27
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Android強化月間 - Androidアプリ開発の知見を共有しよう -

Kotlin Multiplatform は iOS エンジニアの開発者体験が良くないのではと思っていたが、SKIE を使えば良いと思った。

Posted at

経緯

ある日 AndroidDagashi を見ていたら #292 2023-09-10

Swift-friendlyなAPIをKotlin Multiplatformで利用可能にするSKIEがオープンソースで公開
https://touchlab.co/skie-is-open-source

というものが紹介されていて、公式サイトの Features を読んでみたら、Kotlin Multiplatform (KMP) を用いた iOS/Android アプリ開発について思うところがありましたが、それを解決できるのではと思いました。

概要

この記事は いくつかある SKIE の機能Sealed Classes に焦点を絞って、基本的な内容から説明しています。既存プロジェクトに導入する場合の注意点や別のソリューションとの比較も簡単にではありますが掲載しています。

KMP は iOS エンジニアの開発者体験が良くない説

これは実際に iOS エンジニアに言われたわけではないです。今年4月に Android エンジニアとして KMP をプロダクトに導入している会社に転職したので KMP と iOS の学習を行っていますが、その過程で私個人が思ったことです。

通常、 Kotlin の sealed class は Swift の enums with associated values に変換されないが、 SKIE を使えば変換してくれる

Kotlin の sealed class の例

例えばこのような GitHub からキーワード検索できるアプリがあるとします。(元ネタは株式会社ゆめみ Android エンジニアコードチェック課題)

github_search.gif

この画面の UI 状態をこのように定義しました。items リストの1要素が LazyColumn 1行分の状態です。GitHub リポジトリ項目だけでなく、プログレスやエラー表示も1行分として定義しています。

HomeState.kt
data class HomeState(
    val keyword: String = "",
    val items: List<Item> = listOf(),
) {
    sealed class Item {
        data class Repo(val repo: GithubRepo) : Item()

        object Progress : Item()

        object NetworkError : Item()

        data class ServerError(val statusCode: Int) : Item()
    }
}

1行分の状態(HomeState.Item) から1行分の Composable 関数はこのように作れます。

HomeItem.kt
@Composable
fun HomeItem(item: HomeState.Item) {
    when (item) {
        HomeState.Item.Progress -> {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp),
                horizontalArrangement = Arrangement.Center,
            ) {
                CircularProgressIndicator()
            }
        }

        is HomeState.Item.Repo -> {
            Column(
                modifier = Modifier
                    .fillMaxWidth(),
            ) {
                Text(
                    modifier = Modifier.padding(16.dp),
                    text = item.repo.fullName,
                )
                Divider()
            }
        }

        HomeState.Item.NetworkError -> {
            Column(
                modifier = Modifier
                    .fillMaxWidth(),
                horizontalAlignment = Alignment.CenterHorizontally,
            ) {
                Text(
                    modifier = Modifier.padding(16.dp),
                    text = stringResource(R.string.error_network),
                )
            }
        }

        is HomeState.Item.ServerError -> {
            Column(
                modifier = Modifier
                    .fillMaxWidth(),
                horizontalAlignment = Alignment.CenterHorizontally,
            ) {
                Text(
                    modifier = Modifier.padding(16.dp),
                    text = stringResource(R.string.error_server, item.statusCode),
                )
            }
        }
    }
}

ここで重要なのは when(item) の部分です。例えば HomeState.Item.ServerError の実装を忘れている時はビルドエラーになり、赤くなっているところで Alt + Enter を押すことで足りないクラスに対応する分岐を自動作成できます。

スクリーンショット 2023-09-24 4.48.42.png

Swift の enums with associated values の例

同じものを Swift で書いてみます。

HomeStateItem.swift
enum HomeStateItem {
    case progress
    case repo(repo: GithubRepo)
    case networkError
    case serverError(statusCode: Int)
}

LazyVStack 1行分の View はこのようになります。

