UI 与交互篇(1/6):组件化思路:从页面复制到可复用组件

22 阅读5分钟

组件化思路:从页面复制到可复用组件

系列:UI 与交互篇·第 1/6 篇

做业务 UI 时,最常见的捷径是:需求来了 → 打开旧页面 → 复制一整段 build → 改文案改颜色改接口。短期能交货,两周后同款卡片出现在三个页面,状态各写一份,动一个间距要改三遍,这才是「技术债」的真实利息。

这篇把「复制页面」升级成「可复用组件」的套路讲清楚:什么时候该抽、抽到哪一层、props 怎么定,让后来的需求改在组件上自动传导到所有引用点。


1. 问题背景:业务场景 + 现象

  • 场景:列表卡片、空态占位、带角标的头像区、底部操作条、筛选项一行等,在产品迭代里反复出现变体。
  • 现象
    • 多处 Column/Row 结构几乎相同,只有边距、图标、回调不同。
    • 同一视觉在 A 页用 GestureDetector,B 页用 InkWell,点击态不一致。
    • 「 small 改一下」要 grep 好几个文件,还容易漏改。
    • 新人不敢动大块页面文件,只能继续复制。

目标不是「每个 Widget 都要抽象成库」,而是在重复第三次之前,把稳定结构收拢成组件


2. 原因分析:核心原理 + 排查过程

2.1 Flutter 里「组件」到底是什么

在工程语境下,可复用单元通常是:无业务路由知识、少副作用、通过构造函数明确输入输出的 Widget(+ 可选的 Controller/Style)。页面负责拼装与导航;组件负责一块 UI 的展示与局部交互。

2.2「只复制页面」会坏在哪里

根因表现
没有稳定边界样式、文案、监听散落在页面 State
隐式约定「和首页一样」靠口头,不靠 API
不可组合无法把同一卡片嵌进列表/弹窗/横滑

2.3 自检:该不该抽成组件

满足任意两条就值得抽:

  1. 同一结构在 2+ 页面出现或即将出现。
  2. 有一处 设计规范(圆角、字号、间距)要统一收口。
  3. 需要单独 单测 / Goldens 覆盖一小块 UI。
  4. 同一文件 build 超过 ~150 行且可读性明显下降。

3. 解决方案:方案对比 + 最终选择

3.1 三种常见抽象层级

A. 原子级(Atom)
PrimaryButtonAppAvatar:只解决视觉与交互基线。适合设计系统。

B. 模块级(Molecule / 业务区块)
RoomSeatTileUserStatsHeader:带业务语义,但仍不直连全局单例,通过回调/参数注入。
业务项目里 80% 的「从复制到复用」落在这层。

C. 模板级(Template / Page Section)
Scaffold + AppBar + 统一 padding:少而精,避免过早抽象整页。

3.2 最终选择(推荐口径)

  1. 先抽「结构 + 可变点」:把不变的骨架留在组件内;把文案、资源、回调、bool isEnabled 等放到构造参数。
  2. 样式用 Theme / 设计令牌收口(本篇不深展开,同系列第 4 篇会继续)。抽组件时至少避免魔法数字散落在三个文件。
  3. 禁止组件里 Navigator.push 写死路由名(除非你就是壳工程里的路由封装);改成 onTapVoidCallback? onMore 让页面接线。
  4. 状态策略:展示型用 StatelessWidget + 外部数据;需要动画/展开收起再升 StatefulWidget;和列表滚动强相关再考虑把 Controller 外置。

4. 关键代码:最小必要片段

4.1 反面:页面内联一大段「将来会复制」的 UI

// page_profile.dart(示意:能跑但难复用)
Widget build(BuildContext context) {
  return Column(
    children: [
      Row(
        children: [
          CircleAvatar(radius: 28, backgroundImage: NetworkImage(avatarUrl)),
          const SizedBox(width: 12),
          Expanded(
            child: Column(
              crossAxisConversion: CrossAxisAlignment.start,
              children: [
                Text(nickname, style: Theme.of(context).textTheme.titleMedium),
                Text(bio, style: Theme.of(context).textTheme.bodySmall),
              ],
            ),
          ),
          IconButton(onPressed: onEdit, icon: const Icon(Icons.edit_outlined)),
        ],
      ),
      // ... 下面重复结构又出现在「房间成员列表头」
    ],
  );
}

问题:Row 骨架重复;edit 行为绑死在页面。

4.2 正面:抽出「模块级」组件,显式 API

/// 模块级:ProfileHeaderBar
/// - 不负责拉取头像 URL,只展示
/// - 编辑/更多通过回调交给页面(接线层)
class ProfileHeaderBar extends StatelessWidget {
  const ProfileHeaderBar({
    super.key,
    required this.avatarUrl,
    required this.title,
    this.subtitle,
    this.onEdit,
    this.trailing,
  });

  final String avatarUrl;
  final String title;
  final String? subtitle;
  final VoidCallback? onEdit;
  final Widget? trailing;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Row(
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        CircleAvatar(
          radius: 28,
          backgroundImage: NetworkImage(avatarUrl),
        ),
        const SizedBox(width: 12),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(title, style: theme.textTheme.titleMedium),
              if (subtitle != null)
                Text(subtitle!, style: theme.textTheme.bodySmall),
            ],
          ),
        ),
        if (trailing != null) trailing!,
        if (onEdit != null)
          IconButton(
            onPressed: onEdit,
            icon: const Icon(Icons.edit_outlined),
          ),
      ],
    );
  }
}

页面里只剩「拼数据 + 导航」:

ProfileHeaderBar(
  avatarUrl: user.avatarUrl,
  title: user.nickname,
  subtitle: user.bio,
  onEdit: () => context.push(EditProfileRoute(user.id)),
)

4.3 需要可选变体时:用命名构造或 small API,不要复制类

class ProfileHeaderBar extends StatelessWidget {
  const ProfileHeaderBar.compact({
    super.key,
    required this.avatarUrl,
    required this.title,
    this.subtitle,
    this.onEdit,
  })  : dense = true,
        trailing = null;

  // ...

  final bool dense;
  // dense 为 true 时缩小 avatar、字号 —— 仍是一个组件
}

原则:变体是同一组件的参数组合,而不是再复制一个 ProfileHeaderBar2


5. 效果验证:数据 / 截图 / 日志

工程向的验证可以这样落:

维度做法预期
修改成本改一处圆角/间距,全局引用点同步grep 组件名,引用 ≥2
CR 可读性PR 中页面 diff 主要是数据与回调build 行数下降
一致性设计走查:同组件无不一致点击态统一 InkWell/Material
回归可选:对组件做 Golden test改视觉时有基线对比

6. 可复用结论:通用经验 + 避坑清单

经验:

  1. 第三次重复再抽往往太晚;第二次出现就要评估接口。
  2. 组件 API 要像小函数签名:参数少而准,复杂对象用 ValueKey + 深比较要小心。
  3. 页面是编排(orchestration),组件是表达(presentation)——导航、埋点、权限可在页面层组合。

避坑:

  • Provider/Riverpodref.watch 写进「本应通用的」小组件里,导致任何页面一引用就绑全局。
  • context 滥用:MediaQueryTheme 在子树覆写时行为诡异;需要可考虑把 EdgeInsets 等由上层传入。
  • 一个 500 行的 God Widget:先纵向拆文件,再横向抽模块。
  • 仅为了「看起来优雅」抽原子,却导致 20 个参数;宁可保留一层「业务区块」适度综合。

下期预告:UI 与交互篇(2/6)——复杂列表性能优化(卡顿定位与修复)。