Flutter UI组件化改造技巧

600 阅读12分钟

Flutter UI组件化改造技巧

UI组件化核心概念

UI 组件化,笔者理解是指将产品中的UI部分,抽出公共的部分,规范化,易用化,易拓展。

我们会有一些问题,其中首先被问到的就是:

  1. Material Design提供了一系列组件,他们和UI组件化是什么关系?

第二个紧随起来的问题是:

  1. UI组件化的难度在于哪里?业务Component算不算UI组件

而后,无法完成UI组件化的制肘:

  1. 面对前期野蛮生长的项目,后期如何改造?

本文以作者自身的经验出发,尝试解答以上问题。

工程中的粒度问题

任何工程化的项目,都需要一个合适的粒度,例如小型的积木项目,我们在有完整组装图基础上,我们会很容易想到如下流程:

leao.jpg
  1. 拼机身
  2. 拼飞机的螺旋桨
  3. 拼尾巴
  4. 拼底部

这里有一个思维的顺序,从核心(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展示,同时承担了一些通用的业务逻辑。此时仅仅使用CacheNetworkImageImage很难解决我们的问题。当然解决方案有很多,我们下例子尝试使用封装自己的组件扩展能力来解决。

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提供的基础组件,并提供统一通用逻辑的实现层,额外附加的优势包括但不限于,灵活的主题控制,特殊字体控制等分层带来的好处。

尝试回答第一个问题

  1. Material Design提供了一系列组件,他们和UI组件化是什么关系?

ui_level.png

如图,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的去完成替换。

最坏情况的改造思路

笔者在此提供一个最坏情况的思路,其中最坏包括:

  1. 没有设计规范
  2. 没有前期插桩
  3. 没有遵循Material Design的色值系统
  4. 使用静态色值/圆角等
  5. 项目当中存在未归纳的色值和Dimension
  6. 项目存在非常深的布局嵌套(无拆分)
  7. etc...

UI组件化并不是开发一个部门的工作,需要设计部门的深度参与和产品部门的最终验收。

  1. 所以前期的沟通,包括
  • 选择合适的时机,这个非常重要,例如,多主题支持,多设备支持,UI开发时间产品侧觉得长且一致性不好。
  • 和设计和产品部门,沟通组件化UI的优势,时间优势,一致性优势,拓展优势等。
  • 内部统一概念和开发规范,选择粒度并对齐,建立组件填充流程,保证组件质量。
  1. 开发端前期工作
  • 归纳,归纳顺序为,色值、圆角、尺寸、基础组件Button等。目的是减少散落的色值等静态值,例如颜色相近取其中一个,大小7和8,不影响UI的情况下,取偶数值等。
  • 规整,将归纳的结果,向Material Design的色值和主题系统靠拢,如果不想依赖Material Design则需要编写自己的Theme系统。目的建立良好的视觉基础,例如,Primary和OnPrimary, Backgroud和OnBackground, DividerColor,建议但不强制的是色值派生关系,也就是从主色值派生出其他色值的关系。这有助于在 Android 13系统上应用动态主题色。
  • 动态化,将静态依赖色值等,或方法依赖大小等转变为依赖Theme和Context绑定,除了色值,尺寸,圆角等也可以放大ThemeExtension, 当然还有别的解决方案。
  1. 开发规范的建立
  • 独立的UI package, 帮助强制隔离业务相关的可能性,保证组件和业务无关可重用和维护。
  • 建立Code Review,对组件库的新增和修改,需要测试和团队公示。
  • 建立example项目,方便设计验收和测试。
  1. 使用并扩充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组件化和良好的拆分设计,组件化改造是非常难的,开发者会面临极大的不确定性和开发难度,很难在一个迭代周期完成这项工作。

迭代示意图:

ui_step.png

版本管理

如上图所示,归纳阶段是极其重要的,它起到了承上启下的作用,我们可以通过若干个版本完成归纳工作。