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

Android integration

The Android native layer: Gradle wiring, ProGuard rules, Unity 6 reflection patterns, touch handling and rendering activation.

source: doc/android-integration.md

The 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)

android/settings.gradlegroovy
include ':unityLibrary'
project(':unityLibrary').projectDir = file('./unityLibrary')

2. Resolve unity-classes.jar (root build.gradle)

android/build.gradlegroovy
allprojects {
    repositories {
        flatDir {
            dirs "${project(':unityLibrary').projectDir}/libs"
        }
    }
}

3. Depend on the module (app build.gradle)

android/app/build.gradle — inside dependencies { }groovy
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.

GroovyKotlin 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/app/build.gradle — inside android { defaultConfig { } }groovy
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

  • minSdkVersion and ndk.abiFilters in 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.*.

<flutter-app>/android/unityLibrary/proguard-unity.txtproguard
-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/android/consumer-rules.proproguard
# 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:

kotlin
// build.gradle.kts
android {
    defaultConfig {
        consumerProguardFiles("consumer-rules.pro")
    }
}

What breaks without the rules

  • R8 renames FlutterBridgeRegistry — Unity C#'s AndroidJavaClass("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 versionClassExtendsConstructor
2019–2022.3com.unity3d.player.UnityPlayerFrameLayoutUnityPlayer(Activity)
6000+com.unity3d.player.UnityPlayerForActivityOrServiceDoes NOT extend FrameLayoutUnityPlayerForActivityOrService(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.

unity_kit Android native layer (plugin-internal)kotlin
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 getViewgetPlayerViewgetSurfaceView getRootView, and finally the player itself if it is a View (legacy).

unity_kit Android native layer (plugin-internal)kotlin
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.

unity_kit Android native layer (plugin-internal)kotlin
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.

unity_kit Android native layer (plugin-internal)kotlin
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.

UnityContainerLayout (plugin-internal)kotlin
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.

AspectVirtual Display (AndroidView)Hybrid Composition (PlatformViewLink)
MechanismRenders the native view into an offscreen texture.Places the native view directly in the Android view hierarchy via initExpensiveAndroidView.
Z-orderingUnity's SurfaceView bypasses the texture and can cover all Flutter content, ignoring bounds.Respects z-ordering and widget bounds correctly.
TouchForwards 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

dart
// 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

dart
// 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.

Codigee
We are using cookies. Learn more