初學J2V8

三味碼屋~發表於2023-03-18

V8和J2V8

V8

V8是Google開源的JavaScript和WebAssembly引擎,被用於Chrome瀏覽器和Node.js等。和其它JavaScript引擎把JavaScript轉換成位元組碼或解釋執行不同的是,V8在執行JavaScript之前,會將JavaScript編譯成原生機器碼,並且使用內聯快取等方法來提高效能,V8引擎執行JavaScript的速度可以媲美二進位制程式。V8採用 C++ 編寫,可以獨立執行,也可以嵌入到任何 C++ 程式中。

J2V8

J2V8是對V8引擎的一層Java封裝,即J2V8藉助JNI來實現Java層對C++層的訪問,透過Java將V8的一些關鍵API暴露出來供外部程式使用。J2V8旨在為Java世界帶來更加高效的JavaScript執行時環境,同時J2V8也可以在Windows、Linux、Mac OS等平臺上執行。

J2V8的使用

我們在Android工程中演示J2V8如何具體使用。

建立Android工程

首先,我們透過Android Studio建立一個Android工程。

依賴J2V8

建立好Android工程之後,需要依賴J2V8庫,如下:

// j2v8
implementation "com.eclipsesource.j2v8:j2v8:6.2.1@aar"

建立V8執行時例項

要使用J2V8,我們首先需要建立一個V8執行時例項,透過這個V8執行時例項,我們可以執行js指令碼、注入js變數、注入原生方法等。
com.eclipsesource.v8.V8提供了一系列建立V8執行時例項的靜態方法,如下:

public static V8 createV8Runtime() {
    return createV8Runtime((String)null, (String)null);
}

public static V8 createV8Runtime(String globalAlias) {
    return createV8Runtime(globalAlias, (String)null);
}

public static V8 createV8Runtime(String globalAlias, String tempDirectory) {
    if (!nativeLibraryLoaded) {
        synchronized(lock) {
            if (!nativeLibraryLoaded) {
                load(tempDirectory);
            }
        }
    }

    checkNativeLibraryLoaded();
    if (!initialized) {
        _setFlags(v8Flags);
        initialized = true;
    }

    V8 runtime = new V8(globalAlias);
    synchronized(lock) {
        ++runtimeCounter;
        return runtime;
    }
}

我們用最簡單的方法建立一個V8執行時例項,如下:

val v8 = V8.createV8Runtime()

執行js指令碼

com.eclipsesource.v8.V8提供了一系列執行js指令碼的方法,如下:
J2V8執行js指令碼的所有方法.png
這些方法涵蓋了多種不同的使用場景,例如返回不同的資料型別、執行某些具體的js函式等等,我們可以根據需要選擇合適的方法來執行js指令碼。

注入變數

透過com.eclipsesource.v8.V8Object提供的一系列add方法,可以給V8Object例項注入js變數,如下:

v8.add("key1", "value1")
v8.add("key2", "value2")
v8.add("key3", "value3")

變數注入之後,我們可以在js中直接使用這些變數。

注入原生物件

J2V8支援注入原生物件,向js注入原生物件之後,在js中可以訪問原生物件以及原生物件內部的方法和屬性。
如何注入原生物件呢?我們以在js中呼叫原生程式碼輸出日誌的場景為例。
首先,在原生程式碼中定義一個Console類,如下:

/**
 * 輸出日誌的類
 */
class Console {

    fun log(tag: String, message: V8Array) {
        Log.d(tag, message.toString())
    }
}

Console類實現了一個log方法,用於列印日誌內容。
然後,建立Console類物件,如下:

val console = Console()

接下來,我們建立一個V8Object物件,並向V8執行時物件注入這個物件,物件名命名為console,如下:

val consoleObject = V8Object(v8)
v8.add("console", consoleObject)

再接下來,我們透過consoleObject註冊原生方法。
這裡,我們選擇透過如下方法實現原生方法的註冊:

public V8Object registerJavaMethod(Object object, String methodName, String jsFunctionName, Class<?>[] parameterTypes) {
    return this.registerJavaMethod(object, methodName, jsFunctionName, parameterTypes, false);
}

public V8Object registerJavaMethod(Object object, String methodName, String jsFunctionName, Class<?>[] parameterTypes, boolean includeReceiver) {
    this.v8.checkThread();
    this.checkReleased();

    try {
        Method method = object.getClass().getMethod(methodName, parameterTypes);
        method.setAccessible(true);
        this.v8.registerCallback(object, method, this.getHandle(), jsFunctionName, includeReceiver);
        return this;
    } catch (NoSuchMethodException var7) {
        throw new IllegalStateException(var7);
    } catch (SecurityException var8) {
        throw new IllegalStateException(var8);
    }
}

