上一篇我们聊了Android插件化的技术流派演进,最后抛出了一个核心问题:Shadow凭什么敢说"零反射、零Hook"就能实现插件化?
今天这篇,我要把Shadow最核心的那层纸捅破------壳子Activity代理机制。说实话,第一次看懂这个设计的时候,我的反应是"卧槽,这帮人是怎么想出来的"。它的精妙之处在于,用一种几乎"作弊"的方式,让系统完全感知不到插件Activity的存在,同时又保证了插件Activity拥有完整的生命周期。
先回忆一下:为什么Hook方案不行了
在深入Shadow之前,我需要再强调一下背景------这决定了为什么代理方案是未来的方向。
传统Hook方案(DroidPlugin、VirtualAPK)的核心思路是:拦截AMS(ActivityManagerService)的IPC调用,在startActivity的时候偷梁换柱,把插件Activity替换成已注册的占坑Activity骗过系统检查,然后在Handler的mCallback里再换回来。
这套方案在Android 9之前运转良好。但从Android 9开始,Google引入了Hidden API限制(@hide注解的反射调用会被系统拦截),并且每个版本都在收紧。到Android 14,连一些曾经的灰名单API都进了黑名单。更致命的是,AMS的内部实现每个版本都在重构------你Hook的那个字段,下个版本可能就改名了,甚至不存在了。
所以Hook方案的维护成本是指数级增长的:每出一个新系统版本,你就得去翻AOSP源码,找到变化的地方,加一层版本适配。这不是工程师该干的事。
Shadow的核心思路:壳子Activity
Shadow的设计哲学完全不同。它不跟系统对着干,而是顺着系统的规则玩。
核心思路只有一句话:在宿主的AndroidManifest中真实注册一批"壳子Activity"(HostActivity),运行时由壳子Activity代理执行插件Activity的逻辑。
对系统来说,启动的永远是宿主里合法注册的Activity,不需要任何Hook。对插件来说,它的代码被Shadow在编译期做了Transform(下一篇细讲),把继承的android.app.Activity替换为Shadow的ShadowActivity,而ShadowActivity本身并不是真的Activity------它只是一个普通Java类,内部持有壳子Activity的引用,所有系统调用都委托给壳子。
系统启动壳子Activity(HostActivity)
↓
壳子Activity.onCreate() 触发
↓
通过Intent找到目标插件Activity类名
↓
用插件ClassLoader加载并实例化ShadowActivity
↓
建立双向引用:壳子⟷插件Activity
↓
转发生命周期:壳子.onXxx() → 插件.onXxx()
这个设计的天才之处在于:系统只跟壳子打交道,壳子只做生命周期转发,插件代码认为自己是一个Activity但其实不是------整个过程没有一行反射,没有一个Hook点。
HostActivity的实现细节
来看具体代码。壳子Activity(在Shadow中叫PluginContainerActivity)的核心逻辑大概长这样:
kotlin
class PluginContainerActivity
: Activity() {
// 持有插件Activity的引用
private var pluginActivity:
HostActivityDelegate? = null
override fun onCreate(
savedState: Bundle?
) {
// 1. 先把自己传给代理
pluginActivity =
getDelegate(this)
// 2. 转发onCreate
pluginActivity
?.onCreate(savedState)
}
override fun onResume() {
super.onResume()
pluginActivity?.onResume()
}
override fun onPause() {
pluginActivity?.onPause()
super.onPause()
}
// ... 所有生命周期方法都转发
}
注意这里有个细节:onCreate里没有调用super.onCreate()吗?实际上在Shadow的实现中,super.onCreate()的调用时机是被精心设计过的------它在代理完成初始化之后才调用,确保插件侧的setContentView等操作能正确作用到壳子Activity的Window上。
getDelegate:如何找到插件Activity
getDelegate()方法的实现是整个代理机制的枢纽。它需要完成三件事:
• 第一步:从启动Intent中取出插件Activity的类名(Shadow在startActivity时把真实类名存进了Intent的Extra)
• 第二步:用插件的ClassLoader加载这个类(插件有自己独立的ClassLoader,后面会细讲)
• 第三步 :实例化并建立双向引用------壳子持有插件实例的引用用于转发生命周期,插件实例持有壳子的引用用于调用系统API(比如startActivity、finish等)
kotlin
private fun getDelegate(
host: Activity
): HostActivityDelegate {
// 从Intent取出真实类名
val className = host.intent
.getStringExtra(
"PLUGIN_ACTIVITY_CLASS"
)
// 用插件ClassLoader加载
val clazz = pluginClassLoader
.loadClass(className)
// 实例化
val delegate = clazz
.newInstance()
as HostActivityDelegate
// 建立双向引用
delegate.setHost(host)
return delegate
}
ShadowActivity:让插件代码"以为"自己是Activity
这是Shadow最巧妙的一层抽象。插件开发者写代码时,代码里的Activity是正常的------继承AppCompatActivity,调setContentView,用startActivity跳转。但在编译期,Shadow的Gradle Transform会把所有Activity的父类替换为ShadowActivity。
ShadowActivity是个什么东西呢?它不继承系统的Activity,而是一个实现了HostActivityDelegate接口的普通类,内部用组合的方式持有宿主壳子Activity的引用:
kotlin
open class ShadowActivity
: HostActivityDelegate {
// 宿主壳子Activity的引用
private lateinit var
hostActivity: Activity
override fun setHost(
host: Activity
) {
hostActivity = host
}
// 插件代码调setContentView
// 实际操作的是壳子的Window
fun setContentView(
layoutId: Int
) {
hostActivity
.setContentView(layoutId)
}
// 插件代码调startActivity
// 需要拦截并路由到正确壳子
fun startActivity(
intent: Intent
) {
val newIntent =
convertToHostIntent(intent)
hostActivity
.startActivity(newIntent)
}
// 插件代码调finish
fun finish() {
hostActivity.finish()
}
// 插件代码调getResources
// 返回插件自己的Resources
fun getResources(): Resources {
return pluginResources
}
}
看到没有?插件代码里写setContentView(R.layout.xxx),运行时实际调用的是ShadowActivity.setContentView(),后者再委托给壳子Activity------整条链路完全在用户态完成,不需要任何系统级Hook。
生命周期同步:比你想的要复杂
生命周期转发看起来简单------壳子onResume了就转发onResume嘛。但魔鬼在细节里。
难点一:onCreate的savedInstanceState
系统在配置变更(旋转屏幕等)时会销毁重建Activity,并通过onSaveInstanceState/onCreate(Bundle)恢复状态。对Shadow来说,壳子Activity被重建时,它必须:
• 从Bundle中恢复插件Activity的类名信息
• 重新用ClassLoader加载并实例化插件Activity
• 把原始的savedInstanceState传给插件的onCreate
Shadow的处理方式是在onSaveInstanceState时,把插件Activity的类名和状态一起打包存进Bundle:
kotlin
override fun onSaveInstanceState(
outState: Bundle
) {
// 先让插件保存自己的状态
pluginActivity
?.onSaveInstanceState(outState)
// 再额外记录插件类名
outState.putString(
"PLUGIN_CLASS",
pluginActivity?.javaClass?.name
)
super.onSaveInstanceState(outState)
}
难点二:onActivityResult的路由
当插件Activity A启动插件Activity B(startActivityForResult),B finish时,系统会把result回传给A对应的壳子Activity。壳子收到onActivityResult后需要正确路由给插件A。
这里Shadow用了一个映射表来管理壳子和插件的对应关系------每个壳子Activity实例对应唯一一个插件Activity实例,result自然就能正确送达。
难点三:LaunchMode
这个是我觉得Shadow设计里最让人拍案叫绝的地方。系统的LaunchMode(singleTop、singleTask、singleInstance)是在AndroidManifest里声明的。插件的Manifest系统根本不认------那怎么办?
Shadow的方案是:预注册多种LaunchMode的壳子Activity。
xml
<!-- 宿主AndroidManifest -->
<activity
android:name=".HostStandard1"
android:launchMode="standard"/>
<activity
android:name=".HostStandard2"
android:launchMode="standard"/>
<activity
android:name=".HostSingleTop1"
android:launchMode="singleTop"/>
<activity
android:name=".HostSingleTask1"
android:launchMode="singleTask"/>
<!-- 按需注册更多 -->
当插件Activity声明了singleTask时,Shadow在路由阶段会选择一个LaunchMode匹配的壳子来启动。这种"坑位预注册"的思路虽然不够优雅(你得预估需要多少个坑位),但胜在完全不需要Hook系统。
实践经验:一般来说,standard模式的壳子多注册几个(10-20个),singleTop准备2-3个,singleTask和singleInstance各1-2个就够了。如果你的插件Activity数量特别多,可以用一个分配器来管理壳子的复用。
ClassLoader隔离:各插件互不干扰
Shadow给每个插件分配了独立的DexClassLoader。这是插件化能成立的基础条件之一------如果所有插件共用一个ClassLoader,类名冲突就是灾难。
ClassLoader的继承关系设计得很巧妙:
BootClassLoader(系统类)
↓
PathClassLoader(宿主类)
↓
Shadow自定义ClassLoader(桥接层)
↙ ↘
A → 插件A的DexClassLoader
B → 插件B的DexClassLoader
关键设计点:
• 双亲委派保留 :插件加载android.*等系统类时,依然走标准双亲委派链
• 宿主类可见:插件可以使用宿主暴露的公共接口(通过中间的桥接ClassLoader控制可见性)
• 插件间隔离:插件A和插件B各自有独立的ClassLoader,即使有同名类也不冲突
kotlin
class PluginClassLoader(
dexPath: String,
parent: ClassLoader,
// 宿主暴露的接口白名单
private val hostWhiteList:
Set<String>
) : DexClassLoader(
dexPath, null, null, parent
) {
override fun loadClass(
name: String,
resolve: Boolean
): Class<*> {
// 系统类:走双亲委派
if (name.startsWith(
"android.")
) {
return super.loadClass(
name, resolve)
}
// 宿主白名单类:从宿主加载
if (name in hostWhiteList) {
return hostClassLoader
.loadClass(name)
}
// 其他:从插件自己的dex加载
return findClass(name)
}
}
我当初接入Shadow的时候,踩过一个坑:第三方SDK用了全限定类名的单例,在宿主和插件中各加载了一次,导致状态不一致。解决方法是把这个SDK的包名加入hostWhiteList,确保宿主和插件用同一份实例。
资源加载:解决packageId冲突
资源加载是插件化里公认的难题之一。Android的资源ID是这样组成的:0xPPTTEEEE,其中PP是packageId(默认应用是0x7f),TT是资源类型,EEEE是资源条目。
如果宿主和插件都用0x7f作为packageId,它们的资源ID就会冲突。Shadow的解决方案是:
• 在编译期,通过修改AAPT的参数,给插件分配不同的packageId(比如0x7e、0x7d)
• 运行时,为插件构建独立的Resources对象,只包含插件自己的资源
kotlin
fun createPluginResources(
pluginApkPath: String
): Resources {
val assetManager = AssetManager()
.apply {
// 添加插件APK的资源路径
addAssetPath(pluginApkPath)
}
return Resources(
assetManager,
hostResources.displayMetrics,
hostResources.configuration
)
}
当插件Activity调用getResources()时,ShadowActivity会返回插件专属的Resources,而不是宿主的。这确保了R.layout.xxx引用的是插件自己的布局文件。
与Hook方案的本质区别
说了这么多,让我用一张对比表来总结Shadow代理方案和传统Hook方案的根本差异:
| 维度 | Hook方案 | Shadow代理方案 |
|---|---|---|
| 系统兼容性 | 每版本适配 | 天然兼容 |
| 反射使用 | 大量反射 | 零反射 |
| 开发体验 | 对插件透明 | 需编译期Transform |
| 稳定性 | 取决于Hook点 | 极高(合法调用) |
| Manifest注册 | 不需要(占坑+Hook) | 需预注册壳子 |
| 维护成本 | 高(随系统版本增长) | 低(接近零) |
简单说,Hook方案是"偷偷摸摸地做违规操作,祈祷不被抓",Shadow是"光明正大地在规则内找到了一条别人没想到的路"。代价是编译期多了一步Transform------但这是一次性成本,构建完就完事了。
一些我踩过的坑
实际接入Shadow的时候,纯看原理文档不会告诉你的坑:
坑1:Fragment的Context
Fragment通过requireContext()拿到的是壳子Activity。如果你在Fragment里做context.resources取资源,拿到的是宿主的Resources而不是插件的。Shadow的解决方式是也对Fragment做了Transform------把Fragment替换为ShadowFragment,重写了getResources。
坑2:插件中使用Application
插件代码里调getApplication()或applicationContext,拿到的是宿主的Application。Shadow提供了ShadowApplication------同样通过Transform替换------来模拟插件自己的Application生命周期。但要注意,第三方SDK如果在Application.onCreate里做初始化,你得确保它走的是ShadowApplication的onCreate。
坑3:插件间跳转
如果插件A要跳转到插件B的Activity,Intent里写的是插件B的类名,但Shadow需要把它路由到正确的壳子。这时候就需要一个全局的路由表(Shadow叫ComponentManager),它维护了"插件Activity类名 → 壳子Activity"的映射关系。
小结与下篇预告
今天我们拆解了Shadow最核心的运行时机制------壳子Activity代理。总结起来就三句话:
• 系统只认壳子Activity,壳子是合法注册的,不需要Hook
• 插件Activity在编译期被Transform成ShadowActivity(普通类),运行时通过组合而非继承获得"Activity能力"
• ClassLoader隔离和独立Resources确保插件间不冲突
但我知道你一定有个疑问:编译期的Transform到底做了什么?它是怎么把extends Activity变成extends ShadowActivity的?为什么不能直接改源码而要用字节码操作?
下一篇我们就来深入Shadow Transform的实现------用ASM在字节码层面做手术,这是一种非常有意思的"编译期AOP"思维。到时候我会带着实际的Transform规则和调试技巧,把这个黑盒也给拆开。
本文是「Android插件化:Shadow深度剖析」系列第2篇。上一篇:Android插件化江湖:从DroidPlugin到Shadow的技术演进下一篇:Shadow Transform:编译期的魔法------字节码替换实战