8.页面导航与路由设计
# 8.页面导航与路由设计
📍 本篇位置:第 5 卷 · 交互与系统 · 第 8 篇(承接 5.1「窗口」、5.2「视图加载」、5.7「生命周期」——5.1 解决「容器从哪里来」,5.2 解决「视图怎么挂上去」,5.7 解决「容器怎么活怎么死」,5.8 解决「容器之间怎么跳、怎么回、怎么传参、怎么栈管理」) 🎯 核心矛盾:「业务想随心所欲地跳页面」 vs 「系统必须维护一个有限可回退的栈」——产品想要"从首页 → 详情 → 评论 → 个人主页 → 详情(同一个)→ 回退到首页"这种自由跳转;但系统受限于内存、可回退栈深度、URL 可分享性、深链可达性等约束。解法只有一个:把"页面跳转"抽象为「路由表 + 栈管理 + 参数传递 + 生命周期联动」的状态机——这就是路由(Router) 🧭 设计灵魂:路由 = 「URI + 栈 + 参数 + 拦截器 + 转场」五件套。任何导航框架(Web History API/React Router/Vue Router/Android Navigation/iOS UINavigationController/Flutter Navigator 2.0/小程序 wx.navigateTo)都在这五件套上做选择——理解了通用骨架,所有平台的路由都是同一棵树的不同枝叶 🌐 跨平台覆盖:Web (History/Hash/React Router/Vue Router/Next.js App Router) · Android (Intent/startActivity/Jetpack Navigation/ARouter/DeepLink) · iOS (UINavigationController/Push-Modal/URLRouter/SwiftUI NavigationStack) · 跨端 (Flutter Navigator 1.0/2.0/go_router · RN Stack Navigator · Compose Navigation · Taro) · 小程序 (wx.navigateTo / redirectTo / switchTab / reLaunch) · 鸿蒙 (Router/Navigation) 🔗 延伸阅读:← 5.1 窗口核心设计思想 (opens new window) · ← 5.2 视图加载渲染设计 (opens new window) · ← 5.7 组件生命周期管理 (opens new window) · → 5.9 响应式数据绑定设计 (opens new window) · ↔ 6.x 架构思想 · ↔ 4.x 内存的真相 💡 通用心智:忘掉 startActivity / pushViewController / history.push / Navigator.push / wx.navigateTo 这些具体 API,记住一句话——路由 = 「把一个『字符串 URI』映射成『一个页面 + 一组参数 + 一种转场动画 + 一个回退栈位置』的状态机」。Web 的 URL、Android 的 Intent、iOS 的 Push、Flutter 的 Route、小程序的 wx.navigate——本质都是把"我要去哪里"这件事标准化、可序列化、可分享、可回退、可拦截。核心理解:① 路由的本质是"栈"——不是树、不是图,是 LIFO 后进先出栈;② URI 是路由的"主键"——能 URI 化的页面才能深链、分享、恢复;③ 拦截器是路由的"权限网关"——登录、埋点、AB 测试全靠它——把这三点想明白,所有导航问题就有答案了。
# 目录
# 0.路由事故
场景:某 O2O App 上线分享功能后,运营反馈:「用户从微信点开分享链接 → App 启动 → 偶发性出现『进入了别人的订单详情页』、『未登录用户直接看到付款页』、『回退键直接退出 App』三连问题」。崩溃率没涨,但客诉单堆成山。
# 0.1 案例代码
// MainActivity 处理外部深链
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uri = intent.data // 假设是 myapp://order/detail?id=12345
when (uri?.host) {
"order" -> {
val orderId = uri.getQueryParameter("id")
// ❌ 直接跳转,未检查登录状态
startActivity(Intent(this, OrderDetailActivity::class.java).apply {
putExtra("id", orderId)
})
}
"pay" -> {
val amount = uri.getQueryParameter("amount")
// ❌ 付款页直达,未做权限/订单归属校验
startActivity(Intent(this, PayActivity::class.java).apply {
putExtra("amount", amount)
})
}
}
finish() // ❌ MainActivity 直接 finish,回退栈只剩目标页,按返回直接退出
}
// OrderDetailActivity 内部跳转
fun gotoComment(orderId: String) {
// ❌ 字符串硬编码,没有统一的路由表
startActivity(Intent(this, CommentActivity::class.java).apply {
putExtra("orderId", orderId)
// ❌ 通过 Intent 传了一个 1MB 的商品图列表
putParcelableArrayListExtra("images", largeImageList)
})
}
# 0.2 五个埋雷点
| # | 埋雷点 | 触发条件 | 后果 |
|---|---|---|---|
| 1 | 深链直达未做权限校验 | 攻击者构造 myapp://order/detail?id=他人订单ID | 越权查看他人订单(横向越权 IDOR) |
| 2 | 付款页未做归属校验 | 构造 myapp://pay?amount=0.01&orderId=他人订单 | 金额篡改 / 替他人付款 |
| 3 | MainActivity 过早 finish | 用户从外链进入 → 目标页 → 按返回键 | 直接退出 App(栈深度只剩 1,体验崩溃) |
| 4 | Intent 传大对象 | Bundle 超过 1MB(TransactionTooLargeException) | 跨进程 Binder 崩溃(5.6 跨进程通信痛点) |
| 5 | 跳转字符串硬编码 | 改名 / 删除目标 Activity | 编译期不报错,运行时 ClassNotFoundException |
# 0.3 路由化修复
// 1. 定义路由表(单一事实源)
object Routes {
const val ORDER_DETAIL = "/order/detail"
const val PAY = "/pay"
const val LOGIN = "/login"
}
// 2. 注册路由 + 拦截器
Router.register(Routes.ORDER_DETAIL, OrderDetailActivity::class.java)
.interceptor(LoginInterceptor()) // 必须登录
.interceptor(OwnershipInterceptor()) // 必须是订单归属人
Router.register(Routes.PAY, PayActivity::class.java)
.interceptor(LoginInterceptor())
.interceptor(OrderOwnershipInterceptor()) // 必须是订单归属人
.interceptor(AmountValidInterceptor()) // 金额必须等于订单金额
// 3. 深链入口统一交给路由
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uri = intent.data
if (uri != null) {
// 不 finish MainActivity,让路由把"首页"放到栈底
Router.with(this)
.uri(uri)
.ensureHomeInStack() // ★ 保证首页在栈底,回退不会退出 App
.navigate()
}
}
// 4. 内部跳转用路由 + 参数 Builder
Router.with(this)
.path(Routes.COMMENT)
.withString("orderId", orderId)
.withImages(largeImageList, transferMode = MMAP) // ★ 大对象走 MMAP 不走 Bundle
.navigate()
# 0.4 本节启示
- 路由不是"跳页面",是"系统级状态机"——它要同时管理 URI 解析、栈深度、参数传递、权限拦截、转场动画 5 件事
- 5 个埋雷点对应 5 个设计契约:权限网关 = 拦截器、栈深度 = ensureHomeInStack、大参数 = 不走 Bundle、硬编码 = 路由表、归属校验 = 业务拦截器
- 本章要回答:① 为什么需要路由(§1)② 路由的通用契约是什么(§2)③ 各端怎么实现的(§3-§6)④ 反模式与现代演进(§8-§9)
# 1.为何需要路由
# 1.1 手动跳转混乱
想象一个没有路由的 App,所有页面跳转都靠"直接 new + 入栈":
// 直接 new 一个页面对象
val detail = OrderDetailActivity(orderId)
windowManager.push(detail)
听起来很简洁,但马上会面对四个无解的问题:
| 问题 | 具体表现 |
|---|---|
| 解耦失败 | 模块 A 想跳模块 B,必须 import B——模块化彻底失败,编译时间爆炸 |
| 深链无门 | 微信分享一条 myapp://order/detail?id=123,App 无法把字符串 URI 映射回页面对象 |
| 回退栈混乱 | 用户从首页 → A → B → A(同一个 A)→ 回退几次能到首页?没人说得清 |
| 跨进程不可达 | 从浏览器拉起 App、从 Push 通知拉起 App、从 Widget 拉起 App——没有 URI 就无法描述目标 |
结论:只要 App 不是只有一个页面,就必须有路由。路由 = "把『去哪里』这件事从『代码调用』升级为『字符串协议』"。
# 1.2 三大根因
为什么所有平台、所有框架最终都演化出了"路由系统"?归根到底是三个根本需求:
# 根因 1:模块解耦(Decoupling)
- 现象:组件化 / 微前端 / 插件化时代,模块之间不能互相
import - 解法:模块只发布 URI(
/order/detail),不发布类名(OrderDetailActivity),通过路由中心做映射 - 典型代表:ARouter(Android)、Module Federation(Web)、Coordinator(iOS)
# 根因 2:栈管理(Stack Management)
- 现象:用户的"返回"操作必须有可预期的语义,不能让 App 莫名其妙退出
- 解法:路由系统维护一棵或一棵以上的"页面栈",规定入栈、出栈、清栈、替换的语义
- 典型代表:Android Task / Back Stack、iOS UINavigationController、小程序页面栈(最多 10 层)
# 根因 3:深链可达(Deep Link)
- 现象:从 App 外(微信、浏览器、Push、Widget、二维码)必须能直达 App 内任意页面
- 解法:每个页面都有一个全局唯一的 URI,外部链接 → URI 解析 → 路由匹配 → 页面打开
- 典型代表:Web URL、Android App Links、iOS Universal Links、Flutter 的 Navigator 2.0
# 1.3 路由设计目标
任何合格的路由系统都必须同时实现这 7 个目标——少一个都不够格:
| # | 目标 | 含义 | 反例 |
|---|---|---|---|
| 1 | URI 化 | 任何页面都有唯一字符串标识 | 字符串硬编码 OrderDetailActivity 跳转 |
| 2 | 可序列化 | 路由状态可保存到 SP/disk/进程间 | 页面对象本身不可序列化 |
| 3 | 可拦截 | 跳转前可注入登录/埋点/AB 测试 | 在每个 Activity 的 onCreate 手写 if (!login) finish |
| 4 | 可回退 | 系统返回键有明确语义 | 按返回直接退出 App |
| 5 | 可传参 | 参数有类型约束(不只是 String) | Bundle 传大对象导致 TransactionTooLarge |
| 6 | 可降级 | 目标页面不存在时有 fallback | 直接 ClassNotFoundException 崩溃 |
| 7 | 可定制转场 | 动画 / 共享元素 / 模态 | 所有跳转都是同一种系统动画 |
这 7 个目标,就是 §2 五件套契约的现实需求。
# 2.路由五件套
本节是全篇的"骨架"——抽象出所有端通用的五件套契约,后面 §3-§6 的所有平台实现,都是这五件套在不同端的具体投影。
# 2.1 URI 标识
URI = "页面的主键",必须做到:
scheme://host/path?query#fragment
myapp://order/detail?id=12345&from=push
https://yccoding.com/pages/f54c32/
/pages/order/detail?id=12345
| 维度 | 设计要点 |
|---|---|
| scheme | 区分应用边界:https(Web)、myapp://(私有协议)、内部路由用 / |
| host | 模块名 / 子系统名(电商 / 社交 / 支付) |
| path | 具体页面(/detail、/list、/edit) |
| query | 参数(短小、可见、可分享) |
| fragment | 锚点 / 页面内位置(Web 专属) |
URI 的本质:把"跳哪里"从"运行时引用"提升为"编译时字符串契约"——只要双方约定好字符串协议,模块之间就完全解耦。
# 2.2 路由栈
栈是路由的核心数据结构。所有平台的"返回键"语义,本质都是 stack.pop():
栈顶 ─→ PayActivity ← 当前可见
OrderDetailActivity
OrderListActivity
栈底 ─→ HomeActivity ← 按返回最终回到这里
栈的五种操作语义(跨端通用术语):
| 操作 | 语义 | Android | iOS | Web | Flutter | 小程序 |
|---|---|---|---|---|---|---|
| push | 入栈一个新页面 | startActivity | pushViewController | history.pushState | Navigator.push | navigateTo |
| pop | 弹出栈顶 | finish/back | popViewController | history.back | Navigator.pop | navigateBack |
| replace | 替换栈顶(不入栈、不可回退到原页面) | finish+start | setViewControllers | history.replaceState | pushReplacement | redirectTo |
| clearTop | 清除栈顶到目标页之间的所有页面 | FLAG_CLEAR_TOP | popTo | —— | popUntil | —— |
| reLaunch | 清空整个栈,重新作为唯一页面 | NEW_TASK | rootViewController | history.replaceAll | pushAndRemove | reLaunch |
栈管理的三个铁律:
- 栈深度有上限(小程序 10 层、iOS 实际可超但体验差、Android Back Stack 受任务亲和性影响)
- 同一页面可重复入栈——这是 90% 的"栈污染"根因(详见 §8.3)
- 栈状态必须可序列化——进程死亡后要能从 SP / SavedInstanceState 重建
# 2.3 参数传递
参数传递的三种范式:
| 范式 | 适用场景 | 局限 |
|---|---|---|
| URI Query | < 200 字符的基础类型 | 只能 String、容易暴露 |
| Bundle/Object | 中等结构体、可 Parcelable/Serializable | Android 上限 1MB(Binder 限制,5.6 章详述)、iOS 无强限制但建议 < 4MB |
| 外部存储 + Key | 大对象(图片列表、文件、富文本) | 跳转方 put、目标方 get、销毁方 delete——需要生命周期管理 |
参数设计的两个铁律:
- 能 URI 化的就别用 Bundle——URI 可分享、可恢复、可埋点、可降级
- 大对象(> 100KB)绝对不走 Bundle——用 MMAP / 临时文件 / EventBus 中转
// ❌ 错误:通过 Bundle 传 List<Bitmap>
intent.putParcelableArrayListExtra("bitmaps", bitmaps) // TransactionTooLargeException
// ✅ 正确:先存到全局缓存,传 key
val key = ImageCache.put(bitmaps)
intent.putExtra("imagesKey", key)
// 目标页:
val bitmaps = ImageCache.get(intent.getStringExtra("imagesKey"))
# 2.4 拦截器守卫
拦截器是路由系统的"权限网关"——所有横切关注点(登录、埋点、AB 测试、降级)都在这里处理:
拦截器责任链:
外部 URI ─→ [全局拦截器] ─→ [模块拦截器] ─→ [页面拦截器] ─→ 目标页面
↓ ↓ ↓
埋点/AB 登录/会员 归属/金额
拦截器的四种典型职责:
| 职责 | 典型实现 |
|---|---|
| 登录态 | LoginInterceptor:未登录 → 跳到登录页,登录后回到原路由 |
| 权限 | PermissionInterceptor:检查角色/会员等级 |
| 埋点 | TrackInterceptor:记录"从哪来、到哪去、什么参数" |
| 降级 | FallbackInterceptor:目标页不存在时跳兜底页 |
Web/Vue Router 的"路由守卫"是同一个东西:
router.beforeEach((to, from, next) => {
if (to.meta.requireAuth && !isLoggedIn()) {
next('/login?redirect=' + to.fullPath)
} else {
next()
}
})
# 2.5 转场动画
转场是用户对"页面切换"的唯一感官输入,必须可定制:
| 转场类型 | 含义 | 场景 |
|---|---|---|
| Push/Pop | 横向推进/回退 | 同一层级的前进后退 |
| Modal/Present | 纵向弹出/收起 | 独立任务(编辑、设置) |
| Fade/Cross | 淡入淡出 | 启动页 → 首页 |
| Shared Element | 共享元素过渡 | 列表 → 详情的图片放大 |
| Custom | 完全自定义(如 PIP、Hero、3D 翻转) | 视频小窗、特殊动效 |
转场动画的设计哲学:动画不只是"好看",它是"信息层级"的可视化——横向推进表示"同一任务的下一步",纵向弹出表示"独立的子任务"。混淆它们会让用户失去方向感。
# 2.6 跨端名词矩阵
| 概念 | Android (Activity) | Android (Navigation) | iOS (UIKit) | iOS (SwiftUI) | Web (React Router) | Flutter | 小程序 |
|---|---|---|---|---|---|---|---|
| URI | Intent + URI | NavDeepLink | URL Scheme | URL | path | RouteSettings | path |
| 栈 | Task + Back Stack | NavController | UINavigationController | NavigationStack | History | Navigator | 页面栈 |
| push | startActivity | navigate | pushVC | navigationDestination | Link / navigate | Navigator.push | navigateTo |
| pop | finish | popBackStack | popVC | dismiss | navigate(-1) | Navigator.pop | navigateBack |
| replace | finish + start | popUpTo + navigate | setViewControllers | replace | replace | pushReplacement | redirectTo |
| 拦截器 | (无原生) | NavController.addListener | 自定义中间件 | 自定义中间件 | beforeEach (Vue) / loaders (RR v6) | RouteGuard | App.onLaunch |
| 转场 | overridePendingTransition | NavOptions.anim | UIViewControllerAnimatedTransitioning | NavigationTransition | CSS Transition | PageRouteBuilder | 系统默认 |
记住一个观察:所有端的路由系统都在"五件套契约"上做选择——URI、栈、参数、拦截器、转场——只是叫法不同。理解了这点,再学任何新框架(鸿蒙 Router、Compose Multiplatform、Server Components)都能 5 分钟上手。
# 3.Web 路由
Web 路由是所有平台路由的"祖师爷"——URL 天生就是 URI,浏览器天生就是状态机。后来 SPA 时代,前端把"路由"从浏览器手里抢了过来,自己做栈管理、参数解析、拦截守卫——本质是"在客户端重新发明了一遍服务端路由"。
# 3.1 Hash vs History
SPA 出现之前,"路由"是服务端的事——浏览器请求 /order/detail?id=123,服务端返回完整 HTML。SPA 时代,前端要在不刷新页面的前提下切换"视图"——这就有了两种方案:
# 方案 1:Hash 路由(# 后面的部分)
https://example.com/#/order/detail?id=123
- 原理:URL 中
#后面的部分(fragment)不会触发浏览器刷新,但变化时会触发hashchange事件 - 优点:兼容性好(IE8+)、无需服务端配合
- 缺点:URL 难看(多个
#)、SEO 不友好(搜索引擎默认忽略 hash)
// 监听 hash 变化
window.addEventListener('hashchange', () => {
const path = location.hash.slice(1) // 去掉 #
render(path)
})
// 跳转
location.hash = '/order/detail?id=123'
# 方案 2:History 路由(HTML5 History API)
https://example.com/order/detail?id=123
- 原理:用
history.pushState()/replaceState()修改 URL 但不触发刷新 - 优点:URL 干净、SEO 友好
- 缺点:必须服务端配合——刷新
/order/detail时服务端要 fallback 到index.html,否则 404
// pushState 不触发刷新,但会在历史栈中加一条记录
history.pushState({ id: 123 }, '订单详情', '/order/detail?id=123')
// 监听浏览器前进后退
window.addEventListener('popstate', (event) => {
const state = event.state // 之前 pushState 时存的对象
render(location.pathname, state)
})
对比矩阵:
| 维度 | Hash 路由 | History 路由 |
|---|---|---|
| URL 形式 | /#/order | /order |
| 服务端配合 | 不需要 | 需要(fallback to index) |
| SEO | 不友好 | 友好 |
| 兼容性 | IE8+ | IE10+ |
| 状态携带 | 只能 URL | URL + state 对象 |
| 现代推荐 | 内嵌 H5、不可控环境 | 独立 SPA、官网应用 |
# 3.2 React Router
React Router 是 SPA 路由的"事实标准",从 v4 开始全面拥抱声明式路由:
# 声明式:路由就是一棵组件树
// React Router v6
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="order" element={<OrderLayout />}>
<Route path="list" element={<OrderList />} />
<Route path="detail/:id" element={<OrderDetail />} />
</Route>
<Route path="login" element={<Login />} />
<Route path="*" element={<NotFound />} /> {/* 兜底 */}
</Route>
</Routes>
</BrowserRouter>
三个核心特性:
| 特性 | 体现 |
|---|---|
| 嵌套路由 | <Outlet /> 占位符让父路由决定子路由渲染位置——天然实现"母版页 + 内容区" |
| 路径参数 | :id 自动解析为 useParams().id |
| 动态 import | lazy() + Suspense 实现路由级代码分割(首屏加速) |
# 命令式:useNavigate
function ProductCard({ id }) {
const navigate = useNavigate()
return (
<div onClick={() => navigate(`/order/detail/${id}`)}>
...
</div>
)
}
# v6.4+ 的 Loaders:数据获取与路由绑定
const router = createBrowserRouter([
{
path: "/order/detail/:id",
element: <OrderDetail />,
loader: async ({ params }) => {
// ★ 路由匹配后、组件渲染前先取数据
const res = await fetch(`/api/order/${params.id}`)
if (!res.ok) throw new Response("Not Found", { status: 404 })
return res.json()
},
errorElement: <ErrorPage />,
}
])
function OrderDetail() {
const order = useLoaderData() // ★ loader 的返回值
return <div>{order.title}</div>
}
Loader 模式的颠覆点:把"数据获取"从组件 useEffect 里搬到了"路由层"——这意味着:① 避免瀑布流请求 ② 天然支持 SSR ③ 路由切换前就能确定数据状态。
# 3.3 Vue Router 守卫
Vue Router 的设计哲学和 React Router 类似,但路由守卫(Guard)做得最完整——这是它最值得借鉴的部分:
# 三层守卫
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/order/detail/:id',
component: OrderDetail,
meta: { requireAuth: true },
// ★ 路由级守卫
beforeEnter: (to, from) => {
if (!checkOwnership(to.params.id)) return '/403'
}
}
]
})
// ★ 全局前置守卫(登录校验、埋点)
router.beforeEach((to, from) => {
if (to.meta.requireAuth && !isLoggedIn()) {
return { path: '/login', query: { redirect: to.fullPath } }
}
})
// ★ 全局后置守卫(埋点、标题)
router.afterEach((to, from) => {
document.title = to.meta.title
track('page_view', { from: from.path, to: to.path })
})
// 组件内守卫
export default {
beforeRouteEnter(to, from, next) { /* 进入前 */ },
beforeRouteUpdate(to, from) { /* 同组件路径变化 */ },
beforeRouteLeave(to, from) { /* 离开前——常用于"未保存提示" */ }
}
守卫的执行顺序(13 步):
导航触发 → 失活组件 beforeRouteLeave → 全局 beforeEach → 重用组件 beforeRouteUpdate
→ 路由配置 beforeEnter → 解析异步组件 → 激活组件 beforeRouteEnter
→ 全局 beforeResolve → 导航确认 → 全局 afterEach → DOM 更新
→ 调用 beforeRouteEnter 的 next 回调
记住一句话:守卫就是"路由的中间件"——所有横切关注点(登录、权限、埋点、未保存提示)都应该在守卫里处理,不要污染业务组件。
# 3.4 文件系统路由
2023 年后 Web 路由的最大演进:"文件系统即路由表"——文件路径自动映射为 URL:
app/
├── page.tsx → /
├── login/page.tsx → /login
├── order/
│ ├── layout.tsx → (嵌套布局)
│ ├── list/page.tsx → /order/list
│ └── detail/[id]/page.tsx → /order/detail/:id
└── api/
└── order/[id]/route.ts → GET /api/order/:id
四个革命性特性(Next.js App Router):
| 特性 | 含义 |
|---|---|
| 文件即路由 | 不再手写 routes 数组,文件路径就是 URL——天然"自解释" |
| layout.tsx | 同目录自动共享布局,类似嵌套路由的 Outlet |
| loading.tsx | 路由级 Loading UI(基于 Suspense) |
| Server Components | 路由可以是 RSC——数据获取直接在路由级完成,零客户端 JS |
这是路由演进的终局形态:URL = 文件系统路径 = 数据源 = 组件树——四者统一,开发者不需要再"维护路由表"了。
# 4.Android 导航
Android 的导航演进是一部"渐进式架构升级史"——从最早的 Activity + Intent,到 Fragment 时代的混乱,到 Jetpack Navigation 的官方收编,再到组件化时代的 ARouter/TheRouter。每一步都在解决前一阶段暴露的问题。
# 4.1 Intent 跳转
Intent 是 Android 的"通用通信总线"——它不只是跳页面,还能跳服务、广播、跨应用:
# 显式 Intent:类名直达
val intent = Intent(this, OrderDetailActivity::class.java).apply {
putExtra("id", 12345)
}
startActivity(intent)
特点:编译期绑定、跨模块需要 compile project(":order")——这就是组件化的"编译墙"。
# 隐式 Intent:通过 Action / Data / Category 匹配
<!-- AndroidManifest.xml 注册过滤器 -->
<activity android:name=".OrderDetailActivity">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="order" android:pathPrefix="/detail" />
<!-- App Links:用真实域名做深链 -->
<data android:scheme="https" android:host="example.com" android:pathPrefix="/order/detail" />
</intent-filter>
</activity>
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("myapp://order/detail?id=123"))
startActivity(intent)
隐式 Intent 的本质:Android 系统级的"URI → Activity"路由表——所有 App 共享这张表(PackageManagerService 维护)。这就是 Android 的"系统路由"。
# 4.2 TaskAffinity launchMode
Activity 不是简单进栈,而是按 Task 和 launchMode 组织:
Task A (taskAffinity = com.example.app)
├── MainActivity (standard)
├── OrderList (standard)
└── OrderDetail (singleTop)
四种 launchMode:
| launchMode | 行为 | 典型场景 |
|---|---|---|
| standard | 每次都创建新实例 | 默认(详情页等) |
| singleTop | 如果当前栈顶就是它,不创建新实例(onNewIntent) | 搜索结果页、通知点击 |
| singleTask | 全局只有一个实例,已存在则把它上面的全部 pop | 首页、登录页 |
| singleInstance | 独占一个 Task,整个系统只有一个实例 | 拨号器、闹钟 |
栈污染的根因:90% 的"按返回退不出去"都是因为 standard 模式 + 不正确的 Intent Flag 组合——例如把同一个详情页推 N 次。
配合 Intent Flags:
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) // 清除栈顶到目标页之间的所有
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) // 新开 Task
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) // 等同 singleTop
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) // 清空目标 Task(配合 NEW_TASK 用)
# 4.3 Jetpack Navigation
Android 团队 2018 年推出 Jetpack Navigation,目的:消灭多 Activity 时代的栈混乱,回归单 Activity + 多 Fragment。
# 核心三件套
<!-- nav_graph.xml:路由图(声明式) -->
<navigation xmlns:app="..." app:startDestination="@id/home">
<fragment android:id="@+id/home" android:name=".HomeFragment">
<action android:id="@+id/to_order_list"
app:destination="@id/order_list" />
</fragment>
<fragment android:id="@+id/order_list" android:name=".OrderListFragment">
<action android:id="@+id/to_order_detail"
app:destination="@id/order_detail" />
</fragment>
<fragment android:id="@+id/order_detail" android:name=".OrderDetailFragment">
<!-- ★ 类型安全的参数 -->
<argument android:name="orderId" app:argType="string" />
<!-- ★ 深链 -->
<deepLink app:uri="myapp://order/detail?id={orderId}" />
</fragment>
</navigation>
// Activity 只放一个 NavHostFragment
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setContentView(R.layout.activity_main)
// 处理深链
navController.handleDeepLink(intent)
}
}
// Fragment 内跳转:类型安全
class OrderListFragment : Fragment() {
fun gotoDetail(orderId: String) {
val action = OrderListFragmentDirections.toOrderDetail(orderId)
findNavController().navigate(action)
}
}
四个革命性收益:
| 收益 | 解决了什么 |
|---|---|
| 可视化路由图 | 路由关系一眼可见——告别"全工程 grep startActivity" |
| Safe Args | 编译期生成参数类——告别 getString("id")?: "" 这种字符串 key |
| 统一深链 | <deepLink> 标签 + handleDeepLink——告别 manifest 写一堆 filter |
| 回退栈可控 | popUpTo / popUpToInclusive 在 XML 里声明——告别 Flag 组合记忆 |
# 4.4 ARouter 组件化
Jetpack Navigation 解决了"单 Activity 内部"的导航,但组件化场景下"模块 A 跳模块 B"还是不能 import——这就是 ARouter(阿里)、TheRouter(字节)等第三方路由的舞台。
# 核心设计:注解 + APT 自动生成路由表
// 模块 B 声明
@Route(path = "/order/detail")
class OrderDetailActivity : AppCompatActivity() {
@Autowired var orderId: String = "" // 自动注入
}
// 模块 A 跳转(不需要 import 模块 B)
ARouter.getInstance()
.build("/order/detail")
.withString("orderId", "12345")
.navigation()
# 完整的拦截器体系
@Interceptor(priority = 1)
class LoginInterceptor : IInterceptor {
override fun process(postcard: Postcard, callback: InterceptorCallback) {
if (postcard.path == "/login" || UserManager.isLoggedIn()) {
callback.onContinue(postcard)
} else {
// 跳登录页,带 redirect
ARouter.getInstance().build("/login")
.withString("redirect", postcard.path)
.navigation()
callback.onInterrupt(null) // 中断原路由
}
}
}
# 设计核心
- APT 编译期生成路由表:每个模块独立编译为
Routes$$App_OrderModule.java,应用启动时合并——这是"组件化"的关键 - 跨模块解耦:模块 A 只依赖
/order/detail字符串,不依赖OrderDetailActivity.class - 服务发现:除了页面路由,还能注册"服务接口"——这是模块化 IPC 的另一种形态(5.6 跨进程通信的"App 内 IPC 版")
对比矩阵:
| 维度 | Intent | Jetpack Navigation | ARouter |
|---|---|---|---|
| 路由表 | manifest 注册 | nav_graph.xml | 注解 + APT 自动生成 |
| 类型安全 | ❌ | ✅ Safe Args | ⚠️ @Autowired 半自动 |
| 跨模块跳转 | 需要 import | 不支持(设计为单 Activity) | ✅ 字符串解耦 |
| 拦截器 | ❌ | ⚠️ listener | ✅ 完整责任链 |
| 深链 | manifest filter | <deepLink> | @Route(path) 即 URI |
| 转场动画 | overridePending | NavOptions | withTransition |
| 适用场景 | 跨 App / 系统级 | 单 App 单 Activity | 大型组件化 App |
# 5.iOS 导航
iOS 导航的精髓是"栈即视图"——UINavigationController 本身就是一个可视化的栈容器,pushVC 时屏幕从右边推入,popVC 时从左边滑出,栈的"数据结构"和"UI 视觉"是统一的。这种"所见即栈"的设计哲学,被后来的 SwiftUI NavigationStack 完整继承。
# 5.1 UINavigation 栈
let nav = UINavigationController(rootViewController: HomeVC())
// Push 入栈
nav.pushViewController(OrderListVC(), animated: true)
nav.pushViewController(OrderDetailVC(orderId: "123"), animated: true)
// 栈状态
print(nav.viewControllers) // [HomeVC, OrderListVC, OrderDetailVC]
// Pop 出栈
nav.popViewController(animated: true) // 弹出栈顶
nav.popToRootViewController(animated: true) // 回到根
nav.popToViewController(orderListVC, animated: true) // 回到指定 VC
// Replace:直接重置整个栈
nav.setViewControllers([HomeVC(), NewVC()], animated: true)
栈模型的三个特性:
- viewControllers 是开放的数组——可以读、可以改、可以重排(Android 的 BackStack 是封闭的)
- navigationItem 是 VC 自己的属性——每个 VC 自带导航栏配置,无需父级配合
- interactivePopGestureRecognizer——iOS 原生右滑返回手势(5.4 手势事件章详述)
# 5.2 Modal/Push/Present
iOS 的页面切换有三种层次完全不同的范式,混用是最常见的设计灾难:
| 范式 | API | 视觉 | 语义 | 栈关系 |
|---|---|---|---|---|
| Push | pushViewController | 横向滑入 | 同一任务的"下一步" | 加入当前导航栈 |
| Present | present(_:animated:) | 从下往上弹出 | 独立的子任务(编辑、设置、登录) | 开启新栈 |
| Show | show(_:sender:) | 自适应(Push 或 Modal) | Adaptive:在 iPad/Mac 自动调整 | 由 containerVC 决定 |
典型混用错误:
// ❌ 反模式:在 Modal 弹出的 VC 里做 push
let editVC = EditVC()
self.present(editVC, animated: true) // editVC 没有 navigationController!
editVC.navigationController?.pushViewController(...) // ⚠️ nil,跳转失效
// ✅ 正确:present 时包一层 NavigationController
let editVC = EditVC()
let nav = UINavigationController(rootViewController: editVC)
self.present(nav, animated: true) // 现在 editVC.navigationController 不为 nil
记住一句话:Push 是"同一任务的延续",Present 是"独立任务的开始"——前者共享栈,后者开启新栈。
# 5.3 URLRouter 协调器
iOS 原生没有"路由表"的概念,所以社区演化出两套主流方案:
# 方案 1:URLRouter(类似 ARouter)
// 注册
Router.register("myapp://order/detail") { params in
let vc = OrderDetailVC()
vc.orderId = params["id"] as? String ?? ""
return vc
}
// 调用
Router.open("myapp://order/detail?id=123")
代表库:JLRoutes、URLNavigator、SwiftRouter。
# 方案 2:Coordinator 模式(更"iOS 原生"的方案)
Coordinator 模式的核心:VC 不知道"自己之后跳哪",由 Coordinator(协调器)决定——这就把"导航逻辑"从 VC 里剥离出来:
protocol Coordinator {
var navigationController: UINavigationController { get }
func start()
}
class OrderCoordinator: Coordinator {
let navigationController: UINavigationController
init(nav: UINavigationController) {
self.navigationController = nav
}
func start() {
let listVC = OrderListVC()
listVC.onSelectOrder = { [weak self] orderId in
// ★ VC 只发"我选中了订单"事件,不知道之后去哪
self?.showDetail(orderId: orderId)
}
navigationController.pushViewController(listVC, animated: true)
}
private func showDetail(orderId: String) {
let detailVC = OrderDetailVC(orderId: orderId)
detailVC.onCheckout = { [weak self] in
self?.showPayment(orderId: orderId) // ★ 决定下一步
}
navigationController.pushViewController(detailVC, animated: true)
}
private func showPayment(orderId: String) {
let payVC = PayVC(orderId: orderId)
// ★ 支付完成后的去向也由 Coordinator 决定
navigationController.pushViewController(payVC, animated: true)
}
}
Coordinator 的核心收益:
- VC 不耦合"导航逻辑":可独立测试、可复用到不同流程
- 流程可视化:每个业务流程(下单流、注册流)就是一个 Coordinator
- 嵌套 Coordinator:父 Coordinator 启动子 Coordinator,形成树状导航图
# 5.4 SwiftUI NavStack
SwiftUI 在 iOS 16+ 引入 NavigationStack——这是 iOS 路由设计的"声明式革命":
struct ContentView: View {
@State private var path = NavigationPath() // ★ 路径就是数据
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Order.self) { order in
OrderDetailView(order: order)
}
.navigationDestination(for: User.self) { user in
UserProfileView(user: user)
}
}
}
// 跳转:直接操作数据
func gotoOrder(_ order: Order) {
path.append(order) // 数据 push → UI push
}
func popToRoot() {
path = NavigationPath() // 清空数据 → 清空 UI 栈
}
}
三个革命性特性:
| 特性 | 含义 |
|---|---|
| 数据驱动路由 | path 是 @State,栈的状态就是普通数据——可保存、可恢复、可反序列化 |
| 类型安全分发 | navigationDestination(for: Order.self)——按类型自动分发到目标视图 |
| 深链原生支持 | NavigationPath 实现了 Codable——直接 JSON 序列化/反序列化 |
对比 UIKit 的 UINavigationController:
UIKit: 栈 = UIViewController 数组(对象)
SwiftUI: 栈 = NavigationPath(值类型 / Codable 数据)
↑
这是质变——数据驱动 UI,而不是反过来
这就是路由设计的终极形态之一:"栈状态" = "可序列化数据"——天然支持深链、状态恢复、URL 同步。Flutter Navigator 2.0(§6.1)走的是同一条路。
# 6.跨端路由
跨端框架的路由设计有一个共同的演进方向:从"命令式 push/pop"走向"声明式 state-driven"——Flutter Navigator 1.0 → 2.0、React Native v4 → v5、小程序的页面栈管理——本质都是同一场革命。
# 6.1 Flutter Navigator
# Navigator 1.0:命令式
// 跳转
Navigator.push(context, MaterialPageRoute(
builder: (_) => OrderDetailPage(orderId: '123')
));
// 回退
Navigator.pop(context);
// 命名路由
Navigator.pushNamed(context, '/order/detail', arguments: '123');
问题:
- 栈状态由 Navigator 内部管理,外部读不到完整栈
- 深链处理麻烦:需要在 onGenerateRoute 里手写解析
- Web 端 URL 和 App 端栈无法同步
# Navigator 2.0:声明式(2020 年推出)
class AppRouterDelegate extends RouterDelegate<AppRoutePath> {
// ★ 整个栈是一个"值"
AppRoutePath _currentPath = AppRoutePath.home();
Widget build(BuildContext context) {
return Navigator(
pages: [
MaterialPage(child: HomePage()),
if (_currentPath.isOrderList) MaterialPage(child: OrderListPage()),
if (_currentPath.isOrderDetail)
MaterialPage(child: OrderDetailPage(id: _currentPath.orderId)),
],
onPopPage: (route, result) {
if (!route.didPop(result)) return false;
// ★ pop 时更新数据
_currentPath = AppRoutePath.home();
notifyListeners();
return true;
},
);
}
// ★ 从 URL 解析路径
Future<void> setNewRoutePath(AppRoutePath path) async {
_currentPath = path;
}
}
Navigator 2.0 的三个革命:
| 革命 | 1.0 | 2.0 |
|---|---|---|
| 栈表达 | 不可见,内部数组 | 由 pages 列表完全描述(可见、可改) |
| URL 同步 | 手动 | 双向自动同步(RouteInformationParser) |
| 状态可序列化 | 不行 | 路径对象本身就是数据,可保存/恢复 |
坦白说:Navigator 2.0 写起来非常啰嗦——所以社区出现了 go_router。
# 6.2 go_router 声明式
go_router 是 Flutter 团队推荐的"高级 API"——封装 Navigator 2.0 的复杂性,提供类似 React Router 的声明式 API:
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(path: '/', builder: (ctx, state) => HomePage()),
GoRoute(
path: '/order',
builder: (ctx, state) => OrderListPage(),
routes: [
GoRoute(
path: 'detail/:id', // ★ 路径参数
builder: (ctx, state) {
final id = state.pathParameters['id']!;
return OrderDetailPage(id: id);
},
),
],
),
GoRoute(path: '/login', builder: (ctx, state) => LoginPage()),
],
// ★ 全局拦截器(路由守卫)
redirect: (ctx, state) {
final isLoggedIn = AuthService.isLoggedIn;
final isLoggingIn = state.matchedLocation == '/login';
if (!isLoggedIn && !isLoggingIn) {
return '/login?redirect=${state.matchedLocation}';
}
return null;
},
);
// 跳转
context.go('/order/detail/123');
context.push('/order/detail/123'); // push 不替换栈顶
go_router 把 Flutter 路由拉回了"易用区"——保留了 Navigator 2.0 的所有优势(声明式、URL 同步、可恢复),同时 API 简单得像 React Router。
# 6.3 RN/Compose 导航
# React Native:React Navigation v6
const Stack = createNativeStackNavigator()
function App() {
return (
<NavigationContainer linking={{
prefixes: ['myapp://', 'https://example.com'],
config: {
screens: {
Home: '',
OrderDetail: 'order/detail/:id',
}
}
}}>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen
name="OrderDetail"
component={OrderDetailScreen}
options={{ animation: 'slide_from_right' }}
/>
</Stack.Navigator>
</NavigationContainer>
)
}
// 跳转
navigation.navigate('OrderDetail', { id: '123' })
navigation.goBack()
特点:
- 多种 Navigator 组合:Stack(栈式)、Tab(底部 Tab)、Drawer(抽屉)——可嵌套
- 底层用各端原生导航:iOS 用 UINavigationController、Android 用 Fragment——所以转场和原生一致
- linking 配置即深链:一处配置,App Links / Universal Links 同时启用
# Jetpack Compose Navigation
Android 的 Compose 版本与 Jetpack Navigation 一脉相承,但 API 更"现代":
val navController = rememberNavController()
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen(navController) }
composable(
"order/detail/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
deepLinks = listOf(navDeepLink { uriPattern = "myapp://order/detail/{id}" })
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("id")!!
OrderDetailScreen(id)
}
}
// 跳转
navController.navigate("order/detail/123")
navController.popBackStack()
Compose Navigation 把 XML(nav_graph)改成了 DSL——本质相同,写法更现代。
# 6.4 小程序导航 API
小程序的导航 API 看似简单,但 90% 的小程序 Bug 都跟这四个 API 混用有关:
| API | 行为 | 栈变化 | 典型场景 |
|---|---|---|---|
| navigateTo | 跳转到新页面 | push(最多 10 层) | 详情页、子页面 |
| redirectTo | 关闭当前页跳转 | replace | 登录成功 → 首页 |
| switchTab | 跳转到 Tab 页 | 清空非 Tab 栈 | 跳到首页 Tab |
| reLaunch | 关闭所有页面,打开到新页 | 清空整个栈 | 退出登录、深链入口 |
| navigateBack | 返回 N 层 | pop(N) | 多层流程后跳回 |
四种 API 的"决策树":
我要去哪?
├── 一个新的"子页面",按返回能回到当前 → navigateTo
├── 替换当前页(流程页转换) → redirectTo
├── 一个 Tab 页 → switchTab
├── 清空所有,重新开始 → reLaunch
└── 回到 N 层之前 → navigateBack(delta)
核心约束:页面栈深度最多 10 层(微信小程序硬约束)——超过会"路由失败"。所以长流程必须用 redirectTo 替换,而不是层层 navigateTo。
// ❌ 错误:注册流走了 5 步 navigateTo,加上原本 5 层,第 6 步炸了
wx.navigateTo({ url: '/pages/register/step1' })
// ... 用户走到 step5,栈深 10,再 navigateTo 失败
// ✅ 正确:流程页之间用 redirectTo
wx.redirectTo({ url: '/pages/register/step2' }) // 替换 step1
鸿蒙 Router 的设计和小程序高度相似(pushUrl / replaceUrl / clear / back),可以认为是"小程序 + 类型安全"的进化版。
# 7.横向对比矩阵
# 7.1 能力对齐表
| 维度 | Web (React Router) | Android (Navigation) | iOS (UIKit) | iOS (SwiftUI) | Flutter (go_router) | RN (React Navigation) | 小程序 |
|---|---|---|---|---|---|---|---|
| URI 形式 | /order/detail/:id | order/detail/{id} | URL Scheme | NavigationPath | /order/detail/:id | screen + params | pages/xx/xx |
| 栈数据结构 | History stack | NavController | viewControllers [] | NavigationPath | pages [] | StackNavigator | 页面栈(最多10层) |
| 声明式 | ✅ Routes 组件 | ✅ nav_graph.xml | ❌ 命令式 | ✅ NavigationStack | ✅ GoRoute 列表 | ✅ Stack.Screen | ⚠️ app.json 配置 |
| 类型安全 | ⚠️ TS 加持 | ✅ Safe Args | ❌ | ✅ navigationDestination | ⚠️ 字符串路径 | ⚠️ TS 类型 | ❌ |
| 路由守卫 | ✅ loaders + guards | ⚠️ listener | ❌(Coordinator 自实现) | ❌(手动) | ✅ redirect | ⚠️ listener | ⚠️ App 级钩子 |
| 深链 | ✅ 天然 URL | ✅ <deepLink> | ✅ URL Scheme + Universal | ✅ Codable Path | ✅ uriPattern | ✅ linking 配置 | ✅ URL Scheme |
| 转场动画 | ✅ CSS 完全可控 | ✅ NavOptions.anim | ✅ 自定义 transition | ⚠️ 系统默认+部分自定义 | ✅ pageBuilder | ✅ animation 配置 | ⚠️ 系统默认 |
| 栈状态可恢复 | ✅ 浏览器历史 | ✅ SavedStateHandle | ⚠️ 状态恢复 API | ✅ Codable | ✅ RouteInformation | ✅ persistKey | ⚠️ enterOptions |
# 7.2 设计哲学站位
「命令式 push/pop」
▲
UIKit ● │ ● Navigator 1.0
│
小程序 ● │ ● Intent
│
─────────────────────────────────────── 老派 vs 现代
│
ARouter ● │ ● React Router v5
│
● Jetpack Navigation
│
● Compose Navigation
│
│ ● go_router / React Router v6 loaders
│
SwiftUI NavStack ● ● Next.js App Router
│
▼
「声明式 state-driven」
观察:
- 底部:所有现代框架都在向"声明式 + 数据驱动"汇聚——栈是数据,UI 是渲染
- 顶部:传统命令式 API 仍在大量存在(旧项目维护成本)——理解它们也是必修课
- 左侧:Android 阵营走"声明式 XML/DSL"
- 右侧:Web/SwiftUI 阵营走"声明式 + 类型安全"
- 未来终点:URL = 文件系统 = 数据源 = 组件树(Next.js App Router)
# 8.反模式与陷阱
本节是"血泪汇总"——每一个反模式都对应一个真实事故。
# 8.1 硬编码跳转
症状:
// 工程里 200 处这种代码
startActivity(Intent(this, OrderDetailActivity::class.java).apply {
putExtra("id", id)
putExtra("from", "list")
})
问题:
- 改一个类名要全工程修改——OrderDetailActivity 改名 OrderDetailActivityV2,编译期不报错(字符串 key),运行时全崩溃
- 跨模块不能跳——模块 A 想跳模块 B,必须 import B
- 无法做埋点 / AB 测试 / 拦截——分散在各处,无切面
修复:建立全局路由表(§4.4 ARouter / §3.2 React Router routes 数组)。
# 8.2 大对象传参
症状:
intent.putParcelableArrayListExtra("products", productList) // 5000 个 Product 对象
触发条件:
- Android Binder 单次事务上限 1MB(实际 < 800KB 才安全)
- 超过抛
TransactionTooLargeException,Crashlytics 后台才看得到
修复:
| 数据大小 | 方案 |
|---|---|
| < 200 字符 | URI Query 参数 |
| < 100KB | Bundle / Intent Extras |
| 100KB ~ 10MB | 中转容器(单例缓存 + key) |
| > 10MB | 临时文件 + 文件路径(参考 5.6 IPC) |
// ✅ 中转容器方案
object DataBus {
private val cache = mutableMapOf<String, Any>()
fun put(value: Any): String {
val key = UUID.randomUUID().toString()
cache[key] = value
return key
}
fun <T> take(key: String): T? = cache.remove(key) as? T // ★ 一次性,自动释放
}
// 跳转方
val key = DataBus.put(productList)
intent.putExtra("dataKey", key)
// 目标方
val products: List<Product>? = DataBus.take(intent.getStringExtra("dataKey")!!)
# 8.3 栈污染
症状 1:同页面重复入栈
Home → Detail(id=1) → User → Detail(id=2) → User → Detail(id=3) → ...
栈深度爆炸,按返回十几次都退不出去
修复:根据业务语义选择 launchMode 或 popUpTo:
// Jetpack Navigation:跳转时清掉栈顶到指定页之间的所有
findNavController().navigate(
R.id.detail,
args,
navOptions {
popUpTo(R.id.home) { inclusive = false } // 保留 home,清掉中间所有
}
)
症状 2:死循环跳转
LoginGuard 检查:未登录 → 跳 Login 页
Login 页 onCreate 检查:已登录 → 跳 Home → LoginGuard → ... 死循环
修复:拦截器一定要有"豁免页面"列表:
class LoginInterceptor : IInterceptor {
private val exemptPaths = setOf("/login", "/register", "/forgot")
override fun process(postcard: Postcard, callback: InterceptorCallback) {
if (postcard.path in exemptPaths || UserManager.isLoggedIn()) {
callback.onContinue(postcard)
} else {
// ... 跳登录
}
}
}
# 8.4 深链未鉴权
症状(参考 §0 案例):
攻击者构造:myapp://order/detail?id=别人的订单ID
→ 直接进入详情页,看到他人订单
这是 IDOR(不安全的直接对象引用)漏洞——OWASP Top 10 的高危项。
修复:所有"敏感页面"必须在路由拦截器里做归属/权限校验:
@Interceptor(priority = 10)
class OwnershipInterceptor : IInterceptor {
override fun process(postcard: Postcard, callback: InterceptorCallback) {
when (postcard.path) {
"/order/detail", "/pay" -> {
val orderId = postcard.extras.getString("id")
if (orderId != null && !OrderRepo.belongsToCurrentUser(orderId)) {
// ★ 拒绝,跳到 403 页
ARouter.getInstance().build("/403").navigation()
callback.onInterrupt(null)
return
}
}
}
callback.onContinue(postcard)
}
}
铁律:任何 URI 参数都视为"用户可控输入"——必须服务端二次校验,绝不可信客户端的"已登录用户 = 已授权"。
# 8.5 模块编译墙
症状:
:app
├── :order (依赖 :pay, :user)
├── :pay (依赖 :order, :user) ⚠️ 循环依赖
└── :user
为什么这是反模式:
- 改 :user 的任何代码,:order 和 :pay 都要重新编译——单次构建 5 分钟
- 单元测试要拉起整个依赖链
- 无法做"独立模块发布"(业务台、SDK 化)
修复:模块化路由(§4.4)
:app
├── :router-api ← 只有接口和路由协议
├── :order → depends on :router-api
├── :pay → depends on :router-api
└── :user → depends on :router-api
模块之间不再互相 import,全部通过 :router-api 的字符串路由协议交互
这就是为什么"路由系统"是组件化的基石——没有路由,组件化就是伪命题。
# 9.现代演进
2020 年之后,路由设计有四个共同演进方向:类型安全、声明式、深链统一、状态可恢复。这四个方向不是孤立的——它们最终汇聚到同一个终极目标:让"路由"成为应用状态的一部分,可序列化、可推导、可测试。
# 9.1 类型安全
老派写法:
intent.putExtra("id", "12345") // key 拼错了?运行时才知道
intent.putExtra("amount", 100) // 类型不对?运行时才知道
intent.getStringExtra("ID") // 大小写错了?返回 null,NPE
现代写法 1:Kotlin Sealed Class + Type-Safe Args
// Compose Navigation 2.8+ 的 Type-Safe Routes
@Serializable
sealed class Route {
@Serializable data object Home : Route()
@Serializable data class OrderDetail(val id: String, val from: String = "list") : Route()
@Serializable data class Pay(val orderId: String, val amount: Long) : Route()
}
NavHost(navController, startDestination = Route.Home) {
composable<Route.Home> { HomeScreen() }
composable<Route.OrderDetail> { entry ->
val route: Route.OrderDetail = entry.toRoute()
OrderDetailScreen(route.id, route.from)
}
}
// ★ 跳转——参数和类型完全编译期校验
navController.navigate(Route.OrderDetail(id = "12345"))
现代写法 2:TypeScript Discriminated Union
type Route =
| { kind: 'home' }
| { kind: 'orderDetail'; id: string; from?: 'list' | 'push' }
| { kind: 'pay'; orderId: string; amount: number }
function navigate(route: Route) { /* ... */ }
navigate({ kind: 'orderDetail', id: '123' }) // ★ TS 编译期校验
navigate({ kind: 'orderDetail' }) // ❌ 编译失败:缺少 id
类型安全的本质:把"路由错误"从"运行时崩溃"提前到"编译期报错"——这是工程化的核心收益。
# 9.2 声明式路由
老派的"命令式路由":
状态变化 ──→ 调用 push/pop ──→ 栈变化 ──→ UI 渲染
(开发者要保证 状态 和 栈 同步——经常忘记)
现代的"声明式路由":
状态变化 ──→ UI 自动渲染(栈是状态的一部分)
URL 变化 ──→ 状态变化 ──→ UI 自动渲染
(栈、UI、URL 三者由数据驱动,自动同步)
典型实现:
| 框架 | 声明式 API |
|---|---|
| React Router v6 | <Routes><Route> JSX 树 + useNavigate |
| SwiftUI | NavigationStack(path: $path) + navigationDestination |
| Flutter | pages: [...] + Navigator 2.0 / go_router |
| Compose | NavHost { composable() } |
| Next.js App Router | 文件系统路径 = URL |
记住一句话:声明式路由 = "栈是数据的投影,不是开发者手动操作的副产品"。这一步走通了,深链、状态恢复、URL 同步都是自动获得的副产品。
# 9.3 深链三件套
深链是"路由系统的对外接口"——让 App 内的页面可以被 App 外的链接直达:
| 类型 | 平台 | 触发方式 | 优劣 |
|---|---|---|---|
| URL Scheme | iOS/Android | myapp://order/detail | ⚠️ 可被其他 App 抢注、无 fallback、微信屏蔽 |
| Universal Link | iOS | https://example.com/order | ✅ 真实域名 + apple-app-site-association 文件,未装 App 走 H5 |
| App Links | Android | https://example.com/order | ✅ assetlinks.json 验证,autoVerify=true 可独占 |
| Intent Scheme | Android | intent://...#Intent;...end | 浏览器拉起 App,配合 fallback URL |
最佳实践组合:
分享链接:https://example.com/order/detail?id=123
│
┌───────────────┴───────────────┐
装了 App 未装 App
│ │
Universal Link / App Links 在浏览器打开 H5 页
│ │
直接打开 OrderDetailVC 引导下载/扫码
深链的安全铁律(呼应 §8.4):
- 深链参数必须二次校验——别相信 URL 里的 userId、amount
- 敏感页面必须经过登录拦截器——直达付款页 = 灾难
- 保留"首页"在回退栈底——别让用户从深链进来后按返回直接退出 App
# 9.4 路由状态恢复
呼应 5.7 章「组件生命周期」的进程死亡——Android/iOS 在内存吃紧时会回收后台应用进程,恢复时要能"无感"重建用户当前所在页面。
Web 的天然优势:URL = 状态。用户从 /order/detail?id=123 离开,回来时 URL 还在,刷新就重建。
App 端需要主动设计:
// Android:SavedStateHandle + Compose Navigation
@Composable
fun OrderDetailScreen(
viewModel: OrderDetailViewModel = hiltViewModel()
) {
// ★ SavedStateHandle 自动保存 NavArgs,进程恢复自动重建
val orderId = viewModel.orderId // 从 SavedStateHandle 拿
// ...
}
// iOS:SceneDelegate 持久化栈
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
// ★ 把当前栈编码为 NSUserActivity
let activity = NSUserActivity(activityType: "com.example.routePath")
activity.userInfo = ["path": currentNavigationPath.encoded]
return activity
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
// ★ 进程恢复时重建栈
if let pathData = userActivity.userInfo?["path"] as? Data {
navigationPath = NavigationPath.decode(pathData)
}
}
}
这就是"声明式路由"最大的现代收益——栈即数据,数据即可序列化,可序列化即可恢复。
# 10.心智与哲学
# 10.1 跨端术语对照
| 概念 | 命令式术语 | 声明式术语 | 本质 |
|---|---|---|---|
| 路由表 | 散落的 startActivity | Routes / nav_graph / GoRouter | 字符串 URI → 页面的映射 |
| 栈 | viewControllers / Task | path / pages / history | LIFO 数据结构(可序列化) |
| 跳转 | push / startActivity | navigate(to) / state update | 修改"栈数据",UI 自动响应 |
| 回退 | pop / finish / back | navigate(-1) / pop state | 弹出栈顶,触发回调 |
| 守卫 | 自定义检查代码 | beforeEach / redirect / loader | 拦截器责任链 |
| 深链 | URL Scheme + manifest | uriPattern / linking config | 外部字符串 → 内部路由的映射 |
| 状态恢复 | SavedInstanceState | URL / Codable path | 栈数据持久化 + 重建 |
| 转场 | overridePendingTransition | NavOptions.anim / Transition | 栈状态变化的可视化呈现 |
# 10.2 本卷章节呼应
路由不是一个孤立的设计——它贯穿了整本第 5 卷:
| 章节 | 与路由的关系 |
|---|---|
| 5.1 窗口设计 | 路由的"载体"——每个 Activity / VC / Window 都是路由栈中的一个节点 |
| 5.2 视图加载 | 路由跳转的"内部动作"——push 即触发新 VC 的 viewDidLoad / onCreateView |
| 5.3 图形渲染 | 转场动画的实现层——CALayer / Compositor 负责"两屏过渡"的合成 |
| 5.4 手势事件 | 路由的"输入入口"——返回手势、抽屉手势都是路由的触发器 |
| 5.5 消息机制 | 路由内部依赖——所有跳转都通过主线程消息队列调度 |
| 5.6 跨进程通信 | 跨 App 跳转的底层——Intent 走 Binder、URL Scheme 走 PackageManagerService |
| 5.7 生命周期 | 路由的"时间维度"——push/pop 触发新页生命周期开始、旧页生命周期暂停/结束 |
| 5.9 响应式数据 | 路由参数 → ViewModel → UI 的数据流入口 |
| 5.10 数据加密 | 深链参数的安全防护——签名、加密、防篡改 |
核心观察:路由是第 5 卷所有篇章的"连接器"——它把窗口、视图、生命周期、手势、消息、IPC 全部串起来,让用户的"点一下"变成"换一个世界"。
# 10.3 通用心智口诀
记住这七句,所有平台的路由设计都能融会贯通:
- 路由 = 字符串 URI + 栈 + 参数 + 拦截器 + 转场——五件套缺一不可
- 栈是 LIFO 数据结构,不是树、不是图——所有"返回键"都是 pop
- 能 URI 化的就别用 Bundle——URI 可分享、可恢复、可拦截
- 大对象(>100KB)绝不走 Intent/Bundle——用 MMAP / 临时文件 / 中转容器
- 拦截器是权限网关——登录、归属、埋点、AB 全在这里
- 声明式路由是终局——栈即数据,数据驱动 UI,自动同步 URL
- 深链参数永远不可信——必须二次校验,IDOR 是高频漏洞
# 最终升华
路由的本质不是"跳页面",而是「把『用户当前所在何处』这件事,从『内存中的对象引用』提升为『可序列化的字符串协议』,让 App 的每一个角落都可被分享、被恢复、被拦截、被埋点」。
- 2010 年代:Activity + Intent,开发者手写跳转代码
- 2015 年代:ARouter / React Router,路由表登场,模块解耦
- 2020 年代:Jetpack Navigation / SwiftUI NavigationStack / go_router,声明式革命,栈即数据
- 2025 年代:Server Components + 文件系统路由,URL = 文件 = 数据 = 组件
走过这一段历史,回头看就懂了——所有路由框架都在做同一件事:让"位置"成为一等公民。位置是数据,位置可序列化,位置可拦截,位置可恢复——这就是路由的设计哲学,也是现代应用架构的灵魂。
本篇结——下一篇 5.9 响应式数据绑定设计 (opens new window) 我们将看到,当"位置"变成数据之后,"数据"本身又如何反过来驱动 UI——这是 SwiftUI/React/Vue/Compose 共同走向的"声明式终局"。