故事背景
故事的开始还要来源于一个很经典的故事。我是一个重度听歌爱好者,平时的学习都在linux上。奈何网易云的第三方客户端都写的实在太差了。要么是性能差劲,要么是bug满天飞。
由于最近一段时间都在研究bevy,并且还写了一本5w字的书(虽然还没彻底写完,感兴趣的读者也可以看看,同时这也是我的个人研究笔记:ailrid.github.io/Bevy-Engine...)。于是我突发奇想,能不能用bevy那样的游戏引擎的设计思维,来设计应用程序呢?
在研究了各种应用开发方案后,我还是选择了使用electron而不是Tauri(linux上的webkitgtk兼容性简直是灾难)作为我的试验场。年初开始正式动工,到目前已经大致完成了初版了,全部使用TypeScript编写,并正式将框架命名为Virid(拉丁语,意为翠绿的,充满生命力的。取这个名字,是因为我希望virid能够在面对复杂的业务逻辑下,仍然充满生命力)
设计理念
游戏,本质上其实是一个高并发的大型3D应用程序。Bevy目前采用了游戏引擎领域内的ECS设计模式。这是一种专为处理大型数据设计的实体-组件-系统设计理念。其中每个系统是一个完整的逻辑单元体,不同的系统之间靠着消息来相互驱动。数据则被单独组织出来,作为纯净的组件存在。
virid吸收并借鉴了大量的Bevy以及Nestjs的思想。设计了一套完全环境无关,0依赖的纯净调度引擎系统。即@virid/core来作为整个virid的核心。设计目标为:"将业务逻辑与数据彻底剥离为单独的核心可服用的、高度解耦的资产"。
virid将游戏引擎的传统时间帧概念进行了拓展。对于一个应用程序业务引擎来说,每秒钟运行60次来检测是否需要执行和更新是荒谬的,因此我引入了"逻辑帧"的概念:从一个起点开始,直至整个状态稳定。这保证了执行顺序的同时,也不会耗尽cpu资源。
有人可能会问,用游戏引擎级别的理念来设计应用程序有什么好处呢?确实,如果你只是需要构建一个能跑起来快速上线然后也觉得维护几千行的屎山代码没关系的话,那确实并不需要virid。但如果你希望你的程序是一个需要高确定性,不希望有太多bug,不希望会变成屎山代码的应用,那么virid绝对值得你看看的优点如下:
- 环境绝对独立:不依赖任何浏览器、Node.js 的特殊 API 或第三方库(仅依赖 reflect-metadata 实现装饰器功能)。这确保了你写的业务逻辑,可以无缝运行在 Electron 主进程、Worker 线程、Web 渲染层甚至纯服务器环境中。
- 确定性调度:引入了类似游戏引擎的 Tick 机制,通过双缓冲消息池确保逻辑执行顺序的可预测性。
- 强类型与所有权:所有的系统使用强类型构建,强制使用现代TS类型和类本身作为标识,且拥有运行时修改护盾检查拦截任何UI的非法写操作,你永远不可能遇到"不知道我的变量在哪儿改了"这种事情。
代码实现
在virid中,任何外部IO(不管是来自http、还是ipc通讯、还是DOM事件)全部都被抽象为一个Message。任何消息只需要被发送!剩下的事情引擎将会自动帮你完成。
要定义自己的业务逻辑。首先需要设计一个Component,这个特殊的class承载了一类特定的数据,并用@Component来标识,就像下面这样。
kotlin
//定义数据
@Component()
class CounterComponent {
public count = 0;
}
定义好需要的数据,接下来是决定如何处理这样的数据,并且设计一个独特的信号。在virid中,逻辑被抽象为System。这是一个纯static的静态方法,写上他,然后用@System装饰器标记,就这样!一切就完成了。
接着,只需要在该system的参数中声明需要的类型,并且指定其为我们刚才创建的组件的类型。并且从SingleMessage继承一个我们自己的Message,同样用@Message在system中标记,一切就完成了!
scala
//定义消息
class IncrementMessage extends SingleMessage {
constructor(public amount: number) {
super();
}
}
//纯静态static,当消息发送时引擎自动调用onIncrement
class CounterSystem {
@System()
static onIncrement(
@Message(IncrementMessage) msg: IncrementMessage,
count: CounterComponent
) {
count.count += msg.amount; // 自动注入的 CounterComponent 实例
}
}
要触发更改,只需要在任何地方写下这些代码,@virid/core将会精确的找到onIncrement,并将msg与count传入执行此system
scss
//send内的参数为IncrementMessage的构造函数可以接受的参数
IncrementMessage.send(newNumber)
就是这样!这就是virid/core的核心功能,一个消息驱动的业务引擎。
你或许可能会问,这和eventBus有什么区别呢?我为什么不直接使用eventBus呢?这是一个好问题,如果你只是需要一个简单的总线,那么你确实不需要virid。但是如果你需要以下的功能,那么virid将会直接提供强的内置支持:
- Electron开发生命周期全流程护航:@virid/core仅提供了核心的调度功能,同时还存在着以下的插件,让你无缝将此模式应用到其他领域:
virid/vue:提供了强大的vue兼容层,彻底剥离了vue中的所有业务代码,让你的代码完全脱离vue运行,彻底把vue变为渲染终端。让你哪怕明天换react重写,也不需要重新写几行代码。 virid/main(bridge、renderer):提供了强大的ipc抽象机制,你再也不会写ipcMain.on这样的代码。从渲染进程发消息,主进程直接收到并执行。 virid/express:提供了强大的express兼容层,将http请求映射为Message,让你的后端代码和前端代码看起来一模一样,并且支持请求的内部重定向,而且通过引用计数机制无需关心请求挂起。
- 确定性调度:通过Tick机制和双缓冲机制,绝对不会存在普通eventBus那样的执行顺序混乱问题,virid可以向你保证,在一帧之内,A一定会在B之前执行,且支持消息的内部转发与重定向,像构建一个网络一样来处理消息。
- 0成本抽象:@virid/core只提供核心调度功能,且始终坚持0成本抽象,不会向你的应用中引入任何依赖,完全兼容你的旧代码。
- 全插件化:virid的整个设计理念和生态高度插件化。并且提供了大量的hooks可以进行插件开发,上面的所有功能全部都以插件的形式存在,不会向你捆绑任何功能。
- 等等等等.......
实战演示
借助virid核心以及生态插件,用virid搭建了一个完整的第三方云音乐客户端。前端使用virid/vue、后端使用virid/express来搭建。且支持本地在线缓存、歌曲下载、AudioWorklet处理、MediaSession交互处理的完善播放器(还未彻底完成,代码仓库:github.com/Ailrid/star...)
以MediaSession为例,通过几行代码,即可直接对接完整的系统接口,且无需重构之前的任何代码:
typescript
@System({
priority: 998,
messageClass: InitializationMessage
})
static initMediaSession(playerComponent: PlayerComponent, playlistComponent: PlaylistComponent) {
const actionHandlers: [MediaSessionAction, (params?: any) => void][] = [
['play', () => PlayOrPauseMessage.send(true)],
['pause', () => PlayOrPauseMessage.send(false)],
['previoustrack', () => PreviousSongMessage.send()],
['nexttrack', () => NextSongMessage.send()],
[
'seekbackward',
() => {
playerComponent.player.seek(playerComponent.player.currentTime - 10)
}
],
[
'seekforward',
() => {
playerComponent.player.seek(playerComponent.player.currentTime + 10)
}
],
[
'seekto',
(details: MediaSessionActionDetails) => {
if (details.seekTime !== undefined) {
playerComponent.player.seek(details.seekTime)
navigator.mediaSession.setPositionState({
duration: playlistComponent.currentSong?.duration,
playbackRate: 1,
position: details.seekTime
})
}
}
]
]
for (const [action, handler] of actionHandlers) {
navigator.mediaSession.setActionHandler(action, handler)
}
}
播放器展示如下:
