Вступление
Всем привет! На связи снова команда dev.family. Мы продолжаем разбирать тему интеграции Unity-проекта в приложение, написанное на React Native.
В предыдущей серии…
Мы начали с того, что поместили игру на Unity в наше приложение. Как это было, можно почитать в нашем блоге. Но пока части кода не взаимодействуют друг с другом, значит, работа не закончена. У нас есть кнопка «Save Result». Под нее было бы неплохо написать логику, чтобы показать, что у нас получилось.
Спойлер: этим и не только займемся прямо сейчас.
Что будет дальше
Во второй части статьи мы возьмем текущую связку React Native + Unity и сделаем так, чтобы одна часть кода могла получать и обрабатывать сообщения с другой. И наоборот.
Продолжаем наше Unity-journey!
Предупреждение
В материалах статьи мы разобрали процессы работы над тестовым приложением. Это не панацея и не четкое указание, как именно нужно писать код. Но оно классно сработало для нас, и мы будем рады, если вдохновим вас на решение схожих задач в разработке.
Подготовка приложения
Для начала поработаем с кодом на React Native.
const UnityScreen: React.FC> = ({
route,
}) => {
// Start
const unityRef = useRef<UnityView>(null);
const {messageToUnity} = route.params;
useEffect(() => {
if (messageToUnity) {
unityRef.current?.postMessage('', '', messageToUnity);
}
}, [messageToUnity]);
const handleUnityMessage = (message: string) => {
console.log(message);
};
//End
return (
<UnityView
ref={unityRef}
//@ts-expect-error UnityView needs a 'flex: 1' style to show full screen view
style={styles.flex}
onUnityMessage={e => handleUnityMessage(e.nativeEvent.message)} // and this line
/>
);
};- unityRef – ссылка на наш UnityView, чтобы взаимодействовать с ним;
- messageToUnity – сообщение при навигации с нашего начального экрана. Именно его мы будем передавать в метод postMessage в Unity;
- useEffect – проверка наличия messageToUnity или изменений в нем с его дальнейшей передачей в Unity;
- postMessage – передача сообщения в Unity по gameObject, в methodName наше сообщение;
- handleUnityMessage – наш метод для обработки сообщения с Unity.
Далее передаем наш unityRef и вызываем handleUnityMessage в UnityView.
Чтобы избежать лишние подсвечивания, добавим:
type RootStackParamList = {
[RootRoutes.HOME]: undefined;
[RootRoutes.UNITY]: {messageToUnity?: string}; // added messageToUnity
};Теперь на начальный экран (HomeScreen) добавим список с нашими результатами, где будем хранить 10 лучших попыток в игре.
// score data type
type Score = {
date: string;
score: number;
};
const HomeScreen: React.FC<RootStackScreenProps<RootRoutes.HOME>> = ({
navigation,
}) => {
const [scores, setScores] = useState<Score[]>([]); // scores to display in list
const insets = useSafeAreaInsets();
//List item to render
const renderScore: ListRenderItem<Score> = useCallback(({item, index}) => {
return (
<View style={styles.score}>
<Text style={styles.scoreText}>{index + 1}.</Text>
<Text style={[styles.scoreText, styles.flex]}>{item.score}</Text>
<Text style={styles.scoreDate}>{item.date}</Text>
</View>
);
}, []);
return (
<View style={[styles.screen, {paddingBottom: Math.max(insets.bottom, 15)}]}>
<Text style={styles.welcomeText}>
Hello, from{' '}
<Text style={[styles.welcomeText, styles.purple]}>dev.family</Text> team
</Text>
{/** scoreboard */}
<Text style={styles.welcomeText}>Scores 🏆:</Text>
<FlatList
data={scores}
renderItem={renderScore}
keyExtractor={i => i.date}
style={styles.list}
contentContainerStyle={styles.listContent}
ListEmptyComponent=<Text>You have no scoreboard yet</Text>
<TouchableOpacity
style={styles.button}
onPress={() => {
navigation.navigate(RootRoutes.UNITY, {messageToUnity: ''});
}}>
<Text style={styles.buttonText}>Go Unity</Text>
</TouchableOpacity>
</View>
);
};Теперь перейдем в Unity Editor.
Подготовка игры
Перед тем, как настроить получение сообщений, давайте добавим новое поле, которое будет отображать наш лучший результат.

