如何打造一个带类型的事件系统

最近去Unity进修去了,首先感谢TEngine,事件系统的实现借鉴了TEngine。本文只展示了我的实现,如果看懂了可以自己实现出来。实现虽然有点繁琐,但是一次实现,受益终生。

先看效果

事件定义

interface IGame {
    onCoinChange(coin: number): void;
}

事件自动化生成

import { RegisterEvent } from "db://assets/startup/framework/Event/Event";
import { EventBase } from "db://assets/startup/framework/Event/EventBase";
import { EventDecorator } from "db://assets/startup/framework/Event/EventDecorator";

export class Game_Name {
    public static readonly onCoinChange = RegisterEvent("Game_onCoinChange");
}

@EventDecorator
export class Game extends EventBase {

    onCoinChange(coin: number): void {
        this.event.dispatch(Game_Name.onCoinChange, coin);
    }

}

如何注册,发送事件

import { Event } from "../../startup/framework/Event/Event";
import { GameEntryBase } from "../../startup/framework/GameEntry/GameEntryBase";
import { GameEntryDecorator } from "../../startup/framework/GameEntry/GameEntryDecorator";
import { GameModule } from "../../startup/framework/Module/GameModule";
import { Game, Game_Name } from "./event/generate/Game";

@GameEntryDecorator
export class GameEntry extends GameEntryBase {

    public async OnGameStart() {
        console.log("GameEntry OnGameStart");
        let event = GameModule.Instance().getModule<Event>(Event);
        //注册
        event.addListener(Game_Name.onCoinChange, this.onCoinChange, this);
        //发送
        let game = event.getEventInterface<Game>(Game);
        game.onCoinChange(100);
    }

    onCoinChange(coin: number) {
        console.log("GameEntry onCoinChange", coin);
    }   

}

interface名字作为事件模块名,方法来描述这个事件要干什么,实在是巧妙,完美的诠释了这个事件是用来干什么的,你甚至注释都不用写,也不用告诉别人参数。

如何实现

首先来讲一个ts范式,如何收集一系列指定模块的相关对象

  1. 定义模块功能基类
import { Event } from "./Event";

export abstract class EventBase {
    protected event: Event;
    constructor(event: Event) {
        this.event = event;
    }
}

2.定义装饰器收集所有实现过基类的子类

import { Event } from "./Event";
import { EventBase } from "./EventBase";

export interface EventBaseConstructor {
    new(event: Event): EventBase;
}

export const eventList: Array<EventBaseConstructor> = [];

export function EventDecorator(target: EventBaseConstructor) {
    eventList.push(target);
}

3.在实现的子类上标记

@EventDecorator
export class XXXX extends EventBase {
    
}

在Event里根据收集到的构造函数,实例化所有的对象,并且提供获取接口,所以Event的第一个接口出来了


export class Event extends ModuleBase {
    private eventInterfaceMap: Map<EventBaseConstructor, EventBase> = new Map();

    public init(): void {
        for (let i = 0; i < eventList.length; i++) {
            let eventDecorator = eventList[i];
            let eventBase = new eventDecorator(this);
            this.eventInterfaceMap.set(eventDecorator, eventBase);
        }
    }

    public getEventInterface<T extends EventBase>(type: EventBaseConstructor): T {
        if (!this.eventInterfaceMap.has(type)) {
            throw new Error(`Event ${type} not found`);
        }
        return this.eventInterfaceMap.get(type) as T;
    }
}

我们需要一个类来帮助我们记录事件类别,方法,方法的指针

export class EventHandle implements IReferencePoolObject {
    public eventType: number;
    public object: object;
    public method: Function;

    public dispose(): void {
        this.eventType = 0;
        this.object = null;
        this.method = null;
    }

}

为什么eventType是number,在map里找一个number,肯定比找一个string要快,如何定义这个number,当然是自动生成了。可以看到,每次调用RegisterEvent都会自动生成一个idx,并且在自动化生成的代码中自动调用。

let eventIndex = 1;

let idx2name = new Map<number, string>();
let name2idx = new Map<string, number>();

export function RegisterEvent(name: string) {
    if (name2idx.has(name)) {
        throw new Error(`Event ${name} already registered`);
    }
    let idx = eventIndex++;
    idx2name.set(idx, name);
    name2idx.set(name, idx);
    return idx;
}

// 这里是生成出来的代码
export class Game_Name {
    public static readonly onCoinChange = RegisterEvent("Game_onCoinChange");
}

好,让我们来补齐,Event剩下来的接口,包括添加、移除、发送

public addListener(eventType: number, method: Function, object: object) {
        let handle = this.referencePool.getReference<EventHandle>(EventHandle);
        handle.object = object;
        handle.method = method;
        handle.eventType = eventType;
        if (!this.eventMap.has(eventType)) {
            this.eventMap.set(eventType, []);
        }
        this.eventMap.get(eventType).push(handle);
    }

    public removeListener(eventType: number, method: Function, object: object) {
        if (!this.eventMap.has(eventType)) {
            return;
        }
        let handles = this.eventMap.get(eventType);
        for (let i = 0; i < handles.length; i++) {
            if (handles[i].object === object && handles[i].method === method) {
                handles.splice(i, 1);
                this.referencePool.returnReference(handles[0]);
                break;
            }
        }
        if (handles.length === 0) {
            this.eventMap.delete(eventType);
        }
    }

    public dispatch(eventType: number, ...args: any[]) {
        if (!this.eventMap.has(eventType)) {
            return;
        }
        let handles = this.eventMap.get(eventType);
        for (let i = 0; i < handles.length; i++) {
            handles[i].method.apply(handles[i].object, args);
        }
    }

剩下来就是自动化生成相关了,我们需要导入装饰器,需要继承父类,需要调用RegisterEvent去生成idx,那么首先就要import这三个,然后IModule里面import的部分,我们自动化生成类也要import对应部分。生成出2个类,一个用来记录事件名,一个用来发送事件。这部分代码十分枯燥,直接让ai生成即可,我放出我的ai实现,各位自己生成一个插件来调用这段代码即可。

import * as fs from 'fs';
import * as path from 'path';

export async function OnEventGenerate() {
    let projectPath = Editor.Project.path;
    let eventInterfacePath = `${projectPath}/assets/scripts/game/event/interface`;
    let generatePath = `${projectPath}/assets/scripts/game/event/generate`;

    // 删除生成目录下的所有文件
    if (fs.existsSync(generatePath)) {
        const files = fs.readdirSync(generatePath);
        for (const file of files) {
            if(file.endsWith('.ts')){
                const filePath = path.join(generatePath, file);
                await Editor.Message.request('asset-db', 'delete-asset', filePath);
            }
        }
    }

    let tsFiles = readDir(eventInterfacePath);

    for (let i = 0; i < tsFiles.length; i++) {
        let tsFile = tsFiles[i];
        let newFileContent = '';
        let content = fs.readFileSync(tsFile, 'utf-8');
        let contents = content.split('\n');

        // 保留原有的import语句
        for (let j = 0; j < contents.length; j++) {
            let item = contents[j];
            if (item.includes('import')) {
                newFileContent += item + '\n';
            }
        }

        // 添加Event导入
        newFileContent += 'import { RegisterEvent } from "db://assets/startup/framework/Event/Event";\n';
        newFileContent += 'import { EventBase } from "db://assets/startup/framework/Event/EventBase";\n';
        newFileContent += 'import { EventDecorator } from "db://assets/startup/framework/Event/EventDecorator";\n\n';

        // 获取文件名(不含扩展名)
        let fileName = path.basename(tsFile, '.ts');
        let interfaceName = fileName.replace('I', '');
        
        // 生成事件名称类
        newFileContent += `export class ${interfaceName}_Name {\n`;
        
        // 收集所有方法名
        let methodNames: string[] = [];
        for (let j = 0; j < contents.length; j++) {
            let item = contents[j];
            if (item.includes('(') && item.includes(')')) {
                let methodMatch = item.match(/(\w+)\s*\((.*?)\)/);
                if (methodMatch) {
                    let methodName = methodMatch[1];
                    methodNames.push(methodName);
                    // 为每个方法生成事件名
                    newFileContent += `    public static readonly ${methodName} = RegisterEvent("${interfaceName}_${methodName}");\n`;
                }
            }
        }
        
        newFileContent += '}\n\n';

        // 生成事件类
        newFileContent += `@EventDecorator\n`;
        newFileContent += `export class ${interfaceName} extends EventBase {\n\n`;

        // 处理接口方法
        for (let j = 0; j < contents.length; j++) {
            let item = contents[j];
            if (item.includes('interface')) {
                continue;
            }
            if (item.includes('(') && item.includes(')')) {
                let methodMatch = item.match(/(\w+)\s*\((.*?)\)/);
                if (methodMatch) {
                    let methodName = methodMatch[1];
                    let params = methodMatch[2];
                    newFileContent += `    ${methodName}(${params}): void {\n`;
                    // 提取参数名列表
                    let paramNames = params.split(',').map(p => p.trim().split(':')[0].trim()).filter(p => p);
                    newFileContent += `        this.event.dispatch(${interfaceName}_Name.${methodName}${paramNames.length > 0 ? ', ' + paramNames.join(', ') : ''});\n`;
                    newFileContent += '    }\n\n';
                }
            }
        }

        newFileContent += '}\n';

        // 确保目标目录存在
        if (!fs.existsSync(generatePath)) {
            fs.mkdirSync(generatePath, { recursive: true });
        }

        // 写入新文件
        let newFilePath = path.join(generatePath, `${interfaceName}.ts`);
        fs.writeFileSync(newFilePath, newFileContent);
        await Editor.Message.request('asset-db', 'refresh-asset', newFilePath);
    }
}

