手擼網易雲進階課程-效能優化之NDK高效載入GIF

藍師傅_Android發表於2019-12-01

之前很多次看到網易雲課程廣告,裡面有個熟悉的標題是這樣的

效能優化之NDK高效載入GIF--NDK開發實戰 三個小節如下:

  1. 安卓NDK開發快速入門
  2. giflib在安卓開發中的使用
  3. NDK載入GIF較傳統載入方式的優勢

這個廣告標題已經看到過好多次了,是一個進階課程,那麼肯定是收費的,我想不花錢就能學會NDK高效載入GIF,行嗎?

首先第一點,安卓NDK開發快速入門並不是很難,大多數Android開發都是屬於應用層開發,很少涉及NDK,只要找一篇入門文章學習即可。

第二點,giflib在安卓開發中的使用,看到 giflib,猜想應該是一個開源庫來的,在安卓中使用這個開源庫,應該是跟FFmpeg類似,需要會一點NDK,用c++程式碼呼叫這個開源庫api,然後通過JNI,提供給Android端呼叫,如果單純是API使用,對於有JNI、NDK基礎的同學來說,這一節其實難度不是很大。

最後一點,NDK載入GIF較傳統載入方式的優勢,是對標題的“高效載入”進行補充了,猜想這一節可能會講giflib 載入GIF的原理,為什麼高效? 應該還會對比其它的不使用NDK載入的方式,例如Glide,為什麼就慢?這個涉及到載入gif原理了,能掌握的話,面試是不虧的。

會NDK,會使用NDK高效載入gif,知道其中的原理,這三點都會,吹吹牛逼應該不成問題。

有了這個思路之後,我覺得我應該可以寫出這個所謂的網易雲的進階課程~

當然,寫好這一篇文章,需要考慮初中級的同學可能對JNI、NDK不熟悉,cmake語法可能需要提及,流程必須清晰,必須提供可以執行的demo,對原理必須解釋清楚等等~

根據之前文章的風格,這篇文章不會單單介紹NKD高效載入gif,同時會把涉及到的相關知識點都總結總結,例如:so載入原理、native方法呼叫原理~

直接進入正文吧~

一、JNI和NDK基礎

這一塊基本沒太大難度的,只是大部分Android開發都是做應用層業務開發,很少涉及到動手寫c++程式碼,所以覺得JNI、NDK是很高階的技術,曾經的我也是這麼認為的。

是不是要會C++? 會肯定最好,不會的話,用到的時候學也可以。

1.1 JNI,本文只需要知道這些

Java呼叫c++的方法沒啥好說的,寫個native方法,然後通過快捷鍵在cpp中生成對應方法,傻瓜式操作就行。

而c++呼叫Java方法要了解一下,例如本文涉及到:Java層bitmap交給native層處理完,通過JNI回撥Java層的Runable的run方法

    // runnable 是Java傳過來的引數,型別是 jobject,
    //第一步獲取Class物件
    jclass runClass = env->GetObjectClass(runnable);
    //第二部獲取run方法的方法id,引數1是物件,引數2是方法名,引數3是方法簽名,這裡是void
    jmethodID runMethod = env->GetMethodID(runClass, "run", "()V");
    //通過 JNIEnv 的 CallVoidMethod函式,呼叫Java層的方法
    env->CallVoidMethod(runnable, runMethod);
複製程式碼

重點:

型別對應:Java 的Class 物件對應JNI 的jclass物件;
方法簽名:為了區別過載方法,可以理解為就是方法引數型別;
JNIEnv: 每個JNI方法的第一個引數,提供了操作Java層的一些方法,例如呼叫某個方法。

如果對JNI不熟悉的話,當然最好可以找一篇入門文章看一下,例如這一篇:
Android JNI(一)——NDK與JNI基礎

1.2 對於c++ ,讀懂本文需要知道這些

  • Java 是通過 . 來呼叫一個方法的,c++ 是通過 -> 來呼叫一個函式(方法)的;
  • c++ 有指標概念,指標箭頭指向的是一個記憶體地址,很多方法引數是指標型別,簡單理解就是址傳遞;
  • 指標如果指向的是一個陣列,那麼指標代表陣列首地址,訪問該指標就是訪問陣列的首地址。

二、giflib在安卓開發中的使用

giflib 是啥呢?
通過搜尋引擎,發現 giflib是android原始碼中的一個用C語言寫的載入GIF的庫

xref/external/giflib

giflib

把.c 和 .h 結尾的檔案下載下來,放到giflib資料夾中備用

giflib

先把giflib整合到Android Studio專案中先~

三、giflib 使用

上一步 giflib 已經是下載下來了

3.1 新建一個自帶JNI功能的專案

為什麼要新建,主要是考慮到部分同學對cmake 語法不清楚,所以通過新建專案來熟悉它,

Android Studio 新建 Native C++ 專案

create new project

專案名就叫 GifLoaderTest

新建帶有c++程式碼的專案

然後next,finish,

等待同步和編譯完成(可能會提示安裝cmake,按提示安裝即可),這個是可以執行成功的帶有JNI基本配置的專案。

3.2 引入giflib

看下 CMakeLists.txt 的需要配置什麼

CMakeLists.txt

cmake_minimum_required(VERSION 3.4.1)

