摘要: 咱们透过系统底层来捕获ui事件流和业务数据的流动,并利用捕获到的这些数据经过事件回放机制来复现线上的问题。本文先介绍录制和回放的总体框架,接着介绍里面涉及到的3个关键技术点,也是这里最复杂的技术(模拟触摸事件,统一拦截器实现,统一hook block)。node
咱们透过系统底层来捕获ui事件流和业务数据的流动,并利用捕获到的这些数据经过事件回放机制来复现线上的问题。本文先介绍录制和回放的总体框架,接着介绍里面涉及到的3个关键技术点,也是这里最复杂的技术(模拟触摸事件,统一拦截器实现,统一hook block)。git
背景数据库
如今的app基本都会提供用户反馈问题的入口,然而提供给用户反馈问题通常有两种方式:数组
● 直接用文字输入表达,或者截图这两种反馈方式经常带来如下抱怨:服务器
● 用户:输入文字好费时费力因此,为了解决以上问题,咱们用一套全新的思路来设计线上问题回放体系。网络
从上面的关系图能够看出,整个app的运行无非是用户ui操做,而后触发app从外界获取数据,包括网络数据,gps数据等等,也包括从手机本地获取数据,好比相册数据,机器数据,系统等数据。 因此咱们要实现问题回放只须要记录用户的UI操做和外界数据,app自身数据便可。数据结构
app录制 = 用户的UI操做 + 外界数据(手机内和手机外) + app自身数据闭包
录制其实就是把ui和数据记录下来,回放其实就是app自动驱动UI操做并把录制时的数据塞回相应的地方。
回放跟录制框架图基本同样,实际上录制和回放的代码是在一块儿,逻辑也是统一的,为了便于表达,我人为划分红两个架构图出来。架构
回放的流程: app
1.启动app,点击回放按钮。
2.引擎加载回放脚本。
3.从脚本中解析出须要注册的运行时事件并注册,在回放里不须要业务上层来注册事件,这里跟录制是不同的。
4.从脚本中解析出须要注册的静态数据事件并注册。
5.从脚本中解析出须要播放的事件数据,并组成消费队列。
6.启动播放器,从消费队列里读取一个个事件来播放,若是是ui事件则直接播放,若是是静态数据事件则直接按照指令要求替换数据值,若是是非ui运行时事件则经过事件指令规则来肯定是主动播放仍是等待拦截对应的事件,若是须要等待拦截对应的事件,则播放器会一直等待此事件直到此事件被app消费掉为止。只有此事件被消费了,播放器才能播放下一个事件。
7.当拦截到被注册的事件后,根据此事件指令要求把相应的数据塞到相应的字段里。
8.跳回6继续运行,直到消费队列里的事件被消费完。
注意:回放每一个事件时会实时自动打印出相应的堆栈信息和事件数据,有利于排查问题
从ui事件数据解中析出被触摸的view,以及此view所在的视图树中的层级关系,并在当前回放界面上查找到对应的view,而后往该view上发送ui操做事件(点击,双击等等),并带上触摸事件的坐标信息,其实这里是模拟触摸事件。咱们先来介绍触摸事件的处理流程:
等待触摸阶段
● 手机屏幕处于待机状态,等待触摸事件发生系统反应阶段
● 屏幕感应器接收到触摸,并将触摸数据传给系统IOKit(IOKit是苹果的硬件驱动框架)SpringBoard进程就是iOS的系统桌面,它存在于iDevice的进程中,不可清除,它的运行原理与Windows中的explorer.exe系统进程相相似。它主要负责界面管理,因此只有它才知道当前触摸到底有谁来响应。
SpringBoard接收阶段
● SpringBoard收到IOHIDEvent消息后,触发runloop中的Source1回调__IOHIDEventSystemClientQueueCallback()方法。app处理阶段
● 前台app主线程Runloop收到SpringBoard转发来的消息,并触发对应runloop 中的Source1回调_UIApplicationHandleEventQueue()。从上面触摸事件处理过程当中咱们能够看出要录制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;
}
}
咱们来看一下代码怎么模拟单击触摸事件(为了容易理解,我把有些不是关键,复杂的代码已经去掉),接着咱们来看一下模拟触摸事件代码 一个基本的触摸事件通常由三部分组成:
● UITouch对象 - 将用于触摸实现步骤:
1.代码的前面部分都是一些UITouch和UIEvent私有接口,私有变量字段,因为苹果并不公开它们,为了让其编译不报错,因此咱们须要把这些字段包含进来,回放是在线下,因此没必要担忧私有接口被拒的事情。
2.构造触摸对象:UITouch和UIEvent,把记录对应的字段值塞回相应的字段。塞回去就是用私有接口和私有字段。
3.触摸的view位置转换为Window坐标,而后往app里发送事件 [[UIApplication sharedApplication] sendEvent:event];
4.要回放这些触摸事件,咱们须要把他丢到CADisplayLink里面来执行。
//
// SimulationTouch.m
//
// Created by 诗壮殷 on 2018/5/15.
//
#import "SimulationTouch.h"
#import <objc/runtime.h>
#include <mach/mach_time.h>
@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;
}
@interface UIInternalEvent : UIEvent
- (void)_setHIDEvent:(IOHIDEventRef)event;
@interface UITouchesEvent : UIInternalEvent
- (void)_addTouch:(UITouch *)touch forDelayedDelivery:(BOOL)delayedDelivery;
- (void)_clearTouches;
@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
总的来讲就下载苹果提供触摸事件的源码库,分析源码,而后设置断掉调试,甚至反汇编来理解触摸事件的原理。
2.统一拦截器
录制和回放都居于事件流来处理的,而数据的事件流其实就是对一些关键方法的hook,因为咱们为了保证对业务代码无侵入和扩展性(随便注册事件),咱们须要对全部方法统一hook,全部的方法由同一个钩子来响应处理。以下图所示
这个钩子是用用汇编编写,因为汇编代码比较多,并且比较难读懂,因此这里暂时不附上源码,汇编层主要把硬件里面的一些数据统一读取出来,好比通用寄存器数据和浮点寄存器数据,堆栈信息等等,甚至前面的前面的方法参数均可以读取出来,最后转发给c语言层处理。
汇编层把硬件相关信息组装好后调用c层统一拦截接口,汇编层是为c层服务。c层没法读取硬件相关信息,因此这里只能用汇编来读取。c层接口经过硬件相关信息定位到当前的方法是属于哪一个事件,知道了事件,也意味着知道了事件指令,知道了事件指令,也知道了哪些字段须要塞回去,也知道了被hook的原始方法。
c层代码介绍以下: 因为是统一调用这个拦截器,因此拦截器并不知道当前是哪一个业务代码执行过来的,也不知道当前这个业务方法有多少个参数,每一个参数类型是什么等等,这个接口代码处理过程大概以下:
● 经过寄存器获取对象self//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语言结合汇编研究底层细节。
何谓 oc block?
● block就是闭包,跟回调函数callback很相似,闭包也是对象。系统底层怎样表达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引用,第一个变量有关键字__block,表示能够在block里对该变量赋值,第二个变量没有__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要用结构体来表达。篇幅有限,这个本身想一想吧?
block小结
● 为了表达block,底层设计三种结构体:block_info_1,block_desc_1,byref_var1_1,三种函数指针: block invoke方法体,copy方法,dispose方法怎样统一 hook block?
经过上面的分析,得知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)));
}
本文大概介绍了问题回放框架,接着介绍三个关键技术。这三个技术相对比较深刻,欢迎在留言区评论,咱们期待与你们交流,共同探讨。
阿里云双十一1折拼团活动:已满6人,都是最低折扣了
【满6人】1核2G云服务器99.5元一年298.5元三年 2核4G云服务器545元一年 1227元三年
【满6人】1核1G MySQL数据库 119.5元一年
【满6人】3000条国内短信包 60元每6月