Android Compose UI 深色模式适配探索与实践

1,077 阅读10分钟

什么是深色模式?

Android 深色模式(Dark Mode)是 Android 操作系统提供的一种显示模式,它将应用程序的整体配色方案从亮色模式切换为暗色模式。在深色模式下,背景通常为深色或黑色,而文本和其他 UI 元素则使用浅色调。深色模式不仅可以改善视觉体验,还可以在某些场景下减少电量消耗,尤其是在 AMOLED 屏幕上。

Android Compose如何适配深色模式

xml布局时代

在非ComposeUI的情况下,我们可以使用资源文件限定符的形式(drawable-nightvalues-night)进行深色模式和浅色模式资源进行区分。在上述文件夹下放入深色模式图片,文案及color资源等。上层使用没有变化。即还是按正常获取方式进行获取,当app在深色模式情况下,会自动获取其位于night文件夹下的资源。实现资源自动适配。那么Compose中深色模式适配也会如此简单吗?它的图片和颜色资源等是如何适配深色模式的?

Compose遇到的问题

在Compose中系统给我们提供了两个方法colorResource()painterResource(),我们还是可以通过资源id形式,用这两个方法去分别拿图片资源和颜色资源的。但是经过详细测试后,令人烦恼的事情发生了。我们发现在系统设置深色模式和app不一致时,代码总是选择系统设置的去加载。举个例子:如果app设置深色模式,但是安卓系统设置的是浅色模式。那么Compose中通过 colorResource()painterResource() 拿取到的资源也是浅色模式始终跟随系统,而不是根据app配置的,这和产品需求以及开发预期都是不一致的。下面我们介绍下深色模式切换配置,以及这两个方法的使用。

f78253ceaa3853e7884b6d894c193718.jpg

  • 设置跟随系统
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
  • 设置浅色模式
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
  • 设置深色模式
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
  • colorResource的使用

如果在values-night文件夹下有配置app_bar_bg_color,则程序会根据系统的深色或浅色模式自动拿取相应的color资源

colorResource(id = R.color.app_bar_bg_color)

此方法返回的Color可直接赋值给Compose的Text组件

  • painterResource的使用

如果在drawable-night文件夹下有配置icon_info_preview,则程序会根据系统的深色或浅色模式自动拿取相应的drawable资源

painterResource(id = R.drawable.icon_info_preview)

此方法返回的painter可直接赋值给Compose的Image组件

解决办法

遇到上述问题后,我们知道不能只是简单使用 colorResource()painterResource() 方法获取color和图片资源资源进行深色模式适配。后面我想到两种方式:

1主题

利用Compose官方主题进行颜色适配是google官方比较推荐的一种方式。

在 Jetpack Compose 中,主题(Theme)可以用来定义应用的整体外观和样式,例如颜色、形状、排版等。通过配置主题,开发者可以轻松应用自定义的风格到整个应用或特定的组件上。

Compose 使用 MaterialTheme 作为主要的主题系统,这与传统的 Android XML 主题不同。通过 MaterialTheme,可以控制应用的配色方案、字体、形状等。

1. 创建主题

Compose 主题的配置通常通过 MaterialTheme 组件来实现。MaterialTheme 提供了三大核心属性:

• colors: 控制颜色方案

• typography: 控制字体排版

• shapes: 控制组件的形状(例如按钮的圆角等)

@Composable
fun MyAppTheme(content: @Composable () -> Unit) {
    // 使用 MaterialTheme 来定义应用的主题
    MaterialTheme(
        colorScheme = myColorScheme(),
        typography = myTypography(),
        shapes = myShapes(),
        content = content
    )
}

2. 配置颜色

在 Compose 中,颜色是通过 colorScheme 来定义的,类似于传统的 colors.xml 文件。你可以自定义颜色并应用到主题中。使用 Color 类来定义颜色,然后在 ColorScheme 中设置它们。方式如下

import androidx.compose.ui.graphics.Color
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme

// 定义颜色
val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)

// 配置浅色模式的颜色方案
private val LightColors = lightColorScheme(
    primary = Purple500,
    onPrimary = Color.White,
    primaryContainer = Purple200,
    onPrimaryContainer = Color.Black,
    secondary = Teal200
)

// 配置深色模式的颜色方案
private val DarkColors = darkColorScheme(
    primary = Purple200,
    onPrimary = Color.Black,
    primaryContainer = Purple700,
    onPrimaryContainer = Color.White,
    secondary = Teal200
)

