单例模式

第25章 单例模式

单例模式在所有的设计模式当中属于一种最为简单的‌‌模式‌‌。不仅如此,‌‌单例模式在游戏或者应用‌‌开发当中,也算得上最为常见的一种模式。这一模式的目的是使得类的一个对象成为系统中的唯一实例。在‌‌确保一个类只有‌‌一个实例的同时提供一个全局访问的入口。‌‌

动机

对于系统中某些类来说,只有一个实例非常重要。例如,在游戏中存在一个全局的计时器用来计算当前玩家流逝的时间,亦或是一个全局的随机事件生成器用来随机生成某些特殊事件。如果不适用某些机制来对这些对象进行唯一化,那可能导致多个副本对象同时触发从而在不同模块中得到的结果不一致。

如何保证一个类只有一个实例并且这个实例易于被访问呢?定义一个全局变量可以确保对象随时都可以被访问,但不能防止我们实例化多个对象。一个更好的解决办法是让类自身负责保存它的唯一实例。这个类可以保证没有其他实例被创建,并且它可以提供一个访问该实例的方法。这就是单例模式的模式动机。

优势

构建方式

单例模式拥有两种构建方式:

基本实现

下面我们就来实现一个最基本的单例模式。

class Singleton {
    private static singleton: Singleton;

    public static getInstance(): Singleton {
        if (!Singleton.singleton) {
            Singleton.singleton = new Singleton();
        }
        return Singleton.singleton;
    }
}

当前Singleton类是一个单例模式。在使用该类的时候,你应该调用其getInstance()方法,以获取全局唯一的Singleton类实例。在函数内,会先判断私有静态变量singleton对象是否被初始化。如果他的值为null,则对其进行实例化操作。确保singleton对象被创建后返回该对象。

这个例子使用了懒汉构建方式,只有当getInstance方法被调用后才会创建对应的实例。类似的代码,再来看一下饿汉构建方式。

class Singleton {
    private static singleton: Singleton = new Singleton();

    public static getInstance(): Singleton {
        return Singleton.singleton;
    }
}

由代码中可见,所谓饿汉构建方式是在定义私有静态变量singleton对象的时候直接对其进行初始化操作。而在getInstance方法中无需进行任何判断以及初始化操作,直接将其返回即可。

上面的两端代码看似简单,实际还存在一个极为严重的问题。单例模式要求当前类在全局中存在并仅存在一个唯一实例。而现有代码,允许在其他类中进行初始化操作。例如执行如下代码是完全没有问题的。

let a:Singleton = new Singleton();

这种行为完全违背了单例模式的设计原则。为此我们应该稍微修改一下相关代码,让其符合我们的要求。在TypeScript中,当使用new关键字时,实际上是在调用对应类的constructor方法。默认情况下constructor方法是public权限。只需要将其定义为private即可,这样外部就无法实例化该类,完整代码如下:

class Singleton {
    private static singleton: Singleton;

    private constructor(){
    }

    public static getInstance(): Singleton {
        if (!Singleton.singleton) {
            Singleton.singleton = new Singleton();
        }
        return Singleton.singleton;
    }
}

内存控制

单例模式使用简单,但在内存控制方面却要非常小心。因为在全局任意地方我们都可以访问类的唯一实例,这将导致其他模块对单例类的依赖变得更多。当架构代码需要调整的时候,这将是一场灾难。与此同时,对于单例类的随意使用也会时整个架构变得难以维护。为此你可以借助其他设计模式来避免类似问题,以保证控制对单例类的引用在一定范围内。

在调用单例类接口时,应该像下面的代码一样去访问类中的接口。

Singleton.getInstance().play();

其中play方法为类中定义的方法名。切记在类中不应定义类内变量,并持有单例类实例的操作方法。错误演示如下:

class Main {
    private singleton: Singleton;
    constructor() {
        this.singleton = Singleton.getInstance();
    }

    public play(): void {
        this.singleton.play();
    }
}

上面这段示例代码中,将单例类Singleton的唯一实赋值给当前类的singleton变量中。此操作方法会导致内存上Main实例对象持有Singleton实例对象的引用。而Singleton实例对象在生命周期中不会被GC掉,导致Main实例对象无法被清除。所以使用过程中,切记对单例类对象进行类内缓存。只要保证对象的调用在函数内即可,函数执行完毕后,其栈内对象会被彻底释放。

