A
第 41 章JAVA40 分钟

热修复与插件化入门

热修复与插件化入门:ClassLoader/Dex 元素加载原理、Tinker(微信)热修复接入、VirtualApk(滴滴)插件化、动态加载 APK、Google Play 政策风险与替代方案(应用内更新/远程配置)。

学习目标

  • 理解 Android ClassLoader 与 Dex 元素加载机制
  • 掌握 Tinker 热修复的工作原理与接入流程
  • 了解 VirtualApk 插件化方案
  • 理解 Google Play 对动态代码加载的政策限制
  • 能够评估热修复风险并选择替代方案

学习目标

  • 理解 Android ClassLoader 与 Dex 元素加载机制;
  • 掌握 Tinker 热修复的工作原理与接入流程;
  • 了解 VirtualApk 插件化方案;
  • 理解 Google Play 对动态代码加载的政策限制;
  • 能够评估热修复风险并选择替代方案。

热修复与插件化入门

应用上线后发现一个严重 bug,传统流程是:修代码 → 打包 → 上架 → 审核 → 用户更新——周期 1-3 天,期间崩溃持续发生。热修复 让应用启动时从服务器下载“补丁包”动态替换有 bug 的方法,几乎实时生效。本章覆盖原理、主流方案、政策风险。

⚠️ 重要前置:Google Play 自 2019 年起禁止使用任何“运行时下载可执行代码并加载”的机制(含热修复、插件化)。本章知识对国内市场仍适用,Google Play 上架的应用不能使用 Tinker/VirtualApk 等方案——必须用应用内更新或远程配置。

一、原理:ClassLoader 与 Dex 元素

Android 应用编译后,所有 Java 代码被打包成 classes.dex(或多个 classes2.dex 等),APK 内的 dex 由 BaseDexClassLoader 加载。每个 BaseDexClassLoader 内部有一个 DexPathList,它持有一个 Element[] dexElements 数组——加载类时按顺序遍历数组找第一个匹配的。

// BaseDexClassLoader 内部(伪代码)
public class BaseDexClassLoader extends ClassLoader {

    private final DexPathList pathList;

    @Override
    protected Class<?> findClass(String name) {
        // 从 dexElements 数组依次查找
        Class<?> c = pathList.findClass(name, suppressed);
        if (c != null) return c;
        return super.findClass(name);  // 委托父 ClassLoader
    }
}

// DexPathList.findClass 遍历 dexElements
public Class<?> findClass(String name, ...) {
    for (Element element : dexElements) {
        Class<?> clazz = element.findClass(name);
        if (clazz != null) return clazz;
    }
    return null;
}

热修复的核心 trick:把补丁 dex 插到 dexElements 数组最前面,让修复后的类先于旧类被找到,从而“覆盖”原方法。

加载补丁的核心步骤

import dalvik.system.DexFile;
import dalvik.system.PathClassLoader;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.util.List;

public class Hotfix {

    /**
     * 把补丁 dex 文件插入到 application 的 dexElements 数组最前。
     * @param context  Application Context
     * @param patchDexPath  补丁 dex 在本地的路径
     */
    public static void installPatch(Context context, String patchDexPath) throws Exception {
        // 1. 拿到当前应用的 ClassLoader(PathClassLoader)
        ClassLoader appClassLoader = context.getClassLoader();
        //   PathClassLoader extends BaseDexClassLoader
        Class<?> baseDexClassLoaderClass = Class.forName("dalvik.system.BaseDexClassLoader");
        Field pathListField = baseDexClassLoaderClass.getDeclaredField("pathList");
        pathListField.setAccessible(true);
        Object pathList = pathListField.get(appClassLoader);

        // 2. 拿到 pathList.dexElements 数组
        Class<?> pathListClass = pathList.getClass();
        Field dexElementsField = pathListClass.getDeclaredField("dexElements");
        dexElementsField.setAccessible(true);
        Object[] originalDexElements = (Object[]) dexElementsField.get(pathList);

        // 3. 构建补丁 dex 的 Element[]
        Object[] patchElements = makeDexElements(pathList, patchDexPath, context);

        // 4. 合并:补丁在前,原始在后
        Object[] merged = combineArrays(patchElements, originalDexElements);

        // 5. 反射写回 dexElements
        dexElementsField.set(pathList, merged);
    }

