之前的一篇文章,我写了怎么给 uni-app x 做 Android 离线打包。
当时的流程其实不算复杂:
HBuilderX 生成资源 → Android Studio 打开工程 → 替换资源 → 改证书 → 改 AppKey → Sync → Build。
最后成功打出 APK,不再使用搞人心态的 HBuilderX 云打包,
当时我还挺开心。
直到项目真正进入持续开发阶段。
然后我发现:
真正折磨人的,不是一次打包。
而是你每天都要打,反复打。

一天改十次代码,我要打十次包
刚开始的时候,我还可以忍。
改一点东西:
HBuilderX → 生成资源 → Android Studio → Sync → Build。
再改一点:
再来一遍。
原生插件改了一行:
再来一遍。
签名配置改了一下:
再来一遍。
正式包突然有问题:
再来一遍。
最痛苦的是,有一些问题:
自定义基座完全正常,正式包才会出现。
这时候你就会开始怀疑人生。
"是不是我的代码有问题?"
"是不是插件有问题?"
"是不是 Gradle 有问题?"
最后发现:
不是代码的问题,是我刚才少改了一个地方。
然后重新打。
所以我最后做了一件比较便捷的事情:
既然我每次都要重复做,那就让脚本替我做。
这一次,我不再讲"怎么离线打包"
上一篇文章已经把 Android 离线打包的基础流程讲过了。
如果你还没看过,可以先看看上一篇:
《告别漫长的 HBuilderX 云打包排队!uni-app x 安卓本地打包保姆级教程》
上一篇解决的是:
"怎么完成 Android 离线打包?"
这一篇解决的是:
"怎么让 Android 离线打包变成一件一键完成的事情?"
我的目标也很简单:
HBuilderX
↓
生成本地资源
↓
执行脚本
↓
自动修改配置
↓
自动处理依赖
↓
自动编译
↓
自动检查 APK
↓
把最终产物放到指定目录
最好最后只剩下一个指令:
bash
bash scripts/android-offline/prepare-android-offline.sh --env prod
喝口水后给我一个新的apk

一、我为什么要自己写这个脚本?
其实最开始并没有这么复杂。
我的需求只是:
- 自动同步 HBuilderX 生成的资源
- 自动配置签名
- 自动配置渠道
- 自动修改 Manifest
- 自动处理 ABI
- 自动打 APK / AAB
- 自动检查最终产物
但是随着项目不断迭代,事情开始逐渐失控。
因为 Android 离线工程里,有很多东西并不是:
"改一个配置,重新 Build 就完事。"
而是:
改了 A,还必须把 B、C、D 一起改。
例如:
国内 APK 和 Google Play AAB 使用的配置就不完全一样。
国内包:
APK
armeabi-v7a
arm64-v8a
国内签名
国内渠道
Google Play:
AAB
arm64-v8a
Google Play 上传证书
Google 渠道
所以我最后干脆把它们抽象成了几个指令:
bash
--env test
--env prod
--env google
脚本自己决定应该改什么。
二、先把最烦人的"手动切配置"干掉
以前我的脑子里经常需要记这种东西:
"现在打的是 Google 包,所以 ABI 要改一下。"
"Manifest 里面这个权限要删。"
"刚才用了国内证书,现在要换 Google 证书。"
"这个渠道判断不能按照现在的 SHA1。"
"上次正式包删掉了 LeakCanary,这次基座又要加回来。"
这已经不是打包了。
这是在玩:
《找不同:Android 离线工程版》
所以现在脚本会根据环境直接决定:
bash
case "$ENV_NAME" in
test)
# 调试基座
;;
prod)
# 国内正式包
;;
google)
# Google Play
;;
esac
这样至少不会出现:
"我到底改了哪个配置?"
这种低级但非常浪费时间的问题。
三、一个 prod,实际上要打两次
我的 prod 并不是:
执行一次 Gradle
↓
得到一个正式包
而是:
第一次
↓
国内配置
↓
assembleRelease
↓
得到 APK
第二次
↓
切换 Google 配置
↓
clean
↓
bundleRelease + assembleRelease
↓
得到 AAB
也就是说:
同一份 Android 工程,需要连续切两套配置再编译。
大概类似这样:
bash
apply_optimize fat
apply_channel cn
run_assemble assembleRelease
copy_release_package cn
echo "--- 切换到 Google 配置 ---"
apply_optimize
apply_signing google
apply_channel google
force_google_appstore_type
run_assemble "bundleRelease assembleRelease" 1
copy_release_package google
这里第二次我特意加了一次:
ruby
:app:clean
因为切换签名、渠道以后,我不太愿意相信之前留下来的构建产物。
有时候不是不相信自己。
主要是:
害怕自己会给自己挖坑。
所以干脆 Clean。
四、然后我遇到了一个非常阴间的问题:Google Play 重签
这个坑我觉得非常值得单独拿出来讲。
我的渠道判断最开始是这样的:
ini
appStoreType =
if (sha1 == GOOGLE_PLAY_SHA1)
"google"
else
"qrCode"
看起来没什么问题。
本地测试也没问题。
AAB 上传 Google Play 之前也没问题。
然后......
正式发布。
用户安装以后,发现:
怎么跑到国内接口去了?
查了一圈以后才发现:
Google Play 会重新签名。
也就是说:
ini
本地 AAB
↓
使用上传证书
↓
SHA1 = A
Google Play
↓
重新签名
↓
SHA1 = B
而我的代码却在运行时根据 SHA1 判断:
css
A → google
B → qrCode
于是:
本地测试完全正常。
Google Play 正式安装直接走错渠道。
这种问题最恶心的地方就是:
你在自定义基座里很难复现。
因为自定义基座并不是 Google Play 重签之后的那个最终产物。
五、所以 Google 包不能再让它自己判断渠道
既然已经确定:
Google Play 安装后的签名不是我本地上传 AAB 的签名。
那 Google 包就不要再靠 SHA1 猜了。
直接:
ini
appStoreType = "google"
但是这里又有一个坑。
HBuilderX 导出的代码可能存在两种形式:
ini
sha1 == xxx ? "google" : "qrCode"
或者:
arduino
if (sha1 == xxx) {
"google"
} else {
"qrCode"
}
所以脚本不能简单粗暴地:
scss
text.replace(...)
而是需要同时处理这两种情况。
更重要的是:
替换失败必须报错。
因为最危险的情况不是编译失败。
而是:
脚本执行成功
↓
Gradle BUILD SUCCESSFUL
↓
Google Play 上传成功
↓
用户安装
↓
发现服务器连错了
这种问题就属于:
编译成功了,测试时候才发现失败了。
所以现在我的脚本会检查:
css
if still_contains_sha1_channel_logic:
raise SystemExit(...)
没改成功?
直接不让包出来。

