I主题、资源与基础品质

主线最后一章管「看起来像同一个 App」:颜色/字阶走 Material 3 令牌、深色能跟系统也能手开、文案进 strings.xml、图标能被读屏念到。写死色值、拼字符串、装饰图不写 contentDescription,是业务里最常见的品质债。

先按场景认问题

常用几乎每个业务页都会碰到 偶尔品牌 / 无障碍 / 动态色再加深
实际会遇到根因做法频率
切深色后按钮、分割线、文字「消失」写死 Color.White / #333colorScheme.onSurface 等令牌常用
每个页面一种蓝,换品牌色要改 80 处组件上直接 Color(0xFF…)只改 light/darkColorScheme常用
设置里开关了深色,界面不听只用了 isSystemInDarkTheme()三态:跟随系统 / 浅 / 深,存 DataStore常用
状态栏图标浅色底上看不清边到边后没改 isAppearanceLightStatusBars跟 darkTheme 同步系统栏常用
要出海 / 运营改文案,代码里全是中文Text("保存") 写死stringResource + 格式化 / 复数常用
TalkBack 遇到返回键沉默、装饰图却念一串图标描述反了有含义写描述,装饰传 null常用
系统字体放大后标题被裁、点不到写死高度、热区 < 48.dp字阶走 typography;最小点击区域常用
Android 12+ 想跟壁纸色只用固定 schemedynamicLight/DarkColorScheme偶尔
Fragment 里 Compose 岛颜色怪岛上没包 MaterialTheme每个 setContent 都包主题偶尔
选型口诀:色/字/圆角 → 令牌深浅 → 一份 Theme 入口给人看的字 → strings.xml给人听的控件 → contentDescription。不要在业务页各自发明一套。

Material 3 与 MaterialTheme 常用

概念

MaterialTheme 往下提供三袋令牌:colorSchemetypographyshapes。Button、TopAppBar、TextField 默认都吃这些值。业务代码读令牌,不要抄设计稿 hex。

和 XML 对照

XMLCompose
themes.xml + MaterialComponentsMaterialTheme { } 包住 App
?attr/colorPrimary / ?attr/colorOnSurfaceMaterialTheme.colorScheme.primary / onSurface
TextAppearance.Material3.BodyLargeMaterialTheme.typography.bodyLarge
@style/ShapeAppearance…MaterialTheme.shapes
android:background="?attr/colorSurface"SurfaceModifier.background(colorScheme.surface)

怎么用 · App 入口只配一次

@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colors = if (darkTheme) darkScheme else lightScheme
    MaterialTheme(
        colorScheme = colors,
        typography = AppTypography,
        shapes = AppShapes,
        content = content
    )
}

private val lightScheme = lightColorScheme(
    primary = Color(0xFF1A73E8),
    onPrimary = Color.White,
    surface = Color(0xFFF4F6FA),
    onSurface = Color(0xFF161A22),
    error = Color(0xFFB3261E)
)
private val darkScheme = darkColorScheme(
    primary = Color(0xFF8EC5FF),
    onPrimary = Color(0xFF00315C),
    surface = Color(0xFF0F1115),
    onSurface = Color(0xFFE6E8EE)
)
setContent { AppTheme { AppNav() } }

业务页怎么读(不要再写 hex)

Text(
    text = title,
    style = MaterialTheme.typography.titleMedium,
    color = MaterialTheme.colorScheme.onSurface
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Surface(color = MaterialTheme.colorScheme.surfaceContainer) { /* 卡片底 */ }

常用令牌:primary / onPrimary(主按钮)、surface / onSurface(页面底与正文)、onSurfaceVariant(次要说明)、error / onError(失败)、outline(描边)。主色上的字必须用 onPrimary,否则浅色主色 + 白字会看不清。

易错点

  • Box(Modifier.background(Color.White)) 当页面底 → 深色模式一块白斑。用 SurfacecolorScheme.surface
  • 所有强调都用 primary:链接、图标、分割线、进度条全是同一蓝,层次没有了。次要信息走 onSurfaceVariant / secondary
  • Preview / 测试 / Fragment 的 ComposeView.setContent 忘了包 AppTheme → 默认紫、和线上不一致。
  • Text(fontSize = 14.sp) 写死,设置里「字体放大」标题先溢出。走 typography,必要时 maxLines + overflow
▶ 按钮颜色跟主题走
Button(onClick = { }) {
    Text("保存") // 默认吃 colorScheme.primary / onPrimary
}
TextButton(onClick = { }) {
    Text("取消") // 字色走 primary,底透明
}

深色模式 常用

概念

产品几乎都要「跟随系统 / 始终浅色 / 始终深色」。只调 isSystemInDarkTheme() 不够:用户在设置页关掉跟随后,必须改 AppTheme(darkTheme = …) 的入参。偏好存 DataStore(或等价),启动时先读再 setContent,避免闪一帧反色。

和 XML 对照

values-night + AppCompatDelegate.setDefaultNightMode。Compose 主界面用两套 ColorScheme 切换;启动窗、Splash、系统栏仍可能吃 XML 主题,values-night/themes.xmlwindowBackground 还要配对,否则冷启动白闪。

怎么用 · 三态

enum class ThemeMode { FollowSystem, Light, Dark }

fun ThemeMode.toDark(systemDark: Boolean) = when (this) {
    ThemeMode.FollowSystem -> systemDark
    ThemeMode.Light -> false
    ThemeMode.Dark -> true
}

@Composable
fun App(mode: ThemeMode) {
    val systemDark = isSystemInDarkTheme()
    AppTheme(darkTheme = mode.toDark(systemDark)) {
        AppNav()
    }
}
@Composable
fun DarkToggle(dark: Boolean, onChange: (Boolean) -> Unit) {
    Row(
        modifier = Modifier.fillMaxWidth().padding(16.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(stringResource(R.string.settings_dark), modifier = Modifier.weight(1f))
        Switch(checked = dark, onCheckedChange = onChange)
    }
}

设置页若是三选一,用单选列表改 ThemeMode,不要只做一个 Switch 却还想「跟随系统」——两个控件会打架。

状态栏 / 导航栏

val view = LocalView.current
val darkTheme = /* 与 AppTheme 同一值 */
SideEffect {
    val window = (view.context as Activity).window
    val ctl = WindowCompat.getInsetsController(window, view)
    ctl.isAppearanceLightStatusBars = !darkTheme
    ctl.isAppearanceLightNavigationBars = !darkTheme
}

边到边(enableEdgeToEdge())之后,状态栏是透明的,图标颜色全靠上面两行。只改 Compose 背景、不改 InsetsController,会出现「深色页 + 深色图标」。

易错点

  • 图片、Lottie、WebView、Map 仍是浅色资源:令牌管不到它们。深色下换 drawable-night 或给图片加遮罩。
  • Color.Black.copy(alpha = 0.1f) 当分割线,浅色还行、深色脏一块。用 outlineVariant
  • 动态取色 + 用户强制浅色:仍走 dynamicLightColorScheme,不要系统深色时还取 dark dynamic。
  • Preview 不验深色:加 @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
▶ 设置页深色开关(简化成布尔)

布尔版适合「只有开关、不跟随系统」。要三态就把 Switch 换成三项列表,状态进 DataStore,App() 入口用 toDark(systemDark)

动态取色 偶尔

概念

Android 12+ 可从壁纸生成一套 ColorScheme。品牌约束严的 App 往往关掉;工具类、系统感 App 常开,并给设置项。

val context = LocalContext.current
val scheme = when {
    dynamicColor && Build.VERSION.SDK_INT >= 31 ->
        if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
    darkTheme -> darkScheme
    else -> lightScheme
}

易错点

动态色下 primary 可能很浅或很艳,写死「白字配主色」会翻车——继续用 onPrimary。设计验收要用两套壁纸各截一张。

资源:文案、图、尺寸 常用

概念

Composable 里用 stringResource / pluralStringResource / painterResource。它们读当前 Configuration(语言、夜间、密度),重组时会更新。不要 LocalContext.current.getStringremember 住——换语言不会刷新。

和 XML 对照

还是那份 res/values/strings.xmldrawable@string/xstringResource(R.string.x)@drawablepainterResource。尺寸优先写 16.dp;只有和 XML 主题共用 dimen 时才 dimensionResource

怎么用

Text(stringResource(R.string.settings_title))
Text(stringResource(R.string.hello_user, userName))  // %s
Text(pluralStringResource(R.plurals.item_count, count, count))

Image(
    painter = painterResource(R.drawable.logo),
    contentDescription = stringResource(R.string.cd_logo)
)
<string name="hello_user">你好,%1$s</string>
<plurals name="item_count">
  <item quantity="one">%d 条</item>
  <item quantity="other">%d 条</item>
</plurals>

实际开发里的坑

  • "共 " + n + " 条" 拼接:语序一换语言就错,用带参数的 string / plurals。
  • ViewModel 里 context.getString:VM 不该持 Context,也难测。让 UI 收资源 id + 参数,或准备好已格式化的文案再交给 Text。
  • 网络下发的文案(运营配置)本来就不是资源,可以直接 Text(serverText);App 自己的按钮、空态、错误码走资源。
  • stringResource 只能在 Composable / 有 Composition 的地方调。普通工具函数里继续 context.getString,或把 id 传上去。
▶ 标题不写死中文
// strings.xml: <string name="settings_title">设置</string>
Text(stringResource(R.string.settings_title))

字阶、形状、系统字体放大 常用

概念

typography.titleLarge / titleMedium / bodyLarge / bodyMedium / labelSmall 覆盖标题、正文、按钮字。用户在系统设置里把字体拖到最大时,写死 height(48.dp) 的标题行会裁字,写死 fontSize 会无视无障碍字号。

怎么用

Text(
    text = title,
    style = MaterialTheme.typography.titleLarge,
    maxLines = 2,
    overflow = TextOverflow.Ellipsis
)

点击热区:Material 组件默认约 48.dp。自绘图标要用 Modifier.minimumInteractiveComponentSize()sizeIn(minWidth = 48.dp, minHeight = 48.dp),不要只 size(20.dp) 再 clickable。

易错点

sp 当间距(padding(16.sp))——间距应是 dp,字号才是 sp。字体放大时 padding 跟着狂涨,版面会散。

无障碍基础 常用

概念

读屏(TalkBack)读的是语义树,不是你看见的像素。图标按钮必须有 contentDescription;装饰图传 null 才不会念「未加标签的图像」。同一行图标+文字通常让文字当名字,图标 null,或 Modifier.semantics(mergeDescendants = true) 合成一条。

和 XML 对照

android:contentDescriptionimportantForAccessibilityminHeight=48dp。Compose 把这些收成参数和 Modifier.semantics

怎么用

IconButton(onClick = onBack) {
    Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}

Image(painterResource(R.drawable.divider_wave), contentDescription = null) // 装饰

Row(
    Modifier
        .fillMaxWidth()
        .clickable(onClick = onOpen)
        .semantics(mergeDescendants = true) { }
        .padding(16.dp)
) {
    Icon(Icons.Default.Person, contentDescription = null)
    Text(user.name)
}

自定义可点区域补 role = Role.Button;开关类用 toggleable / Selectable,比裸 clickable 多「已勾选」语义。测试定位用 testTag,见测试

易错点

  • 返回、分享、更多只有图标,描述还 null → 读屏无法操作。
  • 装饰 Logo 写了很长 contentDescription → 进页先念广告词。装饰就是 null
  • 只用颜色表示状态(红涨绿跌、仅色点表示未读)→ 色盲和读屏都丢信息。加文字或图标。
  • 动画闪个不停:系统开了「减弱动画」时,官方动画会缩短;自写 Infinite 转圈不会,见无限动画
▶ 图标按钮能被读屏念到
IconButton(onClick = onBack) {
    Icon(Icons.Default.ArrowBack, contentDescription = "返回")
}

综合:设置页把品质收口

一页设置同时用到主题开关、资源文案、Scaffold 壳。Theme 仍在更外层的 App(),本页只上报用户选择。

▶ 一页设置:主题开关 + 文案资源
@Composable
fun SettingsPage(dark: Boolean, onDark: (Boolean) -> Unit) {
    Scaffold(topBar = {
        TopAppBar(title = { Text(stringResource(R.string.settings_title)) })
    }) { padding ->
        Column(Modifier.padding(padding)) {
            DarkToggle(dark, onDark)
        }
    }
}

上线前手测:浅色、深色、系统字体最大、TalkBack 扫一遍顶栏图标。这四项能拦住本章大部分品质债。

易错合集

  • 业务页写死 hex / Color.White;深色全军覆没。
  • 主色上的字不用 onPrimary;动态取色后对比度崩。
  • 只跟系统深色,忽略设置页;或 Switch 与「跟随系统」两套状态。
  • 边到边后不改状态栏图标颜色。
  • 文案拼接、VM 持 Context getString、remember 住 stringResource。
  • 图标 contentDescription 该写不写、装饰图写太多。
  • 点击热区小于 48.dp;用 sp 当 padding。
  • Compose 岛、Preview 不包 MaterialTheme。
主线正文到此。继续:组件总表 · 属性总表 · 速查
Android Compose 现代开发知识体系 · 主线必读 / 扩展选读。
术语表常驻;组件与属性先看分讲,再查两张总表。案例默认收起。