没有一个整体。Unity中的模块化方法

图片


本文将考虑在Unity引擎上设计和进一步实现游戏的模块化方法。描述了您必须面对的主要利弊。



术语“模块化方法”是指内部使用独立的,可插拔的最终组件的软件组织,这些组件可以并行开发,即时更改并根据配置实现不同的软件行为。



模块结构



首先确定模块是什么,模块具有什么结构,系统的哪些部分负责什么以及如何使用它们,这一点很重要。



该模块是一个相对独立的程序集,不依赖于项目。可以在完全不同的项目中使用适当的配置,并在项目中使用一个公共核心。执行模块的强制条件是存在跟踪。部分:



基础设施组装


该装配体包含其他装配体可以使用的模型和合同。重要的是要了解,模块的这一部分一定不能链接到特定功能的实现。理想情况下,框架只能引用项目的核心。

组装结构如下所示。办法:



图片


  • 实体-在模块内部使用的实体。
  • 消息传递-请求/信号模型。您可以稍后再阅读。
  • 合同是存储接口的地方。


重要的是要记住,建议尽量减少对基础架构程序集之间链接的使用。



构建功能


该功能的具体实现。它可以在其内部使用任何体系结构模式,但是经过修改后,该系统必须是模块化的。

内部架构如下所示:



图片


  • 实体-在模块内部使用的实体。
  • 安装程序-用于注册DI合同的类。
  • 服务是业务层。
  • 管理器-管理器的任务是从服务中提取必要的数据,创建一个ViewEntity并返回ViewManager。
  • ViewManagers-从Manager接收一个ViewEntity,创建所需的View,转发所需的数据。
  • 视图-显示从ViewManager传递的数据。


实施模块化方法



为了实施该方法,可能需要至少两种机制。我们需要一种将代码分为程序集和DI框架的方法。本示例使用程序集定义文件和Zenject机制。



上述特定机制的使用是可选的。最主要的是要了解它们的用途。您可以用带有IoC容器或其他任何东西的任何DI框架替换Zenject,并用其他任何可以将代码组合成程序集或使其独立的其他系统来替换程序集定义文件(例如,可以将不同的存储库用于不同的模块,这些模块可以作为包装,子模块连接吉塔或其他任何东西)。



模块化方法的一个特点是,从一个功能部件到另一个功能部件的装配没有明确的引用,除了对可以存储模型的基础结构装配的引用之外。模块之间的交互是通过包装来自Zenject框架的信号来实现的。包装器允许您将信号和请求发送到不同的模块。应当注意,信号表示当前模块对其他模块的任何通知,而请求表示对可以返回数据的另一个模块的请求。



讯号


信号-一种通知系统有关某些更改的机制。拆卸它们的最简单方法是在实践中。



假设我们有2个模块。Foo和Foo2。Foo2模块应响应Foo模块中的某些更改。为了摆脱模块的依赖性,实现了2个信号。Foo模块内部的一个信号将通知系统有关状态更改的信息,而Foo2模块内部的第二个信号则通知系统。Foo2模块将对此信号做出反应。OnFoo2Signal中OnFooSignal信号的路由将在路由模块中。

从示意图上看,它将如下所示:



图片




询价


查询可以解决一个模块从另一个模块(其他模块)接收/发送数据的通信问题。



考虑上面针对信号给出的类似示例。

假设我们有2个模块。Foo和Foo2。Foo模块需要Foo2模块中的一些数据。在这种情况下,Foo模块应该对Foo2模块一无所知。实际上,可以使用其他信号来解决此问题,但是带有查询的解决方案看起来更简单,更漂亮。



它看起来像这样:



图片


模块之间的通讯



为了最小化具有功能的模块之间的链接(包括基础设施-基础架构链接),决定对Zenject框架提供的信号编写一个包装器,并创建一个模块,其任务是路由不同的信号和地图数据。



PS实际上,此模块具有指向所有不好的基础结构程序集的链接。但是这个问题可以通过IoC解决。



模块交互的例子



假设有两个模块。LoginModule和RewardModule。FB登录后,RewardModule应该向用户提供奖励。



namespace RewardModule.src.Infrastructure.Messaging.Signals
{
    public class OnLoginSignal : SignalBase
    {
        public bool IsFirstLogin { get; set; }
    }
}


