Uzzu::Blog

Software Design, and my life.

「消費可能な値」を導入した

本記事はクラスター Advent Calendar 2024 2日目の記事です。

前日は @htomine さんのなんかでした。すてきですね☺️


この記事では、clusterのAndroidアプリにおいて Consumable<T> (消費可能な値) なる型を導入していい感じにやっているという話をします。 一応近況報告をしておくと、ここ1年はUnityエンジニアをやっていて、本記事の内容は1年以上前から運用されています。

UI開発において、状態の変化を一度だけ購読し、状態変化を処理し終えたら以降は処理したくない、というケースは多く発生すると思います。 以下に具体的なケースを示します。

ケース1: エラー表示

例えばActivityのonCreateに以下のような実装されていたとします。

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)

   lifecycleScope.launch {
       lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
           viewModel.systemErrorFlow.collect {
               errorUtil.showDialog(this@SomeActivity, it)
           }
       }
   }
   setContent {
       SomeTheme {
           SomeScreen(viewModel)
       }
   }
   viewModel.initialize()
}

この実装において、ViewModelのsystemErrorFlowプロパティが SharedFlow<Throwable> であった場合、問題が発生します。 ViewModel#initialize() のコールスタックの中でsystemErrorFlowにエラーが流れる場合、ActivityはSharedFlowをcollectできません。なぜならLifecycleがまだSTARTEDに移行していないからです。

では、このsystemErrorFlowを StateFlow<Throwable> にしてみたらどうでしょうか。StateFlowなので、LifecycleがSTARTEDに移行した時点でエラーが流れてきます。 一見良さそうに見えますが、この場合も問題が発生します。Lifecycle.State.STARTEDなタイミングは一回きりではないためです。

こういったシーンでは、StateFlow によって 「STARTEDでcollectする」を達成しつつ、「一度処理したら二度とそのエラーは処理しない」が求められます。

ケース2: ModalBottomSheetの表示/非表示

添付のキャプチャの様なModalBottomSheetの利用シーンを想定した場合

  1. ViewModelでModalBottomSheetの表示に必要な情報を取得する
  2. ViewModelでModalBottomSheetの表示するためのState (便宜上 bottomSheetState と記載)を更新する
  3. Viewは ViewModel.bottomSheetState を購読してModalBottomSheetを表示する

な処理フローになるかと思いますが、この後の分岐は以下のようになります。

  • A) スペースを始める処理実施したのち、ModalBottomSheetを閉じる
  • B) 戻る操作などを実施して、ModalBottomSheetを閉じる

Aの場合、ViewModelのメソッドを呼び出して最終的に bottomSheetState を更新して非表示にするのは自然なことです。 Bの場合特にビジネスロジックがあるわけでもなく、ModalBottomSheetを閉じるためだけのViewModelのメソッドを用意して呼び出す必要があります。 煩わしいですよね。

ModalBottomSheetにおいては他にも、要件に応じて期待した振る舞いが変わってきます。

  • アプリがバックグラウンドから戻ってきた時に、BottomSheetを再表示する or しない
  • すでに一度表示したModalBottomSheetを再表示する or しない

そして、これらの要件が

  • ModalBottomSheetを表示する画面ごとに対応される
  • 実装ノウハウの継承がレビュワー/実装者に依存する

という状況になってしまうと、ModalBottomSheetの表示に関連する実装考慮漏れバグやデグレだけでなく、画面毎の振る舞いの整合性の観点がそもそも抜け落ちてしまうといった弊害もあります。

ModalBottomSheetに限らずUI開発においては、ビジネスロジックを実施したのちにUIを非表示にしたいシーンと、ビジネスロジック非依存にUIを非表示にしたいシーンが存在します。 これらはいずれも「任意のタイミングで状態を処理した後、以降その状態を処理しないようにしたい」という点で同じです。

以上を踏まえると、ViewModelからもView単体でもModalBottomSheetを閉じる処理が完結可能である設計にしておいて、要件に合わせて柔軟に対処できる状況になっているのが理想です。

