鸿蒙 Next 时间胶囊 App 开发实战:ArkUI 数据持久化与声明式 UI 深度实践

作者:duluo
SDK 版本:HarmonyOS API 24 (Next)
开发工具:DevEco Studio
语言框架:ArkTS + ArkUI
字数:约 12000 字


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

目录

  1. 引言
  2. 需求分析与设计思路
  3. 数据模型与存储架构
  4. ArkUI 组件树设计
  5. 状态管理进阶实践
  6. 数据持久化:Preferences 全解析
  7. 声明式 Builder 语法深度解读
  8. UI 视觉设计:复古主题实现
  9. 条件渲染与状态分支管理
  10. 编译错误全记录与解决方案
  11. 总结与展望

1. 引言

1.1 什么是时间胶囊

“时间胶囊”(Time Capsule)是一个充满仪式感的数字产品概念:用户在当下写下一段文字——对未来的期许、此刻的心情、某个秘密——然后设定一个未来的日期将其"封存"。在设定的日期到来之前,任何人都无法查看内容。只有当时间到达解锁日,胶囊才会被"开启",用户得以阅读来自过去的自己留下的讯息。

这种"寄往未来的信"的情感价值,在快节奏的现代生活中尤为珍贵。它让我们在忙碌中停下来思考,也给未来的自己一份惊喜或慰藉。

1.2 项目定位与技术选型

维度 选择 理由
开发语言 ArkTS HarmonyOS Next 原生语言,静态类型安全
UI 框架 ArkUI 声明式 DSL,编译期优化,原生性能
数据持久化 @ohos.data.preferences 轻量级 KV 存储,适合小规模结构化数据
视觉风格 复古做旧 + 暖色调 契合"时间"主题,营造怀旧氛围
页面架构 单页多 Builder 功能聚焦,避免路由复杂度

1.3 与"白噪音"App 的对比思考

这是本系列的第二款应用。相比前作"沉浸式白噪音","时间胶囊"在技术维度上呈现出不同的挑战:

对比维度 白噪音 App 时间胶囊 App
核心能力 多媒体播放(AudioPlayer) 数据持久化(Preferences)
状态复杂度 6 个独立播放器并行管理 数组增删改 + 时间判断
UI 复杂度 Grid 网格 + 动画 Dialog 弹窗 + 条件渲染
Builder 挑战 @Builder 内无变量声明 @Builder 条件分支复杂
存储需求 无持久化 必须持久化存储

两种应用分别代表了 ArkUI 开发的两个重要方向:多媒体能力数据持久化能力


2. 需求分析与设计思路

2.1 核心功能需求

用户故事 1:作为用户,我想写一封信并设定一个未来的解锁日期
用户故事 2:作为用户,我想在列表中看到所有已创建的胶囊
用户故事 3:作为用户,我想清楚地知道每个胶囊当前的状态(锁定/可开启/已开启)
用户故事 4:作为用户,当解锁日到达时,我希望能够开启胶囊阅读内容
用户故事 5:作为用户,我想删除不再需要的胶囊

功能列表:
├── F1: 创建胶囊(标题 + 内容 + 解锁日期)
├── F2: 胶囊列表展示(按创建时间倒序)
├── F3: 三种状态区分(锁定 / 可开启 / 已开启)
├── F4: 开启胶囊(到期后才能开启)
├── F5: 删除胶囊(左滑删除)
├── F6: 数据持久化(App 重启后数据不丢失)
└── F7: 剩余天数提示(锁定状态下显示)

2.2 非功能需求

需求 说明
启动速度 冷启动 < 2 秒,数据加载异步非阻塞
存储容量 支持至少 100 个胶囊的存储
日期校验 解锁日期不能早于当前日期
空状态引导 无胶囊时显示友好的空状态页面
防误操作 删除需滑动确认,弹窗可取消

2.3 信息架构

首页(胶囊列表)
├── 标题栏:App 名称 + 创建按钮
├── 胶囊列表(主内容区)
│   ├── 锁定状态卡片(🔒 未到时间 + 剩余天数)
│   ├── 可开启状态卡片(🔓 突出提示)
│   └── 已开启状态卡片(✅ 已读标记)
└── 空状态(无数据时)

创建弹窗(模态覆盖)
├── 标题输入框
├── 内容文本域
├── 解锁日期选择
├── 取消按钮
└── 埋下胶囊按钮

详情弹窗(模态覆盖)
├── 未到期 → 显示锁定图标 + 日期信息 + 倒计时
├── 已到期未开启 → 显示"开启胶囊"按钮
└── 已开启 → 显示完整信件内容

3. 数据模型与存储架构

3.1 核心数据模型

interface TimeCapsule {
  id: number;           // 唯一标识(使用时间戳)
  title: string;        // 胶囊标题
  message: string;      // 信件内容
  createdAt: number;    // 创建时间戳
  unlockAt: number;     // 解锁时间戳
  isOpened: boolean;    // 是否已开启
}

