您的位置:  首页 > 技术杂谈 > 正文

得物App直播复杂页面架构实践

2022-07-11 15:01 https://my.oschina.net/u/5783135/blog/5552197 得物技术 次阅读 条评论

原创 李振全 得物技术

1. 背景

当前直播间业务迭代越来越频繁,开发人员也越来越多,而几乎百分之九十的需求都是在 直播观众页,直播主播开播这两个页面上的功能开发和代码累积。因此,页面中代码的膨胀速度相当快。

直播代码之前做了一次 Layer 层级的拆分,将整个直播间按照视图的层级进行了划分,通过 ViewStub 对每个层级进行渐进式的加载,以提高页面的加载速度,Layer 层级拆分后的组件图如下:

但现在越来越不能支撑目前迭代的速度,因为之前拆分的粒度是以视图层级为单位的,粒度比较粗,容易代码膨胀。为了防止 Layer 层代码逻辑过度膨胀大家也各显神通,通过一个个帮助类,
xxxController/xxxHelper/xxxUtil/xxxManager/xxxAdapter/xxxHandler 等将各个业务逻辑封装起来,代码风格各异,阅读起来也比较费劲。即使这样各种抽取,也无法避免 Layer 逻辑的膨胀,且各帮助类没有统一的API 和代码规范, 很依赖外部调用的正确性,目前最大的 Layer视图交互层代码量已经接近两千行,消息面板及商卡层也有一千行左右。

布局 XML 复杂,预埋控件很多。直播间有不同种类的直播间(带货、娱乐),有各种 AB 策略,还有各种活动入口,大部分组件实际是不展示的,可能一个直播间只需要展示其中一两项,但是目前很多都是预埋到 XML 中代码动态控制其显隐,这其实就是无意义的性能消耗。

Layer 层级之间互相引用,职责不单一,比如:语音连麦逻辑是在一个单独的LiveRoomVoiceLinkLayer语音连麦层, 连麦按钮在 FunctionLayer 的底部布局中,需求是当主播接受连麦的时候,这个按钮不可点击,当挂断连麦的时候,这个按钮可以点击。

代码是这样:

也就是在 语音连麦层 调用了宿主 Fragment,然后通过宿主Fragment获取到 FunctionLayer 也就是视图交互层的 View,去进行控制的。这就造成了层级之间的相互调用,且修改了本不属于自己层级的视图。这样会有什么问题呢?比如说一个不清楚这块逻辑的同学需要对连麦按钮的点击状态进行修改,他在 FunctionLayer 找到了这个按钮,理所当然的在 FunctionLayer 进行修改,但是可能收到预期外的效果,因为此时可能这个按钮被 LiveRoomVoiceLinkLayer 给改回去了,本属于 FunctionLayer 的视图被其他地方修改了。

视图层级架构由简单的视图层级关系变成了互相调用的关系:

在这个背景下,我们一个初步的想法是将直播页面进行组件化拆分。组件内部处理组件各自的业务,粒度更小,逻辑更内聚。直播页只需注册这些组件,然后根据数据驱动的思想,当数据发生改变时,因为组件内部 observe 了自己关心的数据,就可以自动更新组件。这样还有个好处就是可以动态注册组件,也就是只有当真正需要展示此组件的时候再去注册组件模块,而不必所有组件都在进入直播间的一瞬间去初始化,减少性能的消耗。

也就是期望是这样一个新的组件架构:

直播间、商详都是非常复杂的一个页面,而我们的交易商详已经有了组件化拆分的成功示例,直播作为后来者其实做起来的难度并不是很大,但是却有可预见的较大的收益。

目标:因为我们此次改造属于代码重构,所以核心目标是让程序在功能不变的前提下代码更清晰,拓展性更好,更解耦,改善代码膨胀问题; 次要目标是在做完此项重构后能够提升代码的运行性能,可以方便的动态注册和删除组件,通过数据驱动动态 inflate 添加布局,删除预埋在 XML 中的布局,做到按需加载业务模块,提高页面启动速度。

2. 基础页面组件设计

我们通过抽象一个轻量化的页面内组件单元——IComponent 来解决上面背景中提到的几个问题。

