框架的诞生-一:我想要的框架

我想要的框架是什么样的?

上一篇文章: 《框架的诞生-零:为什么写框架?》里有讲 什么是框架。

框架是一个架子,在游戏程序中,抛开渲染层引擎框架,我们的指的框架就是支撑业务逻辑的架子,也是一个框框,规范和约束着开发人员。

每个框架都有着自己的边界,解决特定领域问题的。

那我们从去分析我遇到了什么问题,有什么需求,如何解决问题,如何实现需求。

无论是小项目还是大项目,或者是一个会不断扩张的项目,都希望能够项目代码结构清晰有条理,管理好不同模块。

我尝试过的方式

Manager of Managers

很多框架都用这种方式(包括我之前写的框架)

这是刘钢老师的《Unity 项目架构设计与开发管理》中讲到的一种相对较好的方式 ▼

优点是:类似于分级结构,各司其职;比如音频管理,场景管理,关卡管理等,每一个都是一个单例脚本,配合使用。结构相对清晰。可以复用

但我个人不太喜欢的点

  • 调用不方便,调用链太长,费手指。比如 UIManager.getInstance().showUI 或者 UIMgr.ins.showUI
    • 我想这样: m.uiMgr.showUI 少敲几个字母,也不想 import UIMgr
  • 无生命周期统一管理,单例初始化和依赖不太可控。惰性初始化,等到调用 getInstance 才初始化。
  • 控制台调试调用不方便,单例可能得单独绑定暴露到全局才能,每个模块都得这样做才行
  • 直接依赖不可动态替换
    • 有个模块我想在不同的平台切换成不同的实现,单例做不到。
  • 大多耦合引擎来开发,只能在同引擎项目进行复用

模块字典挂载到全局变量 window

这是我之前的框架使用的方式

就是将所有的模块初始化,然后注入一个模块字典

然后将字典挂载到全局的一个变量中。

export class Main {
    constructor(){
        const moduleMap = {

        };
        moduleMap["uiMgr"] = new UIMgr();
        moduleMap["netMgr"] = new NetMgr();

        window["aa"] = moduleMap;
    }
}
class UIMgr {

}
class NetMgr {

}

优点:调用方便

缺点: 有点危险,别人知道了可以在控制台调用进行调试。

这些方式在模块管理上都有问题,先不考虑如何方便调用,先实现个如何管理模块的核心机制。

一个具有生命周期的模块化机制

pomelo 给我的灵感

这个想法的灵感来自 pomelo 一个网易开发的基于 nodejs 的分布式服务器框架。

pomelo 支持可插拔的 component 扩展架构

用户只要实现 component 相关的接口: start, afterStart, stop, 就可以加载自定义的组件:

app.load([name], comp, [opts])

start, afterStart 这些生命周期 接口跟 cocos 和 Unity 的组件式接口很像。 主要是方便处理不同模块之间的依赖引用。比如:A 依赖了 B,但 B 还未初始化。

各自的初始化,都在 start 里处理,然后在 afterStart 里进行依赖调用。

可能对于不同的业务,这些生命周期可能不够用,可以根据具体业务进行扩展,满足自定义需求。

比如登录业务相关的:

C 模块依赖 A 和 B 登录后的数据状态,那么增加两个接口 onLoginInit,onAfterLoginInit。

那么 A 和 B 实现 onLoginInit 接口进行登录初始化,C 在 onAfterLoginInit 接口进行依赖调用。

怎么实现我想要的框架?

模块生命周期图

模块生命周期图

接口设计

