التأثيرات الجانبية في Compose

التأثير الجانبي هو تغيير في حالة التطبيق يحدث خارج نطاق دالة مركّبة. بسبب دورة حياة العناصر المركبة وخصائصها، مثل عمليات إعادة الإنشاء غير المتوقّعة، أو عمليات إعادة الإنشاء التي يمكن تجاهلها، أو عمليات إعادة الإنشاء التي يتم تنفيذها بترتيبات مختلفة، يجب أن تكون العناصر المركبة خالية من الآثار الجانبية.

ومع ذلك، تكون الآثار الجانبية ضرورية في بعض الأحيان، مثلاً لتشغيل حدث لمرة واحدة، مثل عرض شريط معلومات أو الانتقال إلى شاشة أخرى عند توفّر حالة معيّنة. يجب استدعاء هذه الإجراءات من بيئة خاضعة للتحكّم على دراية بدورة حياة العنصر المركب. في هذه الصفحة، ستتعرّف على واجهات برمجة التطبيقات المختلفة التي توفّرها Jetpack Compose للتأثيرات الجانبية.

حالات استخدام الحالة والتأثير

كما هو موضّح في مستندات التفكير في Compose، يجب أن تكون العناصر المركّبة خالية من الآثار الجانبية. عندما تحتاج إلى إجراء تغييرات على حالة التطبيق (كما هو موضّح في مستند إدارة الحالةعليك استخدام واجهات برمجة التطبيقات الخاصة بالتأثيرات حتى يتم تنفيذ هذه الآثار الجانبية بطريقة يمكن توقّعها.

بسبب الإمكانيات المختلفة التي تتيحها التأثيرات في Compose، يمكن بسهولة الإفراط في استخدامها. تأكَّد من أنّ العمل الذي تجريه في هذه التأثيرات مرتبط بواجهة المستخدم و لا يكسر تدفق البيانات أحادي الاتجاه كما هو موضّح في مستند إدارة الحالة.

LaunchedEffect: تشغيل دوال تعليق في نطاق عنصر مركّب

لتنفيذ عمل على مدار عمر عنصر مركّب مع إمكانية استدعاء دوال معلّقة، استخدِم العنصر المركّب LaunchedEffect. عندما يدخل LaunchedEffect إلى Composition، يتم تشغيل كوروتين باستخدام مجموعة الرموز البرمجية التي تم تمريرها كمَعلمة. سيتم إلغاء الكوروتين إذا غادر 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 مرتبطًا بنقطة التركيب التي يتم استدعاؤها فيها. سيتم إلغاء النطاق عندما يغادر الاستدعاء 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). للتأكّد من أنّ تعبير lambda onTimeout يحتوي دائمًا على أحدث قيمة تم إعادة إنشاء LandingScreen بها، يجب تضمين onTimeout في الدالة rememberUpdatedState. يجب استخدام State الذي تم إرجاعه، وهو currentOnTimeout في الرمز البرمجي، في التأثير.

DisposableEffect: التأثيرات التي تتطلّب التنظيف

بالنسبة إلى الآثار الجانبية التي يجب تنظيفها بعد تغيير المفاتيح أو إذا غادر العنصر المركّب Composition، استخدِم DisposableEffect. في حال تغيّر مفاتيح DisposableEffect، يجب أن تتخلص العناصر المركّبة (أي أن تنفّذ عملية التنظيف) من التأثير الحالي، ثم تعيد ضبطه من خلال استدعاء التأثير مرة أخرى.

على سبيل المثال، قد تريد إرسال أحداث إحصاءات استنادًا إلى 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 كعبارة نهائية في كتلة الرمز البرمجي. وإلا، تعرض بيئة التطوير المتكاملة خطأ في وقت الإنشاء.

SideEffect: نشر حالة Compose إلى رمز برمجي غير Compose

لمشاركة حالة Compose مع عناصر لا تتم إدارتها من خلال Compose، استخدِم الدالة البرمجية المركّبة SideEffect. يضمن استخدام 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 كوروتين ضمن نطاق Composition يمكنه إرسال القيم إلى State تم إرجاعه. يمكنك استخدامها لتحويل حالة غير Compose إلى حالة Compose، مثلاً، نقل حالة خارجية مستندة إلى الاشتراك، مثل Flow أو LiveData أو RxJava، إلى Composition.

يتم تشغيل المنتج عندما تدخل produceState إلى Composition، وسيتم إلغاؤه عند مغادرة Composition. يتم دمج 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 يمكنك مراقبته، ولا يتم تعديله إلا بالقدر الذي تحتاج إليه. وبهذه الطريقة، يعمل بشكل مشابه لعامل التشغيل distinctUntilChanged() في Kotlin Flows.

الاستخدام الصحيح

يعرض المقتطف التالي حالة استخدام مناسبة لـ 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 إلى Flows

استخدِم snapshotFlow لتحويل State<T> كائنات إلى Flow بارد. يُشغِّل snapshotFlow الكتلة عند جمعها ويعرض نتيجة كائنات State التي تم قراءتها فيها. عندما يتغيّر أحد كائنات State التي تم قراءتها داخل كتلة snapshotFlow، سيُرسِل Flow القيمة الجديدة إلى جامعها إذا كانت القيمة الجديدة لا تساوي القيمة السابقة التي تم إرسالها (يشبه هذا السلوك سلوك 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، لأنّ قيمتهما لا تتغيّر أبدًا في Composition بسبب استخدام rememberUpdatedState. إذا لم يتم تمرير lifecycleOwner كمَعلمة وتغيّرت، تتم إعادة إنشاء HomeScreen، ولكن لا يتم التخلّص من DisposableEffect وإعادة تشغيله. يؤدي ذلك إلى حدوث مشاكل لأنّه يتم استخدام lifecycleOwner غير الصحيح من تلك النقطة فصاعدًا.

الثوابت كمفاتيح

يمكنك استخدام ثابت، مثل true، كمفتاح تأثير لجعله يتبع دورة حياة موقع الاستدعاء. هناك حالات استخدام صالحة لذلك، مثل مثال LaunchedEffect الموضّح أعلاه. ومع ذلك، قبل إجراء ذلك، فكِّر مليًا وتأكَّد من أنّ هذا هو ما تحتاجه.