前言

在本系列的前两篇文章中咱们现已学会了如安在 kotlin native 渠道(iOS)运用 cinterop 调用 C/C++ 代码。以及在 jvm 渠道(Android、Desktop)运用 jni 调用 C/C++ 代码,并且知道了怎么主动编译 Android 端运用的 jni 代码给 Desktop 运用。

那么,咱们还犹疑什么呢,是时分把它们都组合在一起,完结真实的 Compose MultiPlatform 全渠道调用 C/C++ 了。

在本文中咱们将以我上一年运用 Compose 写的安卓端的一个简略的小游戏 demo 举例。(终于换项目举例了,哈哈哈)

项目地址life-game-compose

准备工作

在正式开端之前,咱们需求先简略的了解一下这个项目,能够看我之前写的这两篇介绍文章:基于 jetpack compose,运用MVI架构+自定义布局完结的康威生命游戏运用compose完结康威生命游戏之二:我是怎么将核算速度减缩将近十倍的

简略介绍一下便是这是个模仿”细胞演化”的小游戏,跟着时刻的推移,其间的细胞会由于周围的环境(周围细胞数量)而逝世或继续存活。

而在我这个 demo 中,关于细胞状况的逻辑运算运用的是 C 言语写的,由于这部分是动画的帧间核算,对运算速度的要求比较高,运用 C 来完结速度将大幅提高。

当然,这仅仅我上一年测验时的状况,本年当我重新测验时,发现其实在一般设备上直接运用 kotlin 运算的速度和调用 C 运算的速度实际上相差无几,乃至运用 kotlin 比调用 C 更快。

这并不是由于 kotlin 速度真的比 C 快,仅仅由于如果想要调用 C 的话,需求首要将 kotlin 的数据类型转为 C 中可用的数据类型,时刻大多数耗在了数据类型转化上。说句欠好听的,有转数据的这个功夫,我直接拿 kotlin 算都现已算出成果来了。

或许你也会说,那我不转数据能够吗?

欸,还真能够。仅仅关于咱们这篇文章要讲的状况不能够,由于咱们想要完结的是同一套 C 代码,提供给不同的渠道运用,显然 cinterop 和 jni 关于 kotlin 的数据类型与 C 之间的数据类型映射不相同,因而咱们为了完结通用性,只能在将数据传递给 C 之前先转为通用的数据类型。

(以上表述比较片面,也不太精确,后文会有详细的解说)

上面说了这么多,仅仅想叠个 buff ,那便是咱们这儿运用的这个项目用于举例并不是很恰当,由于这反而会让程序的性能下降。

可是用于了解怎么完结在 KMP 中调用 C/C++ 还是十分有用的,权当是抛砖引玉。

最终,上面提到过,本来这个项目仅仅个 Android 项目,所以在开端咱们今日的修正之前,需求先把它移植为 KMP 项目,这儿就不再赘述了,由于之前很多篇文章都有说过怎么移植,需求的能够自行翻阅我之前的历史文章。

开端修正

项目全体结构

开端之前,咱们先来看看修正完结之后的项目全体结构,需求要点关注的是图中圈起来的部分:

为 Compose MultiPlatform 增加 C/C++ 支持(3):实战 Desktop、Android、iOS 调用同一个 C/C++ 代码

其间 圈1 是项目的 nativelib 模块,这个模块实际上是一个 Android native library 模块,可是咱们这儿把中心运算代码直接放到这个模块里边了。

game.h 文件即咱们需求的算法的详细完结。

nativelib.cpp 是提供给 jni (Android、Desktop)调用的进口函数。

圈 2 是 Desktop 完结调用上述 C++ 代码编译成的二进制库的 kotlin 代码。

圈 3 是 iOS 运用 cinterop 完结调用上述 C++ 算法的进口函数。

接下来,咱们挨个看它们的详细代码和需求留意的地方。

中心算法完结

nativelib 模块下的 game.h 文件是整个项目的细胞状况运算中心代码,一切渠道最终都是调用其间的 updateStep(int **board, int len1, int len2) 函数完结对细胞状况的核算,代码如下:

#ifndef LIFEGAME_GAME_H
#define LIFEGAME_GAME_H
/*
* 该算法来历如下:
*
* 作者:Time-Limit
* 链接:https://leetcode.cn/problems/game-of-life/solution/c-wei-yun-suan-yuan-di-cao-zuo-ji-bai-shuang-bai-b/
* 来历:力扣(LeetCode)
* 著作权归作者一切。商业转载请联系作者获得授权,非商业转载请注明出处。
*/
void updateStep(int **board, int len1, int len2) {
    int dx[] = {-1,  0,  1, -1, 1, -1, 0, 1};
    int dy[] = {-1, -1, -1,  0, 0,  1, 1, 1};
    for(int i = 0; i < len1; i++) {
        for(int j = 0 ; j < len2; j++) {
            int sum = 0;
            for(int k = 0; k < 8; k++) {
                int nx = i + dx[k];
                int ny = j + dy[k];
                if(nx >= 0 && nx < len1 && ny >= 0 && ny < len2) {
                    sum += (board[nx][ny]&1); // 只累加最低位
                }
            }
            if(board[i][j] == 1) {
                if(sum == 2 || sum == 3) {
                    board[i][j] |= 2;  // 运用第二个bit符号是否存活
                }
            } else {
                if(sum == 3) {
                    board[i][j] |= 2; // 运用第二个bit符号是否存活
                }
            }
        }
    }
    for(int i = 0; i < len1; i++) {
        for(int j = 0; j < len2 ; j++) {
            board[i][j] >>= 1; //右移一位,用第二bit覆盖第一个bit。
        }
    }
}
#endif //LIFEGAME_GAME_H

能够看到,这是个纯粹的 C 代码,没有参杂任何涉及到渠道相关的东西,因而它也是咱们的共享代码部分。

详细的运算逻辑这儿就不说了,咱们只需求知道咱们项目中的细胞状况运用一个二维整数数组存放,数组索引即为游戏中对应方位细胞的状况,其间数组内容为 0 时表明该方位细胞已逝世,1 表明该方位细胞处于存活状况。

所以该函数接纳三个参数 boardlen1len2 ,其间 board 即表明当前状况的二维数组,该函数会在运算中直接修正该数组的内容;len1len2 分别表明数组的行长度和列长度。

了解了中心运算函数,接下来咱们看看各个渠道怎么运用它。

jvm 运用 jni 调用中心算法

在 Android 和 Desktop 将运用 jni 调用上一小节中的 updateStep 函数。

这儿咱们的 Android 和 Desktop 虽然编译方式不同,可是仍旧能够直接复用同一个进口函数,即 nativelib 模块中的 nativelib.cpp 文件的 Java_com_equationl_nativelib_NativeLib_stepUpdate 函数。

该函数签名如下:

extern "C" JNIEXPORT jobjectArray JNICALL
Java_com_equationl_nativelib_NativeLib_stepUpdate(
        JNIEnv* env,
        jobject,
        jobjectArray lifeList
        )

能够看到,该函数接纳 3 个参数: JNIEnv* envjobjectjobjectArray lifeList ,其间前两个是 jni 的固定参数,最终一个 jobjectArray 类型的参数 lifeList 才是咱们实际需求传递的参数,该函数回来的数据类型也是一个 jobjectArray

接下来,咱们看下在 kotlin 中调用这个函数的签名:

external fun stepUpdate(lifeList: Array<IntArray>): Array<IntArray>

能够看到,C 中的 jobjectArray 被映射为了 kotlin 中的 Array<IntArray> 类型。

事实上,jobjectArray 并不是 C 的类型,而是 jni 定义的一个自定义数据类型:

class _jobject {};
class _jarray : public _jobject {};
class _jobjectArray : public _jarray {};
typedef _jobjectArray*  jobjectArray;

而咱们的中心运算代码运用的是纯粹的 C 类型,所以这儿就需求对数据类型进行一个转化。

咱们来看 Java_com_equationl_nativelib_NativeLib_stepUpdate 函数的完好代码:

#include <jni.h>
#include <string>
#include <valarray>
#include "game.h"
extern "C" JNIEXPORT jobjectArray JNICALL
Java_com_equationl_nativelib_NativeLib_stepUpdate(
        JNIEnv* env,
        jobject,
        jobjectArray lifeList
        ) {
	// jni 数据转为 c 数据
    int len1 = env -> GetArrayLength(lifeList);
    auto dim =  (jintArray)env->GetObjectArrayElement(lifeList, 0);
    int len2 = env -> GetArrayLength(dim);
    int **board;
    board = new int *[len1];
    for(int i=0; i<len1; ++i){
        auto oneDim = (jintArray)env->GetObjectArrayElement(lifeList, i);
        jint *element = env->GetIntArrayElements(oneDim, JNI_FALSE);
        board[i] = new int [len2];
        for(int j=0; j<len2; ++j) {
            board[i][j]= element[j];
        }
        // 开释数组
        env->ReleaseIntArrayElements(oneDim, element, JNI_ABORT);
        // 开释引用
        env->DeleteLocalRef(oneDim);
    }
    // 实际的处理逻辑
    updateStep(board, len1, len2);
    // C 数据转回 jni 数据
    jclass cls = env->FindClass("[I");
    jintArray iniVal = env->NewIntArray(len2);
    jobjectArray result = env->NewObjectArray(len1, cls, iniVal);
    for (int i = 0; i < len1; i++)
    {
        jintArray inner = env->NewIntArray(len2);
        env->SetIntArrayRegion(inner, 0, len2, (jint*)board[i]);
        env->SetObjectArrayElement(result, i, inner);
        env->DeleteLocalRef(inner);
    }
    return result;
}

能够看到,在这个代码中,仅仅只需一句 updateStep(board, len1, len2); 是真实的运算代码,而剩余的代码都仅仅在做数据转化。

其实这儿如果咱们不是为了能够复用运算逻辑代码,咱们彻底不需求转化数据,直接在运算时运用 jni 自定义的数据类型就能够了。

咱们来看这儿的转化代码,有一点需求额外留意的是,在最初将 jobjectArray 转为 int **board 时,有两行我增加了注释的代码:

// 开释数组
// ① 形式 0 : 改写 Java 数组 , 开释 C/C++ 数组
// ② 形式 1 ( JNI_COMMIT ) : 改写 Java 数组 , 不开释 C/C ++ 数组
// ③ 形式 2 ( JNI_ABORT ) : 不改写 Java 数组 , 开释 C/C++ 数组
env->ReleaseIntArrayElements(oneDim, element, JNI_ABORT);
// 开释引用
env->DeleteLocalRef(oneDim);

这两行代码简略了解便是在咱们现已完结了将 kotlin 中传过来的 jobjectArray lifeList 仿制到了 C 的 int **board 中后就开释掉 lifeList 的内存。

这儿一开端我不小心把开释内存的代码写到了仿制完结之前,也便是写成了:

env->ReleaseIntArrayElements(oneDim, element, JNI_ABORT);
env->DeleteLocalRef(oneDim);
board[i] = new int [len2];
for(int j=0; j<len2; ++j) {
    board[i][j]= element[j];
}

而我在写这段代码时运用的是 Windows 系统,所以我其时并没有发现不妥,运转代码也没有报错,运转成果也契合预期。

仅仅当我在 macOS 上测验时却发现,不管我传值是什么,回来成果始终为满是 0 的数组。

为此我还调试了很久,最终在 B 大佬的协助下才发现原来是我把开释内存写在了仿制完结之前。

那么问题来了,为什么同样的代码在 Windows 上运转却没有任何问题?

咱们猜想可能是 Windows 和 macOS 的内存收回战略不同,在 macOS 收回更为急进,所以在我调用开释内存后,它的内存就被当即收回了,而 Windows 收回没有这么急进,所以乃至能答应我仿制完了都还没有被收回。

记住这个内存收回的问题,在后面咱们还会被这个坑一次。

kotlin native 运用 cinterop 调用中心算法

在 iOS 中咱们将运用 cinterop 调用中心运算函数 updateStep()

在正式开端之前,咱们插个题外话,咱们先写一个更简略的 demo 来演示一下上文中提到过的内存收回战略问题的坑。

看下面的代码,

C:

#ifndef LIB2_H_INCLUDED
#define LIB2_H_INCLUDED
int** get_message(int** name, int row, int col) {
    for (int i = 0; i < row; i++) {
        for (int j = 0; j < col; j++) {
            printf("current value from c: name[%d][%d]=%dn", i, j, name[i][j]);
        }
    }
    // 随意改一个值
    name[1][1] = 114514;
    return name;
}
#endif

kt:

@OptIn(ExperimentalForeignApi::class)
fun main() {
    println("Hello, Kotlin/Native!")
    val list1 = listOf(intArrayOf(0, 1, 2), intArrayOf(4, 5, 6))
    val row = list1.size
    val col = list1[0].size
    val passList  = list1.map { it.pin() }
    val list2 = passList.map { it.addressOf(0) }
    val newList = get_message(list2.toCValues(), row, col)
    for (i in 0 until row) {
        for (j in 0 until col) {
            println("current value from kotlin: result[$i][$j] = ${newList?.get(i)?.get(j)}")
        }
    }
}

无奖竞猜,将上述代码在 Desktop native 中运转,输出成果是什么?

哈哈,不卖关子了,在 Windows 上运转输出如下:

为 Compose MultiPlatform 增加 C/C++ 支持(3):实战 Desktop、Android、iOS 调用同一个 C/C++ 代码

在 macOS 中输出如下:

为 Compose MultiPlatform 增加 C/C++ 支持(3):实战 Desktop、Android、iOS 调用同一个 C/C++ 代码

显然,和咱们上文说的内存收回战略有关。

咱们先来看以上的 kt 代码,为了把 kt 的数据传递给 C ,咱们运用:

val passList  = list1.map { it.pin() }
val list2 = passList.map { it.addressOf(0) }
val newList = get_message(list2.toCValues(), row, col)

首要运用 pin() 函数将 kt 的 Array 中的 IntArray 地址固定以方便传递给 C 运用,然后运用 addressOf(0) 获取到 IntArray 第一个元素的地址,最终运用 toCValues() 将其转为指针引用传递给 C 的 get_message() 函数。

咋一看如同没有问题是吧?

那么,咱们来看看文档中关于 toCValues() 的解说:

为 Compose MultiPlatform 增加 C/C++ 支持(3):实战 Desktop、Android、iOS 调用同一个 C/C++ 代码

要点在于第一段最终一句 In this case the sequence is passed “by value”, i.e., the C function receives the pointer to the temporary copy of that sequence, which is valid only until the function returns.

换句话说便是,咱们在上面代码中传递给 C 的数据仅仅一个复制的“副本”,并且这个副本数据的有效期只需函数的运转时刻这么短,只需函数运转完毕,这个“副本”就会被销毁。

这就不难解说为什么在 macOS 上这段代码运转成果会是这样,至于为什么同样的代码在 Windwos 上运转没有出现问题,我想还是和上一节说的原因相同,便是 Windows 和 macOS 的内存收回战略不同,在 Windows 上内存收回没有 macOS 急进,所以得以在数据本应该现已被收回了还能继续运用。

也便是说,其实咱们上面代码的写法是错误的,虽然能够在 Windows 上运转,可是也是极其不可靠的,保禁绝数据量大时、内存紧张时、乃至在不知道的状况下随机就会出现问题。

那么,怎么处理这个问题呢?根据文档,咱们需求运用 nativeHeap.alloc() 或许 nativeHeap.allocArray() 在 kotlin 代码中事先分配一段内存,然后将该内存指针传给 C 运用,此时这个内存就不会被主动开释,需求咱们手动开释 nativeHeap.free()

当然,也能够直接运用 memScoped { ... } 句子,在该句子中分配的内存会主动在该句子完毕时开释。

现在,让咱们回到咱们的项目中来。

首要看看 iOS 调用中心运算函数的进口 sahred 模块下的 iosMain 包中的 /nativeinterop/cinterop/nativelib.h 文件。

在该文件运用 #include "../../../../../../nativelib/src/main/cpp/game.h" 引入了 nativelib 模块的中心运算文件 game.h

然后定义了进口函数: int* update(int** board, int row, int col, int* newList)

能够看到不同于 jni 的进口函数,它多了几个参数:rowcolnewList

rowcolboard 的行和列数量,由于这儿传递给 C 的数组是指针的形式,所以欠好确认长度,索性直接从 kotlin 传过来,而 newList 则是咱们在 kotlin 中分配的内存地址的指针,用于将运算完结的成果存入。

