前阵子 Shopify 从 React Native 回到 Swift/Kotlin 原生栈的新闻,又在技术社区刷了一轮屏。很多人的第一反应是简单粗暴的两句:“早就说了跨平台不行,早晚要换原生”,以及“既然原生更好,直接换回去不就行了?”。第二句话尤其让我这种做过迁移的技术人头皮发麻,因为真实情况根本不是“用 Swift 重写一遍页面”这么轻巧。
这篇文章不打算站队,也不想争论 React Native 和原生谁更优秀。我想把 Shopify 这次迁移当成一个完整的技术案例来拆解:它的背景是什么、真正的难点藏在哪、如果换成你的团队来做,迁移路线应该怎么设计。适合正在做技术选型评估的移动端负责人、被 React Native 性能问题折腾得头秃的工程师,以及那些打算把跨端项目迁回原生、但还没想清楚工作量的同学。
顺便说一句,网上提到“React Native 启动白屏”“Swift URLRequest GET 的差异”“Kotlin DSL 与 Groovy DSL 的迁移”这些具体问题,其实都是迁移路上必然会踩到的点,后面我会结合实操一起聊。
1. 事件全貌:Shopify 为什么离开 React Native
1.1 当初为什么选 React Native
很多人看到 Shopify 放弃 React Native 的新闻,容易误以为 Shopify 一直是原生死忠、只是被忽悠着用了跨平台。实际上,Shopify 曾经是 React Native 的坚定支持者,在社区里属于体量最大的实践方之一。
那时候 Shopify 面临的业务场景和大多数电商公司一样:页面多、表单多、运营活动频繁、希望快速改版上线。React Native 的核心卖点正好打在这几个痛点上:
- 一套 TypeScript/JavaScript 代码同时运行在 iOS 和 Android,理论上可以省掉一半的开发人力。
- 声明式 UI 让页面的组合和复用更直观,前端工程师上手门槛低。
- JS 生态里有海量的现成库,处理表单、动画、网络请求都有成熟方案。
- 动态更新和热修的能力虽然官方不推荐,但对电商这种强运营的业务具备天然的吸引力。
在那个阶段,Shopify 的选择一点也不反智。对于以商品列表、购物车、结算表单为主的业务,React Native 的开发效率和快速迭代能力确实能打。问题在于,电商业务的复杂度远不止表单和列表,一旦进入商品详情页的商品三维展示、支付流程的页面栈管理、相机扫码、以及核心路径上的丝滑手感,React Native 就开始暴露短板。
1.2 为什么最终放弃
Shopify 官方公告里没有把 React Native 说成一无是处,但核心意思非常清楚:移动端体验是他们的品牌核心,他们希望团队能把注意力完全放在平台原生能力上。这句话翻译成人话就是:
第一,性能问题到了核心商业路径上就绷不住了。商品详情页的长列表滚动、转场动画、图片预加载,在 iOS 上还能靠硬件和系统优化扛一扛,在 Android 的中低端机型上就会出现肉眼可见的掉帧。而移动电商的核心指标——转化率、加购率、支付完成率——和页面流畅度强相关,这种性能损耗直接影响生意。
第二,依赖生态的复杂性越来越难控制。React Native 的第三方库质量参差不齐,很多库在某一个版本上表现很好,升级一个 RN 版本就出问题。Shopify 这种体量的 App,内部有成百上千个页面和几十个业务模块,不可能每个库都自己维护。RN 版本升级的代价,往往等同于一次小型重构。
第三,开发组织的边界开始互相拖后腿。纯业务团队大部分人都写 JS,真正懂原生实现的人越来越少。当团队想调用一个 iOS 独有的系统能力时,要先写原生模块、封装 JS 桥接、再给业务侧提供类型定义,协作链路非常长。而且原生工程师长期被 JS 业务封装挡在业务逻辑外面,对产品痛点的感知也在变弱。
第四,长期演进的不确定性。React Native 的新架构(Fabric、TurboModule)虽然方向很对,但对老项目来说是一次接近重写的升级。与其在别人的架构路线上反复追赶,不如把命运握在平台厂商手里。SwiftUI 和 Jetpack Compose 这些原生声明式框架的成熟,也让原生开发的效率劣势不再那么明显。
这些原因叠加在一起,才促成了 Shopify 的掉头。它不是拍脑袋的决定,而是一个基于多年产研投入产出的判断。
2. 从 React Native 迁回原生,五层难点逐个拆
2.1 架构不是推倒重来,而是新老共存
网上那种“直接重写 App”的说法最坑人,因为任何一家有规模的公司都不可能在一夜之间砍掉所有业务、闭关三个月后交付一个新 App。用户还在用,运营还要发版,新功能还要继续上线,迁移只能是一个渐进过程。
渐进式迁移的第一难点,就是新老共存。你的 React Native 页面还留在线上,原生页面在旁边慢慢替换,两边需要共用登录状态、用户数据、埋点体系、路由跳转逻辑。我见过很多团队把这一步做成了车祸现场:原生页面里嵌套一个 RN 视图,RN 视图里又跳回原生页面,数据却各存一份,最后登录态都同步不过来。
要解决这个问题的核心,就是架构上提前定义好边界。业务层不关心页面是由 RN 还是原生渲染,只面向接口编程。数据层要下沉到公共能力,让 JS 侧和原生侧都能访问。路由层做统一注册,RN 发起跳转的时候能调到原生页面,原生页面也能调回 RN 页面。只有在这样的架构约束下,迁移才可能逐页进行。
2.2 生态替换不是“找个替代库”那么轻巧
React Native 时代,很多功能是依赖 JS 第三方库搞定的。存储、网络、图片加载、导航、权限申请,每个都有对应的社区优选库。迁到原生以后,你要把这些库全换成平台原生的方案,但它们的行为差异比想象中更大。
我整理过一个简单的对照表:
| 功能维度 | React Native 常用方案 | iOS 原生方案 | Android 原生方案 |
|---|---|---|---|
| 本地存储 | AsyncStorage | UserDefaults / CoreData | DataStore / Room |
| 网络请求 | fetch / axios | URLSession | OkHttp / Retrofit |
| 图片加载 | react-native-fast-image | SDWebImage / Kingfisher | Coil / Glide |
| 导航 | react-navigation | NavigationStack / Coordinator | Navigation Compose / Coordinator |
| 权限申请 | react-native-permissions | 系统权限 API | Activity Result API |
看起来只是换了 API,实际上每个替换点都隐藏着行为差异。AsyncStorage 是异步写入,UserDefaults 有缓存机制且适合轻量数据,CoreData 的对象生命周期和线程模型又完全是另一套玩法。图片库的缓存策略、内存管理、增量更新逻辑也各不相同。你把页面代码从 TSX 改写成 SwiftUI 或 Compose 只是第一步,真正烧时间的是这些基础能力层的对齐。
2.3 团队与组织调整被严重低估
技术迁移最容易被低估的其实是组织准备度。一个长期用 React Native 写业务的团队,成员的原生技能树通常是参差不齐的:一部分人熟悉 JavaScript 和 React 但没写过一行 Swift,一部分人懂 Android 但不懂 iOS,真正双端原生都熟的人少之又少。
迁移一开始,最尴尬的局面是:写 React 的人想帮忙但帮不上,因为他不知道 SwiftUI 的 State 怎么管理、不知道 Coordinator 怎么搭。公司临时去招聘原生工程师,又要面临新人不熟悉业务代码、不理解历史逻辑的问题。所以很多迁移项目最后都会形成“原生平台组 + 业务适配组”的矩阵结构,平台组负责基础能力落地,业务组负责把页面搬过去。
如果你正在规划类似的迁移,我建议第一步不是写代码,而是先盘点团队的技能矩阵。哪些人能承担 Swift/Kotlin 的核心开发,哪些人需要转型学习,哪些人可以继续维护存量 RN 代码直到它被全部替换。组织这件事不解决,代码层面就是无源之水。
2.4 性能指标怎么证明“新版本更好”
迁移过程中最容易被忽略的是性能回归的验收标准。每个团队都会说“原生更快”,但很少有人把“快”定义成可量化的指标。迁移做完以后你想灰度放量,领导问一句“凭什么说新页面更好”,你拿不出数据,就只能继续拍脑袋。
一组合理的移动端核心性能指标应该能覆盖这几个维度:冷启动时间、页面首帧耗时、滑动帧率(FPS)、内存占用、卡顿次数、崩溃率、无响应率(ANR,Android 上)。迁移前要在同一批测试设备上跑旧版基线数据,迁移后再跑新版,用同样机型、同样系统版本、同样网络环境,否则对比没有意义。
如果是电商 App,还要额外关注业务转化指标。商品详情页从 RN 迁到原生后,加载速度提升了 20%,这 20% 是否带来了加购率提升、支付成功率提升,这些数据要和性能数据放在一起看。技术迁移花了几百万,最终要落到商业结果上才站得住脚。
2.5 业务复杂度的迁移代价藏得最深
最后的难点是业务本身。购物车、订单、支付这些链路,不是简单的 UI 重写。它们背后有复杂的业务状态、接口依赖、第三方 SDK 集成、以及各种历史边界情况。
举个例子,支付模块往往同时接了 Apple Pay、支付宝、微信支付等好几种支付渠道,每个渠道都有独立的 SDK 和三方回调处理。你从 RN 迁到原生,不仅要重新集成一遍所有 SDK,还要保证回调链路、验签逻辑、订单状态同步完全一致。商品详情页也是一样,SKU 选择、库存判断、优惠券逻辑、推荐位加载,个个都是布满业务细节的泥潭。
很多团队把迁移的工作量只估了 UI 层,结果做到一半发现每个页面都连着几百行业务逻辑,只能不断追加人力和排期。所以迁移之前,建议先对核心业务链路做一次完整的依赖梳理,评估每个模块的代码量、接口数量、第三方依赖数量,把工作量估准了再动手。
3. 迁移实操:四阶段渐进式替换方案
3.1 阶段一:基建先行,把仓库和模块边界理清楚
迁移的第一步不是写原生页面,而是做基建改造。这一步做好了,后面所有工作都会顺畅。
建议先把现有代码按业务模块拆分,无论这个模块是 RN 还是原生实现。梳理出完整的路由清单,标记每个页面所属的业务域、当前技术栈、依赖的下游服务。然后定义好模块边界,明确哪些能力应该收敛到公共数据层、哪些能力保持页面独立。
数据层最好独立成模块,让 JS、Swift、Kotlin 三端共用同一套接口契约。这里我多说一句,如果你们的后端接口有 OpenAPI 描述文件,可以直接用它生成 Swift 和 Kotlin 的客户端代码,省掉大量重复的 DTO 定义和解析逻辑。CI 要提前适配,保证新旧代码能够同时编译,不然你的本地环境和远端流水线很容易在迁移中期分裂成两套标准。
3.2 阶段二:桥接层设计,让新旧代码共存
如果迁移期内 RN 和原生必须共存,桥接层就是关键设施。桥接层要做两件事:原生侧能注册自己的能力给 RN 调用,同时 RN 页面能被原生容器加载。
以 Android 为例,如果你用的是 React Native 旧架构,可以这样暴露一个原生模块:
class NativePaymentModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { override fun getName() = "NativePayment" @ReactMethod fun pay(orderId: String, promise: Promise) { // 在这里调用原生支付逻辑 val result = PaymentManager.startPayment(orderId) if (result.success) { promise.resolve(result.paymentId) } else { promise.reject("PAYMENT_FAILED", result.errorMessage) } } }新架构的 TurboModule 写法会有些不同,但设计思想是一样的:通过模块名和注解暴露方法,让 JS 侧通过桥接调用原生能力。这里特别提醒一下,桥接层是最容易出内存泄漏的地方,原生对象被 JS 侧长生命周期引用会导致原生实例无法释放。所以桥接方法里不要持有 Activity 或 Context 的强引用,该用 ApplicationContext 就用 ApplicationContext。
3.3 阶段三:按页面灰度替换,先拿低风险页面试水
桥接层就绪后,开始选择替换页面。我强烈建议先挑一个低风险的页面做试点,比如设置页、个人资料页、消息通知页。这类页面业务逻辑简单、不涉及支付链路、出了问题用户影响有限。
试点页面通过灰度发布上线,先放 1% 的流量跑一两天,看性能和稳定性数据有没有异常。确认没问题后逐步放到 5%、10%、50%,直到全量。灰度期间要同时关注两类数据:性能数据(首帧耗时、内存、崩溃率)和业务漏斗数据(按钮点击率、转化率、流程完成率)。任何一项异常都要回滚,不要抱有“可能只是偶发”的侥幸心理。
等到你跑通了几个低风险页面,整套灰度、监测、回滚的流程也成熟了,再碰商品详情页、购物车、支付这种核心链路。这个过程很慢,但每一步都有数据兜底,不容易翻车。
3.4 阶段四:验收与回滚机制,用远程开关保命
灰度迁移阶段,代码层面必须做好远程开关。原生化完成的新页面,要通过服务端下发的开关来控制路由,而不是直接把路由指向写死。我习惯在路由层做一个配置读取:
| 配置项 | 取值 | 作用 |
|---|---|---|
| product_detail_render | native / rn / auto | 控制商品详情页渲染方案 |
| cart_render | native / rn / auto | 控制购物车页面渲染方案 |
| checkout_render | native / rn / auto | 控制结算流程渲染方案 |
| migration_switch | on / off | 全局总开关,一键切回旧版 |
每次替换完一个页面,先在白名单环境里验证,再到小流量灰度。一旦新页面崩溃率超过预设阈值(比如 0.1%),远程开关直接回滚到旧页面,让用户无感切换。这套机制虽然传统,但比“新代码出问题就紧急发版修复”靠谱得多。
4. 实战里的典型坑,以及对应的排查思路
4.1 典型坑一:React Native 启动白屏
“React Native 启动白屏”是很多团队在迁移前后都会遇到的问题,只不过程度不同。白屏的含义很明确:页面渲染了,但用户看到一片空白。问题根源可能出在几个位置:
- JS Bundle 在启动阶段还没加载完,或者加载失败。
- 原生容器的主线程在等待 JS 线程初始化,首帧渲染被阻塞。
- 新架构下 TurboModule 初始化偏慢,导致 JS 侧启动流程被拖后。
- 容器背景色是白色,而业务 JS 还没执行到渲染逻辑。
排查的时候,我建议按这个顺序来:先看 OS 层启动耗时,确认原生容器本身没有阻塞;再检查 bundle 的加载日志,确认它是从磁盘加载还是网络加载;然后用 React DevTools 或者 Flipper 看 JS 加载完成时间和渲染时间;最后观察白屏是只在冷启动出现,还是从原生页面跳转 RN 页面时也会出现。
优化方向也很明确:bundle 预热和预下载、沙盒加载、拆分 bundle 按需加载、原生侧先展示骨架屏。说实话,如果你的目标是全面迁回原生,“白屏”这个伤只会越来越少,因为原生渲染不需要等待 JS bundle 执行,首帧天然快很多。
4.2 典型坑二:Swift URLRequest 和 RN fetch 的行为差异
这是迁移过程中很少被提前预料到的坑。RN 的 fetch 默认行为和原生 URLSession 有很多不一样的地方,你可能觉得“都是发个 GET 请求能有多大差别”,但实际线上问题往往就是这么刁钻。
举例来说,RN fetch 默认的超时时间是 0,也就是无超时;而 URLRequest 的 timeoutInterval 默认是 60 秒。再来,fetch 默认会带上一些浏览器风格的 HTTP 头,URLRequest 不会;Cookie 的处理、UA 的拼接、缓存策略也不一样。迁移完成后,原来好好的接口请求突然超时或者不带 Cookie,非常常见。
用 Swift 发起一个标准的 GET 请求,我会显式设置这些字段:
var request = URLRequest(url: url) request.httpMethod = "GET" request.timeoutInterval = 30 request.cachePolicy = .reloadRevalidatingCacheData request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("your-app-ua", forHTTPHeaderField: "User-Agent") let task = URLSession.shared.dataTask(with: request) { data, response, error in // 处理响应 } task.resume()另外,迁移网络层时一定要先对比认证头、Cookie 存储、UA、缓存策略这四类默认值,并用线上抓包验证请求行为完全一致再合入。不要想当然。
4.3 典型坑三:构建配置从 Groovy DSL 迁到 Kotlin DSL
如果你的 Android 工程之前是 React Native 生态或者老式 Java 工程,迁移到 Kotlin 之后大概率会顺手把 build.gradle 从 Groovy DSL 换到 Kotlin DSL。这一步看起来只是换个语言写构建脚本,实际坑非常多。
Groovy 是动态语言,写起来很随意,方法调用可以省略括号、字符串可以用单引号、闭包整体更宽松。Kotlin DSL 是类型安全的 DSL,IDE 补全好、编译期就能发现错误,但语法上的约束也更多。常见的问题包括:
plugins块里id必须带引号,例如id("com.android.application")。compileSdkVersion 34要改成compileSdk = 34。- Groovy 里的
def变量声明,在 Kotlin DSL 里要写显式类型或者val/var。 - Task 配置的闭包写法完全不同,Kotlin DSL 更推荐
tasks.named<T>("...")这种类型化访问。
一个最简单的 plugins 配置在两种 DSL 下的差异:
// Kotlin DSL plugins { id("com.android.application") version "8.1.0" apply false }// Groovy DSL plugins { id 'com.android.application' version '8.1.0' apply false }差别就一个引号和一对括号,但迁移到大项目里这种细节到处都是。我的建议是让 Gradle 官方迁移向导先跑一遍自动转换,剩下的人工修,别手动硬啃,不然一下午就耗在了一个闭包报错上。
4.4 排查速查表
为了方便大家直接参考,我把上面提到的问题整理成了一份速查表:
| 问题现象 | 常见原因 | 处理建议 |
|---|---|---|
| 新页面白屏 | JS bundle 加载慢、首帧等待 JS 执行 | Bundle 预加载、原生侧骨架屏、拆包优化 |
| 请求行为不一致 | fetch 和 URLSession 默认值差异 | 统一封装网络层,抓包对比认证头、Cookie、UA |
| 内存持续上涨 | 桥接层对象被长引用持有 | 检查桥接方法里的 Context/Activity 强引用 |
| Android 构建失败 | Groovy DSL 转 Kotlin DSL 语法不兼容 | 用迁移向导自动转换,再手动修 |
迁移期最常见的错误,是把这个表里的问题当成孤立的 bug 去修,修完就忘。实际上它们都是系统性问题,最好在公共的基础层统一解决,不然换个页面又会再犯。
5. 从 Shopify 这次迁移,看到的技术选型信号
5.1 Shopify 与 WordPress 的路线分野,以及“有手就行”的错觉
很多觉得“迁移有手就行”的人,其实是从小项目的视角去看的。一个只有几十个页面的 App,页面之间耦合低、逻辑简单,两周重写一个版本确实不难。但 Shopify 这种平台级产品,体量完全不同,这也是为什么它和 WordPress 在技术路线上会走向完全不同的分支。
经常有人拿 Shopify 和 WordPress 对比:WordPress 是开源生态、自托管、插件自由,适合内容型和强定制化需求,但扩展的复杂度和安全问题都得自己扛;Shopify 是 SaaS 闭环、托管服务、开箱即用,适合快速开卖和低成本维护。你会发现,这两者的本质差异不在功能,而在“你可能获得多少控制权,以及相应地要付多少复杂度税”。
这和移动端技术栈的迁移逻辑是相通的。小团队用 React Native 开发,可能一直很顺,因为业务复杂度低、页面数量少、性能瓶颈不明显。一旦业务规模涨上去,流量、交互、系统能力需求上来以后,当初“低门槛”的代价会集中兑现。这就是为什么 Shopify 得出结论:核心业务的移动体验,必须在原生能力上构建。
5.2 技术选型真正该看的三个维度
从 Shopify 的案例里,我提炼出三个评估维度,不管团队大小,做技术选型的时候都值得对照一下。
第一是业务长周期下的人力成本。短期来看,React Native 可以省一半开发人力;但长期来看,如果你要长期投入原生能力建设、每次版本升级都要处理跨端兼容问题,这笔账要重新算。
第二是技术栈和核心能力的匹配度。如果你的产品核心是内容展示、社区互动,跨平台方案完全够用;但如果核心是相机、AR、地图、支付、复杂动画,原生永远更有优势。技术选型不是选最好,而是选最匹配。
第三是团队组织结构的适配度。跨平台方案要求团队对 JS 和原生都有足够的掌控力,这其实是很高的组织要求。如果原生团队本来就薄弱,用跨平台方案更是雪上加霜,不如把人力集中在一个技术栈上做深做透。
5.3 给中小团队的建议
如果你不在一家拥有几百人移动团队的大厂,去迁移 Core 链路确实可能是过度工程。但 Shopify 这个案例给中小团队最大的启示,是不要等到骑虎难下才做架构调整。
想避免走到“全面重写”那一天,最实际的做法是三件事:第一,从一开始就给业务模块定义清晰的接口边界,不要让页面之间互相依赖不可拆解。第二,核心商业路径的页面,哪怕其他页面用跨端方案,这几个页面也尽量用原生实现,守好转化率生命线。第三,定期做技术债评估,不要因为“现在还能跑”就放任核心库版本一直落后。
迁移这种事,最怕的不是工作量大,而是方向不清晰。你只要把握住一条主线:任何技术栈的取舍,都要回到“业务核心体验是否受益”这个问题上来。React Native 不是不行,原生也不是万能。问题的本质是你有没有把技术栈当成服务业务的工具,而不是反过来为了用某个技术而硬套业务。
我在实际项目里见过太多团队在跨端和原生之间反复横跳,每次都有听起来很合理的新理由。但真正让迁移做不走的原因,往往不是技术本身,而是迁移这件事从一开始就被当成了一次“重写”,而不是一个“演进”。演进需要及格线以上的架构边界、性能指标和组织准备,这三样东西,每一样都拿不出来,怎么可能“有手就行”。