设计决策说明

为什么 id 使用 Date.now() 而不是自增数字?
在本地存储场景中,使用时间戳作为 ID 有如下优势:

  • 天然全局唯一(不考虑毫秒级并发创建)
  • 携带创建时间信息(可用于排序)
  • 无需维护自增计数器状态

为什么使用时间戳(number)而不是字符串日期?

  • 时间比较:unlockAtDate.now() 直接比较,无需解析
  • 格式化:可在展示时统一格式化,存储原始数据
  • 时区无关:时间戳与时区无关,避免时区转换错误

3.2 颜色主题模型

ArkTS 要求所有对象字面量都必须有显式类型声明。我们为颜色主题定义了专门的接口:

interface ColorSet {
  primary: string;
  accent: string;
  bg: string;
  cardBg: string;
  text: string;
  textLight: string;
  lockColor: string;
  unlockColor: string;
  border: string;
}

const COLORS: ColorSet = {
  primary: '#8B6B4A',    // 主色:复古棕
  accent: '#C49A6C',     // 强调色:金色
  bg: '#F5ECD7',         // 背景:米白
  cardBg: '#FFF8EC',     // 卡片:暖白
  text: '#3D2B1F',       // 文字:深棕
  textLight: '#8B7355',  // 辅助文字:浅棕
  lockColor: '#B8860B',  // 锁定色:暗金
  unlockColor: '#6B8E23', // 解锁色:橄榄绿
  border: '#DEB887'      // 边框:淡棕
};

ArkTS 约束:在 ArkTS 中,const COLORS = { ... } 这种写法不被允许,因为编译器要求对象字面量必须对应一个显式声明的接口或类。因此我们需要先定义 ColorSet 接口,再将 COLORS 声明为 ColorSet 类型。

3.3 图标资源设计

我们使用 Emoji 作为胶囊图标,通过 id % CAPSULE_ICONS.length 为每个胶囊分配一个随机图标:

const CAPSULE_ICONS: string[] = [
  '\u{1F4E7}', // 📧 信件
  '\u{1F4EC}', // 📬 邮筒
  '\u{1F48C}', // 💌 情书
  '\u{1F4E9}', // 📩 信封
  '\u{1F381}', // 🎁 礼物
  '\u{1F3F0}', // 🏰 城堡
  '\u{1F4A0}', // 💠 钻石
  '\u{2B50}',  // ⭐ 星星
  '\u{1F308}', // 🌈 彩虹
  '\u{1F33C}'  // 🌼 花朵
];

这种设计的优势:

  • 零资源依赖:不需要任何图片资源文件,减少包体积
  • 视觉丰富度:10 种不同的图标让列表不单调
  • 确定性映射:同一个胶囊始终显示相同的图标

3.4 存储架构设计

┌─────────────────────────────────────┐
│          应用进程                     │
│  ┌──────────────────────────────┐   │
│  │   @State capsuleList          │   │  ← 内存中的响应式数组
│  │   (UI 渲染的数据源)            │   │
│  └──────────┬───────────────────┘   │
│             │ 读写                    │
│  ┌──────────▼───────────────────┐   │
│  │   Preferences 本地存储         │   │  ← 磁盘上的持久化存储
│  │   key: 'time_capsules'        │   │
│  │   value: JSON.stringify([])   │   │
│  └──────────────────────────────┘   │
└─────────────────────────────────────┘

存储流程:

  1. 启动时aboutToAppear() → 从 Preferences 加载 JSON → 解析为数组 → 赋值给 @State capsuleList
  2. 变更时:每次对 capsuleList 的增删改操作后 → 调用 saveCapsules() → 序列化为 JSON → 写入 Preferences
  3. 退出时aboutToDisappear() → 再次保存(双重保险)

4. ArkUI 组件树设计

4.1 整体组件树

Index (@Entry @Component)
├── buildBackground()           // 渐变背景
├── buildHeader()               // 标题栏 + 创建按钮
├── buildCapsuleList()          // 胶囊列表或空状态
│   ├── [empty] →
│   │   └── buildEmptyState()   // 空状态引导
│   └── [has data] →
│       └── List → ForEach →
│           └── ListItem →
│               └── buildCapsuleCard(capsule)
│                   └── swipeAction →
│                       └── buildDeleteButton(id)
├── buildCreateDialog()         // 创建弹窗
│   ├── 标题输入 TextInput
│   ├── 内容输入 TextArea
│   ├── 解锁日期 TextInput
│   └── 确认/取消按钮
├── buildDetailDialog()         // 详情弹窗
│   ├── buildOpenedCapsuleContent()  // 已开启
│   ├── buildOpenableCapsuleContent() // 可开启
│   └── buildLockedCapsuleContent()  // 未到期
└── 辅助方法 (普通成员方法)
    ├── getCapsuleStatusIcon()
    ├── getCapsuleStatusText()
    ├── getCapsuleStatusColor()
    ├── getCapsuleBottomHint()
    ├── getCapsuleBorderColor()
    ├── isCapsuleOpenable()
    └── copyCapsule()

