Open Source

Flutter package

unity_kit

Embed Unity 3D in Flutter with a typed, testable bridge — JSON or binary protocol, an enforced lifecycle, AR Foundation, and CDN asset streaming. Runs on Android and iOS (web/WebGL experimental).

v2.0.222.6k / month21 likesMITUnity 2022.3 LTS → Unity 6

iOS integration

The iOS native layer: UnityFramework embedding, the .mm bridge, Metal crash prevention and device-only constraints.

source: doc/ios-integration.md

iOS

How Unity embeds on iOS

On iOS, Unity runs as UnityFramework.framework — a separate framework embedded in the app. It packages the Unity runtime and Metal renderer, the IL2CPP-compiled C# code (including NativeAPI.cs), and the Data files (scenes, assets, shaders). The Flutter plugin loads it at runtime and owns a single Unity instance through the UnityPlayerManager singleton, which survives Flutter navigation.

iOS native layertext
Swift Plugin Code (unity_kit/ios/Classes/)
├── SwiftUnityKitPlugin.swift      FlutterPlugin entry point, view factory registration
├── UnityKitViewFactory.swift      FlutterPlatformViewFactory
├── UnityKitViewController.swift   FlutterPlatformView + MethodChannel handler
├── UnityKitView.swift             UIView container for the Unity view
├── UnityPlayerManager.swift       Singleton Unity player lifecycle
├── FlutterBridgeRegistry.swift    Routes Unity messages to Flutter controllers
└── UnityEventListener.swift       Protocol for Unity event callbacks

Native Bridge (compiled into UnityFramework)
└── UnityKitNativeBridge.mm        C symbols for IL2CPP DllImport

Key difference from Android: the C# [DllImport("__Internal")] calls resolve to C symbols that must exist inside UnityFramework.framework at link time — not in the Flutter plugin, which is a separate module (unity_kit.framework). That single constraint drives the .mm bridge and most of the iOS-specific setup below. Flutter's window is also kept above Unity's full-screen surface (windowLevel = UIWindow.Level.normal − 1).

Placement

Where the exported project goes

Before exporting, set the iOS Player Settings. Unity's export then produces a UnityLibrary Xcode project that you deploy into the Flutter app's ios/ folder.

Edit ▸ Project Settings ▸ Player ▸ iOStext
Scripting Backend = IL2CPP        (Required)
Target SDK        = Device SDK    (Simulator not supported)
Architecture      = ARM64         (Required)

Prerequisites

Unity 2022.3 LTS or newer, Flutter 3.0+, and Xcode 14+ (for iOS export). Run Tools ▸ UnityKit ▸ Validate Project before exporting — it checks the build scene list, scripting backend and ARM64.

<unity-project>/Builds/ios/UnityLibrary/
my_flutter_app/ios/UnityLibrary/

Exported iOS Xcode project. Auto-copied when a Flutter path is set (Flutter ▸ Settings, or the UNITY_KIT_FLUTTER_PROJECT env var); otherwise copy it manually.

Build UnityFramework from the export with the device SDK and arm64 only (this is what the app bundle ultimately embeds):

bash
# Build UnityFramework from the Unity export
xcodebuild \
  -project /path/to/Builds/ios/Unity-iPhone.xcodeproj \
  -scheme UnityFramework \
  -configuration Release \
  -sdk iphoneos \
  -arch arm64 \
  ONLY_ACTIVE_ARCH=NO \
  BUILD_DIR=/path/to/Builds/ios/build

# Result: build/Release-iphoneos/UnityFramework.framework

For local development, symlink the built framework next to the plugin podspec so the vendored-framework block below picks it up:

bash
cd unity_kit/ios
ln -s /path/to/Unity/Builds/ios/build/Release-iphoneos/UnityFramework.framework .

Heads up

Verification: at runtime the plugin loads the framework from Bundle.main.bundlePath + "/Frameworks/UnityFramework.framework", so it must end up in the app bundle's Frameworks/ directory. If it is missing you get the iOS error "No such module 'UnityFramework'".

Runner

Podfile & Runner workspace

The Flutter app's ios/Podfile needs the iOS 13 platform and Bitcode disabled on every pod target:

ios/Podfileruby
platform :ios, '13.0'

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['ENABLE_BITCODE'] = 'NO'
    end
  end
end

Then include the exported framework in your Runner workspace. The package README states this in a single sentence and gives no per-click Xcode steps:

From the package README

Export the Unity project as an iOS framework and include it in your Runner workspace.

doc/ios-integration.md documents the vendored-framework route through the plugin podspec: when UnityFramework.framework is symlinked or copied next to unity_kit.podspec, it is vendored automatically.

unity_kit/ios/unity_kit.podspecruby
Pod::Spec.new do |s|
  s.name             = 'unity_kit'
  s.platform         = :ios, '13.0'
  s.source_files     = 'Classes/**/*'

  # Vendored framework support (when symlinked or copied)
  unity_framework_path = File.join(__dir__, 'UnityFramework.framework')
  if File.exist?(unity_framework_path) || File.symlink?(unity_framework_path)
    s.ios.vendored_frameworks = 'UnityFramework.framework'
    s.preserve_paths = 'UnityFramework.framework'
  end

  s.pod_target_xcconfig = {
    'DEFINES_MODULE' => 'YES',
    'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
    'FRAMEWORK_SEARCH_PATHS' =>
      '$(inherited) "${PODS_TARGET_SRCROOT}" "${PODS_CONFIGURATION_BUILD_DIR}"',
    'OTHER_LDFLAGS' => '$(inherited) -ObjC',
  }
end

Heads up

The Data folder reference and SKIP_INSTALL = YES on the UnityFramework target are added automatically by XCodePostBuild (next section) — the docs prescribe no manual "Embed & Sign" or Data-folder drag step. ENABLE_BITCODE = NO is set automatically on the exported Unity project too, but the ios/Podfile post_install for the app's pods is a manual edit.

Native bridge

UnityKitNativeBridge.mm — the C symbols

Unity's C# uses [DllImport("__Internal")] to declare SendMessageToFlutter and SendSceneLoadedToFlutter. IL2CPP compiles these into C function calls, and the linker requires their symbols inside the same binary — UnityFramework.framework. The Swift plugin runs in a separate module, so a dedicated Objective-C++ file provides the C symbols and forwards to FlutterBridgeRegistry via the ObjC runtime.

unity_kit/unity/Assets/Plugins/iOS/UnityKitNativeBridge.mm
YourUnityProject/Assets/Plugins/iOS/UnityKitNativeBridge.mm

Copy it here BEFORE exporting so Unity compiles it into UnityFramework.

It must live in Assets/Plugins/iOS/ before the export. Unity's build system automatically includes files from that folder and compiles them into UnityFramework's "Compile Sources" build phase. Skip it and the linker fails with Undefined symbol: _SendMessageToFlutter.

UnityKitNativeBridge.mmobjc
// unity_kit/unity/Assets/Plugins/iOS/UnityKitNativeBridge.mm
#import <Foundation/Foundation.h>
#import <objc/runtime.h>

static const NSUInteger kMaxMessageSize = 1048576; // 1 MB

extern "C" {

void SendMessageToFlutter(const char* message) {
    if (message == NULL) return;
    size_t length = strnlen(message, kMaxMessageSize + 1);
    if (length > kMaxMessageSize) return; // size guard before UTF-8 read

    NSString *msg = [NSString stringWithUTF8String:message];

    // Unity calls from the render thread; MethodChannel needs the main thread
    dispatch_async(dispatch_get_main_queue(), ^{
        Class cls = NSClassFromString(@"FlutterBridgeRegistry");
        if (cls == nil) return; // discovered at runtime, not linked
        SEL sel = NSSelectorFromString(@"sendMessageToFlutter:");
        if ([cls respondsToSelector:sel]) {
            [cls performSelector:sel withObject:msg];
        }
    });
}

} // extern "C"

Do not try @_cdecl in Swift instead — it exports the symbol from unity_kit.framework, but the IL2CPP linker looks for it in UnityFramework.framework. Different binaries, so you still get the undefined-symbol error.

swift
// DOES NOT WORK — symbol lands in unity_kit.framework, not UnityFramework
@_cdecl("SendMessageToFlutter")
func sendMessageToFlutterC(_ message: UnsafePointer<CChar>) { ... }
// IL2CPP linker looks inside UnityFramework → "Undefined symbol" at link time

Heads up

The .mm dispatches to the main queue because Unity calls it from the render thread while FlutterMethodChannel requires the main thread, and it validates message length against kMaxMessageSize = 1048576 (1 MB) before reading the string.

Automation

What XCodePostBuild.cs automates

XCodePostBuild.cs ships with the UnityKit scripts (Assets/Scripts/UnityKit/Editor/XCodePostBuild.cs) and post-processes the exported Unity Xcode project automatically. You do not do these four things by hand:

StepWhatWhy
1Inject the UnityReady NSNotification in startUnity:The unity_kit iOS plugin observes this to know Unity is ready.
2Set SKIP_INSTALL = YES on the UnityFramework targetRequired for framework builds.
3Set ENABLE_BITCODE = NO on the projectBitcode is deprecated.
4Add the Data folder reference to UnityFrameworkRequired for Unity data files to be included.

Heads up

XCodePostBuild does not inject OnUnityMessage / OnUnitySceneLoaded C callbacks (unlike flutter_unity_widget) — message bridging is UnityKitNativeBridge.mm's job. To confirm the patch ran, check UnityAppController.mm for the UnityReady notification and the Xcode build logs for SKIP_INSTALL and ENABLE_BITCODE.

Rendering

Metal zero-size texture crash

Unity's Metal renderer creates render textures matching the root view size. If it initializes before UIKit has laid out the platform view, it gets a 0×0 frame and crashes:

text
*** Terminating app due to uncaught exception 'NSInvalidArgumentException'
*** reason: '-[MTLTextureDescriptorInternal validateWithDevice:]:
    MTLTextureDescriptor has width of zero.'

unity_kit prevents this automatically with two layers of protection, driven by the init chain init() → waitForNonZeroFrame() → autoInitialize() → waitForUnityView(). First, it polls until the container has non-zero bounds before initializing:

UnityKitViewController.swiftswift
private func waitForNonZeroFrame(attempt: Int = 0) {
    guard !isDisposed else { return }

    if !containerView.bounds.isEmpty {
        autoInitialize()
    } else if attempt < 20 {
        // Poll every 50ms for up to 1 second
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) {
            [weak self] in
            self?.waitForNonZeroFrame(attempt: attempt + 1)
        }
    } else {
        autoInitialize() // timeout — proceed anyway, logs the attempt
    }
}

Second, even after auto-init, view attachment keeps a bounds guard with linear backoff (up to 30 attempts, 100ms × attempt):

swift
private func waitForUnityView(attempt: Int, maxAttempts: Int) {
    guard !isDisposed else { return }

    // Bounds guard — never attach into a zero-size container
    guard !containerView.bounds.isEmpty else {
        if attempt < maxAttempts {
            let delayMs = 100 * (attempt + 1) // linear backoff
            // retry after delayMs, up to 30 attempts...
        }
        return
    }

    if let unityView = UnityPlayerManager.shared.getView() {
        containerView.attachUnityView(unityView)
        sendEvent(name: "onUnityCreated", data: nil)
    }
}

Heads up

Prevention is fully automatic in the plugin. Your only obligation is to give UnityView a non-zero size in the layout — not hidden, not zero-height.

iOS only

Transparent Unity rendering

iOS can render Unity on top of Flutter widgets without an opaque background. The default is transparentBackground: false; the native container and Unity root view are marked isOpaque = false with a clear background when you opt in:

dart
UnityView(
  config: const UnityConfig(
    sceneName: 'AvatarScene',
    transparentBackground: true, // iOS only; default is false
  ),
)

