本文的内容和观点很多来自于 winter 的分享《从spritejs谈UI系统》,文末附有该分享的slide
常见的2D图形(GUI)系统包括UI系统、游戏和可视化系统等等。本文讨论的UI系统是承载用户和计算机进行进行交互的通用图形系统。常见的操作系统都会自带UI系统以便和用户交互,比如Windows、Mac、Linux、Android、IOS等等自带的UI系统。同时也有很多跨平台的UI系统,比如Web浏览器、QT、React Native、Flutter等等。游戏作为另一种常见的图形系统,也具有广泛的影响。游戏的开发往往依赖于游戏引擎,常见的游戏引擎有Unity、Unreal、Godot、Cocos2d、PlayCanvas等等。
可视化系统是一种服务于数据展示分析的图形系统,在发展成熟程度上和UI系统及游戏有一定差距,也因此可视化系统的开发可以从这两种最流行的图形系统中借鉴很多经验。比如游戏引擎往往会包含高性能的渲染引擎,这对于可视化系统也有很多借鉴意义。而UI系统作为最贴近用户的系统,在交互方式上也有很多值得可视化系统学习的地方。本文会拆解UI系统的构建过程,提出一些关于如何在Web浏览器中构建可视化系统的想法,欢迎拍砖。
0. Why:UI系统对于可视化的意义
为了说明UI系统对于可视化的借鉴意义,这里对几种图形系统(UI系统、可视化、游戏引擎)做一个简要的对比:
UI系统 | 可视化 | 游戏(2D) | |
---|---|---|---|
图形 | box model | vector | sprite/raster |
文字 | 强大的文字渲染和排版能力 多语言支持 |
修饰说明,能力弱 | 弱 |
布局 | inline/block/flex… 种类少,通用性强 | 确定性,种类多,领域通用 | 自由 |
动画 | 完整的动画系统,但是使用并不多,作为修饰 | 可能有动画系统,动画要求较高,作为表达手段 | 动画是核心 |
开发者 | 难度低 | 难度高 | 另一种难度 |
成熟度 | 高(大厂支持,战略价值大) | 低,学术研究多 | 高,商业价值大 |
大概总结说明一下:
- 图形:UI系统以矩形盒子来作为基础图形抽象界面元素,方便进行布局;可视化的基础图形则是矢量图形为主;游戏则是以栅格图形居多;
- 文字:UI系统提供了最好的文字渲染和排版能力;可视化和游戏使用文字作为辅助修饰;
- 布局: 有了基础图形之后,就是对这些图形进行组合和布局。从布局的灵活性来讲,UI系统往往提供固定的布局算法/组件,可视化系统也有自己的布局算法,但是往往根据目的不同,算法差异很大;
- 动画:动画在UI系统中往往是辅助修饰作用;在可视化系统可以使用动画作为表达手段;而动画在游戏中居于核心地位;
- 可视化系统 vs UI系统
- 共同点:
- 对2D矢量图形和栅格图形的渲染需求,都是以矢量图形渲染为主,栅格图像为辅助
- 对于布局都有需求,但是具体的算法不太一样,UI一般提供固定的常用布局,可视化系统布局多种多样,一般用户层自己实现
- 差异点:
- 文字渲染和排版能力。UI系统一般有强大的文字渲染和排版,可视化系统对文字排版的需求要低很多
- 共同点:
- 可视化系统与游戏
- 游戏没有布局,基本看做全是动画,可视化对于动画有一定需求
- 游戏更多的是栅格图sprite的渲染,可视化更多是矢量图形渲染
- 对于高渲染性能的需求是相同的
总结: UI系统限制最多,内置的能力最多。系统做的多,开发者需要做的就少,开发速度快。多数由大厂主导。
游戏引擎做的少,开发者的工作更多。可视化系统关注度相对较低,成熟的框架更少。
总体来看,可视化系统和UI系统有很多的相同点的,可视化系统在很多方面可以借鉴UI系统。
1. UI 框架的结构
一般的UI系统的分层结构:
2. 典型 UI 框架分析
下面是对几种典型UI系统的具体分析
2.1 Web浏览器
开发者使用到的浏览器核心能力主要来源于模型层,包括但不限于:
- 盒模型
- DOM
- 文档流
- 布局
- …
2.1.1 基于Web浏览器的可视化
基于Web浏览器的可视化依赖于浏览器提供的绘图API如SVG、canvas2d、WebGL等。这些渲染API为何形式差别如此之大?做可视化时应该如何选择渲染引擎?
其实,这三种API正是浏览器的分层模型的体现。SVG是和HTML类似的语言层DSL;而在模型层SVGOM和DOM是基本一致的;在图形层,DOM这类抽象的树模型被转化为命令式的绘图API,通过Skia(或者其他的2D绘图库)来进行绘制。
Skia是被Chrome、Firefox、Flutter、Android等众多UI系统使用的2D渲染引擎。如果看一下其API,会发现和Canvas的API十分相似。其实Canvas可以看做是浏览器将Skia的部分2D绘图能力暴露出来,提供给JS开发者。再往下深入,Skia要完成绘制任务,也必须依赖于操作系统提供的图形API。 底层的图形API相对变化缓慢不少,早期的图形API主要是OpenGL和Direct3D,近期图形API有了比较大的发展,OpenGL的继任者Vulcan放弃了向后兼容,苹果平台放弃OpenGL,转向自家的Metal。但是不管底层如何变化,Skia基于这些图形库,维护着一个稳定的跨平台高性能2D绘图引擎。
但是对于3D来说情况就不太一样。从OpenGL ES到 WebGL,API还是大致兼容的,浏览器通过引入中间层ANGLE来实现了跨平台的3D API。但是从OpenGL到Vulcan的破坏性更新,导致原有的WebGL也开始过时了。需要有新的兼容Vulcan的web 3D API来发挥出硬件的最强3D性能,这就是WebGPU诞生的原因。
大致可以认为,WebGL在渲染层(渲染层里面最弱的),canvas在绘图层(类似于阉割版的skia),SVG在模型层(和DOM非常类似,只有矢量绘图能力,没有排版能力)
2.2 Flutter
2.3 更多的UI框架
以上的分层模型也可以用来分析其他的UI系统,欢迎尝试,说不定会有意外发现:
- IOS
- Android
- Weex
- Qt
- React Native
- 小程序
3. 模型层:冰山之下
在可视化框架研发的过程中,底层的渲染引擎和上层的DSL设计都是大家的关注重点。SpriteJS的出现引入了新的思路,模型层的重要性被提出来。
3.1 SpriteJS:抽象模型层
SpriteJS是一个支持多种绘图API的可视化库,它提供一个和DOM十分类似的模型层,来屏蔽多种绘图API带来的差异。
浏览器的模型层用C++实现,而SpriteJS的模型层使用JavaScript构建,带来的好处包括:
- 下层抽象渲染引擎,实现跨平台
- 上层抽象组件树模型
- 兼容性DOM API,对接web生态
- 在模型层可以提供丰富的能力
- 图形 shape
- 布局系统 layout
- 样式系统 style
- 动画 animation
- 对接语言层的DSL(react、vue),提供更好的语言层抽象
可以说,构建上层应用需要的一系列重要抽象都位于模型层,使用JS来构建模型层带来了巨大的便利。
3.2 G:更进一步
G作为整个AntV系列产品的渲染引擎,目前正在进行一场大的重构。底层API首次同时支持浏览器的三种渲染API,而在上层设计方面,G也在参考SpriteJS的基础之上,也正在尝试打造更加强大的模型层:
- 基础图形
- DOM API,完全兼容,支持兼容Web的Dom库,比如D3、React等
- 样式系统
- CSS selector 元素选择
- class 样式复用
- 主题机制
- 事件系统:
- 兼容DOM事件API,支持复用Web的手势库
- 布局系统
- 借鉴CSS Layout API,打造支持多种布局的布局引擎
- 组件系统
- 借鉴Web Components,提供原生的组件系统,实现组件复用
- 动画系统
- 借鉴Web Animation
上述每一个子系统都可以单独用一篇文章来讲,有一些还在设计开发阶段,这里不做展开,敬请期待后续。引用一下winter老师在《从spritejs谈UI系统》中的总结:
目前的G也需要更多开发者的加入!
上面的图中还留下一个问号,假设模型层构建完毕,G的语言层该如何设计呢?下一步就会进入语言层这个争议较多的领域,看一下语言层的发展趋势。
4. 语言层的演进:大一统?
4.0 Web & React
Ract的第一个版本发布于2013年,它的出现让之前各种命令式的API一下子变得“面目可憎”,Declarative and Composable UI的理念开始深入人心 ,成为UI语言的大趋势,各种平台竞相模仿。
4.1 IOS/MacOS & SwiftUI
SwiftUI由苹果在2019年发布,是Mac/IOS平台上的新一代UI编程框架,基于Swift编程语言,下面是一段示例代码:
可以看到,声明式UI、单向数据流、组件化等等设计理念和React是非常类似的。
4.2 Flutter
Flutter由谷歌在2017年由谷歌发布的跨平台UI框架,编程语言为Dart,下面是一段示例代码:
// Copyright 2018 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Welcome to Flutter',
home: Scaffold(
appBar: AppBar(
title: const Text('Welcome to Flutter'),
),
body: const Center(
child: Text('Hello World'),
),
),
);
}
}
同样也可以看到,声明式UI、单向数据流、组件化等等设计理念。这篇文章也阐述了其设计理念。
4.3 Android & Jetpack Compose
Jetpack Compose 是Android平台即将发布的下一代UI编程框架,编程语言为Kotlin,下面是一段示例代码:
@Composable
fun NewsStory() {
MaterialTheme {
val typography = MaterialTheme.typography
Column(
modifier = Modifier.padding(16.dp)
) {
Image(
painter = painterResource(R.drawable.header),
contentDescription = null,
modifier = Modifier
.height(180.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(4.dp)),
contentScale = ContentScale.Crop
)
Spacer(Modifier.height(16.dp))
Text(
"A day wandering through the sandhills " +
"in Shark Fin Cove, and a few of the " +
"sights I saw",
style = typography.h6,
maxLines = 2,
overflow = TextOverflow.Ellipsis)
Text("Davenport, California",
style = typography.body2)
Text("December 2018",
style = typography.body2)
}
}
}
同样也可以看到,声明式、函数式的组件和react简直太像。
4.4 visx:expressive, low-level visualization primitives for React
再把视角转换到可视化领域。visx是airbnb开源的基础可视化库,可以看做是React版本的D3,使用声明式的方式提供底层的可视化基本元素。
export default function Example({
width,
height,
events = false,
margin = defaultMargin,
}: BarStackProps) {
const {
tooltipOpen,
tooltipLeft,
tooltipTop,
tooltipData,
hideTooltip,
showTooltip,
} = useTooltip<TooltipData>();
const { containerRef, TooltipInPortal } = useTooltipInPortal({
// TooltipInPortal is rendered in a separate child of <body /> and positioned
// with page coordinates which should be updated on scroll. consider using
// Tooltip or TooltipWithBounds if you don't need to render inside a Portal
scroll: true,
});
if (width < 10) return null;
// bounds
const xMax = width;
const yMax = height - margin.top - 100;
dateScale.rangeRound([0, xMax]);
temperatureScale.range([yMax, 0]);
return width < 10 ? null : (
<div style={{ position: 'relative' }}>
<svg ref={containerRef} width={width} height={height}>
<rect x={0} y={0} width={width} height={height} fill={background} rx={14} />
<Grid
top={margin.top}
left={margin.left}
xScale={dateScale}
yScale={temperatureScale}
width={xMax}
height={yMax}
stroke="black"
strokeOpacity={0.1}
xOffset={dateScale.bandwidth() / 2}
/>
<Group top={margin.top}>
<BarStack<CityTemperature, CityName>
data={data}
keys={keys}
x={getDate}
xScale={dateScale}
yScale={temperatureScale}
color={colorScale}
>
{barStacks =>
barStacks.map(barStack =>
barStack.bars.map(bar => (
<rect
key={`bar-stack-${barStack.index}-${bar.index}`}
x={bar.x}
y={bar.y}
height={bar.height}
width={bar.width}
fill={bar.color}
onClick={() => {
if (events) alert(`clicked: ${JSON.stringify(bar)}`);
}}
onMouseLeave={() => {
tooltipTimeout = window.setTimeout(() => {
hideTooltip();
}, 300);
}}
onMouseMove={event => {
if (tooltipTimeout) clearTimeout(tooltipTimeout);
// TooltipInPortal expects coordinates to be relative to containerRef
// localPoint returns coordinates relative to the nearest SVG, which
// is what containerRef is set to in this example.
const eventSvgCoords = localPoint(event);
const left = bar.x + bar.width / 2;
showTooltip({
tooltipData: bar,
tooltipTop: eventSvgCoords?.y,
tooltipLeft: left,
});
}}
/>
)),
)
}
</BarStack>
</Group>
<AxisBottom
top={yMax + margin.top}
scale={dateScale}
tickFormat={formatDate}
stroke={purple3}
tickStroke={purple3}
tickLabelProps={() => ({
fill: purple3,
fontSize: 11,
textAnchor: 'middle',
})}
/>
</svg>
<div
style={{
position: 'absolute',
top: margin.top / 2 - 10,
width: '100%',
display: 'flex',
justifyContent: 'center',
fontSize: '14px',
}}
>
<LegendOrdinal scale={colorScale} direction="row" labelMargin="0 15px 0 0" />
</div>
{tooltipOpen && tooltipData && (
<TooltipInPortal top={tooltipTop} left={tooltipLeft} style={tooltipStyles}>
<div style={{ color: colorScale(tooltipData.key) }}>
<strong>{tooltipData.key}</strong>
</div>
<div>{tooltipData.bar.data[tooltipData.key]}℉</div>
<div>
<small>{formatDate(getDate(tooltipData.bar.data))}</small>
</div>
</TooltipInPortal>
)}
</div>
);
}
4.5 Chart-parts: expressive,high-level, Grammer of Graphics in React
同时,在更高的抽象维度上,微软也推出了一个使用JSX结合图形语法来声明式的构建chart的库chart-parts,下面是一段示例代码:
export const BarChart = memo(({ data, height, width }) => {
const [hoverIndex, setHoverIndex] = useState()
return (
<Chart height={height} width={width} data={data}>
<LinearScale name="y" domain="data.amount" range="height" zero />
<BandScale name="x" domain="data.category" range="width" padding={0.05} />
<Axis orient="left" scale="y" />
<Axis orient="bottom" scale="x" />
<Rect
table="data"
width={({ xWidth }) => xWidth()}
x={({ d, x }) => x(d.category)}
y={({ d, y }) => y(d.amount)}
y2={({ y }) => y(0)}
onMouseEnter={({ index }) => {
if (hoverIndex !== index) {
setHoverIndex(index)
}
}}
onMouseLeave={({ index }) => {
if (hoverIndex === index) {
setHoverIndex(undefined)
}
}}
fill={({ index }) => (hoverIndex === index ? 'firebrick' : 'steelblue')}
/>
{hoverIndex === undefined ? null : (
<Text
text={({ data }) => data[hoverIndex].amount}
fill="black"
x={({ data, x, xWidth }) =>
x(data[hoverIndex].category) + xWidth() / 2
}
y={({ data, y }) => y(data[hoverIndex].amount) - 3}
baseline="bottom"
align="center"
/>
)}
</Chart>
)
})
4.6 总结
从上面列举的一系列的UI框架,可以看到UI系统的编程范式正在经历一次大的转变。语言层的DSL都在向声明式、组件化转变。在可视化领域,从低抽象的基础组件到高抽象的图形语法,都在向Declarative方向发展。我们有理由相信这一进程还将持续相当长的时间。G的语言层设计也会朝着这个方向前进。