概述篇

AndFix,全称是Android hot-fix。是阿里开源的一个热补丁框架,允许APP在不重新发布版本的情况下修复线上的bug。支持Android 2.3 到 7.0,并且支持arm 与 X86系统架构的设备。完美支持Dalvik与ART的Runtime,补丁文件是以 .apatch 结尾的文件,并且是立即生效的

项目地址https://github.com/alibaba/AndFix

官方ReadMe

大致修复图

How to Use(官方)

Initialize PatchManager,

`patchManager = new PatchManager(context);
 patchManager.init(appversion);//current version`

Load patch,

`patchManager.loadPatch();`

You should load patch as early as possible, generally, in the initialization phase of your application(such as Application.onCreate()).

Add patch,

`patchManager.addPatch(path);//path of the patch file that was downloaded`

When a new patch file has been downloaded, it will become effective immediately by addPatch

还有一点就是混淆需要注意

`-keep class * extends java.lang.annotation.Annotation
 -keepclasseswithmembernames class * {
  native <methods>;
  }
 -keep class com.alipay.euler.andfix.** { *; }
 `

如何制作一个apatch呢,阿里在这个开源项目中提供了一个工具https://github.com/alibaba/AndFix/blob/master/tools/apkpatch-1.0.3.zip
,这里先大致介绍一下原理:通过diff增量比对两个apk改变的地方,在其上通过加上注解标记,生成一个apatch

例如旧的apk为1.apk,新的apk为2.apk, -o表示补丁的输出目录,-k表示keystore, -p表示keystore的密码,-a表示alias, -e表示entry password。

命令输入有点麻烦,可以自己写一个win的脚本

apkpatch -f 2.apk -t 1.apk -o . -k finance.keystore -p finance.tech.netease.com -a android.finance.163.com -e finance.tech.netease.com

这样基本可以照猫画虎折腾热更新了,当然不要忘记添加读写权限

`<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
 <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>`

原理篇

  • andfix 深入

  • 补丁深入

andfix 原理

andfix的核心原理就是方法替换 在通过其apath工具给需要替换的方法加上注解@repleaceMethod,这样在执行时把有bug的方法替换成补丁文件中执行的方法。(在Native 层使用指针替换的方式替换bug的方法,从而达到修复bug的目的),具体过程如下图:

  • 加载补丁

使用虚拟机的JarFile加载的补丁文件,读取PATCH.MF文件得到补丁类名称

  • 获取补丁方法

使用DexFile读取patch文件的dex文件,获取后根据注解获取补丁方法

  • 获取bug所在的方法

    根据注解中获取到的类名和方法,使用ClassLaoder获取到class,然后根据反射得到bug Method,并将其访问属性修改为public
    —————————————–java 层————————————————————-

  • Native 层替换方法

使用JNI来替换bug所在方法对象的属性来修复bug

简要类之间关系图

修复的具体过程为:

1)我们及时修复好bug之后,我们可以apkpatch工具将两个apk做一次对比,然后找出不同的部分。生成的apatch了文件。若果这个时候,我们把后缀改成zip再解压开,里面有一个dex文件。反编译之后查看一下源码,里面就是被修复的代码所在的类文件,这些更改过的类都加上了一个_CF的后缀,并且变动的方法都被加上了一个叫@MethodReplace的annotation,通过clazz和method指定了需要替换的方法。(后面补丁原理会说到)

2)客户端得到补丁文件后就会根据annotation来寻找需要替换的方法。从AndFixManager.fix方法开始,客户端找到对应的需要替换的方法,然后在fix方法的具体实现中调用fixClass方法进行方法替换过程。

