前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >探索Android复杂页面管理之道-QQ音乐播放页代码演进之路

探索Android复杂页面管理之道-QQ音乐播放页代码演进之路

作者头像
QQ音乐技术团队
发布2021-07-23 11:10:17
3.2K1
发布2021-07-23 11:10:17
举报

前言

播放页是QQ音乐内曝光量最大的二级页,是端内展示歌曲信息、提供播控操作、进行推荐宣发的重要入口。随着QQ音乐的快速发展,播放页也从一个简单播控页面逐渐演变到了现在业务众多、UI多变的复杂页面。在该转变的过程中,播放页Android端的代码也根据不同时期的需要,进行了持续演进。本文将简要回顾Android端播放页代码在过去不同时期的结构特点,并重点介绍在最近一次代码结构调整中,我们探索出的一种适合多人开发和代码复用的复杂页面管理模式。

图 1: QQ音乐播放页

背景

MVC

在QQ音乐发展之初,播放页承载的功能较少,业务逻辑也比较简单,主要负责用户浏览歌曲信息、进行播控操作等功能。在代码架构上采用了如下图所示的MVC结构:View负责UI展示,Controller负责业务逻辑,Model负责数据交互。

图 2: 播放页初期MVC结构

在具体实现时:

  1. View层由XML布局文件、PlayerActivity实现:
    • xml负责布局
    • PlayerActivity负责为对应的View控件设置点击事件、进行数据绑定
  2. Controller层同样由PlayerActivity实现,主要负责:
    • 接收model层的数据更新广播,获取最新数据更新UI;
    • 接受View层点击事件,触发model层数据更新
    • 页面路由
  3. Model层则由端内各种Manager实现。

SubControllers

上述MVC的架构在QQ音乐发展初期基本满足我们的需要,实现了需求的快速迭代。但是随着QQ音乐的快速发展,作为QQ音乐曝光量最大的页面之一,播放页成为端内重要的推荐宣发入口,承载的功能逐渐增多,PlayerActivity代码行数快速膨胀。为了解决上诉问题,在后续的需求迭代中开始有意将复杂业务从PlayerActivity中独立出来,拆分出新的SubController与PlayerActivity共同承担Controller层的职责,在具体职责划分时:

  • PlayerActivity负责接收公共数据更新的广播(如播放歌曲或播放状态的变化),并根据各Controller的需要调用相关API驱动业务逻辑和UI更新。
  • 各SubControllers则封装了对应业务的UI、页面路由及对应Model的数据更新逻辑。

同时为了方便各SubController获取自己业务相关的ViewReference并避免重复调用findViewById,PlayerActiivty在XML被inflate之后将所有子View的引用存储在了ViewHolder中,各SubController通过ViewHolder获取自己所需的View引用。 此时衍生出的架构如下图所示:

图 3: 播放页后期SubController结构

上述策略在一定程度上缓解了PlayerActivity日益膨胀的代码,且由于当时各业务模块之间相互独立,UI控件与Controller之间保持了N : 1的关系;此时各Controller的职责相对单一,各模块之间依赖关系简单,数据流也是自上而下的单向数据流,代码复杂度相对可控,因此该架构在一段时间内也很好的支撑了业务的发展。

图 4: SubController之间简单的依赖关系和数据流动

但是随着q音内容的多元化,播放页的业务模块越来越多,不同的业务模块之间也开始相互影响,上述SubControllers的模式开始显示出其不足,为后续的维护和扩展带来了困难:

  1. 由于不同的业务模块之间开始相互影响,以及没有及时对这种影响进行代码层面的管控,原来简洁的依赖关系和数据流动逐渐恶化成了下图混乱的结构,这导致代码理解困难,根据代码很难还原一个UI控件的更新逻辑,bug频出:

