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

使用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:
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。

  1. 下载和解压 Android SDK 从 Android Studio 官方网站 下载 Android Studio ,然后从菜单中下载
  2. 设置 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. 打包 这里用到的是apktoolb (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 文件,步骤如下:

  1. 创建一个清单文件 MANIFEST.MF,内容如下:
css 复制代码
Main-Class: ApkProcessor
  1. 使用以下命令创建 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,即可使用这个脚本。

  1. 上传(复制)文件到自定义目录

    ApkProcessorJar/
    ├── apktool
    ├── apktool.jar
    ├── apkprocessor
    └── ApkProcessor.jar

  2. 添加执行权限 在apktoolapkprocessor脚本所在目录打开终端运行以下命令:

bash 复制代码
sudo chmod +x apktool
sudo chmod +x apkprocessor
  1. 配置环境变量 打开 ~/.bashrc 文件,将apktool.jarApkProcessor.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/")

相关推荐
Mac的实验室20 小时前
最新真实社交平台注册教程 | 手把手教你如何如何注册一个Truth Social账号
app
QING6182 天前
Activity和Fragment生命周期 —— 新手指南
android·面试·app
QING6182 天前
Kotlin Result 类型扩展详解 —— 新手使用指南
android·kotlin·app
Star7683 天前
鸿蒙系统中实现保存pdf至本地
app·harmonyos
linpengteng3 天前
开发 ArkTS 版 HarmonyOS 日志库 —— logger
前端·app·harmonyos
QING6183 天前
Retrofit 与 Ktor 的简介和对比分析 !!!!
网络协议·kotlin·app
QING6183 天前
【LeakCanary】的实现原理与调优技巧
android·性能优化·app
QING6183 天前
Android LruCache 与 DiskLruCache 深度解析
android·kotlin·app
没有机器猫的大雄4 天前
Flutter版本的PopupWindow,可自行调整显示位置
flutter·app