1. 程式人生 > >Android程式中,內嵌ELF可執行檔案-- Android開發C語言混合程式設計總結

Android程式中,內嵌ELF可執行檔案-- Android開發C語言混合程式設計總結

前言

都知道的,Android基於Linux系統,然後覆蓋了一層由Java虛擬機器為核心的殼系統。跟一般常見的Linux+Java系統不同的,是其中有對硬體驅動進行支援,以避開GPL開源協議限制的HAL硬體抽象層。
大多數時候,我們使用JVM語言進行程式設計,比如傳統的Java或者新貴Kotlin。碰到對速度比較敏感的專案,比如遊戲,比如視訊播放。我們就會用到Android的JNI技術,使用NDK的支援,利用C++開發高計算量的模組,供給上層的Java程式呼叫。
本文先從一個最簡單的JNI例子來開始介紹Android中Java和C++的混合程式設計,隨後再介紹Android直接呼叫ELF命令列程式的規範方法,以及呼叫混合了第三方庫略微複雜的命令列程式。

Android Studio配置

第一個配置是安裝Android的SDK,這是開發Android程式必須的。
進入Android Studio的設定介面,Mac的快捷鍵是Command+,,Windows和Linux版本請自行從選單中選擇。
在設定介面中,從左側順序選擇:Appearance&Behavior -> System Settings -> Android SDK,可以進入到SDK的設定。

右側的SDK版本列表中,最前面顯示了✔️或者後面顯示了Installed,表示該版本的SDK已經安裝。通常如果沒有特殊需要,只安裝1個最新版本的SDK即可。圖中我是因為某些專案特殊的要求,安裝了兩個特定不同版本的SDK。

希望安裝某版本的SDK,只要點選相應行最前面的多選框,然後單擊右下角確認按鈕即可安裝。
如果不是自己從頭開始,而是接手了其他開發人員的原始碼,原始碼中可能指定了特定版本的SDK。這時候可以修改其專案配置檔案中版本的設定,到你安裝的SDK版本。更簡單的方法是直接在這裡安裝對應的SDK,防止因為版本依賴出現的很多繁瑣問題。

第二個配置的是NDK,還在剛才SDK設定的介面中,點選介面上側中間的“SDK Tools”標籤,可以進入到NDK設定的介面。

NDK的設定沒有那麼多的選擇,只要安裝就好,已經安裝碰到有新版本,也可以隨性選擇更新或者使用老版本繼續。NDK不同版本間的相容性都還不錯,大多都不用擔心。

NDK的設定是Android開發中,Java/C混合程式設計需要的。

第三個配置是增加一個外部工具javah,這個工具是將Java編寫的“包裝”檔案,轉換一個C/C++的.h檔案。雖然Java/C++都是面嚮物件語言,但兩者的面向物件實現是不同的。所以在Java中某個類的方法,轉換到C++的世界中,是使用很長的函式名來做區分。這種情況使用手工編寫雖然效果一樣,但很容易出錯,使用javah工具則能自動完成。
在Android Studio設定介面左側的列表中,順序選擇Tools -> External Tools,單擊右側介面左下角的“+”,新建一個工具,比如就叫"javah"。

其中三個需要設定的內容分別是:

  • javah程式路徑:$JDKPath$/bin/javah,這個跟jdk安裝的路徑有關。
  • 命令列引數:-classpath . -jni -d $ModuleFileDir$/src/main/jni $FileClass$,主要指定輸出路徑。
  • 工作目錄:$ModuleFileDir$/src/main/Java,當前專案路徑。

至此Android Studio的主要設定就完成了,當然只是最基本必須的設定,如果自己還有其它需求,類似git倉庫地址等,可以再自行設定。
下面就可以開始進行專案的開發。

先準備一個基本的Android程式

在Android Studio介面選擇New Project,如果是在開始介面,直接點選主介面上的按鈕;也可以在檔案選單中選擇。

選擇基本的Empty Activity就好。

接著是專案的設定,專案名稱、儲存位置這些都不用說了,最低的API版本決定了你的程式可以在最低什麼版本的Android手機上執行,如果沒有特殊需要,儘量可以低一點,畢竟Android手機的升級比例,比iOS是低了好多倍的。
這樣,專案就建立完成,Android Studio使用標準模板,對專案做了初始化。我們可以在這個基礎上再新增自己的內容。

