前提
最近苹果手机发布黑夜模式,要求app必须实现黑白两种布局,虽然是苹果公司的要求,但是对于一个android开发者来说,肯定会思考,如果换成Android APP,我们又该怎么实现呢。
于是便有了这篇andrid Hook换肤的文章,可能会有不对之处,欢迎指出一起探讨。本篇将通过也将通过以下四点来解密android换肤的全过程。
本文大纲
- 何为换肤
- xml的view是如何加载到activity之上的
- 如何根据apk中的资源找到要替换的资源以及其中的原理。
- 如何执行换肤
一丶何为换肤
换肤的过程,其实就是收集应用中所有的view,然后更换这些view的属性,比如背景颜色,字体颜色,状态栏等信息。 在xml的加载到activity过程中,我们通过hook技术,获取到我们需要的view,然后保存这些view的节点信息。这时候加载提前准备好的资源apk文件,通过获取前面view保存的id,来获取对应的name,然后再通过name去加载apk中对应的资源,完成替换的过程
说了这么多,可能很多人还是云里雾里,比喻xml是如何加载到acitivyt之中?我们又是怎么拿到需要换肤的view属性?资源文件是通过哪种方式保存?以及最后如何完成换肤的替换操作? 本文就是为了解决这些问题,还请耐心的看下去。
二丶xml的view是如何加载到activity之上的
对于这个疑问,不用说我们肯定是要往源码层级追了,我将尽量的将重要代码简单的呈现出来,希望大部分人有耐心和我一起完成xml被加载之旅,那么现在出发吧。
追溯这个问题,我们第一反应肯定是Activity.setContentView()方法,在这个方法中,我们会将我们的资源文件传入,那么我们就从这里开始,首先打开Activity.setContentView();
public void setContentView(@LayoutRes int layoutResID) {
getWindow().setContentView(layoutResID);
initWindowDecorActionBar();
}
如果对源码熟悉的同学,看到getWindow()我们首先想到的就是window这个抽象类,而这个类的唯一实现就是PhoneWindow,所以调用Activity的setContentView()其实就是调用PhoneWindow的setContentView();那么我们将视线移步PhoneWindow;
@Override
public void setContentView(int layoutResID) {
if (mContentParent == null) {
//第一次进来mContentParent肯定为null,先初始化decorview
installDecor();
}
if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
getContext());
transitionTo(newScene);
} else {
//通过inflate加载布局
mLayoutInflater.inflate(layoutResID, mContentParent);
}
}
首先会调用installDecor(); 这个方法就是初始化decorView的操作,而这个DecorView其实就是一个FrameLayout在我们布局之上。新建项目的时候都有主题的,decorview就是主题,我们重点不关注这个方法,主要看下面的mLayoutInflater.inflate(layoutResID, mContentParent)方法,而我们追踪inflate会发现,调用的最终还是LayoutInflater的inflate方法,而在这个方法中,我们只关注一下createViewFromTag
// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
@UnsupportedAppUsage
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 {
if (-1 == name.indexOf('.')) {
view = onCreateView(context, parent, name, attrs);
} else {
view = createView(context, name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
}
仔细阅读createViewFromTag的源码,其实核心只做了两件事
- createView 所有的view最终都会调用到这个方法,来创建view,具体过程下面分析
- tryCreateView 可以理解为系统预留给我们开发者提供的创建view方式,通过Factory2接口来实现具体逻辑,如果实现了我们的逻辑,就不会在调用系统的方法。
而我们的换肤核心就是要hook上面的代码执行逻辑,在createview之前,创建Factory2然后实现onCreateview方法,来收集我们需要换肤的view。
@UnsupportedAppUsage
static final Class<?>[] mConstructorSignature = new Class[] {
Context.class, AttributeSet.class};
public final View createView(@NonNull Context viewContext, @NonNull String name,
@Nullable String prefix, @Nullable AttributeSet attrs)
throws ClassNotFoundException, InflateException {
try {
if (constructor == null) {
// Class not found in the cache, see if it's real, and try to add it
clazz = Class.forName(prefix != null ? (prefix + name) : name, false,
mContext.getClassLoader()).asSubclass(View.class);
if (mFilter != null && clazz != null) {
boolean allowed = mFilter.onLoadClass(clazz);
if (!allowed) {
failNotAllowed(name, prefix, viewContext, attrs);
}
}
//传入两个构造方法,来反射view。
constructor = clazz.getConstructor(mConstructorSignature);
constructor.setAccessible(true);
sConstructorMap.put(name, constructor);
}
}
tryCreateView 代码:
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;
//可以通过重新mFactory2,来hook,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;
}
到目前位置我们已经知道,如何将一个xml资源文件加载到布局上,以及如何把他们变成对应的对象。而我们换肤思想的第一步,就是要在view的加载过程中,收集到这些view的信息,以及所有需要换肤的view,然后保存起来等待我们换肤的时候使用:
所以总结一下:
- 首先会调用PhoneWindow的 setContentView
- 在phoneWindow的setcontentview中,会初始化dectorView来加载主题。
- 加载完主题,然后调用LayoutInflate.inflate方法。
- 在inflate的方法中,会通过调用crateViewFroamTag来创建View
- 所有View最终都会通过createView来创建对象
- 创建对象的过程是通过view的第二个构造方法反射获取。
- 在createview之前,Google源码中会有对开发中暴露出来的一个Factory2工厂类,在创建view之前,会先调用一下tryCreateView方法,来判断我们的Factory2是否为空,如果不为空就开始使用我们自己的接口创建view。所以这一步是换肤的关键步骤,实现Factory2接口来收集需要换肤的view。
- 所以到目前为止,我们有两种方案来换肤
- 通过hook源码的过程来hook crateview的过程但是这个过程侵入性太高不建议采用
- 我们观察到在crateview之前,会有一个队Factory的判空,我们可以利用这个接口,来做hook操作,收集到所有需要换肤的button。
三 如何根据apk中的资源找到要替换的资源以及其中的原理。
其实想要弄清楚这个问题,我们还需要知道皮肤包到底是怎样生成的呢。
##### 皮肤包的生成
我们执行换肤操作时,需要更换的皮肤其实是从另外一个apk中加载过来,而这个apk是开发者提前准备好, 或者从网络下载下来。我们不需要里面有任何的java代码,只需要把我们要替换的资源的src文件保留,且保证key值相同。也就是说在最初的apk中,保存TextView字体颜色的key = textColor,那么在加载的apk中我们的添加的key也要 = textColor,只不过我们改了这个textColor对应的颜色值。
如何找到要替换的资源
应用中所有资源文件都被保存到一个resource.arsc文件中,里面包括id,name等信息。我们通过调用原始apk的Resources的api来获取name,type
getResourcesEntryName(id)// 获取resname
getResourcesTypeName(id)// 获取resType
然后根据获取的结果,来到皮肤包apk中寻找,也是调用Resources的api
skinId = getIdentifier(resname,resType,mSkinPkgName)//第三个参数就是我们皮肤包的路径 返回一个skinid
如果这个skinId不为0 ,说明我们在换肤apk中找到了要更换的资源,就可以完成替换。
原理
整个换肤过程中,其实有一个关键的类AssentManager,就是这个类来管理和获取resource.arsc。可能很多人会问为什么我知道是AssentManager呢,这里面就涉及到AMS的源码,本文不做深入研究。
四 如何执行换肤(整个换肤设计思路)
首先我们知道了xml的加载过程,所以我们需要实现一个Factory2接口,在这个接口中我们要重写createView的过程,在重新的过程中,我们需要收集view的基本信息
-
我们每一个xml里面的view,本身都包含大量的属性,size,color,drawable,等等,我们需要创建一个view的bean类,来保存这些信息。
因为view的属性有很多,所以需要一个集合来保存每一个view的属性,所以我们在创建一个view的类,增加两个成员属性,view以及view属性集合
换肤的过程是给所有的view换肤,所有还需要一个类来保存所有的view信息。
-
我们需要完成在全局任何部位,无闪烁的换肤,所以需要Factory实现系统的被观察者接口,在所有需要换肤的地方,调用接口实现换肤。
而我们的观察者就是每一个activity,我们可以利用application中的ActivityLifecycleCallbacks接口,来监听activity生命周期,在每一个acitivity初始化的时候,来注册观察者。然后在需要换肤时候通知被观察者。
五 细节
- 实现Factory2接口以后,需要调用setFactory给Factory赋值,但是根据源码可以看到,让我们赋值一次以后,就不能在继续调用 了,所以需要才用hook继续来反射拿到mFactoryset属性,动态改变它的值。
public void setFactory2(Factory2 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 = mFactory2 = factory;
} else {
mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
}
}