HarmonyOS 深色模式适配实践:从资源、WebView 到网络图统一处理

深色模式不是把页面背景改黑这么简单。真正落地时,应用会同时遇到资源目录、状态栏、WebView、前端页面、网络图片、特殊页面覆盖层等多条链路。如果这些链路各自处理,后期很容易出现切换不一致、局部闪烁、图片过暗或业务页面遗漏的问题。

这篇文章整理一套 HarmonyOS 深色模式适配思路:先把系统/应用模式统一收口,再让资源、WebView 和图片渲染分别接入。文中的代码均为脱敏示例,类名、资源名和页面名都已替换为通用命名。

目录

  • 先明确适配边界
  • 推荐落地结构
  • dark 资源集承接本地资源
  • 应用级模式切换要集中管理
  • 状态栏要单独适配
  • WebView 优先让网页自己支持深色模式
  • Web 页面不支持时,用覆盖层做兜底
  • 网络图片用 AttributeModifier 做低侵入统一处理
  • 验收清单

先明确适配边界

一次完整的深色模式适配至少要覆盖五类对象:

  1. 本地颜色和图片资源:优先交给资源目录自动切换。
  2. 应用级模式:支持跟随系统、浅色、深色三种状态。
  3. 系统栏:状态栏文字颜色要跟随当前背景反转。
  4. WebView:如果网页本身支持深色模式,优先让 Web 按 CSS 媒体查询切换。
  5. 网络图片:不能靠资源目录,需要运行时统一调节亮度、饱和度或做定制替换。

可以把适配对象按"是否可由系统自动切换"先分层:

适配对象 推荐方案 适用场景 风险点
本地颜色、本地图片 base / dark 资源集 颜色、占位图、切片 资源名不统一会导致兜底失败
应用模式 ApplicationContext.setColorMode() 跟随系统、手动浅色、手动深色 入口分散会导致页面状态不一致
状态栏 统一窗口工具方法 顶部深浅背景切换 页面单独设置容易互相覆盖
自有 Web 页面 Web.darkMode() + CSS 媒体查询 H5 可改造 前端没写深色 CSS 时不会自动变好
不可改造 Web 页面 半透明覆盖层兜底 历史页、三方页 可能二次变暗,只适合过渡
网络图片 AttributeModifier<ImageAttribute> 通用网络图降亮度、降饱和 透明图、Logo、活动图要例外处理

推荐落地结构

最终结构建议按下面的职责拆分:

  • EntryAbility:监听系统配置变化,初始化应用模式。
  • ThemeManager:记录当前模式,统一通知各层更新。
  • 资源目录:承接本地颜色和本地图片自动切换。
  • 系统栏工具:集中设置状态栏颜色。
  • Web 容器:优先使用 darkMode(WebDarkMode.Auto),必要时提供覆盖层兜底。
  • 图片 Modifier:对网络图做亮度、饱和度调整。
  • 特殊页面:单独做图片、背景、透明度等定制处理。

这种拆法的核心是:模式判断只做一次,后续各层只关心"现在是不是深色模式"。这样后续新增页面、替换 WebView 或调整图片策略时,改动面会小很多。

1. 用 dark 资源集承接本地资源

本地颜色、切片、占位图这类资源,优先通过 resources 下的 basedark 资源集做自动适配。创建 dark 资源集时,需要选择 Color Mode = Dark,这样同名资源才能在深色模式下被系统自动命中。

推荐把语义稳定的资源名保留下来,只在不同资源集中提供不同值:

text 复制代码
src/main/resources/
  base/element/color.json
  dark/element/color.json
  base/media/placeholder.png
  dark/media/placeholder.png

例如浅色和深色都使用同一个资源名:

typescript 复制代码
Column()
  .backgroundColor($r('app.color.page_bg'))

Image($r('app.media.placeholder'))

这样业务代码不需要判断当前模式,系统会根据当前颜色模式解析到对应资源。

2. 应用级模式切换要集中管理

应用一般需要提供三种模式:

  • 跟随系统:ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET
  • 深色模式:ConfigurationConstant.ColorMode.COLOR_MODE_DARK
  • 浅色模式:ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT

注意:以当前 HarmonyOS 官方文档为准,COLOR_MODE_NOT_SET 表示不设置颜色模式,也就是跟随系统;setColorMode 从 API 11 开始可用,且官方说明仅支持主线程调用。

typescript 复制代码
import { common, ConfigurationConstant } from '@kit.AbilityKit';

export enum ThemeMode {
  System = 'system',
  Light = 'light',
  Dark = 'dark',
}

export function toColorMode(mode: ThemeMode): ConfigurationConstant.ColorMode {
  switch (mode) {
    case ThemeMode.Dark:
      return ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
    case ThemeMode.Light:
      return ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT;
    default:
      return ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET;
  }
}

