打造你自己的動態化引擎

語言: CN / TW / HK

highlight: atelier-seaside-light theme: cyanosis


背景

什麼是動態化

近年來,越來越多基於前端技術棧的動態化方案被引入到客戶端開發中,大家有沒有想過平時開發寫程式碼時,使用的客戶端技術棧和前端技術棧有什麼不同呢?

簡單來說,無論是Android還是iOS應用,在釋出之前,都要經歷原始碼編寫、打包編譯、釋出應用商店、使用者升級安裝等過程。首先,編譯速度會隨著應用規模成正比增加,對於規模較大的應用,有時我們僅僅是修改一個控制元件的顏色,卻要等待幾分鐘來驗證結果,開發效率是非常低下的;其次,釋出應用商店、使用者升級安裝,會極大的拉長應用釋出週期,延遲產品效果的驗證;最後,開發一個相同功能,至少需要雙端各一個工程師,寫兩份程式碼,人力成本++。

我們再看看前端開發流程:由於是使用JavaScript指令碼語言開發,並不需要提前編譯,在瀏覽器中可以直接預覽效果;新版本釋出後,也可以直接觸達到瀏覽器,使用者無需額外操作;最重要的是,對於一個相同功能,只需要開發一次,即可在幾乎所有作業系統的瀏覽器上執行。

瞭解了兩種開發模式後,我們會自然而然的想到,為什麼不能把前端技術棧的開發流程,引入到客戶端中,讓客戶端應用也擁有前端的動態性和跨端性呢?

其實業界早已有各種各樣的解決方案,大家應該多多少少聽說或者接觸過React Native、Weex、微信小程式等。甚至某些特定場景下,客戶端功能已經完全使用前端技術進行開發了,比如需要動態下發的運營活動、需要快速試錯的產品功能、小程式類的應用內生態建設等。

動態化引擎是動態化方案中最核心的模組,有了動態化引擎,才能使開發者編寫的JS應用執行在客戶端中,實現UI和邏輯的動態化。

Hello Hybrid World

其實動態化沒有想象中的困難,只要瞭解了其中原理,每個同學都能從0到1打造一個動態化引擎。更進一步,我們甚至可以用自己實現的動態化引擎,為它編寫一個如下的JS應用:

1 (1).jpg

從應用開發者的角度來看,要實現這樣一個介面,需要在JS程式碼中建立縱向佈局文字元件圖片元件以及按鈕元件,且按鈕可以設定點選事件。

這些就是我們動態化引擎需要支援的一部分能力,當然,還有更多底層的能力從開發者角度無法直觀感受,下一節會詳細介紹。

打造自己的動態化引擎Step by step

筆者是Android工程師,所以會使用Android及Java技術棧實現,iOS或其他端原理其實是類似的。

Step 1. 目標拆解

Question: 大家可以思考下,要實現一個基於前端技術棧的動態化引擎,都需要哪些模組?

下圖展示了一個基礎的動態化引擎所需的模組和元件:

從上到下依次是:

| 模組 | 作用 | | --- | --- | | Business Code | JS應用的介面和邏輯程式碼 | | JS Framework | 業務程式碼之下的一層JS執行時封裝,提供了諸如生命週期回撥、應用入口函式、VDom、Diff演算法等基礎能力,直接與Native側進行通訊 | | JS Engine | JS虛擬機器,執行JS程式碼的核心模組,如V8、JavaScriptCore等 | | JS Bridge | JS和Native之間雙向通訊的通道 | | ModuleManager | 通常是所有Native橋的集合,並提供橋的註冊、獲取等方法 | | RenderManager | 管理應用的渲染流程,比如解析JS Framework發來的VDom資料、渲染指令、構建Native側的Dom樹、View樹等 | | Debugging | 除錯能力支援,主要是和CDP協議(Chrome DevTools Protocol)對接,可在Chrome DevTools上進行除錯操作 | | Native Modules | Native側實現的橋,基本上是對Native API的二次封裝,供JS側呼叫 | | Native Components | Native側實現的控制元件,基本上是對Native View的二次封裝,供JS側呼叫 |

熟悉動態化引擎的重要模組之後,我們就可以開始逐步實現啦。

Step 2. JS引擎

JS引擎是處理JavaScript指令碼的虛擬機器,是動態化的前提和基礎,有了它開發者才可以在客戶端應用中執行JS程式碼。

目前常見的JS引擎有V8和JavaScriptCore。

V8引擎是C++實現的,由於我們在Android中開發,所以需要使用J2V8。J2V8是V8引擎的Java封裝,提供了各種易用的介面。

J2V8:http://github.com/eclipsesource/J2V8

依賴

gradle dependencies { implementation 'com.eclipsesource.j2v8:j2v8:6.2.1@aar' }

建立V8引擎

java V8 runtime = V8.createV8Runtime();

Native執行JS指令碼

執行一段JS邏輯:

java V8 runtime = V8.createV8Runtime(); int result = runtime.executeIntegerScript("var i = 0; i++; i"); System.out.println("result: " + result); // result: 1

Native執行JS方法

定義一個JS方法並執行:

java V8 runtime = V8.createV8Runtime(); runtime.executeVoidScript("function add(a, b) { return a + b }"); V8Array args = new V8Array(runtime).push(1).push(2); int result = runtime.executeIntegerFunction("add", args); System.out.println("result: " + result); // result: 3

封裝JS引擎

我們可以將引擎部分抽象成兩個模組——JsBundleJsContext

JsBundle

JsBundle是JS應用的打包檔案,包含了應用的所有原始碼和資源,如本地圖片資源和應用資訊清單。不過,我們只是實現一個簡單的動態化框架,暫時只包含JS原始碼檔案就OK,或者,直接把一個.js檔案當作bundle也可以。

``` java public class JsBundle {

private String mAppJavaScript;

public String getAppJavaScript() {
    return mAppJavaScript;
}

public void setAppJavaScript(String appJavaScript) {
    this.mAppJavaScript = appJavaScript;
}

} ```

mAppJavaScript就是應用的JS程式碼。

JsContext

JsContext是對V8引擎的二次封裝,用來描述一個JS引擎如何初始化和執行應用JS程式碼:

``` java public class JsContext {

private V8 mEngine;

public JsContext() {
    init();
}

private void init() {
    mEngine = V8.createV8Runtime();
}

public V8 getEngine() {
    return mEngine;
}

public void runApplication(JsBundle jsBundle) {
    mEngine.executeStringScript(jsBundle.getAppJavaScript());
}

} ```

理論上來說,當我們執行下面程式碼時,一個JS引擎就啟動起來了,並可以執行任意和Native無關的JS程式碼了:

``` java JsBundle jsBundle = new JsBundle(); jsBundle.setAppJavaScript("var a = 1");

JsContext jsContext = new JsContext(); jsContext.runApplication(jsBundle); ```

Tips: 如果想使用Native的能力,還需要在引擎初始化之前,注入所謂的橋,用來完成JS到Native的通訊

Step 3. 雙向通訊——JS Bridge

上節中我們已經知道如何建立一個V8引擎並執行JS指令碼了。但是想做到JS呼叫原生系統的能力、原生系統通知JS有事件發生,則需要一種通訊機制,也就是我們常說的橋——JS Brdige。

JS Bridge作為一種雙向通訊機制,保證了JS程式碼可以使用原生系統能力(如拍照、訪問網路、獲取裝置資訊等);同時當原生系統有訊息或事件發生時,也可以通知到JS側(如陀螺儀監聽、推送訊息觸達、使用者點選事件等)。

JS執行Native方法

V8引擎提供了向JS注入Native方法的能力,比如前端中最常見的console.info函式,我們可以這樣實現:

java V8 runtime = V8.createV8Runtime(); V8Object console = new V8Object(runtime); console.registerJavaMethod((v8Object, params) -> { String msg = params.getString(0); Log.i(TAG, msg); return null; }, "info"); runtime.add("console", console);

javascript console.info("print some messages!")

然後在adb logcat中我們就會看到這樣一條日誌打出來。

Native執行JS函式

直接執行executeScript,呼叫一個已經定義好的JS函式:

javascript function sayHello() { return "Hello Hybrid World!" }

java V8 runtime = V8.createV8Runtime(); String result = runtime.executeStringScript("sayHello()"); // Hello Hybrid World!

JS可以傳遞一個V8Function到Native,比如實現一個監聽經緯度變化的回撥:

java V8 runtime = V8.createV8Runtime(); V8Object device = new V8Object(runtime); console.registerJavaMethod((v8Object, params) -> { V8Function listener = (V8Function) params.getObject(0); V8Array locations = new V8Array(runtime).push(116.1234567).push(46.1234567); listener.call(v8Object, locations); return null; }, "onLocationChanged"); runtime.add("$device", device);

javascript $device.onLocationChanged(listener: function (x, y) { console.info(x); }) // 116.1234567

構建JS Bridge

我們已經學會了如何利用V8引擎的能力實現JS-Native雙向通訊,現在我們將這些行為和資訊進行抽象,從而更方便的對橋進行管理和註冊。

可以用JsModule代表一個Native橋的能力:

``` java public abstract class JsModule {

public abstract String getName();

public abstract List<String> getFunctionNames();

public abstract Object execute(String functionName, V8Array params);

}

// console.info方法的抽象 public class ConsoleModule extends JsModule {

@Override
public String getName() {
    return "console";
}

@Override
public List<String> getFunctionNames() {
    List<String> functions = new ArrayList<>();
    functions.add("info");
    return functions;
}

@Override
public Object execute(String functionName, V8Array params) {
    switch (functionName) {
        case "info":
            Log.i("Javascript Console", params.getString(0));
            break;
    }
    return null;
}

} ```

使用ModuleManager來管理和註冊所有的JsModule:

```java public class ModuleManager {

private ModuleManager() {
}

private static class Holder {
    private static final ModuleManager INSTANCE = new ModuleManager();
}

public static ModuleManager getInstance() {
    return Holder.INSTANCE;
}

private final List<JsModule> mModuleList = new ArrayList<>();
private JsContext mJsContext;

public void init(JsContext jsContext) {
    mJsContext = jsContext;
    mModuleList.add(new UiModule());
    mModuleList.add(new ConsoleModule());
    registerModules();
}

private void registerModules() {
    for (JsModule module : mModuleList) {
        V8Object moduleObj = new V8Object(mJsContext.getEngine());
        for (String functionName : module.getFunctionNames()) {
            moduleObj.registerJavaMethod((v8Object, params) -> {
                return module.execute(functionName, params);
            }, functionName);
        }
        mJsContext.getEngine().add(module.getName(), moduleObj);
    }
}

} ```

至此,我們的動態化框架已經支援了JS呼叫Native能力,大家可以繼承JsModule,編寫任意所需要的橋,實現各種各樣的能力。

相比於上一節,現在JS應用的程式碼可以包含Native相關的方法了,不再侷限於最原始的JS環境。

Step 4. 渲染引擎

到這一步,我們已經可以實現邏輯動態化了,理論上所有不需要使用者互動的邏輯行為,都可以放到JS中執行。

但一個現代化的應用,除了有後臺邏輯,更重要的一部分是直接面向用戶的UI介面,這意味著使用者對應用的第一印象,所以這一節我們來看看如何實現UI動態化。

UI Framework

Android開發者都很熟悉XML,我們會在其中定義靜態頁面結構,比如:

```xml

<TextView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="16dp"
    android:gravity="center"
    android:text="Hello Hybrid World!"
    android:textSize="24sp" />

<ImageView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center"
    android:layout_marginTop="24dp"
    android:src="@drawable/ic_launcher_background" />

<Button
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center"
    android:layout_marginTop="32dp"
    android:text="BUTTON" />

```

Question: 大家可以思考下XML為什麼能轉換成螢幕上的UI元件?

