Q1: Shadow是如何实现tinker热修复动态修复资源文件?
不支持tinker一样的差分加载,只支持全量加载!
在Android插件化开发中,资源文件的动态加载一直是一个技术难点。由于Android系统的资源ID在编译时固化,插件APK与宿主APK的资源ID冲突会导致资源加载失败。本文将深入解析腾讯开源的Shadow插件化框架如何通过创新设计解决这一棘手问题。
一、资源冲突的根源:为什么这是个难题?
在传统Android开发中,资源(如layout、drawable、string等)被打包进APK的resources.arsc和res/目录中,运行时通过Resources对象访问。每个资源在编译时被分配一个唯一的ID,格式为0xPPTTEEEE:
- PP(包ID):通常为0x7f表示应用自身资源
- TT(类型ID):表示资源类型(如string、drawable)
- EEEE(条目ID):具体资源条目
问题来了 :当插件APK独立编译时,它的资源ID也是从0x7f开始分配。如果插件和宿主都有R.string.app_name,它们的ID可能都是0x7f010001。当两个APK的资源合并时,系统无法区分应该加载哪个资源。
Shadow 的核心解决思路:资源 ID 物理隔离 + 资源查找逻辑接管
Shadow 的资源加载方案可以拆解为两个关键步骤:
- 编译时资源 ID 强制修改
- 运行时自定义 Resources(MixResources)接管资源查找逻辑
1.1 编译时修改资源包 ID(避免 ID 冲突)
Shadow 通过 Gradle 插件在编译插件 APK 时,强制将插件的资源包 ID(PP 段)改为 0x7E,与宿主的 0x7F 区分开。
实现方式(build.gradle 中):
arduino
aaptOptions {
additionalParameters "--package-id", "0x7E", "--allow-reserved-package-id"
}
--allow-reserved-package-id允许使用系统保留的包 ID 段(通常0x7E不会被系统 App 使用)。
效果对比:
| 模块 | 资源 ID 示例 | 包 ID 段 |
|---|---|---|
| 宿主 APK | 0x7f010001 |
0x7f |
| 插件 APK | 0x7e020005 |
0x7e |
✅ 物理层面彻底避免 ID 冲突
1.2 运行时资源加载:MixResources 双资源兜底机制
虽然 ID 不再冲突,但插件可能仍需要复用宿主资源 (例如公共样式、主题、字符串)。Shadow 并没有简单隔离,而是实现了 "优先插件,降级宿主" 的查找策略。
核心类:MixResources
它继承自 Resources,内部持有两个 Resources 对象:
pluginResources:插件自身的 AssetManager 构建的 ResourceshostResources:宿主的 Resources(通过宿主 Context 获取)
核心逻辑伪代码:
scala
public class MixResources extends Resources {
private Resources pluginResources;
private Resources hostResources;
@Override
public String getString(int id) throws NotFoundException {
try {
// 1. 优先从插件资源中查找
return pluginResources.getString(id);
} catch (NotFoundException e) {
// 2. 插件中找不到,降级到宿主资源
return hostResources.getString(id);
}
}
// 同样重写 getDrawable, getText, getColor 等方法
}
运行时工作流程:

✅ 既避免冲突,又能复用宿主资源,提升插件包体积与兼容性
整体的流程图:

二、Shadow资源加载整体架构
2.1 Android资源系统回顾
Android资源加载的核心是Resources和AssetManager:
Resources:资源管理器,提供资源访问APIAssetManager:实际加载资源的底层组件- 资源ID结构:
0xPPTTEEEE(包ID+类型ID+条目ID)
2.2 Shadow的资源加载设计
Shadow采用"资源代理与重定向"的方案,其设计哲学是代理与路由:
- 代理 :所有插件对
Resources和Context的访问,都被一个中间层(如ShadowContext,MixResources)代理 - 路由:中间层根据资源ID、API版本或自定义策略,决定将请求路由到插件自身的资源系统还是宿主的资源系统
2.3 核心组件协作架构

