领域驱动视角下的情绪追踪底座:打造高内聚、零耦合的鸿蒙应用模型

在现代移动应用开发的宏大叙事中,UI 框架的演进往往最能吸引开发者的目光。HarmonyOS 的 ArkUI 以其简洁的声明式语法和数据驱动的特性,极大地降低了界面构建的门槛。然而,这种极其便利的视图层绑定机制,也悄悄埋下了一个架构陷阱:"UI 驱动开发(UI-Driven Development)"

当开发者习惯于在界面的 build() 函数旁直接发起数据库查询,在页面的生命周期中直接编写业务清洗逻辑时,应用的"核心资产"------业务规则,便会被无情地打碎,散落在数百个 .ets 文件中。随着业务的迭代,这些代码将迅速腐化,变得难以测试、无法复用,最终成为阻碍项目演进的技术债。

为了在 HarmonyOS 环境下构建一个具有长生命周期的复杂应用,我们需要将目光从绚丽的 UI 移开,重新聚焦于软件工程的核心:领域(Domain)

本文将以情绪追踪应用为切入点,深度剖析如何将领域驱动设计(Domain-Driven Design, DDD)的哲学引入 ArkTS 工程,剥离底层的持久化细节与上层的视图状态,打造一个高内聚、零耦合、纯粹基于 TypeScript/ArkTS 语言特性的核心业务底座。


一、破除"贫血模型":让实体重新掌握业务主权

在很多传统的 MVC 或 MVVM 项目中,数据模型往往被定义为只有 gettersetter(或在 ArkTS 中仅包含公共属性)的简单数据载体,这在领域驱动设计中被称为贫血模型(Anemic Domain Model)

如果情绪追踪的底座采用贫血模型,它的代码可能长这样:

typescript 复制代码
// 反面模式:贫血的接口定义
export interface MoodData {
  id: string;
  score: number;
  timestamp: number;
  note: string;
}

这种定义下,MoodData 仅仅是一个哑巴数据包。如果我们需要判断这个情绪是否属于"极端低落",或者需要计算它的标准展示日期,这些逻辑就不可避免地会泄漏到 ViewModel 或具体的 UI 组件中。

1.1 充血模型(Rich Domain Model)的构建

在 DDD 的视角下,MoodRecord 不仅仅是一条数据库记录,它是情绪追踪领域中的聚合根(Aggregate Root)。它不仅应当包含数据,更应当封装所有与该数据强相关的业务行为与不变量约束。

在 ArkTS 中,我们利用 class 来构建真正的领域实体:

typescript 复制代码
import { generateUUID } from '../utils/IdGenerator';

/**
 * 情绪记录聚合根 (Aggregate Root)
 * 封装了核心情绪数据及其领域行为,绝对不依赖任何 UI 框架或数据库 API
 */
export class MoodRecord {
  // 实体标识 (Identity)
  private readonly _id: string;
  private readonly _timestamp: number;
  
  // 核心领域状态
  private _score: number;
  private _note: string;
  private _tags: Set<string>;

  constructor(score: number, note: string = "", timestamp: number = Date.now(), id?: string) {
    this.validateScore(score); // 构造时进行防腐校验
    this._id = id || generateUUID();
    this._timestamp = timestamp;
    this._score = score;
    this._note = note;
    this._tags = new Set<string>();
  }

  // --- 属性访问器 ---
  get id(): string { return this._id; }
  get timestamp(): number { return this._timestamp; }
  get score(): number { return this._score; }
  get note(): string { return this._note; }
  get tags(): string[] { return Array.from(this._tags); }

  // --- 领域行为 (Domain Behaviors) ---

  /**
   * 业务规则:情绪净值必须在 -2 到 2 之间
   */
  private validateScore(score: number): void {
    if (!Number.isInteger(score) || score < -2 || score > 2) {
      throw new Error(`领域异常:无效的情绪分值 [${score}]`);
    }
  }

  /**
   * 业务行为:更新情绪分值
   */
  public reviseScore(newScore: number): void {
    this.validateScore(newScore);
    this._score = newScore;
  }

  /**
   * 业务行为:添加标签(内聚了去重逻辑)
   */
  public addTag(tag: string): void {
    const cleanTag = tag.trim();
    if (cleanTag.length > 0 && cleanTag.length <= 10) {
      this._tags.add(cleanTag);
    }
  }

