📖

Compose Multiplatform(KMP)で未来を切り拓く: @cosmeアプリの取り組み事例紹介

2023/09/07に公開

はじめに

こんにちは。
株式会社アイスタイルで@cosmeアプリのAndroidエンジニアをしている鈴木と申します。

今回は、Compose Multiplatformを@cosmeアプリにて、一部の画面でプロダクト導入しました。
そこで導入するにあたってどのような設計でいくのか、検討したことやハマった点、メリット・デメリットなどについて書き残しておこうと思います。
これからCompose Multiplatformを導入しようと考えている皆様に、役立つ情報であればと思います。
(この記事の情報は、2023/08時点での情報です。)

Compose Multiplatformとは?

iOS、Android、デスクトップ、ウェブといった複数のプラットフォームにて、Jetpack Composeを利用することができる仕組みです。
https://www.jetbrains.com/ja-jp/lp/compose-multiplatform/

Jetpack Composeとは、AndroidでのUI開発を行える最新のツールキットのことを指します。詳細についてはこちらをご覧ください。

つまり、複数のプラットフォームにて、Androidの最新のUI開発の仕組みでViewを共通化することが可能になりました。

Compose Multiplatform導入前の@cosmeアプリの設計

実は、@cosmeアプリではすでに Kotlin Multiplatform(KMP)を一部導入していました。
KMPとは、KotlinでAndroid, iOS, Desktop, Webそれぞれの環境で共通コードの共有ができる仕組みです。
古い画面ではまだKMP導入以前の設計が残っているものの、新しい画面ではKMPの設計で行うこととしています。
なお、Compose Multiplatform導入前の設計は次のとおりに定めています。

MVVMパターンに基づいて作成されており、UseCase・Repository・APIとDBについては、共通のコードをAndroidとiOSの両方で使用できるように、sharedモジュール内で作成しています。
ViewとViewModelに関しては、それぞれネイティブ側での実装を行っている形になります。

Compose Multiplatform導入後の@cosmeアプリの設計

Compose Multiplatform導入後は、導入前まで共通化できなかったViewとViewModelも含めてsharedモジュールにて、AndroidとiOS共通のコードで作成しています。

アプリの元々のアーキテクチャはMVVMパターンに基づいて設計されており、それを継続して採用しています。変わった点としては、shared側に一部移行した部分です。
より公式の形で定義するのであれば、UI Layer(View & ViewModel)、Domain Layer(UseCase)、Data Layer(Repositories & DataSources)という形になります。

Shared側とNative側で担う役割は次のとおりです。

Shared側

  • DB参照もしくは、API通信を行って共通のViewを表示するまでの流れを担います。
  • 取得したデータを元にして、どのような状態の時にどのViewを表示するのか、UIのデータを保持するかはShared側のViewModel内部で持ちます。

Native側

  • Shared側では公式にサポートされていない機能を主にこちらに記述します。
  • Nativeでのみ実行可能な処理を記述します。
  • 画面遷移、カメラ周りなどは、Native側で行うこととしています。
  • 画面間での情報の保持などは、Native側のViewModelで行います。

ライブラリ選定について

Compose Multiplatformに対応したライブラリはいくつかあります。画面遷移なども、それらのライブラリを使えば実現可能だと思います。

しかし、@cosmeアプリではそれらのライブラリを極力使わないようにすることにしました。 メンテナンスの継続性や、将来的な公式サポートへの移行の容易さに関する懸念から、外部ライブラリへの依存を最小限に抑える方針を採用しました。

原則として、公式に採用されたライブラリと、既に@cosme内で使用されているライブラリがCompose Multiplatformに適合した場合にのみ使用する方針を採用しています。

ただし、例外として、ローカル画像の読み取り部分に関しては、moko resourcesを使用することに特別に決定しました。

ViewModelについて

Shared側のViewModelは、現時点では公式に提供されていないため、自前で用意することになりました。将来的に公式のViewModelが提供される場合、それに移行する予定です。

自前で用意したViewModelは次のようになります。

open class ViewModel {
    val viewModelScope = CoroutineScope(Dispatchers.Main)
 
    open fun onCleared() {
        viewModelScope.cancel()
    }
}

Shared側でViewModelを作成する際には、このViewModelを継承して作成し、Native側ではAndroidとiOSでそれぞれインスタンス作成とCoroutineのcancelをOSごとの都合のいいタイミングで行うようにしています。

Shared側サンプル

class TestViewModel(
  private val testUseCase: TestUseCase
): ViewModel() {
   fun load() {
     // 何か取得する処理を書く
   }
}

