مدد بقاء الحالة في Compose

في Jetpack Compose، غالبًا ما تحتفظ الدوال القابلة للإنشاء بالحالة باستخدام الدالة remember. يمكن إعادة استخدام القيم التي يتم تذكّرها في عمليات إعادة الإنشاء، كما هو موضّح في State وJetpack Compose.

في حين أنّ remember تعمل كأداة للاحتفاظ بالقيم على مستوى عمليات إعادة الإنشاء، غالبًا ما تحتاج الحالة إلى البقاء نشطة بعد انتهاء مدة صلاحية عملية الإنشاء. توضّح هذه الصفحة الفرق بين واجهات برمجة التطبيقات remember وretain وrememberSaveable وrememberSerializable، ومتى يجب اختيار أي واجهة برمجة تطبيقات، وما هي أفضل الممارسات لإدارة القيم التي تم تذكّرها والاحتفاظ بها في Compose.

اختيار العمر المناسب

في Compose، تتوفّر عدة دوال يمكنك استخدامها للحفاظ على الحالة في عمليات الإنشاء وبعدها، وهي remember وretain وrememberSaveable وrememberSerializable. تختلف هذه الدوال في مدة بقائها ودلالاتها، ويناسب كل منها تخزين أنواع معيّنة من الحالة. تم توضيح الاختلافات في الجدول التالي:

remember

retain

rememberSaveable، rememberSerializable

هل تبقى القيم بعد إعادة التركيب؟

هل تظل القيم محفوظة عند إعادة إنشاء النشاط؟

سيتم دائمًا عرض المثيل نفسه (===)

سيتم عرض عنصر مكافئ (==)، ربما نسخة تم إلغاء تسلسلها

هل تظل القيم محفوظة عند إيقاف العملية نهائيًا؟

أنواع البيانات المتوافقة

الكل

يجب عدم الإشارة إلى أي عناصر سيتم تسريبها في حال إيقاف النشاط

يجب أن تكون قابلة للتسلسل
(إما باستخدام Saver مخصّص أو باستخدام kotlinx.serialization)

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

  • العناصر التي يقتصر نطاقها على المقطوعة الموسيقية
  • عناصر الإعداد للعناصر القابلة للإنشاء
  • الحالة التي يمكن إعادة إنشائها بدون فقدان دقة واجهة المستخدم
  • وحدات ذاكرة التخزين المؤقت
  • العناصر التي تدوم طويلاً أو عناصر "المدير"
  • بيانات أدخلها المستخدم
  • الحالة التي لا يمكن للتطبيق إعادة إنشائها، بما في ذلك إدخال حقل النص وحالة التمرير وأزرار التبديل وما إلى ذلك

remember

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

تحتوي كل نسخة من دالة مركّبة على مجموعة خاصة من القيم التي تم تخزينها مؤقتًا، ويُشار إليها باسم التخزين المؤقت حسب الموضع. عندما يتم تخزين القيم التي تم تذكّرها مؤقتًا لاستخدامها في عمليات إعادة إنشاء متعددة، يتم ربطها بموضعها في التسلسل الهرمي للإنشاء. إذا تم استخدام عنصر قابل للإنشاء في مواقع مختلفة، سيكون لكل مثيل في التسلسل الهرمي للتركيب مجموعة خاصة به من القيم التي تم تذكُّرها.

عندما لا يتم استخدام قيمة محفوظة، يتم تجاهلها ويتم حذف سجلّها. يتم نسيان القيم التي تم تذكّرها عند إزالتها من التسلسل الهرمي للتكوين (بما في ذلك عند إزالة قيمة وإعادة إضافتها للانتقال إلى موقع مختلف بدون استخدام الدالة المركّبة key أو MovableContent)، أو عند استدعائها باستخدام مَعلمات key مختلفة.