從螢幕左側專案檔案的列表中,選擇app -> res -> layout -> acitvity_main.xml檔案,檔案會在右側開啟,模式是互動式的介面設計器。在其中,按照下圖的樣子,我們增加一個TextView控制元件和一個按鈕。文字框是為了將來顯示輸出的結果,按鈕當然就是開始執行的觸發器。

TextView控制元件我們修改一下名字,叫textView1。按鈕的名字改為button1,另外為按鈕的onClick屬性增添一個呼叫:bt1_click。
介面部分就完成了,記著存檔,然後可以關掉這個檔案。

這時候,Android Studio介面會顯示在MainActivity.java檔案的位置。這是新建專案之後自動開啟的檔案,也是這個專案的主視窗程式檔案。我們首先編輯窗口布局檔案的時候,這個檔案被隱藏在了後面。
我們在檔案的庫引用部分,增加如下兩行:

import android.widget.TextView;
import android.view.View;

這兩行是我們接下來的程式會使用到的庫引用。
在類的變數宣告部分,增加這樣兩行:

    TextView textview1;
    int c=0;

第一行是宣告一個文字框,用於關聯到剛才介面編輯器中加入的文字框。
c變數就是一個簡單的計數器,我們希望每點選一次按鈕,這個計數器累加1,從而確認我們每次點選都被響應了,而不是程式沒有任何反饋給使用者。
onCreate函式的最後,增加關聯文字框的程式碼:

        textview1=(TextView)findViewById(R.id.textView1);

R.id.後面的textView1就是我們在介面編輯的時候,為文字框起的名字。
接著,在類的最後,增加按鈕點選響應的處理函式:

    public void bt1_click(View view){
        c = c+1;
        textview1.setText("click:"+c);
    }

清晰起見,我們把這部分完成的程式碼再抄過來一遍:

package com.test.calljni;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.widget.TextView;
import android.view.View;

public class MainActivity extends AppCompatActivity {

    TextView textview1;
    int c=0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        textview1=(TextView)findViewById(R.id.textView1);
    }
    public void bt1_click(View view){
        c = c+1;
        textview1.setText("click:"+c);
    }
}

程式完成,可以從Build選單選擇Make Project編譯專案。然後在Run選單選擇Run 'app'。
如果是第一次使用Android Studio,你還可能會被提醒需要你新建一個Android模擬器來執行程式。當然也可以把打開了除錯功能的Android手機插在電腦上進行真機除錯。
執行的結果如圖:

點選兩次按鈕後,畫面變為:

好了,我們的基本實驗平臺準備完成,下面才是進入正題。

呼叫JNI庫

每個JNI庫都分為兩部分,一個是C++編寫的.so動態連結庫,另一部分則是Java對這個動態連結庫的封裝。我們先從Java部分看起。

編寫JNI庫的Java封裝類

開始寫這個JNI庫之前,我們首先要對這個庫的總體功能、結構劃分、介面型別充分做好規劃,這樣才能保證兩種語言之間的順暢呼叫。因為尚沒有一種工具可以同時有效的對兩種語言進行跟蹤除錯,所以在介面部分如果碰到問題,往往只能在大量的日誌輸出中去查詢線索,費時費力。
作為一個簡單的演示,我們的JNI庫功能很簡單,從Java封裝的角度看,我們有一個名為JniLib的Java類,其中包含一個方法,叫callToCpp,這個方法,將會在C++中來實現。
在檔案列表中,選擇MainActivity.java所在的包名,點選右鍵,選擇New->Java Class。
一切選用預設設定,類名為JniLib。

Android Studio會自動生成並開啟一個JniLib.java檔案。其中只有一個而空白的類定義。我們在其中繼續編寫自己的內容。
這個封裝類的程式碼非常簡單,我們直接列出全部:

package com.test.calljni;

public class JniLib {
    static {
        System.loadLibrary("JniLib");
    }

    public static native String callToCpp();
}

