paint-brush
StatefulUI : une bibliothèque d'interface utilisateur Unity basée sur les états et le balisagepar@dmitrii
2,943 lectures
2,943 lectures

StatefulUI : une bibliothèque d'interface utilisateur Unity basée sur les états et le balisage

par Dmitrii Ivashchenko12m2023/05/17
Read on Terminal Reader

Trop long; Pour lire

Dmitrii Ivashchenko est ingénieur logiciel chez MY.GAMES. Dans cet article, nous parlerons du développement d'une interface utilisateur dans Unity basée sur les états et le balisage des éléments. L'approche décrite n'est pas applicable à [UI Toolkit] ou à d'autres systèmes de création d'interface utilisateur.
featured image - StatefulUI : une bibliothèque d'interface utilisateur Unity basée sur les états et le balisage
Dmitrii Ivashchenko HackerNoon profile picture
0-item
1-item

Bonjour à tous, je m'appelle Dmitrii Ivashchenko et je suis ingénieur logiciel chez MY.GAMES. Dans cet article, nous parlerons du développement d'une interface utilisateur dans Unity basée sur les états et le balisage des éléments.

Introduction

Tout d'abord, il convient de noter que nous parlerons dans le cadre de la technologie Unity UI (uGUI), qui est toujours recommandée pour le Runtime selon la documentation . L'approche décrite ne s'applique pas à UI Toolkit , IMGUI ou à d'autres systèmes de création d'interface utilisateur.



Le plus souvent, dans les projets Unity , vous rencontrerez une implémentation d'interface utilisateur basée sur des classes View héritées de MonoBehaviour et parsemée d'un grand nombre de champs SerializeField . Cette approche offre un contrôle total sur le comportement de l'interface utilisateur, mais elle nécessite également l'écriture d'une grande quantité de code au niveau de la vue et du présentateur (selon l'architecture utilisée).


Souvent, au fur et à mesure que le développement du projet se poursuit, ces classes atteignent des tailles incroyables et les composants de GameObject eux-mêmes sont couverts d'un grand nombre de liens vers des objets internes :



Modifier des composants comme celui-ci n'est pas agréable non plus : pour obtenir une référence à un nouvel élément dans une classe, vous devez ajouter SerializeField , recompiler le code, trouver le nouveau champ dans le composant préfabriqué et y faire glisser l'objet nécessaire. Au fur et à mesure que le projet grandit, le temps de compilation, le nombre de champs et la complexité de l'organisation des préfabriqués augmentent également à leur tour.


En conséquence, nous nous retrouvons avec des sous-classes volumineuses et surchargées de MonoBehaviour (ou un grand nombre de petites, selon votre préférence).


Il convient également de considérer que toute modification du comportement d'une telle interface utilisateur est une tâche pour le programmeur, et que cette tâche s'accompagne de tous les coûts associés : révision du code, résolution des conflits de fusion, couverture du code avec des tests, etc.


Je voudrais souligner la mise en œuvre de fenêtres à plusieurs états. J'ai vu de nombreuses variantes, qui peuvent être divisées en deux approches:


  1. Tout d'abord, toute modification de l'état de la fenêtre se produit à l'aide de code . Pour changer la couleur du texte, changer une image, jouer une animation, déplacer un objet sur l'écran - tous les objets et paramètres impliqués nécessitent un SerializeField correspondant, puis une grande quantité de code est écrite pour le faire fonctionner selon les exigences . Naturellement, seul un programmeur peut gérer cela, et l'implémentation s'avère longue, coûteuse et super efficace (souvent beaucoup plus efficace que quiconque ne peut le remarquer).
  2. Une autre approche peut être décrite comme « l' Animateur tout-puissant ». En plus de la classe View, un Animator Controller est créé et contrôlé via des paramètres. Un nouvel animateur apparaît dans la nouvelle fenêtre, et ainsi de suite, jusqu'à ce que le FPS lors de l'affichage des fenêtres commence à baisser.