function readDir(dirPath: string): string[] {
    let tsFiles: string[] = [];
    let files = fs.readdirSync(dirPath);

    files.forEach(file => {
        let fullPath = path.join(dirPath, file);
        let stat = fs.statSync(fullPath);
        if (stat.isDirectory()) {
            readDir(fullPath);
        } else if (file.endsWith('.ts')) {
            tsFiles.push(fullPath);
        }
    });
    return tsFiles;
}

最后,如何防止用户只监听却不注销,在你的UIBase里这么做

export abstract class UIBase implements IReferencePoolObject {
    protected event: Event;
    private eventHandles: Array<EventHandle>;

    constructor() {
        this.event = GameModule.Instance().getModule(Event);
        this.eventHandles = [];
    }

    protected addEventHandle(eventType: number, method: Function, object?: object) {
        if (!object) {
            object = this;
        }
        let handle = this.referencePool.getReference<EventHandle>(EventHandle);
        handle.object = object;
        handle.method = method;
        this.eventHandles.push(handle);
        this.event.addListener(eventType, method, object);
    }

    public dispose(): void {
        this.node = null;
        this.info = null;
        this.userData = null;
        for (let i = this.eventHandles.length - 1; i >= 0; i--) {
            this.event.removeListener(this.eventHandles[i].eventType, this.eventHandles[i].method, this.eventHandles[i].object);
            this.referencePool.returnReference(this.eventHandles[i]);
        }
        this.eventHandles.length = 0;
    }
}
4赞

不如发起一个投票 看看大家经历过的大小项目用的是什么事件系统

这个事件机制绕来绕去。使用起来看过去很不直观啊。
event.getEventInterface(Game) 我认为是最难用的地方:因为在定义一个事件后、需要定义 Game 类,里面定义个方法,方法中才去派发这个事件。也就是每次增加事件,需要增加 2 个子步骤。

然后,又引入了额外的很多个装饰器。。。使用事件系统的时候,涉及的元素更多了(元素应该越少越好)。

最后,在某个 game.onCoinChange(100) 的地方想看看到底哪些模块处理该逻辑的时候,需要二次跳转查询的: onCoinChange -> 事件名 -> 查引用,这个是很不直观的,后续回顾代码逻辑的时候很难受。

分享下我的事件机制设计(其实和官方的 EventTarget 没太大差别,就是把底层抽出了一个 Signal,方便对象派发数据时监听情况下能复用逻辑,然后组件封装了销毁自动注销功能,以及异步派发机制)

1、区分全局事件和对象事件。g_Event 全局事件。对象事件直接 new。
image

2、组件注册事件,组件销毁时自动注销,组件内事件注册只需要一行。

3 支持派发异步事件

4、事件使用 Signal 机制实现分组,方便复用。支持派发 XX 消息时,同步注销自身,甚至删除所有该事件。(对了,这个 Signal 是从 Laya 引擎源码里拿过来修改的)

