在X11下使用Cairo引擎绘制图形

cairo是一个方便和高性能的第三方C库。它可以作为绘制引擎,帮助我们绘制各种图形,并且提供多种输出方式。本文将在Linux下结合X11图形显示协议绘制简单的图形。

这是效果图:

59cb53826fcba1602fd3769171f5f127

安装Cairo库

cairo的官方网站是https://cairographics.org。上面对cairo图形库做了一个完整的介绍,包括如何下载、API接口、示例等等。

通过包管理器安装

通过官方文档知道,在Linux下可以直接通过包管理器进行下载,以Ubuntu为例。

1
sudo apt install libcairo2-dev

下载好以后头文件和动态库就安装好了。头文件安装在/usr/include/cairo/中,静态库和动态库分别位于/usr/lib/x86_64-linux-gnu/libcairo.a/usr/lib/x86_64-linux-gnu/libcairo.so。因此能直接被系统识别,直接引入头文件,编译的时候链接cairo库即可。

通过Conan安装

对于个人而言,apt安装自然是非常友好的。但是对于LarkSDK这样一个面向用户的基础框架而言,除非最基本的系统库例如X窗口系统libx11-devWayland窗口系统libwayland-devWayland键盘处理libxkbcommon-dev等,其他的第三方库均最好不以apt包的方式引入。鉴于C++没有自己的包管理器,因此需要借助第三方的包管理器,例如conancpm等。本项目使用conan管理第三方包。

conan是一个python语言编写的C++的包管理器,官方网址是https://conan.io/conan管理了众多第三方的C++库,通过命令行操作就能方便的在自己的项目中引入conan包。需要通过conanfile.py或者conanfile.txt进行配置,当然这不是本文的重点,具体见文档https://docs.conan.io/2/

conan官方提供了自己的conan仓库,https://conan.io/center。在上面能找到很多我们熟知的第三方库,例如gtestqtboostfmt等。当然还有一些更基础的工具库,这里不赘述。当然cairo库也在其中。

尝试安装Cairo

现在让我们尝试安装cairo库并尝试用CMake将其引入。在新开的项目中创建conanfile.txt,引入项目依赖cairo。使用conanfile.py主要用于生成和发布自己的conan包,conan提供了丰富的选项供用户操作。这里只是为了测试,因此使用最简单的conanfile.txt即可。

1
2
3
4
5
[requires]
cairo/1.18.0

[generators]
cmake

编写自己的CMakeLists.txt,配置项目的相关信息,引入conan的部分类似如下:

1
2
3
4
5
6
7
8
9
10
11
...

include (${PROJECT_BINARY_DIR}/conanbuildinfo.cmake)
conan_basic_setup (NO_OUTPUT_DIRS)

...

add_executable (xxx
...
)
target_link_libraries (xxx ${CONAN_LIBS})

之后执行一般的构建流程即可。

1
2
3
conan install ..
cmake ..
make # windows 下默认没有 make 命令,使用 cmake --build ./ 代替

conan官方的包很多,很全,但是conan本身还有很多bug,单就cairo包的使用过程中就有很多问题。最典型的,执行conan install ..可能会失败,并且遇到很多错误。因此我们需要了解conan包是个什么东西,才能明确问题是如何形成的。

关于Conan包

现在对conan包做一个简述。对于C++库而言,为了让用户方便的使用,把.h.cpp代码全部打包出去是不合适的,这些代码不应该在用户的机器上再被编译一次,而应该在需要用的时候被直接使用。因此需要通过库的方式进行发布,也就是使用静态库和动态库。在发布的包中,最重要的文件就是头文件和库文件。当然可能会携带一些其他必要的文件,例如资源文件、版本说明文件等。

我们都知道,编译C/C++的代码需要依赖于C++和编译器的版本。进一步的,由于C/C++是非常接近底层的代码,虽然标准库是跨平台的,但是如果需要写平台相关的程序,还需要注意操作系统的版本。因此,对于同一个版本的conan包,不同的系统,不同的编译环境,是静态库还是动态库,是Debug包还是Release包,甚至依赖包的版本,都可能对最后的编译造成一定影响。在本地的conan配置中会保存相关的这些信息,在conan拉包的时候会匹配本地的配置拉取合适的包。配置文件默认位于~/.conan/profile/default中。

Linux下的配置类似于这样,看其参数很明显就能知道对应的含义。

1
2
3
4
5
6
7
8
9
10
11
12
[settings]
os=Linux
os_build=Linux
arch=x86_64
arch_build=x86_64
compiler=gcc
compiler.version=9
compiler.libcxx=libstdc++11
build_type=Release
[options]
[build_requires]
[env]

Windows下类似于这样:

1
2
3
4
5
6
7
8
9
10
11
[settings]
os=Windows
os_build=Windows
arch=x86_64
arch_build=x86_64
compiler=Visual Studio
compiler.version=16
build_type=Release
[options]
[build_requires]
[env]

现在我们以cairo/1.18.0@包为例,使用conan命令查看其远端的包的列表。

1
conan search cairo/1.18.0@ -r conancenter

得到的结果大致是这样:

image-20240715163723130

我们发现settings中的内容和我们的profiles/default中的内容对应,这就是前面提到的匹配。例如图中是一个Mac下的apple-clang13.0版本的静态库Debug包。每个包的optionssettingsrequires都会对最前面的Package_ID产生影响,这是一个哈希计算值,具体如何影响和生成请参考https://docs.conan.io/2/reference/binary_model/package_id.html。当然这其中也有令人费解的地方,请见下文。

解决错误(并吐槽)

前面提到,conan官方的包有问题,会导致在安装的时候出现错误。例如,我在安装的出现的错误如下:

image-20240715165238889

错误信息告诉我fontconfig2.15.0的版本需要conan 1.60.4以上才能安装。由于公司使用的是conan 1.60.1,首先我想到的是conan版本不正确。深入研究后,我发现的问题出乎我的意料。

由于公司实际开发的conan版本不可能从1.60.1升到1.60.4,要升肯定一步到位到conan 2.0了。首先查看conancentercairo/1.18.0@远端的包,检索符合当前系统和编译环境的。我发现了三个长的几乎一样的包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
; package 1
Package_ID: 703bcc640002869a53960c4449d3825ff8a103e6
[options]
fPIC: True
shared: False
tee: False
with_fontconfig: True
with_freetype: True
with_glib: True
with_lzo: True
with_png: True
with_symbol_lookup: False
with_xcb: True
with_xlib: True
with_xlib_xrender: True
with_zlib: True
[settings]
arch: x86_64
build_type: Release
compiler: gcc
compiler.version: 9
os: Linux
[requires]
brotli/1.1.0:b21556a366bf52552d3a00ce381b508d0563e081
bzip2/1.0.8:da606cf731e334010b0bf6e85a2a6f891b9f36b0
expat/2.6.0:c215f67ac7fc6a34d9d0fb90b0450016be569d86
fontconfig/2.15.0:b172ac37518ca059ccac0be9c3eb29e5179ecf1e
freetype/2.13.2:f1014dc4f9380132c471ceb778980949abf136d3
glib/2.78.3:06c63123a2bb8b6d3ea5dcae501525df32efb7b5
libelf/0.8.13:6af9cc7cb931c5ad942174fd7838eb655717c709
libffi/3.4.4:6af9cc7cb931c5ad942174fd7838eb655717c709
libmount/2.39:6af9cc7cb931c5ad942174fd7838eb655717c709
libpng/1.6.43:7929d8ecf29c60d74fd3c1f6cb78bbb3cb49c0c7
libselinux/3.5:6b0384e3aaa343ede5d2bd125e37a0198206de42
lzo/2.10:6af9cc7cb931c5ad942174fd7838eb655717c709
pcre2/10.42:647f8233073b10c84d51b1833c74f5a1cb8e8604
pixman/0.43.4:6af9cc7cb931c5ad942174fd7838eb655717c709
util-linux-libuuid/2.39.2:6af9cc7cb931c5ad942174fd7838eb655717c709
xorg/system:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9
zlib/1.3.1:6af9cc7cb931c5ad942174fd7838eb655717c709
Outdated from recipe: True

; package 2
Package_ID: 8098347825649d9fd3e21c49992446a2a2193ad4
[options]
fPIC: True
shared: False
tee: False
with_fontconfig: True
with_freetype: True
with_glib: True
with_lzo: True
with_png: True
with_symbol_lookup: False
with_xcb: True
with_xlib: True
with_xlib_xrender: True
with_zlib: True
[settings]
arch: x86_64
build_type: Release
compiler: gcc
compiler.version: 9
os: Linux
[requires]
brotli/1.1.0:b21556a366bf52552d3a00ce381b508d0563e081
bzip2/1.0.8:da606cf731e334010b0bf6e85a2a6f891b9f36b0
expat/2.5.0:c215f67ac7fc6a34d9d0fb90b0450016be569d86
fontconfig/2.14.2:b172ac37518ca059ccac0be9c3eb29e5179ecf1e
freetype/2.13.0:f1014dc4f9380132c471ceb778980949abf136d3
glib/2.78.0:06c63123a2bb8b6d3ea5dcae501525df32efb7b5
libelf/0.8.13:6af9cc7cb931c5ad942174fd7838eb655717c709
libffi/3.4.4:6af9cc7cb931c5ad942174fd7838eb655717c709
libmount/2.39:6af9cc7cb931c5ad942174fd7838eb655717c709
libpng/1.6.40:7929d8ecf29c60d74fd3c1f6cb78bbb3cb49c0c7
libselinux/3.5:6b0384e3aaa343ede5d2bd125e37a0198206de42
lzo/2.10:6af9cc7cb931c5ad942174fd7838eb655717c709
pcre2/10.42:647f8233073b10c84d51b1833c74f5a1cb8e8604
pixman/0.40.0:6af9cc7cb931c5ad942174fd7838eb655717c709
util-linux-libuuid/2.39:6af9cc7cb931c5ad942174fd7838eb655717c709
xorg/system:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9
zlib/1.3:6af9cc7cb931c5ad942174fd7838eb655717c709
Outdated from recipe: True

