本篇文章是講述 iOS 無(wú)埋點(diǎn)數(shù)據(jù)收集 SDK 系列的第二篇摄凡。在第一篇 <iOS無(wú)埋點(diǎn)數(shù)據(jù) SDK 實(shí)踐之路> 中主要介紹了 SDK 整體實(shí)現(xiàn)思路以及基于 viewPath
與 KVC
實(shí)現(xiàn) SDK 的無(wú)埋點(diǎn)技術(shù)。而本篇的重點(diǎn)是介紹一下 SDK 中的頁(yè)面別名方案以及針對(duì) React Native
頁(yè)面的數(shù)據(jù)收集方案纵竖,其中在講解 React Native
點(diǎn)擊事件的收集時(shí)么鹤,詳細(xì)的分析了 Native 端與 JS 端對(duì)點(diǎn)擊事件的詳細(xì)處理過(guò)程,相信你在看了這部分之后也會(huì)對(duì) React Native
中的 JS 與 Native 間的通信機(jī)制有一定的了解了字逗。
頁(yè)面別名方案
為什么引入頁(yè)面別名尘执?
在 iOS 項(xiàng)目開發(fā)中草姻,經(jīng)常會(huì)使用同一個(gè) ViewController
去創(chuàng)建與展示多個(gè)頁(yè)面,其中最有代表性的就是商品詳情頁(yè)肺孵。對(duì)于這種情況來(lái)說(shuō)匀借,由于這些頁(yè)面的類名是同一個(gè),因此在進(jìn)行數(shù)據(jù)收集時(shí)平窘,無(wú)法對(duì)這些頁(yè)面的數(shù)據(jù)進(jìn)行分別統(tǒng)計(jì)與顯示吓肋。那么為了實(shí)現(xiàn)對(duì)這類頁(yè)面進(jìn)行數(shù)據(jù)的單獨(dú)統(tǒng)計(jì)與分析,SDK 引入了頁(yè)面別名方案瑰艘。
頁(yè)面別名方案是什么是鬼?
頁(yè)面別名方案就是指給一個(gè)頁(yè)面設(shè)置另一個(gè)名字,主要用于對(duì)同類頁(yè)面進(jìn)行細(xì)分紫新。對(duì)頁(yè)面設(shè)置了別名之后均蜜,SDK 在數(shù)據(jù)收集時(shí),就會(huì)使用設(shè)置的別名了芒率,這樣就能將頁(yè)面的數(shù)據(jù)區(qū)分開了囤耳。
頁(yè)面別名方案的實(shí)現(xiàn)
頁(yè)面別名方案的具體實(shí)現(xiàn)是給 UIViewController
擴(kuò)展了一個(gè)別名屬性,而對(duì)屬性的存取是通過(guò) Associated Objects
(關(guān)聯(lián)對(duì)象) 來(lái)實(shí)現(xiàn)。
在給 UIViewController
擴(kuò)展別名屬性時(shí)充择,對(duì) Native 頁(yè)面和 React Native(以下簡(jiǎn)稱 RN)頁(yè)面進(jìn)行了分別定義德玫。這么做的原因是 SDK 對(duì)這2種別名屬性進(jìn)行了不同的處理,接下來(lái)詳細(xì)的介紹一下它們宰僧。
Native 頁(yè)面的別名屬性
/**
* 對(duì)原生頁(yè)面設(shè)置別名
*/
@property (nonatomic, copy, nullable) NSString *pageAlias;
這個(gè)屬性被用于對(duì) Native 頁(yè)面設(shè)置別名观挎,比如上面提到的商品詳情頁(yè),如果想查看某一個(gè)商品的詳情頁(yè)的數(shù)據(jù)造成,那就可以將 productId 設(shè)置為這個(gè)詳情頁(yè)的別名雄嚣。
SDK 對(duì) Native 頁(yè)面的別名的處理方案如下:
- 對(duì)于 數(shù)據(jù)SDK,如果頁(yè)面有別名,那么在上報(bào)的事件數(shù)據(jù)中履肃,page 字段的值為:類名 + 別名。(page字段用于標(biāo)識(shí)事件數(shù)據(jù)所歸屬的頁(yè)面)
- 對(duì)于 圈選SDK封锉,不論頁(yè)面有無(wú)別名,在對(duì) view 進(jìn)行圈選時(shí)膘螟,page 字段的值為:類名成福。(page字段用于標(biāo)識(shí)圈選配置所作用的頁(yè)面)
為何 數(shù)據(jù)SDK 對(duì)于有別名的頁(yè)面,在進(jìn)行數(shù)據(jù)收集時(shí)荆残,page字段要帶上類名奴艾,而不是直接使用別名呢?又為何 圈選SDK 的圈選配置中的 page 字段不帶類名内斯?這么做的主要原因是:同一個(gè)類名的所有頁(yè)面共用同一份圈選配置蕴潦,避免重復(fù)的圈配。具體看下圖:
后臺(tái)在對(duì)這些別名頁(yè)面進(jìn)行統(tǒng)計(jì)分析時(shí)俘闯,首先通過(guò)類名獲取到對(duì)應(yīng)的圈選配置潭苞,然后再對(duì)每個(gè)別名頁(yè)面的數(shù)據(jù)進(jìn)行統(tǒng)計(jì),最后將統(tǒng)計(jì)結(jié)果展示到對(duì)應(yīng)的別名頁(yè)面中真朗。
RN 頁(yè)面的別名屬性
對(duì)于 RN 頁(yè)面的別名屬性此疹,又針對(duì)不同的場(chǎng)景分別定義了別名屬性。
場(chǎng)景1
進(jìn)行 Native 與 RN 的混合開發(fā)時(shí),封裝了一個(gè) RNViewController
來(lái)創(chuàng)建不同的實(shí)例去承載一個(gè)或多個(gè) RN 頁(yè)面蝗碎。
表面上看湖笨,這個(gè)場(chǎng)景與上面的商品詳情頁(yè)的情況很類似,都使用同一個(gè)類名創(chuàng)建不同的實(shí)例來(lái)展示不同的頁(yè)面衍菱,但是這 2 者卻存在一個(gè)很大的不同:上面的場(chǎng)景創(chuàng)建的實(shí)例是展示相同結(jié)構(gòu)的頁(yè)面赶么,只是顯示的數(shù)據(jù)不同。而這個(gè)場(chǎng)景創(chuàng)建的實(shí)例是用于展示不同結(jié)構(gòu)的 RN 頁(yè)面脊串。如果頁(yè)面的結(jié)構(gòu)都不同辫呻,就應(yīng)該認(rèn)為是不同的頁(yè)面,因此也就不能共用同一套圈選配置了放闺。
因此,針對(duì)這個(gè)場(chǎng)景單獨(dú)定義了一個(gè)頁(yè)面別名屬性:
/**
* 用于設(shè)置 RN 頁(yè)面別名(通常使用 ModuleName 作為頁(yè)面別名)
*/
@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 頁(yè)面一罩。在純 RN 開發(fā)項(xiàng)目中,只有一個(gè)最外層的 controller 實(shí)例來(lái)展示所有的 RN 頁(yè)面推汽。
這個(gè)場(chǎng)景的主要特點(diǎn)在于,多個(gè) RN 頁(yè)面被放到了一個(gè) Native 頁(yè)面中展示暖夭,這樣就沒(méi)法直接區(qū)分里面的每一個(gè) RN 頁(yè)面迈着,因此為了能夠進(jìn)一步區(qū)分不同的 RN 頁(yè)面咬清,又定義了另一個(gè)頁(yè)面別名屬性:
/**
* 用于設(shè)置Native頁(yè)面里當(dāng)前展示的 component(通常使用 componentName 作為 RN 頁(yè)面別名)
*/
@property (nonatomic, copy, nullable) NSString *componentName;
如果當(dāng)前的 controller 實(shí)例的這個(gè)別名屬性不為空旧烧,那么數(shù)據(jù)SDK、圈選SDK中的page字段的值為:componentName夺谁。
RN 頁(yè)面的數(shù)據(jù)收集
介紹完頁(yè)面別名方案后匾鸥,就可以講一下 SDK 對(duì) RN 頁(yè)面的數(shù)據(jù)收集的實(shí)現(xiàn)方案了。對(duì) RN 頁(yè)面的數(shù)據(jù)收集主要包括3個(gè)方面:
- 頁(yè)面事件(show、hide)
- 點(diǎn)擊事件
- 滑動(dòng)事件
接下來(lái)逐個(gè)介紹 SDK 的實(shí)現(xiàn)方案厚者。
頁(yè)面事件的收集
頁(yè)面事件的收集具體又分為如下2種情況:
- 多個(gè) RN 頁(yè)面在一個(gè) controller 實(shí)例中通過(guò)
Navigator
跳轉(zhuǎn)(簡(jiǎn)稱:Navigator
跳轉(zhuǎn)) - 2個(gè)展示 RN 頁(yè)面的 controller 實(shí)例通過(guò)原生
UINavigationController
跳轉(zhuǎn)(簡(jiǎn)稱:原生跳轉(zhuǎn))
上述2種情況中库菲,由于一個(gè) controller 實(shí)例中展示 1個(gè)或多個(gè) RN 頁(yè)面,因此不能再使用 controller 的名字來(lái)區(qū)分每個(gè) RN 頁(yè)面烫止。在 React Native 中馆蠕,一個(gè) RN 頁(yè)面可以看做是一個(gè)組件(Component
)播赁,因此這里可以使用 componentName 作為 RN 頁(yè)面名。
另外坎背,由于 component 的名字是在 RN 中定義的沼瘫,SDK 是無(wú)法自動(dòng)獲取的耿戚,因此需要在 JS 端通過(guò)埋點(diǎn)的方式將相應(yīng) component 的名字傳給 SDK。
Navigator 跳轉(zhuǎn)的頁(yè)面事件收集
這種情況是指皂股,在一個(gè)原生的 controller 實(shí)例中展示了多個(gè) RN 頁(yè)面呜呐,而多個(gè) RN 頁(yè)面間的跳轉(zhuǎn)是由 RN 中的Navigator
來(lái)管理蘑辑。這種情形主要存在于純 RN 項(xiàng)目中洋魂,不過(guò)在混合開發(fā)中也會(huì)存在。此情形可以用下圖表示:
結(jié)合上圖豁翎,在 controller 實(shí)例1中谨垃,進(jìn)行了2個(gè)操作:
- 頁(yè)面1通過(guò) Navigator
push
到頁(yè)面2胳赌; - 頁(yè)面2通過(guò) Navigator
pop
到頁(yè)面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
上企蹭。
從上面可以看出,在通過(guò)Navigator
進(jìn)行 RN 頁(yè)面間的跳轉(zhuǎn)時(shí)系馆,SDK 內(nèi)部對(duì)每個(gè) RN 頁(yè)面都生成了相應(yīng)的 show闽寡、hide 事件下隧。除此之外淆院,SDK 還做了另一步:將當(dāng)前顯示的 component 的名字設(shè)置到 controller 的別名屬性 componentName
上。這一步其實(shí)是為了 RN 頁(yè)面點(diǎn)擊事件的收集做準(zhǔn)備的拷淘,在點(diǎn)擊事件的收集中會(huì)用到。
原生 UINavigationController 跳轉(zhuǎn)的頁(yè)面事件收集
這種情形是指恃轩,承載 RN 頁(yè)面的原生 controller 實(shí)例通過(guò) iOS 原生的 UINavigationController
進(jìn)行跳轉(zhuǎn)松忍,這里的跳轉(zhuǎn)有 3 種情況:
- 承載 RN 頁(yè)面的 controller 跳轉(zhuǎn)到不含 RN 頁(yè)面的 controller
- 不含 RN 頁(yè)面的 controller 跳轉(zhuǎn)到承載 RN 頁(yè)面的 controller
- 承載 RN 頁(yè)面的 controller1 跳轉(zhuǎn)到承載 RN 頁(yè)面的 controller2
其實(shí)宏所,對(duì)于 SDK 來(lái)說(shuō),第 3 種情況包含了前 2 種情況盖腕,因此這里主要講解第 3 種情況時(shí)的頁(yè)面事件收集方案溃列。第 3 種情況可以表示成下圖:
結(jié)合上圖,SDK 的頁(yè)面事件收集方案由如下 6 步組成:
- 在 controller1 的
viewWillAppear:
觸發(fā)時(shí)雅任,首先檢查 controller1 的別名屬性componentName
是否有值,如果有值锌半,則對(duì)此componentName
產(chǎn)生一個(gè)show
事件殉摔。如果 controller 首次創(chuàng)建或者不含 RN 頁(yè)面,此時(shí)別名屬性為空碗硬。 - JS 端在 RN 組件加載時(shí),將組件的名字傳給 SDK特笋。由于 RN 組件只會(huì)被加載 1 次猎物,因此這步只會(huì)發(fā)生在 controller 被首次創(chuàng)建時(shí)。SDK 在拿到 JS 傳過(guò)來(lái)的組件名時(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 頁(yè)面的點(diǎn)擊事件的收集時(shí),首先簡(jiǎn)單閱讀了 iOS 端 RN 框架的源碼,發(fā)現(xiàn)里面有一個(gè)類叫 RCTTouchHandler
呈驶,它繼承自 UIGestureRecognizer
蚌吸。RN 主要使用這個(gè)類來(lái)完成統(tǒng)一接收和處理用戶的點(diǎn)擊事件佩微,它的具體實(shí)現(xiàn)是重寫了 UIResponder
中事件分發(fā)的四個(gè)方法:touchesBegan、touchesMoved掌猛、touchesEnded、touchesCancelled,并將觸摸事件封裝成RCTTouchEvent
分發(fā)到 JS 端接校。
錯(cuò)誤的方案(踩的一個(gè)坑)
從上面的分析看出诽凌,其實(shí) RN 在底層也是通過(guò)UIResponder
中提供的 4 個(gè)處理觸摸事件的方法來(lái)實(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)重的問(wèn)題:
- 在 RN 頁(yè)面的任意位置的點(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)觸摸事件。
看來(lái)對(duì) RN 點(diǎn)擊事件的收集并沒(méi)有之前想象的那么簡(jiǎn)單,需要認(rèn)真閱讀下 RN 框架的源碼了。雖然是 2 個(gè)問(wèn)題孽拷,但其實(shí)在查找原因時(shí)秋茫,發(fā)現(xiàn)這 2 個(gè)問(wèn)題產(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
溪胶,并未處理用戶的觸摸事件搂擦。
因此,到這里就清楚了上述問(wèn)題出現(xiàn)的原因了:
由于
RCTRootContentView
中添加了RCTTouchHandler
對(duì)象载荔,同時(shí)其它的RCTView
都未攔截處理觸摸事件盾饮,因此在 RN 頁(yè)面的任何位置被點(diǎn)擊時(shí),用戶的Touch
事件都會(huì)交給RCTRootContentView
去響應(yīng)處理懒熙,進(jìn)而進(jìn)入了RCTTouchHandler
的幾個(gè)方法里丘损,同時(shí)也執(zhí)行了 SDK 的數(shù)據(jù)收集的代碼。在 SDK 中工扎,是通過(guò)
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 端來(lái)查找到的(后面會(huì)詳細(xì)分析)呈础。
綜上,上述方案是不正確的橱健,SDK 不能通過(guò) hook
RCTTouchHandler
類的_updateAndDispatchTouches:
方法來(lái)執(zhí)行點(diǎn)擊事件的收集而钞,因?yàn)檫@個(gè)時(shí)機(jī)無(wú)法獲取到真正響應(yīng)與處理Touch
事件的view。
正確的方案
那么拘荡,SDK 應(yīng)該在哪個(gè)時(shí)機(jī)去執(zhí)行數(shù)據(jù)收集呢臼节?這個(gè)問(wèn)題涉及到了 RN 框架中的 JS 與 OC 間的通信機(jī)制以及 JS 端的觸摸事件處理機(jī)制。這里不會(huì)單獨(dú)介紹 RN 的通信機(jī)制是如何實(shí)現(xiàn)的珊皿,接下來(lái)主要以用戶的點(diǎn)擊事件如何在 Native 端傳遞以及如何在 JS 端處理為主線网缝,來(lái)講解 SDK 對(duì) RN 頁(yè)面的點(diǎn)擊事件的收集方案。
對(duì)于用戶觸摸事件的處理蟋定,主要包含了 2 部分:Native 端粉臊、JS 端。下面詳細(xì)分析下各自的處理流程驶兜。
Native 端觸摸事件的處理過(guò)程
根據(jù)上面 錯(cuò)誤的方案 中的分析扼仲,在用戶進(jìn)行點(diǎn)擊操作時(shí),首先是 Native 端捕獲到此觸摸事件抄淑,然后在主線程中對(duì)觸摸事件進(jìn)行一些處理屠凶, 具體的處理過(guò)程如下圖:
圖中涉及到 RN 框架中的幾個(gè)主要的類,先簡(jiǎn)單介紹一下它們的功能:
- RCTTouchHandler:前面介紹過(guò)蝇狼,它是
UIGestureRecognizer
的子類阅畴,主要用來(lái)處理用戶的觸摸事件,其實(shí)只是將觸摸事件的信息封裝成了RCTTouchEvent
對(duì)象迅耘,即 JS 端在處理觸摸事件時(shí)所需要的一些信息贱枣。 - RCTEventDispatcher:將 Native 產(chǎn)生的 event 緩存起來(lái)监署,并切換至 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ì)象來(lái)實(shí)現(xiàn)的春塌。 - RCTBatchedBridge:負(fù)責(zé)實(shí)現(xiàn)很多核心的業(yè)務(wù)邏輯晓避,集中在
start
方法中。簡(jiǎn)要來(lái)說(shuō)只壳,主要包含如下幾點(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 來(lái)執(zhí)行 JS 方法的調(diào)用余佛。
接著來(lái)分析下上面的調(diào)用流程柠新,從圖中可以看出窍荧,Native 端對(duì)用戶的點(diǎn)擊事件的處理發(fā)生在 2 個(gè)線程上:Main-Thread、JS-Thread恨憎。調(diào)用過(guò)程中一些主要的點(diǎn)在圖中使用 tag 標(biāo)出來(lái)了蕊退,下面逐個(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)行切換橘荠。
接下來(lái)的操作,全部在 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
通過(guò) .
拆分開优训,前面的 RCTEventEmitter
是 module 名朵你,后面的 receiveTouches
是 method。
tag6:調(diào)用 JS 執(zhí)行器去執(zhí)行 JS 代碼揣非。這里有一個(gè)重要的點(diǎn):聲明了一個(gè)回調(diào)的 block撬呢,用來(lái)處理 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)用魂拦,不過(guò)這里并沒(méi)有直接調(diào)用前面 Native 所指定的 JS 方法,而是先調(diào)用了 JS 中的 1 個(gè)中轉(zhuǎn)方法 callFunctionReturnFlushedQueue
搁嗓,這個(gè)方法被定義在 MessageQueue.js
中芯勘。
Native 端按照上面的流程執(zhí)行完對(duì)點(diǎn)擊事件的處理后,通過(guò) RCTBatchedBridge
主動(dòng)調(diào)用 JS 方法腺逛,將點(diǎn)擊事件交給了 JS 端處理荷愕。下面再詳細(xì)分析下 JS 端的處理過(guò)程。
JS 端觸摸事件的處理過(guò)程
在 JS 端對(duì)點(diǎn)擊事件的處理過(guò)程中棍矛,包含了對(duì) Native 方法的調(diào)用安疗,同時(shí) Native 端又針對(duì) JS 發(fā)起的調(diào)用,進(jìn)行 Native 方法的調(diào)用够委。為了使整個(gè)過(guò)程連貫起來(lái)荐类,這里將 JS 端與 Native 端的處理放到一起分析了。整個(gè)處理過(guò)程見(jiàn)下圖:
下面仍然針對(duì)上面所標(biāo)出的點(diǎn)逐個(gè)講解:
tag8:首先會(huì)進(jìn)入 MessageQueue.js
的 callFunctionReturnFlushedQueue
方法茁帽,即前面所說(shuō)的中轉(zhuǎn)方法玉罐,這個(gè)中轉(zhuǎn)方法主要做了2步:(1)調(diào)用 __callFunction
方法根據(jù) Native 端傳過(guò)來(lái)的 module、method潘拨、args 來(lái)找到 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ì)問(wèn):前面在 Native 端要調(diào)用的 module 是 RCTEventEmitter
,在 JS 端怎么變成了 ReactNativeEventEmitter
,其實(shí)這是因?yàn)?JS 端在通過(guò) 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ī)制濒生,用來(lái)查找能夠響應(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)用過(guò)程晒骇,此時(shí)會(huì)直接進(jìn)入 NativeModules.js
的 genMethod
方法來(lái)查找到對(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)來(lái)取剥啤,JS 端通過(guò) flushedQueue
方法將 _queue
傳過(guò)去,接著就進(jìn)入到了 tag6 中所提到的回調(diào) block 中不脯。這里將主要的 JS 代碼也貼出來(lái)府怯,便于理解:
// 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)來(lái)取 _queue
中的值外,還有 1 種方式就是:JS 端主動(dòng)發(fā)起對(duì) Native 方法的調(diào)用防楷,具體是調(diào)用 global.nativeFlushQueueImmediate
方法牺丙。不過(guò)這種方式有一個(gè)條件:距離 Native 上次主動(dòng)獲取超過(guò) 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:通過(guò)圖中的方式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í)行它的方法。因此,通過(guò) 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ù)沒(méi)有使用到,而第 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è)處理過(guò)程岔乔,可以清晰得看到 RN 頁(yè)面中的用戶點(diǎn)擊事件是如何被傳遞與處理的。其實(shí)滚躯,對(duì)于 SDK 來(lái)說(shuō)雏门,最重要的是找到一個(gè)正確的時(shí)機(jī)去執(zhí)行數(shù)據(jù)收集,通過(guò)上面的分析可以很容易的找到這個(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ī)制患蹂,并沒(méi)有詳細(xì)講解,不過(guò)可以去看一下這篇文章,里面講解的非常清楚传于。
滑動(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 頁(yè)面中的滑動(dòng)事件,就相當(dāng)于收集 iOS 原生 UIScrollView
的滑動(dòng)事件鞍帝,因此只需要 hook
UIScrollViewDelegate
的相關(guān)方法即可诫睬。
END
全文主要講述了無(wú)埋點(diǎn) SDK 中的 RN 頁(yè)面的數(shù)據(jù)收集方案,以及頁(yè)面別名方案的引入帕涌。其中 RN 點(diǎn)擊事件的收集方案占了較大篇幅摄凡,里面涉及到了 Native 與 JS 的通信過(guò)程,包括 RN 中多線程的使用蚓曼。個(gè)人感覺(jué)從 RN 框架的源碼中還是能發(fā)掘到不少干貨的亲澡,所以建議大家有時(shí)間了可以去閱讀下 RN 的源碼。