过多的耦合

单例模式简单而且使用方便,你可以在任意代码地方使用类似Singleton.getInstance().play();这样的语句去调用它。也正是由于这样的全局性,导致在代码的各个地方都会对单例类有所引用,造成过多的耦合。而解决该问题的方法是可以将它包装到一个模块的公共接口当中。

例如,在游戏逻辑控制模块存在一个GameContext的对象引用。只要我们编写逻辑控制相关模块,都可以在类中访问到该对象。那么我们可以在GameContext类里面封装一个getSingleton的方法,从而避免在逻辑类中直接引用Singleton类。

class GameContext {
    public getSingleton():Singleton {
        return Singleton.getInstance();
    }
}

假设PlayController类是我们的一个逻辑控制器,这个类中可以通过this.context的方法获取GameContext实例对象,那么当需要调用Singleton中play方法,可以通过如下方式:

this.context.getSingleton().play();

通过这种方式可以有效避免代码中存过多的耦合。

对异步不友好

如果你编写的是Java或者C#等存在多线程处理的代码,那单例模式绝对会是你的灾难。在并发操作的情况下,单例类设置了全局变量,必然会创建一块内存区域,每个线程都可以访问和修改它,不管它们是否知道其他线程正在访问或者操作。这可能导致死锁,条件竞争和其他的一些难以修复的线程同步Bug。

值得庆幸的是,你在使用TypeScript或者JavaScript的时候,并没有多线程处理的方法(微信小游戏提供Worker这种线程的操作,但很少有人用到它)。但绝大部分操作都是异步操作,这也令单例模式使用起来有些困难。

如果将一个存在异步操作的内容封装到单例类中,那么外界将无法得到响应回调。它只能绑定在单例类内部,或许你可以将响应回调函数注入到单例类中,但这样的操作又会引起更多的引用,导致内存管理变得棘手。

我们经常在一些游戏代码看到各种“manager”类,例如SceneManager、ParticleManager、MonsterManager等等。但在开发过程中,需要真实的考虑,这些类是否真的使用单例模式合理。它们真的需要单例模式来维护一个全局对象才能实现想要的效果么?

最好的方式在于,单例类处理一个由外部控制,内部实现的操作。而外部只需要调用对应接口,即可实现想要的功能。同时不用关心操作后异步结果如何,这种情况下使用单例模式较为合适。

单例模式的继承

单例的继承是一个经常被人忽视的特性,有时候它真的非常有用。我们可以借助继承来重写单例类中一些API的行为,而其父类则变相等同于接口。举一个简单的例子。

当我们要实现感知通知操作的时候,在微信小游戏平台中,需要调用震动操作来达到提醒用户的效果。而发布到HTML5网页平台时,并没有标准的震动接口。此时可能替换为播放某一个音频的操作。这就出现了平台差异,通过继承可以实现我们想要的效果。

建立一个单例类Notice,其中存在一个notice的接口,负责实现通知行为。但在当前父类中,该接口不做实现为了子类重写使用。

class Notice {
    private static _notice:Notice;
    protected constructor(){
    }

    public static getInstance(): Notice {
        if (!Notice._notice) {
            Notice._notice = new Notice();
        }
        return Notice._notice;
    }
    
    public notice():void {
    }
}

下面来针对微信小游戏平台和HTML5网页平台实现格子的子类。

微信小游戏平台

class WXGameNotice extends Notice {
    public notice():void {
    	//微信小游戏震动API调用(可参考wx.vibrateShort)
    }
}

HTML5网页平台

class HTML5Notice extends Notice {
    public notice():void {
    	//音频播放逻辑
    }
}

最后我们使用懒汉构建方式针对不同平台实例化不同的静态对象,修改Notice类中的getInstance函数。

class Notice {
    private static _notice:Notice;
    protected constructor(){
    }

    public static getInstance(): Notice {
        if (!Notice._notice) {
        	switch(platform){
            	case "wxgame":
            		Notice._notice = new WXGameNotice();
            		break;
            	default:
            		Notice._notice = new HTML5Notice();
            		break;
    		}
            
        }
        return Notice._notice;
    }
    
