手写热更新阐述tinker实现原理

关于热更新如今面试也是基本上都会提及到的,我上一家公司用的是tinker,而这里准备研究的也是它的原理,而关于tinker的原理网上也是一大堆文章进行介绍,为了对它有个更加进一步的认识,所以自己动手来实现类似于tinker的效果,当然关于补丁这块是如何打的不在这次研究的范围,这里只研究最最核心的,能根据补丁动态进行修复,通过分析ClassLoader的源码来弄清整个修复的原理。

界面框架搭建:

先简单弄一个示例界面,供我们能看到热修复的效果,如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:paddingStart="24dp"
    android:paddingEnd="24dp"
    android:paddingTop="12dp"
    android:gravity="center_horizontal"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/titleTv"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp" />

    <Button
        android:id="@+id/showTitleBt"
        android:layout_width="match_parent"
        android:layout_height="56dp"
        android:layout_marginTop="8dp"
        android:text="Show Title"/>

    <Button
        android:id="@+id/hotfixBt"
        android:layout_width="match_parent"
        android:layout_height="56dp"
        android:layout_marginTop="8dp"
        android:text="Hot Fix"/>

    <Button
        android:id="@+id/removeHotfixBt"
        android:layout_width="match_parent"
        android:layout_height="56dp"
        android:layout_marginTop="8dp"
        android:text="Remove Hot Fix"/>

    <Button
        android:id="@+id/killSelfBt"
        android:layout_width="match_parent"
        android:layout_height="56dp"
        android:layout_marginTop="8dp"
        android:text="Kill Self"/>

</LinearLayout>

Activity中给这些按钮增加点击事件:

public class MainActivity extends AppCompatActivity {

    TextView titleTv;
    Button showTitleBt;
    Button hotfixBt;
    Button removeHotfixBt;
    Button killSelfBt;

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

        titleTv = findViewById(R.id.titleTv);
        showTitleBt = findViewById(R.id.showTitleBt);
        hotfixBt = findViewById(R.id.hotfixBt);
        removeHotfixBt = findViewById(R.id.removeHotfixBt);
        killSelfBt = findViewById(R.id.killSelfBt);

        View.OnClickListener onClickListener = new View.OnClickListener() {
            @Override
            public void onClick(final View v) {
                switch (v.getId()) {
                    case R.id.showTitleBt:
                        Util util = new Util();
                        titleTv.setText(util.getTitle());
                        break;
                    case R.id.hotfixBt:
                        //TODO:进行热更新

                        Util util1 = new Util();
                        titleTv.setText(util1.getTitle());
                        break;
                    case R.id.removeHotfixBt:
                        break;
                    case R.id.killSelfBt:
                        android.os.Process.killProcess(android.os.Process.myPid());
                        break;
                }
            }
        };

        showTitleBt.setOnClickListener(onClickListener);
        hotfixBt.setOnClickListener(onClickListener);
        removeHotfixBt.setOnClickListener(onClickListener);
        killSelfBt.setOnClickListener(onClickListener);
    }
}

其中我们以热修复Util中的一个简单方法来例,来阐述如何通过热补丁达到程序的动态更新,先来看一下Util的定义:

思考:Android的类是如何被加载的?

如何达到热修复的效果呢?这里就得分类Util这个类的加载机制了,我们知道一个类都是由类加载器从字节码给加载到内存当中的,所以咱们先来看一下当前ClassLoader是哪个类,如下:

其实关于这个类在网上都有介绍过,这里当作纯小白,从0一点点去挖掘出来热修复的原理,那接下来就打开PathClassLoader源码瞅下呗:

从包名"dalvik.system"就可以得知,这是内核的代码,通常下载肯定是不会下载有它,接下来很显然得关联相应的源码了,看下面。

看系统源码的方式【重点是如何关联IDE的系统源码】:

方式一:通过androidxref.com在上去查阅【如果比较着急,但是阅读性比IDE的要差】

先打开它瞅一下:

其中包含了Android的所有版本,如下:

它跟github的镜像是一样的,但是支持类之间的链接,在线的体验还比较好,比如我们想看"8.1.0_r33"的PathClassLoader,点进去:

就会进行一个查询界面:

然后点"Search":

此时就可以点击进去查看该类的源码了:

