首页 / 文章 / 大规模使用 React Native 时的权衡:状态、列表、设备、令牌与升级问题

大规模使用 React Native 时的权衡:状态、列表、设备、令牌与升级问题

五个能体现工程决策能力的 React Native 问题:服务器端与客户端状态管理、FlatLists 的卡顿问题、低端 Android 设备兼容性、令牌存储方式以及增量升级策略。

1719 词

在 React Native 中构建界面非常简单。但随着应用规模的扩大,问题就出现了:状态到处蔓延、列表加载卡顿、预算有限的安卓手机会崩溃、令牌以明文形式存储,而且该框架的发展速度远远落后于时代。以下是五种常见情况,常用于面试中考察高级工程师的能力,同时会说明优秀答案应包含的思路,以便你将其应用到自己的代码库中。

区分服务器状态与客户端状态

当本地状态、API 响应、缓存以及共享的 UI 标志混杂在一起时,首要问题不是“用 Redux 还是 Context?”,而是“这些数据由谁负责管理?”

来自 API 的数据,如个人资料、动态信息或产品列表,属于服务器状态。这类数据会过时,必须重新获取、缓存并标记为无效。若将其存入全局存储,则需要手动重新实现所有相关功能,还包括加载处理和错误提示机制。下面的代码片段展示了这种不良设计。

// ❌ Server data forced into a global store —
// now YOU own caching, staleness, invalidation, loading states…
dispatch(setProducts(await fetchProducts()));

TanStack Query 和 RTK Query 的存在正是为了解决这一问题。在这里,useQuery会根据类别对数据进行分类,并通过 staleTime 参数确保数据在60秒内保持最新状态。代码块的后半部分(在源码中已合并为一处)则展示了全局存储仅能保留的内容:一个简单的会话对象。

// ✅ Server state managed by a tool built for it
const { data, refetch } = useQuery({
  queryKey: ['products', category],
  queryFn: () => fetchProducts(category),
  staleTime: 60_000,
});// ✅ Truly shared client state — small and intentional
const useSession = create<SessionStore>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
}));

剩下的问题要小得多。表单输入、切换控件以及动画值都保留在组件内部,这样处理成本较低、相互隔离,且会随屏幕一同消失。全局存储应仅保存多个屏幕都需要且由客户端自身管理的数据,例如已登录用户信息、主题设置、功能开关或跨屏幕的UI状态。

当一切都是全局化的时会出什么问题

更新操作会重新渲染无关的屏幕,每个功能都会与存储的结构绑定,测试时必须模拟外部环境,而代码重构则如同挖掘工作一般繁琐。臃肿的全局存储看似有良好的架构设计,实则如同债务一般拖累性能。

无需猜测即可诊断反应迟缓的FlatList

尽管API响应很快,但FlatList的使用体验仍显得迟缓。到处使用React.memo其实只是猜测;正确的做法是先进行测量。

在滚动时使用 React DevTools(或 why-did-you-render 库)查看情况。如果每次滚动都会重新渲染所有行,其原因在于引用身份问题:renderItem 中使用了内联箭头函数、每次渲染都会重新构建样式对象,或者没有 keyExtractor,这些情况都会迫使组件重新挂载。在此情况下,每次渲染都会生成新的 onPress 闭包和 style 对象,因此没有行可以跳过渲染。

// ❌ New function + new object on EVERY render → every row re-renders
<FlatList
  data={items}
  renderItem={({ item }) => (
    <Row item={item} onPress={() => open(item.id)} style={{ padding: 12 }} />
  )}
/>

解决方案是为 React 提供稳定的引用:使用记忆化的 Row 组件、用 useCallback 包装的 renderItem、稳定的键值,以及 getItemLayout 以确保列表不会重新测量行高。加上类型注解后,这段代码就变成了 TSX 代码。

// ✅ Stable identities + memoized rows
const Row = React.memo(({ item, onPress }: RowProps) => { /* … */ });const renderItem = useCallback(
  ({ item }: ListRenderItemInfo<Item>) => <Row item={item} onPress={handlePress} />,
  [handlePress],
);<FlatList
  data={items}
  renderItem={renderItem}
  keyExtractor={(item) => item.id}
  getItemLayout={(_, index) => ({
    length: ROW_HEIGHT,
    offset: ROW_HEIGHT * index,
    index,
  })}
/>

getItemLayout 仅适用于固定高度的行;对于可变高度的行,如果使用错误的值,则会导致页面跳动和空白间隙。

查看性能监控器

如果渲染效果正常但帧率仍下降,可对比两个性能监控指标:

  • JS FPS值过低意味着JavaScript线程负担过重,通常是由于复杂的renderItem操作或未加限制的onScroll事件导致的。
  • UI FPS值过低则表明是原生代码方面的问题,常见原因是图片。将全分辨率照片解码为80像素大小的缩略图会浪费内存和帧率;应在服务器端调整尺寸,或使用expo-image、FastImage并设置合适的尺寸。

之后再通过调整windowSize、removeClippedSubviews参数或改用FlashList来优化列表性能。在分析数据行之前就调整列表属性只是治标不治本。

在预算有限的Android设备上检测崩溃

在旗舰机上运行流畅的应用,在大多数用户使用的廉价安卓手机上却可能崩溃。首先关注数据:Play Console或分析工具能显示实际使用的设备类型,而这往往与团队测试用的手机不一致。

让低端硬件成为日常测试的一部分:在附近准备一台配备2到3GB内存的实体设备,或者像Firebase Test Lab那样根据分析结果中的设备型号进行测试。

gcloud firebase test android run \
  --app app-release.apk \
  --device model=a10,version=29 \
  --device model=redmi9,version=30   # the phones in your analytics, not yours

