Mobx库应用于Cocos项目的可行性研究
1.概述
Mobx是一个运用透明的函数式响应编程,实现状态管理的库,具体的一些介绍还是前往这里看吧:https://mobx.nodejs.cn/README.html
在这里就只是记录一下我自己对Mobx库的一些粗浅认知,以及尝试把Mobx库应用到cocos项目的可行性。
2.研究引入Mobx库的原因
一般开发我在一个功能或者一个系统的时候都会有一个Model类,对应一个或多个View类,它们一个负责处理数据,一个负责处理界面业务逻辑。所以在业务开发过程中,总是会有一个这样的需求:我需要监听Model类里某个数据结构内的某一个特定变量,当这个变量发生改变时,实时地去处理某些界面业务。
比如角色等级变了,那么我需要去播放一个等级提升动画,同时改变角色的等级文本展示内容。
这个功能很容易实现,使用简单的发布-订阅模式就可以了,流程大概如下:
class 数据类 {
//...省略单例实例化
public 变量A: number = 0;
public 变量B: {Name: string, Age: number} = {Name: '张三', Age: 18};
public onDataChange(data: any): void {
if (this.变量A !== data.变量A) {
this.变量A = data.变量A;
发布消息(自定义消息Id1, this.变量A);
}
}
public onDataChange2(data: any): void {
this.变量B.Name = data.Name;
this.变量B.Age = data.Age;
发布消息(自定义消息Id2, this.变量B);
}
}
class 界面类 {
onLoad(): void {
订阅消息(自定义消息Id1, 业务接口1, this);
订阅消息(自定义消息Id2, 业务接口2, this);
}
业务接口1(变量A): void {
// .....处理数据类.I.变量A相关的业务
}
业务接口2(变量B): void {
// .....处理数据类.I.变量B相关的业务
}
onDestroy(): void {
注销消息(自定义消息Id1, 业务接口, this);
注销消息(自定义消息Id2, 业务接口, this);
}
}
一般来说都是这样的一个流程,一直以来我也都是这么做。然后有天偶然间接触到了Mobx,了解了一下Mobx的响应式编程,觉得也许可以将Mobx引入到项目中来,简化上面的流程。
使用Mobx来实现上述的功能,大概是以下流程:
import {makeAutoObservable, autorun} from "mobx"
class 数据类 {
//...省略单例实例化
public 变量A: number = 0;
public 变量B: {Name: string, Age: number} = {Name: '张三', Age: 18};
constructor() {
makeAutoObservable(this);
}
public onDataChange(data: any): void {
this.变量A = data.变量A;
}
public onDataChange2(data: any): void {
this.变量B.Name = data.Name;
this.变量B.Age = data.Age;
}
}
class 界面类 {
onLoad(): void {
autorun(() => {
// ...与数据类.I.变量A相关的业务
})
autorun(() => {
// ...与数据类.I.变量B相关的业务
})
}
}
随后只要数据类里的变量A或变量B有变化,界面类的对应的autorun就会自动执行,突出一个简单。
3.主要概念介绍
Ⅰ.可观察状态
想要使用Mobx的响应编程,首要的就是为自己想要观察(或者说监听)的对象创建可观察状态。方法则是使用Mobx的 makeObservable 和 makeAutoObservable ,为每个属性指定一个注解,这里只展开说以下三个,具体的定义还是看官网,我这里就简单概括一下:
- observable 定义了一个对象可以被观察,拥有了状态,这是使用Mobx的前提;
- action 用于标记一个函数方法,这个方法主要用于修改被observable观察了的对象;
- computed 则用于标记getter方法,用于缓存结果输出,当结果发生变化时,会自动调用。
接下来简单说一下 makeObservable 和 makeAutoObservable :
-
makeAutoObservable 是 makeObservable 的加强版,两者都是需要在构造函数中使用,第一个参数传this。
-
makeObservable 需要自己手动将每一个需要观察的对象罗列出来,作为参数传递给第二位参数,而 makeAutoObservable 则是能自动推断this中的所有属性和对象,包括函数方法,自动为他们添加注解。推断规则为:
1. 所有拥有的属性都为 observable 2. 所有的 getter 方法都为 computed 3. 所有的 setter 方法都为 action 4. 所有的 function 都为action
举个🌰:
class Model {
private _name: string = '';
private _age: number = 18;
private _data: {value: number, max: number} = {value: 10, max: 20};
constructor() {
// 使用makeObservable
makeObservable(this, {
_name: observable,
_age: observable,
_data: observable,
name: computed,
age: computed,
setDataValue: action.bound
})
// 使用makeAutoObservable
makeAutoObservable(this, null, {autoBind: true});// 结束了,是的你没看错,就是这么简单
}
public get name(): string {
return this._name;
}
public set name(v: string) {
this._name = v;
}
public get age(): number {
public this._age;
}
public set age(v: number ) {
this._age = v;
}
public setDataValue(v): number {
this._data.value = v;
}
}
那么问题来了,无脑用 makeAutoObservable 不就可以了吗?这就得说到 makeAutoObservable 的一个限制,它不能用在 具有父类 或 拥有子类 的类,也就是说,如果你这个类是继承别的类的,或者你这个类是有被其他类继承的,那么你就不能使用 makeAutoObservable。
Ⅱ.action-操作
对于Mobx来说,如果你为一个对象创建了可观察状态,那么当你需要改变该对象时,Mobx会要求你在action标注的函数内改变,例如上面代码块中的 setDataValue。
Ⅲ.computed-计算
可以通过computed来创建计算值,它会充当一个缓存点,只有当计算值发生了变化,才会触发计算。
简单的🌰
class Model {
//.....省略一大堆代码
public value1: number = 0;
public value2: number = 2;
// 使用computed为getTotalNum添加注解
public getTotalNum() {
return this.value1 + this.value2;
}
}
autorun(() => {
console.log(Model.I.getTotalNum());
})
首先autorun是什么暂且不说,只需要知道,这样的写法就会有一个效果:当getTotalNum()的计算结果发生变化,那么autorun内的逻辑就会被自动触发执行。
此时如果改变 value2,由于value1=0,所以计算结果始终是0,autorun就不会被执行。当改变value1,计算结果改变了。autorun被执行。
Ⅳ.reaction-反应
反应简单来说就是对观察对象发生变化后,自动执行的逻辑,比如上面代码里的autorun。
- autorun(effect: (reaction) => void, options?)
autorun 函数接受一个函数作为参数,该函数会在每次观察到任何变化时运行。 当你创建 autorun 本身时,它也会运行一次。 它仅响应可观察状态的变化,即你注释为 observable 或 computed 的内容。 - reaction(() => value, (value, previousValue, reaction) => { sideEffect }, options?)
reaction 与 autorun 类似,但对跟踪哪些可观察量提供了更细粒度的控制。 它需要两个函数作为参数: 第一个函数会观察、跟踪对象,在对象发生改变时自动执行,并拥有一个返回值,该返回值会用作第二个函数的输入。 - when(predicate: () => boolean, effect?: () => void, options?)
when需要两个函数,观察并运行第一个函数,当第一个函数结果为true时,执行第二个函数。
4.使用
在上面的介绍里,我们知道了为对象添加观察状态有两种方法,makeAutoObservable 无可否认是最简单的,但是却有不能用在子类或有子类的父类上的限制,那么在使用Mobx的过程中就出现一个问题,如果我这个类要观察的对象很多,那么我岂不是要手动写观察写到手软。
为了偷懒,还是简单封装一下吧,下面是我封装的一个接口,目前还没真正去验证是否能在复杂的类中使用。
/** 遍历类实例对象中定义的属性、对象和方法成员
* @param target 类实例对象
*/
function myMakeAutoObservable(target: any): { [key: string]: any } {
// 拿到所有成员
let proto = Object.getPrototypeOf(target);
// 拿到所有定义的属性成员
let proto1 = Object.keys(target);
// 拿到所有定义的函数方法
let proto2 = Object.getOwnPropertyDescriptors(proto);
// 梳理所有定义的成员,进行Mobx观察状态添加
const observableMap: { [key: string]: any } = {};
let key: string = '';
for(let i = 0; i < proto1.length; i++) {
key = proto1[i];
if (target[key] !== undefined && target[key] !== null) {
// 为属性和对象添加observable注解
observableMap[key] = observable;
}
}
for (let key in proto2) {
const e = proto2[key];
if (key === 'constructor') {
// 跳过构造函数
continue;
}
if (target[key] == undefined || target[key] == null) {
continue;
}
if (e.value) {
if (typeof e.value === 'function') {
// 函数方法添加action
observableMap[key] = action.bound;
}
}
if (e.get) {
// getter添加computed
observableMap[key] = computed;
}
}
return observableMap;
}
// 父类
class ModelBase {
constructor() {
}
/** 用于自动注解类中定义的成员和属性 */
public makeAutoObservable(target: any): void {
const map = myMakeAutoObservable(target);
Mobx.makeObservable(target, map);
}
}
class MyModel extends ModelBase {
// ..... 省略了 .....
constructor() {
super();
this.makeAutoObservable(this);
}
// ..... 也省略了 ......
}
5.注意点
Ⅰ.创建观察状态的前提
Mobx要为属性和对象创建观察状态,也就是observable,那么属性和对象一定要是已经进行过初始化、实例化的。也就是说,如果你定义了一个对象:public myNum: number = null; public myObject = null;那么myNum 和 myObject 都是没办法观察的。
Ⅱ.内存泄漏处理
Mobx的反应处理,在不需要用到的时候,也要记得销毁。比如在界面内使用了autorun,那么界面销毁时,也要把相关的反应销毁掉,避免造成内存泄漏。目前我觉得比较简单的方法,就是在界面的基类进行接口封装,自动销毁。简单来说,就是这样:
import { autorun, reaction, when } from "mobx";
class ViewBase extends cc.Component {
/** 保存mobx反应disposer */
private _mobxDisposers: any[] = [];
/**
* 封装autorun
* @param runFn 自动执行的函数
*/
public autorun(runFn: () => any) {
const disposer = autorun(runFn);
this._mobxDisposers.push(disposer);
}
/**
* 封装reaction
* @param expression 跟踪观察对象并返回结果,作为第二个参数runFun的输入
* @param runFn 自动执行的函数
*/
public reaction(expression: () => any, runFn: (args: any) => any) {
const disposer = reaction(expression, runFn)
this._mobxDisposers.push(disposer);
}
/**
* 封装when
* @param predicate 跟踪观察对象,判断结果
* @param runFn 如果第一个参数结果为true,则自动执行
*/
public when(predicate: () => boolean, runFn: () => any) {
const disposer = when(predicate, runFn)
this._mobxDisposers.push(disposer);
}
/** 界面销毁时自动调用 */
public onDestroy() {
if (this._mobxDisposers && this._mobxDisposers.length > 0) {
this._mobxDisposers.forEach(disposer => {
disposer && disposer();
});
this._mobxDisposers = null;
}
}
}
6.待解决的困难
Ⅰ.如何处理在类初始化时未进行初始赋值的复杂数据结构对象
局限于Mobx的规则,想要对对象进行观察,那么首要条件就是这个对象是已经实例化了的。也就是说,假设一个对象在类实例化的时候,它是这样的:private _data: RoleData = null; 那么 _data 对象是没办法被观察的。
但是出于业务需求,我们不可能在类实例化的时候就能对所有的属性成员都进行赋值操作,毕竟很多时候数据是需要与服务端通讯之后才能获得的,这个时候该怎么办呢?
Ⅱ.是否会存在性能问题
实际开发中,一个类可能会很复杂,特别是经过较长一段时间的迭代之后,里面的成员变量、函数方法可能会非常多,但是根据上面的逻辑来说,会自动对类的所有属性对象和方法都进行相应的注解,那么无可避免的会出现注解了一些原本没必要进行注解的对象或方法,这种情况下是否会造成一定的性能问题或者性能浪费呢?
7.实战
Ⅰ.新建项目
Cocos版本这里我使用的是最新的3.8.1,理论上来说Mobx库应该是不会受限于Cocos版本的,所以应该其他版本也都可以,不过我就不做验证了,需要的人请自行创建项目进行验证。怎么新建项目这里就不水字数了。
Ⅱ.安装Mobx库
在项目根目录调起终端,执行以下命令:
npm init
npm install --save-dev mobx
Ⅲ.项目中引入Mobx库
顺利的话此时你应该能在项目中获得Mobx库的一些引入提示了,比如:
然鹅~~这样直接引入并没办法用,妹想到吧。具体原因我也不知道,可能是Cocos对于npm第三方库的支持有自己的一套逻辑在吧,小菜鸡一只也没办法去研究cocos引擎的底层。总之,在论坛逛了一圈之后,发现要用Mobx库的话,得这么用:
import mobx from "mobx/dist/mobx.cjs.development.js";
这样子才能通过 mobx 来调用Mobx库。但是…它就没有了代码提示功能,蛋疼,很蛋疼。还有,最好是在项目的入口文件处引入。
具体Cocos能使用哪些npm库,这里有个链接,有兴趣的可以看看:https://shrinktofit.github.io/can-i-use-npm-in-cocos-creator/
Ⅳ.简单封装Mobx方法
创建一个Mobx类,挂载在项目启动入口处,之后就通过这个类来使用Mobx库的功能,当然目前只是封装了几个简单的,Mobx库可不止这点东西,后续有需要就再添加吧,这样子也算是有一点代码提示功能,用起来也方便一点,聊胜于无吧。
import { Component, _decorator } from "cc";
import { makeObservable } from "mobx";
import mobx from "mobx/dist/mobx.cjs.development.js";
const { ccclass, executionOrder } = _decorator;
const configure = mobx.configure;
configure({
enforceActions: "nerver",
})
@ccclass('Mobx')
@executionOrder(-999)
export default class Mobx extends Component {
public static observable = mobx.observable;
public static action = mobx.action;
public static computed = mobx.computed;
/** 遍历类中定义的属性和方法成员,添加对应的mobx观察状态,用于在子类中替代mobx的makeAutoObservable */
public static myMakeAutoObservable(target: any) {
// 拿到所有成员
const proto = Object.getPrototypeOf(target);
// 拿到所有定义的属性成员
const proto1 = Object.keys(target);
// 拿到所有定义的函数方法
const proto2 = Object.getOwnPropertyDescriptors(proto);
// 梳理所有定义的成员,进行Mobx观察状态添加
const observableMap: { [key: string]: any } = {};
let key: string = '';
for (let i = 0; i < proto1.length; i++) {
key = proto1[i];
if (target[key] !== undefined && target[key] !== null) {
// 为所有的属性和对象添加observable注解
observableMap[key] = this.observable;
}
}
for (key in proto2) {
const e = proto2[key];
if (key === 'constructor') {
// 跳过构造函数
continue;
}
if (target[key] == undefined || target[key] == null) {
continue;
}
if (e.value) {
if (typeof e.value === 'function') {
// 函数方法添加action注解
observableMap[key] = this.action.bound;
}
}
if (e.get) {
// getter添加computed注解
observableMap[key] = this.computed;
}
}
return this.makeObservable(target, observableMap);
}
/**
* 添加注解
* @param target 目标实例对象
* @param annotations 注解内容
* @param options 额外设置选项 https://mobx.nodejs.cn/observable-state.html
* @returns
*/
public static makeObservable(target: any, annotations?: any, options?: any) {
return mobx.makeObservable(target, annotations, options);
}
/**
* 自动推断添加注解
* @param target 目标实例对象
* @param overrides 可用来指定某些对象不需要被注解
* @param options 额外设置选项 https://mobx.nodejs.cn/observable-state.html
* @returns
*/
public static makeAutoObservable(target: any, overrides: any, options: any) {
return mobx.makeAutoObservable(target, overrides, options);
}
/**
* 观察跟踪 runFn中涉及到的可观察对象,当对象发生改变时,自动执行。第一次调用时会默认执行一次runFn
* @param runFn
* @returns
*/
public static autorun(runFn: () => any) {
return mobx.autorun(runFn);
}
/**
* 观察跟踪predicate中涉及到的可观察对象,当对象发生改变时,自动执行predicate,并拥有一个返回值,返回值为true时执行runFn
* @param predicate
* @param runFn
* @returns
*/
public static when(predicate: () => boolean, runFn: () => any) {
return mobx.when(predicate, runFn);
}
/**
* 观察跟踪expression中涉及到的可观察对象,当对象发生改变时,自动执行expression,并拥有一个返回值,返回值作为参数传递给runFn,并执行runFn
* @param expression
* @param runFn
* @returns
*/
public static reaction(expression: () => any, runFn: (args: any) => any) {
return mobx.reaction(expression, runFn);
}
}
Ⅴ.简单封装Model基类
Model基类其实没什么好说的,每个人每个项目自己的基类内容都不一样,这里只是加一个接口,让子类在构造函数中调用,对子类进行Mobx注解就行了。比如这样一个接口,target传this就行了:
public makeAutoObservable(target: any): void {
Mobx.myMakeAutoObservable(target);
}
Ⅵ.简单封装View基类
View基类也是一样,各有各的东西在,这里只是为了实现能够自动对mobx的反应进行销毁,避免出现内存泄漏问题而已。
import { Component } from "cc";
import Mobx from "./Mobx";
export default class ViewBase extends Component {
/** 保存mobx反应disposer */
private _mobxDisposers: any[] = [];
/**
* 封装autorun 当runFn内被观察的对象发生改变时,会自动执行,第一次调用会自动执行一次runFun
* @param runFn 自动执行的函数
*/
public autorun(runFn: () => any) {
const disposer = Mobx.autorun(runFn);
this._mobxDisposers.push(disposer);
}
/**
* 封装reaction
* @param expression 跟踪观察对象并返回结果,作为第二个参数runFun的输入
* @param runFn 自动执行的函数
*/
public reaction(expression: () => any, runFn: (args: any) => any) {
const disposer = Mobx.reaction(expression, runFn)
this._mobxDisposers.push(disposer);
}
/**
* 封装when
* @param predicate 跟踪观察对象,判断结果 如果结果为true,自动执行runFn
* @param runFn
*/
public when(predicate: () => boolean, runFn: () => any) {
const disposer = Mobx.when(predicate, runFn)
this._mobxDisposers.push(disposer);
}
/** 界面销毁时自动调用 若子类需要重写onDestroy,一定要记得super.onDestroy() */
public onDestroy() {
if (this._mobxDisposers && this._mobxDisposers.length > 0) {
this._mobxDisposers.forEach(disposer => {
disposer && disposer();
});
this._mobxDisposers = null;
}
}
}
8.未完待续
对于Mobx我也只是一个刚接触的新手,肯定会有很多理解不到位,或者使用不恰当的地方,也会有很多内容没有提及,所以如果有人看了我的文章,发现了文章中有哪些是错误的,还请多多指教。
还有就是如果有人跟我一样是新手,然后用了我上面写的这些代码,出现了报错之类的问题,emmmmmm…你懂我意思吧~~
后续也会继续学习了解Mobx,有新的内容,也许就会更新了,大概吧。