从零开始的Android热修复之旅(一):热修复简单原理

2,062 阅读9分钟

前言

如果要给热修复一个大致的定义,那么简单来说,热修复所做的就是在不重新安装的情况下改变原有应用的代码,以达成修复bug的目的。热修复在长时间的演进当中,出现了很多的实现方式,如在native层替换ArtMethod(代表框架:Andfix)、使用ClassLoader加载补丁(代表框架:Tinker)、多方案混合(代表框架Sophix,吸收了不同方案的优点和缺点),AOP+自动化补丁(代表框架:Instant Run、Robust),本文主要深入的讲解参考Instant Run设计的AOP热修复方案,其他的实现方式,会在后文中提及,并会将类Instant Run和这些方案进行对比,从多个角度更好的分析不同解决方案的优缺点。

方案简介

在深入Instant Run方案的细节之前,先介绍一下该方案的优点:1. 支持运行时修复、2. 无需hook(不用根据Android版本和ROM做兼容)
那么接下来就从实现原理来看一下Instant Run为什么能做到这两点。仔细思考热修复的功能,发现其实就是一种轻量级的动态化表现,要实现这种动态化有很多种方式,比如在Java提供的API中,就有动态加载代码最简单的方式——通过ClassLoader去加载符合虚拟机(JVM, AVM)规范的类文件,因此Classloader方案就应运而生了,这个方案会将修改过后的class文件制作成补丁,然后在将会被修复的终端上通过ClassLoader进行加载,这样就相当于类粒度的替换,诸如注解、字段的修改都可以支持。不过由于虚拟机的限制,类名一致的class无法通过同一个ClassLoader重复加载,已经加载到虚拟机中的Class卸载的条件又较为苛刻,因此这种使用ClassLoader的热修复方案都必须应用重启才可生效。
那么,如果退一步呢?其实从实际情况来考虑,很多需要热修复的场景下,并不会去改动注解、字段,绝大多数需要热修的都是方法逻辑。如果只加载方法的话,就可以将方法提取到新生成的类中,而不需要加载原本的类了。并且通过这种加载新类的方式也无需重启应用。Instant Run就是使用的这样的方式做到的运行时修复。当然,光是加载新类还完全做不到热修复,因为无法替换原有的类,所以需要建立映射关系,并且根据补丁状态进行补丁逻辑的跳转。接下来,就按照上文所描述的内容,一步步深入参考Instant Run设计的热修复方案的原理,并从实际的应用角度分析设计思路。

如何运行补丁?

从简单实例开始

假设我们已经有了一个准备好的补丁,并且准备进行热修复的应用只有下面一个类,类中只有一个方法

public class HotfixDemo {
    public String method() {
        return "bug";
    }
}

如果要修改这段代码,让其具有在没有加载补丁的时候执行原有逻辑,在有补丁的时候可以执行补丁内逻辑的功能,我们可以在编译时通过字节码工具做如下改动

public class HotfixDemo {
    public String method() {
        if (Hotfix.needPatch()) {
            return (String)Hotfix.invokePatch();
        } else {
            return "bug";
        }
    }
}

public class Hotfix {
    public static boolean needPatch() {
        //has patch
    }

    public static Object invokePatch() {
        //invoke patch
    }
}

在方法的原本逻辑之外增加一层判断,如果需要应用补丁,就通过Hotfix执行补丁包提供的逻辑,否则执行原本的逻辑,如此便能让被处理后的方法具有简单的动态化了。最简单的情况处理完了,那么接下来给HotfixDemo这个类新增一些其他的方法,并把上述的方案覆盖到所有的实方法。

从扩展用例深入

一般来说,使用热修复时不会所有方法都将会被修复,所以要判断补丁的修复范围,如果说要使用一个标志来选择方法,那么方法签名就非常合适了,符合虚拟机规范的类文件都可以保证一个方法的签名是唯一的,那么增加方法签名扩写一下逻辑:

public class HotfixDemo {
    public String method() {
        if (Hotfix.needPatch("method()Ljava/lang/String;")) {
            return (String)Hotfix.invokePatch("method()Ljava/lang/String;");
        } else {
            return "bug";
        }
    }

    // etc...
}

public class Hotfix {
    public static boolean needPatch(String identity) {
        // identity method need patch
    }

    public static String invokePatch(String identity) {
        // invoke identity method patch
    }
}

