1 背景 最近接到一个需求,app有多个服务器环境,上传发布时可以根据所处环境生成对应的配置参数以供app初始化时使用,我们选择的方法是在服务器对上传的apk进行解包插入配置文件并重新打包及签名,再发布。主要用到apktool及签名工具apksigner的相关知识,顺便了解一下如将java代码打包成可执行程序及脚本的使用。
2 用到的工具 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:1 2 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 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 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 apksigner verify signed.apk
获取签名信息
1 2 apksigner verify --verbose --print-certs your_app.apk
2.4 在linux下安装和运行apksigner和zipalign 有以下两种方式:
Android Sdk中的 build-tools文件夹,其中就包含了 apksigner 和 zipalign。
下载和解压 Android SDK 从 Android Studio 官方网站 下载 Android Studio ,然后从菜单中下载 设置 Android SDK环境变量 解压下载的 SDK 包,并将 build-tools 目录添加到你的 PATH 环境变量中:1 2 export ANDROID_HOME=~/path/to/your/android-sdkexport PATH=$PATH :$ANDROID_HOME /build-tools/<version>:$ANDROID_HOME /platform-tools
也可以使用SDK 管理工具安装适当版本的 build-tools,1 sdkmanager "build-tools;<version>
方式二:安装 Ubuntu 包管理器的 apksigner1 2 3 4 5 sudo apt install apksigner 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 { 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 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。
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 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 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 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; } @SuppressWarnings("all") private static Map<String, String> parseKeystoreYaml (String filePath) { if (!new File (filePath).exists()){ log(filePath+"签名配置文件不存在" ); return Collections.emptyMap(); } try { String content = new String (Files.readAllBytes(Paths.get(filePath))); 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 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)){ if (args.length < 5 ) { log("缺少参数" ); return ; } String keystoreConfigFile = args[4 ]; processAllTask(apkFilePath,targetFile,outputDir,keystoreConfigFile); }else { } } 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; } String unpackDir = outputDir + File.separator + "unpack" ; checkDirectory(outputDir,unpackDir); String targetFileName = targetFile.substring(targetFile.lastIndexOf(File.separator) + 1 ); String configDestPath = unpackDir + File.separator + "assets" + File.separator + targetFileName; String apkNameWithoutSuffix = apkFile.substring(apkFile.lastIndexOf(File.separator) + 1 ).replace(".apk" , "" ); String apkRebuildFile = outputDir + File.separatorChar + apkNameWithoutSuffix + "_added.apk" ; log("正在解包apk..." ); if (!decodeApk(apkFile, unpackDir)) { log("解包 APK 失败" ); return "" ; } log("正在复制配置文件..." ); if (!copyFile(targetFile, configDestPath)) { log("复制配置文件失败" ); return "" ; } log("正在打包APK..." ); if (!buildApk(unpackDir, apkRebuildFile)) { log("打包 APK 失败" ); return "" ; } 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 java ApkProcessor <args>
创建可执行 JAR 文件 目录结构:
1 2 3 ApkProcessor/ ├── ApkProcessor.class └── MANIFEST.MF
javac 生成 .class 文件只是编译的中间产物,如果想把多个 class 和资源打包为一个能运行的 jar,只要声明 Main-Class,即可使用。要创建一个名为 ApkProcessor.jar 的 JAR 文件,步骤如下:
创建一个清单文件 MANIFEST.MF
,内容如下:1 Main-Class: ApkProcessor
使用以下命令创建 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 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 cd "E:\Download\ApkProcessor\jar"set INPUT_APK="E:\Download\your/apk/path .apk"set CONFIG_FILE="E:\Download\your/keystore_config/path .yaml"set OUTPUT_DIR="%~dp0output"java -jar ApkProcessor.jar sign %INPUT_APK% %CONFIG_FILE% %OUTPUT_DIR% pause
打包apk 由于不能使用签名,因此在Android studio的终端窗口使用命令行打包apk,在Gradle终端运行以下命令:1 2 ./gradlew assembleRelease
配置及使用 最后我们在服务器上安装好java环境、对齐和签名工具,然后上传apktool和apkprocessor及对应的脚本文件,配置环境变量或直接复制到Path默认目录/usr/local/bin
,即可使用这个脚本。 上传(复制)文件到自定义目录1 2 3 4 5 ApkProcessorJar/ ├── apktool ├── apktool.jar ├── apkprocessor └── ApkProcessor.jar
添加执行权限 在apktool
和apkprocessor
脚本所在目录打开终端运行以下命令:1 2 sudo chmod +x apktool sudo chmod +x apkprocessor
配置环境变量 打开 ~/.bashrc
文件,将apktool.jar
和ApkProcessor.jar
所在目录添加到环境变量,并使用source ~/.bashrc
更新。1 export PATH=$PATH :path/to/ApkProcessor/jar
即可通过命令使用这个脚本。 参考资料 [1]. apktool : https://apktool.org/