前阵子把一套 Flutter 应用的主界面迁移到 OpenHarmony 电视盒子上,遇到一个特别尴尬的画面:同一个界面模板,在 Android 模拟器上挺清楚,一上电视,浅色背景上的浅灰色说明文字几乎直接消失。家里老人想看清楚操作提示,翻遍了系统设置也没找到对比度相关的选项。也就是从那时候开始,我认真琢磨起高对比度 UI 这件事。
这篇文章就从颜色模型讲起,把 Flutter 在 OpenHarmony 上做高对比度模式的完整链路理一遍:怎么理解色彩空间、怎么计算对比度、怎么在 Flutter 侧读取系统无障碍状态、怎么做主题切换、怎么处理图片和相机预览,最后讲几个我实际踩过的坑。想给电视、平板、工控屏这类 OpenHarmony 设备做可访问性适配的同学,可以直接按这套思路去搭。
1. 高对比度 UI 不是简单的换色:先看清问题边界
1.1 我在 OpenHarmony 设备上遇到的真实对比度事故
当时的情况是这样的:应用里有一块“操作详情”区域,我用了Colors.grey.shade400作为说明文字,背景是Colors.grey.shade100。在电脑屏幕上算一下,前景和背景的对比度大约只有 2.1:1,勉强能看清。但如果放到电视端,经过 HDMI 输出、电视面板的对比度压缩和色温偏移之后,这两块灰色几乎融为一体。这还不是最糟糕的,更麻烦的是 OpenHarmony 的电视盒子上默认没有类似“高对比度文字”的开关,用户想开都没地方开。
这就引出一个很关键的判断:高对比度 UI 不是简单地在深色背景下换成白字,而是要在一个完整的颜色模型体系下,让界面上每个可读元素都达到可量化的对比度标准,同时还要适配平台本身的能力差异。OpenHarmony 生态里设备形态极多,电视、手表、平板、开发板,屏幕素质参差不齐,指望“一种颜色方案走天下”完全不现实。
1.2 颜色模型差异带来的第一道坎:sRGB、P3 与设备校正
颜色模型这个词听起来抽象,但它在高对比度 UI 里是第一道实打实的坎。绝大多数 Flutter 开发者在写颜色时,脑子里默认用的是 sRGB 色彩空间:Color(0xFF6B7280)这种写法后面隐含着一个假设,就是设备会按 sRGB 的伽马曲线来显示这个颜色。但 OpenHarmony 设备上,很多中高端电视面板默认走的是 DCI-P3 或者广色域,系统又没有做严格的色彩管理,同一个十六进制色值在不同面板上实际呈现的亮度差异非常大。
我测过两台 OpenHarmony 设备,一台是 RK3588 开发板接普通显示器,另一台是电视盒子接客厅电视。同样的Color(0xFF9CA3AF),在开发板上显示偏亮,在电视上显示偏暗,肉眼可见的差别。这意味着如果你在高对比度模式下仍然只依赖某个固定色值,而不去考虑设备色域和亮度曲线,最终效果就是你在设计稿里算好的对比度,到了用户设备上完全不是那么回事。
更实际的做法是:核心界面元素不依赖色值本身,而是依赖“对比度目标值”。你只需要在代码里保证文本和背景的对比度比值达到 7:1 甚至 14:1,再配合一些亮度计算和校验函数,即使设备色域有偏差,线性变化的范围里也不会掉到不可读的程度。
1.3 双框架并存:ArkTS 原生页面与 Flutter 页面需要同步主题
还有另一个很容易忽视的问题:OpenHarmony 应用往往不是纯 Flutter,系统设置页、权限弹窗、相机授权这类系统组件通常是 ArkTS 或者原生组件实现,应用自己的业务界面则是 Flutter 页面。两边高对比度状态如果不打通,就会出现“Flutter 页面切到了高对比度模式,而系统弹出的对话框还是原来的样子”,用户会觉得整个系统精神分裂。
实测下来,最稳妥的做法是在 Flutter 侧维护一份“高对比度状态快照”,不仅用于自己页面渲染,还通过通道反馈给原生侧,让原生组件也切换对应主题;同时原生侧系统无障碍状态变化时,也要主动推给 Flutter。两个方向都要有线,不能只做单向同步。
2. 颜色模型与对比度计算:把“看起来清楚”变成可度量的指标
2.1 WCAG 2.1 相对亮度公式的 Dart 实现
高对比度 UI 的度量标准,业内最常用的是 WCAG 2.1 里的对比度公式。它不是简单地比较两个颜色的亮度值,而是要把 sRGB 颜色先线性化,再乘亮度系数,最后算比值。这个细节非常关键,很多团队自己拍脑袋写了个(r+g+b)/3的近似公式,结果写出来的对比度结果和真实视觉感受差距很大。
标准的计算方式是先把每个通道从 0-255 的整数转换成 0-1 的浮点值,然后做伽马线性化。sRGB 的线性化公式是:当通道值小于等于 0.04045 时,直接除以 12.92;否则先加 0.055 再除以 1.055,然后取 2.4 次幂。线性化后再分别乘 0.2126、0.7152、0.0722 这三个亮度系数,加起来就是相对亮度。对比度比值就是较亮的颜色相对亮度加 0.05,除以较暗的颜色相对亮度加 0.05。
在 Dart 里可以直接写成这样:
import 'dart:math'; import 'dart:ui'; double _linearizeChannel(double channel) { if (channel <= 0.04045) { return channel / 12.92; } return pow((channel + 0.055) / 1.055, 2.4).toDouble(); } double relativeLuminance(Color color) { final r = _linearizeChannel(color.r / 255.0); final g = _linearizeChannel(color.g / 255.0); final b = _linearizeChannel(color.b / 255.0); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } double contrastRatio(Color foreground, Color background) { final l1 = relativeLuminance(foreground); final l2 = relativeLuminance(background); final lighter = max(l1, l2); final darker = min(l1, l2); return (lighter + 0.05) / (darker + 0.05); }这个算法看着简单,但工程上很有用。我在高对比度模式里加了一个“自检函数”,每次主题切换后遍历所有语义色组合,凡是对比度低于 4.5:1 的 Text 组合一律打日志报警。跑一轮下来,哪些颜色不合格一目了然,不用靠人去一处处盯。
2.2 HSL 比 HSV 更适合做高对比度调色板
做高对比度模式,离不开颜色模型之间的转换。Flutter 的Color类默认是 RGBA 存储,但在做主题切换时,我更愿意在 HSL 空间里描述变换规则:比如“高对比度模式下,把所有背景色的 Lightness 压到 10% 以下,把所有文本色的 Lightness 抬到 85% 以上”。为什么选 HSL 而不是 HSV?
因为 HSL 里的 Lightness 和人的亮度感知更接近,50% 的 Lightness 差不多就是中间亮度;而 HSV 里的 Value 代表的是色彩明暗的最亮通道,调整它容易产生“颜色饱和度过高”或“发灰”的问题。简单说,HSV 更适合做颜色选取器,HSL 更适合描述“某个颜色在明度轴上的位置”。
举一个实际例子。普通模式下主文本色是#1F2937,转成 HSL 大约是 hue 220、saturation 28%、lightness 17%。高对比度模式下我把 lightness 抬到 90%,同时把饱和度降到 0%,得到的就是接近纯白的颜色。这个过程如果用 RGB 通道直接改,很难控制住色相偏移;但在 HSL 空间里只需要改一个分量。
Color highContrastTransform(Color color) { final hsl = HSLColor.fromColor(color); final targetLightness = hsl.lightness > 0.5 ? 0.92 : 0.08; final targetSaturation = 0.0; return hsl.withSaturation(targetSaturation).withLightness(targetLightness).toColor(); }这个函数的意思很简单:亮色提亮成近白,暗色压暗成近黑,同时把饱和度清零,避免高对比度模式下出现“刺眼的彩色块”。遇到图标、强调色这类不想变成纯黑白的场景,可以保留少量色相,只把饱和度压到 15% 左右,这样既保留了品牌色识别度,又不会太花。
2.3 高对比度调色板:预计算的语义色映射表
理论上可以用转换函数在运行时动态生成高对比度模式下的所有颜色,但实测下来性能不太划算。Flutter 的HSLColor.fromColor在主题构建时会被调用非常多次,尤其是ThemeData里有大量颜色字段时,反复转换会拖慢首次构建速度。更稳妥的做法是预计算一份语义色映射表:普通模式一套色值,高对比度模式一套色值,写死成一个配置类。
我这里直接给出我在项目里用的调色板参考,注意这是语义层而不是主题层,你的代码里引用的是AppColors.textPrimary,而不要直接写Colors.white或者 Color(0xFF) 字面量:
| 语义角色 | 普通浅色模式 | 高对比度深色模式 | 设计说明 |
|---|---|---|---|
| 页面背景 | #F9FAFB | #000000 | 深色模式用纯黑底,减少眩光 |
| 主要文本 | #1F2937 | #FFFFFF | 保证至少 14:1 对比度 |
| 次要文本 | #6B7280 | #E5E7EB | 普通模式 4.6:1,高对比度模式拉到 10:1 |
| 卡片背景 | #FFFFFF | #0A0A0A | 卡片和页面底有微弱层次 |
| 边框分割线 | #E5E7EB | #FFFFFF | 高对比度模式下分割线直接变白 |
| 主按钮背景 | #2563EB | #FFFFFF | 按钮本身变成白色,文字变黑 |
| 错误提示 | #B91C1C | #FF6B6B | 保留红色色相但提亮 |
| 成功提示 | #15803D | #4ADE80 | 饱和度降低,明度提高 |
预计算映射表还有一个好处:可以直接做自动化单元测试。我在 CI 里加了一个 Dart test,遍历映射表所有前景和背景的组合,用contrastRatio函数校验,任何一个组合低于阈值就 fail。这样后续有人往调色板里加颜色,只要对比度不达标,代码根本合不进去。
3. Flutter 侧完整实现:状态检测、全局分发与组件适配
3.1 用 MethodChannel 读取 OpenHarmony 无障碍状态
高对比度模式不能靠用户手动在应用里切换,而是要响应系统设置。OpenHarmony 的无障碍能力有一套系统级开关,Flutter 侧要通过MethodChannel去读取原生状态。我在工程里注册了一个独立的通道,专门负责无障碍状态:
import 'package:flutter/services.dart'; const _accessibilityChannel = MethodChannel('com.example.app/accessibility'); class AccessibilityStatus { final bool highContrast; final bool boldText; const AccessibilityStatus({ required this.highContrast, required this.boldText, }); } Future<AccessibilityStatus> loadAccessibilityStatus() async { final result = await _accessibilityChannel .invokeMethod('loadAccessibilityStatus'); return AccessibilityStatus( highContrast: result['highContrast'] == true, boldText: result['boldText'] == true, ); }原生侧在 OpenHarmony 的 ArkTS 层里,通过系统无障碍能力接口查询高对比度开关。具体 API 名称不同版本可能不一样,常见的形式是通过accessibility模块获取状态快照,再取出类似isHighContrastEnabled的字段。这里我要特别提醒:不同 OpenHarmony API 版本字段名会有差异,如果你升级了 SDK,一定要重新查看官方文档,别照抄旧代码。
MethodChannel 设计的时候建议把“查询”和“监听”分开。查询用于应用启动时拉一次快照,监听则由原生侧在系统设置变化时主动调EventChannel推送。Flutter 侧收到推送后更新状态并触发主题重建。
3.2 用 Provider + ChangeNotifier 做高对比度状态的分发
状态拿到之后,要解决的是怎么让整个组件树响应变化。我推荐用ChangeNotifier配合Provider来做。为什么不用全局变量或者InheritedWidget手写?
全局变量的问题是组件无法感知变化,你得手动 setState,漏一处就出现“一半界面已经是高对比度模式,另一半还是老样子”的灵异现象。InheritedWidget能解决问题,但代码写起来太啰嗦,而且notifyClients的时机不好管理。Provider的好处是职责单一:我只负责“状态来了,通知依赖它的组件重建”,其余的布局和渲染逻辑保持不变。
实际代码可以这样写:
import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; class AccessibilityController extends ChangeNotifier { bool _highContrast = false; bool get highContrast => _highContrast; bool _boldText = false; bool get boldText => _boldText; Future<void> init() async { final status = await loadAccessibilityStatus(); _highContrast = status.highContrast; _boldText = status.boldText; notifyListeners(); } void updateStatus(AccessibilityStatus status) { if (status.highContrast != _highContrast || status.boldText != _boldText) { _highContrast = status.highContrast; _boldText = status.boldText; notifyListeners(); } } }在main.dart里挂到顶层:
void main() { WidgetsFlutterBinding.ensureInitialized(); final controller = AccessibilityController(); runApp( ChangeNotifierProvider.value( value: controller, child: const MyApp(), ), ); // 初始化异步读取系统状态 controller.init(); }组件侧使用时注意,不要在build里写context.watch<AccessibilityController>()和context.read混一起。watch会注册依赖,read不会。如果你只是想在一个点击事件里读当前状态,用read就好,避免无谓的重建。
组件通信这块有个细节:不是所有组件都需要响应主题变化。比如动画组件、滚动组件、静态渐变背景,它们不直接读颜色,就不需要监听。要在监听范围上做切割,最粗暴的做法是context.watch放在MaterialApp层,让它重建整个主题;页面内部的局部组件只消费Theme.of(context)里的值,这样ThemeData一换,所有使用Theme.of(context)的组件自动更新。
3.3 主题系统里的语义色 Token 改造
上一节说到了语义色映射表,那一套要真正生效,必须接入 Flutter 的ThemeData。我的习惯是定义两个构建函数,一个构建普通模式主题,一个构建高对比度模式主题,然后根据AccessibilityController的状态在MaterialApp层做切换。
ThemeData buildTheme(BuildContext context, bool highContrast) { final baseScheme = highContrast ? const ColorScheme.dark() : ColorScheme.fromSeed(seedColor: const Color(0xFF2563EB)); final baseTheme = ThemeData( colorScheme: baseScheme, useMaterial3: true, ); if (!highContrast) { return baseTheme.copyWith( textTheme: baseTheme.textTheme.apply( bodyColor: AppColors.textPrimary, displayColor: AppColors.textPrimary, ), ); } // 高对比度模式:直接覆盖关键字段 return baseTheme.copyWith( colorScheme: baseScheme.copyWith( primary: AppColors.buttonBackgroundHighContrast, onPrimary: AppColors.buttonTextHighContrast, surface: AppColors.pageBackgroundHighContrast, onSurface: AppColors.textPrimaryHighContrast, error: AppColors.errorHighContrast, ), textTheme: baseTheme.textTheme.apply( bodyColor: AppColors.textPrimaryHighContrast, displayColor: AppColors.textPrimaryHighContrast, fontSizeFactor: 1.1, // 高对比度模式顺带放大字体 ), dividerColor: AppColors.dividerHighContrast, ); }这里有个重要的工程习惯:任何时候都不要在组件里直接写Color(0xFF...)。把颜色集中到AppColors这个静态类或者ThemeExtension里,后续做对比度校验的时候,只需改一张表;如果颜色散落在几十个文件里,改完你都不知道哪里漏了。
我踩过一个很具体的坑:TextField的光标颜色是独立的cursorColor属性,不随textTheme变化。高对比度模式下,我用深色底、白字,输入框的光标还是原来的蓝色,在纯黑背景上晃眼到不行。排查了半天才发现要单独设置cursorColor。类似的容易被遗漏的还有:
AppBar的foregroundColorSwitch的activeTrackColorSlider的activeColorProgressIndicator的colorTooltip的背景色Dialog的backgroundColor
建议在高对比度模式下,给copyWith补上所有组件的细节颜色字段,不要只改colorScheme就收工。
3.4 图片、图标与相机预览在对比模式下的处理
文字和背景搞定之后,图片和图标是另一个重灾区。图标相对好办,把图标的前景颜色强制映射成语义色即可,但图片不行。一张深色的风景照、一张医疗产品图,在高对比度模式下如果原样展示,视力障碍用户可能根本看不清图中主体。
我的处理策略分三级:
第一级,对装饰性背景图片,高对比度模式下直接用ColorFiltered降低饱和度并提高亮度。Flutter 里可以给图片包一层ColorFiltered,配合颜色矩阵,把图片调成偏灰白的高反差版本。
ColorFiltered( colorFilter: const ColorFilter.matrix([ 0.3, 0.3, 0.3, 0, 0, 0.3, 0.3, 0.3, 0, 0, 0.3, 0.3, 0.3, 0, 0, 0, 0, 0, 1, 0, ]), child: Image.asset('assets/bg.png'), )第二级,对内容性图片(比如产品图、操作指引图),只在图片周围加上明显的白色描边或者边框,增加图片和背景之间的边界感。不要在图片本身上做过激的变换,否则会影响用户理解图片内容。
第三级,相机预览场景。这个是我在 OpenHarmony 平板上做扫码功能时发现的:相机预览默认是 YUV 转 RGB 的裸画面,在普通模式下色彩正常,但高对比度模式下画面中央的暗色商品和纯黑背景混在一起。处理方式是在CameraController的渲染链路上加一个预览后处理,比如通过Texture组件的滤镜或者在获取帧数据后做灰度增强。
OpenHarmony 的相机 API 在部分设备上支持SceneMode和Filter参数,能直接设置“高对比度”或者“增强可见度”预览模式。如果你查文档时发现自己设备的相机接口不支持这类参数,就用ColorFiltered包一层Texture,把预览画面的对比度拉高,这个方案在任何设备上都有效。
4. OpenHarmony 工程侧的集成细节与 XTS 合规
4.1 Flutter 工程在 OpenHarmony 上的打包与依赖方式
这部分可能一般做 Flutter 开发的同学接触得少,但实际集成时坑很多。OpenHarmony 的 Flutter 工程不是简单地把android目录换成harmony就能跑,它有自己的工程结构:entry/src/main/ets是 ArkTS 入口,Flutter 引擎通常被打包成 AAR 或者独立 HAP 引入。
如果你是从零开始接,建议直接使用 flutter_flutter 官方的 OpenHarmony 分支,生成工程后再叠加自己的业务代码。官方模板已经把 Flutter 引擎和 ArkTS 壳工程打通了,你只需要关注两件事:一是插件通道要自己注册,二是工程构建 Gradle 版本要匹配。
这里顺手说一下我在构建时遇到的一个经典报错,报错信息大致是you are applying flutter's main gradle plugin imperatively using the apply script。这个报错的原因是你在 OpenHarmony 工程的build.gradle里用了命令式的apply,而 Flutter Gradle 插件要求使用声明式插件配置。解决方法是把apply plugin: 'com.flutter.gradle'这种写法改成 plugins block 里声明,或者升级到对应版本的 Flutter Gradle 插件。
4.2 ArkTS 侧读取高对比度能力
ArkTS 侧实现 MethodChannel 的 handler,核心是拿到系统无障碍状态。比较常见的读取方式是通过@ohos.accessibility或者系统能力接口。一个典型的实现结构是:
import accessibility from '@ohos.accessibility'; import { MethodCall, MethodResult } from '@ohos.abilityAccessCtrl'; const ACCESSIBILITY_METHOD = 'loadAccessibilityStatus'; function handleAccessibilityCall(method: string, call: MethodCall, result: MethodResult) { if (method === ACCESSIBILITY_METHOD) { // 注意:API 版本不同,字段名可能不同,需要以官方文档为准 const state = accessibility.getAccessibilityState(); const highContrast = state.isHighContrastEnabled ?? false; const boldText = state.isBoldTextEnabled ?? false; result.success({ highContrast: highContrast, boldText: boldText, }); } else { result.notSupported(); } }还要注册事件监听,在系统无障碍状态变化时主动推给 Flutter。这里我建议用EventChannel而不是MethodChannel:系统设置变化是持续性的实时事件,EventChannel的流式模型更合适。
### 4.3 XTS 认证对可访问性的要求 这里的 XTS 是 OpenHarmony 的兼容性测试套件。很多团队以为 XTS 只测硬件抽象层和系统接口,实际上它有相当一部分测试覆盖了应用的无障碍行为。尤其是如果你的应用要上架到官方应用市场或者预装到某款设备里,XTS 对无障碍能力的检查项会直接影响你的应用能否通过认证。 我经历过的检查项包括:应用是否在启动时正确读取了系统无障碍状态;在开启“高对比度文本”或者“颜色反转”后,应用界面是否及时更新;界面中是否包含可被辅助服务识别的语义标签;关键按钮是否暴露了无障碍代理节点。如果你的应用想走“无障碍安全认证”的绿色通道,这几个点全部要过。 应对方式就是在代码里把无障碍状态监听做成一个必须初始化的服务,并且要有一个日志埋点,记录系统状态变化时应用响应的时间。XTS 测试人员看到“设置变化后界面在 3 秒内完成切换”这种记录,通过率会高很多。 ### 4.4 各设备形态的差异:电视、手表、平板与开发板 OpenHarmony 设备形态跨度非常大,高对比度 UI 的适配标准也完全不同。电视端的问题是远距离观看,文本要放大、对比度要拉满,同时要避免大面积纯白,否则在暗光环境会刺眼。平板端的问题是触控精度和要求操作反馈明显,高对比度模式不能牺牲按钮的边界可见度。手表端的问题是屏幕尺寸小,文字放大是第一位,但颜色对比度的计算要特别小心,因为 OLED 面板的纯黑就是完全熄灯,和纯白对比度极高,但长时间显示大面积纯白会加速烧屏。 开发板(比如 RK3588 开发板)又是另一种情况:外接显示器的色域和亮度都不受控制,有些廉价显示器在纯白背景下会有明显的闪烁和波纹。我一般会在开发板环境里检测到外接屏幕后,把高对比度模式的背景从 `#FFFFFF` 调成 `#E5E7EB`,避免纯白带来的硬件问题。 ## 5. 现场排查实录:我踩过的四个典型坑 ### 5.1 对比度计算正确,但屏幕显示仍然“看不出”变化 这个问题我在电视端遇到一次。算法算出来前景 `#FFFFFF` 和背景 `#000000` 的对比度是 21:1,理论上绝对清楚,但实际显示时文字还是有点糊。排查后发现问题不在颜色,而在电视面板的“边缘增强”算法和 HDMI 输出的采样格式。电视在接收低分辨率信号时会做拉伸和边缘锐化,导致白色文字的边缘出现淡淡的光晕,视觉上反而觉得不干净。 解决办法不是改颜色,而是把文字控件在高对比度模式下整体放大一档,并且给文字加一圈很薄的描边,让电视的面板增强算法不至于把笔画糊在一起。这也是一个经验:高对比度 UI 不只是对比度比值问题,还要考虑到显示链路的物理特性。 ### 5.2 启动检测高对比度模式的时序竞争 应用启动初期,Flutter engine 可能还没有完全就绪,`MethodChannel` 调用原生侧时,原生侧的 `AbilityContext` 可能还没绑定完成,这就导致启动时读到的状态永远是 `false`。等我真正进入首页、需要显示高对比度主题时,整个页面已经按普通模式渲染了一遍,用户能明显看到主题闪烁。 我的处理方法是增加一个“启动状态等待窗口”:在 `init` 方法里先等待原生侧回调一个 `EngineReady` 事件,再发查询请求。另外在收到查询结果后如果发现状态和当前主题不一致,不要直接 `notifyListeners` 完事,要先用 `WidgetsBinding.instance.addPostFrameCallback` 等这一帧结束再改主题,避免在 build 过程中修改状态导致断言崩溃。 ### 5.3 Impeller 渲染下的模糊与阴影效果差异 Flutter 3.x 开始推 Impeller 渲染引擎,OpenHarmony 的 Flutter 分支使用 Impeller 后,UI 渲染的阴影和高斯模糊效果和原先 Skia 有差异。我遇到的具体问题是:普通模式下用的 `BoxShadow` 在高对比度模式下被强制关掉后,某些卡片组件的边界变模糊,看起来像没有描边。 这是因为 Impeller 在 OpenHarmony 部分 GPU 驱动(常见于 RK3588 平台)上对半透明星形多边形做了近似降级,导致带有半透明描边的容器看起来发虚。解决方式很简单:高对比度模式下,卡片不要用 BoxShadow 来表示层次,直接换成 1 像素的纯色边框 `Border.all(color: Colors.white, width: 1)`。这样不仅视觉更清晰,也避免了 Impeller 下的渲染差异。 ### 5.4 Gradle 工程集成和开发环境问题 这部分其实值得单独写一篇。我在 Windows 上配置 OpenHarmony Flutter 环境时,先后遇到过 SDK 路径带空格导致编译失败、新建项目后 Gradle 下载超时、设备识别不到等问题。 新建项目跑不起来的常见原因之一是 OpenHarmony SDK 的 `ohpm` 依赖没有同步。你 flutter create 出来的工程里,ArkTS 侧的依赖是通过 `ohpm install` 拉的,不是 Gradle 自动拉的。如果只跑 Gradle 不跑 ohpm,就会缺一堆依赖。建议每次切工程后先执行 `ohpm install`,再执行 Gradle 构建。 ## 6. 最终代码组织与后续扩展空间 ### 6.1 一套可以复制的代码结构 经过这么多折腾,我最终把项目里的高对比度相关代码收敛成了这样一个目录结构: - `lib/accessibility/`:管理无障碍状态 - `accessibility_controller.dart`:ChangeNotifier,维护状态 - `accessibility_status.dart`:状态模型 - `accessibility_channel.dart`:MethodChannel/EventChannel 封装 - `lib/theme/`:主题与颜色 - `app_colors.dart`:语义色映射表 - `theme_builder.dart`:普通/高对比度主题构建 - `contrast_utils.dart`:对比度计算、校验函数 - `lib/widgets/`:适配组件 - `high_contrast_image.dart`:图片适配 - `high_contrast_card.dart`:卡片边框适配 核心原则是“状态层和主题层彻底分离,组件层只消费主题,不直接感知无障碍状态”。这样后来接手的人只要维护 `app_colors.dart` 和 `theme_builder.dart`,业务 Widget 基本不用动。 ### 6.2 接下来可以继续做的方向 这套方案做完之后,我规划了几个后续方向。首先是色弱模式,这和高对比度模式同理,可以通过颜色矩阵在 R/G/B 通道上做模拟,让红绿色弱用户可以识别原本靠颜色区分的状态。其次是动态字体,OpenHarmony 设置里如果开启了特大字体,`textTheme` 要相应调整,不然高对比度模式下文字放大后会溢出卡片。最后是焦点管理,电视端用遥控器操作时,高对比度模式下焦点框要格外明显,建议用 3 到 4 像素的白色焦点环替代原来的默认焦点边框。 ### 6.3 个人的一点体会 高对比度 UI 这件事,一开始我以为只是个“换皮肤”的小需求,真正做完才发现它横跨颜色科学、系统能力、渲染引擎三个层面。做这套方案给我最大的启示是:可访问性适配不管在哪个平台,本质上都是“用系统能力降低信息获取门槛”的工程,而不是视觉美化。这中间最值得投资的不是写多少 UI 代码,而是把对比度计算、状态同步、设备差异这几张底图先画清楚。 最后再分享一个小技巧:如果你和我一样经常要在多台 OpenHarmony 设备上验证高对比度效果,可以做一个隐藏的调试入口,双击 Logo 五次弹出设备状态面板,把当前计算出的所有前景/背景对比度值实时显示出来。这样你在真机上微调调色板时,就不用反复回电脑看日志了。