見出し画像

Jetpack Compose とandroidx.lifecycle 2.5.0 の SavedStateHandle 連携強化を試す

こんにちは、株式会社カウシェで Android 版アプリを開発している @sintario です。
今回は Jetpack Compose でのアプリ開発における androidx.lifecycle 2.5.0 を使った状態管理のシンプルな実装プラクティスをご紹介します。

本稿執筆時のライブラリ

  • Kotlin 1.7.0

  • kotlinx.coroutines 1.6.3

  • google.dagger 2.42 (Hilt を使っています)

  • Jetpack Compose 1.2.0

  • androidx.hilt-navigation-compose 1.0.0

  • androidx.lifecycle-viewmodel-compose 2.5.0

問題設定

アプリ開発をしていると、こういう処理を頻繁に書きますよね、という問題設定です

- 画面を開くと、 API からデータを読み込み、それを表示する
- API からのデータ読込中はプログレスインジケーターを表示する
- API からのデータ読み込みが失敗したら、再読み込みボタンを表示し、タップすると再読み込みができること

素朴に実装してみる: androidx.lifecycle 2.4.x の範囲で

これだけだったらカンタンですよね。

API と状態定義

API の抽象化として

import java.io.Serializable

data class Blog(val title: String) : Serializable
interface BlogRepository {
    suspend fun loadBlogs(): Result<List<Blog>>
}

という形でデータを返してくれるインターフェイスがあるとします。
画面の状態を表現するのについてはこんな感じではいかがでしょう。

import android.os.Parcelable
import kotlinx.parcelize.Parcelize

@Parcelize
data class BlogListState(
    val isLoading: Boolean = false,
    val blogs: Result<List<Blog>>? = null,
) : Parcelable

Jetpack Compose で状態を UI に反映する

BlogListState を素直に UI に反映します。

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.EditNote
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BlogListScreen(
    state: BlogListState, 
    onReload: () -> Unit
) {

    Scaffold(
        topBar = {
            MediumTopAppBar(
                title = { Text(text = "ブログタイトル") },
                actions = {
                    Icon(
                        imageVector = Icons.Outlined.EditNote,
                        contentDescription = null
                    )
                },
                colors = TopAppBarDefaults.mediumTopAppBarColors(containerColor = Color.Yellow),
            )
        },
    ) { contentPadding ->
        if (state.isLoading) {
            Box(
                contentAlignment = Alignment.Center,
                modifier = Modifier
                    .padding(contentPadding)
                    .fillMaxSize(),
            ) {
                CircularProgressIndicator(
                    color = Color.Red
                )
            }
        } else {
            state.blogs
                ?.onSuccess { blogs ->
                    Column(
                        modifier = Modifier
                            .padding(contentPadding)
                            .padding(vertical = 12.dp, horizontal = 16.dp),
                        verticalArrangement = Arrangement.spacedBy(16.dp),
                    ) {
                        blogs.forEach { Text(it.title) }
                    }
                }
                ?.onFailure { e ->
                    Box(
                        modifier = Modifier.padding(contentPadding).fillMaxSize(),
                        contentAlignment = Alignment.Center
                    ) {
                        Column(
                            verticalArrangement = Arrangement.spacedBy(12.dp),
                            horizontalAlignment = Alignment.CenterHorizontally,
                        ) {
                            Text(text = e.localizedMessage)
                            Button(onClick = onReload) {
                                Text(text = "もう一回")
                            }
                        }
                    }
                }
        }
    }
}

BlogListState を正しく導出できれば、素直に状態を反映した画面が表示されるはずです。

素朴にデータ読み込みを実装してみる

BlogListState を正しく導出できれば、というところがまだ達成されていないので、これを ViewModel で実装してみましょう。 LiveData でもいいですが suspend 関数呼び出しなので何も考えずに MutableStateFlow と組み合わせてみます。

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class BlogListViewModel @Inject constructor(
    private val repository: BlogRepository
) : ViewModel() {

    private val _state = MutableStateFlow(BlogListState())
    val state = _state.asStateFlow()

    fun loadData() {
        viewModelScope.launch {
            _state.update { it.copy(isLoading = true) }
            _state.update { it.copy(isLoading = false, blogs = repository.loadBlogs()) }
        }
    }
}