    private static Object[] makeDexElements(Object pathList, String patchDexPath,
                                            Context context) throws Exception {
        // 用 DexPathList.makeDexElements 或 makePathElements(不同版本方法名不同)
        // 这里给出通用思路
        Class<?> pathListClass = pathList.getClass();
        Class<?> elementClass = Class.forName(
                "dalvik.system.DexPathList$Element");

        // 加载补丁 dex
        DexFile patchDex = DexFile.loadDex(patchDexPath,
                context.getCodeCacheDir().getAbsolutePath() + "/patch.odex", 0);

        // 创建 Element(构造方法签名不同版本不同,需做兼容)
        java.lang.reflect.Constructor<?> ctor = elementClass.getDeclaredConstructors()[0];
        ctor.setAccessible(true);
        Object element = ctor.newInstance(...);
        return (Object[]) Array.newInstance(elementClass, 1);
    }

    private static Object[] combineArrays(Object[] front, Object[] back) {
        Class<?> componentType = front.getClass().getComponentType();
        Object[] result = (Object[]) Array.newInstance(componentType,
                front.length + back.length);
        System.arraycopy(front, 0, result, 0, front.length);
        System.arraycopy(back, 0, result, front.length, back.length);
        return result;
    }
}

上面是核心原理的“手写版”,实际项目不要自己写——Android 版本兼容性极复杂(API 19/21/26/28 各有差异),用成熟框架。

类加载时机坑:CLASS_ISPREVERIFIED

Android 5.0 之前有个坑:类如果在原 dex 中验证通过,会被打上 CLASS_ISPREVERIFIED 标记。如果热修复尝试让这个类引用补丁中的新类,会抛 IllegalAccessError。Tinker 的解决方案是在原类中插入一个引用其他 dex 类的空字段(com.tinker.tinkerapp.AntiloadFakeClass),强制类不被预校验。Android 5.0+ 移除了该机制,问题不再存在。

二、Tinker:微信热修复方案

Tinker 是微信团队开源的热修复框架,支持类替换、so 库替换、资源替换,国内市场应用最广泛之一。

1. 工作流程

开发修复补丁 → 上传到 Tinker Server → 应用启动检查 → 下载补丁 → 下次启动生效

Tinker 默认是冷启动修复——下载补丁后需要重启 App 才生效(不重启涉及类已被加载到内存无法替换)。也有 热启动 模式(仅替换未加载的类),但应用范围有限。

2. 接入步骤

步骤一:根目录 build.gradle 添加 Tinker 插件:

buildscript {
    dependencies {
        classpath "com.tencent.tinker:tinker-patch-gradle-plugin:1.9.14.25"
    }
}

步骤二:app 模块 build.gradle:

android {
    compileSdk 34

    defaultConfig {
        // 必须关闭 Java 8+ 的 instant run 兼容
        // Tinker 用 tinkerId 区分补丁版本
        buildConfigField "String", "TINKER_ID", "\"1.0\""
    }

    dexOptions {
        jumboMode true   // 支持超多 string 常量
    }
}

dependencies {
    // Tinker 核心库(仅 release 生效,debug 用占位 stub)
    implementation("com.tencent.tinker:tinker-android-lib:1.9.14.25") { changing = true }
    annotationProcessor "com.tencent.tinker:tinker-android-anno:1.9.14.25"
    compileOnly "com.tencent.tinker:tinker-android-anno:1.9.14.25"
}

// 应用 Tinker 插件
apply from: 'tinker-support.gradle'

步骤三:Application 类加 @DefaultLifeCycle 注解:

import com.tencent.tinker.loader.app.DefaultApplicationLike;
import com.tencent.tinker.loader.app.TinkerApplication;
import com.tencent.tinker.loader.shareutil.ShareConstants;
import com.tencent.tinker.annotation.DefaultLifeCycle;

@DefaultLifeCycle(
    application = "com.example.app.MyApplication",
    flags = ShareConstants.TINKER_ENABLE_ALL,
    loaderClass = ShareConstants.TINKER_LOADER_CLASS
)
public class MyApplicationLike extends DefaultApplicationLike {

    public MyApplicationLike(Application application, int tinkerFlags,
                              boolean tinkerLoadVerifyFlag, long applicationStartElapsedTime,
                              long applicationStartMillisCount, Intent tinkerResultIntent) {
        super(application, tinkerFlags, tinkerLoadVerifyFlag,
                applicationStartElapsedTime, applicationStartMillisCount, tinkerResultIntent);
    }

    @Override
    public void onCreate() {
        super.onCreate();
        // 真实业务初始化
        Application app = getApplication();
        // 启动时检查是否有补丁
        TinkerManager.checkAndPatch(app);
    }
}

