【时光清单|10】HarmonyOS ArkTS 情侣空间实战:设计双方纪念内容的数据模型

“情侣空间”看起来只是两个头像、一块留言板和几条共同任务,真正落到数据层却很容易失控:双方资料放在哪里,任务与留言是否属于同一个聚合,纪念日要不要复制一份,页面更新数组后为什么必须重新赋值,未来做备份或多设备协同时又怎样避免旧结构把新数据覆盖掉?这些问题如果没有在模型阶段回答,页面越丰富,数据边界越模糊。

时光清单 的真实源码给出了一个小而完整的起点。CoupleSpace.ets 定义双方头像、双方称呼、关系起始日期、共同任务和留言;CoupleView.etsDataStore 读取并保存该聚合,同时从 AnniversaryRepository 单独筛选恋爱与纪念日;BackupService.ets 又把 CoupleSpace | null 纳入备份结构。它没有账号体系、远端同步、双向实时通信,也没有把“本地留言”包装成在线聊天。

本文从这些可复核代码出发,不把未运行的构建、设备验证或双用户同步写成当前结果,说明怎样用 ArkTS 明确聚合边界、子项身份、时间语义、不可变更新和持久化协议,并进一步讨论版本迁移、输入校验、多设备扩展与隐私审核。文中的“当前实现”和“演进建议”会分开标注,避免把设计方案误写成已经上线的能力。

情侣空间双数据源模型主题封面

本文将解决六个具体问题:

  1. 为什么 CoupleSpace 适合作为页面聚合,而不应吞并所有纪念日。
  2. CoupleTaskCoupleMessage 为什么都需要稳定标识和创建时间。
  3. 当前 fromSelf 语义在哪些场景成立,何时必须升级为参与者标识。
  4. ArkUI 中为什么采用展开运算符生成新数组和新对象。
  5. Preferences JSON 持久化怎样补上校验、版本与迁移。
  6. 从单设备本地功能扩展到多设备协同时,模型还缺哪些协议。

本文唯一标记:CSDN-SERIES:ALL-163208083

一、先复核真实模型:一个聚合包含什么

源码中的核心接口只有三组:

export interface CoupleSpace {
  selfAvatar: string;
  loverAvatar: string;
  selfName: string;
  loverName: string;
  startDate: number;
  tasks: CoupleTask[];
  messages: CoupleMessage[];
}

export interface CoupleTask {
  id: string;
  title: string;
  completed: boolean;
  createdAt: number;
}

export interface CoupleMessage {
  id: string;
  content: string;
  fromSelf: boolean;
  createdAt: number;
}

这里没有额外的基类,也没有把任务与留言塞进一个通用 ContentItem。这是合理的:两类数据虽然都有 idcreatedAt,但业务状态不同。任务需要 completed,留言需要发送方语义 fromSelf。为了少写两个字段而强行合并,会让页面到处判断 type,反而削弱类型系统。

数据 所属边界 当前用途 不应承担的职责
双方头像与称呼 CoupleSpace 顶部关系信息 账号身份认证
startDate CoupleSpace 计算相伴天数 取代全部纪念日
tasks CoupleSpace 页面任务列表 全局待办系统
messages CoupleSpace 本地留言展示 网络聊天记录
共同纪念日 AnniversaryRepository 按类型筛选组合 复制进聚合形成双真源

这个边界最有价值的地方,是页面数据可以组合,数据真源却不必合并。情侣空间负责自身资料、任务与留言;纪念日仍由纪念日仓库维护。

二、默认对象不是示例数据,而是合法空状态

模型文件提供了默认工厂:

export function createDefaultCoupleSpace(): CoupleSpace {
  return {
    selfAvatar: 'avatar_default_male',
    loverAvatar: 'avatar_default_female',
    selfName: '我',
    loverName: 'TA',
    startDate: Date.now(),
    tasks: [],
    messages: [],
  };
}

