引子:列表是应用的"记忆"

一个完整的应用还需要"记住"用户做过什么。骰子应用的历史记录页面就承担了这个职责——它把每次投掷的结果保存下来,让用户可以回顾。

完整效果
在这里插入图片描述

HistoryTab 的代码结构

先看历史记录页面的核心代码:

@Builder HistoryTab() {
  Column() {
    Row() {
      Text('历史').fontSize(FontSize.title).fontColor(this.gc().text)
        .fontWeight(FontWeight.Bold)
      Blank()
      if (this.history.length > 0) {
        Text('清空').fontSize(FontSize.sm).fontColor('#EF4444')
          .onClick(() => this.clr())
      }
    }.width('100%').padding({ left: Spacing.md, right: Spacing.md, top: Spacing.lg, bottom: Spacing.sm })

    if (this.history.length === 0) {
      Column() {
        Text('📜').fontSize(48)
        Text('还没有投掷记录').fontSize(FontSize.md).fontColor(this.gc().text2)
      }.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
    } else {
      List() {
        ForEach(this.history, (r: RollResult) => {
          ListItem() {
            Row() {
              Text(DICE_TYPES.find((d: DiceType) => d.key === r.diceType)?.emoji || '🎲').fontSize(22)
              Column() {
                Text((DICE_TYPES.find((d: DiceType) => d.key === r.diceType)?.label || '') + ' ×' + String(r.results.length))
                  .fontSize(FontSize.sm).fontColor(this.gc().text)
                Text('[' + r.results.join(', ') + ']').fontSize(FontSize.xs)
                  .fontColor(this.gc().text2).maxLines(1)
              }.margin({ left: Spacing.sm }).alignItems(HorizontalAlign.Start)
              Blank()
              Column() {
                Text(String(r.total)).fontSize(FontSize.lg).fontColor(this.gc().accent)
                  .fontWeight(FontWeight.Bold)
                Text(this.fmt(r.rolledAt)).fontSize(FontSize.xs).fontColor(this.gc().text2)
              }
            }.width('100%').padding(Spacing.md).backgroundColor(this.gc().surface)
              .borderRadius(BorderRadius.md)
              .margin({ bottom: Spacing.sm, left: Spacing.md, right: Spacing.md })
          }
        })
      }.width('100%').layoutWeight(1).scrollBar(BarState.Off)
    }
  }.width('100%').height('100%')
}

在这里插入图片描述

这段代码虽然不长,但包含了几个重要的设计模式:空状态处理、列表渲染、数据格式化、批量操作。

空状态设计:用户体验的细节

当历史记录为空时,页面显示一个友好的提示:

if (this.history.length === 0) {
  Column() {
    Text('📜').fontSize(48)
    Text('还没有投掷记录').fontSize(FontSize.md).fontColor(this.gc().text2)
  }.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
}

在这里插入图片描述

空状态设计是用户体验的重要细节。很多开发者觉得"空就空呗,有什么好显示的",但其实空状态有三个作用:

  1. 解释现状:告诉用户"这里没有数据",而不是让用户猜测是不是出了 bug
  2. 引导操作:暗示用户"去投掷几次骰子吧"
  3. 视觉平衡:避免页面大面积空白,保持视觉完整性

这个应用的空状态设计还不错,但可以更进一步——加一个按钮引导用户去投掷:

if (this.history.length === 0) {
  Column() {
    Text('📜').fontSize(48)
    Text('还没有投掷记录').fontSize(FontSize.md).fontColor(this.gc().text2)
      .margin({ top: Spacing.sm })
    Button('去投掷骰子')
      .fontSize(FontSize.md).fontColor('#FFFFFF')
      .backgroundColor(this.gc().primary).borderRadius(BorderRadius.xl)
      .margin({ top: Spacing.md })
      .onClick(() => this.tab = 'dice')  // 切换到骰子 Tab
  }.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
}

在这里插入图片描述

这样用户看到空状态后,可以一键跳转到骰子页面,而不是手动点击底部 Tab。

列表渲染:ForEach vs List

代码中用了 List + ForEach 的组合:

List() {
  ForEach(this.history, (r: RollResult) => {
    ListItem() { ... }
  })
}.width('100%').layoutWeight(1).scrollBar(BarState.Off)

这里有两个关键点:

1. 为什么用 List 而不是 Column?

如果用 Column + ForEach,所有列表项都会被渲染,即使用户只看前几项。当数据量大时,这会导致:

  • 初始渲染慢:需要创建所有列表项的 UI 元素
  • 内存占用高:所有列表项都占用内存
  • 滚动卡顿:滚动时需要重新布局所有元素

