Integrating Zig and SwiftUI

Mitchell Hashimoto

整合 Zig 與 SwiftUI

原文由 Mitchell Hashimoto 發布,訂閱此部落格

為跨平台應用程式打造原生 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 工具組,我在本文中的說明就都能通用。


整體概念

整體概念如下:

  1. 用任何支援匯出相容 C 函式庫的語言撰寫商業邏輯。這幾乎涵蓋所有系統語言(Rust、Zig、C、C++ 等)。你當然也能用更高階的語言(JavaScript、Ruby、Python),但因為需要執行環境,架構就會有所不同。

  2. 將跨平台的邏輯編譯成靜態函式庫,並以 C ABI 作為主要介面(就像一般的系統函式庫那樣運作)。

  3. 用平台所推薦的原生語言與工具組撰寫 GUI 邏輯,例如在 Xcode 中使用 SwiftUI。

  4. 將 GUI 與你的跨平台函式庫連結起來。🎉


用 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 的函式庫相依時才需要這麼做。如果你是將所有程式碼與相依套件編譯成單一單元,就不需要這個步驟。

由於我們建置靜態函式庫只是為了與 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_64aarch64 兩種架構的函式庫。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_aarch64static_lib_x86_64 是前幾個小節中呼叫 addStaticLibrarylibtool 所產生的結果。最終的產物就是一個通用函式庫!


建立 XCFramework

最後,我們需要建置一個 xcframework 檔案xcframework 是一個單一的 bundle,裡面包含了函式庫、標頭檔以及其他相關檔案,讓 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 型別(也就是用來橋接 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(否則就得繞一大圈、做一些完全不值得的 workaround 才能讓它運作)。

很多時候這歸結於便利性:如今便利的 GUI 整合都是用 Swift 提供的(例如 SwiftUI)。但有時,這也關乎實際功能。例如,如果你想整合 iPhone 動態島,據我所知,你就必須匯出一個 SwiftUI 視圖。我相信一定有某種很扭曲的方法可以只用純 UIKit 做到,但……那等於是在跟 Apple 希望你做的方式對著幹。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言