查看原文
其他

哔哩哔哩Android客户端基于依赖注入实现复杂业务架构探索

尹乐&朱文波&蒋威 哔哩哔哩技术
2024-09-08

本期作者



尹乐

哩哔哩资深开发工程师


朱文波

哔哩哔哩资深开发工程师




蒋威

哔哩哔哩高级开发工程师


01 背景介绍


B站作为一个视频网站,视频播放页作为用户的核心消费场景,其重要程度可想而知。目前APP客户端的主要播放页场景主要有UP主稿件播放页,Story模式播放页,直播播放页跟番剧影视播放页。每一个都是大量业务的汇总点作为用户核心消费场景,需要在承接各种业务到播放页的转化,还要负责承接各业务在播放页的功能展示。可以说播放页代码复杂度属于客户端最高的代码之一,这不仅仅是因为播放页本身的功能复杂,还因为播放页往往需要融合大量外部业务功能。复杂的功能自然会产比较高的代码复杂度,而高代码复杂度又往往意味着高代码维护成本。

另一方面,在这个降本增效的大时代之下,公司层面上决定把UP主稿件播放页与番剧影视播放页进行页面合并,这样既可以确保用户播放体验尽可能一致的,又避免了相同功能又不需要重复开发从而降低开发成本,并且后续课堂业务也会融合进来。然而这也意味着在这个新的融合播放页中会承载直接三个播放页的代码复杂度。为此我们需要探索出一种新的架构模式来满足当前的业务诉求。


02 明确需求


既然已经明确了需求的大方向是搭建一套能兼容多个业务的架构,那首先要做的自然是需求分析。

由于本次需求主要是基于UP主稿件播放页(简称UGC播放页)与番剧影视播放页(简称OGV播放页)融合产生(简称融合播放页),我们先对两个播放页的业务进行简单拆分:


图2.1


上图是融合部分业务的拆分图,实际的业务远比上图复杂。如上图所示,我们可以将融合播放页的业务简单的拆分成6个部分,它们分别是:

视频播放业务的通用,视频播放业务UGC特化,视频播放业务OGV特化

播放页页面交互通用,播放页页面交互UGC特化,播放页页面交互OGV特化

每一个部分又有一堆具体的业务组成,其中特化部分又存在一定的关联性,比如UGC特化的投屏业务,跟OGV特化的投屏业务都会依赖同一个投屏SDK业务,并且绝大部分业务又都是基于接口请求的返回值+用户行为驱动的。

那么最终如果用代码去反应这些业务必然会形成一个庞大的网状结构,如何相对优雅的设计这个网状结构让其在开发与迭代的过程中尽可能稳固成为了新架构的首要目标。


03 需求分析


由于业务的复杂性必然会形成网状结构,那么如何优雅的实现这个网状结构呢?网的本质是点跟线,点可以理解为一个具体的业务单元,而线则是依赖于被依赖的关系。那如何提升网的迭代维护质量可以简单的拆分成三个步骤:

1.如何优雅的新建业务单元

2.如何优雅的建立依赖关系

3.如何尽可能缩小对整张大网的维护成本

首先是新建业务单元,考虑到一个业务单元能正确运行,他所依赖的其他业务单元必须先被创建。那么为了避免一个业务单元A依赖另一个没有被创建的业务B,最简单直接的办法就是把B放到A的构造函数里。那有什么办法能够优雅的基于构造函数的依赖关系按照正确的时许创建对应的对象同时还能够确保没有环形引用呢?答案显而易见,是依赖注入框架。

接下来是如何解决业务复杂的依赖关系,这里可以先看一个简单的例子:


图3.1


业务诉求是当屏幕状态变化成半屏并且视频在播放的时候我们需要展示UI否则则隐藏UI,我们先用常见的事件监听的方式来实现一下伪代码:


