嗶哩嗶哩Android客戶端基于依賴注入實(shí)現(xiàn)復(fù)雜業(yè)務(wù)架構(gòu)探索
本期作者

01 背景介紹
B站作為一個視頻網(wǎng)站,視頻播放頁作為用戶的核心消費(fèi)場景,其重要程度可想而知。目前APP客戶端的主要播放頁場景主要有UP主稿件播放頁,Stroy模式播放頁,直播播放頁跟番劇影視播放頁。每一個都是大量業(yè)務(wù)的匯總點(diǎn)作為用戶核心消費(fèi)場景,需要在承接各種業(yè)務(wù)到播放頁的轉(zhuǎn)化,還要負(fù)責(zé)承接各業(yè)務(wù)在播放頁的功能展示??梢哉f播放頁代碼復(fù)雜度屬于客戶端最高的代碼之一,這不僅僅是因?yàn)椴シ彭摫旧淼墓δ軓?fù)雜,還因?yàn)椴シ彭撏枰诤洗罅客獠繕I(yè)務(wù)功能。復(fù)雜的功能自然會產(chǎn)比較高的代碼復(fù)雜度,而高代碼復(fù)雜度又往往意味著高代碼維護(hù)成本。
另一方面,在這個降本增效的大時代之下,公司層面上決定把UP主稿件播放頁與番劇影視播放頁進(jìn)行頁面合并,這樣既可以確保用戶播放體驗(yàn)盡可能一致的,又避免了相同功能又不需要重復(fù)開發(fā)從而降低開發(fā)成本,并且后續(xù)課堂業(yè)務(wù)也會融合進(jìn)來。然而這也意味著在這個新的融合播放頁中會承載直接三個播放頁的代碼復(fù)雜度。為此我們需要探索出一種新的架構(gòu)模式來滿足當(dāng)前的業(yè)務(wù)訴求。
02 明確需求
既然已經(jīng)明確了需求的大方向是搭建一套能兼容多個業(yè)務(wù)的架構(gòu),那首先要做的自然是需求分析。
由于本次需求主要是基于UP主稿件播放頁(簡稱UGC播放頁)與番劇影視播放頁(簡稱OGV播放頁)融合產(chǎn)生(簡稱融合播放頁),我們先對兩個播放頁的業(yè)務(wù)進(jìn)行簡單拆分:

上圖是融合部分業(yè)務(wù)的拆分圖,實(shí)際的業(yè)務(wù)遠(yuǎn)比上圖復(fù)雜。如上圖所示,我們可以將融合播放頁的業(yè)務(wù)簡單的拆分成6個部分,它們分別是:
視頻播放業(yè)務(wù)的通用,視頻播放業(yè)務(wù)UGC特化,視頻播放業(yè)務(wù)OGV特化
播放頁頁面交互通用,播放頁頁面交互UGC特化,播放頁頁面交互OGV特化
每一個部分又有一堆具體的業(yè)務(wù)組成,其中特化部分又存在一定的關(guān)聯(lián)性,比如UGC特化的投屏業(yè)務(wù),跟OGV特化的投屏業(yè)務(wù)都會依賴同一個投屏SDK業(yè)務(wù),并且絕大部分業(yè)務(wù)又都是基于接口請求的返回值+用戶行為驅(qū)動的。
那么最終如果用代碼去反應(yīng)這些業(yè)務(wù)必然會形成一個龐大的網(wǎng)狀結(jié)構(gòu),如何相對優(yōu)雅的設(shè)計(jì)這個網(wǎng)狀結(jié)構(gòu)讓其在開發(fā)與迭代的過程中盡可能穩(wěn)固成為了新架構(gòu)的首要目標(biāo)。
03 需求分析
由于業(yè)務(wù)的復(fù)雜性必然會形成網(wǎng)狀結(jié)構(gòu),那么如何優(yōu)雅的實(shí)現(xiàn)這個網(wǎng)狀結(jié)構(gòu)呢?網(wǎng)的本質(zhì)是點(diǎn)跟線,點(diǎn)可以理解為一個具體的業(yè)務(wù)單元,而線則是依賴于被依賴的關(guān)系。那如何提升網(wǎng)的迭代維護(hù)質(zhì)量可以簡單的拆分成三個步驟:
1.如何優(yōu)雅的新建業(yè)務(wù)單元
2.如何優(yōu)雅的建立依賴關(guān)系
3.如何盡可能縮小對整張大網(wǎng)的維護(hù)成本
首先是新建業(yè)務(wù)單元,考慮到一個業(yè)務(wù)單元能正確運(yùn)行,他所依賴的其他業(yè)務(wù)單元必須先被創(chuàng)建。那么為了避免一個業(yè)務(wù)單元A依賴另一個沒有被創(chuàng)建的業(yè)務(wù)B,最簡單直接的辦法就是把B放到A的構(gòu)造函數(shù)里。那有什么辦法能夠優(yōu)雅的基于構(gòu)造函數(shù)的依賴關(guān)系按照正確的時許創(chuàng)建對應(yīng)的對象同時還能夠確保沒有環(huán)形引用呢?答案顯而易見,是依賴注入框架。
接下來是如何解決業(yè)務(wù)復(fù)雜的依賴關(guān)系,這里可以先看一個簡單的例子:

業(yè)務(wù)訴求是當(dāng)屏幕狀態(tài)變化成半屏并且視頻在播放的時候我們需要展示UI否則則隱藏UI,我們先用常見的事件監(jiān)聽的方式來實(shí)現(xiàn)一下偽代碼:
同樣的業(yè)務(wù),是不是看起來代碼少了很多?distinctUntilChanged確保了只要屏幕狀態(tài)跟播放狀態(tài)的組合結(jié)果沒有改變,哪怕事件發(fā)生了變化也不會把變化傳遞給下游,從而實(shí)現(xiàn)了去重的效果。collectLatest則是解決了事件頻繁變化產(chǎn)生的背壓問題,當(dāng)事件頻繁變化時,由于collectLatest每次都會產(chǎn)生一個新的CoroutineScope,并且如果有上一次還未完成的CoroutineScope則會自動取消,從而實(shí)現(xiàn)性能上的優(yōu)化。
這里我相信有人會有一些疑問,比如@BizScope是什么?Kotlin協(xié)程(https://kotlinlang.org/docs/coroutines-overview.html)的CoroutineScope又代表什么?
要回答這個問題,首先我們要先確定一個概念:什么是Scope?這里我們引用Koltin中關(guān)于自定義CoroutineScope的描述:CoroutineScope 應(yīng)聲明為具有明確定義的生命周期的實(shí)體的屬性,負(fù)責(zé)啟動子協(xié)程。而在本文中Scope代表一個有明確生命周期的業(yè)務(wù)單元集合,該集合下的所有的業(yè)務(wù)單元都應(yīng)該擁有同一個對應(yīng)的Scope,隨著該Scope創(chuàng)建而創(chuàng)建并啟動,隨著Scope的結(jié)束自動終止。
Scope存在父子關(guān)系,當(dāng)父Scope結(jié)束時,對應(yīng)的子Scope必須也結(jié)束,反之則不需要。同時子Scope可以引用父Scope的業(yè)務(wù)單元,因?yàn)楦窼cope的生命周期一定大于等于子Scope,反之則不行。
基于上述Scope的定義融合播放頁來舉例子進(jìn)行Scope的拆分:

上圖是融合播放頁的一個簡流程圖,主要流程是進(jìn)入頁面后通過傳入的路由參數(shù)請求服務(wù)端接口進(jìn)而返回判斷當(dāng)前業(yè)務(wù)是UGC業(yè)務(wù)還是OGV業(yè)務(wù),之后啟動對應(yīng)業(yè)務(wù)的業(yè)務(wù)單元,并且有對應(yīng)業(yè)務(wù)的選集邏輯驅(qū)動相應(yīng)的視頻播放業(yè)務(wù)同時播放視頻。
UGC視頻一個頁面存在多P每1P對應(yīng)一個單獨(dú)的獨(dú)立視頻物料。與此相對應(yīng),OGV一個季度合集存在多個EP視頻,每一個EP視頻也對應(yīng)一個獨(dú)立的視頻物料。而這里我們結(jié)合圖2.1里提到的6個業(yè)務(wù)分組,可以對這6個分組進(jìn)行一個工作范圍的劃分:

從上圖可以看出,融合播放頁目前可以分成三層一共有5個Scope:
第一層是頁面Scope,對應(yīng)的生命周期是融合播放頁本身,打開頁面啟動,關(guān)閉頁面退出。該Scope生命周期中間運(yùn)行的是貫穿整個頁面生命周期的業(yè)務(wù):比如埋點(diǎn)上報,頁面接口請求浮層管理等業(yè)務(wù)單元。
第二層是業(yè)務(wù)Scope,是業(yè)務(wù)Scope的子Scope,又分成UGC業(yè)務(wù)Scope跟OGV業(yè)務(wù)Scope,兩者不會并存,且同一時間同一個頁面Scope只會存在一個業(yè)務(wù)Scope。當(dāng)頁面接口返回時根據(jù)服務(wù)端的數(shù)據(jù)返回來決定啟動哪個具體的業(yè)務(wù)Scope,如果當(dāng)前已經(jīng)存在業(yè)務(wù)Scope則銷毀當(dāng)前Scope跟其子Scope。該Scope生命周期中間運(yùn)行的是UGC/OGV業(yè)務(wù)獨(dú)有的業(yè)務(wù)單元,如UGC多P邏輯,UGC投屏評論等業(yè)務(wù)單元,OGV的多季,OGV分節(jié)列表。
第三層是視頻播放Scope,分成UGC視頻播放Scope跟OGV視頻播放Scope,分別是UGC跟OGV業(yè)務(wù)Scope的子Scope。由于兩者的父Scope不會并存,所以這兩者也不會并存,并且同一個業(yè)務(wù)Scope也只會存在一個視頻播放Scope。每次觸發(fā)新的視頻播放的時候會啟動對應(yīng)視頻的業(yè)務(wù)Scope,如果當(dāng)前已經(jīng)存在視頻播放Scope則銷毀當(dāng)前Scope。該Scope生命周期中間運(yùn)行的是UGC/OGV視頻播放業(yè)務(wù)獨(dú)有的業(yè)務(wù)單元,UGC的充電試看,OGV的大會員鑒權(quán)等視頻單元。
上述邏輯是由業(yè)務(wù)本身的作用域所定義,直接映射具體的真實(shí)業(yè)務(wù)。事實(shí)上,大部分業(yè)務(wù)如果本身定義清晰并且合理,在這個模式下也是可以很容易的映射到具體的Scope跟具體的業(yè)務(wù)單元的。這種拆分是很有必要的,在一個頁面,如果我們不新建業(yè)務(wù)Scope那通常只會存在一個Scope即頁面生命周期Scope,考慮到所有業(yè)務(wù)在事實(shí)上都有生命周期,如果不按業(yè)務(wù)范圍進(jìn)行拆分,通常就只能綁定運(yùn)行在頁面生命周期Scope,即LifecycleScope下。而很多業(yè)務(wù)在事實(shí)上的生命周期是遠(yuǎn)小于頁面生命周期的,子啊這個情況下,要么開發(fā)需要手動維護(hù)業(yè)務(wù)單元內(nèi)部的Scope,要么是疏于考慮從而引入業(yè)務(wù)Bug。并且,在這種情況下還很容易因?yàn)閿?shù)據(jù)的更新的時序問題導(dǎo)致業(yè)務(wù)依賴錯誤的數(shù)據(jù)。
我們還是播放業(yè)務(wù)來舉例子,比如當(dāng)前OGV有一個業(yè)務(wù)單元需要視頻播放開始后獲取對應(yīng)視頻當(dāng)前季的相關(guān)信息,而在切集同時切季的時候,由于視頻信息接口已經(jīng)返回,于是視頻正常起播,此時如果季信息的接口還沒有回來,則會導(dǎo)致該業(yè)務(wù)單元從季業(yè)務(wù)里獲取的信息是上一季的,從而引發(fā)業(yè)務(wù)錯誤。而在建立對對應(yīng)的業(yè)務(wù)Scope 并完成Scope間依賴后,由于集業(yè)務(wù)Scope是季業(yè)務(wù)Scope的子Scope,所以集業(yè)務(wù)Scope下的所有業(yè)務(wù)單元在啟動時,依賴注入框架會確保當(dāng)前季的信息已經(jīng)是正確的。依托于依賴注入框架,我們甚至可以把當(dāng)前季信息這種集維度廣泛使用的數(shù)據(jù)作為集Scope的初始化參數(shù),從而讓集業(yè)務(wù)單元不需要關(guān)心季變動這種事,進(jìn)一步簡化業(yè)務(wù)代碼。
那么接下來只要我們將之前提到的業(yè)務(wù)單元網(wǎng)按照Scope進(jìn)行拆分,就可以獲得一系列小的Scope跟對應(yīng)的業(yè)務(wù)單元。每個業(yè)務(wù)的負(fù)責(zé)人只需要關(guān)心自己業(yè)務(wù)相關(guān)的Scope而不需要理解全部業(yè)務(wù)單元。同時因?yàn)镾cope間的隔離關(guān)系,通過不同的Scope按照業(yè)務(wù)進(jìn)行模塊的拆分從而實(shí)現(xiàn)代碼隔離,從而降低了建立錯誤引用關(guān)系的成本跟代碼維護(hù)成本。參考融合播放頁當(dāng)前場景,可以將融合播放頁的代碼分成4個模塊,頁面Scope,UGCScopeModule,OGVScopeModule,融合Modle。融合模塊引用OGV跟UGC模塊,OGV跟UGC模塊彼此之間引用隔離,但是都引用頁面模塊。通過將代碼分成不同的業(yè)務(wù)模塊實(shí)現(xiàn)了不同業(yè)務(wù)間代碼的的隔離,從而避免建立錯誤的業(yè)務(wù)單元依賴關(guān)系。
由此,我們通過分析,進(jìn)一步明確了需求,我們需要一個能在一個Android頁面運(yùn)行,并且支持依賴注入與使用Kotlin協(xié)程作為依賴關(guān)系綁定的且能方便的進(jìn)行多層級父子關(guān)系Scope創(chuàng)建的架構(gòu)設(shè)計(jì)。
04 具體方案
首先是依賴注入框架的選擇,考慮到安卓本身的生態(tài)體系結(jié)合項(xiàng)目中歷史的代碼引用,我們選擇了Dagger作為依賴注入的實(shí)現(xiàn)。有人會問為什么不用koin或者h(yuǎn)ilt(Dagger官方的Android定制版),不用前者是因?yàn)楫?dāng)前B站Android端已經(jīng)引入了,積累了一定的使用經(jīng)驗(yàn)并且基于編譯時創(chuàng)建依賴關(guān)系可以更好的在編譯期檢查是否有不合理依賴,不像koin需要運(yùn)行時才能發(fā)現(xiàn)錯誤。至于不用hilt的原因,參考圖4.1,這個是hilt官網(wǎng)的scope示意圖,hilt的scope已經(jīng)有一套獨(dú)立的父子關(guān)系,對應(yīng)的映射是Android的系統(tǒng)生命周期,而我們的訴求是在一個頁面(Activity或者Fragment)中基于業(yè)務(wù)自定義Scope,當(dāng)前hilt并不能很好的滿足我們的訴求,最終衡量之下我們還是采用了Dagger本身。

