AppState とは Jetpack Compose を UI レイヤーを超えて拡張し、アプリのあらゆるレイヤー間でリアクティブな状態を変換および共有できるようにするライブラリ群です。
Compose の UI で使用していた State を UI を超えて使うためのヘルパーを提供するものとなっています。
AppState 1.0.0-alpha01 時点では Transform と StateStore が用意されているので、それぞれ使い方を見ていきます。
Transform
Flow のようなリアクティブデータを UI で使用可能な状態に変換したり、複数の Flow をまとめて一つの State に統合したりするのが Transform のユースケースです。
Transform が提供する API は以下の変換する処理だけを提供しています。引数の onUpdate が Composable fun になっているのが特徴で、内部で購読や LaunchedEffect の使用ができるようになっています。
(つまりは UI モジュール以外での Compose Compiler の設定や Compose の buildFeature を有効にする必要がある)
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable (R) -> R,
): State<R>
@Composable
public fun <R> transform(
initialValue: R,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable (R) -> R,
): State<R>
public suspend fun listener(
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable () -> Unit,
)
例えば ViewModel で Transform を利用すると次のようになります。
onUpdate が Composable fun なので networkMonitor.isOnline の State の状態更新や Flow の購読を行うことができます。
interface NetworkMonitor {
val isOnline: State<Boolean>
}
interface MediaRepository {
val allTracksFlow: Flow<List<Track>>
val downloadedTracksFlow: Flow<List<Track>>
}
class HomeViewModel(
private val mediaRepository: MediaRepository,
private val networkMonitor: NetworkMonitor,
) : ViewModel() {
val uiState: State<HomeUiState> = transform<HomeUiState>(
initialValue = HomeUiState.Loading,
scope = viewModelScope,
) {
val isOnline by networkMonitor.isOnline
val tracks = if (isOnline) {
mediaRepository.allTracksFlow.collectAsState(initial = null).value
} else {
mediaRepository.downloadedTracksFlow
.collectAsState(initial = null).value
} ?: return@transform HomeUiState.Loading
HomeUiState.Success(
tracks = tracks,
isOnline = isOnline,
)
}
}
以下は LaunchedEffect を使用した debounce の実装例です。
Composable fun を使っているので LaunchedEffect のような副作用も扱うことができます。
var query: String by mutableStateOf("")
val searchResults: List<String> by transform<List<String>>(
initialValue = emptyList(),
scope = coroutineScope,
) {
var results by remember { mutableStateOf(emptyList<String>()) }
LaunchedEffect(query) {
if (query.isBlank()) {
results = emptyList()
return@LaunchedEffect
}
delay(300.milliseconds)
results = api.search(query)
}
results
}
StateStore
State をアプリ本体だけでなくて、AppWidget であったり Glasses 、 AppFunctions などの同じプロセスのコンポーネント間で共有するのがユースケースです。
StateStore が提供する API は以下で、key と CURD の機能を提供しています。
public class StateStore {
public val keys: Set<StateStoreKey<*>>
public fun <T> getState(
stateKey: StateStoreKey<T>,
defaultValue: T = stateKey.defaultValue,
): State<T>
public fun <T> setState(stateKey: StateStoreKey<T>, value: T)
public inline fun <T> updateState(
stateKey: StateStoreKey<T>,
defaultValue: T = stateKey.defaultValue,
update: (T) -> T,
)
}
@Serializable
public open class StateStoreKey<T>(
public val defaultValue: T,
)
基本的な使い方は以下のように、StateStore をインスタンス化して Repository などに渡して使います。
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Singleton
@Provides
fun provideStateStore(): StateStore = StateStore()
}
@Serializable
private object AccessTokenKey : StateStoreKey<String>("")
class AuthStateHolder @Inject constructor(
private val stateStore: StateStore
) {
val accessToken: State<String?> = derivedStateOf {
stateStore.getState(AccessTokenKey).value.ifBlank { null }
}
val isLoggedIn: State<Boolean> = derivedStateOf {
accessToken.value != null
}
fun setAccessToken(token: String) {
stateStore.setState(AccessTokenKey, token)
}
fun clear() {
stateStore.setState(AccessTokenKey, "")
}
}
StateStore と DataStore を統合することもできます。
PersistToDataStore のインターフェースを key に実装することで、永続化すべきものをマークし、StateStore#syncToDataStore で DataStore を bind します。
@Serializable
private object AccessTokenKey : StateStoreKey<String>(""), PersistToDataStore
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@Singleton
@Provides
fun provideStateStore(
@ApplicationContext context: Context,
): StateStore = StateStore().also { store ->
applicationScope.launch {
store.syncToDataStore(
path = dataStoreFile(context.applicationContext.filesDir, "statestore.json").absolutePath,
coroutineScope = applicationScope,
)
}
}
}
UI 以外で Compose を使うことについて
Jetpack Compose の State は Compose の snapshot を使った汎用的な値ホルダーで、主に UI の再構成に使用されますが State 自体は UI レンダリングとは直接関係ありません。
Compose 特有の snapshot による状態管理の恩恵をアプリのアーキテクチャ全体で受けられることが UI 以外で Compose を使うことのメリットで、AppState が提供する機能になります。
StateFlow と比較した場合は、StateFlow のように「単一の巨大なStateFlowで全てを包む(StateFlow<UiState> のような関係ない値の更新でも全て再計算されてしまう)」か、「複数の StateFlow に分ける(同時に更新できないため読み取り側が不整合な中間状態を見てしまう)」のどちらかになることを避けられるのが利点になります。
ちなみにこのアプローチは Molecule が元になっています。
AppState は Compose の機能を UI 以外でも使うかどうか、で議論が生まれると思います。個人的には現状の StateFlow で破綻していないなら、無理に採用する必要はないと見ています。
StateFlow を使っていて更新競合であったり combine での分岐の難しさに遭遇しているのであれば導入を検討してみるといいと思います。