鸿蒙原生应用实战(四):成就系统与排行榜开发 --- 数据展示与交互进阶
前言
经过前三篇的开发,我们的数独游戏已经具备了完整的游戏体验。本篇将为游戏添加激励机制------成就系统和排行榜。这两个页面将显著提升用户粘性和游戏趣味性。
本篇重点:
- 成就系统的数据结构设计与分类筛选
- 进度条组件的 ArkTS 实现
- 排行榜的多难度切换
- 列表渲染性能优化
- 条件渲染的最佳实践
一、成就系统 --- AchievementsPage
成就系统是游戏化设计的核心。我们设计了 12 个成就,分为 3 个类别。
1.1 数据结构设计
typescript
interface Achievement {
id: number; // 唯一标识
title: string; // 成就名称
description: string; // 成就描述
icon: string; // 表情图标
unlocked: boolean; // 是否已解锁
progress: number; // 当前进度
maxProgress: number; // 目标进度
category: string; // 分类
}
1.2 12 个成就的完整设计
成就分为三大类:
局数成就(5个):鼓励持续游戏
| 成就 | 条件 | 难度 |
|---|---|---|
| 🎯 初次挑战 | 完成第1局 | ⭐ |
| 🔰 小试牛刀 | 完成10局 | ⭐⭐ |
| ⭐ 渐入佳境 | 完成50局 | ⭐⭐⭐ |
| 🌟 数独达人 | 完成100局 | ⭐⭐⭐⭐ |
| 🔥 坚持不懈 | 连续7天每日挑战 | ⭐⭐⭐ |
速度成就(3个):鼓励快速解题
| 成就 | 条件 | 难度 |
|---|---|---|
| ⚡ 闪电手 | 简单5分钟内完成 | ⭐⭐ |
| 💨 快如风 | 中等10分钟内完成 | ⭐⭐⭐ |
| 🏆 极速传说 | 困难15分钟内完成 | ⭐⭐⭐⭐ |
特殊成就(4个):鼓励挑战自我
| 成就 | 条件 | 难度 |
|---|---|---|
| 💎 完美无缺 | 不使用提示完成一局 | ⭐⭐⭐ |
| 🎯 零失误 | 不擦除完成一局 | ⭐⭐⭐⭐ |
| 📝 笔记大师 | 笔记模式下完成一局 | ⭐⭐ |
| 👑 全通挑战 | 完成全部难度每日挑战 | ⭐⭐⭐⭐⭐ |
1.3 计算属性
typescript
// 按分类筛选
get filteredAchievements(): Achievement[] {
if (this.selectedCategory === 0) return this.achievements;
return this.achievements.filter(
a => a.category === this.categories[this.selectedCategory]
);
}
// 已解锁计数
get unlockedCount(): number {
let count = 0;
for (let a of this.achievements) {
if (a.unlocked) count++;
}
return count;
}
1.4 进度条组件详解
进度条是用 ArkTS 的 overlay 特性实现的:
typescript
Row() {
Column()
.width('100%') // 灰色背景条(满宽)
.height(8)
.backgroundColor('#FFF0F0F0')
.borderRadius(4)
.overlay(() => {
Column()
.width((this.unlockedCount / this.achievements.length) * 100 + '%') // 百分比宽度
.height(8)
.backgroundColor($r('app.color.primary'))
.borderRadius(4)
})
}
overlay 是 ArkTS 的一个强大特性------它可以让一个组件"叠加"在另一个组件之上,且不改变布局流。这里相当于:底层是灰色背景条,上层是动态宽度的彩色填充条。
1.5 分类标签栏
typescript
@State selectedCategory: number = 0;
private categories: string[] = ['全部', '局数成就', '速度成就', '特殊成就'];
Row() {
ForEach(this.categories, (cat: string, index: number) => {
Text(cat)
.fontSize($r('app.float.small_font_size'))
.fontColor(this.selectedCategory === index ? Color.White : $r('app.color.text_primary'))
.backgroundColor(this.selectedCategory === index ? $r('app.color.primary') : $r('app.color.background'))
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(16)
.margin({ right: 4 })
.onClick(() => { this.selectedCategory = index; })
}, (cat: string) => cat)
}
这种胶囊标签设计在移动应用中很流行:
borderRadius(16)产生胶囊形状- 点按时切换
selectedCategory,触发 UI 刷新 ForEach的第三个参数是键值生成器,用于列表 diff 优化
1.6 成就列表渲染
typescript
List() {
ForEach(this.filteredAchievements, (ach: Achievement) => {
ListItem() {
Column() {
Row() {
Text(ach.icon).fontSize(36)
.opacity(ach.unlocked ? 1 : 0.3)
Column() {
Text(ach.title)
.fontColor(ach.unlocked ? $r('app.color.text_primary') : $r('app.color.text_hint'))
Text(ach.description)
.fontSize($r('app.float.small_font_size'))
.fontColor($r('app.color.text_hint'))
.margin({ top: 2 })
}
.margin({ left: 12 })
.layoutWeight(1)
Text(ach.unlocked ? '✓' : '🔒')
.fontColor(ach.unlocked ? $r('app.color.status_delivered') : $r('app.color.text_hint'))
}
.width('100%')
// 未解锁显示进度条
if (!ach.unlocked) {
Row() { /* 进度条 */ }.width('100%').margin({ top: 8 })
Text('进度: ' + ach.progress + '/' + ach.maxProgress)
.fontSize(11)
.fontColor($r('app.color.text_hint'))
}
}
.padding(16)
.backgroundColor($r('app.color.card_bg'))
.borderRadius($r('app.float.card_corner_radius'))
}
.padding({ left: 16, right: 16, top: 4, bottom: 4 })
}, (ach: Achievement) => ach.id.toString())
}
.width('100%')
.layoutWeight(1)
1.7 条件渲染详解
ArkTS 的条件渲染使用 if 指令,与 TypeScript/JavaScript 类似但语法略有不同:
typescript
if (!ach.unlocked) {
// 这些内容只在未解锁时渲染
Row() { /* progress bar */ }
Text('进度: ' + ach.progress + '/' + ach.maxProgress)
}
要点:
if直接在组件树中使用,不需要大括号包裹- 条件不满足时,内部组件树完全不会创建(不是隐藏)
- 这比 CSS 的
display: none更高效
已完成 vs 未完成视觉对比:
| 状态 | 图标透明度 | 标题颜色 | 右侧标记 | 额外内容 |
|---|---|---|---|---|
| 已解锁 | 1(正常) | 深色 | 绿色 ✓ | 无 |
| 未解锁 | 0.3(半透明) | 灰色 | 🔒 | 进度条 + 进度文字 |
二、排行榜 --- LeaderboardPage
2.1 数据结构
typescript
interface RankEntry {
rank: number; // 排名
name: string; // 玩家名称
difficulty: string; // 难度
time: string; // 用时 (MM:SS)
}
2.2 分难度数据管理
三种难度各有 5 条排名数据,通过 selectedDifficulty 索引切换:
typescript
@State selectedDifficulty: number = 0;
private difficulties: string[] = ['简单', '中等', '困难'];
@State easyRanks: RankEntry[] = [
{ rank: 1, name: '张三', difficulty: '简单', time: '03:25' },
{ rank: 2, name: '李四', difficulty: '简单', time: '04:10' },
{ rank: 3, name: '王五', difficulty: '简单', time: '05:48' },
{ rank: 4, name: '赵六', difficulty: '简单', time: '06:32' },
{ rank: 5, name: '陈七', difficulty: '简单', time: '08:15' }
];
// mediumRanks / hardRanks 类似...
get currentRanks(): RankEntry[] {
if (this.selectedDifficulty === 0) return this.easyRanks;
if (this.selectedDifficulty === 1) return this.mediumRanks;
return this.hardRanks;
}
2.3 排名显示的高级技巧
前三名用奖杯表情,其余显示数字:
typescript
Text(entry.rank <= 3
? ['🥇', '🥈', '🥉'][entry.rank - 1]
: entry.rank.toString()
)
.fontSize(20)
.width(40)
.textAlign(TextAlign.Center)
这个一行的三元表达式实现了:第 1 名 🥇 → 第 2 名 🥈 → 第 3 名 🥉 → 第 4+ 名数字。
2.4 排行榜完整列表项
typescript
ListItem() {
Row() {
// 排名(奖杯或数字)
Text(entry.rank <= 3 ? ['🥇', '🥈', '🥉'][entry.rank - 1] : entry.rank.toString())
.fontSize(20)
.width(40)
.textAlign(TextAlign.Center)
// 玩家信息
Column() {
Text(entry.name)
.fontSize($r('app.float.body_font_size'))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text(entry.difficulty)
.fontSize($r('app.float.small_font_size'))
.fontColor($r('app.color.text_hint'))
}
.margin({ left: 12 })
Blank()
// 用时
Column() {
Text($r('app.string.rank_time'))
.fontSize($r('app.float.small_font_size'))
.fontColor($r('app.color.text_hint'))
Text(entry.time)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.primary'))
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding($r('app.float.padding_medium'))
.backgroundColor($r('app.color.card_bg'))
.borderRadius($r('app.float.card_corner_radius'))
}
布局分析 :每一行由三段组成------左侧排名(固定宽度 40vp)、中间玩家信息(弹性空间)、右侧用时(右对齐),Blank() 把左右两部分撑开。
2.5 难度标签切换
typescript
Row() {
ForEach(this.difficulties, (diff: string, index: number) => {
Text(diff)
.fontSize($r('app.float.body_font_size'))
.fontWeight(this.selectedDifficulty === index ? FontWeight.Bold : FontWeight.Regular)
.fontColor(this.selectedDifficulty === index ? Color.White : $r('app.color.text_primary'))
.backgroundColor(this.selectedDifficulty === index ? $r('app.color.primary') : $r('app.color.background'))
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
.borderRadius(20)
.onClick(() => { this.selectedDifficulty = index; })
.margin({ right: 8 })
}, (diff: string) => diff)
}
这里比成就系统的标签多了一个 fontWeight 变化------选中时加粗,视觉反馈更明显。
三、ForEach 与列表渲染优化
3.1 ForEach 的第三个参数:键值生成器
typescript
ForEach(
this.filteredAchievements, // 数据源
(item: Type) => { /* 渲染函数 */ }, // 渲染逻辑
(item: Type) => item.id.toString() // 键值生成器
)
第三个参数键值生成器非常重要:
- 帮助 ArkTS 框架进行高效的列表 diff 比较
- 当数据变化时,只重新渲染变化的项,而不是整个列表
- 键值应该是唯一且稳定 的,一般用
id.toString()
3.2 List + ListItem 的性能优势
为什么用 List + ListItem 而不是 Column + ForEach?
typescript
// ✅ 推荐:List + ListItem(虚拟滚动)
List() {
ForEach(items, (item) => {
ListItem() { /* 内容 */ }
})
}
// ❌ 不推荐:Column + ForEach(全量渲染)
Column() {
ForEach(items, (item) => {
Column() { /* 内容 */ }
})
}
List 组件支持虚拟滚动------只渲染屏幕可见区域内的列表项,数据量大时性能优势极其明显。我们的成就列表只有 12 项,差异不明显,但在排行榜扩展到 50+ 项时就能感受到差距。
四、@State 的角色------数据驱动 UI
在全篇中,所有页面的交互都遵循"数据驱动 UI"的模式:
用户操作 → 修改 @State 变量 → 框架自动更新 UI
这个模式的核心在于:
- 声明式:你声明 UI 和数据的关系,不需要手动操作 DOM
- 不可变触发 :
@State变量引用变化时会触发重渲染 - 最小更新:框架只更新变化的部分
在我们的成就系统中:
typescript
@State selectedCategory: number = 0;
// 用户点击分类标签
.onClick(() => { this.selectedCategory = index; })
// 模板中自动响应
ForEach(this.filteredAchievements, ...) // filteredAchievements 依赖 selectedCategory
修改 selectedCategory → filteredAchievements 重新计算 → ForEach 比较新旧列表 → 仅更新变化的列表项。全链路自动完成。
五、页面路由导航
两个页面都实现了统一的导航方式:
成就页面进入:目前通过路由直接跳转,可在首页或游戏中添加入口按钮:
typescript
router.pushUrl({ url: 'pages/AchievementsPage' });
排行榜页面:已在首页底部提供入口:
typescript
Row() {
Text('🏆 ')
Text($r('app.string.title_leaderboard'))
}
.onClick(() => { router.pushUrl({ url: 'pages/LeaderboardPage' }); })
返回上一页:两个页面都实现了统一的返回按钮:
typescript
Button('←')
.fontSize(22)
.fontColor($r('app.color.text_primary'))
.backgroundColor(Color.Transparent)
.onClick(() => { router.back(); })
六、完整页面效果
AchievementsPage 功能清单
| 功能 | 实现方式 | 交互效果 |
|---|---|---|
| 进度总览 | unlockedCount 计算属性 + overlay 进度条 |
顶部卡片展示已解锁/总数 |
| 分类筛选 | selectedCategory @State + filter |
4 个胶囊标签切换 |
| 成就列表 | List + ForEach | 虚拟滚动渲染 |
| 解锁/未解锁状态 | 条件渲染 if + 视觉差异 | 半透明/彩色图标,锁定/对勾 |
| 进度显示 | 进度条 + 百分比文字 | 未解锁时显示进度追踪 |
LeaderboardPage 功能清单
| 功能 | 实现方式 | 交互效果 |
|---|---|---|
| 难度切换 | selectedDifficulty @State + 标签栏 |
三档难度切换 |
| 排名列表 | List + ForEach | 奖杯排名 + 玩家信息 + 用时 |
| 视觉效果 | 三元表达式选择奖杯表情 | 前三名奖杯,其他显示数字 |

七、小结与预告
本篇我们完成了:
- ✅ 成就系统(12 个成就、3 分类、进度追踪)
- ✅ 排行榜(3 难度、5 排名、奖杯显示)
- ✅ 条件渲染(if)、列表性能优化(List + 键值)
- ✅ 数据驱动 UI 的完整模式
目前所有数据都是静态的。在实际应用中,应该使用 PersistentStorage 或数据库来持久化数据,确保成就进度和排行榜数据在应用重启后不丢失。
最后一篇我们将开发教程页面 和自定义主题功能,并回顾整个项目的架构设计与优化方向。敬请期待!