LoginSignup
107
67

More than 1 year has passed since last update.

Jetpack Compose State Practices

Last updated at Posted at 2021-12-11

The English version is available here.

この記事ではJetpack Composeの公式から提供されているものをプラクティス形式でリストにして紹介します

以下のセッションやコードラボには大切なプラクティスがたくさんあるのですが、あまり広まっておらずリスト化はされていないので、それを拾って、DO, DON'T形式でまとめることによってプロダクションでComposeの開発をスムーズにしたいなと思いました。

Composeの基本

おそらくこれを知らないとうまくComposeは動かないので、Composeでアプリが作れないような知識たちで、プラクティスというよりはComposeの使い方という感じです。

Practice1: 表示するStateの変更はComposeによって観測されるようにする

アプリケーションにおいてのStateとは時間や条件などによって変わるものすべてです。例えばニュースのアプリであれば記事などもそうですし、UIにスイッチがあるのであればスイッチがONになっているというのも状態です。基本的にアプリは何らかのStateを使っているはずです。
Stateの変更をComposeによって観測されるようにするには、Kotlinのvarなどの変数ではなく、MutableStateやcollectAsState()などを利用しComposeのStateを使う必要があります。
ComposeではKotlinのvarを使って変数を宣言しても、Composeによって変更が観測されないので、うまく動きません。

:x: DON'T

変更が反映されない。スイッチとして動作しない ​:sob:

@Composable
fun MySwitch() {
   // Kotlinの変数でcheckのStateを持っている
   var checked = false
   Switch(
       checked = checked,
       onCheckedChange = {
           checked = it
       }
   )
}

:o: DO

@Composable
fun MySwitch() {
   // ComposeのMutableStateで持っている
   val checked = remember { mutableStateOf(false) }
   Switch(
       // Stateをvalueプロパティにアクセスして観測する
       checked = checked.value,
       onCheckedChange = {
           checked.value = it
       }
   )
}

なぜ

状態の変更が反映されず動かないため。

なぜDOの例はうまく動くのか?

実はDOの方のMySwitchの関数は状態変更時に再実行(Recompose)されます。しかしDON'Tの方の関数は再実行されません。
println()を入れてDOの例では本当にもう一度動くのか確かめてみましょう。

DOの例

@Composable
fun MySwitch() {
   // MutableState
   val checked = remember { mutableStateOf(false) }
   // ****↓追加****
   println("MySwitch(): $checked")
   // ****↑追加****
   Switch(
       checked = checked.value,
       onCheckedChange = {
           checked.value = it
       }
   )
}

以下のようにMySwitch()が再実行されていることがわかります。

なぜMySwitch()は再実行されるのか?

次に疑問に思うのはどうやって再実行されるのかが気になってきますよね?

ほんの少しだけ実装を見てみましょう。MutableStateの実装を見に行くと以下のような実装を見ることができます。Jetpack ComposeのStateのvalueにはcustom setterやcustom getterが設定されている、つまりただの変数ではなく処理が行われるようになっています

SnapshotMutableStateImplより

つまりJetpack ComposeではStateの読み込みが監視されており、Jetpack ComposeはMutableStateに対して読み込みした関数を保持しているため、そのMutableStateが変更されたときにStateを読み込んでいる関数を呼び出せます。

(Compose runtimeはMySwitch()がMutableState.valueをget()したことを知っている図)

Practice2: Composableの中で作られたStateはrememberされなければならない

Composableの中で作られたStateはremember{}を使う必要があります。
remember{}を使うとComposeの中でデータを保持することができます。

:x: DON'T

前回の例同様に変更が反映されない :sob:

@Composable
fun MySwitch() {
   // rememberされていない
   var checked = mutableStateOf(false)
   Switch(
       checked = checked.value,
       onCheckedChange = {
           checked.value = it
       }
   )
}

:o: DO

