基于Instant Run思想的HotFix方案实现

前言

近一年来,各种HotFix库层出不穷,各家大厂百花齐放,QQ空间最早提出了自己的热修复实现,接着阿里也开源了自家的AndFix(貌似阿里百川已经给开发者提供了新的Hotfix功能),现在微信又有了Tinker,各家都如此关心HotFix,无非是线上版本的bug对产品影响太大,尤其是DAU比较高的app,更是不能容忍。前几天看到美团基于Instant run原理推出了自己的Hotfix库,不过貌似没有开源,于是自己就按照Instant run的原理也鼓捣出了一个简单的HotFix实现,可以在不重启App和Activity的条件下实现修复,代码地址会在文章最后贴出,供大家研究学习。

实现效果

先让大家看看具体的实现效果是怎样的,很简单,一个Acitivty中点击按钮,会弹出Toast“我有bug!”,然后加载补丁,再点击按钮,会弹出“我是补丁,没有bug啦!”

接下来让我们看看怎么实现这个库。

Instant Run原理

Instant run的原理是采用了狸猫换太子的戏法,在编译阶段给每个类都注入了一个$change(代理,即补丁)变量,并且在每个方法前都注入了一段代码,判断$change是否为空,如果不为空,就执行代理里的方法。

关于Instant Run具体的原理,我在文章《浅谈Instan-Run中的热替换》中已经讲了很多,这里不再赘述,建议不了解的同学在阅读本文前先看看这篇文章。

实现

Step1:代码注入

上面说到Instan Run在编译期间给每个类都注入了变量和代码,那么这是怎么实现的呢?其实很简单,android studio给我们提供了transform Api,transform其实也就是打包过程中的一个task,我们可以根据这个特性,利用javassist来注入代码。关于代码注入这块,我参考了文章《通过自定义Gradle插件修改编译后的class文件》,谢谢这位同学慷慨的分享,具体过程大家可以看看这篇文章,我就不讲详细的步骤了。

代码注入的实现代码如下:

File dir = new File(path)
   if (dir.isDirectory()) {
       dir.eachFileRecurse { File file ->

           String filePath = file.absolutePath
           //确保当前文件是class文件,并且不是系统自动生成的class文件
           if (filePath.endsWith(".class")
                   && !filePath.contains('R$')
                   && !filePath.contains('R.class')
                   && !filePath.contains("BuildConfig.class")
                   && !filePath.contains("\$Patch.class")
                   && !filePath.contains("PatchBox.class")) {
               // 判断当前目录是否是在我们的应用包里面
               int index = filePath.indexOf(packageName);
               boolean isMyPackage = index != -1;
               if (isMyPackage) {
                   int end = filePath.length() - 6 // .class = 6
                   String className = filePath.substring(index, end).replace('\\', '.').replace('/', '.')
                   //开始修改class文件
                   CtClass c = pool.getCtClass(className)

                   if (c.isFrozen()) {
                       c.defrost()
                   }
                   pool.importPackage("com.wangxiandeng.savior")

                   //给类添加$savior变量,即补丁变量
                   CtField savior = new CtField(pool.get("com.wangxiandeng.savior.Savior"), "\$savior", c);
                   savior.setModifiers(Modifier.STATIC);
                   c.addField(savior);

                   //遍历类的所有方法
                   CtMethod[] methods = c.getDeclaredMethods();
                   for (CtMethod method : methods) {
                       //在每个方法之前插入判断语句,判断类的补丁实例是否存在
                       StringBuilder injectStr = new StringBuilder();
                       injectStr.append("if(\$savior!=null){\n")
                       String javaThis = "null,"
                       if (!Modifier.isStatic(method.getModifiers())) {
                           javaThis = "this,"
                       }
                       String runStr = "\$savior.dispatchMethod(" + javaThis + "\"" + method.getName() + "." + method.getSignature() + "\" ,\$args)"
                       injectStr.append(addReturnStr(method, runStr))
                       injectStr.append("}")
                       print("插入了:" + injectStr.toString() + "语句")
                       method.insertBefore(injectStr.toString())
                   }
                   c.writeFile(path)
                   c.detach()
               }
           }
       }
   }

上面这段代码中,我们给每个类都注入了一个类型为Savior的静态变量$savior,并且在每个方法前加入了一段代码,判断$savior是否为null,如果不为null,则执行$savior.dispatchMethod(),传入方法的方法签名和参数,让补丁类代以执行。

Step2:制作补丁类

补丁类的命名方式必须为XXX$Patch,比如MainActivity有bug,那么就制作一个名为MainActivity$Patch的补丁类,注意补丁类必须和原有类要放在同一包下。

先让我们写一个MainActivity,该类有bug(当然不是真的有bug啊)

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        //将补丁文件从资源目录拷贝到sd卡
        FileUtil.copyJarToFile(this);

        findViewById(R.id.btn_show).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                show();
            }
        });
        //点击加载补丁
        findViewById(R.id.btn_load).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                try {
                       PatchLoader.getInstance().loadPatch(PatchUtil.PATCH_PATH);
                    Toast.makeText(MainActivity.this, "load success", Toast.LENGTH_SHORT).show();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        });
    }

    public void show() {
        Toast.makeText(this, "我有bug!", Toast.LENGTH_SHORT).show();
    }
}

现在我们要制作一个补丁类,用来修复bug。如果原有类继承自某个类,则补丁类同样要继承自该类,并且要实现Savior接口。

