使用apktool实现一个apk修改和打包脚本

1 背景

最近接到一个需求,app有多个服务器环境,上传发布时可以根据所处环境生成对应的配置参数以供app初始化时使用,我们选择的方法是在服务器对上传的apk进行解包插入配置文件并重新打包及签名,再发布。主要用到apktool及签名工具apksigner的相关知识,顺便了解一下如将java代码打包成可执行程序及脚本的使用。

2 用到的工具

2.1 apktool

Apktool 是一个开源的逆向工程工具,用于 反编译(解包)和回编译(重新打包)Android APK 文件,这里会用到其中的解包
和打包命令 。
安装方式

  1. apktool官网下载最新版的jar文件,重命名为apktool.jar
  2. 下载Apktool的启动脚本(右键保存),其作用是简化运行 Apktool 的命令行调用。不同平台的脚本不同,window上是apktool.bat,linux则是不带后缀的apktool
  3. 将jar文件和脚本文件保存至自定义的目录,然后将该路径添加到配置环境变量Path中,也可以复制到Path默认的目录,windows是 C://Windows,Linux则是 /usr/local/bin
  4. 对于Linux,如果脚本文件是新建、下载或者复制建立的,还需要执行chmod + x命令来赋予文件可执行权限 ,之后即可使用终端来运行Apktool。

示例用法

  • 解包 APK:
    1
    2
    # 将 myapp.apk 解包到 myapp_source 目录。
    apktool d myapp.apk -o myapp_source
  • 重新打包 APK
1
2
# 将 myapp_source 目录中的文件重新打包成 myapp_modified.apk。
apktool b myapp_source -o myapp_modified.apk

2.2 对齐工具zipalign

对齐是一种内存优化手段,zipalign 工具会让 APK 中的资源(特别是 .so、图片、XML等)在 4 字节对齐的边界上存储,可以提高内存访问效率和节省内存。由于对齐会改变文件,破坏签名,因此应该先对齐再签名。
命令

1
2
# 将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 签名

1
2
3
4
5
6
# 使用 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 签名

1
2
# 确认 APK 签名是否成功
apksigner verify signed.apk

获取签名信息

1
2
# 打印证书相关信息和详细的签名
apksigner verify --verbose --print-certs your_app.apk

2.4 在linux下安装和运行apksigner和zipalign

有以下两种方式:

  • 方式一:使用官方SDK中附带的工具

Android Sdk中的 build-tools文件夹,其中就包含了 apksigner 和 zipalign。

  1. 下载和解压 Android SDK
    从 Android Studio 官方网站 下载 Android Studio ,然后从菜单中下载
  2. 设置 Android SDK环境变量
    解压下载的 SDK 包,并将 build-tools 目录添加到你的 PATH 环境变量中:
    1
    2
    export ANDROID_HOME=~/path/to/your/android-sdk
    export PATH=$PATH:$ANDROID_HOME/build-tools/<version>:$ANDROID_HOME/platform-tools
    也可以使用SDK 管理工具安装适当版本的 build-tools,
    1
    sdkmanager "build-tools;<version>
  • 方式二:安装 Ubuntu 包管理器的 apksigner
    1
    2
    3
    4
    5
    # 安装  apk 签名工具
    sudo apt install apksigner

    # 安装 apk 对齐工具
    sudo apt install zipalign

3 代码实现

我们要实现的流程是APK 反编译(解包) → 修改 → 打包 → 签名,对于apktool命令的封装可以使用Bash、Java,Python等语言,这里使用java语言。
1. 解包
新建java类,解包用到apktool的d (decode) 命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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的原生资源目录下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 将文件从源路径复制到目标路径
* @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. 打包
这里用到的是apktoolb (build)命令,将使用apktool解包出来的文件重新打包成apk。

1
2
# 令将 myapp_source 目录中的文件重新打包成 myapp_modified.apk。
apktool b myapp_source -o myapp_modified.apk
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 打包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或以上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 /**
* 使用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等文件读取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/**
* 对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 修改入口方法
我们最终的效果是在终端输入以下命令:

1
2
3
4
# <apkFile> apk源文件路径
# <configFile> 待写入的文件
# <outputDir> 临时文件及输出目录
java -jar ApkProcessor.jar <apkFile> <configFile> <outputDir>

我们在终端输入的命令和参数对应main方法的可变长参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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 文件,

1
javac -encoding UTF-8 ApkProcessor.java

它也可以在终端窗口通过 java <类名> 的方式运行:

1
2
# 程序名称后面的参数会被main方法接收
java ApkProcessor <args>

创建可执行 JAR 文件
目录结构:

1
2
3
ApkProcessor/
├── ApkProcessor.class
└── MANIFEST.MF

javac 生成 .class 文件只是编译的中间产物,如果想把多个 class 和资源打包为一个能运行的 jar,只要声明 Main-Class,即可使用。要创建一个名为 ApkProcessor.jar 的 JAR 文件,步骤如下:

  1. 创建一个清单文件 MANIFEST.MF,内容如下:
    1
    Main-Class: ApkProcessor
  2. 使用以下命令创建 JAR 文件(在 ApkProcessor.class 文件所在的目录下执行):
    1
    jar cvfm ApkProcessor.jar MANIFEST.MF ApkProcessor.class
    linux下使用jar包
    在jar包所在目录(或者完整路径),使用java -jar <path.jar> <arg...>命令即可运行这个jar
1
2
3
4
5
java -jar youjar.jar 

# 或者

java -jar /path/to/youjar.jar <arg...>

将jar包使用命令封装成脚本
进一步地,我们可以将上命令封装成脚本,再配置环境变量,就可达到跟使用其他终端命令一样的方便。

  • Linux
    新建apkprocessor文件,添加以下内容:
    1
    2
    3
    4
    #!/bin/bash
    # 使用脚本所在目录的相对路径来运行 JAR 文件
    # 保存脚本后,确保它具有执行权限:sudo chmod +x apkprocessor
    java -jar "$(dirname "$0")/ApkProcessor.jar" "$@"
  • Windows
    Window使用的脚本是bat,新建apkprocessor.bat文件,添加以下内容:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @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终端运行以下命令:
    1
    2
    # 构建 release 类型的 APK(未签名)
    ./gradlew assembleRelease
    配置及使用
    最后我们在服务器上安装好java环境、对齐和签名工具,然后上传apktool和apkprocessor及对应的脚本文件,配置环境变量或直接复制到Path默认目录/usr/local/bin,即可使用这个脚本。
  1. 上传(复制)文件到自定义目录
    1
    2
    3
    4
    5
    ApkProcessorJar/
    ├── apktool
    ├── apktool.jar
    ├── apkprocessor
    └── ApkProcessor.jar
  2. 添加执行权限
    apktoolapkprocessor脚本所在目录打开终端运行以下命令:
    1
    2
    sudo chmod +x apktool
    sudo chmod +x apkprocessor
  3. 配置环境变量
    打开 ~/.bashrc 文件,将apktool.jarApkProcessor.jar所在目录添加到环境变量,并使用source ~/.bashrc更新。
    1
    export PATH=$PATH:path/to/ApkProcessor/jar
    即可通过命令使用这个脚本。

参考资料

[1]. apktool : https://apktool.org/