Doric,渐进式跨端框架 —— 从2D到3D

avatar
@比心

本文内容为作者在GIAC 2022 全球互联架构大会上海站的分享总结

Doric是什么

Doric是一个极简、高效的跨端开发框架,使用TypeScript作为开发语言。
上层遵循MVVM设计,可以在多个终端平台上执行,实现一份代码可以多处执行。
目前已经支持Android、iOS、Web及Qt,可按开发者所需接入更多场景和平台。

Doric名字的起源

640.jpeg

上图为希腊的帕特农神庙,其历经数千年风雨侵蚀,早已断墙残垣。然而那些廊柱屹立千年,仍然坚固巍然。这种廊柱样式叫做 Doric(多立克柱式),即我们这个项目的名称由来。
正如Doric样式的廊柱撑起了神庙的千年风雨,我们也希望Doric项目能够作为前端页面的支柱,简洁,可靠。

Doric环境准备

使用Doric前,需要准备好下列开发环境:

  • Node.js(Node.js 版本需不低于 8.0,建议使用 Node.js 12.0 及以上版本)
  • npm (一般已随Node.js安装包安装好)

Doric使用TypeScript作为开发语言,推荐使用编辑器

  • Visual Studio Code

当必需的开发环境准备就绪后,即可使用npm安装Doric命令行工具:

npm install -g doric-cli

此命令行工具包含以下用法:

doric create [ProjectName]

新建一个Doric项目,项目名为ProjectName

doric dev

开始开发Doric页面,此时终端会输出二维码。可通过Doric Playground扫码查看页面效果。也可以在此模式下进行热加载,调试等

doric build

进行代码打包,此时输出最终编译好的javascript文件至bundle目录下。

doric run android
doric run ios

分别编译Android和iOS项目,并安装App到手机或模拟器中

doric clean

清理编译缓存文件。此时会删除.dxx(旧版本为build)和bundle文件夹中内容

Doric Playground

为了方便开发者可以快速开发,获得所见即所得的开发体验,Doric Pub提供了在线版的Playground:p.doric.pub/play/

640.png

左侧是一个在线的Monaco Editor编辑器,右侧即是一个所见即所得的交互展示区

640 (1).png

该页面预置了一系列的Examples代码,选取或者更改了对应源码后可直接点击Run按钮便可以实时体验。

除在线的站点外,Doric Playground还有对应的App版本,可扫描下方二维码获取该App:

640 (2).png

Android版本

640 (3).png

iOS TestFlight版本

640 (4).png

打开App后有如上图有六个菜单项,点击开始开发会进入如下的页面,这里运行着一个Doric实例,可以配合前面提到的doric-cli来进行热加载展示:

640 (5).png

此外开发手册提供了基础组件和一些常见功能的代码模板和示例如下:

展示视频1

其中3D模型菜单点击后会进入一个3D模型展示页,是一个经典的Littlest Tokyo的GLB格式的模型。

展示视频2

Doric编程范式

声明式UI

声明式UI在当今移动互联网的背景下,已经有太多的框架都采用了这种范式。和传统的命令式UI不同。声明式UI只描述当前的UI状态,并且不需要关心它是如何过渡的。而命令式UI则需要编程者构建全功能UI实体,然后在UI更改时使用方法对其进行变更。因此采用声明式的UI的益处也显而易见,不需要手动刷新数据,较强兼容性 ,并且可以加速开发 ,一般框架均会提供了很多开箱即用的组件,同时精简代码数量 ,减少缺陷的出现,很多都支持实时预览 ,可以做到真正的所见所即得。Doric本身也提供了声明式的UI编程模式,下面是一个Hello World的代码:

@Entry
export class HelloDoric extends Panel {
  onShow() {
    navbar(this.context).setTitle("Doric HelloWorld");
  }
  build(root: Group) {
    let count = 0;
    let myText: Text;
    vlayout(
      [
        image({
          imageUrl: "https://doric.pub/logo.png",
          onClick: () => {
            myText.text = `${++count}`;
          }
        }),
        myText = text({
          text: "0",
          textSize: 12,
          textColor: Color.RED,
        }),
      ],
      {
        layoutConfig: layoutConfig().fit().configAlignment(Gravity.Center),
        space: 30,
        gravity: Gravity.Center,
      }
    ).in(root);
  }
}