HomeItemView.swift
struct HomeItemView: View {
    var item: HomeStateItem
    
    var body: some View {
        switch item {
        case .progress:
            HStack(alignment: .center) {
                ProgressView().padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
            }.frame(maxWidth: .infinity)
        case .repo(let repo):
            Text(repo.fullName).padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
        case .networkError:
            Text(NSLocalizedString("network error", comment: "network error"))
                .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
        case .serverError(let statusCode):
            let format = NSLocalizedString("server error", comment: "network error")
            Text(String.localizedStringWithFormat(format, statusCode))
                .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
        }
    }
}

Kotlin の sealed class 同様に重要なのは switch item の部分です。例えば HomeStateItem.serverError の実装を忘れている時はビルドエラーになり、エラーをクリックして Fix ボタンを押すことで足りない値に対応する分岐を自動作成できます。

スクリーンショット 2023-09-24 6.46.43.png

通常、 Kotlin の sealed class は普通のクラスに変換される

前節で紹介した sealed class は Swift ではクラスに変換されます。

関連 Interoperability with Swift/Objective-C

よって前節で紹介した 1行分の sealed class のオブジェクトに対応する LazyVStack 1行分の View はこのように書く必要があります。

HomeItemView.swift
struct HomeItemView: View {
    let item: HomeState.Item

    var body: some View {
        switch item {
        case is HomeState.ItemProgress:
            HStack(alignment: .center) {
                ProgressView().padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
            }.frame(maxWidth: .infinity)
        case let item as HomeState.ItemRepo:
            Text(item.repo.fullName).padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
        case is HomeState.ItemNetworkError:
            Text(NSLocalizedString("network error", comment: "network error"))
                .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
        case let item as HomeState.ItemServerError:
            let format = NSLocalizedString("server error", comment: "network error")
            Text(String.localizedStringWithFormat(format, item.statusCode))
                .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
        default:
            EmptyView()
        }
    }
}

このコードには3点の問題があります。

  • default: EmptyView() の部分は呼ばれないですが必要です。無いと、 Switch must be exhaustive ビルドエラーになります。
  • 例えば HomeState.ItemServerError に該当する実装を忘れてもビルドエラーになりません。
  • 本来ならば HomeState.Item.Progress というネストされたクラスですが HomeState.ItemProgress のように、なぜかドットが1つしか無く2つ目のドットが消えています。

これをもって、私は個人的に KMP は iOS エンジニアの開発者体験が良くないのではと思っていました。もちろんビルド速度など他にも問題がある気がしていますが、この記事では sealed class と enums with associated values に限定して説明を進めます。

SKIE を使うと Kotlin の sealed class は Swift の enums with associated values に変換してくれる

ここで SKIE を導入していると、自動生成された onEnum 関数を使って、このように書くことができます。

HomeItemView.swift
struct HomeItemView: View {
    let item: HomeState.Item

    var body: some View {
        switch onEnum(of: item) {
        case .progress:
            HStack(alignment: .center) {
                ProgressView().padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
            }.frame(maxWidth: .infinity)
        case .repo(let data):
            Text(data.repo.fullName).padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
        case .networkError:
            Text(NSLocalizedString("network error", comment: "network error"))
                .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
        case .serverError(let data):
            let format = NSLocalizedString("server error", comment: "network error")
            Text(String.localizedStringWithFormat(format, data.statusCode))
                .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
        }
    }
}

onEnum 関数が sealed class のオブジェクトを enums with associated values に変換します。よって例えば .serverError を書き忘れている場合はエラーになり、足りない分岐を Xcode で生成することができます。

値の取り出し方に関する注意点

前節で説明した Swift だけで作った enums with associated values とは違い、一段オブジェクトを辿る必要があります。

data class Repo(val repo: GithubRepo) : Item()
// これは間違い
case let .repo(repo):
    Text(repo.fullName)
// このようにする
case let .repo(data):
    Text(data.repo.fullName)