4.2 Stack 层叠布局

与白噪音 App 类似,我们使用 Stack 进行层叠布局:

build() {
  Stack() {
    // 背景层
    this.buildBackground()

    // 主内容层
    Column() {
      this.buildHeader()
      this.buildCapsuleList()
    }

    // 弹窗层(条件渲染)
    if (this.showCreateDialog) {
      this.buildCreateDialog()
    }
    if (this.showDetailDialog && this.selectedCapsule) {
      this.buildDetailDialog()
    }
  }
}

三层结构的职责分离:

  • 背景层buildBackground() — 纯视觉,无交互
  • 内容层buildHeader() + buildCapsuleList() — 主要交互区域
  • 弹窗层buildCreateDialog() + buildDetailDialog() — 模态覆盖,条件渲染

4.3 List + ForEach 实现可滚动列表

List() {
  ForEach(this.capsuleList, (capsule: TimeCapsule) => {
    ListItem() {
      this.buildCapsuleCard(capsule)
    }
    .swipeAction({ end: this.buildDeleteButton(capsule.id) })
  }, (capsule: TimeCapsule) => capsule.id.toString())
}
.width('100%')
.layoutWeight(1)

关键点:

  • .layoutWeight(1):让 List 填充剩余空间,配合顶部标题栏
  • .swipeAction():提供左滑删除手势,交互符合移动端习惯
  • 稳定 keycapsule.id.toString() 确保列表 diff 性能

4.4 弹窗的 Z-Index 管理

弹窗使用 position({ x: 0, y: 0 }) + 全屏蒙层实现:

@Builder
buildCreateDialog() {
  Column() {
    // 全屏蒙层
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(61, 43, 31, 0.5)')
      .onClick(() => { this.showCreateDialog = false; })

    // 弹窗卡片(绝对定位)
    Column() { /* 弹窗内容 */ }
      .width('88%')
      .position({ x: '6%', y: '12%' })
  }
  .width('100%')
  .height('100%')
  .position({ x: 0, y: 0 })
}

这种"蒙层 + 浮层"的模式是移动端弹窗的标准实现。蒙层点击关闭提供了良好的用户体验——用户不会被困在弹窗中。


5. 状态管理进阶实践

5.1 状态变量总览

struct Index {
  // ─── 核心数据 ───
  @State capsuleList: TimeCapsule[] = [];

  // ─── 弹窗控制 ───
  @State showCreateDialog: boolean = false;
  @State showDetailDialog: boolean = false;

  // ─── 选中数据(详情弹窗使用)───
  @State selectedCapsule: TimeCapsule | null = null;

  // ─── 创建表单数据 ───
  @State newTitle: string = '';
  @State newMessage: string = '';
  @State newUnlockDate: string = '';
  @State minDate: string = '';

  // ─── 非响应式数据 ───
  private dataPreferences: preferences.Preferences | null = null;
}

5.2 @State 的深度理解

问题:为什么 @State capsuleList: TimeCapsule[] = [] 能驱动列表渲染?

因为在 ArkUI 中,@State 装饰的属性会被框架建立依赖追踪。当 buildCapsuleList() 中使用了 this.capsuleList,框架就会将该组件标记为依赖于这个状态变量。一旦 capsuleList 的引用发生变化,框架就会调度一次重渲染。

问题:修改数组元素的属性为什么不触发渲染?

// ❌ 下面的操作不会触发 UI 更新
this.capsuleList[0].title = '新标题';

// ✅ 必须创建新数组引用
let newList = this.capsuleList.concat([]);
newList[0] = { ...newList[0], title: '新标题' };
this.capsuleList = newList;

这是因为 @State 的变更检测基于引用相等性(即 === 比较)。修改数组内部对象的内容不改变数组本身的引用,因此框架无法检测到变化。

5.3 三种实用的数组更新模式

在本次开发中,我们总结出三种最常用的数组更新模式:

模式一:数组头部插入(新胶囊)

this.capsuleList = [newCapsule].concat(this.capsuleList);

模式二:触发渲染(修改元素属性后)

capsule.isOpened = true;
this.capsuleList = this.capsuleList.concat([]);
// concat([]) 返回一个新数组,元素是原数组的浅拷贝

模式三:数组过滤(删除胶囊)

this.capsuleList = this.capsuleList.filter(c => c.id !== capsuleId);
// filter() 本身就会返回一个新数组

5.4 | null 联合类型的使用

selectedCapsule 被声明为 TimeCapsule | null,这体现了 ArkTS 的类型安全性:

// 声明
@State selectedCapsule: TimeCapsule | null = null;

// 赋值(打开详情时)
this.selectedCapsule = capsule;  // 非空赋值

