أساسيات التطبيقات

تجاوز حالة Compose إلى ما هو أبعد من واجهة المستخدم باستخدام Jetpack AppState

سماعات

  • بن ساغمو

    مهندسة علاقات المطوّرين

  • إليف بيلغين موريس

    مهندس برمجيات

لمحة

لمحة عن المحاضرة

لا يجب أن تتوقف ميزات Compose، مثل وقت تشغيل اللقطة وتتبُّع التبعية التلقائي والتحديثات الذرية، عند طبقة واجهة المستخدم. تتيح لك Jetpack AppState نقل حالة Compose إلى أعلى السلسلة، ما يسهّل ما يصل إلى شاشاتك من خلال رفع الحالة واستخلاصها باستخدام نظام اللقطات قبل أن تصل إلى واجهة المستخدم.

في هذه الجلسة، سنتعرّف على مكتبتَي AppState الأساسيتَين. سنتناول StateStore لرفع الحالة، وTransform لاستخدام التركيب في اشتقاق الحالة من خلال تدفق التحكّم البسيط في Kotlin، ما يغني عن سلاسل دمج Flow المعقّدة ويحافظ على مزامنة واجهة المستخدم بسهولة.

ملخّص

  • يعمل Compose Runtime كمحرّك لإدارة الحالة للأغراض العامة بدون واجهة مستخدم Compose. يتتبّع نظام اللقطات عمليات القراءة بشكل ديناميكي ويحافظ على اتساق التحديثات المتزامنة في جميع المواقع.
  • تحسب Jetpack AppState Transform حالة Compose وتعرضها من وحدات قابلة للإنشاء بلا واجهة مستخدم رسومية. ويحلّ محل سلاسل عوامل تشغيل Flow المعقّدة، مثل combine، مع إتاحة عمليات الإرجاع المبكر واستخلاص الحالات المتداخلة.
  • تستخدم ميزة "التحويل" ساعة إطارات بدون واجهة مستخدم لتشغيل عمليات التحويل خارج سلسلة واجهة المستخدم. تتوقف الساعة مؤقتًا عندما تظل الحالات التي تتم مراقبتها بدون تغيير، ما يمنع إهدار العمل.
  • تشارك Jetpack AppState StateStore حالة Compose عبر مكوّنات مختلفة، مثل الأنشطة والخدمات التي تُشغَّل في الخلفية. ويتيح دمجها مع DataStore بقاء المفاتيح الفردية بعد إيقاف العملية نهائيًا بدون الحاجة إلى استخدام رمز نموذجي مخصّص.

الموارد

Gemini Notebook

المواضيع

  • jetpack-compose
  • jetpack-appstate
  • state-management
  • android-architecture
  • compose-runtime

النص

تنزيل النص