// 颜色方案选择函数,自动根据模式选择
@Composable
fun myColorScheme(isDarkTheme: Boolean = false) = if (isDarkTheme) DarkColors else LightColors

3. 使用颜色

@Composable
fun MyAppTheme(isDarkTheme: Boolean = false, content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = myColorScheme(isDarkTheme),
        typography = myTypography(),
        shapes = myShapes(),
        content = content
    )
}

4. 将主题应用到界面中

@Composable
fun MyApp(content: @Composable () -> Unit) {
    MyAppTheme {
        // 所有的 UI 都会继承定义的主题样式
        Surface {
            content()
        }
    }
}

5. 使用主题中的属性

在 Composable 函数中,使用 MaterialTheme 提供的属性来动态地获取主题中的颜色、排版或形状:

import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color

@Composable
fun Greeting() {
    Text(
        text = "Hello, Compose!",
        color = MaterialTheme.colorScheme.primary,  // 使用主题的主颜色
        style = MaterialTheme.typography.titleLarge // 使用主题的排版样式
    )
}

主题总结:

上面的主题形式可解决深色模式适配的问题。但同时又暴露出另外的问题。

  1. colorScheme中的颜色名称都是预置的不能很好的自定义。无法覆盖我们UI的设计师千奇百怪的色值需求,面对五彩斑斓的黑的需求我们无法应对。
  2. colorScheme中大量内置名称公司APP基本应用不上。定义标准不一样不能强复用。

2自定义适配

根据上面主题的启发,和之前在Flutter中做深色模式适配的经验。我想到了另外一种适配深色模式的方法。介绍如下:

1. 定义开放基类 IeltsColors

open class IeltsColors(
    val app_bg_gray_color:Color,
    val app_bg_bar_color:Color,
    val app_text_primary_color:Color,
    val app_text_secondary_color:Color,
    val app_line_gray_color:Color,
    val app_dialog_bg_color:Color,
    //深色浅色模式通用的在此处直接赋值
    val C_FFFE2442: Color =Color(0xFFFE2442),
    val C_FFFE5B64: Color =Color(0xFFFE5B64),
    val C_FF8C91A3: Color =Color(0xFF8C91A3),
    val app_iconfont_color:Color,
)

其中未给出默认值的是深色模式和浅色模式有不同的色值,给出默认值的则为深浅两种模式相同色值。

2. 定义浅色模式,色值类(继承自IeltsColors)并给其中基类没有默认值的属性赋值

/**
 * lightColor 浅色模式
 */
class LightColor : IeltsColors(
    app_bg_gray_color = Color(0xFFF5F6F9),
    app_bg_bar_color = Color(0xFFFFFFFF),
    app_text_primary_color = Color(0xFF32374E),
    app_text_secondary_color = Color(0xFF989DA9),
    app_line_gray_color = Color(0xFFECECEC),
    app_dialog_bg_color = Color(0xFFFFFFFF),
    app_iconfont_color = Color(0xFF32374E),
)

3. 定义深色模式,色值类(继承自IeltsColors)并给其中基类没有默认值的属性赋值

/**
 * darkColor 深色模式
 */
class DarkColor : IeltsColors(
    app_bg_gray_color = Color(0xFF000000),
    app_bg_bar_color = Color(0xFF1D1B1D),
    app_text_primary_color = Color(0xFFF2F4FF),
    app_text_secondary_color = Color(0xFF959BA0),
    app_line_gray_color = Color(0xFF3F4044),
    app_dialog_bg_color = Color(0xFF4F4A4F),
    app_iconfont_color = Color(0xFFF2F4FF),
)

4. 创建IeltsTheme

根据上面的物料,我们可以定义自己的主题对象啦。代码如下:

object IeltsTheme{
    //定义light和dark颜色
    private val lightColors = LightColor()
    private val darkColors = DarkColor()
    //定义外接拿取的theme。
    private var darkTheme = DarkModeUtils.isNightMode(IeltsApp.getIeltsApp())
    var currentMode: IeltsColors = if(darkTheme){
        darkColors
    }else{
        lightColors
    }