シーン3. 複数のActivityで構成されており、かつJetpack ComposeでUIが構築されているAndroidアプリにおけるSnackbarの表示

今回紹介する例に留まらず、Jetpack ComposeのSnackbarは正直まあまあ不便ですが、そのうち良くなるだろうということでその話は一旦置いておきます。

例えば、以下の様なシーンを想定します。

投稿詳細のActivityでその投稿を削除した後は、その投稿を操作されても困るので、前のActivityに戻りながら、「投稿を削除しました」とSnackbar(画面下に表示されている黒背景のUI)で表示しています。

これはどう実装したら良いでしょうか。 投稿詳細ActivityのSnackbarHostを使用しても、Activityをすぐfinishしてしまうので、Snackbarは一瞬で消えてしまいます。 加えて、この投稿詳細ActivityがDeeplinkによる遷移であった場合、投稿削除したあとどのActivityに戻るかはわかりません。

つまり、戻り先のいずれかのActivityで「投稿を削除しました」とSnackbarを一度だけ表示する必要があります。

ここまでのまとめ

様々なケースを書いてきましたので、一旦まとめです。

  • ケース1 .. 状態の変化を一度だけ処理したい
  • ケース2 .. 状態の変化を処理するが、その処理の完了は任意の箇所で行いたい
  • ケース3 .. 別のActivityで発生した状態変化を、他のいずれかの画面で一度だけ処理したい

これらはいずれも本記事の冒頭に書いた通り、状態の変化を一度だけ購読し、(複数箇所でcollectしていても、いずれかのSubscriberが) 状態変化を処理し終えたら、以降はその状態変化を処理したくない、という需要に当てはまると思います。 これを「状態を消費する」という捉え方をして生み出されたのが「消費可能な値 Consumable<T> 」 になります。

Consumable<T> を使用するとどうなるのか

Cosumable<T> のinterfaceはとてもシンプルです。

@Stable
interface Consumable<T> {
    val value: T
    val isConsumed: Boolean
    fun consume()
}

Consumable<T>StateFlow<T> (UniRxなどで言えば ReactiveProperty<T> に相当するクラス) との併用を前提としています。

@HiltViewModel
class SomeViewModel @Inject constructor(
) : ViewModel() {
    private val _systemErrorState = MutableStateFlowConsumable<SystemError>()
    val systemErrorState: StateFlow<Consumable<SystemError>> = _systemErrorState
}

ケース1はエラーダイアログの表示でしたが、 Consumable<T> を使用して以下のようにして解決しています。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    lifecycleScope.launch {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.systemErrorState.collectAsConsumed {
                // collectAsConsumedはStateFlow<Consumable<T>>の拡張関数
                // このblockはsystemErrorStateの状態変化に合わせて一度しか呼ばれないようになっている
                errorUtil.showDialog(this@SomeActivity, it)
            }
        }
    }
    setContent {
        SomeTheme {
            SomeScreen(viewModel)
        }
    }
    viewModel.initialize()
}

Jetpack Composeでの実装の場合は以下の様に実装できるようにしています。

@Composable
fun SomeScreen(
    viewModel: SomeViewModel,
) {
    val systemErrorState by viewModel.systemErrorState.collectAsStateWithLifecycle()

    // 色々

    // SystemErrorEffect(エラーダイアログ表示)はsystemErrorStateの状態変化に合わせて一度しか処理されないようになっている
    SystemErrorEffect(systemErrorState)
}

続いて、ケース2はModalBottomSheetの表示についてでした。

ModalBottomSheetでの解決の前に、少し触れておくべき話題があります。

interfaceを見れば分かるとおり、 Consumable<T>@Stable annotationはつけつつも、mutableっぽいメソッド(consume())が生えています。 危険な香りがしますよね。分かります。

