0%

CocosCreator 中单元测试入门

一、是什么

  单元测试是针对一个小模块(函数、类)做的检查。检查模块在执行后,它的输出是否符合期望。简单来说,就是测试咱们写的小模块正确。

二、为什么

  那为什么需要单元测试呢?因为它会带来很多好处。

  • 1、保证小模块的编写是正确的;
  • 2、大模块、程序是由小模块组成的,这也保证了程序的质量;
  • 3、易于重构。有了单元测试,我们可以大胆的重构代码;
  • 4、使代码更加清晰、简洁。

  很多大神已经总结过单元测试的好处了。其本质是能减轻咱们开发的工作量,使工作变得轻松。

三、代码分类

  我们需要在具体进行操作之前,先定义两个概念:

  • 1、业务逻辑代码;
  • 2、界面、用户交互代码;
  • 3、原生代码。

  在 CocosCreator 工程中,我们写的代码大致可以分为以上这几种。   

3.1、业务逻辑代码

  业务逻辑代码主要是一些业务计算逻辑。比如:数据建模、算法、字符串或数组等API的扩展。这些代码不会涉及到任何 CocosCreator 中的 API。我们的测试重点也在于这些代码。   

3.2、界面、用户交互代码

  凡是使用了 CocosCreator 中的 API,都可以归为此类代码。比如自定义的组件、cc.Label、cc.Sprite 等。
  这些代码用户展示界面、处理用户的交互行为。一般依赖于开发、专业的测试人员进行白盒测试;另外,这些代码依赖了引擎 API,所以运行这些代码时,需要引擎代码参与,这对于单元测试是一个比较大的限制。所以此类代码不在本文的讨论范围。   

3.3、原生代码

  iOS、android 等原生代码。这些原生已经有成熟的测试工具,不在本文讨论范围。   

3.4、小结

  经过上面的总结,可以发现,本文讨论的单元测试,由于各类限制,仅适用于业务逻辑代码部分。这会强迫你将业务代码和界面交互代码分离,这对于清洁的代码而言,也是一个好消息。
  
  接下来我们开始动手准备单元测试了。

四、测试

1、环境准备

  在单元测试前,我们需要搭建后测试的环境。 主要是安装两个软件。   

4.1.1、NodeJs

  NodeJs 是我们测试代码运行起来的环境。下载地址是:https://nodejs.org/en/download/。建议安装 LTS 版本。   

4.1.2、npm

  npm 是 js 的包管理器。上面有很多第三方的 js 库。一般而言在安装 NodeJs 时,会附带安装好 npm。
  
安装验证:

1
2
node -v  # 会输出如: v12.16.1
npm -v # 会输出如:6.14.5

2、开始

4.2.1、CocosCreator 工程结构

  一个典型工程结构如下(去掉无关的文件及目录):

1
2
3
4
5
6
7
8
9
.
├── assets
│   ├── Scene
│   ├── Script
│   └── Texture
├── creator.d.ts
├── jsconfig.json
├── project.json
└── tsconfig.json

  我们写的代码一般都放到 ./assets/Script 目录下。咱们的工程里,测试的环境有一个要求:不能对 CocosCreator 工程本身造成影响。这意味着测试代码、配置不能让 CocosCreator 感知到。

4.2.2、初始化为 nodejs 工程

  咱们的单元测试是建立在 nodejs 工程上的,在工程的根目录下运行初始化命令:

1
npm init # 初始化 nodejs 工程

  经过交互,最终会生成一个 package.json 文件,其内容如下:

1
2
3
4
5
6
7
8
9
10
11
{
"name": "testdemo",
"version": "1.0.0",
"description": "Hello world new project template.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}

  这里可以先关注上面的 scripts.test 属性。
  
  scripts 下面的属性,都可以当作命令运行: npm run commond。比如你可以添加一个显示目录的命令 (类 unix 下):

1
2
3
4
5
6
{
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"ls": "ls -al"
},
}

  现在,你可以在工程根目录下运行命令:npm run ls,这会在控制台打印出当前目录。扩展一下:咱们就可以方便地定义很多命令了,比如构造工程、图片压缩等。
  
  结合上面的内容,我们可以轻易地看到,运行测试的命令是:npm run test。其实 test 是一个特殊的命令,你还可以这么运行:npm test,而且它还有两个别名:npm t,这很方便。试一下:   

1
npm t # 输出:Error: no test specified

  由于测试环境没有搭建完成,所以给了上面的错误提示。

4.2.3、开始单元测试(jest)

  Nodejs 下的单元测试库很多。比如:mochajsjest 等,都是很优秀的库。本文采用 jest。其官网是:https://jestjs.io/docs/en/getting-started,上面的文档很详细,建议阅读。
  
  在项目根目录下运行命令安装 jest (ts 工程):

1
2
3
4
5
# 如果是ts工程:
npm install --save-dev ts-jest

# 如果是js工程:
npm install --save-dev jest

  这会在咱们工程里安装 jest 包,除此之外,还需要安装包:

1
2
3
4
npm i --save-dev @types/jest

# 如果是 ts 工程:
npm install --save-dev typescript

  @types/jest 用于在写单元测试时,在 vscode 中给出代码提示。
  
  安装好包后,修改测试命令(package.json文件):

1
2
3
4
5
{
"scripts": {
"test": "jest"
},
}

  运行测试命令试一下:

1
npm t #输出:No tests found

  运行成功,但 jest 没有找到需要进行的单元测试。
  
  我们先准备被测试的素材 ./assets/Script/util/readableNum.ts 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 分割字符串