簡單來說:Android UI框架會讀取並解析XML檔案,然後將其構建成一個一個View和ViewGroup,形成一棵頁面的View樹,最後交由系統自頂向下進行渲染,顯示到螢幕上。所以XML是Android UI框架的一種DSL,View系統是Android UI框架的一種渲染引擎。

我們瞭解了UI框架核心的兩點:1. 面向開發者的DSL;2. 面向作業系統的渲染引擎

React Native、Weex等原生渲染的動態化框架,其實改變的是DSL這一層,只是開發者書寫UI的方式變了,但介面依然是構建成View樹進行渲染。

然而,像Flutter、Jetpack Compose等UI框架,不僅改變了DSL,也使用了完全不同的渲染引擎(基於skia),實現了在Android裝置上的UI繪製。

UI動態化

要實現UI動態化,核心原理就是使構建頁面的DSL支援動態下發,渲染引擎支援動態解析和建立檢視元件即可。

最簡單的DSL,可以用JSON結構表示介面元素及佈局。比如文章開頭我們期望實現的介面,可以這樣描述:

javascript const hello = "Hello "; const title = hello + "Hybrid World!" $view.render({ rootView: { type: "verticalLayout", children: [ { "type": "text", "text": title, "textSize": 24, "marginTop": 16 }, { "type": "image", "width": 72, "height": 72, "marginTop": 80, "url": "" }, { "type": "button", "text": "點選列印日誌", "marginTop": 80, "marginLeft": 40, "marginRight": 40, "onClick": function () { console.info("success!") } } ] } })

我們定義了一個$view.render方法,作為介面繪製的入口函式,當JS執行到這個方法時,就會開始渲染;在這之前,大家可以寫任意介面無關的邏輯。

Tips: React和Vue都是前端的UI框架,它們擁有直觀的的DSL語法、強大的VDom機制以及各種語法糖,可以讓開發者很輕鬆的編寫UI介面,這也是UI DSL的目標之一。筆者使用JSON作為UI DSL,因為其資料結構最常見、也容易理解,不需要額外的語法解析器就能實現,真正業界的UI DSL要比這個複雜得多😊

既然$view.render是一個Native橋,那麼就用上一節定義的JsModule來實現吧:

```java public class UiModule extends JsModule { @Override public String getName() { return "$view"; }

@Override
public List<String> getFunctionNames() {
    List<String> functionNames = new ArrayList<>();
    functionNames.add("render");
    return functionNames;
}

@Override
public Object execute(String functionName, V8Array params) {
    switch (functionName) {
        case "render":
            V8Object param1 = params.getObject(0);
            V8Object rootViewObj = param1.getObject("rootView");
            RenderManager.getInstance().render(rootViewObj);
            break;
    }
    return null;
}

} ```

$view.render方法傳進來的是一個物件,其中rootView欄位表明這個介面的根佈局;一般來說,一個介面只能有一個根節點,根節點下面會有很多子節點,最終形成一個樹狀結構。

rootView節點下有type欄位,表示它是一個verticalLayout型別,即縱向佈局;以及children欄位,表明了其子節點都有哪些。

children陣列中的第一個子節點,是typetext的文字元件,它也有很多屬性,如文字大小、間距等;類似的,剩下的子節點分別是image圖片元件和button按鈕元件,也同樣有各自的屬性。

DomElement

JS傳遞過來的物件,會以V8Object的形式承載,不方便直接進行操作,我們可以將JS傳遞過來的V8Object抽象成DomElement,表示一個節點元素的屬性資訊,也方便之後Native View使用這些屬性。

DomElement是資料類,直接對應JS側傳遞過來的檢視節點資訊。

