Android插件化开发之动态加载技术学习

为什么要插件化开发和动态加载呢?我认为原因有三点:

  • 可以实现解耦
  • 可以解除单个dex函数不能超过65535的限制
  • 可以给apk瘦身,比如说360安全卫士,整个安装包才13.7M,对于一个用户量上亿的app这个大小已经很小了,它里面很多功能都是以插件的形式存在的

插件化开源框架有哪些

目前插件化开发已不是什么高深的技术了,各大互联网公司基本都有自己插件化开发框架,而且大部分都已经开源出来,听起来都是很高大上的东西,但是他们的原理有没有真正了解过呢?这两天通过查找的一些资料,想跟大家分享一下。

主要解决三个问题

  1. 如何加载插件apk的资源文件?
  2. 如何调用插件apk的方法?
  3. 如何加载插件中的activity,并且有生命周期?

第一个问题:如何加载插件apk的资源文件?

对于第一个问题我们假设有这么一个需求:我们有个app想做类似qq换肤的功能,但是这个皮肤文件很大,如果跟宿主app一起打包的话可能会导致apk包很大,希望通过插件的方式,在用户需要换肤的时候去下载各种皮肤插件,来完成换肤的需求。

首先要了解一个类:

  • DexClassLoader
DexClassLoader是一个类加载器,可以用来从.jar和.apk文件中加载class。可以用来加载执行没用和应用程序一起安装的那部分代码。
构造函数:
DexClassLoader(
String dexPath, //被解压的apk路径,不能为空。
String optimizedDirectory, //解压后的.dex文件的存储路径,不能为空。这个路径强烈建议使用应用程序的私有路径,不要放到sdcard上,否则代码容易被注入攻击。
String libraryPath, //os库的存放路径,可以为空,若有os库,必须填写。
ClassLoader parent//父亲加载器,一般为ClassLoader.getSystemClassLoader()。
)

  • AssetManager
    中的内部的方法addAssetPath,
    将插件apk路径传入,从而添加进assetManager中,
    然后通过new Resource把assetManager传入构造方法中,
    可以得到未安装apk对应的Resource对象。
    /**
     * Add an additional set of assets to the asset manager.  This can be
     * either a directory or ZIP file.  Not for use by applications.  Returns
     * the cookie of the added asset, or 0 on failure.
     * {@hide}
     */
    public final int addAssetPath(String path) {
        int res = addAssetPathNative(path);
        return res;
    }
    

接下来解决这个问题的思路是,先把插件apk下载到本地sd卡上,然后获取这个apk的信息,最后用DexClassLoader动态加载

第一步,下载插件apk:

/**
     * 下载插件apk
     * */
    private void downLoadPlugApk() {
        DownloadUtils.get().downloadFile(APK_URL, new File(PLUG_APP_PATH, APK_NAME), new DownLoadListener() {
            @Override
            public void onFail(File file) {
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        Toast.makeText(UnInstallActivity.this,"下载失败",Toast.LENGTH_LONG).show();
                    }
                });

            }

            @Override
            public void onSucess(File file) {
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        btn_download_plug_apk.setText("下载插件apk");
                        Toast.makeText(UnInstallActivity.this,"下载成功",Toast.LENGTH_LONG).show();
                    }
                });

            }

            @Override
            public void onProgress(long bytesRead, long contentLength, boolean done) {
                LogUtils.d("contentLength:"+contentLength+" | bytesRead:"+bytesRead+" | done:"+done);
                final float persent = (float) bytesRead / contentLength*100;
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        btn_download_plug_apk.setText((int)persent+"%");
                    }
                });
            }
        });

    }

这个插件apk里面有一张图片test.png放在mipmap-xxhdpi目录下,我是先把plugapp.apk文件放在一个服务器上,通过代码下载到sd卡的根目录下面

第二步,获取plugapk的信息 通过PackageManager的getPackageArchiveInfo方法获得

 /**
     * 获取未安装apk的信息
     * @param context
     * @param apkPath apk文件的path
     * @return
     */
    private String[] getUninstallApkInfo(Context context, String apkPath) {
        String[] info = new String[2];
        PackageManager pm = context.getPackageManager();
        PackageInfo pkgInfo = pm.getPackageArchiveInfo(apkPath, PackageManager.GET_ACTIVITIES);
        if (pkgInfo != null) {
            ApplicationInfo appInfo = pkgInfo.applicationInfo;
            String versionName = pkgInfo.versionName;//版本号
            Drawable icon = pm.getApplicationIcon(appInfo);//图标
            String appName = pm.getApplicationLabel(appInfo).toString();//app名称
            String pkgName = appInfo.packageName;//包名
            info[0] = appName;
            info[1] = pkgName;
        }
        return info;
    }


