DefaultApplication / MainActivity
App负责应用侧初始化、导航、更新、广告和设置页面。这里看到的是用户直接打开 APK 后的行为。
这篇文档写给准备直接动手改 OShin 的开发者与维护者。
它不是仓库观光手册,也不是旧版单体 app 时代的补丁说明。更实际的目标是让你在开始改代码前,先把下面几件事理顺:
YukiHookAPIKavaRefDexKitDexKitCacheManager 时,为什么不应该再到处手写 DexKitBridge.create(...)如果你以前看过旧版文档,先把“所有代码都在 app 模块里”这个印象清掉。当前项目已经完成模块化,下面所有内容都以当前仓库的真实结构为准。
Contributor Mode
这页内容很长,但参与开发不需要从头背到尾。先明确自己当前在改什么,再进入对应章节,效率会高很多。
OShin 同时是两个东西:
这两个部分打包在同一个最终 APK 里,但开发时要分开理解。
可以先从下面两个运行世界理解整个项目:
世界 A:App 世界
用户点击桌面图标
-> DefaultApplication
-> MainActivity
-> AppNavHost
-> Main / Welcome / Feature / Update 等 Compose 页面
世界 B:Hook 世界
LSPosed 加载模块
-> xposed_init
-> YukiHookAPI 生成的入口类
-> HookEntry
-> 各个 YukiBaseHooker
-> 目标系统进程 / 目标应用进程Development Explorer
同一套代码同时服务 App 世界与 Hook 世界。这里把最容易混淆的运行链路、模块职责和开发落点拆开看。
Runtime
多数定位错误都发生在没分清当前代码究竟运行在哪个进程。
负责应用侧初始化、导航、更新、广告和设置页面。这里看到的是用户直接打开 APK 后的行为。
负责把 LSPosed 的加载入口桥接到项目的 Hook 体系。这里不是普通 Activity 生命周期。
真正的行为修改发生在系统进程或目标应用进程内部,排查问题必须带着进程视角看。
开发时最常见的工作大致分成四类:
当前仓库根目录下与开发直接相关的模块如下:
OShin
├─ app
├─ core-ads
├─ core-common
├─ core-data
├─ core-model
├─ core-navigation
├─ core-ui
├─ core-update
├─ feature-catalog
├─ hook-apps
└─ tempModule Advisor
模块化之后,很多返工都不是因为实现错了,而是代码一开始就落错层。先选你准备改的内容,再看首选模块和常见误放点。
页面目录、PageDefinition、ModuleEntry、ScreenItem 等声明层内容,应该留在功能目录和模型层,而不是直接堆到 app。
Module Landscape
不需要把模块说明逐段背下来。先在这里确认职责边界、典型内容和不该落进去的东西,再回到具体章节看实现细节。
最终应用模块,负责壳、入口、导航和应用级页面组装。
模型层最底,声明层和数据层在中间,应用层做组装,Hook 层单独承载系统行为修改。
这里的目的不是背模块说明,而是先建立两条判断:
如果你现在还不知道先开哪个文件,直接从下面这张入口图开始:
Entry Atlas
这里不是简单列路径,而是告诉你每一组入口文件的阅读目的。先建立骨架,再去看具体业务 Hook 或页面定义。
先搞清应用怎么启动、导航如何组织、Feature 页面如何被 ViewModel 和渲染器消费。
看应用级初始化、模块环境兼容和基础设施启动。
看语言切换、根宿主和应用侧启动时机。
看欢迎引导、主页面、独立页面与功能页路由如何接起来。
看页面声明如何被转成界面状态和偏好状态。
看标准 PageDefinition 最终如何分发到具体组件。
Startup Flow
这一段最重要的不是背类名,而是分清应用侧初始化、Activity 根宿主和导航分流分别发生在哪一层。
应用启动后先进入应用级初始化层,这里承载的是基础设施启动,不是普通占位 Application。
Execution Path
OShin 最容易把人绕晕的不是代码量,而是链路层次。功能页渲染链路和 Hook 注册链路最好同时建立整体图像。
先用 PageDefinition、ModuleEntry、screenMap 把页面纳入目录体系,否则后续导航、搜索、渲染都无从谈起。
仓库和 ViewModel 负责把声明转成实际可显示的数据、偏好状态和界面层所需的结构。
通用页面渲染器根据 ScreenItem 类型把数据分发给 Switch、Slider、Dropdown、Arrow 等组件。
页面交互最终要写回约定好的 category / key,这一步直接决定 Hook 能不能读到正确配置。
Feature System
这一套机制不用靠长段解释硬记。先切换看角色分工,再回去看具体实现文件,理解会快很多。
FeatureCatalog 是页面目录系统的接口,定义模块入口和 categoryId 到 PageDefinition 的映射。
这一段最核心的理解只有一条:
页面声明在
feature-catalog,状态装配在app/core-data,渲染落到FeatureScreen,依赖通过 Hilt 串起来。
假设你要新增一个针对 com.example.demo 的功能页,可以先按下面这个顺序理解。
Feature Workflow
这一块最容易被讲成大段“先这样再那样”。这里改成按交付物看,每一步都明确写在哪、产出什么、漏了会怎样。
`feature-catalog` 里的对应目录,例如 `features/demo/demo.kt`。
Wiring Lab
这里不是抽象说明,而是把 `category`、`key`、包名和路由直接拼成一套最小可工作的接线模板。改字段时,下方代码会同步变化。
object demo {
val definition = PageDefinition(
category = "demo",
appList = listOf("com.example.demo"),
title = AppName("com.example.demo"),
items = listOf(
CardDefinition(
items = listOf(
Switch(
key = "remove_ads",
title = StringResource(R.string.demo_remove_ads)
)
)
)
)
)
}moduleEntries += ModuleEntry("com.example.demo", "demo")
screenMap += mapOf(
"demo" to demo.definition
)loadApp(name = "com.example.demo") {
val prefs = prefs("demo")
if (!prefs.getBoolean("remove_ads", false)) return@loadApp
// install hook here
}这部分真正要记住的是:页面本体先落在 feature-catalog,再接入 FeatureRegistry,最后才去看它是否需要 Hook 配套。
建议先在 feature-catalog 新建对应目录:
feature-catalog/src/main/java/com/suqi8/oshin/features/demo/再建 demo.kt:
package com.suqi8.oshin.features.demo
import com.suqi8.oshin.feature.catalog.R
import com.suqi8.oshin.data.models.AppName
import com.suqi8.oshin.data.models.CardDefinition
import com.suqi8.oshin.data.models.PageDefinition
import com.suqi8.oshin.data.models.StringResource
import com.suqi8.oshin.data.models.Switch
object demo {
val definition = PageDefinition(
category = "demo",
appList = listOf("com.example.demo"),
title = AppName("com.example.demo"),
items = listOf(
CardDefinition(
items = listOf(
Switch(
key = "remove_ads",
title = StringResource(R.string.demo_remove_ads)
)
)
)
)
)
}只要先把下面三件事钉住,后面的接线就不会乱:
category 会成为该页面的偏好命名空间key 会成为具体设置项的键title、summary 用的资源应放在页面声明所属模块能访问到的资源里页面定义写完之后,至少还要补两处注册:
ModuleEntry("com.example.demo", "demo")"demo" to demo.definition如果这两处缺任何一处,模块页、路由、搜索链路都会表现异常。字符串资源则跟着页面所属模块走,不要为了省事跨模块乱放。
如果这个页面最终还要改目标进程行为,下一步就进入 Hook 接线。
页面定义在 feature-catalog,真正的行为修改在 hook-apps。这两边接得是否稳,决定了功能到底是“存在于界面里”还是“真的在目标进程生效”。
Hook Workflow
一个 Hook 能否工作,重点不在“写了个 Hook 类”,而在于作用域、技术路径、聚合入口和配置读取是否全部接通。
先判断目标到底是普通 App、系统 App 还是系统服务,再决定用 `loadApp`、`loadSystem` 还是上层聚合 Hooker。
例如:
package com.suqi8.oshin.hook.demo
import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
class demo : YukiBaseHooker() {
override fun onHook() {
loadApp(name = "com.example.demo") {
val prefs = prefs("demo")
if (!prefs.getBoolean("remove_ads", false)) return@loadApp
// 在这里写 Hook
}
}
}这里至少保持两个习惯:
prefs("对应页面 category")如果目标类名稳定,这里通常继续接 KavaRef;如果明显混淆或版本波动大,再往下接 DexKit。
如果你写了 hook/demo/demo.kt,还需要把它接入某个上层入口。
例如在 HookEntry 里,或者在某个分类 Hooker 里:
registerAppHookers(
demo(),
)或者:
class android : YukiBaseHooker() {
override fun onHook() {
loadApp(hooker = SomeSubHooker())
loadHooker(SomeSystemLevelHooker())
}
}这里最容易断的是 category / key 对齐问题。页面里写什么,FeatureViewModel 最终就会往 context.prefs(pageDefinition.category) 写什么;Hook 端必须读同一份命名空间和同一个键,否则界面能显示、代码能编译、功能却不会生效。
Hook Entry
当前项目的 Hook 入口不是“单文件手写入口”模式。这里把真实链路和每一层的职责拆开看。
LSPosed 首先从这里读取模块入口声明。
这是入口类名,对应 YukiHookAPI 生成的桥接入口。
KSP 生成的入口实现,负责把注解声明转换为实际桥接代码。
生成实现继续承接入口初始化逻辑。
你真正维护的主入口源码,负责注册和调度各类 Hooker。
YukiHookAPI 是本项目 Hook 体系的基础框架,也是 App 世界和 Hook 世界之间的一部分桥接层。
YukiHookAPI
这里不再按库文档全量铺开,而是只讲 OShin 里最常用、最容易写错、最值得统一口径的那部分。
class SomeHook : YukiBaseHooker() {
override fun onHook() {
// hook logic here
}
}项目里的绝大多数 Hook 文件都从 `YukiBaseHooker` 起步,这是组织 Hook 逻辑的最基本单元。
如果你只记一句:在 OShin 里,绝大部分 Hook 入口、偏好桥接、Hook DSL、模块状态判断,都建立在这套 API 上。
KavaRef 是当前项目处理稳定反射路径时的主力工具。只要类名、方法名、字段结构还算稳定,优先用它,不要一上来就把问题交给 DexKit。
KavaRef
当前文档应统一到 `resolve()`、`firstMethod` / `firstField`、`instance.asResolver()` 这套路径,不再继续扩散旧式兼容写法。
"com.oplus.weather.utils.SecondaryPageUtil"
.toClass()
.resolve()
.firstMethod {
name = "startJumpToBrowser"
}
.hook {
before { }
}当类名和方法名稳定时,KavaRef 应该是第一选择。当前仓库里天气、系统类、部分设置相关 Hook 都大量采用这套写法。
如果你发现类名经常变、方法经常变、包路径经常变,或者只能靠字符串、数字、调用关系定位目标,那就该转到下一节的 DexKit 路线。
DexKit 的核心用途是:
在类名、方法名、字段名可能混淆或版本变化较大时,通过字符串、数字、特征条件去找到目标类或目标方法。
满足下面情况之一,优先考虑 DexKit:
DexKit 很强,但也有成本:
所以项目里已经引入了 DexKitCacheManager,你通常不应该再自己散落一堆 DexKitBridge.create(...)。
DexKitCacheManager当前项目存在两类 DexKit 用法:
例如某些文件里会看到:
DexKitBridge.create(this.appInfo.sourceDir).use {
...
}这种写法本身不是错的,但在 OShin 当前项目里,它通常不是首选。
原因是项目已经有 DexKitCacheManager 统一处理:
DexKitBridge 复用项目里更推荐的路径是:
DexKitCacheManager.findClass(...)DexKitCacheManager.findMethods(...)DexKitCacheManager.findAndHook(...)DexKitCacheManager 到底帮你做了什么当前它至少做了下面这些事情:
内部会调用:
System.loadLibrary("dexkit")并用 isLibLoaded 避免重复加载。
它会在目标进程里初始化缓存目录。
DexKitBridge 缓存它按 packageName 维护 DexKitBridge 缓存,避免你每次都重新创建桥。
搜索到的方法或类会被序列化后写入 MMKV。
这样下次同版本再进来,就不用重复全量搜索。
它会记录目标 App 的版本。
如果目标 App 升级了,就会清掉对应缓存,避免旧结果污染新版本。
这点非常关键。
否则你会遇到:
DexKitBridge虽然当前项目推荐优先用 DexKitCacheManager,但也不是说直接 DexKitBridge 永远不能写。
以下场景可以考虑直接用:
但只要准备正式合入项目,建议重新评估能否切回 DexKitCacheManager。
理由很现实:
DexKitCacheManager 三个核心 API 怎么选findClass适合场景:
返回值是 List<String>,也就是类名列表。
典型适合:
findMethods适合场景:
返回值是 List<DexMethod>。
典型例子是 GamesAIHook。
它先搜索方法,再根据方法所在类名中是否包含:
sgamepubgmlbb来决定不同分支的处理方式。
这就是典型的“先找方法,后分流”。
findAndHook适合场景:
DexMethod 再转换一遍这是当前项目里最常见、最省事的 DexKit 接入方式。
它会:
DexKitCacheManager 的标准写法以 browser.kt 为例,典型写法如下:
findAndHook(
param = this,
queryKey = "remove_weather_injected_ads",
finder = { bridge ->
bridge.findMethod {
matcher {
declaredClass = "com.heytap.browser.business.weather.js.ThirdCommonJsHook"
modifiers = Modifier.PUBLIC
paramCount = 0
returnType = "void"
usingNumbers(4)
}
}
}
) {
replaceUnit {}
}这四个参数需要理解清楚:
param通常直接传当前 loadApp { ... } 作用域里的 this。
因为 DexKitCacheManager 需要它里面的:
packageNameappInfo.sourceDirappClassLoaderqueryKey这是缓存键的一部分,非常重要。
要求:
不要写成这种模糊名字:
"a""method""search1"推荐写成:
"remove_weather_injected_ads""games_hok_ai_v2""gallery_unlock_hassel_watermark"finder这里写真正的 DexKit 搜索逻辑。
注意:
这里就写正常的 YukiHookAPI Hook DSL:
beforeafterreplaceUnit以 GamesAIHook 为例:
DexKitCacheManager.findMethods(
param = this,
queryKey = "games_ai_play_v1"
) { bridge ->
bridge.findMethod {
searchPackages("com.oplus.feature.utils")
matcher {
modifiers = Modifier.PUBLIC
returnType = "boolean"
usingStrings("feature.support.game.AI_PLAY")
}
}
}它拿到的是方法集合,然后再根据 declaringClass.name 分情况 Hook。
所以:
findMethodsfindAndHook以 gallery3d.kt 为例,可以看到链式思路:
bridge.findClass {
...
}.findMethod {
...
}这类写法适合:
当混淆严重但仍有稳定字符串特征时非常有用。
DexKit 最容易出问题的通常不是 Hook 块,而是查询条件本身。
DexKit Workbench
这块用来把“我手上到底有什么稳定特征”翻译成查询策略、`queryKey` 命名和 `DexKitCacheManager` 接法,避免一开始就把条件写爆。
feature_strings_hookDexKitCacheManager.findAndHook(
param = this,
queryKey = "feature_strings_hook",
finder = { bridge ->
bridge.findMethod {
matcher {
usingStrings("feature_flag")
paramCount = 0
}
}
}
) {
replaceUnit {}
}searchPackages(...)className = "..."name = "..."paramTypes(...)paramCount = ...returnType = "..."usingStrings(...)usingNumbers(...)modifiers = ...优先 KavaRef。
优先 DexKit 的 usingStrings(...)。
用 usingNumbers(...) 进一步收窄。
补:
paramCountreturnTypemodifierssearchPackages错误思路:
正确思路:
可以用一个很简单的判断表:
Hook Strategy
不同 Hook 问题的关键不在于 DSL 写法,而在于定位目标的稳定性。下面这组开关对应项目里最常见的四种决策条件。
推荐结果
目标结构稳定时,反射路径最清晰,也最容易维护,通常没有必要引入特征扫描。
val targetClass = "com.example.Target".toClass().resolve()
val method = targetClass.firstMethod {
name = "run"
}
method.hook {
before {
instance.asResolver().firstField {
name = "enabled"
}.set(true)
}
}适合:
Method 或 Constructor适合:
这是系统类、部分稳定应用类最常见的方案。
适合:
适合:
这在项目里也很常见。
可以直接记下面这条:
先判断 KavaRef 能否稳定解决;如果不能,再引入 DexKit;如果项目里已经有
DexKitCacheManager,优先复用它,不要重复实现桥接和缓存层。
这是另一类高频接线问题。
下面三样内容必须保持一致:
Symptom Triage
遇到问题时,不要一上来就在整个仓库里乱搜。先按症状判断更可能是页面接线、Hook 入口、搜索索引还是 DexKit 条件问题。
例如:
PageDefinition(
category = "browser",
...
)例如:
Switch(
key = "remove_weather_search_box",
...
)例如:
val prefs = prefs("browser")
if (prefs.getBoolean("remove_weather_search_box", false)) {
...
}如果这三者中有任何一个名字不一致,功能就会表现为:
Discovery Layer
这几段本质上都在解决“页面声明如何被用户找到”。与其分别背,不如按搜索、路由、应用信息三层一起看。
这一层和搜索、应用信息本质上是一条“页面如何被发现”的链路。
这一层优先复用现有 AppInfoProvider,不要在页面里手写一套新的按包名取信息逻辑。
Lifecycle Relations
这几条链路最容易被误认为是一件事。实际要把触发入口、验证状态和内部更新跳过策略分开看。
不要把“是否进入欢迎引导”和“是否需要验证”硬编码成同一层判断。
这一节的重点不是记零散规则,而是先确认资源归属和唯一入口,再补语言与翻译。
Support Systems
这几块都属于“容易被随手写进页面”的辅助系统。更好的方式是先确认已有承载模块,再去改唯一入口或唯一实现。
这一节专门列“不要再这样写”的点。
Modern Patterns
项目文档应优先教当前维护策略,而不是兼容层还能工作的旧路径。这里把最容易继续扩散的几类旧式写法直接对照掉。
val field = targetClass.field { name = "enabled" }
field.get(instance).set(true)instance.asResolver().firstField {
name = "enabled"
}.set(true)当前项目文档已经统一走 KavaRef 的 asResolver 实例作用域,这比继续教旧式 get(instance) 更清晰。
当你已经拿到实例对象,优先把实例转换到 resolver 作用域里,再做字段和方法操作。
app 模块现在不是过去那种:
app/
hook/
features/
ui/的单一结构了。
app 模块维护 xposed_init当前项目真实入口链路已经在前面讲过。
DexKitBridge.create(...)优先看是否可以走 DexKitCacheManager。
category、key、prefs(...) 必须对齐。
先判断标准 PageDefinition 能不能描述。
能描述就走声明式体系。
app例如:
core-uicore-updatefeature-catalog例如:
instance.current() 作为主要示例路径get(instance) 这类旧式实例访问写成推荐写法instanceClass 组织新的 Hook 示例当前项目示例应优先展示:
resolve()firstMethod / firstFieldinstance.asResolver()invoke(),不要继续把旧式 call() 当成主示例Method 后再写 hook { ... }下面这块直接按场景拆好了,照着走通常不会乱:
Runbook
新功能、修现有 Hook、补独立入口,这三类工作起手顺序并不一样。这里按场景拆开,同时把最常用任务放在一起。
先在 feature-catalog 写页面或开关定义
再在 hook-apps 写 Hook
确保 category 和 key 对齐
补字符串资源
构建调试包
上机验证
./gradlew :app:assembleDebug./gradlew reportMissingTranslationsXml调试包编译和缺失翻译输出已经合并到上面的操作面板里,这里不再重复铺开命令说明。
Reading Roadmap
这 12 个点不是“必修课目录”,而是最快形成整体地图的一条阅读路径。先看骨架,再看代表性的 Hook 和缓存层。
先看应用侧基础设施从哪里启动。
看语言切换、根宿主和应用入口行为。
看独立路由页与功能页是怎么接起来的。
看整个功能目录和页面声明中心。
看页面定义如何被转成 UI 状态。
看声明式页面最终如何渲染。
看 Hook 世界的总入口和注册方式。
看聚合 Hooker 的组织模式。
看稳定类名下 KavaRef 的典型做法。
看 DexKitCacheManager 在真实场景中的接法。
看另一个应用侧 Hook 场景,避免只盯一个样本。
最后再看缓存层,理解为什么项目不鼓励到处手写桥接。
读完这条路径,基本就能独立完成大多数常规改动。
Review Pass
提交前最容易漏的不是大逻辑,而是接线一致性、资源归属和缓存策略。按当前改动类型筛一下,比机械读清单更有效。
在 OShin 里写代码,最常见的问题不是“不会写 Hook”,而是:
开始改之前,建议先确认:
把这三件事想清楚,代码通常就不会走偏。