    public notice():void {
    }
}

随着一个简单的目标平台判断,我们将通知封装绑定到具体类型上。在其他代码逻辑中,可以使用Notice.getInstance().notice来调用通知操作。业务逻辑中不比和任何平台的代码发生耦合,也不用考虑当前所在平台是什么。

示例:声音管理器

单例模式在设计的时候一定要注意其功能单一性。如果我们设计了一个单例类,而它又承载了非常多的功能,这将导致代码的可维护性降低,同时也会导致逻辑功能划分混乱。所以在设计单例模式的时候,应该尽量‌‌要封装‌‌相对比较干净的功能,‌‌以保证接口的简单高效。

在这一小节中,我们将设计一个声音管理器。该管理器负责游戏中背景音乐和音效的操作与设置。游戏中所有和音频相关的播放暂停以及调整音量都应该调用此管理器的接口,而不应该直接操作egret.Sound对象。

声音类封装

首先我们需要对音频类进行一次封装,为其提供播放状态和恢复播放等能力,代码如下:

class Voice {

    private _isPlay: boolean;
    private _channel: egret.SoundChannel;
    private _sound: egret.Sound;
    private _position: number = 0;

    public constructor(sound: egret.Sound) {
        this._sound = sound;
        this._isPlay = false;
    }

    public setVolume(value: number): void {
        if (this._channel) {
            this._channel.volume = value;
        }
    }

    public get isPlay(): boolean {
        return this._isPlay;
    }

    public play(startTime: number = 0, loops: number = 0): egret.SoundChannel {
        this._isPlay = true;
        if (this._channel) {
            this._channel.removeEventListener(egret.Event.SOUND_COMPLETE, this.playComplete, this);
        }
        this._channel = this._sound.play(startTime, loops);
        this._channel.addEventListener(egret.Event.SOUND_COMPLETE, this.playComplete, this);
        return this._channel;
    }

    private playComplete(evt: egret.Event): void {
        this._isPlay = false;
    }

    public stop(): void {
        this._isPlay = false;
        if (this._channel) {
            this._position = this._channel.position;
            this._channel.stop();
        }
    }

    public resume() {
        this.play(this._position);
    }

    public dispose(): void {
        if (this._channel) {
            this._channel.stop();
            this._channel.removeEventListener(egret.Event.SOUND_COMPLETE, this.playComplete, this);
        }
        this._channel = null;
        this._sound = null;
    }
}

在Voice初始化时,我们需要直接出入Sound对象。音频的播放能力来源于Sound对象,而该对象并不从Voice类中直接创建。究其原因在于Egret引擎中的RES模块会为我们提供一个Sound对象,所以创建操作可以不放在Voice类中。

Voice类中的播放接口,参数和Sound类play接口参数一致。我们需要在play接口中,实现对于当前播放状态的切换。同时保存音频播放时返回的egret.SoundChannel对象。当音频播放完成后,我们需要切换isPlay属性状态,所以在得到egret.SoundChannel对象后,监听其egret.Event.SOUND_COMPLETE事件。在响应函数中将isPlay播放状态修改为false。

在侦听音频播放完成时间前,我们做了一步移除操作。

if (this._channel) {
            this._channel.removeEventListener(egret.Event.SOUND_COMPLETE, this.playComplete, this);
}

这是由于每次Sound对象调用play接口后,都会返回一个全新的egret.SoundChannel对象。为了防止内存泄漏,这里需要先对原有对象的侦听进行移除。

public stop(): void {
    this._isPlay = false;
    if (this._channel) {
        this._position = this._channel.position;
        this._channel.stop();
    }
}

stop方法用于暂停当前音频播放,除了修改isPlay播放状态和暂停音频以外。还需要对当前音频播放的进度进行缓存。原因在于,当我们需要恢复音频播放的时候,需要知道上一次音频暂停的位置。Egret引擎中Sound对象并没有为我们提供恢复播放的接口。每一次恢复只能从新调用play,而得到全新的egret.SoundChannel对象,该对象的position属性值为0。不仅如此,上一次播放所得到的egret.SoundChannel对象在执行stop后,其position属性值亦为0。

然后你可以在resume方法中看到对position的使用。

public resume() {
	this.play(this._position);
}