//伪代码@BizScopeclass BizService @Inject constructor(screenStateService:ScreenStateService, playingStateService: PlayingStateService){   var isHalfScreen = false   val isPause = false   val vm = BizViewModel   val screenStateCallback = object:ScreenStateCallback{            onScreenStateChange(isHalf:Boolean){             isHalfScreen = isHalfScreen            }        }  val playingStateCallback = object:PlayingStateCallback{            onScreenStateChange(isPause:Boolean){               isPause = isPause            }        }   fun start(){        screenStateService.register(screenStateCallback)        playingStateService.regitster(playingStateCallback)    }   fun refreshViewState(){        vm.showtargetView = isHalfScreen && isPause    }   fun clear(){       screenStateService.unregister(screenStateCallback)        playingStateService.unregitster(playingStateCallback)   }}事件监听需要进行注册跟解注册,还需要本地去维护状态跟,同时如果其中某个状态在频繁变化的时候还需要手动处理倍压问题(例如播放暂停状态频繁切换会导致refreshViewState方法频繁执行)。
考虑到现在Koltin已经成为Android的一等公民,那么如果我们抛弃旧的API风格全面采用Kotlin协程会怎么样呢?还是刚刚的例子:
//伪代码@BizScopeclass BizService @Inject constructor(coroutineScope: CoroutineScope, screenStateService:ScreenStateService, playingStateService: PlayingStateService){   val vm = BizViewModel   init{        coroutineScope.launch{            combaine(screenStateService.isHalfScreenStateFlow,playingStateService.isPauseStateFlow,:Pair)        }.distinctUntilChanged().collectLatest{            vm.showtargetView = it.first && it.second        }    }}


同样的业务,是不是看起来代码少了很多?distinctUntilChanged确保了只要屏幕状态跟播放状态的组合结果没有改变,哪怕事件发生了变化也不会把变化传递给下游,从而实现了去重的效果。collectLatest则是解决了事件频繁变化产生的背压问题,当事件频繁变化时,由于collectLatest每次都会产生一个新的CoroutineScope,并且如果有上一次还未完成的CoroutineScope则会自动取消,从而实现性能上的优化。

这里我相信有人会有一些疑问,比如@BizScope是什么?Kotlin协程https://kotlinlang.org/docs/coroutines-overview.html)的CoroutineScope又代表什么?

要回答这个问题,首先我们要先确定一个概念:什么是Scope?这里我们引用Koltin中关于自定义CoroutineScope的描述:CoroutineScope 应声明为具有明确定义的生命周期的实体的属性,负责启动子协程。而在本文中Scope代表一个有明确生命周期的业务单元集合,该集合下的所有的业务单元都应该拥有同一个对应的Scope,随着该Scope创建而创建并启动,随着Scope的结束自动终止。

Scope存在父子关系,当父Scope结束时,对应的子Scope必须也结束,反之则不需要。同时子Scope可以引用父Scope的业务单元,因为父Scope的生命周期一定大于等于子Scope,反之则不行。

基于上述Scope的定义融合播放页来举例子进行Scope的拆分:


图3.2


上图是融合播放页的一个简流程图,主要流程是进入页面后通过传入的路由参数请求服务端接口进而返回判断当前业务是UGC业务还是OGV业务,之后启动对应业务的业务单元,并且有对应业务的选集逻辑驱动相应的视频播放业务同时播放视频。

UGC视频一个页面存在多P每1P对应一个单独的独立视频物料。与此相对应,OGV一个季度合集存在多个EP视频,每一个EP视频也对应一个独立的视频物料。而这里我们结合图2.1里提到的6个业务分组,可以对这6个分组进行一个工作范围的划分:


图3.3


从上图可以看出,融合播放页目前可以分成三层一共有5个Scope:

第一层是页面Scope,对应的生命周期是融合播放页本身,打开页面启动,关闭页面退出。该Scope生命周期中间运行的是贯穿整个页面生命周期的业务:比如埋点上报,页面接口请求浮层管理等业务单元。

第二层是业务Scope,是业务Scope的子Scope,又分成UGC业务Scope跟OGV业务Scope,两者不会并存,且同一时间同一个页面Scope只会存在一个业务Scope。当页面接口返回时根据服务端的数据返回来决定启动哪个具体的业务Scope,如果当前已经存在业务Scope则销毁当前Scope跟其子Scope。该Scope生命周期中间运行的是UGC/OGV业务独有的业务单元,如UGC多P逻辑,UGC投屏评论等业务单元,OGV的多季,OGV分节列表。

第三层是视频播放Scope,分成UGC视频播放Scope跟OGV视频播放Scope,分别是UGC跟OGV业务Scope的子Scope。由于两者的父Scope不会并存,所以这两者也不会并存,并且同一个业务Scope也只会存在一个视频播放Scope。每次触发新的视频播放的时候会启动对应视频的业务Scope,如果当前已经存在视频播放Scope则销毁当前Scope。该Scope生命周期中间运行的是UGC/OGV视频播放业务独有的业务单元,UGC的充电试看,OGV的大会员鉴权等视频单元。