Android側サンプル

Androidの場合、ViewModelのインスタンス生成は通常、onCreateメソッド内で行い、onDestroyメソッド内でonClearedを呼び出します。以下はFragmentの例です。...は省略を意味し、これ以降同様です。

class TestFragment : Fragment() {
  ...
  private val testUseCase: TestUseCase by KoinJavaComponent.inject(
        TestUseCase::class.java
  )
  private lateinit var viewModel: TestViewModel
  ...
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    viewModel = TestViewModel(testUseCase)
    viewModel.load()
  }

  override fun onCreateView(
      inflater: LayoutInflater,
      container: ViewGroup?,
      savedInstanceState: Bundle?
  ): View {
    ...
    return ComposeView(requireContext()).apply {
      setContent {
          setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
          TestScreenView(
            viewModel = viewModel,
            ...
            )
        }
      }
    }
    ...

    override fun onDestroy() {
        super.onDestroy()
        viewModel.onCleared()
    }
}

AndroidにおけるViewModelの生存期間は通常、onCreateからonDestroyまでの間です。

画面間で情報を共有する必要がある場合、通常、sharedViewModelという別のViewModelを使用し、このViewModelはNative側でも利用されることが想定されます。

Android側でViewModelをそのままInjectすることは可能かと疑問に思うかもしれません。Compose Multiplatformを利用する場合、Koinを使用してViewModelの生存期間を明示的に設定する方法が現時点では存在しないため、現在はKoinでUseCaseまでを管理しています。ただし、この点については今後変更される可能性もあります。

iOS側サンプル

iOSの場合、ViewControllerを初めて作成する際にmakeUIViewControllerが呼び出されるため、ここで初回のロードを実行しています。画面が破棄される際にはdismantleUIViewControllerが呼び出されるため、onCleared内でShared側のViewModelのタスクをキャンセルするようにしています。ViewModelの生存期間は通常、画面生成から画面破棄までの間です。

Androidと同様に、画面を跨いで情報を共有する必要がある場合、通常、別のViewModelがその役割を果たし、Native側でも利用されることが想定されます。

struct TestView: View {
    var body: some View {
        TestViewContlollerRepresentable()
    }
}

struct TestViewContlollerRepresentable: UIViewControllerRepresentable {
    private var viewModel: TestViewModel = TestViewModel(testUseCase: TestUseCase(repository: TestRepositoryImpl(apiClient: ApiClientImpl())))
    private var callBack: TestCallBack = TestCallBack()
    func makeUIViewController(context: Context) -> UIViewController {
        viewModel.load()
        return MainKt.TestViewController(viewModel: viewModel, callback: callBack)
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
    
    func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: ()) {
        viewModel.onCleared()
    }
}

Viewを利用する側は、UIHostingControllerを用いてrootViewに指定するのみです。

...
self.show(UIHostingController(rootView: TestView()), sender: self)
...

UiStateについて

@cosmeアプリでは、UIの状態をUiStateとして4つの状態を定義しています。

  • 完成系(Success)
    • UIの構築に必要な要素全てが揃っている状態のことを指します
  • エラー(Error)
    • エラーが発生した場合の状態のことを指します
  • ローディング状態(Loading)
    • 通信が発生していて、読み込み中の状態のことを指します
  • 空っぽの状態(Empty)
    • 要素が何もない場合の状態のことを指します

コードに落とした場合は、次のとおりです。

sealed interface TestUiState {
    object Loading : TestUiState
    data class Success(val uiModels: List<TestUiModel>) : TestUiState
    object Error : TestUiState
    object Empty : TestUiState
}

画面によっては、この状態が仕様上存在しないという場合もございますので、その場合は明記しなくて良いとしています。

UiStateの状態について保持するのは、ViewModel側の役割としています。
ViewModel側では次のようにして保持するようにします。

class TestViewModel(
    private val testUseCase: TestUseCase
) : ViewModel() {

    val uiState: MutableState<TestUiState> = mutableStateOf(TestUiState.Loading)
}

ComposeのMutableStateで、UiStateを保持するようにし、何かしらのイベントに応じてUIの状態が変わる場合には、MutableStateを更新する形を取ります。

class TestViewModel(
    private val testUseCase: TestUseCase
) : ViewModel() {
    val uiState: MutableState<TestUiState> = mutableStateOf(TestUiState.Loading)
    
    fun load() {
        uiState.value = TestUiState.Loading
        viewModelScope.launch {
            uiState.value = try {
                val result = testUseCase.getDatas()
                TestUiState.Success(
                    result.map { data ->
                        TestUiModel(data)
                    }
                )
            } catch (e: Exception) {
                TestUiState.Error
            }
        }
    }
}

