一个音乐播放器的设计流程,含完整实例

前言

上篇文章给大家分享了我的WanAndroid项目,简要描述了一下项目的整体结构。由于作者非常喜欢听音乐,鉴于对音乐的情怀,就在一个技术社区App中强行加了一个音乐播放功能。关于这个播放器的设计,我也花了很多心思,应用了很多设计模式,今天就逐个给大家进行分型。

贴一下上篇文章的链接:https://juejin.im/post/5f09ac336fb9a07e93303162

先细心阅读,文末会给出github链接

1. 需求背景

设计一个音乐播放功能,包含一个播放界面,包含播放/暂停、上一首、下一首、播放列表、播放模式、进度更新等,返回到首页又一个悬浮窗包含播放状态、音乐信息,大概就长下面这样:

image

当前这是第一版,功能比较简单,后面我会持续完善。

2. 定义播放器

第一步肯定要先来定义一个音乐播放器,这里采用的是Android原生提供的MediaPlayer,使用MediaPlayer之前先考虑一个事情,可以直接将MediaPlayer定义在业务中吗?答案是否定的,为什么?大概有如下几点:

  • 如果将MediaPlayer与业务进行耦合,每次做修改势必会影响到业务,进而会产生不可预期的错误。
  • 如果某一天需要将MediaPlayer替换,定会牵扯到大量代码,那代价会相当之大.

如何解决上述问题?其实我们可以先设计一个接口,该接口包含一个音乐播放器所有功能。如下

interface IPlayer {
    ...
    ...
    /**
     * 播放新的音频
     * @param path 本地路径
     */
    fun play(path: String)

    /**
     * 播放
     */
    fun resume()

    /**
     * 暂停
     */
    fun pause()

    /**
     * 停止播放,释放播放内容
     */
    fun stop()
    ...
    ...
}

本篇文章会侧重讲设计思想,所以为了节省篇幅我只会贴出部分关键代码,下同。

定义一个类MediaPlayerHelper实现IPlayer接口,内部通过MediaPlayer实现每个方法对应的功能,并基于IPlayer接口编程。这种写法其实就是设计原则中的基于接口而非实现编程,好处就是隐藏了具体实现,修改具体实现不会影响到上层业务。并且符合开闭原则(对 扩展开放、修改关闭),如果需要对MediaPlayer替换,直接写一个类实现IPlayer接口中的功能,然后对目标类做替换即可。用一段代码表示:

 private val playerHelper: IPlayer = MediaPlayerHelper()
 //替换为
 private val playerHelper: IPlayer = XXPlayerHelper()

关于这部分完整代码可至package com.zs.base_library.play(包名)目录下参考

3. 状态管理

一个音乐播放器通常要与整个App的生命周期保持一致,并且多处UI状态如:Notification播放页首页悬浮必须保持一致,所以此时我们需要一个单例来维护播放状态与音乐信息,这个单例大概长这样:

class PlayerManager private constructor(){
    //单例创建
    companion object {
        val instance: PlayerManager by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
            PlayerManager()
        }
    }
    ....
    private val playerHelper: IPlayer = MediaPlayerHelper()
    
    fun pause() {
       ...
       playerHelper.pause()
    }
    
    private fun resume() {
       ...
       playerHelper.resume()
    }
    ...
    ...
}

PlayerManager由单例模式实现,内部包含了所有IPlayer方法,通过PlayerManager指使IPlayer去实现具体的播放操作,并且生命周期与App进程一致,可在App任意地方操作播放器。

PlayerManager作用大概有如下几点:

  • 将目标对象IPlayer与具体业务进行隔离
  • 内部维护了观察者(后面会讲到),对状态同一做分发。
  • 统一管理播放模式播放列表

结合其特性分析,PlayerManager实则也是一个代理类。如果想对音频播放做一些附加操作,比如记录播放日志、播放时长等,都可以统一在PlayerManager中实现。

为了进一步符合单一设计原则播放列表之类的我都抽成了单独的类去管理。完整代码可至package com.zs.zs_jetpack.play(包名)下参考

4. 状态分发

基于上面的设计,我们如何在具体的界面做UI渲染与交互呢?

首先来尝试第一种方案:

  • 进入到具体的Activity/Fragment,通过PlayerManager获取到播放信息与状态,填充到具体的View
  • 当播放状态、信息改变时如点击了暂停按钮,通过PlayerManager暂停音频,然后手动将对应的View置为暂停状态