declare global {
    namespace egf {

        interface IModule {
            /**模块名 */
            key?: string
            /**
             * 当初始化时
             */

            onInit?(app: IApp): void;
            /**
             * 所有模块初始化完成时
             */

            onAfterInit?(app: IApp): void;
            /**
             * 模块停止时
             */

            onStop?(): void;
        }
        type BootEndCallback = (isSuccess: boolean) => void;
        /**
         * 引导程序
         */

        interface IBootLoader {
            /**
             * 引导
             * @param app
             */

            onBoot(app: IApp, bootEnd: BootEndCallback): void;
        }
        /**
         * 主程序
         */

        interface IApp<ModuleMap = any> {
            /**
             * 程序状态
             * 0 未启动 1 引导中, 2 初始化, 3 运行中
             */

            state: number;
            /**
             * 模块字典
             */

            moduleMap: ModuleMap;
            /**
             * 引导
             * @param bootLoaders
             */

            bootstrap(bootLoaders: egf.IBootLoader[]): Promise<boolean>;
            /**
             * 初始化
             */

            init(): void;
            /**
             * 加载模块
             * @param module
             */

            loadModule(module: IModule | any, key?: keyof ModuleMap): void;
            /**
             * 停止
             */
            stop(): void;
            /**
             * 获取模块实例
             * @param moduleKey
             */
            getModule<T extends IModule = any>(moduleKey: keyof ModuleMap): T;
            /**
             * 判断有没有这个模块
             * @param moduleKey
             */
            hasModule(moduleKey: keyof ModuleMap): boolean;

        }
    }
}

// eslint-disable-next-line @typescript-eslint/semi
export { }

Bootloader: CatLib 给我的灵感

这里有一个 bootloader 的东西我没有讲到,它的灵感来自 CatLib,一个我觉得很棒的 Unity 框架。

这个机制是什么呢?以开发测试环境和生产环境举例。

有一个 debugBootLoader,这个引导程序处理一些测试用的模块加载和初始化,杂七杂八的。

当你发布生产环境时,可以通过 debug 变量屏蔽加载这个引导程序,也可以通过编译工具剔除这段代码。

具体实现可以看:https://github.com/AILHC/EasyGameFrameworkOpen/tree/main/packages/core#readme

怎么使用?

具体使用请看 demo 工程

cocoscreator2.x 的 demo https://github.com/AILHC/egf-ccc-empty

cocoscreator3d 的 demo https://github.com/AILHC/egf-ccc3d-empty

如何接入项目

//FrameworkLoader.ts
import { HelloWorld } from "../HelloWorld";
export class FrameworkLoader implements egf.IBootLoader {
    onBoot(app: egf.IApp, bootEnd: egf.BootEndCallback): void {
        const helloWorld = new HelloWorld();
        app.loadModule(helloWorld);
        bootEnd(true);
    }

}
//AppMain.ts
import { App } from "@ailhc/egf-core"
import { FrameworkLoader } from "./boot-loaders/FrameworkLoader";
import { setModuleMap, m } from "./ModuleMap";
/**
 * 这是一种启动和初始化框架的方式,在cocos加载脚本时启动
 * 不依赖场景加载和节点组件挂载
 */

export class AppMain {
    public static app: App<IModuleMap>;
    public static initFramework() {
        const app = new App<IModuleMap>();
        AppMain.app = app;
        app.bootstrap([new FrameworkLoader()]);
        setModuleMap(app.moduleMap);
        app.init();
        window["m"] = m;//挂在到全局,方便控制台调试,生产环境可以屏蔽=>安全
        m.helloWorld.say();
    }

}
AppMain.initFramework();

接入项目很简单,new 一下,bootstrap,init 就可以了~

注入模块也很简单

//在UIMgr.ts开头增加个声明
declare global {
    interface IModuleMap {
        uiMgr:UIMgr
    }
}
//在初始化地方,注入实例
app.loadModule(UIMgr.getInstance(),"uiMgr");

注入的模块是什么类型的,不限制,你可以将业务模块 比如 HeroModule 注入进去,那么业务模块之间就可以直接调用了。也不用担心 typescript 的循环引用了。

举个栗子(随便的):

// BattleModule.ts
m.hero.showHero(1);

//HeroModule.ts
m.battle.startTestBattle(1);

就像服务端的 rpc 调用一样。

app.rpc.chat.chatRemote.kick(session, uid, player, function(data){
});

至于怎么使得接口调用更方便,这个看个人的喜好,我呢,用了一点点魔法,让自己用着舒服又有点安全感。具体实现细节请看 demo

我想在 CocosCreator 和 C3d 中使用

