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;纯原生慎用原生热修,优先考虑架构升级
实践 合规优先、版本约束、灰度回滚、安全校验

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

iOS 开发中的 JSPatch

由浅入深,从原理到源码,全面解析 JSPatch 的设计思想、实现机制与关键技术细节


一、JSPatch 是什么?

1.1 定位与目标

JSPatch 是一个 iOS 动态更新框架,由 bang590 开源。其核心能力是:在 App 内引入极小的引擎后,用 JavaScript 调用任意 Objective-C 接口,并可替换原生方法实现,从而实现:

  • 热修复:下发 JS 脚本修复线上 Bug,无需发版、无需审核
  • 动态能力:为项目动态添加模块或替换原生逻辑

1.2 与 Apple 审核政策的关系

⚠️ 重要说明:Apple 开发者协议 3.3.2 明确禁止「下载、安装或执行未包含在应用中的可执行代码」。JSPatch 通过 JS 间接调用 Runtime、替换方法 IMP,被认为绕过审核、改变应用行为,已被 Apple 明确禁止上架使用。
本文仅从原理分析、技术学习与架构设计角度展开,不鼓励在正式上架 App 中接入。

1.3 技术栈位置

1
2
3
4
5
6
7
┌─────────────────────────────────────────────────────────────┐
│ JSPatch 技术栈层次 │
├─────────────────────────────────────────────────────────────┤
│ JS 脚本层 │ require / defineClass / 业务补丁逻辑 │
│ 桥接层 │ __c() 元函数、JPEngine、JPBoxing、类型转换 │
│ 系统层 │ JavaScriptCore、Objective-C Runtime │
└─────────────────────────────────────────────────────────────┘

二、基础原理:为什么 JS 能调用 OC?

2.1 根本原因:Objective-C 的动态性

JSPatch 能通过 JS 调用和改写 OC 方法的根本原因是:Objective-C 是动态语言。在 OC 中,类与方法的查找、调用、替换都在运行时通过 Objective-C Runtime 完成,而不是在编译期写死。

因此可以:

  • 通过类名/方法名字符串反射得到类和方法
  • 替换某个类的方法实现(IMP)
  • 动态注册新类、添加方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 反射调用
Class class = NSClassFromString("UIViewController");
id vc = [[class alloc] init];
SEL sel = NSSelectorFromString("viewDidLoad");
[vc performSelector:sel];

// 替换方法实现
static void newViewDidLoad(id slf, SEL sel) {}
class_replaceMethod(class, @selector(viewDidLoad), (IMP)newViewDidLoad, "v@:");

// 动态注册类
Class cls = objc_allocateClassPair(superCls, "JPObject", 0);
class_addMethod(cls, selector, implement, typedesc);
objc_registerClassPair(cls);

结论:JSPatch 的基本原理就是——JS 把类名、方法名、参数等以字符串/结构化数据传给 OC,OC 通过 Runtime 接口完成「查找类 → 查找方法 → 调用/替换」

2.2 整体数据流

1
2
3
4
5
6
7
8
9
10
11
12
13
JS: require('UIView').alloc().init()


__c() 元函数:解析调用链,得到 类名/方法名/参数/调用者


OC 桥接层:JPEngine 接收参数,类型转换,构造 NSInvocation


Runtime:objc_msgSend / NSInvocation 调用,返回结果


结果经 JPBoxing/包装后回传 JS,继续链式调用或使用

三、方法调用:从 JS 到 OC 的完整链路

下面以一段典型代码为例,拆解「JS 调用 OC」的五个环节:

1
2
3
4
require('UIView')
var view = UIView.alloc().init()
view.setBackgroundColor(require('UIColor').grayColor())
view.setAlpha(0.5)

涉及:require 机制 → JS 接口设计 → 消息传递 → 对象持有/转换 → 类型转换

3.1 require:在 JS 中「引入」OC 类

require('UIView') 的作用是:在 JS 全局作用域 上创建一个同名变量,指向一个表示 OC 类的 JS 对象。该对象用 __clsName 保存类名,并标记「这是 OC 类」。

1
2
3
4
5
6
7
8
var _require = function(clsName) {
if (!global[clsName]) {
global[clsName] = {
__clsName: clsName
}
}
return global[clsName]
}

于是 require('UIView') 之后,全局有:

1
UIView === { __clsName: "UIView" }

后续 UIView.alloc() 等调用,都基于这个对象进行。

3.2 JS 接口设计:如何让 UIView.alloc() 不报错?

3.2.1 问题:JS 没有「未定义方法」的转发机制