; package 3
Package_ID: a336bac291d8ec6a55c6257f3266f9a8760c7403
[options]
fPIC: True
shared: False
tee: False
with_fontconfig: True
with_freetype: True
with_glib: True
with_lzo: True
with_png: True
with_symbol_lookup: False
with_xcb: True
with_xlib: True
with_xlib_xrender: True
with_zlib: True
[settings]
arch: x86_64
build_type: Release
compiler: gcc
compiler.version: 9
os: Linux
[requires]
brotli/1.1.0:b21556a366bf52552d3a00ce381b508d0563e081
bzip2/1.0.8:da606cf731e334010b0bf6e85a2a6f891b9f36b0
expat/2.5.0:c215f67ac7fc6a34d9d0fb90b0450016be569d86
fontconfig/2.14.2:b172ac37518ca059ccac0be9c3eb29e5179ecf1e
freetype/2.13.2:f1014dc4f9380132c471ceb778980949abf136d3
glib/2.78.1:06c63123a2bb8b6d3ea5dcae501525df32efb7b5
libelf/0.8.13:6af9cc7cb931c5ad942174fd7838eb655717c709
libffi/3.4.4:6af9cc7cb931c5ad942174fd7838eb655717c709
libmount/2.39:6af9cc7cb931c5ad942174fd7838eb655717c709
libpng/1.6.40:7929d8ecf29c60d74fd3c1f6cb78bbb3cb49c0c7
libselinux/3.5:6b0384e3aaa343ede5d2bd125e37a0198206de42
lzo/2.10:6af9cc7cb931c5ad942174fd7838eb655717c709
pcre2/10.42:647f8233073b10c84d51b1833c74f5a1cb8e8604
pixman/0.42.2:6af9cc7cb931c5ad942174fd7838eb655717c709
util-linux-libuuid/2.39.2:6af9cc7cb931c5ad942174fd7838eb655717c709
xorg/system:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9
zlib/1.3:6af9cc7cb931c5ad942174fd7838eb655717c709
Outdated from recipe: True

这三个包的唯一区别在于requires不同,第一个包需要expat/2.6.0,第二个和第三个包需要expat/2.5.0。第二个包需要freetype/2.13.0,第三个包freetype/2.13.2。这就是唯一区别,然后生成了三个不同的哈希值,对应不同的package

那么问题来了,conan install默认会读取本地的配置,但是本地配置不可能指定requires啊。不同的项目可能用的版本不同,这是很正常的事情,也不应该是由用户承担责任的地方。其次,conan install不能指定package_id下载,因此下载的是哪一个包,完全就看他怎么想了。非常不幸运的是,我需要第二个包,但是下载只能下载到第一个包。

那么如何解决这个问题呢?幸运的是,conan提供了conan download的方法,这个东西可以指定package_id进行下载。那就简单了,先把cairo本包下载到本地,然后再指定这一套流程,由于本地有缓存,会跳过去远端拉取cairo包的这一步,这样所有本包和所有依赖不就顺利拉取下来了吗?说干就干。

1
conan download cairo/1.18.0@:8098347825649d9fd3e21c49992446a2a2193ad4 -r conancenter

成功下载下来以后,再次执行conan install ..,又出问题了。

image-20240715172400074