务必测试正式发布版本。调试版本会掩盖真实的性能表现,而且正式发布版本的Hermes运行时与调试模式有所不同。在性能较差的硬件上,常见的故障包括大尺寸图片或冗余列表导致的内存压力、主线程过载,以及内存不足引发的崩溃——这些在高端手机上是不会出现的。

优雅降级而非直接崩溃

你可以根据不同设备调整体验。react-native-device-info可以提供总内存信息。

import DeviceInfo from 'react-native-device-info';

利用该工具,可将内存小于3GB的设备标记为低端设备,从而提供缩略图而非完整图片,并跳过模糊处理、视差效果以及复杂的动画。由于getTotalMemory是异步操作,建议在应用启动时一次性计算出相关标识,而非在渲染过程中等待结果。

const totalMemory = await DeviceInfo.getTotalMemory();
const isLowEnd = totalMemory < 3 * 1024 ** 3; // < 3 GB RAM<Image
  source={{ uri: isLowEnd ? item.thumbUrl : item.fullResUrl }}
  // skip blur, parallax and heavy animations on low-end devices
/>

此外,应采取防御性措施:按设备等级对Sentry或Crashlytics的报告进行分类,采用分阶段的Play Store发布策略(5%、20%、100%),避免有问题的版本被所有用户使用。目标并非实现零崩溃,而是尽早且低成本地发现故障。

安全存储认证令牌

AsyncStorage会将数据以未加密的形式写入磁盘:在Android上为SQLite文件,在iOS上为普通沙箱文件。如果设备被root或越狱,或者备份文件或文件系统被恶意访问,令牌就会直接暴露。它原本是用于存储偏好设置,而非机密信息。

令牌应存储在硬件支持的存储介质中,即iOS的Keychain和Android的Keystore,而react-native-keychain负责对它们进行封装。

import * as Keychain from 'react-native-keychain';

此操作会以WHEN_UNLOCKED_THIS_DEVICE_ONLY选项存储序列化后的令牌:仅在设备未锁状态下可读取,且不会迁移到其他设备上。

await Keychain.setGenericPassword('auth', JSON.stringify(tokens), {
  accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});

能够应对并发401错误的刷新流程

将访问令牌的有效期设置为几分钟而非几天,用每次交换都会被替换的刷新令牌作为支持,并将这一切集中在一个认证层中,无论有多少请求同时失败,该层都只会执行一次刷新操作。共享的承诺机制使得这成为可能。

let refreshing: Promise<string> | null = null;

Axios拦截器会重新抛出除401状态码之外的所有错误。对于401响应,只有当没有正在进行的刷新操作时,??=才会启动刷新流程,因此同时发生的错误会等待同一个承诺完成;finally块会重置该承诺,随后使用新的令牌重新发起原始请求。原代码将多条语句挤在单行中。如需更深入的了解,可参阅为何并发的401错误会导致用户登出以及单次刷新解决方案。

api.interceptors.response.use(undefined, async (error) => {
  if (error.response?.status !== 401) throw error;  // Concurrent 401s all await the SAME refresh — no refresh storm
  refreshing ??= refreshTokens().finally(() => (refreshing = null));
  const newToken = await refreshing;  error.config.headers.Authorization = `Bearer ${newToken}`;
  return api.request(error.config); // retry the original request
});

还有两个要点:不要在JS可访问的全局状态中存储令牌超过必要时间,对于真正敏感的API应添加证书固定机制,同时绝不要记录令牌信息,因为崩溃报告工具会自动捕获请求头。“使用SecureStore”是一种工具名称;一个完善的解决方案应涵盖令牌的生命周期、刷新机制以及故障处理方式。

升级已运行两年的React Native应用

该应用已经落后两年,不允许出现停机情况,且也没有重新编写的预算。

切勿直接升级到最新版本。React Native Upgrade Helper可以显示各版本之间的具体差异;建议**每次只升级一两个次要版本**,确保应用始终能够正常构建和发布。每一次升级都应视为一次常规发布,这样才能实现零停机。

首先审计依赖项

升级受阻的原因在于那些老旧且无人维护的本地库,而非 React Native 本身。在开始升级之前,先找出那些阻碍新架构或更高版本 Gradle、Xcode 要求的依赖项,然后替换或 fork那些已被废弃的库。

自动化验证每一阶段

在开始之前为关键流程设置端到端的冒烟测试,这样每个阶段的验证都可以在几分钟内完成,而无需依赖人工 QA。该 Maestro 流程会使用环境变量中的电子邮件登录,并检查主页、购物车和结账页面是否可访问。

# smoke-test.yaml — run with Maestro on every upgrade hop
appId: com.myapp
---
- launchApp
- tapOn: "Log in"
- inputText: ${EMAIL}
- tapOn: "Continue"
- assertVisible: "Home"
- tapOn: "Cart"
- assertVisible: "Checkout"

向管理层阐述这项工作是为了降低风险,而非进行代码重构:每次落后都会增加因商店规则、操作系统废弃以及安全补丁而不得不进行的强制升级的成本。小步渐进的升级能让原本令人畏惧的项目变成一系列乏味的版本发布,而“乏味”正是最终目标。

关键要点

  • 确定每条数据的归属者;让查询库负责管理服务器状态,从而保持全局存储的规模较小。
  • 优化之前先进行性能分析:身份验证问题、JS线程操作以及图像解码需要不同的解决方案。
  • 在用户的真实设备上测试发布版本,并分阶段推出。
  • 将令牌视为一个系统来处理:采用安全存储方式,设置较短的有效期,定期更换,并实现单次使用后自动刷新。
  • 通过自动化冒烟测试作为支撑,以小步、可交付的方式逐步进行升级。