performance-optimization
  • Introduction
  • JVM基础分析
  • AndroidGC研究一
  • AndroidGC研究二
  • 内存泄露从入门到精通三部曲之基础知识篇
  • 内存泄露从入门到精通三部曲之排查方法篇
  • 内存泄露从入门到精通三部曲之常见原因与用户实践
  • Android渲染机制以及优化(官方版)
  • LeakCanary原理分析
  • Android性能优化之运算与工具
  • 渲染绘制性能优化实践
  • Android性能优化之电量
  • Wakelock、AlarmManager、JobScheduler实例分析
  • 移动端 推送 心跳包
  • 多线程优化
  • 数据传输效率优化
  • 应用启动优化
  • Android进程保活
  • HotFix
  • 安装包性能优化
Powered by GitBook
On this page
  • Dex分包机制
  • 类加载机制
  • 如何打包补丁包
  • 创建Demo
  • 创建补丁包
  • 加载补丁
  • CLASS_ISPREVERIFIED
  • 解决方案
  • 插入代码的难点
  • 使用Javassist注入字节码
  • 自动化生成补丁——解决混淆问题

Was this helpful?

HotFix

PreviousAndroid进程保活Next安装包性能优化

Last updated 6 years ago

Was this helpful?

Dex分包机制

大家都知道,我们开发的代码在被编译成class文件后会被打包成一个dex文件。但是dex文件有一个限制,由于方法id是一个short类型,所以导致了一个dex文件最多只能存放65536个方法。随着现今App的开发日益复杂,导致方法数早已超过了这个上限。为了解决这个问题,Google提出了multidex方案,即一个apk文件可以包含多个dex文件。 不过值得注意的是,除了第一个dex文件以外,其他的dex文件都是以资源的形式被加载的,换句话说就是在Application.onCreate()方法中被注入到系统的ClassLoader中的。这也就为热修复提供了一种可能:将修复后的代码达成补丁包,然后发送到客户端,客户端在启动的时候到指定路径下加载对应dex文件即可。 根据Android虚拟机的类加载机制,同一个类只会被加载一次,所以要让修复后的类替换原有的类就必须让补丁包的类被优先加载。接下来看下Android虚拟机的类加载机制。

类加载机制

Android的类加载机制和jvm加载机制类似,都是通过ClassLoader来完成,只是具体的类不同而已

Android系统通过PathClassLoader来加载系统类和主dex中的类。而DexClassLoader则用于加载其他dex文件中的类。上述两个类都是继承自BaseDexClassLoader,具体的加载方法是findClass:

public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;

    /**
     * Constructs an instance.
     *
     * @param dexPath the list of jar/apk files containing classes and
     * resources, delimited by {@code File.pathSeparator}, which
     * defaults to {@code ":"} on Android
     * @param optimizedDirectory directory where optimized dex files
     * should be written; may be {@code null}
     * @param libraryPath the list of directories containing native
     * libraries, delimited by {@code File.pathSeparator}; may be
     * {@code null}
     * @param parent the parent class loader
     */
    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        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;
    }
}

从代码中可以看到加载类的工作转移到了pathList中,pathList是一个DexPathList类型,从变量名和类型名就可以看出这是一个维护Dex的容器:

/*package*/ final class DexPathList {
    private static final String DEX_SUFFIX = ".dex";
    private static final String JAR_SUFFIX = ".jar";
    private static final String ZIP_SUFFIX = ".zip";
    private static final String APK_SUFFIX = ".apk";

    /** class definition context */
    private final ClassLoader definingContext;

    /**
     * List of dex/resource (class path) elements.
     * Should be called pathElements, but the Facebook app uses reflection
     * to modify 'dexElements' (http://b/7726934).
     */
    private final Element[] dexElements;

    /**
     * Finds the named class in one of the dex files pointed at by
     * this instance. This will find the one in the earliest listed
     * path element. If the class is found but has not yet been
     * defined, then this method will define it in the defining
     * context that this instance was constructed with.
     *
     * @param name of class to find
     * @param suppressed exceptions encountered whilst finding the class
     * @return the named class or {@code null} if the class is not
     * found in any of the dex files
     */
    public Class findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
            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;
    }
}

DexPathList的findClass也很简单,dexElements是维护dex文件的数组,每一个item对应一个dex文件。DexPathList遍历dexElements,从每一个dex文件中查找目标类,在找到后即返回并停止遍历。所以要想达到热修复的目的就必须让补丁dex在dexElements中的位置先于原有dex:

如何打包补丁包

创建Demo

package com.biyan.demo
public class MainActivity extends Activity {

    private Calculator mCal;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mCal = new Calculator();
    }
    public void click(View view) {
        Toast.makeText(this, String.valueOf(mCal.calculate()),Toast.LENGTH_SHORT).show();
    }
}

Public class Caculoator {
    public float calculate() {
        return 1 / 0;
    }
}

创建补丁包

首先修复Calculator的bug。

package com.biyan.demo
Public class Caculoator {
    public float calculate() {
        return 1 / 1;
    }
}

重新编译项目,在build目录下找到Calculator.class文件,将其拷出来,准备打包。放置在于Calculator包名相同的路径下。

将其打成jar包:

jar -cvf patch.jar com

然后再将对应的jar包打成dex包:

dx --dex --output=patch_dex.jar patch.jar

dx是讲jar包打成dex包的工具,安装在path-android-sdk/build-tools/version(如24.0.0)/dx。 patch_dex.jar就是补丁包,接下来将其安装在sdCard中,接下来应用从sdCard上加载该补丁包。

加载补丁

加载补丁的思路如下:

  1. 在Application的onCreate()方法中获取应用本身的BaseDexClassLoader,然后通过反射得到对应的dexElements

  2. 创建一个新的DexClassLoader实例,然后加载sdCard上的补丁包,然后通过同样的方法得到对应的dexElements

  3. 将两个dexElements合并,然后再利用反射将合并后的dexElements赋值给应用本身的BaseDexClassLoader

import android.app.Application;
import android.os.Environment;
import android.util.Log;
import java.io.File;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import dalvik.system.DexClassLoader;

/**
 * Created by hp on 2016/4/6.
 */
public class HotPatchApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();

        // 获取补丁,如果存在就执行注入操作
        String dexPath = Environment.getExternalStorageDirectory().getAbsolutePath().concat("/patch_dex.jar");
        File file = new File(dexPath);
        if (file.exists()) {
            inject(dexPath);
        } else {
            Log.e("BugFixApplication", dexPath + "不存在");
        }
    }

    /**
     * 要注入的dex的路径
     *
     * @param path
     */
    private void inject(String path) {
        try {
            // 获取classes的dexElements
            Class<?> cl = Class.forName("dalvik.system.BaseDexClassLoader");
            Object pathList = getField(cl, "pathList", getClassLoader());
            Object baseElements = getField(pathList.getClass(), "dexElements", pathList);

            // 获取patch_dex的dexElements(需要先加载dex)
            String dexopt = getDir("dexopt", 0).getAbsolutePath();
            DexClassLoader dexClassLoader = new DexClassLoader(path, dexopt, dexopt, getClassLoader());
            Object obj = getField(cl, "pathList", dexClassLoader);
            Object dexElements = getField(obj.getClass(), "dexElements", obj);

            // 合并两个Elements
            Object combineElements = combineArray(dexElements, baseElements);

            // 将合并后的Element数组重新赋值给app的classLoader
            setField(pathList.getClass(), "dexElements", pathList, combineElements);

            //======== 以下是测试是否成功注入 =================
            Object object = getField(pathList.getClass(), "dexElements", pathList);
            int length = Array.getLength(object);
            Log.e("BugFixApplication", "length = " + length);

        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        }
    }

    /**
     * 通过反射获取对象的属性值
     */
    private Object getField(Class<?> cl, String fieldName, Object object) throws NoSuchFieldException, IllegalAccessException {
        Field field = cl.getDeclaredField(fieldName);
        field.setAccessible(true);
        return field.get(object);
    }

    /**
     * 通过反射设置对象的属性值
     */
    private void setField(Class<?> cl, String fieldName, Object object, Object value) throws NoSuchFieldException, IllegalAccessException {
        Field field = cl.getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(object, value);
    }

    /**
     * 通过反射合并两个数组
     */
    private Object combineArray(Object firstArr, Object secondArr) {
        int firstLength = Array.getLength(firstArr);
        int secondLength = Array.getLength(secondArr);
        int length = firstLength + secondLength;

        Class<?> componentType = firstArr.getClass().getComponentType();
        Object newArr = Array.newInstance(componentType, length);
        for (int i = 0; i < length; i++) {
            if (i < firstLength) {
                Array.set(newArr, i, Array.get(firstArr, i));
            } else {
                Array.set(newArr, i, Array.get(secondArr, i - firstLength));
            }
        }
        return newArr;
    }

}

原因是类预校验问题引起的:

  • 在apk安装的时候系统会将dex文件优化成odex文件,在优化的过程中会涉及一个预校验的过程

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

  • 如果在运行时被打上CLASS_ISPREVERIFIED的类引用了其他dex的类,就会报错

  • 所以MainActivity的onCreate()方法中引用另一个dex的类就会出现上文中的问题

  • 正常的分包方案会保证相关类被打入同一个dex文件

  • 想要使得patch可以被正常加载,就必须保证类不会被打上CLASS_ISPREVERIFIED标记。而要实现这个目的就必须要在分完包后的class中植入对其他dex文件中类的引用

  • 要在已经编译完成后的类中植入对其他类的引用,就需要操作字节码,惯用的方案是插桩。常见的工具有javaassist,asm等。

CLASS_ISPREVERIFIED

我在这里总结了一个过程,想知道详细分析过程的请看QQ空间开发团队的原文。

  1. 在apk安装的时候,虚拟机会将dex优化成odex后才拿去执行。在这个过程中会对所有class一个校验。

  2. 校验方式:假设A该类在它的static方法,private方法,构造函数,override方法中直接引用到B类。如果A类和B类在同一个dex中,那么A类就会被打上CLASS_ISPREVERIFIED标记

  3. 被打上这个标记的类不能引用其他dex中的类,否则就会报图中的错误

  4. 在我们的Demo中,MainActivity和Cat本身是在同一个dex中的,所以MainActivity被打上了CLASS_ISPREVERIFIED。而我们修复bug的时候却引用了另外一个dex的Cat.class,所以这里就报错了

  5. 而普通分包方案则不会出现这个错误,因为引用和被引用的两个类一开始就不在同一个dex中,所以校验的时候并不会被打上CLASS_ISPREVERIFIED

  6. 补充一下第二条:A类如果还引用了一个C类,而C类在其他dex中,那么A类并不会被打上标记。换句话说,只要在static方法,构造方法,private方法,override方法中直接引用了其他dex中的类,那么这个类就不会被打上CLASS_ISPREVERIFIED标记。

解决方案

根据上面的第六条,我们只要让所有类都引用其他dex中的某个类就可以了。

下面是QQ控件给出的解决方案

  1. 在所有类的构造函数中插入这行代码 System.out.println(AntilazyLoad.class); 这样当安装apk的时候,classes.dex内的类都会引用一个在不相同dex中的AntilazyLoad类,这样就防止了类被打上CLASS_ISPREVERIFIED的标志了,只要没被打上这个标志的类都可以进行打补丁操作。

  2. hack.dex在应用启动的时候就要先加载出来,不然AntilazyLoad类会被标记为不存在,即使后面再加载hack.dex,AntilazyLoad类还是会提示不存在。该类只要一次找不到,那么就会永远被标上找不到的标记了。

  3. 我们一般在Application中执行dex的注入操作,所以在Application的构造中不能加上System.out.println(AntilazyLoad.class);这行代码,因为此时hack.dex还没有加载进来,AntilazyLoad并不存在。

  4. 之所以选择构造函数是因为他不增加方法数,一个类即使没有显式的构造函数,也会有一个隐式的默认构造函数。

插入代码的难点

  1. 首先在源码中手动插入不太可行,hack.dex此时并没有加载进来,AntilazyLoad.class并不存在,编译不通过。

  2. 所以我们需要在源码编译成字节码之后,在字节码中进行插入操作。对字节码进行操作的框架有很多,但是比较常用的则是ASM和javaassist

  3. 但AndroidStudio是使用Gradle构建项目,编译-打包都是自动化的

使用Javassist注入字节码

这篇博文解决了class_ispreverified问题,并且成功使用javassist注入字节码,完成了热补丁框架的雏形。 但是还有几个需要解决的问题

  1. 补丁没有签名校验,不安全,容易被恶意注入代码

  2. 混淆开启的情况下,类名可能被更换,补丁打包不成功。

自动化生成补丁——解决混淆问题

核心代码就这么多,接下来运行一下程序。程序还是Crash了。。。

20160317145346556

参考:

http://blog.csdn.net/u010386612/article/details/51077291
http://blog.csdn.net/u010386612/article/details/51131642
http://blog.csdn.net/u010386612/article/details/51192421
ea0643c460ef9cbbb1bab3fb1a8a3a20ecc09eff