Side-effect تغییری در وضعیت برنامه است که خارج از محدوده یک تابع Composable اتفاق میافتد. به دلیل چرخه عمر Composableها و ویژگیهایی مانند Recompositionهای غیرقابل پیشبینی، اجرای Recompositionها با ترتیبهای مختلف، یا Recompositionهایی که ممکن است کنار گذاشته شوند، بهتر است Composableها تا حد امکان بدون Side-effect باشند.
با این حال، گاهی Side-effectها ضروری هستند؛ برای مثال، برای اجرای یک رویداد یکباره مانند نمایش Snackbar یا رفتن به یک صفحه دیگر بر اساس یک وضعیت مشخص. این کارها باید از یک محیط کنترلشده اجرا شوند که از چرخه عمر Composable آگاه است. در این صفحه، با APIهای مختلف Side-effect که Jetpack Compose ارائه میدهد آشنا میشوید.
همانطور که در مستندات Thinking in Compose توضیح داده شد، Composableها باید بدون Side-effect باشند. وقتی لازم است تغییراتی در وضعیت برنامه ایجاد کنید، باید از Effect APIها استفاده کنید تا Side-effectها به شکلی قابل پیشبینی اجرا شوند.
اصطلاح کلیدی: یک Effect تابعی Composable است که UI تولید نمیکند، اما باعث میشود Side-effectها پس از کامل شدن Composition اجرا شوند.
به دلیل امکانات مختلفی که Effectها در Compose فراهم میکنند، ممکن است بهراحتی بیش از حد از آنها استفاده شود. مطمئن شوید کاری که داخل آنها انجام میدهید مربوط به UI است و جریان داده یکطرفه را نقض نمیکند.
نکته: یک UI واکنشگرا ذاتاً asynchronous است و Jetpack Compose این موضوع را با استفاده از coroutineها در سطح API حل میکند، نه با callbackها.
برای انجام کاری در طول عمر یک Composable و داشتن امکان فراخوانی توابع suspend، از Composable به نام LaunchedEffect استفاده کنید. وقتی LaunchedEffect وارد Composition میشود، یک coroutine را با بلوک کدی که بهعنوان پارامتر ارسال شده اجرا میکند.
اگر LaunchedEffect از Composition خارج شود، آن coroutine لغو میشود. اگر LaunchedEffect با keyهای متفاوت Recomposition شود، coroutine موجود لغو شده و تابع suspend جدید در یک coroutine جدید اجرا میشود.
برای مثال، کد زیر انیمیشنی را نشان میدهد که مقدار alpha را با یک تأخیر قابل تنظیم کم و زیاد میکند:
// Allow the pulse rate to be configured, so it can be sped up if the user is running
// out of time
var pulseRateMs by remember { mutableLongStateOf(3000L) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) { // Restart the effect when the pulse rate changes
while (isActive) {
delay(pulseRateMs) // Pulse the alpha every pulseRateMs to alert the user
alpha.animateTo(0f)
alpha.animateTo(1f)
}
}
در کد بالا، انیمیشن از تابع suspend به نام delay استفاده میکند تا به اندازه زمان تعیینشده صبر کند. سپس مقدار alpha را به ترتیب به صفر و دوباره به یک انیمیت میکند. این کار تا زمانی که Composable زنده باشد تکرار میشود.
از آنجا که LaunchedEffect خودش یک تابع Composable است، فقط میتواند داخل توابع Composable دیگر استفاده شود. برای اجرای یک coroutine خارج از یک Composable، اما با Scopeای که وقتی از Composition خارج شد بهصورت خودکار لغو شود، از rememberCoroutineScope استفاده کنید.
همچنین هر زمان نیاز دارید چرخه عمر یک یا چند coroutine را بهصورت دستی کنترل کنید، مثلاً هنگام لغو یک animation پس از یک رویداد کاربر، از rememberCoroutineScope استفاده کنید.
rememberCoroutineScope یک تابع Composable است که یک CoroutineScope متصل به نقطهای از Composition که در آن فراخوانی شده برمیگرداند. این Scope زمانی که آن فراخوانی از Composition خارج شود، لغو میشود.
با ادامه مثال قبلی، میتوانید از کد زیر برای نمایش Snackbar هنگام لمس یک Button توسط کاربر استفاده کنید:
@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {
// Creates a CoroutineScope bound to the MoviesScreen's lifecycle
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { contentPadding ->
Column(Modifier.padding(contentPadding)) {
Button(
onClick = {
// Create a new coroutine in the event handler to show a snackbar
scope.launch {
snackbarHostState.showSnackbar("Something happened!")
}
}
) {
Text("Press me")
}
}
}
}
LaunchedEffect وقتی یکی از پارامترهای key تغییر کند دوباره شروع میشود. اما در بعضی موقعیتها ممکن است بخواهید مقداری را داخل Effect نگه دارید که اگر تغییر کرد، باعث restart شدن Effect نشود.
برای انجام این کار باید از rememberUpdatedState استفاده کنید تا یک reference به آن مقدار ساخته شود؛ referenceای که قابل capture و update باشد. این روش برای Effectهایی مفید است که عملیات طولانیمدت دارند و ساختن یا شروع دوباره آنها ممکن است پرهزینه یا نامناسب باشد.
برای مثال، فرض کنید برنامه شما یک LandingScreen دارد که پس از مدتی ناپدید میشود. حتی اگر LandingScreen دوباره Recomposition شود، Effectای که منتظر گذشت زمان میماند و سپس اطلاع میدهد زمان تمام شده، نباید دوباره از ابتدا شروع شود:
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
// This will always refer to the latest onTimeout function that
// LandingScreen was recomposed with
val currentOnTimeout by rememberUpdatedState(onTimeout)
// Create an effect that matches the lifecycle of LandingScreen.
// If LandingScreen recomposes, the delay shouldn't start again.
LaunchedEffect(true) {
delay(SplashWaitTimeMillis)
currentOnTimeout()
}
/* Landing screen content */
}
برای ایجاد Effectای که با چرخه عمر Call site هماهنگ باشد، یک ثابت بدون تغییر مانند Unit یا true بهعنوان پارامتر ارسال میشود. در کد بالا از LaunchedEffect(true) استفاده شده است.
برای اینکه مطمئن شوید lambda مربوط به onTimeout همیشه جدیدترین مقداری را دارد که LandingScreen با آن Recomposition شده، باید onTimeout را با تابع rememberUpdatedState بستهبندی کنید. State برگشتی، یعنی currentOnTimeout در کد بالا، باید داخل Effect استفاده شود.
هشدار: LaunchedEffect(true) به اندازه while(true) مشکوک است. با اینکه موارد استفاده معتبر دارد، همیشه مکث کنید و مطمئن شوید واقعاً به آن نیاز دارید.
برای Side-effectهایی که پس از تغییر keyها یا خروج Composable از Composition نیاز به پاکسازی دارند، از DisposableEffect استفاده کنید. اگر keyهای DisposableEffect تغییر کنند، Composable باید Effect فعلی خود را dispose کند، یعنی عملیات پاکسازی را انجام دهد، و سپس با فراخوانی دوباره Effect آن را reset کند.
برای مثال، ممکن است بخواهید با استفاده از LifecycleObserver بر اساس رویدادهای Lifecycle، رویدادهای analytics ارسال کنید. برای گوش دادن به این رویدادها در Compose، از DisposableEffect استفاده کنید تا observer در زمان مناسب register و unregister شود.
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // Send the 'started' analytics event
onStop: () -> Unit // Send the 'stopped' analytics event
) {
// Safely update the current lambdas when a new one is provided
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
// If `lifecycleOwner` changes, dispose and reset the effect
DisposableEffect(lifecycleOwner) {
// Create an observer that triggers our remembered callbacks
// for sending analytics events
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
currentOnStart()
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnStop()
}
}
// Add the observer to the lifecycle
lifecycleOwner.lifecycle.addObserver(observer)
// When the effect leaves the Composition, remove the observer
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
/* Home screen content */
}
در کد بالا، Effect، observer را به lifecycleOwner اضافه میکند. اگر lifecycleOwner تغییر کند، Effect dispose شده و با lifecycleOwner جدید دوباره شروع میشود.
یک DisposableEffect باید حتماً یک بخش onDispose بهعنوان آخرین statement داخل بلوک کد خود داشته باشد. در غیر این صورت، IDE خطای زمان build نمایش میدهد.
نکته: داشتن یک بلوک خالی داخل onDispose روش مناسبی نیست. همیشه دوباره بررسی کنید که آیا Effect مناسبتری برای مورد استفاده شما وجود دارد یا نه.
برای اشتراکگذاری State مربوط به Compose با objectهایی که توسط Compose مدیریت نمیشوند، از Composable به نام SideEffect استفاده کنید. استفاده از SideEffect تضمین میکند که Effect پس از هر Recomposition موفق اجرا شود.
از طرف دیگر، انجام Effect قبل از اینکه موفق بودن Recomposition تضمین شود اشتباه است؛ این همان حالتی است که Effect را مستقیماً داخل یک Composable مینویسید.
برای مثال، ممکن است کتابخانه analytics شما اجازه دهد کاربران را با اتصال metadata سفارشی، که در این مثال «user properties» نامیده شده، به همه رویدادهای analytics بعدی تقسیمبندی کنید. برای ارسال نوع کاربر فعلی به کتابخانه analytics، از SideEffect استفاده کنید تا مقدار آن بهروزرسانی شود.
@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
val analytics: FirebaseAnalytics = remember {
FirebaseAnalytics()
}
// On every successful composition, update FirebaseAnalytics with
// the userType from the current User, ensuring that future analytics
// events have this metadata attached
SideEffect {
analytics.setUserProperty("userType", user.userType)
}
return analytics
}
produceState یک coroutine در محدوده Composition اجرا میکند که میتواند مقدارها را به یک State برگشتی ارسال کند. از آن برای تبدیل Stateهای غیر Compose به State مربوط به Compose استفاده کنید؛ برای مثال آوردن Stateهای خارجی مبتنی بر subscription مانند Flow، LiveData یا RxJava به داخل Composition.
Producer زمانی اجرا میشود که produceState وارد Composition شود و وقتی از Composition خارج شود لغو خواهد شد. State برگشتی conflated است؛ یعنی تنظیم همان مقدار قبلی باعث Recomposition نمیشود.
با اینکه produceState یک coroutine ایجاد میکند، میتواند برای مشاهده منابع دادهای غیر suspend نیز استفاده شود. برای حذف subscription آن منبع، از تابع awaitDispose استفاده کنید.
مثال زیر نشان میدهد چگونه از produceState برای بارگذاری یک تصویر از شبکه استفاده کنید. تابع Composable به نام loadNetworkImage یک State برمیگرداند که میتواند در سایر Composableها استفاده شود.
@Composable
fun loadNetworkImage(
url: String,
imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
// Creates a State<T> with Result.Loading as initial value
// If either `url` or `imageRepository` changes, the running producer
// will cancel and will be re-launched with the new inputs.
return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
// In a coroutine, can make suspend calls
val image = imageRepository.load(url)
// Update State with either an Error or Success result.
// This will trigger a recomposition where this State is read
value = if (image == null) {
Result.Error
} else {
Result.Success(image)
}
}
}
نکته: Composableهایی که مقدار بازگشتی دارند باید مانند یک تابع معمولی Kotlin نامگذاری شوند و با حرف کوچک شروع شوند.
نکته کلیدی: در پشت صحنه، produceState از Effectهای دیگر استفاده میکند. این تابع یک متغیر result را با remember { mutableStateOf(initialValue) } نگه میدارد و بلوک producer را داخل یک LaunchedEffect اجرا میکند. هر زمان value داخل بلوک producer بهروزرسانی شود، State مربوط به result با مقدار جدید بهروزرسانی میشود.
شما میتوانید بهراحتی Effectهای مخصوص خودتان را با استفاده از APIهای موجود بسازید.
در Compose، هر بار که یک State object مشاهدهشده یا ورودی Composable تغییر کند، Recomposition رخ میدهد. ممکن است یک State object یا ورودی بیشتر از آنچه UI واقعاً نیاز دارد تغییر کند و این باعث Recompositionهای غیرضروری شود.
باید زمانی از تابع derivedStateOf استفاده کنید که ورودیهای یک Composable بیشتر از مقدار لازم برای Recomposition تغییر میکنند. این حالت معمولاً زمانی رخ میدهد که چیزی مرتب تغییر میکند، مثل موقعیت اسکرول، اما Composable فقط باید وقتی به آن واکنش نشان دهد که از یک آستانه مشخص عبور کند.
derivedStateOf یک State object جدید در Compose ایجاد میکند که میتوانید آن را observe کنید و فقط به اندازه نیاز بهروزرسانی میشود. از این نظر، شبیه operator به نام distinctUntilChanged() در Kotlin Flow عمل میکند.
احتیاط: derivedStateOf پرهزینه است و فقط باید برای جلوگیری از Recomposition غیرضروری زمانی استفاده شود که نتیجه تغییر نکرده است.
قطعهکد زیر یک مورد استفاده مناسب برای derivedStateOf را نشان میدهد:
@Composable
// When the messages parameter changes, the MessageList
// composable recomposes. derivedStateOf does not
// affect this recomposition.
fun MessageList(messages: List<Message>) {
Box {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary compositions
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
}
}
در این قطعهکد، firstVisibleItemIndex هر بار که اولین آیتم قابل مشاهده تغییر کند تغییر میکند. هنگام اسکرول، مقدار آن به شکل ۰، ۱، ۲، ۳، ۴، ۵ و غیره تغییر میکند. اما Recomposition فقط زمانی لازم است که مقدار بزرگتر از ۰ باشد. این اختلاف در تعداد بهروزرسانیها باعث میشود این مورد استفاده خوبی برای derivedStateOf باشد.
یک اشتباه رایج این است که تصور کنید وقتی دو State object در Compose را ترکیب میکنید، باید از derivedStateOf استفاده کنید، چون در حال «derive کردن State» هستید. اما این کار فقط سربار اضافی ایجاد میکند و لازم نیست، همانطور که در قطعهکد زیر نشان داده شده است.
هشدار: قطعهکد زیر استفاده نادرست از derivedStateOf را نشان میدهد. از این کد در پروژه خود استفاده نکنید.
// DO NOT USE. Incorrect usage of derivedStateOf.
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // This is bad!!!
val fullNameCorrect = "$firstName $lastName" // This is correct
lastName تغییر میکنند بهروزرسانی شود. بنابراین Recomposition اضافی رخ نمیدهد و استفاده از derivedStateOf لازم نیست.
از snapshotFlow برای تبدیل objectهای State<T> به یک Flow سرد یا cold Flow استفاده کنید. snapshotFlow هنگام collect شدن، بلوک خود را اجرا میکند و نتیجه State objectهایی را که داخل آن خوانده شدهاند emit میکند.
وقتی یکی از State objectهایی که داخل بلوک snapshotFlow خوانده شده تغییر کند، Flow مقدار جدید را به collector خود emit میکند؛ البته فقط اگر مقدار جدید با مقدار emit شده قبلی برابر نباشد. این رفتار شبیه رفتار Flow.distinctUntilChanged است.
مثال زیر یک Side-effect را نشان میدهد که وقتی کاربر از اولین آیتم یک لیست عبور میکند، این اتفاق را در analytics ثبت میکند:
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { index -> index > 0 }
.distinctUntilChanged()
.filter { it == true }
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}
در کد بالا، listState.firstVisibleItemIndex به یک Flow تبدیل میشود که میتواند از قدرت operatorهای Flow استفاده کند.
بعضی Effectها در Compose، مانند LaunchedEffect، produceState یا DisposableEffect، تعداد متغیری از آرگومانها را میگیرند که key نامیده میشوند. این keyها برای لغو Effect در حال اجرا و شروع یک Effect جدید با keyهای جدید استفاده میشوند.
شکل معمول این APIها به این صورت است:
EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }
به دلیل ظرافتهای این رفتار، اگر پارامترهایی که برای restart کردن Effect استفاده میشوند درست انتخاب نشوند، ممکن است مشکل ایجاد شود:
Restart شدن کمتر از حد لازم میتواند باعث bug در اپلیکیشن شود.
Restart شدن بیشتر از حد لازم میتواند ناکارآمد باشد.
بهعنوان یک قانون کلی، متغیرهای mutable و immutable که داخل بلوک کد Effect استفاده میشوند باید بهعنوان پارامتر به Effect Composable اضافه شوند. جدا از این موارد، میتوان پارامترهای بیشتری هم اضافه کرد تا Effect مجبور به restart شود.
اگر تغییر یک متغیر نباید باعث restart شدن Effect شود، آن متغیر باید داخل rememberUpdatedState قرار گیرد. اگر متغیر هرگز تغییر نمیکند چون با remember بدون key بستهبندی شده، لازم نیست آن را بهعنوان key به Effect ارسال کنید.
نکته کلیدی: متغیرهایی که داخل یک Effect استفاده میشوند باید بهعنوان پارامتر Effect Composable اضافه شوند یا با rememberUpdatedState استفاده شوند.
در کد DisposableEffect که بالاتر نشان داده شد، Effect مقدار lifecycleOwner را بهعنوان پارامتر دریافت میکند، چون هر تغییری در آن باید باعث restart شدن Effect شود.
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // Send the 'started' analytics event
onStop: () -> Unit // Send the 'stopped' analytics event
) {
// These values never change in Composition
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
/* ... */
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
currentOnStart و currentOnStop لازم نیست بهعنوان keyهای DisposableEffect ارسال شوند، چون به دلیل استفاده از rememberUpdatedState، مقدارهای آنها در Composition تغییر نمیکند.
اگر lifecycleOwner را بهعنوان پارامتر ارسال نکنید و این مقدار تغییر کند، HomeScreen دوباره Recomposition میشود، اما DisposableEffect dispose و restart نمیشود. این موضوع مشکل ایجاد میکند، چون از آن لحظه به بعد lifecycleOwner اشتباه استفاده خواهد شد.
این محتوا کاملا رایگان توسط تیم کدلپر ترجمه شده و در اختیار شما کاربران عزیز قرار گرفته است، هر گونه کپی برداری برای مقاصد غیر رایگان و بدون ذکر منبع، مورد پیگیری قانونی قرار میگیرد.
ترجمه شده از منبع: منبع مستندات