注解处理器会生成 MyApplication 类(继承 TinkerApplication),需要在 AndroidManifest.xml 注册它而非 MyApplicationLike:

<application
    android:name="com.example.app.MyApplication"
    ...>
</application>

步骤四:生成基准包与补丁

# 1. 构建 release 基准包(含 bug 的版本)
./gradlew assembleRelease

# 2. 修复代码后构建补丁
./gradlew tinkerPatchRelease
# 输出:app/build/outputs/apk/tinkerPatch/release/*.apk

生成的 *.apk 是补丁包,上传到自有 Tinker Server(或用 Tinker 平台)。

步骤五:应用启动时检查补丁

import com.tencent.tinker.lib.tinker.Tinker;
import com.tencent.tinker.lib.tinker.TinkerInstaller;

public class TinkerManager {

    public static void checkAndPatch(Context context) {
        // 初始化 Tinker
        Tinker tinker = Tinker.with(context);
        tinker.install();

        // 检查服务端是否有新补丁
        // 实际项目用自有后端 + 接口
        String serverPatchUrl = "https://example.com/api/tinker/latest";
        fetchPatchAndInstall(context, serverPatchUrl);
    }

    private static void fetchPatchAndInstall(Context context, String url) {
        // 下载补丁 APK 后调用:
        // TinkerInstaller.onReceiveUpgradePatch(context, patchPath);
    }
}

补丁下载成功后调 TinkerInstaller.onReceiveUpgradePatch 安装,下次启动生效。

三、VirtualApk:滴滴插件化方案

VirtualApk 是滴滴开源的 Android 插件化框架,支持动态加载未安装的 APK 文件作为“插件”,规避应用商店审核、按需加载功能模块。

1. 应用场景

  • 主 App 接入多个第三方业务模块(如外卖、酒店、票务),但部分功能不常开;
  • 大型电商把首页/详情/订单拆为独立 APK,热部署上线新模块;
  • 测试场景:A/B 测试不同代码版本。

2. 工作原理

VirtualApk 在宿主进程内:

  • 用 DexClassLoader 加载插件 APK 的 dex;
  • 通过 Hook Instrumentation / ActivityThread 等系统组件,让插件中声明的 Activity、Service、ContentProvider 能在宿主中启动;
  • 资源(layout/drawable/string)合并到一个 Resources 中,避免资源 ID 冲突。

3. 接入示例(宿主侧)

根目录 build.gradle:

buildscript {
    dependencies {
        classpath "com.didi.virtualapk:gradle:0.9.8"
    }
}

宿主 app/build.gradle:

apply plugin: 'com.didi.virtualapk.host'

dependencies {
    implementation "com.didi.virtualapk:core:0.9.8"
}

宿主 Application 注册插件:

import com.didi.virtualapk.PluginManager;

public class HostApp extends Application {

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        // 必须在 super 之前
        PluginManager.getInstance(base).init();
    }

    @Override
    public void onCreate() {
        super.onCreate();
        // 加载本地插件 APK
        File pluginApk = new File(getFilesDir(), "feature_order.apk");
        if (pluginApk.exists()) {
            PluginManager.getInstance(this).loadPlugin(pluginApk);
        }
    }
}

启动插件中的 Activity:

Intent intent = new Intent();
intent.setClassName("com.example.plugin",   // 插件包名
                    "com.example.plugin.OrderActivity");
startActivity(intent);

4. 插件侧配置

插件 build.gradle:

apply plugin: 'com.didi.virtualapk.plugin'

virtualApk {
    // 把宿主已有的依赖排除,避免重复加载
    forceCompatibility true
    packageId 0x6f   // 避免与宿主资源 ID 冲突
}

插件构建:

./gradlew assemblePlugin
# 输出:app/build/outputs/plugin/release/*.apk

VirtualApk 项目近年维护趋缓,新版本 Android (13/14) 兼容性需自行测试。生产项目建议评估 Shadow(腾讯最新插件化)或直接走 Dynamic Feature Module(AAB 内官方模块化)。

四、动态加载 APK 的通用思路

不依赖框架时,最小化动态加载外部代码:

import dalvik.system.DexClassLoader;

public class PluginLoader {

