HarmonyOS7 元服务「诗境」的配色、圆角与组件复用
文章目录
前言
一个 25 个文件的元服务,视觉规范最后浓缩成三个常量类:11 个颜色、3 个尺寸、4 套卡片主题。有意思的不是它们定义了哪些值,而是哪些值被真的复用了、哪些只是写在文件里没人引用 —— 这两者的差别恰好说明了「什么该抽成常量」。

11 个颜色,一套纸感
export class AppColors {
static readonly PRIMARY: string = '#FF8A65'; // 暖阳橙
static readonly PRIMARY_LIGHT: string = '#FFAB91';
static readonly ACCENT_YELLOW: string = '#FFD54F'; // 活力黄
static readonly SKY_BLUE: string = '#4FC3F7'; // 天空蓝
static readonly BG_CREAM: string = '#FFF8E1'; // 米黄纸感
static readonly CARD_BG: string = '#FFFFFF';
static readonly TEXT_MAIN: string = '#3E2723';
static readonly TEXT_SUB: string = '#8D6E63';
static readonly DIVIDER: string = '#FFE0B2';
static readonly GRADIENT_START: string = '#FFB74D';
static readonly GRADIENT_END: string = '#FF8A65';
}
这套色的底色不是白,是米黄(#FFF8E1)。所有页面背景都用 BG_CREAM,卡片是纯白浮在上面 —— 于是「卡片」这个概念由一层 5% 不到的明度差建立,不需要画边框。
文字色也不是黑,是深棕 #3E2723,副文案 #8D6E63 是更浅的棕。这两个色值在「纸」的语境下比 #000000 / #666666 更贴题 —— 纯黑在米黄底上会显得脏,深棕则像墨。
| 常量 | 色值 | 实际用在哪 |
|---|---|---|
PRIMARY | #FF8A65 | 强调数字、按钮底色、选中态、进度条 |
PRIMARY_LIGHT | #FFAB91 | 定义但未见引用 |
ACCENT_YELLOW | #FFD54F | 刷卡页「上一首」可用时的底色 |
SKY_BLUE | #4FC3F7 | 定义但未见引用 |
BG_CREAM | #FFF8E1 | 全部页面背景、册图标底色 |
CARD_BG | #FFFFFF | 所有白卡、按钮文字反色 |
TEXT_MAIN | #3E2723 | 标题、诗文 |
TEXT_SUB | #8D6E63 | 说明、计数、副标题 |
DIVIDER | #FFE0B2 | 与进度条底色 #FFE0B2 同值 |
GRADIENT_START / GRADIENT_END | #FFB74D / #FF8A65 | 等级进度条、Tab 选中胶囊 |
PRIMARY_LIGHT 与 SKY_BLUE 定义了两个但项目里没有引用点。这类「预留色」在只有一套主题的应用里很常见 —— 它们的存在成本是零(常量不参与打包后的逻辑),价值是下次要加一个「次级按钮」或「信息提示」时不用重新想色号。
DIVIDER 的值 #FFE0B2 在代码里以字面量形式出现了更多次(进度条底色、进度条背景)。同一个色值既是「分割线」又是「进度槽」,因为它本来就是「纸上一道浅浅的痕迹」。这里能看到常量的边界:它定义了语义(分隔),但同一个色值在另一个语义(进度槽)下被复用时,调用点宁愿写字面量也不愿 import 一个叫 DIVIDER 的常量来当进度槽用。
3 个尺寸,只有 1 个在用
export class AppSizes {
static readonly CARD_RADIUS: number = 20;
static readonly PADDING: number = 16;
static readonly GAP: number = 12;
}
CARD_RADIUS 被 7 个文件引用了 16 次 —— 所有「容器级的卡」都用它:统计行、等级卡、列表行、册行、每日一诗卡、年级选择卡。这是整套视觉里唯一必须一致的量:同一屏上出现三四张卡,圆角差 2vp 就会看出来。
PADDING 与 GAP 从未被引用。实际代码里的内边距是 14、18、20、24、10、7,间距是 4、6、8、10、12、14。没有形成 4 的倍数节奏,也不按「大容器 20、小容器 14」的规则来 —— 每一处都是按内容手调的。
这不是疏忽,而是内边距与间距本来就没有全局最优解:一张 2×2 的小卡片(进度卡)用 padding(10) 刚好,一个全屏 Tab 用 padding({ left: 20, right: 20 }) 刚好,一个按钮内部用 padding({ left: 14, right: 14 }) 刚好。把这三个数字收敛成一个常量,得到的是一个「在哪都不太对」的中间值。
圆角不一样。圆角是形状语言,不是排版细节 —— 它必须统一,否则就不是「一套卡的圆角是 20」,而是「每张卡碰巧都挺圆」。所以 CARD_RADIUS 被抽出来并严格执行,另外两个留在文件里当文档。
四套主题,六个方法
刷卡页的卡片有四种纸色,按索引轮换:
export class PoemCardThemes {
static readonly THEME_COUNT: number = 4;
static getTopColor(themeIndex: number): string {
switch (themeIndex % PoemCardThemes.THEME_COUNT) {
case 0:
return '#F5E6C4'; // 米黄宣纸
case 1:
return '#C9E3D5'; // 淡青竹影
case 2:
return '#F2D3D8'; // 藕荷雅韵
default:
return '#D9E4F2'; // 月白远山
}
}

四个主题各有两个渐变色(顶/底)加一个光晕色,共 12 个色值:
| 主题 | 顶部 | 底部 | 光晕(阴影色) |
|---|---|---|---|
| 米黄宣纸 | #F5E6C4 | #FFF8E7 | #59D4B06A |
| 淡青竹影 | #C9E3D5 | #F0F8F2 | #595FA87A |
| 藕荷雅韵 | #F2D3D8 | #FDF0F2 | #59C9738A |
| 月白远山 | #D9E4F2 | #F3F7FC | #595A7BA8 |
顶色饱和、底色接近白 —— 渐变从上到下逐渐化开,像宣纸吸了墨又淡下去。光晕色都是 #59 开头(35% 透明度)的主题色,用 shadow 打在卡片外圈,让卡片周围有一层同色的柔性辉光。这一点很关键:如果阴影是黑色(像其他所有卡片那样),四套配色在视觉上会退化成「四张白卡换了底色」;换成主题色辉光之后,每张卡像一个发光体。
六个方法里三个是 switch(顶色、底色、光晕),另外三个直接返回 AppColors 的常量:
/** 主文字颜色(随主题微调,整体偏深) */
static getTextMainColor(themeIndex: number): string {
return AppColors.TEXT_MAIN;
}
getTextMainColor 接收 themeIndex 却完全不使用它。签名统一是为了调用点整齐 —— 卡片里六处取色都写成 PoemCardThemes.getXxx(this.themeIndex),不用区分「哪个方法需要索引、哪个不需要」。如果直接写 AppColors.TEXT_MAIN,读者会问「文字色为什么不跟着主题变」,而写成方法调用,"随主题微调"这条注释就说明了当前的选择:四种底色都很浅,深棕文字在四种上都够对比度,不需要分别调。
themeIndex % THEME_COUNT 取模在两个层次上各做了一次:页面侧 index % PoemCardThemes.THEME_COUNT 传给卡片,卡片内的每个取值方法里又 % 一次。重复取模是防御性的 —— 万一有人传进来 7 或 -1,最坏结果是落到某个已有分支,而不是 switch 全部落空返回 undefined。switch 用了 default 兜底,所以即使不取模也不会返回空,但取模让「7 和 3 是同一套色」这件事在语义上成立。
六档阴影,一层一层往上浮
全项目 16 处 shadow(),参数收敛得相当整齐:
| radius | 颜色 | offsetY | 用在哪 |
|---|---|---|---|
| 4 | #0D000000 | 2 | 首页统计三卡、我的页三个设置行 |
| 6 | #0D000000 | 2 | 详情页诗文卡 |
| 8 | #14000000 | 2 | 收藏列表行、我的页列表行、册行 |
| 8 | #22FF8A6520 | 3 | 我的页等级卡(暖橙光晕) |
| 16 | #55FF8A65 | 4 | 年级选择的选中卡、Tab 选中胶囊 |
| 20 | #40000000 | 8 | 升级弹窗 |
| 24 | 主题光晕色 | 6 | 首页每日一诗卡 |
| 30 | #33000000 | 10 | 刷卡页完成庆祝层 |
| 40 | 主题光晕色 | 12 | 刷卡页诗词卡 |
半径就是层级:4 是「贴在纸上」,8 是「一张卡」,20 以上是「浮在页面之上」。这条映射是连续的,没有出现「一个 6 和一个 28 表达同一个层级」的混乱。
颜色的选择更有意思。#0D000000、#14000000、#40000000、#33000000 是同一个黑色加不同透明度 —— 0D 约 5%、14 约 8%、33 约 20%、40 约 25%。透明度也跟着层级涨:越高的层,影子越重。

三处用了彩色阴影(#22FF8A6520 等级卡、#55FF8A65 选中态、主题光晕色)。它们都遵循同一个格式:#AARRGGBB 里的 AA 是透明度,RRGGBB 是强调色。等级卡的 #22FF8A6520 里 FF8A65 就是 PRIMARY,22 是 13% 透明度 —— 比黑色阴影(5%)重,因为它要表达「这张卡在发光」而不是「这张卡有厚度」。
圆角的两条线
CARD_RADIUS = 20 管容器,按钮另有一套:
| 元素 | 高度 | 圆角 | 形态 |
|---|---|---|---|
| 主操作按钮(上一首/下一首/开始读诗) | 48–52 | 16 / 26 | 圆角矩形 / 胶囊 |
| 卡片内朗读按钮(静置) | 48 | 24 | 圆形 |
| 卡片内朗读按钮(朗读中) | 48 | 24 | 胶囊(宽度撑开) |
| 卡片内收藏/学会按钮 | 48 | 16 | 圆角矩形 |
| 弹窗按钮(太棒了) | 42 | 21 | 胶囊 |
| 底部 Tab 胶囊 | 48 | 24 | 胶囊 |
| 小标签(已学会、每日一诗) | — | 6 / 8 | 小圆角 |
| 小标签(年级、点击提示) | — | 20 | 胶囊 |
规律是「圆角等于高度一半 → 胶囊,小于一半 → 圆角矩形」。朗读按钮 48 高、24 圆角,就是一个正圆;朗读中宽度被 padding 撑开、高度仍是 48,同一个 24 的圆角就变成了胶囊两端。一个数值同时管住了两种形态,不需要为「朗读中」单独写一套圆角。
主操作按钮反而用 16 而不是 24 —— 48 高的按钮配 16 圆角是「圆角矩形」,配 24 才是胶囊。刷卡页底部两个按钮、卡片内两个状态按钮都用 16,只有「开始读诗」这种全页唯一的行动按钮用 26(52 高,刚好一半)。层级越高的操作越圆,这条隐含规则在项目里执行得挺一致。
三个列表行,三种复用方式
PoemRowCard(收藏页、我的页)与 VolumeRowItem(首页册列表)长得像,内部机制完全不同。
PoemRowCard 的左侧是一个色块,里面放标题首字:
Column() {
Text(this.title.substring(0, 1))
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(AppColors.CARD_BG)
}
.width(48)
.height(48)
.borderRadius(14)
.backgroundColor(AppColors.PRIMARY)
.justifyContent(FlexAlign.Center)
不用图片、不用图标,取 title.substring(0, 1) 当视觉锚点。111 首诗的标题首字分布足够散(《静夜思》是「静」,《咏鹅》是「咏」),列表里一行一个橙块一个字,比一排相同的书本图标更容易分辨。代价是像《江南》《江雪》这种同首字的诗在视觉上会撞,但它们的第二三行(朝代·作者·年级、正文预览)足以区分。
两个状态徽标靠 @Local 快照加订阅总线:
aboutToAppear(): void {
this.syncFlags();
StorageService.getInstance().addChangeListener(this.onStoreChange);
}
private syncFlags(): void {
// 直接从单例现取数据,避免包装/拷贝导致读到旧数据
const u: UserState = StorageService.getInstance().userState;
this.masteredFlag = u.isMastered(this.poemId);
this.collectedFlag = u.isCollected(this.poemId);
}
父组件只传四个只读字段(poemId、title、meta、preview),状态由行自己维护。这样收藏页里取消收藏一首诗时,不需要父组件重新构造列表 —— 该行的 masteredFlag 变 false,「已学会」标签自己消失。
VolumeRowItem 走的是另一条路,它不注册总线:
@Local userState: UserState = StorageService.getInstance().userState;
private getMasteredCount(): number {
if (this.poems.length === 0) {
return 0;
}
let count: number = 0;
for (let i = 0; i < this.poems.length; i++) {
if (this.userState.isMastered(this.poems[i].id)) {
count++;
}
}
return count;
}
getMasteredCount() 在 build 里被调用两次(数字文案一次、Progress 的 value 一次),内部读 this.userState.isMastered(...) —— 这个方法读的是 @ObservedV2 类上的 @Trace 字段 masteredIds。V2 在渲染时记录这次读取,masteredIds 一旦被整体替换,组件就重渲染。
不需要注册总线,是因为「读取发生在 build 里」这件事本身就让依赖被追踪到了。而 PoemRowCard 之所以要注册,是因为它的 masteredFlag 是 @Local 副本,build 读的是副本而不是 userState,依赖链断在 syncFlags() 那一刻 —— 得靠外部通知来重新同步。
两种方式各有代价:副本方式每行多两个字段、多一次注册与退订;直读方式每次渲染都要 indexOf 一遍。册列表只有两三行、每行二十首诗,indexOf 的总量在一千次以内;收藏列表可能有上百行,逐行订阅反而更省。
两处空态,两种形制
收藏页的空态占满整个内容区:
if (this.collected.length === 0) {
Column({ space: 8 }) {
Image($r('app.media.ic_fav_empty_star'))
.width(72)
.height(72)
Text('还没有收藏的诗')
.fontSize(14)
.fontColor(AppColors.TEXT_SUB)
Text('在诗词详情页点星标收藏')
.fontSize(12)
.fontColor(AppColors.TEXT_SUB)
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
layoutWeight(1) 加 justifyContent(Center) 让它在剩下的所有空间里垂直居中,72 图标、两行文案,没有卡片底色 —— 空态本身就是页面。
我的页的空态在页面中部:
if (this.masteredList.length === 0) {
Column({ space: 6 }) {
Image($r('app.media.ic_empty_backpack'))
.width(64)
.height(64)
Text('还没有已学会的诗')
.fontSize(14)
.fontColor(AppColors.TEXT_SUB)
Text('在诗词详情页点「标记已学会」')
.fontSize(12)
.fontColor(AppColors.TEXT_SUB)
}
.width('90%')
.padding(24)
.backgroundColor(AppColors.CARD_BG)
.borderRadius(AppSizes.CARD_RADIUS)
}
白色卡片底、padding(24)、占 90% 宽、不撑满剩余高度。因为它上面还有等级卡、三行统计、一行「当前年级」,空态只是「已学会列表」这个区块的内容 —— 如果它也 layoutWeight(1) 居中,就会把上面的内容顶到屏幕外。
两处空态的文案结构是一样的:「还没有 XX」加「在 YY 页点 ZZ」。第二行必须说清去哪里能改成非空状态,否则用户看到空页面只会退出去。图标尺寸 72 与 64 的差别来自容器:整屏居中可以用大一点的图,卡片内的占位图不能超过卡的留白。

那个没人用的 SafeTopSpace
Common.ets 里有个组件专门处理顶部留白:
/** 顶部安全留白(元服务胶囊避让) */
@ComponentV2
export struct SafeTopSpace {
build() {
Column() {
}
.width('100%')
.height(56)
}
}
它没有出现在任何 build 里。三个 Tab 全都是内联写法:
Column() {}.width('100%').height(56)
三处逐字相同。抽成组件能省下的是三次 Column() {},而内联的写法不需要 import、不需要记组件名、在文件里的位置就是它生效的位置。56 这个数字是元服务胶囊的避让高度,改动频率几乎为零。
刷卡页更直接,把留白做进 padding:
.padding({ left: 20, right: 20, top: 56, bottom: 8 })
因为它那一行本身就要横向 20 的边距,顶部留白顺手并进同一个 padding 里,比拼一个 SafeTopSpace 再排一行更省一层节点。
卡片的布局里也能看到同一件事的另一面:标题上方有一个 Blank()、下方也有一个 Blank(),靠两个弹性占位把内容挤到垂直中间。Blank() 是零配置的弹性占位,layoutWeight(1) 的 Column 是带排版的弹性容器 —— 项目里两种都用,选哪个取决于中间内容需不需要再排。
Tab 栏:一份样式,两种状态
底部 Tab 的每一项都由同一个 @Builder 渲染:
@Builder
buildTabBar(idx: number) {
Row({ space: 6 }) {
Image(this.tabIcons[idx])
.width(24)
.height(24)
if (this.currentTab === idx) {
Text(this.tabLabels[idx])
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
}
.height(48)
.padding({ left: 16, right: this.currentTab === idx ? 18 : 16 })
.borderRadius(24)
.linearGradient(this.currentTab === idx
? { angle: 135, colors: [[AppColors.GRADIENT_START, 0], [AppColors.PRIMARY, 1]] }
: undefined)
.shadow(this.currentTab === idx
? { radius: 16, color: '#55FF8A65', offsetX: 0, offsetY: 4 }
: { radius: 0, color: '#00000000', offsetX: 0, offsetY: 0 })
.justifyContent(FlexAlign.Center)
.animation({ duration: 220, curve: Curve.EaseInOut })
}
未选中时只显示图标、选中时图标加文字。padding.right 在选中时从 16 变成 18 —— 多出的 2vp 补偿文字右侧的视觉重量,让胶囊两端的留白看起来一样。
「取消效果」用了两种不同写法:渐变传 undefined,阴影传 { radius: 0, color: '#00000000' }。因为 linearGradient 接受 undefined 表示不画渐变,而 shadow 不接受 undefined,只能传一个「半径 0、颜色全透明」的空阴影。
.animation({ duration: 220 }) 挂在最后,让这一整条链上的属性变化(宽度、渐变、阴影、文字出现)统一补间 220ms。切换 Tab 时,橙色胶囊从旧位置滑到新位置,蓝灰的图标变白 —— 这个「滑动高亮」不靠任何动画组件,只靠一个 @Local currentTab 加属性动画。
Tab 容器本身用的是系统 Tabs 加毛玻璃底栏:
.barBackgroundBlurStyle(BlurStyle.COMPONENT_THICK)
.barBackgroundColor('#CCFFF6E0')
.barHeight(72)
#CC 是 80% 透明度的米黄加在毛玻璃上 —— 底下的内容(列表滚到底时露出的最后一张卡)会模糊地透出来。barHeight(72) 比常规 Tab 栏高,因为里面装的是 48 高的胶囊加 12 上下的呼吸空间。
年级选择页把「同一个组件的两种状态」写得最直白:
.shadow({ radius: this.selected === idx + 1 ? 16 : 4, color: this.selected === idx + 1 ? '#55FF8A65' : '#10000000', offsetX: 0, offsetY: this.selected === idx + 1 ? 4 : 2 })
一行里三个三元表达式。没有拆成两个 if 分支、没有把选中/未选中写成两个 @Builder —— 因为除了颜色、阴影、文字色,其余十几行完全一样。三元表达式让「差异」集中在一处,读者一眼能看到「选中只是换了底色、阴影和文字反色」。
一处不该复用却开了口子的地方
等级卡的进度条是手写的:
Row() {
Column()
.width((LevelSystem.getProgress(this.userState.masteredCount) * 100).toFixed(1) + '%')
.height(8)
.borderRadius(4)
.linearGradient({ angle: 90, colors: [[AppColors.GRADIENT_START, 0], [AppColors.PRIMARY, 1]] })
Column()
.layoutWeight(1)
.height(8)
}
而册行和刷卡页顶部都用系统的 Progress:
Progress({
value: this.getMasteredCount(),
total: this.poemCount > 0 ? this.poemCount : 1,
type: ProgressType.Linear
})
.width('100%')
.height(6)
.color(AppColors.PRIMARY)
.backgroundColor('#FFE0B2')
Progress 的 color 只接受一个颜色,等级卡的进度条要从 #FFB74D 渐变到 #FF8A65。为了这一处渐变,多写了两个 Column 的拼接。
这个取舍是有意识的:一处特殊(等级卡是唯一带渐变的进度条,也是「我的」页上的视觉焦点),两处常规(册行、刷卡页顶部只需要一条实心进度)。把渐变版本包成 GradientProgress 组件,等于用一处调用点去反推一个组件的参数面(高度、圆角、底色、两端颜色),成本远大于收益。
一句话的取舍
这套视觉里真正被严格复用的只有两样:11 个颜色和 20 的圆角。其余全部按场景手调 —— 内边距、间距、图标尺寸、阴影档位、按钮圆角,都能在同一份代码里找到五个以上的不同取值。这不是规范缺失,而是「三层结构」的分工:颜色和容器圆角决定「这是同一个应用」,其余细节决定「每一屏都刚好」。判断某个值该不该抽成常量,标准其实只有一个 —— 它不一致时,用户会不会看出来。
更多推荐
所有评论(0)