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).
iOS integration
The iOS native layer: UnityFramework embedding, the .mm bridge, Metal crash prevention and device-only constraints.
source: doc/ios-integration.mdiOS
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.
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 DllImportKey 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.
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):
# 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.frameworkFor local development, symlink the built framework next to the plugin podspec so the vendored-framework block below picks it up:
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:
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
endThen 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.
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',
}
endHeads 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.mmYourUnityProject/Assets/Plugins/iOS/UnityKitNativeBridge.mmCopy 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.
// 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.
// 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 timeHeads 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:
| Step | What | Why |
|---|---|---|
| 1 | Inject the UnityReady NSNotification in startUnity: | The unity_kit iOS plugin observes this to know Unity is ready. |
| 2 | Set SKIP_INSTALL = YES on the UnityFramework target | Required for framework builds. |
| 3 | Set ENABLE_BITCODE = NO on the project | Bitcode is deprecated. |
| 4 | Add the Data folder reference to UnityFramework | Required 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:
*** 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:
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):
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:
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:
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:
'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.Place UnityKitNativeBridge.mm in the Unity project's Assets/Plugins/iOS/ before exporting.
- 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.Build it with xcodebuild -scheme UnityFramework (device SDK, arm64).
- 2.Confirm Builds/ios/UnityLibrary/ contains the exported Xcode project.
- 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.Give UnityView a non-zero size in the layout (not hidden, not zero-height).
- 2.Check iOS logs that UnityFramework.framework loaded successfully.
- 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.Check that UnityAppController.mm was patched — look for the UnityReady notification injected by XCodePostBuild.
- 2.Check Xcode build logs for SKIP_INSTALL = YES and ENABLE_BITCODE = NO.
- 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.For debugging, send diagnostic info through the MethodChannel to Dart (e.g. an onUnityMessage event) and read it in the Dart console.
- 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.