其中的靜態部分,相當於構造函數了,直接載入一個動態連結庫,名稱為“JniLib”。這個是對於Java來說的庫名,實際對應的檔名將是libJniLib.so。就是說,Android在載入動態連結庫的時候,自動在給定的連結庫名稱前面新增“lib”,後面新增“.so”字尾。這個我們在後面還會更直觀的展示。
接著是宣告一個native型別的函式,callToCpp(),native表示這個函式將在剛剛載入的libJniLib.so中實現,也就是將由C++來實現。

由封裝類生成C++標頭檔案

下面是利用這個JniLib類,生成C++使用的.h標頭檔案。
在Android Studio介面的左側列表中,用滑鼠右鍵點選JniLib檔案,彈出選單中選擇External Tools -> javah,這個javah就是我們前面建立的附加工具。

此時最好將Android Studio左側的檢視從預設的“Android”方式修改到“Project”方式,這樣能更清晰的看到目錄層次關係。
隨後左側列表中,跟Java資料夾同級,會出現一個jni資料夾,其中有一個檔案:com_test_calljni_JniLib.h,這就是剛才由javah自動生成的。
標頭檔案生成到src/main/jni目錄,這是我們在javah擴充套件工具設定的時候所確定下來的。
在列表中雙擊com_test_calljni_JniLib.h檔案開啟,其內容為:

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_test_calljni_JniLib */

#ifndef _Included_com_test_calljni_JniLib
#define _Included_com_test_calljni_JniLib
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     com_test_calljni_JniLib
 * Method:    callToCpp
 * Signature: ()Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_com_test_calljni_JniLib_callToCpp
  (JNIEnv *, jclass);

#ifdef __cplusplus
}
#endif
#endif

Java_com_test_calljni_JniLib_callToCpp函式定義這一行,對應就是我們在Java JniLib類中所宣告的callToCpp方法。整個函式名中包含了封裝語言Java/Java包名com.test.calljni/類名JniLib/方法名callToCpp幾個部分。
請注意檔案第一行的提醒資訊,這個標頭檔案的內容不要自行修改,如果修改Java封裝檔案JniLib.java導致了類名、函式名的變化,應當重複上一步,使用javah工具重新完整生成標頭檔案。

C++實現JNI庫

繼續用C++編寫我們的函式實現。用滑鼠右鍵點選列表中的jni資料夾,新建一個c++原始檔,名稱定為JniLib.cpp。
內容如下:

#include "com_test_calljni_JniLib.h"

JNIEXPORT jstring JNICALL Java_com_test_calljni_JniLib_callToCpp
  (JNIEnv *env, jclass){
    return (*env).NewStringUTF("從cpp返回的文字。");
  };

c++程式碼中,首先是引用剛才由javah生成的標頭檔案,這是為了保證c++中定義的函式,嚴格吻合Java封裝類中所指定的型別。
函式的定義比較長,可以從.h檔案中直接拷貝進來。因為JNIEnv引數我們會用到,所以我們在後面新增一個具體的變數名,這裡用“env”。
函式中只有一條語句,就是返回一個文字字串,使用JNI中提供的NewStringUTF函式把這個C++的字串轉換為一個Java的String物件。

NDK編譯指令碼

使用NDK系統編譯JNI庫,還需要有兩個檔案,都將位於src/main/jni資料夾中,一個是Application.mk檔案,內容只有一行:

APP_ABI := all

ABI是應用程式二進位制介面的縮寫,指的是Android主機的CPU型別,不同CPU需要有不同的二進位制介面型別。
Java是一種跨CPU的語言,並不要求指定特定的CPU。而C/C++語言,在不同的CPU上,都需要進行特定的編譯。
這裡設定APP_ABI為all,指的是我們寫的這個JniLib庫,將接受所有NDK支援的CPU型別。NDK在編譯的時候,會自動編譯多個不同CPU需要的動態連結庫。並都打包在最終的APK檔案中。
在不同的Android系統安裝的時候,會自動選擇正確的CPU型別安裝其中一種。

接著看第二個NDK編譯所需檔案,Android.mk:

LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)
LOCAL_MODULE := JniLib
LOCAL_SRC_FILES := JniLib.cpp
include $(BUILD_SHARED_LIBRARY)

