插件化之代码调用与加载资源

本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

最近一直在忙公司的业务,有两个月时间没有更新博客了,感叹坚持真是不容易。今天分享一下插件化的一些预备知识点,插件化是一个很大的话题,写一本书也不一定能说完。整体就是跨APP去加载资源或者代码,在Android里面尤其是加载四大组件,涉及到更多的姿势。今天我们不涉及四大组件,主要是看下怎么去跨APP调用代码或者加载资源。涉及到下面几个知识点:

  1. gradle打包和移动apk
  2. 资源加载机制,包括resources/assets等
  3. 移动apk位置,会涉及到两种io方式
  4. 构造DexClassLoader

写了一个小Demo,后面插件化的相关知识都会往这个Demo里面去补充,先看看这次的实现效果。

1.实现效果

整个Demo里面会有三个application工程,一个 library工程,布局文件很简单,点击上面两个按钮,app主工程回去调用另外两个工程下面的代码和加载对应的图片资源。两个按钮下面有个TextView和`ImageView``,分别用来显示调用代码返回的字符串和加载得到的图片。

demo1.jpg

2.gradle配置

先看下整个工程的目录结构

demo2.png

先看看Demo里面的多工程配置,主要是是两类文件, build.gradlesettings.gradle, plugin1和plugin2中的build.gradle基本是一样的,就看plugin1下面的build.gradle,要编译成apk需要使用Android的application插件,一行代码

apply plugin: 'com.android.application'

com这个目录是要编译成Android的library,需要加载library插件

apply plugin: 'com.android.library'

com这个Module下面是一个接口文件,另外三个Module都依赖这个工程,在调用的时候就不用去通过反射拿到方法,方便舒爽。接口下就两个api,一个调用代码获取字符串,一个拿到图片资源。

public interface ICommon {
    String getString();

    int getDrawable();
}

同时要配置工程根目录下的settings.gradle文件,这个目录是告诉编译时需要编译哪几个工程,

include ':app', ':plugin1', ':plugin2', ':com'

上面就是项目多工程编译需要注意的点。另外一个就是三个工程都依赖com库

dependencies {
    ...
    implementation project(':com')
}

接下来我们就需要编译plugin1和plugin2两个apk,最终需要再app中去加载这两个apk文件中的内容,所以我们在编译后自动把这两个apk移动到app的assets目录下。在assemble这个task下面的doLast中去添加移动逻辑就行。

assemble.doLast {
    android.applicationVariants.all { variant ->
            println "onAssemble==="
        if (variant.name.contains("release") || variant.name.contains("debug")) {
            variant.outputs.each { output ->
                File originFile = output.outputFile
                println originFile.absolutePath
                copy {
                    from originFile
                    into "$rootDir/app/src/main/assets"
                    rename(originFile.name, "plugin1.apk")
                }
            }
        }
    }
}

然后在命令行中通过gradle assemble完成编译apk并移动的任务。

demo3.png

经过上面的步骤,两个apk已经移动到app目录下面的assets,并且分别命名为plugin1.apkplugin2.apk,接下来看看对apk的操作。

3.移动apk

在assets下的资源是不能通过路径去直接操作的,必须通过AssetManager,所以我们把apk复制到包下面进行操作,这就涉及到io操作,有两种方式可以,一种是okio,另外一种是传统的Java IO。我们分别来看下这两种方式的实现方式和耗时。

先看下okio的方式, okio的方式可以通过Okio.buffer的方式构造一个读缓冲区,buffer有个最大值是64K,可以减少读的次数。

        AssetManager assets = context.getAssets();
        InputStream inputStream = null;
        try {
            inputStream = assets.open(apkName);
            Source source = Okio.source(inputStream);
            BufferedSource buffer = Okio.buffer(source);
            Log.i(MainActivity.TAG, "" + context.getFileStreamPath(apkName));
            buffer.readAll(Okio.sink(context.getFileStreamPath(apkName)));
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }

看下用这种方式移动两种apk的时间需要多久:

demo4.png

另外一种方式是传统的io方式:

        AssetManager am = context.getAssets();
        InputStream is = null;
        FileOutputStream fos = null;
        try {
            is = am.open(apkName);
            File extractFile = context.getFileStreamPath(apkName);
            fos = new FileOutputStream(extractFile);
            byte[] buffer = new byte[1024];
            int count = 0;
            while ((count = is.read(buffer)) > 0) {
                fos.write(buffer, 0, count);
            }
            fos.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            closeSilently(is);
            closeSilently(fos);
        }

看下耗时:


demo5.png

当然在传统方式中把缓冲区改大一点时间上是会快一点,但是okio给我们提供了缓冲区的自动管理,更省心一点不用担心oom,所以还是推荐用okio的方式。

上面的okio的截图可以看出apk最终移动到包下面的files目录。这里说一个小知识点,通过run-as 包名就能看见两个apk了。

adb shell
run-as com.example.juexingzhe.plugindemo

现在已经有了两个apk了,接下来就是通过操作来调用代码和资源了。

4.调用代码和资源

Android里面说资源(除了代码)一般分为两类,一类是在/res目录,一类是在/assets目录。/res目录下的资源会在编译的时候通过aapt工具在项目R类中生成对应的资源ID,通过resources.arsc文件就能映射到对应资源,/res目录下可以包括/drawable图像资源,/layout布局资源,/mipmap启动器图标,/values字符串颜色style等资源。而/assets目录下会保存原始文件名和文件层次结构,以原始形式保存任意文件,但是这些文件没有资源ID,只能使用AssetManager读取这些文件。

平时在Activity中通过getResources().getXXX其实都会通过AssetManager去读取,比如我们看下getText:

@NonNull public CharSequence getText(@StringRes int id) throws NotFoundException {
        CharSequence res = mResourcesImpl.getAssets().getResourceText(id);
        if (res != null) {
            return res;
        }
        throw new NotFoundException("String resource ID #0x"
                + Integer.toHexString(id));
    }

看下getDrawable():

    public Drawable getDrawable(@DrawableRes int id) throws NotFoundException {
        final Drawable d = getDrawable(id, null);
        if (d != null && d.canApplyTheme()) {
            Log.w(TAG, "Drawable " + getResourceName(id) + " has unresolved theme "
                    + "attributes! Consider using Resources.getDrawable(int, Theme) or "
                    + "Context.getDrawable(int).", new RuntimeException());
        }
        return d;
    }

    public Drawable getDrawableForDensity(@DrawableRes int id, int density, @Nullable Theme theme) {
        final TypedValue value = obtainTempTypedValue();
        try {
            final ResourcesImpl impl = mResourcesImpl;
            impl.getValueForDensity(id, density, value, true);
            return impl.loadDrawable(this, value, id, density, theme);
        } finally {
            releaseTempTypedValue(value);
        }
    }

ResourcesImpl中会通过loadDrawableForCookie加载, 如果不是xml类型就直接通过AssetManager加载,

/**
     * Loads a drawable from XML or resources stream.
     */
    private Drawable loadDrawableForCookie(@NonNull Resources wrapper, @NonNull TypedValue value,
            int id, int density, @Nullable Resources.Theme theme) {

            ...
            if (file.endsWith(".xml")) {
                final XmlResourceParser rp = loadXmlResourceParser(
                        file, id, value.assetCookie, "drawable");
                dr = Drawable.createFromXmlForDensity(wrapper, rp, density, theme);
                rp.close();
            } else {
                final InputStream is = mAssets.openNonAsset(
                        value.assetCookie, file, AssetManager.ACCESS_STREAMING);
                dr = Drawable.createFromResourceStream(wrapper, value, is, file, null);
                is.close();
            }
        } catch (Exception e) {
            ...
        }
      ...

        return dr;
    }

如果是xml,会通过调用loadXmlResourceParser加载,可以看见最终还是AssetManager加载:

    @NonNull
    XmlResourceParser loadXmlResourceParser(@NonNull String file, @AnyRes int id, int assetCookie,
            @NonNull String type)
            throws NotFoundException {
        if (id != 0) {
            try {
                synchronized (mCachedXmlBlocks) {
                  ....
                    // Not in the cache, create a new block and put it at
                    // the next slot in the cache.
                    final XmlBlock block = mAssets.openXmlBlockAsset(assetCookie, file);
                    if (block != null) {
                        ...
                }
            } catch (Exception e) {
                final NotFoundException rnf = new NotFoundException("File " + file
                        + " from xml type " + type + " resource ID #0x" + Integer.toHexString(id));
                rnf.initCause(e);
                throw rnf;
            }
        }

        throw new NotFoundException("File " + file + " from xml type " + type + " resource ID #0x"
                + Integer.toHexString(id));
    }

上面简单说了下Android中资源的类型和它们的关系,所以我们如果要加载插件中的资源,关键就是AssetManager,而AssetManager加载资源其实是通过addAssetPath来添加资源路径,然后就能加载到对应资源。

    /**
     * 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) {
        return  addAssetPathInternal(path, false);
    }

所以我们就可以把插件apk的路径添加到addAssetPath中,然后再构造对应的Resources,那么就可以拿到插件里面res目录下的资源了。而系统addAssetPath是不对外开放的,我们只能通过反射拿到。

有了上面思路,代码实现就简单了,在Demo里面点击按钮的时候去通过反射拿到addAssetPath,然后把插件apk的路径传给它,然后构造一个新的AssetManager,和新的Resources.

    public static void addAssetPath(Context context, String apkName) {
        try {
            AssetManager assetManager = AssetManager.class.newInstance();
            Method addAssetPath = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
            addAssetPath.invoke(assetManager, pluginInfos.get(apkName).getDexPath());

            sAssetManager = assetManager;
            sResources = new Resources(assetManager,
                    context.getResources().getDisplayMetrics(),
                    context.getResources().getConfiguration());
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }

    }

然后在Activity中重写接口,返回新的AssetManagerResources:

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

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

最后奉上一段英文解释/res和/assets区别的:

Resources are an integral part of an Android application. In general, these are 
external elements that you want to include and reference within your application, 
like images, audio, video, text strings, layouts, themes, etc. Every Android 
application contains a directory for resources (`res/`) and a directory for 
assets (`assets/`). Assets are used less often, because their applications are far
 fewer. You only need to save data as an asset when you need to read the raw bytes. 
The directories for resources and assets both reside at the top of an Android 
project tree, at the same level as your source code directory (`src/`).

The difference between "resources" and "assets" isn't much on the surface, but in 
general, you'll use resources to store your external content much more often than 
you'll use assets. The real difference is that anything placed in the resources 
directory will be easily accessible from your application from the `R` class, which 
is compiled by Android. Whereas, anything placed in the assets directory will 
maintain its raw file format and, in order to read it, you must use the [AssetManager]
(https://developer.android.com/reference/android/content/res/AssetManager.html) to 
read the file as a stream of bytes. So keeping files and data in resources (`res/`) 
makes them easily accessible.

现在就差最后一步,就是通过自定义ClassLoader去加载插件apk中的ICommon的实现类,然后调用方法获取字符串和图像。

5.构造ClassLoader

我们都知道Java能跨平台运行关键就在虚拟机,而虚拟机能识别的文件是class文件,Android的虚拟机DalvikART则对class文件进行优化,它们加载的是dex文件。

Android系统中有两个类加载器分别为PathClassLoaderDexclassLoader,PathClassLoaderDexClassLoader都是继承与BaseDexClassLoaderBaseDexClassLoader继承于ClassLoader,看下Android 8.0里面的ClassLoaderloadClass方法:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    c = findClass(name);
                }
            }
            return c;
    }

上面就是Java里面的双亲委托机制,加载一个类都会先通过parent.loadClass,最终找到BootstrapClassLoader,如果还是没找到,会通过
findClass(name)去查找,这个就是我们自定义classLoader需要自己实现的方法。

但是在Android 8.0系统里面,

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }

这是因为Android的基类BaseDexClassLoader实现了findClass去加载指定的class。Android系统默认的类加载器是它的子类PathClassLoaderPathClassLoader只能加载系统中已经安装过的apk,而DexClassLoader能够加载自定义的jar/apk/dex。

BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String libraryPath, ClassLoader parent)

二者构造函数差不多,区别就是一个参数optimizedDirectory,这个是指定dex优化后的odex文件,PathClassLoaderoptimizedDirectory为null,DexClassLoader中为new File(optimizedDirectory)PathClassLoader在app安装的时候会有一个默认的优化odex的路径/data/dalvik-cache,DexClassLoader的dex输出路径为自己输入的optimizedDirectory路径。

所以我们需要去构造一个DexClassLoader来加载插件的代码。先抽出一个bean来保存关键的信息,一个就是apk的路径,另外一个就是自定义的DexClassLoader:

/**
 * 插件包信息
 */
public class PluginInfo {
    private String dexPath;
    private DexClassLoader classLoader;

    public PluginInfo(String dexPath, DexClassLoader classLoader) {
        this.dexPath = dexPath;
        this.classLoader = classLoader;
    }

    public String getDexPath() {
        return dexPath;
    }

    public DexClassLoader getClassLoader() {
        return classLoader;
    }
}

再接着看下构造DexClassLoader的方法:

    /**
     * 构造apk对应的classLoader
     *
     * @param context
     * @param apkName
     */
    public static void extractInfo(Context context, String apkName) {
        File apkPath = context.getFileStreamPath(apkName);
        DexClassLoader dexClassLoader = new DexClassLoader(
                apkPath.getAbsolutePath(),
                context.getDir("dex", Context.MODE_PRIVATE).getAbsolutePath(),
                null,
                context.getClassLoader());
        PluginInfo pluginInfo = new PluginInfo(apkPath.getAbsolutePath(), dexClassLoader);
        pluginInfos.put(apkName, pluginInfo);
    }

先看下apk1里面的接口代码:

package com.example.juexingzhe.plugin1;


import com.example.juexingzhe.com.ICommon;

public class PluginResources implements ICommon {
    @Override
    public String getString() {
        return "plugin1";
    }

    @Override
    public int getDrawable() {
        return R.drawable.bg_1;
    }
}

很简单,就是实现com包下的ICommon接口,接着看下点击按钮时候怎么去调用代码和拿到资源的。

        btn1.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                PluginInfo pluginInfo = AssetUtils.getPluginInfo(APK_1);
                AssetUtils.addAssetPath(getBaseContext(), APK_1);

                DexClassLoader classLoader = pluginInfo.getClassLoader();
                try {
                    Class PluginResources = classLoader.loadClass("com.example.juexingzhe.plugin1.PluginResources");
                    ICommon pluginObject = (ICommon) PluginResources.newInstance();
                    textView.setText(pluginObject.getString());
                    imageView.setImageResource(pluginObject.getDrawable());
                } catch (ClassNotFoundException e) {
                    e.printStackTrace();
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                } catch (InstantiationException e) {
                    e.printStackTrace();
                }

            }
        });
  1. 首先调用addAssetPath构造AssertManagerResources
  2. 从pluginInfo中拿到DexClassLoader,pluginInfo是在onCreate中赋值的
  3. 通过上面DexClassLoader加载apk1中的接口com.example.juexingzhe.plugin1.PluginResources
  4. 将上面Class构造实例并强转为接口ICommon,这样就可以直接调用方法,不用反射调用
  5. 调用方法获得字符串和图像资源

6.总结

简单总结下,上面通过构造AssetManagerResources去加载插件apk中的资源,当然代码调用需要通过DexClassLoader,这个也需要自己去构造,才能加载指定路径的apk代码。还简单介绍了下gradle打包和复制的功能,资源加载,双亲委托机制,IO的两种方式等。

本文结束。

欢迎大家关注哈。

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

推荐阅读更多精彩内容