动态加载、插件化、热部署、热修复(更新)知识汇总

开发中经常能听到动态加载,插件化,热部署等词,动态加载到底是何方神物,它能实现什么功能,实现原理又如何?动态加载和插件化、热部署又有着什么样的联系呢?下面我们一起来学习吧。

1. 基本知识

1.1 动态加载

动态加载,是指在应用运行时,动态加载某个模块,达到新增或是改变某一部分功能/行为。

1.1.1 Java的动态加载

通过类加载器ClassLoader(用来加载类的)实现动态加载Jar文件,当一个Class被加载时,这个Class所引用的所有Class也会被加载。ClassLoader是双亲委派,即总是先请求父ClassLoader查找自身,依次类推,不存在则用本类的类加载器加载。

1.1.2 Android中动态加载

由于Android的虚拟机(Dalvik VM)是不能识别Java打出jar的byte code,需要通过dx工具来优化转换成Dalvik byte code才行。所以Android动态加载是指应用在运行的时候,使用类加载器加载相应的apk\dex\jar(必须含有dex文件),再过通反射获取apk\dex\jar内部资源(class、图片、color等),提供给宿主app使用。

Android动态加载的类型:so库, dex/jar/apk文件。

1.2 Android插件化

动态加载是插件化实现的基础,Android中可以将一个应用分成多个不同的部分,每一部分可看成一个插件,再利用动态加载技术实现插件化动态加载。
Android类加载器相关知识可以参考我前面写的一篇博文:android类加载器ClassLoader
简单的Android动态加载实践可参考这篇文章,讲解了Android动态加载dex的实现:Android动态加载jar/dex

1.3 热部署

是一个独立的apk, 也是程序运行动态加载,但加载完了后与宿主apk没有很大联系。

1.4 插件化和热部署的区别

独立运行的插件APK叫热部署:如在用户使用的时候才加载插件,插件一旦运行后,与主项目没有任何逻辑,只有在主项目启动插件时才触发一次调用插件的行为。

需要依赖主项目的环境运行的播件apk叫插件化:一启动项目就加载插件,主项目提供一个启动入口及从服务器下载最新插件更新逻辑。或是插件需要使用主项目中的功能时,如插件apk和主项目中都有ImageLoader,如果两个项目都引入,无疑造成代码冗余,所以我们可以把ImageLoader相关代码抽离成一个AndroidLibrary项目,主项目以Compile模式引用这个Library,而插件以Provider模式引入这个Library(编译出来的jar),这样丙者之间就能交互了。

1.5 热更新、热修复

上线的应用,如果发现紧急bug,又不想重机关报发版,可以通过服务器向用户发送修复补丁包,使用户不需要重新下载,安装,而修复取决于。

2. 动态加载的作用

利用动态加载技术,实现插件化动态加载,可以解决以下问题:

2.1 方法数不得超过65535

问题:一个dex文件的方法数最大不得超过65535个,且android在加载dex时会对其进行优化成optDex文件,早期的optDex文件要求不能大于5M,后期提升到8M,早期的手机可能方法数没有超过65535限制,但超过了LinearAllocHdr的分配空间,也会导致安装失败问题。

解决办法:可利用插件化拆分多个dex并动态加载,从而解决android端代码方法数不得超过65535;

2.2 减小初始安装包的体积

问题:像淘宝一个apk可能包含多个第三方应用,如聚划算,天猫商城等,如果把所有第三方apk也打包进同一个apk包,会导致初始apk体积过大。用户可能在下载时会有顾虑。
解决办法:利用动态加载来实现模块加载,应用在运行时按需动态加载,减少apk包的体积;

2.3 动态更新

问题:应用上线后如果发些bug了,传统方法是必须重新打个修复包,用户需要再次升级更新,用户体验上很不好;
解决办法:将需要修复的代码打成一个插件,通过服务器下发,应用运行时,动态加载更新;

2.4 动态换肤

实现原理同2.3,这样用户可以实时在线更新皮肤。

2.5 加快应用启动速度

问题:应用比较大时,被拆成多个dex打进初始apk包中,首次启动时速度很慢;
解决办法:利用动态加载技术,使用懒加载机制,在需要时才初始化,提高应用的启动速度;

2.6 降低耦合,提高开发速度

