话说什么是动态换肤?这里举个例子:在APP中可以下载某一个皮肤包,然后应用起来整个APP的界面就发生了改变,诸如某些图片,文字字体,文字颜色等等。
那么这种功能是怎么实现的呢?其实初步分析一把,应该就是在应用了皮肤包之后这些换肤了的控件的某些布局属性发生了变化,比如width、height、src、background、textsize、textcolor等。话说回来,在没有实现换肤功能之前我们的APP对控件进行属性指定一般都是写在属性文件中,比如android:textColor="@color/textColorDefault",我们会在专门的color.xml文件中定义这个颜色属性的具体value值,那么我们换肤时就应该是去替换color.xml文件中定义的textColorDefault这个属性值。
现在开始分析Android默认是在什么时候开始加载视图组件的。我们应该会联想到Activity的onCreate()方法里面我们都要去调用setContentView(int id) 来指定当前Activity的布局文件,就像这样:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
按照流程我们找到了这里:
@Override
public void setContentView(int resId) {
ensureSubDecor();
ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
contentParent.removeAllViews();
LayoutInflater.from(mContext).inflate(resId, contentParent);//这里实现view布局的加载
mOriginalWindowCallback.onContentChanged();
}
LayoutInflater的功能我们在fragment中应该很熟悉了,多说一句,在自定义viewGroup的时候我们也可以仿照这样的写法对自定义viewGroup指定默认的布局文件了。
好接下来我们顺藤摸瓜来到了LayoutInflater.java里面看看inflate是怎么实现的:
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
final String name = parser.getName();
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
return temp;
}
可以看到inflate会返回具体的View对象出去,那么我们的关注焦点就放在createViewFromTag中了。
/**
* Creates a view from a tag name using the supplied attribute set.
*
* Note: Default visibility so the BridgeInflater can
* override it.
*
* @param parent the parent view, used to inflate layout params
* @param name the name of the XML tag used to define the view
* @param context the inflation context for the view, typically the
* {@code parent} or base layout inflater context
* @param attrs the attribute set for the XML tag used to define the view
* @param ignoreThemeAttr {@code true} to ignore the {@code android:theme}
* attribute (if set) for the view being inflated,
* {@code false} otherwise
*/
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
try {
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;
}
return view;
} catch (Exception e) {
}
}
这里我们先看看这几个参数的意义,name指的是在layout.xml中给出的名称,例如:
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="skinSelect"
android:text="个性换肤"/>
这里拿到的name值就是“Button”。再如:
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:tabIndicatorColor="@color/tabSelectedTextColor"
app:tabTextColor="@color/tab_selector"/>
这里拿到的name值就是“com.dongnao.dnskin.widget.MyTabLayout”,或者:
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
这里拿到的name值就是“android.support.v4.view.ViewPager”。
因此我们要明确一点,参数name可能是View控件的java全路径名称,也有可能不是,比如第一种情况的Button,但是这种情况只会出现在系统已有控件里面,它们的包名我们是可以大胆猜测出来的,无非就是在这么几个包下面:
private static final String[]mClassPrefixList = {
"android.widget.",
"android.view.",
"android.webkit."
};
OK,分析完name参数,我们在看看AttributeSet是个什么梗,其实源码注释已经写得很清楚了,就是xml文件中对这个View给出的属性描述。
参数分析完了,我们看看方法体是怎么实现的。会发现生成View的时候会优先从mFactory2中的onCreateView里面去获取View对象,获取到了就直接返回。所以我们是不是可以自己去实现这个mFactory2来代替系统生成View对象?因为生成View对象的工作由我们自己来完成的话我们就可以很轻松的获取attrs参数,并且根据attrs对象知道在layout.xml中对这个View做了哪些属性描述,比如说拿到了background=“@drawable/bg_01”,当需要换肤的时候,我们就可以去皮肤包里面找到“@drawable/bg_01”这个资源,用来给这个View替换上去View.setBackground(...),那么我们的换肤功能不就实现了吗?答案也确实是这样做的。
接下来把精力放在怎么实现mFactory2上面,并且设置进入LayoutInflater中。前面我们知道Activity的setContentView()回去调用LayoutInflater.from(Context)拿到 LayoutInflater对象,代码如下:
/**
* Obtains the LayoutInflater from the given context.
*/
public static LayoutInflater from(Context context) {
LayoutInflater LayoutInflater =
(LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
if (LayoutInflater == null) {
throw new AssertionError("LayoutInflater not found.");
}
return LayoutInflater;
}
通过源码的注释也可以看到每一个Activity会有自己的LayoutInflater对象,此外LayoutInflater还暴露了mFactory2的set方法提供给我们:
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);
}
}
因此,这个setter就可以在每个Activity的onCreate之前进行调用,达到我们想要的目的。到了这里,相信我们会想到使用ActivityLifecycleCallbacks回调来监听Activity的各个生命周期回调,在onActivityCreated()进行mFactory2的初始化并且调用setter。
接下来,我们定义一个单例类SkinManager.java :
public class SkinManager extends Observable {
Application application;
private static SkinManager instance;
/**
* 客户端程序在application的onCreate()后调用.
* @param application
*/
public static void init(Application application) {
synchronized (SkinManager.class) {
if (null == instance) {
instance = new SkinManager(application);
}
}
}
public static SkinManager getInstance() {
return instance;
}
private SkinManager(Application application) {
this.application = application;
application.registerActivityLifecycleCallbacks(new SkinActivityLifecycleCallbacks());
}
SkinActivityLifecycleCallbacks.java如下:
public class SkinActivityLifecycleCallbacks implements Application.ActivityLifecycleCallbacks {
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
LayoutInflater layoutInflater = LayoutInflater.from(activity);
try {
Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
field.setAccessible(true);
field.setBoolean(layoutInflater, false);
} catch (Exception e) {
e.printStackTrace();
}
SkinLayoutFactory skinLayoutFactory = new SkinLayoutFactory(activity,typeface);
LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutFactory);
}
...
}
因为setFactory2()中有一个mFactorySet布尔类型的判断,我们使用了反射对mFactorySet置为true。
我们只需要在自定义application的onCreate()后面调用SkinManager.init()就完成了所有Activity的mFactory2设置。
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
SkinManager.init(this);
}
OK,到了这一步准备工作就做完了,我们重心放入自定义Factory2的实现中来。看看Factory2是个什么东西:
public interface Factory2 extends Factory {
/**
* Version of {@link #onCreateView(String, Context, AttributeSet)}
* that also supplies the parent that the view created view will be
* placed in.
*
* @param parent The parent that the created view will be placed
* in; note that this may be null.
* @param name Tag name to be inflated.
* @param context The context the view is being created in.
* @param attrs Inflation attributes as specified in XML file.
*
* @return View Newly created view. Return null for the default
* behavior.
*/
public View onCreateView(View parent, String name, Context context, AttributeSet attrs);
}
它是一个接口,声明了一个创建View的函数等待实现类去实现。我们的实现类如下:
public class SkinLayoutFactory implements LayoutInflater.Factory2, Observer {
private static final HashMap> sConstructorMap =
new HashMap>();
private static final String[] mClassPrefixList = {
"android.widget.",
"android.view.",
"android.webkit."
};
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
View view = createViewFromTag(name, context, attrs);
return view;
}
private View createViewFromTag(String name, Context context, AttributeSet attrs) {
//包含了. 自定义控件
if (name.contains(".")) {
return createView(name,context,attrs);
}
for (String tag : mClassPrefixList) {
View v = createView(tag + name, context, attrs);
if (null == v)
continue;
return v;
}
return null;
}
private View createView(String name, Context context, AttributeSet attrs) {
Constructor extends View> constructor = sConstructorMap.get(name);
if (null == constructor) {
try {
Class extends View> aClass = context.getClassLoader().loadClass(name).asSubclass(View.class);
constructor = aClass.getConstructor(Context.class, AttributeSet.class);
sConstructorMap.put(name, constructor);
} catch (Exception e) {
}
}
if (null != constructor) {
try {
return constructor.newInstance(context, attrs);
} catch (Exception e) {
}
}
return null;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
return null;
}
}
这里的实现其实也很简单,就是我们获取View的java全名称,然后通过反射机制获取View的构造方法进行实例化再返回。当然,我们这里还做了一个静态的map来缓存View的构造方法,可以优化一定的性能,毕竟反射多了总是不好的对吧(其实你仔细看了LayoutInflater的源码,它就是这么做的,我们这里借鉴一下)。
OK,按照前面给出的思路,我们自己构建mFactory2,代替系统来创建View对象,接下来还差一个步骤,就是通过attrs参数知道这个View在xml中被哪些属性描述了,我们需要一个机制来记录这个View被描述过了的并且可能会被皮肤包替换资源的属性名称还有默认的资源Id,在换肤时就去这些记录里面查找View及它的换肤属性的名称和资源Id,拿到默认资源Id后就可以知道这个资源的类型和名称,比如@string/s_name、@color/co_default_bg,然后拿着资源类型和名称去皮肤包中查找同类型同名称的资源,然后根据属性名称给这个View更改相应的表现。比如描述属性名称和资源类型名称是textColor=“@color/default_tx_color”,在皮肤包中找到“@color/default_tx_color”这个资源,给View.setTextColor(皮肤包中找到的资源),如果属性名称是background,那么就是View.setBackground(皮肤包中找到的资源),这样就达到了换肤效果。
接下来就是怎么去实现这个记录View及它的换肤属性和资源名称的机制了。
我们设计一套数据结构来记录这种关系。
记录需要换肤View及其属性名称和资源id的数据结构
其实可以在自定义Factory2返回View对象之前做这些工作,比如交给SkinAttribute对象去做。SkinAttribute以及SkinView、SkinPair代码如下:
public class SkinAttribute {
private static final List mAttributes = new ArrayList<>();//支持换肤的属性
static {
mAttributes.add("background");
mAttributes.add("src");
mAttributes.add("textColor");
mAttributes.add("drawableLeft");
mAttributes.add("drawableTop");
mAttributes.add("drawableRight");
mAttributes.add("drawableBottom");
}
private List skinViews = new ArrayList<>();
/**
* 筛选符合属性的view
*
* @param view
* @param attrs
*/
public void load(View view, AttributeSet attrs) {
List skinPairs = new ArrayList<>();
for (int i = 0; i < attrs.getAttributeCount(); i++) {
//获得属性名
String attributeName = attrs.getAttributeName(i);
if (mAttributes.contains(attributeName)) {
String attributeValue = attrs.getAttributeValue(i);
//写死了 不管了
if (attributeValue.startsWith("#")) {
continue;
}
//资源id
int resId = 0;
if (attributeValue.startsWith("?")) {//?开头, "?colorAccess" 对应主题中的属性名称id
int attrId = Integer.parseInt(attributeValue.substring(1));//属性id
//获得主题style中对应attr的资源id值
resId = SkinUtils.getResId(view.getContext(), new int[]{attrId})[0];
} else {//@开头 "@ID"
resId = Integer.parseInt(attributeValue.substring(1));
}
if (resId != 0) {
//可以替换的属性
SkinPair skinPair = new SkinPair(attributeName, resId);
skinPairs.add(skinPair);
}
}
}
if (!skinPairs.isEmpty() || view instanceof TextView) {
SkinView skinView = new SkinView(view, skinPairs);
skinViews.add(skinView);
}
}
static class SkinView {
View view;
/**
* 当前view支持换肤特性的属性与id键值对列表
*/
List skinPairs;
public SkinView(View view, List skinPairs) {
this.view = view;
this.skinPairs = skinPairs;
}
}
static class SkinPair {
/**
* 属性名称,例如:background,src,textColor等
*/
String attributeName;
/**
* 资源ID值
*/
int resId;
public SkinPair(String attributeName, int resId) {
this.attributeName = attributeName;
this.resId = resId;
}
}
}
上述代码中有一处需要清楚的是:String attributeValue = attrs.getAttributeValue() 返回的是一个字符串,例如textColor=“#ffffff”,那么attributeValue = “#ffffff”,textColor=“@color/default_color”,那么attributeValue = “@12345678”,这里的“12345678”指的就是“@color/default_color”对应的资源ID,类似的还有“@drawable/default_bg”等等。当然,还有一种情况就是textColor=“?colorAccess”,虽然程序最终引用的资源是style.xml中定义的属性值“colorAccent”指向的“@color/colorAccent”,
@color/colorAccent
这个时候attributeValue = “?12121212”,但是“12121212”并不是“@color/colorAccent”的资源ID,而是style属性“colorAccent”代表的ID,因此对待“?12121212”这样的情况我们还需要再去style.xml中去查找真正引用的资源ID。具体做法如下:
public class SkinUtils {
public static int[] getResId(Context context, int[] attrs) {
int[] resIds = new int[attrs.length];
TypedArray typedArray = context.obtainStyledAttributes(attrs);
for (int i = 0; i < typedArray.length(); i++) {
resIds[i] = typedArray.getResourceId(i, 0);
}
typedArray.recycle();
return resIds;
}
}
到了这里,相信你也就知道了SkinLayoutFactory该怎么改造了:
public class SkinLayoutFactory implements LayoutInflater.Factory2{
......
private SkinAttribute skinAttribute;
public SkinLayoutFactory() {
skinAttribute = new SkinAttribute();
}
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
View view = createViewFromTag(name, context, attrs);
skinAttribute.load(view,attrs);//在返回view之前维护我们需要的 属性-资源 关系数据结构
return view;
}
......
}
OK,现在需要换肤控件的资源信息也采集到了,接下来就是怎么去实现换肤了。
换肤之前我们要清楚什么是皮肤包,又该怎么把皮肤包加载到系统里面供我们获取资源并使用。
皮肤包其实就是一个apk文件,只不过内部只包含资源文件,我们的皮肤包目录结构如下:
皮肤包工程目录结构
接下来,我们该如何把一个皮肤包加载进项目中,并且根据资源类型和名称来获取指定资源的Id呢?
首先是将皮肤包加载进入项目,我们会用到AssetManager这个工具:
/**
* 加载皮肤包 并 立即通知观察者更新
*
* @param path 皮肤包路径
*/
public void loadSkin(String path) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
// 添加资源进入资源管理器
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String
.class);
addAssetPath.setAccessible(true);
addAssetPath.invoke(assetManager, path);
//app默认资源
Resources resources = application.getResources();
//皮肤包资源
Resources skinResource = new Resources(assetManager, resources.getDisplayMetrics(),
resources.getConfiguration());
//获取外部Apk(皮肤包) 包名
PackageManager mPm = application.getPackageManager();
PackageInfo info = mPm.getPackageArchiveInfo(path, PackageManager
.GET_ACTIVITIES);
String packageName = info.packageName;
} catch (Exception e) {
e.printStackTrace();
}
}
}
上述代码我们传入皮肤包的路径,通过AssetManager获取到Resource对象,其实到这一步就已经将皮肤包的资源文件加载进来了。
那么加载到了皮肤包的Resource对象,我们该如何通过APP程序默认的一个资源id去拿到在皮肤包中同类型同名称的这个资源的id呢?
public int getIdentifier(int resId) {
if (isDefaultSkin) {
return resId;
}
//在皮肤包中不一定就是 当前程序的 id
//获取对应id 在当前的名称 colorPrimary
//R.drawable.ic_launcher
String resName = mAppResources.getResourceEntryName(resId);//ic_launcher
String resType = mAppResources.getResourceTypeName(resId);//drawable
int skinId = mSkinResources.getIdentifier(resName, resType, mSkinPkgName);
return skinId;
}
这个方法其实就是将默认资源id转化成皮肤包中对应资源的id,获取到了id我们就可以通过Resources.getXXX(int id)来拿到想要的资源了。
到了这个时候,相信我们都有了一个完整的换肤思路了:
①重写Factory2,代替系统创建View对象,在这期间记录下 需要换肤控件的 属性名-资源ID 的集合。
②通过AssetManager加载外部的皮肤包资源Resource,通过默认的资源ID找到在皮肤包中对应的资源ID,通过属性名称去动态修改View的具体表现。
③开始换肤时,我们可以使用观察者模式来通知所有还未销毁的Activity持有的 SkinLayoutFactory(作为观察者),让SkinLayoutFactory去遍历其下面的所有 SkinView来完成应用换肤资源的工作。
/**
* 对当前view进行支持换肤的属性进行配置,应用原生或者皮肤包的资源.
* @param typeface
*/
public void applySkin() {
for (SkinPair skinPair : skinPairs) {
Drawable left = null, top = null, right = null, bottom = null;
switch (skinPair.attributeName) {
case "background":
Object background = SkinResources.getInstance().getBackground(skinPair
.resId);
//Color
if (background instanceof Integer) {
view.setBackgroundColor((Integer) background);
} else {
ViewCompat.setBackground(view, (Drawable) background);
}
break;
case "src":
background = SkinResources.getInstance().getBackground(skinPair
.resId);
if (background instanceof Integer) {
((ImageView) view).setImageDrawable(new ColorDrawable((Integer)
background));
} else {
((ImageView) view).setImageDrawable((Drawable) background);
}
break;
case "textColor":
((TextView) view).setTextColor(SkinResources.getInstance().getColorStateList
(skinPair.resId));
break;
case "drawableLeft":
left = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableTop":
top = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableRight":
right = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableBottom":
bottom = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
default:
break;
}
if (null != left || null != right || null != top || null != bottom) {
((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,
bottom);
}
}
}
上述代码清晰的展示了通过属性名称来做出不同View展示调整的逻辑。
我们再来看下SkinResources.java的代码:
public class SkinResources {
private static SkinResources instance;
private Resources mSkinResources;
private String mSkinPkgName;
private boolean isDefaultSkin = true;
private Resources mAppResources;
private SkinResources(Context context) {
mAppResources = context.getResources();
}
public static void init(Context context) {
if (instance == null) {
synchronized (SkinResources.class) {
if (instance == null) {
instance = new SkinResources(context);
}
}
}
}
public static SkinResources getInstance() {
return instance;
}
public void reset() {
mSkinResources = null;
mSkinPkgName = "";
isDefaultSkin = true;
}
public void applySkin(Resources resources, String pkgName) {
mSkinResources = resources;
mSkinPkgName = pkgName;
//是否使用默认皮肤
isDefaultSkin = TextUtils.isEmpty(pkgName) || resources == null;
}
public int getIdentifier(int resId) {
if (isDefaultSkin) {
return resId;
}
//在皮肤包中不一定就是 当前程序的 id
//获取对应id 在当前的名称 colorPrimary
//R.drawable.ic_launcher
String resName = mAppResources.getResourceEntryName(resId);//ic_launcher
String resType = mAppResources.getResourceTypeName(resId);//drawable
int skinId = mSkinResources.getIdentifier(resName, resType, mSkinPkgName);
return skinId;
}
public int getColor(int resId) {
if (isDefaultSkin) {
return mAppResources.getColor(resId);
}
int skinId = getIdentifier(resId);
if (skinId == 0) {
return mAppResources.getColor(resId);
}
return mSkinResources.getColor(skinId);
}
public ColorStateList getColorStateList(int resId) {
if (isDefaultSkin) {
return mAppResources.getColorStateList(resId);
}
int skinId = getIdentifier(resId);
if (skinId == 0) {
return mAppResources.getColorStateList(resId);
}
return mSkinResources.getColorStateList(skinId);
}
public Drawable getDrawable(int resId) {
//如果有皮肤 isDefaultSkin false 没有就是true
if (isDefaultSkin) {
return mAppResources.getDrawable(resId);
}
int skinId = getIdentifier(resId);
if (skinId == 0) {
return mAppResources.getDrawable(resId);
}
return mSkinResources.getDrawable(skinId);
}
/**
* 可能是Color 也可能是drawable
*
* @return
*/
public Object getBackground(int resId) {
String resourceTypeName = mAppResources.getResourceTypeName(resId);
if (resourceTypeName.equals("color")) {
return getColor(resId);
} else {
// drawable
return getDrawable(resId);
}
}
public String getString(int resId) {
try {
if (isDefaultSkin) {
return mAppResources.getString(resId);
}
int skinId = getIdentifier(resId);
if (skinId == 0) {
return mAppResources.getString(skinId);
}
return mSkinResources.getString(skinId);
} catch (Resources.NotFoundException e) {
}
return null;
}
}
OK,整个换肤原理基本就讲完了,当然还有字体的动态全局及单个切换,自定义view的自定义属性切换,某些控件加载时序问题导致无法换肤等问题,后面继续补充。