任务队列方案记
一 按顺序执行的动画
美好的一天上班时间,你正要喝杯养生茶时,接到了需求:
现在游戏中有两个动画:
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 “等待” 声明
在实现之前,你用笔在纸上思考。
- 既然是
等待
,那么方法名就叫wait
好了; - 接下来,
wait
返回的参数
、返回值
是什么呢?关键在于如何承载等待
逻辑。
-
参数
:如果其用来承载主逻辑,则应该是wait(cb: () => {}): void
,这一看就又回到了 “回调地狱” 的老路上了!否定! -
返回值
:在写到return
这个字时,你灵光一闪:“Promise”!它可以用来承载未来的值,还可以用链式表达式,用在这里刚刚好。至于Promise
的包装值,这里没什么用,直接void
即可。
好了,现在来写方法声明:
// file: GameStartAnim.ts 开局动画<1>
class GameStartAnim extends cc.Component {
// 等待动画完成
wait(): Promise<void>
}
2.4 “wait” 实现
方法已经声明好了,实现起来速度就快。要注意的事项如下:
- [1]. 当前可能没有正在执行的动画;
- [2].
Promise
需要在play
方法里完成。
弄清了要注意的地方,实现起来就轻松了。只需将 promise
对象及其 resolve
和 reject
方法保存起来即可。
代码如下:
// 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; // 未放出不重要的代码