
Dans les articles précédents, nous avons décrit comment un modèle devrait être organisé de manière pratique et avec de larges capacités, quel type de système de commande lui conviendrait, qui agit comme un contrôleur, il est temps de parler de la troisième lettre de notre abréviation alternative MVC.
En fait, le Assetstore a une bibliothèque UniRX très sophistiquée prête à l'emploi qui implémente la réactivité et contrôle l'inversion pour l'unité. Mais nous en parlerons à la fin de l'article, car cet outil puissant, énorme et compatible RX pour notre cas est assez redondant. Faire tout ce dont nous avons besoin est parfaitement possible sans tirer le RX, et si vous le possédez, il ne vous sera pas difficile d'en faire de même.
Solutions architecturales pour un jeu mobile. Partie 1: ModèleSolutions architecturales pour un jeu mobile. Partie 2: commande et leurs files d'attenteLorsqu'une personne commence à peine à écrire le premier jeu, il semble logique pour elle d'exister une fonction qui dessine la forme entière ou une partie de celle-ci, et la tire à chaque fois que quelque chose d'important change. Au fil du temps, l'interface grandit, la forme et les parties des moules deviennent cent, puis deux cents, et lorsque le portefeuille change d'état, un quart d'entre eux doivent être redessinés. Et puis le manager vient et dit que "comme dans ce jeu" vous devez faire un petit point rouge sur le bouton s'il y a une section à l'intérieur du bouton dans laquelle il y a une sous-section dans laquelle le bouton est, et maintenant vous avez suffisamment de ressources pour faire quelque chose en cliquant dessus c'est important. Et c'est tout, navigué ...
Le départ de la notion de dessin se déroule en plusieurs étapes. Premièrement, le problème des champs uniques est résolu. Vous avez, par exemple, un champ dans le modèle et un champ de texte dans lequel tout son contenu doit être affiché. Ok, nous commençons un objet qui s'abonne aux mises à jour de ce champ, et à chaque mise à jour il ajoute les résultats à un champ de texte. Dans le code, quelque chose comme ceci:
var observable = new ChildControl(FCPlayerModel.ASSIGNED, Player); observable.onChange(i => Assigned.text = i.ToString())
Maintenant, nous n'avons pas besoin de suivre le redessin, il suffit de créer cette conception, puis tout ce qui se passe dans le modèle tombera dans l'interface. Bon, mais encombrant, il contient beaucoup de gestes manifestement inutiles qu'un programmeur devra écrire 100 500 fois avec ses mains et parfois faire des erreurs. Emballons ces publicités dans une fonction d'extension qui masquera les lettres supplémentaires sous le capot.
Player.Get(c, FCPlayerModel.ASSIGNED).Action(c, i => Assigned.text = i.ToString());
Bien mieux, mais ce n'est pas tout. Le déplacement du champ de modèle dans le champ de texte est une opération tellement fréquente et typique que nous allons créer une fonction wrapper distincte pour cela. Maintenant, cela se révèle assez brièvement et bien, comme il me semble.
Player.Get(c, FCPlayerModel.ASSIGNED).SetText(c, Assigned);
Ici, j'ai montré l'idée principale, qui me guidera lors de la création d'interfaces pour le reste de ma vie: "Si un programmeur devait faire quelque chose au moins deux fois, enveloppez-le dans une fonction spéciale pratique et courte."
Collecte des ordures
Un effet secondaire de l'ingénierie d'interface réactive est la création d'un tas d'objets qui sont abonnés à quelque chose et ne laisseront donc pas de mémoire sans coup de pied spécial. Pour moi, dans les temps anciens, j'ai trouvé un moyen qui n'est pas si beau, mais simple et abordable. Lors de la création d'un formulaire, une liste de tous les contrôleurs créés en relation avec ce formulaire est appelée, par souci de concision, simplement "c". Toutes les fonctions d'encapsuleur spéciales acceptent cette liste comme premier paramètre requis et lorsque DisconnectModel le formulaire, il passe la liste de tous les contrôles et la désactive sans pitié avec le code de l'ancêtre commun. Pas de beauté et de grâce, mais bon marché, fiable et relativement pratique. Vous pouvez avoir un peu plus de sécurité si, au lieu de la feuille de contrôle, vous avez besoin d'IView pour entrer et donner cela à tous ces endroits. Essentiellement la même chose, oublier de remplir tout de même ne fonctionnera pas, mais c'est plus difficile à pirater. J'ai peur d'oublier, mais je n'ai pas très peur que quelqu'un brise délibérément le système, car des gens aussi intelligents doivent être combattus avec une ceinture et d'autres méthodes non logicielles, donc je me limite à c.
Une approche alternative peut être tirée d'UniRX. Chaque wrapper crée un nouvel objet qui a un lien vers le précédent qu'il écoute. Et à la fin, la méthode AddTo (composant) est appelée, qui attribue la chaîne entière de contrôles à un objet destructible. Dans notre exemple, un tel code ressemblerait à ceci:
Player.Get(FCPlayerModel.ASSIGNED).SetText(Assigned).AddTo(this);
Si ce dernier propriétaire de la chaîne décide d'être détruit, il enverra à tous les contrôles qui lui sont assignés la commande «tuez-vous de disposer si personne ne vous écoute sauf moi». Et toute la chaîne est nettoyée docilement. Donc, bien sûr, c'est beaucoup plus concis, mais de mon point de vue, il y a un défaut important. AddTo peut être accidentellement oublié et personne ne le saura jusqu'à ce qu'il soit trop tard.
En fait, vous pouvez utiliser le hack Unity sale et vous passer de tout code supplémentaire dans View:
public static T AddTo<T>(this T disposable, Component component) where T : IDisposable { var composite = new CompositeDisposable(disposable); Observable .EveryUpdate() .Where(_ => component == null) .Subscribe(_ => composite.Dispose()) .AddTo(composite); return disposable; }
Comme vous le savez, un lien vers un composant Unicomponent ou GameObject dans Unity est nul. Mais vous devez comprendre que cet hakokostyl crée un écouteur de mise à jour pour chaque chaîne de contrôles détruite, et c'est déjà un peu poliment.
Interface indépendante du modèle
Notre idéal, que nous pouvons cependant facilement atteindre, est la situation où nous pouvons charger le GameState complet à tout moment, à la fois le modèle vérifié par le serveur et le modèle de données pour l'interface utilisateur, et l'application sera exactement dans le même état, jusqu'à l'état de tous les boutons. Il y a deux raisons à cela. La première est que certains programmeurs aiment stocker à l'intérieur du contrôleur de formulaire, ou même dans la vue elle-même, citant le fait que leur cycle de vie est exactement le même que celui du formulaire lui-même. La seconde est que même si toutes les données du formulaire se trouvent dans son modèle, la commande pour créer et remplir le formulaire lui-même prend la forme d'un appel de fonction explicite, avec quelques paramètres supplémentaires, par exemple, sur quel champ de la liste doit être concentré.
Vous n'avez pas à gérer cela si vous ne voulez pas vraiment de débogage. Mais nous ne sommes pas comme ça, nous voulons déboguer l'interface aussi facilement que les opérations de base avec le modèle. Pour ce faire, l'accent suivant. Dans la partie UI du modèle, une variable est configurée, par exemple .main, et dans celle-ci, dans le cadre de la commande, vous mettez le modèle du formulaire que vous souhaitez voir. L'état de cette variable est surveillé par un contrôleur spécial, si un modèle apparaît dans cette variable, selon son type, il instancie la forme souhaitée, la place là où c'est nécessaire et lui envoie un appel à ConnectModel (modèle). Si la variable est libérée du modèle, le contrôleur supprimera le formulaire du canevas et l'utilisera. Ainsi, aucune action pour contourner le modèle ne se produit et tout ce que vous avez fait avec l'interface est clairement visible sur le modèle ExportChanges. Et puis nous sommes guidés par le principe de "tout ce qui a été fait deux fois" et utilisons exactement le même contrôleur à tous les niveaux de l'interface. Si le moule a une place pour un autre moule, un modèle d'interface utilisateur est créé pour lui et une variable est créée dans le modèle du moule parent. Exactement la même chose avec les listes.
Un effet secondaire de cette approche est que deux fichiers sont ajoutés à n'importe quel formulaire, l'un avec un modèle de données pour ce formulaire, et l'autre, généralement une monobah contenant des liens vers des éléments d'interface utilisateur, qui, après avoir reçu le modèle dans sa fonction ConnectModel, créera tous les contrôleurs réactifs pour tous champs de modèle et tous les éléments de l'interface utilisateur. Eh bien, il est encore plus compact, de sorte qu'il est également pratique de travailler avec, probablement impossible. Si possible, écrivez dans les commentaires.
Liste des contrôles
Une situation typique est lorsque le modèle a une liste de certains éléments. Comme je veux que tout soit fait de manière très pratique, et de préférence sur une seule ligne, je voulais également faire quelque chose pour les listes qui seraient pratiques à gérer. Une ligne est possible, mais elle s'avère inconfortablement longue. Empiriquement, il s'est avéré que presque toute la diversité des cas n'est couverte que par deux types de contrôles. Le premier surveille l'état d'une collection et appelle trois fonctions lambda, la première est appelée lorsqu'un élément est ajouté à la collection, la seconde lorsque l'élément quitte la collection et enfin la troisième est appelée lorsque les éléments de la collection changent l'ordre. Le deuxième type de contrôle le plus courant surveille la liste et est la source d'un abonnement à partir d'elle - les pages avec un numéro spécifique. C'est-à-dire, par exemple, qu'il suit une liste avec une longueur de 102 éléments, et il retourne lui-même une liste de 10 éléments, du 20 au 29. Et les événements générés sont exactement les mêmes que s'il s'agissait d'une liste elle-même.
Bien sûr, suivant le principe de «créer un wrapper pour tout ce qui a été fait deux fois», un grand nombre de wrappers pratiques sont apparus, par exemple, un qui accepte uniquement Factory comme entrée, créant une correspondance entre les types de modèle et leurs vues, et un lien vers Canvas dans lequel vous devez ajouter les éléments. Et bien d'autres similaires, seulement environ une douzaine d'emballages pour des cas typiques.
Contrôles plus complexes
Parfois, des situations surgissent qui sont redondantes à exprimer à travers le modèle, autant qu'elles sont évidentes. Ici, les contrôles qui effectuent une sorte d'opération sur une valeur peuvent venir à la rescousse, ainsi que les contrôles qui surveillent d'autres contrôles. Par exemple, une situation typique: une action a un prix et le bouton n'est actif que s'il y a plus d'argent dans le compte que son prix.
item.Get(c, FCUnitItem.COST).Join(c, Player.Get(c, MONEY)).Func(c, (cost, money) => cost <= money).SetActive(c, BuyButton);
En fait, la situation est si typique que, selon mon principe, il y a un emballage prêt à l'emploi, mais j'ai ensuite montré son contenu.
Nous avons pris l'article à acheter, créé un objet qui est abonné à l'un de ses champs, et a une valeur de type long. Ils ont ajouté un contrôle supplémentaire, qui est également de type long, la méthode a renvoyé un contrôle qui a une paire de valeurs, et l'événement Changed est déclenché lorsque l'un d'eux change, puis Func crée un objet pour toute modification de l'entrée qui calcule la fonction, et l'événement Changed est déclenché si la valeur finale est calculée fonction a changé.
Le compilateur réussira à créer le type de contrôle nécessaire sur la base des types de données d'entrée et du type de l'expression résultante. Dans de rares cas où le type retourné par la fonction lambda n'est pas évident, le compilateur vous demandera de le clarifier explicitement. Enfin, le dernier appel écoute le contrôle booléen, selon lequel il active ou désactive le bouton.
En fait, le véritable wrapper du projet accepte deux boutons en entrée, l'un pour le cas où il y a de l'argent et l'autre quand il n'y en a pas assez, et la commande pour ouvrir la fenêtre modale "Acheter des devises" se bloque également sur le deuxième bouton. Et tout cela en une seule ligne.
Il est facile de voir qu'en utilisant Join et Func, vous pouvez construire des structures arbitrairement complexes. Dans mon code, il y avait une fonction qui générait des contrôles complexes, calculant combien un joueur pouvait acheter en tenant compte du nombre de joueurs de son côté, et la règle selon laquelle tout le monde pouvait dépasser le budget de 10% si tous ensemble ne dépassaient pas le budget total. Et ceci est un exemple de la façon dont il n'est pas nécessaire de le faire, car combien il est simple et facile de déboguer ce qui se passe dans les modèles, il est tout aussi difficile de détecter une erreur dans les contrôles réactifs. Vous allez même assister à l'exécution et passer beaucoup de temps à comprendre ce qui y a conduit.
Par conséquent, le principe général de l'utilisation de contrôles complexes est le suivant: lors du prototypage d'un formulaire, vous pouvez utiliser des structures sur des contrôles réactifs, surtout si vous n'êtes pas sûr qu'ils deviendront plus compliqués à l'avenir, mais dès que vous pensez que s'il se casse, vous ne comprendrez pas ce qui s'est passé, vous devez immédiatement transférer ces manipulations vers le modèle et placer les calculs effectués précédemment dans les contrôles dans les méthodes d'extension des classes de règles statiques.
Ceci est significativement différent du principe de «Bien faire tout de suite», si aimé des perfectionnistes, car nous vivons dans un monde de développement de jeux, et lorsque vous commencez à sauter un formulaire, vous ne pouvez absolument pas être sûr de ce qu'il fera en trois jours. Comme l’a dit un de mes collègues: «Si j’obtenais cinq cents chaque fois que les concepteurs de jeux changent d’avis, je serais déjà une personne très riche.» En fait, ce n'est pas mal, mais même vice versa. Le jeu devrait se développer par essais et erreurs, car si vous ne faites pas un clone stupide, vous ne pouvez pas imaginer ce dont les joueurs ont vraiment besoin.
Une source de données pour plusieurs vues
Pour tant de cas archétypaux que vous devez en parler séparément. Il arrive que le même modèle d'un élément faisant partie d'un modèle d'interface soit rendu dans une vue différente selon l'endroit et dans quel contexte cela se produit. Et nous utilisons le principe - «un type, une vue». Par exemple, vous avez une carte d'achat d'armes qui contient les mêmes informations simples, mais dans différents modes de magasin, elle doit être représentée par des préfabriqués différents. La solution se compose de deux parties pour deux situations différentes.
Le premier est lorsque cette vue est placée dans deux vues différentes, par exemple, un magasin sous la forme d'une liste courte et un magasin avec de grandes images. Dans ce cas, deux usines distinctes sont configurées pour vous aider à créer une correspondance de type préfabriqué. Dans la méthode ConnectModel d'une vue, vous utiliserez l'une et l'autre dans l'autre. C'est un cas complètement différent si vous devez montrer des cartes avec des informations absolument identiques en un seul endroit un peu différemment. Parfois, dans ce cas, le modèle d'élément a un champ supplémentaire qui indique l'arrière-plan festif d'un élément particulier, et parfois c'est juste que le modèle d'élément a un héritier qui n'a pas de champs et doit uniquement être dessiné avec un autre préfabriqué. En principe, rien ne contredit.
Cela semblerait une solution évidente, mais j'en ai vu assez dans un code étrange sur des danses étranges avec un tambourin autour de cette situation, et j'ai jugé nécessaire d'écrire à ce sujet.
Cas particulier: contrôles avec beaucoup de dépendances
Il y a un cas très spécial dont je veux parler séparément. Ce sont des contrôles qui surveillent un très grand nombre d'éléments. Par exemple, un contrôle qui surveille une liste de modèles et résume le contenu d'un champ situé à l'intérieur de chacun des éléments. Avec un grand surtube dans la liste, par exemple, en le remplissant de données, un tel contrôle risque d'attraper autant d'événements sur le changement qu'il y en a plus un dans la liste des éléments. Recalculer la fonction d'agrégation tant de fois est bien sûr une mauvaise idée. Surtout pour de tels cas, nous faisons un contrôle qui s'abonne à l'événement onTransactionFinished, qui sort du GameState, et un lien vers le GameState, comme nous le rappelons, est disponible dans n'importe quel modèle. Et avec tout changement dans l'entrée, ce contrôle se contentera de mettre une marque sur lui-même que les données source ont changé, et ne seront recomptées que lorsqu'il recevra un message concernant la fin de la transaction, ou lorsqu'il trouvera que la transaction est déjà terminée au moment où elle a reçu un message du flux d'événements d'entrée . Il est clair qu'un tel contrôle peut ne pas être protégé contre les messages inutiles s'il existe deux de ces contrôles dans la chaîne de traitement des flux. Le premier accumulera un nuage de changements, attendra la fin de la transaction, démarrera le flux de changements plus loin, et il y en a un autre qui a déjà attrapé un tas de changements, a reçu l'événement vers la fin de la transaction (il n'a pas eu de chance d'être dans la liste des fonctions abonnées à l'événement plus tôt), a tout compté, puis il bam et un autre événement de changement, et tout raconter une deuxième fois. Cela peut être, mais rarement, et plus important encore, si vos contrôles effectuent de tels calculs monstrueux plus d'une fois dans un même flux de calculs, alors vous faites quelque chose de mal, et vous devez transférer toutes ces manipulations infernales au modèle et aux règles, où ils , en fait, l'endroit.
Bibliothèque prête pour UniRX
Et il serait possible de nous limiter à tout ce qui précède, et de commencer calmement à écrire votre chef-d'œuvre, d'autant plus que par rapport au modèle et aux équipes de contrôle, c'est très simple et ils sont écrits en moins d'une semaine, si l'idée que vous inventiez un vélo n'a pas obscurci, et tout est déjà pensé et écrit avant moi est distribué gratuitement à tous.
En découvrant UniRX, nous trouvons un design magnifique et conforme aux normes qui peut créer des threads à partir de tout en général, les fusionner intelligemment, les filtrer du thread principal au thread non principal, ou retourner le contrôle au thread principal, qui a un tas d'outils prêts à l'emploi à envoyer à différents endroits, etc. plus loin. Nous n'avons pas exactement deux choses là-bas: simplicité et commodité de débogage. Avez-vous déjà essayé de déboguer un bâtiment à plusieurs étages sur Linq par étapes dans le débogueur? Ici, c'est encore bien pire. Dans le même temps, nous manquons totalement de la raison d'être de toutes ces machines sophistiquées. Dans un souci de simplicité des états de débogage et de reproduction, nous manquons complètement d'une variété de sources de signaux, tout se passe dans le flux principal, car jouer avec le multithreading dans le méta-jeu est complètement redondant, toute l'asynchronie du traitement des commandes est cachée dans le moteur d'envoi des commandes, et l'asynchronie elle-même en prend beaucoup en elle. pas beaucoup d'espace, beaucoup plus d'attention est accordée à toutes sortes de vérifications, d'autocontrôles et aux possibilités de journalisation et de lecture.
En général, si vous savez déjà comment utiliser UniRX, je le ferai spécialement pour vous pour les modèles IObservable, et vous pouvez utiliser les fonctionnalités d'atout de votre bibliothèque préférée là où vous en avez besoin, mais pour le reste, je suggère de ne pas essayer de construire des réservoirs à partir de voitures à grande vitesse et des voitures à partir de réservoirs uniquement au sol qui ont tous deux des roues.
A la fin de l'article, j'ai à vous, chers lecteurs, des questions traditionnelles qui sont très importantes pour moi, mes idées sur le beau, et pour les perspectives de développement de mon travail scientifique et technique.