iOS 开发中的热更新

由浅入深,从基本概念到源码解析,带你全面掌握 iOS 热更新的原理、方案与实战理、方案与实战


一、什么是热更新?为什么需要它?

1.1 从「发版周期」说起

传统原生 iOS 开发的痛点:

  • 审核周期长:App Store 审核通常需要 1~3 天,紧急 Bug 无法及时修复
  • 版本割裂:用户更新率有限,线上可能同时存在多个旧版本
  • 迭代成本高:每次改动都要重新打包、提审、等待上线

热更新(Hot Update) 的核心诉求是:在不发新版本、不通过 App Store 审核的前提下,动态更新应用逻辑与资源

1.2 热更新的分类

类型 说明 典型场景
资源热更新 更新图片、配置、文案等非代码资源 活动页、Banner、AB 实验
脚本热更新 更新 JavaScript/Lua 等脚本,在解释器中执行 RN、Weex、游戏 Lua
原生热修复 通过 Runtime 等手段替换/修补 OC 方法 紧急 Bug 修复(受政策限制)

1.3 热更新能解决什么问题?

  • 紧急 Bug 修复:线上崩溃、逻辑错误,可快速下发补丁
  • 业务快速迭代:活动页、营销逻辑,无需等审核
  • 灰度与回滚:小范围验证后全量,出问题可秒级回滚

二、核心原理

2.1 动态执行:热更新的基础

iOS 支持多种「运行时执行代码」的方式,这是热更新的技术前提:

1
2
3
4
5
6
7
┌─────────────────────────────────────────────────────────────┐
│ 热更新技术栈层次 │
├─────────────────────────────────────────────────────────────┤
│ 应用层 │ 下发 JS/Lua/配置 → 解释执行 / 加载资源 │
│ 中间层 │ JavaScriptCore / Lua VM / 自研解释器 │
│ 系统层 │ Runtime、dlopen、performSelector 等 │
└─────────────────────────────────────────────────────────────┘

不同方案对「动态执行」的依赖程度不同,也决定了其合规性。

2.2 两类热更新思路

思路 机制 代表方案 Apple 态度
脚本在容器内执行 使用系统提供的 JS 引擎(如 JavaScriptCore)执行脚本,不直接修改原生代码 React Native OTA、Weex 原则上允许
直接 Hook 原生方法 通过 Runtime、libffi 等替换 OC 方法的 IMP,或动态下发可执行代码 JSPatch、WaxPatch 明确禁止

2.3 Apple 3.3.2 条款与审核边界

Apple 开发者协议 3.3.2 明确规定:

应用不得包含、提供或使用未包含在应用中的可执行代码的下载、安装或执行机制。

被严格禁止的:

  • 下载并执行任意原生代码(如通过 dlopendlsym 动态加载)
  • 使用可调用原生 API 的脚本引擎(如 JSPatch 通过 JS 调用 performSelector:、修改 IMP)
  • 绕过审核、改变应用主要功能或目的的动态能力

允许的例外:

  • 使用 WebKitJavaScriptCore 执行脚本
  • 前提:不改变应用的主要功能或目的,与提交版本及宣传描述相符
  • 典型:React Native 的 JS Bundle 热更新、游戏内 Lua 脚本(在引擎容器内运行)

核心差异:Apple 禁止的是「能绕过审核、调用私有 API、实质改变应用功能」的方案,而非热更新本身。使用系统 JS 引擎、仅更新业务逻辑(不修改原生层)的方案,在合理范围内通常可接受。


三、主流方案概览

3.1 方案对比

方案 类型 合规性 能力边界 适用场景
React Native OTA 脚本热更新 ✅ 合规 只更新 JS 层 RN 项目
CodePush 脚本热更新 ✅ 合规 RN/Weex JS Bundle 需完善 OTA 能力的 RN 项目
JSPatch 原生热修复 ❌ 禁止 可替换任意 OC 方法 已弃用
TTDFKit (TTPatch) 原生热修复 ⚠️ 风险 基于 libffi 动态调用 内测/企业包
BuglyHotfix 原生热修复 ⚠️ 风险 兼容 JSPatch 脚本 企业级、需完整工具链
Lua + 游戏引擎 脚本热更新 ✅ 合规 游戏逻辑在引擎内 游戏项目

3.2 选型建议

项目类型 推荐方案
React Native 应用 CodePush 或自建 OTA 服务
纯原生 + 需热修 优先考虑架构调整(如 RN/Flutter 化),慎用原生热修
游戏 引擎自带 Lua/脚本热更
企业内部分发 可评估 TTDFKit、BuglyHotfix,注意合规

四、React Native OTA 热更新

4.1 基本原理

RN 的 UI 与业务逻辑运行在 JavaScript 中,而 JS 可由 JavaScriptCore(或 Hermes)在运行时执行。因此,只需将新的 JS Bundle 下发到设备,启动时优先加载该 Bundle,即可实现热更新,无需修改原生代码

