《任务队列方案记 |社区征文》

任务队列方案记

一 按顺序执行的动画

       美好的一天上班时间,你正要喝杯养生茶时,接到了需求:

现在游戏中有两个动画:

1. 开局动画<1>
2. 角色移动动画<2>

要求: 先执行 `动画<1>`,再执行 `动画<2>`.

       略一思索,这简单,给每一个动画提供一个完成时回调即可。

       于是你实现如下:

// file: GameStartAnim.ts  开局动画<1>
class GameStartAnim extends cc.Component {
    play(cb: () => {}) {
        cc.tween(this.node)
           ....   // 动画具体实现
           .call(cb)
           .start();
    }
}

// file: RoleMoveAnim.ts 角色移动动画<2>
class RoleMoveAnim extends cc.Component {
    play(cb: () => {}) {
        ... // 类似于 `GameStartAnim`
    }
}

// file: Game.ts 游戏
class Game extends cc.Component {
    startGame() {
        this.startAnim.play(() => {
            this.roleAnim.play(() => cc.log('动画完成'));
        });
    }
}

       “搞定”,你心里想到。正好一天的工作时间,完美。虽然两个有两个嵌套回调,但能正常工作,对吧?这就足够了。

二 多个按顺序执行的动画

       第二天,你来到公司刚坐下,新的需求就来了,如下:

       - [1]. 新增 角色打招呼动画<3>

  • [2]. 动画<3> 优先级在 动画<2> 之后;
  • [3]. 其他动画优先级在 动画<3> 之后;
  • [4]. 可以在任何阶段进入游戏;

2.1 动画<3>

       讨论完需求后,你回到电脑前,开始划水,呃不对,开始工作了。

       已经实现了 动画<1>动画<2> 的你,实现 动画<3> 已经是轻车熟路:

// file: RoleSayHiAnim.ts 角色打招呼动画<3>
class RoleSayHiAnim extends cc.Component {
    play(cb: () => {}) {
        ... // 类似于 `GameStartAnim`
    }
}

       集成到 Game 中:

class Game extends cc.Component {
    startGame() {
        this.startAnim.play(() => {
            this.roleAnim.play(() => {
                this.sayHiAnim.play(() => cc.log('动画完成'));
            });
        });
    }
}

       看着自己亲手写出来的回调地狱,你心里也不禁翻出一阵恶心感。“有空了,把这里的优化下”,你这样想,当然,你知道这不过是自己安慰自己罢了。

现在是时候开始实现下一个需求了。

2.2 可以在任何阶段进入游戏

       之前讨论需求时,没有觉得,直到现在要实现时,你才隐约感觉到,这可能是个坑。

       随着你的思考,发现这个需求的表面之下,还隐藏着不少隐藏需求。这是你花了一个小时整理出来的子需求:

1. 由于进入游戏时,需要显示当前状态,这意味着 `动画` 之间是 `等待` 关系。
   比如:要执行 `动画<3>`,如果当前没有 `动画<1/2>`,则可以立即执行。

2. 多个动画之间的先后顺序,应该由服务器通知客户端来做。
   比如,由于网络慢,可能会造成情况:当前正在执行 `动画<2>`,来了通知,需要执行 `动画<1>`。
   
3. 有 “恢复”,也就意味着需要有 “停止”。

       好了,现在 动画 之间,需要等待。怎么来实现它,“用回调地狱的方式?”,你想想就直摇头,这玩意儿怕是要写出几十层嵌套出来了!

       现在好了,不得不改掉之前 “多个回调” 式写法了。

2.3 “等待” 声明

       在实现之前,你用笔在纸上思考。

  1. 既然是 等待,那么方法名就叫 wait 好了;
  2. 接下来,wait 返回的 参数返回值 是什么呢?关键在于如何承载 等待 逻辑。
  1. 参数:如果其用来承载主逻辑,则应该是 wait(cb: () => {}): void,这一看就又回到了 “回调地狱” 的老路上了!否定!
  2. 返回值:在写到 return 这个字时,你灵光一闪:“Promise”!它可以用来承载未来的值,还可以用链式表达式,用在这里刚刚好。至于 Promise 的包装值,这里没什么用,直接 void 即可。

       好了,现在来写方法声明:

// file: GameStartAnim.ts  开局动画<1>
class GameStartAnim extends cc.Component {
    // 等待动画完成
    wait(): Promise<void>
}

2.4 “wait” 实现

       方法已经声明好了,实现起来速度就快。要注意的事项如下:

       - [1]. 当前可能没有正在执行的动画;

  • [2]. Promise 需要在 play 方法里完成。

       弄清了要注意的地方,实现起来就轻松了。只需将 promise 对象及其 resolvereject 方法保存起来即可。

       代码如下:

// file: GameStartAnim.ts  开局动画<1>
class GameStartAnim extends cc.Component {
    private _promise?: Promise<void>;
    private _resolve?: () => void;
    private _reject?: (any) => void;
    
    // 等待动画完成
    wait(): Promise<void> {
        // 有动画则等待;无则返回
        return this._promise || Promise.resolve();
    }

    /** 播放动画 */
    play() {
        if (this._promise) {
            return;
        }

        cc.tween(this.node)
           ....   // 动画具体实现
           .call(() => {
                this._resolve && this._resolve();
                this.clean();
           })
           .start();

        this._promise = new Promise((resolve, reject) => {
            this._resolve = resolve;
            this._reject = reject;
        });
    }

    /** 停止动画 */
    stop() {
        cc.Tween.stopAllByTarget(this.node);
        this._reject && this._reject("stop");
        this.clean();
    }

    private clean() {
        this._promise = null;
        this._resolve = null;
        this._reject = null;
    }
}

       接下打算依葫芦画瓢,实现另外几个动画的 wait 时,你突然发现上面的 _promise、_resolve、_reject 几个变量都是一模一样的,重复且无趣的代码。

       略一思索,你想到了 “包装类”:将这三个包装成一个对象,这样管理起来就方便了。说干咱就干:

/** 用于包装 `Promise` 的类 */
class PromiseWapper<T> {
    private _prmoise: Promise<T>;
    private _resolve: (v: T) => void;
    private _reject: (reason?: any) => void;

    constructor() {
        this._prmoise = new Promise((resolve, reject) => {
            this._resolve = resolve;
            this._reject = reject;
        });
    }

    getPromise() {
        return this._prmoise;
    }

    resolve(value: T) {
        this._resolve && this._resolve(value);
        this._resolve = null;
    }

    reject(reason?: any) {
        this._reject && this._reject(reason);
        this._reject = null;
    }
}

       现在用这个包装来实现动画 wait 方法:

class GameStartAnim extends cc.Component {
    private _promiseWapper?: PromiseWapper<void>;

    /** 播放动画 */
    play() {
        if (this._promiseWapper) {
            return;
        }

        cc.tween(this.node)
           ....   // 动画具体实现
           .call(() => {
                this._promiseWapper.resolve();
                this._promiseWapper = null;
           })
           .start();

        this._promiseWapper = new PromiseWapper();
    }

    /** 停止动画 */
    stop() {
        cc.Tween.stopAllByTarget(this.node);
        this._promiseWapper.reject("stop");
        this._promiseWapper = null;
    }

    // 等待动画完成
    wait(): Promise<void> {
        return this._promiseWapper?.getPromise() || Promise.resolve();
    }
}

       再次集成到 Game 中:

class Game extends cc.Component {

    // 各动画需要分开
    startAnim() {
        this.roleAnim.stop();
        this.sayHiAnim.stop();
        this.startAnim.play();
    }
    
    roleMovelAnim() {
        this.sayHiAnim.stop();
        this.startAnim.wait()
            .then(() => this.roleAnim.play())
            .catch(cc.error);
    }
    
    sayHiAnim() {
        this.startAnim.wait()
            .then(() => this.roleAnim.wait())
            .then(() => this.sayHiAnim.play())
            .catch(cc.error);
    }
}

       到了这里,你成功地利用 Promise 实现了动画间的 “等待” 逻辑。开心,下班!

