如何用 Expo 让移动零售应用现代化
了解 bitglow 如何借助 Expo Prebuild 对 DEPOT 的 React Native 应用进行现代化改造,将升级时间缩短 80%,并把性能评分从 36 提升到 90。
中文
复制

本文是 Jonathan Bones 的客座文章——他是 bitglow 的高级前端开发者,热衷于细节:升级、优化和调试。你可以在 GitHub 和 Bluesky 上找到他。
…
Expo 和 React Native 让开发者能够构建快速、精美的应用。但如果不注重细节,尤其是在低端设备上,这个承诺很快就会落空,开发者备受挫折,用户也不买账。听起来很熟悉?这正是 DEPOT 面临的挑战——这家德国家居装饰零售商颇具规模。

借助 Expo 和 Expo 云服务 的最新功能,加上我们 bitglow 团队深厚的 React Native 功底,我们帮助 DEPOT 彻底改造了陈旧的 React Native 架构,清除了多年积累的技术债,打造出一套面向未来的代码库。
为什么这与你有关?
因为当技术债拖慢开发团队时,再好的想法也无法及时送到用户手中。别只听我说——以下是我们动手之前用户的评价:
★☆☆☆☆ “这个应用本来可以很棒,如果它不总是崩溃的话。你老得等着,什么都加载半天,要么根本加载不出来。”
过去一年,随着我们逐步改造这个应用,反馈开始发生变化。虽然现在有些用户的意见集中在具体功能或 bug 上,但讨论明显不再围绕性能问题:
★★★★★ “很棒的应用。如果用户能收藏附近的门店,然后应用显示该商品在那个分店是否有货,那就更好了。”
这种转变不是一夜之间发生的。我们首先审计了现有代码库,了解痛点,建立基准。搞清楚自己处在什么位置,是规划前进路径的第一步。
审计代码库
加入项目时,我们的审计揭示了所面对的全貌:
-
已 eject 的 Expo 应用——React Native 0.63
-
React Navigation v5,大量依赖已废弃的 v4 兼容层
-
Redux 管理状态,Sagas 处理 API 调用等副作用
-
TypeScript v4.4
这套配置算不上最新,但作为基础足够扎实。借助 TypeScript 的能力,我们可以放心重构大块代码,同时把新增问题的数量控制在接近零的水平。
分析下来,有四个关键方向需要处理:
-
采用 Prebuild,降低 React Native 的维护负担
-
优化应用性能
-
用 Expo 的服务实现构建与提交自动化
-
通过 OTA 部署实现快速更新
从几周到几天:用 Expo Prebuild 加速升级周期
主要的难点在于维护和更新那些过时且无人维护的 React Native 版本。应用还跑在 React Native 0.63 上,落后最新稳定版好几个大版本。Google Play 和 Apple App Store 越来越频繁地强制要求更新 target SDK,跟上这些政策截止日期注定是一场硬仗。
另外,对 React Navigation v5 及其兼容层的依赖——这东西本来就只是临时方案——让核心依赖升级时混入隐蔽 bug 的风险更高。
最初几次 React Native 升级是手动完成的。React Native 开发者社区在历年的 State of React Native 调查中都把这件事列为痛点,不过自从有了 Upgrade Helper,这几年升级流程已经好管理多了。
注:Expo 最近推出了一个用于升级应用的 Claude Code skill。我们还没试过,但听说很有用。
然而即便有这些改进,团队仍然要花上几周时间做技术改动、对构建做 QA、修补各种刁钻的 bug,然后准备发版。这些宝贵的开发时间,实际上都花在了管理框架所需的原生代码上。
在冲刺期间,这段时间往往也被视为停工期,因为我们要停下功能开发,把精力集中在升级上,而升级通常需要额外的协调与风险管理,以免对业务目标和交付期限造成负面影响。有没有办法在不偷工减料的前提下加快这一过程?
采用 Prebuild 加快版本升级
Expo 在 2022 年 app.js conf 上关于 Continuous Native Generation 的主题演讲,让我们认真考虑在客户项目中采用 expo prebuild。自动生成原生框架代码,而不是手动管理 ios/ 和 android/ 原生工程,意味着升级更短、更可预测,功能“停工期”也更少。
为了验证对 DEPOT 的这一建议,我们结合以往项目的经验,并根据手动更新所花的时间、原生与非原生依赖的数量以及代码库的整体规模等因素进行了推算。由此得出两个估算:一个是迁移本身所需的工作量,另一个是未来升级所能节省的时间。
我们的结论是,迁移所需的时间大致相当于一次 React Native 升级。作为回报,未来的升级最快只需原先 20% 的时间,升级周期从数周缩短到几天。
随着时间推移,我们完成了多次从 React Native CLI 到 Expo 的迁移,对成功完成这一过程所需的细节已经非常熟悉。再加上 Expo 团队和社区持续不断的改进,迁移变得越来越可预测、越来越高效。实际上,对许多项目来说,前期投入确实与一次 React Native 升级相当,而投资回报从之后的每一次升级开始体现。
如果一次迁移同时包含多个 React Native 版本升级,这一点就更有说服力,DEPOT 正是如此。我们花了传统上升级单个 React Native 版本差不多的时间,就完成了整个迁移,同时升级了三个版本。结果是立刻节省了时间,此后的升级也一直更快、更省心。
迁移准备
我们从依赖审计入手。package.json 中的每一项都被归类为原生或纯 JS,并用 unimported 找出未使用的库——第一轮就裁掉了 @react-native-voice/voice、isomorphic-fetch 和 traverse。我们还偿还了一笔积压已久的技术债:终于移除了 @react-navigation/compat 层,并把对 StackActions 和 NavigationActions 的引用改用 useNavigation hook 重构,为升级到更新的 React Navigation 版本扫清了障碍。
我们还排查了低使用率的依赖(引用数 ≤3),把其中几个换成了极简的自定义实现;比如 react-native-multi-tap-component 改成了一个原生 RN 组件。这样精简依赖规模,大幅降低了原生集成的风险,也是让 DEPOT 应用为采用 Prebuild 做好准备的过程中收效最大的一步。
构建配置搭建
精简完 package.json 之后,我们以 Expo config-plugins 仓库为参考,逐一检查剩余原生包是否支持 config plugin。大多数原生依赖已经有官方或社区插件——这是个好兆头。没有的,就得自己写一个自定义 config plugin。但真写起来,发现出乎意料地简单。
Expo 文档写得很清楚,再参照现有的 config plugin,几个小时内我们就为剩余的原生 SDK 做好了支持。本以为会是个大障碍,结果轻松过关,也让我们有了继续推进的信心。
迁移分阶段稳步推进。我们把 android/ 和 ios/ 目录加进 .gitignore,并把应用入口简化到最小渲染,这样就能先专注恢复原生集成,再去处理 TypeScript 和业务逻辑的问题:
export default function App() {
return (
<View style={styles.container}>
<Text>If you can see this the app builds and starts</Text>
</View>
);
}
接着我们安装了最新版 Expo SDK,加入 expo-dev-client,并运行 npx expo install expo --fix 来更新原生库、让配置与所安装的 SDK 版本对齐。app.json 重命名为 app.config.ts,以便根据 APP_VARIANT 环境变量启用不同的构建 flavor:
import { ExpoConfig } from "expo/config";
const name = {
development: "DEPOT (dev)",
preview: "DEPOT (preview)",
production: "DEPOT",
}[process.env.APP_VARIANT ?? "development"];
export default (): ExpoConfig => ({
name: name!,
version: "6.1.0",
// ...other properties
});
一个初始的 iOS 构建成功了。🎉 Android 构建则抛出了几个 Gradle 错误。深入查看堆栈跟踪后,我们很快找到了罪魁祸首:一个过时的 Emarsys SDK 版本,它与生成的 native 项目不兼容。解决办法很直接——升级 SDK、清理构建产物和缓存,然后重新构建。
恢复应用功能
native 集成搭建好之后,下一步是把最初的 app 代码加回来,结果发现了一个阻塞性问题:导航到个人资料标签页时,过渡动画明显卡顿,随后就崩溃了。
堆栈跟踪和复现出来的行为都指向导航层,而不是应用逻辑,于是我们面临一个选择:花时间在无人维护的 v5 上打补丁,还是直面技术债、升级到最新受支持的版本(v7)。考虑到给已弃用的内部实现打补丁的长期维护成本,我们选择了升级,这本身就值得单独写一篇博客。
应用升级的 ROI
通过剔除未使用且脆弱的依赖、升级导航和 SDK 版本,并采用 Prebuild 自动生成 native 项目文件,我们把一套高风险、耗时的工作流变成了可重复、自动化的流程。
收益已经显现:升级所需时间(TTU)更短,开发者的推进速度也明显更快。Expo 和 React Native 的升级现在只需要过去 20% 的精力——团队得以把精力放在产品任务上,而不是到处救火处理 native 问题。在运维层面,Target API level 要求相关的邮件也不再像以前那样让人紧张了。
解决性能瓶颈
native 依赖集成的问题清完之后,还有一批 JS 层面的性能问题要处理。动手之前,我们需要一个可靠的基准来衡量进展。我们用 Maestro 创建了一系列典型用户流程,并借助 Flashlight 获得基于 Lighthouse 的性能评分,覆盖 JS 和 native 线程的使用情况。下面是我们编写的 Maestro 脚本片段,它会导航到分类详情页并分页浏览结果:
- launchApp
- assertVisible: "Entdecke dein DEPOT"
- tapOn: "Deko & Wohnen"
- tapOn: "Kerzen & Lichtobjekte"
- tapOn: "Kerzen"
- tapOn: "Stumpenkerzen"
- waitForAnimationToEnd
# Title of first product in "Stumpenkerzen" PLP
- assertVisible: ${PRODUCT_TITLE}
# Imitates a fast scroll to bottom of listing page
- swipe:
start: 50%, 75%
end: 50%, 25%
duration: 40
- assertVisible: "Mehr Produkte sehen"
- tapOn:
text: "Mehr Produkte sehen"
index: 1
- waitForAnimationToEnd
- scroll
- scroll
- scroll
我们在具有代表性的入门机型(Xiaomi Redmi 9,Android 12)上采集了基准数据,初始得分为 36。显然还有提升空间,但重要的是,这印证了我们的观察结果,以及客户反馈中提到的商品列表页滚动问题。