* @param str 将会被分割的字符串
* @param charCount 每 `charCount` 个会被分割
* @param divChar 分割字符。
*/
function division(str: string, charCount: number, divChar: string = ',') {
const chars: string[] = [];
let count = 0;
for (let i = str.length - 1; i >= 0; --i) {
chars.push(str[i]);

count++;
if (count % charCount == 0 && i !== 0) {
chars.push(divChar);
}
}

const result = chars.reverse().join('');
return result;
}


/**
* 使数字可读。如:1004213 -> '1,004,213'
* @param number
* @param divChar 分割字条符
*/
export function readableNum(number: number, divChar = ',') {
if (String(number).length <= 3) {
return String(number);
}

if (number < 0) {
return '-' + division(String(-1 * number), 3, divChar);
}

return division(String(number), 3, divChar);
}

  现在来写针对该文件里的 readableNum 函数的单元测试。首页考虑的事:测试文件放到哪里呢?为了不对工程本身造成影响,不能放到 ./assets 目录下。可以在工程根目录下新建一个 test 目录,建完后,如下:

1
2
3
4
5
6
7
8
9
10
11
12
.
├── assets
│ ├── Scene
│ ├── Script
│ │ └── util
│ │ └── readableNum.ts
│ └── Texture
├── test
├── creator.d.ts
├── jsconfig.json
├── project.json
└── tsconfig.json

  咱们写的单元测试文件都会放到该目录下。
  
  在 jest 中,测试文件名与被测试文件名相同,文件名中间加上 test,比如 readableNum.ts 的测试文件名应该是:readableNum.test.ts;然后保持相同的目录结构。基于这样的规则,我们新建文件:./test/util/readableNum.test.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.
├── assets
│ ├── Scene
│ ├── Script
│ │ └── util
│ │ └── readableNum.ts
│ └── Texture
├── test
│ └── util
│ │ └── readableNum.test.ts
├── creator.d.ts
├── jsconfig.json
├── project.json
└── tsconfig.json

  编辑 readableNum.test.ts 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { readableNum } from "../../assets/Script/util/readableNum";

test('readableNum', () => {
expect(readableNum(1000)).toBe('1,000');
expect(readableNum(10000)).toBe('10,000');
expect(readableNum(416506250)).toBe('416,506,250');
expect(readableNum(416506250, '.')).toBe('416.506.250');
expect(readableNum(416506250, '')).toBe('416506250');
expect(readableNum(-600 * 1000, '.')).toBe('-600.000');
expect(readableNum(-600 * 1000 * 1000, '.')).toBe('-600.000.000');
expect(readableNum(-121892262728, '.')).toBe('-121.892.262.728');
expect(readableNum(0)).toBe('0');
});

  基于 jest 文档说明,上面测试简单说明一下:test 方法开启一个单元测试,第 1 个参数是名字,第 2 个参数是测试的内容。expect 方法参数中传入被测试的内容,toBe 是期望的结果。这里只是简单做的示范,jest 远比这里展示的丰富。
  
  接下来,我们还得告诉 jest 一些我们的配置,让它去哪个目录找测试文件。在根目录下新建:./jest.config.js 文件:

1
2
3
4
5
6
7
8
9
10
module.exports = {
preset: "ts-jest", // 如果是 js 工程,则是 "jest"
testEnvironment: 'node', // 测试代码所运行的环境
// verbose: true, // 是否需要在测试时输出详细的测试情况
rootDir: "./test", // 测试文件所在的目录
globals: { // 全局属性。如果你的被测试的代码中有使用、定义全局变量,那你应该在这里定义全局属性
window: {},
cc: {}
}
};

  
  好了,我们可以使用 npm t 测试了。如果你看到的是绿色的 PASS ,则表示测试通过啦。
  
  下图是一个测试通过的示例:

此处输入图片的描述

微服务一:概述

微服务是最近几年大热的话题。不过这倒不是这几年才有东西,而是在零几年就有大公司们实施了,不过那时候不叫这个名字。

一、微服务是什么

所谓微服务,就是大的服务器拆分成各种小的服务器,由各种不同功能的小服务组合成对外完整功能的服务器。

为什么需要拆分呢?

传统型单服务的问题

以前,一个产品,一般对应一个后台服务器。随着功能不断迭代,服务器代码越来越复杂,越来越庞大,问题也越来越多:

1. 维护成本高

随着时间推移,不断迭代,产品功能增多,模块不断增加,开发人员增减、变化,都在一起进行,这些都会相应增加维护成本。需求增加,可能会造成模块间的交互错综复杂,接下来开发的时间成本必然增加。如果开发时间不足,会引起代码混乱,得不偿失,但这是很常见的情况,不懂行的上司或老板们会说:“明明上个功能不用这么长的时间呀,得加快”。复杂、功能多的产品,代码量也不少,那新入职的开发者想要上手的时间也就会增加,因为老代码不是想动就能动的(国内测试驱动开发可不多),万一造成线上问题呢?

由于是单系统,当新版本上线时,势必需要重启线上环境。大型系统启动时间是有成本的,也许是几十秒钟,也许是几分钟。也许咱们只是修复部分数据显示错误的问题,但整个系统重启,想想那些正在使用其他功能的用户们,体验肯定不好。

2. 开发成本高

同样地,维护成本增加,开发成本也必然增加。数据库增加表、老表加字段等。 我甚至听说过200多个表、单个表超过 50 多个字段的数字库,想必这开发起来还是蛮有难度的。

3. 系统扩展困难

随着系统的用户增加,系统的压力会随之增大,内存需要更大的,CPU 需要更快的才能系统的需求。这时就需要升级硬件来满足扩展的需求,但硬件升级很容易就到头,而且成本也非常高。所以通过硬件升级,上限是很低的。

