تكلفة استعادة الحالة
إرجاع التطبيق لمكان ما تركه المستخدم. النسخة الرخيصة مقابل الغالية.
جاري تحميل المقال…
إرجاع التطبيق لمكان ما تركه المستخدم. النسخة الرخيصة مقابل الغالية.
جاري تحميل المقال…
مستخدم يملأ فاتورة. نظام التشغيل يقتل التطبيق لتحرير ذاكرة. يعود المستخدم. يتوقّع أن يعود إلى شاشة الفاتورة، مع القيم المكتوبة لا تزال هناك. الطريقة الرخيصة لتقديم هذا تنكسر تحت الحمل. الطريقة الباهظة تهدر البطّاريّة. شحنّا الاثنتَين وانتهينا في مكان ما في الوسط.
هذا المقال عمّا تكلّفه استعادة الحالة، والمقايضات التي قرّرت أين هبطنا.
ثلاثة أشياء مختلفة يجمعها الناس تحت نفس الاسم:
كلّ منها لديه ملفّ كلفة مختلف. نحسّنها بشكل منفصل.
النسخة التي تشحنها معظم التطبيقات هي «احفظ كلّ شيء على القرص في كلّ ضغطة مفتاح». تحديداً، في Flutter:
class InvoiceFormController extends ValueNotifier<InvoiceFormState> {
InvoiceFormController() : super(InvoiceFormState.empty()) {
addListener(_persist);
}
Future<void> _persist() async {
await Hive.box('drafts').put('current_invoice', value.toJson());
}
}كلّ تغيير في النموذج يطلق كتابة إلى Hive. Hive سريع، فـ «سريع» يبدو جيّداً في التطوير. في الإنتاج:
الإصلاح هو throttling الكتابات. الخطأ هو افتراض أنّ throttling يحلّ المشكلة.
«احفظ كلّ ٢٥٠ مللي ثانية» مع debounce يقلّص حجم الكتابة بـ ١٠-٢٠×. لا يغيّر الصورة لسببَين:
احتجنا أن نجعل الكتابات أنذر وأصغر.
ثلاثة تغييرات:
WidgetsBindingObserver في Flutter يكشف أحداث lifecycle. نوصل
controller النموذج بها ونكتب فقط حين:
AppLifecycleState.paused).class InvoiceFormController extends ChangeNotifier with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_persistDraft(); // أطلق وانسَ
}
}
}هذا التغيير الواحد قطع معدّل كتابتنا للفلاش بـ ~٩٥٪. المستخدم يكتب بحرّيّة؛ النموذج يُحفَظ حين يغادر أو يذهب التطبيق إلى الخلفيّة.
لبيانات طويلة الشكل (فاتورة مع بنود)، نحتفظ بـ snapshot lastSaved
ونحفظ JSON-patch بين الحاليّ والأخير فقط. إعادة التحميل تُعيد البناء
بتطبيق الـ patch على الـ snapshot.
final patch = json_patch.diff(state.lastSaved, state.toJson());
if (patch.isNotEmpty) {
await Hive.box('drafts').put('invoice_${formId}', {
'snapshot_at': state.lastSavedAt,
'patch': patch,
});
}متوسّط حجم الحمولة انخفض من ٤ KB إلى ٢٠٠ بايت. على جهاز متدنّ الفرق غير قابل للقياس؛ في المجموع عبر آلاف المستخدمين على فلاش بطيء يتراكم.
موضع التمرير بيانات عالية التردّد، منخفضة الأهمّية. حفظها على كلّ حدث تمرير عديم الجدوى؛ حفظها على أحداث lifecycle متأخّر. نتنازل: debounce ١ ثانية بينما المستخدم يمرّر بنشاط، يُحفَظ في الذاكرة فقط، ويُفرَّغ إلى القرص على أحداث lifecycle مع كلّ شيء آخر.
استعادة التمرير: عند استئناف التطبيق، ننتظر أوّل إطار، ثمّ نقفز إلى الـ offset المحفوظ. إن كانت القائمة قد أُعيدت جلبها ولم يعد الـ offset ذا معنى (نادر)، نثبّت إلى أقرب رأس لاصق معروف.
مقياسان:
كلا الرقمَين على لوحة معلومات. أوّل مرّة شحنّا فيها تغيير diff-persist، انخفض رقم البايتات المكتوبة ٢٠×، لكنّنا لم نلاحظ لأسبوعَين لأنّ لا أحد كان ينظر. الآن نفعل.
استعادة الحالة ليست «احفظ كلّ شيء»؛ هي «احفظ الأشياء التي يهتمّ المستخدم بفقدها، في اللحظات التي يكون فيها الحفظ رخيصاً». بيانات النموذج التي كتبها: نعم. البكسل الفرعيّ الدقيق لموضع تمريره: لا. معظم الكلفة في النسخة الرخيصة تأتي من حفظ الأشياء الخاطئة.
الجزء التالي من هذه السلسلة عن Riverpod مقابل Bloc، مع سنة من إيصالات الإنتاج. صفحة السلسلة تتابع البقيّة.