关闭

Android热补丁技术,ClassLoader、dexposed、Andfix、smart app updates补丁技术收集整理

665人阅读 评论(0) 收藏 举报
分类:

介绍

你所看到的,是一个用于Android应用程序增量更新的开源库。

包括客户端、服务端两部分代码。

原理

自从 Android 4.1 开始,Google引入了应用程序的增量更新。

Link: http://developer.android.com/about/versions/jelly-bean.html

Smart app updates is a new feature of Google Play that introduces a better way of delivering app updates to devices. When developers publish an update, Google Play now delivers only the bits that have changed to devices, rather than the entire APK. This makes the updates much lighter-weight in most cases, so they are faster to download, save the device’s battery, and conserve bandwidth usage on users’ mobile data plan. On average, a smart app update is about 1/3 the sizeof a full APK update.

增量更新的原理非常简单,就是将手机上已安装apk与服务器端最新apk进行二进制对比,并得到差分包,用户更新程序时,只需要下载差分包,并在本地使用差分包与已安装apk,合成新版apk。

例如,当前手机中已安装微博V1,大小为12.8MB,现在微博发布了最新版V2,大小为15.4MB,我们对两个版本的apk文件查分比对之后,发现差异只有3M,那么用户就只需要要下载一个3M的差分包,使用旧版apk与这个差分包,合成得到一个新版本apk,提醒用户安装即可,不需要整包下载15.4M的微博V2版apk。

