咱們透過系統底層來捕獲ui事件流和業務數據的流動,並利用捕獲到的這些數據經過事件回放機制來複現線上的問題。本文先介紹錄製和回放的總體框架,接着介紹裏面涉及到的3個關鍵技術點,也是這裏最複雜的技術(模擬觸摸事件,統一攔截器實現,統一hook block)。node
如今的app基本都會提供用戶反饋問題的入口,然而提供給用戶反饋問題通常有兩種方式:git
● 直接用文字輸入表達,或者截圖
● 直接錄製視頻反饋
這兩種反饋方式經常帶來如下抱怨:數組
● 用戶:輸入文字好費時費力
● 開發1:看不懂用戶反饋說的是什麼意思?
● 開發2:大概看懂用戶說的是什麼意思了,可是我線下沒辦法復現哈
● 開發3:看了用戶錄製的視頻,可是我線下沒辦法重現,也定位不到問題
因此,爲了解決以上問題,咱們用一套全新的思路來設計線上問題回放體系。服務器
線上問題回放體系的意義
● 用戶不須要輸入文字反饋問題,只須要從新操做一下app重現問題步驟便可。
● 開發者拿到用戶反饋的問題腳本後,經過線下回放對問題一目瞭然,跟錄製視頻效果同樣,是的,你沒看錯,就是跟看視頻同樣。
● 經過腳本的回放實時獲取到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是蘋果的硬件驅動框架)
● 系統IOKit封裝該觸摸事件爲IOHIDEvent對象
● 接着系統IOKit把IOHIDEvent對象轉發給SpringBoard進程
SpringBoard進程就是iOS的系統桌面,它存在於iDevice的進程中,不可清除,它的運行原理與Windows中的explorer.exe系統進程相相似。它主要負責界面管理,因此只有它才知道當前觸摸到底有誰來響應。
● SpringBoard收到IOHIDEvent消息後,觸發runloop中的Source1回調__IOHIDEventSystemClientQueueCallback()方法。
● SpringBoard開始查詢前臺是否存在正在運行的app,若是存在,則SpringBoard經過進程通訊方式把此觸摸事件轉發給前臺當前app,若是不存在,則SpringBoard進入其本身內部響應過程。
● 前臺app主線程Runloop收到SpringBoard轉發來的消息,並觸發對應runloop 中的Source1回調_UIApplicationHandleEventQueue()。
● _UIApplicationHandleEventQueue()把IOHIDEvent處理包裝成UIEvent進行處理分發。
● Soucre0回調內部UIApplication的sendEvent:方法,將UIEvent傳給UIWindow。
● 在UIWindow爲根節點的整棵視圖樹上經過hitTest(_:with:)和point(inside:with:)這兩個方法遞歸查找到合適響應這個觸摸事件的視圖。
● 找到最終的葉子節點視圖後,就開始觸發此視圖綁定的相應事件,好比跳轉頁面等等。
從上面觸摸事件處理過程當中咱們能夠看出要錄製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對象 - 將用於觸摸
● 第一個UIEvent Began觸摸
● 第二個UIEvent Ended觸摸
實現步驟:
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; } @end @interface UIInternalEvent : UIEvent - (void)_setHIDEvent:(IOHIDEventRef)event; @end @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
● 經過寄存器獲取方法sel
● 經過self和sel獲取對應的事件指令
● 經過事件指令回調上層來決定是否往下執行
● 獲取須要回放該事件的數據
● 把數據塞回去,好比塞到某個寄存器裏,或者塞到某個寄存器所指向的對象的某個字段等等
● 若是須要當即回放則調用原來被hook的原始方法,若是不是當即回放,則須要把現場信息保存起來,並等待合適的時機由播放隊列來播放(調用)
//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]; } }
3.怎樣統一hook block
若是你只是想大概理解block的底層技術,你只需google一下便可。 若是你想全面深刻的理解block底層技術,那網上的那些資料遠遠知足不了你的需求。 只能閱讀蘋果編譯器clang源碼和列出比較有表明性的block例子源碼,而後轉成c語言和彙編,經過c語言結合彙編研究底層細節。
何謂 oc block?
● block就是閉包,跟回調函數callback很相似,閉包也是對象。
● blcok的特色: 1.可有參數列表 2.可有返回值 3.有方法體 4.capture上下文變量 5.有對象引用計數的內存管理策略(block生命週期)。
● block的通常存儲在內存中形態有三種 _NSConcretStackBlock(棧)_NSConcretGlobalBlock(全局)_NSConcretMallocBlock(堆)。
系統底層怎樣表達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的語句:第一個直接在當前方法test()裏調用,此時的block內存數據在棧上,第二個是異步調用,就是說當執行block(2)時test()可能已經運行完了,test()調用棧可能已經被銷燬。那這種狀況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_info_1,block_desc_1,byref_var1_1,三種函數指針: block invoke方法體,copy方法,dispose方法
● 其實表達block是很是複雜的,還涉及到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))); }
本文大概介紹了問題回放框架,接着介紹三個關鍵技術。這三個技術相對比較深刻,歡迎在留言區評論,咱們期待與你們交流,共同探討。
雲服務器99元拼團購!拉新還可贏現金紅包!300萬等你瓜分!
立刻一鍵開團贏紅包: http://click.aliyun.com/m/100...
本文來自雲棲社區合做夥伴「阿里技術",如需轉載請聯繫原做者。