デベロッパー リレーション エンジニア
Compose のスナップショット ランタイム、自動依存関係トラッキング、アトミック アップデートは、UI レイヤで停止する必要はありません。Jetpack AppState を使用すると、Compose の状態をアップストリームに移動できます。つまり、UI に到達する前にスナップショット システムで状態をホイスティングして導出することで、画面に到達する内容を効率化できます。
このセッションでは、AppState の 2 つの基盤ライブラリについて説明します。状態をホイスティングする StateStore と、コンポジションを使用してプレーンな Kotlin 制御フローで状態を導出する Transform について詳しく説明します。これにより、複雑な Flow 結合チェーンが不要になり、UI を簡単に同期できるようになります。
combine などの複雑な Flow 演算子チェーンを置き換え、早期リターンとネストされた状態の導出をサポートします。文字起こし
「Jetpack AppState で Compose の状態を UI の枠を超えて活用する」へようこそ。
私は Ben です。Jetpack アーキテクチャ ライブラリを担当するデベロッパー リレーションズ エンジニアです。
Elif です。アプリ アーキテクチャ ライブラリを担当するエンジニアの一人です。
本日は、Compose が UI を超えてアプリケーションのデータレイヤとドメインレイヤにまで進出する未来についてお話しします。
ご存じのとおり、Compose は Android での UI 開発に推奨されるツールキットです。
スマートフォンやタブレットから、ウィジェット、スマートウォッチ、テレビ、XR デバイスまで、Compose はあらゆるデバイスに対応しています。
しかし、アプリが成長して UI サーフェスやバックグラウンド コンポーネントが増えると、その背後にある状態の管理が難しくなります。特に、2 つの一般的な課題があります。
1 つ目は、複雑な状態を導出することです。
複数のデータ ストリーム、ローカル UI 入力、ネットワーク接続などのアンビエント状態を組み合わせると、combine、flatMapLatest、distinctUntilChanged などの演算子が複雑に絡み合うことがよくあります。
2 つ目は、コンポーネント間での状態の共有です。アクティビティ、サービス、アプリ ウィジェットなどの同期を維持し、必要に応じてプロセスの終了後も状態が維持されるようにします。
これらの課題を振り返ったとき、Compose 自体がどのように構築されているかを確認することにしました。
Compose は UI ツールキットと考えられがちですが、実際には、上部の Compose UI と下部の Compose ランタイムという 2 つの異なるレイヤで構成されています。
Compose UI はレイアウト、描画、入力を処理しますが、Compose ランタイムは状態管理と再コンポーズのための汎用エンジンです。
UI に依存しません。
この認識が、Jetpack AppState の開発につながりました。
これは、UI 以外でも Compose の状態を最大限に活用することに重点を置いたライブラリ ファミリーです。
AppState の詳細に入る前に、Compose の状態とは何かについて、認識を一致させておきましょう。
正解は androidx.compose.runtime.State です。
/**
* A value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value.
*/
@Stable
public interface State<out T> {
public val value: T
}そして、おそらくより使い慣れているインターフェースである MutableState です。
/**
* A mutable value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value. When the
* [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
* will be scheduled. If [value] is written to with the same value, no recompositions will be
* scheduled.
*/
@Stable
public interface MutableState<T> : State<T> {
override var value: T
public operator fun component1(): T
public operator fun component2(): (T) -> Unit
}基本的に、これらは最小限のコンテナです。
これらを実際に機能させているのは、Compose のスナップショット システムです。
そのため、Compose の状態とは、通常はスナップショットの状態を意味します。
そのため、mutableStateOf を呼び出すと、スナップショット システムを介して読み取りと書き込みをルーティングするコンテナが作成されます。
また、コンポジションなどのスナップショット オブザーバー内でコードが実行されるたびに、Flow などのストリームでは提供されない 2 つのプロパティが取得されます。
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
}1 つ目は、自動かつ動的な読み取りトラッキングです。
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}つまり、自動とは、サブスクライブや収集を行わないということです。
.value を読み取るだけで、サブスクリプションになります。
ここで Counter を見てみましょう。コンポーズ可能な関数内で count.value を直接読み取ると、その読み取りが Composition に報告され、Counter が読み取り元のリストに登録されます。
次に AddButton を見てみましょう。
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}
@Composable
fun AddButton(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("Add")
}
}クリック時にカウントを書き込むだけです。
そのため、リーダーではありません。
ユーザーが AddButton をクリックして count を変更すると、スナップショット システムはその書き込みを追跡します。これにより、コンポジションは Counter を無効にすることを認識しますが、AddButton は無効にしません。
動的とは、実際に実行されるコードパスに基づいて、実行時に読み取りトラッキングが行われることを意味します。
@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(true) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}ここでは、isVisible フラグの背後でカウンタ テキストを保護しています。
isVisible が true の場合、isVisible と count の両方が読み取られます。
そのため、Counter は両方の読み取り元として登録されます。
isVisible が false の場合、if 分岐は実行されず、count は読み取られないため、Counter は count ではなく isVisible のリーダーとしてのみ登録されます。
@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(false) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}そのため、AddButton が count に書き込むときに、無効化するものは残っていません。
2 つ目の特性は、一貫性と粒度のどちらかを選択する必要がないことです。
整合性とは、スナップショット内の複数の値を更新するときに、別のスレッドが同時に書き込みを行っている場合でも、コードの実行中はそれらの変更が分離されたままになることを意味します。
これらの更新は、スナップショットが完了すると一度に表示されます。
粒度とは、特に無関係なプロパティを個別に宣言して追跡できることを意味します。
そのため、1 つのプロパティを更新すると、実際にそのプロパティに依存しているリーダーのみが無効になります。
実際のコードで見てみましょう。
たとえば、レベル名、生存時間、スコアという 3 つの状態を持つゲームについて考えてみましょう。
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0まず、すべてを GameUiState クラスにバンドルすることで、粒度を犠牲にして整合性を選択する Flow の実装を見てみましょう。
data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)つまり、updateScore が実行されるたびに GameUiState 全体がコピーされ、timeSurvived とスコアが同期して更新されます。
data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}UI では、GameScreen が UI 状態を収集し、そこから読み取って、関連するプロパティを Header と ScoreBadge に渡します。
data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column {
Header(levelName = state.levelName)
ScoreBadge(
timeSurvived = state.timeSurvived,
score = state.score
)
}
}GameScreen はすべての状態を読み取るため、updateScore を呼び出すたびに、ヘッダーのデータが変更されていなくても GameScreen が無効になります。
ここで、各プロパティを独自の Flow に分割して粒度を細かくすることもできますが、そうすると、別の更新ブロックの実行中に別のスレッドが値を更新する可能性があるため、一貫性が失われます。
次に、別の状態ベースの実装について考えてみましょう。
@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}この例では、再び GameUiState を使用します。
しかし、この例では、各プロパティが mutableStateOf に委譲されるため、少し異なります。
ここで、updateScore は書き込みを withMutableSnapshot でラップします。これにより、書き込み全体で整合性が確保されます。
@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}UI では、GameScreen が State を Header と ScoreBadge に直接渡します。
@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state = viewModel.uiState
Column {
Header(state = state)
ScoreBadge(state = state)
}
}プロパティを読み取らないため、updateScore の実行時に GameScreen は読み取り側になりません。
実際のコンポーネント(この場合は ScoreBadge)のみが無効になります。
最後に、このいずれも UI に関連付けられていないことに注目してください。
UI は、これらのプロパティが真価を発揮する場所の一つにすぎません。
本日はご参加いただきありがとうございました。
冗談はさておき、最初のライブラリである Transform については、Elif に説明してもらいましょう。
違いますね。
ありがとう、Ben。
ベンが言ったように、Compose のスナップショット状態は、自動的かつ動的な読み取りトラッキング、整合性、粒度を提供します。そこで、データフローも Compose の状態スナップショット(スナップショット状態)によって駆動できるとしたらどうでしょうか。
状態がデータモデルである場合、すべてのコンシューマーの状態を実際に計算して変換するにはどうすればよいでしょうか。
そこで、新しい Jetpack AppState Transform ライブラリの出番です。
ライブラリ自体について詳しく説明する前に、ライブラリがどのようにして生まれたかについて説明する必要があります。
そのため、Compose エンジン(ここではランタイムとコンパイラ)を利用してヘッドレス コンポジションで状態値を生成するというアイデアは、新しいものではありません。
Molecule ライブラリをすでに使用している、またはよく知っている方も多いかもしれません。
ここでいう「ヘッドレス」とは、UI がないことを意味します。
Molecule は Compose Runtime の力を実証する先駆者となりました。コンポーズ可能な関数は UI ノードを出力する必要はなく、純粋なデータモデルを返すコンポーズ可能な関数を記述するために実際に使用できることを実証したのです。
Molecule の場合、これは Kotlin Flow または StateFlow でした。
つまり、combine などのリアクティブ演算子を連結する代わりに、通常の Kotlin を記述できます。
しかし、UI がすでに Compose にあるため、Flow を扱う代わりに、データモデルが State だったらどうでしょうか。
Transform では、データモデルが State になります。
実際の API を見てみましょう。
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>これが API のすべてです。
1 つの関数で構成される非常にシンプルなものです。
Flow や StateFlow ではなく、プレーンな監視可能な State を返します。
また、それを使用するユーザーは、Transform がそれを生成したことを知ることも、気にする必要もありません。
ここで特に興味深いのは、このブロックです。
コンポーザブルである。
つまり、すでに知っていることはすべてその中で機能します。
LaunchedEffect、collectAsState、早期リターン、プレーンな if など、コンポーザブル内から読み取れるものはすべて、変換で派生させることができます。
このプレゼンテーションで紹介する API はすべてアルファ版であり、変更される可能性があります。それでは、例を見てみましょう。
そこで、非常にシンプルで小さなフォーカス タイマー アプリを例に考えてみましょう。
タスクを選び、25 分のタイマーをセットして、時計が時を刻みます。
1 つの画面を作成するたびに、このようなシンプルな画面を作成する場合でも、複数の独立したデータ ストリームを組み合わせて管理し、内部で状態を組み立てる必要があります。
フォーカス画面には、フォーカス セッション自体と、タイマー エンジンから 1 秒ごとに StateFlow として送信される情報の 2 つがあるとします。フォーカス セッション自体は、現在のフェーズ(一時停止中かどうか、残り秒数など)を示します。
もう 1 つのデータ ストリームは、ユーザーが現在取り組んでいるタスクで、Room データベースから Flow として取得されます。
つまり、2 つのデータ ストリームと、最後に 1 つの画面状態があります。
ViewModel は、それらをまとめる場所です。その出力(ここではサマリーと呼びます)は、次に記述するコードの一部になります。
従来は、ViewModel で、チェーン演算子を使用してこれらのフローを組み合わせる必要がありました。
class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: StateFlow<FocusSummary> =
combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle,
)
}そこで、2 つのフローを 1 つのストリームに結合し、ラムダ内で分岐(if else)して、最後に状態を返します。
Transform を使用すると、このボイラープレート コード全体を削除できます。
class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)この呼び出しは、スコープ内の初期値が渡されるため、見覚えがあるように見えますが、チェーンするアップストリームはなく、派生はブロック自体です。
また、stateIn とは異なり、アップストリーム ストリームを一時停止して再開するために WhileSubscribed タイムアウトを構成する必要がある場合、Transform はそのスコープの存続期間内で継続的に実行されます。
繰り返しになりますが、私たちのソースはフローであり、フローは歓迎されます。
class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}それぞれを collectAsState でブリッジします。セッションは StateFlow であるため、初期値は必要ありませんが、Room Flow はコールドであるため、初期値を指定します。
定期購入のストーリーは以上です。
読み取りによってサブスクライブされます。
これは、先ほどの例で Ben が話していた自動読み取りトラッキングですが、ViewModel で行われます。
アイドル状態のときに、このように早期復帰を導入することもできます。
新しいタスクがない場合は、残りの処理は行われません。
ここでも、動的読み取りトラッキングが効果を発揮しています。
アイドル状態の間は、Room Flow はまったく収集されません。
最後に、要約を出力します。
ユーザーがタスクを一時停止したり、タイマーが作動したりすると、すべてが自動的に更新されます。
単一の ViewModel の例から外れると、少し複雑になります。
たとえば、この同じセッションを、さらに 2 つのサーフェス(アプリのすべての画面の下部に表示されるミニタイマーバーと、ホーム画面のウィジェット)に表示する必要があるとします。
どちらも、セッションの概要、フェーズ、一時停止中かどうか、タイマーの残り時間など、セッションに関する同じ派生情報を必要とします。
つまり、すべてのサーフェスが独自のコピーを組み立てる場合、3 つの異なる場所で同じ派生が 3 回行われ、それらがすべて互いに一致している必要があります。
そのため、代わりに、アプリケーション スコープでサマリーを 1 回導出し、下流のすべてでそれを使用する必要があります。
では、実際にこのようなものを実装するにはどうすればよいでしょうか。
この実装には 3 つの要素が必要になると思われます。
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}つまり、アプリケーション スコープのセッション マネージャー(概要が取得される場所)、ミニタイマーの ViewModel、Glance ウィジェットです。後者の 2 つは、このフォーカス セッション マネージャーで取得された概要を使用します。
従来の方法と Transform を使用する方法で、それぞれを構築してみましょう。
従来は、まずアプリケーション スコープで StateFlow としてサマリーを 1 回導出していました。
class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: StateFlow<FocusSummary> =
combine(
sessionFlow,
taskRepository.tasksFlow
) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}これは、前の ViewModel の例で見たものと同じ combine 呼び出しと stateIn 呼び出しですが、1 つ上のレベルに移動しています。
Transform では、先ほど確認した ViewModel の同じ宣言を再利用することもできます。
class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = scope,
container = handle.getOrCreateContainer("transform")
) {
val session by sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow
.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}変更されたのは、アプリケーション スコープに配置されたことだけです。
次に、ミニタイマーの ViewModel を作成します。
class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: StateFlow<MiniTimerUiState> =
combine(
focusManager.summary,
isExpanded
) { summary, expanded ->
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MiniTimerUiState()
)
fun toggleExpanded() { isExpanded.update { !it } }
}ここで、少し難易度を上げてみましょう。
ミニタイマーで、すでに存在する 2 つのデータ ストリーム(StateFlow と Room Flow)に加えて、タイマーバーが展開されているかどうかも追跡するとします。
そのために MutableStateFlow を使用します。
これを行うには、別の combine を使用して、概要フローと isExpanded StateFlow を追跡する必要があります。
そして、2 つ目の状態が追加されます。
Transform を使用すると、データ ストリーム間の同期を実現するために、これらのチェーン演算子を再度使用する必要はありません。
class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: State<MiniTimerUiState> =
transform(
initialValue = MiniTimerUiState(),
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val summary by focusManager.summary
val expanded by isExpanded.collectAsState()
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}
fun toggleExpanded() { isExpanded.update { !it } }
}ここでできることは、ネストされた変換です。これは非常に便利です。
ここで、ViewModel は、他の状態と同様に、アップストリーム変換の出力であるサマリーを読み取り、collectAsState を使用して isExpanded を収集します。
そのため、同じブロック内で、StateFlow のアップストリームの状態とダウンストリームのフローを混在させることができます。
粒度を意識する。
したがって、isExpanded を切り替えると、このブロックのみが再実行されます。
アプリレベルのブロックとウィジェットは、このことを認識する必要さえありません。
最後に、ウィジェットがあります。
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}Glance は Compose であるため、他のコンポーザブルと同様に、provideContent 内で collectAsState を使用して StateFlow を収集できます。
Transform を使用すると、さらに簡単になります。
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}フォーカス マネージャーは Compose の State を提供しているため、ここで変換する必要はありません。
最終的に、ミニタイマーバーとホーム画面ウィジェットは同じ状態を読み取るため、不一致になることはありません。
それだけでなく、その状態に加えられた変更は、その状態のすべてのコンシューマーに反映されます。
どういう意味ですか?
たとえば、このウィジェットで一時停止ボタンを押すと、その操作がマネージャーを介してセッション ストリームに送られ、サマリーを介して戻ってきます。そして、これら 3 つの画面がすべて同期して更新されます。
Transform の使用方法を簡単に確認したところで、その仕組みを詳しく見ていきましょう。
先ほど説明した Transform API は、実際には基本的なマネージド変換を作成するために使用されるヘルパー関数です。
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R> {
val transformScope = TransformScope()
val transform = transformScope
.launchTransform(initialValue, onUpdate)
scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
) {
try { transformScope.run() } finally {
transform.dispose()
}
}
return transform
}実際には、TransformScope と launchTransform 拡張関数を使用します。これにより、この TransformScope でコンポーズ可能な変換が起動され、使い捨ての状態が返されます。
上から下まで読むと、すべてが自動的に行われることがわかります。
TransformScope を作成し、その中に Transform を起動します。これが、返される状態になります。
また、指定したディスパッチャの TransformScope で起動が実行されるため、スコープのライフタイムとスレッドはユーザーに属します。
そして最後に、スコープが終了すると変換を破棄します。
これが Transform のライフサイクル全体のストーリーです。
では、TransformScope のインターフェースを簡単に見てみましょう。
public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
public abstract class TransformScope {
public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
public abstract suspend fun run()
}
public fun <T> TransformScope.launchTransform(
initialState: T,
onUpdate: @Composable (T) -> T,
): DisposableState<T>
public interface DisposableState<T> : State<T> {
public fun dispose()
}createTransform 関数があり、この TransformScope にアタッチされた Transform を作成することがわかります。
また、suspend 関数である run もあり、TransformScope を開始して、現在の状態変換を更新するプロセスを開始します。
どういう意味ですか?
つまり、run は実際に Recomposer とコンポジションをセットアップします。
また、スコープ内のすべての Transform はそのコンポジション内に存在し、独自の再コンポーズ スコープを持ちます。
このため、スコープを共有する必要があり、TransformScope が用意されています。
これにより、状態が変更されるたびに、その状態を読み取る変換のみが再実行されます。
前述のように、run は suspend 関数です。
override suspend fun run() {
val context = currentCoroutineContext()
val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
...
val recomposer = Recomposer(context)
val composition = Composition(UnitApplier, recomposer)
try {
withContext(clock) {
if (clock is HeadlessTransformClock) {
launch(start = CoroutineStart.UNDISPATCHED) {
clock.runClock()
}
}
...
}
} finally {
// dispose of composition, cancel the clock, close the recomposer
}
}そのため、呼び出し元のコルーチンがキャンセルされるまで、そのコルーチンで実行されます。
ここで、Recomposer にはフレームを生成するタイミングを伝えるフレーム クロックが必要なのではないか、という疑問が生じるかもしれません。
Compose UI では、それがディスプレイですが、前述のように、ヘッドレス環境であるため、UI はありません。
そのため、クロックを指定しない場合は、独自のクロックが作成されます。
Transform は、再コンポーズをディスプレイ フレーム クロックから切り離すことで、すべての変換と状態の導出を UI スレッド外で実行します。
これを可能にするため、専用の HeadlessTransformClock を構築しました。
フレーム クロックを変換します。
ウィジェット、サービス、または別の Transform の下流が Transform からの値を受け取るのを待っている可能性があるため、クロックは積極的です。
つまり、Transform が使用している状態が変化した瞬間に、新しいフレームがすぐに生成されます。
これは小さなロックフリーのステート マシンであるため、フレームのリクエストは単純な比較設定オペレーションです。
状態に変化がない場合、クロックは停止します。
したがって、変更されていない状態を使用する変換には費用がかかりません。
Ben に戻す前に、Transform が rememberSaveable をサポートするようになったこともお伝えしておきます。これにより、構成変更をまたいでデータが保存されるようになります。
これは、新しい SavedStateContainer API を通じて行われます。この API は、Compose の SaveableStateRegistry、SavedStateHandle、SavedStateRegistry のすべてが構築される構成要素になりました。
これらを SavedStateContainer に変換するか、子コンテナを作成して Transform に渡すことで、データを確実に保存できます。
最後に、本日ご紹介した API はすべてアルファ版であり、変更される可能性があることを再度お伝えしておきます。
それでは、Ben にバトンタッチします。
それでは
Elif、ありがとう。
Compose の状態がどれほど強力か、そして Transform を使用して UI の外部で状態を導出する方法について説明しました。これまでの例で気づいたかもしれませんが、入力は collectAsState を使用してブリッジした Flow でした。
しかし、これは常に必要なのでしょうか。
状態ホルダーで Compose の状態を直接使用しないのはなぜでしょうか。
この質問に答えるには、状態を 3 つの大きなカテゴリに分けて考えるとよいでしょう。
1 つ目は、メモリにのみ保存されるライブ状態です。
たとえば、ネットワークや Bluetooth の接続ステータスなどです。
永続化された値は使用したくないでしょう。
2 つ目は、プロセスの終了後も存続する必要があるものの、無期限に保存する必要はない一時的な状態です。
たとえば、カードが展開されているかどうかなどの UI 状態や、ナビゲーション バックスタックやフォームの下書きなどのセッション状態です。
3 つ目は永続的な状態です。これは無期限に保存されます。
たとえば、ユーザー設定やメディア ライブラリをデータベースに保存する場合です。
まず、Compose の状態をライブ状態と一時的な状態の両方に使用する方法から説明します。
これまでのアーキテクチャ ガイダンスでは、Compose の状態を UI レイヤ(場合によっては ViewModel)に保持し、それ以外の場所では Flow を使用するよう推奨していました。
なぜでしょうか?
なぜなら、状態プロパティを Flow と組み合わせる必要が生じるとすぐに、snapshotFlow を使用して変換し直す必要があったからです。
しかし、Transform を使用すれば、この変換は必要ありません。
では、ViewModel から始めて、これがどのようなものか、どのような意味を持つのかを見ていきましょう。
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}ここでの selectedTaskIds のようなローカル画面入力の場合、MutableStateFlow ではなくプレーンな mutableStateOf プロパティを宣言できます。
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}これは、プロセスの終了をまたいで保持する必要があるものではないため、mutableStateOf を使用するだけで問題ありません。
検索クエリなど、プロセスの終了後も保持したいものについては、savedStateHandle.saveable を使用して変更可能な状態を直接バックアップできます。
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}また、先ほどのゲームの例の updateScore と同様に、メソッドが複数の状態値を一度に更新する場合は、書き込みを withMutableSnapshot でラップすることで整合性が確保されます。
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
fun updateSearchQuery(query: String) {
Snapshot.withMutableSnapshot {
searchQuery = query
selectedTaskIds = emptySet()
}
}
}次に、searchQuery を Room Flow と組み合わせるには、Flow を収集しながら、Transform 内で直接読み取ります。
val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}では、ネットワーク接続などのライブ状態はどうでしょうか。
@Singleton
class NetworkMonitor @Inject constructor(
@ApplicationContext context: Context,
) {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
...
}これは通常、シングルトンに存在します。複数の画面や、サービスなどの複数のアプリ コンポーネントによって読み取られるためです。
ただし、Compose State を使用して保存できないわけではありません。
実際、フローベースのバージョンよりもシンプルな場合がよくあります。
適応を行うためにコールバック フローなどは必要ありません。コールバックでドット値を直接更新するだけです。
@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}また、コンシューマが引き続きフローを必要とする場合は、snapshotFlow を使用してブリッジバックできます。
@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { _isOnline.value = true }
override fun onLost(network: Network) { _isOnline.value = false }
}
init {
connectivityManager.registerDefaultNetworkCallback(callback)
}
}では、ViewModel でどのように表示されるか見てみましょう。
isOnline は Transform で直接読み取ることができ、値が変更されると再コンポーズされます。
val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
if (!networkMonitor.isOnline.value) {
return@transform TaskListState.Offline
}
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}これらはすべて直接行われます。状態を更新するために提供する必要があるアプリケーション スコープやコルーチン スコープなどはなく、直接的な同期更新が行われます。
そのため、Transform を使用すると、ViewModel はシングルトン マネージャーからの State、ViewModel 内の State、リポジトリからの Flow をすべて 1 つの Transform ブロックに結合できます。
では、一時的な状態に戻りましょう。
先ほど、UI 状態とセッション状態の 2 種類について説明しました。
UI の状態や、単一のアクティビティ内に存在するセッションの状態には、ViewModel と SavedState が適しています。
まさにそのために設計されています。
しかし、そのセッション状態をさまざまな UI やアプリ コンポーネント間で共有する必要がある場合、問題が発生し始めます。
その状態が ViewModel の外部に移動すると、既存の API とガイダンスでは少しギャップが生じます。
まず、SavedState API はアクティビティの Bundle に保存しますが、これはサービスなどの他のアプリ コンポーネントでは使用できません。
2 つ目は、シングルトンの mutableStateOf です。これはメモリ内にのみ存在するため、プロセスの終了後まで存続しません。
このギャップを埋めるのが、Jetpack AppState の StateStore です。
これは、Compose State の型安全性マップであり、アプリ コンポーネント間で共有でき、DataStore によってバックアップされるため、プロセスの終了後も維持されます。
DataStore をすべてのシングルトンに自分で接続することもできますが、その場合、すべてのプロパティに対して保存と復元のボイラープレートを作成する必要があります。
StateStore DataStore 統合では、DataStore を一度フックすると、永続化する状態を少しずつオプトインできます。
では、フォーカス タイマー アプリにこれを組み込んでみましょう。
まずインメモリから始めて、後で DataStore 永続性を追加します。
まず、FocusSession を、アイドル状態、実行中、一時停止状態を持つ sealed インターフェースとしてモデル化します。
sealed interface FocusSession {
data object Idle : FocusSession
data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}そして、この状態を StateStore に保存するために、StateStoreKey を定義し、その型を FocusSession、デフォルト値を idle にしました。
sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)次に、Hilt でシングルトン StateStore を提供します。これは、Focus Session Manager など、必要なさまざまなコンポーネントに挿入できます。
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
...
}これは、ViewModel が SavedStateHandle を挿入する方法に似ています。
違いますね。
これで StateStore が作成されましたが、どのように使用すればよいでしょうか。
幸い、これは小さな API です。
メソッドは 3 つだけなので、1 つずつ見ていきましょう。
1 つ目は setState で、キーの新しい値を書き込みます。
fun startFocus(taskId: String, durationMinutes: Int = 25) {
val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
stateStore.setState(
FocusSessionKey,
FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
)
}
fun endFocus() {
stateStore.setState(FocusSessionKey, FocusSession.Idle)
}セッションが開始されると、実行中に設定され、セッションが終了すると、アイドル状態に設定されます。
以上が、
2 つ目は、updateState です。これは、現在の値に基づいて新しい値を計算するために使用します。たとえば、実行中と一時停止中を切り替えるときに、残り時間を計算するために使用します。
fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Running) return@updateState session
FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
}
}
fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Paused) return@updateState session
FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
}
}最後に、getState はキーの読み取り専用の State を返します。特定の呼び出しサイトでデフォルト値を変更する場合は、デフォルト値をオーバーライドするオプションがあります。
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}また、このマネージャーで FocusSession 状態を公開することで、コンシューマーが StateStore やそのキーに直接アクセスする必要がなくなります。
書き込みと読み取りのカプセル化を維持できます。
そして、コンシューマーの代わりに FocusSessionManager を挿入します。これは、セッションを制御するためにすでに用意されているはずです。そして、State を読み取ることができます。
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}そして、それらのリーダーは、State を直接再公開するか、Transform を使用して、先ほどの Mini-Timer ViewModel の例のように、新しい更新された State を作成できます。
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}その後、UI コンポーザブルは他の Compose State と同様にその State を読み取り、必要に応じて再コンポーズできます。
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}
@Composable
fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
val session by viewModel.session
if (session !is FocusSession.Idle) {
TimerReadout(session = session)
}
}しかし、これまでのところ、FocusSession はメモリにのみ存在しています。
では、プロセスの終了を回避するために必要なことを見てみましょう。
そのため、まず、ストアの作成時に syncToDataStore を起動して、1 回限りの設定を行う必要があります。
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(
@ApplicationContext context: Context,
@ApplicationScope scope: CoroutineScope,
): StateStore = StateStore().apply {
val path = context.dataStoreFile("app_state.json").absolutePath
scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
}
}その後、保持する各キーをオプトインします。
@Serializable
sealed interface FocusSession {
@Serializable data object Idle : FocusSession
@Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
@Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
PersistToDataStore必要な作業は、型がシリアル化可能であることと、キーが PersistToDataStore インターフェースを実装していることを確認することだけです。
「簡単そうだけど、
では、キーを保持するために実際には何が行われるのでしょうか。
そのため、内部的には、syncToDataStore は Transform のリスナー API を使用します。これは基本的に Transform とまったく同じですが、何も返さず、監視と副作用にのみ使用されます。
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}そして、そのリスナー内で、ストア内のすべてのキーを読み取り、PersistToDataStore キーかどうかを確認します。
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener {
for (key in keys) {
if (key is PersistToDataStore) {
val value = getState(key).value
LaunchedEffect(key) {
// Restore saved value from dataStore on first access
}
LaunchedEffect(key, value) {
// Flush updated value to dataStore on change
}
}
}
}
}LaunchedEffect は、DataStore の値が変更されるたびに、その値を DataStore に同期します。
ここで注目していただきたいのは、StateStore のような標準コンテナがあることで、こうしたドロップイン統合を提供できるということです。
DataStore は、最初に確認するものです。
状態のカテゴリに話を戻すと、Compose の状態をアプリケーション全体で使用する方法が明確になりました。
ライブ状態用の mutableStateOf、画面ローカルのプレゼンテーション状態用の SavedState API、クロスコンポーネントのセッション状態用の StateStore、そして最後に、Flow との相互運用用の collectAsState があります。
実際には、相互運用性は特定のカテゴリに限定されません。オブザーバブル タイプを Compose State にブリッジする必要がある場合は、collectAsState などのヘルパーを使用できます。
永続的な状態については、Compose と連携する方法や、DataStore と Room で Compose State を直接公開する方法を検討しています。これにより、そのためのより優れた構文を独自に作成する必要がなくなります。
そして、Transform、StateStore、DataStore の統合がすべてアルファ版で利用可能になったことをお知らせします。また、developer.android.com ですべてのドキュメントを公開しました。GitHub には、StateStore と Transform の両方の使用例を示す AppState Recipes リポジトリがあります。
[versions]
appstate = "1.0.0-alpha01"
...
[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }ぜひアプリでお試しください。
フィードバックをお寄せください。
現在はアルファ版の段階ですので、この段階で繰り返しテストを行い、お客様のユースケースに確実に適合するよう変更を加えることができます。
/**
* A value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value.
*/
@Stable
public interface State<out T> {
public val value: T
}/**
* A mutable value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value. When the
* [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
* will be scheduled. If [value] is written to with the same value, no recompositions will be
* scheduled.
*/
@Stable
public interface MutableState<T> : State<T> {
override var value: T
public operator fun component1(): T
public operator fun component2(): (T) -> Unit
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}
@Composable
fun AddButton(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("Add")
}
}@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(true) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(false) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column {
Header(levelName = state.levelName)
ScoreBadge(
timeSurvived = state.timeSurvived,
score = state.score
)
}
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state = viewModel.uiState
Column {
Header(state = state)
ScoreBadge(state = state)
}
}public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: StateFlow<FocusSummary> =
combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle,
)
}class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: StateFlow<FocusSummary> =
combine(
sessionFlow,
taskRepository.tasksFlow
) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = scope,
container = handle.getOrCreateContainer("transform")
) {
val session by sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow
.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: StateFlow<MiniTimerUiState> =
combine(
focusManager.summary,
isExpanded
) { summary, expanded ->
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MiniTimerUiState()
)
fun toggleExpanded() { isExpanded.update { !it } }
}class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: State<MiniTimerUiState> =
transform(
initialValue = MiniTimerUiState(),
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val summary by focusManager.summary
val expanded by isExpanded.collectAsState()
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}
fun toggleExpanded() { isExpanded.update { !it } }
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R> {
val transformScope = TransformScope()
val transform = transformScope
.launchTransform(initialValue, onUpdate)
scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
) {
try { transformScope.run() } finally {
transform.dispose()
}
}
return transform
}public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
public abstract class TransformScope {
public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
public abstract suspend fun run()
}
public fun <T> TransformScope.launchTransform(
initialState: T,
onUpdate: @Composable (T) -> T,
): DisposableState<T>
public interface DisposableState<T> : State<T> {
public fun dispose()
}override suspend fun run() {
val context = currentCoroutineContext()
val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
...
val recomposer = Recomposer(context)
val composition = Composition(UnitApplier, recomposer)
try {
withContext(clock) {
if (clock is HeadlessTransformClock) {
launch(start = CoroutineStart.UNDISPATCHED) {
clock.runClock()
}
}
...
}
} finally {
// dispose of composition, cancel the clock, close the recomposer
}
}@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
fun updateSearchQuery(query: String) {
Snapshot.withMutableSnapshot {
searchQuery = query
selectedTaskIds = emptySet()
}
}
}val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}@Singleton
class NetworkMonitor @Inject constructor(
@ApplicationContext context: Context,
) {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
...
}@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { _isOnline.value = true }
override fun onLost(network: Network) { _isOnline.value = false }
}
init {
connectivityManager.registerDefaultNetworkCallback(callback)
}
}val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
if (!networkMonitor.isOnline.value) {
return@transform TaskListState.Offline
}
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}sealed interface FocusSession {
data object Idle : FocusSession
data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
...
}fun startFocus(taskId: String, durationMinutes: Int = 25) {
val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
stateStore.setState(
FocusSessionKey,
FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
)
}
fun endFocus() {
stateStore.setState(FocusSessionKey, FocusSession.Idle)
}fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Running) return@updateState session
FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
}
}
fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Paused) return@updateState session
FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
}
}@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}
@Composable
fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
val session by viewModel.session
if (session !is FocusSession.Idle) {
TimerReadout(session = session)
}
}@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(
@ApplicationContext context: Context,
@ApplicationScope scope: CoroutineScope,
): StateStore = StateStore().apply {
val path = context.dataStoreFile("app_state.json").absolutePath
scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
}
}@Serializable
sealed interface FocusSession {
@Serializable data object Idle : FocusSession
@Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
@Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
PersistToDataStoresuspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener {
for (key in keys) {
if (key is PersistToDataStore) {
val value = getState(key).value
LaunchedEffect(key) {
// Restore saved value from dataStore on first access
}
LaunchedEffect(key, value) {
// Flush updated value to dataStore on change
}
}
}
}
}[versions]
appstate = "1.0.0-alpha01"
...
[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }