Android本地搜尋最佳化

雲音樂技術團隊發表於2023-05-11
本文作者:lsnbing

引言

在本文中,我們將透過 Android 本地搜尋業務介紹如何使用 JavaScriptCore(以下簡稱 JSC)和Java Native Interface(以下簡稱 JNI)相關技術來實現搜尋效率提升。

背景

本地搜尋業務內部使用動態下發 JS 程式碼實現一些業務邏輯,使用者觸發搜尋到最終展示資料耗時久,體驗很差 ( 8000 首歌曲的處理量大概在 7 秒左右),分析:

  • 本地的 DB 和資料處理耗時佔 50%
  • JS 引擎的資料傳輸上佔 50%

DB 和資料處理不做討論,這裡主要解決 JS 引擎的資料傳輸問題

基於現有方案的分析:
效能瓶頸

可以發現 Native 在和 JVM 傳輸次數過多,且跨語言的資料傳輸序列化耗時

方案

結合現有業務特點:

  • 演算法是變化的、動態下發的,所以程式碼由 JS 實現,故需要在 JS 引擎中執行
  • Java 使用 JSC 需要藉助 JNI,並加入一些邏輯處理
  • JNI 需要向 JS 引擎輸入資料,同時需要獲取執行得結果

得出如下流程圖

流程

如何實現?

  1. 準備好 JavaScriptCore 庫,這裡複用 ReactNative 中的 so 庫
  2. C++呼叫 JavaScriptCore 庫,實現部分邏輯,輸出業務層 a.so 庫
  3. 上層使用 a.so 對庫進行呼叫

前置知識

方案實現需要了解 JavaScriptCore 和 JNI 的相關知識,下面分別介紹

JavaScriptCore 簡介

JavaScriptCore 是一個開源的 JavaScript 引擎,可以用來解析和執行 JavaScript 程式碼,類似的還有 V8、Hermes 等。

JSAPI 是 JavaScriptCore 的 C++介面,它提供了一組 C++類和函式,可以用於將 JavaScript 嵌入到 C++程式中。JSAPI 提供了以下功能:

  • 建立和管理 JavaScript 物件和值
  • 執行 JavaScript 程式碼
  • 訪問 JavaScript 物件的屬性和方法
  • 註冊 JavaScript 函式
  • 處理 JavaScript 異常
  • 進行垃圾回收
JavaScriptCore 型別
  • JSC::JSObject:表示一個 JavaScript 物件。
  • JSC::JSValue:表示一個 JavaScript 值。
  • JSC::JSGlobalObject:表示 JavaScript 物件的全域性物件。
  • JSC::JSGlobalObjectFunctions:包含一組函式,用於實現 JSAPI 的功能,如執行 JavaScript 程式碼、訪問 JavaScript 物件的屬性和方法等。

在 JSAPI 中,JavaScript 物件和值透過 JSC::JSObject 和 JSC::JSValue 類進行表示。
JSC::JSObject 表示一個 JavaScript 物件,它可以包含一組屬性和方法;
JSC::JSValue 表示一個 JavaScript 值,它可以是一個物件、一個數值、一個字串或一個布林值等。

JSAPI 提供了 JSC::JSGlobalObject 類作為 JavaScript 物件的全域性物件,所有的 JavaScript 物件都是從該全域性物件繼承而來。

API 介紹

JSContextGroupCreate

JSContextGroupRef 是一個包含多個 JSContext 的分組,它們可以共享記憶體池和垃圾回收器,從而提高 JavaScript 執行效率和減少記憶體佔用。

JSGlobalContextCreateInGroup

JSGlobalContextCreateInGroup 函式會建立一個 JSGlobalContextRef 型別的物件,表示一個 JavaScript 上下文物件,該物件包含一個虛擬機器物件、記憶體池、全域性物件等成員變數。該函式返回值為建立的 JSGlobalContextRef 型別的物件,表示 JavaScript 上下文物件。
由於不同的 JSGlobalContextRef 物件擁有不同的全域性物件,因此它們之間不會相互影響。在不同的 JSGlobalContextRef 物件中建立的 JavaScript 物件、函式、變數等,都是相互獨立的,它們之間不會共享資料或狀態。

JSEvaluateScript

用於執行一段 JavaScript 程式碼。其內部工作機制主要包括以下幾個步驟:

  • 將 JavaScript 程式碼轉換為抽象語法樹(AST)
    在執行 JavaScript 程式碼之前,JavaScriptCore 需要將其轉換為抽象語法樹(AST),這樣才能對其進行解析和執行。JavaScriptCore 的 AST 解析器可以將 JavaScript 程式碼轉換為一棵 AST 樹,其中每個節點代表了一條 JavaScript 語句或表示式。
  • 解析和執行 AST 樹
    一旦生成了 AST 樹,JavaScriptCore 就可以對其進行解析和執行了。在解析過程中,JavaScriptCore 會對 AST 樹進行遍歷,同時將其中的變數、函式等識別符號與對應的值進行繫結。在執行過程中,JavaScriptCore 會按照 AST 樹的結構逐步執行其中的語句和表示式,同時根據需要呼叫相應的函式和方法。
  • 將執行結果返回給呼叫方
    一旦 JavaScript 程式碼執行完畢,JavaScriptCore 就會將其執行結果返回給呼叫方。這個結果可以是任何 JavaScript 值,包括數字、字串、物件、函式等。呼叫方可以根據需要對這個結果進行處理和使用。

JSEvaluateScript 是一個同步函式,即在執行完 JavaScript 程式碼之前,它會一直等待,直到 JavaScript 程式碼執行完畢並返回結果。這意味著,在執行長時間執行的 JavaScript 程式碼時,JSEvaluateScript 函式可能會阻塞程式的執行。

我們可以透過執行緒來對 JS 程式碼的非同步化(以下省略一些判空邏輯)

void completionHandler(JSContextRef ctx, JSValueRef value, void *userData) {
    JSValueRef *result = (JSValueRef *)userData;
    *result = value;
}

void evaluateAsync(JSContextRef ctx, const char* script, JSObjectRef thisObject, JSValueRef* exception, JSAsyncEvaluateCallback completionHandler) {
    // 非同步執行
    std::thread([ctx, script, thisObject, exception, completionHandler]() {
        // 執行指令碼
        JSStringRef scriptStr = JSStringCreateWithUTF8CString(script);
        JSValueRef result = JSEvaluateScript(ctx, scriptStr, thisObject, nullptr, 0, exception);
        JSStringRelease(scriptStr);

        // 回撥 completionHandler
        completionHandler(result, exception);
    }).detach();
}

此外還應關注註冊到 JS 環境中的 C 介面回撥,這裡因儘快返回,如果有耗時任務,則需要將結果透過非同步去通知 JS 層,否則會阻塞 JS 執行緒(也就是呼叫該函式的執行緒)。

關鍵程式碼示例

下面實現了一個向 global 中新增 getData 的 Native 函式

// 回撥函式
JSValueRef JSCExecutor::onGetDataCallback(JSContextRef ctx, JSObjectRef function, JSObjectRef thisObject,
                                   size_t argumentCount, const JSValueRef arguments[],
                                   JSValueRef *exception) {
        LOGD(TAG, "onGetDataCallback");
        NativeBridge::JSCExecutor *executor = static_cast<NativeBridge::JSCExecutor *>(JSObjectGetPrivate(
                thisObject));
        ... // 省略引數、型別等判斷
        executor->xxx(); // C++業務側
        return xxx; // 返回到JS內
}

bool JSCExecutor::initJSC() {
        // 初始化 JSC 引擎
        context_group_ = JSContextGroupCreate();
        JSClassDefinition global_class_definition = kJSClassDefinitionEmpty;
        global_class_ = JSClassCreate(&global_class_definition);

        // 在js執行上下文環境(Group)中建立一個全域性的js執行上下文
        context_ = JSGlobalContextCreateInGroup(context_group_, global_class_);
        if (!context_) {
            LOGE(TAG, "create js context error!");
            return false;
        }

        // 獲取js執行上下文的全域性物件
        global_ = JSContextGetGlobalObject(context_);
        if (!global_) {
            LOGE(TAG, "get js context error!");
            return false;
        }

        // 繫結c++物件地址
        JSObjectSetPrivate(global_, this);

        // 註冊函式
        JSStringRef dynamic_get_data_func_name = JSStringCreateWithUTF8CString("getData");
        JSObjectRef dynamic_get_data_obj = JSObjectMakeFunctionWithCallback(context_,
                                                                            dynamic_get_data_func_name,
                                                                            onGetDataCallback);
        JSObjectSetProperty(context_,
                            obj,
                            dynamic_get_data_func_name,
                            dynamic_get_data_obj,
                            kJSPropertyAttributeDontDelete,
                            NULL);
        return true;
    }

JNI(Java Native Interface)

JNI 全稱為 Java Native Interface,是一種允許 Java 程式碼與本地(Native)程式碼互動的技術。JNI 提供了一組 API,可以使 Java 程式訪問和呼叫本地方法和資源,也可以使原生程式碼訪問和呼叫 Java 物件和方法。
此方案需要使用 JNI 進行雙向呼叫。

C 呼叫 Java

步驟:

  • 獲取 JNIEnv 指標:JNIEnv 是一個結構體指標,代表了 Java 虛擬機器呼叫本地方法時的環境資訊。JNIEnv 指標可以透過 Java 虛擬機器例項、呼叫執行緒等引數獲取。
  • 獲取 Java 類、方法、欄位等的 ID:透過 JNIEnv 指標,可以使用函式 FindClass()、GetMethodID()、GetStaticMethodID()、GetFieldID()等函式獲取 Java 類、方法、欄位等的 ID。比如在 C 中去建立 Java 物件,並操作相關 Java 物件
  • 呼叫 Java 方法或訪問 Java 欄位:透過 JNIEnv 指標和 Java 物件的 ID,可以使用 CallObjectMethod()、CallStaticObjectMethod()、GetDoubleField()、SetObjectField()等函式呼叫 Java 方法或訪問 Java 欄位。

