Talking to your unmanaged code efficiently from Unity using P/Invoke — software design edition
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}
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}
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}
And the import now look like this:
1public class DeviceCameraService {
2 [DllImport(NATIVE_LIB.NAME)]
3 public static extern int availableCameras();
4}
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}
Now we can instantiate a fake for the tests, but still use the real implementation for production!
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}
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}
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}
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}
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.
- Tell
SafeHandle
what an invalid value is for the internal handle, and whether it is the sole owner of that handle through its constructor. - 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. - 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}
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}
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}
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!