namespace RewardModule.src.Infrastructure.Messaging.RequestResponse.Produce
{
    public class GainRewardRequest : EventBusRequest<ProduceResponse>
    {
        public bool IsFirstLogin { get; set; }
    }
}


namespace MessagingModule.src.Feature.Proxy
{
    public class LoginModuleProxy
    {
        [Inject]
        private IEventBus eventBus;
        
        public override async void Subscribe()
        {            
            eventBus.Subscribe<OnLoginSignal>((loginSignal) =>
            {
                var request = new GainRewardRequest()
                {
                    IsFirstLogin = loginSignal.IsFirstLogin;
                }

                var result = await eventBus.FireRequestAsync<GainRewardRequest, GainRewardResponse>(request);
                var analyticsEvent = new OnAnalyticsShouldBeTracked()
                {
                   AnalyticsPayload = new Dictionary<string, string>
                    {
                      {
                        "IsFirstLogin", "false"
                      },
                    },
                  };
                eventBus.Fire<OnAnalyticsShouldBeTrackedSignal>(analyticsEvent);
            });


在上面的示例中,模块之间没有直接链接。但是它们通过MessagingModule链接。重要的是要记住,除了信号/请求路由和映射之外,路由中应该没有其他内容。



替代实施



使用模块化方法和功能切换模式,就可以对应用程序产生影响,从而获得惊人的结果。在服务器上具有一定的配置后,您可以在应用程序启动时操纵启用/禁用不同模块,并在游戏中进行更改。



这是通过在Zenject中的模块绑定(实际上是绑定到容器)过程中检查模块可用性标志来实现的,基于此,模块是否绑定到容器中。为了实现游戏过程中行为的改变(假设您需要在游戏过程中更改机制。有一个纸牌模块和克朗代克模块。对于50%的用户,方巾模块应该起作用),开发了一种机制,当从一个场景切换到另一个场景时清理特定的模块容器并绑定新的依赖项。



他在那条路上工作。原则:如果启用了某个功能,然后在会话期间禁用了该功能,则有必要清空该容器。如果启用了此功能,则需要对容器进行所有更改。在“空”阶段执行此操作很重要,以免破坏数据和连接的完整性。可以实现此行为,但是不建议使用这种功能作为生产功能,因为它带来更大的损坏某些东西的风险。



下面是基类的伪代码,要求其后代在容器中注册某些内容。



    public abstract class GlobalInstallerBase<TGlobalInstaller, TModuleInstaller> : MonoInstaller<TGlobalInstaller>
        where TGlobalInstaller : MonoInstaller<TGlobalInstaller>
        where TModuleInstaller : Installer
    {
        protected abstract string SubContainerName { get; }
        
        protected abstract bool IsFeatureEnabled { get; }
        
        public override void InstallBindings()
        {
            if (!IsFeatureEnabled)
            {
                return;
            }
            
            var subcontainer = Container.CreateSubContainer();
            subcontainer.Install<TModuleInstaller>();
            
            Container.Bind<DiContainer>()
                .WithId(SubContainerName)
                .FromInstance(subcontainer)
                .AsCached();
        }
        
        protected virtual void SubContainerCleaner(DiContainer subContainer)
        {
            subContainer.UnbindAll();
        }

        protected virtual DiContainer SubContainerInstanceGetter(InjectContext containerContext)
        {
            return containerContext.Container.ResolveId<DiContainer>(SubContainerName);
        }
    }


基本模块的示例



让我们看一下如何实现模块的简单示例。



假设您需要实现一个模块,该模块将限制摄像头的移动,以使用户无法将其带到屏幕的“边界”之外。



该模块将包含一个基础结构组件,该组件带有一个信号,该信号将通知系统相机已尝试离开屏幕。



功能-功能实现。这将是检查摄像头是否超出范围,将其通知其他模块等的逻辑。



图片


  • BorderConfig是描述屏幕边界的实体。
  • BorderViewEntity是要传递给ViewManager和View的实体。
  • BoundingBoxManager-从服务器获取BorderConfig,创建BorderViewEntity。
  • BoundingBoxViewManager — MonoBehaviour'a. , .
  • BoundingBoxView — , «» .




  • . , , .
  • .
  • EventHell, , .
  • — , . , , — .
  • .
  • .
  • - , . , MVC, — ECS.
  • , .
  • , .



All Articles