HarmonyOS应用实战-启示散页-11-深色模式一开就发灰:把答案卡片的颜色语义和资源覆盖管住

这一组文章继续围绕 The_Book_of_Answers 展开。它不是一次性页面 Demo,而是一个小型 HarmonyOS 离线应用:默认题库从 rawfile 播种,题库、收藏、历史写进 Preferences,抽取流程经过 DrawingPage,发布前还要说明隐私、备份、包体和日志口径。

轻量应用最容易被误判为“页面写完就结束”。实际交付时,真正拖慢排障的通常不是某个 ArkUI 组件,而是状态从哪里来、数据由谁写、页面靠什么刷新、异常能不能恢复。下面每篇只拆一个问题,并尽量把它落到答案之书已有的页面、Service、Repository、AppStorage、资源或发布配置边界上。

在这里插入图片描述

这篇文章解决四件事:

  1. 复盘 深色模式一开就发灰 在答案之书项目里怎么出现。
  2. 把答案卡片的颜色语义和资源覆盖管住 落到页面、Service、Repository 和 AppStorage 的具体边界。
  3. 给出能迁移到 HarmonyOS/ArkTS 项目的代码形态,并指出反例。
  4. 用验证清单和排障表收尾,避免只看源码不看实际入口。

在这里插入图片描述
在这里插入图片描述

1. 从故障链路看:深色模式一开就发灰

答案之书的首页卡片、抽取结果和收藏列表都在展示短文本。浅色模式下卡片有层次,切到深色后如果还沿用同一套背景和阴影,最明显的问题不是崩溃,而是答案文本发灰、收藏按钮不明显、默认题库和自建题库颜色区分不出来。 这个问题如果只在当前页面补一个判断,短期可能能跑,但下一次从历史、收藏、分享或恢复入口进入时,同样的问题还会出现。先把故障链路写出来,才能知道该修页面、服务还是持久层。

触发点:深色模式一开就发灰
错误写法:页面直接判断或直接读写持久化数据
放大后果:返回重进、前后台切换、删除/恢复、发布排查都会看到旧状态
收口位置:DeckThemeService 处理业务规则,HomePage 只消费结果态
刷新方式:业务动作成功后写 AppStorageKey.ThemeChangedAt

2. 先把边界表写清楚

主题能力属于 UI 语义层,不属于 DeckRepository。Repository 只保存 colorKey,页面不能直接把 colorKey 当颜色值用;真正的颜色映射应该在 ThemeToken 或页面样式服务中完成。 这张表的作用是防止后面写代码时顺手越界。尤其是答案之书这种本地应用,很多问题看起来都能在页面里临时解决,但页面一旦知道太多存储细节,发布后的排障成本会明显升高。

层级 在这篇里的职责 不应该做的事
HomePage 展示、点击、跳转、订阅刷新信号 直接拼 Preferences key 或修复脏数据
DeckThemeService 校验输入、组装 DeckCardTheme、决定空态和恢复路径 持有 ArkUI 组件状态
DeckRepository 稳定读写本地实体和索引 判断页面怎么展示
AppStorage 传递 AppStorageKey.ThemeChangedAt 这类轻量刷新信号 保存完整业务对象

3. DeckCardTheme 只表达页面结果,不照搬存储实体

DeckCardTheme 是给 HomePage 消费的结果模型,不是 Preferences 里的原始结构。这样做的好处是:Repository 可以继续按本地存储优化字段,页面仍然拿到稳定、可渲染、可判断动作的结果。

interface DeckCardTheme {
  deckId: string;
  title: string;
  bgColor: ResourceColor;
  textColor: ResourceColor;
  accentColor: ResourceColor;
}

4. DeckThemeService 才是规则 owner

DeckThemeService 负责把 DeckRepository 读出的数据转成页面能用的结果。空值、损坏、回退和默认值都应该在这一层处理,页面不需要知道底层为什么缺字段。

class DeckThemeService {
  async load(deckId: string): Promise<DeckCardTheme> {
    const deck: Deck | null = await DeckRepository.loadDeck(deckId);
    if (deck === null) {
      throw new Error('题库不存在');
    }
    const key: string = deck.colorKey || 'mint';
    return ThemeToken.resolveDeckCard(key, colorMode);
  }
}

5. HomePage 不直接碰持久化

HomePage 的职责应该保持轻:进入时加载,收到信号时重载,用户点击时发起明确动作。这样页面不会同时背上 Preferences、业务规则、错误恢复和发布排查四种职责。

@Component
struct HomePage {
  @State private deckCardTheme: DeckCardTheme | null = null;
  @StorageLink('themeChangedAt') @Watch('reload')
  private changedAt: number = 0;
  private currentDeckId: string = '';

  async aboutToAppear(): Promise<void> {
    await this.reload();
  }

  private async reload(): Promise<void> {
    const deckId: string = BookRouteGuard.requireDeckParam({ deckId: this.currentDeckId });
    this.deckCardTheme = await new DeckThemeService().load(deckId);
  }
}

6. 反例:把所有逻辑塞回页面会怎样

这个反例在第一版开发时很常见,因为写起来快;但它把题库读取、空态判断、展示模型转换和刷新信号都揉在组件里。后续一旦增加 Sheet、深链、恢复页或平板布局,就会出现多个入口行为不一致。

// 反例:页面同时知道数据结构、业务规则和刷新方式
const deck = await DeckRepository.loadDeck(this.currentDeckId);
if (deck === null) {
  promptAction.showToast({ message: '暂无数据' });
  return;
}
this.deckCardTheme = deck as unknown as DeckCardTheme;
AppStorage.setOrCreate(AppStorageKey.ThemeChangedAt, Date.now());

