由浅入深,从原理到源码,全面解析 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 上就有 alloc、init 等「真实存在」的属性。
问题在于:一个类就有几百个方法,还要沿继承链汇总,内存暴涨 ,且要维护 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 时会创建 JavaScriptCore 的 JSContext,并在 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 侧就变成:
这样在 __c() 里可以通过「是否有 __obj」判断调用者是 OC 实例,并取出 __obj 与方法名、参数一起传回 OC,完成实例方法调用。
对象生命周期:当 JS 侧有变量引用该包装对象时,OC 对象引用计数 +1;JS 侧释放后 -1,由 OC/JS 共同管理。
3.5 类型转换:参数与返回值的 OC 类型 OC 侧实际调用是通过 NSInvocation 完成的。要正确调用并拿到返回值,需要:
根据 OC 方法的 NSMethodSignature 得到每个参数的类型 ,把 JS 传过来的对象(如 NSNumber、NSDictionary)转成对应类型(如 int、float、CGRect 等)再传入。
根据返回值类型 从 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。
以替换 UIViewController 的 viewDidLoad 为例(无参数情况):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 static void viewDidLoadIMP(id slf, SEL sel) { 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);class_addMethod(cls, @selector (ORIGviewDidLoad), imp, typeDescription); 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]; va_end(args); }
在 arm64 上,va_list 的 ABI 与 32 位不同,无法用上述方式正确取参,会 crash,因此 64 位必须换方案。
4.2.2 64 位:利用消息转发与 NSInvocation OC 在「找不到方法实现」时会走消息转发 链路,最终会到 -forwardInvocation:,此时会拿到一个 NSInvocation ,其中已经包含了本次调用的 selector、参数类型、参数值、返回值类型 。因此可以:
把要替换的方法的 IMP 改为 _objc_msgForward ,这样一旦调用该方法,就会直接进入转发流程,最终进入 -forwardInvocation:。
重写该类的 -forwardInvocation: :在实现里判断「若是我们替换的方法」,则从 NSInvocation 里解出所有参数,调用我们新增的 _JPxxx 方法(该方法内部再调 JS);否则调原来的 ORIGforwardInvocation:,保证其他转发逻辑不受影响。
新增 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 通过 NSMethodSignature 的 debugDescription 是否包含 "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 实例。
super :self.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:,即原实现
八、设计思想总结
用字符串与 Runtime 打通 JS 与 OC 不依赖预编译或代码生成,完全依赖「类名/方法名 + Runtime 反射 + NSInvocation」,使任意 OC 接口都能被 JS 调用和替换。
用「正则替换 + 元函数」规避 JS 语言限制 JS 没有「未定义方法转发」,通过脚本预处理把方法调用统一成 __c('methodName'),用一层元函数模拟「消息转发」,避免在 JS 侧枚举海量方法,兼顾内存与实现复杂度。
区分 32/64 位与返回值类型 32 位用 va_list 取参,64 位用 forwardInvocation + NSInvocation;对 special struct return 用 _objc_msgForward_stret,体现对 ABI 与底层调用约定的细致处理。
用包装类型统一「跨引擎对象」 JPBoxing、__obj/__clsName 等,把「OC 对象/类在 JS 侧的句柄」标准化,便于在 __c() 中统一分支(类方法 / 实例方法 / super)。
扩展点清晰 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、作者博客及公开技术资料整理,仅用于学习与原理分析。