بنية التطبيق وأنماط التصميم

طبقة العرض ونمط MVVM

16 دقيقة الدرس 6 من 12

طبقة العرض ونمط MVVM

في تطبيق Flutter ذي البنية الجيدة، تُعدّ طبقة العرض (Presentation Layer) الحلقة الخارجية في هندسة البصلة (Onion Architecture). مهمتها حصراً هي عرض واجهة المستخدم والاستجابة لتفاعلات المستخدم — لا أكثر. كل قرارات الأعمال والتحقق من البيانات وعمليات التحويل تنتمي إلى طبقات أخرى. يمنح نمط MVVM (Model-View-ViewModel) هذه الطبقة هيكلاً داخلياً واضحاً عبر تقديم ViewModel بوصفه الجسر بين واجهة المستخدم ومنطق المجال (Domain).

لماذا MVVM في Flutter؟

يمكن لودجات Flutter أن تتضخم بسهولة بمنطق الأعمال: جلب البيانات، وتحويل الاستجابات، ومعالجة الأخطاء، وإدارة مؤشرات التحميل، كلها محشورة في طريقة build() واحدة. يحول MVVM دون ذلك بالفصل بين المخاوف:

  • النموذج (Model) — كيانات المجال ومصادر البيانات (UseCases وRepositories)
  • العرض (View) — شجرة الودجات؛ تصريحية بالكامل ولا تحتوي على أي منطق أعمال
  • ViewModel — يحمل حالة واجهة المستخدم، ويستدعي UseCases، ويُخطر العرض بالتحديث
ملاحظة: في Flutter، لا يحتفظ ViewModel بأي مرجع إلى العرض. يسير التواصل في اتجاه واحد: يكشف ViewModel عن الحالة؛ يراقبها العرض ويتفاعل معها. هذا يُلغي الاقتران الوثيق ويجعل الاختبار أمراً سهلاً — يمكنك اختبار ViewModel وحدةً دون الحاجة لشجرة ودجات.

بناء ViewModel باستخدام ChangeNotifier

أبسط طريقة لتنفيذ ViewModel في Flutter هي توسيع (extend) كلاس ChangeNotifier من package:flutter/foundation.dart. كلما تغيرت الحالة تستدعي notifyListeners()، مما يُطلق إعادة بناء كل Consumer أو ListenableBuilder يراقب هذا الكائن.

UserProfileViewModel (ChangeNotifier)

import 'package:flutter/foundation.dart';
import '../domain/use_cases/get_user_profile_use_case.dart';
import '../domain/entities/user_profile.dart';

/// ViewModel لشاشة الملف الشخصي للمستخدم.
/// يستدعي GetUserProfileUseCase ويكشف عن حالة واجهة المستخدم.
class UserProfileViewModel extends ChangeNotifier {
  final GetUserProfileUseCase _getUserProfile;

  // --- حالة واجهة المستخدم ---
  bool _isLoading = false;
  UserProfile? _profile;
  String? _errorMessage;

  bool get isLoading => _isLoading;
  UserProfile? get profile => _profile;
  String? get errorMessage => _errorMessage;

  UserProfileViewModel({required GetUserProfileUseCase getUserProfile})
      : _getUserProfile = getUserProfile;

  Future<void> loadProfile(String userId) async {
    _isLoading = true;
    _errorMessage = null;
    notifyListeners(); // أخبر العرض: اعرض مؤشر التحميل

    try {
      _profile = await _getUserProfile(userId);
    } catch (e) {
      _errorMessage = 'فشل تحميل الملف الشخصي: $e';
    } finally {
      _isLoading = false;
      notifyListeners(); // أخبر العرض: أخفِ المؤشر، اعرض البيانات أو الخطأ
    }
  }
}
نصيحة: احرص على أن تكون كل حقل قد تحتاج شجرة الودجات لعرضه داخل ViewModel. يشمل ذلك علامات التحميل ورسائل الخطأ والبيانات الفعلية. لا تدع العرض يُجري استدعاءات غير متزامنة مباشرة أو يحتوي على منطق أعمال في جمل if/else.

العرض (View): ودجت تفاعلي بحت

