Un-Stringifying Android Development with Qt 6.4
June 10, 2022 by Volker Hilsheimer | Comments
The Java Native Interface (JNI) makes it possible to call Java from C++ and vice versa, and in Qt we have a pair of classes, QJniObject
and QJniEnvironment
, that provide convenient, Qt-friendly APIs. Until recently, I have had little opportunity to develop for Android, but porting Qt Speech to Qt 6 and the face-lift of its Android implementation gave me a chance to get some experience. I also wanted to experiment with some of the new capabilities we are planning to introduce after 6.4, which involved adding a few more callbacks and passing a few more parameters between C++ and Java.
Even with the convenient Qt classes, calling APIs via JNI requires signature strings. This makes it time-consuming and error-prone to develop for the Android platform. After spending more time than I should have on putting together the correct strings, I wondered whether we could make developing against the JNI easier using modern C++.
Let's start with the status quo.
Calling Java methods
When calling a Java method from C++, we have to specify the method, which might be a static class method or an instance method, by name. And since Java, like C++, supports method overloading, we also have to specify which overload we want to use through a signature string.
QJniObject string = QJniObject::fromString(QLatin1String("Hello, Java"));
QJniObject subString = string.callObjectMethod("substring", "(II)Ljava/lang/String;", 0, 4);
Here we create a Java string from a QString, and then call the substring
method of the Java string class to get another string with the first 5 characters. The signature string informs the runtime that we want to call the overload that accepts two integers as parameters (that's the (II)
) and that returns a reference to a Java object (the L
prefix and ;
suffix) of type String
from the java/lang
package. We then have to use the callObjectMethod
from QJniObject
because we want to get a QJniObject
back, rather than a primitive type.
QJniObject string2 = QJniObject::fromString(QLatin1String("HELLO"));
jint ret = string.callMethod<jint>("compareToIgnoreCase", "(Ljava/lang/String;)I",
string2.object<jstring>());
Here we instantiate a second Java string, and then call the compareToIgnoreCase
method on the first string, passing the second string as a parameter. The method returns a primitive jint
, which is just a JNI typedef to int
, so we don't have to use callObjectMethod
but can use callMethod<jint>
instead.
Evidently, calling Java functions from C++ requires that we pass information to the compiler multiple times: we already have the types of the parameters we want to pass, and the compiler knows those: 0 and 4 are two integers in the first example, and string2.object<jstring>
is a jstring
. Nevertheless we also need to encode this information into the signature string, either remembering or regularly looking up the correct string for dozens of different types. The longest signature string I found in our repos is 118 characters long:
(Ljava/lang/String;ILjava/lang/Object;Ljava/lang/Object;FFFLjava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;FF)V
And we need to remember to use callObjectMethod
when we call a method that returns an object-type, while with callMethod
we have to explicitly specify the return type of the method.
Native callbacks
C/C++ functions that are callable from Java must accept a JNIEnv *
and a jclass
(or jobject
) as the first arguments, and any number of additional arguments of a JNI-compatible type, including a return type:
static jint nativeCallback(JNIEnv *env, jclass thiz, jstring value)
{
// ...
return 0;
}
JNI provides a type JNINativeMethod
that needs to be populated with the name of the function as a string, the signature string, and the pointer to the free function as void *
.
static const JNINativeMethod nativeMethods[] = {
"nativeCallback", "(Ljava/lang/String;)I", reinterpret_cast<void *>(nativeCallback));
}
An array of such JNINativeMethod structures can then be registered with a Java class:
static void initOnce()
{
QJniEnvironment env;
env.registerNativeMethods("className", nativeMethods, std::size(nativeMethods));
}
That's a lot of duplicate information again. Not only do we have to provide a signature string (when the compiler already knows the parameter types of the nativeCallback
function), we also have to pass the name of the function as a string when in almost all practical cases it will be the exact same as the name of the function itself. And we have to pass the size of the array, which the compiler evidently knows already as well.
Improvements with Qt 6.4
In the spirit of Don't Repeat Yourself, the goal for Qt 6.4 was to get rid of the explicit signature strings and of the explicit call to callObjectMethod
when calling Java methods from C++.
QJniObject subString = string.callObjectMethod("substring", "(II)Ljava/lang/String;", 0, 4);
can now be
QJniObject subString = string.callMethod<jstring>("substring", 0, 4);
And we also wanted to get rid of the signature string and other duplicate information when registering native callbacks with the JVM. Here we have to use a macro to declare a free function as a native JNI method and to register a list of such methods with the JVM:
static jint nativeCallback(JNIEnv *env, jclass thiz, jstring value)
{
// ...
return 0;
}
Q_DECLARE_JNI_NATIVE_METHOD(nativeCallback)
static void initOnce()
{
QJniEnvironment env;
env.registerNativeMethods("className", {
Q_JNI_NATIVE_METHOD(nativeCallback)
});
}
Let's have a look at how this is implemented.
Argument deduction and compile-time if
We need to solve three problems: we need to deduce the types from the parameters passed into the callMethod
function; we need to return a QJniObject
instance that wraps the Java reference if the return type is a reference type; and we need to assemble a complete signature string from the individual type strings for each parameter.
The first two parts of this problem are solved for us by C++ 17:
template<typename Ret, typename ...Args>
auto callMethod(const char *methodName, Args &&...args) const
{
const char *signature = "(?...?)?"; // TODO
if constexpr (std::is_convertible<Ret, jobject>::value) {
return callObjectMethod(methodName, signature, std::forward<Args>(args)...);
} else {
return callMethod<Ret>(methodName, signature, std::forward<Args>(args)...);
}
}
We use compile-time if
to call the old callObjectMethod
if the return type is a type that converts to jobject
; otherwise we use callMethod
. And the template return type is now auto
, so automatically deduced for us based on which branch gets compiled.
The last part of the problem remains. The types in the args
parameter pack are automatically deduced at compile time as well based on the values we pass into it:
QJniObject subString = string.callMethod<jstring>("substring", 0, 4);
Args
will be two integers, and Ret
is explicitly specified as jstring
. So the compiler has all the information it needs, just in the wrong form. Now we need to turn that list of types into a single signature string, and we need to do it at compile time.
Deducing the signature string at compile time
Before Qt 6.4, QJniObject
already allowed us to omit the signature string in a few situations:
QJniObject obj("org/qtproject/qt/android/QtActivityDelegate");
QVERIFY(obj.isValid());
QVERIFY(!obj.getField<jboolean>("m_fullScreen"));
Here, we don't provide any signature string to access the m_fullScreen
field of the QtActivityDelegate
Java object. Instead, Qt will use a template function that maps the type of the field, jboolean
in this case, to the signature string, which would be "Z".
template<typename T>
static constexpr const char* getTypeSignature()
{
if constexpr(std::is_same<T, jobject>::value)
return "Ljava/lang/Object;";
// ...
else if constexpr(std::is_same<T, int>::value)
return "I";
else if constexpr(std::is_same<T, bool>::value)
return "Z";
// ...
else
assertNoSuchType("No type signature known");
}
This happens at compile time: the compiler knows which type we instantiate QJniObject::getField
with, and can use the getTypeSignature
template function to generate a call of the JNI function with the correct const char *
, "Z"
. But we can hardly extend this template, or specialize it, for any arbitrary combination of types. What we need is a string type that can hold a fixed-size character array, but also supports compile-time concatenation of multiple such strings, and compile-time access to the string data as a const char*
.
template<size_t N_WITH_NULL>
struct String
{
char m_data[N_WITH_NULL] = {};
constexpr const char *data() const noexcept { return m_data; }
static constexpr size_t size() noexcept { return N_WITH_NULL; }
To allow constructing such a type from a string literal we need to add a constructor that accepts a reference to a character array, and use a constexpr
compliant method to copy the string:
constexpr explicit String(const char (&data)[N_WITH_NULL]) noexcept
{
for (size_t i = 0; i < N_WITH_NULL - 1; ++i)
m_data[i] = data[i];
}
To concatenate two such strings and create a new String object that holds the characters from both, we need an operator+
implementation that takes two String objects with sizes N_WITH_NULL
and N2_WITH_NULL
and returns a new String with the size N_WITH_NULL + N2_WITH_NULL - 1
:
template<size_t N2_WITH_NULL>
friend inline constexpr auto operator+(const String<N_WITH_NULL> &lhs,
const String<N2_WITH_NULL> &rhs) noexcept
{
char data[N_WITH_NULL + N2_WITH_NULL - 1] = {};
for (size_t i = 0; i < N_WITH_NULL - 1; ++i)
data[i] = lhs[i];
for (size_t i = 0; i < N2_WITH_NULL - 1; ++i)
data[N_WITH_NULL - 1 + i] = rhs[i];
return String<N_WITH_NULL + N2_WITH_NULL - 1>(data);
}
And to make testing of our type possible we can add index-access and the usual set of comparison operators, such as:
constexpr char at(size_t i) const { return m_data[i]; }
template<size_t N2_WITH_NULL>
friend inline constexpr bool operator==(const String<N_WITH_NULL> &lhs,
const String<N2_WITH_NULL> &rhs) noexcept
{
if constexpr (N_WITH_NULL != N2_WITH_NULL) {
return false;
} else {
for (size_t i = 0; i < N_WITH_NULL - 1; ++i) {
if (lhs.at(i) != rhs.at(i))
return false;
}
}
return true;
}
};
with trivial overloads.
We can now test this type using compile-time assertion, which then also proves that we didn't introduce any runtime overhead:
constexpr auto signature = String("(") + String("I") + String("I") + String(")")
+ String("Ljava/lang/String;");
static_assert(signature == "(II)Ljava/lang/String;");
Now we have a string type that can be concatenated at compile time, and we can use it in the getTypeSignature
template:
template<typename T>
static constexpr auto getTypeSignature()
{
if constexpr(std::is_same<T, jobject>::value)
return String("Ljava/lang/Object;");
// ...
else if constexpr(std::is_same<T, int>::value)
return String("I");
else if constexpr(std::is_same<T, bool>::value)
return String("Z");
// ...
else
assertNoSuchType("No type signature known");
}
Note that the return type of the getTypeSiganture
template has changed from const char *
to auto
, as a String<2>
holding a single character (plus null) is a different C++ type from String<19>
.
We now need a method that generates a single signature string from a template parameter pack by concatenating all the strings for all the types. For this we can use a fold expression:
template<typename Ret, typename ...Args>
static constexpr auto getMethodSignature()
{
return (String("(") +
... + getTypeSignature<std::decay_t<Args>>())
+ String(")")
+ typeSignature<Ret>();
}
With this helper, our new callMethod
function becomes:
template<typename Ret, typename ...Args>
auto callMethod(const char *methodName, Args &&...args) const
{
constexpr auto signature = getMethodSignature<Ret, Args...>();
if constexpr (std::is_convertible<Ret, jobject>::value) {
return callObjectMethod(methodName, signature.data(), std::forward<Args>(args)...);
} else {
return callMethod<Ret>(methodName, signature.data(), std::forward<Args>(args)...);
}
}
We can now call Java methods without providing an explicit signature string:
QJniObject subString = string.callMethod<jstring>("substring", 0, 4);
To be able to use our getMethodSignature
helper with the native callbacks that we want to register with the JVM we need to get compile-time access to the return type and parameter types of a free function:
template<typename Ret, typename ...Args>
static constexpr auto getNativeMethodSignature(Ret (*)(JNIEnv *, jclass, Args...))
{
return getMethodSignature<Ret, Args...>();
}
With that helper it would now be tempting to set up a JNINativeMethod
array like this:
static const JNINativeMethod nativeMethods[] = {
"nativeCallback", getNativeMethodSignature(nativeCallback),
reinterpret_cast<void *>(nativeCallback));
}
However, while the signature string is created at compile time, the String
object and the JNINativeMethod
struct instances have a life time like every other object in C++. We need to keep the String
object that holds the the native method signature string alive. And we also would still need the nativeCallback
both as a void *
and in its stringified version.
To get rid of the duplication and boiler plate involved in this we define a macro through which we can declare any matching function as a JNINativeMethod
:
static jint nativeCallback(JNIEnv *env, jclass thiz, jstring value)
{
// ...
return 0;
}
Q_DECLARE_JNI_NATIVE_METHOD(nativeCallback)
This macro expands to definitions of signature
and method
objects in a dedicated namespace:
namespace QtJniMethods {
static constexpr auto nativeCallback_signature =
QtJniTypes::nativeMethodSignature(nativeCallback);
static const JNINativeMethod nativeCallback_method = {
"nativeCallback", nativeCallback_signature.data(),
reinterpret_cast<void *>(nativeCallback)
};
}
Lastly, we can add a QJniEnvironment::registerNativeMethods
overload that takes an initializer list, which we populate in-place with the help of a second macro that unwraps the data structures declared by Q_DECLARE_JNI_NATIVE_METHOD
:
static void initOnce()
{
QJniEnvironment env;
env.registerNativeMethods("className", {
Q_JNI_NATIVE_METHOD(nativeCallback)
});
}
Representing more Java types in C++
We now have the pieces in place to simplify the interface between native C++ code and the JVM. However, we are limited to the types that are known to the type-mapping function template getTypeSignature
. In Qt code, we often have to work with additional types, like a Java File
or an Android Context
. For JNI these are all passed around as jobject
s and will be wrapped in a QJniObject
, but we do need to specify the correct type in the signature strings. This is fortunate, because all we have to do now is to provide a specialization of the getTypeSignature
template function for a C++ type that represents our Java type in the C++ code. This can be a simple tag type:
struct MyCustomJavaType {};
template<>
constexpr auto getTypeSignature<MyCustomJavaType>
{
return String("Lmy/custom/java/type;");
}
This is again made easy with the help of a few macros:
Q_DECLARE_JNI_TYPE(Context, "Landroid/content/Context;")
Q_DECLARE_JNI_TYPE(File, "Ljava/io/File;")
Q_DECLARE_JNI_CLASS(QtTextToSpeech, "org/qtproject/qt/android/speech/QtTextToSpeech")
With all this in place, the Android implementation of the Qt TextToSpeech engine could be simplified quite a bit. Have a look at the complete change on gerrit.
Next steps
The new APIs in QJniObject
and QJniEnvironment
are available and documented from Qt 6.4 on. The enablers and the macros for extending this type system with custom types are declared in the qjnitypes.h
header file, but are at the time of writing not fully documented. We will start rolling out the new APIs in Qt, and perhaps we will identify a number of Java types that we want to register centrally, and make further improvements to the templates and macros introduced here. So for the moment we are leaving some of the features introduced here as preliminary or internal APIs until we are confident that they are ready.
But if you are working on the Android port of Qt, or on Android Automotive, or if you need to use native Android APIs in your mobile application and don't want to bother with signature strings anymore, then we'd like to hear from you. Send us your feedback, and do let us know if you run into any issues with these new APIs in your projects.
Blog Topics:
Comments
Subscribe to our newsletter
Subscribe Newsletter
Try Qt 6.8 Now!
Download the latest release here: www.qt.io/download.
Qt 6.8 release focuses on technology trends like spatial computing & XR, complex data visualization in 2D & 3D, and ARM-based development for desktop.
We're Hiring
Check out all our open positions here and follow us on Instagram to see what it's like to be #QtPeople.