Android动手撸一个热更新

2,405 阅读6分钟

代码请戳 git

热更新原理

热更新 / 热修复

不安装新版本的软件,直接从网络下载新功能模块来对软件进行局部更新

热更新和插件化的区别

区别有两点

  1. 插件化的内容在原App中没有,而热更新是原App中的内容做了改动
  2. 插件化在代码中有固定的入口,而热更新则可能改变任何一个位置的代码

热更新的原理

ClassLoader 的 dex 文件替换 直接修改字节码

前置知识:loadClass() 的类加载过程

  • 宏观上:是一个带缓存的、从上到下的加载过程(即网上所说的「双亲委托机 制」)
  • 对于具体的一个 ClassLoader:
    • 先从自己的缓存中取
    • 自己没有缓存,就找父 ClassLoader 要(parent.loadClass())
    • 父 View 也没有,就自己加载(findClass())
  • BaseDexClassLoader 或者它的子类(DexClassLoader、PathClassLoader 等)的 findClass():
    • 通过它的 pathList.findClass()
    • 它的 pathList.loadClass() 通过 DexPathList 的 dexElements 的 findClass()
    • 所以热更新的关键在于,把补丁 dex 文件加载放进一个 Element,并且插 入到 dexElements 这个数组的前面(插入到后面的话会被忽略掉)

手写热更细

  • 因为无法在更新之前就指定要更新谁;所以不能定义新的 ClassLoader,而只能 选择对 ClassLoader 进行修改,让它能够加载补丁里面的类
  • 因为补丁的类在原先的 App 中已经存在,所以应该把补丁的 Element 对象插入 到 dexElements 的前面才行,插入到后面会被忽略掉。
  • 具体的做法:反射
  1. 自己用补丁创建一个PathClassLoader
  2. 把补丁PathClassLoader里面的elements替换到旧的里面去
  3. 注意:
    1. 尽早加载热更新(通用手段是把加载过程放在 Application.attachBaseContext())
    2. 热更新下载完成后在需要时先杀死程序才能让补丁生效
  4. 优化:热更新没必要把所有内容都打过来,只要把改变的类拿过来就行了用 d8 把指定的 class 打包进 dex
  5. 完整化:从网上加载
  6. 再优化:把打包过程写一个task

整包替换(全量替换)

  • 首先我们我们写一个页面,显示的文字是Plugin类提供的「我要热更新」
public class Plugin {
    public static String getTitle(){
        return "我要热更新";
    }
}
  • 然后我们修改这个类,热更细后显示的文字是「更新了」
return "更新了";
  • 然后我们要将修改后的工程进行打包为hotfix.apk,将这个修改后的整包apk复制到assets/apk目录下,方便我们的复制
  • 然后我们要将文字修改为更新前的「我要热更新」
  • 下面编写热更新的代码,通过点击热更新按钮,完成热更新,主要包含以下两个步骤
    • 1.复制apk文件
    • 2.替换ClassLoader,加载新的dex文件
    • 下面是代码,在点击热更新的时候调用它,然后强杀应用重启就有效果了
          private void loadHotFix() {
              //1.复制文件
              File apk = new File(getCacheDir() + "hotfix.apk");
              if (!apk.exists()) {
                  try (Source source = Okio.source(getAssets().open("apk/hotfix.apk"));
                       BufferedSink sink = Okio.buffer(Okio.sink(apk));) {
                      sink.writeAll(source);
                  } catch (IOException e) {
                      e.printStackTrace();
                  }
              }
      
              //2.替换classLoader,里面的dex文件
              try {
                  ClassLoader originalLoader = getClassLoader();
                  DexClassLoader classLoader = new DexClassLoader(apk.getPath(),getCacheDir().getPath(),null,null);
                  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);
      
                  Object originalPathListObject = pathListField.get(originalLoader);
                  dexElementsField.set(originalPathListObject,dexElementsObject);
      
                  //originalLoader.pathList.dexElements = classLoader.pathList.dexElement
              } catch (NoSuchFieldException e) {
                  e.printStackTrace();
              } catch (IllegalAccessException e) {
                  e.printStackTrace();
              }
          }
      

以下是效果图
1 2

将热更新代码挂载到Application中

上面热更新功能每次都需要点击按钮才能触发,这显然不是我们需要的,我们需要的是App启动时就尽早加载了热更新,下面要移植代码到Application中

public class HotfixApp extends Application {
    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        //加载热更新插件
        loadHotFix();
    }
}

增量替换

在我们的开发中,热更新往往只是更新某几个类或者资源文件,上面的全量替换的方式显得很笨拙,那么增量替换就是很好的选择

上面的代码我们只需要替换Plugin类就可以了,我么可以只给Plugin编译打包就可以了

  • 首先修改Plugin.java
    public class Plugin {
        public static String getTitle(){
            return "我是dex热修复后的标题";
        }
    }
    
  • 然后将我们的Plugin.java编译为class文件
    javac Plugin.java
    
  • 然后将class文件编译为dex文件
    d8 Plugin.class
    
  • 最后将生成的classes.dex文件放在app的assets/apk目录下
  • 再后修改热修复挂载代码,值得注意的是要把最新的补丁dex文件插入到最前面