还是我自己的实现比较好用,多种使用方式,支持异步事件(只要在某个常驻Node的update中tick消费队列事件)

各用各的,哈哈。
我看你这事件,每次都要 new 一个 Message 对象,为什么不单独抽一个方法,内部去 new ?
然后你 onDestroy 里面如果忘记注销了,那不是蛋疼?人是会犯错的动物,要通过机制确保每个事件都能自动 remove。(可以看下 js 新特性 using 关键字)组件还是要在注册的时候,能够在不用时自动 remove 的。

哈哈,太复杂了吧,我这边连接口都和cocos的统一了,就是为了方便

我怎么觉得在Unity那边看过类似的写法。但说实话,我不太理解事件系统为什么需要类型。作为一个目的是相对安全地进行跨模块调用和数据传递。增加了类型岂不是提高了系统的耦合?另外,如果所谓的类型,如果只是cocos2dx那种事件类,那个是引擎为了进行传递的逻辑操作(例如触碰的block和吞噬),上层开发最多也只是用一下CustomEvent,并不需要特别处理事件传递的东西。

因为某些时候需要使用Message子类,一旦事件函数使用固定参数,就失去灵活性了。
UI事件,我一般是用下面这个类,会自动销毁事件

明白你的设计了。TypeScript 版的 pureMVC 里面,notify(eventName, eventData, eventType)
它的第三个参数,和你 Message 有子类其实本质上是一回事儿。不传就是 Message 类,传了 eventType,就去查对应的构造类。
但是你没必要去扩展 message 类,其实你扩展 data 参数就好了呀。这样所有 message 对象都能复用了。(但其实池化优化的必要性不大)

是的,主旨和PureMVC差不多,再结合TS装饰器,某些时候为了优化性能减少对象创建,Message对象可以池化。

其实事件系统是一个比较简单的组件,怎么方便怎么来。我用的就是string为key,拿普通的object结构当参数,其实基本上也够用了。

lz的事件系统实现,用了接口去规范类型,其实是RPC的设计思路。使用上稍微复杂了点。

2赞

需要定义 Game 类,里面定义个方法,方法中才去派发这个事件,引入了额外的很多个装饰器。

所有在Ixxxx里定义好的Function都会自动生成出相应的key->xxxx_Function,以及派发事件的方法。这一块是自动生成的,所以理论上应该不存在什么心智负担,如果是调用链太长了,习惯就好,作为用户流程上是不需要感知你说的事情的。

onCoinChange -> 事件名 -> 查引用,看习惯,我倒是觉得还好,哈哈。

百花齐放,mark一下

1赞

太复杂了,可以用我的,所有接口都是类型安全的

import * as cc from "cc";

/**
 * 事件对象(类型安全)
 * @noInheritDoc
 * @remarks
 * 获取事件键使用 event_target.key.xxx
 */
class mk_event_target<CT> extends cc.EventTarget {
	/** 事件键 */
	key: { [k in keyof CT]: k } = new Proxy(Object.create(null), {
		get: (target, key) => key,
	});

	/* ------------------------------- 功能 ------------------------------- */
	/**
	 * 监听事件
	 * @param type_ 事件类型
	 * @param callback_ 触发回调
	 * @param target_ 事件目标对象
	 * @param once_b_ 是否触发单次
	 * @returns 触发回调
	 */
	// @ts-ignore
	on<T extends keyof CT, T2 extends (...event_: Parameters<CT[T]>) => void>(
		type_: T | T[],
		callback_: T2,
		target_?: any,
		once_b_?: boolean
	): typeof callback_ | null {
		if (Array.isArray(type_)) {
			type_.forEach((v) => {
				super.on(v as any, callback_ as any, target_, once_b_);
			});

			return null;
		} else {
			// 录入事件对象
			target_?.event_target_as?.push(this);

			return super.on(type_ as any, callback_ as any, target_, once_b_);
		}
	}

	/**
	 * 监听单次事件
	 * @param type_ 事件类型
	 * @param callback_ 触发回调
	 * @param target_ 事件目标对象
	 * @returns 触发回调
	 */
	// @ts-ignore
	once<T extends keyof CT, T2 extends (...event_: Parameters<CT[T]>) => void>(
		type_: T | T[],
		callback_: T2,
		target_?: any
	): typeof callback_ | null {
		if (Array.isArray(type_)) {
			type_.forEach((v) => {
				super.once(v as any, callback_ as any, target_);
			});

			return null;
		} else {
			// 录入事件对象
			target_?.event_target_as?.push(this);

			return super.once(type_ as any, callback_ as any, target_);
		}
	}