7. 刷新信号只写 AppStorageKey.ThemeChangedAt,不要广播完整对象

AppStorageKey.ThemeChangedAt 是运行期联动,不是数据仓库。业务动作成功后只写一个时间戳,页面收到后再按自己的 owner 重拉数据,可以避免跨页面共享可变对象。

class BookRefreshCenter {
  static notifyDeckCardThemeChanged(): void {
    AppStorage.setOrCreate(AppStorageKey.ThemeChangedAt, Date.now());
  }

  static async afterBusinessAction(action: () => Promise<void>): Promise<void> {
    await action();
    this.notifyDeckCardThemeChanged();
  }
}

8. 入口参数要先校验,再进入业务

答案之书的入口不只首页一个:历史再次提问、收藏再次提问、分享导入、恢复页都可能进入 RouteName.Drawing。目标页先校验参数,错误进入可恢复路径,不要让空 deckId 流到 Service 深处才爆出难懂异常。

class BookRouteGuard {
  static requireDeckParam(param: object | undefined): string {
    const deckId = (param as Record<string, string> | undefined)?.deckId ?? '';
    if (!deckId) {
      throw new Error('缺少题库 id,不能进入 RouteName.Drawing');
    }
    return deckId;
  }
}

9. 排查命令围绕 owner 搜,不围绕页面猜

排查时不要只盯着出问题的 UI。先搜 Service、模型、刷新信号,再看页面是否绕过了 owner。这样可以分清是数据没写、结果没组装,还是页面没订阅刷新。

rg -n "DeckThemeService|DeckCardTheme|AppStorageKey.ThemeChangedAt" D:\ProgramData\huawei\lesson\The_Book_of_Answers
rg -n "RouteName.Drawing|HomePage" D:\ProgramData\huawei\lesson\The_Book_of_Answers
rg -n "DeckRepository|AppStorage.setOrCreate" D:\ProgramData\huawei\lesson\The_Book_of_Answers

10. 验证要覆盖正常路径和损坏路径

分别在浅色、深色和系统跟随三种模式下打开首页、抽取页、收藏页,确认卡片背景、答案文本、按钮和空态文案都有足够对比度。

建议至少按这四组走:

  1. 清应用数据后的首次进入。
  2. 有自建题库、收藏和历史后的返回重进。
  3. 手工制造空值、重复值或损坏数据后的恢复路径。
  4. 发布态检查日志和截图,确认没有把用户问题、答案全文或题库全文暴露出去。
验收口径:
1. 正常入口可用。
2. 异常入口有提示或恢复页。
3. 返回重进不显示旧数据。
4. AppStorageKey.ThemeChangedAt 变化后只刷新对应 owner。
5. 发布态不输出敏感明文。

11. 落地时的取舍

这里没有把 把答案卡片的颜色语义和资源覆盖管住 做成很重的框架能力,是因为答案之书的核心仍然是离线、轻量、可维护。过度抽象会让一个小功能穿过太多层;完全写在页面里,又会让数据修复、备份恢复、发布排障没有稳定入口。比较合适的取舍是:用户内容、持久化结构、跨页面刷新和发布自查进入 Service 或 Repository;只影响当前视觉节奏的内容留在页面。

适合抽出去:
- DeckCardTheme
- DeckThemeService
- AppStorageKey.ThemeChangedAt

不急着抽出去:
- 当前页面的一次性动画状态
- 只影响局部样式的临时 UI 变量
- 不跨页面复用的按钮交互

12. 常见问题与处理

现象 先看哪里 处理
深色模式文字发灰 是否直接写死浅色文本 改用语义 token 输出 textColor
自建题库颜色失效 colorKey 是否绕过映射 页面只拿 DeckCardTheme,不直接拼颜色
切换主题页面不变 ThemeChangedAt 是否更新 设置页切换后写 AppStorage 信号
复查顺序:
1. DeckRepository 是否返回可信数据。
2. DeckThemeService 是否统一处理空值和异常。
3. HomePage 是否绕过 Service。
4. AppStorageKey.ThemeChangedAt 是否在业务动作成功后写入。
5. 发布态日志是否隐藏用户输入和答案全文。

验证清单

  • 清应用数据后进入 HomePage,确认默认题库、页面状态和刷新信号都能闭环。
  • 从首页、Sheet、历史、收藏、分享或恢复入口触发一次,确认 RouteName.Drawing 的参数校验稳定。
  • 手工制造空值、重复数据或损坏数据,确认错误停在 DeckThemeService 或恢复页,而不是让页面崩掉。
  • 触发业务动作后观察 AppStorageKey.ThemeChangedAt,确认只有相关页面重拉数据,没有全局乱刷新。
  • 如果涉及主题、资源、布局、隐私或发布态,必须用真机截图、构建产物或发布清单补充确认。

小结

深色模式一开就发灰:把答案卡片的颜色语义和资源覆盖管住 不是一个孤立 API 问题,而是答案之书这种离线应用在长期维护里一定会遇到的边界问题。把 DeckCardThemeDeckThemeServiceDeckRepositoryHomePageAppStorageKey.ThemeChangedAt 分清以后,项目继续扩展题库、收藏、历史、分享、恢复和发布诊断时,才不会把每个入口都写成一次性的临时逻辑。
就发灰:把答案卡片的颜色语义和资源覆盖管住不是一个孤立 API 问题,而是答案之书这种离线应用在长期维护里一定会遇到的边界问题。把DeckCardThemeDeckThemeServiceDeckRepositoryHomePageAppStorageKey.ThemeChangedAt` 分清以后,项目继续扩展题库、收藏、历史、分享、恢复和发布诊断时,才不会把每个入口都写成一次性的临时逻辑。

Logo

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

更多推荐