由于我的工作中是用 Laya 的,项目也用了这个框架。但我私底下都是玩 CocosCreator 和 CocosCreator3d 的(为什么啊?你懂得 😉😉)

我不想在项目之间将源码拷贝来拷贝去,迭代更新同步麻烦。

如果能像 npm 包一样 安装就好了。而且核心模块是一个模块,其他模块也是一个模块。

于是我开发了一个模块编译发布的工具,开发之前以为很简单,实际上,踩坑了好久 😂。

这个模块编译发布工具有什么功能?

  • 编译模块成 iife、commonjs、systemjs 格式的 js 文件
  • 自动生成单个.d.ts 声明文件

这个 systemjs 格式的 js 文件可以让不支持 npm 包的 CocosCreator3d 可以像使用 npm 包一样使用。即使到时 Cocos3.0 支持 npm 了,使用方式也一模一样。使用 C3d1.2.0 发布 web,微信小游戏,验证运行没有问题。

import { App } from '@ailhc/egf-core';//像引用npm包一样引用
import { _decorator, Component, Node } from 'cc';
import { m, setModuleMap } from './ModuleMap';
import { FrameworkLoader } from './boot-loaders/FrameworkLoader';
const { ccclass, property } = _decorator;
@ccclass('AppMainComp')
export class AppMainComp extends Component {
    /* class member could be defined like this */
    // dummy = '';

    /* use `property` decorator if your want the member to be serializable */
    // @property
    // serializableDummy = 0;

    onLoad() {
        this._initFramework();
    }
    private _initFramework() {
        const app = new App<IModuleMap>();
        // new TestImport();
        app.bootstrap([new FrameworkLoader()]);
        // app.bootstrap([new FrameworkLoader2()]);
        setModuleMap(app.moduleMap);
        app.init();
        window["m"] = m;//挂在到全局,方便控制台调试,生产环境可以屏蔽=>安全
        m.helloWorld.say();
    }
    start() {

    }

    // update (dt) {}
}

如何开发一个模块

  1. 克隆项目 git clone https://github.com/AILHC/EasyGameFrameworkOpen.git
  2. 复制 packages/package-template 项目,改文件夹名,改 package.json 里的项目名等信息
  3. npm install 初始化项目
  4. 然后用 typescript 进行开发,使用 index.ts 文件将所有代码 export(可以使用 export-typescript 插件自动化,插件版本必须是 0.0.5 之前的)
  5. 使用 egf build 进行编译发布

总结一下框架有什么特性

  • 轻量级模块化机制
  • 模块生命周期
    • 让模块的初始化有序,依赖可控
  • 可面向接口编程
    • 方便具体实现细节可替换,模块可动态替换
  • 友好的类型声明
    • 点一下就有类型提示,传字符串获取模块也有类型提示,很香的。
  • 基于 TypeScript 与引擎无关
  • 每个模块库都是一个 npm 包
  • 模块库可以导出多种 js 格式,让 laya,ccc,c3d 使用,甚至给 Unity、Unreal 用(xLua 作者的 Puerts 了解一下?)

这个框架可以做什么?

特性

  • 基于轻量级无依赖的模块机制,可以为不同项目量身定制框架,可大可小。也可以根据项目的不同阶段进行渐进式扩展。还可以在项目的不同阶段轻易地接入

  • 面向接口编程的模块,底层组件可以无感知替换

  • 基于模块开发工具,我们可以开发和发布一个单独的对核心零依赖的模块,给不同的项目使用。

    • 方便别的项目引用
    • 方便开源
    • 方便做单元测试
  • 基于模块化机制和配套开发工具,大家可以为公司或者个人建立自己的模块库,在不同项目按需复用。

架构设想 ▼

谢谢大家阅读我的文章,希望大家能有所收获。

框架开发系列文章

最后

欢迎关注,更多内容持续更新

公众号搜索:玩转游戏开发

或扫码:img

QQ 群: 1103157878

博客主页: https://ailhc.github.io/

github: https://github.com/AILHC

25赞

仓库地址
github:https://github.com/AILHC/EasyGameFrameworkOpen
coding:https://e.coding.net/AILHC/easy-game-framework/EasyGameFrameworkOpen.git

