将 Zig 与 SwiftUI 集成
为跨平台应用构建原生 GUI 是一个存在了几十年的难题。如今,大多数人干脆不做原生界面,转而退而求其次,采用 Electron 之类的非原生体验。
为跨平台应用构建原生 GUI 的一种做法是:用跨平台语言(C、Rust、Zig 等)编写全部业务逻辑,然后再编写平台特定的 GUI 代码。我的终端模拟器采用的就是这种做法,而且效果非常好。在撰写本文时,我的代码仓库中 93% 是用 Zig 和 C 编写的业务逻辑,4% 是用 Swift 编写的 macOS 专用 GUI 代码。
这样一来,我的终端模拟器是真正的原生应用:你会得到原生 Mac 窗口、Mac GUI 组件(按钮、文本框)等等。它的外观和手感都很棒。但它同时依然是跨平台的:我还支持 Linux(使用 GTK),并且共享了约 90% 的代码。在本文中,我将分享这套方案的具体运作方式,以及我为何选择这种 GUI 编程方式。
我将使用 Zig 作为共享逻辑语言的示例,但这种通用模式应该适用于任何能够编译为 C 兼容库的系统级语言,比如 Rust。
本文不会教你 Zig 或 SwiftUI 编程。不过,你也不需要熟悉其中任何一门。只要你明白 Zig 是一门编程语言、SwiftUI 是一个原生 GUI 工具包,那么本文的解释就具有更广泛的适用性。
总体思路
总体思路是:
用任何支持导出 C 兼容库的语言编写业务逻辑。这几乎涵盖所有系统级语言(Rust、Zig、C、C++ 等)。你也可以用更高级的语言(JavaScript、Ruby、Python),但由于需要运行时,架构会有所不同。
将你的跨平台逻辑编译成一个静态库,以 C ABI 作为主要接口(表现得像一个“典型”的系统库)。
用平台推荐的任何原生语言和工具包编写 GUI 逻辑,例如在 Xcode 中使用 SwiftUI。
将 GUI 链接到你的跨平台库。🎉
用 Zig 导出 C API
Zig 让导出 C API 变得非常简单。只需在函数前加上 export,该函数就会使用 C 调用约定,并可供其他程序通过标准链接调用。下面是我为终端初始化全局状态的真实导出函数:
export fn ghostty_init() c_int {
main.state.init();
return 0;
}导出函数的签名(更准确地说:使用 C 调用约定的函数)仅限于 C 所支持的参数和返回值。这意味着你不能使用编译期参数、泛型、错误集、任意位宽整数等。此限制仅作用于签名。在函数体内你可以使用所有这些特性。
接下来,你可以编写自己的头文件,它用起来就像任何其他用 C 编写的库一样。注意,你实际上必须编写头文件,因为 Swift 正是通过它来了解你的库的 API。
# ghostty.h
int ghostty_init(void);最后,要构建静态库,我们可以使用 Zig 原生的构建工具。build.zig 最终看起来大致如下。其结果是你应该能在 zig-out 目录中看到一个名为 something.a 的文件。
const lib = b.addStaticLibrary(.{
.name = "ghostty",
.root_source_file = .{ .path = "src/main_c.zig" },
.target = .{
.cpu_arch = .aarch64,
.os_tag = .macos,
.os_version_min = target.os_version_min,
},
.optimize = optimize,
});
lib.bundle_compiler_rt = true;
lib.linkLibC();
b.default_step.dependOn(&lib.step);Zig 版本。本文所有示例均使用 0.11 nightly 构建。随着语言走向成熟,Zig 的 API 仍在频繁变动,所以我不指望本文中的代码能长期保持有效,但如果你使用的版本接近 0.11,应该只需做少量微调。
合并所有依赖
静态库并不会同时嵌入其静态依赖。例如,如果你的 Zig 代码链接了 libcurl,那么你的静态库的任何使用者仍然需要提供一份 libcurl 的静态版本。
注意:仅当你有非 Zig 的库依赖时才需要这一步。如果你将所有代码和依赖编译成一个单元,则无需此步骤。
既然我们构建静态库只是为了与 GUI 集成,而不是作为通用静态库,那我们不妨把所有依赖也一并打包。要做到这一点,我们必须使用 libtool(1)。
build.zig 中的代码如下:
var lib_list = ...;
try lib_list.append(.{ .generated = &lib.output_path_source });
const libtool = LibtoolStep.create(b, .{
.name = "ghostty",
.out_name = "libghostty-aarch64-bundle.a",
.sources = lib_list.items,
});
libtool.step.dependOn(&lib.step);
b.default_step.dependOn(libtool.step);LibtoolStep 是我自己编写的一个自定义步骤,源码可以在这里找到。LibtoolStep 需要一份所有依赖的列表,我们在 lib_list 中逐步构建它(包括添加我们刚写好的库)。libtool 运行的结果是一个“捆绑”库,其中包含我们的库及其全部依赖。
制作通用(多架构)库
macOS 仍处于从 Intel 向 Apple Silicon 过渡的进程之中,因此我们必须构建一个同时支持 x86_64 和 aarch64 架构的库。Mac 支持所谓的“通用二进制文件(Universal Binaries)”,它把两种架构的最终机器码复制到同一个文件中,从而在两种系统上都能运行。
要构建通用二进制文件,我们必须针对每种具体架构分别构建静态库,然后使用 lipo 工具将它们合并。
在 build.zig 中,代码如下:
const static_lib_universal = LipoStep.create(b, .{
.name = "ghostty",
.out_name = "libghostty.a",
.input_a = static_lib_aarch64.output,
.input_b = static_lib_x86_64.output,
});
static_lib_universal.step.dependOn(static_lib_aarch64.step);
static_lib_universal.step.dependOn(static_lib_x86_64.step);LipoStep 是我自己编写的用于调用 lipo 的自定义步骤,源码可以在这里找到。static_lib_aarch64 和 static_lib_x86_64 是前几节中 addStaticLibrary 或 libtool 调用的结果。最终产物就是一个通用库!
创建 XCFramework
最后,我们需要构建一个 xcframework 文件。xcframework 是一个单一包,其中包含库、头文件以及其他关联文件,Xcode 可以将其作为一个单元轻松集成库。
我不打算详细解释 xcframework 文件。本文提供的信息应该足以让你准确地进行 Google 搜索并找到所需答案。这对我来说是最难的部分:搞清楚我需要知道什么。我希望本文能帮你跨过这道坎!
为此我也编写了一个自定义步骤,名为 XCFrameworkStep。在 build.zig 中,它看起来如下:
// The xcframework wraps our ghostty library so that we can link
// it to the final app built with Swift.
const xcframework = XCFrameworkStep.create(b, .{
.name = "GhosttyKit",
.out_path = "macos/GhosttyKit.xcframework",
.library = static_lib_universal.output,
.headers = .{ .path = "include" },
});
xcframework.step.dependOn(static_lib_universal.step);
b.default_step.dependOn(xcframework.step);这个步骤接收我们最终的库输出以及头文件目录的路径(其中包含 ghostty.h 文件),并构建出我们的 xcframework。
重要提示:你需要一个 modulemap。你需要在 include 目录中创建一个 module.modulemap 文件。Xcode 会结合 xcframework 文件使用它来正确构建你的库。将 module.modulemap 文件放在你的 C 头文件旁边:
// This makes Ghostty available to the XCode build for the macOS app.
// We append "Kit" to it not to be cute, but because targets have to have
// unique names and we use Ghostty for other things.
module GhosttyKit {
umbrella header "ghostty.h"
export *
}集成到 Xcode 项目
我们的库终于可以在 Xcode 中使用了。这一步非常简单:只需将构建好的 xcframework 文件拖放到 Xcode 项目的“Frameworks”部分,并将嵌入选项选为“Do Not Embed”。就这样,你的 Swift 代码现在可以导入它了:
import SwiftUI
import GhosttyKit
@main
struct GhosttyApp: App {
var body: some Scene { ... }
}import 的名称必须与 modulemap 中的名称(上一节)一致。导入之后,自动补全将包含你头文件中的所有函数和类型,并且它们会被自动转换为 Swift 类型(即 Swift 用于与 C 桥接的类型)。
到了这一步,你可能会在 C 数值类型、C 布尔值、C 指针等与 Swift 互操作方面遇到一些挑战。但这些都是非常容易通过 Google 搜索解决的问题。
完成
我承认,要抵达这个构想的“应许之地”,需要掌握很多概念:导出 C API、构建静态库、用 libtool 打包依赖、用 lipo 制作通用二进制文件、生成 xcframework、编写 C 头文件和 modulemap 文件,然后将其导入 Xcode 项目。
但是,这些步骤中的每一步都没有什么前沿或深奥之处。所有步骤都是久经考验的——通常已有几十年历史——处理系统库的操作和工具。它们在将来不太可能变得脆弱不堪。
就我的终端应用而言,我使用这项技术已经一年多,其间还经历了一次 macOS 大版本更新,而一切都没有出过任何问题。展望未来,我也不认为会有什么问题。
而且我认为回报是值得的:你可以在保留几乎全部应用逻辑跨平台的同时,获得真正的原生 GUI 体验。重复一下引言中的话:在我的应用中,94% 的代码用 Zig 编写并且是跨平台使用的,只有 4% 是针对 macOS 的平台专用 GUI 代码。诚然,终端模拟器并没有那么多 GUI 交互,但它实现了原生标签页、分屏、偏好设置面板等。
我知道这篇博客文章并没有提供一个拿来即用、复制粘贴就能搞定 Zig 与 SwiftUI 集成的方案,但我希望它为你掌握这一模式提供了必要的知识基础。
附录:为什么不用 Objective-C?
在 macOS 上,还有一种做法是走得更底层,直接使用 Objective-C 与 AppKit 或 Foundation 等系统库交互。Objective-C 拥有原生的 C API,因此大多数编程语言都可以直接与之交互。我最初采用的就是这种方式,但我认为它实际上并不可行。
主要问题在于,一个显而易见的事实是:Apple 设备编程的未来是 Swift。虽然一些核心库在 ObjC 中仍然可用,但大多数现代集成在某种程度上要求使用 Swift(或者要经历一些绝对不值得的折腾才能实现)。
这在很大程度上归结于便利性:如今便利的 GUI 集成都在 Swift 中(例如 SwiftUI)。但有时,这也归结于实际功能。例如,如果你想集成 iPhone 灵动岛,据我所知你必须导出一个 SwiftUI 视图。我确信存在某种邪门歪道可以只用纯 UIKit 来实现,但……那你就真的在与 Apple 希望你做的事情对着干了。
随机一篇博客