export function applyAppColorMode(context: common.Context, mode: ThemeMode): void {
  const applicationContext = context.getApplicationContext();
  applicationContext.setColorMode(toColorMode(mode));
}

更推荐把模式切换收口到一个管理类里,由它负责记录当前模式、派发通知,并驱动特殊页面、Web 层和图片层更新。

typescript 复制代码
export class ThemeManager {
  private static currentMode: ThemeMode = ThemeMode.System;
  private static listeners: Array<(isDark: boolean) => void> = [];

  static update(context: common.Context, mode: ThemeMode): void {
    this.currentMode = mode;
    applyAppColorMode(context, mode);
    this.notify(this.isDarkMode());
  }

  static onChanged(listener: (isDark: boolean) => void): void {
    this.listeners.push(listener);
  }

  static isDarkMode(): boolean {
    return this.currentMode === ThemeMode.Dark;
  }

  static notifySystemChanged(isDark: boolean): void {
    if (this.currentMode === ThemeMode.System) {
      this.notify(isDark);
    }
  }

  private static notify(isDark: boolean): void {
    this.listeners.forEach((listener) => listener(isDark));
  }
}

如果应用需要响应系统深浅色变化,可以在 Ability 中监听配置更新,并把结果同步给统一的管理类:

typescript 复制代码
import { Configuration, ConfigurationConstant } from '@kit.AbilityKit';

onConfigurationUpdate(newConfig: Configuration): void {
  const isDark =
    newConfig.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;

  ThemeManager.notifySystemChanged(isDark);
  updateSystemBarStyle(isDark);
}

3. 状态栏要单独适配

很多深色模式问题不是内容区造成的,而是状态栏文字颜色没有切换。状态栏通常需要跟着页面背景反转:浅色背景用深色文字,深色背景用浅色文字。

typescript 复制代码
import { window } from '@kit.ArkUI';

export function updateSystemBarStyle(isDark: boolean, win?: window.Window): void {
  win?.setWindowSystemBarProperties({
    statusBarContentColor: isDark ? '#E5FFFFFF' : '#000000',
  });
}

这里建议只在窗口级工具方法里设置系统栏,不要散落在每个页面里,否则后续切换模式、打开弹窗、进入沉浸页时会互相覆盖。

4. WebView:优先让网页自己支持深色模式

如果 Web 页面本身支持深色模式,HarmonyOS 的 Web 组件可以通过 darkMode() 配置 Web 深色模式。官方文档说明,WebDarkMode.Auto 表示开启深色模式并跟随系统,页面需要通过 prefers-color-scheme 定义深色样式。

ArkTS 侧示例:

typescript 复制代码
import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebContainer {
  private controller: webview.WebviewController = new webview.WebviewController();
  @State private mode: WebDarkMode = WebDarkMode.Auto;

  build() {
    Column() {
      Web({
        src: $rawfile('index.html'),
        controller: this.controller,
      })
        .darkMode(this.mode)
    }
  }
}

前端页面侧示例:

html 复制代码
<style>
  @media (prefers-color-scheme: dark) {
    .page {
      background: #000000;
      color: #ffffff;
    }

    .link {
      color: #317af7;
    }
  }
</style>

这里有一个关键判断:如果前端页面已经有深色样式,应用侧只负责把模式传递给 WebView;如果前端页面没有深色样式,不建议默认强制反色,因为强制转换无法保证所有颜色都符合预期。官方也建议强制深色模式要配合 forceDarkAccess() 使用,并注意转换结果不可完全控。

5. Web 页面不支持时,用覆盖层做兜底

对于历史 Web 页面、三方页面或暂时无法改前端代码的页面,可以在 WebView 上层盖一个半透明深色层作为兜底。这种方式侵入小,但它不是完整深色适配,只适合过渡。

typescript 复制代码
Stack() {
  Web({
    src: this.webUrl,
    controller: this.controller,
  })

  Column()
    .width('100%')
    .height('100%')
    .backgroundColor(Color.Black)
    .opacity(0.3)
    .enabled(false)
    .visibility(this.showDarkOverlay ? Visibility.Visible : Visibility.Hidden)
}

覆盖层方案的优点是接入快,缺点也很明显:

  • 页面图片、文字、背景会一起变暗,不是真正的语义配色。
  • 对已经支持深色的页面可能造成二次变暗。
  • 遮罩透明度需要按页面类型调试,很难全局统一。

因此更合理的策略是:自有 Web 页面优先改 CSS,兜底层只服务无法改造的页面。

6. 网络图片用 AttributeModifier 做低侵入统一处理

本地资源可以通过 dark 资源集切换,但网络图不行。网络图的目标通常不是"变黑",而是在深色背景下降低刺眼程度,并尽量保持内容可识别。