图 5: SubController结构的恶化

  • 数据流混乱: 不同Controller之间开始出现相互调用,数据流不再是简单的自上而下,开始横向在SubControllers之间流动
  • Controller和UI控件开始出现多对多关系,一个View可能在多个Controller里被更新,进一步增加了代码的理解成本: 由于ViewHolder持有了所有的View,在快速开发过程中,各Controller很容易扩大自己的职责,绕过对应业务的Controller直接从ViewHolder中更新其他View; 这导致为了梳理一个UI控件的更新逻辑,除了需要关注对应Controller复杂的API调用链外,还需要关注viewHolder中对应View的所有引用场景。

  1. 所有SubControllers的创建和销毁均跟随PlayerActivity的生命周期,拖垮了播放页的性能,增加了业务之间的不必要耦合:

图 6: 所有SubController在播放页内单例在播放页的众多业务中,很多业务是有出现条件的,并不需要在在PlayerActivity初始化时创建:如PortraitController只需要在用户选择写真模式时才需要运行;同时很多业务之间存在互斥或包含关系:比如PortraitController与AlbumController之间存在互斥关系,展示写真模式时一定不展示专辑图;又如AlbumController是AlbumVideoPageController运行的必要条件:只有在专辑图存在且当前播放页支持进行视频推荐时才需要AlbumVideoPageController运行; 而由于SubControllers之间混乱的依赖关系,我们很难将一个SubController与其他业务隔离开来,其结果就是所有的SubControllers,无论是否需要,都不得不在PlayerActivity初始化时被创建,这导致:

  • 播放页的性能被拖垮: 影响播放页的启动速度; 浪费内存、IO以及网络等系统资源;
  • 代码复杂度进一步增加
    • 由于SubController存在于整个播放页的生命周期,每个SubController不得不处理自己原本不需要处理的场景 比如当PortraitController被调用更新写真View API时,需要做的不仅仅是加载图片并显示写真View,而是首先要判断当前播放页是否需要展示写真图。
    • 增加了不同Controller之间不必要的耦合 比如AlbumVideoPageController准备进行视频页展示时,需要通过PortraitController排除写真模式的场景写真模式。

    图 7: AlbumVideoPageController与PortraitController之间不必要的耦合

  1. SubController内部状态的更新依赖于其API被PlayerActivity正确调用,代码不够自洽且使用成本高
    • 由于不同controller根据其业务的不同API也不尽相同,PlayerActivity为了正确的使用一个controller,必须理解其API的调用要求,很容易出现调用时机不当导致bug。
    • SubControllers的不断增多,导致PlayerActivity对SubController的管理逻辑越来越复杂,代码量也不可避免的开始二次膨胀。
  2. SubController与某些具体的View对象绑定,导致UI层逻辑无法复用 比如在对电台播放页进行差异化时,收藏按钮的位置与普通播放页有不同的要求: 为了实现该需求,我们需要在XML中新增一个电台的收藏按钮Widget,在VIewHolder中新增一个对应的View引用,然后FavoriteButtonController中所有更新歌曲收藏按钮的位置,增加根据当前是否处于电台还是歌曲而选择不同收藏按钮进行更新的逻辑; 这个过程及在后续的维护过程中中很容易出现遗漏导致bug。
  3. 其他问题:
    • PlayerActivity的布局文件越来越复杂: 由于业务逻辑没有按需加载,View控件也很难做到按需加载,这导致所有要展示的View全部需要预埋到XML中,导致inflate和layout耗时,浪费内存;
    • SubController没有显式的生命周期,大多只有一个构造函数,缺乏明确的销毁逻辑,很容易造成内存泄漏。
    • SubController对Model层的具体Manager进行直接依赖,导致数据源发生变化时,需要全局修改代码。

改变

面对日益多样化的需求,原来的架构中存在的问题被逐渐放大,导致bug频出,开发效率也逐渐被拖垮;最终,我们决心对播放页的代码结构进行调整,希望在能解决上述技术债的同时,为未来快速迭代新的需求打下基础。

目标:

  • 对同一页面内不同业务进行模块化
    • 实现各业务模块解耦,支持模块复用
    • 实现各业务模块按需加载和移除,避免性能浪费,减少不必要的耦合
  • 对View/业务/数据层逻辑进行分层和解耦,实现各层代码单独复用
  • 提升代码的可读性和扩展性