所以如果实际中遇到想看的源码木有在SDK中,而且又非常着急的想看,那采用这种在线的方式是非常好的,不过网页版的怎么的还是没直接在IDE查看爽,所以下面重点来看一下如何在IDE中关联我们想要的任意版本的源码。

方式二:通过从Android的系统git仓库来关联IDE【推荐】

这里要用一个非常之方便,但是貌似不多人用过的方式【平常可能也就是通过IDE的提示直接下载源码到SDK中】,也就是直接将git仓库下载到本地,然后我们可以随意的切换任意的Android本,然后在IDE中任意的关联各个版本的代码,下面来看一下如何做?当然得找到我们所看源码的github的仓库才行,如何找呢?首先直接在google中这样搜“PathClassLoader source code”,如下:

点击进去:

然后光只关联这个类肯定不行,应该是关联整个系统的源码,PathClassLoader是属于libcore项目的,所以我们往回切到这个目录:

然后点击进去:

但是!!经过几分钟后会报一个超时异常。。

在网上搜了一下解决方法:https://blog.csdn.net/zzsfqiuyigui/article/details/8832544

所以按步骤试一下,先去谷歌注册一下:

接下来需要将它拷贝到~/.bash_profile文件中,如下:

保存,然后记得重新加载一下该bash文件:

最终发现clone代码还是有问题,那。。咋办,现在源码下不下来呀,当然能解决啦,不然也不可能有此篇博文的存在,经过不断的网上找寻,发现这篇博客最终解决了我的问题:https://blog.csdn.net/qq_33487412/article/details/78458000,居然是git下androidsource得设置一下本地FQ的代理才行,也就是如博文所说:

好,接下来咱们来设置一下,我是自有梯子的哈,具体用的啥梯子就不透露了,根据个人的喜爱去选择,如下:

然后在本地瞅一下:

接下来就需要将我们本地下下来的源码跟IDE的进行关联,首先我们要查看的PathClassLoader的包名是:

所以先在已经下载的代码中找到要看的代码:

我们知道IDE要与Android源码关联其实就是要将我们已经下载的相关源码拷贝到SDK的指定目录才可以,所以进入我们的SDK目录:

进去之后则是各个SDK中已经下载了源码的目录,如下:

那我们到底要放到哪个版本下呢?很显然就得看工程中目前所使用的sdk版本是多少了,而我们工程目前使用的targetSdkVersion是28:

所以我们应该将代码放到这个文件夹里:

而我们要拷贝的目录应该是这:

另外我们得确保拷的代码确实是28这个版本的,既然已经下载下来了libcore的整个git仓库,那只要本地切换一下版本既可,怎么切,一般某个版本发布都会打tag的,所以可以通过git tag来查看我们想要的版本,如下:

28是9.0貌似太新了,暂时还是要8.0的代码,所以咱们切到8.0的分支:

再来查看一下当前分支:

嗯~~目前已经确保我们本地的源码是我们想要的版本了,接着就可以将其指定的目录拷贝到我们的SDK指定的版本当中了,如下:

好!!接下来在Android Studio再来看是否我们想看的PathClassLoader的源码已经能看了:

以后想要看任何看不到Android源代码的都非常之方便啦~~

通过分析ClassLoader的源码找出热修复思路【常说的类加载的双亲委托细节就在里面】:

解决IDE看源码的问题之后,接着就来详细分析PathClassLoader的源码了:

其实Android还有另一个DexClassLoader也是如此,调用了一下父类的:

所以转到父类BaseDexClassLoader来瞅一下:

我们知道类加载器加载类的核心方法叫loadClass(),发现在这个父类也没有此方法,那就还得继续往它的父类ClassLoader找了,如下:

然后会调用带个参数的重载方法,其中第二个参数为resolve:

不信可以瞅一下JDK中的这个方法resolve参数是否用到:

好,接下来就来分析一下Android中的ClassLoader.loadClass()方法的具体代码:

看一下它的细节:

所以再回到主方法上来继续分析:

接下来就是重点分析类没有被加载的情况了,类加载器的双亲委托模型就开始体现了,如下:

这样的话就会父级一层层往上委托,只要找到了则就直接返回了,假如说没有父亲,则就会执行:

好,假如说都没有找到,接着就到了我们重点要看的方法了,如下:

点击查看一下:

然后转到BaseDexClassLoader类中来看到此方法的实现:

此时就需要看一下pathList这个成员变量了:

看一下它里面的findClass()方法:

关键的思路就产生了,也就是网上都会说的:

由此萌发啥思路呢?如果说我们要加载的这个类是有BUG的,那么想办法在这个类所在的dexElements的位置之前插一个修复好的类,那。。是不是之后的查找就会用我们修复的类从而达到一个热修复的目的?接下来继续看下源码的细节,既然dexElements这么重要,有必要了解一下它是如何被初始化的,此时得回溯一下调用链,目前分析的整个类的调用关系总结如下:

接下来再回来挼一挼流程:

所以定位到该构造方法:

 

而接下来就看一下DexPathList里面的dexElements的实例化过程,因为最终寻找就是遍历dexElements,如下:

好,这是最后的一点硬骨头了,得啃一下里面的肉,这样有助于我们思路的定型,其中makeDexElements()的第一个参数其实就是对我们传过来的文件路径进行分割,比如我们传了“”这样的路径,以冒号进行分隔,则会生成多个File集合,进去简单瞅一眼,不是重点:

而我们在修复补丁时肯定只会有一个文件,所以经过这个方法之后其实也就是生成一个带有一个File的集合,仅此而已,接着来看具体的makeDexElements()的实现:

/**
     * Makes an array of dex/resource path elements, one per element of
     * the given array.
     */
    private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
            List<IOException> suppressedExceptions, ClassLoader loader) {
      Element[] elements = new Element[files.size()];
      int elementsPos = 0;
      /*
       * Open all files and load the (direct or contained) dex files up front.
       */
      for (File file : files) {
          if (file.isDirectory()) {
              // We support directories for looking up resources. Looking up resources in
              // directories is useful for running libcore tests.
              elements[elementsPos++] = new Element(file);
          } else if (file.isFile()) {
              String name = file.getName();

              if (name.endsWith(DEX_SUFFIX)) {
                  // Raw dex file (not inside a zip/jar).
                  try {
                      DexFile dex = loadDexFile(file, optimizedDirectory, loader, elements);
                      if (dex != null) {
                          elements[elementsPos++] = new Element(dex, null);
                      }
                  } catch (IOException suppressed) {
                      System.logE("Unable to load dex file: " + file, suppressed);
                      suppressedExceptions.add(suppressed);
                  }
              } else {
                  DexFile dex = null;
                  try {
                      dex = loadDexFile(file, optimizedDirectory, loader, elements);
                  } catch (IOException suppressed) {
                      /*
                       * IOException might get thrown "legitimately" by the DexFile constructor if
                       * the zip file turns out to be resource-only (that is, no classes.dex file
                       * in it).
                       * Let dex == null and hang on to the exception to add to the tea-leaves for
                       * when findClass returns null.
                       */
                      suppressedExceptions.add(suppressed);
                  }

                  if (dex == null) {
                      elements[elementsPos++] = new Element(file);
                  } else {
                      elements[elementsPos++] = new Element(dex, file);
                  }
              }
          } else {
              System.logW("ClassLoader referenced unknown path: " + file);
          }
      }
      if (elementsPos != elements.length) {
          elements = Arrays.copyOf(elements, elementsPos);
      }
      return elements;
    }

下面简单过一下流程:

而如果是一个文件,则会判断是否是".dex"文件或者是“apk”的文件,如下:

如果是.dex文件,则会调用loadDexFile()方法加载成DexFile对象,然后再将它放到Element数组当中:

而如果是apk,也是类似的,是不是我们只要将Apk正在运行的DexPathList中的dexElements的内容给替换成我们想要既可,那如何替换呢,反射!!!

实现热修复:

先补丁本地修复,实现初步修复:

有了思路之后,接下来热修复的核心代码直接贴出,不难:

记得补丁的dexElements一定是要插到程序本身的dexElements之前,这是核心。好,接下来咱们生成一个补丁,这里是以修复Util.getTitle()为目的,修复一下代码,并打成一个apk补下,如下:

然后修复方法,这里当然就是简单的修改下文案来模拟了:

然后运行生成一个apk就当成是补丁:

好,此时将它拷贝到assert目录中:

好,记得将此代码还原,因为我们要基于一个有错误的程序来验证热修复:

最后,需要将热补丁从assets目录拷贝到app的私有目录,直接也贴代码比较简单:

好,下面来运行一下:

嗯,最最简单的热修复就实现了,当然有很多不完善的地方,重点是了解其原理。

