整合 Zig 與 SwiftUI
為跨平台應用程式打造原生 GUI 是數十年來的老問題。如今,大多數人乾脆不這麼做,轉而採用 Electron 這類非原生的體驗。
打造跨平台應用程式原生 GUI 的一種做法,是用跨平台語言(C、Rust、Zig 等)撰寫所有的商業邏輯,然後再撰寫各平台專屬的 GUI 程式碼。這就是我在我的終端機模擬器上採用的做法,而且效果非常好。截至撰寫本文的當下,我的儲存庫中有 93% 是用 Zig 和 C 撰寫的商業邏輯,4% 是用 Swift 撰寫的 macOS 專屬 GUI 程式碼。
因此,我的終端機模擬器是真正的原生應用程式:你會得到原生的 Mac 視窗、Mac GUI 元件(按鈕、文字欄位)等。外觀與操作感受都非常好。但它同時仍保有跨平台特性:我在共用約 90% 程式碼的情況下,也支援 Linux(使用 GTK)。在本文中,我將分享這種架構的運作細節,以及我為何選擇以這種方式進行 GUI 程式設計。
我將以 Zig 作為共用邏輯語言的範例來說明,但這個通用模式應該適用於任何能編譯為 C 相容函式庫的系統語言,例如 Rust。
這篇文章不會教你 Zig 或 SwiftUI 程式設計。不過,你也不需要熟悉這兩者。只要你理解 Zig 是一種程式語言,而 SwiftUI 是一套原生 GUI 工具組,那麼本文的說明就具有更普遍的適用性。
高層次概念
高層次概念:
用任何支援匯出 C-compatible library(C 相容函式庫)的語言撰寫你的商業邏輯。這幾乎涵蓋任何系統語言(Rust、Zig、C、C++ 等)。你也可以使用高階語言(JavaScript、Ruby、Python),但由於需要執行環境,架構會有所不同。
將你的跨平台邏輯編譯為 static library(靜態函式庫),並以 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 所支援的參數與回傳值。這表示你不能使用 comptime 參數、泛型、錯誤集合、任意位元寬度的整數等。此限制僅適用於簽章。你仍然可以在函式本體內使用所有這些功能。
接著,你可以自行撰寫標頭檔,它用起來的感覺就和任何其他用 C 撰寫的函式庫一模一樣。請注意,你實際上必須撰寫標頭檔,因為 Swift 就是透過它來得知你的函式庫的 API 是什麼。
# ghostty.h
int ghostty_init(void);最後,要建置 static library,我們可以使用原生的 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 的版本,應該只需要做些微調整。
合併所有相依項目
Static libraries 不會同時嵌入其靜態相依項目。舉例來說,如果你的 Zig 程式碼連結了 libcurl,那麼你的 static library 的任何使用者仍需另外提供 libcurl 的靜態版本。
注意:只有當你有非 Zig 的函式庫相依項目時才需要此步驟。如果你是將所有程式碼與相依項目編譯為單一單元,則不需要此步驟。
由於我們建置 static libraries 僅是為了與 GUI 整合,而非作為通用的 static libraries,因此我們就一併將所有相依項目打包起來。要做到這點,我們必須使用 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 後的結果是一個「bundled」函式庫,其中包含我們的函式庫及其所有相依項目。
製作 Universal(多架構)函式庫
macOS 仍處於從 Intel 轉向 Apple Silicon 的過渡期,因此我們必須建置一個同時適用於 x86_64 與 aarch64 架構的函式庫。Mac 支援一種稱為「Universal Binaries(通用二進位檔)」的機制,其原理只是將兩種架構的最終機器碼複製到同一個檔案中,就能在兩個系統上運作。
要建置 universal binary,我們必須為每個特定架構建置 static library,然後使用 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 呼叫的結果。最終的成果就是一個 universal library!
建立 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、建置 static lib、用 libtool 處理相依項目、為 universal binaries 執行 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 希望你做的事情對抗。
隨機一篇部落格