Kotlin Multiplatform の SKIE (スカイ)を導入して、iOS エンジニアの開発者体験を改善しました。

これは Kyash Advent Calendar 2023 1日目の記事です。

4月に Android エンジニアとしてジョインした高田(tfandkusu)です。 Kyash のモバイルアプリでは昨年から Kotlin Multiplatform (KMP) を導入し、Swift UI/Jetpack Compose など見た目の部分以外を Kotlin 言語で共通化することで、iOS/Android らしいユーザ体験の追求と両 OS の仕様差異を仕組みでなくすことを両立しています。2023年11月時点で86画面が KMP を活用して作られています。

このたび iOS 側での開発者体験の改善のために SKIE (スカイ)を導入致しましたので、実プロダクトにおける SKIE 導入の実態を報告しようと思います。

予備知識

Kyash への導入のために、まず個人で学習と検証を行い、その過程を Qiita に公開しています。

qiita.com

SKIE の導入に関する予備知識となる Kotlin と Swift の構文 - Sealed classesEnums with associeted values についてはそちらで解説しています。

この記事では

  • Kyash のコードベースにおいて SKIE を導入して開発者体験を改善した箇所
  • 小規模な個人のサンプルでなく、大規模なプロダクションに導入したら、どのような問題が発生したか

の2点を中心に説明します。

SKIE 導入の背景

SKIE は KMP によって作られた iOS 向けのライブラリを Swift らしい構文で利用できるようにするものですが、まずはその前提となる KMP を用いた Kyash のアーキテクチャについて説明します。

Kyash のアーキテクチャiOSReactorKit を参考にした独自の実装になっていて、ユーザー操作を Action として渡すと、UI の状態である State が更新されたり、単発イベントである Event が発行されます。単発イベントの例として Toast 表示や画面遷移があります。エラーダイアログの表示も歴史的経緯があり、State ではなく Event をトリガーにして表示しています。

今回の話で重要な点は Event は sealed class であるという点です。例えば支払いの一覧画面があるとします。

支払いの一覧画面

この画面の Reactor クラスには以下のような Event クラスが定義されています。

※ 説明のため、実際のコードから大幅に変更および省略しています。

class PaymentListReactor() {
    sealed class Event : Reactor.Event {

        data object Back : Event()

        data class NavigateToPaymentDetail(val info: PaymentInfo) : Event()

        data class ShowError(val error: Error) : Event()
    }
}

Reactor を Android から使う

Android 側は when 文を使って Event を処理します。

class PaymentListFragment : Fragment() {

    private fun handleEvent(event: PaymentListReactor.Event) {
        when (event) {
            is PaymentListReactor.Event.Back -> {
                findNavController().popBackStack()
            }

            is PaymentListReactor.Event.NavigateToPaymentDetail -> {
                paymentDetailResultContract.launch(
                    event.info,
                )
            }

            is PaymentListReactor.Event.ShowError -> {
                router.showErrorDialog(
                    event.error,
                )
            }
        }
    }
}

ここで重要なことは when 文を使うと else を使わない限り、網羅性が保証される点です。例えば PaymentListReactor.Event.ShowError に対する実装が抜けている場合はビルドエラーとなります。

iOS から Reactor を利用したときの問題点

同じものを iOS で実装すると switch 文を使って、このようになります。

final class PaymentListViewController: KyashViewController {
    private var cancellables = [AnyCancellable]()

    private func subscribe() {
        reactor.event.receive(on: DispatchQueue.main).sink { [weak self] event in
            guard let self = self else { return }

            switch event {
            case is Reactor.EventBack:
                self.navigationController?.popViewController(animated: true)
            case let item as Reactor.EventNavigateToPaymentDetail:
                self.router.pushToPaymentDetail(from: self, item: item.info)
            case let e as Reactor.EventShowError:
                self.navigator.showError(e.error)
            default:
                break
            }
        }
        .store(in: &cancellables)
    }
}

この書き方には以下の3点で、安全性が低く開発者体験に課題があります。

  • default: の部分は呼ばれないですが必要です。無いと、Switch must be exhaustive ビルドエラーになります。
  • 例えば PaymentListReactor.Event.ShowError に該当する実装を忘れてもビルドエラーになりません。実際、エラーダイアログ表示 Event の処理が抜けていて、圏外などでネットワークエラーになっても、その情報が表示されない不具合も開発途中に発生しました。
  • 本来ならば PaymentListReactor.Event.ShowError というネストされたクラスですが Reactor.EventShowError のように、なぜかドットが1つしか無く2つ目以降のドットが消えています。

次の節では SKIE を導入することでこれらの問題点を解決します。

SKIE 導入後の Reactor の利用方法

SKIE 導入後は前述の Reactor をこのように使います。重要な部分は switch onEnum(of: ) の部分です。

final class PaymentListViewController: KyashViewController {
    private var cancellables = [AnyCancellable]()

