Android SDK的轻量级热修更新

需求

最近要做sdk的热更新
因为需求方的sdk其实是jar包,只有class文件,没有资源文件,所以此文只针对class文件更新

首先罗列下一个轻量级更新框架的功能最小边界:

  • 需要配置文件描述更新包
  • 更新包需要在线下载,并检验包的完整性
  • 只针对特定版本
  • 针对特定渠道
  • 补丁包的版本控制

调研

市面上比较流行的热更新有Tinker、QZone、AndFix、Sophix、Robust、Dexposed这些大家都很熟悉,但绝大部分是针对app的
先回顾下class文件是如何实现热修复的:

概念:

DexClassLoader和PathClassLoader

回顾下Android中Classloader的知识
DexClassLoaderPathClassLoader都是继承自BaseDexClassLoader
看下Android10的代码

public class DexClassLoader extends BaseDexClassLoader {
    /**
     * ...
     */
    public DexClassLoader(String dexPath, String optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}

public class PathClassLoader extends BaseDexClassLoader {
    public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}

可见这两个类没有本质上区别,网上说的optimizedDirectory在Api26之后已废弃
只不过PathClassLoader多了构造函数参数sharedLibraryLoaders,可以加载系统类

ClassLoader

在Java的ClassLoader中:

 protected Class loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // First, check if the class has already been loaded
            Class c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    c = findClass(name);
                }
            }
            return c;
    }

findLoadedClass是保证加载的类不会被加载
findBootstrapClassOrNull返回的是空
所以调用的findClass方法

BaseDexClassLoader的findClass方法:

 @Override
    protected Class findClass(String name) throws ClassNotFoundException {
        // First, check whether the class is present in our shared libraries.
        if (sharedLibraryLoaders != null) {
            for (ClassLoader loader : sharedLibraryLoaders) {
                try {
                    return loader.loadClass(name);
                } catch (ClassNotFoundException ignored) {
                }
            }
        }
        // Check whether the class in question is present in the dexPath that
        // this classloader operates on.
        List suppressedExceptions = new ArrayList();
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException(
                    "Didn't find class \"" + name + "\" on path: " + pathList);
            for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        return c;
    }

可以由传进来的sharedLibraryLoaders来加载,但DexClassLoader的sharedLibraryLoaders为空
是交由DexPathList类来处理实现findClass

DexPathList

 DexPathList(ClassLoader definingContext, String dexPath,
            String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
       ...
        this.definingContext = definingContext;

        ArrayList suppressedExceptions = new ArrayList();
        // save dexPath for BaseDexClassLoader
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                           suppressedExceptions, definingContext, isTrusted);

        //native相关
        this.nativeLibraryDirectories = splitPaths(librarySearchPath, false);
        this.systemNativeLibraryDirectories =
                splitPaths(System.getProperty("java.library.path"), true);
        this.nativeLibraryPathElements = makePathElements(getAllNativeLibraryDirectories());
        ...
    }