用過Makefile的人應當看上去感覺很熟悉。這個就相當於Makefile的主檔案,用於描述如何編譯我們的JNI庫。當然因為我們其中大量的使用了NDK已有的環境變數和指令碼,所以Applcation.mk/Android.mk實際都將被NDK的主體Makefile呼叫,最終完成完整的編譯。
其中LOCAL_MODULE變數所指定的名稱,就是我們編譯之後的模組名稱,這個跟JniLib.java中載入的類名,必須是一致的。

Gradle自動編譯NDK專案

有了這些,如果用過命令列的話,我們可以直接在命令列對JNI部分進行編譯了。
但作為一個完整的程式,我們更希望JNI部分,也能在整體Android Studio專案編譯的時候編譯,並一起打包進APK。
所以我們修改一下本專案的Gradle指令碼,增加NDK編譯的配置。Gradle是Android Studio中所採用的開源工具,用於專案的管理和自動構建。
在Android Studio左側列表中找到app/build.gradle檔案,雙擊開啟。在專案的主目錄下還有一個build.gradle檔案,不要誤選到那一個。
在android一節中,defaultConfig之下、buildTypes之上增加如下程式碼:

    externalNativeBuild {
        ndkBuild {
            path "src/main/jni/Android.mk"
        }
    }

表示本專案使用ndk編譯JNI庫,本專案JNI庫的編譯指令碼為src/main/jni/Android.mk檔案。還可以選擇使用CMAKE系統來編譯JNI專案,不過為了不擴充套件太大的話題,這裡就不講了。對CMAKE情有獨鍾的開發者可以搜尋相關資料。
為了能看的清楚,貼一次完整的app/build.gradle檔案:

apply plugin: 'com.android.application'

