I主题、资源与基础品质
主线最后一章管「看起来像同一个 App」:颜色/字阶走 Material 3 令牌、深色能跟系统也能手开、文案进 strings.xml、图标能被读屏念到。写死色值、拼字符串、装饰图不写 contentDescription,是业务里最常见的品质债。
🔗 引用:Material 3 · 颜色 · 动态取色 · 资源与配置 · 无障碍
先按场景认问题
| 实际会遇到 | 根因 | 做法 | 频率 |
|---|---|---|---|
| 切深色后按钮、分割线、文字「消失」 | 写死 Color.White / #333 | 读 colorScheme.onSurface 等令牌 | 常用 |
| 每个页面一种蓝,换品牌色要改 80 处 | 组件上直接 Color(0xFF…) | 只改 light/darkColorScheme | 常用 |
| 设置里开关了深色,界面不听 | 只用了 isSystemInDarkTheme() | 三态:跟随系统 / 浅 / 深,存 DataStore | 常用 |
| 状态栏图标浅色底上看不清 | 边到边后没改 isAppearanceLightStatusBars | 跟 darkTheme 同步系统栏 | 常用 |
| 要出海 / 运营改文案,代码里全是中文 | Text("保存") 写死 | stringResource + 格式化 / 复数 | 常用 |
| TalkBack 遇到返回键沉默、装饰图却念一串 | 图标描述反了 | 有含义写描述,装饰传 null | 常用 |
| 系统字体放大后标题被裁、点不到 | 写死高度、热区 < 48.dp | 字阶走 typography;最小点击区域 | 常用 |
| Android 12+ 想跟壁纸色 | 只用固定 scheme | dynamicLight/DarkColorScheme | 偶尔 |
| Fragment 里 Compose 岛颜色怪 | 岛上没包 MaterialTheme | 每个 setContent 都包主题 | 偶尔 |
Material 3 与 MaterialTheme 常用
概念
MaterialTheme 往下提供三袋令牌:colorScheme、typography、shapes。Button、TopAppBar、TextField 默认都吃这些值。业务代码读令牌,不要抄设计稿 hex。
和 XML 对照
| XML | Compose |
|---|---|
themes.xml + MaterialComponents | MaterialTheme { } 包住 App |
?attr/colorPrimary / ?attr/colorOnSurface | MaterialTheme.colorScheme.primary / onSurface |
TextAppearance.Material3.BodyLarge | MaterialTheme.typography.bodyLarge |
@style/ShapeAppearance… | MaterialTheme.shapes |
android:background="?attr/colorSurface" | Surface 或 Modifier.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))当页面底 → 深色模式一块白斑。用Surface或colorScheme.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.xml 的 windowBackground 还要配对,否则冷启动白闪。
怎么用 · 三态
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.getString 再 remember 住——换语言不会刷新。
和 XML 对照
还是那份 res/values/strings.xml、drawable。@string/x → stringResource(R.string.x);@drawable → painterResource。尺寸优先写 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:contentDescription、importantForAccessibility、minHeight=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。