Android-NDK開發——基本概念

2023-06-18 06:01:16

在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;
}

我們的原始檔就這樣完成了,是不是很簡單。

但是僅僅有原始檔是不夠的,因為這個只是給人看的,機器看不懂。所以我們就需要第一個工具——編譯器。編譯器能幫我們把人看得懂的轉化成機器也能看得懂的東西。

編譯器

編譯器是個複雜工程,但是都是服務於兩個基本功能

  1. 理解原始檔的內容(人能看懂的)——檢查出原始檔中的語法錯誤
  2. 理解二進位制的內容(機器能看懂的)——生成二進位制的機器碼。

基於這兩個樸素的功能,編譯器卻是撓斷了頭。難點在於功能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目錄裡,它們都是以clangclang++結尾的,如aarch64-linux-android21-clang++
  1. aarch64代表著這個編譯器能生成用在arm64架構機器上的二進位制檔案,其他對應的還有armv7ax86_64等。不同的平臺要使用相匹配的編譯器。它就是交叉編譯中所說的目標平臺。

  2. linux代表我們執行編譯這個操作發生在linux機器上,它就是交叉編譯中所說的主機平臺。

  3. android21這個顯然就是目標系統版本了

  4. 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專案呢?一共分三步:

  1. 建一個資料夾

範例中我們就建一個math的資料夾吧。

  1. 在新建的資料夾裡新建CMakeLists.txt文字檔案。注意,這裡的檔名不能變。

  2. 在新建的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上的庫就幾乎是零設定的。

  1. math目錄下新建一個build的目錄,然後把工作目錄切換到build目錄。

    cd build
    cmake ..
    

    在命令執行之後,就能在build目錄下找到VS的工程,可以直接使用VS開啟,無錯誤地完成編譯。當然,更快的方法還是直接使用CMake編譯.

  2. 使用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_ABIANDROID_PLATFORM。前面這個是指當前構建的包執行的CPU指令集是哪一個,可選的值有arneabi-v7aarn64-v8ax86x86_64mipsmips64。後一個則是指構建包的Android版本。它的值有兩種形式,一種就是直接android-[version]的形式[version]在使用時替換成具體的系統版本,如android-23,代表最低支援的系統版本是Android 23。另一種形式是字串latest。這個值就如這個單詞的意思一樣,用最新的。

那麼我們怎麼知道哪個引數可以取哪些值呢,有個簡單方法:先在檔案頭確定要檢視的引數,然後全域性搜尋,看setif相關的語句就能確定它支援的引數形式了。

使用組態檔完成交叉編譯

說了那麼一大堆,回到最開始的例子上來。現在我們有了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有著層層保證唯一性的機制。

  1. 包名可以保證類名的唯一性;
  2. 類名可以保證同一包名下類的唯一性;
  3. 同一個類下可以用方法名保證唯一性;
  4. 方法發生過載的時候可以用引數型別和個數確定類的唯一性。

而對於C/C++來說,沒有包名和類名,那麼用方法名和方法引數可以確定唯一性嗎?答案是可以,只要我們把包名和類名作為一種限定條件。

而新增限定條件的方式有兩種,一種就是簡單粗暴,直接把包名類名作為函數名的一部分,這樣JVM也不用看其他的東西,直接粗暴地將包名,類名,函數名和引數這些對應起來就能確定對端對應的方法了。這種方法叫做靜態註冊。其實這和Android裡面的廣播特別像:廣播的靜態註冊就是直接粗暴地在AndroidManifest檔案中寫死了,不用在程式碼裡設定,一寫了就生效。對應於靜態註冊,肯定還有個動態註冊的方法。動態註冊就是用寫程式碼的方式告訴JVM函數間的對應關係,而不是讓它在函數呼叫時再去查詢。顯然這種方式的優勢就是呼叫速度更快一點,畢竟我們只需要一次註冊,就可以在後續呼叫中直接存取到對端,不再需要查詢操作。但是同樣和Android中廣播的動態註冊一樣,動態註冊要繁瑣得多,而且動態註冊還要注意把握好註冊時機,不然容易造成呼叫失敗。我們繼續以前面的libmath.so為例講解。

Java使用本地庫

