在 Cocos Creator 中优雅且高效地管理弹窗

【本文参与征文活动】


title: 在 Cocos Creator 中优雅且高效地管理弹窗
date: 2020/10/22
updated: 2020/10/22
description: 分享一套弹窗管理方案,真的不开玩笑,效率和优雅我全都要…

前言

弹窗

弹窗对于我们来说应该一点都不陌生,无论是在网页上,APP 上还是在游戏中都非常的常见。

有没有想过,我们既然已经有如此多的界面了,为什么还需要弹窗?

因为弹窗可以快速吸引用户的注意力,可以快速且准确地传递信息。

回到正题

在大多数游戏中都会有或多或少的弹窗出现,所以在我们游戏开发中,对于弹窗的处理也是必不可少的。

一套好的弹窗管理流程可以大大提高开发效率,减少大量不必要的重复性工作,让我们专注于弹窗信息传递方面的开发。

接下来,本篇文章将给大家分享一套我自以为 优雅且高效的弹窗管理方案


正文

标准化

通常,我们都会希望同一产品中的弹窗风格是一致的,才不会给到用户一种突兀感。就好像我上一秒还在玩消消乐,下一秒就让我拿枪跟别人中门对狙。

一般情况下,即使是不同类型的弹窗其基础逻辑都是基本相同的,相同的动画相同的生命周期等等,大多只有界面和内部逻辑不同。

所以,我们大可 将弹窗标准化,让所有弹窗共用同一套基础逻辑。

就不再需要在每个弹窗脚本里都写一大段几乎相同的代码,大大简化了脚本结构,更易于后期维护。

弹窗基类

此时,面向对象三大特性之 继承 的优势就体现出来了。

我们只需要实现 一个包含基础逻辑的弹窗基类,之后 所有弹窗都将继承自这个弹窗基类,只重写或新增需要的函数和逻辑即可。

为了帮助理解,这里先贴一下简化版的弹窗基类组件:

const { ccclass, property } = cc._decorator;

@ccclass
export default class PopupBase<Options> extends cc.Component {

    /** 弹窗选项 */
    protected options: Options = null;

    /** 弹窗已完全展示 */
    protected onShow(): void { }

    /** 弹窗已完全隐藏 */
    protected onHide(): void { }

    /** 展示弹窗 */
    public show(options?: Options): void {
        // 储存选项
        this.options = options;
        // 初始化
        this.init(this.options);
        // 更新样式
        this.updateDisplay(this.options);
        // 弹窗展示逻辑
        // ...
        // 弹窗已完全展示
        this.onShow();
    }

    /** 隐藏弹窗 */
    public hide(): void {
        // 弹窗隐藏逻辑
        // ...
        // 弹窗已完全隐藏
        this.onHide();
    }

    /** 初始化 */
    protected init(options: Options): void { }

    /** 更新样式 */
    protected updateDisplay(options: Options): void { }

}

完整的弹窗基类传送门在这里~

弹窗基类:https://gitee.com/ifaswind/eazax-ccc/blob/master/components/popups/PopupBase.ts

下面稍微讲解一下:

定义

首先,弹窗基类 PopupBase 直接继承自 cc.Component,派生类就无需再继承了。

另外,类定义中使用了 泛型 来指定派生类的 options 属性的类型,派生类就无需再显式指定其类型。

下方代码中使用尖括号 <> 包裹的 Options 就是类型参数,类型参数的命名没有特殊限制,不过最常见的命名为 T

export default class PopupBase<Options> extends cc.Component { }

举个栗子:chestnut:,下面是一个弹窗派生类 MyPopup 的定义:

const { ccclass, property } = cc._decorator;

@ccclass
export default class MyPopup extends PopupBase<MyPopupOptions> { }

/** 弹窗选项类型 */
export interface MyPopupOptions {
    title: string;
    content: string;
}

如上,通过 <MyPopupOptions> 指定类型后,MyPopup 中继承自 PopupBaseoptions 属性就已经被指定为 MyPopupOptions 类型了。

这样一来,在脚本中调用 options 时就会有智能提示了,哎呀针不戳~

泛型是 TypeScript 的特性之一,很酷!

了解更多:https://www.tslang.cn/docs/handbook/generics.html

生命周期

增加了两个生命周期函数,方便自定义不同的效果:

  • onShow():在弹窗 完全展示(即展示动画完成后)时调用
  • onHide():在弹窗 完全隐藏(即隐藏动画完成后)时调用
protected onShow(): void { }

protected onHide(): void { }

通用成员

每个弹窗都会有的成员:

  • options:用于储存弹窗调用时传入的选项(参数)
  • init():用于初始化弹窗(如数据、状态等)
  • updateDisplay():用于更新弹窗的样式(UI)

