KMPで作った受付アプリをiPadに対応させた話

こんにちは、 スタメンでEMをしているあさしんです

最近、Claudeのプランを上げて色々作ったり、自作キーボードの沼に徐々に沈み始めています。

はじめに

スタメンが提供する「TUNAG 受付アプリ」は、2022年7月にリリースした来客受付アプリです。来訪者がiPad(当時はAndroidタブレット)を操作し、担当グループ・担当者を選んで呼び出すと、担当者へ通知が届く仕組みになっています。TUNAGをご利用いただいているお客様にご提供しているサービスです。

当初の通知先はTUNAGアプリ内のチャット機能でしたが、今回の改修によって「TUNAG チャット」にも通知できるようになりました。TUNAG チャットは、TUNAGアプリの中の1機能として提供されていたチャットを独立させたアプリで、2025年3月にリリースされました。音声通話やスレッド機能など、コミュニケーション機能をより強化した形で提供しています。サービスの成長に合わせてアプリが分化していく中で、受付アプリ側も新しい通知先に対応した形になります。

リリース当時の技術的な経緯は2022年のテックブログ記事にまとめています。かんたんに振り返ると、Android開発を主担当するエンジニアが、将来のiOS拡張を見越してKotlin Multiplatform Mobile(KMM、現KMP)を採用したのが出発点でした。

当時の判断を引用すると:

「今後iOSアプリへの拡張性を考慮」してデータ・ドメイン層にKMMを導入しました。

この「将来の伏線」をついに回収しました。KMPで積み上げたロジック資産をそのまま活用し、SwiftUIのUI層を追加するだけでiPad対応を実現した話をします。


なぜ最小コストで済んだのか

最初にポイントを整理します。

Android版リリース時点で、ビジネスロジックはすでにKMPの shared モジュールにまとまっていました。具体的には:

  • 認証フロー(パスワード認証・SSO判定)
  • グループ・ユーザー一覧のフェッチ
  • ViewModel の UI State 管理
  • APIクライアント(Ktor)
  • クッキー永続化

これらはすべて commonMain に書かれており、Kotlinのまま動きます。iOS対応でやるべきことは、SwiftUIの画面を足すだけでした。ビジネスロジックを書き直す必要はありません。

ここで重要なのは、現在のチームにはiOSメンバーが増えており、ネイティブで0から作り直す選択肢も技術的には十分あった点です。それでもKMPの上に載せる判断をしたのは、動いている資産を捨てるコストに見合うリターンがないからです。既存のロジックを再実装しても機能は増えません。エンジニアのリソースを「新しいビジネス価値を生む開発」に使える方が、今回の私たちの状況においては、ビジネス判断として合理的でした。


プロジェクト構成

tunag-reception/
├── androidApp/   # Jetpack Compose による Android UI層
├── iosApp/       # SwiftUI による iOS(iPad)UI層
└── shared/       # KMP 共有モジュール
    └── src/
        ├── commonMain/   # 共通ロジック(Android/iOS 両方で動く)
        ├── androidMain/  # Android 固有実装
        └── iosMain/      # iOS 固有実装

shared モジュールのレイヤリングはこうなっています。

shared/commonMain/
├── domain/
│   ├── entity/       # データクラスのみ。ビジネスロジックなし
│   └── repository/   # インターフェース定義のみ
├── application/
│   ├── service/      # ユースケース
│   ├── dto/          # データ転送オブジェクト
│   └── AppContainer.kt  # DIコンテナ(唯一の組み立て口)
├── infrastructure/
│   ├── api/          # Ktor クライアント、Cookie管理
│   └── repository_impl/
├── presentation/     # ViewModel
└── stub/             # テスト・Preview 用スタブ

最重要ルールは「ViewModel・Repository・Entity は必ず shared に収める」こと。iOS/Android のモジュールには View レイヤのみを配置します。この制約を守ることで、ロジックのダブルメンテが構造的に発生しません。


expect/actual パターン:プラットフォーム差を局所化する

KMPでプラットフォーム固有の実装が必要な箇所は expect/actual で分岐します。代表的な例として ApiClient を紹介します。

ApiClient

HTTPクライアントはプラットフォームごとにエンジンが異なります。commonMain では expect だけを宣言します。

// commonMain — 共通のシグネチャ定義(各プラットフォームで実装を切り替える枠組み)
expect class ApiClient(apiHost: ApiHost) {
    val apiHost: ApiHost
    val client: HttpClient
    suspend fun clearCookies()
    suspend fun addCookieToStorage(name: String, value: String)
    fun close()
}

iOS側の actual はDarwinエンジン(NSURLSessionベース)を使います。