apk文件的差分、合成,可以通过开源的二进制比较工具bsdiff来实现(Link:http://www.daemonology.net/bsdiff/)

因为bsdiff依赖bzip2,所以我们还需要用到bzip2(Link:http://www.bzip.org/downloads.html

bsdiff中,bsdiff.c用于生成查分包,bspatch.c用于合成文件。

接下来,我们分开说,需要做3件事。

1.在服务器端,生成这两个版本微博的差分包;

2.在手机客户端,使用已安装的旧版apk与这个差分包,合成为一个新版微博apk;

3.校验新合成的微博客户端文件是否完成,签名时候和已安装客户端一致,如一致,提示用户安装;

过程分析

1 生成差分包

这一步需要在服务器端来实现,一般来说,每当apk有新版本需要提示用户升级,都需要运营人员在后台管理端上传新apk,上传时就应该由程序生成之前所有旧版本们与最新版的差分包。

例如: 你的apk已经发布了3个版,V1.0、V2.0、V3.0,这时候你要在后台发布V4.0,那么,当你在服务器上传最新的V4.0包时,服务器端就应该立即生成以下差分包:

  1. V1.0 ——> V4.0的差分包;

  2. V2.0 ——> V4.0的差分包;

  3. V3.0 ——> V4.0的差分包;

ApkPatchLibraryServer工程即为Java语言实现的服务器端查分程序。

下面对ApkPatchLibraryServer做一些简单说明:

1.1 C部分

ApkPatchLibraryServer/jni 中,除了以下4个:

com_cundong_utils_DiffUtils.c com_cundong_utils_DiffUtils.h com_cundong_utils_PatchUtils.c com_cundong_utils_PatchUtils.h

全部来自bzip。

com_cundong_utils_DiffUtils.c com_cundong_utils_DiffUtils.h

用于生成差分包。

com_cundong_utils_PatchUtils.c com_cundong_utils_PatchUtils.h

用于合成新apk文件。

其中,com_cundong_utils_DiffUtils.c修改自 bsdiff/bsdiff.c,com_cundong_utils_PatchUtils.c修改自bsdiff/bspatch.c。

我们在需要将jni中的C文件,build输出为动态链接库,以供Java调用(Window环境下生成的文件名为libApkPatchLibraryServer.dll,Unix-like系统下为libApkPatchLibraryServer.so,OSX下为libApkPatchLibraryServer.dylib)。

Build成功后,将该动态链接库文件,加入环境变量,供Java语言调用。

1.2 Java部分

com.cundong.utils包,为调用C语言的Java实现; com.cundong.apkdiff包,为apk查分程序的Demo; com.cundong.apkpatch包,为apk合并程序的Demo;

调用,com.cundong.utils.DiffUtils中genDiff()方法,可以通过传入的新旧apk路径,得到差分包。

/**
 * 类说明:     apk diff 工具类
 * 
 * @author  Cundong
 * @date    2013-9-6
 * @version 1.0
 */
public class DiffUtils {

    /**
     * 本地方法 比较路径为oldPath的apk与newPath的apk之间差异,并生成patch包,存储于patchPath
     * 
     * @param oldPath
     * @param newPath
     * @param patchPath
     * @return
     */
    public static native int genDiff(String oldApkPath, String newApkPath, String patchPath);
}

调用,com.cundong.utils.PatchUtils中patch()方法,可以通过旧apk与差分包,合成为新apk。

/**
 * 类说明:   APK Patch工具类
 * 
 * @author  Cundong
 * @date    2013-9-6
 * @version 1.0
 */
public class PatchUtils {

    /**
     * native方法
     * 使用路径为oldApkPath的apk与路径为patchPath的补丁包,合成新的apk,并存储于newApkPath
     * @param oldApkPath
     * @param newApkPath
     * @param patchPath
     * @return
     */
    public static native int patch(String oldApkPath, String newApkPath,
            String patchPath);
}

2.使用旧版apk与差分包,在客户端合成新apk

需要在手机客户端实现,ApkPatchLibrary工程封装了这个过程。

2.1 C部分

ApkPatchLibrary/jni/bzip2目录中所有文件都来自bzip2项目。

ApkPatchLibrary/jni/com_cundong_utils_PatchUtils.c、ApkPatchLibrary/jni/com_cundong_utils_PatchUtils.c实现文件的合并过程,其中com_cundong_utils_PatchUtils.c修改自bsdiff/bspatch.c。

我们需要用NDK编译出一个libApkPatchLibrary.so文件,生成的so文件位于libs/armeabi/ 下,其他 Android 工程便可以使用该libApkPatchLibrary.so文件来合成apk。

2.2 Java部分

com.cundong.utils包,为调用C语言的Java实现;

调用,com.cundong.utils.PatchUtils中patch()方法,可以通过旧apk与差分包,合成为新apk。

/**
 * 类说明:   APK Patch工具类
 * 
 * @author  Cundong
 * @date    2013-9-6
 * @version 1.0
 */
public class PatchUtils {

    /**
     * native方法
     * 使用路径为oldApkPath的apk与路径为patchPath的补丁包,合成新的apk,并存储于newApkPath
     * @param oldApkPath
     * @param newApkPath
     * @param patchPath
     * @return
     */
    public static native int patch(String oldApkPath, String newApkPath,
            String patchPath);
}

3.校验新合成的apk文件

新包和成之后,还需要对客户端合成的apk包与最新版本apk包进行MD5或SHA1校验,如果校验码不一致,说明合成过程有问题,新合成的包将不能被安装。

注意事项

增量更新的前提条件,是在手机客户端能让我们读取到当前应用程序安装后的源apk,如果获取不到源apk,那么就无法进行增量更新了。

另外,如果你的应用程序不是很大,比如只有2、3M,那么完全没有必要使用增量更新,增量更新适用于apk包比较大的情况,比如游戏客户端。

GitHub地址

GitHub:https://github.com/cundong/SmartAppUpdates

一些说明

源码中,包含以下文件:

1.ApkPatchLibraryServer:Java语言实现的,服务器端生成差分包工程;

2.ApkPatchLibrary:客户端使用的apk合成库;

3.ApkPatchLibraryDemo:引用ApkPatchLibrary Library 的Demo,以新浪微博客户端的升级为例,假设手机上安装的是V4.5.0,最新版是V4.5.5,用户需要从V4.5.0升级到V4.5.5。

4.TestApk:用于测试的,旧版本的微博客户端,以及使用ApkPatchLibraryServer生成的新旧新浪微博差分包;


其它流行的补丁方式:

随着移动互联网蓬勃发展,App 规模越来越大,对 App 发布迭代速度和质量有更高的要求,技术开发同学面临着更大的挑战。怎样让 App 发布更快更灵活,以及上线后更快地修复各种 Crash 和紧急 Bug,让用户免去下载安装的操作,在最短的时间内升级用户手中的 App,是 Android 开发哥面临的一个重要的技术课题。业界也有 Dexposed、AndFix 等补丁技术,取得了一定的效果。但这些技术在 Android 平台兼容性还存在着一些问题,以及修复 Bug 的代码需要以反射的方式来实现,不太方便。而且不能更新资源,无法对 App 进行版本升级,仅用于修复 Bug。QQ 空间团队在去年实现 class 替换热补丁包技术的基础上,更进一步在业内首创超级补丁包技术,实现了 App 上 Dex 和资源替换覆盖,在开发人员和用户都完全透明无感知的情况下,可把任意App直接升级到最新版本。


classLoader       可参考:http://blog.csdn.net/tencent_bugly/article/details/51821722

dexposed                        可参考:http://dengyin2000.iteye.com/blog/2234430

andfix                               可参考:http://blog.csdn.net/u011176685/article/details/50984638


三者优劣对比:


热补丁技术都是大公司推崇的方向。也是能更好的解决应用出问题及时补就的方法。目前列出兴时的技术方案。若有需要,可方便学习和查询!


最近开源界涌现了很多热补丁项目,但从方案上来说,主要包括 Dexposed 、 AndFix 、 ClassLoader (来源是原QZone,现淘宝的工程师陈钟,在15年年初就已经开始实现)三种。前两个都是阿里巴巴内部的不同团队做的(淘宝和支付宝),后者则来自腾讯的QQ空间团队。

开源界往往一个方案会有好几种实现(比如ClassLoader方案已经有不下三种实现了),但这三种方案的原理却徊然不同,那么让我们来看看它们三者的原理和各自的优缺点吧。

Dexposed

基于 Xposed 的AOP框架,方法级粒度,可以进行AOP编程、插桩、热补丁、SDK hook等功能。

Xposed需要Root权限,是因为它要修改其他应用、系统的行为,而对单个应用来说,其实不需要root。 Xposed通过修改Android Dalvik运行时的Zygote进程,并使用Xposed Bridge来hook方法并注入自己的代码,实现非侵入式的runtime修改。比如蜻蜓fm和喜马拉雅做的事情,其实就很适合这种场景,别人反编译市场下载的代码是看不到patch的行为的。 小米 (onVmCreated里面还未小米做了资源的处理)也重用了dexposed,去做了很多自定义主题的功能,还有沉浸式状态栏等。

我们知道,应用启动的时候,都会fork zygote进程,装载class和invoke各种初始化方法,Xposed就是在这个过程中,替换了app_process,hook了各种入口级方法(比如handleBindApplication、ServerThread、ActivityThread、ApplicationPackageManager的getResourcesForApplication等),加载XposedBridge.jar提供动态hook基础。

具体到方法,可参见 XposedBridge :

/**
 * Intercept every call to the specified method and call a handler function instead.
 * @param method The method to intercept
 */
private native synchronized static void hookMethodNative(Member method, Class<?> declaringClass, int slot, Object additionalInfo);

其具体native实现则在 Xposed的libxposed_common.cpp 里面有注册,根据系统版本分发到libxposed_dalvik和libxposed_art里面,以dalvik为例大致来说就是记录下原来的方法信息,并把方法指针指向我们的hookedMethodCallback,从而实现拦截的目的。

方法级的替换是指,可以在方法前、方法后插入代码,或者直接替换方法。只能针对java方法做拦截,不支持C的方法。

来说说硬伤吧,不支持art,不支持art,不支持art。重要的事情要说三遍。尽管在6月,项目网站的roadmap就写了7、8月会支持art,但事实是现在还无法解决art的兼容。

另外,如果线上release版本进行了混淆,那写patch也是一件很痛苦的事情,反射+内部类,可能还有包名和内部类的名字冲突,总而言之就是写得很痛苦。

AndFix

同样是方法的hook,AndFix不像Dexposed从Method入手,而是以Field为切入点。

先看Java入口, AndFixManager.fix :

/**
 * fix
 *
 * @param file        patch file
 * @param classLoader classloader of class that will be fixed
 * @param classes     classes will be fixed
 */
public synchronized void fix(File file, ClassLoader classLoader, List<String> classes) {
    // 省略...判断是否支持,安全检查,读取补丁的dex文件

    ClassLoader patchClassLoader = new ClassLoader(classLoader) {
      @Override
      protected Class<?> findClass(String className) throws ClassNotFoundException {
        Class<?> clazz = dexFile.loadClass(className, this);
        if (clazz == null && className.startsWith("com.alipay.euler.andfix")) {
          return Class.forName(className);// annotation’s class not found
        }
        if (clazz == null) {
          throw new ClassNotFoundException(className);
        }
        return clazz;
      }
    };
    Enumeration<String> entrys = dexFile.entries();
    Class<?> clazz = null;
    while (entrys.hasMoreElements()) {
      String entry = entrys.nextElement();
      if (classes != null && !classes.contains(entry)) {
        continue;// skip, not need fix
      }
      // 找到了,加载补丁class
      clazz = dexFile.loadClass(entry, patchClassLoader);
      if (clazz != null) {
        fixClass(clazz, classLoader);
      }
    }
  } catch (IOException e) {
    Log.e(TAG, "pacth", e);
  }
}

看来最终fix是在 fixClass方法 :

private void fixClass(Class<?> clazz, ClassLoader classLoader) {
  Method[] methods = clazz.getDeclaredMethods();
  MethodReplace methodReplace;
  String clz;
  String meth;
  // 遍历补丁class里的方法,进行一一替换,annotation则是补丁包工具自动加上的
  for (Method method : methods) {
    methodReplace = method.getAnnotation(MethodReplace.class);
    if (methodReplace == null)
      continue;
    clz = methodReplace.clazz();
    meth = methodReplace.method();
    if (!isEmpty(clz) && !isEmpty(meth)) {
      replaceMethod(classLoader, clz, meth, method);
    }
  }
}

private void replaceMethod(ClassLoader classLoader, String clz, String meth, Method method) {
  try {
    String key = clz + "@" + classLoader.toString();
    Class<?> clazz = mFixedClass.get(key);
    if (clazz == null) {// class not load
      // 要被替换的class
      Class<?> clzz = classLoader.loadClass(clz);
      // 这里也很黑科技,通过C层,改写accessFlags,把需要替换的类的所有方法(Field)改成了public,具体可以看Method结构体
      clazz = AndFix.initTargetClass(clzz);
    }
    if (clazz != null) {// initialize class OK
      mFixedClass.put(key, clazz);
      // 需要被替换的函数
      Method src = clazz.getDeclaredMethod(meth, method.getParameterTypes());
      // 这里是调用了jni,art和dalvik分别执行不同的替换逻辑,在cpp进行实现
      AndFix.addReplaceMethod(src, method);
    }
  } catch (Exception e) {
    Log.e(TAG, "replaceMethod", e);
  }
}

在dalvik和art上,系统的调用不同,但是原理类似,这里我们尝个鲜,以6.0为例 art_method_replace_6_0 :

// 进行方法的替换
void replace_6_0(JNIEnv* env, jobject src, jobject dest) {
  art::mirror::ArtMethod* smeth = (art::mirror::ArtMethod*) env->FromReflectedMethod(src);
  art::mirror::ArtMethod* dmeth = (art::mirror::ArtMethod*) env->FromReflectedMethod(dest);

  dmeth->declaring_class_->class_loader_ =
      smeth->declaring_class_->class_loader_; //for plugin classloader
  dmeth->declaring_class_->clinit_thread_id_ =
      smeth->declaring_class_->clinit_thread_id_;
  dmeth->declaring_class_->status_ = smeth->declaring_class_->status_-1;

  // 把原方法的各种属性都改成补丁方法的
  smeth->declaring_class_ = dmeth->declaring_class_;
  smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
  smeth->access_flags_ = dmeth->access_flags_;
  smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
  smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
  smeth->method_index_ = dmeth->method_index_;
  smeth->dex_method_index_ = dmeth->dex_method_index_;

  // 实现的指针也替换为新的
  smeth->ptr_sized_fields_.entry_point_from_interpreter_ =
      dmeth->ptr_sized_fields_.entry_point_from_interpreter_;
  smeth->ptr_sized_fields_.entry_point_from_jni_ =
      dmeth->ptr_sized_fields_.entry_point_from_jni_;
  smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
      dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;

  LOGD("replace_6_0: %d , %d",
      smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,
      dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);
}

// 这就是上面提到的,把方法都改成public的,所以说了解一下jni还是很有必要的,java世界在c世界是有映射关系的
void setFieldFlag_6_0(JNIEnv* env, jobject field) {
  art::mirror::ArtField* artField =
      (art::mirror::ArtField*) env->FromReflectedField(field);
  artField->access_flags_ = artField->access_flags_ & (~0x0002) | 0x0001;
  LOGD("setFieldFlag_6_0: %d ", artField->access_flags_);
}

在dalvik上的实现略有不同,是通过jni bridge来指向补丁的方法。

使用上,直接写一个新的类,会由补丁工具会生成注解,描述其与要打补丁的类和方法的对应关系。

ClassLoader

原腾讯空间Android工程师,也是我的启蒙老师的陈钟发明的热补丁方案,是他在看源码的时候偶然发现的切入点。

我们知道,multidex方案的实现,其实就是把多个dex放进app的classloader之中,从而使得所有dex的类都能被找到。而实际上findClass的过程中,如果出现了重复的类,参照下面的类加载的实现,是会使用第一个找到的类的。

public Class findClass(String name, List<Throwable> suppressed) {  

    for (Element element : dexElements) {  //每个Element就是一个dex文件
        DexFile dex = element.dexFile;
        if (dex != null) {
            Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
            if (clazz != null) {
              return clazz;
            }
        }
    }
    if (dexElementsSuppressedExceptions != null) {  
        suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
    }
    return null;
}

该热补丁方案就是从这一点出发,只要把有问题的类修复后,放到一个单独的dex,通过反射插入到dexElements数组的最前面,不就可以让虚拟机加载到打完补丁的class了吗。

说到此处,似乎已经是一个完整的方案了,但在实践中,会发现运行加载类的时候报preverified错误,原来在 DexPrepare.cpp ,将dex转化成odex的过程中,会在 DexVerify.cpp 进行校验,验证如果直接引用到的类和clazz是否在同一个dex,如果是,则会打上CLASS_ISPREVERIFIED标志。通过在所有类(Application除外,当时还没加载自定义类的代码)的构造函数插入一个对在单独的dex的类的引用,就可以解决这个问题。空间使用了javaassist进行编译时字节码插入。

开源实现有 Nuwa , HotFix , DroidFix 。

比较

Dexposed不支持Art模式(5.0+),且写补丁有点困难,需要反射写混淆后的代码,粒度太细,要替换的方法多的话,工作量会比较大。

AndFix支持2.3-6.0,但是不清楚是否有一些机型的坑在里面,毕竟jni层不像java曾一样标准,从实现来说,方法类似Dexposed,都是通过jni来替换方法,但是实现上更简洁直接,应用patch不需要重启。但由于从实现上直接跳过了类初始化,设置为初始化完毕,所以像是静态函数、静态成员、构造函数都会出现问题,复杂点的类Class.forname很可能直接就会挂掉。

ClassLoader方案支持2.3-6.0,会对启动速度略微有影响,只能在下一次应用启动时生效,在空间中已经有了较长时间的线上应用,如果可以接受在下次启动才应用补丁,是很好的选择。

总的来说,在兼容性稳定性上, ClassLoader方案很可靠 ,如果需要应用 不重启就能修复 ,而且方法足够简单,可以使用 AndFix ,而 Dexposed由于还不能支持art,所以只能暂时放弃,希望开发者们可以改进使它能支持art模式,毕竟xposed的种种能力还是很吸引人的(比如hook别人app的方法拿到解密后的数据,嘿嘿),还有比如无痕埋点啊线上追踪问题之类的,随时可以下掉。


相关文章推荐:http://blog.csdn.net/lmj623565791/article/details/49883661


0
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:259484次
    • 积分:3825
    • 等级:
    • 排名:第8313名
    • 原创:100篇
    • 转载:112篇
    • 译文:9篇
    • 评论:51条
    文章分类
    最新评论