反射与 Java 类操作
反射与 Java 类操作
反射这块是 ScriptX 里最容易把人卡住的一节,因为它同时牵扯 4 件事:
- 你要先拿到对的
Class - 你要分清自己现在调的是静态还是实例
- 你要知道参数到底要传什么类型
- 一旦遇到重载、私有字段、隐藏构造器,就不能只靠“猜”
所以这页我不打算只把 API 列一遍,而是按真正写脚本时的思路,把它拆成“怎么找类”“怎么调方法”“怎么读写字段”“怎么构造对象”“什么时候该下沉到原生反射”几块来讲。
先建立脑图
你可以先把 ScriptX 里的反射相关能力分成 3 套写法:
| 写法 | 典型入口 | 适合场景 | 优点 | 容易踩的坑 |
|---|---|---|---|---|
| 类代理写法 | imports() / importClass() | 同一个类要反复用 | 写起来最顺手,像普通 JS 一样 | 重载复杂时不够精确 |
| 辅助函数写法 | findClass() / callMethod() / getField() | 日常 Hook 最常见 | 比原生反射短很多 | 参数类型匹配比你想象中更严格 |
| 接口代理写法 | createProxy() | Java 要求你传 listener / callback / interface 实例 | 不用单独写 Java 类,脚本里就地实现接口 | 方法名、返回值、loader 一旦写糊就容易翻车 |
| 原生反射写法 | 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?)
接口代理
createProxy(interfaces, callbackOrCallbacks, classLoader?)
构造对象和数组
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}`);
createProxy(interfaces, callbackOrCallbacks, classLoader?)
这是这次新增进来的一个很实用的桥接能力。
它的作用不是“查类”或者“调方法”,而是让你在 JS 里临时实现一个 Java 接口,再把这个代理对象传回 Java 世界。
如果你以前写过 Java / Kotlin 里的:
RunnableView.OnClickListenerDialogInterface.OnClickListenerComparator- 各种
Listener/Callback
那你可以把 createProxy(...) 理解成:
- JS 侧手写一个接口实现
- 运行时帮你
Proxy.newProxyInstance(...)出来 - Java 调这个接口方法时,再回调到你的 JS 函数
先记住它最适合什么场景
- Java 代码要你传一个接口实例
- 你不想单独写一个 Java 类
- 你想在脚本里就地拼一个 listener / callback
- 你想快速把事件、比较逻辑、异步回调留在脚本层
参数
| 参数 | 类型 | 必填 | 可填值 | 说明 |
|---|---|---|---|---|
interfaces | string / Class / 类代理 / 数组 / 列表 | 是 | 单个接口或多个接口 | 要实现的 Java 接口 |
callbackOrCallbacks | Function / Scriptable object | 是 | 单函数,或按方法名分发的对象 | 代理方法被调到时要执行的 JS 回调 |
classLoader | ClassLoader | 否 | 常见是 lpparam.classLoader | 显式指定查接口和创建代理时使用的 loader |
先说结论:它只支持接口,不支持普通类
源码里会逐个检查你传进去的类型是不是 interface。
所以这类写法会直接报错:
createProxy("java.lang.Thread", function () {});
原因很简单:Thread 是类,不是接口。
正确思路应该是传:
createProxy("java.lang.Runnable", function () {
log("run");
});
interfaces 能传哪些形式
当前实现会先用和 findClass() / newArray() 很接近的类型解析规则,把你传的值统一转成 Class 列表。
你可以传下面这些:
| 写法 | 是否支持 | 说明 |
|---|---|---|
"java.lang.Runnable" | 支持 | 单个字符串接口名 |
findClass("java.lang.Runnable") | 支持 | 直接传 Class |
imports("java.lang.Runnable") 返回的类代理 | 支持 | 会解包成真实 Class |
["java.lang.Runnable", "java.util.concurrent.Callable"] | 支持 | JS 数组 |
[RunnableClass, CallableClass] | 支持 | Class 数组 |
Java List<Class> / Class[] | 支持 | 会被逐项解析 |
单接口最常见写法:传字符串
const runnable = createProxy("java.lang.Runnable", function () {
log("Runnable.run() called");
});
传 Class
const RunnableClass = findClass("java.lang.Runnable");
const runnable = createProxy(RunnableClass, function () {
log("run from Class");
});
传类代理
const RunnableProxyClass = importClass("java.lang.Runnable");
const runnable = createProxy(RunnableProxyClass, function () {
log("run from imported proxy");
});
一次传多个接口
const runnableAndCallable = createProxy(
["java.lang.Runnable", "java.util.concurrent.Callable"],
{
run() {
log("run()");
},
call() {
log("call()");
return "done";
}
}
);
当前实现里,多接口代理本身是允许的。
但你要自己保证这些接口方法名不互相打架,或者你有意让它们共用同一个处理函数。
不传任何接口会怎样
这会直接抛错:
createProxy([], function () {});
因为源码要求至少要解析出一个接口:
- 空数组不行
null不行- 根本解析不出任何
Class也不行
第二参数有两种模式:函数模式和对象模式
这是 createProxy(...) 最核心的用法分叉。
函数模式:所有接口方法都走同一个函数
如果第二参数本身就是一个函数,那么当前实现会把它当成兜底处理函数。
也就是说:
- 这个代理对象上的所有接口方法调用
- 最终都会打到这一份函数上
最基础例子:Runnable
const runnable = createProxy("java.lang.Runnable", function () {
log("task running");
});
callMethod(runnable, "run");
这里 Runnable 只有一个 run(),所以函数模式很顺手。
适合哪些接口
函数模式最适合:
- 只有一个抽象方法的接口
- 你根本不在意方法名,只想统一兜底
- 你自己知道这个代理只会被某一个方法调用
最典型的就是:
Runnable- 很多单方法 listener
- 某些功能型接口
函数模式的一个重要限制
函数模式里,当前桥接层不会额外把 Method 对象或方法名作为第一个参数传给你。
传进 JS 回调的,只是这个 Java 方法收到的原始参数列表。
也就是说,下面这种写法不要想当然:
createProxy(["java.lang.Runnable", "java.util.concurrent.Callable"], function (method, arg1) {
// 当前实现不是这样喂参数的
});
实际上它更接近:
run()被调用时:你的函数收到[]call()被调用时:你的函数收到[]compare(a, b)被调用时:你的函数收到[a, b]
所以如果你需要按方法名区分逻辑,就别用函数模式,改用对象模式。
对象模式:按方法名分别写回调
如果第二参数是一个普通 JS 对象,那么当前实现会按方法名同名属性去找回调函数。
最基础例子:Runnable
const runnable = createProxy("java.lang.Runnable", {
run() {
log("Runnable.run()");
}
});
例子:Comparator
const comparator = createProxy("java.util.Comparator", {
compare(left, right) {
return String(left).length - String(right).length;
}
});
这个例子里:
- Java 调用
compare(left, right) - JS 侧就会找对象上的
compare - 找到后把
left、right作为普通参数传进来
例子:多接口对象模式
const proxy = createProxy(
["java.lang.Runnable", "java.util.concurrent.Callable"],
{
run() {
log("run()");
},
call() {
return "ok";
}
}
);
找不到对应方法时会怎样
对象模式不是“找不到就静默返回”。
如果某个接口方法被调到了,但你的回调对象上没有同名函数,源码会直接抛错:
const runnable = createProxy("java.lang.Runnable", {});
callMethod(runnable, "run");
这类错误本质上会非常接近:
createProxy missing callback for java.lang.Runnable#run
所以对象模式最重要的一条就是:
- 接口方法会被谁调到,你就把对应同名函数写出来
方法名匹配规则很直白
当前实现只按:
method.name
去取对象属性。
也就是说:
onClick就写onClickcompare就写comparerun就写run
不会自动识别:
on_clickCompareRUN
这些名字对不上就不行。
第三个参数:classLoader
什么时候该传
如果你的接口不是系统类,而是:
- 宿主 App 自己的接口
- 插件 dex 里的接口
- 动态加载 dex 里的接口
那就很建议显式传 lpparam.classLoader 或你手上的目标 loader。
const listener = createProxy(
"com.example.target.DownloadListener",
{
onSuccess(path) {
log(`path=${path}`);
},
onFailure(code, message) {
log(`code=${code}, message=${message}`);
}
},
lpparam.classLoader
);
它参与两件事
第三参数的 classLoader 主要影响:
- 接口字符串解析
- 最终
Proxy.newProxyInstance(...)创建代理
如果你不传,当前实现的优先级大概是:
- 显式传入的 loader
lpparam.classLoaderhostLoader- 当前桥接类自己的
classLoader
而真正创建代理实例时,优先级又会稍微偏向:
- 显式传入的 loader
- 接口本身自带的
classLoader lpparam.classLoaderhostLoader- 当前桥接类 loader
你不用死记顺序,但可以记住实战原则:
- 系统接口通常不传也能用
- 宿主接口、插件接口尽量显式传 loader
回调收到的参数是什么
Java 调接口方法时传进来的参数,会被桥接层逐个包装成 JS 侧可用值,再传给你的回调。
你可以先按下面的心智模型理解:
- Java 对象 -> JS 里可继续调用的 Java 对象包装
null->null- 基本类型 / 字符串 -> 直接变成对应 JS 可读值
例子:Comparator.compare(a, b)
const comparator = createProxy("java.util.Comparator", {
compare(a, b) {
log(`a=${a}, b=${b}`);
return String(a).localeCompare(String(b));
}
});
这里的 a、b 就是 Java 调用时带进来的两个参数。
回调里的 this 是谁
这个细节也值得讲清楚。
对象模式
对象模式下:
- 回调执行时的
this - 就是你传进去的那个 callbacks 对象本身
所以你可以这样写共享状态:
const listener = createProxy("java.lang.Runnable", {
count: 0,
run() {
this.count += 1;
log(`count=${this.count}`);
}
});
函数模式
函数模式下:
- 回调执行时的
this - 是当前脚本作用域
它不是自动帮你造出来的一个“代理上下文对象”。
如果你想在函数模式里保存状态,更稳的是闭包:
let count = 0;
const runnable = createProxy("java.lang.Runnable", function () {
count += 1;
log(`count=${count}`);
});
返回值怎么处理
这部分非常重要,因为 Java 接口方法很多都不是 void。
当前实现会按目标方法的返回类型,尽量把你的 JS 返回值收敛成 Java 能接收的值。
void / Void
如果方法返回类型是 void,那你的 JS 回调返回什么都无所谓,最终都会当成 null。
const runnable = createProxy("java.lang.Runnable", {
run() {
log("done");
return 123;
}
});
这里 123 不会被拿去当什么神秘结果,run() 最终还是 void。
基本类型会做默认兜底
如果回调结果是 null,或者转换失败后没有可用值,当前实现会给基本类型方法一个默认值:
| Java 返回类型 | 默认返回值 |
|---|---|
boolean | false |
byte | 0 |
char | \\u0000 |
double | 0.0 |
float | 0f |
int | 0 |
long | 0L |
short | 0 |
例子:Comparator.compare(...) 忘了返回
const comparator = createProxy("java.util.Comparator", {
compare(a, b) {
log(`compare ${a} vs ${b}`);
// 忘了 return
}
});
因为 compare 返回类型是 int,最后会被兜成 0。
这虽然不一定立刻崩,但业务语义往往就变了,所以这类方法最好明确 return。
非基本类型的返回
如果接口方法返回的是对象类型:
- 返回
null没问题 - 返回的值本身就是目标类型实例,也没问题
- 如果桥接层能把它转换成目标类型,就会转换
- 如果最后仍然不是目标类型,就会抛错
一个典型例子:Callable<String>
const callable = createProxy("java.util.concurrent.Callable", {
call() {
return "hello";
}
});
log(callMethod(callable, "call"));
如果你把这里写成:
const callable = createProxy("java.util.concurrent.Callable", {
call() {
return { text: "hello" };
}
});
那当 Java 侧期望的是某个明确对象类型,而这个对象又不能赋值过去时,就可能抛出“返回值不可赋给目标类型”的错误。
当前实现会不会自动帮你处理 toString / equals / hashCode
会,而且这是桥接层专门做过的。
当 Java 世界调用代理对象的这些 Object 基础方法时,当前实现不会再去找你的 callbacks,而是直接走内建行为:
toString()
会返回类似这样一段文本:
ScriptXProxy(java.lang.Runnable)
如果你实现了多个接口,会把接口名一起拼进去。
hashCode()
返回这个代理对象的 identity hash。
equals(other)
这里走的是引用相等:
- 只有
proxy === other - 才会返回
true
它不会按你自己业务上的“内容相等”来算。
和类代理 / 原生反射配合怎么用
例子 1:给 Java API 传一个 Runnable
const runnable = createProxy("java.lang.Runnable", function () {
log("run()");
});
callMethod(runnable, "run");
这个例子只是演示代理本身能工作。
真实项目里更常见的是把 runnable 作为参数传给别的 Java API。
例子 2:配合 Handler.post(...)
const HandlerClass = findClass("android.os.Handler");
const LooperClass = findClass("android.os.Looper");
const mainLooper = callStaticMethod(LooperClass, "getMainLooper");
const handler = this["new"](HandlerClass, mainLooper);
const runnable = createProxy("java.lang.Runnable", function () {
log("posted on main looper");
});
callMethod(handler, "post", runnable);
这个例子就很接近真实用法了:
- 先构造
Handler - 再构造
Runnable代理 - 把代理对象传给
post(...)
例子 3:宿主接口 + 显式 classLoader
const listener = createProxy(
"com.example.target.LoginCallback",
{
onSuccess(userInfo) {
log(`success=${userInfo}`);
},
onError(code, message) {
log(`error=${code}, ${message}`);
}
},
lpparam.classLoader
);
这类写法最适合:
- 接口不在系统类里
- 你已经明确知道它属于宿主加载链
例子 4:需要共享状态时用对象模式
const counterRunnable = createProxy("java.lang.Runnable", {
count: 0,
run() {
this.count += 1;
log(`run count=${this.count}`);
}
});
callMethod(counterRunnable, "run");
callMethod(counterRunnable, "run");
例子 5:比较器
const comparator = createProxy("java.util.Comparator", {
compare(left, right) {
const l = String(left);
const r = String(right);
if (l === r) return 0;
return l > r ? 1 : -1;
}
});
log(callMethod(comparator, "compare", "b", "a"));
什么时候优先用它
createProxy(...) 特别适合下面这些情况:
- 目标 Java API 明确要求一个接口参数
- 你只想在脚本里快速实现一小段监听逻辑
- 你不想再额外写 Java 类或者改宿主代码
- 你只是想把回调逻辑就地放在当前脚本
什么时候别硬用它
下面这些情况,最好先冷静一下:
- 目标其实要的是普通类,不是接口
- 目标回调对象行为非常复杂,你需要完整类状态、继承层次、父类逻辑
- 多接口方法名重叠,你又需要严格区分来源
- 返回值类型很严格,你现在连 Java 侧真正期望什么都还没摸清
遇到这种情况时:
- 先用
findClass()把接口和目标方法摸清 - 必要时直接下沉到原生反射看参数签名
常见误区
误区 1:把普通类当接口传
createProxy("java.lang.Thread", function () {});
这不行,因为 Thread 不是接口。
误区 2:对象模式里少写了方法也没事
不行。
只要 Java 真调到了那个方法,而你对象上没有同名函数,就会直接报错。
误区 3:函数模式会告诉我“当前是哪个方法触发的”
当前实现不会额外把方法名或 Method 传给你。
函数模式收到的只是该次调用的原始参数列表。
误区 4:接口返回 int,我不返回也没关系
从桥接层角度看,确实会兜成 0。
但从业务角度看,这往往就是错的。像 compare(...)、状态码判断这类方法,最好明确返回。
误区 5:宿主接口也能像系统接口一样不传 loader
有时能撞上,有时会翻车。
只要接口来自宿主、插件或动态 dex,尽量显式传 lpparam.classLoader。
难点场景:重载多、签名复杂时,直接上原生反射
这部分很重要,因为很多人卡住不是因为“不知道 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 里追调用链,基本都能自己往下推了。
