亮暗色与视觉令牌封面

一个 HarmonyOS 应用页面增加到十几个之后,视觉问题往往不是“某个颜色不好看”,而是同一种语义在不同页面被写成不同值:标题有时 18vp、有时 20vp;卡片圆角一会儿 12vp、一会儿 18vp;成功状态和主按钮都用绿色,却没有区分品牌色与语义色;暗色模式下某些页面自动变暗,另一些页面仍固定白底。这样的应用单页看似正常,连续使用时却会明显割裂。

本文基于句匠项目 D:\huawei\one18-11 的真实 ArkTS 源码,重点核对 entry/src/main/ets/common/constants/ThemeConstants.etsEntryAbility.etsresources/base/element/color.jsonresources/dark/element/color.json,并抽查 TopBar.etsGreenButton.etsBankCard.etsSectionHeader.etsPracticePage.etsSettingsPage.ets 等页面和组件。

本文唯一源码标识:com.jiaweikang.one18

先说结论:句匠已经把大量颜色、字号、间距、圆角和状态色集中到 ColorsSizes,共享组件也在复用这些令牌;但应用当前明确锁定浅色模式,dark 资源与 base 资源值相同,ArkTS 颜色常量又是固定十六进制字符串。因此,源码可以证明的是“浅色视觉体系已经部分令牌化”,不能证明“亮暗色自动适配已经完成”。

一、视觉令牌解决的不是美术问题,而是语义一致性

视觉令牌是把“为什么使用这个值”变成稳定名称。比如:

Colors.TEXT_PRIMARY
Colors.TEXT_HINT
Colors.SUCCESS
Sizes.BODY_FONT
Sizes.CARD_RADIUS
Sizes.PADDING_LARGE

页面不需要记住标题到底是 #1F2A26,只需要表达“这是主要文本”。以后调整配色时,修改令牌即可影响所有正确引用它的页面。

视觉令牌通常分成三层:

层级示例作用
原始值#2F6B5E、16vp最底层数值
语义令牌PRIMARYTEXT_HINT描述用途
组件令牌按钮背景、卡片圆角描述具体组件状态

句匠已有前两层的一部分,也有答题选项这一类接近组件级的状态令牌。

二、句匠的品牌色如何被集中

ThemeConstants.ets 将英伦学院风定义为墨绿、米白与暗金:

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'
}

这些值承担不同职责:

  • PRIMARY 用于主按钮、进度、选中状态;
  • PRIMARY_DARK 用于按压感或渐变下端;
  • PRIMARY_LIGHT 用于标签底色和选中区域浅底;
  • ACCENT 用于金色点缀;
  • INK 作为墨色扩展。

把“深一点的绿色”命名为 PRIMARY_DARK,比页面里散落 #1F5246 更容易维护。设计调整时也能知道它与主色的关系。

三、背景与表面必须分开

项目定义了三种背景层级:

static readonly BACKGROUND = '#F5F1E8'
static readonly BACKGROUND_ALT = '#FBF8F0'
static readonly SURFACE = '#FFFFFF'

BACKGROUND 是页面底,SURFACE 是卡片与工具栏,BACKGROUND_ALT 是卡片内部的次级区域。即使都接近浅色,语义仍不同。

如果所有区域都直接写 Color.White,页面层级只能依赖阴影和边框;如果每个页面随意挑一种米白,又会出现轻微但持续的色差。令牌让页面背景、卡片表面与嵌套区域保持稳定关系。

视觉令牌从语义到组件的结构

四、文本颜色要按信息层级定义

真实源码中有:

static readonly TEXT_PRIMARY = '#1F2A26'
static readonly TEXT_SECONDARY = '#444444'
static readonly TEXT_HINT = '#5F6763'

它们分别服务标题、正文、辅助信息。TEXT_HINT 的注释明确写了“符合 4.5:1”,说明项目在调整辅助文字时考虑了正文可读性,而不是只追求浅灰效果。

页面使用时应按语义选择:

Text('题库')
  .fontColor(Colors.TEXT_PRIMARY)

Text('按地区与题型挑选练习内容')
  .fontColor(Colors.TEXT_HINT)

