在Android開發中,有時候出於安全,性能,代碼共用的考慮,需要使用C/C++編寫的庫。雖然在現代化工具鏈的支持下,這個工作的難度已經大大降低,但是畢竟萬事開頭難,初學者往往還是會遇到很多不可預測的問題。本篇就是基於此背景下寫的一份簡陋指南,希望能對剛開始編寫C/C++庫的讀者有所幫助。同時為了盡 ...
在Android開發中,有時候出於安全,性能,代碼共用的考慮,需要使用C/C++編寫的庫。雖然在現代化工具鏈的支持下,這個工作的難度已經大大降低,但是畢竟萬事開頭難,初學者往往還是會遇到很多不可預測的問題。本篇就是基於此背景下寫的一份簡陋指南,希望能對剛開始編寫C/C++庫的讀者有所幫助。同時為了儘可能減少認知斷層,本篇將試著從一個最簡單的功能開始,逐步添加工具鏈,直到實現最終功能,真正做到知其然且之所以然。
目標
本篇的目標很簡單,就是能在Android應用中調用到C/C++的函數——接收兩個整型值,返回兩者相加後的值,暫定這個函數為plus
。
從C++源文件開始
為了從我們最熟悉的地方開始,我們先不用複雜工具,先從最原始的C++源文件開始.
打開你喜歡的任何一個文本編輯器,VS Code,Notpad++,記事本都行,新建一個文本文件,並另存為math.cpp
。接下來,就可以在這個文件中編寫代碼了.
前面我們的目標已經說得很清楚,實現個plus
函數,接收兩個整型值,返回兩者之和,所以它可能是下麵這樣
int plus(int left,int right)
{
return left + right;
}
我們的源文件就這樣完成了,是不是很簡單。
但是僅僅有源文件是不夠的,因為這個只是給人看的,機器看不懂。所以我們就需要第一個工具——編譯器。編譯器能幫我們把人看得懂的轉化成機器也能看得懂的東西。
編譯器
編譯器是個複雜工程,但是都是服務於兩個基本功能
- 理解源文件的內容(人能看懂的)——檢查出源文件中的語法錯誤
- 理解二進位的內容(機器能看懂的)——生成二進位的機器碼。
基於這兩個朴素的功能,編譯器卻是撓斷了頭。難點在於功能2。基於這個難點編譯器分成了很多種,常見的像Windows平臺的VS,Linux平臺的G++,Apple的Clang。而對於Android來說,情況略有不同,前面這些編譯器都是運行在特定系統上的,編譯出來的程式通常也只能運行在對應的系統上。以我現在的機器為例,我現在是在Deepin上寫的C++代碼,但是我們的目標是讓代碼跑在Android手機上,是兩個不同的平臺。更悲觀的是,目前為止,還沒有一款可以在手機上運行的編譯器。那我們是不是就不能在手機上運行C++代碼了?當然不是,因為有交叉編譯。
交叉編譯就是在一個平臺上將代碼生成另一個平臺可執行對象的技術。它和普通編譯最大的不同是在鏈接上。因為一般的鏈接直接可以去系統庫找到合適的庫文件,而交叉編譯不行,因為當前的平臺不是最終運行代碼的平臺。所以交叉編譯還需要有目標平臺的常用庫。當然,這些Google都替我們準備好了,稱為NDK。
NDK
NDK全稱是Native Development Kit,裡面有很多工具,編譯器,鏈接器,標準庫,共用庫。這些都是交叉編譯必不可少的部分。為了理解方便,我們首先來看看它的文件結構。以我這台機器上的版本為例——/home/Andy/Android/Sdk/ndk/21.4.7075529
(Windows上預設位置則是c:\Users\xxx\AppData\Local\Android\Sdk\
)。 NDK就保存在Sdk目錄下,以ndk
命名,並且使用版本號作為該版本的根目錄,如示例中,我安裝的NDK版本就是21.4.7075529
。同時該示例還是ANDROID_NDK
這個環境變數的值。也就是說,在確定環境變數前,我們需要先確定選用的NDK版本,並且路徑的值取到版本號目錄。
瞭解了它的存儲位置,接下來我們需要認識兩個重要的目錄
build/cmake/
,這個文件夾,稍後我們再展開。toolchains/llvm/prebuild/linux-x86_64
,最後的linux-x86_64
根據平臺不同,名稱也不同,如Windows平臺上就是以Windows開頭,但是一般不會找錯,因為這個路徑下就一個文件夾,並且前面都是一樣的。這裡有我們心心念念的編譯器,鏈接器,庫,文件頭等。如編譯器就存在這個路徑下的bin
目錄里,它們都是以clang
和clang++
結尾的,如aarch64-linux-android21-clang++
-
aarch64
代表著這個編譯器能生成用在arm64
架構機器上的二進位文件,其他對應的還有armv7a
,x86_64
等。不同的平臺要使用相匹配的編譯器。它就是交叉編譯中所說的目標平臺。 -
linux
代表我們執行編譯這個操作發生在linux
機器上,它就是交叉編譯中所說的主機平臺。 -
android21
這個顯然就是目標系統版本了 -
clang++
代表它是個C++編譯器,對應的C編譯器是clang
。
可以看到,對於Android來說,不同的主機,不同的指令集,不同的Android版本,都對應著一個編譯器。
瞭解了這麼多,終於到激動人性的時刻啦,接下來,我們來編譯一下前面的C++文件看看。
編譯
通過aarch64-linux-android21-clang++ --help
查看參數,會發現它有很多參數和選項,現在我們只想驗證下我們的C++源文件有沒有語法錯誤,所以就不管那些複雜的東西,直接一個aarch64-linux-android21-clang++ -c math.cpp
執行編譯。
命令執行完後,假如一切順利,就會在math.cpp
相同目錄下生成math.o
對象文件,說明我們的源碼沒有語法錯誤,可進行到下一步的鏈接。
不過,在此之前,先打斷一下。通常我們的項目會包含很多源文件,引用一些第三方庫,每次都用手工的形式編譯,鏈接顯然是低效且容易出錯的。在工具已經很成熟的現在,我們應該儘量使用成熟的工具,將重心放在我們的業務邏輯上來,CMake
就是這樣的一個工具。
CMake
CMake是個跨平臺的項目構建工具。怎麼理解呢?編寫C++代碼時,有時候需要引用其他目錄的文件頭,但是在編譯階段,編譯器是不知道該去哪裡查找文件頭的,所以需要一種配置告訴編譯器文件頭的查找位置。再者,分佈在不同目錄的源碼,需要根據一定的需求打包成不同的庫。又或者,項目中引用了第三方庫,需要在鏈接階段告訴鏈接器從哪個位置查找庫,種種這些都是需要配置的東西。
而不同的系統,不同的IDE對於上述配置的支持是不盡相同的,如Windows上的Visual Studio就是需要在項目的屬性裡面配置。在開發者使用同樣的工具時,問題還不是很大。但是一旦涉及到多平臺,多IDE的情況,協同開發就會花費大把的時間在配置上。CMake就是為瞭解決這些問題應運而生的。
CMake的配置信息都是寫在名為CMakeLists.txt
的文件中。如前面提到頭文件引用,源碼依賴,庫依賴等等,只需要在CmakeLists.txt
中寫一次,就可以在Windows,MacOS,Linux平臺上的主流IDE上無縫使用。如我在Windows的Visual Studio上創建了一個CMake的項目,配置好了依賴信息,傳給同事。同事用MacOS開發,他可以在一點不修改的情況下,馬上完成編譯,打包,測試等工作。這就是CMake跨平臺的威力——簡潔,高效,靈活。
使用CMake管理項目
建CMake項目
我們前面已經有了math.cpp
,又有了CMake,現在就把他們結合一下。
怎樣建立一個CMake項目呢?一共分三步:
- 建一個文件夾
示例中我們就建一個math
的文件夾吧。
-
在新建的文件夾里新建
CMakeLists.txt
文本文件。註意,這裡的文件名不能變。 -
在新建的
CMakeLists.txt
文件里配置項目信息。
最簡單的CMake項目信息需要包括至少三個東西
1)、支持的最低CMake版本
cmake_minimum_required(VERSION 3.18。1)
2)、項目名稱
project(math)
3)、生成物——生成物可能是可執行文件,也可能是庫。因為我們要生成Android上的庫,所以這裡是的生成物是庫。
add_library(${PROJECT_NAME} SHARED math.cpp)
經過這三步,CMake項目就建成了。下一步我們來試試用CMake來編譯項目。
編譯CMake項目
在執行真正的編譯前,CMake有個準備階段,這個階段CMake會收集必要的信息,然後生成滿足條件的工程項目,然後才能執行編譯。
那麼什麼是必要的信息呢?CMake為了儘可能降低複雜性,會自己猜測收集一些信息。
如我們在Windows上執行生成操作,CMake會預設目標平臺就是Windows,預設生成VS的工程,所以在Windows上編譯Windows上的庫就幾乎是零配置的。
-
在
math
目錄下新建一個build
的目錄,然後把工作目錄切換到build
目錄。cd build cmake ..
在命令執行之後,就能在
build
目錄下找到VS的工程,可以直接使用VS打開,無錯誤地完成編譯。當然,更快的方法還是直接使用CMake編譯. -
使用CMake編譯
cmake --build .
註意前面的
..
代表父目錄,也就是CMakeLists.txt
文件存在的math
目錄,而.
則代表當前目錄,即build
這個目錄。假如這兩步都順利執行了,我們就能在build目錄下收穫一個庫文件。Windows平臺上可能叫math.dll
,而Linux平臺上可能叫math.so
,但是都是動態庫,因為我們在CMakelists.txt
文件里配置的就是動態庫。
從上面的流程來看,CMake的工作流程不複雜。但是我們使用的是預設配置,也就是最終生成的庫只能用在編譯的平臺上。要使用CMake編譯Android庫,我們就需要在生成工程時,手動告訴CMake一些配置,而不是讓CMake去猜。
CMake的交叉編譯
配置參數從哪來
雖然我們不知道完成交叉編譯的最少配置是什麼,但是我們可以猜一下。
首先要完成源碼的編譯,編譯器和鏈接器少不了,前面也知道了,Android平臺上有專門的編譯器和鏈接器,所以至少有個配置應該是告訴CMake用哪一個編譯器和鏈接器。
其次Android的系統版本和架構也是必不可少的,畢竟對於Android開發來說,這個對於Android應用都很重要。
還能想到其他參數嗎,好像想不到了。不過,好消息是,Google替我們想好了,那就是直接使用CMAKE——TOOLCHAIIIN_FILE
。這個選項是CMake 提供的,使用的時候把配置文件路徑設置為它的值就可以了,CMake會通過這個路徑查找到目標文件,使用目標文件裡面的配置代替它的自己靠猜的參數。而這個配置文件,就是剛纔提到過的兩個重要文件夾之一的build/camke
,我們的配置文件就是該文件夾下麵的android.toolchain.cmake
。
Google的CMake配置文件
android.toolchain.cmake
扮演了一個包裝器的作用,它會利用提供給它的參數,和預設的配置,共同完成CMake的配置工作。其實這個文件還是個很好的CMake學習資料,可以學到很多CMake的技巧。現在,我們先不學CMake相關的,先來看看我們可用的參數有哪些。在文件的開頭,Google就把可配置的參數都列舉出來了
ANDROID_TOOLCHAIN
ANDROID_ABI
ANDROID_PLATFORM
ANDROID_STL
ANDROID_PIE
ANDROID_CPP_FEATURES
ANDROID_ALLOW_UNDEFINED_SYMBOLS
ANDROID_ARM_MODE
ANDROID_ARM_NEON
ANDROID_DISABLE_FORMAT_STRING_CHECKS
ANDROID_CCACHE
這些參數其實不是CMake的參數,在配置文件被執行的過程中,這些參數會被轉換成真正的CMake參數。我們可以通過指定這些參數的值,讓CMake完成不同的構建需求。假如都不指定,則會使用預設值,不同的NDK版本,預設值可能會不一樣。
我們來著重看看最關鍵的ANDROID_ABI
和ANDROID_PLATFORM
。前面這個是指當前構建的包運行的CPU指令集是哪一個,可選的值有arneabi-v7a
,arn64-v8a
,x86
,x86_64
,mips
,mips64
。後一個則是指構建包的Android版本。它的值有兩種形式,一種就是直接android-[version]
的形式[version]
在使用時替換成具體的系統版本,如android-23
,代表最低支持的系統版本是Android 23。另一種形式是字元串latest
。這個值就如這個單詞的意思一樣,用最新的。
那麼我們怎麼知道哪個參數可以取哪些值呢,有個簡單方法:先在文件頭確定要查看的參數,然後全局搜索,看set
和if
相關的語句就能確定它支持的參數形式了。
使用配置文件完成交叉編譯
說了那麼一大堆,回到最開始的例子上來。現在我們有了CMakelists.txt
,還有了math.cpp
,又找到了針對Android的配置文件android.toolchin.cmake
。那麼怎樣才能把三者結合起來呢,這就不得不提到CMake的參數配置了。
在前面,我們直接使用
cmake ..
就完成了工程文件的生成配置,但是其實它是可以傳遞參數的。CMake的參數都是以-D
開頭,用空白符分割的鍵值對。而CMake預設的參數都是以CMAKE
為開頭的,所以大部分情況下參數的形式都是-DCMAKE_XXX
這種。如給CMake傳遞toolchain
文件的形式就是
cmake -DCMAKE_TOOLCHAIN_FILE=/home/Andy/Android/Sdk/ndk/21.4.7075529/build/cmake/android.toolchain.cmake
這個參數的意思就是告訴CMake,使用=
後面指定的文件來配置CMake的參數。
然而,完成交叉編譯,我們還少一個選項——-G
。這個選項是交叉編譯必需的。因為交叉編譯CMake不知道該生成什麼形式的工程,所以需要使用這個選項指定生成工程的類型。一種是傳統形式的Make工程,指定形式是
cmake -G "Unix Makefiles"
可以看出,這種形式是基於Unix平臺下的Make工程的,它使用make
作為構建工具,所以指定這種形式以後,還需要指定make
的路徑,工程才能順利完成編譯。而另一種Google推薦的方式是Ninja
,這種方式更簡單,因為不需要單獨指定Ninja
的路徑,它預設就隨CMake安裝在同一個目錄下,所以可以減少一個傳參。Ninja
也是一種構建工具,但是專註速度,所以我們這一次就使用Ninja
。它的指定方式是這樣的
cmake -G Ninja
結合以上兩個參數,就可以得到最終的編譯命令
cmake -GNinja -DCMAKE_TOOLCHAIN_FILE=/home/Andy/Android/Sdk/ndk/21.4.7075529/build/cmake/android.toolchain.cmake ..
生成工程後再執行編譯
cmake --build .
我們就得到了最終能運行在Android上的動態庫了。用我這個NDK版本編譯出來的動態庫支持的Android版本是21,指令集是armeabi-v7a。當然根據前面的描述我們可以像前面傳遞toolchain
文件一下傳遞期望的參數,如以最新版的Android版本構建x86
的庫,就可以這樣寫
cmake -GNinja -DCMAKE_TOOLCHAIN_FILE=/home/Andy/Android/Sdk/ndk/21.4.7075529/build/cmake/android.toolchain.cmake -DANDROID_PLATFORM=latest -DANDROID_ABI=x86 ..
這就給我們個思路,假如有些第三方庫沒有提供編譯指南,但是是用CMake管理的,我們就可以直接套用上面的公式來編譯這個第三方庫。
JNI
前面在CMake的幫助下,我們已經得到了libmath.so
動態庫,但是這個庫還是不能被Android應用直接使用,因為Android應用是用Java(Kotlin)語言開發的,而它們都是JVM語言,代碼都是跑在JVM上的。要想使用這個庫,還需要想辦法讓庫載入到JVM中,然後才有可能訪問得到。它碰巧的是,JVM還真有這個能力,它就是JNI。
JNI基本思想
JNI能提供Java到C/C++的雙向訪問,也就是可以在Java代碼里訪問C/C++的方法或者數據,反過來也一樣支持,這過程中JVM功不可沒。所以要理解JNI技術,需要我們以JVM的角度思考問題。
JVM好比一個貨物集散中心,無論是去哪個地方的貨物都需要先來到這個集散中心,再通過它把貨物分發到目的地。這裡的貨物就可以是Java方法或者C/C++函數。但是和普通的快遞不一樣的是,這裡的貨物不知道自己的目的地是哪裡,需要集散中心自己去找。那麼找的依據從哪裡來呢,也就是怎樣保證集散中心查找結果的唯一性呢,最簡單的方法當然就是貨物自己標識自己,並且保證它的唯一性。
顯然對於Java來說,這個問題很好解決。Java有著層層保證唯一性的機制。
- 包名可以保證類名的唯一性;
- 類名可以保證同一包名下類的唯一性;
- 同一個類下可以用方法名保證唯一性;
- 方法發生重載的時候可以用參數類型和個數確定類的唯一性。
而對於C/C++來說,沒有包名和類名,那麼用方法名和方法參數可以確定唯一性嗎?答案是可以,只要我們把包名和類名作為一種限定條件。
而添加限定條件的方式有兩種,一種就是簡單粗暴,直接把包名類名作為函數名的一部分,這樣JVM也不用看其他的東西,直接粗暴地將包名,類名,函數名和參數這些對應起來就能確定對端對應的方法了。這種方法叫做靜態註冊。其實這和Android裡面的廣播特別像:廣播的靜態註冊就是直接粗暴地在AndroidManifest
文件中寫死了,不用在代碼里配置,一寫了就生效。對應於靜態註冊,肯定還有個動態註冊的方法。動態註冊就是用寫代碼的方式告訴JVM函數間的對應關係,而不是讓它在函數調用時再去查找。顯然這種方式的優勢就是調用速度更快一點,畢竟我們只需要一次註冊,就可以在後續調用中直接訪問到對端,不再需要查找操作。但是同樣和Android中廣播的動態註冊一樣,動態註冊要繁瑣得多,而且動態註冊還要註意把握好註冊時機,不然容易造成調用失敗。我們繼續以前面的libmath.so
為例講解。
Java使用本地庫
Java端訪問C/C++函數很簡單,一共分三步:
-
Java調用
System.loadLibrary()
方法載入庫System.loadlibrary("math.so");
這裡有個值得註意的地方,CMake生成的動態庫是
libmath.so
,但是這裡只寫了math.so
,也就是說不需要傳遞lib
這個首碼。這一步執行完後,JVM就知道有個plus
函數了。 -
Java聲明一個和C++函數對應的
native
方法。這裡對應指的是參數列表和返回值要保持一致,方法名則可以不一致。public native int nativePlus(int left,int right);
通常,習慣將
native
方法添加native
的首碼。 -
在需要的地方直接調用這個
native
方法。調用方法和普通的Java方法是一致的,傳遞匹配的參數,用匹配的類型接收返回值。
把這幾布融合到一個類裡面就是這樣
package hongui.me;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import hongui.me.databinding.ActivityMainBinding;
public class MainActivity extends AppCompatActivity {
static {
System.loadLibrary("me");
}
ActivityMainBinding binding;
private native int nativePlus(int left,int right);
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
// Example of a call to a native method
binding.sampleText.setText("1 + 1 = "+nativePlus(1,1));
}
}
C/C++端引入JNI
JNI其實對於C/C++來說是一層適配層,在這一層主要做函數轉換的工作,不做具體的功能實現,所以,通常來說我們會新建一個源文件,用來專門處理JNI層的問題,而JNI層最主要的問題當然就是前面提到的方法註冊問題了。
靜態註冊
靜態註冊的基本思路就是根據現有的Java native
方法寫一個與之對應的C/C++函數簽名,具體來說分四步。
- 先寫出和Java
native
函數一模一樣的函數簽名
int nativePlus(int left,int right)
- 在函數名前面添加包名和類名。因為包名在Java中是用
.
分割的,而C/C++中點通常是用作函數調用,為了避免編譯錯誤,需要把.
替換成_
。
hongui_me_MainActivity_nativePlus(int left,int right)
- 轉換函數參數。前面提到過所有的操作都是基於JVM的,在Java中,這些是自然而然的,但是在C/C++中就沒有JVM環境,提供JVM
環境的形式就只能是添加參數。為了達到這個目的,任何JNI的函數都要在參數列表開頭添加兩個參數。而Java裡面的最小環境是線程,所以第一個參數就是代表調用這個函數時,調用方的線程環境對象JNIEnv
,這個對象是C/C++訪問Java的唯一通道。第二個則是調用對象。因為Java中不能直接調用方法,需要通過類名或者某個類來調用方法,第二個參數就代表那個對象或者那個類,它的類型是jobjet
。從第三個參數開始,參數列表就和Java端一一對應了,但是也只是對應,畢竟有些類型在C/C++端是沒有的,這就是JNI中的類型系統了,對於我們當前的例子來說Java裡面的int
值對應著JNI裡面的jint
,所以後兩個參數都是jint
類型。這一步至關重要,任何一個參數轉換失敗都可能造成程式崩潰。
hongui_me_MainActivity_nativePlus(
JNIEnv* env,
jobject /* this */,
jint left,
jint right)
- 添加必要首碼。這一步會很容易被忽略,因為這一部分不是那麼自然而然。首先我們的函數名還得加一個首碼
Java
,現在的函數名變成了這樣Java_hongui_me_MainActivity_nativePlus
。其次在返回值兩頭需要添加JNIEXPORT
和JNICALL
,這裡返回值是jint
,所以添加完這兩個巨集之後是這樣JNIEXPORT jint JNICALL
。最後還要在最開頭添加extern "C"
的相容指令。至於為啥要添加這一步,感興趣的讀者可以去詳細瞭解,簡單概括就是這是JNI的規範。
經過這四步,最終靜態方法找函數的C/C++函數簽名變成了這樣
#include "math.h"
extern "C" JNIEXPORT jint JNICALL
Java_hongui_me_MainActivity_nativePlus(
JNIEnv* env,
jobject /* this */,
jint left,
jint right){
return plus(left,right);
}
註意到,這裡我把前面的math.cpp
改成了math.h
,併在JNI適配文件(文件名是native_jni.cpp
)中調用了這個函數。所以現在有兩個源文件了,需要更新一下CMakeList.txt
。
cmake_minimum_required(VERSION 3.18。1)
project(math)
add_library(${PROJECT_NAME} SHARED native_jni.cpp)
可以看到這裡我們只把最後一行的文件名改了,因為CMakeLists.txt
當前所在的目錄也是include
的查找目錄,所以不需要給它單獨設置值,假如需要添加其他位置的頭文件則可以使用include_directories(dir)
添加。
現在使用CMake重新編譯,生成動態庫,這次Java就能直接不報錯運行了。
動態註冊
前面提到過動態註冊需要註意註冊時機,那麼什麼算是好時機呢?在前面Java使用本地庫這一節,我們知道,要想使用庫,必須先載入,載入成功後就可以調用JNI方法了。那麼動態註冊必然要發生在載入之後,使用之前。JNI很人性化的想到了這一點,在庫載入完成以後會馬上調用jint JNI_OnLoad(JavaVM *vm, void *reserved)
這個函數,這個方法還提供了一個關鍵的JavaVM
對象,簡直就是動態註冊的最佳入口了。確定了註冊時機,現在我們來實操一下。註意:動態註冊和靜態註冊都是C/C++端實現JNI函數的一種方式,同一個函數一般只採用一種註冊方式。所以,接下來的步驟是和靜態註冊平行的,並不是先後關係。
動態註冊分六步
- 新建
native_jni.cpp
文件,添加JNI_OnLoad()
函數的實現。
extern "C" JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM *vm, void *reserved) {
return JNI_VERSION_1_6;
}
這就是這個函數的標準形式和實現,前面那一串都是JNI函數的標準形式,關鍵點在於函數名和參數以及返回值。要想這個函數在庫載入後自動調用,函數名必須是這個,而且參數形式也不能變,並且用最後的返回值告訴JVM當前JNI的版本。也就是說,這些都是模板,直接搬就行。
- 得到
JNIEnv
對象
前面提到過,所有的JNI相關的操作都是通過JNIEnv
對象完成的,但是現在我們只有個JavaVM
對象,顯然秘訣就在JavaVM
身上。
通過它的GetEnv
方法就可以得到JNIEnv
對象
JNIEnv *env = nullptr;
vm->GetEnv(env, JNI_VERSION_1_6);
- 找到目標類
前面說過,動態註冊和靜態註冊都是要有包名和類名最限定的,只是使用方式不一樣而已。所以動態註冊我們也還是要使用到包名和類名,不過這次的形式又不一樣了。靜態註冊包名類名用_
代替.
,這一次要用/
代替.
。所以我們最終的類形式是hongui/me/MainActivity
。這是一個字元串形式,怎樣將它轉換成JNI中的jclass
類型呢,這就該第二步的JNIEnv
出場了。
jclass cls=env->FindClass("hongui/me/MainActivity");
這個cls
對象就和Java裡面那個MainActivity
是一一對應的了。有了類對象下一步當然就是方法了。
- 生成JNI函數對象數組。
因為動態註冊可以同時註冊一個類的多個方法,所以註冊參數是數組形式的,而數組的類型是JNINativeMethod
。這個類型的作用就是把Java端的native
方法和JNI方法聯繫在一起,怎麼做的呢,看它結構。
typedef struct {
const char* name;
const char* signature;
void* fnPtr;
} JNINativeMethod;
name
對應Java端那個native
的方法名,所以這個值應該是nativePlus
。signature
對應著這個native
方法的參數列表外加函數類型的簽名。
什麼是簽名呢,就是類型簡寫。在Java中有八大基本類型,還有方法,對象,類。數組等,這些東西都有一套對應的字元串形式,好比是一張哈希表,鍵是類型的字元串表示,值是對應的Java類型。如jint
是真正的JNI類型,它的類型簽名是I
,也就是int
的首字母大寫。
函數也有自己的類型簽名(paramType)returnType
這裡的paramType
和returnType
都需要是JNI類型簽名,類型間不需要任何分隔符。
綜上,nativePlus
的類型簽名是(II)I
。兩個整型參數,返回另一個整型。
fnPtr
正如它名字一樣,它是一個函數指針,值就是我們真正的nativePlus
實現了(這裡我們還沒有實現,所以先假定是jni_plus
)。
綜上,最終函數對象數組應該是下麵這樣
JNINativeMethod methods[] = {
{"nativePlus","(II)I",reinterpret_cast<void *>(jni_plus)}
};
- 註冊
現在有了代表類的jclass
對象,還有了代表方法的JNINativeMethod
數組,還有JNIEnv
對象,把它們結合起來就可以完成註冊了
env->RegisterNatives(cls,methods,sizeof(methods)/sizeof(methods[0]));
這裡第三個參數是代表方法的個數,我們使用了sizeof
操作法得出了所有的methods
的大小,再用sizeof
得出第一個元素的大小,就可以得到methods
的個數。當然,這裡直接手動填入1也是可以的。
- 實現JNI函數
在第4步,我們用了個jni_plus
來代表nativePlus
的本地實現,但是這個函數實際上還沒有創建,我們需要在源文件中定義。現在這個函數名就可以隨便起了,不用像靜態註冊那樣那麼長還不能隨便命名,只要保持最終的函數名和註冊時用的那個名字一致就可以了。但是這裡還是要加上extern "C"
的首碼,避免編譯器對函數名進行特殊處理。參數列表和靜態註冊完全一致。所以,我們最終的函數實現如下。
#include "math.h"
extern "C" jint jni_plus(
JNIEnv* env,
jobject /* this */,
jint left,
jint right){
return plus(left,right);
}
好了,動態註冊的實現形式也完成了,CMake編譯後你會發現結果和靜態註冊完全一致。所以這兩種註冊方式完全取決於個人喜好和需求,當需要頻繁調用native
方法時,我覺得動態註冊是有優勢的,但是假如調用次數很少,完全可以直接用靜態註冊,查找消耗完全可以忽略不記。
One more thing
前面我提到CMake是管理C/C++項目的高手,但是對於Android開發來說,Gradle才是YYDS。這一點Google也意識到了,所以gradle的插件上直接提供了CMake和Gradle無縫銜接的絲滑配置。在android
這個構建塊下,可以直接配置CMakeLists.txt
的路徑和版本信息。
externalNativeBuild {
cmake {
path file('src/main/cpp/CMakeLists.txt')
version '3.20.5'
}
}
這樣,後面無論是修改了C/C++代碼,還是修改了Java代碼,都可以直接點擊運行,gradle會幫助我們編譯好相應的庫並拷貝到最終目錄里,完全不再需要我們手動編譯和拷貝庫文件了。當然假如你對它的預設行為還不滿意,還可以通過defaultConfig
配置預設行為,它的大概配置可以是這樣
android {
compileSdkVersion 29
defaultConfig {
minSdkVersion 21
targetSdkVersion 29
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles 'consumer-rules.pro'
externalNativeBuild {
cmake {
cppFlags += "-std=c++1z"
arguments '-DANDROID_STL=c++_shared'
abiFilters 'armeabi-v7a', 'arm64-v8a'
}
}
}
}
這裡cppFlags
是指定C++相關參數的,對應的還有個cFlags
用來指定C相關參數。arguments
則是指定CMake的編譯參數,最後一個就是我們熟悉的庫最終要編譯生成幾個架構包了,我們這裡只是生成兩個。
有了這些配置,Android Studio開發NDK完全就像開發Java一樣,都有智能提示,都可以即時編譯,即時運行,縱享絲滑。
總結
NDK開發其實應該分為兩部分,C++開發和JNI開發。
C++開發和PC上的C++開發完全一致,可以使用標準庫,可以引用第三方庫,隨著項目規模的擴大,引入了CMake來管理項目,這對於跨平臺項目來說優勢明顯,還可以無縫銜接到Gradle中。
而JNI開發則更多的是關註C/C++端和Java端的對應關係,每一個Java端的native
方法都要有一個對應的C/C++函數與之對應,JNI提供
靜態註冊和動態註冊兩種方式來完成這一工作,但其核心都是利用包名,類名,函數名,參數列表來確定唯一性。靜態註冊將包名,類名體現在函數名上,動態註冊則是使用類對象,本地方法對象,JNIENV
的註冊方法來實現唯一性。
NDK則是後面的大BOSS,它提供編譯器,鏈接器等工具完成交叉編譯,還有一些系統自帶的庫,如log
,z
,opengl
等等供我們直接使用。