View側については、ルートで表示するComposable関数をXXXScreen(XXXは任意の名称)とし、uiStateに応じてViewの表示を切り替える形にしています。

@Composable
internal fun TestScreen(
    viewModel: TestViewModel,
    callback: TestEventCallback
) {
    val state = viewModel.uiState
    when (state.value) {
        TestUiState.Loading -> {
            ProgressIndicator()
        }
        is TestUiState.Success -> {
            if ((state.value as TestUiState.Success).uiModels.isNotEmpty()) {
                TestList(
                    modifier = Modifier.fillMaxSize(),
                    uiModels = (state.value as TestUiState.Success).uiModels,
                    onClick = { callback.onClickItem(it) },
                )
            } else {
                EmptyList()
            }
        }
        TestUiState.Error -> {
            ReloadPage(onClickReloadButton = { viewModel.load() })
        }
    }
}

イベント周りについて

現状は、特に追加でFlowやLiveDataを持たずにしており、EventCallBackという形でinterfaceを定義して、Native側で実装する形を取っています。

interface TestEventCallback { 
    fun onClickItem(url: String)
    fun onRootClick()
}

interfaceを使用して定義することで、Shared側からNative側に受け渡す部分で、コールバックの引数を簡略化したかったためです。
例えば、interfaceを使用しない場合は次のようになります。

@Composable
fun TestScreenView(viewModel: TestViewModel, onClickItem: (String) -> Unit, onRootClick: () -> Unit) =
    TestScreen(viewModel, onClickItem, onRootClick)

次に、interfaceを利用する場合は、次のようにすっきりと表現されます。

@Composable
fun TestScreenView(viewModel: TestViewModel, callBack: TestEventCallBack) =
    TestScreen(viewModel, callBack)

iOSへ渡す際のコードは以下のようになります。

fun TestViewController(viewModel: TestViewModel, callback: TestEventCallback) =
    ComposeUIViewController { TestScreen(viewModel, callback) }

マルチタップや連続タップの場合にイベント処理を一つの流れにまとめたいというケースも出てくると思います。
現状では、@cosmeアプリではその方式を採用せずに進めていくことにしています。

イベント通知用のFlowやLiveDataを使わずにいるのは、次の理由からです。

  • iOSエンジニアもShared側のコードを書くため、学習コストを減らしたい
  • UiStateで、状態を見ることができるので、複数のイベントが発生し得る場合も基本的にはUiStateに応じて早期リターンなどの処理を行う想定でいるため
    • 通信中の場合でもう一度タップした場合などは、UiState.Loading中はタップしないようにする、など

現状では、上記の想定でいますが、今後イベントの流れ的にどうしても厳しい状況が発生した場合にはFlowを導入していくことも考えております。
導入する場合は、ViewModel側にEvent用のFlowを用意して、Screen側にてLaunchedEffectにてcollectを行う想定を考えています。コードで記載すると次の通りです。

@Composable
internal fun TestScreen(
    viewModel: TestViewModel,
    callback: TestEventCallback
) {
    val state = viewModel.uiState
    LaunchedEffect(Unit) {
        viewModel.eventsFlow
            .collect {
                println("TestEvent: collect")
                when (it) {
                    is TestEvent.OnClickItem -> {
                        println("TestEvent: OnClickItem")
                        callback.onClickItem(it.url)
                    }
                    TestEvent.Update -> {
                        println("TestEvent: Update")
                    }
                }
            }
    }
}

ログを出して、Recomposeが走った場合にLaunchedEffect内部に影響が出ないことまで確認しています。
イベント周りについては、状況を鑑みて柔軟に変えていきます。

ViewModelのテストについて

@cosmeアプリでは、すでにKMPが導入されていたため、UseCaseやRepositoryなどのテストはすでに作成されています。しかし、今回からViewModelが追加されたため、ViewModelのテストも新たに追加することになりました。

ViewModelのテストに関する観点は次のとおりです。

  • ViewModelの関数を呼び出した後、UiStateの変化に関するテストケースを追加します。
  • ViewModelにデータ加工やロジックが含まれている場合、それらのロジックに関するテストケースも追加します。

ViewModelでは、ComposeのMutableStateを使用してUiStateを管理しているため、UiStateの変化が意図通りに行われているかを必須としてテストします。
また、ViewModelのテスト作成にあたり、Local Unit TestでMutableStateの値がうまく変化しない現象が発生しました。
この問題に対処するため、下記リンクを参考にし、@cosmeアプリの仕組みに合わせて対応しました。
参考リンク: ComposeのMutableStateってどうやってLocal Unit Testすれば良いの?