# 1、指定原始檔目錄,把 /cpp/giflib 目錄的下的所有檔案賦值給 GIF_LIB
file(GLOB_RECURSE GIF_LIB ${CMAKE_SOURCE_DIR}/giflib/*.*)
# 同1,cpp 目錄下的檔案檔案放到 MAIN_SOURCE 裡,不用一個一個新增
file(GLOB_RECURSE MAIN_SOURCE ${CMAKE_SOURCE_DIR}/*.*)

add_library(
        # 要編譯的庫的名稱,可以改
        native-lib

        # SHARED 表示要編譯動態庫
        SHARED

        ${GIF_LIB}  # 2、把giflib原始檔新增到 native-lib 這個庫中去
        ${MAIN_SOURCE} # 同2,我們寫的cpp原始檔
)

target_link_libraries(
        native-lib

        # 3、給native-lib 新增一些依賴
        log
        jnigraphics
        android)
複製程式碼

有些同學沒接觸過NDK開發,或者接觸過,但是停留在基於Android.mk的構建方式,對cmake語法不太熟悉,沒關係,本文只需3個步驟把giflib整合進去:

  1. 將 giflib 複製到 cpp目錄下
  2. CMakeLists.txt 中將 giflib 目錄的檔案指定為原始檔,對應上面的註釋1和註釋2
  3. 新增其它依賴,對應註釋3

對於註釋3,log、jnigraphics、android,這幾個依賴在哪裡呢?答案是在NDK目錄下,例如我的mac是在這個目錄

/Users/{使用者名稱}/Library/Android/sdk/ndk-bundle/platforms/android-27/arch-arm/usr/lib

ndk-lib

NDK工具包提供了一些依賴庫給我們使用,log 是在控制檯列印日誌,jnigraphics 是影像操作相關。

3.3 定義 Java層 Gif管理類

定義一個gif管理類,叫 GifHandler

定義幾個native方法

    // 1.載入gif,返回 giflib中的 GifFileType物件地址,之後的操作都傳這個GifFileType的地址過去
    public static native long loadGif(String gifPath);

    // 2. 獲取gif寬高
    public static native int getWidth(long nativeGifFile);

    public static native int getHeight(long nativeGifFile);

    // 3.更新bitmap,更新成功就回撥runnable
    public static native int updateBitamap(long nativeGifFile, Bitmap bitmap, Runnable runnable);

    public static native void destroy(long nativeGifFile);
複製程式碼

3.4 native層生成對應方法

滑鼠放在native方法上面,按下快捷鍵生成native方法,mac 快捷鍵是 option + enter,windows應該是alt+enter

生成JNI方法

會自動在 native-lib.cpp 中生成native方法對應的JNI方法,就是這麼簡單

extern "C"
JNIEXPORT void JNICALL
Java_com_lanshifu_gifloadertest_GifHandler_destroy(JNIEnv *env, jclass clazz,
                                                   jlong native_gif_file) {
    // TODO: implement destroy()
}
複製程式碼

各個方法按順序講解:

3.4.1 loadGif

extern "C"
JNIEXPORT jlong JNICALL
Java_com_lanshifu_gifloadertest_GifHandler_loadGif(JNIEnv *env, jclass clazz, jstring path) {

    const char *filePath = env->GetStringUTFChars(path, 0);

    int err;
    // 1.呼叫原始碼api裡方法,開啟gif,返回GifFileType實體
    GifFileType *GifFile = DGifOpenFileName(filePath, &err);

    LOGD("filePath = %s", filePath);
    LOGD("loadGif,SWidth = %d", GifFile->SWidth);
    LOGD("loadGif,SHeight = %d", GifFile->SHeight);
    return (long long) GifFile;
}

複製程式碼

開啟一張gif,呼叫 DGifOpenFileName 函式,這個函式是giflib 這個庫裡邊的,會返回一個 GifFileType 型別的指標。GifFileType 結構體如下

typedef struct GifFileType {
    GifWord SWidth, SHeight;         /* Size of virtual canvas */
    GifWord SColorResolution;        /* How many colors can we generate? */
    GifWord SBackGroundColor;        /* Background color for virtual canvas */
    GifByteType AspectByte;	     /* Used to compute pixel aspect ratio */
    ColorMapObject *SColorMap;       /* Global colormap, NULL if nonexistent. */
    int ImageCount;                  /* Number of current image (both APIs) */
    GifImageDesc Image;              /* Current image (low-level API) */
    SavedImage *SavedImages;         /* Image sequence (high-level API) */
    int ExtensionBlockCount;         /* Count extensions past last image */
    ExtensionBlock *ExtensionBlocks; /* Extensions past last image */    
    int Error;			     /* Last error condition reported */
    void *UserData;                  /* hook to attach user data (TVT) */
    void *Private;                   /* Don't mess with this! */
} GifFileType;
複製程式碼

通過第一步我們我們可以獲取到gif的寬高 SWidth, SHeight,列印出來是

11-17 16:42:40.681 D/GIF_JNI: filePath = /storage/emulated/0/test.gif
11-17 16:42:40.682 D/GIF_JNI: loadGif,SWidth = 224
11-17 16:42:40.682 D/GIF_JNI: loadGif,SHeight = 400
複製程式碼

通過呼叫 DGifOpenFileName 開啟了gif檔案,用一個變數GifFile儲存起來,把地址返回給Java層,之後Java層可以通過傳這個地址過來獲取寬高,解析幀資料啥的

3.4.2 getWidth 和 getHeight

extern "C"
JNIEXPORT jint JNICALL
Java_com_lanshifu_gifloadertest_GifHandler_getWidth(JNIEnv *env, jclass clazz, jlong nativeGifFile) {
    // 獲取gif 寬
    GifFileType *GifFile = (GifFileType *) nativeGifFile;
    return GifFile->SWidth;
}

extern "C"
JNIEXPORT jint JNICALL
Java_com_lanshifu_gifloadertest_GifHandler_getHeight(JNIEnv *env, jclass clazz, jlong nativeGifFile) {
    // 獲取gif 高
    GifFileType *GifFile = (GifFileType *) nativeGifFile;
    return GifFile->SHeight;
}
複製程式碼

2.5.1 loadGif 的時候,將native層的GifFile地址返回到Java層,那麼獲取寬高只要把這個地址傳過來就行,強轉,然後呼叫寬高欄位SWidth/SHeight即可。

3.4.3 updateBitmap

這個方法是用來解析gif中每一幀圖片,並且將圖片資料更新到Bitmap上,回撥到Java層。

分幾個部分講解~

螢幕緩衝區

我們需要先建立一個螢幕緩衝區,所有的影像要繪製到螢幕緩衝區上面。 首先需要建立ScreenBuffer並且分配記憶體,設定背景顏色為gif背景顏色

    //GifRowType
    GifRowType *ScreenBuffer;
    //首先我們需要給螢幕分配記憶體:
    ScreenBuffer = (GifRowType *) malloc(gifHeight * sizeof(GifRowType));
    if (ScreenBuffer == NULL) {
        LOGE("ScreenBuffer malloc error");
        goto end;
    }

    //一行畫素佔用的記憶體大小
    size_t rowSize;
    rowSize = gifWidgh * sizeof(GifPixelType);
    ScreenBuffer[0] = (GifRowType) malloc(rowSize);

    /***** 給 ScreenBuffer 設定背景顏色為gif背景*/
    //設定第一行背景顏色
    for (int i = 0; i < gifWidgh; i++) {
        ScreenBuffer[0][i] = (GifPixelType) GifFile->SBackGroundColor;
    }
    //其它行拷貝第一行,每一行都要申請記憶體
    for (int i = 1; i < gifHeight; i++) {
        if ((ScreenBuffer[i] = (GifRowType) malloc(rowSize)) == NULL) {
            LOGE("Failed to allocate memory required, aborted.");
            goto end;
        }
        memcpy(ScreenBuffer[i], ScreenBuffer[0], rowSize);
    }

複製程式碼

ScreenBuffer 已經初始化好了,帶有gif背景顏色,接下來的操作就是將gif的每一幀資料畫到ScreenBuffer上面。

解碼gif資料

gif中的資料是有順序的塊,每一個塊代表的是當前幀的圖片資料,或者圖片資料的描述,例如延時多少毫秒這些額外資料。DGifGetRecordType函式用來獲取下一塊資料的型別,通過這個函式,就能把gif中每一幀圖片資料取出來

