Android 主题切换(Theme)实现日夜间功能

7,711 阅读15分钟

前言

随着一款APP应用功能的不断完善,用户群体的不断增多,APP的更新也就不仅仅局限于功能需求,如何做好良好的用户体验,让用户传播良好的体验口碑,显得尤为重要,而用户体验一块日夜间模式俨然成为了标配。其实,日夜间功能就是换肤的一种,关于换肤功能的实现,也是众说纷纭,总的来讲分为两类:主题换肤(Theme)和插件换肤(APK换肤)。

插件换肤 插件换肤的实现原理就是主APK根据当前环境需求,解析指定目录下对应的插件APK,获得其中同名的资源文件并动态替换到主APK的应用程序中。插件APK并不需要安装,只需要放置在指定目录下即可。

  • 优点: 能够实现各种主题样式的加载,比较灵活,需要增添新的主题只要新建一个插件APK,并配置好相关的资源,放置到指定的文件目录下就行,很方便。
  • 缺点: 需要对控件进行适配修改,实现换肤功能,对于自定义控件,也需要在适配上花点时间。而且放置在文件夹中的插件APK也可能会因为被误删或是损坏而造成资源获取不到,导致换肤失败。

主题换肤 主题换肤的实现原理就是在主apk配置多套主题,每套主题对同一个属性使用相应的资源。

  • 优点: 相比插件换肤来说更容易上手,理解起来也会更容易。
  • 缺点: 增添新的主题样式必须要发布新版本。全部资源文件都放在APK中,APK会显得十分臃肿,特别是图片资源,因此个人推荐纯色线条的图标,并通过着色来实现不同主题下换肤的可能。

因为今天的主题是日夜间模式,考虑到并不会涉及主题样式增添的可能,所以权衡之下还是选择使用主题换肤来实现日夜间模式,老套路,效果预览(文末将附上高清地址入口)


预览效果第一季枪版
预览效果第二季枪版

准备相关的属性样式及主题:

自定义attr属性:

主题换肤和插件换肤原理其实一样,就是控制不同模式下加载对应的资源文件,只是实现的方式不同而已。以往我们在写xml布局文件的时候,默认的属性赋值都是绝对的,即android:background="#FFFFFF"android:background="@color/white"
而一旦属性被这样赋值,默认的资源加载就被限制,倘若有需求需要视图在加载时能够根据当前环境配置特定的资源,那就只能在Java程序代码中动态修改,繁琐程度可想而知。那么是否一个办法能够使xml属性的赋值能够动态的根据当前主题样式的改变而去加载默认的资源呢 ?
有,那就是今天的腕儿:自定义属性。在我看来自定义属性在主题换肤中充当着占位符的角色,它会告诉系统这是一个相对的引用,真正的资源引用是当前上下文环境所对应的主题样式属性列表中,对这个自定义属性的赋值。

1.在res-value目录下新建attr属性的资源文件,例如:custom_theme_attrs.xml
2.在custom_theme_attrs.xml文件中新建自定义属性。

格式:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <attr name="自定义属性名称" format="资源引用格式(color、dimen、reference...)" />
</resources>

示例:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 控制app背景色 format:颜色值、资源引用 -->
    <attr name="custom_attr_app_bg" format="color|reference" />
    <!-- 控制app标题栏背景色 format:颜色值、资源引用 -->
    <attr name="custom_attr_app_title_layout_bg" format="color|reference" />
    <!-- 用户头像显示占位Drawable format:颜色值、资源引用 -->
    <attr name="custom_attr_user_photo_place_holder" format="color|reference" />
    <!-- 用户昵称字体颜色 format:颜色值、资源引用 -->
    <attr name="custom_attr_nickname_text_color" format="color|reference" />
    <!-- 用户备注字体颜色 format:颜色值、资源引用 -->
    <attr name="custom_attr_remark_text_color" format="color|reference" />
    <!-- 用户头像显示的透明度 format:尺寸值、资源引用 -->
    <attr name="custom_attr_user_photo_alpha" format="dimension|reference" />
</resources>

写过自定义View的朋友一定不会陌生,不就是自定义属性嘛。区别就是这些属性值没有包裹在styleable中,至于为啥我就不班门弄斧,有需要的朋友可以了解简书作者楚云之南写的《深入理解Android 自定义attr Style styleable以及其应用》,感觉写的不错,感谢分享 !!

自定义theme主题:

Style想必并不陌生,在需要写很多类似的代码块时,我们通常会提取其中共有部分,配置在Style中,直接在xml中的style属性中引用即可,非常方便。这里所说的主题其实也是Style样式中的一种,只是它不仅仅局限于控件样式属性的赋值,常常还涉及到window窗口相关,就是样式属性的一个集合。既然是通过切换主题来切换应用UI样式,所以在定义Style主题样式的时候,需要准备多套主题样式。

