前言
插件化技术主要应用在动态化和换肤领域,前者还需要解决插件化Activity
跳转的问题,跳转带来的兼容性问题等。这里主要是分析插件化换肤功能,从视图的流程分析原理到方案实现,Darren
、鸿洋ChangeSkin
、网易云音乐等换肤框架思路都差不多,但是现在不需要继承BaseActivity
了,可以用LifeCycle
避免入侵性。虽然说技术已经过时了,大家都会,还是输出一下更深刻点。
API :androidx 1.2.0、29
目录
一、视图加载
这里直接从setContentView
方法入手。作为移动开发,Android
类启动、类的加载流程已经是基本知识了,多看多画流程图就清除了,这里不作过多介绍。
1、Activity启动流程简单介绍
Activity
的启动过程,我们从Context
的startActivity
说明,其实现是ContextImpl
的startActivity
,然后内部会通过Instrumentation
来尝试启动Activity
,这是一个跨进程过程,它会调用AMS的startActivity
方法,当AMS
校验Activity
的合法性后,会通过ApplicationThread
回调到我们的进程,这也是一次跨进程通信,而ApplicationThread
就是一个Binder
。回调逻辑是在Binder
线程池中完成的,所以需要通过Handler H
将其切回UI
线程,第一个消息是LAUNCH_ACTIVITY
,它对应这handleLaunchActivity
。这个方法里面完成了Activity
的创建和启动。接着,在Activity
的onResume
中,Activity
的内容将开始渲染到Window
上面,然后开始绘制直到我们可以看见。
2、setContentView
创建DecorView
是在这个过程,后续显示是onResume
阶段创建ViewRootImpl
并关联到PhoneWindow
,再显示。
API
环境 adnroidx 1.2.0
setContentView
方法,在androidx
中是交给 AppCompatDelegateImpl
处理的,接下来代码关注流程主要在视图创建部分
@Override
public void setContentView(int resId) {
ensureSubDecor();
ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
contentParent.removeAllViews();
LayoutInflater.from(mContext).inflate(resId, contentParent);
mAppCompatWindowCallback.getWrapped().onContentChanged();
}
先看下 ensureSubDecor
private void ensureSubDecor() {
if (!mSubDecorInstalled) {
mSubDecor = createSubDecor();
.........
}
createSubDecor
方法中有一段关键代码 mWindow.getDecorView()
,mWindow
就是PhoneWindow
@Override
public final @NonNull View getDecorView() {
if (mDecor == null || mForceDecorInstall) {
installDecor();
}
return mDecor;
}
这里会确保创建DecorView
,这里主要是设置一些主题等、为布局设置ID
,创建一个contentParent
,就是下图的FrameLayout
,看下面的图大致了解一下。
我们再回到最初的setContentView
方法,我们主要是拿到contentParent
,然后呢,把我们setContentView
中写的视图加载进去,说白了,就是inflate
方法
LayoutInflater.from(mContext).inflate(resId, contentParent);
3、inflate
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
final Resources res = getContext().getResources();
if (DEBUG) {
Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
+ Integer.toHexString(resource) + ")");
}
View view = tryInflatePrecompiled(resource, res, root, attachToRoot);
if (view != null) {
return view;
}
XmlResourceParser parser = res.getLayout(resource);// xml解析
try {
return inflate(parser, root, attachToRoot);
} finally {
parser.close();
}
}
上面的 tryInflatePrecompiled
方法必然是返回 null
的,因为在 API
中是否开启预编译的字段 mUseCompiledView
始终是false
的,所以我们关注下面的 inflate
方法即可,layout
布局这里是通过 XmlResourceParser
解析。
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate");
final Context inflaterContext = mContext;
final AttributeSet attrs = Xml.asAttributeSet(parser);
Context lastContext = (Context) mConstructorArgs[0];
mConstructorArgs[0] = inflaterContext;
View result = root;
...........
// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
ViewGroup.LayoutParams params = null;
if (root != null) {
if (DEBUG) {
System.out.println("Creating params from root: " +
root);
}
// Create layout params that match root, if supplied
params = root.generateLayoutParams(attrs);
if (!attachToRoot) { // false ,不绑定,则设置Params,有时候我们写的attachToRoot为false会崩溃,是因为这里,设置父布局的LayoutParams,可能类型不一样
// Set the layout params for temp if we are not
// attaching. (If we are, we use addView, below)
temp.setLayoutParams(params);
}
}
//这里会addView ,如果说attachToRoot了
....
return result;
}
}
我们主要看上面的 createViewFromTag
方法,
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
........
try {
View view = tryCreateView(parent, name, context, attrs);
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
//这里是因为如果没有的.的需要加前缀,例如TextView这种,我们需要加上android.widget,才能去反射构建该类,也就是加上包名
if (-1 == name.indexOf('.')) {
view = onCreateView(context, parent, name, attrs);
} else {
view = createView(context, name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
........
}
关注点到上面的 tryCreateView
方法,主要是这个方法创建View
的,如果它能正确返回view
的话,后面 if
就不会执行了。这个方法内主要代码如下
public final View tryCreateView(@Nullable View parent, @NonNull String name,
@NonNull Context context,
@NonNull AttributeSet attrs) {
//注意 彩蛋 一闪一闪亮晶晶的效果 TAG_1995 = blink
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); // 4个参数哦
} 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
去构建view
,如果还是空的,通过mPrivateFactory
创建view
,如果都是空的,那只能返回 null
,给外层去处理了。
实际上这个方法确实是返回 null
的,所以换肤的技术关键点就在于如何使得mFactory2
或者 mFactory
替换成我们自己的类,然后在这个类里面,我们可以搜集所有的视图View
,然后每次就可以动态改变了。至于创建视图如何做,我们可以按照 createViewFromTag
方法内创建视图照抄过来,内部两个细节不能丢失,一个是对构造函数的缓存,另一个是对不是类的全限定名需要加前缀进行反射构造类。
接下来还有一个问题?我们已经知道了,可以替换成我们写的类,那么对于其它APK
中的资源如何获取?
4、资源加载
之前在 inflate
方法开头时就创建了Resources
Resources res = getContext().getResources();
具体对应于ContextImpl
的getResources
方法
@Override
public Resources getResources() {
return mResources;
}
那这个mResources
是怎么创建的呢?
Android
启动后,会调用到ActivityThread
的 handleBindApplication
方法,该方法内有一行代码,
app = data.info.makeApplication(data.restrictedBackupMode, null);
主要是构造Application
的,我们关注makeApplication
方法,截取部分内部代码
java.lang.ClassLoader cl = getClassLoader();
if (!mPackageName.equals("android")) {
Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER,
"initializeJavaContextClassLoader");
initializeJavaContextClassLoader();
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
}
ContextImpl appContext = ContextImpl.createAppContext(mActivityThread, this);
app = mActivityThread.mInstrumentation.newApplication(
cl, appClass, appContext);
appContext.setOuterContext(app);
继续关注 ContextImpl.createAppContext
这行,发现创建上下文的过程中,会设置Resources
,主要是通过packageInfo
拿到Resources
,所以我们也可以通过插件APK
包的PackageInfo
拿到插件资源。
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
是如何拿到资源的
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;
}
后面就不具体看代码了,主要是会创建Resources
,然后会创建ResourcesImpl
与Resources
关联,还会调用 createAssetManager
创建AssetManager
关联给ResourcesImpl
,然后缓存起来,下次就不用创建了。
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;
}
}
.....
}
所以对于插件包的资源处理,可以按照源码
Resources appResource = mContext.getResources();
//反射创建AssetManager 与 Resource
AssetManager assetManager = AssetManager.class.newInstance();
//资源路径设置 目录或压缩包
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath",
String.class);
addAssetPath.invoke(assetManager, skinPath);
//根据当前的设备显示器信息 与 配置(横竖屏、语言等) 创建Resources
Resources skinResource = new Resources(assetManager, appResource.getDisplayMetrics
(), appResource.getConfiguration());
//获取外部Apk(皮肤包) 包名
PackageManager mPm = mContext.getPackageManager();
PackageInfo info = mPm.getPackageArchiveInfo(skinPath, PackageManager
.GET_ACTIVITIES);
String packageName = info.packageName;
5、其他布局是否会调用createViewFromTag
你可能还会问,之前分析的是Activity
创建时布局的构建,我们可以通过 createViewFromTag
方法链中的 mFactory2
或者 mFactory
来替代系统创建View
,那么子View
,我们自定义的View
会不会调用呢?不会调用,那还是没法处理动态换肤
结果肯定是会的。
之前的inflate
方法中 还有一个方法
// Inflate all children under temp against its context.
rInflateChildren(parser, temp, attrs, true);
这个方法内主要代码就是
final View view = createViewFromTag(parent, name, context, attrs);
final ViewGroup viewGroup = (ViewGroup) parent;
final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
rInflateChildren(parser, view, attrs, true);
viewGroup.addView(view, params);
所以可以放心用了
二、方案实现
1、方案脉络
主要分为三步
-
收集
XML
数据,利用生产对象过程中的Factory2
接口,onCreateView
方法 -
记录需要换肤的属性名和对应的资源
ID
,Factory2
生产View
的时候,对应SkinAttribute
、SkinView
、SkinPair
-
读取皮肤包的内容,通过
AssetManager
加载进来,最终替换的原理是通过拿到插件包的Resources
,然后去插件包里面找之前记录的ID
所对应的资源名和资源类型,再去插件包里找到插件包的资源ID
,设置给控件。
2、注意事项
-
处理视图时需要对
xml
中不同的写法做处理,例如xml
中控件EditText
和androidx.constraintlayout.widget.ConstraintLayout
这种写法区分,像没有写类的全限定名的,我们需要加上包名,全路径,然后才能反射构造类。还需要注意xml中控件的属性值写法不同,例如textColor=“#FFFFFF”
和textColor="@color/white"
和textColor="?colorAccent"
,针对第一个,我们处理不了,因为值是这样写死的。 -
如果是通过
LayoutInflaterCompat.setFactory2
方式设置mFactory2
的值,需要注意mFactorySet
会反射设置失败,具体写法可以看下代码SkinLayoutInflaterFactory skinLayoutInflaterFactory = new SkinLayoutInflaterFactory(activity); if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { //反射 try { Field field = LayoutInflater.class.getDeclaredField("mFactorySet"); field.setAccessible(true); field.setBoolean(layoutInflater, false); } catch (Exception e) { e.printStackTrace(); } LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutInflaterFactory); mLayoutInflaterFactories.put(activity, skinLayoutInflaterFactory); }else{ try { Field field = LayoutInflater.class.getDeclaredField("mFactory2"); field.setAccessible(true); field.set(layoutInflater,skinLayoutInflaterFactory); } catch (Exception e) { e.printStackTrace(); } }
通过第一种方法,将mFactorySet置为false,如果成果确实是可以的,但是在API 29 中
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P) private boolean mFactorySet; //受限制了 @UnsupportedAppUsage private Factory mFactory; @UnsupportedAppUsage private Factory2 mFactory2;
在
Android Q
的更新中有一条就是非SDK
接口在其中的受限情况变化,上面这个就反应出来了,反射mFactorySet
为false
,始终不会成功,除非targetSdk
版本改成29
以下,否则运行即闪退。