android {
    compileSdkVersion 28
    defaultConfig {
        applicationId "com.test.calljni"
        minSdkVersion 19
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    externalNativeBuild {
        ndkBuild {
            path "src/main/jni/Android.mk"
        }
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

至此,JNI部分的完整定義就完成了。

在Java中呼叫JNI庫

JNI庫的效果,還要修改一下我們程式的MainActivity類,才能體現出來。不然JNI庫會被編譯,會被打包,但並沒有什麼用。
首先修改專案的佈局檔案activity_main.xml檔案,在當前按鈕的右邊,再增加一個按鈕,名稱為button2,onClick設定為bt2_click,順便也為按鈕設定一個新的顯示字串“CALLJNI”。修改完成存檔,關閉檔案。
這個小例子重點是說明同C/C++語言的混合程式設計,所以很多細節都從簡了,比如剛才按鈕的顯示資訊,都應當是定義在資原始檔中的,而不是在這裡直接使用常量字串。常量字串雖然簡便,但無法完成多國語言自動切換等基本功能,在正式的專案中應當避免這樣使用。
接著在MainActivity.java檔案中,增加點選事件處理程式,新增在bt1_click定義的下面就成:

    public void bt2_click(View view){
        c = c+1;
        textview1.setText("click:"+c+"\n"+JniLib.callToCpp());
    }

現在可以完整的編譯一遍了,如果沒有錯誤發生,就在模擬器中執行來測試。

點選CALLJNI按鈕後,文字框顯示的資訊表示JNI正常執行了。

解析包含JNI庫的APK安裝檔案

先上一張apk包的檔案結構圖片吧:

包含JNI庫的安裝包,比平常的安裝包多一個lib資料夾。其中按照支援的CPU型別,再細緻分類。最終裡面是JNI庫的二進位制檔案。
在我們這個例子中,就是libJniLib.so,如同前面說過的。
APK包安裝的時候,根據確定的硬體平臺,實際只有一個對應的.so檔案會被安裝的裝置上。

呼叫一個完整的命令列可執行檔案

呼叫完整的可執行檔案,這在Android中並不是官方推薦的。但通常基於Linux系統的程式設計,這又是不可避免的。很多必要操作,如果開發系統的SDK支援不足,或者用起來不方便。都可以通過直接訪問系統層引數檔案或者系統層可執行檔案來完成。
不同的作業系統,有不同的可執行檔案格式。比如Windows的EXE/PE格式,macOS的Mach-O。在Linux上,就是ELF格式。
作為C語言為主要程式設計工具的Linux系統,擁有龐大的ELF可執行資源,幾乎所有的程式都是直接、或者間接由ELF可執行程式完成的,甚至包括JVM本身。
一些新興語言,比如golang,也提供了直接生成Android二進位制檔案的交叉編譯功能。
所以讓Android程式直接可以同ELF可執行程式互動,不僅僅是同C語言混合程式設計的問題,而是這樣可以獲得大量社群資源的支援。很多開源專案拿來,很少的修改,就可以在Android程式的背後發揮作用。

早期的Android系統呼叫可執行程式非常容易,把編譯好的程式拷貝到Android中,設定為可執行屬性,就可以執行了。
隨著Android系統的升級,安全性越來越好,除非root,上面這種方式已經不靈了。越來越多的限制讓直接執行內嵌的可執行檔案變得不再可行。

在當前的Android版本中,在APK程式中內嵌可執行檔案,需要通過以下幾個步驟:

  • 在NDK中編譯對應的原始碼。或者在其它語言環境中,使用對應工具,生成在Android環境可以執行的二進位制程式碼。
  • 除了.so之外的編譯結果,並不會自動打包到APK中。所以編譯出的二進位制程式碼,需要作為資料檔案,放入APK的資源區。
  • 在Java程式碼中,根據檢測到的CPU型別,把對應的可執行檔案,從資料區拷貝到Android裝置上,並設定為可執行。
  • 在Java程式碼中呼叫可執行程式,並獲取結果。
編譯可執行檔案

首先當然是準備一個C/C++程式碼,比如我們用一個最經典的Hello World。這麼多年以來,這居然是相容性最好的程式碼了:)

#include<stdio.h>

int main(int argc, char **argv){
    printf("你好世界, I'm hello.c\n");
    return 0;
}

檔名叫hello.c,放到jni資料夾下面。

然後配置Android.mk檔案,以編譯這個程式碼。
把下面的程式碼放置到Android.mk的最後:


include $(CLEAR_VARS)
LOCAL_MODULE := hello
LOCAL_SRC_FILES := hello.c
include $(BUILD_EXECUTABLE)

仔細看,其實只有最後一行有區別,根據英文應當能理解含義,就是編譯為可執行檔案的意思。

編譯結果打包進入APK

因為內建可執行檔案並不是官方推薦的方式,所以編譯的結果,並不會被自動打包到安裝包APK。
經由Gradle呼叫ndk-build編譯的結果儲存在如下的路徑:

# Debug版本
app/build/intermediates/ndkBuild/debug/obj/local/
# Release版本
app/build/intermediates/ndkBuild/release/obj/local/

同樣在Gradle的設定中,可以指定把具體的內容打包到Android的assets資料夾中。assets資料夾中包含的是程式執行所需的資原始檔,所以這裡,也是把可執行檔案,當做資源、資料檔案,嵌入在APK中。
請把下面程式碼,放置到app/build.gradle檔案,android.defaultConfig一節的最後:

        sourceSets{
            main{
                assets{
                    srcDirs = ['build/intermediates/ndkBuild/debug/obj/local']
                }
            }
        }

sourceSets.main.assets.srcDirs的設定實際是一個數組,可以包含多個路徑。如果開發的專案還有別的資料檔案需要打包,可以在這裡增添自己的內容。
注意上面示例中設定中的路徑,是個不完美的地方。當前指向了debug除錯編譯輸出的結果。在開發完成,正式投產的時候,應當換到release輸出結果,也即:build/intermediates/ndkBuild/release/obj/local。不然包含的二進位制檔案中間會有除錯資訊,除了檔案尺寸會大,也造成不安全因素。
其實我個人常用的方式,是直接用Release方式編譯一遍整個專案,然後release資料夾中就會有二進位制編譯結果。隨後Gradle的設定,就一直保持在release版本的打包。反正你也不可能用Android Studio對C/C++程式碼進行除錯,那個工作你肯定是使用另外的開發工具完成的。

然後事情並沒有結束,我們開啟編譯結果的資料夾看一看,是類似下面的樣子:

其中同樣會根據CPU型別不同,分為幾個資料夾,這是預料之中的。但中間除了有我們需要的hello可執行檔案,還會有本已打包的JNI庫.so檔案,以及一些編譯輸出資訊和中間檔案。而這些,就成為了我們的垃圾檔案,需要排除在外。
可以把下面程式碼,新增在app/build.gradle中,externalNativeBuild上面的位置,跟externalNativeBuild處在同一級:

    aaptOptions {
        ignoreAssetsPattern '!*.txt:!*.so:!*debug:!*release:!*.a'
    }

這裡要吐槽一下Android Studio Gradle指令碼的設計。通常講,ignoreAssetsPattern關鍵詞已經有了“忽略、排除”的含義,是個否定詞。而在其中的設定中,又對每個需要排除的內容,前面增加“!”否定,實在是反人類啊......

現在如果編譯一遍,看看打包的結果,當然也只是完成了打包,我們還沒有執行這個程式。

APK中多了一個assets資料夾,其中根據CPU型別分類,hello已經在裡面了。

把可執行程式拷貝到Android系統

這個工作是最複雜的部分,至少比我們演示中顯示一個字串複雜多了。
好在這個程式非常通用,把這個類留著,以後所有同類程式都可以直接拿來使用。
在java資料夾自己的包名上右鍵點選滑鼠,增加一個Java類,命名為CopyElfs。在生成的java檔案中,把下面的程式碼帖進去:

package com.test.calljni;

import android.content.Context;
import android.content.res.AssetManager;
import android.util.Log;

import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import android.os.Build;

public class CopyElfs {
    String TAG="Ce_Debug:";
    Context ct;
    String appFileDirectory,executableFilePath;
    AssetManager assetManager;
    List resList;
    String cpuType;
    String[] assetsFiles={
            "hello"
    };

    CopyElfs(Context c){
        ct=c;
        appFileDirectory = ct.getFilesDir().getPath();
        executableFilePath = appFileDirectory + "/executable";

        // cpuType = Build.SUPPORTED_ABIS[0];
        cpuType = Build.CPU_ABI;
        assetManager = ct.getAssets();
        try {
            resList = Arrays.asList(ct.getAssets().list(cpuType+"/"));
            Log.d(TAG,"get assets list:"+resList.toString());
        } catch (IOException e){
            Log.e(TAG, "Error list assets folder:", e);
        }
    }
    boolean resFileExist(String filename){
        File f=new File(executableFilePath+"/"+filename);
        if (f.exists())
            return true;
        return false;
    }
    void copyFile(InputStream in, OutputStream out){
        try {
            byte[] buf = new byte[1024];
            int len;
            while ((len = in.read(buf)) > 0) {
                out.write(buf, 0, len);
            }
        } catch (IOException e){
            Log.e(TAG, "Failed to read/write asset file: ", e);
        }
    };
    private void copyAssets(String filename) {
        InputStream in = null;
        OutputStream out = null;
        Log.d(TAG, "Attempting to copy this file: " + filename);

        try {
            in = assetManager.open(cpuType+"/"+filename);
            File outFile = new File(executableFilePath, filename);
            out = new FileOutputStream(outFile);
            copyFile(in, out);
            in.close();
            in = null;
            out.flush();
            out.close();
            out = null;
        } catch(IOException e) {
            Log.e(TAG, "Failed to copy asset file: " + filename, e);
        }
        Log.d(TAG, "Copy success: " + filename);
    }
    void copyAll2Data(){
        int i;

        File folder=new File(executableFilePath);
        if (!folder.exists()){
            folder.mkdir();
        }

        for(i=0;i<assetsFiles.length;i++){
            if (!resFileExist(assetsFiles[i])){
                copyAssets(assetsFiles[i]);
                File execFile = new File(executableFilePath+"/"+assetsFiles[i]);
                execFile.setExecutable(true);
            }
        }
    }

    String getExecutableFilePath(){
        return executableFilePath;
    }
}

類成員assetsFiles陣列中,可以包含多個可執行檔案,把檔名放在這裡,就會被拷貝到Android裝置的/data/data/包名/files/excutable/資料夾,並設定為可以執行。
接著在MainActivity類的onCreate成員中,增加對拷貝可執行檔案功能的呼叫:

    CopyElfs ce;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        textview1=(TextView)findViewById(R.id.textView1);

        ce = new CopyElfs(getBaseContext());
        ce.copyAll2Data();
    }
執行對Elf執行檔案的呼叫

做了這麼多準備性工作,開始真正對程式的呼叫。
首先還是修改佈局檔案,再增加一個按鈕,名稱叫button3,顯示字串是“CALLELF”,onClick的事件處理函式是bt3_click。

這次要新增的程式碼不僅僅是bt3_click方法,還要對呼叫命令列程式以及獲取其結果單獨抽象為一個方法。
考慮到還要增加一些對應的類成員變數,和庫檔案的引用。我們把完整的MainActivity.java程式碼列出來:

package com.test.calljni;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.widget.TextView;
import android.view.View;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import android.util.Log;

public class MainActivity extends AppCompatActivity {
    String TAG="Main_Debug:";
    TextView textview1;
    int c=0;
    CopyElfs ce;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        textview1=(TextView)findViewById(R.id.textView1);

        ce = new CopyElfs(getBaseContext());
        ce.copyAll2Data();
    }
    public void bt1_click(View view){
        c = c+1;
        textview1.setText("click:"+c);
    }
    public void bt2_click(View view){
        c = c+1;
        textview1.setText("click:"+c+"\n"+JniLib.callToCpp());
    }
    public String callElf(String cmd){
        Process p;
        String tmpText;
        String execResult = "";

        try {
            p = Runtime.getRuntime().exec(ce.getExecutableFilePath() + "/"+cmd);
            BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()));
            while ((tmpText = br.readLine()) != null) {
                execResult += tmpText+"\n";
            }
        }catch (IOException e){
            Log.i(TAG,e.toString());
        }
        return execResult;
    }

    public void bt3_click(View view){
        c = c+1;
        textview1.setText("click:"+c+"\n"+callElf("hello"));
    }
}