Tips: 

  如果是在组件中使用异步回调,当该回调非引擎触(如 `setTimeout`、`promise.then`),都应该用判断 组件是否合法。
  
  比如 `promise.then` 保守写法可以是:
  promise.then(() => cc.isValid(this) && this.doSomething())
  
  本文为了尽量简洁,故意忽略了这一点。
  
  原因:在小部分情况下,异步回调触发前,组件已 `destory`.

三 任务队列

       新的一天,果然不出你的所料,来了新的需求。

3.1 活动弹窗需求

       需求是关于活动弹窗的。你们的游戏中,不定期会有一些活动。比如:

       - [1]. “情人节”、“愚人节” 之类的商业节日会有的活动任务;
       - [2]. 还有 “Cocos 征稿活动” 运营业活动等。

       由你们运营人员在后台配置生成。他们还可以配置是否需要主动弹窗。另外活动的数量不受限制。

3.2 交杂在一块的弹窗需求

接到了“活动弹窗需求”后,你的心里拔凉拔凉的。因为你们的游戏之前已经有:1、事件奖励弹窗(升级奖励、游戏内部任务奖励动画);2、新手引导(引导动画、首次使用某功能时的引导弹窗等)。

现在你把它们的特性整理到一块:

  • [1]. 可能会多个弹窗、动画需求,但同一时间只能有一个显示出来;
  • [2]. 以队列顺序显示。有的由后台通过 WebSocket 触发,有的由 http 触发,按先进先出的顺序显示;
  • [3]. 多类型。不仅有 活动弹窗,还有 引导动画延时等。

3.3 解决方案

       心情平复之后,你开始着手实现。

1. “等待”

       首先,你打算使用之前在游戏动画 “等待” 方案,当很快你就发现行不通:

       现在这些弹窗、动画彼此之间是 “不知道” 的,这意味着,你无法为它们之间写死 “等待”。

       随着思考,你转换了视角,发现之前的 “游戏动画等待” 实际上是现在的了一个 “子集”。

2. 弹窗队列

       第1种方案行不通,随即你想到大佬们分享的 “弹窗队列”。但当你集成时发现,它们很强大、目标极其明确,嗯,只管理弹窗,而且有的还要求继承自它们的 “基类”。对于你的需求里,动画延时 是无法满足的。

       好了,“我还是自己来试试看吧”,也许行呢?

3. 任务队列

       首次,确认这是一个 “队列”,毕竟它们是 “先到先出”。

       那些 “各类弹窗”、“动画”、“延时” 等元素,你打算统一都叫 “任务”,可供 “队列” 管理。即然要做到统一管理,那么需要统一抽象,即要找到它们之间的共同点。共同点是什么呢?

       经过多方对比,你发现了共同特点:

       - [1]. 任务可以被 “开始”;

  • [2]. 任务可以得知自己什么时候 “结束”。

       经过这个抽象后,它们就可以被管理了。同时,你发现 “function” 也是符合这一点的。这意味着,你的 “任务队列” 能管理的范围极大,极灵活。

4. 编码

       优秀的你,写代码之前,都会把最小限度的类、函数声明稍微写一下,使它们能在你的脑海连接起来,看是否满足功能所需。

4.1 任务

       先来考虑 “任务”。它是多个不同类型的任务的基础。最少需要一个 “开始” 方法供别人调用。

export interface ITask {
    /**
     * 开始任务
     */
    startTask(): void;
}

       之前有提到,“任务” 知道它自己什么时候 “结束”,这意味 “结束” 方法得由 “任务” 自己来调用。这样你就需要传一个可以接受 “已结束” 的对象给它,你把这对象叫 任务订阅者:

/**
 * 任务订阅者
 */
export interface ITaskSubscriber {
    /**
     * 当你的任务完成时,应该调用此方法,告知:任务已完成
     */
    complete(): void;
}

export interface ITask {
    /**
     * 开始任务
     */
    startTask(subscriber: ITaskSubscriber): void;
}

Tips: 抽象时,应该优先考虑 “接口”,而不是“类继承”。接口更加灵活。

4.2 队列

       队列就简单了,就是执行任务,当然还有取消任务:

/**
 * 任务列表. 
 * 
 * 以队列的形式依次执行任务
 */