Java端存取C/C++函數很簡單,一共分三步:

  1. Java呼叫System.loadLibrary()方法載入庫

    System.loadlibrary("math.so");
    

    這裡有個值得注意的地方,CMake生成的動態庫是libmath.so,但是這裡只寫了math.so,也就是說不需要傳遞lib這個字首。這一步執行完後,JVM就知道有個plus函數了。

  2. Java宣告一個和C++函數對應的native方法。這裡對應指的是參數列和返回值要保持一致,方法名則可以不一致。

    public native int nativePlus(int left,int right);
    

    通常,習慣將native方法新增native的字首。

  3. 在需要的地方直接呼叫這個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++函數簽名,具體來說分四步。

  1. 先寫出和Java native函數一模一樣的函數簽名
int nativePlus(int left,int right)
  1. 在函數名前面新增包名和類名。因為包名在Java中是用.分割的,而C/C++中點通常是用作函數呼叫,為了避免編譯錯誤,需要把.替換成_
hongui_me_MainActivity_nativePlus(int left,int right)
  1. 轉換函數引數。前面提到過所有的操作都是基於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)
  1. 新增必要字首。這一步會很容易被忽略,因為這一部分不是那麼自然而然。首先我們的函數名還得加一個字首Java,現在的函數名變成了這樣Java_hongui_me_MainActivity_nativePlus。其次在返回值兩頭需要新增JNIEXPORTJNICALL,這裡返回值是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函數的一種方式,同一個函數一般只採用一種註冊方式。所以,接下來的步驟是和靜態註冊平行的,並不是先後關係。

動態註冊分六步

  1. 新建native_jni.cpp檔案,新增JNI_OnLoad()函數的實現。
extern "C" JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM *vm, void *reserved) {

   return JNI_VERSION_1_6;
}

這就是這個函數的標準形式和實現,前面那一串都是JNI函數的標準形式,關鍵點在於函數名和引數以及返回值。要想這個函數在庫載入後自動呼叫,函數名必須是這個,而且引數形式也不能變,並且用最後的返回值告訴JVM當前JNI的版本。也就是說,這些都是模板,直接搬就行。

  1. 得到JNIEnv物件

前面提到過,所有的JNI相關的操作都是通過JNIEnv物件完成的,但是現在我們只有個JavaVM物件,顯然祕訣就在JavaVM身上。
通過它的GetEnv方法就可以得到JNIEnv物件

JNIEnv *env = nullptr;
vm->GetEnv(env, JNI_VERSION_1_6);
  1. 找到目標類

前面說過,動態註冊和靜態註冊都是要有包名和類名最限定的,只是使用方式不一樣而已。所以動態註冊我們也還是要使用到包名和類名,不過這次的形式又不一樣了。靜態註冊包名類名用_代替.,這一次要用/代替.。所以我們最終的類形式是hongui/me/MainActivity。這是一個字串形式,怎樣將它轉換成JNI中的jclass型別呢,這就該第二步的JNIEnv出場了。

jclass cls=env->FindClass("hongui/me/MainActivity");

這個cls物件就和Java裡面那個MainActivity是一一對應的了。有了類物件下一步當然就是方法了。

  1. 生成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這裡的paramTypereturnType都需要是JNI型別簽名,型別間不需要任何分隔符。

綜上,nativePlus的型別簽名是(II)I。兩個整型引數,返回另一個整型。

  • fnPtr正如它名字一樣,它是一個函數指標,值就是我們真正的nativePlus實現了(這裡我們還沒有實現,所以先假定是jni_plus)。

綜上,最終函數物件陣列應該是下面這樣

 JNINativeMethod methods[] = {
    {"nativePlus","(II)I",reinterpret_cast<void *>(jni_plus)}
 };
  1. 註冊

現在有了代表類的jclass物件,還有了代表方法的JNINativeMethod陣列,還有JNIEnv物件,把它們結合起來就可以完成註冊了

env->RegisterNatives(cls,methods,sizeof(methods)/sizeof(methods[0]));

這裡第三個引數是代表方法的個數,我們使用了sizeof操作法得出了所有的methods的大小,再用sizeof得出第一個元素的大小,就可以得到methods的個數。當然,這裡直接手動填入1也是可以的。

  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等等供我們直接使用。