عوارض جانبی در Compose

عوارض جانبی تغییری در وضعیت برنامه است که خارج از محدوده تابع ترکیب‌پذیر اتفاق می‌افتد. به‌دلیل چرخه حیات و ویژگی‌های composable، مانند بازترکیب‌های غیرقابل‌پیش‌بینی، اجرای بازترکیب‌های composable با ترتیب‌های مختلف، یا بازترکیب‌هایی که می‌توانند دور انداخته شوند، composableها در حالت ایده‌آل باید بدون عوارض جانبی باشند.

بااین‌حال، گاهی اوقات عوارض جانبی ضروری است، برای مثال، برای راه‌اندازی یک رویداد یک‌باره مانند نمایش یک اسنک‌بار یا پیمایش به صفحه دیگری با توجه به یک شرایط حالت خاص. این کنش‌ها باید از محیطی کنترل‌شده فراخوانده شوند که از چرخه حیات ترکیب‌پذیر آگاه است. در این صفحه، با میاناهای برنامه‌سازی کاربردی مختلف عوارض جانبی که Jetpack Compose ارائه می‌دهد آشنا خواهید شد.

موارد استفاده از State و Effect

همان‌طور که در سند Thinking in Compose توضیح داده شده است، composable-ها باید بدون عوارض جانبی باشند. وقتی لازم است تغییراتی در وضعیت برنامه ایجاد کنید (همان‌طور که در سند مدیریت اسناد وضعیت توضیح داده شده است)، باید از Effect API استفاده کنید تا این عوارض جانبی به‌صورت قابل‌پیش‌بینی اجرا شوند.

به‌دلیل امکانات مختلفی که جلوه‌ها در Compose ایجاد می‌کنند، به‌راحتی می‌توان از آن‌ها بیش‌ازحد استفاده کرد. مطمئن شوید کاری که در آن‌ها انجام می‌دهید مربوط به UI باشد و جریان داده یک‌سویه را، همان‌طور که در سند مدیریت وضعیت توضیح داده شده است، نقض نکند.

LaunchedEffect: اجرای توابع تعلیق در محدوده یک composable

برای انجام کار در طول عمر یک composable و داشتن توانایی فراخوانی توابع تعلیق، از LaunchedEffect composable استفاده کنید. وقتی LaunchedEffect وارد «ترکیب‌بندی» می‌شود، یک روال همگام با بلوک کد ارسال‌شده به‌عنوان پارامتر راه‌اندازی می‌کند. اگر LaunchedEffect ترکیب‌بندی را ترک کند، روال همگام لغو خواهد شد. اگر LaunchedEffect با کلیدهای متفاوتی بازترکیب شود (به بخش راه‌اندازی مجدد جلوه‌ها در زیر مراجعه کنید)، روال همکار موجود لغو خواهد شد و تابع تعلیق جدید در روال همکار جدیدی راه‌اندازی خواهد شد.

برای مثال، در اینجا پویانمایی‌ای وجود دارد که مقدار آلفا را با تأخیر قابل‌پیکربندی پالس می‌کند:

// 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)
    }
}

در کد بالا، پویانمایی از تابع تعلیق delay برای منتظر ماندن به‌مدت زمان تنظیم‌شده استفاده می‌کند. سپس، به‌طور متوالی آلفا را به صفر و دوباره به عقب بااستفاده از animateTo پویانمایی می‌کند. این کار برای عمر ترکیب‌پذیر تکرار خواهد شد.

‫rememberCoroutineScope: دریافت یک دامنه آگاه از ترکیب‌بندی برای راه‌اندازی یک کوروتین خارج از یک تابع ترکیب‌پذیر

ازآنجایی‌که LaunchedEffect یک تابع ترکیب‌پذیر است، فقط می‌توان از آن در داخل سایر توابع ترکیب‌پذیر استفاده کرد. برای راه‌اندازی یک کوروتین خارج از یک composable، اما با دامنه‌ای که به‌طور خودکار پس‌از خروج از ترکیب‌بندی لغو شود، از rememberCoroutineScope استفاده کنید. همچنین، هرگاه لازم بود چرخه حیات یک یا چند کوروتین را به‌صورت دستی کنترل کنید، از rememberCoroutineScope استفاده کنید، برای مثال، لغو کردن پویانمایی هنگام وقوع رویداد کاربر.

‫rememberCoroutineScope یک تابع ترکیب‌پذیر است که یک CoroutineScope را که به نقطه 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")
            }
        }
    }
}

rememberUpdatedState: به مقداری در تأثیری ارجاع دهید که درصورت تغییر مقدار نباید بازراه‌اندازی شود

وقتی یکی‌از پارامترهای کلیدی تغییر کند، LaunchedEffect بازراه‌اندازی می‌شود. بااین‌حال، در برخی شرایط ممکن است بخواهید مقداری را در جلوه خود ضبط کنید که اگر تغییر کند، نمی‌خواهید جلوه بازراه‌اندازی شود. برای انجام این کار، لازم است از rememberUpdatedState برای ایجاد ارجاع به این مقدار استفاده شود تا بتوان آن را ضبط و به‌روز کرد. این رویکرد برای جلوه‌هایی مفید است که حاوی عملیات‌های طولانی‌مدت هستند که ممکن است بازآفرینی و بازراه‌اندازی آن‌ها پرهزینه یا بازدارنده باشد.

برای مثال، فرض کنید برنامه‌تان LandingScreen دارد که پس‌از مدتی ناپدید می‌شود. حتی اگر LandingScreen دوباره ترکیب شود، جلوه‌ای که مدتی منتظر می‌ماند و اطلاع می‌دهد که زمان گذشته است نباید بازراه‌اندازی شود:

@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 */
}

برای ایجاد جلوه‌ای که با چرخه حیات سایت تماس مطابقت داشته باشد، ثابت تغییرناپذیری مانند Unit یا true به‌عنوان پارامتر ارسال می‌شود. در کد بالا، از LaunchedEffect(true) استفاده شده است. برای اطمینان از اینکه onTimeout lambda همیشه حاوی جدیدترین مقداری است که LandingScreen با آن بازترکیب شده است، onTimeout باید با تابع rememberUpdatedState پیچیده شود. State برگردانده‌شده، currentOnTimeout در کد، باید در جلوه استفاده شود.

‫DisposableEffect: جلوه‌هایی که نیاز به پاک‌سازی دارند

برای عوارض جانبی که باید پس‌از تغییر کلیدها پاک‌سازی شوند یا اگر composable از Composition خارج شود، از DisposableEffect استفاده کنید. اگر کلیدهای DisposableEffect تغییر کنند، composable باید جلوه فعلی خود را دور بیندازد (پاک‌سازی کند) و با فراخوانی مجدد جلوه، بازنشانی شود.

برای مثال، ممکن است بخواهید رویدادهای Analytics را براساس رویدادهای Lifecycle بااستفاده از LifecycleObserver ارسال کنید. برای گوش دادن به این رویدادها در Compose، از DisposableEffect برای ثبت‌نام و لغو ثبت‌نام ناظر درصورت نیاز استفاده کنید.

@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 */
}

در کد بالا، جلوه observer را به lifecycleOwner اضافه می‌کند. اگر lifecycleOwner تغییر کند، جلوه حذف می‌شود و با lifecycleOwner جدید بازراه‌اندازی می‌شود.

DisposableEffect باید شامل عبارت onDispose به‌عنوان عبارت نهایی در بلوک کد خود باشد. درغیراین‌صورت، IDE خطای زمان ساخت را نمایش می‌دهد.

‫SideEffect: انتشار وضعیت Compose در کد غیرCompose

برای هم‌رسانی کردن وضعیت Compose با اشیایی که Compose آن‌ها را مدیریت نمی‌کند، از SideEffect composable استفاده کنید. استفاده از SideEffect تضمین می‌کند که جلوه پس‌از هر بازترکیب موفق اجرا می‌شود. ازسوی دیگر، اجرای جلوه قبل‌از تضمین بازترکیب موفقیت‌آمیز نادرست است، که این مورد زمانی اتفاق می‌افتد که جلوه مستقیماً در یک composable نوشته شود.

برای مثال، کتابخانه تجزیه‌وتحلیل شما ممکن است به شما اجازه دهد جمعیت کاربران خود را با پیوست کردن فراداده سفارشی («ویژگی‌های کاربر» در این مثال) به همه رویدادهای تجزیه‌وتحلیل بعدی تقسیم‌بندی کنید. برای انتقال نوع کاربر کاربر فعلی به کتابخانه تجزیه‌وتحلیل خود، از 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: تبدیل وضعیت غیرCompose به وضعیت Compose

produceState یک کوروتین را که به Composition محدود شده است راه‌اندازی می‌کند که می‌تواند مقادیر را به State برگردانده‌شده ارسال کند. از آن برای تبدیل وضعیت غیرCompose به وضعیت Compose استفاده کنید، برای مثال، آوردن وضعیت خارجی مبتنی بر اشتراک مانند Flow، LiveData، یا RxJava به Composition.

وقتی produceState وارد «ترکیب‌بندی» می‌شود، تولیدکننده راه‌اندازی می‌شود و وقتی از «ترکیب‌بندی» خارج می‌شود، لغو خواهد شد. State برگردانده‌شده ادغام می‌شود؛ تنظیم همان مقدار باعث راه‌اندازی بازترکیب نمی‌شود.

اگرچه produceState یک کوروتین ایجاد می‌کند، اما می‌توان از آن برای مشاهده منابع داده غیرتعلیقی نیز استفاده کرد. برای برداشتن اشتراک آن منبع، از تابع awaitDispose استفاده کنید.

مثال زیر نحوه استفاده از produceState را برای بار کردن تصویر از شبکه نشان می‌دهد. تابع ترکیب‌پذیر loadNetworkImage یک State برمی‌گرداند که می‌تواند در ترکیب‌پذیرهای دیگر استفاده شود.

@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)
        }
    }
}

‫derivedStateOf: تبدیل یک یا چند شیء حالت به حالتی دیگر

در Compose، هربار که شیء حالت مشاهده‌شده یا ورودی قابل‌ترکیب تغییر می‌کند، ترکیب‌بندی مجدد رخ می‌دهد. ممکن است یک شیء حالت یا ورودی بیشتر از آنچه واسط کاربر واقعاً نیاز به به‌روزرسانی دارد تغییر کند، که منجر به بازترکیب غیرضروری می‌شود.

وقتی ورودی‌هایتان به یک ترکیب‌پذیر بیشتر از دفعاتی که نیاز به ترکیب مجدد دارید تغییر می‌کند، باید از تابع derivedStateOf استفاده کنید. این اتفاق اغلب زمانی رخ می‌دهد که چیزی به‌طور مکرر تغییر می‌کند، مانند موقعیت پیمایش، اما ترکیب‌پذیر فقط زمانی نیاز به واکنش به آن دارد که از آستانه خاصی عبور کند. ‫derivedStateOf شیء حالت Compose جدیدی ایجاد می‌کند که می‌توانید آن را مشاهده کنید و فقط به‌اندازه‌ای که نیاز دارید به‌روز می‌شود. به این ترتیب، مشابه عملگر Kotlin Flow distinctUntilChanged() عمل می‌کند.

استفاده درست

تکه‌کد زیر مورد استفاده مناسبی را برای 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 هروقت اولین مورد نمایان تغییر کند، تغییر می‌کند. با پیمایش کردن، مقدار به 0،‏ 1،‏ 2،‏ 3،‏ 4،‏ 5، و غیره تغییر می‌کند. بااین‌حال، بازترکیب فقط زمانی باید انجام شود که مقدار بزرگ‌تر از 0 باشد. این عدم تطابق در فواصل تجدید نظر به این معنی است که این مورد استفاده خوبی برای derivedStateOf است.

استفاده نادرست

یک اشتباه رایج این است که فرض کنید وقتی دو شیء حالت Compose را ترکیب می‌کنید، باید از 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

در این قطعه کد، fullName باید به‌اندازه firstName و lastName به‌روز شود. بنابراین، بازترکیب اضافی رخ نمی‌دهد و استفاده از derivedStateOf ضروری نیست.

‫snapshotFlow: تبدیل Compose's State به Flows

از snapshotFlow برای تبدیل کردن اشیای State<T> به Flow سرد استفاده کنید. snapshotFlow بلوک خود را هنگام جمع‌آوری اجرا می‌کند و نتیجه اشیاء State خوانده‌شده در آن را منتشر می‌کند. When one of the State objects read inside the snapshotFlow block mutates, the Flow will emit the new value to its collector if the new value is not equal to the previous emitted value (this behavior is similar to that of Flow.distinctUntilChanged).

مثال زیر یک اثر جانبی را نشان می‌دهد که وقتی کاربر از اولین مورد در فهرست عبور می‌کند، در تجزیه‌وتحلیل ثبت می‌شود:

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 تبدیل می‌شود که می‌تواند از قدرت اپراتورهای Flow بهره‌مند شود.

درحال بازراه‌اندازی جلوه‌ها

برخی‌از جلوه‌ها در Compose، مثل LaunchedEffect،‏ produceState، یا DisposableEffect، تعداد متغیری از آرگومان‌ها، کلیدها، را می‌گیرند که برای لغو جلوه درحال اجرا و شروع جلوه جدید با کلیدهای جدید استفاده می‌شوند.

شکل معمول این میاناهای برنامه‌سازی کاربردی به‌صورت زیر است:

EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }

به‌دلیل ظرافت‌های این رفتار، اگر پارامترهای استفاده‌شده برای بازراه‌اندازی جلوه درست نباشند، ممکن است مشکلاتی رخ دهد:

  • بازراه‌اندازی جلوه‌ها کمتر از حد لازم می‌تواند باعث ایجاد اشکال در برنامه‌تان شود.
  • بازراه‌اندازی جلوه‌ها بیش‌از حد لازم می‌تواند ناکارآمد باشد.

به‌عنوان یک قاعده کلی، متغیرهای تغییرپذیر و تغییرناپذیر استفاده‌شده در بلوک جلوه کد باید به‌عنوان پارامتر به جلوه ترکیب‌پذیر اضافه شوند. علاوه‌بر این‌ها، پارامترهای بیشتری را می‌توان برای اجبار به بازراه‌اندازی جلوه اضافه کرد. اگر تغییر متغیر نباید باعث بازراه‌اندازی اثر شود، متغیر باید در rememberUpdatedState قرار گیرد. اگر متغیر هرگز تغییر نمی‌کند چون در remember بدون کلید قرار گرفته است، لازم نیست متغیر را به‌عنوان کلید به جلوه ارسال کنید.

در کد DisposableEffect نشان‌داده‌شده در بالا، اثر به‌عنوان پارامتر lifecycleOwner استفاده‌شده در بلوک خود درنظر گرفته می‌شود، زیرا هرگونه تغییر در آن‌ها باید باعث راه‌اندازی مجدد اثر شود.

@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 به‌عنوان کلیدهای DisposableEffect لازم نیستند، زیرا مقادیر آن‌ها به‌دلیل استفاده از rememberUpdatedState هرگز در «ترکیب‌بندی» تغییر نمی‌کند. اگر lifecycleOwner را به‌عنوان پارامتر ارسال نکنید و تغییر کند، HomeScreen بازترکیب می‌شود، اما DisposableEffect دور انداخته و بازراه‌اندازی نمی‌شود. این امر باعث بروز مشکل می‌شود زیرا از آن نقطه به بعد از lifecycleOwner اشتباهی استفاده می‌شود.

ثابت‌ها به‌عنوان کلید

می‌توانید از ثابتی مثل true به‌عنوان کلید اثر استفاده کنید تا چرخه حیات سایت تماس را دنبال کند. موارد استفاده معتبری برای آن وجود دارد، مثل نمونه LaunchedEffect که در بالا نشان داده شده است. بااین‌حال، قبل‌از انجام این کار، دوباره فکر کنید و مطمئن شوید که این همان چیزی است که نیاز دارید.