Nem um único monólito. Abordagem modular no Unity

imagem


Este artigo considerará uma abordagem modular para o design e posterior implementação de um jogo no motor Unity. Os principais prós, contras e problemas que você teve que enfrentar são descritos.



O termo "abordagem modular" significa uma organização de software que usa conjuntos finais independentes e conectáveis ​​internamente que podem ser desenvolvidos em paralelo, alterados em tempo real e atingir diferentes comportamentos de software, dependendo da configuração.



Estrutura do Módulo



É importante primeiro determinar o que é o módulo, que estrutura ele tem, quais partes do sistema são responsáveis ​​por quais e como devem ser usados.



O módulo é uma montagem relativamente independente que não depende do projeto. Pode ser usado em projetos completamente diferentes com configuração adequada e a presença de um núcleo comum no projeto. A condição obrigatória para a implementação do módulo é a presença de um traço. partes:



Montagem de infraestrutura


Esta montagem contém modelos e contratos que podem ser usados ​​por outras montagens. É importante entender que esta parte do módulo não deve conter links para a implementação de recursos específicos. Idealmente, a estrutura pode fazer referência apenas ao núcleo do projeto.

A estrutura da montagem é semelhante à seguinte. caminho:



imagem


  • Entidades - entidades usadas dentro do módulo.
  • Mensagens - modelos de solicitação / sinal. Você pode ler sobre eles mais tarde.
  • Contratos é um lugar para armazenar interfaces.


É importante lembrar que é recomendável minimizar o uso de links entre conjuntos de infraestrutura.



Construir com recursos


Implementação específica do recurso. Ele pode usar dentro de si qualquer um dos padrões arquitetônicos, mas com a alteração de que o sistema deve ser modular.

A arquitetura interna pode ser assim:



imagem


  • Entidades - entidades usadas dentro do módulo.
  • Instaladores - Aulas de registro de contratos de DI.
  • Os serviços são a camada de negócios.
  • Gerenciadores - a tarefa do gerente é extrair os dados necessários dos serviços, criar um ViewEntity e retornar o ViewManager.
  • ViewManagers - recebe um ViewEntity do gerenciador, cria as visualizações necessárias e encaminha os dados necessários.
  • Exibir - exibe os dados que foram passados ​​do ViewManager.


Implementando uma abordagem modular



Para implementar esta abordagem, pelo menos dois mecanismos podem ser necessários. Precisamos de uma abordagem para dividir o código em assemblies e uma estrutura de DI. Este exemplo usa os arquivos de definição de montagem e mecanismos Zenject.



O uso dos mecanismos específicos acima é opcional. O principal é entender para que foram usados. Você pode substituir o Zenject por qualquer estrutura de DI com um contêiner IoC ou qualquer outra coisa, e Arquivos de Definições de Montagem - com qualquer outro sistema que permite combinar código em montagens ou simplesmente torná-lo independente (por exemplo, você pode usar diferentes repositórios para diferentes módulos que podem ser conectados como pekages, submódulos gita ou outra coisa).



Uma característica da abordagem modular é que não há referências explícitas da montagem de um recurso para outro, com exceção de referências a montagens de infraestrutura em que os modelos podem ser armazenados. A interação entre os módulos é implementada usando um wrapper sobre sinais do framework Zenject. O wrapper permite enviar sinais e solicitações para diferentes módulos. Deve-se notar que o sinal significa qualquer notificação pelo módulo atual de outros módulos, e a solicitação significa uma solicitação de outro módulo que pode retornar dados.



Sinais


Sinal - um mecanismo para notificar o sistema sobre algumas mudanças. E a maneira mais fácil de desmontá-los é na prática.



Digamos que temos 2 módulos. Foo e Foo2. O módulo Foo2 deve responder a alguma mudança no módulo Foo. Para se livrar da dependência dos módulos, 2 sinais são implementados. Um sinal dentro do módulo Foo, que informará o sistema sobre a mudança de estado, e o segundo sinal dentro do módulo Foo2. O módulo Foo2 reagirá a este sinal. O roteamento do sinal OnFooSignal em OnFoo2Signal será no módulo de roteamento.

