先来看一个最基本的 K/N 作用于 JNI 的函数,它将是一切的开端:
@CName("Java_com_rarnu_common_HelloJni_hello")
fun jniHello(env: CPointer, thiz: jobject): jstring = memScoped {
return env.pointed.pointed!!.NewStringUTF!!.invoke(env, "Hello NDK".cstr.ptr)!!
}
你是不是会觉得写这样的代码很麻烦,一点都不 Kotlin,甚至还有一些反感?
如果不爽就对了,如果爽的话也就没有这篇了,为了把代码写舒服了,真的也是要付出不少代价的,至少在 K/N 的场景下,没有一些舒服的封装真的会让人生不如死的。
那么开始正文,首先想要封装的东西是文件操作的 API,由于之前基本上都在用 JVM 下的 Kotlin,遇到文件操作基本上就直接 File
了,可惜 K/N 下没有这东西,K/N 能用的东西基本上就是 Kotlin 标准库以及 cinterop
,虽说很强大,但是真的用起来却是实实在在的麻烦,比如读取一个文本文件:
actual fun readText() = memScoped {
val st = alloc()
stat(innerFilePath, st.ptr)
val size = st.st_size
val buf = allocArray(size)
val f = fopen(innerFilePath, "rb")
fread(buf, 1UL, size.toULong(), f)
fclose(f)
buf.toKString()
}
诶,这么一写不就是 C 么?Kotlin 自己没有 API?是的,目前就是没有,所以才如此强调 cinterop
,所幸的是 cinterop
转换的函数与 Kotlin 相容性很好,而且也忠于原始的 C 库。
这里有一个关键的关型,即 stat
,它的定义是这样的:
@kotlinx.cinterop.internal.CStruct
public final class stat public constructor(rawPtr: kotlinx.cinterop.NativePtr ) : kotlinx.cinterop.CStructVar
或许一开始接触的人都会很郁闷,构造函数里那个 NativePtr
参数是啥,要怎么传参构造呢?在这里我必须告诉各位的是,以后看到这种构造方式的类型,直接在 memScoped
里面 alloc
就好,可以直接得到想要的对象,至于利用构造函数来构造,放弃这个想法吧。相同的情况也出现在构造 jni
调用 java 的传参问题上,后面会讲到。
在 K/N 里面,JetBrains 还算照顾我们,提供了一些不错的转换函数,比如说以下这些,可以让开发变得更简单:
toKString() // 将一个CPointer 转换成 Kotlin 字符串
readBytes() // 将一个 CPointer 转换成 Kotlin 的 ByteArray
cstr // 将一个 Kotlin 字符串转换成 CValues
toCValues() // 将一个 Kotlin ByteArray 转换成 CValues
所以我们可以在此基础之上,轻松的写出以下函数:
actual fun readContent() = memScoped {
val st = alloc()
stat(innerFilePath, st.ptr)
val size = st.st_size
val buf = allocArray(size)
val f = fopen(innerFilePath, "rb")
fread(buf, 1UL, size.toULong(), f)
fclose(f)
buf.readBytes(size.toInt())
}
actual fun writeContent(content: ByteArray) = memScoped {
val f = fopen(innerFilePath, "wb")
val buf = content.toCValues()
val ret = fwrite(buf, buf.size.toULong(), 1, f)
fclose(f)
ret == 0UL
}
actual fun list(): List = memScoped {
val list = mutableListOf()
val d = opendir(innerFilePath)
while (true) {
val entry = readdir(d)
if (entry == NULL) break
val dname = entry!!.pointed.d_name.toKString()
if (dname == "." || dname == ".." || dname.trim() == "") continue
list.add(dname)
}
return list
}
同样的思路,可以把常用的文件操作都包装起来,就像 JVM 下的 File
一样,在这里(点击查看)我放了一份封装后的文件,可以直接取用。
下面要来封装一下 JNI 相关的函数了,像开头那种写法太不友好了,有必要造桥。桥的造法各有千秋,这里我不打算对各种方法作任何的评论,只谈自己的封装方法。
首先我一定会把 env: CPointer
这个对象封装掉,在 JNI 方法中,时时刻刻要用它,于是先写个简单架子,把 env
和一些常用函数写进去:
data class JniClass(val jclass: jclass)
data class JniObject(val jobject: jobject)
data class JniMethod(val jmethod: jmethodID)
fun asJniClass(jclass: jclass?) = if (jclass != null) JniClass(jclass) else null
fun asJniObject(jobject: jobject?) = if (jobject != null) JniObject(jobject) else null
fun asJniMethod(jmethodID: jmethodID?) = if (jmethodID != null) JniMethod(jmethodID) else null
class JniBridge(val env: CPointer) {
private val innerEnv = env.pointed.pointed!!
private val fNewStringUTF = innerEnv.NewStringUTF!!
private val fGetStringUTFChars = innerEnv.GetStringUTFChars!!
private val fReleaseStringUTFChars = innerEnv.ReleaseStringUTFChars!!
... ...
}
基本的架子就这么简单,后面慢慢加内容,有了这些东西后,可以写两个基本函数,用于完成 String
和 jstring
的相互转换:
class JniBridge(val env: CPointer) {
... ...
private fun toJString(string: String) = memScoped {
val result = asJniObject(fNewStringUTF(env, string.cstr.ptr))
check()
result
}
private fun toKString(string: jstring) = memScoped {
val isCopy = alloc()
val chars = fGetStringUTFChars(env, string, isCopy.ptr)
var ret: String? = null
if (chars != null) {
ret = chars.toKString()
fReleaseStringUTFChars(env, string, chars)
}
ret
}
}
这次再次遇到 alloc
这种写法,同样的,它也是一个接受 NativePtr
作为构造参数的类型,可以直接 alloc
。
写完后要怎么用呢?方法如下:
val jniStr = JniBridge(env).toJString("hello")
此时就能得到一个 jstring
类型的对象了。但是对于我来说,我觉得它依然不方便,我希望可以在字符串对象上直接转换,那么再扩展下:
class JniBridge(val env: CPointer) {
... ...
fun String.asJString() = toJString(this)!!.jobject
fun jstring.asKString() = toKString(this)
}
这下可好,是不是更加不知道怎么用了?因为 String
或 jstring
的上下文都没有嘛,现在就要开始变魔术了,我们在 JniBridge
里再加一个方法:
class JniBridge(val env: CPointer) {
... ...
val fPushLocalFrame = innerEnv.PushLocalFrame!!
val fPopLocalFrame = innerEnv.PopLocalFrame!!
... ...
inline fun withLocalFrame(block: JniBridge.() -> T): T {
if (fPushLocalFrame(env, 0) < 0) throw Error("Cannot push new local frame")
try { return block() } finally { fPopLocalFrame(env, null) }
}
}
有了这个函数后,我们可以在全局加一个函数,来实现对完整上下文的包装:
inline fun jniWith(env: CPointer, block: JniBridge.() -> T) =
JniBridge(env).withLocalFrame(block)
下面就是使用了,我们现在已经完成了变魔术所需要的条件,把本文开头的那个函数改一下:
@CName("Java_com_rarnu_common_HelloJni_hello")
fun jniHello(env: CPointer, thiz: jobject): jstring = jniWith(env) {
"Hello NDK".asJString()
}
这么一来,就看不到 JNI 函数在背后的动作了,API 非常简洁,对开发者友好。我们可以把所有的操作都写在 jniWith
里面,它具备 JniBridge
的完整上下文。
下面再来看一下如何从 JNI 调用 Java 方法,有了上面的封装经验后,要搞个好玩的东西出来就简单了:
class JniBridge(val env: CPointer) {
... ...
private val fFindClass = innerEnv.FindClass!!
private val fGetMethodID = innerEnv.GetMethodID!!
private val fCallObjectMethodA = innerEnv.CallObjectMethodA!!
private val fGetObjectClass = innerEnv.GetObjectClass!!
... ...
fun findClass(name: String) = memScoped {
asJniClass(fFindClass(env, name.cstr.ptr))
}
fun getObjectClass(obj: jobject) = memScoped {
asJniClass(fGetObjectClass(env, obj))
}
fun getMethodID(clazz: JniClass?, name: String, signature: String) = memScoped {
asJniMethod(fGetMethodID(env, clazz?.jclass, name.cstr.ptr, signature.cstr.ptr))
}
fun callObjectMethod(receiver: JniObject?, method: JniMethod, vararg arguments: Any?) = memScoped {
asJniObject(fCallObjectMethodA(env, receiver?.jobject, method.jmethod, null))
}
}
细心的话你会发现这里留了个尾巴,调用函数如何传参呢?虽然写了 arguments
参数,但是实际传参是 null
,很显然这里需要把参数补齐。
这个参数是一个 CPointer
类型的对象,因此我们就必须把参数构造成这样的,才可以正常传递,在此又要多写一个函数:
class JniBridge(val env: CPointer) {
... ...
private fun toJValues(arguments: Array, scope: MemScope): CPointer? {
val result = scope.allocArray(arguments.size)
arguments.mapIndexed { index, it -> when (it) {
null -> result[index].l = null
is JniObject -> result[index].l = it.jobject
is String -> result[index].l = toJString(it)?.jobject
is Int -> result[index].i = it
is Long -> result[index].j = it
is Byte -> result[index].b = it
is Short -> result[index].s = it
is Double -> result[index].d = it
is Float -> result[index].f = it
is Char -> result[index].c = it.toInt().toUShort()
is Boolean -> result[index].z = (if (it) JNI_TRUE else JNI_FALSE).toUByte()
else -> throw Error("Unsupported conversion for ${it::class.simpleName}")
}}
return result
}
}
看起来很复杂,但是实质上是在根据不同的参数类型,对 jvalue
进行填充,这里再一次的用到了 alloc
,来对一组 jvalue
进行初始化,这是必须掌握的一种写法,要好好记住哦:)
这里还有一个 scope: MemScope
参数,这是个什么东西呢?其实它来源于 memScoped
方法,会直接构造出一个 MemScope
类型,toJValues
必须用这种传入 scope 的方法来实现,是因为 memScoped
会在函数结束时,回收分配的内存,而我们构造出来的 CPointer
却必须被返回,并且被使用后才可以销毁,因此对它的内存管理必须依赖上一个 scope。
好了,有了这个方法后,改一下上面的代码:
fun callObjectMethod(receiver: JniObject?, method: JniMethod, vararg arguments: Any?) = memScoped {
asJniObject(fCallObjectMethodA(env, receiver?.jobject, method.jmethod, toJValues(arguments, this@memScoped)))
}
不过我依然觉得这样不简洁,我希望有更简单的写法,加一个扩展:
class JniBridge(val env: CPointer) {
... ...
fun Array<*>.asJValues(scope: MemScope) = toJValues(this, scope)
}
这样就又可以做一个很细小的改动了:
fun callObjectMethod(receiver: JniObject?, method: JniMethod, arguments: Array?) = memScoped {
asJniObject(fCallObjectMethodA(env, receiver?.jobject, method.jmethod, arguments?.asJValues(this@memScoped)))
}
最终我们想要的效果是这样的:
@CName("Java_com_rarnu_common_HelloJni_callJvm")
fun jniCallJvm(env: CPointer, thiz: jobject): jstring = jniWith(env) {
val jcls = getObjectClass(thiz)
val jmthd = getMethodID(jcls, "callFromNative", "(ILjava/lang/String;)Ljava/lang/String;")
callObjectMethod(thiz, jmthd!!, 1, "NDK")!!.jobject
}
以上对于 JNI 的封装,我同样提供了一个完整文件供取用,点击查阅
另外,我也发布了一个 K/N for NDK 的封装库,如果你打算使用 Kotlin 来开发 NDK 应用,可以直接在 gradle 内使用它:
... ...
maven { url 'http://119.3.22.119:8081/repository/maven-releases' }
... ...
implementation 'com.rarnu:kn-common-ndk:0.0.1' // for common
... ...
implementation 'com.rarnu:kn-common-ndk-android64:0.0.1' // for android64