< 返回我的博客

爱国的张浩予 发表于 2021-04-03 01:09

Tags:win32,gtk,gui

Rust原生gui编程搭建win32开发环境

演艺圈有“演而优则导”的人才跨界成长模式。技术圈何尝没有类似的人才养成计划呢。在熟练掌握了面向WEBRust + WASM开发之后,趁势向相关技术领域伸展技能树便是很自然的进阶诉求。而我的选择方向就是:桌面原生GUI应用程序开发。谈及 GUI应用,第一件需要操心的事就是GUI Framework的选型(即,选择合适的【渲染引擎】与【组件库】)。

技术选型

我曾经比较仔细地考查过如下三个技术方向:

  1. Rust GUI Framework
    1. 这是一款纯 Rust 的技术路线。比如,Conrod, druid, Iced都属于这一类。

    2. 其最大的好处就是不用费脑筋于:

      1. 【动态链接】- 因为所有的执行程序都会被打包入一个.exe文件里,根本不需要运行时链接到其它库。
      2. 【(交叉)编译】- 因为有极致精简的cargo + rustc toolchain。相比之下,make, cmake, gcc, msvc, vcpkg, minGW, msys, cygwin(还有...)也太太太复杂了吧?
    3. 其最大的不足就是:虽然开源的Framework产品很多,但都“太年轻”,并且

      1. 可用控件十分贫瘠,
      2. 也没有刻意挖掘GPU性能 --- 我分析多半还是顾不上。看他们roadmap更多提及的还是:“再做这个控件,和计划实现那个控件,之类的事”。

      一句话概括之,They are too young, too simple, too naive.

    4. 因为我还是想做点冲门面的东西的,所以调研结果:不推荐

  2. OS自带的GUI Framework
    1. 这条技术路线的特点就是:借助某个Glue-like CrateRust程序直接桥接至操作系统渲染引擎与组件库。比如,NWG就属于这一类。
    2. 其最大的好处就是完全不用为【交叉编译】费神了,因为根本就不存在【可移植性portable】。不要说不同类型的操作系统之间,就算是同一款操作系统的不同版本之间,其绑定接口与调用套路都高概率地不一样。【可移植】还是省省吧。
    3. 因为支持多平台还是我想要的,所以调研结果:也不推荐
  3. 独立Cpp GUI Framework
    1. 这条技术路线算是前两种技术方案的折中了。
      1. 相对于纯Rust生态圈,Cpp GUI Framework的组件库算是丰富得不要、不要的了。比如说,GTK3FLTK
      2. 相对于直接绑定OSCpp GUI Framework是独立第三方GUI解决方案,其确保了在不同平台上调用接口的一致性。此外,浏览他们官网也发现这些FrameworkGPU性能优化也做得不错。
    2. 其不足之处
      1. 因为市面上免费的Cpp GUI Framework多原生于Linux系平台,所以Win32开发环境真心地不好搭。好心人都去搞 Linux 去了,留在 Windows 平台上的都是“唯利是图”营商精英。
      2. 除了 Rust 自身的【交叉编译】之外,咱们还得额外地准备一份针对Cpp GUI Framework的【交叉编译】解决方案。当然,若能找到针对目标平台的【动态链接库】预编译包,那就太省心。不是没有,可以碰碰运气。
    3. 虽然困难重重,但调研结果:推荐。再想到Flutter也是将Dart绑定Skia。这套路和我的选择很像呀!俺就充满了信心。

不确定你是否已经注意到了我在上文的一个措词【可移植性portable】而没有使用另一个技术“热词”【跨平台 cross-platform】?这是因为没有VM何来跨平台,而咱们恰恰没有VM。这就正如咱们果决地抛弃了GC一样。

Cpp GUI Framework的选择

在口碑榜上名列前茅的Cpp GUI Framework还是有不少的。我考查过的有GTK3FLTK,和QT。我选中的是GTK3 GUI Frameworkgtk-rs的组合。

GTK的光辉历史不用我再多讲了吧?虽然眼下QT的崛起很强势(据说新版的Ubuntu桌面系统要选用QT打造),但由GTK实现的GNOME已经占据了Linux桌面系统的大半江山。

开发环境搭建

