В этой статье я расскажу вам историю о том, как наша команда создала мультиплатформенный, полноценный игровой движок с MVI-архитектурой, полностью на Kotlin! Вы также узнаете о том, как я реализовал безумные требования от нашего заказчика при работе над этим движком. Так что давайте начнём!
Я работаю над приложением Overplay. Оно похоже на TikTok, но видео, которые вы видите - это на самом деле игры, в которые можно играть прямо во время скролла. Однажды я красил очередную кнопку, когда заказчик пришёл ко мне, чтобы обсудить производительность приложения и опыт запуска и завершения игры. Короче говоря, проблема, которая мучила нас годами - это легаси игровой движок.
Он всё ещё использовал XML на Android и содержал 7 тысяч строк легаси-кода, большая часть которого была мёртвой, но всё равно выполнялась, убивая производительность. Опыт не был плавным, игра загружалась медленно (20 секунд на загрузку игры было для нас обычным делом), и всё лагало. У нас также было много противных крашей, связанных с параллелизмом и управлением стейтом, потому что десятки разных частей движка хотели отправлять события и обновлять стейт игры одновременно. Команда понятия не имела, как решить эти проблемы - наша текущая простая MVVM-архитектура не справлялась. Только вьюмодель содержала 2000 строк кода, и любое изменение ломало что-то ещё.
Так что заказчик сказал что пора наконец пофиксить это дело. Но новые требования, которые он хотел реализовать, были просто безумными:
- Игровой движок должен быть встроен напрямую в ленту игр, чтобы пользователь мог проскроллить дальше, как только закончит игру. Это значит, что он должен находиться внутри другой страницы и приносить с собой всю логику!
- Игровой движок должен запускать игры меньше чем за 2 секунды. Это значит, что всё должно управляться параллельно и в фоне, пока пользователь скроллит!
- Если пользователь переигрывает или перезапускает игру, загрузка должна быть мгновенной. Таким образом, мы должны держать движок запущенным и управлять ресурсами динамически, никаких viewModelScope!.
- Каждое действие пользователя должно быть покрыто аналитикой, чтобы продолжать улучшать его в будущем.
- Игровой движок должен поддерживать все виды видео, включая локальные, на случай если кто-то хочет сделать свою игру и поиграть в неё.
- Так как пользователь скроллит видео как в TikTok, нам нужно эффективно освобождать и переиспользовать наши медиа-кодеки и видеоплееры, чтобы плавно прыгать туда-сюда между воспроизведением игрового видео и другими элементами ленты.
- Все ошибки всегда должны обрабатываться, отправляться в крашлитику и не прерывать игровой процесс, чтобы мы больше не портили опыт пользователей крашами.
Буду честен, я думал, что меня уволят.
“Невозможно реализовать эту безумную логику за 1 спринт” - думал я. Половина приложения должна легко встраиваться, и стейт всегда должен быть консистентным, с сотнями обновлений стейта, происходящих одновременно: сенсоры устройства, наш графический движок, видеоплеер и многое другое. Всё должно переиспользоваться везде и загружаться параллельно. Чтобы вбить последний гвоздь в гроб, количество кода тоже нужно держать маленьким, чтобы команда могла вносить будущие изменения в движок, не стреляя себе в ногу.
Но мне пришлось это сделать, избежать этого было никак нельзя. Конечно, я не мог бы сделать это один. Огромное уважение команде:
- Один участник взял наш графический движок и сделал его совместимым с Compose, так как без Compose мы бы это точно не сделали.
- Другой разработчик потратил время на создание модуля для Game Loop, который отправляет события и управляет графическим движком.
Подготовка
Так что в итоге я отвечал за загрузку игры и общую интеграцию. И я подумал - ну, эти требования не о фичах, они об архитектуре. Моя задача была реализовать архитектуру, которая поддерживает всё это. Легче сказать, чем сделать…
Вот упрощённая диаграмма того, как выглядела моя финальная архитектура:

Важная вещь, которую нужно понять перед тем, как мы начнём - это то, что чтобы реализовать новую архитектуру, я вдохновился Ktor и их потрясающей системой “плагинов”, которые формируют цепочку ответственности и перехватывают любые входящие и исходящие события. Почему бы не использовать это для любой бизнес-логики, подумал я? Это новый подход к архитектуре приложений, потому что раньше мы делали такое только с CQRS на бэкенде или в сетевом коде.
К счастью, это уже было реализовано в архитектурном фреймворке, который мы использовали - FlowMVI - так что мне не нужно было писать новый код для этого, мне просто нужно было творчески использовать систему плагинов. Но фреймворк был создан для UI, а не для игровых движков! Мне пришлось внести в него некоторые изменения, если я не хотел, чтобы меня уволили.
Так что в течение следующих двух недель я потратил время на реализацию поддерживающей инфраструктуры:
- Я добавил кучу новых плагинов, которые позволят мне инжектить любой код в любое место жизненного цикла игрового движка. Мы поговорим о них через момент.
- Я запускал бенчмарки сотни раз, сравнивая скорость обработки интентов с самыми быстрыми решениями, чтобы получить максимальную производительность. Я работал над кодом, пока не оптимизировал библиотеку до точки, когда она вошла в топ-5 по производительности среди 35+ сравниваемых фреймворков и стала такой же быстрой, как использование простого Channel (из корутин).
- Я реализовал новую систему для отслеживания цепочки вызовов плагинов, которая позволила мне прозрачно мониторить процессы в любой бизнес-логике, которую я очень креативно назвал “Decorators”.
Я также поставил себе требование - ЛЮБОЙ кусок логики должен быть отдельной штукой в коде движка, которую можно удалить и изменить по требованию. Код не должен располагаться в одном классе. Моя цель была - я сохраню код движка меньше 400 строк.
Это было похоже на то, как я вооружаюсь до зубов как какой-то секретный агент из фильма.
Поехали.
Начало работы - Contract
Прежде всего, давайте определим простое семейство MVI-стейтов, интентов и сайд-эффектов для нашего движка. Я использовал IDE-плагин FlowMVI, набрал fmvim в новом файле и получил это:
internal sealed interface GameEngineState : MVIState {
data object Stopped : GameEngineState
data class Error(val e: Exception?) : GameEngineState
data class Loading(val progress: Float = 0f): GameEngineState
data class Running(
override val game: GameInstance,
override val player: MediaPlayer,
override val isBuffering: Boolean = false,
) : GameEngineState
}
internal sealed interface GameEngineIntent : MVIIntent {
// ...
}
internal sealed interface GameEngineAction : MVIAction {
data object GoBack : GameEngineAction
}
Я также добавил стейт Stopped (так как наш движок может существовать даже когда человек не играет), и значение прогресса в стейт загрузки.
Настройка нашего движка
Я начал с создания синглтона под названием Container, который будет хостить зависимости. Мы должны держать его как синглтон и запускать/останавливать все его операции по требованию, чтобы поддержать мгновенный переигрыш игр и кэширование. Мы попытаемся установить в него кучу плагинов для управления нашей логикой. Итак, чтобы создать его, я набрал fmvic в пустом файле, а затем добавил конфигурацию:
internal class GameEngineContainer(
private val appScope: ApplicationScope,
userRepo: UserRepository,
configuration: StoreConfiguration,
pool: PlayerPool,
// ...
) : Container<GameEngineState, GameEngineIntent, GameEngineAction>, GameLauncher {
override val store by lazyStore(GameEngineState.Stopped) {
configure(configuration, "GameEngine") // 1
configure {
stateStrategy = Atomic(reentrant = false) // 2
allowIdleSubscriptions = true
parallelIntents = true // 3
}
}
}
Таким образом, мы можем легко инжектить зависимости здесь. “Store” - это объект, который будет хостить наш GameState, отвечать на GameIntents и отправлять события в UI (GameActions).
- Здесь я прозрачно инжекчу что-то в store, используя DI, подробнее об этом чуть позже.
- Во время моих бенчмарков я выяснил, что транзакции стейта с повторным входом (о которых я говорил в моей прошлой статье) убивали производительность. Они в 15 раз медленнее, чем без повторного входа! Время всё ещё измеряется в микросекундах, так что имеет смысл использовать их для простого UI, но нам нужно было выжать каждую каплю мощности CPU для движка. Я добавил поддержку этого в последнем обновлении, что сократило время до наносекунд на событие!
- Всё должно было быть параллельным для игрового движка, чтобы сохранить скорость, так что я включил параллельную обработку. Но если мы не синхронизируем доступ к стейту, у нас будут те же race conditions, что и раньше! Включив этот флаг, одновременно сохраняя атомарные транзакции стейта, я достиг лучшего из обоих миров: скорости и безопасности!
Вот что у нас уже есть:
- Скорость
- Потокобезопасность
- Возможность держать ресурсы загруженными по требованию
- Аналитика и отчёты о крашах
“Подождите”, можете спросить вы, “но здесь нет ни одной строчки кода аналитики в сниппете!”, и я отвечу - магия в инжектнутом параметре configuration.
Он устанавливает кучу плагинов прозрачно. Мы можем добавить любую логику в любой контейнер, используя концепцию плагинов, так почему бы не использовать их с DI? Эта функция устанавливает плагин обработки ошибок, который ловит и отправляет исключения в аналитику, не влияя на остальной код движка, отслеживает действия пользователя (Intents) и события посещения и ухода с экрана игрового движка тоже. Иметь огромный игровой движок, загаженный кодом аналитики - это для нас неприемлемо, потому что у нас была эта проблема с MVVM - всё просто накапливается и накапливается, пока не становится неподдерживаемым. Больше такого не будет.
Запуск и остановка движка
Окей, мы создали наш Container лениво. Как теперь мы чистим и отслеживаем ресурсы?
Особенность FlowMVI в том, что это единственный фреймворк, который я знаю, позволяющий останавливать и перезапускать компонент бизнес-логики (Store) по требованию. Каждый store имеет StoreLifecycle, который позволяет вам контролировать и наблюдать за store, используя CoroutineScope. Если scope отменён - store тоже, но store также может быть остановлен отдельно, гарантируя, что наша иерархия родитель-потомок всегда соблюдается.
Мои коллеги поначалу скептически отнеслись к этой фиче, и какое-то время я думал, что она бесполезна, но в этот раз она буквально спасла меня от увольнения: мы можем просто использовать глобальный application scope для запуска нашей логики и останавливать движок, когда он нам не нужен, чтобы не потреблять ресурсы!
Для реализации мы просто позволим Container имплементировать интерфейс под названием GameLauncher, который даст доступ к жизненному циклу:
override suspend fun awaitShutdown() = store.awaitUntilClosed()
override fun shutdown() = store.close()
override suspend fun start(params: GameParameters) {
val old = this.parameters.getAndUpdate { params }
when {
!store.isActive -> store.start(appScope).awaitStartup() // 1
old == params -> store.intent(ReplayedGame) // 2
else -> { // 3
store.closeAndWait()
store.start(appScope).awaitStartup()
}
}
}
Тогда код из других модулей будет просто использовать интерфейс, чтобы останавливать движок, когда ему не нужна запущенная игра (например, пользователь проскроллил дальше, вышел из приложения и т. д.), и вызывать start каждый раз, когда клиенты хотят, чтобы мы запустили игру. Но эта фича была бы только частично полезной для нас, если бы store не имел способа что-то сделать при выключении. Так что давайте поговорим об управлении ресурсами далее.
Управление ресурсами
У нас есть куча всего, что нужно инициализировать при старте игры параллельно:
- Удалённая конфигурация для feature flags
- Игровые ассеты, такие как текстуры, нужно загрузить и закэшировать
- Конфигурация игры и данные игры в JSON
- Инициализация медиа-кодека
- Буферизация и кэширование видеофайла
- И многое другое…
И почти всё это не может быть просто собрано сборщиком мусора. Нам нужно закрыть файловые хэндлы, выгрузить кодеки, освободить ресурсы, удерживаемые нативным кодом, и вернуть видеоплеер в пул для повторного использования, так как создание плеера - очень тяжёлый процесс.
И некоторые вещи на самом деле зависят от других, например, видеофайл зависит от конфигурации игры, откуда он берётся. Как нам это сделать?
Ну, для начала, я создал плагин, который будет использовать упомянутый выше колбэк, чтобы создать значение, когда движок запускается, и очистить значение, когда движок останавливается (упрощённый код):
public fun <T> cached(
init: suspend PipelineContext.() -> T,
): CachedValue<T> = CachedValue(init)
fun <T> cachePlugin(
value: CachedValue<T>,
) = plugin {
onStart { value.init() }
onStop { value.clear() }
}
CachedValue - это как lazy, но с потокобезопасным контролем того, когда очищать и инициализировать значение. В нашем случае он вызывает init, когда store запускается, и очищает ссылку, когда store останавливается. Супер просто!
Но у этого плагина всё ещё есть проблема, потому что он приостанавливает весь store до завершения инициализации, что означает, что наша загрузка будет последовательной, а не параллельной. Чтобы исправить это, мы можем просто использовать Deferred и запустить инициализацию в отдельной корутине:
inline fun <T> asyncCached(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.UNDISPATCHED,
crossinline init: suspend PipelineContext.() -> T,
): CachedValue<Deferred<T>> = cached { async(context, start) { init() } }
Тогда мы просто передаём наш asyncCached вместо обычного при установке плагина cache. Добавим немного DSL поверх этого, и получим следующую логику загрузки игры:
override val store by lazyStore(GameEngineState.Stopped) {
configure { }
val gameClock by cache {
GameClock(coroutineScope = this) // 1
}
val player by cache {
playerPool.borrow(requireParameters.playerType)
}
val remoteConfig by asyncCache { // 2
remoteConfigRepo.updateAndGet()
}
val graphicsEngine by asyncCache {
GraphicsEngine(GraphicsRemoteConfig(from = remoteConfig() // 2
}
val gameData by asyncCache {
gameRepository.getGameData(requireParameters().gameId)
}
val game by asyncCache {
GameLoop(
graphics = graphicsEngine(),
remoteConfig = remoteConfig(),
clock = gameClock,
data = gameData(),
params = requireParameters(),
).let { GameInstance(it) }
}
asyncInit { // 3
updateState { Loading() }
player.loadVideo(gameData().videoUrl)
updateState {
GameEngineState.Running(
game = game(),
player = player,
)
}
clock.start()
}
deinit { // 4
graphicsEngine.release()
player.stop()
playerPool.return(player)
}
}
- Наш игровой клок запускает цикл событий и синхронизирует время игры со временем видео. К сожалению, ему требуется coroutine scope, где он запускает цикл, который должен быть активен только во время игры. К счастью, у нас уже есть такой!
PipelineContext, который является контекстом выполненияStore, передаётся с плагинами и реализуетCoroutineScope. Мы можем просто использовать его в нашем плагинеcacheи запустить игровой клок, который автоматически остановится, когда мы выключим движок. - Вы можете видеть, что мы использовали кучу
asyncCache, чтобы распараллелить загрузку, и с Graphics Engine мы также смогли зависеть от удалённой конфигурации внутри (в качестве примера, на самом деле он зависит от многого). Это сильно упрощает нашу логику, потому что зависимости между компонентами теперь неявные, и запрашивающей стороне, которая хочет только графический движок, не нужно управлять его зависимостями! Оператор invoke (скобки) - это сокращение дляDeferred.await()для дополнительного сахарка в коде. - Мы также использовали
asyncInit, который по сути запускает фоновую джобу в текущем scope геймплея игрового движка для загрузки игры. Внутри джобы мы делаем финальные приготовления, ждём всех зависимостей и запускаем игровой клок. - Мы использовали встроенный плагин
deinit, чтобы поместить всю нашу логику очистки в колбэк, который вызывается, как только игровой движок остановлен (и его scope отменён). Он будет запущен до того, как наши кэшированные значения очистятся (потому что он был установлен позже), но после того, как наши джобы были отменены, так что мы можем делать что хотим, а плагинcacheзатем соберёт остальное без беспокойства об утечках.
В целом, эти 50 строк кода заменили 1.5 тысячи строк реализации нашего старого игрового движка! Я охренел, когда увидел готовый результат.
Но нам всё ещё не хватает одной вещи.
Обработка ошибок
Много вещей в движке может пойти не так во время геймплея:
- Какой-то автор игры забыл добавить фрейм в анимацию
- Человек потерял соединение во время игры
- Шейдеры не смогли отрендериться из-за бага платформы, и многое другое…
Обычно в приложениях обрабатываются только основные ошибки для API-вызовов с обёртками вроде ApiResult или какой-то try/catch. Но представьте, что нужно обернуть каждую строчку кода игрового движка в try-catch… Это означало бы сотни строк try-catch-finally мусора!
Ну, вы, наверное, знаете, что щас будет :). Так как мы теперь можем перехватывать любое событие, давайте сделаем плагин обработки ошибок! Я назвал его recover, и теперь наш код выглядит так:
override val store by lazyStore(GameEngineState.Stopped) {
configure { } // 1
val player by cache { }
recover { e ->
if (config.debuggable) updateState { // 1
GameEngineState.Error(e)
} else when(e) { // 2
is StoreTimeoutException, is GLException -> Unit
is MediaPlaybackException -> player.retry()
is AssetCorruptedException -> assetManager.refreshAssetsSync()
is BufferingTimeoutException -> action(ShowSlowInternetMessage)
// ...
else -> shutdown() // 3
}
null
}
}
- Если наш store настроен как отлаживаемый (
configдоступен в плагинах store), мы можем показать полноэкранный оверлей со стек-трейсом, чтобы наша QA-команда могла легко отправлять ошибки разработчикам до того, как они попадут в прод. Принцип Fail Fast в действии. - В проде, однако, мы будем обрабатывать некоторые ошибки, делая ретрай, пропуская анимацию или предупреждая пользователя о его соединении без прерывания геймплея.
- Если мы не можем обработать ошибку и не можем восстановиться, тогда мы выключаем движок и позволяем пользователю попробовать сыграть в игру снова, без краша приложения или показа непонятных сообщений (они идут в crashlytics).
С этим мы получили обработку ошибок для любого существующего и нового кода, который разработчик может когда-либо добавить в наш игровой движок, с 0 try-catch.
Финальные штрихи
Мы почти закончили! Эта статья становится длинной, так что я быстро пробегусь по дополнительным плагинам, которые мне пришлось установить для поддержки наших кейсов:
override val store by lazyStore(GameEngineState.Stopped) {
configure { }
val subs = awaitSubscribers() // 1
val jobs = manageJobs<GameJobs>() // 2
initTimeout(5.seconds) { // 3
subs.await()
}
whileSubscribed { // 4
assetManager.loadingProgress.collect { progress ->
updateState<Loading, _> {
copy(progress = progress)
}
}
}
install(
autoStopPlugin(jobs), // 5
resetStatePlugin(), // 6
)
}
- Так как разработчик может совершить ошибку, запустив игру, но никогда не показав фактический опыт геймплея (пользователь ушёл, баг, планы изменились и т. д.), я использую готовый плагин
awaitSubscribersв сниппете (3), чтобы проверить, появятся ли они в течение 5 секунд после запуска игры, и если нет, закрыть store и автоматически очистить удерживаемые ресурсы, чтобы предотвратить утечки. Готово! - Я использую другой плагин -
JobManager, чтобы запускать некоторые долгоживущие операции в фоне. Код, который его использует, не поместился, но в основном он нужен для отслеживания того, играет ли пользователь в данный момент. InitTimeout- это кастомный плагин, который проверяет, завершилась ли загрузка игры в течение 5 секунд, и если нет, мы передаём ошибку нашему плагинуrecover, чтобы решить, что делать, и отправить проблему в аналитику.- Плагин
whileSubscribedзапускает джобу, которая активна только когда подписчики (в нашем случае, UI) присутствуют, где мы обновляем визуалы прогресса загрузки только когда пользователь на самом деле видит экран загрузки. Это позволяет нам легко избежать утечек ресурсов, если игровой движок перекрыт чем-то или скрыт. autoStopPluginиспользует наш job manager, чтобы следить за прогрессом загрузки игры и прогрессом геймплея. Он смотрит, есть ли у нас подписчики, чтобы поставить игру на паузу, когда пользователь уходит, затем останавливает её, как только движок не используется какое-то время, исключая риск утечки памяти.resetStatePlugin- это встроенный плагин, который мне пришлось установить для автоочистки стейта, когда игра заканчивается. По умолчанию stores не сбрасывают свой стейт при остановке. Это хорошо для обычного UI, но не в нашем случае - мы хотим, чтобы движок возвращался к стейту Stopped, когда игра заканчивается.
Все эти плагины уже были в библиотеке, так что их использование было проще простого.
Заключение
Это была дикая история, но после всего этого я не только сохранил свою работу, но и думаю, что общее решение получилось довольно классным. Движок перешёл с 7+ тысяч строк в всего 400 строк читаемого, линейного, структурированного, производительного, расширяемого кода, и пользователи уже наслаждаются результатами:
- Время загрузки сократилось с ~20 секунд до всего 1.75 секунды!
- Краши игр упали с 8% до 0.01%!
- Мы улучшили пропускную способность обработки игровых событий на 1700%
- Случаи буферизации видео во время игр упали с ~31% до <10% благодаря нашему кэшированию
- Потребление батареи во время геймплея сократилось на порядки
- ANR во время геймплея стали статистически равны 0
- Нагрузка на GC уменьшилась на 40% во время геймплея
Надеюсь, к этому моменту я показал, почему паттерны, которые мы ненавидели, такие как Decorators, Interceptors и Chain of Responsibility, могут быть безумно полезными при создании не только какого-то бэкенд-сервиса, сетевого кода или специализированного кейса, но и при реализации обычной логики приложения, включая UI и управление стейтом.
С той мощью, которую даёт Kotlin при создании DSL, мы можем превратить фундаментальные паттерны (используемые в разработке ПО десятилетиями) из месива бойлерплейта, наследования и сложного делегирования в быстрый, прямолинейный, компактный, декларативный код, с которым весело и просто работать. Я призываю вас создать что-то подобное для архитектуры вашего собственного приложения.
И если вы не хотите погружаться в это и хотите что-то уже готовое, или вам любопытно узнать больше, тогда загляните в оригинальную библиотеку, где я реализовал всё, что упомянуто здесь, на GitHub, или сразу погрузитесь в quickstart-гайд, чтобы попробовать её за 10 минут.