Skip to content

主题与设计基调 ​

这一页不教你怎么造主题 —— 配色、字阶、形状、动效已经在组件库里定完了,插件侧没有第二套。这一页教另一件事:怎么正确地消费它们。

做砸界面的从来不是「不知道有哪些 token」,而是两件事:

  1. 把视觉规格当成可以直接写的数字 —— Color(0xFF...)、14.sp、RoundedCornerShape(12.dp)。这类写法在深色模式和宿主换肤时当场失效。
  2. 把「层级」理解成「多加几层颜色」—— 结果是整页越写越糊,用户不知道先看哪。

一句话定调:颜色、字阶、圆角只从 MaterialTheme 与 AppShapes 取;层级靠留白、圆角、容器色、字阶这四件套做,不靠堆色。

本页提到的一切 token 事实(有哪些角色、有哪些档位、圆角各是多少)一律以组件库为准:

要知道什么去哪
有哪些颜色角色、深浅色怎么来的/api/ui/theme
15 级字阶各是多少、用在哪/api/ui/type
M3 形状档位与命名形状/api/ui/shapes

一、主题谁负责:PluginTheme,不是你 ​

插件界面的外面已经有一层主题环境了。它由插件入口装好:

kotlin
// 这是入口文件(步骤 ⑥)里的代码,已经写好了 —— 你不要在 App() 里再包一层
class MainActivity : BasePluginActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        activity.setContent {
            PluginTheme {                                  // ← 主题入口只有这一个
                App(onFinish = { activity.finish() })
            }
        }
    }
}

PluginTheme(在 obsession-shared,见宿主桥)会读宿主的深浅色设置(0 跟随系统 / 1 强制浅 / 2 强制深),折算成一个布尔值再交给组件库的主题函数,顺带把配色、字阶、形状、动效规格、过渡滚动、轻提示容器一起挂好。用户切浅色 / 深色,插件自动跟着变。

你的职责只有一句话:在已经挂好的环境里,取正确的 token。

下面这些一行都不要写

别写为什么错你该做的
HuaLuoTheme { … }它没有 darkTheme 的默认值,不传就退化成「跟系统」,直接忽略宿主的深浅色设置 —— 这正是「浅色 / 深色失效」的根因什么都不包。宿主已经包过了
MaterialTheme { … } 自建一层它不会装上配色 / 字阶 / 形状;包一层之后下游取到的全是 M3 默认值同上
自建 ColorScheme / lightColorScheme() / defaultColorScheme()换配色是宿主的事。插件塞进第二套,用户换肤在这个页面就失效需要什么语义,取 MaterialTheme.colorScheme.*
isSystemInDarkTheme()、SettingsManager.getDarkModeOption()「深浅色怎么折算」就是 PluginTheme 正在做的活;自己再算一遍 = 绕过宿主设置不需要判断深浅色 —— token 自己会跟着变
自定义字体、覆盖 Typography、MaterialTheme(fontFamily = …)字阶是骨架、字体是系统默认。内嵌第三方字体会让 APK 多出十几 MB用 MaterialTheme.typography.* 的档位
局部再套一层主题嵌套会把轻提示容器与过渡滚动工厂再挂一份,内层还整体覆盖外层配色只取 token,不建主题

三层分工看清楚,就不会越界:

角色负责什么产出
组件库 hualuo-compose-ui定义 token 与每个组件的标准规格Typography / Shapes / AppShapes / 两套内置 ColorScheme / 组件默认值
宿主 + PluginTheme决定这一刻用哪一套(浅色 / 深色 / 将来换肤)一个挂好的主题环境
你(插件)在里面取对的 token,把业务结构搭成组件干净的界面

插件不要去组件库找「主题入口」

组件库文档里的主题入口是给宿主用的。插件侧唯一的主题入口是 PluginTheme。你要从组件库那几页拿的不是「怎么建主题」,而是「有哪些角色和档位可以取」。

