打造你自己的動態化引擎
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] <<<, 備註我的花名成功率更高哦~ 😘