Integrating Zig and SwiftUI

Mitchell Hashimoto

整合 Zig 与 SwiftUI

原文由 Mitchell Hashimoto 发布,订阅该博客

为跨平台应用构建原生图形界面是一个存在了数十年的老问题。如今,大多数人干脆不做,转而退回到 Electron 这类非原生方案。

为跨平台应用构建原生图形界面的一种做法是将所有业务逻辑用跨平台语言(C、Rust、Zig 等)编写,再为各个平台分别编写图形界面代码。我的终端模拟器采用的就是这种方式,效果非常好。截至撰写本文时,我的仓库中有 93% 是用 Zig 和 C 编写的业务逻辑,4% 是用 Swift 编写的 macOS 专属界面代码。

因此,我的终端模拟器是真正原生的:你能得到原生的 Mac 窗口、Mac 界面组件(按钮、文本框等),外观和体验都很好。但它同时依然是跨平台的:在共享约 90% 代码的前提下,我还支持了 Linux(使用 GTK)。在这篇文章中,我会详细介绍这套方案是如何运作的,以及我为何会选择这种方式来进行图形界面编程。

我会以 Zig 作为共享逻辑的示例语言,不过这一通用模式应该适用于任何能够编译为兼容 C 的库的系统级语言,例如 Rust。

本文不会教你 Zig 或 SwiftUI 编程。不过,你也不需要熟悉这两者。只要明白 Zig 是一门编程语言、SwiftUI 是一套原生图形界面工具包,文中的讲解就依然普遍适用。


核心思路

核心思路如下:

  1. 用任何支持导出兼容 C 的库的语言编写业务逻辑。几乎所有系统级语言(Rust、Zig、C、C++ 等)都可以。你也可以使用更高级的语言(JavaScript、Ruby、Python),但由于需要运行时,架构会有所不同。

  2. 将跨平台逻辑编译为静态库,并以 C ABI 作为主要接口(就像一个“典型”的系统库那样)。

  3. 用平台推荐的原生语言和工具包编写图形界面逻辑,例如在 Xcode 中使用 SwiftUI。

  4. 将图形界面链接到你的跨平台库。🎉


用 Zig 导出 C API

Zig 可以很方便地导出 C API。只要在函数前加上 export,该函数就会使用 C 调用约定,并可通过标准链接的方式供其他程序调用。下面是我终端中用于初始化全局状态的真实导出函数:

export fn ghostty_init() c_int {
    main.state.init();
    return 0;
}

导出函数的签名(更准确地说,是使用 C 调用约定的函数)仅限于 C 支持的参数和返回值类型。这意味着你不能使用 comptime 参数、泛型、错误集、任意位宽整数等。这一限制仅适用于签名,在函数体内你仍可以使用所有这些特性。

接下来,你可以自己编写头文件,它用起来就像其他任何用 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 的库依赖时才需要这一步。 如果你的所有代码和依赖都被编译进了同一个单元,就不需要这一步。

由于我们构建静态库只是为了与图形界面集成,而不是作为通用的静态库分发,不妨把所有依赖也一起打包。为此,我们需要使用 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_64aarch64 两种架构的库。Mac 支持所谓的“通用二进制”,做法就是将两种架构的最终机器码都复制到同一个文件中,使其在两种系统上都能运行。

要构建通用二进制,我们需要分别为每种特定架构构建静态库,然后使用 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_aarch64static_lib_x86_64 是前几节中 addStaticLibrarylibtool 调用的结果。最终得到的就是一个通用库!


创建 XCFramework

最后,我们需要构建一个 xcframework 文件xcframework 是一个单一的 bundle,其中包含了 Xcode 可以作为一个整体来轻松集成库所需的库文件、头文件及其他相关文件。

我不会在这里详细介绍 xcframework 文件。这篇博文提供的信息应该足以让你准确地通过搜索找到所需答案。对我来说最困难的部分,仅仅是搞清楚需要了解什么。希望这篇文章能帮你跨过这道坎!

为此我也编写了一个自定义步骤,名为 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 类型(即用于与 C 桥接的类型)。

到这一步,你可能会在 C 数值类型、C 布尔值、C 指针等与 Swift 的互操作上遇到一些挑战。但这些都是很好搜索解决的问题。


完成

我承认,要实现这个想法,需要理解不少概念:导出 C API、构建静态库、用 libtool 打包依赖、用 lipo 合并通用二进制、生成 xcframework、编写 C 头文件和 modulemap 文件,然后再导入到 Xcode 项目中。

不过,这些步骤中没有哪一步是在做前沿或冷门的事情。所有步骤都是经过时间检验的、通常已有数十年历史的、用于处理系统库的常规操作和工具,未来也不太可能变得脆弱。

对于我的终端应用,我已经使用这套技术一年多,期间经历了一次 macOS 大版本更新,完全没有出现任何问题。往后,我也不预期会出什么问题。

而我认为回报是值得的:你可以在保持几乎所有应用逻辑跨平台的同时,获得真正原生的图形界面体验。再次强调,就像引言中提到的那样:我的应用中有 94% 的代码是用 Zig 编写的、可跨平台复用,只有 4% 是 macOS 平台专属的图形界面代码。诚然,终端模拟器本身并没有那么多图形界面交互,但这些代码已经实现了原生标签页、分屏、偏好设置面板等功能。

我知道这篇博文并没有提供一个开箱即用、复制粘贴就能让 Zig 与 SwiftUI 集成的方案,但我希望它能为你提供遵循这一模式所需的知识基础。


附录:为什么不用 Objective-C?

在 macOS 上,另一种做法是更底层一些,直接尝试用 Objective-C 与 AppKit 或 Foundation 等系统库交互。Objective-C 拥有原生的 C API,因此大多数编程语言都可以直接与之交互。我一开始也尝试过这种方式,但我认为它在实践中并不可行。

主要问题在于,Apple 设备编程的未来显然是 Swift。虽然一些核心库仍可通过 ObjC 访问,但如今大多数现代集成在某种程度上都要求使用 Swift(或者要绕极其繁琐、得不偿失的弯子才能实现)。

很大程度上这关乎便利性:如今便利的图形界面集成都在 Swift 中(例如 SwiftUI)。但有时,这也关乎实际功能。例如,如果你想集成 iPhone 灵动岛,据我所知你必须导出一个 SwiftUI 视图。我确信肯定有某种诡异的方法能用纯 UIKit 实现……但那样你就是在与 Apple 希望你做的事情背道而驰了。

本文章由 muse-spark-1.2-contributor 进行翻译

评论