二、颜色:只从 MaterialTheme.colorScheme 取 ​

2.1 为什么一行 Color(0xFF…) 都不能写 ​

写法后果
Color(0xFFF2F3FA) 当卡片底这是浅色主题的卡片色。深色模式下它还是一块亮白,页面直接花掉
Color(0xFF1A1A1A) 当正文色深色模式下黑字压深底,读不出来
任意硬编码色宿主换肤 = 换一个 ColorScheme;硬编码色不认识它,不跟着走

还有一条更隐蔽的:语义色是成对设计的(容器色 + 压在上面的 on 角色),它们的对比度是算过的。你手挑两种颜色配在一起,没人保证它们能读清 —— 尤其在深色下。

2.2 角色取用对照表 ​

M3 的槽位分两类:容器角色(铺底的那一支)和 on 角色(压在该容器上的文字 / 图标那一支)。成对取,颜色和对比度就都是对的。

角色用在哪别用在哪
primary唯一的强调色:实心按钮底、选中胶囊底、聚焦描边与光标大面积铺底(整块卡片背景);长段正文
onPrimary压在 primary 上的文字 / 图标(FilledButton 内部就是这么取的)任何非 primary 底色之上
primaryContainer「有色但不抢戏」的小块:ListItem 左侧图标容器底、强调型小标签和 primary 实心块同屏大面积并用
onPrimaryContainer压在 primaryContainer 上的图标 / 文字(ListItem 图标默认色)直接铺在白底上当正文色
secondaryContainer次级容器底:低强调的块、TonalButton 的默认底当主色用;承载大段正文
onSecondaryContainer压在 secondaryContainer 上的文字 / 图标—
surface表面色(页面骨架的容器色默认就是它,通常不用你手取)强调色;当卡片底去区分层级
surfaceContainer一屏里最多的卡片色:内容卡、分组卡(SectionRow 的 GROUP 卡就是它)卡片套卡片时内外同色(两张卡会糊成一块)
surfaceContainerLow比 surfaceContainer 更靠近页面底色的低一层容器:嵌套卡片内层、次级区块需要「浮起来」的主卡片
surfaceContainerHighest最强的一档容器色:空态图标容器底(EmptyHint 默认)、角标、chip 底大面积铺底
onSurface主文字、标题、主要图标次要说明文字
onSurfaceVariant次要文字与弱图标:副标题、说明、时间戳、未选中的图标标题与主要动作
outline需要看清边界的描边:未选中胶囊描边、输入框未聚焦描边当分割线用;大面积描边
outlineVariant最弱的分割与描边:HorizontalDivider、标签描边需要明确边界的可操作控件(那里用 outline)
error错误态与危险动作:错误提示文字、错误描边、SectionItem(danger = true) 的标题与图标当装饰色用
onError压在 error 上的文字 / 图标—
errorContainer错误态的容器底:错误档提示条、错误图标容器当普通强调色用
onErrorContainer压在 errorContainer 上的文字 / 图标—
background页面整体底色(骨架的 containerColor 默认就是它)卡片底(那是 surfaceContainer 系)

选角色的顺序只有两步:先问「我是容器还是压在容器上的内容」,再问「我在这一屏是主、是次、还是分隔」。答完这两问,上面表里只有一个格子。

2.3 三条补充规则 ​

  • M3 没有的语义色不要往主题里加。 成功绿、警告琥珀这类状态色由用到它的那个组件自己持有常量。往主题里塞第二套「扩展配色」,每加一个语义就多维护一组明暗映射,还会让「换个配色」从改一个文件变成改两个。
  • Color.kt 里的 *Light / *Dark token 不要直接引用。 它们是给主题构造用的原料,换肤时不跟着走。业务代码能碰的只有 MaterialTheme.colorScheme.*。
  • 组件给了默认色的地方不要自己传。 ListItem 的图标底 / 图标色、SectionItem(danger = true) 的 error 色、EmptyHint 的图标容器色、TextField 的聚焦 primary / 未聚焦 outline,都是刻意的取值。只有在组件明确开放了 colors / tint 参数、且你能说出「为什么要偏离默认」时才覆盖。