	/**
	 * 取消监听事件
	 * @param type_ 事件类型
	 * @param callback_ 触发回调
	 * @param target_ 事件目标对象
	 * @returns 触发回调
	 */
	// @ts-ignore
	off<T extends keyof CT, T2 extends (...event_: Parameters<CT[T]>) => void>(type_: T | T[], callback_?: T2, target_?: any): void {
		if (Array.isArray(type_)) {
			type_.forEach((v) => {
				super.off(v as any, callback_ as any, target_);
			});
		} else {
			super.off(type_ as any, callback_ as any, target_);
		}
	}

	/**
	 * 派发事件
	 * @param type_ 事件类型
	 * @param args_ 事件参数
	 */
	// @ts-ignore
	emit<T extends keyof CT, T2 extends Parameters<CT[T]>>(type_: T | T[], ...args_: T2): void {
		if (Array.isArray(type_)) {
			type_.forEach((v) => {
				super.emit(v as any, ...args_);
			});
		} else {
			super.emit(type_ as any, ...args_);
		}
	}

	/**
	 * 是否存在事件
	 * @param type_ 事件类型
	 * @param callback_ 触发回调
	 * @param target_ 事件目标对象
	 * @returns
	 */
	// @ts-ignore
	has<T extends keyof CT, T2 extends (...event_: Parameters<CT[T]>) => void>(type_: T, callback_?: T2, target_?: any): boolean {
		return super.hasEventListener(type_ as any, callback_ as any, target_);
	}

	/** 清空所有事件 */
	declare clear: () => void;

	/**
	 * 请求事件
	 * @param type_ 事件类型
	 * @param args_ 事件参数
	 * @remarks
	 * 等待请求事件返回
	 */
	// @ts-ignore
	request<T extends keyof CT, T2 extends Parameters<CT[T]>, T3 extends ReturnType<CT[T]>>(type_: T | T[], ...args_: T2): Promise<T3>[] {
		if (Array.isArray(type_)) {
			const result_as: Promise<any>[] = [];

			type_.forEach((v) => {
				result_as.push(...this._request_single(v, ...args_));
			});

			return result_as;
		} else {
			return this._request_single(type_, ...args_);
		}
	}

	/**
	 * 请求单个事件
	 * @param type_ 事件类型
	 * @param args_ 事件参数
	 * @returns
	 */
	// @ts-ignore
	private _request_single<T extends keyof CT, T2 extends Parameters<CT[T]>, T3 extends ReturnType<CT[T]>>(type_: T, ...args_: T2): Promise<T3>[] {
		/** 返回值 */
		const result_as: Promise<any>[] = [];
		/** 回调列表 */
		const callback_as: { callback: Function; target?: any }[] = this["_callbackTable"][type_]?.callbackInfos;

		if (!callback_as) {
			return result_as;
		}

		callback_as.forEach((v) => {
			const old_callback_f = v.callback;
			const target = v.target;

			v.callback = (...args: any[]) => {
				result_as.push(old_callback_f.call(target, ...args));
				v.callback = old_callback_f;
			};
		});

		this.emit(type_, ...args_);

		return result_as;
	}
}

export default mk_event_target;

这个是驼峰版本的

3赞

事件是为了解耦,但是在你准备好定义事件的那一刻,你不就想好了这个事件属于什么模块以及应该要进行什么数据传递么?
以前的思路是写一个eventname,你希望得到别人的数据,或者你希望传递这个数据,你在对应的模块emit或者addListener,现在只是把eventname变成了方法名,数据变成了方法参数,原理是一样的

我现在用的就是学习你的写的 :laughing: 请求事件方法非常好用

1赞

我现在看到这种引入一堆文件的模块就头疼。
踩过很多这种坑。

在此分享一个我用过的WebSocket RPC调用实现,处理网络异步响应非常好用

还是太复杂了,我的MK,甚至不需要消息号[狗头]

我个人觉得你发的使用心智负担太大了.