3)由JNI层完成方法的替换。fixClass方法遍历补丁class里的方法,在jni层对所需要替换的方法进行一一替换。(AndfixManager#replaceMethod)

源码解析

遵循使用时四步走:

Step1:初始化PatchManger

`PatchManager patchManager = new PatchManager();`

参阅 patchManager类源码——>AndfixManager 其中包含了Compat兼容性检测类、SecurityChecker安全性检查类

`public AndFixManager(Context context) {
    mContext = context;
    //判断机型是否支持Andfix 阿里的YunOs不支持
    mSupport = Compat.isSupport();
    if (mSupport) {
        //初始化签名判断类
        mSecurityChecker = new SecurityChecker(mContext);

        mOptDir = new File(mContext.getFilesDir(), DIR);
        // make directory fail
        if (!mOptDir.exists() && !mOptDir.mkdirs()) {
            mSupport = false;
            Log.e(TAG, "opt dir create error.");
        } else if (!mOptDir.isDirectory()) {// not directory
            //如果不是目录则删除
            mOptDir.delete();
            mSupport = false;
        }
    }
}`

Step2:使用PatchManger检查版本

`patchManager.init(apk版本)`

参阅patchManager#init ——>Patch 构造函数初始化 init
主要是版本比对,记录版本号;根据版本号将patch清除或者加载到缓存中

参阅Patch#init

`   public void init(String appVersion) {
    if (!mPatchDir.exists() && !mPatchDir.mkdirs()) {// make directory fail
        Log.e(TAG, "patch dir create error.");
        return;
    } else if (!mPatchDir.isDirectory()) {// not directory
        mPatchDir.delete();
        return;
    }
    SharedPreferences sp = mContext.getSharedPreferences(SP_NAME,
            Context.MODE_PRIVATE);//缓存版本号
    String ver = sp.getString(SP_VERSION, null);
    if (ver == null || !ver.equalsIgnoreCase(appVersion)) {
        //根据传入版本号作对比,若不同,则删除本地的补丁文件
        cleanPatch();
        sp.edit().putString(SP_VERSION, appVersion).commit();//传入新的版本号
    } else {
        initPatchs();//初始化patch列表,把本地的patch加载到内存中
    }
}

private void initPatchs() {
    File[] files = mPatchDir.listFiles();
    for (File file : files) {
        addPatch(file);
    }
}`

Patch文件的加载 使用JarFile读取Patch文件,读取一些属性如patchname,createtime,其中如果本地保存了多个补丁,那么AndFix会按照补丁生成的时间顺序加载补丁。具体是根据.apatch文件中的PATCH.MF的字段Created-Time。

step3:loadPatch

`patchManager.loadPatch();`

参阅patchManager#loadPatch

提供了3个重载方法

`public void loadPatch()//andfix 初始化之后调用
 private void loadPatch(Patch patch)//下载补丁完成后调用,addPatch(path)
 public void loadPatch(String patchName, ClassLoader classLoader)//提供了自定义类加载器的实现
 `

这三个核心都是调用了public synchronized void fix(File file, ClassLoader classLoader, List classes)

参看AndfixManager#fix

`public synchronized void fix(File file, ClassLoader classLoader,
        List<String> classes) {
    if (!mSupport) {
        return;
    }
    //判断补丁的签名
    if (!mSecurityChecker.verifyApk(file)) {// security check fail
        return;
    }

    try {
        File optfile = new File(mOptDir, file.getName());
        boolean saveFingerprint = true;
        if (optfile.exists()) {
            // need to verify fingerprint when the optimize file exist,
            // prevent someone attack on jailbreak device with
            // Vulnerability-Parasyte.
            // btw:exaggerated android Vulnerability-Parasyte
            // http://secauo.com/Exaggerated-Android-Vulnerability-Parasyte.html
            //如果本地已经存在补丁文件,则校验指纹信息
            if (mSecurityChecker.verifyOpt(optfile)) {
                saveFingerprint = false;
            } else if (!optfile.delete()) {
                return;
            }
        }
        //加载patch文件中的dex
        final DexFile dexFile = DexFile.loadDex(file.getAbsolutePath(),
                optfile.getAbsolutePath(), Context.MODE_PRIVATE);

        if (saveFingerprint) {
            mSecurityChecker.saveOptSig(optfile);
        }

        ClassLoader patchClassLoader = new ClassLoader(classLoader) {
            //重写了ClassLoader的findClass方法
            @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注解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
            }
            clazz = dexFile.loadClass(entry, patchClassLoader);//获取有bug的类文件
            if (clazz != null) {
                fixClass(clazz, classLoader);//核心-
            }
        }
    } catch (IOException e) {
        Log.e(TAG, "pacth", e);
    }
}`

fix——>fixclass——>replaceMethod——>Andfix#replaceMethod(Method dest,Method src) Native方法

`private void fixClass(Class<?> clazz, ClassLoader classLoader) {
    //反射找到clazz中的所有方法
    Method[] methods = clazz.getDeclaredMethods();
    //MethodReplace的注解
    MethodReplace methodReplace;
    String clz;
    String meth;
    for (Method method : methods) {
        //遍历所有方法,找到有MethodReplace注解的方法,即需要替换的方法
        methodReplace = method.getAnnotation(MethodReplace.class);//获取此方法的注解,有bug的方法生成patch的类中的方法都是有注解的
        if (methodReplace == null)
            continue;
        clz = methodReplace.clazz(); //获取注解中的clazz的值
        meth = methodReplace.method(); //获取注解中method的值
        if (!isEmpty(clz) && !isEmpty(meth)) {
            //找到需要替换的方法后调用replaceMethod替换方法
            replaceMethod(classLoader, clz, meth, method);
        }
    }
}

`

`private void replaceMethod(ClassLoader classLoader, String clz,
        String meth, Method method) {
    try {
        String key = clz + "@" + classLoader.toString();
        //根据key查找缓存中的数据,该缓存记录了已经被修复过得class
        Class<?> clazz = mFixedClass.get(key);
        if (clazz == null) {// class not load
            //找不到说明该class没有被修复过,则通过类加载器去加载
            Class<?> clzz = classLoader.loadClass(clz);
            // initialize target class
            //通过C层改写accessFlag,把需要替换的类的所有方法(Field)改成了public
            clazz = AndFix.initTargetClass(clzz);//初始化target class
        }
        if (clazz != null) {// initialize class OK
            mFixedClass.put(key, clazz);
            Method src = clazz.getDeclaredMethod(meth,
                    method.getParameterTypes());  //根据反射拿到有bug的类的方法
            //这里是调用了jni,art和dalvik分别执行不同的替换逻辑,在cpp进行实现
            AndFix.addReplaceMethod(src, method);//替换方法 src是有bug的方法,method是补丁方法
        }
    } catch (Exception e) {
        Log.e(TAG, "replaceMethod", e);
    }
}`

Natvie方法的分析见下面

前三步都是一开始初始化时候要做的,而最后一步第四步则是补丁下载好之后再做的

step4: 添加Patch

`patchManager.addPatch(path)`

参阅PatchManager#addPatch,最终还是执行loadpatch

appPatch——>copy到andfix默认的文件夹下——>执行loadPatch(补丁立即生效)

`   public void addPatch(String path) throws IOException {
    File src = new File(path);
    File dest = new File(mPatchDir, src.getName());
    if(!src.exists()){
        throw new FileNotFoundException(path);
    }
    if (dest.exists()) {
        Log.d(TAG, "patch [" + path + "] has be loaded.");
        return;
    }
    //这一步很重要,通过这一步将你所下载保存的patch文件,copy到andfix自己默认的文件夹内存的data/data/apatch
    FileUtil.copyFile(src, dest);// copy to patch's directory
    Patch patch = addPatch(dest);
    if (patch != null) {
        //加载patch 补丁立即生效
        loadPatch(patch);
    }
}`

小结一下:
可以看出andfix的核心就是两大步
- java层 实现加载补丁文件,安全验证等操作,然后根据补丁汇总的注解找到将要替换的方法,交给Native层去处理替换方法
- native层:利用java hook的技术来替换要修复的方法

附 Native 分析

在JNI目录下 art和darvik文件中

andfix.cpp#replaceMethod——>art_method_replace.cpp(根据版本)——art_method_replace_5_0.cpp

  • Dalvik

    Dalvik是Google公司自己设计用于Android平台的Java虚拟机。Dalvik虚拟机是Google等厂商合作开发的Android移动设备平台的核心组成部分之一。它可以支持已转换为 .dex(即Dalvik Executable)格式的Java应用程序的运行,.dex格式是专为Dalvik设计的一种压缩格式,适合内存和处理器速度有限的系统。Dalvik 经过优化,允许在有限的内存中同时运行多个虚拟机的实例,并且每一个Dalvik 应用作为一个独立的Linux 进程执行。独立的进程可以防止在虚拟机崩溃的时候所有程序都被关闭。

  • ART

Android操作系统已经成熟,Google的Android团队开始将注意力转向一些底层组件,其中之一是负责应用程序运行的Dalvik运行时。Google开发者已经花了两年时间开发更快执行效率更高更省电的替代ART运行时。 ART代表Android Runtime,其处理应用程序执行的方式完全不同于Dalvik,Dalvik是依靠一个Just-In-Time (JIT)编译器去解释字节码。开发者编译后的应用代码需要通过一个解释器在用户的设备上运行,这一机制并不高效,但让应用能更容易在不同硬件和架构上运 行。ART则完全改变了这套做法,在应用安装时就预编译字节码到机器语言,这一机制叫Ahead-Of-Time (AOT)编译。在移除解释代码这一过程后,应用程序执行将更有效率,启动更快。

-优缺点

ART优点:

1、系统性能的显著提升。

2、应用启动更快、运行更快、体验更流畅、触感反馈更及时。

3、更长的电池续航能力。

4、支持更低的硬件。

ART缺点:

1、更大的存储空间占用,可能会增加10%-20%。

2、更长的应用安装时间。

总的来说ART的功效就是“空间换时间”。

其他重要函数

PatchManage#removeAllPatch()

这个函数是在PatchManage#init(viersin) verision不同时调用的方法一样,清空补丁目录文件,这在做保护的时候十分重要。

`   public void removeAllPatch() {
    cleanPatch();
    SharedPreferences sp = mContext.getSharedPreferences(SP_NAME,
            Context.MODE_PRIVATE);
    sp.edit().clear().commit();
}`

比如在laodPatch,包括初始化的时候patchManager.loadPatch()和patchManager.addPatch(其实也是调用loadpath)

`public void loadPatch() {
    mLoaders.put("*", mContext.getClassLoader());// wildcard
    Set<String> patchNames;
    List<String> classes;
    for (Patch patch : mPatchs) {
        patchNames = patch.getPatchNames();
        for (String patchName : patchNames) {
            classes = patch.getClasses(patchName);//获取patch对用的class类集合
            mAndFixManager.fix(patch.getFile(), mContext.getClassLoader(),
                    classes);//核心-修复bug方法
        }
    }
}`

因此需要在以下两处做好保护

`public void starAndfix() {
    try {
        mPatchManager = new PatchManager(context);
        mPatchManager.init(BuildConfig.VERSION_NAME);//更换版本号,补丁会被清除
        AppLog.d(TAG, "inited.");
        mPatchManager.loadPatch();
                  ……
        requestHotFixServer(lastSign);

    } catch (Throwable throwable) {
        mPatchManager.removeAllPatch();
        AppLog.d(TAG, "outer catch error remove apatch");
    }
}`



` try{
                mPatchManager.addPatch(context.getFilesDir() + "/" + DIR + APATCH_PATH);
            }catch (Throwable throwable){
                mPatchManager.removeAllPatch();
                AppLog.d(TAG, "inner catch error remove apatch");
            }`

补丁原理

apkPatch工具解析

apkpatch是一个jar包,并没有开源出来,我们可以使用JD-GUI来查看其源码。首先找到Main.class 位于com.euler.patch包下,找到main方法 Main#97

`public static void main(final String[] args) {
    .....
    //根据上面命令输入拿到参数        
   ApkPatch apkPatch = new ApkPatch(from, to, name, out, keystore, password, alias, entry);
   apkPatch.doPatch();
}`

——>ApkPatch#doPatch

` public void doPatch() {
try {
  //生成smail文件夹
  File smaliDir = new File(this.out, "smali");
  if (!smaliDir.exists())
    smaliDir.mkdir();
  try
  {
    FileUtils.cleanDirectory(smaliDir);
  } catch (IOException e) {
    throw new RuntimeException(e);
  }
  //新建diff.dex文件
  File dexFile = new File(this.out, "diff.dex");
  if ((dexFile.exists()) && (!dexFile.delete())) {
    throw new RuntimeException("diff.dex can't be removed.");
  }
  //新建diff.apatch文件
  File outFile = new File(this.out, "diff.apatch");
  if ((outFile.exists()) && (!outFile.delete())) {
    throw new RuntimeException("diff.apatch can't be removed.");
  }
  //第一步:拿到两个apk文件对比,对比信息写入DiffInfo
  DiffInfo info = new DexDiffer().diff(this.from, this.to);
  //第二步:将对比结果info写入.smail文件中,然后打包成dex文件
  this.classes = buildCode(smaliDir, dexFile, info);
  //第三步:将生成的dex文件写入jar包,并根据输入的签名信息进行签名生成diff.apatch文件
  build(outFile, dexFile);
  //第四步:将diff.apatch文件重命名
  release(this.out, dexFile, outFile);
} catch (Exception e) {
  e.printStackTrace();
}
}`

代码翻译一下:

  • 对比apk文件,得到所需信息
  • 将结果打包为apatch文件

主要的就是对比文件信息的DexDiffer().diff(this.from, this.to)方法

——>diff#DexDiffer#diff

`public DiffInfo diff(File newFile, File oldFile)
throws IOException
{
//提取新apk的dex文件
DexBackedDexFile newDexFile = DexFileFactory.loadDexFile(newFile, 19, 
  true);
//提取旧apk的dex文件
DexBackedDexFile oldDexFile = DexFileFactory.loadDexFile(oldFile, 19, 
  true);

DiffInfo info = DiffInfo.getInstance();

boolean contains = false;
for (DexBackedClassDef newClazz : newDexFile.getClasses()) {
  Set oldclasses = oldDexFile
    .getClasses();
  for (DexBackedClassDef oldClazz : oldclasses) {
    //对比相同的方法,存储为修改的方法
    if (newClazz.equals(oldClazz)) {
      //对比class文件的变量
      compareField(newClazz, oldClazz, info);
      //对比class的方法,如果同一个类中没有相同的方法,则判断为新增方法(后面方法)
      compareMethod(newClazz, oldClazz, info);
      contains = true;
      break;
    }
  }
  if (!contains)
  {
    info.addAddedClasses(newClazz);
  }
}
return info;
}`

从这段代码可以看出dex diff得到两个apk文件的差别信息,变量和方法

变量

`public void addAddedFields(DexBackedField field) {
this.addedFields.add(field);
throw new RuntimeException("can,t add new Field:" + 
  field.getName() + "(" + field.getType() + "), " + "in class :" + 
  field.getDefiningClass());
 }

 public void addModifiedFields(DexBackedField field) {
 this.modifiedFields.add(field);
 throw new RuntimeException("can,t modified Field:" + 
  field.getName() + "(" + field.getType() + "), " + "in class :" + 
  field.getDefiningClass());
}
`

可以看出不支持增加成员变量,也不支持修改成员变量。

方法

`public void addAddedMethods(DexBackedMethod method) {
System.out.println("add new Method:" + method.getReturnType() + 
  "  " + method.getName() + "(" + 
  Formater.formatStringList(method.getParameterTypes()) + 
  ")  in Class:" + method.getDefiningClass());
this.addedMethods.add(method);

if (!this.modifiedClasses.contains(method.classDef))
  this.modifiedClasses.add(method.classDef);
}

public void addModifiedMethods(DexBackedMethod method) {
System.out.println("add modified Method:" + method.getReturnType() + 
  "  " + method.getName() + "(" + 
  Formater.formatStringList(method.getParameterTypes()) + 
  ")  in Class:" + method.getDefiningClass());
this.modifiedMethods.add(method);

if (!this.modifiedClasses.contains(method.classDef))
  this.modifiedClasses.add(method.classDef);
}
}`

可以看出对比方法过程中对比两个dex文件中同时存在的方法,如果方法实现不同则存储为修改过的方法;如果方法名不同,存储为新增的方法,也就是说AndFix支持增加新的方法

最后还有一点需要注意下:
在diff#DexDiffer#diff中
//提取新apk的dex文件
DexBackedDexFile newDexFile = DexFileFactory.loadDexFile(newFile, 19, true);

——>org#jf#dexlib2#DexFileFactory

`public static DexBackedDexFile loadDexFile(String path, int api, boolean experimental)
throws IOException
{
return loadDexFile(new File(path), "classes.dex", new Opcodes(api, experimental));
}`

可以看出只提取出了classes.dex这个文件,所以并不支持multidex,如果使用了multidex方案,并且修复的类不在同一个dex文件中,那么补丁就不会生效。

生成补丁解析

当时在研究热更新时出现了使用release包加壳后的补丁不能使,为了更好地研究生成的补丁的使用,需要进一步研究一下生成的补丁具体是什么。

工具: jadx

使用参考:https://liuzhichao.com/2016/jadx-decompiler.html

将加壳前和加壳后生成的补丁,后缀改为zip,得到noshell.out.zip和shell.out.zip,解压后二者都是由两部分组成

通过jadx查看 未加壳生成的补丁dex文件

可以清楚看到加注解的方法,注解之中写了clazz和method的值,对应着apk包中的类名和方法名称;然后就是前后替换的地方

而当用jadx查看加壳后引起一场的补丁时候,

可以看出,加壳之后两个apk根本无法通过diff正确生成补丁,初步推断应该是加壳引入更大的混淆,是的前后两个apk根本无法通过增量比对判断变化,这种error补丁后补丁加入之后会引起 java.lang.VerifyError

因此做好异常保护十分重要

优缺点:

优点

1)可以多次打补丁。如果本地保存了多个补丁,那么AndFix会按照补丁生成的时间顺序加载补丁。具体是根据.apatch文件中的PATCH.MF的字段Created-Time。