且这些成员应在弹窗展示之前就进行处理。

protected options: Options = null;

protected init(options: Options): void { }

protected updateDisplay(options: Options): void { }

对外接口

理论上,展示 show() 以及隐藏 hide() 这两个函数应该是弹窗中唯二公开的接口。

且大多数情况下隐藏都是由弹窗内部调用的(如点击关闭按钮)。

public show(options?: Options): void { 
    // ...
}

public hide(): void {
    // ...
}

运筹帷幄

游戏中的弹窗也会有许多不同的情况发生,比如当一个弹窗在展示时另一个弹窗又弹了出来该怎么办?

所以,所有弹窗都必须以一种统一的方式来管理,才能保证每个弹窗能够平稳有序地展示。

此时我们就需要一个独立的 弹窗管理器 来运筹帷幄,来替我们干那些“脏活累活”。

弹窗管理器

我认为一个合格的弹窗管理器应当包含以下功能:

  • 加载并展示不同的弹窗
  • 等待队列和切换机制(有序)
  • 缓存机制(提高加载速度)
  • 选项(参数)传递机制
  • 弹窗回收和资源释放机制

让我们先看一下弹窗管理器的逻辑流程图:

看完流程图大家就大概知道怎么回事了,所有这里就不贴完整代码了,留下传送门吧~(不过还是建议先看看完整代码)

弹窗管理器:https://gitee.com/ifaswind/eazax-ccc/blob/master/core/PopupManager.ts

不过下面的讲解中还是会使用一些简化后的代码片段来帮助理解。

等待 & 切换

首先,理论上 show() 函数是展示弹窗的唯一途径。

另外,show() 函数可以接收 4 个参数:

  • path:弹窗预制体的路径(相对于 resources 文件夹)
  • options:需要传入弹窗组件的选项(或者说是参数)
  • mode:弹窗的缓存模式(后面会说到)
  • priority:是否优先展示(就是插队)
排好队

PopupManager 中定义了一个属性 curPopup 来储存当前展示中的弹窗,当调用 show() 请求展示弹窗时,需先判断当前是否有展示中的弹窗,有的话就调用 push 函数将请求推入等待队列 queue 中,没有才开始处理这个请求。

且当参数 prioritytrue 时,弹窗请求将会插入到 queue 最前面,也就是下一个。

public static show(path, options, mode, priority) {
    return new Promise(res => {
        if (this.curPopup) {
            this.push(path, options, mode, priority);
            // ...
        }
        // ...
    });
}

public static push(path, options, mode, priority) {
    // ...
    if (priority) {
        this.queue.unshift({ path, options, mode });
    } else {
        this.queue.push({ path, options, mode });
    }
}
下一个

在弹窗的生命周期结束后,会调用函数 next() 来处理等待队列 queue 中的第一个请求(如果有的话)。

public static show(path, options, mode, priority) {
    return new Promise(res => {
        // ...
        this.next();
    });
}

private static next() {
    // ...
    const request = this.queue.shift();
    this.show(request.path, request.options, request.mode);
}

缓存 & 获取

在游戏中,有些弹窗展示得会很频繁,而有些弹窗偶尔会出现但算不上频繁,还有些弹窗只会出现一两次。

我们不会希望只用了一次的弹窗却一直占用着内存,或者是频繁展示的弹窗每次都重新加载。

缓存模式

为了达到速度与内存占用的平衡,从而提升用户体验,我们可以实现一个缓存机制来针对不同的模式做不同的处理。

于是设计了如下三种缓存模式:

  • Once:一次性的,展示的机会比较少
  • Normal:正常的,偶尔会展示,或者是有一定的时间间隔
  • Frequent:频繁的,展示的场景很多,可能会是比较通用的弹窗
获取节点

另外,我主要使用了两个表 Map 来实现这个缓存机制,分别是预制体表 prefabMap 和节点表 nodeMap

当我们尝试展示弹窗时,弹窗管理器会先 从节点表中获取弹窗节点 或者 从预制体表中获取预制体来实例化新的弹窗节点;假如在缓存中没有找到(即从未加载过该弹窗),则 从本地动态加载目标弹窗的预制体资源并实例化,并且将预制体保存到预制体表中。

private static getNodeFromCache(path) {
    if (this.nodeMap.has(path)) {
        const node = this.nodeMap.get(path);
        if (cc.isValid(node)) {
            return node;
        }
        this.nodeMap.delete(path);
    }
    if (this.prefabMap.has(path)) {
        const prefab = this.prefabMap.get(path);
        if (cc.isValid(prefab)) {
            return cc.instantiate(prefab);
        }
        this.prefabMap.delete(path);
    }
    return null;
}

组件 & 展示

获取到弹窗的节点后,先将其添加到场景中,接下来就是去获取节点上的弹窗组件并调用组件上的 show() 函数。

