Kodokon kodokon.com

هندسة تطبيق: الواجهة، المنطق، البيانات

نظِّم تطبيق Flutter في طبقات محكمة الإغلاق باستخدام نمط المستودع (repository) والنتائج المُغلَقة (sealed) ذات الأنواع المحددة.

11 دقيقة · 3 أسئلة

افتح هذا الدرس في Kodokon

إن تطبيق Flutter المُصمَّم ليدوم يفصل بين ثلاث مسؤوليات: العرض (الودجات، BuildContext)، والمنطق (الحالة، حالات الاستخدام) والبيانات (واجهة برمجة التطبيقات API، قاعدة البيانات المحلية). والقاعدة الأساسية هي اتجاه التبعيات: العرض يعتمد على المنطق، والمنطق يعتمد على تجريدات البيانات - ولا يحدث العكس أبدًا. وبشكل ملموس، لا ينبغي أن يظهر أيّ BuildContext ولا أيّ استيراد لـ material.dart تحت طبقة العرض: فالمتحكّم الذي يعرض SnackBar بنفسه هو متحكّم غير قابل للاختبار. ونقطة أخرى كثيرًا ما تُغفَل: الأخطاء جزء من العقد بين الطبقات. فبدلًا من ترك الاستثناءات غير المُصنَّفة نوعيًا تتسرّب، اعرض النتيجة عبر صنف sealed - سيُجبرك المُصرِّف على معالجة كل حالة.

DART
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;
}
النوع المُغلَق (sealed) يجعل الفشل صريحًا في التوقيع.

إن المستودع (repository) هو الحدّ الفاصل بين النطاق (domain) والعالم الخارجي. ودوره الدقيق: كشف كيانات النطاق (وليس أبدًا كائنات نقل البيانات DTO الخام الآتية من واجهة برمجة التطبيقات)، وإخفاء استراتيجية الوصول (الشبكة، الذاكرة المؤقتة، قاعدة البيانات المحلية) وتركيز سياسة حداثة البيانات في مكان واحد. في Dart 3، صرِّح بالعقد باستخدام abstract interface class: فهذا المُعدِّل لا يسمح إلا بـ implements من خارج المكتبة - فلا يمكنك أن ترث جزءًا من التنفيذ عن طريق الخطأ، وبذلك يبقى العقد نقيًا. وتقع مطابقة JSON إلى الكيان داخل التنفيذ: فإذا أعادت واجهة برمجة التطبيقات تسمية حقل غدًا، فلن تتغير سوى طبقة البيانات.

DART
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);
    }
  }
}
المستودع يطابق البيانات، ويخزّنها مؤقتًا، ويحوّل الأخطاء إلى Result.

على جانب العرض، غالبًا ما يكفي ChangeNotifier مُحقَن عبر الباني (constructor) - دون الحاجة إلى اللجوء إلى إطار عمل. تجنّب المفردات (singletons) العامة: فهي تُجمِّد مخطط التبعيات وتجعل الاستبدال في الاختبارات مؤلمًا. لا يعيد ListenableBuilder بناء سوى builder الخاص به، ويُحكِم switch الشامل على Result إغلاق الواجهة: أضِف حالة ثالثة إلى النوع المُغلَق (sealed) وسيفشل التصريف في كل مكان لم تُعالَج فيه. وهذه هي القيمة الكاملة للنمط - إذ يصبح السهو خطأ تصريف، لا خللًا في الإنتاج.

DART
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),
              ),
            ),
        };
      },
    );
  }
}
null = قيد التحميل، و switch يغطّي كل حالة من حالات النوع المُغلَق (sealed).

اختبار المعرفة

تأكّد من أنك تذكّرت النقاط الأساسية في هذا الدرس.

  1. لماذا تعتمد الواجهة على الواجهة البرمجية CourseRepository بدلًا من ApiCourseRepository؟
    • لإدراج تنفيذ زائف (fake) في الاختبارات وتغيير مصدر البيانات دون المساس بالودجات
    • لأن الأصناف المجرّدة أسرع في الإنشاء
    • لأن Dart تمنع إنشاء نسخة من صنف محسوس (concrete) داخل ودجت
  2. ما الميزة التي يوفّرها نوع مُغلَق (sealed) مثل Result مقارنةً بالاستثناء؟
    • يتحقق المُصرِّف من أن switch شامل: فلا يمكنك أن تنسى حالة الخطأ
    • يجعل الكود غير المتزامن أسرع
    • يتجنّب أيّ تخصيص للكائنات على الكومة (heap)
    • يلتقط أيضًا الأخطاء الآتية من العُزلات (isolates)
  3. أين ينبغي أن تقع مطابقة JSON إلى كيان Course؟
    • في طبقة البيانات، كي يبقى النطاق (domain) غير مطّلع على تنسيق واجهة برمجة التطبيقات
    • في الودجت، أقرب ما يكون إلى العرض
    • في main()، قبل runApp