מהנדס קשרי מפתחים
התכונות של Compose – סביבת זמן ריצה של תמונת מצב, מעקב אוטומטי אחרי יחסי תלות ועדכונים אטומיים – לא חייבות להסתיים בשכבת ממשק המשתמש. Jetpack AppState מאפשר להעביר את המצב של פיתוח נייטיב במעלה הזרם – לייעל את מה שמגיע למסכים על ידי הרמה והפקה של המצב באמצעות מערכת תמונת המצב לפני שהוא מגיע לממשק המשתמש.
בשיעור הזה נלמד על שתי הספריות הבסיסיות של AppState. נלמד על StateStore להנפת מצב, ועל Transform לשימוש בקומפוזיציה כדי להפיק מצב באמצעות תהליך בקרה פשוט של Kotlin – בלי שרשראות שילוב מורכבות של Flow, וכך ממשק המשתמש יישאר מסונכרן בקלות.
combine, ותומך בהחזרות מוקדמות ובנגזרות של מצבים בתצוגת עץ.תמליל
שלום, וברוכים הבאים לסרטון "איך להשתמש ב-AppState של Jetpack כדי להרחיב את המצב של פיתוח נייטיב מעבר לממשק המשתמש".
קוראים לי בן, ואני מהנדס קשרי מפתחים שעובד על ספריות הארכיטקטורה של Jetpack.
ואני אליף, אחת מהמהנדסות שעובדות על ספריות הארכיטקטורה של האפליקציה.
והיום אנחנו כאן כדי לדבר איתכם על עתיד שבו Compose חורג מממשק המשתמש ונכנס לשכבות הנתונים והדומיינים של האפליקציות.
אני בטוח שכולכם כבר יודעים ש-Compose היא ערכת הכלים המומלצת לפיתוח ממשק משתמש ב-Android.
מטלפונים וטאבלטים ועד לווידג'טים, שעונים, טלוויזיות ומכשירי XR, Compose מפעיל את כולם.
אבל ככל שצמיחת האפליקציה כוללת יותר משטחי ממשק משתמש ורכיבי רקע, קשה יותר לנהל את הערך הדינמי שלהם, במיוחד בשתי בעיות נפוצות.
הראשון הוא הסקת מצב מורכב.
שילוב של כמה מקורות נתונים, קלט מקומי של ממשק המשתמש ומצב סביבתי, כמו קישוריות לרשת, שלעיתים קרובות הופך לסבך של אופרטורים כמו combine, flatMapLatest ו-distinctUntilChanged.
השני הוא שיתוף מצב בין רכיבים: שמירה על סנכרון של פעילות, שירות, ווידג'ט של אפליקציה ועוד, ולוודא שהמצב הזה יכול לשרוד את השבתת התהליך כשצריך.
כשהתבוננו באתגרים האלה, בדקנו איך Compose עצמו בנוי.
רוב האנשים חושבים על Compose כערכת כלים לממשק משתמש, אבל למעשה מדובר בשתי שכבות נפרדות: ממשק המשתמש של Compose למעלה וסביבת זמן הריצה של Compose למטה.
ממשק המשתמש של Compose מטפל בפריסה, בשרטוט ובקלט, ו-Compose Runtime הוא מנוע לשימוש כללי לניהול מצבים וליצירה מחדש.
הוא לא תלוי בממשק המשתמש.
ההבנה הזו הובילה אותנו לפתח את Jetpack AppState.
זו משפחה של ספריות שמתמקדות בהפקת המקסימום מהמצב של Compose מעבר לממשק המשתמש.
אבל לפני שנתעמק ב-AppState, חשוב לוודא שאנחנו מבינים מה הכוונה ב-Compose state.
התשובה היא 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", אנחנו בדרך כלל מתכוונים ל"מצב של Snapshot".
לכן, כשמפעילים את mutableStateOf, נוצר מאגר שמנתב את פעולות הקריאה והכתיבה דרך מערכת תמונות המצב.
ובכל פעם שהקוד פועל בתוך משקיף תמונת מצב, כמו קומפוזיציה, מקבלים שני מאפיינים שזרם כמו Flow לא נותן.
@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++ })
}
}כלומר, אין צורך להירשם או לאסוף את הנתונים.
המינוי הוא רק לקריאת .value.
אז אם נסתכל על Counter כאן, קריאת count.value ישירות בתוך ה-composable מדווחת על הקריאה הזו לקומפוזיציה, והיא רושמת את 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 ומשנה את הספירה, מערכת תמונת המצב עוקבת אחרי הכתיבה הזו, וכך הקומפוזיציה יודעת לבטל את התוקף של 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 וגם הספירה נקראים.
לכן, Counter רשום כקורא של שניהם.
וכאשר isVisible הוא false, אם הענף if לא פועל, count לא נקרא, ולכן Counter רשום רק כקורא של isVisible, ולא של count.
@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 כותב לספירה, אין יותר מה לבטל.
המאפיין השני הוא שלא צריך לבחור בין עקביות לבין רמת פירוט.
עקביות פירושה שכאשר מעדכנים כמה ערכים בתמונת מצב, השינויים האלה נשארים מבודדים בזמן שהקוד פועל, גם אם שרשור אחר כותב להם בו-זמנית.
העדכונים האלה מוצגים בבת אחת כשתמונת המצב מסתיימת.
רמת פירוט פירושה שניתן להצהיר על נכסים נפרדים ולעקוב אחריהם בנפרד, במיוחד אם הם לא קשורים זה לזה.
לכן, עדכון של מאפיין אחד מבטל רק את הקוראים שתלויים בו בפועל.
בואו נראה איך זה נראה בקוד אמיתי.
לדוגמה, נבחן משחק שיש לו שלושה מצבים: שם השלב, זמן ההישרדות והניקוד.
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0אז כדי להתחיל, בואו נסתכל על הטמעה של Flow שבוחרת בעקביות על חשבון רמת הפירוט על ידי קיבוץ של הכל למחלקה GameUiState.
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
)
}
}בממשק המשתמש, GameScreen אוסף את מצב ממשק המשתמש וקורא ממנו כדי להעביר את המאפיינים הרלוונטיים אל 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 משלו כדי לקבל רמת פירוט, אבל אז תאבדו את העקביות כי Thread אחר יכול לעדכן ערך בזמן שבלוק העדכון השני פועל.
עכשיו נבחן הטמעה חלופית שמבוססת על מצב.
@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
}
}ואז בממשק המשתמש, 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, מבוטל.
ולבסוף, שימו לב ששום דבר מזה לא קשור לממשק המשתמש.
ממשק המשתמש הוא רק מקום אחד שבו המאפיינים האלה באמת באים לידי ביטוי.
תודה שהגעתם לשיחה שלנו.
אוקיי, בלי בדיחות, אני אעביר את השרביט לאליף כדי שתסביר לנו על הספרייה הראשונה, Transform.
אוקיי.
תודה, בן.
אז כמו שבן ציין, מצב תמונת המצב של Compose מציע לנו מעקב קריאה אוטומטי ודינמי, עקביות ורמת פירוט, מה שמוביל אותי לשאלה, מה אם היינו יכולים להניע את זרימת הנתונים שלנו גם על ידי תמונת מצב של Compose – מצב תמונת מצב?
ואם המצב הוא מודל הנתונים שלנו, אז איך אנחנו בעצם מחשבים ומשנים מצב לכל הצרכנים שלו?
כאן נכנסת לתמונה הספרייה החדשה Jetpack AppState Transform.
לפני שנתעמק בספרייה עצמה, אנחנו צריכים לדבר על איך הספרייה נוצרה.
אז הרעיון של שימוש במנוע של Compose, שהוא סביבת זמן הריצה והקומפיילר כאן, כדי ליצור ערכי מצב בקומפוזיציה ללא ממשק משתמש, הוא לא חדש.
יכול להיות שרבים מכם כבר משתמשים בספריית Molecule או מכירים אותה.
הכוונה ב-Headless היא שאין ממשק משתמש.
אז Molecule היה חלוץ בהדגמת הכוח של שימוש ב-Compose Runtime, והוא עשה זאת על ידי הצגת פעולה שפונקציה קומפוזבילית לא חייבת לפלוט צומתי ממשק משתמש, וניתן למעשה להשתמש בה כדי לכתוב פונקציה קומפוזבילית שמחזירה מודל נתונים טהור.
במקרה של Molecule, זה היה Kotlin Flow או StateFlow.
זה אומר שבמקום לשרשר אופרטורים ריאקטיביים כמו combine, אפשר לכתוב קוד Kotlin רגיל.
אבל במקום להתעסק עם תהליכים, מה אם מודל הנתונים שלכם היה State, כי ממשק המשתמש שלכם כבר נמצא ב-Compose?
ב-Transform, מודל הנתונים הוא המצב.
בואו נראה את ה-API בפועל.
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>זה כל ה-API.
זה מאוד פשוט, פונקציה אחת.
היא מחזירה State פשוט שניתן לצפייה, לא Flow ולא StateFlow.
ומי שמשתמש בנתונים האלה לא יודע או לא אכפת לו שהם נוצרו באמצעות טרנספורמציה.
מה שמעניין כאן הוא הבלוק הזה.
הוא קומפוזבילי.
זה אומר שכל מה שאתם כבר יודעים עובד בתוכו.
זכרו: LaunchedEffect, collectAsState, החזרות מוקדמות, הצהרות if פשוטות, כל מה שאפשר לקרוא מתוך Composable, אפשר להפיק מתוך Transform.
חשוב לציין שכל ממשקי ה-API שמוצגים היום במצגת הזו נמצאים בשלב אלפא, והם עשויים להשתנות. בכל מקרה, בואו נסתכל על דוגמה.
לדוגמה, נניח שיש לנו אפליקציה קטנה ופשוטה של טיימר לריכוז.
בוחרים משימה, מגדירים טיימר ל-25 דקות והשעון מתחיל לספור לאחור.
בכל פעם שיוצרים מסך בודד, אפילו מסך פשוט כמו זה, כנראה צריך לשלב ולנהל כמה מקורות נתונים נפרדים כדי להרכיב את המצב מאחורי הקלעים.
למסך המיקוד, נניח שיש לנו שניים: סשן המיקוד עצמו, שיהיה השלב שבו אנחנו נמצאים, כלומר מושהה או לא, כמה שניות נותרו, וכל זה יגיע ממנוע טיימר בתור StateFlow שיתקתק נניח כל שנייה אחת.
מקור הנתונים השני יהיה המשימה הנוכחית שהמשתמש עובד עליה, שתגיע ממסד נתונים של Room בתור Flow.
כלומר, שני מקורות נתונים ומצב מסך אחד בסוף.
ה-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,
)
}אז משלבים את שני התהליכים יחד לתוך זרמים, ההסתעפות בתוך הלמבדה, אז 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 כדי להשהות ולהמשיך את הסטרימינג במעלה הזרם, טרנספורמציה פועלת באופן רציף במהלך משך החיים של ההיקף הזה.
שוב, המקורות שלנו הם תהליכים, ותהליכים הם דבר מבורך.
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 קר, אז אנחנו נותנים לו ערך.
זה כל הסיפור של המינויים.
הקריאה היא מה שגורם לנו להירשם.
זהו מעקב הקריאה האוטומטי שבן דיבר עליו קודם בדוגמה שראינו, אבל ב-ViewModel.
אנחנו יכולים אפילו להוסיף החזרות מוקדמות כאלה כאן כשאנחנו במצב לא פעיל.
נניח שאין לנו משימות חדשות, אפילו לא נעבד את השאר.
ושוב, זה היתרון של מעקב דינמי אחרי קריאה.
כשהמכשיר לא פעיל, לא נאספים נתונים על זרימת האוויר בחדר.
בסופו של דבר, אנחנו מפיקים את הסיכום.
אז אם המשתמש משהה את המשימה או שהטיימר מתקתק, הכל מתעדכן באופן אוטומטי.
אז העניינים קצת יותר מסתבכים כשיוצאים מהדוגמה של ViewModel היחיד.
נניח שאותו סשן צריך להופיע בשני משטחים נוספים: סרגל טיימר קטן שמופיע בתחתית המסך של האפליקציה בכל מסך, ווידג'ט במסך הבית.
שניהם יצטרכו את אותו מידע שנגזר מסיכום הסשן, באיזה שלב הוא נמצא, אם הוא מושהה או לא, כמה זמן נשאר בטיימר.
זה אומר שאם כל פלטפורמה תרכיב עותק משלה, זה יהיה אותו חישוב שלוש פעמים בשלושה מקומות שונים שכולם צריכים להיות תואמים זה לזה.
לכן, מה שנצטרך לעשות במקום זאת הוא להפיק את הסיכום פעם אחת בהיקף האפליקציה, ואז כל מה שבהמשך יצרוך אותו.
אז איך בעצם מטמיעים דבר כזה?
ההטמעה שלנו תכלול כנראה שלושה חלקים.
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, שניהם יצרכו את הסיכום שיופק במנהל הסשנים הזה של סשן המיקוד.
בואו נבנה כל אחד מהם בדרך המסורתית ואז עם Transform.
אז באופן מסורתי, שוב, נתחיל בהפקת הסיכום פעם אחת בהיקף האפליקציה בתור StateFlow.
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
)
}אלה אותן קריאות ל-combine ול-stateIn שראינו קודם בדוגמה של ViewModel, אבל הן פשוט הועברו רמה אחת למעלה.
בעזרת 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 } }
}נניח שאנחנו רוצים לאתגר את עצמנו קצת גם כאן.
נניח שבטיימר המיני, בנוסף לשני מקורות הנתונים שכבר היו לנו, StateFlow ו-Room Flow, אנחנו רוצים גם לעקוב אחרי השאלה אם סרגל הטיימר מורחב או לא.
אנחנו משתמשים ב-MutableStateFlow בשביל זה.
כדי לעשות את זה, נצטרך להשתמש ב-combine אחר כדי לעקוב אחרי תהליך הסיכום ו-isExpanded StateFlow.
ואנחנו מקבלים מצב שני.
בעזרת 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 קורא את הסיכום, שהוא הפלט של הטרנספורמציה במעלה הזרם, בדיוק כמו כל מצב אחר, ואז הוא אוסף את isExpanded עם collectAsState.
אז אנחנו יכולים לשלב מצב במעלה הזרם וזרימה במורד הזרם ב-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, אנחנו יכולים לאסוף את ה-StateFlow עם collectAsState בתוך provideContent כמו כל Composable אחר.
עם Transform, זה אפילו פשוט יותר.
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}מנהל המיקוד שלנו מספק לנו מצב של Compose, אז אין שום דבר שאנחנו צריכים להמיר כאן.
בסופו של דבר, סרגל הטיימר המוקטן והווידג'ט במסך הבית תמיד יציגו את אותו המצב כי הם קוראים את אותו המצב.
ולא רק זה, אלא שכל שינוי שנעשה במצב הזה ישתקף בכל הצרכנים של המצב הזה.
למה הכוונה?
אז אם תלחצו על השהיה בווידג'ט כאן, הפעולה תעבור דרך המנהל לסטרימינג של הסשן, תצא דרך הסיכום, וכל שלושת המסכים האלה יעודכנו בסנכרון.
אחרי שראינו איך משתמשים ב-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, שמפעילה טרנספורמציה של Composable ב-TransformScope הזה ומחזירה מצב חד-פעמי.
אם תקראו אותו מלמעלה למטה, תראו שהוא עושה הכול בשבילכם.
הוא יוצר את TransformScope ומפעיל את הטרנספורמציה, וזה המצב שיוחזר.
ואז הוא גם מפעיל את run ב-TransformScope ב-dispatcher שסיפקתם, כך שמשך החיים של ה-scope וה-thread שייכים לכם.
ולבסוף, הוא נפטר מהטרנספורמציה כשההיקף מסתיים.
זהו הסיפור המלא של מחזור החיים של 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 הזה.
יש לה גם פונקציית הרצה מושהית, שמפעילה את ה-TransformScope ומתחילה את תהליך העדכון של הטרנספורמציות של המצב הנוכחי.
למה הכוונה?
לכן, הפונקציה run מגדירה בפועל את ה-Recomposer ואת הקומפוזיציה.
וכל Transform בהיקף נמצא בתוך הקומפוזיציה הזו ויש לו היקף משלו ליצירה מחדש של קומפוזיציה.
לכן אנחנו רוצים לשתף את ההיקף ולכן יש לנו את TransformScope.
כך שבכל פעם שהמצב משתנה, רק הטרנספורמציות שקוראות אותו מופעלות מחדש.
כמו שציינו קודם, run היא פונקציית השעיה.
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, זו התצוגה, אבל כמו שציינו קודם, אנחנו נמצאים בסביבה ללא ממשק משתמש, אז אין ממשק משתמש.
לכן, אם לא תספקו לנו שעון, ניצור שעון משלנו.
הפרדת ההרכבה מחדש מהשעון של מסגרת התצוגה מאפשרת ל-Transform להריץ את כל הטרנספורמציות והמצבים הנגזרים מחוץ לשרשור ה-UI.
כדי לאפשר את זה, פיתחנו HeadlessTransformClock ייעודי.
שעון עם מסגרת משתנה.
יכול להיות שווידג'ט, שירות או טרנספורמציה אחרת בהמשך השרשרת ממתינים לערך מטרנספורמציה, ולכן השעון שלנו פועל באופן מיידי.
זה אומר שברגע שמשתנה מצב ש-Transform משתמש בו, נוצר מיד פריים חדש.
זו מכונת מצבים קטנה ללא נעילה, כך שבקשת פריים היא פעולה פשוטה של השוואה והגדרה.
וכשאין שינוי במצב, השעון עוצר.
לכן, טרנספורמציה שצורכת מצב שלא השתנה בכלל לא עולה לכם כלום.
לפני שאחזיר את הבמה לבן, חשוב לציין ש-Transform תומך עכשיו ב-rememberSaveable כדי לוודא שהנתונים נשמרים גם כשמשנים את ההגדרות.
הוא עושה זאת באמצעות SavedStateContainer API החדש, שהוא עכשיו אבן הבניין שעליה מבוססים SaveableStateRegistry, SavedStateHandle ו-SavedStateRegistry של Compose.
אפשר להפוך כל אחד מהם ל-SavedStateContainer או ליצור קונטיינרים צאצאים ולהעביר אותם ל-Transform כדי לוודא שהנתונים נשמרים.
לסיום, תזכורת נוספת: כל ממשקי ה-API שאנחנו מדברים עליהם היום נמצאים בשלב אלפא, ולכן הם עשויים להשתנות.
עכשיו אני מעביר את השרביט בחזרה לבן.
טוב.
בסדר, תודה, Elif.
אז עכשיו, אחרי שראינו כמה המצב של Compose יעיל ואיך Transform מאפשר לנו להפיק אותו מחוץ לממשק המשתמש, אולי שמתם לב שבדוגמה עד עכשיו, הקלטים שלנו היו Flows שגישרנו באמצעות collectAsState.
אבל האם זה תמיד הכרחי?
למה לא להשתמש במצב של Compose ישירות במאחסני המצב?
כדי לענות על השאלה הזו, כדאי לחשוב על המצב בשלוש קטגוריות רחבות.
הראשון הוא מצב פעיל שנשמר רק בזיכרון.
למשל, סטטוס החיבור לרשת או ל-Bluetooth.
לא כדאי להשתמש בערך קבוע.
הסוג השני הוא מצב זמני שצריך לשרוד השבתת תהליך, אבל לא צריך לאחסן אותו ללא הגבלת זמן.
למשל, מצב ממשק המשתמש, כמו כרטיס מורחב או לא, ומצב הסשן, כמו מקבץ הפעילויות הקודמות (back stack) של הניווט או טיוטת טופס.
הסוג השלישי הוא מצב קבוע, שמאוחסן ללא הגבלת זמן.
למשל, העדפות משתמש או ספריית מדיה ששמורה במסד נתונים.
אז נתחיל עם איך אפשר להשתמש במצב Compose גם למצב פעיל וגם למצב זמני.
עד עכשיו, ההנחיות שלנו לגבי הארכיטקטורה שמרו את המצב של Compose בשכבת ממשק המשתמש, או לפעמים ב-ViewModels, ואמרנו לכם להשתמש ב-Flow בכל מקום אחר.
אבל למה זה קרה?
וזה בגלל שברגע שהייתם צריכים לשלב מאפיין מצב עם Flow, הייתם צריכים להמיר אותו בחזרה באמצעות snapshotFlow.
אבל עם Transform, אין צורך בהמרה הזו.
בואו נראה איך זה נראה או מה המשמעות של זה, החל מ-ViewModel.
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}בשביל קלט של מסך מקומי, כמו selectedTaskIds כאן, אפשר להצהיר על מאפיין mutableStateOf פשוט במקום MutableStateFlow.
@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, פשוט קוראים אותו ישירות בתוך ה-Transform, תוך כדי איסוף ה-Flow.
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, כי הוא נקרא בכמה מסכים או אפילו בכמה רכיבים של האפליקציה, כמו שירותים.
אבל זה לא אומר שאי אפשר להשתמש ב-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)
}וכל זה קורה ישירות, אין היקף של אפליקציה או היקף של coroutine שצריך לספק כדי לעדכן את המצב, זה פשוט עדכון ישיר וסינכרוני.
לכן, באמצעות Transform, ה-ViewModel שלכם יכול לשלב State ממנהלי Singleton, State ב-ViewModel ו-Flows ממאגרים, והכול בבלוק Transform אחד.
עכשיו נחזור למצב הזמני.
קודם הזכרתי שני סוגים: מצב ממשק המשתמש ומצב הסשן.
למצב של ממשק המשתמש, ואפילו למצב של סשן שמתקיים בתוך פעילות אחת, ViewModel ו-SavedState פועלים מצוין.
זה בדיוק מה שהם נועדו לעשות.
אבל, הבעיות מתחילות כשצריך לשתף את מצב הסשן הזה בין הרבה ממשקי משתמש ורכיבי אפליקציה שונים.
כשהמצב הזה משתנה מחוץ ל-ViewModel, יש פער מסוים בין ממשקי ה-API הקיימים שלנו לבין ההנחיות שלנו.
אז קודם כל, ממשקי ה-API של SavedState שומרים ב-Bundle של Activity, אבל זה לא זמין ברכיבי אפליקציה אחרים, כמו שירות.
שנית, mutableStateOf ב-Singleton, שקיים רק בזיכרון, ולכן הוא לא שורד את השבתת התהליך.
זה הפער ש-Jetpack AppState, StateStore, ממלא.
זו מפה עם מניעת שגיאות הקלדה של ערך דינמי של Compose שאפשר לשתף בין רכיבי האפליקציה, והיא מגובה על ידי DataStore כדי לספק, כדי לשרוד את השבתת התהליך.
אפשר לחבר את DataStore לכל Singleton בעצמכם, אבל זה אומר שתצטרכו לכתוב קוד סטנדרטי לשמירה ולשחזור של כל מאפיין.
עם השילוב של StateStore עם DataStore, מחברים את DataStore פעם אחת, ואז בוחרים להצטרף לכל חלק קטן של ערך דינמי שרוצים לשמר.
אז בואו נבנה את זה באפליקציית טיימר המיקוד שלנו.
נתחיל קודם בזיכרון, ואז נוסיף את ההתמדה של DataStore.
כדי להכין את הקרקע, אנחנו יוצרים מודל של FocusSession כממשק אטום עם מצבי חוסר פעילות, הפעלה והשהיה.
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)ואז אנחנו מספקים Singleton StateStore ב-Hilt שאנחנו יכולים להחדיר לרכיבים שונים שבהם אנחנו צריכים אותו, כמו 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 קטן.
יש לו רק שלוש שיטות, אז נעבור עליהן אחת אחת.
הראשונה היא 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)
}כשהסשן מתחיל, אנחנו מגדירים אותו כפעיל, וכשהסשן מסתיים, אנחנו מגדירים אותו כלא פעיל.
זה הכול.
שנית, עדיין יש לנו את 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, שמחזיר מצב לקריאה בלבד למפתח, ויש לו אפשרות לשנות את ערך ברירת המחדל, אם רוצים לשנות אותו באתר קריאה ספציפי.
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}בנוסף, חשיפת המצב של FocusSession במנהל הזה מאפשרת לצרכנים לא לגשת ישירות ל-StateStore או למפתחות שלו.
אתם יכולים לשמור על אנקפסולציה של כתיבה וקריאה.
ואז, במקום צרכנים, הם פשוט מזריקים את Focus Session Manager, שכנראה כבר יש להם כי הם צריכים לשלוט בסשן, ואז הם יכולים לקרוא את המצב.
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}ואז, הקוראים האלה יכולים לחשוף מחדש את המצב באופן ישיר, או להשתמש ב-Transform כדי ליצור מצב חדש ומעודכן, כמו שראינו בדוגמה של ViewModel קודם לכן.
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}ואז, הרכיבים הקומפוזביליים של ממשק המשתמש יכולים לקרוא את המצב הזה בדיוק כמו כל מצב אחר של Compose, ולבצע קומפוזיציה מחדש כשצריך.
@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 כשיוצרים את החנות.
@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 משתמש ב-API של המאזין של Transform, שהוא בעצם בדיוק אותו דבר כמו 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 בכל פעם שהם משתנים.
הנקודה החשובה כאן היא שקונטיינר סטנדרטי כמו StateStore מאפשר לנו לספק את השילובים האלה.
DataStore הוא רק הראשון שאנחנו בודקים.
אז אם נחזור לקטגוריות של המצבים, עכשיו יש לנו תמונה די ברורה של איך אפשר להשתמש במצב של Compose בכל האפליקציה.
מ-mutableStateOf למצב פעיל, לממשקי ה-API של SavedState למצב של הצגה מקומית במסך, ו-StateStore למצב של סשן בין רכיבים, ולבסוף, יש לנו את collectAsState לפעולה הדדית עם Flows.
אבל למעשה, יכולת הפעולה ההדדית לא מוגבלת לקטגוריה אחת. אתם יכולים פשוט להשתמש ברכיבי העזר האלה כמו collectAsState בכל פעם שאתם צריכים לגשר בין סוגים של Observable לבין Compose State.
אבל לגבי מצב קבוע, דבר נוסף שאנחנו בוחנים הוא דרכים לעבוד עם Compose או עם DataStore ו-Room כדי לחשוף ישירות את Compose State, כך שלא תצטרכו לכתוב תחביר משלכם, כמו תחביר נחמד יותר, כדי לעשות את זה.
אני שמח להודיע ש-Transform, StateStore והשילוב של DataStore זמינים עכשיו בגרסת אלפא. פרסמנו גם את כל המסמכים ב-developer.android.com, ויש לנו מאגר של AppState Recipes ב-GitHub עם דוגמאות לשימוש ב-StateStore וב-Transform.
[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" }