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

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

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

موارد استفاده از حالت و جلوه

همان‌طور که در مستندات تفکر در «نوشتن» پوشش داده شده است، عناصر ترکیبی باید بدون اثر جانبی باشند. وقتی نیاز دارید در وضعیت برنامه تغییراتی ایجاد کنید (همان‌طور که در سند مدیریت وضعیت توضیح داده شده است)، باید از «میاناهای برنامه‌سازی کاربردی جلوه» استفاده کنید تا آن اثرات جانبی به روشی قابل‌پیش‌بینی اجرا شوند.

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

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

برای انجام کار در طول عمر یک عنصر ترکیبی و داشتن قابلیت فراخوانی توابع تعلیق، از عنصر ترکیبی LaunchedEffect استفاده کنید. وقتی 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 تابعی ترکیب‌شدنی است، فقط می‌تواند درون توابع ترکیب‌شدنی دیگر استفاده شود. برای راه‌اندازی یک روتین همکار خارج از یک عنصر ترکیبی، اما با محدوده به‌طوری که وقتی از ترکیب خارج می‌شود به‌طور خودکار لغو شود، از rememberCoroutineScope استفاده کنید. همچنین هرگاه نیاز داشتید چرخه حیات یک یا چند روتین فرعی را به‌صورت دستی کنترل کنید، برای مثال، وقتی رویداد کاربر رخ می‌دهد، پویانمایی را لغو کنید، از rememberCoroutineScope استفاده کنید.

‫rememberCoroutineScope تابع ترکیبی است که CoroutineScope را برمی‌گرداند که به نقطه «ترکیب» در جایی که فراخوانی می‌شود محدود شده است. وقتی تماس از «ترکیب» خارج شود، محدوده لغو خواهد شد.

با دنبال کردن مثال قبلی، می‌توانید از این کد برای نمایش 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 لامبدا همیشه حاوی جدیدترین مقداری است که LandingScreen با آن بازسازی شده است، onTimeout باید با تابع rememberUpdatedState پیچیده شود. State، currentOnTimeout برگشتی در کد باید در افکت استفاده شود.

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

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

برای مثال، ممکن است بخواهید رویدادهای Analytics را براساس رویدادهای Lifecycle بااستفاده از LifecycleObserver ارسال کنید. برای گوش دادن به این رویدادها در «نوشتن»، از 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: انتشار وضعیت «نوشتن» در کد غیر«نوشتن»

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

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

وقتی 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: تبدیل یک یا چند شیء حالت به حالت دیگر

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

وقتی ورودی‌های یک عنصر ترکیبی بیشتر از زمانی که نیاز به ترکیب مجدد دارید تغییر می‌کند، باید از تابع derivedStateOf استفاده کنید. این وضعیت اغلب زمانی رخ می‌دهد که چیزی به‌طور مکرر تغییر می‌کند، مثلاً موقعیت پیمایش، اما عنصر ترکیبی فقط باید وقتی از آستانه معینی عبور می‌کند به آن واکنش نشان دهد. ‫derivedStateOf شیء حالت «نوشتن» جدیدی ایجاد می‌کند که می‌توانید آن را مشاهده کنید و فقط تا جایی که نیاز دارید به‌روزرسانی می‌شود. به این ترتیب، این عملگر مشابه عملگر 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: تبدیل «وضعیت» «نوشتن» به «جریان»

از snapshotFlow برای تبدیل State<T> اشیا به «جریان» سرد استفاده کنید. وقتی snapshotFlow جمع‌آوری می‌شود، بلوک خود را اجرا می‌کند و نتیجه خواندن اشیای State را در آن منتشر می‌کند. وقتی یکی از State شیء خوانده‌شده در داخل بلوک snapshotFlow جهش پیدا می‌کند، اگر مقدار جدید برابر با مقدار قبلی منتشرشده نباشد، «جریان» مقدار جدید را به جمع‌کننده‌اش ارسال می‌کند (این رفتار شبیه رفتار 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 به «جریانی» تبدیل می‌شود که می‌تواند از قدرت عامل‌های «جریان» بهره‌مند شود.

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

برخی‌از جلوه‌ها در «نوشتن»، مثل 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 که در بالا نشان داده شده است. بااین‌حال، قبل‌از انجام این کار، دوبار فکر کنید و مطمئن شوید که این همان چیزی است که نیاز دارید.