Flutter CI/CD 指北:Desktop、Web & Android 平台

持续集成和持续交付 (CI/CD) 对于现代软件开发来说至关重要。它允许团队频繁地将代码更改集成到主分支,并自动构建、测试和部署应用程序。这有助于确保应用程序始终处于最新状态,并减少手动错误的可能性。

对于 Flutter 开发,有许多 CI/CD 工具可用,比如 GitHub Actions 和 Jenkins,互联网上相关的文档和资料丰富,但大部分都是以 Android 和 iOS 移动平台作为编译目标,而较少有涉及 Windows,macOS 以及 Web 平台的。实际上,由于编译目标平台的不同,选用的系统镜像、编译过程差异较大,且国内开发者更多使用阿里云等云服务商服务,部署也多使用虚拟机而非 SaaS 平台,因此能够拿来即用的相关集成和交付方案就更加凤毛麟角。因此本文提出了一个以 “虚拟机部署和简单对象存储” 为中心的 Flutter 桌面平台 CI/CD 方案,期望解决此问题。

跨平台的客户端解决方案需要一个跨平台的 CI/CD 方案做支撑

我的 Flutter Web 和 Windows 平台项目一开始没有集成 CI/CD,编译、打包都是手动敲 flutter 命令实现的。随着交付频次增高,手工打包和版本管理越来越容易出错,因此我使用 babashka 写了一个 release.clj 的脚本来做这件事,这个脚本就是简单的调用 shell 命令执行编译、版本文件创建、压缩和重命名工作。后来由于需要将打包好的二进制分发到简单文件存储,即阿里云的 OSS,因此 shell 脚本增加了调用 ossutil 执行文件上传的功能,babashka 是基于 GraalVM 的跨平台工具,ossutil 也是,不过这两个工具都不可思议的大,babashka 压缩后 20M,ossutil 10M,这个时候 CI/CD 还仅仅在开发机上,虽然使用的工具有跨平台能力,但过于臃肿的工具集并不适合随项目仓库分发,因此依旧受限于特定平台。

(defn pull-backend
  "运行远程命令更新代码"
  []
  (println
   (sh/sh "plink" "-ssh" 
          "-l" "root" "-batch"
          "-pwfile" "deploy.pass"
          "abc.chchma.com"
          "cd /root/xxx && git pull")))

为了解决在任意项目支持的平台都可以执行 CI/CD 功能,我开始将 Clojure 脚本改写为 Dart 文件,Flutter 本身就是一个 Dart 项目,因此写一个 Dart 文件用于 CI/CD 无疑是最简单的,随着项目分发的,可根据项目定制的方案,在执行 CI/CD 时,只需要 dart run release.dart 即可。

一开始,我仅仅用 Dart CI/CD 脚本调用 Shell,背后还是平台安装的 dart、flutter、ossutil 工具,只是现在甩开了 babashka,于是我将 CI/CD 流程移到了一台 Windows 虚拟机。但随着项目增多和需求增多,简单的上传 Windows 安装包不再能满足需要,我还要上传版本信息到 OSS,如果服务有后端,则需要通过 putty 的 plink 工具执行一些 SSH 命令在后端拉去新代码并执行重新字节码生成工作。随着依赖的外部工具增多,CI/CD 对平台的依赖也增多,如果需要跨平台,则需要依赖的外部工具全部可以跨平台。

Windows 和 MacOS 上的 SSH 工具不能提供直接密码进入,而必须需要使用密钥以及交互式密码输入,并且第一次还需要交互式验证虚拟机指纹,这是一个头疼的问题。我在 Windows 平台曾经魔改过 Putty 的 SSH 和 plink 工具,删除了交互式验证,允许在任何情况下直接通过密码鉴权执行 SSH 命令,但 Putty 并不是一个跨平台的技术,因而我面临着类似于当年 React Native 这样的困境,以及所有试图使用本机插件实现“虚假跨平台”能力的技术困境:在不同的平台,支持的功能是有差异的,这严重破坏了跨平台保证。

这个问题的本质是,所有声称跨平台的技术,都面临着将功能通过跨平台端或本机端实现的抉择,跨平台端的能力越弱,性能越差,那么就越需要使用本机端实现对应的功能。React Native 和 Flutter 的业务逻辑都可以跨平台,但 RN 的组件受限于 DOM 性能需要在本机端实现,而 Flutter 借助于另开炉灶的 Dart 和新式 “Web” 彻底避免了这一问题,在本机端只留下一张画布,以及特定平台能力接口,这也是为什么 Flutter 一定会打败 React Native,包括所有基于 Web 实现的跨平台方案的原因,哪怕它在 5 年前生态还不成熟的时候(当然,国内流行的本机 + WebView 模式还会存在很长一段时间,但这种模式并不是一种所谓的跨平台技术,只是并行的,几乎互相隔离的两个平台而已)。

跨平台的客户端解决方案需要一个跨平台的 CI/CD 方案做支撑。

由于基于 Dart 的 CI/CD 方案随项目分发,所以其本质是可以利用 pub.dev 上丰富的生态功能的,只需要将其依赖添加到 pubspec.yaml 中的 dev_dependencies 即可。因此借助于 dartssh2, archive, flutter_oss_aliyun 这三个库,我彻底摆脱了 ssh、winrar 以及 ossutil 这些臃肿、不跨平台的本机工具,而仅依赖 flutter sdk 实现编译、压缩、文件修改、SSH 和 SFTP、版本信息 OSS 分发等工作:

await runDetail(["flutter build web --release"]);
await ZipFileEncoder().zipDirectoryAsync(Directory(path.join("build", "web")),
    filename: path.join("build", "web.zip"));

final file = await sftp.open(zipPath,
      mode: SftpFileOpenMode.create | SftpFileOpenMode.write);
await file.write(File(path.join("build", zipName)).openRead().cast());
await client.cmd("cd $targetPath; unzip -o $zipName;");

await ossUtil.cp("build/web.zip", MetaInfo.ossDir,
    isFolderRecursive: true, override: true);

await withTempFileCall(
    tempFile: MetaInfo.ossSupportLocalTempFile,
    write: MetaInfo.ossSupportContent,
    func: () async => await ossUtil.cp(
        MetaInfo.ossSupportLocalTempFile, MetaInfo.ossSupportFilePath,
        override: true));

而只依赖 flutter SDK 的 CI/CD 实现,则很容易和 Github 仓库的 Action 集成,实现自动发布、部署功能。这里尤其是 Dart 实现 SSH 的引入,允许我们直接在 Action 中将中间产物通过 SFTP 上传到服务端特定目录,对于中小项目而言这很“中国特色”,避免了传统开源项目 Action 最佳实践中那样将产物放在 Github Release 页面或 CircleCI 等第三方存储,还需要特定令牌的 URL 触发下载等繁琐步骤,基本实现了“借鸡生蛋”的需求,使用公共平台编译、打包,然后灵活发布到私有平台和私有简单文件存储服务中。

下面是一个借助于阿里云 OSS 部署的以 Web 平台作为部署目标的 Flutter 项目的 Github Action:

name: Web Release
on:
  push:
    tags: [ "v*-bd" ]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: szenius/set-timezone@v1.1
        with:
          timezoneLinux: "Asia/Shanghai"
          timezoneMacos: "Asia/Shanghai"
          timezoneWindows: "China Standard Time"
      - uses: actions/checkout@v3
      - name: Install and set Flutter version
        uses: subosito/flutter-action@v2.4.0
      - name: Restore packages
        run: flutter pub get
      - name: Run Build Runner
        run: flutter pub run build_runner build --delete-conflicting-outputs
      - name: Run Release
        run: dart run release.dart i p s
        env:
          DEPLOY_PASS: ${{ secrets.DEPLOY_PASS }}
          BUILD_ENV: actions_ci
      - name: Notice to Slack
        id: slack
        uses: slackapi/slack-github-action@v1.18.0
        with:
          payload: |
            {
              "text": "Flutter Project CI/CD Build ${{ job.status }} <${{ github.event.pull_request.html_url || github.event.head_commit.url }}| HERE>"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
          SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

下面是一个依托 Github Release 存储的以 Android 平台作为部署目标的开源 Flutter 项目的 Github Action:

name: 'APK Release'
on:
    push:
        tags:
        - "v*"
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: 'Set up JDK 11'
        uses: 'actions/setup-java@v1'
        with:
          java-version: '11'
          distribution: 'temurin'
      - name: 'Clone Flutter repository with master channel'
        uses: 'subosito/flutter-action@v1'
        with:
          channel: 'master'
      - name: 'Check Flutter environment'
        run: 'flutter doctor -v'
      - name: 'Checkout code'
        uses: 'actions/checkout@v2'
      - name: 'Get packages'
        run: 'flutter pub get'
      - name: Run Build Runner
        run: flutter pub run build_runner build --delete-conflicting-outputs
      - name: 'Build APK'
        id: 'build'
        run: 'flutter build apk && echo "::set-output name=built-apk::build/app/outputs/flutter-apk/"'
      - name: 'Release'
        uses: "marvinpinto/action-automatic-releases@latest"
        with:
            repo_token: "${{ secrets.GITHUB_TOKEN }}"
            prerelease: false
            files: |
                build/app/outputs/flutter-apk/*.apk
                build/app/outputs/flutter-apk/*.apk.sha1                

实际上,除了 Flutter 项目,我的很多基于 shadow-cljs 的前端项目也是这样做的,通过 package.json 的 scripts 暴露操作,然后通过 npx shadow-cljs run 来运行 Clojure CI 脚本,通过 Java SSH 和 OSS 库实现 CI/CD 集成。

总的来说,虽然 Vercel 和 Cloudflare 以及国内云效等 SaaS 提供了从 Git 仓库到部署的一整套方案,但本质上都是为了将用户绑在他们各自的平台和服务商,使用 Vercel 或 Cloudflare 部署,那么需要将域名或 CDN 指向他们家,相当于前端托管,如果有后端需求,付费的数据库在那里等着呢。所以纯前端项目撸撸羊毛可以,但灵活性受限。而传统的 CircleCI 或 Github Action 提供了更好的 CI,提供产物允许自由部署,但国外的网络环境在国内相当不灵活,且面临着复杂的鉴权、版本管理问题。

因此这套基于任何集成服务商的方案在国内环境下就变得相当有吸引力了,Git 仓库 tag 一打,触发编译流程,自动将产物通过 SFTP 和 OSS 工具回传到目标服务器和我们自己选择的简单存储服务商,完整实现了“借‘机’下蛋”的目的。

等等... 还有一个问题,Github 仓库在国内网络不通畅,借这只鸡下蛋似乎并不方便。实际上,抛开码云、云效这些滥竽充数的 Git 托管商,自己在香港或者新加坡搭建 Gitea 等自托管 Git 服务,然后通过 Github 推送时同步这一功能,即可将自有 Git 服务变成 “Github 服务” 的虚拟代理,这样开发机和部署虚拟机的仓库拉取、同步就方便很多了。