這是一種基於反射的方法,其中,第一個引數傳原生物件即console,第二個引數傳原生方法名稱即log,第三個引數傳js方法的名稱,這裡和原生方法名稱保持一致(當然也可以不一致),最後一個引數傳Class型別的陣列,表示方法引數的型別,陣列元素和方法引數型別必須一一對應。如下:

consoleObject.registerJavaMethod(
    console, "log", "log", arrayOf(
        String::class.java, V8Array::class.java
    )
)

最後,一定要記得關閉手動建立的V8Object物件,釋放native層的記憶體,否則會報記憶體洩露方面的錯誤。如下:

consoleObject.close()

以上步驟完成之後,我們就可以在js中愉快地列印日誌了,我們使用V8執行時物件執行列印日誌的js指令碼,將此前注入的js變數列印出來,如下:

v8.executeScript("console.log('myConsole', [key1, key2, key3]);")

在Android Studio的Logcat中將會輸出如下資訊:

2022-12-25 13:21:48.932  4556-4556  myConsole               com.xy.j2v8                          D  value1,value2,value3

注入原生方法

上面在講注入原生物件的過程當中,其實也包含了原生方法的注入,在注入原生方法的時候,除了上面講到的方法,還有更簡單的方法。
com.eclipsesource.v8.V8Object還提供瞭如下方法:

public V8Object registerJavaMethod(JavaCallback callback, String jsFunctionName) {
    this.v8.checkThread();
    this.checkReleased();
    this.v8.registerCallback(callback, this.getHandle(), jsFunctionName);
    return this;
}

public V8Object registerJavaMethod(JavaVoidCallback callback, String jsFunctionName) {
    this.v8.checkThread();
    this.checkReleased();
    this.v8.registerVoidCallback(callback, this.getHandle(), jsFunctionName);
    return this;
}

兩個方法只有第一個引數不一樣,com.eclipsesource.v8.JavaVoidCallback表示原生方法無返回值,com.eclipsesource.v8.JavaCallback表示原生方法有返回值,返回型別是java.lang.Object型別的。
兩個方法的第二個參數列示要注入的方法的名稱。
我們使用第一個方法,來注入一個在js中彈Toast的方法,程式碼如下:

v8.registerJavaMethod({ v8Object, v8Array ->
    Toast.makeText(this, "$v8Object, $v8Array", Toast.LENGTH_SHORT).show()
}, "toast")

接下來,我們執行呼叫toast的js指令碼:

v8.executeJSFunction("toast", "Hello, I am a toast!")

指令碼執行之後,螢幕中將彈出文字內容為"Hello, I am a toast!"的toast。

執行緒模型

JavaScript本身是單執行緒的,J2V8也嚴格遵循這一點,即對單個V8執行時的所有訪問必須來自同一執行緒,換句話說就是:V8執行時例項在哪個執行緒被建立,就只能在哪個執行緒被使用。
如果在一個執行緒建立了V8執行時例項,在另外一個執行緒中直接訪問這個例項,會拋異常,如下:

Process: com.xy.j2v8, PID: 27274
java.lang.Error: Invalid V8 thread access: current thread is Thread[Thread-3,5,main] while the locker has thread Thread[main,5,main]
	at com.eclipsesource.v8.V8Locker.checkThread(V8Locker.java:99)
	at com.eclipsesource.v8.V8.checkThread(V8.java:840)
	at com.eclipsesource.v8.V8.executeScript(V8.java:715)
	at com.eclipsesource.v8.V8.executeScript(V8.java:685)
	at com.xy.j2v8.MainActivity.onCreate$lambda$1$lambda$0(MainActivity.kt:27)
	at com.xy.j2v8.MainActivity.$r8$lambda$jWdwVCxaGZnvyZS9q138fD71tFk(Unknown Source:0)
	at com.xy.j2v8.MainActivity$$ExternalSyntheticLambda2.run(Unknown Source:2)
	at java.lang.Thread.run(Thread.java:929)

J2V8單執行緒模型確保了在使用單個V8執行時時不存在多執行緒問題,例如執行緒之間資源競爭,出現死鎖等。

GitHub

XyJ2V8