来看看这个搞笑的极端例子:史上最烂的项目:苦撑 12 年,600 多万行代码

上面例举了部分单机服务器的问题,咱们再来看看微服务。

微服务方案

微服务就是将单个系统根据功能拆分成多个功能完备的小系统。比如,一个电商产品,可以分解成:用户服务器、订单服务器、商家服务器、产品服务器、秒杀服务器、产品推荐服务器等。

通过拆分后,有不少的优点:

1. 单个小服务器开发成本低

服务经过拆分后,单个服务的功能少,清晰,开发起来当然更简单。新入职的开发也能更简单上手了。

2. 单服务维护成本低

服务小了,维护起来更简单了。由于代码量少、依赖的东西少,启动速度也更快一些。

3. 扩展简单

多个小服务,我们可以布署到多台机器上。甚至可以到达上万台机器。这样系统压力增大时,我们可以通过简单的增加低成本的服务器即可达到扩展的目的。

4. 不限制语言

一般单系统时,我们会采用一种或少数语言开发。现在只有服务器采用统一的通信方式(如 rest api),就可以不限制语言了。比如有的用 Java,有的用 C++,有的用 Node.js 等,任由工程师们发挥。

二、微服务的问题

微服务解决了不少单系统的问题,但同时,它也有不少自身的问题。

1. 服务器间的交互成本

用户功能是多个微服务间交互完成的。比如用户下单,会涉及到用户服务、订单服务、支付服务、仓库服务等。这些交互都是通过网络异步进行的,速度肯定没有单服务器来的快。

2. 服务管理难

如果有非常多个微服务,那就需要有能管理服务管理工具了。想想,如果 有成面上千个服务,纯靠手工一个一个管理,是什么样的场景,运维会哭瞎吧。另外,如果某个服务崩溃了、或者网络线缆被挖断了等问题。

3. 事务成本

以上面例子来说,单系统下,直接开启数据库事务即可。但在多服务下,很难保证所有服务都不出问题。

4. 调试难

由于常有跨服务调用,当出现问题时,我们很难快速定位到问题所在。

当然还有其它问题和挑战,都是多服务才有的问题。

总结

微服务是将单系统拆分成多个服务。一个完成的功能通过多个服务间调用达成目的。其解决了一部分单系统下的问题,但它有多个自身独特的问题,它并不是万能药。如果系统的功能不多,或者只能少数开发者,则不建议采用微服务,或只拆分出少数服务。

用 Typescript 写个状态机

有限状态机,是常用的一种编程范式。游戏领域和编译器领域等工作的小伙伴,应该很常用的了。如果不熟悉,那咱们先来看看状态机是什么。

状态机

假设有这样一个需求

在介绍状态机之前,假设有这样一个需求:咱们在开发一款打斗类游戏,游戏里有一个主角,咱们要通过键盘控制主角的行为。主角可以站立、蹲着、跳跃,这些行为的流程如下:

英雄行为

总结一下:

  1. 通过键盘按钮主角的行为;
  2. 主角的行为包括:站立、蹲着、跳跃;
  3. 站立(按S) -> 蹲着;蹲着(按空格) -> 站立;
  4. 站立(按空格) -> 跳跃;跳跃 -> 站立;

很明显,“蹲着” 不能直接到 “跳跃”。

接下来,按照一般的思路,实现这个需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Hero {
isStand = false; // 是否是站立
isKneel = false; // 是否是蹲着
isLeap = false; // 是否在空中

handleInput(event: any) {
if (event == 'S') { // 按下 'S' 键
if (this.isStand) { // 如果是站立,则蹲下
this.toKneel();
}
} else if (event == 'Space') { // 按下 ‘空格’ 锋
if (this.isStand) { // 如果是站立,则跳起
this.jump();
} else if (this.isKneel) { // 如果是蹲着,则站起
this.standup();
}
} else if (event == 'down') { // 下落事件
if (this.isLeap) { // 如果在空中,则站起
this.land();
}
}
}

toKneel() {
// ... 蹲下
this.isStand = false;
this.isKneel = true;
}

jump() {
// ... 跳起来
this.isStand = false;
this.isLeap = true;
}

standup() {
// ... 站起来
this.isStand = true;
this.isKneel = false;
}

land() {
// ... 落地
this.isStand = true;
this.isLeap = false;
}
}

上面的代码中,我们使用 isXXX 变量来标识是当前的状态。在输入事件处理方法中,根据 状态event 调用相应的方法。

现在一切都很完美,可以泡杯绿茶好好享受一下了。

但还没喝到五分钟,产品小姐姐跑到你的身边,“为了不使英雄这么单调,我给他加了些需求。包括行走、跑、二段跳、三段跳”,小姐姐看着你甜甜地说道。于是只好将手中杯子放下,敲起了键盘。

现在新增了四个行为:“行走、跑、二段跳、三段跳”