同一页面内,如果标题、正文、占位符、禁用文本都用同一种灰色,信息层级会消失;如果辅助文字过浅,又会成为 AppGallery 色彩对比风险。

五、状态色不能由品牌色代替

句匠单独定义:

static readonly SUCCESS = '#267A55'
static readonly ERROR = '#B3261E'
static readonly WARNING = '#8A5A00'

虽然品牌主色也是绿色,但成功色仍有独立语义。这样未来品牌色改成蓝色时,“答题正确”不需要跟着变成蓝色。

错误、警告、成功还应同时使用文字、图标或形状表达,不能只依赖色相。对于色觉差异用户,仅靠红绿判断选项正误并不可靠。当前项目已有正确/错误背景与边框令牌,后续可以结合图标或明确文案继续增强。

六、答题选项已经形成组件状态矩阵

Colors 对答题选项定义了完整状态:

OPTION_BG
OPTION_BORDER
OPTION_SELECTED_BG
OPTION_SELECTED_BORDER
OPTION_CORRECT_BG
OPTION_CORRECT_BORDER
OPTION_WRONG_BG
OPTION_WRONG_BORDER

这比页面里写一串三元表达式更稳。一个答题选项至少包含默认、选中、正确、错误四种状态,每种状态又有背景与边框两个维度。

状态矩阵可以写成:

状态背景边框文本/图标
默认OPTION_BGOPTION_BORDER主要文本
选中OPTION_SELECTED_BGOPTION_SELECTED_BORDER主色强调
正确OPTION_CORRECT_BGOPTION_CORRECT_BORDER成功语义
错误OPTION_WRONG_BGOPTION_WRONG_BORDER错误语义

这类组件级令牌最能防止 PracticePage 多种题型之间出现交互状态不一致。

七、字号、间距和圆角同样是令牌

Sizes 并不只有颜色:

static readonly H1_FONT = 22
static readonly H2_FONT = 18
static readonly TITLE_FONT = 16
static readonly BODY_FONT = 14
static readonly CAPTION_FONT = 12
static readonly SMALL_FONT = 10

同时还集中:

CARD_RADIUS
CARD_RADIUS_SM
BTN_RADIUS
PADDING_SMALL
PADDING_MEDIUM
PADDING_LARGE
PADDING_XL
TAB_BAR_HEIGHT
BOTTOM_NAV_MIN_PADDING

这让视觉一致性从颜色扩展到排版、空间与形状。TopBarSectionHeaderBankCardGreenButton 都在引用这些值。

需要注意,SMALL_FONT = 10 对手机辅助信息尚可,但不能无差别用于正文;在 PC/2in1 场景,10vp 也可能偏小。令牌化不代表数值永远正确,它只是让后续统一调整变得可能。

八、共享组件是令牌真正落地的位置

仅定义 ColorsSizes 不会自动获得一致性,页面必须通过共享组件消费它们。

TopBar.ets 使用:

.colorBlend(Colors.PRIMARY)
.backgroundColor(Colors.SURFACE)
.border({ width: 1, color: Colors.DIVIDER })
.fontSize(Sizes.H2_FONT)
.fontColor(Colors.TEXT_PRIMARY)

GreenButton.ets 使用主色与深主色构建渐变:

.linearGradient({
  angle: 135,
  colors: [
    [Colors.PRIMARY, 0],
    [Colors.PRIMARY_DARK, 1]
  ]
})

BankCard.ets 则统一卡片背景、标题、标签、辅助文字和圆角。共享组件覆盖得越多,页面越不容易自行发明另一套样式。

九、alpha 工具如何减少透明色散落

项目提供:

export function alpha(
  color: string,
  alphaHex: string
): string {
  if (!color || color.length < 7) return color
  return '#' + alphaHex + color.substring(1, 7)
}

ArkUI 十六进制颜色采用 #AARRGGBB,这个工具把基础颜色和透明度组合。比如:

alpha(Colors.SUCCESS, '15')

比直接写 #15267A55 更能看出语义关系。