上述逻辑是由业务本身的作用域所定义,直接映射具体的真实业务。事实上,大部分业务如果本身定义清晰并且合理,在这个模式下也是可以很容易的映射到具体的Scope跟具体的业务单元的。这种拆分是很有必要的,在一个页面,如果我们不新建业务Scope那通常只会存在一个Scope即页面生命周期Scope,考虑到所有业务在事实上都有生命周期,如果不按业务范围进行拆分,通常就只能绑定运行在页面生命周期Scope,即LifecycleScope下。而很多业务在事实上的生命周期是远小于页面生命周期的,子啊这个情况下,要么开发需要手动维护业务单元内部的Scope,要么是疏于考虑从而引入业务Bug。并且,在这种情况下还很容易因为数据的更新的时序问题导致业务依赖错误的数据。

我们还是播放业务来举例子,比如当前OGV有一个业务单元需要视频播放开始后获取对应视频当前季的相关信息,而在切集同时切季的时候,由于视频信息接口已经返回,于是视频正常起播,此时如果季信息的接口还没有回来,则会导致该业务单元从季业务里获取的信息是上一季的,从而引发业务错误。而在建立对对应的业务Scope 并完成Scope间依赖后,由于集业务Scope是季业务Scope的子Scope,所以集业务Scope下的所有业务单元在启动时,依赖注入框架会确保当前季的信息已经是正确的。依托于依赖注入框架,我们甚至可以把当前季信息这种集维度广泛使用的数据作为集Scope的初始化参数,从而让集业务单元不需要关心季变动这种事,进一步简化业务代码。

那么接下来只要我们将之前提到的业务单元网按照Scope进行拆分,就可以获得一系列小的Scope跟对应的业务单元。每个业务的负责人只需要关心自己业务相关的Scope而不需要理解全部业务单元。同时因为Scope间的隔离关系,通过不同的Scope按照业务进行模块的拆分从而实现代码隔离,从而降低了建立错误引用关系的成本跟代码维护成本。参考融合播放页当前场景,可以将融合播放页的代码分成4个模块,页面Scope,UGCScopeModule,OGVScopeModule,融合Modle。融合模块引用OGV跟UGC模块,OGV跟UGC模块彼此之间引用隔离,但是都引用页面模块。通过将代码分成不同的业务模块实现了不同业务间代码的的隔离,从而避免建立错误的业务单元依赖关系。

由此,我们通过分析,进一步明确了需求,我们需要一个能在一个Android页面运行,并且支持依赖注入与使用Kotlin协程作为依赖关系绑定的且能方便的进行多层级父子关系Scope创建的架构设计。


04 具体方案


首先是依赖注入框架的选择,考虑到安卓本身的生态体系结合项目中历史的代码引用,我们选择了Dagger作为依赖注入的实现。有人会问为什么不用koin或者hilt(Dagger官方的Android定制版),不用前者是因为当前B站Android端已经引入了,积累了一定的使用经验并且基于编译时创建依赖关系可以更好的在编译期检查是否有不合理依赖,不像koin需要运行时才能发现错误。至于不用hilt的原因,参考图4.1,这个是hilt官网的scope示意图,hilt的scope已经有一套独立的父子关系,对应的映射是Android的系统生命周期,而我们的诉求是在一个页面(Activity或者Fragment)中基于业务自定义Scope,当前hilt并不能很好的满足我们的诉求,最终衡量之下我们还是采用了Dagger本身。


图4.1


之前我们提到要支持Daggerhttps://dagger.dev/)依赖注入,跟Kotlin协程,巧合的是这两者恰好都有Scope的概念并且都支持父子Scope联动,甚至定义上都很类似,于是事情似乎变得有意思起来。(接下来建议先了解Dagger跟Kotlin协程的使用方法再继续阅读)

还是用融合播放页的例子,

在Dagger层面,我们定义了三个Dagger Scope注解:PageScope, BizScope, OGVBizScope, EpScope,

还需要定义5个Dagger component对象:PageComponent(PageScope), UGCBizComponent(BizScope), OGVBizComponent(BizScope), UGCEpComponent(EpScope), OGVComponent(EpScope),同时按照图3.3建立起父子关系,这里的每一个component对应的是图3.3提到的具体业务作用域。在每个Dagger component里我们需要声明一个对应的anchor对象,这里的anchor只是一个单纯的业务矛点用来声明在该Scope下需要启动的业务单元, 参考之前BizService的例子,业务单员本身创建就会基于依赖的CoroutineScope自动运行,所以只要依赖注入框架创建了矛点对象,那么矛点对象里声明的业务单元也会相应的被创建并且自动化运行。

接着我们再定义三个CoroutineScope 别名(Qualifier)注解:PageCoroutineScope,BizCoroutineScope, EpCoroutineScope,用来给Dagger识别对用的CoroutineScope实现依赖注入。