現在已經完整了,可以編譯然後在模擬器執行來嘗試一下。

還可以詳細探究可執行檔案,拷貝到Android裝置之後的細節。這個使用adb工具連線到裝置上就能看出來,請看下面執行的截圖:

編譯帶有擴充套件庫的可執行檔案

前面的例子,我們已經認識到了NDK的強大。而ndk-build編譯工具,基本屬於一個Makefile的工作方式。
然而在Linux龐大的開源社群中,多種編譯管理工具都同時存在。其實不僅僅Android,即便在桌面版的Linux版本中,編譯不同的軟體包,也是一件費時費力的事情。
因此想繼承開源社群的龐大優勢,除了上面講到的這些必要工作,把軟體包編譯到Android的環境中,是最主要需要完成的工作。
這個話題太大,內容太多也太分散,我們的文章是遠遠無法涵蓋的。以最常用的OpenSSL開源庫為例,GitHub上有一個編譯指令碼,值得參考:
https://github.com/lllkey/android-openssl-build

我們下面只演示一下,在自己的程式中,呼叫openssl庫的方式。實際在Android SDK以及Java標準庫中,都已經有很多編、解碼功能足以滿足應用。所以這裡只是用於演示操作的方法,正式開發中,要根據實際需要選擇開源庫來使用。
首先我們把上面編譯好的openssl庫下載到本地,放到跟當前的Android專案平級就好,其實路徑隨意自己定,只要在接下來的設定中,指到正確的路徑就沒有問題。

