هندسة تطبيقات Flutter التي تتجاوز حدود الدروس التعليمية
أنماط هندسة تطبيقات Flutter للإنتاج — من إدارة الحالة إلى التنقل، وحقن التبعية، وهيكل المشروع الموجه بالميزات.

تعمل تطبيقات Flutter التعليمية بشكل رائع حتى تصل إلى حوالي عشرين شاشة. ثم يصبح ملف main.dart الواحد الذي يحتوي على جميع المسارات غير قابل للإدارة، وتخلق استدعاءات setState ظروف سباق، وينتج نهج "فقط مررها عبر المُنشئ" أشجار واجهات مستخدم بعشرة معلمات، وتلمس كل ميزة جديدة خمسة ملفات موجودة. لقد شاهدت هذا يحدث في مشاريع متعددة، بما في ذلك مشاريعي الخاصة، والنمط دائمًا هو نفسه — يصل الفريق إلى طريق مسدود حوالي الشهر الثالث ويجب عليه إما إعادة الهيكلة أو قبول فقدان متزايد في سرعة التطوير.
الهندسة المعمارية التي أصفها هنا هي ما أستخدمه لتطبيقات الإنتاج. إنها ليست أبسط نهج ممكن، وهذا هو الهدف. النهج الأبسط هو ما تعلمه الدروس التعليمية، ويتوقف عن العمل في اللحظة التي يصبح فيها لتطبيقك متطلبات حقيقية.
لماذا تفشل الهندسة المعمارية التعليمية
تعلم معظم دروس ودورات Flutter هيكل مشروع مسطح:
lib/
├── main.dart
├── home_screen.dart
├── detail_screen.dart
├── user_model.dart
├── api_service.dart
└── widgets/
├── custom_button.dart
└── loading_indicator.dart
هذا يعمل لتطبيق قائمة مهام. ويفشل لتطبيق حقيقي لأسباب متوقعة:
تنمو الملفات بدون حدود طبيعية. عندما يحتوي home_screen.dart على تخطيط الشاشة، وإدارة الحالة، واستدعاءات API، ومنطق الأعمال، فإنه يصل إلى 500+ سطر بسرعة. لا يوجد توجيه هيكلي حول ما يجب استخراجه وأين يجب أن يذهب.
تصبح التبعيات ضمنية. عندما يقوم home_screen.dart بإنشاء ApiService() مباشرة، لا توجد طريقة لاستبداله بنموذج وهمي في الاختبارات، ولا توجد طريقة لتكوينه بشكل مختلف لكل بيئة، ولا توجد طريقة لمشاركة مثيل واحد عبر الشاشات التي تحتاج إلى نفس البيانات.
يصبح التنقل شبكة من المسارات المكتوبة بشكل ثابت. يتم ربط الروابط العميقة، وحراس المصادقة، والتدفقات الشرطية بقائمة مسارات مسطحة. بحلول الوقت الذي يكون لديك فيه ثلاثون مسارًا مع حراس وإعادة توجيهات، يصبح رمز التنقل هو الجزء الأكثر هشاشة في التطبيق.
يصبح الاختبار غير عملي. بدون حقن التبعية، تتطلب اختبارات الواجهة الأمامية (widget tests) المحاكاة على مستوى HTTP بدلاً من مستوى الخدمة. بدون فصل واضح لمنطق الأعمال عن واجهة المستخدم، ينتهي بك الأمر باختبار تفاصيل التنفيذ بدلاً من السلوك.
هيكل المشروع الموجه بالميزات
القرار المعماري الأكثر تأثيرًا هو هيكل المجلدات. أستخدم نهجًا موجهًا بالميزات حيث تمتلك كل ميزة شاشاتها، واجهاتها، حالتها، ونماذجها:
lib/
├── app/
│ ├── app.dart # MaterialApp configuration
│ ├── router.dart # GoRouter configuration
│ └── theme.dart # Theme data
├── core/
│ ├── constants/
│ │ ├── api_constants.dart
│ │ └── app_constants.dart
│ ├── errors/
│ │ ├── app_exception.dart
│ │ └── error_handler.dart
│ ├── network/
│ │ ├── api_client.dart
│ │ ├── interceptors/
│ │ └── network_info.dart
│ └── utils/
│ ├── date_utils.dart
│ └── string_utils.dart
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── auth_repository.dart
│ │ │ └── auth_remote_source.dart
│ │ ├── domain/
│ │ │ ├── user_model.dart
│ │ │ └── auth_state.dart
│ │ ├── presentation/
│ │ │ ├── screens/
│ │ │ │ ├── login_screen.dart
│ │ │ │ └── register_screen.dart
│ │ │ ├── widgets/
│ │ │ │ ├── login_form.dart
│ │ │ │ └── social_login_buttons.dart
│ │ │ └── providers/
│ │ │ └── auth_provider.dart
│ │ └── auth.dart # Barrel file for public API
│ ├── home/
│ │ ├── data/
│ │ ├── domain/
│ │ ├── presentation/
│ │ └── home.dart
│ └── settings/
│ ├── data/
│ ├── domain/
│ ├── presentation/
│ └── settings.dart
└── shared/
├── widgets/
│ ├── app_button.dart
│ ├── app_text_field.dart
│ ├── loading_overlay.dart
│ └── error_view.dart
├── models/
│ └── paginated_response.dart
└── providers/
└── connectivity_provider.dart
القواعد
لا تستورد الميزات أبدًا من ميزات أخرى. إذا احتاجت الميزة A بيانات من الميزة B، فإن هذه البيانات تعيش في نموذج مشترك أو خدمة مشتركة، وليس في مجلد الميزة B. هذا يمنع التبعيات الدائرية ويحافظ على الميزات قابلة للنشر بشكل مستقل (مفيد إذا انتقلت يومًا إلى مستودع أحادي معياري).
يحتوي دليل core/ على البنية التحتية. الشبكات، معالجة الأخطاء، الثوابت، الأدوات المساعدة — أشياء قد تحتاجها كل ميزة ولكنها لا تنتمي إلى أي ميزة واحدة.
يحتوي دليل shared/ على واجهة مستخدم قابلة لإعادة الاستخدام واهتمامات شاملة. واجهات مستخدم مشتركة، ونماذج مشتركة، ومزودات تعتمد عليها ميزات متعددة.
تكشف ملفات Barrel عن واجهة برمجة التطبيقات العامة لكل ميزة. تستورد الأجزاء الأخرى من التطبيق من features/auth/auth.dart، وليس أبدًا من الملفات الداخلية. يتيح لك هذا إعادة هيكلة البنية الداخلية للميزة دون كسر المستهلكين الخارجيين.
// features/auth/auth.dart
export 'domain/user_model.dart';
export 'domain/auth_state.dart';
export 'presentation/providers/auth_provider.dart';
// Don't export internal implementation details
إدارة الحالة: Riverpod
لقد استخدمت Provider و Bloc و Riverpod في مشاريع الإنتاج. لكل منها مقايضات، ويعتمد الاختيار أكثر على إلمام الفريق به من التفوق التقني. ومع ذلك، أتوجه إلى Riverpod في المشاريع الجديدة لأسباب محددة.
لماذا Riverpod أفضل من Bloc
Bloc ممتاز للفرق القادمة من خلفية مؤسسية. نمط الحدث/الحالة مألوف للمطورين الذين عملوا مع Redux أو MVI. إنه يفرض تدفق بيانات صارم أحادي الاتجاه ويولد آلات حالة يمكن التنبؤ بها وقابلة للاختبار. الجانب السلبي هو الإسهاب — تتطلب الميزة البسيطة فئة حدث، وفئة حالة، وفئة bloc، غالبًا عبر ثلاثة ملفات. بالنسبة للتطبيقات ذات منطق الأعمال المعقد والعديد من المطورين، فإن هذا الاحتفال يؤتي ثماره. بالنسبة للفرق الأصغر التي تتحرك بسرعة، قد يبدو الأمر وكأنه عبء إضافي.
يعمل Provider (الحزمة الأصلية) جيدًا لحقن التبعية البسيط ولكنه يواجه صعوبة مع الحالة المعقدة. يعتمد على شجرة الواجهة الأمامية (widget tree) لتحديد النطاق، مما يعني أن دورة حياة حالتك مرتبطة بهيكل واجهة المستخدم الخاص بك. يمكن أن تؤدي إعادة هيكلة شجرة الواجهة الأمامية إلى كسر إدارة الحالة بطرق خفية.
يحل Riverpod قيود Provider الأساسية. المزودات عالمية (لا تعتمد على شجرة الواجهة الأمامية)، آمنة في وقت التجميع، وتدعم أنماطًا متقدمة مثل التخلص التلقائي (auto-disposal)، ومزودات العائلة (family providers)، والحالة غير المتزامنة (async state). كما أنها تولد رمزًا أقل تكرارًا من Bloc مع الحفاظ على قابلية الاختبار.
Riverpod في الممارسة
إليك كيفية هيكلة إدارة الحالة لميزة نموذجية:
// features/auth/domain/auth_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'user_model.dart';
part 'auth_state.freezed.dart';
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated(User user) = _Authenticated;
const factory AuthState.unauthenticated() = _Unauthenticated;
const factory AuthState.error(String message) = _Error;
}
// features/auth/presentation/providers/auth_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/auth_repository.dart';
import '../../domain/auth_state.dart';
part 'auth_provider.g.dart';
@riverpod
class AuthNotifier extends _$AuthNotifier {
@override
AuthState build() {
_checkAuthStatus();
return const AuthState.initial();
}
Future<void> _checkAuthStatus() async {
final repository = ref.read(authRepositoryProvider);
final user = await repository.getCurrentUser();
state = user != null
? AuthState.authenticated(user)
: const AuthState.unauthenticated();
}
Future<void> signIn(String email, String password) async {
state = const AuthState.loading();
final repository = ref.read(authRepositoryProvider);
try {
final user = await repository.signIn(email, password);
state = AuthState.authenticated(user);
} on AuthException catch (e) {
state = AuthState.error(e.message);
}
}
Future<void> signOut() async {
final repository = ref.read(authRepositoryProvider);
await repository.signOut();
state = const AuthState.unauthenticated();
}
}
يؤدي استخدام التعليق التوضيحي @riverpod مع توليد الكود إلى التخلص من تكرار إعلان المزود اليدوي. قم بتشغيل dart run build_runner build لتوليد كود المزود.
متى يكون Bloc أفضل
على الرغم من تفضيلي لـ Riverpod، هناك سيناريوهات يكون فيها Bloc هو الخيار الأقوى:
- الفرق الكبيرة حيث تمنع الأنماط المفروضة عدم الاتساق.
- آلات الحالة المعقدة مع العديد من الانتقالات — يجعل نظام الأحداث في Bloc هذه صريحة وقابلة للاختبار.
- عندما تحتاج إلى وظيفة إعادة التشغيل/التراجع — يجعل سجل أحداث Bloc هذا أمرًا مباشرًا.
- عندما يكون الفريق يعرف Bloc بالفعل. الاتساق عبر قاعدة الكود أهم من أي فرق تقني بين الاثنين.
التنقل باستخدام GoRouter
أصبح GoRouter المعيار للتنقل في Flutter، ولسبب وجيه. إنه يدعم التوجيه التصريحي، والروابط العميقة، وإعادة التوجيهات، والتنقل المتداخل خارج الصندوق.
// app/router.dart
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'router.g.dart';
@riverpod
GoRouter router(RouterRef ref) {
final authState = ref.watch(authNotifierProvider);
return GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isAuthenticated = authState is Authenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isAuthenticated && !isAuthRoute) return '/auth/login';
if (isAuthenticated && isAuthRoute) return '/';
return null;
},
routes: [
GoRoute(
path: '/auth/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/auth/register',
builder: (context, state) => const RegisterScreen(),
),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/item/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ItemDetailScreen(itemId: id);
},
),
],
),
],
);
}
حراس التنقل
يتعامل رد الاتصال redirect مع حراس المصادقة عالميًا. ولكن قد تحتاج إلى منطق أكثر تعقيدًا — الوصول المستند إلى الدور، تدفقات الإعداد، علامات الميزات. أتعامل مع هذه الأمور بسلسلة إعادة توجيه:
redirect: (context, state) {
// Authentication check
final isAuthenticated = authState is Authenticated;
if (!isAuthenticated && !state.matchedLocation.startsWith('/auth')) {
return '/auth/login';
}
// Onboarding check
if (isAuthenticated && !hasCompletedOnboarding && state.matchedLocation != '/onboarding') {
return '/onboarding';
}
// Already authenticated, redirect away from auth screens
if (isAuthenticated && state.matchedLocation.startsWith('/auth')) {
return '/';
}
return null; // No redirect
},
نمط المستودع (Repository Pattern)
تقع المستودعات بين منطق عملك ومصادر البيانات. إنها تجرد ما إذا كانت البيانات تأتي من واجهة برمجة تطبيقات REST، أو قاعدة بيانات محلية، أو ذاكرة تخزين مؤقت، أو مزيج من الثلاثة.
// features/auth/data/auth_repository.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/user_model.dart';
part 'auth_repository.g.dart';
@riverpod
AuthRepository authRepository(AuthRepositoryRef ref) {
return AuthRepository(
remoteSource: ref.read(authRemoteSourceProvider),
localSource: ref.read(authLocalSourceProvider),
);
}
class AuthRepository {
final AuthRemoteSource _remoteSource;
final AuthLocalSource _localSource;
AuthRepository({
required AuthRemoteSource remoteSource,
required AuthLocalSource localSource,
}) : _remoteSource = remoteSource,
_localSource = localSource;
Future<User?> getCurrentUser() async {
// Try local cache first
final cachedUser = await _localSource.getCachedUser();
if (cachedUser != null) return cachedUser;
// Fall back to remote
try {
final user = await _remoteSource.fetchCurrentUser();
if (user != null) await _localSource.cacheUser(user);
return user;
} catch (_) {
return null;
}
}
Future<User> signIn(String email, String password) async {
final user = await _remoteSource.signIn(email, password);
await _localSource.cacheUser(user);
await _localSource.saveToken(user.token);
return user;
}
Future<void> signOut() async {
await _remoteSource.signOut();
await _localSource.clearCache();
}
}
لماذا لا يتم استدعاء API مباشرة
تخلق استدعاءات API المباشرة من كود إدارة الحالة عدة مشاكل:
- منطق التخزين المؤقت يتسرب إلى منطق الأعمال. هل تتحقق من ذاكرة التخزين المؤقت قبل استدعاء API؟ ما هي مدة صلاحية ذاكرة التخزين المؤقت؟ هذا اهتمام طبقة البيانات، وليس اهتمام إدارة الحالة.
- يصبح دعم عدم الاتصال بالإنترنت إعادة كتابة. إذا كان Bloc الخاص بك يستدعي
http.getمباشرة، فإن إضافة دعم عدم الاتصال بالإنترنت يعني تعديل كل Bloc. باستخدام المستودع، يمكنك إضافة منطق التخزين المؤقت والاحتياطي في مكان واحد. - يتطلب الاختبار محاكاة HTTP. محاكاة المستودع هي محاكاة واحدة لكل اختبار. تتطلب محاكاة HTTP إعداد نصوص استجابة، ورموز حالة، ورؤوس لكل حالة اختبار.
طبقة الخدمة
للعمليات التي تمتد عبر مستودعات متعددة أو تتضمن منطق عمل معقد، أستخدم طبقة خدمة:
class OrderService {
final OrderRepository _orderRepo;
final PaymentRepository _paymentRepo;
final NotificationRepository _notificationRepo;
OrderService({
required OrderRepository orderRepo,
required PaymentRepository paymentRepo,
required NotificationRepository notificationRepo,
}) : _orderRepo = orderRepo,
_paymentRepo = paymentRepo,
_notificationRepo = notificationRepo;
Future<Order> placeOrder(Cart cart, PaymentMethod method) async {
// Validate stock
await _orderRepo.validateStock(cart.items);
// Process payment
final payment = await _paymentRepo.charge(method, cart.total);
// Create order
final order = await _orderRepo.create(
items: cart.items,
paymentId: payment.id,
);
// Send confirmation (fire and forget)
_notificationRepo.sendOrderConfirmation(order).ignore();
return order;
}
}
تحتوي الخدمات على منطق الأعمال الذي لا ينتمي إلى واجهة المستخدم أو إلى أي مستودع واحد. تستدعي طبقة إدارة الحالة (Riverpod notifier, Bloc) الخدمة، وتنسق الخدمة بين المستودعات.
معالجة الأخطاء
تعد معالجة الأخطاء المتسقة أحد الجوانب الأكثر إهمالًا في هندسة Flutter. أستخدم مزيجًا من الاستثناءات المكتوبة ونوع النتيجة.
// core/errors/app_exception.dart
sealed class AppException implements Exception {
final String message;
final String? code;
const AppException(this.message, {this.code});
}
class NetworkException extends AppException {
const NetworkException([String message = 'Network error']) : super(message);
}
class ServerException extends AppException {
final int statusCode;
const ServerException(this.statusCode, String message) : super(message);
}
class AuthException extends AppException {
const AuthException([String message = 'Authentication failed']) : super(message);
}
class ValidationException extends AppException {
final Map<String, String> fieldErrors;
const ValidationException(this.fieldErrors) : super('Validation failed');
}
يلتقط عميل API أخطاء HTTP ويحولها إلى استثناءات مكتوبة:
// core/network/api_client.dart
class ApiClient {
final Dio _dio;
ApiClient(this._dio);
Future<T> get<T>(String path, {T Function(dynamic)? fromJson}) async {
try {
final response = await _dio.get(path);
return fromJson != null ? fromJson(response.data) : response.data as T;
} on DioException catch (e) {
throw _mapError(e);
}
}
AppException _mapError(DioException error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return const NetworkException('Connection timed out');
}
final statusCode = error.response?.statusCode;
if (statusCode == null) return const NetworkException();
return switch (statusCode) {
401 => const AuthException('Session expired'),
403 => const AuthException('Access denied'),
422 => ValidationException(
_parseValidationErrors(error.response?.data),
),
_ => ServerException(statusCode, 'Server error'),
};
}
}
هندسة الاختبار
الهندسة المعمارية الجيدة تجعل الاختبار مباشرًا. كل طبقة لها استراتيجية اختبار خاصة بها:
اختبارات المستودع (Repository Tests)
void main() {
late AuthRepository repository;
late MockAuthRemoteSource mockRemote;
late MockAuthLocalSource mockLocal;
setUp(() {
mockRemote = MockAuthRemoteSource();
mockLocal = MockAuthLocalSource();
repository = AuthRepository(
remoteSource: mockRemote,
localSource: mockLocal,
);
});
group('getCurrentUser', () {
test('returns cached user when available', () async {
final user = User(id: '1', name: 'Test');
when(() => mockLocal.getCachedUser()).thenAnswer((_) async => user);
final result = await repository.getCurrentUser();
expect(result, equals(user));
verifyNever(() => mockRemote.fetchCurrentUser());
});
test('fetches from remote when cache is empty', () async {
final user = User(id: '1', name: 'Test');
when(() => mockLocal.getCachedUser()).thenAnswer((_) async => null);
when(() => mockRemote.fetchCurrentUser()).thenAnswer((_) async => user);
when(() => mockLocal.cacheUser(any())).thenAnswer((_) async {});
final result = await repository.getCurrentUser();
expect(result, equals(user));
verify(() => mockLocal.cacheUser(user)).called(1);
});
});
}
اختبارات المزود (Provider Tests) مع Riverpod
void main() {
test('signIn updates state to authenticated', () async {
final user = User(id: '1', name: 'Test');
final mockRepository = MockAuthRepository();
when(() => mockRepository.signIn(any(), any()))
.thenAnswer((_) async => user);
final container = ProviderContainer(
overrides: [
authRepositoryProvider.overrideWithValue(mockRepository),
],
);
final notifier = container.read(authNotifierProvider.notifier);
await notifier.signIn('test@test.com', 'password');
final state = container.read(authNotifierProvider);
expect(state, AuthState.authenticated(user));
});
}
تجعل ProviderContainer و آلية التجاوز (override mechanism) في Riverpod الاختبار نظيفًا — يمكنك تجاوز التبعيات على مستوى الحاوية بدلاً من مستوى شجرة الواجهة الأمامية (widget tree).
المقايضات العملية
هذه الهندسة المعمارية ليست مجانية. هناك تكاليف حقيقية:
المزيد من الملفات. قد تكون الميزة التي ستكون ملفًا واحدًا في تطبيق تعليمي خمسة إلى ثمانية ملفات في هذا الهيكل. بالنسبة للتطبيقات الصغيرة (أقل من عشر شاشات)، قد يبدو هذا مبالغًا فيه.
منحنى التعلم. يحتاج المطور الجديد للمشروع إلى فهم حدود الطبقات ومكان وضع الأشياء. تساعد الوثائق، ولكن لا يزال هناك وقت للتعلم.
توليد الكود. يتطلب نظام التعليقات التوضيحية في Riverpod، و Freezed للنماذج غير القابلة للتغيير، وتسلسل JSON جميعها build_runner. يضيف توليد البناء خطوة إلى التطوير ويمكن أن يكون بطيئًا في قواعد الكود الكبيرة.
إغراء الإفراط في التجريد. عندما يكون لديك هندسة معمارية نظيفة، من السهل إضافة تجريدات "فقط في حالة". واجهة مستودع لمصدر بيانات سيكون له دائمًا تطبيق واحد فقط يضيف تعقيدًا بدون قيمة. أقوم بإنشاء تجريدات عندما يكون لدي تطبيق ثانٍ ملموس، وليس قبل ذلك.
تستحق الهندسة المعمارية العناء عندما يحتوي تطبيقك على أكثر من حوالي عشر شاشات، أو أكثر من مطور واحد، أو عمر افتراضي يتجاوز بضعة أشهر. لمشروع هاكاثون أو إثبات مفهوم، استخدم أبسط شيء يعمل. ولكن عندما تعلم أن التطبيق يحتاج إلى النمو، فإن الاستثمار في الهيكل مبكرًا يوفر أضعاف التكلفة لاحقًا.