scripts\build-android.ps1¶
源码路径:
scripts\build-android.ps1· 310 行
# 出安卓版:交叉编译 nfarms-mobile → 放进 Gradle 工程的 jniLibs → (可选)打 APK。
#
# 用法:
# .\scripts\build-android.ps1 # 编 .so 并摆好
# .\scripts\build-android.ps1 -Apk # 再调 gradle 打 APK
# .\scripts\build-android.ps1 -DebugBuild # debug 构建(默认 release)
# .\scripts\build-android.ps1 -NdkRoot <目录> # 指定 NDK
#
# 前置:
# * Android NDK(rustup 的安卓目标 + NDK 的 clang/llvm-ar)
# * 出 APK 还要 Gradle(本机没有;见 README 3.7 那段说明)
[CmdletBinding()]
param(
[switch]$Apk,
# 注意别叫 -Debug:[CmdletBinding()] 已经提供同名的**通用参数**,
# 再声明一个会直接报 "A parameter with the name 'Debug' was defined multiple times"。
[switch]$DebugBuild,
[string]$NdkRoot = '',
# minSdk。要和 android\app\build.gradle 里的 minSdk 一致。
[int]$Api = 24,
[string]$Triple = 'aarch64-linux-android'
)
$ErrorActionPreference = 'Stop'
$ws = Split-Path -Parent $PSScriptRoot
# 没 source 过 dev.ps1 时自己补上。**这一步不能省**:
# 本机没有 MSVC,不选 GNU 工具链的话 cargo 会用默认的 msvc 目标 ——
# 连 build script 都编不过(会去下 windows_x86_64_msvc 那些包),
# 而 CARGO_HOME 没指到工作区内还会顺带撞上沙箱的 TLS 限制。
if (-not $env:RUSTUP_TOOLCHAIN) {
$dev = Join-Path $ws 'dev.ps1'
if (Test-Path $dev) { . $dev | Out-Null }
}
# ================================================================
# 1. 交叉编译环境
#
# 等价于 `cargo-ndk` 在背后做的事 —— 没引那个工具,直接设环境变量:
# CC_<target> / AR_<target> 给 cc-rs(ring 之类的 crate 要编 C 代码)
# CARGO_TARGET_<TARGET>_LINKER 给 rustc 链接
# ================================================================
if (-not $NdkRoot) {
foreach ($p in @(
'C:\Cy\Install\Services\android-ndk',
"$env:LOCALAPPDATA\Android\Sdk\ndk",
"$env:ANDROID_HOME\ndk",
"$env:ANDROID_SDK_ROOT\ndk")) {
if (-not (Test-Path $p)) { continue }
# 目录下可能直接是 NDK,也可能隔一层版本号
$inner = Get-ChildItem $p -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName 'toolchains\llvm\prebuilt') } |
Select-Object -First 1
if ($inner) { $NdkRoot = $inner.FullName; break }
if (Test-Path (Join-Path $p 'toolchains\llvm\prebuilt')) { $NdkRoot = $p; break }
}
}
if (-not $NdkRoot -or -not (Test-Path $NdkRoot)) {
throw "找不到 Android NDK。装一份,或用 -NdkRoot 指定。`n(出 APK 一定要它:ring 之类的 crate 要编 C 代码)"
}
$prebuilt = Join-Path $NdkRoot 'toolchains\llvm\prebuilt\windows-x86_64\bin'
$clang = Join-Path $prebuilt "$Triple$Api-clang.cmd"
if (-not (Test-Path $clang)) { $clang = Join-Path $prebuilt "$Triple-clang.cmd" }
if (-not (Test-Path $clang)) { throw "找不到 $Triple 的 clang 包装脚本:$prebuilt" }
$llvmAr = Join-Path $prebuilt 'llvm-ar.exe'
if (-not (Test-Path $llvmAr)) { throw "找不到 llvm-ar.exe:$prebuilt" }
# 目标里的 `-` 在环境变量名里要写成 `_`;cc-rs 新旧两套名字都设上
$underscored = $Triple -replace '-', '_'
Set-Item -Path "Env:CC_$underscored" -Value $clang
Set-Item -Path "Env:AR_$underscored" -Value $llvmAr
Set-Item -Path "Env:CXX_$underscored" -Value $clang
Set-Item -Path "Env:CC_$Triple" -Value $clang
Set-Item -Path "Env:AR_$Triple" -Value $llvmAr
Set-Item -Path "Env:CARGO_TARGET_$($Triple.ToUpper() -replace '-','_')_LINKER" -Value $clang
Write-Host "NDK = $NdkRoot" -ForegroundColor DarkGray
Write-Host "clang = $clang" -ForegroundColor DarkGray
if (-not $env:CARGO_HOME) { $env:CARGO_HOME = Join-Path $ws '.cargo-home' }
# ================================================================
# 2. 编 .so
# ================================================================
$profile = if ($DebugBuild) { 'debug' } else { 'release' }
# 用数组拼参数再 splat,别用「可空位置参数 + 条件分支」的写法 ——
# PowerShell 很容易把它拼出一个空参数,cargo 会报 "unexpected argument '-' found"。
$cargoArgs = @('build', '-p', 'nfarms-mobile', '--target', $Triple)
if (-not $DebugBuild) { $cargoArgs += '--release' }
if ($env:RUSTUP_TOOLCHAIN) { $cargoArgs = @("+$env:RUSTUP_TOOLCHAIN") + $cargoArgs }
Write-Host ("cargo " + ($cargoArgs -join ' ')) -ForegroundColor DarkGray
Push-Location $ws
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
& cargo @cargoArgs 2>&1 | Write-Host
$code = $LASTEXITCODE
} finally {
$ErrorActionPreference = $prevEap
Pop-Location
}
if ($code -ne 0) { throw "cargo build 失败 (exit $code)" }
# ================================================================
# 3. 摆到 Gradle 工程里
# ================================================================
$so = Join-Path $ws "target\$Triple\$profile\libnfarms_mobile.so"
if (-not (Test-Path $so)) { throw "没有产物:$so" }
$jniDir = Join-Path $ws 'android\app\src\main\jniLibs\arm64-v8a'
New-Item -ItemType Directory -Force -Path $jniDir | Out-Null
Copy-Item $so $jniDir -Force
Write-Host ''
Write-Host ("[OK] {0} {1:N1} MB" -f (Join-Path $jniDir 'libnfarms_mobile.so'), ((Get-Item $so).Length / 1MB)) -ForegroundColor Green
# ================================================================
# 4. 启动图标:从 assets\nfarms.ico 里剥出那张 256×256 PNG 再缩成各密度
# ================================================================
$ico = Join-Path $ws 'assets\nfarms.ico'
if (Test-Path $ico) {
$png = Join-Path $env:TEMP 'nfarms-icon-256.png'
$b = [System.IO.File]::ReadAllBytes($ico)
$count = [BitConverter]::ToUInt16($b, 4)
for ($i = 0; $i -lt $count; $i++) {
$o = 6 + $i * 16
$size = [BitConverter]::ToUInt32($b, $o + 8)
$off = [BitConverter]::ToUInt32($b, $o + 12)
if (($b[$off] -eq 0x89) -and ($b[$off + 1] -eq 0x50)) {
$bytes = New-Object byte[] $size
[Array]::Copy($b, $off, $bytes, 0, $size)
[System.IO.File]::WriteAllBytes($png, $bytes)
}
}
if (Test-Path $png) {
Add-Type -AssemblyName System.Drawing
$src = [System.Drawing.Image]::FromFile($png)
$densities = @{ 'mdpi' = 48; 'hdpi' = 72; 'xhdpi' = 96; 'xxhdpi' = 144; 'xxxhdpi' = 192 }
foreach ($d in $densities.Keys) {
$px = $densities[$d]
$dir = Join-Path $ws "android\app\src\main\res\mipmap-$d"
New-Item -ItemType Directory -Force -Path $dir | Out-Null
$bmp = New-Object System.Drawing.Bitmap $px, $px
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.DrawImage($src, 0, 0, $px, $px)
$g.Dispose()
$bmp.Save((Join-Path $dir 'ic_launcher.png'), [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
}
$src.Dispose()
Write-Host '[OK] 启动图标已生成 5 种密度(mdpi..xxxhdpi)' -ForegroundColor Green
}
}
# ================================================================
# 5. 打 APK —— 用 SDK 自带的 aapt2 / zipalign / apksigner,**不需要 Gradle**
#
# 为什么不用 Gradle:这个应用**一行 Java/Kotlin 都没有**(纯 NativeActivity,
# android:hasCode="false"),Gradle + AGP 那一整棵依赖树纯属白拖;而且 AGP 要从
# maven.google.com 拉,那台机器在国内不可达,得再配一堆镜像。
#
# APK 本身就是个 zip,结构是:
# AndroidManifest.xml aapt2 编成二进制 XML
# resources.arsc aapt2 link 生成
# res/ 编译后的资源(图标)
# lib/arm64-v8a/*.so Android 会 dlopen 它
# META-INF/ apksigner 的签名
#
# android\ 下那个 Gradle 工程仍然保留(要加 Java 代码时用得着),但**默认不走它**。
# ================================================================
if ($Apk) {
# aapt2 / zipalign / apksigner / keytool 都会往 stderr 写东西(进度、警告、
# 甚至正常的提示信息)。PowerShell 5.1 在 EAP='Stop' 下会把 native 的 stderr
# 升级成**终止性异常** —— 工具明明成功了,脚本却在半路断掉。
# 这一段统一放宽:成败靠 $LASTEXITCODE 判断,比看 stderr 可靠。
$ErrorActionPreference = 'Continue'
# --- SDK 定位 ---
$sdk = $env:ANDROID_HOME
if (-not $sdk) { $sdk = $env:ANDROID_SDK_ROOT }
if (-not $sdk) {
foreach ($p in @("$env:LOCALAPPDATA\Android\Sdk", 'C:\Android\Sdk')) {
if (Test-Path $p) { $sdk = $p; break }
}
}
if (-not $sdk -or -not (Test-Path $sdk)) { throw '找不到 Android SDK(设 ANDROID_HOME 或装到默认位置)' }
$bt = Get-ChildItem (Join-Path $sdk 'build-tools') -Directory -ErrorAction SilentlyContinue |
Sort-Object { [version]($_.Name -replace '[^\d.]', '') } -Descending | Select-Object -First 1
if (-not $bt) { throw "SDK 里没有 build-tools:$sdk\build-tools" }
$plat = Get-ChildItem (Join-Path $sdk 'platforms') -Directory -ErrorAction SilentlyContinue |
Sort-Object Name -Descending | Select-Object -First 1
if (-not $plat) { throw "SDK 里没有 platforms:$sdk\platforms" }
$androidJar = Join-Path $plat.FullName 'android.jar'
if (-not (Test-Path $androidJar)) { throw "缺 android.jar:$androidJar" }
$aapt2 = Join-Path $bt.FullName 'aapt2.exe'
$zipalign = Join-Path $bt.FullName 'zipalign.exe'
$apksigner = Join-Path $bt.FullName 'apksigner.bat'
Write-Host "build-tools = $($bt.Name) platform = $($plat.Name)" -ForegroundColor DarkGray
# --- 版本号取自 Cargo.toml,免得两处手工同步 ---
$verLine = Select-String -Path (Join-Path $ws 'Cargo.toml') -Pattern '^version\s*=\s*"([^"]+)"' | Select-Object -First 1
$versionName = if ($verLine) { $verLine.Matches[0].Groups[1].Value } else { '0.1.0' }
$parts = $versionName.Split('.')
$versionCode = [int]$parts[0] * 10000 + [int]$parts[1] * 100 + [int]$parts[2]
$out = Join-Path $ws 'dist-android'
$work = Join-Path $ws 'build\android-apk'
New-Item -ItemType Directory -Force -Path $out, $work | Out-Null
# --- 5.1 编译资源 ---
$resZip = Join-Path $work 'res.zip'
Remove-Item $resZip -Force -ErrorAction SilentlyContinue
& $aapt2 compile --dir (Join-Path $ws 'android\app\src\main\res') -o $resZip | Write-Host
if ($LASTEXITCODE -ne 0) { throw 'aapt2 compile 失败' }
# --- 5.2 链接出未签名的 APK ---
#
# aapt2 要求 <manifest> 上有 `package` 属性;而 AGP 8 反过来**禁止**手写它
# (改用 build.gradle 里的 namespace)。所以这里生成一份**临时 manifest**
# 把 package 注进去,源文件保持 Gradle 兼容 —— 两条路都能走。
$pkg = (Select-String -Path (Join-Path $ws 'android\app\build.gradle') -Pattern "namespace\s+'([^']+)'" |
Select-Object -First 1).Matches[0].Groups[1].Value
if (-not $pkg) { throw '从 android\app\build.gradle 里读不到 namespace' }
$tmpManifest = Join-Path $work 'AndroidManifest.xml'
$mf = Get-Content (Join-Path $ws 'android\app\src\main\AndroidManifest.xml') -Raw -Encoding UTF8
$mf = $mf -replace '(<manifest\s+xmlns:android="[^"]*")', ('$1' + "`r`n package=`"$pkg`"")
[System.IO.File]::WriteAllText($tmpManifest, $mf, (New-Object System.Text.UTF8Encoding $false))
$unsigned = Join-Path $work 'nfarms-unsigned.apk'
Remove-Item $unsigned -Force -ErrorAction SilentlyContinue
& $aapt2 link -o $unsigned `
-I $androidJar `
--manifest $tmpManifest `
-R $resZip `
--min-sdk-version 24 --target-sdk-version 36 `
--version-code $versionCode --version-name $versionName `
--auto-add-overlay
if ($LASTEXITCODE -ne 0) { throw 'aapt2 link 失败' }
# --- 5.3 把 .so 塞进 lib/arm64-v8a/ ---
# 用 .NET 的 ZipArchive 追加(aapt2 产出的就是普通 zip)
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$soSrc = Join-Path $jniDir 'libnfarms_mobile.so'
$zip = [System.IO.Compression.ZipFile]::Open($unsigned, 'Update')
try {
$entryName = 'lib/arm64-v8a/libnfarms_mobile.so'
$old = $zip.GetEntry($entryName)
if ($old) { $old.Delete() }
[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
$zip, $soSrc, $entryName, [System.IO.Compression.CompressionLevel]::Optimal)
}
finally { $zip.Dispose() }
Write-Host "[OK] 已塞入 $entryName" -ForegroundColor DarkGray
# --- 5.4 对齐(签名前必须做)---
$aligned = Join-Path $work 'nfarms-aligned.apk'
Remove-Item $aligned -Force -ErrorAction SilentlyContinue
& $zipalign -f -p 4 $unsigned $aligned
if ($LASTEXITCODE -ne 0) { throw 'zipalign 失败' }
# --- 5.5 签名 ---
# 用自签的 debug 证书。正式发布换成你自己的 keystore:
# apksigner sign --ks your.jks --ks-key-alias youralias out.apk
$ks = Join-Path $work 'nfarms-debug.keystore'
if (-not (Test-Path $ks)) {
# 注意:两条分支都要给出**字符串路径**。
# Get-Command 返回 ApplicationInfo(有 .Source),而 Get-Item 返回 FileInfo
# (没有 .Source)—— 混着用会变成 `& $null`,报 "The expression after '&' ..."。
$keytool = (Get-Command keytool -ErrorAction SilentlyContinue).Source
if (-not $keytool) {
# JDK 装了但没进 PATH(本机就是:只有 C:\Program Files\Java\jdk-26.0.1)
$keytool = Get-ChildItem 'C:\Program Files\Java' -Directory -ErrorAction SilentlyContinue |
ForEach-Object { Join-Path $_.FullName 'bin\keytool.exe' } |
Where-Object { Test-Path $_ } | Select-Object -First 1
}
if (-not $keytool) { throw '找不到 keytool(在 JDK 的 bin 里);生成签名证书需要它' }
& $keytool -genkeypair `
-keystore $ks -storepass android -keypass android `
-alias nfarms -keyalg RSA -keysize 2048 -validity 10000 `
-dname 'CN=NFarms HUB, OU=NFarms, O=NFarms, L=Shanghai, C=CN' 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'keytool 生成证书失败' }
Write-Host '[OK] 已生成自签证书(build\android-apk\nfarms-debug.keystore)' -ForegroundColor DarkGray
}
# 变量别叫 $apk!PowerShell 变量名**大小写不敏感**,$apk 就是上面 param 里的
# [switch]$Apk —— 给它赋字符串会抛
# "Cannot convert value "System.String" to type "...SwitchParameter""
# 而且报错行指向的是这一行赋值,不是 param,很难看出根因。
# 更坑的是脚本会继续跑(错误要么被 EAP 降级,要么你漏看了),
# 于是 `apksigner --out $apk` 收到的是 "True",在当前目录留下一个名叫 True 的文件。
$apkFile = Join-Path $out "nfarms-hub-$versionName.apk"
Remove-Item $apkFile -Force -ErrorAction SilentlyContinue
& $apksigner sign --ks $ks --ks-pass pass:android --key-pass pass:android `
--ks-key-alias nfarms --out $apkFile $aligned
if ($LASTEXITCODE -ne 0) { throw 'apksigner 签名失败' }
# --- 5.6 校验 ---
Write-Host ''
& $apksigner verify --print-certs $apkFile 2>&1 | Select-Object -First 4 | ForEach-Object { Write-Host " $_" }
Write-Host ''
Write-Host ("[OK] APK: {0} {1:N1} MB" -f $apkFile, ((Get-Item $apkFile).Length / 1MB)) -ForegroundColor Green
}