Integrating Zig and SwiftUI

Mitchell Hashimoto

ZigとSwiftUIの統合

クロスプラットフォームなアプリケーションにネイティブなGUIを構築することは、数十年来の課題です。最近では、多くの人がそもそも挑戦せず、Electronのような非ネイティブな体験で済ませてしまっています。

クロスプラットフォームなアプリケーションでネイティブなGUIを実現する一つの方法は、ビジネスロジックのすべてをクロスプラットフォームな言語(C、Rust、Zigなど)で書き、GUIのコードだけをプラットフォームごとに書き分けることです。私は自身のターミナルエミュレータでこのアプローチを採用していますが、非常にうまく機能しています。本記事の執筆時点では、リポジトリの93%がZigとCで書かれたビジネスロジックで、4%がSwiftで書かれたmacOS固有のGUIコードです。

その結果、私のターミナルエミュレータは真にネイティブになりました。Mac本来のウィンドウや、ボタン、テキストフィールドといったMacのGUIコンポーネントがそのまま使え、見た目も使い心地も優れています。一方で、依然としてクロスプラットフォームでもあります。コードの約90%を共有しながら、Linux(GTKを使用)にも対応しています。本記事では、この構成がどのように機能するのか、そしてなぜこの方法でGUIプログラミングに取り組んだのかを詳しく紹介します。

例として共有ロジックの言語にはZigを用いますが、このパターン自体はRustをはじめ、C互換のライブラリにコンパイルできるあらゆるシステム言語に当てはまるはずです。

本記事では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のナイトリービルドを使用しています。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では「ユニバーサルバイナリ」と呼ばれる仕組みがサポートされており、両アーキテクチャ向けの最終的なマシンコードを一つのファイルにまとめることで、どちらの環境でも動作します。

ユニバーサルバイナリをビルドするには、アーキテクチャごとに静的ライブラリをビルドしたうえで、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);

LipoSteplipoを呼び出すために私が自作したカスタムステップで、ソースは こちらで公開していますstatic_lib_aarch64static_lib_x86_64は、前述のセクションでaddStaticLibrarylibtoolを呼び出した結果です。最終的にユニバーサルライブラリが出来上がります!


XCFrameworkの作成

最後に、xcframeworkファイルをビルドする必要があります。xcframeworkは、ライブラリやヘッダー、その他関連ファイルを一つのバンドルにまとめたもので、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(前のセクション)で指定した名前と一致している必要があります。インポートすると、ヘッダーファイルにあるすべての関数や型が自動補完に表示され、Cとのブリッジに使われる型として自動的にSwiftの型へ変換されます。

この段階で、Cの数値型や真偽値、ポインタなどをSwiftと相互運用するあたりでいくつか課題に直面するかもしれません。ただ、これらはいずれも検索すればすぐに解決策が見つかる問題です。


まとめ

認めますが、このアイデアで目指すゴールにたどり着くまでには、理解すべき概念が数多くあります。C APIの公開、静的ライブラリのビルド、libtoolによる依存関係の統合、ユニバーサルバイナリのためのlipo、xcframeworkの生成、Cヘッダーとmodulemapファイルの作成、そしてXcodeプロジェクトへのインポートです。

しかし、これらのステップはいずれも最先端だったり難解だったりするものではありません。すべての工程は、システムライブラリを扱うための実績ある――多くの場合、数十年の歴史を持つ――操作やツールです。今後、壊れやすくなる可能性は低いでしょう。

私のターミナルアプリケーションでは、この手法を1年強使ってきました。その間にはmacOSのメジャーアップデートも一度ありましたが、何も壊れることはありませんでした。今後も壊れることはないだろうと考えています。

そして、その見返りは十分に価値があると思います。アプリケーションロジックのほとんどをクロスプラットフォームに保ったまま、真にネイティブなGUI体験を得られるのです。導入部で述べたことを繰り返しますが、私のアプリケーションではコードの94%がZigで書かれてクロスプラットフォームで使われ、macOS固有のGUIコードはわずか4%です。たしかにターミナルエミュレータはGUIのインタラクションがそれほど多くはありませんが、それでもネイティブなタブや分割表示、環境設定ペインなどを実装しています。

本記事がZigとSwiftUIを統合するための、すぐにコピー&ペーストで使える万能な解決策を提供するものではないことは承知しています。ただ、このパターンを実践するために必要な知識の土台を提供できていれば幸いです。


付録:なぜObjective-Cではないのか?

macOSにおける一つのアプローチは、より低レイヤーに踏み込み、AppKitやFoundationといったシステムライブラリとやり取りするためにObjective-Cを直接使うことです。Objective-CはネイティブなC APIを持っているため、ほとんどのプログラミング言語から直接扱えます。私も最初はこのアプローチを試しましたが、実用的ではないと考えています。

最大の問題は、Appleデバイスのプログラミングの未来がSwiftにあることが痛いほど明らかだという点です。一部のコアライブラリはObjCでも利用できますが、最近の統合の多くは、程度の差こそあれSwiftを必要とします(あるいは、かろうじて動くようにするために割に合わないほどの手間をかける必要があります)。

その多くは利便性の問題です。現在、手軽なGUI統合はSwift(例えばSwiftUI)に集約されています。しかし、場合によっては機能そのものに関わってきます。たとえば、iPhoneのDynamic Islandと統合したい場合、私の知る限りSwiftUIのビューをエクスポートする必要があります。純粋なUIKitだけで実現する呪われたような方法もきっと存在するのでしょうが……それはAppleが望んでいるやり方に真っ向から逆らうことになります。

原文は Mitchell Hashimoto により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。