这儿我将回来类型写为了 int* 其实这儿能够不必回来值,在 kotlin 直接运用传给它的 newList 指针即可,可是我仅仅为了保持一致性,所以把 newList 又回来回去了。

别的,这儿我的回来值 newList 不运用二维数组而是运用一维数组是由于回来二维数组有点麻烦,索性转成一维来回来了。

检查 nativelib.h 函数的完好代码如下:

#include "../../../../../../nativelib/src/main/cpp/game.h"
#ifndef LIB2_H_GAME
#define LIB2_H_GAME
int* update(int** board, int row, int col, int* newList) {
    updateStep(board, row, col);
    // 将成果转为一维数组传回
    for (int i = 0; i < row; i++) {
        for (int j = 0; j < col; j++) {
            int value = board[i][j];
            int index = i * col + j;
            newList[index] = value;
        }
    }
    return newList;
}
#endif

对了,上述代码还有个问题,便是我在将二维数组转为一维时直接遍历了整个数组来转,这样效率十分低,彻底能够直接运用 memcpy批量仿制内存数据,这样会快的多。

接下来,咱们来看一下 iOS 端调用这个函数的 kotlin 代码,咱们在 iosMain 包中定义了一个函数 stepUpdateNative

@OptIn(ExperimentalForeignApi::class, ExperimentalForeignApi::class)
fun stepUpdateNative(sourceData: Array<IntArray>): Array<IntArray> {
    val row = sourceData.size
    val col = sourceData[0].size
    val list1 = sourceData.map { it.pin() }
    val passList = list1.map { it.addressOf(0) }
    val result = mutableListOf<IntArray>()
    memScoped {
        val arg = allocArray<IntVar>(row * col)
        val resultNative = update(passList.toCValues(), row, col, arg)
        for (i in 0 until row) {
            val line = IntArray(col)
            for (j in 0 until col) {
                val index = i * col + j
                line[j] = resultNative!![index]
                // println("current value from kotlin: result[$index] = ${resultNative?.get(index)}")
            }
            result.add(line)
        }
    }
    return result.toTypedArray()
}

代码很简略,要点在于中间:

memScoped {
    val arg = allocArray<IntVar>(row * col)
    val resultNative = update(passList.toCValues(), row, col, arg)
}

咱们在 memScoped 句子中运用 allocArray<IntVar>(row * col) 分配了一块类型为 IntVar 长度为 row * col 的数组空间,然后将其作为 C 函数 update 的参数传入。

回来值 resultNative 即 C 函数运算完毕后得到的一维数组成果,所以咱们在后面遍历了一遍将其转回二维数组:

for (i in 0 until row) {
    val line = IntArray(col)
    for (j in 0 until col) {
        val index = i * col + j
        line[j] = resultNative!![index]
        // println("current value from kotlin: result[$index] = ${resultNative?.get(index)}")
    }
    result.add(line)
}

简略总结一下

通过上面两个小节的解说,相信读者也看出问题来了,这时分如果我再说调用 C 的运算速度实际上比直接运用 kotlin 还慢读者应该也恍然大悟知道什么原因了吧。

其实并不是 C 自身慢,而在于我为了在调用 C 的同时复用 C 代码,做了大量的数据转化工作,而每次数据转化的代价都是极其贵重的,这就导致运算时刻反而相比于直接运用 kt 核算还要慢了。

这也是我说的运用这个项目举例是不恰当的原因,由于这就相当于为了这碗醋还特意去声势浩大的包了顿饺子相同。

实际上,这系列文章仅仅为了阐明如安在 kmp 中调用 C 代码而不需求每个渠道都独自写一套适配代码。

或许在我举的这个比如中小题大做了,可是如果是调用第三方的优异 C 项目,例如 FFmpeg ,那便是收益远大于支付。

不管怎么说,现在咱们现已在一切渠道中复用了同一套 C 代码,并且编写好了相应的调用函数,接下来只需求按照我前两篇文章的步骤顺次将其接入咱们的 KMP 项目中即可。

文章中有没有提到的地方各位读者也能够直接看我的项目代码:life-game-compose