// 重置(关闭弹窗时)
this.selectedCapsule = null;

// 安全访问(在 Builder 中)
if (this.selectedCapsule !== null) {
  // 在这个分支中,编译器知道 selectedCapsule 不是 null
  Text(this.selectedCapsule.title)
}

ArkTS 的空值收窄(Null Narrowing)机制,让我们在 if 分支中安全地访问属性,而无需使用非空断言操作符 !

5.5 表单状态的临时性

创建胶囊的表单状态(newTitlenewMessagenewUnlockDate)具有临时性——它们只在弹窗打开期间有意义:

  • 打开弹窗时:重置为空字符串
  • 用户输入时:通过 onChange 回调更新
  • 提交时:读取这些值创建新胶囊
  • 关闭弹窗时:丢弃这些值(不需要清理,下次打开会重置)
openCreateDialog(): void {
  this.newTitle = '';
  this.newMessage = '';
  this.initDateRange();  // 重置日期
  this.showCreateDialog = true;
}

5.6 非响应式数据的管理

dataPreferences 被声明为 private 而非 @State,原因如下:

  • Preferences 对象不是 UI 状态,不应驱动渲染
  • Preferences 是重量级对象(包含文件句柄),不适合频繁响应式更新
  • 只在 loadCapsules()saveCapsules() 两个方法中使用

6. 数据持久化:Preferences 全解析

6.1 Preferences 简介

@ohos.data.preferences 是 HarmonyOS 提供的轻量级键值对存储方案,适用于存储应用的配置信息、用户偏好等小规模结构化数据。

特性 说明
数据模型 Key-Value,Key 为字符串,Value 可为 string/number/boolean
存储位置 应用沙箱内,其他应用无法访问
读写方式 异步 API(Promise/Callback)
持久化策略 主动 flush(写入磁盘)
适用场景 配置项、用户偏好、轻量级数据
不适场景 大量数据、二进制文件、关系型数据

6.2 完整生命周期

// 1. 获取 Preferences 实例(异步)
this.dataPreferences = await preferences.getPreferences(context, 'time_capsule_db');

// 2. 读取数据(异步)
let value = await this.dataPreferences.get('key', 'defaultValue');

// 3. 写入数据(异步)
await this.dataPreferences.put('key', 'value');

// 4. 刷入磁盘(异步,必须调用)
await this.dataPreferences.flush();

// 5. 删除数据
await this.dataPreferences.delete('key');

6.3 序列化与反序列化

由于 Preferences 只支持基本的 ValueType(string/number/boolean),我们需要将复杂对象(TimeCapsule[])序列化为 JSON 字符串:

保存(序列化)

async saveCapsules(): Promise<void> {
  try {
    if (this.dataPreferences) {
      let jsonStr = JSON.stringify(this.capsuleList);
      await this.dataPreferences.put(STORAGE_KEY, jsonStr);
      await this.dataPreferences.flush();
    }
  } catch (err) {
    console.error(`Failed to save: ${JSON.stringify(err)}`);
  }
}

加载(反序列化)

async loadCapsules(): Promise<void> {
  try {
    let context = getContext(this);
    this.dataPreferences = await preferences.getPreferences(context, 'time_capsule_db');
    let jsonValue = await this.dataPreferences.get(STORAGE_KEY, '');
    let jsonStr: string = jsonValue as string;
    if (jsonStr !== '') {
      let data = JSON.parse(jsonStr) as TimeCapsule[];
      if (data && data.length > 0) {
        this.capsuleList = data;
      }
    }
  } catch (err) {
    console.error(`Failed to load: ${JSON.stringify(err)}`);
  }
}

注意preferences.get() 的返回类型是 ValueType(即 string | number | boolean),而不是直接的 string。因此需要先将其断言为 string 类型,再传给 JSON.parse()

6.4 flush() 的重要性

Preferences 的 put() 操作默认是内存操作,数据不会立即写入磁盘。必须显式调用 flush() 方法才能将数据持久化到磁盘文件。

// ❌ 只 put 不 flush,App 被杀后数据丢失
await this.dataPreferences.put('key', 'value');

// ✅ put + flush,数据安全持久化
await this.dataPreferences.put('key', 'value');
await this.dataPreferences.flush();

性能优化:不需要每次 put 后都 flush。可以在连续多次 put 操作后调用一次 flush:

async saveAll() {
  await this.dataPreferences.put('key1', 'val1');
  await this.dataPreferences.put('key2', 'val2');
  // 只需一次 flush
  await this.dataPreferences.flush();
}

6.5 启动时加载策略

aboutToAppear() 是组件的生命周期函数,在组件创建时自动调用。我们将数据加载放在这里:

aboutToAppear(): void {
  this.initDateRange();
  this.loadCapsules();  // 异步加载,不阻塞 UI
}

由于 loadCapsules() 是异步方法,数据加载不会阻塞 UI 渲染。这意味着 App 启动时会先显示空列表(或默认 UI),等数据加载完成后自动更新列表。