但是假如我们有许多不同的弹窗,每种弹窗都有其相对应的弹窗组件,那我们要怎么在不知道具体类名的时候去获取目标弹窗相应的组件呢?

获取组件

先揭晓答案:

// 这里实际上获取到的就是继承自 PopupBase 的弹窗组件实例
const popup = node.getComponent(PopupBase);

为什么呢,让我们看下 getComponent() 函数的源码:

getComponent (typeOrClassName) {
    var constructor = getConstructor(typeOrClassName);
    if (constructor) {
        return findComponent(this, constructor);
    }
     return null;
}

再看看 findComponent() 函数的源码:

function findComponent(node, constructor) {
    if (constructor._sealed) {
        for (let i = 0; i < node._components.length; ++i) {
            let comp = node._components[i];
            if (comp.constructor === constructor) {
                return comp;
            }
        }
    }
    else {
        for (let i = 0; i < node._components.length; ++i) {
            let comp = node._components[i];
            if (comp instanceof constructor) {
                return comp;
            }
        }
    }
    return null;
}

可以看到 getComponent() 主要是通过 instanceof 运算符对比原型的方式来获取目标组件的。

instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。

也就是说,只要弹窗组件是继承自 PopupBase 的,我们就可以通过 getComponent() 来获取节点上的弹窗组件实例。

课间休息

盲生,你发现华点了吗?

这种通过父类来操作子类的实例的方式,有没有让你觉得很像一种非常酷的东西?

没错!就是面向对象三大特性之 多态

多态:同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。在运行时,可以通过指向基类的指针,来调用实现派生类中的方法。

:nerd_face: 实际上 JavaScript 并没有多态这个概念,但是我们可以通过这种方式来实现多态,真的非常有意思~

操作组件

拿到弹窗组件后,先给弹窗设置一个生命周期的结束回调,这样在当前弹窗关闭后就可以进行弹窗回收以及切换操作。

public static show(path, options, mode, priority) {
    return new Promise(res => {
        // ...
        const popup = node.getComponent(PopupBase);
        popup.setFinishCallback(() => {
            this.recycle(path, node, mode);
            res(PopupShowResult.Done);
            this.curPopup = null;
            this.next();
        });
        popup.show(options);
    });
}

有一个特别的点你可能也注意到了,show() 函数的逻辑是包裹在一个 Promise 中的,有没有猜到目的是什么?

其实这样做的原因就是为了让 show() 函数可以同步执行,而且还返回了一个枚举类型 PopupShowResult 的值来表示请求结果。

举个栗子:chestnut:

private async test() {
    console.log('请求展示弹窗');
    const result = await PopupManager.show('prefabs/MyPopup');
    switch (result) {
        case PopupShowResult.Done:
            cc.log('弹窗展示成功且已关闭');
            break;
        case PopupShowResult.Wait:
            cc.log('弹窗已加入等待队列');
            break;
    }
}

回收 & 释放

回收节点

当弹窗生命周期完全结束后,会调用 recycle() 函数对弹窗的节点进行回收。

根据弹窗的缓存模式进行不同的处理:

  • Once:立即销毁节点,释放预制体资源
  • Normal:立即销毁节点,保留预制体资源
  • Frequent:将节点移出场景,保留预制体资源
private static recycle(path, node, mode) {
    switch (mode) {
        case PopupCacheMode.Once:
            node.destroy();
            if (this.nodeMap.has(path)) {
                this.nodeMap.delete(path);
            }
            this.release(path);
            break;
        case PopupCacheMode.Normal:
            node.destroy();
            if (this.nodeMap.has(path)) {
                this.nodeMap.delete(path);
            }
            break;
        case PopupCacheMode.Frequent:
            node.removeFromParent(false);
            if (!this.nodeMap.has(path)) {
                this.nodeMap.set(path, node);
            }
            break;
    }
}
释放资源

得益于 Cocos Creator 2.4 版本给力的资源管理模块,资源的释放变得前所未有的简单,不再需要开发者自己维护引用计数和释放。

  • 对于直接挂载在节点上的资源(静态引用),引擎内部会自动记录引用,完全不需要开发者为此操心。
  • 对于使用代码动态加载的资源(动态引用),加载后调用资源的 addRef() 函数来增加一个计数,不再需要时只需调用 decRef() 函数来减少一个计数,就可以把释放工作交给引擎处理,引擎会自动判断资源是否可以释放。

因为弹窗管理器在加载预制体的时候已经增加了一个引用计数,所以释放时直接相应减少一个引用计数即可。

:warning: 但是注意了,对于在弹窗内部逻辑中额外动态加载的资源,需要自行进行计数!

