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コンポーネント(ボタン、テキストフィールドなど)がそのまま使えます。見た目も使い心地も素晴らしいものです。一方で、依然としてクロスプラットフォームでもあります。コードの約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は「ユニバーサルバイナリ」と呼ばれるものをサポートしており、両方のアーキテクチャ向けの最終的なマシンコードを1つのファイルにコピーすることで、どちらのシステムでも動作するようにしています。

ユニバーサルバイナリをビルドするには、各アーキテクチャ向けに静的ライブラリをビルドし、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ファイルについて詳しく説明するつもりはありません。このブログ記事で、正確に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ファイルを作成する必要があります。これはxcframeworkファイルと共にXcodeがライブラリを正しくビルドするために使われます。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プロジェクトへのインポートです。

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

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

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

このブログ記事がZigとSwiftUIを統合するためのすぐにコピー&ペーストできるような万能な解決策を提供していないことは承知していますが、このパターンに従うために必要な知識の基盤を提供できれば幸いです。


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

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

大きな問題は、Appleデバイスのプログラミングの未来が明らかにSwiftにあるということです。一部のコアライブラリはObjCで利用できますが、現代的な統合の大部分は、ある程度Swiftを必要とします(あるいは、何とか動作させるために到底割に合わないような苦労を強いられます)。

その多くは利便性の問題に帰着します。最近では、便利なGUI統合はSwift(例えばSwiftUI)にあります。しかし、時には実際の機能の問題になることもあります。例えば、iPhoneのDynamic Islandと統合したい場合、私の知る限りSwiftUIビューをエクスポートする必要があります。純粋なUIKitを使うという呪われた方法もあるのでしょうが……Appleがあなたにやらせたいことに真っ向から逆らうことになるでしょう。

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

コメント