// iosMain — Darwin エンジンで実装
actual class ApiClient actual constructor(actual val apiHost: ApiHost) {
    actual val client: HttpClient = HttpClient(Darwin) {
        engine {
            // Ktor の HttpCookies プラグインでクッキーを管理するため
            // NSURLSession のクッキーストレージを無効化して二重付与を防ぐ
            configureSession {
                setHTTPCookieStorage(null)
            }
        }
        install(HttpCookies) {
            storage = PersistentCookieStorage(apiHost)
        }
        install(UserAgent) {
            agent = "Tunag-iOS/5.8.0.0 Reception"
        }
    }
}

Android側はOkHttpエンジンが入ります。呼び出し側は ApiClient を使うだけで、プラットフォームを意識しません。

なお、現在のKMPではクラス全体を expect class にするより interface + DI で注入するアプローチが推奨されることが多いです。今回は2022年当時の設計資産をそのまま活かす方針のため、当時の expect class をそのまま使い続けています。


クッキー永続化:iOS/Android の保存先の違いを吸収する

アプリ再起動後もログイン状態を維持するため、セッションクッキーを永続化しています。保存先がプラットフォームで異なるため、Multiplatform Settings ライブラリの Settings クラスで抽象化し、commonMainPersistentCookieStorage を実装しました。プラットフォーム固有の保存先は Settings の裏側に隠れるため、共通コードはストレージを意識しません。

// commonMain — プラットフォームを意識しない CookiesStorage 実装
internal class PersistentCookieStorage(apiHost: ApiHost) : CookiesStorage {
    private val settings = createCookieSettings()  // expect/actual でプラットフォームごとの保存先に切り替わる
    private val storageKey = "ktor_cookies_${apiHost.raw}"

    override suspend fun get(requestUrl: Url): List<Cookie> = mutex.withLock {
        ensureLoaded()
        val host = requestUrl.host
        val now = currentEpochMillis()
        cookieMap.entries
            .filter { (domain, _) -> host == domain || host.endsWith(".$domain") }
            .flatMap { it.value }
            .filter { it.expiresAt == null || it.expiresAt > now }
            .map { it.toCookie() }
    }
}

実装中にひとつ罠がありました。Ktor 3へのアップグレード後、ログアウト直後に再ログインするとクッキーが消えるバグが発生しました。原因はKtor 3での挙動変更で、max-age=0 が「未設定」扱いから「即時削除」に変わっていました。RFC 6265に立ち返り maxAge <= 0 を正しく削除処理することで解決しましたが、地味な落とし穴でした。


StateFlow を SwiftUI から消費する:Wrapper パターンと SKIE

KMPのViewModelはKotlinの StateFlow でUI状態を公開します。

// shared/commonMain — Kotlin ViewModel
class SystemViewModel(...) : ViewModel() {
    val uiState: StateFlow<SystemUiState> = viewModelState
        .map { it.toUiState() }
        .stateIn(viewModelScope, SharingStarted.Eagerly, ...)
}

Swiftから StateFlow を直接 @Published として扱うことはできません。当初はKotlin側にコールバックを受け取るラッパーを用意していましたが(*ViewModelIos.kt)、SKIE の導入でそれが不要になりました。

SKIEは、Kotlin Multiplatformで作られたコードをSwiftから利用しやすくするためのコンパイラプラグインです。 Kotlinの Flow をSwiftの AsyncSequence に変換し、sealed interface をSwiftのパターンマッチで扱えるようにしてくれます。

現在はSwift側に薄いWrapperクラスを置くだけです。

// iosApp — Swift Wrapper
@MainActor
final class SystemViewModelWrapper: ObservableObject {
    @Published private(set) var uiState: SystemUiState

    private var observationTask: Task<Void, Never>?

    init(container: AppContainer) {
        let vm = SystemViewModel(...)
        self.uiState = vm.uiState.value

        // StateFlow を for-await-in で購読し、@Published を更新する
        observationTask = Task { [weak self] in
            for await state in vm.uiState {
                guard let self else { return }
                self.uiState = state
            }
        }
    }

    deinit { observationTask?.cancel() }
}

sealed interface のパターンマッチも自然に書けます。

// SKIE の onEnum(of:) で Kotlin sealed interface を Swift で分岐する
var currentBaseUrl: String {
    switch onEnum(of: uiState) {
    case .signIn(let s):      return "https://\(s.currentHost)"
    case .enterPassword(let e): return "https://\(e.currentHost)"
    case .admin(let a):       return "https://\(a.currentHost)"
    }
}

SKIEなしではKotlin側にiOS専用のラッパー関数を用意する必要がありました。その手間がなくなり、Android向けに書いたKotlinコードをSwift側から直接使える体験が格段に改善されています。

※コードは弊社環境のSKIEバージョン(0.10.11)に基づいています。SKIEの比較的新しいバージョンでは onEnum(of:) を使わずネイティブの switch で直接分岐できる場合もあります。


CI/CDを2本立てで構築する

AndroidとiOSでCI/CDのパイプラインを分けています。

Android:CircleCI → Google Play

元のリリース時から変わらず、CircleCIを使っています。

