前言
近期前端移动组因项目需求,需要在用户行为上进行打点统计,但由于部分早期SDK在初始设计时并未考虑到日志记录这一功能,临时去变更代码所花费的成本也较高,所以架构组决定针对这一需求进行一次AOP开发实践,用面向切面统计来代替部分传统代码埋入打点。
AOP介绍
AOP的全称是Aspect Oriented Programming ,中文翻译是【面向切面编程】,与面向对象编程是两种不同的领域设计思想,AOP主要理念是针对业务处理过程中的切面进行提取,然后在切面上进行开发。
iOS 中的AOP实践
由于Objective-C的运行时特性(Runtime),可以很方便的实现一些看起来非常Hack的行为,其中就包括Method Swizzling ,Method Swizzling主要应用就是来交换2个已经存在的方法,让方法A在真实运行中实际执行的是方法B,而方法B在运行中又是执行的是方法A。
Method Swizzling 原理
要理解Method Swizzling,首先要熟悉Objective-C中的消息转发机制,比如下面这段代码:
[array insertObject:foo atIndex:5];
编译器会将它翻译成下面这段代码来执行:
objc_msgSend(array, @selector(insertObject:atIndex:), foo, 5);
而具体的执行逻辑则是:
1 通过 array 的 isa 指针找到它的 class,这里比如说是 NSArray
2 在 NSArray class 的 method list中 找到 insertObject:atIndex:方法
3 一旦找到 insertObject:atIndex: 这个方法,就去执行它的IMP实现
具体见图:
由上图方法的数据结构中可以看到一个完整的方法是主要由三部分组成,分别是SEL方法名、IMP 方法实现、Method_Type 方法参数和返回值,而@selector(insertObject:atIndex:) 这段代码的作用就是获取方法的SEL
在MethodLists中,方法的SEL和IMP是一一对应的,如下图:
如果此时将SEL1指向IMP2、SEL2再指向IMP1,那么通过@selector(method1)拿到的实现就是IMP2,只要能完成此功能,那么就能实现方法的交换,
这个就是Method Swizzling的原理,通过替换SEL对应的IMP来达到方法替换的目的
下面来看如何在代码中具体实施这一行为:
// 将class中的originalSelector和swizzledSelector进行交换
+ (void)drm_swizzleMethod:(Class)cls originalSelector:(NSString *)originalSel swizzledSelector:(NSString *)swizzledSel {
Class class = cls;
// 通过String拿到对应的SEL
SEL originalSelector = NSSelectorFromString(originalSel);
SEL swizzledSelector = NSSelectorFromString(swizzledSel);
// 通过SEL拿到对应的Method, Method用于取IMP和TypeEcoding
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
// 开始交换,先直接往当前class中添加该方法,如果失败,说明方法已存在,直接交换即可(else逻辑)
BOOL success = class_addMethod(class, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod));
if (success) {
// 如果添加成功,此时需要把swizzled方法也添加到当前class中,class_replaceMethod包含addMethod过程(如swizzledMethod不存在)
class_replaceMethod(class, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
}
当我们有了上面这个方法之后,就可以很轻易的将2个方法进行交换:
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class class = [self class];
[self drm_swizzleMethod:class originalSelector:@"viewDidAppear:" swizzledSelector:@"drmSwizzled_viewDidAppear:"];
});
}
- (void)drmSwizzled_viewDidAppear:(BOOL)animated {
// 注意这个只是SEL,最终会指向viewDidAppear的IMP,不会形成死循环
[self drmSwizzled_viewDidAppear:animated];
[DRMLogging logWithEvent:@"交换成功"];
}
这样当一个ViewController开始展示时,会首先调用到drmSwizzled_viewDidAppear方法里面,再经由[self drmSwizzled_viewDidAppear]调回原来的viewDidAppear,然后我们就可以进行愉快的打点操作了,打点可以在原方法执行前或执行后都可以进行,也可以拿到对应的原方法的参数。
点融iOS端AOP再实践
如果只用上面的Method Swizzling方法的确已经可以完成在模块外部对模块内的方法进行打点,但缺点是对于关键方法可能每一个都需要进行Swizzle操作,如果方法量过多,其实接入成本也很高,然后我们再细化了需求,发现对于普通的用户操作,比如按钮点击、输入框文本变动其实可以用更快速的方式来监听。
原理解释
对于一个ViewController实例来说,必然会存在一个Root View,而拿到Root View之后,我们可以遍历它当前所有的subViews,层层递归下去,就可以拿到当前ViewController上面的所有包含页面控件。
当我们拿到一个UIControl时,可以在外部给它再添加一个Touch Event事件、
当我们拿到一个UITextField时,可以在外部给它再添加一个Text Changed事件、
...
这样当我们遍历完所有控件时,我们也同步把页面所有的需要监听的控件增加的对应的方法,
如果也就可以快速的为页面控件进行打点监听。
方法演示
说完了原理,来看看具体的监听方法是如何添加到每一个控件上的(代码仅供参考):
@implementation UIViewController (Logging)
// 首先还是需要使用Method Swizzling替换一下ViewController的viewDidAppear:方法
- (void)drmSwizzled_viewDidAppear:(BOOL)animated {
// 调用原viewDidAppear:方法
[self drmSwizzled_viewDidAppear:animated];
// 设置下允许被监听的类列表
NSArray *allowedClasses = @["MyViewController", @"HomeViewController"];
// 先判断当前Class是否允许被监听
NSString *currentClass = NSStringFromClass([self class]);
if ([allowedClasses containsObject:currentClass]) {
// 开始递归查找所有的页面元素
[self recursiveSubViews:self.view.subviews];
}
}
// 递归subView,找到所有子控件,对于UIControl或者UITextField控件,开始增加监听
- (void)recursiveSubViews:(NSArray *)subViews {
for (UIView *subView in subViews) {
if ([subView isKindOfClass:[UITextField class]]) {
[self addTextChangeEvent:(UITextField *)subView];
}
else if ([subView isKindOfClass:[UIControl class]]) {
[self addTouchEvent:(UIControl *)subView];
}
else {
if (subView.subviews.count > 0) {
[self recursiveSubViews:subView.subviews];
}
}
}
}
// 增加点击事件监听
- (void)addTouchEvent:(UIControl *)control {
[control addTarget:self action:@selector(onControlTapped:) forControlEvents:UIControlEventTouchUpInside];
}
// 增加文本框变动事件监听
- (void)addTextChangeEvent:(UITextField *)textField {
[textField addTarget:self action:@selector(onTextfieldChanged:) forControlEvents:UIControlEventEditingChanged];
}
// 处理按钮点击事件,打点
- (void)onControlTapped:(UIControl *)sender {
NSString *className = NSStringFromClass([self class]);
NSString *senderTitle = nil;
if ([sender isKindOfClass:[UIButton class]]) {
senderTitle = [(UIButton*)sender currentTitle];
}
[DRMLogging logWithEventName:senderTitle class:className];
}
// 处理文本框事件,打点
- (void)onTextfieldChanged:(UITextField *)sender {
NSString *className = NSStringFromClass([self class]);
NSString *value = sender.text;
NSString *title = sender.placeholder;
[DRMLogging logWithEventName:title eventValue:value class:className];
}
@end
以上就可以快速的减少Swizzle的代码量,对于用户的实时行为,也能快速监听,初步猜测这也是GrowingIO前端数据无侵入埋点的主要逻辑。
性能和风险
1.使用Method Swizzling可能会导致Debug困难,问题不易排查,但如果仅仅是swizzle UIViewController中的一个didAppear方法并不会带来太大的问题,多次swizzle也没有妨碍(前提是不会出现重名的方法,否则会陷入死循环),swizzle并不会增加太大开销。
2.遍历当前页面控件会有极小的性能开销,此处也可以再优化,对于已经不需要进行递归的单一控件,就直接return掉就好。
本文作者:余永凯,现就职于点融网工程部,iOS开发工程师一枚。爱好互联网,乐于创造新事物。