6.6 退出时保存策略

aboutToDisappear() 在组件销毁时调用:

aboutToDisappear(): void {
  this.saveCapsules();
}

每次退出时保存,确保数据不会丢失。加上每次操作后的保存,形成双重保险


7. 声明式 Builder 语法深度解读

7.1 Builder 的本质

@Builder 是 ArkUI 中用于定义 UI 片段的装饰器。本质上,它将一个函数标记为"UI 构建函数",使其可以像普通组件一样在 build 方法中被调用。

@Builder
buildCapsuleCard(capsule: TimeCapsule) {
  Column() { /* UI 声明 */ }
}

// 在 build 中调用
this.buildCapsuleCard(capsule)

7.2 Builder 的严格约束

ArkTS 对 @Builder 方法施加了严格的语法约束:

允许的语法

  • UI 组件声明(Column、Text、Row、List 等)
  • 组件属性链式调用(.fontSize().width() 等)
  • if/else 条件渲染(条件内只能放 UI 组件)
  • ForEach 循环渲染
  • 调用其他 @Builder 方法

禁止的语法

  • let 变量声明
  • return 语句(除空返回外)
  • 对象字面量赋值
  • 非 UI 相关的函数调用

7.3 绕过 Builder 约束的两种模式

模式一:计算方法(Getter Methods)

将数据获取逻辑提取为普通成员方法,Builder 中只调用这些方法:

// ❌ 错误:Builder 内使用 let
@Builder
buildCapsuleCard(capsule: TimeCapsule) {
  let statusText = capsule.isOpened ? '已开启' : '未开启';  // ❌
  Text(statusText)
}

// ✅ 正确:提取为计算方法
getCapsuleStatusText(capsule: TimeCapsule): string {
  return capsule.isOpened ? '已开启' : '未开启';
}

@Builder
buildCapsuleCard(capsule: TimeCapsule) {
  Text(this.getCapsuleStatusText(capsule))  // ✅ 调用方法
}

模式二:if/else 条件分支

对于状态分支,使用 if/else 替代变量:

// ❌ 错误:用变量存储分支选择
@Builder
buildDetail() {
  let showContent = this.selectedCapsule !== null;  // ❌
  if (showContent) { /* UI */ }
}

// ✅ 正确:直接用条件
@Builder
buildDetail() {
  if (this.selectedCapsule !== null) {  // ✅
    /* UI */
  }
}

7.4 参数化 Builder 设计

Builder 支持参数传递,这使得 UI 片段可以复用:

@Builder
buildDeleteButton(capsuleId: number) {
  Row() {
    Text('\u{1F5D1}\uFE0F 删除')
      .fontSize(14)
      .fontColor(Color.White)
  }
  .width(80)
  .height('90%')
  .backgroundColor('#DC143C')
  .borderRadius(16)
  .justifyContent(FlexAlign.Center)
  .onClick(() => {
    this.deleteCapsule(capsuleId);
  })
}

ListswipeAction 中调用:

ListItem() {
  this.buildCapsuleCard(capsule)
}
.swipeAction({ end: this.buildDeleteButton(capsule.id) })

7.5 Builder 嵌套的注意事项

Builder 可以嵌套调用,但需要注意:

  1. 子 Builder 不能访问父 Builder 的局部变量(因为 Builder 内不能有局部变量)
  2. 子 Builder 通过参数接收数据
@Builder
buildDetailDialog() {
  Column() {
    if (this.selectedCapsule !== null) {
      if (this.selectedCapsule.isOpened) {
        this.buildOpenedCapsuleContent(this.selectedCapsule)  // 传参
      } else if (this.isCapsuleOpenable(this.selectedCapsule)) {
        this.buildOpenableCapsuleContent(this.selectedCapsule)
      } else {
        this.buildLockedCapsuleContent(this.selectedCapsule)
      }
    }
  }
}

@Builder
buildOpenedCapsuleContent(capsule: TimeCapsule) {
  // 通过参数 capsule 访问数据
  Text(capsule.title)
  Text(capsule.message)
}

8. UI 视觉设计:复古主题实现

8.1 色彩系统设计

我们选择了暖色调的复古配色方案,营造"泛黄信纸"的怀旧感:

基础背景 (#F5ECD7)  →  米白色,像陈年纸张
卡片背景 (#FFF8EC)  →  暖白色,比背景略亮
文字颜色 (#3D2B1F)  →  深棕色,柔和不刺眼
主色调 (#8B6B4A)    →  复古棕,按钮和强调
边框颜色 (#DEB887)  →  淡棕色,柔和的边界
锁定色 (#B8860B)    →  暗金色,锁的隐喻
解锁色 (#6B8E23)    →  橄榄绿,开放与生机

8.2 渐变背景实现

@Builder
buildBackground() {
  Column()
    .width('100%')
    .height('100%')
    .linearGradient({
      direction: GradientDirection.Bottom,
      colors: [
        ['#F5ECD7', 0],    // 顶部:米白
        ['#EDE0C8', 0.5],  // 中部:浅棕
        ['#E8D5B0', 1]     // 底部:深米色
      ]
    })
}

三层渐变模拟了纸张从中心到边缘的自然泛黄效果。

8.3 卡片设计

胶囊卡片使用白色背景 + 圆角 + 阴影,营造"纸质便签"的视觉:

Column() { /* 卡片内容 */ }
  .width('100%')
  .backgroundColor(COLORS.cardBg)
  .borderRadius(16)
  .borderWidth(1)
  .borderColor(this.getCapsuleBorderColor(capsule))
  .shadow({
    radius: 8,
    color: 'rgba(139, 107, 74, 0.10)',
    offsetY: 3
  })

8.4 弹窗的纸张质感

创建弹窗和详情弹窗采用类似的纸张风格,模拟"信纸"的视觉效果:

Column() { /* 弹窗内容 */ }
  .width('88%')
  .backgroundColor(COLORS.cardBg)
  .borderRadius(24)
  .borderWidth(1)
  .borderColor(COLORS.border)
  .shadow({
    radius: 30,
    color: 'rgba(61, 43, 31, 0.3)',
    offsetY: 10
  })

8.5 状态视觉区分

三种胶囊状态使用不同的视觉标识:

状态 边框颜色 状态图标 底部提示
已开启 橄榄绿 #6B8E2344 无提示
可开启 金色 #CD853F44 🔓 “点击开启 ✨”
未到期 淡棕 #DEB88788 🔒 “解锁日: 2026/12/31”

颜色 + 图标 + 文字的三重编码,确保即使色觉障碍用户也能清晰区分。

8.6 空状态设计

当没有任何胶囊时,显示友好的空状态引导:

📬 (大号图标,半透明)
还没有时间胶囊
点击右上角 "写一封" 创建
给未来的自己写点什么吧
✨——✨ (装饰分隔线)

9. 条件渲染与状态分支管理

9.1 胶囊卡片的三种状态

胶囊卡片是应用中最复杂的 UI 组件,需要根据三种状态显示不同的视觉元素:

// 状态分支逻辑:
// 1. isOpened === true        → 已开启
// 2. !isOpened && 已到解锁日  → 可开启
// 3. !isOpened && 未到解锁日  → 锁定

在 Builder 中,我们通过多种辅助方法处理这些分支:

getCapsuleStatusIcon(capsule: TimeCapsule): string {
  if (capsule.isOpened) return '\u{2705}';        // ✅
  if (Date.now() >= capsule.unlockAt) return '\u{1F513}'; // 🔓
  return '\u{1F512}';                              // 🔒
}

每个辅助方法处理一个视觉属性的分支逻辑,保持 Builder 的简洁。

9.2 详情弹窗的三重分支

详情弹窗根据胶囊状态渲染完全不同的内容:

buildDetailDialog()
├── selectedCapsule === null → 不渲染
└── selectedCapsule !== null →
    ├── isOpened === true →
    │   └── buildOpenedCapsuleContent()  → 显示信件内容
    ├── 已到解锁日 →
    │   └── buildOpenableCapsuleContent() → 显示"开启"按钮
    └── 未到解锁日 →
        └── buildLockedCapsuleContent()   → 显示锁定+倒计时

9.3 Builder 中容错处理

在 Builder 中,我们不能使用 if (!data) return; 的提前返回模式,必须用条件包裹 UI:

// ❌ 错误:Builder 中不能 return
@Builder
buildDetailDialog() {
  if (this.selectedCapsule === null) {
    return;  // ❌ 不允许
  }
  Column() { /* ... */ }
}

// ✅ 正确:用 if 包裹 UI
@Builder
buildDetailDialog() {
  if (this.selectedCapsule !== null) {
    Column() { /* ... */ }
  }
}

9.4 创建表单的校验

创建胶囊时的表单校验,我们在普通方法(非 Builder)中实现:

createCapsule(): void {
  // 校验:标题不为空
  if (this.newTitle.trim() === '') {
    return;
  }
  // 校验:内容不为空
  if (this.newMessage.trim() === '') {
    return;
  }
  // 校验:日期合法
  if (this.newUnlockDate < this.minDate) {
    return;
  }
  // 校验:解锁日必须在未来
  let unlockTimestamp = new Date(this.newUnlockDate).getTime();
  if (unlockTimestamp <= Date.now()) {
    return;
  }

  // 创建新胶囊
  let newCapsule: TimeCapsule = {
    id: Date.now(),
    title: this.newTitle.trim(),
    message: this.newMessage.trim(),
    createdAt: Date.now(),
    unlockAt: unlockTimestamp,
    isOpened: false
  };

  this.capsuleList = [newCapsule].concat(this.capsuleList);
  this.showCreateDialog = false;
  this.saveCapsules();
}

多层校验策略:我们同时使用前端约束(minDate 初始化为今天)和后端校验(提交时再次检查),确保数据完整性。


10. 编译错误全记录与解决方案

本节记录了在时间胶囊 App 开发中遇到的 17 个编译错误及其解决方案,涵盖对象类型、Builder 语法、数组操作、API 调用等常见问题。

10.1 对象字面量必须声明类型

错误

Object literal must correspond to some explicitly declared class or interface

场景

const COLORS = {  // ❌ 错误:没有类型声明
  primary: '#8B6B4A',
  // ...
};

解决:先定义接口,再声明变量:

interface ColorSet {
  primary: string;
  accent: string;
  // ...
}

const COLORS: ColorSet = {  // ✅ 正确
  primary: '#8B6B4A',
  // ...
};

10.2 展开运算符不可用

错误

It is possible to spread only arrays or classes derived from arrays

场景

this.capsuleList = [newCapsule, ...this.capsuleList];  // ❌
this.capsuleList = [...this.capsuleList];                // ❌

解决:使用 concat() 方法代替展开运算符:

this.capsuleList = [newCapsule].concat(this.capsuleList);  // ✅
this.capsuleList = this.capsuleList.concat([]);             // ✅ 触发渲染

10.3 Builder 中禁止变量声明

错误

Only UI component syntax can be written here.

场景:在 @Builder 方法中使用 let 声明局部变量。

解决:将数据提取逻辑移动到普通成员方法中:

// ❌ 错误
@Builder
buildCapsuleCard(capsule: TimeCapsule) {
  let statusIcon = capsule.isOpened ? '✅' : '🔒';
  Text(statusIcon)
}

// ✅ 正确
getCapsuleStatusIcon(capsule: TimeCapsule): string {
  return capsule.isOpened ? '✅' : '🔒';
}

@Builder
buildCapsuleCard(capsule: TimeCapsule) {
  Text(this.getCapsuleStatusIcon(capsule))
}

10.4 InputType.Date 不可用

错误

Property 'Date' does not exist on type 'typeof InputType'.

场景:尝试在 TextInput 上设置 .type(InputType.Date)

解决:移除该属性,使用普通文本输入。用户手动输入 YYYY-MM-DD 格式的日期。

背景InputType.Date 在部分 HarmonyOS SDK 版本中不存在。如果需要日期选择器,可以考虑使用 DatePicker 组件,或者等待 SDK 更新。

10.5 Preferences.get() 返回类型问题

错误

Argument of type 'ValueType' is not assignable to parameter of type 'string'.

场景preferences.get() 返回 Promise<ValueType>,而 ValueTypestring | number | boolean 的联合类型,不能直接赋值给 string

解决:使用中间变量 + 类型断言:

let jsonValue = await this.dataPreferences.get(STORAGE_KEY, '');
let jsonStr: string = jsonValue as string;  // ✅ 显式断言

10.6 Builder 参数数量不匹配

错误

Expected 2 arguments, but got 1.

场景:调用 @Builder 时传入的参数数量与定义不一致:

@Builder
buildLockedCapsuleContent(capsule: TimeCapsule, iconIndex: number) { } // 2 个参数

// 调用时只传 1 个
this.buildLockedCapsuleContent(this.selectedCapsule)  // ❌

解决:统一参数数量。由于 iconIndex 已通过 capsule.id % CAPSULE_ICONS.length 在 Builder 内计算,不需要作为参数传入,直接移除:

@Builder
buildLockedCapsuleContent(capsule: TimeCapsule) { }  // ✅ 1 个参数

10.7 错误总结

# 错误类型 根因 解决方案
1 arkts-no-untyped-obj-literals 对象字面量无类型 先定义 interface
2-3 arkts-no-spread 展开运算符不可用 使用 .concat()
4-16 Only UI component syntax @Builder 中含非 UI 语法 提取为计算方法
17 InputType.Date 不存在 API 不支持 移除该属性
18 ValueType 不可赋值 类型不匹配 显式类型断言
19-20 参数数量不匹配 Builder 参数未更新 统一参数签名

11. 总结与展望

11.1 完成功能回顾

通过本项目的开发,我们成功构建了一个完整的"时间胶囊"App:

功能模块 实现方案 关键代码量
数据模型 TimeCapsule 接口 ~10 行
颜色主题 ColorSet 接口 + 常量 ~20 行
胶囊列表 List + ForEach + swipeAction ~30 行
创建弹窗 模态覆盖 + 表单校验 ~100 行
详情弹窗 三重条件分支 Builder ~150 行
数据持久化 Preferences 序列化/反序列化 ~40 行
辅助方法 7 个获取状态的计算方法 ~60 行
总计 ~900 行

11.2 ArkUI 开发核心原则复盘

通过两款 App 的开发,我们总结了 ArkUI 开发的几条核心原则:

原则一:数据驱动 UI

UI = f(State)

@State 是 UI 的唯一数据源,UI 是状态的函数。永远不要手动操作 DOM。

原则二:Builder 是纯声明式

@Builder 方法体 = UI 组件声明 + 条件渲染 + 方法调用

不能在 Builder 中混入命令式逻辑(变量声明、提前返回、函数调用)。

原则三:方法提取优先

遇到条件逻辑 → 提取为计算方法
遇到数据获取 → 提取为计算方法
遇到复杂计算 → 提取为计算方法

计算方法让 Builder 保持干净,让逻辑可测试。

原则四:引用敏感

数组/对象变化 → 必须创建新引用 → @State 检测变化 → 触发渲染

这是 ArkTS 与 Vue/React 最大的不同——没有 Proxy 或 defineProperty 的自动追踪。

11.3 可扩展方向

当前版本已实现 MVP,未来可以扩展:

  1. DatePicker 组件:使用 HarmonyOS 的 DatePicker 替代手动输入的 TextInput,提供更好的日期选择体验
  2. 推送通知:集成 @ohos.reminderAgent,在解锁日到达时推送通知提醒用户
  3. 多胶囊同时解锁:支持批量开启所有到期的胶囊
  4. 密码保护:为重要胶囊设置查看密码
  5. 图片/语音胶囊:支持附加图片或录音,丰富内容形式
  6. iCloud 同步:接入华为帐号体系实现多设备同步
  7. 主题切换:提供多套配色主题(复古、简约、暗黑等)
  8. 分享功能:将胶囊内容生成为图片分享到社交平台

11.4 对 HarmonyOS 开发者的建议

  1. 拥抱声明式思维:不要试图用命令式的方式控制 UI,让数据驱动一切
  2. 理解编译期约束:ArkTS 的许多限制(如 Builder 语法、展开运算符)是为了编译期优化所做的权衡
  3. 善用计算方法:Builder 的约束不是限制,而是引导你将逻辑与 UI 分离
  4. 关注 API 版本差异:不同 API Level 的接口可能有差异,开发前查阅对应版本的 API 文档
  5. 数据持久化早规划:在设计数据模型时就考虑持久化方案,避免后期重构

附录 A:代码文件结构

entry/src/main/ets/pages/Index.ets
├── 导入区(1-4 行)
├── 类型定义(7-16 行)      → TimeCapsule 接口
├── 常量定义(18-55 行)     → STORAGE_KEY, ColorSet, COLORS, CAPSULE_ICONS
├── Index 组件(57-955 行)
│   ├── 状态变量(61-73 行)
│   ├── 生命周期(75-82 行)
│   ├── build() 方法(84-110 行)
│   ├── @Builder 方法(112-759 行)
│   │   ├── buildBackground()
│   │   ├── buildHeader()
│   │   ├── buildCapsuleList()
│   │   ├── buildEmptyState()
│   │   ├── buildCapsuleCard()
│   │   ├── buildDeleteButton()
│   │   ├── buildCreateDialog()
│   │   ├── buildDetailDialog()
│   │   ├── buildOpenedCapsuleContent()
│   │   ├── buildOpenableCapsuleContent()
│   │   └── buildLockedCapsuleContent()
│   └── 业务方法(761-955 行)
│       ├── getCapsuleStatus*() 系列
│       ├── isCapsuleOpenable()
│       ├── copyCapsule()
│       ├── openCreateDialog()
│       ├── openCapsuleDetail()
│       ├── createCapsule()
│       ├── openCapsule()
│       ├── deleteCapsule()
│       ├── loadCapsules()
│       ├── saveCapsules()
│       ├── formatDate() / formatDateTime()
│       └── getRemainingDays()

附录 B:状态变量对照表

变量名 类型 作用域 用途
capsuleList TimeCapsule[] 全局 所有胶囊数据
showCreateDialog boolean 弹窗 控制创建弹窗显示
showDetailDialog boolean 弹窗 控制详情弹窗显示
selectedCapsule `TimeCapsule null` 弹窗
newTitle string 表单 创建弹窗的标题输入
newMessage string 表单 创建弹窗的内容输入
newUnlockDate string 表单 创建弹窗的日期输入
minDate string 表单 日期约束(今天)
dataPreferences `Preferences null` 持久化

附录 C:常见错误码速查

错误码 含义 典型场景
10505001 类型不匹配 / 不存在 API 方法不存在、参数数量错误
10605038 无类型对象字面量 未声明 interface 直接定义对象
10605099 展开运算符不可用 使用 [...arr] 语法
10905209 Builder 中非 UI 语法 @Builder 内含 let 声明

本文由 AtomCode 基于 HarmonyOS Next API 24 编写,记录了时间胶囊 App 的完整开发过程,涵盖数据持久化、声明式 UI、状态管理等核心技术点。希望对 HarmonyOS 应用开发者有所帮助。

(全文完,约 12000 字)

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