  /**
   * 派生属性:获取标准化的情绪强度级别 (供上层调用,屏蔽内部数值逻辑)
   */
  public getIntensityLevel(): 'EXTREME_NEGATIVE' | 'NEGATIVE' | 'NEUTRAL' | 'POSITIVE' | 'EXTREME_POSITIVE' {
    switch (this._score) {
      case -2: return 'EXTREME_NEGATIVE';
      case -1: return 'NEGATIVE';
      case 0: return 'NEUTRAL';
      case 1: return 'POSITIVE';
      case 2: return 'EXTREME_POSITIVE';
      default: return 'NEUTRAL';
    }
  }
}

架构红利分析:

  1. 状态安全 :通过私有属性(private)和只读属性(readonly),彻底杜绝了外部代码对 idtimestamp 的非法篡改。
  2. 规则内聚validateScore 方法将业务规则死死地钉在了实体内部。无论是在新建记录页面,还是在后台自动同步模块,只要实例化 MoodRecord,就必然受到这套规则的约束。
  3. 语言纯粹性 :这个类完全是一段纯粹的 TypeScript 代码,没有任何 @State@ohos.data.relationalStore 的导入。这意味着它可以脱离鸿蒙设备,直接在 Node.js 环境下使用 Jest 进行毫秒级的单元测试。

二、隔离存储细节:仓储模式(Repository Pattern)的落地

在定义了纯粹的领域实体后,应用必须面对现实:数据需要被持久化到磁盘上。

在鸿蒙开发中,最常用的本地存储是关系型数据库(RDB, relationalStore)。如果我们在修改了 MoodRecord 后,直接在业务代码里编写 SQL 语句并调用 RDB 接口,那么领域层就严重依赖了基础设施层(Infrastructure Layer)。

一旦未来华为推出了更高效的端侧向量数据库,或者我们需要将数据无缝同步到云端,由于 SQL 逻辑散落各处,重构将极其艰难。

2.1 依赖倒置:定义仓储接口

为了实现零耦合,我们必须遵循依赖倒置原则(Dependency Inversion Principle)。领域层不应该知道数据是如何存储的,它只负责定义"它需要什么存储能力"。

在领域层(Domain Layer)中,我们声明一个仓储接口:

typescript 复制代码
// domain/repository/IMoodRepository.ets
import { MoodRecord } from '../model/MoodRecord';

/**
 * 情绪仓储接口
 * 定义了领域实体持久化的契约,领域层仅依赖此接口
 */
export interface IMoodRepository {
  /**
   * 保存或更新情绪记录
   */
  save(record: MoodRecord): Promise<void>;

  /**
   * 根据 ID 查找记录
   */
  findById(id: string): Promise<MoodRecord | null>;

  /**
   * 获取指定时间范围内的记录
   */
  findInRange(startTime: number, endTime: number): Promise<MoodRecord[]>;

  /**
   * 删除记录
   */
  delete(id: string): Promise<void>;
}

2.2 防腐层(ACL)的物理实现:关系型数据库适配器

接口定义完成后,我们在基础设施层(Infrastructure Layer)去具体实现这个接口。这里的实现类充当了防腐层(Anti-Corruption Layer)的角色,它负责将我们优雅的领域实体 MoodRecord,转化为底层的数据库键值对(ValuesBucket)。

typescript 复制代码
// infrastructure/database/RdbMoodRepository.ets
import relationalStore from '@ohos.data.relationalStore';
import { IMoodRepository } from '../../domain/repository/IMoodRepository';
import { MoodRecord } from '../../domain/model/MoodRecord';

export class RdbMoodRepository implements IMoodRepository {
  private rdbStore: relationalStore.RdbStore;

  constructor(store: relationalStore.RdbStore) {
    this.rdbStore = store;
  }

  async save(record: MoodRecord): Promise<void> {
    // 【防腐层转换】:将领域实体转换为底层 RDB 接受的扁平数据字典
    const valueBucket: relationalStore.ValuesBucket = {
      'id': record.id,
      'timestamp': record.timestamp,
      'score': record.score,
      'note': record.note,
      'tags': JSON.stringify(record.tags) // 将 Set 序列化为 JSON 字符串入库
    };

    // 使用 RDB 提供的 insert API(若存在则替换)
    await this.rdbStore.insert('MoodTable', valueBucket, relationalStore.ConflictResolution.ON_CONFLICT_REPLACE);
  }

  async findById(id: string): Promise<MoodRecord | null> {
    let predicates = new relationalStore.RdbPredicates('MoodTable');
    predicates.equalTo('id', id);
    
    const resultSet = await this.rdbStore.query(predicates);
    if (resultSet.rowCount > 0) {
      resultSet.goToFirstRow();
      
      // 【防腐层转换】:将数据库结果集还原为纯粹的领域实体
      const score = resultSet.getDouble(resultSet.getColumnIndex('score'));
      const note = resultSet.getString(resultSet.getColumnIndex('note'));
      const timestamp = resultSet.getLong(resultSet.getColumnIndex('timestamp'));
      const tagsJson = resultSet.getString(resultSet.getColumnIndex('tags'));
      
      const record = new MoodRecord(score, note, timestamp, id);
      
      // 恢复 tags
      if (tagsJson) {
        const tagsArray: string[] = JSON.parse(tagsJson);
        tagsArray.forEach(tag => record.addTag(tag));
      }
      
      resultSet.close();
      return record;
    }
    
    resultSet.close();
    return null;
  }