$ git clone https://github.com/lllkey/android-openssl-build.git

因為這個開源庫並非我們專案的一部分,我們只把它的編譯結果,連結到我們的專案中:

$ cd calljni/app/src/main/jni
$ ln -s /home/andrew/dev/android/android-openssl-build/result/ openssl
#注意上面的路徑,應當是你clone下來的真實路徑
$ ls -lh openssl/
total 0
drwxr-xr-x  4 andrew  staff   136B Jun  4 08:48 arm64-v8a
drwxr-xr-x  4 andrew  staff   136B Jun  4 08:48 armeabi-v7a
drwxr-xr-x  4 andrew  staff   136B Jun  4 08:48 x86
drwxr-xr-x  4 andrew  staff   136B Jun  4 08:48 x86_64

下面我們寫一個小程式,用於呼叫openssl庫中的md5編碼功能,程式名為md5.c,放置在jni路徑下面:

#include <stdio.h>
#include <string.h>
#include <openssl/md5.h>

void openssl_md5(const char *data, int size, char *rs){
    unsigned char buf[16];

    memset(buf,0,16);

    MD5_CTX c;
    MD5_Init(&c);
    MD5_Update(&c,data,size);
    MD5_Final(buf,&c);

    char tmp[3];
    strcpy(rs,"");
    int i;
    for (i = 0; i < 16; i++){
        sprintf(tmp,"%02x",buf[i]);
        strcat(rs,tmp);
    }
}

