天天看點

網易雲音樂Android本地搜尋優化

作者:碼客生活
網易雲音樂Android本地搜尋優化

引言

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

背景

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

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

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

基于現有方案的分析:

網易雲音樂Android本地搜尋優化

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

方案

結合現有業務特點:

  • 算法是變化的、動态下發的,是以代碼由 JS 實作,故需要在 JS 引擎中執行
  • Java 使用 JSC 需要借助 JNI,并加入一些邏輯處理
  • JNI 需要向 JS 引擎輸入資料,同時需要擷取執行得結果

得出如下流程圖

網易雲音樂Android本地搜尋優化

流程

如何實作?

  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 層線程安全需要針對自己得業務去區分是否需要加鎖

資料優化結果

網易雲音樂Android本地搜尋優化

根據資料分析,性比之前減少了 50%的耗時

總結

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

繼續閱讀