反射与 Java 类操作
反射与 Java 类操作
反射这块是 ScriptX 里最容易把人卡住的一节,因为它同时牵扯 4 件事:
- 你要先拿到对的
Class - 你要分清自己现在调的是静态还是实例
- 你要知道参数到底要传什么类型
- 一旦遇到重载、私有字段、隐藏构造器,就不能只靠“猜”
所以这页我不打算只把 API 列一遍,而是按真正写脚本时的思路,把它拆成“怎么找类”“怎么调方法”“怎么读写字段”“怎么构造对象”“什么时候该下沉到原生反射”几块来讲。
先建立脑图
你可以先把 ScriptX 里的反射相关能力分成 3 套写法:
| 写法 | 典型入口 | 适合场景 | 优点 | 容易踩的坑 |
|---|---|---|---|---|
| 类代理写法 | imports() / importClass() | 同一个类要反复用 | 写起来最顺手,像普通 JS 一样 | 重载复杂时不够精确 |
| 辅助函数写法 | findClass() / callMethod() / getField() | 日常 Hook 最常见 | 比原生反射短很多 | 参数类型匹配比你想象中更严格 |
| 原生反射写法 | getDeclaredMethod() / getDeclaredField() / getDeclaredConstructor() | 重载很多、签名必须精确、要动私有成员 | 最稳、最可控 | 写法更长,需要你自己管好类型 |
如果你只先记住一句话:
- 正常开发先用
imports/findClass+ 辅助函数 - 一旦遇到重载、私有成员、签名不准,立刻改走原生反射
先分清几组 API
找类
imports(className)importClass(className)findClass(nameOrClass, classLoader?)
调方法
callStaticMethod(clazz, methodName, ...args)callMethod(instance, methodName, ...args)invoke(target, methodName, ...args)
读写字段
getField(obj, fieldName)setField(obj, fieldName, value)getStaticField(clazzOrName, fieldName)setStaticField(clazzOrName, fieldName, value)printFields(target, separator?)
构造对象和数组
new 导入后的类(...)this["new"](clazzOrName, ...args)this["new"](className, lpparam.classLoader, ...args):第一参数是字符串类名时可显式指定 loadernewArray(typeName, size)ByteArray(size)
一眼分清 3 个“类”的来源
这是整个反射章节最基础、也最容易混的地方。
1. imports() / importClass() 返回的是“类代理”
你可以把它理解成“脚本里可直接拿来用的 Java 类门面”。
它最适合你后面要反复写这个类的场景。
const ArrayList = imports("java.util.ArrayList");
const Toast = importClass("android.widget.Toast");
const list = new ArrayList();
callMethod(list, "add", "jsxhook");
类代理最顺手的地方在于:
- 可以直接
new ArrayList() - 可以直接调静态方法,比如
Toast.makeText(...) - 可以直接读静态字段,比如
BuildVersion.SDK_INT
2. findClass() 返回的是真正的 Java Class
它更像“精准定位后的原始类对象”。
适合这几类情况:
- 目标类来自宿主 App
- 你要显式传
lpparam.classLoader - 你后面要拿
getDeclaredMethod()/getDeclaredField()/getDeclaredConstructor()
const ProfileManagerClass = findClass(
"com.example.target.ProfileManager",
lpparam.classLoader
);
3. 字符串类名只是“待解析名字”
很多 API 第一参数既支持类代理,也支持 Class,还支持字符串类名:
getStaticField("android.os.Build$VERSION", "SDK_INT");
但不要因为“字符串能用”就一路都传字符串。
一旦是宿主类、插件类、动态 dex 里的类,优先先 findClass()。
imports(className) / importClass(className)
这两个名字在当前实现里本质上是一回事,importClass 就是 imports 的别名。
单类导入
const ArrayList = imports("java.util.ArrayList");
const Toast = importClass("android.widget.Toast");
const BuildVersion = importClass("android.os.Build$VERSION");
返回值
- 单类导入时,返回值是一个类代理
- 这个类代理既能构造对象,也能访问静态方法 / 静态字段
最常见用法:导入后反复使用
const ArrayList = imports("java.util.ArrayList");
const list = new ArrayList();
list.add("first");
list.add("second");
log(`size=${list.size()}`);
这段代码能这样写,是因为类代理支持:
new ArrayList()构造实例list.add()调实例方法list.size()调实例方法
用类代理直接调静态方法
const ActivityThread = importClass("android.app.ActivityThread");
const application = ActivityThread.currentApplication();
log(`application=${application}`);
用类代理直接读静态字段
const BuildVersion = importClass("android.os.Build$VERSION");
log(`sdk=${BuildVersion.SDK_INT}`);
log(`release=${BuildVersion.RELEASE}`);
用类代理直接改静态字段
const DebugFlags = importClass("com.example.target.DebugFlags");
DebugFlags.ENABLE_LOG = true;
log(`ENABLE_LOG=${DebugFlags.ENABLE_LOG}`);
这其实等价于:
setStaticField("com.example.target.DebugFlags", "ENABLE_LOG", true);
通配导入
imports("java.util.*");
const list = new ArrayList();
const map = new HashMap();
list.add("demo");
map.put("lang", getAppLanguage());
log(`size=${list.size()}`);
log(`lang=${map.get("lang")}`);
通配导入的真实规则
- 只导入这个包下的直接类
- 不会递归导入子包
- 返回值是成功导入的类数量
也就是说:
const count = imports("java.util.*");
log(`count=${count}`);
这里的 count 是数字,不是类代理。
什么情况下优先用 imports
这几种情况最适合:
- 同一个类要用很多次
- 你想让脚本更短、更接近日常 Java 调用风格
- 这个类本身就不依赖复杂
classLoader
什么情况下别只靠 imports
这几种情况更建议转去 findClass():
- 类来自宿主 App 或插件 dex
- 你后面要拿精确构造器 / 精确方法
- 你要查私有字段、私有方法、隐藏成员
findClass(nameOrClass, classLoader?)
这是“我就是要找到这个类本体”时最重要的 API。
参数
| 参数 | 类型 | 必填 | 可填值 | 说明 |
|---|---|---|---|---|
nameOrClass | string / Class | 是 | 完整类名,或已经拿到的 Java Class | 传字符串时做类查找;传 Class 时直接原样返回 |
classLoader | ClassLoader | 否 | 常见是 lpparam.classLoader | 宿主类、插件类建议显式传 |
它现在不只是“查字符串类名”
当前实现里,findClass(...) 可以吃两类输入:
- 字符串类名
- 已经拿到的 Java
Class对象
如果第一参数本来就是 Class,它不会再重复查找,而是直接把这个 Class 返回给你。
这个行为很适合做“统一入口”或“归一化参数”。
最基础写法:传字符串
const ActivityThread = findClass("android.app.ActivityThread");
传已拿到的 Class:直接原样返回
const FileClass = findClass("java.io.File", lpparam.classLoader);
const SameFileClass = findClass(FileClass);
log(FileClass === SameFileClass);
你可以把它理解成:
- 传字符串:
帮我找类 - 传
Class:帮我确认并原样拿回来
查宿主自己的类
const TargetClass = findClass(
"com.example.target.ProfileManager",
lpparam.classLoader
);
在 Hook 回调里继续顺着当前对象的加载器查类
hook({
class: "com.example.target.EntryActivity",
classloader: lpparam.classLoader,
method: "onCreate",
params: ["android.os.Bundle"],
after(it) {
const loader = it.thisObject.getClass().getClassLoader();
const ProfileClass = findClass("com.example.target.Profile", loader);
log(`profileClass=${ProfileClass}`);
}
});
写工具函数时很有用:统一吃字符串或 Class
这个能力最适合的一个场景,就是你自己封装工具函数时不想强迫调用方只能传字符串。
function ensureClass(nameOrClass, loader) {
return findClass(nameOrClass, loader);
}
const FileClass1 = ensureClass("java.io.File", lpparam.classLoader);
const FileClass2 = ensureClass(FileClass1, lpparam.classLoader);
log(FileClass1 === FileClass2);
这样调用方就可以:
- 还没查类时传字符串
- 已经查过类时直接传
Class
你这边不需要分两套逻辑。
不传 classLoader 时,内部大概会怎么找
当前实现不是只死盯一个加载器,而是会按下面思路尝试:
- 你显式传入的
preferredLoader lpparam.classLoader- 已加载的 dex loaders
- host loader
Class.forName(...)
所以:
- 系统类很多时候不传也能找到
- 宿主类有时不传也能撞到
- 第一参数如果已经是
Class,这里这套查找流程就不会再跑 - 但不要把“偶尔能找到”误当成稳定行为
什么时候必须显式传 lpparam.classLoader
这几种情况都建议你别偷懒:
- 目标类是宿主 App 自己的业务类
- 目标类来自被你
loadDex()过的 dex - 你已经见过
Class not found - 同名类可能在多个加载链里同时存在
查类之后马上做静态调用
const ActivityThread = findClass(
"android.app.ActivityThread",
lpparam.classLoader
);
const app = callStaticMethod(ActivityThread, "currentApplication");
log(`app=${app}`);
查类之后马上下沉到原生反射
const ActivityThread = findClass(
"android.app.ActivityThread",
lpparam.classLoader
);
const method = ActivityThread.getDeclaredMethod("currentApplication");
method.setAccessible(true);
const app = method.invoke(null);
log(`app=${app}`);
callStaticMethod(clazz, methodName, ...args)
这个是“已知静态方法,直接调”的辅助函数。
第一参数能传什么
| 写法 | 例子 |
|---|---|
| 类名字符串 | "android.os.Build$VERSION" |
Class | findClass("android.app.ActivityThread", lpparam.classLoader) |
| 类代理 | importClass("android.text.TextUtils") |
调无参静态方法
const ActivityThread = importClass("android.app.ActivityThread");
const application = callStaticMethod(ActivityThread, "currentApplication");
log(`application=${application}`);
调带参静态方法
const TextUtils = importClass("android.text.TextUtils");
log(callStaticMethod(TextUtils, "isEmpty", ""));
log(callStaticMethod(TextUtils, "isEmpty", "hello"));
直接传字符串类名
const sdk = getStaticField("android.os.Build$VERSION", "SDK_INT");
log(`sdk=${sdk}`);
虽然这里演示的是 getStaticField(),但第一参数支持规则和静态调用是一类思路。
callMethod(instance, methodName, ...args)
这个是“我手里已经有对象了,现在要调它的方法”。
最基础例子
const ArrayList = imports("java.util.ArrayList");
const list = new ArrayList();
callMethod(list, "add", "first");
callMethod(list, "add", "second");
log(`size=${callMethod(list, "size")}`);
log(`first=${callMethod(list, "get", 0)}`);
Hook 回调里调 thisObject
hook({
class: "com.example.target.ProfileManager",
classloader: lpparam.classLoader,
method: "loadProfile",
after(it) {
const profile = callMethod(it.thisObject, "getCurrentProfile");
const name = callMethod(profile, "getName");
log(`name=${name}`);
}
});
一个很实用的小细节:直接传 it
在当前实现里,callMethod() 的第一个参数如果是 XC_MethodHook.MethodHookParam,内部会自动取它的 thisObject。
所以这类写法也能工作:
callMethod(it, "finish");
不过日常更推荐你写成:
callMethod(it.thisObject, "finish");
因为可读性更高,别人一眼能看懂你到底在调谁。
invoke(target, methodName, ...args)
它是一个“通用入口”:
- 第一参数如果是类 -> 走静态调用
- 第一参数如果是对象 -> 走实例调用
传类,走静态
const ActivityThread = importClass("android.app.ActivityThread");
const application = invoke(ActivityThread, "currentApplication");
log(`application=${application}`);
传对象,走实例
const list = new (imports("java.util.ArrayList"))();
invoke(list, "add", "jsxhook");
invoke(list, "add", "reflection");
log(`size=${invoke(list, "size")}`);
适合什么时候用
适合封装通用工具时:
function safeInvoke(target, methodName, ...args) {
try {
return invoke(target, methodName, ...args);
} catch (error) {
log(`invoke failed: ${error}`);
return null;
}
}
什么时候不如分开写
如果你已经明确知道:
- 这就是静态方法
- 这就是实例方法
那直接写 callStaticMethod() / callMethod() 更清楚。
methodName 现在不一定非得手写成普通 JS 字符串
当前桥接层在处理 callStaticMethod()、callMethod()、invoke() 的 methodName 时,会先做一次“解包 -> 转字符串”。
这意味着下面几类值现在都更稳:
- 普通 JS 字符串:
"login" - Rhino 包装后的字符串值
- Java 侧对象上返回的字符串类结果
- 例如 DexKit / 反射结果里的
matches[0].name、methodData.name
const methodData = matches[0];
callMethod(target, methodData.name);
callStaticMethod(TargetClass, methodData.name, arg0);
invoke(TargetClass, methodData.name);
如果方法名本来就是固定的,还是推荐你直接写字面量字符串,因为这样最清楚:
callMethod(target, "login");
DexKit 结果里的 isXxx 现在也能直接当布尔属性读
这条虽然不是 callMethod() 本身的参数规则,但和实际联动非常强,值得放在这里一起记。
像下面这些 Kotlin 布尔属性:
MethodData.isConstructorMethodData.isStaticInitializerMethodData.isMethodClassData.isArray
现在在 ScriptX 里直接读就是 true / false,可以直接这样判断:
const hit = methodHits.firstOrNull();
if (hit && hit.isMethod) {
callMethod(target, hit.name);
}
注意这里应该写:
hit.isMethod
而不是:
hit.isMethod()
方法调用的参数匹配并不宽松
这是整页里最值得你记住的一个点。
callMethod(obj, methodName, ...args)
调用实例方法。第一参数通常是目标对象,第二参数是方法名。
callStaticMethod(clazzOrName, methodName, ...args)
调用静态方法。第一参数可以是类名、类代理或者真实 Class。
invoke(target, methodNameOrMethod, ...args)
更底层的调用入口,既可以喂方法名,也可以直接喂反射 Method。
上面那层“解包再转字符串”的兼容,只针对 方法名这个位置。
真正传给 Java 方法的 ...args 仍然按原来的类型匹配规则处理,不会因为 methodName 更宽松了,就顺手帮你把 "1" 变成 int、把 "true" 变成 boolean。
当前实现的匹配规则,尽量按这 4 条理解
- 参数个数必须对上
null不能传给基本类型参数- 基本类型主要接受对应包装类型
- 不会帮你自动把字符串
"1"当成int,也不会自动把"true"当成boolean
错误例子:你以为能自动转,其实不一定
// 如果 Java 方法签名是 setLevel(int)
callMethod(target, "setLevel", "1");
这类写法很容易匹配失败。
正确例子:直接传对的 JS 类型
callMethod(target, "setLevel", 1);
callMethod(target, "setVip", true);
callMethod(target, "setRatio", 0.75);
再举一个容易忽略的例子
// 假设 Java 签名是 setEnabled(boolean)
callMethod(target, "setEnabled", "true"); // 容易失败
callMethod(target, "setEnabled", true); // 更稳
看到重载多的时候,不要死猜
比如同名方法很多、而且参数类型接近时,最稳的做法不是一遍遍换参数试,而是直接拿精确 Method:
const StringBuilderClass = findClass("java.lang.StringBuilder");
const IntegerClass = importClass("java.lang.Integer");
const appendInt = StringBuilderClass.getDeclaredMethod(
"append",
IntegerClass.TYPE
);
appendInt.setAccessible(true);
const sb = this["new"](StringBuilderClass);
appendInt.invoke(sb, 123);
log(sb.toString());
实例字段读写
这组 API 是读写实例字段的。
getField(obj, fieldName)
读取实例字段。
setField(obj, fieldName, value)
修改实例字段。
先记住它的真实行为
- 能找私有字段
- 会沿继承链往父类找
- 第一参数如果传的是
it,会自动取it.thisObject fieldName会先做一次“解包 -> 转字符串”,静态字段版本也是同一套规则
读取实例字段
const profile = callMethod(manager, "getCurrentProfile");
const name = getField(profile, "name");
log(`name=${name}`);
修改实例字段
setField(profile, "vip", true);
setField(profile, "loginCount", 99);
在 Hook 里直接改参数对象
hook({
class: "com.example.target.ProfileManager",
classloader: lpparam.classLoader,
method: "updateProfile",
params: ["com.example.target.Profile"],
before(it) {
const profile = it.args[0];
log(`before.name=${getField(profile, "name")}`);
log(`before.vip=${getField(profile, "vip")}`);
setField(profile, "vip", true);
setField(profile, "name", "patched-by-jsxhook");
log(`after.name=${getField(profile, "name")}`);
log(`after.vip=${getField(profile, "vip")}`);
}
});
传 it 也能工作
const title = getField(it, "mTitle");
setField(it, "mResumed", true);
但和 callMethod() 一样,平时更建议你把目标写清楚:
const title = getField(it.thisObject, "mTitle");
连续下钻一个对象
const manager = it.thisObject;
const profile = getField(manager, "mProfile");
const token = getField(profile, "token");
log(`token=${token}`);
fieldName 可以直接吃 Java 返回值
这点在和 DexKit、反射扫描结果配合时特别实用。
现在 getField()、setField()、getStaticField()、setStaticField() 处理字段名时,也会先解包再转字符串,所以不一定非得自己再手动 String(...) 一遍。
const fieldName = fieldData.name;
log(getField(profile, fieldName));
setField(profile, fieldName, "patched-by-jsxhook");
常见来源包括:
fieldData.namefieldHit.fieldName- 其他 Java 返回对象里的字符串类字段名
静态字段读写
这一组是读写静态字段的。
getStaticField(clazzOrName, fieldName)
读取静态字段。
setStaticField(clazzOrName, fieldName, value)
修改静态字段。
第二参数 fieldName 的行为和上面实例字段一致,也支持先解包再转字符串。
第一参数可传值
| 形式 | 例子 |
|---|---|
| 字符串类名 | "android.os.Build$VERSION" |
Class | findClass("com.example.target.DebugFlags", lpparam.classLoader) |
| 类代理 | importClass("android.os.Build$VERSION") |
读取静态字段
log(`sdk=${getStaticField("android.os.Build$VERSION", "SDK_INT")}`);
用类代理读静态字段
const BuildVersion = importClass("android.os.Build$VERSION");
log(`sdk=${getStaticField(BuildVersion, "SDK_INT")}`);
log(`release=${BuildVersion.RELEASE}`);
修改静态字段
const DebugFlags = findClass(
"com.example.target.DebugFlags",
lpparam.classLoader
);
setStaticField(DebugFlags, "ENABLE_LOG", true);
log(`ENABLE_LOG=${getStaticField(DebugFlags, "ENABLE_LOG")}`);
直接用类代理改静态字段
const DebugFlags = importClass("com.example.target.DebugFlags");
DebugFlags.ENABLE_LOG = true;
log(`ENABLE_LOG=${DebugFlags.ENABLE_LOG}`);
printFields(target, separator?)
这个函数特别适合反射阶段“先看清楚对象长什么样”。
参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
target | object / Class | 是 | - | 传对象时打印实例字段;传类时打印静态字段 |
separator | string | 否 | " " | 字段之间怎么分隔 |
传对象时会发生什么
- 只打印实例字段
- 会沿继承链向上找
- 输出走日志,不是函数返回值
hook({
class: "com.example.target.ProfileManager",
classloader: lpparam.classLoader,
method: "getProfile",
after(it) {
printFields(it.thisObject, "\n");
}
});
传类时会发生什么
- 只打印静态字段
- 同样会沿继承链向上看
const BuildVersion = importClass("android.os.Build$VERSION");
printFields(BuildVersion, "\n");
最推荐的用法:陌生对象先 dump,再决定下一步
hook({
class: "com.example.target.EntryActivity",
classloader: lpparam.classLoader,
method: "onResume",
after(it) {
printFields(it.thisObject, "\n");
}
});
这样你很快就能知道字段到底叫:
mProfileprofilecurrentProfile- 还是别的名字
构造对象:其实你有 3 条路
路线 1:new 导入后的类(...)
这是最像平时写代码的方式,适合公开构造器、参数也比较普通的类。
const ArrayList = imports("java.util.ArrayList");
const list = new ArrayList();
list.add("a");
list.add("b");
log(list.size());
路线 2:this["new"](clazzOrName, ...args)
这是全局的构造辅助函数。
它比类代理构造更强,适合:
- 你手上拿的是
Class - 你要调
declaredConstructors - 参数类型比较复杂
- 你想利用当前实现里更宽松的构造参数转换
先记住它支持的几种写法
| 写法 | 什么时候用 |
|---|---|
this["new"](FileClass, arg1, arg2) | 你手上已经是 Class |
this["new"](FileProxy, arg1, arg2) | 你手上是 imports() / importClass() 返回的类代理 |
this["new"]("java.io.File", arg1, arg2) | 默认 loader 就能找到这个类 |
this["new"]("java.io.File", lpparam.classLoader, arg1, arg2) | 你要显式指定查类用的 ClassLoader |
const FileClass = findClass("java.io.File", lpparam.classLoader);
const file = this["new"](FileClass, "/sdcard/Download/demo.txt");
log(`exists=${file.exists()}`);
log(`path=${file.getPath()}`);
第一参数是字符串类名时,第二参数可以显式传 lpparam.classLoader
这是当前实现里一个很实用、但很容易被忽略的细节。
只有同时满足下面两件事时,第二参数才会被当成“查类用的 ClassLoader”:
- 第一参数是字符串类名
- 第二参数本身是一个真正的 Java
ClassLoader
一旦满足这两个条件:
- 第二参数只参与“先把类找出来”
- 真正的构造参数会从第三个位置开始算
const sendImage = this["new"](
"com.example.mobileqq.SendImage",
lpparam.classLoader,
imagePath,
peerUin
);
这类写法最适合:
- 类名现在只有字符串
- 这个类不在默认 loader 里
- 你已经拿到了一个专门的 loader,比如插件 loader、宿主 loader、动态 dex loader
sendImageClass 如果已经不是字符串,这条规则就不再生效
如果 sendImageClass 已经是下面这些形式之一:
Class- 类代理
那 ScriptX 会直接拿它当“已经解析好的类”去构造。
这时你再把第二参数写成 lpparam.classLoader,它不会再被当成“查类 loader”,而会继续参与构造器参数匹配。
const sendImageClass = findClass(
"com.example.mobileqq.SendImage",
lpparam.classLoader,
);
// 这里推荐只传真正的构造参数
const sendImage = this["new"](sendImageClass, imagePath, peerUin);
如果目标构造器本身第一参数就是 ClassLoader,那当然也可以传:
const sendImage = this["new"](sendImageClass, lpparam.classLoader, imagePath);
但这里的 classLoader 含义已经变了,它是构造器参数,不是“帮你查类的 loader”。
第一参数是字符串,但第二参数不是 ClassLoader 时
那它就只是普通构造参数,不会被特殊处理。
const url = this["new"]("java.net.URL", "https://example.com/image.jpg");
路线 3:精确 Constructor
当一个类构造器很多、签名很接近、你不想碰运气时,直接拿 Constructor。
const FileClass = findClass("java.io.File", lpparam.classLoader);
const StringClass = findClass("java.lang.String");
const ctor = FileClass.getDeclaredConstructor(StringClass, StringClass);
ctor.setAccessible(true);
const file = ctor.newInstance("/sdcard", "Download/demo.txt");
log(`path=${file.getPath()}`);
log(`exists=${file.exists()}`);
new 导入后的类(...) 和 this["new"](...) 的差别一定要知道
这点很关键。
new 导入后的类(...)
优点:
- 写法最短
- 最像普通 Java / JS
- 日常最顺手
限制:
- 更适合公开构造器
- 重载复杂时不够透明
this["new"](...)
优点:
- 可以直接传字符串类名、类代理、
Class - 第一参数是字符串类名时,第二参数还能显式传
lpparam.classLoader - 走的是
declaredConstructors - 会尝试更积极地匹配和转换参数
- 出错时会把可用构造器列表带出来,排查更容易
一个非常实用的结论
如果你遇到下面这些情况:
new SomeClass(...)一直不稳- 构造器重载很多
- 你怀疑它用的是私有 / protected 构造器
那就别硬扛,直接改成:
const SomeClass = findClass("com.example.target.SomeClass", lpparam.classLoader);
const obj = this["new"](SomeClass, arg1, arg2);
或者更进一步,直接拿精确 Constructor。
this["new"](...) 的参数转换比方法调用更宽松
这也是一个很容易忽略的细节。
和 callMethod() 相比,它能多帮你做什么
当前实现对构造参数会尝试做这些转换:
CharSequence -> String- 单字符字符串 / 数字 ->
char - 数字 /
"true"/"false"/"1"/"0"/"yes"/"no"->boolean - 数字 / 数字字符串 / 字符 -> 数值类型
- 字符串 -> 枚举名
List/ JS 数组 / Java 数组 -> 目标数组类型
例子 1:单字符字符串转 char
const CharacterClass = findClass("java.lang.Character");
const ch = this["new"](CharacterClass, "A");
log(`${ch}`);
例子 2:JS 数组转 Java 数组参数
如果某个构造器参数本身是数组类型,this["new"](...) 会比你手动拼更轻松。
const ArrayListClass = findClass("java.util.ArrayList");
const list = this["new"](ArrayListClass);
list.add("alpha");
list.add("beta");
log(list.size());
上面这个例子没用到数组参数,但你可以先记住结论:
构造对象时,this["new"](...) 往往比 callMethod() 更愿意帮你做类型转换。
newArray(typeName, size)
这是专门创建 Java 数组的。
参数
| 参数 | 类型 | 必填 | 可填值 | 说明 |
|---|---|---|---|---|
typeName | string / Class / 类代理 | 是 | 基本类型名、String、完整类名、数组类型名 | 数组元素类型 |
size | number | 否 | 非负整数 | 长度;不传时等价于 0 |
支持的常见类型名
intlongbooleandoublefloatcharbyteshortStringjava.io.Filejava.lang.Stringint[]java.lang.String[]
创建基础类型数组
const ints = newArray("int", 4);
ints[0] = 10;
ints[1] = 20;
ints[2] = 30;
ints[3] = 40;
log(`len=${ints.length}`);
log(`first=${ints[0]}`);
创建对象数组
const files = newArray("java.io.File", 2);
const FileClass = findClass("java.io.File", lpparam.classLoader);
files[0] = this["new"](FileClass, "/sdcard/Download/a.txt");
files[1] = this["new"](FileClass, "/sdcard/Download/b.txt");
log(`files[0]=${files[0].getPath()}`);
log(`files[1]=${files[1].getPath()}`);
直接传类代理 / Class
const FileClass = findClass("java.io.File", lpparam.classLoader);
const files = newArray(FileClass, 3);
log(`len=${files.length}`);
一个容易忽略的小细节
如果长度传的是负数,当前实现会把它压成 0,不会真的创建负长度数组。
const arr = newArray("int", -5);
log(arr.length); // 0
ByteArray(size)
这是 byte[] 的快捷创建函数。
最基础例子
const bytes = ByteArray(8);
bytes[0] = 65;
bytes[1] = 66;
bytes[2] = 67;
log(`len=${bytes.length}`);
log(`${bytes[0]},${bytes[1]},${bytes[2]}`);
什么时候比 newArray("byte", size) 更适合
通常就 3 种情况:
- 你就是要
byte[] - 你在写加密、文件、网络字节处理
- 你不想每次都重复写
"byte"
例子:配合 String(byte[]) 构造字符串
const bytes = ByteArray(3);
bytes[0] = 65;
bytes[1] = 66;
bytes[2] = 67;
const StringClass = findClass("java.lang.String");
const text = this["new"](StringClass, bytes);
log(`${text}`);
难点场景:重载多、签名复杂时,直接上原生反射
这部分很重要,因为很多人卡住不是因为“不知道 API 名字”,而是因为“明明 API 名字都知道,但就是调不中”。
场景 1:我要精确拿某个重载方法
const StringBuilderClass = findClass("java.lang.StringBuilder");
const IntegerClass = importClass("java.lang.Integer");
const appendInt = StringBuilderClass.getDeclaredMethod(
"append",
IntegerClass.TYPE
);
appendInt.setAccessible(true);
const sb = this["new"](StringBuilderClass);
appendInt.invoke(sb, 123);
log(sb.toString());
场景 2:我要拿私有字段
const clazz = findClass("com.example.target.DebugFlags", lpparam.classLoader);
const field = clazz.getDeclaredField("ENABLE_LOG");
field.setAccessible(true);
field.set(null, true);
log(field.get(null));
场景 3:我要用精确构造器
const FileClass = findClass("java.io.File", lpparam.classLoader);
const StringClass = findClass("java.lang.String");
const ctor = FileClass.getDeclaredConstructor(StringClass, StringClass);
ctor.setAccessible(true);
const file = ctor.newInstance("/sdcard", "Download/demo.txt");
log(file.getPath());
一个很实战的结论
如果你遇到这些现象:
callMethod()总是NoSuchMethodinvoke()看起来参数差不多,但就是调不到new SomeClass(...)重载太多,一会儿能用一会儿不能用
那就立刻切到:
findClass()getDeclaredMethod()/getDeclaredConstructor()/getDeclaredField()setAccessible(true)invoke()/newInstance()/field.get()/field.set()
别再靠猜。
3 个完整操作例子
例子 1:查类 -> 拿单例 -> 调实例方法
const ActivityThread = findClass(
"android.app.ActivityThread",
lpparam.classLoader
);
const currentApplication = ActivityThread.getDeclaredMethod("currentApplication");
currentApplication.setAccessible(true);
const app = currentApplication.invoke(null);
const packageName = app.getPackageName();
log(`package=${packageName}`);
例子 2:在 Hook 回调里读字段、改字段、再调方法
hook({
class: "com.example.target.ProfileManager",
classloader: lpparam.classLoader,
method: "updateProfile",
params: ["com.example.target.Profile"],
before(it) {
const profile = it.args[0];
log(`before.name=${getField(profile, "name")}`);
log(`before.vip=${getField(profile, "vip")}`);
setField(profile, "vip", true);
setField(profile, "name", "patched-user");
log(`after.name=${getField(profile, "name")}`);
log(`after.vip=${getField(profile, "vip")}`);
},
after(it) {
const result = callMethod(it.thisObject, "getCurrentProfile");
log(`current=${result}`);
}
});
例子 3:先看字段,再决定读哪个
hook({
class: "com.example.target.EntryActivity",
classloader: lpparam.classLoader,
method: "onResume",
after(it) {
printFields(it.thisObject, "\n");
// 观察日志后,假设你发现有个字段叫 mProfile
const profile = getField(it.thisObject, "mProfile");
printFields(profile, "\n");
}
});
常见误区
误区 1:宿主类也用系统类那种查法
不推荐:
findClass("com.example.target.ProfileManager");
更稳:
findClass("com.example.target.ProfileManager", lpparam.classLoader);
误区 2:把字符串当数字 / 布尔硬塞给 callMethod
不稳:
callMethod(target, "setLevel", "1");
callMethod(target, "setEnabled", "true");
更稳:
callMethod(target, "setLevel", 1);
callMethod(target, "setEnabled", true);
误区 3:实例字段和静态字段不分
实例字段用:
getField(obj, "name");
静态字段用:
getStaticField(clazz, "SDK_INT");
误区 4:对象长什么样没看清就直接猜字段名
先:
printFields(obj, "\n");
再决定:
getField(obj, "mProfile");
误区 5:重载太多还坚持只用简写 helper
一旦重载多、签名复杂,直接切原生反射。
最后给你一个选择口诀
可以直接照这个顺序选:
- 要反复用同一个类:先
imports()/importClass() - 要查宿主类:
findClass(name, lpparam.classLoader) - 已知就是实例方法:
callMethod() - 已知就是静态方法:
callStaticMethod() - 想偷个懒写通用入口:
invoke() - 要读写实例字段 / 静态字段:
getField()/getStaticField() - 要先摸清对象结构:
printFields() - 普通构造:
new 导入后的类(...) - 构造器复杂 / 想更稳:
this["new"](...) - 重载、私有成员、签名必须精准:直接
getDeclaredMethod()/getDeclaredConstructor()/getDeclaredField()
如果你把这页吃透,后面无论是改字段、抓单例、造对象、还是在 Hook 里追调用链,基本都能自己往下推了。
