日间模式和夜间模式的换皮肤在很早期的一些APP中就已经有实践了。用过的众多APP中,知乎的夜间模式换肤算是体验感非常好的。两年前反编译知乎的app学习了人家的实现思路,效果不错。当然最新版的知乎应用上线后也大面积进行了混淆,捋源码会困难一些。本篇基于未混淆的旧版知乎。

早期简单的一种实现方法

换肤的主要工作就是切换颜色,之前我经手的一个项目中也有该功能,做法无非是资源文件中定义了两套theme,布局文件中有关颜色或图片资源等属性用attr引用替代。切换时将主题设置为相应的theme,并调用recreate()重新创建当前Activity。这种方法实现简单,代码少,侵入性小,但是recreate()会销毁当前Activity并重走生命周期,类似EditText、RecyclerView等控件先前的状态和数据都会重置,类似于Configuration改变如屏幕旋转。即使在onSaveInstanceState中保存数据也无法完全保持换肤前的状态,加上Fragment后更有各种奇怪的问题。如果想实现换肤后切回来还可以继续使用刚才的功能,页面不重新创建实例,无疑体验更好。

反编译知乎apk

先看知乎实现效果,引一张gif:

android 如何禁用夜间模式 安卓怎么关夜间模式_夜间模式

知乎apk解压后内部:

android 如何禁用夜间模式 安卓怎么关夜间模式_xml_02


应用较大,用了MultiDex方案,全部用dex2jar转为jar包,然后用jd-gui打开查看.class文件。通过adb logcat|grep ActivityManager查看具体的主页面路径,定位到源码所在的位置,也是叫MainActivity~。代码很多,根据Fragment的命名,在MainActivity中的创建顺序确定了设置夜间模式的Fragment,果然是实现了CompoundButton.OnCheckedChangeListener接口,实现方法:

public void onCheckedChanged(CompoundButton paramCompoundButton, boolean paramBoolean)
  {
    if (paramBoolean);
    for (int i = 2; ; i = 1)
    {
      ThemeSwitcher.switchThemeTo(i, true);
      getMainActivity().showAutoSwitchThemeTip();
      ThemeSwitcher.setAutoThemeTime(getContext(), i);
      return;
    }
  }

点进去看,经过一层封装后:

public static void switchThemeTo(int paramInt)
  {
    setCurrentTheme(paramInt);
    Iterator localIterator = ZHActivity.sActivityStack.iterator();
    while (localIterator.hasNext())
      ((ZHActivity)localIterator.next()).switchTheme(paramInt);
  }

原来它是让需要变换主题的Activity继承自己封装的ZHActivity,内部维护了一个ArrayList存储当前未销毁的Activity。切换的时候遍历列表调用每个Activity的switchTheme,精简该方法如下:

public void switchTheme(int paramInt)
  {
   
      View localView1 = getWindow().getDecorView();
      Bitmap localBitmap = obtainCachedBitmap(localView1);
      if (((localView1 instanceof ViewGroup)) && (localBitmap != null))
      {
        View localView2 = new View(this);
        localView2.setBackgroundDrawable(new BitmapDrawable(getResources(), localBitmap));
        ((ViewGroup)localView1).addView(localView2, new ViewGroup.LayoutParams(-1, -1));
        switchThemeInternal(i);
        localView2.animate().alpha(0.0F).setDuration(300L).setListener(new ZHActivity.1(this, localView1, localView2, i)).start();
     
      }
  }

原来在这里使用了一个小技巧:为了避免切换主题的时候太生硬,先将未变换前的整个Window页面(DecorView)缓存下来,相当于截图覆盖在窗体上。切换后主题后做一个渐变的动画,完全变为透明时移除该View。可以说非常巧妙了,体验也好。真正实现主题切换的在switchThemeInternal里,这里做了一系列的操作:setTheme,变换背景颜色,变换标题栏颜色(5.0以上),最重要的是该方法:

private void change(View paramView)
  {
    if ((paramView instanceof IDayNightView));
  
      ((IDayNightView)paramView).resetStyle();
      if ((paramView instanceof ViewGroup))
        for (int i = 0; i < ((ViewGroup)paramView).getChildCount(); i++)
          change(((ViewGroup)paramView).getChildAt(i));
   
  }

原来是所有布局中需要换肤的控件都实现了IDayNightView这个接口,从DecorView顶层开始,递归调用内部所有子节点,类型为IDayNightView则调用其resetStyle。因为setTheme无法在当前的Activity立即生效,那么最直接最平滑的方法就是手动一个个去改变控件的属性,虽然麻烦了一些,但是完全不用影响组件的生命周期,也完整的保持了用户的操作状态。
这里知乎的换肤主要思路就理清了。

自己实现在Fragment中设置换肤

搭建界面

首先仿照知乎界面,搭建一个MainActivity和五个Fragment,每个Fragment可以放不同类型的控件来测试实现效果。定义一个BaseActivity让MainActivity继承,该类中就包含了上述切换主题的方法。

自定义属性和主题

首先在attr.xml中创建自定义属性,为的是动态引用主题中设置的对应值:

<?xml version="1.0" encoding="utf-8"?>
<resources >
    <attr name="textMainColor" format="color" />
    <attr name="backgroundMainColor" format="color" />
    <attr name="statusbarMainColor" format="color" />
    <attr name="drawableRes" format="reference" />
    <attr name="textCursorRes" format="reference" />
    <attr name="editbgRes" format="reference" />
</resources >

然后定义两套主题,这里就是日间模式和夜间模式:

<?xml version="1.0" encoding="utf-8"?>
<resources >

    <style name="Theme_Day" parent="Theme.AppCompat.Light.NoActionBar">
        <item name="colorPrimary" >@color/colorPrimary</item >
        <item name="colorPrimaryDark" >@color/colorPrimaryDark</item >
        <item name="colorAccent" >@color/colorAccent</item >

        <!--自定义-->
        <item name="textMainColor">#26364D</item>
        <item name="backgroundMainColor">#F7F8FA</item>
        <item name="statusbarMainColor">#3F51B5</item>
        <item name="drawableRes">@drawable/ic_live_share_day</item>
        <item name="textCursorRes">@drawable/textcursor_day</item>
        <item name="editbgRes">@drawable/edittext_bg_day</item>
    </style>

    <style name="Theme_Night" parent="Theme.AppCompat.Light.NoActionBar">
        <item name="colorPrimary" >#555555</item >
        <item name="colorPrimaryDark" >#26364D</item >
        <item name="colorAccent" >#F9BF66</item >

        <!--自定义-->
        <item name="textMainColor">#F96666</item>
        <item name="backgroundMainColor">#667080</item>
        <item name="statusbarMainColor">#26364D</item>
        <item name="drawableRes">@drawable/ic_live_share_night</item>
        <item name="textCursorRes">@drawable/textcursor_night</item>
        <item name="editbgRes">@drawable/edittext_bg_night</item>
    </style>
</resources >
封装控件

各种支持换肤的控件,其实就是原生控件实现IDayNightView接口,知乎里面的实现方法稍复杂,我这里的resetStyle入参为当前设置的Theme,根据Theme和对应的attr获取相对应的属性,以TextView改变文字颜色为例:

@Override
public void resetStyle(Resources.Theme theme) {
    TypedArray typedArray = theme.obtainStyledAttributes(new int[]{R.attr.textMainColor});
    int color = typedArray.getColor(0, 0);
    this.setTextColor(color);
    typedArray.recycle();
}

以此类推,Button、ImageView、背景色、标题栏颜色都可以按此方法设置。但是RecyclerView和EditText稍特殊一些。

RecyclerView不用手动改变属性,只要其item布局中的子控件支持换肤就可以了。但是由于视图缓存复用的原因,会出现一家在的列表有部分状态没有改变,导致一块和一块状态不一致的问题。解决的办法就是调用RecyclerView中负责回收复用视图的Recycler中的clear方法清空缓存,该方法只提供了默认权限,需要反射调用:

@Override
public void resetStyle(Resources.Theme theme) {
    try {
        Field mRecycler = RecyclerView.class.getDeclaredField("mRecycler");
        mRecycler.setAccessible(true);
        Method clear = Recycler.class.getDeclaredMethod("clear");
        clear.setAccessible(true);
        clear.invoke(mRecycler.get(this));
    } catch (NoSuchFieldException e) {
        e.printStackTrace();
    } catch (NoSuchMethodException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    } catch (InvocationTargetException e) {
        e.printStackTrace();
    }
    this.getRecycledViewPool().clear();

}

EditText中光标的颜色默认是和colorAccent保持一致的,在不recreate的情况下我们只能手动为其设置android:textCursorDrawable属性,但是EditText及其父类中并没有提供API可以代码设置,只能通过反射修改:

private void resetEditAttr(Resources.Theme theme) {
    try {
        TypedArray typedArray = theme.obtainStyledAttributes(new int[]{R.attr.textCursorRes});
        Drawable drawable = typedArray.getDrawable(0);
        typedArray.recycle();

        Field fEditor = TextView.class.getDeclaredField("mEditor");
        fEditor.setAccessible(true);
        Object editor = fEditor.get(this);
        Class<?> clazz = editor.getClass();
        Field fCursorDrawable = clazz.getDeclaredField("mCursorDrawable");
        fCursorDrawable.setAccessible(true);
        Drawable[] drawables = new Drawable[]{drawable};
        fCursorDrawable.set(editor, drawables);
    } catch (NoSuchFieldException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }
    setCursorVisible(true);
}

变换主题

在设置换肤的Fragment中监听控件状态,当状态改变后为当前的主题模式定义相对应的值,持久化存储到本地,这里用SharedPreferences。然后调用BaseActivity中的switchTheme变换主题。这里有一个点,安卓从23以后Android Support包提供了一个切换日间/夜间模式的方法:
AppCompatDelegate.setDefaultNightMode(themeMode); //0:自动切换 1:日间模式 2:夜间模式。还是以变换TextView的文字颜色为例,在res目录下创建一个value-night文件夹,新建一个colors.xml,这里定义的颜色只要名称和value文件夹下colors.xml中的保持一致,那么切换到夜间模式后颜色就会自动选取value-night下的。

value

<?xml version="1.0" encoding="utf-8"?>
<resources >
    <!--日间模式-->
    <color name="colorPrimary" >#3F51B5</color >
    <color name="colorPrimaryDark" >#303F9F</color >
    <color name="colorAccent" >#FF4081</color >
    
    <color name="textColor">#26364D</color>
    <color name="backgroundColor" >#F7F8FA</color>
</resources >

value-night

<?xml version="1.0" encoding="utf-8"?>
<resources >

    <!--夜间模式-->
    <color name="colorPrimary" >#555555</color >
    <color name="colorPrimaryDark" >#26364D</color >
    <color name="colorAccent" >#F9BF66</color >

    <color name="textColor" >#F96666</color>
    <color name="backgroundColor">#667080</color>
</resources >

同理还有drawable-night等,这样切换主题后将值保存到SP中,在Application初始化的时候就可以根据值选择主题。当然这种方法也是无法在不重新创建的情况下改变当前页面的主题的。

这样,我们把需要动态改变主题的用仿知乎的方式实现,而在此基础上新开启的页面中,就可以用上述的方式实现,不需要封装组件,也不需要遍历和调用方法,只需要在对应的night文件夹中创建对应的xml文件,并在布局中正常引用即可。

效果

android 如何禁用夜间模式 安卓怎么关夜间模式_xml_03

这就初步完成了仿知乎的夜间模式切换,多个主题的切换与此类似,只是多加点逻辑判断。当然这种方式有局限,就是主题是内置在应用中的,无法做到动态获取新的主题,定制性基本没有,如果有这种需求,就需要考虑插件式换肤的方案。