写在前面
Shadow是非常强,之前的博客也介绍了很多! 这次完整的实战
两年前,我们团队满怀激情地引入了腾讯 Shadow 插件化框架,信心满满地要在双11大促中大展拳脚。当时的想法很简单:活动页面上线再也不用等应用商店审核,老用户也能第一时间体验新功能,包体积也能控制住。
"Shadow 真香!"是我们当时的一致评价。
然而,经过两年真实业务场景的迭代,我们逐渐发现了一些当初文档里没写到、社区里没人提过的"坑"。多插件相互依赖像一团乱麻,代码调试如同大海捞针,插件化与组件化共存的架构让人头秃,宿主与插件之间、插件与插件之间的通信写接口写到怀疑人生。
今天,我就把这 2 年踩过的坑、总结的经验,以双11电商业务为背景,完整地分享给大家。希望能让后来者少走一些弯路。比如下面的:
1).多插件的相互依赖
2).代码的调试
3).插件化和组件化的一起使用,架构的修改
4).复杂的业务,插件和宿主,宿主和插件的通信,要写很多接口,需要把协议转义
1. 业务背景
一个完整的电商双11大促插件化系统:
业务场景:双11当天0点,需要上线全新的「限时秒杀」活动页面,包含倒计时、库存秒杀、弹幕互动等复杂功能。传统发版需等待应用商店审核,而插件化可以实现:
- 11月10日23:50 提前预加载秒杀插件
- 11月11日0:00 立即激活秒杀页面
为啥我的电商项目用插件化?
- 常规发版需要7-15天审核周期,错过营销时机 经常有很多活动
- 老用户无法体验最新的功能,必须发版本,强制升级才行! 但有的用户不喜欢强制升级!
- 包体积膨胀:每次新增功能都增加APK体积,影响下载转化率
- 灵活性差:无法针对不同用户群体展示不同购物流程
2. 详细的双11电商需求 (Shadow使用的10大核心需求)
2.1. 项目效果图

2.2. 详细的功能清单列表
| 需求 | 业务场景 | 技术难点与解决方案 |
|---|---|---|
| 1. 加载双11插件 | 点击Tab或App启动时加载活动页面 | 核心:通过 DynamicPluginManager.enter() 动态加载 |
| 2. 插件崩溃处理 | 活动插件崩溃,不能影响主App | 核心:进程隔离 + 自动回退/降级策略,实现用户级别灰度 |
| 3. 卸载插件 | 活动结束,释放本地磁盘空间 | 核心:PPSController.exit() + 删除文件 + 清理数据库记录 |
| 4. 加载Activity | 点击商品,跳转插件内的详情页 | 核心:插件内Activity继承 PluginContainerActivity,宿主注册容器 |
| 5. 加载Fragment | 首页Tab嵌入活动页 | 核心:宿主通过插件的ClassLoader加载Fragment实例并注入 |
| 6. 宿主↔插件通信 | 获取用户信息、同步购物车 | 核心:定义公共接口,通过Bundle传递Binder或直接通过ClassLoader调用 |
| 7. 插件↔插件通信 | 双11插件添加商品,通知购物车插件 | 核心:通过Loader作为服务注册中心,利用Binder跨ClassLoader调用 |
| 8. 热更新策略 | 多插件版本管理、增量更新 | 核心:Manager负责版本检测、依赖排序、下载安装,实现全动态化 |
| 9. 热修复类/资源 | 修复优惠券计算逻辑、替换侵权图片 | 核心:本质是插件全量更新,Manager下载新版插件包替换旧版 |
| 10. 热修复SO库 | 替换有崩溃的图片加载SO库 | 核心:同样是插件全量更新,重启插件进程后加载新SO库 |
3. 电商App的架构
双11,新增了一个双11tab的入口
架构对比:常规 vs. 双11
常规架构(发版固定)
首页 ------▶ 分类 ------▶ 购物车 ------▶ 我的
(Native) (Native) (Native) (Native)
双11架构(动态插入)
scss
【新增插件化入口】
↑
首页 ------▶ 双11 ------▶ 分类 ------▶ 购物车 ------▶ 我的
(Native) (插件) (Native) (Native) (Native)
插件化实现
3.1. 模块划分图
宿主:Host 插件: 首页, 分类,购物,我的,新增双11主会场
scss
电商主APP (宿主)
├── 插件管理层 (插件Manager)
├── Shadow引擎层(宿主)
└── 核心业务插件 (插件)
├── 首页
├── 分类
├── 购物车
├── 我的
└── 双11插件(shadow)
3.2. 整体的架构是怎么样的?分层 (需要修改插件)
bash
Root project 'ATaoDuoduoShadow'
+--- Project ':app' # 宿主应用
+--- Project ':introduce-shadow-lib' # 宿主的Shadow核心库
+--- Project ':plugin-app' # 双11插件应用(包含秒杀、弹幕等复杂功能)
+--- Project ':sample-loader' # Shadow插件加载器(负责加载插件APK)
+--- Project ':sample-manager' # Shadow插件管理器(管理插件生命周期、版本)
+--- Project ':sample-runtime' # Shadow运行时环境(提供插件运行所需组件)
\--- Project ':shadow_common' # shadow 公共库(宿主与插件共享的接口)
3.3. 整体业务流程图
宿主加载插件,对于上面流程图的调用

4. 项目搭建过程:集成shadow
考虑下面2个问题
Q1: shadow框架中,插件 能不能依赖host,如果不能依赖,那么很多插件之间使用到的公共库要各自自己引入吗? 插件一般是依赖宿主的
shadow插件化Maven的封装思想:(比较科学) 用这个插件和宿主,公共的模块怎么共享 依赖关系不要错了: 插件依赖宿主工程!
Q2: shadow插件化源码的封装思想:(太复杂了,代码量大的惊人)
项目中通过maven集成shadow的步骤
4.1 宿主工程搭建---APP-project:
用的1.8的jdk进行编译的!
用17编译,然后项目的的gradle版本是这个!
arduino
classpath 'com.android.tools.build:gradle:7.0.2'
1.已经有了一个项目 2.集成host工程 拷贝introduce-shadow-lib
在host工程中添加依赖:
java
implementation project(':introduce-shadow-lib')
//如果introduce-shadow-lib发布到Maven,在pom中写明此依赖,宿主就不用写这个依赖了。
implementation "com.tencent.shadow.dynamic:host:$shadow_version"
把类拷贝过来:MyApplication
4.2 主工程集成插件:插件集成shadow
插件工程创建,和宿主放在一起,然后插件中依赖shadow 插件里面的这2个类是干嘛的? runtime和loader
php
include ':sample-runtime'
include ':sample-loader'
用到的是application,而不是libray
arduino
applicationId "com.tencent.shadow.sample.loader"//applicationId不重要
这3个apk是的关系是怎么样的? 如何相互工作,相互调用的
1).创建插件的主工程,在一个项目中
编译项目: 插件:
./gradlew packageDebugPlugin
4.3 主工程集成Manager插件,Manager集成shadow
manager:
cd manager-project ./gradlew assembleDebug
4.4 运行主工程,加载插件逻辑
这个流程和之前博客写的是一样的,push apk,安装apk
主宿中用插件一样的包名,否则报一下的错! 出现多实例,出现包名不匹配的提示!


