由于最近申请的辞职,所以不得不做好下一家面试的准备。 在iOS面试过程中Runtime, Runloop基本是必问的两个问题。 Runtime的概念性问题就不多说了, 本篇文章我会讲述一下Runtime在iOS开发中的实际使用场景。
首先,归纳下Runtime的几个使用场景。
做用户埋点统计处理异常崩溃(NSDictionary, NSMutableDictionary, NSArray, NSMutableArray 的处理)按钮最小点击区设置按钮重复点击设置手势的重复点击处理UIButton点击事件带多参数MJRefresh封装服务端控制页面跳转字典转模型在做app运营的时候, 我们经常会需要接入一些第三方做统计, 例如友盟统计,google统计等。 例如外面需要统计某个页面用户停留的时长, 统计某个页面的展示次数。 通常我们的做法是 : 需要统计A页面停留时长的时候,我们再A页面出现(appear)的时候记录一个时间戳,页面消失(dispear)的时候用当前时间戳与之前的时间戳求出时间间隔,然后上报到分析平台。 如果统计页面展示次数, 就在每次页面出现时调用统计方法。 这样做的坏处是 代码侵入性太强,维护性与易读性都不太好。 假设以后要改需求, 就要进入到代码所在处进行修改。 又或者别人接手你的代码, 根本不知道已经做了哪些埋点, 需求改来改去,时间久了, 项目中全都是垃圾代码。
此时,为了优化统计, 我们使用 Hook (钩子)的思想, 例如Runtime的 Method sweezing(方法交换)去拦截系统方法来实现共计。
首先,我们写一个集成NSObject的工具类,实现方法交换
#import "HookTool.h" #import <objc/runtime.h> @implementation HookTool +(void)swizzingForClass:(Class)cls originalSel:(SEL)originalSelector swizzingSel:(SEL)swizzingSelector { Class class = cls; Method originalMethod = class_getInstanceMethod(class, originalSelector); Method swizzingMethod = class_getInstanceMethod(class, swizzingSelector); BOOL addMethod = class_addMethod(class, originalSelector, method_getImplementation(swizzingMethod), method_getTypeEncoding(swizzingMethod)); if (addMethod) { class_replaceMethod(class, swizzingSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod)); }else{ method_exchangeImplementations(originalMethod, swizzingMethod); } } @end接着,我们写一个UIViewController的分类, 在Load方法中把系统方法替换掉:
#import "UIViewController+actionAnalysis.h" #import "HookTool.h" #import "NSDate+Convenience.h" @implementation UIViewController (actionAnalysis) +(void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ SEL originalAppearSelector = @selector(viewWillAppear:); SEL swizzingAppearSelector = @selector(user_viewWillAppear:); [HookTool swizzingForClass:[self class] originalSel:originalAppearSelector swizzingSel:swizzingAppearSelector]; SEL originalDisappearSelector = @selector(viewWillDisappear:); SEL swizzingDisappearSelector = @selector(user_viewWillDisappear:); [HookTool swizzingForClass:[self class] originalSel:originalDisappearSelector swizzingSel:swizzingDisappearSelector]; }); } -(void)user_viewWillAppear:(BOOL)animated { //页面出现 [self user_viewWillAppear:animated]; } -(void)user_viewWillDisappear:(BOOL)animated { //页面消失 [self user_viewWillDisappear:animated]; } @end此时还有个问题, 首先你可能并不想对每个页面进行统计, 但是又不想每次添加一个统计就加一个if判断。 这个时候我们就在Xcode中加入一张plist表, plist表里面记录我们所需统计的信息
此时,我们只需要在hook的方法中去实现统计逻辑
-(void)user_viewWillAppear:(BOOL)animated { NSDictionary * pageenter = [[HookTool getConfig] objectForKey:@"page_enter_anysis"]; if ([pageenter.allKeys containsObject:NSStringFromClass([self class])]) { NSLog(@"%@ 页面展示", NSStringFromClass([self class])); } NSDictionary * pagetime = [[HookTool getConfig] objectForKey:@"page_time_anysis"]; if ([pagetime.allKeys containsObject:NSStringFromClass([self class])]) { //此处用Userdefault存储只是因为方便书写, 实际用可以用一个单例去存储中间值 [[NSUserDefaults standardUserDefaults] setDouble:[[NSDate date] timeIntervalSince1970] * 1000 forKey:@"appeartime"]; } [self user_viewWillAppear:animated]; } -(void)user_viewWillDisappear:(BOOL)animated { //页面停留时间统计 NSDictionary * pagetime = [[HookTool getConfig] objectForKey:@"page_time_anysis"]; if ([pagetime.allKeys containsObject:NSStringFromClass([self class])]) { double leaveTime = NSDate.currenMillisecondTimestamp - [[NSUserDefaults standardUserDefaults] doubleForKey:@"appeartime"]; NSLog(@"%@ 页面的停留时间为 %lf ms", [self class], leaveTime); } [self user_viewWillDisappear:animated]; } 这样的话,以后做页面时长或者页面展示的统计,就只需要维护这个plist表就行了,不需要具体改动代码。点击事件统计:
与VC的统计类似, 也是利用catagory + hook的思想来实现, 我们可以添加一个UIControl的分类。但是具体需要hook UIControl的哪个方法那 ? 点击进入UIControl的api, 我们很容易发现需要Hook的方法
- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event;接着我们在UIControl的分类中实现方法的交互
@implementation UIControl (actionAnalysis) +(void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ SEL originalSelector = @selector(sendAction:to:forEvent:); SEL swizzingSelector = @selector(user_sendAction:to:forEvent:); [HookTool swizzingForClass:[self class] originalSel:originalSelector swizzingSel:swizzingSelector]; }); } -(void)user_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event { NSLog(@"\n***hook success.\n[1]action:%@\n[2]target:%@ \n[3]event:%ld", NSStringFromSelector(action), target, (long)event); [self user_sendAction:action to:target forEvent:event]; }同样的, 我们只需要在plist中添加click的统计所需的参数就可以了
利用Runtime做用户埋点的就说这么多, 文章只提供思路, 具体plist的结构,或者代码细节根据情况自己做实现就行了。另外, 由于需求变动的原因,造成代码与配置表不匹配(例如可能会出现某个method名字被改变 )从而造成埋点统计失败, 建议写一个单元测试对Plist进行测试,思路: 在单元测试中我们首先读取plist配置文件,遍历所有的页面。在一个页面内遍历所有的ControlEventIDs,对每个响应函数名进行respondsToSelector:判断。 这样可以有效减少埋点失效问题。
在开发过程中, 有时候会出现set object for key的时候 object为Nil或者Key为Nil, 又或者初始化array, dic的时候由于数据个数与指定的长度不一致造成崩溃。 此时利用runtime对异常情况进行捕捉,提前return或者抛弃多余的长度。
Dic:
#import "NSDictionary+Safe.h" #import <objc/runtime.h> @implementation NSDictionary (Safe) + (void)load { Method originalMethod = class_getClassMethod(self, @selector(dictionaryWithObjects:forKeys:count:)); Method swizzledMethod = class_getClassMethod(self, @selector(na_dictionaryWithObjects:forKeys:count:)); method_exchangeImplementations(originalMethod, swizzledMethod); } + (instancetype)na_dictionaryWithObjects:(const id [])objects forKeys:(const id <NSCopying> [])keys count:(NSUInteger)cnt { id nObjects[cnt]; id nKeys[cnt]; int i=0, j=0; for (; i<cnt && j<cnt; i++) { if (objects[i] && keys[i]) { nObjects[j] = objects[i]; nKeys[j] = keys[i]; j++; } } return [self na_dictionaryWithObjects:nObjects forKeys:nKeys count:j]; } @end @implementation NSMutableDictionary (Safe) + (void)load { Class dictCls = NSClassFromString(@"__NSDictionaryM"); Method originalMethod = class_getInstanceMethod(dictCls, @selector(setObject:forKey:)); Method swizzledMethod = class_getInstanceMethod(dictCls, @selector(na_setObject:forKey:)); method_exchangeImplementations(originalMethod, swizzledMethod); } - (void)na_setObject:(id)anObject forKey:(id<NSCopying>)aKey { if (!anObject || !aKey) return; [self na_setObject:anObject forKey:aKey]; } @endarray:
#import "NSArray+Safe.h" #import <objc/runtime.h> @implementation NSArray (Safe) + (void)load { Method originalMethod = class_getClassMethod(self, @selector(arrayWithObjects:count:)); Method swizzledMethod = class_getClassMethod(self, @selector(na_arrayWithObjects:count:)); method_exchangeImplementations(originalMethod, swizzledMethod); } + (instancetype)na_arrayWithObjects:(const id [])objects count:(NSUInteger)cnt { id nObjects[cnt]; int i=0, j=0; for (; i<cnt && j<cnt; i++) { if (objects[i]) { nObjects[j] = objects[i]; j++; } } return [self na_arrayWithObjects:nObjects count:j]; } @end @implementation NSMutableArray (Safe) + (void)load { Class arrayCls = NSClassFromString(@"__NSArrayM"); Method originalMethod1 = class_getInstanceMethod(arrayCls, @selector(insertObject:atIndex:)); Method swizzledMethod1 = class_getInstanceMethod(arrayCls, @selector(na_insertObject:atIndex:)); method_exchangeImplementations(originalMethod1, swizzledMethod1); Method originalMethod2 = class_getInstanceMethod(arrayCls, @selector(setObject:atIndex:)); Method swizzledMethod2 = class_getInstanceMethod(arrayCls, @selector(na_setObject:atIndex:)); method_exchangeImplementations(originalMethod2, swizzledMethod2); } - (void)na_insertObject:(id)anObject atIndex:(NSUInteger)index { if (!anObject) return; [self na_insertObject:anObject atIndex:index]; } - (void)na_setObject:(id)anObject atIndex:(NSUInteger)index { if (!anObject) return; [self na_setObject:anObject atIndex:index]; } @end“按钮太不好点中了,点击好几次才点击到”, 测试经常会有这样的抱怨, 但是此时按钮图片本身设计就很小。 此时,例如Runtime进行点击区放大, 是个挺好的解决版本(当然也要注意不需要扩大的场景: 例如去年开发一个类似猫眼电影的app, 用户选座位的View里面是一个个小的控件,此时点击区域就不能放大,不然会误点,或者点击区遮盖。)
static const void *topNameKey = @"topNameKey"; static const void *rightNameKey = @"rightNameKey"; static const void *bottomNameKey = @"bottomNameKey"; static const void *leftNameKey = @"leftNameKey"; - (void)setEnlargeEdgeWithTop:(CGFloat)top right:(CGFloat)right bottom:(CGFloat)bottom left:(CGFloat)left{ objc_setAssociatedObject(self, &topNameKey, [NSNumber numberWithFloat:top], OBJC_ASSOCIATION_COPY_NONATOMIC); objc_setAssociatedObject(self, &rightNameKey, [NSNumber numberWithFloat:right], OBJC_ASSOCIATION_COPY_NONATOMIC); objc_setAssociatedObject(self, &bottomNameKey, [NSNumber numberWithFloat:bottom], OBJC_ASSOCIATION_COPY_NONATOMIC); objc_setAssociatedObject(self, &leftNameKey, [NSNumber numberWithFloat:left], OBJC_ASSOCIATION_COPY_NONATOMIC); } - (CGRect)enlargedRect { NSNumber *topEdge = objc_getAssociatedObject(self, &topNameKey); NSNumber *rightEdge = objc_getAssociatedObject(self, &rightNameKey); NSNumber *bottomEdge = objc_getAssociatedObject(self, &bottomNameKey); NSNumber *leftEdge = objc_getAssociatedObject(self, &leftNameKey); if (topEdge && rightEdge && bottomEdge && leftEdge) { return CGRectMake(self.bounds.origin.x - leftEdge.floatValue, self.bounds.origin.y - topEdge.floatValue, self.bounds.size.width + leftEdge.floatValue + rightEdge.floatValue, self.bounds.size.height + topEdge.floatValue + bottomEdge.floatValue); } else { return self.bounds; } } - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { CGRect rect = [self enlargedRect]; if (CGRectEqualToRect(rect, self.bounds)) { return [super hitTest:point withEvent:event]; } return CGRectContainsPoint(rect, point) ? self : nil; }这个就不多说了,详细大部分程序员都遇到过, 直接上代码
+ (void)load{ Method originalMethod = class_getInstanceMethod([self class], @selector(sendAction:to:forEvent:)); Method swizzledMethod = class_getInstanceMethod([self class], @selector(User_SendAction:to:forEvent:)); method_exchangeImplementations(originalMethod, swizzledMethod); } #pragma mark -- 时间间隔 -- static const void *ButtonDurationTime = @"ButtonDurationTime"; - (NSTimeInterval)durationTime{ NSNumber *number = objc_getAssociatedObject(self, &ButtonDurationTime); return number.doubleValue; } - (void)setDurationTime:(NSTimeInterval)durationTime{ NSNumber *number = [NSNumber numberWithDouble:durationTime]; objc_setAssociatedObject(self, &ButtonDurationTime, number, OBJC_ASSOCIATION_COPY_NONATOMIC); } - (void)User_SendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event{ self.userInteractionEnabled = NO; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.durationTime * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ self.userInteractionEnabled = YES; }); [self User_SendAction:action to:target forEvent:event]; }手势重复点击有个误区: 不能通过拦截 addTarget:(id)target action:(SEL)action 这个方法来实现,因为这个方法是是添加方法,即使我们交换了,在执行的时候并没有什么变化的。正确的做法是添加一个timeInterval,然后在代理里面根据timeInterval设置UITapGestureRecognizer的enable属性
#import "UITapGestureRecognizer+LOOExtension.h" #import <objc/runtime.h> @interface UITapGestureRecognizer () ///时间间隔 @property (nonatomic,assign) NSTimeInterval duration; @end static const void *UITapGestureRecognizerduration = @"GestureRecognizerduration"; @implementation UITapGestureRecognizer (LOOExtension) #pragma mark - Getter Setter - (NSTimeInterval)duration{ NSNumber *number = objc_getAssociatedObject(self, &UITapGestureRecognizerduration); return number.doubleValue; } - (void)setDuration:(NSTimeInterval)duration{ NSNumber *number = [NSNumber numberWithDouble:duration]; objc_setAssociatedObject(self, &UITapGestureRecognizerduration, number, OBJC_ASSOCIATION_COPY_NONATOMIC); } /** 添加点击事件 @param target taeget @param action action @param duration 时间间隔 */ - (instancetype)initWithTarget:(id)target action:(SEL)action withDuration:(NSTimeInterval)duration{ self = [super init]; if (self) { self.duration = duration; self.delegate = self; [self addTarget:target action:action]; } return self; } - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer{ self.enabled = NO; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.duration * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ self.enabled = YES; }); return YES; } @end这么使用runtime感觉有点鸡肋,至少在自己的iOS生涯中,没有必须需要这么做的时候。 其实写个子类,添加个Parameter属性岂不是更简单。
大部分程序员应该都用过MJRefresh这个工具,大部分用法都每次出现tabview初始化后, 都初始化出来一个 mj_header, mj_footer, 并且设置 header与footer后, 把mj_header与mj_footer复制给tableview.mj_header, tableview.mj_footer. 每次去重复创建Header, Footer, 这个是不能容忍的。 我们知道tableview和collectionView都是继承自scrollView,那么我们可以在 scrollView的分类里面添加一些方法,那么我们在以后使用的时候,就不需要一遍一遍的重复写无用代码了,只需要调用scrollView分类方法就可以了。
#import "UIScrollView+JHRefresh.h" #import <MJRefresh.h> @implementation UIScrollView (JHRefresh) /** 添加刷新事件 @param headerBlock 头部刷新 @param footerBlock 底部刷新 */ - (void)setRefreshWithHeaderBlock:(void(^)(void))headerBlock footerBlock:(void(^)(void))footerBlock{ if (headerBlock) { MJRefreshNormalHeader *header= [MJRefreshNormalHeader headerWithRefreshingBlock:^{ if (headerBlock) { headerBlock(); } }]; header.stateLabel.font = [UIFont systemFontOfSize:13]; header.lastUpdatedTimeLabel.font = [UIFont systemFontOfSize:13]; self.mj_header = header; } if (footerBlock) { MJRefreshBackNormalFooter *footer = [MJRefreshBackNormalFooter footerWithRefreshingBlock:^{ footerBlock(); }]; footer.stateLabel.font = [UIFont systemFontOfSize:13]; [footer setTitle:@"暂无更多数据" forState:MJRefreshStateNoMoreData]; [footer setTitle:@"" forState:MJRefreshStateIdle]; self.mj_footer.ignoredScrollViewContentInsetBottom = 44; self.mj_footer = footer; } } /** 开启头部刷新 */ - (void)headerBeginRefreshing{ [self.mj_header beginRefreshing]; } /** 没有更多数据 */ - (void)footerNoMoreData{ [self.mj_footer setState:MJRefreshStateNoMoreData]; } /** 结束刷新 */ - (void)endRefresh{ if (self.mj_header) { [self.mj_header endRefreshing]; } if (self.mj_footer) { [self.mj_footer endRefreshing]; } }项目开发中,我们可能会有这样的需求: 根据服务端推送过来的数据规则,跳转到对应的控制器。 之前我们的做法是这样的: 前端与服务端定义好规则, 例如服务端推送 Push/Live/WatchLive/12, Push: push方式跳转 , Live指的直播模块, WatchLive指的看直播的功能, 12指的房间号, 也就是跳转到12号主播间。 但是这么做坏处就是,必须提前与服务端约定好协议, 每次运营如果加一个新的跳转, 移动端需要改代码,重新上线。扩展性很低。
其实利用Runtime完全可以写成通用的方式来实现跳转。例如外面与服务端定义好推送规则后,服务端推送过来的数据如下:
// 这个规则肯定事先跟服务端沟通好,跳转对应的界面需要对应的参数 NSDictionary *userInfo = @{ @"class": @"LiveViewController", //VC的名字 @"property": @{ @"ID": @"123", //参数名字为 ID , value为 123 @"type": @"12" //type为附加信息, 根据实际情况定义 } };接着我们利用Runtime进行跳转
// 类名 NSString *class =[NSString stringWithFormat:@"%@", params[@"class"]]; const char *className = [class cStringUsingEncoding:NSASCIIStringEncoding]; // 从一个字串返回一个类 Class newClass = objc_getClass(className); if (!newClass) { return; //推送的class不存在 } // 创建对象 id instance = [[newClass alloc] init]; // 对该对象赋值属性 NSDictionary * propertys = params[@"property"]; [propertys enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { // 检测这个对象是否存在该属性 if ([self checkIsExistPropertyWithInstance:instance verifyPropertyName:key]) { // 利用kvc赋值 [instance setValue:obj forKey:key]; } }]; // 获取导航控制器 UITabBarController *tabVC = (UITabBarController *)self.window.rootViewController; UINavigationController *pushClassStance = (UINavigationController *)tabVC.viewControllers[tabVC.selectedIndex]; // 跳转到对应的控制器 [pushClassStance pushViewController:instance animated:YES];检测属性是否存在
- (BOOL)checkIsExistPropertyWithInstance:(id)instance verifyPropertyName:(NSString *)verifyPropertyName { unsigned int outCount, i; // 获取对象里的属性列表 objc_property_t * properties = class_copyPropertyList([instance class], &outCount); for (i = 0; i < outCount; i++) { objc_property_t property =properties[i]; // 属性名转成字符串 NSString *propertyName = [[NSString alloc] initWithCString:property_getName(property) encoding:NSUTF8StringEncoding]; // 判断该属性是否存在 if ([propertyName isEqualToString:verifyPropertyName]) { free(properties); return YES; } } free(properties); return NO; }获取属性的列表的方法是字典转模型的比较核心的方法。常见的字典转模型的三方有 MJExtension, YYModel, JsonModel等, 翻看其源码, 都会发现 Ivar *class_copyIvarList(Class cls, unsigned int *outCount)的使用
MJExtension核心代码摘录 YYModel核心代码摘录JsonModel json字典转model 摘录
基本上主流的json 转model 都少不了,使用运行时动态获取属性的属性名的方法,来进行字典转模型替换,字典转模型效率最高的(耗时最短的)的是KVC,其他的字典转模型是在KVC 的key 和Value 做处理,动态的获取json 中的key 和value ,当然转换的过程中,第三方框架需要做一些判空啊,镶嵌的逻辑处理, 再进行KVC 转模型.这句代码 [xx setValue:value forKey:key];无论JsonModle,YYKIt,MJextension 都少不了[xx setValue:value forKey:key];这句代码的,不信可以去搜,这是字典转模型的核心方法,
参考文章:
RunTime使用案例
iOS Runtime基础学习
Runtime 10种用法
iOS 万能跳转界面方法
iOS动态性(二)可复用而且高度解耦的用户统计埋点实现
