小V健身助手开发手记(三):用成就点燃坚持——构建可视化激励系统

小V健身助手开发手记(三)

  • 用成就点燃坚持------构建可视化激励系统
    • [🎯 成就系统的业务目标](#🎯 成就系统的业务目标)
    • [🧱 架构设计:数据模型与组件拆分](#🧱 架构设计:数据模型与组件拆分)
    • [🖼️ 视觉呈现:从数据到情感](#🖼️ 视觉呈现:从数据到情感)
      • [1. 顶部进度环 ------ `AchievementTop`](#1. 顶部进度环 —— AchievementTop)
      • [2. 成就徽章网格 ------ `AchievementContent`](#2. 成就徽章网格 —— AchievementContent)
    • [🔁 数据驱动:如何计算"连续达成天数"?](#🔁 数据驱动:如何计算“连续达成天数”?)
    • [🎨 设计细节与用户体验](#🎨 设计细节与用户体验)
    • [🛠️ 工程亮点总结](#🛠️ 工程亮点总结)
    • [✅ 结语](#✅ 结语)
    • 代码总结

用成就点燃坚持------构建可视化激励系统

在健康类应用中,用户流失率高是一个普遍难题。研究表明,持续的行为激励是提升用户长期活跃度的关键。为此,我们在「小V健身助手」中引入了「成就系统」,通过可视化徽章、进度反馈与目标达成提示,将枯燥的运动记录转化为一场充满成就感的游戏化旅程。

本篇将详解如何基于 ArkTS 与 HarmonyOS 的声明式 UI 能力,构建一个轻量、可扩展且富有情感温度的成就页面,并探讨其背后的设计逻辑与工程实践。


🎯 成就系统的业务目标

我们的成就模块并非简单堆砌图标,而是围绕三个核心目标设计:

  1. 正向反馈:让用户清晰看到"我做到了什么";
  2. 行为引导:通过阶梯式目标(如连续3天、7天、30天)鼓励长期坚持;
  3. 情感连接:用温暖的视觉语言传递"你很棒"的肯定。

为此,我们将成就分为两类:

  • 短期成就:如"今日完成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 支持任意天数配置;
  • 未来可加入"点亮动画"、"成就通知"等增强反馈。

🛠️ 工程亮点总结

  1. 声明式 UI + 状态驱动

    所有视觉变化均由 @State 和条件渲染自动更新,无需手动操作 DOM。

  2. 资源引用标准化

    通过 $r('app.media.xxx') 统一管理媒体资源,支持多分辨率适配。

  3. 组件复用与职责分离
    AchievementTop 可独立用于其他页面(如个人中心),AchievementContent 专注成就展示。

  4. 可测试性
    AchievementInfoAchievementMapInfo 为纯数据类,便于单元测试。


✅ 结语

成就系统不是锦上添花的装饰,而是用户坚持下去的"精神燃料"。在「小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');
}
相关推荐
西岸行者6 天前
学习笔记:SKILLS 能帮助更好的vibe coding
笔记·学习
悠哉悠哉愿意6 天前
【单片机学习笔记】串口、超声波、NE555的同时使用
笔记·单片机·学习
别催小唐敲代码6 天前
嵌入式学习路线
学习
毛小茛6 天前
计算机系统概论——校验码
学习
babe小鑫6 天前
大专经济信息管理专业学习数据分析的必要性
学习·数据挖掘·数据分析
winfreedoms6 天前
ROS2知识大白话
笔记·学习·ros2
在这habit之下6 天前
Linux Virtual Server(LVS)学习总结
linux·学习·lvs
我想我不够好。6 天前
2026.2.25监控学习
学习
im_AMBER6 天前
Leetcode 127 删除有序数组中的重复项 | 删除有序数组中的重复项 II
数据结构·学习·算法·leetcode
CodeJourney_J6 天前
从“Hello World“ 开始 C++
c语言·c++·学习