同一款 App 我写了三遍,说点数字
去年 9 月接了个私活,一个工具类 App,要求 iOS + Android 双端,还得能塞进企业微信和钉钉的内嵌容器里跑。团队就我和一个后端。选型这事我不想拍脑袋,就用同一套页面在三个框架上各写了一遍:首页卡片流、一个 1000 条带图的搜索列表、一个 12 个字段的表单页、一个 Lottie 加载动画。三个版本的功能、接口、图片资源完全一致,连请求的 mock 数据都是同一份 JSON。前前后后折腾了大概三个周末,下面是我记下来的东西,不吹不黑,数字都在。
先把测试口径说清楚,不然数字没意义
测试机一台 Redmi Note 12(天玑 1080 / 8GB+256GB / Android 14 / MIUI 15.0.6),一台 iPhone 13(iOS 17.5.1)。冷启动全部手动划掉后台再点图标,每组跑 30 次取中位数。包体积统一取 release 出的 arm64 APK,Proguard / R8 都开着,资源不压缩,因为实际项目里我懒得配。
版本是 Flutter 3.22.0 stable(Android 上 Impeller 默认开)、React Native 0.74.5(Hermes 开,newArchEnabled 我是两个值都测了)、uni-app x 走的 HBuilderX 4.23 的 uvue 原生编译。三个框架写的代码量差不多,Flutter 那份 1400 行左右,RN 1300 行,uni-app x 因为用了内置组件少一点,1100 行上下。
下面这张表是我最后整理出来的,单位都是实打实测的:
| 指标 | uni-app x | Flutter 3.22 | RN 0.74 新架构 | RN 0.74 老架构 |
|---|---|---|---|---|
| arm64 包体积 | 7.4MB | 9.6MB | 8.9MB | 8.9MB |
| 冷启动中位数 | 688ms | 934ms | 1512ms | 2077ms |
| 列表滚动 95 分位帧耗时 | 13.6ms | 11.2ms | 17.4ms | 28.1ms |
| 滚动 1000 条后 PSS 内存 | 156MB | 187MB | 214MB | 231MB |
启动耗时基本是引擎决定的,业务代码说了不算
这张表里最有意思的是启动那一列。我一开始以为多写几个页面会明显拖慢启动,后来做了个对照:把 Flutter 版本的页面从 4 个减到 1 个,冷启动只从 934ms 掉到 897ms,差了 37ms。但把 RN 从老架构切到新架构,直接砍掉 565ms。
原因在引擎层。Flutter 那 934ms 里,Dart VM 初始化加上 Skia/Impeller 的上下文创建就占了 500ms 以上,你的业务代码跑完首屏大概只花了 200 多毫秒。RN 老架构慢在 JSC 引擎本身要 800ms 左右启动,加上 bridge 的序列化开销;换了 Hermes 之后引擎启动降到 300ms 上下,新架构的 Fabric 又把首屏渲染的 bridge 通信改成了 C++ 直连,这才把总数压到 1.5 秒。
所以有个结论我挺想说的:跨平台项目的启动优化,ROI 极低。你在选型阶段就已经把启动耗时的底盘定死了,后面怎么折腾都是在这个底盘上抠 30ms、50ms。真要优化启动,不如把选型做对,或者干脆学大厂,开屏放个 800ms 的动画把这段时间盖过去。
滚动性能别看平均帧率,看 95 分位
很多测评喜欢报「平均 58 帧,很流畅」。我一般不看这个数,因为平均帧率会把偶尔的卡顿抹平。1000 条列表,你哪怕有 40 帧掉到 30ms,平均下来还是很好看,但用户手指感受到的就是那 40 次卡。
我统计的是 95 分位帧耗时,也就是把最差的那 5% 拎出来。Flutter 11.2ms 是最好的,这个不意外,自绘引擎在列表滚动上确实稳。但要注意一个坑:Flutter 在 Android 上第一次滚动到一个新类型的图片组件时,会有一次 shader 编译的抖动,通常 30 到 60ms,只在首次出现。Impeller 号称解决了这个问题,我在 3.22 上还是能抓到一两次,概率不高但存在。
RN 新架构 17.4ms 算及格了,老架构 28.1ms 就是肉眼可见的黏手。这里有个细节,RN 新架构下 FlatList 的 scrollToIndex 在快速连续调用时精度会飘,我最后是用 getItemLayout 老老实实量了每个 item 高度才稳住。uni-app x 的 13.6ms 说实话超出我预期,它编译出来的确实是原生列表,不是 WebView。
我真正想聊的:出问题的时候,这口锅归谁
性能数据其实网上一搜一大把,我写得再细也没什么新东西。这三遍写下来,我觉得选型真正该看的是一件事:线上出故障的时候,你能不能定位,社区里有没有人踩过同一个坑。
我整理了个故障归属表,都是我实际遇到或者搜到过的:
- 输入法遮挡输入框:Flutter 靠 resizeToAvoidBottomInset + MediaQuery.viewInsets,RN 得自己监听 Keyboard 事件算偏移,uni-app x 的 input 组件默认会顶上去但软键盘高度在部分华为机型上给的是 0。
- 系统字体放大到 1.3 倍以上:Flutter 用 textScaler 得手动 clamp,不然卡片直接撑破;RN 的 Text 默认跟随系统且不好局部关闭;uni-app x 需要在 manifest 里配字体缩放策略。
- 深色模式切换时闪白:Flutter 要在 MaterialApp 里显式指定 themeMode 并处理原生侧背景色,RN 得改 Android 的 windowBackground,uni-app x 走 pages.json 的配置。
- 刘海屏安全区:三家都要处理,但关键词不一样,Flutter 搜 SafeArea,RN 搜 react-native-safe-area-context,uni-app x 搜 safe-area-inset。
这张表的价值在于,你能提前知道每个问题该去哪个仓库提 issue、搜什么关键词。我第一版用 RN 的时候,输入法遮挡卡了整整一个下午,最后是在 GitHub 上一个 2019 年的 issue 里翻到解决方案的,而这个 issue 现在已经关了,理由是「新架构已修复」。
热更新这件事,2025 年的处境变了
还有一个选型时必须想清楚的点:能不能不发版就修 bug。
Flutter 官方是不支持动态下发 Dart 代码的,AOT 编译出来的 snapshot 改不了。想用只能上 Shorebird,它 2024 年发 1.0 之后确实能用,但你的构建流程得整个迁到它的 CI 上,而且 iOS 那边依然有审核风险。
RN 这边,用了很多年的微软 CodePush 已经没了——App Center 在 2025 年 3 月 31 日正式退役,CodePush 服务跟着关停。现在要么迁到社区维护的开源版本自己搭服务,要么换 Expo 的 EAS Update。这个变化挺多人还不知道,我上周还看到有团队在按老教程接 CodePush。
uni-app 的 wgt 资源包热更新倒是还能用,而且配置简单,但同样,iOS 上只更新 JS 和资源不动原生代码是相对安全的,动了原生模块就等着被拒。
我最后选了哪个
这个 App 最后用的是 uni-app x。不是因为它性能最好——列表滚动它输给 Flutter——而是因为需求里有一半的入口在企业微信和钉钉里,这两个容器本身对包体积敏感,而且我只有一个人,wgt 热更新能省掉一大半发版流程。156MB 的内存占用在中低端安卓机上也不算吃力。
如果你的项目是重交互、重动画、有专门的设计规范要复刻,Flutter 依然是我的第一选择,它的确定性最强。如果团队本来就有 React 技术栈、需要大量复用 web 端逻辑,RN 新架构现在已经可以正经用了,但一定记得把 newArchEnabled 打开,不然你测出来的数据会劝退你自己。
选框架这件事,说到底不是比谁的 benchmark 好看,是比谁在凌晨两点出线上事故的时候,能让你更快找到人问。