发布app后,开发者最头疼的问题就是如何解决交付后的用户侧问题的还原和定位,是业界缺少一整套系统的解决方案的空白领域,闲鱼技术团队结合本身业务痛点提出一套全新的技术思路解决这个问题并在线上取得了比较满意的实践效果。node
咱们透过系统底层来捕获ui事件流和业务数据的流动,并利用捕获到的这些数据经过事件回放机制来复现线上的问题。本文先介绍录制和回放的总体框架,接着介绍里面涉及到的3个关键技术点,也是这里最复杂的技术(模拟触摸事件,统一拦截器实现,统一hook block)
如今的app基本都会提供用户反馈问题的入口,然而提供给用户反馈问题通常有两种方式:git
这两种反馈方式经常带来如下抱怨:数组
因此:为了解决以上问题,咱们用一套全新的思路来设计线上问题回放体系网络
从上面的关系图能够看出,整个app的运行无非是用户ui操做,而后触发app从外界获取数据,包括网络数据,gps数据等等,也包括从手机本地获取数据,好比相册数据,机器数据,系统等数据。
因此咱们要实现问题回放只须要记录用户的UI操做和外界数据,app自身数据便可。数据结构
app录制 = 用户的UI操做 + 外界数据(手机内和手机外) + app自身数据闭包
录制是为回放服务,录制的信息越详细,回放成功率就越高,定位问题就越容易 录制其实就是把ui和数据记录下来,回放其实就是app自动 驱动UI操做并把录制时的数据塞回相应的地方。
回放跟录制框架图基本同样,实际上录制和回放的代码是在一块儿,逻辑也是统一的,为了便于表达,我人为划分红两个架构图出来。架构
回放流程图在这里省略app
注意:回放每一个事件时会实时自动打印出相应的堆栈信息和事件数据,有利于排查问题框架
从ui事件数据解中析出被触摸的view,以及此view所在的视图树中的层级关系,并在当前回放界面上查找到对应的view,而后往该view上发送ui操做事件(点击,双击等等),并带上触摸事件的坐标信息,其实这里是模拟触摸事件。异步
咱们先来介绍触摸事件的处理流程
SpringBoard进程就是iOS的系统桌面,它存在于iDevice的进程中,不可清除,它的运行原理与Windows中的explorer.exe系统进程相相似。它主要负责界面管理,因此只有它才知道当前触摸到底有谁来响应。
从上面触摸事件处理过程当中咱们能够看出要录制ui事件只须要在app处理阶段中的UIApplication sendEvent方法处截获触摸数据,回放时也是在这里把触摸模拟回去。
下面是触摸事件录制的代码,就是把UITouch相应的数据保存下来便可
这里有一个关键点,须要把touch.timestamp的时间戳记录下来,以及把当前touch事件距离上一个touch事件的时间间隔记录下来,由于这个涉及到触摸引发惯性加速度问题。好比咱们平时滑动列表视图时,手指离开屏幕后,列表视图还要惯性地滑动一小段时间。
- (void)handleUIEvent:(UIEvent *)event { if (!self.isEnabled) return; if (event.type != UIEventTypeTouches) return; NSSet *allTouches = [event allTouches]; UITouch *touch = (UITouch *)[allTouches anyObject]; if (touch.view) { if (self.filter && !self.filter(touch.view)) { return; } } switch (touch.phase) { case UITouchPhaseBegan: { self.machAbsoluteTime = mach_absolute_time(); self.systemStartUptime = touch.timestamp; self.tuochArray = [NSMutableArray array]; [self recordTouch:touch click:self.machAbsoluteTime]; break; } case UITouchPhaseStationary: { [self recordTouch:touch click:mach_absolute_time()]; break; } case UITouchPhaseCancelled: { [self recordTouch:touch click:mach_absolute_time()]; [[NSNotificationCenter defaultCenter] postNotificationName:@"notice_ui_test" object:self.tuochArray]; break; } case UITouchPhaseEnded: { [self recordTouch:touch click:mach_absolute_time()]; [[NSNotificationCenter defaultCenter] postNotificationName:@"notice_ui_test" object:self.tuochArray]; break; } case UITouchPhaseMoved: { [self recordTouch:touch click:mach_absolute_time()]; } default: break; } } - (NSDictionary *)recordTouch:(UITouch *)touch click:(uint64_t)curClick { NSDictionary *dic = [NSMutableDictionary dictionary]; NSString *name = NSStringFromClass([touch.view class]); [dic setValue:name forKey:@"viewClass"]; NSString *frame = NSStringFromCGRect(touch.view.frame); [dic setValue:frame forKey:@"viewframe"]; [dic setValue:[NSNumber numberWithInteger:touch.phase] forKey:@"phase"]; [dic setValue:[NSNumber numberWithInteger:touch.tapCount] forKey:@"tapCount"]; uint64_t click = curClick - self.machAbsoluteTime; [dic setValue:[NSNumber numberWithUnsignedLongLong:click] forKey:@"click"]; UIWindow *touchedWindow = [UIWindow windowOfView:touch.view]; CGPoint location = [touch locationInView:touchedWindow]; // if(CGPointEqualToPoint(location, self.perlocation)) // { // return nil; // } NSString *pointStr = NSStringFromCGPoint(location); [dic setValue:pointStr forKey:@"LocationInWindow"]; NSTimeInterval timestampGap = touch.timestamp - self.systemStartUptime; [dic setValue:[NSNumber numberWithDouble:timestampGap] forKey:@"timestamp"]; // self.perlocation = location; [self.tuochArray addObject:dic]; return dic; }
咱们来看一下代码怎么模拟单击触摸事件(为了容易理解,我把有些不是关键,复杂的代码已经去掉)
接着咱们来看一下模拟触摸事件代码
一个基本的触摸事件通常由三部分组成:
实现步骤:
3.触摸的view位置转换为Window坐标,而后往app里发送事件
[[UIApplication sharedApplication] sendEvent:event];
// // SimulationTouch.m // // Created by 诗壮殷 on 2018/5/15. // #import "SimulationTouch.h" #import <objc/runtime.h> #include <mach/mach_time.h> #ifdef __LP64__ typedef double IOHIDFloat; #else typedef float IOHIDFloat; #endif typedef UInt32 IOOptionBits; typedef struct __IOHIDEvent *IOHIDEventRef; @interface UITouch (replay) - (void)_setIsFirstTouchForView:(BOOL)first; - (void)setIsTap:(BOOL)isTap; - (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)reset; - (void)setPhase:(UITouchPhase)phase; - (void)setTapCount:(NSUInteger)tapCount; - (void)setTimestamp:(NSTimeInterval)timestamp; - (void)setView:(UIView *)view; - (void)setWindow:(UIWindow *)window; - (void)_setHidEvent:(IOHIDEventRef)event; @end @implementation UITouch (replay) - (id)initPoint:(CGPoint)point window:(UIWindow *)window { NSParameterAssert(window); self = [super init]; if (self) { [self setTapCount:1]; [self setIsTap:YES]; [self setPhase:UITouchPhaseBegan]; [self setWindow:window]; [self _setLocationInWindow:point resetPrevious:YES]; [self setView:[window hitTest:point withEvent:nil]]; [self _setIsFirstTouchForView:YES]; [self setTimestamp:[[NSProcessInfo processInfo] systemUptime]]; } return self; } @end @interface UIInternalEvent : UIEvent - (void)_setHIDEvent:(IOHIDEventRef)event; @end @interface UITouchesEvent : UIInternalEvent - (void)_addTouch:(UITouch *)touch forDelayedDelivery:(BOOL)delayedDelivery; - (void)_clearTouches; @end @interface UIApplication (replay) - (BOOL)_isSpringBoardShowingAnAlert; - (UIWindow *)statusBarWindow; - (void)pushRunLoopMode:(NSString *)mode; - (void)pushRunLoopMode:(NSString *)mode requester:(id)requester; - (void)popRunLoopMode:(NSString *)mode; - (void)popRunLoopMode:(NSString *)mode requester:(id)requester; - (UITouchesEvent *)_touchesEvent; @end typedef enum { kIOHIDDigitizerEventRange = 0x00000001, kIOHIDDigitizerEventTouch = 0x00000002, kIOHIDDigitizerEventPosition = 0x00000004, } IOHIDDigitizerEventMask; IOHIDEventRef IOHIDEventCreateDigitizerFingerEvent(CFAllocatorRef allocator, AbsoluteTime timeStamp, uint32_t index, uint32_t identity, IOHIDDigitizerEventMask eventMask, IOHIDFloat x, IOHIDFloat y, IOHIDFloat z, IOHIDFloat tipPressure, IOHIDFloat twist, Boolean range, Boolean touch, IOOptionBits options); @implementation SimulationTouch - (void)performTouchInView:(UIView *)view start:(bool)start { UIWindow *_window = view.window; CGRect fInWindow; if ([view isKindOfClass:[UIWindow class]]) { fInWindow = view.frame; } else { fInWindow = [_window convertRect:view.frame fromView:view.superview]; } CGPoint point = CGPointMake(fInWindow.origin.x + fInWindow.size.width/2, fInWindow.origin.y + fInWindow.size.height/2); if(start) { self.touch = [[UITouch alloc] initPoint:point window:_window]; [self.touch setPhase:UITouchPhaseBegan]; } else { [self.touch _setLocationInWindow:point resetPrevious:NO]; [self.touch setPhase:UITouchPhaseEnded]; } CGPoint currentTouchLocation = point; UITouchesEvent *event = [[UIApplication sharedApplication] _touchesEvent]; [event _clearTouches]; uint64_t machAbsoluteTime = mach_absolute_time(); AbsoluteTime timeStamp; timeStamp.hi = (UInt32)(machAbsoluteTime >> 32); timeStamp.lo = (UInt32)(machAbsoluteTime); [self.touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]]; IOHIDDigitizerEventMask eventMask = (self.touch.phase == UITouchPhaseMoved) ? kIOHIDDigitizerEventPosition : (kIOHIDDigitizerEventRange | kIOHIDDigitizerEventTouch); Boolean isRangeAndTouch = (self.touch.phase != UITouchPhaseEnded); IOHIDEventRef hidEvent = IOHIDEventCreateDigitizerFingerEvent(kCFAllocatorDefault, timeStamp, 0, 2, eventMask, currentTouchLocation.x, currentTouchLocation.y, 0, 0, 0, isRangeAndTouch, isRangeAndTouch, 0); if ([self.touch respondsToSelector:@selector(_setHidEvent:)]) { [self.touch _setHidEvent:hidEvent]; } [event _setHIDEvent:hidEvent]; [event _addTouch:self.touch forDelayedDelivery:NO]; [[UIApplication sharedApplication] sendEvent:event]; } @end
怎样调用私有接口,以及使用哪些私有接口,这点不须要再解释了,若是感兴趣,请关注咱们公众号,后续我专门写篇文章来揭露这方面的技术,总的来讲就下载苹果提供触摸事件的源码库,分析源码,而后设置断掉调试,甚至反汇编来理解触摸事件的原理。
录制和回放都居于事件流来处理的,而数据的事件流其实就是对一些关键方法的hook,因为咱们为了保证对业务代码无侵入和扩展性(随便注册事件),咱们须要对全部方法统一hook,全部的方法由同一个钩子来响应处理。以下图所示
这个钩子是用用汇编编写,因为汇编代码比较多,并且比较难读懂,因此这里暂时不附上源码,汇编层主要把硬件里面的一些数据统一读取出来,好比通用寄存器数据和浮点寄存器数据,堆栈信息等等,甚至前面的前面的方法参数均可以读取出来,最后转发给c语言层处理。
汇编层把硬件相关信息组装好后调用c层统一拦截接口,汇编层是为c层服务。c层没法读取硬件相关信息,因此这里只能用汇编来读取。c层接口经过硬件相关信息定位到当前的方法是属于哪一个事件,知道了事件,也意味着知道了事件指令,知道了事件指令,也知道了哪些字段须要塞回去,也知道了被hook的原始方法。
c层代码介绍以下:
因为是统一调用这个拦截器,因此拦截器并不知道当前是哪一个业务代码执行过来的,也不知道当前这个业务方法有多少个参数,每一个参数类型是什么等等,这个接口代码处理过程大概以下
//xRegs 表示统一汇编器传入当前全部的通用寄存器数据,它们地址存在一个数组指针里 //dRegs 表示统一汇编器传入当前全部的浮点寄存器数据,它们地址也存在一个数组指针里 //dRegs 表示统一汇编器传入当前堆栈指针 //fp 表示调用栈帧指针 void replay_entry_start(void* xRegs, void* dRegs, void* spReg, CallBackRetIns *retIns,StackFrame *fp, void *con_stub_lp) { void *objAdr = (((void **)xRegs)[0]);//获取对象自己self或者block对象自己 EngineManager *manager = [EngineManager sharedInstance]; ReplayEventIns *node = [manager getEventInsWithBlock:objAdr]; id obj = (__bridge id)objAdr; void *xrArg = ((void **)xRegs)+2; if(nil == node) { SEL selecter = (SEL)(((void **)xRegs)[1]); //对应的对象调用的方法 Class tclass = [obj class];//object_getClass(obj);object_getClass方法只能经过对象获取它的类,不能传入class 返回class自己, do { node = [manager getEventIns:tclass sel:selecter];//经过对象和方法获取对应的事件指令节点 }while(nil == node && (tclass = class_getSuperclass(tclass))); } else { xrArg = ((void **)xRegs)+1; } assert(node && "node is nil in replay_call_start"); //回调通知上层当前回放是否打断 if(node.BreakCurReplayExe && node.BreakCurReplayExe(obj,node,xrArg,dRegs)) { retIns->nodeAddr = NULL; retIns->recordOrReplayData = NULL; retIns->return_address = NULL; return; } bool needReplay = true; //回调通知上层当前即将回放该事件 if(node.willReplay) { needReplay = (*(node.willReplay))(obj,node,xrArg,dRegs); } if(needReplay) { ReplayEventData *replayData = nil; if(node.getReplayData) { //获取回放该事件对应的数据 replayData = (*(node.getReplayData))(obj,node,xrArg,dRegs); } else//默认获取方法 { replayData = [manager getNextReplayEventData:node]; } //如下就是真正的回放,便是把数据塞回去,并调用原来被hook的方法 if(replayData) { if(replay_type_intercept_call == node.replayType) { sstuffArg(xRegs,dRegs,spReg,node,replayData.orgDic); NSArray *arglist = fetchAllArgInReplay(xRegs, dRegs, spReg, node); ReplayInvocation *funobj = [[ReplayInvocation alloc] initWithFunPtr:node.callBack ? node.callBack : [node getOrgFun] args:arglist argType:[node getFunTypeStr] retType:rf_return_type_v]; if([[EngineManager sharedInstance] setRepalyEventReady:replayData funObj:funobj]) { //放到播放队列里播放,返回没调用地址,让其不往下走 retIns->return_address = NULL; return ; } } else { //塞数据 sstuffArg(xRegs,dRegs,spReg,node,replayData.orgDic); } } retIns->nodeAddr = (__bridge void *)node; retIns->recordOrReplayData = (__bridge void *)replayData; retIns->return_address = node.callBack ? node.callBack : [node getOrgFun]; replayData.runStatus = relay_event_run_status_runFinish; } else { retIns->nodeAddr = NULL; retIns->recordOrReplayData = NULL; retIns->return_address = [node getOrgFun]; } }
若是你只是想大概理解block的底层技术,你只需google一下便可。
若是你想全面深刻的理解block底层技术,那网上的那些资料远远知足不了你的需求。
只能阅读苹果编译器clang源码和列出比较有表明性的block例子源码,而后转成c语言和汇编,经过c语言结合汇编研究底层细节。
3.有方法体
4.capture上下文变量
5.有对象引用计数的内存管理策略(block生命周期)
咱们先来看一下block的例子:
void test() { __block int var1 = 8; //上下文变量 NSString *var2 = @"我是第二个变量”; //上下文变量 void (^block)(int) = ^(int arg)//参数列表 { var1 = 6; NSLog(@"arg = %d,var1 = %d, var2 = %@", arg, var1, var2); }; block(1);//调用block语法 dispatch_async(dispatch_get_global_queue(0, 0), ^ { block(2); //异步调用block }); }
这段代码首先定义两个变量,接着定义一个block,最后调用block。
系统底层表达block比较重要的几种数据结构以下:
enum { BLOCK_REFCOUNT_MASK = (0xffff), BLOCK_NEEDS_FREE = (1 << 24), BLOCK_HAS_COPY_DISPOSE = (1 << 25), BLOCK_HAS_CTOR = (1 << 26),//todo == BLOCK_HAS_CXX_OBJ? BLOCK_IS_GC = (1 << 27), BLOCK_IS_GLOBAL = (1 << 28), BLOCK_HAS_DESCRIPTOR = (1 << 29),//todo == BLOCK_USE_STRET? BLOCK_HAS_SIGNATURE = (1 << 30), OBLOCK_HAS_EXTENDED_LAYOUT = (1 << 31) }; enum { BLOCK_FIELD_IS_OBJECT = 3, BLOCK_FIELD_IS_BLOCK = 7, BLOCK_FIELD_IS_BYREF = 8, OBLOCK_FIELD_IS_WEAK = 16, OBLOCK_BYREF_CALLER = 128 }; typedef struct block_descriptor_head { unsigned long int reserved; unsigned long int size; //表示主体block结构体的内存大小 }block_descriptor_head; typedef struct block_descriptor_has_help { unsigned long int reserved; unsigned long int size; //表示主体block结构体的内存大小 void (*copy)(void *dst, void *src);//当block被retain时会执行此函数指针 void (*dispose)(void *);//block被销毁时调用 struct block_arg_var_descriptor *argVar; }block_descriptor_has_help; typedef struct block_descriptor_has_sig { unsigned long int reserved; unsigned long int size; const char *signature;//block的签名信息 struct block_arg_var_descriptor *argVar; }block_descriptor_has_sig; typedef struct block_descriptor_has_all { unsigned long int reserved; unsigned long int size; void (*copy)(void *dst, void *src); void (*dispose)(void *); const char *signature; struct block_arg_var_descriptor *argVar; }block_descriptor_has_all; typedef struct block_info_1 { void *isa;//表示当前blcok是在堆上仍是在栈上,或在全局区_NSConcreteGlobalBlock int flags; //对应上面的enum值,这些枚举值是我从编译器源码拷贝过来的 int reserved; void (*invoke)(void *, ...);//block对应的方法体(执行体,就是代码段) void *descriptor;//此处指向上面几个结构体中的一个,具体哪个根据flags值来定,它用来进一步来描述block信息 //从这个字段开始起,后面的字段表示的都是此block对外引用的变量。 NSString *var2; byref_var1_1 var1; } block_info_1;
这个例子中的block在底层表达大概以下图:
首先用block_info_1来表达block自己,而后用block_desc_1来具体描述block相关信息(好比block_info_1结构体大小,在堆上仍是在栈上?copy或dispose时调用哪一个方法等等),然而block_desc_1具体是哪一个结构体是由block_info_1中flags字段来决定的,block_info_1里的invoke字段是指向block方法体,便是代码段。block的调用就是执行这个函数指针。因为var1是可写的,因此须要设计一个结构体(byref_var1_1)来表达var1,为何var2直接用他原有的类型表达,而var1要用结构体来表达。篇幅有限,这个本身想一想吧?
经过上面的分析,得知oc里的block就是一个结构体指针,因此我在源码里能够直接把它转成结构体指针来处理。
统一hook block源码以下
VoidfunBlock createNewBlock(VoidfunBlock orgblock, ReplayEventIns *blockEvent,bool isRecord) { if(orgblock && blockEvent) { VoidfunBlock newBlock = ^(void) { orgblock(); if(nil == blockEvent) { assert(0); } }; trace_block_layout *blockLayout = (__bridge trace_block_layout *)newBlock; blockLayout->invoke = (void (*)(void *, ...))(isRecord?hook_var_block_callBack_record:hook_var_block_callBack_replay); return newBlock; } return nil; }
咱们首先新建一个新的block newBlock,而后把原来的block orgblock 和 事件指令blockEvent包到新的blcok中,这样达到引用的效果。而后把新的block转成结构体指针,并把结构体指针中的字段invoke(方法体)指向统一回调方法。你可能诧异新的block是没有参数类型的,原来block是有参数类型,外面调用原来block传递参数时会不会引发crash?答案是否认的,由于这里构造新的block时 咱们只用block数据结构,block的回调方法字段已经被阉割,回调方法已经指向统一方法了,这个统一方法能够接受任何类型的参数,包括没有参数类型。这个统一方法也是汇编实现,代码实现跟上面的汇编层代码相似,这里就不附上源码了。
那怎样在新的blcok里读取原来的block和事件指令对象呢?
代码以下:
void var_block_callback_start_record(trace_block_layout * blockLayout) { VoidfunBlock orgBlock = (__bridge VoidfunBlock)(*((void **)((char *)blockLayout + sizeof(trace_block_layout)))); ReplayEventIns *node = (__bridge ReplayEventIns *)(*((void **)((char *)blockLayout + 40))); }
`
js本文做者:闲鱼技术
本文为云栖社区原创内容,未经容许不得转载。