打造你自己的動態化引擎
highlight: atelier-seaside-light theme: cyanosis
背景
什麼是動態化
近年來,越來越多基於前端技術棧的動態化方案被引入到客戶端開發中,大家有沒有想過平時開發寫程式碼時,使用的客戶端技術棧和前端技術棧有什麼不同呢?
簡單來說,無論是Android還是iOS應用,在釋出之前,都要經歷原始碼編寫、打包編譯、釋出應用商店、使用者升級安裝等過程。首先,編譯速度會隨著應用規模成正比增加,對於規模較大的應用,有時我們僅僅是修改一個控制元件的顏色,卻要等待幾分鐘來驗證結果,開發效率是非常低下的;其次,釋出應用商店、使用者升級安裝,會極大的拉長應用釋出週期,延遲產品效果的驗證;最後,開發一個相同功能,至少需要雙端各一個工程師,寫兩份程式碼,人力成本++。
我們再看看前端開發流程:由於是使用JavaScript指令碼語言開發,並不需要提前編譯,在瀏覽器中可以直接預覽效果;新版本釋出後,也可以直接觸達到瀏覽器,使用者無需額外操作;最重要的是,對於一個相同功能,只需要開發一次,即可在幾乎所有作業系統的瀏覽器上執行。
瞭解了兩種開發模式後,我們會自然而然的想到,為什麼不能把前端技術棧的開發流程,引入到客戶端中,讓客戶端應用也擁有前端的動態性和跨端性呢?
其實業界早已有各種各樣的解決方案,大家應該多多少少聽說或者接觸過React Native、Weex、微信小程式等。甚至某些特定場景下,客戶端功能已經完全使用前端技術進行開發了,比如需要動態下發的運營活動、需要快速試錯的產品功能、小程式類的應用內生態建設等。
動態化引擎是動態化方案中最核心的模組,有了動態化引擎,才能使開發者編寫的JS應用執行在客戶端中,實現UI和邏輯的動態化。
Hello Hybrid World
其實動態化沒有想象中的困難,只要瞭解了其中原理,每個同學都能從0到1打造一個動態化引擎。更進一步,我們甚至可以用自己實現的動態化引擎,為它編寫一個如下的JS應用:
從應用開發者的角度來看,要實現這樣一個介面,需要在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引擎
我們可以將引擎部分抽象成兩個模組——JsBundle
和JsContext
。
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
陣列中的第一個子節點,是type
為text
的文字元件,它也有很多屬性,如文字大小、間距等;類似的,剩下的子節點分別是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
。如:DomButton
、DomVerticalLayout
等
我們還需要一個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
。如:ButtonJsView
、VerticalLayoutJsView
等。
同樣的,我們仍然需要一個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應用,及實際執行在手機上的效果:
三、總結
動態化引擎:http://github.com/kwai-ec/HybridDemo
筆者已將實現好的動態化引擎放到github上了,大家可以clone後按照自己的想法進行修改。
本文主要介紹了動態化引擎有哪些核心模組,並將每個模組的實現方法分步驟展開,希望大家能從手動實現的過程中,理解動態化引擎的原理,也瞭解前端技術棧和客戶端的不同之處。
大家感興趣的話,可以再繼續完善這個動態化引擎,新增自己想要的能力,寫出更多有趣的JS應用~
hi, 我是快手電商的謝同學
快手電商無線技術團隊正在招賢納士🎉🎉🎉! 我們是公司的核心業務線, 這裡雲集了各路高手, 也充滿了機會與挑戰. 伴隨著業務的高速發展, 團隊也在快速擴張. 歡迎各位高手加入我們, 一起創造世界級的電商產品~
熱招崗位: Android/iOS 高階開發, Android/iOS 專家, Java 架構師, 產品經理(電商背景), 測試開發... 大量 HC 等你來呦~
內部推薦請發簡歷至 >>>我們的郵箱: [email protected] <<<, 備註我的花名成功率更高哦~ 😘