咱们如下实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Hero {
isStand = false; // 是否是站立
isKneel = false; // 是否是蹲着
isLeap = false; // 是否在空中
isWalking = false; // 是否在行走
isRunning = false; // 是否在跑
isLeapTwo = false; // 是否是二段跳
isLeapThree = false; // 是否是三段跳

handleInput(event: any) {
if (event == 'S') {
if (this.isStand) {
this.toKneel();
}
} else if (event == 'DA' || event == 'DD') { // 按下 A 或 D 键
if (this.isStand) {
this.toWalk(); // 如果站立,则行走
} else if (this.isWalking) {
this.toRunning(); // 如果正在跑,则行走
}
} else if (event == 'UA' || event == 'UD') { // 松开 A 或 D 键
if (this.isWalking || this.isRunning) {
this.standup(); // 如果是正在行走 或 跑,则到站立
}
} else if (event == 'Space') {
if (this.isStand) {
this.jump();
} else if (this.isLeap && !this.isLeapTwo && !this.isLeapThree) {
this.toLeapTwo(); // 如果正在空中,则进入二段跳
} else if (this.isLeapTwo) {
this.toLeapThree(); // 如果正在二段跳,则进入三段跳
} else if (this.isKneel) {
this.standup();
}
} else if (event == 'down') { // 下落事件
if (this.isLeap || this.isLeapTwo || this.isLeapThree) {
this.land();
}
}
}

......
}

代码写到一半,小姐姐又过来了:“要给英雄加上技能,有的技能要在站着时才能放,有的要在空中才能放,还有的技能要续能…”。想想这些需求吧,如果我们继续按照上面的代码那样处理,代码会是一个什么样的状况?无数的状态变量,深得可怕的分支,一大堆的 bug …

用状态机实现这个需求

状态机适合处理这种多状态的需求。可以用 enum 将各状态定义出来,再使用根据这些状态来进行处理:

1
2
3
4
5
6
7
8
9
enum EHeroStatus {
Stand,
Kneel,
Walking,
Running,
Leap,
LeapTwo,
LeapThree,
}

上面的代码将英雄的状态都用枚举列出来了,现在根据这些状态再来看看处理的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Hero {
status: EHeroStatus;

handleInput(event: any) {
if (this.status == EHeroStatus.Stand) {
if (event == 'S') {
this.toKneel();
} else if (event == 'DA' || event == 'DD') {
this.toWalk();
} else if (event == 'Space') {
this.jump();
}
} else ...
}
}

这里将各类状态分开,再处理各类输入的事件。

别看这里改动不大,但成功地将复杂度降低了不少,再每一个状态中,只需简单考虑事件与下一个状态的转换关系即可。当然,这里可以将状态封装成类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
interface HeroState {
handInput(hero: Hero, input: Input): void;
}

class StandState implements HeroState {
handInput(hero: Hero, input: Input) {
if (input == 'S') {
hero.toState(new KneelState());
} else if (input == 'DA' || input == 'DD') {
hero.toState(new WalkState());
} else if (input == 'Space') {
hero.toState(new SpaceState());
}
}
}

class KneelState implements HeroState ...

class Hero {
private state: HeroState;

handleInput(input: Input) {
this.state.handInput(this, input);
}

toState(state: HeroState) {
this.state = state;
}
}

上面的代码中,Hero 类的 handleInput 方法清晰明了,内部的逻辑已经封装到那些状态类内部了。

以上,就是一个简单的状态机了。

Typescript 实现

Typescript 是一门像极了 Java 的动态语言,可它本质上,还是 Javascript。在写这个状态机库之前,我们先整理一下需求,即想好库应该写成什么样子:

  1. 先定个名字。嗯,就叫 StateMachine 吧;
  2. 抽象状态。上面第二种状态机代码实现中,将每一种状态都抽象成类,这样处理,会将 状态逻辑 完全耦合在一起,难以复用。所以将 状态 抽象出来;
  3. 抽象状态转换时的行为。上面第一种状态机代码实现中,就是将状态定义为枚举,但在其业务逻辑却混在一起,所以需要将这一部分抽象出来;
  4. 状态转换验证。状态间的转换是有一定规则的,比如二段跳状态,其前置状态必定是跳跃状态,所以需要有一个验证机制;
  5. ‘*’ 支持。有时不希望转换被前置条件限制,比如:重置功能。

1. State

状态。在咱们这里,不需要做任何限制,直接以模板参数表示即可。

2. Transition

状态机,主要在于状态之间的转换。首先,我们将状态间的转换抽象出来。把 状态转换 定义为 Transition。转换的数据成员包括:来源状态(即上一个状态)、去向(转换后的状态),再加一个转换触发时的回调,这样就提供给外部进行逻辑处理的机会。

1
2
3
4
5
6
7
8
9
/** 状态转换描述接口 */
interface ITransitionDir<State> {
/** 来源状态,可以是 1 到 N 状态,'*' 表示任何状态 */
from: State | State[] | '*';
/** 转换后的目标状态 */
to: State;
/** 转换触发时的回调 */
onTransition?: (from: State, to: State) => void;
}

为了表示方便,可以导出一个构建函数:

1
2
3
export function BuildTransition<State>(from: State | State[] | '*', to: State, onTransition?: (from: State, to: State) => void): ITransitionDir<State> {
return {from, to, onTransition};
}

3. 初始化条件

这些条件是需要由用户提供的。基本条件有两个:

  1. 初始状态;
  2. 转换对象。

用代码示例为:

1
2
3
4
5
6
7
8
interface STOptions<State> {
init: State;
transitions: {
transName1: ITransitionDir<State>;
transName2: ITransitionDir<State>;
...
}
}

实际上,这些数据描述了状态机的数据的流向。我们的在此要实现的状态机代码本质上是对这些数据的自驱动。上面的这个接口有一个问题:transitions 对象里的 keys ,即状态转换的名字应该是由用户定义的,如上面的代码直接给出名字,明显不合理。还好 typescript 的模版功能非常强大,它能将某个对象里所有已知的属性名展开:

1
2
3
type IDemo<T> = {
[P in keyof T]: string;
}

这里的代码意思就是将 T 中的属性依次取出,并且其类型为 string。如:

1
2
3
4
5
6
7
interface IA {
a: number;
b: string;
c: () => void;
}

let b: IDemo<IA>;

此时变量 b 的类型是:

1
2
3
4
5
inetface TypeB {
a: string;
b: string;
c: string;
}

利用这个方便的特性,我们再定义一次 STOptions

1
2
3
4
5
6
7
8
type ITransitions<T, State> = {
[P in keyof T]: ITransitionDir<State> | ITransitionDir<State>[];
}

interface STOptions<T, State> {
init: State;
transitions: ITransitions<T, State>;
}

4. 定义类

现在可以定义类了,先利用已经的信息将框架定义出来:

1
2
3
4
5
export class StateMachine<T, State> {

constructor(option: STOptions<T, State>) {
}
}

那咱们应该如何来进行状态转换呢?最方便的当然是使用用户自己定义的名字啦,我们可以再使用上面那个套路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
type TransitionCall<T> = {
[P in keyof T]: () => void;
};

export class StateMachine<T, State> {
private _transitions: TransitionCall<T>;
private _curState: State;
private _originTransitions: ITransitions<T, State>;

constructor(option: STOptions<T, State>) {
const {init, transitions} = option;

this._curState = init;

this.setupTransitions(transitions);
}

private setupTransitions(transitions: ITransitions<T, State>) {
this._originTransitions = transitions;
this._transitions = {} as any;

Object.keys(transitions).forEach(k => {
const key = k as keyof T;

const value: ITransitionDir<State> | ITransitionDir<State>[] = transitions[key];

this._transitions[key] = (() => {
//进行状态转换
...
});
});
}


transition() {
return this._transitions;
}
}

现在可以这么用了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

enum EQiuActionStatus {
None = 'None',
PreAction = 'PreAction',
MyTurn = 'MyTurn',
Standup = 'Standup',
};

const option = {
init: EQiuActionStatus.None,
transitions: {
step: [
BuildTransition(EQiuActionStatus.None, EQiuActionStatus.PreAction),
BuildTransition(EQiuActionStatus.PreAction, EQiuActionStatus.MyTurn),
BuildTransition(EQiuActionStatus.MyTurn, EQiuActionStatus.Standup),
BuildTransition(EQiuActionStatus.Standup, EQiuActionStatus.None),
],
reset: BuildTransition('*', EQiuActionStatus.None, (from, to) => console.log(from, to)),
}
};

const fsm = new StateMachine(option);
fsm.transition().step();
fsm.transition().reset();

现在来给这个类添砖加瓦:

一. 需要能获取到当前的状态:

1
2
3
4
5
6
7
8
export class StateMachine<T, State> {
...
/** 获取当前状态 */
state() {
return this._curState;
}
....
}

二. 状态转换是有前提的,所以需要一个方法,判断能不能进行转换:

1
2
3
4
5
6
7
8
export class StateMachine<T, State> {
...
/** 是否可以进行 `t` 转换 */
can(t: keyof T) {
...
}
....
}

三. 还需要状态转换前、后、错误回调:

1
2
3
4
5
6
7
export class StateMachine<T, State> {
...
onBefore?: (from: State, to: State) => void;
onAfter?: (from: State, to: State) => void;
onError?: (code: number, reason: string) => void;
....
}

上面代码的基本框架都已准备好,细节可以自行实现。想偷懒可以看这里:https://github.com/NathanLi/typescript-state-machine

完结

首先我们简单介绍了状态机,接下来使用 typescript 进行可用的简单实现。当然状态机远不止于此,比如有的开发者们,为了方便自己或产品同事们,还会自己写一套可视化、可配置的状态机工具。

CocosCreator 主循环源码浅析

标签(空格分隔): CocosCreator


MainLoop

  游戏有好看的界面、有趣的动画,能响应用户的操作,所以才好玩。基本动画的本质是在于随着时间不断地绘制图片,这跟电影放映原理是有些类似的。但不同之处在于,游戏的界面是根据时间、用户输入、定时器等信息动态生成并绘制的,也就是说需要有逻辑控制游戏内容。我们可以将游戏抽象成以下几个内容:

  1. 处理用户事件
  2. 处理定时器事件
  3. 绘图

  应用在运行的过程中,会不断地重复上面的逻辑。我们可以用下面的代码来表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
// JavaScript

// onTick 表示要处理的任务
function onTick() {
handleUserEvents(); // 处理用户输入等事件
handleSchedules(); // 处理定时器事件
draw(); // 绘图
...
}

while(true) {
onTick();
}

  上面的代码显然不合理,这会使 CPU 的使用达到 100%,所以需要能每隔一段时间就能执行一次 onTick 的那种。比如定时器:

1
2
// JavaScript
setInterval(onTick, 33) // 大概每秒执行 (1000 / 33) = 30 次

  这样就好多了,但其实我们有更好的选择。屏幕在亮起工作的时候,会不断地刷新,一般手机上是每秒 60 次,在每次屏幕刷新前都执行任务,这会带来几个好处:

  • 平台或浏览器会自动优化刷新时机
  • 窗口没激活时,任务不会工作,能节省 CPU 资源。

  接下来,我们看看不同三大平台(浏览器、iOS 原生、Android 原生)如何实现这个功能。

浏览器中的 requestAnimationFrame

  在浏览器中,window.requestAnimationFrame() 方法方法告诉浏览器您希望执行动画并请求浏览器在下一次重绘之前调用指定的函数来更新动画。该方法使用一个回调函数作为参数,这个回调函数会在浏览器重绘之前调用。我们利用这个方法来重新实现上面的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// JavaScript
var intervalId, callback;

