Speed Tools:一套低侵入的 Android 插件化 + 动态换肤 + 字体切换框架

、前言

几年前我在 CSDN 写过一篇 《android 插件化框架 speed-tools》,那时候框架只解决了"不安装 APK 就能加载页面"这一个核心诉求。几年过去,Android 系统经历了多次大版本迭代,插件化方案也面临新的挑战:

Android 10 收紧了私有目录访问;

Android 14 彻底禁止了加载可写 dex 文件;

业务侧对"动态换肤"和"全局字体调节"的需求越来越强烈。

于是我把整个项目从里到外升级了一遍:

构建基线从 Support Library + compileSdk 28 升级到 AndroidX + compileSdk 35 + AGP 8.8.2;

类加载策略在 Android 8.0+ 切换为 InMemoryDexClassLoader(内存加载),天然规避 Android 14+ 的文件权限限制;

新增 运行时换肤和运行时字体切换两大能力。

本文会带你从 0 到 1 跑通整个 Demo,并理解背后的核心原理。

二、Speed Tools 能做什么?

一句话概括:一套面向 Android 的本地插件化框架,同时附带换肤和字体调节能力。

能力 一句话说明 典型场景

插件化 宿主加载未安装的 APK,代理启动插件页面 多业务独立演进、按插件解耦

动态换肤 运行时加载皮肤包 APK,替换颜色/图片/背景 夜间模式、节日主题、品牌定制

字体调节 运行时全局调整字体大小,支持用户偏好持久化 无障碍适配、老年模式

2.1 为什么不用 Google Play Dynamic Delivery?

国内应用商店生态复杂,很多渠道不支持 PAD;

需要完全本地可控,不依赖外部服务;

希望低侵入接入现有工程,而非改造成 Dynamic Feature Module 结构。

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 演示了换肤和字体切换的完整链路。

四、10 分钟跑通 Demo

4.1 环境要求

Android Studio(推荐最新稳定版)

JDK 17

compileSdk 35 / minSdk 21 / targetSdk 35

4.2 编译插件和皮肤包

编译插件 APK

./gradlew :module_client_one:assembleDebug

./gradlew :module_client_two:assembleDebug

编译皮肤包

./gradlew :black_theme:assembleDebug

4.3 放置 APK 到 assets

把编译产物复制到对应目录:

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

4.4 运行

选择运行配置 module_host_main → 启动后自动加载插件,点击按钮进入插件页面;

选择运行配置 theme_demo → 依次体验:切换黑色主题 → 恢复默认 → 放大字体 → 恢复字体。

到这里,你已经亲眼见证了框架的三项核心能力。

五、插件化核心原理

5.1 整体架构

┌─────────────────────────────────────────┐

│ 宿主 APK │

│ ┌─────────┐ ┌─────────────────────┐ │

│ │ Assets │──▶│ SpeedApkManager │ │

│ │ (插件) │ │ (类加载 + 资源桥接) │ │

│ └─────────┘ └─────────────────────┘ │

│ │ │

│ ▼ │

│ ┌───────────────────────────────────┐ │

│ │ 代理 Activity │ │

│ │ (转发 onCreate/onResume/onDestroy)│ │

│ └───────────────────────────────────┘ │

└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐

│ 插件 APK(未安装) │

│ ┌──────────────┐ ┌──────────────┐ │

│ │ 业务实现类 │ │ res/ │ │

│ │(继承接口) │◀──│ (资源文件) │ │

│ └──────────────┘ └──────────────┘ │

└─────────────────────────────────────────┘

5.2 类加载:InMemoryDexClassLoader 如何规避 Android 14 限制

Android 14 引入了一条硬性限制:禁止加载可写的 dex 文件。旧方案使用 DexClassLoader 直接加载 APK 路径,如果文件权限是可写的,就会抛出:

java.lang.SecurityException: Writable dex file ... is not allowed.

Speed Tools 的解决思路很直接:

Android 8.0+(API 26):从 APK 中解压出 classes.dex,读取到内存 ByteBuffer,通过 InMemoryDexClassLoader 加载。dex 数据完全在内存中,不走文件系统,自然不受"可写"限制。

Android 5.0~7.1(API 21~25):回退到传统的 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);

}

5.3 资源桥接

插件的 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) 就能正确找到插件自己的布局了。

5.4 生命周期转发

插件业务类不直接继承 Activity,而是继承 SpeedBaseInterfaceImp,实现一套与 Activity 对应的生命周期接口。宿主端的 SpeedHostBaseActivity 作为"壳",在 onCreate、onResume、onDestroy 等节点调用插件实现类的对应方法,完成生命周期转发。

六、插件化接入实战

6.1 宿主侧:加载插件

// 优先从外部目录查找,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

);

6.2 宿主侧:跳转插件

SpeedUtils.goActivity(this, "first_apk", null);

第三个参数 classTag 对应插件 AndroidManifest.xml 中 meta-data 的 name,为空时走默认入口。

6.3 插件侧:声明入口

插件业务类需要实现 SpeedBaseInterface,代理层会通过反射实例化这个类。

七、换肤与字体切换

7.1 核心设计

换肤和字体调节的本质是资源替换:

换肤:拦截 LayoutInflater 创建 View 的过程,把颜色/图片资源替换为皮肤包中的同名资源;

字体:拦截 textSize 属性的读取,在基础值上叠加用户设置的偏移量。

为了框架能识别哪些资源需要被替换,规定了强制前缀:

类型 前缀 示例

颜色/背景/图片 cxt_ @color/cxt_primary

字体维度 cxf_ @dimen/cxf_normal

7.2 三步接入

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();

7.3 皮肤包怎么制作?

皮肤包就是一个只包含资源、不含业务代码的普通 Android App 模块:

新建 Android App 模块;

在 res/ 中放置与主工程同名的 cxt_* / cxf_* 资源;

打包生成 APK。

主工程和皮肤包的资源名必须完全一致,值可以不同。运行时 SPThemeManager 会读取皮肤包 APK 的资源,通过 AssetManager.addAssetPath 建立资源上下文,完成替换。

八、踩坑记录

8.1 Android 14 上插件加载失败

现象:SecurityException: Writable dex file is not allowed

根因:Android 14 禁止加载可写 dex 文件

解决:框架已在 Android 8.0+ 自动切换 InMemoryDexClassLoader,无需业务侧改动。如果仍在维护旧版本框架,可参考本文 5.2 节的实现思路自行迁移。

8.2 换肤后页面没有变化

按这个清单逐条排查:

Application 是否初始化了 SPThemeManager 和 SPFontManager;

Activity 是否 registerUpdateUI / unRegisterUpdateUI;

资源名是否用了 cxt_ / cxf_ 前缀;

是否调用了 sendUpdateUIAction() 触发刷新。

8.3 插件页面白屏

检查插件 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')

}

迁移收益:

直接获得 Android 14 兼容性修复;

获得换肤和字体切换能力;

相关推荐
浮江雾2 小时前
Flutter第三节----Dart中的数据类型
android·开发语言·学习·flutter·入门·函数
风和先行2 小时前
Android 数据库相关学习总结
android·数据库·学习
杉氧3 小时前
Compose 渲染内核 (3):Slot Table 与 Gap Buffer —— 极速重组的数学艺术
android·架构·android jetpack
阿巴斯甜4 小时前
ContentObserver的使用:
android
casual_clover4 小时前
【Android 控件】使用 Navigation 实现底部导航功能时左右滑动退出页面功能
android·底部导航滑动退出
urkay-4 小时前
Kotlin Flow分类
android·开发语言·kotlin
阿pin5 小时前
Android随笔-MVC/MVP/MVVM/MVI
android·mvc·mvvm·mvp·mvi
阿pin5 小时前
Android随笔-View绘制流程
android·开发语言·view
2501_916008895 小时前
iOS 证书管理最佳实践 从创建到续期的完整指南
android·ios·小程序·https·uni-app·iphone·webview