
-
个人首页: VON
-
鸿蒙系列专栏: 鸿蒙开发小型案例总结
-
综合案例 :鸿蒙综合案例开发
-
鸿蒙6.0:从0开始的开源鸿蒙6.0.0
-
鸿蒙5.0:鸿蒙5.0零基础入门到项目实战
-
Electron适配开源鸿蒙专栏:Electron for OpenHarmony
-
Flutter 适配开源鸿蒙专栏:Flutter for OpenHarmony
-
本文所属专栏:鸿蒙综合案例开发
-
本文atomgit地址:小V健身
小V健身助手开发手记(三)
- 用成就点燃坚持------构建可视化激励系统
-
- [🎯 成就系统的业务目标](#🎯 成就系统的业务目标)
- [🧱 架构设计:数据模型与组件拆分](#🧱 架构设计:数据模型与组件拆分)
- [🖼️ 视觉呈现:从数据到情感](#🖼️ 视觉呈现:从数据到情感)
-
- [1. 顶部进度环 ------ `AchievementTop`](#1. 顶部进度环 ——
AchievementTop) - [2. 成就徽章网格 ------ `AchievementContent`](#2. 成就徽章网格 ——
AchievementContent)
- [1. 顶部进度环 ------ `AchievementTop`](#1. 顶部进度环 ——
- [🔁 数据驱动:如何计算"连续达成天数"?](#🔁 数据驱动:如何计算“连续达成天数”?)
- [🎨 设计细节与用户体验](#🎨 设计细节与用户体验)
- [🛠️ 工程亮点总结](#🛠️ 工程亮点总结)
- [✅ 结语](#✅ 结语)
- 代码总结

用成就点燃坚持------构建可视化激励系统
在健康类应用中,用户流失率高是一个普遍难题。研究表明,持续的行为激励是提升用户长期活跃度的关键。为此,我们在「小V健身助手」中引入了「成就系统」,通过可视化徽章、进度反馈与目标达成提示,将枯燥的运动记录转化为一场充满成就感的游戏化旅程。
本篇将详解如何基于 ArkTS 与 HarmonyOS 的声明式 UI 能力,构建一个轻量、可扩展且富有情感温度的成就页面,并探讨其背后的设计逻辑与工程实践。
🎯 成就系统的业务目标
我们的成就模块并非简单堆砌图标,而是围绕三个核心目标设计:
- 正向反馈:让用户清晰看到"我做到了什么";
- 行为引导:通过阶梯式目标(如连续3天、7天、30天)鼓励长期坚持;
- 情感连接:用温暖的视觉语言传递"你很棒"的肯定。
为此,我们将成就分为两类:
- 短期成就:如"今日完成3项任务";
- 长期成就:如"连续7天达成目标"。
当前版本先聚焦连续达成天数这一最直观的长期指标。
🧱 架构设计:数据模型与组件拆分
整个成就页面由两个核心组件构成:
| 组件 | 职责 |
|---|---|
AchievementTop |
展示当日卡路里消耗进度与目标差距 |
AchievementContent |
渲染成就徽章列表 |
同时引入两个 ViewModel 类,实现数据与视图解耦:
ts
// AchievementInfo.ts
export default class AchievementInfo {
days: number; // 达成所需天数
icOn: ResourceStr; // 已解锁图标
icOff: ResourceStr; // 未解锁图标
}
ts
// AchievementMapInfo.ts
export default class AchievementMapInfo {
off_3 = $r('app.media.ic_achieve1_off');
on_3 = $r('app.media.ic_achieve1_on');
// 后续可扩展:off_7, on_7, off_30, on_30...
}
💡 设计哲学:图标资源通过映射类集中管理,便于未来动态加载或 A/B 测试。
🖼️ 视觉呈现:从数据到情感
1. 顶部进度环 ------ AchievementTop
ts
Progress({
value: this.value, // 当前消耗(如1500千卡)
total: this.task, // 目标值(如3000千卡)
type: ProgressType.Ring
})
.color('#ff2727')
.backgroundColor('#362423')
.width('50%')
.height('95%')
.style({ strokeWidth: 25 })

- 使用 环形进度条 直观展示完成比例;
- 颜色对比强烈(红 vs 深棕),突出"未完成"的紧迫感;
- 文字辅助说明:"距离目标还差1500千卡",强化目标意识。
✅ 交互细节 :日期区域可点击,调出
DateDialog切换查看历史成就------与首页保持一致体验。
2. 成就徽章网格 ------ AchievementContent
ts
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) {
ForEach(this.success, (item: AchievementInfo) => {
Column() {
Image(this.successDays >= item.days ? item.icOn : item.icOff)
.width('100%')
.height(88)
.objectFit(ImageFit.Contain)
Text(`已经${item.days}天达成目标`)
.fontSize(12)
.fontColor(Color.White)
}
.width('33%')
})
}

- 采用
Flex + wrap实现自适应网格布局(3列); - 图标根据
successDays(当前连续达成天数)动态切换亮/灰状态; - 文案统一为"已经X天达成目标",降低认知负担。
⚠️ 注意 :当前代码中所有
AchievementInfo均使用相同的days=3和图标,仅为演示。实际应配置不同天数(3/7/15/30...)。
🔁 数据驱动:如何计算"连续达成天数"?
目前 successDays 是硬编码的 @State successDays: number = 9,但在真实场景中,它应由以下逻辑生成:
ts
// 伪代码:计算连续达成天数
function calculateStreak(tasks: Task[]): number {
let streak = 0;
const today = new Date();
for (let i = 0; i < 30; i++) { // 最多回溯30天
const date = new Date(today);
date.setDate(today.getDate() - i);
if (isDayCompleted(date, tasks)) {
streak++;
} else {
break; // 中断即终止
}
}
return streak;
}
该函数需结合本地存储的每日任务完成记录进行判断。未来我们将:
- 在
data_preferences或关系型数据库中持久化每日完成状态; - 在每日首次打开 App 时触发
calculateStreak; - 将结果写入
AppStorage,供成就页实时读取。
🎨 设计细节与用户体验
| 元素 | 设计考量 |
|---|---|
| 深色背景 | 营造"荣誉殿堂"氛围,突出徽章 |
| 白色文字 | 高对比度,确保可读性 |
| 图标尺寸统一 | 避免视觉混乱,强调平等价值 |
| 文案简洁 | 不解释规则,只陈述事实,降低认知负荷 |
此外,我们预留了扩展空间:
AchievementMapInfo可轻松添加新成就图标;AchievementInfo支持任意天数配置;- 未来可加入"点亮动画"、"成就通知"等增强反馈。
🛠️ 工程亮点总结
-
声明式 UI + 状态驱动
所有视觉变化均由
@State和条件渲染自动更新,无需手动操作 DOM。 -
资源引用标准化
通过
$r('app.media.xxx')统一管理媒体资源,支持多分辨率适配。 -
组件复用与职责分离
AchievementTop可独立用于其他页面(如个人中心),AchievementContent专注成就展示。 -
可测试性
AchievementInfo与AchievementMapInfo为纯数据类,便于单元测试。
✅ 结语
成就系统不是锦上添花的装饰,而是用户坚持下去的"精神燃料"。在「小V健身助手」中,我们试图用一行行 ArkTS 代码,传递一句无声的鼓励:"你今天的努力,值得被记住。"
而这,正是技术与人文交汇之处。
代码已通过 HarmonyOS SDK API Version 10+ 验证,适用于 Stage 模型项目。
代码总结
本次主要集中在成就页面的编写

achieve
AchievementContent
ts
import AchievementInfo from "../../viewmodel/AchievementInfo"
import AchievementMapInfo from "../../viewmodel/AchievementMapInfo"
import AchievementTop from "./AchievementTop";
@Component
export default struct AchievementContent{
achievementMapInfo: AchievementMapInfo = new AchievementMapInfo()
@State successDays : number = 9
@State success: Array<AchievementInfo> = [
new AchievementInfo(3, this.achievementMapInfo.on_3, this.achievementMapInfo.off_3),
new AchievementInfo(3, this.achievementMapInfo.on_3, this.achievementMapInfo.off_3),
new AchievementInfo(3, this.achievementMapInfo.on_3, this.achievementMapInfo.off_3),
new AchievementInfo(3, this.achievementMapInfo.on_3, this.achievementMapInfo.off_3),
new AchievementInfo(3, this.achievementMapInfo.on_3, this.achievementMapInfo.off_3),
new AchievementInfo(3, this.achievementMapInfo.on_3, this.achievementMapInfo.off_3),
new AchievementInfo(3, this.achievementMapInfo.on_3, this.achievementMapInfo.off_3),
new AchievementInfo(3, this.achievementMapInfo.on_3, this.achievementMapInfo.off_3),
new AchievementInfo(3, this.achievementMapInfo.on_3, this.achievementMapInfo.off_3),
new AchievementInfo(3, this.achievementMapInfo.on_3, this.achievementMapInfo.off_3),
new AchievementInfo(3, this.achievementMapInfo.on_3, this.achievementMapInfo.off_3),
new AchievementInfo(3, this.achievementMapInfo.on_3, this.achievementMapInfo.off_3),
new AchievementInfo(3, this.achievementMapInfo.on_3, this.achievementMapInfo.off_3),
];
build() {
Column(){
Row(){AchievementTop()}.height('40%')
Column(){
Row(){
Text('成就')
.fontSize(20)
.fontWeight(500)
.fontColor(Color.White)
}
.width('95%')
.margin({top:25})
Column({space:10}){
Flex({direction:FlexDirection.Row,wrap:FlexWrap.Wrap}){
ForEach(this.success,(item:AchievementInfo)=>{
Column(){
Image(this.successDays >= item.days ? item.icOn : item.icOff)
.width('100%')
.height(88)
.objectFit(ImageFit.Contain)
Text(`已经${item.days}天达成目标`)
.lineHeight(16)
.fontSize(12)
.fontColor(Color.White)
}
.width('33%')
.padding({top:38,bottom:10})
})
}
}
}
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
}
}
AchievementTop
ts
import DateDialog from "../../dialog/DateDialog"
import DateUtil from "../../util/DateUtil"
@Extend(Text)
function textStyle(color:ResourceStr,fw:number | FontWeight){
.fontSize(20)
.fontWeight(fw)
.fontColor(color)
}
@Component
export default struct AchievementTop{
@StorageProp('date') date: number = DateUtil.beginTimeOfDay(new Date())
controller:CustomDialogController = new CustomDialogController({
builder: DateDialog({date:new Date(this.date)})
})
@State value:number = 1500
@State task:number = 3000
build() {
Column(){
// 前两行
Column({space:5}){
// 头部日期部分
Text(DateUtil.formatDate(this.date))
.fontSize(25)
.fontColor('#b50f0f0f')
.onClick(()=>{
this.controller.open()
})
Text('健身记录')
.textStyle('#fff',800)
}
.width('95%')
.margin({top:30,bottom:10})
.alignItems(HorizontalAlign.Start)
Row(){
// 左侧文本
Column({space:5}){
Text('运动消耗')
.textStyle('#c2c2bf',500)
Text(this.value + '/' + this.task + '千卡')
.textStyle('#85ccb7',800)
.margin({bottom:20,right:10})
Text('距离目标')
.textStyle('#ccc',500)
Text(this.task - this.value + '千卡')
.textStyle('#a03336',800)
.margin({right:10})
}
.alignItems(HorizontalAlign.Start)
.margin({left:5})
// 右侧进度展示
Progress({
value:this.value,
total:this.task,
type:ProgressType.Ring
})
.color('#ff2727')
.backgroundColor('#362423')
.width('50%')
.height('95%')
.style({strokeWidth:25})
}
.backgroundColor('#2c2c2a')
.width('95%')
.height('190')
.borderRadius(15)
}
.width('100%')
.height('100%')
}
}
viewmodel
AchievementInfo
ts
export default class AchievementInfo{
days: number;
icOn:ResourceStr;
icOff:ResourceStr;
constructor(days:number,icOn:ResourceStr,icOff:ResourceStr) {
this.days = days;
this.icOn = icOn;
this.icOff = icOff;
}
}
AchievementMapInfo
ts
export default class AchievementMapInfo {
off_3 : ResourceStr = $r('app.media.ic_achieve1_on');
on_3 : ResourceStr = $r('app.media.ic_achieve1_on');
}