Android mobile

State Holder を用いた Jetpack Compose のプレゼンテーションロジックについて

Android mobile

Jetpack Compose がリリースされて UI 実装が大きく変わってきていると思います。さまざまなメリットからプロダクトに導入しているところも多いのではないでしょうか。minne でも積極的に導入している最中です。

Jetpack Compose は Kotlin で UI コンポーネントが記述できるためコーディングにかかる物理的・精神的コストが下がるとは思いますが、xml で強制的にレイアウトファイルが分かれていた従来の UI 実装とは異なりコンポーネントとロジックが混在しやすくなり注意して実装しないと逆に可読性の低下にもつながります。

今回は State Holder の考え方を元に minne で採用した Jetpack Compose における UI コンポーネントとプレゼンテーションロジックの責務の分離方法を解説していきます。

環境

使用する Jetpack Compose のバージョンは以下です。また Dagger Hilt で ViewModel などを DI しているので Hilt のバージョンも記載しています。

androidx.compose : 1.2.0-rc01
com.google.dagger:hilt-android : 2.41
androidx.hilt:hilt-navigation-compose : 1.0.0

State Holder

State Holder (状態ホルダー) とは、ViewModel と Composable を繋ぐレイヤーに位置し UI コンポーネントの状態管理やプレゼンテーションロジックを担当したりします。

そもそも ViewModel が必要かと言う議論もあると思いますが、minne では Hilt などの関係で AAC の ViewModel を データホルダー & ドメインレイヤーとのタッチポイントとして利用し、その ViewModel のデータを利用して今までは Acitivty や Fragment で行っていたようなステート管理・イベントハンドリングなどを State Holder で解決しています。

実装

ユーザ情報を入力するプロフィール画面を例に解説していきます。

ViewModel

まずは ViewModel を用いて Composable が利用するデータホルダーを作成します。(UseCase などについては こちら で運用について解説しています。)

@HiltViewModel  
class ProfileViewModel @Inject internal constructor(private val useCase: ProfileUseCase) : ViewModel() {
  
    data class UiState(
        val firstName: String = "",
        val lastName: String = "",
        val loadingState: LoadingState = LoadingState.LOADED
    ) {  
        companion object {  
            val default = UiState()  
        }  
    }  
  
    sealed interface UiEvent {  
        data class Error(val e: Throwable) : UiEvent
        data class InvalidEntry(val e: InvalidCause) : UiEvent
        object SaveComplete : UiEvent
    }  
  
    private val _uiState: MutableStateFlow<UiState> = MutableStateFlow(UiState.default)  
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()  
  
    private val _uiEvent: Channel<UiEvent> = Channel()  
    val uiEvent: Flow<UiEvent> = _uiEvent.receiveAsFlow()

    fun fetch() {
        useCase.fetch()
            .onSuccess { user ->
                _uiState.update { state ->
                    state.copy(
                        firstName = user.firstName,
                        lastName = user.lastName
                    )
                }
            }
            .onFailure { e ->
                _uiEvent.send(UiEvent.Error(e))
            }
    }

    fun enterFirstName(firstName: String) {
        _uiState.upddate { it.copy(firstName = firstName) }
    }

    fun enterLastName(lastName: String) {
        _uiState.upddate { it.copy(lastName = lastName) }
    }

    fun save() {
        _uiState.update { it.copy(loadingState = LoadingState.LOADING) }

        val uiState = _uiState.value
        useCase.save(
            firstName = uiState.firstName,
            lastName = uiState.lastName
        )
            .onSuccess {
                _uiState.update { it.copy(loadingState = LoadingState.LOADED) }
                _uiEvent.send(UiEvent.SaveComplete)
            }
            .onFailure { e ->
                _uiState.update { it.copy(loadingState = LoadingState.LOADED) }
                _uiEvent.send(
                    when (e) {
                        is ProfileUseCaseError.InvalidEntry -> UiEvent.InvalidEntry(e.cause)
                        else -> UiEvent.Error(e)
                    }
                )
            }
    }
}

基本的には Google のアーキテクチャガイド に従っているため UI に表示するデータは UiState としてカプセル化しています。

ガイドと異なる点としては、State と Event はそもそもデータの性質が異なるので分けています。State はリアルタイムに現在のデータを垂れ流すメリットがあるので StateFlow で公開しますが、 Event は基本的に one-shot なもので State と異なり消費すると言うプロセスが存在する ため SingleLiveData の様な挙動を期待して Channel.receiveAsFlow() を利用して公開しています

公式のガイドでは Event も UiState に含めイベントを消費したことを ViewModel に伝えるような実装になっていますが、Event を消費したかどうかは ViewModel は意識したくないためこのような実装としました。

