用纯Dart重写Flutter打包脚本:从Shell迁移到工程化自动化的实践
2026/9/8 14:41:19 网站建设 项目流程

简介:这是一份使用纯Dart语言编写的自动化打包上线脚本,专注解决应用在构建、测试、发布环节中的重复劳动和人工干预问题,适合有Flutter或Dart工具链经验的开发者参考,也适合想学习用Dart实现命令行工具与自动化流程的工程人员。压缩包共34个文件,以26个Dart源码文件为核心,配合YAML格式的依赖与项目配置、Markdown文档、JSON参数文件,整体仅35KB,体量轻巧但功能模块划分相当清晰。目前该资源已有380余人学习,属于小而实用的自动化脚本样例。脚本从主入口启动后,在库目录中按命令、管理、流程、通用工具、配置等职责拆分成多个子模块,分别处理命令行参数解析、构建任务调度、子进程调用和环境变量读取等操作;同时附带依赖管理文件和参数配置文件,方便使用者针对不同平台或环境调整构建策略。通过阅读并运行这套代码,读者能直观理解Dart在自动化运维中的落地方式,还可以直接复用其中的模块化结构,快速搭建适合自己的打包上线流水线,从而提升发布效率。 从 Flutter 项目工程化的角度聊起,我动手重写过不少次打包脚本。最早是用 Shell,后来换过 Python,最后在维护一个跨端项目时下决心把整套自动化打包含上线逻辑用纯 Dart 重写了一遍。这个选择背后没有太多玄学,单纯是“团队工具链统一”带来的长期收益。这篇文章把脚本的设计思路、关键模块拆解和踩过的坑都写出来,给同样想在 Dart/Flutter 技术栈里省掉一份心智负担的团队做个参考。

1. 为什么我放弃 Shell 和 Python,用纯 Dart 写构建脚本

1.1 先从一个场景说起:脚本要读 pubspec.yaml 里的版本号

之前用 Shell 写打包脚本时,遇到一个很现实的问题:打包前我要从pubspec.yaml里把版本号读出来,作为--build-name--build-number传给flutter build。用 Shell 解析 YAML,要么靠grep加正则硬切,要么依赖yq这个第三方工具。硬切的方式在版本号格式稍有变化时就是一场灾难,而yq又不是每个新同事的电脑上都装了的。

后来换成 Dart 之后,这个问题被彻底解决——直接import 'package:yaml/yaml.yaml';,两行代码就把版本号解析出来了。整个解析逻辑跟业务代码是同一种语言,新同事接手脚本时几乎没有额外学习成本,这就是我最初换语言的核心动机。

1.2 纯 Dart 方案的真实收益与适用边界

如果团队的主技术栈就是 Dart/Flutter,用 Dart 写脚本的好处相当明显:

  • 类型安全:命令行参数、配置结构都有类型约束,传错参数在运行前甚至编译阶段就能发现。
  • 可以复用业务代码里的工具函数:比如我直接 import 了项目里现成的日志模块和常量定义,不需要在脚本里再造一遍轮子。
  • 跨平台一致:macOS、Linux、Windows 上执行行为一致,不会出现同一个sed命令在不同平台上表现不同的诡异问题。
  • 不需要额外安装运行时:用 Flutter 项目的人机器上基本都有 Dart SDK,不像 Python 还要处理版本、虚拟环境、依赖冲突。

当然,这不代表 Dart 适合写所有自动化任务。如果你的打包流程大量依赖系统命令管道符、需要频繁处理文本流,Shell 依然有它的优势;如果团队主语言是 Java 或 Go,那也没必要硬拗成 Dart。我的建议很务实:只有当脚本和主项目共享大量语言生态、需要强类型约束、且团队本来就熟悉 Dart 时,这个切换才划算。

2. 脚本骨架搭建:目录、入口、命令行与进程封装

2.1 在 Flutter 仓库里放置 dart_build_script 的方式

我习惯把脚本放在仓库根目录下的tool/目录里,跟lib/test/平级。单独的包结构长这样:

tool/ build_script/ bin/ main.dart lib/ core/ process_runner.dart logger.dart tasks/ android_build.dart ios_build.dart publish.dart pubspec.yaml

注意这里用了独立的pubspec.yaml,因为脚本有自己依赖的第三方包,比如argsyamlhttp等。把脚本依赖和主项目依赖隔离,能避免污染 Flutter 业务代码的依赖树。

运行方式也很简单:

cd tool/build_script dart pub get dart run bin/main.dart android

这里提前提醒一个容易踩的坑:单独用dart tool/build_script/bin/main.dart这种路径方式运行时,脚本内部引用package:build_script/core/...可能会解析失败。最好的方式就是先cd到脚本包的根目录,再用dart run bin/main.dart运行,这样包解析上下文是确定的。

