DexKit 与 APK 分析
DexKit 与 APK 分析
这是 JSXHook 里非常值得单独掌握的一组能力,因为它其实同时覆盖了两条完全不同的分析路线:
- 运行时 DexKit:你已经 Hook 进目标进程了,现在要快速定位类、方法、字段。
- APK 工作区分析:你把 APK 当成静态材料来拆、看、搜、读、建索引、给 MCP 暴露工具。
对应的内置手册示例可以配合一起看:内置手册全量示例 - DexKit 与 APK 分析。
如果你这次想学的是“界面版 DexKit 调试器怎么用”,建议先看单独整理的这页:DexKit 调试器。那一页专门讲启动页、表单参数、scope、结果页和教学例子;这一页更偏底层路线和工作区分析原理。
先把这两条路线彻底分开
第一次接触时,最容易混淆的是:
“我现在到底是在查当前运行时,还是在查一个已经导入好的 APK 工作区?”
先用这个表切开:
| 你想做的事 | 更适合走哪条线 |
|---|---|
| 当前脚本里快速找目标类 / 方法 / 字段 | 运行时 DexKit |
想慢慢浏览 smali/、AndroidManifest.xml、引用关系 | APK 工作区分析 |
| 想给外部 AI / Agent 暴露 APK 阅读接口 | APK 工作区 MCP 工具 |
想让结果更贴近当前进程真实 ClassLoader 视角 | openDexKitByClassLoader(...) |
很多“明明知道有这个类,为什么查不到”的问题,根本不是检索语法错,而是你一开始就走错了路线。
路线一:运行时 DexKit
这条线面向脚本作者。
你已经在目标进程里了,现在想利用 DexKit 帮自己缩小范围。
openDexKit()
这是最直接的入口:
const bridge = openDexKit();
try {
log(`dexCount=${bridge.getDexNum()}`);
} finally {
bridge.close();
}
第一次写时,建议你先确认两件事:
bridge能不能正常打开。bridge.getDexNum()有没有返回看起来合理的结果。
openDexKitByClassLoader(...)
如果你更在意“当前这条运行时类加载链实际能看到什么”,这个入口更合适:
const bridge = openDexKitByClassLoader(false);
try {
log(`bridgeValid=${bridge.isValid()}`);
} finally {
bridge.close();
}
新手可以先这样理解它和 openDexKit() 的差别:
openDexKit():更像直接打开 Dex 视角。openDexKitByClassLoader(...):更像贴着当前运行时真正加载出来的类世界去找。
如果你怀疑“静态上确实有,但当前运行时不一定按那个样子加载”,这条线通常更靠谱。
DexFinder
JSXHook 还桥接了一层 DexFinder,适合你已经拿到了 APK 路径时使用:
DexFinder.INSTANCE.create(lpparam.appInfo.sourceDir);
const bridge = DexFinder.getDexKitBridge();
try {
log(`bridgeValid=${bridge != null && bridge.isValid()}`);
} finally {
DexFinder.INSTANCE.close();
}
这种写法通常适合:
- 你已经有
lpparam.appInfo.sourceDir - 想沿用 JSXHook 现成桥接
- 后面还要把结果接回 Hook 逻辑
用完一定记得 close()
无论你走哪种运行时 DexKit 入口,最稳妥的写法都应该保留 finally:
const bridge = openDexKit();
try {
// do search
} finally {
bridge.close();
}
这不是形式主义,而是资源管理的基本习惯。
FindClass / FindMethod / FindField 的基本写法
如果你是第一次上手 DexKit,不用一口气把所有查询都学完。
先把这 4 步记住就够用了:
imports(...)把 DexKit 查询类导进来。FindXxx.create()创建查询对象。Matcher.create()一条条加限制条件。bridge.findXxx(query)执行查询。
查方法
const FindMethod = imports("org.luckypray.dexkit.query.FindMethod");
const MethodMatcher = imports("org.luckypray.dexkit.query.matchers.MethodMatcher");
const bridge = openDexKit();
try {
const query = FindMethod.create()
.matcher(
MethodMatcher.create()
.name("onClick")
.paramCount(1)
.addParamType("android.view.View")
);
const matches = bridge.findMethod(query);
log(`matches=${matches.size()}`);
} finally {
bridge.close();
}
查字段
const FindField = imports("org.luckypray.dexkit.query.FindField");
const FieldMatcher = imports("org.luckypray.dexkit.query.matchers.FieldMatcher");
const StringMatchType = imports("org.luckypray.dexkit.query.enums.StringMatchType");
const bridge = openDexKit();
try {
const query = FindField.create()
.matcher(
FieldMatcher.create()
.declaredClass("com.example.target.Profile", StringMatchType.Contains)
.name("token", StringMatchType.Contains)
.type("java.lang.String", StringMatchType.Equals)
);
const matches = bridge.findField(query);
log(`matches=${matches.size()}`);
} finally {
bridge.close();
}
多级筛选
当你想先缩小类集合,再在这些类里继续找方法和字段时,可以这样写:
const FindClass = imports("org.luckypray.dexkit.query.FindClass");
const FindMethod = imports("org.luckypray.dexkit.query.FindMethod");
const FindField = imports("org.luckypray.dexkit.query.FindField");
const ClassMatcher = imports("org.luckypray.dexkit.query.matchers.ClassMatcher");
const MethodMatcher = imports("org.luckypray.dexkit.query.matchers.MethodMatcher");
const FieldMatcher = imports("org.luckypray.dexkit.query.matchers.FieldMatcher");
const StringMatchType = imports("org.luckypray.dexkit.query.enums.StringMatchType");
const bridge = openDexKit();
try {
const classHits = bridge.findClass(
FindClass.create().matcher(
ClassMatcher.create().className("login", StringMatchType.Contains)
)
);
const methodHits = bridge.findMethod(
FindMethod.create()
.searchInClass(classHits)
.matcher(
MethodMatcher.create()
.name("login", StringMatchType.Contains)
.paramCount(1)
)
);
const fieldHits = bridge.findField(
FindField.create()
.searchInClass(classHits)
.matcher(
FieldMatcher.create()
.name("token", StringMatchType.Contains)
)
);
log(`classes=${classHits.size()}, methods=${methodHits.size()}, fields=${fieldHits.size()}`);
} finally {
bridge.close();
}
这类查询在混淆 App 里尤其好用,因为思路是:
- 先宽搜
- 再缩小
- 最后精确命中
路线二:APK 工作区分析
这一条线不是“在当前运行时里找”,而是“把 APK 先导进工作区,再以静态材料方式分析”。
你可以把它理解成:
- 导入 APK。
- 拆出
AndroidManifest.xml和 Smali 结构。 - 建数据库索引。
- 再按目录、类、字段、方法、字符串、引用关系去查。
工作区实际放在哪里
APK 分析工作区不放在 /data/local/tmp/JSXHook/Project 里。
它走的是另一套目录,根目录在应用私有目录下:
<app filesDir>/apk_analysis
源码里几个关键目录常量是:
| 常量 | 实际目录名 | 作用 |
|---|---|---|
ROOT_DIR_NAME | apk_analysis | 工作区根目录 |
INCOMING_DIR_NAME | .incoming | 导入中的临时 APK |
STAGING_DIR_NAME | .staging | 重建索引前的中转区 |
TRASH_DIR_NAME | .trash | 删除或替换前的回收中转区 |
工作区 ID 长什么样
工作区目录名不是包名,也不是 APK 文件名。
源码按 APK SHA-256 生成:
project_<sha256前16位>
例如:
project_7ab93c1022c1d4ef
这就是为什么你在 MCP 工具里经常会看到 project_xxx 这种名字。
当前激活工作区怎么记住的
当前激活工作区不是靠“最后打开哪个页面”猜的,而是保存在 SharedPreferences:
| 键 | 值 |
|---|---|
PREFS_NAME | apk_analysis |
KEY_ACTIVE_PROJECT_DIR | active_project_dir |
所以很多 API 才会有这种表现:
- 不传
projectDir也能工作 - 但前提是你已经有一个当前激活工作区
ApkAnalysisState:状态对象每个字段是什么意思
如果你用 get_analysis_state 或观察分析状态,这个对象就是核心。
基础状态字段
| 字段 | 类型 | 说明 |
|---|---|---|
projectDir | String? | 当前工作区 ID,例如 project_7ab93c1022c1d4ef |
status | ApkWorkspaceStatus | 当前整体状态 |
indexing | Boolean | 是否正在跑导入 / 重建索引流程 |
progressStage | ApkAnalysisStage | 当前所处阶段 |
progressPercent | Int | 0 到 100 的总进度 |
hasAnyWorkspaces | Boolean | 是否至少存在过一个工作区 |
来源信息字段
| 字段 | 类型 | 说明 |
|---|---|---|
sourceName | String | 原始 APK 显示名 |
sourceUri | String | 导入来源 URI |
sourceSha256 | String | 源 APK 的 SHA-256 |
packageName | String | Manifest 里的包名 |
appLabel | String | App 名称 |
versionName | String | 版本名 |
versionCode | Long | 版本号 |
路径与统计字段
| 字段 | 类型 | 说明 |
|---|---|---|
cachedApkPath | String | 缓存 APK 实际路径,一般是 <workspace>/source.apk |
smaliRootPath | String | 工作区根目录 |
manifestPath | String | Manifest 文件路径 |
dexCount | Int | dex 数量 |
classCount | Int | 类数量 |
methodCount | Int | 方法数量 |
fieldCount | Int | 字段数量 |
stringCount | Int | 字符串记录数量 |
refCount | Int | 引用记录数量 |
lastIndexedAt | Long | 最近索引完成时间戳 |
lastError | String | 最近错误信息 |
派生字段
源码里还有两个很实用的派生属性:
| 字段 | 判断方式 | 说明 |
|---|---|---|
hasWorkspace | projectDir 非空 | 当前状态对象是否绑定了工作区 |
isReady | status == READY | 工作区是否已就绪可查 |
status 和 progressStage 的真实可选值
这部分你前面已经特别强调过,所以这里直接把枚举值摊平写清楚。
ApkWorkspaceStatus
当前源码只有这 4 个值:
| 值 | 含义 |
|---|---|
IDLE | 空闲,还没有在跑索引 |
INDEXING | 正在导入或重建索引 |
READY | 工作区可用了 |
ERROR | 最近一次分析或清理发生错误 |
ApkAnalysisStage
当前源码只有这 6 个值:
| 值 | 含义 |
|---|---|
IDLE | 空闲 |
COPYING | 复制 APK 中 |
PREPARING | 准备工作区、清理旧数据 |
DISASSEMBLING | 拆 dex / 准备 Smali 输出 |
PARSING | 解析类、字段、方法、字符串、引用 |
PERSISTING | 持久化索引结果 |
progressPercent 不是随便跳的
源码对总进度做了分段映射:
| 阶段 | 在总进度里的占比 |
|---|---|
COPYING | 0 到 10 |
PREPARING | 10 到 15 |
DISASSEMBLING | 15 到 45 |
PARSING | 45 到 80 |
PERSISTING | 80 到 100 |
所以你看到 progressStage = PARSING 且 progressPercent = 62,它的意思不是“解析阶段内部 62%”,而是“整个导入流程整体已经走到 62% 左右”。
导入 APK:importApk(context, uri, forceReparse = false)
这是 APK 工作区链路的核心入口。
参数是什么意思
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
context | Context | 无 | Android 上下文 |
uri | Uri | 无 | 你选中的 APK |
forceReparse | Boolean | false | 是否强制重新解析,即使同 SHA 工作区已可复用 |
成功调用前的前提
如果当前已经有一个导入 / 重建 / 清理操作正在跑,reserveOperation() 会失败,这个方法会直接返回:
false
这不是“导入逻辑报错”,而是“当前忙,拒绝并发进入”。
它真实会做什么
源码流程可以简化理解成:
- 进入
INDEXING + COPYING状态。 - 把源 APK 复制进
.incoming,同时计算 SHA-256。 - 根据 SHA-256 生成
project_<前16位>作为工作区 ID。 - 如果已经有相同 SHA 且状态为
READY的可用工作区,并且forceReparse = false,直接复用。 - 否则创建
.staging临时工作区。 - 删除旧索引数据。
- 重新建索引。
- 持久化数据库。
- 把 staging 结果切换成正式工作区。
- 把它设为当前激活工作区。
什么时候会复用旧工作区
必须同时满足:
forceReparse = false- 已存在同 SHA 的工作区
- 该工作区状态为
READY cachedApkPath和manifestPath文件都还存在
缺一个都不会复用,而是走重建。
失败时会怎样
导入过程中如果抛异常:
- 状态会切到
ERROR indexing = falselastError会被写入错误信息
重建索引:reparseActiveWorkspace()
这个方法只重建当前激活工作区,不接收其他 projectDir。
它适合什么时候用
- 你怀疑旧工作区数据不完整
- 你想在保留同一份 APK 缓存的前提下重新建索引
- 你调整了分析逻辑,想让当前工作区按新规则重跑
真实行为
- 如果当前有其他操作在跑,直接返回
false。 - 读取当前激活工作区。
- 用它缓存下来的
source.apk复制到新的 staging 目录。 - 删除旧索引。
- 全量重建。
- 成功后切回原
projectDir,并更新状态。
常见失败原因
| 场景 | 结果 |
|---|---|
| 当前没有激活工作区 | 抛错 |
缓存的 source.apk 已丢失 | 抛错 |
| 有并发操作占用 | 返回 false |
清理 API:删的不是“某个列表项”,而是一整套工作区
当前源码有 3 个清理入口:
| API | 作用 |
|---|---|
clearActiveWorkspace() | 清掉当前激活工作区 |
clearWorkspace(projectDir) | 清掉指定工作区 |
clearAllWorkspaces() | 清掉所有工作区 |
共同点
它们都先经过 reserveOperation(),也就是:
- 当前有其他任务在跑时,直接返回
false
真正会删除什么
删除工作区不是只删一个目录名,它会一起删:
apk_workspacesapk_filesapk_classesapk_fieldsapk_methodsapk_stringsapk_refs- 以及工作区文件目录
文件删除不是直接“立刻硬删”
源码不是一上来就对工作区目录 deleteRecursively(),而是先尝试:
- 把旧目录挪到
.trash - 后台再异步删掉
.trash里的目标
这套逻辑在:
moveWorkspaceToTrash(...)deleteWorkspaceFiles(...)
里都能看到。
所以你可以把它理解成一种“先移开、再回收”的删除模式。
路径规则一定要看懂,不然很多 API 会像“随机失灵”
APK 工作区 API 最大的坑之一不是检索本身,而是路径到底怎么写。
resolveWorkspacePath(...) 的真实规则
源码会这样处理传入的 path:
- 去掉前后空白。
- 把
\统一成/。 - 去掉前导和尾部
/。 - 如果第一段是
project_...,就把它当成显式工作区前缀。 - 如果没写显式工作区,就尝试拿当前激活工作区。
- 如果两者都没有,直接报错。
明确支持的两种写法
写法一:显式带工作区 ID
project_7ab93c1022c1d4ef/AndroidManifest.xml
project_7ab93c1022c1d4ef/smali/com/example/MainActivity.smali
这是最稳的写法,特别适合:
- 你在多个工作区之间切换
- 你在写 MCP 自动化
- 你不想依赖“当前激活工作区”这个隐式上下文
写法二:只写相对路径
AndroidManifest.xml
smali/com/example/MainActivity.smali
只有在“当前已经有激活工作区”时,这种写法才成立。
哪些输入会直接报错
| 输入问题 | 结果 |
|---|---|
path 为空 | 报 path 不能为空 |
不带 projectDir,同时当前也没有激活工作区 | 报“必须包含 projectDir,或先选择一个 APK 工作区” |
| 某些 API 不允许根路径却传了空相对路径 | 报错 |
一个容易忽略的小细节
如果你的 relativePath 指向的是一个文件,listFiles(projectDir, relativePath) 最终返回的会是它父目录下的 children,而不是“把这个文件本身当列表唯一结果返回”。
哪些 API 依赖当前激活工作区,哪些不依赖
这个表很实用,建议你记下来:
| API | 是否依赖当前激活工作区 | 说明 |
|---|---|---|
listFiles(projectDir, relativePath = "") | 否 | 必须显式给 projectDir |
searchCode(query) | 是 | 只查当前激活工作区 |
readFile(path) | 可显式也可隐式 | 可写 project_xxx/...,也可依赖当前激活工作区 |
getMethodContent(filePath, methodName) | 可显式也可隐式 | 路径规则同上 |
inspectClass(path) | 可显式也可隐式 | 本质是 getClassSnapshot(path) |
findUsages(signature) | 是 | 只查当前激活工作区 |
getManifest(projectDir) | 否 | 需要显式 projectDir |
如果你在 MCP 里写自动化脚本,建议尽量用显式 project_xxx/... 路径,少依赖隐式 active workspace。
listFiles(projectDir, relativePath = "")
这个 API 用来浏览工作区树。
参数规则
| 参数 | 可填值 | 默认值 | 说明 |
|---|---|---|---|
projectDir | project_xxx | 无 | 必填,必须是已存在工作区 |
relativePath | ""、目录路径、文件路径 | "" | 空串表示根目录 |
返回值 ApkTreeEntry
每一项包含:
| 字段 | 说明 |
|---|---|
name | 当前条目显示名 |
path | 相对工作区根目录的路径 |
kind | 条目类型 |
isDirectory | 是否目录 |
kind 真实可见值
目前工作区文件实体里最重要的几种值是:
| 值 | 含义 |
|---|---|
MANIFEST | 根 Manifest 文件 |
SMALI | Smali 类文件 |
DIR | 目录条目 |
典型调用
列根目录:
listFiles("project_7ab93c1022c1d4ef")
列 smali/:
listFiles("project_7ab93c1022c1d4ef", "smali")
searchCode(query)
这是“全局搜索”入口,但它不是单纯搜文本,而是四路结果合并。
前提
- 当前必须有可用的激活工作区
query.trim()不能为空
否则:
- 没工作区时会抛“当前没有可用的 APK 工作区”
- 空查询时会抛
query 不能为空
它实际会搜哪四类对象
| 类别 | kind 返回值 | signature 内容 |
|---|---|---|
| 类 | CLASS | classDescriptor |
| 方法 | METHOD | methodSignature |
| 字段 | FIELD | fieldSignature |
| 字符串 | STRING | 字符串字面量内容 |
也就是说,这个 API 的思路不是“grep 一行文本”,而是:
- 类表搜一遍
- 方法表搜一遍
- 字段表搜一遍
- 字符串表搜一遍
- 最后按键去重、排序后合并
返回值 ApkSearchHit
每一项包含:
| 字段 | 说明 |
|---|---|
kind | CLASS / METHOD / FIELD / STRING |
signature | 命中的核心签名或字符串值 |
filePath | 所在文件路径 |
lineNumber | 行号 |
readFile(path)
这个 API 用来读工作区里的具体文件内容。
它不是只会读“磁盘上已经存在的文件”
源码真实逻辑是:
- 先按路径解析到工作区和相对路径。
- 如果目标文件真实存在并且是文件,就直接读取。
- 如果相对路径是
AndroidManifest.xml,直接返回缓存的manifestXml。 - 如果磁盘上没有现成文本,但能通过类描述符还原,就尝试动态渲染 Smali。
- 最后统一经过
SmaliTextCleaner.clean(...)。
这意味着:
- 你看到的某些 Smali 内容,不一定每次都必须先落盘成实体文本文件。
- 输出前会做一次清理,所以和原始反汇编产物相比,显示会更干净。
常见写法
显式工作区:
readFile("project_7ab93c1022c1d4ef/AndroidManifest.xml")
readFile("project_7ab93c1022c1d4ef/smali/com/example/MainActivity.smali")
依赖当前激活工作区:
readFile("AndroidManifest.xml")
readFile("smali/com/example/MainActivity.smali")
getMethodContent(filePath, methodName)
这个 API 不是简单“把整文件读出来”,而是从一个 Smali 类文件里截出某个方法体。
参数要求
| 参数 | 要求 |
|---|---|
filePath | 必须能解析到一个 Smali 类文件 |
methodName | 去掉空白后不能为空 |
失败条件
下面这些情况都会直接报错:
| 场景 | 结果 |
|---|---|
filePath 指到的不是 Smali 类文件 | 报错 |
| 这个类里没有任何可用方法 | 报错 |
methodName 为空白 | 报错 |
| 没找到匹配方法 | 报错 |
方法名是怎么匹配的
源码当前规则是:
- 先找“方法名大小写无关的精确匹配”。
- 如果没有,再找“方法名包含匹配”。
- 把候选按起始行和签名排序。
- 取第一个作为主结果返回。
所以如果你传:
methodName = "login"
在重载多、相似方法多的类里,很可能会返回:
- 一个主命中方法
- 一整组候选方法
candidates
返回值 ApkMethodContentResult
包含:
| 字段 | 说明 |
|---|---|
workspace | 所属工作区 |
file | 所属文件实体 |
method | 当前选中的方法实体 |
content | 截出来的方法体文本 |
candidates | 候选方法列表,方便你处理重载或模糊匹配 |
如果你只是想“尽快看某个方法正文”,这个 API 非常好用。
inspectClass(path) / getClassSnapshot(path)
这两个本质上是一回事。inspectClass(path) 只是转调 getClassSnapshot(path)。
它适合什么时候用
当你已经定位到某个类文件,想一次性看它的整体画像时,用这个最顺手:
- 父类
- 接口
- 字段
- 方法
- 类中出现的字符串
返回值 ApkClassSnapshot
包含:
| 字段 | 说明 |
|---|---|
workspace | 当前工作区实体 |
file | 当前文件实体 |
classInfo | 类元数据 |
fields | 字段列表 |
methods | 方法列表 |
strings | 类内字符串记录 |
findUsages(signature)
这个 API 用来查“谁引用了这个目标签名”。
前提
- 当前必须有激活工作区
signature.trim()不能为空
否则会报:
- 当前没有可用工作区
signature 不能为空
它查的是什么表
底层查的是:
apk_refs
也就是索引阶段生成的引用关系表。
返回值 ApkUsageHit
每一项包含:
| 字段 | 说明 |
|---|---|
sourceSignature | 发起引用的一侧 |
targetSignature | 目标签名 |
filePath | 引用所在文件 |
lineNumber | 行号 |
refType | 引用类型 |
refType 真实值
索引阶段当前至少会产出这三种:
| 值 | 来源 |
|---|---|
CLASS | ReferenceType.TYPE |
FIELD | ReferenceType.FIELD |
METHOD | ReferenceType.METHOD |
getManifest(projectDir)
这个 API 很纯粹,就是给你当前工作区的 Manifest。
参数
| 参数 | 要求 |
|---|---|
projectDir | 必须是存在且文件有效的工作区 ID |
返回值 ApkManifestResult
| 字段 | 说明 |
|---|---|
workspace | 工作区实体 |
manifestXml | Manifest 文本 |
它不依赖当前激活工作区,所以做自动化时非常稳定。
工作区文件什么时候会被判定为“失效”
这个点特别值得写清楚,因为用户很容易以为“我明明导入过,为什么现在又没了”。
源码 workspaceFilesExist(workspace) 只硬检查两样东西:
cachedApkPath对应文件必须存在manifestPath对应文件必须存在
只要这两者有一个丢了,这个工作区就会被视为失效。
失效后会发生什么
requireWorkspace(projectDir) 发现工作区失效时会:
- 删除该工作区数据库记录和关联索引数据。
- 如果它刚好还是当前激活工作区,再把 active 也清掉。
- 抛出“工作区文件已失效”的错误。
所以你看到的“工作区突然消失”,很多时候不是 UI 抽风,而是源码主动做了清理。
索引阶段到底产出了哪些东西
导入 APK 后,工作区不是只生成几个 Smali 文件,而是同时往数据库里塞了多类实体。
入口元信息
索引开始时会先读取 APK 元信息:
manifestXmlpackageNameappLabelversionNameversionCode
这些都来自 ApkFile / Manifest 解析结果。
数据实体
当前会产出:
| 实体 | 作用 |
|---|---|
ApkWorkspaceEntity | 工作区级别信息 |
ApkFileEntity | 文件树条目 |
ApkClassEntity | 类元信息 |
ApkFieldEntity | 字段元信息 |
ApkMethodEntity | 方法元信息 |
ApkStringEntity | 字符串记录 |
ApkRefEntity | 引用关系 |
ApkClassEntity 里有哪些核心字段
| 字段 | 说明 |
|---|---|
classDescriptor | 类描述符 |
className | 类名 |
packageName | 所属包名 |
filePath | Smali 文件路径 |
superClass | 父类描述符 |
interfacesJson | 接口列表 |
sourceFile | 原始源文件名 |
accessFlags | 访问修饰符 |
lineNumber | 声明行 |
fieldCount | 字段数 |
methodCount | 方法数 |
ApkMethodEntity 里有哪些核心字段
| 字段 | 说明 |
|---|---|
methodSignature | 完整方法签名 |
methodName | 方法名 |
descriptor | 方法描述符 |
returnType | 返回类型 |
parameterTypesJson | 参数类型列表 |
accessFlags | 访问修饰符 |
filePath | 所属文件路径 |
startLine | 起始行 |
endLine | 结束行 |
bodyLineCount | 方法体行数 |
ApkFieldEntity 里有哪些核心字段
| 字段 | 说明 |
|---|---|
fieldSignature | 完整字段签名 |
fieldName | 字段名 |
typeDescriptor | 类型描述符 |
accessFlags | 访问修饰符 |
initialValue | 初始值 |
ApkStringEntity 和 ApkRefEntity
字符串记录会带:
valueclassDescriptormethodSignaturefilePathlineNumber
引用记录会带:
sourceSignaturesourceMethodSignaturetargetSignaturerefTypefilePathlineNumber
smali/、smali_classes2/ 这些目录名是怎么来的
源码 smaliOutputDirName(entryName) 的真实规则是:
| dex 名 | 输出目录 |
|---|---|
classes.dex | smali |
classes2.dex | smali_classes2 |
classes3.dex | smali_classes3 |
| 其他 dex | smali_<dex文件名去后缀并转小写> |
所以你在工作区里看到:
smali
smali_classes2
smali_classes3
完全是预期行为,不是重复反编译。
大 APK 会自动切低内存模式
源码里有一个很实用的保护逻辑:
- 当
source.apk大小大于等于32MB时,开启低内存模式。
开启后:
| 项 | 值 |
|---|---|
lowMemoryMode | true |
analysisParallelism | 1 |
classBatchSize | 4 |
否则:
| 项 | 规则 |
|---|---|
analysisParallelism | CPU 数裁到 2..4 |
classBatchSize | 至少 24,通常和并行度联动 |
这也是为什么你会发现:
- 小 APK 索引更快、更并行
- 大 APK 看起来更稳、更保守
源码是在主动压内存压力。
APK 工作区 MCP 工具怎么和这套模块对上
你在 MCP 里常见到的这些工具:
list_filessearch_coderead_fileget_method_contentget_class_infoinspect_classfind_usagesget_manifestget_analysis_state
本质上就是围绕这套工作区与索引模型做的外层暴露。
如果你已经理解了上面这页讲的“工作区 / activeProjectDir / 路径规则 / 索引实体”,这些工具的行为就会突然变得非常好理解。
已经知道精确类或方法时
如果你已经知道:
- 完整类描述符
- 完整方法签名
那通常比全局搜更直接的,是走这类“直达式”工具:
get_class_by_descriptorget_method_by_signature
这能少绕很多路。
新手最推荐的分析工作流
如果你的目标是“找到目标逻辑,然后 Hook 它”,最顺的一条线通常是:
- 先把 APK 导进工作区。
- 用
search_code搜关键词,先宽搜。 - 用
read_file或inspect_class缩小到具体类。 - 用
get_method_content看方法正文。 - 用
find_usages看调用链和引用链。 - 再回到运行时用
openDexKit()或openDexKitByClassLoader(...)精确定位。 - 最后再写
hook(...)/hookAll(...)。
这比“全靠猜类名直接上 Hook”稳定得多。
补一个和新版运行时直接相关的细节:
如果你最后拿到的是 methodHit.name、fieldHit.name 这类 Java 侧返回值,现在接到 hook(...)、hookAll(...)、callMethod(...)、getField(...) 这些入口时,通常可以直接传,不必先手动 String(...) 一遍。
为什么你明明导入过 APK,却还是查不到
这里把最常见原因直接拆开。
1. 你没有可用的当前激活工作区
这会直接影响:
searchCodefindUsages- 以及那些依赖隐式路径解析的
readFile、inspectClass、getMethodContent
2. 你传的是相对路径,但当前没有 active workspace
比如你写:
readFile("smali/com/example/MainActivity.smali")
如果当前没有 active workspace,这不是“文件不存在”,而是“路径前提不成立”。
3. 工作区文件已经失效
只要:
source.apk丢了AndroidManifest.xml丢了
这个工作区就会被判无效并清理。
4. 你把运行时 DexKit 和 APK 静态分析混用了
比如:
- 你想看当前运行时真实加载结果,却一直在看静态 Smali
- 你拿静态结果强行要求运行时按完全同样路径命中
这类问题非常常见。
5. 查询条件写得太死
典型表现:
- 方法参数数量写死但猜错了
- 直接按完整类名等值匹配,但目标已经混淆
- 还没缩小范围就一口气叠太多条件
排查建议永远是:
- 先宽搜
- 再加限制
- 最后才精确匹配
6. 当前正有其他导入 / 清理任务在跑
这时像 importApk、reparseActiveWorkspace、clearWorkspace 这类方法会直接返回 false,不是因为逻辑语义错,而是因为并发占用。
一个最实用的总原则
真正好用的方式,通常不是在“DexKit”和“Smali”之间二选一,而是把它们串起来:
- APK 工作区分析:帮你把范围缩小
- 运行时 DexKit:帮你贴近真实加载环境
- 最终 Hook:真正拦截行为
把这三层接起来,才是 JSXHook 里最顺手、也最适合新手成长的分析路径。
