[Kotlin/Native] 封装 JNI 常用函数

先来看一个最基本的 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!!
    ... ...
}

基本的架子就这么简单,后面慢慢加内容,有了这些东西后,可以写两个基本函数,用于完成 Stringjstring 的相互转换:

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)
}

这下可好,是不是更加不知道怎么用了?因为 Stringjstring 的上下文都没有嘛,现在就要开始变魔术了,我们在 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

你可能感兴趣的:([Kotlin/Native] 封装 JNI 常用函数)