    public static Object loadAndInvoke(Context context, String apkPath,
                                        String className, String methodName) throws Exception {
        File dexOutput = context.getCodeCacheDir();  // dex 优化输出目录
        DexClassLoader loader = new DexClassLoader(
                apkPath,
                dexOutput.getAbsolutePath(),
                null,  // native 库目录
                context.getClassLoader()  // 父加载器
        );

        Class<?> clazz = loader.loadClass(className);
        Object instance = clazz.getDeclaredConstructor().newInstance();
        java.lang.reflect.Method method = clazz.getMethod(methodName);
        return method.invoke(instance);
    }
}

局限:

  • 不能直接启动插件中的 Activity/Service(需 Hook AMS);
  • 资源加载需自建 Resources 实例;
  • Android 9+ 对 hidden API 限制让 Hook 难度大幅增加。

五、Google Play 政策与风险

1. 政策原文(Device and Network Abuse)

“An app distributed via Google Play may not modify, replace, or update its own APK bytecode or add new executable code from a source other than Google Play.”

直白解读:Google Play 分发的应用不能下载并执行 Google Play 之外的代码——含 Tinker 补丁、VirtualApk 插件、DexClassLoader 加载外部 dex。

2. 为什么这么严

  • 安全:防止应用通过热修复绕过商店审核,发布恶意代码;
  • 可追溯:商店要能审查应用所有版本代码;
  • 公平竞争:防止绕过商店的“版本更新”机制(影响 Crashlytics、灰度发布等机制的公平性)。

3. 违规后果

  • 第一次发现:警告邮件,要求 7 天内移除相关代码;
  • 不响应:应用下架,开发者账号封禁;
  • 严重(如已上架病毒代码):直接封号 + 法律追责。

4. 国内 vs 海外策略

平台 热修复政策
Google Play 禁止任何动态代码加载
华为应用市场 默许,但要求补丁内容合规
小米应用商店 默许
应用宝 默许
OPPO/vivo 商店 默许

结论:国内市场用 Tinker/VirtualApk,Google Play 上架版本必须移除该代码或走替代方案。多渠道分发时做 BuildVariant 区分:

android {
    flavorDimensions 'market'
    productFlavors {
        google {
            dimension 'market'
            buildConfigField 'boolean', 'ENABLE_HOTFIX', 'false'
        }
        domestic {
            dimension 'market'
            buildConfigField 'boolean', 'ENABLE_HOTFIX', 'true'
        }
    }
}

代码里:

if (BuildConfig.ENABLE_HOTFIX) {
    TinkerManager.checkAndPatch(context);
} else {
    // Google Play 走 In-App Update(第 39 章)
    UpdateManager um = new UpdateManager(activity);
    um.checkForUpdate(callback);
}

六、替代方案

1. 应用内更新(In-App Update)

Google Play 官方方案,见第 39 章:

  • Flexible:后台下载 + 提示用户重启;
  • Immediate:全屏强制;
  • 配合 updatePriority 区分严重度。

优势:合规、官方维护;劣势:需用户同意更新、需重新安装,无法精准替换单个方法。

2. Firebase Remote Config

不需要发版就能改应用行为——服务端配置下发,应用读取后改 UI/功能:

import com.google.firebase.remoteconfig.FirebaseRemoteConfig;
import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings;

public class FeatureFlagManager {

    private final FirebaseRemoteConfig remoteConfig;

    public FeatureFlagManager() {
        remoteConfig = FirebaseRemoteConfig.getInstance();
        remoteConfig.setConfigSettingsAsync(
                FirebaseRemoteConfigSettings.Builder()
                        .setMinimumFetchIntervalInSeconds(3600)
                        .build());
        remoteConfig.setDefaultsAsync(R.xml.remote_config_defaults);
    }

    public void fetchAndActivate(Runnable onActivated) {
        remoteConfig.fetchAndActivate()
                .addOnCompleteListener(task -> {
                    if (task.isSuccessful()) {
                        boolean changed = task.getResult();
                        Log.d("RC", "Config updated: " + changed);
                    }
                    onActivated.run();
                });
    }

    public boolean isFeatureEnabled(String featureKey) {
        return remoteConfig.getBoolean(featureKey);
    }

    public String getString(String key) {
        return remoteConfig.getString(key);
    }
}

res/xml/remote_config_defaults.xml:

<?xml version="1.0" encoding="utf-8"?>
<defaults>
    <entry>
        <key>new_payment_enabled</key>
        <value>false</value>
    </entry>
    <entry>
        <key>maintenance_message</key>
        <value></value>
    </entry>
</defaults>

