【时光清单|10】HarmonyOS ArkTS 情侣空间实战:设计双方纪念内容的数据模型
【时光清单|10】HarmonyOS ArkTS 情侣空间实战:设计双方纪念内容的数据模型
“情侣空间”看起来只是两个头像、一块留言板和几条共同任务,真正落到数据层却很容易失控:双方资料放在哪里,任务与留言是否属于同一个聚合,纪念日要不要复制一份,页面更新数组后为什么必须重新赋值,未来做备份或多设备协同时又怎样避免旧结构把新数据覆盖掉?这些问题如果没有在模型阶段回答,页面越丰富,数据边界越模糊。
时光清单 的真实源码给出了一个小而完整的起点。CoupleSpace.ets 定义双方头像、双方称呼、关系起始日期、共同任务和留言;CoupleView.ets 从 DataStore 读取并保存该聚合,同时从 AnniversaryRepository 单独筛选恋爱与纪念日;BackupService.ets 又把 CoupleSpace | null 纳入备份结构。它没有账号体系、远端同步、双向实时通信,也没有把“本地留言”包装成在线聊天。
本文从这些可复核代码出发,不把未运行的构建、设备验证或双用户同步写成当前结果,说明怎样用 ArkTS 明确聚合边界、子项身份、时间语义、不可变更新和持久化协议,并进一步讨论版本迁移、输入校验、多设备扩展与隐私审核。文中的“当前实现”和“演进建议”会分开标注,避免把设计方案误写成已经上线的能力。

本文将解决六个具体问题:
- 为什么
CoupleSpace适合作为页面聚合,而不应吞并所有纪念日。 CoupleTask与CoupleMessage为什么都需要稳定标识和创建时间。- 当前
fromSelf语义在哪些场景成立,何时必须升级为参与者标识。 - ArkUI 中为什么采用展开运算符生成新数组和新对象。
- Preferences JSON 持久化怎样补上校验、版本与迁移。
- 从单设备本地功能扩展到多设备协同时,模型还缺哪些协议。
本文唯一标记:
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。这是合理的:两类数据虽然都有 id 和 createdAt,但业务状态不同。任务需要 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,第二条路径读取纪念日仓库。页面将它们放在 couple 与 sharedAnniversaries 两个状态变量中。这样做避免了一个常见问题:把纪念日对象复制进 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'
);
}
并发只是性能演进,不是当前源码已有行为。更重要的是补齐页面状态:加载中、读取失败、无内容和正常内容不能都落成同一个默认界面。
当前源码边界:这是本地双人主题页,不是双方账号协作
字段名里的 self 与 lover 容易让人误以为已经存在两个登录用户。本轮复核没有找到账号、成员绑定或网络同步逻辑,页面也没有编辑双方称呼、头像和关系起始日的交互入口。selfName、loverName 与 startDate 会参与展示,但两张头像并未读取模型中的 selfAvatar、loverAvatar,而是直接引用 avatar_couple_male、avatar_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,会收集仓库中所有 love 和 memorial 条目,并没有 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;
}
如果未来接入账号,authorId 比 authorRole 更可靠;页面再根据当前登录身份计算 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 | 需要 | 根据设备角色转换 |
| 时间单位改变 | 必须 | 显式换算并记录版本 |
| 删除字段 | 建议 | 读取兼容,写回新格式 |
迁移应保持幂等:同一份数据执行两次,结果不能继续变化。
十、时间字段:毫秒值必须约定日历语义
模型中的 startDate 和 createdAt 都是 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() 当前检查 version 和 anniversaries 是否存在,但没有逐项校验 coupleSpace。这意味着 JSON 语法正确的文件仍可能带来错误类型、超长文本或无效时间。
导入流程建议拆成四步:
- 限制文件大小,避免超大 JSON 占用内存。
- 解析后验证备份包版本。
- 分别校验纪念日、语录和情侣空间。
- 先生成迁移后的预览,用户确认后再覆盖本地数据。
情侣空间包含私人称呼、留言和共同事项。导出时应明确文件存放位置、分享风险与删除方式;不要默认上传云端,也不要在日志中输出正文内容。
十四、从本地双人页面到多设备协同,还缺什么
当前源码是单设备本地能力。真正的多设备情侣空间至少需要以下新增协议:
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.ets、CoupleView.ets、DataStore.ets、Anniversary.ets 与 BackupService.ets 进行人工复核;演进代码为明确标注的设计建议,不代表当前版本已经实现。
更多推荐




所有评论(0)