مقدمة

  • مرحبًا بكم في دورة "استخدام حالة Compose خارج واجهة المستخدم مع Jetpack AppState".

  • اسمي "بن"، وأنا مهندس علاقات مطوّرين أعمل على مكتبات بنية Jetpack.

  • اسمي "إليف"، وأنا أحد المهندسين الذين يعملون على مكتبات بنية التطبيقات.

  • واليوم، نحن هنا لنتحدث معكم عن مستقبل يتجاوز فيه Compose واجهة المستخدم وينتقل إلى طبقات البيانات والنطاق في التطبيقات.

  • كما تعلمون جميعًا، Compose هي مجموعة الأدوات المقترَحة لتطوير واجهة المستخدم على Android.

  • من الهواتف والأجهزة اللوحية إلى التطبيقات المصغّرة والساعات وأجهزة التلفزيون وأجهزة XR، تتيح لك أداة Compose إنشاء تطبيقات لكل هذه الأجهزة.

  • ولكن مع نمو تطبيقك عبر واجهات المستخدم ومكوّنات الخلفية هذه، تصبح إدارة الحالة وراءها أكثر صعوبة، خاصةً في ما يتعلق بنقطتَي ضعف شائعتَين.

  • الأول هو اشتقاق الحالة المعقدة.

  • يؤدي دمج مصادر بيانات متعددة وإدخالات واجهة المستخدم المحلية والحالة المحيطة، مثل الاتصال بالشبكة، إلى تشابك عوامل التشغيل مثل combine وflatMapLatest وdistinctUntilChanged.

  • والثاني هو مشاركة الحالة عبر المكونات: الحفاظ على مزامنة نشاط وخدمة وتطبيق مصغّر والمزيد، والتأكد من أن هذه الحالة يمكن أن تنجو من إيقاف العملية نهائيًا عند الحاجة.

  • عندما تراجعنا للنظر في هذه التحديات، توصلنا إلى النظر في كيفية إنشاء Compose نفسه.

  • يعتقد معظم الأشخاص أنّ Compose هي مجموعة أدوات لواجهة المستخدم، ولكنّها في الواقع تتألف من طبقتَين مختلفتَين: واجهة مستخدم Compose في الأعلى وبيئة تشغيل Compose في الأسفل.

  • بينما تتعامل واجهة مستخدم Compose مع التنسيق والرسم والإدخال، فإنّ Compose Runtime هو محرّك للأغراض العامة لإدارة الحالة وإعادة الإنشاء.

  • ولا يعتمد على واجهة المستخدم.

  • وهذا ما دفعنا إلى إنشاء Jetpack AppState.

  • وهي مجموعة من المكتبات التي تركّز على الاستفادة إلى أقصى حد من حالة Compose خارج واجهة المستخدم.

ما هي حالة الإنشاء؟

  • قبل التعمّق في AppState، لنتأكّد من أنّنا متفقون على المقصود بحالة Compose.

  • والإجابة هي androidx.compose.runtime.State.

    /**
     * A value holder where reads to the [value] property during the execution of a [Composable]
     * function, the current [RecomposeScope] will be subscribed to changes of that value.
     */
    @Stable
    public interface State<out T> {
        public val value: T
    }
  • وواجهة الاستخدام التي من المرجّح أنّك على دراية بها، وهي MutableState.

    /**
     * A mutable value holder where reads to the [value] property during the execution of a [Composable]
     * function, the current [RecomposeScope] will be subscribed to changes of that value. When the
     * [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
     * will be scheduled. If [value] is written to with the same value, no recompositions will be
     * scheduled.
     */
    @Stable
    public interface MutableState<T> : State<T> {
        override var value: T
        public operator fun component1(): T
        public operator fun component2(): (T) -> Unit
    }
  • في الأساس، هذه مجرد حاويات أساسية.

  • ما يجعلها تعمل فعليًا هو نظام لقطات Compose.

  • لذا، عندما نقول "حالة Compose"، فإنّنا نعني عادةً "حالة اللقطة".

  • لذلك، عند استدعاء mutableStateOf، يتم إنشاء حاوية توجّه عمليات القراءة والكتابة من خلال نظام اللقطات.

  • وعندما يتم تشغيل الرمز داخل مراقب لقطة، مثل التركيب، تحصل على خاصيتين لا يوفرهما لك تدفق مثل Flow.

    @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 مباشرةً داخل الدالة المركّبة تُرسل تقريرًا إلى عملية الإنشاء، وتسجّل 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 ويعدّل العدد، يتتبّع نظام اللقطة عملية الكتابة هذه، ما يتيح للإنشاء معرفة أنّه يجب إبطال "العدّاد"، ولكن ليس 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 صحيحة، تتم قراءة كل من isVisible والعدد.

  • لذلك، يتم تسجيل Counter كقارئ لكليهما.

  • وعندما تكون قيمة isVisible خاطئة، إذا لم يتم تشغيل فرع 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" في "count"، لن يبقى أي شيء لإبطاله.

  • والآن، الخاصية الثانية هي عدم الاضطرار إلى الاختيار بين الاتساق والدقة.

  • يعني الاتساق أنّه عند تعديل قيم متعددة في لقطة، تبقى هذه التغييرات معزولة أثناء تشغيل الرمز، حتى إذا كان هناك سلسلة تعليمات أخرى تكتب فيها في الوقت نفسه.

  • وتصبح هذه التعديلات مرئية كلها مرة واحدة عند انتهاء اللقطة.

  • تعني الدقة أنّه يمكن الإعلان عن السمات المنفصلة وتتبُّعها بشكل فردي، خاصةً عندما تكون غير مرتبطة ببعضها.

  • وبالتالي، فإنّ تعديل سمة واحدة يؤدي فقط إلى إبطال القرّاء الذين يعتمدون عليها فعليًا.

  • لنلقِ نظرة على ذلك في بعض الرموز الحقيقية.

  • على سبيل المثال، سنلقي نظرة على لعبة تتضمّن ثلاث حالات: اسم المستوى، والوقت الذي تم البقاء فيه على قيد الحياة، والنتيجة.

    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 خاص بها للحصول على دقة، ولكن بعد ذلك ستفقد الاتساق لأن سلسلة رسائل أخرى يمكنها تحديث قيمة أثناء تشغيل كتلة التحديث الأخرى.

  • لنلقِ نظرة الآن على طريقة تنفيذ بديلة تستند إلى الحالة.

    @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 الحالة مباشرةً إلى 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.

