HarmonyOS时光胶囊——记忆洗牌的随机展示与交互设计
记忆洗牌是时光胶囊里最有仪式感的功能——用户点击🎲按钮,系统随机抽取一个历史胶囊展示。用户不知道下一张会显示哪个胶囊,这种"随机重逢"的设计让回顾历史变成了拆盲盒式的体验。这个页面的代码量不大,但随机算法、卡片翻转、空状态处理都有值得拆解的细节。
完整效果

一、记忆洗牌的入口
两个入口位置
| 入口 | 位置 | 触发方式 |
|---|---|---|
| 标题栏 🎲 | 时间线 Tab 右上角 | 点击跳转 |
| 设置页 | 设置 Tab 列表项 | 点击跳转 |
// 入口 1:标题栏
Text('🎲').fontSize(20).width(40).height(40)
.onClick(() => this.goToShuffle())
// 入口 2:设置页
Row() {
Text('🎲 记忆洗牌').fontSize(FontSize.md)
Blank()
Text('›').fontSize(FontSize.xxl).fontColor(this.getColors().textSecondary)
}
.onClick(() => this.goToShuffle())
两个入口指向同一个页面——但使用场景不同。 标题栏入口适合"我想随机看看"的冲动式操作,设置页入口适合"我找找这个功能"的探索式操作。两种用户行为都有对应的入口。
路由跳转
private goToShuffle(): void {
router.pushUrl({ url: 'pages/MemoryShuffle' })
}

没有传参数——MemoryShuffle 自己从数据库获取所有胶囊。 和详情页不同(传 capsuleId),洗牌页需要的是"全部胶囊"的数据,不是某一个。所以不需要路由参数,页面自己查询。
二、随机算法的设计
Fisher-Yates 洗牌算法
// 推断的洗牌算法
private shuffleArray(array: Capsule[]): Capsule[] {
const result = array.slice() // 复制原数组
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
const temp = result[i]
result[i] = result[j]
result[j] = temp
}
return result
}
Fisher-Yates 是标准的均匀洗牌算法——保证每种排列出现的概率相等。 slice() 复制原数组——不修改数据库里的顺序。
为什么不直接用 Math.random() 排序
// 错误做法:按 random 排序
array.sort(() => Math.random() - 0.5)
sort(() => Math.random() - 0.5) 不是均匀洗牌——它有 bias。 某些排列出现的概率比其他排列高。Fisher-Yates 保证均匀性——每个胶囊出现在每个位置的概率都是 1/n。
洗牌队列的管理
private queue: Capsule[] = [] // 洗牌队列
private currentIndex: number = 0 // 当前展示位置
private initQueue(): void {
this.queue = this.shuffleArray(this.database.getAllCapsules())
this.currentIndex = 0
}
洗牌一次,顺序全部确定——不是每次都随机取一个。 用户点击"下一张"时,按队列顺序展示下一个。这保证了:1)同一次洗牌不会重复展示同一个胶囊;2)所有胶囊都会被展示一遍。
三、卡片展示的三种状态
从 CapsuleState 推断
// 导入
import { getCapsuleState, CapsuleState } from '../model/Capsule'
洗牌展示的卡片需要区分胶囊状态:
| 状态 | 展示方式 | 交互 |
|---|---|---|
| SEALED | 🔒 + 倒计时 | 显示"这个胶囊还在封印中" |
| READY | 显示心情 + 引导开启 | 可以点击开启 |
| OPENED | 显示内容 + 心情 + 时间 | 完整展示 |
封印中胶囊的特殊处理
// 推断的封印中展示
Column() {
Text('🔒').fontSize(64)
Text('这个胶囊还在封印中')
.fontSize(FontSize.md).fontColor(this.getColors().textSecondary)
Text('距离开启还有 ' + this.countdownText(capsule.unlockAt))
.fontSize(FontSize.sm).fontColor(this.getColors().primary)
}
洗到封印中的胶囊不是"失败"——而是"提醒"。 它告诉用户"你还有一个胶囊在等待",增加期待感。这比只展示已开启的胶囊更有意义——随机性包括了"还没到时间"的情况。
四、卡片的视觉设计
大卡片模式
// 推断的洗牌卡片结构
Column() {
// 心情 emoji
Text(capsule.mood).fontSize(72).margin({ bottom: Spacing.lg })
// 内容
Text(capsule.content)
.fontSize(FontSize.lg).fontColor(this.getColors().text)
.lineHeight(28).textAlign(TextAlign.Center)
.margin({ bottom: Spacing.lg })
// 标签
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
ForEach(capsule.tags, (tag: string) => {
Text('#' + tag).fontSize(FontSize.sm).fontColor(this.getColors().primary)
.margin({ right: Spacing.xs, bottom: Spacing.xs })
})
}
// 时间信息
Text(this.formatDate(capsule.createdAt))
.fontSize(FontSize.xs).fontColor(this.getColors().textSecondary)
.margin({ top: Spacing.md })
}
.width('100%').padding(Spacing.xl)
.backgroundColor(this.getColors().surface)
.borderRadius(BorderRadius.lg)

