参考:
知乎和简书的夜间模式实现套路
对于Android日夜间模式实现的探讨
【Android】开发干货-技术分享之高仿QQ换肤SkinEngine实现
Android中插件开发篇之----应用换肤原理解析 (QQ空间)
Android换肤技术总结
Android 源码系列之<四>从源码的角度深入理解LayoutInflater.Factory之主题切换(上)
浅谈Android Support Library 23.2新增夜间模式主题
开源中国源码学习(五)——切换皮肤(日间模式和夜间模式)
新浪微博Android客户端夜间模式是如何实现的
Android主题切换方案总结
一直希望研究下android app主题换肤的优秀实现方案,做些分析记录。
下面主要是从源码角度来分析几款主流app的换肤方案,分析结果基于有限途径的分析,不一定完全正确,因为市场上的app混淆方面大多都做的非常好,但通过反编译,依旧可以看出很多有效信息。
1. 知乎
用过就知道,知乎app的夜间模式切换效果在目前市面上众多app中做的是相当好的,赶紧来分析。
反编译知乎app(用apktool反编译apk可以得到smali文件和资源文件等,用于分析资源文件,用smali2java反编译apk可以看到java源码,用于分析源码),知乎简直是个良心app啊,正如他的主旨一样 "与世界分享你的知识、经验和见解",知乎app竟然没有混淆啊!用好一点的反编译工具理论上应该可以完全反编译出来,当然实际上我反编译的还有些代码没有反编译成功。 继续分析...
可是源码不好找, 于是先看资源文件,在res\values目录下查看colors.xml文件:
#0d000000
#1a000000
#1f000000
#49000000
#4c000000
#4d000000
#4d000000
#4d000000
#4d000000
#4d000000
#4d3d9bf5
#60000000
命名很有规律,但是初看就有个疑惑,为什么颜色名称包含有两个颜色值,并且颜色值和名称里的第一个颜色值一样,后来也是按着心中的猜想走,于是找到了res\values-night-v8目录下的colors.xml文件:
#26000000
#1affffff
#33ffffff
#49ffffff
#4cffffff
#2bffffff
#4cffffff
#4d000000
#54ffffff
#ff2e3e45
#4d3d9bf5
#60ffffff
(注意上述两份颜色资源只截取了原colors.xml文件的一部分)
这一份资源文件里的颜色值和名称里的第二个颜色值一样,豁然开朗,于是基本路线就可以初步确定,知乎app也是采用在本地xml文件里配置两份颜色资源,然后根据当前的主题去取对应的颜色资源。而values-night-v8目录下的colors.xml就是在夜间模式下取的颜色资源。
同样,res目录下的color目录也有一个color-night-v8与之对应,drawable目录也有drawable-night-v8与之对应。
继续分析,在res\layout目录打开一个布局文件,这里打开的是fragment_live.xml,手动格式化了下:
Button,CardView, CheckBox, EditText, ImageButton, ImageView....常用的封装过了的控件都在这里。
看一下ZHButton的源码:
public class ZHButton extends AppCompatButton implements IDayNightView {
//此处代码省略
}
ZHButton实现了 IDayNightView接口,该接口就是白天/夜间主题切换的接口, 来看一下该接口:
public interface abstract class IDayNightView {
public abstract void resetStyle();
}
继续查看可以发现上述封装过的控件的确都实现了该接口。
继续查看源码发现还有个关键的ThemeManager类, 来看com.zhihu.android.base.ThemeManager的switchThemeTo()方法:
public static void switchThemeTo(int mode) {
setCurrentTheme(mode);
ZHActivity activity = (ZHActivity)ZHActivity.sActivityStack.iterator().next()) {
activity.switchTheme(mode);
}
}
public void switchTheme(int mode) {
if(overrideDefaultDayNightMode) {
return;
}
int toMode = getMode(mode);
if(isActive) {
View decorView = getWindow().getDecorView();
Bitmap drawingCache = obtainCachedBitmap(decorView);
if((decorView instanceof ViewGroup) && (drawingCache != null)) {
View maskView = new View(this);
maskView.setBackgroundDrawable(new BitmapDrawable(getResources(), drawingCache));
new BitmapDrawable(getResources(), drawingCache) = decorView;
(ViewGroup)new BitmapDrawable(getResources(), drawingCache).addView(maskView, new ViewGroup.LayoutParams(-0x1, -0x1));
onPrepareThemeChanged(toMode);
switchThemeInternal(toMode);
localViewPropertyAnimator1 = maskView.animate().alpha(0.0f).setDuration(0x12c)ZHActivity.1 localZHActivity.12 = new ZHActivity.1(this, decorView, maskView, toMode);
localViewPropertyAnimator1 = maskView.animate().alpha(0.0f).setListener(localZHActivity.12);
maskView.animate().alpha(0.0f).start();
}
return;
}
onPrepareThemeChanged(toMode);
switchThemeInternal(toMode);
onPostThemeChanged(toMode);
}
private void switchThemeInternal(int mode) {
ResourceFlusher.flush(getResources());
setDayNightMode(mode);
invalidateOptionsMenu();
supportInvalidateOptionsMenu();
clearDrawableCache();
reTheme();
recolorBackground();
ThemeManager.switchViewTree(getWindow().getDecorView());
}
该方法里面调用关键的ThemeManager.switchViewTree(getWindow().getDecorView());方法,可是我用smali2java工具反编译出来的源码里该方法竟然反编译不出来:
public static void switchViewTree(View view) {
// :( Parsing error. Please contact me.
}
public static void switchViewTree(View var0) {
if(var0 instanceof IDayNightView) {
try {
((IDayNightView)var0).resetStyle();
} catch (Exception var3) {
logException(var3);
}
}
if(var0 instanceof ViewGroup) {
for(int var1 = 0; var1 < ((ViewGroup)var0).getChildCount(); ++var1) {
switchViewTree(((ViewGroup)var0).getChildAt(var1));
}
}
}
smali2java反编译的:
/**
* Generated by smali2java 1.0.0.558
* Copyright (C) 2013 Hensence.com
*/
package com.zhihu.android.base;
import android.content.Context;
import android.support.v7.app.AppCompatDelegate;
import android.content.SharedPreferences;
import java.util.ArrayList;
import java.util.Iterator;
import android.view.View;
import com.zhihu.android.base.view.IDayNightView;
import android.content.res.Resources;
import android.content.res.Configuration;
import android.util.DisplayMetrics;
public class ThemeManager {
private static Context sApplicationContext;
private static ThemeManager.ThemeLogger sLogger;
private static int sCurrentMode = 0x1;
public static void init(Context context) {
sApplicationContext = context;
int theme = readTheme(context);
if(theme == 0x2) {
AppCompatDelegate.setDefaultNightMode(0x2);
return;
}
AppCompatDelegate.setDefaultNightMode(0x1);
}
public static void switchViewTree(View view) {
// :( Parsing error. Please contact me.
}
public static void switchThemeTo(int mode) {
setCurrentTheme(mode);
ZHActivity activity = (ZHActivity)ZHActivity.sActivityStack.iterator().next()) {
activity.switchTheme(mode);
}
}
public static boolean isLight() {
return (sCurrentMode != 0x2);
}
public static boolean isDark() {
return (sCurrentMode == 0x2);
}
public static int getCurrentTheme() {
return sCurrentMode;
}
public static int readTheme(Context pContext) {
SharedPreferences sharedPreferences = getSharedPreferences("theme", 0x0);
int theme = sharedPreferences.getInt("theme", 0x1);
if((theme != 0x1) && (theme != 0x2)) {
theme = 0x1;
}
sCurrentMode = theme;
return theme;
}
public static void setCurrentTheme(int pTheme) {
SharedPreferences sharedPreferences = sApplicationContext.getSharedPreferences("theme", 0x0);
sharedPreferences.edit().putInt("theme", pTheme).apply();
sCurrentMode = pTheme;
}
public static void logException(Throwable e) {
if(sLogger != null) {
sLogger.log(e);
}
}
public static void setLogger(ThemeManager.ThemeLogger logger) {
sLogger = logger;
}
public static boolean updateConfigurationIfNeeded(Context context) {
Resources res = getResources();
Configuration conf = res.getConfiguration();
int currentNightMode = conf.uiMode & 0x30;
int newNightMode = sCurrentMode == 0x2 ? 0x20 : 0x10;
if(currentNightMode != newNightMode) {
Configuration config = new Configuration(conf);
DisplayMetrics metrics = res.getDisplayMetrics();
config.uiMode = ((config.uiMode & -0x31) | newNightMode);
res.updateConfiguration(config, metrics);
ZHActivity activity = findZHActivity(context);
if(activity != null) {
activity.switchSilently(sCurrentMode);
}
return true;
}
return false;
}
private static ZHActivity findZHActivity(Context context) {
// :( Parsing error. Please contact me.
}
}
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.zhihu.android.base;
import android.content.Context;
import android.content.ContextWrapper;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.support.v7.app.AppCompatDelegate;
import android.util.DisplayMetrics;
import android.view.View;
import android.view.ViewGroup;
import com.zhihu.android.base.ZHActivity;
import com.zhihu.android.base.ThemeManager.ThemeLogger;
import com.zhihu.android.base.view.IDayNightView;
import java.util.Iterator;
public class ThemeManager {
private static Context sApplicationContext;
private static int sCurrentMode = 1;
private static ThemeLogger sLogger;
private static ZHActivity findZHActivity(Context var0) {
while(true) {
if(!(var0 instanceof ZHActivity)) {
if(var0 instanceof ContextWrapper) {
var0 = ((ContextWrapper)var0).getBaseContext();
continue;
}
return null;
}
return (ZHActivity)var0;
}
}
public static int getCurrentTheme() {
return sCurrentMode;
}
public static void init(Context var0) {
sApplicationContext = var0;
if(readTheme(var0) == 2) {
AppCompatDelegate.setDefaultNightMode(2);
} else {
AppCompatDelegate.setDefaultNightMode(1);
}
}
public static boolean isDark() {
return sCurrentMode == 2;
}
public static boolean isLight() {
return sCurrentMode != 2;
}
public static void logException(Throwable var0) {
if(sLogger != null) {
sLogger.log(var0);
}
}
public static int readTheme(Context var0) {
int var2 = var0.getSharedPreferences("theme", 0).getInt("theme", 1);
int var1 = var2;
if(var2 != 1) {
var1 = var2;
if(var2 != 2) {
var1 = 1;
}
}
sCurrentMode = var1;
return var1;
}
public static void setCurrentTheme(int var0) {
sApplicationContext.getSharedPreferences("theme", 0).edit().putInt("theme", var0).apply();
sCurrentMode = var0;
}
public static void setLogger(ThemeLogger var0) {
sLogger = var0;
}
public static void switchThemeTo(int var0) {
setCurrentTheme(var0);
Iterator var1 = ZHActivity.sActivityStack.iterator();
while(var1.hasNext()) {
((ZHActivity)var1.next()).switchTheme(var0);
}
}
public static void switchViewTree(View var0) {
if(var0 instanceof IDayNightView) {
try {
((IDayNightView)var0).resetStyle();
} catch (Exception var3) {
logException(var3);
}
}
if(var0 instanceof ViewGroup) {
for(int var1 = 0; var1 < ((ViewGroup)var0).getChildCount(); ++var1) {
switchViewTree(((ViewGroup)var0).getChildAt(var1));
}
}
}
public static boolean updateConfigurationIfNeeded(Context var0) {
Resources var3 = var0.getResources();
Configuration var4 = var3.getConfiguration();
int var2 = var4.uiMode;
byte var1;
if(sCurrentMode == 2) {
var1 = 32;
} else {
var1 = 16;
}
if((var2 & 48) != var1) {
var4 = new Configuration(var4);
DisplayMetrics var5 = var3.getDisplayMetrics();
var4.uiMode = var4.uiMode & -49 | var1;
var3.updateConfiguration(var4, var5);
ZHActivity var6 = findZHActivity(var0);
if(var6 != null) {
var6.switchSilently(sCurrentMode);
}
return true;
} else {
return false;
}
}
}
可以 看到, smali2java反编译不出来的方法 Fernflower decompiler都反编译出来了,然而smali2java反编译出的代码可读性很强。
总结出: 有些反编译工具的反编译能力差点,但是反编译出的代码可读性强。 有些反编译工具的反编译能力好,但是反编译出的代码可读性差点,所以可以综合多种反编译工具,结合几份反编译出来的代码综合比对查看会更容易得出原代码的全貌。
好了,继续分析,上述 switchViewTree()方法采用递归遍历当前window下所有View,如果该View实现了 IDayNightView接口,就调用该接口的resetStyle()方法重新设置该View的样式,从而实现换肤,关键的代码也分析结束了。