そして BlogListScreen を少しだけ書き換えます。

-  fun BlogListScreen(
-     state: BlogListState, 
-     onReload: () -> Unit
-  ) {
+  fun BlogListScreen(
+     viewModel: BlogListViewModel = hiltViewModel(), 
+     onReload: () -> Unit = viewModel::loadData
+  ) {
+     val state by viewModel.state.collectAsState()
+     LaunchedEffect(viewModel) {
+         viewModel.loadData()
+     }

これで BlogListScreen() を画面上に配置するだけで自動で読み込みが始まって表示されるコンポーネントのできあがりです。

少し厳しい条件を追加:状態保持

ここまではなんにも難しくないので、もうちょっと現実にありそうな要件を追加します。

- 画面上でデータ読み込みが成功したら、その画面のまま他アプリにスイッチしてから戻ってきてもデータ再読み込みが発生しないこと

カウシェの場合ですと、購入手続きのために一度 Shopify の決済画面に行って戻ってくるというのもあり、購入の途中でちょっとアプリ外の情報を見てから戻ってきて…なんていう一瞬外に行くユースケースが想定されるので、アプリが取得済みのデータを揮発させずに保持したいという状況はリアルに存在します。

状態を持ち越せる ViewModel に書き換える

Android Architecture Components の ViewModel を使っているんだからこんな条件自動で満たすでしょ(?)、とお思いの方もいるかも知れませんが、Android はバックグラウンドに行ったアプリの Activity を生かしておいてもらえる保証がない OS なので、対策が必要になります。
下記に引用した図の通り、例えば Activity に対して払い出した ViewModel インスタンスには対応する生存期間があって、 Activity が破棄されるときにクリアされてしまい、次に同じ画面が再生成されるときには別の ViewModel インスタンスになります。インスタンスが使い回されることを期待した実装だと、インスタンスが使い回されている間はデータを引き継げますが、前面にいなくなった Activity が破棄されてしまうとその画面を再生成されるときには別の ViewModel インスタンスが払い出されるので\(^o^)/オワタとなります。

https://developer.android.com/topic/libraries/architecture/viewmodel?hl=ja#lifecycle

ご利用の検証機種にも依存するので、確実にバックグラウンドでの画面廃棄を体験するには、開発者向けオプションの「アクティビティを保持しない」が使えます。

今回の問題設定では画面単位でのキャッシュで良く、アプリに永続的に書き込んでおくようなものではないので、 SavedStateHandle を使います。 Hilt を使っているのであれば ViewModel のコンストラクタに引数を増やすだけで使えるようになります。一時的な画面破棄を経て再生成される場合は、破棄前に書き込んでおいたデータの詰まった SavedStateHandle がコンストラクタに渡ってくるので、キャッシュを引き継げるということです。

androidx.lifecycle 2.4.x 以前の語彙で実装してみるとこんな感じになります。

import android.os.Bundle
import androidx.core.os.bundleOf
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class BlogListViewModel @Inject constructor(
    private val repository: BlogRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _state = MutableStateFlow(
        savedStateHandle.get<Bundle>(KEY_BUNDLE)?.getParcelable(KEY_STATE) ?: BlogListState() // ... (1)
    )
    val state = _state.asStateFlow()

    init {
        savedStateHandle.setSavedStateProvider(KEY_BUNDLE) { // ... (2)
            bundleOf(KEY_STATE to _state.value)
        }
    }

    fun loadData() {
        viewModelScope.launch {
            if (_state.value.blogs?.isSuccess == true) return@launch  // ... (3)
            _state.update { it.copy(isLoading = true) }
            _state.update { it.copy(isLoading = false, blogs = repository.loadBlogs()) }
        }
    }

    companion object {  // ... (4)
        const val KEY_STATE = "my_sweet_state"
        const val KEY_BUNDLE = "my_bundle"
    }
}

再読み込みの抑止は (3) です。
ViewModel が clear される際に揮発させたくないデータを保存する処理 (2) が追加され、それに対応して復元すべき情報があれば Bundle を掘り出して使う処理 (1) が追加されました。また、状態保管のために Bundle の中に埋め込まなくてはならない都合で独自にキーを定数 (4) として持たないといけなくなりました。
これで対応はできてるんですが、雑然としてきましたね。 (3) は要件なので外せないとしても、それ以外が明らかに煩わしい手続きです。
そもそも、 UI が Jetpack Compose なので、参照側では viewModel.state.collectAsState() のようなさらなる変換をしていたことも思い出すと、もっと簡単にかけないかなあという気持ちが湧いてきます。

androidx.lifecycle 2.5.0 のリリースノートを読む

https://developer.android.com/jetpack/androidx/releases/lifecycle#2.5.0 を見ますと、いろいろと Kotlin Coroutines と Jetpack Compose 全盛の時代を象徴するような魅惑の更新が書いてあります。特に今回は SavedStateHandle Compose セーバー統合 というのに注目します。

非常に簡潔な一節ですが、これだけで SavedStateHandle から androidx.compose.runtime.MutableState を直接的に出し入れすることができるようになったこと が見て取れます。実際に組み込んでみましょう。

ViewModel に androidx.lifecycle 2.5.0 を適用してみる

ではさっそく使ってみます。

import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject

@OptIn(SavedStateHandleSaveableApi::class)
@HiltViewModel
class BlogListViewModel @Inject constructor(
    private val repository: BlogRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    var state by savedStateHandle.saveable { mutableStateOf(BlogListState()) } // ... (a)
        private set

    fun loadData() {
        viewModelScope.launch {
            if (state.blogs?.isSuccess == true) return@launch
            state = state.copy(isLoading = true)
            state = state.copy(isLoading = false, blogs = repository.loadBlogs())
        }
    }
}

( ゚д゚)ハッ!
一度は複雑化した実装がずいぶんと簡単になりました。 (a) だけに状態管理に関するごちゃごちゃした処理が全て隠しきれています。

  • by delegate のおかげで var state の型は BlogListState になります。 Jetpack Compose の MutableState に包まれているという事情もうまいこと意識しないで済むようになりました。

  • private set のおかげで ViewModel の外から見ると readonly な property になり、 ViewModel の中からだけ変更されるのだ、というのも見て取れます。

この ViewModel をつかう Composable 関数の方も少し書き換わって、以下のようになりました

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.EditNote
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BlogListScreen(
    viewModel: BlogListViewModel = hiltViewModel(),
    onReload: () -> Unit = viewModel::loadData
) {
    val state = viewModel.state // ... (b)

    LaunchedEffect(viewModel) {
        viewModel.loadData()
    }

    Scaffold(
        topBar = {
            MediumTopAppBar(
                title = { Text(text = "ブログタイトル") },
                actions = {
                    Icon(
                        imageVector = Icons.Outlined.EditNote,
                        contentDescription = null
                    )
                },
                colors = TopAppBarDefaults.mediumTopAppBarColors(containerColor = Color.Yellow),
            )
        },
    ) { contentPadding ->
        if (state.isLoading) {
            Box(
                contentAlignment = Alignment.Center,
                modifier = Modifier
                    .padding(contentPadding)
                    .fillMaxSize(),
            ) {
                CircularProgressIndicator(
                    color = Color.Red
                )
            }
        } else {
            state.blogs
                ?.onSuccess { blogs ->
                    Column(
                        modifier = Modifier
                            .padding(contentPadding)
                            .padding(vertical = 12.dp, horizontal = 16.dp),
                        verticalArrangement = Arrangement.spacedBy(16.dp),
                    ) {
                        blogs.forEach { Text(it.title) }
                    }
                }
                ?.onFailure { e ->
                    Box(
                        modifier = Modifier.padding(contentPadding).fillMaxSize(),
                        contentAlignment = Alignment.Center
                    ) {
                        Column(
                            verticalArrangement = Arrangement.spacedBy(12.dp),
                            horizontalAlignment = Alignment.CenterHorizontally,
                        ) {
                            Text(text = e.localizedMessage)
                            Button(onClick = onReload) {
                                Text(text = "もう一回")
                            }
                        }
                    }
                }
        }
    }
}

collectAsState がなくなって (b) のようにあたかも viewModel の state を見ているだけ、と素直に読める形になりました。
アクティビティを保持しない設定にした実機上で動作確認したところ、バックグラウンド・フォアグラウンド切り替えの際にクラッシュすることもなく、意図したように画面再表示時にローディングUIを出さずにリストが表示されるのが確認できました。

ViewModel の単体テスト

機能実装はできたので、最後に単体テストを。状態遷移のテストをしましょう。
SavedStateHandle に保存されたキャッシュ済み状態から再開するようなケースをどうやってテストしようか、というところで一瞬頭を抱えますが SavedStateHandle.saveable(...) の実装を見てみると

@SavedStateHandleSaveableApi
@JvmName("saveableMutableState")
fun <T : Any, M : MutableState<T>> SavedStateHandle.saveable(
    stateSaver: Saver<T, out Any> = autoSaver(),
    init: () -> M,
): PropertyDelegateProvider<Any?, ReadWriteProperty<Any?, T>> =
    PropertyDelegateProvider<Any?, ReadWriteProperty<Any?, T>> { _, property ->
        val mutableState = saveable(
            key = property.name,
            stateSaver = stateSaver,
            init = init
        )

        // Create a property that delegates to the mutableState
        object : ReadWriteProperty<Any?, T> {
            override fun getValue(thisRef: Any?, property: KProperty<*>): T =
                mutableState.getValue(thisRef, property)

            override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) =
                mutableState.setValue(thisRef, property, value)
        }
    }

そこからもう少し掘り進んで

@SavedStateHandleSaveableApi
fun <T : Any> SavedStateHandle.saveable(
    key: String,
    saver: Saver<T, out Any> = autoSaver(),
    init: () -> T,
): T {
    @Suppress("UNCHECKED_CAST")
    saver as Saver<T, Any>
    // value is restored using the SavedStateHandle or created via [init] lambda
    @Suppress("DEPRECATION") // Bundle.get has been deprecated in API 31
    val value = get<Bundle?>(key)?.get("value")?.let(saver::restore) ?: init()

    // Hook up saving the state to the SavedStateHandle
    setSavedStateProvider(key) {
        bundleOf("value" to with(saver) {
            SaverScope { validateValue(value) }.save(value)
        })
    }
    return value
}

つまり key として property.name を使い、state を詰め込んだ bundle を保存している、ということのようです。この実装は将来的に変わることが有り得そうですが、ともあれ、 mockk を使って

    fun SavedStateHandle.mockkSaveableState(
        state: BlogListState
    ) {
        val bundle: Bundle = mockk()
        every { get<Bundle>("state") } returns bundle
        every { bundle.get(any()) } answers { mutableStateOf(state) }
    }

といった感じの関数を用意することで保管状態からの再開を再現できそうです。ここで "state" は BlogListViewModel の持っているプロパティ var state の名前を文字列として入れたものということです。
実際に一通り書ききってみると以下のようになりました。

import android.os.Bundle
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.SavedStateHandle
import io.mockk.coEvery
import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

@OptIn(ExperimentalCoroutinesApi::class)
internal class BlogListViewModelTest {

    private val dispatcher = StandardTestDispatcher()
    private val repository: BlogRepository = mockk()
    private val savedStateHandle: SavedStateHandle = spyk(SavedStateHandle()) // ... (A)

    @BeforeEach
    fun beforeEach() {
        Dispatchers.setMain(dispatcher) // ... (B)
    }

    @AfterEach
    fun afterEach() {
        Dispatchers.resetMain() // ... (C)
    }

    @Test
    fun `調子が良ければ普通に読み込める`() {
        val blogs: List<Blog> = listOf(Blog("A"), Blog("B"))
        coEvery { repository.loadBlogs() } coAnswers { // ... (D)
            delay(200)
            Result.success(blogs)
        }
        val viewModel = BlogListViewModel(repository, savedStateHandle)
        runTest {
            assertEquals(BlogListState(isLoading = false, blogs = null), viewModel.state)

            viewModel.loadData()

            advanceTimeBy(1) // ... (E)
            assertEquals(BlogListState(isLoading = true, blogs = null), viewModel.state)

            advanceTimeBy(200) // ... (F)
            assertEquals(BlogListState(isLoading = false, blogs = Result.success(blogs)), viewModel.state)
        }
    }

    @Test
    fun `調子が悪いとエラーになる`() {
        coEvery { repository.loadBlogs() } coAnswers {
            delay(200)
            Result.failure(RuntimeException("broken"))
        }
        val viewModel = BlogListViewModel(repository, savedStateHandle)
        runTest {
            assertEquals(BlogListState(isLoading = false, blogs = null), viewModel.state)

            viewModel.loadData()

            advanceTimeBy(1)
            assertEquals(BlogListState(isLoading = true, blogs = null), viewModel.state)

            advanceTimeBy(200)
            assertFalse(viewModel.state.isLoading)
            assertTrue { viewModel.state.blogs?.isFailure == true }
        }
    }

    @Test
    fun `読み込み済みなら再読み込み阻止`() {
        val blogs: List<Blog> = listOf(Blog("A"), Blog("B"))
        val state = BlogListState(isLoading = false, blogs = Result.success(blogs))
        savedStateHandle.mockkSaveableState(state) // ... (G)
        val viewModel = BlogListViewModel(repository, savedStateHandle)
        runTest {
            assertEquals(state, viewModel.state)

            viewModel.loadData()

            advanceTimeBy(100)
            confirmVerified(repository) // ...(H)
            assertEquals(state, viewModel.state)
        }
    }

    private fun SavedStateHandle.mockkSaveableState(
        state: BlogListState
    ) {
        val bundle: Bundle = mockk()
        every { get<Bundle>("state") } returns bundle
        every { bundle.get(any()) } answers { mutableStateOf(state) }
    }
}

要点だけ:

  • (A) SavedStateHandle は実際にインスタンスを作れますが内部で Bundle を操作するところは mock が必要になるので spyk で 必要なところだけ mock に置き換えられる状態にしています。

  • (B)(C) ViewModel のテストなので Dispatchers.Main をテスト用のものにすり替えるのが必須です

  • (D) 状態遷移を見るため、 coEvery と coAnswer を使って repository の suspend 関数を遅延付きで応答する関数として mock しています。 runTest のなかでは advanceTimeBy により時間経過を制御できるので、 (E) で読み込み開始に isLoading フラグが立ったこと、 (F) で delay 後の応答を受領して読込中フラグが倒れたこと、を見ています

  • (G) で savedStateHandle が 保存された読み込み済み状態 を返すようにしてから、ViewModel のインスタンスを作ります。loadData を呼び出しても、 (H) で repository へのメソッド呼び出しがなにも起きなかったこと、 state が不変であったことを確認しています。

実際に実行してすべて成功することが確認できました。

まとめ

典型的な画面読み込みを題材として

  • Jetpack Compose と AAC ViewModel を用いた宣言的 UI の実装例

  • androidx.lifecycle 2.5.0 で強化された SavedStateHandle と Jetpack Compose の状態連携の例示

  • および上記を使った ViewModel の単体テストの実装例

をご紹介しました。
今回はかなり限定的な部分だけを扱っていますが、最近の Android Jetpack の更新は使ってみたくなる改善がいろいろとあるので、いち早く製品に取り入れていけるよう引き続き探求していきたいと思います。

プロダクトチームの採用情報、直近のイベント情報は、下記からぜひご覧ください。皆さんのエントリーをお待ちしています。


この記事が気に入ったらサポートをしてみませんか?