






















作者:一航
GitHub:jasonliyihang/speed_tools
博客首发于 CSDN,本文基于 2026 年最新代码重构整理。
几年前我在 CSDN 写过一篇 [《android 插件化框架 speed-tools》],那时候框架只解决了"不安装 APK 就能加载页面"这一个核心诉求。几年过去,Android 系统经历了多次大版本迭代,插件化方案也面临新的挑战:
于是我把整个项目从里到外升级了一遍:
本文会带你从 0 到 1 跑通整个 Demo,并理解背后的核心原理。
一句话概括:一套面向 Android 的本地插件化框架,同时附带换肤和字体调节能力。
| 能力 | 一句话说明 | 典型场景 |
|---|---|---|
| 插件化 | 宿主加载未安装的 APK,代理启动插件页面 | 多业务独立演进、按插件解耦 |
| 动态换肤 | 运行时加载皮肤包 APK,替换颜色/图片/背景 | 夜间模式、节日主题、品牌定制 |
| 字体调节 | 运行时全局调整字体大小,支持用户偏好持久化 | 无障碍适配、老年模式 |
Speed Tools 的定位就是:本地可控、低侵入、开箱即用。
speed_tools/
├── lib_speed_tools/ # 核心库(插件加载、代理、换肤、字体)
├── module_host_main/ # 宿主示例 App
├── module_client_one/ # 插件示例 1
├── module_client_two/ # 插件示例 2
├── theme_demo/ # 换肤与字体切换演示 App
├── black_theme/ # 皮肤包示例(纯资源,无业务代码)
└── lib_img_utils/ # 第三方图片库测试模块
lib_speed_tools 是唯一的依赖入口,宿主和插件都只依赖它;module_host_main 演示了如何加载插件并跳转;theme_demo 演示了换肤和字体切换的完整链路。# 编译插件 APK
./gradlew :module_client_one:assembleDebug
./gradlew :module_client_two:assembleDebug
# 编译皮肤包
./gradlew :black_theme:assembleDebug
把编译产物复制到对应目录:
module_host_main/src/main/assets/
├── module_client_one-debug.apk
└── module_client_two-debug.apk
theme_demo/src/main/assets/
└── black_theme-debug.apk
module_host_main → 启动后自动加载插件,点击按钮进入插件页面;theme_demo → 依次体验:切换黑色主题 → 恢复默认 → 放大字体 → 恢复字体。到这里,你已经亲眼见证了框架的三项核心能力。
┌─────────────────────────────────────────┐
│ 宿主 APK │
│ ┌─────────┐ ┌─────────────────────┐ │
│ │ Assets │──▶│ SpeedApkManager │ │
│ │ (插件) │ │ (类加载 + 资源桥接) │ │
│ └─────────┘ └─────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────┐ │
│ │ 代理 Activity │ │
│ │ (转发 onCreate/onResume/onDestroy)│ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 插件 APK(未安装) │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 业务实现类 │ │ res/ │ │
│ │(继承接口) │◀──│ (资源文件) │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────┘
Android 14 引入了一条硬性限制:禁止加载可写的 dex 文件。旧方案使用 DexClassLoader 直接加载 APK 路径,如果文件权限是可写的,就会抛出:
java.lang.SecurityException: Writable dex file ... is not allowed.
Speed Tools 的解决思路很直接:
classes.dex,读取到内存 ByteBuffer,通过 InMemoryDexClassLoader 加载。dex 数据完全在内存中,不走文件系统,自然不受"可写"限制。DexClassLoader,这些旧版本没有此限制。核心代码片段(SpeedUtils.java):
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ByteBuffer[] dexBuffers = extractDexBuffersFromApk(apkPath);
return new InMemoryDexClassLoader(dexBuffers, appContext.getClassLoader());
} else {
return new DexClassLoader(apkPath, optimizedDir, nativeLibDir, parent);
}
插件的 res/ 资源如何被宿主识别?答案是反射创建 AssetManager:
AssetManager assetManager = AssetManager.class.getDeclaredConstructor().newInstance();
Method addAssetPath = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, apkPath);
Resources pluginRes = new Resources(assetManager, hostMetrics, hostConfig);
宿主代理 Activity 持有这份 pluginRes,插件页面里的 setContentView(R.layout.xxx) 就能正确找到插件自己的布局了。
插件业务类不直接继承 Activity,而是继承 SpeedBaseInterfaceImp,实现一套与 Activity 对应的生命周期接口。宿主端的 SpeedHostBaseActivity 作为"壳",在 onCreate、onResume、onDestroy 等节点调用插件实现类的对应方法,完成生命周期转发。
// 优先从外部目录查找,fallback 到 assets 拷贝
File apkFile = SpeedUtils.resolvePluginApk(
context, "/sdcard/Download",
"module_client_one-debug.apk"
);
SpeedApkManager.getInstance().loadApk(
"first_apk", // 插件 key
apkFile.getAbsolutePath(),
"dex_output2", // dex 优化目录(每个插件独立)
context
);
SpeedUtils.goActivity(this, "first_apk", null);
第三个参数 classTag 对应插件 AndroidManifest.xml 中 meta-data 的 name,为空时走默认入口。
<application>
<meta-data
android:name="root_class"
android:value="com.example.clientdome.ClientMainActivity" />
</application>
插件业务类需要实现 SpeedBaseInterface,代理层会通过反射实例化这个类。
换肤和字体调节的本质是资源替换:
LayoutInflater 创建 View 的过程,把颜色/图片资源替换为皮肤包中的同名资源;textSize 属性的读取,在基础值上叠加用户设置的偏移量。为了框架能识别哪些资源需要被替换,规定了强制前缀:
| 类型 | 前缀 | 示例 |
|---|---|---|
| 颜色/背景/图片 | cxt_ |
@color/cxt_primary |
| 字体维度 | cxf_ |
@dimen/cxf_normal |
Step 1:Application 初始化
@Override
public void onCreate() {
super.onCreate();
SPFontManager.getInstance().init(this);
SPThemeManager.getInstance().init(this);
}
Step 2:Activity 注册监听
public class BaseActivity extends AppCompatActivity implements SPUpdateUIListener {
@Override
protected void onCreate(Bundle savedInstanceState) {
SPThemeManager.getInstance().registerUpdateUI(this);
super.onCreate(savedInstanceState);
}
@Override
protected void onDestroy() {
SPThemeManager.getInstance().unRegisterUpdateUI(this);
super.onDestroy();
}
@Override
public void updateUI(boolean isFistLoading) {
// 自定义控件手动刷新
}
}
Step 3:触发切换
// 换肤
SPThemeManager.getInstance()
.changeTheme("black_theme-debug.apk")
.sendUpdateUIAction();
// 字体放大
SPFontManager.getInstance().changeConfig(40).updateUI();
皮肤包就是一个只包含资源、不含业务代码的普通 Android App 模块:
res/ 中放置与主工程同名的 cxt_* / cxf_* 资源;主工程和皮肤包的资源名必须完全一致,值可以不同。运行时 SPThemeManager 会读取皮肤包 APK 的资源,通过 AssetManager.addAssetPath 建立资源上下文,完成替换。
现象:SecurityException: Writable dex file is not allowed
根因:Android 14 禁止加载可写 dex 文件
解决:框架已在 Android 8.0+ 自动切换 InMemoryDexClassLoader,无需业务侧改动。如果仍在维护旧版本框架,可参考本文 5.2 节的实现思路自行迁移。
按这个清单逐条排查:
SPThemeManager 和 SPFontManager;registerUpdateUI / unRegisterUpdateUI;cxt_ / cxf_ 前缀;sendUpdateUIAction() 触发刷新。AndroidManifest.xml 的 meta-data 入口声明是否正确;lib_speed_tools 版本是否一致。如果你还在使用早期的 com.liyihangjson:speed_tools:1.1.1 Maven 依赖,建议迁移到源码依赖:
// settings.gradle
include ':lib_speed_tools'
// app/build.gradle
dependencies {
implementation project(':lib_speed_tools')
}
迁移收益:
Speed Tools 从最早的"纯插件化"框架,逐步演变成了插件化 + 换肤 + 字体调节的三位一体工具集。本次升级的核心是适配新时代 Android 系统的安全限制(Android 14 的 dex 文件权限),同时响应业务侧对动态 UI 的诉求。
项目完全开源,欢迎 Star 和 PR。
如有问题,欢迎在 GitHub Issues 交流。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。