من بين الخيارات المتاحة، remember هو الأقصر عمرًا وينسى القيم في أقرب وقت من بين دوال التخزين المؤقت الأربع الموضّحة في هذه الصفحة. وهذا يجعلها الأنسب لما يلي:

  • إنشاء عناصر الحالة الداخلية، مثل موضع التمرير أو حالة الصورة المتحركة
  • تجنُّب إعادة إنشاء العناصر المكلفة في كل عملية إعادة إنشاء

ومع ذلك، عليك تجنُّب ما يلي:

  • تخزين أي بيانات أدخلها المستخدم باستخدام remember، لأنّه يتم نسيان العناصر التي تم تذكّرها عند إجراء تغييرات على إعدادات النشاط وعند إيقاف العملية التي بدأها النظام.

rememberSaveable وrememberSerializable

تستند الإصدارات rememberSaveable وrememberSerializable إلى الإصدار remember. وهي تتميّز بأطول عمر بين دوال التخزين المؤقت التي تمت مناقشتها في هذا الدليل. بالإضافة إلى التخزين المؤقت للأغراض حسب الموضع في عمليات إعادة التكوين، يمكن أيضًا حفظ القيم ليتم استعادتها في عمليات إعادة إنشاء الأنشطة، بما في ذلك عمليات إعادة الإنشاء الناتجة عن تغييرات في الإعدادات وعن إيقاف العملية نهائيًا (عندما يوقف النظام عملية تطبيقك أثناء تشغيله في الخلفية، ويكون ذلك عادةً إما لتحرير الذاكرة للتطبيقات التي تعمل في المقدّمة أو إذا ألغى المستخدم الأذونات من تطبيقك أثناء تشغيله).

تعمل السمة rememberSerializable بالطريقة نفسها التي تعمل بها السمة rememberSaveable، ولكنّها تتيح تلقائيًا إمكانية الاحتفاظ بالأنواع المعقّدة التي يمكن تسلسلها باستخدام مكتبة kotlinx.serialization. اختَر rememberSerializable إذا كان نوعك يحمل العلامة @Serializable (أو يمكن أن يحملها)، واختَر rememberSaveable في جميع الحالات الأخرى.

وهذا يجعل كلاً من rememberSaveable وrememberSerializable خيارَين مثاليَين لتخزين الحالة المرتبطة ببيانات أدخلها المستخدم، بما في ذلك إدخال حقل النص وموضع التمرير وحالات التبديل وغير ذلك. عليك حفظ هذه الحالة لضمان عدم فقدان المستخدم موضع التمرير. بشكل عام، يجب استخدام rememberSaveable أو rememberSerializable لتخزين أي حالة لا يمكن لتطبيقك استرجاعها من مصدر بيانات دائم آخر، مثل قاعدة بيانات.

يُرجى العِلم أنّ rememberSaveable وrememberSerializable يحفظان القيم المخزّنة مؤقتًا من خلال تحويلها إلى Bundle. وينتج عن ذلك نتيجتان:

  • يجب أن تكون القيم التي يتم تخزينها مؤقتًا قابلة للتمثيل بنوع واحد أو أكثر من أنواع البيانات التالية: الأنواع الأساسية (بما في ذلك Int أو Long أو Float أو Double) أو String أو مصفوفات أي من هذه الأنواع.
  • عند استعادة قيمة محفوظة، ستكون مثيلاً جديدًا يساوي (==)، ولكن ليس المرجع نفسه (===) الذي استخدمته التركيبة من قبل.

لتخزين أنواع بيانات أكثر تعقيدًا بدون استخدام kotlinx.serialization، يمكنك تنفيذ Saver مخصّص لتسلسل العنصر وإلغاء تسلسله إلى أنواع البيانات المتوافقة. يُرجى العِلم أنّ Compose تفهم أنواع البيانات الشائعة، مثل State وList وMap وSet وما إلى ذلك، وتُحوِّلها تلقائيًا إلى أنواع متوافقة نيابةً عنك. في ما يلي مثال على Saver لفئة Size. يتم تنفيذ ذلك من خلال تجميع جميع خصائص Size في قائمة باستخدام listSaver.