export class HMFTaskQueue {
     /**
      * 添加任务。
      */ 
    addTask(task: ITask): void;
        
    /**
      * 取消任务。
      */
    cancel(task: ITask, reason?: any): void;
}

       队列的代码的核心逻辑就是:有任务就执行,任务执行完就执行下一个。

4.3 使用

       好了,先拿代码来实现之前的需求:

class Game extends cc.Component {
    private tasks = new HMFTaskQueue();
    
    onDestory() {
        this.tasks.cancelAll();
    }
    
    // 示例:显示奖励弹窗
    onDisplayRewardAlert(alertData: any) {
        this.tasks.addTask(() => this.getRewardAlert(alertData).show());
    }
    
    // 示例:显示新手引导
    onDisplayNewGuilder(stepData: any) {
        this.tasks.addTask(() => this.getGuilderStep(stepData).show());
    }
    
    // 示例:显示活动弹窗
    onDisplayEventAlert(eventData: nay) {
        this.tasks.addTask(() => this.getEventAlert(eventData).show());
    }
}

       用它来实现之前的动画 “等待”(这里忽略了 play 返回 task 的实现):

class Game extends cc.Component {

    // 各动画需要分开
    startAnim() {
        this.tasks.addTask(() => this.startAnim.play());
    }
    
    roleMovelAnim() {
        this.tasks.addTask(() => this.roleAnim.play());
    }
    
    sayHiAnim() {
        this.tasks.addTask(() => this.sayHiAnim.play());
    }
}

       现在你看到代码之间相当的清晰,以后也好维护些。

       下面是本文作者的 “新手引导” 部分代码:

    this.tasks.addFuncTask(this.funcDisMsg(500, NGMsgStep1()));   // 新手游戏开场动画
    this.tasks.addFuncTask(this.funcDisMsg(200, NGMsgStep2()));   // “英雄” 介绍动画
    this.tasks.addFuncTask(this.funcDisMsg(200, NGMsgStep3()));   // “小兵” 介绍动画
    this.tasks.addFuncTask(this.funcDisMsg(1000, NGMsgStep4()));  // “攻击” 介绍动画
    this.tasks.addFuncTask(this.funcDisMsg(1000, NGMsgStep5()));  // “技能” 介绍
    this.tasks.addFuncTask(this.funcDisMsg(100, NGMsgStep6()));  // 给 “用户” 介绍权限
    this.tasks.addTimerTask(1000);  // 延时
    this.tasks.addPromiseTask(() => this.loadGuilderPromise().startPlay());  // 申请权限
    this.tasks.addFuncTask(() => LogEvent(LogEventText.tutorial.tutorial_play_promise)); // 事件记录

四 总结

       随着游戏游戏的慢慢迭代,你成功搭建了自己的一个小框架: “任务队列”。一定程度上解放了自己的任务复杂度。

       你不禁期待起来:“下一个框架会是什么呢”?

参考代码

       当然,随着你的业务逻辑迭代,你添加了一些方便的功能。比如:任务事件的回调、常用任务类。这里就不一一展开的。

       目前笔者还添加了 “任务优先级” 等功能。

       你放出的参考代码如下:

/**
 * 任务订阅者
 */
export interface ITaskSubscriber {
    /**
     * 当你的任务完成时,应该调用此方法,告知:任务已完成
     */
    complete(): void;
}

export interface ITask {
    /**
     * 开始任务
     */
    startTask(subscriber: ITaskSubscriber): void;
    
    /**
     * 当任务完成时会回调的方法
     */
    onDone?(): void;

    /**
     * 当任务取消/错误时会回调的方法
     */
    onError?(err: any): void;

    /**
     * 任务结束时一定会回调的方法
     */
    onFinally?(): void;
}

/**
 * 任务列表.
 * 
 * 以队列的形式依次执行任务
 */
export class HMFTaskQueue {
    private tasks: ITask[] = [];

    /**
     * 所有任务结束时的回调
     */
    onAllFinally?(): void;