:grinning: :grinning:

厉害,我至今都没想清楚,我想要什么框架····
只是把自己平常的积累整理成工具框架,根据需要取用···

没有想过自己想要什么框架,但是看了文章发现自己好菜啊,用的最顺手的就是单例这种,包括自己弄小项目的时候自己搭的也是用的单例模式,尴尬

每个人研究的方向不同,成果不同,我也只是因为工作的原因,在这方面涉猎较多,有一些自己的想法。
论坛里有很多大佬,对游戏性,物理,图形学等方面的分享也让我惊叹和向往,也会觉得自己好菜:joy:
等我整完这一系列,再整游戏性或图形学

坐等图形学,前几天工作不忙,然后去看了下shader学习了下,简直打开了新世界大门。然后发现自己真心菜如狗,大学学的不是计算机,然后选修过计算机图形学,结果必修课选逃,选修课必逃。。。

:smile 又又有新东西得慢慢看了,收藏从未停止。

你想要的东西感觉可以通过常驻节点实现呀,可以参考一下 Unity Game Framework https://gameframework.cn/

2赞

谢谢,常驻节点我知道,那个也可以,有依赖性,依赖cocoscreator的节点机制。
而且初始化时机依赖于场景加载和组件加载机制
我的实现突破限制,更加自由

请教一下,
模块注入之后,再使用的时候还会有intellisense么?

可以的
比如

有一个注意事项,就是需要传一个模块字典接口
像示例一样
new App()
这样即使使用 app.getModule(``)
也会提示你传什么字符串,传错了也会红线提示,
而且返回的实例也有对应的类型提示

这两天经过一些小伙伴的反馈,以及自己去迭代
核心模块做了一些更新

更新日志

0.1.6 (2020/12/02)

  1. 修改类型声明使getModule也能有更好的类型提示

框架库工具也更新了

更新日志

1.0.0 (2020/12/2)

  1. 修复引用外部声明文件时,把声明也合进来了,没必要

  2. 增加一个tsconfig字段externalTag配置控制是否编译引用模块(避免编译进来又不能用的问题)

  3. 将模块模板转移到cli工程里,方便发布出去

0.0.5 (2020/11/30)

更新构建工具,修复引用第三方npm包时声明文件导出出错的问题

谢谢各位小伙伴,欢迎提问题,互相交流
https://github.com/AILHC/EasyGameClientFrameworkOpen.git

image

看测试例子,_test1的类型是声明好为TestModule的类型,才能做到提示吧?
但如果类似在dpctrl-ccc中使用core中的接口,那如何做到提示呢?
将接口声明到d.ts中?

不是哦
首先在每个模块都可以在任意文件内加入这个全局声明(推荐在每个模块文件顶部加入)
image
这里你可以指向具体的类,也可以指向一个接口(推荐)
然后
将这个IModuleMap赋值给 App
app = new App();

app.getModule() 就可以获得类型声明了
可以参考egf-ccc-empty 项目中的helloworld模块的创建

有点明白了。
core其实就是提供一个类似模块管理(注册、获取、生命周期管理)。
layer、display-ctrl以及hellowold是依赖core的模块。
这些模块通过core结合在一个“空间”里,提供给具体项目任意地方使用。
然后这个模块需要带上声明文件、src生成的js之类发布到出去,具体项目npm下来,通过ts的声明合并和项目的模块结合,提供提示。

不知理解是否有误

这个理解不太对,其实它们都可单独使用,比如你对我的core模块和设计不满意,但你觉得layer模块和display-ctrl不错,想用都可以拿去用。
不用依赖core

明白,
这些模块代码没有任何地方依赖core。
只是为了脱离import / 单例 / 全局变量而使用到core接口。

那么,模块之间的依赖怎么处理呢?
一个框架应该需要提供模块裁剪能力以减少代码包大小吧?
因为迭代的功能越多,框架会越庞大复杂。

package.json的dependencies字段?

下一篇文章我讲一下,我对我这个框架和工具的定位,可以期待一下