使用apktool实现一个apk修改和打包脚本
1 背景
最近接到一个需求,app有多个服务器环境,上传发布时可以根据所处环境生成对应的配置参数以供app初始化时使用,我们选择的方法是在服务器对上传的apk进行解包插入配置文件并重新打包及签名,再发布。主要用到apktool及签名工具apksigner的相关知识,顺便了解一下如将java代码打包成可执行程序及脚本的使用。
2 用到的工具
2.1 apktool
Apktool 是一个开源的逆向工程工具,用于 反编译(解包)和回编译(重新打包)Android APK 文件,这里会用到其中的解包 和打包命令 。 安装方式
- 从apktool官网下载最新版的jar文件,重命名为
apktool.jar
- 下载Apktool的启动脚本(右键保存),其作用是简化运行 Apktool 的命令行调用。不同平台的脚本不同,window上是
apktool.bat
,linux则是不带后缀的apktool
。 - 将jar文件和脚本文件保存至自定义的目录,然后将该路径添加到配置环境变量Path中,也可以复制到Path默认的目录,windows是
C://Windows
,Linux则是/usr/local/bin
。 - 对于Linux,如果脚本文件是新建、下载或者复制建立的,还需要执行
chmod + x
命令来赋予文件可执行权限 ,之后即可使用终端来运行Apktool。
示例用法
- 解包 APK:
bash
# 将 myapp.apk 解包到 myapp_source 目录。
apktool d myapp.apk -o myapp_source
- 重新打包 APK
bash
# 将 myapp_source 目录中的文件重新打包成 myapp_modified.apk。
apktool b myapp_source -o myapp_modified.apk
2.2 对齐工具zipalign
对齐是一种内存优化手段,zipalign 工具会让 APK 中的资源(特别是 .so、图片、XML等)在 4 字节对齐的边界上存储,可以提高内存访问效率和节省内存。由于对齐会改变文件,破坏签名,因此应该先对齐再签名。 命令
bash
# 将infile.apk 对齐,保存为 outfile.apk
zipalign -P 16 -f -v 4 infile.apk outfile.apk
如果APK 包含共享库(.so 个文件),请使用 -P 16 以确保它们与适合 mmap(2) 的 16KiB 页面边界对齐 在 16KiB 和 4KiB 设备中。对于其他文件,其对齐方式由 zipalign 的强制性对齐参数,应按 4 个字节对齐 在 32 位和 64 位系统上运行,注意-P是Android sdk 35 或以上新增的参数
2.3 签名工具apksigner
Android apk有专门的签名工具apksigner,apksigner会校验文件,确保 APK 完整性(防篡改),验证 APK 开发者身份。 为 APK 签名
bash
# 使用 apksigner 对 APK 进行签名
# <keystore.jks>: 签名文件(.jks 格式)
# <keyAlias> :keystore 中的 key 别名(alias)
# <storePassword>: keystore 的密码(使用 pass: 前缀表示明文)
# <keyPassword> :私钥的密码(一般和 keystore 密码相同)
apksigner sign --ks <keystore.jks> --ks-key-alias <keyAlias> --ks-pass pass:"<storePassword>" --key-pass pass:"<keyPassword>" --out signed_app.apk app-name.apk
验证 APK 签名
bash
# 确认 APK 签名是否成功
apksigner verify signed.apk
获取签名信息
bash
# 打印证书相关信息和详细的签名
apksigner verify --verbose --print-certs your_app.apk
2.4 在linux下安装和运行apksigner和zipalign
有以下两种方式:
- 方式一:使用官方SDK中附带的工具
Android Sdk中的 build-tools文件夹,其中就包含了 apksigner 和 zipalign。
- 下载和解压 Android SDK 从 Android Studio 官方网站 下载 Android Studio ,然后从菜单中下载
- 设置 Android SDK环境变量 解压下载的 SDK 包,并将 build-tools 目录添加到你的 PATH 环境变量中:
bash
export ANDROID_HOME=~/path/to/your/android-sdk
export PATH=$PATH:$ANDROID_HOME/build-tools/<version>:$ANDROID_HOME/platform-tools
也可以使用SDK 管理工具安装适当版本的 build-tools,
bash
sdkmanager "build-tools;<version>
- 方式二:安装 Ubuntu 包管理器的 apksigner
bash
# 安装 apk 签名工具
sudo apt install apksigner
# 安装 apk 对齐工具
sudo apt install zipalign
3 代码实现
我们要实现的流程是APK 反编译(解包) → 修改 → 打包 → 签名,对于apktool命令的封装可以使用Bash、Java,Python等语言,这里使用java语言。 1. 解包 新建java类,解包用到apktool的d (decode) 命令。
java
public class ApkProcessor {
/**
* 打包apk
* @param sourceDir 解包后生成的目录
* @param apkFileName 重新打包生成的 APK 文件路径,如xx.apk
* created by ZHG on 2024/8/21
*/
public static boolean buildApk(String sourceDir, String apkFileName) {
// 补上后缀
apkFileName = apkFileName.endsWith(".apk") ? apkFileName : apkFileName + ".apk";
// 打包命令
String command = String.format("apktool b %s -o %s", sourceDir, apkFileName);
boolean success = executeJavaCommand(command);
return success;
}
@SuppressWarnings("all")
private static boolean executeJavaCommand(String command) {
boolean success = false;
try {
String[] cmdArray = command.split(" ");
ProcessBuilder processBuilder = new ProcessBuilder(cmdArray);
// 如果你希望输出显示在控制台上
processBuilder.inheritIO();
Process process = processBuilder.start();
int exitCode = process.waitFor();
success = exitCode == 0;
} catch (Exception e) {
e.printStackTrace();
}
return success;
}
}
2. 修改 使用File api将指定的文件配置文件复制到解包后的apk目录的中assets
目录下,即app的原生资源目录下。
java
/**
* 将文件从源路径复制到目标路径
* @param source 源文件路径
* @param dest 目标文件路径
*/
public static boolean copyFile(String source, String dest) {
boolean success = false;
File sourceFile = new File(source);
if (!sourceFile.exists()) {
log("copyFile:" + source + "不存在");
} else {
try {
log("复制文件:" + source + " -> " + dest);
Files.copy(new File(source).toPath(), new File(dest).toPath());
success = true;
} catch (Exception e) {
e.printStackTrace();
}
}
return success;
}
3. 打包 这里用到的是apktool
的b (build)
命令,将使用apktool解包出来的文件重新打包成apk。
bash
# 令将 myapp_source 目录中的文件重新打包成 myapp_modified.apk。
apktool b myapp_source -o myapp_modified.apk
java
/**
* 打包apk
* @param sourceDir 解包后生成的目录
* @param apkFileName 重新打包生成的 APK 文件路径,如xx.apk
*/
public static boolean buildApk(String sourceDir, String apkFileName) {
// 补上后缀
apkFileName = apkFileName.endsWith(".apk") ? apkFileName : apkFileName + ".apk";
// 打包命令
String command = String.format("apktool b %s -o %s", sourceDir, apkFileName);
boolean success = executeJavaCommand(command);
return success;
}
4. 对齐 这里用到了zipalign的对齐命令,-P参数需要Android sdk 35或以上。
java
/**
* 使用zipalign 对apk进行对齐优化
*/
private static boolean zipalignApk(String apkFilePath, String outputApkFile) {
if (!new File(apkFilePath).exists()) {
log("zipalignApk:" + apkFilePath + "文件不存在");
return false;
}
String signCommand = String.format(
"zipalign -P 16 -f -v 4 %s %s",
apkFilePath, outputApkFile
);
return executeJavaCommand(signCommand);
}
5. 签名 这一步用到apksigner的签名命令,如果是Windows,程序名称可能被封装成bat,因此可能需要调用对应的bat。签名密钥信息可以使用常量,也可以通过json或yaml等文件读取。
java
/**
* 对apk进行签名
* @param apkFilePath apk文件路径
* @param keystoreFile 签名所使用的密钥库文件的路径
* @param storePassword 密钥库文件的密码
* @param keyAlias 密钥别名
* @param keyPassword 密钥密码
* @param signedApkPath 签名后的输出文件路径
* created by ZHG on 2024/8/20
*/
private static boolean signApk(
String apkFilePath,
String keystoreFile,
String storePassword,
String keyAlias,
String keyPassword,
String signedApkPath) {
if (!new File(apkFilePath).exists()) {
log("signApk:" + apkFilePath + "文件不存在");
return false;
}
if (!new File(keystoreFile).exists()) {
log("signApk:" + keystoreFile + "文件不存在");
return false;
}
String osName = System.getProperty("os.name");
boolean isWin = (osName == null ? "" : osName).toLowerCase().contains("win");
String apksignerName = isWin ? "apksigner.bat" : "apksigner";
String signCommand = String.format(
apksignerName + " sign --ks %s --ks-key-alias %s --ks-pass pass:%s --key-pass pass:%s --out %s %s",
keystoreFile, keyAlias, storePassword, keyPassword, signedApkPath, apkFilePath
);
boolean success = executeJavaCommand(signCommand);
return success;
}
/**
* 读取一个Android签名的配置信息,格式:
* keystore:
* file: "path/to/keystore"
* storePassword: "password"
* keyAlias: "alias"
* keyPassword: "keypassword"
*/
@SuppressWarnings("all")
private static Map<String, String> parseKeystoreYaml(String filePath){
if (!new File(filePath).exists()){
log(filePath+"签名配置文件不存在");
return Collections.emptyMap();
}
try {
// 读取 YAML 文件内容
String content = new String(Files.readAllBytes(Paths.get(filePath)));
// 解析 YAML 内容(简单的实现,适用于特定格式)
Map<String, String> config = new HashMap<>();
String[] lines = content.split("\n");
for (String line : lines) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) {
continue; // 跳过空行和注释
}
int index = line.indexOf(":");
if (index != -1) {
String key = line.substring(0, index);
// 去掉开头的空格和双引号
String value = line.substring(index + 1).trim().replaceAll("\"","");
config.put(key.trim(), value.trim());
}
}
if (config.size()<4){
return Collections.emptyMap();
}
// 获取配置信息
String file = config.get("file");
String storePassword = config.get("storePassword");
String keyAlias = config.get("keyAlias");
String keyPassword = config.get("keyPassword");
return config;
} catch (Exception e) {
e.printStackTrace();
}
return Collections.emptyMap();
}
6 修改入口方法 我们最终的效果是在终端输入以下命令:
bash
# <apkFile> apk源文件路径
# <configFile> 待写入的文件
# <outputDir> 临时文件及输出目录
java -jar ApkProcessor.jar <apkFile> <configFile> <outputDir>
我们在终端输入的命令和参数对应main方法的可变长参数。
java
public class ApkProcessor {
public static void main(String[] args) {
// 检查参数数量
if (args.length < 4) {
log("缺少参数");
return;
}
String cmd = args[0];
String apkFilePath = args[1];
String targetFile = args[2];
String outputDir = args[3];
// 文件是否存在判断
...
if ("sign".equals(cmd)){
zipAndSignApk(apkFilePath, targetFile, outputDir);
}else if ("full".equals(cmd)){
//java ApkProcessor full <apkFile> <configFile> <outputDir> <keystoreConfigFile>
if (args.length < 5) {
log("缺少参数");
return;
}
String keystoreConfigFile = args[4];
processAllTask(apkFilePath,targetFile,outputDir,keystoreConfigFile);
}else {
// do nothing
}
}
public static String processApk(String apkFile, String targetFile,String outputDir) {
if (outputDir == null || outputDir.length() ==0|| outputDir.equals("")){
String apkFolder = apkFile.substring(0, apkFile.lastIndexOf("/"));
outputDir = apkFolder;
}
// apk解包输出目录
String unpackDir = outputDir + File.separator + "unpack";
checkDirectory(outputDir,unpackDir);
// 配置文件复制的目标路径,即apk解包目录的资源文件夹中
String targetFileName = targetFile.substring(targetFile.lastIndexOf(File.separator) + 1);
String configDestPath = unpackDir + File.separator + "assets" + File.separator + targetFileName;
// 重新打包生成的apk文件路径
String apkNameWithoutSuffix = apkFile.substring(apkFile.lastIndexOf(File.separator) + 1).replace(".apk", "");
String apkRebuildFile = outputDir + File.separatorChar + apkNameWithoutSuffix + "_added.apk";
// 1. 解包 APK
log("正在解包apk...");
if (!decodeApk(apkFile, unpackDir)) {
log("解包 APK 失败");
return "";
}
// 2. 复制配置文件
log("正在复制配置文件...");
if (!copyFile(targetFile, configDestPath)) {
log("复制配置文件失败");
return "";
}
// 3. 重新打包 APK
log("正在打包APK...");
if (!buildApk(unpackDir, apkRebuildFile)) {
log("打包 APK 失败");
return "";
}
// 4.签名
log("正在签名APK...");
if (!signApk(apkZipalignFile, file, storePassword, keyAlias, keyPassword, signedApkFile)) {
log("签名 APK 失败");
return "";
}
log("APK 处理成功,输出文件:" + apkRebuildFile);
// 清理临时文件
deleteFile(new File(unpackDir));
return apkRebuildFile;
}
4 打包 Java 程序(JAR 文件)
编译 将上述类生成字节码,执行后得到 ApkProcessor.class 文件,
bash
javac -encoding UTF-8 ApkProcessor.java
它也可以在终端窗口通过 java <类名>
的方式运行:
bash
# 程序名称后面的参数会被main方法接收
java ApkProcessor <args>
创建可执行 JAR 文件 目录结构:
arduino
ApkProcessor/
├── ApkProcessor.class
└── MANIFEST.MF
javac 生成 .class 文件只是编译的中间产物,如果想把多个 class 和资源打包为一个能运行的 jar,只要声明 Main-Class,即可使用。要创建一个名为 ApkProcessor.jar 的 JAR 文件,步骤如下:
- 创建一个清单文件
MANIFEST.MF
,内容如下:
css
Main-Class: ApkProcessor
- 使用以下命令创建 JAR 文件(在 ApkProcessor.class 文件所在的目录下执行):
arduino
jar cvfm ApkProcessor.jar MANIFEST.MF ApkProcessor.class
linux下使用jar包 在jar包所在目录(或者完整路径),使用java -jar <path.jar> <arg...>
命令即可运行这个jar
bash
java -jar youjar.jar
# 或者
java -jar /path/to/youjar.jar <arg...>
将jar包使用命令封装成脚本 进一步地,我们可以将上命令封装成脚本,再配置环境变量,就可达到跟使用其他终端命令一样的方便。
- Linux 新建
apkprocessor
文件,添加以下内容:
bash
#!/bin/bash
# 使用脚本所在目录的相对路径来运行 JAR 文件
# 保存脚本后,确保它具有执行权限:sudo chmod +x apkprocessor
java -jar "$(dirname "$0")/ApkProcessor.jar" "$@"
- Windows Window使用的脚本是bat,新建
apkprocessor.bat
文件,添加以下内容:
bat
@echo off
REM 定位到ApkProcessor.jar的文件夹
cd "E:\Download\ApkProcessor\jar"
REM 参数路径
set INPUT_APK="E:\Download\your/apk/path.apk"
set CONFIG_FILE="E:\Download\your/keystore_config/path.yaml"
set OUTPUT_DIR="%~dp0output"
REM 执行命令
java -jar ApkProcessor.jar sign %INPUT_APK% %CONFIG_FILE% %OUTPUT_DIR%
pause
打包apk 由于不能使用签名,因此在Android studio的终端窗口使用命令行打包apk,在Gradle终端运行以下命令:
bash
# 构建 release 类型的 APK(未签名)
./gradlew assembleRelease
配置及使用 最后我们在服务器上安装好java环境、对齐和签名工具,然后上传apktool和apkprocessor及对应的脚本文件,配置环境变量或直接复制到Path默认目录/usr/local/bin
,即可使用这个脚本。
-
上传(复制)文件到自定义目录
ApkProcessorJar/
├── apktool
├── apktool.jar
├── apkprocessor
└── ApkProcessor.jar -
添加执行权限 在
apktool
和apkprocessor
脚本所在目录打开终端运行以下命令:
bash
sudo chmod +x apktool
sudo chmod +x apkprocessor
- 配置环境变量 打开
~/.bashrc
文件,将apktool.jar
和ApkProcessor.jar
所在目录添加到环境变量,并使用source ~/.bashrc
更新。
bash
export PATH=$PATH:path/to/ApkProcessor/jar
即可通过命令使用这个脚本。
更多文章欢迎访问我的博客。
参考资料
1\]. apktool : [apktool.org/](https://link.juejin.cn?target=https%3A%2F%2Fapktool.org%2F "https://apktool.org/")