لمحة عن Transform

  • حسنًا

  • شكرًا، أيمن.

  • كما أشار بن للتو، توفر لنا حالة لقطة Compose تتبعًا تلقائيًا وديناميكيًا للقراءة والاتساق والدقة، وهو ما يقودني إلى السؤال، ماذا لو كان بإمكاننا أن نجعل تدفقات البيانات لدينا مدفوعة أيضًا بلقطة حالة Compose - حالة لقطة؟

  • وإذا كانت الحالة هي نموذج البيانات لدينا، فكيف نحسب الحالة ونحولها فعليًا لجميع مستهلكيها؟

  • وهنا يأتي دور مكتبة Jetpack AppState Transform الجديدة.

  • قبل أن نتعمّق في المكتبة نفسها، علينا التحدّث عن كيفية إنشائها.

  • لذا، فإنّ فكرة الاستفادة من محرّك Compose، وهو بيئة التشغيل وبرنامج التجميع هنا، لإنتاج قيم الحالة في تركيبة Headless، ليست جديدة.

  • قد يكون الكثير منكم على دراية بمكتبة Molecule أو يستخدمونها حاليًا.

  • ونقصد بـ "بلا واجهة" هنا أنّه لا توجد واجهة مستخدم.

  • لذلك، كانت Molecule رائدة في إظهار قوة استخدام Compose Runtime، وقد فعلت ذلك من خلال إظهار أن الدالة المركّبة لا تحتاج إلى إصدار عُقد واجهة المستخدم، ويمكن استخدامها في الواقع لكتابة دالة مركّبة تعرض نموذج بيانات خالصًا.

  • في حالة Molecule، كان هذا هو Kotlin Flow أو StateFlow.

  • وهذا يعني أنّه بدلاً من ربط عوامل التشغيل التفاعلية مثل combine، يمكنك كتابة Kotlin العادية.

  • ولكن بدلاً من التعامل مع التدفقات، ماذا لو كان نموذج البيانات هو State، لأنّ واجهة المستخدم تستخدم Compose؟

  • باستخدام Transform، يكون نموذج البيانات هو الحالة.

  • لنلقِ نظرة على واجهة برمجة التطبيقات الفعلية.

    public fun <R> transform(
        initialValue: R,
        scope: CoroutineScope,
        onUpdate: @Composable (R) -> R,
    ): State<R>
  • هذه هي واجهة برمجة التطبيقات بالكامل.

  • الأمر بسيط جدًا، دالة واحدة.

  • وتعرض الحالة State قابلة للملاحظة، وليس Flow أو StateFlow.

  • ولا يعرف من يستهلكها أو يهتم بأنّ عملية تحويل قد أنتجتها.

  • ما يثير الاهتمام هنا هو هذه المجموعة.

  • إنّها دالة مركّبة.

  • هذا يعني أنّ كل ما تعرفه عن لغة SQL ينطبق على BigQuery.

  • تذكَّروا أنّ LaunchedEffect وcollectAsState وعمليات الإرجاع المبكر وعبارات if البسيطة وأي شيء يمكن قراءته من داخل دالة مركّبة، يمكن اشتقاقه في دالة Transform.

  • تجدر الإشارة إلى أنّ جميع واجهات برمجة التطبيقات التي نراها اليوم في هذا العرض التقديمي هي في مرحلة الإصدار الأولي، وهي عرضة للتغيير، ولكن بغض النظر عن ذلك، لنلقِ نظرة على مثال.

  • لذا، لنأخذ تطبيقًا بسيطًا وصغيرًا لمؤقت التركيز.

  • تختار مهمة، وتضبط مؤقتًا لمدة 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,
    )
    }
  • لذا، ادمج التدفقين معًا في تدفقات معًا، والتفرع داخل lambda، لذلك إذا كان الأمر كذلك، ثم حالة في النهاية.

  • باستخدام Transform، يمكننا الاستغناء عن هذا الرمز البرمجي النموذجي بالكامل.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
    initialValue = FocusSummary.Idle,
    scope = viewModelScope,
    container = handle.getOrCreateContainer("transform")
    ) {
                val session by focusManager.sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
                val task = tasks.find { it.id == session.activeTaskId }
                FocusSummary.Active(...)
  • يبدو هذا الاستدعاء مألوفًا نوعًا ما لأنّ لدينا قيمة أولية في نطاق يتم تمريره، ولكن لا يوجد شيء في المصدر لربطه، والاشتقاق هو الكتلة نفسها.

  • وعلى عكس stateIn، حيث قد تحتاج إلى ضبط مهلة WhileSubscribed لإيقاف التدفقات السابقة مؤقتًا واستئنافها، يتم تشغيل Transform بشكل مستمر خلال فترة حياة هذا النطاق.

  • مرة أخرى، مصادرنا هي التدفقات، والتدفقات مرحب بها.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
                initialValue = FocusSummary.Idle,
                scope = viewModelScope,
                       container = handle.getOrCreateContainer("transform")
            ) {
                val session by focusManager.sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle)
                    return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                         FocusSummary.Active(...)
            }
    }
  • سنربط كل منها بـ collectAsState، والجلسة هي StateFlow، لذا لا نحتاج إلى قيمة أولية، ولكنّ Room Flow بارد، لذا سنمنحه قيمة.

  • هذا كل ما يتعلّق بالاشتراكات.

  • القراءة هي ما يشترك فينا.

  • إنّها ميزة التتبّع التلقائي للقراءة التي تحدّث عنها "باسم" سابقًا في المثال الذي رأيناه، ولكن في ViewModel.

  • يمكننا أيضًا تقديم عمليات إرجاع مبكرة مثل هذه هنا عندما نكون في حالة خمول.

  • لنفترض أنّه ليس لدينا مهام جديدة، لن نعالج حتى بقية المهام.

  • ومرة أخرى، هذا هو تتبع القراءة الديناميكي الذي يؤتي ثماره مرة أخرى.

  • أثناء عدم استخدام الجهاز، لا يتم جمع بيانات Room Flow على الإطلاق.

  • بعد ذلك، نُصدر الملخّص.

  • لذلك، إذا أوقف المستخدم المهمة مؤقتًا أو حدد المؤقت، فسيتم تحديث كل شيء تلقائيًا.

