1. 为什么还要手动跑apksigner:一个被IDE惯坏之后的回归
绝大多数Android开发者的签名经历,都是从Android Studio里点一下"Generate Signed Bundle/APK"开始的。向导式界面把keystore路径、别名、密码、签名版本全填好,点Finish,一个签好名的包就出来了。日常开发这么干没问题,但只要项目稍微复杂一点,你就会发现这条路走不通:CI流水线里没有图形界面、渠道包需要批量重签、加固后的包必须二次签名、给测试同事的包要换一套证书、甚至只是想验证一下某个APK到底用的哪个签名。这些场景下,你最终都会回到同一个命令行工具——apksigner。
apksigner是Android SDK Build-Tools里自带的一个签名与验签工具,从Build-Tools 24.0.3开始正式提供,用来替代早期的jarsigner。它和jarsigner最大的区别在于:jarsigner只懂JAR签名(v1方案),而apksigner完整支持APK Signature Scheme v1/v2/v3/v4,并且能针对不同minSdkVersion自动选择该用哪些方案。这一点非常关键,因为v2及以上方案是把签名信息写在APK的ZIP中央目录之前的"签名块"里,对整个文件做完整性校验,能防篡改,而v1只是对ZIP内的条目逐个签名,存在Janus这类漏洞的利用空间。所以只要你的targetSdk不是上古版本,正确做法就是用apksigner而不是jarsigner。
这篇内容面向的是这样几类人:需要在自己机器上手动签一个APK验证流程的开发者、要写CI脚本把签名步骤自动化的工程同学、拿到别人给的未签名包需要自己补签的测试或运维、以及想搞清楚"签名到底签了什么"的进阶学习者。我会从工具定位讲起,把参数逐个拆开,给出可直接抄的命令,再重点讲那些文档里不会写、只有实际跑过才会遇到的坑——比如v2签名后为什么jarsigner -verify会报错、为什么zipalign必须在签名之前做、密码怎么从命令行安全传入而不是明文暴露在history里。全程以命令行实操为主线,不依赖任何图形界面。
需要先说明一点:下文涉及的具体路径、别名、密码都是示例值,你替换成自己的即可;涉及Build-Tools版本的地方,我会说明如何查你本机装了哪些版本,而不是让你照抄某个固定版本号。
2. 把工具找出来:apksigner到底藏在哪、依赖什么
2.1 定位Build-Tools与apksigner脚本
apksigner不是一个单独下载的二进制,它随Build-Tools一起分发。在SDK目录下,路径形如:
<SDK_ROOT>/build-tools/<version>/apksigner <SDK_ROOT>/build-tools/<version>/apksigner.bat (Windows)<SDK_ROOT>通常是你配置的Android SDK位置,<version>是类似34.0.0、35.0.0这样的Build-Tools版本号。想看你本机装了哪些版本,直接列目录:
ls "$ANDROID_HOME/build-tools"Windows下用dir "%ANDROID_HOME%\build-tools"。挑一个较新的版本用就行,签名这种操作对Build-Tools版本不敏感,但建议不要用太老的,老版本可能不支持v3/v4方案。我一般习惯固定一个版本写进脚本,避免不同机器上PATH里恰好是不同版本导致行为不一致。
apksigner本身是个包装脚本,Linux/macOS下是shell脚本,Windows下是.bat。它内部会调用同目录下的lib/apksigner.jar,所以运行时需要机器上有可用的Java。这也是第一个容易踩的点:如果你的JAVA_HOME指向的是一个很老的JDK,或者根本没配,脚本会直接报找不到java。建议用JDK 8以上,实际项目里用11或17都没问题。
2.2 为什么建议用绝对路径调用
很多人会把Build-Tools目录加进PATH,然后直接敲apksigner。这在个人机器上方便,但在CI或多版本共存的机器上是个隐患:PATH里可能有多个Build-Tools版本,谁在前面用谁,某天升级SDK后行为就变了。我的做法是在脚本里定义一个变量:
APKSIGNER="$ANDROID_HOME/build-tools/35.0.0/apksigner"后面所有调用都用"$APKSIGNER"。这样版本是锁死的,换机器只需要改这一行。Windows批处理里同理,用set APKSIGNER=%ANDROID_HOME%\build-tools\35.0.0\apksigner.bat。
注意:路径里如果带空格(Windows上SDK装在
Program Files下很常见),一定要给变量加引号,否则脚本会在空格处断词,报"不是内部或外部命令"。
2.3 先跑一次help,确认工具可用
在正式签名前,先执行:
"$APKSIGNER" --version "$APKSIGNER" help sign第一条输出工具版本,第二条输出sign子命令的完整参数列表。这一步看着多余,但它能一次性验证三件事:脚本路径对不对、Java环境通不通、这个版本的参数名有没有变化(不同Build-Tools版本参数偶有增删)。我见过有人拿着网上抄来的命令直接跑,结果因为版本差异某个参数不被识别,排查半天以为是证书问题,其实只是参数名对不上。先看help,能省掉这类无谓的折腾。
3. 签名前的两件必做事:对齐与清理旧签名
3.1 zipalign必须在签名之前,顺序错了签名就白做
这是新手最容易搞反的一步。zipalign的作用是把APK内未压缩的数据按4字节边界对齐,让运行时可以通过mmap直接读取资源,减少内存占用。关键在于:对齐会改变文件内数据的偏移量,而v2/v3签名是对整个文件内容做摘要的。如果你先签名再对齐,对齐操作改动了文件,签名立刻失效,装包时就会报INSTALL_PARSE_FAILED_NO_CERTIFICATES或校验失败。
正确顺序永远是:构建出未签名APK → zipalign → apksigner签名。
对齐命令:
"$ANDROID_HOME/build-tools/35.0.0/zipalign" -p -f -v 4 input-unsigned.apk output-aligned.apk参数含义:-p表示按页对齐(对.so库友好,4KB页对齐),-f强制覆盖输出文件,-v输出详细日志,4是对齐字节数(必须是4)。跑完你会看到类似Verification succesful的输出,说明对齐通过。想单独校验一个已对齐的包,用zipalign -c -v 4 your.apk,返回0表示对齐正确。
有个细节值得说:-p这个参数在部分老版本Build-Tools里行为略有差异,如果你签完发现某些设备上加载so有问题,可以对比一下加不加-p的差异。现代版本按页对齐是推荐做法,一般不用纠结。
3.2 清掉旧签名,别在已签名的包上叠加
如果你拿到的是一个已经签过名的APK(比如加固后的包、或者别人给的debug包),直接再签一次会怎样?apksigner默认会检测到已有签名并报错,提示你使用--ks覆盖或者先移除。更稳妥的做法是先把旧的签名信息剥掉,得到一个干净的未签名包,再走对齐+签名流程。
移除签名的思路是删掉APK里META-INF目录下的签名相关文件(*.SF、*.RSA、*.DSA、*.EC以及MANIFEST.MF),同时v2/v3的签名块位于ZIP结构里,单纯删文件删不掉。实际操作用zip命令处理v1部分:
zip -d input.apk "META-INF/*.SF" "META-INF/*.RSA" "META-INF/*.DSA" "META-INF/*.EC" "META-INF/MANIFEST.MF"但v2/v3签名块还在。这时候更省事的办法是:如果源工程还在,直接重新构建一个未签名包;如果只有APK,可以用apksigner的--ks直接覆盖签名(它会重写签名块),或者借助一些专门的重签名脚本。我的经验是,能重新构建就别在成品包上折腾,因为剥离签名再重签的过程里,稍不注意就会破坏ZIP结构,导致包虽然能签上但装不上。
提示:判断一个APK当前是什么签名状态,用
apksigner verify --verbose --print-certs your.apk,它会告诉你用了v1/v2/v3哪些方案、证书指纹是什么、是否通过校验。这个命令在排查"为什么装不上"时是第一步。
4. 核心签名命令拆解:每个参数都在干什么
4.1 最小可用命令与参数逐项说明
一条能跑通的手动签名命令长这样:
"$APKSIGNER" sign \ --ks release.jks \ --ks-key-alias mykey \ --ks-pass pass:YourStorePassword \ --key-pass pass:YourKeyPassword \ --out app-release-signed.apk \ app-release-aligned.apk逐项拆开看:
sign:子命令,表示执行签名。对应的还有verify(验签)。--ks:keystore文件路径。支持JKS、PKCS12等格式,现代Android Studio默认生成的是PKCS12(.jks后缀但内部是PKCS12),apksigner都能识别。--ks-key-alias:密钥别名。一个keystore里可以有多个key,必须指定用哪个。忘了别名可以用keytool -list -keystore release.jks查。--ks-pass:keystore的密码。格式是pass:明文或file:密码文件路径或env:环境变量名。--key-pass:单个key的密码。PKCS12格式下通常和keystore密码相同,JKS格式下可能不同。--out:输出文件路径。不指定的话会原地覆盖输入文件,风险较大,建议总是显式指定。- 最后一个位置参数:输入的已对齐APK。
这里有个容易混淆的点:--ks-pass和--key-pass到底该不该都写?如果你的keystore是PKCS12格式(现在默认都是),key密码和store密码是同一个,只写--ks-pass通常就够。但如果是老的JKS格式且两者不同,就必须都写。保险起见两个都写上不会错。
4.2 密码不要明文写在命令行里
上面那条命令有个致命问题:密码明文出现在命令行里,会被记录到shell history(~/.bash_history)、会被ps aux看到、CI日志里也会打印出来。这在团队协作和CI环境里是安全事故。
三种更安全的传法:
方式一,用环境变量:
export KS_PASS='YourStorePassword' "$APKSIGNER" sign --ks release.jks --ks-key-alias mykey \ --ks-pass env:KS_PASS --key-pass env:KS_PASS \ --out signed.apk aligned.apk方式二,用密码文件:
# 文件内容就是一行密码,注意权限设为600 "$APKSIGNER" sign --ks release.jks --ks-key-alias mykey \ --ks-pass file:ks.pass --key-pass file:ks.pass \ --out signed.apk aligned.apk方式三,交互式输入:不传--ks-pass,工具会提示你手动输入。适合本地一次性操作,不适合自动化。
CI里我一般用环境变量方式,密码由CI的secret管理注入,脚本里只引用变量名。密码文件方式要注意别把文件提交进版本库,.gitignore里加上*.pass。
4.3 控制签名方案:v1/v2/v3/v4怎么选
apksigner默认会根据APK的minSdkVersion自动决定启用哪些方案,但你可以用参数强制指定:
--v1-signing-enabled true|false --v2-signing-enabled true|false --v3-signing-enabled true|false --v4-signing-enabled true|false什么时候需要手动控制?几个典型场景:
- minSdk很低(比如<24):必须保留v1,因为Android 7.0以下不认识v2。默认行为已经处理了,一般不用管。
- 只想要v2+,不要v1:某些安全要求高的场景会禁用v1,减少攻击面。但前提是minSdk≥24。
- v4签名:v4是配合
adb install --incremental做增量安装用的,生成一个.idsig附属文件。普通分发不需要,开了反而多一个文件要管理。默认关闭,除非你明确要用增量安装。
我的建议是:除非有明确理由,交给默认行为。默认逻辑已经考虑了minSdk兼容性,手动干预容易顾此失彼。真要确认最终用了哪些方案,签完跑一次verify --verbose看输出里的Verified using v1 scheme、v2 scheme等行即可。
4.4 一个完整的、可复用的签名脚本
把前面的步骤串起来,一个本地可用的脚本大概是这样:
#!/usr/bin/env bash set -euo pipefail SDK="${ANDROID_HOME:?ANDROID_HOME not set}" BT="$SDK/build-tools/35.0.0" APKSIGNER="$BT/apksigner" ZIPALIGN="$BT/zipalign" IN="$1" # 未签名APK OUT="$2" # 最终输出 KS="$3" # keystore路径 ALIAS="$4" # 别名 ALIGNED="${OUT%.apk}-aligned.apk" "$ZIPALIGN" -p -f 4 "$IN" "$ALIGNED" "$APKSIGNER" sign \ --ks "$KS" \ --ks-key-alias "$ALIAS" \ --ks-pass env:KS_PASS \ --key-pass env:KS_PASS \ --out "$OUT" \ "$ALIGNED" "$APKSIGNER" verify --verbose --print-certs "$OUT" rm -f "$ALIGNED"set -euo pipefail保证任何一步失败就中断,不会带着半成品继续往下走。最后自动验签,把证书信息打出来,方便确认签的是不是预期的那把key。这个脚本我在多个项目里用过,改改路径就能直接用。
5. 验签与排错:装不上、验不过时按这个顺序查
5.1 verify命令的输出怎么读
签完不等于签对。apksigner verify是唯一可信的验收手段:
"$APKSIGNER" verify --verbose --print-certs signed.apk正常输出会包含几块信息:Verifies表示整体校验通过;Verified using v1 scheme (JAR signing): true/false;Verified using v2 scheme (APK Signature Scheme v2): true/false;Verified using v3 scheme: true/false;然后是Signer #1 certificate DN、SHA-256 digest等证书信息。重点看两处:一是所有Verified using里至少有一个为true(通常v2或v3为true就够现代设备用);二是证书DN和指纹是不是你预期的那把key。
如果输出里有WARNING,比如WARNING: META-INF/... is not protected by signature,说明APK里有文件没被签名覆盖,通常是v1签名时META-INF下混入了非签名文件。这种警告不一定导致装不上,但值得清理。
5.2 常见报错与对应根因
我把实际遇到过的报错整理成一张表,按出现频率排序:
| 报错信息(大意) | 根因 | 处理方式 |
|---|---|---|
INSTALL_PARSE_FAILED_NO_CERTIFICATES | 没签名,或签名后又被改动(如先签后对齐) | 确认顺序:对齐→签名,且签名后不再动文件 |
INSTALL_FAILED_UPDATE_INCOMPATIBLE | 新包签名与设备上已装版本不一致 | 卸载旧包,或统一用同一把key签 |
jarsigner: java.lang.SecurityException: invalid SHA-256 signature file | 用jarsigner验apksigner签的包 | 改用apksigner verify,jarsigner不认v2+ |
Failed to load keystore | 路径错、密码错、格式不识别 | 用keytool -list确认keystore可读 |
No key with alias 'xxx' found | 别名拼错或大小写不符 | keytool -list查真实别名 |
zip flinger/ 对齐校验失败 | 没对齐或对齐参数不对 | 重新zipalign -c -v 4检查 |
这张表里最值得展开的是第一条和第三条。第一条的本质是"签名是对文件字节的承诺",任何后续改动都会让承诺失效,所以顺序不能乱。第三条则是工具代际差异:jarsigner是JDK自带的JAR签名工具,它只认v1,看到v2签名块会认为文件被篡改,于是报签名文件无效。很多人被这个报错误导,以为签名坏了,其实是验签工具用错了。
5.3 一个真实的排查链路:包能签上但装不上
说一个我实际处理过的案例(细节做了脱敏)。某次给测试同事出一个包,apksigner verify显示v2、v3都通过,证书也对,但装到设备上就是报INSTALL_PARSE_FAILED_NO_CERTIFICATES。按下面的顺序查下来:
第一步,确认设备Android版本和minSdk。设备是较新系统,minSdk也不低,v2应该被支持,排除方案不兼容。
第二步,怀疑签名后文件被改动。检查构建脚本,发现签名之后还有一个"拷贝到分发目录并重命名"的步骤,重命名本身不改内容,没问题;但紧接着有个"压缩资源"的后处理脚本,它重新打包了APK——这就是元凶。重新打包改变了文件字节,v2签名自然失效。
第三步,验证这个判断:对后处理之后的包再跑verify,果然报DOES NOT VERIFY。把后处理步骤挪到签名之前,问题消失。
这个案例的教训是:签名必须是APK生命周期的最后一步,任何会改动APK字节的操作(重打包、二次压缩、注入文件)都要排在签名之前。排查时不要一上来就怀疑证书,先确认"签名之后文件有没有被动过",能省大量时间。
6. 把签名接进自动化:CI里的几个实战要点
6.1 密钥材料怎么在CI里安全存放
CI环境没有交互式输入,密码和keystore都得提前准备好。常见做法是把keystore文件做base64编码后存进CI的secret变量,构建时解码还原:
echo "$KS_BASE64" | base64 -d > release.jks密码同样走secret变量,通过env:方式传给apksigner。这里有几个纪律:keystore文件用完即删(CI的临时工作区一般构建结束就销毁,但保险起见脚本末尾rm掉);secret变量不要echo到日志;构建日志里如果出现密码片段要立刻轮换。我见过有团队把keystore直接提交进仓库,这等于把签名权公开,任何能读仓库的人都能签出"官方"包,风险极高。
6.2 多渠道包批量重签的脚本骨架
渠道包场景下,往往是一个基础包要按渠道重签多次。思路是循环处理,每次输出独立文件:
for ch in "${CHANNELS[@]}"; do aligned="build/${ch}-aligned.apk" out="dist/app-${ch}.apk" "$ZIPALIGN" -p -f 4 "build/base-unsigned.apk" "$aligned" "$APKSIGNER" sign --ks release.jks --ks-key-alias mykey \ --ks-pass env:KS_PASS --key-pass env:KS_PASS \ --out "$out" "$aligned" "$APKSIGNER" verify "$out" || { echo "verify failed: $ch"; exit 1; } done注意每个渠道包都要独立对齐、独立签名,不能签完一个再复制改名——复制出来的文件虽然字节相同、签名也有效,但如果渠道信息是写进APK内部的(比如渠道文件),就必须在签名前注入,签完再改就失效了。这个顺序问题在渠道包场景里特别容易翻车。
6.3 签名耗时与并行
单个APK签名通常几秒内完成,渠道多的时候串行也还能接受。如果渠道数量上百,可以考虑并行,但要注意apksigner每次启动都要加载JVM,进程启动开销不小。并行度控制在CPU核数以内即可,别开太多把机器拖垮。实测下来,几十个渠道串行也就一两分钟,多数团队没必要上并行。
7. 几个只有踩过才知道的细节
第一个细节关于--out覆盖输入。如果你图省事不写--out,apksigner会原地覆盖输入文件。这在脚本里很危险:一旦签名中途失败,输入文件可能已经被截断,原始未签名包就没了。所以永远显式指定--out,且输出路径和输入路径不同。
第二个细节关于keystore格式。老项目可能还在用JKS格式,新工具链默认PKCS12。apksigner两种都支持,但如果你用keytool生成新keystore,建议直接用PKCS12(keytool -genkeypair -storetype PKCS12 ...),因为JKS在新版JDK里已经被标记为过时,未来可能移除支持。
第三个细节关于证书有效期。签名用的证书有有效期(通常25年以上),但Google Play对上传包的签名证书有额外要求(比如必须用上传密钥而非签名密钥,涉及Play App Signing)。手动签名本地验证没问题,但如果这个包要上架,得确认证书策略符合商店要求,别用了一个临时生成的证书签了正式包。
第四个细节关于apksigner和apksigner.jar的区别。脚本包装器会做一些环境检查,直接java -jar apksigner.jar则绕过这些检查。一般用脚本即可,只有在脚本因为环境问题跑不起来时,才考虑直接调jar作为兜底。
第五个细节,验签时加--print-certs能打出证书指纹,把这个指纹和团队记录的指纹比对,是确认"这个包确实是我们签的"最直接的手段。建议把正式签名证书的SHA-256指纹记录在团队文档里,每次发版前比对一次,防止误用测试证书签了正式包。
8. 收尾:手动签名的价值不在于"手动"
把整个流程走一遍你会发现,apksigner本身并不复杂,真正花时间的是它周边的约束:对齐必须在签名前、签名必须是最后一步、密码不能明文、验签要用对工具、CI里密钥要安全注入。这些约束背后是同一条逻辑——签名是对APK字节内容的密码学承诺,任何改变字节的操作都会让承诺失效。理解了这一点,很多"莫名其妙装不上"的问题就能自己推出来。
我在实际项目里的体会是,与其在出问题后对着报错猜,不如把"对齐→签名→验签"这三步固化成脚本,每次发版都跑,让验签成为流水线的强制关卡。这样即使某天有人往构建流程里插了一个后处理步骤,验签也会第一时间拦下来,而不是等到测试同事装包失败才发现。手动跑命令的意义,从来不是拒绝自动化,而是先把每一步的因果搞清楚,再放心地交给脚本。