    private func subscribe() {
        reactor.event.receive(on: DispatchQueue.main).sink { [weak self] event in
            guard let self = self else { return }

            switch onEnum(of: event) {
            case .back:
                self.navigationController?.popViewController(animated: true)
            case .navigateToPaymentDetail(let item):
                self.router.pushToPaymentDetail(from: self, item: item.info)
            case .showError(let e):
                self.navigator.showError(e.error)
            }
        }
        .store(in: &cancellables)
    }
}

onEnum 関数は SKIE の Sealed Classes 機能 によって自動生成された関数です。KMP において sealed class は iOS 向けにはクラスとして出力されていましたが、onEnum 関数を通すことで Enums with associeted values に変換されます。それにより前述した3つ問題点は解消されました。

SKIE の導入方法

SKIE の導入方法はシンプルです。

iOS 向けの KMP ライブラリモジュールの build.gradle にプラグインを追加します。

※ 説明のため Version Catalog については省略しています

plugins {
    id("co.touchlab.skie") version "0.5.5"
}

SKIE には sealed class 以外にも Flow や suspend 関数を Swift から使いやすくする機能がありますが、Kyash では Swift から KMP の Flow や suspend 関数を使わないため。余分なビルド時間を作らないように無効化しました。

skie {
    features {
        group {
            FlowInterop.Enabled(false)
            SuspendInterop.Enabled(false)
        }
    }
}

Analytics についてはデフォルトで有効なので、開発チームの方針に応じて必要に応じて無効にします。

skie {
    analytics {
        enabled.set(false)
    }
}

導入した結果発生した問題

Kyash に導入したことで発生した問題が2点あったので紹介します。

SKIE 導入前と導入後のビルドキャッシュに互換性が無い

SKIE 導入前の iOS アプリをビルドした状態で、SKIE を導入するとこのようなエラーが発生しました。

❌  Undefined symbols for architecture x86_64
> Symbol: _$s11KyashMobile25BankWithdrawalAccountTypeON
> Referenced from: _$s11KyashMobile25BankWithdrawalAccountTypeO0A8SharedUIE16sortedValidCases_WZ in BankWithdrawalAccountType+Extension.o

KMP の enum class に対して、extension を使っていると発生するようです。

// 出金用銀行口座の口座種別
extension BankWithdrawalAccountType {
    // 表示対象と並び順を定義する(普通、当座、貯蓄)
    public static var sortedValidCases: [BankWithdrawalAccountType] = [.ordinary, .checking, .savings]
}

手元ではビルドキャッシュをクリアしてからビルドすると発生しないのですが、問題は CI 環境です。

CI 環境について詳細を説明すると Kyash では GitHub Actions で irgaly/xcode-cache を使っています。

qiita.com

SKIE 導入前のビルドが QA フェーズに入っていたりなどして、SKIE 導入前と導入後の CI 環境のビルドが平行して走り互換性のないビルドキャッシュが使われるとエラーが発生することが分かりました。

そこでビルドキャッシュ保存と復元のキーを SKIE 導入前と導入後で変更することで、互換性の無いビルドキャッシュが相互に使われないようにしました。

こちらの現象について、ミニマムな個人のサンプルアプリで再現できたら SKIE の開発元に報告しようと思いましたが、再現できませんでした。

SKIE バージョン 0.5.2 以前のバージョンではビルド時間が大幅に伸びる

SKIE 導入前の iOS アプリはフルビルド3分でした(M1 Max, 64GBメモリで計測)。 しかし SKIE 導入後フルビルドが12分になってしまいました。 iOS アプリの作り上 KMP モジュールが少しでも変わると、ほぼフルビルドになってしまうので、この12分が iOS エンジニアの手元で高頻度に発生してしまいました。開発者体験を良くするつもりが悪くなってしまいました。

幸いなことに SKIE バージョン 0.5.3 では多くのクラスが iOS 向けに公開されている場合のビルド速度が改善されたため、それにアップデートすることで、フルビルドが40秒増の3分40秒になり、iOS エンジニアとしては許容範囲となりました。

Kyash のコードベースの問題点として KMP の iOS 向けライブラリで直接使う必要の無いクラスも公開されている点があります。モジュール構成の調整や internal 修飾子の使用などで必要最低限のクラスだけを公開できたら、ビルド速度が改善されると思われるので、今後の取り組みとして進めていく予定です。

iOS エンジニアの感想

SKIE を導入後に iOS エンジニアが社内 Slack に投稿した内容です。

SKIE 導入後の感想 Slack

また別の iOS エンジニアからも口頭で「ビルド時間は増えたけど、それは enum の網羅性担保とトレードオフで、導入して良かった」という感想を頂きました。

まとめ

Kyash における KMP では、sealed class を使用していましたが、Swift の switch 文から使用するときはクラスになるため

  • default: が必ず必要
  • 網羅性が担保されない

という問題がありました。 そこで SKIE を導入することで、sealed class のオブジェクトは自動生成された onEnum 関数に渡すことで enums with associated values に変換できるため、switch 文を安全に使うことができるようになりました。

今後も Kyash では iOS/Android らしいユーザ体験を提供しつつも、iOS/Android の垣根無くクロスプラットフォーム施策を推進して、開発者体験をより良くしていきたいと考えています。