حالة الواجهة وعقود العرض: نمط حالات العرض
حالة الواجهة وعقود العرض: نمط حالات العرض
تمرّ كل شاشة في تطبيق Flutter بدورة حياة محددة: تبدأ بتحميل البيانات، ثم إما تنجح أو تصادف خطأ أو لا تجد شيئاً لعرضه. حين تكون هذه الدورة ضمنيةً — مدفونةً في أعلام منطقية مثل isLoading وhasError وisEmpty — يصبح شجرة الودجات هشةً ومليئةً بالأخطاء. يجعل نمط حالات العرض هذه الدورة صريحة بنمذجتها كـ sealed class أو enum، مما يُنشئ عقداً مكتوباً بين الـ ViewModel والودجت.
switch الشاملة في Dart على معالجة كل حالة ممكنة. لا يمكنك أن تنسى مؤشر التحميل أو رسم التوضيح الفارغ أو زر إعادة المحاولة — سيرفض المترجم البناء حتى تغطي كل الفروع.نمذجة دورة حياة الشاشة
تمر الشاشة المعتمدة على البيانات بأربع حالات متمايزة:
- التحميل (Loading) — عملية غير متزامنة جارية؛ اعرض مؤشر التقدم.
- النجاح (Success) — وصلت البيانات؛ اعرض المحتوى.
- الخطأ (Error) — فشلت العملية؛ اعرض رسالة وزر إعادة المحاولة.
- الفراغ (Empty) — نجحت العملية لكن لم تُرجع أي عناصر؛ اعرض توضيحاً مفيداً.
ترميز هذه الحالات كـ sealed class (Dart 3+) يمنح كل متغير حمولته الخاصة من البيانات ويجعل كل switch شاملةً في وقت الترجمة.
تعريف ViewState كـ Sealed Class
/// الحالات الأربع الممكنة لأي شاشة بيانات.
sealed class ViewState<T> {
const ViewState();
}
final class LoadingState<T> extends ViewState<T> {
const LoadingState();
}
final class SuccessState<T> extends ViewState<T> {
const SuccessState(this.data);
final T data;
}
final class ErrorState<T> extends ViewState<T> {
const ErrorState(this.message, {this.retry});
final String message;
final VoidCallback? retry;
}
final class EmptyState<T> extends ViewState<T> {
const EmptyState({this.message = 'لا توجد عناصر.'});
final String message;
}
جانب الـ ViewModel
يحتفظ الـ ViewModel المعتمد على ChangeNotifier (أو أي بدائل تفاعلية) بحقل ViewState واحد ويُحوّله بصورة ذرية. يلغي هذا النهج النمط المضاد الكلاسيكي المعروف بـ "ثلاثة أعلام" حيث يمكن أن يتعايش isLoading = true وhasError = true معاً — تركيبة مستحيلة في الواقع يجب أن يحرسها كود الودجت الدفاعي رغم ذلك.
ViewModel يُصدر حالات ViewState مكتوبة
class PostsViewModel extends ChangeNotifier {
ViewState<List<Post>> _state = const LoadingState();
ViewState<List<Post>> get state => _state;
Future<void> loadPosts() async {
_state = const LoadingState();
notifyListeners();
try {
final posts = await _repository.fetchPosts();
_state = posts.isEmpty
? const EmptyState(message: 'لا توجد منشورات بعد!')
: SuccessState(posts);
} catch (e) {
_state = ErrorState(
'فشل تحميل المنشورات: ${e.toString()}',
retry: loadPosts,
);
}
notifyListeners();
}
}
جانب الودجت — العرض الشامل
يستمع الودجت إلى الـ ViewModel ويستخدم تعبير switch على الـ ViewState. لأن الـ switch شاملة على sealed class، فإن إضافة متغير جديد لاحقاً ستُنتج خطأ في وقت الترجمة في كل ودجت يحتاج لمعالجته — شبكة أمان مدمجة.
ودجت يعرض الحالات الأربع
class PostsScreen extends StatelessWidget {
const PostsScreen({super.key});
@override
Widget build(BuildContext context) {
final vm = context.watch<PostsViewModel>();
return Scaffold(
appBar: AppBar(title: const Text('المنشورات')),
body: switch (vm.state) {
LoadingState() => const Center(
child: CircularProgressIndicator(),
),
SuccessState(:final data) => ListView.builder(
itemCount: data.length,
itemBuilder: (_, i) => PostTile(post: data[i]),
),
ErrorState(:final message, :final retry) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, textAlign: TextAlign.center),
if (retry != null)
ElevatedButton(
onPressed: retry,
child: const Text('إعادة المحاولة'),
),
],
),
),
EmptyState(:final message) => Center(
child: Text(message),
),
},
);
}
}
sealed class المكتوبة على enum العادي حين تحتاج كل حالة إلى حمل بيانات مختلفة (مثلاً SuccessState تحمل قائمة وErrorState تحمل سلسلة نصية). استخدم enum العادي فقط في آلات الحالة التي لا تحتاج إلى حمولة.المتغير بالـ Enum — حين لا تُحتاج الحمولات
للشاشات الأبسط التي تجلب بياناتها الخاصة من مصدر واحد وتعيش الحمولات في مكان آخر، يُعدّ enum العادي بديلاً أخف يفرض مع ذلك المعالجة الشاملة في تعبيرات switch الحديثة في Dart.
ViewState بسيط كـ Enum
enum ScreenStatus { loading, success, error, empty }
class SimpleListViewModel extends ChangeNotifier {
ScreenStatus status = ScreenStatus.loading;
List<String> items = [];
String errorMessage = '';
Future<void> load() async {
status = ScreenStatus.loading;
notifyListeners();
try {
items = await _repo.fetchItems();
status = items.isEmpty ? ScreenStatus.empty : ScreenStatus.success;
} catch (e) {
errorMessage = e.toString();
status = ScreenStatus.error;
}
notifyListeners();
}
}
// في الودجت:
// switch (vm.status) {
// ScreenStatus.loading => LoadingWidget(),
// ScreenStatus.success => ContentWidget(items: vm.items),
// ScreenStatus.error => ErrorWidget(message: vm.errorMessage),
// ScreenStatus.empty => EmptyWidget(),
// }
الفوائد والمقايضات
يُوفّر اعتماد نمط حالات العرض باتساق عبر قاعدة الكود مزايا عدة:
- اكتمال وقت الترجمة — يكتشف مترجم Dart الحالات غير المعالجة فوراً.
- مصدر حقيقة واحد — تُشتق واجهة المستخدم من متغير واحد بالضبط، مما يُلغي تعارضات الأعلام المنطقية.
- قابلية الاختبار — تحتاج اختبارات الوحدة للـ ViewModel فقط إلى التحقق من نوع الـ
ViewStateالفرعي المُصدر لكل سيناريو. - القراءة — يمكن للمطور الجديد قراءة تعريف الـ sealed class وفهم كل حالة يمكن أن تكون عليها الشاشة فوراً.
RefreshingState أو PaginatingState قبل الحاجة إليها. ابدأ بالحالات الأربع القانونية وأضف متغيرات جديدة فقط حين يتطلب متطلب UX حقيقي عرضاً مميزاً — المتغيرات المبكرة تُضخّم التسلسل الهرمي المختوم وتضيف فروعاً للـ switch في كل ودجت.الخلاصة
يستبدل نمط حالات العرض الأعلام المنطقية المتناثرة بمتغير حالة واحد مكتوب. بنمذجة دورة حياة الشاشة كـ sealed class — LoadingState وSuccessState وErrorState وEmptyState — تُنشئ عقداً لا لبس فيه بين الـ ViewModel والودجت. يُحوّل الـ ViewModel بصورة ذرية عبر حالات محددة جيداً؛ يعرض الودجت كلاً منها بشكل شامل عبر تعبير switch. النتيجة هي طبقة واجهة مستخدم يستحيل تركها في حالة غير محددة، سهلة الاختبار، وتوثّق نفسها بنفسها لأي مطور يقرأ تعريف الـ sealed class.