文章

热更新原理深度解析

从Bundle生成、增量补丁到回滚机制,拆解RN热更新的核心原理和自建方案。

热更新原理深度解析

一句话概括

RN 热更新的本质是在不经过应用商店审核的情况下替换 JS Bundle 文件和 Assets 资源,核心三件事:Bundle 怎么生成、增量补丁怎么打、回滚怎么兜底。

核心知识点

1. 热更新能做什么、不能做什么

清楚边界是面试第一关:

1
2
✅ 能做的:JS 逻辑变更、UI 调整、文案修改、样式修复、第三方 JS 库升级
❌ 不能做的:原生代码(Java/ObjC)修改、新增权限、新增 Native Module、修改 Info.plist

为什么不能更新原生代码? 因为 iOS/Android 不允许从网络下载并执行可执行代码。JS 能热更是因为它在 Hermes/JSC 虚拟机里运行,字节码不算「原生可执行代码」。这也是 Flutter 不支持热更新的根本原因——Dart 编译成了原生机器码。

2. Bundle 的生成与结构

Metro 打包出来的 Bundle 是一个自执行的闭包数组,每个模块被分配一个数字 ID:

1
2
3
4
5
6
7
8
9
// Metro 输出的 Bundle 结构
__d(function(g, r, i, a, m, e, d) {
  // 模块 42 的代码
  // r(0) = require 模块 0
}, 42);

__d(function(g, r, i, a, m, e, d) { /* 模块 0 = react */ }, 0);

require(42); // 入口

为什么用数字 ID 而不是路径字符串? 数字 ID 在 gzip 下压缩率极高、且 require(42) 比 require('./path/to/file') 查找快得多。这个设计也是增量补丁体积能做到全量 10-20% 的基础。

3. 增量更新——只传变化的部分

全量下载一个 5MB 的 Bundle 太慢了。增量更新的思路是:服务端算出「旧版本 → 新版本」的差异补丁,客户端下载补丁后本地还原:

1
旧 Bundle (1.0, 5MB) + 补丁包 (0.5MB) → bspatch → 新 Bundle (2.0, 5MB)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 客户端判断用补丁还是全量
async function checkUpdate(localHash, remoteHash) {
  // 1. 先问服务端:有没有针对我当前版本的补丁?
  const patchUrl = `${CDN}/patch/${localHash}/${remoteHash}`;
  const patchResp = await fetch(patchUrl);
  
  if (patchResp.ok) {
    // 有补丁 → 只下载 0.5MB
    const patch = await patchResp.arrayBuffer();
    const oldBundle = await readLocalBundle();
    return applyBsPatch(oldBundle, patch); // 本地还原
  }
  
  // 无补丁 → 回退到全量下载
  return downloadFullBundle(remoteHash);
}

补丁体积为什么能做到这么小? bsdiff 算法在二进制层面找差异,Metro 的数字 ID 模块系统让模块插入/删除只影响局部,不会导致整个文件「面目全非」。

4. 回滚机制——热更新的最后防线

热更新最怕的事:新 Bundle 上线后所有用户崩溃,而你来不及审核发版。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// CodePush 的回滚检测:启动时设一个标记文件,正常启动后删除
// 如果下次启动发现标记还在 = 上次 crash 了

const ROLLBACK_MARKER = `${sandboxDir}/codepush/crashed.json`;

function onAppStart() {
  const crashed = readJSON(ROLLBACK_MARKER);
  if (crashed && crashed.count >= 3) {
    // 连续 crash 3 次 → 回滚到上一个版本
    revertToPreviousBundle();
    deleteFile(ROLLBACK_MARKER);
    return;
  }
  // 标记「正在运行新版本」,30 秒后自动删除(表示没 crash)
  writeJSON(ROLLBACK_MARKER, { count: (crashed?.count || 0) + 1 });
  setTimeout(() => deleteFile(ROLLBACK_MARKER), 30000);
}

核心思路: crash 标记 + 计数的组合确保「偶然 crash 不回滚,确实坏了才回滚」。

5. 分包策略——基础包和业务包分离

单一 Bundle 在大型 App 中会膨胀到 10-20MB。分包把代码拆成两层:

1
2
基础包 (base.bundle, ~5MB):react、react-native、通用工具库 → 打成 App 包内,基本不变
业务包 (biz.bundle, ~1-3MB):页面组件、业务逻辑 → 走热更新,每次迭代只升级这个

Metro 的分包配置关键是 createModuleIdFactory——让基础包和业务包共享同一套模块 ID 空间,加载时不会冲突。

其实你每天都在用

  • 微信小程序更新: 背后的逻辑和 RN 热更新一模一样——JS 逻辑包从 CDN 下载,替换本地缓存,下次启动生效
  • 淘宝/京东首页布局突然变了却没更新 App: 就是热更了业务 Bundle,页面结构是「动态楼层」渲染的
  • App 内弹窗让你「立即重启」: 强制更新(Mandatory)模式,下载完立即 RCTReloadCommand
  • 游戏 App 的「正在下载更新包」进度条: 同样是 Bundle 替换 + Assets 资源更新,只是换了下载进度 UI
  • App 首次安装后打开特别快、第二次反而慢: 首次用的是 APK 内的基础包(本地加载极快),第二次检测到更新后开始下载新业务包

常见误解(FAQ)

❌ 误区1:「热更新 = 绕过审核,什么都能改」

热更新只能改 JS 代码,不能改原生代码。如果你新增了一个需要相机权限的功能,热更新做不到——因为没有在 AndroidManifest.xml 中声明权限。苹果审核尤其关注这一点:如果热更新被用来「从审核版切换到功能完全不同」的商业版,会被下架。

❌ 误区2:「用了 Hermes,热更新就不能做了」

Hermes 的 .hbc 字节码文件可以像 .jsbundle 一样替换。只是构建链路多了一步:Babel 转译 → Hermes 编译 → 打包为 .hbc。CodePush 和大多数自建方案都已适配 Hermes。

❌ 误区3:「自建热更新太复杂,直接用 CodePush 就行」

CodePush 已被微软整合进 App Center,进入维护模式(不新增功能、不保证 bug 修复节奏)。新项目建议评估自建方案——核心就一个文件上传 + 版本比对 + 下载的 HTTP 服务,复杂度不高,但自主可控。

❌ 误区4:「灰度和热更新是两件事」

它们是同一件事。灰度就是在「检查更新」接口里加条件判断——按用户 ID 哈希分流、按渠道分流、按地区分流。自建更新服务做灰度只需要在 checkUpdate 里加一段 if。

一句话总结

热更新 = Bundle 替换 + 增量补丁 + 回滚保护,三件事做好了就能做到「上线一个修复,10 分钟全网生效」——但别忘了,它改写不了原生代码。

本文由作者按照 CC BY 4.0 进行授权