@Composable
fun MySwitch() {
   // remember{}が使われている
   var checked = remember { mutableStateOf(false) }
   Switch(
       checked = checked.value,
       onCheckedChange = {
           checked.value = it
       }
   )
}

なぜ?

今回もDON'Tの例のちゃんとMySwitch()が動いているのかをMySwitch()println()を入れて確かめてみましょう。

@Composable
fun MySwitch() {
   val checked = mutableStateOf(false)
   println("MySwitch(): $checked")
   Switch(
       checked = checked.value,
       onCheckedChange = {
           checked.value = it
       }
   )
}

どうやらMySwitch()は再実行(Recompose)はされているようです。が、MutableStateの値が変わっていません。

MutableStateを使っていて変更は検知され関数のRecomposeはされるのですが、Composableの中で作られたStateはrememberを使わないと保持されないため、また新しく同じMutableState(false)が作られてしまい、状態を保持できません。
毎回インスタンスのHashCodeが変わることによって、毎回MutableStateが作り直されている事がわかると思います。

remember{}をするとどこに保存されるのか?

これを理解するにはCompositionというものを先に理解する必要があります
Composeは実行時にComposable関数を実行することによって、Compositionを構成します。
Compositionは木構造になっており、例えば以下のようなComposable関数であれば下の図のようなCompositionを構成します。

@Composable
fun MyComposable() {
   Column {
       Text("Hello")
       Text("World")
   }
}

from: https://developer.android.com/jetpack/compose/lifecycle?hl=en#composition-anatomy

このCompositionはUIの要素だけでなく、データも保持しておくことができ、それがremember{}による保存です。

さて今回の例に戻ってみましょう。rememberされていない状態だとこのCompositionはどのようになるでしょうか?

@Composable
fun MySwitch() {
   // rememberされていない!!
   val checked = mutableStateOf(false)
   Switch(
       checked = checked.value,
       onCheckedChange = {
           checked.value = it
       }
   )
}

以下のようにMySwitch()の下にSwitchがある形になります。CompositionにMutableStateが保存されていないです。

MySwitch
└── Switch

ではremember{}を使うとどのようになるでしょうか?

@Composable
fun MySwitch() {
   // remember{}が使われている
   var checked = remember { mutableStateOf(false) }
   Switch(
       checked = checked.value,
       onCheckedChange = {
           checked.value = it
       }
   )
}
MySwitch
├── **MutableState (var checked)**
└── Switch

このようにremember{}を利用するとこのCompositionの中にMutableStateが保存されています。remember{}はMySwitchがCompositionに入った時点の値を保持し、次の呼び出し以降ではそれを利用するというものなので、保存されていて、それが利用されます。

より

Composable functions can store a single object in memory by using the remember composable. A value computed by remember is stored in the Composition during initial composition, and the stored value is returned during recomposition. remember can be used to store both mutable and immutable objects.

Practice3: StateへのアクセスはKotlinのDelegated Propertyを使おう

:x: DON'T

@Composable
fun MySwitch(initialEnabled: Boolean) {
    val checked = remember { mutableStateOf(initialEnabled) }
    Switch(
        checked = checked.value,
        onCheckedChange = {
            checked.value = it
        }
    )
}

:o: DO

@Composable
fun MySwitch(initialEnabled: Boolean) {
    var checked by remember { mutableStateOf(initialEnabled) }
    Switch(
        checked = checked,
        onCheckedChange = {
            checked = it
        }
    )
}

なぜ

KotlinにはDelegated Propertyという機能があり、単純に毎回valueプロパティにアクセスする必要がなくすことができます。そのためそれを利用してかんたんに書きましょうということです。

Practice4: Stateの変更はComposable関数のスコープ内で行ってはならない

:x: DON'T

@Composable
fun MySwitch() {
    var checked by remember { mutableStateOf(false) }
    // Composable関数のスコープ内で、checkedをいじっている!
    checked = !checked
    Switch(
        checked = checked,
        onCheckedChange = {
            checked = it
        }
    )
}

:o: DO