```java // 檢視元素可以有公用的屬性 public class DomElement {

public String type;
public int marginTop;
public int marginBottom;
public int marginLeft;
public int marginRight;
public V8Function onClick;

public void parse(V8Object v8Object) {
    for (String key : v8Object.getKeys()) {
        switch (key) {
            case "type":
                this.type = v8Object.getString("type");
                break;
            case "marginTop":
                this.marginTop = v8Object.getInteger("marginTop");
                break;
            case "marginBottom":
                this.marginBottom = v8Object.getInteger("marginBottom");
                break;
            case "marginLeft":
                this.marginLeft = v8Object.getInteger("marginLeft");
                break;
            case "marginRight":
                this.marginRight = v8Object.getInteger("marginRight");
                break;
            case "onClick":
                this.onClick = (V8Function) v8Object.get("onClick");
                break;
            default:
                break;
        }
    }
}

}

// 每個具體的檢視元素也可以有自己獨有的屬性 public class DomText extends DomElement { public String text; public int textSize; public String textColor;

@Override
public void parse(V8Object v8Object) {
    super.parse(v8Object);
    for (String key : v8Object.getKeys()) {
        switch (key) {
            case "text":
                this.text = v8Object.getString("text");
                break;
            case "textSize":
                 int textSize = v8Object.getInteger("textSize");
                if (textSize == 0) {
                    textSize = 16;
                }
                this.textSize = textSize;
                break;
            case "textColor":
                String textColor = v8Object.getString("textColor");
                if (TextUtils.isEmpty(textColor)) {
                    textColor = "#000000";
                }
                this.textColor = textColor;
                break;
        }
    }
}

} ```

Question: 大家可以嘗試編寫剩下所需要的DomElement。如:DomButtonDomVerticalLayout

我們還需要一個DomFactory,使用工廠模式來建立不同型別的DomElement

```java public class DomFactory {

public static DomElement create(V8Object rootV8Obj) {
    String type = rootV8Obj.getString("type");
    switch (type) {
        case "text":
            DomText domText = new DomText();
            domText.parse(rootV8Obj);
            return domText;
        case "image":
            DomImage domImage = new DomImage();
            domImage.parse(rootV8Obj);
            return domImage;
        case "button":
            DomButton domButton = new DomButton();
            domButton.parse(rootV8Obj);
            return domButton;
        case "verticalLayout":
            DomVerticalLayout domVerticalLayout = new DomVerticalLayout();
            domVerticalLayout.parse(rootV8Obj);
            return domVerticalLayout;
    }
    return null;
}

} ```

Tips: 當然,工廠模式只是其中一種實現方式,大家可以有更多靈活的建立方法,比如利用註解記錄型別資訊,反射生成對應的DomElement物件,好處是建立物件完全自動化了,當以後有幾十個UI控制元件時,不需要手動例項化。

然後就可以很容易的建立一顆JS側根佈局的DomElement樹:

java V8Object rootViewObj = ...; DomElement rootViewElement = DomFactory.create(rootViewObj);

JsView

我們已經可以在Native中隨意訪問節點元素資料了,目的是為了給即將被渲染出來的Native View使用,因為Native View需要知道自己應該如何展示、展示什麼文案、響應什麼點選事件等等。

不過,直接在$view.render方法執行後例項化Native View、設定DomElement中的屬性、構建Native View樹,會使UiModule類過於臃腫,所以我們還需要一箇中間層抽象出Native View所對應的虛擬檢視——JsView

JsView的作用是使元素節點更加內聚,只需要關注如何建立自己,JsView也和DomElement一樣會構建出一顆樹,用來表示介面結構;每個JsView都有createView方法,用來返回其真正對應的Native View例項:

```java public abstract class JsView {

protected D mDomElement;
protected V mNativeView;

public void setDomElement(DomElement domElement) {
    mDomElement = (D) domElement;
}

public abstract String getType();

public abstract V createViewInternal(Context context);

public V createView(Context context) {
    V view = createViewInternal(context);
    mNativeView = view;
    return view;
}

} ```

比如,文字元件需要繼承自JsView:

```java public class TextJsView extends JsView {

@Override
public String getType() {
    return "text";
}

@Override
public TextView createViewInternal(Context context) {
    TextView textView = new TextView(context);
    textView.setGravity(Gravity.CENTER);
    textView.setText(mDomElement.text);
    textView.setTextSize(mDomElement.textSize);
    textView.setTextColor(Color.parseColor(mDomElement.textColor));
    return textView;
}

} ```