التحويلات المتداخلة

  • تصبح الأمور أكثر تعقيدًا عندما نخرج عن مثال 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، وكلاهما سيستخدم الملخّص الذي تم إنشاؤه في مدير جلسات التركيز هذا.

  • لننشئ كلّ منها بالطريقة التقليدية ثم باستخدام ميزة "التحويل".

  • لذلك، كالعادة، سنبدأ باستخلاص الملخّص مرة واحدة في نطاق التطبيق كـ 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
    )
    }
  • إنّها طلبات الدمج و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 لذلك.

  • لإجراء ذلك، علينا استخدام دمج آخر لتتبُّع مسار الملخّص و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 مثل أي دالة مركّبة أخرى.

  • ومع ميزة "التحويل"، أصبح الأمر أكثر بساطة.

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • يوفّر لنا مدير التركيز حالة Compose، لذا لا نحتاج إلى تحويل أي شيء هنا.

  • وفي النهاية، لا يمكن أن يختلف شريط الموقّت المصغّر والتطبيق المصغّر على الشاشة الرئيسية لأنّهما يقرآن الحالة نفسها.

  • وليس ذلك فحسب، بل إن أي تغيير يتم إجراؤه على هذه الحالة سينعكس على جميع مستهلكي هذه الحالة.

  • ماذا يعني ذلك؟

  • لذا، إذا كنت ستذهب وتضغط على إيقاف مؤقت على الأداة هنا، فستنتقل من خلال المدير إلى دفق الجلسة، ثم تعود من خلال الملخص، وسيتم تحديث جميع هذه الشاشات الثلاث بشكل متزامن.

  • بعد أن تعرّفنا على كيفية استخدام ميزة "التحويل"، لنتعمّق أكثر في تفاصيلها.

  • إنّ واجهة Transform API التي رأيناها سابقًا هي في الواقع دالة مساعِدة تُستخدَم لإنشاء عملية تحويل أساسية مُدارة.

    public fun <R> transform(
        initialValue: R,
        scope: CoroutineScope,
        onUpdate: @Composable (R) -> R,
    ): State<R> {
            val transformScope = TransformScope()
        val transform = transformScope
    .launchTransform(initialValue, onUpdate)
        scope.launch(
    dispatcher,
    start = CoroutineStart.UNDISPATCHED
     ) {
            try { transformScope.run() } finally {
                transform.dispose()
            }
        }
        return transform
    }
  • في الواقع، تستخدم هذه الدالة دالة إضافة TransformScope وlaunchTransform، والتي تطلق عملية تحويل دالة مركّبة في TransformScope وتعرض حالة يمكن التخلص منها.

  • عند قراءته من الأعلى إلى الأسفل، سترى أنه يفعل كل شيء من أجلك.

  • يتم إنشاء TransformScope وتشغيل عملية التحويل فيه، وهي الحالة التي ستظهر لك.

  • بعد ذلك، يتم تشغيل دالة run في TransformScope على المُرسِل الذي قدّمته، ليكون عمر النطاق وسلسلة التعليمات تابعَين لك.

  • ثم يتخلص أخيرًا من التحويل عند انتهاء النطاق.

  • هذه هي رحلة المستخدم الكاملة لـ Transform.

  • لذا، لنلقِ نظرة على واجهة TransformScope هنا بسرعة كبيرة.

    public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
    
    public abstract class TransformScope {
            public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
    public abstract suspend fun run()
    }
    
    public fun <T> TransformScope.launchTransform(
    initialState: T,
    onUpdate: @Composable (T) -> T,
    ): DisposableState<T>
    
    public interface DisposableState<T> : State<T> {
    public fun dispose()
    }
  • يمكننا أن نرى أنّها تحتوي على دالة createTransform، والتي ستنشئ تحويلاً مرتبطًا بهذا الـ TransformScope.

  • وتتضمّن أيضًا دالة تشغيل معلّقة تبدأ TransformScope وتبدأ عملية تعديل تحويلات الحالة الحالية.

  • ماذا يعني ذلك؟

  • لذلك، تعمل الدالة run على إعداد Recomposer وComposition.

  • وكل دالة 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
            }
        }
  • لذلك، يتم تشغيلها على أي دالة coroutine يتم استدعاؤها منها إلى أن يتم إلغاء هذه الدالة.

  • قد تتساءل الآن عن كيفية عمل Recomposer، فهو يحتاج إلى ساعة إطار لتحديد وقت إنتاج الإطار.

  • في واجهة مستخدم Compose، هذا هو العرض، ولكن كما ذكرنا سابقًا، نحن في بيئة بلا واجهة مستخدم، لذا لا توجد واجهة مستخدم.

  • لذا، ما لم تزوّدنا بساعة، سننشئ ساعتنا الخاصة.

  • من خلال فصل إعادة التركيب عن ساعة إطار العرض، ينفّذ Transform جميع عمليات التحويل واستنتاجات الحالة خارج سلسلة واجهة المستخدم.

  • للسماح بذلك، أنشأنا HeadlessTransformClock مخصّصًا.

  • ساعة بإطار قابل للتحويل

  • بما أنّ التطبيق المصغّر أو الخدمة أو عملية تحويل أخرى في مرحلة لاحقة قد تنتظر قيمة من عملية تحويل، فإنّ الساعة تكون حريصة على تقديم القيمة في أسرع وقت ممكن.

  • وهذا يعني أنّه في اللحظة التي تتغيّر فيها أي حالة يستهلكها "التحويل"، يتم إنتاج إطار جديد على الفور.

  • وهو عبارة عن جهاز صغير لا يتضمّن قفلًا، لذا فإنّ طلب إطار هو عملية بسيطة للمقارنة والضبط.

  • وعندما لا يتغير أي شيء في الحالة، تتوقف الساعة.

  • وبالتالي، لن تكلّفك عملية التحويل التي تستهلك حالة لم يتم تغييرها أي شيء.

  • قبل أن أسلّم الكلمة إلى "بن"، تجدر الإشارة أيضًا إلى أنّ Transform يتيح الآن استخدام rememberSaveable لضمان حفظ بياناتك في جميع تغييرات الإعدادات.

  • ويتم ذلك من خلال واجهة برمجة التطبيقات SavedStateContainer الجديدة، والتي أصبحت الآن اللبنة الأساسية التي تستند إليها كل من SaveableStateRegistry وSavedStateHandle وSavedStateRegistry في Compose.

  • يمكنك تحويل أيّ من هذه الحاويات إلى SavedStateContainer أو إنشاء حاويات فرعية وتمريرها إلى Transform لضمان حفظ بياناتك.

  • أخيرًا، نذكّركم مجددًا بأنّ جميع واجهات برمجة التطبيقات التي نتحدث عنها اليوم هي في مرحلة الإصدار الأولي، لذا فهي عرضة للتغيير.

  • والآن، سأعيد الكلمة إلى "بن".

