这是一篇讲架构思路与工程原理的技术文章。文中的类名、任务名、路径均为脱敏与抽象后的示意,不代表任何具体商业项目。
本文要回答一个核心问题:能不能不引入任何传统插件化框架(不 Hook 系统 AMS/PMS),只借用 Tinker 的热更新能力,就把两个完全独立开发的 APK(一个轻量宿主(云游戏) + 一个庞大的插件(游戏整包))合并成同一个应用,在运行时动态合体?
答案是能。下文会完整拆解这套"编译期静态嫁接骨架 + 运行期 Tinker 注入血肉"的反向插件化方案。
术语约定:宿主 (云游戏)指轻量宿主包,插件(游戏整包)指完整游戏整包;为避免图表拥挤,Mermaid 图中仍使用简称。
写在前面:一句话讲透核心脑洞
传统的 Android 插件化(如 VirtualApp、RePlugin、Shadow)思路是容器分身:
宿主(云游戏)提供一个运行时容器,通过 Hook 系统底层 API 或构造复杂的委托机制,让未注册的插件(游戏整包)APK 在容器里"借壳上市"。
而本文介绍的方案,思路完全反过来,是借热更之手,行合包之实:
- 编译期先嫁接骨架 :把目标插件(游戏整包)APK 反编译,抽取出它的
AndroidManifest.xml、四大组件空壳类(Stub)和最小引用资源,合并进宿主(云游戏)工程,编译出一个长着两个包的骨架、但只有宿主(云游戏)几十 MB 血肉的基准包 (oldApk);- 构建期算全量血肉差 :把完整的目标插件(游戏整包)APK 注入
TINKER_ID后伪装成"新版本(newApk)",与上面的基准包做 Tinker 差分,产出一份包含插件(游戏整包)全部 DEX、Res、Assets、SO 的补丁包(Patch);- 运行期合二为一 :用户先秒装几十 MB 的宿主(云游戏)运行基础业务;后台下完补丁后交由 Tinker 合并,重启瞬间通过反射将插件(游戏整包)的真实
Application扶正、切换入口 Activity,宿主(云游戏)摇身一变,直接成为完整的目标 App!
这就是"用 Tinker 反向实现插件化 "的精髓:没有容器隔离,没有 AMS Hook,而是利用热更新把两个独立 APK 在生命周期里真正融合成了一个包。
一、为什么放弃传统插件化,选择"反向利用 Tinker"?
1.1 典型业务痛点
在大型重度应用或重资源应用(如数 GB 的 3D 游戏、大型互动应用)分发时,有一个经典的转化率漏斗痛点:首包体积越大,用户下载流失率越高。
理想的产品形态是:
- 先装极小包(如 30~40 MB):包含轻量体验、云端串流体验或定制下载器,让用户 3 秒内安装并进入应用;
- 后台静默升级为完整包(如 2 GB):在用户使用轻量功能的同时,后台把完整原生应用下载并"合成"进来,下次启动直接运行原生完整版。
但工程上的硬约束往往是:完整版应用是由独立团队(甚至外部引擎团队)开发的成品 APK,宿主(云游戏)团队拿不到它的源码,也不能要求对方按照插件化规范重构代码。
1.2 传统插件化 vs Tinker 反向合包
面对"把一个成品 APK 塞进另一个 APK"的需求,两条技术路线的对比极为鲜明:
| 对比维度 | 传统插件化框架(VirtualApp / RePlugin) | Tinker 反向插件化(两包合并方案) |
|---|---|---|
| 核心哲学 | 分身与隔离:在宿主(云游戏)里模拟系统环境跑另一个 App | 嫁接与同化:把插件(游戏整包)当成宿主(云游戏)的"下一版本"融进体内 |
| 四大组件注册 | 插件(游戏整包)组件未注册,靠运行期 Hook 系统 AMS / 占坑欺骗 | 编译期已将插件(游戏整包)全部组件合并进宿主(云游戏) Manifest,堂堂正正注册 |
| Android 9~15 兼容性 | 极差,Hidden API 限制导致 Hook 频繁失效,随系统升级持续崩 | 极高,零 AMS/PMS Hook,顺应系统组件管理机制 |
| 目标包改造成本 | 需接入插件框架 SDK 或受限于特定宿主(云游戏)容器约束 | 零源码改造 ,直接拿成品 APK 反编译拆解 + 注入 TINKER_ID |
| 类与资源空间 | 独立 DexClassLoader、独立 Resources |
合并入宿主(云游戏)主 ClassLoader 与 Resources,天然互通 |
| 适用场景 | 一个宿主(云游戏)同时运行多个小型业务插件(游戏整包) | 一个轻量宿主(云游戏)动态合体为一个巨型完整应用 |
一句话总结:传统插件化是在运行期跟 Android 系统对抗;而 Tinker 反向插件化是在编译期就把合法手续(Manifest 组件、资源 ID)办齐,运行期只让 Tinker 负责把"代码和资源字节"搬运到位。
二、总体架构:两个独立 APK 是如何合二为一的?
2.1 系统全景架构图
整套系统分为 前置拆包嫁接 (Stub Merge)、基准构建与差分 (Build & Patch)、运行期动态合体(Runtime Assimilation) 三个阶段:
2.2 合包前后的进程形态演变
为了直观理解"两个包合成一个包",可以看下面这张包体结构演变图:
这套设计最妙的地方在于:
- 对 Android 系统(AMS/PMS)而言 :从用户安装
oldApk的那一刻起,插件(游戏整包)的所有 Activity、Service、Provider、Permission 就已经合法注册在系统里了; - 对编译器(javac/AAPT2)而言 :编译
oldApk时,Manifest 里引用的插件(游戏整包)类名有空壳.java顶着,引用的@drawable/@style有最小资源闭包顶着,编译丝滑通过; - 对运行期而言 :Patch 装上之前,空壳类和插件(游戏整包)组件根本不会被调用;Patch 装上之后,Tinker 的 DEX 数组前置机制和资源替换机制,刚好用插件(游戏整包)的真实实现覆盖掉编译期的空壳!
三、构建期核心原理:如何把两个 APK 揉进同一个构建流?
要让宿主(云游戏)APK 成为插件(游戏整包)APK 的"合法前世(oldApk)",直接拿宿主(云游戏)源码去跟插件(游戏整包)APK 做 Tinker 差分是绝对行不通的。必须先通过构建插件(mergePluginStub)完成四大嫁接手术。
3.1 手术一:Manifest 深度融合与"启动器劫持"
反编译目标插件(游戏整包)APK 后,构建器会解析其 AndroidManifest.xml,生成一份专门供宿主(云游戏) Flavor 合并的 Stub Manifest。这里有四个极易踩坑的细节:
(1)为什么要强制同化 versionName 和 versionCode?
在 apktool 反编译时,版本号往往被剥离到了 apktool.yml 中。构建脚本必须把它重新解析出来,并在生成的 Stub Manifest 根节点写上:
xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.example.targetapp"
android:versionName="1.0.255"
android:versionCode="255"
tools:replace="android:versionName,android:versionCode">
原因 :合体后的包本质上代表的是该版本的插件(游戏整包)。编译期用 tools:replace 把宿主(云游戏)的 versionName/versionCode 强制覆盖为插件(游戏整包)的版本号,不仅能保证差分时版本基准一致,还能让运行期通过 PackageManager 读取到的版本号直接就是插件(游戏整包)版本号,从而与服务端下发的补丁 URL 模板(patch_{gameCode}_{versionName}_{versionCode}_{tinkerId}.apk)完美契合。
(2)为什么要劫持 LAUNCHER 入口?
插件(游戏整包)APK 原本有自己的桌面启动 Activity(或 activity-alias)。如果原封不动合进宿主(云游戏),用户刚装上 40MB 的宿主(云游戏)、还没下载补丁时点击桌面图标,系统就会直接去启动插件(游戏整包)的主 Activity------而此时只有空壳类,没有插件(游戏整包)业务代码和资源,必然黑屏或崩溃。
因此,合并器在扫描组件时:
- 將宿主(云游戏)的路由页
RouterActivity(代码中如SelectActivity)设为真正的桌面入口; - 如果插件(游戏整包)使用了
<activity-alias>作为桌面MAIN/LAUNCHER,则自动将其android:targetActivity重定向到宿主(云游戏)的RouterActivity; - 同时通过
<meta-data android:name="targetActivity" android:value="插件真实主Activity"/>记录下插件(游戏整包)原本的入口,供合包完成后的路由跳转使用。
3.2 手术二:自动生成 Java 空壳类(Stub Classes)
当插件(游戏整包)的几百个 Activity、Service、Provider 被合进宿主(云游戏) AndroidManifest.xml 后,编译宿主(云游戏)时立刻会遇到问题:
- AAPT2 / Lint / ProGuard 会校验 Manifest 中声明的类是否存在;
- 如果类不存在,或者包名以
.开头(相对路径),编译或运行期解析就会报错。
解决方案是在编译期扫描所有组件,动态生成对应的 .java 源码文件:
生成的空壳类非常干净,只实现最基本的抽象方法存根(比如 Service.onBind 返回 null,ContentProvider.onCreate 返回 false)。
- 在宿主(云游戏)未打补丁时:它们满足了 Manifest 校验和包体积最小化(几百个空类编译后只有几十 KB);
- 在打上 Tinker 补丁后 :Tinker 会将补丁中的完整 DEX 插到
ClassLoader的dexElements数组最前面,系统加载同名类时会直接命中补丁里的真实插件(游戏整包)类,空壳类自动隐退。
3.3 手术三:BFS 最小资源闭包提取(为什么宿主(云游戏)不会膨胀?)
这是构建期设计的精髓之一。 插件(游戏整包)的 AndroidManifest.xml 里必然引用了大量资源,比如:
android:icon="@mipmap/ic_launcher"android:theme="@style/AppTheme"- 而
@style/AppTheme在values/styles.xml里又引用了@color/primary、@drawable/bg_splash......
矛盾点:
- 如果把插件(游戏整包)的
res/全量拷进宿主(云游戏),宿主(云游戏)就会瞬间暴涨几百 MB,失去"轻量微端"的意义; - 如果不拷贝这些资源,宿主(云游戏)编译
AndroidManifest.xml时 AAPT2 会直接报resource not found编译失败。
解法:基于广度优先搜索(BFS)的最小资源依赖闭包提取器。
通过这个 BFS 算法,哪怕插件(游戏整包)原本有 20,000 个资源,最终被抽进宿主(云游戏)的往往只有几十个与 Manifest 强相关的图标、启动主题和基础字符串,体积控制在几百 KB 以内。
同时还要处理一个细节:attrs.xml 按需保留 。如果插件(游戏整包)的自定义主题里用到了自定义属性,抽取器会把对应的 <attr> 定义一并抽出来,防止 AAPT2 报属性未定义错误。
3.4 手术四:用 plugin_R.txt 锁死资源 ID(生死线)
为什么说资源 ID 固化是整套方案的"生死线"?
问题根源:AAPT2 的 ID 漂移
Android 编译时,AAPT2 会为每个资源分配一个 32 位整型 ID(0x7fPPTTEEEE)。
- 插件(游戏整包)APK 原本编译时,有全量 20,000 个资源,
@drawable/icon分到的 ID 是0x7f080001; - 宿主(云游戏)APK 编译时,只有抽取出来的几十个最小闭包资源,AAPT2 默认重新编号,
@drawable/icon分到的 ID 变成了0x7f020001。
一旦两边的资源 ID 不一致:
- Tinker 在做资源差分时对不上号;
- 运行时插件(游戏整包)代码拿着编译期写死在字节码里的常量
0x7f080001去找资源,直接抛出Resources$NotFoundException崩溃!
解决方案:public.xml → plugin_R.txt → AAPT2 --stable-ids
工程铁律 :构建宿主(云游戏)基准包时,必须开启 Tinker 插件的 applyResourceMapping (通过构建参数如 -PenableTinker=true 触发 tinkerProcessResourceId 任务)。如果构建时漏掉了这个开关,编译不会报错,但打出来的基准包资源 ID 是飘的,到了线上加载补丁必崩。
3.5 双 TinkerId 机制与全量差分方向
准备好了混合基准包(oldApk)之后,下一步就是用 Tinker 算出它与完整插件(游戏整包)(newApk)之间的差值。这里有两个反直觉的设计:
(1)差分的方向:宿主(云游戏)是 oldApk,插件(游戏整包)是 newApk
在常规热更里,oldApk 是旧版应用,newApk 是新版应用。 而在本方案里:
oldApk(旧包)= 刚刚编译出来的宿主(云游戏)混合基准包(~40MB);newApk(新包)= 外部团队给的完整插件(游戏整包)APK(~2GB)。
Tinker 差分引擎对比两者后发现:newApk 比 oldApk 多出了全部的插件(游戏整包)业务 DEX、全部的游戏关卡 Assets、全部的引擎 SO 库以及完整的 resources.arsc。于是 Tinker 把这些"多出来的全部内容"打包成了一个 patch.apk。
(2)为什么要设计两个不同的 TinkerId?
Tinker 的补丁校验机制要求:
oldApk和newApk的AndroidManifest.xml中都必须含有<meta-data android:name="TINKER_ID" .../>;oldApk和newApk的TINKER_ID必须不同(否则 Tinker 认为新旧包一致,拒绝生成补丁);- 生成出来的
Patch包内部会记录它所对应的基准包 TINKER_ID (即oldApk的 ID),运行时宿主(云游戏)拿自己的TINKER_ID与补丁核对,一致才允许安装。
但问题来了:插件(游戏整包)APK 是外部团队编译好的成品,里面根本没有 TINKER_ID!
因此构建流水线设计了双 ID 生命周期:
| ID 类型 | 作用 | 写入位置 | 是否用于产物命名与线上匹配 |
|---|---|---|---|
| 临时 ID(插件(游戏整包)注入 ID) | 仅作为占位符骗过 Tinker 差分器,让插件(游戏整包)APK 具备合法的 newApk 身份 |
modified/plugin_inject_tinker_id.apk |
否(用完即弃) |
| 核心 ID(宿主(云游戏)基准 ID) | 真正标识本次构建批次,写入宿主(云游戏) Manifest 及补丁元信息 package_meta.txt |
宿主(云游戏)基准包 & Patch 校验头 | 是(决定客户端下哪个补丁) |
四、运行期核心原理:补丁是如何"夺舍"宿主(云游戏)进程的?
当用户在宿主(云游戏) App 内下载完那份 2GB 的 Patch 并通过 TinkerInstaller.onReceiveUpgradePatch() 安装成功后,下一次进程启动时,真正的"魔术"才刚开始。
为什么说是"夺舍"?因为宿主(云游戏)进程启动后,要把自己的代码空间、资源空间、文件路径、Application 实例以及主入口 Activity 全部移交给插件(游戏整包)。
4.1 运行期五步"夺舍"时序图
下面我们逐一拆解这里面最核心的 4 个技术难点。
4.2 难点一:代理 Application 与系统全局引用篡改
每个大型应用或游戏都有自己的 Application 子类(如果插件(游戏整包)本身带有加固壳,它的入口 Application 往往是加固壳的 StubApp)。插件(游戏整包)的大量 SDK 初始化、全局 Context 持有、解密逻辑都在这个真实 Application 的 attachBaseContext 和 onCreate 中执行。
但 Android 系统启动进程时,只会实例化 Manifest <application android:name="..."> 中声明的宿主(云游戏)代理 ProxyApplication(继承自 TinkerApplication)。
为了让插件(游戏整包)感觉不到自己是被"寄宿"的,代理 ProxyApplication 在检测到 isTinkerLoaded() == true 时,会执行狸猫换太子:
为什么要改这 4 个地方?
ActivityThread.mInitialApplication:很多第三方 SDK(以及加固壳)会通过反射调用ActivityThread.currentApplication()获取全局 Context,如果不改,它们拿到的还是宿主(云游戏)的ProxyApplication,一旦做强制类型转换((GameApp) context)就会直接ClassCastException;LoadedApk.mApplication:系统后续启动任何插件(游戏整包)Activity、Service时,都会从LoadedApk里取mApplication赋给Activity.getApplication();替换之后,插件(游戏整包)所有 Activity 调getApplication()拿到的都是原汁原味的插件(游戏整包)Application;ContentProvider.mContext:Android 系统中ContentProvider.onCreate()的执行时机早于Application.onCreate()(在installContentProviders阶段就已经用ProxyApplication初始化了)。所以在onCreate()里必须回头遍历mProviderMap,把已经创建的 Provider 内部的mContext修正为realApplication。
4.3 难点二:加固壳冲突与 PatchPathRedirector 路径重定向
这是实际落地时最隐蔽、最体现工程深度的一个坑。
为什么带加固壳的插件(游戏整包)在 Tinker 加载后会崩?
商业游戏或重度应用 APK 几乎都上了加固壳(如网易易盾、腾讯乐固、梆梆等)。加固壳的 StubApp 在启动时,会干一件事:
去读
context.getApplicationInfo().sourceDir(即/data/app/~~xxx/base.apk),打开这个 ZIP 文件,从中寻找加密的主 DEX payload 或签名校验数据,解密后动态加载。
但是在我们这套"Tinker 反向合包"方案里:
/data/app/~~xxx/base.apk是那个只有 40MB 的宿主(云游戏)基准包,里面根本没有插件(游戏整包)的加固 payload!- 插件(游戏整包)真正的 payload 和完整文件,在
/data/user/0/<pkg>/tinker/patch-xxxxxxx/patch-xxxxxxx.apk里!
如果此时让加固壳去读默认的 sourceDir,加固壳就会因为找不到加密数据而直接闪退。
解法:双向路径手术(PatchPathRedirector + injectHostAssetPath)
通过"先重定向 sourceDir 骗过加固壳,再 addAssetPath 补回宿主(云游戏)资源路径"这记组合拳,连带商业加固壳的第三方成品 APK 都能在这套方案下无缝跑通。
4.4 难点三:模拟器与真机的 ABI 错配问题
当补丁里的 SO 库需要加载时,代码会调用 TinkerLoadLibrary.installNavitveLibraryABI(context, currentABI)。 但 currentABI 怎么取?
如果直接写 Build.SUPPORTED_ABIS[0],在 PC 安卓模拟器(如 MuMu、雷电)上会立刻踩雷:
- 模拟器的
Build.SUPPORTED_ABIS返回的是["x86_64", "x86", "arm64-v8a", "armeabi-v7a"](通过 Houdini / libndk_translation 转译 ARM); - 插件(游戏整包)APK 为了控制体积,往往只打包了
lib/arm64-v8a/*.so或lib/armeabi-v7a/*.so,并没有x86_64目录; - 如果盲目取
SUPPORTED_ABIS[0](即x86_64)传给 Tinker,Tinker 就会去补丁的lib/x86_64/下找 SO,结果自然是UnsatisfiedLinkError崩溃!
因此,正确的 ABI 决策必须走三层实探兜底:
4.5 难点四:大体积整包补丁的下载、安装、失败自愈与 Emergency Mode
因为这里的"补丁"不是几百 KB 的热修包,而是动辄 1~2 GB 的完整游戏包,运行期的稳定性面临极大考验:
(1)主线程 I/O 与 ANR 防护
Tinker 原生的 TinkerInstaller.onReceiveUpgradePatch() 和 tinker.cleanPatchByPatchApk() 内部都会对补丁文件做全量 MD5 计算。1KB 的文件算 MD5 只要 1 毫秒,2GB 的文件算 MD5 需要数秒甚至十几秒!
- 安装必须异步 + 并发锁 :必须封装
tryInstallPatchAsync,在独立工作线程触发,并用synchronized锁拦截用户的重复点击; - 清理必须异步 :清理失效补丁(
cleanPatchAsync)同样必须放入子线程,否则在主线程一调就会直接触发 5 秒 ANR。
(2)Emergency Mode(紧急模式:安装速度 vs 首启性能的权衡)
面对 2GB 的补丁和数万个方法数的多个 DEX,如果安装补丁时同步执行 dex2oat 编译机器码,用户在"正在合成游戏..."的进度条界面可能要卡上好几分钟,导致极高的中途强退率。
- 开启
Emergency Mode(配合dexMode = "raw")后,安装阶段跳过耗时的dex2oat,把分钟级的合成时间压缩到十秒级; - 代价是首次重启进入游戏时依赖 ART 的 JIT / 解释执行,加载稍慢,但可以通过服务端下发开关(
useEmergencyMode)按机型高低端动态调控。
(3)异常精细化分类与自愈状态机
2GB 补丁在用户千奇百怪的手机环境下安装,最常见的失败有三类:磁盘空间不够 (解压 + 合并需要双倍空间)、下载中途网络污染导致文件损坏 、I/O 偶发中断。如果无脑重试,只会陷入死循环。
因此方案在 DefaultTinkerResultService 之上构建了基于异常栈特征的自愈状态机:
五、深度复盘:这套"反向插件化"方案的优劣势与边界
任何架构都是妥协的艺术。把 Tinker 反向用于两包合并,它的收益和代价都非常鲜明:
5.1 核心优势(为什么它在特定场景下是降维打击?)
- 目标包零侵入:不需要目标插件(游戏整包)团队改一行代码、不需要接任何插件 SDK,给一个打好的成品 APK 就能融合。
- 四大组件 100% 原生兼容:因为编译期已经把组件、权限、最小资源闭包合进了宿主(云游戏) Manifest,运行期不存在任何"未注册组件欺骗 AMS"的黒科技,Android 14 / 15 / 16 升级稳如泰山。
- 跨包平滑过渡体验 :合包前是一个完整的轻量 App(微端/云游),合包后直接蜕变为完整的重度原生 App,共享同一个桌面图标、同一个包名、同一套用户登录态数据目录(
/data/data/<pkg>/)。
5.2 客观代价与局限(什么情况下不能用?)
- 只支持二合一(1+1=1),不支持一拖多 (1+N): 因为所有的类、资源 ID(
0x7f)、Application实例都是全局同化的,一个宿主(云游戏) Flavor 只能定向绑定并融合一个目标插件(游戏整包),不能像 RePlugin 那样同时加载 A、B、C 三个互不相干的插件(游戏整包)。 - 补丁体积接近整包,本质是延迟整包下载而非节省总量 : 当目标包体积大头在
assets/(如游戏引擎资产)时,由于宿主(云游戏)基准包里没有这些资产,Tinker 算出来的 Patch 体积几乎等于目标包本身(例如 2.14GB 的游戏算出的 Patch 约 2.11GB)。它解决的是首包秒装与转化率问题,而不是总流量节省问题。 - 磁盘空间峰值要求高 : 下载 2GB 补丁 + Tinker 解压合并到
/data/user/0/<pkg>/tinker/目录,安装瞬间的峰值磁盘占用接近 4GB(安装成功后MicroResultService删除原始补丁包才会回落到 2GB)。因此对低存储设备的空间检查(INSUFFICIENT_STORAGE)至关重要。 Application反射篡改存在系统演进维护成本 : 虽然绕开了 AMS/PMS Hook,但为了把ProxyApplication替换成插件(游戏整包)的真实Application,依然反射了ActivityThread.mInitialApplication、LoadedApk.mApplication、ContentProvider.mContext。虽然有useProxyFallback全量方法转发兜底,但每次大版本系统升级仍需回归验证。
六、代码级隐患与演进建议(实战避坑清单)
如果要在生产环境长期维护这套架构,以下 4 个工程细节值得重点关注:
- 避免
ApplicationLike中的重复TinkerInstaller.install()调用 在DefaultApplicationLike子类的onBaseContextAttached()中,应当只初始化一次TinkerInstaller.install(...),并与TinkerManager中的安装标志位isInstalled保持同步,避免重复注册LoadReporter/PatchReporter。 - 统一
tinkerId的单一事实来源 (Single Source of Truth) 构建期如果在多个 Task 中分别调用System.currentTimeMillis()生成tinkerId,一旦配置阶段与执行阶段取值不一致,就会导致产出的 Patch 与基准包无法匹配。应固定从project.tinkerPatch.buildConfig.tinkerId读取,并在构建结束时输出一份build-manifest.json供 CI/CD 校验。 - 公共依赖库的冲突治理 (
EXCLUDED_PACKAGE_PREFIXES) 因为宿主(云游戏)和插件(游戏整包)的 DEX 最终处于同一个ClassLoader下,如果宿主(云游戏)和插件(游戏整包)都集成了同一个第三方 SDK(但版本不同),就会发生类掩盖或NoSuchMethodError。构建期的StubGenerator排除列表和 Gradle 的packagingOptions.exclude(如排除重复的协程调试类或公共.so)必须建立自动化冲突扫描。 - 增加"启动连续闪退自动回滚"机制 整包合体后如果因为特定机型兼容问题导致插件(游戏整包)
Application.onCreate()连续崩溃,应当利用 Tinker 的UncaughtExceptionHandler计数:若连续 3 次启动在 5 秒内崩溃,自动调用Tinker.with(context).cleanPatch()退回轻量宿主(云游戏)模式,向用户展示修复提示,而不是让 App 彻底死锁。
结语
回看整套方案,最精彩的地方在于跳出框架看问题:
当所有人都在研究"怎么在 Android 高版本上继续 Hook 系统 API 实现插件化"时,这套方案反其道而行之------
- 编译期 ,用
apktool+ AST 空壳生成 + BFS 资源提取 +stable-ids,把插件(游戏整包)的"户口"合法地落进宿主(云游戏); - 运行期 ,把插件(游戏整包)当成宿主(云游戏)的"热更新补丁",让成熟的 Tinker 框架替我们完成最脏最累的 DEX、Res、Assets、SO 合并,最后只在
Application和入口路由上轻轻推一把,就完成了两个独立 APK 的合二为一。
不动目标包一行源码,不 Hook 系统一个服务,借热更之舟,渡插件化之河。 这就是工程架构中最迷人的"巧劲"。