代码设计

页面内不同业务模块化的实现
模块化建模

根据我们页面模块化的目标及播放页的业务特点,我们在进行模块化单元设计时,首先描绘了如图8所示的理想模块化架构并提出了如下设计目标:

图 8: 播放页理想模块化架构

  1. 同级模块之间互不依赖 如在图8中,只有写真模式模块和专辑图模式模块两者互不依赖,我们才能根据用户设置选择性地加载其中一个模块,而不影响模块代码的正常运行。
  2. 模块单元应该以业务为核心,能够根据业务需要进行创建和嵌套 如视频推广模块仅在专辑图存在的情况下才需要加载,为了体现两业务之间的关系,我们可以创建一个父模块来表征两个模块之间的业务联系,这个父模块在View树上没有具体的UI呈现,而是两个模块业务关系的体现。
  3. 子模块对父模块的要求应该全部通过依赖的形式体现: 只要一个模块的依赖能被满足,其就能在任一场景下使用: 如播控模块,只要为其注入所需依赖,它就能被用在写真模式模块或专辑图模式模块中。
  4. 每一个模块都是自洽且易复用的,应该完全对自己的状态负责
    • 为了保证易用性,每一个模块在创建之后对使用方来说是没有差别的,即模块的API应该是统一且固定的。
    • 为了使每个模块专注于自身状态的管理,模块应该根据依赖注入的数据源自己实现内部状态的流转,不对外暴露自己的状态。
  5. 模块之间的交流通过共同依赖实现:
    • 同级模块之间的数据共享和通信,可以通过二者共同的父模块注入的共同依赖实现。
    • 父模块与子模块的交流则可以通过父模块注入给子模块的依赖实现。
  6. 提供满足业务需求的生命周期回调 如在实际开发中,往往需要对模块的曝光情况进行上报,因此需要为模块提供统一的可见性生命周期回调。 又比如为了防止内存泄漏,需要为模块提供开始和结束运行的生命周期回调。
ViewDelegate:一个轻量化的页面内模块化单元

按照上述要求,我们考察了android官方提供的fragment和View,以及我们之前使用的Controller方案,发现都不能很好的满足我们的需求:

  • fragment: 支持复用和按需加载,但对嵌套的支持性很差。
  • View: 支持复用和按需加载,且对嵌套的支持性较好; 但是每层嵌套都要增加View的层级,嵌套太深影响布局性能; 生命周期过于简单(只有创建/attatchWindow等),不满足业务需求; 限制了业务模块一定要与当前View树中的View绑定,限制了使用场景,不满足弹窗或无UI需求模块的需要;
  • Controller: 没有统一API和代码规范,管理困难; 依赖混乱,每个单元之间不是独立的,不能单独复用; 不支持嵌套;

由于现有方案都不满足我们的需求,根据上述要求,我们提出了一个轻量化的页面内模块化单元——ViewDelegate:

图 9: ViewDelegate——轻量化的页面内模块化单元

  1. ViewDelegate作为桥梁,对Android系统组件复杂的生命周期进行了抽象,为业务提供了满足需求且含义清晰的四个生命周期回调onBind/onVisible/onInvisible/onUnbind,保证ViewDelegate的生命周期跟随对应的Activity、Fragment或父ViewDelegate,使业务可以专注自身业务的执行。
  2. ViewDelegate以业务逻辑为核心,不受限于UI元素:一个ViewDelegate可以对应0、1或多个UI元素:

对于不需要View Widget的业务模块,只需在ViewDelegate对应的生命周期填充逻辑即可;对于需要进行UI展示的业务模块,ViewDelegate提供了三种形式:

  • 通过构造函数注入预埋在XML或父模块中的UI Widgets,由ViewDelegate进行数据绑定:  如播放暂停模块,只需外界传入一个ImageView,由ViewDelegate负责根据播放状态设置ImageView的drawable、为其设置点击事件与播放器交互等。
  • 通过构造函数注入parent container,由ViewDelegate按需添加View: 如专辑图区域,由外界传入Container,ViewDelegate负责Inflate自己的所需的View并进行数据绑定。
  • 对于弹窗和tips等类型: 通过构造函数注入Context即可。

  1. ViewDelegate支持任意层级的嵌套,鼓励根据业务之间的关系组织模块代码:
    • 本质上ViewDelegate只是一个提供生命周期回调的简单Java对象,对资源和性能的影响可以忽略,因此只要业务需要,即可创建一个的ViewDelegate
    • ViewDelegate原生支持管理child ViewDelegate的生命周期,保证子ViewDelegate的生命周期跟随父ViewDelegate
为了实现模块自洽,ViewDelegate与外界的数据交互通过响应式编程实现

在面向对象的编程方式下,模块A为了更新模块B的状态,往往是在A内部调用B的API进行状态的更新:如为了更新专辑图模块中的专辑图,我们需要在其父模块内调用专辑图模块的updateAlbum()方法;这种情况下,父模块是主动的,专辑图模块是被动的:专辑图模块的内部状态的流转由父模块负责,专辑图模块并不能知道影响其内部状态的因素有哪些。这种方式存在的问题是:

  • 为了让外部更新其内部状态,每个模块必须根据各自状态的不同,将自己内部的状态通过不同的API暴露出来
  • 模块内部状态的正确流转依赖外部正确调用其API,增加了外部使用模块的成本
  • 为了能够理解模块内部状态是如何流转的,我们必须探索其API的调用链,增加了模块的理解难度

而采用响应式编程的方式,即让ViewDelegate监听外界状态的变化,当外界状态发生变化时,由ViewDelegate自己管理自己内部的状态,这样带来的好处有:

  1. 实现依赖反转: 每个ViewDelegate通过对外界状态变化的响应,实现对自己内部的状态完全负责;
    • 在实现一个ViewDelegate时只需关注自身功能的实现而不必关心与其他模块的交互
    • 为了理解一个ViewDelegate的状态是如何流转的,只需查看其内部代码,无需关心数据的来源和变化的原因
  2. ViewDelegate不再需要通过API暴露自己内部状态,为统一API提供可能; 外界在使用模块时,只需为其提供可供监听的依赖并保证其生命周期回调被正确执行即可,不再需要手动更新模块内部的状态;

在Android开发背景下,我们调研了实现响应式编程可选办法:

  1. RxJava:
    • 优点: 支持冷热Observable,对数据流的操作有丰富的API支持;
    • 缺点: 上手困难,需要手动注册和解注册,源码复杂问题定位困难,需要手动实现支持replay;
  2. EventBus:
    • 优点: 上手简单;
    • 缺点: 需要手动注册和解注册; API简陋; 设计思想基于事件和时间,缺乏数据流的概念,不支持Cold Observable;
  3. LiveData:
    • 优点: 源码简单,上手简单,引入风险小; 支持跟随模块的生命周期自动注册和解注册,可有效避免内存泄漏; 模块注册后可以自动获得最新的数据源,避免了在模块初始化时提供各个数据源的初始值;
    • 缺点: 对数据流操作的API支持不是很丰富;

根据我们的需要和使用场景,我们最终选择了LiveData作为ViewDelegate的可响应数据源来实现响应式的ViewDelegate;针对LiveData操作符缺乏的情况,我们在实际使用过程中进行了按需定制。

ViewDelegate及响应式编程的设计规范使用举例:
  • 如图10中PlayButtonViewDelegate的实现,在编码过程中我们只需定义好该模块所需依赖:承载自身View的viewGroup,以及requireDependencyViewModel——以LiveData为API的可响应数据源;然后专注于实现自身的状态管理和UI展示对数据源的响应即可,无需对外暴露自己的内部状态和关心依赖来源。