和 CapsuleCard 的对比
| 特性 | CapsuleCard(列表) | 洗牌卡片 |
|---|---|---|
| 尺寸 | 小卡片,紧凑 | 大卡片,宽松 |
| 内容 | 截取前50字 | 完整内容 |
| 心情 | 小 emoji(20px) | 大 emoji(72px) |
| 信息密度 | 高(多字段) | 低(聚焦内容) |
| 用途 | 快速浏览 | 沉浸阅读 |
洗牌卡片的核心是"沉浸感"——大字号、大 emoji、大间距。 用户不需要快速扫描信息,而是坐下来慢慢读一篇"来自过去的信"。
五、翻页交互
下一张按钮
// 推断的翻页 UI
Row() {
Text('🎲 再来一张')
.fontSize(FontSize.md).fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
}
.width('100%').height(48)
.justifyContent(FlexAlign.Center)
.backgroundColor(this.getColors().primary)
.borderRadius(BorderRadius.lg)
.onClick(() => this.nextCard())
nextCard 的逻辑
private nextCard(): void {
this.currentIndex++
if (this.currentIndex >= this.queue.length) {
// 队列用完,重新洗牌
this.initQueue()
}
}
队列用完后自动重新洗牌——用户可以无限刷。 不会出现"没有更多了"的死胡同。重新洗牌时顺序重新随机——用户不会看到完全相同的顺序。
进度指示
// 推断的进度显示
Text(`${this.currentIndex + 1} / ${this.queue.length}`)
.fontSize(FontSize.xs).fontColor(this.getColors().textSecondary)
显示"3 / 12"格式——告诉用户当前是第几张,总共多少张。 进度感让用户知道"还剩多少没看",控制浏览节奏。
六、空状态处理
没有胶囊时的洗牌页
if (this.queue.length === 0) {
Column() {
Text('🔮').fontSize(64).margin({ bottom: Spacing.md })
Text('还没有胶囊可洗')
.fontSize(FontSize.lg).fontColor(this.getColors().text)
.fontWeight(FontWeight.Bold).margin({ bottom: Spacing.sm })
Text('先去封印几个胶囊吧')
.fontSize(FontSize.sm).fontColor(this.getColors().textSecondary)
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
}
和首页空状态同样的设计模式——图标+标题+引导文字。 但没有"开始封印"按钮——洗牌页是独立页面,跳转回首页再切到封印 Tab 比直接改 currentTab 更合理(因为 currentTab 是首页的状态,洗牌页无法直接修改)。
只有一个胶囊时
只有一个胶囊也能洗——洗牌结果就是它自己。 不需要特殊处理——Fisher-Yates 对单元素数组不做任何交换,直接返回原数组。
七、记忆洗牌的心理设计
随机性的价值
| 模式 | 用户心理 | 适用场景 |
|---|---|---|
| 时间线(有序) | “我想找某个特定胶囊” | 有目的的回顾 |
| 洗牌(随机) | “我想看看命运给我什么” | 无目的的偶遇 |
随机性创造了"命运感"——用户觉得是"命运让我看到这个胶囊",而不是"我选择了这个胶囊"。 这种心理让回顾历史变得更有仪式感——就像翻开一副随机抽到的塔罗牌。
为什么洗牌比列表更有温度
列表是"我管理数据"的心态——用户像管理员一样浏览记录。洗牌是"我和记忆对话"的心态——用户像读者一样接收来自过去的信。产品设计的目标是后者——时光胶囊不是一个数据库管理工具,它是一个情感载体。
八、页面的嵌套结构
Column 页面容器
├→ Row (标题栏) 返回按钮 + 标题
├→ Column (内容区)
│ ├→ 洗牌卡片 心情 + 内容 + 标签
│ ├→ 进度指示 "3 / 12"
│ └→ 下一张按钮 "🎲 再来一张"
└→ 底部信息 创建时间
嵌套深度 3 层——最浅的页面之一。 因为洗牌页只有一个核心功能——展示一张卡片。不需要复杂布局,Column 纵向排列就够了。
九、与其他功能的数据关系
洗牌页的数据来源
Database.getAllCapsules()
→ shuffleArray() 洗牌
→ queue[] 洗牌队列
→ queue[currentIndex] 当前展示
洗牌页不修改数据库——它是只读的。 用户在洗牌页看到胶囊后,如果想开启,需要跳转到详情页操作。洗牌页只负责"展示",不负责"操作"。
从洗牌到详情的跳转
// 推断:点击洗牌卡片跳转详情
.onClick(() => {
setCurrentCapsuleId(capsule.id)
router.pushUrl({
url: 'pages/CapsuleDetail',
params: { capsuleId: capsule.id }
})
})
洗到一个胶囊后,用户可以点进去看详情。 跳转逻辑和首页完全一致——setCurrentCapsuleId + router.pushUrl。复用同一套路由模式。
十、洗牌功能的扩展可能
当前版本
- 随机展示所有胶囊
- 封印中胶囊特殊展示
- 队列用完自动重新洗牌
- 点击跳转详情页
未来可能的扩展
| 扩展方向 | 实现方式 | 价值 |
|---|---|---|
| 按心情筛选 | queue = capsules.filter(c => c.mood === ‘😊’) | “我想看开心的回忆” |
| 按时间筛选 | queue = capsules.filter(c => c.createdAt > oneWeekAgo) | “我想看最近的回忆” |
| 分享卡片 | Canvas 截图 + 分享 API | 把回忆分享给朋友 |
| 每日推送 | 每天随机展示一个胶囊 | 被动回忆,增加打开率 |
当前版本足够简单——但架构为扩展留了空间。 只需要改 initQueue() 的筛选逻辑,就能实现按心情/时间筛选。不需要改页面 UI——卡片展示逻辑不变。
更多推荐

所有评论(0)