תשתית לאפליקציות

איך להשתמש במצב של Compose מעבר לממשק המשתמש באמצעות Jetpack AppState

רמקולים

  • בן סגמו (Ben Sagmoe)

    מהנדס קשרי מפתחים

  • אליף בילגין מוריס

    מהנדס תוכנה

מידע כללי

מידע על ההרצאה

התכונות של Compose – סביבת זמן ריצה של תמונת מצב, מעקב אוטומטי אחרי יחסי תלות ועדכונים אטומיים – לא חייבות להסתיים בשכבת ממשק המשתמש. ‫Jetpack AppState מאפשר להעביר את המצב של פיתוח נייטיב במעלה הזרם – לייעל את מה שמגיע למסכים על ידי הרמה והפקה של המצב באמצעות מערכת תמונת המצב לפני שהוא מגיע לממשק המשתמש.

בשיעור הזה נלמד על שתי הספריות הבסיסיות של AppState. נלמד על StateStore להנפת מצב, ועל Transform לשימוש בקומפוזיציה כדי להפיק מצב באמצעות תהליך בקרה פשוט של Kotlin – בלי שרשראות שילוב מורכבות של Flow, וכך ממשק המשתמש יישאר מסונכרן בקלות.

סיכום

  • Compose Runtime פועל כמנוע לניהול מצבים למטרות כלליות בלי Compose UI. מערכת תמונת המצב של Firestore עוקבת אחרי קריאות באופן דינמי ושומרת על עקביות של עדכונים בו-זמניים בין נכסים.
  • ‫Jetpack AppState Transform מחשב וחושף את Compose State מבלוקים קומפוזביליים ללא ממשק משתמש. הוא מחליף שרשראות מורכבות של אופרטורים של Flow כמו combine, ותומך בהחזרות מוקדמות ובנגזרות של מצבים בתצוגת עץ.
  • הטרנספורמציה משתמשת בשעון פריים ללא ממשק משתמש כדי להריץ טרנספורמציות מחוץ לשרשור UI. השעון מושהה בכל פעם שהמצבים שנמצאים במעקב לא משתנים, כדי למנוע עבודה מיותרת.
  • ‫StateStore של Jetpack AppState משתף את Compose State בין רכיבים שונים, כמו פעילויות ושירותים שפועלים ברקע. שילוב של SavedStateHandle עם DataStore מאפשר למפתחות ספציפיים לשרוד את השבתת התהליך בלי קוד סטנדרטי בהתאמה אישית.

משאבים

Gemini Notebook

נושאים

  • jetpack-compose
  • jetpack-appstate
  • ניהול מצב (state-management)
  • android-architecture
  • compose-runtime

תמליל

הורדת התמליל

מבוא

  • שלום, וברוכים הבאים לסרטון "איך להשתמש ב-AppState של Jetpack כדי להרחיב את המצב של פיתוח נייטיב מעבר לממשק המשתמש".

  • קוראים לי בן, ואני מהנדס קשרי מפתחים שעובד על ספריות הארכיטקטורה של Jetpack.

  • ואני אליף, אחת מהמהנדסות שעובדות על ספריות הארכיטקטורה של האפליקציה.

  • והיום אנחנו כאן כדי לדבר איתכם על עתיד שבו Compose חורג מממשק המשתמש ונכנס לשכבות הנתונים והדומיינים של האפליקציות.

  • אני בטוח שכולכם כבר יודעים ש-Compose היא ערכת הכלים המומלצת לפיתוח ממשק משתמש ב-Android.

  • מטלפונים וטאבלטים ועד לווידג'טים, שעונים, טלוויזיות ומכשירי XR, ‏Compose מפעיל את כולם.

  • אבל ככל שצמיחת האפליקציה כוללת יותר משטחי ממשק משתמש ורכיבי רקע, קשה יותר לנהל את הערך הדינמי שלהם, במיוחד בשתי בעיות נפוצות.

  • הראשון הוא הסקת מצב מורכב.

  • שילוב של כמה מקורות נתונים, קלט מקומי של ממשק המשתמש ומצב סביבתי, כמו קישוריות לרשת, שלעיתים קרובות הופך לסבך של אופרטורים כמו combine,‏ flatMapLatest ו-distinctUntilChanged.

  • השני הוא שיתוף מצב בין רכיבים: שמירה על סנכרון של פעילות, שירות, ווידג'ט של אפליקציה ועוד, ולוודא שהמצב הזה יכול לשרוד את השבתת התהליך כשצריך.

  • כשהתבוננו באתגרים האלה, בדקנו איך Compose עצמו בנוי.

  • רוב האנשים חושבים על Compose כערכת כלים לממשק משתמש, אבל למעשה מדובר בשתי שכבות נפרדות: ממשק המשתמש של Compose למעלה וסביבת זמן הריצה של Compose למטה.

  • ממשק המשתמש של Compose מטפל בפריסה, בשרטוט ובקלט, ו-Compose Runtime הוא מנוע לשימוש כללי לניהול מצבים וליצירה מחדש.

  • הוא לא תלוי בממשק המשתמש.

  • ההבנה הזו הובילה אותנו לפתח את Jetpack AppState.

  • זו משפחה של ספריות שמתמקדות בהפקת המקסימום מהמצב של Compose מעבר לממשק המשתמש.