Maintenant que nous avons mis en évidence certaines des difficultés de travailler avec uGUI, je voudrais parler d'une approche différente pour résoudre ce problème.

Interface utilisateur avec état

Au cours de mon travail sur l'un de mes projets favoris, j'ai développé une bibliothèque pour le développement structuré de l'interface utilisateur dans Unity. Plus tard, mon équipe et moi l'avons testé en production et nous avons été satisfaits des résultats.


Le code source de la bibliothèque est disponible en téléchargement sur GitHub .

Composant avec état

L'élément clé de la bibliothèque est le composant StatefulComponent . Ce composant est placé sur le GameObject racine de chaque écran et contient toutes les références nécessaires aux éléments internes, répartis sur les onglets :



Chaque lien est nommé en fonction de son rôle . Du point de vue du code, l'ensemble des rôles est un enum régulier. Des ensembles de rôles distincts sont préparés pour chaque type d'élément d'interface utilisateur (boutons, images, textes, etc.) :


 public enum ButtonRole { ... } public enum ImageRole { ... } public enum TextRole { ... } ...


Les rôles sont générés directement à partir du composant et il n'est pas nécessaire de modifier manuellement l' enum . Il n'est pas non plus nécessaire d'attendre la recompilation lors de la création d'un rôle, car ces éléments enum peuvent être utilisés immédiatement après la création.