这里可以看出,视图配置一般是不可变的,若一定需要改变UI结构,也可以通过API构造新的子视图树进行替换即可,遵循state改变UI状态这种模式。

TSX

JSX是Javascript和XML结合的一种格式。React发明了JSX,利用HTML语法来创建虚拟DOM。当遇到<,JSX就当HTML解析,遇到{就当JavaScript解析。JSX 只是为React.createElement(component, props, …children) 方法提供的语法糖。React 自创了JSX语法,是一个 JavaScript 的语法扩展,JSX 可以更好的描述 UI 应该呈现出它应有交互的本质形式。在Doric编程中TSX也是支持的,可以参考如下代码示例:

@Entry
export class HelloDoric extends Panel {
  onShow() {
    navbar(this.context).setTitle("Doric HelloWorld");
  }
  build(root: Group) {
    let count = 0;
    let myTextRef = createRef<Text>();

    <VLayout
      parent={root}
      gravity={Gravity.Center}
      space={30}
      layoutConfig={layoutConfig().fit().configAlignment(Gravity.Center)}
    >
      <Image
        imageUrl="https://doric.pub/logo.png"
        onClick={() => {
          myTextRef.current.text = `${++count}`;
        }}
      ></Image>
      <Text ref={myTextRef} text="0" textSize={12} textColor={Color.RED}></Text>
    </VLayout>;
  }
}

通过TSX这种编程模式呢,也可以后期更好结合适配低代码(Low Code)平台

MVVM

Model-View-ViewModel(MVVM)已成为业界数据绑定与Model展示层相结合是非常好的做法,这种模式使得开发人员可以将View和逻辑分离出来,数据绑定技术也非常简单实用。在Doric中也预置了这种编码风格:

首先是Model,View,ViewModel注册关系层:

@Entry
export class CounterPage extends VMPanel<CountModel, CounterView> {
  state = {
    count: 1,
  }
  constructor() {
    super();
    log("Constructor");
  }
  getViewHolderClass() {
    return CounterView;
  }

  getViewModelClass() {
    return CounterVM;
  }

  getState(): CountModel {
    return this.state;
  }
}

接着是Model部分的代码:

interface CountModel {
  count: number;
}

View部分的代码:

class CounterView extends ViewHolder {
  number!: Text;
  counter!: Text;
  build(root: Group) {
    let group = vlayout(
      [
        this.number = text({
          textSize: 40,
          tag: "tvNumber",
        }),

        this.counter = text({
          text: "Click To Count 1",
          textSize: 20,
          tag: "tvCounter",
        }),
      ],
      {
        layoutConfig: layoutConfig().most(),
        gravity: Gravity.Center,
        space: 20,
      }
    ).in(root);
  }
}

最后是ViewMode部分的数据视图关系的代码:

class CounterVM extends ViewModel<CountModel, CounterView> {
  onAttached(s: CountModel, vh: CounterView) {
    vh.counter.onClick = () => {
      this.updateState(state => {
        state.count++
      })
    };
  }
  onBind(s: CountModel, vh: CounterView) {
    vh.number.text = `${s.count}`;
  }
}

MobX

引用一段官方文档对于MobX此框架的描述和MobX的整体运作机制图:作为一个经过战火洗礼的库,它通过透明的函数响应式编程(transparently applying functional reactive programming - TFRP)使得状态管理变得简单和可扩展。MobX背后的哲学很简单: 任何源自应用状态的东西都应该自动地获得。

640 (6).png

在Doric中我们也可以借助MobX来管理我们的UI状态可见如下的ToDo列表的代码示例:

首先我们定义要使用的数据原型:

interface ToDoItem {
  title: string;
  checked: boolean;
  deadline: number;
}

接着通过Decorator标注需要观测的属性和计算值:

class ToDoList {
  constructor() {
    makeObservable(this);
  }

  @observable todos: ToDoItem[] = [];

  @computed get allLength() {
    return this.todos.length;
  }

  @action.bound
  add(todo: ToDoItem) {
    this.todos.push(todo);
  }
}

通过observe相关的数据引发局部UI变更:

export class ToDoListPanel extends Panel {
  todos = new ToDoList();
  build(root: Group) {
    <Stack layoutConfig={layoutConfig().most()} parent={root}>
      {observer(() => (
        <List
          layoutConfig={layoutConfig().most()}
          itemCount={this.todos.allLength}
          renderItem={(idx) =>
            (<ToDoCell item={this.todos.todos[idx]} />) as ListItem
          }
        />
      ))}
      <AddButton
        onClick={async () => {
          const text = await modal(this.context).prompt({
            title: "Please input todo",
          });
          this.todos.add({
            title: text,
            checked: false,
            deadline: 0,
          });
        }}
      />
    </Stack>;
  }
}

Doric整体架构和特点

首先Doric整体API设计的比较贴合原生,对于原生开发者较为友好,上手也会较快。此外Doric本身渲染性能较高,并有完善的周边开发工具箱(Devkit),且采用JS Bundle发布,支持动态化。

640 (7).png

上图为Doric整体的结构图,从图中可以看到,Doric SDK渲染层面抹平了多个平台差异,提供插件能力支持多个扩展能力如网络,路由,组件和APM统计进而支撑起不同的业务。周边的工具链,发布系统,监控服务也可以更好的支撑业务快速接入使用

Doric渲染机制

JS Tree

在Doric JS SDK执行完如下一段声明式UI的代码后,会生成一个相应的JS Tree树结构

vlayout(
  [
    image({
      imageUrl: "https://doric.pub/logo.png",
      onClick: () => {
        myText.text = `${++count}`;
      }
    }),
    myText = text({
      text: "0",
      textSize: 12,
      textColor: Color.RED,
    }),
  ],
  {
    layoutConfig: layoutConfig().fit().configAlignment(Gravity.Center),
    space: 30,
    gravity: Gravity.Center,
  }
).in(root);

JS Tree树结构:

640 (8).png

全量渲染和增量渲染

上述JS Tree会被转化为如下的JSON结构,我们称之为Dirty props(脏属性),这部分结构会被传到原生渲染侧作全量的渲染

640 (9).png

而当image响应了点击后,Doric会找到需要变更的视图:

640 (10).png

进而获得一个增量待变更的Dirty props:

640 (12).png

这样的益处是可以很大程度减小从JS到Native侧的数据传输,同时控制也更为精确。

因为有了JS Tree结构这层,故而Doric实现SSR(Server Side Rendering)服务端渲染会较为容易,当一个Doric组件未采用SSR优化方案时,其执行流程如下图所示:

640 (13).png

Doric通过执行对应组件打包好的Bundle JS生成视图DOM数据进而全量渲染首屏UI,后续则在此基础上做随数据变化的局部更新。

而当采用了首屏SSR优化后,则开始时Doric便可以直接使用SSR做好的视图DOM数据做全量渲染,这对于较为复杂的页面会有很大程度的优化:

640 (14).png

综合如上的Doric渲染机制我们做了如下的性能benchmark,以100个视图平铺为例,分别对比了纯原生,Doric,React Native以及Flutter,在全量和增量渲染耗时,所占用的内存大小以及CPU占用率等4个维度上做了对比,可见Doric是存在一定的性能优势的:

640 (15).png

Devkit工具链

为了方便Doric开发者,Doric提供了完整的开发工具箱(Devkit),此Devkit包含了热加载(Hot Reload)所见即所得,Doric实例的源码查看,实时性能Profiler,以及随时可查看Doric实例内的视图树结构。

640 (16).png

Devkit工具箱菜单

640 (17).png

Doric Profiler

640 (19).png

Doric视图树查看器

此外,通过Doric CLI创建的工程本身即可借助Visual Studio Code来进行断点调试,并不需要安装任何VS Code插件,如下所示:

640 (20).png

2D拓展到3D

Doric原生组件侧支持了类似CSS 2D和3D的动画,这样可以使得原生视图组件可以以动画的形态实现二维或者三维的旋转变换等:

展示视频

但在此基础上我们希望能够进一步支持实时三维绘制,因而,Doric内核侧实现了WebGL的标准协议和一套高效的二进制数据加载和传输机制,其中在WebGL标准实现层做了如下四方面:WebGL API到OpenGL ES API映射,GL层对象和JS侧对象关系管理,GL指令流管理以及AnimationFrame管理。此外还完成了一部分试验性的工作可以将部分GL层的API通过Google ANGLE胶水层将其运行在iOS Metal或者Android的Vulkan之上。

640 (21).png

通过对齐WebGL标准层,一些支持Headless的WebGL的引擎如three.js,babylon.js和PLAYCANVAS等就可以运行在这套标准之上。

WebGL API到OpenGL ES API映射主要是分两部分,一部分是GL的宏常量数值映射,另一方面就是GL API映射,具体可以参考如下链接:

github.com/doric-pub/D…

而GL对象层的管理主要是管理如下几类对象:

  • WebGLBuffer
  • WebGLFramebuffer
  • WebGLRenderbuffer
  • WebGLTexture
  • WebGLQuery
  • WebGLSampler
  • WebGLTransformFeedback
  • WebGLVertexArrayObject

而对于GL层面的指令流处理,Doric会把这些GL指令逐个转化为

std::function<void(void)>

这样的vector结构,当显式调用flush或者WebGL函数需要立即返回时进行批量执行清空存放GL指令的此vector,具体流程可见下图:

640 (22).png

此外对于AnimationFrame的管理,Doric也做到了多个GL上下文进行隔离,通过监控Android Choreographer和iOS CADisplayLink做到VSync同步,同时任意上下文的GL线程倘若过载会传导到其他上下文的GL线程,以防止出现冻屏等问题。

640 (23).png

高效的二进制数据传输

Doric实现了一套自管理的资源体系,在这套体系下,二进制数据(ArrayBufferLike)可以很高效的传输和操作,进而给Doric生态带来了更多的进阶玩法。如下面这个实时图像处理的demo:

展示视频

可以很便捷的对图片进行透明度,二值化以及模糊处理。

此外对于加载3D模型(包含动画)进入场景中也变得较为容易:

展示视频

展示视频

展示视频

展示视频

压缩纹理

Doric也实现了针对于压缩纹理渲染的接口如glCompressedTexImage2D和glCompressedTexSubImage2D,进而可以渲染出dds和ktx等格式的压缩纹理:

展示视频

DDS格式的压缩纹理

展示视频

KTX格式的压缩纹理

Doric也适用于一些小型互动类3D游戏,3D模型加载与互动以及需要播放骨骼动画等业务场景

640 (24).png

3D国际象棋,包含完整的象棋逻辑,包含每种棋子的GLTF模型,源码共计1589行,可参见链接 github.com/doric-pub/D…

640 (25).png

比较经典的一个GLTF模型案例,以HDR作为环境光,模型表面纹理漫反射等,可参见链接 github.com/doric-pub/D…

640 (26).png

无需额外模型资源,纯源码实现几何体和阴影,物理碰撞检测等,源码共计1079行,可参见链接 github.com/doric-pub/D…

640 (27).png

一个流光溢彩的数字藏馆,也是以HDR作为环境光,包含shader后置处理等

640 (28).png

纯WebGL实现Spine动画资源加载和播放,支持JSON和Binary两种格式,支持一些特殊效果如拉伸抖动等,支持动画切换和换肤以及可以通过Debug骨骼脉络展示,可参考链接 github.com/doric-pub/D…

最后,欢迎读者加入Doric社区来一起共建Doric生态:

640 (1).jpeg


wxg.JPG