通过Element[]数组来存储dex,makeDexElements这个方法:

 private static Element[] makeDexElements(List files, File optimizedDirectory,
            List suppressedExceptions, ClassLoader loader, boolean isTrusted) {
      Element[] elements = new Element[files.size()];
      int elementsPos = 0;
      for (File file : files) {
          if (file.isDirectory()) {
              elements[elementsPos++] = new Element(file);
          } else if (file.isFile()) {
              String name = file.getName();
              DexFile dex = null;
              if (name.endsWith(DEX_SUFFIX)) {
                  // Raw dex file (not inside a zip/jar).
                  try {
                      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 {
                  try {
                      dex = loadDexFile(file, optimizedDirectory, loader, elements);
                  } catch (IOException suppressed) {
                      suppressedExceptions.add(suppressed);
                  }
                  if (dex == null) {
                      elements[elementsPos++] = new Element(file);
                  } else {
                      elements[elementsPos++] = new Element(dex, file);
                  }
              }
              if (dex != null && isTrusted) {
                dex.setTrusted();
              }
          } else {
              System.logW("ClassLoader referenced unknown path: " + file);
          }
      }
      if (elementsPos != elements.length) {
          elements = Arrays.copyOf(elements, elementsPos);
      }
      return elements;
    }

DexPathList在构造函数通过makeDexElements方法,生成Element[]数组
在BaseDexClassLoader中调用的是DexPathList的findClass方法:

public Class findClass(String name, List suppressed) {
        for (Element element : dexElements) {
            Class clazz = element.findClass(name, definingContext, suppressed);
            if (clazz != null) {
                return clazz;
            }
        }

        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }

很清楚的看到element是按顺序取出
也就是说两个element如果有相同的class,第二个就不会加载,如果将目标dex包插队到列队最前面,则可以实现目标dex包中的class替换掉其他dex中的class
热修复的实现:

  1. DexClassLoader加载目标dex
  2. 将目标的和系统的 dexElements 进行合并
  3. 赋值给系统的 pathList
 public void loadCustomDex(Context mContext){
      //遍历所有修复的dex
      File fileDir = yourfilepath;
      File[] listFiles = fileDir.listFiles();
      for (File f : listFiles){
          if(isValid(f)){
              dexList.add(f);
          }
      }
      //合并与应用
      PathClassLoader pathClassLoader = (PathClassLoader) mContext.getClassLoader();
      for (File f : dexList){
          //加载指定的dex文件。
          DexClassLoader dexClassLoader = new DexClassLoader(f.getAbsolutePath(),null,null,pathClassLoader);
          //合并
          Object dexObj = getPathList(dexClassLoader);
          Object pathObj = getPathList(pathClassLoader);
          Object dexElementsList = getDexElements(dexObj);
          Object pathElementsList = getDexElements(pathObj);
          Object newElements = combineArray(dexElementsList,pathElementsList);
          //给PathList里面的dexElements赋值
          Object pathList = getPathList(pathClassLoader);
          setField(pathList,pathList.getClass(),"dexElements",newElements);
      }
  }
 
    
private Object getPathList(Object obj) throws Exception {
        return reflectField(obj,Class.forName("dalvik.system.BaseDexClassLoader"),"pathList");
}

private Object getDexElements(Object obj) throws Exception {
        return reflectField(obj,obj.getClass(),"dexElements");
}

private static Object combineArray(Object array1, Object array2) {
        Class localClass = array1.getClass().getComponentType();
        int i = Array.getLength(array1);
        int j = i + Array.getLength(array2);
        Object result = Array.newInstance(localClass, j);
        for (int k = 0; k < j; ++k) {
            if (k < i) {
                Array.set(result, k, Array.get(array1, k));
            } else {
                Array.set(result, k, Array.get(array2, k - i));
            }
        }
        return result;
    }

方案:

上述的热更新实现方式有两个问题需要解决:

  • app中可以在Application中的attachBaseContext中去做插入dex,SDK的类是不知道何时被调用的
  • ISPREVERIFIED问题

如果一个类的static方法,private方法,override方法以及构造函数中引用了其他类,而且这些类都属于同一个dex文件,此时该类就会被打上CLASS_ISPREVERIFIED
如果在运行时被打上CLASS_ISPREVERIFIED的类引用了其他dex的类,就会报错

解决ISPREVERIFIED问题,常用的就是“插装”(往字节码里插入自定义的字节码),我们只要让所有类都引用其他dex中的某个类就可以了:

  1. 当安装apk的时候,classes.dex内的类都会引用一个在不相同dex中的XX类,这样就防止了类被打上CLASS_ISPREVERIFIED的标志了,只要没被打上这个标志的类都可以进行打补丁操作
  2. 我们需要在源码编译成字节码之后,在字节码中进行插入操作。对字节码进行操作的框架有很多,但是比较常用的则是ASM和javaassist

那么有没有简单点的方法:

  1. 将工程拆分,保证要进行更换的工程与其他工程没有直接引用(都用反射)
  2. 对要进行更换的工程采用全量替换方法,考虑到sdk体积比较小,此方法最简单

配置文件

对应需求中的最小边界,定义了如下的配置文件:

{
    "checksum": {
        "TYPE": "md5",
        "value": "20d81614ab8ac44b3185af0f5e8a96b2"
    },
    "channel": "abcdefgh",
    "version": "1.0.0",
    "subVersion": 1,
    "package": "http://robinfjb.github.io/dex.jar",
    "className": "robin.sdk.sdk_impl2.ServiceImpl"
}
  1. checksum是对包完整性的验证
  2. channel是渠道,在sdk一般用appkey作为渠道标识
  3. version目标版本,由于sdk的定制化比较多,不同版本的代码功能都不一样,所以只针对某个版本更新
  4. subVersion补丁版本,只有新的补丁才会应用
  5. package包的下载地址
  6. className新包的入口类名,后面会讲到

工程

我们选择类似类加载方案
将整个sdk拆分成 业务实现,更新模块,对外api 三个模块

  • 业务实现:sdk-impl , 更新则只要更新业务实现模块
  • 更新模块:sdk-dynamic , 实现热更新下载,应用
  • 对外api:sdk,sdk对外暴露的类
  • 代理: sdk-proxy 为了避免sdk-implsdk的依赖,用单独工程维护两个模块之间的接口
  • 公共:sdk-common 放一些基础类,比如log之类

代码

命名

补丁类可以命名为packagename+version
比如业务包名:robin.sdk.sdk_impl
补丁包名则为:robin.sdk.sdk_impl1

关键代码:

代理:

例子中使用了CS结构,我们将service代理:

public interface ServiceProxy {
    void onCreate(Context var1);

    int onStartCommand(Intent var1, int var2, int var3);

    IBinder onBind(Intent var1);

    boolean onUnBind(Intent var1);

    void onDestroy();
}

对外的service中实现:

public final class RobinService extends Service {
    @Override
    public void onCreate() {
        try {
            context = getApplicationContext();
            proxy = serviceLoad();
            proxy.onCreate(this);
            checkUpdate();
        } catch (Throwable e) {
        }
    }
    
    @Override
    public int onStartCommand(Intent var1, int var2, int var3) {
        return proxy.onStartCommand(var1, var2, var3);
    }

    @Override
    public IBinder onBind(Intent var1) {
        return proxy.onBind(var1);
    }

    @Override
    public boolean onUnbind(Intent var1) {
        return proxy.onUnBind(var1);
    }

    @Override
    public void onDestroy() {
        proxy.onDestroy();
    }
}

然后在sdk-impl中实现代理接口:

public class ServiceImpl implements ServiceProxy {
}

这个类就是我们需要更换的类

下载补丁

在程序入口(Service onCreate)方法, 开启下载
先下载配置文件,下载完成后:

DyInfo dyInfo = new DyInfo(new JSONObject(response));
if (checkDyInfo(context, dyInfo)) {//检查配置文件有效性
    LogUtil.e(UPDATE_TAG, "downloadDyJar");
    downloadDyJar(dyInfo, context);
}

checkDyInfo检查当前包是否需要下载与应用补丁
几个关键检测:

  • 检测key是否等于当前key,说明是针对某个key发的补丁,如果为空说明是全部渠道都应用补丁
  • 检测SDK版本是否和配置文件中一致,补丁只针对某个版本
  • 检测补丁版本,在补丁应用成功后会记录应用的补丁版本,只有新的补丁版本大于已应用的,补丁才会下载与生效

下载完成后:检查文件完整性与保存配置信息

if (!checkJarMd5(context, dyInfo.checksumValue)) {
    LogUtil.e(UPDATE_TAG, "checkJarMd5 fail");
    deleteFailjar(context);
} else {
    //保存jar信息
    SpUtil.setDyInfo(context, dyInfo);
    LogUtil.e(UPDATE_TAG, "checkJarMd5 success");
}

应用补丁

        try {
            DyInfo dyInfo = SpUtil.getDyInfo(context);
            if (UpdateManager.checkDyInfo(context, dyInfo) && checkJar(dyInfo, usingJar)) {
                DexClassLoader dexClassLoader = new DexClassLoader(usingJar.getAbsolutePath(),
                        context.getCacheDir().getAbsolutePath(), null, context.getClassLoader());
                Class libclass = dexClassLoader.loadClass(dyInfo.className);
                lib = (ServiceProxy) libclass.newInstance();
                SpUtil.setPatchVersion(context, dyInfo.subVersion);
                LogUtil.e(DYNAMIC_TAG, "动态包已加载成功 ");
            }
        } catch (Throwable throwable) {
            LogUtil.e(DYNAMIC_TAG, "动态包加载异常:" + throwable.getLocalizedMessage());
        }
        if(lib == null) {
            try {
                Class libclass = context.getClassLoader().loadClass("robin.sdk.sdk_impl.ServiceImpl");
                lib = (ServiceProxy) libclass.newInstance();
            } catch (Throwable throwable) {
                LogUtil.e(DYNAMIC_TAG, "正常包加载异常:" + throwable.getLocalizedMessage());
            }
        }
  • 读出上一次下载的配置文件与补丁包,进行校验
  • DexClassLoader加载目标usingJar(data/user/0/packagename/file/robin/dex.jar)的包
  • DexClassLoader加载包中的类ServiceImpl(类名比如:robin.sdk.sdk_impl1.ServiceImpl
  • 如果未加载到,则加载默认包中的类robin.sdk.sdk_impl.ServiceImpl

脚本

sdk

Android studio的assembleXXX往往打的都是aar包,我们需要一个jar包的gradle task:

task makeSdkJar(type: Jar) {
   //指定生成的jar名
    baseName 'sdk'
    //从哪里打包class文件
    from('build/intermediates/javac/release/classes/')
    from('../sdk-proxy/build/intermediates/javac/release/classes/')
    from('../sdk-common/build/intermediates/javac/release/classes/')
    from('../sdk-dynamic/build/intermediates/javac/release/classes/')
    from('../sdk-impl/build/intermediates/javac/release/classes/')
}
makeSdkJar.dependsOn(clean, 'compileReleaseJavaWithJavac')

注意:由于gradle版本不同,intermediates目录的路径可能会有变化

打出sdk.jar包,然后进行混淆

task _proguardJar(dependsOn: makeSdkJar, type: proguard.gradle.ProGuardTask) {
    String inJar = makeSdkJar.archivePath.getAbsolutePath()
    println("正在混淆jar...path= " + inJar)

    injars inJar
    outjars "build/libs/proguard.jar"
    configuration "$rootDir/sdk/proguard-rules.pro"
}

proguard-rules.pro中,需要keep住所有需要反射的类

-keep public class robin.sdk.*.ServiceImpl {*;}
-keep class robin.sdk.*.ServiceImpl$* {*;}

对于sdk对外的类,也需要keep:

-keep public class robin.sdk.hotfix.RobinClient {*;}
-keep class robin.sdk.hotfix.RobinClient$* {*;}

补丁

这里采用全量打包方式,将sdk的jar包解压,去除hotfix,proxy,sdk_common,service_dynamic目录:

//打patch任务
task renameJar(type: Copy) {
    from 'build/libs/'
    include 'proguard.jar'
    destinationDir file('build/libs/')
    rename 'proguard.jar', "classes.zip"
}

task upzip(dependsOn: renameJar, type: Copy) {
    def zipFile = file('build/libs/classes.zip')
    def outputDir = file("build/libs/unzip")
    from zipTree(zipFile)
    into outputDir
}

task _patchProguardJar(dependsOn: upzip, type: Jar) {
//指定生成的jar名
    baseName 'patch'
    from('build/libs/unzip/')
    exclude('robin/sdk/hotfix')
    exclude('robin/sdk/proxy')
    exclude('robin/sdk/sdk_common')
    exclude('robin/sdk/service_dynamic')
    doLast {
        delete('build/libs/unzip')
        delete('build/libs/classes.zip')
    }
}

将jar包转dex方便DexClassLoader加载:

task _jarToDex(type: Exec) {
    commandLine 'cmd'
    doFirst {
        //jar文件对象
        def srcFile = file("/build/libs/hot.jar")
        //需要生成的dex文件对象
        def desFile = file(srcFile.parent + "/" + "dex.jar")
        workingDir srcFile.parent
        //拼接dx.bat执行的参数
        def list = []
        list.add("/c")
        list.add("dx")
        list.add("--dex")
        list.add("--output")
        list.add(desFile)
        list.add(srcFile)
        args list
    }
}

测试验证

使用_proguardJar task打包sdk.jar文件,放到测试app中:
app的gradle依赖:

implementation files("libs/sdk.jar")

首次启动:

日志如下:

Android SDK的轻量级热修更新_第1张图片
image

此日志为CLient和Service直接的交互,可见Service为 robin.sdk.sdk_impl.ServiceImpl,为原版的Service
image

此日志为ServiceImpl中类的引用,目前的类为 robin.sdk.sdk_impl.a(已混淆)

Service启动后会满足条件自动下载补丁包;


Android SDK的轻量级热修更新_第2张图片
image

第二次启动:

日志如下:
加载动态包后的:

image

原来的 robin.sdk.sdk_impl.a已替换为 robin.sdk.sdk_impl2.a
Android SDK的轻量级热修更新_第3张图片
image

Service也替换成功

工程代码:

https://github.com/robinfjb/Android_SDK_Hotfix

你可能感兴趣的:(Android SDK的轻量级热修更新)