不对啊,这个包依赖的fontconfig的版本是2.14.2啊,为什么这里还是下载的2.15.0啊?为了解决这个问题,我打开了对应的conanfile.py,阅读到requirements()函数的时候,豁然开朗。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def requirements(self):
self.requires("pixman/0.43.4")
if self.options.with_zlib and self.options.with_png:
self.requires("expat/[>=2.6.2 <3]")
if self.options.with_lzo:
self.requires("lzo/2.10")
if self.options.with_zlib:
self.requires("zlib/[>=1.2.11 <2]")
if self.options.with_freetype:
self.requires("freetype/2.13.2", transitive_headers=True, transitive_libs=True)
if self.options.with_fontconfig:
self.requires("fontconfig/2.15.0", transitive_headers=True, transitive_libs=True)
if self.options.with_png:
self.requires("libpng/[>=1.6 <2]")
if self.options.with_glib:
self.requires("glib/2.78.3")
if self.settings.os in ["Linux", "FreeBSD"]:
if self.options.with_xlib or self.options.with_xlib_xrender or self.options.with_xcb:
self.requires("xorg/system", transitive_headers=True, transitive_libs=True)
if self.options.get_safe("with_opengl") == "desktop":
self.requires("opengl/system", transitive_headers=True, transitive_libs=True)
if self.settings.os == "Windows":
self.requires("glext/cci.20210420")
self.requires("wglext/cci.20200813")
self.requires("khrplatform/cci.20200529")
if self.options.get_safe("with_opengl") and self.settings.os in ["Linux", "FreeBSD"]:
self.requires("egl/system", transitive_headers=True, transitive_libs=True)

不是哥们,expat需要的是2.5.0,但是你规定依赖的包是>=2.6.2 and <3。哈?这不前后矛盾吗?对于freetypefontconfig也是相同的问题。同时,我们再浏览一下cairo包拉下来以后的整体结构。奥,原来同一个版本的所有包的conanfile.py都是同一个文件。好,这没有任何问题,这是conan的设计。但是你自己不更新依赖版本的限制是什么意思,如果硬要不改的话,发布不同的版本也是ok的啊。这样一套流程下来,导致conan拉包就出现了问题。

image-20240715172916780

那么如何解决呢?公司这边的解决方式是将该版本的cairo包上传到公司的conan服务器上,并手动修改conanfile.py使其版本匹配,并将所有的依赖以及穿透依赖全部拷贝到公司服务器上。至此,cairo终于成功通过conan安装。

Windows那边也是同样的问题,同样操作即可。

补充

Linux下的Cairo包中,有一个依赖项值得看一下,即xorg/system。这个包的版本是system,意思是和系统相关的包,这里是LinuxX图形协议相关的依赖。众所周知,系统包是通过apt或者yum进行安装的,意思是conan能够调用这些包命令来帮我们自动安装对应的包吗?

答案是可以的,参见文档https://docs.conan.io/2/reference/tools/system/package_manager.html。留意这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def system_requirements(self):
apt = package_manager.Apt(self)
apt.install(["libx11-dev", "libx11-xcb-dev", "libfontenc-dev", "libice-dev", "libsm-dev", "libxau-dev", "libxaw7-dev",
"libxcomposite-dev", "libxcursor-dev", "libxdamage-dev", "libxdmcp-dev", "libxext-dev", "libxfixes-dev",
"libxi-dev", "libxinerama-dev", "libxkbfile-dev", "libxmu-dev", "libxmuu-dev",
"libxpm-dev", "libxrandr-dev", "libxrender-dev", "libxres-dev", "libxss-dev", "libxt-dev", "libxtst-dev",
"libxv-dev", "libxxf86vm-dev", "libxcb-glx0-dev", "libxcb-render0-dev",
"libxcb-render-util0-dev", "libxcb-xkb-dev", "libxcb-icccm4-dev", "libxcb-image0-dev",
"libxcb-keysyms1-dev", "libxcb-randr0-dev", "libxcb-shape0-dev", "libxcb-sync-dev", "libxcb-xfixes0-dev",
"libxcb-xinerama0-dev", "libxcb-dri3-dev", "uuid-dev", "libxcb-cursor-dev", "libxcb-dri2-0-dev",
"libxcb-dri3-dev", "libxcb-present-dev", "libxcb-composite0-dev", "libxcb-ewmh-dev",
"libxcb-res0-dev"], update=True, check=True)
apt.install_substitutes(
["libxcb-util-dev"], ["libxcb-util0-dev"], update=True, check=True)

yum = package_manager.Yum(self)
yum.install(["libxcb-devel", "libfontenc-devel", "libXaw-devel", "libXcomposite-devel",
"libXcursor-devel", "libXdmcp-devel", "libXtst-devel", "libXinerama-devel",
"libxkbfile-devel", "libXrandr-devel", "libXres-devel", "libXScrnSaver-devel",
"xcb-util-wm-devel", "xcb-util-image-devel", "xcb-util-keysyms-devel",
"xcb-util-renderutil-devel", "libXdamage-devel", "libXxf86vm-devel", "libXv-devel",
"xcb-util-devel", "libuuid-devel", "xcb-util-cursor-devel"], update=True, check=True)