onEnum 関数の中身

onEnum 関数の中身はこのようになっていて、enum が生成されていることが分かります。

public extension __SwiftGen.HomeState.Item {

  @frozen
  enum Enum {

    case networkError(__Skie.class__KmmGithubSearch_feature_home__com_tfandkusu_kgs_feature_home_HomeState_Item_NetworkError)
    case progress(__Skie.class__KmmGithubSearch_feature_home__com_tfandkusu_kgs_feature_home_HomeState_Item_Progress)
    case repo(__Skie.class__KmmGithubSearch_feature_home__com_tfandkusu_kgs_feature_home_HomeState_Item_Repo)
    case serverError(__Skie.class__KmmGithubSearch_feature_home__com_tfandkusu_kgs_feature_home_HomeState_Item_ServerError)

  }

}

public func onEnum<SEALED : __Skie.class__KmmGithubSearch_feature_home__com_tfandkusu_kgs_feature_home_HomeState_Item>(of sealed: SEALED) -> __SwiftGen.HomeState.Item.Enum {
  if let sealed = sealed as? __Skie.class__KmmGithubSearch_feature_home__com_tfandkusu_kgs_feature_home_HomeState_Item_NetworkError {
    return __SwiftGen.HomeState.Item.Enum.networkError(sealed)
  } else if let sealed = sealed as? __Skie.class__KmmGithubSearch_feature_home__com_tfandkusu_kgs_feature_home_HomeState_Item_Progress {
    return __SwiftGen.HomeState.Item.Enum.progress(sealed)
  } else if let sealed = sealed as? __Skie.class__KmmGithubSearch_feature_home__com_tfandkusu_kgs_feature_home_HomeState_Item_Repo {
    return __SwiftGen.HomeState.Item.Enum.repo(sealed)
  } else if let sealed = sealed as? __Skie.class__KmmGithubSearch_feature_home__com_tfandkusu_kgs_feature_home_HomeState_Item_ServerError {
    return __SwiftGen.HomeState.Item.Enum.serverError(sealed)
  } else {
    fatalError("Unknown subtype. This error should not happen under normal circumstances since local type: HomeState.Item is sealed.")
  }
}

SKIE の導入方法

SKIE の導入方法はシンプルです。iOS から使われるライブラリモジュールの build.gradle.kts にプラグインを追加するだけです。

build.gradle.kts
plugins {
    // 略
    // 追加
    id("co.touchlab.skie") version "0.4.20"
}

moko-kswift との違い

KMP において Kotlin の sealed class を Swift の enums with associated values に変換するソリューションとしては以前から moko-kswift がありました。しかし生成された swift ファイルを Xcode プロジェクトに取り込む設定が別途必要で、 iOS 開発の知識がまだ少ない私には難しく感じました。

既存プロジェクトに導入する場合は Migration Guide を読む

SKIE 公式サイトでは既存プロジェクトに導入する場合は Migration Guide を読むことを強く推奨しています。できるだけ SKIE 導入前の出力と互換性を保つようにしているようですが、型が変わるので、既存のコードが壊れる可能性があるらしいです。この件につきましては、今後、ある程度の規模がある既存プロジェクトに導入することがありましたら、何らかの形でその様子を公開できればと考えています。

まとめ

Kotlin の sealed class と Swift の enums with associated values は、ともに複数種類の値を保持し、安全に網羅性を担保しつつ取り扱うことができますが、KMP における sealed class は Swift では普通のクラスになっていました。そこで SKIE を使うことで Kotlin の sealed class は Swift の enums with associated values として出力されるようになり、KMP を導入したことによる iOS エンジニアの開発者体験減少を防ぐことができます。導入方法も iOS から使われるライブラリモジュールの build.gradle.kts にプラグインを追加するだけとシンプルです。既存のプロジェクトに導入する場合は、公式より Migration Guide を読むことを強く推奨されています。

27
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?