真实边界也要写清楚:该函数只取 color.substring(1, 7),适用于项目当前的 #RRGGBB 字符串;如果传入资源颜色、短格式、已有 Alpha 或非十六进制表示,就不一定符合预期。它不是通用颜色解析器。

十、当前应用明确锁定浅色模式

EntryAbility.onCreate() 中存在:

this.context
  .getApplicationContext()
  .setColorMode(
    ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT
  )

这意味着应用启动时主动选择浅色,而不是跟随系统暗色模式。调用放在 try/catch 中,失败会记录日志,但正常情况下用户切换系统暗色不会让应用进入真正暗色主题。

因此,标题里的“亮暗色”应理解为对现状与迁移方法的实战分析,不能解释为项目当前已提供亮暗色开关。

十一、dark 资源存在,但内容与 base 完全相同

项目同时存在:

resources/base/element/color.json
resources/dark/element/color.json

但两个文件中的 start_window_backgroundprimarybackgroundsurfacetext_primary 等值完全相同,例如背景都为 #F5F5F5,表面都为 #FFFFFF

这说明资源目录结构已经准备好,但暗色值并没有设计完成。即使移除强制浅色,如果页面引用这些资源,暗色限定目录也不会产生视觉变化。

更关键的是,主要页面实际大量引用 ThemeConstants.ets 中的固定字符串,而不是 $r('app.color.background')。资源限定机制无法自动替换这些 ArkTS 常量。

十二、当前存在两套并不一致的颜色来源

ThemeConstants.ets 的主色是 #2F6B5E,资源 color.jsonprimary 却是 #4CAF50;前者背景是米白 #F5F1E8,后者是灰白 #F5F5F5

这构成双重真源:

ArkTS Colors.PRIMARY → #2F6B5E
resource app.color.primary → #4CAF50

如果启动页、原生配置或少数页面使用资源,而大部分页面使用 ArkTS 常量,就可能在启动过渡、对话框或系统组件上看到配色跳变。

真正稳定的主题体系需要指定唯一真源。对于需要系统亮暗色限定的颜色,优先使用同名资源;ArkTS 中可以保留尺寸、业务映射和辅助函数,但不应再复制一套固定颜色值。

十三、硬编码颜色仍然大量存在

源码抽查显示,多个页面除了 Colors.* 之外,还直接使用:

Color.White
'#E6FFFFFF'
'#33000000'
'#29000000'
'#00000000'
'#B3000000'

部分硬编码是合理的,例如深色渐变上的白字与半透明遮罩;但它们仍需进入主题审计。暗色模式下,固定白字可能落在浅色背景上,固定黑色遮罩可能使内容过暗。

迁移时可以将常见叠加层集中:

HERO_TEXT
HERO_TEXT_SECONDARY
SCRIM_LIGHT
SCRIM_STRONG
PRESSED_OVERLAY

这段命名是建议,不是当前源码已有字段。

十四、真正双主题应怎样迁移

建议按风险从低到高分四步:

  1. 先确定是否跟随系统、提供应用内切换,还是继续锁定浅色;
  2. 统一颜色真源,把页面级固定颜色迁移到语义资源;
  3. 为 dark 限定目录填写真实暗色值;
  4. 移除强制浅色并做全页面回归。

资源可以保持同名:

{
  "color": [
    { "name": "background", "value": "#F5F1E8" },
    { "name": "surface", "value": "#FFFFFF" },
    { "name": "text_primary", "value": "#1F2A26" }
  ]
}

dark 目录使用同样名称,但填写经过对比度验证的深色值。页面引用:

.backgroundColor($r('app.color.background'))
.fontColor($r('app.color.text_primary'))

这样系统资源解析才会根据限定目录切换。

十五、不能机械反转浅色调色板

暗色主题不是把白变黑、黑变白。句匠的墨绿和暗金在深色背景上可能失去对比度,浅绿色标签底也不能直接沿用。

暗色设计至少要重新确定:

语义浅色关注点暗色关注点
页面背景米白氛围避免纯黑造成强烈反差
卡片表面与背景分层比背景略亮并保持边界
主文本深色高对比近白但避免刺眼
辅助文本满足 4.5:1不使用过暗灰
主色品牌识别提升亮度或降低饱和
分割线可见但克制避免完全消失