最后在dispose方法中,进行对象引用清除操作。

public dispose(): void {
	if (this._channel) {
		this._channel.stop();
		this._channel.removeEventListener(egret.Event.SOUND_COMPLETE, this.playComplete, this);
	}
	this._channel = null;
	this._sound = null;
}

基础音频封装

背景音乐和音效虽然在功能上有所差异,但本质还是维护一组声音对象,然后按需播放暂停以及调整音量。所以先要封装一个关于音频操作的基础类,将相同能力放到一起,不同能力分别在背景声音类和音效类中实现。

class BaseSound {
    protected cache: any;
    protected volume: number;

    constructor() {
        this.cache = {};
        this.volume = 0.5;
    }

    public play(key: string): void {

    }

    public stop(key: string): void {

    }

    public setVolume(value: number): void {
        this.volume = value;
        let voices: Voice[];
        for (let key in this.cache) {
            voices = this.cache[key];
            voices.map(voice => {
                voice.setVolume(value);
            })
        }
    }

    public clearCache(): void {
        let voices: Voice[];
        for (let key in this.cache) {
            voices = this.cache[key];
            for (let i: number = 0; i < voices.length; i++) {
                if (!voices[i].isPlay) {
                    voices[i].dispose();
                    voices.splice(i);
                    i--;
                }
            }
            if (voices.length == 0) {
                delete this.cache[key];
            }
        }
    }

    protected find(key: string, unused: boolean = false): Voice {
        let voices: Voice[] = this.cache[key];
        if (voices) {
            if (unused) {
                voices.map(voice => {
                    if (!voice.isPlay) {
                        return voice;
                    }
                })
            } else {
                return voices[0];
            }
        }

        let sound: egret.Sound = RES.getRes(key);
        if (sound) {
            if (!voices) {
                voices = [];
                this.cache[key] = voices;
            }
            let voice: Voice = new Voice(sound);
            voices.push(voice);
            return voice;
        }
        console.error("sound resource not found, key:", key);
        return null;
    }

    protected finds(key: string): Voice[] {
        return this.cache[key];
    }
}

一开始我们定义一个cache对象,用户缓存当前的所有使用过和正在被使用的音频对象。cache对象是一个Object类型对象,我们要以key-value形式存储音频对象。其中key为对应音频在资源表中的资源名,而value则是Voice类型的数组。

之所以这样设计,是因为音效播放的时候,同一个音频可同时播放多个。而背景音乐则不同,当前只允许循环播放一段声音。剩下的三个接口,分别负责统一设置音量(setVolume方法),清理缓存(clearCache方法)和查找音频对象(find方法和finds方法)。

设置音量实际上是遍历所当前cache缓存中所有Voice对象,并以此调用对应setVolume方法,修改其音量。

清理缓存操作需要按组来遍历音频,资源名称相同的为一组,一组内可能包含多个Voice对象。需要判断当前Voice对象是否正在播放。如果没有播放,说明它处于未使用状态,此时就可以将其内存清理,并从数组中删除。

for (let i: number = 0; i < voices.length; i++) {
    if (!voices[i].isPlay) {
        voices[i].dispose();
        voices.splice(i);
        i--;
    }
}

这段循环要注意的是,当数组中某一个元素被删除后,其长度发生变化。如果不对循环计数变量i做减1处理,会导致数组越界问题。

当一个数组中Voice对象全部检查完毕,并清理完成后,再对数据长度做一个判断。如果当前数组变为空数组,那么就讲数组移除。

if (voices.length == 0) {
    delete this.cache[key];
}

查找分为两种,由于每个音频可能存在多个Voice对象(以数组形式存储)。为此应该有一个接口返回单一Voice对象,另外一个接口返回Voice类型数组。

先来看finds方法,代码非常简单。

protected finds(key: string): Voice[] {
    return this.cache[key];
}

直接查找cache对象中对应的key,返回其值。当这个对应音频存在时我们肯定会得到一个长度不为零的数组。当这个音频资源没有被使用过时,应该返回null。在上层业务逻辑中,调用此后需要对结果进行一个安全检查,以确保其值有效。

find方法相对复杂一些,它负责查找一个可用的Voice对象。如何定义“可用”,又和第二个参数unused有关。在一开始,我们先从cache缓存中查找是否存在对应的音频组。