@Composable
fun MySwitch() {
    var checked by remember { mutableStateOf(false) }
    Switch(
        checked = checked,
        onCheckedChange = {
            checked = it
        }
    )
}

なぜ?

この例ではcheckedの変更に伴って無限にMySwitch()が呼び出し直され、スイッチは点滅し続けます。 :innocent: このような事故を防ぐためです。 onCheckedChangeに渡しているラムダはComposable関数ではないのでComposable関数のスコープ外のため、変更しても安全です。
MySwitch()がCompositionに入ったときや出たときにStateを変更する処理を行いたい場合は、SideEffectの関数の利用を検討しましょう。

ComposeのState管理のプラクティス

State hostingに関連したプラクティス

State hoistingはStateを上に上げることで、コンポーネントをStatelessにするパターンです。Composable関数に適応する場合はだいたい以下2つの引数を導入することを意味します。

value: T 表示するデータ
onValueChange: (T) -> Unit 値の変更を要求するイベント

Practice5: State hostingでComposable関数を再利用可能でテスト可能にしよう

:o: DO

Screen()がデータをMySwitch()に流し、MySwitch()のイベントをScreen()が受け取っています。

checked: Boolean
onCheckChanged: (Boolean) -> Unit

@Composable
fun Screen() {
    var checked by remember { mutableStateOf(false) }
    MySwitch(checked = checked, onCheckChanged = { checked = it })
}

@Composable
fun MySwitch(checked: Boolean, onCheckChanged: (Boolean) -> Unit) {
    Switch(
        checked = checked,
        onCheckedChange = {
            onCheckChanged(it)
        }
    )
}

:x: DON'T

@Composable
fun Screen() {
    MySwitch()
}

@Composable
fun MySwitch() {
    var checked by remember { mutableStateOf(false) }
    Switch(
        checked = checked,
        onCheckedChange = {
            checked = it
        }
    )
}

なぜ?

  • タイトル通り再利用可能で、テスト可能にするため。 例えば保存されている状態からこのSwitchの状態を最初ONから始めたいとします。が、これではそういうふうにできないので、他のボタンとかでは使えないです。また内部でしか状態を持っていないので、テストで値を入れてチェックすることもやりにくいです。

Practice6: State hostingでSingle source of truthにしよう。またState hoistingは少なくとも一番低いStateを消費している共通の親にしよう。

:o: DO

今回は横にテキストを並べて"ON"か"OFF"かを表示してみました。

@Composable
fun Screen() {
    // StateはScreenにのみ持っている
    var checked by remember { mutableStateOf(false) }
    Row {
        MySwitch(checked = checked, onCheckChanged = { checked = it })
        Text(
            text = if (checked) "on" else "false",
            Modifier.clickable {
                checked = !checked
            }
        )
    }
}

// checkedはImmutable、変更不可能な値になっている
@Composable
fun MySwitch(checked: Boolean, onCheckChanged: (Boolean) -> Unit) {
    Switch(
        checked = checked,
        onCheckedChange = {
            onCheckChanged(it)
        }
    )
}

checkwithtext.gif

:x: DON'T

initialCheckedなどで渡してMySwitch内でデータを管理させています。一応これならreusableだし、テストも書けるかも?? このコードだと何が起こるでしょうか?

@Composable
fun Screen() {
    // ScreenとMySwitch両方で状態を持っている
    var checked by remember { mutableStateOf(false) }
    Row {
        MySwitch(initialChecked = false, onCheckChanged = {checked = it})
        Text(
            text = if(checked) "on" else "false",
            Modifier.clickable {
                checked = !checked
            }
        )
    }
}

@Composable
fun MySwitch(initialChecked: Boolean, onCheckChanged: (Boolean) -> Unit) {
    // ScreenとMySwitch両方で状態を持っている
    var checked by remember { mutableStateOf(initialChecked) }
    Switch(
        checked = checked,
        onCheckedChange = {
            checked = it
            onCheckChanged(it)
        }
    )
}

