【鸿蒙心迹】深色模式适配实战:资源限定词、动态取色与一张图两个主题

前言

并发篇把重活搬下了主线程,功能线基本收束,这一篇补体验细节:深色模式。起因是晚上十点多收到一条用户反馈——"晚上打开你这套白花花的界面像开闪光灯"。检查代码发现颜色全是硬编码的十六进制,系统切深色模式自然一切照旧。

补课做完,回头看最深的感受是:深色模式不是"加个开关",而是把颜色从代码里搬进资源。搬完之后系统替你切,代码里一行判断都不用写;搬不干净的地方,才会漏出需要手动响应的口子。这篇记录补课的全过程,包括那个让我脸上发烫的反色事故。

环境照旧:DevEco Studio 5.x、HarmonyOS NEXT、API 14+,资源与 mediaquery 细节以官方文档为准。

清点家底:43 处硬编码

动手第一步不是改,是数。全局搜十六进制色值和 Color. 前缀,把结果导出来一看:43 处,分布在页面、组件、画笔、描边各处。这个数字比想象中少,改造一晚上就完了;但比想象中分散——有两处藏得很深,一处是自定义组件的默认参数里,一处是第三方图表组件的初始化配置里。全局搜索不带"默认参数"这个心智,漏掉它们的结果是深色下那一个组件永远是白底,像一块补丁。

清点完就该理解正确姿势的全貌了:

resources 下放一份 base 的浅色定义,再放一份 dark 目录的同名覆盖,界面一律 $r('app.color.xxx') 引用。用户在系统里切深色的那一瞬间,所有资源引用自动换血——没有 if,没有监听,没有重启。之前硬编码的 43 处,就是这次改造的全部工作量:逐个清理,归拢成一批语义资源名。

命名按"用途",不按"色值"

归拢的时候差一点犯一个事后看来很傻的错误:给资源按色值起名。被自己拦下来之后,专门画了张图立此存照:

按色值命名(night_text_black、grey_bg)的问题是名字从出生那天起就开始说谎:深色模式下 text_black 必须是白的,grey_bg 在三处的灰深浅其实不一样。按用途命名(text_primary、page_background、card_bg、divider)则永远诚实:名字说"它是干嘛的",深浅两份色值各归各位,读代码的人不会再被名字骗。

这套语义清单后来变成了团队的公共词汇:谁要加新界面,先看看清单里有没有语义匹配的资源,有就引用,没有再提议新增——资源清单是契约,不是仓库。深色模式改造最大的隐性收益就在这里:43 处散装颜色收敛成了 12 个语义资源,颜色的一致性从此有了制度保障。

两类进不了资源的颜色

资源化覆盖九成场景,剩下两类颜色天生进不了资源文件,要手动响应:

画布与图表。Canvas 上画的线、填的色是运行时算出来的,$r 够不着。用 mediaquery 监听深色模式,回调里更新画板配色再重绘:

const listener = mediaquery.matchMediaSync('(dark-mode: true)');
listener.on('change', (res) => {
  this.dark = res.matches;
  this.repaint();   // 换一套画板配色重画
});

这里藏着一个时机的坑。切换深色的瞬间,mediaquery 回调和界面刷新的时序并不保证画布想要的先后:我第一次实现在回调里直接重绘,偶发出现图表用新色画了一半、背景还是旧的——像穿了新上衣旧裤子。解法是重绘前留一帧缓冲(用 requestAnimation 帧回调排队),让背景先落定,画布再动笔。一行的改动,玄学消失。这类"回调时序"问题没有报错、没有日志,只能靠肉眼盯切换瞬间的十几次反复——记录于此,为后来者省下这半天。

服务端下发的配色。接口里别传死色值,传"语义槽位"(theme: 'warning'),客户端按槽位映射到 $r 资源,深浅色各自有定义。协议传色值是把客户端的深色能力交给后端保管,谁用谁后悔。

改造顺序的教训:先立契约,再搬家

43 处颜色的搬家顺序,我一开始走反了。原计划是"搬一处、起一个名",搬到第十处发现新的语义资源和前面的重复了——text_primary 和第八处起的 text_dark 说的是同一个用途,两套名字两份定义,一致性当场破功。

