Aujourd'hui, je vais parler de la façon dont certains projets de Pixonic sont parvenus à ce qui a longtemps été la norme pour l'ensemble du front-end mondial - la liaison réactive.
La grande majorité de nos projets sont écrits en Unity 3D. Et, si d'autres technologies clientes avec réactif fonctionnent bien (MVVM, Qt, des millions de frameworks JS), et que cela est pris pour acquis, Unity n'a pas de liaisons intégrées ou généralement acceptées.
Quelqu'un à cette époque avait probablement une question: «Pourquoi? Nous n'utilisons pas cela et nous vivons bien. "
Il y avait des raisons. Plus précisément, il y a des problèmes, dont l'une des solutions pourrait être l'utilisation d'une telle approche. En conséquence, il est devenu un. Et les détails sont sous la coupe.
Premièrement, à propos du projet, dont les problèmes nécessitaient une telle solution. Bien sûr, nous parlons de War Robots - un projet géant avec de nombreuses équipes de développement, de support, de marketing, etc. Nous ne nous intéressons désormais qu'à deux d'entre eux: l'équipe de programmeurs clients et l'équipe de l'interface utilisateur. Dans ce qui suit, pour simplifier, nous les appellerons «code» et «mise en page». Il se trouve que certaines personnes sont engagées dans la conception et la mise en page de l'interface utilisateur, tandis que d'autres font la «revitalisation» de tout cela. C'est logique, et d'après mon expérience, j'ai rencontré de nombreux exemples similaires d'organisation d'équipe.
Nous avons remarqué qu'avec le flux croissant de fonctionnalités sur le projet, l'interaction entre le code et la mise en page devient un lieu de blocage et de goulot d'étranglement. Les programmeurs attendent des widgets prêts à l'emploi pour le travail, les concepteurs de mise en page - pour certaines modifications du code. Oui, beaucoup de choses se sont produites pendant cette interaction. En bref, cela s'est parfois transformé en chaos et en procrastination.
Laissez-moi vous expliquer maintenant. Jetez un œil à l'exemple de widget simple classique - en particulier la méthode RefreshData. Le reste du passe-partout que je viens d'ajouter pour la crédibilité, et cela ne mérite pas une attention particulière.
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);
}
}
Ceci est un exemple de liaison descendante statique. Dans le composant du GameObject supérieur (dans la hiérarchie), vous liez les composants des types correspondants des objets inférieurs. Tout ici est extrêmement simple, mais pas très flexible.
La fonctionnalité des widgets est en constante expansion avec l'avènement de nouvelles fonctionnalités. Imaginons. Il devrait maintenant y avoir une bordure autour de l'avatar, dont l'apparence dépend du niveau du joueur. D'accord, ajoutons un lien vers l'image du cadre et plongeons le sprite correspondant au niveau là-bas, puis ajoutons le paramètre pour faire correspondre le niveau et le cadre et donnons le tout à la mise en page. Terminé.
Un mois s'est écoulé. Maintenant, une icône de clan apparaît dans le widget du joueur, s'il est membre. Et vous devez également enregistrer le titre qu'il y a. Et le surnom doit être peint en vert s'il y a une mise à niveau. De plus, nous utilisons maintenant TextMeshPro. Et aussi ...
Eh bien, vous voyez l'idée. Le code devient de plus en plus, il devient de plus en plus compliqué, envahi par diverses conditions.
Il existe plusieurs options pour travailler ici. Par exemple, le programmeur modifie le code du widget, donne les changements à la mise en page. Ils ajoutent et lient des composants à de nouveaux champs. Ou vice versa: le layout peut arriver à temps à l'avance, le programmeur lui-même reliera tout ce qui est nécessaire. Habituellement, il existe plusieurs autres itérations de correctifs. Dans tous les cas, ce processus n'est pas parallèle. Les deux contributeurs travaillent sur la même ressource. Et fusionner des préfabriqués ou des scènes est toujours un plaisir.
Pour les ingénieurs, tout est simple: si vous voyez un problème, vous essayez de le résoudre. Alors nous avons essayé. Du coup, nous en sommes venus à l'idée qu'il fallait resserrer le front de contact entre les deux équipes. Et les modèles réactifs réduisent ce front à un point - ce que l'on appelle communément le modèle de vue. Pour nous, il agit comme un contrat entre le code et la mise en page. Quand j'arriverai aux détails, le sens du contrat deviendra clair, et pourquoi il ne bloque pas le fonctionnement parallèle de deux équipes.
Au moment où nous venons de penser à tout cela, il y avait plusieurs solutions tierces. Nous recherchions Unity Weld, Peppermint Data Binding, DisplayFab. Ils avaient tous leurs avantages et leurs inconvénients. Mais l'un des défauts fatals pour nous était commun: de mauvaises performances pour nos objectifs. Ils peuvent fonctionner correctement sur des interfaces simples, mais à ce moment-là, nous ne pouvions pas éviter la complexité des interfaces.
Étant donné que la tâche ne semblait pas extrêmement difficile et que même l'expérience pertinente était disponible, il a été décidé de mettre en œuvre un système de reliure réactive à l'intérieur du studio.
Les tâches étaient les suivantes:
- Performance. Le mécanisme de propagation des changements lui-même doit être rapide. Il est également souhaitable de réduire la charge sur le GC afin que vous puissiez utiliser tout cela même dans un gameplay où les gels ne sont pas du tout heureux.
- Création pratique. Cela est nécessaire pour que les membres de l'équipe de l'interface utilisateur puissent travailler avec le système.
- API pratique.
- Extensibilité.
De haut en bas ou description générale
La tâche est claire, les objectifs sont clairs. Commençons par le "contrat" - le ViewModel. N'importe qui doit pouvoir le former, ce qui signifie que le ViewModel doit être implémenté aussi simplement que possible. Il s'agit essentiellement d'un ensemble de propriétés qui déterminent l'état d'affichage actuel.
Pour plus de simplicité, nous avons limité autant que possible l'ensemble des types de propriétés avec des valeurs bool, int, float et string. Cela a été dicté par plusieurs considérations à la fois:
- La sérialisation de ces types dans Unity est sans effort;
- , -, . , Sprite -, PlayerModel , ;
- , .
Toutes les propriétés sont actives et informent les abonnés des modifications apportées à leurs valeurs. Ces valeurs ne sont pas toujours présentes - il n'y a que des événements dans la logique métier qui doivent être visualisés d'une manière ou d'une autre. Dans ce cas, il existe un type de propriété sans valeur - événement.
Bien sûr, vous ne pouvez pas non plus vous passer de collections dans les interfaces. Par conséquent, il existe également un type de propriété de collection. La collection informe les abonnés de tout changement dans sa composition. Les éléments de collection sont également des ViewModels d'une certaine structure ou d'un certain schéma. Ce schéma est également décrit dans le contrat lors de l'édition.
Dans l'éditeur ViewModel ressemble à ceci:
Il est à noter que les propriétés peuvent être modifiées directement dans l'inspecteur et à la volée. Cela vous permet de voir comment le widget (ou la fenêtre, ou la scène, ou autre) se comportera à l'exécution même sans code, ce qui est très pratique en pratique.
Si le ViewModel est le haut de notre système de reliure, le bas est ce qu'on appelle les applicateurs. Voici les abonnés finaux des propriétés ViewModel qui font tout le travail:
- Activez / désactivez GameObject ou des composants individuels en modifiant la valeur de la propriété booléenne;
- Modifiez le texte dans le champ en fonction de la valeur de la propriété de chaîne;
- L'animateur est lancé, ses paramètres sont modifiés;
- Remplacez le sprite souhaité de la collection par un index ou une clé de chaîne.
Je vais m'arrêter là, car le nombre d'applications n'est limité que par l'imagination et la gamme de tâches que vous résolvez.
Voici à quoi ressemblent certains applicateurs dans l'éditeur:
Pour plus de flexibilité, des adaptateurs peuvent être utilisés entre les propriétés et les applicateurs. Ce sont des entités pour transformer les propriétés avant d'être appliquées. Il en existe également de nombreux:
- Boolean - par exemple, lorsque vous devez inverser une propriété booléenne ou émettre true ou false en fonction d'une valeur d'un type différent (je veux une bordure dorée lorsque le niveau est supérieur à 15).
- Arithmétique . Aucun commentaire ici.
- Opérations sur les collections : inverser, ne prendre qu'une partie d'une collection, trier par clé, et bien plus encore.
Encore une fois, il peut y avoir une grande variété d'options d'adaptateur différentes, donc je ne vais pas continuer.
En fait, bien que le nombre total d'applicateurs et d'adaptateurs différents soit important, l'ensemble de base utilisé partout est très limité. Une personne travaillant avec du contenu doit d'abord étudier cet ensemble, ce qui augmente légèrement le temps de formation. Cependant, vous devez consacrer du temps à cela une fois, de sorte qu'il n'y ait plus de gros problèmes ici. De plus, nous avons un livre de recettes et une documentation à ce sujet.
Lorsque la mise en page manque quelque chose, les programmeurs ajoutent les composants nécessaires. Dans le même temps, la grande majorité des applicateurs et adaptateurs sont universels et sont activement réutilisés. Par ailleurs, il convient de noter que nous avons encore des applicateurs qui travaillent sur la réflexion via UnityEvent. Ils sont applicables dans les cas où l'applicateur requis n'a pas encore été mis en œuvre ou où sa mise en œuvre n'est pas pratique.
Cela ajoute certainement au travail de l'équipe de mise en page. Mais dans notre cas, ils sont même satisfaits du degré de liberté et d'indépendance qu'ils obtiennent par rapport aux programmeurs. Et si le travail a augmenté du côté de la mise en page, du côté du code, tout est maintenant beaucoup plus facile.
Revenons à l'exemple PlayerProfileWidget. Voici à quoi cela ressemble maintenant dans notre projet hypothétique sous la forme d'un présentateur, car nous n'avons plus besoin d'un Widget sous la forme d'un composant, et nous pouvons tout obtenir du ViewModel au lieu de tout lier directement:
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;
}
}
Dans le constructeur, vous pouvez voir le code obtenir des propriétés à partir de ViewModel. Oui, dans ce code, les vérifications sont omises pour des raisons de simplicité, mais il existe des méthodes qui lèveront une exception si elles ne trouvent pas la propriété souhaitée. De plus, nous avons plusieurs outils qui fournissent une garantie assez solide que les champs requis sont présents. Ils sont basés sur la validation des actifs, que vous pouvez lire ici .
Je n'entrerai pas dans les détails de la mise en œuvre, car cela prendra beaucoup de texte et votre temps. S'il y a une enquête publique, il vaudrait mieux la publier dans un article séparé. Je dirai seulement que la mise en œuvre n'est pas très différente du même Rx, seul tout est un peu plus simple.
Le tableau présente les résultats d'un benchmark qui crée 500 formulaires avec InputField, Text et Button, associés à une propriété du modèle et une fonction d'action.
En conclusion, je peux signaler que les objectifs ci-dessus ont été atteints. Des benchmarks comparatifs montrent des gains à la fois en mémoire et en temps par rapport aux options mentionnées. Au fur et à mesure que l'équipe de mise en page et les personnes des autres services qui traitent le contenu deviennent plus familières, les frictions et les blocages deviennent de moins en moins nombreux. L'efficacité et la qualité du code ont augmenté, et maintenant beaucoup de choses ne nécessitent pas l'intervention du programmeur.