public class HotfixApp extends Application {
    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        //加载热更新插件
        //全量替换
        //loadHotFix();
        //增量替换
        loadDexFix();

    }

    /**
     * 增量替换,替换dex
     */
    private void loadDexFix() {
        //1.复制文件
        File apk = new File(getCacheDir() + "hotfix.dex");
        if (!apk.exists()) {
            try (Source source = Okio.source(getAssets().open("apk/hotfix.dex"));
                 BufferedSink sink = Okio.buffer(Okio.sink(apk));) {
                sink.writeAll(source);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        //2.替换classLoader,里面的dex文件
        try {
            ClassLoader originalLoader = getClassLoader();
            DexClassLoader
                    classLoader = new DexClassLoader(apk.getPath(),getCacheDir().getPath(),null,null);
            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);

            Object originalPathListObject = pathListField.get(originalLoader);
            Object originalDexElementsObject = dexElementsField.get(originalPathListObject);

            //数组操作,把最新的补丁dex文件插入到最前面
            int oldLength = Array.getLength(originalDexElementsObject);
            int newLength = Array.getLength(dexElementsObject);
            Object concatDexElementsObject = Array.newInstance(dexElementsObject.getClass().getComponentType(), oldLength + newLength);
            for (int i = 0; i < newLength; i++) {
                Array.set(concatDexElementsObject, i, Array.get(dexElementsObject, i));
            }
            for (int i = 0; i < oldLength; i++) {
                Array.set(concatDexElementsObject, newLength + i, Array.get(originalDexElementsObject, i));
            }
            dexElementsField.set(originalPathListObject, concatDexElementsObject);

            //全量替换伪代码 -> originalLoader.pathList.dexElements = classLoader.pathList.dexElement
            //增量替换伪代码 -> originalLoader.pathList.dexElements += classLoader.pathList.dexElement
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }
}

下面是效果图
3

流程完善

下面为我们的热更新功能增加一个「移除按钮」和「杀掉应用」的按钮

onClick{
    ...
    //加载文字
    case R.id.showText:
        tv_hotfix.setText(Plugin.getTitle());
        break;
    //加载热更新dex文件
    case R.id.hotFix:
        loadHotFix();
        break;
    //移除热更新
    case R.id.removehotFix:
        File apk = new File(getCacheDir() + "/hotfix.dex");
        if (apk.exists()){
            apk.delete();
        }
        break;
    //强杀进程
    case R.id.killSelf:
        android.os.Process.killProcess(android.os.Process.myPid());
        break;
    ...
}

现在操作这些按钮

  • 【显示文字】->“我要热更新”
  • 【热更新】(加载dex修复文件)
  • 【杀掉应用】(重启应用)
  • 【显示文字】->“我是dex热修复后的标题”
  • 【移除热更新】(移除热更新dex)
  • 【杀掉应用】(重启应用)
  • 【显示文字】->“我要热更新”
  • ...

由此完成了一个完整的热更新演示过程

从网络下载补丁

热更新的补丁在实际开发中不可能存放在本地,一般都会存在服务器上 下面使用okhttp模拟下载过程

//从网络上下载补丁
case R.id.hotFix:
    OkHttpClient client = new OkHttpClient();
    final Request request = new Request.Builder()
        .url("https://api.dsh.com/patch/upload/hotfix.dex")
        .build();
    client.newCall(request)
        .enqueue(new Callback() {
          @Override
          public void onFailure(@NotNull Call call, @NotNull IOException e) {
            v.post(new Runnable() {
              @Override
              public void run() {
                Toast.makeText(MainActivity.this, "出错了", Toast.LENGTH_SHORT).show();
              }
            });
          }

          @Override
          public void onResponse(@NotNull Call call, @NotNull Response response) {
            try (BufferedSink sink = Okio.buffer(Okio.sink(apk))) {
              sink.write(response.body().bytes());
            } catch (IOException e) {
              e.printStackTrace();
            }
            v.post(new Runnable() {
              @Override
              public void run() {
                Toast.makeText(MainActivity.this, "补丁加载成功", Toast.LENGTH_SHORT).show();
              }
            });
          }
        });
    break;

补丁自动打包

在build.gradle中添加如下代码,并执行打包命令,这样就可以输出补丁的dex文件了,上传下载,搞定

def patchPath = 'com.dsh.txlessons.plugin.utils/Plugin'

task hotfix {
  doLast {
    exec {
      commandLine 'rm', '-r', './build/patch'
    }
    exec {
      commandLine 'mkdir', './build/patch'
    }
    exec {
      commandLine 'javac', "./src/main/java/${patchPath}.java", '-d', './build/patch'
    }
    exec {
      commandLine '/Users/dsh/Library/Android/sdk/build-tools/29.0.2/d8', "./build/patch/${patchPath}.class", '--output', './build/patch'
    }
    exec {
      commandLine 'mv', "./build/patch/classes.dex", './build/patch/hotfix.dex'
    }
  }
}