工程师如何“神还原”用户问题?闲鱼回放技术揭秘
我們透過系統底層來捕獲ui事件流和業務數據的流動,并利用捕獲到的這些數據通過事件回放機制來復現線上的問題。本文先介紹錄制和回放的整體框架,接著介紹里面涉及到的3個關鍵技術點,也是這里最復雜的技術(模擬觸摸事件,統一攔截器實現,統一hook block)。
背景
現在的app基本都會提供用戶反饋問題的入口,然而提供給用戶反饋問題一般有兩種方式:
?●??直接用文字輸入表達,或者截圖
?●??直接錄制視頻反饋
這兩種反饋方式常常帶來以下抱怨:
?●??用戶:輸入文字好費時費力
?●??開發1:看不懂用戶反饋說的是什么意思?
?●??開發2:大概看懂用戶說的是什么意思了,但是我線下沒辦法復現哈
?●??開發3:看了用戶錄制的視頻,但是我線下沒辦法重現,也定位不到問題
所以,為了解決以上問題,我們用一套全新的思路來設計線上問題回放體系。
線上問題回放體系的意義
?●??用戶不需要輸入文字反饋問題,只需要重新操作一下app重現問題步驟即可。
?●??開發者拿到用戶反饋的問題腳本后,通過線下回放對問題一目了然,跟錄制視頻效果一樣,是的,你沒看錯,就是跟看視頻一樣。
?●??通過腳本的回放實時獲取到app運行時相關數據(本地數據,網絡數據,堆棧等等), 以便排查問題。
?●??為后續自動測試提供想象空間——你懂的。
效果視頻
技術原理
1.app與外部環境的關系
?
從上面的關系圖可以看出,整個app的運行無非是用戶ui操作,然后觸發app從外界獲取數據,包括網絡數據,gps數據等等,也包括從手機本地獲取數據,比如相冊數據,機器數據,系統等數據。 所以我們要實現問題回放只需要記錄用戶的UI操作和外界數據,app自身數據即可。
app錄制 = 用戶的UI操作 + 外界數據(手機內和手機外) + app自身數據
2.線上問題回放架構由兩部分組成:錄制和回放
錄制是為回放服務,錄制的信息越詳細,回放成功率就越高,定位問題就越容易
錄制其實就是把ui和數據記錄下來,回放其實就是app自動驅動UI操作并把錄制時的數據塞回相應的地方。
3.錄制架構圖
?
錄制流程:
?
4.回放架構圖
?
回放跟錄制框架圖基本一樣,實際上錄制和回放的代碼是在一起,邏輯也是統一的,為了便于表達,我人為劃分成兩個架構圖出來。
回放的流程:?
1.啟動app,點擊回放按鈕。
2.引擎加載回放腳本。
3.從腳本中解析出需要注冊的運行時事件并注冊,在回放里不需要業務上層來注冊事件,這里跟錄制是不一樣的。
4.從腳本中解析出需要注冊的靜態數據事件并注冊。
5.從腳本中解析出需要播放的事件數據,并組成消費隊列。
6.啟動播放器,從消費隊列里讀取一個個事件來播放,如果是ui事件則直接播放,如果是靜態數據事件則直接按照指令要求替換數據值,如果是非ui運行時事件則通過事件指令規則來確定是主動播放還是等待攔截對應的事件,如果需要等待攔截對應的事件,則播放器會一直等待此事件直到此事件被app消費掉為止。只有此事件被消費了,播放器才能播放下一個事件。
7.當攔截到被注冊的事件后,根據此事件指令要求把相應的數據塞到相應的字段里。
8.跳回6繼續運行,直到消費隊列里的事件被消費完。
注意:回放每個事件時會實時自動打印出相應的堆棧信息和事件數據,有利于排查問題
關鍵技術介紹
1.模擬觸摸事件
從ui事件數據解中析出被觸摸的view,以及此view所在的視圖樹中的層級關系,并在當前回放界面上查找到對應的view,然后往該view上發送ui操作事件(點擊,雙擊等等),并帶上觸摸事件的坐標信息,其實這里是模擬觸摸事件。我們先來介紹觸摸事件的處理流程:
等待觸摸階段
?●??手機屏幕處于待機狀態,等待觸摸事件發生
?●??手指開始觸摸屏幕
系統反應階段
?●??屏幕感應器接收到觸摸,并將觸摸數據傳給系統IOKit(IOKit是蘋果的硬件驅動框架)
?●??系統IOKit封裝該觸摸事件為IOHIDEvent對象
?●??接著系統IOKit把IOHIDEvent對象轉發給SpringBoard進程
SpringBoard進程就是iOS的系統桌面,它存在于iDevice的進程中,不可清除,它的運行原理與Windows中的explorer.exe系統進程相類似。它主要負責界面管理,所以只有它才知道當前觸摸到底有誰來響應。
SpringBoard接收階段
?●??SpringBoard收到IOHIDEvent消息后,觸發runloop中的Source1回調__IOHIDEventSystemClientQueueCallback()方法。
?●??SpringBoard開始查詢前臺是否存在正在運行的app,如果存在,則SpringBoard通過進程通信方式把此觸摸事件轉發給前臺當前app,如果不存在,則SpringBoard進入其自己內部響應過程。
app處理階段
?●??前臺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;@endtypedef?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的原始方法,如果不是立即回放,則需要把現場信息保存起來,并等待合適的時機由播放隊列來播放(調用)
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是在堆上還是在棧上,或在全局區_NSConcreteGlobalBlockint?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方法
?●??其實表達block是非常復雜的,還涉及到block的生命周期,內存管理問題等等,我在這里只是簡單的貫穿主流程來介紹的,很多細節都沒介紹。
怎樣統一 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月
參團地址:http://click.aliyun.com/m/1000020293/
原文鏈接
本文為云棲社區原創內容,未經允許不得轉載。
總結
以上是生活随笔為你收集整理的工程师如何“神还原”用户问题?闲鱼回放技术揭秘的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 教你用一条SQL搞定跨数据库查询难题
- 下一篇: UI2Code智能生成Flutter代码