dnf = package_manager.Dnf(self)
dnf.install(["libxcb-devel", "libfontenc-devel", "libXaw-devel", "libXcomposite-devel",
"libXcursor-devel", "libXdmcp-devel", "libXtst-devel", "libXinerama-devel",
"libxkbfile-devel", "libXrandr-devel", "libXres-devel", "libXScrnSaver-devel",
"xcb-util-wm-devel", "xcb-util-image-devel", "xcb-util-keysyms-devel",
"xcb-util-renderutil-devel", "libXdamage-devel", "libXxf86vm-devel", "libXv-devel",
"xcb-util-devel", "libuuid-devel", "xcb-util-cursor-devel"], update=True, check=True)

zypper = package_manager.Zypper(self)
zypper.install(["libxcb-devel", "libfontenc-devel", "libXaw-devel", "libXcomposite-devel",
"libXcursor-devel", "libXdmcp-devel", "libXtst-devel", "libXinerama-devel",
"libxkbfile-devel", "libXrandr-devel", "libXres-devel", "libXss-devel",
"xcb-util-wm-devel", "xcb-util-image-devel", "xcb-util-keysyms-devel",
"xcb-util-renderutil-devel", "libXdamage-devel", "libXxf86vm-devel", "libXv-devel",
"xcb-util-devel", "libuuid-devel", "xcb-util-cursor-devel"], update=True, check=True)

pacman = package_manager.PacMan(self)
pacman.install(["libxcb", "libfontenc", "libice", "libsm", "libxaw", "libxcomposite", "libxcursor",
"libxdamage", "libxdmcp", "libxtst", "libxinerama", "libxkbfile", "libxrandr", "libxres",
"libxss", "xcb-util-wm", "xcb-util-image", "xcb-util-keysyms", "xcb-util-renderutil",
"libxxf86vm", "libxv", "xcb-util", "util-linux-libs", "xcb-util-cursor"], update=True, check=True)

package_manager.Pkg(self).install(["libX11", "libfontenc", "libice", "libsm", "libxaw", "libxcomposite", "libxcursor",
"libxdamage", "libxdmcp", "libxtst", "libxinerama", "libxkbfile", "libxrandr", "libxres",
"libXScrnSaver", "xcb-util-wm", "xcb-util-image", "xcb-util-keysyms", "xcb-util-renderutil",
"libxxf86vm", "libxv", "xkeyboard-config", "xcb-util", "xcb-util-cursor"], update=True, check=True)

对于Ubuntu来讲,conan能手动帮我们调用apt安装所需要的系统依赖。需要注意一点,需要在配置文件中加上两句,来指明开启这个功能和使用sudo。所以最终的配置文件类似如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[settings]
os=Linux
os_build=Linux
arch=x86_64
arch_build=x86_64
compiler=gcc
compiler.version=9
compiler.libcxx=libstdc++11
build_type=Release
[options]
[build_requires]
[env]
[conf]
tools.system.package_manager:mode=install
tools.system.package_manager:sudo=True

至此,我们成功通过conan安装下来了cairo,看到成功的结果,我的内心无比兴奋。

image-20240715175839407

使用Cairo库绘制图形

安装完cairo库,是时候使用它绘制一些简单的图形了。

Linux GUI背景简述

Linux本身是不带有图形界面的,真正原生的Linux系统只是一个基于命令行的操作系统。但是我们目前所使用的Linux发行版,例如UbuntuCentos等,都是有图形界面的啊。这是因为这些图形界面是Linux下的一个应用程序而已,是通过程序和协议模拟和实现出来的,或者说图形界面并不属于Linux内核的一部分。这一点和Windows系统完全不一样,Windows的命令行在我看来完全不如Linux这么好用,甚至有时候我会爆粗口喷他,但是Windows的用户的依然最多。为什么呢?就是因为GUI好看。Windows的图形界面是操作系统的一部分,并且做的确实好看和丝滑。这样变相的降低了用户的学习和使用成本,对大多数的人而言是件好事。但是对于程序员特别是偏向底层的程序员来讲,却真不一定。

Linux下,需要通过应用程序实现图形界面,那就需要设计一个合适的协议。目前市面上比较流行的有两种协议,X协议和Wayland协议。这两种协议都是基于网络通信的思想,将图形显示分为了客户端(即你的应用程序)和服务端通信的过程。输入设备和显示设备不是同一个设备,而且他们需要相互配合,进行画面显示,所以需要一个交互协议,建立他们直接的沟通桥梁。当然X协议和Wayland协议的细节有所区别,粗略的讲是servercompositor的设计不同,具体可见https://www.secjuice.com/wayland-vs-xorg/