好了,单个类中方法分发的逻辑大致完成了,让我们再次拓展范围,现在应用中不止HotifxDemo这个类了,我们要有新的字段来区分不同的类。按照上面的流程,应该会很自然的想到为needPatch()invokePatch()再增加一个参数,这个参数用类全限定名做区分,就像下面这样

public class HotfixDemo {
    public String method() {
        if (Hotfix.needPatch(HotfixDemo.class.getName(), "method()Ljava/lang/String;")) {
            return (String)Hotfix.invokePatch(HotfixDemo.class.getName(), "method()Ljava/lang/String;");
        } else {
            return "bug";
        }
    }

}

public class Hotfix {
    public static boolean needPatch(String className, String identity) {
        MethodPatchInfo methodInfo = classesPatchInfo.get(className);
        if (methodInfo == null) {
            return false;
        }
        return methodInfo.needPatch(identity);
    }

    public static String invokePatch(String className, String identity) {
        //invoke identity method patch
    }
}

这时,我们先去查询该方法所在的类是否在修复列表中,而后再去查询方法是否需要修复。按照这样的实现,一般来说热修复框架会去维护一个类名和对应类热修复信息的表,这个表在加载补丁的时候进行数据填充,如果把上面的方案推及到应用所有的方法中,就会令每个方法都会额外增加一步从映射表(classPatchInfo)中查询的操作,并且,考虑到多线程并发的情况,要为这个映射表的操作进行加锁(更新/卸载补丁会修改这个映射表),对于一些原本逻辑指令就很少的方法来说,这样无疑增加了非常大的开销。那么是否有优化的方式呢?既然Class本身也可以作为一个容器,那么为什么不直接把MethodPatchInfo放到Class中,用以取代维护ClassmethodPatchInfo映射关系的映射表呢。

public class HotfixDemo {
    public static MethodPatchInfo sInfo;

    public String method() {
        MethodPatchInfo info = sInfo;    // 局部变量赋值
        if (info != null && info.needPatch("method()Ljava/lang/String;")) {
            return (String)info.invokePatch("method()Ljava/lang/String;");
        } else {
            return "bug";
        }
    }
}

在这里,为每个类都新增一个static field,在安装补丁时,为sInfo进行赋值,待方法执行时,只需判断sInfo是否为空就能知道当前类中的方法是否要执行补丁逻辑。如此一来,由每一个来类维护自己修复方法的分发逻辑,用一些微不足道的空间换时间,就完全不用进行额外的检索操作了。额外注意第五行的局部变量赋值,这里是为了在方法的作用域内保证指向sInfo的引用不变,防止其他线程卸载补丁后,needPatch()invokePatch()方法出现NPE。

如何制作补丁

在上文中我们分析了如何通过注入修改app的代码,让app在对开发透明的情况下实现对动态化的支持。那么接下来,为了完成热修复流程,最少还有三个问题等待我们去解决

  1. 如何定位修复范围
  2. 如何在实际执行时,将app方法的执行正确转发到补丁内的对应方法
  3. 补丁方法的逻辑是什么形式的

定位修复范围

因为无法在app上线时知道未来的修复内容,所以修复范围一定是携带于补丁文件中的。定位到一个具体的修复方法,除了必须的类全限定名、方法签名,还须有新生成补丁类的全限定名,
类全限定名和方法签名都可以在运行时通过注入的参数拿到,所以只用维护一个被修复类全限定名和补丁类全限定名的一个映射关系就可以了。
我们可以把维护这个映射关系的映射表放到一个Class类中(当然放到普通文件中也完全可以),就像下面这样

public class PatchMetaInfo {
    public Map<String, String> loadPatchDeliverInfo() {
        HashMap<String, String> result = new HashMap<>();
        result.put("com.test.HotfixDemo", "com.test.HotfixDemo$Patch");
        return result;
    }
}

我们可以让**$Patch类继承MethodPatchInfo并实现具体的needPatch()invokePatch()逻辑,拿到map之后,就可以通过反射创建**$Patch的实例,并注入每一个key所对应的类中了。

com.test.HotfxiDemo.sInfo = ReflectHelper.newInstance("com.test.HotfixDemo$Patch");

待到下次执行到被修复的方法时,sInfo不为空,便可以直接执行sInfo提供的方法了。接下来,逻辑执行就来到了MethodPatchInfo的实现当中了。

补丁逻辑调用

