☰
【共创稿事节】主题色系统的三个坑:ARGB、对比度和 emoji 兜底
2026/10/28 1:59:08 网站建设 项目流程

![## 一、从一个注释说起

ThemeConstants.ets 的开头有一条我写给自己的注释:

// 给颜色添加透明度。ArkUI 颜色格式是 #AARRGGBB(alpha 在前),// 直接拼接 '#RRGGBB' + '15' 会得到 #RRGGBB15,被解析成 alpha=RR,结果完全错乱。// 用法:alpha('#FF9800', '15') → '#15FF9800'exportfunctionalpha(color:string,alphaHex:string):string{if(!color||color.length<7)returncolorreturn'#'+alphaHex+color.substring(1,7)}

这条注释救过我不止一次。Web 前端写惯了 #RRGGBB,到 ArkUI 里想给主题色加个 12% 透明度的背景,顺手拼接 ‘#RRGGBB’ + ‘15’,编译不报错、预览也"差不多"——直到某天界面出现诡异的洋红色块才发现:ArkUI 是 #AARRGGBB,alpha 在前,我把透明度拼到了末尾,等于把 RR 当成了 alpha。

这类"编译器不帮你查"的格式约定,值得在项目第一天就封装成函数。本文讲菜谱应用主题色系统里踩过的三个坑:透明度拼接、对比度文字变体、无图占位的 emoji 兜底。

二、坑一:透明度拼接

正确做法是 alpha(color, alphaHex) 把两位透明度插到最前面。项目里所有半透明背景都走这个函数:卡片底色 alpha(themeColor, ‘12’)、描边 alpha(themeColor, ‘30’)、遮罩 ‘#99000000’(直接写全)。

封装的另一个收益是语义:代码里看到 alpha(color, ‘33’) 就知道是"约 20% 透明度的同色系",比裸字符串可读得多。主题切换时只改 appThemeColor 一个 AppStorage 变量,所有 alpha 派生色自动跟随——这是把主题做成"单点数据源"的直接回报。

三、坑二:主题色不能直接当文字色

第二个坑是对比度。主题色是给大面积背景用的,直接拿来写文字,在白底上经常不达标。工程里的解法是 getThemeTextColor:给每个可选主题色配一个"深色文字变体",保证 WCAG AA 的 4.5:1 对比度,可以安全用在白底文字上。

// 获取主题色对应的深色文字变体(满足 WCAG AA 4.5:1 对比度,可安全用在白底文字上)exportfunctiongetThemeTextColor(themeColor:string):string

同理还有 getCuisineBg:十二个菜系各配一个深色背景(川菜深红、鲁菜深橙、粤菜深绿……),用于无图时的占位卡片,注释里逐个标注了与白字的对比度均 ≥ 4.5:1。配色不是审美题,是无障碍题——把对比度写进注释,后来者就不会随手改浅。

四、坑三:没有图的时候,用什么撑住界面

菜谱应用的图片资源永远不够:几百道菜不可能都配图。工程里的兜底是双层策略:先按菜名关键字匹配 emoji(鱼虾🐟、鸡鸭🍗、面食🍜、包子饺子🥟),匹配不上再按菜系兜底(川菜湘菜🌶️、粤菜🥡、东北菜❄️)。

exportfunctiongetRecipeEmoji(name:string,category:string):string

这个函数的本质是"降级体验设计":emoji 不是理想方案,但比灰底白框好——它给无图卡片提供了语义线索和一点趣味性。列表页、搜索结果、收藏页都复用同一个函数,无图体验全应用一致。

五、收束:主题系统是数据,不是样式

三条经验收拢成一句话:主题系统应该做成数据驱动——一个 appThemeColor 变量、一组派生函数(alpha / getThemeTextColor / getCuisineBg / getRecipeEmoji),所有组件只读不写。改主题、加深色模式、新增菜系,都只是改数据,不动组件。

下一步计划是把深色模式补上(当前 colorMode 跟随系统但色彩表只有亮色一套),到时候这些派生函数会再立一次功。

参考资料

  • ArkUI 颜色与背景文档
  • WCAG 2.1 对比度要求(AA 级 4.5:1)
  • 工程源码:ThemeConstants.ets

#鸿蒙 #HarmonyOS 7(API 26)

六、主题色的数据结构定型

绕开前面三个坑之后,主题系统该定型了。我的结构分三层:语义层定义"这个颜色是什么角色"(brand、bgPage、textPrimary、textSecondary、danger、success 共十二个角色),实例层定义"这个角色在各主题下的值"(亮色一套、暗色一套),引用层只允许出现角色名——任何组件写十六进制色值都在评审时打回。三层各是一张纯数据表,换主题等于换实例层引用,组件代码零改动。

interface ThemeRoles { brand: string; bgPage: string; textPrimary: string; textSecondary: string; danger: string; success: string } interface ThemeInstance { name: 'light' | 'dark'; colors: ThemeRoles } const light: ThemeInstance = { name: 'light', colors: { brand: '#FF6B35', bgPage: '#FFFFFF', textPrimary: '#1A1A1A', textSecondary: '#666666', danger: '#D93026', success: '#1E8E3E' } }

十二个角色是反复删减后的结果:最初定了二十六个,发现一半的角色只被一个组件用一次——那不是角色,是硬编码的换皮。判定标准很简单:一个角色至少被三类不同组件引用才有资格进语义层,否则就是过度设计。

七、对比度自动校验

坑二(主题色不能直接当文字色)的彻底解法是不靠人眼:写一个校验函数,输入前景色和背景色,按 WCAG 公式算对比度,文字类组合要求不低于 4.5:1、大字号不低于 3:1。校验挂在构建流程里,每次改主题色自动跑全部组合,不达标直接构建失败——把"看不清"从用户反馈变成提交时报错。

function luminance(hex: string): number { /* sRGB 相对亮度 */ return 0 } function contrast(fg: string, bg: string): number { const l1 = luminance(fg), l2 = luminance(bg); return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05) } const textOk = (fg: string, bg: string) => contrast(fg, bg) >= 4.5
组合对比度判定
brand橙 on 白底约3.2:1大字可用、正文不可用
textPrimary on bgPage约16:1全部可用
textSecondary on bgPage约5.7:1可用
danger红 on 白底约4.6:1刚好达标

