التنقل والتوجيه

Navigator 2.0 وواجهة Router

18 دقيقة الدرس 8 من 14

Navigator 2.0 وواجهة Router

نظام Navigator الأصلي في Flutter (الذي يُسمى أحياناً Navigator 1.0) عبارة عن مكدس أمري بسيط: تدفع المسارات وتسحبها. يعمل هذا النموذج بشكل جيد لمعظم التطبيقات، لكنه ينهار في اللحظة التي تحاول فيها المنصة المضيفة إخبار Flutter بما يجب عرضه—على سبيل المثال، عندما يتنقل مستخدم بعنوان URL مباشر إلى /products/42 على الويب، أو عندما يجب أن يسحب زر الرجوع في نظام التشغيل جزءاً فقط من مكدس متداخل.

يحل Navigator 2.0 (المعروف أيضاً بـ Router API) هذا الإشكال بجعل التنقل تصريحياً. بدلاً من دفع الصفحات بشكل أمري، تحتفظ بـ كائن حالة تطبيق يصف نية التنقل الحالية، ومجموعة من الفئات المتعاونة تستخلص مكدس الصفحات الصحيح من تلك الحالة—تلقائياً، في كل مرة تتغير فيها الحالة.

ملاحظة: Navigator 2.0 هو الأساس الذي تبنى عليه حزم مثل go_router وauto_route وbeamer. تعلم الواجهة البرمجية الخام يمنحك فهماً عميقاً لكيفية عمل تلك الحزم ومتى تلجأ إلى البدائيات مباشرة.

القطع الأربعة المتعاونة

تتكون واجهة Router API من أربع فئات متفاعلة:

  • RouteInformationProvider — يزود RouteInformation (عنوان URI + كتلة حالة) من المنصة. التنفيذ الافتراضي PlatformRouteInformationProvider يستمع إلى شريط العنوان في المتصفح على الويب ونظام الـ intent في Android على الهاتف المحمول.
  • RouteInformationParser — يحول RouteInformation الخام إلى كائن حالة التطبيق المُكتَّب لديك (مثل AppRoutePath). هنا يقع منطق تحليل عناوين URL.
  • RouterDelegate — قلب النظام. يحتفظ بحالة مسار التطبيق الحالية، ويبني ودجت Navigator مع قائمة pages الصحيحة، ويُبلغ المنصة بعنوان URL الحالي عبر currentConfiguration.
  • BackButtonDispatcher — يعترض زر الرجوع للمنصة ويوزعه على المفوض النشط. RootBackButtonDispatcher الافتراضي يعمل لمعظم الحالات.
نصيحة: فكر في التدفق على أنه حلقة: URL المنصة ← المحلل ← حالة التطبيق ← RouterDelegate ← صفحات Navigator ← واجهة مستخدم مُصيَّرة ← إجراء المستخدم ← حالة تطبيق جديدة ← المحلل يُسلسل URL جديداً إلى المنصة.

تعريف حالة مسار تطبيقك

ابدأ بنمذجة كل ما يمكن لتطبيقك عرضه ككلاس Dart بسيط. اجعله غير قابل للتغيير وقابل للمقارنة.

نموذج مسار التطبيق

// يمثل كل "عنوان" يمكن للتطبيق أن يكون فيه
class AppRoutePath {
  final bool isHomePage;
  final int? productId;    // غير فارغ عند الوجود في صفحة تفاصيل المنتج
  final bool isUnknown;

  const AppRoutePath.home()
      : isHomePage = true,
        productId = null,
        isUnknown = false;

  const AppRoutePath.productDetail(this.productId)
      : isHomePage = false,
        isUnknown = false;

  const AppRoutePath.unknown()
      : isHomePage = false,
        productId = null,
        isUnknown = true;

  bool get isProductDetail => productId != null;
}

تطبيق RouteInformationParser

للمحلل مسؤوليتان: تحليل عنوان URL وارد إلى AppRoutePath، واستعادة AppRoutePath إلى RouteInformation ليبقى شريط المتصفح متزامناً.

AppRouteInformationParser

class AppRouteInformationParser
    extends RouteInformationParser<AppRoutePath> {

  @override
  Future<AppRoutePath> parseRouteInformation(
      RouteInformation routeInformation) async {
    final uri = Uri.parse(routeInformation.uri.toString());

    // معالجة "/"  =>  الصفحة الرئيسية
    if (uri.pathSegments.isEmpty) {
      return const AppRoutePath.home();
    }

    // معالجة "/products/:id"  =>  صفحة تفاصيل المنتج
    if (uri.pathSegments.length == 2 &&
        uri.pathSegments[0] == 'products') {
      final id = int.tryParse(uri.pathSegments[1]);
      if (id != null) return AppRoutePath.productDetail(id);
    }

    return const AppRoutePath.unknown();
  }

  @override
  RouteInformation restoreRouteInformation(AppRoutePath path) {
    if (path.isHomePage) {
      return RouteInformation(uri: Uri.parse('/'));
    }
    if (path.isProductDetail) {
      return RouteInformation(
          uri: Uri.parse('/products/${path.productId}'));
    }
    return RouteInformation(uri: Uri.parse('/404'));
  }
}

تطبيق RouterDelegate