1.在res-value目录下新建style属性的资源文件,例如:custom_theme_styles.xml
2.在custom_theme_styles.xml文件中新建自定义主题,并对特定的系统、自定义属性进行赋值操作。

格式:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="自定义主题样式的名称" parent="继承的主题,可以是自定义主题样式也可以是系统主题样式">
        <item name="属性名称">赋值的对应资源</item>
    </style>
</resources>

示例:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="MarioTheme" parent="Theme.AppCompat.Light.DarkActionBar" >
        <!-- 隐藏Activity窗口的ActionBar -->
        <item name="windowActionBar">false</item>
        <!-- 隐藏Activity窗口的Title标题栏 -->
        <item name="windowNoTitle">true</item>
    </style>

    <style name="MarioTheme.Day" >
        <!-- 日间模式下 "custom_attr_app_bg" 的赋值为#FFFFFF -->
        <item name="custom_attr_app_bg">#FFFFFF</item>
        ...
    </style>

    <style name="MarioTheme.Night" >
        <!-- 夜间模式下 "custom_attr_app_bg" 的赋值为#1F1F1F -->
        <item name="custom_attr_app_bg">#1F1F1F</item>
        ...
    </style>
</resources>

如上述示例所示,首先是新建一个继承自系统Theme.AppCompat.Light.DarkActionBar样式的自定义主题MarioTheme算是一个主题的Base基础主题,在这个基础主题中,可以对一些通用的属性进行赋值,比如一些全局性的窗口样式,当然这些赋值上去的属性也是可以被后来继承的子类主题覆盖。
然后又新建了两个继承自这个基础主题的MarioTheme.DayMarioTheme.Night分别作为日间和夜间的主题,而且分别在两个主题中对自定义属性custom_attr_app_bg进行了赋值。

其实通过上述两个步骤:[自定义属性 --> 自定义主题,并在主题中对自定义属性进行相应的赋值],主题换肤的准备工作可以说是已经完成。但是为了项目的可维护性更高,尚且有不少可以优化的地方,如上#1F1F1F颜色值直接出现在style中。这是我非常反对的一种操作方式,在使用主题换肤的应用中,随着应用功能的强大,自定义属性的数量一定会越来越多,而且我觉得自定义属性定义的越精细越好,所以一定会有一个庞大数量的属性列表需要去维护。其中也有可能大部分是可以被重复使用的,何不将它们整理到统一的文件中,倘若到时候需求变化,资源引用需要修改,也不至于全局搜索挨个去改,何必给自己增加这么多没有必要的工作量呢 ! 所以我还要讲讲自定义属性 。

自定义resource资源:

同类型的资源新建在对应的目录下,尺寸资源定义在values-dimens目录下,颜色资源定义再values-colors目录下,drawable资源定义在values目录下对应的drawable目录下... 并且每一种资源都应该根据不同主题样式配置多套。

自定义color:

1.在res-value目录下新建color属性的资源文件,例如:custom_theme_colors.xml
2.在custom_theme_colors.xml文件中新建自定义color颜色。

格式:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="自定义color名称_day">对应的日间颜色值</color>
    <color name="自定义color名称_night">对应的夜间颜色值</color>
</resources>

定义app背景色为例:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 日间模式下app背景色 -->
    <color name="custom_color_app_bg_day">#FFFFFF</color>
    <!-- 日间模式下app标题栏背景色 -->
    <color name="custom_color_app_title_layout_bg_day">#FF2F3A4C</color>        
    <!-- 夜间模式下app背景色 -->
    <color name="custom_color_app_bg_night">#1F1F1F</color>
    <!-- 夜间模式下app标题栏背景色 -->  
    <color name="custom_color_app_title_layout_bg_night">#FF1D1D1D</color> 
    ... 
</resources>
自定义drawable:

1.在res目录下新建drawable文件夹。
1.在res-drawable目录下新建drawable资源文件。

定义圆形图片的占位drawable,示例:

custom_drawable_user_photo_place_holder_day.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/custom_color_user_photo_place_holder_bg_day" />
    <corners android:radius="32dp" />
</shape>

custom_drawable_user_photo_place_holder_night.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/custom_color_user_photo_place_holder_bg_night" />
    <corners android:radius="32dp" />
</shape>

自定义drawable中使用到的颜色值推荐也统一整理到custom_theme_colors.xml文件中。

<!-- 用户头像占位drawable背景颜色 -->
<color name="custom_color_user_photo_place_holder_bg_day">#29303B</color>
<color name="custom_color_user_photo_place_holder_bg_night">#171717</color>
自定义colorStateList:

同SelectorDrawable一样,color也可以设置Selector选择器。
1.value目录下新建color.xml文件。
2.在res-color.xml目录下新建color资源文件。

示例:

custom_selector_text_day.xml

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:color="@color/custom_color_text_pressed_day" android:state_pressed="true" />
    <item android:color="@color/custom_color_text_day" />
</selector>

custom_selector_text_night.xml

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:color="@color/custom_color_text_pressed_night" android:state_pressed="true" />
    <item android:color="@color/custom_color_text_night" />
</selector>

同理,自定义colorStateList中使用到的颜色值推荐也统一整理到custom_theme_colors.xml文件中。

在不同的主题样式下为自定义属性赋值:

示例:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    // 日间相关属性集
    <style name="MarioTheme.Day" >
        <item name="custom_attr_app_bg">@color/custom_color_app_bg_day</item>
        <item name="custom_attr_app_title_layout_bg">@color/custom_color_app_title_layout_bg_day</item>
        <item name="custom_attr_user_photo_place_holder">@drawable/custom_drawable_user_photo_place_holder_day</item>
    </style>
    // 夜间相关属性集
    <style name="MarioTheme.Night" >
        <item name="custom_attr_app_bg">@color/custom_color_app_bg_night</item>
        <item name="custom_attr_app_title_layout_bg">@color/custom_color_app_title_layout_bg_night</item>
        <item name="custom_attr_user_photo_place_holder">@drawable/custom_drawable_user_photo_place_holder_night</item>
    </style>
</resources>

到这里就完成了相关的准备工作。因为在日夜间模式切换中基本不太会涉及字符串、尺寸的资源样式的修改,实现的方式是一样的,因此不做过多的赘述,有需要的朋友可以自定义去尝试。

在XML布局文件中使用自定义属性:

只要前期准备工作做好了使用起来其实是非常简单的,就是在属性赋值的时候不再使用绝对的资源引用,而是引用已经完成赋值的自定义的属性:

android:需要修改的属性="?attr/自定义属性名称"

这样的话只要设置自定义属性的View控件的Context上下文环境设置了对应的Theme主题样式,且对我们的自定义样式进行了相应的赋值,则样式的使用就会奏效,切记,项目中使用到的属性一定要在使用的主题样式下赋值,否则应用运行的时候会报错。当然为了更好的开发体验,我们可以在预览模式下设置对应的主题预览我们设置的样式效果是否起效,效果怎么样。 ☟

Demo部分布局代码展示,示例:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:background="?attr/custom_attr_app_bg"
    android:layout_height="match_parent"
    android:layout_width="match_parent"
    android:orientation="vertical">

    <View
        android:background="?attr/custom_attr_app_title_layout_bg"
        android:id="@id/custom_id_title_status_bar"
        android:layout_width="match_parent"
        android:layout_height="0dp" />

    <RelativeLayout
        android:background="?attr/custom_attr_app_title_layout_bg"
        android:id="@id/custom_id_title_layout"
        android:layout_width="match_parent"
        android:layout_height="136dp"
        android:paddingBottom="16dp"
        android:paddingRight="12dp"
        android:paddingLeft="12dp"
        android:paddingTop="8dp" >

        <ImageView
            android:padding="3dp"
            android:layout_width="72dp"
            android:layout_height="72dp"
            android:id="@+id/theme_user_photo"
            android:layout_alignParentLeft="true"
            android:layout_alignParentBottom="true"
            android:alpha="?attr/custom_attr_user_photo_alpha"
            tools:src="?attr/custom_attr_user_photo_place_holder" />

        <LinearLayout
            android:layout_toRightOf="@+id/theme_user_photo"
            android:layout_alignTop="@+id/theme_user_photo"
            android:layout_width="wrap_content"
            android:gravity="center_vertical"
            android:layout_marginLeft="12dp"
            android:orientation="vertical"
            android:layout_height="72dp" >

            <TextView
                android:textColor="?attr/custom_attr_nickname_text_color"
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:id="@+id/theme_nickname"
                android:text="@string/nickname"
                android:textSize="19dp" />

            <TextView
                android:textColor="?attr/custom_attr_remark_text_color"
                android:text="@string/remark"
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:layout_marginTop="3dp"
                android:id="@+id/theme_remark"
                android:textSize="12dp" />
        </LinearLayout>
    </RelativeLayout>
</LinearLayout>

日间预览
夜间预览

预览中的两个主题MarioTheme.Day.PreviewMarioTheme.Night.Preview分别继承之MarioTheme.DayMarioTheme.Night,并没有在项目中使用起来,主要用来控制状态栏的颜色,个人用于编辑器状态栏沉浸效果的一个预览效果。