(1)针对各帮助类没有统一 API 问题 ,基础组件需要统一 API 回调和代码规范。

类似 Activity 生命周期模板方法,组件需要默认提供组件自身独立的生命周期和宿主如 Activity/Fragment/父Component 生命周期回调。

之前的 Layer 是在宿主中主动调用 lifecycle.addObserver():

新的组件可以简化这步操作,默认实现对父组件的生命周期观察,同时,自身生命周期跟随父组件生命周期,父组件销毁,子组件也会跟着销毁,类似于 ViewGroup 和 View 之间的关系。这也符合业务上的定义,比如我们要销毁某一个大的组件我们只需解绑这个大组件即可,无需关心其内部的子组件的解绑。

如何实现呢?

这里我们定义了 IComponent 接口实现 LifecycleOwner 和 DefaultLifecycleObserver, 作为和宿主 Fragment/Activity/Component 生命周期的绑定,对外暴露系统同名也和系统生命周期一致的回调(onCreate/onStart/onResume/onPause/onStop/onDestroy),并且提供组件自身的 onAttach/onDetach 生命周期, onAttach 代表绑定上了宿主,onDetach 代表从宿主解绑;开发者只需要在特定的生命周期编写业务逻辑即可,比如 onAttach 进行一些初始化 View 和监听的操作, onDetach 进行一些释放资源的操作,onResume/onPause 做一些曝光埋点的上报等等,使业务组件可以专注自身业务的逻辑实现。

(2)针对代码膨胀问题,组件需要支持任意层级嵌套,细化组件的粒度。

组件可以有他的子组件,子组件也可以有子组件的子组件,组件之间的组合方式就是一个树形结构。

这里我们抽象了 IComponentRegister 接口用来提供组件注册和管理的功能,实现此接口需要提供一个 Map 记录注册的组件,以及对应的注册反注册方法。

父组件的生命周期是在 register 的时候绑定,unregister 的时候解绑。目前实现IComponentRegister接口的有 BaseLiveActivity/BaseLiveFragment/BaseComponent, 他们都支持组件的注册和反注册。

 

(3)针对 Layer 层级之间互相引用,职责不单一问题,新组件需要做到同级组件之间不强依赖。

实现 IComponentRegister 接口的宿主提供的 map 是 protected 修饰的,所以组件不能直接获取到同级的组件。

那原本组件之间的耦合怎么处理呢?

组件之间的耦合分为两种:

一种是组件 A 依赖组件 B 的一个状态,比如点赞模块时可能需要知道关注模块的关注状态;对于这种我的解决方案是组件 A 不直接依赖组件 B 而是依赖一个 LiveData 数据状态,关注的状态通过 LiveData 存在ViewModel中,组件 A 依赖的就变成 ViewModel 即可。

一种是组件 A 依赖组件 B 的 View 视图,比如引导组件在显示评论引导的时候需要评论控件作为定位锚点,因为评论控件属于底部操作区,而底部操作区是需要根据直播类型以及 AB 动态去创建的,所以在初始化的时候可能评论控件并没有初始化完成,所以在注册引导模块的组件时通过构造函数无法获取到评论控件。

这种依赖我的解决方案是组件 A 不直接依赖组件 B 而是通过根布局 view或者AB组件对应的父布局 findViewById 获取 组件 B 的 View 视图,如下:

这样其实并不算是完全解耦,只是将显式的依赖其他组件变成了依赖布局中的一个控件,并且注意这种方式获取的View只能读,不能写,算是目前解耦的临时方案。也是本次改造做的不完美的一个地方,但考虑到如果要彻底解决这个问题,成本较高,开发也会更不便捷,这里最终取舍还是开发效率优先。大家如果有更好的处理方式,欢迎讨论。

(4)针对布局 XML 越来越复杂预埋控件很多的问题,新组件方便的需要支持 ViewStub 改造。

2.1 抽象 BaseComponent

Android 应用架构指南说到,最重要的原则就是分离关注点:

那么我们抽象出的 BaseComponent 要做的就是这么一件事,将页面逻辑按照业务进行划分为一个个的页面内组件,将关注点内聚到自己业务的独立组件中。

