نظِّم تطبيق Flutter في طبقات محكمة الإغلاق باستخدام نمط المستودع (repository) والنتائج المُغلَقة (sealed) ذات الأنواع المحددة.
افتح هذا الدرس في Kodokonإن تطبيق Flutter المُصمَّم ليدوم يفصل بين ثلاث مسؤوليات: العرض (الودجات، BuildContext)، والمنطق (الحالة، حالات الاستخدام) والبيانات (واجهة برمجة التطبيقات API، قاعدة البيانات المحلية). والقاعدة الأساسية هي اتجاه التبعيات: العرض يعتمد على المنطق، والمنطق يعتمد على تجريدات البيانات - ولا يحدث العكس أبدًا. وبشكل ملموس، لا ينبغي أن يظهر أيّ BuildContext ولا أيّ استيراد لـ material.dart تحت طبقة العرض: فالمتحكّم الذي يعرض SnackBar بنفسه هو متحكّم غير قابل للاختبار. ونقطة أخرى كثيرًا ما تُغفَل: الأخطاء جزء من العقد بين الطبقات. فبدلًا من ترك الاستثناءات غير المُصنَّفة نوعيًا تتسرّب، اعرض النتيجة عبر صنف sealed - سيُجبرك المُصرِّف على معالجة كل حالة.
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
const Success(this.value);
final T value;
}
class Failure<T> extends Result<T> {
const Failure(this.error);
final Object error;
}
class Course {
const Course({required this.id, required this.title});
final String id;
final String title;
}إن المستودع (repository) هو الحدّ الفاصل بين النطاق (domain) والعالم الخارجي. ودوره الدقيق: كشف كيانات النطاق (وليس أبدًا كائنات نقل البيانات DTO الخام الآتية من واجهة برمجة التطبيقات)، وإخفاء استراتيجية الوصول (الشبكة، الذاكرة المؤقتة، قاعدة البيانات المحلية) وتركيز سياسة حداثة البيانات في مكان واحد. في Dart 3، صرِّح بالعقد باستخدام abstract interface class: فهذا المُعدِّل لا يسمح إلا بـ implements من خارج المكتبة - فلا يمكنك أن ترث جزءًا من التنفيذ عن طريق الخطأ، وبذلك يبقى العقد نقيًا. وتقع مطابقة JSON إلى الكيان داخل التنفيذ: فإذا أعادت واجهة برمجة التطبيقات تسمية حقل غدًا، فلن تتغير سوى طبقة البيانات.
abstract interface class CourseApi {
Future<List<Map<String, Object?>>> fetchRaw();
}
abstract interface class CourseRepository {
Future<Result<List<Course>>> getAll();
void invalidate();
}
class ApiCourseRepository implements CourseRepository {
ApiCourseRepository(this._api);
final CourseApi _api;
List<Course>? _cache;
@override
void invalidate() => _cache = null;
@override
Future<Result<List<Course>>> getAll() async {
final cached = _cache;
if (cached != null) {
return Success(cached);
}
try {
final rows = await _api.fetchRaw();
final courses = [
for (final row in rows)
Course(
id: row['id'] as String,
title: row['title'] as String,
),
];
_cache = courses;
return Success(courses);
} on Exception catch (error) {
return Failure(error);
}
}
}على جانب العرض، غالبًا ما يكفي ChangeNotifier مُحقَن عبر الباني (constructor) - دون الحاجة إلى اللجوء إلى إطار عمل. تجنّب المفردات (singletons) العامة: فهي تُجمِّد مخطط التبعيات وتجعل الاستبدال في الاختبارات مؤلمًا. لا يعيد ListenableBuilder بناء سوى builder الخاص به، ويُحكِم switch الشامل على Result إغلاق الواجهة: أضِف حالة ثالثة إلى النوع المُغلَق (sealed) وسيفشل التصريف في كل مكان لم تُعالَج فيه. وهذه هي القيمة الكاملة للنمط - إذ يصبح السهو خطأ تصريف، لا خللًا في الإنتاج.
import 'package:flutter/material.dart';
class CourseListController extends ChangeNotifier {
CourseListController(this._repository);
final CourseRepository _repository;
Result<List<Course>>? state;
Future<void> load() async {
state = null;
notifyListeners();
state = await _repository.getAll();
notifyListeners();
}
}
class CourseListScreen extends StatelessWidget {
const CourseListScreen({
super.key,
required this.controller,
});
final CourseListController controller;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return switch (controller.state) {
null => const Center(
child: CircularProgressIndicator(),
),
Failure() => const Center(
child: Text('Loading error'),
),
Success(value: final courses) =>
ListView.builder(
itemCount: courses.length,
itemBuilder: (context, i) => ListTile(
title: Text(courses[i].title),
),
),
};
},
);
}
}