Android插件化换肤原理初探
我们经常可以看到京东,天猫等App在618,双11的时候,一些按钮,文字等控件都会发生相应的变化来配合活动的展开。显然不会是单独的切一套UI,然后每个控件去进行更换背景,颜色等,然后发版。
所谓换肤就是对目标控件的图片,颜色等属性进行更改。这其中需要我们首先得拿到要替换资源的控件。要了解这个问题就首先需要了解控件是怎么从我们自己写的XML文件中被识别,加载的。
setContentView()流程
当我们启动一个Activity的时候,就会首先执行Activity的onCreate()生命周期方法,然后就会调用setContentView()去加载我们的布局,那么我们就从Activity被创建并加载执行onCreate()开始分析。
一个App的真正的入口是在ActivityThread类中的main()方法。从这个入口开始经过一些列的调用最终会调用到这个类中的handleLaunchActivity()去真正创建Activity并启动:
@Override
public Activity handleLaunchActivity(ActivityClientRecord r,
PendingTransactionActions pendingActions, Intent customIntent) {
// 省略部分代码
final Activity a = performLaunchActivity(r, customIntent);
// 省略部分代码
return a;
}
private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
// 省略部分代码
// 创建activity的上下文对象
ContextImpl appContext = createBaseContextForActivity(r);
Activity activity = null;
try {
// 拿到上下文的classloader对象
java.lang.ClassLoader cl = appContext.getClassLoader();
// 创建Activity实例
activity = mInstrumentation.newActivity(
cl, component.getClassName(), r.intent);
// ......
try {
Application app = r.packageInfo.makeApplication(false, mInstrumentation);
if (activity != null) {
//......
Window window = null;
// 1
if (r.mPendingRemoveWindow != null && r.mPreserveWindow) {
window = r.mPendingRemoveWindow;
r.mPendingRemoveWindow = null;
r.mPendingRemoveWindowManager = null;
}
appContext.setOuterContext(activity);
// 2
activity.attach(appContext, this, getInstrumentation(), r.token,
r.ident, app, r.intent, r.activityInfo, title, r.parent,
r.embeddedID, r.lastNonConfigurationInstances, config,
r.referrer, r.voiceInteractor, window, r.configCallback,
r.assistToken);
// ......
activity.mCalled = false;
if (r.isPersistable()) {
// 调用Activity的onCreate()函数
mInstrumentation.callActivityOnCreate(activity, r.state, r.persistentState);
} else {
mInstrumentation.callActivityOnCreate(activity, r.state);
}
// ......
return activity;
}
假设我们是刚刚启动应用,那么注释1处的条件就不会成立,那么最终在注释2处就传入了一个空的window。下面就看一下Activity的attach()函数做了什么:
final void attach(Context context, ActivityThread aThread,
Instrumentation instr, IBinder token, int ident,
Application application, Intent intent, ActivityInfo info,
CharSequence title, Activity parent, String id,
NonConfigurationInstances lastNonConfigurationInstances,
Configuration config, String referrer, IVoiceInteractor voiceInteractor,
Window window, ActivityConfigCallback activityConfigCallback, IBinder assistToken) {
attachBaseContext(context);
mFragments.attachHost(null /*parent*/);
// 创建PhoneWindow
mWindow = new PhoneWindow(this, window, activityConfigCallback);
mWindow.setWindowControllerCallback(this);
mWindow.setCallback(this);
mWindow.setOnWindowDismissedCallback(this);
mWindow.getLayoutInflater().setPrivateFactory(this);
// ......
}
这个函数的参数很多,不过我们只需要关心的是在这个方法内创建了一个PhoneWindow()对象赋值给了成员变量mWindow。也就是说每启动一个Activity都会创建一个新的PhoneWindow对象。也就是说现在的情况是这样的:
在调用了onCreate()方法之后就会执行setContentView()方法来加载我们的布局文件。我们都知道setContentView()最终会调用到PhoneWindow()中的setContentView(),下面就一起去PhoneWindow中看一下:
public void setContentView(int layoutResID) {
if (mContentParent == null) {
installDecor();
} else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
mContentParent.removeAllViews();
}
if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
getContext());
transitionTo(newScene);
} else {
mLayoutInflater.inflate(layoutResID, mContentParent);
}
mContentParent.requestApplyInsets();
final Callback cb = getCallback();
if (cb != null && !isDestroyed()) {
cb.onContentChanged();
}
mContentParentExplicitlySet = true;
}
老规矩,刚开始的时候mContentParent肯定为空,那么去看一下installDecor()方法都干了什么:
private void installDecor() {
mForceDecorInstall = false;
if (mDecor == null) {
mDecor = generateDecor(-1);
mDecor.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS);
mDecor.setIsRootNamespace(true);
if (!mInvalidatePanelMenuPosted && mInvalidatePanelMenuFeatures != 0) {
mDecor.postOnAnimation(mInvalidatePanelMenuRunnable);
}
} else {
mDecor.setWindow(this);
}
if (mContentParent == null) {
mContentParent = generateLayout(mDecor);
mDecor.makeOptionalFitsSystemWindows();
// ......
}
}
同样的逻辑,mDecor第一次肯定为空,那么继续看generateDecor()方法:
protected DecorView generateDecor(int featureId) {
// ......
return new DecorView(context, featureId, this, getAttributes());
}
这个方法没有做太多的事情,最主要的就是创建了一个DecorVIew对象给返回了回去。
而DecorView就是个FrameLayout。那么接着回去看installDecor()。在这个方法的下面同样也是一样的套路去初始化了mContentParent。那么看一下这个方法都干了什么:
protected ViewGroup generateLayout(DecorView decor) {
if{
// ......
}else {
layoutResource = R.layout.screen_simple;
}
mDecor.startChanging();
mDecor.onResourcesLoaded(mLayoutInflater, layoutResource);
ViewGroup contentParent = (ViewGroup)findViewById(ID_ANDROID_CONTENT);
if (contentParent == null) {
throw new RuntimeException("Window couldn't find content container view");
}
// ......
return contentParent;
}
这个方法很长,其前面的大部分代码都是在对窗口的一些属性进行设置以及一些系统内置的布局资源的加载。就比如这上面保留的R.layout.screen_simple。这个布局的文件可以在源码的framework中找到。其实就是一个垂直的LinearLayout。上半部是一个ViewStub标签,做占位功能。下半部就是一个FrameLayout:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:orientation="vertical">
<ViewStub android:id="@+id/action_mode_bar_stub"
android:inflatedId="@+id/action_mode_bar"
android:layout="@layout/action_mode_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<FrameLayout
android:id="@android:id/content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:foregroundInsidePadding="false"
android:foregroundGravity="fill_horizontal|top"
android:foreground="?android:attr/windowContentOverlay" />
</LinearLayout>
接着下面就调用了findViewById(ID_ANDROID_CONTENT),就是把这个FrameLayout给初始化并返回。那现在接着回到PhoneWindow的setContentView()方法当中。
在初始化DecorView以及mContentParent之后,下面又调用了LayoutInflater的inflate()方法去解析布局,并把mContentParent和我们在Activity中设置的布局文件传了进去。这个方法最终会调用到LayoutInflater中的这个方法:
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
// 如果是merge标签
if (TAG_MERGE.equals(name)) {
if (root == null || !attachToRoot) {
throw new InflateException("<merge /> can be used only with a valid "
+ "ViewGroup root and attachToRoot=true");
}
rInflate(parser, root, inflaterContext, attrs, false);
} else {
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
ViewGroup.LayoutParams params = null;
if (root != null) {
params = root.generateLayoutParams(attrs);
if (!attachToRoot) {
temp.setLayoutParams(params);
}
}
if (root != null && attachToRoot) {
root.addView(temp, params);
}
if (root == null || !attachToRoot) {
result = temp;
}
}
// ......
return result;
}
}
merge标签是优化布局用的,这里就按照正常流程来假设没有merge标签,那么就会调用createViewFromTag()方法。根据名字应该是到是用来创建View实例的。这个方法经最终会调到createView()这个方法:
public final View createView(@NonNull Context viewContext, @NonNull String name,
@Nullable String prefix, @Nullable AttributeSet attrs)
throws ClassNotFoundException, InflateException {
// 先从缓存中根据名字去取要加载的View的构造方法
Constructor<? extends View> constructor = sConstructorMap.get(name);
if (constructor != null && !verifyClassLoader(constructor)) {
constructor = null;
sConstructorMap.remove(name);
}
// 1
Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = viewContext;
Object[] args = mConstructorArgs;
args[1] = attrs;
try {
// 调用构造方法的newInstance创建View的实例
final View view = constructor.newInstance(args);
if (view instanceof ViewStub) {
// Use the same context when inflating ViewStub later.
final ViewStub viewStub = (ViewStub) view;
viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
}
return view;
} finally {
mConstructorArgs[0] = lastContext;
}
// ......
}
注释1处的mConstructorArgs是一个长度为2的Object[]数组,下面分别给索引0的位置存入一个context实例,索引1的位置则存入了attrs属性集,attrs其实就是AttributeSet。了解反射的就知道这其实就是构造方法的参数。故这里最终调用的是View的两个参数的构造方法来创建的View。这也就是为什么我们自定义View在布局当中使用为什么一定至少要重写两个构造函数。
通过刚刚的分析我们得知createViewFromTag()其实就是去解析我们的布局,并返回了一个temp,这个temp就是我们写在XML里的View。那么现在回到inflate()方法中。在解析完XML文件之后,首先会判断root是否为null。root就是前面在generateLayout()的时候从系统内置的布局当中找到FrameLayout,显然不为null。那么就会去获得这个FrameLayout的所有属性。由于attachToRoot在上面传入的是root != null,也就是true,那么就会走到下面通过addView()的形式把我们自己写的布局添加到系统的FrameLayout当中。至此,我们的View层级应该是这样的:
至此,setContentView()的整个流程就走完了。我们也看到了我们写的XML是如何一步一步的被加载显示以及整个的显示层级都包含什么。那既然我们看到了View的创建过程,那么我们是不是可以实现一个自己的LayoutInflater,然后在createView()方法中对解析出来的View进行改变就可以了?显然这样做是可以的。不过还有另外一种方式。这种方式是在createViewFromTag()中,在createView()方法调用之前:
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
// ......
try {
// 1
View view = tryCreateView(parent, name, context, attrs);
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(context, parent, name, attrs);
} else {
view = createView(context, name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
// ......
return view;
}
}
可以看到在真正调用createView()之前先调用了注释1处的tryCreateView()并且返回的是View类型。下面并且判断了如果tryCreateView()返回的View如果为null才会去进行刚才的解析过程。反过来说如果tryCreateView()返回的View不为null,那么就直接使用tryCreateView()方法返回的View了。下面我们看一下这个方法的内部逻辑:
public final View tryCreateView(@Nullable View parent, @NonNull String name,
@NonNull Context context,
@NonNull AttributeSet attrs) {
if (name.equals(TAG_1995)) {
// Let's party like it's 1995!
return new BlinkLayout(context, attrs);
}
View view;
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
return view;
}
可以看到,如果mFactory2或者mFactory不为null,那么就调用这两个Factory的onCreateView方法去创建View。mFactory2和mFactory是LayoutInflater的成员变量,默认为null,需要我们自己传入。具体来说是LayoutInflater类的两个内部接口:
public interface Factory {
@Nullable
View onCreateView(@NonNull String name, @NonNull Context context,
@NonNull AttributeSet attrs);
}
public interface Factory2 extends Factory {
@Nullable
View onCreateView(@Nullable View parent, @NonNull String name,
@NonNull Context context, @NonNull AttributeSet attrs);
}
Factory2继承自Factory,且方法参数比Factory多了一个parent。我们可以根据有没有parent来灵活的选择实现Factory2还是Factory,然后用过setFactory()把我们的实现类传入进去。这样我们就可以在我们自己的实现类中去创建View并且可以做任何我们想做的事情,比如我们要做的换肤。但是在使用setFactory()的时候需要注意一下:
public void setFactory(Factory factory) {
if (mFactorySet) {
throw new IllegalStateException("A factory has already been set on this LayoutInflater");
}
if (factory == null) {
throw new NullPointerException("Given factory can not be null");
}
mFactorySet = true;
if (mFactory == null) {
mFactory = factory;
} else {
mFactory = new FactoryMerger(factory, null, mFactory, mFactory2);
}
}
mFactorySet默认为false。当我们调用一次该方法之后就会被置为true。那么当下次再调用setFactory()的时候就会进入第一个if并抛出异常。所以在使用这个方法的时候要先利用反射把mFactorySet置为false。
总结:1、可以实现自己的LayoutInflater,并在createView()方法中对View进行改变
2、根据情况实现Factory或者Factory2接口创建View并对View进行操作
资源文件加载流程
在对App进行打包的时候,会把项目中的资源文件打包称一个resources.arsc文件:
双击点开之后,Android Studio上会这样显示:
类似于数据库,左边是表名,右边是表结构。这是一个二进制文件,资源的id,名称以及所在的路径全都包含在这个文件当中。所以当我们加载一个apk文件的时候,会有一个类会去读取这些资源信息。那么我们就要去看一下是哪个类,然后是怎么加载的。
首先还是去ActivityThread类中,只不过这次我们要看的方法是handleBindApplication():
public void handleBindApplication(AppBindData data)
// ......
mInstrumentation = new Instrumentation();
mInstrumentation.basicInit(this);
}
// ......
try {
app = data.info.makeApplication(data.restrictedBackupMode, null);
// ......
try {
mInstrumentation.onCreate(data.instrumentationArgs);
}
// ......
try {
mInstrumentation.callApplicationOnCreate(app);
// ......
}
可以看到首先创建了mInstrumentation对象,然后创建了一个Application对象,最后调用了Application的onCreate()方法。其实真正有用的是在makeApplication()当中:
public Application makeApplication(boolean forceDefaultAppClass,
Instrumentation instrumentation) {
// ......
ContextImpl appContext = ContextImpl.createAppContext(mActivityThread, this);
app = mActivityThread.mInstrumentation.newApplication(
cl, appClass, appContext);
appContext.setOuterContext(app);
// ......
return app;
}
这里也没有什么重要的信息,我们需要点击createAppContext()继续向下追踪。经过一层调用之后会调到这个方法:
static ContextImpl createAppContext(ActivityThread mainThread, LoadedApk packageInfo,
String opPackageName) {
if (packageInfo == null) throw new IllegalArgumentException("packageInfo");
ContextImpl context = new ContextImpl(null, mainThread, packageInfo, null, null, null, 0,
null, opPackageName);
context.setResources(packageInfo.getResources());
return context;
}
终于看到与资源相关的的。首先从packageInfo中去取资源,然后set到context中。那么我们就要看LoadedApk这个类是什么去获取资源的:
public Resources getResources() {
if (mResources == null) {
final String[] splitPaths;
try {
splitPaths = getSplitPaths(null);
} catch (NameNotFoundException e) {
// This should never fail.
throw new AssertionError("null split not found");
}
mResources = ResourcesManager.getInstance().getResources(null, mResDir,
splitPaths, mOverlayDirs, mApplicationInfo.sharedLibraryFiles,
Display.DEFAULT_DISPLAY, null, getCompatibilityInfo(),
getClassLoader());
}
return mResources;
}
到了这里可以看到是通过资源管理器ResourceManager去获取资源的。那么继续向下追踪:
private @Nullable Resources getOrCreateResources(@Nullable IBinder activityToken,
@NonNull ResourcesKey key, @NonNull ClassLoader classLoader) {
synchronized (this) {
// ......
ResourcesImpl resourcesImpl = createResourcesImpl(key);
if (resourcesImpl == null) {
return null;
}
// ......
return resources;
}
}
看这个方法的名字我们就能猜到这个方法的作用要么是去缓存中取资源,要么就是创建资源。假设我们是第一次运行,肯定是要去创建的,那么就需要去关注createResourceImpl():
private @Nullable ResourcesImpl createResourcesImpl(@NonNull ResourcesKey key) {
final DisplayAdjustments daj = new DisplayAdjustments(key.mOverrideConfiguration);
daj.setCompatibilityInfo(key.mCompatInfo);
final AssetManager assets = createAssetManager(key);
if (assets == null) {
return null;
}
// ......
final ResourcesImpl impl = new ResourcesImpl(assets, dm, config, daj);
return impl;
}
这个方法的主要功能是创建了一个AssetManager并创建了一个ResourceImpl对象并返了回去。那么看一下createAssetManager()除了创建AssetManager之外还干了什么:
protected @Nullable AssetManager createAssetManager(@NonNull final ResourcesKey key) {
final AssetManager.Builder builder = new AssetManager.Builder();
// resDir can be null if the 'android' package is creating a new Resources object.
// This is fine, since each AssetManager automatically loads the 'android' package
// already.
if (key.mResDir != null) {
try {
builder.addApkAssets(loadApkAssets(key.mResDir, false /*sharedLib*/,
false /*overlay*/));
} catch (IOException e) {
Log.e(TAG, "failed to add asset path " + key.mResDir);
return null;
}
}
if (key.mSplitResDirs != null) {
for (final String splitResDir : key.mSplitResDirs) {
try {
builder.addApkAssets(loadApkAssets(splitResDir, false /*sharedLib*/,
false /*overlay*/));
} catch (IOException e) {
Log.e(TAG, "failed to add split asset path " + splitResDir);
return null;
}
}
}
if (key.mOverlayDirs != null) {
for (final String idmapPath : key.mOverlayDirs) {
try {
builder.addApkAssets(loadApkAssets(idmapPath, false /*sharedLib*/,
true /*overlay*/));
} catch (IOException e) {
Log.w(TAG, "failed to add overlay path " + idmapPath);
// continue.
}
}
}
if (key.mLibDirs != null) {
for (final String libDir : key.mLibDirs) {
if (libDir.endsWith(".apk")) {
// Avoid opening files we know do not have resources,
// like code-only .jar files.
try {
builder.addApkAssets(loadApkAssets(libDir, true /*sharedLib*/,
false /*overlay*/));
} catch (IOException e) {
Log.w(TAG, "Asset path '" + libDir +
"' does not exist or contains no resources.");
// continue.
}
}
}
}
return builder.build();
}
可以看到除了创建AssetManger之外还把各种目录下的资源文件都加载并保存了起来,那么就来看一下loadApkAssets()是怎么加载的:
private @NonNull ApkAssets loadApkAssets(String path, boolean sharedLib, boolean overlay)
throws IOException {
final ApkKey newKey = new ApkKey(path, sharedLib, overlay);
ApkAssets apkAssets = null;
if (mLoadedApkAssets != null) {
apkAssets = mLoadedApkAssets.get(newKey);
if (apkAssets != null) {
return apkAssets;
}
}
// Optimistically check if this ApkAssets exists somewhere else.
final WeakReference<ApkAssets> apkAssetsRef = mCachedApkAssets.get(newKey);
if (apkAssetsRef != null) {
apkAssets = apkAssetsRef.get();
if (apkAssets != null) {
if (mLoadedApkAssets != null) {
mLoadedApkAssets.put(newKey, apkAssets);
}
return apkAssets;
} else {
// Clean up the reference.
mCachedApkAssets.remove(newKey);
}
}
// We must load this from disk.
if (overlay) {
apkAssets = ApkAssets.loadOverlayFromPath(overlayPathToIdmapPath(path),
false /*system*/);
} else {
apkAssets = ApkAssets.loadFromPath(path, false /*system*/, sharedLib);
}
if (mLoadedApkAssets != null) {
mLoadedApkAssets.put(newKey, apkAssets);
}
mCachedApkAssets.put(newKey, new WeakReference<>(apkAssets));
return apkAssets;
}
首先看是否已经加载了,如果有就返回,没有则继续往下从缓存当中去取。如果缓存也没有则会根据overlay的值来分别调用ApkAssets中的两个方法。这两个方法分别会创建一个ApkAssets对象并返回。在ApkAssets的构造方法中又会去调下面的native方法进入C层:
private static native long nativeLoad(
@NonNull String path, boolean system, boolean forceSharedLib, boolean overlay)
其中path就是apk资源的路径,那么就会把这个path对应的资源文件都加载进来。至此整个资源文件的加载流程就结束了。
有了上面的了解之后,那么我们是不是就可以生产一个插件apk,里面专门存放我们要替换的资源文件。当我们需要替换的时候,只要拿到这个插件apk的中资源的path,然后通过loadApkAssets()就可以获取到需要的资源了。
在Android系统中,所有表示资源相关的类是Resources。而我们取资源的时候经常是通过下面的方式去取的:
context.resources.getString(R.string.app_name)
context.resources.getColor(R.color.colorAccent)
context.resources.getDrawable(R.drawable.star)
我们随便点开一个进去看一下:
public int getColor(@ColorRes int id) throws NotFoundException {
return getColor(id, null);
}
public int getColor(@ColorRes int id, @Nullable Theme theme) throws NotFoundException {
final TypedValue value = obtainTempTypedValue();
try {
final ResourcesImpl impl = mResourcesImpl;
impl.getValue(id, value, true);
if (value.type >= TypedValue.TYPE_FIRST_INT
&& value.type <= TypedValue.TYPE_LAST_INT) {
return value.data;
} else if (value.type != TypedValue.TYPE_STRING) {
throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id)
+ " type #0x" + Integer.toHexString(value.type) + " is not valid");
}
final ColorStateList csl = impl.loadColorStateList(this, value, id, theme);
return csl.getDefaultColor();
} finally {
releaseTempTypedValue(value);
}
}
在上面的方法中会拿到成员变量mResourcesImpl去调用getValue():
void getValue(@AnyRes int id, TypedValue outValue, boolean resolveRefs)
throws NotFoundException {
boolean found = mAssets.getResourceValue(id, 0, outValue, resolveRefs);
if (found) {
return;
}
throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id));
}
而getValue()又会调用AssetsManager的getResourceValue():
boolean getResourceValue(@AnyRes int resId, int densityDpi, @NonNull TypedValue outValue,
boolean resolveRefs) {
Preconditions.checkNotNull(outValue, "outValue");
synchronized (this) {
ensureValidLocked();
final int cookie = nativeGetResourceValue(
mObject, resId, (short) densityDpi, outValue, resolveRefs);
if (cookie <= 0) {
return false;
}
// Convert the changing configurations flags populated by native code.
outValue.changingConfigurations = ActivityInfo.activityInfoConfigNativeToJava(
outValue.changingConfigurations);
if (outValue.type == TypedValue.TYPE_STRING) {
outValue.string = mApkAssets[cookie - 1].getStringFromPool(outValue.data);
}
return true;
}
}
不管调用哪个方法,最后都是殊途同归,都会调用AssetsManager的getResourceValue()方法。Resources和ResourcesImpl只是做了一个转发,真正干活的还是AssetsManager。
现在假设在宿主app中有一个R.color.tv_bg_color = #000000 ,然后插件app中有一个R.color.tv_bg_color = #FF00FF,那么就可以通过如下方式获取到插件apk中的资源id:
val resName = resources.getResourceEntryName(R.color.tv_bg_color)
val resType = resources.getResourceTypeName(R.color.tv_bg_color)
val skinTvBgColorId = mSkinResources.getIdentifier(resname,resType,mSkinPackageName)
上面的代码分为两步:
- 1、用宿主app中的Resources类根据id获取到对应资源的名称和类型
- 2、用插件app中的Resources类,通过上一步获取到的名称和类型来获取到该资源在插件app中的id
获取到id之后,自然就可以拿到该id对应的资源属性了。结合上部分的拿到XML中的View,再结合下半部分拿到资源文件,这就是整个插件化换肤的思路。