在 JS 中,若对象没有 alloc 属性,调用 UIView.alloc() 会直接抛错。不像 OC/Lua/Ruby 有「方法缺失 → 转发」的机制。

早期思路是:在 require 时向 OC 要该类(及父类)的全部方法名,在 JS 对象上为每个方法名挂一个函数,函数内部再调 OC。这样 JS 上就有 allocinit 等「真实存在」的属性。

问题在于:一个类就有几百个方法,还要沿继承链汇总,内存暴涨,且要维护 OC→JS 的方法列表同步,难以接受。

3.2.2 方案:正则替换 + __c() 元函数(关键优化)

不改变「JS 语法」,但在 OC 执行 JS 脚本之前,用正则把所有方法调用统一替换成对 __c() 的调用,从而在 JS 侧实现「任意方法名 → 统一入口」的转发:

1
2
3
4
5
// 替换前
UIView.alloc().init()

// 替换后(示意)
UIView.__c('alloc')().__c('init')()

再给 JS 的 Object.prototype 增加 __c 方法,使任意对象(类对象、实例对象)都能走到同一套逻辑:

1
2
3
4
5
6
7
8
9
10
Object.defineProperty(Object.prototype, '__c', {
value: function(methodName) {
if (!this.__obj && !this.__clsName) return this[methodName].bind(this);
var self = this;
return function() {
var args = Array.prototype.slice.call(arguments);
return _methodFunc(self.__obj, self.__clsName, methodName, args, self.__isSuper);
};
}
});
  • 若调用者是 OC 类(有 __clsName):把类名、方法名、参数传给 OC,由 OC 通过 Runtime 调类方法。
  • 若调用者是 OC 实例(有 __obj):把对象指针、方法名、参数传给 OC,调实例方法。

这样不需要在 JS 上枚举任何 OC 方法,内存占用大幅下降,是 JSPatch 中最重要的一步优化。

3.3 消息传递:JS 与 OC 如何互传数据?

OC 端在启动 JSPatch 时会创建 JavaScriptCoreJSContext,并在 context 上挂载 OC 实现的 Block/方法。JS 调这些方法时,参数和返回值会由 JavaScriptCore 自动在 JS 与 OC 类型之间转换(如 NSArray ↔ Array、NSString ↔ string、NSNumber ↔ number 等)。

因此,_methodFunc 只需把「类名 / 对象 / 方法名 / 参数列表」通过 context 上暴露给 JS 的函数传给 OC;OC 用 Runtime 完成调用后,再把返回值通过同一机制回传给 JS。

3.4 对象持有与转换:OC 对象在 JS 侧的表示

  • 类对象:在 JS 里就是 { __clsName: "UIView" },不涉及 OC 对象生命周期。
  • 实例对象:OC 的 id 若直接以指针形式交给 JS,JS 无法「理解」这个指针,但可以再把它传回 OC。
    为了在 JS 里识别「这是一个 OC 实例」,OC 在把对象返回给 JS 前会做一层包装,例如:
1
2
3
static NSDictionary *_wrapObj(id obj) {
return @{@"__obj": obj};
}

在 JS 侧就变成:

1
{ __obj: [OC 对象指针] }

这样在 __c() 里可以通过「是否有 __obj」判断调用者是 OC 实例,并取出 __obj 与方法名、参数一起传回 OC,完成实例方法调用。

对象生命周期:当 JS 侧有变量引用该包装对象时,OC 对象引用计数 +1;JS 侧释放后 -1,由 OC/JS 共同管理。

3.5 类型转换:参数与返回值的 OC 类型

OC 侧实际调用是通过 NSInvocation 完成的。要正确调用并拿到返回值,需要:

  1. 根据 OC 方法的 NSMethodSignature 得到每个参数的类型,把 JS 传过来的对象(如 NSNumber、NSDictionary)转成对应类型(如 intfloatCGRect 等)再传入。
  2. 根据返回值类型NSInvocation 取出返回值,再包装成 JS 可用的对象(或 JPBoxing 等)传回 JS。

例如 view.setAlpha(0.5):JS 传的是 NSNumber,OC 根据 setAlpha: 的签名得知参数是 float,于是把 NSNumber 转为 float 再调用。


四、方法替换(热修复的核心)

4.1 基础思路:替换 IMP

OC 的类方法列表里,每个方法对应一个 Method(SEL + 类型编码 + IMP)。通过 Runtime 可以:

  • 保留原 IMP:给类新增一个方法(如 ORIGviewDidLoad),其 IMP 指向原来的实现。
  • 替换原方法的 IMP:把 viewDidLoad 的 IMP 改成自定义函数,在自定义函数里调 JS 传入的实现,并在需要时再调 ORIGviewDidLoad

