Меня зовут Алексей Цуцоев, я разработчик мобильных приложений в KODE, и я хочу поделиться историей того, как мы внедряли игру в уже готовое React-Native приложение. Как выбирали технологию, с какими трудностями столкнулись и к каким выводам пришли. Прежде чем мы начнем, хотелось бы отметить несколько важных моментов:
Я не game-developer. Остальная команда также не имела опыта разработки игр. Я — просто мобильный разработчик, которому поставили задачу разработать и интегрировать игру в приложение.
У нас была выстроена инфраструктура CI/CD, которая может отличаться от вашей.
Я постарался сфокусироваться на процессе от аналитики и дизайна до интеграции и подготовки билда. Для одной библиотеки я отдельно подготовил инструкцию.
Начнем повествование с постановки задачи по внедрению игры, а закончим конкретными реализациями на различных технологиях.
Постановка задачи и преданалитика
Мы разрабатывали финтех-приложение для обмена и инвестиций в криптовалюте. В прошлом году, на Новый год, решили добавить акцию с лотереей: пользователь вращает виртуальный барабан и ему выпадает какой-то приз. Эта акция была признана мега-успешной. И мы решили развивать эту идею. Поскольку второй барабан продукт-овнер не хотела, решили добавить мини-игру. Выбрали клон Flappy Bird — с ограничением по количеству очков и своими ассетами. Исполнителем назначили меня.
Цель игры — поднять вовлеченность и напомнить о приложении. Насколько я знаю, цели мы достигли — продажи были подняты на 10%.
И первые трудности возникли уже на старте. Как упоминалось в предисловии — команда не имела опыта разработки игр. Поэтому, когда дизайнеры закончили макеты, те оказались мало пригодными для использования.
``Cпойлер
``
Дизайнер в продуктовой разработке мобильных приложений совсем не равен художнику в геймдеве. Основная проблема в том, что они не умели готовить ассеты, из которы я бы потом мог сделать текстуры, их PNG просто не получалось натянуть на игровые объекты.
Первый важный момент: предоставьте пример ассетов вашим дизайнерам.
Найдите открытый репозиторий кода с игрой, клон которой собираетесь делать, и предоставьте ассеты из этого репозитория вашим дизайнерам. Это серьезно упростит процесс и сэкономит время на старте.
После нескольких итераций подготовки макетов и ассетов мы, наконец, сделали что-то, с чем я бы мог работать, что нравилось дизайнерам и продукт-овнеру. Так что мы приступили к, с позволения сказать, гейм-дизайну.
На этом этапе необходимо максимально продумать, как пользователь будет играть. Склонировать core-gameplay Flappy Bird, или вашего аналога, скорее всего, не составит труда. Проблемы начнутся, когда придётся сделать шаг влево или вправо от изначальной механики: добавить ограничение по очкам, упростить сложность или что-либо ещё, что будет отличать вашу игру от изначальной. И тут проблема не в технической реализации — в целом, большинство вещей будет правиться парой строк кода. Сложность в том, чтобы сделать это весёлым.
Звучит весьма банально, но QA окажется как нельзя кстати — опыт тестировщиков в "а если пользователь сделает вот так?" будет невероятно полезен уже на этапе аналитики. Таким образом вы сможете покрыть максимальное количество кейсов. Забегая вперёд, мы с аналитиком упустили парочку моментов, которые пришлось дорабатывать после тестирования.
Второй момент: если кажется, что продумано всё — подумайте ещё.
Так или иначе, дизайн готов, какой бы то ни было гейм-дизайн — тоже. Пришло время выбрать, как будем делать саму игру.
Поиск технологии
Для начала я взял некоторое время на ресерч и определил круг технологий, которые я смогу применить. Всерьёз рассмотрел три варианта:
Сделать всё на React Native, Skia и React Native Reanimated
Сделать игру нативно на UIKit/SwiftUI под iOS и Compose под Android
Завести один из игровых движков: Unity, Godot, Unreal или React Native Game Engine + matter-js
Определившись с шорт-листом номинантов, я пошёл методом исключения.
Почему не React Native?
Причины просты: все мы любим React Native за удобство и скорость разработки, но ещё ни один разработчик мобильных приложений в этой вселенной не сказал: "я люблю React Native за его производительность". У меня были серьёзные опасения, что перфоманс на не флагманских девайсах будет неудовлетворительным. Кроме того, пришлось бы самостоятельно реализовывать механизмы спавна-удаления игровых объектов и коллизии. Посовещавшись с командой, мы решили, что делать на React Native нам не подходит.
Почему не нативно?
Это решение 100% работало бы быстрее, чем RN + Skia + RNR, даже на слабых девайсах, но проблема реализации спавна-удаления объектов и коллизии никуда не девается. Кроме того, при выборе нативной реализации астрологи объявляют месяц дублирования кода — количество игр увеличивается вдвое. Да, выбирая нативную реализацию, мы обрекаем себя на разработку и поддержку сразу двух игр: одну для iOS, вторую для Android. А это означает, что любую правку от менеджмента или баг сразу умножаем на два.
Остаются игровые движки
Unreal мы сразу отмели, поскольку экспертизы в С++ в команде не было. Почти сразу за ним отправился React Native Game Engine из-за недоверия к этой технологии — последний коммит по существу был оставлен летом 2020-го.
А вот дальше интереснее: остаются Unity и Godot.
Плюсы Unity:
Популярная и понятная технология с миллионами туториалов
Есть несколько библиотек для интеграции в React Native
Условно-бесплатное распространение (рекомендую внимательно ознакомиться с правилами лицензирования перед релизом)
Плюсы Godot:
Полностью бесплатный
Легковеснее Unity (что нужно для простой игры в духе клон Flappy Bird)
Можно вести разработку на gdScript – питонообразный язык, который с нуля будет проще и быстрее освоить, чем C#, который необходим для Unity.
Но имелся один большой минус, перечёркивающий всё — для него существует единственная библиотека интеграции игры в React Native, и она не поддерживает Android. А нас это категорически не устраивает. Таким образом, выбор сам собой сузился до Unity. Наконец-то мы можем приступить к разработке.
Внедрение Unity
Про интеграцию Unity в React Native уже написано немало — и статей, и туториалов. Плюс библиотека, которую мы будем использовать (@azesmway/react-native-unity), имеет вполне приличную документацию. Но, как всегда, дьявол в деталях. Поэтому ниже — пошаговое описание процесса с указанием нюансов.
Установка Unity и создание проекта
Заходим на официальный сайт Unity и скачиваем Unity Hub.
Через него устанавливаем нужную версию движка. В моём случае это была Unity 6 (6000.0.36f1).

