Эпиграф:
— Как я тебе оценю, если неизвестно что делать?
— Ну там будут экранчики и кнопочки.
— Дима, ты сейчас всю мою жизнь описал в трёх словах!
(с) Реальный диалог на митинге в игровой компании
Набор потребностей и отвечающих им решений, о которых я поговорю в этой статье, сформировался у меня в ходе участия примерно в десятке крупных проектов сначала на Flash, а позже на Unity. Самый крупный из проектов имел больше 200000 DAU и дополнил мою копилку новыми оригинальными вызовами. С другой стороны, подтвердилась уместность и нужность предыдущих находок.
В нашей суровой реальности каждый, кто хоть раз архитектурил крупный проект хотя бы в своих мыслях, имеет свои представления о том, как надо делать, и часто готов отстаивать свои идеи до последней капли крови. У окружающих это вызывает улыбку, а менеджмент часто смотрит на всё на это как на огромный чёрный ящик, который никому углом не упёрся. Но что если я скажу вам, что правильные решения помогут сократить создание нового функционала в 2-3 раза, поиск ошибок в старом в 5-10 раз, и позволят делать многие новые и важные вещи, которые раньше были вообще недоступны? Достаточно лишь впустить архитектуру в сердце своё!
Модель
Доступ к полям
Большинство программистов осознают важность использования чего-нибудь наподобие MVC. Мало кто использует чистый MVC из книжки банды четырёх, но все решения у нормальных контор так или иначе схожи с этим паттерном по духу. Сегодня мы поговорим про первую из буковок в этой аббревиатуре. Потому что большая по объёму часть работы программистов в мобильной игре это новые фичи в метаигре, реализуемые как манипуляции с моделью, и прикручивание к этим фичам тысяч интерфейсиков. И удобство модели играет в этом занятии ключевую роль.
Полный код не привожу, потому что его немножечко дофига, и вообще речь не о нём. Свои рассуждения буду иллюстрировать простейшим примером:
public class PlayerModel {
public int money;
public InventoryModel inventory;
/* Using */
public void SomeTestChanges() {
money = 10;
inventory.capacity++;
}
}
Такой вариант нам вообще никак не подходит, потому что модель не рассылает событий о происходящих в ней изменениях. Если информацию о том, какие поля были затронуты изменениями, а какие нет, и какие надо перерисовывать, а какие нет, программист будет указывать вручную в той или иной форме — именно это и станет основным источником ошибок и затрат времени. И только не надо делать удивлённые глаза.В большей части крупных контор, в которых я работал, программист сам отправлял всякие InventoryUpdatedEvent, а в некоторых случаях ещё и вручную их заполнял. Некоторые из этих контор зарабатывали миллионы, как думаете, благодаря или вопреки?
Воспользуемся неким своим классом ReactiveProperty который будет прятать под капотом все манипуляции по рассылке сообщений, которые нам нужны. Получится примерно так:
public class PlayerModel : Model {
public ReactiveProperty<int> money = new ReactiveProperty<int>();
public ReactiveProperty<InventoryModel> inventory = new ReactiveProperty<InventoryModel>();
/* Using */
public void SomeTestChanges() {
money.Value = 10;
inventory.Value.capacity.Value++;
}
public void Subscription(Text text) {
money.SubscribeWithState(text, (x, t) => t.text = x.ToString());
}
}
Это первый вариант модели. Такой вариант — уже мечта для многих программистов, но мне все ещё не нравится. Первое, что мне не нравится, что обращения к значениям осложнены. Я успел запутаться, пока писал этот пример, забыв в одном месте Value.А ведь именно эти манипуляции с данными составляют львиную часть всего, что с моделью делают и в чём путаются. Если вы пользуетесь версией языка 4.x можно делать так:
public ReactiveProperty<int> money { get; private set; } = new ReactiveProperty<int>();
но это решает далеко не все проблемы. Хотелось бы писать просто: inventory.capacity++;. Допустим мы попытаемся для каждого поля модели сделать get; set; Но для того чтобы подписываться на события нам потребуется ещё и доступ к самому ReactiveProperty. Явное неудобство и источник для путаницы. При том, что нам требуется только указать, за каким именно полем мы собираемся следить. И вот тут я придумал хитрый маневр, который мне понравился.
Посмотрим, понравится ли вам.
В конкретную модель, с которой имеет дело программист, пишущий правила, вставляется не сам ReactiveProperty, а его статический описатель PValue, наследник более общего Property, он идентифицирует поле, а внутри под капотом конструктора Model спрятано создание и хранение ReactiveProperty нужного типа. Не самое удачное название, но так сложилось, потом переименую.
В коде это выглядит так:
public class PlayerModel : Model {
public static PValue<int> MONEY = new PValue<int>();
public int money { get { return MONEY.Get(this); } set { MONEY.Set(this, value) } }
public static PModel<InventoryModel> INVENTORY = new PModel<InventoryModel>();
public InventoryModel inventory { get { return INVENTORY.Get(this); } set { INVENTORY.Set(this, value) } }
/* Using */
public void SomeTestChanges() {
money = 10;
inventory.capacity++;
}
public void Subscription(Text text) {
this.Get(MONEY).SubscribeWithState(text, (x, t) => t.text = x.ToString());
}
}
Это второй вариант. Общий предок Model, конечно при этом осложнился, за счёт создания и добывания реального ReactiveProperty по его описателю, но это можно сделать очень быстро и без рефлекшена, вернее применив рефлекшен всего один раз на этапе инициализации класса. И это работа, которая делается один раз и создателем движка, а использоваться будет потом всеми. Кроме того, такое оформление позволяет избежать случайных попыток манипулировать самим ReactiveProperty вместо хранящихся в нем значений. Загромождется само создание поля, но оно во всех случаях совершенно одинаковое, и его можно создавать шаблоном.
В конце статьи есть опрос какой вариант вам больше нравится.
Всё, что описано дальше, можно реализовать в обоих вариантах.
Транзакции
Я хочу, чтобы программисты могли изменять поля модели только тогда, когда это допускается принятыми в движке ограничениями, то есть внутри команды, и никогда больше. Для этого сеттер должен куда-то сходить и проверить, открыта ли в данный момент транзакция-команда, и только после этого разрешать править информацию в модели. Это очень нужно, потому что пользователи движка регулярно пытаются сделать что-то странное в обход типичного процесса, ломая логику движка и порождая трудноуловимые ошибки. Видел такое не раз и не два.
Существует вера, что если сделать отдельно интерфейс для чтения данных из модели и для записи, это как-то поможет. В реальности модель обрастает дополнительными файлами и нудными дополнительными операциями. Эти ограничения конечные.Программисты вынуждены, во-первых, знать и постоянно о них думать: “что должна отдавать каждая конкретная функция, модель или её интерфейс”, а во-вторых, так же постоянно возникают ситуации когда эти ограничения приходится обходить, так что на выходе имеем д’Артаньяна, который весь в белом это придумал, и множество пользователей его движка, которые плохие гвардейцы Проджект-менеджера, и, несмотря на постоянную ругань, ничего не работает так как предполагалось. Поэтому я предпочитаю просто намертво заблокировать возможность такую ошибку совершать. Уменьшаем дозу конвенций, так сказать.
Сеттер ReactiveProperty должен иметь ссылку на место, где текущее состояние транзакции проверять. Допустим этим местом будет класcModelRoot. Самый простой вариант — передавать его в конструктор модели в явном виде. Второй вариант кода при вызове RProperty получает ссылку на this в явном виде, и может оттуда достать всю нужную информацию. Для первого варианта кода придётся в конструкторе рефлекшеном обежать все поля типа ReactiveProperty и раздать им ссылку на this для дальнейших манипуляций. Небольшое неудобство заключается в необходимости создавать в каждой модели явный конструктор с параметром, как-то так:
public class PlayerModel : Model {
public PlayerModel(ModelRoot gamestate) : base (gamestate) {}
}
Но для других возможностей моделей очень полезно, чтобы модель имела ссылку на родительскую модель, образуя двухсвязную конструкцию. В нашем примере это будет player.inventory.Parent == player. И тогда этого конструктора можно избежать. Любая модель сможет получить и закэшировать ссылку на волшебное место у своего родителя, а тот у своего родителя, и так пока очередной родитель не окажется тем самым волшебным местом. В итоге на уровне деклараций всё это будет выглядеть так:
public class ModelRoot : Model {
public bool locked { get; private set; }
}
public partial class Model {
public Model Parent { get; protected set; }
public ModelRoot Root { get; }
}
Заполняться вся эта красота будет автоматически при попадании модели в дерево геймстейта. Да, у только что созданной модели, которая ещё туда не попала, не будет возможности узнать о транзакции и заблокировать манипуляции с собой, но если состоянием транзакций это запрещено, попасть в состояние она после этого никак не сможет, ей сеттер будущего родителя не позволит, так что неприкосновенность геймстейта не пострадает. Да, это потребует дополнительной работы на этапе программирования движка, но зато программиста, пользующегося движком, это полностью избавит от необходимости знать и думать об этом пока он не попытается сделать что-то не то, и не поймает за это эксепшеном по рукам.
Раз уж зашёл разговор о транзактности, сообщения об изменениях должны обрабатываться не сразу после того, как изменение произведено, а только тогда, когда все манипуляции с моделью в рамках текущей команды закончены. Причин тут две, первая — консистентность данных.Не все состояния данных внутренне непротиворечивы.Возможно, его нельзя пытаться отрисовывать. Или если Вам приспичило, например, отсортировать массив или менять какую-то переменную модели в цикле. Вы не должны получить сотню сообщений об изменениях.
Сделать это можно двумя способами. Первый заключается в том, чтобы, подписываясь на обновления переменной, пользоваться хитрой функцией, которая к потоку изменений переменной присовокупит поток окончаний транзакций, и пропускать сообщения дальше будет только после них. Это достаточно легко сделать, если вы используете UniRX, например. Но этот вариант имеет много недостатков, в частности порождает много ненужной движухи. Лично мне нравится другой вариант.
Каждый ReactiveProperty будет помнить своё состояние до начала транзакции и текущее свой состояние. А сообщение об изменении и фиксацию произведённых изменений будет производить только по окончании транзакции. В случае, когда объектом изменения являлся некоторый коллекшен, это позволит в рассылаемое сообщение в явном виде включить информацию о произошедших изменениях.Например, такие два элемента в списке добавились, а такие два удалились. Вместо того чтобы просто сказать, что что-то изменилось, и заставить получателя самого анализировать список длиной в тысячу элементов в поисках информации, что нужно перерисовать.
public partial class Model {
public void DispatchChanges(Command transaction);
public void FixChanges();
public void RevertChanges();
}
Вариант более трудоёмкий на этапе создания движка, но зато потом меньше затраты на использование. И что самое главное, открывает возможность для следующего усовершенствования.
Информация о произведённых в модели изменениях
Я хочу от модели большего. В любой момент хочу легко и удобно увидеть, что изменилось в состоянии модели в результате моих действий. Например, в таком виде:
{"player":{"money":10, "inventory":{"capacity":11}}}
Чаще всего программисту полезно видеть diff между состоянием модели до начала команды и после её окончания, или на какой-то момент внутри команды. Некоторые для этого клонируют весь геймстейт до начала команды, и потом сравнивают. Это частично решает проблему на этапе отладки, но запускать такое в проде решительно нельзя. Что клонирование стейта, что вычисление незначительной разницы между двумя списками –это чудовищно затратные операции, чтобы делать их при любом чихе.
Поэтому ReactiveProperty должен хранить не только свое нынешнее состояние, но и предыдущее. Это порождает ещё целую группу крайне полезных возможностей. Во-первых, извлечение разницы в такой ситуации происходит быстро, и мы можем спокойненько вывалить это всё в прод. Во-вторых, можно получить не громоздкий diff, а компактненький hash от изменений, и сравнить его с хэшем изменений в другом таком же геймстейте. Если не сошлось — у вас проблемы. В-третьих, если выполнение команды упало с эксепшеном, всегда можно отменить изменения и узнать о неиспорченном состоянии на момент начала транзакции. Вместе с примененной к стейту командой эта информация бесценна, потому что Вы легко можете в точности воспроизвести ситуацию. Конечно для этого потребуется иметь готовый функционал удобной сериализации и десериализации геймстейта, но он вам в любом случае понадобится.
Сериализация произведённых в модели изменений
В движке предусмотрена сериализация и бинарная, и в json – и это не случайно. Конечно бинарная сериализация занимает сильно меньше места и работает сильно быстрее, что важно, особенно при первоначальной загрузке. Но это не человекочитаемый формат, а мы тут молимся на удобство отладки. Кроме того, есть ещё один подводный камень. Когда ваша игра пойдёт в прод, вам потребуется постоянно переходить с версии на версию. Если ваши программисты соблюдают некоторые незатейливые предосторожности и не вычёркивают из геймстейта ничего без необходимости, вы этого перехода чувствовать не будете. А в бинарном формате строковых имён полей по понятным причинам нет, и при несовпадении версий вам придётся прочитать бинарник старой версией стейта, экспортировать во что-то более информативное, например, в тот же json, после чего импортировать в новый стейт, экспортировать в бинарник, записать, и только после всего этого работать дальше как обычно. В итоге в некоторых проектах конфиги пишут в бинарники в виду их циклопических размеров, а уже стейт предпочитают таскать туда-сюда в виде json. Оценивать накладные расходы и выбирать Вам.
[Flags] public enum ExportMode {
all = 0x0,
changes = 0x1,
serverVerified = 0x2, // Про это поговорим позже, когда затронем интерфейсы
}
/** более простая версия */
public partial class Model {
public bool GetHashCode(ExportMode mode, out int code);
public bool Import(BinaryReader binarySerialization);
public bool Import(JSONReader json);
public void ExportAll(ExportMode mode, BinaryWriter binarySerialization);
public void ExportAll(ExportMode mode, JSONWriter json);
public bool Export(ExportMode mode, out Dictionary<string, object> data);
}
Сигнатура метода Export(ExportMode mode, out Dictionary<string, object> data) несколько настораживает. А дело тут вот в чём: Когда вы сериализуете всё дерево писать можно сразу в поток, или в нашем случае в JSONWriter, являющийся простенькой надстройкой над StringWriter. Но когда вы экспортируете изменения не всё так просто, потому что когда вы обходя дерево в глубину заходите в одну из ветвей вы ещё не знаете нужно ли из неё экспортировать вообще хоть что-нибудь. Поэтому на этом этапе я придумал два решения, одно попроще, второе посложнее и поэкономнее. Более простое сводится к тому, что экспортируя только изменения вы превращаете все изменения в дерево из Dictionary<string, object> и List
Автор: kraidiky