経緯
ある日 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 エンジニアコードチェック課題)
この画面の UI 状態をこのように定義しました。items
リストの1要素が LazyColumn 1行分の状態です。GitHub リポジトリ項目だけでなく、プログレスやエラー表示も1行分として定義しています。
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 関数はこのように作れます。
@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 を押すことで足りないクラスに対応する分岐を自動作成できます。
Swift の enums with associated values の例
同じものを Swift で書いてみます。
enum HomeStateItem {
case progress
case repo(repo: GithubRepo)
case networkError
case serverError(statusCode: Int)
}
LazyVStack 1行分の View はこのようになります。
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 ボタンを押すことで足りない値に対応する分岐を自動作成できます。
通常、 Kotlin の sealed class は普通のクラスに変換される
前節で紹介した sealed class は Swift ではクラスに変換されます。
関連 Interoperability with Swift/Objective-C
よって前節で紹介した 1行分の sealed class のオブジェクトに対応する LazyVStack 1行分の View はこのように書く必要があります。
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
関数を使って、このように書くことができます。
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
にプラグインを追加するだけです。
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 を読むことを強く推奨されています。