let voices: Voice[] = this.cache[key];
if (voices) {
    if (unused) {
        voices.map(voice => {
            if (!voice.isPlay) {
                return voice;
            }
        })
    } else {
        return voices[0];
    }
}

如果voices不为null,证明存在这个音频组。接下来判断unused是否是需要一个当前闲置状态的Voice对象。如果是,那么对数组进行遍历,找到空闲的Voice对象后,将其返回。如果不需要判定闲置状态,那么返回数组中第一个Voice对象。

为何这样设计?因为音效与背景音乐的播放行为不同。音效允许我们同时播放多个相同的音频。而背景音乐当前只播放一个。如果播放第二个,那么上一个将被暂停,永远不会发生重叠的情况。换句话说,在背景音乐的实现中,每一个音频对象数组其长度必定为1。特效声音则完全不同,我们不可能将当前的特效音暂停,然后再次播放,这样可能导致音效刚刚播放一个开头,就立刻结束并再次播放。听上去效果非常怪异。

如果上面的情况都不符合,那么执行后面的逻辑。这表示,目前我们还没有找到可用的Voice对象,需要重新创建并放入到cache缓存中。

let sound: egret.Sound = RES.getRes(key);
if (sound) {
    if (!voices) {
        voices = [];
        this.cache[key] = voices;
    }
    let voice: Voice = new Voice(sound);
    voices.push(voice);
    return voice;
}

这段代码则是创建全新的Voice对象并将其放入对应的数组中。一开始获取Sound对象后一定要进行安全检查。因为对应的音频可能没有被加载,此时会发生资源获取失败的情况。如果资源被正常创建,那么检查对应的数组是否创建。此后即可将新的Voice对象创建放入数组后返回给上层业务逻辑。

最为糟糕的情况是,我们无法获取Sound对象。此时该模块唯一能做的是打印出错误警告,并且返回一个null值。

console.error("sound resource not found, key:", key);
return null;

通常情况下,一个音频播放失败不会对游戏逻辑造成严重影响。而查询不到资源的错误应该在开发过程中就避免掉。这里仅仅做了简单的打印处理,你也可以设置一个回调钩子,让错误情况回传给你的服务器。当放生大量此类错误时,证明线上的游戏逻辑确实出现问题,你要考虑修改并更新游戏了。

public play(key: string): void {

}

public stop(key: string): void {

}

play和stop两个方法定义后不做实现,我们留着这两个接口在子类当中进行重写。

背景音乐

背景音乐相对于音效实现起来要简单的多,因为全局中当前只有一个音频在播放。其代码如下:

class SoundBackground extends BaseSound {
    private curKey: string = "";
    public constructor() {
        super();
    }

    public play(key: string): void {
        if (key == this.curKey) {
            return;
        }

        if (this.curKey != "") {
            this.stop(this.curKey);
        }
        this.curKey = key;
        let voice: Voice = this.find(key);
        if (voice) {
            voice.play();
            voice.setVolume(this.volume);
        }
    }

    public stop(key: string): void {
        if (key == "") {
            key = this.curKey;
        }
        let voice: Voice = this.find(key);
        if (voice) {
            voice.stop();
        }
    }

    public resume(): void {
        let voice: Voice = this.find(this.curKey);
        if (voice) {
            voice.resume();
            voice.setVolume(this.volume);
        }
    }
}

curKey变量用来保存当前正在播放的背景声音的key。如果它是一个空字符串的话,则表示当前没有正在播放的音频。由于继承自BaseSound类,我们需要实现play和stop方法。

播放背景音乐

先来看play声音的操作。

public play(key: string): void {
    if (key == this.curKey) {
        return;
    }

    if (this.curKey != "") {
        this.stop(this.curKey);
    }
    this.curKey = key;
    let voice: Voice = this.find(key);
    if (voice) {
        voice.play();
        voice.setVolume(this.volume);
    }
}

当播放一个声音时,先要判断指定播放的声音和当前正在播放的声音是否是同一音频。如果相同,则不需要做任何操作。接下来检查当前是否有正在播放的音频,直接curKey值是否为空字符串即可。如果有就将当前的音频暂停掉。然后播放新背景音频。