callback = function() {
/** callback 被调用 */

// 告诉浏览器下次重绘前调用 callback
intervalId = window.requestAnimationFrame(callback);

// 执行任务
onTick();
}

// 告诉浏览器下次重绘前调用 callback
intervalId = window.requestAnimationFrame(callback);

  如上面代码所示,我们通过回调成功地让浏览器在每次重绘前都调用 callback

  iOS 原生的 Core Animation 框架中,有一个 CADisplayLink 类。这是一个计时器对象,它允许应用程序将其绘图与显示的刷新速率同步。它可以绑定一个方法,该方法会被屏幕刷新时调用。我们看一下它的实现方式:

1
2
3
4
5
6
7
// Swift

// 创建绑定了 self 的 onTick 方法的 CADisplayLink 对象
let displaylink = CADisplayLink(target: self, selector: #selector(onTick))

// 注册。接下就系统会在合适的时候调用 onTick 方法
displaylink.add(to: .current, forMode: .defaultRunLoopMode)

  Cocos Creator 在 CCDirectorCaller-ios.mm 文件中使用了 CADisplayLink

Android 下的 GLSurfaceView

  Android 原生有一个类 android.opengl.GLSurfaceView,它内部定义了 Renderer 接口,该接口声明了 void onDrawFrame(GL10 gl) 方法。该方法负责绘制当前帧。我们可以继承这个类并实现该方法:

1
2
3
4
5
6
7
8
9
10
11
// Java
public class Renderer implements GLSurfaceView.Renderer {
@Override
public void onDrawFrame(final GL10 gl) {
onTick();
}

public void onTick() {
//...
}
}

  Cocos Creator 在 org.cocos2dx.lib.Cocos2dxRenderer 中实现了 onDrawFrame 方法。

本文以 Web 平台为主,各平台原理基本相同。接下来不会再涉及其它到原生代码。

MainLoop 简化源码

  在游戏引擎中,上面的循环中执行的方法 onTick 实际上就是 CCDirector 中的 mainLoop 方法。也就是主循环了。下一节中,我们通过源码来了解其中的基本流程。

源码解析

MainLoop 在引擎中被调用的流程

  Cocos Creator 游戏引擎的入口在 main.js 中,它在 cocos2d-js-min.js 加载完后会开始运行游戏:cc.game.run()。源码如下:

1
2
3
4
5
6
7
8
// main.js

function boot () {
//....
cc.game.run();
}

boot();

  在 run 方法中,会根据配置进行引擎的准备工作,包括初始化渲染器、全局视图对象 cc.view、导演 cc.director 还有注册系统各类事件等工作。简化代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// CCGame.js

var game = {
prepare: function() {
// 初始化渲染器
this._initRenderer();

// cc.view 是全局的视图对象。
cc.view = View ? View._getInstance() : null;

// 初始化导演类
cc.director = cc.Director._getInstance();

// 初始化 OpenGLView
cc.director.setOpenGLView(cc.view);

// 注册各类事件
this._initEvents();

// 开始运行主循环
this._runMainLoop();
},

run: function () {
this.prepare();
},

//.... 其它方法
}

cc.game = game;

  从上面的代码中可以看到,引擎在完成初始化工作之后,就会开始运行主循环了:this._runMainLoop(); 我们再看一下这个方法的工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// CCGame.js

var game = {
// ... 其它方法

// 开始运行游戏
_runMainLoop: function () {
var callback, director = cc.director;

callback = function () {
self._intervalId = window.requestAnimationFrame(callback);

// 调用导演类的 mainLoop 方法
director.mainLoop();
}
};

self._intervalId = window.requestAnimationFrame(callback);
}
}

cc.game = game;

  这里在上文介绍过,利用 window.requestAnimationFrame 及其回调函数来实现循环。循环里调用的是 director.mainLoop();

  流程图如下:

1
2
3
4
5
6
7
8
boot=>start: Boot()
gamerun=>inputoutput: cc.game.run()
gameprepare=>inputoutput: cc.game.prepare()
gamerunloop=>inputoutput: cc.game._runMainLoop();
requestAnimationFrame=>inputoutput: window.requestAnimationFrame()
directormainloop=>inputoutput: director.mainLoop();

boot->gamerun->gameprepare->gamerunloop->requestAnimationFrame->directormainloop->requestAnimationFrame

  从上面的流程中,可以看到,director.mainLoop 是引擎的逻辑控制的中枢。接下来,我们看看这个方法内部。

CCDirector

  cc.director 是一个单例对象,管理你的游戏逻辑流程,它还负责同步定时器与显示器的刷新速率。这个对象在引擎做初始化工作时,就被创建了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// CCGame.js
var game = {
prepare: function() {
// 初始化导演类
cc.director = cc.Director._getInstance();
}
}

// CCDirector.js
cc.Director._getInstance = function () {
if (cc.Director.firstUseDirector) {
cc.Director.firstUseDirector = false;
cc.Director.sharedDirector = new cc.DisplayLinkDirector();
cc.Director.sharedDirector.init();
}
return cc.Director.sharedDirector;
};

  可以看到,实际上的 cc.directorDisplayLinkDirector 类的实例,这个类继承自 Director :