1
2
3
4
5
6
7
8
┌─────────────────────────────────────────────────────────────┐
│ RN 热更新流程 │
├─────────────────────────────────────────────────────────────┤
│ 1. 服务端:打包 JS Bundle(含业务逻辑) │
│ 2. 下发:通过 HTTP/CDN 将 Bundle 下发到客户端 │
│ 3. 客户端:存储到本地(如 Document 目录) │
│ 4. 启动:RCTBridge 优先加载本地 Bundle,而非打包进 App 的 │
└─────────────────────────────────────────────────────────────┘

4.2 CodePush 架构

CodePush 是微软推出的 RN 热更新方案,由三部分组成:

组件 职责
code-push-server 服务端:身份认证、更新包存储、版本校验、下载、统计
code-push-cli 命令行:登录、打包、部署
react-native-code-push 客户端 SDK:检测更新、下载、安装、上报

版本策略:支持 semver 约束,例如 ^1.2.3 表示 >=1.2.3 <2.0.0 的原生版本可收到该更新。

4.3 集成示例

1
2
3
# 安装
npm install --save react-native-code-push
cd ios && pod install
1
2
3
4
5
6
7
8
9
10
11
// AppDelegate.m - 指定 Bundle 加载路径
#import <CodePush/CodePush.h>

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
return [CodePush bundleURL]; // 生产环境从 CodePush 获取
#endif
}
1
2
3
4
5
6
7
// 检查更新
import codePush from 'react-native-code-push';

codePush.sync({
updateDialog: { title: '发现新版本' },
installMode: codePush.InstallMode.IMMEDIATE,
});

4.4 自建 OTA 简要思路

若不使用 CodePush,可自建:

  1. 服务端:提供接口,根据 appVersionbinaryVersion 返回可用的 Bundle 信息(URL、hash、是否强制)
  2. 客户端:启动时请求接口,若有新版本则下载到本地
  3. 加载:修改 RCTRootViewbridge 初始化,优先从本地路径加载 JS Bundle

五、JSPatch 原理与源码解析(历史与教育意义)

⚠️ JSPatch 已被 Apple 明确禁止,此处仅作原理与源码层面的学习参考。

5.1 核心思想

JSPatch 通过 JavaScript 调用 Objective-C,利用 OC 的 Runtime 动态性,实现:

  1. 用 JS 写「补丁逻辑」
  2. 下发 JS 到客户端
  3. 在 JavaScriptCore 中执行
  4. JS 通过桥接层调用 OC Runtime,替换方法实现(IMP)

5.2 JS 调用 OC 的底层机制

OC 是「消息型」语言,方法调用本质是 objc_msgSend(receiver, selector, ...)。JSPatch 的做法是:

  • JS 侧:调用 require('UIView').alloc().init()
  • 桥接层:将 UIViewallocinit 等字符串传给 OC
  • OC 侧:通过 NSClassFromStringclass_getInstanceMethod 等 Runtime API 获取类和方法,用 objc_msgSendNSInvocation 完成调用
1
2
3
4
5
6
7
8
9
10
11
JS: UIView.alloc().init()


JPEngine: 解析调用链 → 获取 Class、SEL


Runtime: objc_msgSend([UIView class], @selector(alloc))
objc_msgSend(allocResult, @selector(init))


返回 OC 对象给 JS(封装为 JPBoxing 等结构)

5.3 方法替换(热修复)实现

热修复的关键是「替换方法的 IMP」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 伪代码:JSPatch 方法替换思路
// 原方法:- (void)originalMethod;
// 新实现:在 JS 中定义,通过桥接调用

static void (^jsImplementation)(id, ...) = ...; // 从 JS 传过来的 block

IMP newIMP = imp_implementationWithBlock(^(id self, ...) {
// 调用 JS 定义的逻辑
jsImplementation(self, ...);
});

// 替换
Method m = class_getInstanceMethod(cls, @selector(originalMethod));
method_setImplementation(m, newIMP);

5.4 为何被禁止

JSPatch 能让 JS 间接调用任意 OC 方法,包括:

  • performSelector:
  • class_replaceMethodmethod_setImplementation
  • 私有 API、系统内部方法

这相当于 绕过了 App Store 审核,可动态改变应用行为,因此被 Apple 明确列入违规。


六、TTDFKit / TTPatch 与 libffi

6.1 简介

TTDFKit(原 TTPatch)是基于 libffi 的 iOS 热修复方案,不依赖 JSPatch,但能力类似:

  • 方法替换、动态创建方法
  • 添加属性、支持 Block
  • 支持 JavaScript 脚本下发

6.2 libffi 的作用

libffi(Foreign Function Interface)用于在运行时根据函数签名动态调用 C 函数。OC 方法本质上也是 C 函数(带 self_cmd 等参数),通过 libffi 可以:

  1. 根据 NSMethodSignature 得到参数类型、返回值类型
  2. 构造 ffi_cif(调用约定)
  3. 使用 ffi_call 调用目标 IMP

