瑞幸 UI 上 pub.dev 了 —— 22 個 Flutter 元件,與微信小程序版雙端對齊
把 DESIGN.md 當作跨端的「單一真相」,一套設計語言同時供給 WeChat 小程序和 Flutter。









之前我寫了《我從瑞幸咖啡小程序裡,拆出了一套 22 個元件的開源 UI 庫》,發布了 npm 套件 lkcn-ui。
驗證 DESIGN.md 真的就是「可重用的設計規範」嗎,那它至少應該能驅動兩個不同的執行環境。於是有了這一版:
https://github.com/qwfy5287/lkcn-ui-flutterhttps://pub.dev/packages/lkcn_uihttps://github.com/qwfy5287/lkcn-ui(小程序版)兩個倉庫,一份 DESIGN.md,相同的 22 個元件:
平台包名分發倉庫微信小程序lkcn-uinpmqwfy5287/lkcn-uiFlutterlkcn_uipub.devqwfy5287/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 件事需要做決定:
小程序版把 token 寫成 CSS 變數,注入到 page {}:
<div><div><div></div><span>css</span></div><div><div> <span>體驗AI代碼助手</span></div><div> <span>代碼解讀</span></div><div>複製代碼</div></div></div>```
<span>page {</span>
<span> <span>--lkcn-blue</span>: <span>#1A6EFF</span>;</span>
<span> <span>--lkcn-radius-md</span>: <span>24</span>rpx;</span>
<span>}</span>
Flutter 沒有 CSS 變數這種執行期機制,但它的型別系統更強。我用 const class 做等價物:
<div><div><div></div><span>dart</span></div><div><div> <span>體驗AI代碼助手</span></div><div> <span>代碼解讀</span></div><div>複製代碼</div></div></div>```
<span><span><span>class</span> <span>LkcnColors</span> </span>{</span>
<span> <span>static</span> <span>const</span> Color primary = Color(<span>0xFF002FA7</span>); <span>// 克萊因藍</span></span>
<span> <span>static</span> <span>const</span> Color accentOrange = Color(<span>0xFFFF6A3D</span>);</span>
<span> <span>static</span> <span>const</span> Color accentGold = Color(<span>0xFFC9A66B</span>);</span>
<span>}</span>
<span></span>
<span><span><span>class</span> <span>LkcnRadius</span> </span>{</span>
<span> <span>static</span> <span>const</span> <span>double</span> md = <span>12</span>;</span>
<span> <span>static</span> <span>const</span> <span>double</span> pill = <span>999</span>;</span>
<span>}</span>
使用:
<div><div><div></div><span>dart</span></div><div><div> <span>體驗AI代碼助手</span></div><div> <span>代碼解讀</span></div><div>複製代碼</div></div></div>```
<span>Container(</span>
<span> decoration: BoxDecoration(</span>
<span> color: LkcnColors.primary,</span>
<span> borderRadius: BorderRadius.circular(LkcnRadius.md),</span>
<span> ),</span>
<span>)</span>
好處是編譯期常量、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></slot>` → Widget 參數
小程序靠 `<slot></slot>` 傳子內容,支援具名插槽。Flutter 對應的是具名參數:
<div><div><div></div><span>dart</span></div><div><div> <span>體驗AI代碼助手</span></div><div> <span>代碼解讀</span></div><div>複製代碼</div></div></div>```
<span>LkcnCard(</span>
<span> title: <span>'我的資產'</span>,</span>
<span> child: Column(children: [...]), <span>// 主內容</span></span>
<span> footer: Row(...), <span>// footer 槽</span></span>
<span>)</span>
一個命名參數 = 一個插槽,清楚、型別安全、IDE 能提示。
pages/demo-* → example/lib/demos/*小程序版每個 demo 是獨立 page(wxml/wxss/js/json 四件套),透過 pages.json 註冊。Flutter 版按 pub.dev 慣例,example/ 是個獨立的可執行 app,每個元件對應一個 .dart 檔,用 MaterialPageRoute 跳轉:
<div><div><div></div><span>bash</span></div><div><div> <span>體驗AI代碼助手</span></div><div> <span>代碼解讀</span></div><div>複製代碼</div></div></div>```
<span>example/</span>
<span>├── lib/</span>
<span>│ ├── main.dart <span># 按 原子/互動/容器/業務 分組的索引頁</span></span>
<span>│ └── demos/</span>
<span>│ ├── button_demo.dart</span>
<span>│ ├── product_card_demo.dart</span>
<span>│ └── ... (21 個)</span>
<span>└── pubspec.yaml <span># path: ../ 引用主包</span></span>
`cd example && flutter run` 就能跑,iOS / Android / macOS / Web 四端都能看。這比小程序的「打開微信開發者工具」門檻低多了。
### 幾個還原得比較得意的元件
#### LkcnStepper:加購從 `+` 展開到 `[-] n [+]`
瑞幸選單頁最有辨識度的微互動,Flutter 版用 `setState` 切兩個形態:
<div><div><div></div><span>dart</span></div><div><div> <span>體驗AI代碼助手</span></div><div> <span>代碼解讀</span></div><div>複製代碼</div></div></div>```
<span>LkcnStepper(</span>
<span> value: _quantity,</span>
<span> onChanged: (v) => setState(() => _quantity = v),</span>
<span>)</span>
彈性動畫走 LkcnMotion.bounce(即 Cubic(0.34, 1.56, 0.64, 1)),跟 WXSS cubic-bezier 常量完全一致。
「符號小 + 整數大 + 小數小」的層次是瑞幸價格的靈魂:
<div><div><div></div><span>dart</span></div><div><div> <span>體驗AI代碼助手</span></div><div> <span>代碼解讀</span></div><div>複製代碼</div></div></div>```
<span>LkcnPrice(value: <span>9.9</span>, original: <span>32</span>, prefix: <span>'預估到手'</span>)</span>
內部把 `9.9` 拆成 `9` 和 `.9` 兩段不同字級,`¥` 給第三種字級,原價走 `TextDecoration.lineThrough`。
#### LkcnCouponScroll:票據左側半圓缺口
小程序版靠 CSS `clip-path` 裁出缺口,Flutter 沒有這個 API。我用 `CustomPainter` 手畫 path:
<div><div><div></div><span>dart</span></div><div><div> <span>體驗AI代碼助手</span></div><div> <span>代碼解讀</span></div><div>複製代碼</div></div></div>```
<span><span>final</span> path = Path()</span>
<span> ..moveTo(r, <span>0</span>)</span>
<span> ..lineTo(size.width - r, <span>0</span>)</span>
<span> <span>// ...</span></span>
<span> ..lineTo(<span>0</span>, size.height <span>0.5</span> + <span>6</span>)</span>
<span> ..arcToPoint( <span>// ← 半圓缺口</span></span>
<span> Offset(<span>0</span>, size.height <span>0.5</span> - <span>6</span>),</span>
<span> radius: <span>const</span> Radius.circular(<span>6</span>),</span>
<span> clockwise: <span>false</span>,</span>
<span> )</span>
<span> ..close();</span>
最終效果和小程序版幾乎一致。CustomPainter 寫起來比 CSS clip-path 囉嗦,但控制粒度更細。
方案選擇器 + 訂閱 CTA + 協議勾選,三件事一個 Widget 解決:
<div><div><div></div><span>dart</span></div><div><div> <span>體驗AI代碼助手</span></div><div> <span>代碼解讀</span></div><div>複製代碼</div></div></div>```
<span>LkcnMembershipPlan(</span>
<span> plans: <span>const</span> [</span>
<span> LkcnPlan(name: <span>'連續包月'</span>, price: <span>9.9</span>, badge: <span>'爆款天天 9.9 起'</span>),</span>
<span> LkcnPlan(name: <span>'月卡'</span>, price: <span>19.9</span>),</span>
<span> ],</span>
<span> agreement: <span>'開通會員代表接受'</span>,</span>
<span> agreementLinks: <span>const</span> [</span>
<span> LkcnAgreementLink(text: <span>'《會員服務協議》'</span>),</span>
<span> LkcnAgreementLink(text: <span>'《自動續費協議》'</span>),</span>
<span> ],</span>
<span> onSubscribe: (plan, agreed) {</span>
<span> <span>// agreed = false 時可以彈 toast 提示勾選</span></span>
<span> },</span>
<span>)</span>
### 快速上手
`pubspec.yaml`:
<div><div><div></div><span>yaml</span></div><div><div> <span>體驗AI代碼助手</span></div><div> <span>代碼解讀</span></div><div>複製代碼</div></div></div>```
<span><span>dependencies:</span></span>
<span> <span>lkcn_ui:</span> <span>^0.1.0</span></span>
業務代碼:
<div><div><div></div><span>dart</span></div><div><div> <span>體驗AI代碼助手</span></div><div> <span>代碼解讀</span></div><div>複製代碼</div></div></div>```
<span><span>import</span> <span>'package:flutter/material.dart'</span>;</span>
<span><span>import</span> <span>'package:lkcn_ui/lkcn_ui.dart'</span>;</span>
<span></span>
<span><span><span>class</span> <span>MenuPage</span> <span>extends</span> <span>StatelessWidget</span> </span>{</span>
<span> <span>@override</span></span>
<span> Widget build(BuildContext context) {</span>
<span> <span>return</span> Scaffold(</span>
<span> backgroundColor: LkcnColors.pageBg,</span>
<span> body: ListView(</span>
<span> padding: <span>const</span> EdgeInsets.all(<span>16</span>),</span>
<span> children: [</span>
<span> LkcnProductCard(</span>
<span> image: <span>'https://.../coconut-latte.png'</span>,</span>
<span> title: <span>'生椰拿鐵'</span>,</span>
<span> tags: <span>const</span> [<span>'全球銷量第一'</span>, <span>'IIAC 金獎'</span>],</span>
<span> price: <span>9.9</span>,</span>
<span> originalPrice: <span>32</span>,</span>
<span> pricePrefix: <span>'預估到手'</span>,</span>
<span> onAdd: () {},</span>
<span> ),</span>
<span> <span>const</span> SizedBox(height: <span>16</span>),</span>
<span> LkcnButton.cta(</span>
<span> text: <span>'立即開通連續包月 ¥9.9'</span>,</span>
<span> size: LkcnButtonSize.large,</span>
<span> block: <span>true</span>,</span>
<span> round: <span>true</span>,</span>
<span> onTap: () {},</span>
<span> ),</span>
<span> ],</span>
<span> ),</span>
<span> );</span>
<span> }</span>
<span>}</span>
### 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 之後,最深的感受是:**跨端元件庫的真正難點不在程式碼,在保持紀律。**
1. **DESIGN.md 做單一真相**:色值 / 間距 / 圓角這些決策寫在文件裡,而不是寫在某一端程式碼的註解裡。PR 有分歧時,以文件為準。
2. **MAJOR.MINOR 對齊 + PATCH 獨立**:兩端版本號不強求完全一致,但 API 變更要同步發版。
3. **Issue 加端標籤**:`[wx]` / `[flutter]` / `[design]` 三類,避免跨端 issue 混戰。
4. **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…](https://link.juejin.cn?target=https%3A%2F%2Fmy.feishu.cn%2Fwiki%2FXUmGw85iWiCtf1kj7ykcE0WYnBi%3Ffrom%3Dfrom_copylink)
有合適職缺歡迎留言或私訊,感謝。
---
原文出處:https://juejin.cn/post/7629664084891746339