int main(int argc, char **argv){
    if (argc != 2){
        printf("Wrong argument.\n");
        return 1;
    }
    char md5str[33];
    openssl_md5(argv[1],strlen(argv[1]),md5str);
    printf("%s\n",md5str);
    return 0;
}

然後是修改Android.mk編譯指令碼,這次增加的是三部分。兩個是已經編譯完成的openssl Android版本庫;一個是我們新增的md5.c編譯。編譯時還要滿足,根據不同的CPU型別,選擇不同的openssl庫,並且編譯對應的CPU版本md5可執行檔案。這個過程中,需要使用不同的預定義環境參量來完成這個工作:

include $(CLEAR_VARS)
LOCAL_MODULE    := ssl
LOCAL_SRC_FILES := $(LOCAL_PATH)/openssl/$(TARGET_ARCH_ABI)/lib/libssl.a
include $(PREBUILT_STATIC_LIBRARY)

include $(CLEAR_VARS)
LOCAL_MODULE    := crypto
LOCAL_SRC_FILES := $(LOCAL_PATH)/openssl/$(TARGET_ARCH_ABI)/lib/libcrypto.a
include $(PREBUILT_STATIC_LIBRARY)

include $(CLEAR_VARS)
LOCAL_SHARED_LIBRARIES := \
    ssl \
    crypto
LOCAL_C_INCLUDES += $(LOCAL_PATH)/openssl/$(TARGET_ARCH_ABI)/include
LOCAL_MODULE := md5
LOCAL_SRC_FILES := md5.c
include $(BUILD_EXECUTABLE)

上面的程式碼中:

  • $(PREBUILT_STATIC_LIBRARY)指定了預定義的靜態庫檔案
  • $(LOCAL_PATH)就是指jni資料夾路徑
  • $(TARGET_ARCH_ABI)是根據目標CPU的ABI不同,選擇不同的庫檔案和C語言標頭檔案。

想必你也想到了,還要在MainActivity.java中,增加呼叫md5的程式碼,當然還有layout檔案:

按鍵響應程式碼:

    public void bt4_click(View view){
        c = c+1;
        textview1.setText("click:"+c+"\n"+callElf("md5 testString"));
    }

作為md5引數的字串,在正式的程式中,肯定應當是從某些計算中獲取,或者從螢幕的輸入框讀取。這裡直接使用一個常量“testString”。
最後還有特別容易忘的一個地方,就是CopyElfs中可執行檔案的列表:

    String[] assetsFiles={
            "hello","md5"
    };

不得不承認,有了上一小節的基礎,增加個可執行程式或者第三方庫,都不算什麼工作量。
程式的執行結果如下:

還可以在臺式電腦中驗證一下計算的結果:

$ echo -n "testString" | md5
536788f4dbdffeecfbb8f350a941eea3
使用第三方庫的其它注意事項

md5程式,使用了openssl的靜態連結庫.a檔案。在Android4之後的版本中,如果不做root,似乎暫時沒有好辦法使用.so動態連結庫。
JNI則可以使用.so檔案,這時候在Android.mk中,應當使用$(PREBUILT_SHARED_LIBRARY)參量,來說明一個.so的預定義動態連結庫。
使用了第三方的動態連結庫,在呼叫JNI的時候也有額外一點需要注意,就是在載入自己的JNI庫之前,必須把用到的依賴庫,首先載入進來,否則直接載入JNI庫會報錯:

public class JniLib {
    static {
        System.loadLibrary("crypto");
        System.loadLibrary("ssl");
        System.loadLibrary("JniLib");
    }
    .......

最後是本文中所使用的示例程式碼:
連結: https://pan.baidu.com/s/1yDU0q5nikorSyD0av0Ue5w 提取碼: 8