3. 跨平台路由与导航统一封装
为什么跨平台路由不能直接照搬单端方案
移动端和 Web 端的导航模型存在本质差异:移动端以「页面栈」为核心,返回键弹出栈顶页面;Web 端以「URL 历史」为核心,前进/后退改变地址栏并触发路由匹配。若在共享层直接调用 Navigator.push 或 window.history.back(),同一套跳转逻辑必然在另一端失效。统一封装的目标是:共享层只描述「去哪里、带什么参数、以何种方式进入」,由平台适配层决定如何落到各自的导航机制上。
路由表与统一接口设计
共享层定义一份声明式路由表,每个条目包含路径、名称、参数类型和页面构造器。适配层实现统一的 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/42 | intent 解析后进入详情页 | 初始路由匹配,直接渲染详情页 |
Web 端首次加载时,需在应用启动阶段读取
location.pathname 并匹配路由表,否则刷新页面会丢失当前页面状态。移动端则需在应用启动时解析启动 intent 或 scheme,转为对应的 RouteDef。不要在共享层直接使用
Navigator 或 window.history。一旦共享层出现平台 API,后续新增桌面端或小程序端时就必须修改业务代码,违背统一封装的初衷。