その他、実装時に気になった点

プレビュー表示について

プレビュー表示は、Compose Multiplatformではまだ使うことができません。
androidx.compose.ui:ui-toolingをsharedモジュール内のcommonMainにて依存関係を追加しようとすると、エラーになります。
そこで、androidMain側にてandroidx.compose.ui:ui-toolingを追加することで、この問題を回避することができます。
sharedのbuild.gradle.kts内のandroidブロックにて、次のように追加します。

android {
  ...
  buildFeatures {
    ...
    compose = true
  }
  ...
  composeOptions {
    kotlinCompilerExtensionVersion = "1.5.0"
  }
  dependencies {
    debugImplementation("androidx.compose.ui:ui-tooling:1.4.3")
    implementation("androidx.compose.ui:ui-tooling-preview:1.4.3")
  }
  ...
}

あとは、androidMain側で好きなディレクトリにてPreview.ktファイルを作成してPreviewのソースを記載すると、参照することができるようになります。

謎のObjective-Cのエラーが出た場合

Compose Multiplatform導入時に単純に依存関係を追加しただけでは、下記のようなObjective-Cのエラーが表示されることがありました。関連するライブラリを更新したとしても起きた時に、エラー内容から何が原因かがわからずハマってしまったことがありました。

Undefined symbols for architecture x86_64:  "_OBJC_CLASS_$_MTLCommandBufferDescriptor", referenced from:
      objc-class-ref in libskia.a(gpu.GrMtlCommandBuffer.o)

エラー内容からは、読み取ることが難しく原因がわからずに難航しておりました。
しかし、解決方法として、一つずつコメントアウトしながら原因を探ってみたところ、結果として、@cosmeアプリではすでにKMPを導入していたことからcommonTestにてtestを書いていたのですが、test周りの依存関係が原因であることがわかりました。
おそらく、test周りがまだCompose Multiplatformに対応できておらず途中でエラーとなってObjective-Cのエラーが表示されているのではないかと考えています。
解決方法として、testをモジュールごと分けることで解決できました。もし、Objective-Cのエラーが出てよくわからない場合はtestを疑ってみると良いかもしれません。

Resource周りについて

アプリの開発において、Color、String、画像などのリソース管理は重要です。現在の方針と検討事項を以下に示します。

Color、Stringなど

Android公式の実装に合わせて、専用のKotlinファイルを作成し、ColorやStringなどを定義します。Colors.ktやStrings.ktのようなファイルを作成し、そちらにまとめる形を取っています。

ローカル画像

独自で作成したアイコンなどのローカル画像に関しては、現時点で公式のサポートがないためmoko resourcesを活用しています。

通信した結果の画像表示

現在、Android側ではcoilというライブラリを使用しています。
coilがまだComposeMultiplatformに対応できていないものの、coil-kt/coil#842 (comment)にある通り、今後対応されそうなため、coilの開発に関する情報を追跡し、新しいバージョンがリリースされるのを待つ姿勢です。

また、coilが対応されるまでの間にこのような処理を行いたい場合は、他の代替ライブラリ(Kamelなど)を一時的に利用することも検討しています。

@cosmeアプリにおいては、Compose Multiplatform導入箇所において現時点で画像を利用していないため、画像関連の方針は検討段階になります。

Compose Multiplatform導入のメリット

Compose Multiplatformの導入には、以下の3つのメリットがあります。

実装にかかる工数の大幅な削減

Compose Multiplatformを導入することで、AndroidとiOSでのアプリ実装にかかる工数を大幅に削減できます。共通のViewを作成し、OSごとの差異を最小限に抑えるため、AndroidとiOSそれぞれのプラットフォームにおいてViewまでの独自の実装を用意する必要がなくなります。Shared側でViewの表示と、OSごとのタイミングに応じてViewModelの関数をコールする部分と画面遷移部分をNative側で実装するため、Native側の負担が大幅に軽減されます。

OS間でのロジックの違いをなくせる

AndroidとiOSのアプリ実装では、コミュニケーションのミスや実装者間の認識の違いにより、微妙な差異が生じることがあります。Compose Multiplatformを使用することで、ViewからViewModelまでの共通化により、OS間での差異がほぼなくなります。さらに、AndroidとiOSのエンジニアがそれぞれ1名ずつ選出されてレビューを行う体制をとることで、認識違いなどの問題を事前に解決できます。

AndroidエンジニアがiOSアプリにも貢献できる

