
简介这是一份使用纯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因为脚本有自己依赖的第三方包比如args、yaml、http等。把脚本依赖和主项目依赖隔离能避免污染 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; Futurevoid main(ListString 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这个官方维护的命令行解析包比自己在ListString里手工判断参数要可靠得多。子命令的设计让脚本支持android、ios、publish这种直观的调用方式后续加新命令时只需要新增一个 case。2.3 进程调用的统一封装日志要流式退出码要显式打包脚本的实质就是不断调用flutter、pod、gradle等外部进程。我把进程调用统一封装成一个ProcessRunner原因是避免每次调用都重复处理日志输出和退出码逻辑。class ProcessRunner { Futureint run( String executable, ListString 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 打包的命令本身不复杂但要让脚本真正“自动化”关键在于把版本号读出来、注入构建命令并在构建完成后把产物归档到约定目录。FutureFile 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参数用来区分test、staging、prod等渠道不同渠道通过--dart-defineCHANNEL$channel注入到代码里。Android 产物的路径有历史坑需要注意新版 Flutter 的产物在build/app/outputs/flutter-apk/app-release.apk但有些老版本的 Flutter 或特殊配置下会在build/app/outputs/apk/release/。写脚本时不要写死路径最好在打包前先确认一下目录结构或者通过glob包做一次通配查找。3.2 iOS 导出动态生成 ExportOptions.plistiOS 打包比 Android 多一个导出环节。核心问题是flutter build ipa时Xcode 需要一个ExportOptions.plist来指定签名方式、导出方法等。这个文件在不同项目、不同环境下的配置不一样所以我选择在脚本里根据参数动态生成。FutureString 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-plistdist/ExportOptions.plist注意 iOS 构建的产物路径是build/ios/ipa/*.ipa和 Android 一样也需要复制到dist/目录并按版本号归档。另外 iOS 打包依赖签名证书和描述文件这些状态不是脚本层面能控制好的所以我在脚本里加了一个前置检查先执行flutter doctor检查 Xcode 环境是否就绪避免构建到一半因为签名问题失败。3.3 失败重试与构建日志落盘打包过程难免遇到网络抖动导致的依赖下载失败、Gradle 超时等偶发问题。在ProcessRunner上层我加了一个简单但实用的重试机制Futureint runWithRetry({ required ListString 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 上传并不复杂Futurevoid 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(ListString 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.dartDart 会认为你加载的是一个库而不是一个可执行程序自然找不到main函数。第二种main函数被定义成了私有函数或者签名不对void _main() { } // 错误私有函数不会被当作入口 void main() { } // 正确 Futurevoid main() async { } // 正确异步入口是合法的 void main(ListString 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加版本号注入的逻辑跑顺了再逐步扩充。工具链的演进从来不是一步到位的但语言选型这种底层的决定越早统一越省心。本文还有配套的精品资源点击获取