它让页面在持久化读取完成前就拥有完整、非空、可渲染的状态。对 ArkUI 来说,这比把每个字段声明为可空更稳:构建阶段不需要不断写 ?. 或占位分支,空任务与空留言也能自然映射为页面空状态。

不过,默认值包含明确的产品语义:

  • startDate: Date.now() 表示首次进入时从当天开始计时。
  • “我”和“TA”是显示称呼,不是账号身份。
  • 头像字符串是资源键语义,实际页面当前直接使用固定媒体资源。
  • 空数组表示功能可用但暂无内容,而不是加载失败。

默认工厂需要满足“随时调用都得到全新对象”。当前实现每次都创建新数组,因此不会出现两个页面实例共享同一个可变数组的问题。若未来把默认数组提升为模块常量,再直接复用,就可能产生跨实例污染。

const first = createDefaultCoupleSpace();
const second = createDefaultCoupleSpace();

// 期望:两个数组不是同一引用
const isolated = first.tasks !== second.tasks;

默认状态还不等于持久化成功。页面必须把“没有历史数据”和“读取失败”区分开,否则损坏的 JSON 也会悄悄表现成首次使用。

三、读取流程:页面组合两个真源

CoupleView 出现时执行 loadData()

private async loadData(): Promise<void> {
  const saved =
    await this.store.getJson<CoupleSpace | null>(
      DataKeys.COUPLE,
      null
    );
  if (saved) {
    this.couple = saved;
  }

  const all = await this.anniversaryRepo.getAll();
  this.sharedAnniversaries = all.filter(
    (item: Anniversary) =>
      item.type === 'love' ||
      item.type === 'memorial'
  );
}

第一条数据路径读取 DataKeys.COUPLE,第二条路径读取纪念日仓库。页面将它们放在 couplesharedAnniversaries 两个状态变量中。这样做避免了一个常见问题:把纪念日对象复制进 CoupleSpace,之后编辑纪念日时只更新仓库,情侣空间仍展示旧副本。

任务留言持久化与共同纪念日读取流程

当前串行读取便于理解,但两个数据源互不依赖,可以在后续优化时并发等待:

private async loadData(): Promise<void> {
  const couplePromise =
    this.store.getJson<CoupleSpace | null>(
      DataKeys.COUPLE,
      null
    );
  const anniversariesPromise =
    this.anniversaryRepo.getAll();

  const results = await Promise.all([
    couplePromise,
    anniversariesPromise
  ]);

  const saved = results[0] as CoupleSpace | null;
  const all = results[1] as Anniversary[];

  if (saved) {
    this.couple = saved;
  }
  this.sharedAnniversaries = all.filter(
    (item: Anniversary) =>
      item.type === 'love' ||
      item.type === 'memorial'
  );
}

并发只是性能演进,不是当前源码已有行为。更重要的是补齐页面状态:加载中、读取失败、无内容和正常内容不能都落成同一个默认界面。

当前源码边界:这是本地双人主题页,不是双方账号协作

字段名里的 selflover 容易让人误以为已经存在两个登录用户。本轮复核没有找到账号、成员绑定或网络同步逻辑,页面也没有编辑双方称呼、头像和关系起始日的交互入口。selfNameloverNamestartDate 会参与展示,但两张头像并未读取模型中的 selfAvatarloverAvatar,而是直接引用 avatar_couple_maleavatar_couple_female 固定媒体资源。因此,头像字符串目前只是模型中的预留数据,不能据此宣称用户已能换头像。

当前任务只支持新增与完成状态切换,没有编辑、删除;留言只支持新增,没有编辑、删除,也没有“代另一方留言”的入口。新留言固定保存 fromSelf: true,所以左右气泡只是本机视角的展示规则,不是经过身份认证的发送者记录。这些限制属于当前事实;增加资料编辑、双成员身份与完整增删改,则是后续设计范围。

四、聚合边界:为什么纪念日不放进 CoupleSpace