问题:大型项目,代码量一般比较大,所有代码全放在一个module里,编译速度慢,且复用低,耦合度高;
解决办法:分割插件模块,做到项目级别的代码分离,大大降低模块之间的耦合度,同一个项目能分割出不同模块在多个开发团队之间并行开发,若出现bug也容易定位问题;

2.6 过hook系统做一些想改变系统操作

3. 动态加载过程

1) 获取到要加载的插件(.so/apk/dex/jar),可直接copy或是从网络下载)并放在手机本地;
2) 加载可执行文件;
3) 调用具体的方法执行业务逻辑。

4. 动态加载插件apk里的类和资源问题

使用ClassLoader动态加载外部的dex文件非常简单,但它只能用来加载类,而插件中的apk里的资源、XML文件等却无法加载,同时由于无法更改本地的AndroidMainfest清单文件,所以无法启动新的Activity等组件。

4.1 一种简单的解决办法

先把要用到的全部res资源都放到主APK里面,同时把所有需要的Activity先全部写进AndroidManifest.xml文件里,只通过动态加载更新代码,不更新res资源,如果需要改动UI界面,可以通过使用纯Java代码创建布局的方式绕开XML布局。同时也可以使用Fragment代替Activity,这样可以最大限度得避开“无法注册新组件的限制”。

4.2 插件Activity生命周期管理

Android中动态加载技术虽然能把类加载进来,可是Activity\ Service等组件是有生命周期的,合用ClassLoader可以从插件中创建Activity对象,但是无法负责其生命周期。所以我们需要把加载进来的Activity等组件交给系统管理,让AMS赋予组件生命周期。同时组件必须在AndroidMainfest.xml中显示注册,否则会报错,而插件的组件并没有在宿主apk中注册。
网上有三种方法:

4.2.1 反射方法

通过Java的反射去获取Activity的各种生命周期方法,再在代理Activity中去调用插件对应的生命周期方法。这种方法比较复杂,且反射有一定的性能开销。

4.2.2 接口方式

将Activity的生命周期方法提取出来作为一个接口,再通过代理Activity去调用插件Activity的生命周期方法,这样就完成了插件Activity的生命周期管理。
具体要参考: Android apk动态加载机制的研究(二):资源加载和activity生命周期管理

4.2.3 使用傀儡类

使用傀儡类Activity用于代理执行插件APK的Activity的生命周期。
(1) 实现方法:在宿主apk的AndroidMainfest.xml注册一个代理ProxyActivity,ProxyActivity是一个傀儡类,自身没有什么业务逻辑。让ProxyActivity进入AMS进程接受检验,再在适当时候替换成真正要启动的Activity。

(2) 实现思想: Activity的启动过程,通过IPC调用进入系统进程system_server,完成Activity管理及一些校验工作,最后回到App进程中完成真正的Activity对象的创建。
当app进程调用startActivity启动插件类PluginActivity时,在进入AMS进程的入口使用hook代理提前在启动插件apk时使用ProxyActivity去系统层校验,等校验完毕,会调用Handler的dispatchMessage方法,在此时换回真正的PluginActivity,从而达到使用傀儡类欺骗系统成功校验。
(3) 实现步骤:
1) 代理系统启动Activity的方法,将要启动的Activity替换成我们占坑的Activity,欺骗系统去检查的目的。需要拦截startActivity,系统启动Activity最终会调用:ActivityManagerNative.getDefault().startActivity

int result = ActivityManagerNative.getDefault()
            .startActivity(whoThread, who.getBasePackageName(), intent,
                    intent.resolveTypeIfNeeded(who.getContentResolver()),
                    token, target != null ? target.mEmbeddedID : null,
                    requestCode, 0, null, options);
//而ActivityManagerNative.getDefault()的方法是:
static public IActivityManager getDefault() {
    return gDefault.get();
}
//gDefault是一个单例对象,Singleton是系统提供的单例辅助类,由于AMS需要频繁的与我们的应用通信,故采用单例把这个AMS的代理对象保存起来
private static final Singleton<IActivityManager> gDefault = new Singleton<IActivityManager>() {
    protected IActivityManager create() {
        IBinder b = ServiceManager.getService("activity");
        if (false) {
            Log.v("ActivityManager", "default service binder = " + b);
        }
        IActivityManager am = asInterface(b);
        if (false) {
            Log.v("ActivityManager", "default service = " + am);
        }
        return am;
    }
};