5. 具体完整的10项需求的源码和实战方案
5.1. 如何加载一个双11活动插件,双11限时秒杀活动
业务场景:双11当天0点,需要上线全新的「限时秒杀」活动页面,包含倒计时、库存秒杀、弹幕互动等复杂功能
核心原理 :宿主不直接加载业务插件,而是加载 Manager。Manager 通过 DexClassLoader 反射调用 ManagerFactoryImpl,获取 PluginManager 实例,再由该实例去加载具体的业务插件。
5.1.1 打包manager.zip
这个manger是在apk中,那怎么改变manger
DynamicPluginManager SamplePluginManager: 是怎么加载的?
Shadow 官方推荐使用 PluginContainerActivity 的方式:
关键的问题: 1.怎么使用manager中的SamplePluginManager!!! (搞定)
关键原理:宿主如何"调用" SamplePluginManager?
宿主 并不直接引用 SamplePluginManager,而是:
DynamicPluginManager读取 ZIP 中的manager.apk- 用
DexClassLoader加载manager.apk - 反射调用:
ini
Class<?> factoryClass = classLoader.loadClass("com.tencent.shadow.dynamic.impl.ManagerFactoryImpl");
ManagerFactory factory = (ManagerFactory) factoryClass.newInstance();
PluginManagerImpl manager = factory.buildManager(context);
DynamicPluginManager,里面调用了实现
scss
public void enter(Context context, long fromId, Bundle bundle, EnterCallback callback) {
if (mLogger.isInfoEnabled()) {
mLogger.info("enter fromId:" + fromId + " callback:" + callback);
}
updateManagerImpl(context);
mManagerImpl.enter(context, fromId, bundle, callback);
mUpdater.update();
}
FastPluginManager的源码需要多看看!
5.1.2 打包双11的APK
打包插件
ruby
# 编译并打包插件
./gradlew :plugin-double11:packageDebugPlugin
# 输出文件位置
# plugin-double11/build/outputs/plugin/debug/plugin-double11-debug.zip
5.1.3 插件配置
css
// plugin-double11/src/main/assets/config.json
{
"partKey": "double11",
"version": 1,
"uuid": "double11-group",
"hostWhiteList": [
"com.double11.app.MainActivity",
"com.double11.app.CartActivity"
],
"dependencies": []
}
5.1.4 宿主中的加载插件的核心逻辑
typescript
// 宿主:Double11PluginLoader.java
public class Double11PluginLoader {
private DynamicPluginManager mPluginManager;
public void loadDouble11Plugin() {
// 1. 获取插件管理器
mPluginManager = (DynamicPluginManager)
Shadow.getPluginManager();
// 2. 构建加载参数
Bundle bundle = new Bundle();
bundle.putString("activityClassName",
"com.double11.plugin.double11.FlashSaleActivity");
bundle.putString("pageTitle", "双11限时秒杀");
// 3. 加载并启动插件
mPluginManager.enter(
getApplicationContext(),
1001, // fromId
bundle,
new EnterCallback() {
@Override
public void onSuccess() {
Log.d("Double11", "插件加载成功");
}
@Override
public void onError(String msg) {
Log.e("Double11", "插件加载失败: " + msg);
// 降级到H5页面
loadH5Fallback();
}
}
);
}
private void loadH5Fallback() {
Intent intent = new Intent(this, WebViewActivity.class);
intent.putExtra("url", "https://m.double11.com/flashsale");
startActivity(intent);
}
}
5.2. 插件奔溃,怎么处理,双11活动插件有奔溃问题
业务场景:双11活动插件出现崩溃,不能影响主 App 使用。
核心原则:插件崩溃不影响宿主,并支持自动恢复或降级。
核心设计原则:
- 插件崩溃不影响宿主
- 版本更新(插件升级)
- 支持用户级别降级(插件降级)
- 异常自动上报,方便定位问题
说白了就是:强制更新插件和强制回退插件
崩溃处理与插件更新的关系图
scss
┌─────────────────────────────────────────────────────────────────┐
│ 用户使用插件 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ 发生崩溃 │
└─────────────────┘
│
▼
┌───────────────────────────────┐
│ PluginCrashHandler │
│ 记录崩溃信息并上报 │
└───────────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 偶发崩溃 │ │ 版本Bug │ │ 严重问题 │
│ (1-2次) │ │ (3次以上) │ │ (崩溃率>10%) │
└───────────────┘ └───────────────┘ └───────────────┘
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 重启插件 │ │ 检查更新 │ │ 回退版本 │
│ 清理现场 │ │ 下载新版本 │ │ 使用旧版本 │
└───────────────┘ └───────────────┘ └───────────────┘
│ │ │
└─────────────────────┼─────────────────────┘
▼
┌─────────────────┐
│ 继续使用 │
└─────────────────┘
5.3. 如何卸载插件?
业务场景:11月12日00:00活动结束:
空间回收:删除临时插件,释放50-100MB存储空间,双11活动结束,插件在本地暂用磁盘
插件卸载 - 核心实现
流程图
scss
┌─────────────────────────────────────────────────────────────┐
│ 双11活动结束 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ markForUninstall("double11") │
│ 标记待卸载 + 延迟7天 │
└───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 7天后自动触发 │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 1.停止插件 │ │ 2.删除文件 │ │ 3.清理记录 │
│ exit() │──▶│ deleteRecursive│──▶│ clearDB() │
└───────────────┘ └───────────────┘ └───────────────┘
│
▼
┌─────────────────┐
│ 释放空间 │
│ ~50-100MB │
└─────────────────┘
核心代码
scss
// PluginUninstallManager.java - 核心卸载逻辑
public class PluginUninstallManager {
/**
* 卸载插件(核心3步)
*/
public static void uninstall(String partKey) {
// 1. 停止插件进程
Shadow.getPPSController().exit(partKey);
// 2. 删除插件文件
File pluginDir = new File(context.getFilesDir(), "ShadowPlugin/" + partKey);
deleteFile(pluginDir);
// 3. 清理数据库记录
Shadow.getInstalledDao().deleteByPartKey(partKey);
Log.d("Uninstall", "插件已卸载: " + partKey);
}
// 递归删除文件/文件夹
private static void deleteFile(File file) {
if (file == null || !file.exists()) return;
if (file.isDirectory()) {
File[] children = file.listFiles();
if (children != null) {
for (File child : children) {
deleteFile(child);
}
}
}
file.delete();
}
/**
* 延迟卸载(活动结束7天后)
*/
public static void scheduleUninstall(String partKey, long endTime) {
long delay = endTime + 7 * 24 * 3600 * 1000 - System.currentTimeMillis();
if (delay <= 0) {
// 已过期,立即卸载
uninstall(partKey);
} else {
// 延迟卸载
new Handler().postDelayed(() -> uninstall(partKey), delay);
}
}
}
5.4. 如何加载插件中新增一个Activity,双11活动插件
业务场景:点击双11tab,也就是fragment页面,里面的案例,进入到双11的商品详细页面
核心原理 :Shadow通过 PluginContainerActivity 作为"占坑"Activity,在运行时将插件Activity的代码"注入"进来。
实现步骤:
- 插件内定义Activity :继承
PluginContainerActivity。 - 宿主Manifest注册 :注册
com.tencent.shadow.core.runtime.PluginContainerActivity。 - 跳转 :在插件内使用标准的
Intent进行跳转。
插件 Activity 加载 - 核心代码
流程图
scss
用户点击商品
│
▼
┌─────────────────────────────────────────┐
│ 宿主 Fragment 中点击商品 │
│ (双11插件内的 Fragment) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 跳转到插件的 Activity │
│ Intent(宿主包名, 插件Activity类名) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Shadow 自动处理 │
│ Activity → PluginContainerActivity │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 显示商品详情页 │
│ (插件内的 Activity) │
└─────────────────────────────────────────┘
核心代码
5.4.1 插件中定义 Activity
scala
// 插件工程:ProductDetailActivity.java
package com.double11.plugin;
public class ProductDetailActivity extends PluginContainerActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_product_detail);
// 获取商品ID
String productId = getIntent().getStringExtra("product_id");
// 加载商品详情
loadProductDetail(productId);
}
private void loadProductDetail(String productId) {
// 插件内的业务逻辑
TextView title = findViewById(R.id.tv_title);
title.setText("双11秒杀商品: " + productId);
}
}
5.4.2 插件中跳转 Activity
scala
// 插件工程:FlashSaleFragment.java
public class FlashSaleFragment extends Fragment {
private void onProductClick(String productId) {
// 方法1:标准 Intent 跳转(推荐)
Intent intent = new Intent(getActivity(), ProductDetailActivity.class);
intent.putExtra("product_id", productId);
startActivity(intent);
// 方法2:通过类名跳转
// Intent intent = new Intent();
// intent.setClassName(getActivity(),
// "com.double11.plugin.ProductDetailActivity");
// intent.putExtra("product_id", productId);
// startActivity(intent);
}
}
5.4.3 宿主动态加载插件 Fragment
scala
// 宿主工程:MainActivity.java
public class MainActivity extends AppCompatActivity {
// 点击双11 Tab,加载插件的 Fragment
private void loadDouble11Fragment() {
try {
// 1. 获取插件 ClassLoader
PluginParts parts = Shadow.getPluginParts("double11");
ClassLoader classLoader = parts.getClassLoader();
// 2. 加载 Fragment 类
Class<?> fragmentClass = classLoader.loadClass(
"com.double11.plugin.FlashSaleFragment"
);
// 3. 创建实例
Fragment fragment = (Fragment) fragmentClass.newInstance();
// 4. 添加到宿主 Activity
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.container, fragment)
.commit();
} catch (Exception e) {
e.printStackTrace();
// 降级到 H5
loadH5Page();
}
}
}
AndroidManifest 配置
5.4.4 插件 Manifest(无需注册 Activity)
xml
<!-- 插件工程:AndroidManifest.xml -->
<manifest package="com.double11.plugin">
<application>
<!-- Shadow 会自动处理 Activity,无需手动注册 -->
<!-- ProductDetailActivity 不需要在这里声明 -->
</application>
</manifest>
5.4.5 宿主 Manifest(注册插件容器)
xml
<!-- 宿主工程:AndroidManifest.xml -->
<manifest package="com.double11.app">
<application>
<!-- 注册 Shadow 容器 Activity(必须) -->
<activity android:name="com.tencent.shadow.core.runtime.PluginContainerActivity"
android:configChanges="orientation|screenSize"
android:theme="@style/PluginTheme" />
</application>
</manifest>
关键点总结
| 要点 | 说明 | 代码 |
|---|---|---|
| Activity 定义 | 继承 PluginContainerActivity |
extends PluginContainerActivity |
| 无需注册 | 插件 Manifest 不需要注册 Activity | 自动处理 |
| 跳转方式 | 标准 Intent | new Intent(context, TargetActivity.class) |
| 宿主配置 | 注册容器 Activity | PluginContainerActivity |
5.5. 如何加载新增的Fragment,双11活动插件?(非常重要)
核心挑战 :Fragment不是Shadow的一等公民,需要宿主主动获取 解决方案:
- 获取插件的ClassLoader :通过
Shadow.getPluginParts(partKey).getClassLoader()获取。 - 反射创建Fragment :
clazz.newInstance()。 - 将Fragment添加到宿主Activity :使用
FragmentManager的replace方法
页面就是:DoubleElevenFragment
具体方案有如下3种:
-
通过插件提供的
Activity -
如果插件没用Activity,宿主怎么启动插件的fragment 通过插件暴露的"工厂接口"或"服务"来获取 Fragment 实例
第一步:定义公共接口(放在宿主和插件都能引用的模块)
-
宿主通过插件ClassLoader加载Fragment类,创建实例,然后将插件的Context注入到Fragment中,最后将Fragment添加到宿主Activity的Fragment容器里。
- Fragment 并不是 Shadow 的一等公民,没有像 Activity 那样的自动代理和生命周期接管。
- 因此,即使你手动加载 Fragment,它的
getActivity()、getContext()、资源访问等行为都可能出错。
我们采用第三种方案:
5.5.1. 宿主怎么获取插件的ClassLoader,加载插件的fragment(重点)
需要自定义shadow,修改它的源码,然后发布到自己的maven! 提供一个共同的方法,插件的ClassLoader的获取
模仿的Shadow源码中的SamplePluginLoader!
先得到PluginParts
ini
PluginParts pluginParts = getPluginParts(partKey);
String packageName = pluginParts.getApplication().getPackageName();
ApplicationInfo applicationInfo = pluginParts.getPluginPackageManager().getApplicationInfo(packageName, GET_META_DATA);
PluginClassLoader classLoader = pluginParts.getClassLoader();
Resources resources = pluginParts.getResources();
5.5.2. 通过宿主获取插件的ClassLoader,加载插件的fragment
ini
// 获取插件的 ClassLoader
public ClassLoader getPluginClassLoader(String partKey) {
try {
// 1. 获取 PluginParts
PluginParts pluginParts = Shadow.getPluginParts(partKey);
// 2. 获取 ClassLoader
ClassLoader classLoader = pluginParts.getClassLoader();
return classLoader;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
// 使用示例
ClassLoader loader = getPluginClassLoader("double11");
Class<?> clazz = loader.loadClass("com.double11.plugin.FlashSaleFragment");
Fragment fragment = (Fragment) clazz.newInstance();
5.5.3 更可行的方案:宿主与插件共享同一个进程(关键!)
在 宿主与插件运行于同一个进程 的前提下(即 Shadow 配置为同进程加载),
pluginCl必须使用插件的ClassLoader,而不是宿主的ClassLoader。
自定义 PluginLoader 获取 ClassLoader (重要)
如果你确实需要在宿主中获取插件 ClassLoader,需要自定义 PluginLoader:
方法一:反射
如果是用fragment , 用Activity跳转,那么会有很大的问题!
用腾讯shadow插件化加载fragment, 一个宿主,主Activity,4个tab页面,3个页面是fragment。另外一个tab,点击也是加载fragment,放在插件里面,那么这个宿主如何加载这个fragment,
定义宿主 Fragment 创建一个空的 PlaceholderFragment,用于占位。
宿主和插件依赖问题,他们都有相同的依赖,会不会加载有问题!
Android shadow用maven方式,有host-project, plugin-project, 现在我要在host-project中直接加载plugin-project的fragment,通过接口在common模块,宿主和插件都依赖它,IPluginUiProvider! 具体怎么做?host-project和plugin-project是2个独立的工程
1.这是最标准的做法,将 common 模块发布到 Maven 仓库,两个工程都从 Maven 依赖。
- 配置就会导致能看到它上面的一层结构!
php
include ':common'
project(':common').projectDir = new File('../common')
Q1: 不知道其他人有没有更好的办法获取ClassLoader!
5.6. 宿主与插件之间如何高效通信?
插件与宿主通信:同一个进程,通过预定义接口(Service、Callback)实现双向调用
5.6.1. 插件获取宿主信息:(用户信息:插件需要获取登录状态、收货地址)
方案一:通过插件加载时的Bundle参数传递
在加载插件时,通过Bundle传递宿主API的Binder。
java
// 宿主工程: PluginManager.java
public class PluginManager {
public void loadPluginWithHostApi(String partKey, IHostApi hostApi) {
Bundle extras = new Bundle();
extras.putBinder("host_api_binder", hostApi.asBinder());
// Shadow加载插件时传入extras
mPluginLoader.loadPlugin(partKey, extras);
}
}
// Loader插件工程: SamplePluginLoader.java
public class SamplePluginLoader implements PluginLoader {
@Override
public void loadPlugin(String partKey, Bundle extras) {
// 保存extras中的host_api_binder
if (extras != null) {
IBinder hostApiBinder = extras.getBinder("host_api_binder");
if (hostApiBinder != null) {
setHostApi(partKey, hostApiBinder);
}
}
// 继续正常的加载流程
}
}
方案二:官方推荐的标准模式是:"接口回调"(Callback / Dependency Injection)。
核心思路
- 定义接口 :在共享模块(Host 和 Plugin 都依赖的 module)中定义一个接口,描述宿主需要提供的能力。
- 宿主实现:宿主工程实现这个接口,提供具体逻辑。
- 传递接口 :宿主在加载插件或初始化插件时,将实现类的实例传递给插件。
- 插件调用:插件持有该接口引用,直接调用方法。
方案:接口回调模式(官方推荐)
通过公共模块定义接口,宿主实现并传递给插件。
核心代码
5.6.1.1 公共模块定义接口 (shadow-common)
arduino
// IHostInfoProvider.java
public interface IHostInfoProvider {
String getLoginToken();
UserInfo getUserInfo();
Address getDefaultAddress();
}
// 数据Bean
public class UserInfo implements Serializable {
public String userId;
public String nickName;
public boolean isVip;
}
public class Address implements Serializable {
public String receiverName;
public String phone;
public String fullAddress;
}
5.6.1.2 宿主实现接口
ini
// HostInfoProviderImpl.java
public class HostInfoProviderImpl implements IHostInfoProvider {
@Override
public String getLoginToken() {
return UserManager.getInstance().getToken();
}
@Override
public UserInfo getUserInfo() {
User user = UserManager.getInstance().getCurrentUser();
UserInfo info = new UserInfo();
info.userId = user.getId();
info.nickName = user.getName();
info.isVip = user.isVip();
return info;
}
@Override
public Address getDefaultAddress() {
AddressEntity addr = AddressManager.getInstance().getDefaultAddress();
Address address = new Address();
address.receiverName = addr.getName();
address.phone = addr.getPhone();
address.fullAddress = addr.getFullAddress();
return address;
}
}
5.6.1.3 加载插件时传递接口实例
java
// 宿主:Double11PluginLoader.java
public void loadDouble11Plugin() {
// 创建接口实例
IHostInfoProvider hostApi = new HostInfoProviderImpl();
// 通过Bundle传递(需实现Parcelable)
Bundle bundle = new Bundle();
bundle.putParcelable("host_api", (Parcelable) hostApi);
mPluginManager.enter(context, 1001, bundle, callback);
}
5.6.1.4 插件接收并使用
scss
// 插件:FlashSaleActivity.java
public class FlashSaleActivity extends PluginContainerActivity {
private IHostInfoProvider mHostApi;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 获取宿主API
Bundle bundle = getIntent().getExtras();
if (bundle != null) {
mHostApi = bundle.getParcelable("host_api");
}
// 使用宿主信息
loadUserInfo();
}
private void loadUserInfo() {
if (mHostApi == null) return;
// 获取登录信息
String token = mHostApi.getLoginToken();
if (TextUtils.isEmpty(token)) {
// 未登录,跳转登录页
showLoginDialog();
return;
}
// 获取用户信息
UserInfo user = mHostApi.getUserInfo();
tvNickName.setText(user.nickName);
// 获取默认地址
Address address = mHostApi.getDefaultAddress();
tvAddress.setText(address.fullAddress);
}
}
5.6.2. 宿主获取插件信息:(购物车同步:插件添加商品,宿主购物车实时更新)
这个就像是跳转Activity一样的原理!
宿主获取插件的方法
5.6.2.1 优化一:将自定义方法加入预定义的Loader接口
你之前的做法是通过反射去调用Loader中新增的getPluginApi方法。这在Java中是一种"侵入式"的调用,不够稳健。更好的做法是让Loader实现一个预定义的、通用的Binder接口。
- 原理 :Shadow的
PluginLoader本身就是一个跨进程的Binder对象。你可以定义一个通用的Binder接口(例如IBinderLoader),并让Loader的实现类(如SamplePluginLoader)去实现它。 - 好处 :宿主可以通过
BinderPluginLoader(Loader的Binder代理)直接查询这个通用接口,然后安全地转换成你的IPluginApi。这样就完全避免了反射,利用的是Binder自带的queryLocalInterface机制,代码更加健壮。
5.6.2.2 优化二:通过插件Service方式暴露接口(更推荐)
如果你的插件功能比较复杂,需要处理多个请求或长时间任务,那么将其设计为一个"插件Service"是Shadow官方思路中最标准的方式。
- 原理 :Shadow的Loader本身也是一个动态的Service。你可以在插件中创建一个自定义的Binder(继承自
IPluginApi.Stub),然后在Loader中通过bindPluginService的方式将这个Binder返回给宿主。 - 好处 :这种方式最"Android化",你的插件
getString方法将作为一个标准的、跨进程的Service方法被调用。它拥有清晰的生命周期管理,能处理更复杂的交互,并且完全遵循Shadow通过Binder进行跨进程通信的设计核心。
具体代码:Loader作为中介 + Binder回调
购物车同步:插件添加商品,宿主购物车实时更新
插件将API注册到Loader,宿主通过Loader获取并调用。
5.6.2.3 公共模块定义接口 (shadow-common)
java
// IPluginCartApi.java - 插件提供的购物车接口
public interface IPluginCartApi extends IInterface {
// AIDL 风格定义
void addToCart(String productId, int count);
void removeFromCart(String productId);
List<CartItem> getCartItems();
abstract class Stub extends Binder implements IPluginCartApi {
public static IPluginCartApi asInterface(IBinder obj) {
// 标准 AIDL 转换
}
}
}
// CartItem.java - 数据Bean
public class CartItem implements Parcelable {
public String productId;
public String name;
public int count;
public long price;
}
5.6.2.4 插件实现API并注册到Loader
scala
// 插件工程:PluginCartApiImpl.java
public class PluginCartApiImpl extends IPluginCartApi.Stub {
private CartManager cartManager = CartManager.getInstance();
@Override
public void addToCart(String productId, int count) {
cartManager.add(productId, count);
// 通知宿主购物车变化(通过Loader回调)
notifyCartChanged();
}
@Override
public void removeFromCart(String productId) {
cartManager.remove(productId);
notifyCartChanged();
}
@Override
public List<CartItem> getCartItems() {
return cartManager.getItems();
}
}
// 插件加载时注册
public class Double11PluginLoader extends SamplePluginLoader {
@Override
public void loadPlugin(String partKey, Bundle extras) {
super.loadPlugin(partKey, extras);
// 将API注册到Manager
IPluginCartApi cartApi = new PluginCartApiImpl();
registerPluginApi(partKey, cartApi.asBinder());
}
}
5.6.2.5 宿主获取并调用插件API
typescript
// 宿主工程:CartSyncManager.java
public class CartSyncManager {
private IPluginCartApi mCartApi;
// 获取插件API
public void bindPluginCart(String partKey) {
DynamicPluginManager manager = (DynamicPluginManager) Shadow.getPluginManager();
// 通过Loader获取插件的Binder
IBinder binder = manager.getPluginApi(partKey);
if (binder != null) {
mCartApi = IPluginCartApi.Stub.asInterface(binder);
}
}
// 调用插件方法
public void addToCart(String productId, int count) {
if (mCartApi != null) {
try {
mCartApi.addToCart(productId, count);
// 成功后同步到宿主购物车
syncToHostCart();
} catch (RemoteException e) {
e.printStackTrace();
}
}
}
// 宿主购物车同步
private void syncToHostCart() {
try {
List<CartItem> items = mCartApi.getCartItems();
// 更新宿主购物车UI
EventBus.getDefault().post(new CartUpdateEvent(items));
} catch (RemoteException e) {
e.printStackTrace();
}
}
}
5.6.2.6 宿主接收购物车更新
typescript
// 宿主工程:CartActivity.java
public class CartActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 监听购物车更新事件
EventBus.getDefault().register(this);
// 同步双11插件的购物车
CartSyncManager.getInstance().bindPluginCart("double11");
}
@Subscribe
public void onCartUpdate(CartUpdateEvent event) {
// 刷新宿主购物车列表
adapter.setNewData(event.getItems());
updateTotalPrice();
}
@Override
protected void onDestroy() {
super.onDestroy();
EventBus.getDefault().unregister(this);
}
}
事件总线方案(同进程场景)
如果插件和宿主运行在同一个进程,可以用更简单的事件总线:
scala
// 插件添加商品后发送事件
public class Double11PluginActivity extends PluginContainerActivity {
private void addToCart(String productId) {
// 插件内部添加逻辑...
// 发送全局事件
EventBus.getDefault().post(new CartAddEvent(productId));
}
}
// 宿主接收事件
public class CartActivity extends AppCompatActivity {
@Subscribe
public void onCartAdd(CartAddEvent event) {
// 直接更新宿主购物车
updateCartUI(event.getProductId());
}
}
5.6.3. 插件和插件通信:(双11活动插件,添加商品到了购物车插件)
方案一:宿主中转模式(最推荐,解耦最彻底)
原理:
- 宿主加载插件 B,获取其实例。
- 宿主将插件 B 的实例注册到共享模块 的一个静态管理器中,或者通过
IHostService接口提供给插件 A。 - 插件 A 通过共享模块的管理器,或者调用宿主提供的接口,间接拿到插件 B 的能力。
方案二:SPI 服务发现机制(适合多个插件提供同类服务)
如果插件 B 只是众多提供某种能力(如"支付能力"、"登录能力")的插件之一,可以使用 Java SPI 思想。
- 共享模块 定义
ServiceProvider接口和ServiceRegistry注册表。 - 宿主 遍历所有已加载插件,查找实现了该接口的类,注册到
ServiceRegistry。 - 插件 A 从
ServiceRegistry查找所需服务。
优点 :插件 A 不需要知道插件 B 的具体类名,只需知道"我要找一个能支付的插件"。 缺点:实现稍复杂,需要宿主维护加载顺序。
方案三:通过Loader作为中介(最推荐)
利用Loader作为桥梁,通过Binder接口实现插件间通信。
这个方案的核心思想是:让Loader扮演"路由器"或"服务注册中心"的角色,插件B将自己的API注册到Loader,插件A通过Loader查询并获取插件B的API,然后直接调用。
核心原理图解
css
┌─────────────────────────────────────────────────────────────┐
│ 宿主进程 │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 插件A │ │ 插件B │ │
│ │ │ │ │ │
│ │ 需要调用B的方法│◄────────►│ 提供API给A │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ │ 3. 获取B的API │ 1. 注册自己的API │
│ ▼ ▼ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Loader │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ 插件API注册中心 (Map<String, IBinder>) │ │ │
│ │ │ - plugin_b -> Binder(插件B的API) │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Binder通信机制 (跨ClassLoader调用) │ │
│ │ 插件A → Loader → 插件B 的调用链路 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
为什么能跨插件调用?
关键因素有两个:
- Binder的跨ClassLoader能力
- Binder是Android的跨进程通信机制,但它同样可以在同一个进程内跨ClassLoader通信
- Binder对象在传输过程中不依赖于ClassLoader,它是二进制安全的
- 当插件A收到Binder对象时,可以通过
Stub.asInterface()将其转换为接口,这个转换过程不关心目标实现在哪个ClassLoader中
- Loader作为统一命名空间
- Loader运行在独立的ClassLoader中,但它可以同时访问所有插件的类
- Loader维护的
Map<String, IBinder>相当于一个跨插件的服务目录 - 任何插件只要知道目标插件的"服务名"(partKey),就能通过Loader找到对应的Binder
具体代码:插件和插件通信(双11插件 → 购物车插件)
核心原理:Loader作为服务注册中心
Loader运行在独立ClassLoader中,可同时访问所有插件。通过维护Map<String, IBinder>服务注册表,实现插件间解耦通信。
为什么能跨插件调用?
- Binder的跨ClassLoader能力:Binder是二进制安全的,不依赖ClassLoader,可在同进程内跨ClassLoader传输
- Loader的统一命名空间 :Loader维护全局服务目录,任何插件通过
partKey即可找到对应服务
核心代码
5.6.3.1 公共模块定义接口 (shadow-common)
java
// ICartService.java - 购物车插件对外提供的服务
public interface ICartService extends IInterface {
void addToCart(String productId, int count);
void removeFromCart(String productId);
List<CartItem> getCartItems();
abstract class Stub extends Binder implements ICartService {
public static ICartService asInterface(IBinder obj) {
if (obj == null) return null;
IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
return (iin instanceof ICartService) ? (ICartService) iin : new Proxy(obj);
}
private static class Proxy implements ICartService {
private IBinder mRemote;
Proxy(IBinder remote) { mRemote = remote; }
@Override
public void addToCart(String productId, int count) throws RemoteException {
Parcel data = Parcel.obtain();
data.writeInterfaceToken(DESCRIPTOR);
data.writeString(productId);
data.writeInt(count);
mRemote.transact(TRANSACTION_addToCart, data, null, 0);
data.recycle();
}
// ... 其他方法类似
}
}
}
5.6.3.2 购物车插件注册服务
scala
// 购物车插件:CartPluginLoader.java
public class CartPluginLoader extends SamplePluginLoader {
private ICartService mCartService;
@Override
public void loadPlugin(String partKey, Bundle extras) {
super.loadPlugin(partKey, extras);
// 创建服务实现
mCartService = new CartServiceImpl();
// 注册到Loader的服务中心
registerPluginService("cart_plugin", mCartService.asBinder());
}
// 服务实现
private class CartServiceImpl extends ICartService.Stub {
@Override
public void addToCart(String productId, int count) {
CartDataManager.getInstance().add(productId, count);
}
@Override
public List<CartItem> getCartItems() {
return CartDataManager.getInstance().getItems();
}
}
}
5.6.3.3 双11插件调用购物车服务
scala
// 双11插件:Double11PluginLoader.java
public class Double11PluginLoader extends SamplePluginLoader {
private ICartService mCartService;
// 获取购物车服务(在需要时调用)
private void bindCartService() {
if (mCartService != null) return;
// 从Loader获取购物车插件的Binder
IBinder binder = getPluginService("cart_plugin");
if (binder != null) {
mCartService = ICartService.Stub.asInterface(binder);
}
}
// 双11秒杀添加商品
public void onSeckillSuccess(String productId) {
bindCartService();
if (mCartService != null) {
try {
// 直接调用购物车插件的方法
mCartService.addToCart(productId, 1);
// 可选:发送通知刷新UI
notifyCartUpdated();
} catch (RemoteException e) {
// 降级处理
handleAddToCartFailed(productId);
}
}
}
}
5.6.3.4 宿主中的Loader增强(支持服务注册)
typescript
// 宿主工程:EnhancedDynamicPluginManager.java
public class EnhancedDynamicPluginManager extends DynamicPluginManager {
// 服务注册表
private Map<String, IBinder> mServiceRegistry = new ConcurrentHashMap<>();
// 插件注册服务
public void registerPluginService(String serviceName, IBinder service) {
mServiceRegistry.put(serviceName, service);
}
// 获取插件服务
public IBinder getPluginService(String serviceName) {
return mServiceRegistry.get(serviceName);
}
// 插件卸载时清理服务
public void unregisterPluginService(String serviceName) {
mServiceRegistry.remove(serviceName);
}
}
流程图解
scss
┌─────────────────────────────────────────────────────────────────┐
│ 宿主进程 │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ 双11插件 │ │ 购物车插件 │ │
│ │ │ │ │ │
│ │ onSeckillSuccess() │ │ CartServiceImpl │ │
│ │ │ │ │ │ │ │
│ │ ▼ │ │ ▼ │ │
│ │ bindCartService() │ │ registerService() │ │
│ │ │ │ │ │ │ │
│ └─────────┼────────────┘ └──────┼───────────────┘ │
│ │ │ │
│ │ 2. getPluginService() │ 1. registerService()│
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Loader (服务注册中心) │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Map<String, IBinder> serviceRegistry │ │ │
│ │ │ "cart_plugin" -> Binder(购物车插件API) │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │ │
│ └──────────┬───────────────────┘ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ Binder跨ClassLoader │ │
│ │ 直接调用方法 │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
直接依赖方案(同UUID分组)
如果两个插件在同一UUID分组(共用Loader),可以更简单:
typescript
// 公共模块定义接口
public interface ICartProvider {
void addToCart(String productId, int count);
}
// 购物车插件实现
public class CartPluginImpl implements ICartProvider {
@Override
public void addToCart(String productId, int count) {
// 实现逻辑
}
}
// 双11插件通过ClassLoader直接获取
public class Double11Plugin {
private ICartProvider getCartProvider() {
try {
// 同一个ClassLoader下可以直接加载类
Class<?> clazz = Class.forName("com.cart.plugin.CartProviderImpl");
return (ICartProvider) clazz.newInstance();
} catch (Exception e) {
return null;
}
}
}
通信总结:
- 插件获取宿主信息:在加载插件时,通过Bundle传递一个Binder(即宿主服务)给插件。
- 宿主获取插件信息 :通过
Manager获取插件的Binder(即插件服务),然后调用。 - 核心原理 :利用Loader作为服务注册中心。插件B将自己的API(Binder)注册到Loader,插件A通过 Loader查询并获取插件B的Binder,然后直接调用其方法
5.7. Shadow如何更新:多插件管理、版本控制与热更新策略!宿主的版本和插件如果要更新,插件和插件之间依赖?(非常重要)
宿主与插件通信 Double11CommunicationBridge.java
宿主只负责加载 Manager 插件,这个后面是通过服务器加载,开始是本地加载
重点结论:
1).插件的加载和管理是通过manager
2).manager的加载和管理是通过自己检测自己的更新
插件动态更新策略,&& 插件动态化部署&& 多插件管理:同时加载多个插件的策略&& 插件生命周期管理
css
第一次版本,宿主A,包含2个插件,插件B 和插件C, 都是1.0版本
第一种场景: 过了7天,发现插件B有bug,热修复,插件B是2.0版本
第二次发版:宿主A 3.0,插件B,C都有更新,同时新增插件D 都是3.0版本
建立插件间的依赖关系图 管理插件与宿主的版本兼容关系
css
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 宿主A 1.0 │ │ 插件热更新 │ │ 宿主A 3.0 │
│ - 插件B 1.0 │────▶│ 插件B 2.0 │────▶│ - 插件B 3.0 │
│ - 插件C 1.0 │ │ 插件C 1.0 │ │ - 插件C 3.0 │
│ │ │ │ │ - 插件D 3.0 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Double11PluginManager
5.7.1: 第一种场景:过了7天,发现插件B有bug,热修复,插件B是2.0版本
针对你提出的 Shadow 插件化框架中,宿主 A 包含插件 B 和 C,并需要动态热修复插件 B 的场景,结合 Shadow 的架构特性,提供一个详细的实现方案。
核心思路
Shadow 是一个全动态插件化框架 ,其核心设计是:宿主只负责加载 Manager 插件,Manager 负责下载、安装新插件,然后通过跨进程通信(IPC)拉起 Loader 和 Runtime 来运行具体的业务插件(如 B 和 C)。
要实现插件 B 的热修复(从 1.0 升级到 2.0),核心在于 Manager 模块需要具备版本检测和插件包替换的能力。
第一阶段:初始版本(1.0)
此时的架构和职责如下:
-
宿主 A:
- 包含一个非常轻量的初始化代码,不包含任何业务逻辑。
- 负责在启动时从 assets 目录或本地加载初始的
Manager插件(管理插件)。
-
插件 Manager:
- 这是一个独立的插件,负责插件管理。
- 它知道去哪里下载插件 B 和 C(比如 CDN 地址)。
- 它负责将插件 B 和 C 的 1.0 版本 APK/ZIP 文件下载到本地,并存储到宿主应用的私有目录下(如
/data/data/包名/files/ShadowPluginManager/)。
-
插件 B 和 C:
- 标准的 Shadow 业务插件,打包成包含
config.json的 ZIP 包。 - ZIP 包内包含插件本身的 APK 以及必要的
config.json描述文件(记录版本号、UUID、依赖等)。
- 标准的 Shadow 业务插件,打包成包含
初始加载流程 :
宿主 A -> 加载 Manager -> Manager 检查本地插件 -> 发现无版本或版本低 -> 下载插件 B 1.0 -> 安装(解压、odex、存入数据库) -> 启动插件 B。
第二阶段:热修复(插件 B 升级到 2.0)
假设 7 天后,你需要修复插件 B 的 Bug。由于 Manager 本身也是一个插件,你可以选择不更新宿主和 Manager,只更新插件 B。
5.7.1.1 插件打包与下发
你需要重新打包插件 B,生成 2.0 版本 的 ZIP 包。在打包配置中(shadow 闭包),你需要修改 version 字段:
ini
shadow {
packagePlugin {
pluginTypes {
release {
pluginApks {
pluginB {
// ... 其他配置
version = 2 // 关键:版本号从1改为2
// 或者通过 version = 2 和 uuidNickName = "2.0.0" 组合控制
}
}
}
}
// 如果共用Loader/Runtime,UUID必须保持一致
uuid = "your-unique-uuid-for-bc"
}
}
打包完成后,将这个新的 plugin_b_2.0.zip 上传到你的服务器(CDN),并更新下发策略。
5.7.1.2 Manager 的版本检测机制(关键)
这是实现热修复的核心逻辑。你需要在你自定义的 Manager 插件中实现以下逻辑:
- 版本对比 :Manager 不能只判断插件是否存在,必须判断版本号。通常通过解析本地数据库或
config.json中的version字段与服务器下发的版本信息(如接口返回的latestVersion)进行对比。 - 按需下载 :当用户打开插件 B 时,Manager 发起请求
https://your-server/api/plugin/B/latest,获取到最新版本号为 2,本地版本为 1,判定需要更新。 - 覆盖安装 :下载新的
plugin_b_2.0.zip。 - 数据库更新 :在安装过程中,调用
InstalledDao#insert或update,将数据库中的插件 B 记录更新为新版本的信息(路径、版本号、hash 值等)。注意:这不会删除旧文件,但下次加载时会根据数据库的最新记录指向 2.0 版本的文件。
5.7.1.3 进程隔离与版本共存
Shadow 的一个强大特性是插件进程隔离。在版本切换时,需要注意处理进程中的残留实例:
-
如果插件 B 正在运行:
- 简单方案 :提示用户重启应用,或者自行杀死插件进程(调用
PPSController.exit()),这样下次进入时 Manager 会加载新的 2.0 版本。 - 复杂方案(无感知) :用户关闭插件 B 界面后,后台立即清理旧进程,下次打开自动为新版本。
- 简单方案 :提示用户重启应用,或者自行杀死插件进程(调用
-
如果插件 B 未运行:直接更新数据库中的记录,下次启动自然就是 2.0 版本。
方案总结与组件职责图
| 组件 | 角色 | 热修复中的职责 | 关键操作 |
|---|---|---|---|
| 宿主 A | 壳 | 不参与更新。提供运行环境,加载 Manager。 | 无改动。仅负责启动 Manager。 |
| Manager | 管家 | 核心控制层。负责版本检测、下载、安装、数据库版本管理。 | 1. 对比本地与远程版本(发现 2.0)。 2. 下载新包。 3. 解压并更新数据库(覆盖旧记录)。 4. 触发插件进程重启(可选)。 |
| 插件 B | 业务 | 被更新的对象。 | 1. 打包版本号必须递增(version=2)。 2. 上传至服务器。 |
| 插件 C | 业务 | 保持不变。 | 无改动。继续运行 1.0 版本。 |
总结:核心就是Manager,需要从服务器获取配置,动态下载插件,和插件管理的操作
5.7.2. 第二种场景:第二次发版:宿主A ,插件B,C都有更新,同时新增插件D ,如何管理插件和动态下方插件,方案是啥
核心挑战
| 挑战 | 说明 |
|---|---|
| 多版本共存 | 三个插件版本各不相同,Manager 需要能区分管理 |
| 依赖关系 | 如果插件间有依赖(如 D 依赖 B 的某些能力),加载顺序需保证 |
| 增量下载 | 避免用户下载完整包,尤其是只更新了小 Bug 的情况 |
| Loader/Runtime 共用 | 多个插件共用同一套 Loader 和 Runtime,避免重复下载和内存浪费 |
方案设计:基于 UUID 的分组管理与增量更新
5.7.2.1 插件分组设计(关键)
Shadow 的核心设计是 UUID 决定一组插件的 Loader 和 Runtime。在第二次发版时,你需要决定插件 B、C、D 是否共用同一套 Loader/Runtime。
ini
┌─────────────────────────────────────────────────────────────┐
│ UUID = "group-1" │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Loader 插件 (版本独立) │ │
│ │ Runtime 插件 (版本独立) │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ 插件 B (partKey="pluginB", version=3.0) │ │
│ │ 插件 C (partKey="pluginC", version=2.0) │ │
│ │ 插件 D (partKey="pluginD", version=1.0) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
配置示例(插件 B 的 build.gradle):
ini
shadow {
packagePlugin {
pluginTypes {
release {
// 关键:共用 Loader/Runtime,只配置业务插件
// loaderApkConfig 和 runtimeApkConfig 留空
pluginApks {
pluginB {
partKey = "pluginB"
businessName = "business_group"
version = 3 // 版本号递增
hostWhiteList = [...] // 宿主白名单
dependsOn = [] // 如有依赖其他插件,填写 partKey
}
}
}
}
uuid = "group-1" // 与插件 C、D 共用同一个 UUID
version = 1 // 分组版本号(非插件版本)
}
}
5.7.2.2 插件包结构
每个插件独立打包为 ZIP,包含:
pluginB.apk(业务代码)config.json(元信息,含版本号)
config.json 示例:
json
{
"partKey": "pluginB",
"version": 3,
"uuid": "group-1",
"dependencies": [],
"minHostVersion": "1.0.0"
}
5.7.2.3 下发策略设计
在服务端维护插件版本配置表:
| partKey | latestVersion | downloadUrl | patchUrl | dependsOn | required |
|---|---|---|---|---|---|
| pluginB | 3 | /full/b3.zip | /patch/b2-b3.hpz | [] | true |
| pluginC | 2 | /full/c2.zip | /patch/c1-c2.hpz | [] | true |
| pluginD | 1 | /full/d1.zip | null | [] | false |
5.7.2.4 Manager 升级检测逻辑
Manager 需要实现批量版本检测,而不是逐个检测:
less
┌─────────────────────────────────────────────────────────────────┐
│ Manager 版本检测流程 │
├─────────────────────────────────────────────────────────────────┤
│ 1. 请求服务端接口 /api/plugins/latest │
│ 传入当前已安装插件列表:[{partKey:"B",version:2}, ││ {partKey:"C",version:1}] │
│ │
│ 2. 服务端返回需要更新的插件: │
│ - pluginB: 2→3 (patch 可用) │
│ - pluginC: 1→2 (full 下载) │
│ - pluginD: 新增 (full 下载) │
│ │
│ 3. Manager 批量下载:优先下载 patch,并行下载 full │
│ │
│ 4. 按依赖顺序安装:如 D 依赖 B,则先安装 B 再安装 D │
└─────────────────────────────────────────────────────────────────┘
5.7.2.5 数据库版本管理
Shadow 的插件安装信息通过 InstalledDao 持久化到数据库。每个插件按 partKey 唯一标识,版本信息独立存储。
| 字段 | 说明 |
|---|---|
| partKey | 插件唯一标识(如 pluginB) |
| version | 插件版本号 |
| uuid | 所属分组 |
| apkPath | 插件文件绝对路径 |
| hash | 文件完整性校验 |
| dependencies | 依赖的其他 partKey 列表 |
5.7.2.6 依赖加载顺序
Shadow 支持插件间的依赖关系配置,通过 dependsOn 指定:
ini
// 插件 D 依赖插件 B
pluginD {
partKey = "pluginD"
dependsOn = ["pluginB"] // 确保加载插件 D 前先加载插件 B
}
加载时处理:Shadow 会将依赖插件的 ClassLoader 设置为当前插件的 parent,实现类共享。
完整时序图
less
用户启动 App
│
▼
宿主 A ──────────────────────────────────────────┐
│ 加载 Manager (本地已有) │
▼ │
Manager ─────────────────────────────────────────┐│
│ 1. 请求服务端 /api/plugins/latest ││
│ (携带本地插件列表: B2, C1) ││
│ ││
│ 2. 服务端返回: ││
│ - B: 2→3 (patch可用) ││
│ - C: 1→2 (full) ││
│ - D: 新增 (full) ││
│ ││
│ 3. 并行下载 ││
│ ├── 下载 B 的 patch.hpz ││
│ ├── 下载 C 的 full zip ││
│ └── 下载 D 的 full zip ││
│ ││
│ 4. 本地合并 (B) ││
│ old.apk + patch.hpz → new.apk ││
│ MD5 校验 ││
│ ││
│ 5. 按依赖顺序安装 ││
│ ├── 安装插件 B (version 3) ││
│ ├── 安装插件 C (version 2) ││
│ └── 安装插件 D (version 1) ││
│ ││
│ 6. 更新数据库 ││
│ InstalledDao.update/insert ││
└────────────────────────────────────────────┘│
│ │
▼ │
启动插件 B/C/D(按需)◄─────────────────────────────┘
实施清单
| 步骤 | 负责模块 | 具体内容 |
|---|---|---|
| 1 | 构建脚本 | 统一三个插件的 UUID,分别设置 version |
| 2 | 服务端 | 新增批量版本检测接口,返回增量/全量下载地址 |
| 3 | Manager | 实现批量检测、并行下载、依赖排序安装 |
| 4 | Manager | 集成 ApkDiffPatch 实现增量合并 |
| 5 | Manager | 数据库支持多版本共存,按 partKey 区分 |
版本演进时间线
css
时间线
│
├── 第一次发版
│ ├── 宿主 A 1.0
│ ├── 插件 B 1.0
│ └── 插件 C 1.0
│
├── 热修复阶段(第一次发版后第7天)
│ └── 插件 B 2.0(热修复 Bug)
│
└── 第二次发版(当前)
├── 宿主 A 2.0(更新)
├── 插件 B 3.0(更新)
├── 插件 C 2.0(更新)
└── 插件 D 1.0(新增)
组件关系图


核心设计原则
| 原则 | 实现方式 |
|---|---|
| 全动态化 | 宿主只负责加载 Manager,Manager 负责所有插件管理 |
| 版本隔离 | 每个插件独立版本,通过 partKey 唯一标识 |
| 按需下载 | Manager 根据本地版本与服务端对比,只下载需要更新的插件 |
| 依赖感知 | 插件支持 dependsOn 配置,Manager 按依赖顺序安装加载 |
服务端插件版本表
| partKey | version | downloadUrl | patchUrl | size | md5 | dependsOn | minHostVersion | status |
|---|---|---|---|---|---|---|---|---|
| plugin_b | 3 | /full/b3.zip | /patch/b2-b3.hpz | 5MB | abc123 | [] | 1.0 | active |
| plugin_b | 2 | /full/b2.zip | - | 5MB | def456 | [] | 1.0 | archived |
| plugin_c | 2 | /full/c2.zip | /patch/c1-c2.hpz | 3MB | ghi789 | [] | 1.0 | active |
| plugin_c | 1 | /full/c1.zip | - | 3MB | jkl012 | [] | 1.0 | archived |
| plugin_d | 1 | /full/d1.zip | - | 2MB | mno345 | ["plugin_b"] | 1.0 | active |
用户状态矩阵
| 用户类型 | 宿主版本 | Manager 版本 | 初始插件 | 操作 | 最终状态 |
|---|---|---|---|---|---|
| 活跃老用户 | 1.0 | 2.0 (已更新) | B 2.0, C 1.0 | 检测更新 → 下载 B 3.0, C 2.0, D 1.0 | B 3.0, C 2.0, D 1.0 |
| 不活跃老用户 | 1.0 | 1.0 (未更新) | 无 | 更新 Manager → 下载所有插件 | B 3.0, C 2.0, D 1.0 |
| 新用户 | 2.0 | 2.0 (内置) | 无 | 下载所有插件 | B 3.0, C 2.0, D 1.0 |
实施步骤清单
| 阶段 | 任务 | 负责人 |
|---|---|---|
| 准备阶段 | 1. 统一三个插件的 UUID,配置各自的 version 2. 配置插件依赖关系(如 D 依赖 B) 3. 生成增量补丁包(可选) | 开发 |
| 服务端 | 1. 搭建版本管理 API 2. 上传插件包到 CDN 3. 配置版本数据库 | 后端 |
| Manager | 1. 实现批量版本检测 2. 实现增量更新合并 3. 实现依赖排序安装 4. 实现数据库版本管理 | 客户端 |

总结: 核心方案 :全动态化 。宿主只负责加载 Manager,Manager 负责所有插件(B、C、D)的版本检测、下载、安装和依赖管理。
关键策略:
- 分组管理 :通过
UUID将一组业务插件(B、C、D)绑定在一起,共用一套Loader和Runtime。 - 版本控制:服务端维护插件版本表,Manager批量检测。
- 依赖排序 :插件通过
dependsOn声明依赖,Manager按顺序安装(如D依赖B,则先安装B)
5.8. 热修复一个类:优惠券叠加计算逻辑紧急修复,同时有一个奔溃问问题
业务场景:双11期间发现优惠券叠加计算Bug:满300减50的店铺券与满200减20的平台券同时使用时,计算金额错误,导致用户多付或少付款。需要:
- 快速定位问题所在类
CouponCalculator.java - 开发修复版本,生成修复Dex文件
- 通过后台配置系统,向所有用户推送热修复包
- 用户下次打开购物车时自动应用修复,无需重启App
热修复是全量更新,还是增量更新? 结论:Shadow 的"热修复"本质上是「插件全量更新」,不是增量热修复(如修复单个方法)
基于Shadow的类级别热修复
优惠券计算器修复 CouponHotFixManager.java
热修复配置文件
json
// hotfix_config.json
{
"hotfixes": [
{
"id": "coupon_calc_fix_20231101",
"className": "com.taobao.app.coupon.CouponCalculator",
"version": "1.2.0",
"description": "修复双11优惠券叠加计算逻辑错误",
"downloadUrl": "https://cdn.taobao.com/hotfix/double11/coupon_fix_v1.2.dex",
"md5": "a1b2c3d4e5f678901234567890123456",
"minAppVersion": "9.5.0",
"maxAppVersion": "9.8.0",
"applyStrategy": {
"type": "immediate",
"conditions": ["wifi_only", "battery_high"],
"rollbackOnFailure": true
},
"affectedUsers": "all",
"releaseTime": "2023-11-01T10:00:00Z",
"expireTime": "2023-11-15T23:59:59Z"
}
]
}
核心原理
Shadow的热修复本质是插件全量更新,通过Manager下载新版本插件包替换旧版本。
用户启动App → Manager检测版本 → 发现新版本 → 下载新插件包 → 更新数据库 → 下次加载生效
核心代码
5.8.1 优惠券计算器(待修复)
arduino
// 插件工程:CouponCalculator.java(Bug版本)
public class CouponCalculator {
// Bug: 店铺券和平台券叠加计算错误
public long calculateTotalDiscount(List<Coupon> coupons, long totalPrice) {
long discount = 0;
for (Coupon coupon : coupons) {
if (coupon.getType() == CouponType.SHOP) {
discount += calculateShopDiscount(coupon, totalPrice);
} else if (coupon.getType() == CouponType.PLATFORM) {
// Bug: 应该用优惠后金额,但用了原始金额
discount += calculatePlatformDiscount(coupon, totalPrice);
}
}
return discount;
}
}
5.8.2 修复后的版本
ini
// 插件工程:CouponCalculator.java(修复版本 version=2.0)
public class CouponCalculator {
// 修复: 店铺券和平台券正确叠加
public long calculateTotalDiscount(List<Coupon> coupons, long totalPrice) {
long currentPrice = totalPrice;
long totalDiscount = 0;
// 先计算店铺券(通常门槛低)
for (Coupon coupon : coupons) {
if (coupon.getType() == CouponType.SHOP) {
long discount = calculateShopDiscount(coupon, currentPrice);
totalDiscount += discount;
currentPrice -= discount; // 关键修复: 使用优惠后金额
}
}
// 再计算平台券(基于优惠后金额)
for (Coupon coupon : coupons) {
if (coupon.getType() == CouponType.PLATFORM) {
totalDiscount += calculatePlatformDiscount(coupon, currentPrice);
}
}
return totalDiscount;
}
}
5.8.3 Manager热修复管理器
typescript
// Manager工程:CouponHotFixManager.java
public class CouponHotFixManager {
private static final String HOTFIX_CONFIG_URL = "https://api.taobao.com/hotfix/config";
/**
* 检查热修复更新
*/
public void checkHotFix() {
// 1. 拉取配置
fetchHotFixConfig(new ConfigCallback() {
@Override
public void onSuccess(HotFixConfig config) {
for (HotFixItem item : config.hotfixes) {
// 2. 版本对比
if (needUpdate(item)) {
// 3. 下载修复包
downloadAndApply(item);
}
}
}
});
}
/**
* 判断是否需要更新
*/
private boolean needUpdate(HotFixItem item) {
// 获取当前插件版本
int currentVersion = getPluginVersion(item.partKey);
// 版本比较
if (item.version <= currentVersion) return false;
// 用户范围判断
if (!isInUserScope(item.affectedUsers)) return false;
// App版本范围判断
String appVersion = getAppVersion();
if (appVersion.compareTo(item.minAppVersion) < 0) return false;
if (appVersion.compareTo(item.maxAppVersion) > 0) return false;
return true;
}
/**
* 下载并应用热修复
*/
private void downloadAndApply(HotFixItem item) {
// 检查网络条件
if (!checkNetworkCondition(item.applyStrategy.conditions)) {
// 延迟下载
scheduleDownload(item);
return;
}
// 下载新插件包
DownloadManager.download(item.downloadUrl, new DownloadCallback() {
@Override
public void onSuccess(File pluginFile) {
// MD5校验
if (!verifyMd5(pluginFile, item.md5)) {
onError("MD5校验失败");
return;
}
// 安装新版本(更新数据库记录)
installNewVersion(item.partKey, item.version, pluginFile);
// 通知用户
notifyUser("优惠券计算已优化,重启后生效");
}
@Override
public void onError(String error) {
// 降级处理
if (item.applyStrategy.rollbackOnFailure) {
rollbackToOldVersion(item.partKey);
}
}
});
}
/**
* 安装新版本插件
*/
private void installNewVersion(String partKey, int version, File pluginFile) {
// 1. 复制到插件目录
File destFile = new File(getPluginDir(), partKey + "_v" + version + ".apk");
FileUtils.copy(pluginFile, destFile);
// 2. 更新数据库
InstalledDao dao = Shadow.getInstalledDao();
InstalledPlugin plugin = dao.getByPartKey(partKey);
if (plugin == null) {
plugin = new InstalledPlugin();
plugin.partKey = partKey;
}
plugin.version = version;
plugin.apkPath = destFile.getAbsolutePath();
plugin.updateTime = System.currentTimeMillis();
dao.insertOrUpdate(plugin);
// 3. 清理旧版本(可选,延迟清理)
cleanOldVersion(partKey, version);
}
}
5.8.4 热修复配置文件解析
arduino
// HotFixConfig.java
public class HotFixConfig {
public List<HotFixItem> hotfixes;
public static HotFixConfig parse(String json) {
Gson gson = new Gson();
return gson.fromJson(json, HotFixConfig.class);
}
}
// HotFixItem.java
public class HotFixItem {
public String id;
public String partKey; // 插件标识
public String className; // 修复的类名(仅记录)
public int version; // 新版本号
public String downloadUrl; // 下载地址
public String md5; // 文件校验
public String minAppVersion; // 最小宿主版本
public String maxAppVersion; // 最大宿主版本
public ApplyStrategy applyStrategy;
public String affectedUsers; // 影响用户范围
public long releaseTime; // 发布时间
public long expireTime; // 过期时间
}
// ApplyStrategy.java
public class ApplyStrategy {
public String type; // immediate / delay
public List<String> conditions; // wifi_only, battery_high
public boolean rollbackOnFailure;
}
5.8.5 在宿主中触发检查
scala
// 宿主工程:MainActivity.java
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// App启动时检查热修复
CouponHotFixManager hotFixManager = new CouponHotFixManager();
hotFixManager.checkHotFix();
}
}
// 或在购物车页面打开时检查
public class CartActivity extends AppCompatActivity {
@Override
protected void onResume() {
super.onResume();
// 每次打开购物车检查优惠券逻辑是否有更新
CouponHotFixManager hotFixManager = new CouponHotFixManager();
hotFixManager.checkHotFix();
}
}
热修复流程时序图
scss
┌──────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐
│ 用户 │ │ 宿主App │ │ Manager │ │ 服务器 │
└──┬───┘ └────┬────┘ └────┬────┘ └────┬─────┘
│ │ │ │
│ 打开购物车 │ │ │
│─────────────>│ │ │
│ │ 检查热修复 │ │
│ │──────────────>│ │
│ │ │ 请求配置 │
│ │ │──────────────>│
│ │ │ │
│ │ │ 返回配置 │
│ │ │<──────────────│
│ │ │ │
│ │ │ 版本对比 │
│ │ │ (发现新版本) │
│ │ │ │
│ │ │ 下载新插件包 │
│ │ │──────────────>│
│ │ │ │
│ │ │ 返回插件包 │
│ │ │<──────────────│
│ │ │ │
│ │ │ 校验MD5 │
│ │ │ 更新数据库 │
│ │ │ │
│ 下次启动生效 │ │ │
│<─────────────│ │ │
│ │ │ │
5.9. 热修复替换一个资源文件: 发现活动主图侵犯版权,需立即更换
业务场景 :发现活动主图侵犯版权,需立即更换, 活动文案涉及敏感词,需立即修改 ResourceHotFixManager.java
核心原理
资源热修复同样是插件全量更新,通过Manager下载包含新资源的新版本插件包。
核心代码
5.9.1 资源热修复管理器
typescript
// Manager工程:ResourceHotFixManager.java
public class ResourceHotFixManager {
private static final String RESOURCE_CONFIG_URL = "https://api.taobao.com/resource/hotfix";
/**
* 检查资源热修复
*/
public void checkResourceHotFix() {
fetchResourceConfig(new ConfigCallback() {
@Override
public void onSuccess(ResourceConfig config) {
for (ResourceItem item : config.resources) {
if (needUpdate(item)) {
downloadAndApply(item);
}
}
}
});
}
/**
* 判断是否需要更新
*/
private boolean needUpdate(ResourceItem item) {
// 获取当前插件版本
int currentVersion = getPluginVersion(item.partKey);
// 版本比较
if (item.version <= currentVersion) return false;
// 检查是否在生效时间范围内
long now = System.currentTimeMillis();
if (now < item.startTime || now > item.endTime) return false;
return true;
}
/**
* 下载并应用资源修复包
*/
private void downloadAndApply(ResourceItem item) {
DownloadManager.download(item.downloadUrl, new DownloadCallback() {
@Override
public void onSuccess(File pluginFile) {
// MD5校验
if (!verifyMd5(pluginFile, item.md5)) {
onError("MD5校验失败");
return;
}
// 安装新版本插件
installNewVersion(item.partKey, item.version, pluginFile);
// 记录修复日志
logHotFix(item);
}
@Override
public void onError(String error) {
// 降级:继续使用旧版本
Log.e("ResourceHotFix", "下载失败: " + error);
}
});
}
}
5.9.2 资源修复配置
arduino
// ResourceConfig.java
public class ResourceConfig {
public List<ResourceItem> resources;
}
// ResourceItem.java
public class ResourceItem {
public String id; // 修复ID
public String partKey; // 插件标识
public int version; // 新版本号
public String description; // 修复描述
public String downloadUrl; // 下载地址
public String md5; // 文件校验
public long startTime; // 生效开始时间
public long endTime; // 生效结束时间
public List<ResourceChange> changes; // 变更的资源列表
}
// ResourceChange.java
public class ResourceChange {
public String type; // image / string / layout
public String name; // 资源名称
public String oldValue; // 旧值(仅记录)
public String newValue; // 新值(图片为URL)
}
5.9.3 热修复配置文件示例
json
{
"resources": [
{
"id": "double11_banner_fix_20231101",
"partKey": "double11_plugin",
"version": 2,
"description": "替换侵权主图",
"downloadUrl": "https://cdn.taobao.com/plugin/double11_plugin_v2.zip",
"md5": "a1b2c3d4e5f678901234567890123456",
"startTime": 1698768000000,
"endTime": 1701446400000,
"changes": [
{
"type": "image",
"name": "banner_main",
"oldValue": "侵权图片.png",
"newValue": "合法图片.png"
},
{
"type": "string",
"name": "activity_title",
"oldValue": "限时秒杀",
"newValue": "狂欢秒杀"
},
{
"type": "string",
"name": "activity_slogan",
"oldValue": "全年最低价",
"newValue": "超值优惠"
}
]
}
]
}
5.9.4 插件内资源使用(需兼容新旧版本)
scss
// 插件工程:Double11Activity.java
public class Double11Activity extends PluginContainerActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_double11);
// 加载主图(自动使用新版本资源)
ImageView banner = findViewById(R.id.iv_banner);
int bannerResId = getResources().getIdentifier(
"banner_main", "drawable", getPackageName()
);
banner.setImageResource(bannerResId);
// 加载文案(自动使用新版本字符串)
TextView title = findViewById(R.id.tv_title);
title.setText(R.string.activity_title);
TextView slogan = findViewById(R.id.tv_slogan);
slogan.setText(R.string.activity_slogan);
}
}
5.10. 热修复一个so文件:图片加载so库,会有奔溃
业务场景 :双11期间图片加载量剧增,发现:需要紧急替换为优化后的Native库 解决方案 :在不发版的情况下,动态替换 libimage_decoder.so 库文件
SoHotFixManager.java
核心原理
SO库热修复同样基于插件全量更新,通过Manager下载包含新SO库的新版本插件包,替换旧版本。
核心代码
5.10.1 SO热修复管理器
typescript
// Manager工程:SoHotFixManager.java
public class SoHotFixManager {
private static final String SO_CONFIG_URL = "https://api.taobao.com/so/hotfix";
/**
* 检查SO热修复
*/
public void checkSoHotFix() {
fetchSoConfig(new ConfigCallback() {
@Override
public void onSuccess(SoConfig config) {
for (SoItem item : config.soFiles) {
if (needUpdate(item)) {
downloadAndApply(item);
}
}
}
});
}
/**
* 判断是否需要更新
*/
private boolean needUpdate(SoItem item) {
// 获取当前插件版本
int currentVersion = getPluginVersion(item.partKey);
// 版本比较
if (item.version <= currentVersion) return false;
// 检查SO是否在运行中
if (isSoLoaded(item.soName)) {
// 需要重启插件才能生效
markNeedRestart(item.partKey);
}
return true;
}
/**
* 下载并应用SO修复包
*/
private void downloadAndApply(SoItem item) {
DownloadManager.download(item.downloadUrl, new DownloadCallback() {
@Override
public void onSuccess(File pluginFile) {
// MD5校验
if (!verifyMd5(pluginFile, item.md5)) {
onError("MD5校验失败");
return;
}
// 解压并替换SO库
installNewVersion(item.partKey, item.version, pluginFile);
// 提示用户重启插件
if (needRestart(item.partKey)) {
showRestartDialog(item.partKey);
}
}
@Override
public void onError(String error) {
// 降级:继续使用旧版本
Log.e("SoHotFix", "下载失败: " + error);
}
});
}
/**
* 安装新版本插件
*/
private void installNewVersion(String partKey, int version, File pluginFile) {
// 1. 解压插件包
File pluginDir = new File(getPluginDir(), partKey + "_v" + version);
unzip(pluginFile, pluginDir);
// 2. 更新数据库
InstalledDao dao = Shadow.getInstalledDao();
InstalledPlugin plugin = dao.getByPartKey(partKey);
if (plugin == null) {
plugin = new InstalledPlugin();
plugin.partKey = partKey;
}
plugin.version = version;
plugin.apkPath = pluginDir.getAbsolutePath();
plugin.updateTime = System.currentTimeMillis();
dao.insertOrUpdate(plugin);
// 3. 预加载SO到缓存(可选)
preloadSo(pluginDir, "libimage_decoder.so");
}
/**
* 预加载SO库
*/
private void preloadSo(File pluginDir, String soName) {
try {
File soFile = new File(pluginDir, "lib/armeabi-v7a/" + soName);
if (soFile.exists()) {
System.load(soFile.getAbsolutePath());
Log.d("SoHotFix", "SO预加载成功: " + soName);
}
} catch (Throwable e) {
Log.e("SoHotFix", "SO预加载失败: " + e.getMessage());
}
}
/**
* 检查SO是否已加载
*/
private boolean isSoLoaded(String soName) {
// 通过反射检查已加载的SO列表
try {
ClassLoader loader = ClassLoader.getSystemClassLoader();
Field field = loader.getClass().getDeclaredField("nativeLibraries");
field.setAccessible(true);
Vector<?> libraries = (Vector<?>) field.get(loader);
for (Object lib : libraries) {
if (lib.toString().contains(soName)) {
return true;
}
}
} catch (Exception e) {
// 忽略反射异常
}
return false;
}
}
5.10.2 SO热修复配置
arduino
// SoConfig.java
public class SoConfig {
public List<SoItem> soFiles;
}
// SoItem.java
public class SoItem {
public String id; // 修复ID
public String partKey; // 插件标识
public String soName; // SO文件名
public int version; // 新版本号
public String description; // 修复描述
public String downloadUrl; // 下载地址
public String md5; // 文件校验
public String abi; // armeabi-v7a / arm64-v8a
public boolean needRestart; // 是否需要重启
public long releaseTime; // 发布时间
}
// 配置文件示例
{
"soFiles": [
{
"id": "image_decoder_fix_20231101",
"partKey": "double11_plugin",
"soName": "libimage_decoder.so",
"version": 2,
"description": "修复图片解码崩溃问题",
"downloadUrl": "https://cdn.taobao.com/plugin/double11_plugin_v2.zip",
"md5": "a1b2c3d4e5f678901234567890123456",
"abi": "arm64-v8a",
"needRestart": true,
"releaseTime": 1698768000000
}
]
}
5.10.3 插件内加载SO库(支持动态替换)
typescript
// 插件工程:ImageLoader.java
public class ImageLoader {
private static boolean sSoLoaded = false;
/**
* 加载SO库(优先使用插件内的SO)
*/
public static void loadSoLibrary(String soName) {
if (sSoLoaded) return;
try {
// 获取插件自身的ClassLoader
ClassLoader loader = ImageLoader.class.getClassLoader();
// 尝试从插件路径加载SO
String pluginSoPath = getPluginSoPath(soName);
if (pluginSoPath != null) {
System.load(pluginSoPath);
Log.d("ImageLoader", "从插件路径加载SO成功: " + pluginSoPath);
} else {
// 降级:从系统路径加载
System.loadLibrary(soName);
Log.d("ImageLoader", "从系统路径加载SO成功: " + soName);
}
sSoLoaded = true;
} catch (Throwable e) {
Log.e("ImageLoader", "SO加载失败: " + e.getMessage());
// 降级:使用原生解码
useFallbackDecoder();
}
}
/**
* 获取插件内的SO路径
*/
private static String getPluginSoPath(String soName) {
try {
// 获取插件安装目录
Context context = MyApplication.getContext();
File pluginDir = new File(context.getFilesDir(), "ShadowPlugin/double11_plugin");
// 遍历查找SO文件
String[] abis = {"arm64-v8a", "armeabi-v7a"};
for (String abi : abis) {
File soFile = new File(pluginDir, "lib/" + abi + "/" + soName);
if (soFile.exists()) {
return soFile.getAbsolutePath();
}
}
} catch (Exception e) {
Log.e("ImageLoader", "获取SO路径失败: " + e.getMessage());
}
return null;
}
/**
* 降级方案
*/
private static void useFallbackDecoder() {
// 使用Java层解码库
Log.w("ImageLoader", "使用降级解码器");
}
}
5.10.4 在插件中使用
scala
// 插件工程:Double11Activity.java
public class Double11Activity extends PluginContainerActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 加载修复后的SO库
ImageLoader.loadSoLibrary("libimage_decoder.so");
// 使用图片加载功能
loadImages();
}
private void loadImages() {
ImageView imageView = findViewById(R.id.iv_product);
// 使用SO库解码图片
Bitmap bitmap = NativeImageDecoder.decode(imagePath);
imageView.setImageBitmap(bitmap);
}
}
SO热修复流程时序图
┌──────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐
│ 用户 │ │ 宿主App │ │ Manager │ │ 服务器 │
└──┬───┘ └────┬────┘ └────┬────┘ └────┬─────┘
│ │ │ │
│ 打开双11页面 │ │ │
│─────────────>│ │ │
│ │ │ │
│ │ 检查SO修复 │ │
│ │──────────────>│ │
│ │ │ 请求配置 │
│ │ │──────────────>│
│ │ │ │
│ │ │ 返回新版本 │
│ │ │<──────────────│
│ │ │ │
│ │ │ 下载新插件包 │
│ │ │──────────────>│
│ │ │ │
│ │ │ 解压SO库 │
│ │ │ 更新数据库 │
│ │ │ │
│ │ │ 提示重启 │
│ │<──────────────│ │
│ │ │ │
│ 重启插件页面 │ │ │
│─────────────>│ │ │
│ │ │ │
│ │ 加载新SO库 │ │
│ │──────────────>│ │
│ │ │ │
│ 崩溃问题修复 │ │ │
│<─────────────│ │ │
│ │ │ │
热修复的总结:
核心结论 :Shadow的"热修复"本质上是插件全量更新。Manager通过版本对比,下载包含修复内容的新版本插件包,替换旧版本,下次加载时生效。
6. 业务场景总结表
| 需求 | 业务场景 | 触发时机 | 技术实现 |
|---|---|---|---|
| 1 | 加载双11插件 | 点击Tab、App启动 | Shadow动态加载 |
| 2 | 插件崩溃处理 | 运行中崩溃 | 异常捕获+自动恢复 |
| 3 | 卸载插件 | 活动结束、存储不足 | 数据迁移+文件删除 |
| 4 | 加载Activity | 进入活动页面 | Shadow代理Activity |
| 5 | 加载Fragment | 首页嵌入活动 | 类加载+注入 |
| 6 | 宿主插件通信 | 数据同步、用户操作 | 接口通信+事件总线 |
| 7 | 热更新策略 | 版本更新、bug修复 | 灰度发布+版本管理 |
| 8 | 类热修复 | 逻辑错误、崩溃 | Dex替换+类加载 |
| 9 | 资源热修复 | 图片侵权、文案错误 | 资源包替换 |
| 10 | SO库热修复 | Native崩溃、性能问题 | SO文件替换 |
7. 上线后实际效果对比
7.1. 崩溃率统计
数据对比(双11活动期间)
| 指标 | 使用插件化前 | 使用插件化后 | 改善率 |
|---|---|---|---|
| 整体应用崩溃率 | 0.32% | 0.28% | ↓ 12.5% |
| 双11活动模块崩溃率 | N/A (发版固定) | 0.15% | - |
| 插件崩溃影响宿主率 | N/A | 0% | 完全隔离 |
| 崩溃自动恢复成功率 | N/A | 78.3% | - |
关键发现:
- 插件化并未显著增加整体崩溃率,反而通过进程隔离机制,有效防止了单个模块崩溃影响整个应用。
- 通过内置的崩溃恢复机制,近八成插件崩溃可在用户无感知的情况下自动恢复。
- 双11活动高峰期(0:00-0:30)插件加载请求激增,但崩溃率稳定在0.2%以下,证明Shadow的稳定性可满足大促场景需求。
7.2. 插件性能监控
核心性能指标
| 指标 | 平均值 | P95 | P99 | 说明 |
|---|---|---|---|---|
| 首次插件加载耗时 | 1.2s | 2.8s | 4.5s | 包含下载、解压、安装、启动全流程 |
| 二次启动耗时 | 0.3s | 0.6s | 0.9s | 插件已安装,仅需加载启动 |
| 插件Activity启动耗时 | 0.15s | 0.3s | 0.5s | 与原生Activity启动耗时相当 |
| 插件与宿主通信耗时 | 8ms | 25ms | 50ms | Binder跨进程调用 |
| 插件内存占用 | 35MB | 68MB | 95MB | 独立进程,不影响宿主 |
优化成果:
- 通过预加载策略,将首次启动的感知时间从平均2.8秒优化至1.2秒,降幅57%。
- 采用异步加载+骨架屏方案,用户感知加载时长缩短至0.8秒内。
- 插件独立进程的内存占用得到有效控制,未出现OOM问题。
7.3. 性能监控详细数据
加载时间分解
erlang
首次加载双11插件 (总耗时 1200ms)
├── 插件包下载 (网络) 500ms ████████████████░░░░ 41.7%
├── MD5校验 + 解压 200ms ████████░░░░░░░░░░░░ 16.7%
├── 插件安装 (Dex优化) 300ms ████████████░░░░░░░░ 25.0%
├── 进程启动 150ms ██████░░░░░░░░░░░░░░ 12.5%
└── Activity初始化 50ms ██░░░░░░░░░░░░░░░░░░ 4.1%
内存占用趋势
| 时间节点 | 宿主内存 (MB) | 双11插件进程 (MB) | 说明 |
|---|---|---|---|
| 插件未加载 | 180 | - | 基线 |
| 插件预加载后 | 185 | 0 | 仅下载,未启动进程 |
| 首次打开插件页 | 190 | 42 | 插件进程启动 |
| 浏览商品详情 | 195 | 68 | 图片加载,内存峰值 |
| 退出插件页后 | 188 | 35 | 部分缓存保留 |
| 卸载插件后 | 181 | - | 完全释放 |
性能优化建议:
- 预加载策略:在用户可能打开插件前(如启动App后3秒),提前下载插件包,但不启动进程。
- 资源按需加载:插件内的图片、布局等资源采用懒加载策略,避免一次性加载过多。
- 进程生命周期管理:退出插件页面后,延迟30秒再销毁进程,避免频繁创建销毁的开销。
7.4. 四大组件使用案例
Activity 使用示例
java
// 插件内启动新的Activity
public class FlashSaleActivity extends PluginContainerActivity {
private void openProductDetail(String productId) {
// 标准Intent跳转
Intent intent = new Intent(this, ProductDetailActivity.class);
intent.putExtra("product_id", productId);
startActivity(intent);
// 也支持startActivityForResult
// startActivityForResult(intent, REQUEST_CODE);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE && resultCode == RESULT_OK) {
// 处理返回结果
refreshCart();
}
}
}
Service 使用示例
java
// 1. 在插件中定义Service
public class DownloadService extends PluginService {
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String taskId = intent.getStringExtra("task_id");
// 执行下载任务
startDownload(taskId);
return START_NOT_STICKY;
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
}
// 2. 在宿主中启动插件Service
public void startPluginDownload(String taskId) {
Intent intent = new Intent();
intent.setClassName(getPackageName(),
"com.double11.plugin.DownloadService");
intent.putExtra("task_id", taskId);
try {
mPluginLoader.startPluginService(intent);
} catch (RemoteException e) {
// 降级:使用宿主自身的下载服务
startHostDownload(taskId);
}
}
// 3. 绑定插件Service(用于通信)
public void bindPluginService() {
Intent intent = new Intent();
intent.setClassName(getPackageName(),
"com.double11.plugin.DownloadService");
try {
mPluginLoader.bindPluginService(intent, new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
// 获取Binder,可以调用Service的方法
IDownloadService downloadService =
IDownloadService.Stub.asInterface(service);
// 进行通信...
}
@Override
public void onServiceDisconnected(ComponentName name) {
// 处理断开连接
}
}, Context.BIND_AUTO_CREATE);
} catch (RemoteException e) {
e.printStackTrace();
}
}
BroadcastReceiver 使用示例 (替代方案)
由于Shadow对BroadcastReceiver支持有限,推荐使用LocalBroadcastManager 或EventBus作为替代:
java
// 插件内发送事件
public class CartManager {
public void addToCart(String productId) {
// 插件内部处理...
// 使用EventBus发送事件(同进程)
EventBus.getDefault().post(new CartUpdatedEvent(productId));
// 或使用LocalBroadcastManager
Intent intent = new Intent("ACTION_CART_UPDATED");
intent.putExtra("product_id", productId);
LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
}
}
// 宿主接收事件
public class CartActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// EventBus方式
EventBus.getDefault().register(this);
// LocalBroadcastManager方式
LocalBroadcastManager.getInstance(this).registerReceiver(
mCartReceiver,
new IntentFilter("ACTION_CART_UPDATED")
);
}
@Subscribe
public void onCartUpdated(CartUpdatedEvent event) {
// 更新购物车UI
refreshCartUI();
}
private BroadcastReceiver mCartReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String productId = intent.getStringExtra("product_id");
refreshCartUI(productId);
}
};
}
ContentProvider 使用示例 (替代方案)
对于数据共享需求,推荐使用宿主提供的ContentProvider 或公共数据库:
java
// 宿主提供ContentProvider
public class AppDataProvider extends ContentProvider {
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
// 返回宿主的数据,如用户信息、购物车数据等
return getCartDataCursor();
}
@Override
public Uri insert(Uri uri, ContentValues values) {
// 插件调用此方法插入数据
String partKey = uri.getQueryParameter("part_key");
// 根据partKey区分数据来源
return insertCartData(values);
}
}
// 插件中访问
public class Double11Plugin {
private void syncCartToHost(String productId, int count) {
ContentValues values = new ContentValues();
values.put("product_id", productId);
values.put("count", count);
values.put("source", "double11_plugin");
Uri uri = Uri.parse("content://com.double11.app/cart");
getContentResolver().insert(uri, values);
}
}
8. Shadow插件化与ARouter组件化如何共存
8.1. 核心挑战
在同时使用Shadow插件化和ARouter组件化的项目中,面临的核心问题是:
| 挑战 | 说明 |
|---|---|
| 路由表独立 | 插件和宿主各自维护自己的ARouter路由表,无法互相感知 |
| ClassLoader隔离 | ARouter在宿主ClassLoader中无法直接加载插件内的类 |
| 注解处理器冲突 | 两者都依赖编译时注解处理,可能产生冲突 |
| 依赖管理复杂 | 需要同时管理ARouter和Shadow的依赖,版本兼容性风险 |
8.2. 混合架构方案
方案一:宿主使用ARouter,插件内用原生跳转(推荐)
java
宿主工程 插件工程
┌─────────────────┐ ┌─────────────────┐
│ 使用ARouter │ │ 使用原生Intent │
│ │ │ │
│ @Route(path) │ │ startActivity()│
│ navigation() │◄─────────────│ │
│ │ 通过Intent │ │
│ │ 传递路由信息 │ │
└─────────────────┘ └─────────────────┘
实现代码:
java
// 宿主中定义路由
@Route(path = "/host/cart/activity")
public class CartActivity extends AppCompatActivity {
// 宿主内的页面
}
// 插件内跳转到宿主的页面
public class Double11PluginActivity extends PluginContainerActivity {
private void openCartPage() {
// 方式1:通过ARouter的Intent构建
Intent intent = ARouter.getInstance()
.build("/host/cart/activity")
.withString("from", "double11")
.getIntent();
startActivity(intent);
// 方式2:直接通过类名跳转(更简单)
Intent intent = new Intent();
intent.setClassName(getPackageName(),
"com.double11.app.CartActivity");
startActivity(intent);
}
}
方案二:路由服务化(更解耦)
将路由能力封装成服务,通过Shadow的通信机制暴露给插件:
java
// 1. 公共模块定义路由服务接口
public interface IRouterService extends IInterface {
void navigate(String path, Bundle params);
abstract class Stub extends Binder implements IRouterService {
// AIDL风格实现
}
}
// 2. 宿主实现路由服务
public class RouterServiceImpl extends IRouterService.Stub {
@Override
public void navigate(String path, Bundle params) {
// 使用ARouter进行跳转
Postcard postcard = ARouter.getInstance().build(path);
if (params != null) {
postcard.with(params);
}
postcard.navigation();
}
}
// 3. 加载插件时注入路由服务
public class PluginManager {
public void loadPlugin(String partKey) {
Bundle bundle = new Bundle();
bundle.putBinder("router_service", new RouterServiceImpl().asBinder());
mPluginManager.enter(context, partKey, bundle, callback);
}
}
// 4. 插件内调用
public class Double11PluginActivity extends PluginContainerActivity {
private IRouterService mRouterService;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
IBinder binder = getIntent().getExtras().getBinder("router_service");
mRouterService = IRouterService.Stub.asInterface(binder);
}
private void openCartPage() {
Bundle params = new Bundle();
params.putString("from", "double11");
mRouterService.navigate("/host/cart/activity", params);
}
}
8.3. 优缺点总结
| 方案 | 优点 | 缺点 |
|---|---|---|
| 方案一:混合使用 | 实现简单,改造成本低 | 插件内无法享受ARouter的便利性 |
| 方案二:路由服务化 | 完全解耦,插件也能享受路由能力 | 需要额外开发,有一定学习成本 |
8.4. 关于ComboLite 2.0等新框架
随着2025年ComboLite 2.0等新一代框架的出现,插件化技术也在演进:
- 多端融合:ComboLite 2.0支持在Flutter页面中嵌入WebView或小程序容器,实现了跨技术栈的融合。
- 调试能力:新框架普遍加强了调试支持,解决了传统插件化框架"插件内无法Debug"的痛点。
- 轻量级:相比Shadow,新框架更加轻量,对工程侵入性更小。
启示:技术选型时需要前瞻性地评估框架的生命周期和社区活跃度,避免陷入技术债务。
9. 深度思考:能否用Shadow加载第三方APK?
9.1. 结论
不能。Shadow无法直接加载一个未经适配的第三方APK。
9.2. 根本原因
-
编译时Transform处理 :Shadow要求在编译阶段对插件代码进行Transform,将原生的
Activity替换为PluginContainerActivity,Service替换为PluginService等。未经处理的第三方APK无法在Shadow环境中运行。 -
资源ID冲突 :Shadow通过修改
R.java中的资源ID,为每个插件分配独立的资源段,避免与宿主或其他插件冲突。第三方APK的资源ID未经过此处理,必然导致资源访问错误。 -
ClassLoader隔离:插件运行在独立的ClassLoader中,需要插件代码适配这种隔离机制。第三方APK的代码默认假设自己运行在主ClassLoader中,会因找不到类而崩溃。
-
生命周期管理:Shadow对插件内四大组件的生命周期进行了接管,需要插件主动配合。第三方APK没有这种配合,会导致生命周期回调异常。
9.3. 类比理解
就像微信给了一个入口跳转京东,京东的APK并不是作为一个插件运行在微信内部,而是:
- 通过Scheme跳转拉起京东App(独立进程)
- 或者通过小程序的方式运行(京东需要专门开发微信小程序版本)
Shadow的角色更像是"小程序容器",而不是"虚拟机"。它要求插件主动适配,而不是被动加载。
9.4. 如果要实现"加载任意APK"需要什么?
如果要实现类似功能,需要:
- Hook框架:使用Xposed、VirtualApp等框架,通过Hook系统API来模拟运行环境。
- 资源处理:运行时动态修改资源ID,解决冲突问题。
- Dex加载 :使用
DexClassLoader加载APK的Dex,但需要解决ClassLoader隔离问题。 - 四大组件模拟:通过占坑Activity的方式,模拟插件内Activity的启动。
这些方案技术难度极高,且存在兼容性和稳定性问题,不是Shadow的设计目标。
9.5. 正确使用姿势
Shadow的正确使用姿势是:插件开发者主动接入,遵循Shadow规范,享受插件化带来的动态能力。
scss
第三方SDK
│
▼
是否需要动态加载?
│
├── 是 ──▶ 使用Shadow规范开发插件
│ (需要第三方配合)
│
└── 否 ──▶ 传统集成方式
(直接依赖SDK)
9.6. 最佳实践建议
- 明确目标:Shadow适合"自己开发、自己分发"的插件,不适合"加载任意第三方APK"。
- 合作开发:如果需要集成第三方动态能力,应与第三方协商,共同按Shadow规范开发插件版本。
- 小程序替代:对于无法配合的第三方,可以考虑使用小程序容器(如微信小程序SDK)作为替代方案。
10:项目的地址:
Shadow作为一个强大的插件化框架,为电商大促等需要高度灵活性的业务场景提供了完美的解决方案。通过两年的实践,我们验证了其在稳定性、性能和动态化能力上的优势。
当然,道路并非一帆风顺,我们遇到了调试难、通信复杂、架构改造等挑战。但通过深入理解Shadow的设计思想(尤其是Loader和Runtime机制),并结合服务注册中心、Binder跨进程通信、全动态化下发等设计模式,这些问题都得到了妥善解决。