מהו מצב ב-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 שאנחנו מדברים עליהם היום נמצאים בשלב אלפא, ולכן הם עשויים להשתנות.

  • עכשיו אני מעביר את השרביט בחזרה לבן.

מצב Compose מחוץ לממשק המשתמש

  • טוב.

  • בסדר, תודה, 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, שקיים רק בזיכרון, ולכן הוא לא שורד את השבתת התהליך.

חדש: StateStore

  • זה הפער ש-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

  • אז אם נחזור לקטגוריות של המצבים, עכשיו יש לנו תמונה די ברורה של איך אפשר להשתמש במצב של 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" }
  • ואנחנו ממליצים לכם לנסות אותם באפליקציה שלכם.

  • לשלוח לנו משוב.

  • למשל, אנחנו בשלב אלפא, אז זה הזמן שבו אנחנו יכולים באמת לחזור על הפעולות ולבצע שינויים שיבטיחו שהן יתאימו לתרחישי השימוש שלכם.

קוד

קטעי קוד

קטע קוד ב-Kotlin בדקה 01:56 בסרטון "What is Compose 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
}
קטע קוד ב-Kotlin בדקה 02:00 בסרטון "What is Compose State?‎"
/**
 * 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
}
קטע קוד ב-Kotlin בדקה 02:23 בסרטון "What is Compose State?‎"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
}
קטע קוד ב-Kotlin בדקה 02:32 בסרטון "What is Compose State?‎"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}
קטע קוד ב-Kotlin בדקה 02:52 בסרטון "What is Compose State?‎"
@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")
    }
}
קטע קוד ב-Kotlin בדקה 03:08 בסרטון "What is Compose State?‎"
@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++ })
    }
}
קטע קוד ב-Kotlin בדקה 03:26 בסרטון "What is Compose State?‎"
@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++ })
    }
}
קטע קוד ב-Kotlin בדקה 04:15 בסרטון "What is Compose State?‎"
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0
קטע קוד ב-Kotlin בדקה 04:23 בסרטון "What is Compose State?‎"
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)
קטע קוד ב-Kotlin בדקה 04:33 בסרטון "What is Compose State?‎"
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
        )
    }
}
קטע קוד ב-Kotlin בדקה 04:42 בסרטון "What is Compose State?‎"
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
        )
    }
}
קטע קוד ב-Kotlin בדקה 05:09 בסרטון "What is Compose State?‎"
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}
קטע קוד ב-Kotlin בדקה 05:21 בסרטון "What is Compose State?‎"
@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
    }
}
קטע קוד ב-Kotlin בדקה 05:30 בסרטון "What is Compose State?‎"
@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)
    }
}
קטע קוד ב-Kotlin בדקה 07:35 בסרטון Introducing Transform
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R>
קטע קוד ב-Kotlin בדקה 09:20 בסרטון Introducing Transform
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,
)
}
קטע קוד ב-Kotlin בדקה 09:37 בסרטון Introducing 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(...)
קטע קוד ב-Kotlin בדקה 10:01 בסרטון "Introducing 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(...)
        }
}
קטע קוד ב-Kotlin בדקה 11:39 בסרטון "Nested transforms"
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
    ...
}

class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
    ...
}

class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            ...
        }
    }
}
קטע קוד ב-Kotlin בדקה 12:03 בסרטון "Nested transforms"
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
)
}
קטע קוד ב-Kotlin בדקה 12:17 בסרטון "Nested transforms"
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(...)
        }
}
קטע קוד ב-Kotlin בדקה 12:28 בסרטון "Nested transforms"
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 } }
}
קטע קוד ב-Kotlin בדקה 12:56 בסרטון "Nested transforms"
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 } }
}
קטע קוד ב-Kotlin בדקה 13:37 בסרטון "Nested transforms"
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
                .collectAsState()
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
קטע קוד ב-Kotlin בדקה 13:47 בסרטון "Nested transforms"
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
קטע קוד ב-Kotlin בדקה 14:33 בסרטון "Nested transforms"
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
}
קטע קוד ב-Kotlin בדקה 15:18 בסרטון "Nested transforms"
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()
}
קטע קוד ב-Kotlin בדקה 16:02 בסרטון "Nested transforms"
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
        }
    }
קטע קוד ב-Kotlin בדקה 19:12 בסרטון "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    private val networkMonitor: NetworkMonitor,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    ...
}
קטע קוד ב-Kotlin בדקה 19:17 בסרטון "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set
}
קטע קוד ב-Kotlin בדקה 19:33 בסרטון "מצב פיתוח נייטיב מחוץ לממשק המשתמש"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set
}
קטע קוד ב-Kotlin בדקה 19:41 בסרטון "Compose State outside of the UI"
@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()
        }
    }
}
קטע קוד ב-Kotlin בדקה 19:52 בסרטון "Compose State outside of the UI"
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)
}
קטע קוד ב-Kotlin בדקה 20:00 בסרטון "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(
    @ApplicationContext context: Context,
) {
    private val connectivityManager =
        context.getSystemService(ConnectivityManager::class.java)
    ...
}
קטע קוד ב-Kotlin בדקה 20:17 בסרטון "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline
}
קטע קוד ב-Kotlin בדקה 20:23 בסרטון "Compose State outside of the UI"
@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)
    }
}
קטע קוד ב-Kotlin בדקה 20:32 בסרטון "Compose State outside of the UI"
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)
}
קטע קוד ב-Kotlin בדקה 22:07 בסרטון Introducing StateStore
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
}
קטע קוד ב-Kotlin בדקה 22:14 בסרטון "Introducing StateStore"
sealed interface FocusSession { ... }

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
קטע קוד ב-Kotlin בדקה 22:23 בסרטון "Introducing StateStore"
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(): StateStore = StateStore()
}

@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    ...
}
קטע קוד ב-Kotlin בדקה 22:42 בסרטון "Introducing 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)
}
קטע קוד ב-Kotlin בדקה 22:52 בסרטון "Introducing StateStore"
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)
    }
}
קטע קוד ב-Kotlin בדקה 23:03 בסרטון Introducing StateStore
@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
    ...
}
קטע קוד ב-Kotlin בדקה 23:22 בסרטון "Introducing StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }

@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
    fun focusSessionManager(): FocusSessionManager
}
קטע קוד ב-Kotlin בדקה 23:33 בסרטון Introducing StateStore
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}
קטע קוד ב-Kotlin בדקה 23:45 בסרטון "Introducing StateStore"
@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)
    }
}
קטע קוד ב-Kotlin בדקה 23:58 בסרטון "Introducing StateStore"
@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) }
    }
}
קטע קוד ב-Kotlin בדקה 24:04 בסרטון "Introducing StateStore"
@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
קטע קוד ב-Kotlin בדקה 24:22 בסרטון "Introducing StateStore"
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener { … }
}
קטע קוד ב-Kotlin בדקה 24:33 בסרטון "Introducing StateStore"
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
                }
            }
        }
    }
}
קטע קוד TOML בדקה 25:39 בסרטון "Summary of Compose State categories"
[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" }