30 · 包与插件开发:组件库抽离与发布
本篇解决什么问题: 当同一个组件/工具在 HaierEnergy(
lib/1118 个文件)里被复制粘贴了三遍, 或者你想把沉淀的能力复用到下一个项目时,该怎么抽、怎么组织、怎么发布。⚠️ 易变信息说明:
pub.dev的评分规则、发布流程细节、命令参数会随时间变化, 按本库约定(正文写原理与判断框架)不写死具体版本,请以 pub.dev 官方文档 为准。 本篇讲抽离时机判断、三种形态选择、包设计原则、发布前自检——这些是稳定的。📌 前置:11 篇(组件与主题)、16 篇(工程化)、 19 篇(测试)、21 篇(Platform Channel,插件篇会用到)。
开篇:前端概念对照
| 前端 | Flutter / Dart | 说明 |
|---|---|---|
npm publish | flutter pub publish | 发布到公共仓库 |
package.json | pubspec.yaml | 包清单 |
node_modules | .dart_tool/package_config.json | 依赖解析结果 |
| npm 私有源(Verdaccio / Nexus) | 私有 pub server / git 依赖 | 企业内部包 |
| Monorepo(pnpm workspace) | melos / pub workspace | 多包管理 |
"dependencies" vs "devDependencies" | dependencies vs dev_dependencies | 运行时/开发时依赖 |
npm link(本地联调) | path: 依赖 | 本地路径引用 |
src/index.ts(导出入口) | lib/<包名>.dart(barrel 文件) | 统一导出入口 |
| Storybook | widgetbook / Monarch | 组件预览与走查 |
| Semantic Versioning | Semantic Versioning(强制语义化) | 版本规范 |
关键认知差:
- npm 里
^1.2.3的语义很宽松;Dart 的^1.2.3语义是>=1.2.3 <2.0.0,且 pub 有严格的版本解析与冲突处理 - Dart 生态对语义化版本执行更严格——破坏性变更必须升大版本,否则下游项目会被静默破坏
- 发布到 pub.dev 有自动评分(like/trust/popularity),且发布后不可撤销、只能标记撤回
一、先判断:现在该抽离吗?(最重要的一节)
过早抽离是灾难——抽象错了,比重复三遍更糟。
1.1 抽离的三个信号(满足其一再动手)
| 信号 | 说明 |
|---|---|
| 同一份代码复制了 3 次以上 | "三振出局"原则(Rule of Three):第 3 次复制时再抽 |
| 要跨项目复用 | 下一个项目明确要用 |
| 它是稳定的领域概念 | 有清晰边界、不会每周变 |
1.2 反信号(这些时候别抽)
| 反信号 | 为什么 |
|---|---|
| 只用了 1 次 | 抽了也是"假复用",增加跳转成本 |
| 需求还在剧烈变化 | 抽象会跟着天天改,反而拖慢 |
| 三个"复用点"其实长得不一样 | 强行统一会得到一堆 if 和可选参数 |
| 只是"看起来该抽" | 没有真实第二、第三个使用方,就是过度设计 |
💡 经验法则:先写重复,等到第 3 次再抽。 因为只有见过 3 个真实用例,你才知道哪些该变、哪些该固定—— 第 1、2 次就抽,抽出来的往往是错的抽象。
1.3 抽离粒度:先内后外
第 1 步:先在项目内统一(lib/widgets/ 或 lib/core/)
→ 解决"同一组件有三种样式"的问题,成本最低
第 2 步:确认稳定 + 有第二个使用方 → 抽成独立 package(本地 path 依赖)
第 3 步:确认要公开或跨团队 → 发布(pub.dev 或私有仓库)不要跳步。直接从"项目内复制粘贴"跳到"发布 pub.dev",是新手最常见的错误。
二、三种复用形态(按成本递增)
| 形态 | 适用场景 | 成本 | 独立版本 |
|---|---|---|---|
| ① 项目内目录 | 只在当前项目复用 | 极低 | ❌ |
② 本地 path: 依赖 | 同一仓库多包 / 联调期 | 低 | ✅(独立 pubspec) |
| ③ 发布到仓库 | 跨项目、跨团队、公开 | 高(要维护/发版/写文档) | ✅ |
形态 ①:项目内(先做这个)
结合 11 篇 的组件体系:
lib/
├── widgets/ 通用无状态组件(按钮、输入框、卡片、空状态)
├── theme/ 主题、颜色、字号(与 ThemeExtension 配合)
└── core/ 工具类(日期格式化、校验、日志)要点:组件不要持有业务状态,主题走 ThemeExtension 而非硬编码颜色。 详见 11 篇。
形态 ②:本地 path: 依赖(推荐的中转形态)
# 主项目的 pubspec.yaml
dependencies:
my_ui_kit:
path: ../packages/my_ui_kit # 相对路径优点:
- 有独立的
pubspec.yaml,为将来发布做好了准备 - 改了立即生效,不用发版(联调神器)
- 主项目和包可以分开测试
缺点:路径依赖,别人 clone 你的仓库时目录结构要一致。
💡 这相当于前端的
npm link/ workspace,是抽包过程中最舒服的中间态。
形态 ③:发布
dependencies:
my_ui_kit: ^1.0.0 # 从 pub.dev 拉
# 或私有 git 仓库:
# my_ui_kit:
# git:
# url: https://gitee.com/xxx/my_ui_kit.git
# ref: v1.0.0 # ← 一定要锁定 tag,别用分支三、Package vs Plugin:别搞混
这是最基础但最容易混的概念:
| Package(纯 Dart 包) | Plugin(插件) | |
|---|---|---|
| 是否含原生代码 | ❌ 纯 Dart | ✅ 含 Android/iOS/Web 等平台代码 |
| 依赖 Platform Channel | 不需要 | 需要(见 21 篇) |
| 典型例子 | 组件库、工具类、状态管理库 | 相机、定位、推送、蓝牙 |
| 创建命令 | flutter create --template=package | flutter create --template=plugin |
| 复杂度 | 低 | 高(要写多端原生代码) |
判断口诀:要调用系统能力就必须是 Plugin,否则用 Package。
3.1 联邦插件(Federated Plugin)
当插件要支持多平台时,官方推荐拆成联邦结构:
my_plugin/ ← 主包(app-facing):声明接口 + 平台注册
├── my_plugin_platform_interface/ ← 平台接口层(抽象)
├── my_plugin_android/ ← Android 实现
├── my_plugin_ios/ ← iOS 实现
└── my_plugin_web/ ← Web 实现为什么这么拆:各平台实现可以独立发版,且用户只依赖 my_plugin 主包。
💡 只在要支持多平台且各端差异大时才需要。 单平台或简单插件,一个包写完即可。
四、创建一个 Package(完整流程)
4.1 创建
flutter create --template=package my_ui_kit
# 或指定平台(package 模板也支持)
flutter create --template=package --platforms=android,ios my_ui_kit生成的目录:
my_ui_kit/
├── lib/
│ └── my_ui_kit.dart ← 导出入口(barrel 文件)
├── test/
│ └── my_ui_kit_test.dart
├── pubspec.yaml
├── README.md
├── CHANGELOG.md
├── LICENSE
└── analysis_options.yaml ← lint 规则(重要)4.2 pubspec.yaml 关键字段
name: my_ui_kit # ① 包名:全局唯一,小写+下划线
description: A reusable UI component kit for HaierEnergy apps.
version: 0.0.1 # ② 语义化版本
homepage: https://gitee.com/xxx/my_ui_kit
# repository: https://... # 可选,填了能提分
environment: # ③ SDK 约束(影响下游能否用)
sdk: ^3.4.0
flutter: ">=3.4.0"
dependencies: # ④ 运行时依赖
flutter:
sdk: flutter
dev_dependencies: # ⑤ 开发时依赖(不传给下游)
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
# publish_to: none # ⑥ 私有包:加这行可防止误发布到 pub.dev六个要点:
| # | 要点 |
|---|---|
| ① | name 全局唯一,先去 pub.dev 搜一下有没有重名 |
| ② | 版本必须语义化:MAJOR.MINOR.PATCH,破坏性变更升 MAJOR |
| ③ | environment 的下限越低越好(能兼容更多下游),上限一般不设(避免卡住下游升级) |
| ④ | dependencies 里的东西会传给下游,能不放就不放(依赖越少越受欢迎) |
| ⑤ | 测试/lint 相关放 dev_dependencies |
| ⑥ | 私有包一定要加 publish_to: none,防止误发到公网 |
4.3 导出入口(barrel 文件)
// lib/my_ui_kit.dart
library;
export 'src/buttons/primary_button.dart';
export 'src/inputs/labeled_input.dart';
export 'src/feedback/empty_state.dart';
// ⚠️ 只导出"公开 API",内部实现放 lib/src/ 不导出约定:
lib/src/下的文件是"私有"的(Dart 约定),下游不应直接 import- 只通过
lib/<包名>.dart导出你想公开的 API - 这样内部重构不会破坏下游
五、组件库设计原则(抽包的"内功")
这部分比"怎么发布"更重要——包好不好用,取决于 API 设计。
5.1 五条原则
| 原则 | 说明 | 反例 |
|---|---|---|
| ① 单一职责 | 一个组件只做一件事 | 一个"万能卡片"接受 20 个参数 |
| ② 参数少而正交 | 参数之间不互相影响 | isLarge + size 同时存在 |
| ③ 不持有业务状态 | 组件纯展示,状态由外部传入 | 组件内部直接调接口取数据 |
④ 主题从 ThemeExtension 取 | 不硬编码颜色/字号 | color: Color(0xFF1677FF) |
| ⑤ 可访问性 | 支持 Semantics、足够点击区域 | 图标按钮小于 44×44 |
5.2 关于"配置化"的度(最容易过度设计的地方)
// ❌ 过度配置:参数爆炸,没人记得住怎么用
MyCard(
title: 'xx', subtitle: 'xx', showDivider: true, dividerColor: Colors.grey,
borderRadius: 8, elevation: 2, padding: 16, onTap: ..., trailing: ...,
leading: ..., backgroundColor: ..., showShadow: true, isHighlighted: false,
)
// ✅ 适度:常用参数暴露,细节走主题,特殊需求用组合
MyCard(
title: 'xx',
subtitle: 'xx',
onTap: () {},
child: const CustomContent(), // 特殊需求用 child 组合,而不是加参数
)判断标准:当参数超过 7 个,就该考虑拆组件或用 child 组合了。
5.3 与项目主题体系衔接
结合 11 篇:组件库不要自带一套颜色,而要读 ThemeExtension:
final theme = Theme.of(context);
final ext = theme.extension<AppColors>()!; // 项目的主题扩展
return Container(
color: ext.surface, // ✅ 从主题取
// color: Color(0xFF1677FF), ❌ 硬编码,换主题就废
);好处:组件库在 HaierEnergy 和未来其他项目里,只要换 ThemeExtension 就能换肤。
六、文档与示例(决定别人会不会用)
一个没人会用的包 = 白写。
| 必须项 | 说明 |
|---|---|
| README | 一句话定位 + 安装 + 最小可用示例(复制就能跑) |
| CHANGELOG | 每个版本改了什么(pub.dev 会展示) |
| dartdoc 注释 | 公开 API 都要有 /// 注释(影响评分,也影响 IDE 提示) |
| example/ 目录 | 一个可运行的示例 App(最加分) |
| LICENSE | 开源必填(如 MIT / BSD) |
/// 主按钮组件。
///
/// 用于页面中的主要操作,一屏建议只出现一个。
/// 若 [onPressed] 为 null,按钮显示为禁用态。
///
/// 示例:
/// ```dart
/// PrimaryButton(text: '提交', onPressed: _submit)
/// ```
class PrimaryButton extends StatelessWidget { ... }💡
example/目录很重要:pub.dev 会抓它展示,也是你自测包能否被正常使用的最好方式 (因为 example 是通过path:引用父包,能验证导出是否完整)。
七、发布前自检清单(逐条过)
7.1 代码质量
flutter analyze # ① 静态分析:0 issues
dart format . # ② 格式化(pub.dev 会检查)
flutter test # ③ 测试全过| 检查 | 命令 | 要求 |
|---|---|---|
| 静态分析 | flutter analyze | 0 issues(有 warning 也会扣分) |
| 格式 | dart format --output=none --set-exit-if-changed . | 无变更 |
| 测试 | flutter test | 全过 |
| 文档生成 | dart doc | 无警告(可选但推荐) |
7.2 发布演练(必做)
flutter pub publish --dry-run这个命令会:
- 检查
pubspec.yaml完整性 - 列出将要上传的文件
- 报出所有会阻止发布的问题
常见被拦原因:
| 原因 | 解法 |
|---|---|
缺 LICENSE | 加一个(如 MIT) |
缺 description 或太短 | 写清楚(建议 60~180 字符) |
有 path: 依赖 | 发布前必须改掉(下游拉不到你的本地路径) |
version 已存在 | 升版本号 |
| 含无关大文件 | 用 .pubignore 排除 |
7.3 CHANGELOG 与版本
## 1.1.0
- 新增 `SecondaryButton` 组件
- `PrimaryButton` 支持 `icon` 参数
## 1.0.1
- 修复 `EmptyState` 在深色模式下文字不可见
## 1.0.0
- 首个稳定版本语义化版本规则(Dart 生态执行严格):
| 变更类型 | 版本位 | 例子 |
|---|---|---|
| 破坏性变更(改/删 API) | MAJOR | 1.x.x → 2.0.0 |
| 新增功能(向后兼容) | MINOR | 1.0.x → 1.1.0 |
| 修 bug(向后兼容) | PATCH | 1.0.0 → 1.0.1 |
7.4 正式发布
flutter pub publish # 需要授权(首次会打开浏览器)⚠️ 发布后不可撤销。pub.dev 的政策是只能标记"撤回"(retracted),不能删除。 所以 --dry-run 一定要先跑。
7.5 私有包(不想公开)
| 方案 | 适用 |
|---|---|
publish_to: none + git 依赖 | 小团队、Gitee/GitHub 私有仓库 |
私有 pub server(如 pub_server 包) | 企业级 |
本地 path: | 同一仓库内的多包 |
# 私有 git 依赖(注意锁 tag)
my_ui_kit:
git:
url: https://gitee.com/xxx/my_ui_kit.git
ref: v1.0.0 # ← 锁死版本,别用分支名(会飘)八、HaierEnergy 的抽离建议(落地)
结合项目现状(lib/ 1118 个文件,有 core/ data/ domain/ 等目录,见 07 篇):
抽离优先级
| 优先级 | 内容 | 理由 |
|---|---|---|
| P0 | 通用 UI 组件(按钮/输入框/卡片/空状态/加载) | 复用最频繁、最稳定、视觉一致性收益最大 |
| P1 | 主题与 ThemeExtension | 换肤、多项目一致(见 11 篇) |
| P1 | 纯工具函数(日期、校验、格式化) | 无状态、易测、零依赖 |
| P2 | 网络层封装(Dio 拦截器、错误处理) | 与 09 篇 强相关,但耦合业务较多,要小心 |
| P3 | 业务流程组件 | ⚠️ 不建议抽,耦合业务,抽了反而难改 |
建议路径
1. 先在 lib/widgets/ 统一(形态 ①) ← 现在就做,成本最低
2. 稳定后抽成 packages/my_ui_kit(形态 ② path 依赖)
3. 有第二个项目要用时再考虑发布(形态 ③)⚠️ 不要一开始就追求"完美组件库"。 先在项目内把重复的组件统一掉,这一步的收益已经很大了。
九、动手练习
练习 A:抽一个真实组件(形态 ①)
从 HaierEnergy 里找一个被复制了多次的组件(如"空状态占位"), 统一到 lib/widgets/,要求:
- 颜色从
ThemeExtension取,不硬编码 - 参数不超过 7 个
- 有
///文档注释
练习 B:抽成独立 package(形态 ②)
flutter create --template=package my_ui_kit把练习 A 的组件迁进去,主项目用 path: 引用。 验证:
- 在
example/里写一个 demo App,确认能正常 import 和使用 - 故意漏掉一个
export,观察下游是否报错(体会 barrel 文件的作用) - 加一个
lib/src/下的私有文件,确认下游无法直接 import
练习 C:发布前自检(形态 ③ 演练)
对练习 B 的包执行:
flutter analyze
dart format --output=none --set-exit-if-changed .
flutter test
flutter pub publish --dry-run验证:
- 故意删掉
LICENSE,看--dry-run是否报错 - 故意写一个
path:依赖,看是否被拦 - 加
publish_to: none,确认不会误发到公网
十、自检清单
学完本文,你应该能够:
- [ ] 说出"三振出局"原则,并判断什么时候不该抽离
- [ ] 说出三种复用形态(项目内 /
path:依赖 / 发布)及各自适用场景 - [ ] 区分 Package(纯 Dart)与 Plugin(含原生 + Platform Channel)
- [ ] 说出联邦插件的结构与适用时机
- [ ] 写对一个可发布的
pubspec.yaml(6 个关键字段的含义) - [ ] 解释
lib/src/的作用,以及为什么要用 barrel 文件导出 - [ ] 说出组件库 API 设计的 5 条原则,识别"参数爆炸"的过度设计
- [ ] 说明为什么组件库应该读
ThemeExtension而不是硬编码颜色 - [ ] 列出发布前的完整自检清单(analyze / format / test / dry-run)
- [ ] 解释
--dry-run会检查什么,以及发布后能否撤销 - [ ] 说出语义化版本三类变更对应的版本位
- [ ] 说明私有包如何防止误发布(
publish_to: none) - [ ] 针对 HaierEnergy 给出抽离优先级建议
十一、常见误区
| 误区 | 正解 |
|---|---|
| "组件复用是好事,越早抽越好" | ❌ 过早抽象比重复更糟。等第 3 次复制再抽 |
| "参数越多越灵活" | ❌ 参数 >7 个就是过度设计,改用组合(child) |
| "组件里直接调接口取数据" | ❌ 组件应无状态、纯展示,状态由外部传入 |
| "颜色写死在组件里" | ❌ 走 ThemeExtension,否则换肤即失效 |
| "path 依赖也能发布" | ❌ 发布前必须改成版本依赖,下游拉不到你的本地路径 |
| "发布后可以删掉重发" | ❌ pub.dev 不可删除,只能标记撤回 |
| "1.0.0 之前随便改" | ⚠️ 0.x.x 阶段确实允许破坏性变更,但一旦有下游用户就要守规矩 |
| "私有包不用管发布安全" | ❌ 必须加 publish_to: none,否则手滑就发到公网了 |
| "抽包能提升性能" | ❌ 抽包是为了复用和维护性,不是性能优化手段 |
| "业务流程组件也抽出来" | ⚠️ 耦合业务的组件抽出来反而难改,留在项目内 |
相关文档
- 11 · 组件与主题 ——
ThemeExtension、组件体系(本篇的前置) - 07 · 项目架构总览 ——
lib/目录结构,抽离的起点 - 09 · 数据层链路 —— 网络层(P2 抽离候选)
- 21 · Platform Channel 原生桥接 —— Plugin 开发的必备知识
- 19 · 测试实战 —— 发布前的测试要求
- 16 · 工程化与调试 —— lint、静态分析配置
- 23 · 打包签名与发布上架 —— ⚠️ 那篇是 App 上架,本篇是包发布,别混淆