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

最近一直在忙公司的业务,有两个月时间没有更新博客了,感叹坚持真是不容易。今天分享一下插件化的一些预备知识点,插件化是一个很大的话题,写一本书也不一定能说完。整体就是跨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的两种方式等。

本文结束。

欢迎大家关注哈。

猜你喜欢

转载自juejin.im/post/5be3da465188254ad2138885