基于Aspects框架的iOS热修复方案
背景
- JSPatch 无法审核,就算进行深度的代码混淆依然无法逃脱苹果审核机制
- App 审核加快,但是依然无法很好的控制线上 Bug 的影响范围
- 目前未发现有其他可替代方案,只能另寻他径
目标
JSPatch 可以任意替换和新增方法,甚至可以用来开发新模块。但是如果纯粹用来修复线上bug的话,我们并不需要如此强大的功能。热修复只需要具备以下几点功能足以:
- 方法替换为空实现
- 方法参数修改
- 方法返回值修改
- 方法调用前后插入自定义代码
- 支持任意 OC 方法调用
- 支持赋值语句
- 支持 if 语句:==、!=、>、>=、<、<=、||、&&
- 支持 super 调用
- 支持自定义局部变量
- 支持 return 语句
原理
Runtime 术语
1. SEL
2. IMP
3. Method
4. NSMethodSignature
5. NSInvocation
6. void _objc_msgForward(void /* id receiver, SEL sel, ... */ )
7. id _Nullable objc_msgSend(id _Nullable self, SEL _Nonnull op, ...)
8. Objective-C type encodings
Runtime 基本操作
- Class 反射创建
// 1
NSClassFromString(@"NSObject");
// 2
objc_getClass("NSObject");
- SEL 反射创建
// 1
@selector(init);
// 2
sel_registerName("init");
// 3
NSSelectorFromString(@"init");
- 方法替换
static void cc_forwardInvocation(id slf, SEL sel, NSInvocation *invocation)
{
// do what you want to do
}
class_replaceMethod(klass, @selector(forwardInvocation:), (IMP)cc_forwardInvocation, "v@:@");
- 方法新增
Class tClass = NSClassFromString(@"UIViewController");
SEL selector = NSSelectorFromString(@"viewDidLoad");
Method targetMethod = class_getInstanceMethod(tClass, selector);
IMP targetMethodIMP = method_getImplementation(targetMethod);
const char *typeEncoding = method_getTypeEncoding(targetMethod);
SEL aliasSelector = NSSelectorFromString([@"cc" stringByAppendingFormat:@"_%@", NSStringFromSelector(selector)]);
class_addMethod(klass, aliasSelector, method_getImplementation(targetMethod), typeEncoding);
- 新类创建
Class cls = objc_allocateClassPair([NSObject class], “CCObject”, 0);
objc_registerClassPair(cls);
- 消息转发
// 1. 正常转发
+ (BOOL)resolveClassMethod:(SEL)sel
+ (BOOL)resolveInstanceMethod:(SEL)sel
- (id)forwardingTargetForSelector:(SEL)aSelector
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
- (void)forwardInvocation:(NSInvocation *)anInvocation
// 2. 自定义转发
void _objc_msgForward(void /* id receiver, SEL sel, ... */ )
Method Invoke 的几种方式
@interface People : NSObject
- (void)helloWorld;
@end
- 常规调用
- 反射调用
- objc_msgSend
- C 函数调用
- NSInvocation 调用
// 常规调用
People *people = [[People alloc] init];
[people helloWorld]
; // 反射调用 Class cls = NSClassFromString(@"People"); id obj = [[cls alloc] init];
[obj performSelector:NSSelectorFromString(@"helloWorld")]
; // objc_msgSend ((void(*)(id, SEL))objc_msgSend)(people, sel_registerName("helloWorld")); // C 函数调用 Method initMethod = class_getInstanceMethod([People class], @selector(helloWorld)); IMP imp = method_getImplementation(initMethod); ((void (*) (id, SEL))imp)(people, @selector(helloWorld)); // NSInvocation 调用 NSMethodSignature *sig = [[People class] instanceMethodSignatureForSelector:sel_registerName("helloWorld")]; NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:sig]; invocation.target = people; invocation.selector = sel_registerName("helloWorld");
[invocation invoke]
;复制代码
Aspects 原理分析
新版热修复是基于 Aspects 框架开发的,无审核风险,已上线。Aspects 和 JSPatch 的都是基于消息转发实现的。
一、简介
- AspectsContainer:Tracks all aspects for an object/class
- AspectIdentifier:Tracks a single aspect
二、Hook 流程
- 检查 selector 是否可以替换,里面涉及一些黑名单等判断
- 获取 AspectsContainer,如果为空则创建并绑定目标类
- 创建 AspectIdentifier,引用自定义实现(block)和 AspectOptions 等信息
- 将目标类 forwardInvocation: 方法替换为自定义方法
- 目标类新增一个带有
aspects_
前缀的方法,新方法(aliasSelector)实现跟目标方法相同 - 将目标方法实现替换为
_objc_msgForward
// 将目标类 **forwardInvocation:** 方法替换为自定义方法
IMP originalImplementation = class_replaceMethod(klass, @selector(forwardInvocation:), (IMP)__ASPECTS_ARE_BEING_CALLED__, "v@:@");
if (originalImplementation) {
class_addMethod(klass, NSSelectorFromString(AspectsForwardInvocationSelectorName), originalImplementation, "v@:@");
}
// 目标类新增一个带有` aspects_`前缀的方法,新方法(aliasSelector)实现跟目标方法相同
Method targetMethod = class_getInstanceMethod(klass, selector);
IMP targetMethodIMP = method_getImplementation(targetMethod);
const char *typeEncoding = method_getTypeEncoding(targetMethod);
SEL aliasSelector = NSSelectorFromString([AspectsMessagePrefix stringByAppendingFormat:@"_%@", NSStringFromSelector(selector)]);
class_addMethod(klass, aliasSelector, method_getImplementation(targetMethod), typeEncoding);
// 将目标方法实现替换为 `_objc_msgForward`
class_replaceMethod(klass, selector, aspect_getMsgForwardIMP(self, selector), typeEncoding);
三、Invoke 流程
- 调用目标方法进入消息转发流程
- 调用自定义
__ASPECTS_ARE_BEING_CALLED__
方法 - 获取对应 invocation,将 invocation.selector 设置为 aliasSelector
- 通过 aliasSelector 获取对应 AspectsContainer
- 根据 AspectOptions 调用用户自定实现(目标方法调用前/后/替换)
四、Aspects 使用遇到的问题
- 使用了自旋锁,存在优先级反转问题,使用
pthread_mutex_lock
代替即可 - 特殊
struct
判断逻辑不够全面,例如:NSRange, NSPoint等 在 32 位架构下有问题,需要自行兼容
#if defined(__LP64__) && __LP64__
if (valueSize == 16) {
methodReturnsStructValue = NO;
}
#endif
- 类方法无法直接 hook, 不过可以 hook 其
Meta class
元类方式进行解决
object_getClass(targetCls)
- 无法同时 hook 一个类的实例方法和类方法,原因是使用了相同的
swizzledClasse
key, 解决如下:
static Class aspect_swizzleClassInPlace(Class klass) {
NSCParameterAssert(klass);
NSString *className = [NSString stringWithFormat:@"%@_%p", NSStringFromClass(klass), klass];
_aspect_modifySwizzledClasses(^(NSMutableSet *swizzledClasses) {
if (![swizzledClasses containsObject:className]) {
aspect_swizzleForwardInvocation(klass);
[swizzledClasses addObject:className];
}
});
return klass;
}
static void aspect_undoSwizzleClassInPlace(Class klass) {
NSCParameterAssert(klass);
NSString *className = [NSString stringWithFormat:@"%@_%p", NSStringFromClass(klass), klass];
_aspect_modifySwizzledClasses(^(NSMutableSet *swizzledClasses) {
if ([swizzledClasses containsObject:className]) {
aspect_undoSwizzleForwardInvocation(klass);
[swizzledClasses removeObject:className];
}
});
}
开发中遇到的坑
一、Illegal Instruction Crash
从 -forwardInvocation:
里的 NSInvocation
对象取参数值时,若参数值是id类型,一般会这样取:
id value = nil;
[invocation getArgument:&value atIndex:2]
;复制代码
但是这种写法存在 crash 风险。例如 Hook NSMutableArray 的 insertObject:atIndex: 方法.你会发现在有些系统调用会出现 EXC_BAD_INSTRUCTION
崩溃
[NSClassFromString(@"__NSArrayM") aspect_hookSelector:@selector(insertObject:atIndex:) withOptions:AspectPositionInstead usingBlock:^(id<AspectInfo> info){
NSLog(@"insertObject:atIndex: hook");
id value = nil;
[info.originalInvocation getArgument:&value atIndex:2]
; if (value) {
[info.originalInvocation invoke]
; } } error:NULL];复制代码
开启 Zombie objects 下的异常打印
-[UITraitCollection retain]: message sent to deallocated instance 0x6000007cde00
崩溃原因分析:
- NSInvocation 不会引用参数,详情可以看官方文档(This class does not retain the arguments for the contained invocation by default)
- ARC 在隐式赋值不会自动插入 retain 语句
- ARC 下 id value 相当于 __strong id vaule,所以在退出作用域时会自动插入 release 语句。
- 综上123可以得出:参数对象会多调用一次 release 方法,导致对象提前释放。如果此时再对该对象发送消息则会发生野指针崩溃
解决办法:
1、将 value 变成 __unsafe_unretained
或 __weak
,让 ARC 在它退出作用域时不插入 release 语句
__unsafe_unretained id value = nil;
2、通过 __bridge
转换让 value 持有返回对象,显示赋值
id value = nil;
void *result;
[invocation getArgument:&result atIndex:2]
; value = (__bridge id)result;复制代码
二、Memory leak
背景:
因为要支持参数替换,所以要从 -forwardInvocation:
里的 NSInvocation
对象取返回值,然后替换为自定义的参数。一般生成一个对象都会调用 alloc 方法,然后再初始化
内存泄漏原因分析:
1、根据内存管理规则可知,当调用 alloc / new / copy / mutableCopy 方法的返回对象的 retainCount = 1。
2、如果方法有返回值的话,ARC会在 return 后自动插入 autorelease,所以一般的常规返回是没有问题的。
3、ARC 对隐式赋值不会自动插入 autorelease,所以少了一次 release,从而导致内存泄漏。
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
invocation.target = [NSObject class];
invocation.selector = sel_registerName([@"alloc" UTF8String]);
[invocation invoke]
; id returnValue = nil;
[invocation getReturnValue:&returnValue]
; return returnValue; 复制代码
解决办法:
- 把返回对象的内存管理权移交出来,让外部对象管理其内存。由于是显示赋值,ARC机制生效。
- 采用常规方法调用代替 NSInvocation
id target = [NSObject class];
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
invocation.target = target;
invocation.selector = sel_registerName([@"alloc" UTF8String]);
[invocation invoke]
; id resultObj = nil, void *result;
[invocation getReturnValue:&result]
; // 方法1 if ([selName isEqualToString:@"alloc"] || [selName isEqualToString:@"new"] || [selName isEqualToString:@"copy"] || [selName isEqualToString:@"mutableCopy"]) { resultObj = (__bridge_transfer id)result; } else { resultObj = (__bridge id)result; } // 方法2 if ([selName isEqualToString:@"alloc"]) { resultObj = [[target class] alloc]; } else if ([selName isEqualToString:@"new"]) { resultObj = [[target class] new]; } else if ([selName isEqualToString:@"copy"]) { resultObj = [target copy]; } else if ([selName isEqualToString:@"mutableCopy"]) { resultObj = [target mutableCopy]; } else { expectObj = (__bridge id)result; } 复制代码
功能实现简介
一、方法替换为空实现
这个功能其实很容易实现,直接替换即可
[NSClassFromString(@"UIViewController") aspect_hookSelector:@selector(viewDidLoad:) withOptions:AspectPositionInstead usingBlock:^(id<AspectInfo> info){
// 空实现
} error:NULL];
二、判断方法参数
核心点就是通过 Aspect 获取目标方法 Invocation ,然后对 Invocation 的参数进行对比,如果符合期望值则继续之前原方法,例如插入的对象是否为 nil,如果为 nil 则放弃调用原方法,相当于执行了一个空方法。这个可以扩展为基础变量判断,例如数组越界判断。
[NSClassFromString(@"__NSArrayM") aspect_hookSelector:@selector(insertObject:atIndex:) withOptions:AspectPositionInstead usingBlock:^(id<AspectInfo> info){
// 当 value = nil,结束当前方法调用
__unsafe_unretained id value = nil;
[info.originalInvocation getArgument:&value atIndex:2]
; if (value) {
[info.originalInvocation invoke]
; } } error:NULL];复制代码
三、替换方法参数
这个也是通过 Invocation 去修改方法里面的参数,然后再调用,具体实现如下
[NSClassFromString(@"__NSArrayM") aspect_hookSelector:@selector(insertObject:atIndex:) withOptions:AspectPositionInstead usingBlock:^(id<AspectInfo> info){
// 不管外面怎么调用,每次 atIndex = 0
NSUInteger value = 0;
[info.originalInvocation setArgument:& value atIndex:3]
;
[info.originalInvocation invoke]
; } error:NULL];复制代码
四、更改方法返回值
[NSClassFromString(@"__NSArrayM") aspect_hookSelector:@selector(objectAtIndex:) withOptions:AspectPositionInstead usingBlock:^(id<AspectInfo> info){
// 不管外面怎么调用,每次都返回 nil
[info.originalInvocation invoke]
; id expectValue = nil;
[info.originalInvocation setReturnValue:&expectValue]
; } error:NULL];复制代码
五、方法调用前后插入自定义代码
这个实现的起来稍微复杂一点,因为要实现方法前后插入方法,所以你必须要构建消息发送对象和方法参数。例如在 UIViewController
的 viewDidLoad
方法前设置其背景颜色为红色。首先需要获取 viewDidLoad
方法的 Invocation
,然后通过 Invocation.target
获取到控制器对象 self
,获取到 self
之后调用 objc_msgSend
方法获取 view,到这里我们已经获取到消息发送对象,然后我们用 sel_registerName
获取 setBackgroundColor:
方法的 SEL。通过 SEL 获取到函数签名 NSMethodSignature
,同过函数签名去获取 setBackgroundColor:
的 Invocation,最后通过设置 Invocation 的参数为红色,然后调用 Invocation 的 invoke
方法就将背景色改为 redColor
。到此相信已经了解其核心原理了,我们只需要在此基础上再扩展,那么足以应付线上的 90% 以上的问题了。下面是具体实现代码。
[NSClassFromString(@"UIViewController") aspect_hookSelector:@selector(viewDidLoad) withOptions:AspectPositionInstead usingBlock:^(id<AspectInfo> aspectInfo){
// viewDidLoad 执行前插入 self.view.backgroundColor = [UIColor redColor];
target = ((id (*)(id, SEL))objc_msgSend)(aspectInfo.originalInvocation.target, NSClassFromString(@"view"));
SEL selector = sel_registerName([@"setBackgroundColor:" UTF8String]);
NSMethodSignature *signature = [[target class] instanceMethodSignatureForSelector:selector];
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
invocation.target = target;
invocation.selector = selector;
id value = ((id(*)(id, SEL))objc_msgSend)([UIColor class], sel_registerName("redColor"));
[invocation setArgument:&value atIndex:2]
;
[invocation invoke]
;
[info.originalInvocation invoke]
; } error:NULL];复制代码
JPAspect
JPAspect 一款轻量级、无侵入和无审核风险的 iOS 热修复框架。JPAspect 通过下发指定规则的 json 即可轻松实现线上 Bug 修复。JPAspect 已实现上述所有功能,具体实现请参考代码。