关于上述方案,如果只有播放/暂停一个按钮没啥问题,大胆去使用吧。但实际上我们面临的时多界面中的多操作,加一块有一二十个,而且不同界面的状态信息也必须保持一致,还按照这种方式去写,相信我,你会欲死欲仙的。

关于这种方案,我的答案是:弃之

第二种方案

第一种方案面临的问题是:状态UI容易产生一致性问题。那么我们能不能做一种设计,播放让状态去驱动UI?

何为状态驱动UI:

顾名思义,就是播放状态改变后第一时间通知到视图层,视图层只做UI渲染。在此背景下我又想到了另一种设计模式观察者模式,只需将视图层定义为观察者,随后与被观察者PlayerManager进行绑定,统一由PlayerManager下发播放信息,视图层拿到状态第一时间对UI进行渲染。

捋清了思路我们来做代码上的设计

首先定义一个观察者接口:

interface AudioObserver {

    /**
     * 歌曲信息
     * 空实现,部分界面可不用实现
     */
    fun onAudioBean(audioBean: AudioBean){}

    /**
     * 播放状态,目前有四种。可根据类型进行扩展
     * release
     * start
     * resume
     * pause
     *
     * 空实现,部分界面可不用实现
     */
    fun onPlayStatus(playStatus:Int){}

    /**
     * 当前播放进度
     * 空实现,部分界面可不用实现
     */
    fun onProgress(currentDuration: Int,totalDuration:Int){}

    /**
     * 播放模式
     */
    fun onPlayMode(playMode:Int)
}

实现了该接口就可被视为观察者

被观察者是PlayerManager,当内部状态发生改变时统一通知到观察者对象。关于管理观察者的代码大概是这样的:

class PlayerManager private constructor(){
     /**
     * 音乐观察者集合,目前有三个
     * 1.播放界面
     * 2.悬浮窗
     * 3.通知栏
     */
    private val observers = mutableListOf<AudioObserver>()
    
    private fun resume() {
        ...
        playerHelper.resume()
        //状态改变,通知观察者
        sendPlayStatusToObserver()
    }

    private fun pause() {
        ...
        playerHelper.pause()
        //状态改变,通知观察者
        sendPlayStatusToObserver()
    }
    
    /**
     * 给观察者发送播放状态
     */
    private fun sendPlayStatusToObserver() {
        observers.forEach {
            it.onPlayStatus(playStatus)
        }
    }
    
     /**
     * 注册观察者
     */
    fun register(audioObserver: AudioObserver) {
        observers.add(audioObserver)
        //TODO 注册时手动更新观察者,相当于粘性通知
        notifyObserver(audioObserver)
    }

    /**
     * 解除观察者
     */
    fun unregister(audioObserver: AudioObserver) {
        observers.remove(audioObserver)
    }

    /**
     * 手动更新观察者
     */
    private fun notifyObserver(audioObserver: AudioObserver) {
        ...
        ...
    }
}

有了上面的骨架代码,将Notification播放页首页悬浮实现AudioObserver接口并且与PlayerManager进行绑定,在对应的方法中做视图渲染,这样就可以实现状态驱动UI,解决了状态UI的一致性问题。同时可以基于此模式在任意处做播放信息的视图展示,对扩展开放,修改关闭,无处不在的开闭原则。

数据绑定

关于这个项目我使用到了Jetpack中的DataBinding,将状态数据View进行绑定,当几个观察者观察到PlayerManager状态改变时,由DataBinding自动渲染到View中。

到此我们就实现了一个真正的状态驱动UI,从状态分发到UI渲染之间没有任何多余操作,一气呵成~~

关于PlayerManagerAudioObserver代码在package com.zs.zs_jetpack.play(包名)目录下

综上所述

  • 通过基于IPlayer接口编程,将功能组件与业务做隔离
  • 通过单例实现PlayerManager使播放信息在进程中共享
  • PlayerManager实则也是一个代理类,将播放逻辑与具体业务进行隔离,并且在内部统一管理了观察者播放列表
  • 基于观察者模式,解决了状态UI的一致性问题
  • 最后由DataBinding状态数据View进行绑定,真正的实现状态驱动UI

最后附上github地址:https://github.com/zskingking/Jetpack-WanAndroid

关于播发模块的代码在项目中的路径,文章中已经给出,请仔细阅读。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,539评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,594评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,871评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,963评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,984评论 6 393
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,763评论 1 307
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,468评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,357评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,850评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,002评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,144评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,823评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,483评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,026评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,150评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,415评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,092评论 2 355