حالة Compose خارج واجهة المستخدم

  • حسنًا.

  • حسنًا، شكرًا لك يا "إليف".

  • بعد أن رأينا مدى فعالية حالة Compose وكيف تتيح لنا Transform اشتقاقها خارج واجهة المستخدم، قد تكون لاحظت في المثال حتى الآن أنّ المدخلات كانت عبارة عن تدفقات تم ربطها باستخدام collectAsState.

  • ولكن هل هذا ضروري دائمًا؟

  • لماذا لا نستخدم حالة Compose مباشرةً في عناصر الاحتفاظ بالحالة؟

  • لذا، للإجابة على هذا السؤال، أجد أنه من المفيد التفكير في الحالة ضمن ثلاث فئات عريضة.

  • الأولى هي الحالة المباشرة التي يتم تخزينها في الذاكرة فقط.

  • مثل حالة اتصال الشبكة أو البلوتوث.

  • لن ترغب في استخدام قيمة ثابتة.

  • والثانية هي الحالة المؤقتة التي يجب أن تستمر بعد إيقاف العملية نهائيًا، ولكن لا يلزم تخزينها إلى أجل غير مسمى.

  • مثل حالة واجهة المستخدم، مثل ما إذا كانت البطاقة موسّعة أم لا، وحالة الجلسة، مثل الأنشطة السابقة للتنقّل أو مسودة نموذج.

  • والحالة الثالثة هي الحالة الدائمة، والتي يتم تخزينها إلى أجل غير مسمى.

  • مثل الإعدادات المفضّلة للمستخدم أو مكتبة وسائط في قاعدة بيانات.

  • لنتعرّف أولاً على كيفية استخدام حالة 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 دمج الحالة من مدراء Singleton والحالة في ViewModel والتدفقات من المستودعات، كل ذلك في كتلة Transform واحدة.

  • والآن، لنعد إلى الحالة المؤقتة.

  • ذكرتُ سابقًا نوعَين: حالة واجهة المستخدم وحالة الجلسة.

  • بالنسبة إلى حالة واجهة المستخدم، وحتى حالة الجلسة التي تظهر داخل نشاط واحد، تعمل ViewModel وSavedState بشكل رائع.

  • هذا هو الغرض من تصميمها بالضبط.

  • ولكن، تبدأ المشاكل عندما يحتاج المستخدم إلى مشاركة حالة الجلسة هذه على مجموعة من واجهات المستخدم ومكوّنات التطبيق المختلفة.

  • وعندما تخرج هذه الحالة من ViewModel، تترك واجهات برمجة التطبيقات والإرشادات الحالية فجوة بسيطة.

  • لذا، تحفظ واجهات برمجة تطبيقات SavedState أولاً في حزمة النشاط، ولكن هذا غير متاح في مكونات التطبيق الأخرى، مثل الخدمة.

  • ثانيًا، 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، ولكن كيف نستخدمه؟

  • من الجيد أنّها واجهة برمجة تطبيقات صغيرة.

  • وتتضمّن ثلاث طرق فقط، لذا سنستعرضها واحدة تلو الأخرى.

  • الأول هو 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 واجهة برمجة التطبيقات Transform's listener API، وهي في الأساس نفسها واجهة برمجة التطبيقات Transform، باستثناء أنّها لا تعرض أي شيء، بل تُستخدَم فقط للملاحظة والآثار الجانبية.

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener { … }
    }
  • بعد ذلك، يقرأ كل مفتاح في المتجر، ويتحقق مما إذا كان أحدها مفتاح PersistToDataStore.

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener {
            for (key in keys) {
                if (key is PersistToDataStore) {
                    val value = getState(key).value
    
                    LaunchedEffect(key) {
                        // Restore saved value from dataStore on first access
                    }
    
                    LaunchedEffect(key, value) {
                        // Flush updated value to dataStore on change
                    }
                }
            }
        }
    }
  • وعندما يكون كذلك، يستخدم LaunchedEffect لمزامنة هذه القيم مع DataStore كلما تغيّرت.

  • ما أريد الإشارة إليه هنا هو أنّ توفُّر حاوية عادية مثل StateStore هو ما يتيح لنا توفير عمليات الدمج السلسة هذه.

  • ‫DataStore هو أول نموذج نلقي نظرة عليه.