六、Manifest 也不能"改一次就不管了"
这个坑其实非常典型。
国内包和 Google 包共用同一份:
AndroidManifest.xml
Google 包需要删除一些权限。
所以我最开始的想法是:
ini
<uses-permission
android:name="android.permission.READ_MEDIA_IMAGES"
tools:node="remove" />
然后打 Google 包。
问题来了。
下一次我要打国内包。
如果之前的:
ini
tools:node="remove"
还在。
那么国内包也会被一起删权限。
所以脚本现在不是:
arduino
Google 包 → 添加 remove
而是:
arduino
先清理历史 remove
↓
根据当前渠道
↓
重新生成 remove
也就是说:
每次打包都从一个相对干净的状态重新计算。
这其实也是我写脚本以后最大的感受之一:
自动化不是把"修改配置"写进脚本就结束了。
真正麻烦的是:
如何保证脚本执行 1 次、2 次、10 次,结果都一样。
这就是所谓的:
幂等性。

七、还有一个正式包才会出现的坑:LeakCanary
我的调试基座里需要:
lua
debug-server
LeakCanary
但是正式包不需要。
而且之前就遇到过正式包启动异常的问题。
最后定位到:
LeakCanary Provider
被带进了正式包。
所以现在逻辑变成:
markdown
调试基座
↓
保留 LeakCanary
正式包
↓
移除 LeakCanary Provider
↓
同时移除对应依赖
这里有个容易犯的错误:
只改 Manifest,不删依赖。
这样并不完整。
因为依赖还在那里,相关类仍然可能被打进来。
所以脚本需要同时处理:
diff
Manifest
+
Gradle dependencies
这类问题也是为什么我后来越来越不喜欢"手动改 Android 工程"。
你今天改了一个地方。
明天可能还要记得另一个地方。

