Hoy voy a hablar sobre cómo algunos proyectos en Pixonic llegaron a lo que durante mucho tiempo ha sido la norma para todo el front-end global: enlaces reactivos.
La gran mayoría de nuestros proyectos están escritos en Unity 3D. Y, si otras tecnologías de cliente con reactivo funcionan bien (MVVM, Qt, millones de marcos JS), y se da por sentado, Unity no tiene ningún enlace integrado o generalmente aceptado.
En ese momento, probablemente alguien tenía una pregunta: “¿Por qué? No usamos eso y vivimos bien ".
Había razones. Más precisamente, había problemas, una de cuyas soluciones podría ser el uso de ese enfoque. Como resultado, se convirtió en uno. Y los detalles están debajo del corte.
Primero, sobre el proyecto, cuyos problemas requerían tal solución. Por supuesto, estamos hablando de War Robots, un proyecto gigantesco con muchos equipos diferentes de desarrollo, soporte, marketing, etc. Ahora solo nos interesan dos de ellos: el equipo de programadores cliente y el equipo de la interfaz de usuario. En lo que sigue, para simplificar, nos referiremos a ellos como "código" y "diseño". Dio la casualidad de que algunas personas se dedican al diseño y la distribución de la interfaz de usuario, mientras que otras hacen la "revitalización" de todo esto. Esto es lógico y, en mi experiencia, me he encontrado con muchos ejemplos similares de organización en equipo.
Notamos que con el flujo creciente de funciones en el proyecto, la interacción del código y el diseño se convierte en un lugar de puntos muertos y un cuello de botella. Los programadores están esperando widgets listos para trabajar, diseñadores de diseño, algunas modificaciones del código. Sí, sucedieron muchas cosas durante esta interacción. En resumen, a veces se convirtió en caos y postergación.
Déjame explicarte ahora. Eche un vistazo al ejemplo clásico de widget simple, especialmente el método RefreshData. El resto del texto estándar lo acabo de agregar por credibilidad, y no merece una atención especial.
public class PlayerProfileWidget : WidgetBehaviour
{
[SerializeField] private Text nickname;
[SerializeField] private Image avatar;
[SerializeField] private Text level;
[SerializeField] private GameObject hasUpgradeMark;
[SerializeField] private Button upgradeButton;
public void Initialize(ProfileService profileService)
{
RefreshData(profileService.Player);
upgradeButton.onClick
.Subscribe(profileService.UpgradePlayer)
.DisposeWith(Lifetime);
profileService.PlayerUpgraded
.Subscribe(RefreshData)
.DisposeWith(Lifetime);
}
private void RefreshData(in PlayerModel player)
{
nickname.text = player.Id;
avatar.overrideSprite = Resources.Load<Sprite>($"Avatars/{player.Avatar}_Small");
level.text = player.Level.ToString();
hasUpgradeMark.SetActive(player.HasUpgrade);
}
}
Este es un ejemplo de enlace estático de arriba hacia abajo. En el componente del GameObject superior (en la jerarquía), vincula componentes de los tipos correspondientes de objetos inferiores. Todo aquí es extremadamente simple, pero no muy flexible.
La funcionalidad de los widgets se expande constantemente con la llegada de nuevas funciones. Imaginemos. Ahora debería haber un borde alrededor del avatar, cuya apariencia depende del nivel del jugador. Bien, agreguemos un enlace a la Imagen del marco y sumerjamos el sprite correspondiente al nivel allí, luego agreguemos la configuración para hacer coincidir el nivel y el marco y entregámoslo todo al diseño. Hecho.
Ha pasado un mes. Ahora aparece un icono de clan en el widget del jugador, si es miembro. Y también necesitas registrar el título que tiene allí. Y el apodo debe pintarse de verde si hay una actualización. Además, ahora estamos usando TextMeshPro. Y también ...
Bueno, ya captas la idea. El código se vuelve cada vez más, se vuelve cada vez más complicado, cubierto de varias condiciones.
Hay varias opciones para trabajar aquí. Por ejemplo, el programador modifica el código del widget, da los cambios al diseño. Agregan y vinculan componentes a nuevos campos. O viceversa: el diseño puede llegar con tiempo de antelación, el propio programador vinculará todo lo que se necesite. Por lo general, hay varias iteraciones más de correcciones. En cualquier caso, este proceso no es paralelo. Ambos colaboradores están trabajando en el mismo recurso. Y fusionar prefabricados o escenas sigue siendo un placer.
Para los ingenieros, todo es simple: si ves un problema, intentas solucionarlo. Así que lo intentamos. Como resultado, llegamos a la idea de que era necesario estrechar el frente de contacto entre los dos equipos. Y los patrones reactivos reducen este frente a un punto, lo que comúnmente se llama modelo de vista. Para nosotros, actúa como un contrato entre el código y el diseño. Cuando llego a los detalles, el significado del contrato queda claro y por qué no bloquea el funcionamiento paralelo de dos equipos.
En el momento en que pensamos en todo esto, existían varias soluciones de terceros. Estábamos mirando hacia Unity Weld, Peppermint Data Binding, DisplayFab. Todos tenían sus pros y sus contras. Pero uno de los defectos fatales para nosotros era común: un desempeño deficiente para nuestros propósitos. Puede que funcionen bien en interfaces simples, pero en ese momento no pudimos evitar la complejidad de las interfaces.
Dado que la tarea no parecía prohibitivamente difícil, e incluso se disponía de la experiencia relevante, se decidió implementar un sistema reactivo de encuadernación dentro del estudio.
Las tareas fueron las siguientes:
- Actuación. El propio mecanismo de propagación de cambios debe ser rápido. También es deseable reducir la carga en el GC para que puedas usar todo esto incluso en el juego, donde los congelamientos no son nada felices.
- Creación conveniente. Esto es necesario para que los chicos del equipo de UI puedan trabajar con el sistema.
- API conveniente.
- Extensibilidad.
De arriba a abajo, o descripción general
La tarea está clara, los objetivos están claros. Comencemos con el "contrato": ViewModel. Cualquier persona debería poder formarlo, lo que significa que la implementación del ViewModel debería ser lo más simple posible. Básicamente, es solo un conjunto de propiedades que determinan el estado de visualización actual.
Para simplificar, hemos limitado el conjunto de tipos de propiedad con valores a bool, int, float y string tanto como sea posible. Esto fue dictado por varias consideraciones a la vez:
- Serializar estos tipos en Unity no requiere esfuerzo;
- , -, . , Sprite -, PlayerModel , ;
- , .
Todas las propiedades están activas e informan a los suscriptores sobre cambios en sus valores. Estos valores no siempre están presentes, solo hay eventos en la lógica empresarial que deben visualizarse de alguna manera. En este caso, hay un tipo de propiedad sin valor: evento.
Por supuesto, tampoco puede prescindir de colecciones en interfaces. Por lo tanto, también existe un tipo de propiedad de colección. La colección notifica a los suscriptores de cualquier cambio en su composición. Los elementos de la colección también son ViewModels de una determinada estructura o esquema. Este esquema también se describe en el contrato al editar.
El ViewModel se ve así en el editor:
Cabe señalar que las propiedades se pueden editar directamente en el inspector y sobre la marcha. Esto le permite ver cómo se comportará el widget (o ventana, o escena, o lo que sea) en tiempo de ejecución incluso sin código, lo cual es muy conveniente en la práctica.
Si ViewModel es la parte superior de nuestro sistema de encuadernación, la parte inferior son los llamados aplicadores. Estos son los suscriptores finales de las propiedades de ViewModel que hacen todo el trabajo:
- Habilite / deshabilite GameObject o componentes individuales cambiando el valor de la propiedad booleana;
- Cambie el texto en el campo según el valor de la propiedad de la cadena;
- Se lanza el animador, se cambian sus parámetros;
- Sustituye el objeto deseado de la colección por índice o clave de cadena.
Me detendré en esto, ya que el número de aplicaciones está limitado solo por la imaginación y el abanico de tareas que resuelves.
Así es como se ven algunos de los aplicadores en el editor:
para mayor flexibilidad, se pueden utilizar adaptadores entre propiedades y aplicadores. Son entidades para transformar propiedades antes de ser aplicadas. También hay muchos diferentes:
- Booleano : por ejemplo, cuando necesita invertir una propiedad booleana o devolver verdadero o falso dependiendo de un valor de otro tipo (quiero un borde dorado cuando el nivel está por encima de 15).
- Aritmética . Sin comentarios aquí.
- Operaciones en colecciones : invertir, tomar solo una parte de la colección, ordenar por clave y mucho más.
Nuevamente, puede haber una gran variedad de opciones de adaptadores diferentes, por lo que no continuaré.
De hecho, aunque el número total de aplicadores y adaptadores diferentes es grande, el conjunto básico utilizado en todas partes es muy limitado. Una persona que trabaja con contenido debe estudiar este conjunto primero, lo que aumenta ligeramente el tiempo de formación. Sin embargo, debe dedicar tiempo a esto una vez, para que además no haya grandes problemas aquí. Además, contamos con un recetario y documentación al respecto.
Cuando el diseño carece de algo, los programadores agregan los componentes necesarios. Al mismo tiempo, la inmensa mayoría de aplicadores y adaptadores son universales y se reutilizan activamente. Por separado, cabe señalar que todavía tenemos aplicadores que trabajan en la reflexión a través de UnityEvent. Son aplicables en los casos en que el aplicador requerido aún no se ha implementado o su implementación no es práctica.
Esto sin duda se suma al trabajo del equipo de diseño. Pero en nuestro caso, incluso están contentos con el grado de libertad e independencia que obtienen de los programadores. Y si el trabajo ha aumentado desde el lado del diseño, desde el lado del código ahora todo es mucho más fácil.
Volvamos al ejemplo de PlayerProfileWidget. Así es como se ve ahora en nuestro hipotético proyecto en forma de presentador, porque ya no necesitamos un Widget en forma de componente, y podemos obtener todo del ViewModel en lugar de vincular todo directamente:
public class PlayerProfilePresenter : Presenter
{
private readonly IMutableProperty<string> _playerId;
private readonly IMutableProperty<string> _playerAvatar;
private readonly IMutableProperty<int> _playerLevel;
private readonly IMutableProperty<bool> _playerHasUpgrade;
public PlayerProfilePresenter(ProfileService profileService, IViewModel viewModel)
{
_playerId = viewModel.GetString("player/id");
_playerAvatar = viewModel.GetString("player/avatar");
_playerLevel = viewModel.GetInteger("player/level");
_playerHasUpgrade = viewModel.GetBoolean("player/has-upgrade");
RefreshData(profileService.Player);
viewModel.GetEvent("player/upgrade")
.Subscribe(profileService.UpgradePlayer)
.DisposeWith(Lifetime);
profileService.PlayerUpgraded
.Subscribe(RefreshData)
.DisposeWith(Lifetime);
}
private void RefreshData(in PlayerModel player)
{
_playerId.Value = player.Id;
_playerAvatar.Value = player.Avatar;
_playerLevel.Value = player.Level;
_playerHasUpgrade.Value = player.HasUpgrade;
}
}
En el constructor, puede ver el código obteniendo propiedades de ViewModel. Sí, en este código, las comprobaciones se omiten por simplicidad, pero hay métodos que generarán una excepción si no encuentran la propiedad deseada. Además, tenemos varias herramientas que brindan una garantía bastante sólida de que los campos requeridos están presentes. Se basan en la validación de activos, sobre la que puede leer aquí .
No entraré en detalles de implementación, ya que tomará mucho texto y su tiempo. Si hay una investigación pública, sería mejor publicarla en un artículo separado. Solo diré que la implementación no es muy diferente del mismo Rx, solo que todo es un poco más simple.
La tabla muestra los resultados de una evaluación comparativa que crea 500 formularios con InputField, Text y Button asociados con un modelo de propiedad y una función de acción.
Como conclusión, puedo informar que se han logrado los objetivos anteriores. Los puntos de referencia comparativos muestran ganancias tanto en memoria como en tiempo en relación con las opciones mencionadas. A medida que el equipo de diseño y las personas de otros departamentos que se ocupan del contenido se vuelven más familiares, la fricción y el bloqueo se vuelven cada vez menos. La eficiencia y la calidad del código han aumentado y ahora muchas cosas no requieren la intervención del programador.