【靶点突破】网易云换肤方案探讨
- 老方案
- 网易云音乐换肤方案原理
- 动手实现一个网易云换肤方案的demo
- 动手打造换肤方案的轮子
- 黑白夜模式切换
Hello,大家好,我是Ellen,这是Android靶点突破系列文章,旨在帮助你更加了解Android技术开发的同时,把业务做到精致。思考自己的职业生涯,想成为怎样的技术人,想追求怎么样的生活。
至尊宝脚踏七彩祥云娶了紫霞,希望你也能成为她的自尊宝。
| from Ellen缘言
1.老方案
App皮肤切换老方案分为2点:
- 1.设置不同的Style,结合Activity的recreate & setTheme方法
- 2.通过全局Setting进行修改,回调通知所有存活的Activity & Fragment & Dialog等
如果是老的项目突然需要添加换肤功能,那么这将是一个极大的劳动工程,费时又费力,而且随着皮肤的增多,你的资源文件会越来越大,这首先很不方便管理,而且还会让apk的体积越来越大,开发起来吃力,用户体验也不好。
对于老方案的实现代码我这里就不讲解了,我会贴一个Github项目代码,读者可以自行去看看瞧瞧,代码注释写的很清晰,注意的是这里笔者只实现了Style & Setting两种方式,Style方式是切换Theme的方式,需要配置不同的style和自定义属性,Setting方式则更为灵活,它是通过属性对界面的皮肤进行控制,每个界面收到回调然后进行切换,还有其它很多实现方式,但核心缺点都是一样的,包体积越来越臃肿,管理性越来越差,我们重点要实现网易云音乐的换肤方案,这才是换肤的王道。当然你可以通过后端配置方式将资源都放在接口里,比较占apk的图片资源用url的方式,但是无疑增加皮肤切换的业务逻辑复杂度,随着项目业务越来越多,负责皮肤的bean对象也许会越来越多的属性。
老方案:OldSwitchSkinDemo
2.网易云音乐换肤方案原理
网易云音乐相信你使用过,它的换肤可以算是秒切,那么它是怎样做到的呢?我们先来看看它的原理,然后追求精致,我们也要实现这种秒切皮肤的效果。
我们来看看,它的原理需要了解的如下:
- 1.LayoutInflater mFactory & mFactory2 反射替代成自定义的
- 2.解析空壳apk获取Resource替代原有的App Resource
步骤1:LayoutInflater mFactory & mFactory2 反射替代成自定义的
LayoutInflater通常我们用来解析布局文件的,将布局文件映射成一个一个的控件对象,下列代码就是将布局item_skin_manager映射为一个View对象:
LayoutInflater.from(parent.getContext()).inflate(R.layout.item_skin_manager, parent, false);
那么它是如何将布局文件映射为View对象的呢,我们来看看Android SDK版本31下inflate方法的源码:
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
//**********注意点1
final Resources res = getContext().getResources();
if (DEBUG) {
Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
+ Integer.toHexString(resource) + ")");
}
View view = tryInflatePrecompiled(resource, res, root, attachToRoot);
if (view != null) {
return view;
}
//**********注意点2
XmlResourceParser parser = res.getLayout(resource);
try {
//**********注意点3
return inflate(parser, root, attachToRoot);
} finally {
parser.close();
}
}
请注意上方代码笔者标注的"注意点1"和"注意点2"以及"注意点3",后面我直接简称为点1和点2以及点3,从点1中我们可以看到它是获取了一个Resource res,再从点2看到,它获取了一个XML解析负责相关的类XmlResourceParser parser,这个parser应该提供了XML解析相关的,那么我们接下来看看点3标注的inflate方法:
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate");
final Context inflaterContext = mContext;
final AttributeSet attrs = Xml.asAttributeSet(parser);
Context lastContext = (Context) mConstructorArgs[0];
mConstructorArgs[0] = inflaterContext;
View result = root;
try {
advanceToRootNode(parser);
final String name = parser.getName();
if (DEBUG) {
System.out.println("**************************");
System.out.println("Creating root view: "
+ name);
System.out.println("**************************");
}
if (TAG_MERGE.equals(name)) {
if (root == null || !attachToRoot) {
throw new InflateException("<merge /> can be used only with a valid "
+ "ViewGroup root and attachToRoot=true");
}
rInflate(parser, root, inflaterContext, attrs, false);
} else {
//**********注意点4
// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
ViewGroup.LayoutParams params = null;
if (root != null) {
if (DEBUG) {
System.out.println("Creating params from root: " +
root);
}
// Create layout params that match root, if supplied
params = root.generateLayoutParams(attrs);
if (!attachToRoot) {
// Set the layout params for temp if we are not
// attaching. (If we are, we use addView, below)
temp.setLayoutParams(params);
}
}
if (DEBUG) {
System.out.println("-----> start inflating children");
}
// Inflate all children under temp against its context.
rInflateChildren(parser, temp, attrs, true);
if (DEBUG) {
System.out.println("-----> done inflating children");
}
// We are supposed to attach all the views we found (int temp)
// to root. Do that now.
if (root != null && attachToRoot) {
root.addView(temp, params);
}
// Decide whether to return the root that was passed in or the
// top view found in xml.
if (root == null || !attachToRoot) {
result = temp;
}
}
} catch (XmlPullParserException e) {
final InflateException ie = new InflateException(e.getMessage(), e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} catch (Exception e) {
final InflateException ie = new InflateException(
getParserStateDescription(inflaterContext, attrs)
+ ": " + e.getMessage(), e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} finally {
// Don't retain static reference on context.
mConstructorArgs[0] = lastContext;
mConstructorArgs[1] = null;
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
return result;
}
}
看点4老外注释的, Temp is the root view that was found in the xml,大概意思就是说Temp 是在 xml 中找到的根视图,原来我们的xml布局是这样的解析的哦,我们再来看看createViewFromTag方法:
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();
}
try {
//***********注意点5
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;
}
}
return view;
} catch (InflateException e) {
throw e;
} catch (ClassNotFoundException e) {
final InflateException ie = new InflateException(
getParserStateDescription(context, attrs)
+ ": Error inflating class " + name, e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} catch (Exception e) {
final InflateException ie = new InflateException(
getParserStateDescription(context, attrs)
+ ": Error inflating class " + name, e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
}
}
我们再看点5,它通过tryCreateView方法获取到一个View,这个View就是Temp了,也就是解析布局获取到的View对象,我们在来看看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;
if (mFactory2 != null) {
//*******注意点6
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
//*******注意点7
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
return view;
}
我们看到点6和点7,原来我们的View都是通过mFactory2或 mFactory创建出来的,我们看看下面代码:
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; <em>note that this may be null</em>.
* @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.
*/
@Nullable
View onCreateView(@Nullable View parent, @NonNull String name,
@NonNull Context context, @NonNull AttributeSet attrs);
}
public interface Factory {
/**
* Hook you can supply that is called when inflating from a LayoutInflater.
* You can use this to customize the tag names available in your XML
* layout files.
*
* <p>
* Note that it is good practice to prefix these custom names with your
* package (i.e., com.coolcompany.apps) to avoid conflicts with system
* names.
*
* @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.
*/
@Nullable
View onCreateView(@NonNull String name, @NonNull Context context,
@NonNull AttributeSet attrs);
}
可以看到Factory和Factory2都是接口,那么mFactory2或 mFactory是啥呢?
@UnsupportedAppUsage
private Factory mFactory;
@UnsupportedAppUsage
private Factory2 mFactory2;
它是 LayoutInflater内私有属性成员,那么我们是否可以通过反射拦截XML解析成具体控件对象的过程呢?只要拦截了,那么我们是否可以拿到控件对象任性设置自己要的皮肤属呢?如果是通过设置属性的方式进行切换,那么我们估计也还是会像老方案那样,只会越来越复杂,那么怎么办呢?我们拿到控件对象啦,还记得前面提到的Resource,它是负责整个控件体系的资源设置的类,同样的原理,我们是否可以通过我们的Resource来进行设置呢,我们再来看看Resource是如何来的:
步骤2:解析空壳apk获取Resource替代原有的App Resource
通过上图我们可以确定Resource通过AssetManager来加载的,Asset是不是很熟悉,它是asset目录啊,怎么会加载项目的资源呢?难道它还可以解析目录下资源吗?
我们接着看看这个方法:
//这里的path就是apk所在目录
public int addAssetPath(String path) {
return addAssetPathInternal(path, false /*overlay*/, false /*appAsLib*/);
}
虽然这个方法是public的,但是被隐藏掉了,我们只能通过反射进行调用,也就是方案已经很明了,就是我们将每个皮肤的资源打进空壳apk内,然后通过AssetManager的addAssetPath方法解析空壳apk的资源,获取到一个Resource,然后我们通过反射LayoutInflater赋值自定义的mFactory&mFactory2来拦截控件创建过程,进行属性的替换,眼下我们还存在一个问题,那么如何new一个Resource对象,并且将空壳apk的资源打进去呢?我们看看Resource的构造器:
public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
this(null);
mResourcesImpl = new ResourcesImpl(assets, metrics, config, new DisplayAdjustments());
}
惊喜且意外的发现Resources(AssetManager assets, DisplayMetrics metrics, Configuration config)这个构造器完全满足我们的需求,但是metrics和config是啥呢,没关系,我们通过获取当前的Resource,将当前的Resource的metrics和config传进去即可,我们只需要设置我们重要的解析空客apk的assets即可,然后通过Resource为我们提供的解析资源的api给拦截的控件对象设置对应的皮肤属性即可。
3.动手实现一个网易云换肤方案的demo
经过网易云音乐换肤方案原理分析,我们要实现换肤的步骤如下:
- 0.准备好换肤对应的界面
- 1.反射赋值LayoutInflater mFactory & mFactory2,拦截控件对象创建过程
- 2.过滤出我们需要换肤的控件的属性
- 3.下载服务器空壳apk资源,加载空壳apk获取到一个当前皮肤的Resource skinResource
- 4.通过解析换肤属性的资源id在skinResource中寻找对应的值,并设置给控件对象
步骤0:准备好换肤对应的界面
由于只是例子讲解,笔者就不搞的太复杂,就弄一个Activity & 3个Fragment进行实现,通过res资源color.xml文件中"main_color属性进行更换",代码请到SwitchSkinDemo查看,这里不在啰嗦。
demo 演示gif如下所示: 待上传
点击下载apk体验
步骤1:反射赋值LayoutInflater mFactory & mFactory2,拦截控件对象创建过程
要想反射赋值到mFactory & mFactory2,我们首先要先获取Activity对应的LayoutInflater,因为需要每个存活的Activity都需要进行反射赋值,很容易联想到,我们可以通过Application的registerActivityLifecycleCallbacks方法做到,话不多说我们上代码:
//皮肤管理类
public class SkinManager {
//单例对象
private volatile static SkinManager INSTANCE;
//Application对象
private Application application;
//皮肤名字集合
private List<String> skinNames = new ArrayList<>();
//记录当前应用的皮肤名
private String currentSkin = "skin_default.apk";
//记录默认的皮肤名
private static final String DEFAULT_SKIN_NAME = "skin_default.apk";
//应用Activity生命周期监听
private SkinActivityLifecycle skinActivityLifecycle;
private SkinManager(){
//初始化皮肤数据,当然这里可以网络下载即可,但是为了方便
//笔者就用assets目录copy到本地目录的方式模拟网络加载皮肤过程
skinNames.add("skin_blue.apk");
skinNames.add("skin_red.apk");
skinNames.add("skin_black.apk");
skinNames.add("skin_green.apk");
skinNames.add("skin_default.apk");
}
public List<String> getSkinData(){
return skinNames;
}
/**
* 切换皮肤
* @param skinName
*/
public void switchSkin(String skinName){
this.currentSkin = skinName;
skinActivityLifecycle.switchSkin();
}
/**
* 是否是默认皮肤
* @return
*/
public boolean isDefaultSkin(){
return currentSkin.equals(DEFAULT_SKIN_NAME);
}
/**
* 获取到当前的皮肤名
* @return
*/
public String getCurrentSkin(){
return currentSkin;
}
public static SkinManager getInstance(){
if(INSTANCE == null){
synchronized (SkinManager.class){
if(INSTANCE == null){
INSTANCE = new SkinManager();
}
}
}
return INSTANCE;
}
public Application getApplication(){
return application;
}
/**
* 皮肤管理初始化
* @param app
*/
public void initApp(Application app){
this.application = app;
//对所有Activity的声明周期进行监听
app.registerActivityLifecycleCallbacks(skinActivityLifecycle = new SkinActivityLifecycle());
}
}
因为服务器下载空壳apk的接口没有做,这里笔者用asset目录copy到本地目录的方式去模拟从服务器下载空壳apk的过程,请读者仔细阅读以上代码,笔者的皮肤切换机制里带有5种皮肤,分别是:
- skin_default.apk【黄色】
- skin_blue.apk【蓝色】
- skin_red.apk【红色】
- skin_black.apk【黑色】
- skin_green.apk【绿色】
笔者皮肤的属性只把包含color.xml下"main_color"这个资源字段,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
//皮肤主色
<color name="main_color">#FFA500</color>
</resources>
并且笔者先将这个"main_color"修改为对应皮肤的颜色值,然后进行空壳打包,打完的包放进了项目目录下的assets目录下,然后我们把皮肤空壳apk准备好了,接下来我们就看看如何拿到每个Activity的LayoutInflater,然后反射赋值mFactory & mFactory2那两个属性,请看笔者上述SkinManager类中的initApp方法:
/**
* 皮肤管理初始化
* @param app
*/
public void initApp(Application app){
this.application = app;
//对所有Activity的声明周期进行监听
app.registerActivityLifecycleCallbacks(skinActivityLifecycle = new SkinActivityLifecycle());
}
我们可以看到笔者是通过SkinActivityLifecycle对所有的Activity进行生命周期监听的,其代码如下:
public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {
private List<Activity> activeActivityList = new ArrayList<>();
@Override
@SuppressLint("SoonBlockedPrivateApi")
public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle bundle) {
activeActivityList.add(activity);
LayoutInflater layoutInflater = LayoutInflater.from(activity);
//反射setFactory2,Android Q及以上已经失效-> 报not field 异常
//Android Q以上setFactory2问题
//http://www.javashuo.com/article/p-sheppkca-ds.html
forceSetFactory2(layoutInflater);
}
/**
* 最新的方式,适配Android Q
* @param inflater
*/
private static void forceSetFactory2(LayoutInflater inflater) {
Class<LayoutInflaterCompat> compatClass = LayoutInflaterCompat.class;
Class<LayoutInflater> inflaterClass = LayoutInflater.class;
try {
Field sCheckedField = compatClass.getDeclaredField("sCheckedField");
sCheckedField.setAccessible(true);
sCheckedField.setBoolean(inflater, false);
Field mFactory = inflaterClass.getDeclaredField("mFactory");
mFactory.setAccessible(true);
Field mFactory2 = inflaterClass.getDeclaredField("mFactory2");
mFactory2.setAccessible(true);
//自定义的Factory2
SkinLayoutFactory skinLayoutFactory = new SkinLayoutFactory();
mFactory2.set(inflater, skinLayoutFactory);
mFactory.set(inflater, skinLayoutFactory);
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}
@Override
public void onActivityStarted(@NonNull Activity activity) {
}
@Override
public void onActivityResumed(@NonNull Activity activity) {
}
@Override
public void onActivityPaused(@NonNull Activity activity) {
}
@Override
public void onActivityStopped(@NonNull Activity activity) {
}
@Override
public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle bundle) {
}
@Override
public void onActivityDestroyed(@NonNull Activity activity) {
activeActivityList.remove(activity);
}
public void switchSkin(){
for(Activity activity:activeActivityList){
//重新使用资源
if(!(activity instanceof SkinManagerActivity)) {
activity.recreate();
}
}
}
}
在上述代码中我们完成了mFactory & mFactory2的反射赋值,我们看到forceSetFactory2方法中,我们将SkinLayoutFactory对象通过反射赋值给了mFactory & mFactory2,那么SkinLayoutFactory我们应该在它里面写哪些逻辑呢,聪明的你应该知道mFactory2 & mFactory不过只是负责将XML中的控件标签映射为具体内存中的控件对象,我们不仅要实现这个,还要实现拦截并设置我们需要更换皮肤的属性,接下来我们就来看看如何实现。
步骤2:过滤出我们需要换肤的控件的属性
public class SkinLayoutFactory implements LayoutInflater.Factory2 {
//具体拦截逻辑都在该类里
private SkinAttribute skinAttribute;
public SkinLayoutFactory(){
skinAttribute = new SkinAttribute();
}
//系统自带的控件名包名路径
//因为布局中会直接使用<TextView没带全路径的,所以我们该手动加上
private static final String[] systemViewPackage = {
"androidx.widget.",
"androidx.view.",
"androidx.webkit.",
"android.widget.",
"android.view.",
"android.webkit."
};
//反射控件对应的构造器而使用
private static final Class[] mConstructorSignature = new Class[]{Context.class,AttributeSet.class};
//存储控件的构造器,避免重复创建
private static final HashMap<String, Constructor<extends View>> mConstructor = new HashMap<>();
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attributeSet) {
View view = onCreateViewFromTag(name,context,attributeSet);
if(view == null){
view = onCreateView(name, context, attributeSet);
}
//筛选符合属性的View
skinAttribute.loadView(view,attributeSet);
return view;
}
/**
* 通过反射构建控件对象
* @param name
* @param context
* @param attributeSet
* @return
*/
@Nullable
@Override
public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attributeSet) {
Constructor<extends View> constructor = mConstructor.get(name);
View view = null;
if(constructor == null){
try {
Class<extends View> viewClass = context.getClassLoader().loadClass(name).asSubclass(View.class);
constructor = viewClass.getConstructor(mConstructorSignature);
mConstructor.put(name,constructor);
} catch (ClassNotFoundException | NoSuchMethodException e) {
e.printStackTrace();
}
}
if(constructor != null){
try {
view = constructor.newInstance(context,attributeSet);
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}
return view;
}
private View onCreateViewFromTag(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attributeSet){
if(name.indexOf(".") > 0){
//说明XML中该控件带有包名全路径
}
View view = null;
for(String packageName:systemViewPackage){
view = onCreateView(packageName+name,context,attributeSet);
if(view != null){
break;
}
}
return view;
}
}
这个类的作用不用笔者多说了,仔细看下代码就会一目了然,它存在以下作用:
- 1.将XML对应的控件标签映射为对应的具体控件对象,有具体包名则直接进行反射构建,无包名则需要先拼接对应的全路径包名然后再反射,例如TextView->android.widget.TextView
- 2.拦截构建出的控件对象,设置对应的皮肤属性
看以上代码,如下所示:
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attributeSet) {
View view = onCreateViewFromTag(name,context,attributeSet);
if(view == null){
view = onCreateView(name, context, attributeSet);
}
//筛选符合属性的View
skinAttribute.loadView(view,attributeSet);
return view;
}
SkinAttribute类具体负责拦截逻辑,具体代码如下所示:
public class SkinAttribute {
//过滤出皮肤需要的属性
private static final List<String> ATTRIBUTE = new ArrayList<>();
static {
ATTRIBUTE.add("background");
ATTRIBUTE.add("src");
ATTRIBUTE.add("textColor");
ATTRIBUTE.add("SkinTypeface");
//TabLayout
ATTRIBUTE.add("tabIndicatorColor");
ATTRIBUTE.add("tabSelectedTextColor");
}
public void loadView(View view, AttributeSet attributeSet) {
for (int i = 0; i < attributeSet.getAttributeCount(); i++) {
String attributeName = attributeSet.getAttributeName(i);
if (ATTRIBUTE.contains(attributeName)) {
String attributeValue = attributeSet.getAttributeValue(i);
if (attributeValue.startsWith("#")) {
//固定的Color值,无需修改
} else {
int resId = 0;
//判断前缀是否为?
int attrId = Integer.parseInt(attributeValue.substring(1));
if (attributeValue.startsWith("?")) {
int[] array = {attrId};
resId = SkinThemeUtils.getResId(view.getContext(), array)[0];
} else {
resId = attrId;
}
if (resId != 0) {
String skinName = SkinManager.getInstance().getCurrentSkin();
File skinFile = new File(view.getContext().getCacheDir(), skinName);
//拿到空壳App资源
if (!SkinManager.getInstance().isDefaultSkin()) {
//如果皮肤包不存在,那么先从asset里进行拷贝到SD卡【模拟从服务器下载过程】
if (!skinFile.exists()) {
//复制文件
FileUtils.copyFileFromAssets(view.getContext(), skinName,
view.getContext().getCacheDir().getAbsolutePath(), skinName);
}
}
SkinLoadApkPath skinLoadApkPath = new SkinLoadApkPath();
skinLoadApkPath.loadEmptyApkPath(skinFile.getAbsolutePath());
Resources skinResource = skinLoadApkPath.getSkinResource();
if (attributeName.equals("textColor")) {
TextView textView = (TextView) view;
textView.setTextColor(skinResource.getColorStateList(resId));
}
if (attributeName.equals("background")) {
view.setBackgroundColor(skinResource.getColor(resId));
}
if (attributeName.equals("tabIndicatorColor")) {
//TabLayout下划线颜色
TabLayout tabLayout = (TabLayout) view;
tabLayout.setSelectedTabIndicatorColor(skinResource.getColor(resId));
}
if (attributeName.equals("tabSelectedTextColor")) {
//TabLayout选中文本颜色
TabLayout tabLayout = (TabLayout) view;
tabLayout.setTabTextColors(Color.BLACK, skinResource.getColor(resId));
}
}
}
}
}
}
}
主要拦截设置皮肤属性的逻辑都在loadView方法里,先遍历控件对象对应的AttributeSet,然后过滤出自己需要的皮肤属性,负责过滤的集合是ATTRIBUTE,拿到我们需要更改的控件对象以及需要修改的皮肤属性,我们思考一个问题,如果想设置对应的皮肤属性,首先我们是不是要确定这个属性使用哪个资源id?,如果你XML用了"?"方式使用了Style的资源,那么这时又该如何正确获取该属性使用的资源id呢?其具体代码逻辑如下:
int attrId = Integer.parseInt(attributeValue.substring(1));
if (attributeValue.startsWith("?")) {
int[] array = {attrId};
resId = SkinThemeUtils.getResId(view.getContext(), array)[0];
} else {
resId = attrId;
}
如果你的XML使用了?访问XML资源,那么就需要使用SkinThemeUtils工具将其映射为具体的资源id,其代码如下:
public class SkinThemeUtils {
public static int[] getResId(Context context, int[] attrs){
int[] ints = new int[attrs.length];
TypedArray typedArray = context.obtainStyledAttributes(attrs);
for (int i = 0; i < typedArray.length(); i++) {
ints[i] = typedArray.getResourceId(i, 0);
}
typedArray.recycle();
return ints;
}
}
接下来我们是不是该解析空壳apk,然后再拿到对应的Resource,然后通过对应的Resource api已经对应的皮肤属性名和资源id,这样我们就能更改皮肤控件对应的皮肤属性值啦,从loadView方法看以下代码:
if (resId != 0) {
String skinName = SkinManager.getInstance().getCurrentSkin();
File skinFile = new File(view.getContext().getCacheDir(), skinName);
//拿到空壳App资源
if (!SkinManager.getInstance().isDefaultSkin()) {
//如果皮肤包不存在,那么先从asset里进行拷贝到SD卡【模拟从服务器下载过程】
if (!skinFile.exists()) {
//复制文件
FileUtils.copyFileFromAssets(view.getContext(), skinName,
view.getContext().getCacheDir().getAbsolutePath(), skinName);
}
}
SkinLoadApkPath skinLoadApkPath = new SkinLoadApkPath();
skinLoadApkPath.loadEmptyApkPath(skinFile.getAbsolutePath());
Resources skinResource = skinLoadApkPath.getSkinResource();
if (attributeName.equals("textColor")) {
TextView textView = (TextView) view;
textView.setTextColor(skinResource.getColorStateList(resId));
}
......
从以上代码看出SkinLoadApkPath类就是我们负责加载空壳apk的类,接下来我们看看如何解析空壳apk获取一个Resource对象:
3.下载服务器空壳apk资源,加载空壳apk获取到一个当前皮肤的Resource skinResource
public class SkinLoadApkPath {
private Resources skinResources;
public Resources getSkinResource(){
return skinResources;
}
/**
* 加载空壳Apk资源
*
* @param apkPath
*/
public void loadEmptyApkPath(String apkPath) {
try {
Resources appResources = SkinManager.getInstance().getApplication().getResources();
if(SkinManager.getInstance().isDefaultSkin()){
//使用默认资源,当前应用的Resource就是皮肤Resource
skinResources = appResources;
}else {
//反射addAssetPath方法进行解析空壳apk
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, apkPath);
//使用空壳Apk资源,并传入当前App Resource的Metrics,Configuration获取Resource
skinResources = new Resources(assetManager,
appResources.getDisplayMetrics(), appResources.getConfiguration());
}
} catch (Exception e) {
Log.d("Skin","发生异常");
}
}
}
步骤4:通过解析换肤属性的资源id在skinResource中寻找对应的值,并设置给控件对象
空壳apk的Resource赋值到skinResources中了,SkinAttribute的loadView方法只需要传入空壳apk的路径即可获取到皮肤对应的Resource,接下来通过Resource的api,控件对象,资源id设置对应的属性值:
String skinName = SkinManager.getInstance().getCurrentSkin();
File skinFile = new File(view.getContext().getCacheDir(), skinName);
//拿到空壳App资源
if (!SkinManager.getInstance().isDefaultSkin()) {
//如果皮肤包不存在,那么先从asset里进行拷贝到SD卡【模拟从服务器下载过程】
if (!skinFile.exists()) {
//复制文件
FileUtils.copyFileFromAssets(view.getContext(), skinName,
view.getContext().getCacheDir().getAbsolutePath(), skinName);
}
}
SkinLoadApkPath skinLoadApkPath = new SkinLoadApkPath();
skinLoadApkPath.loadEmptyApkPath(skinFile.getAbsolutePath());
Resources skinResource = skinLoadApkPath.getSkinResource();
if (attributeName.equals("textColor")) {
TextView textView = (TextView) view;
textView.setTextColor(skinResource.getColorStateList(resId));
}
if (attributeName.equals("background")) {
view.setBackgroundColor(skinResource.getColor(resId));
}
if (attributeName.equals("tabIndicatorColor")) {
//TabLayout下划线颜色
TabLayout tabLayout = (TabLayout) view;
tabLayout.setSelectedTabIndicatorColor(skinResource.getColor(resId));
}
if (attributeName.equals("tabSelectedTextColor")) {
//TabLayout选中文本颜色
TabLayout tabLayout = (TabLayout) view;
tabLayout.setTabTextColors(Color.BLACK, skinResource.getColor(resId));
}
这里还要说明一点,demo中皮肤管理界面切换相应的皮肤时,会出现短暂的黑屏闪烁现象,其原因是调用了该界面的recreate方法导致的,为了更好的用户体验,此界面需要手动在Activity添加逻辑进行皮肤改变,这样用户在此界面切换皮肤时不会出现闪屏,并完成了皮肤切换效果,也就达到了网易云那种秒切效果。整体代码如下:
Github整体代码demo:SwitchSkinDemo
4.动手打造换肤的轮子
目前换肤笔者已经封装完毕,只是文档没有写,没有发布到Jitpack上,等文档写了,发布到Jitpack后,你就可以用到自己项目中啦,GitHub地址如下所示:
基于网易云换肤方案打造的轮子:LmySkinSwitcher
5.黑白夜模式切换
以上已经讲解完了网易云换肤方案的原理,而且还实践了代码,最后造成一个可以换肤的轮子,那么黑白夜模式切换自然也是一个水到渠成的事情,用上面的轮子去实践一把吧,打两个空壳apk,一个负责黑夜模式,一个负责白天模式,还有个问题是否跟随系统的黑白夜模式?在Application中提供了一个方法onConfigurationChanged用来判断当前系统处于黑夜还是白天模式,代码如下:
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if ((newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO) {
//白天模式
} else if ((newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES) {
//黑夜模式
}
}
详细的代码笔者这里就不演示了,请读者自行实践哦!