本篇文章是講述 iOS 無埋點(diǎn)數(shù)據(jù)收集 SDK 系列的第二篇。在第一篇 <iOS無埋點(diǎn)數(shù)據(jù) SDK 實(shí)踐之路> 中主要介紹了 SDK 整體實(shí)現(xiàn)思路以及基于 viewPath
與 KVC
實(shí)現(xiàn) SDK 的無埋點(diǎn)技術(shù)。而本篇的重點(diǎn)是介紹一下 SDK 中的頁面別名方案以及針對(duì) React Native
頁面的數(shù)據(jù)收集方案,其中在講解 React Native
點(diǎn)擊事件的收集時(shí),詳細(xì)的分析了 Native 端與 JS 端對(duì)點(diǎn)擊事件的詳細(xì)處理過程,相信你在看了這部分之后也會(huì)對(duì) React Native
中的 JS 與 Native 間的通信機(jī)制有一定的了解了。
頁面別名方案
為什么引入頁面別名?
在 iOS 項(xiàng)目開發(fā)中,經(jīng)常會(huì)使用同一個(gè) ViewController
去創(chuàng)建與展示多個(gè)頁面,其中最有代表性的就是商品詳情頁。對(duì)于這種情況來說,由于這些頁面的類名是同一個(gè),因此在進(jìn)行數(shù)據(jù)收集時(shí),無法對(duì)這些頁面的數(shù)據(jù)進(jìn)行分別統(tǒng)計(jì)與顯示。那么為了實(shí)現(xiàn)對(duì)這類頁面進(jìn)行數(shù)據(jù)的單獨(dú)統(tǒng)計(jì)與分析,SDK 引入了頁面別名方案。
頁面別名方案是什么?
頁面別名方案就是指給一個(gè)頁面設(shè)置另一個(gè)名字,主要用于對(duì)同類頁面進(jìn)行細(xì)分。對(duì)頁面設(shè)置了別名之后,SDK 在數(shù)據(jù)收集時(shí),就會(huì)使用設(shè)置的別名了,這樣就能將頁面的數(shù)據(jù)區(qū)分開了。
頁面別名方案的實(shí)現(xiàn)
頁面別名方案的具體實(shí)現(xiàn)是給 UIViewController
擴(kuò)展了一個(gè)別名屬性,而對(duì)屬性的存取是通過 Associated Objects
(關(guān)聯(lián)對(duì)象) 來實(shí)現(xiàn)。
在給 UIViewController
擴(kuò)展別名屬性時(shí),對(duì) Native 頁面和 React Native(以下簡(jiǎn)稱 RN)頁面進(jìn)行了分別定義。這么做的原因是 SDK 對(duì)這2種別名屬性進(jìn)行了不同的處理,接下來詳細(xì)的介紹一下它們。
Native 頁面的別名屬性
/**
* 對(duì)原生頁面設(shè)置別名
*/
@property (nonatomic, copy, nullable) NSString *pageAlias;
這個(gè)屬性被用于對(duì) Native 頁面設(shè)置別名,比如上面提到的商品詳情頁,如果想查看某一個(gè)商品的詳情頁的數(shù)據(jù),那就可以將 productId 設(shè)置為這個(gè)詳情頁的別名。
SDK 對(duì) Native 頁面的別名的處理方案如下:
- 對(duì)于 數(shù)據(jù)SDK,如果頁面有別名,那么在上報(bào)的事件數(shù)據(jù)中,page 字段的值為:類名 + 別名。(page字段用于標(biāo)識(shí)事件數(shù)據(jù)所歸屬的頁面)
- 對(duì)于 圈選SDK,不論頁面有無別名,在對(duì) view 進(jìn)行圈選時(shí),page 字段的值為:類名。(page字段用于標(biāo)識(shí)圈選配置所作用的頁面)
為何 數(shù)據(jù)SDK 對(duì)于有別名的頁面,在進(jìn)行數(shù)據(jù)收集時(shí),page字段要帶上類名,而不是直接使用別名呢?又為何 圈選SDK 的圈選配置中的 page 字段不帶類名?這么做的主要原因是:同一個(gè)類名的所有頁面共用同一份圈選配置,避免重復(fù)的圈配。具體看下圖:
后臺(tái)在對(duì)這些別名頁面進(jìn)行統(tǒng)計(jì)分析時(shí),首先通過類名獲取到對(duì)應(yīng)的圈選配置,然后再對(duì)每個(gè)別名頁面的數(shù)據(jù)進(jìn)行統(tǒng)計(jì),最后將統(tǒng)計(jì)結(jié)果展示到對(duì)應(yīng)的別名頁面中。
RN 頁面的別名屬性
對(duì)于 RN 頁面的別名屬性,又針對(duì)不同的場(chǎng)景分別定義了別名屬性。
場(chǎng)景1
進(jìn)行 Native 與 RN 的混合開發(fā)時(shí),封裝了一個(gè) RNViewController
來創(chuàng)建不同的實(shí)例去承載一個(gè)或多個(gè) RN 頁面。
表面上看,這個(gè)場(chǎng)景與上面的商品詳情頁的情況很類似,都使用同一個(gè)類名創(chuàng)建不同的實(shí)例來展示不同的頁面,但是這 2 者卻存在一個(gè)很大的不同:上面的場(chǎng)景創(chuàng)建的實(shí)例是展示相同結(jié)構(gòu)的頁面,只是顯示的數(shù)據(jù)不同。而這個(gè)場(chǎng)景創(chuàng)建的實(shí)例是用于展示不同結(jié)構(gòu)的 RN 頁面。如果頁面的結(jié)構(gòu)都不同,就應(yīng)該認(rèn)為是不同的頁面,因此也就不能共用同一套圈選配置了。
因此,針對(duì)這個(gè)場(chǎng)景單獨(dú)定義了一個(gè)頁面別名屬性:
/**
* 用于設(shè)置 RN 頁面別名(通常使用 ModuleName 作為頁面別名)
*/
@property (nonatomic, copy, nullable) NSString *pageAliasInRN;
SDK 對(duì)這個(gè)別名屬性的處理方案是:
- 如果設(shè)置了此別名屬性,數(shù)據(jù)SDK 和 圈選SDK 中的 page 字段的值都為:別名,不再添加類名。
場(chǎng)景2
在 RN 混合開發(fā)項(xiàng)目中,使用同一個(gè) controller 實(shí)例展示多個(gè) RN 頁面。在純 RN 開發(fā)項(xiàng)目中,只有一個(gè)最外層的 controller 實(shí)例來展示所有的 RN 頁面。
這個(gè)場(chǎng)景的主要特點(diǎn)在于,多個(gè) RN 頁面被放到了一個(gè) Native 頁面中展示,這樣就沒法直接區(qū)分里面的每一個(gè) RN 頁面,因此為了能夠進(jìn)一步區(qū)分不同的 RN 頁面,又定義了另一個(gè)頁面別名屬性:
/**
* 用于設(shè)置Native頁面里當(dāng)前展示的 component(通常使用 componentName 作為 RN 頁面別名)
*/
@property (nonatomic, copy, nullable) NSString *componentName;
如果當(dāng)前的 controller 實(shí)例的這個(gè)別名屬性不為空,那么數(shù)據(jù)SDK、圈選SDK中的page字段的值為:componentName。
RN 頁面的數(shù)據(jù)收集
介紹完頁面別名方案后,就可以講一下 SDK 對(duì) RN 頁面的數(shù)據(jù)收集的實(shí)現(xiàn)方案了。對(duì) RN 頁面的數(shù)據(jù)收集主要包括3個(gè)方面:
- 頁面事件(show、hide)
- 點(diǎn)擊事件
- 滑動(dòng)事件
接下來逐個(gè)介紹 SDK 的實(shí)現(xiàn)方案。
頁面事件的收集
頁面事件的收集具體又分為如下2種情況:
- 多個(gè) RN 頁面在一個(gè) controller 實(shí)例中通過
Navigator
跳轉(zhuǎn)(簡(jiǎn)稱:Navigator
跳轉(zhuǎn)) - 2個(gè)展示 RN 頁面的 controller 實(shí)例通過原生
UINavigationController
跳轉(zhuǎn)(簡(jiǎn)稱:原生跳轉(zhuǎn))
上述2種情況中,由于一個(gè) controller 實(shí)例中展示 1個(gè)或多個(gè) RN 頁面,因此不能再使用 controller 的名字來區(qū)分每個(gè) RN 頁面。在 React Native 中,一個(gè) RN 頁面可以看做是一個(gè)組件(Component
),因此這里可以使用 componentName 作為 RN 頁面名。
另外,由于 component 的名字是在 RN 中定義的,SDK 是無法自動(dòng)獲取的,因此需要在 JS 端通過埋點(diǎn)的方式將相應(yīng) component 的名字傳給 SDK。
Navigator 跳轉(zhuǎn)的頁面事件收集
這種情況是指,在一個(gè)原生的 controller 實(shí)例中展示了多個(gè) RN 頁面,而多個(gè) RN 頁面間的跳轉(zhuǎn)是由 RN 中的Navigator
來管理。這種情形主要存在于純 RN 項(xiàng)目中,不過在混合開發(fā)中也會(huì)存在。此情形可以用下圖表示:
結(jié)合上圖,在 controller 實(shí)例1中,進(jìn)行了2個(gè)操作:
- 頁面1通過 Navigator
push
到頁面2; - 頁面2通過 Navigator
pop
到頁面1;
那么,SDK 對(duì)這2個(gè)操作相應(yīng)的處理方案如下:
- 從 component1
push
到 component2 時(shí),JS 端會(huì)將 component2 的名字傳遞給 SDK。此時(shí),SDK 先對(duì) component1 產(chǎn)生一個(gè)hide
事件,然后再對(duì) component2 產(chǎn)生一個(gè)show
事件,最后將 component2 的名字設(shè)置到 controller 的別名屬性componentName
上。 - 從 component2
pop
到 component1 時(shí),JS 端會(huì)將 component1 的名字傳遞給 SDK。此時(shí),SDK 先對(duì) component2 產(chǎn)生一個(gè)hide
事件,然后再對(duì) component1 產(chǎn)生一個(gè)show
事件,最后將 component1 的名字設(shè)置到 controller 的別名屬性componentName
上。
從上面可以看出,在通過Navigator
進(jìn)行 RN 頁面間的跳轉(zhuǎn)時(shí),SDK 內(nèi)部對(duì)每個(gè) RN 頁面都生成了相應(yīng)的 show、hide 事件。除此之外,SDK 還做了另一步:將當(dāng)前顯示的 component 的名字設(shè)置到 controller 的別名屬性 componentName
上。這一步其實(shí)是為了 RN 頁面點(diǎn)擊事件的收集做準(zhǔn)備的,在點(diǎn)擊事件的收集中會(huì)用到。
原生 UINavigationController 跳轉(zhuǎn)的頁面事件收集
這種情形是指,承載 RN 頁面的原生 controller 實(shí)例通過 iOS 原生的 UINavigationController
進(jìn)行跳轉(zhuǎn),這里的跳轉(zhuǎn)有 3 種情況:
- 承載 RN 頁面的 controller 跳轉(zhuǎn)到不含 RN 頁面的 controller
- 不含 RN 頁面的 controller 跳轉(zhuǎn)到承載 RN 頁面的 controller
- 承載 RN 頁面的 controller1 跳轉(zhuǎn)到承載 RN 頁面的 controller2
其實(shí),對(duì)于 SDK 來說,第 3 種情況包含了前 2 種情況,因此這里主要講解第 3 種情況時(shí)的頁面事件收集方案。第 3 種情況可以表示成下圖:
結(jié)合上圖,SDK 的頁面事件收集方案由如下 6 步組成:
- 在 controller1 的
viewWillAppear:
觸發(fā)時(shí),首先檢查 controller1 的別名屬性componentName
是否有值,如果有值,則對(duì)此componentName
產(chǎn)生一個(gè)show
事件。如果 controller 首次創(chuàng)建或者不含 RN 頁面,此時(shí)別名屬性為空。 - JS 端在 RN 組件加載時(shí),將組件的名字傳給 SDK。由于 RN 組件只會(huì)被加載 1 次,因此這步只會(huì)發(fā)生在 controller 被首次創(chuàng)建時(shí)。SDK 在拿到 JS 傳過來的組件名時(shí),先對(duì)它產(chǎn)生一個(gè)
show
事件,再將其設(shè)置到 controller1 的別名屬性componentName
上。 - 在 controller1 的
viewDidDisappear:
觸發(fā)時(shí),如果 controller1 的別名屬性componentName
有值,則對(duì)它產(chǎn)生一個(gè)hide
事件。 - 與第 1 步類似。
- 與第 2 步類似。
- 與第 3 步類似。
當(dāng)從 controller1 push
到 controller2 時(shí),按照先后順序會(huì)執(zhí)行上述的第 3、4、5 步。當(dāng)從 controller2 pop
到 controller1 時(shí),按照先后順序會(huì)執(zhí)行上述的第 6、1 步。而第 2 步則只會(huì)在 controller1 被創(chuàng)建時(shí)執(zhí)行。
點(diǎn)擊事件的收集
在實(shí)現(xiàn)對(duì) RN 頁面的點(diǎn)擊事件的收集時(shí),首先簡(jiǎn)單閱讀了 iOS 端 RN 框架的源碼,發(fā)現(xiàn)里面有一個(gè)類叫 RCTTouchHandler
,它繼承自 UIGestureRecognizer
。RN 主要使用這個(gè)類來完成統(tǒng)一接收和處理用戶的點(diǎn)擊事件,它的具體實(shí)現(xiàn)是重寫了 UIResponder
中事件分發(fā)的四個(gè)方法:touchesBegan、touchesMoved、touchesEnded、touchesCancelled,并將觸摸事件封裝成RCTTouchEvent
分發(fā)到 JS 端。
錯(cuò)誤的方案(踩的一個(gè)坑)
從上面的分析看出,其實(shí) RN 在底層也是通過UIResponder
中提供的 4 個(gè)處理觸摸事件的方法來實(shí)現(xiàn)對(duì)用戶點(diǎn)擊事件的處理,同時(shí)發(fā)現(xiàn)在這 4 個(gè)方法中都調(diào)用了 _updateAndDispatchTouches:
,那么理所當(dāng)然的就想到了直接讓 SDK 去 hook
此方法即可攔截到各個(gè)階段的 Touch 事件(其實(shí)這種做法是錯(cuò)誤的,是我采坑的開始)。
有了這個(gè)思路后,很快完成了代碼編寫,接著就迫不及待的放到 RN 的工程里去測(cè)試,結(jié)果發(fā)現(xiàn)了 2 個(gè)很嚴(yán)重的問題:
- 在 RN 頁面的任意位置的點(diǎn)擊都會(huì)觸發(fā)點(diǎn)擊事件的處理,并執(zhí)行數(shù)據(jù)收集
- 在點(diǎn)擊一個(gè)包含多個(gè)普通子視圖的視圖時(shí),由于點(diǎn)擊到的子視圖的不同,收集到的數(shù)據(jù)也是不同的。但是這幾個(gè)普通子視圖是不具有響應(yīng)處理能力的,不應(yīng)該響應(yīng)觸摸事件。
看來對(duì) RN 點(diǎn)擊事件的收集并沒有之前想象的那么簡(jiǎn)單,需要認(rèn)真閱讀下 RN 框架的源碼了。雖然是 2 個(gè)問題,但其實(shí)在查找原因時(shí),發(fā)現(xiàn)這 2 個(gè)問題產(chǎn)生的原因是同一個(gè)。
仔細(xì)閱讀源碼后,發(fā)現(xiàn) RCTTouchHandler
只被用到了 2 個(gè)類中:RCTRootView
、RCTModalHostView
。RCTModalHostView
應(yīng)該是在做 Modal 視圖時(shí)使用的;而RCTRootView
則是 RN 中最重要的一個(gè)類,功能類似于 UIView
。其實(shí)真正使用RCTTouchHandler
的類是RCTRootContentView
,它的聲明與實(shí)現(xiàn)都在RCTRootview.m
文件中。在RCTRootContentView
的初始化方法中創(chuàng)建了RCTTouchHandler
的對(duì)象,并添加到了自己上。而其它的組件大多都是RCTView
,并未處理用戶的觸摸事件。
因此,到這里就清楚了上述問題出現(xiàn)的原因了:
由于
RCTRootContentView
中添加了RCTTouchHandler
對(duì)象,同時(shí)其它的RCTView
都未攔截處理觸摸事件,因此在 RN 頁面的任何位置被點(diǎn)擊時(shí),用戶的Touch
事件都會(huì)交給RCTRootContentView
去響應(yīng)處理,進(jìn)而進(jìn)入了RCTTouchHandler
的幾個(gè)方法里,同時(shí)也執(zhí)行了 SDK 的數(shù)據(jù)收集的代碼。在 SDK 中,是通過
touch.view
獲取了當(dāng)前點(diǎn)擊的view,其實(shí)也是錯(cuò)誤的做法,因?yàn)楫?dāng)前點(diǎn)擊的 view 并不一定是響應(yīng)此點(diǎn)擊事件的 view,這個(gè)真正響應(yīng)點(diǎn)擊事件的 view 其實(shí)是由 JS 端來查找到的(后面會(huì)詳細(xì)分析)。
綜上,上述方案是不正確的,SDK 不能通過 hook
RCTTouchHandler
類的_updateAndDispatchTouches:
方法來執(zhí)行點(diǎn)擊事件的收集,因?yàn)檫@個(gè)時(shí)機(jī)無法獲取到真正響應(yīng)與處理Touch
事件的view。
正確的方案
那么,SDK 應(yīng)該在哪個(gè)時(shí)機(jī)去執(zhí)行數(shù)據(jù)收集呢?這個(gè)問題涉及到了 RN 框架中的 JS 與 OC 間的通信機(jī)制以及 JS 端的觸摸事件處理機(jī)制。這里不會(huì)單獨(dú)介紹 RN 的通信機(jī)制是如何實(shí)現(xiàn)的,接下來主要以用戶的點(diǎn)擊事件如何在 Native 端傳遞以及如何在 JS 端處理為主線,來講解 SDK 對(duì) RN 頁面的點(diǎn)擊事件的收集方案。
對(duì)于用戶觸摸事件的處理,主要包含了 2 部分:Native 端、JS 端。下面詳細(xì)分析下各自的處理流程。
Native 端觸摸事件的處理過程
根據(jù)上面 錯(cuò)誤的方案 中的分析,在用戶進(jìn)行點(diǎn)擊操作時(shí),首先是 Native 端捕獲到此觸摸事件,然后在主線程中對(duì)觸摸事件進(jìn)行一些處理, 具體的處理過程如下圖:
圖中涉及到 RN 框架中的幾個(gè)主要的類,先簡(jiǎn)單介紹一下它們的功能:
- RCTTouchHandler:前面介紹過,它是
UIGestureRecognizer
的子類,主要用來處理用戶的觸摸事件,其實(shí)只是將觸摸事件的信息封裝成了RCTTouchEvent
對(duì)象,即 JS 端在處理觸摸事件時(shí)所需要的一些信息。 - RCTEventDispatcher:將 Native 產(chǎn)生的 event 緩存起來,并切換至 JSThread 執(zhí)行 event 的分發(fā),其實(shí)就是調(diào)用了
-[RCTBridge enqueueJSCall:args:]
方法去主動(dòng)發(fā)起 JS 的調(diào)用。 - RCTBridge:負(fù)責(zé) JS 與 Native 間的橋接,其內(nèi)部創(chuàng)建并持有一個(gè)
RCTBatchedBridge
對(duì)象,大部分的代碼邏輯都是由這個(gè)對(duì)象來實(shí)現(xiàn)的。 - RCTBatchedBridge:負(fù)責(zé)實(shí)現(xiàn)很多核心的業(yè)務(wù)邏輯,集中在
start
方法中。簡(jiǎn)要來說,主要包含如下幾點(diǎn):- loadSource:從本地或網(wǎng)絡(luò)異步獲取 jsbundle
- initModules:針對(duì)每一個(gè)
RCTBridgeModule
創(chuàng)建對(duì)應(yīng)的RCTModuleData
,其中也包括RCTJSCExecutor
并將其存儲(chǔ)到_moduleDataByID
、_moduleDataByName
中。 - setUpExecutor:初始化 JS 代碼執(zhí)行器,就是創(chuàng)建一個(gè)
RCTJSCExecutor
對(duì)象,并將一些 block 添加到 js 的 context 中,JavaScriptCore 框架會(huì)將其轉(zhuǎn)換成 JS 的 function。這一步在 JS 與 Native 的通信中是非常重要的,而且這一步是在 JSThread 上執(zhí)行的。 - injectJSONConfig:獲取每個(gè)模塊的 conifg ,并設(shè)置到 JS 的全局變量
__fbBatchedBridgeConfig
上。 - executeSourceCode:執(zhí)行 loadSource 中的 js 代碼。
- RCTJSCExecutor:JS 代碼的執(zhí)行器,其內(nèi)部使用了
JavaScriptCore
的 context 作為 JS 的執(zhí)行引擎。在其初始化時(shí),創(chuàng)建了一個(gè) JSThread 來執(zhí)行 JS 方法的調(diào)用。
接著來分析下上面的調(diào)用流程,從圖中可以看出,Native 端對(duì)用戶的點(diǎn)擊事件的處理發(fā)生在 2 個(gè)線程上:Main-Thread、JS-Thread。調(diào)用過程中一些主要的點(diǎn)在圖中使用 tag 標(biāo)出來了,下面逐個(gè)解釋一下:
tag1:將用戶的 Touch 事件的信息封裝成 RCTTouchEvent
對(duì)象,其中包含了 eventName、reactTag、reactTouches、changedIndexes 等信息。
tag2:將當(dāng)前 event 生成一個(gè) eventID 并放到 _events 全局字典中,并向 JS 線程提交事件處理申請(qǐng)。
tag3:將執(zhí)行 flush event 的 block 分發(fā)到 JS 線程上去執(zhí)行。如果當(dāng)前不在 JS 線程,則進(jìn)行切換。
接下來的操作,全部在 JSThread 上執(zhí)行,JSThread 是 Native 端創(chuàng)建的一個(gè)專門用于執(zhí)行 JS 代碼的線程,所有的 JS 代碼只會(huì)在這個(gè)線程上執(zhí)行。
tag4:將 _events 中所有的 event 都分發(fā)給 JSBridge,并指定要調(diào)用的 JS 端的方法:RCTEventEmitter.receiveTouches
,即 RCTEventEmitter 的 receiveTouches 方法。
tag5:將上述 RCTEventEmitter.receiveTouches
通過 .
拆分開,前面的 RCTEventEmitter
是 module 名,后面的 receiveTouches
是 method。
tag6:調(diào)用 JS 執(zhí)行器去執(zhí)行 JS 代碼。這里有一個(gè)重要的點(diǎn):聲明了一個(gè)回調(diào)的 block,用來處理 JS 端想調(diào)用的 Native 端的方法。這個(gè)回調(diào)會(huì)在執(zhí)行完 JS 方法后執(zhí)行。block 代碼如下:
callback:^(id json, NSError *error) {
[weakSelf _processResponse:json error:error];
}];
tag7:這個(gè)環(huán)節(jié)才進(jìn)行真正的 JS 代碼調(diào)用,不過這里并沒有直接調(diào)用前面 Native 所指定的 JS 方法,而是先調(diào)用了 JS 中的 1 個(gè)中轉(zhuǎn)方法 callFunctionReturnFlushedQueue
,這個(gè)方法被定義在 MessageQueue.js
中。
Native 端按照上面的流程執(zhí)行完對(duì)點(diǎn)擊事件的處理后,通過 RCTBatchedBridge
主動(dòng)調(diào)用 JS 方法,將點(diǎn)擊事件交給了 JS 端處理。下面再詳細(xì)分析下 JS 端的處理過程。
JS 端觸摸事件的處理過程
在 JS 端對(duì)點(diǎn)擊事件的處理過程中,包含了對(duì) Native 方法的調(diào)用,同時(shí) Native 端又針對(duì) JS 發(fā)起的調(diào)用,進(jìn)行 Native 方法的調(diào)用。為了使整個(gè)過程連貫起來,這里將 JS 端與 Native 端的處理放到一起分析了。整個(gè)處理過程見下圖:
下面仍然針對(duì)上面所標(biāo)出的點(diǎn)逐個(gè)講解:
tag8:首先會(huì)進(jìn)入 MessageQueue.js
的 callFunctionReturnFlushedQueue
方法,即前面所說的中轉(zhuǎn)方法,這個(gè)中轉(zhuǎn)方法主要做了2步:(1)調(diào)用 __callFunction
方法根據(jù) Native 端傳過來的 module、method、args 來找到 JS 端方法并觸發(fā)。(2)調(diào)用 flushedQueue
將當(dāng)前保存的要調(diào)用 Native 的方法的 queue 返回給 Native 端。
tag9:接著進(jìn)入 ReactNativeEventEmitter.js
的 receiveTouches
方法中,即 Native 端想要調(diào)用的 JS 方法。這里有一點(diǎn)可能有人會(huì)問:前面在 Native 端要調(diào)用的 module 是 RCTEventEmitter
,在 JS 端怎么變成了 ReactNativeEventEmitter
,其實(shí)這是因?yàn)?JS 端在通過 BatchedBridge.registerCallableModule
注冊(cè)對(duì) Native 端暴露的 module 時(shí),就是注冊(cè)的 ReactNativeEventEmitter
,只是名字設(shè)成了 RCTEventEmitter
。看如下代碼就明白了:
// RCTEventEmitter.js
const RCTEventEmitter = {
register(eventEmitter: any) {
BatchedBridge.registerCallableModule(
'RCTEventEmitter',
eventEmitter
);
}
};
// ReactNativeDefaultInjection.js
RCTEventEmitter.register(ReactNativeEventEmitter);
tag10:React.js 也提供了一種類似于 Native 端的觸摸事件處理機(jī)制,用來查找能夠響應(yīng)觸摸事件的組件,并執(zhí)行事件響應(yīng)。JS 端在接收到 Touch 事件后,進(jìn)入觸摸事件處理流程,并在查找到當(dāng)前觸摸事件對(duì)應(yīng)的響應(yīng)者時(shí),觸發(fā) ReactNativeGlobalResponderHandler.js
的 onChange
指定的函數(shù)。
tag11:在 onChange
的函數(shù)中主動(dòng)調(diào)用了 UIManager
的 setJSResponder
方法,對(duì)應(yīng)到 Native 的 RCTUIManager
的 setJSResponder:blockNativeResponder:
方法。即開始了 JS -> Native 的調(diào)用過程,此時(shí)會(huì)直接進(jìn)入 NativeModules.js
的 genMethod
方法來查找到對(duì)應(yīng)的 moduleID、methodID、args 等信息,最終執(zhí)行 MessageQueue.js
的 enqueueNativeCall
方法。
tag12:在 enqueueNativeCall
方法中,將 JS 端要調(diào)用的 Native 端方法的 moduleID、methodID、args 放入全局?jǐn)?shù)組 _queue
中。然后等待 Native 端主動(dòng)來取,JS 端通過 flushedQueue
方法將 _queue
傳過去,接著就進(jìn)入到了 tag6 中所提到的回調(diào) 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 端主動(dòng)來取 _queue
中的值外,還有 1 種方式就是:JS 端主動(dòng)發(fā)起對(duì) Native 方法的調(diào)用,具體是調(diào)用 global.nativeFlushQueueImmediate
方法。不過這種方式有一個(gè)條件:距離 Native 上次主動(dòng)獲取超過 5 ms。相應(yīng)的 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,最終轉(zhuǎn)到了 Native 端,并進(jìn)入 RCTBatchedBridge
的 handleBuffer:batchEnded:
方法中。接著調(diào)用 handleBuffer:
方法從 JS 的 _queue
數(shù)組中解析出 moduleID、methodID、params。
tag15:在 RN 中定義 module 時(shí),可以為 module 指定一個(gè) methodQueue 執(zhí)行它的方法。因此,通過 dispatchBlock:queue:
方法將各個(gè) module 的方法的調(diào)用分發(fā)到各自的 methodQueue 上。這里 RCTUIManager
的 methodQueue 是名字為 com.facebook.react.ShadowQueue 的串行隊(duì)列。
tag16:在切換至指定的 methodQueue 上后,調(diào)用 callNativeModule:method:params:
方法開始 Native 方法的調(diào)用。在這個(gè)方法中,會(huì)根據(jù) moduleID、methodID 從全局?jǐn)?shù)組 _moduleDataByID
中找到 Native 端對(duì)應(yīng)的 moduleData、moduleMethod。
tag17:在 invokeWithBridge:module:arguments:
方法中,將 Native 方法的調(diào)用封裝成 NSInvocation
對(duì)象,并觸發(fā)執(zhí)行。其實(shí)就是調(diào)用在 tag11 中提到的 RCTUIManager
類的 setJSResponder:blockNativeResponder:
。這個(gè)方法的定義是:RCT_EXPORT_METHOD(setJSResponder:(nonnull NSNumber *)reactTag blockNativeResponder:(__unused BOOL)blockNativeResponder)
,它具有 2 個(gè)參數(shù),其中第 2 個(gè)參數(shù)沒有使用到,而第 1 個(gè)參數(shù) reactTag 是非常重要的,使用它從全局字典 _viewRegistry
中能拿到對(duì)應(yīng)的 view,而這個(gè) view 就是真正響應(yīng)用戶點(diǎn)擊事件的視圖。
收集點(diǎn)擊事件的正確時(shí)機(jī)
到這里,已經(jīng)從 Native -> JS -> Native 完整的分析了一遍整個(gè)處理過程,可以清晰得看到 RN 頁面中的用戶點(diǎn)擊事件是如何被傳遞與處理的。其實(shí),對(duì)于 SDK 來說,最重要的是找到一個(gè)正確的時(shí)機(jī)去執(zhí)行數(shù)據(jù)收集,通過上面的分析可以很容易的找到這個(gè)時(shí)機(jī):在 JS 端回調(diào) RCTUIManager
的 setJSResponder:blockNativeResponder:
時(shí)。因?yàn)樵谶@個(gè)時(shí)機(jī),SDK 能夠拿到真正響應(yīng)點(diǎn)擊事件的 view。
最終的實(shí)現(xiàn)方案
那么,SDK 對(duì) RN 的點(diǎn)擊事件的收集方案也已經(jīng)明確了,具體可以分為如下 3 步:
-
hook
RN 框架中的RCTUIManager
類的setJSResponder:blockNativeResponder:
方法。 - 根據(jù)
_viewRegistry
和reactTag
拿到真正響應(yīng)點(diǎn)擊事件的 view 對(duì)象。 - 將此點(diǎn)擊事件的數(shù)據(jù)歸屬到正確的 page 中。如果
componentName
屬性有值,則使用它;否則使用pageAliasInRN
屬性的值。
另外,在第 2 個(gè)調(diào)用流程圖中,提到了 RN 的觸摸事件處理機(jī)制,并沒有詳細(xì)講解,不過可以去看一下這篇文章,里面講解的非常清楚。
滑動(dòng)事件的收集
RN 中有 2 個(gè)很常用的組件:ScrollView、ListView。這 2 個(gè)組件最常用的交互動(dòng)作就是滑動(dòng),因此 SDK 也需要收集它的滑動(dòng)事件。由于 ListView 是基于 ScrollView 封裝實(shí)現(xiàn)的,因此 SDK 只需要對(duì) ScrollView 的滑動(dòng)事件進(jìn)行收集即可。
其實(shí)查看一下 ScrollView.js
的源碼可以看出,在 iOS 平臺(tái)上,ScrollView 組件其實(shí)使用的是 RN 框架中的 RCTScrollView
,相應(yīng)的 JS 代碼為:
else if (Platform.OS === 'ios') {
nativeOnlyProps = {
nativeOnly: {
onMomentumScrollBegin: true,
onMomentumScrollEnd : true,
onScrollBeginDrag: true,
onScrollEndDrag: true,
}
};
RCTScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps);
}
接著,查看一下 RN 框架中的 RCTScrollView
,發(fā)現(xiàn)其內(nèi)部支持一個(gè) RCTCustomScrollView
類的對(duì)象,而這個(gè) RCTCustomScrollView
是 UIScrollView
的子類。因此,可以得出 1 個(gè)結(jié)論:RN 中的 ScrollView
組件其實(shí)對(duì)應(yīng)到 iOS 中的 UIScrollView
。
那么,SDK 要收集 RN 頁面中的滑動(dòng)事件,就相當(dāng)于收集 iOS 原生 UIScrollView
的滑動(dòng)事件,因此只需要 hook
UIScrollViewDelegate
的相關(guān)方法即可。
END
全文主要講述了無埋點(diǎn) SDK 中的 RN 頁面的數(shù)據(jù)收集方案,以及頁面別名方案的引入。其中 RN 點(diǎn)擊事件的收集方案占了較大篇幅,里面涉及到了 Native 與 JS 的通信過程,包括 RN 中多線程的使用。個(gè)人感覺從 RN 框架的源碼中還是能發(fā)掘到不少干貨的,所以建議大家有時(shí)間了可以去閱讀下 RN 的源碼。