上面讲了这么多铺垫的段子,现在开始聊正文。给原生于LinuxGTK GUI Framework搭建一套基于Windows 10开发环境是一件既烧脑又充满成就感的事。

  1. 首先,下载与安装msys2MSYS2是一款服务于Cpp的【软件构建与分发平台】(形似Maven2)。它的功能非常全面 (粗讲都足够独立开一期分享了)。咱们这里只使用它的两个功能:

    1. 编译出针对当前Windows系统的GTK动态链接库。若匹配你操作系统的GTK版本在云端仓库有预编译包,它也会直接下载使用(聪明!)。
    2. 当作cmdPowerShell来用,因为
      1. 它在Windows系统上模拟出了一个伪Linux环境。你之前背过的大部分Linux指令在MSYS2终端都可以大展拳脚了。
      2. 它更容易集成cmake, make, gcc工具链,方便一站式地解决rustc + gcc的编译问题。
  2. 然后,双击安装目录下的mingw64.exe,打开终端。如果你的机器是32位的话,那就需要双击mingw32.exe文件。

  3. 接着,在命令行内,依次执行下面罗列的指令

    # 同步【本地】与【云端】的仓库数据库
    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
    
  4. 接下来,向环境变量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
    

    由此,馁馁的满足感袭来一波。

  5. 【可选步骤】将MSYS2命令行终端与VSCode集成终端关联起来。我是懒人一枚且非常不情愿在多个窗口之间Ctrl + TAB地来回切换。

    1. Windows 10操作系统,添加一个新环境变量CHERE_INVOKING,其值为1

    2. VSCode安装一个新的扩展插件Shell launcher

    3. 打开VSCode的【用户-设置】界面,并直接跳转至settings.json文件

    4. 添加/更新配置项

      "shellLauncher.shells.windows": [{
          "shell": "<MSYS2 安装目录>\\usr\\bin\\bash.exe",
          "args": ["--login", "-i"],
          "label": "msys2"
      }],
      
    5. 敲击键盘F1键,输入命令Shell Launcher: Launch回车。

    6. MSYS2命令行终端就会在VSCode集成终端内被打开了,并已经贴心地定位到当前工程的根目录。

    又一波满足感馁馁地袭来!

  6. 再然后,执行cargo new --bin app-demo1命令。创建一个Binary App类型的空工程。

    1. Cargo.toml文件内,添加GTK依赖

      [dependencies]
      gtk = {version = "0.9.0", features = ["v3_16"]}
      gio = {version = "0", features = ["v2_44"]}
      
    2. 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(&[]);
      }
      
    3. 执行cargo check,检查是否存在

      1. 语法错误
      2. 反【借入规则】与【所有权规则】的代码
    4. 执行cargo build,检查rust是否能够与GTK绑定得上。

      1. 这一波操作对咱们业务开发者是透明的。它是由gtk-rs胶水层crate私下来做的。
    5. 执行cargo run运行被编译生成的.exe文件。

      1. 检查GTK DLL文件在运行时是否都能被正确地链接上。

      2. 不出意外的话,此时你会遇到程序崩溃和一条莫名其妙的报错消息

        exit code: 0xc0000139, STATUS_ENTRYPOINT_NOT_FOUND
        

        这条错误消息是告诉你:“DLL链接失败,很可能是不正确版本的DLL文件被关联”。

不要慌,此坑我趟过。这个报错的原因不神秘。简单地讲,就是

  1. System32系统DLL与你的应用程序所依赖的某个本地DLL有命名冲突。
  2. 同时,你的应用程序优先加载与链接了版本不匹配的System32系统DLL

这不是你程序的错,而是Windows操作系统从XP SP2版本以来,就刻意这么设计的。这款设计学名叫Dynamic-Link Library Search Order。和UnixOS不同,微软为了应付来自黑客精英们的重点关照不得不采纳了更为严格的【DLL搜索规则】来遏制日益猖獗的【DLL劫持攻击】。哎,大家出来混,其实谁都不容易呀! 所以,PATH环境变量对DLL搜索的加权贡献是最低的。于是,一款应用程序搜索其依赖DLL位置(由高往低)依次为:

  1. .exe文件的同级目录
  2. system32系统目录
  3. 启动指令的执行目录
  4. 最后才是PATH环境变量罗列的目录或文件清单。

关于从浩瀚的DLL海洋里捞出那个与系统DLL重名的mingw64/bin/zlib1.dll文件,这里跳过1000个字。我仅只感慨大学没白上。 既然知晓了重名DLL文件,那我解决问题的思路就相当简单粗暴了。即,在.exe文件的同级目录(也就是target/debugtarget/release)内,创建一个指向正确DLL文件的符号链接。于是,版本匹配的DLL文件便会优先于System32系统DLL被加载与关联。

这当然不是我彪乎乎地徒手执行mklink /D指令,来创建符号链接。请别把我想得那么蠢萌蠢萌的。 像这样简单且重复性高的劳动太应该分配给rust toolchain去完成了。简单地讲,就是利用cargo hook(类似于,git hooknpm hook),让cargo在每次开始编译前执行一段脚本代码给mingw64/bin/zlib1.dll文件创建符号链接文件。

  1. 第一步,在工程根目录,创建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**子目录。
  2. 第二步,在~/.bashrc里,添加一个新环境变量MSYS2_HOME。其值就是MSYS2的安装目录。

  3. 第三步,重新执行cargo run。于是,GTKHello World界面就会出现了。完美!!!

    为Rust原生gui编程搭建win32开发环境

至此,我这次要和大家分享的内容就结束了。哎!这我就足足准备了一周。至于【交叉编译】的坑,咱们下次再填吧(实在太深)。今天,我先立一个Flag

突然,想感慨:“谁人心里没有‘星辰大海’?或许咱们前端人的‘诗和远方’就是走出【浏览器】,迈向【原生桌面】,再涉足【IoT嵌入式开发】吧。最终,做到技术链上游。”。我依旧是那位“水平不高,能力有限,但初心犹在”的前行者。

评论区

写评论
Mercury 2021-04-30 13:34

顶一个。

kidd808 2021-04-10 21:21

这是要逆天啊

RedPanda 2021-04-06 08:51

好评一个~

Mike Tang 2021-04-03 23:11

666

zhangchunzhong 2021-04-03 08:31

非常赞!!!

1 共 5 条评论, 1 页