Embedding Unity in React Native: Building a Hybrid iOS Architecture
March 8, 2021
The problem: Building a construction app with complex AR visualization and a polished user experience. React Native handles navigation, authentication, project management. Unity handles BIM rendering, AR anchoring, 3D interaction.
Standard approach: build two separate apps. Switch between them using deep links. Poor UX. Fragmented experience.
We needed one seamless app. React Native as the shell. Unity loaded on demand for AR sessions. Single binary. Single Xcode project. Clean communication between both runtimes.
Architecture Overview
The integration follows a host-guest pattern:
React Native — Primary app shell. Handles all UI, navigation, authentication, data management. This is what users interact with 95% of the time.
Unity — AR engine loaded as an embedded framework. Activates only when users enter AR viewing mode. Renders BIM models, handles marker tracking, processes 3D interactions.
Native Bridge — Objective-C layer enabling bidirectional communication. React Native calls into Unity. Unity calls back to React Native.
Build Process
Unity compiles to an iOS framework rather than a standalone application. This requires specific build configuration and post-processing.
Step 1: Unity Build Settings
Configure Unity to export as framework. Target iOS. Enable ARM64. Disable bitcode.
Step 2: Post-Process Build
Unity's PostProcessBuild hooks modify the generated Xcode project:
#if UNITY_IOS
[PostProcessBuildAttribute(999)]
public static void OnPostProcessBuild(BuildTarget buildTarget, string path)
{
if (buildTarget == BuildTarget.iOS)
{
string projectPath = path + "/Unity-iPhone.xcodeproj/project.pbxproj";
PBXProject pbxProject = new PBXProject();
pbxProject.ReadFromFile(projectPath);
string target = pbxProject.GetUnityMainTargetGuid();
pbxProject.SetBuildProperty(target, "ENABLE_BITCODE", "NO");
pbxProject.AddFrameworkToProject(target,
"UserNotifications.framework", false);
pbxProject.WriteToFile(projectPath);
// Configure Info.plist
string plistPath = path + "/Info.plist";
PlistDocument plist = new PlistDocument();
plist.ReadFromString(File.ReadAllText(plistPath));
PlistElementDict rootDict = plist.root;
rootDict.SetBoolean("ITSAppUsesNonExemptEncryption", false);
// Remove armv7, require arm64
const string capsKey = "UIRequiredDeviceCapabilities";
PlistElementArray capsArray = rootDict.CreateArray(capsKey);
capsArray.AddString("arm64");
File.WriteAllText(plistPath, plist.WriteToString());
}
}
#endif
This handles iOS compliance requirements and architecture settings. The generated framework integrates cleanly into the React Native Xcode workspace.
Step 3: Xcode Integration
Drag the UnityFramework.framework into the React Native project. Configure as embedded framework. Unity loads at runtime only when needed — keeping launch time fast.
Native Call Proxy
The bridge layer uses Objective-C protocols. Unity defines what it needs from the host. React Native implements the protocol.
Protocol Definition (NativeCallProxy.h)
// [!] important set UnityFramework in Target Membership for this file
// [!] and set Public header visibility
#import <Foundation/Foundation.h>
// NativeCallsProtocol defines methods you want called from managed
@protocol NativeCallsProtocol
@required
- (void) requestPreferences;
- (void) requestModel;
- (void) requestMarker;
- (void) logMessageReceived:(char*)stackTrace;
@end
__attribute__ ((visibility("default")))
@interface FrameworkLibAPI : NSObject
// call it any time after UnityFrameworkLoad to set object implementing protocol
+(void) registerAPIforNativeCalls:(id<NativeCallsProtocol>) aApi;
@end
Implementation (NativeCallProxy.mm)
#import <Foundation/Foundation.h>
#import "NativeCallProxy.h"
@implementation FrameworkLibAPI
id<NativeCallsProtocol> api = NULL;
+(void) registerAPIforNativeCalls:(id<NativeCallsProtocol>) aApi
{
api = aApi;
}
@end
extern "C" {
void requestPreferences() { return [api requestPreferences]; }
void requestModel() { return [api requestModel]; }
void requestMarker() { return [api requestMarker]; }
void logMessageReceived(char* stackTrace) {
return [api logMessageReceived:stackTrace];
}
}
The extern "C" block exposes functions Unity can call via P/Invoke. Clean interface. No direct coupling between runtimes.
Unity-Side Bridge
On the Unity side, a NativeAPI class wraps the external calls:
#if HOSTMANAGER
using System.Runtime.InteropServices;
public class NativeAPI
{
[DllImport("__Internal")]
public static extern void requestPreferences();
[DllImport("__Internal")]
public static extern void requestModel();
[DllImport("__Internal")]
public static extern void logMessageReceived(string stackTrace);
public static void handleUnhandledExceptions()
{
AppDomain.CurrentDomain.UnhandledException +=
(object sender, UnhandledExceptionEventArgs eventArgs) =>
{
Exception e = (Exception)eventArgs.ExceptionObject;
logMessageReceived($"NativeAPI.UnhandledException: ${e.Message}");
};
Application.logMessageReceived +=
(string condition, string stackTrace, LogType type) =>
{
if (type == LogType.Exception)
{
logMessageReceived(stackTrace);
}
};
}
}
#endif
The HOSTMANAGER compiler directive controls this code path. Development builds run standalone. Production builds communicate through the bridge.
Host Manager Pattern
The HostManager singleton coordinates all host application interaction:
[DefaultExecutionOrder(100)]
public class HostManager : Singleton<HostManager>
{
[SerializeField] private bool testHostInput = false;
[SerializeField] private GameObject loadingScreen;
public string ModelResponseString { get; private set; }
#if HOSTMANAGER
protected override void Awake()
{
base.Awake();
JsonConvert.DefaultSettings = () => new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
};
DontDestroyOnLoad(gameObject);
if (!testHostInput)
{
try
{
NativeAPI.handleUnhandledExceptions();
NativeAPI.requestPreferences();
}
catch
{
enabled = false;
return;
}
}
loadingScreen.gameObject.SetActive(true);
}
#endif
public void RequestModelData()
{
ModelResponseString = null;
#if HOSTMANAGER
NativeAPI.requestModel();
#endif
}
/// <summary>
/// Called by React Native when model data is ready
/// </summary>
public void ResponseRequestModel(string data)
{
ModelResponseString = data;
}
}
Startup flow:
- Unity framework loads
HostManager.Awake()runs- Calls
NativeAPI.requestPreferences()— asks React Native for configuration - React Native responds with settings via
UnitySendMessage - Unity shows loading screen while waiting for model data
Data flow:
- Unity calls
RequestModelData() - Native bridge forwards to React Native
- React Native fetches BIM data from API
- React Native sends JSON back via
UnitySendMessage ResponseRequestModel()receives data- Import process begins
Communication Protocol
Data flows as JSON strings. Both sides serialize/deserialize independently. The protocol is simple — string in, string out.
Settings Structure:
[Serializable]
private class SettingsHost
{
public string domain;
public string accessToken;
public long account;
public long location;
public long project;
public long projectPhase;
public Storey[] storey;
public string viewType; // "AR" or "3D"
public string[] parameters;
public bool binary;
public bool isOffline;
[Serializable]
public class Storey
{
public string name;
public long id;
}
}
React Native owns authentication and project selection. Unity receives tokens and configuration ready to use. No duplicate auth logic. Single source of truth.
Conditional Compilation
The codebase supports multiple build targets through compiler directives:
public class ImportStarter : MonoBehaviour
{
[SerializeField] private Transform parent;
private void Start()
{
#if HOSTMANAGER
ModelImporter.Instance.StartImport<HostManagerImporterModule>(parent);
#else
ModelImporter.Instance.StartImport<BimLinkImporterModule>(parent);
#endif
}
}
HOSTMANAGER defined: Data comes from React Native bridge
HOSTMANAGER undefined: Data loads directly from BIMlink API
Same codebase. Different data sources. Development uses direct API for faster iteration. Production uses bridge for integrated experience.
UI Adaptation
Some Unity UI elements shouldn't appear in embedded mode. React Native provides those screens. The HideInReactBuild component handles this:
public class HideInReactBuild : MonoBehaviour
{
private void Awake()
{
#if HOSTMANAGER
Destroy(gameObject);
#endif
}
}
Attach to any GameObject that should only appear in standalone builds. Login screens. Project selection. Settings menus. All handled by React Native in production.
Model Import Integration
The HostManagerImporterModule waits for React Native to provide model data:
public class HostManagerImporterModule : IImporterModule
{
public void StartImport(Transform parent,
Action<ModelController> onModelImported,
ImportReport report)
{
CoroutineService.Instance.StartContext(
WaitForReactCoroutine(parent, onModelImported, report),
this);
}
private IEnumerator WaitForReactCoroutine(
Transform parent,
Action<ModelController> onModelImported,
ImportReport report)
{
HostManager.Instance.RequestModelData();
yield return new WaitUntil(() =>
HostManager.Instance.ModelResponseString != null);
Debug.Log($"[{GetType().Name}] React responses received. Starting Import.");
onModelImported(BimFactory.ImportBimLinkJsonModel(
parent,
report,
HostManager.Instance.ModelResponseString));
}
}
Async handshake:
- Request model data from host
- Yield until response arrives
- Parse and import when ready
This keeps Unity responsive during the wait. Loading animations continue. UI remains interactive.
Viewer Type Selection
React Native determines the viewing mode. Unity configures accordingly:
private void ImportUsingData(string data)
{
SettingsHost settings = JsonUtility.FromJson<SettingsHost>(data);
ApplicationConfiguration.Configuration.Viewer =
settings.viewType == "AR"
? Configuration.ViewerType.MarkerAR
: Configuration.ViewerType.Mobile3D;
ApplicationConfiguration.Configuration.Model = settings.ToImportSettings();
BimLinkUIController.Instance.ImportUsingSettings();
loadingScreen.gameObject.SetActive(false);
}
Two viewer types available:
MarkerAR — Full AR mode with QR marker anchoring. Camera feed, spatial tracking, real-world overlay.
Mobile3D — 3D viewer mode. Pan, zoom, rotate the model without AR.
React Native controls which mode launches. Unity loads the appropriate scene.
Error Handling
Unity crashes shouldn't crash the entire app. The bridge catches exceptions and forwards them:
public static void handleUnhandledExceptions()
{
AppDomain.CurrentDomain.UnhandledException +=
(sender, eventArgs) =>
{
Exception e = (Exception)eventArgs.ExceptionObject;
logMessageReceived($"NativeAPI.UnhandledException: ${e.Message}");
logMessageReceived($"IsTerminating: ${eventArgs.IsTerminating}");
};
Application.logMessageReceived +=
(condition, stackTrace, type) =>
{
if (type == LogType.Exception)
{
logMessageReceived(stackTrace);
}
};
}
React Native receives crash logs. Can display user-friendly error messages. Can report to crash analytics. Unity's problems don't leave users stranded.
Why This Architecture
Single app experience. Users don't switch between apps. Seamless transition from project list to AR view.
Optimized loading. Unity framework loads only when needed. App launches fast. Memory used only during AR sessions.
Clean separation. React Native handles UI/UX. Unity handles rendering. Each runtime does what it does best.
Shared authentication. Login once in React Native. Token flows to Unity. No duplicate auth flows.
Native performance. Not a web view. Not a cross-compiled shim. Real native code on both sides.
Maintainable. Update React Native UI without touching Unity. Update Unity rendering without touching React Native. Independent development cycles.
Build Configuration
Two build variants maintain development velocity:
Development build — HOSTMANAGER undefined. Unity runs standalone. Direct API access. Fast iteration. Hot reload friendly.
Production build — HOSTMANAGER defined. Unity embeds in React Native. Bridge communication. Full integration testing.
Same codebase compiles both ways. No code duplication. No drift between environments.
The result: a construction app that feels native everywhere. Smooth navigation in React Native. Powerful AR in Unity. One binary. One experience. The architecture invisible to users — which is exactly the point.