八、16KB Page Size:又是一个"自定义基座没问题,正式包有问题"
最近 Android 打包还有一个比较容易让人踩坑的东西:
16KB Page Size。
这个问题特别适合证明:
"为什么我不想手动打包。"
因为 ABI 和 16KB Page Size 其实是两件事情。
我的国内 APK 可能需要:
arduino
abiFilters 'armeabi-v7a', 'arm64-v8a'
而针对部分 16KB Page Size 设备,则需要:
arduino
abiFilters 'arm64-v8a'
同时还要处理:
ini
packaging {
jniLibs {
useLegacyPackaging = false
}
}
而且还有一个坑:
abiFilters 只能控制哪些 ABI 被打进去。
它不能保证:
"这个 ABI 目录里面就一定没有奇怪的旧 so。"
所以我还需要检查最终产物。
九、脚本不能只负责"打包",还得负责"验包"
这是我后来给脚本加的一个比较重要的思路。
以前是:
Gradle BUILD SUCCESSFUL
↓
"好了,包出来了。"
现在是:
Gradle BUILD SUCCESSFUL
↓
检查 ABI
↓
检查权限
↓
检查签名 SHA1
↓
检查产物
↓
通过
↓
才允许复制
例如检查 ABI:
bash
listing="$(unzip -l "$apk")"
case "$listing" in
*lib/armeabi-v7a/*) ;;
*)
echo "ERROR: 缺少 armeabi-v7a"
exit 1
;;
esac
这里我还踩过一个 Bash 的坑。
最开始我写的是:
perl
unzip -l "$apk" | grep -q 'lib/armeabi-v7a/'
结果明明有这个目录,脚本却告诉我:
没有 armeabi-v7a
我当时:
???
后来才发现:
diff
set -o pipefail
+
grep -q
+
unzip
grep -q 找到以后提前退出。
unzip 还在往管道写。
于是收到:
SIGPIPE
最后整条管道的退出状态反而异常。
所以现在先:
ini
listing="$(unzip -l "$apk")"
再检查。
这种坑你如果只是"会用 Android Studio 打包",大概率永远不会遇到。
但是一旦开始写自动化脚本:
Bash 会非常认真地教育你。
十、还有一个让我真正意识到"脚本有价值"的地方:HBuilderX 导出的 Kotlin
前面的东西还算是:
Android 打包自动化。
到了这里,就开始进入:
"我和 HBuilderX 生成代码之间的私人恩怨。"
因为 HBuilderX 导出的 Kotlin,并不是任何情况下都能直接编译。
例如某些 UTS 导出代码可能出现:
javascript
import uts.sdk.modules.xweBluetooth as GlobalData
但 Kotlin 这里并不是你想怎么给包起别名都可以。
所以脚本会在每次导出后自动修正。
还有一个比较经典的问题:
kotlin
val list = if (res.data != null && res.data.data != null) {
res.data.data!!
}
如果:
kotlin
res.data
是一个 var。
Kotlin 编译器就可能认为:
"你判断完以后,这个变量说不定被别人改了。"
于是 smart cast 不成立。
所以需要先:
ini
val otaLogList = res.data
再使用:
kotlin
val list = if (
otaLogList != null &&
otaLogList.data != null
) {
otaLogList.data!!
}
这些代码如果手工修改当然也可以。
但问题是:
HBuilderX 下一次重新导出,又给你覆盖了。
所以我的选择是:
不改 HBuilderX 的生成逻辑。
而是:
每次导出完成以后,自动给导出物打 Patch。
这才是这个脚本真正有意义的地方。
十一、Patch 有一个非常重要的要求:不能越改越坏
既然每次都要自动修改导出文件,那么必须考虑:
第一次运行
↓
修改成功
第二次运行
↓
不能再修改一次
第三次运行
↓
还是一样
所以我的 Patch 基本都会设计成:
markdown
先判断是否已经修改
↓
已经修改
↓
跳过
没有修改
↓
执行 Patch
比如语言包转换以后,会留下明确的标记。
脚本下一次发现:
__uts_locale_build_
已经存在。
就知道:
这个文件已经处理过了。
直接跳过。
十二、语言包还有一个更离谱的问题
HBuilderX 某些导出代码会把语言包生成成一个非常大的匿名对象:
kotlin
val default__0: UTSJSONObject =
object : UTSJSONObject() {
var `app.name` = "..."
var hello = "..."
// ...
// 几百行
}
这个东西字段一多,Kotlin 编译器就开始不开心。
所以我后来把它改成:
kotlin
fun __uts_locale_fill_0_0(obj: UTSJSONObject) {
obj["app.name"] = "..."
obj["hello"] = "..."
}
再:
kotlin
fun __uts_locale_build_0(): UTSJSONObject {
val obj = _uO()
__uts_locale_fill_0_0(obj)
return obj
}
把一个巨大对象拆成多个小函数。
简单理解:
不要试图让 Kotlin 一口气吃掉几百个字段。
切成 150 个一组。
一口一口吃。
十三、还有一些我本来以为不会出现的问题
例如:
sed 把自己的模板删了
当时想用:
arduino
sed 's/旧/新/' file > file
结果:
先把 file 清空,再执行 sed。
最终得到一个空文件。
属于:
"我本来是来修改文件的,结果我选择了消灭文件。"
所以后来这种修改统一交给 Python。
AGP 8 又给 Manifest 上了一课
UTS 导出的 Manifest 里有时候会出现:
ini
package="xxx"
而 AGP 8 对 namespace 的要求发生了变化。
所以脚本会把:
go
package=
清掉。
同时处理导出 XML 中可能出现的:
arduino
// comment
因为:
XML 不是 JavaScript。
你不能因为它看起来像注释,它就真的是 XML 注释。
十四、最后还有一个很现实的问题:Java 25
这个问题属于:
"我电脑上的东西明明都是最新的,为什么反而不能编译?"
Android Studio 新版本可能自带:
JBR 25
而我这套 Gradle 环境并不接受 Java 25。
所以脚本现在不会直接相信:
JAVA_HOME
而是自己检查机器上可用的 JDK:
erlang
17
18
...
21
...
24
25
只选择 Gradle 支持的版本。
如果有 Java 21:
优先 21。
没有的话:
再选择其它可用版本。
这样换一台电脑,也不需要重新研究一遍:
"为什么 Android Studio 能打开,但是 Gradle 又不能跑?"
十五、到这里,我才发现:所谓"离线打包",其实只是第一步
之前的文章解决的是:
我会离线打包
而这一次解决的是:
我可以把离线打包流程工程化
这两者其实完全不是一个概念。
手动打包:
人记配置
↓
人改文件
↓
人点 Build
↓
人检查结果
自动化以后:
环境参数
↓
脚本计算配置
↓
自动修改导出物
↓
自动处理依赖
↓
自动编译
↓
自动检查
↓
输出产物
真正让我觉得舒服的不是:
"现在一条命令可以打包。"
而是:
"我不用记那么多东西了。"

十六、现在我的实际流程
最终已经简化成:
第一步:HBuilderX 生成本地资源
这里我还是让 HBuilderX 干它最擅长的事情:
生成 uni-app x 本地打包资源(30秒左右导出一个包)
HBuilderX → 发行 → 生成本地打包 App 资源
导出 ios / android 成功
第二步:执行脚本
测试基座:
bash
bash scripts/android-offline/prepare-android-offline.sh \
--bootstrap \
--env test
正式包:
sql
bash scripts/android-offline/prepare-android-offline.sh \
--bootstrap \
--env prod
如果 SDK 和环境已经初始化完成:
bash
bash scripts/android-offline/prepare-android-offline.sh \
--env prod
就可以了。

第三步:拿最终产物
最终脚本会把不同环境的产物放到对应目录。
例如:
lua
unpackage/
├── debug/
│ └── xxx-debug.apk
│
└── release/
├── xxx-cn.apk
└── xxx-google.aab

十七、这套东西最大的意义,其实不是"省几分钟"
如果只是为了省几分钟,我可能根本不会写这个脚本。
真正让我觉得值得记录的是:
它把一个高度依赖个人记忆的流程,变成了一个可以重复执行的工程化流程。
比如:
证书
渠道
ABI
Manifest
依赖
Kotlin Patch
语言包
JDK
Gradle
最终产物检查
这些东西以前都在我的脑子里。
现在:
都在脚本里。
换句话说:
以前是"我知道怎么打包"。
现在变成:
"我把怎么打包这件事情做成了工具。"
这可能才是我觉得这次离线打包最值得记录下来的地方。
最后
如果你只是偶尔打一次 Android 包,其实完全没必要折腾这么多。
上一篇文章那套手动流程已经够用了。
但如果你和我一样:
- 经常修改原生插件
- 经常需要重新打包
- 同时维护调试基座和正式包
- 国内 APK、Google Play AAB 都要出
- 正式包还有一些只有真机 / 商店环境才出现的问题
- 又不想每次都重新点一遍 Android Studio
那么:
把离线打包脚本化,真的会舒服很多。
以前:
"等一下,给半小时我,我先打个包。"
现在:
bash
bash scripts/android-offline/prepare-android-offline.sh --env prod
然后:
去喝水。

如果终端最后出现:
BUILD SUCCESSFUL
OK ABI
OK SHA1
OK permissions
那一刻的感觉还是挺不错的。
毕竟这一次不是:
"终于又手动打出来了。"
而是:
"我把这件破事自动化了。"
下一篇:
下次有空会更新 iOS 端离线打包脚本。 iOS 端更方便,可以一键打包+上传到Testflight。
下次再见!🌈

导出 ios / android 成功 