ملخّص فئات حالة الإنشاء

  • بالعودة إلى فئات الحالة، لدينا الآن صورة واضحة جدًا عن كيفية استخدام Compose State في تطبيقك بالكامل.

  • بدءًا من mutableStateOf للحالة المباشرة، ووصولاً إلى واجهات برمجة التطبيقات SavedState لحالة العرض على الشاشة المحلية، وStateStore لحالة الجلسة على مستوى المكوّنات، وأخيرًا، لدينا collectAsState للتشغيل التفاعلي مع التدفقات.

  • في الواقع، لا تقتصر إمكانية التشغيل التفاعلي على فئة واحدة، بل يمكنك استخدام هذه الدوال المساعدة، مثل collectAsState، متى أردت ربط أنواع البيانات القابلة للتتبّع بحالة Compose.

  • ولكن بالنسبة إلى الحالة الدائمة، هناك شيء آخر نبحث عنه وهو طرق العمل مع Compose أو لـ DataStore وRoom لعرض حالة Compose مباشرةً، حتى لا تضطر إلى كتابة بناء الجملة الخاص بك مثل بناء الجملة الأفضل للقيام بذلك.

  • لذلك، يسعدني الإعلان عن أنّ واجهة برمجة التطبيقات 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 في فيديو "ما هي حالة Compose؟"
