千人千面線上問題回放技術揭祕
導語
釋出app後,開發者最頭疼的問題就是如何解決交付後的使用者側問題的還原和定位,是業界缺乏一整套系統的解決方案的空白領域,閒魚技術團隊結合自己業務痛點提出一套全新的技術思路解決這個問題並在線上取得了比較滿意的實踐效果。
我們透過系統底層來捕獲ui事件流和業務資料的流動,並利用捕獲到的這些資料通過事件回放機制來複現線上的問題。本文先介紹錄製和回放的整體框架,接著介紹裡面涉及到的3個關鍵技術點,也是這裡最複雜的技術(模擬觸控事件,統一攔截器實現,統一hook block)
背景
現在的app基本都會提供使用者反饋問題的入口,然而提供給使用者反饋問題一般有兩種方式:
-
直接用文字輸入表達,或者截圖
-
直接錄製視訊反饋
這兩種反饋方式常常帶來以下抱怨:
-
使用者:輸入文字好費時費力
-
開發1:看不懂使用者反饋說的是什麼意思?
-
開發2:大概看懂使用者說的是什麼意思了,但是我線下沒辦法復現哈
-
開發3:看了使用者錄製的視訊,但是我線下沒辦法重現,也定位不到問題
所以:為了解決以上問題,我們用一套全新的思路來設計線上問題回放體系
線上問題回放體系的意義
-
使用者不需要輸入文字反饋問題,只需要重新操作一下app重現問題步驟即可
-
開發者拿到使用者反饋的問題指令碼後,通過線下回放對問題一目瞭然,跟錄製視訊效果一樣,是的,你沒看錯,就是跟看視訊一樣。
-
通過指令碼的回放實時獲取到app執行時相關資料(本地資料,網路資料,堆疊等等), 以便排查問題
-
為後續自動測試提供想象空間--你懂的
效果視訊
技術原理
1.app與外部環境的關係
從上面的關係圖可以看出,整個app的執行無非是使用者ui操作,然後觸發app從外界獲取資料,包括網路資料,gps資料等等,也包括從手機本地獲取資料,比如相簿資料,機器資料,系統等資料。 所以我們要實現問題回放只需要記錄使用者的UI操作和外界資料,app自身資料即可。
app錄製 = 使用者的UI操作 + 外界資料(手機內和手機外) + app自身資料
2.線上問題回放架構由兩部分組成:錄製和回放
3.錄製架構圖
錄製流程
4.回放架構圖
回放跟錄製框架圖基本一樣,實際上錄製和回放的程式碼是在一起,邏輯也是統一的,為了便於表達,我人為劃分成兩個架構圖出來。
回放的流程:
回放流程圖在這裡省略
1.啟動app,點選回放按鈕
2.引擎載入回放指令碼
3.註冊事件(如ui事件,網路資料事件,本地檔案事件,頁面跳轉事件等等)
4.從指令碼中解析出一個個事件資料節點,並組成消費佇列
5.啟動播放器,從消費佇列裡讀取一個個事件來播放,如果是ui事件則直接播放,如果是靜態資料事件則直接按照指令要求替換資料值,如果是非ui執行時事件則通過事件指令規則來確定是主動播放還是等待攔截對應的事件,如果需要等待攔截對應的事件,則播放器會一直等待此事件直到此事件被app消費掉為止。只有此事件被消費了,播放器才能播放下一個事件。
6.當攔截到被註冊的事件後,根據此事件指令要求把相應的資料塞到相應的欄位裡
7.跳回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事件的時間間隔記錄下來,因為這個涉及到觸控引起慣性加速度問題。比如我們平時滑動列表檢視時,手指離開屏幕後,列表檢視還要慣性地滑動一小段時間。
我們來看一下程式碼怎麼模擬單擊觸控事件(為了容易理解,我把有些不是關鍵,複雜的程式碼已經去掉)
接著我們來看一下模擬觸控事件程式碼 一個基本的觸控事件一般由三部分組成:
-
1.UITouch物件 - 將用於觸控
-
2.第一個UIEvent Began觸控
-
3.第二個UIEvent Ended觸控
實現步驟:
-
1.程式碼的前面部分都是一些UITouch和UIEvent私有介面,私有變數欄位,由於蘋果並不公開它們,為了讓其編譯不報錯,所以我們需要把這些欄位包含進來,回放是線上下,所以不必擔心私有介面被拒的事情。
-
2.構造觸控物件:UITouch和UIEvent,把記錄對應的欄位值塞回相應的欄位。塞回去就是用私有介面和私有欄位
-
3.觸控的view位置轉換為Window座標,然後往app裡傳送事件 [[UIApplication sharedApplication] sendEvent:event];
-
4.要回放這些觸控事件,我們需要把他丟到CADisplayLink裡面來執行
怎樣呼叫私有介面,以及使用哪些私有介面,這點不需要再解釋了,如果感興趣,請關注我們公眾號,後續我專門寫篇文章來揭露這方面的技術,總的來說就下載蘋果提供觸控事件的原始碼庫,分析原始碼,然後設定斷掉除錯,甚至反彙編來理解觸控事件的原理。
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的例子:
這段程式碼首先定義兩個變數,接著定義一個block,最後呼叫block。
-
兩個變數:這兩個變數都是被block引用,第一個變數有關鍵字_ block,表示可以在block裡對該變數賦值,第二個變數沒有_ block關鍵字,在block裡只能讀,不能寫。
-
兩個呼叫block的語句:第一個直接在當前方法test()裡呼叫,此時的block記憶體資料在棧上,第二個是非同步呼叫,就是說當執行block(2)時test()可能已經執行完了,test()呼叫棧可能已經被銷燬。那這種情況block的資料肯定不能在棧上,只能在堆上或者在全域性區。
系統底層表達block比較重要的幾種資料結構如下:
-
注意:雖然底層是用這些結構體來表達block,但是它們並不是原始碼,是二進位制程式碼
這個例子中的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原始碼如下
我們首先新建一個新的block newBlock,然後把原來的block orgblock 和 事件指令blockEvent包到新的blcok中,這樣達到引用的效果。然後把新的block轉成結構體指標,並把結構體指標中的欄位invoke(方法體)指向統一回調方法。你可能詫異新的block是沒有引數型別的,原來block是有引數型別,外面呼叫原來block傳遞引數時會不會引起crash?答案是否定的,因為這裡構造新的block時 我們只用block資料結構,block的回撥方法欄位已經被閹割,回撥方法已經指向統一方法了,這個統一方法可以接受任何型別的引數,包括沒有引數型別。這個統一方法也是彙編實現,程式碼實現跟上面的彙編層程式碼類似,這裡就不附上原始碼了。
那怎樣在新的blcok裡讀取原來的block和事件指令物件呢? 程式碼如下:
總結
閒魚技術團隊是一隻短小精悍的工程技術團隊。我們不僅關注於業務問題的有效解決,同時我們在推動打破技術棧分工限制(android/iOS/Html5/Server 程式設計模型和語言的統一)、計算機視覺技術在移動終端上的前沿實踐工作。作為閒魚技術團隊的軟體工程師,您有機會去展示您所有的才能和勇氣,在整個產品的演進和使用者問題解決中證明技術發展是改變生活方式的動力。
簡歷投遞: [email protected]
Line"/>
識別二維碼,前瞻技術盡在掌握