Flutter UI组件化改造技巧
UI组件化核心概念
UI 组件化,笔者理解是指将产品中的UI部分,抽出公共的部分,规范化,易用化,易拓展。
我们会有一些问题,其中首先被问到的就是:
- Material Design提供了一系列组件,他们和UI组件化是什么关系?
第二个紧随起来的问题是:
- UI组件化的难度在于哪里?业务Component算不算UI组件
而后,无法完成UI组件化的制肘:
- 面对前期野蛮生长的项目,后期如何改造?
本文以作者自身的经验出发,尝试解答以上问题。
工程中的粒度问题
任何工程化的项目,都需要一个合适的粒度,例如小型的积木项目,我们在有完整组装图基础上,我们会很容易想到如下流程:
- 拼机身
- 拼飞机的螺旋桨
- 拼尾巴
- 拼底部
这里有一个思维的顺序,从核心(Core)到插件(Plugin),我们也可以从飞机的部件开始, 最后拼成完整乐高飞机。但对一般项目而言,我们大多数情况下都是从核心出发,然后根据需求增加功能,我们后续再讨论。
我们试着将问题复杂化,例如,尝试使用乐高建造机场,其中飞机是其中的一个独立的部分,我们依旧可以使用上述思维过程来建立一步一步的建立我们的机场,但如果我们并没有设计图,或者没有完整的设计图,此时,我们大概率会从选择从核心到插件这个思维过程,这也是一般创业项目优先选择的路径。
此时我们需面一个肯定会有争议的问题,就是UI组件的粒度应该有多大。笔者习惯于将组件划分为如下几个粒度
- Page
- Screen
- Slots
- (Biz)Component
- Desgin UI Kit
- Material Design/其他设计实现
其中,Desgin UI Kit是笔者认为UI组件化最重要的部分,因为他承接了部分业务无关,但通用业务有关的逻辑,同时扩充了Flutter基础组件的能力。
案例:可选择文本
FLutter是一个跨平台的解决方案,我们经常在Web端,文本一般都是可选择的,也就是我们必须使用SelectableText来替代客户端的Text这一基础组件。我们发现Text组件并没有根据平台为我们内部实现为Selectable, 无论是出于历史原因或单一职责的要求,如果我们面对多平台,就需要如下判断:
class BrText extends StatelessWidget {
final String data;
const BrText({super.key, required this.data});
@override
Widget build(BuildContext context) {
if (Platform.isMacOS) {
return SelectableText(data);
}
return Text(data);
}
}
图片加载信息上报
通过Oss获取图片,我们会经常遇到转码操作,转为适合屏幕的尺寸,此时需要记录加载时长,错误等信息,此时我们的基础Image组件不仅仅作为UI展示,同时承担了一些通用的业务逻辑。此时仅仅使用CacheNetworkImage或Image很难解决我们的问题。当然解决方案有很多,我们下例子尝试使用封装自己的组件扩展能力来解决。
class _BrImageState extends State<BrImage> {
final stopWatch = Stopwatch();
@override
void initState() {
super.initState();
stopWatch.start();
}
@override
Widget build(BuildContext context) {
return CachedNetworkImage(
imageUrl: widget.url,
imageBuilder: (context, imageProvider) {
final duration = stopWatch.elapsed;
// report:
return Image(image: imageProvider);
},
errorListener: (value) {
final duration = stopWatch.elapsed;
// report error
},
);
}
}
从以上两个例子来看,我们希望这UI组件这一层可以实现,扩展Flutter提供的基础组件,并提供统一通用逻辑的实现层,额外附加的优势包括但不限于,灵活的主题控制,特殊字体控制等分层带来的好处。
尝试回答第一个问题
- Material Design提供了一系列组件,他们和UI组件化是什么关系?
如图,UI组件化是在基础组件和业务相关组件中间的区域,可以根据定制化程度,向下延展到CustomPaint自定义组件,向上延展到包含一些业务逻辑的实现。我们常说的业务组件,通常是绑定至少一个概念上的对象,例如,我们有一个帖子的Item组件他接受一个固定的PostModel并且操作PostModel, 这种情况下,我们认为帖子的Item组件(PostItemCard)是一个业务组件。相反的,当我们有一个条目UI一致,但接受的参数为(title, desc, datetime, creatorname,), 这种情况,我们可以认为这个是一个业务完全无关的组件。
所以我们认为UI组件化包含,平台要求、通用业务、UI规范,主题,特殊业务的UI扩展包。
- 特殊业务一般指,例如表格组件,分页组件,图表等。他们有自己接受的特有的数据结构,需要外部数据转换为内部数据结构。
尝试回答第二个问题
Component不被归属于本文意义上的UI组件,Component一般作为Screen的组成部分,我们命名Screen时,是带有明确业务属性的,例如LoginScreen, PostDetailScreen, 通常带有很明确的业务属性,Screen作为某一个业务主体的承载,是和业务强相关的(确实存在一些较大的组件也经常被称为Component,他们通常放在单独的Widget包中)。我们一般将复杂的Page拆分为多个Screen和Component,其中Component经常被放在Page包下,这意味着,Component的生命周期一般是小于等于Page的生命周期。
UI组件化的首要困难就是定义一个一致性的概念,并在开发,设计,产品中普及认同这个概念。
- 规范意味着不灵活,也意味着一致性和生产力
项目和团队向商增的方向发展时,中间的痛苦必然会有认知的不统一造成的沟通和交流困难,例如,产品认为某个UI效果已经实现过了,可以复用。但对未组件化的UI可重用性非常差,或者UI本身属于Component,编写时就已经和业务深度绑定,重用非常困难,只能通过复制粘贴的方式创建一个新的业务组件。 UI验收随着项目逐渐扩张,覆盖率也越来越低。
- 概念的统一是沟通的基础
第二个问题是实现时粒度的选择,现在很多项目仍旧使用单体架构,或者因为便利性,将UI组件和业务逻辑并没有区分(这里推荐使用Melos)管理组件化的Flutter项目。单体架构情况下,我们很容易根据当下需求写成业务相关的Component, 而不去填充或扩展UI组件库。从开发人员的角度来看,前期的便利性,是很难被完全自我戒除地,只能通过Code Review的方式来认为规则迫使UI组件化的落地。
- 对齐粒度,并严格检查,可以防止UI组件偏离
尝试回答第三个问题
如果我们从0开始一个项目,且理想情况下,已经有一套简单的基础UI组件或有时间准备一些初始桩位,这种情况下,实施组件化是相对容易的,如果设计也了解Material Design的颜色系统,并且支持或接受这种前期的限制性工作,这件事情基本上就完成了80%,实际上有部分设计同学思路是
- 设计完成后才能给出设计规范和组件化
这和我们小型试错型项目的开发思路一致,实现所有的功能后,再从现有功能抽取出组件,这个思路并不适合中大型项目,这种路径在大型项目中,会导致很多的不确定型,散落的功能,包含特定的业务逻辑,开发者很难简单的完成替换(前期良好的插桩可以减轻这种窘境)。
例如:
图片组件所有的地方都使用了CacheNetworkImage, 我们需呀对这个组件加上一个环绕的Loading组件,此时需要遍历项目当中的CacheNetworkImage, 然后Case By Case的去完成替换。
最坏情况的改造思路
笔者在此提供一个最坏情况的思路,其中最坏包括:
- 没有设计规范
- 没有前期插桩
- 没有遵循Material Design的色值系统
- 使用静态色值/圆角等
- 项目当中存在未归纳的色值和Dimension
- 项目存在非常深的布局嵌套(无拆分)
- etc...
UI组件化并不是开发一个部门的工作,需要设计部门的深度参与和产品部门的最终验收。
- 所以前期的沟通,包括
- 选择合适的时机,这个非常重要,例如,多主题支持,多设备支持,UI开发时间产品侧觉得长且一致性不好。
- 和设计和产品部门,沟通组件化UI的优势,时间优势,一致性优势,拓展优势等。
- 内部统一概念和开发规范,选择粒度并对齐,建立组件填充流程,保证组件质量。
- 开发端前期工作
- 归纳,归纳顺序为,色值、圆角、尺寸、基础组件Button等。目的是减少散落的色值等静态值,例如颜色相近取其中一个,大小7和8,不影响UI的情况下,取偶数值等。
- 规整,将归纳的结果,向Material Design的色值和主题系统靠拢,如果不想依赖
Material Design则需要编写自己的Theme系统。目的建立良好的视觉基础,例如,Primary和OnPrimary, Backgroud和OnBackground, DividerColor,建议但不强制的是色值派生关系,也就是从主色值派生出其他色值的关系。这有助于在 Android 13系统上应用动态主题色。 - 动态化,将静态依赖色值等,或方法依赖大小等转变为依赖Theme和Context绑定,除了色值,尺寸,圆角等也可以放大ThemeExtension, 当然还有别的解决方案。
- 开发规范的建立
- 独立的UI package, 帮助强制隔离业务相关的可能性,保证组件和业务无关可重用和维护。
- 建立Code Review,对组件库的新增和修改,需要测试和团队公示。
- 建立example项目,方便设计验收和测试。
- 使用并扩充UI组件
- 能用组件尽量使用组件解决,尽量避免使用原生的Text, Image等组件。
- 扩展组件可以通过参数,也可以通过独立的Widget,例如CustomImage可以加, type: s, m, l, 也可以通过CustomImageS, CustomImageM, CustomImageL这种方式来解决,没有特殊的偏好,一般而言,根据参数的复杂性和概念的独立性做权衡。
- 保持组件的单一化职责,扩充UI组件库和扩展UI组件需要经验上的权衡。
小Tip, 为何需要前期冗余的插桩
计算机行业有一个,或者延展到工程架构,大部分情况下都可以通过加一层解决绝大数问题,对于UI组件的插桩,假如我们有基础组件的的替身例如:
class BText extends StatelessWidget {
const BText({super.key, ...}); // Text全部或部分参数
@override
Widget build(BuildContext context) {
return Text(
....// 填充参数
);
}
}
当我们需要扩展是,因为所有的使用都是依赖于BText,我们可以对他重命名,对内部进行不同平台的特点实现,甚至根据不同屏幕尺寸调整字体缩放或颜色等。都是统一修改的,当需要扩展时,我们也可以通过新增类型参数,扩充不同的类型,多样化的实现。但如果使用系统的Text+Style难度就会麻烦很多,也更容易出错。
改造总结:
一个从0开始的Flutter项目,前期都是野蛮生长的,有经验的开发工程师会前期预埋点,通过不复杂的买点来规避将来可能遇到的风险。这其实是架构设计中,权衡(trade off)的一部分。前期过早的提出组件化和应用组件化,设计大概率是排斥的,因为设计风格会被制肘,但从开发侧需要进行一些冗余的操作,提前应对这可能到来的变化,之后多项目研发可以快速扩展,适应快速接入新的App。
- 架构设计的主要工作,就是权衡冗余
项目管理
野蛮增长阶段
前期一切不固定的情况下,大部分团队会选择野蛮增长,由于团队新建立,缺少规范等原因,会经常缺少UI组件化中非常重要的一环,我不确定有没有更专业的名词来表示这个概念,笔者借用热修复中的概念,称之为插桩,或者预留,这有助于帮助后期的组件化进程。
功能开始耦合阶段
项目逐渐生长,其中功能的扩展,产品侧会倾向于和现有App模块之间交互,或不可避免的被动交互,此时功能模块化、组件化会被提上日程,模块化如果是单体项目,仍旧可以简单支撑,但组件化,也就是分package的方式,此时UI公用组件库,来保证一致性就会被提出。但这个阶段仍处于项目高速发展阶段,仍旧以功能为主
稳定迭代阶段
项目进入稳定阶段,就开始对旧的模块进行逐渐的改版,会有V1,V2类似的概念,UI设计的V1, V2, 此时如果没有前期UI组件化和良好的拆分设计,组件化改造是非常难的,开发者会面临极大的不确定性和开发难度,很难在一个迭代周期完成这项工作。
迭代示意图:
版本管理
如上图所示,归纳阶段是极其重要的,它起到了承上启下的作用,我们可以通过若干个版本完成归纳工作。