Chapter 10

走到线上,这些细节不能漏

基础跑通只是第一步。真正的 App 还要面对埋点、AB、性能、可访问性、错误追踪——本章把线上 App 的这些实战要点集中过一遍。

路由组织的经验

app/
├── _layout.tsx                 ← 全局 Provider + 守卫
├── +not-found.tsx
├── +native-intent.ts           ← 自定义 deep link 解析(可选)
│
├── (auth)/                     ← 未登录区
│   ├── _layout.tsx
│   ├── login.tsx
│   ├── register.tsx
│   └── forgot.tsx
│
├── (app)/                      ← 登录态区
│   ├── _layout.tsx
│   ├── (tabs)/                 ← 底部栏
│   │   ├── _layout.tsx
│   │   ├── home/
│   │   │   ├── _layout.tsx
│   │   │   ├── index.tsx
│   │   │   └── [id].tsx
│   │   ├── search/
│   │   └── profile.tsx
│   ├── settings/               ← tab 外的深层页(带 header)
│   │   ├── _layout.tsx
│   │   ├── index.tsx
│   │   ├── account.tsx
│   │   └── billing.tsx
│   └── product/
│       └── [id].tsx            ← 公共详情(多入口跳转)
│
└── api/                        ← Server Routes(可选)
    └── webhook+api.ts

经验:

埋点:监听导航事件

// app/_layout.tsx
import { useSegments, usePathname } from 'expo-router';
import { useEffect } from 'react';
import analytics from '../lib/analytics';

function Analytics() {
  const pathname = usePathname();
  const segments = useSegments();

  useEffect(() => {
    analytics.track('screen_view', {
      pathname,
      group: segments[0],       // (auth) / (app)
    });
  }, [pathname]);

  return null;
}

所有页面切换会自动触发——无需在每个屏手写。Amplitude、Mixpanel、PostHog 都能这样接。

错误边界

Expo Router 内置 ErrorBoundary——在 _layout.tsx 同级 export 一个叫 ErrorBoundary 的组件,路由内的错误会被它兜住:

// app/_layout.tsx
export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
  return (
    <View>
      <Text>出错了:{error.message}</Text>
      <Pressable onPress={retry}><Text>重试</Text></Pressable>
    </View>
  );
}

export default function RootLayout() { ... }

Sentry / 错误上报

npx expo install sentry-expo @sentry/react-native
// app/_layout.tsx
import * as Sentry from 'sentry-expo';

Sentry.init({
  dsn: 'https://xxx@sentry.io/yyy',
  enableInExpoDevelopment: false,
  debug: __DEV__,
  tracesSampleRate: 0.1,
});

export default Sentry.Native.wrap(RootLayout);

Sentry 会自动关联 OTA update-id、设备信息、breadcrumb(路由切换、fetch 请求),崩溃能还原到具体路由。

A/B 与 Feature Flag

import { useFeatureFlag } from '../lib/flags';

export default function Home() {
  const enabled = useFeatureFlag('new-home-layout');
  return enabled ? <NewHome /> : <OldHome />;
}

推荐 GrowthBook / Statsig / LaunchDarkly 客户端 SDK——分桶、实验结果和 Mixpanel 打通。

性能:避免不必要的重渲染

useLocalSearchParams 优于 useGlobalSearchParams
前者只在本屏参数变时重渲,后者会在任何父子路由参数变时重渲。
用 FlashList 替 FlatList
@shopify/flash-list 回收更聪明,长列表滑动 60fps 不卡。API 兼容。
图片用 expo-image
内存缓存、磁盘缓存、placeholder 一站式,替换 Image 后滚动 jank 显著下降。
Reanimated 跑动画
用 worklet 在 UI 线程运行,不阻塞 JS 线程。RN 原生 Animated API 只在小动画下够用。

启动时间优化

// app/_layout.tsx —— lazy 加载重组件
import { lazy, Suspense } from 'react';
const Chart = lazy(() => import('../components/Chart'));

<Suspense fallback={<ActivityIndicator />}>
  <Chart />
</Suspense>

Expo Router v4 会自动按路由切分 bundle,冷启动不加载全部屏幕代码。你要做的是把路由内的「大组件/重依赖」再 lazy 一次。

可访问性(a11y)

<Pressable
  accessibilityRole="button"
  accessibilityLabel="购买,价格 99 元"
  accessibilityHint="双击打开结算页"
  onPress={buy}
>
  <Text>立即购买</Text>
</Pressable>

iOS VoiceOver、Android TalkBack 会读这些。图标按钮必须给 label;动态状态(已收藏 vs 未收藏)用 accessibilityState

国际化

pnpm add i18next react-i18next expo-localization
import i18n from 'i18next';
import { initReactI18next, useTranslation } from 'react-i18next';
import * as Localization from 'expo-localization';

i18n.use(initReactI18next).init({
  lng: Localization.getLocales()[0].languageCode,
  fallbackLng: 'en',
  resources: { zh: { ... }, en: { ... } },
});

const { t } = useTranslation();
<Text>{t('home.greeting', { name: user.name })}</Text>

测试

单测:jest-expo
组件测试用 @testing-library/react-native。路由部分:expo-router/testing-library 提供 renderRouter
E2E:Maestro
Maestro flow YAML 一小时上手,跑在模拟器/真机。iOS + Android 同一套脚本。
视觉回归:Chromatic(Web) / EAS Snapshot
三端共用的 UI 用 Storybook + Chromatic 在 Web 上做视觉对比。

常见大型 App 问题

导航栈爆炸
用户疯狂点详情→列表→详情,栈深度无限增加。对 tab 内用 router.replace 限深;或深层用 router.dismissTo('/home') 一次弹多层。
Android 返回键语义
Android 返回键默认走 router.back。根屏按返回默认退出 App,如需「确认退出」,用 BackHandler + Alert。
iOS 手势冲突
全屏轮播 + 手势返回冲突时,对屏幕设 fullScreenGestureEnabled: false,或屏内 GestureHandlersimultaneousHandlers 协调。
键盘遮罩输入框
KeyboardAwareScrollViewreact-native-keyboard-controller 让页面自动滚。

EAS Update 灰度

# 把 10% 的生产用户切到 experiment branch
eas channel:edit production --branch-mapping \
  '[{"branchId":"experiment-id","rolloutPercentage":10},{"branchId":"stable-id"}]'

出问题直接回到 100% stable,几分钟见效。

发布清单

Expo Router 的未来

结语

十章读完,你应该能从 npx create-expo-app 造出一个自带 Stack/Tabs/Drawer、支持 Universal Links、三端运行、EAS 打包 + OTA 发布的完整产品。

Expo Router 做的其实只是一件事:让 Native 开发拥有 Web 级别的开发速度。配合 Expo 全家桶,一两个人做出一个有模有样的跨端 App,这在 2020 年还是不可想象的事。

写代码的乐趣之一,就是见证工具持续化繁为简。2026 年的 RN 生态,真的不再是那个「iOS 一套 Android 一套心智分裂」的时代了。