手写热更新阐述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:
接下来走一波:
当然啦真实商用一般都会用成熟开源的,配置也可能比较麻烦,但是学习其本质是很有必要的。