Un-Stringifying Android Development with Qt 6.4
使用Qt 6.4取消Android开发的限制
Friday June 10, 2022 by Volker Hilsheimer | Comments
2022年6月10日,星期五,沃尔克·希尔希默评论
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.
Java本地接口(JNI)使从C++调用Java成为可能,反之亦然。在Qt中,我们有一对类QJniObject和QJniEnvironment,它们提供了方便的、Qt友好的API。直到最近,我几乎没有机会为Android开发,但将Qt语音移植到Qt 6及其Android实现的改头换面让我有机会获得一些经验。我还想尝试一下我们计划在6.4之后引入的一些新功能,其中包括添加更多回调以及在C++和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++.
即使使用方便的Qt类,通过JNI调用API也需要签名字符串。这使得为Android平台开发非常耗时且容易出错。在花了比我应该花的更多的时间来组合正确的字符串之后,我想知道是否可以使用现代C++使针对JNI的开发变得更容易。
Let's start with the status quo.
让我们从现状开始。
Calling Java methods
调用Java方法
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.
当从C++调用Java方法时,我们必须按名称指定该方法,该方法可能是静态类方法或实例方法。由于Java和C++一样支持方法重载,我们还必须通过签名字符串指定要使用的重载。
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.
这里,我们从QString创建一个Java字符串,然后调用Java string类的substring方法来获取另一个包含前5个字符的字符串。签名字符串通知运行时,我们要调用重载,该重载接受两个整数作为参数(即(II)),并返回对Java/lang包中string类型的Java对象(L前缀和;后缀)的引用。然后,我们必须使用来自QJniObject的callObjectMethod,因为我们想要返回QJniObject,而不是原语类型。
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.
这里,我们实例化第二个Java字符串,然后对第一个字符串调用compareToIgnoreCase方法,将第二个字符串作为参数传递。该方法返回一个原始的jint,它只是一个JNI typedef to int,因此我们不必使用callObjectMethod,而是可以使用callMethod<jint>。
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:
显然,从C++调用Java函数需要我们多次向编译器传递信息:我们已经有了要传递的参数类型,编译器知道这些:在第一个示例中,0和4是两个整数,string2是两个整数。string2.object<jstring>
是一个jstring。然而,我们还需要将这些信息编码到签名字符串中,记住或定期查找几十种不同类型的正确字符串。我在回购协议中发现的最长签名字符串为118个字符:
(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.
当调用返回对象类型的方法时,我们需要记住使用callObjectMethod,而使用callMethod时,我们必须显式指定方法的返回类型。
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:
可从Java调用的C/C++函数必须接受JNIEnv*和jclass(或jobject)作为第一个参数,以及任何数量的JNI兼容类型的附加参数,包括返回类型:
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 *
.
JNI提供了一个类型JNINativeMethod,需要将函数名填充为字符串,签名字符串和指向自由函数的指针填充为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:
然后,可以向Java类注册这样的JNINativeMethod结构数组:
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.
这又是大量重复信息。我们不仅必须提供签名字符串(当编译器已经知道nativeCallback函数的参数类型时),我们还必须将函数名作为字符串传递,而在几乎所有实际情况下,它将与函数本身的名称完全相同。我们必须传递数组的大小,编译器显然也已经知道了。
Improvements with Qt 6.4
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++.
本着“不要重复自己”的精神,Qt 6.4的目标是在从C++调用Java方法时去掉显式签名字符串和对callObjectMethod的显式调用。
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:
我们还希望在向JVM注册本机回调时消除签名字符串和其他重复信息。在这里,我们必须使用宏来声明一个自由函数作为本机JNI方法,并向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
参数推导和编译时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.
我们需要解决三个问题:我们需要从传入callMethod函数的参数推断类型;如果返回类型是引用类型,我们需要返回一个包装Java引用的QJniObject实例;我们需要从每个参数的各个类型字符串中组装一个完整的签名字符串。
The first two parts of this problem are solved for us by C++ 17:
此问题的前两部分由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.
如果返回类型是转换为jobject的类型,则使用编译时if调用旧的callObjectMethod;否则,我们使用callMethod。而且模板返回类型现在是自动的,因此我们可以根据编译的分支自动推断。
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:
问题的最后一部分仍然存在。args参数包中的类型也会在编译时根据传入的值自动推断出来:
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.
Args是两个整数,Ret显式指定为jstring。因此,编译器以错误的形式获得了所需的所有信息。现在,我们需要将该类型列表转换为单个签名字符串,并且需要在编译时完成。
Deducing the signature string at compile time
在编译时推导签名字符串
Before Qt 6.4, QJniObject
already allowed us to omit the signature string in a few situations:
在Qt 6.4之前,QJniObject已经允许我们在一些情况下省略签名字符串:
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".
这里,我们不提供任何签名字符串来访问QtActivityDelegate Java对象的m_fullScreen
字段。相反,Qt将使用一个模板函数,该函数将字段的类型jboolean映射到签名字符串,即“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*
.
这发生在编译时:编译器知道我们实例化QJniObject::getField的类型,并且可以使用getTypeSignature模板函数生成具有正确const char *
, "Z"
的JNI函数调用。但是,对于任意类型的组合,我们很难扩展或专门化此模板。我们需要的是一种字符串类型,它可以保存一个固定大小的字符数组,但也支持多个此类字符串的编译时串联,并支持以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的方法复制字符串:
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
:
为了连接两个这样的字符串并创建一个新的字符串对象来保存这两个字符串中的字符,我们需要一个operator+
实现,它接受两个大小为N_WITH_NULL
和N2_WITH_NULL
的字符串对象,并返回一个大小为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:
现在我们有了一个可以在编译时串联的字符串类型,我们可以在getTypeSignature模板中使用它:
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>
.
请注意,getTypeSiganture模板的返回类型已从const char*更改为auto,因为包含单个字符(加上null)的String<2>
是与String<19>
不同的C++类型。
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:
有了这个助手,我们的新callMethod函数变成:
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:
现在,我们可以在不提供显式签名字符串的情况下调用Java方法:
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:
为了能够将我们的getMethodSignature助手用于我们想要向JVM注册的本机回调,我们需要获得对自由函数的返回类型和参数类型的编译时访问权:
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:
有了这个助手,现在可以像这样设置JNINativeMethod数组:
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.
然而,虽然签名字符串是在编译时创建的,但String
对象和JNINativeMethod
结构实例的生命周期与C++中的其他对象一样。我们需要保持保存本机方法签名字符串的String对象处于活动状态。我们还需要nativeCallback
作为一个void *
和它的字符串化版本。
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
:
为了消除其中涉及的复制和锅炉板,我们定义了一个宏,通过该宏,我们可以将任何匹配函数声明为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
:
最后,我们可以添加一个QJniEnvironment::registerNativeMethods重载,该重载接受一个初始值设定项列表,我们在第二个宏的帮助下将其填充到位,该宏打开了由Q_DECLARE_JNI_NATIVE_METHOD
方法声明的数据结构:
static void initOnce()
{
QJniEnvironment env;
env.registerNativeMethods("className", {
Q_JNI_NATIVE_METHOD(nativeCallback)
});
}
Representing more Java types in C++
用C++表示更多Java类型
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:
我们现在已经准备好了简化本地C++代码和JVM之间接口的部分。但是,我们仅限于类型映射函数模板getTypeSignature已知的类型。在Qt代码中,我们经常需要处理其他类型,如Java File
或Android Context
。对于JNI,这些都作为jobjects传递,并将包装在QJniObject中,但我们确实需要在签名字符串中指定正确的类型。这是幸运的,因为我们现在所要做的就是为C++类型提供getTypeSignature模板函数的专门化,该类型在C++代码中表示我们的Java类型。这可以是一种简单的标记类型:
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.
有了所有这些,Qt TextToSpeech引擎的Android实现可以大大简化。看看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.
QJniObject和QJniEnvironment中的新API在上的Qt 6.4中提供并记录。用于使用自定义类型扩展此类型系统的启用码和宏在qjnitypes.h中声明头文件,但在编写时没有完整的文档记录。我们将开始在Qt中推出新的api,也许我们将确定一些要集中注册的Java类型,并进一步改进这里介绍的模板和宏。因此,目前我们将这里介绍的一些功能作为初步或内部API,直到我们确信它们已经准备好为止。
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.
但是,如果您正在使用Qt的Android端口,或Android Automotive,或者如果您需要在移动应用程序中使用本地Android API,并且不想再为签名字符串而烦恼,那么我们很想听听您的意见。将您的反馈发送给我们,如果您在项目中遇到这些新API的任何问题,一定要让我们知道。