为Rust原生gui编程搭建win32开发环境
演艺圈有“演而优则导”的人才跨界成长模式。技术圈何尝没有类似的人才养成计划呢。在熟练掌握了面向WEB的Rust + WASM开发之后,趁势向相关技术领域伸展技能树便是很自然的进阶诉求。而我的选择方向就是:桌面原生GUI应用程序开发。谈及 GUI应用,第一件需要操心的事就是GUI Framework的选型(即,选择合适的【渲染引擎】与【组件库】)。
技术选型
我曾经比较仔细地考查过如下三个技术方向:
- Rust GUI Framework
-
其最大的好处就是不用费脑筋于:
- 【动态链接】- 因为所有的执行程序都会被打包入一个
.exe文件里,根本不需要运行时链接到其它库。 - 【(交叉)编译】- 因为有极致精简的
cargo + rustc toolchain。相比之下,make,cmake,gcc,msvc,vcpkg,minGW,msys,cygwin(还有...)也太太太复杂了吧?
- 【动态链接】- 因为所有的执行程序都会被打包入一个
-
其最大的不足就是:虽然开源的
Framework产品很多,但都“太年轻”,并且- 可用控件十分贫瘠,
- 也没有刻意挖掘
GPU性能 --- 我分析多半还是顾不上。看他们roadmap更多提及的还是:“再做这个控件,和计划实现那个控件,之类的事”。
一句话概括之,They are too young, too simple, too naive.
-
因为我还是想做点冲门面的东西的,所以调研结果:不推荐。
OS自带的GUI Framework- 这条技术路线的特点就是:借助某个
Glue-like Crate将Rust程序直接桥接至操作系统渲染引擎与组件库。比如,NWG就属于这一类。 - 其最大的好处就是完全不用为【交叉编译】费神了,因为根本就不存在【可移植性
portable】。不要说不同类型的操作系统之间,就算是同一款操作系统的不同版本之间,其绑定接口与调用套路都高概率地不一样。【可移植】还是省省吧。 - 因为支持多平台还是我想要的,所以调研结果:也不推荐。
- 这条技术路线的特点就是:借助某个
- 独立
Cpp GUI Framework- 这条技术路线算是前两种技术方案的折中了。
- 其不足之处
- 因为市面上免费的
Cpp GUI Framework多原生于Linux系平台,所以Win32开发环境真心地不好搭。好心人都去搞 Linux 去了,留在 Windows 平台上的都是“唯利是图”营商精英。 - 除了 Rust 自身的【交叉编译】之外,咱们还得额外地准备一份针对
Cpp GUI Framework的【交叉编译】解决方案。当然,若能找到针对目标平台的【动态链接库】预编译包,那就太省心。不是没有,可以碰碰运气。
- 因为市面上免费的
- 虽然困难重重,但调研结果:推荐。再想到
Flutter也是将Dart绑定Skia。这套路和我的选择很像呀!俺就充满了信心。
不确定你是否已经注意到了我在上文的一个措词【可移植性
portable】而没有使用另一个技术“热词”【跨平台cross-platform】?这是因为没有VM何来跨平台,而咱们恰恰没有VM。这就正如咱们果决地抛弃了GC一样。
Cpp GUI Framework的选择
在口碑榜上名列前茅的Cpp GUI Framework还是有不少的。我考查过的有GTK3,FLTK,和QT。我选中的是GTK3 GUI Framework和gtk-rs的组合。
GTK的光辉历史不用我再多讲了吧?虽然眼下QT的崛起很强势(据说新版的Ubuntu桌面系统要选用QT打造),但由GTK实现的GNOME已经占据了Linux桌面系统的大半江山。
开发环境搭建
上面讲了这么多铺垫的段子,现在开始聊正文。给原生于Linux的GTK GUI Framework搭建一套基于Windows 10开发环境是一件既烧脑又充满成就感的事。
-
首先,下载与安装msys2。
MSYS2是一款服务于Cpp的【软件构建与分发平台】(形似Maven2)。它的功能非常全面 (粗讲都足够独立开一期分享了)。咱们这里只使用它的两个功能:- 编译出针对当前
Windows系统的GTK动态链接库。若匹配你操作系统的GTK版本在云端仓库有预编译包,它也会直接下载使用(聪明!)。 - 当作
cmd或PowerShell来用,因为- 它在
Windows系统上模拟出了一个伪Linux环境。你之前背过的大部分Linux指令在MSYS2终端都可以大展拳脚了。 - 它更容易集成
cmake,make,gcc工具链,方便一站式地解决rustc + gcc的编译问题。
- 它在
- 编译出针对当前
-
然后,双击安装目录下的
mingw64.exe,打开终端。如果你的机器是32位的话,那就需要双击mingw32.exe文件。 -
接着,在命令行内,依次执行下面罗列的指令
# 同步【本地】与【云端】的仓库数据库 pacman -Syu # 安装工具链,gcc/make/cmake 等工具一次性安装完成 pacman -S base-devel mingw-w64-x86_64-toolchain --needed # 安装 GTK。一般来说,都会有预编译包可用。若你中奖了,那就从源码来编译吧。也耽误不了多少时间。 pacman -S mingw-w64-x86_64-gtk3 mingw-w64-x86_64-glade --needed -
接下来,向环境变量
PATH内添加rust toolchain路径。之前不是提及过MSYS2模拟出了一个伪Linux环境了吗!若你还没忘的话,此时此刻,千万别犹豫,用上vi + ~/.bashrc呀!vim ~/.bashrc # cargo 目录 export PATH=/c/Users/<账号名>/.cargo/bin:$PATH # 安装后的 GTK 动态链接库,就在这个目录。 export PATH=/mingw64/bin:$PATH # 这个也加上吧,rust 程序崩溃时,至少有个完整的调用栈日志可看。 export RUST_BACKTRACE=full由此,馁馁的满足感袭来一波。
-
【可选步骤】将
MSYS2命令行终端与VSCode集成终端关联起来。我是懒人一枚且非常不情愿在多个窗口之间Ctrl + TAB地来回切换。-
给
Windows 10操作系统,添加一个新环境变量CHERE_INVOKING,其值为1 -
给
VSCode安装一个新的扩展插件Shell launcher -
打开
VSCode的【用户-设置】界面,并直接跳转至settings.json文件 -
添加/更新配置项
"shellLauncher.shells.windows": [{ "shell": "<MSYS2 安装目录>\\usr\\bin\\bash.exe", "args": ["--login", "-i"], "label": "msys2" }], -
敲击键盘
F1键,输入命令Shell Launcher: Launch回车。 -
MSYS2命令行终端就会在VSCode集成终端内被打开了,并已经贴心地定位到当前工程的根目录。
又一波满足感馁馁地袭来!
-
-
再然后,执行
cargo new --bin app-demo1命令。创建一个Binary App类型的空工程。-
在
Cargo.toml文件内,添加GTK依赖[dependencies] gtk = {version = "0.9.0", features = ["v3_16"]} gio = {version = "0", features = ["v2_44"]} -
在
src/main.rs文件内,添加少量的Hello World代码。// 强烈推荐在 path 前冠以 :: 符, // 其表示这个包来自于第三方,而不是本地模块。 use ::gtk::prelude::*; use ::gio::prelude::*; use ::gtk::{Application, ApplicationWindow, Button}; fn main() { let application = Application::new( Some("my.demo1"), Default::default(), ).expect("GTK 绑定失败"); application.connect_activate(|app| { let window = ApplicationWindow::new(app); window.set_title("Hello World 窗体标题"); window.set_default_size(350, 70); let button = Button::with_label("点击【Hello World】!"); button.connect_clicked(|_| println!("点了!")); window.add(&button); window.show_all(); }); application.run(&[]); } -
执行
cargo check,检查是否存在- 语法错误
- 反【借入规则】与【所有权规则】的代码
-
执行
cargo build,检查rust是否能够与GTK绑定得上。- 这一波操作对咱们业务开发者是透明的。它是由
gtk-rs胶水层crate私下来做的。
- 这一波操作对咱们业务开发者是透明的。它是由
-
执行
cargo run运行被编译生成的.exe文件。-
检查
GTK DLL文件在运行时是否都能被正确地链接上。 -
不出意外的话,此时你会遇到程序崩溃和一条莫名其妙的报错消息
exit code: 0xc0000139, STATUS_ENTRYPOINT_NOT_FOUND这条错误消息是告诉你:“
DLL链接失败,很可能是不正确版本的DLL文件被关联”。
-
-
不要慌,此坑我趟过。这个报错的原因不神秘。简单地讲,就是
System32系统DLL与你的应用程序所依赖的某个本地DLL有命名冲突。- 同时,你的应用程序优先加载与链接了版本不匹配的
System32系统DLL。
这不是你程序的错,而是Windows操作系统从XP SP2版本以来,就刻意这么设计的。这款设计学名叫Dynamic-Link Library Search Order。和Unix系OS不同,微软为了应付来自黑客精英们的重点关照不得不采纳了更为严格的【DLL搜索规则】来遏制日益猖獗的【DLL劫持攻击】。哎,大家出来混,其实谁都不容易呀! 所以,PATH环境变量对DLL搜索的加权贡献是最低的。于是,一款应用程序搜索其依赖DLL位置(由高往低)依次为:
.exe文件的同级目录system32系统目录- 启动指令的执行目录
- 最后才是
PATH环境变量罗列的目录或文件清单。
关于从浩瀚的DLL海洋里捞出那个与系统DLL重名的mingw64/bin/zlib1.dll文件,这里跳过1000个字。我仅只感慨大学没白上。 既然知晓了重名DLL文件,那我解决问题的思路就相当简单粗暴了。即,在.exe文件的同级目录(也就是target/debug或target/release)内,创建一个指向正确DLL文件的符号链接。于是,版本匹配的DLL文件便会优先于System32系统DLL被加载与关联。
这当然不是我彪乎乎地徒手执行mklink /D指令,来创建符号链接。请别把我想得那么蠢萌蠢萌的。 像这样简单且重复性高的劳动太应该分配给rust toolchain去完成了。简单地讲,就是利用cargo hook(类似于,git hook与 npm hook),让cargo在每次开始编译前执行一段脚本代码给mingw64/bin/zlib1.dll文件创建符号链接文件。
-
第一步,在工程根目录,创建
build.rs文件,并添加如下代码use ::std::{env, fs, os, path::Path, process}; fn main() { let out_dir = env::var("OUT_DIR") .expect("失败:环境变量`OUT_DIR`未提供"); println!("调试:OUT_DIR={}", out_dir); let exe_dir = Path::new(&out_dir[..]).join("../../..").canonicalize() .expect(&format!("失败:不能从 {} 推断出 exe 目录", out_dir)[..]); symbolic_link_zlib1(&exe_dir); } #[cfg(windows)] fn symbolic_link_zlib1(exe_dir: &PathBuf) { let msys2_home = match env::var("MSYS2_HOME") { Ok(value) => value, Err(_) => { println!("cargo:warning=环境变量`MSYS2_HOME`没有提供,没有链接操作会被执行"); return; } }; println!("调试:MSYS2_HOME={}", msys2_home); println!("调试:EXE_DIR={}", exe_dir.display()); if !exe_dir.is_dir() { println!("cargo:warning={} 不是一个目录", exe_dir.display()); process::exit(1); } let zlib1_symbol = exe_dir.join("zlib1.dll"); println!("调试:ZLIB1_EXE={}", zlib1_symbol.display()); if zlib1_symbol.exists() { fs::remove_file(zlib1_symbol.clone()) .expect(&format!("失败:不能删除原来的 {} 符号链接文件", zlib1_symbol.display())[..]); } let bits = if cfg!(target_pointer_width = "32") { 32usize } else { 64usize }; let bin_dir = Path::new(&msys2_home[..]).join(&format!("mingw{}", bits)[..]).join("bin"); let bin_dir = bin_dir.canonicalize() .expect(&format!("失败:不能从 {} 推断出 mingw**/bin 目录", bin_dir.display())[..]); println!("调试:BIN_DIR={}", bin_dir.display()); if !bin_dir.is_dir() { println!("cargo:warning={} 不是一个目录", bin_dir.display()); process::exit(1); } let zlib1_origin = bin_dir.join("zlib1.dll"); println!("调试:ZLIB1_FILE={}", zlib1_origin.display()); if !zlib1_origin.is_file() { println!("cargo:warning={} 不是一个文件", zlib1_origin.display()); process::exit(1); } os::windows::fs::symlink_file(zlib1_origin.clone(), zlib1_symbol.clone()) .expect(&format!("失败:不能创建文件链接 {} 指向 {}", zlib1_symbol.display(), zlib1_origin.display())[..]); println!("成功:能创建文件链接 {} 指向 {}", zlib1_symbol.display(), zlib1_origin.display()); } #[cfg(not(windows))] fn symbolic_link_zlib1(_: &PathBuf) {}- 借助【条件编译】元属性,这段代码将仅在
Windows下才会被编译与执行 - 借助【条件编译】宏,这段代码能够自动识别当前
OS的最大指针宽度,和指向正确的mingw**子目录。
- 借助【条件编译】元属性,这段代码将仅在
-
第二步,在
~/.bashrc里,添加一个新环境变量MSYS2_HOME。其值就是MSYS2的安装目录。 -
第三步,重新执行
cargo run。于是,GTK的Hello World界面就会出现了。完美!!!
至此,我这次要和大家分享的内容就结束了。哎!这我就足足准备了一周。至于【交叉编译】的坑,咱们下次再填吧(实在太深)。今天,我先立一个Flag。
突然,想感慨:“谁人心里没有‘星辰大海’?或许咱们前端人的‘诗和远方’就是走出【浏览器】,迈向【原生桌面】,再涉足【IoT嵌入式开发】吧。最终,做到技术链上游。”。我依旧是那位“水平不高,能力有限,但初心犹在”的前行者。
评论区
写评论顶一个。
这是要逆天啊
好评一个~
666
非常赞!!!