以替换 UIViewControllerviewDidLoad 为例(无参数情况):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void viewDidLoadIMP(id slf, SEL sel) {
// 从 JS 侧取到的函数并调用
JSValue *jsFunction = ...;
[jsFunction callWithArguments:nil];
}

Class cls = NSClassFromString(@"UIViewController");
SEL selector = @selector(viewDidLoad);
Method method = class_getInstanceMethod(cls, selector);
IMP imp = method_getImplementation(method);
char *typeDescription = (char *)method_getTypeEncoding(method);

// 原实现保留到 ORIGviewDidLoad
class_addMethod(cls, @selector(ORIGviewDidLoad), imp, typeDescription);
// viewDidLoad 指向新实现
class_replaceMethod(cls, selector, (IMP)viewDidLoadIMP, typeDescription);

这样,所有对 viewDidLoad 的调用都会走到 viewDidLoadIMP,进而执行 JS 逻辑;JS 里可通过 self.ORIGviewDidLoad() 调回原实现。

4.2 有参数时的问题:通用 IMP 如何拿到所有参数?

需要一个通用 IMP,能对「任意方法、任意参数个数与类型」都拿到参数并传给 JS。这里就出现了 32 位与 64 位 的差异。

4.2.1 32 位:va_list 取参(已不可用于 64 位)

最初用可变参数实现:

1
2
3
4
5
6
7
8
static void commonIMP(id slf, ...) {
va_list args;
va_start(args, slf);
NSMethodSignature *methodSignature = [cls instanceMethodSignatureForSelector:selector];
// 根据 methodSignature 的 typeEncoding 用 va_arg 逐个取出参数,组成 NSArray
// 再 [jsFunction callWithArguments:list];
va_end(args);
}

arm64 上,va_list 的 ABI 与 32 位不同,无法用上述方式正确取参,会 crash,因此 64 位必须换方案。

4.2.2 64 位:利用消息转发与 NSInvocation

OC 在「找不到方法实现」时会走消息转发链路,最终会到 -forwardInvocation:,此时会拿到一个 NSInvocation,其中已经包含了本次调用的 selector、参数类型、参数值、返回值类型。因此可以:

  1. 把要替换的方法的 IMP 改为 _objc_msgForward,这样一旦调用该方法,就会直接进入转发流程,最终进入 -forwardInvocation:
  2. 重写该类的 -forwardInvocation::在实现里判断「若是我们替换的方法」,则从 NSInvocation 里解出所有参数,调用我们新增的 _JPxxx 方法(该方法内部再调 JS);否则调原来的 ORIGforwardInvocation:,保证其他转发逻辑不受影响。
  3. 新增 ORIGviewWillAppear:_JPviewWillAppear::前者指向原 IMP,后者是「从 NSInvocation 取参并调 JS」的桥接实现。

这样在 64 位上就能通用地拿到任意方法的参数并交给 JS,无需依赖 va_list

4.3 返回值是 struct 时的注意点:_objc_msgForward_stret

部分架构下,当方法返回值是「较大的 struct」时,用的是 objc_msgSend_stret 的调用约定(返回值通过内存指针传回),若仍用 _objc_msgForward 会出错。此时需要改用 _objc_msgForward_stret
是否「special struct return」没有公开 API,JSPatch 通过 NSMethodSignaturedebugDescription 是否包含 "is special struct return? YES" 来判断,在非 arm64 上对这类方法使用 _objc_msgForward_stret

4.4 新增方法、Protocol、Property

  • 新增方法:OC 侧通过 class_addMethod 动态添加,参数与返回值类型先统一为 id(因为新增方法主要给 JS 用)。若类声明实现了某 Protocol,则从 Protocol 的方法描述里取类型信息,保证与 Protocol 一致(如 tableView:sectionForSectionIndexTitle:atIndex: 等)。
  • Property:已有属性直接通过 getter/setter 方法在 JS 里按普通方法调用即可。动态新增成员则用 objc_setAssociatedObject / objc_getAssociatedObject 模拟(因为 class_addIvar 只能在类注册前使用,无法给已有类加 ivar)。

4.5 self 与 super

  • self:在 defineClass 的实例方法执行前,把「当前实例」写入一个 JS 全局变量(如 self),方法执行完后清空,这样在 JS 里写的 self 就指向当前 OC 实例。
  • superself.super()__c() 里做特殊处理,返回一个带 __isSuper: 1 标记的对象。OC 侧若发现是 super 调用,则取父类该方法的 IMP,为当前类临时加一个方法(如 SUPER_viewDidLoad)指向该 IMP,再转调该方法,从而模拟 OC 的 super 语义。