needPathch(),顾名思义,我们会使用这个方法,判断当前执行的方法是否在修复列表中。修复列表就是所有被改动方法的方法签名组成的集合,注入的调用代码中会将方法签名通过参数传递。这个方法同样也可以在制作补丁时编写

public class HotfixDemo$Patch {
    private HashSet<String> mHotfixRangeSet = new HashSet<>();

    public HotfixDemo$Patch() {
        mHotfixRangeSet.add("method()Ljava/lang/String;")
    }

    public boolean needPatch(String methodSignature) {
        return mHotfixRangeSet.contains(methodSignature);
    }
}

若执行后判定在修复范围内,之后就需要调用invokePatch()方法执行具体补丁方法的逻辑了。invokePatch()方法完全可以认为是,修复后的且未注入热修复逻辑的原方法,所以他的返回值、参数一定要和原方法匹配,当然这个匹配并不是说必须完全一致,我们可以用Object[]接受各式各样的参数列表,用Object代替所有的返回值,并在调用处进行一次强转即可,方法声明会像这个样子:Object invokePatch(String methodSignature, Object[] params)。接下来,在方法内部去调用不同的修复方法,这里我们仍然需要方法签名

public class HotfixDemo$Patch {
    public Object invokePatch(String methodSignature, Object[] params) {
        switch (methodSignature) {
            case "method()Ljava/lang/String;";
                return method();
            default:
                break;
        }
    }

    public String method() {
        return "fix";
    }
}

可以看到,这些形式固定的流程都可以在生成补丁时进行确定,在最开始对代码进行注入时,也只需要传递不同的参数到统一的接口就可以做到执行补丁内指定方法的逻辑。
到这里终于把整个执行流程都串起来了,让我们先来梳理一下

应用被赋予热修复能力后,当执行到一个方法时,会先判断当前类对应的补丁是否存在(sInfo != null),若存在则继续判断当前执行的方法是否在修复类的修复范围内(**$Patch#needPatch()),两者条件都满足则会去调用**$Patch#invokePatch(),执行具体的修复后的方法。否则,执行原本的逻辑。
流程理清了,我们还剩最后一个问题,也是最主要的一个问题,补丁内的修复方法逻辑是如何的?

补丁实际逻辑

上文中我们用来做示例的方法非常简单,声明方面仅有一个返回值没有参数,并且方法体内也仅有处理一个字符串常量的逻辑,但在一般情况下,一个方法的内容要复杂的多,除了有各种形式的参数和返回值外,还会在方法逻辑中调用其他方法,操作处理字段,所以让我们再举一个稍微复杂些的例子

public class HotfixDemo {
    private static int sNumberA = 1;
    protected int mNumberB = 2;
    public String mString = "3";

    public String method() {
        String msg = sNumberA + ":" + mNumberB + mString;
        print(msg);
        return msg;
    }

    private void print(String msg) {
        System.out.println(msg);
    }
}

在这个例子的method()方法中,处理了HotfixDemo的字段,还调用了一个成员方法,假如我们把这段逻辑修改后,像之前的例子一样,将方法全部copy到HotfixDemo$Patch中,肯定是行不通的,因为作为一个和HotfixDemo没有关联的类,并未声明逻辑中访问的这些字段和方法。因此我们要在HotfixDemo$Patch中,处理和调用HotfixDemo中的字段方法,而无法访问的私有字段,通过反射就可以了。通过这样的“转译”,我们可以得到的补丁如下:

public class HotfixDemo$Patch {

    /**
     * 修复过后的补丁方法,这里方法签名有些变化,因为要访问类的成员,需要其对应的实例
     * @param host 原有类的运行实例
     */
    public String method(HotfixDemo host) {
        int sNumberA = ReflectHelper.getField(host, "sNumberA");
        int mNumberB = ReflectHelper.getField(host, "mNumberB");
        String mString = host.mString;    //public字段,直接访问即可

        String msg = sNumberA + ":" + mNumberB + mString;
        ReflectHelper.invokeMethod(host, msg);
        return msg;
    }
}

这里我们在方法签名中新增了一个参数,传递一个被修复类的实例,这样我们才能够访问到被修复类中的成员。这个参数可以在invokePatch()时使用this进行传递。到此,一整个热修复的流程都走下来了,不过上文提到很多的代码,比如HotfixDemo$Patch类,其实都是有一定规律可循的,在每次发布补丁时,这些逻辑通过人为编写的话开销太高且容易出错,因此我们可以考虑把补丁生成的流程完全自动化,这部分内容将在下一节详细讲述。