之前我們提到要支持Dagger(https://dagger.dev/)依賴注入,跟Kotlin協(xié)程,巧合的是這兩者恰好都有Scope的概念并且都支持父子Scope聯(lián)動,甚至定義上都很類似,于是事情似乎變得有意思起來。(接下來建議先了解Dagger跟Kotlin協(xié)程的使用方法再繼續(xù)閱讀)
還是用融合播放頁的例子,
在Dagger層面,我們定義了三個Dagger Scope注解:PageScope, BizScope, OGVBizScope, EpScope,
還需要定義5個Dagger component對象:PageComponent(PageScope), UGCBizComponent(BizScope), OGVBizComponent(BizScope), UGCEpComponent(EpScope), OGVComponent(EpScope),同時按照圖3.3建立起父子關(guān)系,這里的每一個component對應(yīng)的是圖3.3提到的具體業(yè)務(wù)作用域。在每個Dagger component里我們需要聲明一個對應(yīng)的anchor對象,這里的anchor只是一個單純的業(yè)務(wù)矛點(diǎn)用來聲明在該Scope下需要啟動的業(yè)務(wù)單元, 參考之前BizService的例子,業(yè)務(wù)單員本身創(chuàng)建就會基于依賴的CoroutineScope自動運(yùn)行,所以只要依賴注入框架創(chuàng)建了矛點(diǎn)對象,那么矛點(diǎn)對象里聲明的業(yè)務(wù)單元也會相應(yīng)的被創(chuàng)建并且自動化運(yùn)行。
接著我們再定義三個CoroutineScope 別名(Qualifier)注解:PageCoroutineScope,BizCoroutineScope, EpCoroutineScope,用來給Dagger識別對用的CoroutineScope實(shí)現(xiàn)依賴注入。
接下來首先我們要提到Kotlin協(xié)程Flow里一個重要的操作符collectLatest ,首先這是一個suspend方法,所以它必須要在一個CoroutineScope下運(yùn)行,并且每當(dāng)收到一個新的結(jié)果時,他會創(chuàng)建一個當(dāng)前CoroutineScope下的子Scope 并且執(zhí)行傳入的代碼塊,同時取消上一個代碼塊里的子Scope。利用這個特點(diǎn),我們可以將 圖3.2 的流程圖利用代碼實(shí)現(xiàn)成:

進(jìn)入頁面初始化時,首先通過依賴注入創(chuàng)建PageScopeAnchor下的業(yè)務(wù)單元,注入當(dāng)前頁面Lifecycle的CoroutineScope并運(yùn)行,而這些業(yè)務(wù)單元因?yàn)椴捎肒otlin協(xié)程的API設(shè)計(jì)會自動在頁面銷毀時停止并釋放,從而避免內(nèi)存泄露。PageScopeAnchor下有兩個比較重要的業(yè)務(wù)單元,一個是頁面接口業(yè)務(wù)單元,一個是業(yè)務(wù)Scope驅(qū)動業(yè)務(wù)單元。前面說到頁面初始化的時候PageScopeAnchor下的業(yè)務(wù)單元都會自動創(chuàng)建并在Lifecycle的CoroutineScope下運(yùn)行,此時頁面接口業(yè)務(wù)會先通過傳入當(dāng)前頁面的入?yún)?,請求服?wù)端數(shù)據(jù)。而業(yè)務(wù)Scope驅(qū)動業(yè)務(wù)會訂閱頁面接口服務(wù)的返回值,從而開啟對應(yīng)業(yè)務(wù)(UGC/OGV)的BizScope。
此時會通過依賴注入創(chuàng)建BizScopeAnchor下的業(yè)務(wù)單元,注入通過CollectLatest方法產(chǎn)生的CoroutineScope并運(yùn)行。每當(dāng)PageScope下的閱頁面接口服務(wù)有新的返回值,前一個CoroutineScope會被協(xié)程框架取消,從而停止上一個BizScope下的所有業(yè)務(wù)單元,接著會創(chuàng)建一新的BizScope。
接下來是一段示意代碼,用來示范如何通過Kotlin協(xié)程驅(qū)動Dagger創(chuàng)建并運(yùn)行業(yè)務(wù)單元:
至此,融合播放頁的例子到此結(jié)束,上述方案中,可以繼續(xù)疊加Dagger的各種module 或者provider從而進(jìn)一步簡化代碼,具體方法請參考Dagger官網(wǎng),本文不再展開。
05?總結(jié)
事實(shí)上,單一頁面的復(fù)雜業(yè)務(wù)的拆分目前Android客戶端并沒有現(xiàn)成的方案,本文也是基于業(yè)務(wù)開發(fā)過程中的探索給出了一種可能性。針對Android客戶端復(fù)雜業(yè)務(wù)頁面的開發(fā),本文結(jié)合當(dāng)前Android Kotlin的協(xié)程與常見的依賴注入框架Dagger以B站安卓粉版融合播放頁為例子,介紹了一種可以將復(fù)雜業(yè)務(wù)按照業(yè)務(wù)自己真實(shí)的生命周期與驅(qū)動關(guān)系拆解成多個相對內(nèi)聚的業(yè)務(wù)單元模塊,并通過依賴注入降低代碼量從而提高開發(fā)效率的架構(gòu)探索與業(yè)務(wù)落地。在上述例子中,我們采用的方案可以簡單總結(jié)成三步:
? 1. 將復(fù)雜而龐大的業(yè)務(wù)網(wǎng)按照不同的生命周期拆成多個獨(dú)立的業(yè)務(wù)作用域并建立起對應(yīng)的依賴注入Scope跟協(xié)程Scope。
? 2. 針對每個業(yè)務(wù)作用域基于對應(yīng)的協(xié)程Scope跟依賴注入完成業(yè)務(wù)單元的開發(fā)。
? 3. 基于真實(shí)的業(yè)務(wù)將業(yè)務(wù)單元跟業(yè)務(wù)作用域通過對驅(qū)動業(yè)務(wù)事件流的訂閱組合起來,建議對應(yīng)的驅(qū)動關(guān)系。
雖然本文使用的是Dagger與Kotlin協(xié)程來實(shí)現(xiàn),但在實(shí)際落地過程中也可以使用類似的主流框架,比如依賴注入可以換成koin,協(xié)程可以換成Rxjava與Android Lifecycle,只要核心是通過事件訂閱驅(qū)動依賴注入創(chuàng)建業(yè)務(wù)單元,從而實(shí)現(xiàn)業(yè)務(wù)邏輯基于對應(yīng)的生命周期自運(yùn)行都可以實(shí)現(xiàn)類似的效果。
