本文へスキップ

アプリリリースなしでネイティブ Swift アプリの UI を更新する

はじめに

App Store のレビュー待ちは、通常 1 日から数日かかります。 文言修正やちょっとした挙動変更でもレビューを通す必要があります。

React Native には Expo Updates という OTA 更新の仕組みがあり、アプリのロジックだけを差し替える範囲では実務上は問題なく通っています。 ネイティブ Swift だけこの選択肢を持っていませんでしたが、Patch が SwiftWasm と WasmKit を組み合わせて同じことを実現しています。 SwiftWasm は Swift のコードを WebAssembly バイナリにコンパイルする toolchain で、上流の swift.org にも取り込まれつつあります。 WasmKit は Swift 製の WebAssembly ランタイムで、JIT を使わない interpreter 実装なので、JIT が禁じられている iOS 上でも wasm を実行できます。

この記事では、自作の macOS アプリに Patch を組み込み、TestFlight ビルドに OTA でパッチを配信して SwiftUI の文言を差し替えた記録を残します。

Patch を組み込む

CLI は Homebrew tap 経由で入ります。 そのまま patchcli を叩けば、Xcode プロジェクトへの SDK 追加とスタートアップコード注入まで自動で行います。

brew install patch-release/tap/patchcli
patchcli setup      # Swift → WebAssembly toolchain を導入
patchcli init       # プロジェクト検出 + アプリ登録 + SDK 追加
patchcli prepare    # SwiftUI ビューを OTA 差し替え可能な形に変換
patchcli fingerprint register  # App Store バイナリのフィンガープリント登録

このうち setupinit は初回セットアップの 1 度きりですが、patchcli fingerprint registerApp Store に新しいバイナリを提出するたびに再実行が必要 です。 ネイティブシェルの fingerprint が変わると、その端末での OTA パッチが受け付けられなくなるためです。 patchcli prepare は SwiftUI ビューを新しく追加したときや private / internal を変更したときに再実行しておくと安全ですが、通常のロジック修正や文言変更のためには不要です。

prepare が生成するもの

patchcli prepare は具体的に 2 種類の変更を行います。 まず既存の SwiftUI ビューに dynamic マーカーが 1 語追加されます。