接下来首先我们要提到Kotlin协程Flow里一个重要的操作符collectLatest ,首先这是一个suspend方法,所以它必须要在一个CoroutineScope下运行,并且每当收到一个新的结果时,他会创建一个当前CoroutineScope下的子Scope 并且执行传入的代码块,同时取消上一个代码块里的子Scope。利用这个特点,我们可以将 图3.2 的流程图利用代码实现成:


图4.2


进入页面初始化时,首先通过依赖注入创建PageScopeAnchor下的业务单元,注入当前页面Lifecycle的CoroutineScope并运行,而这些业务单元因为采用Kotlin协程的API设计会自动在页面销毁时停止并释放,从而避免内存泄露。PageScopeAnchor下有两个比较重要的业务单元,一个是页面接口业务单元,一个是业务Scope驱动业务单元。前面说到页面初始化的时候PageScopeAnchor下的业务单元都会自动创建并在Lifecycle的CoroutineScope下运行,此时页面接口业务会先通过传入当前页面的入参,请求服务端数据。而业务Scope驱动业务会订阅页面接口服务的返回值,从而开启对应业务(UGC/OGV)的BizScope。

此时会通过依赖注入创建BizScopeAnchor下的业务单元,注入通过CollectLatest方法产生的CoroutineScope并运行。每当PageScope下的阅页面接口服务有新的返回值,前一个CoroutineScope会被协程框架取消,从而停止上一个BizScope下的所有业务单元,接着会创建一新的BizScope。

接下来是一段示意代码,用来示范如何通过Kotlin协程驱动Dagger创建并运行业务单元:


//伪代码@PageScopeclass BizScopeDriver @Inject constructor(viewRepository: ViewRepository, @PageCoroutineScope private val coroutineScope: CoroutineScope){   init{     coroutineScope.launch{         //订阅页面接口返回对象          viewRepository.collectLatest{ it->                coroutineScope {                    when(it.bizType){                        //驱动UGC业务Scope                        UGC ->{                         val component = UGCComponentBuilder                          .bindBizCoroutineScope(scope)                          .build()                           //创建UGCScopeAnchor从而自动创建对应的UCCBiz业务单元并运行                           component.ugcScopeAnchor()                        }                        //驱动OGV业务Scope                      OGV ->{                           val component = OGVComponentBuilder                          .bindBizCoroutineScope(scope)                          .build()                           //创建OgvScopeAnchor从而自动创建对应的OGVBiz业务单元并运行                           component.ogvScopeAnchor()                        }                        //什么都不做,等待当前coroutineScope,为了确保coroutineScope持续运行并且Anchor对象不被释放                        awaitCancellation()                    }                }              }        }    }}


至此,融合播放页的例子到此结束,上述方案中,可以继续叠加Dagger的各种module 或者provider从而进一步简化代码,具体方法请参考Dagger官网,本文不再展开。


05 总结


事实上,单一页面的复杂业务的拆分目前Android客户端并没有现成的方案,本文也是基于业务开发过程中的探索给出了一种可能性。针对Android客户端复杂业务页面的开发,本文结合当前Android Kotlin的协程与常见的依赖注入框架Dagger以B站安卓粉版融合播放页为例子,介绍了一种可以将复杂业务按照业务自己真实的生命周期与驱动关系拆解成多个相对内聚的业务单元模块,并通过依赖注入降低代码量从而提高开发效率的架构探索与业务落地。在上述例子中,我们采用的方案可以简单总结成三步:

  1. 将复杂而庞大的业务网按照不同的生命周期拆成多个独立的业务作用域并建立起对应的依赖注入Scope跟协程Scope。

  2. 针对每个业务作用域基于对应的协程Scope跟依赖注入完成业务单元的开发。

  3. 基于真实的业务将业务单元跟业务作用域通过对驱动业务事件流的订阅组合起来,建议对应的驱动关系。

虽然本文使用的是Dagger与Kotlin协程来实现,但在实际落地过程中也可以使用类似的主流框架,比如依赖注入可以换成koin,协程可以换成Rxjava与Android Lifecycle,只要核心是通过事件订阅驱动依赖注入创建业务单元,从而实现业务逻辑基于对应的生命周期自运行都可以实现类似的效果。


以上是今天的分享内容,如果你有什么想法或疑问,欢迎大家在留言区与我们互动,如果喜欢本期内容的话,欢迎点个“在看”吧!


往期精彩指路

修改于
继续滑动看下一个
哔哩哔哩技术
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存