本文以及后续以X协议为例展开,并以XClientXServer分别代指客户端和服务端。例如现在我需要画一个圆,XClient需要告诉XServer在屏幕的什么地方,使用什么颜色,画多大的一个圆。至于这个圆如何生成,如何使用硬件真正绘制图形等等这些操作,都是由XServer完成的。当然更进一步的,XServer还可以捕捉鼠标和键盘的动作,会触发相应的事件。XClient可以接受相应的事件并且完成相应的逻辑。这就是整个X协议以及绘制逻辑的简要概括。

X协议有很多实现。目前用的最多的是XOrg,对应的XClientXlibXCB的两种实现,提供了和XServer对接的API。(At the bottom level of the X client library stack are Xlib and XCB, two helper libraries (really sets of libraries) that provide API for talking to the X server.)本文的背景是X11,也是Xlib库的一个特殊版本。

上面只是非常粗略的说明了一下基本思路,更多文档请参考:

  1. X.Org
  2. Wayland
  3. The X New Developer’s Guide: Xlib and XCB

安装X11并编写GUI程序

强烈建议在虚拟机下进行,因为Wsl需要内核更新到2.2.4以后才能使用最新功能的GUI,而且体验还不是很好。

Ubuntu为例,系统默认是未安装X11库的(XServer肯定是有的,不然你怎么看得到图形界面呢?安装X11只是提供了窗口系统的开发支持)。因此我们使用如下命令手动安装。值得一提的是,由于X11是系统库,因此使用apt包安装即可。同理对于Wayland也是一样。

1
sudo apt install libx11-dev

安装完成以后,使用CMake就能很方便的引入X11支持了。

1
2
3
4
add_executable (xxx
...
)
target_link_libraries (xxx X11)

现在我们编写一个样例程序,用于在X11下绘制一个图形窗口。这里面涉及到很多的X11API,本文只做简单介绍,具体请参考文档https://www.x.org/releases/X11R7.7/doc/libX11/libX11/libX11.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <X11/Xlib.h>

#include <iostream>


int main()
{
// 用于和 XServer 建立连接,dpy 指针全局只有一份
Display *dpy = XOpenDisplay(nullptr);
if (!dpy)
{
std::cerr << "Unable to open X display!" << std::endl;
throw;
}

int screen = DefaultScreen(dpy);
// 创建一个顶层窗口
Window w = XCreateSimpleWindow(
dpy,
RootWindow(dpy, DefaultScreen(dpy)),
100, 100, 400, 300, 0,
BlackPixel(dpy, screen),
BlackPixel(dpy, screen)
);
// 将需要检测的事件绑定在窗口上
XSelectInput(dpy, w, ExposureMask);
// 展示这个窗口
XMapWindow(dpy, w);

std::cout << "Entering loop ..." << std::endl;

// 进入事件循环
XEvent e;
while (1)
{
XNextEvent(dpy, &e);
switch (e.type)
{
case Expose:
std::cout << "event: Expose" << std::endl;
break;
default:
std::cout << "event: " << e.type << std::endl;
break;
}
}

return 0;
}

接口的大致功能以在代码注释中体现。注意到整个程序最重要的架构是最后的这个事件循环,当XClient窗口成功创建出来以后,XServer需要不断监听XClient发送的事件并予以处理,这样才能实现GUI程序的功能。因此事件循环的存在就自然而然了。有点类似于IO多路复用中epoll技术的框架。在这里监听的是Expose事件,不用具体关心这个语义是什么意思,在窗口创建和窗口大小发生改变的时候会触发Expose事件,这里也是用作信息打印测试。

程序的运行结果如下,可以发现窗口创建和改变窗口大小的时候在不断打印Expose的信息。

image-20240716111623175

对于事件循环这个概念,不管是Qt,还是LarkSDK,还是对于一个跨平台的GUI框架,事件循环显然是必不可少的。问题在于跨平台需要统一不同平台下的窗体系统和事件处理等逻辑,最终抽象出跨平台的接口,这就是这些框架正在做最重要的一件事情。以LarkSDK为例,虽然最简单的跨平台的程序是四行就能搞定,但是其中涉及到的知识和背景是非常庞大的。

1
2
3
4
5
6
7
8
9
10
#include <lwindowapplication.h>
#include <lwindow.h>

int main()
{
LWindowApplication app; // 创建窗体程序主框架实例
LWindow w; // 创建顶层窗体
w.show(); // 让窗体可见
return app.exec(); // 进入主事件循环
}

尝试引入Cairo

cairo将输出和绘制的概念做了严格区分。cairo surface是一个抽象出来的概念,与其对接的是多种输出方式,例如PDFPNGSVGWin32XLibXCB等,如图所示。