1
2
3
4
5
6
7
8
// CCDirector.js
cc.DisplayLinkDirector = cc.Director.extend({
//...

mainLoop: function() {
//....
}
});

  mainLoop 方法也是在这个类中实现的。在深入这个方法之前,我们先回顾一下游戏逻辑中最常用的类:cc.Component,下面列出我们常继承或使用的逻辑流程方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class MyComponent extends cc.Component {
/** 用于继承的方法 */

// 当附加到一个激活的节点上或者其节点第一次激活时候调用。onLoad 总是会在任何 start 方法调用前执行,这能用于安排脚本的初始化顺序。
onLoad() {}

// 如果该组件第一次启用,则在所有组件的 update 之前调用。通常用于需要在所有组件的 onLoad 初始化完毕后执行的逻辑。
start() {}

// 如果该组件启用,则每帧调用 update。
update(dt) {}

// 如果该组件启用,则每帧调用 LateUpdate。
lastUpdate() {}

// 当该组件被启用,并且它的节点也激活时。
onEnable() {}

// 当该组件被禁用或节点变为无效时调用。
onDisable() {}

// 当该组件被销毁时调用
onDestroy() {}

/** 下面的方法是需要组件自身主动调用的 */

// 调度一个自定义的回调函数。
// 如果回调函数已调度,那么将不会重复调度它,只会更新时间间隔参数。
schedule(callback, interval, repeat, delay) {
var scheduler = cc.director.getScheduler();
var paused = scheduler.isTargetPaused(this);

scheduler.schedule(callback, this, interval, repeat, delay, paused);
}

// 调度一个只运行一次的回调函数,可以指定 0 让回调函数在下一帧立即执行或者在一定的延时之后执行。
scheduleOnce (callback, delay) {
this.schedule(callback, 0, 0, delay);
}

// 取消调度一个自定义的回调函数。
unschedule(callback) {
cc.director.getScheduler().unschedule(callback_fn, this);
}

// 取消调度所有已调度的回调函数:定制的回调函数以及 'update' 回调函数。动作不受此方法影响。
unscheduleAllCallbacks () {
cc.director.getScheduler().unscheduleAllForTarget(this);
}
}

  再回到 mainLoop,简化后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// CCDirector.js

mainLoop() {
// 计算全局的时间增量,即 dt
this.calculateDeltaTime();

// 每个帧的开始时所触发的事件。
this.emit(cc.Director.EVENT_BEFORE_UPDATE);

// 对最新加入的组件调用 `start` 方法
this._compScheduler.startPhase();

// 调用组件的 `update` 方法
this._compScheduler.updatePhase(this._deltaTime);

// 调用调度器的 `update` 方法
this._scheduler.update(this._deltaTime);

// 调用组件的 `lateUpdate` 方法
this._compScheduler.lateUpdatePhase(this._deltaTime);

// 将在引擎和组件 “update” 逻辑之后所触发的事件。
this.emit(cc.Director.EVENT_AFTER_UPDATE);

// 回收内存
cc.Object._deferredDestroy();

//
if (this._nextScene) {
this.setNextScene();
}

// 访问渲染场景树之前所触发的事件。
this.emit(cc.Director.EVENT_BEFORE_VISIT);
// 访问渲染场景树
this._visitScene();
// 访问渲染场景图之后所触发的事件,渲染队列已准备就绪,但在这一时刻还没有呈现在画布上。
this.emit(cc.Director.EVENT_AFTER_VISIT);

// 绘图渲染
cc.g_NumberOfDraws = 0;
cc.renderer.clear();

cc.renderer.rendering(cc._renderContext);
this._totalFrames++;

// 渲染过程之后所触发的事件。
this.emit(cc.Director.EVENT_AFTER_DRAW);

eventManager.frameUpdateListeners();
}

  上面的代码展示了 mainLoop 所做的事情。可以看到,在各阶段的前后,都有相应的事件发出:EVENT_BEFORE_XXXEVENT_AFTER_XXX。接下来,我们分析除绘图渲染外的逻辑部分。

calculateDeltaTime

  calculateDeltaTime 用于计算本次 mainLoop 调用与上一次调用之间的时间间隔。就是计算 this._deltaTime 计算的值:

1
2
3
4
5
6
7
8
9
10
11
// CCDirector.js
init() {
//...
this._lastUpdate = Date.now();
}

calculateDeltaTime() {
var now = Date.now();
this._deltaTime = (now - this._lastUpdate) / 1000;
this._lastUpdate = now;
}

  计算非常简单,将当前时间与上次计算时的时间相减即可。

组件的生命周期方法

  compScheduler 属性是 ComponentScheduler 类的实例。该类定义在 component-scheduler.js 类中。 在进入这个方法的代码之前,我们先看一下添加组件逻辑。

CCObject

  在 CocosCreator 中的,cc.Nodecc.Component 等大部分类的基类是 CCObject。来看一下它的定义:

1
2
3
4
5
6
class CCObject {
constructor() {
this._name = '';
this._objFlags = 0;
}
}

  可以看到,CCObject 有两个属性。其中值得注意的是 _objFlags,它被引擎用来标识对象的部分状态,这需要用到标志位、与操作、或操作、位移操作符。以下是引擎定义的部分标志位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// CCObject.js

// definitions for CCObject.Flags
var Destroyed = 1 << 0;
var RealDestroyed = 1 << 1;
var ToDestroy = 1 << 2;
var DontSave = 1 << 3;
var EditorOnly = 1 << 4;
var DontDestroy = 1 << 6;
var Destroying = 1 << 7;
var Deactivating = 1 << 8;

var IsOnEnableCalled = 1 << 11;
var IsEditorOnEnableCalled = 1 << 12;
var IsPreloadStarted = 1 << 13;
var IsOnLoadCalled = 1 << 14;
var IsOnLoadStarted = 1 << 15;
var IsStartCalled = 1 << 16;

  从上面的定义的变量名,可以得知标志位的作用。比如,判断一个组件的 onLoad 方法是否调用过:

1
2
3
4
5
6
//CCComponent.js
_isOnLoadCalled: {
get () {
return this._objFlags & IsOnLoadCalled;
}
},

addComponent

  CCNodeCCComponent 都有方法:addComponent。实际上组件的这个方法调用的就是节点的方法:

1
2
3
4
//CCComponent.js
addComponent (typeOrClassName) {
return this.node.addComponent(typeOrClassName);
},

  所以只需要关注 CCNode 里的实现即可,这个方法是定义在其父类 base-node 中的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// base-node.js

//向节点添加一个指定类型的组件类,你还可以通过传入脚本的名称来添加组件。
addComponent (typeOrClassName) {
// 根据 typeOrClassName 找出组件类
var constructor = typeOrClassName;
if (typeof typeOrClassName === 'string') {
constructor = JS.getClassByName(typeOrClassName);
}

// 新建组件并添加到 _components 数组中
var component = new constructor();
component.node = this;
this._components.push(component);

// 如果 node 及其父组件链是 active 状态,则调用组件的 onLoad 方法。
if (this._activeInHierarchy) {
cc.director._nodeActivator.activateComp(component);
}

return component;
}

  添加组件的逻辑非常简单,先找到组件的类,再创建组件对象,并保存对象。值得一提的是 _nodeActivator,这是对象是 NodeActivator 类的实例,在 cc.director 初始化时创建。NodeActivator 是用于执行节点和组件的 activatingdeactivating 操作的类。下面是该类的 activateComp 方法的简化版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// node-activator.js
activateComp: function(comp) {
if (!(comp._objFlags & IsPreloadStarted)) {
comp._objFlags |= IsPreloadStarted;

// __preload 是引擎自定义的组件需要实现的方法。如 CCButton、CCLabel 都实现了该方法
comp.__preload();
}

// 如果 comp 没有执行过 onLoad 方法
if (!(comp._objFlags & IsOnLoadStarted)) {
comp._objFlags |= IsOnLoadStarted;
if (comp.onLoad) {
comp.onLoad();
}
comp._objFlags |= IsOnLoadCalled;
}

if (comp._enabled) {
cc.director._compScheduler.enableComp(comp);
}
}

  activateComp 的工作就是调用组件的 onLoad 方法,并且保证只调用一次。再将组件添加到 _compScheduler 中并启用组件。

ComponentScheduler

  ComponentScheduler 用于统一管理所有组件的生命周期方法,方法包括:startupatelateUpdate_compSchedulercc.director 初始化时创建。从上面的代码中,可以看出通过 enableComp 方法将组件管理起来的。下面是该类代码的简化版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// component-scheduler.js
class ComponentScheduler {
constructor() {
// 用于将组件方法调用延迟到下一帧的数组
this.scheduleInNextFrame = [];

// 标识正在一次循环中
this._updating = false;

this.startInvoker = [];
this.updateInvoker = [];\
this.lateUpdateInvoker =[];
}

// 启用组件
enableComp (comp) {
if (!(comp._objFlags & IsOnEnableCalled)) {
if (comp.onEnable) {
// 调用组件的 onEnable 方法
comp.onEnable();
}
this._onEnabled(comp);
}
}

// 将组件添加到主循环逻辑中
_onEnabled (comp) {
cc.director.getScheduler().resumeTarget(comp);
comp._objFlags |= IsOnEnableCalled;

// schedule
if (this._updating) {
this.scheduleInNextFrame.push(comp);
} else {
this._scheduleImmediate(comp);
}
}

// 将组件添加到要调用的方法数组列表中
_scheduleImmediate (comp) {
if (comp.start && !(comp._objFlags & IsStartCalled)) {
this.startInvoker.add(comp);
}
if (comp.update) {
this.updateInvoker.add(comp);
}
if (comp.lateUpdate) {
this.lateUpdateInvoker.add(comp);
}
}

// 执行延迟到当前帧调用的组件方法
_deferredSchedule () {
var comps = this.scheduleInNextFrame;
for (var i = 0, len = comps.length; i < len; i++) {
var comp = comps[i];
this._scheduleImmediate(comp);
}

// 清空 this.scheduleInNextFrame 数组
comps.length = 0;
}

startPhase () {
// 当前帧开始
this._updating = true;

if (this.scheduleInNextFrame.length > 0) {
this._deferredSchedule();
}

// 调用组件的 start 方法
this.startInvoker.forEach(comp => {
if (!(comp._objFlags & IsStartCalled)) {
comp._objFlags |= IsStartCalled;
comp.start();
}
});
}

updatePhase (dt) {
// 调用组件的 update 方法
this.updateInvoker.forEach(comp => {
comp.update(dt);
});
}

lateUpdatePhase (dt) {
// 调用组件的
this.lateUpdateInvoker.forEach(comp => {
comp.lateUpdate(dt);
});

// 当前帧结束
this._updating = false;
}
}

  可以看到,该类提供了三个方法给外部,用于统一管理组件生命周期方法。它们在前文提到的主循环里有调用:

1
2
3
4
5
6
7
8
mainLoop() {
// ...
this._compScheduler.startPhase();
this._compScheduler.updatePhase(this._deltaTime);
this._scheduler.update(this._deltaTime);
this._compScheduler.lateUpdatePhase(this._deltaTime);
// ...
}

组件生命周期流程

  从上面的代码可以分析出,当添加组件到节点后,组件被托管到 _compScheduler 统一管理。再由 mainLoop 通过 _compScheduler 调用到组件的相应方法。

参考

1、cocos2d-x游戏引擎核心之三——主循环和定时器
2、JavaScript 技术文档
3、CADisplayLink
4、cocos-creator/engine