3. 跨平台路由与导航统一封装

跨平台路由与导航统一封装 学习卡片

为什么跨平台路由不能直接照搬单端方案

移动端和 Web 端的导航模型存在本质差异:移动端以「页面栈」为核心,返回键弹出栈顶页面;Web 端以「URL 历史」为核心,前进/后退改变地址栏并触发路由匹配。若在共享层直接调用 Navigator.push 或 window.history.back(),同一套跳转逻辑必然在另一端失效。统一封装的目标是:共享层只描述「去哪里、带什么参数、以何种方式进入」,由平台适配层决定如何落到各自的导航机制上。

共享业务层(平台无关) RouteDef 路由表 path / name / 参数 schema AppRouter 接口 push / replace / pop / go RouteArgs 参数对象 类型安全、可序列化 导航守卫 登录/权限拦截 平台适配层(PlatformRouter 实现) MobileRouter(Android / iOS) Navigator 页面栈 · 系统返回键监听 · 手势返回 WebRouter(浏览器) History API · popstate 监听 · URL 同步 平台差异处理要点 返回键 / 后退 移动端:拦截系统返回键 Web:监听 popstate 事件 统一映射为 router.pop() URL 与页面栈同步 Web:pushState 更新地址栏 移动端:无 URL,仅维护栈 共享层不感知 URL 存在 深链接 / 初始路由 Web:解析 location.pathname 移动端:解析 intent / scheme 统一转为 RouteDef 匹配

路由表与统一接口设计

共享层定义一份声明式路由表,每个条目包含路径、名称、参数类型和页面构造器。适配层实现统一的 AppRouter 接口,共享层只依赖接口,不依赖具体平台实现。

// shared/router/route_def.dart
class RouteDef {
  final String path;        // '/user/:id'
  final String name;        // 'userDetail'
  final Map<String, Type> params; // {'id': String}
  const RouteDef({required this.path, required this.name, this.params = const {}});
}

const appRoutes = <RouteDef>[
  RouteDef(path: '/', name: 'home'),
  RouteDef(path: '/user/:id', name: 'userDetail', params: {'id': String}),
  RouteDef(path: '/settings', name: 'settings'),
];

// shared/router/app_router.dart
abstract class AppRouter {
  Future<void> push(String name, {Map<String, dynamic>? args});
  Future<void> replace(String name, {Map<String, dynamic>? args});
  Future<void> pop({dynamic result});
  Future<void> go(String name, {Map<String, dynamic>? args}); // 清栈跳转
}

移动端适配:页面栈 + 返回键拦截

移动端用 Navigator 维护页面栈,并通过 WillPopScope(或 PopScope)拦截系统返回键,将其统一映射为 pop()。当栈内只剩根页面时,返回键应交给系统处理(退出应用),而不是被路由层吞掉。

// mobile/mobile_router.dart
class MobileRouter implements AppRouter {
  final GlobalKey<NavigatorState> navKey;
  MobileRouter(this.navKey);

  @override
  Future<void> push(String name, {Map<String, dynamic>? args}) async {
    final route = appRoutes.firstWhere((r) => r.name == name);
    await navKey.currentState!.pushNamed(route.path, arguments: args);
  }

  @override
  Future<void> pop({dynamic result}) async {
    navKey.currentState!.pop(result);
  }

  // 返回键拦截:栈深 > 1 时消费事件,否则放行给系统
  Future<bool> onWillPop() async {
    final canPop = navKey.currentState?.canPop() ?? false;
    if (canPop) { await pop(); return false; }
    return true; // 交给系统退出
  }
}

Web 端适配:History API + popstate

Web 端每次 push 调用 history.pushState 写入新地址,同时渲染对应页面;浏览器后退触发 popstate,适配层据此回退页面栈。关键点是:pushState 不会触发 popstate,只有用户点击后退/前进才会触发,因此需要区分「程序主动导航」和「用户历史操作」两条路径。

// web/web_router.dart
class WebRouter implements AppRouter {
  final List<String> _stack = ['/'];

  WebRouter() {
    window.onPopState.listen((event) {
      final path = event.state?['path'] ?? '/';
      _stack.removeLast();          // 用户后退,弹出当前页
      _render(path);                // 渲染目标页面
    });
  }

  @override
  Future<void> push(String name, {Map<String, dynamic>? args}) async {
    final route = appRoutes.firstWhere((r) => r.name == name);
    final path = _buildPath(route, args);
    _stack.add(path);
    window.history.pushState({'path': path}, '', path); // 更新地址栏
    _render(path);
  }

  @override
  Future<void> pop({dynamic result}) async {
    window.history.back(); // 触发 popstate,由监听器统一处理
  }

  String _buildPath(RouteDef route, Map<String, dynamic>? args) {
    var p = route.path;
    args?.forEach((k, v) => p = p.replaceAll(':$k', '$v'));
    return p;
  }
}

共享层调用示例与验证

业务代码只调用 AppRouter 接口,不出现任何平台 API。以下示例在两端行为一致:从首页进入用户详情,再返回。

// 业务代码(两端共用)
final router = AppRouterProvider.of(context);

// 进入用户详情,携带参数
await router.push('userDetail', args: {'id': '42'});

// 返回上一页
await router.pop();
验证项移动端预期Web 端预期
push('userDetail', {id:'42'})新页面入栈,显示用户 42地址栏变为 /user/42,渲染用户 42
pop()页面出栈,回到首页history.back(),地址栏回到 /,渲染首页
系统返回键 / 浏览器后退拦截并 pop,栈空时退出应用popstate 触发,回退页面栈
直接访问 /user/42intent 解析后进入详情页初始路由匹配,直接渲染详情页
Web 端首次加载时,需在应用启动阶段读取 location.pathname 并匹配路由表,否则刷新页面会丢失当前页面状态。移动端则需在应用启动时解析启动 intent 或 scheme,转为对应的 RouteDef。
不要在共享层直接使用 Navigator 或 window.history。一旦共享层出现平台 API,后续新增桌面端或小程序端时就必须修改业务代码,违背统一封装的初衷。