使用 go_router 为生产级 Flutter 应用开发类型安全的路由模块

在 Flutter 中使用 go_router 和类型安全的路由处理高级导航场景?

介绍?

如果您正在寻找在应用程序中导航屏幕和处理深度链接的方法,您可能听说过 go_router。它是一个强大的路由包,支持 Web、多导航器、重定向和其他高级场景。但如何有效且优雅地使用它呢??

在本文中,我将与您分享我的团队和我是如何借助增强的枚举和扩展方法开发类型安全路由模块的。这种方法使我们的代码更具可维护性和可测试性,而无需依赖代码生成。?

阅读本文后,您将学会如何

  • 使用 go_router 配置您的应用程序。?️
  • 使用 go_router 定义路由并执行基本导航。?
  • 使用枚举和扩展方法为 go_router 方法创建类型安全的包装器。?

准备好深入了解了吗?让我们开始吧!?

先决条件??‍?

要阅读本文,您需要

  • Flutter SDK 版本 2.8 或更高版本。
  • 您选择的 IDE(我使用 VS Code)。
  • go_router 包(版本 6.4.1 或更高版本)。
  • 对 Flutter 小部件和导航的基本理解。

我们要构建什么?️

我们的应用程序将允许您浏览和保存简单的食谱。

该应用程序有两个页面

  1. 食谱页面:此页面显示食谱列表。您可以点击任何食谱查看其详细信息。
  2. 食谱详情页面:此页面显示所选食谱的配料和说明。您还可以按下添加到收藏夹按钮来保存食谱并返回列表。一个 snack bar 将确认食谱已成功添加。

设置?️

要在应用程序中使用 go_router,您需要在 MaterialApp 或 CupertinoApp 上切换到 router 构造函数,并为其提供 Router 配置。像 go_router 这样的路由包通常会为您提供一个配置。

首先,我们需要将 go_router 添加到 pubspec.yaml 的依赖项中

go_router: ^6.5.0

或者,您可以在终端中使用以下命令获取最新版本

flutter pub add go_router 

让我们开始在 router/app_router.dart 文件中定义我们的路由配置和路由,如下所示

/// Contains all of the app routes configurations
class AppRouter {
  static final router = GoRouter(
    debugLogDiagnostics: kDebugMode,
    initialLocation: '/recipesList',
    routes: [
      GoRoute(
        name: 'recipesList',
        path: '/recipesList',
        pageBuilder: (context, state) => MaterialPage(
          key: state.pageKey,
          child: const RecipesListPage(),
        ),
      ),
      GoRoute(
        name: 'recipeDetails',
        path: '/recipeDetails',
        pageBuilder: (context, state) {
          final extraMap = state.extra as Map<String, dynamic>;

          final recipe = extraMap['recipe'] as Recipe;
          final favoriteCallback = extraMap['onAddedToFavorite'];

          return MaterialPage(
            key: state.pageKey,
            child: RecipeDetailsPage(
              recipe: recipe,
              onAddedToFavorite: favoriteCallback,
            ),
          );
        },
      ),
    ],
  );
}

我们已经定义了两个路由 recipesListrecipeDetails,并且还将初始路由定义为 recipesList,即食谱列表页面。

现在我们需要在应用程序中使用路由器,因此我们需要将 MaterialApp 小部件替换为 MaterialApp.router 小部件,如下所示

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Type-safe routes Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      routeInformationParser: AppRouter.router.routeInformationParser,
      routerDelegate: AppRouter.router.routerDelegate,
      routeInformationProvider: AppRouter.router.routeInformationProvider,
    );
  }

基本导航?️

既然我们已经配置了路由器,我们就可以开始使用它在页面之间进行导航。我们可以使用 GoRouter.of(context) 或方便的 context.pushNamed 方法来获取路由器实例并使用它在页面之间进行导航。

  void onRecipePressed(BuildContext context, Recipe recipe) {
    context.pushNamed(
      'recipeDetails',
      extra: {
        'recipe': recipe,
        'onAddedToFavorite': (Recipe recipe) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text(
                'Added ${recipe.name} to favorite',
              ),
            ),
          );
        },
      },
    );
  }

我们使用了 context.pushNamed 方法导航到 recipeDetails 路由,并且还传递了食谱对象和一个回调函数,以便在食谱添加到收藏夹时使用,将选定的食谱传递回 recipeList 并显示 snackbar。

我们可以看到上面的代码有三个问题

  1. 使用字符串作为路由名称容易出错,因为我们可能会拼错它,而编译器不会发现。

    例如,如果我们写 /recipe-detail 而不是 /recipe-details,应用程序将不会导航到正确的页面,并且我们直到运行应用程序才知道原因。

  2. 将食谱对象和回调函数作为 extra 数据中的 map 传递存在风险,因为我们可能会提供错误的数据而编译器不会警告我们。

    例如,如果我们传递 {'recipe': recipe} 而不是 {'recipe': recipe, 'onAddToFavorite': onAddToFavorite},应用程序将无法调用回调函数,并且我们直到运行应用程序才知道原因。

  3. 在一个拥有许多路由的生产应用程序中,如果不查看 AppRouter 类本身,就很难找到和修改特定路由或更改操作以导航到另一个路由。

    例如,如果我们想为编辑食谱添加一个新路由,我们必须打开 app router 文件并查找所有现有路由,以确保我们不使用重复的名称或路径。

类型安全的路由?

既然我们已经有了基本导航,我们就可以通过使用枚举和扩展方法来改进它,使其更安全、更方便使用。这将帮助我们避免前面提到的问题,并使在应用程序中移动更容易。

定义路由

首先,我们需要像这样将路由定义为枚举