細かい話をするとキリがなくなってしまうので要点だけ抑えた話をすると、 ViewModelに保持しているStateFlowのConsumableの状態を isConsumed = true にするために Consumable#consume() はmutableっぽいinterfaceである必要がありますが、 何も考えずに実装した場合、 Consumable#consume() を呼び出してもre-composeが走りません。 ケース1の様に、ダイアログを一度だけ表示するケースにおいても、ダイアログを閉じた際にはre-composeが走る必要があります。 この際、re-composeを走らせるスコープは最低限に抑えたいです。

そのため、 rememberComsumableState なComposable functionと型をJetpack Compose向けに用意して、consumeした際にre-composeを期待通りに走らせられるようにしています。

ConsumableState, rememberConsumableState
@Stable
interface ConsumableState {
    val shouldConsume: Boolean
    fun consume()
}

@Composable
fun rememberConsumableState(
    consumable: Consumable<*>,
): ConsumableState {

    var recomposeKey by remember { mutableIntStateOf(0) }
    val shouldConsume by remember(consumable, recomposeKey) { derivedStateOf { !consumable.isConsumed } }

    return ConsumableStateImpl(
        shouldConsume,
        onConsume = {
            consumable.consume()
            recomposeKey++
        },
    )
}

private class ConsumableStateImpl(
    override val shouldConsume: Boolean,
    private val onConsume: () -> Unit,
) : ConsumableState {
    override fun consume() {
        onConsume()
    }
}

前置きはこれまでとして、ModalBottomSheetにおいても同様の仕組みでconsumeしつつModalBottomSheetが閉じれるようになっています。

    ModalBottomSheet(
        content = content,
        /* 省略 */
        onDismissRequest = {
            consumableModalBottomSheetState.consume()
            onDismissRequest()
        },
    )

(実際の所、ModalBottomSheetはModalBottomSheet固有の状態管理(SheetState)が必要なため、State自体も別実装になっています)

clusterのAndroidアプリにおいてはModalBottomSheetを表示するためのComposable functionは共通化されているため、dismissの際はそもそも意識する事なく状態が消費され、content引数のblockの中でもconsumeできる様になっています。

ViewModel側で状態を消費するには以下の様に実装できるようになっています。

@HiltViewModel
class SomeViewModel @Inject constructor(
) : ViewModel() {

    private val _modalBottomSheetState = MutableStateFlowConsumable<SomeModalBottomSheetType>()
    val modalBottomSheetState: StateFlow<Consumable<SomeModalBottomSheetType>> = _modalBottomSheetState

    fun doSomething() = viewModelScope.launch(exceptionHandler) {
        // 色々

        _modalBottomSheetState.emitAsConsumed()
    }
}

ケース3については、 SingletonのSnackbarを処理するMediatorクラスを用意して、各ActivityのViewModelのStateに変換して、 Jetpack ComposeのUI実装上でcollectする、というようにして解決しています。

Consumable<T> を導入してみて

Consumable<T> を導入することで、目的であった「状態の変化を一度だけ購読し、状態変化を処理し終えたら以降は処理しない」を解決できていますし、 適切に拡張関数を用意する事でふつうのJetpack Composeを用いたAndroidアプリ開発の体裁を保てていると思います。

シビアな話をすると、 消費可能な値という定義自体が曖昧なので、現時点では性善説で成り立っています。 実装もだいぶガバいです(gist (実際にはKDocがちゃんと書かれています))。 クラスターのソフトウェアエンジニアは優秀なので、多少ガバい型を用意しても良い感じに運用が回っています。 一見便利に使えそうな場面でも、ユースケースをよく考えた上で使用するようにしています。 Design Doc文化の賜物だと思います。 Kotlinの言語機能が育ってきたら、利用可能なスコープをより小さくするなどもう少しやりようがありそうです。 またこの「一度だけ状態を消費する」という考え方はAndroidアプリ開発に限らずUI開発の分野ではぼちぼち使えるんじゃないかなと思います。


というわけで、『「消費可能な値」を導入した』 というお話でした。

クラスター Advent Calendar 2024の明日の記事は @n_mattun さんの、「バッテリー残量みえるくんの話」 です。