转载请注明出处:http://blog.csdn.net/llew2011/article/details/51287391
在上篇文章Android 源码系列之<四>从源码的角度深入理解LayoutInflater.Factory之主题切换(上)中我们主要讲解了LayoutInflater渲染xml布局文件的流程,文中讲到如果在渲染之前为LayoutInflater设置了Factory,那么在渲染每一个View视图时都会调用Factory的onCreateView()方法,因此可以拿onCreateView()方法做切入口实现主题切换功能。如果你不清楚LayoutInflater的渲染流程,请点击这里。今天我们就从实战出发来实现自己的主题切换功能。
既然主题切换是依赖Factory的,那么就需要定义自己的Factory了,自定义Factory其实就是实现系统的Factory接口,代码如下:
public class SkinFactory implements Factory {
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
Log.e("SkinFactory", "==============start==============");
int attrCounts = attrs.getAttributeCount();
for(int i = 0; i < attrCounts; i++) {
String attrName = attrs.getAttributeName(i);
String attrValue = attrs.getAttributeValue(i);
Log.e("SkinFactory", "attrName = " + attrName + " attrValue = " + attrValue);
}
Log.e("SkinFactory", "==============end==============");
return null;
}
}
自定义SkinFactory什么都没有做,仅仅在onCreateView()方法中循环打印了attrs包含的属性名和对应的属性值,然后返回了null。创建完SkinFactory之后就是如何使用它了,
上篇文章中我们讲过在Activity中可以通过getLayoutInflater()方法获取LayoutInflater实例对象,获取到该对象之后就可以给该其赋值Factory了,代码如下:
public class MainActivity extends Activity {
private LayoutInflater mInflater;
private SkinFactory mFactory;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mFactory = new SkinFactory();
mInflater = getLayoutInflater();
mInflater.setFactory(mFactory);
setContentView(R.layout.activity_skin);
}
}
需要注意的是给Activity的LayoutInflater设置Factory时一定要在调用setContentView()方法之前,否则不起作用。设置好Factory之后,我们看看一下activity_skin.xml布局文件是如何定义的,代码如下:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/color_app_bg" >
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="Factory的小练习"
android:textColor="@color/color_title_bar_text" />
</FrameLayout>
布局文件居中显示了一个TextView,并且给TextView设置文本为"Factory的小练习",运行一下程序,打印结果如下:
这里只贴出了TextView的打印数据,从打印出的数据可以发现如果属性值是以@开头就表示该属性值是一个应用(以后可以通过@符号来判断当前属性是否是引用)。因为我们可以在attrs中拿到View在布局文件中定义的所有属性,所以可以猜想:如果给View添加自定义属性,在onCreateView()方法中通过解析这个自定义属性就可以判别出要做主题切换的View了。这个猜想正不正确,我们来试验一下。
在values文件夹下创建attrs.xml属性文件,定义属性名为enable,属性值为boolean类型(true表示需要主题切换,false表示不需要主题切换),代码如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="enable" format="boolean" />
</resources>
定义完属性后,若要使用该属性需要先申明命名空间,比如系统自带的:xmlns:android="http://sckemas.android.com/apk/res/android",申明命名空间有两种方法:xmlns:skin="http://schemas.android.com/apk/包名"或者是xmlns:skin="http://schemas.android.com/apk/res-auto"。我们采用第二种写法,代码如下:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:skin="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/color_app_bg" >
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="Factory的小练习"
skin:enable="true"
android:textColor="@color/color_title_bar_text" />
</FrameLayout>
在activity_skin.xml布局文件中给TextView添加了自定义的enable属性并把值设为true,添加完属性后编译器报错提示说TextView没有该属性,只要手动清理一下就好了。然后运行代码,打印结果如下:
看到打印结果我们心里好happy呀,采用给View添加自定义的属性这种方式是OK的,接下来我们就可以根据该属性区分出哪些View需要做主题切换了。做主题切换的前提是缓存那些需要做主题切换的View,但是View做主题切换可能需要更改背景,文字等。也就说一个View可能要更改多个属性,那这个属性就要求在不同的场景下对应不同的类型,所以可以抽象出代表属性的类BaseAttr,BaseAttr类有属性名,属性值,属性类型等成员变量,还要有一个抽象方法(该方法在不同的场景下有不同的实现,比如当前属性为background,那在BackgroundAttr实现中就应该是设置背景;若当前属性为textColor,那在TextColorAttr实现中就应该是设置文字颜色)。所以BaseAttr可以抽象如下:
public abstract class BaseAttr {
public String attrName;
public int attrValue;
public String entryName;
public String entryType;
public abstract void apply(View view);
}
定义好BaseAttr类之后就可以定义具体的实现类了,比如背景属性类BackgroundAttr,字体颜色改变类TextColorAttr等,BackgroundAttr代码如下:
public class BackgroundAttr extends BaseAttr {
@Override
public void apply(View view) {
if(null != view) {
view.setBackgroundXXX();
}
}
}
抽象出属性类BaseAttr之后我们还要考虑缓存View的问题,因为一个View可能要对应多个BaseAttr,所以我们还要封装一个类SkinView,该类表示一个View对应多个BaseAttr,它还要提供更新自己的方法,所以代码如下:
public class SkinView {
public View view;
public List<BaseAttr> viewAttrs;
public void apply() {
if(null != view && null != viewAttrs) {
for(BaseAttr attr : viewAttrs) {
attr.apply(view);
}
}
}
}
抽象属性类BaseAttr和SkinView定义完了,接下来就可以在SkinFactory中做缓存逻辑了,代码如下:
public class SkinFactory implements Factory {
private static final String DEFAULT_SCHEMA_NAME = "http://schemas.android.com/apk/res-auto";
private static final String DEFAULT_ATTR_NAME = "enable";
private List<SkinView> mSkinViews = new ArrayList<SkinView>();
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
View view = null;
final boolean skinEnable = attrs.getAttributeBooleanValue(DEFAULT_SCHEMA_NAME, DEFAULT_ATTR_NAME, false);
if(skinEnable) {
view = createView(name, context, attrs);
if(null != view) {
parseAttrs(name, context, attrs, view);
}
}
return view;
}
public final View createView(String name, Context context, AttributeSet attrs) {
View view = null;
if(-1 == name.indexOf('.')) {
if("View".equalsIgnoreCase(name)) {
view = createView(name, context, attrs, "android.view.");
}
if(null == view) {
view = createView(name, context, attrs, "android.widget.");
}
if(null == view) {
view = createView(name, context, attrs, "android.webkit.");
}
} else {
view = createView(name, context, attrs, null);
}
return view;
}
View createView(String name, Context context, AttributeSet attrs, String prefix) {
View view = null;
try {
view = LayoutInflater.from(context).createView(name, prefix, attrs);
} catch (Exception e) {
}
return view;
}
private void parseAttrs(String name, Context context, AttributeSet attrs, View view) {
int attrCount = attrs.getAttributeCount();
final Resources temp = context.getResources();
List<BaseAttr> viewAttrs = new ArrayList<BaseAttr>();
for(int i = 0; i < attrCount; i++) {
String attrName = attrs.getAttributeName(i);
String attrValue = attrs.getAttributeValue(i);
if(isSupportedAttr(attrName)) {
if(attrValue.startsWith("@")) {
int id = Integer.parseInt(attrValue.substring(1));
String entryName = temp.getResourceEntryName(id);
String entryType = temp.getResourceTypeName(id);
BaseAttr viewAttr = createAttr(attrName, attrValue, id, entryName, entryType);
if(null != viewAttr) {
viewAttrs.add(viewAttr);
}
}
}
}
if(viewAttrs.size() > 0) {
SkinView skinView = new SkinView();
skinView.view = view;
skinView.viewAttrs = viewAttrs;
mSkinViews.add(skinView);
}
}
// attrName:textColor attrValue:2130968576 entryName:common_bg_color entryType:color
private BaseAttr createAttr(String attrName, String attrValue, int id, String entryName, String entryType) {
BaseAttr viewAttr = null;
if("background".equalsIgnoreCase(attrName)) {
viewAttr = new BackgroundAttr();
} else if("textColor".equalsIgnoreCase(attrName)) {
viewAttr = new TextColorAttr();
}
if(null != viewAttr) {
viewAttr.attrName = attrName;
viewAttr.attrValue = id;
viewAttr.entryName = entryName;
viewAttr.entryType = entryType;
}
return viewAttr;
}
private boolean isSupportedAttr(String attrName) {
if("background".equalsIgnoreCase(attrName)) {
return true;
} else if("textColor".equalsIgnoreCase(attrName)) {
return true;
}
return false;
}
public void applaySkin() {
if(null != mSkinViews) {
for(SkinView skinView : mSkinViews) {
if(null != skinView.view) {
skinView.apply();
}
}
}
}
}
SkinFactory中定义了装载SkinView类型的mSkinViews缓存集合,当解析到符合条件的View时就会缓存到该集合中。在onCreateView()方法中调用AttributeSet的getAttributeBooleanValue()方法检测是否含有enable属性,如果有enable属性并且属性值为true时我们自己调用系统API来创建View,如果创建成功就解析该View,分别获取其attrName,attrValue,entryName,entryType值取完之后创建对应的BaseAttr,然后加入缓存集合mSkinViews中,否则返回null。
创建完SkinFactory之后还需要创建一个主题资源管理器SkinManager,主题切换就是通过该管理器来决定的。所以其主要有以下功能:实现读取额外主题资源功能,恢复默认主题功能,更新主题功能等。
先看一下如何读取额外主题资源问题。做主题切换需要准备多套主题,这些主题其实就是一些图片,颜色等。有了素材之后我们还要考虑如何提供给APP素材的形式,是直接提供一个Zip包文件还是说做成一个apk文件的形式提供给APP?如果提供Zip包接下来的处理是解压该Zip包得到里边的素材然后解析读取,理论上来说这种方式是可行的,但是操作起来有点复杂。所以我们采用apk的形式,若希望访问素材apk中的资源如同在APP中访问资源一样,我们得获取到素材apk的Resources实例,下面我直接提供一种通用的可以获取apk的Resources实例代码,代码如下:
public final Resources getResources(Context context, String apkPath) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
addAssetPath.setAccessible(true);
addAssetPath.invoke(assetManager, apkPath);
Resources r = context.getResources();
Resources skinResources = new Resources(assetManager, r.getDisplayMetrics(), r.getConfiguration());
return skinResources;
} catch (Exception e) {
}
return null;
}
这段代码可以有效的获取到apk中的Resources实例,然后通过该Resources实例访问资源就如同我们在APP中直接访问自己资源一般,如果你对Android的资源访问机制很熟悉的话,很清楚这段代码为什么要这么写。不清楚也没关系,先暂时这么用,我会在后续文章中从源码的角度分析一下Android的资源访问机制并解释这么写的原因。
好了,现在我们已经解决了访问素材资源的问题,那接下来就是编写我们的SkinManager类了,SkinManager类的功能是来加载素材资源文件的,在加载文件时可能有失败的情况,所以需要给APP回调来通知加载资源的结果,我们定义接口ILoadListener,代码如下:
public interface ILoadListener {
void onStart();
void onSuccess();
void onFailure();
}
ILoadListener接口有三个方法,分别表示资源开始加载的回调,加载成功后的回调和加载失败后的回调。我们接着完成我们SkinManager代码,如下所示:
public final class SkinManager {
private static final Object mClock = new Object();
private static SkinManager mInstance;
private Context mContext;
private Resources mResources;
private String mSkinPkgName;
private SkinManager() {
}
public static SkinManager getInstance() {
if(null == mInstance) {
synchronized (mClock) {
if(null == mInstance) {
mInstance = new SkinManager();
}
}
}
return mInstance;
}
public void init(Context context) {
enableContext(context);
mContext = context.getApplicationContext();
}
public void loadSkin(String skinPath) {
loadSkin(skinPath, null);
}
public void loadSkin(final String skinPath, final ILoadListener listener) {
enableContext(mContext);
if(TextUtils.isEmpty(skinPath)) {
return;
}
new AsyncTask<String, Void, Resources>() {
@Override
protected void onPreExecute() {
if(null != listener) {
listener.onStart();
}
}
@Override
protected Resources doInBackground(String... params) {
if(null != params && params.length == 1) {
String skinPath = params[0];
File file = new File(skinPath);
if(null != file && file.exists()) {
PackageManager packageManager = mContext.getPackageManager();
PackageInfo packageInfo = packageManager.getPackageArchiveInfo(skinPath, 1);
if(null != packageInfo) {
mSkinPkgName = packageInfo.packageName;
}
return getResources(mContext, skinPath);
}
}
return null;
}
@Override
protected void onPostExecute(Resources result) {
if(null != result) {
mResources = result;
if(null != listener) {
listener.onSuccess();
}
} else {
if(null != listener) {
listener.onFailure();
}
}
}
}.execute(skinPath);
}
public Resources getResources(Context context, String apkPath) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
addAssetPath.setAccessible(true);
addAssetPath.invoke(assetManager, apkPath);
Resources r = context.getResources();
Resources skinResources = new Resources(assetManager, r.getDisplayMetrics(), r.getConfiguration());
return skinResources;
} catch (Exception e) {
}
return null;
}
public void restoreDefaultSkin() {
if(null != mResources) {
mResources = null;
mSkinPkgName = null;
}
}
public int getColor(int id) {
enableContext(mContext);
Resources originResources = mContext.getResources();
int originColor = originResources.getColor(id);
if(null == mResources || TextUtils.isEmpty(mSkinPkgName)) {
return originColor;
}
String entryName = mResources.getResourceEntryName(id);
int resourceId = mResources.getIdentifier(entryName, "color", mSkinPkgName);
try {
return mResources.getColor(resourceId);
} catch (Exception e) {
}
return originColor;
}
public Drawable getDrawable(int id) {
enableContext(mContext);
Resources originResources = mContext.getResources();
Drawable originDrawable = originResources.getDrawable(id);
if(null == mResources || TextUtils.isEmpty(mSkinPkgName)) {
return originDrawable;
}
String entryName = mResources.getResourceEntryName(id);
int resourceId = mResources.getIdentifier(entryName, "drawable", mSkinPkgName);
try {
return mResources.getDrawable(resourceId);
} catch (Exception e) {
}
return originDrawable;
}
private void enableContext(Context context) {
if(null == context) {
throw new NullPointerException();
}
}
}
SkinManager我们采用了单例模式保证应用中只有一个实例,在使用的时候需要先进行初始化操作否则会抛异常。SkinManager不仅定义了属性mContext和mResources(mContext表示APP的运行上下文环境,mResources代表资源apk的Resources实例对象,如果为空表示使用默认APP主题资源),而且它还对外提供了一系列方法,比如读取资源的getColor()和getDrawable()方法,加载资源apk的方法loadSkin()等。
现在主题切换的核心逻辑都有了,我们看一下程序包结构图是怎样的,切图如下:
主题切换的核心逻差不多已经然完成了,接下来就是要练习使用一下看看效果能不能成了,首先修改activity_skin.xml布局文件,修改如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:skin="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/common_bg_color"
android:orientation="vertical"
skin:enable="true" >
<FrameLayout
android:layout_width="match_parent"
android:layout_height="65dp"
android:background="@color/common_title_bg_color"
skin:enable="true" >
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="主题切换标题"
android:textColor="@color/common_title_text_color"
android:textSize="18sp"
skin:enable="true" />
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="right|center_vertical"
android:onClick="updateSkin"
android:text="切换主题" />
</FrameLayout>
</LinearLayout>
在activity_skin.xml布局文中给需要做主题切换的View节点添加了enable属性并且设置其值为true。接下来就是要做一个主题apk包了,做主题包的简单方式就是新建一个工程,里边不添加Activity等,然后在资源文件夹下创建对应的资源等,需要注意的是资源文件名一定要和APP中的资源名一致。然后编译打包成一个apk文件,这里就不再演示了。打包完apk后我们导入到模拟器根目录下,然后修改MainActivity,添加updateSkin()方法,代码如下:
public void updateSkin(View view) {
String skinPath = Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + "skin.apk";
SkinManager.getInstance().loadSkin(skinPath, new ILoadListener() {
@Override
public void onSuccess() {
mFactory.applaySkin();
}
@Override
public void onStart() {
}
@Override
public void onFailure() {
}
});
}
添加完updateSkin()方法之后,就可以实现切换主题了,为了方便我直接把skin.apk文件直接导入了SD卡根目录下,
需要注意有的手机没有外置存储卡需要做个判断,别忘了在配置文件添加文件的读写权限,然后运行程序,效果如下:
好了,现在在当前页面进行主题切换看起来是OK的,但是还存在不足,当页面进行跳转比如从A→B→C→D然后在D中进行主题切换,这时候ABC是没有效果的,另外代码的通用性也不强,所以在下篇文章Android 源码系列之<六>从源码的角度深入理解LayoutInflater.Factory之主题切换(下)处理这些问题,敬请期待...