/// Represents the app routes and their paths.
enum AppRoutes {
  recipesList(
    name: 'recipesList',
    path: '/recipesList',
  ),
  recipeDetails(
    name: 'recipeDetails',
    path: '/recipeDetails',
  );

  const AppRoutes({
    required this.name,
    required this.path,
  });

  /// Represents the route name
  ///
  /// Example: `AppRoutes.recipesList.name`
  /// Returns: 'recipesList'
  final String name;

  /// Represents the route path
  ///
  /// Example: `AppRoutes.recipesList.path`
  /// Returns: '/recipesList'
  final String path;

  @override
  String toString() => name;
}

借助增强的枚举,我们可以将路由名称和路径与枚举值关联起来。

现在让我们像这样在 AppRouter 类中用枚举值替换字符串路由名称

class AppRouter {
  static final router = GoRouter(
    debugLogDiagnostics: kDebugMode,
    initialLocation: AppRoutes.recipesList.path,
    routes: [
      GoRoute(
        name: AppRoutes.recipesList.name,
        path: AppRoutes.recipesList.path,
      ...
      ),
      GoRoute(
        name: AppRoutes.recipeDetails.name,
        path: AppRoutes.recipeDetails.path,
       ...
      ),
    ],
  );
}

现在,我们可以像这样在 RecipesListPage 的导航代码中使用枚举值而不是字符串路由名称

  void onRecipePressed(BuildContext context, Recipe recipe) {
    context.pushNamed(
      AppRoutes.recipeDetails.name,
      extra: ...
    );
  }

我们已经解决了第一个问题,因此编译器将检测到路由名称或路径中的任何拼写错误。但是,我们仍然面临第二个问题,即将食谱对象和回调函数作为 extra 数据中的 map 传递。这意味着我们可能会提供错误的数据而编译器不会警告我们。

为了解决这个问题,让我们将食谱对象和回调函数包装在一个 RecipeDetailsArgs 类中,如下所示

class RecipeDetailsArgs {
  RecipeDetailsArgs({
    required this.recipe,
    required this.onAddedToFavorite,
  });

  final Recipe recipe;
  final Function(Recipe) onAddedToFavorite;
}

现在,让我们像这样更新 recipeDetails 路由以接受 RecipeDetailsArgs 类作为 extra 数据

class AppRouter {
...
      GoRoute(
        name: AppRoutes.recipeDetails.name,
        path: AppRoutes.recipeDetails.path,
        pageBuilder: (context, state) {
          return RecipeDetailsPage(
            args: state.extra as RecipeDetailsArgs,
          );
        },
      ),
...
}

现在,让我们像这样更新 RecipeDetailsPage 以将 RecipeDetailsArgs 类用作参数

class RecipeDetailsPage extends StatelessWidget {
  const RecipeDetailsPage({
    super.key,
    required this.args,
  });

  /// The args used to display the recipe.
  final RecipeDetailsArgs args;

...
}

现在,让我们创建一个新的抽象类,称其为 AppNavigator,并在其中定义 pushRecipeDetails 方法,如下所示

abstract class AppNavigator {
  /// Pushes the [RecipesListPage] to the navigation stack
  void pushRecipeDetails(RecipeDetailsArgs args);
}

从现在起,此类将保存我们所有的导航方法。我们将 AppNavigator 类设为抽象类,以便于在测试中对其进行模拟或更改实现。这样,页面将不直接依赖于 go_router 包。

现在,让我们为 AppNavigator 抽象类提供一个实现,如下所示

class AppNavigatorImpl implements AppNavigator {
  AppNavigatorImpl(this.context);

  final BuildContext context;

  @override
  void pushRecipeDetails(RecipeDetailsArgs args) {
    context.pushNamed(
      AppRoutes.recipeDetails.name,
      extra: args,
    );
  }
}

但是等等,我们如何从我们的页面访问 AppNavigator 实例呢?幸运的是,我们可以利用 BuildContext 的扩展方法来使任何小部件能够访问应用程序导航器。

extension NavigationHelpersExt on BuildContext {
  AppNavigator get navigator => AppNavigatorImpl(this);
}

现在,我们可以通过像这样更新 RecipesListPage 中的导航代码,从任何小部件访问 AppNavigator 实例

  void onRecipePressed(BuildContext context, Recipe recipe) {
    context.navigator.pushRecipeDetails(
      RecipeDetailsArgs(
        recipe: recipe,
        onAddedToFavorite: (Recipe recipe) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text(
                'Added ${recipe.name} to favorite',
              ),
            ),
          );
        },
      ),
    );
  }

我们还解决了第二个和第三个问题。编译器将检测参数中的任何拼写错误。此外,对于拥有许多页面的生产项目,我们可以轻松地在应用程序中看到所有已定义的页面,并通过键入 context.navigator 来导航到它们,而无需检查 AppRouter?。

使用此方法的另一个好处是,我们可以将整个路由逻辑提取到一个单独的模块中,并在不同模块之间共享。

结论?

总而言之,GoRouter 是一个很棒的包,可以使 Flutter 应用程序中的导航更加容易。?但当我们的页面很多时,它也会引起混乱。?为防止这种情况,我们可以利用增强的枚举和扩展方法来使我们的导航更可靠、更用户友好。?这样,我们就可以获得两全其美:GoRouter 的功能以及代码质量和可读性。?

您可以在 Github 上找到项目的源代码。如果您有任何问题、建议或反馈,请随时随意尝试并随时在 Twitter 上联系我。

我非常感谢 @A-Fawzyy 的精彩帮助和建议,这些建议使本文更加出色。❤️

感谢阅读!❤️

GitHub

查看 Github