/**
 * 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 في فيديو "ما هي حالة Compose؟"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
}
مقتطف Kotlin في الدقيقة 02:32 في فيديو "ما هي حالة Compose؟"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}
مقتطف Kotlin في الدقيقة 02:52 في فيديو "ما هي حالة Compose؟"
@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 في فيديو "ما هي حالة Compose؟"
@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 في فيديو "ما هي حالة Compose؟"
@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 في فيديو "ما هي حالة Compose؟"
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0
مقتطف Kotlin في الدقيقة 04:23 في فيديو "ما هي حالة Compose؟"
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)
مقتطف Kotlin في الدقيقة 04:33 في فيديو "ما هي حالة Compose؟"
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 في فيديو "ما هي حالة Compose؟"
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 في فيديو "ما هي حالة Compose؟"
@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 في فيديو "ما هي حالة Compose؟"
@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 في فيديو "ما هي حالة Compose؟"
@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 في فيديو "مقدمة حول Transform"
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R>
مقتطف Kotlin في الدقيقة 09:20 في فيديو "مقدمة حول 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 في فيديو "مقدّمة حول 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 في فيديو "مقدمة حول 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 في "عمليات التحويل المُدمَجة"
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 في "عمليات التحويل المُدمَجة"
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 في "عمليات التحويل المُدمَجة"
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 في "عمليات التحويل المُدمَجة"
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 في "عمليات التحويل المُدمَجة"
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 في "عمليات التحويل المُدمَجة"
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 في "عمليات التحويل المُدمجة"
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 في "عمليات التحويل المُدمجة"
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 في "عمليات التحويل المُدمَجة"
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 في "عمليات التحويل المُدمَجة"
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 خارج واجهة المستخدم"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set
}
مقتطف Kotlin في الدقيقة 19:33 في "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
}
مقتطف Kotlin في الدقيقة 19:41 في "حالة الإنشاء خارج واجهة المستخدم"
@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 في "حالة الإنشاء خارج واجهة المستخدم"
@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 في فيديو "مقدمة عن 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 في فيديو "مقدّمة حول StateStore"
sealed interface FocusSession { ... }

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
مقتطف Kotlin في الدقيقة 22:23 في فيديو "مقدمة عن 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 في فيديو "مقدمة عن 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 في فيديو "مقدمة عن 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 في فيديو "مقدمة حول StateStore"
@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
    ...
}
مقتطف Kotlin في الدقيقة 23:22 في فيديو "مقدمة عن StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }

@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
    fun focusSessionManager(): FocusSessionManager
}
مقتطف Kotlin في الدقيقة 23:33 في فيديو "مقدمة عن StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}
مقتطف Kotlin في الدقيقة 23:45 في فيديو "مقدمة حول 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 في فيديو "مقدمة عن 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 في فيديو "مقدمة حول 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 في فيديو "مقدمة حول StateStore"
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener { … }
}
مقتطف Kotlin في الدقيقة 24:33 في فيديو "التعرّف على 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 في "ملخّص فئات حالة Compose"
[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" }