共同纪念日看起来属于情侣空间,但它同时出现在全局纪念日列表、详情页、卡片服务和提醒服务中。若直接在 CoupleSpace 增加 anniversaries: Anniversary[],就会出现两个可写副本:

DataKeys.ANNIVERSARIES
  -> love / memorial 条目

DataKeys.COUPLE
  -> 再复制一份相同纪念日

两个副本需要同步标题、日期、封面、置顶、重复规则和更新时间。任意一次保存漏掉一侧,就会产生不一致。真实实现选择在加载时过滤:

all.filter((item: Anniversary) =>
  item.type === 'love' ||
  item.type === 'memorial'
);

这相当于建立一个页面投影,而不是创建第二个数据源。

方案 一致性 查询成本 适合场景
复制完整纪念日对象 几乎不建议
只保存纪念日 ID 需要手工挑选共享项
按类型实时筛选 当前恋爱/纪念日自动归集
独立关系表 多空间、多成员、权限复杂

当前“按类型筛选”与产品规模匹配。如果未来允许用户决定某条纪念日是否出现在情侣空间,可在 CoupleSpace 中保存 sharedAnniversaryIds: string[],但仍不复制完整对象。

当前投影还有四个需要如实标注的限制。第一,筛选条件只看 type,会收集仓库中所有 lovememorial 条目,并没有 coupleId 或空间成员关联;当产品只有一个本地情侣空间时尚可理解,扩展多空间后就不够。第二,页面使用 calcDaysPassed(item.startDate ?? item.targetDate) 统一显示“经过天数”,若 memorial 只有一个未来 targetDate,结果可能是负数,语义上应改为“还有多少天”或按日期方向选择计算函数。

第三,sharedAnniversaries 只在 aboutToAppear() 触发的 loadData() 中读取;当前页面没有监听 DATA_VERSION 或仓库变更通知,所以纪念日在别处修改后,只有重新进入并完成加载才能确认展示最新值。第四,共同纪念日使用普通 Column + ForEach,没有 List 或外层 Scroll;条目很多时是否全部可达,本轮没有进行设备或窗口尺寸测试。以上是源码审阅结论,不是已复现的运行故障。

历史证据:共同纪念日同步问题曾有修复记录

项目级错误记录在 2026-05-20 留下过“恋爱/情侣共同纪念日不同步”的问题,记录的修复方向是让情侣页从 AnniversaryRepository 读取并筛选对应类型。当前源码确实存在该仓库读取与过滤代码,因此可以证明修复方案仍保留在代码中。另一方面,本轮对相关模型和页面执行了聚焦提交历史查询,没有得到可用于进一步归因的提交记录;也没有重新运行构建、安装或真机流程。历史记录只能说明当时记录过问题与修复,不能替代当前版本的构建和设备验证。

五、任务模型:稳定身份比数组下标更重要

任务创建代码使用当前时间生成字符串 ID:

const newTask: CoupleTask = {
  id: Date.now().toString(),
  title: this.newTaskText.trim(),
  completed: false,
  createdAt: Date.now()
};

页面渲染时也把 task.id 作为 ForEach 键,切换完成状态时按 ID 查找。这样即使列表排序变化,操作仍指向同一个任务。若使用数组下标,删除、插入或重新排序后,UI 复用与业务定位都可能错位。

当前 ID 在单设备、低频点击下通常够用,但同一毫秒创建两项仍可能冲突。面向备份导入或多设备合并时,建议使用带随机片段的生成函数:

function createLocalId(prefix: string): string {
  const now = Date.now();
  const random =
    Math.random().toString(36).slice(2, 10);
  return `${prefix}_${now}_${random}`;
}

更完整的任务模型还可以加入:

export interface CoupleTaskV2 {
  id: string;
  title: string;
  completed: boolean;
  createdAt: number;
  updatedAt: number;
  completedAt?: number;
  deletedAt?: number;
}

updatedAt 支持新旧版本判断,completedAt 支持完成记录,deletedAt 则为多设备合并提供“墓碑”。这些字段属于演进设计,当前源码只有四个字段。

六、留言模型:fromSelf 是视角,不是永久身份

当前留言定义:

export interface CoupleMessage {
  id: string;
  content: string;
  fromSelf: boolean;
  createdAt: number;
}

在单设备本地页面里,fromSelf 很实用:它直接决定气泡方向与配色,不需要账号系统。创建留言时源码固定写入 true,因此当前功能更接近“留给彼此看的本地便签”,不是两台设备实时互发消息。

当数据要跨设备共享时,布尔值会失去稳定意义。设备 A 的 self 可能是设备 B 的 lover,导入后若不转换,气泡方向就会相反。跨设备模型应保存稳定参与者 ID:

export type CoupleMemberRole = 'owner' | 'partner';

export interface CoupleMessageV2 {
  id: string;
  content: string;
  authorRole: CoupleMemberRole;
  createdAt: number;
  updatedAt: number;
}

如果未来接入账号,authorIdauthorRole 更可靠;页面再根据当前登录身份计算 fromSelf

const fromSelf =
  message.authorId === currentUserId;

模型应存事实,页面状态应存视角。authorId 是事实,fromSelf 是根据当前用户推导出的视角。当前实现没有账号与联网能力,因此布尔字段是符合现阶段边界的简化。

七、不可变更新:让 ArkUI 明确感知变化

添加任务时,源码没有直接执行 this.couple.tasks.push(newTask),而是创建新数组和新对象:

this.couple = {
  selfAvatar: this.couple.selfAvatar,
  loverAvatar: this.couple.loverAvatar,
  selfName: this.couple.selfName,
  loverName: this.couple.loverName,
  startDate: this.couple.startDate,
  tasks: [...this.couple.tasks, newTask],
  messages: this.couple.messages,
};

切换完成状态时同样先 map(),再通过 cloneCouple() 替换聚合:

private async toggleTask(id: string): Promise<void> {
  const nextTasks =
    this.couple.tasks.map((task: CoupleTask) => {
      if (task.id !== id) return task;
      return {
        id: task.id,
        title: task.title,
        completed: !task.completed,
        createdAt: task.createdAt,
      };
    });

  this.couple =
    this.cloneCouple(
      nextTasks,
      this.couple.messages
    );
  await this.saveData();
}

重新赋值能给 ArkUI 清晰的状态变化信号,也减少“内层数组已经变了,但依赖外层引用的逻辑没有更新”的隐患。

当前单设备模型与建议双用户协作模型边界

随着字段增加,手写复制容易漏字段。可以让克隆函数接收局部变更:

interface CoupleSpacePatch {
  tasks?: CoupleTask[];
  messages?: CoupleMessage[];
  selfName?: string;
  loverName?: string;
}

function patchCoupleSpace(
  current: CoupleSpace,
  patch: CoupleSpacePatch
): CoupleSpace {
  return {
    selfAvatar: current.selfAvatar,
    loverAvatar: current.loverAvatar,
    selfName: patch.selfName ?? current.selfName,
    loverName: patch.loverName ?? current.loverName,
    startDate: current.startDate,
    tasks: patch.tasks ?? current.tasks,
    messages: patch.messages ?? current.messages,
  };
}

ArkTS 项目中应保持显式类型,不用宽泛 any 或动态扩展对象。字段多到难以维护时,优先把更新逻辑放入 ViewModel 或服务层,而不是在每个点击事件里复制整段对象。

八、Preferences 持久化:JSON 是格式,不是协议

DataStore 把复杂对象序列化为字符串:

async putJson<T>(
  key: string,
  value: T
): Promise<void> {
  await this.ensureReady();
  if (!this.pref) return;

  try {
    const json = JSON.stringify(value);
    await this.pref.put(key, json);
    await this.pref.flush();
  } catch (e) {
    hilog.error(
      DOMAIN,
      TAG,
      'putJson [%{public}s] failed: %{public}s',
      key,
      JSON.stringify(e)
    );
  }
}