这里需要注意的是,背景音乐行为都是循环播放,我们在调用play方法时,不需要传入任何参数。它的默认行为就是循环播放。调用了play之后,千万不要忘记设置音量。

暂停背景音乐

暂停声音要简单许多,在暂停之前先要判断当前是否有正在播放的声音。如果没有,就将参数key设置为空字符串即可。因为在接下来的逻辑中,无一例外搜索不到key为空字符串的Voice对象,无需担心调用空对象的问题。

public stop(key: string): void {
    if (key == "") {
        key = this.curKey;
    }
    let voice: Voice = this.find(key);
    if (voice) {
        voice.stop();
    }
}

无论是播放还是暂停,每次获取Voice对象后,都要进行空值判断。因为find方法可能返回Null。

恢复音频播放

咱音频一章中,曾经对声音的生命周期有过相关讲解。当小游戏退入到后台时,音频会被关闭,再次激活后音频对象无法自动恢复。所以我们要提供一个恢复音频的操作,以方面外部逻辑在恢复游戏时恢复背景音乐的播放功能。

public resume(): void {
    let voice: Voice = this.find(this.curKey);
    if (voice) {
        voice.resume();
        voice.setVolume(this.volume);
    }
}

依然是通过find方法,查找到当前播放的音频,然后进行恢复。恢复操作不仅用在生命周期处理操作中。一会我们将看到,当用户手动关闭音频再次开启音频后,也将会调用恢复操作。

为什么背景音乐要单独实现resume恢复操作?因为业务需求中背景音乐一般持续时间较长,当声音暂停后应该在暂停位置恢复播放。而音效一般持续时间较短,一般为1秒左右。所以对于音效的恢复操作没有太多业务需求。

特效音频操作

特效音频操作的特殊点在于暂停操作不仅仅对一个Voice对象进行操作,而是对一组Voice对象操作。代码如下:

class SoundEffects extends BaseSound {

    public play(key: string): void {
        let voice: Voice = this.find(key, true);
        if (voice) {
            voice.play(0, 1);
            voice.setVolume(this.volume);
        }
    }

    public stop(key: string): void {
        let voices: Voice[] = this.finds(key);
        if (voices) {
            voices.map(voice => {
                voice.stop();
            })
        }
    }

    public stopAll(): void {
        let voices: Voice[];
        for (let key in this.cache) {
            voices = this.cache[key];
            voices.map(voice => {
                voice.stop();
            })
        }
    }

}

播放特效音

播放特效声音和背景音乐最大的不同在于我们无需考虑当前是否有相同音频正在播放。每一次只需要关注播放音频即可。具体使用哪个Voice对象在BaseSound类中已经做了处理。需要注意的是在调用find接口时,第二个参数要使用true,表示查到闲置状态的Voice对象。

public play(key: string): void {
    let voice: Voice = this.find(key, true);
    if (voice) {
        voice.play(0, 1);
        voice.setVolume(this.volume);
    }
}

在播放声音的时候,第二个参数一定要传入1,表示当前音频只播放1次,否则当前音效将无限循环播放。

暂停特效音

暂停特效音时,我们只知道当前音频的key,并不知道要具体暂停哪一个。而同key的音效可能有多个在同时播放。此时做遍历处理即可。

public stop(key: string): void {
    let voices: Voice[] = this.finds(key);
    if (voices) {
        voices.map(voice => {
            voice.stop();
        })
    }
}

这是所有代码中,唯一一处调用finds接口的地方,获取Voice对象数组后遍历,然后操作stop暂停即可。千万不要对数据进行任何元素添加删除的操作。因为返回的Voice对象数组属于引用类型,当进行元素操作时,内部的数组也会发生变化。你无法保证操作后结果是否符合内部规则,这可能导致内存泄漏或者某些时候状态不正确,出现未知Bug。

暂停所有特效音

这个接口属于特效音中特殊接口,在背景音乐中并不存在。究其原因在于背景音乐只有唯一一个音频在播放,而特效音当前播放的数量属于未知状态,所以我们只能通过循环遍历对每一个Voice对象进行操作。

public stopAll(): void {
    let voices: Voice[];
    for (let key in this.cache) {
        voices = this.cache[key];
        voices.map(voice => {
            voice.stop();
        })
    }
}