五、扩展能力:Struct 与 C 函数

5.1 Struct 支持

JS 与 OC 之间不能直接传 C struct,需要序列化/反序列化。JSPatch 的做法是:

  • 内置:对常用类型如 NSRange、CGRect、CGSize、CGPoint 等做专门转换。
  • 可扩展:在 JS 里通过 defineStruct 声明 struct 的「名字、类型串、字段名」,OC 根据类型串按内存布局逐字段读写,再封装成 NSDictionary 与 JS 互传。这样新增 struct 不需要改 OC 代码,只需在 JS 声明布局即可(依赖当前 ABI 下 struct 内存布局稳定)。

5.2 C 函数支持

C 函数无法通过 Runtime 反射调用,因此采用「在 JSContext 上挂 OC Block 包装」的方式:在 context 上暴露与 C 函数同名的 JS 可调接口,内部转调 C 函数,并做好指针等类型转换。为避免引擎体积和启动时一次性注册过多 C 函数,设计了 JPExtension 机制:通过 +main:(JSContext *)formatJSToOC / formatOCToJS 等接口,让扩展按需注册 C 函数,JS 端通过 require('JPEngine').addExtensions(['JPMemory']) 等方式按需加载。


六、关键实现细节

6.1 JPBoxing:避免可变集合被 JavaScriptCore 自动转换

NSMutableArray / NSMutableDictionary / NSMutableString 从 OC 返回给 JS 时,JavaScriptCore 会强制转成 JS 的 Array / Object / String,导致「回到 OC 时无法再调原生的可变方法」。
解决办法:不直接返回这些对象,而是用 JPBoxing 包装一层(把 OC 对象放在 Boxing 的 property 里),返回 Boxing 实例给 JS。JS 再把这个 Boxing 传回 OC 时,OC 从 Boxing 里取出原对象,即可继续调可变方法。同时,为规则统一,NSArray/NSDictionary/NSString 也采用「默认以指针形式在 JS 侧存在,需要再调 .toJS() 转成纯 JS 类型」的策略。

6.2 nil / NSNull 的区分与链式调用

  • nil 与 NSNull:JS 的 null/undefined 传到 OC 时统一变成 nil;若需要明确表示 NSNull,在 JS 里使用全局变量 nsnull,OC 侧据此区分。
  • 链式调用:OC 里 [[obj returnNil] doSomething] 是安全的(对 nil 发消息不崩溃),但 JS 里 null 没有方法,无法写 require("JPObject").returnNil().hash()。JSPatch 用 false 表示 OC 返回的 nil:在 JS 里 false 也是对象可调方法,同时 if (!obj) 仍可用来判断「是否为 nil」。这样链式调用在 JS 侧也能安全进行。唯一的小坑是:若 OC 参数类型是 NSNumber* 而 JS 传 false,OC 会得到 nil 而非 NSNumber,需要业务侧注意。

6.3 下划线 _ 的歧义

OC 方法名用 : 分隔参数,JSPatch 在 JS 里用 单个下划线 _ 连接多参数方法名,例如:

  • setObject:forKey:setObject_forKey_

若 OC 方法名里本身带下划线(如 set_object:forKey:),就会与「参数分隔符」混淆。约定:OC 方法名中的字面下划线在 JS 里用双下划线 __ 表示,例如 set__object_forKey_。这样 OC 的 _ 与 JSPatch 的「参数分隔」可以区分开。

6.4 内存与 ARC

  • 从 NSInvocation 取参数/返回值:若用 id arg; [invocation getArgument:&arg atIndex:i];,ARC 会在退出作用域时对 arg 做 release,但 getArgument:atIndex: 并不会自动做 retain,容易造成 double release。解决方式是用 __unsafe_unretained__weak,或通过 void * + __bridge 明确所有权。
  • alloc / new / copy / mutableCopy 返回值:按 OC 约定,这些方法返回的对象调用方持有,retainCount 已 +1。从 NSInvocation 取返回值时,若 selector 是这类方法,需用 __bridge_transfer 把所有权交给 ARC,否则会泄漏。

七、核心模块与源码结构