优化一下代码:

1、咱们需要将热修复的代码提到Application中去弄,或者也可以放到Splash界面等,反正就是在我们要用之前肯定得提前加载,不然达不到热修复的效果,如下:

原因是由于我们热复的代码是写在了“HOT FIX”的点击事件上了,所以咱们将其修复代码往前移:

public class HotfixApplication extends Application {

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);

        File apk = new File(getCacheDir() + "/hotfix.apk");
        if (apk.exists()) {
            try {
                //首先获取当前正在运行的apk中的dexElements数组
                ClassLoader classLoader = getClassLoader();
                Class loaderClass = BaseDexClassLoader.class;
                Field pathListField = loaderClass.getDeclaredField("pathList");
                pathListField.setAccessible(true);
                Object pathListObject = pathListField.get(classLoader);
                Class pathListClass = pathListObject.getClass();
                Field dexElementsField = pathListClass.getDeclaredField("dexElements");
                dexElementsField.setAccessible(true);
                Object dexElementsObject = dexElementsField.get(pathListObject);

                //然后再获取指定apk中的dexElements对象

                PathClassLoader newClassLoader = new PathClassLoader(apk.getPath(), classLoader.getParent());
                Object newPathListObject = pathListField.get(newClassLoader);
                Object newDexElementsObject = dexElementsField.get(newPathListObject);


                //最后再用我们自己生成的dexElements对象来替换掉当前应用的dexElements对象,其中自己生成的是包含当前的和要修复的dexElements
                int oldLength = Array.getLength(dexElementsObject);
                int newLength = Array.getLength(newDexElementsObject);
                Object concatDexElementsObject = Array.newInstance(dexElementsObject.getClass().getComponentType(), oldLength + newLength);
                for (int i = 0; i < newLength; i++) {
                    Array.set(concatDexElementsObject, i, Array.get(newDexElementsObject, i));
                }
                for (int i = 0; i < oldLength; i++) {
                    Array.set(concatDexElementsObject, newLength + i, Array.get(dexElementsObject, i));
                }

                dexElementsField.set(pathListObject, concatDexElementsObject);
            } catch (NoSuchFieldException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
        }
    }
}

这样的话,进app主界面就可以用了:

2、补丁可以换成dex,只包含我们修复的内容,而不是整个apk中包含不需要的东东做为补丁包。【传说中的增量补丁,不过不是成熟的增量补丁】

热修复除了能直接用apk来作为补丁,也能够用dex,所以我们需要利用SDK中的dex工具来将我们指定的类指成dex包,如下:

然后在SDK中找到打dex的工具:

平常我们打dex包通常用的是dx命令,其实是可以用d8的,如下:

然后再将它复制到assets中:

此时再修改一下文件名:

然后再运行:

将打补丁的过程gradle化:

在上面的演示中我们打一个补丁是手动通过SDK中的工具在命令行中对我们指定要打入补丁的类进行打补丁,很显然现实中不太可取,所以需要将其自动化,所以将其打补丁的过程和配置到gradle,这里就需要定义一个任务,不难,下面直接贴出具体配置代码:

def patchPath = 'com/tinkerdemo/test/Util'

task hotfix {
    doLast {
        exec {
            commandLine 'rm', '-rf', './build/patch'
        }
        exec {
            commandLine 'mkdir', './build/patch'
        }
        exec {
            commandLine 'javac', "./src/main/java/${patchPath}.java", '-d', './build/patch'
        }
        exec {
            commandLine '/Users/xiongwei/android-sdks/Android/sdk/build-tools/28.0.3/d8', "./build/patch/${patchPath}.class", '--output', './build/patch'
        }
        exec {
            commandLine 'mv', "./build/patch/classes.dex", './build/patch/hotfix.dex'
        }
    }
}

其实对于gradle来执行具体的命令必须是每条命令的执行单独写一个exec,如图:

对于这个配置不难理解,也就是将我们执行的命令通过gradle来自动执行了,跟批处理类似,然后最后会在这个目录下生成补下:

好,咱们来玩一下,比如我们修改了类的BUG,还是之前的Util:

接下来走一波:

当然啦真实商用一般都会用成熟开源的,配置也可能比较麻烦,但是学习其本质是很有必要的。

posted on 2019-04-15 09:17  cexo  阅读(1809)  评论(0编辑  收藏  举报

导航