关键流程:
插件APK加载 → 根据Android版本选择策略 → 创建插件Resources → 替换Context的Resources
2.4 关键设计决策
- 编译时隔离:插件使用独立的包名(或资源包ID)编译,生成仅包含自身资源ID的R类
- 运行时双轨制 :
- 高版本(API 27+):利用Android原生资源分区机制
- 低版本(API ≤25):通过自定义
MixResources实现运行时优先查找
- 访问控制:默认禁止插件访问宿主资源,需要共享时通过显式、受控的接口进行
三、Shadow如何解决资源冲突问题
3.1 资源访问机制:如何访问插件资源
一个关键问题:Shadow是否使用AssetManager?
答案是:Shadow使用Resources作为主要接口,而Resources内部管理着AssetManager。在Shadow的设计中,插件通过Resources对象访问资源,而这个Resources对象内部封装了正确的AssetManager。
java
public class ShadowContext extends SubDirContextThemeWrapper {
PluginComponentLauncher mPluginComponentLauncher;
ClassLoader mPluginClassLoader;
ShadowApplication mShadowApplication;
Resources mPluginResources;
LayoutInflater mLayoutInflater;
ApplicationInfo mApplicationInfo;
protected String mPartKey;
private String mBusinessName;
@Override
public Resources getResources() {
return mPluginResources;
}
@Override
public AssetManager getAssets() {
return mPluginResources.getAssets(); // 从Resources获取AssetManager
}
}
关键点 :Shadow框架不直接暴露AssetManager给插件开发者,而是通过Resources对象统一管理资源访问。插件代码通过context.getResources()获取的已经是封装好的插件专用Resources。
3.2 资源加载流程:三步走策略

Shadow的资源加载遵循简洁而高效的三步流程:
第1步:入口启动
scss
LoadPluginBloc.loadPlugin()
↓
创建 Resources 构建任务
↓
调用 CreateResourceBloc.create()
第2步:资源创建
scss
CreateResourceBloc.create()
↓
根据 Android 版本选择策略:
├─ 高版本 (API 27+) → 资源分区方案
└─ 低版本 (API ≤25) → MixResources方案
↓
创建 Resources 对象
第3步:资源使用
markdown
Resources 存入 PluginParts
↓
插件 Activity.getResources()
↓
根据 API 版本访问:
├─ 高版本 → 系统自动路由
└─ 低版本 → MixResources 代理访问
一句话总结 : LoadPluginBloc 启动 → CreateResourceBloc 根据 Android 版本创建不同策略的 Resources → 插件通过 Context.resources 访问
3.3 具体实现细节
3.3.1 插件Resources的创建时机
kotlin
// 在LoadPluginBloc.loadPlugin中并行创建
val buildResources = executorService.submit(Callable {
CreateResourceBloc.create(installedApk.apkFilePath, hostAppContext)
})
3.3.2 Resources绑定到插件Activity

kotlin
// ShadowActivityDelegate中插件Activity创建流程
class ShadowActivityDelegate {
fun onCreate(savedInstanceState: Bundle?) {
// 1. 创建插件Activity实例
val pluginActivity = createPluginActivity()
// 2. 获取插件Application(包含Resources)
val pluginApplication = getPluginApplication()
// 3. 创建插件的BaseContext
val pluginBaseContext = ShadowContext(pluginApplication)
// 4. 为插件Activity设置BaseContext
pluginActivity.attachBaseContext(pluginBaseContext)
// 这个BaseContext的getResources()返回插件Resources
}
}
// ShadowContext中资源访问
class ShadowContext : ContextWrapper {
override fun getResources(): Resources {
// 返回插件专属的Resources
return pluginApplication.resources
}
}
3.3.3 NativeActivity的特殊处理
对于使用NativeActivity的场景,Shadow也有专门的处理流程:
ShadowPluginLoader
↓
ShadowNativeActivityDelegate
↓
PackageManagerWrapper
PackageManagerWrapper负责包装宿主的PackageManager,确保插件在访问包信息时不会出现问题。
3.4 资源ID冲突问题的双轨解决方案

