Ni un solo monolito. Enfoque modular en Unity

imagen


Este artículo considerará un enfoque modular para el diseño y la implementación adicional de un juego en el motor Unity. Se describen los principales pros, contras y problemas a los que tuvo que enfrentarse.



El término "enfoque modular" significa una organización de software que utiliza ensamblajes finales independientes, conectables internamente que se pueden desarrollar en paralelo, cambiar sobre la marcha y lograr un comportamiento de software diferente según la configuración.



Estructura del módulo



Es importante determinar primero qué es el módulo, qué estructura tiene, qué partes del sistema son responsables de qué y cómo deben usarse.



El módulo es un conjunto relativamente independiente que no depende del proyecto. Se puede utilizar en proyectos completamente diferentes con la configuración adecuada y la presencia de un núcleo común en el proyecto. Las condiciones obligatorias para la implementación del módulo es la presencia de un rastro. partes:



Montaje de infraestructura


Este conjunto contiene modelos y contratos que pueden utilizar otros conjuntos. Es importante comprender que esta parte del módulo no debe tener enlaces a la implementación de características específicas. Idealmente, el marco solo puede hacer referencia al núcleo del proyecto.

La estructura de montaje se parece a la siguiente. camino:



imagen


  • Entidades: entidades utilizadas dentro del módulo.
  • Mensajería: modelos de solicitud / señal. Puedes leer sobre ellos más tarde.
  • Contracts es un lugar para almacenar interfaces.


Es importante recordar que se recomienda minimizar el uso de enlaces entre ensamblajes de infraestructura.



Construye con características


Implementación específica de la función. Puede utilizar en su interior cualquiera de los patrones arquitectónicos, pero con la enmienda de que el sistema debe ser modular.

La arquitectura interna puede verse así:



imagen


  • Entidades: entidades utilizadas dentro del módulo.
  • Instaladores - Clases de registro de contratos para DI.
  • Los servicios son la capa empresarial.
  • Administradores: la tarea del administrador es extraer los datos necesarios de los servicios, crear una ViewEntity y devolver el ViewManager.
  • ViewManagers: recibe una ViewEntity del administrador, crea las vistas requeridas y reenvía los datos requeridos.
  • Ver: muestra los datos que se pasaron desde ViewManager.


Implementar un enfoque modular



Para implementar este enfoque, se pueden requerir al menos dos mecanismos. Necesitamos un enfoque para dividir el código en ensamblados y un marco DI. Este ejemplo utiliza los archivos de definiciones de ensamblaje y los mecanismos de Zenject.



El uso de los mecanismos específicos anteriores es opcional. Lo principal es comprender para qué se utilizaron. Puede reemplazar Zenject con cualquier marco DI con un contenedor de IoC u otra cosa, y archivos de definiciones de ensamblaje, con cualquier otro sistema que le permita combinar código en ensamblajes o simplemente hacerlo independiente (por ejemplo, puede usar diferentes repositorios para diferentes módulos que se pueden conectar como paquetes, submódulos gita o algo más).



Una característica del enfoque modular es que no hay referencias explícitas del ensamblaje de una característica a otra, con la excepción de las referencias a ensamblajes de infraestructura en los que se pueden almacenar modelos. La interacción entre módulos se implementa mediante un contenedor sobre señales del marco de Zenject. El contenedor le permite enviar señales y solicitudes a diferentes módulos. Vale la pena señalar que una señal significa cualquier notificación por parte del módulo actual de otros módulos, y una solicitud significa una solicitud de otro módulo que puede devolver datos.



Señales


Señal: un mecanismo para notificar al sistema sobre algunos cambios. Y la forma más sencilla de desmontarlos es en la práctica.



Digamos que tenemos 2 módulos. Foo y Foo2. El módulo Foo2 debería responder a algún cambio en el módulo Foo. Para deshacerse de la dependencia de los módulos, se implementan 2 señales. Una señal dentro del módulo Foo, que informará al sistema sobre el cambio de estado, y la segunda señal, dentro del módulo Foo2. El módulo Foo2 reaccionará a esta señal. El enrutamiento de la señal OnFooSignal en OnFoo2Signal estará en el módulo de enrutamiento.