DataKeys.COUPLE 的真实键值是 ds_couple。JSON 便于保存小型本地对象,但 JSON.parse() 成功不代表对象结构合法。旧版本可能没有新字段,导入文件也可能把 tasks 写成字符串。

建议在模型边界增加正规化函数:

interface CoupleSpaceRecord {
  schemaVersion: number;
  data: CoupleSpace;
}

function normalizeCoupleSpace(
  value: CoupleSpace | null
): CoupleSpace {
  const fallback = createDefaultCoupleSpace();
  if (!value) return fallback;

  return {
    selfAvatar:
      typeof value.selfAvatar === 'string'
        ? value.selfAvatar : fallback.selfAvatar,
    loverAvatar:
      typeof value.loverAvatar === 'string'
        ? value.loverAvatar : fallback.loverAvatar,
    selfName:
      typeof value.selfName === 'string'
        ? value.selfName : fallback.selfName,
    loverName:
      typeof value.loverName === 'string'
        ? value.loverName : fallback.loverName,
    startDate:
      Number.isFinite(value.startDate)
        ? value.startDate : fallback.startDate,
    tasks:
      Array.isArray(value.tasks) ? value.tasks : [],
    messages:
      Array.isArray(value.messages)
        ? value.messages : [],
  };
}

正规化至少防止缺字段导致页面构建异常。更严格的实现还应逐项验证任务和留言,限制文本长度,并拒绝无效时间戳。

九、模型版本:为字段演进留下入口

当前 CoupleSpace 本身没有 schemaVersion,但 BackupData 已有 version: number。备份版本负责整个备份包,聚合版本负责单个模型,两者作用不同。

export interface CoupleSpaceEnvelope {
  schemaVersion: number;
  updatedAt: number;
  data: CoupleSpace;
}

读取时根据版本迁移:

function migrateCoupleSpace(
  envelope: CoupleSpaceEnvelope
): CoupleSpaceEnvelope {
  if (envelope.schemaVersion === 1) {
    return envelope;
  }
  throw new Error(
    `Unsupported couple schema: ${
      envelope.schemaVersion
    }`
  );
}

首个版本看似不需要迁移,但只要未来把 fromSelf 改为 authorRole、为任务增加删除标记、允许自定义头像,就会需要稳定入口。没有版本字段时,只能靠“字段是否存在”猜测来源。

变化 是否需要迁移 推荐处理
新增可选 UI 字段 视情况 读取时补默认值
字段重命名 需要 旧字段映射到新字段
布尔身份改为参与者 ID 需要 根据设备角色转换
时间单位改变 必须 显式换算并记录版本
删除字段 建议 读取兼容,写回新格式

迁移应保持幂等:同一份数据执行两次,结果不能继续变化。

十、时间字段:毫秒值必须约定日历语义

模型中的 startDatecreatedAt 都是 number,实际写入 Date.now() 毫秒值。两者含义不同:

  • startDate 是面向日历的关系起始日。
  • createdAt 是事件发生时刻。

源码中的 calcDaysPassed() 会把当前时间与起始时间都归一到本地当天零点,再计算天数:

export function calcDaysPassed(
  startDate: number
): number {
  const now = new Date();
  const today = new Date(
    now.getFullYear(),
    now.getMonth(),
    now.getDate()
  ).getTime();

  const start = new Date(startDate);
  const startDay = new Date(
    start.getFullYear(),
    start.getMonth(),
    start.getDate()
  ).getTime();

  return Math.floor(
    (today - startDay) /
    (1000 * 60 * 60 * 24)
  );
}

这避免了“今天 23:00 到明天 01:00 只过两小时却应显示跨一天”的部分问题,但跨夏令时地区可能出现一天不是固定 24 小时的情况。当前面向本地设备使用;若跨地区同步,应明确按哪个时区解释关系起始日。更稳的做法是存储日历日期字段,例如 2026-07-26,显示时再按业务时区转换。