我们可以通过hook这个单例,改变代理ActivityManagerNative.getDefault()的返回值,就可以实现AMS的代理对象

 /**
     * Hook AMS
     * 主要完成的操作是  "把真正要启动的Activity临时替换为在AndroidManifest.xml中声明的替身Activity"
     * 进而骗过AMS
     */
    public static void hookActivityManagerNative() Exception {

        //获取ActivityManagerNative的类
        Class<?> activityManagerNativeClass = Class.forName("android.app.ActivityManagerNative");
        //拿到gDefault字段
        Field gDefaultField = activityManagerNativeClass.getDeclaredField("gDefault");
        gDefaultField.setAccessible(true);
        //从gDefault字段中取出这个对象的值
        Object gDefault = gDefaultField.get(null);

        // gDefault是一个 android.util.Singleton对象; 我们取出这个单例里面的字段
        Class<?> singleton = Class.forName("android.util.Singleton");

        //这个gDefault是一个Singleton类型的,我们需要从Singleton中再取出这个单例的AMS代理
        Field mInstanceField = singleton.getDeclaredField("mInstance");
        mInstanceField.setAccessible(true);
        //ams的代理对象
        Object rawIActivityManager = mInstanceField.get(gDefault);
    }

现在我们已经拿到了这个AMS的代理对象,现在需要创建一个自己的代理对象去拦截原AMS中的方法

class IActivityManagerHandler implements InvocationHandler {
    ...

     @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        if ("startActivity".equals(method.getName())) {
            Log.e("Main","startActivity方法拦截了");

            // 找到参数里面的第一个Intent 对象
            Intent raw;
            int index = 0;

            for (int i = 0; i < args.length; i++) {
                if (args[i] instanceof Intent) {
                    index = i;
                    break;
                }
            }
            raw = (Intent) args[index];
            //创建一个要被掉包的Intent
            Intent newIntent = new Intent();
            // 替身Activity的包名, 也就是我们自己的"包名"
            String stubPackage = MyApplication.getContext().getPackageName();

            // 这里我们把启动的Activity临时替换为 ZhanKengActivitiy
            ComponentName componentName = new ComponentName(stubPackage, ZhanKengActivitiy.class.getName());
            newIntent.setComponent(componentName);

            // 把我们原始要启动的TargetActivity先存起来
            newIntent.putExtra(AMSHookHelper.EXTRA_TARGET_INTENT, raw);

            // 替换掉Intent, 达到欺骗AMS的目的
            args[index] = newIntent;
            Log.e("Main","startActivity方法 hook 成功");
            Log.e("Main","args[index] hook = " + args[index]);
            return method.invoke(mBase, args);
        }

        return method.invoke(mBase, args);
    }
}

使用动态代理去代理上面获取的AMS

// 创建一个这个对象的代理对象, 然后替换这个字段, 让我们的代理对象帮忙干活,这里我们使用动态代理
//动态代理依赖接口
Class<?> iActivityManagerInterface = Class.forName("android.app.IActivityManager");
//返回代理对象,IActivityManagerHandler是我们自己的代理对象
Object proxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
        new Class<?>[] { iActivityManagerInterface }, new IActivityManagerHandler(rawIActivityManager));
//将我们的代理设值给singleton的单例
mInstanceField.set(gDefault, proxy);

等系统检查完后,再次代拦截系统创建Activity的方法,将原来我们替换的Activity再替换回来,达到启动不在AndroidMainfest注册的目的
系统检查合法性后,会回调ActivityThread里的scheduleLaunchActivity方法,在这个方法里发送一个消息到ActivityThread的内部类H中

private class H extends Handler {

    ...
 public void handleMessage(Message msg) {
     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);
                    Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
                } break;
    ...

    }

}

/**
 * Handle system messages here.
 */