This requires a matching Unity-side camera setting — without it the camera still paints a solid colour behind your 3D objects:

Unity scene — main cameratext
1. Select your scene's main camera.
2. Clear Flags → Solid Color.
3. Background → RGBA (0, 0, 0, 0)   (alpha must be 0).

Heads up

iOS only — on Android the flag is ignored and a warning is logged via UnityKitLogger. Related: UnityArMode.overlay automatically enables transparent rendering so the camera feed or Flutter content shows through.

Constraint

Device only — no iOS Simulator

UnityFramework.framework is built for the arm64 device architecture only. The iOS Simulator on Intel Macs uses x86_64 and cannot load it, so always test on a physical device. The plugin podspec excludes i386 from simulator builds:

ruby
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'

Heads up

Even on Apple Silicon Macs, where the simulator runs arm64, UnityFramework may still have compatibility issues with the simulator runtime — the Player Settings Target SDK = Device SDK reflects the same constraint. This is a production reality: unity_kit ships Android + iOS (iOS device-only); web/WebGL is not production-ready and there is no desktop player view yet.

Troubleshooting

iOS problems & verification

The iOS-specific failure modes, each with the fix and how to verify it. Most trace back to the framework not being built, embedded, or bridged correctly.

Undefined symbol: _SendMessageToFlutter (linker error)

Cause: UnityKitNativeBridge.mm was not compiled into UnityFramework, so the IL2CPP DllImport symbol has no definition.

  1. 1.Place UnityKitNativeBridge.mm in the Unity project's Assets/Plugins/iOS/ before exporting.
  2. 2.Re-export the Unity project so its build system includes the file automatically.

Verify: The UnityFramework target's "Compile Sources" build phase lists UnityKitNativeBridge.mm.

No such module 'UnityFramework'

Cause: The framework was not built or not embedded next to the plugin / in the Runner workspace.

  1. 1.Build it with xcodebuild -scheme UnityFramework (device SDK, arm64).
  2. 2.Confirm Builds/ios/UnityLibrary/ contains the exported Xcode project.
  3. 3.For local dev, symlink the built framework into unity_kit/ios/ so the podspec vendors it.

Verify: At runtime the plugin loads Bundle.main.bundlePath + "/Frameworks/UnityFramework.framework" — the framework must sit in the app bundle's Frameworks/ directory.

White / blank screen on iOS (Metal zero-size texture)

Cause: The UnityView widget has a zero size, so Unity builds a 0×0 Metal texture and crashes or renders nothing.

  1. 1.Give UnityView a non-zero size in the layout (not hidden, not zero-height).
  2. 2.Check iOS logs that UnityFramework.framework loaded successfully.
  3. 3.Watch for onError events on the Dart side.

Verify: You never see the NSInvalidArgumentException "MTLTextureDescriptor has width of zero" — waitForNonZeroFrame() has done its job.

Unity not sending messages to Flutter

Cause: The export was not patched, or the bridge GameObject is missing from the first scene.

  1. 1.Check that UnityAppController.mm was patched — look for the UnityReady notification injected by XCodePostBuild.
  2. 2.Check Xcode build logs for SKIP_INSTALL = YES and ENABLE_BITCODE = NO.
  3. 3.Verify the FlutterBridge GameObject exists in the first loaded scene and sendReadyOnStart is enabled on it.

Verify: The Dart messageStream receives Unity's {"type":"ready"} once the platform view attaches.

NSLog from the Swift plugin not visible in flutter run

Cause: flutter run only forwards Debug.Log (Unity C#) and print() (Dart); iOS NSLog from Swift plugins is not captured.

  1. 1.For debugging, send diagnostic info through the MethodChannel to Dart (e.g. an onUnityMessage event) and read it in the Dart console.
  2. 2.Remove the debug messages before production.

Verify: The diagnostic string appears in the Dart console output rather than being silently dropped.

Let's make something together.

If you have any questions about a new project or other inquiries, feel free to contact us. We will get back to you as soon as possible.

Codigee
We are using cookies. Learn more