热修复一直是这几年来很热门的话题,主流方案大致有两种,一种是微信Tinker的dex文件替换,另一种是阿里的Native层的方法替换。这里重点介绍Tinker的大致原理。
介绍Tinker原理之前,我们先来回顾一下类加载机制。
我们编译好的class文件,需要先加载到虚拟机然后才会执行,这个过程是通过ClassLoader来完成的。
双亲委派模型:
作用:
1.避免类的重复加载。
比如有两个类加载器,他们都要加载同一个类,这时候如果不是委托而是自己加载自己的,则会将类重复加载到方法区。
2.避免核心类被修改。
比如我们在自定义一个 java.lang.String 类,执行的时候会报错,因为 String 是 java.lang 包下的类,应该由启动类加载器加载。
JVM并不会一开始就加载所有的类,它是当你使用到的时候才会去通知类加载器去加载。
当我们new一个类时,首先是Android的虚拟机(Dalvik/ART虚拟机)通过ClassLoader去加载dex文件到内存。
Android中的ClassLoader主要是PathClassLoader和DexClassLoader,这两者都继承自BaseDexClassLoader。它们都可以理解成应用类加载器。
PathClassLoader和DexClassLoader的区别:
当ClassLoader加载类时,会调用它的findclass方法去查找该类。
下方是BaseDexClassLoader的findClass方法实现:
public class BaseDexClassLoader extends ClassLoader { ... @UnsupportedAppUsage private final DexPathList pathList; ... @Override protected Class<?> findClass(String name) throws ClassNotFoundException { // 首先检查该类是否存在shared libraries中. if (sharedLibraryLoaders != null) { for (ClassLoader loader : sharedLibraryLoaders) { try { return loader.loadClass(name); } catch (ClassNotFoundException ignored) { } } } //再调用pathList.findClass去查找该类,结果为null则抛出错误。 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; } }
接下来我们再来看看DexPathList的findClass实现:
public DexPathList(ClassLoader definingContext, String librarySearchPath) { ... /** * List of dex/resource (class path) elements. * 存放dex文件的一个数组 */ @UnsupportedAppUsage private Element[] dexElements; ... public Class<?> findClass(String name, List<Throwable> suppressed) { //遍历Element数组,去查寻对应的类,找到后就立刻返回了 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; } ... }
Ok,这样就替换成功了,重启App,再调用原来的bug类,将会优先使用补丁包中的修复类。
为什么要重启:因为双亲委派模型,一个类只会被ClassLoader加载一次,且加载过后的类不能卸载。
接下来我们动手撸一个乞丐版的Tinker。
首先我们写一个bug类。
package com.baima.plugin; class BugClass { public String getTitle(){ return "这是个Bug"; } }
接着我们新建一个module来生成补丁包apk。
创建bug修复类,注意包名类名要一样。
package com.baima.plugin; class BugClass { public String getTitle(){ return "修复成功"; } }
生成补丁apk,让用户下载这个补丁包。接下来就是加载这个apk文件并替换了。
public void loadDexAndInject(Context appContext, String dexPath, String dexOptPath) { try { // 加载应用程序dex的Loader PathClassLoader pathLoader = (PathClassLoader) appContext.getClassLoader(); //dexPath 补丁dex文件所在的路径 //dexOptPath 补丁dex文件被写入后存放的路径 DexClassLoader dexClassLoader = new DexClassLoader(dexPath, dexOptPath, null, pathLoader); //利用反射获取DexClassLoader和PathClassLoader的pathList属性 Object dexPathList = getPathList(dexClassLoader); Object pathPathList = getPathList(pathLoader); //同样用反射获取DexClassLoader和PathClassLoader的dexElements属性 Object leftDexElements = getDexElements(dexPathList); Object rightDexElements = getDexElements(pathPathList); //合并两个数组,且补丁包的dex文件在数组的前面 Object dexElements = combineArray(leftDexElements, rightDexElements); //反射将合并后的数组赋值给PathClassLoader的pathList.dexElements Object pathList = getPathList(pathLoader); Class<?> pathClazz = pathList.getClass(); Field declaredField = pathClazz.getDeclaredField("dexElements"); declaredField.set看,ccessible(true); declaredField.set(pathList, dexElements); } catch (Exception e) { e.printStackTrace(); } } private static Object getPathList(Object classLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException { Class<?> cl = Class.forName("dalvik.system.BaseDexClassLoader"); Field field = cl.getDeclaredField("pathList"); field.setAccessible(true); return field.get(classLoader); } private static Object getDexElements(Object pathList) throws NoSuchFieldException, IllegalAccessException { Class<?> cl = pathList.getClass(); Field field = cl.getDeclaredField("dexElements"); field.setAccessible(true); return field.get(pathList); } private static Object combineArray(Object arrayLeft, Object arrayRight) { Class<?> clazz = arrayLeft.getClass().getComponentType(); int i = Array.getLength(arrayLeft); int j = Array.getLength(arrayRight); int k = i + j; Object result = Array.newInstance(clazz, k);// 创建一个类型为clazz,长度为k的新数组 System.arraycopy(arrayLeft, 0, result, 0, i); System.arraycopy(arrayRight, 0, result, i, j); return result; }
ok,乞丐版Tinker完成了,使用时先在Splash界面检查是否有插件补丁,有的话执行替换,这时你再使用bug类会发现它已经被替换成补丁中的修复类了。
插件化开发模式,打包时是一个宿主apk+多个插件apk。
组件化开发模式,打包时是一个apk,里面分多个module。
优点:
需要掌握的知识:
上图是普通的Activity启动流程,和根Activity启动流程的区别是不用创建应用程序进程(Application Thread)。
启动过程:
他们之间的跨进程通信是通过Binder实现的。
通过上面介绍的热修复,我们有办法去加载插件apk里面的类,但是还没有办法去启动插件中的Activity,因为如果要启动一个Activity,那么这个Activity必须在AndroidManifest.xml中注册。
这里介绍插件化的一种主流实现方式--Hook技术。
步骤1、2这里就不在赘述了,2就是上面讲到的热修复技术。
AMS是在SystemServer进程中,我们无法直接进行修改,只能在应用程序进程中做文章。
介绍一个类--IActivityManager,IActivityManager它通过AIDL(内部使用的是Binder机制)和SystemServer进程的AMS通讯。所以IActivityManager很适合作为一个hook点。
Activity启动时会调用IActivityManager.startActivity方法向AMS发出启动请求,该方法参数包含一个Intent对象,它是原本要启动的Activity的Intent。
我们可以动态代理IActivityManager的startActivity方法,将该Intent换为占坑Activity的Intent,并将原来的Intent作为参数传递过去,以此达到欺骗AMS绕开验证。
public class IActivityManagerProxy implements InvocationHandler { private Object mActivityManager; private static final String TAG = "IActivityManagerProxy"; public IActivityManagerProxy(Object activityManager) { this.mActivityManager = activityManager; } @Override public Object invoke(Object o, Method method, Object[] args) throws Throwable { if ("startActivity".equals(method.getName())) { Intent intent = null; int index = 0; for (int i = 0; i < args.length; i++) { if (args[i] instanceof Intent) { index = i; break; } } intent = (Intent) args[index]; Intent subIntent = new Intent(); String packageName = "com.example.pluginactivity"; subIntent.setClassName(packageName,packageName+".StubActivity"); subIntent.putExtra(HookHelper.TARGET_INTENT, intent); args[index] = subIntent; } return method.invoke(mActivityManager, args); } }
接下来就通过反射的方式,将ActivityManager中的IActivityManager替换成我们的代理对象。
public void hookAMS() { try { Object defaultSingleton = null; if (Build.VERSION.SDK_INT >= 26) { Class<?> activityManagerClazz = Class.forName("android.app.ActivityManager"); defaultSingleton = FieldUtil.getObjectField(activityManagerClazz, null, "IActivityManagerSingleton"); } else { Class<?> activityManagerNativeClazz = Class.forName("android.app.ActivityManagerNative"); defaultSingleton = FieldUtil.getObjectField(activityManagerNativeClazz, null, "gDefault"); } Class<?> singletonClazz = Class.forName("android.util.Singleton"); Field mInstanceField = FieldUtil.getField(singletonClazz, "mInstance"); Object iActivityManager = mInstanceField.get(defaultSingleton); Class<?> iActivityManagerClazz = Class.forName("android.app.IActivityManager"); Object proxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), new Class<?>[]{iActivityManagerClazz}, new IActivityManagerProxy(iActivityManager)); mInstanceField.set(defaultSingleton, proxy); } catch (Exception e) { e.printStackTrace(); } }
Note: 这里获取IActivityManager实例会因为Android版本不同而不同,具体获取方法就需要去看源码了解了。这里的代码Android 8.0是可以运行的。
ActivityThread启动Activity的过程如下所示:
ActivityThread会通过H在主线程中去启动Activity,H类是ActivityThread的内部类并继承自Handler。
private class H extends Handler { public static final int LAUNCH_ACTIVITY = 100; public static final int PAUSE_ACTIVITY = 101; ... public void handleMessage(Message msg) { if (DEBUG_MESSAGES) Slog.v(TAG, ">>> handling: " + codeToString(msg.what)); switch (msg.what) { case LAUNCH_ACTIVITY: { Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityStart"); final ActivityClientRecord r = (ActivityClientRecord) msg.obj; r.packageInfo = getPackageInfoNoCheck( r.activityInfo.applicationInfo, r.compatInfo); handleLaunchActivity(r, null, "LAUNCH_ACTIVITY"); Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER); } break; ... } ... }
H中重写的handleMessage方法会对LAUNCH_ACTIVITY类型的消息进行处理,最终会调用Activity的onCreate方法。那么在哪进行替换呢?接着来看Handler的dispatchMessage方法:
public void dispatchMessage(Message msg) { if (msg.callback != null) { handleCallback(msg); } else { if (mCallback != null) { if (mCallback.handleMessage(msg)) { return; } } handleMessage(msg); } }
Handler的dispatchMessage用于处理消息,可以看到如果Handler的Callback类型的mCallback不为null,就会执行mCallback的handleMessage方法。因此,mCallback可以作为Hook点,我们可以用自定义的Callback来替换mCallback,自定义的Callback如下所示。
public class HCallback implements Handler.Callback{ public static final int LAUNCH_ACTIVITY = 100; Handler mHandler; public HCallback(Handler handler) { mHandler = handler; } @Override public boolean handleMessage(Message msg) { if (msg.what == LAUNCH_ACTIVITY) { Object r = msg.obj; try { //得到消息中的Intent(启动占坑Activity的Intent) Intent intent = (Intent) FieldUtil.getField(r.getClass(), r, "intent"); //得到此前保存起来的Intent(启动插件Activity的Intent) Intent target = intent.getParcelableExtra(HookHelper.TARGET_INTENT); //将占坑Activity的Intent替换为插件Activity的Intent intent.setComponent(target.getComponent()); } catch (Exception e) { e.printStackTrace(); } } mHandler.handleMessage(msg); return true; } }
最后一步就是用反射将我们自定义的callBack设置给ActivityThread.sCurrentActivityThread.mH.mCallback
。
public void hookHandler() { try { Class<?> activityThreadClass = Class.forName("android.app.ActivityThread"); Object currentActivityThread = FieldUtil.getObjectField(activityThreadClass, null, "sCurrentActivityThread"); Field mHField = FieldUtil.getField(activityThreadClass, "mH"); Handler mH = (Handler) mHField.get(currentActivityThread); FieldUtil.setObjectField(Handler.class, mH, "mCallback", new HCallback(mH)); } catch (Exception e) { e.printStackTrace(); } }
其实要想启动一个Activity到这步还没有完,一个完整的Activity应该还需要布局文件,而我们的宿主APP并不会包含插件的资源。
android中的资源大致分为两类:一类是res目录下存在的可编译的资源文件,比如anim,string之类的,第二类是assets目录下存放的原始资源文件。因为Apk编译的时候不会编译这些文件,所以不能通过id来访问,当然也不能通过绝对路径来访问。于是Android系统让我们通过Resources的getAssets方法来获取AssetManager,利用AssetManager来访问这些文件。
其实Resource的getString, getText等各种方法都是通过调用AssetManager的私有方法来完成的。 过程就是Resource通过resource.arsc(AAPT工具打包过程中生成的文件)把ID转换成资源文件的名称,然后交由AssetManager来加载文件。
AssetManager里有个很重要的方法addAssetPath(String path)方法,App启动的时候会把当前apk的路径传进去,然后AssetManager就能访问这个路径下的所有资源也就是宿主apk的资源了。我们可以通过hook这个方法将插件的path传进去,得到的AssetManager就能同时访问宿主和插件的所有资源了。
public void hookAssets(Activity activity,String dexPath){ try { AssetManager assetManager = activity.getResources().getAssets(); Method addAssetPath = assetManager.getClass().getMethod("addAssetPath",String.class); addAssetPath.invoke(assetManager,dexPath); Resources mResources = new Resources(assetManager, activity.getResources().getDisplayMetrics(), activity.getResources().getConfiguration()); //接下来我们要将宿主原有Resources替换成我们上面生成的Resources。 FieldUtil.setObjectField(ContextWrapper.class,activity.getResources(),"mResources",mResources); } catch (NoSuchMethodException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (InvocationTargetException e) { e.printStackTrace(); } catch (Exception e) { e.printStackTrace(); } }
新的问题又出现了,宿主apk和插件apk是两个不同的apk,他们在编译时都会产生自己的resources.arsc。即他们是两个独立的编译过程。那么它们的resources.arsc中的资源id必定是有相同的情况。这样我们上面生成的新Resources中就出现了资源id重复的情况,这样在运行的时候使用资源id来获取资源就会报错。
怎么解决资源Id冲突的问题?这里介绍一下VirtualApk采用的方案。
修改aapt的产物。即编译后期重新整理插件Apk的资源,编排ID,更新R文件
VirtualApkhook了ProcessAndroidResourcestask。这个task是用来编译Android资源的。VirtualApk拿到这个task的输出结果,做了以下处理:
大致原理是这样的,但如何保证新的Id不会重复了,这里在介绍一下资源Id的组成。
packageId: 前两位是packageId,相当于一个命名空间,主要用来区分不同的包空间(不是不同的module)。目前来看,在编译app的时候,至少会遇到两个包空间:android系统资源包和咱们自己的App资源包。大家可以观察R.java文件,可以看到部分是以0x01开头的,部分是以0x7f开头的。以0x01开头的就是系统已经内置的资源id,以0x7f开头的是咱们自己添加的app资源id。
typeId:typeId是指资源的类型id,我们知道android资源有animator、anim、color、drawable、layout,string等等,typeId就是拿来区分不同的资源类型。
entryId:entryId是指每一个资源在其所属的资源类型中所出现的次序。注意,不同类型的资源的Entry ID有可能是相同的,但是由于它们的类型不同,我们仍然可以通过其资源ID来区别开来。
所以为了避免冲突,插件的资源id通常会采用0x02 - 0x7e之间的数值。