public void dispatchMessage(Message msg) {
    if (msg.callback != null) {
    //如果传递的Message本身就有callback,那么直接使用Message对象的callback方法; 
        handleCallback(msg);
    } else {
        if (mCallback != null) {
        //如果Handler类的成员变量mCallback不为空,那么首先执行这个mCallback回调; 
            if (mCallback.handleMessage(msg)) {
                //如果mCallback的回调返回true,那么表示消息已经成功处理;直接结束。
                return;
            }
        }
        // 如果mCallback的回调返回false,那么表示消息没有处理完毕,会继续使用Handler类的handleMessage方法处理消息。
        handleMessage(msg);
    }
}

通过上面分析,我们可以给这个H设置一个Callback让他在走handleMessage之前先走我们的方法,然后我们替换回之前的信息,再让他走H的handleMessage

 /**
     * 由于之前我们用替身欺骗了AMS; 现在我们要换回我们真正需要启动的Activity
     * <p/>
     * 不然就真的启动替身了, 狸猫换太子...
     * <p/>
     * 到最终要启动Activity的时候,会交给ActivityThread 的一个内部类叫做 H 来完成
     * H 会完成这个消息转发; 最终调用它的callback
     */
    public static void hookActivityThreadHandler() throws Exception {

//         先获取到当前的ActivityThread对象
        Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
        //他有一个方法返回了自己
        Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
        currentActivityThreadMethod.setAccessible(true);
        //执行方法得到ActivityThread对象
        Object currentActivityThread = currentActivityThreadMethod.invoke(null);

        // 由于ActivityThread一个进程只有一个,我们获取这个对象的mH
        Field mHField = activityThreadClass.getDeclaredField("mH");
        mHField.setAccessible(true);
        //得到H这个Handler
        Handler mH = (Handler) mHField.get(currentActivityThread);

        Field mCallBackField = Handler.class.getDeclaredField("mCallback");
        mCallBackField.setAccessible(true);
        //设置我们自己的CallBackField
        mCallBackField.set(mH, new ActivityThreadHandlerCallback(mH));

    }
  try {
        // 把替身恢复成真身
        Field intent = obj.getClass().getDeclaredField("intent");
        intent.setAccessible(true);
        Intent raw = (Intent) intent.get(obj);

        Intent target = raw.getParcelableExtra(AMSHookHelper.EXTRA_TARGET_INTENT);
        raw.setComponent(target.getComponent());
        Log.e("Main","target = " + target);

    } catch (Exception e) {
        throw new RuntimeException("hook launch activity failed", e);
    }

4.2.4 插件apk中的资源怎么加载

因为res里的每一个资源都会在R.java里生成一个对应的Integer类型的id,APP启动时会先把R.java注册到当前的上下文环境中,我们在代码中以R文件的方式使用资源正是通过使用这些id访问Res资源,然而插件的R.java没注册到上下文环境中,所以插件中的res资源无法通过id使用。
我们平时使用res资源一般通过getResources().getXXX(resid),我们来看看getResources()的源码:

@Override
    public Resources getResources() {
        if (mResources != null) {//直接使用mResources实例获取res资源
            return mResources;
        }
        if (mOverrideConfiguration == null) {
            mResources = super.getResources();//通过父类的getResources()方法
            return mResources;
        } else {
            Context resc = createConfigurationContext(mOverrideConfiguration);
            mResources = resc.getResources();
            return mResources;
        }
    }

那mResources实例是怎么初始化的呢,它是通过Context的getResources()方法,而Context是一个抽象类,它的具体实现是在ContextImpl类,具体mResources实例是在ContextImpl类的构造函数中进行初始化的。

resources = mResourcesManager.getTopLevelResources(packageInfo.getResDir(),
                        packageInfo.getSplitResDirs(), packageInfo.getOverlayDirs(),
                        packageInfo.getApplicationInfo().sharedLibraryFiles, displayId,
                        overrideConfiguration, compatInfo);
mResources = resources;
//ResourcesManager中的getTopLevelResources方法
Resources getTopLevelResources(String resDir, String[] splitResDirs,
            String[] overlayDirs, String[] libDirs, int displayId,
            Configuration overrideConfiguration, CompatibilityInfo compatInfo) {
        Resources r;
        AssetManager assets = new AssetManager();//获取AssetManager实例
        if (libDirs != null) {
            for (String libDir : libDirs) {
                if (libDir.endsWith(".apk")) {//先查找lib包中的APK文件
                    //调用AssetManager对象中addAssetPath将apk包中的资源加载进去
                    if (assets.addAssetPath(libDir) == 0) {
                        Log.w(TAG, "Asset path '" + libDir +
                                "' does not exist or contains no resources.");
                    }
                }
            }
        }
        DisplayMetrics dm = getDisplayMetricsLocked(displayId);
        Configuration config ……;
        //通过AssetManager、DisplayMetrics、onfiguration、CompatibilityInfo实例创建我们需要的Resources实例
        r = new Resources(assets, dm, config, compatInfo);
        return r;
    }