public static release(path) {
    let prefab = this.prefabMap.get(path);
    if (prefab) {
        this.prefabMap.delete(path);
        prefab.decRef();
        prefab = null;
    }
}

【文档】资源释放:https://docs.cocos.com/creator/manual/zh/asset-manager/release-manager.html

做个总结

这一套弹窗管理方案的关键就两个东西,弹窗基类 PopupBase 和弹窗管理器 PopupManager

使用起来还是比较简单直接的,无论是大项目小项目都完全适用。

而且灵活性也比较高,基本上可以根据自己的喜好任意修改。另外,如果游戏中需要用到多级弹窗,只需稍微调整弹窗管理器也是可以兼容到的。

弹窗基类:https://gitee.com/ifaswind/eazax-ccc/blob/master/components/popups/PopupBase.ts

弹窗管理器:https://gitee.com/ifaswind/eazax-ccc/blob/master/core/PopupManager.ts

使用示例

最后再举一个使用的栗子:chestnut:吧~

就只是一个展示一个标题和一句话的弹窗。

定义弹窗

@ccclass
export default class ConfirmPopup extends PopupBase<ConfirmPopupOptions> {
    
    @property(cc.Label)
    private titleLabel: cc.Label = null;
    
    @property(cc.Label)
    private contentLabel: cc.Label = null;
    
    @property(cc.Node)
    private confirmBtn: cc.Node = null;
    
    public static get path() { return 'prefabs/ConfirmPopup'; }
    
    protected onLoad() {
        this.confirmBtn.on('touchend', this.onConfirmBtnClick, this);
    }
    
    protected updateDisplay(options: ConfirmPopupOptions) {
        this.titleLabel.string = options.title; // this.options.title 也行
        this.contentLabel.string = options.content;  // this.options.content 也行
    }
    
    private onConfirmBtnClick() {
        this.hide();
    }
    
}

export type ConfirmPopupOptions = {
    title: string;
    content: string;
}

展示弹窗

const options: ConfirmPopupOptions = {
    title: '你好',
    content: '很高兴认识你!'
}
PopupManager.show(ConfirmPopup.path, options);

最后的最后

Eazax-CCC 示例项目中的弹窗就是用的这套方案,我也用了各种奇怪的姿势进行测试,都没问题,计划通~

Eazax-CCC 示例在线预览:https://ifaswind.gitee.io/eazax-cases


传送门

微信推文版本

个人博客:菜鸟小栈

开源主页:陈皮皮

Eazax-CCC 游戏开发脚手架

Eazax-CCC 示例在线预览


更多分享

《为什么选择使用 TypeScript ?》

《高斯模糊 Shader》

《一文看懂 YAML》

《Cocos Creator 性能优化:DrawCall》

《互联网运营术语扫盲》

《在 Cocos Creator 里画个炫酷的雷达图》

《用 Shader 写个完美的波浪》


公众号

菜鸟小栈

:smiley_cat:我是陈皮皮,一个不断学习的游戏开发者,一个热爱分享的 Cocos Star Writer。

:art:这是我的个人公众号,专注但不仅限于游戏开发和前端技术分享。

:sparkling_heart:每一篇原创都非常用心,你的关注就是我原创的动力!

Input and output.

36赞

666 mark 学习的东西太多了

看描述是只支持单个弹框在线吧,并不支持多个弹框同时在线0.0

66666 mark一下

看来我还是有必要加上多级弹窗的支持了

我是觉得不用弄成队列的形式,一般都是想弹的时候调用一下show:joy:我好像没遇到过需要弄成队列的需求

:+1: 实用

队列其实更多来说是一种容错机制~

有多个弹窗Ui时,都有半透明黑色bg, 这个时候都叠在一起界面就黑乎乎一片,,

思维导图做的太棒了:clap:

感谢大佬分享

感谢大佬分享

写的很棒,但是有个诡异的问题,如果弹窗里有Slider组件,在init或者updateDisplay里修改slider的progress的话就会导致slider的bar消失不见,如果在init/updateDisplay里延迟一帧修改或者把init/updateDisplay放到show方法的最上面也行,或者去掉tween动画也可以,怀疑是tween动画导致的,动画开始前如果把节点的scale设为非0也正常。
https://gitee.com/ifaswind/eazax-cases你的这个工程也一样可以复现问题。
#####原来的 PopupBase.ts


#####改成这样slider就可以正常显示

引擎版本是2.4.3,百思不得其解啊。另外这个init和updateDisplay功能一样感觉有点多余。

大佬,喝茶。

MARK.

肯定会有大量需要多级弹窗的

mark.

mark。

我正在遇到的问题是
服务器下发 切换场景+弹窗
则可能这个弹窗 在当前场景弹出 马上就 切换场景, 导致看不到弹窗或者看不全弹窗
有解吗?

真不错,先打卡收藏!