本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布
最近一直在忙公司的业务,有两个月时间没有更新博客了,感叹坚持真是不容易。今天分享一下插件化的一些预备知识点,插件化是一个很大的话题,写一本书也不一定能说完。整体就是跨APP去加载资源或者代码,在Android里面尤其是加载四大组件,涉及到更多的姿势。今天我们不涉及四大组件,主要是看下怎么去跨APP调用代码或者加载资源。涉及到下面几个知识点:
- gradle打包和移动apk
- 资源加载机制,包括resources/assets等
- 移动apk位置,会涉及到两种io方式
- 构造DexClassLoader
写了一个小Demo,后面插件化的相关知识都会往这个Demo里面去补充,先看看这次的实现效果。
1.实现效果
整个Demo里面会有三个application
工程,一个 library
工程,布局文件很简单,点击上面两个按钮,app主工程回去调用另外两个工程下面的代码和加载对应的图片资源。两个按钮下面有个TextView
和`ImageView``,分别用来显示调用代码返回的字符串和加载得到的图片。
2.gradle配置
先看下整个工程的目录结构
先看看Demo里面的多工程配置,主要是是两类文件, build.gradle
和settings.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并移动的任务。
经过上面的步骤,两个apk已经移动到app目录下面的assets,并且分别命名为plugin1.apk
和plugin2.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的时间需要多久:
另外一种方式是传统的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);
}
看下耗时:
当然在传统方式中把缓冲区改大一点时间上是会快一点,但是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
中重写接口,返回新的AssetManager
和Resources
:
@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的虚拟机Dalvik
和ART
则对class
文件进行优化,它们加载的是dex
文件。
Android系统中有两个类加载器分别为PathClassLoader
和DexclassLoader
,PathClassLoader
和DexClassLoader
都是继承与BaseDexClassLoader
,BaseDexClassLoader
继承于ClassLoader
,看下Android 8.0里面的ClassLoader
的loadClass
方法:
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系统默认的类加载器是它的子类PathClassLoader
,PathClassLoader
只能加载系统中已经安装过的apk,而DexClassLoader
能够加载自定义的jar/apk/dex。
BaseDexClassLoader(String dexPath, File optimizedDirectory,
String libraryPath, ClassLoader parent)
二者构造函数差不多,区别就是一个参数optimizedDirectory
,这个是指定dex优化后的odex文件,PathClassLoader
中optimizedDirector
y为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();
}
}
});
- 首先调用
addAssetPath
构造AssertManager
和Resources
- 从pluginInfo中拿到
DexClassLoader
,pluginInfo是在onCreate
中赋值的- 通过上面
DexClassLoader
加载apk1中的接口com.example.juexingzhe.plugin1.PluginResources
- 将上面Class构造实例并强转为接口
ICommon
,这样就可以直接调用方法,不用反射调用- 调用方法获得字符串和图像资源
6.总结
简单总结下,上面通过构造AssetManager
和Resources
去加载插件apk中的资源,当然代码调用需要通过DexClassLoader
,这个也需要自己去构造,才能加载指定路径的apk代码。还简单介绍了下gradle打包和复制的功能,资源加载,双亲委托机制,IO的两种方式等。
本文结束。
欢迎大家关注哈。