3. В Unity Hub создаём новый проект:
Жмём New Project
Выбираем шаблон 2D (Mobile)
Даём имя, указываем папку, запускаем
Игру писать с нуля мы не стали — взяли готовый туториал, адаптировали под свои нужды и заменили ассеты. Сам код геймплея сюда не включаю — это отдельная тема. Уверен, вы легко найдёте десятки руководств по Flappy Bird под Unity.
После окончания разработки игры необходимо экспортировать исходники под каждую платформу в React-Native. Начнем с подготовки самого проекта для этой интеграции.
Теперь нам понадобится библиотека @azesmway/react-native-unity. Она поможет интегрировать игру в наше React-Native приложение.
4. Устанавливаем зависимость по инструкции.
yarn add @azesmway/react-native-unity && npx pod-nstall
Могут возникнуть проблемы с кешами. После выгрузки новых iOS-исходников имеет смысл очищать поды, но об этом позже. После установки зависимости необходимо экспортировать исходники под каждую платформу. Начнем с iOS, так как там меньше нюансов.
iOS
Важный нюанс: пока в проекте присутствует Unity, сборка под iOS Simulator работать не будет. Только физическое устройство. Это означает, что вы теряете привычный цикл быстрой отладки на симуляторе. К этому стоит подготовиться заранее.
В первую очередь в unity-проект необходимо добавить код, чтобы ios могла общаться с unity. Нам потребуется два файла: NativeCallProxy.h и NativeCallProxy.mm. Им можно найти в репозитории самой библиотеки.
Вот что нужно сделать:
В Unity откройте File → Build Settings и выберите платформу iOS. Нажмите Switch Platform.
Затем откройте Player Settings. В разделе Other Settings укажите: Bundle Identifier, версию, IL2CPP как Scripting Backend
Перейдите в раздел Identification и настройте Team ID и Provisioning Profile, если необходимо.
В проекте добавьте файлы NativeCallProxy.h и NativeCallProxy.mm в Assets/Plugins/iOS.
Вернитесь в Build Settings, нажмите Build, выберите папку (не внутрь RN-проекта!) — Unity сгенерирует Xcode-проект.
Откройте его и выполните ручные настройки:
У Data и NativeCallProxy.h измените Target Membership на UnityFramework
Установите public для NativeCallProxy.h, чтобы он был доступен
В Xcode выберите таргет UnityFramework, схему Any iOS Device (arm64) и соберите проект (Cmd + B).
Найдите полученный UnityFramework.framework в Xcode → Products, щёлкните правой кнопкой, выберите Show in Finder, и скопируйте его в папку unity/builds/ios внутри вашего RN-проекта.
Android
Теперь нужно повторить то же упражнение, но уже для Android. В этот раз буду краток.
Сборка Android-проекта в Unity
В Unity выбираем Android как платформу.
По необходимости настраиваем Player Settings.
Экспортируем исходники для Android (как делали для iOS).
Первая волна правок
Далее следуем инструкции из библиотеки:
В AndroidManifest.xml (ROOT_OF_YOUR_ANDROID_UNITY/unityLibrary/src/main/AndroidManifest.xml) находим и удаляем тег <intent-filter> вместе с его содержимым.
Вносим настройки в:
build.gradle (корневой),
settings.gradle,
strings.xml.
Вторая волна правок (эксклюзив!)
На этом библиотечная инструкция заканчивается, но я раскопал еще пару мест, которые нуждаются во внимании:
Глобальным поиском по проекту ищем
mobilenotifications.androidlib
и удаляем в конфигаруционных файлах все что с ним связано.После этого делаем Sync Gradle.
В структуре проекта появится одноимённый модуль — его можно удалить (необязательно, он и так не участвует в сборке, но зачем держать лишний мусор?).
Фокус с enableOnBackInvokedCallback
Возвращаемся к AndroidManifest.xml, где мы уже удаляли интенты.
Там есть тег <application> и в нём параметр: android:enableOnBackInvokedCallback="false"
Нужно убедиться, что это значение совпадает с тем, что указано в основном проекте. Эту настройку я не встречал ни в одном туториале или статье. Пришел к ней методом проб и ошибок. Без нее проект отказывался собираться. Дело в том, что она конфликтовала с нашим манифестом. Так что пришлось перевести значение в true.
Финал: копируем проект
После всех танцев с бубном — копируем весь игровой Android-проект целиком и вставляем его в: ROOT_YOUR_RN_PROJECT/unity/builds/android
Unity 6 не поддерживается (но мы хитрые)
Текущая версия библиотеки официально не поддерживает Unity 6, но мы можем это быстро починить.
Открываем файл: android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java
Находим метод: public FrameLayout requestFrame()
И правим catch-блок следующим образом:
diff --git a/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java b/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java
index b944779adbf2392ad8fed91bfd0f5a93c7fff28d..f9668b64827fe161717676bf1b562630e68b4d3b 100644
Вот тебе и «патч с любовью». Я оформил его с помощью yarn patch, если что.
Интеграция
Честно скажу, я боялся, что это окажется самой сложной частью. Но к моему удивлению и радости, это оказалось максимально просто. Следуем инструкции из библиотеки и все будет хорошо. Можем подготовить методы для общения игры со стороны Unity. Я сделал простенький класс, который выглядел примерно так:
Этот код позволит нам отправлять ивенты в React Native.
Теперь нужно создать GameObject с этим компонентом в сцене и вызывать нужные методы в нужных местах. Примеры можно найти в документации библиотеки или в Unity-проекте.
Интеграция React Native → Unity
Теперь двигаемся в обратную сторону — React Native шлёт команды в Unity. Всё тоже довольно просто. В RN у нас будет доступен ref на unityView, у которого можно вызывать метод postMessage. Он принимает три строки:
gameObject — важно! Это имя объекта, а не класса. Старайтесь, чтобы имя объекта совпадало с названием класса, обрабатывающего сообщение. Так будет меньше путаницы.
methodName — метод, который вы хотите вызвать на стороне Unity.
message — строка, которую передаём. Это может быть обычный текст, а может быть и JSON.stringify(object).
В моем коде это выглядело примерно так:
Теперь интеграция готова в обе стороны. Нам остается просто протестировать логику и проверить, что все работает хотя бы локально.
Пара советов
UI: Unity или React Native
Можно, конечно, сверстать интерфейс прямо внутри Unity — у него для этого есть вполне приличные инструменты. Но есть нюанс: забудьте о вашей теме. Все шрифты, цвета и отступы придётся прописывать вручную, без поддержки системных стилей.
Поэтому мой совет: делайте интерфейс в React Native и размещайте его поверх Unity-вьюхи с помощью абсолютного позиционирования. Так вы сохраните визуальную консистентность с остальным приложением, плюс получите бесплатную поддержку светлой/тёмной темы.
Разделяйте логику: геймплей vs бизнес
Старайтесь отделить игровую логику от бизнес-логики. Пусть Unity полностью отвечает за геймплей, физику, анимации и прочее веселье, а React Native — за всё, что связано с данными, аналитикой, навигацией и прочими взрослыми делами.
Идеально, если Unity просто отправляет события, а RN уже решает, что с ними делать.
Минимизируйте связь Unity ↔ RN
Каждое взаимодействие между Unity и RN — потенциальный источник багов, особенно если вы ещё не полностью уверены в стабильности геймплея. Постарайтесь сократить такие вызовы до минимума. Чем проще канал связи, тем спокойнее жизнь.
Тестируйте игру в изоляции
Перед тем как интегрировать игру в приложение, тщательно тестируйте её отдельно. Я, например, собирал отдельные билды под macOS, чтобы дать возможность всем заинтересованным пощупать игру без обёртки.
Это важно. Изменения в игровом процессе — штука неизбежная. Кто-то захочет изменить музыку, кто-то — сложность, кто-то — скорость появления врагов. А теперь вспомните, у нас нет hot reload'а. И будет совсем обидно, если вы закончите интеграцию, и тут вам скажут: «А давай сделаем другую музыку?».
Тогда вам придется идти обратно в Unity Hub, вносить правки, тестировать, а потом… заново проходить через экспорт и подготовку исходников под платформы. То есть все, что я описывал в пунктах iOS и Android, придется делать заново. Это не страшно, но порядком утомительно.
Проблемы только начинаются
А сейчас я расскажу о реальных проблемах, с которыми мы столкнулись при развёртывании приложения. Я не нашел информации на эту тему. Ни в одной статье. Именно они и стали толчком к написанию этой статьи — хочется вас предупредить, а в идеале — вообще отговорить от использования Unity в вашем проекте. И начнём мы с самого очевидного.
iOS Simulator? Забудьте
Библиотека честно предупреждает: Unity не запускается на iOS Simulator. И это значит ровно то, что написано. Пока у вас в проекте есть Unity-игра — о симуляторе можно забыть. И это, мягко говоря, неудобно. Все React Native-разработчики, которых я знаю (и я в их числе), используют iOS-симулятор как основное средство разработки, прибегая к Android-эмулятору лишь для сверки. Так что подключая Unity, вы фактически лишаетесь привычного способа разработки.
Решения? Увы:
использовать реальное iOS-устройство;
или переключиться на Android-эмулятор.
Вес Unity-файлов
Во всех статьях и туториалах говорится, что экспортированный промежуточный код занимает не так уж и много места. Но мои сборки занимали очень много места на диске.
Я даже консультировался с Unity-разработчиками — вдруг я что-то делаю не так? Но серьёзных косяков никто не нашёл. Похоже, дело в версии Unity: чем новее, тем прожорливее.
Пример:
Flappy Bird под iOS — ~250 MB,
Flappy Bird под Android — ~800 MB (!). И это только исходники. Финальный билд, к счастью, выходит не таким огромным.
Но вся эта масса кода должна попасть в репозиторий. Git не обрадуется файлу на 250 MB. А вы не обрадуетесь, когда git push просто откажется это проглатывать.
Варианты решений:
Удалять ненужные файлы вручную — звучит просто, но без опыта работы с Unity понять, что можно удалить, а что нет, практически невозможно.
Оптимизировать экспорт в PlayerSettings: сжать текстуры, поиграться с Graphic API, Scripting Backend и т.д. Я пробовал — толку мало. Может, у вас получится лучше. Если да — расскажите в комментах, я серьёзно. В моем случае разница составила менее 1%.
Git LFS — наше финальное решение. Устанавливаете, подключаете, трекаете большие файлы, пушите. Хорошее ли это решение? Трудно сказать, но на момент написания статьи другого я не нашел.
CI/CD: добро пожаловать в ад
Ошибки в CI/CD — это худшее, что может случиться. Почему? Потому что у вас всё работает, а на билд-машине — нет. Нужно каждый раз фиксить проблему наугад, заливать, ставить сборку, ждать 20-40 минут и надеяться, что проблема решена.
И именно тут у нас возник целый каскад трудностей.
Сначала у нас падали iOS-сборки, потому что на билд-машине не было unityFramework. Проблема оказалась в Git LFS — он не подтягивался по умолчанию на нашу билд-машину. Но это мы решили с третьей попытки.
Потом мы увидели проблему в Android. Она заключается в том, что по умолчанию Unity экспортирует проект с захардкоженным ndkPath. Выглядит он примерно так:

Как вы можете догадаться, на вашей билд-машине не будет никакой папки Applications, и уж тем более — Unity/Hub, откуда можно было бы подтянуть нужную версию NDK. Поэтому нам придётся ещё пошаманить и заменить путь к NDK на что-то вроде:

Мы будем прокидывать путь к нашему NDK из наших CI-скриптов. Для GitHub Actions есть специальный action, который это делает. Если у вас GitLab — можно настроить вручную. Тут в нагрузку добавляются проблемы с версионированием этих NDK, потому что где-то указана полная версия (как на скринах выше), а в репозитории NDK они указаны с буквенными обозначениями, так что придётся ещё поискать нужную. 🙂
И в этот момент мы начали ловить ошибки в CMAKE_C_COMPILER и CMAKE_CXX_COMPILER.
И… это стало концом. Я понял, что уже неделю пытаюсь наладить сборки, озадачил своей проблемой примерно половину техлидов компании и всех знакомых, кто мог хоть как-то помочь. И всё это не дало результатов. После ошибок CMAKE будет ещё сотня похожих. А релиз всё ближе.
Именно в этот момент я отказался от реализации Unity в нашем проекте и сделал быстрое решение на React Native Game Engine. О котором речь пойдёт дальше. Там будет сильно короче — обещаю.
Промежуточный итог
Вся секция про Unity может показаться бесполезной — ведь мы так и не довели игру до продакшена. Но я так не считаю.
Во-первых, мы подсветили множество нюансов, о которых до этого никто не писал. Во-вторых, эта часть может оказаться полезной тем, кто просто хочет добавить игру в свой пет-проект, без необходимости заморачиваться с полноценным развёртыванием и поддержкой. А может, кто-то решится пройти весь этот путь от начала до конца. В таком случае он потратит меньше времени на уже изученные и описанные здесь грабли.
Если кто-то из читателей всё-таки доведёт интеграцию Unity до production, и поделитесь опытом в комментариях Тогда у нас — как у сообщества React Native-разработчиков — наконец появится готовая инструкция по решению не самой тривиальной задачи.
Пара слов о Unity в отрыве от интеграции в RN
Мне очень понравился dev experience при разработке игры на Unity: редактор интуитивно понятный, с кучей полезных функций, много готовых шаблонов, всё работает быстро, и туториалов — хоть залейся. А ещё — отличная производительность.
Я тестировал игру в нашем приложении на Samsung Galaxy A11, и всё работало как часы, без тормозов. Более того, я бы даже сказал, что игра в приложении работала лучше самого приложения.
React Native Game Engine
Тут все будет невероятно просто:
Переходим на репозиторий библиотеки.
Читаем документацию
Находим туториал по созданию нужной вам игры
Готово
Напомню, что мы здесь больше рассуждаем о разработке игр в составе RN-приложения, а не составляем пошаговое руководство по разработке конкретной игры. За такими подробностями можно обратиться к статьям из серии «Как сделать GAME NAME с помощью React Native Game Engine». Я же постараюсь разобрать плюсы и минусы этого подхода.
Начнем с плюсов:
Разработка на JS/TS, никаких новых ЯП.
Понятное API.
В отличии от Unity доступен Hot Reload.
Но на каждое преимущество технологии найдётся свой недостаток
"Game Engine" в названии — лукавство.
Инструментария для отладки практически нет. Хотите проверить уничтожение или спавн объектов? Делайте это вручную. Изолированного тестирования игрового процесса — нет. Проверка коллизий? Только визуально, с помощью бордеров. Создание и удаление объектов — через console.log. Да, серьёзно.Дополнительные зависимости. Придётся подключить:
matter.js — для физики,
ещё одну библиотеку — для воспроизведения музыки.
Но, будем честны: для JS-разработчика пара новых зависимостей — не повод для паники.
Производительность страдает
Было сразу понятно, что RNGE не сравнится с Unity по производительности. Но реальность оказалась хуже, чем я ожидал. На флагманах (Realme 14 Pro, Samsung Galaxy S20FE, S22, iPhone 14 Pro) всё выглядело хорошо. А вот на бюджетных устройствах — производительность была на грани допустимого.
Возможные утечки памяти
В какой-то момент я заметил, что приложение с запущенной игрой начинает постепенно увеличивать потребление оперативной памяти. Я запустил игру с пустым полем — без объектов, без физики — но потребление всё равно росло. Более того, каждый новый запуск игры потреблял ещё больше памяти, несмотря на то, что я строго следовал инструкциям по корректной остановке движка.
Трудно сказать, была ли проблема в самом RNGE или в моём коде — без полноценного инструментария это проверить невозможно. Поэтому я не выношу этот пункт в отдельный "минус", но посчитал важным его упомянуть.
Компромисс, который заработал
Как и ожидалось, React Native Game Engine — крайне компромиссное решение: производительность и инструментарий явно оставляют желать лучшего.
Но! Все заработало. И, как вы могли заметить по супер-короткой инструкции, заработало буквально сразу — без миллиона подводных камней, которые утянули на дно нашу интеграцию с Unity.
В итоге главной проблемой RNGE стала производительность. Но поскольку релиз был буквально «вчера», мы признали это решение «good enough» и выпустили игру.
На этом история разработки и интеграции заканчивается. Я остался не до конца доволен результатом и хотел, уже без спешки, попробовать переехать на Godot после релиза.
Если вы помните, мы изначально отказались от Godot потому, что библиотека для интеграции с React Native не поддерживала Android, а писать свою интеграцию тогда было некогда. Сейчас я как раз этим и занимаюсь. Godot, с Android-поддержкой, выглядит как идеальный вариант для внедрения игр в RN-приложения:
значительно проще Unity,
заметно легче — исходники занимают меньше места,
производительность на хорошем уровне.
Но процесс интеграции — не быстрый. Поэтому этот материал выходит без подробностей по Godot — возможно, это будет тема для следующего текста.
Вместо заключения
Внедрение игры в уже готовое, живое и не самое новое React Native-приложение — это действительно интересный и ценный опыт. И хотя идеального решения я пока не нашёл, кажется, что я на пути к нему.
Если у вас есть подобный опыт или идеи, как могу бы упросить путь – жду вас в комментариях.