做 HarmonyOS 5.0+ 应用时,视觉一致性经常不是被一个大问题击穿,而是被很多小偏差慢慢拖垮:首页浅米色,题库页白底卡片,答题页有正确、错误、选中、未答四种状态,搜索页和笔记弹窗又要处理输入框。在笔下生辉源码里,这些颜色、字号、圆角、触控尺寸和答题状态并没有散落在每个页面里,而是集中在ThemeConstants.ets里,再由页面按职责引用。
本文基于com.jiaweikang.one17的真实源码复核,不把未实现的能力写成既有结果。源码里有两份主题常量:librarya/src/main/ets/constants/ThemeConstants.ets作为公共能力层导出给页面使用,entry/src/main/ets/common/constants/ThemeConstants.ets在入口侧同步了主色、题型图标和等级映射。项目文档还记录了上架色彩对比度整改和深色模式输入框整改:应用整体采用浅色视觉口径,并通过输入框显式颜色降低系统深色模式下的显示风险。这个边界要先讲清楚,本文讨论的是“视觉令牌治理和深色场景防护”,不是声称源码已经提供完整的动态深浅色资源切换系统。
1. 为什么先看 ThemeConstants,而不是先看页面
页面里直接写颜色看起来最快,但一旦应用进入上架阶段,就会出现三个现实问题。
第一,颜色含义会漂移。#2F6B5E在首页可能是主按钮,在分类页可能是标签文字,在答题页又可能是当前题号背景。如果页面只写十六进制值,后续没人能判断它到底是品牌色、状态色还是交互色。
第二,对比度整改会失控。笔下生辉的文档中已经记录过TEXT_HINT、SUCCESS、ERROR、WARNING、ACCENT等颜色调整,目的就是让小字号文字在白底和米白底上更容易读。如果这些颜色散落在几十个页面里,一次整改就会变成全仓库手工搜索。
第三,深色模式会暴露原生控件默认行为。项目文档《深色模式输入框整改说明》明确提到,搜索输入框、答题页笔记TextArea、收藏页编辑笔记弹窗需要显式设置输入文字、占位文字、光标和输入背景色,避免系统深色模式下局部控件变成不可读状态。
因此,笔下生辉把主题变量做成Colors与Sizes两类,页面只消费命名令牌。
2. 公共层 Colors:把品牌、文本、状态和交互分开
librarya/src/main/ets/constants/ThemeConstants.ets是页面大量使用的公共令牌文件。它的Colors类覆盖了主色、背景、文本、输入框、状态和答题选项。
export class Colors { static readonly PRIMARY: string = '#2F6B5E' static readonly PRIMARY_DARK: string = '#1F5246' static readonly PRIMARY_LIGHT: string = '#E6EFEB' static readonly ACCENT: string = '#8A6814' static readonly INK: string = '#3A4A45' static readonly BACKGROUND: string = '#F5F1E8' static readonly BACKGROUND_ALT: string = '#FBF8F0' static readonly SURFACE: string = '#FFFFFF' static readonly TEXT_PRIMARY: string = '#1F2A26' static readonly TEXT_SECONDARY: string = '#3D4A45' static readonly TEXT_HINT: string = '#5F6763' static readonly INPUT_TEXT: string = '#1F2A26' static readonly INPUT_PLACEHOLDER: string = '#6F7873' static readonly INPUT_BG: string = '#FFFFFF' static readonly DIVIDER: string = '#E5DFD0' static readonly SUCCESS: string = '#267A55' static readonly ERROR: string = '#B3261E' static readonly WARNING: string = '#8A5A00' }这段代码的重点不是“颜色很多”,而是命名把用途拆开了。PRIMARY、PRIMARY_DARK和PRIMARY_LIGHT是品牌视觉;BACKGROUND、BACKGROUND_ALT、SURFACE是层级背景;TEXT_PRIMARY、TEXT_SECONDARY、TEXT_HINT是文字层级;SUCCESS、ERROR、WARNING是语义状态;INPUT_*是专门给原生输入控件准备的深色场景防护。
实际写页面时,使用这些命名比直接写颜色更容易维护。例如辅助文字统一用Colors.TEXT_HINT,后续上架检查发现对比度不够,只需要从令牌层调整,不需要理解每个页面的业务结构。
3. alpha 函数:用透明度表达弱背景,而不是新增一堆浅色
主题文件里还有一个小工具:
export function alpha(color: string, alphaHex: string): string { if (!color || color.length < 7) return color return '#' + alphaHex + color.substring(1, 7) }ArkUI 颜色格式使用#AARRGGBB时,透明度在前面。这个函数把#2F6B5E和透明度15组合成#152F6B5E。它的作用是让页面在需要弱背景、遮罩、淡色标签时,不必创建大量只差透明度的颜色常量。
这里要注意两个边界。
| 输入 | 输出逻辑 | 适合用途 |
|---|---|---|
alpha('#2F6B5E', '15') | #152F6B5E | 主色淡背景、弱选中态 |
| 空字符串或长度不足 | 原样返回 | 避免异常中断构建 |
| 非标准色值 | 不做复杂校验 | 调用方仍应传入标准十六进制色 |
这类函数不应该被滥用到所有颜色上。对文本色、错误色、成功色这类涉及可读性的颜色,源码已经选择了固定令牌。透明度更适合背景层和轻量状态,不适合正文文字。
4. Sizes:尺寸令牌让页面间距和触控目标稳定
视觉割裂不只来自颜色,也来自字号、圆角和间距。公共层Sizes把这些数字集中起来:
export class Sizes { static readonly H1_FONT: number = 22 static readonly H2_FONT: number = 18 static readonly TITLE_FONT: number = 16 static readonly BODY_FONT: number = 14 static readonly CAPTION_FONT: number = 12 static readonly SMALL_FONT: number = 10 static readonly CARD_RADIUS: number = 16 static readonly CARD_RADIUS_SM: number = 10 static readonly BTN_RADIUS: number = 24 static readonly PADDING_SMALL: number = 8 static readonly PADDING_MEDIUM: number = 12 static readonly PADDING_LARGE: number = 16 static readonly PADDING_XL: number = 20 static readonly BOTTOM_NAV_MIN_PADDING: number = 28 static readonly TOUCH_TARGET: number = 44 }从上架审查角度看,TOUCH_TARGET和BOTTOM_NAV_MIN_PADDING特别关键。BankListPage和SearchPage里返回、搜索等图标按钮使用Sizes.TOUCH_TARGET控制宽高和圆角;SearchPage、SettingsPage、PracticePage等页面结合navigationIndicatorHeightPx计算底部避让,避免按钮落到系统手势区。
这说明笔下生辉的视觉令牌不只是“好看”,还承担了可触达性和布局稳定性职责。字号和间距集中后,页面之间的节奏更统一;触控目标集中后,后续如果要按 AppGallery 或 UX 标准调整,不需要逐页猜测按钮尺寸。
5. 答题选项:用 OPTION_* 表达状态机
答题页是最能体现视觉令牌价值的地方。PracticePage.ets里选项背景和边框由两个方法决定:
private optionBg(key: string): string { if (!this.showAnalysis) { return this.selectedKey === key ? Colors.OPTION_SELECTED_BG : Colors.OPTION_BG } const q = this.currentQ() if (!q) return Colors.OPTION_BG if (key === q.answer) return Colors.OPTION_CORRECT_BG if (key === this.selectedKey) return Colors.OPTION_WRONG_BG return Colors.OPTION_BG } private optionBorder(key: string): string { if (!this.showAnalysis) { return this.selectedKey === key ? Colors.OPTION_SELECTED_BORDER : Colors.OPTION_BORDER } const q = this.currentQ() if (!q) return Colors.OPTION_BORDER if (key === q.answer) return Colors.OPTION_CORRECT_BORDER if (key === this.selectedKey && key !== q.answer) return Colors.OPTION_WRONG_BORDER return Colors.OPTION_BORDER }这段逻辑把视觉状态和答题状态绑定起来:未解析时只显示选中态;解析后正确答案使用成功色,选错项使用错误色,其余选项回到普通背景。真正承载颜色的是Colors.OPTION_SELECTED_BG、Colors.OPTION_CORRECT_BG、Colors.OPTION_WRONG_BG等令牌。
如果页面直接写#E5F4EC或#FCE9E8,后续调整正确/错误状态时必须回到业务方法里找颜色;现在只需要检查ThemeConstants.ets的OPTION_*区域。业务方法只负责判断“哪个选项是什么状态”,视觉令牌负责“这个状态长什么样”。
6. 输入框:深色模式风险要显式处理
项目文档《深色模式输入框整改说明》记录了一个具体问题:系统深色模式下,搜索和笔记输入框可能出现文字看不清。源码里的修复方向是给输入控件显式指定颜色。SearchPage.ets、PracticePage.ets、FavoritePage.ets都能看到类似写法:
TextArea({ placeholder: '记录你的学习心得...', text: this.noteText }) .fontSize(Sizes.BODY_FONT) .fontColor(Colors.INPUT_TEXT) .placeholderColor(Colors.INPUT_PLACEHOLDER) .caretColor(Colors.PRIMARY) .backgroundColor(Colors.INPUT_BG) .border({ width: 1, color: Colors.DIVIDER }) .borderRadius(Sizes.CARD_RADIUS_SM)这是一种很务实的策略。应用整体视觉当前以浅色设计为主,不强行宣称完整深色主题;但对原生输入控件这种容易被系统模式影响的组件,显式指定输入文字、占位文字、光标、背景和边框,能降低局部不可读风险。
它也给后续扩展留下空间。如果未来要做完整深色资源,可以保持INPUT_TEXT、INPUT_PLACEHOLDER、INPUT_BG这些语义名不变,只改变资源或常量的具体取值。页面不用知道当前是浅色还是深色,只消费“输入文字色”“占位文字色”“输入背景色”。
7. 对比度整改:文档里的数字可以反推工程目标
doc/上架色彩对比度检查记录.md记录了多组颜色调整。虽然文档内容在终端里可能出现编码显示问题,但表格结构和色值非常明确:
| 令牌 | 调整后 | 用途 |
|---|---|---|
TEXT_HINT | #5F6763 | Tab 未选中、说明文字、空状态 |
SUCCESS | #267A55 | 正确状态、及格标签、答题卡 |
ERROR | #B3261E | 错题、删除、收藏高亮、徽标 |
WARNING | #8A5A00 | 考试倒计时警告 |
ACCENT | #8A6814 | 良好等级、收藏统计、金色点缀 |
这份文档说明主题常量不是凭感觉选出来的,而是围绕 AppGallery 常见审查风险做过收敛。对于 HarmonyOS 应用,浅灰文字、低饱和黄、浅绿状态色都容易在米白或白底上掉到低对比区间。把这些颜色收敛到ThemeConstants后,后续复查只需要看令牌和少量例外色。
源码里仍存在少量直接写色,例如遮罩#66000000、局部收藏工具背景#FFF1F1、笔记工具背景#FBF3D8、答题卡工具背景#EAF4F8。这些颜色并不一定错误,但需要归类:遮罩和一次性弱背景可以保留;如果同类颜色跨页面重复出现,就应该继续抽成令牌。
8. 入口侧 ThemeConstants:同步主视觉与题型映射
entry/src/main/ets/common/constants/ThemeConstants.ets也定义了Colors、Sizes、questionTypeLabel()、questionTypeIcon()和getRankByScore()。其中questionTypeIcon()把题型映射到资源:
export function questionTypeIcon(type: string): Resource { switch (type) { case 'typo': return $r('app.media.ic_category_vocab') case 'sentence': return $r('app.media.ic_category_dialog') case 'punctuation': return $r('app.media.ic_category_diff') case 'usage': return $r('app.media.ic_category_guess') case 'internet': return $r('app.media.ic_category_region') case 'poetry': return $r('app.media.ic_category_culture') default: return $r('app.media.ic_category_vocab') } }HomePage.ets、ExamTab.ets、CategoryPage.ets、ExamResultPage.ets等页面会引用这些函数。这样做的好处是页面不需要记住每个题型应该使用哪个图标、哪个等级应该使用哪个语义色。映射集中后,新增题型、替换图标或调整等级颜色时,修改范围更可控。
不过也要看到源码边界:公共层librarya和入口侧entry/common都有主题常量,二者需要保持同步。项目文档也明确写到曾经同步入口侧主题常量,避免局部页面沿用低对比旧色。后续维护时,应避免只改其中一份导致页面视觉不一致。
9. 页面消费方式:令牌要进入真实 UI,而不是停在常量文件
主题文件本身没有价值,只有被页面稳定消费才有价值。源码中可以看到多个页面直接使用这些令牌:
| 页面 | 令牌用法 | 工程效果 |
|---|---|---|
HomePage.ets | BACKGROUND、SURFACE、PRIMARY、TEXT_* | 首页背景、卡片、标题和标签统一 |
FavoritePage.ets | PRIMARY、ERROR、INPUT_*、CARD_RADIUS | 收藏/笔记/错题状态和编辑弹窗统一 |
PracticePage.ets | OPTION_*、SUCCESS、ERROR、WARNING、INPUT_* | 答题状态、倒计时、笔记输入框统一 |
SearchPage.ets | TOUCH_TARGET、INPUT_*、DIVIDER | 搜索输入框和返回按钮可读可触达 |
SettingsPage.ets | PRIMARY_LIGHT、ERROR、BOTTOM_NAV_MIN_PADDING | 设置项、危险操作和底部安全区统一 |
这种消费方式避免了页面各自发明“自己的绿色”“自己的灰色”“自己的圆角”。当读者接手项目时,不需要先看十个页面才能理解视觉系统,只要先看ThemeConstants.ets,再检查页面是否按语义引用即可。
10. 维护建议:不要让令牌变成另一个垃圾桶
视觉令牌集中以后,也会出现新的风险:所有颜色都往Colors里塞,最后常量文件变成不可维护的色值仓库。结合笔下生辉源码,比较稳的维护规则是:
| 是否应抽成令牌 | 判断标准 | 示例 |
|---|---|---|
| 应该 | 跨页面重复、承担语义、影响审查 | TEXT_HINT、SUCCESS、INPUT_TEXT |
| 可以 | 单页面但属于稳定组件状态 | OPTION_SELECTED_BG |
| 暂缓 | 一次性遮罩或极少出现的局部弱背景 | #66000000 |
| 不建议 | 临时调试、未命名的视觉偏好 | 随手写的浅灰、浅黄 |
如果新增页面,建议先从已有令牌中选择,不够时再新增。新增令牌时要写清用途,不要命名成GREEN_1、GRAY_2这类无法说明业务含义的名字。令牌名应该回答“它服务什么语义”,而不是只描述颜色长什么样。
11. 验证清单:视觉令牌要按页面和状态复核
这类改动不能只靠编译通过。建议按以下路径检查:
| 检查项 | 路径 | 预期 |
|---|---|---|
| 首页视觉 | 首页卡片、横幅、分类入口 | 背景、标题、标签和卡片没有割裂 |
| 答题状态 | 选中、正确、错误、未选 | 背景和边框状态清晰,文字仍可读 |
| 搜索输入 | 搜索页输入内容和占位 | 文字、占位、光标、边框清晰 |
| 笔记弹窗 | 答题页和收藏页编辑笔记 | 深色系统环境下输入框不变黑不可读 |
| 设置页危险操作 | 清空记录等按钮 | 错误色可识别但不过度刺眼 |
| 底部区域 | 手势导航设备 | 按钮与底部安全区不冲突 |
如果准备上架,还要把浅色模式、系统深色模式、手机、平板、PC/2in1 小窗都列入检查。笔下生辉当前策略偏向浅色固定视觉,因此重点不是“所有背景都变暗”,而是确认系统深色模式不会让输入框、启动页、系统栏或原生控件出现不可读。
12. 常见问题与修复方向
| 现象 | 先看哪里 | 可能原因 | 修复方向 |
|---|---|---|---|
| 页面出现两个不同主色 | 页面直接写了色值 | 没有使用Colors.PRIMARY | 替换为语义令牌 |
| 辅助文字在米白底上发灰 | TEXT_HINT或局部硬编码 | 使用旧低对比灰 | 使用#5F6763对应令牌 |
| 深色模式输入框文字看不清 | SearchPage、PracticePage、FavoritePage | 未显式设置INPUT_* | 设置fontColor、placeholderColor、backgroundColor |
| 答题正确/错误状态不一致 | OPTION_*和optionBg() | 状态判断和颜色混写 | 让业务方法只返回令牌 |
| 按钮过小或不易点击 | Sizes.TOUCH_TARGET | 页面写死小宽高 | 使用统一触控尺寸 |
| 两份主题常量不同步 | librarya与entry/common | 只改了一侧 | 同步主色、文本色和语义色 |
这些问题的共同点是:修复不应该从每个页面随手调色开始,而应该先判断这是品牌色、文本色、输入色、状态色还是局部装饰色。确定语义后,再决定是否进入ThemeConstants。
13. 小结
笔下生辉的亮暗色与视觉令牌实践,核心不是追求复杂主题系统,而是把上架和真实设备中最容易出问题的视觉因素集中管理。Colors承担品牌、背景、文本、输入框、状态和答题选项;Sizes承担字号、圆角、间距、底部安全区和触控目标;questionTypeIcon()、questionTypeLabel()、getRankByScore()把业务分类和视觉呈现绑定到可维护的位置。
源码也有明确边界:应用当前主要是浅色视觉口径,深色场景重点通过输入框显式颜色和相关文档整改来规避不可读问题;librarya与entry/common两份主题常量需要持续同步。对 HarmonyOS 5.0+ 应用来说,这种做法比在页面里堆色值更稳,也更适合 AppGallery 发布前的色彩对比度、深色模式和多设备 UI 复核。