В 1994 появилась книга "Банды четырёх" про шаблоны проектирования. Однако истинную популярность у нас она снискала где-то в 2010-х: вопросы про шаблоны стали самими любимыми на собеседованиях. Бессмысленными, но любимыми. Постепенно, мода прошла, но шаблоны остались.
В чём смысл шаблонов проектирования
Нет, разумеется, шаблоны проектирования не являются чем-то выдающимся, наподобие слёз Чака Норриса. По сути, это всего лишь набор подходов для решения типовых задач, многие из которых мы используем даже не задумываясь (потому что это логично) в том или ином виде. Однако самое главное в шаблонах проектирования - это определение и именование концепции.
Все шаблоны разбирать не будем, однако на примере использования покажу, как их применение позволяет сделать код более эффективным, функциональным и расширяемым.
Для измерения эффекта буду использовать доступные данные о расходе памяти вкладкой (внутри DevTools) и замерять время отрисовок.
Входные данные
Давайте предположим, что мы пишем игру, в которой будет отрисовываться некоторая карта, по которой будет перемещаться игровой персонаж (его мы не будем реализовывать, но будем держать в памяти, что он есть).
Для начала напишем 4 класса объектов, которые будут отвечать за анимированную отрисовку четырёх разных объектов карты: траву, камыш, цветок и дорога.
Начало решения
Допустим, мы доверили написание кода четырём разным разработчикам и появилось нечто такое:
Получилось так себе: все будто на разных языках говорят. Ну ничего, давайте попробуем дописать недостающие участки кода и анимировать всё это.
Для демонстрации создадим демо-сцену на несколько тысяч изображений и попробуем всё это отрисовать.
Обработчик изображения должен быть только один
Так мы подходим к первому шаблону - одиночка (singleton). Этот шаблон гарантирует, что будет создан только один экземпляр класса. Например, в рамках текущей задачи нам нужно создать холст для отрисовкии объекты и объекты, а также быть уверенным, что повторный вызов не приведёт к дублированию данных.
Разумеется, есть немало способов решить подобную задачу, да и для разных языков она будет решаться по-разному. Но коль скоро мы работаем с javascript, предложу решение для него.
И вот такой пример результата: в конструкторе создаётся случайное число и потом выводится.
a@b:~/project/$ node main.js
267
267
a@b:~/project/$
Как видим, пересоздание класса возвращает тот же самый результат. Я поменяю код, с тем, чтобы создавался холст и устанавливались данные.
Первый результат
Я уже сгенерировал случайный массив элементов и дополнил классы для вывода изображений. Теперь в конструкторе будут создаваться экземпляры классов и они будут отвечать за отрисовку изображения.
И метод отрисовки:
Конечно, всё выглядит не то, чтобы оптимально. Метод render я ещё доработаю, чтобы изображения на экране обновлялись в соответствии с текущим состоянием. Это часть нашей задачи.
Первичное подведение итогов
У нас есть работающее приложение, реализующее задуманное, но сколько же к нему вопросов!
Вкладка потребляет 2,13 Мб памяти. Немного, но для начала можно запомнить.
Первый цикл отрисовки выполнился за 44 милисекунды, но все последующие, в среднем, отрабатываются примерно за 20 милисекунд. В принципе, 41 милисекунда на кадр - вполне соответствует 24 кадрам в секунду. За анимацию сойдёт, но не оптимально, совсем не оптимально.
Тут надо оговориться: у меня спрайты сильно пережимаются в процессе отображения и это накладывает отпечаток на результат. Но так даже лучше.
Унификация интерфейса
Прежде чем что-либо делать, надо бы унифицировать интерфейс: классы работают кто на что горазд, имена разные, правила вызова метода разные. Давайте напишем абстрактный класс, который будет содержать основные методы.
В javascript отсутствуют интерфейсы - набор необходимых методов - поэтому пойдём по пути абстрактных классов: это как обычный класс, только попытка его выполнения приведёт к ошибке; промежуточный шаг между интерфейсом и классом.
Наличие такого вот обобщённого класса позволит обращаться единообразно со всеми анимированными спрайтами. Сейчас я перепишу код всех классов изображений в соответствии с новыми правилами.
На примере класса Grass я покажу, как меняется содержимое класса: кое-что я закомментировал (это нам более не нужно), также добавил реализацию 2 методов nextFrame и render, которые содержат теперь существующие методы. Разумеется, можно просто переименовать методы или немного их изменить, но я специально хочу наглядно показать было-стало.
Унификация интерфейсов позволила сократить количество кода. Я попробовал несколько раз обновить страницу, но результаты были следующими:
- максимальная скорость первой отрисовки - 30 милисекунд
- количество затраченной памяти - 1,86 Мб
И если с первой метрикой (скорость первой отрисовки) могут быть какие-либо вопросы: тут есть простор для махинаций, то относительно второй я немало удивился. Размер потребления памяти сократился почти на 13%.
Кстати...
Мы только что использовали шаблон "Фабричный метод": благодаря унификации интерфейса мы можем добавлять сколько угодно других спрайтов и коль скоро методы у всех единые - то и результаты будут получаться схожими.
Мы более не зависим от фантазий сторонних разработчиков. Обязав их использовать наш интерфейс (в текущем случае - абстрактный класс) - мы сделали свой код более универсальным.
Оптимизация памяти
Давайте будем честны - у нас крайне простые классы, которые хранят очень мало данных. В среднем, данные одного класса занимают 48 байт + некоторое количество данных, расходуемых на поддержание работоспособности объектов. По факту заявленного потребления памяти - возможно меньше.
На 10000 объектов это составит 480 000 байт. Пустяковые значения. Но давайте представим, что каждый объект хранит изображения, каждое из которых "весит" всего-навсего 1 килобайт. Я провёл такую симуляцию и вкладка сразу заняла в памяти 125.13 Мб - против 1.86 Мб.
И что делать?
А тут на помощь нам придёт ещё один шаблон проектирования - "Хранитель" (он же memento). Если коротко, то для случая, когда у нас есть объекты с общими и частными данными, он предлагает вынести состояние (некоторые параметры) в виде набора данных (или отдельного класса) из объекта и, перед выполнением класса, просто предоставлять ему данные состояния.
Раз у нас 4 типа спрайтов - значит у нас будет всего 4 экземпляра класса, а все расходные данные (координаты и текущий шаг отрисовки) будут храниться в отдельном массиве.
Вообще, шаблон "Хранитель" используется не столько для оптимизации, сколько для выноса состояния с возможностью последующего восстановления (например, для отката изменений), но ведь ничто же нам не мешает использовать возможность выноса состояния вовне для оптимизаций!
Существует довольно много способов реализации данного шаблона (канонических, зависимых от языка, зависимых от требований). Я же, в интересах кода, буду выносить два типа данных:
- ссылку на экземпляр класса, который должен обработать данные;
- данные, которые необходимо обработать;
Я изменил абстрактный класс, теперь он выглядит так:
Благодаря таким изменениям, мы теперь свободно выносим данные, необходимые для обработки вовне.
Однако поменять пришлось и инициализацию,
отрисовку тоже пришлось обновить:
Увы, такая оптимизация привела к негативному эффекту:
- первая отрисовка - 41 миллисекунда
- потребляемая память - 2,19 Мб
Как видим, оптимизация ухудшила начальные показатели (вдвое увеличилось время первой отрисовки, рост потребления памяти составил почти 18%.
Однако, в режиме эмуляции "тяжёлых" классов, объём потребляемой памяти составил всего 2,31 Мб (против, напомню, почти 125 Мб).
Таким образом, не все оптимизации позволяют нам улучшить прямую производительность, но дают возможность внести некоторую экономию. Кстати говоря, "тяжёлые" данные, также, можно было вынести как состояние и предоставлять по необходимости.
Доработка отрисовки
И тут нам, разумеется, понадобилось несколько унифицировать отрисовку спрайтов, добавив отладочные данные. По идее, ничего не стоит взять и дополнить код каждого из методов, но разве это наш путь?
А тут нам на помощь придёт шаблон проектирования Декоратор. Декорировать можно как отдельный метод (функцию), так и класс целиком (мы пойдём по этому пути.
Если кратко, то он предлагает модификацию поведения, но требует полное интерфейсное совпадение. Главное преимущество - возможность добавить что-то новое в текущее поведение (например, вместо "17" выводить "17.00" рублей).
Кстати, шаблон декоратор очень похож на шаблон прокси. Главная разница в том, что если декоратор, если обобщить, немного меняет конечный результат, то прокси предполагает какие-то дополнительные проверки, кэширования или отложенные инициализации (например, создавать некоторый "тяжёлый" класс не сразу, а лишь когда он, действительно, понадобится).
Добавим обёртку - получим результат без переписывания всего кода:
Красные квадратики, появляющиеся на спрайтах - это та самая "отладочная информация", которую мы добавили. Позволяет подсчитывать кадры переключения анимаций.
Осталось за кадром
Кстати, раз уж мы пишем на JS, то необходимо упомянуть ещё несколько шаблонов проектирования, которые использовались у нас в коде, но мы на это и не обращали внимания:
- Прототип - лежит в основе модели классов и объектов языка;
- Итератор - используется в большинстве переборах (циклах).
P.S. Управление героями и злодеями
И, напоследок, хочу упомянуть довольно популярную модель игрового взаимодействия, о которой следует помнить, но о которой редко кто думает: модель управления персонажами.
Прямой метод, который увеличивает или уменьшает смещение по оси икс, при нажатии кнопок влево или вправо - не плох. Но ровно до тех пор, пока пользователь не захочет их переназначить или не появится необходимость управления персонажем по сети.
И вот тут нам на помощь приходит шаблон Наблюдатель.
Если коротко, то он реализует модель издатель/подписчик. Издатель публикует какие-то данные (например, нажатие кнопки влево), соответствующий подписчик вызывается каждый раз, когда публикуется событие. Нажали влево - создали событие - отправили событие - передали подписчику события - подписчик обработал событие и подвинулся влево.
Кстати, ничего не мешает сделать модель более сложной: если игровой персонаж упёрся в стену, он может уведомить об этом звуковой модуль, чтобы тот выдал звук глухого удара.
Заключение
На простых примерах мы рассмотрели довольно популярные шаблоны проектирования, хотя их, разумеется, больше.
Свод шаблонов проектирования - это не священная книга, а набор подходов, позволяющих эффективно решать широкий круг задач.
В данной статье, мы немного пооптимизировали простые механизмы игрового пространства и получилось, надо сказать, довольно неплохо. Но, разумеется, не каждая оптимизация улучшает всё и сразу. Чаще всего приходится выбирать, за счёт памяти или процессора будем оптимизироваться. Хотя иногда получается сэкономить и там, и там.