مهندسة علاقات المطوّرين
لا يجب أن تتوقف ميزات Compose، مثل وقت تشغيل اللقطة وتتبُّع التبعية التلقائي والتحديثات الذرية، عند طبقة واجهة المستخدم. تتيح لك Jetpack AppState نقل حالة Compose إلى أعلى السلسلة، ما يسهّل ما يصل إلى شاشاتك من خلال رفع الحالة واستخلاصها باستخدام نظام اللقطات قبل أن تصل إلى واجهة المستخدم.
في هذه الجلسة، سنتعرّف على مكتبتَي AppState الأساسيتَين. سنتناول StateStore لرفع الحالة، وTransform لاستخدام التركيب في اشتقاق الحالة من خلال تدفق التحكّم البسيط في Kotlin، ما يغني عن سلاسل دمج Flow المعقّدة ويحافظ على مزامنة واجهة المستخدم بسهولة.
combine، مع إتاحة عمليات الإرجاع المبكر واستخلاص الحالات المتداخلة.النص
مرحبًا بكم في دورة "استخدام حالة 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.
حسنًا
شكرًا، أيمن.
كما أشار بن للتو، توفر لنا حالة لقطة 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 وكيف تتيح لنا 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، الذي يعيش فقط في الذاكرة، لذلك لا ينجو من إيقاف العملية نهائيًا.
وهذه هي الفجوة التي تملأها واجهة برمجة التطبيقات 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" }وننصحك بتجربتها في تطبيقك.
أرسِل إلينا ملاحظاتك.
بما أنّنا في مرحلة الإصدار الأولي، يمكننا إجراء تعديلات وتغييرات لضمان تلبية احتياجاتك.
/**
* A value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value.
*/
@Stable
public interface State<out T> {
public val value: T
}/**
* A mutable value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value. When the
* [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
* will be scheduled. If [value] is written to with the same value, no recompositions will be
* scheduled.
*/
@Stable
public interface MutableState<T> : State<T> {
override var value: T
public operator fun component1(): T
public operator fun component2(): (T) -> Unit
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}
@Composable
fun AddButton(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("Add")
}
}@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(true) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(false) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column {
Header(levelName = state.levelName)
ScoreBadge(
timeSurvived = state.timeSurvived,
score = state.score
)
}
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state = viewModel.uiState
Column {
Header(state = state)
ScoreBadge(state = state)
}
}public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: StateFlow<FocusSummary> =
combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle,
)
}class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: StateFlow<FocusSummary> =
combine(
sessionFlow,
taskRepository.tasksFlow
) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = scope,
container = handle.getOrCreateContainer("transform")
) {
val session by sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow
.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: StateFlow<MiniTimerUiState> =
combine(
focusManager.summary,
isExpanded
) { summary, expanded ->
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MiniTimerUiState()
)
fun toggleExpanded() { isExpanded.update { !it } }
}class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: State<MiniTimerUiState> =
transform(
initialValue = MiniTimerUiState(),
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val summary by focusManager.summary
val expanded by isExpanded.collectAsState()
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}
fun toggleExpanded() { isExpanded.update { !it } }
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R> {
val transformScope = TransformScope()
val transform = transformScope
.launchTransform(initialValue, onUpdate)
scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
) {
try { transformScope.run() } finally {
transform.dispose()
}
}
return transform
}public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
public abstract class TransformScope {
public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
public abstract suspend fun run()
}
public fun <T> TransformScope.launchTransform(
initialState: T,
onUpdate: @Composable (T) -> T,
): DisposableState<T>
public interface DisposableState<T> : State<T> {
public fun dispose()
}override suspend fun run() {
val context = currentCoroutineContext()
val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
...
val recomposer = Recomposer(context)
val composition = Composition(UnitApplier, recomposer)
try {
withContext(clock) {
if (clock is HeadlessTransformClock) {
launch(start = CoroutineStart.UNDISPATCHED) {
clock.runClock()
}
}
...
}
} finally {
// dispose of composition, cancel the clock, close the recomposer
}
}@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
fun updateSearchQuery(query: String) {
Snapshot.withMutableSnapshot {
searchQuery = query
selectedTaskIds = emptySet()
}
}
}val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}@Singleton
class NetworkMonitor @Inject constructor(
@ApplicationContext context: Context,
) {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
...
}@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { _isOnline.value = true }
override fun onLost(network: Network) { _isOnline.value = false }
}
init {
connectivityManager.registerDefaultNetworkCallback(callback)
}
}val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
if (!networkMonitor.isOnline.value) {
return@transform TaskListState.Offline
}
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}sealed interface FocusSession {
data object Idle : FocusSession
data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
...
}fun startFocus(taskId: String, durationMinutes: Int = 25) {
val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
stateStore.setState(
FocusSessionKey,
FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
)
}
fun endFocus() {
stateStore.setState(FocusSessionKey, FocusSession.Idle)
}fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Running) return@updateState session
FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
}
}
fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Paused) return@updateState session
FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
}
}@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}
@Composable
fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
val session by viewModel.session
if (session !is FocusSession.Idle) {
TimerReadout(session = session)
}
}@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(
@ApplicationContext context: Context,
@ApplicationScope scope: CoroutineScope,
): StateStore = StateStore().apply {
val path = context.dataStoreFile("app_state.json").absolutePath
scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
}
}@Serializable
sealed interface FocusSession {
@Serializable data object Idle : FocusSession
@Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
@Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
PersistToDataStoresuspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener {
for (key in keys) {
if (key is PersistToDataStore) {
val value = getState(key).value
LaunchedEffect(key) {
// Restore saved value from dataStore on first access
}
LaunchedEffect(key, value) {
// Flush updated value to dataStore on change
}
}
}
}
}[versions]
appstate = "1.0.0-alpha01"
...
[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }