瑞幸 UI 上 pub.dev 了 —— 22 個 Flutter 元件,與微信小程序版雙端對齊

瑞幸 UI 上 pub.dev 了 —— 22 個 Flutter 元件,與微信小程序版雙端對齊

把 DESIGN.md 當作跨端的「單一真相」,一套設計語言同時供給 WeChat 小程序和 Flutter。

效果截圖

瑞幸-fl-首页.png

瑞幸-fl-方案选择.png

瑞幸-fl-等级卡.png

瑞幸-fl-產品.png

瑞幸-fl-左側導航.png

瑞幸-fl-通知.png

瑞幸-fl-網格.png

瑞幸-fl-頭像.png

瑞幸-fl-按鈕.png

背景

之前我寫了《我從瑞幸咖啡小程序裡,拆出了一套 22 個元件的開源 UI 庫》,發布了 npm 套件 lkcn-ui

驗證 DESIGN.md 真的就是「可重用的設計規範」嗎,那它至少應該能驅動兩個不同的執行環境。於是有了這一版:

  • GitHubhttps://github.com/qwfy5287/lkcn-ui-flutter
  • pub.devhttps://pub.dev/packages/lkcn_ui
  • 姊妹專案https://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 件事需要做決定:

1. Design Token:CSS 變數 → Dart const class

小程序版把 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 能提示。

5. Demo 的組織: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 常量完全一致。

LkcnPrice:三段式價格渲染

「符號小 + 整數大 + 小數小」的層次是瑞幸價格的靈魂:

<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 囉嗦,但控制粒度更細。

LkcnMembershipPlan:會員訂閱全流程

方案選擇器 + 訂閱 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

精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝3   💬3   ❤️2
262
🥈
我愛JS
💬1  
5
🥉
Gigi
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登