和暂停操作一样,你只需要遍历然后调用其stop方法即可,千万不要做其他任何操作。

声音管理类

这个类将命名为SoundManager,负责全局的音频管理,同时也是一个单例模式。通过当前单例类,我们可以播放暂停或者切换背景音乐,也可以随意操作特效音。不仅如此,通过一些接口还可以清除当前音频管理模块中的缓存,以达到降低内存使用量的效果。

首先来定义几个私有变量。

private effectOn: boolean;
private backgroundOn: boolean;
private effectVolume: number;
private backgroundVolume: number;

private effectSound: SoundEffects;
private backgroundSound: SoundBackground;

effectOn表示当前特效音是否打开。

backgroundOn表示当前背景音乐是否打开。

effectVolume表示特效音的音量。

backgroundVolume表示背景音乐的音量。

effectSound是SoundEffects类的实例化对象,用来操作特效音。

backgroundSound是SoundBackground类的实例化对象,永利来操作背景音乐。

在构造函数中,我们先对这几个私有属性进行初始化操作。

private constructor() {
    this.effectOn = true;
    this.backgroundOn = true;
    this.effectVolume = 0.5;
    this.backgroundVolume = 0.5;

    this.effectSound = new SoundEffects();
    this.effectSound.setVolume(this.effectVolume);

    this.backgroundSound = new SoundBackground();
    this.backgroundSound.setVolume(this.backgroundVolume);
}

默认的音量为50%,同时背景音乐和特效音默认全部打开。

当前音频管理器采用懒汉方式构建,所以获取其全局唯一对象的接口如下。

private static soundManager: SoundManager;

public static getInstance(): SoundManager {
    if (!SoundManager.soundManager) {
        SoundManager.soundManager = new SoundManager();
    }
    return SoundManager.soundManager;
}

接下来我们依次封装各个业务逻辑操作的接口。对于音量控制和开关来说,可以封装为getter和setter方法,也可以封装为函数操作,我们将采用后一种方式。两种方式没有任何差别,这取决于你的个人喜好。

特效音的开关操作

public getEffectOn(): boolean {
    return this.effectOn;
}

public setEffectOn(value: boolean): void {
    this.effectOn = value;
    if (!this.effectOn) {
        this.effectSound.stopAll();
    }
}

在设置特效音开关是,只需要处理关闭操作即可。因为特效音的调用会非常频繁,其音频播放时间也较短。如果特效音关闭,则处理所有特效音暂停即可。

背景音乐的开关

public getBackgroundOn(): boolean {
    return this.backgroundOn;
}

public setBackgroundOn(value: boolean): void {
    this.backgroundOn = value;
    if (this.backgroundOn) {
        this.backgroundSound.resume();
    } else {
        this.backgroundSound.stop("");
    }
}

背景音乐如果是打开操作,那么要将当前的音频进行恢复播放操作。如果是关闭的话则直接调用stop即可。无需为其传入key。因为在背景音乐的stop操作中,key为空字符串则表示暂停当前正在播放的音频。

特效音量操作

public getEffectVolume(): number {
    return this.effectVolume;
}

public setEffectVolume(value: number) {
    value = Math.min(value, 1);
    value = Math.max(value, 0);
    this.effectVolume = value;
    this.effectSound.setVolume(this.effectVolume);
}

这里要注意的是,音量的取值范围为0-1之间的数字。如果用户出入的值超出了这个取值范围,我们要做边界检查。其中value = Math.min(value, 1);是判断当前因两只是否超出了最大值1,value = Math.max(value, 0);是判断当前音量值是否小于最小值0。

背景音量操作

public getBackgroundVolume(): number {
    return this.backgroundVolume;
}

public setBackgroundVolume(value: number) {
    value = Math.min(value, 1);
    value = Math.max(value, 0);
    this.backgroundVolume = value;
    this.backgroundSound.setVolume(this.backgroundVolume);
}

和特效音量操作几乎一样,唯一不同的是,在设置背景音量的时候,我们要操作backgroundSound对象,而特效音量需要操作effectSound对象。

播放暂停特效音

public playEffect(key: string): void {
    if (!this.effectOn) {
        return;
    }
    this.effectSound.play(key);
}

