简介:一份使用纯Dart语言编写的自动化打包上线脚本,面向需要简化软件发布流程的Dart/Flutter开发者。脚本完整覆盖命令行调用、pubspec.yaml配置、Flutter构建、环境变量管理、Git版本控制、CI/CD集成、签名处理、文件操作及云服务分发等关键环节,代码结构清晰,配有说明文档,可直接参考或按项目定制。压缩包共34个文件,以dart源码为主(26个),另含yaml配置、markdown说明等,整体仅35KB,轻量易读。已有385人学习浏览,适合正在学习Dart实战及希望搭建自动化发版管线的中高级开发者。 做了挺长时间 Flutter 开发之后,我有一个很强烈的感受:很多团队把精力都花在 UI 和业务逻辑上,但发版流程一直停留在“手动点按钮”的阶段。这个项目就是在这样的背景下做的——用纯 Dart 语言写一个自动化打包上线脚本,把客户端在发版阶段的重复劳动全部吃掉。整套脚本虽然叫 dart_build_script,但它不依赖 Flutter 引擎,也不依赖任何 GUI 环境,就是一套跑在命令行里的 Dart 程序。你给它一个版本号、一个平台参数,它就能自动完成编译、打包、上传、通知这一整套动作。对每天被发版折磨的客户端开发来说,这东西能直接把早上的时间从半小时压缩到两三分钟。
这套脚本适合两类人看:一类是天天在手动发版、被测试同事催包的客户端开发,另一类是学了 Dart 语法但不知道它除了 Flutter 还能干什么的同学。我下面讲的不是概念,全是在真实项目中踩过坑之后沉淀出来的实操心得。
1. 为什么用纯 Dart 写自动化打包脚本
1.1 背景:手动发版是真疼
先说说我为什么会写这个脚本。我们团队的项目迭代节奏差不多两周一个版本,每次发版都要走这个链路:改版本号 → 跑 flutter build → 打 dSYM → 上传到后端服务器 → 在群里 @ 测试。其中“传包”这一步尤其烦,服务器是内网的,得先连内网,再走 rsync 或者调试工具传上去。Android 包和 iOS 包的命令还不一样,Windows 和 macOS 上的 shell 语法也有差异。
刚开始我是用 Shell 脚本写的,当时觉得挺顺,后来遇到几个问题就开始难受了:一是 Shell 在字符串处理上太弱,版本号解析、JSON 拼接容易写错;二是跨平台不友好,Linux 上好好的脚本拿到 macOS 上路径就裂开;三是团队成员想改点逻辑,面对一坨 sed 和 awk 直接劝退。后来我把目光转向了 Dart,毕竟团队所有人都会 Dart,与其让大伙学 Shell,不如直接复用现有技术栈。
1.2 方案对比:Shell、Python、Dart 怎么选
有个朋友问我:这种脚本用 Python 不香吗?Python 确实可以,而且生态很好。但我们的情况比较特殊,客户端团队的机器上有的是 Windows,有的是 macOS,装 Dart SDK 是天然的(因为要开发 Flutter),但装 Python 还得管理版本。更关键的是,Dart 本身编译成原生可执行文件之后,丢给没装 SDK 的同事也能直接跑,分发成本极低。
我把三种方案的取舍整理成了一个表,方便你按自己团队情况选:
| 对比维度 | Shell 脚本 | Python 脚本 | Dart 脚本 |
|---|---|---|---|
| 学习成本 | 低,但写复杂逻辑容易翻车 | 低 | 中,但 Flutter 团队零成本 |
| 跨平台一致性 | 差,bash/zsh/cmd 差异多 | 中,依赖解释器环境 | 好,模拟器一致性强 |
| 字符串与文件处理 | 弱 | 强 | 强 |
| 分发方式 | 源码直接跑 | 需要目标机器配 Python | 可编译成单文件可执行程序 |
| 团队维护意愿 | 低 | 中 | 高,尤其 Flutter 团队 |
对我个人来说,选择 Dart 还有一个隐藏理由:它是强类型语言,写错了在编译阶段就能发现,不会像 Shell 那样运行到一半才报错。对一个凌晨才想起来发版的打工人来说,编译期报错比运行期爆炸友好太多了。
1.3 这套脚本的核心能力
整个脚本解决的核心场景有两个。第一个是本地一键构建,你不需要记住 flutter build apk --release 后面那串参数,也不用担心忘掉 --dart-define=API_ENV=prod。第二个是自动上传与通知,构建完成后,脚本会读取配置文件里的上传地址、把产物传上去,并且通过 Webhook 向即时通讯群推送一条消息,内容包含版本号、构建时间、下载链接。一旦这套东西跑起来,发版就不是一个技术活了,而是一个“输入版本号回车”的机械动作。
2. 脚本整体设计与核心模块
2.1 项目结构:一个单独存活的脚本工程
写这个脚本没有把它塞进 Flutter 项目的 tool 目录就算完,我给它的定位是一个独立工程。这么做的好处是它不参与 Flutter 的依赖解析,不会被 install 的版本链影响,逻辑上也跟主工程解耦,后续想抽出来做成公司内部的公共工具,直接拎包走人。
实际目录结构如下:
dart_build_script/ ├── pubspec.yaml ├── bin/ │ └── build.dart # 入口文件 ├── lib/ │ ├── config.dart # 读取配置文件 │ ├── logger.dart # 带颜色的日志输出 │ ├── shell.dart # 封装进程执行 │ ├── version.dart # 版本号处理 │ └── uploader.dart # 上传与通知 ├── config/ │ └── build_config.json # 各环境配置 └── README.md为什么把模块拆成这样?我的原则是:一个文件只干一件事。sys 文件负责跟系统打交道,config 文件负责所有参数,updater 文件只关心上传和推送。这样当一个环节出问题时,排查范围是被限死的。
2.2 几个被低估的 Dart 内置能力
Dart 的 SDK 其实藏着很多适合写 CLI 工具的内置能力,不依赖任何第三方包。
第一个是Process.run,它可以同步或异步地执行外部命令。我封装了一层Shell.run,这样不是直接调系统进程,而是先打日志,再执行,这样在 CI 日志里能看到每一步在跑什么:
Future<ProcessResult> run(String command, List<String> args, {String? workingDirectory}) async { logger.info('[EXEC] $command ${args.join(' ')}'); late final ProcessResult result; if (workingDirectory == null) { result = await Process.run(command, args); } else { result = await Process.run(command, args, workingDirectory: workingDirectory); } if (result.exitCode != 0) { logger.error('[FAIL] ${result.stderr}'); throw ProcessException(command, args, result.stderr as String?, result.exitCode); } logger.info('[OK] ${result.stdout}'); return result; }第二个是专门用于读取文件内容的File和Directory,它们完全是跨平台的。你不需要自己拼路径,尤其不要用字符串硬编码/或者\;Dart 的File在 Windows 和 macOS 上都能正确处理相对路径。
第三个是dart:io包里的HttpClient。上传包体我用它发 multipart/form-data 请求,推送通知我用它发 POST JSON,完全不需要引入http或者dio,因为脚本里用不到复杂的拦截器,内置的这个客户端够用了。
2.3 版本管理:让版本号可控可回退
发版最重要的是版本号不能乱。我设计了两个规则:
- 版本号统一从 pubspec.yaml 读取。构建时把
version: 1.2.3+45解析出来,1.2.3作为展示版本,45作为构建号,iOS 的 CFBundleVersion 和 Android 的 versionCode 都跟它对齐。 - 每次发版前自动 git tag。脚本在构建成功后打一个
v1.2.3+45的 tag,推送到远程仓库。以后任何一个包出了线上问题,都能通过 tag 找到当时对应的代码提交。
版本号解析的核心代码大概长这样:
String parseVersionFromPubspec(String content) { final match = RegExp(r'^version:\s*([^\n]+)$', multiLine: true).firstMatch(content); if (match == null) { throw StateError('pubspec.yaml 中找不到 version 字段'); } return match.group(1)!.trim(); }这个正则看着简单,但有个细节值得注意:我用了multiLine: true,这样^和$会匹配每一行的开头和结尾,而不是整个字符串的开头和结尾。不熟悉 Dart 正则的同学经常在这一步翻车,匹配出来的结果是一大段内容。
3. 实操过程:从初始化到真正跑通一套流程
3.1 项目初始化:别忘记 Dart 的入口约定
初始化一个 Dart CLI 项目其实就两条命令:
dart create -t console dart_build_script cd dart_build_script注意-t console这个模板是专门用于命令行程序的,它会自动生成bin/目录和一个包含main函数的文件。这里有个 Dart 的硬性规定:一个可以被直接运行的程序,必须有一个入口函数叫main,否则运行时会直接报invoked dart programs must have a 'main' function defined。很多新手反手把入口文件改名为build.dart,但忘了函数名要保持main,结果就卡在这里。
如果你不想用模板,直接手动创建bin/build.dart也是可以的,只要确保文件内容里有一个顶层main函数就行:
Future<void> main(List<String> arguments) async { // 整个脚本从这里开始 }3.2 实现核心构建流程
构建流程我拆成了四个步骤:解析参数 → 加载配置 → 执行构建 → 处理产物。参数解析我用的是dart:io的Platform.executableArguments,简单直接,不需要引args包,因为我的参数就两三个,不会膨胀。
完整参数定义大概是这样的:
| 参数名 | 含义 | 示例 |
|---|---|---|
--platform | 目标平台 | android或ios |
--mode | 构建模式 | release或debug |
--env | 环境配置名 | prod、dev、test |
--skip-upload | 跳过上传 | 本地调试时用 |
构建核心代码这里贴一段,能说明问题就行:
final args = parseArguments(arguments); final config = loadConfig('config/build_config_${args.env}.json'); var command = 'flutter'; var commandArgs = <String>[ 'build', args.platform, '--${args.mode}', '--dart-define=API_ENV=${args.env}', ]; if (args.platform == 'android') { commandArgs.add('--split-per-abi'); // 拆出 armeabi-v7a、arm64-v8a } await shell.run(command, commandArgs, workingDirectory: projectRoot);为什么 Android 打包要加--split-per-abi?原因很简单:不信你看应用商店的包体大小限制。一个 fat APK 把所有 CPU 架构全打进去,动辄上百 MB;加上--split-per-abi之后,一个架构一个包,测试下载和上传都快很多。当然这也会带来一个坑,后面我放在第 4 节说。
3.3 上传与通知:把脚本接入团队协作
构建完成之后的产物,在 Android 上是build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk之类的路径,在 iOS 上是build/ios/iphoneos/Runner.ipa。脚本要把这些产物按约定重命名,比如MyApp_v1.2.3_arm64.apk,然后上传到配置好的服务器。
这里我直接使用一个简单的 HTTP PUT 请求来传输文件,避免引入 rsync 这种外部依赖。上传的代码:
Future<void> uploadFile(File file, String uploadUrl) async { final client = HttpClient(); final request = await client.putUrl(Uri.parse(uploadUrl)); request.headers.set(HttpHeaders.contentTypeHeader, 'application/octet-stream'); request.contentLength = file.lengthSync(); await request.addStream(file.openRead()); final response = await request.close(); print('upload status: ${response.statusCode}'); client.close(); }通知部分更简单,就是拼一段 JSON,往群机器人的 Webhook 发一个 POST。这里有一个特别容易踩的坑:HTTP 请求的Content-Type必须是application/json,而且 JSON 字符串里的换行符要处理好。Dart 的jsonEncode会把特殊字符自动转义,所以放心用,不要自己手拼字符串。
3.4 一次完整的运行过程
配置和代码都准备好之后,实际的使用体验就是一行命令:
dart run bin/build.dart --platform android --mode release --env prod脚本输出大概长这样:
[INFO] Loading config: config/build_config_prod.json [INFO] Current version: 1.2.3+45 [EXEC] flutter build apk --release --dart-define=API_ENV=prod --split-per-abi [INFO] Build succeeded in 3m12s [INFO] Renaming artifact to: MyApp_v1.2.3_arm64.apk [UPLOAD] Uploading to https://download.example.com/MyApp_v1.2.3_arm64.apk [INFO] Upload finished, 56.8 MB [NOTIFY] Webhook sent, status 200 [INFO] Tagging commit: v1.2.3+45到这一步,一条完整的发版链路就跑通了。之后测试同事拿到的下载链接就是脚本自动推送的那条消息,他们甚至感知不到“我正在发版”这个过程。
4. 常见问题与排查技巧实录
4.1 invoked dart programs must have a 'main' function defined
这个报错是 Dart 新手最常见的一个,我在项目分享里被问了至少五次。它出现的原因有三个:
bin/build.dart文件里没有声明main函数。- 声明了,但拼写错误,比如
mian、Main。 - 用
dart run bin/build.dart运行了,但bin/build.dart的内容实际上是从其他文件 import 进来的代码,而没有定义main。
检查顺序也按上面来:先看文件里有没有void main(),再确认拼写,最后确认入口函数是在被运行的文件里,而不是在 import 的库文件里。
4.2 Windows 与 macOS 路径分隔符的坑
Dart 的File和Directory会自动处理路径分隔符,但要注意:你如果直接拼接字符串,比如'build/app/outputs/...',这种带正斜杠的路径在 Windows 上是能跑的,因为 Dart 的File会自动把/转成\。但如果是反过来,你在 macOS 上写死了\\,那就会出问题。
我自己的经验是:统一写正斜杠/,不要用反斜杠。如果需要拼接路径,用package:path的join方法,它会在不同平台上生成正确的分隔符。不要自己去判断Platform.isWindows然后拼不同的字符串,代码会变得很难看。
4.3 环境变量与密钥:别把密码写在代码里
上传服务器需要 username/password,或者 token。最忌讳的是把这些信息直接写在build_config.json里,因为配置文件一般是提交到 git 仓库的。我的方案是:配置文件里只写“从哪个环境变量取密钥”的 key 名字,脚本运行时再从Platform.environment读取。
final token = Platform.environment['UPLOAD_TOKEN']; if (token == null || token.isEmpty) { throw StateError('环境变量 UPLOAD_TOKEN 未设置'); }这样配置仓库是干净的,密钥只存在于本机或 CI 平台的 secrets 里。如果你不小心把密钥提交了,一定要去平台撤销掉,不要以为删掉文件就没事了,git 历史里还留着。
4.4 Android 拆分架构后产物文件变多
前面我提到--split-per-abi会把 APK 拆成多个,实际产物是这样的:
app-armeabi-v7a-release.apk app-arm64-v8a-release.apk app-x86_64-release.apk如果你把它们都传上去了,那测试可能会下载错文件。我的处理方式是:脚本里判断一下产物数量,如果大于 1,就只选择arm64-v8a的那个作为默认包,因为现在市面上绝大多数真机都是 arm64 架构。x86_64的包留给模拟器用户,但不会主动推消息。
5. 实测心得与后续可以继续做的方向
脚本目前在团队里跑了三个多月,最大的感受就是“省事”。以前测试同事总在群里问包呢包呢,现在到点自动推送消息,没人催了。我自己也能把早上到公司的半小时拿来干点正事,而不是对着命令行手忙脚乱。
另外我建议刚接触这类脚本的同学,不要一上来就想把什么都自动化。先用手动命令把流程走通,再把跑过次数最多的那一段抽出来写脚本,最后再考虑加触发器、加 CI。步子迈太大,出了问题你连手动操作的路径都忘了,那就更难受了。
这个脚本我后续还打算扩展两个方向:一个是接入 CI,提交 tag 时自动触发构建,构建完自动上传;另一个是多项目支持,现在配置是写死在文件里的,后续打算让命令支持同时传产品名,这样公司里其他项目组也能直接用同一套工具。目前这两个方向都在开发中,等跑通了再来分享细节。
最后再讲一个很实用的点:脚本里所有耗时操作都一定要把进度“打印”出来,不是让你用print随便打,而是将每步耗时也输出。实测下来,这种日志风格对排查问题帮助极大。有一次构建一直卡住,我一眼就看到是flutter build那一步卡了 20 分钟没有日志输出,最后定位到是 CI 机器的磁盘满了,当时如果没打时间戳,排查少说要再花半天。
本文还有配套的精品资源,点击获取