【連載】Compose Multiplatformで自社のクローンアプリを作る - スターターリポジトリの紹介
はじめに
モバイルアプリをマルチプラットフォーム向けにワンソースで開発する手段はいくつかありますが、その中でもJetBrainsが提供するCompose Multiplatformは、Androidアプリ開発と同じ技術スタックでiOSやDesktop、Web向けに開発でき、Androidエンジニアにとっては学習コストをあまりかけずに導入可能です。また、共通化する範囲も自由に設計可能なため、非常に柔軟性の高いフレームワークになっています。
この連載記事では、Compose Multiplatform x KMPで自社のクラシルリワードというアプリのクローンアプリを開発し、現状どこまでプラットフォーム固有のコードを書くことなく開発できるのか、またそのアプリパフォーマンスについて探っていきたいと思います。今回はその第一弾となる記事です。
スコープ
この記事では、今回の活動に当たって開発した、クローンアプリを開発していく土台となるスターターリポジトリについて紹介します。このリポジトリは、Compose Multiplatformを使用した開発を誰でもすぐに始められるように、基本的なモジュール構成と依存関係をすでに含んでいます。
開発環境
この記事の内容は、以下の環境で動作を確認しています。
- Compose Multiplatform 1.7.0
- Android Studio 2024.1.1
- Kotlin 2.0.20
- Xcode 15.4
- Swift 5.10
ちなみに最初はJetBrains FleetのPreview版で開発していましたが、commonMainにあるComposableのプレビューが可能であることは確かにメリットではあるものの、2~3時間に一回は再起動が必要な不安定さとIDE機能の弱さから、結局ずっとAndroid Studioで開発していました。
サマリー
スターターリポジトリではサンプルコードとしてZennの記事一覧画面を実装しています。ユーザ操作をトリガーにネットワークからデータを取得し、そのデータをUIの状態へ変換して画面上に表示するまでの一連の処理が確認できるものになっています。
※サンプルコードは実際のプロダクトコードとは異なります
Pixel 3a - Android 12 | iPhone XS - iOS 18.0 |
---|---|
※より精細なキャプチャを下記のリポジトリページで確認することができます。
スターターリポジトリの紹介
このリポジトリは極力標準的な設計を用いているため、AndroidアプリをMVVMで実装したことがあれば良く見る構成になっているかと思います。そのため、詳しい説明は省略していますが、より詳細が気になる方はぜひ上記からサンプルコードをご覧ください。
アーキテクチャ
アーキテクチャの全体像は下図の通りで、基本的にはGoogleが推奨するAndroid Achitecture GuideとAndroid Modularization Guideに即した典型的なレイヤードアーキテクチャで開発していきます。ただし、複雑なビジネスロジックをあまり持たないアプリの場合、ドメイン層がデータ層の単なるラッパーになることも多いため、単一のユースケースを表現した(単一メソッドを持った)UseCaseクラスではなく、複数のユースケースを大まかなドメイン単位で分類したServiceクラスをドメイン層の実装として用います。
プロジェクト構成
サンプルコードの例になりますが、実際のモジュール構成とクラス群は以下のようになります。
見やすくするためディレクトリ構造は簡略化しています。
プロジェクトツリー
(): モジュールディレクトリ, []: モジュール, <>: プラットフォーム固有ディレクトリ
org.starter.project
├── composeApp
├── iosApp
└── (module)
├── [base]
│ ├── data.model.zenn
│ │ └── Articles.kt
│ ├── error
│ │ ├── ApiError.kt
│ │ └── ConversionError.kt
│ └── extension
│ ├── ConversionErrorExtension.kt
│ └── ResultExtension.kt
├── [core]
│ ├── <androidMain>
│ │ └── api
│ │ └── ApiClient.android.kt
│ ├── <commonMain>
│ │ ├── api
│ │ │ ├── ApiClient.kt
│ │ │ └── ApiConfig.kt
│ │ └── preferences
│ │ ├── PreferencesConfig.kt
│ │ └── PreferencesKey.kt
│ └── <iosMain>
│ └── api
│ └── ApiClient.ios.kt
├── (data)
│ ├── [repository]
│ │ ├── Repository.kt
│ │ └── ZennRepository.kt
│ └── [zenn]
│ ├── converter
│ │ └── ArticlesConverter.kt
│ ├── datasource
│ │ ├── api
│ │ │ ├── ZennApi.kt
│ │ │ └── response
│ │ │ └── ArticlesResponse.kt
│ │ └── preferences
│ │ └── ZennPreferences.kt
│ └── repository
│ └── ZennRepositoryImpl.kt
├── (domain)
│ ├── [service]
│ │ ├── ResultHandler.kt
│ │ ├── Service.kt
│ │ └── ZennService.kt
│ └── [zenn]
│ └── ZennServiceImpl.kt
├── (feature)
│ └── [home]
│ ├── HomeScreen.kt
│ ├── HomeScreenEvent.kt
│ ├── HomeScreenState.kt
│ ├── HomeScreenViewModel.kt
│ └── component
│ ├── article
│ │ ├── ArticleList.kt
│ │ └── ArticleListItem.kt
│ └── paging
│ └── ArticlesPagingSource.kt
└── [ui]
├── design.system
│ ├── loading
│ │ └── SystemLoadingIndicator.kt
│ ├── scaffold
│ │ └── SystemScaffold.kt
│ ├── search
│ │ └── SystemSearchBar.kt
│ └── theme
│ └── SystemTheme.kt
└── shared
├── event
│ └── ScreenEvent.kt
├── handler
│ ├── ErrorScreenThrowableHandler.kt
│ ├── IgnoreThrowableHandler.kt
│ └── SnackBarThrowableHandler.kt
└── state
└── ScreenState.kt
依存関係
このリポジトリには以下のCompose Multiplatform及びKMP対応の3rd party製ライブラリへの依存を含めています。
ライブラリ | バージョン | 説明 |
---|---|---|
koin | 4.0.0 | Dependency Injection を利用できるようにします。 |
multiplatform-settings | 1.2.0 | SharedPreferencesのような key-value のデータストアを簡単に扱えるようにします。 |
ktor | 2.3.12 | HTTPクライアントのコアライブラリです。クライアントエンジンとして、Android向けにはAndroid、iOS向けにはDarwinを使用しています。 |
Ktorfit | 2.1.0 | Retrofitのような使い勝手でAPIを定義できるようにします。 |
multiplatform-paging | 3.3.0-alpha02-0.5.1 | PagingをMultiplatformで使用できるようにします。 |
coil | 3.0.0-rc01 | URLから非同期に画像を取得する際に使用します。 |
Mokkery | 2.4.0 | ユニットテストでインタフェースをモッキングする際に使用します。 |
Napier | 2.7.1 | ロギングで使用します。このリポジトリではデバッグビルド時のみ出力するように実装しています。 |
スターターリポジトリの詳細
標準的な設計を用いているものの、一部独自性のある部分について補足しておきます。
エラーハンドリング
特にViewModelScopeでドメイン層のビジネスロジック(またはリポジトリ層のデータロジック)を実行する際に、そこで発生する可能性のある例外をどのようにハンドリングして、UI状態に変換するかは工夫のしがいがある部分だと思いますが、このリポジトリでは以下のようにハンドリングしています。
ベースにしているのはAndroid Architecture Guideの「エラーを公開する」で説明されている、Resultクラスを用いた実行結果と既知のエラーのラッピングです。
ドメイン層からの返り値を常にResultクラスでラップすることで、UI層では適切にアンラップしないと実行結果を取り出せず、例外ハンドリングの実装漏れが発生しづらいように工夫しています。また、プロダクト共通の例外ハンドラーを実装しておくことで、エラー発生時に一貫したユーザ体験を提供できるようにしています。
サンプルコード
// ResultHandler.kt
class ResultHandler(
val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend inline fun <Success> async(
dispatcher: CoroutineDispatcher = this.dispatcher,
crossinline block: suspend () -> Success
): Result<Success> {
return withContext(dispatcher) {
try {
val value = block()
Result.success(value)
} catch (e: Throwable) {
coroutineContext.ensureActive()
Result.failure(e)
}
}
}
}
// ZennServiceImpl.kt
open class ZennServiceImpl(
private val resultHandler: ResultHandler,
private val zennRepository: ZennRepository
) : ZennService {
override suspend fun fetchArticles(
keyword: String,
nextPage: String?
) = resultHandler.async {
// buisiness logic
}
}
// HomeScreenViewModel.kt
class HomeScreenViewModel(
private val zennService: ZennService
) : ViewModel() {
internal suspend fun fetchArticles(key: String?): Articles? {
return zennService.fetchArticles(
keyword = _state.value.searchKeyword,
nextPage = key
).handle(ErrorScreenThrowableHandler(_screenState))
}
}
// ResultExtension.kt
inline fun <Success> Result<Success>.handle(
handler: (Throwable) -> Unit,
shouldCancelScope: CoroutineScope? = null
): Success? {
return this.getOrElse {
handler(it)
shouldCancelScope?.cancel(it.message.orEmpty())
null
}
}
// ErrorScreenThrowableHandler.kt
object ErrorScreenThrowableHandler {
inline operator fun invoke(state: MutableStateFlow<ScreenState>): (Throwable) -> Unit = { t ->
state.update {
it.copy(
screenLoadingState = ScreenLoadingState.Failure(
showLoading = false,
throwable = t
)
)
}
}
}
androidContextのDI
SharedPreferencesを使用する際など、様々な場面で必要になるandroid.content.Contextですが、このリポジトリではKoinを使用して以下のように必要なモジュールへ注入できるようにしています。
commonMainでは下記のように既存のKoinContextを使用してアプリを構成するようにします。
// Main.kt
@Composable
fun Main() {
KoinContext {
SystemTheme {
// Root Screen
}
}
}
また、プラットフォーム共通の依存性を以下のように定義します。このとき、プラットフォーム固有の定義とモジュールを差し込めるようにしておきます。
// Koin.kt
expect val platformModule: org.koin.core.module.Module
fun startKoin(platformDeclaration: KoinAppDeclaration? = null) {
org.koin.core.context.startKoin {
platformDeclaration?.invoke(this)
val coreModule = module {...}
val dataSourceModule = module {...}
val repositoryModule = module {...}
val serviceModule = module {...}
val appModule = module {...}
modules(
platformModule,
coreModule,
dataSourceModule,
repositoryModule,
serviceModule,
appModule
)
}
}
// Koin.android.kt
actual val platformModule = module {
single<Settings.Factory> { SharedPreferencesSettings.Factory(androidContext()) }
}
// Koin.ios.kt
actual val platformModule = module {
single<Settings.Factory> { NSUserDefaultsSettings.Factory() }
}
最後に実際のKoinContextを各プラットフォームの起動時に開始します。このとき、Androidプラットフォーム側ではandroidContextを定義しておきます。
// androidMain/App.kt
class App : Application() {
override fun onCreate() {
super.onCreate()
startKoin { // ←上記で定義したcommonMainにあるstartKoin
androidContext(this@App)
}
}
}
// iosMain/MainViewController.kt
fun MainViewController() = ComposeUIViewController(
configure = { startKoin() } // ←iOS側では特に何も差し込まない
) { Main() }
このようにKoinを構成することで、Androidプラットフォーム側のモジュールでは安全にandroidContextの参照を扱うことができるようになります。
ビルドタスク
Compose Multiplatformのプロジェクトをビルドすると、iOS向けには必要な依存関係を全て含んだ単一のStatic Frameworkが生成されます。デフォルトでは ComposeApp という名前で作成され、そのXCFrameworkを取り込むことでiOS側では共通コードを参照することができます。
このとき、マルチモジュール構成のプロジェクトでは、ビルドタスクにおいてきちんと全てのモジュールに対するexportの記述が必要になります(参照)。このリポジトリでは以下のような工夫で常に漏れなくモジュールがexportされるようにビルドタスクを構成しています。
// build.gradle.kts (:composeApp)
kotlin {
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
allSubProjects(rootDir) { export(project(":$it")) }
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
allSubProjects(rootDir) { api(project(":$it")) }
}
}
}
// BuildUtils.kt
fun allSubProjects(rootDir: File, action: (String) -> Unit) {
rootDir.resolve("module").walk().maxDepth(3).filter {
it.isDirectory && it.resolve("build.gradle.kts").exists()
}.forEach {
val path = it.relativeTo(rootDir).path.replace(File.separator, ":")
action(path)
}
}
おわり
さて、今回はスターターリポジトリの紹介までとなりましたが、次回からはこのリポジトリのコードベースを元にクラシルリワードのクローンアプリを段階的に開発していき、各パートで得られた知見を共有していこうと思います。
Compose Multiplatformはアプリ全体をそれ単体で作ることもできますし、既存のネイティブアプリにライブラリとしてUIとビジネスロジック(一部の画面郡やコンポーネントなど)を部分的に提供することもできます。非常に強力な開発ツールであることは間違いないので、引き続きウォッチして頂ければと思います。
Discussion