任务与留言的 createdAt 则应保留绝对时间戳,用于排序和冲突判断,不能按日历日期丢失时分秒。

十一、保存失败不能悄悄当作成功

当前 saveData() 只调用:

private async saveData(): Promise<void> {
  await this.store.putJson(
    DataKeys.COUPLE,
    this.couple
  );
}

DataStore.putJson() 捕获异常后记录日志,不向上抛出,也不返回结果。页面会立即显示新任务或新留言,即使持久化失败。用户离开再回来时内容消失,容易被理解为数据丢失。

调用方式还存在一个可观察性差异:新增任务与新增留言的点击处理器会先清空输入框,再直接调用 this.saveData(),既没有 await,也没有根据结果恢复草稿;切换任务完成状态则会 await this.saveData(),之后增加 StateKeys.DATA_VERSION。但由于底层写入异常被吞掉,即使使用了 await,页面仍无法获知持久化是否真正成功。新增任务和留言也没有递增 DATA_VERSION,因此若其他页面依赖这个版本通知刷新,它们收不到这两类变化。这里讨论的是当前调用链的差异,不代表已经观察到数据丢失。

可以把写入结果显式化:

export interface SaveResult {
  success: boolean;
  reason?: string;
}

async putJsonSafely<T>(
  key: string,
  value: T
): Promise<SaveResult> {
  await this.ensureReady();
  if (!this.pref) {
    return {
      success: false,
      reason: 'STORE_NOT_READY'
    };
  }

  try {
    await this.pref.put(
      key,
      JSON.stringify(value)
    );
    await this.pref.flush();
    return { success: true };
  } catch (e) {
    return {
      success: false,
      reason: 'WRITE_FAILED'
    };
  }
}

页面可以采用“先更新后回滚”或“保存成功后更新”两种策略。对于任务与留言这类用户明确输入的内容,至少要在失败时提示,并保留输入草稿,不能清空输入框后静默丢失。

十二、输入约束:在进入模型前做净化

真实页面已经对输入执行 trim(),并拒绝空文本:

if (this.newTaskText.trim().length > 0) {
  // 创建任务
}

这是第一层保护,但还缺长度上限。无限长文本会放大 Preferences JSON、列表布局和备份文件的压力。可以定义领域约束:

export class CoupleLimits {
  static readonly NAME_MAX = 24;
  static readonly TASK_TITLE_MAX = 120;
  static readonly MESSAGE_MAX = 1000;
  static readonly TASK_COUNT_MAX = 500;
  static readonly MESSAGE_COUNT_MAX = 2000;
}

function normalizeText(
  raw: string,
  maxLength: number
): string {
  const text = raw.trim();
  if (text.length > maxLength) {
    return text.slice(0, maxLength);
  }
  return text;
}

约束不应只写在输入框的 maxLength,因为备份导入、未来服务调用也可能绕过页面。页面负责即时反馈,模型服务负责最终校验。

十三、备份边界:结构合法与内容可信是两回事

真实 BackupData 包含:

export interface BackupData {
  version: number;
  timestamp: number;
  anniversaries: Anniversary[];
  quotes: Quote[];
  coupleSpace: CoupleSpace | null;
  themeId: string;
}

BackupService.importBackup() 当前检查 versionanniversaries 是否存在,但没有逐项校验 coupleSpace。这意味着 JSON 语法正确的文件仍可能带来错误类型、超长文本或无效时间。

导入流程建议拆成四步:

  1. 限制文件大小,避免超大 JSON 占用内存。
  2. 解析后验证备份包版本。
  3. 分别校验纪念日、语录和情侣空间。
  4. 先生成迁移后的预览,用户确认后再覆盖本地数据。

情侣空间包含私人称呼、留言和共同事项。导出时应明确文件存放位置、分享风险与删除方式;不要默认上传云端,也不要在日志中输出正文内容。

