打造你自己的动态化引擎

19,676 阅读12分钟

背景

什么是动态化

近年来,越来越多基于前端技术栈的动态化方案被引入到客户端开发中,大家有没有想过平时开发写代码时,使用的客户端技术栈和前端技术栈有什么不同呢?

简单来说,无论是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 CodeJS应用的界面和逻辑代码
JS Framework业务代码之下的一层JS运行时封装,提供了诸如生命周期回调、应用入口函数、VDom、Diff算法等基础能力,直接与Native侧进行通信
JS EngineJS虚拟机,运行JS代码的核心模块,如V8、JavaScriptCore等
JS BridgeJS和Native之间双向通信的通道
ModuleManager通常是所有Native桥的集合,并提供桥的注册、获取等方法
RenderManager管理应用的渲染流程,比如解析JS Framework发来的VDom数据、渲染指令、构建Native侧的Dom树、View树等
Debugging调试能力支持,主要是和CDP协议(Chrome DevTools Protocol)对接,可在Chrome DevTools上进行调试操作
Native ModulesNative侧实现的桥,基本上是对Native API的二次封装,供JS侧调用
Native ComponentsNative侧实现的控件,基本上是对Native View的二次封装,供JS侧调用

熟悉动态化引擎的重要模块之后,我们就可以开始逐步实现啦。

Step 2. JS引擎

JS引擎是处理JavaScript脚本的虚拟机,是动态化的前提和基础,有了它开发者才可以在客户端应用中运行JS代码。

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

V8引擎是C++实现的,由于我们在Android中开发,所以需要使用J2V8。J2V8是V8引擎的Java封装,提供了各种易用的接口。

J2V8:github.com/eclipsesour…

依赖

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

创建V8引擎

V8 runtime = V8.createV8Runtime();

Native执行JS脚本

执行一段JS逻辑:

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

Native执行JS方法

定义一个JS方法并执行:

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也可以。

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代码:

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代码了:

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函数,我们可以这样实现:

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);
console.info("print some messages!")

然后在adb logcat中我们就会看到这样一条日志打出来。

Native执行JS函数

直接执行executeScript,调用一个已经定义好的JS函数:

function sayHello() {
    return "Hello Hybrid World!"
}
V8 runtime = V8.createV8Runtime();
String result = runtime.executeStringScript("sayHello()");
// Hello Hybrid World!

JS可以传递一个V8Function到Native,比如实现一个监听经纬度变化的回调:

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);
$device.onLocationChanged(listener: function (x, y) {
  console.info(x);
})
// 116.1234567

构建JS Bridge

我们已经学会了如何利用V8引擎的能力实现JS-Native双向通信,现在我们将这些行为和信息进行抽象,从而更方便的对桥进行管理和注册。

可以用JsModule代表一个Native桥的能力:

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:

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 version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <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" />
</LinearLayout>

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结构表示界面元素及布局。比如文章开头我们期望实现的界面,可以这样描述:

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来实现吧:

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侧传递过来的视图节点信息。

// 视图元素可以有公用的属性
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

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树:

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实例:

public abstract class JsView<V extends View, D extends DomElement> {

    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:

public class TextJsView extends JsView<TextView, DomText> {

    @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渲染出来的根布局:

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

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即可:

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

三、总结

动态化引擎:github.com/kwai-ec/Hyb…
笔者已将实现好的动态化引擎放到github上了,大家可以clone后按照自己的想法进行修改。

本文主要介绍了动态化引擎有哪些核心模块,并将每个模块的实现方法分步骤展开,希望大家能从手动实现的过程中,理解动态化引擎的原理,也了解前端技术栈和客户端的不同之处。

大家感兴趣的话,可以再继续完善这个动态化引擎,添加自己想要的能力,写出更多有趣的JS应用~

hi, 我是快手电商的谢同学

快手电商无线技术团队正在招贤纳士🎉🎉🎉! 我们是公司的核心业务线, 这里云集了各路高手, 也充满了机会与挑战. 伴随着业务的高速发展, 团队也在快速扩张. 欢迎各位高手加入我们, 一起创造世界级的电商产品~

热招岗位: Android/iOS 高级开发, Android/iOS 专家, Java 架构师, 产品经理(电商背景), 测试开发... 大量 HC 等你来呦~

内部推荐请发简历至 >>>我们的邮箱: hr.ec@kuaishou.com <<<, 备注我的花名成功率更高哦~ 😘