三、字阶:档位就是全部约定 ​

页面里不写 fontSize / fontWeight / letterSpacing。内容类型决定档位,档位决定字号、行高、字距、字重。

内容档位
折叠大标题页的标题(CollapsingTitleScaffold 内)headlineLarge
普通标题栏(TopTitleBar 默认取这条)headlineMedium
首页区块标题(SectionRow(style = SECTION))titleLarge
区块小标题headlineSmall
卡片标题 / 列表项标题 / 菜单选中项(ListItem.title、SectionItem.title)titleMedium
弹窗标题、空态主文案、提示条标题titleSmall
正文、菜单项bodyLarge
次要正文、错误说明、输入框内的文字bodyMedium
副标题、说明文案(ListItem.subtitle、空态说明)bodySmall
按钮文字labelLarge
标签、小号辅助文案(PillLabel、SectionRow(GROUP) 的标题)labelMedium
角标labelSmall

三条容易踩的:

  • 会变宽度的数字必须等宽。 余额、观看次数、版本号、下载进度、倒计时一变,整行就左右抖 —— 因为默认是比例数字,1 比 8 窄。用 NumberStyles.display / title / label,或临时给任意样式开等宽:style = MaterialTheme.typography.titleLarge.tabular()。
  • 不要覆盖字阶自带的字距。 大字号是负字距、小字号是正字距,按字号校准过;行高也比 M3 的英文基线略大,因为中文比拉丁文高,多行中文靠这个才不挤。
  • 别在组件上再手拧一次字阶。 组件内部已经取了正确的档位(ListItem 标题 titleMedium、副标题 bodySmall;空态主文案 titleSmall、说明 bodySmall)。想改字阶意味着你要改的是内容类型,不是「让这行字小一点」。

headline* 的两处真实字重

headlineLarge / headlineMedium 文档注释说改成 Bold 了,但代码里的实际值仍是 Black(900),以代码为准。所以直接引用这两个 token 的地方拿到的是 900 —— 这也是为什么折叠标题栏与登录页大标题选择自己手写 Bold。你自己排大字号标题时,优先用 displaySmall / titleLarge 这类常规档位。

四、形状:优先语义档位,其次命名形状 ​

两个入口:M3 的语义档位(跟主题走)和 AppShapes 的命名形状(按用途命名)。不要自己写 RoundedCornerShape(…)。

入口值用在哪
MaterialTheme.shapes.extraSmall8dp极小元素
MaterialTheme.shapes.small12dp小元素
MaterialTheme.shapes.medium16dp默认首选:一般容器
MaterialTheme.shapes.large24dp大容器
MaterialTheme.shapes.extraLarge32dp特大容器
AppShapes.card四角 24dp标准卡片:内容卡、设置分组、资产卡
AppShapes.cardSmall四角 16dp小卡片:二级卡片、弹窗内块、下拉菜单
AppShapes.row四角 16dp列表行(整行当一张卡时用)
AppShapes.heroTop上 28dp / 下直角折叠标题栏的内容卡片
AppShapes.sheetTop上 28dp / 下直角底部弹层、底部导航上沿
AppShapes.iconContainer12dp列表项左侧图标容器
AppShapes.iconContainerLarge16dp大图标容器:应用图标、头像占位、空态图标
AppShapes.pill50%(胶囊)按钮、标签、chip
AppShapes.badge6dp小角标:Update 标记、数量徽标
AppShapes.textField12dp输入框(单行与多行都是这一档)