- var body: some View {
+ dynamic var body: some View {

そして Patch/Generated/PatchThunks.generated.swift に、そのビュー用の thunk が自動生成されます。

// Patch/Generated/PatchThunks.generated.swift (自動生成、コミット対象)
extension CaptureSettingsPopover {
    // wasm 側に有効なパッチがあれば wasm 由来の View を、無ければ元の body を返す
    @_dynamicReplacement(for: body)
    var __patchedBody: some View {
        Patch.shared.thunkBody(for: "CaptureSettingsPopover", instance: self)
    }

    // wasm 側で再現できないコンポーネントのネイティブフォールバック
    @MainActor func __patchSlots() -> [String: ([String]) -> AnyView] {
        ["op_cd5f757accee912a": { a in AnyView(Button(a[0]) { ... }) }]
    }
    // 他に __patchTokens / __patchActionSlots / __patchRowSlots など計 6 種類
}

@_dynamicReplacement(for: body) が実行時に元の body の代わりに呼ばれ、「wasm パッチがあれば wasm 側の View を、無ければ元の body を」返します。 その下のスロットメソッドは、wasm 側で再現できない部分 (自作 View や private メソッドを呼ぶ Button など) のネイティブフォールバック実装を wasm から呼び戻すためのものです。

以降、パッチの配信は次の 1 コマンドで完結します。

patchcli release -m "test: rename Capture Quality label"

バックエンドに wasm モジュールがアップロードされ、端末側の SDK が起動時に取得・検証・有効化します。

文言差し替えの動作

TestFlight で同一バージョンをインストールしたまま、CLI から 2 回続けてパッチを配信しました。

1 回目のパッチ (patch v1) 2 回目のパッチ (patch v2)
Capture Quality (OTA) 表示 Recording Quality (OTA v2) と Video Resolution 表示
見出しが Capture Quality (OTA)、1 行目のテキストは Resolution 見出しが Recording Quality (OTA v2)、1 行目のテキストが Video Resolution に差し替わっています。

この変更の適用方法

差し替えたい SwiftUI ビューの body を通常通り編集し、patchcli release を叩くだけです。

- Text("Capture Quality")
+ Text("Recording Quality (OTA v2)")
    .font(.headline)

- Text("Resolution")
+ Text("Video Resolution")
    .frame(width: 88, alignment: .leading)
patchcli release -m "rename Capture Quality label"

全体の内部処理の流れは次の通りです。

Patch OTA の内部処理フロー

patchcli release はローカルで Swift → WebAssembly コンパイルを走らせて .wasm を生成し、Patch のバックエンドにアップロードします。 端末側の SDK は起動時にバックエンドへ 1 度だけ更新確認を投げ、新しいバージョンがあれば .wasm をダウンロードし、SHA-256 ハッシュで通信中の改ざんがないかチェックしてから有効化します。 定期ポーリングはせず、次回起動まで新しいパッチは適用されません。 配信から端末で見えるようになるまでは数十秒でした。

ダッシュボード

patchcli status と同じ情報をブラウザ上で読めるダッシュボードもあります。 アクティブデバイス数、適用率、エラー率、直近のリリース履歴 (バージョンごとのデバイス数・ダウンロード数・エラー数) を確認できます。

Patch ダッシュボードの Usage 画面

段階的ロールアウトを走らせているときは、リリースごとの実際の到達数と失敗率を見ながら patchcli rollback を叩くかどうかを判断することになります。

OTA で差し替えられる範囲

SwiftUI と UIKit で自動的に差し替えが効く範囲は次の通りで、粒度と仕組みが少し違います。

SwiftUIUIKit
差し替え対象var body: some View 全体UITableViewCell / UICollectionViewCellconfigure(with:)
使えるコンポーネントText / VStack / HStack / Picker / Button / Toggle / TextField など数十種類UILabel / UIButton / UIImageView / UISwitch / UISlider / UITextField / UIStackView / UIView
対象範囲画面全体セル 1 個の中身

これらは patchcli prepare が自動で thunk を差し込むので、コードを書き換えて patchcli release するだけで反映されます。

UIViewController など、自動 thunk の対象外のコードについては、Patch の下位 API を使って手動で「wasm 側と対話するポイント」を仕込む方法もあります。 ただしここは今回の記事では実際に検証していないため、詳細は割愛します。

日本語が反映されない不具合をサポートに報告した話

検証中、日本語などマルチバイトの文字列だけがパッチを配信しても端末側で差し替わらない不具合に遭遇しました (例: Text("録画品質") に変えても反映されない)。 配信された .wasm を確認すると、ASCII 文字列は含まれているのに日本語部分だけが欠落しており、CLI が Wasm を組み立てる過程で落としてしまっているようでした。

この詳細を Patch のサポート (jack@patchrelease.com) にメールで送ったところ、次の日には対応バージョンをリリースしてもらえました。

Patch サポートからの返信メール

実際に patchcli を 1.6.48 にアップグレードして同じ手順を試したところ、Text("録画品質") の変更が端末側にも反映されました。 配信前の .wasm を確認しても日本語の UTF-8 バイト列がそのまま含まれていて、この不具合は解消されています。

料金プラン

料金は 3 段階で、個人開発では Hobby プランで無料 のまま使えます。 デバイス 100 台まで、OTA アップデート回数とロールバックは無制限、CLI や CI/CD 連携、ship-safety チェックもすべて含まれます。

Patch の料金プラン (Hobby は無料)

10,000 デバイスまでスケールする Startup プランが月 $59、大規模組織向けの Enterprise プランは要問い合わせ、という並びです。

まとめ

書き換え → 数十秒 → 端末で反映という周期を実際に触ってみると、App Store のレビュー待ちがボトルネックだった作業の質は確かに変わります。 差し替え対象を SwiftUI のテキストや軽い挙動修正から始めるのが導入しやすい入り口です。 個人開発では Hobby プランで無料のまま使えるので、興味があれば試してみてください。

参考リンク