Esquematicamente, terá a seguinte aparência:



imagem




Inquéritos


As consultas permitem resolver problemas de comunicação de recepção / transmissão de dados por um módulo de outro (outros).



Vamos considerar um exemplo semelhante dado acima para sinais.

Digamos que temos 2 módulos. Foo e Foo2. O módulo Foo precisa de alguns dados do módulo Foo2. Nesse caso, o módulo Foo não precisa saber nada sobre o módulo Foo2. Na verdade, esse problema poderia ser resolvido usando sinais adicionais, mas a solução com consultas parece mais simples e bonita.



Será semelhante ao seguinte esquematicamente:



imagem


Comunicação entre módulos



A fim de minimizar os links entre módulos com recursos (incluindo links Infraestrutura-Infraestrutura), decidiu-se escrever um wrapper sobre os sinais fornecidos pelo framework Zenject e criar um módulo cuja tarefa seria rotear diferentes sinais e dados de mapas.



PS Na verdade, este módulo possui links para todos os conjuntos de infraestrutura que não são bons. Mas esse problema pode ser resolvido por meio do IoC.



Exemplo de interação de módulo



Digamos que haja dois módulos. LoginModule e RewardModule. O RewardModule deve dar uma recompensa ao usuário após o login no FB.



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);
            });


No exemplo acima, não há links diretos entre os módulos. Mas eles estão vinculados por meio do MessagingModule. É muito importante lembrar que não deve haver nada no roteamento além de mapeamento e roteamento de sinal / solicitação.



Substituição de implementações



Usando uma abordagem modular e o padrão de alternância de recursos, você pode obter resultados surpreendentes em termos de impacto em seu aplicativo. Tendo uma determinada configuração no servidor, você pode manipular a habilitação / desabilitação de diferentes módulos no início da aplicação, alterando-os durante o jogo.



Isso é obtido pelo fato de que durante a vinculação de módulos no Zenject (na verdade, em um contêiner), os sinalizadores de disponibilidade do módulo são verificados e, com base nisso, o módulo é vinculado a um contêiner ou não. Para conseguir uma mudança de comportamento durante uma sessão de jogo (por exemplo, você precisa mudar a mecânica durante uma sessão de jogo. Há um módulo Solitaire e um módulo Klondike. E para 50 por cento dos usuários o módulo lenço deve funcionar), foi desenvolvido um mecanismo que, ao mudar de uma cena para outra limpou um contêiner de módulo específico e vinculou novas dependências.



Ele trabalhou na trilha. princípio: se o recurso foi habilitado e depois durante a sessão foi desabilitado, seria necessário esvaziar o container. Se o recurso foi ativado, você precisa fazer todas as alterações no contêiner. É importante fazer isso em um estágio "vazio" para não violar a integridade dos dados e das conexões. Foi possível implementar esse comportamento, mas como recurso de produção não é recomendado o uso dessa funcionalidade, pois acarreta maior risco de quebrar algo.



Abaixo está o pseudocódigo da classe base, cujos descendentes são obrigados a registrar algo no contêiner.



    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);
        }
    }


Um exemplo de um módulo primitivo



Vejamos um exemplo simples de como um módulo pode ser implementado.



Digamos que você precise implementar um módulo que restrinja o movimento da câmera para que o usuário não possa levá-la além da "borda" da tela.



O módulo conterá um conjunto de infraestrutura com um sinal que notificará o sistema de que a câmera tentou sair da tela.



Recurso - implementação de recurso. Essa será a lógica para verificar se a câmera está fora do alcance, notificar outros módulos sobre isso, etc.



imagem


  • BorderConfig é uma entidade que descreve os limites da tela.
  • BorderViewEntity é uma entidade a ser passada para ViewManager e View.
  • BoundingBoxManager - obtém BorderConfig do servidor e cria BorderViewEntity.
  • BoundingBoxViewManager — MonoBehaviour'a. , .
  • BoundingBoxView — , «» .




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



All Articles