State Holder

今回のメインですが、上記で記載した ViewModel を利用して State Holder を実装します。

interface ProfileScreenState {
    val uiState: ProfileViewModel.UiState  
        @Composable get  
  
    val scaffoldState: ScaffoldState  
  
    fun onBackPressed() {}
    fun enterFirstName(firstName: String) {}
    fun enterLastName(lastName: String) {}
    fun onSaveClick() {}
}
class RealProfileScreenState(
    private val viewModel: ProfileViewModel,
    override val scaffoldState: ScaffoldState,
    private val coroutineScope: CoroutineScope,
    private val navController: NavHostController,
    private val context: Context,
    private val lifecycleOwner: LifecycleOwner
) : ProfileScreenState {

    override val uiState: ProfileViewModel.UiState  
        @Composable get() = viewModel.uiState.collectAsState().value

    init {
        viewModel.uiEvent.collectOnLifecycle(coroutineScope, lifecycleOwner) {  
            when (it) {
                is UserEntryViewModel.UiEvent.InvalidEntry -> {  
                    scaffoldState.snackbarHostState.showSnackbar(
                        message = context.getString(R.string.message_invalid_entry)
                    )
                }
                is UserEntryViewModel.UiEvent.Error -> {
                    scaffoldState.snackbarHostState.showSnackbar(message = it.e.message)
                }
                is UserEntryViewModel.UiEvent.SaveComplete -> {
                    navController.navigate("next_screen")
                }
            }
        }

        viewModel.fetch()
    }

    override fun onBackPressed {
        navController.navigateUp()
    }

    override fun enterFirstName(firstName: String) = viewModel.enterFirstName(firstName)
    override fun enterLastName(lastName: String) = viewModel.enterLastName(lastName)
    override fun onSaveClick() = viewModel.save()
}

@Composable  
fun rememberProfileScreenState(  
    viewModel: ProfileViewModel = hiltViewModel(),  
    scaffoldState: ScaffoldState = rememberScaffoldState(),  
    coroutineScope: CoroutineScope = rememberCoroutineScope(),  
    navController: NavHostController = LocalNavController.current,  
    context: Context = LocalContext.current,
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
): ProfileScreenState = remember {
    RealProfileScreenState(viewModel, scaffoldState, coroutineScope, navController, context, lifecycleOwner)
}

val LocalNavController = staticCompositionLocalOf<NavHostController> {
    error("CompositionLocal LocalNavController not present")
}

画面に関しては次で説明しますが、StateHolder は ~Screen と言う名前の Composable 関数に対して ~ScreenState が 1 : 1 となるように用意しています。また、Preview のために State Holder は interface を用意しています。

State Holder では画面遷移やエラーメッセージを Snackbar に表示するなどのプレゼンテーションロジックや ViewModel からのイベントのハンドリングなどを行います。イベントハンドリングは Lifecylce も考慮して以下の様な拡張関数を用意しています。

inline fun <T> Flow<T>.collectOnLifecycle(  
    coroutineScope: CoroutineScope,  
    lifecycleOwner: LifecycleOwner,  
    observationState: Lifecycle.State = Lifecycle.State.STARTED,  
    crossinline action: suspend (value: T) -> Unit  
) {  
    coroutineScope.launch {  
        lifecycleOwner.repeatOnLifecycle(observationState) {  
            collect {  
                action(it)  
            }  
        }    
    }
}

UiState を State 経由で公開し、Scaffold を利用して Snackbar の表示を行っているため ScaffoldState を State Holder に保持して Composable が同じインスタンスを利用できるように公開します。今は基本的に suspend 関数は State Holder 内で完結していますが今後コンポーネント内に必要となってきたら CoroutineScope も公開するといいかもしれません。

テキストは多言語対応のためかつ Composable に置き換えられていない画面も多数あり、自前で if 分岐させるより楽なので resource 管理しています。それもあり State Holder は Composable 関数ではないのでどうしても Context が必要になってしまっています。

画面遷移では Navigation Compose を利用しています。なので NavHostController を State Holder に保持し遷移処理を行います。(実際はマルチモジュール間での遷移を考慮して NavController をラップしたクラスを用いて運用しています。)

ScaffoldState などを保持しているため Composable 関数内では remember でインスタンスを保持する必要があるので慣例に習って getter を定義しています。

Composable の画面

minne では画面実装にデザイナーチームが先に Atomic Desing を採用していたため足並みを揃えると言う意味でも Atomic Desing を採用しています。いわゆるページ・画面と呼ばれるものを minne では ~Screen と言う名前の Composable 関数を作成して表現しています。

