瑞幸 UI 上 pub.dev 了 ------ 22 个 Flutter 组件,与微信小程序版双端对齐
把 DESIGN.md 当作跨端的"单一真相",一套设计语言同时喂给 WeChat 小程序和 Flutter。
效果截图









背景
之前我写了《我从瑞幸咖啡小程序里,拆出了一套 22 个组件的开源 UI 库》,发布了 npm 包 lkcn-ui。
验证 DESIGN.md 真的是"可复用的设计规范"吗,那它至少应该能驱动两个不同的运行时。于是有了这一版:
- GitHub :
https://github.com/qwfy5287/lkcn-ui-flutter - pub.dev :
https://pub.dev/packages/lkcn_ui - 姊妹项目 :
https://github.com/qwfy5287/lkcn-ui(小程序版)
双端对照
两个仓库,一份 DESIGN.md,相同的 22 个组件:
| 平台 | 包名 | 分发 | 仓库 |
|---|---|---|---|
| 微信小程序 | lkcn-ui |
npm | qwfy5287/lkcn-ui |
| Flutter | lkcn_ui |
pub.dev | qwfy5287/lkcn-ui-flutter |
命名这里踩了个小坑:pub.dev 要求 snake_case,不能用连字符,所以 npm 的 lkcn-ui 到 pub.dev 就成了 lkcn_ui。这是 Dart/Flutter 生态的惯例,不算破坏品牌一致性。
版本号策略是 MAJOR.MINOR 对齐 + PATCH 独立 ------看到 npm 1.2.3 + pub 1.2.1 就知道 API 对齐、只是 Flutter 单独修了两个 bug。
设计语言的「跨端翻译」
如果说小程序版是把 DESIGN.md 翻译成 WXSS + WXML,那 Flutter 版就是翻译成 Dart Widget。这过程有 5 件事需要做决定:
1. Design Token:CSS 变量 → Dart const class
小程序版把 token 写成 CSS 变量,注入到 page {}:
css
page {
--lkcn-blue: #1A6EFF;
--lkcn-radius-md: 24rpx;
}
Flutter 没有 CSS 变量这种运行时机制,但它的类型系统更强。我用 const class 做等价物:
dart
class LkcnColors {
static const Color primary = Color(0xFF002FA7); // 克莱因蓝
static const Color accentOrange = Color(0xFFFF6A3D);
static const Color accentGold = Color(0xFFC9A66B);
}
class LkcnRadius {
static const double md = 12;
static const double pill = 999;
}
使用:
dart
Container(
decoration: BoxDecoration(
color: LkcnColors.primary,
borderRadius: BorderRadius.circular(LkcnRadius.md),
),
)
好处是编译期常量、IDE 自动补全、类型安全;坏处是换肤没办法像 CSS 变量那样"覆盖即生效"------要彻底换肤得上 ThemeExtension。首版先不折腾这个。
2. 单位:rpx → logical pixels
小程序的 rpx 基于 750 设计稿,Flutter 的 logical pixel 是独立密度单位。换算规则就一条:rpx = lpt × 2。
字号 28rpx 对应 14 lpt,间距 24rpx 对应 12 lpt,圆角 16rpx 对应 8 lpt。习惯了之后是肌肉记忆,但第一次做映射表时你会翻 variables.wxss 翻到吐。
3. 组件 API:kebab-case → PascalCase / enum
- 组件类:
lkcn-button→LkcnButton - 枚举属性:
type="primary"→LkcnButtonType.primary - 事件回调:
bind:tap="onClick"→onTap: () {}
Flutter 的 enum 比字符串属性严格得多------如果你传了个不存在的 type 字符串,小程序只会默默 fallback,Flutter 直接编译不过。对库作者是好事。
4. 插槽:<slot> → Widget 参数
小程序靠 <slot> 传子内容,支持具名插槽。Flutter 对应的是具名参数:
dart
LkcnCard(
title: '我的资产',
child: Column(children: [...]), // 主内容
footer: Row(...), // footer 槽
)
一个命名参数 = 一个插槽,清晰、类型安全、IDE 能提示。
5. Demo 的组织:pages/demo-* → example/lib/demos/*
小程序版每个 demo 是独立 page(wxml/wxss/js/json 四件套),通过 pages.json 注册。Flutter 版按 pub.dev 惯例,example/ 是个独立的可运行 app,每个组件对应一个 .dart 文件,用 MaterialPageRoute 跳转:
bash
example/
├── lib/
│ ├── main.dart # 按 原子/交互/容器/业务 分组的索引页
│ └── demos/
│ ├── button_demo.dart
│ ├── product_card_demo.dart
│ └── ... (21 个)
└── pubspec.yaml # path: ../ 引用主包
cd example && flutter run 就能跑,iOS / Android / macOS / Web 四端都能看。这比小程序的"打开微信开发者工具"门槛低多了。
几个还原得比较得意的组件
LkcnStepper:加购从 + 展开到 [-] n [+]
瑞幸菜单页最有辨识度的微交互,Flutter 版用 setState 切两个形态:
dart
LkcnStepper(
value: _quantity,
onChanged: (v) => setState(() => _quantity = v),
)
弹性动画走 LkcnMotion.bounce(即 Cubic(0.34, 1.56, 0.64, 1)),跟 WXSS cubic-bezier 常量完全一致。
LkcnPrice:三段式价格渲染
"符号小 + 整数大 + 小数小"的层次是瑞幸价格的灵魂:
dart
LkcnPrice(value: 9.9, original: 32, prefix: '预估到手')
内部把 9.9 拆成 9 和 .9 两段不同字号,¥ 给第三种字号,原价走 TextDecoration.lineThrough。
LkcnCouponScroll:票据左侧半圆缺口
小程序版靠 CSS clip-path 裁出缺口,Flutter 没有这个 API。我用 CustomPainter 手画 path:
dart
final path = Path()
..moveTo(r, 0)
..lineTo(size.width - r, 0)
// ...
..lineTo(0, size.height * 0.5 + 6)
..arcToPoint( // ← 半圆缺口
Offset(0, size.height * 0.5 - 6),
radius: const Radius.circular(6),
clockwise: false,
)
..close();
最终效果和小程序版几乎一致。CustomPainter 写起来比 CSS clip-path 啰嗦,但控制粒度更细。
LkcnMembershipPlan:会员订阅全流程
方案选择器 + 订阅 CTA + 协议勾选,三件事一个 Widget 解决:
dart
LkcnMembershipPlan(
plans: const [
LkcnPlan(name: '连续包月', price: 9.9, badge: '爆款天天 9.9 起'),
LkcnPlan(name: '月卡', price: 19.9),
],
agreement: '开通会员代表接受',
agreementLinks: const [
LkcnAgreementLink(text: '《会员服务协议》'),
LkcnAgreementLink(text: '《自动续费协议》'),
],
onSubscribe: (plan, agreed) {
// agreed = false 时可以弹 toast 提示勾选
},
)
快速上手
pubspec.yaml:
yaml
dependencies:
lkcn_ui: ^0.1.0
业务代码:
dart
import 'package:flutter/material.dart';
import 'package:lkcn_ui/lkcn_ui.dart';
class MenuPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: LkcnColors.pageBg,
body: ListView(
padding: const EdgeInsets.all(16),
children: [
LkcnProductCard(
image: 'https://.../coconut-latte.png',
title: '生椰拿铁',
tags: const ['全球销量第一', 'IIAC 金奖'],
price: 9.9,
originalPrice: 32,
pricePrefix: '预估到手',
onAdd: () {},
),
const SizedBox(height: 16),
LkcnButton.cta(
text: '立即开通连续包月 ¥9.9',
size: LkcnButtonSize.large,
block: true,
round: true,
onTap: () {},
),
],
),
);
}
}
22 个组件速览
- 原子:Button · Tag · Price · Badge · Avatar
- 交互:SearchBar · Segment · Stepper · Tabs · Tabbar
- 容器:Card · Grid · Swiper · NoticeBar · LocationBar · FloatingButton · CategorySidebar
- 业务:ProductCard · CouponScroll · PromoCard · LevelCard · MembershipPlan
每个的 API 尽量跟 npm 版同名、同语义。小程序那边的 bind:add 事件在 Flutter 是 onAdd,小程序的 custom-class 在 Flutter 通过 child/padding 参数调------这些映射关系看完一遍 README 就能对上号。
一些数据
- 22 个组件,零第三方依赖(只依赖 Flutter SDK)
- 约 3000 行 Dart 代码(不含 example)
flutter analyze/example && flutter analyze均 0 警告 0 错误lib/目录 25 个.dart文件- Dart SDK:
^3.11.3,Flutter:>=3.22.0 - MIT License
跨端维护的几条经验
做完这版 Flutter 之后,最深的感受是:跨端组件库的真正难点不在代码,在保持纪律。
- DESIGN.md 做单一真相:色值 / 间距 / 圆角这些决策写在文档里,而不是写在某一端代码的注释里。PR 有分歧时,以文档为准。
- MAJOR.MINOR 对齐 + PATCH 独立:两端版本号不强求完全一致,但 API 变更要同步发版。
- Issue 加端标签 :
[wx]/[flutter]/[design]三类,避免跨端 issue 混战。 - demo 先行:改组件前先改 demo,再改源码 ------ 这样能强制你想清楚 API 长什么样。
后续计划
- 每个组件写 widget test,提 pub.dev Like / Popularity 评分
-
ThemeExtension版的 Design Token,支持运行时换肤 - 深色模式
- GitHub Actions CI:
analyze+test+ 自动pub publish - VitePress 双端文档站(两端 API 并排展示)
觉得有用的话,欢迎 Star / 试用:
- GitHub:
https://github.com/qwfy5287/lkcn-ui-flutter - pub.dev:
https://pub.dev/packages/lkcn_ui - 小程序版姊妹项目:
https://github.com/qwfy5287/lkcn-ui
🧑💻 顺便求职
目前正在找工作,前端优先,全栈也可以胜任 ,坐标 厦门。
案例集(前端 / 全栈):my.feishu.cn/wiki/XUmGw8...
有合适岗位欢迎评论或私信,感谢。