转载自http://blog.coderclock.com/2016/08/28/android/知乎和简书的夜间模式实现套路/
说到夜间模式,在网上看到很多童鞋都说用什么什么框架来实现这个功能,然后仔细去看一下各个推荐的框架,发现其实都是动态换肤的,动态换肤可比夜间模式要复杂多了,未免大材小用了。说实话,我一直没用什么好思路,虽然网上有童鞋提供了一种思路是通过 setTheme
然后再 recreate Activity
的方式,但是这样带来的问题是非常多的,看起来就相当不科学(为什么不科学,后文会说)。于是,直接想到了去逆向分析那些夜间模式做得好的应用的源代码,学习他们的实现套路。所以,本文的实现思路来自于编写这些应用的夜间模式功能的童鞋,先在这里向他们表示感谢。我的手机里面使用高频的应用不少,其中简书和知乎是属于夜间模式做得相当 nice 的。先给两个效果图大家对比感受下
如果大家仔细观察,肯定会发现,知乎的切换效果更漂亮些,因为它有一个渐变的效果。那么它们的夜间模式到底是如何实现的呢?别急接着往下看,你也可以。
实现套路
这里先展示一下我的实现效果吧
此处分为两个部分,一部分是
xml
文件中要干的活,一部分是 Java
代码要实现的活,先说 xml
吧。
XML 配置
首先,先写一套UI界面出来,上方左边是两个 TextView
,右边是两个 CheckBox
,下方是一个 RecyclerView
,实现很简单,这里我不贴代码了。
接着,在
styles
文件中添加两个 Theme
,一个是日间主题,一个是夜间主题。它们的属性都是一样的,唯一区别在于颜色效果不同。
<!--白天主题-->
<style name="DayTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="clockBackground">@android:color/white</item>
<item name="clockTextColor">@android:color/black</item>
</style>
<!--夜间主题-->
<style name="NightTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/color3F3F3F</item>
<item name="colorPrimaryDark">@color/color3A3A3A</item>
<item name="colorAccent">@color/color868686</item>
<item name="clockBackground">@color/color3F3F3F</item>
<item name="clockTextColor">@color/color8A9599</item>
</style>
需要注意的是,上面的 clockTextColor
和 clockBackground
是我自定义的 color
类型属性
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="clockBackground" format="color" />
<attr name="clockTextColor" format="color" />
</resources>
然后再到所有需要实现夜间模式功能的 xml
布局文件中,加入类似下面设置,比如我在 RecyclerView
的 Item
布局文件中做了如下设置
稍稍解释下其作用,如
TextView
里的 android:textColor=”?attr/clockTextColor”
是让其字体颜色跟随所设置的 Theme
。到这里,xml
需要做的配置全部完成,接下来是 Java
代码实现了。
Java 代码实现
大家可以先看下面的实现代码,看不懂的童鞋可以边结合我代码下方实现思路解说。
package com.clock.study.activity;
import ...
/**
* 夜间模式实现方案
*
* @author Clock
* @since 2016-08-11
*/
public class DayNightActivity extends AppCompatActivity implements CompoundButton.OnCheckedChangeListener {
private final static String TAG = DayNightActivity.class.getSimpleName();
/**用于将主题设置保存到SharePreferences的工具类**/
private DayNightHelper mDayNightHelper;
private RecyclerView mRecyclerView;
private LinearLayout mHeaderLayout;
private List<RelativeLayout> mLayoutList;
private List<TextView> mTextViewList;
private List<CheckBox> mCheckBoxList;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
supportRequestWindowFeature(Window.FEATURE_NO_TITLE);
initData();
initTheme();
setContentView(R.layout.activity_day_night);
initView();
}
private void initView() {
mRecyclerView = (RecyclerView) findViewById(R.id.recycler_view);
RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false);
mRecyclerView.setLayoutManager(layoutManager);
mRecyclerView.setAdapter(new SimpleAuthorAdapter());
mHeaderLayout = (LinearLayout) findViewById(R.id.header_layout);
mLayoutList = new ArrayList<>();
mLayoutList.add((RelativeLayout) findViewById(R.id.jianshu_layout));
mLayoutList.add((RelativeLayout) findViewById(R.id.zhihu_layout));
mTextViewList = new ArrayList<>();
mTextViewList.add((TextView) findViewById(R.id.tv_jianshu));
mTextViewList.add((TextView) findViewById(R.id.tv_zhihu));
mCheckBoxList = new ArrayList<>();
CheckBox ckbJianshu = (CheckBox) findViewById(R.id.ckb_jianshu);
ckbJianshu.setOnCheckedChangeListener(this);
mCheckBoxList.add(ckbJianshu);
CheckBox ckbZhihu = (CheckBox) findViewById(R.id.ckb_zhihu);
ckbZhihu.setOnCheckedChangeListener(this);
mCheckBoxList.add(ckbZhihu);
}
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
int viewId = buttonView.getId();
if (viewId == R.id.ckb_jianshu) {
changeThemeByJianShu();
} else if (viewId == R.id.ckb_zhihu) {
changeThemeByZhiHu();
}
}
private void initData() {
mDayNightHelper = new DayNightHelper(this);
}
private void initTheme() {
if (mDayNightHelper.isDay()) {
setTheme(R.style.DayTheme);
} else {
setTheme(R.style.NightTheme);
}
}
/**
* 切换主题设置
*/
private void toggleThemeSetting() {
if (mDayNightHelper.isDay()) {
mDayNightHelper.setMode(DayNight.NIGHT);
setTheme(R.style.NightTheme);
} else {
mDayNightHelper.setMode(DayNight.DAY);
setTheme(R.style.DayTheme);
}
}
/**
* 使用简书的实现套路来切换夜间主题
*/
private void changeThemeByJianShu() {
toggleThemeSetting();
refreshUI();
}
/**
* 使用知乎的实现套路来切换夜间主题
*/
private void changeThemeByZhiHu() {
showAnimation();
toggleThemeSetting();
refreshUI();
}
/**
* 刷新UI界面
*/
private void refreshUI() {
TypedValue background = new TypedValue();//背景色
TypedValue textColor = new TypedValue();//字体颜色
Resources.Theme theme = getTheme();
theme.resolveAttribute(R.attr.clockBackground, background, true);
theme.resolveAttribute(R.attr.clockTextColor, textColor, true);
mHeaderLayout.setBackgroundResource(background.resourceId);
for (RelativeLayout layout : mLayoutList) {
layout.setBackgroundResource(background.resourceId);
}
for (CheckBox checkBox : mCheckBoxList) {
checkBox.setBackgroundResource(background.resourceId);
}
for (TextView textView : mTextViewList) {
textView.setBackgroundResource(background.resourceId);
}
Resources resources = getResources();
for (TextView textView : mTextViewList) {
textView.setTextColor(resources.getColor(textColor.resourceId));
}
int childCount = mRecyclerView.getChildCount();
for (int childIndex = 0; childIndex < childCount; childIndex++) {
ViewGroup childView = (ViewGroup) mRecyclerView.getChildAt(childIndex);
childView.setBackgroundResource(background.resourceId);
View infoLayout = childView.findViewById(R.id.info_layout);
infoLayout.setBackgroundResource(background.resourceId);
TextView nickName = (TextView) childView.findViewById(R.id.tv_nickname);
nickName.setBackgroundResource(background.resourceId);
nickName.setTextColor(resources.getColor(textColor.resourceId));
TextView motto = (TextView) childView.findViewById(R.id.tv_motto);
motto.setBackgroundResource(background.resourceId);
motto.setTextColor(resources.getColor(textColor.resourceId));
}
//让 RecyclerView 缓存在 Pool 中的 Item 失效
//那么,如果是ListView,要怎么做呢?这里的思路是通过反射拿到 AbsListView 类中的 RecycleBin 对象,然后同样再用反射去调用 clear 方法
Class<RecyclerView> recyclerViewClass = RecyclerView.class;
try {
Field declaredField = recyclerViewClass.getDeclaredField("mRecycler");
declaredField.setAccessible(true);
Method declaredMethod = Class.forName(RecyclerView.Recycler.class.getName()).getDeclaredMethod("clear", (Class<?>[]) new Class[0]);
declaredMethod.setAccessible(true);
declaredMethod.invoke(declaredField.get(mRecyclerView), new Object[0]);
RecyclerView.RecycledViewPool recycledViewPool = mRecyclerView.getRecycledViewPool();
recycledViewPool.clear();
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
refreshStatusBar();
}
/**
* 刷新 StatusBar
*/
private void refreshStatusBar() {
if (Build.VERSION.SDK_INT >= 21) {
TypedValue typedValue = new TypedValue();
Resources.Theme theme = getTheme();
theme.resolveAttribute(R.attr.colorPrimary, typedValue, true);
getWindow().setStatusBarColor(getResources().getColor(typedValuse.resourceId));
}
}
/**
* 展示一个切换动画
*/
private void showAnimation() {
final View decorView = getWindow().getDecorView();
Bitmap cacheBitmap = getCacheBitmapFromView(decorView);
if (decorView instanceof ViewGroup && cacheBitmap != null) {
final View view = new View(this);
view.setBackgroundDrawable(new BitmapDrawable(getResources(), cacheBitmap));
ViewGroup.LayoutParams layoutParam = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT);
((ViewGroup) decorView).addView(view, layoutParam);
ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(view, "alpha", 1f, 0f);
objectAnimator.setDuration(300);
objectAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
((ViewGroup) decorView).removeView(view);
}
});
objectAnimator.start();
}
}
/**
* 获取一个 View 的缓存视图
*
* @param view
* @return
*/
private Bitmap getCacheBitmapFromView(View view) {
final boolean drawingCacheEnabled = true;
view.setDrawingCacheEnabled(drawingCacheEnabled);
view.buildDrawingCache(drawingCacheEnabled);
final Bitmap drawingCache = view.getDrawingCache();
Bitmap bitmap;
if (drawingCache != null) {
bitmap = Bitmap.createBitmap(drawingCache);
view.setDrawingCacheEnabled(false);
} else {
bitmap = null;
}
return bitmap;
}
}
实现思路和代码解说:
1.DayNightHelper
类是用于保存夜间模式设置到 SharePreferences
的工具类,在 initData
函数中被初始化,其他的 View
和 Layout
都是界面布局,在 initView
函数中被初始化;
2.在 Activity
的 onCreate
函数调用 setContentView
之前,需要先去 setTheme
,因为当 View
创建成功后 ,再去 setTheme
是无法对 View
的 UI
效果产生影响的;
3.onCheckedChanged
用于监听日间模式和夜间模式的切换操作;
4.refreshUI
是本实现的关键函数,起着切换效果的作用,通过 TypedValue
和 Theme.resolveAttribute
在代码中获取 Theme
中设置的颜色,来重新设置控件的背景色或者字体颜色等等。需要特别注意的是 RecyclerView
和 ListView
这种比较特殊的控件处理方式,代码注释中已经说明,大家可以看代码中注释;
5.refreshStatusBar
用于刷新顶部通知栏位置的颜色;
6.showAnimation
和 getCacheBitmapFromView
同样是本实现的关键函数,getCacheBitmapFromView
用于将 View
中的内容转换成 Bitmap
(类似于截屏操作那样),showAnimation
是用于展示一个渐隐效果的属性动画,这个属性作用在哪个对象上呢?是一个 View
,一个在代码中动态填充到 DecorView
中的 View
(不知道 DecorView
的童鞋得回去看看 Android Window
相关的知识)。知乎之所以在夜间模式切换过程中会有渐隐效果,是因为在切换前进行了截屏,同时将截屏拿到的 Bitmap
设置到动态填充到 DecorView
中的 View
上,并对这个 View
执行一个渐隐的属性动画,所以使得我们能够看到一个漂亮的渐隐过渡的动画效果。而且在动画结束的时候再把这个动态添加的 View
给 remove
了,避免了 Bitmap
造成内存飙升问题。对待知乎客户端开发者这种处理方式,我必须双手点赞外加一个大写的服。
到这里,实现套路基本说完了,简书和知乎的实现套路如上所述,区别就是知乎多了个截屏和渐隐过渡动画效果而已。
一些思考
整理逆向分析的过程,也对夜间模式的实现有了不少思考,希望与各位童鞋们探讨分享。
最初步的逆向分析过程就发现了,知乎和简书并没有引入任何第三方框架来实现夜间模式,为什么呢?
因为我看到的大部分都实现夜间模式的思路都是用开源的换肤框架,或多或少存在着些 BUG。简书和知乎不用可能是出于框架不稳定性,以及我前面提到的用换肤框架来实现夜间模式大材小用吧。(我也只是瞎猜,哈哈哈)
前面我提到,通过
setTheme
然后再去Activity recreate
的方案不可行,为什么呢?
我认为不可行的原因有两点,一个是 Activity recreate
会有闪烁效果体验不加,二是 Activity recreate
涉及到状态状态保存问题,如自身的状态保存,如果 Activity
中包含着多个 Fragment
,那就更加头疼了。
知乎和简书设置夜间模式的位置,有点巧妙,巧妙在哪?
知乎和简书出发夜间模式切换的地方,都是在 MainActivity
的一个 Fragment
中。也就是说,如果你要切换模式时,必须回到主界面,此时只存在主界面一个 Activity
,只需要遍历主界面更新控件色调即可。而对于其他设置夜间模式后新建的 Activity
,只需要在 setContentView
之前做一下判断并 setTheme
即可。
总结
关于简书和知乎夜间模式功能实现的套路就讲解到这里,整个实现套路都是我通过逆向分析简书和知乎的代码取得,这里再一次向实现这些代码的童鞋以示感谢。当然,上面的代码我是经过精简提炼过的,在原先简书和知乎客户端中的实现代码还做了相应的抽象设计和递归遍历等等,这里是为了方便讲解而做了精简。如果有童鞋喜欢这种实现套路,也可以自己加以抽象封装。这里也推荐各位童鞋一个我常用的思路,就是当你对一个功能没有思路时,大可找一些实现了这类功能的优秀应用进行逆向代码分析。需要实现代码的童鞋,可以访问:https://github.com/D-clock/AndroidStudyCode