oh-my-hermes:让React Native的Hermes引擎从默认开启变为可掌控

oh-my-hermes:让React Native的Hermes引擎从默认开启变为可掌控 升级到 RN 0.70 之后我遇到过一个特别闹心的问题应用冷启动时首屏偶尔白屏 1 到 2 秒而且主要出现在低端 Android 机上。我们用最笨的办法一台一台连 Android Studio 盯内存曲线最后发现启动阶段 JavaScript 堆在几百毫秒内冲到一个很高的水位紧接着引擎触发了一次长时间的垃圾回收UI 线程被卡住白屏随之而来。第一反应当然是查业务代码有没有内存泄漏。查了一圈之后确认泄漏确实存在但它不是白屏的根因。真正的根因是——RN 0.70 之后Hermes 引擎已经是默认引擎而我们团队对它的配置基本是零管理。hermesEnabled用默认值GC 参数用默认值字节码编译参数也用默认值。换句话说整个团队把开箱的默认值当成了调优后的最佳值。这就是我做oh-my-hermes这个项目的起点。一个专门围绕 Hermes 引擎做配置管理、诊断和插件扩展的小工具把散落在原生工程里的各种开关、参数、构建脚本收敛成一份可评审、可回滚、可共享的配置。这篇文章会把这件事完全拆开Hermes 的默认配置到底替我们决定了多少事oh-my-hermes 用什么思路把这些决定权拿回来双端 RN 工程怎么接入参数调整前后实测数据差多少以及我在这个过程中踩过的坑。适合正在用 RN 0.64 以上版本、希望把 Hermes 从默认开启变成可掌控的开发者和团队。1. 先搞清楚Hermes 引擎的默认配置到底替我们决定了几件事1.1 Hermes 为什么值得开启动预编译与无 JIT 的设计Hermes 是 Meta 专门为 React Native 设计的 JavaScript 引擎。它和 iOS 上常用的 JavaScriptCore 最大的区别可以用一句话概括Hermes 在发布包构建阶段就把 JavaScript 源码编译成了字节码运行时不再做逐行解释也不启用 JIT 即时编译。这个决策的本质是取舍。JIT 可以在运行时根据热点代码动态生成机器码理论上的峰值计算性能更高但代价是启动时要经历解释执行、类型推断、编译优化的过程内存占用也会因为 JIT 生成的代码而明显上升。移动端 App 不是跑基准测试用的服务器用户能感知到的性能不是浮点运算速度而是冷启动到首帧的时间、列表滚动的流畅度、弱网下的内存水位。Hermes 放弃了 JIT换来的是更可预测的启动时间、更小的运行时内存占用以及更紧凑的字节码产物。JS Bundle 编译成 Hermes 字节码后体积通常会进一步缩小这对下载包大小也是实打实的收益。RN 从 0.60 开始支持可选启用 Hermes0.64 之后 Android 端默认开启0.70 之后 iOS 端也默认开启。但引擎好不等于默认配置就适用于你的业务。默认配置是官方在通用场景下权衡出来的结果它要考虑的是所有 App 的“平均需求”而不是你这款产品的“具体痛点”。问题恰恰出在这里很多团队升级到新版本后连hermesEnabled代码是从文档里抄的还是自动生成的都不清楚更别说去调整引擎内部的内存管理策略了。1.2 默认配置的三个痛点白屏、GC 停顿、内存峰值我在实际项目里被默认配置坑过三次每次都很有代表性。第一个是启动白屏。App 冷启动时JS 代码开始执行业务初始化逻辑会在极短时间内创建大量对象内存使用量猛涨紧接着触发垃圾回收。在 GC 执行期间JavaScript 线程和 UI 线程都可能被阻塞表现就是首帧迟迟画不出来。默认的 GC 参数是通用的它不知道你的启动逻辑会在 300ms 内分配 80MB 内存也不会提前为你错峰。第二个是GC 停顿带来的滚动卡顿。列表快速滑动、图片批量加载、长页面渲染这些场景都会导致内存频繁分配。一旦某个时点分配速率超过回收速率引擎就会触发一次整堆回收。整堆回收的耗时和存活对象数量成正比对象越多停顿越明显。Hades 并发 GC 虽然已经比之前的 GC 策略平滑很多但并发线程数量、堆水位阈值这些参数在普通 RN 工程里根本没有人去动。第三个是内存峰值不可控。图片缓存、列表 Item 复用产生的中间对象、全局状态管理库里的旧数据这些都会叠加在引擎堆上。默认配置不会主动限制一个页面能用的堆上限结果就是低端机上内存曲线一路走高偶尔还会被 Android 系统的进程优先级调整机制“请”出后台。这三个痛点的共性不是 Hermes 本身不行而是配置没有被工程化地管理起来。每个团队都在用手工方式改 Gradle、改 Podfile、改原生入口代码改完之后没人知道线上有多少个参数版本新增一个成员还得从头解释一遍。oh-my-hermes 就是冲着这个问题去的。2. oh-my-hermes 的设计思路像管理 zsh 配置一样管理引擎2.1 核心概念配置预设、插件、诊断命令oh-my-hermes 的命名是向 oh-my-zsh 致敬设计哲学也直接搬了它三件套配置预设Profile、插件Plugin、诊断命令Doctor。配置预设解决“不同场景下用哪套参数”的问题。低端机、首屏启动、长列表滚动每套预设对应一组引擎参数和构建参数。你不需要理解每个参数的底层含义先在预设里选一个基线再基于基线做增量覆盖。我一开始直接给参数表结果团队评审时没人能看懂改成预设之后讨论才能聚焦在“我们的低端机用户占比高不高”这种业务问题上。插件解决“引擎运行数据从哪来”的问题。内置了 GC 日志解析、性能采样数据导出、字节码产物校验这些能力。插件的输出格式是统一的 JSON方便接进现有监控平台不喜欢默认插件可以自定义。诊断命令解决“Hermes 到底开没开、是不是预期版本、字节码是否匹配”这类问题。oh-my-hermes doctor会检查原生配置、编译产物、运行时版本三端是否一致避免“看起来改了实际没生效”的幻觉。2.2 为什么不用 JSON 做配置文件配置文件我选了 TOML没选 JSON。不是 JSON 不好而是配置预设需要两个 JSON 天然不擅长的事注释和稀疏覆盖。JSON 不允许注释而引擎参数优化是一件特别依赖“历史背景”的事情。heap_used_threshold 80这个数字如果没有注释下一个人根本不知道它是为了压低启动白屏概率才调的还是为了减少 GC 次数调的。TOML 支持注释我可以在每个关键参数旁边写上“为什么是这个值”。JSON 做预设继承也比较痛苦。low_end预设大概率只和base预设差两三个字段但 JSON 的合并逻辑写起来很绕要么引入深合并依赖要么复制一大堆重复键。TOML 配合extends字段可以表达得非常简洁[profile.base] hermes_enabled true [profile.base.gc] # Hades 并发 GC 的线程数字段名以当前引擎暴露的配置为准 gc_threads 2 # 触发 GC 的堆占用阈值百分比 heap_used_threshold 80 [profile.low_end] extends base hermes_enabled true [profile.low_end.gc] gc_threads 1 heap_used_threshold 70 [profile.low_end.assets] bytecode_compression true这纯粹是工具设计上的取舍JSON 适合机器互读TOML 适合人读。引擎配置的维护者最终是“人”所以选择 TOML。2.3 配置注入的原理不改源码只改构建参数工具最怕变得比业务代码本身的冲突还难解决。所以 oh-my-hermes 从一开始就定了一个原则不动业务开发者手写的原生代码。配置注入只在两个层面发生。第一层是构建参数走 Gradle 的gradle.properties和 CocoaPods 的Podfile也就是hermesEnabled这类官方已经预留的开关。第二层是启动参数走原生入口的初始化代码。工具不会直接改你的MainApplication.java或AppDelegate.mm而是生成一个独立的初始化文件由它读取配置、设置 GC 参数后再启动 React 实例。业务代码不侵入卸载工具后只剩一个很小的加载入口删掉也容易。这里补充一点基于常见实践的说明不同 RN 版本、不同架构模式下引擎初始化代码的注入点差异很大工具在init时会自动检测工程类型并生成对应的 hook 模板。也就是说你不一定需要关心底层具体是ReactInstanceManager还是新的ReactHost但自己心里要有数不能完全无脑信任工具。3. 接入 RN 工程从一条命令到双端生效3.1 初始化与目录结构安装和初始化我尽量做得接近零成本直接npx oh-my-hermes init --platform android,ios --profile low_end执行后会在工程根目录生成.hermes/目录.hermes/ ├── hermes.toml ├── profiles/ │ ├── base.toml │ └── low_end.toml ├── plugins/ │ ├── gc-log-parser.js │ └── bundle-inspector.js └── generated/ ├── android/ └── ios/generated目录放的是工具生成的配置补丁不要手动编辑。升级工具之后跑一次oh-my-hermes apply --profile low_end就能重新生成保证补丁和当前版本匹配。这里有个小提醒init 时选的 profile 只是初始值后续可以通过apply命令随时切换。团队里如果不同成员负责不同场景的性能验证各自在本地切 profile 是很正常的只要注意不要带着本地预设去打发布包。3.2 Android 端构建配置Android 端主要改两处。第一处是android/gradle.propertieshermesEnabledtrue第二处是确认app/build.gradle里依赖的是hermes-engine而不是jsc。RN 0.64 之后 Android 默认使用 Hermes但很多老工程是从旧版本升上来的build.gradle里可能残留jsc相关依赖。apply命令会检查这一项如果发现残留会明确提示而不是悄悄覆盖。改完之后老项目建议先清理一次再构建cd android ./gradlew clean这一步容易被忽略但 Gradle 的增量缓存有时候会让旧产物一直躺在你的构建目录里导致改了配置和没改一个样。3.3 iOS 端构建配置iOS 端在ios/Podfile里控制。关键是看这一行:hermes_enabled true不少工程只在 Android 开了 HermesiOS 还在用 JavaScriptCore结果就是双端行为不一致iOS 上复现不了 Android 上测出来的收益。apply 命令会把两端统一到同一个预设下。改完 Podfile 后需要重新执行cd ios pod install这一步工具不会替你跑。pod install可能很慢而且你可能用了私有源环境差异太大建议手动执行并看清楚输出。3.4 doctor 检查怎么确认 Hermes 真的生效了配置改完最怕的是“看起来改了实际没生效”。oh-my-hermes doctor会做三件事读构建配置确认hermesEnabled是 true检查构建产物确认 JS Bundle 是否被编译成了 Hermes 字节码在运行时环境里检测HermesInternal确认真实引擎。运行时检测建议放在开发者菜单或内部测试版里代码很简单if (typeof HermesInternal object HermesInternal ! null) { // 当前确实是 Hermes 引擎 console.log(HermesInternal.getRuntimeProperties()); }这条代码价值极高。我后来排查“线上包怎么比测试包卡很多”的时候就靠它发现某条构建链路过期缓存了旧的 JSC Bundle。doctor 的产物检查能提前拦截这种问题比线上埋点早几步。4. 实测对比同一台手机开与不开的差距4.1 测试方案设计做性能对比最忌讳一次测试就下结论。Hermes 的启动和 GC 行为受系统当前状态影响极大后台进程、温度、网络都会干扰结果。所以我用了一套固定的测试方法固定设备、固定网络、重复 15 次、去掉最大最小各 2 次、取中位数。被测对象是一个“启动即进入列表页并加载 500 条图文混合数据”的业务场景。为什么选这个场景因为当时我被用户反馈最多的就是首屏慢和列表卡其他场景的性能问题优先级都往后排。4.2 实测结果与表格测试设备选了一台 2019 年的中端 Android 机系统 Android 11可用内存 6GB 左右。对比三组JavaScriptCore 默认配置、Hermes 默认配置、Hermes low_end 预设。指标JSC 默认Hermes 默认Hermes low_end冷启动到首帧中位数1840ms1320ms1090ms启动阶段内存峰值312MB258MB221MB列表滚动 60 秒内 GC 卡顿次数420整包体积Android AAB42.1MB38.6MB37.8MB坦率讲这些绝对数字只对那台设备有意义不同设备、RN 版本、业务代码都会让数字变化。但趋势是可以复现的Hermes 让启动更快、内存更低而在 Hermes 之上再做参数调整能在低端机上把 GC 卡顿再压下去一截。4.3 这些提升背后的参数逻辑low_end 预设没有做什么魔法核心就两个方向的调整。一个方向是降低堆水位阈值让 GC 提前触发。这听着反直觉让 GC 频繁卡顿不是应该更多吗但在低端机上延迟触发 GC 会导致堆占用逼近系统阈值最终 GC 一来就是整堆大回收停顿更久。小而频繁的回收反而比一次性大回收平滑。另一个方向是限制 GC 并发线程数。GC 并发线程太多会和 UI 渲染抢占 CPU 核心。中端机的大核数量少限制并发线程能让主线程的时间片更稳定对应用户体验就是滚动更跟手。这也是为什么我一直强调参数调整不是简单的“越大越好”或“越小越好”而是要根据设备能力、业务特性、性能目标三者反复校准。5. 使用中会踩的坑排错链路与兼容性5.1 “Hermes not enabled”的排查链路最经典的问题配置写了 true运行时依然是 JSC。很多人一上来就翻 RN 文档或者干脆重装依赖其实排查链路应该是这样的。第一步先确认HermesInternal是否存在。如果运行时不存在说明根本没加载 Hermes runtime。可能原因之一是 Gradle 任务缓存把旧包当成最新包./gradlew clean清一遍再跑。第二步检查build.gradle里实际生效的依赖坐标。RN 0.60 到 0.64 之间很多工程同时写入了jsc相关依赖hermesEnabled开关只是控制默认引擎但某些依赖传递会把 JSC 带回来。一个隐藏很深的坑是react-native依赖自己在 pom 里同时依赖了 jsc需要看dependencies树才能发现。第三步看构建产物里实际包含的libhermes.so。把 AAB 或 APK 解包lib/arm64-v8a/目录下如果有libhermes.so且没有libjsc.so才可能是 Hermes 生效。两个 so 文件同时在说明构建配置有冲突。5.2 Hermes 字节码与热更新包格式动态下发 JS Bundle 的场景要格外小心。Hermes 使用的是编译后的字节码不是普通 JavaScript 文本所以下载下来的 bundle 必须是对应 Hermes 的字节码格式不能用之前为 JSC 生成的 bundle。我整理过三条原则现在直接写进团队规范里构建时分别产出 JSC 格式和 Hermes 字节码格式按当前引擎版本下发引擎版本升级后老字节码不一定兼容要做版本管控只在测试环境验证通过后再灰度下发。我在一次灰度时就因为下发了 JSC bundle 给 Hermes 运行时导致线上直接报解析错误。现在 oh-my-hermes 的bundle-inspector插件会检查字节码头信息格式不匹配直接构建失败彻底断了这条路。5.3 Debug 与 Release 差异在 Debug 下复现不了线上问题Debug 模式下的行为与 Release 有巨大差异。Debug 模式要连接 Metro 做实时编译还要加载 source map很多构建期优化会被关闭GC 行为也完全不同。一个真实案例团队在 Debug 模式下看到一个内存问题折腾了一整天后来打出 Release 包问题根本不存在。原因是 Debug 模式下 Metro 引擎每次更新都产生大量中间对象把内存曲线整体抬高而 Release 下字节码直接加载内存水位低得多。所以经验是性能问题排查一定要用 Release 包或者至少用--mode release打一个带调试功能的包。doctor命令也支持区分模式oh-my-hermes doctor --mode release会明确提示你当前检查的是哪一种构建产物避免字段误判。5.4 新架构New Architecture下的注意事项如果项目已经切到 RN 新架构也就是启用 Bridgeless 或 FabricHermes 的启动阶段会走新的 runtime 初始化链路不再完全由老的ReactInstanceManager控制。启动参数注入插件在这一层需要改成适配ReactHost的模式。接入前先确认自己 RN 版本是否带新架构开关再决定生成的补丁是老的初始化入口还是新的入口。这两个入口不能混用否则轻则参数不生效重则启动崩溃。我见过一个团队把老架构的注入代码硬塞进新架构工程崩溃日志指向完全无关的模块排查难度直接翻倍。6. 进阶按业务场景自定义配置预设6.1 三种场景的 profile 参考预设不是越多越好。我给团队的默认建议是三套基线覆盖绝大多数业务场景核心诉求参数方向性能优先首屏越快越好提前 GC 频率限制并发线程低端机不闪退、不卡死更激进的内存水位关闭可选耗性能特性长列表/信息流滚动跟手提高堆阈值错峰 GC降低首次 GC 影响这三套基线是从实际业务场景抽象出来的你可以基于它们做增量覆盖。比如“性能优先”基础上再加一个disable_hermes_debug_log之类的构建期精简项完全没问题。6.2 利用内置插件做性能监控与参数反推我的建议是“先有数据再动参数”。内置 GC 日志解析插件会把引擎输出的 GC 日志转成时间线数据重点看两个东西GC 总耗时占比和GC 触发时的堆占用分布。如果 GC 总耗时占比超过 3%优先限制并发线程让 GC 更平滑而不是急着加内存。如果堆占用在接近阈值时频繁触发 GC说明阈值太低空间利用率差要适当上调。如果内存峰值在高位并且伴随 GC 卡顿那大概率是某些操作在短时间连续分配了大量内存比如图片数组一次性 set 进状态这种情况调参解决不了得改业务侧的加载策略。这里贴一段工具导出的日志格式示例方便你理解结构[GC] young collection, heap 58.2MB - 41.3MB, paused 6ms [GC] full collection, heap 171.4MB - 89.6MB, paused 118ms看到这种 118ms 的 full collection就说明整堆回收已经对用户体验造成了可感知的停顿必须处理。6.3 最容易见效的第一件事如果你第一次接触这个方向别急着把参数抄一遍。先做这一步打开 GC 日志跑一遍真实业务路径。Hermes 的 GC 日志能告诉你引擎在哪个阶段做了多少次整堆回收每次回收耗时多少。我看日志时发现一次 120ms 的整堆 GC 停顿才真正下定决心把参数调整作为正式优化项而不是一句“手机太差”就完事。调整参数的过程也别一口气全改。一次只动一个参数对比一组数据再决定下一步。同时改三个参数最后的收益不知道是哪个带来的出了问题也不知道哪个是根因。这条纪律救了我的项目很多次。最后说一下我对 oh-my-hermes 本身的定位。它不打算替代 React Native 官方对 Hermes 的支持也不是一个黑魔法优化器。它解决的是配置管理的问题让引擎的开关、参数、产物校验变得可评审、可回滚、可推演。如果团队里没有专职性能优化的人把 Hermes 从“默认开启”变成“可掌控”比追求几个百分点的提升更实际。先跑一轮 GC 日志你会发现值得优化的地方比想象中多得多。