NNabeel Hassan

Blog · October 18, 2026 · 8 min read

Unity Native Plugins for iOS and Android: What Actually Breaks and How I Build Them

By Nabeel Hassan — AI Engineer · ICPC World Finalist

TL;DR: When a Unity app needs something the engine does not expose, like a system networking API, a vendor's eye-tracking SDK or a platform permission, you write a native plugin: Java or Kotlin on Android, Objective-C or Swift on iOS, and a thin C# wrapper that the rest of the game talks to. The calls themselves are the easy part. What actually breaks is threading (native callbacks arrive off Unity's main thread), build configuration (Gradle conflicts, missing frameworks, Info.plist keys), IL2CPP stripping, and the editor, where none of the native code exists. My rule after years of Unity work across AR platforms, cross-platform SDKs and clinical XR: keep the native layer tiny, give it one C# interface with an editor fake, marshal every callback back to the main thread, and let CI build both platforms on every change.

Most Unity projects can go a long way without touching native code. Then one day the product needs a thing Unity does not wrap: a background service, a specific system API, a vendor SDK that only ships as an Android AAR and an iOS framework. At that point you are writing a bridge between two runtimes, and the bridge is where a surprising amount of production pain lives.

I have crossed that bridge in a few different shapes. At Geonode, alongside building the Repocket C# SDK for Windows, Android and iOS, I set up the Unity and Windows CI/CD pipelines, which meant living with native build steps every day. AR work at ARCortex sat on top of ARKit and ARCore. At Nystag, the clinical eye-tracking app on the Vive Focus 3 depended on a headset vendor's SDK. Different products, same lessons.

When you actually need a native plugin

Before writing one, check that you need it. Unity and its packages cover more than people expect, and every native plugin is code you now maintain on two extra platforms, through every Unity upgrade and every OS release.

You genuinely need native code when:

If an official or well-maintained package exists, use it, even if it is a little heavier than you would like. I made the same argument about choosing an AR SDK: the abstraction someone else maintains is usually worth more than the control you give up.

The shape I use: one interface, three implementations

Every native feature gets a single C# interface that the rest of the app depends on, and three implementations behind it: Android, iOS and an editor fake.

public interface IDeviceBridge
{
    void Start(string config);
    void Stop();
    event Action<string> StateChanged;
}

Game code only ever sees IDeviceBridge. A small factory picks the implementation with platform defines:

#if UNITY_ANDROID && !UNITY_EDITOR
    bridge = new AndroidDeviceBridge();
#elif UNITY_IOS && !UNITY_EDITOR
    bridge = new IosDeviceBridge();
#else
    bridge = new EditorDeviceBridge();
#endif

This one decision solves several problems at once. Designers and other developers can press Play in the editor and the feature behaves plausibly instead of throwing. Tests can run against the fake. And the native surface stays small, because it only has to satisfy a handful of methods, not whatever shape the vendor SDK happens to have. It is the same "share the logic, never the platform" boundary I kept in the Repocket SDK: the platform-specific mess lives in the thinnest possible shim, and the shared code never learns what a Java class is.

Android: Java or Kotlin behind AndroidJavaObject

On Android, the native side is usually a small Java or Kotlin class, built into an .aar or dropped as source into Assets/Plugins/Android. The C# side talks to it through AndroidJavaClass and AndroidJavaObject:

using var activity = new AndroidJavaClass("com.unity3d.player.UnityPlayer")
    .GetStatic<AndroidJavaObject>("currentActivity");
plugin = new AndroidJavaObject("com.example.bridge.DeviceBridge", activity);
plugin.Call("start", config);

For callbacks from Java into C#, there are two options. UnitySendMessage is simple but stringly typed: it targets a GameObject by name and a method by name, and both break silently on a rename. I prefer AndroidJavaProxy, which lets C# implement a Java interface directly, so the Java side just calls a listener.

The Android problems that actually cost time are rarely the calls:

iOS: extern "C" behind DllImport

On iOS, native code lives in .m, .mm or Swift files in Assets/Plugins/iOS, and C# calls it through P/Invoke against __Internal, because everything gets statically linked into the app:

[DllImport("__Internal")]
private static extern void bridge_start(string config);
// DeviceBridge.mm (extern "C" needs Objective-C++)
extern "C" void bridge_start(const char* config) {
    NSString* cfg = [NSString stringWithUTF8String:config];
    [[DeviceBridge shared] startWithConfig:cfg];
}

Swift works too, but it needs an Objective-C or C shim in front of it because P/Invoke can only see C symbols. For callbacks into C#, pass a static function pointer marked with [MonoPInvokeCallback]. Under IL2CPP it has to be static, so you route the result to the right C# object yourself.

The iOS problems that bite:

Threading is where native plugins actually break

If I had to pick the single most common bug in Unity native plugins, it is this: native callbacks arrive on whatever thread the OS or SDK felt like using, and almost all of the Unity API only works on the main thread.

Touch a Transform, start a coroutine or update UI from a network callback thread, and you get anything from an exception to a crash to a bug that only happens on some devices. The fix is boring and non-negotiable: every callback from native code pushes its payload onto a thread-safe queue, and a MonoBehaviour drains that queue in Update.

static readonly ConcurrentQueue<Action> mainThread = new();

void Update()
{
    while (mainThread.TryDequeue(out var work)) work();
}

Native code that is long-running, like a networking client, needs the same discipline in reverse. Do not block Unity's main thread waiting for native work, and design for the app being paused. On mobile, the OS can suspend you at any moment, which is the entire subject of the background-networking section in the Repocket SDK piece. The plugin has to treat OnApplicationPause as a normal event, not an edge case.

IL2CPP, stripping and the "works in development" trap

Release builds use IL2CPP with managed code stripping, and stripping removes code it thinks nobody uses. Anything only reached by reflection or from native code, like a callback method or a type the plugin creates by name, can disappear. The result is a feature that works in a development build and fails in the store build.

Two defenses: a link.xml that preserves the types your bridge needs, and testing the actual release configuration on real devices before every release. This is also one of the reasons native plugins top my checklist when upgrading the Unity version of a live project: they are code Unity cannot see, so they are the first thing an upgrade breaks quietly.

Let CI build both platforms, always

A native plugin that builds on your laptop proves very little. Your laptop has a particular Xcode, a particular Android SDK and a Gradle cache that hides missing dependencies. The only trustworthy check is a clean machine building both platforms from scratch on every change.

That was a big part of the value of the Unity CI/CD pipeline work at Geonode. A change that compiled for Android but broke the iOS build, or a post-process script that only worked with one Xcode version, showed up the same day instead of on release day. For plugins specifically, I want CI to produce the actual signed artifacts, because many native problems, from stripping to manifest merging, only exist in the final build.

My native plugin checklist

Before I consider a native feature done:

  1. One C# interface, three implementations, including an editor fake that lets anyone press Play.
  2. The native layer is thin. It translates; it does not hold product logic.
  3. Every callback is marshalled to the main thread through a queue.
  4. Build configuration is code. Gradle dependencies, manifest entries, frameworks and Info.plist keys come from scripts, never manual edits.
  5. Stripping is handled with link.xml and keep rules, and verified on a release build.
  6. Pause and resume are tested on real devices, not just launch and run.
  7. CI builds both platforms from a clean state on every change.

None of this is glamorous, and that is the point. Native plugins are the part of a Unity project where two toolchains, two operating systems and two threading models meet, which is also why I think of Unity as a serious tool for products that are not games. When the bridge is small, boring and well tested, the rest of the team never has to think about it, which is exactly how it should feel.


I build cross-platform products in Unity, mobile and the web, and lead engineering as a fractional CTO for teams with hard technical edges. If your app needs native integrations that hold up in production, more about my background here, or book a call.

FAQ

How do you call native Android code from Unity?

Put a Java or Kotlin class in an AAR or in Assets/Plugins/Android, then call it from C# with AndroidJavaClass and AndroidJavaObject. For callbacks into C#, implement the Java listener interface in C# with AndroidJavaProxy rather than relying on UnitySendMessage and GameObject names.

How do you call native iOS code from Unity?

Write extern "C" functions in an Objective-C++ (.mm) file in Assets/Plugins/iOS and declare them in C# with [DllImport("__Internal")]. Swift needs a C-callable shim in front of it. Callbacks into C# use static functions marked [MonoPInvokeCallback] under IL2CPP.

Why does my Unity native plugin crash when the callback fires?

Native callbacks usually arrive on a background thread, and most of the Unity API only works on the main thread. Push callback payloads onto a thread-safe queue and drain it from a MonoBehaviour's Update method instead of touching Unity objects directly from the callback.

Why does my Unity plugin work in development builds but not release builds?

Release builds use IL2CPP with managed code stripping, and R8 shrinking on Android, which can remove types only reached by reflection or from native code. Preserve them with link.xml and ProGuard keep rules, and test the real release configuration on devices.

Building something in this space?

I take on AI-agent, automation and product work directly — scoped fast, shipped fast.

Book a discovery call →

Keep reading