JavaC

步驟:

  1. 設計規劃功能、介面
  2. Java 宣告 Native 方法
  3. 按照 JNI 標準實現方法,並透過 System.loadLibrary()載入
public class TestJNI {
   static {
      System.loadLibrary("xxx.so"); // 載入動態連結庫
   }

   // 宣告本地方法
   private native void PrintHelloWorld();

   // 靜態方法
   public static native String GetVersion();

}

// C實現函式
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { ... } // so初始化回撥函式
JNIEXPORT void JNICALL JNI_OnUnload(JavaVM *jvm, void *reserved) { ... } // so解除安裝回撥函式

// 實現
包名_PrintHelloWorld(JNIEnv *env, jobject thiz) { ... }
包名_GetVersion(JNIEnv *env, jclass clazz) { ... }

關注點

JNI 的編寫會遇到有很多坑,比如 Java 封裝物件和 C++物件的生命週期關係、非同步呼叫邏輯、編譯器報錯不完善、型別不匹配、JVM 環境不一致、執行執行緒不一致等等,下面是一些常用的規則

記憶體
  • 在 C/C++程式碼中,使用物件或智慧指標去管理記憶體,若使用 malloc、calloc 等函式分配記憶體,然後使用 free 函式釋放記憶體。
  • 在 JNI 中,透過 jobject 等 JNI 物件的建立和銷燬方法,手動管理 Java 記憶體。例如,在 JNI 中建立 Java 物件時,需要呼叫 NewObject 等 JNI 方法建立 Java 物件,然後在使用完後,需要呼叫 DeleteLocalRef 等 JNI 方法釋放 Java 物件。
效能
  1. 避免頻繁建立和銷燬 JNI 引用:建立和銷燬 JNI 引用(如 jobject、jclass、jstring 等)的開銷比較大,應該儘量避免頻繁建立和銷燬 JNI 引用。
  2. 使用本地資料型別:JNI 支援本地資料型別(如 jint、jfloat、jboolean 等),這些資料型別與 Java 資料型別相對應,可以直接傳遞給 Java 程式碼,避免了資料型別轉換的開銷。
  3. 使用快取:如果有一些資料在 JNI 函式中需要重複使用,可以考慮使用快取,避免重複計算,比如 GetObjectClass、GetMethodID,這些可以儲存起來重複使用。
  4. 避免頻繁切換執行緒:JNI 函式會涉及到 Java 執行緒和本地執行緒之間的切換,這個過程比較耗時。因此,應該儘量避免頻繁切換執行緒。
  5. 避免 Native 側程式碼對整體效能造成得侵入,如 NDK 下 std::vector 分配大資料造成得效能低下,如 RN0.63 版本以前存在這個問題:Make JSStringToSTLString 23x faster (733532e5e9 by @radex)這需要對不同得編譯環境差異性有所瞭解。使用 NDK 編譯彙編程式碼/YourPath/Android/sdk/ndk/23.1.7779620/toolchains/llvm/prebuilt/darwin-x86_64/bin/clang++ --target=armv7-none-linux-androideabi21 --gcc-toolchain=/YourPath/Android/sdk/ndk/23.1.7779620/toolchains/llvm/prebuilt/darwin-x86_64 --sysroot=/YourPath/Android/sdk/ndk/23.1.7779620/toolchains/llvm/prebuilt/darwin-x86_64/sysroot -S native-lib.cpp
執行緒安全
  1. 當一個執行緒呼叫 Java 方法時,JNI 系統將自動為該執行緒建立一個 JNIEnv。因此,在訪問 Java 物件之前,需要手動將當前執行緒與 JVM 繫結,以便獲取 JNIEnv 指標,這個過程就叫做 "Attach"。可以使用 AttachCurrentThread 方法將當前執行緒附加到 JVM 上,然後就可以使用 JNIEnv 指標來訪問 Java 物件了。
    在 JNI 中,一般建議每個執行緒在使用完 JNIEnv 之後,立即 Detach,以釋放資源,避免記憶體洩漏
  2. Native 層執行緒安全需要針對自己得業務去區分是否需要加鎖

資料最佳化結果

最佳化結果
根據資料分析,性比之前減少了 50%的耗時

總結

上面概括性介紹了 JSC 和 JNI 的相關知識及經驗總結,由於篇幅有限一些問題沒有說明白或理解有誤,歡迎一起交流~~

參考

https://webkit.org/blog

https://developer.apple.com/documentation/javascriptcore

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章