四条判断规则:

  1. 能取语义档位就取 MaterialTheme.shapes.*,AppShapes 留给「档位表达不了」的形态 —— 不对称圆角、胶囊、内层收紧的图标容器。
  2. 内层要比外层小一档。 卡片 24dp,卡片内的图标容器只用 12dp。大圆角套大圆角会糊成一团。
  3. 同值不同语义的别互替。 heroTop 和 sheetTop 值一样但语义不同(折叠头部 vs 底部弹层上沿),row 和 cardSmall 同理 —— 将来只改其中一个时不会误伤另一个。
  4. 组件已经定死的形状不要覆盖。 四个按钮形态固定胶囊、TextField / TextArea 固定 12dp(浮动标签在边线上开缺口,胶囊弧放不下)、PillTab 胶囊、ListItem 的图标容器 48dp。这些都不开放参数,也别用 modifier 去改。

五、视觉层级:靠四件套,不靠堆颜色 ​

想让一个东西「更重」,正确的四个手段,按优先顺序:

手段怎么用例
留白一级容器给足内边距,二级收紧,三级最紧20dp → 16dp → 12dp
圆角内层比外层小一档24dp → 16dp → 12dp
容器色用 surfaceContainer 的相邻档位做深浅,一屏最多两档surfaceContainer → surfaceContainerLow → surfaceContainerHighest
字阶同一块内容里最多跨两档titleMedium → titleSmall → labelMedium

不要用:加边框、加阴影、加第三种强调色、把字重再加重一档。边框和阴影在这里都是「外来噪音」,而颜色一多,主次就没了。

同一张卡片,做出一级 / 二级 / 三级 ​

kotlin
// 一级:页面主卡片 —— 留白最松、圆角最大、容器色最靠外、字阶最高
@Composable
fun PrimaryCard(
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit,
) {
    Column(
        modifier = modifier
            .fillMaxWidth()
            .clip(AppShapes.card)                                            // 24dp
            .background(MaterialTheme.colorScheme.surfaceContainer)
            .padding(20.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
        content = content,
    )
}

// 二级:卡片内部的区块 —— 圆角降一档、容器色降一档、内边距收紧
@Composable
fun SecondaryBlock(
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit,
) {
    Column(
        modifier = modifier
            .fillMaxWidth()
            .clip(AppShapes.cardSmall)                                       // 16dp
            .background(MaterialTheme.colorScheme.surfaceContainerLow)
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        content = content,
    )
}

// 三级:最小信息块 —— 圆角再降一档、容器色取最强那档、内边距最紧
@Composable
fun TertiaryChip(
    label: String,
    modifier: Modifier = Modifier,
) {
    Text(
        text = label,
        style = MaterialTheme.typography.labelMedium,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
        modifier = modifier
            .clip(AppShapes.iconContainer)                                   // 12dp
            .background(MaterialTheme.colorScheme.surfaceContainerHighest)
            .padding(horizontal = 8.dp, vertical = 4.dp),
    )
}

配套的用法(层级是组合出来的,不是单靠某一件):

kotlin
PrimaryCard {
    Text("本月统计", style = MaterialTheme.typography.titleMedium)      // 一级字阶
    SecondaryBlock {
        Text("已读 12 本", style = MaterialTheme.typography.titleSmall) // 二级字阶
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            TertiaryChip("达标")
            TertiaryChip("连续 7 天")
        }
    }
}

三条边界:

  • 容器色最多两档同屏。 三级块挤在同一张卡里时,第三档只在「小到一眼看出是标签」时用;否则退回留白区分。
  • 不要为了「有层次」而嵌套。 一个二级块里再放二级块,说明这个结构该拆成两屏或者换成分组标题。
  • 卡片套卡片时内外不能同色。 外层 surfaceContainer,内层就取 surfaceContainerLow 或 surfaceContainerHighest,别复制同一个角色。

六、原子设计分级:先想清楚这个 composable 属于哪一层 ​

分级不是为了好看,是为了回答两个问题:它能不能被第二处复用,以及它该收什么参数。

