轉(zhuǎn)載自http://blog.coderclock.com/2016/08/28/android/知乎和簡書的夜間模式實(shí)現(xiàn)套路/
說到夜間模式,在網(wǎng)上看到很多童鞋都說用什么什么框架來實(shí)現(xiàn)這個(gè)功能,然后仔細(xì)去看一下各個(gè)推薦的框架,發(fā)現(xiàn)其實(shí)都是動(dòng)態(tài)換膚的,動(dòng)態(tài)換膚可比夜間模式要復(fù)雜多了,未免大材小用了。說實(shí)話,我一直沒用什么好思路,雖然網(wǎng)上有童鞋提供了一種思路是通過 setTheme
然后再 recreate Activity
的方式,但是這樣帶來的問題是非常多的,看起來就相當(dāng)不科學(xué)(為什么不科學(xué),后文會(huì)說)。于是,直接想到了去逆向分析那些夜間模式做得好的應(yīng)用的源代碼,學(xué)習(xí)他們的實(shí)現(xiàn)套路。所以,本文的實(shí)現(xiàn)思路來自于編寫這些應(yīng)用的夜間模式功能的童鞋,先在這里向他們表示感謝。我的手機(jī)里面使用高頻的應(yīng)用不少,其中簡書和知乎是屬于夜間模式做得相當(dāng) nice 的。先給兩個(gè)效果圖大家對(duì)比感受下
如果大家仔細(xì)觀察,肯定會(huì)發(fā)現(xiàn),知乎的切換效果更漂亮些,因?yàn)樗幸粋€(gè)漸變的效果。那么它們的夜間模式到底是如何實(shí)現(xiàn)的呢?別急接著往下看,你也可以。
實(shí)現(xiàn)套路
這里先展示一下我的實(shí)現(xiàn)效果吧
此處分為兩個(gè)部分,一部分是
xml
文件中要干的活,一部分是 Java
代碼要實(shí)現(xiàn)的活,先說 xml
吧。
XML 配置
首先,先寫一套UI界面出來,上方左邊是兩個(gè) TextView
,右邊是兩個(gè) CheckBox
,下方是一個(gè) RecyclerView
,實(shí)現(xiàn)很簡單,這里我不貼代碼了。
接著,在
styles
文件中添加兩個(gè) Theme
,一個(gè)是日間主題,一個(gè)是夜間主題。它們的屬性都是一樣的,唯一區(qū)別在于顏色效果不同。
<!--白天主題-->
<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>
然后再到所有需要實(shí)現(xiàn)夜間模式功能的 xml
布局文件中,加入類似下面設(shè)置,比如我在 RecyclerView
的 Item
布局文件中做了如下設(shè)置
稍稍解釋下其作用,如
TextView
里的 android:textColor=”?attr/clockTextColor”
是讓其字體顏色跟隨所設(shè)置的 Theme
。到這里,xml
需要做的配置全部完成,接下來是 Java
代碼實(shí)現(xiàn)了。
Java 代碼實(shí)現(xiàn)
大家可以先看下面的實(shí)現(xiàn)代碼,看不懂的童鞋可以邊結(jié)合我代碼下方實(shí)現(xiàn)思路解說。
package com.clock.study.activity;
import ...
/**
* 夜間模式實(shí)現(xiàn)方案
*
* @author Clock
* @since 2016-08-11
*/
public class DayNightActivity extends AppCompatActivity implements CompoundButton.OnCheckedChangeListener {
private final static String TAG = DayNightActivity.class.getSimpleName();
/**用于將主題設(shè)置保存到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);
}
}
/**
* 切換主題設(shè)置
*/
private void toggleThemeSetting() {
if (mDayNightHelper.isDay()) {
mDayNightHelper.setMode(DayNight.NIGHT);
setTheme(R.style.NightTheme);
} else {
mDayNightHelper.setMode(DayNight.DAY);
setTheme(R.style.DayTheme);
}
}
/**
* 使用簡書的實(shí)現(xiàn)套路來切換夜間主題
*/
private void changeThemeByJianShu() {
toggleThemeSetting();
refreshUI();
}
/**
* 使用知乎的實(shí)現(xiàn)套路來切換夜間主題
*/
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 對(duì)象,然后同樣再用反射去調(diào)用 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));
}
}
/**
* 展示一個(gè)切換動(dòng)畫
*/
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();
}
}
/**
* 獲取一個(gè) 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;
}
}
實(shí)現(xiàn)思路和代碼解說:
1.DayNightHelper
類是用于保存夜間模式設(shè)置到 SharePreferences
的工具類,在 initData
函數(shù)中被初始化,其他的 View
和 Layout
都是界面布局,在 initView
函數(shù)中被初始化;
2.在 Activity
的 onCreate
函數(shù)調(diào)用 setContentView
之前,需要先去 setTheme
,因?yàn)楫?dāng) View
創(chuàng)建成功后 ,再去 setTheme
是無法對(duì) View
的 UI
效果產(chǎn)生影響的;
3.onCheckedChanged
用于監(jiān)聽日間模式和夜間模式的切換操作;
4.refreshUI
是本實(shí)現(xiàn)的關(guān)鍵函數(shù),起著切換效果的作用,通過 TypedValue
和 Theme.resolveAttribute
在代碼中獲取 Theme
中設(shè)置的顏色,來重新設(shè)置控件的背景色或者字體顏色等等。需要特別注意的是 RecyclerView
和 ListView
這種比較特殊的控件處理方式,代碼注釋中已經(jīng)說明,大家可以看代碼中注釋;
5.refreshStatusBar
用于刷新頂部通知欄位置的顏色;
6.showAnimation
和 getCacheBitmapFromView
同樣是本實(shí)現(xiàn)的關(guān)鍵函數(shù),getCacheBitmapFromView
用于將 View
中的內(nèi)容轉(zhuǎn)換成 Bitmap
(類似于截屏操作那樣),showAnimation
是用于展示一個(gè)漸隱效果的屬性動(dòng)畫,這個(gè)屬性作用在哪個(gè)對(duì)象上呢?是一個(gè) View
,一個(gè)在代碼中動(dòng)態(tài)填充到 DecorView
中的 View
(不知道 DecorView
的童鞋得回去看看 Android Window
相關(guān)的知識(shí))。知乎之所以在夜間模式切換過程中會(huì)有漸隱效果,是因?yàn)樵谇袚Q前進(jìn)行了截屏,同時(shí)將截屏拿到的 Bitmap
設(shè)置到動(dòng)態(tài)填充到 DecorView
中的 View
上,并對(duì)這個(gè) View
執(zhí)行一個(gè)漸隱的屬性動(dòng)畫,所以使得我們能夠看到一個(gè)漂亮的漸隱過渡的動(dòng)畫效果。而且在動(dòng)畫結(jié)束的時(shí)候再把這個(gè)動(dòng)態(tài)添加的 View
給 remove
了,避免了 Bitmap
造成內(nèi)存飆升問題。對(duì)待知乎客戶端開發(fā)者這種處理方式,我必須雙手點(diǎn)贊外加一個(gè)大寫的服。
到這里,實(shí)現(xiàn)套路基本說完了,簡書和知乎的實(shí)現(xiàn)套路如上所述,區(qū)別就是知乎多了個(gè)截屏和漸隱過渡動(dòng)畫效果而已。
一些思考
整理逆向分析的過程,也對(duì)夜間模式的實(shí)現(xiàn)有了不少思考,希望與各位童鞋們探討分享。
最初步的逆向分析過程就發(fā)現(xiàn)了,知乎和簡書并沒有引入任何第三方框架來實(shí)現(xiàn)夜間模式,為什么呢?
因?yàn)槲铱吹降拇蟛糠侄紝?shí)現(xiàn)夜間模式的思路都是用開源的換膚框架,或多或少存在著些 BUG。簡書和知乎不用可能是出于框架不穩(wěn)定性,以及我前面提到的用換膚框架來實(shí)現(xiàn)夜間模式大材小用吧。(我也只是瞎猜,哈哈哈)
前面我提到,通過
setTheme
然后再去Activity recreate
的方案不可行,為什么呢?
我認(rèn)為不可行的原因有兩點(diǎn),一個(gè)是 Activity recreate
會(huì)有閃爍效果體驗(yàn)不加,二是 Activity recreate
涉及到狀態(tài)狀態(tài)保存問題,如自身的狀態(tài)保存,如果 Activity
中包含著多個(gè) Fragment
,那就更加頭疼了。
知乎和簡書設(shè)置夜間模式的位置,有點(diǎn)巧妙,巧妙在哪?
知乎和簡書出發(fā)夜間模式切換的地方,都是在 MainActivity
的一個(gè) Fragment
中。也就是說,如果你要切換模式時(shí),必須回到主界面,此時(shí)只存在主界面一個(gè) Activity
,只需要遍歷主界面更新控件色調(diào)即可。而對(duì)于其他設(shè)置夜間模式后新建的 Activity
,只需要在 setContentView
之前做一下判斷并 setTheme
即可。
總結(jié)
關(guān)于簡書和知乎夜間模式功能實(shí)現(xiàn)的套路就講解到這里,整個(gè)實(shí)現(xiàn)套路都是我通過逆向分析簡書和知乎的代碼取得,這里再一次向?qū)崿F(xiàn)這些代碼的童鞋以示感謝。當(dāng)然,上面的代碼我是經(jīng)過精簡提煉過的,在原先簡書和知乎客戶端中的實(shí)現(xiàn)代碼還做了相應(yīng)的抽象設(shè)計(jì)和遞歸遍歷等等,這里是為了方便講解而做了精簡。如果有童鞋喜歡這種實(shí)現(xiàn)套路,也可以自己加以抽象封裝。這里也推薦各位童鞋一個(gè)我常用的思路,就是當(dāng)你對(duì)一個(gè)功能沒有思路時(shí),大可找一些實(shí)現(xiàn)了這類功能的優(yōu)秀應(yīng)用進(jìn)行逆向代碼分析。需要實(shí)現(xiàn)代碼的童鞋,可以訪問:https://github.com/D-clock/AndroidStudyCode