android
主题换肤通常借助LayoutInflater#setFactory
实现换肤。
换肤步骤:
apk
压缩文件,创建自定义的Resource
对象去访问apk
压缩文件的资源。LayoutInfater#setFactoy
,将步骤(1)中的资源应用到View
的创建过程当中。平常设置或者获取一个View
时,用的较多的是setContentView
或LayoutInflater#inflate
,setContentView
内部也是通过调用LayoutInflater#inflate
实现(具体调用在AppCompatViewInflater#setContentView(ind resId)
中)。
通过LayoutInflater#inflate
可以将xml
布局文件解析为所需要的View
,通过分析LayoutInflate#inflate
源码,可以看到.xml
布局文件在解析的过程中会调用LayoutInflater#rInflate
,随后会通过调用LayoutInflater#createViewFromTag
来创建View
。这里推荐《遇见LayoutInflater&Factory》
下面一起看看View
的创建过程LayoutInflate#createViewFormTag
:
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
if (name.equals("view")) {
name = attrs.getAttributeValue(null, "class");
}
// Apply a theme wrapper, if allowed and one is specified.
if (!ignoreThemeAttr) {
final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
final int themeResId = ta.getResourceId(0, 0);
if (themeResId != 0) {
context = new ContextThemeWrapper(context, themeResId);
}
ta.recycle();
}
if (name.equals(TAG_1995)) {
// Let's party like it's 1995!
return new BlinkLayout(context, attrs);
}
try {
View view;
if (mFactory2 != null) {
//根据attrs信息,通过mFactory2创建View
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
//根据attrs信息,通过mFactory创建View
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
//创建Android原生的View(android.view包下面的view)
view = onCreateView(parent, name, attrs);
} else {
//创建自定义View或者依赖包中的View(xml中声明的是全路径)
view = createView(name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
} catch (InflateException e) {
throw e;
} catch (ClassNotFoundException e) {
final InflateException ie = new InflateException(attrs.getPositionDescription()
+ ": Error inflating class " + name, e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} catch (Exception e) {
final InflateException ie = new InflateException(attrs.getPositionDescription()
+ ": Error inflating class " + name, e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
}
}
从上述源码中可以看出View
的创建过程中,会首先找Factory2#onCreateView
和Factory#onCreateView
进行创建,然后走默认的创建流程。所以,我们可以在此处创建自定义的Factory2
或Factory
,并将自定义的Factory2
或Factory
对象添加到LayoutInflater
对象当中,来对View
的创建进行干预,LayoutInflate
也提供了相关的API
供我们添加自己的ViewFactory
。
例如:下面我们通过设置LayoutInflater
的Factory
来,将视图中的Button
转换为TextView
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
LayoutInflater.from(this).setFactory(new LayoutInflater.Factory() {
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
for (int i = 0; i < attrs.getAttributeCount(); i ++){
String attrName = attrs.getAttributeName(i);
String attrValue = attrs.getAttributeValue(i);
Log.i(TAG, String.format("name = %s, attrName = %s, attrValue= %s", name, attrName, attrValue));
}
TextView textView = null;
if (name.equals("Button")){
textView = new TextView(context, attrs);
}
return textView;
}
});
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_theme_change);
}
让后启动Activity
后,视图中的Button
都转化成了TextView
,并且能看到输出:
name = Button, attrName = id, attrValue= @2131230758
name = Button, attrName = background, attrValue= @2131034152
name = Button, attrName = layout_width, attrValue= -2
name = Button, attrName = layout_height, attrValue= -2
name = Button, attrName = id, attrValue= @2131230757
name = Button, attrName = background, attrValue= @2131034150
name = Button, attrName = layout_width, attrValue= -2
name = Button, attrName = layout_height, attrValue= -2
Resource
对象上述过程已经提供了更改View
类型以及属性的方式,下面我们见介绍如何获取一个apk
压缩文件中的res
资源。
我们通常通过Context#getSource()
获取res
目录下的资源,Context#getAssets()
(想当于Context#getSource().getAssets()
)获取asset
目录下的资源。所以要获取一个apk
压缩文件的资源文件,创建对应该压缩文件的Resource
实例,然后通过这个实例获取压缩文件中的资源信息。
比如,新创建的的Resource
实例为mResource
,则可以使用mResource.getColor(colorId)
,来获取实例内colorId
所对应的颜色。
那么接下来的问题分为两步:
由Resource
的构造函数Resources(AssetManager assets, DisplayMetrics metrics, Configuration config)
了解到,需要获取app
外部apk
文件资源的Resource
对象,首先需要创建对应的AssetManager
对象。
public final class AssetManager implements AutoCloseable {
/**
* Create a new AssetManager containing only the basic system assets.
* Applications will not generally use this method, instead retrieving the
* appropriate asset manager with {@link Resources#getAssets}. Not for
* use by applications.
* {@hide}
*/
public AssetManager() {
synchronized (this) {
if (DEBUG_REFS) {
mNumRefs = 0;
incRefsLocked(this.hashCode());
}
init(false);
if (localLOGV) Log.v(TAG, "New asset manager: " + this);
ensureSystemAssets();
}
}
/**
* 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}
*/
//添加额外的asset路径
public final int addAssetPath(String path) {
synchronized (this) {
int res = addAssetPathNative(path);
if (mStringBlocks != null) {
makeStringBlocks(mStringBlocks);
}
return res;
}
}
所以通过反射可以创建对应的AssertManager
,进而创建出对应的Resource
实例,代码如下:
private final static Resources loadTheme(String skinPackageName, Context context){
String skinPackagePath = Environment.getExternalStorageDirectory() + "/" + skinPackageName;
File file = new File(skinPackagePath);
Resources skinResource = null;
if (!file.exists()) {
return skinResource;
}
try {
//创建AssetManager实例
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, skinPackagePath);
//构建皮肤资源Resource实例
Resources superRes = context.getResources();
skinResource = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
} catch (Exception e) {
skinResource = null;
}
return skinResource;
}
在Resource
的源码中,可以发现
public class Resources {
/**
* 通过给的资源名称,类型和包名返回一个资源的标识id。
* @param name 资源的描述名称
* @param defType 资源的类型名称
* @param defPackage 包名
*
* @return 返回资源id,0标识未找到该资源
*/
public int getIdentifier(String name, String defType, String defPackage) {
if (name == null) {
throw new NullPointerException("name is null");
}
try {
return Integer.parseInt(name);
} catch (Exception e) {
// Ignore
}
return mAssets.getResourceIdentifier(name, defType, defPackage);
}
}
也就是说在任意的apk
文件中,只需要知道包名(manifest.xml
中指定的包名,用于寻找资源和Java类)、资源类型名称、资源描述名称。
比如:在包A中有一个defType
为"color"
,name
为color_red_1
的属性,通过Resource#getIdentifier
则可以获取包B中该名称的颜色资源。
//将skina重View的背景色设置为com.example.skinb中所对应的颜色
if (attrValue.startsWith("@") && attrName.contains("background")){
int resId = Integer.parseInt(attrValue.substring(1));
int originColor = mContext.getResources().getColor(resId);
if (mResource == null){
return originColor;
}
String resName = mContext.getResources().getResourceEntryName(resId);
int skinRealResId = mResource.getIdentifier(resName, "color", "com.example.skinb");
int skinColor = 0;
try{
skinColor = mResource.getColor(skinRealResId);
}catch (Exception e){
Log.e(TAG, "", e);
skinColor = originColor;
}
view.setBackgroundColor(skinColor);
}
上述方法也是换肤框架Android-Skin-Loader
的基本思路。