List 组件支持懒加载,只渲染用户可见的列表项。当用户滚动时,List 会自动创建新的列表项,销毁不可见的列表项。这样无论数据量多大,渲染的列表项数量都是固定的(大约 10-20 个),性能稳定。

2. ForEach 的 key 参数

ForEach 的第二个参数是 item generator,第三个参数是 key generator(可选)。代码中没有提供 key,这在数据量小时没问题,但当数据量大时可能导致渲染问题。

比如用户删除了一条记录,ForEach 会重新渲染整个列表。如果没有稳定的 key,ArkUI 可能会复用错误的列表项,导致显示异常。建议添加 key:

ForEach(this.history, (r: RollResult, index: number) => {
  ListItem() { ... }
}, (r: RollResult, index: number) => index.toString())

用索引作为 key 虽然不是最优解(理想情况应该用唯一 ID),但比没有 key 好。

数据格式化:时间戳的处理

代码中用 fmt 方法格式化时间戳:

private fmt(ts: number): string {
  const d = new Date(ts);
  return (d.getMonth() + 1) + '/' + d.getDate() + ' ' 
    + String(d.getHours()).padStart(2, '0') + ':' 
    + String(d.getMinutes()).padStart(2, '0');
}

这个方法把时间戳格式化为 "M/D HH:MM" 的格式,比如 "7/17 19:35"

为什么不用 toLocaleString?

JavaScript 和 ArkTS 都提供了 toLocaleString() 方法,可以自动根据用户 locale 格式化日期。但代码没有用它,原因是:

  1. 一致性toLocaleString() 的输出格式取决于系统设置,不同设备可能不一样
  2. 可控性:手动格式化可以精确控制输出格式
  3. 性能toLocaleString() 涉及 locale 计算,性能略差

对于这个应用,手动格式化是合理的选择。但要注意,getMonth() 返回的月份是从 0 开始的(0-11),所以需要加 1。

更好的时间格式化方案

手动拼接字符串容易出错,建议用工具函数:

function formatTime(timestamp: number): string {
  const d = new Date(timestamp);
  const month = d.getMonth() + 1;
  const day = d.getDate();
  const hours = String(d.getHours()).padStart(2, '0');
  const minutes = String(d.getMinutes()).padStart(2, '0');
  return `${month}/${day} ${hours}:${minutes}`;
}

这样代码更清晰,也更容易维护。如果将来要改格式(比如加年份),只需修改这一个函数。

数据查找:DICE_TYPES.find() 的性能

代码中多次用 DICE_TYPES.find() 来查找骰子类型:

Text(DICE_TYPES.find((d: DiceType) => d.key === r.diceType)?.emoji || '🎲')

find() 方法会遍历数组,直到找到匹配的元素。对于 7 个元素的 DICE_TYPES 数组,性能完全没问题。但如果数组很大(比如几百个元素),频繁调用 find() 可能有性能问题。

优化方案是用 Map:

const DICE_MAP = new Map<string, DiceType>();
DICE_TYPES.forEach((d: DiceType) => DICE_MAP.set(d.key, d));

// 使用
Text(DICE_MAP.get(r.diceType)?.emoji || '🎲')

Map 的查找是 O(1) 复杂度,比 find() 的 O(n) 快很多。但在这个应用中,性能差异可以忽略不计,用 find() 就够了。

数据持久化:DiceDatabase 的设计

DiceDatabase 类封装了底层的存储逻辑。虽然看不到完整代码,但从使用方式可以推断出它的接口设计:

class DiceDatabase {
  async init(): Promise<void> { ... }
  add(result: RollResult): void { ... }
  getHistory(): RollResult[] { ... }
  async clear(): Promise<void> { ... }
}

Preferences API vs 关系型数据库

对于这种简单的键值对存储,HarmonyOS 的 Preferences API 就够了。Preferences 适合存储少量配置数据,API 简单,性能好。但如果要支持复杂查询(比如按日期筛选、统计分析),建议用关系型数据库 RDB。

特性Preferences关系型数据库 (RDB)
数据结构键值对表、行、列
查询能力按 key 获取SQL 查询
性能读写快复杂查询慢
适用场景配置、少量数据大量结构化数据
数据上限约 1MB无硬性限制

在这个应用中,历史记录可能会增长到几百条。如果用 Preferences,每次获取历史记录都需要读取整个数据集,性能会下降。而 RDB 支持分页查询,可以只获取最近的 N 条记录。

数据同步策略

代码中每次投掷后都会调用 this.db.getHistory() 重新获取历史列表:

if (this.db) {
  this.db.add(createRollResult(this.diceType, fr));
  this.history = this.db.getHistory();
}

这种"写后读"的策略简单可靠,但不是最优的。更好的方案是:

if (this.db) {
  const newResult = createRollResult(this.diceType, fr);
  this.db.add(newResult);
  this.history = [newResult, ...this.history];  // 直接更新状态
}

这样不需要重新从数据库读取,直接把新结果添加到列表开头。但要注意,这种方式假设 add() 一定成功。如果 add() 失败,状态和数据库就不一致了。需要权衡一致性和性能。

数据清空的确认

代码中点击"清空"按钮会直接清空所有历史记录:

if (this.history.length > 0) {
  Text('清空').fontSize(FontSize.sm).fontColor('#EF4444')
    .onClick(() => this.clr())
}

这很危险——用户可能误触。建议添加确认弹窗:

private showClearConfirm: boolean = false;

// 在 UI 中
AlertDialog({
  title: '确认清空',
  message: '确定要清空所有历史记录吗?此操作不可恢复。',
  primaryButton: {
    value: '取消',
    action: () => { this.showClearConfirm = false; }
  },
  secondaryButton: {
    value: '清空',
    fontColor: '#EF4444',
    action: () => {
      this.clr();
      this.showClearConfirm = false;
    }
  }
})

这样用户误触时还有机会取消,避免数据丢失。

列表项的布局分析

每个历史记录项的布局是这样的:

┌─────────────────────────────────┐
│ 🎲  六面骰 ×2          14  7/17│
│      [3, 11]              19:35│
└─────────────────────────────────┘

左边是骰子图标和类型信息,右边是总和和时间。这种"左信息、右数值"的布局在移动端很常见,适合展示概要信息。

代码用 Row + Blank() 来实现左右分布:

Row() {
  // 左侧:图标和类型信息
  Text(emoji).fontSize(22)
  Column() { ... }
  
  Blank()  // 占据中间空间
  
  // 右侧:总和和时间
  Column() { ... }
}

Blank() 是 ArkUI 的弹性空白组件,会占据剩余空间。这种布局方式比手动计算宽度更灵活,也更容易适配不同屏幕尺寸。

列表项的点击交互

当前代码没有给列表项添加点击事件。如果要查看详情或删除单条记录,需要添加 onClick

Row() { ... }
.width('100%').padding(Spacing.md)
.backgroundColor(this.gc().surface)
.borderRadius(BorderRadius.md)
.onClick(() => this.showDetail(r))  // 添加点击事件

但要注意,列表项的点击事件和 List 组件的滚动事件可能会冲突。如果点击响应不灵敏,可能需要调整点击区域或增加长按事件。

性能优化:大数据量的处理

当历史记录增长到几百甚至上千条时,性能优化就变得重要了。几个优化方向:

1. 分页加载

不要一次性加载所有历史记录,而是分页加载:

@State page: number = 1;
@State pageSize: number = 20;
@State allHistory: RollResult[] = [];

private loadMore(): void {
  const start = (this.page - 1) * this.pageSize;
  const end = start + this.pageSize;
  const newItems = this.db.getHistoryRange(start, end);
  this.allHistory = [...this.allHistory, ...newItems];
  this.page++;
}

List 组件的 onReachEnd 事件中触发加载更多:

List() {
  ForEach(this.allHistory, ...)
}.onReachEnd(() => this.loadMore())

2. 虚拟列表

ArkUI 的 List 组件默认支持虚拟列表,只渲染可见区域的元素。但需要确保 ListItem 的高度是固定的或可预测的,否则虚拟列表的效果会打折扣。

3. 图片和 Emoji 的渲染

代码中用了 Emoji 作为图标(🎲、📜 等)。Emoji 的渲染比图片快,但有些设备可能不支持某些 Emoji。如果要兼容更多设备,建议用图片资源代替 Emoji。

总结

历史记录页面虽然功能简单,但涉及了 ArkUI 开发的多个核心知识点:列表渲染、空状态设计、数据格式化、数据持久化、性能优化。在实际开发中,这些知识点会反复出现,只是复杂度不同。

适用边界:这个页面适合用作 ArkUI 列表渲染和数据持久化的入门案例,涵盖了 List 组件、ForEach 渲染、空状态设计、时间格式化等核心知识点。但如果要上架应用商店,还需要补充分页加载、删除确认、详情查看、数据导出等功能。建议在此基础上逐步扩展,而不是一次性做完所有功能。

对于 ArkTS 新手,建议从类似的小项目入手,逐步理解框架的设计哲学。ArkTS 的声明式 UI 和 React 有相似之处,但状态管理和生命周期有明显区别,需要花时间适应。

对于有经验的开发者,重点是理解 ArkTS 的约束——它不是 TypeScript 的简单扩展,而是一个有自己规则的框架。遵循框架的最佳实践,才能写出可维护、高性能的代码。

Logo

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

更多推荐