data class Size(val x: Int, val y: Int) {
    object Saver : androidx.compose.runtime.saveable.Saver<Size, Any> by listSaver(
        save = { listOf(it.x, it.y) },
        restore = { Size(it[0], it[1]) }
    )
}

@Composable
fun rememberSize(x: Int, y: Int) {
    rememberSaveable(x, y, saver = Size.Saver) {
        Size(x, y)
    }
}

retain

تتوفّر واجهة برمجة التطبيقات retain بين remember وrememberSaveable/rememberSerializable من حيث مدة تخزين القيم مؤقتًا. يتم تسميتها بشكل مختلف لأنّ القيم المحفوظة تمر أيضًا بدورة حياة مختلفة عن القيم التي تم تذكّرها.

عند الاحتفاظ بقيمة، يتم تخزينها مؤقتًا حسب الموضع وحفظها في بنية بيانات ثانوية لها مدة بقاء منفصلة مرتبطة بمدة بقاء التطبيق. يمكن أن تبقى القيمة المحفوظة بدون أن يتم تسلسلها عند إجراء تغييرات في الإعدادات، ولكن لا يمكن أن تبقى عند إيقاف العملية نهائيًا. إذا لم يتم استخدام قيمة بعد إعادة إنشاء التسلسل الهرمي للتركيب، يتم إيقاف القيمة المحتفظ بها (وهو ما يعادل نسيانها في retain).

في المقابل، يمكن لـ retain الاحتفاظ بالقيم التي لا يمكن تسلسلها، مثل تعبيرات lambda وعمليات نقل البيانات والكائنات الكبيرة، مثل الصور النقطية، وذلك مقابل دورة الحياة الأقصر من rememberSaveable. على سبيل المثال، يمكنك استخدام retain لإدارة مشغّل وسائط (مثل ExoPlayer) لمنع حدوث انقطاعات في تشغيل الوسائط أثناء تغيير الإعدادات.

@Composable
fun MediaPlayer() {
    // Use the application context to avoid a memory leak
    val applicationContext = LocalContext.current.applicationContext
    val exoPlayer = retain { ExoPlayer.Builder(applicationContext).apply { /* ... */ }.build() }
    // ...
}

retain مقابل ViewModel

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

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

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

يكون retain مناسبًا بشكل أفضل للعناصر التي يقتصر نطاقها على مثيلات معيّنة قابلة للإنشاء ولا تتطلّب إعادة استخدام أو مشاركة بين عناصر قابلة للإنشاء متجاورة. حيث إنّ ViewModel مكان مناسب لتخزين حالة واجهة المستخدم وتنفيذ المهام في الخلفية، فإنّ retain خيار مناسب لتخزين العناصر الخاصة ببنية واجهة المستخدم، مثل ذاكرات التخزين المؤقت وتتبُّع مرات الظهور والإحصاءات والتبعيات في AndroidView وغيرها من العناصر التي تتفاعل مع نظام التشغيل Android أو تدير المكتبات التابعة لجهات خارجية، مثل معالجات الدفع أو الإعلانات.

بالنسبة إلى المستخدمين المتقدّمين الذين يصمّمون أنماطًا مخصّصة لبنية التطبيقات خارج نطاق اقتراحات بنية التطبيقات الحديثة على Android، يمكن أيضًا استخدام retain لإنشاء واجهة برمجة تطبيقات داخلية "تشبه ViewModel". على الرغم من أنّ دعم الروتينات الفرعية وحالة الحفظ غير متوفّرين بشكلٍ جاهز، يمكن استخدام retain كعنصر أساسي في مراحل نشاط العناصر المشابهة ViewModel التي تتضمّن هذه الميزات. لا يتناول هذا الدليل تفاصيل كيفية تصميم مثل هذا المكوّن.

retain

ViewModel

تحديد النطاق

لا توجد قيم مشترَكة، ويتم الاحتفاظ بكل قيمة وربطها بنقطة معيّنة في التسلسل الهرمي للتركيب. يؤدي الاحتفاظ بالنوع نفسه في موقع مختلف دائمًا إلى إنشاء مثيل جديد.