テキストを押してもチェックボックスが更新されずバグります。 :sob:

checkwithtext2.gif

なぜState hoistingするのか?

  • タイトルにも書いてあるとおりSingle source of truthにできるためです。
    今回の例も頑張ればバグをなくせると思いますが今回のようなバグを防ぐためには、Stateの複製を防ぎ、Stateの変更箇所を1つにし、変更不可能なStateを流していくことが大切になります。それがSingle source of truthと呼ばれる構造化の方法です。これをState hoistingは可能にします。

なぜ少なくとも一番低いStateを消費している共通の親にするのか?

ここでの共通の親はScreen()のComposale関数になります。DOの例ではここにStateを持っています。
共通の親にしない場合は、Single source of truthにすることが困難であるためです。

Practice7: 必要な引数だけをComposable関数に渡そう

もう少し、実用的なViewModelを使うようなサンプルで見てみましょう。StatelessとStatefulの関数を分けることで、必要な引数だけを渡すことができます。

:o: DO

StatelessとStatefulのViewModelを分けて、必要なデータのみをstateless、つまり状態を持たないComposable関数に渡します。

// stateful
@Composable
fun SettingScreen(
    settingViewModel: SettingViewModel = viewModel()
) {
    val isDarkMode by settingViewModel.isDarkMode.collectAsState()
    SettingScreen(isDarkModeSetting = isDarkMode, onDarkModeSettingChanged = {
        settingViewModel.onDarkModeChange(it)
    })
}

// stateless
@Composable
fun SettingScreen(isDarkModeSetting: Boolean, onDarkModeSettingChanged: (Boolean) -> Unit) {
    MySwitch(checked = isDarkModeSetting, onCheckChanged = {
        onDarkModeSettingChanged(it)
    })
}

:x: DON'T

@Composable
fun SettingScreen(
    settingViewModel: SettingViewModel = viewModel()
) {
    val isDarkMode by settingViewModel.isDarkMode.collectAsState()
    MySwitch(checked = isDarkMode, onCheckChanged = {
        settingViewModel.onDarkModeChange(it)
    })
}

なぜ?

  • StatelessのComposableに対してプレビューやテストをかんたんにできます。(今回はボタン一つだけなのでMySwitch()をプレビューすれば良いですが画面にたくさん項目ができるなどの場合はたいへんになりそうですね🤔)
  • 再利用にする必要があるときにすることができます。

Practice8: Architecture ComponentのViewModelにCompositionで保持されている状態を渡さないようにしよう

:x: DON'T

class SettingViewModel(
    val scaffoldState: ScaffoldState
) : ViewModel()

@Composable
fun SettingScreen() {
  val scaffoldState = rememberScaffoldState()
  val settingViewModel = viewModes(factory = { SettingViewModel.Factory.create(scaffoldState) })
}

なぜ?

ViewModelとCompositionのライフサイクルが違い、ViewModelのほうが長生きするので、scaffoldStateをViewModelで持っていると古いscaffoldStateをViewModelが持ち続けてリークするため。

Practice9: Composable関数にロジックが書かれないようにしよう

今回の例では全然複雑でないので、必要ないですが、Composableが複雑になってきたら分割が必要になってきます。
チェックを変えたらSnackbarを表示するようにしてみたとしましょう。

snackbar.gif

:x: DON'T

まだ全然シンプルですが、SettingScreen()のComposable関数にさまざまな処理が入ってきています :sob:

@Composable
fun SettingScreen(
    settingViewModel: SettingViewModel = viewModel(),
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    coroutinesScope: CoroutineScope = rememberCoroutineScope(),
) {
    val isDarkMode by settingViewModel.isDarkMode.collectAsState()
    SettingScreen(
        scaffoldState = scaffoldState,
        isDarkModeSetting = isDarkMode,
        onDarkModeSettingChanged = {
            settingViewModel.onDarkModeChange(it)
            coroutinesScope.launch {
                scaffoldState.snackbarHostState.showSnackbar("Dark mode changed!")
            }
        }
    )
}