BaseComponent 实现 IComponent 和 IComponentRegister 作为组件的最小单元;IComponent 用来提供最基本的组件的绑定解绑生命周期,IComponentRegister 接口用来提供组件注册和管理的功能,以及和宿主的生命周期绑定。组件以业务逻辑为核心,可以对应1或多个 UI 元素或者 0 个 UI 元素,多个UI 元素从业务逻辑层面属于同一个模块,0 个就是纯逻辑层面的组件而已。

2.2 抽象 BaseLiveComponent

因为用户端有上下滑切换直播间的交互,为了兼容历史逻辑,所以在 BaseComponent 之上又抽象了一个 BaseLiveComponent,BaseLiveComponent 继承自 BaseComponent 之外又实现了 ILiveLifecycle(直播特定生命周期), LayoutContainer(kotlin 自动findViewById 插件)。

 

ILiveLifecycle 有三个生命周期,分别是 onSelected 选中,unSelected 不选中,对应 ViewPager 的 onPageSelected 和滑出,这两个生命周期还是比较好理解的,destroy 又是什么呢?

Destroy 从字面意思看是销毁的时候做一些资源的释放,但实际并不是宿主 onDestroy生命周期 而是 onPause时且isFinishing=true,这是因为 onStop/onDestroy 回调是根据 IdleHandler 来的,如果主线程消息队列消息比较多,那么 onStop/onDestroy 就会延迟回调(详细可参考
https://juejin.cn/post/6936440588635996173),资源释放onStop/onDestroy这里就会释放不及时。于是我们给直播间的 ILiveLifecycle 加了一个特定的 destroy 方法,用来及时释放资源。

上面我们说到 BaseComponent 有两个生命周期,onAttach 代表绑定上了宿主,onDetach 代表解绑了宿主,那释放资源现在就有了三个地方,宿主的 onDestroy(),ILiveLifecycle 的 destroy(), BaseComponent 的 onDetach()。

onDetach destroy onDestroy 傻傻分不清

onDestroy 是什么?
宿主的生命周期回调(Fragment/Activity)。

onDetach 是什么?
Component 被反注册时回调。

destroy 是什么?
直播间特定生命周期,因为 onDestroy 生命周期的延迟回调,我们再 onPause 判断 isFinish 执行 destroy 进行及时的资源释放。


明确了三个方法的含义,为了简化组件的使用,我们将 destroy 和 onDetach 进行了合并,因为两者本质目的都是做资源释放的。为了减少对历史代码的影响,这里 BaseLiveComponent 实现 destroy 方法,用final 修饰即可,这样继承了 BaseLiveComponent 的组件就无法实现 destroy 方法只能选择 onDetach 方法了。

另外 BaseLiveComponent 多了一个 CustomLiveLifecycleOwner 这个是干什么的呢?CustomLiveLifecycleOwner 是 BaseLiveComponent 的一个内部类:

主要用来处理直播间上下滑的场景问题。

直播间 Activity 内有一个 ViewPager2, ViewPager2 上下滑的是一个个独立的直播间 Fragment, 所以直播间 Fragment 就有了 onSelected/unSelected 的自定义生命周期,现在有些情况下我们需要监听 Activity 域的 ViewModel 的 LiveData 状态,那么我们期望的就是在直播间滑出 unSelected 的时候不响应此监听,我们可以根据 isSelected 属性判断,也可以直接使用 customLiveLifecycleOwner 作为 observe 传入的 LifecycleOwner 参数。

页面滑出的时候会调用到 customLiveLifecycleOwner 的 unSelected(), 这样customLiveLifecycleOwner 的 lifecycle就是 ON_STOP状态, ON_STOP 不会响应对应的 LiveData 监听,也就符合了我们的期望。onSelected, destroy 同理。

2.3 组件之间通信

根据我们的需要和架构现状,这个并没有什么纠结的,因为我们之前的架构模式就是 Jetpack MVVM 模式,所以这里还是继续沿用 ViewModel LiveData 的方式进行通信。这样之前的代码基本就不用动,直接挪到 Component 即可。组件内部自己管理自己的状态,组件只需关注自身功能实现而不必关系与其他组件的交互。这里需要注意的两点,一个是LiveData数据倒灌问题,这个网上很多资料我就不再赘述了,另外一个是如何让 LiveData 保持消息同步的一致性,什么意思呢?

比如我们在 ViewModel 定义了一个心跳接口返回的 MutableLiveData 数据:

那么外面只要拿到此 ViewModel 就可以更新这个 MutableLiveData 的数据,这就是消息同步的不一致。如何做到一致呢?其实也非常简单,只需要定义成这样:

私有化 MutableLiveData,暴露出一个 pulic 的 LiveData,为什么要这么做呢?

这是因为 LiveData 对于写操作是 protected 的,对于读操作是 public 的,也就限制了外部拿到 ViewModel 的时候只能读,不能写,这样就限制了我们只能在 ViewModel 内部进行写操作,这就内聚了逻辑确保了消息同步的一致性。

详情可以参考“重学安卓:架构组件 “一致性问题” 全面解析”【1】

3. 改造

在完成基础组件的代码设计后,我们来看下如何进行代码的具体改造。

3.1 基础使用和注意点

(1)定义一个业务 Component, 选择继承 BaseComponent or BaseLiveComponent。

BaseLiveComponent 和 BaseComponent 的区别在于,BaseLiveComponent 有直播间上下滑特殊生命周期处理(onSelected, unSelected, destroy)

比如直播间用户端因为是有上下滑交互的,所以可以选择继承 BaseLiveComponent;主播端的组件因为没有上下滑的交互,所以可以选择继承 BaseComponent;如果是想主播端和用户端共用的组件并且不涉及上下滑的交互处理,那么可以选择继承 BaseComponent。

(2)按需实现 onAttach onDetach 方法,以及需要在宿主生命周期执行的业务逻辑。
在 onAttach 绑定生命周期宿主后进行初始化的操作,如注册 LiveData 监听,初始化 View 状态,设置点击事件等,因为此时 Component 对象已经创建完成且绑定了宿主的生命周期。

这里需要注意的是:宿主生命周期方法如 onCreate 可能是跟绑定的 Fragment onCreate 回调时机有一定延迟的,宿主生命周期方法回调取决于你registerComponent 的时机。比如我们可能是 View 创建完毕 onCreatedView 的时候才会 registerComponent,或者更晚的时机,此时 Fragment onCreate 已经执行完毕,所以实际上 Component 组件回调的 onCreate 真正时机是你创建 Component 并绑定宿主生命周期的时候。当然如果你是在 Fragment 的 onCreate 就去注册 Component 组件,那么onCreate 回调时机是和 Fragment 差不多的。

(3)在宿主中通过registerComponent 注册子组件。

registerComponent 可以自动跟随宿主的生命周期。宿主可以是 BaseLiveActivity, BaseLiveFragment, BaseComponent, 或者其他实现了 IComponentRegister 接口的类。(推荐只在复杂的 Activity/Fragment 使用)

以优惠券组件为例(这里省略了大部分业务逻辑代码,着重突出组件主流程代码):

//1. 创建 CouponComponent 继承 BaseLiveComponent 
class CouponComponent( 
    override val containerView: View, 
    private val itemViewModel: LiveItemViewModel, 
    private val fragment: LiveRoomLayerFragment 
) : BaseLiveComponent(containerView) { 
    private val couponViewModel by fragment.viewModels<CouponActivityViewModel>() 
    
    //2. onAttach 初始化 View, 注册 LiveData 监听 
    override fun onAttach(lifecycleOwner: LifecycleOwner) { 
        super.onAttach(lifecycleOwner) 
        initView() 
        registerObservers() 
    } 
    
    private fun initView() { 
        couponNewIcon?.setOnClickListener { 
            val couponListFragment = LiveProductCouponListFragment.newInstance(LiveProductCouponListFragment.ADAPTER_ROOM) 
            couponListFragment.show(fragment.childFragmentManager) 
        } 
    } 
    
    private fun registerObservers() { 
        //心跳 
        itemViewModel.notifySyncModel.observe(this, { 
            notifySyncModel(it, itemViewModel.roomId) 
        }) 
        //获取优惠券信息 487 
        couponViewModel.couponPopupRequest.observe(this, onSuccess = { m, _, _ -> 
            showCouponDialog(m) 
        }) 
        。。。。
    } 
    
    // 2. 页面滑出逻辑如隐藏弹窗,重置状态等 
    override fun unSelected() { 
        super.unSelected() 
        dissmissCouponDialog() 
    } 
    
    // 3. 如果要做一些资源释放写在 onDetach 
    override fun onDetach(lifecycleOwner: LifecycleOwner) { 
        super.onDetach(lifecycleOwner) 
    } 
    
    // 4. 可选,是否开启 EventBus 开关 
    override fun enableEventBus(): Boolean { 
        return true 
    } 
    
    @Subscribe(threadMode = ThreadMode.MAIN) 
    fun onAutoPopTypeEvent(event: LiveAutoPopTypeEvent) { 
        if (event.autoPopType == AutoPopTypeConstant.TYPE_COUPON) { 
            couponActivityInclude.syncAutoCoupon() 
        } 
    } 
}

3.2 提高页面打开速度

        直播间有相当多的这种挂件视图,目前我们大多数都是将布局预埋到 XML,然后动态控制其显示隐藏,但实际上用户侧真正需要展示的挂件根据直播间,根据用户都是不一样的,有的人不是新人,那么这个新人任务的视图就没必要加载,有的直播间没有抽奖,没有限时直降活动也一样不需要加载对应的挂件视图。

        经过组件化拆分后,每一个挂件实际上都相当于一个页面内小组件,被注册到直播间中,组件依赖 的是一个 View,那么只要我们将 View 改成 ViewStub 并在 ViewStub inflate 完成后再注册到直播间就可以了,改造起来也就十分轻松了。

以抽奖入口和限时直降入口为例:

第一步: 先将其组件预埋的 XML 布局替换成 ViewStub。

图片

第二步:设置ViewStub inflate 监听,inflate完成时注册组件。

第三步:在需要加载组件的时候动态 inflate ViewStub 组件。

改造后代码大概是这样:

/** 
* 直播间交互层级 
*/ 
class LiveRoomFunctionLayer( 
    override val containerView: View, 
    private val layerFragment: LiveRoomLayerFragment 
) : BaseLiveComponent(containerView) { 
    
    override fun onAttach(lifecycleOwner: LifecycleOwner) { 
        super.onAttach(lifecycleOwner) 
        initView() 
        initViewStubComponent() 
        registerObserver() 
        initChildComponent() 
        initViewStubObserver() 
    } 
    /** 
    * 初始化动态加载的组件 
    */ 
    private fun initViewStubComponent() { 
        vsLotteryEntrance?.setOnInflateListener { _, inflated -> 
            registerComponent(LotteryEntranceComponent(inflated, lotteryViewModel, layerFragment)) 
        } 
        vsSecKillEntrance?.setOnInflateListener { _, inflated -> 
            registerComponent(SecKillComponent(inflated, liveInfoViewModel, layerFragment.childFragmentManager)) 
        } 
            ...... 
    } 
    
    private fun initViewStubObserver() { 
        lotteryViewModel.notifyAutoLotteryInfo.observe(this, Observer { 
            it?.apply { vsLotteryEntrance?.inflate() } 
        }) 
        liveInfoViewModel.notifyLiveDiscountInfo.observe(this, Observer { 
            it?.apply { vsSecKillEntrance?.inflate() } 
        }) 
            .... 
    } 
    /** 
    * 初始化非动态加载组件 
    */ 
    private fun initChildComponent() { 
        registerComponent(BottomViewComponent(containerView, layerFragment)) 
        registerComponent(EnergyComponent(energyContainerView, layerFragment)) 
        registerComponent(ShareOrReportComponent(layerFragment)) 
        registerComponent(FansGroupComponent(containerView, layerFragment)) 
        registerComponent(CouponComponent(containerView, liveInfoViewModel, layerFragment)) 
        registerComponent(XXXComponent(depenView, dependData)) 
            ...... 
    }

这样就完成了直播间挂件真正需要的时候再去加载,改造成本很低,且代码可读性也很好。

        小Tip:这里是先收到 LiveData 状态判断需要加载 ViewStub 之后再去注册子组件的,存在一个先后关系,那会不会导致后注册的子组件内部收不到 LiveData 状态更新呢?答案是不会的,因为我们这里更新数据状态使用的 MutableLiveData, MutableLiveData 可以简单的理解为粘性的事件,也就是后注册监听,如果发现这个 MutableLiveData 是有值的就会回调 observe 监听。比如,抽奖组件内部控制 View 的显隐逻辑都不用动。

 

4. 收益

(1)统一代码风格,提升了代码可读性;通过使用统一的自定义的 Component ,提供明确的组件生命周期回调,让大家写一个业务组件,按照模板就可以快速实现,无需再借助很多特定的帮助类,且代码风格统一,阅读起来也会更容易。

(2)有效改善了代码膨胀问题;通过方便的注册子组件的方式,代码可以内聚到子组件中去,组件以业务逻辑划分,整个直播间就由一个个或大或小的组件积木搭建起来。这也符合职责单一原则,父组件负责组合子组件,子组件负责内部具体业务,当一个组件慢慢迭代越来越复杂后,就可以考虑是否可以拆分子组件,因为子组件的拆分设计的非常方便,基本只需挪一下代码位置,注册一下就好了,所以也不怕出现组件代码膨胀阅读困难的问题了。

(3)提高了页面性能;采用响应式编程思想,使用 ViewStub 动态加载组件视图和注册组件,避免 View 集中加载的性能问题,以及可以直接从源头避免不该响应到的事件响应。因为没有被注册的组件,其内部的业务逻辑也都是相当于不存在的。

(4)通过私有化组件集合,避免业务组件间的耦合依赖;可以注册子组件的地方都会有一个 map 来维护子组件,可以新增和删除子组件,但是没有提供获取的方法。也就是说,避免了子组件A被其他子组件B所引用,每个组件只处理自己的业务,减少业务间不必要的耦合。

5. 思考&总结

5.1 思考

        在历史代码迁移过程中要有所妥协,有些历史代码写的不好的地方很想顺手改掉,克制一下,因为这样做极其容易造成线上问题。我们一次改造就专注于一件事,比如这次就主要关注组件之间的划分,原本的逻辑只要注入对应的视图依赖以及数据依赖放到对应的生命周期即可,无需改动任何业务逻辑。比较复杂一点的就是改造过程中发现的业务之间的耦合部分如何解耦,这个因为每个业务都不一样,这里就需要具体问题具体分析。

        不要一下步子跨的太大,循序渐进的进行改造,组件是随着迭代动态增加和删除的,一下子全部拆分并不现实,目前直播间的组件化经历了三个版本持续进行改造,基础组件框架已经搭建完毕,已拆出了二十多个核心组件,后续计划根据业务迭代的具体业务,再进行代码上的拆分或聚合改造,这样也不会给测试照成太大压力。

        同时要考虑到组员之间的接受成本,积极和大家进行讨论和同步,大家的认同才是代码架构改造后面能否落地的关键,这样才能减少改造过程中导致的问题,过渡也更为平滑。

5.2 总结

        架构没有万能的,只有更适合的。在直播间复杂页面的场景下,为了改善日益膨胀的业务代码累积,提高代码可读性,于是设计了这样一套页面组件化的方案。每个人心中或许都有自己对组件设计,代码拆分的想法,以上是我的一些思考,简单总结下有如下几点:

        (1)更细致的业务组件拆分,分离业务关注点,组件支持父子关系,符合单一职责原则;

        (2)组件提供统一生命周期回调和模板方法,提高代码可读性和稳定性;

        (3)组件之间的通信采用响应式编程的方式,让组件监听外部状态的变化,当外界状态变化时,组件内部维护好自己的状态即可,实现了依赖反转;

        (4)在 BaseComponent 之上抽象了一个 BaseLiveComponent,兼容直播间特殊的上下滑交互,对外屏蔽特殊的处理,减少使用者的心智负担。

如果你对页面组件化有什么独特的见解,欢迎评论区和我交流~

参考链接:

【1】https://xiaozhuanlan.com/topic/9340256871

*文/李振全

 关注得物技术,每周一三五晚18:30更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~

展开阅读全文
  • 0
    感动
  • 0
    路过
  • 0
    高兴
  • 0
    难过
  • 0
    搞笑
  • 0
    无聊
  • 0
    愤怒
  • 0
    同情
热度排行
友情链接