关于热修复

Android热修复原理

这段时间比较难闲,就抽空研究一下Android热修复的原理。自从Android热修复这项技术出现之后,随之而现的是多种热修复方案的出现。前两天又看到一篇文章分析了几种热修复方案的比较。

原文地址是:[Android热修复] 技术方案的选型与验证

看完这篇文章,有点汗颜。有这么多的热修复方案,并且他们之间的实现原理也不一样,各有优缺点。

然后在尼古拉斯_赵四的博客中看到几篇关于热修复的文章,对着这几篇文章撸了一番。大概的了解了热修复一种原理,其思路和QQ空间提出的安卓App热补丁动态修复技术介绍,原理上有相同之处,采用的是ClassLoader替换dex的方案。

本文对于这些实践做出一点总结。本文有些代码片段、图片来自上述文章。

首先列出热修复需要解决的几个问题:

资源替换
类替换(四大组件、类)
SO补丁
基于上面3个问题,我做了几个测试,分别是动态加载资源、和动态运行APK中的Activity。至于SO补丁方面的,由于本人技术有限,没有研究。

技术普及

在Android中有两个类加载器,分别为PathClassLoader和DexClassLoader。其中我们正常开发的APP使用的类加载器就是PathClassLoader。

关于这两个类在代码中的实际使用:

PathClassLoader:通过Context getClassLoader() 获取。
DexClassLoader:通过构造函数 new DexClassLoader()获取。
DexClassLoader的构造函数原型是:

  public DexClassLoader(String dexPath, String dexOutputDir, String libPath,  
        ClassLoader parent)
 
dexPath: 表示加载的APK/dex/jar路径
dexOutOutDir: 解压文件的路径,因为APK和JAR最终都要解压出dex文件,这个路径是用来存放dex文件的。
libPath:加载的时候用到的lib库,一般为null
parent:DexClassLoader的父加载器
资源的加载

目标:加载另一个APK中的资源文件

思路:Andorid APP默认的类加载是PathClassLoader,这个只能加载自己APK的dex文件,所以我们需要使用DexClassLoader。我们用DexClassLoader加载外部的APK之后,通过反射获取对应的资源。

项目分为2个工程,一个宿主工程,一个插件工程。

首先我们看插件工程:

public class UIUtil {
    public static String getTextString(Context ctx){
        return ctx.getResources().getString(R.string.text);
    }

    public static Drawable getImageDrawable(Context ctx){
        return ctx.getResources().getDrawable(R.mipmap.ic_launcher);
    }

    public static int getTextBackgroundId(Context ctx){
        return ctx.getResources().getColor(R.color.color_green);
    }
}
 
插件工程中有一个UIUtil类,提供了几个静态的方法分别用来获取对应的资源(文字,图标,颜色)

接下来我们看看宿主工程中如何加载这里的资源。

首先我们需要创建一个DexClassLoader
 DexClassLoader classLoader = new DexClassLoader(filePath, fileRelease, null, getClassLoader());
 
filePath指的是插件APK的文件路径,注意:这里需要放在/data/data/packagename/中才能生效。因为Android系统的限制,自己加载的dex只能在程序独有的文件中存在。

这里代码最后一个参数传进来的是getClassLoader(),实际上就是PathClassLoader,那么为什么需要把这个PathClassLoader作为DexClassLoader的父加载器呢。这里的ClassLoader符合Java类加载器的双亲委派机制,具体关于这两个ClassLoader的介绍,请看这篇文章

通过反射调用APK中的方法。

        Class clazz = null;
        try {
            clazz = classLoader.loadClass("com.example.resourceloaderapk.UIUtil");

            //设置文字
            Method method = clazz.getMethod("getTextString", Context.class);
            String str = (String) method.invoke(null, this);
            textV.setText(str);
            //设置背景
            method = clazz.getMethod("getTextBackgroundId", Context.class);
            int color = (int) method.invoke(null, this);
            Log.i("Loader", "color = " + color);
            textV.setBackgroundColor(color);
            //设置图片
            method = clazz.getMethod("getImageDrawable", Context.class);
            Drawable drawable = (Drawable) method.invoke(null, this);
            Log.i("Loader", "drawable =" + drawable);
            imgV.setImageDrawable(drawable);

        } catch (Exception e) {
            e.printStackTrace();
        }
 
运行流程是这样的,首先我们把插件APK打包之后,拷贝到/data/data/宿主apk packagename/ 中的任意目录。然后上面初始化DexClassLoader的时候把这个路径传过去。从代码可以看到,首先加载UIUtil类,然后调用它的几个方法来获取对应的资源。