@Composable
fun SettingScreen(
    isDarkModeSetting: Boolean,
    onDarkModeSettingChanged: (Boolean) -> Unit,
    scaffoldState: ScaffoldState
) {
    Scaffold(scaffoldState = scaffoldState) {
        MySwitch(checked = isDarkModeSetting, onCheckChanged = {
            onDarkModeSettingChanged(it)
        })
    }
}

:o: DO

State holder使うとかなり分離できて、Composable関数はかなりシンプルになり、ロジックを分離できました。

@Composable
fun SettingScreen(
    settingScreenState: SettingScreenState = rememberSettingScreenState()
) {
    SettingScreen(
        scaffoldState = settingScreenState.scaffoldState,
        isDarkModeSetting = settingScreenState.isDarkMode,
        onDarkModeSettingChanged = {
            settingScreenState.onDarkModeChange(it)
        }
    )
}

@Composable
fun SettingScreen(
    isDarkModeSetting: Boolean,
    onDarkModeSettingChanged: (Boolean) -> Unit,
    scaffoldState: ScaffoldState
) {
    Scaffold(scaffoldState = scaffoldState) {
        MySwitch(checked = isDarkModeSetting, onCheckChanged = {
            onDarkModeSettingChanged(it)
        })
    }
}

// SettingScreenStateがState holderです。
// SettingScreenStateは何も継承していないただのクラスになっています。
// これはComposeの状態を保持するため、他のライフサイクルに縛られないようにするためです。
class SettingScreenState(
    // 他のComposeのStateを持つことができます。
    val scaffoldState: ScaffoldState,
    // ビジネスロジックや画面の状態にアクセスしたい場合は、
    // State holderはViewModelにも依存することができます。
    // ViewModelのほうが寿命が長いため、問題にならないです。
    private val settingViewModel: SettingViewModel,
    private val coroutinesScope: CoroutineScope,
) {
    // 必要であれば変数などはComposableにすることができます。
    val isDarkMode: Boolean
        @Composable get() = settingViewModel.isDarkMode.collectAsState().value

    fun onDarkModeChange(isDarkMode: Boolean) {
        settingViewModel.onDarkModeChange(isDarkMode)
        coroutinesScope.launch {
            scaffoldState.snackbarHostState.showSnackbar("Dark mode changed!", )
        }
    }
}

@Composable
fun rememberSettingScreenState(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    coroutinesScope: CoroutineScope = rememberCoroutineScope(),
    settingViewModel: SettingViewModel = viewModel(),
) = remember {
    SettingScreenState(
        coroutinesScope = coroutinesScope,
        scaffoldState = scaffoldState,
        settingViewModel = settingViewModel
    )
}

なぜ?
関心事の分離のためです。

Androidの開発で有名なJakeさんも、Compose UIを設計する上でComposeはビジネスロジックがUIに混ざりやすいので注意が必要だということを言っていました。
ではどのようにロジックを分離していくべきなのか?Android Developersでは以下のような指針が示されていました。

役割 使うもの
単純なUIの要素のState管理 Composable
複雑なUIの要素のState管理とUIのロジック State holder
ビジネスロジックへのアクセスと画面の状態 Architecture ComponentのViewModel

以下のように依存を持つことができます。

image.png

State holderは複雑なUIの要素のState管理するもので、ここではSnackbarの管理という役割がComposableに増え複雑化したため、必要になってきました。
このように複雑になればなるほどState holderの必要性が増えていきます。

まとめ

・State = 状態とは? “時間とともに変化する可能性がある値すべて”。
・Jetpack ComposeでStateを管理していくための9個のプラクティスを紹介しました。
・ちゃんと動かすにはJetpack Composeへの理解が必要。
・考え方の基本となる部分はSingle source of truthや関心事の分離など。知っておくとCompose以外でも役に立つかもしれません。

107
67
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
107
67