مهندس روابط توسعهدهنده
زمان اجرای عکس آنی Compose، ردیابی وابستگی خودکار، و بهروزرسانیهای اتمی نباید در لایه واسط کاربر متوقف شوند. Jetpack AppState به شما امکان میدهد حالت Compose را به بالادست منتقل کنید—با بالا بردن و مشتق کردن حالت با سیستم عکس آنی قبلاز اینکه به میانای کاربری برسد، آنچه را به صفحهنمایشهای شما میرسد سادهسازی میکند.
در این جلسه، با دو کتابخانه بنیادی AppState آشنا خواهید شد. ما به StateStore برای بالابردن حالت و Transform برای استفاده از ترکیببندی جهت مشتق کردن حالت با جریان کنترل Kotlin ساده خواهیم پرداخت—زنجیرههای پیچیده ترکیب Flow را حذف کرده و میانای کاربر شما را بهراحتی همگام نگه میداریم.
combine میشود و درعینحال از بازگشتهای زودهنگام و مشتقات حالت تودرتو پشتیبانی میکند.آوانویسی
سلام، به «فراتر بردن حالت Compose از میانای کاربر با Jetpack AppState» خوش آمدید.
نام من بن است و من یک مهندس روابط توسعهدهنده هستم که روی کتابخانههای معماری Jetpack کار میکنم.
و من الیف هستم، و یکی از مهندسانی هستم که روی کتابخانههای معماری برنامه کار میکنم.
و امروز اینجا هستیم تا درباره آیندهای صحبت کنیم که در آن Compose فراتر از واسط کاربر میرود و وارد لایههای داده و دامنه برنامهها میشود.
So, as I'm sure you're all well aware by now, Compose is the recommended toolkit for UI development on Android.
از تلفنها و رایانههای لوحی گرفته تا ابزارکها، ساعتها، تلویزیونها، و دستگاههای XR، همه با Compose کار میکنند.
اما با رشد برنامهتان در این سطوح میانای کاربر و مؤلفههای پسزمینه، مدیریت وضعیت پشت آنها دشوارتر میشود، بهویژه درخصوص دو چالش عمده رایج.
اولین مورد، استخراج وضعیت پیچیده است.
Weaving together multiple data streams, local UI inputs, and ambient state, like network connectivity, that often turns into a tangle of operators like combine, flatMapLatest, and distinctUntilChanged.
و دومین مورد، همرسانی وضعیت در بین مؤلفهها است: همگام نگه داشتن فعالیت، سرویس، ابزاره برنامه، و موارد دیگر، و اطمینان از اینکه این وضعیت میتواند درصورت نیاز از مرگ پردازش جان سالم بهدر ببرد.
وقتی برای بررسی این چالشها به عقب برگشتیم، به نحوه ساخت خود Compose نگاه کردیم.
بسیاری از افراد Compose را بهعنوان یک جعبهابزار واسط کاربر میشناسند، اما درواقع از دو لایه متمایز تشکیل شده است: Compose UI در بالا و Compose Runtime در زیر آن.
درحالیکه Compose UI چیدمان، طراحی، و ورودی را مدیریت میکند، 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 را فراخوانی میکنید، درواقع محفظهای میسازید که خواندن و نوشتن را ازطریق سیستم عکس آنی مسیریابی میکند.
And whenever your code runs inside of a snapshot observer, like a composition, you get two properties that a stream like a Flow doesn't give you.
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
}اولین مورد، ردیابی خواندن خودکار و پویا است.
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}بنابراین، خودکار به این معنی است که هیچ اشتراک یا جمعآوری وجود ندارد.
فقط خواندن .value اشتراک است.
بنابراین، اگر به Counter در اینجا نگاه کنیم، خواندن count.value مستقیماً در داخل composable به آن خواندن به composition گزارش میدهد و Counter را در فهرست خوانندگان خود ثبت میکند.
و حالا به AddButton نگاه کنید.
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}
@Composable
fun AddButton(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("Add")
}
}این کد فقط برای شمارش کلیکها نوشته میشود.
بنابراین، این دستگاه کارتخوان نیست.
وقتی کاربر روی AddButton کلیک میکند و تعداد را تغییر میدهد، سیستم عکس آنی آن نوشتن را ردیابی میکند، که به ترکیببندی اجازه میدهد بداند که Counter را باطل کند، اما نه AddButton.
و پویا به این معنی است که ردیابی خواندن در زمان اجرا براساس اینکه کدام مسیرهای کد واقعاً اجرا میشوند اتفاق میافتد.
@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(true) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}بنابراین در اینجا، از نوشتار شمارنده با پرچم isVisible محافظت میکنیم.
وقتی isVisible درست باشد، هم isVisible و هم تعداد خوانده میشود.
بنابراین، «شمارنده» بهعنوان خواننده هر دو آنها ثبت میشود.
و وقتی 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 برای شمارش مینویسد، چیزی برای باطل کردن باقی نمیماند.
و حالا، ویژگی دوم این است که مجبور نیستید بین سازگاری و دانهبندی یکی را انتخاب کنید.
سازگاری به این معنی است که وقتی چندین مقدار را در یک عکس آنی بهروز میکنید، این تغییرات درحین اجرای کد شما جدا میمانند، حتی اگر رشته دیگری همزمان درحال نوشتن در آنها باشد.
و وقتی عکس آنی تمام میشود، همه آن بهروزرسانیها بهطور همزمان نمایان میشوند.
دانهبندی یعنی ویژگیهای جداگانه را میتوان بهصورت جداگانه اعلام و ردیابی کرد، بهویژه زمانی که باهم مرتبط نباشند.
So, updating one property only invalidates the readers that actually depend on it.
بیایید این را در یک کد واقعی ببینیم.
برای مثال، میخواهیم به بازیای نگاه کنیم که سه بخش وضعیت دارد: نام سطح، زمان بقا، و امتیاز.
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 وضعیت واسط کاربر را جمعآوری میکند و آن را میخواند تا ویژگیهای مربوطه را به «سرصفحه» و 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 «وضعیت» را مستقیماً به «سرصفحه» و 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 پیشگام بود و این کار را با نشان دادن این موضوع در عمل انجام داد که یک تابع ترکیبپذیر نیازی به انتشار گرههای UI ندارد و درواقع میتواند برای نوشتن تابع ترکیبپذیر که یک مدل داده خالص را برمیگرداند استفاده شود.
در مورد Molecule، این یک Kotlin Flow یا StateFlow بود.
این یعنی بهجای زنجیر کردن اپراتورهای واکنشی مثل combine، میتوانید Kotlin معمولی بنویسید.
اما بهجای سروکار داشتن با جریانها، اگر مدل داده شما State باشد، چون واسط کاربر شما ازقبل در Compose است، چه میشود؟
با Transform، مدل داده شما State است.
بیایید نگاهی به میانای برنامهسازی کاربردی واقعی بیندازیم.
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>این کل میانای برنامهسازی کاربردی است.
بسیار ساده است، یک تابع.
این تابع یک State قابلمشاهده ساده را برمیگرداند، نه یک Flow و نه یک StateFlow.
و هرکسی که آن را مصرف میکند نمیداند یا برایش مهم نیست که Transform آن را تولید کرده است.
آنچه واقعاً در اینجا جالب است این بلوک است.
ترکیبپذیر است.
این یعنی هر چیزی که ازقبل میدانید در آن کار میکند.
بهخاطر داشته باشید که LaunchedEffect، collectAsState، بازگشتهای زودهنگام، ifهای ساده، و هر چیزی را که میتوانید از داخل یک Composable بخوانید، میتوانید در Transform مشتق کنید.
باید توجه داشته باشیم که همه میاناهای برنامهسازی کاربردی که امروز در این ارائه میبینیم در مرحله آلفا هستند و ممکن است تغییر کنند، اما صرفنظر از این موضوع، بیایید نگاهی به یک نمونه بیندازیم.
بنابراین، بیایید یک برنامه زمانسنج تمرکز بسیار ساده و کوچک را درنظر بگیریم.
تکلیفی را انتخاب میکنید، زمانسنج ۲۵ دقیقهای تنظیم میکنید، و ساعت شروع به تیکتاک میکند.
هر بار که یک صفحه تکی ایجاد میکنید، حتی زمانی که یک صفحه تکی ساده مانند این ایجاد میکنید، احتمالاً باید چندین جریان داده مستقل را ترکیب و مدیریت کنید تا وضعیت خود را در پشت صحنه جمعآوری کنید.
برای صفحه تمرکز، فرض کنید دو صفحه داریم: خود جلسه تمرکز، که نشان میدهد در چه مرحلهای هستیم، یعنی متوقف شده یا نه، چند ثانیه باقی مانده است، و همه اینها از یک موتور زمانسنج بهعنوان یک 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,
)
}بنابراین، این دو را باهم ترکیب کنید، دو جریان را باهم در جریانها فشرده کنید، انشعاب داخل لامبدا، بنابراین اگر در آنجا درست باشد، و سپس یک حالت در پایان.
با Transform، میتوانیم کل این کد متن استاندارد را حذف کنیم.
class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)این فراخوانی تاحدودی آشنا بهنظر میرسد زیرا مقدار اولیهای در محدوده ارسالشده داریم، اما چیزی در بالادست برای زنجیر کردن وجود ندارد، اشتقاق خود بلوک است.
و برخلاف stateIn که احتمالاً باید یک وقفه زمانی WhileSubscribed را برای توقف موقت و ازسرگیری جریانهای بالادستی پیکربندی کنید، یک «تبدیل» بهطور مداوم در طول عمر آن محدوده اجرا میشود.
باز هم میگوییم، منابع ما جریانها هستند و از جریانها استقبال میکنیم.
class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}ما هرکدام را با collectAsState پل میکنیم و جلسه StateFlow است، بنابراین به مقدار اولیه نیاز نداریم، اما Room Flow سرد است، بنابراین به آن مقدار اولیه میدهیم.
و این کل داستان اشتراک است.
خواندن چیزی است که ما را مشترک میکند.
این همان ردیابی خودکار خواندن است که بن قبلاً در مثالی که دیدیم درباره آن صحبت میکرد، اما در ViewModel.
حتی میتوانیم بازگشتهای زودهنگام مانند این را در اینجا معرفی کنیم وقتی در حالت بیکار هستیم.
مثلاً اگر تکلیف جدیدی نداشته باشیم، حتی بقیه را هم پردازش نمیکنیم.
و باز هم، این ردیابی خواندن پویا است که دوباره نتیجه میدهد.
وقتی غیرفعال هستیم، «جریان اتاق» اصلاً جمعآوری نمیشود.
سپس درنهایت خلاصه را منتشر میکنیم.
بنابراین، اگر کاربر تکلیف را موقتاً متوقف کند یا زمانسنج تیک بزند، همه چیز بهطور خودکار بهروز میشود.
بنابراین، وقتی از مثال تکی ViewModel فراتر میرویم، اوضاع کمی پیچیدهتر میشود.
فرض کنید همین جلسه باید در دو سطح دیگر نیز نشان داده شود: یک نوار زمانسنج کوچک که در پایین صفحهنمایش برنامه در هر صفحه قرار دارد، و یک ابزاره صفحه اصلی.
هر دو به اطلاعات مشتقشده یکسانی درباره خلاصه جلسه، اینکه کدام فاز است، موقتاً متوقف شده است یا نه، و اینکه چقدر زمان در زمانسنج باقی مانده است نیاز دارند.
و این به این معنی است که اگر هر سطح بخواهد نسخه خودش را مونتاژ کند، همان اشتقاق سه بار و در سه مکان مختلف انجام میشود که همه باید با یکدیگر مطابقت داشته باشند.
بنابراین، کاری که باید بهجای آن انجام دهیم این است که خلاصه را یکبار در محدوده برنامه استخراج کنیم و سپس همه چیز در پاییندست آن را مصرف کند.
خب، چطور میتوانیم چیزی مثل این را پیادهسازی کنیم؟
جایی که پیادهسازی ما احتمالاً به سه بخش نیاز دارد.
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}بنابراین، یک مدیر جلسه در محدوده برنامه که قرار است خلاصه از آنجا مشتق شود، یک مینیتایمر ViewModel، و یک «ابزارک نگاه سریع»، که هر دو قرار است از خلاصه مشتقشده در این مدیر جلسه تمرکز استفاده کنند.
بیایید هرکدام را یکبار به روش سنتی و یکبار با «تبدیل» بسازیم.
بنابراین، بهطور سنتی، دوباره، با استخراج خلاصه یکبار در محدوده برنامه بهعنوان 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 استفاده کنیم.
و حالت دوم را دریافت میکنیم.
با «تبدیل»، دیگر لازم نیست از هیچکدام از این اپراتورهای زنجیرهای برای همگامسازی بین جاریسازیهای داده استفاده کنیم.
class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: State<MiniTimerUiState> =
transform(
initialValue = MiniTimerUiState(),
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val summary by focusManager.summary
val expanded by isExpanded.collectAsState()
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}
fun toggleExpanded() { isExpanded.update { !it } }
}کاری که میتوانیم انجام دهیم، و این واقعاً جالب است، این است که میتوانیم «تبدیلهای» تودرتو انجام دهیم.
بنابراین، در اینجا ViewModel خلاصه را میخواند، که خروجی تبدیل بالادستی است، درست مانند هر حالت دیگری، و سپس isExpanded را با collectAsState جمعآوری میکند.
بنابراین، میتوانیم یک حالت بالادستی و یک جریان پاییندستی را در StateFlow درست در همان بلوک ترکیب کنیم.
و دانهبندی را بهخاطر داشته باشید.
بنابراین، اگر isExpanded را تغییر دهید، فقط این بلوک دوباره اجرا میشود.
مسدود کردن در سطح برنامه و ابزاره حتی لازم نیست متوجه شوند.
درنهایت، ابزاره را داریم.
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}ازآنجاییکه Glance از نوع Compose است، میتوانیم StateFlow را با collectAsState در provideContent مانند هر Composable دیگری جمعآوری کنیم.
با Transform، این کار حتی سادهتر هم میشود.
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}مدیر تمرکز ما یک Compose State به ما ارائه میدهد، بنابراین چیزی وجود ندارد که لازم باشد در اینجا تبدیل کنیم.
و درنهایت، نوار زمانسنج کوچک و ابزارک صفحه اصلی هرگز نمیتوانند باهم مغایرت داشته باشند زیرا هر دو یک وضعیت را میخوانند.
و نه تنها این، بلکه هر تغییری که در آن حالت ایجاد شود، در همه مصرفکنندگان آن حالت منعکس خواهد شد.
این امر به چه معنا است؟
بنابراین، اگر بروید و روی ابزاره اینجا توقف موقت بزنید، سپس ازطریق مدیر وارد جریان جلسه میشود، ازطریق خلاصه برمیگردد، و هر سه این صفحهها بهطور همزمان بهروزرسانی میشوند.
اکنون که کمی با نحوه استفاده از «تبدیل» آشنا شدیم، بیایید نگاهی عمیقتر به جزئیات آن بیندازیم.
Transform API که قبلاً دیدیم درواقع یک تابع کمکی است که برای ایجاد یک Transform مدیریتشده پایه استفاده میشود.
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R> {
val transformScope = TransformScope()
val transform = transformScope
.launchTransform(initialValue, onUpdate)
scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
) {
try { transformScope.run() } finally {
transform.dispose()
}
}
return transform
}درواقع، از تابع افزونه TransformScope و launchTransform استفاده میکند که تبدیل Composable را در این TransformScope راهاندازی میکند و وضعیت یکبارمصرف را برمیگرداند.
وقتی آن را از بالا به پایین میخوانید، میبینید که همه کارها را برای شما انجام میدهد.
این کار TransformScope را ایجاد میکند و «تبدیل» شما را در آن راهاندازی میکند، که همان وضعیتی است که دریافت میکنید.
And then it also launches run in the TransformScope on the dispatcher that you provided, so that the scope's lifetime and the thread belong to you.
و سپس درنهایت وقتی دامنه بهپایان میرسد، تبدیل شما را دور میاندازد.
این داستان کل چرخه زندگی 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 دارد که قرار است Transform متصل به این TransformScope را ایجاد کند.
همچنین دارای یک تابع اجرای تعلیق است که TransformScope را راهاندازی میکند و فرایند بهروزرسانی تبدیلهای حالت فعلی را آغاز میکند.
این امر به چه معنا است؟
بنابراین، run درواقع Recomposer و ترکیببندی را راهاندازی میکند.
And every Transform in the scope lives inside that composition and has its very own recompose scope.
به همین دلیل است که میخواهیم دامنه را همرسانی کنیم و به همین دلیل است که TransformScope را داریم.
بنابراین هرگاه وضعیتی تغییر کند، فقط «تبدیلهایی» که آن را میخوانند دوباره اجرا میشوند.
همانطور که قبلاً اشاره کردیم، run یک تابع تعلیق است.
override suspend fun run() {
val context = currentCoroutineContext()
val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
...
val recomposer = Recomposer(context)
val composition = Composition(UnitApplier, recomposer)
try {
withContext(clock) {
if (clock is HeadlessTransformClock) {
launch(start = CoroutineStart.UNDISPATCHED) {
clock.runClock()
}
}
...
}
} finally {
// dispose of composition, cancel the clock, close the recomposer
}
}بنابراین، روی هر روال همزمان که آن را از آن فراخوانی میکنید اجرا میشود تا زمانی که آن روال همزمان لغو شود.
اکنون، ممکن است بپرسید یا تعجب کنید، یک Recomposer به یک ساعت فریم نیاز دارد تا به آن بگوید چه زمانی باید یک فریم تولید کند.
در Compose UI، این نمایش است، اما همانطور که قبلاً اشاره کردیم، ما در یک محیط بدون سر هستیم، بنابراین هیچ UI وجود ندارد.
بنابراین، اگر ساعتی به ما ارائه ندهید، خودمان ساعتی خواهیم ساخت.
با جدا کردن بازترکیب از ساعت قاب نمایش، Transform همه تبدیلها و مشتقات حالت خود را خارج از رشته میانای کاربر اجرا میکند.
برای مجاز کردن این کار، HeadlessTransformClock اختصاصیای ساختهایم.
ساعت قاب تحول.
ازآنجاییکه ممکن است یک ابزاره، سرویس، یا Transform پاییندست دیگر منتظر مقداری از Transform باشد، ساعت ما مشتاق است.
این یعنی بهمحض اینکه هر وضعیتی که «تبدیل» مصرف میکند تغییر کند، بلافاصله قاب جدیدی تولید میشود.
این یک ماشین حالت کوچک بدون قفل است، بنابراین درخواست یک فریم یک عملیات ساده مقایسه و تنظیم است.
و وقتی چیزی در وضعیت تغییر نکرده باشد، ساعت متوقف میشود.
بنابراین، «تبدیلی» که از وضعیتی استفاده میکند که اصلاً تغییر نکرده است، هیچ هزینهای برای شما ندارد.
قبلاز اینکه آن را به بن برگردانم، باید به این نکته هم اشاره کنیم که Transform اکنون از rememberSaveable پشتیبانی میکند تا مطمئن شویم دادههای شما در تغییرات پیکربندی ذخیره میشود.
این کار را ازطریق SavedStateContainer API جدید انجام میدهد که اکنون بلوک ساختمانی است که SaveableStateRegistry، SavedStateHandle، و SavedStateRegistry مربوط به Compose همگی بر آن ساخته شدهاند.
میتوانید هرکدام از اینها را به SavedStateContainer تبدیل کنید یا محفظههای فرزند ایجاد کنید و آنها را به Transform خود منتقل کنید تا مطمئن شوید دادههایتان ذخیره میشود.
درنهایت، دوباره یادآوری میکنیم که همه میاناهای برنامهسازی کاربردی که امروز درباره آنها صحبت میکنیم در مرحله آلفا هستند، بنابراین ممکن است تغییر کنند.
با این مقدمه، ادامه را به بن میسپارم.
بسیار خوب.
بسیار خوب، ممنون الیف.
So, now that we've seen how Compose state is powerful and how Transform lets us derive it outside of the UI, one thing you might have noticed in the example so far is our inputs were Flows that we bridged using 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 در اینجا، میتوانید بهجای MutableStateFlow، ویژگی mutableStateOf ساده را اعلام کنید.
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}And because this is something that you does don't necessarily want to persist across process death, it's okay that it's not like that it's just using 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)
}And, all this is just happening directly, there's no like, application scope, or, coroutine scope that you need to provide to update the state, it's just, you know, a direct, synchronous update.
بنابراین، با Transform، ViewModel شما میتواند State را از مدیران Singleton، State را در ViewModel، و Flows را از Repositories، همه را در یک بلوک Transform واحد ترکیب کند.
و حالا بیایید به «وضعیت گذرا» برگردیم.
پیشتر به دو نوع اشاره کردم: وضعیت میانای کاربر و وضعیت جلسه.
برای وضعیت میانای کاربر، و حتی وضعیت جلسه که در یک «فعالیت» واحد قرار دارد، ViewModel و SavedState عالی کار میکنند.
این دقیقاً همان چیزی است که برای آن طراحی شدهاند.
اما وقتی اوضاع خراب میشود که آن وضعیت جلسه باید در بین چندین میانای کاربر و مؤلفه برنامه مختلف همرسانی شود.
وقتی آن وضعیت از ViewModel خارج میشود، میاناهای برنامهسازی کاربردی و راهنماییهای موجود ما کمی شکاف ایجاد میکنند.
بنابراین، ابتدا SavedState APIs در Bundle مربوط به Activity ذخیره میشود، اما این در دیگر مؤلفههای برنامه، مثل سرویس، دردسترس نیست.
دوم، mutableStateOf در Singleton، که فقط در حافظه زندگی میکند، بنابراین از مرگ پردازش جان سالم به در نمیبرد.
و این همان شکافی است که Jetpack AppState، StateStore پر میکند.
این یک نقشه ایمن تایپی از Compose State است که میتواند بین مؤلفههای برنامه همرسانی شود و توسط 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 وجود دارد که یک State فقط خواندنی را برای کلید برمیگرداند و این گزینه را دارد که مقدار پیشفرض را لغو کند، اگر بخواهید آن را در یک سایت فراخوانی خاص تغییر دهید.
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}و آشکار کردن «وضعیت FocusSession» در این مدیر به این معنی است که مصرفکنندگان هرگز مجبور نیستند مستقیماً به StateStore یا کلیدهای آن دسترسی داشته باشند.
میتوانید کپسولهسازی نوشتن و خواندن را حفظ کنید.
And then, instead of consumers they just inject Focus Session Manager, which, well, they probably already have because they need to control the session, and then they can read the State.
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}و سپس، آن خوانندگان میتوانند مستقیماً «وضعیت» را دوباره نمایش دهند یا میتوانند از Transform برای ایجاد نوع جدید و بهروزشدهای از «وضعیت» استفاده کنند، مانند آنچه قبلاً در مثال Mini-Timer ViewModel دیدیم.
@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)
}
}اما تاکنون، 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) }
}
}And then, after that, we opt in each key that we want to persist.
@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 استفاده میکند که اساساً دقیقاً همان 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 را برای تعاملپذیری با Flows داریم.
اما درواقع، قابلیت همکاری به هیچ دسته خاصی محدود نمیشود، شما میتوانید از آن کمککنندهها مانند collectAsState هر زمان که نیاز به پل زدن انواع قابل مشاهده به Compose State دارید استفاده کنید.
اما برای حالت دائمی، چیز دیگری که درحال بررسی آن هستیم، راههایی برای کار با Compose یا برای DataStore و Room برای نمایش مستقیم Compose State است، بنابراین لازم نیست نحو بهتری برای انجام این کار بنویسید.
و با این کار، خوشحالم که اعلام کنم Transform، StateStore، و ادغام DataStore همگی امروز در نسخه آلفا دردسترس هستند، و همچنین همه اسناد را در developer.android.com منتشر کردهایم، و یک مخزن AppState Recipes در GitHub داریم که نمونههایی از استفاده از StateStore و Transform را نشان میدهد.
[versions]
appstate = "1.0.0-alpha01"
...
[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }و بله، لطفاً آنها را در برنامهتان امتحان کنید.
بازخوردتان را ارائه دهید.
مثلاً ما در مرحله آلفا هستیم، بنابراین این زمانی است که واقعاً میتوانیم تکرار کنیم و تغییراتی ایجاد کنیم که مطمئن شویم برای موارد استفاده شما کار میکنند.
/**
* A value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value.
*/
@Stable
public interface State<out T> {
public val value: T
}/**
* A mutable value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value. When the
* [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
* will be scheduled. If [value] is written to with the same value, no recompositions will be
* scheduled.
*/
@Stable
public interface MutableState<T> : State<T> {
override var value: T
public operator fun component1(): T
public operator fun component2(): (T) -> Unit
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}
@Composable
fun AddButton(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("Add")
}
}@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(true) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(false) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column {
Header(levelName = state.levelName)
ScoreBadge(
timeSurvived = state.timeSurvived,
score = state.score
)
}
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state = viewModel.uiState
Column {
Header(state = state)
ScoreBadge(state = state)
}
}public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: StateFlow<FocusSummary> =
combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle,
)
}class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: StateFlow<FocusSummary> =
combine(
sessionFlow,
taskRepository.tasksFlow
) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = scope,
container = handle.getOrCreateContainer("transform")
) {
val session by sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow
.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: StateFlow<MiniTimerUiState> =
combine(
focusManager.summary,
isExpanded
) { summary, expanded ->
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MiniTimerUiState()
)
fun toggleExpanded() { isExpanded.update { !it } }
}class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: State<MiniTimerUiState> =
transform(
initialValue = MiniTimerUiState(),
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val summary by focusManager.summary
val expanded by isExpanded.collectAsState()
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}
fun toggleExpanded() { isExpanded.update { !it } }
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R> {
val transformScope = TransformScope()
val transform = transformScope
.launchTransform(initialValue, onUpdate)
scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
) {
try { transformScope.run() } finally {
transform.dispose()
}
}
return transform
}public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
public abstract class TransformScope {
public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
public abstract suspend fun run()
}
public fun <T> TransformScope.launchTransform(
initialState: T,
onUpdate: @Composable (T) -> T,
): DisposableState<T>
public interface DisposableState<T> : State<T> {
public fun dispose()
}override suspend fun run() {
val context = currentCoroutineContext()
val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
...
val recomposer = Recomposer(context)
val composition = Composition(UnitApplier, recomposer)
try {
withContext(clock) {
if (clock is HeadlessTransformClock) {
launch(start = CoroutineStart.UNDISPATCHED) {
clock.runClock()
}
}
...
}
} finally {
// dispose of composition, cancel the clock, close the recomposer
}
}@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
fun updateSearchQuery(query: String) {
Snapshot.withMutableSnapshot {
searchQuery = query
selectedTaskIds = emptySet()
}
}
}val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}@Singleton
class NetworkMonitor @Inject constructor(
@ApplicationContext context: Context,
) {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
...
}@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { _isOnline.value = true }
override fun onLost(network: Network) { _isOnline.value = false }
}
init {
connectivityManager.registerDefaultNetworkCallback(callback)
}
}val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
if (!networkMonitor.isOnline.value) {
return@transform TaskListState.Offline
}
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}sealed interface FocusSession {
data object Idle : FocusSession
data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
...
}fun startFocus(taskId: String, durationMinutes: Int = 25) {
val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
stateStore.setState(
FocusSessionKey,
FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
)
}
fun endFocus() {
stateStore.setState(FocusSessionKey, FocusSession.Idle)
}fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Running) return@updateState session
FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
}
}
fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Paused) return@updateState session
FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
}
}@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}
@Composable
fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
val session by viewModel.session
if (session !is FocusSession.Idle) {
TimerReadout(session = session)
}
}@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(
@ApplicationContext context: Context,
@ApplicationScope scope: CoroutineScope,
): StateStore = StateStore().apply {
val path = context.dataStoreFile("app_state.json").absolutePath
scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
}
}@Serializable
sealed interface FocusSession {
@Serializable data object Idle : FocusSession
@Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
@Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
PersistToDataStoresuspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener {
for (key in keys) {
if (key is PersistToDataStore) {
val value = getState(key).value
LaunchedEffect(key) {
// Restore saved value from dataStore on first access
}
LaunchedEffect(key, value) {
// Flush updated value to dataStore on change
}
}
}
}
}[versions]
appstate = "1.0.0-alpha01"
...
[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }