Unity Client

Unity Native: Android Native Calls

By Artem Glotov

In this article, we delve into the subtleties of making native calls in Android bindings, the process of data transfer between managed and native code, the optimal methods for transferring complex types, and the differences between invoking Java and Kotlin code.

Before proceeding, familiarize yourself with the official documentation* to grasp the basics, as this article primarily focuses on elaborating on details not covered there.

Supported Types

The table below details the types and typed arrays that Unity’s high-level API — AndroidJavaObject, AndroidJavaClass, and AndroidJavaProxy — supports out of the box. This means you can use these types in the parameters and return types of your methods, and Unity will handle the conversion to the respective Java types automatically. For cases outside of this inherent support, the use of AndroidJavaObject is necessary.

DescriptionC#Java / KotlinSupport for TypeSupport for Typed Array
8-bit Signed Integersbytebyte / ByteYesYes
16-bit Signed Integershortshort / ShortYesYes
32-bit Signed Integerintint / IntYesYes
64-bit Signed Integerlonglong / LongYesYes
32-bit Floating-Point Numberfloatfloat / FloatYesYes
64-bit Floating-Point Numberdoubledouble / DoubleYesYes
16-bit Unicode Charactercharchar / CharYesYes
Booleanboolboolean / BooleanYesYes
StringstringString / StringYesYes
8-bit Unsigned IntegerbytePartially (will be casted to sbyte with warning)Partially (will be casted to sbyte[] with warning)
16-bit Unsigned IntegerushortNo (return default value or throw exception)No (throw exception)
32-bit Unsigned IntegeruintNo (return default value or throw exception)No (throw exception)
64-bit Unsigned IntegerulongNo (return default value or throw exception)No (throw exception)
Value Type (Struct)structNoNo
Reference Type (Class)AndroidJavaObjectany reference typeYesOnly for returned type
This table shows compatibility between C# and Java/Kotlin types.

Example

public class PrimitiveTypesBindings 
{
    public int PassInt(int value)
    {
        using (var javaObject = new AndroidJavaObject("com.sample.java.samples.PrimitiveTypesSample"))
        {
            return javaObject.Call<int>("passInt", value);
        }
    }
}
public class PrimitiveTypesSample {
    public int passInt(int value) {
        System.out.println("Int value: " + value);
        return value;
    }
}

For any reference type, except string, the use of AndroidJavaObject is required; otherwise, an exception will be thrown. It’s important to note that for some unsupported primitive types specified as a return type, Unity doesn’t show an error, but instead returns their default value.

Transfer objects

Let’s consider a Java class with two methods: one that takes a complex object as a parameter and another that takes a simple object.

public class DataTransferSample {
    public void passComplexData(TestData data) {
        System.out.println("Complex data: " + data);
    }
    public void passSimpleData(TestItem data) {
        System.out.println("Simple data: " + data);
    }
}

There are two common ways to pass objects to Java:

  • Using AndroidJavaObject to create a Java object on the managed side and pass it to Java
  • Using serialization to convert a managed object into a string and deserialize it back on the Java side. This approach can only be used if you’re writing the Android Library yourself or if you’re creating a wrapper for an existing Android Library.

Wrapper Example

public class DataTransferSampleWrapper {
    private final DataTransferSample sample;
    private final Gson gson = new Gson();

    public DataTransferSampleWrapper(DataTransferSample sample) {
        this.sample = sample;
    }

    public void passComplexSerializedData(String serializedData) {
        TestData data = gson.fromJson(serializedData, TestData.class);
        sample.passComplexData(data);
    }

    public void passSimpleSerializedData(String serializedData) {
        TestItem data = gson.fromJson(serializedData, TestItem.class);
        sample.passSimpleData(data);
    }
}

Performance tests were conducted to evaluate the speed at which Unity can transfer data to native code using the following methods:

  1. AndroidJavaObject: Utilizes AndroidJavaObject on the managed side to transfer data to native
  2. NewtonsoftJson: Employs Newtonsoft.Json for serializing data into a string on the managed side, then deserializing it using Gson on the native side
  3. JsonUtility: Uses JsonUtility for serializing data into a string on the managed side, then deserializing it using Gson on the native side

For tests I used the following data structures:
Complex object

[System.Serializable]
public class TestData {
    public int Id { get; set; }
    public string Name { get; set; }
    public List<float> Values { get; set; } // x100 items
    public Dictionary<string, TestItem> Items { get; set; } // x100 items
}
public class TestData {
    public int id;
    public String name;
    public List<Float> values;
    public Map<String, TestItem> items;
}

Simple object

[Serializable]
public class TestItem
{
    public long Value { get; set; }
}
public class TestItem {
    public long value;
}

Results:

  • JsonUtility: This is the quickest method for handling complex data. For small data, it shows average results.
  • AndroidJavaObject: This method is fast for small and simple data. But when the data gets big and complex, it becomes the slowest. Also, it uses up more memory compared to other methods, regardless of the size of the data.
  • For any approach, the first call is the most expensive. Subsequent calls are much faster due to the caching of the Java method IDs and reflection information used for serialization.

Measured Number of GC allocation Calls

Measured CPU Time in Milliseconds

Generic Types

JVM generics work using type erasure, which allows you to create instances of generic types without needing to specify the generic type from the C# side. In Java, type checks aren’t performed when you create an instance or pass it as an argument; they only occur when you actually use the generic items.

public int PassListOfWrongType(List<float> values, bool useItems) 
{
    using var arrayList = new AndroidJavaObject("java.util.ArrayList");
    using var floatClass = new AndroidJavaClass("java.lang.Float");
    foreach (var value in values)
    {
        using var floatClassValue = floatClass.CallStatic<AndroidJavaObject>("valueOf", value);
        arrayList.Call<bool>("add", floatClassValue);
    }
    return _javaInstance.Call<int>("passStringList", arrayList, useItems);
}
public class GenericTypesSample {
    public int passStringList(List<String> list, boolean useItems) {
        if (useItems) {
            for (String item : list) {
                System.out.println(item);
            }
        }
        return list.size();
    }
}

Kotlin Key Differences

Many types are similar to those in Java, as you can see in the number representation on the JVM**. However, there are differences when writing Kotlin plugins and bindings for Unity compared to Java. Most of these differences stem from the differences between Kotlin and Java**.

Nullability

Kotlin has a null-safe type system**, so you need to specify whether a type can be null or not. For example, String is a non-nullable type, but String? is nullable type.
Example

The following methods looks quite similar in Java and Kotlin:

public class PrimitiveArrayTypesSample {
    public byte[] passByteArray(byte[] values) {
        System.out.println("Byte array values: " + Arrays.toString(values));
        return values;
    }
}
class PrimitiveArrayTypesSample {
    fun passByteArray(values: ByteArray): ByteArray {
        println("Byte array values: " + values.contentToString())
        return values
    }
}

But the Kotlin example will throw an exception if you pass null as an argument because ByteArray is a non-nullable type. The correct way is to use ByteArray? instead if you expect null as an argument:

class PrimitiveArrayTypesSample {
    fun passByteArray(values: ByteArray?): ByteArray? {
        println("Byte array values: " + values.contentToString())
        return values
    }
}

Singleton (Object)

Kotlin has a concept called an object declaration**. An object is a class that has only one instance (a singleton). To make an object‘s method callable like a static method in Java or C#, you need to annotate it with @JvmStatic. Without this annotation, you must reference the static INSTANCE property to call the method.
Example

With @JvmStatic Annotation

public static bool CallStaticMethodFromSingleton()
{
    using var singletonClass = new AndroidJavaClass("com.sample.kotlin.samples.SingletonSample");
    return singletonClass.CallStatic<bool>("callStaticMethod");
}
object SingletonSample {
    @JvmStatic fun callStaticMethod(): Boolean {
        println("This is a static method")
        return true
    }
}

Without @JvmStatic annotation

public static bool CallInstanceMethodFromSingleton()
{
    using var singletonClass = new AndroidJavaClass("com.sample.kotlin.samples.SingletonSample");
    using var singletonInstance = singletonClass.CallStatic<AndroidJavaObject>("INSTANCE");
    return singletonInstance.Call<bool>("callInstanceMethod");
}
object SingletonSample {
    fun callInstanceMethod(): Boolean {
        println("This is an instance method")
        return true
    }
}

Companion Object

One of the differences between Kotlin and Java is that Kotlin does not have explicit static methods or fields. Instead, it uses companion objects to hold methods and properties that are shared by all instances of a class. To make a companion object method callable like a static method from Java or C#, you need to annotate it with @JvmStatic.
Example

public static bool CallStaticMethod()
{
    using var staticClass = new AndroidJavaClass("com.sample.kotlin.samples.StaticMethodSample");
    return staticClass.CallStatic<bool>("callStaticMethod");
}
class StaticMethodSample {
    companion object {
        @JvmStatic fun callStaticMethod(): Boolean {
            println("This is a static method")
            return true
        }
    }
}

Unsigned types

Kotlin has experimental support for unsigned types**. However, they aren’t compatible with C# unsigned types for the following reasons:

  • Unsigned numbers in Kotlin are implemented as inline classes, having a single storage property that corresponds to the Java signed counterpart of the same width
  • Unity doesn’t support unsigned C# classes in AndroidJavaObject

Summary

Now, you have enhanced your knowledge about how to make calls to Android’s native code, how to transfer simple and complex object types, work with generic Java types from Unity code, and write Java and Kotlin plugins.

(*) Content in this article is sourced from Unity’s Documentation. Copyright © 2024 Unity Technologies. Publication Date: 2024-02-05.

(**) Content in this article is sourced from Kotlin’s Documentation. Copyright © 2024 JetBrains s.r.o.



Tags