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:
- A platform API has no Unity wrapper. Background execution rules, network state callbacks, VPN or tunnel APIs, certain permissions and system settings.
- A vendor ships only native SDKs. Analytics, payments, device or headset SDKs that come as an
.aarand an.xcframeworkwith no Unity package, or with a Unity package so old it no longer builds. - Performance or latency needs it. Rare, but real for things like audio pipelines or tight sensor loops.
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:
- Gradle dependency conflicts. Your plugin needs one version of a support library, another SDK in the project needs a different one, and the build fails or, worse, succeeds and crashes at runtime. Declare dependencies through a resolver rather than copying jars around, and keep one owner for the dependency list.
- Manifest merging. Permissions, services and activities have to land in the final merged manifest. Check the merged output, not just your plugin's manifest.
- Code shrinking. If minification is on, R8 can strip the class your C# looks up by name. Add keep rules for anything called through reflection.
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:
- Missing frameworks and capabilities. If your native code uses a system framework, the generated Xcode project has to link it. Do this in a
PostProcessBuildscript, not by hand in Xcode, or it disappears on the next build. - Info.plist keys. Camera, microphone, location, local network and similar permissions need usage descriptions. A missing key is a crash at the moment of the request or a rejection at review. Again, add them in post-processing.
- Memory ownership of strings. Returning a
char*from native code to C# means someone has to own that memory. Copy it into a buffer the C# side is allowed to free, and agree on that convention once.
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:
- One C# interface, three implementations, including an editor fake that lets anyone press Play.
- The native layer is thin. It translates; it does not hold product logic.
- Every callback is marshalled to the main thread through a queue.
- Build configuration is code. Gradle dependencies, manifest entries, frameworks and Info.plist keys come from scripts, never manual edits.
- Stripping is handled with
link.xmland keep rules, and verified on a release build. - Pause and resume are tested on real devices, not just launch and run.
- 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.