
上个月帮朋友看一个开源阅读类 App 的布局实现他非要我把 APK 拆开给他讲讲人家的自定义 View 是怎么写的上周又有个做测试的同事问我怎么在不改源码的前提下把打包好的测试包里那个隐藏入口打开。这两件事最后都落到同一套东西上——Android 反编译工具。很多人一听反编译三个字就觉得玄乎要么以为是黑客专属技能要么打开某个绿色图标软件点两下就完事真上手才发现命令行报错、回编译失败、装上去闪退一晚上就没了。这篇就把 apktool、jadx、dex2jar、JD-GUI、Android Studio 自带的 APK Analyzer 这几样东西从环境搭建到实际改一个能装能跑的包完整走一遍。适合谁看手上只有 APK 没有源码的 Android 开发者、需要验证自己 App 打包产物是否被过度暴露的安全测试同学、以及想研究开源项目实现细节的学习者。前提只有一个——你操作的必须是自己开发的、开源的、或者拿到明确授权的应用这条后面还会专门展开说。1. 动手之前先想清楚反编译能拿到什么边界在哪1.1 APK 拆开其实就是三层东西先把认知对齐。APK 文件本质上就是一个 ZIP 压缩包你把后缀改成 .zip 用解压软件也能打开只是里面大部分内容直接看是乱码。它的内容可以粗略分成三层第一层是资源层包括res/目录下的布局 XML、图片、字符串、颜色定义以及编译后的二进制资源表resources.arsc第二层是代码层也就是classes.dex大型应用可能拆成classes2.dex、classes3.dex里面是 Dalvik 字节码不是 Java 源码也不完全是机器码位于两者中间第三层是原生层lib/目录下的.so文件这部分是用 C/C 编译出来的机器码反编译难度和前面两层完全不是一个量级。这三层决定了你能拿到什么。资源层基本是开盖即食用 apktool 解包出来XML 虽然被编译成了二进制格式但解包后会自动还原成可读文本代码层需要经过一次翻译dex 转成 smali 汇编或者尝试还原成 Java能读懂逻辑但变量名、注释、代码结构基本都丢了原生层基本只能看导出符号想还原成 C 代码得靠 IDA 这类工具人工分析成本极高。明确这个分层你就不会对反编译抱有不切实际的期待——它是辅助理解,不是拿回源码。1.2 三个真正高频的使用场景我接触下来反编译工具用得最多的其实就三件事。第一件是查资料看到某个 App 的动效做得好想知道它用的是属性动画还是 Lottie或者某个开源库在真实项目里是怎么被调用的拆开看一眼比翻文档快得多。第二件是问题定位线上包和本地源码对不上号怀疑是打包时资源被覆盖或者某个开关没关把 APK 拆开直接看最终产物里到底是什么比对着构建脚本猜靠谱。第三件是改包自测把一个已经打包好的测试包里的接口地址、日志开关、崩溃上报开关改掉重新签名装到测试机上跑不用麻烦开发重新出包。这三件事有个共同点操作对象要么是自己的产物要么是开放许可的项目要么拿到了对方授权。反编译工具本身是中性技术手段用在哪才是关键。我见过不少人拿它去改别人的商业应用、去广告、绕过内购校验这种行为不仅有法律风险技术上也走不远——现在主流商业应用的加固、混淆、完整性校验做得越来越狠你花三天破解的东西可能第二天对方一个热更新就失效了投入产出比极差。1.3 这条线必须说在前面有几条我给自己定死的规矩建议你也照做。只处理自有应用、明确开源许可允许修改的项目、以及书面授权的第三方应用不传播任何修改后的第三方应用安装包不对任何涉及账号、支付、身份校验的模块做绕过尝试哪怕只是研究一下拆出来的代码只用于理解实现思路不直接复制粘贴到自己项目里尤其是那些没有明确开源协议的部分这是版权问题不是技术问题。注意如果你拿到的 APK 是加固过的特征是 dex 文件极小、lib 目录下有奇怪的 so、反编译出来只有寥寥几个类别急着找各种脱壳工具硬刚。先联系对方要源码或者要一个未加固的测试包这条路通常又快又干净。2. 工具选型apktool、jadx、JD-GUI 各自擅长什么2.1 一张表看清能力边界新手最容易犯的错是拿一把锤子敲所有钉子。这几个工具的能力范围差别挺大的我列个表你按需求挑工具核心能力输出形态能否回编译典型场景apktool解包资源 dex 转 smali可读 XML、smali 目录能b 命令打包改资源、改 smali、换图标jadxdex 转 Java 伪代码Java 源码近似不能读逻辑、搜方法、追调用链dex2jar JD-GUIdex 转 jar 再用 Java 反编译器Java 源码近似不能jadx 反编译失败时的备选APK AnalyzerAndroid Studio 内置分析只读视图不能快速看体积构成、方法数、Manifestbaksmali / smali纯 dex 与 smali 互转smali能只改代码不动资源时更轻量一句话总结要改就用 apktool要读就用 jadx要快速摸底就用 APK Analyzer。dex2jar 那套组合现在用得少了因为 jadx 在大多数情况下反编译质量更好只有在 jadx 报错或者你需要拿 jar 包喂给别的 Java 分析工具时才用得上。有人搜反编译 exe 工具结果搜到这一堆那是完全另一码事PE 文件和 APK 不是一套体系别混着找。2.2 环境搭建JDK 版本是最大的坑apktool 是个 jar 包本质上跑在 JVM 上所以JDK 是绕不过去的第一步。我的建议是直接装 JDK 17 的 LTS 版本别装最新的。原因很实际apktool 不同版本对 JDK 的兼容性不一样2.7.x 之前的版本在 JDK 17 上可能报模块访问相关的警告甚至错误而太老的 JDK 8 又会在处理新版应用时踩到别的坑。JDK 17 是目前兼容性最好的折中点。装完之后验证一下java -version # 期望输出类似 openjdk version 17.0.9 2023-10-17apktool 的安装分两步Windows 下先下apktool.jar和apktool.bat两个放同一个目录把这个目录加进 PATHmacOS 或 Linux 下下载 jar 后写个软链chmod x apktool.jar sudo ln -s $(pwd)/apktool.jar /usr/local/bin/apktooljadx 更省事GitHub Release 里直接有带 JRE 的打包版本解压就能用bin/jadx和bin/jadx-gui两个入口。提示解压路径里千万别有中文和空格。jadx 和 apktool 内部都有些路径处理逻辑对特殊字符不友好我见过有人把工具放在我的工具\安卓逆向\新版本下面报了一堆找不到文件的错排查了半小时才发现是路径问题。2.3 我的常用组合与调用顺序实际工作中我基本是这么一套流程第一步用 APK Analyzer 或者unzip -l快速看一眼包结构确认有没有加固、dex 有几个、so 有哪些架构第二步用 jadx-gui 打开全局搜关键词定位到感兴趣的类第三步如果只是读到此为止如果发现要改就换 apktool 解包直接改对应的 smali 或者资源文件回编译签名安装。这个顺序的好处是先用成本最低的方式确认能不能做再决定要不要投入时间动手做。很多人一上来就 apktool d解包五分钟出来几万个文件然后在里面瞎翻效率极低。3. apktool 实战从解包到回编译出能装的 APK3.1 解包命令与几个关键参数最基础的解包命令长这样apktool d app-release.apk -o unpacked-o是指定输出目录不加的话默认用 APK 文件名去掉后缀作为目录名。但真正需要记住的是另外几个参数它们在特定场景下能省你大量时间-f强制覆盖已存在的输出目录。改包过程中你会反复解包不加这个参数每次都要手动删目录。-s不反编译 dex。如果你只想改资源文件换图标、改文案加上它能把解包时间从几分钟压到几秒因为 smali 那几万个文件才是耗时的元凶。-r不反编译资源。反过来如果你只想改 smali 代码加这个能避免资源解析出错导致整个解包失败。--use-aapt2使用 aapt2 处理资源。遇到资源解析报错时优先加这个试试。举个实际例子我要改一个测试包的接口地址这个地址硬编码在某个 Java 常量里不在资源里。那我的命令就是apktool d -f -r app-release.apk -o unpacked_code只解包代码跳过资源。解包完大概十几秒比全解包快十倍不止。3.2 解包后的目录结构逐个拆解解包完了别急着动手先花两分钟把目录结构摸清楚后面定位问题会快很多AndroidManifest.xml已还原成可读 XML应用的包名、权限、四大组件声明、android:debuggable标记都在这。改包名和调试开关都在这里动。res/资源目录layout/布局、values/字符串和颜色、drawable/图片、xml/各类配置。注意文件名可能被混淆成a.xml、b.xml这种。smali/主 dex 的 smali 代码按包名分目录。如果有多个 dex还会有smali_classes2/、smali_classes3/。assets/原始资源目录不会被编译处理保持原样。lib/原生 so 库按 CPU 架构分目录存放。original/apktool 保留的原始文件备份包含未解包的AndroidManifest.xml和META-INF签名信息。这个目录别删回编译时有些信息会用到。apktool.ymlapktool 的元数据文件记录了版本号、是否压缩资源等信息。回编译时的行为受它影响改错了会出各种奇怪问题。注意res/values/strings.xml里如果看到形如\u4f60\u597d的转义那是正常的本来就是注释掉或者被处理器特殊处理过的内容。乱改这个文件容易导致资源编译失败不是必要不建议动。3.3 改资源一个换图标加改文案的完整例子假设你要把测试包的名字从生产环境改成测试环境图标也换掉方便和正式包区分。步骤是这样的先在res/values/strings.xml里搜应用名对应的字符串找到类似string nameapp_name生产环境/string这一行改成你要的文案。然后在AndroidManifest.xml里确认android:label引用的是string/app_name而不是硬编码字符串——如果是硬编码改 Manifest 就行。换图标要麻烦一点。图标通常在res/mipmap-*各个密度目录下文件名可能被混淆成ic_launcher.png或者某个乱码名。这里有个技巧不要去猜哪个是图标直接在 Manifest 里找android:icon属性指向的资源名顺着这个名字去 mipmap 目录找。找到后用自己的图片覆盖注意尺寸要对应mipmap-xxxhdpi一般是 192x192mipmap-hdpi是 72x72尺寸不对会导致图标显示异常但不会报错属于那种能装能跑但看着别扭的问题。如果图标是自适应的Android 8.0 之后的adaptive-icon那mipmap-anydpi-v26/目录下会有一个 XML 文件描述前景和背景图层你得把对应的前景图和背景色都改了只改一层会出现图标缺了一块的情况。这个坑我踩过一次当时以为是回编译出了问题来回折腾了四十分钟。3.4 回编译、签名、安装三步都不能省改完之后回编译apktool b unpacked -o app-modified-unsigned.apkb就是 build第一个参数是解包目录-o指定输出文件名。这一步最常见的报错是资源编译失败报错信息里会指到具体某个文件某一行照着改就行。另一个高频错误是error: No resource identifier found通常是你在 smali 里引用了不存在的资源 ID或者资源 ID 冲突了。编译出来的包是未签名的直接装会提示INSTALL_PARSE_FAILED_NO_CERTIFICATES。签名流程是先生成密钥再对齐再签名# 1. 生成密钥库只做一次就行 keytool -genkeypair -v -keystore mykey.jks -alias testkey \ -keyalg RSA -keysize 2048 -validity 10000 # 2. 对齐必须在签名之前做 zipalign -v -p 4 app-modified-unsigned.apk app-aligned.apk # 3. 签名 apksigner sign --ks mykey.jks --ks-key-alias testkey \ --out app-signed.apk app-aligned.apkzipalign和apksigner都在 Android SDK 的build-tools/版本号/目录下用之前确认一下这个目录在不在 PATH 里。顺序千万别搞反先签名后对齐会破坏签名。签完用apksigner verify -v app-signed.apk验证一下输出里能看到Verified using v1/v2/v3 scheme就说明没问题。装的时候有个细节如果原来的包已经装在手机上了签名不一样是装不上去的会报INSTALL_FAILED_UPDATE_INCOMPATIBLE。要么先卸载原包注意会丢数据要么改个包名让它共存。改包名是个连锁操作AndroidManifest.xml里的package属性、applicationId相关的引用、还有那些content://类型的 FileProvider authority形如com.example.app.fileprovider都得跟着改漏掉任何一个都可能在运行时崩。4. jadx 实战把 dex 变成能读的 Java 代码4.1 命令行批处理和 GUI 两种用法jadx 提供命令行和图形界面两个入口用哪个看场景。只想快速看某个类直接开 GUIjadx-gui app-release.apk要把整个包导出成 Java 文件做全局搜索用命令行jadx -d output_dir --show-bad-code -j 8 app-release.apk--show-bad-code很重要它会让 jadx 把反编译失败的方法也强行输出虽然代码可能是错的但比直接留空好至少能看到方法签名和一部分结构。-j 8是指定线程数大包反编译很吃 CPU按自己机器的核心数给就行给太大会因为内存不够反而更慢。如果不需要资源文件加上--no-res能省不少时间和内存。4.2 搜索、交叉引用和调用链追踪jadx-gui 里最值钱的功能不是看代码是搜索和跳转。假设你在找某个网络请求的地址拼接逻辑但地址是加密后存在别处的你可以这样找先在strings.xml或者某个配置类里搜域名关键词找到之后右键选择查找用例Find Usagesjadx 会把所有引用这个字段的位置列出来顺着往上找就能追到请求发起的地方。另一个高频操作是搜索字符串常量。菜单里的文本搜索支持正则比如你想找所有以https://开头的字符串直接搜https://.*就能一次性列出来。找加密密钥、找埋点上报地址、找硬编码的开关值这一招特别好用。追踪调用链的时候要注意一个点接口和抽象类的方法跳转会断掉。你在 A 类里看到SomeInterface.doWork()的调用右键跳转过去只能看到接口定义找不到实现。这时候得用文本搜索方法名把所有同名实现列出来结合上下文判断哪个是真正被调用的。多态在字节码层面被抹平了这是反编译工具绕不过去的限制。4.3 反混淆让 a、b、c 变成有意义的名字如果目标 APK 开了混淆你看到的会是a.a.a.a()这样的东西。jadx 有个内置的反混淆开关在首选项里勾上Deobfuscation或者命令行加--deobf它会基于一些启发式规则去猜类型比如把Object强转的地方推断成具体类型把a重命名为String之类。效果有限但对读代码的帮助是实打实的。更好的办法是自己的项目保留 mapping 文件。ProGuard 或 R8 每次构建都会生成mapping.txt用 jadx 的导入映射文件功能加载进去所有被混淆的名字都会恢复成原始名称读起来跟你自己的源码一样顺。这个文件是排查线上崩溃的必备品平时就该归档保存别等到要用的时候发现被 CI 清理了。4.4 导出 Gradle 工程的注意事项jadx 能把反编译结果导出成一个 Gradle 工程看起来很美好实际用起来限制不少。导出的工程几乎不可能直接编译通过因为反编译出来的代码引用了大量缺失的库、错的类型推断、还有语法上不合法的地方。我一般只把它当成一个批量导出 Java 文件方便全局 grep的手段真要编译就别指望了。如果你确实需要一个能编译的参考更实际的做法是把导出的代码当文档读关键逻辑自己手写一遍。反编译出来的代码里经常有goto语句、异常处理块缺失、泛型信息丢失照抄过去只会给自己挖坑。5. smali 语法速成改字节码绕不过去的一关5.1 寄存器与参数寄存器的编号规则改 smali 之前必须先搞懂寄存器。每个方法开头会声明寄存器数量有两种写法.registers N表示总共 N 个寄存器包含参数占用的.locals N表示本地寄存器 N 个参数另算。参数寄存器用p前缀表示本地寄存器用v前缀。规则是这样的如果写的是.locals 3那本地寄存器就是v0、v1、v2参数寄存器从p0开始往后数。对于实例方法p0永远是this对于静态方法p0是第一个参数。参数宽类型long、double占两个寄存器位置。如果写的是.registers 5且方法有两个非宽参数那么总寄存器 5 个参数占 3 个this 两个参数本地就是v0、v1两个。这两种写法可以互相换算但混着改容易算错。我的建议是统一用.locals因为它的编号规则更直观改的时候不容易数岔。5.2 高频指令对照表下面这些指令覆盖了日常改包 90% 的场景smali 指令含义Java 近似const/4 v0, 0x1小整数常量int x 1;const-string v0, abc字符串常量String x abc;iget v0, p0, Lx;-f:I读实例字段x this.f;iput v0, p0, Lx;-f:I写实例字段this.f x;invoke-virtual调用实例方法obj.method()invoke-static调用静态方法Cls.method()invoke-direct调用私有方法或构造器this.init()move-result v0取上一个调用的返回值int r ...if-eqz v0, :cond_0等于零则跳转if (x 0) { ... }return-void返回空return;调用方法之后取返回值中间不能插入别的指令move-result必须紧跟invoke-*。这个约束在改代码时特别容易踩你在两行之间插了一句打日志的代码结果move-result就拿不到值了运行时直接抛VerifyError。5.3 一个完整的方法修改示例假设有个方法原本的逻辑是如果设备是调试模式就关闭某个功能你想让它一直关闭。原 smali 大概长这样.method private isFeatureEnabled()Z .locals 2 iget-boolean v0, p0, Lcom/demo/Config;-isDebug:Z if-eqz v0, :cond_0 const/4 v0, 0x0 return v0 :cond_0 const/4 v0, 0x1 return v0 .end method要让它永远返回false最简单的改法是直接把方法体换成.method private isFeatureEnabled()Z .locals 1 const/4 v0, 0x0 return v0 .end method注意我把.locals从 2 改成了 1因为我们只用到了v0。寄存器数声明多了其实不会报错但声明少了会验证失败所以宁多勿少是个安全的策略只在明确知道用了几个的时候才去精简。改完之后别忘了.method和.end method要配对.locals声明必须放在方法体的最前面中间插入的任何指令都不能跑到它前面去。这些格式约束看起来啰嗦但编译器检查得很严错一个字整个 dex 都编译不过。6. 常见问题排查实录6.1 报错速查表这张表是我这几年攒下来的基本覆盖了新手会遇到的绝大部分问题报错信息根本原因解决思路brut.androlib.AndrolibException: Could not decode arsc file资源表格式不被当前 apktool 版本支持升级 apktool 到最新版或加--use-aapt2error: No resource identifier found for attribute引用了 framework 里不存在的属性检查是否漏了-p指定 framework 路径INSTALL_PARSE_FAILED_NO_CERTIFICATES忘了签名走 zipalign apksigner 流程INSTALL_FAILED_UPDATE_INCOMPATIBLE签名与已安装版本不一致卸载原应用或修改包名共存INSTALL_FAILED_VERSION_DOWNGRADE版本号比已装的低改 Manifest 里的versionCode应用启动即闪退无 log签名校验或完整性校验失败检查是否有校验逻辑确认操作合法授权VerifyErrorsmali 寄存器或指令使用错误检查.locals数量与move-result位置jadx 打开大包卡死内存不足修改 jadx 启动脚本里的-Xmx参数6.2 回编译后闪退的通用排查思路改完包装上去闪退这是最让人头大的情况。我的排查顺序是固定的三步。第一步看 logcatadb logcat -c清空后重新启动应用抓AndroidRuntime标签的异常堆栈绝大多数崩溃都能在这找到直接原因比如某个资源 ID 找不到、某个类初始化失败。第二步对比原包把改过的包和原包都解包用 diff 工具对比改动之外的地方有没有意外变化有时候 apktool 版本不一致会导致大量无关文件被重新生成引入意料之外的问题。第三步二分定位如果改动很多把手上的改动拆成几批每批单独编译测试快速缩小范围。有一个特别隐蔽的问题值得一提资源 ID 变化。apktool 回编译时如果资源顺序变了或者你增删了资源文件所有资源的 ID 可能整体偏移。如果 smali 里有地方硬编码了资源 ID这种情况在混淆过的代码里很常见就会指向错误的资源表现为界面元素错乱或者图片显示成了别的图。解决办法是回编译时保持资源集合不变只改内容不增删文件。6.3 那些反编译拿不到的东西有些东西你必须提前知道拿不到省得白费功夫。加固保护的 dex主流加固方案会把真实 dex 加密存放在 so 或者 assets 里运行时动态解密加载静态反编译只能看到壳代码。Flutter 应用的业务逻辑它在assets/flutter_assets/下是 Dart 编译产物常规 Java 反编译工具完全看不懂。Unity 游戏的逻辑在libil2cpp.so里需要专门的工具链分析。服务端下发的逻辑比如小程序、H5 页面、动态化框架里的代码本地根本没有反编译也无从下手。遇到这几种情况正确做法是换思路而不是换工具。要么找开源版本对照要么从接口层面分析行为要么直接联系对方要资料。死磕工具只会浪费你的时间。7. 效率工具与踩坑心得7.1 APK Analyzer 加 adb 的组合拳Android Studio 里的 APK Analyzer 是被严重低估的工具。菜单路径是Build - Analyze APK打开后能看到每个 dex 的方法数和大小、每个资源文件的体积排名、Manifest 的完整解析结果、还有各 so 库的占比。查这个包为什么这么大、方法数超没超 65536、哪个第三方 SDK 塞了一堆资源它比 apktool 快得多而且完全只读零风险。配合 adb 的几个命令能解决很多实地问题。adb shell pm path 包名查已安装应用的 APK 实际路径adb pull拉出来分析adb shell dumpsys package 包名看版本号、签名摘要、权限授予情况adb logcat前面提过了。这几个命令加起来不到十秒钟能跑完比开图形化工具快。顺便说一个容易被忽略的目录/storage/emulated/0/Android/data/包名/下面通常有应用的外部缓存和日志。Android 11 之后普通应用访问这个路径受限但你自己调试的包可以用adb pull直接拉或者如果应用是debuggable的用adb shell run-as 包名进到内部数据目录。分析崩溃问题时这里面的日志往往比 logcat 里抓到的更完整。7.2 我的工作流和版本管理改包这件事我强烈建议每一步都留档。具体做法是新建一个工作目录把原始 APK 复制进去命名成original.apk从不解包第二次每次解包到独立的目录比如work-v1、work-v2每次改动的 smali 文件在改之前备份一份.bak所有编译产物按版本号命名。听起来繁琐但当你改到第七版发现第一版才是对的时候你会庆幸自己留了档。另外改动尽量小而集中。一次只改一个点编译、签名、安装、验证通过了再改下一个。我见过有人一口气改了五个地方再编译结果闪退然后花两小时逐个回退排查如果一开始就一个一个来二十分钟就搞定了。7.3 几条不太写在文档里的经验第一条apktool 版本要跟目标应用对齐。同一个 APK2.6.x 解包可能报错2.9.x 就正常。手边常备两三个版本的 apktool遇到解包失败换一个版本试比看报错猜原因快。第二条改 smali 优先改小方法。几百行的大方法里插入指令寄存器编号极容易算错。如果非改不可先在方法末尾找一段不相关的代码把要插入的逻辑放进去用goto跳过去这样不用重新分配整个方法的寄存器。第三条验证一定要在真机上做一遍。模拟器和真机在签名校验、so 库加载、资源密度选择上的行为都可能不一样。我遇到过一次模拟器跑得好好的包装到真机上因为 so 库只保留了 x86 版本而直接崩溃的情况。第四条善用strings命令做快速侦察。拿到一个陌生的 APK先unzip解出classes.dex然后strings classes.dex | grep -i http几秒钟就能看到里面有哪些接口地址。这个操作比开任何图形化工具都快适合做第一轮摸底。第五条遇到看不懂的逻辑先看它调用了哪些系统 API。反编译出来的代码再乱invoke的目标类名和方法名是不会变的。搜SharedPreferences、getSystemService、HttpURLConnection、Cipher这些关键词很快就能判断出这段代码在干什么类型的事比逐行啃变量名高效得多。这套流程我用了几年从最开始的手忙脚乱到现在基本能在一小时内完成解包-定位-修改-签名-验证的闭环靠的不是什么高级技巧就是把每一步的坑都踩过一遍然后记下来。真要说有什么心得就是每次动手前先问自己一句这个操作我有没有授权答案不明确就别动以及每次失败都完整记录报错和复现步骤下一次遇到同样的问题能省半小时。工具会更新命令会变但这两条习惯一直管用。