يشترك العرض في ViewModel باستخدام ListenableBuilder (Flutter 3.7 فما فوق) أو Consumer من Provider. يقرأ الحالة ويُعيد البناء كلما أُطلق notifyListeners() — لا يحتوي الودجت نفسه على أي منطق سوى تحويل الحالة إلى ودجات.

UserProfileView (عرض بحت)

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'user_profile_view_model.dart';

class UserProfileView extends StatelessWidget {
  final String userId;
  const UserProfileView({super.key, required this.userId});

  @override
  Widget build(BuildContext context) {
    final vm = context.watch<UserProfileViewModel>();

    // تشغيل تحميل البيانات مرة واحدة بعد أول إطار
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (vm.profile == null && !vm.isLoading) {
        vm.loadProfile(userId);
      }
    });

    if (vm.isLoading) {
      return const Center(child: CircularProgressIndicator());
    }

    if (vm.errorMessage != null) {
      return Center(child: Text(vm.errorMessage!));
    }

    final profile = vm.profile;
    if (profile == null) return const SizedBox.shrink();

    return Column(
      children: [
        CircleAvatar(backgroundImage: NetworkImage(profile.avatarUrl)),
        Text(profile.displayName, style: const TextStyle(fontSize: 20)),
        Text(profile.email),
      ],
    );
  }
}

استخدام StateNotifier للحالة غير القابلة للتغيير

عند استخدام Riverpod، الخيار الأمثل هو StateNotifier<S> مقترناً بكلاس حالة واجهة المستخدم المختوم (sealed). يحتوي ViewModel على كل انتقالات الحالة في مكان واحد ويصدر كائن حالة جديدًا غير قابل للتغيير في كل مرة — دون حقول قابلة للتعديل أو استدعاءات notifyListeners() فردية.

UserProfileNotifier (Riverpod StateNotifier)

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../domain/use_cases/get_user_profile_use_case.dart';
import '../domain/entities/user_profile.dart';

// حالة واجهة المستخدم المختومة
sealed class UserProfileState {}

class UserProfileInitial extends UserProfileState {}
class UserProfileLoading extends UserProfileState {}
class UserProfileLoaded extends UserProfileState {
  final UserProfile profile;
  UserProfileLoaded(this.profile);
}
class UserProfileError extends UserProfileState {
  final String message;
  UserProfileError(this.message);
}

// ViewModel / StateNotifier
class UserProfileNotifier extends StateNotifier<UserProfileState> {
  final GetUserProfileUseCase _getUserProfile;

  UserProfileNotifier(this._getUserProfile) : super(UserProfileInitial());

  Future<void> loadProfile(String userId) async {
    state = UserProfileLoading();
    try {
      final profile = await _getUserProfile(userId);
      state = UserProfileLoaded(profile);
    } catch (e) {
      state = UserProfileError('تعذّر تحميل الملف الشخصي.');
    }
  }
}

// مزوّد Riverpod
final userProfileProvider =
    StateNotifierProvider.autoDispose<UserProfileNotifier, UserProfileState>(
  (ref) => UserProfileNotifier(ref.read(getUserProfileUseCaseProvider)),
);
تحذير: لا تُمرر BuildContext إلى ViewModel أبداً. إذا احتجت إلى التنقل بعد عملية غير متزامنة، اكشف عن تيار تأثيرات جانبية أو ValueNotifier أحادي الاستخدام يراقبه العرض ويتصرف بناءً عليه. إبعاد السياق عن ViewModel هو ما يجعله قابلاً للاختبار.

تدفق الاعتماديات

في MVVM تشير أسهم الاعتمادية دائماً إلى الداخل — من الطبقات الخارجية نحو المجال:

  • العرض (View) يعتمد على ViewModel (يقرأ الحالة ويستدعي الدوال)
  • ViewModel يعتمد على واجهات UseCase (ليس على كلاسات طبقة البيانات المباشرة)
  • UseCases تعتمد على واجهات Repository (من طبقة المجال)
الخلاصة الرئيسية: ViewModel هو المكان الوحيد الذي تعيش فيه حالة واجهة المستخدم وتُطلق منه عمليات الأعمال. العروض غير ذكية — تراقب وتُصيّر. UseCases تُستدعى — تُنفّذ منطق المجال. هذا الفصل يجعل كل طبقة قابلة للاختبار والاستبدال بشكل مستقل دون المساس بالطبقات الأخرى.