image-20240716141915019

接着我们查看cairo官方提供的samples,可以发现,官方提供的样例好像完全和surface没有关系,换句话说没有反映输出的方式。例如这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
double xc = 128.0;
double yc = 128.0;
double radius = 100.0;
double angle1 = 45.0 * (M_PI/180.0); /* angles are specified */
double angle2 = 180.0 * (M_PI/180.0); /* in radians */

cairo_set_line_width (cr, 10.0);
cairo_arc (cr, xc, yc, radius, angle1, angle2);
cairo_stroke (cr);

/* draw helping lines */
cairo_set_source_rgba (cr, 1, 0.2, 0.2, 0.6);
cairo_set_line_width (cr, 6.0);

cairo_arc (cr, xc, yc, 10.0, 0, 2*M_PI);
cairo_fill (cr);

cairo_arc (cr, xc, yc, radius, angle1, angle1);
cairo_line_to (cr, xc, yc);
cairo_arc (cr, xc, yc, radius, angle2, angle2);
cairo_line_to (cr, xc, yc);
cairo_stroke (cr);

它的绘制结果是这样的:

image-20240716142350311

这个图样可以被输出到前面提到的任意一种surface中。同时这也是我想说的,cairo将输出和绘制的概念做了完整区分,同样这也是我们容易想到和愿意看到的。所以,如果想要创建一个输出到PNG中的实例,代码应该类似如下:

1
2
3
4
5
6
7
8
9
10
// 创建 surface
cairo_surface_t *surface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 640, 480);
// 创建绘图上下文 context
cairo_t *cr = cairo_create(surface);

// 绘制逻辑,只和 context 相关,与 surface 无关
...

// 输出到 png 格式中
cairo_surface_write_to_png(surface, "xxx.png");

至此,我们用surfacecontext来代指输出和绘制的概念,也明白了cario是如何区分这两个概念的了。surface用作指明绘制的图形输出到哪里,context则用于进行绘制。读者可以尝试编写一个完整的程序输出上面的图案到一张图片文件中。

Cairo和X11相结合

使用cairo成功输出到图片文件中以后,试着想想如何与X11窗口系统结合了。提前声明,用到的surface只有XLib SurfaceImage Surface,其他的API请自行查询文档

XLib Surface

首先想到的肯定是XLib Surface。这代表cairo帮我做好了与X11平台的对接工作,我只需要按部就班地使用cairoAPI即可。所有的surface基本上只有在创建的时候会有区别,在context那一层的绘制几乎没有区别。例如下面就是XLib Surface的创建API,参数具体含义请参考官方文档

1
2
3
4
5
6
cairo_surface_t *
cairo_xlib_surface_create (Display *dpy,
Drawable drawable,
Visual *visual,
int width,
int height);

查询该方法需要的接口如何获取以后,编写出如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <iostream>
#include <exception>

#include <X11/Xlib.h>

#include "cairo.h"
#include "cairo/cairo-xlib.h"


int main()
{
Display *dpy = XOpenDisplay(nullptr);
if (!dpy)
{
throw std::runtime_error("Failed to open X display");
}

int screen = DefaultScreen(dpy);
Window w = XCreateSimpleWindow(
dpy,
RootWindow(dpy, DefaultScreen(dpy)),
100, 100, 640, 480, 0,
BlackPixel(dpy, screen),
BlackPixel(dpy, screen));
XSelectInput(dpy, w, ExposureMask);

XMapWindow(dpy, w);

std::cout << "Entering loop ..." << std::endl;

// 根据 window id 获取该窗口的信息
XWindowAttributes attr;
XGetWindowAttributes(dpy, w, &attr);

// 创建 XLib Surface
cairo_surface_t *surface = cairo_xlib_surface_create(dpy, w, attr.visual, attr.width, attr.height);
cairo_t *cr = cairo_create(surface);

XEvent e;
while (true)
{
XNextEvent(dpy, &e);

switch (e.type)
{
case Expose:
{
std::cout << "event: Expose" << std::endl;

// 绘制操作
cairo_set_source_rgb(cr, 1.0, 1.0, 0.5);
cairo_paint(cr);

cairo_set_source_rgb(cr, 1.0, 0.0, 1.0);
cairo_move_to(cr, 100, 100);
cairo_line_to(cr, 200, 200);
cairo_stroke(cr);

break;
}
default:
std::cout << "event: " << e.type << std::endl;
break;
}
}

cairo_destroy(cr);
cairo_surface_destroy(surface);

XDestroyWindow(dpy, w);
XCloseDisplay(dpy);


return 0;
}

这样能在Expose事件触发的时候成功绘制出文章开始时候展示的图样。

