多端共享组件库的设计哲学、组织方式与业界最佳实践 · 更新于 2026-02-07
Design System(设计系统)不只是一个组件库。它是一套完整的标准体系,包括:
| 层次 | 内容 | 类比 |
|---|---|---|
| 设计原则 | 品牌调性、视觉语言、交互规范 | 宪法 |
| 设计 Token | 颜色、间距、字体、圆角、阴影等具体数值 | 法律条文 |
| 组件库 | 可复用的 UI 组件(按钮、卡片、对话框…) | 执法机构 |
| 文档 & 治理 | 使用指南、贡献规则、版本管理 | 判例与司法解释 |
Design System 的目的不是"让所有页面长一样",而是让一致性成为默认结果,而非额外努力。好的设计系统让开发者"做对的事比做错的事更容易"。
Design Token 是设计系统中最小的原子级决策。它把"设计师说的颜色"翻译成"开发者用的变量":
// 设计师的 Figma 标注
Primary Gold: #C89B3C
Body Spacing: 16px
// 开发者的代码
static const Color brandGold = Color(0xFFC89B3C);
static const double spacingMd = 16.0;
Token 的关键价值:单点修改,全端生效。改一次 tokens.json,Flutter 三端 + Web 端同步更新。
Foundation (日期、字符串、网络等基础工具)
↓
UIKit / SwiftUI (所有 UI 组件,扁平放置)
↓
App 层 (开发者用组件搭建页面)
Text、Button、NavigationSplitView、DatePicker 全部平级放在同一个框架里import SwiftUI,所有组件可用Text 一行代码,NavigationSplitView 几十行配置,但 API 层面不做层级区分.font().padding().background() 链式修饰Apple 认为开发者不应该关心"这个组件在体系中的层级",只需要关心"我需要什么功能"。 Button 和 NavigationBar 对开发者来说都是"一个组件",强制区分原子/分子反而增加认知负担。
这个方法之所以 work,是因为 Apple 只有一个平台方在维护这个体系。当你的团队也很小时,这种扁平结构是最实用的。
| 优点 | 缺点 |
|---|---|
| 认知负担最低 — 不用想"这算原子还是分子" | 组件多了之后难找 — 需要好的搜索和文档 |
| 入门快 — 新人 import 一个包就能开始 | 没有强制的组合规则 — 可能出现不一致的组合方式 |
| 组件间无依赖约束 — 任何组件可以用在任何地方 | 大团队容易各自造轮子 — 缺少层级约束 |
Design Tokens (颜色原语、间距、字体)
↓
Theme System (ColorScheme + ThemeData + ThemeExtension)
↓
Material Components (ElevatedButton, Card, AppBar...)
↓
App 层 (组装页面)
ColorScheme / ThemeData 间接传递ElevatedButton 只知道"我的背景色是 colorScheme.primary",不知道 primary 到底是蓝色还是金色ColorScheme.fromSeed(seedColor: ...),全部组件自动更新FilledButton.containerColor = primary)Reference Tokens (最底层原语)
├── md.ref.palette.primary40 = #6750A4
├── md.ref.palette.neutral90 = #E6E1E5
└── md.ref.typeface.brand = Roboto
↓ 映射
System Tokens (语义化)
├── md.sys.color.primary = md.ref.palette.primary40
├── md.sys.color.on-primary = md.ref.palette.primary100
└── md.sys.typescale.body-large = 16sp / Roboto
↓ 映射
Component Tokens (组件级)
├── md.comp.filled-button.container.color = md.sys.color.primary
├── md.comp.filled-button.label-text.color = md.sys.color.on-primary
└── md.comp.filled-button.container.shape = md.sys.shape.corner.full
这种设计让你可以在不同层级做修改:
| 优点 | 缺点 |
|---|---|
| 主题切换能力强 — 动态换肤/暗色模式开箱即用 | 学习曲线陡峭 — 三层 Token + Theme 系统概念多 |
| 组件与颜色解耦 — 真正的"配置驱动" | 过度设计风险 — 小项目不需要三层 Token |
| Google 官方维护 — 生态完善、文档齐全 | 定制困难 — 想跳出 Material 风格很痛苦 |
Atoms(原子)— 最小的不可再分组件
Button, Input, Label, Icon, Avatar
↓ 组合
Molecules(分子)— 原子的功能组合
SearchBar = Input + Button + Icon
FormField = Label + Input + ErrorText
↓ 组合
Organisms(有机体)— 分子组成的独立区域
Header = Logo + NavBar + SearchBar + Avatar
DataTable = TableHeader + TableRow[] + Pagination
↓ 填入
Templates(模板)— 页面级布局结构(无真实数据)
DashboardLayout = Sidebar + Header + ContentArea
↓ 填入
Pages(页面)— 填充真实数据的最终页面
HomePage = DashboardLayout + 真实用户数据
Brad Frost 受化学启发:就像原子构成分子、分子构成有机体,UI 组件也应该从最小单位开始组合。关键是单向依赖:
| 优点 | 缺点 |
|---|---|
| 强制的组合纪律 — 大团队保持一致性 | 分类争议 — "这是 atom 还是 molecule" 的无尽讨论 |
| 从小到大构建 — 确保基础组件被复用 | 过度工程 — 5 层分级对中小团队太重 |
| 业界知名 — 很多设计师熟悉这套语言 | Flutter 适配差 — Widget 树本身就是组合式的 |
| 维度 | Apple | Material | Atomic |
|---|---|---|---|
| 组件组织 | 扁平,按功能命名 | 按 Material 规范分类 | 5 层严格分级 |
| 样式传递 | Environment / modifier 链 | ThemeData + ColorScheme | 不规定(各自实现) |
| 学习成本 | 🟢 低 | 🟡 中 | 🟡 中(分类争议) |
| 适合团队 | 1-5 人 | 任意规模 | 10+ 人大团队 |
| 主题切换 | 需要自建 | 🟢 开箱即用 | 需要自建 |
| Flutter 适配 | 🟢 天然契合 | 🟢 官方支持 | 🟡 需要适配 |
| 典型代表 | UIKit, SwiftUI | Material, Fluent UI | Shopify Polaris, Atlassian ADG |
Shopify 的开源设计系统,服务于数千名开发者:
@shopify/polaris-tokens — JSON 定义,生成 CSS/JS/Figma@shopify/polaris — React 组件库,扁平结构(不用 Atomic 分层)ali_ui_core,业务组件放 ali_ui_tradeRow, Panel, Toast),而不是技术层级这些公司最终都走向了类似的结构:Token + 扁平组件库 + Theme 系统。Atomic Design 作为设计沟通语言有价值,但作为代码组织方式,成熟的团队往往会简化甚至放弃它。
Flutter 3.0 引入了 ThemeExtension<T>,解决了 Material 的 ColorScheme 不够用的问题。
Material 的 ColorScheme 只有 ~30 个颜色槽位(primary, secondary, surface…)。但实际项目经常需要更多语义颜色:打卡状态色、评分星星色、品牌渐变色… 这些塞不进 ColorScheme。
// 定义自定义 Token 集合
@immutable
class CeloriaBrandColors extends ThemeExtension<CeloriaBrandColors> {
final Color brandPrimary;
final Color brandAccent;
final Color statusClockIn;
final Color statusClockOut;
final Color ratingStar;
const CeloriaBrandColors({
required this.brandPrimary,
required this.brandAccent,
required this.statusClockIn,
required this.statusClockOut,
required this.ratingStar,
});
@override
CeloriaBrandColors copyWith({...}) => CeloriaBrandColors(...);
@override
CeloriaBrandColors lerp(CeloriaBrandColors? other, double t) {
return CeloriaBrandColors(
brandPrimary: Color.lerp(brandPrimary, other?.brandPrimary, t)!,
// ... lerp 支持动画过渡
);
}
}
// 注册到 ThemeData
ThemeData(
extensions: [
CeloriaBrandColors(
brandPrimary: CeloriaTokens.brandGold,
brandAccent: CeloriaTokens.brandCocoaBrown,
statusClockIn: CeloriaTokens.statusClockedIn,
statusClockOut: CeloriaTokens.statusClockedOut,
ratingStar: Color(0xFFFBBF24),
),
],
)
// 组件中使用
final brand = Theme.of(context).extension<CeloriaBrandColors>()!;
Container(color: brand.brandPrimary)
| 能力 | 说明 |
|---|---|
| 类型安全 | 编译时检查,不会拼错属性名 |
| 动画支持 | lerp() 方法让主题切换可以有过渡动画 |
| 多品牌 | Guest/Employee/Kiosk 各注册不同的 extension 实例 |
| 与 Material 共存 | 不替换 ColorScheme,而是补充它 |
结合 Token + ThemeExtension,可以构建一个主题工厂,三端共用:
// celoria_ui/lib/theme/celoria_theme.dart
class CeloriaTheme {
static ThemeData build({
required Color primary,
Color? secondary,
Color? background,
Color? surface,
Color? error,
Color? success,
Brightness brightness = Brightness.light,
}) {
final scheme = ColorScheme.fromSeed(
seedColor: primary,
brightness: brightness,
).copyWith(
primary: primary,
secondary: secondary,
surface: surface ?? CeloriaTokens.neutralSurface,
error: error ?? CeloriaTokens.semanticError,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: background ?? CeloriaTokens.neutralBackground,
extensions: [
CeloriaBrandColors(brandPrimary: primary, ...),
],
// ... 共享的按钮样式、卡片样式、输入框样式
);
}
}
// Guest App
theme: CeloriaTheme.build(
primary: CeloriaTokens.brandGold,
secondary: CeloriaTokens.brandCocoaBrown,
)
// Employee App
theme: CeloriaTheme.build(
primary: CeloriaTokens.brandIndigo,
background: CeloriaEmployeeTokens.neutralBackground,
error: CeloriaEmployeeTokens.semanticError,
)
// Kiosk App
theme: CeloriaTheme.build(
primary: CeloriaTokens.brandTeal,
secondary: CeloriaTokens.brandCoral,
)
class MyButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ElevatedButton(
style: ElevatedButton.styleFrom(
// 组件直接耦合 token 常量
backgroundColor: CeloriaTokens.brandGold,
foregroundColor: CeloriaTokens.textOnPrimary,
),
...
);
}
}
换主题时每个组件都要改
class MyButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 从 Theme 获取,不直接引用 Token
return ElevatedButton(
// 样式由 ThemeData 统一控制
// 组件本身不关心具体颜色
onPressed: onPressed,
child: child,
);
}
}
换主题只需改 ThemeData
| 反模式 | 为什么有害 | 正确做法 |
|---|---|---|
| 在共享库里放业务逻辑 | 共享库应该是纯 UI,混入 API 调用会创造不必要的依赖 | 共享库只负责展示,业务逻辑留在 App 层 |
| 全局静态变量管理主题 | AppTheme.primaryColor 这种静态变量在 Widget rebuild 时不会触发更新 |
通过 Theme.of(context) 获取,确保响应式 |
| 过早抽象 | 一个只用了一次的组件没必要提到共享库 | Rule of Three:至少 2 个 App 都需要时才提取 |
| 不做 breaking change 管理 | 改了共享组件的接口,3 个 App 同时报错 | 语义化版本 + deprecation 标记 + 渐进迁移 |
| 忽略响应式 | 手机上好看的组件在 iPad(Kiosk)上可能太小 | 组件接受尺寸参数,或用 LayoutBuilder 自适应 |
tokens.json (Single Source of Truth)
↓ generate.js
CeloriaTokens / CeloriaGuestTokens / CeloriaEmployeeTokens / CeloriaKioskTokens
↓
celoria_ui/
├── tokens/celoria_tokens.dart # 自动生成,不手动编辑
├── theme/celoria_theme.dart # ThemeData 工厂 (待建)
└── components/ # 扁平组件库,按功能命名
├── loading_indicator.dart
├── error_view.dart
├── empty_state.dart
├── toast.dart
├── selectable_chip.dart # 待建
├── selectable_card.dart # 待建
├── pin_input.dart # 待建
├── settings_menu_item.dart # 待建
└── tappable_contact.dart # 待建
| 决策 | 理由 |
|---|---|
| 扁平不分层 | 1 人团队,10 个组件不需要 atoms/molecules/organisms 三个目录。以后超过 30 个可以再分 |
| 保留 ThemeData | 三端品牌色不同(Gold/Indigo/Teal),ThemeData 工厂是最优解 |
| 分层 Token | Guest 暖色调 vs Employee 冷色调是有意的设计区分,不应强制统一。共享层 + Override 层是正确做法 |
| 组件通过 Theme 取值 | celoria_ui 的共享组件不直接引用 CeloriaTokens.brandGold,而是从 Theme.of(context) 取值,确保在不同 App 里自动适配品牌色 |
| App 层保留自己的 AppTheme | 各 App 的 app_theme.dart 作为"胶水层",调用 CeloriaTheme.build() 并添加 App 特有的样式(如 Kiosk 的大按钮) |
| 组件 | Guest | Employee | Kiosk | celoria_ui |
|---|---|---|---|---|
| LoadingIndicator | ✅ 本地 | Material | Material | ✅ 有但没人用 |
| ErrorView | ✅ 本地 | Material | Material | ✅ 有但没人用 |
| EmptyState | ✅ 本地 | 无 | 无 | ✅ 有但没人用 |
| Toast | ✅ CenteredToast | SnackBar | SnackBar | ✅ 有但没人用 |
| SelectableChip | ✅ 时间/日期 | 无 | 私有 _步骤组件 | ❌ 待建 |
| SelectableCard | ✅ 技师选择 | 无 | 私有 _TouchCard | ❌ 待建 |
| PinInput | 无 | ✅ PinInputWidget | 私有 _NumPad | ❌ 待建 |
| SettingsMenuItem | 无 | ✅ SettingsMenuItem | 无 | ❌ 待建 |
tokens.json → 生成 Dart Token → 三端 app_theme.dart 引用 Token 而不是硬编码 hex 值
CeloriaTheme.build() 工厂方法CeloriaSelectableChip、CeloriaSelectableCardCeloriaPinInputCeloriaSettingsMenuItem设计系统是种出来的,不是设计出来的(grown, not designed)。先解决最痛的重复问题,逐步扩展。追求一步到位的完美设计系统是最常见的失败模式。