此文算初级内容,主要给新玩家提供一个易用的异步处理方案
一句话总结:完全舍弃 Promise 的 reject 和 catch 功能,只用 resolve。
这个做法是有弊端的,那就是如果 Promise 里报错的话,函数会卡住不继续往下执行。
但是换个角度想,同步函数如果执行过程中报错一样会卡住。我们需要做的是从业务层面做好判空处理,而不是依靠 Promise 来做不可靠的兜底。
具体优劣对比我放在文末,下面先举3个例子看看具体实现。
举例1:串行加载3张图片并显示
假设我们需要加载3张图片,并在加载成功后显示出来。
下面直接上代码,主体流程控制函数是 _loadAndShowPics,异步加载函数是 _loadBundle 和 _loadImage。可以看到三个函数看起来跟同步函数差不多,可读性非常好。
private async _loadAndShowPics(): Promise<void> {
// 先加载 bundle
let bundle: cc.AssetManager.Bundle = await this._loadBundle('image');
if (bundle == null) {
return;
}
// 加载并显示图片1
let image1: cc.SpriteFrame = await this._loadImage(bundle, 'image1');
image1 && (this.pic1.SpriteFrame = image1);
// 加载并显示图片2
let image2: cc.SpriteFrame = await this._loadImage(bundle, 'image2');
image2 && (this.pic2.SpriteFrame = image2);
// 加载并显示图片3
let image3: cc.SpriteFrame = await this._loadImage(bundle, 'image3');
image3 && (this.pic3.SpriteFrame = image3);
}
/** 加载 bundle */
private async _loadBundle(name: string): Promise<cc.AssetManager.Bundle> {
return new Promise((resolve) => {
cc.assetManager.loadBundle(name, (err: Error, res: cc.AssetManager.Bundle) => {
if (err) {
console.log(`加载bundle失败,name: ${name}, err: ${err}`);
}
resolve(res);
});
});
}
/** 加载图片 */
private async _loadImage(bundle: cc.AssetManager.Bundle, path: string): Promise<cc.SpriteFrame> {
return new Promise((resolve) => {
bundle.load(path, cc.SpriteFrame, null, (err: Error, res: cc.SpriteFrame) => {
if (err) {
console.log(`加载图片失败, path: ${path}, err: ${err}`);
}
resolve(res);
});
});
}
举例2:并行加载 image1、image2 并显示,然后加载 image3 并显示
同时加载是并行,Promise 实现并行需要用到 Promise.all,只需要把 _loadAndShowPics 修改成下面这样就好了,另外两个函数不用动。
private async _loadAndShowPics(): Promise<void> {
// 先加载 bundle
let bundle: cc.AssetManager.Bundle = await this._loadBundle('image');
if (bundle == null) {
return;
}
// 加载并显示另外 图片1 和 图片2,此处的 await 会等 Promise.all 里面两个函数执行完之后才往下走
let [image1, image2]: [cc.SpriteFrame, cc.SpriteFrame] = await Promise.all([
this._loadImage(bundle, 'image1'),
this._loadImage(bundle, 'image2')
]);
image1 && (this.pic1.SpriteFrame = image1);
image2 && (this.pic2.SpriteFrame = image2);
// 加载并显示 图片3
let image3: cc.SpriteFrame = await this._loadImage(bundle, 'image3');
image3 && (this.pic3.SpriteFrame = image3);
}
举例3:串行加载2张图片,并在第二张图片加载好的时候播一个缩放动画,播完再加载并显示第三张图片
private async _loadAndShowPics(): Promise<void> {
// 先加载 bundle
let bundle: cc.AssetManager.Bundle = await this._loadBundle('image');
if (bundle == null) {
return;
}
// 加载并显示图片1
let image1: cc.SpriteFrame = await this._loadImage(bundle, 'image1');
image1 && (this.pic1.SpriteFrame = image1);
// 加载并显示图片2
let image2: cc.SpriteFrame = await this._loadImage(bundle, 'image2');
image2 && (this.pic2.SpriteFrame = image2);
// 播放缩放动画
let scaleAction: Promise<void> = new Promise((resolve) => {
cc.tween(this.pic)
.to(1.5, { scale: 2 })
.to(1.5, { scale: 1 })
.call(() => {
resolve();
})
.start();
});
// 这个 await 会等动画播放完毕,然后才往下走
await scaleAction;
// 加载并显示图片3
let image3: cc.SpriteFrame = await this._loadImage(bundle, 'image3');
image3 && (this.pic3.SpriteFrame = image3);
}
总结:
只需要实现下面这个函数结构,就可以轻松处理异步:
// 定义函数
function func(): Promise<void> {
// 建议直接 return new Promise,不要掺杂业务代码,降低代码报错的可能性
return new Promise((resolve) => {
setTimeout(() => {
/** 若干业务代码 */
resolve();
}, 1500);
});
}
// 调用函数
await func();
面对需要返回值的情况,有很多处理方法,我列举三种常用的
第一种:
// 这是直接返回值的方法,相当于函数直接 return ret;
function func(): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
let ret = '';
resolve(ret);
}, 1500);
});
}
// 调用取值直接用等号即可
let res: string = await func();
第二种:
// 有时候不适合直接返回值,可以传一个对象进去接收返回值
function func(data: { res: cc.Node }): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {
let ret = new cc.Node();
// 这是 js 的坑之一,原理与指针类似,此处不展开
// 必须用 data.res = ret 才能让外部 data 正确取到值
// 如果写成 data = ret 的话,外界的 data 不变
data.res = ret;
resolve();
}, 1500);
});
}
// 创建一个变量接收返回值
let data: { res: cc.Node } = { res: null };
await func(data);
// 执行完之后 data.res = 一个node
第三种:
// 这是传统的传入回调函数法
function func(callback: (res: cc.Node) => void): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {
let ret: cc.Node = new cc.Node();
callback && callback(ret);
resolve();
}, 1500);
});
}
let callback: (res: cc.Node) => void = (res: cc.Node) => {
this._picNode = res;
};
await func(callback);
这种写法的利弊分析
前面说过,这个做法是有弊端的,那就是如果 Promise 里报错的话,函数会卡住不继续往下执行。我们用下面这个函数来分析一下
async function main(): Promise<void> {
let test1: number = await func();
console.log('流程点1, 返回值:', test1);
let test2: number = await func(test1);
console.log('流程点2, 返回值:', test2);
}
function func(index: number = 0): Promise<number> {
a.age = 100; // 报错点1
return new Promise((resolve) => {
a.age = 100; // 报错点2
setTimeout(() => {
a.age = 100; // 报错点3
index++;
let ret = index;
resolve(ret);
}, 1500);
});
}
所谓函数卡住,在这里可以理解为无法运行到 main 函数的 流程点1 和 流程点2。
针对 func 函数的3个报错点,我们逐一分析:
报错点1:此时未生成 Promise,需要 try catch 捕获错误并返回一个只执行 reject 的 Promise
报错点2:已生成 Promise,需要用 Promise.catch 捕获
报错点3:异步执行后报错,Promise.catch 无法捕获,需要用 try catch 捕获错误后调用 reject 返回错误
改造后的函数如下:
async function main(): Promise<void> {
let test1: number | void = await func().catch((e) => { console.log(e); });
console.log('流程点1, 返回值:', test1);
let test2: number | void = await func(test1 as number).catch((e) => { console.log(e); });
console.log('流程点2, 返回值:', test2);
}
function func(index: number = 0): Promise<number> {
try {
a.age = 100; // 报错点1
return new Promise((resolve, reject) => {
a.age = 100; // 报错点2
setTimeout(() => {
try {
a.age = 100; // 报错点3
index++;
let ret = index;
resolve(ret);
} catch (error) {
reject(error);
}
}, 1500);
});
} catch (error) {
return new Promise((resolve, reject) => { reject(error); });
}
}
哪怕不考虑大量使用 try catch 可能带来的问题,改造后的代码在可读性上也差了很多,写起来又很繁琐,很难在项目中实际推行。
并且 2.4.6 和 2.4.7 使用 Promise.catch 捕获错误的时候无法定位到报错代码的堆栈,只能定位到 .catch 的位置,比较影响调试。
通常情况下异步报错往往是这种情况:
加载 image1,然后调用 image1.show(),在 image1 加载失败的时候就报错了。或者网络请求数据 data,然后调用 data.xxx 的时候报错。
这种情况我称之为业务逻辑,完全可以在业务代码中用判空处理来解决,并且在构造异步函数的时候尽量遵循单一原则,不相关的逻辑不要放在异步函数内部。
全文完。