В предыдущих статьях мы описали как должна быть устроена модель удобная и с широкими возможностями, какая к ней подойдёт система команд, выполняющая функции контроллеров, пришла пора поговорить о третьей букве нашей альтернативной абривиатуры MVC.
Вообще-то в ассетсторе есть готовая очень навороченная библиотека UniRX реализующая реактивность и инверсию контроля для unity. Но о ней мы поговорим в конце статьи, потому что этот могучий, огромный и соответствующий стандартам RX инструмент для нашего случая довольно таки избыточен. Делать всё что нам нужно прекрасно можно и не подтягивая RX-а, а если вы им владеете, вам не составит труда делать всё то же самое с его помощью.
Архитектурные решения для мобильной игры. Часть 1: Model
Архитектурные решения для мобильной игры. Часть 2: Command и их очереди
Когда человек только начинает писать первую игру ему кажется логичным существование функции, которая нарисует ему всю форму, или какую-то её часть, и дёргать её каждый раз когда изменилось что-то важное. Идёт время, интерфейс растёт в размерах, фомочек и частей формочек становится сто, потом двести, и при изменении состояния кошелька перерисовать приходится четверть из них. А потом приходит менеджер, и говорит, что надо «как вот в той игре» сделать на кнопочке маленькую красненькую точечку если внутри кнопочки есть раздел, в котором подраздел, в котором кнопочка, и теперь вам хватило ресурсов, чтобы по её нажатию делалось что-то важное. И всё, приплыли…
Отход от концепции рисования проходит в несколько этапов. Сначала решается проблема одиночных полей. Есть у вас, например, поле в модели, и текстовое поле, в котором должно показываться всё его содержимое. Ок, заводим объект, который подписывается на обновления этого поля, и при каждом обновлении складывает результаты в текстовое поле. В коде как-то так:
var observable = new ChildControl(FCPlayerModel.ASSIGNED, Player);
observable.onChange(i => Assigned.text = i.ToString())
Теперь нам не нужно следить за перерисовыванием, достаточно создать эту конструкцию, и дальше всё что происходит в модели будет попадать в интерфейс. Хорошо, но громоздко, содержит очень много явно лишних телодвижений, которые программисту придётся 100500 раз писать руками и иногда ошибаться. Завернём эти объявления в функции расширения, которые спрячут лишние буковки под капотом.
Player.Get(c, FCPlayerModel.ASSIGNED).Action(c, i => Assigned.text = i.ToString());
На много лучше, но и это ещё не всё. Перекладывание поля модели в текстовое поле на столько частая и типичная операция, что для неё мы заведём отдельную функцию-обёртку. Вот теперь получается достаточно кратко и хорошо, как мне кажется.
Player.Get(c, FCPlayerModel.ASSIGNED).SetText(c, Assigned);
Здесь я показал главную идею, которой я буду руководствоваться при создание интерфейсиков всю оставшуюся жизнь: «Если программисту что-то пришлось сделать хотя бы два раза заверни это в специальную удобную и короткую функцию».
Сбор мусора
Побочным эффектом реактивного интерфейсостроения является создание кучи объектов, которые на что-то там подписаны и потому не покинут память без специального пинка. Я для себя, ещё в стародавние времена, придумал способ не такой красивый, но простой и доступный. При создании любой формы создаётся лист всех контролеров, которые создаются в связи с этой формой, для краткости он называется просто «c». Все специальные функции-обёртки принимают этот список первым обязательным параметром и при DisconnectModel формочки она кодом, лежащим в общем предке проходит по списку всех контролов и всех их безжалостно диспоузит. Никакой красоты и изящества, зато дёшево, надёжно и относительно практично. Чуть большую защищённость можно иметь если вместо листа контролов требовать на вход IView и отдавать во все эти места this. По сути то же самое, забыть заполнить точно так же не получится, но труднее хакнуть. Я боюсь забыть, но не очень боюсь, что кто-то будет сознательно ломать систему, потому что с такими умниками нужно бороться ремнём и другими не программными способами, поэтому ограничиваюсь просто c.
Альтернативный подход можно подчерпнуть из UniRX. Каждая обёртка создаёт новый объект, имеющий ссылку на предыдущий, который он слушает. А в конце вызывается метод AddTo(component) который приписывает всю цепочку контролов к какому-нибудь уничтожимому объекту. В нашем примере такой код будет выглядеть так:
Player.Get(FCPlayerModel.ASSIGNED).SetText(Assigned).AddTo(this);
Если этот последний хозяин цепочки решит уничтожиться он всем приписанным к нему контролам передаст по цепочке команду «убей себя об dispose если тебя кроме меня никто уже не слушает». И вся цепочка послушно подчищается. Так конечно на много лаконичнее, но с моей точки зрения есть один важнючий недостаток. AddTo можно случайно забыть и никто об этом никогда не узнает, пока не станет слишком поздно.
На самом деле можно использовать грязный хак Unity и обойтись вообще без дополнительного кода во 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;
}
Как известно, ссылка на задиспоузившийся Component или GameObject в Unity равна null. Но надо понимать что вот этот вот хакокостыль создаёт слушателя Update на каждую уничтожаемую цепочку контролов, а это уже немножко не вежливо.
Моделезависимый интерфейс
Нашим идеалом, которого мы, впрочем, легко достигнем, является ситуация, когда мы можем в любой момент загрузить полный GameState, как проверяемой сервером модели так и модели данных для UI, и приложение окажется ровно в том же состоянии, вплоть до состояния всех кнопочек. Мешают этому две причины. Первая заключается в том, что некоторые переменные программисты любят хранить внутри контролера формы, или даже в самом вьюве, мотивируя это тем, что их жизненный цикл в точности такой же, как и у самой формы. Вторая заключается в том, что если даже все данные для формы имеются в её модели, сама команда создать и заполнить форму проходит в виде явного вызова функции, ещё и с какими-нибудь дополнительными параметрами, например на каком поле из списка нужно сфокусироваться.
С этим можно и не бороться, если вам не очень хочется удобства отладки. Но мы не такие, мы хотим отлаживать интерфейс так же удобно как основные операции с моделью. Для этого делается следующий фокус. В UI части модели заводится переменная, например .main и в неё вы в рамках команды помещаете модель той формы, которую хотите видеть. За состоянием этой переменной следит специальный контроллер, если в этой переменной появляется модель он в зависимости от её типа инстанцирует нужную форму, поместит её куда нужно, и пошлёт ей вызов ConnectModel(model). Если переменная освободилась от модели контроллер уберёт форму из канваса и подиспоузит её. Таким образом никаких действий в обход модели не происходит, и всё, что вы сделали с интерфейсом прекрасно видно на ExportChanges модели. А дальше мы руководствуемся принципом «всё что сделано дважды оберни» и пользуемся точно тем же самым контроллером на всех уровнях интерфейса. Если в формочке есть место под другую формочку, то под неё создаётся UI модель, и в модели родительской формочки заводится переменная. Точно то же самое со списками.
Побочным эффектом такого подхода является то, что на любую формочку заводится два файла, один с моделью данных для этой формы, и другой, обычно являющийся монобехом, содержащим ссылки на UI элементы, который получив модель в свою функцию ConnectModel произведёт создание всех реактивных контроллеров для всех полей модели и всех UI элементов. Ну, обойтись ещё компактнее, так чтобы с этим ещё и работать было удобно, наверное и нельзя. Если можно — пишите в комментарии.
Контролы списков
Типичная ситуация — когда в модели имеется список каких-то элементов. Поскольку я хочу, чтобы всё делалось очень удобненько, и желательно в одну строку, то и для списков я захотел сделать что-то такое, что будет их удобно обрабатывать. Совсем уж в одну строчку можно, но она получается неудобно длинная. Эмпирически выяснилось, что почти всё разнообразие случаев покрывается всего лишь двумя типами контролов. Первый следит за состоянием какой-нибудь коллекции, и вызывает три лямбда-функции, первая вызывается когда какой-то элемент добавляется в коллекцию, второй, когда элемент покидает коллекцию, и наконец третья вызывается когда элементы коллекции меняют порядок следования. Второй самый частый тип контрола следит за списком, и является источником подсписка из него — странички с определённым номером. То есть, например, следит за List длинной в 102 элемента, а сам отдаёт List из 10 элементов, с 20-ого по 29-ый. И события генерит точно такие же как если бы сам был списком.
Конечно же, следуя принципу «создавай обёртку на всё, что было сделано два раза» появилось огромное количество удобных обёрток, например такая, которая на вход принимает только Factory, строящую соответствие между типами моделей и их View-ами, и ссылку на Canvas в который надо элементы складывать. И множество других подобных, всего около десятка обёрток для типичных случаев.
Более сложные контролы
Иногда возникают ситуации, которые выражать через модель избыточно, на столько они очевидны. Тут на помощь могут прийти контролы, выполняющие какую-нибудь операцию над значением, а также контролы, следящие за другими контролами. Например типовая ситуация: у действия есть цена, и кнопка активна только если на счету больше денег, чем его цена.
item.Get(c, FCUnitItem.COST).Join(c, Player.Get(c, MONEY)).Func(c, (cost, money) => cost <= money).SetActive(c, BuyButton);
На самом деле ситуация на столько типовая, что в соответствии с моим принципом для неё есть готовая обёртка, но тут я показал её содержимое.
Взяли предмет, который надо купить, создали объект, который подписан на одно из его полей, и имеет значение типа long. Присовокупили к нему ещё один контрол, имеющий тип тоже long, метод вернул контрол, имеющий пару значений, и генерящий событие Changed когда меняется любой из них, дальше Func создаёт объект при любом изменении на входе вычисляющий функцию, и генерящий событие Changed если итоговое значение посчитанной функции изменилось.
Необходимый тип контрола компилятор сам успешно построит исходя из типов входных данных, и типа получившегося выражения. В редких случаях, когда тип возвращаемый лямбда-функцией не очевиден компилятор попросит вас уточнить его в явном виде. Наконец последний вызов слушает буленовский контрол, в зависимости от него включает или выключает кнопку.
На самом деле реальная обёртка в проекте принимает на вход две кнопки, одну для случая когда деньги есть и другую когда денег недостаточно, и ещё и на вторую кнопку навешивает команду открыть модальное окно «Докупите валюты». И всё вот это в одну простую строчку.
Легко заметить, что используя Join и Func можно строить сколь угодно сложные конструкции. У меня в коде встречалась функция, генерящая сложный контрол, вычисляющий на какую сумму может закупиться войсками игрок учитывая количество игроков на его стороне, и правило, что каждый может превысить бюджет на 10% если все вместе не превысили суммарного бюджета. И это пример того, как делать не надо, потому что на сколько просто и легко отлаживать происходящее в моделях на столько же сложно поймать ошибку в реактивных контролах. Вы даже словив эксепшен потратите немало времени чтобы понять что же к нему привело.
Поэтому общий принцип использования сложных контролов такой: При прототипировании формы, вы можете использовать конструкции на реактивных контролах, особенно если не уверены, что в дальнейшем они будут усложняться, но как только у вас закрадывается подозрение, что если оно сломается вы не поймёте что произошло, вы сразу же должны перенести эти манипуляции в модель, а вычисления, которые раньше делали в контролах поместить в методы-расширения в статических классах правил.
Это значительно отличается от принципа «Делай сразу хорошо», столь любимого среди перфекционистов, потому что мы живём в мире геймдева, и когда ты начинаешь прогать формочку ты совершенно не можешь быть уверен, что она будет делать через три дня. Как говорила одна моя коллега: «Если бы я получала пять копеек каждый раз когда геймдизайнеры меняют своё мнение, я была бы уже очень богатым человеком». На самом деле это не плохо, а даже наоборот хорошо. Игра должна развиваться методом проб и ошибок, потому что если вы делаете не тупой клон, то вы ваще не представляете, что на самом деле нужно игрокам.
Один источник данных для нескольких View-ов
На столько архитипичный случай, что о нём нужно поговорить отдельно. Бывает, что одна и та же модель элемента в составе модели интерфейса отрисовывается в разных View в зависимости от того где и в каком контексте это происходит. А у нас используется принцип — «один тип, один вьюв». Например у вас есть карточка покупки оружия, содержащая одну и ту же незамысловатую информацию, но в разных режимах магазина она должна изображаться разными префабами. Решение состоит из двух частей для двух различных ситуаций.
Первое, когда этот View помещается внутрь двух разных View-ов, например магазина в виде короткого списка и магазина с большими картинками. В этом случае приходит на помощь две отдельных, по разному настроенных фабрики, строящих соответствие тип-префаб. В методе ConnectModel одного View вы воспользуетесь одним, а в другом другим. Совсем другой случай, если вам надо немного по разному показывать карточки с абсолютно идентичной информацией в одном месте. Иногда в этом случае модели элемента появляется дополнительное поле, указывающее на праздничный фончик конкретно данного элемента, а иногда просто у модели элемента появляется наследник, не имеющий никаких полей, и нужный только чтобы отрисовываться другим префабом. В принципе ничто не противоречит.
Казалось бы очевидное решение, но я насмотрелся в чужом коде на странные пляски с бубном вокруг этой ситуации, и посчитал нужным об этом написать.
Особый случай: контролы с адским количеством зависимостей
Есть один очень особенный случай, о котором я хочу поговорить отдельно. Это контролы, которые следят за очень большим количеством элементов. Например, контрол, который следит за списком моделей и суммирует содержимое какого-нибудь поля, лежащего внутри каждого из элементов. При крупной перетрубации в списке, например его заполнении данными такой контрол рискует поймать столько же событий об изменении сколько в списке элементов плюс один. Пересчитывать столько раз агрегирующую функцию конечно же плохая идея. Специально для таких случаев мы делаем контрол, который подписываем на событие onTransactionFinished, которое торчит из GameState, а ссылка на GameState как мы помним, имеется в любой модели. И при любом изменении во входных данных этот контрол будет просто ставить у себя меточку, что исходные данные поменялись, а пересчитываться только тогда когда получит сообщение об окончании транзакции, или когда обнаружит, что транзакция уже закончена в момент когда он получил сообщение из входного потока событий. Понятно, что такой контрол может оказаться не защищён от лишних сообщений, если в цепочке обработки потока будет друг за другом два таких контрола. Первый накопит тучу изменений, дождётся конца транзакции, пустит поток изменений дальше, а там другой такой же, который уже кучу изменений наловил, событие о конце транзакции получил (ему неповезло оказаться раньше в списке функций подписанных на эвент), всё пересчитал, и тут ему бац и ещё одно событие об изменении, и пересчитывать всё второй раз. Так быть может, но редко, и что более важно, если у вас контролы делают такие монструозные рассчёты больше одного раза в одном потоке вычислений значит вы делаете что-то не так, и нужно переносить все эти адские манипуляции внутрь модели и правил, где им, собственно, и место.
Готовая библиотека UniRX
И можно было бы ограничится всем выше сказанным, и спокойно начинать писать свой шедевр, тем более что по сравнению с моделью и командами контролы это очень просто и пишуться они меньше чем за неделю все, если бы подспудно не свербила мысль, что изобретаешь велосипед, и всё уже продумано и написано до меня раздаётся бесплатно всем желающим.
Расчехлив UniRX мы обнаруживаем красивую и соответствующую всем стандартам конструкцию, умеющую создавать потоки из вообще всего, ловко мерджить их, фильтровать перекладывать из главного треда в не главный, или возвращать управление обратно в главный поток, имеющую кучу готовых инстументов чтобы отсылать в разные места и так далее. Не имеем мы там ровно двух вещей: Простоты и Удобства отладки. Вы когда-нибудь пробовали по шагам в дебагере отлаживать какую-нибудь многоэтажную конструкцию на Linq-е? Так вот тут всё ещё значительно хуже. При этом у нас совершенно отсутствует то, ради чего вся эта навороченная машинерия создавалась. Ради простоты отладки и воспроизводства состояний у нас напрочь отсутствует разнообразие источников сигналов, у нас всё происходит в главном потоке, потому что заигрывания с многопоточностью в метаигре совершенно избыточны, вся асинхронность обработки команд у нас спрятана внутри движка отправки комманд и сама асинхронность занимает в нём очень не много места, гораздо больше внимания уделено всяким проверкам, самопроверкам, и возможностям логирования и воспроизведения.
В общем, если вы уже умеете в UniRX то специально для вас я сделаю для моделей IObservable, и вы сможете там где нужно пользоваться козырными возможностями любимой библиотеки, но в остальном предлагаю не пытаться строить танки из скоростных автомобилей и автомобили из танков только на том основании что и у того и у другого есть колёса.
В конце статьи у меня к вам, дорогие читатели, традиционные вопросы, очень важные для меня, моих представлений о прекрасном, и для перспектив развития моего научно-технического творчества.