服务端在 Firebase Console 修改 value,应用下次启动拉取后行为改变。适合:

  • 灰度开关功能:发现 bug 立即把 feature_enabled = false 关闭功能;
  • 维护公告:服务端下发“维护中”文案,应用展示;
  • 配置参数:超时时间、广告位 ID 等不依赖代码的调整。

优势:合规、实时(5-60 分钟生效)、无需重发版;劣势:不能改逻辑代码,只能改参数与开关。

3. JS 壳:动态下发脚本

如果业务模块用 JS/动态语言(如 React Native、Lua),下发脚本到 WebView/JS 引擎执行——技术上仍属“动态代码加载”,Google Play 政策允许 JS 在 WebView 内执行(WebView 文档),但 React Native 的 JS Bundle 不算 WebView 内执行,Google Play 政策有灰色地带,建议谨慎评估。

4. 紧急修复流程化

不热修复时的紧急修复流程:

  1. 修复代码 → 构建 AAB;
  2. Play Console 走 staged rollout,先 10% 用户验证;
  3. 观察 Crashlytics 1-2 天无回归;
  4. 提至 50% → 100%;
  5. 全量上线后下架旧版本。

配合 Remote Config 立即关闭受影响功能,可大幅降低“修代码到用户拿到新版”窗口期的损失。

常见坑与最佳实践

  1. Google Play 应用使用 Tinker:违反政策,会被下架甚至封号。务必用 BuildConfig.ENABLE_HOTFIX 在 google flavor 关闭。
  2. 自己手写热修复框架:Android 各版本兼容性极复杂,Tinker 已踩过坑。除非是公司有专门维护团队,否则直接用 Tinker。
  3. Tinker 补丁未做签名校验:被中间人篡改补丁 APK 注入恶意代码。每次下载后用 SHA-256 校验签名。
  4. 插件化资源 ID 冲突:两个 APK 资源 ID 撞了导致 layout 错乱。VirtualApk 用 packageId 隔离,但自定义 framework 必须处理。
  5. 冷启动修复用户体验差:用户启动 App 后看到“加载补丁”动画几秒,转化率下降。可改为后台静默下载,下次启动生效。
  6. Remote Config 误把开关设错:把核心功能开关设 false 导致全员功能消失。生产环境修改前做双签审核,并有“自动回滚”机制(如 1 小时内异常率上升自动回退)。
  7. NDK 补丁没考虑 ABI:Tinker 支持 so 替换,但需按 ABI 拆分补丁包,否则下发到错误 ABI 设备无效。
  8. VirtualApk 在 Android 13+ 崩溃:新版本对 hidden API 限制更严,老 Hook 失效。生产前必须在目标系统版本全量测试。
  9. 热修复不更新 Crashlytics mapping:Tinker 补丁会让原 mapping 失效,崩溃堆栈反解错乱。补丁发布同时上传新的 mapping。
  10. 过度依赖热修复:养成“线上 bug 用热修复救”的习惯,导致测试流程松懈、版本质量下降。热修复是兜底,不能替代严格测试 + 灰度发布。

章节小结

本章讲解了热修复与插件化的核心知识与政策风险:

  • 原理:Android 通过 BaseDexClassLoader.pathList.dexElements 加载类,热修复把补丁 dex 插到数组最前“覆盖”原类;
  • Tinker:微信热修复框架,主流冷启动方案,通过注解处理器生成 Application,补丁 dex 在下次启动生效;
  • VirtualApk:滴滴插件化框架,支持动态加载未安装 APK 的 Activity/Service/资源,但近年维护趋缓;
  • Google Play 政策:禁止任何形式下载并执行 Google Play 之外的代码,包括热修复和插件化,违规会被下架;
  • 国内 vs 海外:国内市场可走 Tinker/VirtualApk,Google Play 版本必须走 In-App Update 或 Firebase Remote Config 替代;
  • 替代方案:In-App Update(更新代码)、Remote Config(参数下发,关闭功能开关)、JS 壳(合规评估后使用)。

至此 Part 7 发布与维护 结束。整个 Android 开发核心教程也接近尾声。Part 8-10 将进入 Kotlin + Compose 现代开发——Kotlin 语言基础与协程(Part 8)、Compose UI 基础(Part 9)、Compose 架构与进阶(Part 10),把前面学到的 Views 知识与现代声明式 UI 范式融会贯通。

下一章预告

Part 8-10 进入 Kotlin + Compose 现代开发:Kotlin 语言基础与协程 Flow、Compose UI 基础、Compose 架构与进阶。带上前面 7 个 Part 的全部知识,进入现代声明式 UI 的学习阶段。