    /**
     * Activity启动或暗黑切换时,重置theme
     */
    fun resetDarkTheme():Boolean{
        darkTheme =  DarkModeUtils.isNightMode(IeltsApp.getIeltsApp())
        currentMode = if(darkTheme){
            darkColors
        }else{
            lightColors
        }
        return darkTheme
    }
    fun getIsDarkTheme():Boolean{
        return darkTheme
    }
}

我们将LightColor和DarkColor在此单例类中实例化,并根据我们自己拿取的主题模式(是深色还是浅色)来进行currentMode属性的初始化。程序会根据app设置的模式,来自动调节主题颜色。此时外界只需要做如下使用即可:

color = IeltsTheme.currentMode.app_line_gray_color

a11fd376e98d0ef36e4ac0bb3a4b4a79.jpg

  • 优点

操作简单,利用此套逻辑。如果定义深浅一致色值我们只需要在IeltsColors直接定义及初始化即可。定义深浅不一致色值,我们只需要在IeltsColors定义好属性,然后在相应的LightColor和DarkColor中初始化即可。使用起来也极其方便。

  • 缺点

此方式跟主题方案相比,不能定义公共样式。做不到主题里配置后,其他地方默认都应用了。得需要在相关容器中各自使用定义颜色。

因为IeltsTheme单例类中darkTheme属性是初始化后不再改变了,所以在改变app或系统主题后Compose UI 可能不会及时刷新。因为darkTheme值没有变从而currentMode也没有变化。解决方式是在使用类的

解决方式1

onCreate中增加如下代码:

override fun onCreate(savedInstanceState: Bundle?) {
    IeltsTheme.resetDarkTheme()
    super.onCreate(savedInstanceState)

}

当然,我们可以抽取公共基类,替代上面重复的操作

/**
 * 此类将用作ComposeActivity基类,
 * 1. 目前主要作用是在所有ComposeActivity onCreate前重置一下主题Theme,以应对暗黑 跟随系统切换的场景
 */
open class BaseComposeActivity: ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        IeltsTheme.resetDarkTheme()
        super.onCreate(savedInstanceState)

    }
}

解决方式2

在后续进行技术探讨时,觉得上面的解决方式会有IeltsTheme.resetDarkTheme()调用过于频繁的问题。经后续搜索资料发现,可以在应用的Application中注册深色模式改变的监听,从而进行resetDarkTheme()方法的调用。当你手动切换系统深色模式或浅色模式时,系统会先调用此监听器,再执行目标Activity的onCreate。此方式精简调用次数。也更便于理解。下面是相关java代码,kotlin请自行修改。


registerComponentCallbacks(new ComponentCallbacks() {
    @Override
    public void onConfigurationChanged(@NonNull android.content.res.Configuration newConfig) {
        IeltsTheme.INSTANCE.resetDarkTheme();
    }

    @Override
    public void onLowMemory() {

    }
});


注意,按照解决方式2修改后,当在设置成不跟随系统(app自己设置固定深色或浅色模式)时,你需要自行在设置模式前调用resetDarkTheme,因为上面的监听检测不到。

适配过程中遇到的问题。

  1. 开始用的colorResource获取颜色的方案在经过测试后居然不能跟随应用暗黑切换,只跟系统暗黑切换,产生了巨大的困惑。为什么这么设计???
  2. 一开始不知道ColorScheme可以扩展,从而探寻出了上面第二条深色适配的路子。

现在该讲一讲写这篇文章时才发现的最新的收获了。

原来ColorScheme可以扩展它不局限于系统定义的那几个名称,开发者可以任意定义属性扩展,从而满足各种app奇葩需求。

在Android Developer官网自定义主题章节介绍到,我们可以通过给其添加扩展属性的方式进行扩展。代码如下:

// Use with MaterialTheme.colorScheme.snackbarAction
val ColorScheme.snackbarAction: Color
    @Composable
    get() = if (isSystemInDarkTheme()) Red300 else Red700

Compose 深色模式和主题,现在可以好好玩耍了吧!

image.png

补充

  • isSystemInDarkTheme() 方法返回的主题模式也是跟系统绑定的,而非App配置的。
  • 系统主题是根据切换 colorScheme 来切换颜色系统的。
  • 如何让 isSystemInDarkTheme() 方法跟随你自己配置,而非系统配置 ???

resources.configuration.uiMode= Configuration.UI_MODE_NIGHT_NO

  • 正在探索可以让colorResource和painterResource方法跟随应用配置,而非系统配置的方法。

image.png

接下来你就可以按照你的需求来随意配置app啦。