Product@KAUCHE
カウシェでは、「Enjoy Working」というバリューを掲げ、様々なバックグラウンドをもつメンバーが自ら働く環境を楽しみ、熱量を最大化する組織作りを目指しています。 ここでは、特にプロダクトユニットの体制や働き方について、ご紹介します。
https://enjoy-working.kauche.com/product
こんにちは、株式会社カウシェで Android 版アプリを開発している @sintario です。
今回は Jetpack Compose でのアプリ開発における androidx.lifecycle 2.5.0 を使った状態管理のシンプルな実装プラクティスをご紹介します。
・本稿執筆時のライブラリ
・問題設定
・素朴に実装してみる: androidx.lifecycle 2.4.x の範囲で
・API と状態定義
・Jetpack Compose で状態を UI に反映する
素朴にデータ読み込みを実装してみる
・少し厳しい条件を追加:状態保持
・状態を持ち越せる ViewModel に書き換える
・androidx.lifecycle 2.5.0 のリリースノートを読む
・ViewModel に androidx.lifecycle 2.5.0 を適用してみる
・ViewModel の単体テスト
・まとめ
アプリ開発をしていると、こういう処理を頻繁に書きますよね、という問題設定です
- 画面を開くと、 API からデータを読み込み、それを表示する- API からのデータ読込中はプログレスインジケーターを表示する- API からのデータ読み込みが失敗したら、再読み込みボタンを表示し、タップすると再読み込みができること
これだけだったらカンタンですよね。
API の抽象化として
import java.io.Serializable
data class Blog(val title: String) : Serializable
copy
interface BlogRepository {
suspend fun loadBlogs(): Result<List<Blog>>
}
copy
という形でデータを返してくれるインターフェイスがあるとします。
画面の状態を表現するのについてはこんな感じではいかがでしょう。
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class BlogListState(
val isLoading: Boolean = false,
val blogs: Result<List<Blog>>? = null,
) : Parcelable
copy
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 = "もう一回")
}
}
}
}
}
}
}
copy
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()) }
}
}
}
copy
そして 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()
+ }
copy
これで BlogListScreen() を画面上に配置するだけで自動で読み込みが始まって表示されるコンポーネントのできあがりです。
ここまではなんにも難しくないので、もうちょっと現実にありそうな要件を追加します。
- 画面上でデータ読み込みが成功したら、その画面のまま他アプリにスイッチしてから戻ってきてもデータ再読み込みが発生しないこと
カウシェの場合ですと、購入手続きのために一度 Shopify の決済画面に行って戻ってくるというのもあり、購入の途中でちょっとアプリ外の情報を見てから戻ってきて…なんていう一瞬外に行くユースケースが想定されるので、アプリが取得済みのデータを揮発させずに保持したいという状況はリアルに存在します。
Android Architecture Components の ViewModel を使っているんだからこんな条件自動で満たすでしょ(?)、とお思いの方もいるかも知れませんが、Android はバックグラウンドに行ったアプリの Activity を生かしておいてもらえる保証がない OS なので、対策が必要になります。
下記に引用した図の通り、例えば Activity に対して払い出した ViewModel インスタンスには対応する生存期間があって、 Activity が破棄されるときにクリアされてしまい、次に同じ画面が再生成されるときには別の ViewModel インスタンスになります。インスタンスが使い回されることを期待した実装だと、インスタンスが使い回されている間はデータを引き継げますが、前面にいなくなった Activity が破棄されてしまうとその画面を再生成されるときには別の ViewModel インスタンスが払い出されるので\(^o^)/オワタとなります。
ご利用の検証機種にも依存するので、確実にバックグラウンドでの画面廃棄を体験するには、開発者向けオプションの「アクティビティを保持しない」が使えます。
今回の問題設定では画面単位でのキャッシュで良く、アプリに永続的に書き込んでおくようなものではないので、 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"
}
}
copy
再読み込みの抑止は (3) です。
ViewModel が clear される際に揮発させたくないデータを保存する処理 (2) が追加され、それに対応して復元すべき情報があれば Bundle を掘り出して使う処理 (1) が追加されました。また、状態保管のために Bundle の中に埋め込まなくてはならない都合で独自にキーを定数 (4) として持たないといけなくなりました。
これで対応はできてるんですが、雑然としてきましたね。 (3) は要件なので外せないとしても、それ以外が明らかに煩わしい手続きです。
そもそも、 UI が Jetpack Compose なので、参照側では viewModel.state.collectAsState() のようなさらなる変換をしていたことも思い出すと、もっと簡単にかけないかなあという気持ちが湧いてきます。
https://developer.android.com/jetpack/androidx/releases/lifecycle#2.5.0 を見ますと、いろいろと Kotlin Coroutines と Jetpack Compose 全盛の時代を象徴するような魅惑の更新が書いてあります。特に今回は SavedStateHandle Compose セーバー統合 というのに注目します。
非常に簡潔な一節ですが、これだけで SavedStateHandle から androidx.compose.runtime.MutableState を直接的に出し入れすることができるようになったこと が見て取れます。実際に組み込んでみましょう。
ではさっそく使ってみます。
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())
}
}
}
copy
( ゚д゚)ハッ!
一度は複雑化した実装がずいぶんと簡単になりました。 (a) だけに状態管理に関するごちゃごちゃした処理が全て隠しきれています。
この 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 = "もう一回")
}
}
}
}
}
}
}
copy
collectAsState がなくなって (b) のようにあたかも viewModel の state を見ているだけ、と素直に読める形になりました。
アクティビティを保持しない設定にした実機上で動作確認したところ、バックグラウンド・フォアグラウンド切り替えの際にクラッシュすることもなく、意図したように画面再表示時にローディングUIを出さずにリストが表示されるのが確認できました。
機能実装はできたので、最後に単体テストを。状態遷移のテストをしましょう。
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)
}
}
copy
そこからもう少し掘り進んで
@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
}
copy
つまり 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) }
}
copy
といった感じの関数を用意することで保管状態からの再開を再現できそうです。ここで "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) }
}
}
copy
要点だけ:
実際に実行してすべて成功することが確認できました。
典型的な画面読み込みを題材として
をご紹介しました。
今回はかなり限定的な部分だけを扱っていますが、最近の Android Jetpack の更新は使ってみたくなる改善がいろいろとあるので、いち早く製品に取り入れていけるよう引き続き探求していきたいと思います。
プロダクトチームの採用情報、直近のイベント情報は、下記からぜひご覧ください。皆さんのエントリーをお待ちしています。