Image Surface

XLib Surface的代码结构看着很像自动挡的感觉,创建surface,在事件循环中用context进行绘制,最终得到想要的图案。

我们深入思考一下,图形是如何被绘制到屏幕上的呢?前面举了个例子,画一个圆,告诉XServer在哪里,用什么颜色,画多大、多宽的圆。至于如何用硬件画不是XClient关心的事情,但是如何表示这些信息呢?显然需要用合适的data进行存储。进一步讲,context调用各种方法的实际过程,其实就是往数据缓冲区data中写数据的过程。当类似flush操作的被调用以后,这些数据才会真正反映在屏幕上,形成我们观看的效果。

在这样的语义下,我们进一步思考surface的概念,其实用可绘制表面的概念好像更加贴切(本概念借鉴于LarkSDKLSurface)。绘图的数据缓冲区记录了图形的数据,类型是unsigned char *,这些数据和不同的输出方式对接就能达到不同的输出效果。至于为什么是unsigned char *(我猜测大概是字节流)以及如何对接,这不是本文的重点,有兴趣请自己查询资料。

知道这个过程以后,回到Image Surface本身,为什么要使用这个东西,是因为它为我们提供了获取数据的接口。也就是当我用context绘制以后,调用这个方法就能立刻拿到缓冲区的数据。

1
2
unsigned char *
cairo_image_surface_get_data (cairo_surface_t *surface);

然后让我们思考一下绘制效率。不同引擎的效率的区别根本上就是在于如何快速的把这些数据计算出来,或者换句话讲,如何快速地让缓冲区的内存填充为指定的数据。比如对于最基本的暴力软渲染和cairo引擎,他们的效率差距显然是非常大的。这里有一个例子可以参考,是LarkSDK原生软渲染和cairo引擎同样绘制10000条斜线的效率差距,以下是结果,保守估计至少差了几百到一千倍。

image-20240716155335456

回到正题,再结合X11API,我们可以给出使用Image Surface的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
#include <iostream>
#include <exception>

#include <X11/Xlib.h>

#include "cairo.h"


int main()
{
Display *dpy = XOpenDisplay(nullptr);
if (!dpy)
{
throw std::runtime_error("Failed to open X display");
}

int screen = DefaultScreen(dpy);
Window w = XCreateSimpleWindow(
dpy,
RootWindow(dpy, DefaultScreen(dpy)),
100, 100, 640, 480, 0,
BlackPixel(dpy, screen),
BlackPixel(dpy, screen));
XSelectInput(dpy, w, ExposureMask);

unsigned long mask = 0;
XGCValues values;
GC gc = XCreateGC(dpy, w, mask, &values);

XMapWindow(dpy, w);

// 创建 Image Surface
cairo_surface_t *surface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 640, 480);
cairo_t *cr = cairo_create(surface);

// 获得 Cairo 管理的绘图数据的指针
unsigned char *pData = cairo_image_surface_get_data(surface);
// 创建 X11 下的 Image Buffer ,将其中的数据替换为 Cairo 的数据指针
XImage *pBackBuffer = XCreateImage(
dpy,
DefaultVisual(dpy, screen),
DefaultDepth(dpy, screen),
ZPixmap,
0,
(char *)pData,
640, 480,
8,
0);

std::cout << "Entering loop ..." << std::endl;

XEvent e;
while (true)
{
XNextEvent(dpy, &e);

switch (e.type)
{
case Expose:
{
std::cout << "event: Expose" << std::endl;

cairo_set_source_rgb(cr, 1.0, 1.0, 0.5);
cairo_paint(cr);

cairo_set_source_rgb(cr, 1.0, 0.0, 1.0);
cairo_move_to(cr, 100, 100);
cairo_line_to(cr, 200, 200);
cairo_stroke(cr);

// flush 操作,刷新缓冲区,更新数据
cairo_surface_flush(surface);

// X11 下真正绘制图形的方法,用到了外面定义的 X11 Image Buffer,而其内部的数据就是 Cairo 管理的缓冲区数据
XPutImage(
dpy,
w,
gc,
pBackBuffer,
0, 0,
0, 0,
640, 480);

break;
}
default:
std::cout << "event: " << e.type << std::endl;
break;
}
}


cairo_destroy(cr);
cairo_surface_destroy(surface);

XDestroyWindow(dpy, w);
XCloseDisplay(dpy);


return 0;
}

至此,我们完成了在X11下使用Cairo引擎绘制图形的全部过程。Windows的程序架构和事件循环有所区别,但思路是相同的。当然,这仅仅是阐述了基本过程,还有更多的细节值得研究和探讨。

使用搜索:必应百度