2)安全性

readme提示开发者需要验证下载过来的apatch文件的签名是否就是在使用apkpatch工具时使用的签名,如果不验证那么任何人都可以制作自己的apatch文件来对你的APP进行修改。 但是我看到AndFix已经做了验证,如果补丁文件的证书和当前apk的证书不是同一个的话,就不能加载补丁。 官网还有一条,提示需要验证optimize file的指纹,应该是为了防止有人替换掉本地保存的补丁文件,所以要验证MD5码,然而SecurityChecker类里面也已经做了这个工作。。但是这个MD5码是保存在sharedpreference里面,如果手机已经root那么还是可以被访问的。

3)不需要重启APP即可应用补丁。

缺点

1)不支持YunOS

2)无法添加新类和新的字段

3)需要使用加固前的apk制作补丁,但是补丁文件很容易被反编译,也就是修改过的类源码容易泄露

4)使用加固平台可能会使热补丁功能失效

5)无法添加类和字段

6)如果使用了multidex方案,并且修复的类不在同一个dex文件中,那么补丁就不会生效。

再次总结

andfix热补丁的原理就是,通过加载差分补丁,把需要替换的方法注入到native层,然后通过替换新老方法的函数指针,从而达到bug修复的目的,但是由于Andfix是动态的跳过了类的初始化,所以对于静态方法,静态成员变量,构造方法,是不能处理的,而且也不支持新增成员变量和修改成员变量。

其他一些坑

  • 自己下载文件的位置不要跟andfix默认的位置一致,否则源码执行addpatch先会在默认位置检查,如果存在直接return而不会去执行loadpatch
  • 含有loadpatch的地方要做好保护
  • 需要提供未加壳apk生成的补丁文件,而不是加壳后的补丁
Logo

开源、云原生的融合云平台

更多推荐