整合 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 工具組,我在本文中的說明就都能通用。
整體概念
整體概念如下:
用任何支援匯出相容 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 所支援的參數與回傳值型別。這表示你不能使用 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_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 是一個單一的 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 希望你做的方式對著幹。
隨機一篇部落格
留言
登入後參與討論