从而实现「用动态生成的逻辑替换原方法」。

6.3 合规性说明

TTDFKit 同样具备「动态修改原生方法」的能力,在正式上架 App Store 的包中使用的合规风险与 JSPatch 类似,更适用于企业内部或内测分发场景。


七、实战示例:RN 自建 OTA

7.1 服务端接口设计(简化)

1
2
3
4
5
6
7
8
// GET /api/ota/check?platform=ios&version=1.2.3&build=10
{
"hasUpdate": true,
"downloadUrl": "https://cdn.example.com/bundle/1.2.3.10.jsbundle",
"hash": "abc123",
"forceUpdate": false,
"minVersion": "1.2.0"
}

7.2 客户端核心逻辑(示例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// OTAManager.m
- (void)checkUpdate {
NSString *url = [NSString stringWithFormat:
@"https://api.example.com/ota/check?platform=ios&version=%@&build=%@",
appVersion, buildNumber];
// 发起请求,解析 hasUpdate、downloadUrl、forceUpdate
// 若 hasUpdate,则下载到 Document/OTA/bundle.js
}

- (NSURL *)bundleURL {
NSString *otaPath = [self otaBundlePath];
if ([[NSFileManager defaultManager] fileExistsAtPath:otaPath]) {
return [NSURL fileURLWithPath:otaPath];
}
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
}

7.3 启动时加载

1
2
3
4
// AppDelegate.m
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {
return [[OTAManager shared] bundleURL];
}

八、实际项目中的应用案例

8.1 电商 App:活动页热更新

场景:大促活动页面布局、规则频繁调整,无法每次发版。

方案:使用 React Native 搭建活动页,通过 CodePush 下发 JS Bundle。活动开始前发布新版本,用户打开即可看到最新逻辑,无需等 App Store 审核。

效果:活动页迭代周期从 1~3 天缩短到几分钟,支持灰度与秒级回滚。


8.2 金融 App:合规前提下的热更新

场景:需要快速修复展示类 Bug(如文案错误、接口字段变更),但不能修改核心交易逻辑。

方案

  • 交易、支付等核心流程保持纯原生,不热更
  • 非核心页面(资讯、活动、设置)采用 RN,通过 OTA 更新
  • 严格控制热更范围,符合「不改变应用主要功能」的审核边界

8.3 游戏:Lua 热更新

场景:Unity / Unreal / Cocos 等引擎,游戏逻辑用 Lua 编写。

方案:Lua 脚本存在服务端,启动时或进入场景时下载到本地,由引擎内嵌的 Lua VM 执行。不涉及原生代码动态替换,符合平台规则。


8.4 企业内包:JSPatch 替代方案

场景:内部分发、不通过 App Store,需要紧急修复原生 Bug。

方案:使用 TTDFKit 或 BuglyHotfix,下发 JS 补丁,替换有问题的 OC 方法。需注意仅用于内部分发场景,避免用于正式上架版本。


九、最佳实践与注意事项

9.1 合规优先

  • 上架 App Store 的应用:优先使用 RN OTA、CodePush 等脚本热更新,避免 JSPatch 类方案
  • 热更范围:尽量限制在业务逻辑、UI 展示,不触及支付、权限等敏感能力
  • 文档与描述:确保应用功能与审核描述一致,避免「隐藏能力」引起拒审

9.2 版本与兼容

  • 使用 语义化版本 约束可更新范围,避免旧版本收到不兼容的 Bundle
  • 做好 灰度:先小流量验证,再全量
  • 设计 回滚:保留上一版本 Bundle,出错时快速切回

9.3 安全与校验

  • 对 Bundle 做 哈希校验,防止篡改
  • 使用 HTTPS 下发,避免中间人攻击
  • 敏感逻辑不宜完全依赖热更,核心安全校验应留在原生

9.4 性能与体验

  • 启动时异步检查更新,不阻塞首屏
  • 大 Bundle 可做 差分更新,减少下载量
  • 明确 强制更新静默更新 策略,平衡体验与覆盖率

十、总结

维度 要点
概念 热更新 = 不发版、动态更新逻辑/资源,分资源、脚本、原生三类
原理 依赖运行时执行(JS 引擎、Runtime、libffi),不同方案能力与合规性不同
政策 Apple 3.3.2 禁止下载执行可执行代码;JS 在 JavaScriptCore 内执行、不改变主要功能的可接受
推荐 RN 项目用 CodePush/自建 OTA;纯原生慎用原生热修,优先考虑架构升级
实践 合规优先、版本约束、灰度回滚、安全校验

热更新能显著提升迭代效率,但必须在 合规、安全、可维护 的前提下使用。理解原理与边界,才能做出正确的技术选型与实现。

Author

Felix Tao

Posted on

2020-02-11

Updated on

2022-03-28

Licensed under