一、概述
最近在做一个项目,把小程序的视图层移植到native端做渲染。
大家都知道小程序的逻辑层和视图层是分离的,视图层不执行业务逻辑,只负责呈现结果,所以很适合做这样的改造尝试。我们选用的Native框架是Flutter,它可以在安卓ios双端跨平台运行。
但是,移植并不简单,因为Flutter和传统web渲染从语法到原理上都有差异,简单分析一下。
传统Web方案,通过dom+css样式表来定义一个界面,dom提供结构,css规定样式。一个UI界面的结构类似下图。
但在Flutter里,一切都是widget,这是它的核心思想,用widget来构建UI。Widget既可以是结构,也可以是单纯的样式描述。所以一个UI界面的结构,是下图的样子。
Flutter的设计思路是比较先进的,借鉴了react组件化的思想,用Widget来管理所有的状态,监听Widget的变化,并通过比较来确定底层渲染树从一个状态转换到下一个状态所需的最小更改。尽可能减少渲染消耗。
而且Flutter框架提供了大量现有的widget可供复用,除了基础的文字,图标,还包括滚动列表,顶栏底栏,标准化表单等等,涵盖了视觉、结构、平台和交互,开发者可以像搭积木一样,快速创建一个标准化的应用程序。
这对于从零开始开发是非常方便的。但对于我们的移植工作,反倒成了阻碍。
二、从Web到Native
小程序是采用xml
dom+wxss来定义UI界面的,它本质上只是在标准dom和css上包装了一层,翻译起来非常直观。但它们和Flutter的Widget,就差得很远了。
我们希望能够建立一种机制,尽可能无缝把css样式和dom标签转换成flutter的Widget树,一个直观的思路是,首先选择容器类的Widget来取代xml,然后选择样式类的Widget来取代css。但如果只是僵硬地,把它们对应起来,会产生很大的差异,举个栗子。
<style>
.outer{
width:30px;
height:60px;
background:red;
}
.inner{
width:500px;
height:25px;
background:yellow;
opacity:0.5;
}
</style>
<div class="outer">
<div class="inner">我是一段测试文本</div>
</div>
在xml里我们定义了父子节点,然后给内层赋予比外层更宽更高的样式属性。
在Flutter里我们用container来代替div,建立一个widget
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(
body: Center(
child: Container(
color: Colors.red,
width: 30,
height: 60,
child: Container(
color: Colors.yellow,
width: 500,
height: 25,
child: Text("我是一个测试用的行")),
)
)
),
);
}
}
各自跑一下渲染结果。
我们可以看到,xml里内层会把外层撑开,但在Flutter里,内层被强行圈定在了外层的约束内。
当然,上面的用法本身不太规范,如果换一个更常规的例子呢。
我们在外层定义一个拥有最大最小宽度的容器,内层放一个更小的容器。
<style>
.outer{
min-width:100px;
min-height:80px;
max-width:150px;
max-height:150px;
background:yellow;
}
.inner{
width:10px;
height:10px;
background:red;
}
</style>
<div class="outer">
<div class="inner"></div>
</div>
在Flutter里,我们用ConstrainedBox来容纳最大最小宽度的约束。
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Container(
color: Colors.yellow,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: 100,
minHeight: 80,
maxWidth: 150,
maxHeight: 150,
),
child: Container(color: Colors.red, width: 10, height: 10),
)
)
);
}
}
跑一下。
可以看到Flutter里,由于约束的存在,内层被强行拉大成了外层的尺寸,产生了怪异的结果。
上面的例子告诉我们,强转不可取。要说明差异产生的原因,需要从二者的布局原理来探究。
三、传统Web里的Layout
首先我们需要理解如何渲染。
一个UI界面,最终呈现给用户的都是像素点,而浏览过网页的人都知道,一个页面里元素,都是容纳在大大小小的盒子里的,我们叫它盒模型。
在书写xml的过程中,我们可以很直观地得到一颗dom树,但为了把它渲染出来,需要知道每个盒子在屏幕上的位置坐标,以及它的长宽高,颜色信息等等。
但是我们在书写css的时候,很显然不会把这些东西都写出来,我们会用display,margin,padding,top,left等等定位属性,来间接地规定它们,因为这样更符合人类的直观感受。浏览器在读到样式表后,需要把它们转换成真实渲染用的信息,这个过程就叫做layout,排版,(FF浏览器里也叫reflow)。
传统浏览器的layout过程,类似于下图,首先解析出一棵DOM 树,和CSSOM进行合并,变成渲染树,输出给绘制流程。
这个计算的过程非常复杂,不同的浏览器内核有不同的实现,以Chrome为例,使用了Blink作为布局引擎,而其中布局相关的代码非常古老,布局规则也多种多样,目前存在大量的web标准,并且还在不断添加更多新的标准,很多兼容性的问题也都来自于此。
总体而言,它的计算过程是递归的,父元素计算好自己的坐标,再传给子元素,子元素计算好之后会返回父元素是否需要重新layout,过程中可能会出现反复修正。耗时也比较高。
我们提倡使用更新的标准,比如flex来代替传统的float定位,也是因为这些新标准的布局策略更先进,运行在浏览器里,效率更高的缘故。
四、Flutter的Layout
Flutter的Layout实现比浏览器优雅得多,但对Web开发人员相对陌生,有学习成本。我们学习Flutter布局的时候,都会在官方文档里看到下面的口诀。
Constraints go down. Sizes go up. Parent sets position.
加入自己的理解,我把它解释成:
父组件向下传递约束,子组件向上申报尺寸,最终由父组件决定子组件的显示位置。
字面上有点晦涩,我们用官方提供的例子来说明。
官方只提供了一张图,我们尝试在dart pad里还原一下代码,这样更直观。
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.grey,
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: 80,
minHeight: 60,
maxWidth: 300,
maxHeight: 85,
),
child: Container(
color: Colors.yellow,
padding: const EdgeInsets.all(5.0),
child: Column(children: [
Container(
color: Colors.purple,
width: 290,
height: 20,
),
Container(
color: Colors.green,
width: 140,
height: 30,
)
])),
)));
}
}
下面我们来说明布局的过程,为了说明方便给每个widget起一个名字。
小黄(Container Widget)被小中(center widget)包裹,放置于灰色的画布中央,并且受到小中定义的BoxConstraints的约束,范围是80-300宽,60-85高。
同时小黄拥有两个子元素,分别是小紫(Container Widget,290×20),和小绿(Container Widget,140×30)。
布局计算可以用下面的过程概括:
1.小黄询问父元素(小中),我有什么约束?
2.小中回答,约束是80-300宽,60-85高。(Constraints go down)
3.小黄没有定义自身的尺寸,但它拥有5像素的padding,于是它决定它对子元素的约束变成,宽度不超过290,高度不超过75。
6.下面轮到小紫了。小紫定义了自身尺寸,于是向上申报,希望能够拥有290×20的尺寸。(Sizes go up)
6.小黄判断这个尺寸没有超过自己的约束,于是把20像素从自己的约束中减去,更新了约束数据。
7.下面轮到小绿了。小绿定义了自身尺寸,于是向上申报,希望能够拥有140×30的尺寸。(Sizes go up)
8.小黄判断这个尺寸没有超过约束,至此子元素已经遍历完毕,于是小黄先后计算出小紫的坐标是 x: 5 y: 5,小绿的坐标是x: 80 y: 25。(Parent sets position)
9.小黄向小中申报自己的尺寸300×60。(Sizes go up)
10.小中计算出小黄的坐标(屏幕中央偏移x:-150 y:-30)(Parent sets position)
从上面的例子,我们可以看出,和blink的递归计算相比,Flutter的Layout计算策略非常简洁,只进行一次扫描,先向下(传递约束),后向上(申报尺寸),就可以计算出每个Widget布局结果。
我们再返回去看看拿第一节里的栗子。
栗子一,父组件是一个红色的,30×60的元素,它的约束会传递到下层。所以尽管子元素申报了一个更大的尺寸,但父元素计算时,仍会把它框定在约束范围内,尽可能满足之,所以我们看到红色区域被黄色填满,Text里的内容也只能在这个区域里排布了。
栗子二,父元素规定了一个ConstrainedBox,约束了最大最小宽高,尽管子元素向上申报了自己的size,但它最后还是被父元素约束在了最大最小宽高的范围内,尽可能满足之,所以它就变成了父元素的minWidth×minHeight。
所以要理解Flutter的布局结果,关键是理解每个Widght的约束规则。一旦理解规则,所有结果都会变得非常直观。
反观css的layout,由于递归计算过程的存在,给了外层元素一个修正自己布局的机会,所以css使用起来更自由,纠错性更强,当然结果也变得更加难以归纳和预测。
五、结论
说了半天,从web到flutter到底要怎么转?
看来并没有完美的无缝转换方案,只有凭借经验性的方式做一个映射,同时给出更加规范的css编写方式,保证一些约束的存在。从而保证转换后的布局结果符合预期。
具体的方案还在探索中,to be continued。
参考资料
1. Flutter中文开发文档 https://flutter.cn/docs
2. Google Web开发指引 https://developers.google.com/web/fundamentals/performance/rendering?hl=zh-cn