2.2 入口 main 函数与 args 子命令解析

入口文件是整个脚本的门面。我的main.dart通常长这样:

import 'dart:io'; import 'package:args/args.dart'; import '../tasks/android_build.dart'; import '../tasks/ios_build.dart'; import '../tasks/publish.dart'; Future<void> main(List<String> arguments) async { final parser = ArgParser() ..addCommand('android', ArgParser() ..addOption('build-name') ..addOption('build-number') ..addFlag('release', defaultsTo: true)) ..addCommand('ios', ArgParser() ..addOption('export-method', defaultsTo: 'enterprise')) ..addCommand('publish', ArgParser() ..addOption('file') ..addOption('channel', defaultsTo: 'test')); final results = parser.parse(arguments); final command = results.command; if (command == null) { stdout.writeln(parser.usage); return; } switch (command.name) { case 'android': await buildAndroid(command); break; case 'ios': await buildIos(command); break; case 'publish': await publishArtifact(command); break; default: stderr.writeln('Unknown command: ${command.name}'); } }

使用args这个官方维护的命令行解析包,比自己在List<String>里手工判断参数要可靠得多。子命令的设计让脚本支持androidiospublish这种直观的调用方式,后续加新命令时只需要新增一个 case。

2.3 进程调用的统一封装:日志要流式,退出码要显式

打包脚本的实质就是不断调用flutterpodgradle等外部进程。我把进程调用统一封装成一个ProcessRunner,原因是避免每次调用都重复处理日志输出和退出码逻辑。