第三步,获取Resource对象


    /**
     * @param apkPath
     * @return 得到对应插件的Resource对象
     * 通过得到AssetManager中的内部的方法addAssetPath,
     * 将未安装的apk路径传入从而添加进assetManager中,
     * 然后通过new Resource把assetManager传入构造方法中,进而得到未安装apk对应的Resource对象。
     */
    private Resources getPluginResources(String apkPath) {
        try {
            AssetManager assetManager = AssetManager.class.newInstance();
            Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);//反射调用方法addAssetPath(String path)
            //第二个参数是apk的路径:Environment.getExternalStorageDirectory().getPath()+File.separator+"plugin"+File.separator+"apkplugin.apk"
            //将未安装的Apk文件的添加进AssetManager中,第二个参数为apk文件的路径带apk名
            addAssetPath.invoke(assetManager, apkPath);
            Resources superRes = this.getResources();
            Resources mResources = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
            return mResources;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

第四步,通过DexClassLoader获得resid

    /**
     * 加载apk获得内部资源
     * @param apkPath apk路径
     * @throws Exception
     */
    private int getRecourceIdFromPlugApk(String apkPath,String apkPackageName) throws Exception {
        File optimizedDirectoryFile = getDir("dex", Context.MODE_PRIVATE);//在应用安装目录下创建一个名为app_dex文件夹目录,如果已经存在则不创建
        Log.v("zxy", optimizedDirectoryFile.getPath().toString());// /data/data/com.example.dynamicloadapk/app_dex
        //参数:1、包含dex的apk文件或jar文件的路径,2、apk、jar解压缩生成dex存储的目录,3、本地library库目录,一般为null,4、父ClassLoader
        DexClassLoader dexClassLoader = new DexClassLoader(apkPath, optimizedDirectoryFile.getPath(), null, ClassLoader.getSystemClassLoader());
        Class<?> clazz = dexClassLoader.loadClass(apkPackageName + ".R$mipmap");//通过使用apk自己的类加载器,反射出R类中相应的内部类进而获取我们需要的资源id
        Field field = clazz.getDeclaredField("test");//得到名为test的这张图片字段
        int resId = field.getInt(R.id.class);//得到图片id
        return resId;
    }

第五步,实现换肤效果

  /**
     * 加载资源
     * */
    private void loadPlugResource() {

        String[] apkInfo = getUninstallApkInfo(this, PLUG_APP_PATH + "/" + APK_NAME);
        String appName = apkInfo[0];
        String pkgName = apkInfo[1];
        Resources resource = getPluginResources(APK_PATH);
        try {
            int resid = getRecourceIdFromPlugApk(APK_PATH, pkgName);
            activity_un_install.setBackgroundDrawable(resource.getDrawable(resid));
        } catch (Exception e) {
            e.printStackTrace();

        }

    }

第二个问题:如何调用插件apk的方法?

根据第一个问题就可以得到答案, 通过DexClassLoader加载类,然后通过反射机制执行类里面的方法

  /**
     * @param apkPath apk路径
     * @throws Exception
     */
    private String runPlugApkMethod(String apkPath,String apkPackageName) throws Exception {
        File optimizedDirectoryFile = getDir("dex", Context.MODE_PRIVATE);//在应用安装目录下创建一个名为app_dex文件夹目录,如果已经存在则不创建
        Log.v("zxy", optimizedDirectoryFile.getPath().toString());// /data/data/com.example.dynamicloadapk/app_dex
        //参数:1、包含dex的apk文件或jar文件的路径,2、apk、jar解压缩生成dex存储的目录,3、本地library库目录,一般为null,4、父ClassLoader
        DexClassLoader dexClassLoader = new DexClassLoader(apkPath, optimizedDirectoryFile.getPath(), null, ClassLoader.getSystemClassLoader());
//        //通过使用apk自己的类加载器,反射出R类中相应的内部类进而获取我们需要的资源id
//        Class<?> clazz = dexClassLoader.loadClass(apkPackageName + ".R$mipmap");
//        Field field = clazz.getDeclaredField("test");//得到名为test的这张图片字段
//        int resId = field.getInt(R.id.class);//得到图片id

        // 使用DexClassLoader加载类
        Class libProvierClazz = dexClassLoader.loadClass(apkPackageName+".TestDynamic");
        //通过反射运行sayHello方法
        Object obj=libProvierClazz.newInstance();
        Method method=libProvierClazz.getMethod("sayHello");
        return (String)method.invoke(obj);

    }

第三个问题:如何加载插件中的activity,并且有生命周期?

这个问题是最关键的问题,我们知道通过DexClassLoader可以加载插件app里的任何类包括Activity,也可以执行其中的方法,但是Android中的四大组件都有一个特点就是他们有自己的启动流程和生命周期,我们使用DexClassLoader加载进来的Activity是不会涉及到任何启动流程和生命周期的概念,说白了,他就是一个普普通通的类。所以启动肯定会出错。
这里就要看一下activity的启动流程了,步骤太多就不写了,可以网上搜一下资料或者看《Android源码情景分析》这本书介绍的很详细,一个简单的启动要涉及到30多个步骤。

加载Activity的时候,有一个很重要的类:LoadedApk.Java
image

他内部有一个mClassLoader变量是负责加载一个Apk程序d的,所以可以从这里入手,我们首先要获取这个对象,这个对象在ActivityThread中有实例,
image

ActivityThread类中有一个自己的static对象,然后还有一个ArrayMap存放Apk包名和LoadedApk映射关系的数据结构,那么我们分析清楚了,下面就来通过反射来获取mClassLoader对象。

 private void loadApkClassLoader(DexClassLoader dLoader){
        try{
            String filesDir = this.getCacheDir().getAbsolutePath();
            String libPath = filesDir+File.separator+APK_NAME;

            // 配置动态加载环境
            Object currentActivityThread = RefInvoke.invokeStaticMethod("android.app.ActivityThread", "currentActivityThread", new Class[] {}, new Object[] {});//获取主线程对象 http://blog.csdn.net/myarrow/article/details/14223493
            //当前apk的包名
            String packageName = this.getPackageName();
            ArrayMap mPackages = (ArrayMap) RefInvoke.getFieldOjbect( "android.app.ActivityThread", currentActivityThread, "mPackages");
            WeakReference wr = (WeakReference) mPackages.get(packageName);
            RefInvoke.setFieldOjbect("android.app.LoadedApk", "mClassLoader", wr.get(), dLoader);

        }catch(Exception e){
            e.printStackTrace();
        }


    }

所以我们是通过将LoadedApk中的mClassLoader替换成我们的DexClassLoader来实现加载plugappActivity的

   /**
     * 运行插件apk
     * */
    private void runPlug() {
        String filesDir = this.getCacheDir().getAbsolutePath();
        String libPath = filesDir+File.separator+APK_NAME;
        loadResources(libPath);
        DexClassLoader loader = new DexClassLoader(libPath, filesDir, filesDir, ClassLoader.getSystemClassLoader());
//        DexClassLoader loader = new DexClassLoader(libPath, filesDir,null, getClassLoader());
        Class<?> clazz = null;
        try {
            clazz = loader.loadClass("com.demo.plug.MainActivity");

            Class rClazz = loader.loadClass("com.demo.plug.R$layout");
            Field field = rClazz.getField("activity_main");
            Integer ojb = (Integer)field.get(null);

            View view = LayoutInflater.from(this).inflate(ojb, null);

            Method method = clazz.getMethod("setLayoutView", View.class);
            method.invoke(null, view);
            Log.i("demo", "field:"+ojb);

            loadApkClassLoader(loader);

            Intent intent = new Intent(RunPlugActivity.this, clazz);
            startActivity(intent);

        } catch (Throwable e) {
            Log.i("inject","error:"+Log.getStackTraceString(e));
            e.printStackTrace();
        }

    }

说白了就是偷梁换柱,欺骗系统来达到启动插件的目的。360的插件框架就是使用这种技术称之为hook技术,然后通过预先占坑的方式来预注册Activity。携程的这套插件化开发框架则是使用代理的模式来实现启动插件Activity的,所有activity都需要继承自proxy avtivity(proxy avtivity负责管理所有activity的生命周期),它的优点是不需要预先占坑了(不需要预先在宿主的清单文件里注册actvity)缺点是不支持Service和BroadCastReceiver,因为activity的生命周期启动还是比较复杂的,所以个人觉得携程的这套插件化框架实现起来是比较有难度的。

最后,除了上面这种方式还有两种

  • 通过合并PathClassLoader和DexClassLoader中的dexElements数组,
  • 动态代理加载Activity

这里只是做了一个最简单的探讨,如果想要做一套插件化开发框架可能要对android的framework层有一个更深入的理解,但是大概原理和思路我觉得是差不多的。
附一个下载链接:http://download.csdn.net/detail/breaktian/9776616

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容

  • 动态加载技术 介绍 在程序运行的时候,加载一些程序自身原本不存在的可执行文件并运行这些文件里的代码逻辑。 动态调用...
    冰点k阅读 3,765评论 1 11
  • 本文转自:Android博客周刊专题之#插件化开发# 原文作者:陆镇生_Jomeslu 本人最近研究插件化, 偶然...
    Aegis阅读 35,317评论 25 410
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,568评论 25 707
  • 最近几年移动开发业界兴起了「 插件化技术 」的旋风,各个大厂都推出了自己的插件化框架,各种开源框架都评价自身功能优...
    斜杠时光阅读 3,910评论 1 36
  • 19世纪七十年代,滑板运动在美国风行一时,当时仅加里福尼亚州就有300余个滑板公园。新鲜事物就像洋流一般影响到了对...
    夏耶阅读 2,525评论 1 0