图 10: 一个简单的ViewDelegate的实现

  • 一行代码管理子ViewDelegate:如在图11中,父ViewDelegate只需要一行代码就可实现子ViewDelegate的加载和生命周期的跟随,不再需要理解子模块内部状态的流转和复杂的API调用要求。

图 11: 一行代码管理子ViewDelegate

代码分层的实现

  1. 首先我们参考了Uncle Bob的经典文章clean architecture,按功能将代码划分为Persentation/Domain Logic/Data Access三层,同时以业务逻辑(Domain Logic)为核心,通过面向接口(interface)编程使Domain Logic独立于Presentation和Data Access Layer:

图 12: 整体分层设计

  1. 其次对Presentation按照MVVM架构进行了分层 由于我们选用了响应式的编程规范来实现ViewDelegate,因此在对Presentation进行分层时,我们很自然的选择了MVVM架构:

图 13: Presentation层MVVM分层

  • View层由XML和ViewDelegate实现: XML负责UI控件的布局和呈现,ViewDelegate负责将ViewModel和View控件绑定。
  • ViewModel:负责页面路由和将DomainLayer的数据源转换成View层所需状态的LiveData;
  • Model层: 为了避免过度抽象,Presentation层的model大部分复用的是Domain Model的; 只有在Domain Model与Presentation层状态差异较大我们才会去抽象出Presentation Model
    • 比如SongInfo在qq音乐内是一个很基础的数据对象,在Presentation层对其进行二次抽象显然收益不大;
    • 又如播放页的UI样式受歌曲、列表、应用主题等Domain Model的共同影响,为了简化UI层的逻辑,我们抽象了新的Presentation Model——PlayerStyle来表示当前播放页UI的特点:

    图 14: Presentation建模举例

  1. 除了上述横向分层外,我们在具体实现各层的时,根据单一职责和依赖最小原则,按需对各层进行了竖向拆分,以对Domain Layer和ViewModel的拆分为例:
    • Domain Layer我们使用了Repository & UseCase对业务逻辑进行拆分
    • 为了避免不同业务的ViewModel相互耦合,我们按照界面隔离原则(interface segregation principle)对ViewModel进行了拆分:

    图 15: 对IPlayerVIewModel进行界面隔离如在播放页内,所有模块都需要的基本信息包含了播放信息、魔法色信息两个大类,为了防止这两个功能无关的信息耦合在一个ViewModel内,我们通过Interface对不同的数据源ViewModel进行了拆分,然后通过kotlin的委托模式语法糖来实现相应接口:

    图 16: kotlin by关键词快速实现委托模式

编码

  1. 在完成代码设计后,我们按照上述规范对播放页进行了渐进式改造,首先是按照类似图8中模块划分和组织方式,按照上述规范将各个模式的功能迁移到了ViewDelegate中。
  2. 完成对业务模块的拆分之后,我们对播放页代码进行了整体分层,示意图如下:注意为了控制工期和避免过度抽象增加冗余代码,对于端内基础的、显然没有替换和更改需求的数据源如SharedPreferences我们并没有进行接口抽象。

图 17: 播放页代码整体分层

  1. 最后,在具体代码实现时,我们按需对代码进行了分层,以X模块为例,其代码结构拆分如下:

图 18: ViewDelegate代码实现

  1. 在模块具体使用时,我们首先在模块的父节点寻找其所需依赖,对于尚未存在的依赖,我们合适的父节点进行按需构造: 如在准备图18中X模块的依赖时,由于在模块树的根节点PlayerActivity中已经构造了IPlayerBaseViewModel实例,因此我们只需要将改实例传递至X模块的父节点,然后在其父节点使用该依赖对XViewDelegate进行组装即可:

图19:XXViewDelegate依赖的来源

  1. 最后,在展示播放页时,我们根据当前播放页的要求,对各个模块进行了按需加载和卸载:以播放页背景为例,在不同的场景下,播放页背景有魔法色、主题图、视频、写真等模块,我们在拉起播放页时,根据当前PlayerStyle的要求,对各个背景模块进行了选择性加载:

图 20: 播放页按需对不同的背景模块进行加载

结果

  1. 满足了播放页业务增长需要 改造完成后,QQ音乐的播放页又经历了多轮迭代,业务模块和UI样式快速增加,上述代码设计很好地实现了业务建模、代码复用和需求的快速迭代:

图 21: UI和业务多变的播放页

  • 每种样式的播放页只会加载自己需要的模块,避免了资源浪费
  • 为了保证播放页的启动速度,在播放页启动过程中,对模块进行了分步加载: 优先加载重要的UI的模块,启动完成后再去加载其他模块; 我们还根据机型等级对模块加载进行了精细化控制,在低端机下,在启动完成前我们只加载了极少数必要的ViewDelegate
  • 代码复用:比如上图中不同播放页UI样式中收藏按钮和收藏人数的展示,全部使用的是下图中的FavorButtonViewDelegate实现的:只要父ViewDelegate为其提供合适的container和所需可响应数据源,其就能按需展页面内的不同位置进行模块UI的展示

图 22: PlayerSongFavoriteButtonViewDelegate

  • 业务建模:通过建立ViewDelegate模块树,隔离了无关模块,减少了业务不必要的耦合

图 23: 播放页ViewDelegate树状图如不同样式播放页的背景,都是一个个独立的模块,在实现各个背景模块时,无需考虑各个背景之间的冲突,只需在播放页启动时根据展示需求按需加载即可  又如图21第6个样式中的倍速播放按钮,只会在收听长音频时展示,因此其在处理点击事件时,无需考虑当前不是长音频的场景

  • 灵活的数据源替换:如在图21中1、3、4、7的播放页样式下,使用的是不同的魔法色规则,面对魔法色规则的差异,我们只需在底层构建PlayerBaseViewModel时替换其魔法色接口的实现类,无需修改PlayerBaseViewModel的内部实现

图 24: 根据PlayerStyle使用不同的IPlayerMagicColorVIewModel实现

  • 保证了播放页的性能:

  1. 在多个页面得到了推广: 使用ViewDelegate对页面进行模块化、使用LiveData实现ViewDelegate自恰性的编码规范,在QQ音乐业务内多个业务得到了推广,如直播、一起听房间、扑通小组等页面; 目前QQ音乐端内不同页面共实现了将近200个ViewDelegate,很好的满足了业务的需求;

思考

  • 架构没有银弹,在业务逻辑比较简单时,MV*结构都可以满足需求,甚至没有明确的编码规范都可以; 但是当页面逐渐复杂化之后,问题会逐渐暴露,这时要见招拆招,不要拘泥于某个架构,要从问题本身和基本指导原则(如SOLID)出发,去寻找最适合自己的架构。
  • 不要过于追求完美,在迁移历史代码的过程中,要有所妥协: 是所有的依赖都要注入还是只需注入需要共享或在可见的未来会发生变化的依赖、是要追求跨app的完全复用还是在某个app的某个页面内部复用即可,是否要追求完美的响应式编程还是在基础库不满足要求时回退到常规方式等等,都要根据实际情况进行判断。 要在遵循架构规范、保证代码质量的同时,兼顾需求的进度。

QQ音乐招聘Android/ios客户端开发,点击左下方“查看原文”投递简历~

也可将简历发送至邮箱:tmezp@tencent.com

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-07-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯音乐技术团队 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 背景
    • MVC
      • SubControllers
      • 改变
        • 目标:
          • 代码设计
            • 页面内不同业务模块化的实现
          • 代码分层的实现
            • 编码
            • 结果
            • 思考
            相关产品与服务
            云直播
            云直播(Cloud Streaming Services,CSS)为您提供极速、稳定、专业的云端直播处理服务,根据业务的不同直播场景需求,云直播提供了标准直播、快直播、云导播台三种服务,分别针对大规模实时观看、超低延时直播、便捷云端导播的场景,配合腾讯云视立方·直播 SDK,为您提供一站式的音视频直播解决方案。
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档