这张表就是校验函数的输出样例。danger 红刚好踩线 4.6:1,一度想换深一点的色值,后来保留原色但约定它只用于加粗文字——同一套校验既管颜色也管用法。

八、无图兜底的完整清单

坑三(emoji 兜底)的完整版是一张降级清单:有图用图、图加载失败显示首字卡片、无图字段显示分类 emoji、极端情况显示纯色块加角色色描边。四级降级都在同一个封面组件里实现,外部只传数据不感知层级。判断顺序是数据驱动的:字段里有 image 用一级、有 name 用二级、有 category 用三级,都缺走四级。

type Cover = { kind: 'image'; url: string } | { kind: 'initial'; char: string } | { kind: 'emoji'; icon: string } | { kind: 'plain' } function coverFor(item: RecipeItem): Cover { return item.image ? { kind: 'image', url: item.image } : item.name ? { kind: 'initial', char: item.name[0] } : item.category ? { kind: 'emoji', icon: EMOJI[item.category] } : { kind: 'plain' } }

四级降级上线后再没收到过"界面太空"的反馈。意外收获是二级首字卡片在实测里点击率反而比图片高——用户说"带字的一眼知道是哪个菜"。兜底方案做扎实了,有时比主方案还讨喜。

九、暗色主题的额外两坑

补两个只在做暗色主题时才暴露的坑:一是阴影在暗色下几乎不可见,层级感要改用"亮度和描边"表达——暗色下卡片用比背景亮一档的底色加 0.5vp 描边,比阴影有效得多;二是图片在暗色下显得刺眼,给所有内容图片叠一层 4% 的黑色遮罩就能融入整体,这个数值是拿三十张菜谱图逐一对比试出来的。两个坑的共性是:暗色不是亮色的取反,是另一套视觉语言,值得单独过一遍全部界面而不是只换背景色。

至此主题系统的账算完整了:三个坑的数据结构解法、十二角色语义层、自动对比度校验、四级无图兜底、暗色专项两坑。方法论的总结只有一句——把样式当数据管:进表、进校验、进降级链,样式的问题就都能用工程手段回答。

十、主题切换的过渡动画

亮暗切换如果瞬间跳变,体验很生硬。我的做法是给主题切换加 200 毫秒的过渡:颜色值全部走动画插值,背景从白到深灰渐变、文字色同步过渡。实现上有个细节——插值要在 RGB 空间做,别在 ARGB 的原始整数上做(透明度位参与插值会闪)。切换期间新页面先渲染成新主题再进场,避免出现半新半旧的混合帧。

function lerpColor(from: number[], to: number[], t: number): number[] { return from.map((v, i) => v + (to[i] - v) * t) }

200 毫秒同样是试出来的:100 太快等于没动画,300 开始有人觉得拖沓,200 是"感觉到平滑但说不出哪里变了"的区间——过渡动画的最高境界就是让人意识不到动画存在。

十一、把主题系统讲给未来的自己

这套系统定型三个月后我重构过一次别的模块,回来改主题时发现自己已经忘了大半细节——于是补了一份主题系统的自述文档,放在主题目录里,四段话:为什么只有十二个角色(引用频次门槛)、为什么对比度校验挂在构建(人眼靠不住)、为什么兜底四级(无图是常态)、为什么暗色单独过一遍(不是取反是另一套语言)。写给未来的自己,比写给团队更诚实——三个月后的自己就是最挑剔的新人。

回头看这三个坑的共性:都是"顺手写了样式、懒得立规矩"攒出来的债。ARGB 拼接是一行代码的懒、文字色直取是一个参数的懒、无图不处理是一个分支的懒。而全部解法加起来也不过是三张表加一个校验函数——立规矩的成本从来比还债低,这是主题系统教我最重要的一课,适用于样式,也适用于一切工程约定。

十二、三点复盘

复盘这三个坑,第一点是坑的来源高度一致:都是把"能跑"的临时写法当成了正式实现。透明度拼接是调试时图快写的、主题色直当文字色是没想过对比度、emoji 兜底是懒得设计空态。临时写法和正式实现的区别不在于代码量,而在于有没有考虑输入的边界——正常输入下两者都对,边界输入下只有正式实现活着。而个人项目最难的是没有评审帮你拦,所以更要靠工具:对比度校验挂构建、ARGB 拼接直接封装掉,让边界问题在提交前暴露。

第二点是解决方案要选"数据化"的那个。三个坑的三个解法——角色表、校验函数、降级链——共同点是都把易错的知识从人的脑子里搬进了数据和代码。知识进了表,新增主题色就是加一行;进了校验,改错了会报错;进了降级链,任何数据形态都有归宿。留在脑子里的规范,换个人或者隔三个月就会失效,这条经验后来被我用在了很多别的地方。

第三点是对"小事"的重新估量。这大概是整个菜谱应用里最小的一个模块,三张表一个函数,但它服务了应用里每一个页面、每一处文字和图片。把小模块做扎实的时间,会在之后每一次界面迭代时连本带利地还回来——主题系统上线后的两个月里,我加了四个新页面,没有一页需要考虑颜色问题,这就是当时立规矩换来的复利。工程里的复利大多长这样:不起眼、确定性高、受益期长。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询