使用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/

相关推荐
Fansi4 小时前
iOS 实时活动(Live Activity)开发指南
app
duanze9 小时前
从零开始Android商业项目Vibe coding完全指南(八)
app·vibecoding
Bigger6 天前
Tauri (26)——托盘图标总对不上系统主题?一行 Template Image 搞定
前端·rust·app
duanze10 天前
从零开始Android商业项目Vibe coding完全指南(七)
app·vibecoding
方白羽15 天前
一份 AGENTS.md,让 Android AI 代码规范率飙升
android·app·客户端
私人珍藏库15 天前
[Android] OldRoll复古胶片相机高级版-徕卡-哈苏-宝丽来等等
数码相机·智能手机·app·工具·软件·多功能
私人珍藏库15 天前
[Android] 红妆相机-拍照美颜图片美化工具
android·数码相机·app·软件·多功能
私人珍藏库16 天前
[Android] 精图地球-高清卫星3D街景VR地图工具
智能手机·app·工具·软件·多功能
私人珍藏库16 天前
[Android] 视频下载鸟 v20.02 会员
android·人工智能·智能手机·app·工具·多功能
私人珍藏库16 天前
[Android] 三维山水全景地图-3D地形全景观测地图
android·3d·app·工具·软件·多功能