zoukankan      html  css  js  c++  java
  • 手写热更新阐述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得设置一下本地翻墙的代理才行,也就是如博文所说:

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

    然后在本地瞅一下:

    接下来就需要将我们本地下下来的源码跟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:

    接下来走一波:

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

  • 相关阅读:
    多产品代码架构
    PMBOK 指南 第四章 项目整合管理(4.1-4.3)
    PMBOK 指南 第三章 项目经理的角色
    PMBOK 指南 第二章 项目运行环境
    初识PMP PMBOK初解(指南第一章引论)
    c++ 拷贝构造函数(重点在内含指针的浅拷贝和深拷贝)
    C++中的try throw catch 异常处理
    source insight 3 常用设置
    C++ map insert 另一个map的子集
    PMP学习笔记--11项目干系人管理
  • 原文地址:https://www.cnblogs.com/webor2006/p/10708754.html
Copyright © 2011-2022 走看看