Mihon 深挖:它真的在运行时装 APK
五个系统里,Mihon 的加载方式最不寻常:它的插件不是一个 js 文件、不是一个 jar,而是一个真正被 Android 系统安装的独立 App。这个选择白得了一整套基础设施,也付出了明确的代价。这一章从 PackageManager 一路走到 newInstance(),然后真的跑一个源插件。
先看它要解决的问题有多离谱
回到第 1 章那个例子。Mihon 要支持上千个漫画网站,每个网站的 HTML 结构不同、反爬策略不同、有的要登录、有的图片地址要解密,而且它们随时都在改版。
更麻烦的是这些站点的分布:西班牙语的、印尼语的、俄语的、中文的…… 没有任何一个团队能同时懂这么多站。
所以答案只能是:让懂那个站的人自己写、自己发布、自己维护,宿主完全不参与。
问题变成了:怎么让一个陌生人写的一段 Kotlin 代码,跑进用户手机上那个已经装好的 App 里?
Mihon 的答案:让它自己也是一个 App
这个选择乍看很怪。一个扩展 = 一个要在系统里安装的 APK,用户装一个源要过一遍 Android 的安装流程。为什么不直接下载一个 .jar 或 .dex 文件放到私有目录?
因为用 APK,整套基础设施都是白送的:
| 你本来要自己做的事 | 用 APK 的话 |
|---|---|
| 下载、校验完整性、放到正确目录 | Android 包管理器做完了 |
| 验证这个包是不是被篡改过 | 系统强制的 APK 签名校验 |
| 版本管理、升级、降级保护 | 系统的 versionCode 机制 |
| 卸载、清理残留 | 系统卸载流程 |
| 让用户看到「我装了哪些扩展」 | 就在系统的应用列表里 |
| 存储隔离 | 每个 APK 有自己的包名和数据目录 |
尤其是签名那一条。Android 保证:一个已安装的包,后续更新必须由同一个密钥签名,否则系统直接拒绝安装。这意味着 Mihon 免费得到了「这个扩展的更新确实来自原作者」这个保证 —— 自己实现这套东西(密钥分发、签名校验、防降级)是相当大的工程。
完整加载流程
下面是 Mihon 的 ExtensionLoader 实际在做的事。注意每一步的顺序 —— 最便宜的检查在最前面,最贵的动作(加载 DEX、反射)在最后。
向系统要出所有已安装的包,筛出带那面「旗子」的:
// 扩展在自己的 AndroidManifest.xml 里声明这个 feature
private const val EXTENSION_FEATURE = "tachiyomi.extension"
val extensionPackages = packageManager
.getInstalledPackages(PackageManager.GET_CONFIGURATIONS or ...)
.filter { pkgInfo ->
pkgInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }
}
这就是「发现」。没有扫描目录、没有下载索引 —— 直接问操作系统「谁自称是我的扩展」。这一步极便宜,而且天然防重名(包名由 Android 保证唯一)。
另外 Mihon 还支持第二种来源:应用私有目录下的 .ext 文件(「私有扩展」),用于不方便走系统安装的场景。同名时按 versionCode 取高的那个。
从 ApplicationInfo.metaData 里读出关键信息:
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" // 实现类名(分号分隔,可多个) private const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory" // 是不是工厂 private const val METADATA_NSFW = "tachiyomi.extension.nsfw" // 内容分级 private const val METADATA_HAS_README = "tachiyomi.extension.hasReadme" private const val LIB_VERSION_MIN = ... // 扩展库版本
这就是 Mihon 的「清单」 —— 它没有单独的 manifest.json,而是复用了 Android 的 meta-data 机制。作用完全一样:在不执行任何扩展代码的情况下,知道它是什么。
// 扩展是按哪一版扩展库写的?
val libVersion = ... // 从 metadata 读,或从 versionName 的前缀推断
if (libVersion !in SUPPORTED_LIB_VERSIONS) {
logcat(LogPriority.WARN) { "扩展库版本不支持,不加载:$pkgName" }
return LoadResult.Error
}
撰写本书时(2026 年 7 月)主分支上这个支持列表是 listOf(1.4, 1.6) —— 一个明确的白名单,不是范围。这是个很硬的选择:对不上就整个不加载,没有兼容层、没有降级运行。
为什么敢这么硬?因为扩展的分发不经过应用商店审核,作者能在几小时内发新版。硬拒绝的成本(作者改个数字重新发)远低于「让不兼容的扩展半死不活地跑着」的成本。这和第 19 章会讲的 VS Code「API 几乎从不删除」是两种完全相反的策略,各有各的道理。
这是 Mihon 在没有沙箱的情况下唯一的防线:
// 算出签名证书的 SHA-256
val signatures = getSignatures(pkgInfo)
if (signatures.isNullOrEmpty()) {
return LoadResult.Error // 没签名的包,直接拒
}
// 交给信任机制判断
if (!trustExtension.isTrusted(pkgInfo, signatures)) {
return LoadResult.Untrusted(extension) // ← 注意:不是 Error
}
注意 Untrusted 是一个独立的结果类型,不是错误。这正是第 8 章末尾说的「第三种结果」—— 不是成功也不是失败,而是「我不敢替你决定」。UI 上它会显示成一个需要用户手动确认的状态。
保证了:这个扩展确实由持有那把私钥的人签发;装上之后的每次更新都来自同一个人;中途没有被第三方篡改。
完全没保证:这个扩展是善意的。签名只解决「你是不是你」,不解决「你是不是好人」。
一个恶意作者完全可以签一个恶意扩展 —— 签名机制只保证了出事之后你能准确地知道该找谁,以及一个作者的信誉可以被累积和摧毁。
这在安全设计里叫「可归责性」(accountability),它和「防护」(prevention)是两回事。Mihon 选择的是前者。这是那种「同进程、无沙箱」的系统唯一能选的路 —— 因为它技术上根本挡不住恶意代码,只能保证事后追得到人。
前面四关全过,现在才真的动扩展的代码:
// 子优先的类加载器(第 10 章讲过为什么)
val classLoader = ChildFirstPathClassLoader(
appInfo.sourceDir, // 扩展 APK 的路径
null, // native 库路径
context.classLoader, // 父加载器 = 宿主的
)
val sources = extensionClassNames.map { className ->
// 注意第二个参数 false:先别执行静态初始化块
Class.forName(className, false, classLoader)
.getDeclaredConstructor()
.newInstance()
}.flatMap { instance ->
when (instance) {
is Source -> listOf(instance)
is SourceFactory -> instance.createSources() // 一个包提供多个站点
else -> throw Exception("未知的类型")
}
}
三个细节:
① Class.forName(..., false, ...) 的那个 false —— 第 9 章提过:不要在这一步就触发静态初始化,把扩展代码的第一次执行推迟到你准备好接住异常的地方。
② ChildFirstPathClassLoader —— 扩展能自带库版本(jsoup、okhttp 的某个版本),不被宿主的覆盖。代价就是第 10 章那条纪律:接口签名上只能出现宿主定义的类型。
③ SourceFactory —— 一个 APK 可以提供多个源。这在实践中极重要:很多漫画站是同一套程序的不同域名(同一个「站群」),一份解析代码配上不同的 baseUrl 就能覆盖几十个站。
扩展本体:一个普通对象
做完上面所有事,宿主拿到的是什么?一个普通的 Kotlin 对象。没有任何魔法。
下面这个 demo 是真跑的:左边是宿主抓回来的 HTML(固定不变),右边是插件解析出来的结果。中间那段就是插件的全部代码 —— 改改选择器,看结果当场变:
特别推荐点一下「模拟网站改版」那个按钮。选择器对不上了,插件当场抛异常 —— 但宿主活得好好的,其它的源照常工作。
这就是整个插件系统最大的红利:一千个网站的适配逻辑,一千次改版,全都发生在宿主之外,由一千个不同的人各自维护。而宿主只需要在调用插件的地方包一个 try/catch。
契约的划分:为什么是这么切的
Mihon 的 Source 接口值得单独学习,因为它的切法非常聪明。看看宿主和扩展各自负责什么:
| 宿主负责(难,但所有站一样) | 扩展负责(简单,但每个站不同) |
|---|---|
| HTTP 请求、连接池、重试、超时 | 请求哪个 URL、带什么参数 |
| Cookie 管理、UA、Cloudflare 处理 | 需要哪些特殊 header |
| 下载图片、缓存、断点续传 | 图片地址藏在 HTML 的哪里 |
| 数据库、书架、阅读进度、同步 | — |
| 阅读器、翻页手势、双页模式 | — |
| 更新检查、通知、下载队列 | — |
右边那一栏,会写 CSS 选择器就够了。这个切法把「写一个源」的门槛从「懂 Android 开发」降到了「懂网页结构」,于是社区里几百个人能同时供养上千个源。
切在「难而通用」和「简单但各不相同」之间。
宿主把所有难的、需要专业知识的、但对所有插件都一样的事全包了;插件只做简单的、但每个都不一样的那部分。
切错了会怎样?如果 Mihon 让扩展自己发 HTTP 请求、自己处理 Cookie、自己下载图片,那么:每个扩展作者都要重新踩一遍 Cloudflare 的坑;一千个扩展有一千种超时行为;宿主想加个「全局限速」都做不到。而且门槛会高到没人愿意写。
反过来切太细也不行 —— 如果宿主把「解析 HTML」也包了,只让扩展提供一串选择器配置,那就退化成第 1 章说的「配置项」了,遇到需要登录、需要解密的站就完全无能为力。
判断标准:如果一件事你发现每个插件作者都在重复做同样的处理,那它就该被移到宿主这边。
分发:从官方仓库到社区仓库
这一节涉及一段真实的历史,也是插件生态脆弱性的一个案例。
2024 年 1 月,Tachiyomi 的开发者收到韩国 Kakao Entertainment 的律师函,要求终止项目及其分支。核心开发者随后宣布停止开发、移除仓库,并停止提供官方扩展源。Mihon 是由原开发团队成员发起的继任分支,同月发布了第一个版本,仓库在 mihonapp/mihon,Apache-2.0 许可。
官方扩展仓库消失后,扩展的分发转向了社区维护的第三方仓库(keiyoushi/extensions 是其中被广泛使用的一个)。用户把仓库 URL 填进 App,App 去拉一份索引:
// index.min.json(简化)
[
{
"name": "示例漫画站",
"pkg": "eu.kanade.tachiyomi.extension.zh.example",
"apk": "tachiyomi-zh.example-v1.4.12.apk",
"lang": "zh",
"code": 12,
"version": "1.4.12",
"nsfw": 0,
"sources": [ { "name": "示例漫画站", "lang": "zh", "id": "1234567890", "baseUrl": "https://…" } ]
}
]
App 下载对应的 APK,交给系统安装,然后回到本章开头那个 PackageManager 扫描流程。
① 分发渠道是生态的单点故障。代码是开源的、扩展是分布式的,但「用户从哪里发现扩展」这一环一旦被掐断,整个生态就瘫痪。Mihon 的设计里,仓库地址是用户可配置的 —— 这个看似次要的决定,让生态在官方仓库消失后还能延续。
如果你的插件系统把商店地址硬编码进代码,你就给了自己一个致命的单点。
② 「谁签名」和「谁分发」应该分开。因为扩展由作者签名、由 Android 校验,所以换一个分发仓库并不会削弱安全性 —— 用户依然能确认扩展来自原作者。如果签名信任是绑在商店上的(「商店说它是好的」),那么商店没了,信任链也就断了。
③ 法律风险是插件系统特有的一类风险。你的宿主完全合法,但插件可以指向任何地方。这在技术上无解 —— 只能靠明确的定位(Mihon 官方文档明确说明它不提供任何内容)、以及让分发与项目本体解耦。
代价:这个方案真正的短板
公平起见,把代价也列清楚:
① 安装体验糟糕。用户要允许「安装未知来源的应用」,每装一个源都要过一遍系统安装弹窗。装二十个源就是二十遍。
② 平台绑定。这套方案只在 Android 上成立。Mihon 想上 iOS 或桌面,整个扩展机制要重做。(Suwayomi 那类服务端实现就是另起炉灶的。)
③ 没有任何运行时隔离。扩展的代码和 Mihon 在同一个进程、同样的权限。一个恶意扩展能读 Mihon 能读的一切、能发任意网络请求。签名只保证可归责,不保证安全(上面说过)。
④ 卸载不彻底。DEX 加载进来的类,在应用重启前回收不掉。
⑤ 更新有延迟。扩展更新要走系统安装流程,不能像 Web 那样即时热更。
装到自己身上
该用的信号:
① 插件由不可信的第三方编写,你需要签名和可归责性;
② 插件需要完整的编程能力(不是配置能表达的);
③ 插件的更新频率远高于你的 App;
④ 你只做 Android,不打算跨端。
不该用的信号:
① 插件是你自己写的 → 直接用模块 / 动态特性模块(Play Feature Delivery);
② 需要跨平台 → 考虑 JS 引擎(QuickJS / J2V8)或 WASM,虽然性能差些但一份代码到处跑;
③ 用户体验优先 → 系统安装弹窗会劝退大量普通用户;
④ 要上 Google Play → 动态加载可执行代码有明确的政策风险,务必先读清楚当前的开发者政策。
class ExtensionLoader(private val context: Context) {
fun loadAll(): List<LoadResult> = context.packageManager
.getInstalledPackages(PackageManager.GET_META_DATA or PackageManager.GET_SIGNING_CERTIFICATES)
.filter { it.reqFeatures.orEmpty().any { f -> f.name == EXTENSION_FEATURE } }
.map { load(it) }
private fun load(pkgInfo: PackageInfo): LoadResult {
val appInfo = pkgInfo.applicationInfo ?: return LoadResult.Error("没有 applicationInfo")
// ① 版本闸(最便宜)
val libVersion = appInfo.metaData.getString(METADATA_LIB_VERSION)?.toDoubleOrNull()
if (libVersion !in SUPPORTED_LIB_VERSIONS) return LoadResult.Error("扩展库版本不支持")
// ② 签名(中等)
val sigs = pkgInfo.signingCertSha256() ?: return LoadResult.Error("没有签名")
if (!trust.isTrusted(pkgInfo.packageName, sigs)) return LoadResult.Untrusted(pkgInfo, sigs)
// ③ 加载(最贵,放最后)
val loader = ChildFirstPathClassLoader(appInfo.sourceDir, null, context.classLoader)
val classNames = appInfo.metaData.getString(METADATA_SOURCE_CLASS)
?.split(";")?.map { it.trim() } ?: return LoadResult.Error("没声明实现类")
return try {
val sources = classNames.flatMap { name ->
when (val o = Class.forName(name, false, loader)
.getDeclaredConstructor().newInstance()) {
is Source -> listOf(o)
is SourceFactory -> o.createSources()
else -> error("$name 既不是 Source 也不是 SourceFactory")
}
}
LoadResult.Success(sources)
} catch (e: Throwable) { // ← Throwable,不是 Exception(第 6 章讲过)
LoadResult.Error("实例化失败:${e.message}")
}
}
}
顺序就是重点:便宜的检查在前,最贵的动作(碰扩展代码)在最后。这个结构你在任何插件加载器里都该保持。
这一章的一句话
Mihon 用「插件即 APK」白得了 Android 的安装、签名、更新、卸载全套基础设施,代价是平台绑定和糟糕的安装体验;而它真正值得学的不是加载技巧,是那条把契约切在「难而通用」与「简单但各不相同」之间的分界线。
下一章回到 JS 世界,看 Chrome、VS Code、Obsidian 这三家同样是 JS,却用了三种完全不同的方式把代码放进来 —— 而这三种方式恰好对应着三个不同的隔离级别。