Compose Multiplatformの導入により、AndroidエンジニアはiOSアプリにも貢献できるようになります。特にiOSアプリのユーザー数が多い場合、iOSアプリの影響力が大きくなります。この仕組みにより、Androidエンジニアの貢献度合いも大きくなり、組織全体の成果に対する寄与が増加することが期待されます。

Compose Multiplatformの導入は、開発工数の削減、OS間での一貫性の確保、エンジニア間の協力強化など、多くのメリットをもたらします。組織の開発プロセスやプロジェクトに合わせて最適な導入方法を検討することで、これらのメリットを最大限に活用できます。

Compose Multiplatform導入のデメリット

Compose Multiplatformを導入する際には、以下の3つのデメリットに注意が必要です。

iOS独自のViewがあるのでデザイン面で困る

iOSとAndroidのプラットフォームは、独自のデザイン要素やViewを持っています。特にiOSでは、Segmented ControlやLoading表示など、iOSらしいデザイン要素が存在します。Compose Multiplatformでは共通のViewを作成することが重要ですが、iOS特有のデザインを再現することが難しい場合があります。
この課題については、1つはFlutter等でも共通にしている場合があるのでそれほど気にしなくても良いのではないかということと、もう1つはComposeでそれらしい見た目のViewを作ることは可能だと思うのでexpect/actualで分けてしまう方針にしました。
Native側で作る案もありましたが、せっかくViewまで共通化しているので一部切り出してNativeでというのはややこしいことになりそうだったので、@cosmeアプリとしては、Composeでそれらしい見た目のViewをComposeで作っていく方針にしました。

今回は、実際のプロダクトにおいてProgressIndicatorを次のように実装しました。

Shared側

expectで関数を定義するのみです。

@Composable
expect fun ProgressIndicator()

Android側

androidMainにて、Android側の読み込み中の表示をComposeで作成しています。

@Composable
actual fun ProgressIndicator() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        CircularProgressIndicator(
            color = Color.Green
        )
    }
}

iOS側

iOSMain側で、iOSの標準のプログレス表示に寄せたViewをComposeで作成しています。
SpinningProgressBarについては、すでに作成されていた方がいたので参考にし、サイズが大きかったので若干半径を調整した形で対応しました。
(https://stackoverflow.com/questions/74199391/implement-a-spinning-activity-indicator-with-jetpack-compose)

@Composable
actual fun ProgressIndicator() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        SpinningProgressBar()
    }
}

既存への導入が難しい

長い歴史のアプリですと、そう簡単に変えることができません。もちろん工数をとってやれば可能だと思いますが、いろんな事情ですぐには対応できないことがザラにあります。
そのため、@cosmeアプリでは既存部分でNativeですでに書かれているところに関しては、それぞれのNative側で対応とし、新規画面に関してはCompose Multiplatformの仕組みを入れた新しいパターンで対応していくこととしています。
組織やチーム構成に応じてどこまで共通化するのか、というのを明確に定めておく必要があると思います。

iOSエンジニアの学習コストが増える

学習コストが増えるのは確かだと思います。
Kotlinが未経験だとまずはKotlinについて学ぶところから始まってしまうと思います。
ただし、今回、@cosmeアプリではすでにKMPが入っていたので、ある程度慣れてきているというのもあって同意も得られたことから導入をしています。
とはいえ、Composeだけは初見になってしまうので、多かれ少なかれ学習コストは増えてくると思います。
チーム状況や組織によって、iOSチームの同意やSharedだけを触るチームを別に作るなど、そういった対応が必要になってきそうです。

Compose Multiplatformの導入には多くの利点がありますが、デメリットも存在します。これらのデメリットを克服するために、慎重な計画と適切なリソースの配置が必要です。プロジェクトの要件や状況に合わせて、最適な導入戦略を検討することが重要と考えています。

おわりに

@cosmeアプリでのCompose Multiplatformに対する取り組みについてご紹介しました。まだ試行錯誤の段階ではありますが、共通のViewまでを共有する開発を推進していく予定です。今後の展開に注目していただければ幸いです。

この記事を読んで、@cosmeアプリに興味を持った方、またはCompose Multiplatformを使った開発に参加したい方は、下記の応募リンクからご応募いただけます。一緒に素晴らしいプロジェクトを作っていきませんか?ご応募お待ちしております。

Androidエンジニア

https://open.talentio.com/r/1/c/isytyle_career/pages/43022

iOSエンジニア

https://open.talentio.com/r/1/c/isytyle_career/pages/43019

株式会社アイスタイル

Discussion