返工之后固定成正确顺序:先立契约,再搬家。第一步把 12 个语义资源名和两份色值一次定稿,放进代码评审让同事挑刺;第二步 43 处硬编码逐个对号入座——对不上的先记下来别硬塞,全部搬完再统一评审那几个"对不上"的(最后发现有两个确实缺语义,补进了契约;有一个是设计冗余,删了)。

顺序对了,整个过程没有任何返工;顺序错了,返工比原活还重。这个教训不限于深色模式:任何"归拢"性质的改造,先定收敛后的全集,再动存量。

图片二分:一张图的两个真相

颜色清完,轮到图片。第一版我偷懒给图片列表套了个 invert 滤镜"一键深色",第二天就收到用户投诉:发来的二维码截图变成了负片,扫不出来。这不是审美事故,是功能事故。修完之后把判断标准固化成一张图:

标准就一条:这张图是"我画的"还是"用户给的"。我画的——功能图标、插画——跟随主题:图标走 currentColor 或语义资源自动变色;插画在 dark 目录放同名深色版,注意深色版不是反色,是重新上色:底色融入卡片层、主体色提亮一档、对比度守恒。用户给的——照片、二维码、截图——保持原样,最多加一层略暗的遮罩让它融入背景,绝不反色。

插画深色版和设计师约定了一个极简的交付规则:同名文件放 dark 子目录,其余什么都不用改,资源系统自动接管。遮罩那层也留了实测参数:略暗遮罩的不透明度从 0.3 试到 0.1,最后定在 0.14——再高就开始像"变相反色",用户能感觉到图片被"动了手脚";再低又融不进深色背景,在列表里像一块亮斑。这种数值没有道理可讲,但定下来就写进设计标注,别让每个人重新试一遍。

阴影失效了,层次靠什么

深色模式真正的视觉难点在层次。浅色世界里"白底 + 白卡 + 阴影"三板斧,到了深色下集体失灵——黑影投在黑底上等于隐形:

解法是把"高度"翻译成亮度:越高的表面越亮。底层页面 #121212,卡片 #1E1E1E,浮层弹窗 #262626,再补一条半透明白的细描边,层次感立刻回来。两个细节:深色底别用纯黑 #000——和纯白文字的对比过冲,看久了晃眼;近黑配近白(#E6E6E6)才是深色的礼貌。

踩坑三条

  1. 系统控件自动跟随,别画蛇添足:开关、对话框、菜单这些系统控件自带深色适配,自己又叠一层手动判断反而错乱。适配的颗粒度是"自己的自绘内容",不是整个世界;
  2. WebView 内容不跟主题:网页有自己的 prefers-color-scheme,页面自身适配了才生效。内嵌页要深色得 Web 侧配合,客户端单方面变黑只会黑一半——这一条要提前和 Web 同学对齐,别到联调才发现;
  3. 深色要两套截图验收:功能验收跑浅色、截图评审用浅色,深色成了"听说支持"。后来固定成规矩:每个页面的验收清单里深浅两套截图各一张,很多"深色下看不清"的问题在评审阶段就被拦住了。

收个尾

深色模式补完,"晚上不刺眼"成了默认能力。回头看这次改造,成本几乎全花在把 43 处硬编码归拢成 12 个语义资源上——一开始就用 $r 引用的项目,深色模式几乎是免费的。照例自查清单:

  • 全局搜十六进制色值和 Color.,还有漏网的吗?自定义组件的默认参数查了吗?
  • 资源名按用途起的吗?有没有"色值名字"混进来?
  • 画布和服务端配色有手动响应吗?协议里传的是语义槽位还是死色值?
  • 图片二分了吗?有没有 invert 滤镜这种"一键深色"的定时炸弹?
  • 深浅两套截图都进验收清单了吗?

下一篇写冷启动优化:从点击图标到首屏可交互,这段时间去哪了、怎么用启动框架把它压下来。性能这条线,这篇是最后一个大件。

*本文环境:DevEco Studio 5.x / HarmonyOS NEXT(API 14+)。资源限定词、mediaquery 与图片资源用法以官方《资源分类与访问》《ArkTS 声明式开发》文档为准。系列上一篇:《【鸿蒙心迹】TaskPool 与 Worker 多线程实战》,下一篇:冷启动优化实战。*

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