[译]Flutter Favorite之路由包go_router - 高级路由 - 类型化路由方案

1,009 阅读5分钟

「这是我参与2022首次更文挑战的第4天,活动详情查看:2022首次更文挑战」。

本文翻译自 typed-routing

水平太烂~ 翻到怀疑人生~ 不吝赐教~

[译]Flutter Favorite之路由包go_router - 基础篇 - 掘金 (juejin.cn)


类型化路由方案

下面是类型化路由系统的一个方案。其中一些是在 PR 中实现的,但是还没有完成,现在也没有作为 go_router 包的一部分被支持。写下这些是为了做自己喜欢做的事的一种方式: 聚焦于要写的客户端代码,而不是只聚焦于实现。

目标

go_router 中的路由根本上依赖URI信息中基于字符串的位置来匹配一个或多个页面构建器,每一个需要0个或多个参数作为路径和位置部分的参数传递。go_router 做了一个很好的工作,通过 GoRouterStateparamsqueryParams 属性使路径和查询参数可用,但是通常页面构建器必须首先解析参数为非字符串的类型,例如:

GoRoute(
  path: ':authorId',
  builder: (context, state) {
    // require the authorId to be present and be an integer
    final authorId = int.parse(state.params['authorId']!);
    return AuthorDetailsScreen(authorId: authorId);
  },
),

该示例中,authorId 参数 a) 必须的 b) 必须是 int 。 尽管这样,这些需求直到在运行时都不会被检查,代码写起来很简单,但不是类型安全的。例如:

void _tap() => context.go('/author/a42'); // error: `a42` is not an `int`

因为 Dart 是一种静态类型语言,我们更喜欢在编译时捕获错误而不是等到运行时。类型化路由方案是为了提供一个方式,它可以定义必需的变量和可选的变量用于指定的路由消费,然后使用代码生成器做一些苦差事,如写一堆需要我们自己实现的 go 、 push  和 location 的样板代码。

今天在 [我们的试验性实现]中用于实现该工作的代码生成器是 build_runner 包,但是计划是当它们可用时移动到 Dart macros 。

定义一个路由

定义每个路由作为一个类继承 GoRouteData 然后覆写 build 方法。

class PersonRoute extends GoRouteData {
  PersonRoute({required this.fid, required this.pid});
  final String fid;
  final String pid;

  @override
  Widget build(BuildContext context) => PersonScreen(fid: fid, pid: pid);
}

所需的参数从路由树中定义的路由的 path 中拉取。

路由树

路由树在每个顶级路由定义为一个属性。

@TypedGoRoute<HomeRoute>(
  path: '/',
  routes: [
    TypedGoRoute<FamilyRoute>(
      path: 'family/:fid',
      routes: [
        TypedGoRoute<PersonRoute>(
          path: 'person/:pid',
        ),
      ],
    ),
  ],
)
class HomeRoute extends GoRouteData {...}
class FamilyRoute extends GoRouteData {...}
class PersonRoute extends GoRouteData {...}

@TypedGoRoute<LoginRoute>(path: '/login')
class LoginRoute extends GoRouteData {...}

GoRouter 初始化

代码生成器聚集所有的顶级路由到 $appRoutes 的单个列表中,用于初始化 GoRouter 实例。

final _router = GoRouter(routes: $appRoutes);

Error 构建器

也可以使用类型化路由来提供一个 Error 构建器:

class ErrorRoute extends GoRouteData {
  ErrorRoute({required this.error});
  final Exception error;

  @override
  Widget build(BuildContext context) => ErrorScreen(error: error);
}

有了这个,也以如下提供 errorBuilder 参数:

final _router = GoRouter(
  routes: $appRoutes,
  errorBuilder: (c, s) => ErrorRoute(s.error!).build(c),
);

导航

导航使用代码生成器提供的 go 或者 push 方法:

void _tap() => PersonRoute(fid: 'f2', pid: 'p1').go(context);

如果弄错了,编译器就会抱怨:

// error: missing required parameter 'fid'
// 错误: 缺少必需参数 'fid'
void _tap() => PersonRoute(pid: 'p1').go(context);

这个,当然,是类型化路由的全部;编译器在出错时会让我们知晓。

查询参数

可选的参数指明查询参数:

class LoginRoute extends GoRouteData {
  LoginRoute({this.from});
  final String? from;

  @override
  Widget build(BuildContext context) => LoginScreen(from: from);
}

附加参数

路由可以消费一个附加参数,通过类型化构造器的特殊名称 $extra 的参数:

class PersonRouteWithExtra extends GoRouteData {
  PersonRouteWithExtra(this.$extra);
  final Person $extra;

  @override
  Widget build(BuildContext context) => PersonScreen(person: $extra);
}

传递附加参数作为类型化对象:

void _tap() => PersonRouteWithExtra(Person(name: 'Marvin', age: 42)).go(context);

$extra 会在位置之外被传递,会使动态链接和深度链接失败(包括浏览器的返回按钮),也不建议用于目标为Flutter Web 的应用。

混合参数

当然,也可以联合使用路径、查询和$extra 参数:

class HotdogRouteWithEverything extends GoRouteData {
  HotdogRouteWithEverything(this.ketchup, this.mustard, this.$extra);
  final bool ketchup; // required path parameter 必需的路径参数
  final String? mustard; // optional query parameter 可选的查询参数
  final Sauce $extra; // special $extra parameter 特殊的附加参数

  @override
  Widget build(BuildContext context) => HotdogScreen(ketchup, mustard, $extra);
}

这看上去有点儿愚蠢,但是可以正常使用。

重定向

重定向使用代码生成器提供的路由的 location 属性:

redirect: (state) {
  final loggedIn = loginInfo.loggedIn;
  final loggingIn = state.subloc == LoginRoute().location;
  if( !loggedIn && !loggingIn ) return LoginRoute(from: state.subloc).location;
  if( loggedIn && loggingIn ) return HomeRoute().location;
  return null;
}

路由级别重定向

处理路由级别重定向通过实现路由的 redirect 方法:

class HomeRoute extends GoRouteData {
  // no need to implement [build] when this [redirect] is unconditional
  // 当此[redirect] 无条件时,无需实现 [build]
  @override
  String? redirect() => BooksRoute().location;
}

类型转换

代码生成器可以将简单类型如 intenum , 与底层变量的字符串类型进行相互转换。

enum BookKind { all, popular, recent }

class BooksRoute extends GoRouteData {
  BooksRoute({this.kind = BookKind.popular});
  final BookKind kind;

  @override
  Widget build(BuildContext context) => BooksScreen(kind: kind);
}

过渡

默认情况下, GoRouter 使用在组件树中找到的 APP,例如: MaterialAppCupertinoApp 、 WidgetApp 等,使用相应的页面类型来创建包装路由的 build 方法 返回的 Widget 的页面,例如:MaterialPage 、 CupertinoPage 、 NoTransitionPage 等。此外,它会使用 state.pageKey 属性来设置页面的 key 属性 和 页面的 restorationId

过渡覆写

如果想要改变页面如何创建,例如,使用一个不同的页面类型,当创建页面时传递一个非默认参数(如一个自定义 key ) 或者访问 GoRouterState 对象,可以覆写基类的 buildPage 方法来代替 build 方法:

class MyMaterialRouteWithKey extends GoRouteData {
  static final _key = LocalKey('my-route-with-key');

  @override
  MaterialPage<void> buildPage(BuildContext context, GoRouterState state) =>
    MaterialPage<void>(
      key: _key,
      child: MyPage(),
    );
}

自定义过渡

覆写 buildPage 方法也可用于自定义过渡:

class FancyRoute extends GoRouteData {
  @override
  MaterialPage<void> buildPage(BuildContext context, GoRouterState state) =>
    CustomTransitionPage<void>(
      key: state.pageKey,
      child: FancyPage(),
      transitionsBuilder: (context, animation, animation2, child) =>
          RotationTransition(turns: animation, child: child),
    ),
}

实现的注意事项

  1. @TypedGoRoute 注解:
    • 它只用于注解 GoRouterData 的实现。
    • 必须提供类型参数。
    • @TypedGoRoute 的类型参数必须匹配注解的类。
    • 以后,可能会:
      • 扩展可注解的集合,组件?库?
  2. GoRouteData 实现:
    • 必须有一个默认的构造方法(或工厂方法)
    • 所有的构造方法的参数必须映射1对1的名称和类型,类型为成员或 getter 的类型。
    • 构造方法的参数只有对齐 getter 的字段。
    • 可以有一个 const 的构造方法。对于没有参数的构造方法,const 会用来初始化类型。
    • 有效的 参数/成员/属性类型:
      • String: 原样表示
      • num 、 int 、 bool 、 DateTime 、 Duration 、 BigInt 、 double 、 Uri: 使用 toString 编码,使用解析器或类型的东西解码。
      • Enum: 使用 kebab 大小写名称 进行编码/解码。
    • 以后,可能会:
      • 支持可写的成员或 setter。