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).
Android integration
The Android native layer: Gradle wiring, ProGuard rules, Unity 6 reflection patterns, touch handling and rendering activation.
source: doc/android-integration.mdThe Android native layer
Android is one of unity_kit's two production platforms (alongside iOS; web/WebGL is not production-ready). Almost the entire Android native layer lives inside the plugin's Kotlin code and is generated by Build.cs — the host app only wires a few Gradle files and sets its minimum SDK and ABI filters. This page documents that host wiring plus the internal patterns worth understanding when debugging.
The Unity 6 patterns below were derived from flutter_embed_unity, which ships a separate Unity 6000 Android implementation.
Host app
Where unityLibrary lives & Gradle wiring
The Unity export is deployed as a Gradle module named :unityLibrary inside the Flutter project's android/ folder. Export always writes to the Unity project's Builds/ folder; deploy copies it into the app.
Flutter ▸ Export Android (Debug / Release)<unity-project>/Builds/android/unityLibrary/Builds/android/unityLibrary/<flutter-app>/android/unityLibrary/Destination the three Gradle edits below point at.
Three edits register the module. During deploy Build.cs applies them for you (both Groovy and Kotlin DSL); apply them by hand only when you copied the artifact yourself (for example a CI download-artifact flow).
1. Register the module (settings.gradle)
include ':unityLibrary'
project(':unityLibrary').projectDir = file('./unityLibrary')2. Resolve unity-classes.jar (root build.gradle)
allprojects {
repositories {
flatDir {
dirs "${project(':unityLibrary').projectDir}/libs"
}
}
}3. Depend on the module (app build.gradle)
implementation project(':unityLibrary')Kotlin DSL equivalents (Flutter 3.29+)
Flutter 3.29+ projects use the Kotlin DSL (*.gradle.kts). Build.cs detects it and writes the correct syntax. The docs give only these line-level translations — no full .kts flatDir or projectDir block is published, so do not hand-invent one.
| Groovy | Kotlin DSL |
|---|---|
| include ':unityLibrary' | include(":unityLibrary") |
| dirs "..." | dirs(file("...")) |
| implementation project(':unityLibrary') | implementation(project(":unityLibrary")) |
minSdk & ABI filters (always manual)
Build.cs does not touch the host app's minSdkVersion or abiFilters — set them yourself in android/app/build.gradle. The abiFilters must match the architectures you exported, or the Unity view never loads on unlisted devices.
android {
defaultConfig {
minSdkVersion 22 // Unity requires API 22+
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a'
}
}
}minSdk: prefer 24
This snippet is verbatim from the plugin README (22, "Unity requires API 22+"), but the newer doc/unity-export.md specifies Minimum API Level 24 ("Flutter minimum"). Use 24.
Build.cs
What Build.cs automates vs. what is manual
Unity exports an application module, but Flutter needs a library module. Build.cs's Android post-processing rewrites the artifact on every export, and wires it into the Flutter project during deploy. Steps 1–9 run every time; steps 10–12 run only on deploy.
Artifact patches — every export
- 1.com.android.application → com.android.library
- 2.Remove applicationId (libraries have no app ID)
- 3.Add namespace 'com.unity3d.player' (required by AGP 8+)
- 4.Comment out ndkPath (conflicts with Flutter's NDK)
- 5.Fix ../shared/ → ./shared/ (Unity 6 path correction)
- 6.Replace fileTree → unity-classes jar (library mode)
- 7.Strip <activity> from the manifest (Flutter hosts the activity)
- 8.Add ProGuard keep rules
- 9.Add game_view_content_description string (accessibility)
Flutter project patches — deploy only
- 10.settings.gradle: include ':unityLibrary'
- 11.root build.gradle: flatDir repository for the export
- 12.app/build.gradle: implementation project(':unityLibrary')
Never auto-patched — you own these
- •
minSdkVersionandndk.abiFiltersin the host app. - •Copying the artifact when you skip the built-in deploy (CI).
Release builds
ProGuard / R8 keep rules
The Android plugin reaches its classes through reflection and JNI from Unity C#. Without keep rules, R8 strips or renames them and release builds fail silently — no crash, just non-functional. Two rule files exist, and both are handled for you.
proguard-unity.txt — auto-generated, inside the export
Build.cs adds these keep rules to the exported module (step 8). Verify them only if a release build throws ClassNotFoundException for com.unity_kit.*.
-keep class com.unity_kit.** { *; }
-keep class com.unity3d.player.** { *; }consumer-rules.pro — ships with the plugin
These live in the unity_kit plugin's android/ folder and are applied to any app that depends on it — nothing for the host app to add.
# unity_kit Flutter bridge (called from Unity C# via AndroidJavaClass)
-keep class com.unity_kit.FlutterBridgeRegistry { public static *; }
# Unity player classes (accessed via reflection)
-keep class com.unity3d.player.UnityPlayer { *; }
-keep class com.unity3d.player.UnityPlayerForActivityOrService { *; }
-keep class com.unity3d.player.IUnityPlayerLifecycleEvents { *; }Registered in the plugin's Gradle:
// build.gradle.kts
android {
defaultConfig {
consumerProguardFiles("consumer-rules.pro")
}
}What breaks without the rules
- R8 renames
FlutterBridgeRegistry— Unity C#'sAndroidJavaClass("com.unity_kit.FlutterBridgeRegistry")fails silently. - R8 strips
sendMessageToFlutter— Unity messages never reach Flutter. - R8 strips
UnityPlayerForActivityOrService— reflection-based player creation fails.
The documented files are proguard-unity.txt and consumer-rules.pro; the host app's proguard-rules.pro is never used for these — do not add them there.
Plugin internals
Unity 6 vs. legacy player creation
Unity 6000 introduced a breaking change to the Android player class. unity_kit handles both the new and legacy classes through reflection — no host configuration is needed. Read this only if you fork the native layer.
| Unity version | Class | Extends | Constructor |
|---|---|---|---|
| 2019–2022.3 | com.unity3d.player.UnityPlayer | FrameLayout | UnityPlayer(Activity) |
| 6000+ | com.unity3d.player.UnityPlayerForActivityOrService | Does NOT extend FrameLayout | UnityPlayerForActivityOrService(Context, IUnityPlayerLifecycleEvents?) |
The critical difference: UnityPlayerForActivityOrService is no longer a View, so you cannot add it to a view hierarchy directly — call getFrameLayout() to obtain the embeddable view. The native layer tries the Unity 6 class first, then falls back to legacy, and tries constructor signatures most-specific first.
private fun createPlayerViaReflection(activity: Activity): Any {
// 1. Try Unity 6 class
val clazz = try {
Class.forName("com.unity3d.player.UnityPlayerForActivityOrService")
} catch (_: ClassNotFoundException) {
// 2. Fall back to legacy class
Class.forName("com.unity3d.player.UnityPlayer")
}
// 3. Try multiple constructor signatures (most specific first)
return tryConstructors(clazz, activity)
}
private fun tryConstructors(clazz: Class<*>, activity: Activity): Any {
// Signature 1: Unity 6 with lifecycle events
try {
val lifecycleClass = Class.forName(
"com.unity3d.player.IUnityPlayerLifecycleEvents"
)
return clazz.getConstructor(Context::class.java, lifecycleClass)
.newInstance(activity, null)
} catch (_: Exception) {}
// Signature 2: Unity 6 with Context only
try {
return clazz.getConstructor(Context::class.java)
.newInstance(activity)
} catch (_: Exception) {}
// Signature 3: Legacy with Activity
try {
return clazz.getConstructor(Activity::class.java)
.newInstance(activity)
} catch (_: Exception) {}
throw IllegalStateException("No compatible Unity player constructor found")
}Extracting the embeddable view tries getFrameLayout() (Unity 6) first, then getView → getPlayerView → getSurfaceView → getRootView, and finally the player itself if it is a View (legacy).
private fun getEmbeddableView(player: Any): View {
// Unity 6: use getFrameLayout()
try {
val frameLayout = player.javaClass
.getMethod("getFrameLayout")
.invoke(player) as? View
if (frameLayout != null) return frameLayout
} catch (_: Exception) {}
// Additional fallback methods to try
val fallbackMethods = listOf(
"getView",
"getPlayerView",
"getSurfaceView",
"getRootView",
)
for (methodName in fallbackMethods) {
try {
val view = player.javaClass
.getMethod(methodName)
.invoke(player) as? View
if (view != null) return view
} catch (_: Exception) { continue }
}
// Last resort: player itself if it is a View (legacy UnityPlayer)
if (player is View) return player
throw IllegalStateException("Cannot extract View from Unity player")
}Catch Throwable, not Exception
The constructor call must catch Throwable, not just Exception: UnsatisfiedLinkError extends Error, and it is thrown here when Unity's native libraries are missing or corrupted.
Plugin internals
Rendering activation — the refocus dance
After the Unity view is attached to its container, the rendering pipeline must be explicitly activated or the user sees a black, frozen frame. The fix is a focus signal followed by a pause/resume cycle.
private fun activateRendering(player: Any, unityView: View) {
// 1. Signal window focus to the player
try {
val focusResult = unityView.requestFocus()
player.javaClass
.getMethod("windowFocusChanged", Boolean::class.java)
.invoke(player, focusResult)
} catch (_: Exception) {}
// 2. Pause then resume to unfreeze the rendering pipeline
try {
player.javaClass.getMethod("pause").invoke(player)
player.javaClass.getMethod("resume").invoke(player)
} catch (_: Exception) {}
}windowFocusChanged(true) tells the player it has gained focus, which initialises the rendering surface; the pause() + resume() cycle restarts the pipeline and clears any frozen frame state. Both reference libraries require this pattern. Under Hybrid Composition, a re-focus is delayed ~500 ms after attachment so it runs once HC finishes surface setup.
The container FrameLayout repeats the pause/resume cycle whenever it becomes visible again — after an orientation change, a hot reload, or returning from the background — to prevent the same freeze.
class UnityContainerLayout(context: Context) : FrameLayout(context) {
private var player: Any? = null
fun setPlayer(player: Any) {
this.player = player
}
override fun onWindowVisibilityChanged(visibility: Int) {
super.onWindowVisibilityChanged(visibility)
if (visibility == VISIBLE) {
// When becoming visible again (after orientation change,
// hot reload, or returning from background), restart
// rendering to prevent UI freeze.
player?.let { p ->
try {
p.javaClass.getMethod("pause").invoke(p)
p.javaClass.getMethod("resume").invoke(p)
} catch (_: Exception) {}
}
}
}
}Plugin internals
Touch handling
Flutter's Virtual Display mode forwards touch events with deviceId = 0, which Unity's New Input System refuses to register. The container overrides dispatchTouchEvent to fix the deviceId and set the input source.
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
// Flutter's Virtual Display sends events with deviceId = 0.
// Unity's New Input System requires deviceId != 0 to register touch.
// Copy the event with deviceId = -1 to make Unity recognize it.
val fixedEvent = if (event.deviceId == 0) {
MotionEvent.obtain(event).apply {
try {
val field = MotionEvent::class.java.getDeclaredField("mDeviceId")
field.isAccessible = true
field.setInt(this, -1)
} catch (_: Exception) {
// Fallback: use the event as-is
}
}
} else {
event
}
// Also ensure the event source is set correctly
fixedEvent.source = InputDevice.SOURCE_TOUCHSCREEN
return super.dispatchTouchEvent(fixedEvent)
}InputDevice.SOURCE_TOUCHSCREEN is required because the New Input System uses the event source to determine input type — without it, touches are ignored. If you install a custom touch handler, apply both fixes and do not override the built-in handling.
Platform view
View modes & z-ordering
Flutter can embed the native Unity view two ways, and they behave very differently because Unity draws into a SurfaceView. This is a plugin-internal choice — no host configuration — but the two docs disagree, so verify against the shipped Dart source when debugging z-order or freeze issues.
| Aspect | Virtual Display (AndroidView) | Hybrid Composition (PlatformViewLink) |
|---|---|---|
| Mechanism | Renders the native view into an offscreen texture. | Places the native view directly in the Android view hierarchy via initExpensiveAndroidView. |
| Z-ordering | Unity's SurfaceView bypasses the texture and can cover all Flutter content, ignoring bounds. | Respects z-ordering and widget bounds correctly. |
| Touch | Forwards events with deviceId = 0 (needs the patch above). | Needs a ~500 ms delayed re-focus after surface setup. |
Hybrid Composition — recommended by doc/android-integration.md
// Correct (doc/android-integration.md): Hybrid Composition via PlatformViewLink
PlatformViewLink(
viewType: 'com.unity_kit/unity_view',
surfaceFactory: (context, controller) {
return AndroidViewSurface(
controller: controller as AndroidViewController,
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
gestureRecognizers: gestureRecognizers,
);
},
onCreatePlatformView: (params) {
return PlatformViewsService.initExpensiveAndroidView(
id: params.id,
viewType: viewType,
layoutDirection: TextDirection.ltr,
creationParams: creationParams,
creationParamsCodec: const StandardMessageCodec(),
)
..addOnPlatformViewCreatedListener(params.onPlatformViewCreated)
..create();
},
)Virtual Display
// Virtual Display via AndroidView
AndroidView(
viewType: 'com.unity_kit/unity_view',
creationParams: config.toMap(),
creationParamsCodec: const StandardMessageCodec(),
)The docs disagree — verify against the source
doc/android-integration.md mandates Hybrid Composition and calls Virtual Display wrong ("Unity SurfaceView renders on top of all Flutter widgets", fixed in v0.9.2, tracked in issue #1). The newer doc/faq.md says the opposite — it recommends Virtual Display as the pre-Android-10 z-order workaround and warns that Hybrid Composition can freeze the app. SurfaceView z-ordering is unreliable on Android < 10; prefer Android 10+ and confirm the mode the shipped Dart source actually uses.
Troubleshooting
Android-specific problems & fixes
The issues that are unique to Android, each with the cause, the fix, and how to confirm it worked. "Done" means the Unity view renders on a device — a green build alone is not proof.
Unity view never loads — stuck on the placeholder
- Cause
- logcat shows "Unity view not available after N attempts". Unity was exported with only ARMv7 (32-bit) but the device is arm64.
- Fix
- In the Unity Editor: Edit → Project Settings → Player → Android → Other Settings → Target Architectures — enable ARM64. Re-export and redeploy.
- Verify
- The UnityView widget replaces its placeholder with rendered Unity content; the logcat message no longer appears.
Black screen after Unity loads
- Cause
- The refocus pattern (windowFocusChanged(true) → pause() → resume()) did not run after the view was attached.
- Fix
- unity_kit does this automatically in UnityKitViewController — update to the latest version.
- Verify
- Logs confirm UnityPlayerManager created the player; the surface renders instead of showing a black frame.
Touch not working
- Cause
- Unity's New Input System ignores touches missing InputDevice.SOURCE_TOUCHSCREEN, and Virtual Display forwards events with deviceId = 0.
- Fix
- UnityContainerLayout sets the source and patches the deviceId automatically — do not override the built-in touch handling.
- Verify
- Taps and drags reach Unity; input events register in the scene.
Unity renders on top of all Flutter widgets (z-ordering)
- Cause
- Using AndroidView (Virtual Display): Unity's SurfaceView bypasses the offscreen texture and renders directly on screen. SurfaceView z-ordering is also unreliable on Android < 10.
- Fix
- Upgrade the package (fixed in 0.9.2). Prefer Android 10+; the docs disagree on the view mode — see the section above and verify against the shipped Dart source.
- Verify
- Flutter widgets layered over the Unity view (nav bars, overlays) stay visible and respect their bounds.
Frozen rendering after an orientation change
- Cause
- Unity's rendering pipeline freezes when the Activity configuration changes or Flutter hot-reloads.
- Fix
- UnityContainerLayout overrides onWindowVisibilityChanged(VISIBLE) to call pause() + resume() — automatic in unity_kit.
- Verify
- Rotating the device keeps Unity rendering instead of freezing.
Release build silently broken (works in debug)
- Cause
- R8 stripped or renamed the reflection/JNI classes — no crash, just non-functional messaging (ClassNotFoundException for com.unity_kit.*).
- Fix
- Build.cs auto-adds keep rules during export. Open android/unityLibrary/proguard-unity.txt and confirm the two -keep lines exist.
- Verify
- Unity messages reach Flutter in a release build; no ClassNotFoundException for com.unity_kit.*.
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.