成功、错误、警告状态还要分别检查文字、背景和边框组合,不能只验证单个色值。

十六、组件状态要覆盖按压、禁用与聚焦

当前 GreenButton 能表达主按钮与部分视觉变化,答题选项也有选中、正确、错误状态。但完整的 PC/2in1 和多输入体验还需要:

  • 普通;
  • 按压;
  • 禁用;
  • 键盘聚焦;
  • 鼠标悬停;
  • 加载中;
  • 操作成功或失败。

视觉令牌可以继续扩展:

BUTTON_PRIMARY_BG
BUTTON_PRIMARY_PRESSED_BG
BUTTON_DISABLED_BG
BUTTON_DISABLED_TEXT
FOCUS_RING
HOVER_OVERLAY

是否需要全部字段取决于组件,不应一次性制造庞大体系。但关键工作流至少要有可识别的禁用与反馈状态。

十七、主题切换必须连同系统栏和启动页验证

主题不仅存在于 ArkUI 页面。句匠还涉及:

  • start_window_background
  • 状态栏图标明暗;
  • 底部导航指示区;
  • 对话框与弹窗;
  • 图片、图标和渐变;
  • 空状态与错误状态;
  • TextArea 占位符;
  • Canvas 或自绘内容。

如果启动页仍是白色,而首屏变为深色,会出现明显闪白。若状态栏背景变深但图标仍使用深色模式,图标会不可见。移除 COLOR_MODE_LIGHT 前必须把这些系统表面一起纳入验收。

从现有浅色令牌迁移到双主题的流程

十八、用可执行矩阵检查对比度

发布前不应只凭肉眼说“看起来清楚”。建议至少检查:

正文文字 / 页面背景 > 4.5:1
标题与关键图标 / 背景 > 3:1
按钮文字 / 按钮背景 > 3:1
辅助文字 / 卡片表面 > 4.5:1
禁用态仍能识别,但不与可用态混淆

矩阵需要覆盖浅色与暗色、普通与按压、选中与未选中、正确与错误、占位符、分割线和弹窗。句匠当前注释提到了 TEXT_HINT 对比度,但源码中没有完整自动化对比度测试记录,因此不能把整个应用描述为已经全部测量通过。

十九、如何减少页面割裂

从真实源码出发,最有效的整理顺序是:

  1. 保留 ColorsSizes 已形成的语义命名;
  2. TopBarGreenButtonBankCardSectionHeader 等共享组件成为页面默认入口;
  3. 统计页面中的硬编码颜色与尺寸,按出现频率迁移;
  4. 合并 ArkTS 常量与资源颜色双重真源;
  5. 再设计 dark 限定资源和系统栏策略;
  6. 最后移除强制浅色并做真机/模拟器回归。

这样可以先获得一致性收益,再承担主题切换风险。直接删除 setColorMode(),只会把未完成的 dark 资源和固定十六进制颜色同时暴露出来。

二十、结语:先把现状说清楚,再谈暗色适配

句匠真实源码已经完成了一项有价值的基础工作:墨绿、米白、暗金、文本层级、状态色、答题选项状态、字号、间距、圆角和安全区尺寸,大量集中在 ThemeConstants.ets,共享组件也在持续消费这些令牌。这能显著降低页面各写一套样式的风险。

但“存在 dark 目录”不等于支持暗色,“定义 Colors 类”也不等于具备动态主题。当前应用锁定浅色,dark 与 base 资源相同,主要 ArkTS 颜色是固定字符串,资源文件与主题常量还存在不同配色。这些都是必须如实记录的工程边界。

一个可靠的 HarmonyOS 5.0 及以上主题体系,应以语义令牌为中心,以同名亮暗资源为唯一颜色真源,让共享组件统一消费,并对系统栏、启动页、图片和完整交互状态做对比度回归。做到这一步,亮暗色适配才不是“换一张色表”,而是整个应用视觉语义的一致切换。

本文部分内容由 AI 辅助整理,所有能力判断均基于句匠真实源码;未把浅色锁定状态描述成已完成的系统暗色适配,也未虚构审核或发布结果。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