Esquemáticamente se verá así:



imagen




Consultas


Las consultas permiten resolver problemas de comunicación de la recepción / transmisión de datos de un módulo a otro (otros).



Consideremos un ejemplo similar que se proporcionó anteriormente para las señales.

Digamos que tenemos 2 módulos. Foo y Foo2. El módulo Foo necesita algunos datos del módulo Foo2. Al mismo tiempo, el módulo Foo no debería saber nada sobre el módulo Foo2. De hecho, este problema podría resolverse utilizando señales adicionales, pero la solución con consultas parece más simple y hermosa.



Se verá así esquemáticamente:



imagen


Comunicación entre módulos



Con el fin de minimizar los enlaces entre módulos con características (incluidos los enlaces Infraestructura-Infraestructura), se decidió escribir un contenedor sobre las señales proporcionadas por el marco Zenject y crear un módulo cuya tarea sería enrutar diferentes señales y datos de mapas.



PD De hecho, este módulo tiene enlaces a todos los ensamblajes de infraestructura que no son buenos. Pero este problema se puede resolver a través del IoC.



Ejemplo de interacción del módulo



Digamos que hay dos módulos. LoginModule y RewardModule. RewardModule debe otorgar una recompensa al usuario después del inicio de sesión de 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);
            });


En el ejemplo anterior, no hay vínculos directos entre módulos. Pero están vinculados a través del MessagingModule. Es muy importante recordar que no debe haber nada en el enrutamiento más que el enrutamiento y el mapeo de señal / solicitud.



Sustitución de implementaciones



Con un enfoque modular y el patrón de alternancia de funciones, puede lograr resultados sorprendentes en términos de impacto en su aplicación. Teniendo una determinada configuración en el servidor, puedes manipular la habilitación / deshabilitación de diferentes módulos al inicio de la aplicación, cambiándolos durante el juego.



Esto se logra verificando los indicadores de disponibilidad del módulo durante la vinculación de módulos en Zenject (de hecho, en un contenedor) y, en base a esto, el módulo se vincula a un contenedor o no. Para lograr un cambio en el comportamiento durante una sesión de juego (digamos que necesitas cambiar la mecánica durante una sesión de juego. Hay un módulo de Solitario y un módulo de Klondike. Y para el 50 por ciento de los usuarios el módulo de pañuelo debería funcionar), se desarrolló un mecanismo que, al cambiar de una escena a otra limpió un contenedor de módulo específico y vinculó nuevas dependencias.



Trabajó en el camino. principio: si la función estaba habilitada, y luego durante la sesión se deshabilitaba, sería necesario vaciar el contenedor. Si la función estaba habilitada, se deben realizar todos los cambios en el contenedor. Es importante hacer esto en un escenario "vacío" para no violar la integridad de los datos y las conexiones. Se pudo implementar este comportamiento, pero como característica de producción no se recomienda utilizar dicha funcionalidad, ya que conlleva un mayor riesgo de romper algo.



A continuación se muestra el pseudocódigo de la clase base, cuyos descendientes deben registrar algo en el contenedor.



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


Un ejemplo de módulo primitivo



Veamos un ejemplo simple de cómo se puede implementar un módulo.



Digamos que necesita implementar un módulo que restrinja el movimiento de la cámara para que el usuario no pueda llevarla más allá del "borde" de la pantalla.



El módulo contendrá un conjunto de infraestructura con una señal que notificará al sistema que la cámara ha intentado salir de la pantalla.



Característica: implementación de características. Esta será la lógica para comprobar si la cámara está fuera de alcance, notificar a otros módulos al respecto, etc.



imagen


  • BorderConfig es una entidad que describe los límites de la pantalla.
  • BorderViewEntity es una entidad que se pasará a ViewManager y View.
  • BoundingBoxManager: obtiene BorderConfig del servidor, crea BorderViewEntity.
  • BoundingBoxViewManager — MonoBehaviour'a. , .
  • BoundingBoxView — , «» .




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



All Articles