移动架构04-动态换肤框架
一、前言
换肤就是修改app的样式(包括文字、颜色、背景等),通常用来提升用户体验。
换肤方式分为内置换肤和动态换肤。
内置换肤是在Apk包中存在多种资源(图片、颜色值)用于换肤时候切换。缺点是自由度低,apk文件大。 一般用于没有其他需求的日间/夜间模式app 。
动态换肤是通过运行时动态加载皮肤包。比较典型的是高德地图和网易云音乐。
高德地图是通过更新配置文件来替换样式。适用于简单的换肤,如:修改颜色。
网易云音乐是通过更换完整的皮肤包(APK文件)来替换样式。功能最强大,同时最占资源。
二、动态换肤应用
先说说这个框架怎么用?
1、基础应用
应用很简单,分为初始化和设置皮肤两步。
初始化需要在Application中进行。
public class MyApplication extends android.app.Application {
@Override
public void onCreate() {
super.onCreate();
SkinManager.init(this);
}
}
然后在Activity中设置皮肤。设置皮肤前,需要下载皮肤。这里就省略下载的步骤了。
/**
* 一键换肤demo
*/
public class SkinActivity extends Activity {
...
/**
* 下载皮肤包
*/
public void downloadSkin() {
newPath = new File(getFilesDir(), assetPath).getAbsolutePath();
...
}
/**
* 换皮肤
*
* @param view
*/
public void change(View view) {
SkinManager.getInstance().loadSkin(newPath);
}
/**
* 还原皮肤
*
* @param view
*/
public void restore(View view) {
SkinManager.getInstance().loadSkin(null);
}
}
注意:皮肤包就是一个APK文件,将一个没有代码的Module通过Android Studio的Build-Build APK(s)工具生产APK文件即可。皮肤包的资源属性名要与APP的资源属性名保持一致。
2、状态栏和导航栏
在5.0以上版本,状态栏和导航栏属性可以定义在主题中,设置属性后,在皮肤包中配置同名资源可以实现状态栏换肤。
为了提高兼容性,在vaule-v21目录中创建styles.xml:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="BaseTheme">
<!-- Customize your theme here. -->
<item name="android:statusBarColor">@color/colorPrimaryDark</item>
<item name="android:navigationBarColor">@color/colorPrimaryDark</item>
</style>
</resources>
然后在皮肤包中设置colorPrimaryDark资源就OK了。
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#1F1F1F</color>
</resources>
3、字体
设置全局字体,在Theme中设置skinTypeface属性,值为assets中字体的路径:
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
...
<item name="skinTypeface">@string/typeface1</item>
</style>
设置特定字体,在布局文件中设置skinTypeface属性,值为assets中字体的路径:
<TextView
skinTypeface="@string/typeface2"
...
tools:ignore="MissingPrefix" />
例如:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="typeface1">font/global.ttf</string>
<string name="typeface2">font/specified.ttf</string>
</resources>
4、自定义控件
对于自定义的控件的支持性不够,需要实现SkinViewSupport接口,在换肤时,调用其applySkin(),从而修改样式。
public class CircleView extends View implements SkinViewSupport {
...
@Override
public void applySkin() {
if (corcleColorResId != 0) {
int color = SkinResources.getInstance().getColor(corcleColorResId);
setCorcleColor(color);
}
}
}
三、动态换肤原理
本文采用网易云音乐的动态换肤方式。
换肤的本质就是修改样式。
首先,哪些View需要修改样式?
我们可以在Activity创建时,筛选指定类型和属性的View,并缓存下来。当指定皮肤包后,就可筛选这些View的样式。
然后,改成什么样式?
皮肤包就是APK文件,加载到内存后就可以获取其资源。通过当前View的资源ID从皮肤包中获取对应资源,就是需要修改的样式了。
四、动态换肤实现
动态换肤的实现分为获取View、获取样式、应用样式3步。
1、获取View
动态换肤框架是针对整个应用的,也就是要修改所有Activity的样式。通过Application.ActivityLifecycleCallbacks监听所有的Activity,在Activity创建时获取View。
class SkinActivityLifeCallback implements Application.ActivityLifecycleCallbacks {
...
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
//获取Activity的布局加载器
LayoutInflater layoutInflater = LayoutInflater.from(activity);
try {
//将布局加载器的mFactorySet属性为false,这样就会使用Factory2来加载view
Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
field.setAccessible(true);
field.setBoolean(layoutInflater, false);
} catch (Exception e) {
e.printStackTrace();
}
SkinLayoutFactory skinLayoutFactory = new SkinLayoutFactory();
//兼容低版本的写法:设置Factory2
LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutFactory);
//用于手动换肤
SkinManager.getInstance().addObserver(skinLayoutFactory);
skinLayoutFactorys.put(activity, skinLayoutFactory);
}
...
}
Activity是通过LayoutInflater来创建View的,LayoutInflater会优先使用Factory2来创建View。而这个Factory2是可以指定的,所以使用自定义的Factory2就可以获取View并设置样式了。
class SkinLayoutFactory implements LayoutInflater.Factory2, Observer {
...
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
View view = createViewFromTag(name, context, attrs);
if (view == null) {
view = createView(name, context, attrs);
}
//筛选需要换肤的View
skinAttribute.load(view, attrs);
return view;
}
...
}
并不是所有的View都需要换肤,之筛选包含指定属性的View和指定类型的View。View获取到后,就保存下来,方便以后修改样式。
/**
* View的属性集合
*/
class SkinAttribute {
...
/**
* 筛选View
*
* @param view
* @param attrs
*/
public void load(View view, AttributeSet attrs) {
List<SkinPair> skinPairs = new ArrayList<>();
for (int i = 0; i < attrs.getAttributeCount(); i++) {
//获取属性名
String attributeName = attrs.getAttributeName(i);
//判断属性是否需要处理
if (attributes.contains(attributeName)) {
String attributeValue = attrs.getAttributeValue(i);
//不处理写死的属性值
if (attributeName.startsWith("#")) {
continue;
}
int resID;
//以?开的的资源,定义在theme的attr属性中
if (attributeValue.startsWith("?")) {
//主题中的属性ID
int attrID = Integer.parseInt(attributeValue.substring(1));
//实际的资源ID
resID = SkinThemeUtils.getResID(view.getContext(), new int[]{attrID})[0];
} else {
resID = Integer.parseInt(attributeValue.substring(1));
}
//可被替换的属性
if (resID != 0) {
SkinPair skinPair = new SkinPair(attributeName, resID);
skinPairs.add(skinPair);
}
}
}
//将view与之对应的可动态替换的属性放入集合
if (!skinPairs.isEmpty()) {
SkinView skinView = new SkinView(view, skinPairs);
skinView.applySkin();
skinViews.add(skinView);
}
}
...
}
2、获取样式
样式来自于皮肤包,所以先下载皮肤包。下载的皮肤包保存到SD卡中,通过AssetManager加载到内存中,得到皮肤包的Resources对象。
/**
* 换肤管理器
*/
public class SkinManager extends Observable {
/**
* 加载皮肤包并更新view
*
* @param path 皮肤包路径
*/
public void loadSkin(String path) {
...
AssetManager assetManager = AssetManager.class.newInstance();
//将皮肤包添加到资源管理器
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.setAccessible(true);
addAssetPath.invoke(assetManager, path);
Resources resources = application.getResources();
//获取默认资源的横竖屏与语言参数
Resources newResource = new Resources(assetManager, resources.getDisplayMetrics(), resources.getConfiguration());
//获取皮肤包的包名
PackageManager packageManager = application.getPackageManager();
PackageInfo info = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
String packageName = info.packageName;
//加载皮肤包
SkinResources.getInstance().applySkin(newResource, packageName);
//保存当前使用的皮肤包
SkinPreference.getInstance().setSkin(path);
...
}
}
注意:皮肤包下载后,要保存路径到首选项中,方便下次直接调用。
获取到皮肤包的Resources对象后,就可以根据View的资源ID获取新样式。根据View的资源ID获取资源名,在根据资源名获取皮肤包中的同名资源,即为新的资源。然后根据皮肤包的资源ID,获取资源对象。
/**
* 资源获取工具类
*/
class SkinResources {
...
/**
* 根据资源ID获取新皮肤的ID
*
* @param resID
* @return
*/
public int getIdentifier(int resID) {
if (isDefaultSkin) {
return resID;
}
//资源名称
String resName = appResources.getResourceEntryName(resID);
//资源类型
String resType = appResources.getResourceTypeName(resID);
//新皮肤的ID
int id = newResources.getIdentifier(resName, resType, skinPackageName);
return id;
}
/**
* 返回新皮肤的Color
*
* @param resId
* @return
*/
public int getColor(int resId) {
if (isDefaultSkin) {
return appResources.getColor(resId);
}
int skinId = getIdentifier(resId);
if (skinId == 0) {
return appResources.getColor(resId);
}
return newResources.getColor(skinId);
}
...
}
3、设置样式
这里使用观察者模式,当皮肤包下载后,就更新缓存View的样式。
/**
* 布局加载器
* 用来设置view的属性
*/
class SkinLayoutFactory implements LayoutInflater.Factory2, Observer {
...
@Override
public void update(Observable o, Object arg) {
//换皮肤
skinAttribute.applySkin();
}
...
}
更新View的样式时,需要筛选属性。
/**
* 用来处理view和对应的属性
*/
class SkinView {
...
/**
* 换皮肤
*/
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);
//如果是Color
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":
left = SkinResources.getInstance().getDrawable(skinPair.resID);
break;
case "drawableRight":
left = SkinResources.getInstance().getDrawable(skinPair.resID);
break;
case "drawableBottom":
left = SkinResources.getInstance().getDrawable(skinPair.resID);
break;
default:
break;
}
if (left != null || right != null || top != null || bottom != null) {
((TextView) view).setCompoundDrawables(left, top, right, bottom);
}
}
}
}
五、动态换肤扩展
1、Fragment的扩展
获取View是通过设置Factory2来实现的,所以只需要色画质Fragment的Factory2就可以了。但是Fragment会优先使用Activity的Factory2,所以不需要重新设置了。
public class Fragment implements ComponentCallbacks2, OnCreateContextMenuListener {
...
/**
* Returns the LayoutInflater used to inflate Views of this Fragment. The default
* implementation will throw an exception if the Fragment is not attached.
*
* @return The LayoutInflater used to inflate Views of this Fragment.
*/
public LayoutInflater onGetLayoutInflater(Bundle savedInstanceState) {
if (mHost == null) {
throw new IllegalStateException("onGetLayoutInflater() cannot be executed until the "
+ "Fragment is attached to the FragmentManager.");
}
final LayoutInflater result = mHost.onGetLayoutInflater();
if (mHost.onUseFragmentManagerInflaterFactory()) {
getChildFragmentManager(); // Init if needed; use raw implementation below.
result.setPrivateFactory(mChildFragmentManager.getLayoutInflaterFactory());
}
return result;
}
...
}
2、状态栏和导航栏的扩展
状态栏和导航栏可以通过Activity直接获取,不需要通过Factory2获取。
获取状态栏颜色:先根据android.R.attr.statusBarColor属性获取皮肤包中的资源,获取不到,再根据android.support.v7.appcompat.R.attr.colorPrimaryDark属性获取。
获取导航栏颜色:根据android.R.attr.navigationBarColor属性获取皮肤包中的资源。
注意:在5.0以上版本才可以在Theme中配置状态栏和导航栏属性。
注意:android.R.attr.statusBarColor属性默认指向colorPrimary资源。
/**
* 主图资源ID工具类
*/
public class SkinThemeUtils {
...
/**
* 修改状态栏和导航栏颜色
*
* @param activity
*/
public static void updateStatusBar(Activity activity) {
//5.0以上才能修改
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
return;
}
int[] resIds = getResID(activity, STATUSBAR_COLOR_ATTRS);
//状态栏的颜色如果没有使用statusBarColor,就会使用V7包的colorPrimaryDark
if (resIds[0] == 0) {
int statusBarColorId = getResID(activity, APPCOMPAT_COLOR_PRIMARY_DARK_ATTRS)[0];
if (statusBarColorId != 0){
activity.getWindow().setStatusBarColor(SkinResources.getInstance().getColor(statusBarColorId));
}
} else {
activity.getWindow().setStatusBarColor(SkinResources.getInstance().getColor(resIds[0]));
}
//修改底部虚拟按键的颜色
if (resIds[1] != 0) {
activity.getWindow().setNavigationBarColor(SkinResources.getInstance().getColor(resIds[1]));
}
}
...
}
状态栏和导航栏的修改需要在Activity创建和更换皮肤包时调用。
3、字体的扩展
字体需要保存在资产目录中。
这里使用自定义的属性,来设置字体文件在资产目录中的路径。修改字体也就是修改字体文件的路径,再根据路径获取字体,从而实现换肤。
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="skinTypeface" format="string" />
</resources>
设置全局字体,在Theme中设置skinTypeface属性;设置控件的字体,就设置其skinTypeface属性。
获取字体时,先根据skinTypeface属性的ID值,获取资源ID,然后根据资源ID获取皮肤包中的字体。
/**
* 资源获取工具类
*/
public class SkinResources {
...
/**
* 返回皮肤包的Typeface(字体)
*
* @param resId
* @return
*/
public Typeface getTypeface(int resId) {
String skinTypefacePath = getString(resId);
if (TextUtils.isEmpty(skinTypefacePath)) {
return Typeface.DEFAULT;
}
try {
Typeface typeface;
if (isDefaultSkin) {
typeface = Typeface.createFromAsset(appResources.getAssets(), skinTypefacePath);
return typeface;
}
typeface = Typeface.createFromAsset(newResources.getAssets(), skinTypefacePath);
return typeface;
} catch (RuntimeException e) {
e.printStackTrace();
}
return Typeface.DEFAULT;
}
}
得到皮肤包的字体后,就修改筛选后的View的字体。
4、自定义控件的扩展
自定义控件由于属性不确定,所以通过接口来扩展。
自定义控件的获取也是在Factory2中获取,只是筛选时,要判断是否是SkinViewSupport类型。
/**
* View的属性集合
*/
class SkinAttribute {
/**
* 筛选View
*
* @param view
* @param attrs
*/
public void load(View view, AttributeSet attrs) {
...
//将view与之对应的可动态替换的属性放入集合
//TextView都放入集合中,用于修改全局字体
//SkinViewSupport接口的View都放入集合中
if (!skinPairs.isEmpty() || view instanceof TextView || view instanceof SkinViewSupport) {
SkinView skinView = new SkinView(view, skinPairs);
skinView.applySkin(typeface);
skinViews.add(skinView);
}
}
...
}
更换皮肤时,调用控件自己的applySkin(),实现自定义控件的扩展。
/**
* 用来处理view和对应的属性
*/
class SkinView {
/**
* 换皮肤
*
* @param typeface
*/
public void applySkin(Typeface typeface) {
...
applySkinSupport();
...
}
/**
* 修改自定义控件
*/
private void applySkinSupport() {
if (view instanceof SkinViewSupport) {
((SkinViewSupport) view).applySkin();
}
}
...
}
对于自定义的控件的支持性不够,需要实现SkinViewSupport接口,在换肤时,调用其applySkin(),从而修改样式。
public class CircleView extends View implements SkinViewSupport {
...
@Override
public void applySkin() {
if (corcleColorResId != 0) {
int color = SkinResources.getInstance().getColor(corcleColorResId);
setCorcleColor(color);
}
}
}
最后
代码地址:https://gitee.com/yanhuo2008/Common/tree/master/ToolSkin
移动架构专题:https://www.jianshu.com/nb/25128604
喜欢请点赞,谢谢!
网友评论