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:

  1. Unity framework loads
  2. HostManager.Awake() runs
  3. Calls NativeAPI.requestPreferences() — asks React Native for configuration
  4. React Native responds with settings via UnitySendMessage
  5. Unity shows loading screen while waiting for model data

Data flow:

  1. Unity calls RequestModelData()
  2. Native bridge forwards to React Native
  3. React Native fetches BIM data from API
  4. React Native sends JSON back via UnitySendMessage
  5. ResponseRequestModel() receives data
  6. 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:

  1. Request model data from host
  2. Yield until response arrives
  3. 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.