层级判定标准插件里的例子
atom 原子单一职责;不含业务字段(或只含一个值 + 一个回调);内部只用 token 与 foundation 原语StatNumber(NumberStyles.display + primary)、CoverIcon(AppShapes.iconContainer + surfaceContainerHighest)、TagChip(PillLabel)
molecule 分子2~4 个原子拼成「能干成一件事」的单元;收 data class 与回调,不收仓库 / 状态容器BookRow = ListItem + FavoriteIconButton;SearchRow = TextField + 清空按钮
organism 有机体一个屏幕区域:分子 + 原子 + 它自己的状态分支;仍然只收数据与回调BookListSection = 分组标题 + LazyColumn(BookRow) + 到底脚注;HeaderSection = 折叠标题栏 + PillTabsRow
template 模板只定骨架与插槽,不含数据、不碰业务状态LibraryScreenTemplate = TopTitleScaffold + 三个插槽
screen 页面唯一可以持有业务状态、调工具库、判三态的一层App(onFinish)

依赖规则只有一条:每层只依赖它下面那层。 有机体里不要重新发明原子 —— 判断标准不是「能不能出现 Text」(Text(style = MaterialTheme.typography.…) 本身就是 token 消费,不违规),而是有没有把同一套视觉规格写第二遍。同一个「图标容器 + 圆角 + 配色 + 尺寸」在三个文件里各写一遍,就是该往上抽的信号。

可复用组件的契约 ​

抽出来的玩意要能被下一个人直接用,必须满足这六条:

kotlin
/**
 * 无状态版是主签名:状态由调用方持有,组件只发通知。
 * 需要省事的地方再开一个同名的 stateful 重载,内部 remember。
 */
