小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');
}
相关推荐
Nan_Shu_6146 小时前
学习:Vue (2)
javascript·vue.js·学习
狮恒7 小时前
OpenHarmony Flutter 分布式音视频:跨设备流传输与实时协同交互方案
分布式·flutter·wpf·openharmony
YJlio7 小时前
桌面工具学习笔记(11.1):BgInfo——给服务器桌面“刻”上关键信息
服务器·笔记·学习
TL滕7 小时前
从0开始学算法——第十五天(滑动窗口)
笔记·学习·算法
失败才是人生常态7 小时前
并发编程场景题学习
学习
醇氧7 小时前
springAI学习 一
学习·spring·ai·ai编程
菜鸟‍7 小时前
【论文学习】Co-Seg:互提示引导的组织与细胞核分割协同学习
人工智能·学习·算法
狮恒8 小时前
OpenHarmony Flutter 分布式安全与隐私保护:跨设备可信交互与数据防泄漏方案
分布式·flutter·wpf·openharmony
YJlio8 小时前
Active Directory 工具学习笔记(10.14):第十章·实战脚本包——AdExplorer/AdInsight/AdRestore 一键化落地
服务器·笔记·学习