Question: 大家可以嘗試編寫剩下的JsView。如:ButtonJsViewVerticalLayoutJsView等。
同樣的,我們仍然需要一個JsViewFactory來建立不同型別的JsView例項,如同DomElement一樣,這裡就不贅述了。

最後,我們可以使用RenderManager來管理DSL的解析、DomElement樹的建立、JsView樹的建立和Native View的渲染。同時,RenderManager也需要一個Native View容器,來承載JS渲染出來的根佈局:

```java public class RenderManager {

private RenderManager() {
}

private static class Holder {
    private static final RenderManager INSTANCE = new RenderManager();
}

public static RenderManager getInstance() {
    return Holder.INSTANCE;
}

private Context mContext;
private ViewGroup mContainerView;

public void init(Context context, ViewGroup containerView) {
    mContext = context;
    mContainerView = containerView;
}

public void render(V8Object rootViewObj) { DomElement rootDomElement = DomFactory.create(rootViewObj); JsView rootJsView = JsViewFactory.create(rootDomElement); if (rootJsView != null) { View rootView = rootJsView.createView(mContext); mContainerView.addView(rootView); } } } ```

Step 5. 整合動態化引擎

目前為止,我們幾乎完成了動態化引擎所需要的所有模組,現在只剩下把它組裝起來了。

我們期望Native在建立動態化引擎時,可以很方便的使用,所以可將整個動態化容器對外抽象成一個JsApplication

```java public class JsApplication { private JsContext mJsContext;

public static JsApplication init(Context context, ViewGroup containerView) {
    JsApplication jsApplication = new JsApplication();
    JsContext jsContext = new JsContext();
    jsApplication.mJsContext = jsContext;
    RenderManager.getInstance().init(context, containerView);
    ModuleManager.getInstance().init(jsContext);
    return jsApplication;
}

public void run(JsBundle jsBundle) {
    mJsContext.runApplication(jsBundle);
}

} ```

在MainActivity中,只需要初始化JsApplication並執行JsBundle即可:

```java FrameLayout containerView = findViewById(R.id.js_container_view);

JsBundle jsBundle = new JsBundle(); jsBundle.setAppJavaScript(JS_CODE);

JsApplication jsApplication = JsApplication.init(this, containerView); jsApplication.run(jsBundle); ```

一個基礎的動態化引擎已經完成了,沒想到實現起來如此簡單吧,只要我們理解了動態化引擎核心的原理和必要的模組,最終的實現方法就多種多樣了,大家可以用自己熟悉、擅長的方式,改造這個引擎的各個模組。

比如:將工廠模式建立JsView改造成註解自動例項化;或者將JSON DSL改造成類Vue的宣告式語法;再或者直接使用Lua替換JavaScript,替換應用開發語言。

下圖是使用VS Code編寫的JS應用,及實際執行在手機上的效果:

code (1).jpg

screen (1).jpg

三、總結

動態化引擎:http://github.com/kwai-ec/HybridDemo
筆者已將實現好的動態化引擎放到github上了,大家可以clone後按照自己的想法進行修改。

本文主要介紹了動態化引擎有哪些核心模組,並將每個模組的實現方法分步驟展開,希望大家能從手動實現的過程中,理解動態化引擎的原理,也瞭解前端技術棧和客戶端的不同之處。

大家感興趣的話,可以再繼續完善這個動態化引擎,新增自己想要的能力,寫出更多有趣的JS應用~

hi, 我是快手電商的謝同學

快手電商無線技術團隊正在招賢納士🎉🎉🎉! 我們是公司的核心業務線, 這裡雲集了各路高手, 也充滿了機會與挑戰. 伴隨著業務的高速發展, 團隊也在快速擴張. 歡迎各位高手加入我們, 一起創造世界級的電商產品~

熱招崗位: Android/iOS 高階開發, Android/iOS 專家, Java 架構師, 產品經理(電商背景), 測試開發... 大量 HC 等你來呦~

內部推薦請發簡歷至 >>>我們的郵箱: [email protected] <<<, 備註我的花名成功率更高哦~ 😘