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 分钟跑通 Demo4.1 环境要求Android Studio推荐最新稳定版JDK 17compileSdk 35 / minSdk 21 / targetSdk 354.2 编译插件和皮肤包# 编译插件 APK ./gradlew :module_client_one:assembleDebug ./gradlew :module_client_two:assembleDebug # 编译皮肤包 ./gradlew :black_theme:assembleDebug4.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.apk4.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.0API 26从 APK 中解压出classes.dex读取到内存ByteBuffer通过InMemoryDexClassLoader加载。dex 数据完全在内存中不走文件系统自然不受可写限制。Android 5.0~7.1API 21~25回退到传统的DexClassLoader这些旧版本没有此限制。核心代码片段SpeedUtils.javaif (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/资源如何被宿主识别答案是反射创建AssetManagerAssetManager 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 插件侧声明入口application meta-data android:nameroot_class android:valuecom.example.clientdome.ClientMainActivity / /application插件业务类需要实现SpeedBaseInterface代理层会通过反射实例化这个类。七、换肤与字体切换7.1 核心设计换肤和字体调节的本质是资源替换换肤拦截LayoutInflater创建 View 的过程把颜色/图片资源替换为皮肤包中的同名资源字体拦截textSize属性的读取在基础值上叠加用户设置的偏移量。为了框架能识别哪些资源需要被替换规定了强制前缀类型前缀示例颜色/背景/图片cxt_color/cxt_primary字体维度cxf_dimen/cxf_normal7.2 三步接入Step 1Application 初始化Override public void onCreate() { super.onCreate(); SPFontManager.getInstance().init(this); SPThemeManager.getInstance().init(this); }Step 2Activity 注册监听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和SPFontManagerActivity 是否registerUpdateUI/unRegisterUpdateUI资源名是否用了cxt_/cxf_前缀是否调用了sendUpdateUIAction()触发刷新。8.3 插件页面白屏检查插件AndroidManifest.xml的meta-data入口声明是否正确检查插件资源是否齐全检查宿主和插件依赖的lib_speed_tools版本是否一致。九、从旧版本迁移如果你还在使用早期的com.liyihangjson:speed_tools:1.1.1Maven 依赖建议迁移到源码依赖// settings.gradle include :lib_speed_tools // app/build.gradle dependencies { implementation project(:lib_speed_tools) }迁移收益直接获得 Android 14 兼容性修复获得换肤和字体切换能力构建基线同步升级到 AndroidX AGP 8.x。十、总结Speed Tools 从最早的纯插件化框架逐步演变成了插件化 换肤 字体调节的三位一体工具集。本次升级的核心是适配新时代 Android 系统的安全限制Android 14 的 dex 文件权限同时响应业务侧对动态 UI 的诉求。