Talking to your unmanaged code efficiently from Unity using P/Invoke — software design edition

Tech
Aliaume Morel
By
Aliaume Morel
|
3 Nov 2023

In the previous article about P/INVOKE, we learned how to call unmanaged methods from inside Unity, and how to pass arguments and return values across the interop barrier.

Now let’s start sprinkling DllImport all over our codebase for fun and to increase profits, right? Well…

  • That approach would violate the Single Responsibility Principle
  • There would be a lot of code duplication for multi-platform support
  • It would make any class that uses DllImport harder or impossible to unit-test
  • We don’t have a strategy for safe lifetime management of our unmanaged objects yet — avoiding memory leaks, basically.

So let’s have a look at how we tackled those challenges in our projects at Baracoda!

Table of contents

How to support multiple target platforms

Let’s say you had an existing version of your code for iOS compiling against a static library, libios_plugin.a. So let’s add the Android version of the plugin, libandroid_plugin.aar, which internally contains libandroid_plugin.so.

We would then watch it fail when trying to execute the native code:

E/Unity: DllNotFoundException: __Internal

DllImport and assembly naming

Our first issue is that on iOS we were using a statically-linked library, which requires the name passed to DllImport to be __Internal.

However, we have a dynamically-linked library for the Android version called libandroid_plugin.so. Here the name needs to be used in DllImport.

We can use conditional compilation by using #if / #elif / #else / #endif directives and Unity’s platform scripting symbols, such as UNITY_ANDROID or UNITY_IOS to select the version of the attribute that will be compiled based on the current platform.

1public class DeviceCameraService {
2    #if UNITY_ANDROID
3    [DllImport("libandroid_plugin.so")]
4    #elif UNITY_IOS
5    [DllImport("__Internal")]
6    #endif
7    public static extern int availableCameras();
8}
Copy to clipboard

Hooray, it works! But erm… that’s rather verbose if we need more methods, and we only support 2 platforms right now. So what can we do to avoid repeating this huge chunk?

Well, the library name has to be a constant string, so const string works just as well. Let’s refactor:

1public class DeviceCameraService {
2    #if UNITY_ANDROID
3    private const string LIBNAME = "libandroid_plugin.so";
4    #elif UNITY_IOS
5    private const string LIBNAME = "__Internal";
6    #endif
7
8    [DllImport(LIBNAME)]
9    public static extern int availableCameras();
10}
Copy to clipboard

Now declaring a new method is simply a matter of sticking LIBNAME on it, and supporting another platform is just one more case in the #if for all methods!

SRP and DRY, making the code better

What we wrote works well for a single class. However, as the API surface gets larger, we’ll probably want to group those native methods into objects — in accordance with the Single Responsibility Principle — each representing a different service that we want to access.

Since that LIBNAME variable is private right now, we have to copy/paste the directives in each of our classes, contradicting the Don’t Repeat Yourself principle. So let’s create another class to hold that for us!

1internal static class NATIVE_LIB {
2    #if UNITY_ANDROID
3    public const string NAME = "libandroid_plugin.so";
4    #elif UNITY_IOS
5    public const string NAME = "__Internal";
6    #endif
7} 
Copy to clipboard

And the import now look like this:

1public class DeviceCameraService {
2    [DllImport(NATIVE_LIB.NAME)]
3    public static extern int availableCameras();
4}
Copy to clipboard

Now it's not too verbose, easy to read, and easy to extend. Surely we’re done now, right?

Unmanaged code and unit-testing

Well, can you write unit tests for features that end up calling those unmanaged methods?

Unit tests are a great tool for fighting against regressions, making sure your code does what you expect it to, or even serving as runnable documentation if they’re well written.

As such, they’re crucial to ensuring and maintaining the health of our software and games.

So maybe you can write tests against the real implementation because the native lib is just providing some business logic. Or is the reason you have it in the first place because it gives you access to external resources? Maybe there is no desktop version of the library available, and tests wouldn’t even be able to run in editor.

In any case, this unmanaged code should be considered outside of the unit under test, and yet you still need access to the services it provides in real code.

The best solution in a case like this is usually to decouple the implementation of the service from its interface.

1public interface CameraService {
2    int AvailableCameras();
3}
4
5public class DeviceCameraService : CameraService {
6    [DllImport(NATIVE_LIB.NAME)]
7    private static extern int availableCameras();
8    
9    public int AvailableCameras() = availableCameras();
10}
11
12public class FakeCameraService : CameraService{
13    public int AvailableCameras() = 1; // fake value
14}
Copy to clipboard

Now we can instantiate a fake for the tests, but still use the real implementation for production!

baracoda logo

Get our expert advice!

We offer advisory and development services to help you build your bespoke software solution.

Unmanaged objects and lifetime management

This is still a bit of a toy example, however, as we haven’t been creating instances of unmanaged objects but only have been talking to what seem to be free functions or static methods.

Sometimes only having only a single instance of a service to talk to makes sense. Other times it doesn’t, and you need to be able to create new instances dynamically. So, how do we interact with them from C#?

IntPtr

When creating an object dynamically in C or C++, the program is going to allocate some memory, construct the object in it, and return a pointer to it.

Wait, don’t run! It’s fine!

When marshalling an unmanaged pointer to C#, the runtime can convert it to an IntPtr . You can think of it as an opaque handle to the unmanaged object, with which you can’t do much directly, except for handing it back to the unmanaged side.

1extern "C" CameraService* makeCameraService() {
2    return new CameraService();
3}
4
5extern "C" int32_t availableCameras(CameraService* service) {
6    return service->availableCamera();
7}
Copy to clipboard
1public class DeviceCameraService : CameraService {
2    private IntPtr _nativeHandle;
3    
4    CameraService() {
5        _nativeHandle = makeCameraService();
6    }
7
8    public int AvailableCameras() = availableCameras(_nativeHandle);
9
10    [DllImport(NATIVE_LIB.NAME)]
11    static extern private IntPtr makeCameraService();
12    
13    [DllImport(NATIVE_LIB.NAME)]
14    static extern private int availableCameras(IntPtr service);
15}
Copy to clipboard

So now creating a new instance of C# class also creates a new instance of the unmanaged object, and then we can make method calls on it. Sweet!

We just forgot one detail: we created an unmanaged object, which means the GC doesn’t know how to reclaim it and doesn’t even try by default!

So let’s stop leaking that unmanaged object when the garbage collectors come for that C# class.

Ownership

The preferred way a class signals it requires clean-up steps in C# is by implementing the IDisposable interface.

Deciding when and from where to call Dispose() is outside of the scope of this article, but note that the excellent Extenject framework is capable of handling it automatically for objects it creates.

Assuming there’s a function for destroying our unmanaged object, its API would look like this:

1extern "C" {
2CameraService* makeCameraService() {
3    return new CameraService();
4}
5
6void destroyCameraService(CameraService* service) {
7    delete service;
8}
9
10int32_t availableCameras(CameraService* service) {
11    return service->availableCamera();
12}
13}
Copy to clipboard

Implementing Dispose() is now pretty straightforward:

1public class DeviceCameraService : CameraService, IDisposable {
2    private IntPtr _nativeHandle;
3    
4    public DeviceCameraService() {
5        _nativeHandle = makeCameraService();
6    }
7    
8    public void Dispose() = destroyCameraService(_nativeHandle);
9
10    public int AvailableCameras() = availableCameras(_nativeHandle);
11
12    [DllImport(NATIVE_LIB.NAME)]
13    static extern private IntPtr makeCameraService();
14    
15    [DllImport(NATIVE_LIB.NAME)]
16    static extern private void destroyCameraService(IntPtr service);
17
18    [DllImport(NATIVE_LIB.NAME)]
19    static extern private int availableCameras(IntPtr service);
20}
Copy to clipboard

With this change, we’re good stewards of our resources and can now perform the necessary cleanups.

As the number of static extern methods grows, the class tends to get cluttered. It’s usually a good idea to extract them to a static class on the side to keep the business logic and the unmanaged method declarations apart.
See below for an example.

However, now that we’ve introduced manual resource management, we incur the risk of trying to refer to an unmanaged object that has been disposed of, so let’s make it safer!

Custom Handles

Fortunately, the C# standard library has exactly what we need: SafeHandle! It was meant for holding onto Win32 handles, but its API and finalization guarantees make it perfectly suited for our purpose.

It also has the added benefit of being abstract, so you have to inherit from it yourself, thus enabling type checking, whereas any IntPtr looks like every other one to the compiler.

When inheriting from SafeHandle, there are 3 things that need to be done.

  1. Tell SafeHandle what an invalid value is for the internal handle, and whether it is the sole owner of that handle through its constructor.
  2. Implement the abstract property IsInvalid . It should return true for the invalid value that you gave in the constructor. Why there is no default implementation is surprising.
  3. Implement the abstract method ReleaseHandle() which is called when the handle is either disposed of or garbage collected. It, obviously, should release the resource this handle was holding.

So this is what a custom SafeHandle would look like in our example:

1internal class CameraServiceHandle : SafeHandle {
2    public CameraServiceHandle()
3        : base(/* invalidHandleValue = */ IntPtr.Zero, /* ownsHandle = */ true)
4    {}
5
6    public override bool IsInvalid => handle == IntPtr.Zero;
7
8    protected override bool ReleaseHandle() {
9        CameraServiceExternMethods.destroyCameraService(handle);
10        SetHandleAsInvalid();
11        return true;
12    }
13}
Copy to clipboard

Now we just need to replace IntPtr with CameraServiceHandle everywhere, except in the destruction method that still needs to take an IntPtr.

For the unmanaged method declarations, after extracting them into their own static class, it would then look like this:

1internal static class CameraServiceExternMethods {
2    [DllImport(NATIVE_LIB.NAME)]
3    static extern private CameraServiceHandle makeCameraService();
4    
5    [DllImport(NATIVE_LIB.NAME)]
6    static extern private void destroyCameraService(IntPtr service);
7
8    [DllImport(NATIVE_LIB.NAME)]
9    static extern private int availableCameras(CameraServiceHandle service);
10}
Copy to clipboard

Now our C#-side service uses the handle internally:

1public class DeviceCameraService : CameraService, IDisposable {
2    private CameraServiceHandle _nativeHandle;
3    
4    public DeviceCameraService() {
5        _nativeHandle = CameraServiceExternMethods.makeCameraService();
6    }
7    
8    public void Dispose() = _nativeHandle.Dispose();
9
10    public int AvailableCameras() = 
11        CameraServiceExternMethods.availableCameras(_nativeHandle);
12}
Copy to clipboard

We’ve done it!

We’ve now gone from adding ad-hoc [DllImport] static extern methods all over the place, which was verbose, hard to test and was not necessarily resource safe, to a purposely designed approach.

We have a few small wrappers; they’re well-encapsulated, they don’t prevent testing for the code that depends on them, cross-platform support is easy to add, and we now have systematic ways to guarantee resource and type-safety when interacting with unmanaged objects!

That’s it for our tour on how P/Invoke is being used at Baracoda!

Leveraging P/Invoke has enabled us to write and interact with cross-platform libraries to bring the in-house knowledge in machine learning and computer vision of our R&D teams to our Unity games!

We’re planning on releasing more Unity developer content, so stay tuned!