# .circleci/config.yml(抜粋)
deploy_production_to_playstore:
  docker:
    - image: cimg/android:2024.09
  steps:
    - checkout
    - run:
        name: Place keystore file
        command: echo $STORE_FILE_BASE64 | base64 --decode > ./release.keystore
    - run:
        name: Build bundle & upload to Play Store
        command: ./gradlew publishProductionReleaseBundle

リリースタグを打つと自動でPlay Storeの内部テスト版にデプロイされます。

iOS:Xcode Cloud → App Store

iOS対応を機にXcode Cloudを採用しました。GitHub連携でpushを検知し、アーカイブ・App Storeへの提出を自動化しています。

CircleCIに統一する案も検討しましたが、採用しませんでした。CircleCIでiOS/iPadOS向けのビルド環境を構築するには、App Store Connectとの連携に必要な認証トークンの発行・管理が必要です。さらにAndroidと同様に「ストアへのアップロード」と「社内テスト版の自動配信」を実現しようとすると、Xcode Cloudで構築するより圧倒的にコストがかかります。Xcode CloudはApple公式サービスだけあって、App Storeとの連携がそのまま動きます。

また、このアプリのテストはUnitTestレベルまでで、ほとんどのテストコードは shared モジュールに閉じています。CircleCIの既存のテストジョブで共通ロジックのテストはカバーできているため、iOS側のCIをCircleCIに統合する必然性がありませんでした。

Xcode CloudはApple公式のCI/CDサービスで、macOS環境が整っている点がメリットです。ただし、KMPの shared フレームワークをビルドするにはJDK 17が必要で、Xcode Cloudの環境にはデフォルトでJDKが含まれていません。

この問題を iosApp/ci_scripts/ci_post_clone.sh で解決しました。Xcode Cloudでは .xcodeproj と同階層の ci_scripts/ にスクリプトを置くことでビルドフックとして自動実行されます。クローン後に実行されるこのスクリプトで、AdoptiumのAPIからJDK 17を取得してセットアップします。

#!/bin/sh
# Xcode Cloud: KMP shared framework のビルドに必要な JDK 17 をセットアップする

JDK_INSTALL_DIR="/Volumes/workspace/DerivedData/JDK"
JDK_HOME="$JDK_INSTALL_DIR/Home"

# Apple Silicon / Intel を自動判定して対応アーキテクチャのJDKを取得する
RAW_ARCH=$(uname -m)
ARCH=$( [ "$RAW_ARCH" = "arm64" ] && echo "aarch64" || echo "x64" )

mkdir -p "$JDK_INSTALL_DIR"

curl -fsSL --retry 3 \
  "https://api.adoptium.net/v3/binary/latest/17/ga/mac/${ARCH}/jdk/hotspot/normal/eclipse" \
  -o /tmp/jdk.tar.gz

# 展開前にトップディレクトリ名を取得してパスを決定的にする
TOP_DIR=$(tar -tzf /tmp/jdk.tar.gz | head -1 | cut -d'/' -f1)
tar -xzf /tmp/jdk.tar.gz -C "$JDK_INSTALL_DIR"
rm /tmp/jdk.tar.gz

# シンボリックリンクでパスを固定する
ln -sfn "$JDK_INSTALL_DIR/$TOP_DIR/Contents/Home" "$JDK_HOME"

Xcode CloudのワークフローにはJAVA_HOME環境変数として /Volumes/workspace/DerivedData/JDK/Home を設定し、Gradleが参照できるようにしています。Apple Silicon / Intelを uname -m で自動判定することで、Xcodeのインフラが変わっても動き続けます。


まとめ

3年前の設計判断が、今回の意思決定を楽にしました。

  • KMPで共通化したロジックはそのまま動いた。認証・グループフェッチ・ViewModel・クッキー管理、すべて再実装ゼロ。
  • SwiftUIのUI層を追加するだけでiPad対応が完了した。
  • SKIEでSwift連携が劇的に改善した。Kotlin側にiOS専用のコードを書かずに済む。
  • Xcode CloudでiOSのCI/CDも整備した。JDKをスクリプトで用意するひと工夫が必要だったが、以降は自動化できている。

「将来iOS対応できるようにKMMで作ります」というAndroidエンジニアの判断が、数年後に「動いている資産を捨てずに最大のリターンを得る」という意思決定の根拠になりました。技術選定は当時の最適解を選ぶだけでなく、将来の選択肢を広げるという意味でも重要です。

「体制が整ったから0から作り直す」という選択肢も魅力的に映ることがありますが、動いている資産を捨てるコストと得られる価値を冷静に天秤にかけた結果、今回はKMPの継続利用がベストだと判断しました。同じような場面で迷っている方の参考になれば嬉しいです。


TUNAG 受付アプリはGoogle PlayApp Storeで公開中です。
関連記事:2022年リリース時のテックブログ