下一步是确定一个用于衡量进展的目标分数。我们选择了一个务实的分数:85,因为更高的分数需要投入成倍的努力,而用户能感知到的收益微乎其微。基线确立之后,我们系统地排查并解决了几个关键领域的性能瓶颈:
图片优化
事实证明,这是收益最大的优化点之一。商品列表页渲染的图片尺寸过大,不仅增加了下载时间,还在主线程解码和缩放时造成了内存压力。这直接影响了 CPU 负载,并拉低了商品列表页的可交互时间(TTI)。
为了解决这个问题,我们利用媒体资源 URL 上的图片缩放参数,确保设备下载尺寸合适的图片。把工作负载前移之后,UI 线程的解码开销大幅降低,CPU 负载也随之下降。

网络效率
应用会为列表中渲染的每个商品拉取详情,然后在跳转到商品详情页(PDP)时再次拉取同样的数据。这背后有三个问题:
没有请求批处理 → 商品列表页挂载时发起的是 N 个独立请求的突发流量,而不是一个聚合请求
客户端缓存薄弱 → 对内存中已有的数据反复拉取
持久化延迟高 → 通过 redux-persist 进行读写、由 AsyncStorage 支撑,给 JS 线程带来了额外压力
为了解决这些问题,我们对商品 API 请求做了批处理,并开始从 Redux Saga + Redux Persist 迁移到 TanStack Query,后者开箱即用,提供了强大的缓存层。这立刻消除了重复的商品拉取,网络负载明显减轻,浏览商品列表时的页面切换也流畅了许多。
渲染性能
性能分析显示,JS 线程的很大一部分时间被列表组件中反复且不必要的重渲染消耗掉了。这些渲染中有不少是由不稳定的 props 以及直接在渲染循环里进行的高开销数据操作触发的。
为了解决这个问题,我们把高开销的操作移到了数据获取层,确保渲染循环拿到的是可以直接展示的数据。我们还做了针对性的 memoization,并推动使用稳定的原始类型 props——比如给商品卡片传一个稳定的 product ID,而不是整个 product 对象。
此外,我们对代码库做了现代化改造,把剩余的 class 组件迁移为函数组件。这不仅减少了样板代码、提升了代码一致性,还让我们能用上 React Hooks,从而实现更细粒度的状态管理、对更新边界的更精细控制,以及更低的维护成本。
这些改动加在一起,显著降低了 JS 线程负载,并改善了各个 PLP 上的滚动性能。
性能优化带来的收益
通过逐步引入上述每一项优化,我们的 Flashlight 得分达到了 90——平均 CPU 使用率改善了 48%,高 CPU 使用时长更是大幅改善了 91%。最终结果是,在浏览商品目录时滚动体验明显更顺滑、响应更灵敏。