十四、从本地双人页面到多设备协同,还缺什么

当前源码是单设备本地能力。真正的多设备情侣空间至少需要以下新增协议:

export interface SyncEntityMeta {
  entityId: string;
  spaceId: string;
  authorId: string;
  revision: number;
  createdAt: number;
  updatedAt: number;
  deletedAt?: number;
}
能力 当前模型 多设备所需
成员身份 self/lover 显示语义 稳定用户 ID 与空间成员关系
更新顺序 本地数组顺序 revision、updatedAt 或操作日志
删除 直接从数组移除 删除墓碑与传播确认
冲突 不存在 字段级或实体级合并策略
权限 本机即可信 成员校验、退出与撤销
传输 官方网络能力与安全通道

不能只把 Preferences 文件复制到另一台设备就称为协同。双方可能同时完成同一任务、修改称呼或新增留言;没有空间 ID、作者 ID、版本号和冲突规则,最后写入者会覆盖另一端。

一种保守策略是任务和留言按实体合并,空间资料采用版本较新的整对象;删除使用 deletedAt,等待两端确认后再物理清理。无论采用哪种方式,都需要先定义一致性承诺,再选 HarmonyOS 数据或网络能力。

十五、隐私与审核:亲密内容应默认留在本地

情侣空间天然可能包含私人称呼、计划与留言。当前情侣空间通过 Preferences 存储;项目另有向应用私有目录写 JSON 的 BackupService,但本轮只证明 BackupData 结构可以容纳 coupleSpace,没有证明页面已经把该字段接入完整的导出、选择文件、恢复与覆盖确认流程。源码中没有看到登录、云同步或实时通信,因此文章也不把它描述成在线社交功能。

发布前应核对:

  • 应用说明是否准确描述为本地记录。
  • 权限清单是否没有与功能无关的网络、通讯录、定位等权限。
  • 备份文件是否位于应用可控目录,并有清晰的导出操作。
  • 日志是否只记录键名和错误,不输出留言正文。
  • 删除情侣空间是否有确认,且能清除对应本地数据。
  • 若未来新增云同步,隐私政策、服务端位置、账号注销和数据删除是否同步更新。

敏感信息不应被用于崩溃上报或埋点标签。即使内容由用户主动输入,也不意味着应用可以在后台上传。

十六、测试矩阵:模型比页面截图更需要边界用例

模型测试应覆盖默认值、更新、持久化、迁移和异常:

用例 输入 期望
首次进入 ds_couple 不存在 渲染全新默认对象
空任务 tasks=[] 显示明确空状态
空白任务 仅空格 不创建、不保存
切换完成 已存在任务 ID 只更新目标项
ID 不存在 随机 ID 聚合内容不变
损坏 JSON 无法解析 显示错误或安全默认值
旧版本缺字段 messages 迁移为空数组
超长留言 超过领域上限 阻止或截断并提示
保存失败 Preferences 不可用 提示失败,保留输入
纪念日更新 仓库条目改变 重新加载后展示新值

一个针对纯函数的最小验证可以这样写:

const original = createDefaultCoupleSpace();
const task: CoupleTask = {
  id: 'task_1',
  title: '一起看展',
  completed: false,
  createdAt: 100
};

const updated = patchCoupleSpace(
  original,
  { tasks: [task] }
);

// original.tasks.length 应为 0
// updated.tasks.length 应为 1
// original 与 updated 不应是同一引用

把模型更新写成纯函数,可以在没有 ArkUI 运行环境时验证大部分行为;页面测试再关注点击、空状态、长文本、返回导航和安全区。

十七、性能与容量:Preferences 适合轻量聚合,不适合无限消息流

当前每次保存都会序列化整个 CoupleSpace,任务或留言越多,单次写入成本越高。少量本地内容时这种设计简单可靠;当留言达到数千条,任何一次新增都要复制数组、序列化全部数据并 flush()

可用以下指标判断是否需要迁移存储:

指标 Preferences 仍合适 应考虑关系型存储
数据量 小型设置与有限列表 大量、持续增长记录
查询 整体读取 分页、筛选、排序
更新 整体替换可接受 高频单条更新
迁移 简单版本 多表关系与索引
并发 单页面低频写入 多入口并发读写

如果产品坚持本地留言板,可设合理数量上限并归档旧内容。若演进为真正消息流,应把 CoupleMessage 放入关系型数据表,按 spaceId + createdAt 分页,不再把所有消息嵌入一个 JSON。

十八、常见问题与修复方向

现象 可能原因 修复
新任务当时可见,重进消失 写入失败被静默吞掉 返回保存结果并提示
修改纪念日后情侣页还是旧值 页面未重新加载仓库 页面恢复时刷新或订阅版本
导入后气泡方向反了 fromSelf 被跨设备直接复用 保存作者身份,再计算视角
新字段上线后页面崩溃 旧 JSON 没有该字段 加 schemaVersion 与正规化
快速新增出现重复键 仅用毫秒时间作 ID 加随机片段或 UUID
长留言导致页面卡顿 整个 JSON 高频重写 限长、分页或迁移 RDB
清理数据误删纪念日 聚合与仓库边界混乱 分键存储并明确删除范围
两台设备互相覆盖 没有 revision 与冲突规则 先设计同步协议

排障时先判断问题属于页面状态、模型变换、持久化还是跨源组合。不要看到“显示不对”就直接修改 UI。

十九、发布前验证清单

  • [ ] CoupleSpace.ets 的字段与文章描述一致。
  • [ ] 首次进入能渲染默认称呼、空任务和空留言。
  • [ ] 空白输入不会生成任务或留言。
  • [ ] 添加任务、切换完成状态、添加留言后能持久化。
  • [ ] DataKeys.COUPLE 与纪念日数据键相互独立。
  • [ ] 共同纪念日来自仓库筛选,没有复制第二份真源。
  • [ ] 长文本在手机、小窗、平板布局中不遮挡操作。
  • [ ] 写入失败有用户可理解的状态,输入不会无提示丢失。
  • [ ] 备份导入对版本和 coupleSpace 结构做校验。
  • [ ] 日志、统计与崩溃信息不包含私人留言正文。
  • [ ] 应用说明、权限、隐私政策与“本地功能”事实一致。
  • [ ] 若新增同步,身份、版本、冲突、删除和注销协议已定义。

二十、总结:先稳住聚合,再扩展能力

时光清单 的情侣空间模型没有追求复杂:CoupleSpace 聚合双方资料、关系起始日、共同任务与留言;任务和留言各自保持独立业务字段;共同纪念日继续由 AnniversaryRepository 管理,页面只做筛选组合;更新时生成新数组和新对象,再通过 DataStore 序列化到 ds_couple。这个结构与当前单设备、本地、轻量的真实能力匹配。

下一阶段最值得补的不是更多页面装饰,而是数据协议:保存结果、输入上限、结构校验、模型版本和迁移。只有当产品真的进入多设备协同时,才引入稳定成员身份、空间 ID、实体版本、删除墓碑和冲突合并。把“当前可用”与“未来可同步”分开设计,既能保持 HarmonyOS 本地体验简洁,也不会让后续演进被一个没有边界的 JSON 对象困住。

本轮没有执行当前源码的构建、安装、真机交互或性能测试,也没有据此推断发布状态、用户规模和平台评价结果。可确认的内容限于上述源码、项目错误记录以及文章更新后的平台回读;需要运行环境才能证明的结论,仍应通过当前版本的构建与设备测试补齐。


AI 辅助声明: 本文由 AI 辅助整理,所有源码结构、接口字段与行为结论均依据文中列出的 CoupleSpace.etsCoupleView.etsDataStore.etsAnniversary.etsBackupService.ets 进行人工复核;演进代码为明确标注的设计建议,不代表当前版本已经实现。

Logo

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

更多推荐