Тут вставим два текстовых поля:
- Best – просто текст;
- Best Score Text в Canvas – для хранения значения нашего лучшего результата.
Вот, как это выглядит на экране:

Теперь давайте обсудим, что мы хотим получить от отправки сообщений в/из Unity:
- Нажатие на кнопку «Save result» должно перенести нас на главный экран, но предварительно передать наши очки с Unity (onUnityMessage), после чего обработать и записать их в нашу статистику.
- Запись лучшего результата в поле bestScore (как раз по вызову нашего метода postMessage) при следующем попадании на экран с игрой, и так по кругу.
Но для начала необходимо разобраться, как отправлять и обрабатывать сообщения с Unity.
Отправка сообщений с Unity
Первое, что потребуется, – добавить скрипт. Назовем его MessageToReactScript. В него мы вставим следующий код, он хранится на странице GitHub самой библиотеки:
using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine.UI;
using UnityEngine;
public class NativeAPI {
#if UNITY_IOS && !UNITY_EDITOR
[DllImport("__Internal")]
public static extern void sendMessageToMobileApp(string message);
#endif
}
public class ButtonBehavior : MonoBehaviour
{
public void ButtonPressed()
{
if (Application.platform == RuntimePlatform.Android)
{
using (AndroidJavaClass jc = new AndroidJavaClass("com.azesmwayreactnativeunity.ReactNativeUnityViewManager"))
{
jc.CallStatic("sendMessageToMobileApp", "The button has been tapped!");
}
}
else if (Application.platform == RuntimePlatform.IPhonePlayer)
{
#if UNITY_IOS && !UNITY_EDITOR
NativeAPI.sendMessageToMobileApp("The button has been tapped!");
#endif
}
}
}Это скрипт понадобится для отправки сообщения с Unity.
Теперь немного модифицируем его, чтобы получить нужный результат:
using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine.UI;
using UnityEngine;
public class NativeAPI
{
#if UNITY_IOS && !UNITY_EDITOR
[DllImport("__Internal")]
public static extern void sendMessageToMobileApp(string message);
#endif
}
//Score class
public class Score
{
public int score;
public string date;
public Score(int score, string date)
{
this.score = score;
this.date = date;
}
}
public class MessageToReactScript : MonoBehaviour
{
private Text _score;
public void ButtonPressed()
{
//getting current date in ISO format
var date = DateTime.Now.ToString("o");
//getting current score from UI
_score = GameObject.FindGameObjectWithTag("Score").GetComponent();
//creating an instance of Score class
Score score = new(int.Parse(_score.text), date);
//pare score object to json (we sending a string)
string scoreJSON = JsonUtility.ToJson(score);
print(scoreJSON);
if (Application.platform == RuntimePlatform.Android)
{
using (AndroidJavaClass jc = new AndroidJavaClass("com.azesmwayreactnativeunity.ReactNativeUnityViewManager"))
{
//send message to android
jc.CallStatic("sendMessageToMobileApp", scoreJSON);
}
}
else if (Application.platform == RuntimePlatform.IPhonePlayer)
{
#if UNITY_IOS && !UNITY_EDITOR
//send message to iOS
NativeAPI.sendMessageToMobileApp(scoreJSON);
#endif
}
}
}Обратите внимание: в примере представлена возможность отправлять только строку. Поэтому будем передавать все в JSON формате, чтобы не усложнять себе жизнь.
Далее добавляем новый класс, описывающий наш рекорд. У него есть два поля – date и score. Текущие счет и дата записываются непосредственно в score. Все, что нужно сделать дальше, – обернуть Score instance в JSON и отправить в мобильное приложение.
Скрипт готов. Теперь создаем новый GameObject ReactBridge:

Помещаем в него наш скрипт:

Переходим в Canvas и находим кнопку «Save Result», которую создали еще в прошлый раз. Помещаем в нее наш метод ButtonPressed из сниппета описанного выше: вначале добавляем GameObject ReactBridge, выбираем script MessageToReactScript и далее сам метод ButtonPressed():

Теперь при нажатии на кнопку «Save Result» мы будем отправлять JSON c нашим результатом в мобильное приложение.
На этом основная подготовка к отправке сообщения с Unity закончена. Теперь нужно собрать билд и переустановить Unity билд в Android и iOS.
Для работы можно использовать файлы из репозитория библиотеки, которые лежат здесь.
Обработка сообщений с Unity в мобильной части
Давайте синхронизируемся. На данный момент у нас есть:
- пример Unity-проекта, которое отправляет сообщение с результатом в наше мобильное приложение.
- само мобильное приложение, которое, в свою очередь, обрабатывает сообщение с Unity.
Как пересобрать билд и поставить его в мобильное приложение, мы уже рассказали в первой части статьи.
Далее приступим к обработке сообщения. Для начала поставим alert, чтобы убедиться, что при нажатии на кнопку наше сообщение действительно отправляется.
const handleUnityMessage = (message: string) => {
//alert to show Unity message data
alert(message);
const score = JSON.parse(message) as Score;
console.log({score});
};Если делать все, как было описано выше, получим следующий результат:
Что мы видим? Сообщение отправляется, в нем отображаются данные о счете и времени, когда он был получен. Теперь давайте запишем их в наш стейт scores. Но для того, чтобы данные не были потеряны при перезагрузке приложения, мы запишем их в async-storage, перед этим установив саму библиотеку.
Вводим следующие команды:
yarn add @react-native-async-storage/async-storage
npx pod-install или cd ios && pod install && cd ..Возможно, кто-то предпочитает использовать для хранилища более быстрые варианты по типу mmkv, но здесь нам это не критично. Поэтому нам вполне хватит async-storage.
Теперь снова поработаем с кодом на React Native – перейдем на главный экран и запишем в список результатов новое значение.
Для этого перепишем обработку сообщения Unity следующим образом:
const handleUnityMessage = (message: string) => {
//alert to show Unity message data
const score = JSON.parse(message) as Score; //parse message to Score Object
if (score) {
unityRef.current?.unloadUnity();//unload Unity View
navigation.navigate(RootRoutes.HOME, {score}); //going to Home Screen with recent score
}
};Тут мы парсим наш JSON с результатом и после переходим на HomeScreen, передавая в параметрах сам результат.
В наши типы навигации мы также можем добавить данное изменение (оно поможет избежать ошибок от typescript):
type RootStackParamList = {
[RootRoutes.HOME]: {score?: Score}; // can get Score from Unity Screen
[RootRoutes.UNITY]: {messageToUnity?: string}; // added messageToUnity
};Далее мы модифицируем HomeScreen, чтобы при получении результата через навигацию, он записывал нам в storage и state новые данные. Важный момент: в нашем случае мы храним только 10 лучших результатов в порядке убывания. Вы же можете отображать сколько угодно в любой удобной последовательности.
Вот, что у нас вышло:
//func to setup scores from async storage on app open (we have no scores)
const setupScores = async () => {
const scoresJSON = await AsyncStorage.getItem('scores');
if (scoresJSON) {
setScores(JSON.parse(scoresJSON) as Score[]);
}
};
//setting up existed scores
useEffect(() => {
if (!scores.length) {
setupScores();
}
});
const setNewScores = async (score: Score) => {
//creating new scores with new one, includes filter & sort to show only 10 best results
const newScores = [...scores, score]
.sort((a, b) => b.score - a.score)
.slice(0, 10);
//setting new scores to async storage
await AsyncStorage.setItem('scores', JSON.stringify(newScores));
//setting new scores to scores' state
setScores(newScores);
//clean navigation score param
navigation.setParams({score: undefined});
};
useEffect(() => {
if (route.params?.score) {
setNewScores(route.params.score);
}
}, [route.params]);В данном блоке кода мы делаем следующее:
- Достаем наши прошлые результаты и записываем их, если они имеются.
- В useEffect смотрим, что у нас нет данных (в scores). Если это так, то записываем предыдущие сохраненные в кэше данные.
- Записываем новые результаты, учитывая полученные через навигацию, и чистим параметры.
- Смотрим в useEffect, появился ли в параметрах новый результат, и если так, то переписываем результаты.
Также мы немного работали с UI: добавили обработку даты, чтобы не выводить ее в формате ISO (он смотрится не очень, согласитесь?) и чуть изменили стили. Вы можете сделать то же самое, или поменять интерфейс, как вам угодно.
На выходе мы получили такой результат:
На выходе мы получили такой результат:
Теперь пересобираем все для Android и проверяем, что все работает, как и должно:
Большая половина нашего функционала готова, и мы смело можем передавать данные с Unity в React Native. Теперь время поработать над другой, не менее важной задачей, – передачей данных из React Native в Unity.
Обработка сообщений с React Native в Unity части
Surprise-surprise! Оказывается, для отправки сообщений с React Native у нас уже была заготовка готового блока кода:
const {messageToUnity} = route.params;
useEffect(() => {
if (messageToUnity) {
unityRef.current?.postMessage('', '', messageToUnity);// right here
}
}, [messageToUnity]);Здесь нужно посмотреть, есть ли в параметрах навигации messageToUnity. Если такой имеется, то вызвать функцию postMessage. Она, как видно из описания, принимает в себя следующие аргументы:
(gameObject: string, methodName: string, message: string)Следовательно, необходимо передать сюда наш игровой объект, указать его функцию и message.
Для этого воспользуемся объектом LogicManager и создадим ему новую функцию для обработки данного сообщения.
Далее переместимся в Unity и внесем последние изменения: в LogicManagerScript добавим новое поле, куда поместим BestScore, а затем создадим непосредственно саму функцию, которая будет изменять его значение:
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using TMPro;
public class LogicManagerScript : MonoBehaviour
{
private int _score;
[SerializeField]
private Text _scoreText;
[SerializeField]
private GameObject _gameOverScreen;
//best score field where we write value from RN part
public TextMeshProUGUI bestScoreText;
[SerializeField]
private GameObject _startScreen;
[SerializeField]
private GameObject _game;
[ContextMenu("Increase Score")]
public void IncreaseScore(int number)
{
_score += number;
_scoreText.text = _score.ToString();
}
//rewrite bestScoreText value with RN message
public void SetBestScore(string message)
{
bestScoreText.text = message;
}
public void RestartGame()
{
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}
public void GameOver()
{
_gameOverScreen.SetActive(true);
}
public void StartGame()
{
_startScreen.SetActive(false);
_game.SetActive(true);
}
}
Тут мы добавили:
- bestScoreText – текст, который достаем с UI;
- public void SetBestScore – функцию, которая принимает и помещает в текстовое значение bestScoreText message, передаваемый из React Native посредством функции postMessage.
Далее в Unity Editor поместим в наш LogicManagerScript (который находится внутри GameObject LogicManager ) текстовый элемент, чтобы значение нашего BestScoreText менялось.

Собственно на этом вся наша работа в Unity закончена. Но вы всегда можете добавить что-то еще или улучшить написанный код.
Осталось только в очередной раз пересобрать билд 🙂
Возвращаемся в React Native. Сначала изменим нашу функцию для перехода на страницу с Unity так, чтобы при наличии результатов мы доставали наивысшие из них и передавали в нашу игру.
Добавляем следующую функцию в HomeScreen:
const goUnity = () => {
let messageToUnity = '0';// set default value to 0
if (scores.length) {
// if we have scores select max value
// element with 0 index is the highest because we’ve sorted our scores
messageToUnity = scores[0].score.toString();
}
//go to Unity with max score = messageToUnity
navigation.navigate(RootRoutes.UNITY, {messageToUnity});
};После этого наш экран с Unity всегда будет ждать message. Далее передаем эту функцию в onPress нашей кнопки «Go Unity»:
<TouchableOpacity style={styles.button} onPress={goUnity}>//added a method
<Text style={styles.buttonText}>Go Unity</Text>
</TouchableOpacity>Добавляем значения в функцию для отправки сообщения в Unity. В UnityScreen изменяем/добавляем следующее:
const {messageToUnity} = route.params; // getting our message from route params
//creating message object (not necessary)
const message = {
gameObject: 'LogicManager',
method: 'SetBestScore',
message: messageToUnity,
};
//on getting message from route posting it to Unity
useEffect(() => {
if (messageToUnity) {
unityRef.current?.postMessage(
message.gameObject,
message.method,
message.message,
);
}
}, [messageToUnity]);В данном примере мы указываем, что gameObject и methodName, которым мы передаем наш message – это LogicManager и SetBestScore, соответственно. В то же время наш message хранит в себе наилучший результат, передаваемый с экрана HomeScreen (наш message – это строка, которая может хранить в себе не только текст, но и JSON-объекты).
А теперь давайте запустим приложения на iOS и Android и проверим, что все работает как мы и ожидали:
iOS:
Android:
Всё работает 🙌
Заключение
Нам понадобилось всего две подробных статьи, чтобы разобрать вариант интеграции Unity-проекта в приложение, написанное на React Native. Мы пошагово расписали алгоритм, по которому работали сами. Надеемся, наш опыт облегчит решение похожих задач или вдохновит на новые проекты. Все примеры кода вы найдете в блоке с ссылками ниже. Вы можете узнать о процессе чуть больше, изучив материалы про плагины в Unity.
А здесь представляем вам конечный код React Native (да, мы что-то меняли на ходу):
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import {
FlatList,
ListRenderItem,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import {
SafeAreaProvider,
useSafeAreaInsets,
} from 'react-native-safe-area-context';
import UnityView from '@azesmway/react-native-unity';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
enum RootRoutes {
HOME = 'Home',
UNITY = 'Unity',
}
type RootStackParamList = {
[RootRoutes.HOME]: { score?: Score };
[RootRoutes.UNITY]: { messageToUnity: string }; // added messageToUnity
};
type RootStackScreenProps =
NativeStackScreenProps;
const Stack = createNativeStackNavigator();
// score data type
type Score = {
date: string;
score: number;
};
const HomeScreen: React.FC> = ({
navigation,
route,
}) => {
const [scores, setScores] = useState([]); // scores to display in list
const insets = useSafeAreaInsets();
// func to setup scores from async storage on app open (we have no scores)
const setupScores = async () => {
const scoresJSON = await AsyncStorage.getItem('scores');
if (scoresJSON) {
setScores(JSON.parse(scoresJSON) as Score[]);
}
};
// setting up existed scores
useEffect(() => {
if (!scores.length) {
setupScores();
}
}, []);
const setNewScores = async (score: Score) => {
// creating new scores with new one, includes filter & sort to show only 10 best results
const newScores = [...scores, score]
.sort((a, b) => b.score - a.score)
.slice(0, 10);
// setting new scores to async storage
await AsyncStorage.setItem('scores', JSON.stringify(newScores));
// setting new scores to scores' state
setScores(newScores);
// clean navigation score param
navigation.setParams({ score: undefined });
};
useEffect(() => {
if (route.params?.score) {
setNewScores(route.params.score);
}
}, [route.params]);
const goUnity = () => {
let messageToUnity = '0';
if (scores.length) {
messageToUnity = scores[0].score.toString();
}
navigation.navigate(RootRoutes.UNITY, { messageToUnity });
};
// List item to render
const renderScore: ListRenderItem = useCallback(({ item, index }) => {
return (
<View style={styles.score}>
<Text style={styles.scoreText}>{index + 1}.</Text>
<Text style={[styles.scoreText, styles.flex]}>{item.score}</Text>
<Text style={styles.scoreDate}>{new Date(item.date).toLocaleString()}</Text>
</View>
);
}, []);
return (
<View style={[styles.screen, { paddingBottom: Math.max(insets.bottom, 15) }]}>
<Text style={styles.welcomeText}>
Hello, from{' '}
<Text style={[styles.welcomeText, styles.purple]}>dev.family</Text> team
</Text>
{/* scoreboard */}
<Text style={styles.welcomeText}>Scores 🏆:</Text>
{!!scores.length && (
<View style={[styles.row, styles.scoreInfo]}>
<Text style={[styles.scoreText, styles.flex]}>Score</Text>
<Text style={styles.scoreText}>Date</Text>
</View>
)}
<FlatList
data={scores}
renderItem={renderScore}
keyExtractor={(i) => i.date}
style={styles.list}
contentContainerStyle={styles.listContent}
ListEmptyComponent={<Text>You have no scoreboard yet</Text>}
/>
<TouchableOpacity style={styles.button} onPress={goUnity}>
<Text style={styles.buttonText}>Go Unity</Text>
</TouchableOpacity>
</View>
);
};
const UnityScreen: React.FC> = ({ route, navigation }) => {
// Start
const unityRef = useRef<UnityView>(null);
const { messageToUnity } = route.params;
const message = {
gameObject: 'LogicManager',
method: 'SetBestScore',
message: messageToUnity,
};
useEffect(() => {
if (messageToUnity) {
unityRef.current?.postMessage(message.gameObject, message.method, message.message);
}
}, [messageToUnity]);
const handleUnityMessage = (json: string) => {
// alert to show Unity message data
const score = JSON.parse(json) as Score;
if (score) {
// unityRef.current?.unloadUnity();
navigation.navigate(RootRoutes.HOME, { score });
unityRef.current?.unloadUnity();
}
};
// End
return (
<View style={styles.flex}>
<UnityView
ref={unityRef}
//@ts-expect-error UnityView needs a 'flex: 1' style to show full screen view
style={styles.flex}
onUnityMessage={(e) => handleUnityMessage(e.nativeEvent.message)} // and this line
/>
</View>
);
};
const App = () => {
return (
<View style={styles.flex}>
<StatusBar backgroundColor={'#FFF'} barStyle="dark-content" />
<SafeAreaProvider>
<NavigationContainer>
<Stack.Screen name={RootRoutes.HOME} component={HomeScreen} />
<Stack.Screen name={RootRoutes.UNITY} component={UnityScreen} />
</Stack.Navigator>
</NavigationContainer>
</SafeAreaProvider>
</View>
);
};
const styles = StyleSheet.create({
screen: {
flex: 1,
paddingHorizontal: 16,
gap: 30,
paddingTop: 25,
},
button: {
width: '100%',
backgroundColor: 'purple',
justifyContent: 'center',
alignItems: 'center',
height: 50,
borderRadius: 16,
marginTop: 'auto',
},
purple: { color: 'purple' },
buttonText: {
color: '#FFF',
fontSize: 16,
fontWeight: '600',
},
welcomeText: {
fontSize: 24,
color: 'black',
fontWeight: '600',
},
flex: {
flex: 1,
},
row: { flexDirection: 'row' },
scoreInfo: { paddingHorizontal: 14 },
score: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingBottom: 6,
borderBottomWidth: 1,
borderColor: '#bcbcbc',
},
scoreText: {
fontSize: 18,
fontWeight: '500',
color: 'black',
},
scoreDate: {
color: '#262626',
fontSize: 16,
fontWeight: '400',
},
list: {
flex: 1,
},
listContent: {
flexGrow: 1,
paddingBottom: 20,
gap: 12,
},
});
export default App;На связи была команда dev.family, еще спишемся ;)