ViewModel هي عناصر فردية ضمن ViewModelStore

التدمير

عند مغادرة التسلسل الهرمي للتركيب نهائيًا

عندما تتم إزالة ViewModelStore أو إتلافه

وظائف إضافية

يمكن تلقّي عمليات ردّ الاتصال عندما يكون العنصر في التسلسل الهرمي للتركيب أو لا يكون فيه

يمكن إدخال coroutineScope المضمّنة، والتي تتوافق مع SavedStateHandle، باستخدام Hilt.

المالك

RetainedValuesStore

ViewModelStore

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

  • الاحتفاظ بالقيم الخاصة بواجهة المستخدم والمحلية لكل مثيل من الدوال المركّبة
  • تتبُّع مرات الظهور، ربما من خلال RetainedEffect
  • اللبنة الأساسية لتحديد بنية مخصّصة تشبه "ViewModel"
  • استخراج التفاعلات بين طبقتَي واجهة المستخدم والبيانات إلى فئة منفصلة، وذلك لتنظيم الرموز البرمجية واختبارها
  • تحويل Flow إلى عناصر State واستدعاء دوال تعليق لا يجب أن تتوقّف بسبب تغييرات في الإعدادات
  • مشاركة الحالات على مساحات كبيرة في واجهة المستخدم، مثل الشاشات بأكملها
  • إمكانية التشغيل التفاعلي مع View

الجمع بين retain وrememberSaveable أو rememberSerializable

في بعض الأحيان، يجب أن يكون لعنصر مدة صلاحية مختلطة من retained وrememberSaveable أو rememberSerializable. قد يشير ذلك إلى أنّ العنصر يجب أن يكون ViewModel، ما يتيح إمكانية حفظ الحالة كما هو موضّح في وحدة "الحالة المحفوظة" لدليل ViewModel.

يمكن استخدام retain وrememberSaveable أو rememberSerializable في الوقت نفسه. ويؤدي الجمع بين دورات الحياة هذه بشكل صحيح إلى زيادة التعقيد بشكل كبير. ننصحك باستخدام هذا النمط كجزء من أنماط معمارية أكثر تقدّمًا ومخصّصة، وذلك فقط عندما تنطبق جميع الشروط التالية:

  • تحديد عنصر يتألف من مجموعة من القيم التي يجب الاحتفاظ بها أو حفظها (مثل عنصر يتتبّع بيانات أدخلها المستخدم وذاكرة التخزين المؤقت التي لا يمكن الكتابة إليها على القرص)
  • يكون نطاق حالتك محصورًا في دالة مركّبة ولا يكون مناسبًا لنطاق سينغلتون أو مدة بقاء ViewModel

عندما تكون كل هذه الحالات صحيحة، ننصح بتقسيم صفك إلى ثلاثة أجزاء: البيانات المحفوظة والبيانات المحتفظ بها وعنصر "وسيط" ليس له حالة خاصة به ويفوّض الكائنَين المحفوظ والمحتفظ به لتعديل الحالة وفقًا لذلك. يتّخذ هذا النمط الشكل التالي:

@Composable
fun rememberAndRetain(): CombinedRememberRetained {
    val saveData = rememberSerializable(serializer = serializer<ExtractedSaveData>()) {
        ExtractedSaveData()
    }
    val retainData = retain { ExtractedRetainData() }
    return remember(saveData, retainData) {
        CombinedRememberRetained(saveData, retainData)
    }
}

@Serializable
data class ExtractedSaveData(
    // All values that should persist process death should be managed by this class.
    var savedData: AnotherSerializableType = defaultValue()
)

class ExtractedRetainData {
    // All values that should be retained should appear in this class.
    // It's possible to manage a CoroutineScope using RetainObserver.
    // See the full sample for details.
    var retainedData = Any()
}

class CombinedRememberRetained(
    private val saveData: ExtractedSaveData,
    private val retainData: ExtractedRetainData,
) {
    fun doAction() {
        // Manipulate the retained and saved state as needed.
    }
}

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