到这一步,在Activity中使用setTheme()就能加载对应主题的视图啦 !! 有没有很赞 ?
需要注意的一点是 setTheme()方法必须要在系统调用setContentView()方法前调用,个人推荐统一写到基类BaseActivity的onCreate()方法中。而我们需要做的就是在本地SharePreference中配置一个tag控制BaseActivity设置不同的主题就行啦 !
也许看到这里你已经跃跃欲试,或者你已经一步一步照着写到这里,但是应用跑起来却发现,在Activity中点击按钮调用setTheme()方法,Activity并不会发生变化,或者返回上一个Activity也是没有变化。并不是setTheme()方法没有奏效,setTheme()方法确实起到应有的效果了(可以调用getTheme()方法查看,当前主题确实已经改变)。那又是什么原因呢? 那是因为这些视图都是已经加载完成,设置主题并不会触发系统去刷新UI,因此需要我们手动去触发。

而更改主题后的UI刷新我推荐两种:

  • 重新创建Activity 关于重新创建Activity,只需要调用Activity的recreate()方法就行,普通不复杂的UI,用这个方法基本可以满足,其中主要涉onSaveInstanceState()应用状态的保存,而使用这种方法重新创建Activity也是Google官方比较推崇的,有兴趣可以了解一下
  • 手动加载当前主题下的应用资源 这是我这里需要重点讲一下的。由于UI的复杂性和特殊性,并不是所有应用的Activity都可以通过onSaveInstanceState()来保存当前的应用状态的,因此了解如何从当前主题获取需要的属性资源显得尤为重要。

获得当前主题自定义属性指定的资源:

其实获取这个资源也很简单,也就两步:

  • Step-01 获取TypedValue

      TypedValue typedValue = new TypedValue(); 
      Resources.Theme theme = getTheme(); 
      try {
          theme.resolveAttribute(R.attr.自定义属性, typedValue, true);
      } catch (Exception e) {
          e.printStackTrace();
      }

首先定义一个TypedValue用于承载Resource资源属性,然后获取当前上下文对应的Theme主题,再是通过resolveAttribute()方法获取当前主题下给定属性ID对应的资源信息并赋值给定义好的typedValue。因为可能存在给定属性对应的资源信息获取不到而抛出的异常,所以建议try&catch一下,捕获可能存在的异常情况

  • Step-02 根据获取的TypedValue所包含的资源信息获取对应的资源

      Resources resources = getResources();
          try {
              int color = ResourcesCompat.getColor(resources, typedValue.resourceId, null); // 获取颜色值
              Drawable drawable = ResourcesCompat.getDrawable(resources, typedValue.resourceId, null); // 获取Drawable对象
              String string = resources.getString(typedValue.resourceId); // 获取字符串
          } catch (Exception e) {
              e.printStackTrace();
          }

TypedValue最重要的一个属性就是resourceId,只要确定获取的typedValue不为null。我们就可以通过typedValue.resourceId获取资源的id,就好比知道了一个颜色资源的ID是R.color.black,让你去获取颜色值,知道一个Drawable资源的ID是R.drawable.ic_luncher,让你去获取Drawable对象,想想就简单(捂脸.jpg)。需要注意的是在获取对应资源的时候为避免资源获取失败抛出的异常,各种获取资源的方法还是建议用try&catch包裹一下。关于资源获取,文末给出的Demo中有一个MarioResourceHelper的辅助类,该类对资源获取一块进行了一个小封装,用起来会更加方便。

而接下来需要做的就是对特定的资源进行替换就好了。

补充一点:

关于前文提到主题换肤缺点时,其中一点就是所有资源文件都需要放置在主APK文件中打包发布,也许不同的主题就会有多套图片资源,在Android有限内存的条件下,这是一种非常糟糕的情况。
而在前文我也提及,应对这种现象,我们开发能做的就是使用drawable着色的方式,尽量用一套图片资源实现多种主题。切记! 着色的图片要求纯色且背景透明的PNG,因为着色并不能区分色彩,而是对所有非透明区域统一着色上指定的颜色。着色细节不做赘述,线上《Drawable着色的后向兼容》一文阐述的比较详细了吧,感谢作者分享 !!而我们要做的就是将需要的着色上去的颜色值定义在不同的主题下,不同主题获取对应的颜色值,并对特定的drawable进行着色即可。 而MarioResourceHelper辅助类也会对drawable的着色方法做相应的封装。

☞ Github项目源码地址 ☜

最后附上前文两张动图的原版录制视频,观看效果更佳 !!

预览效果第一季蓝光
预览效果第二季蓝光

作者申明:如果文中有表述不当或阐述错误的地方,还望正在看文章的您可以帮忙指出,有疑惑也可以在评论区提问或者私信,期待您的意见和建议,欢迎关注交流,转载请注明出处 !