مدد بقاء الحالة في 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) }
    )
}