模块 / 文件 职责
JPEngine 初始化 JSContext、注入 require/defineClass 等全局方法,执行脚本入口;提供 OC 侧与 JS 的桥接入口(如接收类名、方法名、参数并调用 Runtime)。
JPBoxing 包装 OC 对象(含 NSMutableArray/Dictionary/String、C 指针、Class 等),避免被 JavaScriptCore 自动转换或无法在 JS 侧标识类型。
JPLoader 负责从网络/本地加载、解密、执行 JS 补丁;版本管理、条件执行等。
JPExtension (JPExtension) 扩展接口:暴露 JSContext 与类型转换方法,供 C 函数、自定义 Struct 等扩展按需注册。
JS 脚本预处理 正则替换方法调用为 __c('methodName') 形式,以及 defineClass 中 self/super 的注入等。

源码阅读顺序建议:JPEngine 初始化与注入 → JS 中的 __c_methodFunc → OC 侧根据类名/对象/方法名调用 Runtime(含 NSInvocation)→ 方法替换(forwardInvocation + ORIG/JP 前缀)→ JPBoxing 与类型转换

关键调用链(OC 侧)

1
2
3
4
5
6
7
JS 调用 UIView.alloc().init()
→ _methodFunc 被调用,参数 [className="UIView", methodName="alloc", args=[]]
→ 通过 JSContext 注册的桥接函数进入 OC(如 callSelector:selectorName:arguments:...)
→ JPEngine 内根据 className 取 Class,根据 selectorName 取 SEL,组装 NSInvocation
→ 设置 target、arguments,invoke
→ 返回值经 formatOCToJS / JPBoxing 包装后回传 JS
→ JS 侧得到包装对象 { __obj: 实例 },再调用 .__c('init')() 继续链式调用

方法替换调用链(64 位)

1
2
3
4
5
6
OC 代码调用 [vc viewWillAppear:YES]
→ viewWillAppear: 的 IMP 已被改为 _objc_msgForward
→ 进入消息转发,最终到 forwardInvocation:
→ 自定义 forwardInvocation 实现中:从 NSInvocation 解出参数,调 _JPviewWillAppear:(BOOL)
→ _JPviewWillAppear: 内部把参数打包,通过 JSContext 调 JS 里 defineClass 定义的 viewWillAppear
→ 若 JS 里调 self.ORIGviewWillAppear(),则 OC 再调 ORIGviewWillAppear:,即原实现

八、设计思想总结

  1. 用字符串与 Runtime 打通 JS 与 OC
    不依赖预编译或代码生成,完全依赖「类名/方法名 + Runtime 反射 + NSInvocation」,使任意 OC 接口都能被 JS 调用和替换。

  2. 用「正则替换 + 元函数」规避 JS 语言限制
    JS 没有「未定义方法转发」,通过脚本预处理把方法调用统一成 __c('methodName'),用一层元函数模拟「消息转发」,避免在 JS 侧枚举海量方法,兼顾内存与实现复杂度。

  3. 区分 32/64 位与返回值类型
    32 位用 va_list 取参,64 位用 forwardInvocation + NSInvocation;对 special struct return 用 _objc_msgForward_stret,体现对 ABI 与底层调用约定的细致处理。

  4. 用包装类型统一「跨引擎对象」
    JPBoxing、__obj/__clsName 等,把「OC 对象/类在 JS 侧的句柄」标准化,便于在 __c() 中统一分支(类方法 / 实例方法 / super)。

  5. 扩展点清晰
    Struct 用类型串 + 键名在 JS 侧声明;C 函数通过 JPExtension 按需注册,既控制体积又保持能力可扩展。


九、合规性与替代方案

维度 说明
Apple 态度 3.3.2 禁止未包含在应用内的可执行代码的下载与执行;JSPatch 通过 JS 调 Runtime 替换方法,被视为违规。
现状 作者已不再维护,新上架 App 不建议使用。
替代思路 热修:RN/Weex/Flutter 等脚本层 OTA;紧急修复:服务端降级、开关、兜底逻辑;架构上减少对「运行时替换原生实现」的依赖。

十、小结

JSPatch 通过 Objective-C Runtime + JavaScriptCore,用「类名/方法名 + 参数」在 JS 与 OC 之间架起桥梁,并用 正则替换 + __c() 元函数 在 JS 侧实现无需枚举方法的调用转发;方法替换在 64 位上依赖 消息转发与 NSInvocation 通用地获取参数。再配合 JPBoxing、nil 用 false 表示、Struct/C 函数扩展 等细节,在技术上演进出一套完整的热修方案。理解其原理有助于掌握 Runtime、消息转发、JS–Native 桥接与 ABI 等知识;在实际项目中则应优先采用符合当前审核政策的热更新与架构方案。


本文基于 JSPatch 官方 Wiki、作者博客及公开技术资料整理,仅用于学习与原理分析。