原文
本篇文章是講述 iOS 無埋點數據收集 SDK 系列的第二篇。在第一篇iOS無埋點SDK 之 RN頁面的數據收集中主要介紹了 SDK 整體實現思路以及基於 viewPath 與 KVC 實現 SDK 的無埋點技術。而本篇的重點是介紹一下 SDK 中的頁面別名方案以及針對 React Native 頁面的數據收集方案,其中在講解 React Native 點擊事件的收集時,詳細的分析了 Native 端與 JS 端對點擊事件的詳細處理過程,相信你在看了這部分之後也會對 React Native 中的 JS 與 Native 間的通信機制有一定的了解了。
頁面別名方案
為什麼引入頁面別名?
在 iOS 項目開發中,經常會使用同一個 ViewController 去創建與展示多個頁面,其中最有代表性的就是商品詳情頁。對於這種情況來說,由於這些頁面的類名是同一個,因此在進行數據收集時,無法對這些頁面的數據進行分別統計與顯示。那麼為了實現對這類頁面進行數據的單獨統計與分析,SDK 引入了頁面別名方案。
頁面別名方案是什麼?
頁面別名方案就是指給一個頁面設置另一個名字,主要用於對同類頁面進行細分。對頁面設置了別名之後,SDK 在數據收集時,就會使用設置的別名了,這樣就能將頁面的數據區分開了。
頁面別名方案的實現
頁面別名方案的具體實現是給 UIViewController 擴展了一個別名屬性,而對屬性的存取是通過 Associated Objects(關聯對象) 來實現。
在給 UIViewController 擴展別名屬性時,對 Native 頁面和 React Native(以下簡稱 RN)頁面進行了分別定義。這麼做的原因是 SDK 對這2種別名屬性進行了不同的處理,接下來詳細的介紹一下它們。
Native 頁面的別名屬性
/** * 對原生頁面設置別名 */ @property (nonatomic, copy, nullable) NSString *pageAlias;
這個屬性被用於對 Native 頁面設置別名,比如上面提到的商品詳情頁,如果想查看某一個商品的詳情頁的數據,那就可以將 productId 設置為這個詳情頁的別名。
SDK 對 Native 頁面的別名的處理方案如下:
對於 數據SDK,如果頁面有別名,那麼在上報的事件數據中,page 字段的值為:類名 + 別名。(page字段用於標識事件數據所歸屬的頁面)
對於 圈選SDK,不論頁面有無別名,在對 view 進行圈選時,page 字段的值為:類名。(page字段用於標識圈選配置所作用的頁面)
為何 數據SDK 對於有別名的頁面,在進行數據收集時,page字段要帶上類名,而不是直接使用別名呢?又為何 圈選SDK 的圈選配置中的 page 字段不帶類名?這麼做的主要原因是:同一個類名的所有頁面共用同一份圈選配置,避免重復的圈配。具體看下圖:
後台在對這些別名頁面進行統計分析時,首先通過類名獲取到對應的圈選配置,然後再對每個別名頁面的數據進行統計,最後將統計結果展示到對應的別名頁面中。
RN 頁面的別名屬性
對於 RN 頁面的別名屬性,又針對不同的場景分別定義了別名屬性。
場景1
進行 Native 與 RN 的混合開發時,封裝了一個 RNViewController 來創建不同的實例去承載一個或多個 RN 頁面。
表面上看,這個場景與上面的商品詳情頁的情況很類似,都使用同一個類名創建不同的實例來展示不同的頁面,但是這 2 者卻存在一個很大的不同:上面的場景創建的實例是展示相同結構的頁面,只是顯示的數據不同。而這個場景創建的實例是用於展示不同結構的 RN 頁面。如果頁面的結構都不同,就應該認為是不同的頁面,因此也就不能共用同一套圈選配置了。
因此,針對這個場景單獨定義了一個頁面別名屬性:
/** * 用於設置 RN 頁面別名(通常使用 ModuleName 作為頁面別名) */ @property (nonatomic, copy, nullable) NSString *pageAliasInRN;
SDK 對這個別名屬性的處理方案是:
如果設置了此別名屬性,數據SDK 和 圈選SDK 中的 page 字段的值都為:別名,不再添加類名。
場景2
在 RN 混合開發項目中,使用同一個 controller 實例展示多個 RN 頁面。在純 RN 開發項目中,只有一個最外層的 controller 實例來展示所有的 RN 頁面。
這個場景的主要特點在於,多個 RN 頁面被放到了一個 Native 頁面中展示,這樣就沒法直接區分裡面的每一個 RN 頁面,因此為了能夠進一步區分不同的 RN 頁面,又定義了另一個頁面別名屬性:
/** * 用於設置Native頁面裡當前展示的 component(通常使用 componentName 作為 RN 頁面別名) */ @property (nonatomic, copy, nullable) NSString *componentName;
如果當前的 controller 實例的這個別名屬性不為空,那麼數據SDK、圈選SDK中的page字段的值為:componentName。
RN 頁面的數據收集
介紹完頁面別名方案後,就可以講一下 SDK 對 RN 頁面的數據收集的實現方案了。對 RN 頁面的數據收集主要包括3個方面:
頁面事件(show、hide)
點擊事件
滑動事件
接下來逐個介紹 SDK 的實現方案。
頁面事件的收集
頁面事件的收集具體又分為如下2種情況:
多個 RN 頁面在一個 controller 實例中通過Navigator跳轉(簡稱:Navigator跳轉)
2個展示 RN 頁面的 controller 實例通過原生UINavigationController跳轉(簡稱:原生跳轉)
上述2種情況中,由於一個 controller 實例中展示 1個或多個 RN 頁面,因此不能再使用 controller 的名字來區分每個 RN 頁面。在 React Native 中,一個 RN 頁面可以看做是一個組件(Component),因此這裡可以使用 componentName 作為 RN 頁面名。
另外,由於 component 的名字是在 RN 中定義的,SDK 是無法自動獲取的,因此需要在 JS 端通過埋點的方式將相應 component 的名字傳給 SDK。
Navigator 跳轉的頁面事件收集
這種情況是指,在一個原生的 controller 實例中展示了多個 RN 頁面,而多個 RN 頁面間的跳轉是由 RN 中的Navigator來管理。這種情形主要存在於純 RN 項目中,不過在混合開發中也會存在。此情形可以用下圖表示:
結合上圖,在 controller 實例1中,進行了2個操作:
頁面1通過 Navigatorpush到頁面2;
頁面2通過 Navigatorpop 到頁面1;
那麼,SDK 對這2個操作相應的處理方案如下:
從 component1push到 component2 時,JS 端會將 component2 的名字傳遞給 SDK。此時,SDK 先對 component1 產生一個hide事件,然後再對 component2 產生一個show事件,最後將 component2 的名字設置到 controller 的別名屬性 componentName 上。
從 component2pop到 component1 時,JS 端會將 component1 的名字傳遞給 SDK。此時,SDK 先對 component2 產生一個hide事件,然後再對 component1 產生一個show事件,最後將 component1 的名字設置到 controller 的別名屬性 componentName 上。
從上面可以看出,在通過Navigator進行 RN 頁面間的跳轉時,SDK 內部對每個 RN 頁面都生成了相應的 show、hide 事件。除此之外,SDK 還做了另一步:將當前顯示的 component 的名字設置到 controller 的別名屬性 componentName 上。這一步其實是為了 RN 頁面點擊事件的收集做准備的,在點擊事件的收集中會用到。
原生 UINavigationController 跳轉的頁面事件收集
這種情形是指,承載 RN 頁面的原生 controller 實例通過 iOS 原生的 UINavigationController 進行跳轉,這裡的跳轉有 3 種情況:
承載 RN 頁面的 controller 跳轉到不含 RN 頁面的 controller
不含 RN 頁面的 controller 跳轉到承載 RN 頁面的 controller
承載 RN 頁面的 controller1 跳轉到承載 RN 頁面的 controller2
其實,對於 SDK 來說,第 3 種情況包含了前 2 種情況,因此這裡主要講解第 3 種情況時的頁面事件收集方案。第 3 種情況可以表示成下圖:
結合上圖,SDK 的頁面事件收集方案由如下 6 步組成:
在 controller1 的viewWillAppear:觸發時,首先檢查 controller1 的別名屬性componentName是否有值,如果有值,則對此componentName產生一個show事件。如果 controller 首次創建或者不含 RN 頁面,此時別名屬性為空。
JS 端在 RN 組件加載時,將組件的名字傳給 SDK。由於 RN 組件只會被加載 1 次,因此這步只會發生在 controller 被首次創建時。SDK 在拿到 JS 傳過來的組件名時,先對它產生一個show事件,再將其設置到 controller1 的別名屬性componentName上。
在 controller1 的viewDidDisappear:觸發時,如果 controller1 的別名屬性componentName有值,則對它產生一個hide事件。
與第 1 步類似。
與第 2 步類似。
與第 3 步類似。
當從 controller1 push到 controller2 時,按照先後順序會執行上述的第 3、4、5 步。當從 controller2 pop到 controller1 時,按照先後順序會執行上述的第 6、1 步。而第 2 步則只會在 controller1 被創建時執行。
點擊事件的收集
在實現對 RN 頁面的點擊事件的收集時,首先簡單閱讀了 iOS 端 RN 框架的源碼,發現裡面有一個類叫 RCTTouchHandler,它繼承自 UIGestureRecognizer。RN 主要使用這個類來完成統一接收和處理用戶的點擊事件,它的具體實現是重寫了 UIResponder 中事件分發的四個方法:touchesBegan、touchesMoved、touchesEnded、touchesCancelled,並將觸摸事件封裝成RCTTouchEvent分發到 JS 端。
錯誤的方案(踩的一個坑)
從上面的分析看出,其實 RN 在底層也是通過UIResponder中提供的 4 個處理觸摸事件的方法來實現對用戶點擊事件的處理,同時發現在這 4 個方法中都調用了 _updateAndDispatchTouches:,那麼理所當然的就想到了直接讓 SDK 去 hook 此方法即可攔截到各個階段的 Touch 事件(其實這種做法是錯誤的,是我采坑的開始)。
有了這個思路後,很快完成了代碼編寫,接著就迫不及待的放到 RN 的工程裡去測試,結果發現了 2 個很嚴重的問題:
在 RN 頁面的任意位置的點擊都會觸發點擊事件的處理,並執行數據收集
在點擊一個包含多個普通子視圖的視圖時,由於點擊到的子視圖的不同,收集到的數據也是不同的。但是這幾個普通子視圖是不具有響應處理能力的,不應該響應觸摸事件。
看來對 RN 點擊事件的收集並沒有之前想象的那麼簡單,需要認真閱讀下 RN 框架的源碼了。雖然是 2 個問題,但其實在查找原因時,發現這 2 個問題產生的原因是同一個。
仔細閱讀源碼後,發現 RCTTouchHandler 只被用到了 2 個類中:RCTRootView、RCTModalHostView。RCTModalHostView應該是在做 Modal 視圖時使用的;而RCTRootView則是 RN 中最重要的一個類,功能類似於 UIView。其實真正使用RCTTouchHandler的類是RCTRootContentView,它的聲明與實現都在RCTRootview.m文件中。在RCTRootContentView的初始化方法中創建了RCTTouchHandler的對象,並添加到了自己上。而其它的組件大多都是RCTView,並未處理用戶的觸摸事件。
因此,到這裡就清楚了上述問題出現的原因了:
由於RCTRootContentView中添加了RCTTouchHandler對象,同時其它的RCTView都未攔截處理觸摸事件,因此在 RN 頁面的任何位置被點擊時,用戶的 Touch 事件都會交給RCTRootContentView去響應處理,進而進入了RCTTouchHandler的幾個方法裡,同時也執行了 SDK 的數據收集的代碼。
在 SDK 中,是通過 touch.view 獲取了當前點擊的view,其實也是錯誤的做法,因為當前點擊的 view 並不一定是響應此點擊事件的 view,這個真正響應點擊事件的 view 其實是由 JS 端來查找到的(後面會詳細分析)。
綜上,上述方案是不正確的,SDK 不能通過 hook RCTTouchHandler類的_updateAndDispatchTouches:方法來執行點擊事件的收集,因為這個時機無法獲取到真正響應與處理Touch事件的view。
正確的方案
那麼,SDK 應該在哪個時機去執行數據收集呢?這個問題涉及到了 RN 框架中的 JS 與 OC 間的通信機制以及 JS 端的觸摸事件處理機制。這裡不會單獨介紹 RN 的通信機制是如何實現的,接下來主要以用戶的點擊事件如何在 Native 端傳遞以及如何在 JS 端處理為主線,來講解 SDK 對 RN 頁面的點擊事件的收集方案。
對於用戶觸摸事件的處理,主要包含了 2 部分:Native 端、JS 端。下面詳細分析下各自的處理流程。
Native 端觸摸事件的處理過程
根據上面 錯誤的方案 中的分析,在用戶進行點擊操作時,首先是 Native 端捕獲到此觸摸事件,然後在主線程中對觸摸事件進行一些處理, 具體的處理過程如下圖:
圖中涉及到 RN 框架中的幾個主要的類,先簡單介紹一下它們的功能:
RCTTouchHandler:前面介紹過,它是 UIGestureRecognizer 的子類,主要用來處理用戶的觸摸事件,其實只是將觸摸事件的信息封裝成了 RCTTouchEvent 對象,即 JS 端在處理觸摸事件時所需要的一些信息。
RCTEventDispatcher:將 Native 產生的 event 緩存起來,並切換至 JSThread 執行 event 的分發,其實就是調用了 -[RCTBridge enqueueJSCall:args:] 方法去主動發起 JS 的調用。
RCTBridge:負責 JS 與 Native 間的橋接,其內部創建並持有一個 RCTBatchedBridge 對象,大部分的代碼邏輯都是由這個對象來實現的。
RCTBatchedBridge:負責實現很多核心的業務邏輯,集中在 start 方法中。簡要來說,主要包含如下幾點:
loadSource:從本地或網絡異步獲取 jsbundle
initModules:針對每一個 RCTBridgeModule 創建對應的 RCTModuleData ,其中也包括 RCTJSCExecutor 並將其存儲到 _moduleDataByID、_moduleDataByName 中。
setUpExecutor:初始化 JS 代碼執行器,就是創建一個 RCTJSCExecutor 對象,並將一些 block 添加到 js 的 context 中,JavaScriptCore 框架會將其轉換成 JS 的 function。這一步在 JS 與 Native 的通信中是非常重要的,而且這一步是在 JSThread 上執行的。
injectJSONConfig:獲取每個模塊的 conifg ,並設置到 JS 的全局變量 __fbBatchedBridgeConfig 上。
executeSourceCode:執行 loadSource 中的 js 代碼。
RCTJSCExecutor:JS 代碼的執行器,其內部使用了 JavaScriptCore 的 context 作為 JS 的執行引擎。在其初始化時,創建了一個 JSThread 來執行 JS 方法的調用。
接著來分析下上面的調用流程,從圖中可以看出,Native 端對用戶的點擊事件的處理發生在 2 個線程上:Main-Thread、JS-Thread。調用過程中一些主要的點在圖中使用 tag 標出來了,下面逐個解釋一下:
tag1:將用戶的 Touch 事件的信息封裝成 RCTTouchEvent 對象,其中包含了 eventName、reactTag、reactTouches、changedIndexes 等信息。
tag2:將當前 event 生成一個 eventID 並放到 _events 全局字典中,並向 JS 線程提交事件處理申請。
tag3:將執行 flush event 的 block 分發到 JS 線程上去執行。如果當前不在 JS 線程,則進行切換。
接下來的操作,全部在 JSThread 上執行,JSThread 是 Native 端創建的一個專門用於執行 JS 代碼的線程,所有的 JS 代碼只會在這個線程上執行。
tag4:將 _events 中所有的 event 都分發給 JSBridge,並指定要調用的 JS 端的方法:RCTEventEmitter.receiveTouches,即 RCTEventEmitter 的 receiveTouches 方法。
tag5:將上述 RCTEventEmitter.receiveTouches 通過 . 拆分開,前面的 RCTEventEmitter 是 module 名,後面的 receiveTouches 是 method。
tag6:調用 JS 執行器去執行 JS 代碼。這裡有一個重要的點:聲明了一個回調的 block,用來處理 JS 端想調用的 Native 端的方法。這個回調會在執行完 JS 方法後執行。block 代碼如下:
callback:^(id json, NSError *error) { [weakSelf _processResponse:json error:error]; }];
tag7:這個環節才進行真正的 JS 代碼調用,不過這裡並沒有直接調用前面 Native 所指定的 JS 方法,而是先調用了 JS 中的 1 個中轉方法 callFunctionReturnFlushedQueue,這個方法被定義在 MessageQueue.js 中。
Native 端按照上面的流程執行完對點擊事件的處理後,通過 RCTBatchedBridge 主動調用 JS 方法,將點擊事件交給了 JS 端處理。下面再詳細分析下 JS 端的處理過程。
JS 端觸摸事件的處理過程
在 JS 端對點擊事件的處理過程中,包含了對 Native 方法的調用,同時 Native 端又針對 JS 發起的調用,進行 Native 方法的調用。為了使整個過程連貫起來,這裡將 JS 端與 Native 端的處理放到一起分析了。整個處理過程見下圖:
下面仍然針對上面所標出的點逐個講解:
tag8:首先會進入 MessageQueue.js 的 callFunctionReturnFlushedQueue 方法,即前面所說的中轉方法,這個中轉方法主要做了2步:(1)調用 __callFunction 方法根據 Native 端傳過來的 module、method、args 來找到 JS 端方法並觸發。(2)調用 flushedQueue 將當前保存的要調用 Native 的方法的 queue 返回給 Native 端。
tag9:接著進入 ReactNativeEventEmitter.js 的 receiveTouches 方法中,即 Native 端想要調用的 JS 方法。這裡有一點可能有人會問:前面在 Native 端要調用的 module 是 RCTEventEmitter,在 JS 端怎麼變成了 ReactNativeEventEmitter,其實這是因為 JS 端在通過 BatchedBridge.registerCallableModule 注冊對 Native 端暴露的 module 時,就是注冊的 ReactNativeEventEmitter,只是名字設成了 RCTEventEmitter。看如下代碼就明白了:
// RCTEventEmitter.js const RCTEventEmitter = { register(eventEmitter: any) { BatchedBridge.registerCallableModule( 'RCTEventEmitter', eventEmitter ); } }; // ReactNativeDefaultInjection.js RCTEventEmitter.register(ReactNativeEventEmitter);
tag10:React.js 也提供了一種類似於 Native 端的觸摸事件處理機制,用來查找能夠響應觸摸事件的組件,並執行事件響應。JS 端在接收到 Touch 事件後,進入觸摸事件處理流程,並在查找到當前觸摸事件對應的響應者時,觸發 ReactNativeGlobalResponderHandler.js 的 onChange 指定的函數。
tag11:在 onChange 的函數中主動調用了 UIManager 的 setJSResponder 方法,對應到 Native 的 RCTUIManager 的 setJSResponder:blockNativeResponder: 方法。即開始了 JS -> Native 的調用過程,此時會直接進入 NativeModules.js 的 genMethod 方法來查找到對應的 moduleID、methodID、args 等信息,最終執行 MessageQueue.js 的 enqueueNativeCall 方法。
tag12:在 enqueueNativeCall 方法中,將 JS 端要調用的 Native 端方法的 moduleID、methodID、args 放入全局數組 _queue 中。然後等待 Native 端主動來取,JS 端通過 flushedQueue 方法將 _queue 傳過去,接著就進入到了 tag6 中所提到的回調 block 中。這裡將主要的 JS 代碼也貼出來,便於理解:
// enqueueNativeCall 方法 this._queue[MODULE_IDS].push(moduleID); // MODULE_IDS = 0 this._queue[METHOD_IDS].push(methodID); // METHOD_IDS = 1 this._queue[PARAMS].push(params); // PARAMS = 2 // flushedQueue 方法 const queue = this._queue; this._queue = [[], [], [], this._callID]; return queue[0].length ? queue : null;
tag13:除了等待 Native 端主動來取 _queue 中的值外,還有 1 種方式就是:JS 端主動發起對 Native 方法的調用,具體是調用 global.nativeFlushQueueImmediate 方法。不過這種方式有一個條件:距離 Native 上次主動獲取超過 5 ms。相應的 JS 代碼如下:
// enqueueNativeCall 方法 const now = new Date().getTime(); if (global.nativeFlushQueueImmediate && now - this._lastFlush >= MIN_TIME_BETWEEN_FLUSHES_MS) { global.nativeFlushQueueImmediate(this._queue); this._queue = [[], [], [], this._callID]; this._lastFlush = now; }
tag14:通過圖中的方式1 與方式2,最終轉到了 Native 端,並進入 RCTBatchedBridge 的 handleBuffer:batchEnded: 方法中。接著調用 handleBuffer: 方法從 JS 的 _queue 數組中解析出 moduleID、methodID、params。
tag15:在 RN 中定義 module 時,可以為 module 指定一個 methodQueue 執行它的方法。因此,通過 dispatchBlock:queue: 方法將各個 module 的方法的調用分發到各自的 methodQueue 上。這裡 RCTUIManager 的 methodQueue 是名字為 com.facebook.react.ShadowQueue 的串行隊列。
tag16:在切換至指定的 methodQueue 上後,調用 callNativeModule:method:params: 方法開始 Native 方法的調用。在這個方法中,會根據 moduleID、methodID 從全局數組 _moduleDataByID 中找到 Native 端對應的 moduleData、moduleMethod。
tag17:在 invokeWithBridge:module:arguments: 方法中,將 Native 方法的調用封裝成 NSInvocation 對象,並觸發執行。其實就是調用在 tag11 中提到的 RCTUIManager 類的 setJSResponder:blockNativeResponder:。這個方法的定義是:RCT_EXPORT_METHOD(setJSResponder:(nonnull NSNumber *)reactTag blockNativeResponder:(__unused BOOL)blockNativeResponder),它具有 2 個參數,其中第 2 個參數沒有使用到,而第 1 個參數 reactTag 是非常重要的,使用它從全局字典 _viewRegistry 中能拿到對應的 view,而這個 view 就是真正響應用戶點擊事件的視圖。
收集點擊事件的正確時機
到這裡,已經從 Native -> JS -> Native 完整的分析了一遍整個處理過程,可以清晰得看到 RN 頁面中的用戶點擊事件是如何被傳遞與處理的。其實,對於 SDK 來說,最重要的是找到一個正確的時機去執行數據收集,通過上面的分析可以很容易的找到這個時機:在 JS 端回調 RCTUIManager 的 setJSResponder:blockNativeResponder: 時。因為在這個時機,SDK 能夠拿到真正響應點擊事件的 view。
最終的實現方案
那麼,SDK 對 RN 的點擊事件的收集方案也已經明確了,具體可以分為如下 3 步:
hook RN 框架中的 RCTUIManager 類的 setJSResponder:blockNativeResponder: 方法。
根據 _viewRegistry 和 reactTag 拿到真正響應點擊事件的 view 對象。
將此點擊事件的數據歸屬到正確的 page 中。如果 componentName 屬性有值,則使用它;否則使用 pageAliasInRN 屬性的值。
另外,在第 2 個調用流程圖中,提到了 RN 的觸摸事件處理機制,並沒有詳細講解,不過可以去看一下這篇文章,裡面講解的非常清楚。
滑動事件的收集
RN 中有 2 個很常用的組件:ScrollView、ListView。這 2 個組件最常用的交互動作就是滑動,因此 SDK 也需要收集它的滑動事件。由於 ListView 是基於 ScrollView 封裝實現的,因此 SDK 只需要對 ScrollView 的滑動事件進行收集即可。
其實查看一下 ScrollView.js 的源碼可以看出,在 iOS 平台上,ScrollView 組件其實使用的是 RN 框架中的 RCTScrollView,相應的 JS 代碼為:
else if (Platform.OS === 'ios') { nativeOnlyProps = { nativeOnly: { onMomentumScrollBegin: true, onMomentumScrollEnd : true, onScrollBeginDrag: true, onScrollEndDrag: true, } }; RCTScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps); }
接著,查看一下 RN 框架中的 RCTScrollView,發現其內部支持一個 RCTCustomScrollView 類的對象,而這個 RCTCustomScrollView 是 UIScrollView 的子類。因此,可以得出 1 個結論:RN 中的 ScrollView 組件其實對應到 iOS 中的 UIScrollView。
那麼,SDK 要收集 RN 頁面中的滑動事件,就相當於收集 iOS 原生 UIScrollView 的滑動事件,因此只需要 hook UIScrollViewDelegate 的相關方法即可。
END
全文主要講述了無埋點 SDK 中的 RN 頁面的數據收集方案,以及頁面別名方案的引入。其中 RN 點擊事件的收集方案占了較大篇幅,裡面涉及到了 Native 與 JS 的通信過程,包括 RN 中多線程的使用。個人感覺從 RN 框架的源碼中還是能發掘到不少干貨的,所以建議大家有時間了可以去閱讀下 RN 的源碼。
文章寫了這麼多,其實主要介紹了 SDK 中的兩個關鍵技術點,希望對你們能有一些參考價值。另外,如果有人對本文的方案有更好的建議,歡迎一起討論學習。
最後,要特別感謝我的同事王佳樂,由於他對文章的排版與校對工作,才使得本文能更好的展示給大家。同時也要感謝組內的所有同事,在我開發遇到困難時,給予了我很多的幫助。
Q & A
關於對本文內容提出的一些問題,將全部記錄在這裡(簡書評論裡的除外),並進行統一解答。
Q1: SDK 都使用KVC配置獲取業務數據,是否會增加維護KVC配置的工作?
A1: 會有對 KVC配置 的維護與管理工作,不過 SDK 也簡化了這塊的管理工作。
一般來說,上傳的所有的 KVC配置 需要與 App 的版本相對應,因為 App 版本不同會直接導致keyPath可能不一樣。所以與 KVC配置 相關的工作有如下2個:
針對當前 App 版本上傳相應的 KVC配置,以獲取想要的業務數據
當 App 新版本發布時,需要對之前版本上的 KVC配置 逐一驗證,是否仍然適用於新版本。如果仍然適用,則直接在管理後台上把新的版本號添加到此 KVC配置;如果不再適用,則對新版本再上傳一個新的KVC配置。
從上面可以看出,在 App 版本不斷迭代的過程中,KVC配置 會越來越多,相應的維護與管理工作也相當繁瑣。
為了解決這個痛點,SDK 中增加了一種方案來避免這種重復且繁瑣的工作。具體的方案是:
在上傳 KVC 配置時,指定某個區間的版本,或者不指定具體的版本(即應用到當前所有版本上);
SDK 在使用KVC配置獲取業務數據失敗時,添加相關的錯誤日志,並上報上去。其中錯誤日志裡包含了appKey、appVersion、keyPath等信息,這樣就能在後台清晰的看到哪些 KVC配置 在哪個 App 版本上存在問題;
使用腳本監控與KVC相關的錯誤日志。如果監控到有錯誤日志上報,則發送郵件通知給相關人員;
因此,SDK 采用此方案優化之後,KVC配置 的管理工作就只有1個了:
根據Log信息快速找到對應的 KVC配置,並上傳一個針對新版本的 KVC配置
Q2: 對於 “內容與位置” 可能會隨時間而變動時,如何實現數據收集與統計?
A2: 使用圈選SDK與數據SDK共同完成動態數據的收集與統計
這個問題在實際產品中也比較常見,比如 App 首頁的內容大多是通過後台配置的。
這個問題其實可以轉化或分解成如下的2個情況:
同一位置會顯示不同的內容
同一內容會顯示在不同的位置
注意,這2個並非同一個,它們分別對應於不同的場景,同時數據收集的方案也有所不同。
另外,“位置” 可以是在列表中,也可以是非列表中的,不過這個對整體的方案沒有太大影響,僅僅是在不關心位置時viewPath中的通配符位置不同。
A2.1 同一位置顯示不同的內容
例子:在 App 首頁有一個展示最近活動的位置,先展示活動1的圖片,過一段時間運營人員又配成活動2的圖片。如何統計活動1、活動2各自的點擊量?
針對這種場景,SDK 的解決方案是:“關心位置” + “關心內容”。
“關心位置” 的意思是只使用當前的位置,具體表現是viewPath中不包含任何通配符;“關心內容” 的意思是指定一個想要統計的內容。
整個過程可以分解為如下3個環節:
圈選SDK上傳“關心位置”的KVC配置。KVC配置中指定獲取活動的url的keyPath。
數據SDK在活動發生點擊時,收集當前活動對應的url,並跟隨點擊事件一起上報。
圈選SDK上傳“關心位置” + “關心內容”的圈選配置,關心的內容指定為想要統計的活動的url值。
A2.2 同一內容顯示在不同的位置
例子:App 首頁有4個固定的入口,假設其中一個叫“熱門推薦”,那麼根據後台配置的順序不同,“熱門推薦”可能被顯示在4個位置中的任何1個,即一段時間顯示在第1個,過一段時間可能顯示在第2個位置。這時如何統計出“熱門推薦”的點擊量?
針對這種場景,SDK 的解決方案是:“不關心位置” + “關心內容”。
“不關心位置” 是指viewPath中含有通配符,用於表示viewTree中的多個位置。例如想要匹配列表所有行時,則將viewPath中的indexPath替換為通配符。
這個問題的解決過程也分為如下3步:
圈選SDK上傳“不關心位置”的KVC配置。KVC配置中指定獲取入口的 title 的keyPath。
數據SDK在4個中任何一個入口被點擊時,都去收集入口的 title,並跟隨點擊事件一起上報。
圈選SDK上傳“不關心位置” + “關心內容”的圈選配置,關心的內容指定為“熱門推薦”。
到這裡,數據收集與圈選配置的工作都已經做完了,接下來就是後台的數據統計了。
上述2種情況對後台進行統計沒有區別,都使用一個統計方案,這裡也介紹一下後台大概的統計思路:
拿到第3步中上傳的圈選配置,根據viewPath 與 “關心的內容” 生成一個正則表達式,然後從數據 SDK 上報的原始數據中進行正則匹配,進而統計出相應數據。