Dart 工程化 03:包发布与私有仓库

C 世界里共享代码靠 .a/.so 加头文件,Dart 世界里共享代码靠 package。把一个库发到 pub.dev,意味着全球开发者一条 dart pub add 就能用;把包发到私有仓库,则是团队内部复用代码的正规军。本章覆盖从「单包发布」到「多包仓库」再到「私有 pub 服务」的完整链路。

前置知识:11 包管理与模块(pubspec、依赖语法)、01 项目架构与规范(版本与目录约定)。


一、package 与 application

pubspec.yaml 里的 publish_to: none 不是装饰,它决定这个工程是「被依赖的库」还是「最终交付的应用」。

维度package(库)application(应用)
入口lib/<name>.dart 导出公共 APIbin/lib/main.dart 或 Flutter 入口
能否被依赖可以,pub 生态一等公民一般不被依赖(publish_to: none
版本管理必须遵循语义化版本版本号只用于自身发布
API 稳定性公共 API 是合同,破坏性变更要升 MAJOR内部随意重构
测试重点公共 API 行为、向后兼容端到端功能
典型结构lib/ + test/ + example/完整应用目录 + 平台工程
# 应用:禁止发布
name: my_app
publish_to: none
# 库:准备发布
name: my_utils
description: 一组零依赖的 Dart 工具函数,适用于 CLI 与服务端。
version: 1.0.0
repository: https://github.com/your-org/my_utils
environment:
  sdk: ^3.4.0

判断标准:这个工程会被别的工程 import?会,就当 package 设计;不会,就是 application,不必为 API 兼容性背包袱。


二、发布到 pub.dev 完整流程

2.1 发布前检查清单

要求
name全小写加下划线,pub.dev 上全局唯一
description60-180 字符,说明用途而非罗列功能
version合法语义化版本,且未在 pub.dev 发布过
LICENSE必须有,常见 MIT/BSD-3/Apache-2.0
CHANGELOG.md按版本倒序记录变更,格式见 2.3
README.md安装、最小示例、API 概览
example/可运行示例,pub.dev 会渲染其代码
test/公共 API 的测试覆盖
文档注释所有公开 API 有 /// 注释

2.2 发布流程

dart pub publish --dry-run     # 只检查不发布:列出将上传的文件与全部警告
dart pub publish               # 真正发布,会要求二次确认

--dry-run 的输出必须逐条处理:警告(warning)会阻止发布,提示(hint)虽不阻止但应尽量消除。常见警告:缺少 LICENSE、README 过长代码块、未提交的生成文件、依赖版本范围过宽。

# 用 pana 在本地做与 pub.dev 相同的评分检查
dart pub global activate pana
dart pub global run pana .     # 输出分数与问题列表,目标 130/130

2.3 CHANGELOG 与版本

<!-- CHANGELOG.md -->
## 1.1.0
- 新增 `retry` 工具函数,支持指数退避。
- `HttpClient` 增加超时参数(向后兼容)。
 
## 1.0.1
- 修复 `parseDuration` 对 "1h30m" 的解析错误。
 
## 1.0.0
- 首个稳定版本。

2.4 发布验证

dart pub publish --dry-run                       # 本地验证
dart pub add my_utils --dry-run                  # 在另一个工程验证可解析
# 发布后在干净容器里验证:
docker run --rm dart:stable dart pub global activate my_utils

发布后的包不可删除(只能标记 discontinued),因此 dry-run 与版本号检查必须成为肌肉记忆


三、语义化版本与破坏性变更

MAJOR.MINOR.PATCH 在 Dart 生态里有额外含义:Dart 没有命名空间,用户升级 minor 也可能拿到新 API,因此 API 设计要更保守。

变更类型版本位示例
删除/重命名公开 API、改签名、改行为MAJORfetch(url) 改为 fetch(Uri)
新增公开 API、新增可选参数、放宽约束MINOR新增 fetchWithRetry
修 bug、改文档、性能优化PATCH修复空列表崩溃
仅改内部实现PATCH重构私有类

破坏性变更的处理策略:

  1. 废弃而非删除:先标 @Deprecated('Use fetchWithRetry instead. Will be removed in 3.0.0'),至少保留一个 MAJOR 周期;
  2. 迁移指南:在 CHANGELOG 与 README 写清「旧写法 → 新写法」;
  3. 依赖约束要诚实dependencies: foo: ^2.0.0 表示允许 2.x 任意版本,若你的包依赖了 2.3 才有的 API,写 >=2.3.0 <3.0.0
@Deprecated('将在 3.0.0 移除,改用 fetchWithRetry')
Future<String> fetch(String url) => fetchWithRetry(Uri.parse(url));

四、多包仓库管理:melos

4.1 什么时候需要 monorepo

一个 SDK 拆成 corenetworkui 三个包,它们版本互相依赖、需要一起测试——用三个 Git 仓库管理会陷入「改 A 要发版才能让 B 用」的循环。melos 是 Dart 生态的 monorepo 工具,解决四件事:本地互相依赖、统一版本、批量执行命令、按依赖顺序发布。

4.2 工程结构

my_sdk/
  melos.yaml
  pubspec.yaml            # workspace 根(Dart 3.6+ 可直接用 pub workspaces)
  packages/
    core/                 # 基础类型与工具
      pubspec.yaml
    network/              # 依赖 core
      pubspec.yaml
    ui/                   # 依赖 core + network
      pubspec.yaml
  scripts/
    analyze.sh
# melos.yaml
name: my_sdk
packages:
  - packages/*
command:
  bootstrap:
    runPubGetInParallel: true
  version:
    # 版本号统一写入各包 pubspec,并在 CHANGELOG 顶部插入条目
    linkToCommits: true

4.3 melos 工作流

flowchart LR
    A["melos bootstrap<br/>本地互相 link"] --> B["开发与测试<br/>melos run test"]
    B --> C["melos version<br/>统一改版本 + CHANGELOG"]
    C --> D["melos publish<br/>按依赖拓扑顺序发布"]
    D --> E["git tag + push<br/>记录发布点"]
dart pub global activate melos
melos bootstrap                 # 解析全部包并建立本地依赖链接
melos run analyze               # 在 melos.yaml 定义的自定义脚本
melos version --yes             # 按变更自动决定版本位并写入各包
melos publish --no-dry-run      # 按依赖顺序发布到 pub.dev

melos version 会自动处理内部依赖:ui 依赖 networknetwork 升了 MINOR,melos 会把 ui 的依赖约束一并更新。没有这一步,手动维护十个包的依赖约束是灾难。


五、私有 pub 服务与私有依赖

5.1 私有方案对比

方案部署认证适用
git 依赖无需服务,直接指向仓库Git 权限(SSH/Token)少量私有包、临时方案
path 依赖本地路径文件系统monorepo 内部、本地联调
unpub自建 Docker,可托管静态站点可配置,较弱内网快速搭建
自建 pub_serverdart pub global activate pub_server基础,需自行加固学习协议、小团队
第三方托管(Cloudsmith/Artifactory)SaaS 或私有部署Token、SSO、细粒度权限企业级、有审计需求

5.2 git 与 path 依赖写法

dependencies:
  # git 依赖:可锁 tag 或 commit,保证可复现
  company_logger:
    git:
      url: https://github.com/your-org/company_logger.git
      ref: v1.2.0
  # 本地 path 依赖:monorepo 开发时使用
  company_core:
    path: ../company_core
# 私有 git 仓库的认证:用 SSH 或带 token 的 HTTPS
git config --global url."git@github.com:".insteadOf "https://github.com/"

规则:生产依赖永远锁 tag 或 commit,绝不依赖分支;分支会移动,构建不可复现。

5.3 unpub 部署示例

# docker-compose.yml
services:
  unpub:
    image: ghcr.io/eryajf/unpub:latest
    ports:
      - "4000:4000"
    volumes:
      - ./data:/data          # 上传的包与索引持久化
# 使用方配置私有源(放在项目根 .dart_tool 之外的全局配置)
# ~/.pub-cache/credentials.json 或环境变量方式:
# PUB_HOSTED_URL 会替换整个 pub.dev,通常不推荐
# 只对单个包指定私有源(推荐)
dart pub add company_logger --hosted-url http://pub.internal.example.com

注意:PUB_HOSTED_URL 是全局替换,会让公共包也走私有源;正确的做法是在 pubspec.yamlhosted 字段按包指定:

dependencies:
  company_logger:
    hosted: http://pub.internal.example.com
    version: ^1.2.0

六、内部共享包的设计原则

  1. 稳定 API 优先:内部包被多个业务依赖,破坏性变更的成本比开源包更高(无法要求所有团队同时升级);
  2. 小而专:一个包只做一件事,company_utils 这种万能包最终会变成依赖地狱;
  3. 零业务语义:公共包不 import 任何业务模块,否则依赖图变成蜘蛛网;
  4. 接口与实现分离:对外暴露抽象接口与工厂函数,实现类不导出,留出替换空间;
  5. 可测试性内建:构造函数注入依赖,不在包内读全局配置或单例;
  6. 文档与示例随包发布example/ 是内部包最好的使用说明书;
  7. 版本策略统一:所有内部包用同一套版本节奏(如统一 MAJOR),减少组合爆炸。
// company_logger/lib/company_logger.dart —— 导出面收敛
export 'src/logger.dart';        // 只导出公共 API
// src/console_logger.dart 不导出,实现细节可随时替换
// src/logger.dart
abstract class Logger {
  void info(String message);
  void error(String message, [Object? error]);
  factory Logger.console({String prefix = ''}) => ConsoleLogger(prefix);
}

七、文档生成:dart doc

dart doc                        # 输出到 doc/api/,用浏览器打开 index.html
dart doc --output web/docs      # 指定输出目录
/// 带指数退避的重试工具。
///
/// 示例:
/// ```dart
/// final result = await retry(() => client.get(url), maxAttempts: 3);
/// ```
///
/// 当 [maxAttempts] 为 0 时立即抛出 [ArgumentError]
Future<T> retry<T>(Future<T> Function() action, {int maxAttempts = 3}) async {
  // 实现
}

纪律:文档注释写在公共 API 的声明处;示例代码用 ```dart 包裹以便被分析器检查;dart doc 的警告(未文档化的公开成员)应清零;CI 中可用 dart doc --validate-links 检查链接有效性。


八、示例工程组织

一个可发布包的标准结构:

my_utils/
  lib/
    my_utils.dart          # 唯一公共入口,export 各模块
    src/                   # 实现细节,外部不应 import
      string_utils.dart
      duration_utils.dart
  test/
    string_utils_test.dart
    duration_utils_test.dart
  example/
    main.dart              # 可运行示例,pub.dev 会展示
  doc/                     # dart doc 输出(gitignore)
  CHANGELOG.md
  LICENSE
  README.md
  analysis_options.yaml
  pubspec.yaml

关键约定:外部只能 import package:my_utils/my_utils.dartsrc/ 下的路径视为私有;用 implementation_imports lint 规则强制这一点(禁止跨包 import 别人的 src/)。


常见坑

  1. 未提交生成文件就发布dart pub publish --dry-run 会警告 .g.dart/.freezed.dart 不在版本控制中,发布后用户生成失败;确认生成文件入库或用户可自行生成。
  2. 版本冲突:内部包 A 依赖 core ^1.0.0,包 B 依赖 core ^2.0.0,同时引入直接解析失败;monorepo 用 melos 统一升级,多仓库则约定同一 MAJOR。
  3. pubspec.lock 入库策略错误:应用(application)必须提交 lock 文件保证可复现;库(package)不提交,让用户自行解析(否则依赖约束被锁死)。
  4. 私有源配置全局覆盖PUB_HOSTED_URL 让公共包也走内网,应改用 hosted 字段按包指定。
  5. 发布后才发现 API 设计错误:pub.dev 不能删除版本;发布前用 example/ 与下游工程真实试用一轮。
  6. 废弃 API 直接删除:下游编译失败;先 @Deprecated 保留一个大版本。
  7. README 里粘贴不可运行的伪代码:pub.dev 评分会检查代码块可分析性,示例要能通过 dart analyze

本章小结

  • package 与 application 的分界线是「是否被别的工程依赖」,后者用 publish_to: none 明确表态;
  • 发布 pub.dev 五件套:pubspec 元数据、LICENSE、CHANGELOG、README、example,--dry-run 与 pana 是发布前门禁;
  • 语义化版本是 API 合同,破坏性变更先废弃一个大版本再删除;
  • melos 解决多包仓库的本地链接、统一版本与拓扑发布,melos version 自动处理内部依赖约束;
  • 私有依赖三种主流方式:git(锁 tag)、path(本地)、私有 hosted(按包指定,勿全局覆盖);
  • 内部共享包的设计目标是最小公共面与稳定 API,src/ 永远不对外;
  • dart doc 把文档注释变成站点,公开 API 文档化应作为发布检查项。

练习

  1. 发布一个真实小包:创建一个 dart create -t package 工程,实现 3-5 个纯函数工具(如字符串截断、时长格式化、重试封装),补齐 LICENSE、CHANGELOG、README 与 example,跑通 dart pub publish --dry-run 并让 pana 分数达到 130/130。
    • 验收:dry-run 无 warning;dart pub global run pana . 输出满分;example 可通过 dart run 执行。
  2. 废弃与兼容:给练习 1 的某个函数改名,用 @Deprecated 保留旧名一个版本,同时更新 CHANGELOG 的 MINOR 版本条目。
    • 验收:旧名调用产生 deprecation 警告但可编译;CHANGELOG 中记录迁移写法。
  3. melos 多包:创建含 coreapp_kit 两个包的 melos 仓库,app_kit 依赖 core,跑通 bootstrap 与统一 version。
    • 验收:melos bootstrapapp_kitpubspec.lock 指向本地 coremelos version 能同时更新两包版本并在 CHANGELOG 插入条目。
  4. 私有依赖:在本地用 path 依赖与 git 依赖各接入一个私有包到示例应用,并说明生产环境该选哪种。
    • 验收:应用能 dart pub get 并调用两个包的 API;提交一份 200 字以内的选型说明(path 用于 monorepo 联调,git 锁 tag 用于跨仓库交付)。