يمتد المفوض من RouterDelegate<AppRoutePath> ويمزج مع ChangeNotifier (حتى يستطيع ودجت Router الاستماع لتغييرات الحالة) وPopNavigatorRouterDelegateMixin (حتى يعمل زر الرجوع للنظام بشكل صحيح).

AppRouterDelegate — بناء مكدس الصفحات من الحالة

class AppRouterDelegate extends RouterDelegate<AppRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRoutePath> {

  @override
  final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

  // ---- حالة مسار التطبيق القابلة للتعديل ----
  int? _selectedProductId;
  bool _show404 = false;

  // أدوات ضبط عامة تحدّث الحالة وتُخطر Router
  void selectProduct(int id) {
    _selectedProductId = id;
    _show404 = false;
    notifyListeners();   // يُطلق إعادة بناء Router
  }

  void clearSelection() {
    _selectedProductId = null;
    _show404 = false;
    notifyListeners();
  }

  // ---- currentConfiguration: يخبر المنصة بعنوان URL الذي يُعرض ----
  @override
  AppRoutePath get currentConfiguration {
    if (_show404) return const AppRoutePath.unknown();
    if (_selectedProductId != null) {
      return AppRoutePath.productDetail(_selectedProductId!);
    }
    return const AppRoutePath.home();
  }

  // ---- setNewRoutePath: يُستدعى عندما ترسل المنصة عنوان URL جديداً ----
  @override
  Future<void> setNewRoutePath(AppRoutePath path) async {
    if (path.isUnknown) {
      _selectedProductId = null;
      _show404 = true;
    } else if (path.isProductDetail) {
      _selectedProductId = path.productId;
      _show404 = false;
    } else {
      _selectedProductId = null;
      _show404 = false;
    }
    // لا حاجة لـ notifyListeners() هنا — Router يستدعي build() تلقائياً
  }

  // ---- build: مكدس الصفحات هو دالة نقية للحالة ----
  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        // الصفحة الرئيسية دائماً في المكدس
        MaterialPage(
          key: const ValueKey('HomePage'),
          child: HomePage(
            onProductSelected: selectProduct,
          ),
        ),

        // صفحة التفاصيل مشروطة في الأعلى
        if (_selectedProductId != null)
          MaterialPage(
            key: ValueKey('ProductDetailPage-$_selectedProductId'),
            child: ProductDetailPage(
              productId: _selectedProductId!,
              onBack: clearSelection,
            ),
          ),

        // صفحة 404 تعلو كل شيء
        if (_show404)
          const MaterialPage(
            key: ValueKey('UnknownPage'),
            child: UnknownPage(),
          ),
      ],
      onPopPage: (route, result) {
        if (!route.didPop(result)) return false;
        clearSelection();
        return true;
      },
    );
  }
}

ربط كل شيء معاً في MaterialApp.router

مرر مفوضك ومحللك إلى MaterialApp.router. يصل الإطار RouteInformationProvider تلقائياً على الويب؛ على الهاتف المحمول يستخدم مزوداً بلا عملية فعلية.

main.dart

void main() => runApp(const MyApp());

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final _delegate = AppRouterDelegate();
  final _parser  = AppRouteInformationParser();

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'عرض Navigator 2.0',
      routerDelegate: _delegate,
      routeInformationParser: _parser,
      // اختياري: زود بمزودك الخاص للاختبار
      // routeInformationProvider: ...,
    );
  }
}
تحذير: احرص دائماً على تخزين RouterDelegate وRouteInformationParser في كائن State أو استخدم حل حقن تبعيات—لا تُنشئهما أبداً داخل build(). إعادة إنشائهما في كل إعادة بناء يُتلف حالة التنقل ويجعل التطبيق يعود إلى مسار الصفحة الرئيسية.

أبرز مزايا Navigator 2.0 على Navigator 1.0

  • دعم الروابط العميقة — يمكن للمنصة تسليم أي عنوان URL إلى parseRouteInformation ويُبنى مكدس الصفحات الصحيح تصريحياً.
  • تنقل الويب للأمام والخلف — تبقي restoreRouteInformation شريط سجل المتصفح متزامناً.
  • قابلية الاختبار — مكدس الصفحات هو دالة نقية لـ AppRoutePath؛ يمكنك اختبار منطق التوجيه بوحدات دون الحاجة إلى WidgetTester.
  • تحكم دقيق في زر الرجوع — المفوضون المتداخلون عبر ChildBackButtonDispatcher يتيحون لـ Navigator الداخلية اعتراض زر الرجوع.

ملخص

يستبدل Navigator 2.0 نموذج الدفع/السحب الأمري بنموذج تصريحي: تُحدّث كائن الحالة؛ يعيد RouterDelegate بناء مكدس صفحات Navigator منه؛ ويحول RouteInformationParser عناوين URL إلى حالة والعكس. تعمل القطع الأربعة—RouteInformationProvider وRouteInformationParser وRouterDelegate وBackButtonDispatcher—معاً لمنح Flutter دعماً متكاملاً للروابط العميقة وسجل المتصفح. عادةً تستخدم التطبيقات الإنتاجية حزمة مثل go_router تُغلّف هذه البدائيات، لكن فهم الواجهة البرمجية الخام يجعلك مطور Flutter أكثر كفاءة بكثير.