  // ... 实现其他接口方法
}

通过这套接口与实现分离的设计,在 ViewModel 或页面拦截器中,我们只需要注入 IMoodRepository 接口即可。当我们在测试环境下,完全可以轻松替换为一个 InMemoryMoodRepository,在不挂载任何真实 RDB 数据库的情况下完成整个业务流程的验证。


三、跨实体业务的收容所:领域服务(Domain Service)

除了实体自有的行为,情绪追踪应用中还存在着一些跨实体的复杂业务逻辑。例如:"计算用户当前的连续记录天数(Streak)",或者"分析近 7 天的情绪起伏趋势"。

这些逻辑不属于单一的 MoodRecord 实体,也不应该写在 UI 层。在 DDD 架构中,我们将这些无状态(Stateless)的复杂业务过程抽象为领域服务(Domain Service)

3.1 构建 EmotionAnalysisService

typescript 复制代码
// domain/service/EmotionAnalysisService.ets
import { MoodRecord } from '../model/MoodRecord';

export class EmotionAnalysisService {
  
  /**
   * 领域逻辑:计算连续记录天数
   * @param records 用户的历史记录(需按时间倒序排列)
   * @returns 连续天数
   */
  public static calculateStreak(records: MoodRecord[]): number {
    if (!records || records.length === 0) return 0;

    let streak = 0;
    const now = new Date();
    // 抹平时间精度,只取日期部分进行比对
    const todayStr = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`;
    
    let lastRecordedDateStr = "";

    for (const record of records) {
      const d = new Date(record.timestamp);
      const recordDateStr = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;

      // 第一条记录必须是今天或昨天,否则连胜已断
      if (streak === 0) {
         // ... 具体的时间差计算逻辑
         streak++;
      } else {
         // 判断是否严格连续
         // ... 
      }
    }
    return streak;
  }

  /**
   * 领域逻辑:提取核心情绪标签
   */
  public static extractDominantTags(records: MoodRecord[]): string[] {
    const tagFrequency = new Map<string, number>();
    records.forEach(r => {
      r.tags.forEach(t => {
        tagFrequency.set(t, (tagFrequency.get(t) || 0) + 1);
      });
    });
    
    // 返回出现频率最高的 Top 3 标签
    return Array.from(tagFrequency.entries())
      .sort((a, b) => b[1] - a[1])
      .slice(0, 3)
      .map(entry => entry[0]);
  }
}

领域服务的存在,填补了聚合根无法处理宏观逻辑的空白。它们像是一个纯净的计算引擎,接收领域实体的数组,输出高度凝练的业务结论。这些服务函数是纯函数,时间复杂度可控,且对 ArkUI 零感知。


四、事件驱动架构:消灭模块间的硬连接

在复杂的鸿蒙原生应用中,当发生一个核心业务动作(例如:新建了一条情绪记录)时,往往需要触发多个旁路操作:

  1. 更新本地数据库。
  2. 触发桌面服务卡片(Widget)的数据刷新。
  3. 检查是否达成了某个隐藏成就(Achievement)。
  4. 将记录推入跨设备分布式软总线进行手表端同步。

如果我们在新建记录的 UI 组件里,将这些操作依次写下:

saveDb(); updateWidget(); checkAchievement(); syncToWatch();

那么这段代码就成了一个巨大的耦合灾难,不仅难以维护,而且任何一个旁路操作的失败(比如手表断连)都可能阻塞主线程,导致 UI 卡顿。

4.1 引入领域事件(Domain Event)

为了实现系统级的零耦合,我们需要在领域底座中引入事件驱动机制。当状态改变时,领域层只负责发布事实,而不在乎谁来处理。

首先定义事件契约:

typescript 复制代码
// domain/event/MoodCreatedEvent.ets
import { MoodRecord } from '../model/MoodRecord';

/**
 * 领域事件:新的情绪记录已创建
 */
export class MoodCreatedEvent {
  public static readonly EVENT_NAME = 'DOMAIN_EVENT_MOOD_CREATED';
  
  public readonly record: MoodRecord;
  public readonly occurredOn: number;

  constructor(record: MoodRecord) {
    this.record = record;
    this.occurredOn = Date.now();
  }
}

4.2 利用鸿蒙 Emitter 实现事件总线

我们可以利用鸿蒙系统底层的 @ohos.events.emitter 模块,构建一个轻量级的跨模块事件总线(Event Bus)。在应用启动时,各个模块(成就模块、桌面卡片模块)自行订阅感兴趣的事件。

typescript 复制代码
// application/EventDispatcher.ets
import emitter from '@ohos.events.emitter';
import { MoodCreatedEvent } from '../domain/event/MoodCreatedEvent';

export class EventDispatcher {
  
  public static publishMoodCreated(event: MoodCreatedEvent): void {
    const eventData: emitter.EventData = {
      data: {
        // 由于 Emitter 只能传递简单对象,我们将领域模型序列化传递
        recordJson: JSON.stringify(event.record)
      }
    };
    
    // 定义唯一的事件 ID
    const innerEvent: emitter.InnerEvent = { eventId: 1001, priority: emitter.EventPriority.HIGH };
    
    // 广播事件
    emitter.emit(innerEvent, eventData);
  }
}

此时,在新建记录的用例(Use Case)中,代码变得极其优雅与干脆:

typescript 复制代码
// application/usecase/CreateMoodUseCase.ets
export class CreateMoodUseCase {
  private repository: IMoodRepository;

  constructor(repo: IMoodRepository) {
    this.repository = repo;
  }

  async execute(score: number, note: string): Promise<void> {
    // 1. 创建领域实体
    const newRecord = new MoodRecord(score, note);
    
    // 2. 通过仓储持久化
    await this.repository.save(newRecord);
    
    // 3. 发布领域事件,彻底解耦旁路逻辑
    const event = new MoodCreatedEvent(newRecord);
    EventDispatcher.publishMoodCreated(event);
  }
}

通过事件驱动架构,成就模块和系统卡片模块成为了被动的"订阅者"。它们可以在自己独立的 Worker 线程或后台任务中去消化这些事件,从而保障了主界面交互的绝对流畅。


结语:在规则的约束下重获自由

构建一个高内聚、零耦合的鸿蒙应用模型,本质上是一场对抗软件熵增的持久战。

从充血的聚合根 MoodRecord 开始,我们夺回了分散在 UI 中的业务校验权;通过 IMoodRepository 的抽象,我们斩断了底层数据库 API 对核心业务的物理绑架;借助纯粹的 Domain Service,我们收编了跨实体的复杂计算;最终,通过领域事件的发布,我们实现了模块间的彻底松绑。

在这套严密的领域驱动视角下,ArkUI 终于可以回归其最本真的使命:成为一组纯粹、高效的数据渲染引擎。当底盘极其稳固时,无论未来的鸿蒙生态是向折叠屏、智能手表还是跨端 AI 演进,这座建立在坚实领域模型之上的大厦,都将拥有无尽的生长空间与不可撼动的稳定性。

相关推荐
在书中成长1 小时前
HarmonyOS 小游戏《对战五子棋》开发第25篇 - 最后一手标记
harmonyos
解局易否结局2 小时前
鸿蒙PC应用开发:窗口管理 + 鼠标键盘交互 + 文件对话框
笔记·华为·计算机外设·交互·harmonyos
心中有国也有家2 小时前
AtomGit Flutter 鸿蒙客户端:呼吸练习的三种科学模式
学习·flutter·华为·harmonyos
FrameNotWork3 小时前
HarmonyOS 6.1 Tabs组件深度定制 — 底部导航栏从入门到踩坑
华为·交互·harmonyos
不才难以繁此生4 小时前
HarmonyOS ArkUI NavPathStack 实战:中式美食详情页多入口返回栈怎么设计
harmonyos·arkts·arkui·navigation·中式美食·navpathstack·路由栈
不才难以繁此生4 小时前
HarmonyOS 实战|中式美食系列目录:从页面体验到本地数据闭环
harmonyos·arkts·中式美食·arkui v2·系列目录
不才难以繁此生4 小时前
ArkTS 本地数据库实战|中式美食购物清单:按菜谱来源合并食材数量
harmonyos·arkts·repository·rdb·中式美食·购物清单·本地数据库
ZZZMMM.zip4 小时前
基于鸿蒙HarmonyOS NEXT开发AI书籍推荐应用:智能阅读新体验与鸿蒙Flutter框架跨端实践
人工智能·flutter·华为·harmonyos·鸿蒙
不才难以繁此生4 小时前
HarmonyOS ResourceStr 实战:中式美食菜谱封面怎么统一注册、兜底和缓存
harmonyos·arkts·image·arkui·中式美食·resourcestr·图片缓存