Shadow通过双轨策略解决资源ID冲突问题,根据Android版本采用不同方案:
方案一:高版本(API ≥ 27)的资源分区隔离
kotlin
// 在 CreateResourceBloc 中
private fun fillApplicationInfoForNewerApi(
applicationInfo: ApplicationInfo,
hostApplicationInfo: ApplicationInfo,
pluginApkPath: String
) {
// 🔥 关键:宿主和插件分配到不同资源分区
applicationInfo.sourceDir = hostApplicationInfo.sourceDir // 宿主:0x7f 分区
applicationInfo.sharedLibraryFiles = arrayOf(pluginApkPath) // 插件:≥0x80 分区
}
工作原理:
ini
宿主资源 ID: 0x7f010001 (包ID=0x7f)
插件资源 ID: 0x80010001 (包ID=0x80 或更高)
↑
分区不同,不会冲突
优势:
- ✅ 物理隔离:系统自动维护分区,彻底避免冲突
- ✅ 性能最佳:资源查找由系统直接路由
- ✅ 类似动态功能模块:与 Android App Bundle 原理相同
方案二:低版本(API ≤ 25)的运行时优先查找
kotlin
// MixResources 的核心查找逻辑
private fun <R> tryMainThenShared(function: (res: Resources) -> R) = try {
function(mainResources) // 1. 先查插件(mainResources)
} catch (e: NotFoundException) {
function(sharedResources) // 2. 找不到再查宿主(sharedResources)
}
工作原理:
scss
假设宿主和插件都有 R.string.app_name (0x7f010001)
插件调用 getString(0x7f010001):
1. 先在插件 Resources 中查找 → 找到插件的 "PluginApp"
2. 如果找不到,才会回退到宿主 Resources
结果是:插件始终使用自己的资源,即使 ID 相同
3.5 编译时配合方案
3.5.1 高版本编译配置
groovy
// 插件 build.gradle 配置
android {
// 为插件分配独立的资源包 ID(如 0x80)
// 编译时自动生成对应的 R.java
// 确保插件资源 ID 在 0x80xxxxxx 范围
aaptOptions {
additionalParameters "--package-id", "0x80",
"--allow-reserved-package-id"
}
}
3.5.2 低版本编译配置
groovy
// 插件 build.gradle 配置
android {
// 资源 ID 仍然使用 0x7f 开头
// 但通过 aapt 参数可以调整 ID 分配
// Shadow 通过运行时策略解决冲突
// 方案1:使用资源前缀
resourcePrefix "plugin_"
// 方案2:重命名资源文件
// 如:宿主用 home_icon.png,插件用 plugin_home_icon.png
}
四、插件如何访问宿主资源?
默认情况下,Shadow设计为插件不能访问宿主资源,以保证隔离性。但确实有共享需求时,可通过以下方式:
方式1:通过getIdentifier()动态获取(推荐)
kotlin
// 在插件代码中
// 注意:Shadow中插件Context的包名 = 宿主包名
val hostPackageName = context.packageName
val hostStringId = resources.getIdentifier(
"host_common_string", // 资源名称
"string", // 资源类型
hostPackageName // 包名(宿主)
)
if (hostStringId != 0) {
val text = getString(hostStringId)
// 使用宿主字符串
}
方式2:宿主暴露接口(更安全可控)
kotlin
// 宿主定义共享接口
interface HostResourceProvider {
fun getCommonString(key: String): String?
fun getCommonDrawable(key: String): Drawable?
// 仅暴露允许共享的资源
}
// 插件通过约定方式获取接口实例
val provider = getSystemService("host_resource") as HostResourceProvider
val text = provider.getCommonString("welcome")
五、完整解决方案对比
| 方案 | 核心原理 | 适用版本 | 优点 | 缺点 |
|---|---|---|---|---|
| 资源分区 | 宿主和插件使用不同的资源包ID | API ≥ 27 | 1. 彻底隔离 2. 性能最佳 3. 系统原生支持 | 1. 需要高版本系统 2. 编译配置复杂 |
| MixResources | 运行时"插件优先"查找策略 | API ≤ 25 | 1. 兼容低版本 2. 无需特殊编译 3. 宿主回退机制 | 1. 性能损耗 2. 重写大量方法 3. 可能误覆盖宿主资源 |
六、实际应用场景
Shadow的资源方案支持以下场景:
- UI热更新:紧急修复布局错位、文案错误,无需发版
- 多语言动态下发:语言包不随主包发布,按需加载
- A/B测试资源:不同用户组看到不同的界面资源
- 业务模块独立:每个插件模块可以独立更新UI资源
- 主题动态切换:插件可以自带主题,动态更换
七、优势与局限
✅ 优势
- 无反射、无Hook:兼容Android高版本,不被系统限制
- 资源隔离清晰:插件与宿主资源互不干扰
- 双轨策略智能切换:根据系统版本自动选择最佳方案
- 性能影响小:高版本下几乎零损耗,低版本下可控
⚠️ 注意事项
- 编译配置:高版本需要特殊的编译配置
- 资源访问限制:默认禁止插件访问宿主资源,需要显式声明
- 版本兼容:需要为不同Android版本测试
- 插件大小:每个插件需要包含自己的资源,可能增加体积
八、总结
Shadow通过巧妙的双轨策略解决了Android插件化中的资源冲突难题:
- 在高版本系统上,利用Android原生的资源分区机制,实现物理隔离
- 在低版本系统上 ,通过自定义的
MixResources实现运行时优先查找
这种设计体现了优秀的工程思想:在系统提供的边界内创新,不依赖危险的黑科技。通过编译时、运行时和架构设计的综合方案,Shadow为Android插件化提供了一个稳定、可靠、高性能的资源解决方案。
对于正在探索插件化、热修复或动态化方案的团队,Shadow的资源设计思路值得深入研究和借鉴。它告诉我们:解决复杂问题不一定需要复杂的技术,有时巧妙的设计比激进的黑客手段更有效、更持久。
扩展思考:随着Android系统的发展,特别是Android App Bundle和动态功能模块的普及,插件化的资源管理会越来越简单。但在相当长的时间内,兼容老版本的需求仍然存在,Shadow的双轨策略为我们提供了很好的过渡方案。