通过上面代码分析,我们只要反射调用addAssetPath这个方法,把插件apk的位置告诉AssetManager类,它就会根据apk内的resources.arsc和已编译资源完成资源加载任务。我们可以自己创建一个Resources实例出来作为插件apk的上下文,具体实现如下:

private Resources getPluginResoures(String apkName) {
try {  
    //1. 创建一个AssetManager实例
        AssetManager assetManager = AssetManager.class.newInstance();  
        //2. 通过反射获取addAssetPath方法
        Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);  
        //3. 将插件dex中的资源通过addAssetPath方法添加到AssetManager实例中
        addAssetPath.invoke(assetManager, mDexPath);  
        mAssetManager = assetManager;  
    } catch (Exception e) {  
        e.printStackTrace();  
    }  
    //4. 获取宿主的resources实例
    Resources superRes = super.getResources();  
    //5. 生成手件的Resources实例,它即为插件的上下文环境,通过它可以获取插件的res资源
    mResources = new Resources(mAssetManager, superRes.getDisplayMetrics(),  
            superRes.getConfiguration());  
   return mResources;
}

完整加载插件中资源的代码如下:

/** 
     * 加载apk获得内部资源 
     * @param apkDir apk目录 
     * @param apkName apk名字,带.apk 
     * @throws Exception 
     */  
    private void dynamicLoadApk(String apkDir, String apkName, String apkPackageName) throws Exception {  
        File optimizedDirectoryFile = getDir("dex", Context.MODE_PRIVATE);//在应用安装目录下创建一个名为app_dex文件夹目录,如果已经存在则不创建  

        DexClassLoader dexClassLoader = new DexClassLoader(apkDir+File.separator+apkName, optimizedDirectoryFile.getPath(), null, ClassLoader.getSystemClassLoader());  
        //参数:1、包含dex的apk文件或jar文件的路径,2、apk、jar解压缩生成dex存储的目录,3、本地library库目录,一般为null,4、父ClassLoader 
        Class<?> clazz = dexClassLoader.loadClass(apkPackageName + ".R$mipmap");//通过使用apk自己的类加载器,反射出R类中相应的内部类进而获取我们需要的资源id  
        Field field = clazz.getDeclaredField("one");//得到名为one的这张图片字段  
        int resId = field.getInt(R.id.class);//得到图片id  
        Resources mResources = getPluginResources(apkName);//得到插件apk中的Resource  
        if (mResources != null) {  
            //通过插件apk中的Resource得到resId对应的资源  
            findViewById(R.id.background).setBackgroundDrawable(mResources.getDrawable(resId));  
        }  
    } 

具体插件化加载未安装apk下的资源的demo可参考: 插件化开发—动态加载技术加载已安装和未安装的apk

(4)插件apk中加载的资源res的id会不会和宿主apk里的资源id冲突

并不会,因为通过这种方式加载进来的res资源,并不是融入到主项目中,主项目里的res资源是保存在ContextImpl里的mResources实例中,整个项目共有,而新加进来的res资源是保存在新创建的Resources实例中的,即代理类有两套Res资源,并不是把新的res资源和原有的资源合并了,所以不怕R.id冲突。

5. 实际应用中一些注意事项

1) 当有多个插件化版本需要更新,如果管理不同插件与不同版本的差别,可通过上传不同版本的插件apk,并向主apk提供插件apk查询与下载功能;
2) 管理在线的插件apk,并能向不同的版本号的主app提供最合适的插件apk;
3) 如果最新插件apk出现紧急bug,需要提供旧版本回滚功能;
4) 出于安全考虑应该对app项目的请示信息做一些安全性校验;可通过校验插件APK的MD5值,如果插件APK的MD5值与我们服务器预置的数值不一样,就认为插被改过,弃用之。

  • 5
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值