用 Expo 的服务简化 CI/CD
现代移动应用开发不只是写代码——它要求快速、可靠且可重复的交付。然而,手动折腾本地工具链来构建、签名和分发二进制包,会在每一步引入摩擦、复杂性和风险。我们的解决方案是把一套全自动的构建与部署流水线集成进 GitLab CI,由 Expo 的云服务提供支持。
具体流程是这样的:只要代码合并进受保护的分支(main 或 release/),点一下就能触发 iOS 或 Android 的预览版本构建——无需本地环境配置,也不用管理各种凭据。
这省去了等待构建完成、处理环境问题以及手动分享二进制文件的麻烦。流水线会把构建清单上传到 Expo,将 URL 分享到 Teams,剩下的交给 Expo 处理。设计师、QA 和产品团队能立即拿到最新的预览版本,而我们开发者则可以不受打扰地继续下一项任务。

要发布到 App Store 或 Google Play 时,流程同样顺畅。在 Git 中给一个 release 打上 tag,就会自动触发生产构建,再借助 Expo 的自动提交功能,构建好的二进制文件会直接提交到对应的应用商店——同样没有任何手动步骤。下面是 iOS 生产构建的 job:

效果如何?我们不再需要专门的构建硬件,降低了人为失误的风险,也省出了更多宝贵的开发时间。
发布流程可预测、可重复,构建产物会直接交付给相关方或应用商店。对 DEPOT 来说,这直接意味着更低的运营成本、更快的上市时间,以及一支能够专注于开发功能、而不是折腾本地构建工具链的开发团队。
用 OTA 更新守住收入
举一个真实案例,它能完美说明为什么对于任何有移动端收入来源的业务来说,OTA(over-the-air)更新都是颠覆性的。那是一个周五的傍晚,DEPOT 的内容管理团队正在敲定一个大型周末促销活动,预计能带来可观的收入增长。突然,一个严重问题冒了出来:应用里一个配置错误的 campaign key,可能在活动上线前几小时就让整个促销泡汤。
在传统模式下,这会引发一场危机:团队不得不在推迟活动(并错失周末销售)、强行推出一个有风险的临时方案,或者整个周末焦虑地等待 App Store 审核通过热修复之间做出艰难选择。无论哪种结果,都意味着收入损失、相关方不满,以及一支神经紧绷的团队。
相反,由于我们已经部署了 OTA 更新机制,得以立即诊断并解决该问题。几分钟内,修复程序就直接推送到了用户手中——无需重新提交应用商店,无需等待,也没有对客户体验造成任何干扰(除了一条短暂的“关键更新”提示横幅)。这正是 React Native 的真正威力所在:该 bug 被隔离在 JavaScript 层,因此只要用户重新启动应用,我们就能无缝更新每一台设备。营销活动如期上线,团队度过了一个无压力的周末,而最重要的是:DEPOT 没有错失任何预期收入。
这不仅仅是技术上的胜利,更是一种业务保障。这次经历清楚地提醒我们,技术障碍可能会阻碍销售目标的达成。像 EAS Update 这样的 OTA 更新系统是一项战略资产,赋予你的业务即时响应市场需求的敏捷性,随时修复关键问题,并保护收入——这一切都无需受制于应用商店的审批周期。在当今快节奏的零售环境中,这不仅仅是一种技术优势,更是保持竞争力的必要条件。
总结
我们与 DEPOT 的合作历程证明,React Native 在搭配恰当的技术专长时,能够带来卓越的性能和稳定性——即便是在低端设备上。通过系统性地解决技术债务、现代化架构,并借助 EAS 自动化关键工作流,我们将一个脆弱的遗留代码库转变为一款健壮、可扩展、面向未来的应用。
成果不言自明:更快的发布周期、更高的开发者效率,以及一个正在逐步赢得更多正面反馈、重新赢回用户信任的用户体验。最重要的是,这些改进使 DEPOT 能够更迅速地响应业务需求、抓住新机遇,而不再被一个无法随需求扩展的技术栈所拖累。
如果你的团队正面临类似的挑战,或者你希望让移动端代码库面向未来,我们很乐意提供帮助。本文由 bitglow 呈现——React Native 与 Expo 专家 🩵