运行时桥接
运行时桥接
这一页讲的不是“某个 API 怎么调用”,而是 JSXHook 整个脚本运行时到底是怎么接起来的。
如果你现在遇到的是下面这类问题,这页会很有用:
- 为什么脚本里会自动有
context、activity、files、device、imgui - 为什么有的进程能跑脚本,有的进程会被跳过
- 为什么
init.js执行了,但项目main.js没有执行 - 为什么同样的脚本,在不同宿主里
activity有时有、有时是null - 新增一个全局 API 时,应该改哪一层
你可以把这页理解成:JSXHook 把 Xposed / Android / Rhino / 各类脚本 API 拼成一个完整运行环境的总线路图。
目录
- 先记住这 10 条
- 关键源码
- 整体链路
- 入口层:谁决定要不要跑脚本
- JavaScriptRuntime:脚本容器层
- JavaScriptBridge:API 注入层
- context 和 activity 到底从哪来
- LegacyXposedCompat 与 XpHelperBridge 的职责
- 项目脚本为什么是 init.js 先跑
- 新增 API 时该怎么落位
- 排错顺序
先记住这 10 条
- JSXHook 的脚本入口不只有一种,至少有三类:
- 全局脚本
[GLOBAL] - App Script
- Project 脚本
- 全局脚本
- 每次真正执行某个脚本时,都会创建一个新的
JavaScriptRuntime,然后由JavaScriptBridge.register()往里面注入全局能力。 JavaScriptRuntime负责的是“容器能力”:- Rhino ES6
- 根作用域
wrap/unwrap- 异步任务计数
- 中断与停止
JavaScriptBridge负责的是“脚本到底能做什么”:- 全局函数
- 全局对象
context/activityhttpServer/mcpServerfiles/device/storages/plugins/imgui
context和activity不是启动时写死的快照,而是动态全局变量,每次读取时都会重新解析。activity在很多场景下本来就可能拿不到,所以依赖界面的 API 不能假设它永远存在。setTimeout/setInterval/Task这些异步能力不是“随便 fire-and-forget”,运行时内部会跟踪未完成任务数量。- 停止脚本时,不只是抛一个中断异常,还会执行 stop action,把定时器之类的资源清掉。
- 项目脚本是先执行
init.js,再解析scope,最后才决定要不要执行main.js。 - 如果你发现“脚本变量没注入”或“动态全局异常”,优先查
JavaScriptBridge.register(),不是先查业务 API 文件。
关键源码
hook/javascript/JavaScriptRuntime.kthook/javascript/JavaScriptBridge.kthook/compat/LegacyXposedCompat.kthook/compat/XpHelperBridge.kthook/entry/MainHook.kthook/entry/NewHook.kt
整体链路
先看总图,再拆每一段:
flowchart TD
A["Xposed Entry<br/>MainHook / NewHook"] --> B["读取工作区配置<br/>[GLOBAL] / App Script / Project"]
B --> C["HookProcessGate<br/>判断当前包名 / 进程是否应该执行"]
C --> D["createJavaScriptRuntime(host, lpparam, ...)"]
D --> E["JavaScriptRuntime<br/>Rhino ES6 + Scope + Async/Stop"]
E --> F["JavaScriptBridge.register()"]
F --> G["注入全局函数<br/>log / sleep / hook / require / ..."]
F --> H["注入全局对象<br/>files / device / storages / plugins / imgui / http / mcpServer / ..."]
G --> I["evaluate(script)"]
H --> I
I --> J["脚本运行期调用 Android / Xposed / DexKit / 文件 / 网络 / UI 能力"]
如果你想一句话理解:
MainHook / NewHook决定“要不要跑”JavaScriptRuntime提供“跑脚本的容器”JavaScriptBridge提供“脚本能看到什么、能调什么”
入口层:谁决定要不要跑脚本
JSXHook 目前有两套入口实现:
MainHookNewHook
它们整体逻辑非常接近,只是分别适配不同的 Xposed / libxposed 入口模型。
两个入口都会做什么
入口层大致都会做这些步骤:
- 记录 Probe 日志
- 安装
ScriptDialogHost - 从工作区读取:
/apps.txt- 全局脚本
- App Script 配置
- Project 列表
- 针对每一类脚本分别判断是否应该执行
- 需要执行时创建
JavaScriptRuntime - 调
evaluate(...)真正执行脚本
[GLOBAL] 脚本的规则
入口层会先读全局脚本,然后判断:
- 当前包名是不是模块自己
- 全局脚本内容是否为空
HookProcessGate.shouldRun(...)是否允许当前进程运行
只有这些条件满足时,才会执行 [GLOBAL]。
App Script 的规则
App Script 的启用范围来自:
/apps.txtAppConf/<包名>.txt
只有当当前包名在选中列表里,并且对应脚本在配置里是启用状态,才会继续执行。
继续执行前,还会再过一遍 HookProcessGate.shouldRun(...)。
Project 脚本的规则
Project 脚本的判断比前两种多一层:
- 先看
Project/info.json里这个项目是否启用 - 创建运行时
- 读取
init.js - 如果
init.js为空,直接跳过 - 读取
main.js - 用
HookProcessGate看当前包/进程是否允许 - 先执行
init.js - 再从作用域里解析项目配置
- 只有当
scope包含all或当前包名时,才执行main.js
所以你以后遇到这种情况:
init.js里的日志打印了main.js死活没进
先查的不是语法错误,而是:
scope有没有包含当前包名scope是不是只有别的 app
JavaScriptRuntime:脚本容器层
JavaScriptRuntime 这层不关心“files API 怎么写”“device API 有什么值”,它主要负责把 Rhino 变成一个可控、可停、可包装返回值的运行容器。
它做的几件核心事
1. 建立根作用域
源码里根作用域是:
ImporterTopLevel
可以把它理解成:脚本执行的最外层全局对象。
2. 统一用 Rhino ES6
每次进入 Rhino 都会设置:
optimizationLevel = -1languageVersion = VERSION_ES6
所以文档里说“JSXHook 脚本环境按 ES6 来跑”,底层就是在这里统一定下来的。
这里现在还有一个很关键的隐藏动作:
所有主要 Rhino 入口都会先统一走 configureContext(...),不只是设 ES6 和优化级别,还会挂上 JSXHook 自己的 WrapFactory。
这意味着像项目脚本解析、桥接执行、MCP 入口这些地方,都会共享同一套包装规则,不会一处是“新行为”、另一处又退回“旧行为”。
3. 提供 put(name, value)
所有静态全局值,比如:
lpparamclassLoaderXposedHelpersfilesdevice
本质上都是通过 put(...) 放进作用域的。
4. 提供 bindDynamicGlobal(name, provider)
这个接口很重要。context 和 activity 不是注册时就固定住,而是通过动态 provider 挂进去的。
这意味着:
- 脚本每次读取
context时,都是实时解析当前上下文 activity也不是“启动时有就永远有”
5. 负责 wrap / unwrap
脚本和 Kotlin/Java 之间来回传值,需要做对象包装。
运行时会处理:
ArrayListScriptableNativeArrayWrapperUndefined
这也是为什么很多 Kotlin List、Map、对象能比较自然地进出 JS。
另外,当前这层还专门给 Kotlin 的真实 isXxx 布尔属性做了别名暴露。
也就是说,如果对象本身真有这些属性:
isConstructorisStaticInitializerisMethodisArray
那么 JS 里直接读到的就是 true / false,而不是一个方法对象。
最常见的受益场景就是 DexKit 结果对象:
MethodData.isConstructorMethodData.isStaticInitializerMethodData.isMethodClassData.isArray
这批现在都可以直接写:
if (hit.isMethod) {
log(hit.name);
}
而不需要再猜它是不是要写成 hit.isMethod()。
同时要分清一件事:
这套规则只针对 Kotlin 真实布尔属性,不会把普通 Java 的 isFile()、isDirectory() 这类方法误改成属性。
6. 跟踪异步任务
运行时内部有:
pendingAsyncTasksacquireAsyncTask()releaseAsyncTask()waitForAsyncTasksToComplete()
这意味着 setTimeout / setInterval / Task 不是无管理状态。
脚本层的异步任务会被计数,停止脚本时也能更稳地收尾。
7. 处理中断与停止
运行时维护了:
interruptedstopActions
调用停止时会:
- 标记中断
- 唤醒等待中的异步监视器
- 逐个执行 stop action
而 JavaScriptBridge.register() 里会注册一个 stop action 去清理所有定时器,所以它们是连在一起的。
你可以把它理解成什么
JavaScriptRuntime 更像一个“脚本进程壳”,而不是业务 API 集合。
它负责:
- 让脚本能跑
- 让全局值能注入
- 让异步能被跟踪
- 让停止动作能真正收尾
JavaScriptBridge:API 注入层
如果说 JavaScriptRuntime 是“容器”,那 JavaScriptBridge.register() 就是“往容器里塞能力”。
它会先做什么
注册时,源码先做两件基础动作:
LegacyXposedCompat.ensureApplicationContextHook()- 给 runtime 注册 stop action,用于取消所有定时任务
这一步意味着后面很多 API 能不能工作,首先取决于:
- 上下文有没有成功桥接
- 运行时停止时能不能正确清理
它会注入哪些静态全局值
像这些属于“启动后基本固定”的静态注入:
thissuparamXpHelperDexFinderXposedHelpersXposedBridgeDexKitBridgelpparamclassLoader
它会绑定哪些动态全局值
目前最关键的两个动态全局是:
contextactivity
它们通过 bindDynamicGlobal(...) 注册,所以读取时会重新解析。
它会注册哪些全局函数
这层会往作用域里放很多函数,例如:
logprinttoasttoastLogsleepexitrandomsetTimeoutclearTimeoutsetIntervalclearIntervalTaskshellgetClipsetClipprintStackTrace
还有反射 / hook / Java 调用 / HTTP / 资源加载那一大批函数。
它会注册哪些全局对象
这几个对象都在 register() 里被创建并注入:
appfilesdevicepluginsstoragesimguihttphttpServermcpServer
所以以后你排查“为什么脚本里看不到某个对象”时,最直接的入口就是看:
- 这个对象有没有
createXxxObject(...) JavaScriptBridge.register()有没有runtime.put("xxx", xxxObject)
一个很好用的判断法
| 现象 | 先查哪里 |
|---|---|
| 某个全局名完全不存在 | JavaScriptBridge.register() |
全局名存在,但值不对 / 是 null | 对应 createXxxObject(...) 或动态 provider |
| 计时器行为怪 | JavaScriptRuntime 的异步任务和 stop action |
context 和 activity 到底从哪来
这是新手和维护者都最容易卡的一段。
currentContext() 的真实回退顺序
JavaScriptBridge.currentContext() 不是只看一个来源,它会按顺序尝试:
hostContextScriptDialogHost.peekCurrentActivity()LegacyXposedCompat.getApplicationContext()XpHelper.context- 通过
ActivityThread.currentApplication()或相关字段反射拿 Application - 最后再尝试
ActivityThread.getSystemContext()
也就是说,context 的来源是一个“多层回退链”,不是单一来源。
activity 的真实回退顺序
resolveRuntimeActivity() 会尝试:
- 直接从
hostContext解包出Activity ScriptDialogHost.peekCurrentActivity()XpHelper.context再解包成Activity- 从当前
context再解包成Activity
如果还拿不到,就返回 null。
为什么 activity 这么容易是 null
因为很多运行场景本来就不保证宿主上下文是前台 Activity,比如:
- 后台进程
- Application 级上下文
- 还没创建可交互界面
- 当前脚本只是跑在包进程里,但没有前台页面
所以文档里凡是依赖 activity 的功能,例如:
- 某些
imgui展示 - 当前窗口亮度即时刷新
- 对话框宿主页面
都应该默认允许“拿不到 activity”的情况。
还有一个内部细节
unwrapActivity(context) 不是无限递归解 ContextWrapper,它最多向里解 12 层。
如果超过这个深度,或者上下文链异常,就会放弃。
LegacyXposedCompat 与 XpHelperBridge 的职责
这两个名字看起来偏底层,但它们非常关键。
LegacyXposedCompat 负责什么
它主要解决的是:
脚本环境需要稳定的 Android Context,但不同 Xposed 运行时拿到它的方式不完全一致。
它做的几件事:
- 保存当前
XposedModule和加载参数 - 提供
createStartupParam() - 尝试通过
ActivityThread.currentApplication()直接解析当前 Application - 安装
Application.attach(Context)hook,尽量在 attach 时拿到 Application Context - 提供
openRemotePreferences(...),给需要读模块侧偏好的地方使用
XpHelperBridge 负责什么
它更像是“把 XpHelper 这一套依赖初始化成可用状态”:
- 设置
XpHelper.context - 设置
XpHelper.classLoader - 初始化
ClassUtils - 初始化
ConfigUtils - 检查 DexKit 缓存
- 在需要时安装
ActivityProxyManager
两者怎么配合
你可以把它们理解成:
LegacyXposedCompat更偏“把宿主上下文拿到手”XpHelperBridge更偏“拿到上下文之后,把 XpHelper 那套依赖点亮”
所以如果你碰到的问题是:
context看起来能拿到,但XpHelper/ DexKit 相关能力怪怪的
就不要只盯着 JavaScriptBridge,还要回头看 XpHelperBridge.initLiteContext(...) 有没有跑通。
项目脚本为什么是 init.js 先跑
Project 脚本的运行顺序有一个很关键的设计:
- 先创建 runtime
- 先执行
init.js - 再从 runtime scope 里解析项目配置
- 根据
scope判断当前包是否应该执行 - 最后才决定是否执行
main.js
为什么要这样设计
因为项目级配置很多时候就是写在 init.js 里的,比如:
scopelauncher- 构建配置
- 其他项目元信息
如果不先执行 init.js,那运行时根本拿不到这些动态声明出来的配置。
这也意味着一个排错重点
如果你发现:
init.js里能读到全局 APImain.js没执行
优先看这些:
init.js有没有正常执行完ProjectScriptParser.fromScope(runtime.scope)读出来的scope是什么scope里有没有当前lpparam.packageName
新增 API 时该怎么落位
如果你要给 JSXHook 新加脚本能力,最稳的判断方式是先分层。
先问自己:这是函数,还是对象
| 情况 | 更适合的形态 |
|---|---|
| 没什么内部状态,只是一个工具入口 | 全局函数 |
| 有一组相关方法、状态、配置、返回对象 | 全局对象 |
例如:
sleep()这种更像全局函数files、device、storages、plugins这种更像对象
新增对象能力的常见做法
通常会走这种结构:
- 在
hook/javascript新建一个ScriptXxxApi.kt - 提供
createXxxObject(...) - 在
JavaScriptBridge.register()里创建并runtime.put("xxx", xxxObject) - 文档上至少同步补 2 处:
docs/api/xxx.md- 相关模块说明或索引页
新增函数能力的常见做法
则更常见是:
- 直接在
JavaScriptBridge.register()里function(scope, "name") { ... } - 如果逻辑开始变复杂,再拆到独立 helper
排错顺序
这一节非常实用,建议真碰到问题时按顺序查。
1. 脚本压根没执行
先查:
- 入口层有没有读取到这个脚本
apps.txt/ 项目启用配置是否匹配HookProcessGate.shouldRun(...)是否放行
2. 脚本执行了,但某个全局函数/对象不存在
先查:
JavaScriptBridge.register()有没有注入这个全局名- 名字是不是文档写法和源码写法不一致
3. 全局对象存在,但行为不对
先查:
- 对应
ScriptXxxApi.kt - 参数解析逻辑
- 默认值逻辑
- 失败返回值是
false、0、-1还是null
4. context / activity 经常拿不到
先查:
- 当前是不是根本没有 Activity 场景
LegacyXposedCompat.ensureApplicationContextHook()是否生效ScriptDialogHost.peekCurrentActivity()有没有宿主XpHelper.context是否已初始化
5. 定时器、异步逻辑收不住
先查:
setTimeout/setInterval是否有对应清理- runtime stop action 是否有执行
pendingAsyncTasks是否正确 release
6. 项目 init 执行了,但 main 没执行
先查:
scope- 当前包名
ProjectScriptParser.fromScope(runtime.scope)解析结果
最后压成一句话
JavaScriptRuntime解决的是“脚本怎么被安全地跑起来”。JavaScriptBridge解决的是“脚本跑起来之后到底能看到什么、调用什么”。LegacyXposedCompat和XpHelperBridge解决的是“这些能力依赖的 Android / Xposed 上下文到底怎么拿、怎么初始化”。- 真正排错时,按“入口层 -> 运行时容器 -> API 注入 -> 具体对象实现”这条线走,效率会高很多。