    /**
     * 添加任务。
     * 
     * 1、如果添加前没有其他任务,则直接开始此任务;  
     * 2、如果先前有任务,则等待所有任务完成后,再开始此任务
     * 
     * @param task 被添加的任务
     */
    addTask(task: ITask | ((subscriber: ITaskSubscriber) => void)): ITask {
        // @ts-ignore
        let rTask: ITask = task;
        if (typeof task === 'function') {
            rTask = {
                startTask: task
            };
        }

        this.tasks.push(rTask);
        this.tryStartTask(rTask);
        return rTask;
    }

    /**
     * 等待所有任务完成
     * 
     * 如果中途 cancel,则会报错
     * @returns 
     */
    wait() {
        return new Promise<void>((resolve, reject) => {
            const task: ITask = {
                startTask: subscriber => {
                    resolve();
                    subscriber.complete();
                },
                onError: reason => {
                    reject(reason);
                }
            };
            this.addTask(task);
        });
    }

    addFuncTask(func: () => void): ITask {
        return this.addTask(FuncTask(func));
    }

    /**
     * 
     * @param timeout ms
     */
    addTimerTask(timeout: number): ITask {
        return this.addTask(TimerTask(timeout));
    }

    addPromiseTask(promise: Promise<any> | (() => Promise<any>)): ITask {
        return this.addTask(PromiseTask(promise));
    }

    /**
     * 取消所有的任务
     */
    cancelAll(reason?: any) {
        const tasks = this.tasks;
        const onFinally = this.onAllFinally;

        this.tasks = [];

        tasks.forEach(task => {
            task.onError && task.onError(reason);
            task.onFinally && task.onFinally();
        });

        onFinally && onFinally();
    }

    isEmpty() {
        return this.tasks.length <= 0;
    }

    /**
     * 取消某个任务  
     * 
     * 1、如果这个任务还没有开始/已完成,则直接被移除;
     * 2、如果这个任务正在执行,则任务直接被取消,并直接开始下一个任务。
     * 
     * @param task 将被取消的任务
     */
    cancel(task: ITask, reason?: any): void {
        const isRunning = this.isRunning(task);
        const index = this.tasks.indexOf(task);
        if (index >= 0) {
            this.tasks.splice(index, 1);
        }

        if (isRunning) {
            task.onError && task.onError(reason);
            task.onFinally && task.onFinally();
            
            this.tryDoFinally();
            this.tryStartNext();
        }
    }

    private tryStartTask(task: ITask) {
        if (!task) {
            return;
        }

        // 只能开启第1个任务
        if (!this.isRunning(task)) { 
            return;
        }

        const subscriber: ITaskSubscriber = {
            complete: () => {
                if (!this.isRunning(task)) {
                    // 如果此 task 在执行的过程中被移除,
                    // 那么不能再此执行下一个任务
                    return;
                }

                this.tasks.shift();

                task.onDone && task.onDone();
                task.onFinally && task.onFinally();

                this.tryDoFinally();
                this.tryStartNext();
            }
        };

        task.startTask(subscriber);
    }

    private tryDoFinally() {
        if (this.isEmpty()) {
            this.onAllFinally && this.onAllFinally();
        }
    }

    private tryStartNext() {
        this.tryStartTask(this.getTopTask());
    }

    private getTopTask() {
        return this.tasks[0];
    }

    private isRunning(task: ITask) {
        return task && (task === this.getTopTask());
    }
}

function TimerTask(timeout: number): ITask {
    let timeoutId: any = 0;
    const iTask: ITask = {
        startTask: subscriber => {
            timeoutId = setTimeout(() => {
                timeoutId = 0;
                subscriber.complete();
            }, timeout);
        },

        onError: () => timeoutId && clearTimeout(timeoutId)
    };
    return iTask;
}

function FuncTask(func: () => any): ITask; // 未放出不重要的代码
function PromiseTask(promise: Promise<any> | (() => Promise<any>)): ITask; // 未放出不重要的代码


5赞

这个我的项目里面也有写,一般针对某些必须按顺序进行的异步操作

看了下,感觉写的太复杂了,我是一个while+一个booean运行状态+promise任务数组搞定的,不过只有简单的添加任务,删除任务就直接对数组操作

是啊

如果只针对 promise 可以很简洁。

测试一下,还可以

大佬也来一个分享!