يمكنك الاطّلاع على النموذج الكامل (RetainAndSaveSample.kt) للحصول على مثال كامل حول كيفية تنفيذ هذا النمط.

التخزين المؤقت حسب الموضع والتنسيقات التكيّفية

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

بما أنّ القيم التي تم تذكّرها والاحتفاظ بها يتم تخزينها مؤقتًا حسب الموضع، لا تتم إعادة استخدامها إلا إذا ظهرت في الموضع نفسه في التسلسل الهرمي للتركيب. عندما تتكيّف التصاميم مع عوامل الشكل المختلفة، قد تغيّر بنية التسلسل الهرمي للتكوين وتؤدي إلى نسيان القيم.

بالنسبة إلى المكوّنات الجاهزة للاستخدام، مثل ListDetailPaneScaffold وNavDisplay (من Jetpack Navigation 3)، لا يشكّل ذلك مشكلة وسيظل حالتك ثابتة أثناء تغييرات التنسيق. بالنسبة إلى المكوّنات المخصّصة التي تتكيّف مع أشكال الأجهزة، تأكَّد من أنّ الحالة لا تتأثر بتغييرات التصميم من خلال تنفيذ أحد الإجراءات التالية:

  • تأكَّد من أنّه يتم دائمًا استدعاء العناصر القابلة للإنشاء ذات الحالة في الموضع نفسه في التدرّج الهرمي للتكوين. يمكنك تنفيذ تنسيقات متجاوبة من خلال تغيير منطق التنسيق بدلاً من نقل العناصر في التسلسل الهرمي للتصميم.
  • استخدِم MovableContent لنقل العناصر القابلة للإنشاء التي تحتفظ بحالتها بسلاسة. يمكن لنسخ MovableContent نقل القيم المحفوظة والمحتفظ بها من مواقعها القديمة إلى المواقع الجديدة.

تذكُّر الدوال الأصلية

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

عند تحديد العناصر التي تركّز على Compose، ننصحك بإنشاء دالة remember لتحديد السلوك المطلوب للتذكُّر، بما في ذلك كل من مدة البقاء والمدخلات الرئيسية. يتيح ذلك لمستهلكي حالتك إنشاء مثيلات بثقة في التسلسل الهرمي للتكوين، وستبقى هذه المثيلات صالحة وسيتم إبطالها على النحو المتوقّع. عند تحديد دالة مصنع قابلة للإنشاء، اتّبِع الإرشادات التالية:

  • أضِف البادئة remember إلى اسم الدالة. يمكنك بدلاً من ذلك استخدام البادئة retain إذا كان تنفيذ الدالة يعتمد على الكائن retained ولن تتطوّر واجهة برمجة التطبيقات أبدًا لتعتمد على صيغة مختلفة من remember.
  • استخدِم rememberSaveable أو rememberSerializable إذا تم اختيار الاحتفاظ بالحالة وكان من الممكن كتابة تنفيذ Saver صحيح.
  • تجنَّب الآثار الجانبية أو القيم الأولية المستندة إلى CompositionLocal التي قد لا تكون ذات صلة بالاستخدام. تذكَّر أنّ المكان الذي يتم فيه إنشاء حالتك قد لا يكون هو المكان الذي يتم فيه استهلاكها.

@Composable
fun rememberImageState(
    imageUri: String,
    initialZoom: Float = 1f,
    initialPanX: Int = 0,
    initialPanY: Int = 0
): ImageState {
    return rememberSaveable(imageUri, saver = ImageState.Saver) {
        ImageState(
            imageUri, initialZoom, initialPanX, initialPanY
        )
    }
}

data class ImageState(
    val imageUri: String,
    val zoom: Float,
    val panX: Int,
    val panY: Int
) {
    object Saver : androidx.compose.runtime.saveable.Saver<ImageState, Any> by listSaver(
        save = { listOf(it.imageUri, it.zoom, it.panX, it.panY) },
        restore = { ImageState(it[0] as String, it[1] as Float, it[2] as Int, it[3] as Int) }
    )
}