MEGALOVANIA MEGALOVANIA

游戏开发中

目录
技术框架的一点想法,以及VContainer和MessagePipe的快速指南
/  

技术框架的一点想法,以及VContainer和MessagePipe的快速指南

前言

今年的新改变是打算偶尔写一点点技术向的文章,不再是只写流水帐了,单纯记录一下一些用过的东西和框架。

本来想着整一套口播 +whisper+Cursor 的自动出视频教程文案的工作流的,奈何有点失败,为此还花了 20 刀买了一个月的 CursorPro。这篇暂时还是先用自己手写了。

因为最近想要花点时间,在重写自己的 GameFramework,而且有开源的意向(虽然估计也没人看就是了)

去年也写了不少的屎山代码。最夸张的时候两个月就能造 9800 多行的 C#屎山代码,这么多的屎山代码里总有一些是能够存留下来,接着为后面的屎山做出巨大贡献的。所以今年可能会有这么一个系列,来统述我逐渐堆积屎山的过程。

现在大概想到的能够放进屎山工具箱的内容可能有:

  • Actor 框架
    • 之前都是用 Zenject 串起来的。现在在测试 Zenject 的替代品 VContainer
    • 主要包含了移动,输入,技能配置,一堆乱七八糟的东西。可能主要问题是有一些类是用的付费的插件,在想这部分怎么用免费的插件版本替代,比如 AIPath 之类
    • 我觉得这套框架能用来做挺多不同类型游戏的,因为本身参考的是一个 UE 上的动作游戏框架,所以我觉得可能适合于做 TPS/FPS 视角的动作游戏,也适合做顶视角的动作游戏。
  • 配置系统
    • 其实只是一套用来读 Excel 的配置,简单用反射写了一点东西。这个主要是在去年比赛的项目用得比较多
    • 虽然遇到了很多 Excel 的神奇问题,但是我觉得这个配置工具本身还是毛病不大的。
    • 但是谨慎起见,有时间的话,还是写一个 Excel 转 csv 然后再读取 csv 的工具把,Excel 这玩意合作起来有点太变态了
  • Buff 系统
    • Buff 系统迭代了四个不同的小项目了,每个项目的需求都。。。不尽相同
    • 事实上 buff 系统真的是一个几乎完全没办法完整复用的东西,但是我觉得在去年比赛的游戏上,那个 buff 的思路是比较可行的。虽然最后出了 bug....但是我当时开发的目标是“能够搞定类似 moba 游戏里所有的技能”这样的目标
    • 所以应该会把比赛那套 buff 屎山重新整理一遍,整理成一个通用的框架
  • 动画系统
    • 说实话,这个是属于 Actor 系统的一部分。但是后来在写联机游戏的时候遇到了很多问题,也从头改动了里面的很多实现上错得离谱但是之前没发现的部分
    • 其实更多的是关于技能配置,最后还是决定用 timeline 来做。我觉得扩展扩展,真的是能做格斗游戏/动作游戏的(大概把
  • 网络联机
    • 这部分没啥可说的,就是 mirror 套壳。事实上 mirror 确实已经简单到不能再简单了。但是为了我曾经创造过的屎山,我决定为我的 actor 支持一套基于 mirror 的 p2p 联机的框架
  • AI
    • 这部分有两个思路,一个是 GOAP,一个是传统行为树。行为树可能确实没啥好写的,直接 BehaviourDesigner 得了
    • 不过这部分我暂时可能不会写,因为我觉得我自己也没做过稍微复杂点的 AI,等有经验了再说
  • UI
    • 有几个解决方案:
      • PSD
      • Figma
      • Rive
    • 说实话还不清楚,再研究研究
    • UI 的代码框架我自己觉得,可能就是写多了自己总结总结把。
    • 毕竟目前感觉下来,开新项目的时候,UGUI 写起来速度还是挺慢的

希望新的一年能够真的实打实地输出一些东西吧

简介

总纲

今天来介绍两个我在做自己的 GameFramework 的时候接触到的两个框架:

  • VContainer
  • MessagePipe

VContainer 框架

简介

Unity 游戏引擎的超快 DI(依赖项注入)。“V”表示使 Unity 的初始“U”更细、更坚固..!

  • **快速解决:**基本上比 Zenject 快 5-10 倍
  • 最低 GC 分配:在 Resolve 中,没有生成的实例的分配为零
  • **代码大小小:**内部类型和 .callvirt 很少。
  • **协助正确的 DI 方式:**提供简单透明的 API,并精心选择功能。这可以防止 DI 声明变得过于复杂。
  • **不可变容器:**线程安全性和健壮性。

对比 Zenject

Zenject 太棒了。但是,VContainer 具有以下优点:

  • 性能好。
  • 反射和断言的大部分部分都与 Container 的构建阶段隔离。
  • 易于阅读的实施。
  • VContainer 精心挑选了功能,不会在容器中注册面向数据的对象,也不会主动注入 View 组件。这可以防止 DI 声明变得过于复杂。
    • Zenject 通常用于注入动态或以数据为中心的对象,但这很复杂
    • 在 VContainer 中,建议注入 MonoBehaviour 而不是注入 MonoBehaviour。
    • 当场景开始时,Zenject 会在反射中查找所有游戏对象,但此操作的成本很高;VContainer 不执行此操作。

MessagePipe 框架

介绍

MessagePipe 是适用于 .NET 和 Unity 的高性能内存/分布式消息传递管道。它支持所有使用 Pub/Sub 的情况、CQRS 的 mediator 模式、Prism 的 EventAggregator(V-VM 解耦)、IPC(进程间通信)-RPC 等。

优势

  • Dependency-injection first
  • Filter pipeline
  • better event
  • sync/async
  • keyed/keyless
  • buffered/bufferless
  • singleton/scoped
  • broadcast/response(+many)
  • in-memory/interprocess/distributed

使用建议

  • 如果你需要一个现代的 DI 框架,建议使用 VContainer
  • 如果你需要一个高性能的消息框架,可以尝试 MessagePipe

Vcontainer

项目地址:https://github.com/hadashiA/VContainer

如果你对 DI/Ioc 还不是很了解,可以自己去看一下相关的视频或者讲解文章,这里就不再赘述了。

这里只写一个最简单的需求来展示一下基础的写法,如果我的理解和解释还有问题的还请指出。

实例需求

我们想要这样一个功能:

一个简单的切换 Tab,我们希望点击 tab 页面的时候能够切换,然后如果点击到某些页面的时候(比如空白页面),显示一个错误的小 Tip

我们大概需要两个功能模块:一个负责处理点 Tab 相关的 UI/数据操作,另一个需要一个 MessageLayerMgr 类作为单例,来处理显示错误小 Tip 的功能。

这个代码直接用 C#在 Monobehaviour 里写是很简单的,但是现在我们有了伟大的 VContainer,所有的代码都可以写在

非 MonoBehaviour 中了

MessageLayerMgr

这个类的声明很简单,代码如下

 1namespace Project.Script.UIFramework
 2{
 3    public class MessageLayerMgr
 4    {
 5        public void ShowErrorTip(string message)
 6        {
 7            Debug.Log($"Error: {message}");
 8        }
 9    }
10}

没什么特别的,就只有一个方法,展示错误 Tip(具体实现就先不写了)

创建 LifeTimeScope

创建这个代码,并且将之挂到一个场景里的 GameObject 上

 1namespace Project.Script.UIFramework
 2{
 3    public class GameLiftetimeScope : LifetimeScope
 4    {
 5        protected override void Configure(IContainerBuilder builder)
 6        {
 7            builder.Register<MessageLayerMgr>(Lifetime.Singleton);
 8        }
 9    
10    }
11}

注意要手动指定 Inject 的 GameObject.当然这个 GameObject 只要是一个父节点即可,VContainer 会自动遍历所有的子节点

然后在使用的时候,只需要在 Canvas 下面的任意子节点的 MonoBehaviour 中

1    [Inject] MessageLayerMgr messageLayerMgr;
2
3    private void OnClickTab(int index)
4    {
5        messageLayerMgr.ShowErrorTip("Error");
6    
7}

分离 UI 的代码设计

这里简单说下 UI 的文件,UI 功能上分离为这么几个文件

  • EasyTabPresenter
    • 主要是控制逻辑流,桥接 View 和 Service
  • EasyTabService
    • 提供具体的数据逻辑,方便组合和重载
  • UIEasyTab
    • 单纯只是一个 ModelView,负责挂上去以后指定具体的 GameObject 与组件

具体的一些思路和写法可以看 https://vcontainer.hadashikick.jp/getting-started/hello-world **6. Inversion of Control (IoC) **的部分,可以根据那个形式写出自己的代码。

子容器

这里只说一下怎么和刚才的注入结合起来。

我的方案是自己定义一个新的**UILiftetimeScope,**内容大概就是注入 EasyTabPresenterEasyTabServiceUIEasyTab,并且将 EasyTabPresenter 加入到 EntryPoint

 1namespace Project.Script.UIFramework
 2{
 3    public class UILifeTimeScope : LifetimeScope
 4    {
 5        protected override void Configure(IContainerBuilder builder)
 6        { 
 7            builder.RegisterEntryPoint<EasyTabPresenter>();
 8            builder.Register<EasyTabService>(Lifetime.Scoped);
 9            builder.RegisterComponent(GetComponentInChildren<UIEasyTab>());
10        }
11    }
12}

然后只需要将这个 UILifeTimeScope 挂到 UI 上,并且指定一下 LifeTimeScope 的父对象

这样即使有很多个 EasyTab,最后的效果也是正确的

MessagePipe

项目地址:https://github.com/Cysharp/MessagePipe

这里只说几个我觉得用得上的功能

Dependency-injection first

最大的卖点。可以非常优雅地写 Publishe 和 subscribe

说实在话我确实想不出来非单例的订阅和发布有啥意义,可能某些特殊的需求下需要将不同种类的订阅发布隔离开来把(比如网络消息和其他应用中消息)

首先注册

1// RegisterMessagePipe returns options.
2var options = builder.RegisterMessagePipe(/* configure option */);
3
4// Setup GlobalMessagePipe to enable diagnostics window and global function
5builder.RegisterBuildCallback(c => GlobalMessagePipe.SetProvider(c.AsServiceProvider()));
6
7// RegisterMessageBroker: Register for IPublisher<T>/ISubscriber<T>, includes async and buffered.
8builder.RegisterMessageBroker<int>(options);

这里注册了一个 Action<int> 的 Puslisher/subscriber(以下简称 PB),需要注意的是,所有的该类型的订阅都会走同一个管线.也就是所有你这个类型下发布的消息都会被收到,这点特别要注意。

用起来很简单

发布端:

 1public class MessageLayerMgr
 2{
 3    [Inject] IPublisher<int> publisher;
 4  
 5    public void ShowErrorTip(string message)
 6    {
 7        Debug.Log($"Error: {message}");
 8        publisher.Publish(10086);
 9    }
10}

订阅端稍微麻烦一点,因为要注意 DIspose 对象,不然的话会有订阅泄露,不过 Message Pipe 提供了一个比较好的写法

 1[Inject]
 2ISubscriber<int> _subscriber;
 3
 4private IDisposable _disposable;
 5
 6public void Start()
 7{
 8    AutoBind();
 9  
10    OnClickTab(view.DefaultIndex);
11
12    var bag = DisposableBag.CreateBuilder();
13    _subscriber.Subscribe((input)=> Debug.Log($"Received {input}")).AddTo(bag);
14    _disposable = bag.Build();
15}
16
17public void Dispose()
18{
19    _disposable.Dispose();
20}

这样多个对象也可以交给一个_disposable 对象来管理

keyed/keyless

因为刚才的注意的问题

这里注册了一个 Action<int> 的 Puslisher/subscriber(以下简称 PB),需要注意的是,所有的该类型的订阅都会走同一个管线.也就是所有你这个类型下发布的消息都会被收到,这点特别要注意。

因此很多时候更常用的其实是带 Key 的 PB,写法也很类似,在泛型里输入两个参数即可,第一个参数便是 Key

 1public class GameLiftetimeScope : LifetimeScope
 2{
 3    protected override void Configure(IContainerBuilder builder)
 4    {
 5        ...
 6        builder.RegisterMessageBroker<GameObject, int>(options);
 7        ...
 8    }
 9  
10}
11

其他的用法一样,唯一的区别就是在 Publish 的时候需要传一个 GameObject 作为 Key 进去

Filter pipeline

花里胡哨,但是现在还没想到有什么用的功能。

简单来说就是能够筛选一些消息管道中的消息,通过在 Subscribe 中指定 Filter 来实现一些过滤或者附加的操作(比如打 Log)

这是 HandlerFilter 的声明

 1public class MessageFilter10086<T> : MessageHandlerFilter<T> 
 2{
 3    public override void Handle(T message, Action<T> next)
 4    {
 5        if (!(message is int))
 6        {
 7            next(message);
 8            return;
 9        }
10
11        var t = int.TryParse((message).ToString(), out int messageInt);
12        if (messageInt == 10086)
13        {
14            Debug.Log("Received 10086 Global");
15            return;
16        }
17    
18        next(message);
19    }
20}

全局设置

1protected override void Configure(IContainerBuilder builder)
2{
3    // RegisterMessagePipe returns options.
4    var options = builder.RegisterMessagePipe(/* configure option */(t) =>
5    {
6        t.AddGlobalMessageHandlerFilter<MessageFilter10086<int>>();
7    });

针对某个单独的 Subscribe 来设置

1_subscriber.Subscribe((input)=> Debug.Log($"Received {input}"), new []{new MessageFilter10086<int>()}).AddTo(bag);

better event

说实在话,我都已经用了消息管道了,我干嘛还要费劲手写 Event,不是非常理解。

所以我暂时认为这个功能没什么用

sync/async

Request/response。经常写 Js 的应该非常熟悉的,中间件模式

目前我认知里觉得还是属于整花活系列,不过配合着 UniTask 能够整出一些有意思的功能

不过某些场景下:如网络,资源异步加载的情况下,是比较适合做这样的操作的

可以简单写一个范例

 1public class TestRequestResponse1 : IAsyncRequestHandler<int, string>
 2{
 3    public async UniTask<string> InvokeAsync(int request, CancellationToken cancellationToken = default)
 4    {
 5        Debug.Log($"requestStart: {request}");
 6        await UniTask.WaitForSeconds(1.0f);
 7        Debug.Log($"requestEnd: {request}");
 8        return $"ResponseResult:{request.ToString()}";
 9    }
10}

注册到 Builder 上

1builder.RegisterAsyncRequestHandler<int, string, TestRequestResponse1>(options);

在具体类中使用

1[Inject] IAsyncRequestAllHandler<int, string> _requestAllHandler;
2
3public async UniTask StartRequest()
4{
5    var pb = _requestAllHandler.InvokeAllAsync(10086);
6    await UniTask.WhenAll(pb);
7    Debug.Log("RequestOver");
8}

甚至可以同时注入多个 Requsest

 1public class TestRequestResponse1 : IAsyncRequestHandler<int, string>
 2{
 3    public async UniTask<string> InvokeAsync(int request, CancellationToken cancellationToken = default)
 4    {
 5        Debug.Log($"requestStart: {request}");
 6        await UniTask.WaitForSeconds(1.0f);
 7        Debug.Log($"requestEnd: {request}");
 8        return $"ResponseResult:{request.ToString()}";
 9    }
10}
11
12public class TestRequestResponse2 : IAsyncRequestHandler<int, string>
13{
14    public async UniTask<string> InvokeAsync(int request, CancellationToken cancellationToken = default)
15    {
16        Debug.Log($"request2Start: {request}");
17        await UniTask.WaitForSeconds(2.0f);
18        Debug.Log($"request2End: {request}");
19        return $"Response2Result:{request.ToString()}";
20    }
21}
22
23...
24protected override void Configure(IContainerBuilder builder)
25{
26    // RegisterMessagePipe returns options.
27    var options = builder.RegisterMessagePipe(/* configure option */(t) =>
28    {
29    });
30  
31    // Setup GlobalMessagePipe to enable diagnostics window and global function
32    builder.RegisterBuildCallback(c =>
33    {
34        GlobalMessagePipe.SetProvider(c.AsServiceProvider());
35    });
36
37    builder.RegisterAsyncRequestHandler<int, string, TestRequestResponse1>(options);
38    builder.RegisterAsyncRequestHandler<int, string, TestRequestResponse2>(options);
39  
40    builder.Register<MessageLayerMgr>(Lifetime.Singleton);
41}
42...

最后能够输出多个结果

默认情况下,多个 Request 是 parallel 执行的,你也可以通过修改 option,来让他们依次序执行

1var options = builder.RegisterMessagePipe(/* configure option */(t) =>
2{
3    t.DefaultAsyncPublishStrategy = AsyncPublishStrategy.Sequential;
4});

输出的结果就是依次执行的了

buffered/bufferless

关于这个可以直接看文档的介绍,简单来说就是 subscribe 刚开始订阅的时候,可以马上返回最后一次 publish 的值

不过好像只支持 valuetype

这也是一个听起来很炫酷但是事实上我没想到有什么用的功能

singleton/scoped

没用,还是那句话,我觉得好像正常都应该是 SIngleton 的

broadcast/response(+many)

我认为这里指的是他的 Request/response 的功能,也可能我理解错了,详情参考 sync/async

in-memory/interprocess/distributed

进程间和网络通信的目前我的客户端开发好像不太会用到,有兴趣的可以自己去体验一下


标题:技术框架的一点想法,以及VContainer和MessagePipe的快速指南
作者:matengli110
地址:https://www.sunsgo.world/articles/2025/01/05/1736070513230.html