@Composable
fun ReaderRow(
    title: String,
    progress: Float,
    onFavoriteToggle: () -> Unit,          // 事件往上走
    modifier: Modifier = Modifier,         // ① 必备,且透传到根节点
    trailing: @Composable (() -> Unit)? = null,   // ② 可变内容走插槽
) {
    ListItem(
        title = title,
        subtitle = "${(progress * 100).toInt()}%",
        icon = Phosphor.Book,
        trailing = trailing
            ?: { FavoriteIconButton(isFavorite = false, onToggle = onFavoriteToggle) },
        modifier = modifier,
    )
}
契约具体要求
modifier 参数modifier: Modifier = Modifier,且透传到根节点、不要吞掉。调用方控制外部边距 / 尺寸的唯一入口
slot 优先于开关内容会变的地方给 @Composable 插槽(trailing / content),不要给一堆 showXxx: Boolean 枚举。组件库自己就是这么做的(ListItem.trailing、SectionRow.content)
默认值能独立跑通不传任何可选参数也应该渲染合理。默认值本身就是「标准用法」—— 随手传 size = ButtonSize.SMALL 属于例外,要有理由
只用 token函数体里出现 Color(0xFF / 14.sp / FontWeight.Bold / RoundedCornerShape( 就是没写完
stateful / stateless 成对主签名无状态(收 value + onValueChange,可预览、可复用、不会出现第二份不同步的数据);另开一个重载在内部 remember 供省事调用
按「它是什么」命名ReaderRow 而不是 HomeReaderRow;GradeCard 而不是 RedBorderCard。按所在页面或外观命名的东西,复用不出去

层级也不是越细越好:原子只在被第二处用到时才抽。一个页面专用的「封面 + 书名 + 进度」直接写成分子就行,为了凑分级把每个 Text 都包一层,只会让调用链变长。

七、设计稿落地五步法 ​

拿到一张设计稿(或截图、或自己画的线框),按这个顺序落,不要从「先摆一个 Column」开始:

步做什么产出
① 拆解层级从上往下切横向分区,每个分区起一个按用途的名字一张分区清单
② 映射 token每个分区里的色 / 字阶 / 圆角,找到对应的角色与档位token 对照,不允许留「看起来差不多的色」
③ 定间距内边距、分区间距、行间距,全部落到 8dp 网格(4dp 只给紧邻的图文配对)一串数字
④ 定状态空 / 加载 / 错误 / 正常,四态各自的组件与文案一个 when
⑤ 定动效只在「状态切换」和「进出场」上加动效,用主题里的动效规格动效清单

间距这一条说清楚:本项目没有间距 token,间距就是数字 dp,按 8dp 网格取值(常用 16dp)。不要为了「整齐」在插件里新建一套 CompositionLocal 间距系统 —— 多一层间接、跨插件不通用,而且它会和组件内部已经定好的边距打架。组件内部边距固定、外部边距全归 modifier,这条规则组件库自己也守着。间距细节见布局与 Modifier。

动效只用两样东西:animate*AsState / AnimatedVisibility 这类官方 API,和 MaterialTheme.motionScheme 里的规格(defaultSpatialSpec / defaultEffectsSpec)。不要写死 tween(300) —— 写死就换不回来了。完整判断见动效。

完整示范:从一张列表页截图到 composable 树 ​

设计稿的文字描述(真实场景都没 Figma,只有一句需求或一张截图):

一个漫画书架列表页。顶部一行大标题「我的书架」,右边一个搜索图标。下面一排胶囊分类:全部 / 连载中 / 已完结。再下面是卡片列表,每行左边一个封面缩略图,右边书名 + 作者,最右侧一个心形收藏按钮。列表滚到底显示「没有更多了」。下拉可以刷新。

① 拆层级(从上往下切,按用途命名,不按布局命名):

分区命名动静
标题栏 + 搜索入口Header静态
分类切换CategoryTabs与列表联动
内容列表BookList长列表、分页、可刷新
分页脚注ListFooter随分页状态变

② 映射 token:

稿子里看到映射到
页面底background(骨架自己管,不用手取)
卡片底surfaceContainer
书名titleMedium + onSurface
作者bodySmall + onSurfaceVariant
收藏心形(未收藏 / 已收藏)onSurfaceVariant / primary(FavoriteIconButton 默认值)
卡片圆角AppShapes.card
封面缩略图容器AppShapes.iconContainer + surfaceContainerHighest

③ 定间距:页面左右 16dp;卡片内边距 16dp;卡片之间 8dp;分类行与列表之间 12dp。

④ 定状态:LoadingState(首屏) / ErrorState(带重试) / EmptyHint(真的没数据) / 正常列表。

⑤ 定动效:分类切换时列表内容用淡入淡出;EmptyHint ↔ 列表之间不额外加动画。

推导出的树(每层标注用哪个组件库组件):

text
App(onFinish)                                  ← screen:持有分页状态、三态分支
└── TopTitleScaffold(title = "我的书架", onBack = onFinish,
│       actions = { ActionImageButton(Phosphor.MagnifyingGlass, …) })
│                                              ← template / 骨架:标题栏 + 三边 inset
    ├── PillTabsRow(labels, selectedIndex, onSelect)
    │                                          ← organism:分类导航,选中态外置
    └── PullToRefresh(...)                     ← 组件库:包住「可刷新的内容」
        └── ContentList(...)                   ← organism:四态分支
            ├── LoadingState()
            ├── ErrorState(message = …, onRetry = …)
            ├── EmptyHint(text = …, description = …)
            └── LazyColumn(state = listState)  ← foundation 原语
                ├── items(books, key = { it.id }) { BookRow(book, onToggleFavorite) }
                │                              ← molecule
                └── item { ListFooter(hasMore, loadFailed, onRetry) }
                                               ← molecule / 脚注

继续往下拆一层,分子就是原子的组合:

text
BookRow(book, onToggleFavorite)                ← molecule
└── ListItem(
        title = book.title,
        subtitle = book.author,
        iconUrl = book.cover,
        trailing = { FavoriteIconButton(isFavorite = book.isFavorite,
                                        onToggle = onToggleFavorite) },
        onClick = onOpenDetail,
    )

几个能对回前面几条规则的落点:

  • 没有一处颜色、字号、圆角是手写的。 卡片底走 surfaceContainer,封面容器走 AppShapes.iconContainer,书名走 titleMedium。深色模式下这些自动变。
  • 标题单行、副标题最多两行、超长都省略 —— 由 ListItem 自己管,不用写 maxLines。同理行高、图标容器 48dp、图标与文字间距都由组件标定。
  • 收藏按钮是受控的:FavoriteIconButton 只发通知、不自己翻转状态,所以 book.isFavorite 必须真的被改掉,否则点了没反应。
  • 分页脚注是一个 item { },不是给 LazyColumn 外面套一层 Column —— 它得跟着列表滚。自动加载更多的完整写法见列表、滚动与自动加载更多。
  • 搜索入口用标题栏图标按钮(ActionImageButton),因为搜索不是这个页面的主要动作。如果搜索是主动作,就改成页面里画 TextField —— 二选一,不要两种都放。

八、常见错误 ​

错误长什么样后果正确做法
硬编码颜色Color(0xFFF2F3FA) 当卡片底、Color.White 当文字深色模式下白底白字;宿主换肤不跟MaterialTheme.colorScheme.*,按角色表取
硬编码字号 / 字重fontSize = 14.sp、fontWeight = FontWeight.Bold全站字阶失去唯一来源,标题一页一个样style = MaterialTheme.typography.…
硬编码圆角RoundedCornerShape(12.dp)改一处要改十处,迟早和 AppShapes 漂移MaterialTheme.shapes.* / AppShapes.*
自己拼标题栏手写 Row + 返回箭头 + 自己补 statusBarsPadding()三边 inset 漏一处就顶到状态栏;返回键触控区不达标TopTitleScaffold(别自己补 inset)
间距魔法数字3dp / 7dp / 18dp / 26dp 随手写全站参差,整体「差一点」但查不出哪差8dp 网格;4dp 只给紧邻图文配对
一页塞太多主色选中胶囊 + FilledButton + 主色图标容器同屏几个实心主色块互相抢焦点,用户不知道主操作是哪个一屏只留一个主色实心块,其余降档(TonalButton / OutlinedButton / TertiaryButton)
深色下对比度不够次要文字用 onSurface、1dp 细线用 outline 硬描深色下读不清或看不见分隔按角色表取;对比度要求见无障碍
用了不存在的 tokenMaterialTheme.colorScheme.surfaceVariantHigh、MaterialTheme.typography.bodyXLarge编译不过;或者你以为取自主题,实际拿到别的值只从 /api/ui/theme / /api/ui/type 列出的档位里取
卡片套卡片同色内外都 surfaceContainer两张卡糊成一块,看不出嵌套内层换相邻档位(surfaceContainerLow / surfaceContainerHighest)+圆角降一档

落笔前过一遍这五条,能滤掉绝大部分问题:

  • [ ] 界面外层是 PluginTheme,我没写 HuaLuoTheme / MaterialTheme / 自建 ColorScheme
  • [ ] 没有任何 Color(0xFF…) / fontSize / fontWeight / RoundedCornerShape(…)
  • [ ] 每个颜色都能说出它取自哪个角色,且和压在它上面的 on 角色成对
  • [ ] 层级靠留白 + 圆角 + 容器色 + 字阶做出来,没靠边框、阴影或第三种强调色
  • [ ] 抽出来的 composable 有 modifier 参数、可变内容走插槽、内部只用 token

组件参数怎么查、哪个组件管哪种形态,看 UI 组件库;间距与 Modifier 顺序看布局与 Modifier;状态怎么组织看状态与副作用。