public class MainActivity$Patch extends Activity implements Savior{

    @Override
    public Object dispatchMethod(Object host, String methodSign, Object[] params) {
        MainActivity mainActivity = (MainActivity) host;
        switch (methodSign.hashCode()) {
            case -641568046:
                onCreate(mainActivity, (Bundle) (params[0]));
                break;
            case -340027132:
                show(mainActivity);
                break;
        }

        return null;
    }

    protected void onCreate(final MainActivity host, Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        findViewById(R.id.btn_show).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                show(host);
            }
        });

        findViewById(R.id.btn_load).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                try {
                    PatchLoader.getInstance().loadPatch(getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS).getPath() + "/patch.dex");
                    Toast.makeText(host, "load success", Toast.LENGTH_SHORT).show();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        });
    }

    public void show(MainActivity host) {
        Toast.makeText(host, "我是补丁,没有bug啦!", Toast.LENGTH_SHORT).show();
    }
}

补丁类需要实现原有类的所有方法,并且要重写原有类有bug的方法,在该例子中,原有类的show()方法含有bug,所以重写了show()方法,当然其他方法可能也会视情况有一些变动。补丁类需要重写接口中dispatchMethod()方法,根据方法签名的hashcode来进行具体的方法调用。

这里还有一个我未解决的问题,即在补丁类中调用原有类的super()方法,Instant run采用的是在每个原有类里又添加了一个函数access$super(),用来调用super方法,这样补丁类遇到super方法时,直接调用原有类的access$super()方法即可,不过就像美团在文章中所说的,该方法会增加app的方法数,所以不采用。美团采用的方法是修改super方法的调用指令。

我们来用javap -c 命令看一下MainActivity的字节码
其中调用super.onCreate()的指令为:

30: invokespecial #2                  // Method android/support/v7/app/AppCompatActivity.onCreate:(Landroid/os/Bundle;)V

可以看见调用的是invokespecial指令(不知美团为何说是invokesuper),该指令用于调用实例构造器方法,私有方法和父类方法。美团的意思应该是去修改指令为invokespecial,不知是不是使用java bytecode editor手动操作,如果有同学知道,希望留言中告知。

接着写一个补丁记录类,用来记录有哪些补丁

public class PatchBox implements IPatchBox {
    @Override
    public List<String> getPatchClasses() {
        List<String> list = new ArrayList<>();
        list.add("com.wangxiandeng.saviortest.MainActivity$Patch");
        return list;
    }
}

Step3:补丁打包

补丁写完后,需要打包成dex,首先在编译过程,拷贝出补丁类和PatchBox.class文件,依据补丁类所在包名,放在文件夹下,比如新建一个总文件夹patch,再新建一个
com/wangxiandeng/saviortest/文件夹,放入MainActivity$Patch.class和PatchBox.class,然后按照以下步骤操作:

1.cd 到patch目录;

2.利用jar cvf patch.jar * 指令打包成jar文件。

3.利用build-tools目录下的dx指令:dx –dex –output=patch_dex.jar patch.jar指令,打包成dex的jar包,patch_dex.jar即为我们打包好的补丁。

Step4:补丁加载

将补丁放在sd卡中,执行补丁加载过程。补丁加载的核心代码如下:

//加载补丁Dex文件
DexClassLoader dexClassLoader = new DexClassLoader(patchPath, getOdexPath(), null, getClass().getClassLoader());

//加载补丁装载类PatchBox
Class<?> patchBoxClass = Class.forName(mPatchBoxName, true, dexClassLoader);
IPatchBox patchBox = (IPatchBox) patchBoxClass.newInstance();

//遍历加载补丁类
for (String className : patchBox.getPatchClasses()) {
    Class<?> patchClass = dexClassLoader.loadClass(className);
    Object patchInstance = patchClass.newInstance();

    //反射修改bug类的mSavior字段
    int index = className.indexOf("$Patch");
    if (index == -1) {
        Log.e("Savior:", "incorrect name for patch, please rename your patch according to the README.md");
        return;
    }
    String bugClassName = className.substring(0, index);
    Class<?> bugClass = getClass().getClassLoader().loadClass(bugClassName);
    Field saviorField = bugClass.getDeclaredField("$savior");
    saviorField.setAccessible(true);
    saviorField.set(null, patchInstance);
}

补丁加载过程主要分为3步:

1.利用DexClassLoader加载补丁;

2.加载补丁记录类PatchBox;

3.遍历PatchBox中记录的补丁类并实例化,反射对应原有类的$savior字段,赋值为补丁实例。

至此,补丁类就已经加载完毕,此时调用原有类的bug方法,实际上调用的是补丁类的修复方法。

结论

以上就是一个简单的HotFix库,当然这只供大家学习使用,离商用还差的很远,真正的HotFix库要考虑的地方还有很多,毕竟是用来修复bug的,总不能库自身一堆bug吧。不过Instant Run确实是实现HotFix的一个很好的方案,不需要考虑android版本兼容性的问题,还可以实现修复的即时生效,期待美团Hotfix库的开源!

代码地址:https://github.com/HalfStackDeveloper/Savior

(转载请标明ID:半栈工程师,个人博客:https://halfstackdeveloper.github.io)

欢迎关注我的知乎专栏:https://zhuanlan.zhihu.com/halfstack

欢迎Follow我的github: https://github.com/HalfStackDeveloper