Pour simplifier les conflits de fusion, les valeurs d'énumération sont calculées en fonction des noms des éléments :


 [StatefulUI.Runtime.RoleAttributes.ButtonRoleAttribute] public enum ButtonRole { Unknown = 0, Start = -1436209294, // -1436209294 == "Start".GetHashCode() Settings = 681682073, Close = -1261564850, Quests = 1375430261, }


Cela vous permet d'éviter de casser les valeurs sérialisées dans les préfabriqués si vous et vos collègues créez simultanément de nouveaux rôles pour les boutons dans différentes branches.


Chaque type d'élément de l'UI (boutons, textes, images) est situé sur son propre onglet :



En utilisant des rôles, le balisage complet de tous les éléments à l'intérieur du préfabriqué est réalisé. Les ensembles de SerializeField ne sont plus nécessaires pour accéder aux images et aux textes, et il suffit d'avoir une référence à StatefulComponent et de connaître le rôle de l'image souhaitée pour, par exemple, remplacer son sprite.


Les types d'éléments actuellement accessibles sont :


  • Boutons, images, bascule, curseurs, listes déroulantes, lecteurs vidéo, animateurs
  • Textes, y compris UnityEngine.UI.Text et TextMeshProUGUI
  • TextInputs, y compris UnityEngine.UI.InputField et TMP_InputField
  • Objets — pour les références à des objets arbitraires.


Il existe des méthodes correspondantes pour travailler avec des objets annotés. Dans le code, vous pouvez utiliser une référence à StatefulComponent ou hériter de la classe de StatefulView :


 public class ExamplePresenter { private StatefulComponent _view; public void OnOpen() { _view.GetButton(ButtonRole.Settings).onClick.AddListener(OnSettingsClicked); _view.GetButton(ButtonRole.Close).onClick.AddListener(OnCloseClicked); _view.GetSlider(SliderRole.Volume).onValueChanged.AddListener(OnVolumeChanged); } } public class ExampleScreen : StatefulView { private void Start() { SetText(TextRole.Title, "Hello World"); SetTextValues(TextRole.Timer, hours, minutes, seconds); SetImage(ImageRole.UserAvatar, avatarSprite); } }

Textes et localisation

L'onglet avec les textes, en plus du rôle et du lien vers l'objet, contient les colonnes suivantes :


  • Code : une clé de texte pour la localisation
  • Case à cocher Localiser : un indicateur indiquant que le champ de texte est soumis à la localisation
  • Valeur : le contenu textuel actuel de l'objet
  • Localisé : le texte actuel trouvé par la clé dans le champ Code



La bibliothèque n'inclut pas de sous-système intégré pour travailler avec les traductions. Pour connecter votre système de localisation, vous devrez créer une implémentation de l'interface ILocalizationProvider . Cela peut être construit, par exemple, sur la base de votre Back-end, ScriptableObjects ou Google Sheets.


 public class HardcodeLocalizationProvider : ILocalizationProvider { private Dictionary<string, string> _dictionary = new Dictionary<string, string> { { "timer" , "{0}h {1}m {2}s" }, { "title" , "Título do Jogo" }, { "description" , "Descrição longa do jogo" }, }; public string GetPhrase(string key, string defaultValue) { return _dictionary.TryGetValue(key, out var value) ? value : defaultValue; } }


En cliquant sur le bouton Copier la localisation, le contenu des colonnes Code et Valeur sera copié dans le presse-papiers dans un format adapté au collage dans Google Sheets.

Composants internes

Souvent, afin de faciliter la réutilisation, des parties distinctes de l'interface utilisateur sont extraites dans des préfabriqués distincts. StatefulComponent nous permet également de créer une hiérarchie de composants, où chaque composant ne fonctionne qu'avec ses propres éléments d'interface enfants.


Dans l'onglet Inner Comps, vous pouvez attribuer des rôles aux composants internes :



Les rôles configurés peuvent être utilisés dans le code de la même manière que d'autres types d'éléments :


 var header = GetInnerComponent(InnerComponentRole.Header); header.GetButton(ButtonRole.Close).onClick.AddListener(OnCloseClicked); header.SetText(TextRole.Title, "Header Title"); var footer = GetInnerComponent(InnerComponentRole.Footer); footer.GetButton(ButtonRole.Continue).onClick.AddListener(OnContinueClicked); footer.SetText(TextRole.Message, "Footer Message");

Conteneurs

Pour créer une liste d'éléments similaires, vous pouvez utiliser le composant ContainerView . Vous devez spécifier le préfabriqué pour l'instanciation et l'objet racine (facultatif). En mode édition, vous pouvez ajouter et supprimer des éléments à l'aide StatefulComponent :



Il est pratique d'utiliser StatefulComponent pour marquer le contenu des préfabriqués instanciés. Dans Runtime, vous pouvez utiliser les méthodes AddInstance<T> , AddStatefulComponent ou FillWithItems pour remplir le conteneur :


 var container = GetContainer(ContainerRole.Players); container.Clear(); container.FillWithItems(_player, (StatefulComponent view, PlayerData data) => { view.SetText(TextRole.Name, data.Name); view.SetText(TextRole.Level, data.Level); view.SetImage(ImageRole.Avatar, data.Avatar); });


Si le standard Object.Instantiate() ne vous convient pas pour la création d'objets, vous pouvez remplacer ce comportement, par exemple, pour l'instanciation à l'aide de Zenject :


 StatefulUiManager.Instance.CustomInstantiateMethod = prefab => { return _diContainer.InstantiatePrefab(prefab); };


Les composants internes et les conteneurs fournissent respectivement une imbrication statique et dynamique pour StatefulComponent .


Nous avons examiné le balisage des préfabriqués, la localisation et l'instanciation. Il est maintenant temps de passer à la partie la plus intéressante : développer des interfaces utilisateur basées sur des états.

États

Nous considérerons le concept d'état comme un ensemble nommé de modifications apportées à un préfabriqué. Dans ce cas, le nom est un rôle de l'énumération StateRole , et des exemples de modifications apportées au préfabriqué peuvent être :


  • Activer et désactiver un GameObject
  • Remplacement de sprites ou de matériaux pour les objets Image
  • Déplacer des objets sur l'écran
  • Modification des textes et de leur apparence
  • Jouer des animations
  • Et ainsi de suite - vous pouvez ajouter vos propres types de manipulations d'objets


Un ensemble de modifications (Description de l'état) peut être configuré dans l'onglet États. Un état configuré peut être appliqué directement depuis l'inspecteur :



Un état configuré peut être appliqué à partir du code à l'aide de la méthode SetState :


 switch (colorScheme) { case ColorScheme.Orange: SetState(StateRole.Orange); break; case ColorScheme.Red: SetState(StateRole.Red); break; case ColorScheme.Purple: SetState(StateRole.Purple); break; }


Dans l'onglet Outils, lorsque le paramètre Appliquer l'état initial à l'activation est activé, vous pouvez configurer l'état qui sera appliqué immédiatement lors de l'instanciation de l'objet.


L'utilisation d'états permet une réduction significative de la quantité de code requise au niveau de la classe View. Décrivez simplement chaque état de votre écran comme un ensemble de changements dans le StatefulComponent et appliquez l'état nécessaire à partir du code en fonction de la situation de jeu.

Arbre d'état

En fait, développer une interface utilisateur basée sur des états est incroyablement pratique. À tel point qu'au fil du temps, cela conduit à un autre problème - à mesure que le projet évolue, la liste des états d'une fenêtre unique peut atteindre une longueur indisciplinée et devient donc difficile à naviguer. De plus, il y a des états qui n'ont de sens que dans le contexte de certains autres états. Pour résoudre ce problème, Statful UI dispose d'un autre outil : State Tree. Vous pouvez y accéder en cliquant sur le bouton State Tree Editor dans l'onglet States.


Supposons que nous ayons besoin de créer une fenêtre de récompense pour un coffre. La fenêtre a 3 phases :


  • Présentation animée du coffre (état Intro )
  • Apparition en boucle de trois différents types de récompenses depuis le coffre (états Money , Emoji et Cards , selon l'état de la récompense , ce qui déclenche une animation de la récompense apparaissant depuis le coffre)
  • Affichage de toutes les récompenses attribuées dans une seule liste (état Résultats )


Les états parents (dans cet exemple Reward ) sont appliqués chaque fois que les états enfants sont appelés :



La gestion d'un StatefulComponent configuré se résume à une quantité minimale de code simple et compréhensible qui remplit les composants avec les données nécessaires et change d'état au bon moment :


 public void ShowIntro() { SetState(StateRole.Intro); } public void ShowReward(IReward reward) { // Update the inner view with the reward reward.UpdateView(GetInnerComponent(InnerComponentRole.Reward)); // Switch on the type of reward switch (reward) { case ICardsReward cardsReward: SetState(StateRole.Cards); break; case IMoneyReward moneyReward: SetState(StateRole.Money); break; case IEmojiReward emojiReward: SetState(StateRole.Emoji); break; } } public void ShowResults(IEnumerable<IReward> rewards) { SetState(StateRole.Results); // Fill the container with the rewards GetContainer(ContainerRole.TotalReward) .FillWithItems(rewards, (view, reward) => reward.UpdateView(view)); }

API et documentation avec état

Les rôles sont destinés à fournir un moyen pratique et sans ambiguïté de nommer les liens et les états pour une utilisation ultérieure dans le code. Cependant, il existe des situations où la description d'un état nécessiterait un nom trop long, et il serait plus pratique de laisser un petit commentaire sur ce vers quoi ce lien pointe ou sur le comportement que l'état reflète. Dans de tels cas, chaque lien et état dans un StatefulComponent vous permet d'ajouter une description :



Vous avez peut-être déjà remarqué les boutons Copier l'API et Copier les documents sur chaque onglet - ces informations de copie pour la section sélectionnée. En plus de ceux-ci, il existe des boutons similaires dans l'onglet Outils - ceux-ci copient les informations pour toutes les sections à la fois. Lorsque vous cliquez sur le bouton Copier l'API, le code généré pour gérer cet objet StatfulComponent sera copié dans le presse-papiers. Voici un exemple pour notre fenêtre de récompenses :


 // Insert the name of the chest here SetText(TextRole.Title, "Lootbox"); // Button to proceed to the reward issuance phase GetButton(ButtonRole.Overlay); // Button to display information about the card GetButton(ButtonRole.Info); // Container for displaying the complete list of awarded rewards GetContainer(ContainerRole.TotalReward); // Insert the card image here SetImage(ImageRole.Avatar, null); // Animated appearance of a chest SetState(StateRole.Intro);


Lorsque vous cliquez sur le bouton Copier les documents, la documentation de ce préfabriqué sera copiée dans le presse-papiers au format Markdown :


 ### RewardScreen Buttons: - Overlay - Button to proceed to the reward issuance phase - Info - Button to display information about the card Texts: - Title - Insert the name of the chest here Containers: - TotalReward - Container for displaying the complete list of awarded rewards Images: - Avatar - Insert the card image here States: - Intro - Animated appearance of a chest - Cards - Displaying rewards in the form of a card - Money - Displaying rewards in the form of game currency - Emoji - Displaying rewards in the form of an emoji - Results - Displaying a complete list of issued rewards


Il est évident que faire une erreur lors de la mise en œuvre de cet écran avec des instructions aussi détaillées est assez difficile. Vous pouvez facilement conserver des informations à jour sur votre organisation d'interface utilisateur dans la base de connaissances du projet.


Dans le même temps, l'interface utilisateur avec état permet de déléguer la création de préfabriqués d'interface utilisateur. En fait, le balisage basé sur l'état permet de tester entièrement le comportement du préfabriqué avant de le transmettre aux programmeurs. Cela signifie que les concepteurs de jeux , les concepteurs techniques ou même les développeurs d'interface utilisateur distincts peuvent préparer des préfabriqués. De plus, puisqu'une API est créée entre le code et le préfabriqué, la programmation et la configuration des préfabriqués peuvent se faire en parallèle ! Il suffit de formuler l'API à l'avance. Mais, même si la tâche de configuration des préfabriqués incombe aux programmeurs, l'utilisation de l'interface utilisateur avec état accélère considérablement ce travail.

Conclusion

Comme nous l'avons vu, l'interface utilisateur avec état simplifie considérablement le travail avec les états des éléments de l'interface utilisateur. De longs cycles ne sont plus nécessaires pour créer SerializeFields, recompiler le code et rechercher des références parmi un grand nombre de champs de classe View. Dans les classes View elles-mêmes, il n'est plus nécessaire d'écrire une grande quantité de code pour des opérations répétitives telles que l'activation et la désactivation d'objets ou la modification de la couleur du texte.


La bibliothèque permet une approche cohérente de l'organisation des mises en page dans un projet, du marquage des objets dans les préfabriqués, de la création d'états, de leur liaison aux éléments de l'interface utilisateur et de la fourniture d'une API et d'une documentation pour la gestion de l'interface utilisateur. Il permet également de déléguer la création de préfabriqués d'interface utilisateur et accélère le travail avec eux.


À l'avenir, la feuille de route du projet comprend les éléments suivants :


  • Étendre les capacités des états, prendre en charge de nouveaux types de modifications de l'interface utilisateur dans la description, tels que de nouveaux types d'animations, jouer des sons dans les états, etc.


  • Ajout de la prise en charge des palettes de couleurs pour la coloration du texte et des images


  • Ajout de la prise en charge des listes d'éléments avec réutilisation de GameObjects


  • Prise en charge d'un plus grand nombre d'éléments d'interface utilisateur Unity


  • Automatisation du déchargement des textes ajoutés pour la localisation


  • Implémentation d'un framework de test. Comme nous disposons d'un balisage exhaustif de nos préfabriqués, nous pouvons créer des scénarios basés sur ScriptableObject faciles à configurer au format suivant :


    1. Cliquez sur le bouton ButtonRole.Settings

    2. Vérifiez que le texte dans TextRole.SomeText est égal à "une valeur"

    3. Vérifiez l'image dans ImageRole.SomeImage pour vous assurer qu'elle est égale à un certain sprite


  • Un système de tutorat. De la même manière que pour les tests, la mise en page marquée permet de créer des scénarios de didacticiel basés sur ScriptableObject sous la forme d'instructions telles que "Afficher le pointeur sur le bouton ButtonRole.UpgradeHero ".


Le code source du projet est disponible sur GitHub . Vous êtes invités à créer des numéros ou à contribuer à la bibliothèque !