导航(React Navigation 与 Expo Router)
移动端没有地址栏,导航的本质是维护一个返回栈:用户在屏幕之间跳转、系统返回键/边缘滑动回退、外部链接直达某个页面,全靠这套栈结构撑起来。本手册基线是 React Native 0.88 / Expo SDK 58,导航层有两条主线:
- React Navigation(社区标准,bare 和 Expo 项目通用,显式代码配置)——v7 世代,写作时
@react-navigation/nativelatest 为 7.5.1 - Expo Router(文件路由,Expo 项目官方默认)——版本已与 Expo SDK 对齐,SDK 58 对应 58.x;底层就是用 react-navigation 的原语搭的,学会一条线,另一条是换皮
两条线都会写透,但建议按项目形态选定一条精读:维护 bare RN 项目或被要求不绑定 Expo,先读 React Navigation 部分;新 Expo 项目可以直接从 Expo Router 上手,再回头补 React Navigation 的心智模型——因为报错信息、深度定制、状态迁移最终都会落到 react-navigation 的概念上。
选型:该用哪个
先说 honest 结论。
用 Expo 且没有特殊诉求,选 Expo Router。 文件路由和 Next.js App Router 心智一致,路由即 URL,深链接配置几乎免费;typed routes、平台分流文件等约定都在往它倾斜,SDK 58 的亮点之一就是原生标签栏(native tabs)的核心改进。
维护 bare RN 项目,或团队需要完全显式的导航树控制,选 React Navigation。 它不绑定任何构建工具链,所有导航结构都写在代码里,review 时一眼看清,社区资料和招聘认知度也更高。
不要被"Expo Router 是不是淘汰了 React Navigation"误导——Expo Router 内部仍然创建 NavigationContainer、native stack、tab navigator,只是把 JSX 声明的导航树换成了文件系统约定。React Navigation 的知识在 Expo Router 项目里九成不作废。
React Navigation v7 完整上手
安装依赖链
以 Native Stack(真正调用 iOS UINavigationController / Android Fragment 的栈)为例:
npm install @react-navigation/native @react-navigation/native-stack
npm install react-native-screens react-native-safe-area-context依赖关系值得说清楚,装报错时你能自己推断原因。@react-navigation/native-stack 把屏幕管理委托给原生,所以必须有 react-native-screens——这是名字里 native 的代价,也是性能来源:未激活的屏幕不持有 JS 侧渲染树。react-native-safe-area-context 负责刘海屏、圆角、系统栏的 insets,edge-to-edge 时代 header 定位靠它。两个原生库都会被自动链接带上。
Expo 项目里这两个库由 SDK 统一管理版本,不要 npm install react-native-screens@x.y.z 手动钉版本,用 npx expo install react-native-screens 让 SDK 挑匹配版本,否则 OTA 更新和原生构建会版本错位。
最小可运行示例:列表 → 详情带参数
// App.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { FlatList, Pressable, Text, View, StyleSheet } from 'react-native';
import type { RootStackParamList } from './src/navigation/types';
const Stack = createNativeStackNavigator<RootStackParamList>();
const ARTICLES = [
{ id: '1', title: 'GMP 调度演进' },
{ id: '2', title: '切片底层原理' },
{ id: '3', title: 'Context 取消语义' },
];
function HomeScreen({ navigation }: { navigation: any }) {
return (
<FlatList
data={ARTICLES}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Pressable
onPress={() => navigation.navigate('Details', { id: item.id })}
style={({ pressed }) => [styles.row, pressed && styles.pressed]}
>
<Text style={styles.title}>{item.title}</Text>
</Pressable>
)}
/>
);
}
function DetailsScreen({ route }: { route: any }) {
const article = ARTICLES.find((a) => a.id === route.params.id);
return (
<View style={styles.container}>
<Text style={styles.title}>{article?.title ?? '未找到'}</Text>
</View>
);
}
export default function App() {
return (
<NavigationContainer>
<Stack.Navigator initialRouteName="Home">
<Stack.Screen name="Home" component={HomeScreen} options={{ title: '文章列表' }} />
<Stack.Screen name="Details" component={DetailsScreen} options={{ title: '详情' }} />
</Stack.Navigator>
</NavigationContainer>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 16 },
row: { padding: 16, borderBottomWidth: StyleSheet.hairlineWidth },
pressed: { backgroundColor: '#f0f0f0' },
title: { fontSize: 16 },
});三个必须记住的点:NavigationContainer 是根组件,负责导航状态、主题和深链接解析,整个 App 只能有一个;createNativeStackNavigator() 返回的对象用 <Stack.Navigator> / <Stack.Screen> 声明导航树;参数通过 navigate 的第二个入参传,目标屏幕从 route.params 读。(示例里两处 any 是故意的,下一节类型化会替换掉。)
TypeScript 类型化写法
集中声明参数表,再让所有 API 吃泛型:
// src/navigation/types.ts
export type RootStackParamList = {
Home: undefined; // 无参数
Details: { id: string }; // 必传
Settings: { tab?: 'general' | 'privacy' }; // 可选参数
};// src/navigation/index.tsx
import React from 'react';
import { Pressable } from 'react-native';
import {
useNavigation,
useRoute,
type RouteProp,
} from '@react-navigation/native';
import {
createNativeStackNavigator,
type NativeStackScreenProps,
type NativeStackNavigationProp,
} from '@react-navigation/native-stack';
import type { RootStackParamList } from './types';
const Stack = createNativeStackNavigator<RootStackParamList>();
// 方式一:组件被 Screen 直接渲染时,用 ScreenProps 标注签名
type HomeProps = NativeStackScreenProps<RootStackParamList, 'Home'>;
function HomeScreen({ navigation }: HomeProps) {
// navigate('Details') 不带 { id } 在这里直接编译报错
navigation.navigate('Details', { id: '1' });
return null;
}
// 方式二:hooks 泛型。组件不是直接挂在 Screen 上、或想减少 prop 透传时
function DetailsButton() {
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, 'Details'>>();
return (
<Pressable onPress={() => nav.push('Details', { id: route.params.id })}>
{null}
</Pressable>
);
}
// 把根栈参数表注册到全局,useNavigation() 不写泛型也能推断根栈屏幕名
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}declare global 那段值得单独说明:注册后大部分根栈屏幕的 useNavigation() 可以省泛型;但嵌套 navigator(比如 tab 内嵌的 stack)的屏幕名不在根表里,仍要显式传泛型。类型化一次投入,之后改名、挪参数、漏参数全是编译期报错,值得在第一天就做。
切换 API 的语义区别
这几个方法行为差异很大,混用是事故源头:
navigate('Details', { id }):目标屏幕如果已在栈里,就回到那个实例并销毁它上面的屏幕;不在才入栈。同屏二次navigate只更新参数,组件不会重新挂载——这直接关联"navigate 不触发 useEffect"大坑,见坑清单。push:无条件入栈,同一屏幕可以在栈里出现多份(评论 A → 用户主页,评论 B → 用户主页)。pop()/pop(n):出栈一或多层,回到上一页。replace('Details', { id }):用新屏幕替换当前屏幕,栈深度不变。登录成功跳首页应当用它。reset({ index: 0, routes: [{ name: 'Home' }] }):整栈重建。退出登录后回到未登录栈的标准动作。setOptions({ title }):屏幕内动态改 header。goBack()/canGoBack():回退与判断。
function LoginScreen() {
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
async function onLogin() {
await doLogin();
// 清掉整条栈:返回键永远回不到登录页
nav.reset({ index: 0, routes: [{ name: 'Home' }] });
}
return null;
}注意 登录成功后
navigate('Home')再想办法清栈是常见反模式——navigate会留下栈残留,偶现返回键复活登录页。正确姿势是先切认证状态让导航树自己重排(见认证流一节),或者干脆replace/reset。
header 定制与返回手势
screenOptions 给全局默认,options 做单屏覆盖,options 传函数时能拿当前 route 动态生成:
<Stack.Navigator
screenOptions={{
headerShadowVisible: false,
headerStyle: { backgroundColor: '#ffffff' },
headerTintColor: '#00aa77',
contentStyle: { backgroundColor: '#fafafa' },
}}
>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen
name="Details"
component={DetailsScreen}
options={({ route }) => ({ title: `文章 ${route.params.id}` })}
/>
<Stack.Screen
name="Video"
component={VideoScreen}
options={{
headerShown: false, // 全屏播放:隐藏 header
gestureEnabled: false, // 有未保存内容时禁用手势,交给 BackHandler 拦截
headerRight: () => <ShareButton />,
}}
/>
</Stack.Navigator>iOS 侧滑返回由原生 stack 免费提供(native-stack 的 gestureEnabled 默认开启),Android 端返回动画跟随系统行为,新版本系统正在推进预测式返回(predictive back)动画,应用侧现阶段仍用 BackHandler 接管即可(见平台细节)。需要"编辑页有未保存内容,禁止直接划走"时,置 gestureEnabled: false 并在返回拦截里弹确认。
bottom-tabs:图标方案与嵌套结构
npm install @react-navigation/bottom-tabsimport Ionicons from '@expo/vector-icons/Ionicons';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
const Tabs = createBottomTabNavigator<MainTabParamList>();
function MainTabs() {
return (
<Tabs.Navigator
screenOptions={({ route }) => ({
headerShown: false,
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={
route.name === 'Feed'
? focused ? 'home' : 'home-outline'
: route.name === 'Profile'
? focused ? 'person' : 'person-outline'
: 'search'
}
size={size}
color={color}
/>
),
})}
>
<Tabs.Screen name="Feed" component={FeedStack} />
<Tabs.Screen name="Search" component={SearchScreen} />
<Tabs.Screen name="Profile" component={ProfileScreen} />
</Tabs.Navigator>
);
}图标方案的现实选择:Expo 项目直接用 @expo/vector-icons(SDK 自带,字体已配好);bare 项目用 react-native-vector-icons 要在 react-native.config.js 里处理字体自动链接,偶有踩坑,更省事的替代是用 react-native-svg 直接渲染 SVG 组件,图标少的时候甚至可以用文字。tab 切换保持各屏挂载,图标本身的重渲染成本可以忽略。
嵌套结构是新手最容易摆反的东西。推荐的标准结构:根是 native stack,Tabs 作为其中一个屏幕,需要盖住 tab 栏的详情页放外层 stack——从任何 tab 进详情,详情都盖在整个 tab 栏之上(主流 App 的视觉),返回键先退详情、再回到 tab:
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Main" component={MainTabs} options={{ headerShown: false }} />
<Stack.Screen name="ArticleDetails" component={ArticleDetailsScreen} options={{ title: '文章' }} />
</Stack.Navigator>
</NavigationContainer>反过来,“每个 tab 内部嵌自己的小 stack”(Feed tab 的二级页不遮挡 tab 栏)也是常见需求,两种嵌套可以叠加:上例 MainTabs 里 FeedStack 就是 tab 内嵌 stack 的写法。选择原则一句话:要盖住 tab 栏的屏幕压在外层,不盖的压在内层。
抽屉导航一句带过:@react-navigation/drawer 提供 createDrawerNavigator,手势依赖 react-native-gesture-handler(见 07-动画与手势.md);国内 App 里抽屉出现频率远低于 tabs + stack,不展开。
参数与深链接(Deep Linking)
外部 URL 直达应用内屏幕,靠 NavigationContainer 的 linking 配置:
import { Linking } from 'react-native';
import type { LinkingOptions } from '@react-navigation/native';
const linking: LinkingOptions<RootStackParamList> = {
prefixes: ['myapp://', 'https://share.example.com'],
config: {
screens: {
Details: 'article/:id', // 对应 RootStackParamList 里的 screen 名
Home: 'home',
},
},
};
// App.tsx
<NavigationContainer linking={linking} fallback={<SplashScreen />}>
{/* ... */}
</NavigationContainer>;
// 应用内也可以用同一套 URL 跳转
Linking.openURL('myapp://article/42');URL 匹配规则:article/:id 里的 :id 会变成 route.params.id;支持可选参数 article/:id?、通配 files/*;?tab=general 这类查询串自动并入 params。冷启动时 NavigationContainer 要等 URL 解析完才知道渲染哪棵树,fallback 就是这期间显示的占位——不给 fallback 会白屏闪一下再跳目标页,体验很差。
为什么 App Links / Universal Links 还要平台侧补配置? 上面这套只解决"应用收到 URL 后路由到哪个屏幕"。而 https:// 链接默认会被浏览器抢走——想让系统直接信任你的域名并唤起 App,必须证明域名归你所有:
- Android App Links:在域名根目录发布
/.well-known/assetlinks.json,内容是应用包名 + APK 签名的 SHA-256 指纹;Manifest 的 intent-filter 带android:autoVerify="true"。 - iOS Universal Links:Xcode 给 target 加 Associated Domains capability(
applinks:share.example.com),域名侧放apple-app-site-association文件(含 Team ID + bundle id)。
也就是说 deep linking 是应用内路由 + 域名侧信任两件事,缺一半都不通。自定义 scheme(myapp://)没有域名信任问题但会被多个 App 抢注,生产环境两条都要配。Expo 项目在 app.json 里配 scheme;用 EAS Build 可以自动生成并托管上述关联文件,这是托管构建的隐藏福利之一。
认证流模式
官方推荐(也是社区公认)的模式:不要条件声明 Screen 的有无,而是条件渲染 Screen 内容——更准确说,是让导航树的结构由认证状态推导:
import React, { createContext, useContext } from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
type Auth = { isSignedIn: boolean; signIn: () => void; signOut: () => void };
const AuthContext = createContext<Auth>({
isSignedIn: false,
signIn: () => {},
signOut: () => {},
});
const RootStack = createNativeStackNavigator();
function RootNavigator() {
const { isSignedIn } = useContext(AuthContext);
return (
<NavigationContainer>
<RootStack.Navigator screenOptions={{ headerShown: false }}>
{isSignedIn ? (
<>
<RootStack.Screen name="Main" component={MainTabs} />
<RootStack.Screen name="ArticleDetails" component={ArticleDetailsScreen} />
</>
) : (
<>
<RootStack.Screen name="Welcome" component={WelcomeScreen} />
<RootStack.Screen name="SignIn" component={SignInScreen} />
</>
)}
</RootStack.Navigator>
</NavigationContainer>
);
}isSignedIn 翻转时,react-navigation 检测到导航树结构变化会重置栈到对应分支——所以登录成功只需要 signIn() 切状态,不需要也不应该再手工 navigate('Main');登出同理,signOut() 之后用户永远回不到已登出的屏幕树。
反模式自查:在 login() 里先 navigate('Main')、再在 Main 挂载时想办法清栈——时序依赖,偶现返回键复活登录页;条件渲染写了但 Screen 的 name/结构不成套变化——导航状态错乱。原则一句话:栈的形态由状态推导,而不是由命令拼出来。 令牌怎么持久化、App 启动时"恢复登录态 or 进登录页"的时序,见 08-存储与文件.md。
状态保持与焦点感知
导航状态持久化的思路一句话:NavigationContainer 提供 initialState 与 onStateChange(state),把 state 序列化进 AsyncStorage,冷启动时传回 initialState,用户就能回到上次浏览的屏幕。三个注意:state 带版本号字段,升级后能判断旧数据作废;deep link 进入时不要用持久化状态(让 URL 说了算);含 token 路由的 state 不要落盘。存储读写细节见 08-存储与文件.md。
useFocusEffect 和 useEffect 的差别值得掰开讲:useEffect 跟着挂载走,而 tab 切换根本不卸载屏幕,所以来回切 tab 它一次都不重跑;useFocusEffect 跟着屏幕获得/失去焦点走,每次进入都执行,离开时执行返回的清理函数:
import React, { useCallback } from 'react';
import { useFocusEffect } from '@react-navigation/native';
function FeedScreen() {
useFocusEffect(
useCallback(() => {
refreshFeed(); // 每次聚焦都拉新,等价 focus 版 componentDidMount
const sub = pushChannel.subscribe(onNotification);
return () => {
sub.remove(); // 失焦即清理,等价 componentWillUnmount 的位置
};
}, []),
);
return null;
}它解决的就是"tab 切回来要刷新列表"“进入聊天页把未读清零、离开停止输入中状态"这类需求。数据刷新与缓存失效的完整策略见 05-数据获取与网络.md,tab 保活带来的内存与性能账见 10-调试测试与性能.md。
Expo Router:文件即路由
目录约定
Expo Router 把导航树写进目录,一个 SDK 58 项目的典型 app/:
app/
_layout.tsx # 根布局:声明 Stack;替代手写的 NavigationContainer + Navigator
index.tsx # 根路由 "/"
(auth)/ # group:不进入 URL,相关文件聚在一起
_layout.tsx
sign-in.tsx # "/sign-in"
(tabs)/
_layout.tsx # 声明 Tabs
index.tsx # tab 首页 "/"
search.tsx # "/search"
article/
[id].tsx # 动态段 "/article/:id"
user/
[...slug].tsx # 捕获剩余路径 "/user/a/b/c"
+not-found.tsx # 未匹配路由的兜底屏幕(较新约定)
modal.tsx # 在 layout 里声明 presentation: 'modal' 即卡片弹出三条肌肉记忆:下划线开头的文件是 layout,不是路由;括号 group 只组织代码、不产生 URL 段;方括号是参数段。文件与 URL 一一对应后,React Navigation 那套 linking 配置基本消失——URL 就是路径。
layout:Stack / Tabs 导出
// app/_layout.tsx
import { Stack } from 'expo-router';
import { AuthProvider } from '../../src/auth';
export default function RootLayout() {
return (
<AuthProvider>
<Stack>
<Stack.Screen name="article/[id]" options={{ title: '文章' }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
</Stack>
</AuthProvider>
);
}
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import Ionicons from '@expo/vector-icons/Ionicons';
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen
name="index"
options={{
title: '首页',
tabBarIcon: ({ color, size }) => <Ionicons name="home" size={size} color={color} />,
}}
/>
<Tabs.Screen name="search" options={{ title: '发现' }} />
</Tabs>
);
}注意 Stack.Screen 的 name 是文件路径(article/[id] 这种字面段名),不是人类可读的 screen 名——从 React Navigation 迁过来最常懵的一点。
Link、useRouter、useLocalSearchParams
// app/article/[id].tsx
import { Link, Redirect, useLocalSearchParams, useRouter } from 'expo-router';
import { Pressable, Text, View } from 'react-native';
export default function ArticleScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
if (!id) return <Redirect href="/" />; // 条件重定向,不留在栈里
return (
<View style={{ flex: 1, padding: 16 }}>
<Text>文章 {id}</Text>
{/* 声明式:Link 渲染真实 <a> 语义,asChild 复用你的 Pressable 样式 */}
<Link href="/article/99" asChild>
<Pressable><Text>看下一篇</Text></Pressable>
</Link>
{/* 命令式 */}
<Pressable onPress={() => router.push('/search')}>
<Text>去搜索</Text>
</Pressable>
<Pressable onPress={() => router.dismissAll()}>
<Text>关掉所有模态回到根</Text>
</Pressable>
</View>
);
}与 React Navigation 的心智映射表:
| Expo Router | React Navigation | 说明 |
|---|---|---|
app/_layout.tsx + <Stack> |
NavigationContainer + createNativeStackNavigator |
根栈声明 |
文件路径即 href |
screen name + linking path | 深链接免配置 |
useLocalSearchParams() |
useRoute().params |
本屏参数 |
router.push / replace / back |
navigation.push / replace / pop |
栈操作,语义一致 |
router.dismiss / dismissAll |
popToTop / reset |
关闭模态或回根 |
<Redirect href> |
挂载时 navigation.replace |
重定向不入栈 |
(group) 目录 |
嵌套 navigator | 组织代码不产生 URL |
Stack.Screen options |
screenOptions / options |
header、手势定制完全同源 |
typedRoutes:app.json 里开 "experiments": { "typedRoutes": true }(较新 SDK 中可能已转正常开,以 Expo 文档为准),开启后 href="/articl" 这类手打错误在编译期就被抓住——文件路由最大的隐性收益就在这。认证流与模态:认证用 group + 条件渲染 <Stack.Screen>(或 <Redirect>),思路与上一节完全相同。
平台细节
Android 硬件返回键
BackHandler 是长期稳定的 API,监听 hardwareBackPress,回调返回 true 表示"我已处理,不要执行默认出栈”:
import React, { useEffect } from 'react';
import { BackHandler } from 'react-native';
function useBackGuard(when: boolean, onBack: () => void) {
useEffect(() => {
if (!when) return; // 只在"有未保存内容"等条件下挂拦截
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
onBack(); // 弹"放弃编辑?"确认后自行 pop
return true;
});
return () => sub.remove(); // 卸载必须移除,否则吞掉全局返回键
}, [when, onBack]);
}多个监听者并存时最后注册者优先(本质是栈),所以在 useEffect 里注册、清理函数里 remove 是铁律。Android 新版本系统侧正在推进预测式返回动画,RN 与 react-native-screens 在逐步对齐系统能力——现阶段业务代码按 BackHandler 写就足够正确,动画体验是库在收敛的事,不要自己发明拦截方案。
edge-to-edge 下的 header 与安全区
RN 0.88 的 bare 模板默认 edgeToEdgeEnabled=true,Android 15+ 系统层面也强制应用内容延伸到状态栏/导航栏之后。连锁影响:
- 内容被系统栏遮挡 → 必须用
react-native-safe-area-context:根上包SafeAreaProvider,自定义屏幕用useSafeAreaInsets()补paddingTop/Bottom。react-navigation 的 header 和 tab 栏会自动消化 insets,前提是 SafeAreaProvider 存在——Expo 模板自带,bare 迁移时漏包裹是头号事故。 - 全屏沉浸场景用
headerTransparent: true+ 自定义headerBackground,配合绝对定位头图。 - edge-to-edge 与键盘遮挡的交互(Android 上影响更大)见
06-表单与交互.md。
坑清单
navigate不触发 useEffect:目标屏幕已在栈里时复用同一实例、只更新 params,挂载类 effect 不再跑。每次进入都要执行的逻辑挂useFocusEffect;参数变化要刷新的,把route.params放进依赖数组。- 列表 → 详情传整个对象:params 会被序列化进导航状态(持久化时膨胀、深链恢复时对不上),对象快照还会与列表数据脱节,点进详情看到的可能是旧内容。传
id,详情页自己取数——见05-数据获取与网络.md。 - 全局跳转的 navigationRef 必须建在 App 组件外:模块顶层
const navigationRef = createNavigationContainerRef<RootStackParamList>(),挂到<NavigationContainer ref={navigationRef}>;onReady(或navigationRef.isReady())之前调用navigationRef.navigate(...)会静默失败。推送点击、支付 SDK 回调这类"App 外层发起的跳转"都走这条路。 - tab 屏保持挂载是内存账:每个 tab 的组件树离开后不卸载,列表、图片、播放器、订阅全都活着。重 tab(播放器、地图)要么失焦时用
useFocusEffect释放资源,要么该 tab 干脆卸载重建。怎么量化看10-调试测试与性能.md。 - 在 Navigator 外面调 useNavigation 拿到 undefined:
NavigationContainer之上的组件(典型是 App.tsx 里放了一个登录按钮)没有导航上下文。把需要导航的组件放到 Container 之内,或改用 navigationRef。
实战建议
新项目做决定只需要回答一个问题:构建工具链用不用 Expo。用 Expo 就从 Expo Router 起步,typedRoutes 第一天就开;bare 工程就先把 React Navigation 的 types.ts + 全局 RootParamList 注册落地,再写第一个屏幕。两条线都建议把参数表当作接口契约来 review——屏幕间传参和微服务间传 DTO 是同一种纪律。
导航树宁可多花十分钟在纸上画:栈是共享可变状态,tab 套 stack、group 套 layout,结构一乱,返回键行为、深链接匹配、状态恢复三处一起出问题。排查时先问"这个屏幕此刻在哪一层栈里",而不是怀疑组件逻辑。
发布前过四条:所有 https:// 深链接在两台真机上走一遍(App Links/Universal Links 的域名侧验证没有捷径,assetlinks.json 缓存刷新以小时计);登录态切换后按返回键,确认回不到被登出的树;Android 硬件返回键穿过每个模态和编辑中页面;tab 全开跑十分钟看内存曲线。表单页的键盘遮挡、校验联动与导航的交叉场景,配合 06-表单与交互.md 一起读。