/***** 迴圈解析gif資料,並根據不同的型別進行不同的處理*/
    do {
        //DGifGetRecordType函式用來獲取下一塊資料的型別
        if (DGifGetRecordType(GifFile, &RecordType) == GIF_ERROR) {
            LOGE("DGifGetRecordType Error = %d", GifFile->Error);
            goto end;

        }

        switch (RecordType) {
            //1、如果是影像資料塊,需要繪製到 ScreenBuffer 中
            case IMAGE_DESC_RECORD_TYPE :
            ...
            break;
            
            
            //2、額外資訊塊,獲取幀之間間隔、透明顏色下標
            case EXTENSION_RECORD_TYPE:
            ...
            break;
            
     } while (RecordType != TERMINATE_RECORD_TYPE);

複製程式碼

主要是處理兩種型別的塊

註釋1,影像資料塊,這個影像資料需要繪製到前面提到的螢幕buffer上面,相應的程式碼如下:

            case IMAGE_DESC_RECORD_TYPE :
                // 1、DGifGetImageDesc 函式是 獲取gif的詳細資訊,例如 是否是隔行掃描,每個畫素點的顏色資訊等等
                if (DGifGetImageDesc(GifFile) == GIF_ERROR) {
                    LOGE("DGifGetImageDesc Error = %d", GifFile->Error);
                    return ERROR_CODE;
                }

                Row = GifFile->Image.Top; /* Image Position relative to Screen. */
                Col = GifFile->Image.Left;
                Width = GifFile->Image.Width;
                Height = GifFile->Image.Height;

                ...

                //隔行掃描
                if (GifFile->Image.Interlace) {
                    //隔行掃描,要執行掃描4次才完整繪製完
                    for (int i = 0; i < 4; i++)
                        for (int j = Row + InterlacedOffset[i];
                             j < Row + Height; j += InterlacedJumps[i]) {
                            // 2、從GifFile 中獲取一行資料,放到ScreenBuffer 中去
                            if (DGifGetLine(GifFile, &ScreenBuffer[j][Col], Width) == GIF_ERROR) {
                                LOGE("DGifGetLine Error = %d", GifFile->Error);
                                goto end;
                            }
                        }
                } else {
                    //沒有隔行掃描,順序一行一行來
                    for (int i = 0; i < Height; i++) {
                        if (DGifGetLine(GifFile, &ScreenBuffer[Row++][Col], Width) == GIF_ERROR) {
                            LOGE("DGifGetLine Error = %d", GifFile->Error);
                            goto end;
                        }
                    }
                }

                //掃描完成,ScreenBuffer 中每個畫素點是什麼顏色就確定好了,就差繪製到Bitmap上了
                ColorMap = (GifFile->Image.ColorMap
                            ? GifFile->Image.ColorMap
                            : GifFile->SColorMap);
                
                ...

                //delayTime 表示幀間隔時間,是從另一個資料塊計算出來的,睡眠一下再畫下一幀
                threadSleep.msleep(delayTime * 10);
                delayTime = 0;

                //3、將資料繪製到Bitmap上
                drawBitmap(env, bitmap, GifFile, ScreenBuffer, bitmapWidth, ColorMap,
                           GifFile->ImageCount - 1, pSavedImage,transparentColorIndex);

                //4、Bitmap繪製好了,回撥runnable的run方法,Java層重新整理ImageView即可看到新的一幀圖片
                env->CallVoidMethod(runnable, runMethod);
                break;


複製程式碼

解碼gif資料主要有4個步驟:
1.呼叫DGifGetImageDesc函式獲取gif的詳細資訊,例如是否是隔行掃描GifFile->Image.Interlace,顏色表 Image.ColorMap等等。
2.不管是不是隔行掃描,都會呼叫 DGifGetLine函式將GifFile中一行資料填充到ScreenBuffer中,這裡的隔行掃描需要遍歷4次才能掃描完一張圖片,掃描完成,ScreenBuffer 中每個畫素點是什麼顏色就確定好了,就差繪製到Bitmap上了。

關於隔行掃描逐行掃描,舉個例子,載入一個網路圖片,網路比較差,先看到圖片上半部分載入出來,下半部分還是黑的,這就是從上到下逐行掃描;如果是整個圖片出來了,但是很模糊,慢慢變清晰,這就屬於隔行掃描。

3.drawBitmap,Java層傳了一個Bitmap過來,但是是沒有任何圖片資料的,這裡要將ScreenBuffer中的畫素填充到Bitmap中去

void drawBitmap(JNIEnv *env, jobject bitmap, const GifFileType *GifFile, GifRowType *ScreenBuffer,
                int bitmapWidth, ColorMapObject *ColorMap, int imageIndex,
                SavedImage *pSavedImage,int transparentColorIndex) {

    //1、AndroidBitmap_lockPixels 鎖定Bitmap畫素以確保畫素的記憶體不會被移動
    void *pixels;
    AndroidBitmap_lockPixels(env, bitmap, &pixels);
    //拿到Bitmap畫素地址
    uint32_t *sPixels = (uint32_t *) pixels;

    int dataOffset = sizeof(int32_t) * DATA_OFFSET;
    int dH = bitmapWidth * GifFile->Image.Top;
    GifByteType colorIndex;
    //從左到右,一層一層設定Bitmap畫素
    for (int h = GifFile->Image.Top; h < GifFile->Image.Height; h++) {
        for (int w = GifFile->Image.Left; w < GifFile->Image.Width; w++) {
            //2、從 ScreenBuffer 中獲取畫素點下標,給一個畫素點設定ARGB
            colorIndex = (GifByteType) ScreenBuffer[h][w];

            //sPixels[dH + w] Bitmap畫素地址,通過遍歷給每個畫素點設定argb,Bitmap就有顏色了
            setColorARGB(&sPixels[dH + w],
                         imageIndex,
                         ColorMap,
                         colorIndex,
                         transparentColorIndex);

            //將顏色下標儲存起來,迴圈播放的時候需要知道這個下標
            pSavedImage->RasterBits[dataOffset++] = colorIndex;
        }

        //遍歷下一層
        dH += bitmapWidth;
    }

    LOGD("dH 結束 = %d ", dH);
    //對應解鎖畫素
    AndroidBitmap_unlockPixels(env, bitmap);
}
複製程式碼

native 層操作Bitmap畫素之前,要先呼叫 AndroidBitmap_lockPixels函式鎖住Bitmap畫素,並且拿到Bitmap畫素記憶體地址,遍歷之前已經填充好資料的ScreenBuffer,給Bitmap每個畫素設定正確的argb即可,對應下面的 setColorARGB方法,最後再呼叫 AndroidBitmap_unlockPixels解鎖Bitmap畫素,到此,Bitmap就已經載入了圖片資料。

setColorARGB 方法很簡單,就是給畫素賦值,不過要注意透明的畫素點,

uint32_t gifColorToColorARGB(const GifColorType &color) {
    return (uint32_t) (MAKE_COLOR_ABGR(color.Red, color.Green, color.Blue));
}

void setColorARGB(uint32_t *sPixels, int imageIndex, ColorMapObject *colorMap,
        GifByteType colorIndex,int transparentColorIndex) {

    if (imageIndex > 0 && colorIndex == transparentColorIndex) {
        return;
    }
    if (colorIndex != transparentColorIndex || transparentColorIndex == NO_TRANSPARENT_COLOR) {
        *sPixels = gifColorToColorARGB(colorMap->Colors[colorIndex]);
    } else {
        *sPixels = 0;
    }

}

複製程式碼

4.回撥到Java層,通知Java層重新整理UI

    //Runnable 的run方法id
    jclass runClass = env->GetObjectClass(runnable);
    jmethodID runMethod = env->GetMethodID(runClass, "run", "()V");
    //Bitmap繪製好了,回撥runnable的run方法,Java層重新整理ImageView即可看到新的一幀圖片
    env->CallVoidMethod(runnable, runMethod);
複製程式碼

使用giflib載入gif的幾個步驟簡單總結一下:

  1. 開啟gif檔案,拿到native層GifFile;
  2. 通過GifFile 可以獲取到gif寬高資訊;
  3. 取出gif每一幀圖片,進行解碼操作,大概就是將圖片畫素資訊讀取到緩衝區,然後將緩衝區中的資料填充到Bitmap中去,最後將解碼結果回撥到應用層,更新顯示圖片。

核心程式碼已經貼出,原始碼放github,沒有任何封裝,只適合學習參考~

github.com/lanshifu/Gi…

giflib 載入gif為什麼高效

從上面的流程來看,giflib載入gif是一幀一幀解析,然後回撥給Java層;對比Glide來說吧,Glide載入gif是怎麼處理的呢?

//com.bumptech.glide.gifdecoder.GifHeaderParser#readContents(int)

 /**
   * Main file parser. Reads GIF content blocks. Stops after reading maxFrames
   */
  private void readContents(int maxFrames) {
  
        // Read GIF file content blocks.
	    boolean done = false;
	    //1、遍歷所有幀
	    while (!(done || err() || header.frameCount > maxFrames)) {
	      int code = read();
	      switch (code) {
	        case IMAGE_SEPARATOR:
	          if (header.currentFrame == null) {
	            header.currentFrame = new GifFrame();
          }
          //2、讀取每一幀的Bitmap資料
          readBitmap();
          ...
  
  
  }
  
  /**
   * Reads next frame image.
   */
  private void readBitmap() {
    // (sub)image position & size.
    header.currentFrame.ix = readShort();
    header.currentFrame.iy = readShort();
    header.currentFrame.iw = readShort();
    header.currentFrame.ih = readShort();
    
    ...
    // 3、每一幀都快取到list
    header.frames.add(header.currentFrame);
    
   }
   
複製程式碼

從上面註釋123可以看出,Glide解析Gif是先一次性將所有圖片幀資訊解析出來快取到List中

而通過giflib的方式是解析一幀就更新顯示一幀。那麼從首次載入速度上對比,Glide肯定是要比giflib慢,特別是當gif中圖片幀比較多的時候。


看到這裡,是否會有一些疑問,例如:
我們編譯的so動態庫,JVM是如何載入這個so的?Java層呼叫一個native方法,最終是如何呼叫到so中對應的c++方法的?

四、System.loadLibrary(...) 原理

很多Android開發即使沒接觸過NDK,但是對於 System.loadLibrary("native-lib");應該不陌生,是否知道其中原理呢?

4.1 System#loadLibrary

public static void loadLibrary(String libname) {
        Runtime.getRuntime().loadLibrary0(Reflection.getCallerClass(), libname);
}
複製程式碼

4.2 Runtime#loadLibrary0

    private synchronized void loadLibrary0(ClassLoader loader, Class<?> callerClass, String libname) {
        if (libname.indexOf((int)File.separatorChar) != -1) {
            throw new UnsatisfiedLinkError(
    "Directory separator should not appear in library name: " + libname);
        }
        String libraryName = libname;
        if (loader != null && !(loader instanceof BootClassLoader)) {
            //1、先查詢so是否存在
            String filename = loader.findLibrary(libraryName);
            if (filename == null) {
                //so 不存在,拋異常
                throw new UnsatisfiedLinkError(loader + " couldn't find \"" +
                                               System.mapLibraryName(libraryName) + "\"");
            }
            // 2、so存在,nativeLoad方法載入so
            String error = nativeLoad(filename, loader);
            if (error != null) {
                throw new UnsatisfiedLinkError(error);
            }
            return;
        }

        ...
    }
複製程式碼

loadLibrary0 方法主要兩個步驟,1、查詢so是否存;2、呼叫native方法載入so

接下來就分析這兩個部分

先看註釋1,ClassLoader#findLibrary

2.1 查詢so是否存在:

ClassLoader#findLibrary
protected String findLibrary(String libname) {
        return null;
}
複製程式碼

ClassLoader是抽象類,實現類是BaseDexClassLoader

BaseDexClassLoader#findLibrary

原始碼傳送BaseDexClassLoader.java

public String findLibrary(String name) {
        return pathList.findLibrary(name);
}
複製程式碼

pathList 是 BaseDexClassLoader裡的一個DexPathList物件,裡面存放dex陣列,之前一篇關於啟動優化的文章講MultiDex原理的時候有分析過 findClass 方法,今天要分析的是 findLibrary 方法

DexPathList#findLibrary

DexPathList.java

public String findLibrary(String libraryName) {
        //1、名稱對映,相當於轉換,例如過濾一些空格啥的
        String fileName = System.mapLibraryName(libraryName);
        //2、應用所有so庫應該都放在nativeLibraryPathElements裡了,遍歷一下
        for (NativeLibraryElement element : nativeLibraryPathElements) {
            //3、從 NativeLibraryElement 裡找
            String path = element.findNativeLibrary(fileName);

            if (path != null) {
                return path;
            }
        }

        return null;
    }
複製程式碼

從nativeLibraryPathElements這個列表裡遍歷,
nativeLibraryPathElements 存放了依賴庫的所有目錄資訊,
NativeLibraryElement 是 DexPathList 的內部類,看下注釋3的 findNativeLibrary方法

NativeLibraryElement#findNativeLibrary
public String findNativeLibrary(String name) {

            // 這個方法裡會嘗試建立urlHandler,如果是zip檔案,urlHandler就不為空
            maybeInit();

            if (zipDir == null) {
                //通過名字,從so目錄建立這個檔案
                String entryPath = new File(path, name).getPath();
                //這個檔案可讀寫則直接返回路徑,也就是so全路徑
                if (IoUtils.canOpenReadOnly(entryPath)) {
                    return entryPath;
                }
            } else if (urlHandler != null) {
                //zip檔案也是可以作為動態庫的,把zip檔案路徑返回回去
                // Having a urlHandler means the element has a zip file.
                // In this case Android supports loading the library iff
                // it is stored in the zip uncompressed.
                String entryName = zipDir + '/' + name;
                if (urlHandler.isEntryStored(entryName)) {
                  return path.getPath() + zipSeparator + entryName;
                }
            }

            return null;
        }
複製程式碼

findNativeLibrary 方法最終是通過new File("xxx.so")來判斷so是否存在。

這裡也瞭解到,動態庫不僅指so庫,zip檔案也是可以作為動態庫的,至於什麼時候會用到zip檔案作為動態庫呢? 在 BaseDexClassLoader#addNativePath方法會新增動態庫搜尋路徑,只要傳了路徑帶有zip分隔符 "!/",就滿足zip庫的條件,例如data/data/1.zip!/data/data/,"!/" 前面是zip檔案路徑,後面是zip所在的目錄。然而BaseDexClassLoader#addNativePath 是一個隱藏方法,我們不能顯式呼叫。

分析到這裡,突然想到動態載入so,是否可以在so下載到指定目錄之後,反射呼叫BaseDexClassLoader的addNativePath 方法新增一個動態庫搜尋目錄?

當然,一般動態載入so並不需要這麼複雜~

動態載入so

動態載入so一般做法是:
將so下載下來,拷貝到私有目錄,/data/app/包名/lib/arm/ ,然後使用System.load("temp.so");去載入指定目錄下的so即可,例項如下:

String soPath = Environment.getExternalStorageDirectory().toString() + "/libtemp.so";
//模擬下載到指定目錄(assets目錄拷貝到sd卡)
File fromFile = new File(soPath);
if (!fromFile.exists()){
    boolean copyAssetFileToPath = FileUtil.copyAssetFileToPath(MainActivity.this, "libtemp.so", soPath);
    if (!copyAssetFileToPath){
        Toast.makeText(MainActivity.this,"拷貝到sdk卡失敗",Toast.LENGTH_SHORT).show();
        return;
    }
}

fromFile = new File(soPath);
if (!fromFile.exists()) {
    Toast.makeText(MainActivity.this,"so不存在",Toast.LENGTH_SHORT).show();
    return;
}

File libFile = MainActivity.this.getDir("libs", Context.MODE_PRIVATE);
String targetDir = libFile.getAbsolutePath() + "/libtemp.so";
//將下載下來的so拷貝到私有目錄:/data/user/0/包名/app_libs/
FileUtil.copyFile(fromFile, libFile);
//載入so,傳絕對路徑
System.load(targetDir);
Toast.makeText(MainActivity.this,"動態載入so成功",Toast.LENGTH_SHORT).show();
複製程式碼

假設so存在,則回到Runtime 類進入下一步,nativeLoad

2.2 Runtime#nativeLoad

native 方法,對應的JNI程式碼如下

libcore/ojluni/src/main/native/Runtime.c

JNIEXPORT jstring JNICALL
Runtime_nativeLoad(JNIEnv* env, jclass ignored, jstring javaFilename,
                   jobject javaLoader)
{
    return JVM_NativeLoad(env, javaFilename, javaLoader);
}
複製程式碼

直接呼叫JVM_NativeLoad , JVM_NativeLoad方法申明在jvm.h中,實現在OpenjdkJvm.cc中 art/openjdkjvm/OpenjdkJvm.cc

JNIEXPORT jstring JVM_NativeLoad(JNIEnv* env,
                                 jstring javaFilename,
                                 jobject javaLoader) {
  ScopedUtfChars filename(env, javaFilename);
  if (filename.c_str() == NULL) {
    return NULL;
  }

  std::string error_msg;
  {
    //主要是這兩句,JavaVMExt 的LoadNativeLibrary方法
    art::JavaVMExt* vm = art::Runtime::Current()->GetJavaVM();
    bool success = vm->LoadNativeLibrary(env,
                                         filename.c_str(),
                                         javaLoader,
                                         &error_msg);
    if (success) {
      return nullptr;
    }
  }

  // Don't let a pending exception from JNI_OnLoad cause a CheckJNI issue with NewStringUTF.
  env->ExceptionClear();
  return env->NewStringUTF(error_msg.c_str());
}
複製程式碼

呼叫JavaVMExt 的LoadNativeLibrary方法,原始碼 /art/runtime/java_vm_ext.cc

這個方法程式碼太多了,我只保留要分析的重點部分,加以註釋

bool JavaVMExt::LoadNativeLibrary(JNIEnv* env,
                                  const std::string& path,
                                  jobject class_loader,
                                  std::string* error_msg) {
...
  SharedLibrary* library;
  Thread* self = Thread::Current();
  {
    //1、讀快取,第一次載入成功會放快取
    library = libraries_->Get(path);
  }
...
  //有快取的情況下
  if (library != nullptr) {
    // 2、ClassLoader 不一致,一個so不能被兩個ClassLoader同時載入,返回失敗
    if (library->GetClassLoaderAllocator() != class_loader_allocator) {
     ...
      std::string old_class_loader = call_to_string(library->GetClassLoader());
      std::string new_class_loader = call_to_string(class_loader);
      StringAppendF(error_msg, "Shared library \"%s\" already opened by "
          "ClassLoader %p(%s); can't open in ClassLoader %p(%s)",
          path.c_str(),
          library->GetClassLoader(),
          old_class_loader.c_str(),
          class_loader,
          new_class_loader.c_str());
      LOG(WARNING) << *error_msg;
      return false;
    }
    //3、so 已經載入過,不需要再次載入了
    VLOG(jni) << "[Shared library \"" << path << "\" already loaded in "
              << " ClassLoader " << class_loader << "]";
              
    if (!library->CheckOnLoadResult()) {
      StringAppendF(error_msg, "JNI_OnLoad failed on a previous attempt "
          "to load \"%s\"", path.c_str());
      return false;
    }
    return true;
  }

  // Below we dlopen but there is no paired dlclose, this would be necessary if we supported
  // class unloading. Libraries will only be unloaded when the reference count (incremented by
  // dlopen) becomes zero from dlclose.
  ...
  //4、開啟共享庫,從註釋看,最終是會通過 dlopen 函式開啟so,返回一個handle,相當於so的記憶體地址吧
  void* handle = android::OpenNativeLibrary(env,
                                            runtime_->GetTargetSdkVersion(),
                                            path_str,
                                            class_loader,
                                            library_path.get(),
                                            &needs_native_bridge,
                                            error_msg);

  VLOG(jni) << "[Call to dlopen(\"" << path << "\", RTLD_NOW) returned " << handle << "]";

  if (handle == nullptr) {
    VLOG(jni) << "dlopen(\"" << path << "\", RTLD_NOW) failed: " << *error_msg;
    return false;
  }

  if (env->ExceptionCheck() == JNI_TRUE) {
    LOG(ERROR) << "Unexpected exception:";
    env->ExceptionDescribe();
    env->ExceptionClear();
  }
  // Create a new entry.
  // TODO: move the locking (and more of this logic) into Libraries.
  bool created_library = false;
  {
    // Create SharedLibrary ahead of taking the libraries lock to maintain lock ordering.
    std::unique_ptr<SharedLibrary> new_library(
        //5、開啟so成功之後,建立一個 SharedLibrary,handle作為引數之一
        new SharedLibrary(env,
                          self,
                          path,
                          handle,
                          needs_native_bridge,
                          class_loader,
                          class_loader_allocator));
    //6、加到快取中
    library = libraries_->Get(path);
    if (library == nullptr) {  // We won race to get libraries_lock.
      library = new_library.release();
      libraries_->Put(path, library);
      created_library = true;
    }
  }
  ...
  bool was_successful = false;
  //7、找找看是否重寫了 JNI_OnLoad 方法
  void* sym = library->FindSymbol("JNI_OnLoad", nullptr);
  //8、沒有重寫,成功標誌為true,就結束了
  if (sym == nullptr) {
    VLOG(jni) << "[No JNI_OnLoad found in \"" << path << "\"]";
    was_successful = true;
  } else {
    // Call JNI_OnLoad.  We have to override the current class
    // 9、有重寫 JNI_OnLoad 函式,呼叫它
    ...
    //10、呼叫 JNI_OnLoad 函式,返回JNI版本號
    int version = (*jni_on_load)(this, nullptr);
    ...
    // 11、JNI_OnLoad 方法返回JNI_ERR,或者返回的版本號不支援,都會導致so載入失敗
    if (version == JNI_ERR) {
      StringAppendF(error_msg, "JNI_ERR returned from JNI_OnLoad in \"%s\"", path.c_str());
    } else if (JavaVMExt::IsBadJniVersion(version)) {
      StringAppendF(error_msg, "Bad JNI version returned from JNI_OnLoad in \"%s\": %d",
                    path.c_str(), version);
      //省略註釋
    } else {
      was_successful = true;
    }
    VLOG(jni) << "[Returned " << (was_successful ? "successfully" : "failure")
              << " from JNI_OnLoad in \"" << path << "\"]";
  }

  library->SetResult(was_successful);
  return was_successful;
}

複製程式碼

整理一下 LoadNativeLibrary 的邏輯:
註釋1、2、3,讀快取,如果之前已經載入過so,那麼判斷ClassLoader是不是一致,不一致就返回失敗,一致就返回成功,不允許一個so被兩個ClassLoader同時載入。
註釋4、5、6:通過dlopen函式開啟so,成功則建立對應的SharedLibrary 物件,並且加到快取中。
註釋7、8、9、10:判斷so中是否有 JNI_OnLoad 函式,如果沒有的話就到此結束,如果有的話,要呼叫這個函式。
註釋11: JNI_OnLoad 函式會返回一個JNI版本號,如果返回的是JNI_ERR或者返回的版本號不支援,會報錯。

JNI_OnLoad 返回值一般是這樣寫的,要判斷JVM支援哪個版本

JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {
    LOGD("JNI_OnLoad");
    JNIEnv *env;
    if (vm->GetEnv((void **) &env, JNI_VERSION_1_6) == JNI_OK) {
        return JNI_VERSION_1_6;
    } else if (vm->GetEnv((void **) &env, JNI_VERSION_1_4) == JNI_OK) {
        return JNI_VERSION_1_4;
    }
    return JNI_ERR;
}
複製程式碼

到此,System.LoadLibrary(...) 的原始碼分析就結束了(基於9.0原始碼分析,其它版本可能會有些差異,但原理是一樣的)
簡單說就是兩個步驟:

  1. 檢測so檔案是否存在;
  2. 呼叫dlopen這個系統函式去開啟so,成功則會建立 SharedLibrary 物件並且快取起來,後面呼叫native方法按道理應該是從這個SharedLibrary中查詢是否有某個JNI方法,進行呼叫。

五、native方法的呼叫原理

當簡歷上寫熟悉JNI、NDK開發的時候,有很大的概率面試官會問這個問題,你熟悉JNI,到底熟悉到哪個程度,native方法呼叫原理知道不,如何找到對應的JNI方法的呢?

這個部分能講清楚的文章很少了,我只能花點時間總結一下了~

5.1 native方法跟普通方法在位元組碼中的區別

首先做一個測試,寫一個JniTest 的java類,看下native方法在位元組碼裡面跟普通方法的區別

public class JniTest {

    static {
        System.loadLibrary("temp");
    }

    public native int nativeAdd(int x, int y);

    public int add(int x, int y) {
        return x + y;
    }

    public static void main(String[] args) {
        JniTest jniTest = new JniTest();
        jniTest.nativeAdd(2012, 3);
        jniTest.add(2012, 3);
    }
}

複製程式碼

編譯成 JniTest.class

javac JniTest.java

檢視位元組碼

javap -c JniTest.class

位元組碼

通過檢視位元組碼可以看到:

  1. 普通方法有Code程式碼塊,native方法沒有方法體,所以也就沒有對應的Code程式碼塊;
  2. 不管是普通方法還是native方法的呼叫,都是通過 invokevirtual 指令。

5.2 dvmInvokeMethod

不管是呼叫普通方法還是native方法,針對dalvik虛擬機器來說,都會呼叫 dvmInvokeMethod方法,

接下來就分析dalvik虛擬機器是怎麼呼叫一個native方法的,

由於4.4之後dalvik虛擬機器 被 art虛擬機器代替,所以這一節基於4.4原始碼進行分析~

手擼網易雲進階課程-效能優化之NDK高效載入GIF

dvmInvokeMethod方法的原始碼位於 /dalvik/vm/interp/Stack.cpp


Object* dvmInvokeMethod(Object* obj, const Method* method,
    ArrayObject* argList, ArrayObject* params, ClassObject* returnType,
    bool noAccessCheck)
{
    ...省略其它程式碼
    // 1、判斷是native方法,則呼叫 *method->nativeFunc 方法
    if (dvmIsNativeMethod(method)) {
        TRACE_METHOD_ENTER(self, method);
        /*
         * Because we leave no space for local variables, "curFrame" points
         * directly at the method arguments.
         */
         // 2、呼叫 nativeFunc
        (*method->nativeFunc)((u4*)self->interpSave.curFrame, &retval,
                              method, self);
        TRACE_METHOD_EXIT(self, method);
    } else {
        dvmInterpret(self, method, &retval);
    }

    ...省略其它程式碼

複製程式碼

我們只看呼叫native方法的邏輯,如果判斷是一個native方法,則呼叫Method物件的 nativeFunc 方法,那麼理論上我們只要看 nativeFunc 是在哪裡賦值的,就可以追蹤到native方法跟JNI方法的對應關係~

5.3 nativeFunc函式的賦值

JNI方法的註冊方式有兩種,預設的和動態註冊,

預設情況下,JVM載入一個類的時候,會呼叫native層Class物件的loadClassFromDex 方法,而這個方法內部會呼叫loadMethodFromDex方法去載入Class物件內部的方法,當遇到native方法,就會對nativeFunc函式進行賦值,可以在原始碼中得到驗證

dalvik/vm/oo/Class.cpp

//呼叫鏈
dvmDefineClass
	findClassNoInit
  		loadClassFromDex
    	   loadClassFromDex0
     		  loadMethodFromDex

static void loadMethodFromDex(ClassObject* clazz, const DexMethod* pDexMethod,
    Method* meth)
{
    ... 

    // 1、native 方法和抽象方法是沒有 Code程式碼塊的
    pDexCode = dexGetCode(pDexFile, pDexMethod);
    if (pDexCode != NULL) {
        /* integer constants, copy over for faster access */
        meth->registersSize = pDexCode->registersSize;
        meth->insSize = pDexCode->insSize;
        meth->outsSize = pDexCode->outsSize;

        /* pointer to code area */
        meth->insns = pDexCode->insns;
    } else {
        /*
         * We don't have a DexCode block, but we still want to know how
         * much space is needed for the arguments (so we don't have to
         * compute it later).  We also take this opportunity to compute
         * JNI argument info.
         *
         * We do this for abstract methods as well, because we want to
         * be able to substitute our exception-throwing "stub" in.
         */
        int argsSize = dvmComputeMethodArgsSize(meth);
        if (!dvmIsStaticMethod(meth))
            argsSize++;
        meth->registersSize = meth->insSize = argsSize;
        assert(meth->outsSize == 0);
        assert(meth->insns == NULL);

        // 2.如果是native方法,nativeFunc 指向 dvmResolveNativeMethod 方法
        if (dvmIsNativeMethod(meth)) {
            meth->nativeFunc = dvmResolveNativeMethod;
            meth->jniArgInfo = computeJniArgInfo(&meth->prototype);
        }
    }
}
複製程式碼

註釋1:讀取當前方法的Code程式碼塊,這個在前面通過javap 分析位元組碼的時候有說到,普通方法有Code 程式碼塊,native方法和抽象方法是沒有的。

註釋2:判斷如果是native方法,則將nativeFunc 指向 dvmResolveNativeMethod 這個方法

5.4 dvmResolveNativeMethod

這個方法定義在Native.cpp這個類中,

dalvik/vm/Native.cpp


void dvmResolveNativeMethod(const u4* args, JValue* pResult,
    const Method* method, Thread* self)
{
    ClassObject* clazz = method->clazz;

    /*
     * If this is a static method, it could be called before the class
     * has been initialized.
     */
     // 1、對於靜態方法,要先確保該Class已經初始化
    if (dvmIsStaticMethod(method)) {
        if (!dvmIsClassInitialized(clazz) && !dvmInitClass(clazz)) {
            assert(dvmCheckException(dvmThreadSelf()));
            return;
        }
    } else {
        assert(dvmIsClassInitialized(clazz) ||
               dvmIsClassInitializing(clazz));
    }

    /* start with our internal-native methods */
    //2、從內部的本地方法表查詢
    DalvikNativeFunc infunc = dvmLookupInternalNativeMethod(method);
    if (infunc != NULL) {
        /* resolution always gets the same answer, so no race here */
        IF_LOGVV() {
            char* desc = dexProtoCopyMethodDescriptor(&method->prototype);
            LOGVV("+++ resolved native %s.%s %s, invoking",
                clazz->descriptor, method->name, desc);
            free(desc);
        }
        if (dvmIsSynchronizedMethod(method)) {
            ALOGE("ERROR: internal-native can't be declared 'synchronized'");
            ALOGE("Failing on %s.%s", method->clazz->descriptor, method->name);
            dvmAbort();     // harsh, but this is VM-internal problem
        }
        //找到就呼叫
        DalvikBridgeFunc dfunc = (DalvikBridgeFunc) infunc;
        dvmSetNativeFunc((Method*) method, dfunc, NULL);
        dfunc(args, pResult, method, self);
        return;
    }

    /* now scan any DLLs we have loaded for JNI signatures */
    //3、 從動態連結庫中查詢
    void* func = lookupSharedLibMethod(method);
    if (func != NULL) {
        /* found it, point it at the JNI bridge and then call it */
        //4、找到就呼叫
        dvmUseJNIBridge((Method*) method, func);
        (*method->nativeFunc)(args, pResult, method, self);
        return;
    }

    IF_ALOGW() {
        char* desc = dexProtoCopyMethodDescriptor(&method->prototype);
        ALOGW("No implementation found for native %s.%s:%s",
            clazz->descriptor, method->name, desc);
        free(desc);
    }

    dvmThrowUnsatisfiedLinkError("Native method not found", method);
}
複製程式碼

註釋1:如果該native方法是靜態方法,要確保對應的Class已經初始化;
註釋2:從內部的本地方法表中查詢,dvmLookupInternalNativeMethod
註釋3:從動態連結庫中查詢,lookupSharedLibMethod,要分析的重點;
註釋4:不管是從內部本地方法表還是從動態庫中找到native方法對應的JNI方法,都會通過JNI橋建立連結關係,然後呼叫這個JNI方法。

這個呼叫分析完了,那麼如何找到native方法對應的JNI方法呢?

分兩種情況

5.4.1 dvmLookupInternalNativeMethod

先看下 dvmLookupInternalNativeMethod 方法, 原始碼在 dalvik/vm/native/InternalNative.cpp


//1、gDvmNativeMethodSet 的定義
static DalvikNativeClass gDvmNativeMethodSet[] = {
    { "Ljava/lang/Object;",               dvm_java_lang_Object, 0 },
    { "Ljava/lang/Class;",                dvm_java_lang_Class, 0 },
    { "Ljava/lang/Double;",               dvm_java_lang_Double, 0 },
    { "Ljava/lang/Float;",                dvm_java_lang_Float, 0 },
    { "Ljava/lang/Math;",                 dvm_java_lang_Math, 0 },
    { "Ljava/lang/Runtime;",              dvm_java_lang_Runtime, 0 },
    { "Ljava/lang/String;",               dvm_java_lang_String, 0 },
    { "Ljava/lang/System;",               dvm_java_lang_System, 0 },
    { "Ljava/lang/Throwable;",            dvm_java_lang_Throwable, 0 },
    ...
};


DalvikNativeFunc dvmLookupInternalNativeMethod(const Method* method)
{
    const char* classDescriptor = method->clazz->descriptor;
    const DalvikNativeClass* pClass;
    u4 hash;

    hash = dvmComputeUtf8Hash(classDescriptor);
    // 2、pClass 指標指向 gDvmNativeMethodSet 這個陣列,
    pClass = gDvmNativeMethodSet;
    while (true) {
        if (pClass->classDescriptor == NULL)
            break;
        if (pClass->classDescriptorHash == hash &&
            strcmp(pClass->classDescriptor, classDescriptor) == 0)
        {
            // 註釋5在這裡、DalvikNativeMethod 物件指向的是 DalvikNativeClass的methodInfo欄位
            const DalvikNativeMethod* pMeth = pClass->methodInfo;
            while (true) {
                if (pMeth->name == NULL)
                    break;
                // 3、匹配到對應方法,返回
                if (dvmCompareNameDescriptorAndMethod(pMeth->name,
                    pMeth->signature, method) == 0)
                {
                    /* match */
                    //ALOGV("+++  match on %s.%s %s at %p",
                    //    className, methodName, methodSignature, pMeth->fnPtr);
                    //4、返回這個方法
                    return pMeth->fnPtr;
                }

                pMeth++;
            }
        }
		 
        // 5、pClass 是一個指標,指向的是gDvmNativeMethodSet 這個陣列首地址,pClass++表示陣列遍歷
        pClass++;
    }

    return NULL;
}

複製程式碼

從內部的本地方法表查詢,這裡有幾個知識點:
註釋1:內部本地方法表gDvmNativeMethodSet(陣列)定義了很多Java類對應的native類;

註釋2:pClass是DalvikNativeClass型別的指標,指向陣列,pClass就代表陣列的首地址,
pClass = gDvmNativeMethodSet等同於 pClass = gDvmNativeMethodSet[0]
註釋5 的第一次pClass++,結果是pClass = gDvmNativeMethodSet[1],也就是陣列的遍歷;

註釋3:通過對比方法名、描述符,從內部本地方法表找到native方法的實現了;
註釋4:返回 pMeth->fnPtr,也就是返回DalvikNativeMethod 物件的fnPtr函式

臨時增加了註釋5,DalvikNativeMethod 物件的fnPtr函式其實是 DalvikNativeClass的methodInfo欄位,直接看 DalvikNativeClass是怎麼給methodInfo 賦值的,

這裡有必要看一下 DalvikNativeClass 的定義

原始碼位於 dalvik/vm/Native.h

struct DalvikNativeClass {
    const char* classDescriptor;
    const DalvikNativeMethod* methodInfo;
    u4          classDescriptorHash;          /* initialized at runtime */
};
複製程式碼

再回頭看陣列的初始化

//1、gDvmNativeMethodSet 的定義
static DalvikNativeClass gDvmNativeMethodSet[] = {
    ...
    //以熟悉的String舉例,這裡第二個引數 dvm_java_lang_String 就是 DalvikNativeMethod 型別
    { "Ljava/lang/String;",               dvm_java_lang_String, 0 },
    ...

複製程式碼

對於 String類來說,DalvikNativeMethod 指向 dvm_java_lang_String,看原始碼

dalvik/vm/native/java_lang_String.cpp

const DalvikNativeMethod dvm_java_lang_String[] = {
    { "charAt",      "(I)C",                  String_charAt },
    { "compareTo",   "(Ljava/lang/String;)I", String_compareTo },
    { "equals",      "(Ljava/lang/Object;)Z", String_equals },
    { "fastIndexOf", "(II)I",                 String_fastIndexOf },
    { "intern",      "()Ljava/lang/String;",  String_intern },
    { "isEmpty",     "()Z",                   String_isEmpty },
    { "length",      "()I",                   String_length },
    { NULL, NULL, NULL },
};
複製程式碼

哦,明白了,dvm_java_lang_String 又是一個陣列,DalvikNativeMethod 型別,到這裡其實已經不用分析 DalvikNativeMethod 的結構了,每一行三個引數對應一個DalvikNativeMethod物件,第一個引數是Java層String的方法名,第二個引數是方法簽名,第三個引數是native層String對應的函式名。
舉個例子,Java層String類的 charAt 方法是一個native方法,對應的JNI方法是 java_lang_String.cpp 的 String_charAt 函式。

分析內部本地方法表,有種抓迷藏的感覺,好像有點跑偏,我們主要還是要分析如何從動態庫so中找到native方法對應的JNI方法,繼續吧~

5.4.2 lookupSharedLibMethod 方法

//dalvik/vm/Native.cpp
static void* lookupSharedLibMethod(const Method* method)
{
    if (gDvm.nativeLibs == NULL) {
        ALOGE("Unexpected init state: nativeLibs not ready");
        dvmAbort();
    }
    //前面判空不管,主要看這個方法
    return (void*) dvmHashForeach(gDvm.nativeLibs, findMethodInLib,
        (void*) method);
}
複製程式碼

從動態庫總查詢,同樣需要遍歷,看下 dvmHashForeach 這個方法,第一個引數是動態庫的集合,第二個引數是一個查詢的方法,第三個引數是我們的native方法,所以,意思就是遍歷動態庫集合,呼叫 findMethodInLib 方法,傳兩個引數,一個是動態庫,一個是native方法名。

所以,這裡只要關注兩個點:

  1. gDvm.nativeLibs 是哪裡賦值的,應該跟System.loadLibrary有關;
  2. findMethodInLib 方法,如何從一個動態庫中找到某個native方法的實現方法。

gDvm.nativeLibs 是一個動態庫集合,先假設 gDvm.nativeLibs 就是 System.loadLibrary 新增進去的,直接看 findMethodInLib 方法

// dalvik/vm/Native.cpp

static int findMethodInLib(void* vlib, void* vmethod)
{
	// 1、動態庫集合裡面放的就是 SharedLib 物件
    const SharedLib* pLib = (const SharedLib*) vlib;
    const Method* meth = (const Method*) vmethod;
    char* preMangleCM = NULL;
    char* mangleCM = NULL;
    char* mangleSig = NULL;
    char* mangleCMSig = NULL;
    void* func = NULL;
    int len;

    ...
    
    /*
     * First, we try it without the signature.
     */
    // 2、通過native方法名,獲取對應的JNI方法名
    preMangleCM =
        createJniNameString(meth->clazz->descriptor, meth->name, &len);
    if (preMangleCM == NULL)
        goto bail;

	//3、對JNI方法進行處理,轉換成so中對應的格式
    mangleCM = mangleString(preMangleCM, len);
    if (mangleCM == NULL)
        goto bail;

    ALOGV("+++ calling dlsym(%s)", mangleCM);
    // 4、通過 dlsym 這個系統函式,查詢so中的方法,找到就返回一個指標
    func = dlsym(pLib->handle, mangleCM);
    ...

bail:
    free(preMangleCM);
    free(mangleCM);
    free(mangleSig);
    free(mangleCMSig);
    return (int) func;
}
複製程式碼

這個方法分析完就到尾聲了,好激動

註釋1:從動態庫集合裡遍歷,拿到的是 SharedLib 物件(動態庫的一個封裝物件,裡面有動態庫的控制程式碼)
註釋2:通過native方法名,獲取對應的JNI方法名,createJniNameString 方法看一下


static char* createJniNameString(const char* classDescriptor,
    const char* methodName, int* pLen)
{
    char* result;
    size_t descriptorLength = strlen(classDescriptor);

	// JNI方法名有三個部分組成,先計算需要的記憶體大小,申請記憶體
    *pLen = 4 + descriptorLength + strlen(methodName);
    result = (char*)malloc(*pLen +1);
    
    /*
     * Add one to classDescriptor to skip the "L", and then replace
     * the final ";" with a "/" after the sprintf() call.
     */
    //  sprintf 函式,將後面的字串賦值給result,
    sprintf(result, "Java/%s%s", classDescriptor + 1, methodName);
    result[5 + (descriptorLength - 2)] = '/';

    return result;
}
複製程式碼

這裡只是獲取native方法對應的JNI方法名,比如:

com.lanshifu.gifloadertest.GifHandler 這個類的一個native方法方法 ,對應的JNI方法如下:

native方法 對應的JNI方法
public static native int getWidth(long nativeGifFile) Java_com_lanshifu_gifloadertest_GifHandler_getWidth(...)

這個JNI方法我們是可以通過Android Studio 快捷鍵自動生成,為什麼生成這樣格式,原理就在上面了~

回到上面註釋3:將獲取的JNI方法名轉換一下,轉換成動態庫中的編碼格式;
註釋4:通過dlsym 函式查詢動態庫中的方法並返回,結束~


由於分析System.loadLibrary的時候是基於9.0 原始碼, 而分析 dalvik 虛擬機器的方法呼叫流程,是基於4.4 的原始碼(4.4之後dalvik被art代替),導致上面的動態庫集合好像跟System.loadLibrary有點脫鉤,但其實動態庫集合中的資料,就是在System.loadLibrary 的時候新增進去的,大家可以基於 4.4 原始碼分析 System.loadLibrary,肯定是這樣的, 我就不再重複分析了~

native 方法呼叫原理小結

native方法的呼叫原理小結如下:

  1. 通過javap 命令檢視位元組碼發現普通方法和native方法的呼叫都是通過invokevirtual指令;
  2. dalvik 虛擬機器呼叫一個方法的時候,如果判斷是native方法,則會通過Method類的nativeFunc函式進行呼叫;
  3. 就JNI預設的靜態註冊流程分析,當虛擬機器載入一個類的時候,會去載入裡面的方法,當遇到native方法,則會給nativeFunc賦值(沒有呼叫),指向一個dvmResolveNativeMethod 方法;
  4. dvmResolveNativeMethod 方法,會先從內部的本地方法表查詢是否有對應的JNI方法,找到就通過JNI橋建立關係並呼叫;如果沒找到,就遍歷動態庫(so)查詢,先組裝native方法名對應的JNI方法名,然後轉換成動態庫的編碼格式,再通過dlsym函式查詢動態庫中是否有該方法。

如果是在面試中問到native方法呼叫原理,那麼最好能先說System.loadLibrary原理:找到so,再通過dlopen函式去開啟so,返回一個控制程式碼,儲存到動態庫集合中;native 方法的呼叫會先從內部本地方法表查詢,找不到再遍歷這個動態庫集合,通過dlsym函式查詢動態庫中是否有對應的JNI方法,有的話就將native方法跟JNI方法建立連結並呼叫。

全文總結

這篇文章從網易雲的進階課程廣告入手:

  1. 通過程式碼示例介紹瞭如何使用giflib實現NDK高效載入gif;
  2. 介紹System.loadLibrary的原理,兩個步驟,第一步是先查詢動態庫檔案是否存在,第二步是通過dlopen函式開啟動態庫,返回handle控制程式碼,新增到動態庫集合中,還會呼叫JNI_OnLoad 函式(如果有的話);引申了動態載入so的方式。
  3. 介紹native方法呼叫的底層原理,由於Android 4.4 之後採用art虛擬機器代替dalvik虛擬機器,所以基於4.4原始碼基礎上分析dalvik虛擬機器呼叫一個native方法的流程。

相關參考連結:

Android 使用系統庫giflib實現高效gif動畫載入
android app動圖優化:原始碼giflib載入gif動圖,效能秒殺glide
影像解碼之三——giflib解碼gif圖片

結尾

由於換了新工作,這篇文章應該是2019年最後一篇啦~

接下來我的計劃是系統學習下Flutter,掘金沒有像簡書那樣的歸檔功能,所以Flutter文章會先在簡書釋出。

面試官系列文章暫時告一段落吧,下一篇文章是什麼內容還不知道呢,當我發現一個知識點是自己不會的,可能就會花時間去深入研究它,作為下一篇文章的主題~

大家有任何問題歡迎在評論區留言~


我在掘金髮布的其它5篇文章:
面試官:簡歷上最好不要寫Glide,不是問原始碼那麼簡單
總結UI原理和高階的UI優化方式
面試官:說說多執行緒併發問題
面試官又來了:你的app卡頓過嗎?
面試官:今日頭條啟動很快,你覺得可能是做了哪些優化?

相關文章