以前的项目中做过一个功能,一键换肤。最近面试被问得到还挺多。有点忘了,抽个时间整理下。写个小Demo
这里用到的是动态加载未安装的apk资源文件,然后访问apk中的资源文件,实现资源的替换。
Demo 展示的是一个帧动画,我们模拟服务器下载apk 的方法,把apk 放到assert 目录中,先模拟下载(copy到sdk中),然后加载未安装apk中的动画资源。
先看一下演示效果:
首次,思考一个问题:我们知道加载资源文件需要用到this.getResurces()方法,但是,这个方法只能加载自己apk中的资源。如果想要加载未安装apk的资源文件,我们该怎么办?
思路:我们先找到它在哪里加载的自己apk的资源文件?然后我们看能不能把这个地方替换成其他的apk,如果可以目的就达到了
先查看源码:
this.getResources():定位到ContextThemeWrapper
public class ContextThemeWrapper extends ContextWrapper {
......
@Override
public Resources getResources() {
return getResourcesInternal();
}
private Resources getResourcesInternal() {
if (mResources == null) {
if (mOverrideConfiguration == null) {
mResources = super.getResources();
} else {
final Context resContext = createConfigurationContext(mOverrideConfiguration);
mResources = resContext.getResources();
}
}
return mResources;
}
......
}
然后继续向下查找:
public class ContextWrapper extends Context {
Context mBase;
public ContextWrapper(Context base) {
mBase = base;
}
......
@Override
public Resources getResources() {
return mBase.getResources();
}
......
}
public abstract class Context {
......
public abstract Resources getResources();
......
}
最后定位到了Context中,但这是一个抽象类,具体方法由子类实现。如果你了解过app的启动流程,就知道,他的初始化实在
ContextImpl中实现的。我们去SDK 的源码中,搜索一下这个类:
ContextImpl:
class ContextImpl extends Context {
/*package*/ LoadedApk mPackageInfo;
final void init(LoadedApk packageInfo, IBinder activityToken, ActivityThread mainThread,
Resources container, String basePackageName, UserHandle user) {
mPackageInfo = packageInfo;
if (basePackageName != null) {
mBasePackageName = mOpPackageName = basePackageName;
} else {
mBasePackageName = packageInfo.mPackageName;
ApplicationInfo ainfo = packageInfo.getApplicationInfo();
......
找到初始化化的地方
mResources = mPackageInfo.getResources(mainThread);
mResourcesManager = ResourcesManager.getInstance();
......
}
@Override
public Resources getResources() {
return mResources;
}
}
有源码知道,mResources有 mPackageInfo 进行初始化的,mPackageInfo 是一个LoadeApk类,在找到这个类:
LoadeApk.java
/**
* Local state maintained about a currently loaded .apk.
* @hide
*/
public final class LoadedApk {
......
public Resources getResources(ActivityThread mainThread) {
if (mResources == null) {
mResources = mainThread.getTopLevelResources(mResDir,
Display.DEFAULT_DISPLAY, null, this);
}
return mResources;
}
......
}
LoadeApk中,又由ActivcityThread 来初始化,继续查看ActivityThread:
ActivittyThread.java:
/**
* {@hide}
*/
public final class ActivityThread {
private final ResourcesManager mResourcesManager;
......
/**
* Creates the top level resources for the given package.
*/
Resources getTopLevelResources(String resDir,
int displayId, Configuration overrideConfiguration,
LoadedApk pkgInfo) {
return mResourcesManager.getTopLevelResources(resDir, displayId, overrideConfiguration,
pkgInfo.getCompatibilityInfo(), null);
}
//
}
最终由ResourcesManger 加载:
ResourcesManger.java:
/** @hide */
public class ResourcesManager {
......
/**
* @param resDir the resource directory.
* @param compatInfo the compability info. Must not be null.
* @param token the application token for determining stack bounds.
*/
public Resources getTopLevelResources(String resDir, int displayId,
Configuration overrideConfiguration, CompatibilityInfo compatInfo, IBinder token) {
final float scale = compatInfo.applicationScale;
ResourcesKey key = new ResourcesKey(resDir, displayId, overrideConfiguration, scale,
token);
Resources r;
synchronized (this) {
// Resources is app scale dependent.
if (false) {
Slog.w(TAG, "getTopLevelResources: " + resDir + " / " + scale);
}
WeakReference<Resources> wr = mActiveResources.get(key);
r = wr != null ? wr.get() : null;
if (r != null && r.getAssets().isUpToDate()) {
if (false) {
Slog.w(TAG, "Returning cached resources " + r + " " + resDir
+ ": appScale=" + r.getCompatibilityInfo().applicationScale);
}
return r;
}
}
*********关键处******
AssetManager assets = new AssetManager();
if (assets.addAssetPath(resDir) == 0) {
return null;
}
DisplayMetrics dm = getDisplayMetricsLocked(displayId);
Configuration config;
boolean isDefaultDisplay = (displayId == Display.DEFAULT_DISPLAY);
final boolean hasOverrideConfig = key.hasOverrideConfiguration();
if (!isDefaultDisplay || hasOverrideConfig) {
config = new Configuration(getConfiguration());
if (!isDefaultDisplay) {
applyNonDefaultDisplayMetricsToConfigurationLocked(dm, config);
}
if (hasOverrideConfig) {
config.updateFrom(key.mOverrideConfiguration);
}
} else {
config = getConfiguration();
}
*******关键代码在这里*************************
r = new Resources(assets, dm, config, compatInfo, token);
if (false) {
Slog.i(TAG, "Created app resources " + resDir + " " + r + ": "
+ r.getConfiguration() + " appScale="
+ r.getCompatibilityInfo().applicationScale);
}
synchronized (this) {
WeakReference<Resources> wr = mActiveResources.get(key);
Resources existing = wr != null ? wr.get() : null;
if (existing != null && existing.getAssets().isUpToDate()) {
r.getAssets().close();
return existing;
}
mActiveResources.put(key, new WeakReference<Resources>(r));
return r;
}
}
......
}
最终 找到 r = new Resources(assets, dm, config, compatInfo, token);
注意:我看了下,不同api的源码有所区别,但大体类似
到这里就知道了:初始化Resueces 需要用到 AssertManger , DisplayMetrics Configuration
然而 DisplayMetrics Configuration 是系统的方法,每个APK 都一样,直接用系统的就好。
所以只需要 初始化 AssertManger,通过源码可以知道。
AssertManger 初始化 调用addAssetPath(resDir) 方法,这个方法 需要传递一个文件路径,而这个路径通过提示可以看出,就是资源文件的路径!
/** @hide */
public class ResourcesManager {
......
/**
* @param resDir the resource directory.
* @param compatInfo the compability info. Must not be null.
* @param token the application token for determining stack bounds.
*/
public Resources getTopLevelResources(String resDir, int displayId,
Configuration overrideConfiguration, CompatibilityInfo compatInfo, IBinder token) {
......
初始化AssetManager
AssetManager assets = new AssetManager();
if (assets.addAssetPath(resDir) == 0) {
return null;
}
DisplayMetrics dm = getDisplayMetricsLocked(displayId);
Configuration config;
......
关键代码在这里*************************
r = new Resources(assets, dm, config, compatInfo, token);
......
}
}
所以,根据源码可知,我们只要拿到AssertManager,然后加载插件apk 中的资源文件,
就可以获取到插件apk 的资源类 Resources。
但是,查看AssertManager源码,发现,构造函数 和 addAssetPath 方法 被隐藏了,所以
需要通过反射来获取:代码如下
MyRessources:
/**
* Created by 84651 on 2018/12/13.
*/
public class MyResouces extends Resources {
public MyResouces(AssetManager assets, DisplayMetrics metrics, Configuration config) {
super(assets, metrics, config);
}
/** 获取Assermanger
* @parm Resources 直接获取系统的就行,然后拿到 DisplayMetrics Configuration
*/
public static MyResouces getMyResueces(Resources resources, AssetManager assets) {
MyResouces myRes = new MyResouces(assets, resources.getDisplayMetrics(), resources.getConfiguration());
return myRes;
}
// 获取Resurse
public static AssetManager getAssertMangaer(String resFile) throws ClassNotFoundException {
//查看源码可以知道,AssertManager 被 hide了,不能直接初始化。所以需要用到反射
Class<?> clazz = Class.forName("android.content.res.AssetManager");
Method[] methods = clazz.getDeclaredMethods();
for (Method method : methods) {
if (method.getName().equals("addAssetPath")) {
try {
AssetManager manager = AssetManager.class.newInstance();
method.invoke(manager, resFile);
return manager;
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}
}
return null;
}
}
开始编写代码:
1、首先创建两个项目,一个作为主项目 MainApk,用来加载业务逻辑。
一个插件项目,只存放了 资源文件 SkinApk
MainApk:
SkinApk:
主项目中:
布局文件,一个ImageView 用来播放动画,两个Button 播放本地动画,和未安装apk 中的动画
activity_main.xml:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/main_iv"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_centerHorizontal="true" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/main_iv"
android:layout_marginTop="20dp"
android:orientation="horizontal">
<Button
android:id="@+id/main_btn"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="加载默认动画" />
<Button
android:id="@+id/main_btn2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="加载apk插件动画" />
</LinearLayout>
</RelativeLayout>
接下来在MainActivity 中实现:
MainActivity:
public class MainActivity extends AppCompatActivity {
private ImageView imageView;
private Button btn;
private Button btn2;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
imageView = (ImageView) findViewById(R.id.main_iv);
btn = (Button) findViewById(R.id.main_btn);
btn2 = (Button) findViewById(R.id.main_btn2);
btn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {// 加载默认动画
imageView.setImageResource(R.drawable.myanim);
starAnim(imageView);
}
});
btn2.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 插件apk 的文件名
String apkName = "SkinApk.apk";
//插件apk的包名(可以服务器传)
String packageName = "com.example.better.skinapk";
// apk 文件保存路径
String apkPath = getCacheDir() + File.separator + apkName;
File file = new File(apkPath);
if (file.exists()) {
try {
AssetManager manager = MyResouces.getAssertMangaer(apkPath);
MyResouces myResueces = MyResouces.getMyResueces(getResources(), manager);
/*** 因为需要加载未安装的apk ,所以需要用到DexClassLoader
* String dexPath 目标所在的apk或者jar文件的路径,
* String optimizedDirectory, dex文件存放的路径
* String librarySearchPath 库的路径
* ClassLoader parent 加载器的父类
*/
DexClassLoader classLoader = new DexClassLoader(apkPath, getDir(apkName, MODE_PRIVATE).getAbsolutePath(), null, getClassLoader());
// 类加载器加载 R 中的drawable (因为动画xml 放到了drawable 目录下)
Class<?> loadClass = classLoader.loadClass(packageName + ".R$drawable");
// 获取所有参数
Field[] fields = loadClass.getDeclaredFields();
for (Field f : fields) {
// 找到我们的动画文件
if (f.getName().equals("myanim")) {
// 获取资源 ID
int id = f.getInt(R.drawable.class);
Drawable drawable = myResueces.getDrawable(id);
if (drawable != null) {
imageView.setImageDrawable(drawable);
starAnim(imageView);
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
} else {
// 如果不存在,则下载(模拟服务器下载,我们直接从assert目录下载apk)
InputStream is = null;
FileOutputStream fos = null;
try {
is = getAssets().open(apkName);
fos = new FileOutputStream(apkPath);
int len = 0;
byte[] buffer = new byte[1024];
while ((len = is.read(buffer)) != -1) {
fos.write(buffer, 0, len);
}
fos.flush();
Toast.makeText(MainActivity.this, "下载成功", Toast.LENGTH_SHORT).show();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (fos != null)
fos.close();
if (is != null)
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
});
}
// 开始动画
private void starAnim(ImageView view) {
view.clearAnimation();
Animatable drawable = (Animatable) view.getDrawable();
if (drawable != null) {
drawable.start();
}
}
}
MainActivity 中代码理解:
我们拿到加载其他apk的Resources 之后,还需要拿到SkinApk 中的资源ID,.资源ID 都是存放在R.java 文件中的,要加载这个文件,就需要使用类加载器加载。比较两个类加载器:
PathClassLoader: 只能操作在本地文件系统的文件列表或目录中的classes;
DexClassLoader:是一个可以从包含classes.dex实体的.jar或.apk文件中加载classes的类加载器。
所以需要DexClassLoader 来加载 未安装apk中的dex文件。
Demo 地址:
https://download.csdn.net/download/lijia1201900857/10850825