从“点点点”到一键出包:我把 uni-app x Android 离线打包做成了脚本

之前的一篇文章,我写了怎么给 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。

下次再见!🌈


相关推荐
Lstone73641 小时前
从 Jetpack Compose 到 CMP:跨平台开发学习笔记
前端
deli0071 小时前
多加一粒沙,整堆为什么就塌了?sandpile 模型 20 万粒实测
前端
涛涛ing1 小时前
乱序HTML流正式进入浏览器:前端流式渲染的“框架特权”被终结了
前端
__sjfzllv___1 小时前
在职前端Leader学习/转行 AI Agent -DAY73
前端
用户1733598075371 小时前
纯前端 PDF 压平避坑指南:压平后表单字段变了?
前端·javascript·vue.js
nyaomaru1 小时前
将一个真实的 TypeScript OSS 库从 tsup 迁移到 tsdown
前端·typescript
胡写代码1 小时前
雪花 ID 传到前端就变了个数?我用全局 Long 转 String 一次收口
前端·后端
沐言人生1 小时前
82.4k 星!把十几万行代码变成知识图谱,新人终于不用硬啃了
前端·后端·github
溪语流沙1 小时前
【Web全栈进阶】JWT无状态认证:签发、校验、刷新
前端·git·python·github