theme: serene-rose
前言
在本系列的前兩篇文章中我們已經學會了如何在 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 項目,這里就不再贅述了,因為之前很多篇文章都有說過怎么移植,需要的可以自行翻閱我之前的歷史文章。
開始修改
項目整體結構
開始之前,我們先來看看修改完成之后的項目整體結構,需要重點關注的是圖中圈起來的部分:
其中 圈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 表示該位置細胞處于存活狀態。
所以該函數接收三個參數 board
、len1
、 len2
,其中 board
即表示當前狀態的二維數組,該函數會在運算中直接修改該數組的內容;len1
、 len2
分別表示數組的行長度和列長度。
了解了核心運算函數,接下來我們看看各個平臺如何使用它。
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* env
、 jobject
、 jobjectArray 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_INCLUDEDint** 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]=%d\n", 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.sizeval col = list1[0].sizeval 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 上運行輸出如下:
在 macOS 中輸出如下:
顯然,和我們上文說的內存回收策略有關。
我們先來看以上的 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()
的解釋:
重點在于第一段最后一句 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 的入口函數,它多了幾個參數:row
、 col
、 newList
。
row
、 col
即 board
的行和列數量,因為這里傳遞給 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_GAMEint* 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.sizeval col = sourceData[0].sizeval 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 + jline[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 + jline[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