这样运行之后发现文字是可以获取的,但是image和color是无法获取到,会抛出Resources$NotFoundException,资源找不到异常。我们知道,APK中所有的资源都是通过Resources来获取的。看回上面的代码,是通过传递一个this关键字把宿主的Context对象传递过去的。这个Context对象是宿主工程的Context,它并不能访问插件APK的资源,那么我们需要做的就是把插件APK的资源加载到宿主Context中对应的Resources对象中。

这里使用的方法是调用AssetManager的addAssetPath()方法,将一个APK中的资源加载到Resources中,这个方法是隐藏的,我们通过反射获取,如下:

  /**
     * 此方法的作用是把resource.apk中的资源加载到AssetManager中,
     * 然后在重组一个Resources对象,这个Resources对象包括了resource.apk中的资源。
     * <p/>
     * resource.apk 中是使用Context.getResources()获得Resource对象的,
     * 所以还要重写一些getResources()方法,返回该Resources对象
     *
     * @param dexPath
     */
    protected void loadResource(String dexPath) {
        try {
            AssetManager assetManager = AssetManager.class.newInstance();
            Method method = assetManager.getClass().getMethod("addAssetPath", String.class);
            method.invoke(assetManager, dexPath);
            mAssetManager = assetManager;
        } catch (Exception e) {
            e.printStackTrace();
        }

        Resources resource = getResources();
        mResources = new Resources(mAssetManager, resource.getDisplayMetrics(), resource.getConfiguration());

        mTheme = mResources.newTheme();
        mTheme.setTo(getTheme());
    }
 
这样处理之后我们就重新生成了一个Resource对象,AssetManger对象和Theme对象。我们需要重写下面3个方法达到替换的目的。

  @Override
    public AssetManager getAssets() {
        return mAssetManager == null ? super.getAssets() : mAssetManager;
    }

    @Override
    public Resources getResources() {
        return mResources == null ? super.getResources() : mResources;
    }

    @Override
    public Resources.Theme getTheme() {
        return mTheme == null ? super.getTheme() : mTheme;
    }
 
这样getResources获取到的就是我们新生成的mResources对象了,这个对象包括了原有的资源和插件APK的资源。

至此,加载插件APK中的资源就实现了功能。

总结

这里仅仅是对动态加载一些R文件的引用成功了,但是还有很多问题没有深入的去解决,比如相同包名的情况下该如何处理?接口如何统一?资源和原有的资源同名怎么处理?这么多问题的存在都需要大量的时间来解决,这里只是给出一个思路,犹如管中窥豹,由此对于资源的加载有一个感性的基础认识。

动态加载Activity

由上面的描述我们知道,一个应用的默认类加载器是PathClassLoader,我们加载插件的时候使用的是DexClassLoader。虽然我们可以用DexClassLoader来获取到Activity的实例,但是我们不能仅仅new一个Intent对象然后启动Activity,因为我们从DexClassLoader中加载的Activity类仅仅是一个普通的JAVA类,Android四大组件都有自己的启动流程和生命周期,使用DexClassLoader不会涉及到任何生命流程的东西。

既然这样,那么就要从Activity的启动流程入手了。我们需要做的不是详细了解Activity的启动流程,思路是将加载了dex的DexClassLoader绑定到系统启动Activity的类加载器上就行了。

第一种方案

了解过一点Andorid源码的小伙伴应该都知道,我们Activity的启动流程涉及到ActivityThread类,我们来看看它的源码: 


在里面有一个静态的sCurrentActivityThread对象,我们暂且不管他是如何创建实例的,因为应用启动的时候就会启动一个Activity,这时候sCurrentActivityThread对象肯定不为空。我们获取到ActivityThread对象之后,我们再看看代码,里面有一个mPackages保存的是以packageName为key,LoadedApk为value的map。

再点开LoadedApk来观察:

 

里面有个mClassLoader对象。好了,好嗨森,我们只需要把自己的DexClassLoader设置到这个mClassLoader对象就能正常启动Activity了

代码如下:

public void replaceLoadedApk(View v) {
        try {
            //通过替换LoadedApk中的mClassLoader来达到加载apk中的Activity
            String fileDir = getCacheDir().getAbsolutePath();
            String filePath = fileDir + File.separator + Constants.RESOURCE_APK_NAME;  //源dex/jar/apk 目录
            DexClassLoader loader = new DexClassLoader(filePath, getDir("dex", MODE_PRIVATE).getAbsolutePath(), null, getClassLoader());

            Object currentActivityThread = RefInvoke.invokeStaticMethod("android.app.ActivityThread", "currentActivityThread", new Class[]{}, new Object[]{});
            String packageName = getPackageName();

            //通过反射获取ActivityThread的 mPackages 对象
            ArrayMap mPackages = (ArrayMap) RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mPackages");
            //通过反射获mPackages获得当前的LoadedApk对象
            WeakReference wr = (WeakReference) mPackages.get(packageName);
            Log.i(TAG, "wr = " + wr.get());
            //替换LoadedApk中的mClassLoader 为我们自己的DexClassLoader
            RefInvoke.setFieldOjbect("android.app.LoadedApk", "mClassLoader", wr.get(), loader);
            Log.i(TAG, "classloader = " + loader);


            startResourceActivity(filePath, loader);


        } catch (Exception e) {
            Log.i(TAG, "load apk error :" + Log.getStackTraceString(e));
        }
    }

    /**
     * 启动插件Activity
     * @param filePath
     * @param loader
     * @throws ClassNotFoundException
     * @throws NoSuchFieldException
     * @throws IllegalAccessException
     * @throws NoSuchMethodException
     * @throws InvocationTargetException
     */
    private void startResourceActivity(String filePath, ClassLoader loader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {
        //加载资源
        loadResources(filePath);


        //加载Activity ,确保这里的类名和Constants.RESOURCE_APK_NAME 中的 类名相同
        Class clazz = loader.loadClass("com.example.resourceloaderapk.MainActivity");
        //找到R.layout.activity_main
        Class rClazz = loader.loadClass("com.example.resourceloaderapk.R$layout");
        Field field = rClazz.getField("activity_main");
        Integer ojb = (Integer)field.get(null);

        View view = LayoutInflater.from(this).inflate(ojb, null);
        //设置静态变量。这里为什么要设置静态变量呢。
        // 因为测试发现setContentView() 没有起作用。
        // 所以在启动Activity之前保存一个静态的View,设置到Activity中

        Method method = clazz.getMethod("setLayoutView", View.class);
        method.invoke(null, view);

        //找到MainActivity,然后启动
        startActivity(new Intent(this, clazz));
    }
 
 
噢,忘了说,在插件工程中创建一个MainActivity,包名为com.example.resourceloaderapk,里面给各个声明周期打一下log

public class MainActivity extends AppCompatActivity {
    public static final String TAG = "Resource_MainActivity";
    private static View parentView;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if(parentView == null){
            setContentView(R.layout.activity_main);
        }else{
            setContentView(parentView);
        }
    }

    public static void setLayoutView(View view){
        parentView = view;
    }

    @Override
    protected void onResume() {
        super.onResume();
        Log.i(TAG, "resource activity onResume");
    }

    @Override
    protected void onStart() {
        super.onStart();
        Log.i(TAG, "resource activity onStart");
    }

    @Override
    protected void onStop() {
        super.onStop();
        Log.i(TAG, "resource activity onStop");

    }

    @Override
    protected void onPause() {
        super.onPause();
        Log.i(TAG, "resource activity onPause");
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        Log.i(TAG, "resource activity onDestroy");
    }
 
这里增加了一个静态方法设置一个View,然后在onCreate中优先加载这个View。具体原因是因为在实践过程中,发现setContentView(layoutId)并不生效,所以先生成一个View在加载页面了。

在这里的过程中,在运行的时候发现会抛出一个熟悉的错误:Unable to find explicit activity class. have you decleared this activity in the AndroidManifest.xml?

没理由呀,在插件工程中已经声明了的,但是想想还是能理解,我们在宿主Activity中`startActivity,所以需要在宿主工程中声明这个组件。

第二种方案

这里的方法比较贴近QQ空间提出的替换dex的方案。PathClassLoader和DexClassLoader都是属于BaseDexClassLoader的子类。 
然后BaseDexClassLoader中一个成员为DexPath pathList: 


再看看DexPathList:

 

里面有个Element数组,这个数组是用来存放dex文件的路径的,系统默认的类加载器是PathClassLoader,程序加载之后会释放出一个dex文件,那么我们的做法就是,把DexClassLoader的dexElements和PathClassLoader的dexElements文件合并之后再放到PathClassLoader的pathList中。这样Activity的启动流程也是正确的。

如下:

public void injectDexElements(View v){
        Log.i(TAG,"this classloader = " + getClassLoader());
        PathClassLoader pathClassLoader = (PathClassLoader) getClassLoader();
        String fileDir = getCacheDir().getAbsolutePath();
        String filePath = fileDir + File.separator + Constants.RESOURCE_APK_NAME;  //源dex/jar/apk 目录
        DexClassLoader loader = new DexClassLoader(filePath, getDir("dex", MODE_PRIVATE).getAbsolutePath(), null, getClassLoader());
        try {
            //把PathClassLoader和DexClassLoader的pathList对象中的 dexElements 合并
            Object dexElements = combineArray(
                    getDexElements(getPathList(pathClassLoader)),
                    getDexElements(getPathList(loader)));
            //把合并后的dexElements设置到PathClassLoader的 pathList对象中的 dexElements
            Object pathList = getPathList(pathClassLoader);
            setField(pathList, pathList.getClass(), "dexElements", dexElements);

            startResourceActivity(filePath,pathClassLoader);

        } catch (IllegalArgumentException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }
    private static Object getPathList(Object baseDexClassLoader)
            throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
        ClassLoader bc = (ClassLoader)baseDexClassLoader;
        return getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
    }

    private static Object getField(Object obj, Class<?> cl, String field)
            throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        return localField.get(obj);
    }

    private static Object getDexElements(Object paramObject)
            throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException {
        return getField(paramObject, paramObject.getClass(), "dexElements");
    }
    private static void setField(Object obj, Class<?> cl, String field,
                                 Object value) throws NoSuchFieldException,
            IllegalArgumentException, IllegalAccessException {

        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        localField.set(obj, value);
    }

    private static Object combineArray(Object arrayLhs, Object arrayRhs) {
        Class<?> localClass = arrayLhs.getClass().getComponentType();
        int i = Array.getLength(arrayLhs);
        int j = i + Array.getLength(arrayRhs);
        Object result = Array.newInstance(localClass, j);
        for (int k = 0; k < j; ++k) {
            if (k < i) {
                Array.set(result, k, Array.get(arrayLhs, k));
            } else {
                Array.set(result, k, Array.get(arrayRhs, k - i));
            }
        }
        return result;
    }
 
运行的结果是一样的。

第三种方案

还有一种方式就和前两种方式的思路截然不同了。这里的思路是在宿主工程中创建一个代理Activity,然后插件Apk中的Activity就仅仅是一个普通的java类,对应着几个声明周期方法,然后通过反射在代理Activity的生命周期方法中调用对应的插件Activity的方法。我这里没有实践过,但是理论上是一种不错的方案。 
具体看这篇文章

总结

还是那句话,热修复的坑很多,这里的知识仅仅是冰山一角,还有很多问题需要解决,但是这样折腾一下,起码不会对热修复这东西两眼懵逼了。

源码地址
--------------------- 
作者:_wangjianfeng 
 参考原文:https://blog.csdn.net/u012943767/article/details/52355214 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
基于微信小程序的家政服务预约系统采用PHP语言和微信小程序技术,数据库采用Mysql,运行软件为微信开发者工具。本系统实现了管理员和客户、员工三个角色的功能。管理员的功能为客户管理、员工管理、家政服务管理、服务预约管理、员工风采管理、客户需求管理、接单管理等。客户的功能为查看家政服务进行预约和发布自己的需求以及管理预约信息和接单信息等。员工可以查看预约信息和进行接单。本系统实现了网上预约家政服务的流程化管理,可以帮助工作人员的管理工作和帮助客户查询家政服务的相关信息,改变了客户找家政服务的方式,提高了预约家政服务的效率。 本系统是针对网上预约家政服务开发的工作管理系统,包括到所有的工作内容。可以使网上预约家政服务的工作合理化和流程化。本系统包括手机端设计和电脑端设计,有界面和数据库。本系统的使用角色分为管理员和客户、员工三个身份。管理员可以管理系统里的所有信息。员工可以发布服务信息和查询客户的需求进行接单。客户可以发布需求和预约家政服务以及管理预约信息、接单信息。 本功能可以实现家政服务信息的查询和删除,管理员添加家政服务信息功能填写正确的信息就可以实现家政服务信息的添加,点击家政服务信息管理功能可以看到基于微信小程序的家政服务预约系统里所有家政服务的信息,在添加家政服务信息的界面里需要填写标题信息,当信息填写不正确就会造成家政服务信息添加失败。员工风采信息可以使客户更好的了解员工。员工风采信息管理的流程为,管理员点击员工风采信息管理功能,查看员工风采信息,点击员工风采信息添加功能,输入员工风采信息然后点击提交按钮就可以完成员工风采信息的添加。客户需求信息关系着客户的家政服务预约,管理员可以查询和修改客户需求信息,还可以查看客户需求的添加时间。接单信息属于本系统里的核心数据,管理员可以对接单的信息进行查询。本功能设计的目的可以使家政服务进行及时的安排。管理员可以查询员工信息,可以进行修改删除。 客户可以查看自己的预约和修改自己的资料并发布需求以及管理接单信息等。 在首页里可以看到管理员添加和管理的信息,客户可以在首页里进行家政服务的预约和公司介绍信息的了解。 员工可以查询客户需求进行接单以及管理家政服务信息和留言信息、收藏信息等。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值