自动构建与自动更新
- 2022 年 10 月 16 日
- 笔记
- WinUI, WinUI 3 踩坑记
本文记录的方法不是 WinUI 3 专属的,也可以用于其他框架。
某不能说名字的软件在开发时需要频繁更新版本,为了兼顾开发体验和用户体验,使用自动化流程达到这一目的。
自动构建
代码托管在 GitHub 上,使用 GitHub Actions 是最合适的选择。在写自动构建的流程之前,考虑了这么几个问题:
- GitHub 在部分地区速度太慢或不可用,不适合作为安装包的下载来源,选择了白嫖阿里云 OSS 和 Cloudflare 代理。
- 为了区分自动构建的版本和发行版,需要在版本号后加上
-dev.*
,选择了在文件的ProductVersion
属性中记录当前版本号。
准备环境
运行 Actions 的系统为 windows-latest
,默认 Shell 为 PowerShell
。
- name: Checkout
uses: actions/checkout@v3
with:
# 迁出所有代码,后面有用
fetch-depth: 0
- name: Install .NET Core
uses: actions/setup-dotnet@v3
with:
dotnet-version: 7.0.x
- name: Restore the Packages
run: dotnet restore
# 配置 阿里云 OssUtil
- name: Setup Aliyun OssUtil
run: |
# 见下方 PowerShell 代码块
# 下载适合 Windows 的 OssUtil 安装包到临时文件夹
Invoke-WebRequest //gosspublic.alicdn.com/ossutil/1.7.14/ossutil64.zip -OutFile ${{runner.temp}}/ossutil.zip
# 解压
Expand-Archive -Path ${{runner.temp}}/ossutil.zip -DestinationPath ${{runner.temp}}
# 把 exe 文件移动到 system32 文件夹下,为了在后续步骤中可以直接使用 ossutil 命令
Move-Item -Path ${{runner.temp}}/ossutil64/ossutil64.exe -Destination C:/Windows/System32/ossutil.exe -Force
# 配置账号
ossutil config -e ${{ secrets.OSS_ENDPOINT }} -i ${{ secrets.ACCESS_KEY_ID }} -k ${{ secrets.ACCESS_KEY_SECRET }}
版本号
每次构建的版本必须比上一次构建的版本要大,可以通过当前时间生成版本 v1.2.3-dev.221015.2201
,或者通过环境变量 ${{github.run_number}}
获取当前 Action 是第几次执行,就是 Actions 页面每次执行时 # 后面的那个数字。但是这两个方法生成的版本号是一直递增的,我比较倾向于使用 上个版本发布后的提交数 作为 -dev.
后的数字。
下面是生成目标版本号的函数,可以不用看
function Get-TargetVersion {
# 获取上一个 tag,迁出代码时设置 fetch-depth: 0
$lastTag = git describe --abbrev=0 --tags
if ([String]::IsNullOrWhiteSpace($lastTag)) {
# 没有 tag
$lastTag = 'v0.1.0'
# 提交次数
$commitCount = git rev-list HEAD --count
}else {
# 有 tag
$commitCount = git rev-list "$lastTag..HEAD" --count
}
# 把 tag 开头的 v 去掉
if ($lastTag.StartsWith('v') -or $lastTag.StartsWith('V')) {
$lastTag = $lastTag.SubString(1)
}
# 引入一个新库 NuGet.Versioning
$null = [System.Reflection.Assembly]::LoadFrom('NuGet.Versioning.dll')
# TryParse 方法中使用 out 修饰的参数必须先定义
[ref]$lastVer = [NuGet.Versioning.SemanticVersion]::Parse('0.1.0')
# 解析上一个版本号,有可能是 v1.2.3、v1.2.3-preview.1 或更复杂的内容
if ([NuGet.Versioning.SemanticVersion]::TryParse($lastTag, $lastVer)) {
if ($lastVer.Value.IsPrerelease) {
# 上个版本是预发行版,直接在后面加上 -dev.*
$targetVer = "$lastVer-dev.$commitCount"
}
else {
# 上个版本是正式版,版本号+1后再接 -dev.*
$targetVer = "$($lastVer.Value.Major).$($lastVer.Value.Minor).$($lastVer.Value.Patch+1)-dev.$commitCount"
}
}
else {
# 理论上来说不会运行到这里
$targetVer = "$lastVer-dev.$commitCount"
}
Write-Output $targetVer
}
在这里使用了一个库 NuGet.Versioning,这是解析 语义化版本 v2.0 一个库,和 System.Version
仅支持 4 个数字不同,它支持解析和比较如 1.2.3-preview.4.5.6+abcdef
这样的格式。
发布
$v = Get-TargetVersion
dotnet publish -p:Configuration=$env:Configuration -p:Platform=$env:Platform -p:Version=$v -p:DefineConstants=DEV
压缩并上传至 OSS
使用 zip 压缩后文件大小大约是 60 MB,使用 7zip 则是 40 MB,用户的网络环境不同,能压缩就尽量压缩。在上传到 OSS 时自定义元数据 x-oss-meta-version
为目标版本号,软件可以通过检查这个值判断是否应该更新。
# 安装 7zip 压缩模块
Install-Module -Name 7Zip4Powershell -Scope CurrentUser -Force
New-Item -Path ./publish -ItemType Directory -Force
# 压缩等级最大,压缩时包含根目录
Compress-7Zip -ArchiveFileName software.7z -Path $env:Publish_Path -OutputPath ./publish -CompressionLevel Ultra -PreserveDirectoryRoot
# 上传至 OSS
ossutil cp -rf ./publish/* oss://${{ secrets.OSS_BUCKET_NAME }}/software.7z --meta x-oss-meta-version:$v
自动更新
非打包的 WinUI 项目和 Win32 项目一样,更新时会遇到文件被占用的情况,一般是通过额外的更新进程解决这个问题。不这么一个小东西要啥自行车,用 PowerShell 一把梭,不仅能规避占用问题,还能少一个项目。
我原本是打算在 PowerShell 中下载新版本安装包的,PowerShell 会自动展示下载进度,但是我发现下载速度始终限制在 1 MB/s 左右,而在浏览器或 C# 中下载速度能够达到 6 MB/s。在网上搜了一圈后发现是 实时显示下载数据量拖慢了性能,参考 Issue Progress bar can significantly impact cmdlet performance,这个问题已在 PowerShell Core 修复,在 Windows 自带的 PowerShell 中仍然存在,唯一的解决办法是禁止显示下载进度。
40 MB 的安装包不显示下载进度还是不太好,只能多写一点代码在软件中下载了(略),下面是 PowerShell 更新脚本代码。
# 出现错误时停止执行后续命令,若不加这一句,即使出现错误也无法被 catch
# 代码省略了最外部的 try-catch 部分
$ErrorActionPreference = 'Stop'
# 检查新版本的安装包是否存在
if(![System.IO.File]::Exists('./temp/software.7z')) {
# 不存在时重新下载
$null = New-Item "./temp" -ItemType "Directory" -Force
Invoke-WebRequest -Uri $url -UseBasicParsing -OutFile "./temp/software.7z"
}
# 检查 7zip 解压模块是否存在
if(![System.IO.File]::Exists('./7Zip4Powershell/7Zip4Powershell.psd1')) {
# 应该使用下面这一句安装解压模块,但是大陆连接源站 PowerShell Gallery 非常困难
# Install-Module -Name 7Zip4Powershell -Scope CurrentUser -Force
# 所以需要自行分发解压模块
Invoke-WebRequest "url/to/7Zip4Powershell.zip" -UseBasicParsing -OutFile "./7Zip4Powershell.zip"
Expand-Archive -Path "./7Zip4Powershell.zip" -DestinationPath "./" -Force
Remove-Item -Path "./7Zip4Powershell.zip" -Force -Recurse
}
# 导入模块
Import-Module -Name "./7Zip4Powershell/7Zip4Powershell.psd1" -Force
# 解压
Expand-7Zip -ArchiveFileName "./temp/software.7z" -TargetPath "./temp/"
# 检查软件是否仍在运行
try {
# 没找到进程时会抛错,需要 catch
$null = Get-Process -Name "software"
Write-Host "software.exe 正在运行,等待进程退出" -ForegroundColor Yellow
Wait-Process -Name "software"
# 停 1s 等待资源释放
Start-Sleep -Seconds 1
} catch { }
# 替换文件
Copy-Item -Path "./temp/*" -Destination "./" -Force -Recurse
# 重启软件
Invoke-Item -Path "./software.exe"
# 清理安装包
Remove-Item -Path "./temp" -Force -Recurse
就这样,使用 PowerShell 代替了额外的更新进程,也不用考虑更新进程需要更新的问题。唯一需要注意的是手动导入的 7zip 解压模块中的文件会被占用,即使使用 Remove-Module
移除模块后,载入的程序集仍不会被卸载,所以不能在安装包中包含该模块,使用前下载最合适,下载后可以一直使用,不用考虑更新问题。
有经验的读者可能会问:哎,系统默认禁用没有签名的 PowerShell 脚本,你总不可能还要让用户手动解除限制吧。这个问题很好解决,不能运行脚本可以运行命令嘛,把脚本内容当作启动参数扔给 PowerShell 进程就好了。
// 这里不是脚本文件路径,而是脚本内容
const string script="...";
Process.Start("PowerShell", script);
最后
单进程软件真的没办法自己更新吗?其实不然,可执行文件在运行期间无法被删除和覆盖,但是可以移动啊。把 exe 和 dll 文件移动到一个待删除文件夹,这个时候就可以把新文件移动到原来的位置。但是还有个问题,非可执行文件被占用后不能移动,所以还是有可能会更新失败,这个方法能用但是不靠谱。