State Holder にプレゼンテーションロジックを切り出すことができたため、Composable 関数内は UI コンポーネントのみに注力できるようになりました。

@Composable
fun UserEntryScreen(  
    state: ProfileScreenState = rememberProfileScreenState(),  
    modifier: Modifier = Modifier.fillMaxSize()  
) {  
    val uiState = state.uiState

    Scaffold(
        scaffoldState = state.scaffoldState,
        modifier = modifier
    ) {
        Column {
            TextField(
                value = uiState.firstName,
                onValueChange = { state.enterFirstName(it) }
            )

            TextField(
                value = uiState.lastName,
                onValueChange = { state.enterLastName(it) }
            )

            Button(
                onClick = { state.onSaveClick() }
            ) {
                Text(text = "保存")
            }
        }
    }
}

以上が State Holder を用いた Composable とのロジックの切り分けについてです。せっかくなのでここからは State Holder に関連した少し特殊なケースの運用を記載します。

Dialog の表示について

Dialog の表示は基本的に何かしらのイベントがあったときに行うものであり、特にカスタムダイアログで表示する値が変わるような場合は複雑なものだと引数も増えるので Screen の UiState には含めず Dialog 用の Controller で管理し remember で State Holder に保持します。

Jetpack Compose の Dialog は普段は gone で隠しておいて必要になったら visible に切り替えるような挙動なので少し違和感があります。

class ConfirmDialogController {

    var isShow by mutableStateOf(false)
        private set

    var name by mutableStateOf("")
        private set

    fun show(name: String) {
        this.name = name
        isShow = true
    }

    fun dismiss() {
        isShow = false
    }
}

@Composable  
fun rememberConfirmDialogController() = remember { ConfirmDialogController() }
class RealProfileScreenState(
    ...
    override val confirmDialogController: ConfirmDialogController
) {
    override fun onSaveClick() {
        val uiState = _uiState.value
        confirmDialogController.show(name = "${uiState.lastName} ${uiState.firstName}")
    }

    override fun cancel() {
        confirmDialogController.dismiss()
    }

    override fun save() {
        confirmDialogController.dismiss()
        viewModel.save()
    }
}

@Composable
fun ProfileScreen(
    state: ProfileScreenState = rememberProfileScreenState(),
    ...
) {
    val dialogController = state.confirmDialogController
    if (dialogController.isShow) {
        Dialog(onDismissRequest = { state.cancel() }) {
            Text(text = dialogController.name)

            Button(
                onClick = { state.save() }
            ) {
                Text(text = "保存")
            }
        }
    }
}

ActivityResult と State Holder

minne ではまだ Activity 間の画面遷移もあり、外部 SDK から onActivityResult 経由でデータを取得する機会などもまだまだ存在します。そのため ActivityResultContract を利用できるようにbase class を作成して State Holder で受け取れるようにしています。Fragment の registerForActivityResult の内部実装を参考にして State Holder でも同じように扱えることを意識して実装しています。

open class ActivityResultState {  
  
    private val nextLocalRequestCode: AtomicInteger = AtomicInteger(0)  
  
    fun <I, O> registerForActivityResult(  
        context: Context,  
        contract: ActivityResultContract<I, O>,  
        result: ActivityResultCallback<O>  
    ) = (context as AppCompatActivity).activityResultRegistry.register(  
        "state_req#${nextLocalRequestCode.getAndIncrement()}",  
        contract,  
        result  
    )  
}

class RealProfileScreenState(
    ...
): ActivityResultState() {

    private val launcher = registerForActivityResult(context, ProfileActivityResultContract.GetAvator()) { avator ->
        viewModel.uploadAvator(avator)
    }

    override fun onIconClick() {
        launcher.launch(Unit)
    }
}

LocalContext が Activity なのでこれで動作はしていますが、少し無理やり感があるので今後改善していきたいと思っています。

まとめ

State Holder を用いてプレゼンテーションロジックを Composable 関数から切り出しコードの責務を分けることができました。しかし今のままだと必要に応じて State Holder のコンストラクタ引数が増えていったり Activity に頼っていたりして、State Holder 自体が Fat になったり秩序がなくなっていったりすると思うので State Holder 内を健全に保つ方法を模索中です。

Jetpack Compose はまだまだ実装に工夫が必要なところがありますがそれが楽しくもあります。ただしオレオレアーキテクチャは後の負債につながってしまうことも多々あるので、銀の弾丸ではないですがより広く普及されるようなアーキテクチャを見つけていきたいです。

まだまだ導入中ではありますが今後もアップデートを続けていきたいと思います。