可以从两个属性开始:

  • brightness(value):调节亮度。
  • saturate(value):调节饱和度。

官方 AttributeModifier<T> 支持动态设置组件属性,开发者可以自定义 class 实现 AttributeModifier<ImageAttribute>,再通过 attributeModifier() 绑定到组件上。需要注意,官方文档也说明它不支持自定义组件,且不要和普通链式属性重复设置同一属性,避免刷新时生效顺序不符合预期。

typescript 复制代码
export class ImageThemeModifier implements AttributeModifier<ImageAttribute> {
  private isDark: boolean = false;

  setDarkMode(isDark: boolean): void {
    this.isDark = isDark;
  }

  applyNormalAttribute(instance: ImageAttribute): void {
    if (this.isDark) {
      instance.brightness(0.82);
      instance.saturate(0.72);
    } else {
      instance.brightness(1);
      instance.saturate(1);
    }
  }
}

使用时可以把 Modifier 实例放在图片封装层或页面状态里:

typescript 复制代码
@State private imageModifier: ImageThemeModifier = new ImageThemeModifier();

aboutToAppear(): void {
  ThemeManager.onChanged((isDark) => {
    this.imageModifier.setDarkMode(isDark);
  });
}

build() {
  Image(this.imageUrl)
    .width('100%')
    .objectFit(ImageFit.Cover)
    .attributeModifier(this.imageModifier)
}

这个方案适合统一降低网络图亮度和饱和度,但并不适合所有图片。异形透明图、带阴影的 PNG、品牌 Logo、运营活动图都要谨慎处理,否则可能出现组件范围内阴影、透明区域发灰或品牌色失真。对这类图片,更稳妥的方式是加白名单、黑名单或提供深色版图片。

验收清单

深色模式的验收不能只看单个页面截图,建议至少覆盖下面这些场景:

场景 验收点
跟随系统 系统从浅色切到深色后,应用资源、状态栏、WebView、网络图都同步变化
应用内手动切换 选择浅色或深色后,不再被系统当前模式误覆盖
冷启动恢复 杀进程后重新进入,模式状态和资源解析一致
页面返回栈 从深色页返回浅色背景页,状态栏颜色恢复正常
WebView 自有 H5 走 prefers-color-scheme,不支持的页面才显示兜底层
网络图片 普通网络图不刺眼,Logo、透明图、活动图不失真
特殊页面 弹窗、协议页、沉浸页、独立安全模式页面等独立背景页面逐一检查

如果只能优先做一部分,建议先做"资源集 + 应用模式入口 + 状态栏",这三项会决定大多数页面的基础体验;WebView 和网络图片可以在统一入口稳定后逐步接入。

总结

HarmonyOS 深色模式适配可以按优先级推进:

  1. 本地资源先用 base / dark 资源集自动切换。
  2. 应用级模式用 setColorMode() 统一设置,并集中管理状态。
  3. 状态栏、WebView、网络图分别接入统一的模式通知。
  4. Web 页面优先支持 prefers-color-scheme,覆盖层只做兜底。
  5. 网络图用 AttributeModifier<ImageAttribute> 做低侵入统一处理,但要为特殊图片预留例外机制。

适配完成后,建议至少覆盖三类测试:跟随系统切换、应用内手动切换、冷启动后模式恢复。深色模式的问题往往不是单点 API,而是链路一致性;只要把模式入口收口,后续维护会轻很多。

参考资料

相关推荐
鸿蒙开发1 天前
鸿蒙(HarmonyOS NEXT)表单校验别再手撸正则了 —— 我写了个 ArkTS 版 zod
harmonyos
TrisighT1 天前
ArkTS 的 @BuilderParam 你八成只用了皮毛——那个尾随闭包写法差点被我当 bug 删了
harmonyos·arkts·arkui
ONEDAY2 天前
HarmonyOS 多 Product 构建实践:一套代码生成多个产物
harmonyos
TT_Close2 天前
别劝退了!5秒搞定 Flutter 鸿蒙 FVM 起跑线
flutter·harmonyos·visual studio code
TrisighT2 天前
ArkTS 列表滚动时为什么会闪现旧数据?我扒了 LazyForEach 的复用逻辑
harmonyos·arkts·arkui
MonkeyKing2 天前
鸿蒙ArkTS深度剖析:ArkTS与TS/JS核心差异、静态强类型实战优势
typescript·harmonyos
TrisighT2 天前
Electron鸿蒙PC上写日志文件,我被权限和路径坑了两次
electron·harmonyos
TrisighT3 天前
一个下午搞定 ArkTS 折叠面板?结果我从两点写到晚上九点
harmonyos·arkts·arkui
花椒技术6 天前
HJPusher / HJPlayer SDK 实践:我们为什么把直播推播链路拆成一套可复用能力
设计模式·harmonyos·直播