class ProcessRunner { Future<int> run( String executable, List<String> args, { String? workingDirectory, }) async { final process = await Process.start( executable, args, workingDirectory: workingDirectory ?? Directory.current.path, ); // 流式输出,让用户实时看到构建进度 process.stdout.transform(systemEncoding.decoder).listen((chunk) { stdout.write(chunk); }); process.stderr.transform(systemEncoding.decoder).listen((chunk) { stderr.write(chunk); }); final exitCode = await process.exitCode; if (exitCode != 0) { throw ProcessException(executable, args, 'exit code: $exitCode'); } return exitCode; } }

这里有个很重要的点:构建日志必须流式打印,而不是等进程结束一次性输出。Process.run虽然写起来更省事,但遇到flutter build这种动辄几分钟、输出量巨大的命令时,用户看不到中间过程,会误以为脚本卡死了。而且一旦日志量超过系统管道缓冲区的上限,子进程甚至会因为写不进去而阻塞,这也是我坚持用Process.start流式消费的原因。

3. 打包流水线核心:Android/iOS 构建与产物处理

3.1 Android APK 构建与产物自动归档

Android 打包的命令本身不复杂,但要让脚本真正“自动化”,关键在于把版本号读出来、注入构建命令,并在构建完成后把产物归档到约定目录。

Future<File> buildAndroid(ArgResults command) async { final pubspec = loadYaml(File('pubspec.yaml').readAsStringSync()) as YamlMap; final parts = (pubspec['version'] as String).split('+'); final buildName = command['build-name'] ?? parts[0]; final buildNumber = command['build-number'] ?? (parts.length > 1 ? parts[1] : '1'); await ProcessRunner().run('flutter', [ 'build', 'apk', '--release', '--build-name', buildName, '--build-number', buildNumber, ]); final apk = File('build/app/outputs/flutter-apk/app-release.apk'); final distDir = Directory('dist'); await distDir.create(recursive: true); final targetName = 'app-v$buildName+$buildNumber.apk'; final target = File('${distDir.path}/$targetName'); await apk.copy(target.path); return target; }

这里把产物重命名为带版本号的文件名,是为了避免同名文件互相覆盖。实际项目中我还会加一个--channel参数,用来区分teststagingprod等渠道,不同渠道通过--dart-define=CHANNEL=$channel注入到代码里。

Android 产物的路径有历史坑需要注意:新版 Flutter 的产物在build/app/outputs/flutter-apk/app-release.apk,但有些老版本的 Flutter 或特殊配置下会在build/app/outputs/apk/release/。写脚本时不要写死路径,最好在打包前先确认一下目录结构,或者通过glob包做一次通配查找。

3.2 iOS 导出:动态生成 ExportOptions.plist

iOS 打包比 Android 多一个导出环节。核心问题是:flutter build ipa时,Xcode 需要一个ExportOptions.plist来指定签名方式、导出方法等。这个文件在不同项目、不同环境下的配置不一样,所以我选择在脚本里根据参数动态生成。

Future<String> generateExportOptions(String method, String teamId, String bundleId) async { final options = { 'method': method, // enterprise / app-store / ad-hoc / development 'teamID': teamId, 'signingStyle': 'automatic', 'stripSwiftSymbols': true, 'uploadSymbols': true, }; final file = File('dist/ExportOptions.plist'); await file.writeAsString(_toPlistXml(options)); return file.path; }

生成 plist 文件后,再调用:

flutter build ipa --release --export-options-plist=dist/ExportOptions.plist

注意 iOS 构建的产物路径是build/ios/ipa/*.ipa,和 Android 一样,也需要复制到dist/目录并按版本号归档。另外 iOS 打包依赖签名证书和描述文件,这些状态不是脚本层面能控制好的,所以我在脚本里加了一个前置检查:先执行flutter doctor检查 Xcode 环境是否就绪,避免构建到一半因为签名问题失败。

3.3 失败重试与构建日志落盘

打包过程难免遇到网络抖动导致的依赖下载失败、Gradle 超时等偶发问题。在ProcessRunner上层,我加了一个简单但实用的重试机制:

Future<int> runWithRetry({ required List<String> args, int maxRetries = 2, }) async { for (var attempt = 1; attempt <= maxRetries; attempt++) { try { return await ProcessRunner().run('flutter', args); } on ProcessException catch (e) { stderr.writeln('第 $attempt 次尝试失败: ${e.message}'); if (attempt == maxRetries) rethrow; } } throw StateError('unreachable'); }

重试不是无脑加,我只对命令超时、网络错误这类“瞬时问题”启用重试,签名失败、编译报错这类确定性错误直接抛出,避免浪费时间。

日志落盘也很重要。我在ProcessRunner里加了一个全局的build_logs/目录,每次运行都把 stdout 和 stderr 同时写入文件,文件名带时间戳。这样如果 CI 中途失败,事后可以把日志拉出来慢慢排查,不用重新构建一次。

4. 上线分发:上传平台与群通知的集成细节

4.1 Multipart 直传内测分发平台

打包产物最终要分发给测试人员,常见做法是上传到内测分发平台或者对象存储。用 Dart 的http包实现 Multipart 上传并不复杂:

Future<void> uploadToPlatform(String filePath, String apiToken) async { final request = http.MultipartRequest( 'POST', Uri.parse('https://api.example.cn/api/v1/upload'), ); request.fields['token'] = apiToken; request.files.add(await http.MultipartFile.fromPath('file', filePath)); final streamed = await request.send(); final response = await http.Response.fromStream(streamed); if (response.statusCode != 200) { throw Exception('上传失败: ${response.body}'); } }

不同的分发平台接口差异挺大。有些平台需要先请求一个upload_token,再把这个 token 拼到上传请求里,甚至需要计算文件的md5作为校验字段。这些平台差异我会单独拆一个PlatformUploader类来封装,不在publish.dart里堆逻辑。脚本的维护性,很大程度上取决于你有没有把这些第三方适配隔离好。

4.2 群机器人 Webhook 的签名计算

上传完成之后,脚本要通知到团队工作群。飞书、钉钉、企业微信都有自定义机器人,接口大同小异。这里只说一个共通的坑:很多机器人都启用了签名校验,而签名算法是HMAC-SHA256加 Base64。Dart 实现如下:

import 'dart:convert'; import 'package:crypto/crypto.dart'; String buildRobotSign(String secret, String timestamp) { final stringToSign = '$timestamp\n$secret'; final hmac = Hmac(sha256, utf8.encode(secret)); final digest = hmac.convert(utf8.encode(stringToSign)); return base64.encode(digest.bytes); }

拼接消息时,把时间戳和签名一起放进请求体:

final body = jsonEncode({ 'timestamp': timestamp, 'sign': buildRobotSign(secret, timestamp), 'msg_type': 'markdown', 'content': '构建成功:v$buildName (+$buildNumber)\n' '渠道:$channel\n' '下载:$downloadUrl', });

这个计算逻辑是通用的,换哪家机器人,HMAC-SHA256和 Base64 这部分都能复用。真正需要调整的只有请求体的字段名和消息格式。

4.3 给脚本加一个轻量交互菜单

本地跑脚本时,每次都要敲一长串参数挺烦的。我加了一个交互模式:当没有传入--channel参数时,脚本会在终端列出可选渠道,让用户直接输入编号选择。

String selectChannel(List<String> channels) { for (var i = 0; i < channels.length; i++) { stdout.writeln('$i. ${channels[i]}'); } stdout.write('请选择渠道编号: '); final text = stdin.readLineSync()?.trim() ?? '0'; final index = int.tryParse(text) ?? 0; return channels[index.clamp(0, channels.length - 1)]; }

stdin.readLineSync()读取输入,用int.tryParse做安全转换,避免用户误输入非数字字符导致崩溃。交互模式适合本地调试,CI 环境里还是老老实实传参数,避免脚本挂起等待输入。

5. 踩坑记录:dart build_script 的常见问题与排错思路

5.1 “invoked dart programs must have a 'main' function defined”到底在说什么

这个报错是 Dart 运行脚本时最常见的拦路虎之一。明明main.dart里写了main函数,为什么运行时报“没有定义 main”?我踩过两种情况:

第一种,入口文件路径不对。Dart 规定,一个可执行程序的入口文件的main函数必须位于指定的入口路径下,也就是bin/目录里的那个文件。如果你把入口文件放在lib/下,或者在运行命令里指定了lib/xxx.dart,Dart 会认为你加载的是一个库而不是一个可执行程序,自然找不到main函数。

第二种,main函数被定义成了私有函数,或者签名不对:

void _main() { } // 错误,私有函数不会被当作入口 void main() { } // 正确 Future<void> main() async { } // 正确,异步入口是合法的 void main(List<String> args) { } // 正确,可以接收命令行参数

还有一个容易忽略的点:如果main.dart里声明了main但同样文件中有顶层报错导致编译失败,运行时也可能给出误导性的 “main function not defined”。排错顺序建议是:先确认运行路径是bin/下的入口 → 再确认main是公开的顶层函数 → 然后看编译阶段是否还有未解决错误。

5.2 Process.start 不读 stdout 会卡死构建

这是我写脚本时踩得最深的一次。最初图省事用Process.start启动flutter build,但没有马上监听 stdout,而是先干别的事,结果构建进程在输出日志到一定量后就“卡死”了,整个构建永远不结束。

原因是系统管道缓冲区有大小限制,如果子进程的 stdout 一直往管道里写,而父进程不消费缓冲区里的数据,子进程就会阻塞在写操作上。Process.run的底层逻辑自己处理了读取,所以没这个问题;但Process.run要等进程完全结束才返回,无法做到流式日志。

解决方案就是我在ProcessRunner里写的那样:启动进程后立刻监听 stdout 和 stderr,每收到一个 chunk 就打印出来,既满足了实时日志,也在消费退出码前把管道读完。千万不要把process.stdout的订阅放在某个耗时操作之后。

5.3 顺带解决:dcn 自定义图像格式怎么解析成 PNG

项目里有同事遇到一个很奇怪的需求:打包脚本需要把一类后缀是dcn的图片资源解析成 PNG,用来做上传前的封面图校验。这类dcn格式是团队内部定义的紧凑纹理格式,Dart 生态里没有现成解析器。

解析思路其实不复杂,关键是拿到格式规范。dcn文件内部结构大致是:

  • 文件头 16 字节:前 4 个字节是魔数'DCN1',接下来 2 字节宽、2 字节高、2 字节颜色深度、2 字节保留位,最后 4 字节是像素数据偏移量。
  • 从偏移量开始是像素编码数据,一般是每像素 4 字节的 RGBA,也可能是 zlib 压缩后的像素块。

解析代码示意:

import 'dart:io'; import 'dart:typed_data'; import 'package:image/image.dart' as img; void dcnToPng(String input, String output) { final raw = File(input).readAsBytesSync(); final header = ByteData.sublistView(raw); final magic = String.fromCharCodes(raw.sublist(0, 4)); if (magic != 'DCN1') { throw FormatException('不是有效的 dcn 文件: $input'); } final width = header.getUint16(4, Endian.big); final height = header.getUint16(6, Endian.big); final dataOffset = header.getUint32(8, Endian.big); var pixelBytes = raw.sublist(dataOffset); // 如果像素块是 zlib 压缩的,先解压 final compressed = header.getUint16(14, Endian.big) == 1; if (compressed) { pixelBytes = ZLibCodec().decode(pixelBytes); } final image = img.Image.fromBytes( width: width, height: height, bytes: pixelBytes.buffer, order: img.ChannelOrder.rgba, ); File(output) ..createSync(recursive: true) ..writeAsBytesSync(img.encodePng(image)); }

注意bitDepth和 pixel format 的差异。有些dcn文件是 4 字节 RGBA,但个别变体是 3 字节 RGB 加 1 字节 padding。解析前最好先确认格式说明,或者做一个小工具批量打开验证,别直接假设所有文件结构一致。这里的核心思路不局限于dcn本身——任何自定义二进制格式的解析,都是“先读头拿元信息 → 按规范解出像素块 → 用现成 PNG 编码器转码”这个套路。

说到这里,目前这套脚本已经在团队仓库里稳定跑了小半年,最大的感受是:把维护成本前置到设计与封装上,后面每次加渠道、加平台、加流程节点都轻松很多。如果你也在维护 Flutter 项目的自动化工具链,可以先用一个小命令试试水,比如只封装一个flutter build加版本号注入的逻辑,跑顺了再逐步扩充。工具链的演进从来不是一步到位的,但语言选型这种底层的决定,越早统一越省心。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询