public stopEffect(key: string): void {
    if (!this.effectOn) {
        return;
    }
    this.effectSound.stop(key);
}

public stopAllEffect(): void {
    if (!this.effectOn) {
        return;
    }
    this.effectSound.stopAll();
}

在处理特效音频播放与暂停前,只需要判定当前特效音是否打开即可。

播放暂停背景音乐

public playBackground(key: string): void {
    if (!this.backgroundOn) {
        return;
    }
    this.backgroundSound.play(key);
}

public stopBackground(): void {
    if (!this.backgroundOn) {
        return;
    }
    this.backgroundSound.stop("");
}

在处理背景音乐播放与暂停前,只需要判断当前背景音乐是否打开即可。

清除缓存

public clearCache(): void {
    this.effectSound.clearCache();
    this.backgroundSound.clearCache();
}

清除缓存只提供了对应接口,具体什么时候清除缓存,频率是多少由外部逻辑控制。

最后我们来一下声音控制类的完整代码:

class SoundManager {

    private static soundManager: SoundManager;

    private effectOn: boolean;
    private backgroundOn: boolean;
    private effectVolume: number;
    private backgroundVolume: number;

    private effectSound: SoundEffects;
    private backgroundSound: SoundBackground;

    private constructor() {
        this.effectOn = true;
        this.backgroundOn = true;
        this.effectVolume = 0.5;
        this.backgroundVolume = 0.5;

        this.effectSound = new SoundEffects();
        this.effectSound.setVolume(this.effectVolume);

        this.backgroundSound = new SoundBackground();
        this.backgroundSound.setVolume(this.backgroundVolume);

        egret.lifecycle.onPause = () => {
            this.backgroundSound.stop("");
        }

        egret.lifecycle.onResume = () => {
            if (this.backgroundOn) {
                this.backgroundSound.resume();
            }
        }
    }

    public static getInstance(): SoundManager {
        if (!SoundManager.soundManager) {
            SoundManager.soundManager = new SoundManager();
        }
        return SoundManager.soundManager;
    }

    public getEffectOn(): boolean {
        return this.effectOn;
    }

    public setEffectOn(value: boolean): void {
        this.effectOn = value;
        if (!this.effectOn) {
            this.effectSound.stopAll();
        }
    }

    public getBackgroundOn(): boolean {
        return this.backgroundOn;
    }

    public setBackgroundOn(value: boolean): void {
        this.backgroundOn = value;
        if (this.backgroundOn) {
            this.backgroundSound.resume();
        } else {
            this.backgroundSound.stop("");
        }
    }

    public getEffectVolume(): number {
        return this.effectVolume;
    }

    public setEffectVolume(value: number) {
        value = Math.min(value, 1);
        value = Math.max(value, 0);
        this.effectVolume = value;
        this.effectSound.setVolume(this.effectVolume);
    }

    public getBackgroundVolume(): number {
        return this.backgroundVolume;
    }

    public setBackgroundVolume(value: number) {
        value = Math.min(value, 1);
        value = Math.max(value, 0);
        this.backgroundVolume = value;
        this.backgroundSound.setVolume(this.backgroundVolume);
    }

    public playEffect(key: string): void {
        if (!this.effectOn) {
            return;
        }
        this.effectSound.play(key);
    }

    public stopEffect(key: string): void {
        if (!this.effectOn) {
            return;
        }
        this.effectSound.stop(key);
    }

    public stopAllEffect(): void {
        if (!this.effectOn) {
            return;
        }
        this.effectSound.stopAll();
    }

    public playBackground(key: string): void {
        if (!this.backgroundOn) {
            return;
        }
        this.backgroundSound.play(key);
    }

    public stopBackground(): void {
        if (!this.backgroundOn) {
            return;
        }
        this.backgroundSound.stop("");
    }

    public clearCache(): void {
        this.effectSound.clearCache();
        this.backgroundSound.clearCache();
    }
}

当我们想要播放一段背景音乐或者播放音效的时候可以使用如下代码来进行操作。

SoundManager.getInstance().playBackground("music_mp3");
SoundManager.getInstance().playEffect("goldPick_mp3");

music_mp3和goldPick_mp3是音频文件在资源配置列表中的资源名。