ArkTS × 心理树洞:情绪健康应用实战开发

一款基于 HarmonyOS ArkTS 开发的心理健康陪伴应用,记录每一种情绪,守护每一颗心灵。


运行截图:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

一、引言:为什么做一款心理树洞

在节奏越来越快的都市生活中,焦虑、压抑、疲惫这些"看不见的伤口"往往比感冒更普遍。世界卫生组织曾发布过一组数据:全球约有 10 亿人正在遭受不同程度的精神健康困扰,但真正走进专业心理咨询机构的人不足两成。剩下的大部分人,要么不知道去哪里求助,要么觉得"自己的问题不够严重,不值得麻烦别人"。

与此同时,SAS(焦虑自评量表)、SDS(抑郁自评量表)、PSQI(匹兹堡睡眠质量指数)、PSS(压力知觉量表)等专业心理测评量表虽然权威且免费,但在日常生活中却面临两个尴尬:其一,这些量表大多散落在医院官网、心理学教材的 PDF 中,普通用户根本不知道去哪里找;其二,即使做完了,结果也只是"轻度焦虑"几个字,没有长期跟踪,也没有可视化,做完就忘,无法形成自我认知的闭环。

正是基于这两点观察,萌生了做"心理树洞"这款应用的想法。它不试图替代专业咨询,而是想做用户和"自我情绪"之间的一座桥梁:

  • 打开应用,三秒钟内,你就能记录下此刻的心情;
  • 做一次心理测评,系统会自动把分数归档,形成"我的测评档案";
  • 连续记录一周以上,应用会用柱状图告诉你"你的情绪健康指数在往上走还是往下走";
  • 所有数据本地保存,不依赖网络,也不上传服务器,守住心理数据最敏感的隐私底线。

“心理树洞"这个名字,寓意也很简单:树洞是一个可以倾诉秘密的地方,我们希望这款应用也能成为都市青年随身携带的"情绪树洞”——你可以向它倾诉、记录、回望,但绝不会有人窥探。

本文不是一份简单的开发教程,而是希望从产品定位、技术选型、数据建模、视觉设计、关键组件实现、状态管理、问题排查、未来规划等多个维度,完整复盘一次 HarmonyOS ArkTS 应用的开发心路,既给 ArkTS 新手一份可参考的入门笔记,也给心理类应用的 UI/UX 设计提供一些可借鉴的思路。


二、产品定位与功能拆解

在动手写代码之前,先把产品边界画清楚。一款"小而美"的心理健康应用,需要满足三类核心诉求。

第一,快速记录。心理学研究表明,情绪在出现后的 30 分钟内最容易被准确识别;一旦超过 24 小时,记忆就会开始失真,各种"事后合理化"会污染原始感受。所以,产品经理和设计师需要围绕"3 秒打卡"这个目标做极致优化——打开应用、点选情绪、看到反馈,整个流程应该不超过三次点击。心理树洞首页的 Grid 情绪卡片正是为此而生:6 个情绪 + emoji + 颜色,不需要输入文字,不需要选择标签,只要点一下,就完成了一次"情绪打卡"。

第二,回溯数据。情绪本身没有好坏,但情绪的"模式"有规律。一个经常焦虑的人,如果能直观看到自己"过去 30 天有 22 天处于焦虑或疲惫状态",往往会受到强烈的视觉冲击,从而主动寻求改变。心理树洞的 List 测评记录区,就是把过去做过的心理测评以时间倒序排列,每条记录包含量表名、得分、结果解读、对应情绪类型,信息密度刚好满足"扫一眼就能回忆起当时的状态"。

第三,趋势感知。把单日情绪得分折算成 0~100 的"情绪健康指数",连成 7 天的曲线或柱状图,用户就能直观感受到"整体状态是平稳、上升还是下降"。这种"小趋势"远比"我今天心情不好"更有说服力——它把模糊的感受量化成具体的数字,把抽象的情绪转译为可比较的曲线,让"自我觉察"从玄学变成科学。

围绕这三个诉求,我们把首页划分为四大功能区:

  • 顶部 Header——渐变 Banner 配上周统计(本周测评次数、当前情绪指数、连续记录天数),三组数据一眼看全;
  • Grid 情绪选择区——6 个情绪卡片(开心/平静/焦虑/悲伤/愤怒/疲惫),3 列排布,点击有选中态与 Toast 反馈,完成"3 秒打卡";
  • List 测评记录——展示过去 5 条心理测评记录(SAS/SDS/PSS/PSQI 等),支持侧滑删除,信息清晰;
  • List 情绪趋势——顶部一根 7 格柱状图,下方 7 行每日详情(emoji、日期、进度条、分数),右上角还有"较前日 ±N 分"的小标签。

整个应用只有一个主页面,没有复杂的路由跳转,这也是 ArkTS + HarmonyOS 单页应用最擅长的领域。如果未来要扩展"测评详情页""心理百科页"等,直接用 router.pushUrl 跳转即可,首页的 4 个 Section 也能保持稳定不动。


三、技术选型与开发环境

在正式开始编码之前,先确定技术栈。心理树洞的技术选型可以归纳为下表:

项目 选型 说明
操作系统 HarmonyOS NEXT / API 12+ 鸿蒙原生应用,体验更流畅
开发语言 ArkTS 基于 TypeScript 扩展的 UI 语言
UI 范式 声明式 UI + 状态驱动 与 React/Vue 心智一致
构建工具 Hvigor 6.1.1 鸿蒙官方构建系统
目标 SDK 6.1.1(24) 兼容主流鸿蒙机型
包管理 ohpm 鸿蒙官方包管理器
IDE DevEco Studio 5.0+ 鸿蒙官方开发工具

为什么选 ArkTS?

ArkTS 在保留 TypeScript 类型系统的基础上,提供了 struct 装饰器、@State / @Prop / @Link / @Provide / @Consume 状态管理、@Builder 模板函数、@Extend 样式扩展、@Styles 通用样式等面向 UI 的扩展。它让前端开发者可以用熟悉的"声明式 + 函数式"思路,快速上手 HarmonyOS 原生应用开发。相比传统的 Java/JS FA 模型,ArkTS 的状态驱动模式更接近 React/Vue,心智成本几乎为零。

更具体地说,ArkTS 有几个特别打动我的设计:

  • 强类型:TypeScript 的类型系统可以在编译期捕获大部分错误,避免运行时崩溃;
  • 状态装饰器:@State 自动追踪依赖、@Builder 复用 UI 片段、@Watch 监听状态变化,非常顺手;
  • 资源化:所有颜色、字符串、尺寸都通过 $r('app.color.xxx') 引用,天然支持多端、多语言、深色模式适配;
  • 生态一致:HarmonyOS 提供的相机、定位、推送、文件、数据库等能力,ArkTS 都能以一致的方式调用。

为什么不上后端?

本项目刻意保持"纯前端 + 本地数据"的形态。所有数据通过 models/EmotionModel.ets 中的模拟数组提供,既方便调试,也尊重心理数据最敏感的隐私属性。

这背后还有一个工程考量:演示型应用应该聚焦核心交互,而不是把精力耗费在网络请求、数据缓存、鉴权这些周边能力上。先让 UI 跑起来,让交互跑通,后续接入云端只是把"内存数组"换成"HTTP 接口 + 本地缓存",UI 层完全不需要修改。


四、项目目录结构解析

理解目录结构,是看懂一个鸿蒙应用的起点。心理树洞的工程目录遵循 HarmonyOS 标准的"AppScope + entry"双层结构:

ArkTSMentalVant/
├── AppScope/                       # 应用级配置
│   ├── app.json5                   # 应用元信息(bundleName/版本号/图标)
│   └── resources/                  # 应用级字符串与图标
│       └── base/element/string.json
├── entry/                          # 主模块(HAP)
│   ├── src/main/
│   │   ├── ets/
│   │   │   ├── entryability/       # 入口 Ability
│   │   │   │   └── EntryAbility.ets
│   │   │   ├── entrybackupability/ # 备份 Ability
│   │   │   ├── models/             # 数据模型
│   │   │   │   └── EmotionModel.ets
│   │   │   └── pages/              # 页面文件
│   │   │       └── Index.ets       # 主页面
│   │   ├── resources/base/element/ # 模块资源
│   │   │   ├── color.json
│   │   │   ├── float.json
│   │   │   └── string.json
│   │   └── module.json5            # 模块清单
│   ├── src/ohosTest/               # 测试代码
│   ├── src/test/                   # 单元测试
│   ├── build-profile.json5         # 模块构建配置
│   └── oh-package.json5            # 模块依赖
├── build-profile.json5             # 工程级构建配置
├── code-linter.json5               # 代码规范
├── hvigorfile.ts                   # Hvigor 任务入口
└── oh-package.json5                # 工程依赖

4.1 AppScope 与 entry 的关系

简单理解,AppScope 是"应用",entry 是"模块"。一个鸿蒙应用可以包含多个 entry 模块(比如手机、平板、手表可以共用 AppScope 配置,但各自有独立的 entry 模块)。本项目只有一个 entry 模块,主要承载 UI 页面。

4.2 ets 目录的约定

ets 是 HarmonyOS 的"Extended TypeScript"目录,所有 ArkTS 源码都放在这里,DevEco Studio 会自动编译其中的 .ets 文件。子目录的划分遵循官方推荐:

  • entryability/ 放 Ability(可以理解为"页面容器");
  • pages/ 放具体的页面文件,本项目的主页就是 Index.ets;
  • models/ 是我们自定义的目录,用于放置数据模型——这种"业务子目录"是允许的,只要符合 ArkTS 的模块导入规则即可。

4.3 resources 资源目录

resources/base 存放默认资源(浅色模式、中文、标清),resources/dark 存放深色模式资源,resources/en_US 存放英文资源。本项目为简化,只用了 base/element 下的 color.jsonstring.json。如果未来要支持深色模式,只需在 resources/dark/element/color.json 中提供同名色值,ArkTS 会自动切换。


五、数据模型设计

数据是应用的"血液"。我们在 EmotionModel.ets 中定义了三种核心数据结构,它们之间的关系可以用一句话概括:一个情绪类型,可以被多条测评记录引用,也可以被多条趋势数据引用

5.1 情绪类型枚举

export enum EmotionType {
  HAPPY = 'happy',
  CALM = 'calm',
  ANXIOUS = 'anxious',
  SAD = 'sad',
  ANGRY = 'angry',
  TIRED = 'tired'
}

用字符串字面量作为枚举值,有两个考虑:

  1. 序列化稳定性:未来把数据存到 Preferences 或云端时,字符串值不会因为枚举重排而错位;
  2. 可读性:在日志或调试时,看到 'anxious' 比看到 2 直观得多。

5.2 情绪状态类

@Observed
export class Emotion {
  type: EmotionType;
  name: string;
  emoji: string;
  color: ResourceColor;

  constructor(type: EmotionType, name: string, emoji: string, color: ResourceColor) {
    this.type = type;
    this.name = name;
    this.emoji = emoji;
    this.color = color;
  }
}

注意这里的 color: ResourceColor,它允许我们直接传入 $r('app.color.emotion_happy') 这种 Resource 引用,而不是写成十六进制字符串。这样做的好处是:如果未来需要切换深色模式,只要在 dark/element/color.json 中提供同名色值,ArkTS 就会自动选择对应模式的颜色,完全不需要在代码里写 if (darkMode) 之类的判断。

@Observed 装饰器则声明这是一个"可观察对象",配合 @ObjectLink 使用时,即使对象的内部属性变化也能触发 UI 更新。本项目目前没用到嵌套修改,但保留这个装饰器是一种"防御性编程"——未来扩展更复杂的情绪状态(比如带详细描述、强度等级)时,可以无缝接入。

5.3 测评记录类

export class AssessmentRecord {
  id: string;
  date: string;
  title: string;
  score: number;
  totalScore: number;
  result: string;
  emotion: EmotionType;
}

测评记录把"心理量表名 + 得分 + 结果"打包,再额外关联一个情绪类型。这样在列表里展示时,可以同时显示 emoji、日期、结果文案和分数,信息密度刚刚好。

注意 id: string 这个字段——它在 List 的 ForEach 中作为 keyGenerator,在侧滑删除时作为筛选条件,是非常关键的"业务主键"。即使日期、标题、得分完全相同,只要 id 不同,就是两条独立的记录。

5.4 情绪趋势类

export class EmotionTrend {
  date: string;
  weekday: string;
  score: number; // 0-100,代表情绪健康指数
  emotion: EmotionType;
}

趋势数据故意只保留 7 天,既不占太多屏幕空间,又足以体现"周内变化"。weekday 字段单独存"周一/周二"这种短字符串,避免在前端反复 new Date().getDay()

score 的取值范围是 0~100,代表"今日的情绪健康指数"。这个值可以通过加权计算得出(睡眠时长 + 自评心情 + 测评得分等),但本项目里直接用模拟值,演示效果优先。

5.5 模拟数据

为了方便演示,我们在文件底部导出了三个常量数组:

  • EMOTIONS:6 个情绪的全局配置;
  • ASSESSMENT_RECORDS:5 条历史测评;
  • EMOTION_TRENDS:近 7 天情绪指数。

真实环境里,这些数据应该来自 relationalStore 或 HTTP 接口,但在 demo 阶段,把它们"写死"在文件里,反而更便于理解整体数据流。


六、视觉与设计系统

为了让界面"看起来像一款正经的心理应用",我们从颜色、文案、间距三个维度统一了视觉语言。

6.1 配色方案

color.json 中,我们定义了 16 个颜色变量:

  • 主色系:primary_color = #5B8CFF(蓝紫色,传递"平静、信任")
  • 背景:background_color = #F5F7FA(极浅冷灰,避免刺激视觉)
  • 文字:text_primary = #1F2937text_secondary = #6B7280
  • 情绪色:emotion_happy / calm / anxious / sad / angry / tired,每种情绪配一种低饱和度色块
  • 趋势:trend_up = #52C41A(绿)、trend_down = #FF4D4F(红)

注意所有颜色都用 $r('app.color.xxx') 引用,而不是直接写 '#5B8CFF'。在 HarmonyOS 中,这是资源管理的最佳实践,既能跟随系统主题切换,也能在打包时统一替换品牌色。

关于主色蓝紫调的思考:心理学研究表明,蓝色与紫色给人"理性、安全、平静"的感受,适合需要建立信任感的健康类应用。相比之下,红色系(高警觉)、黄色系(高刺激)虽然更抓眼球,但容易引起情绪波动,与"心理健康"的主题相悖。所以我们最终选了 #5B8CFF 这个"低饱和度蓝紫"作为主色。

6.2 文案设计

应用名"心理健康"放在 AppScope 的 string.json 中,主页面内的所有文案都走 $r('app.string.xxx') 引用。这样做看似多绕一层,实际上为未来 i18n(国际化)留好了接口:只要新增 zh_CNen_US 等目录并提供同名 string 即可。

文案风格上,我们坚持几个原则:

  • 短句优先:如"今天感觉如何?"比"请选择您今天的情绪状态"更自然;
  • 避免医学化术语:把"焦虑自评量表"完整写在标题里,但描述用"轻度焦虑"而不是"SAS 标准分 50~59"这种专业表达;
  • 保留温度:Toast 提示是"已记录:开心"而不是"已保存",多一个字,温度完全不同。

6.3 间距与圆角

整体走"卡片化"设计,所有内容都装在 borderRadius(16) 的白色卡片里,卡片之间用 12px 的外边距隔开。圆角 16 是经过反复对比验证的"心理类应用黄金比例":既不会显得过于呆板,也不会让用户感觉"不够严肃"。

字体大小上,我们也做了一组相对统一的"字号阶梯":

  • 大标题:26px(Bold)
  • 区块标题:17px(Bold)
  • 列表项主标题:15px(Medium)
  • 列表项副标题:11~13px
  • 辅助信息:9~10px

这种阶梯让用户视觉上有明确的"层次感",知道哪里是重点,哪里是次要信息。


七、主页面整体布局

主页面是整个应用的"门面",我们采用经典的"Header + Scroll(多 Section)"结构:

Stack() {
  Column() {
    this.HeaderBuilder()                // 顶部 Banner
    Scroll() {
      Column() {
        this.EmotionGridBuilder()      // Section 1: Grid 情绪
        this.AssessmentListBuilder()   // Section 2: List 测评
        this.TrendListBuilder()        // Section 3: List 趋势
        Column().height(40)            // 底部留白
      }
    }
    .layoutWeight(1)
    .scrollBar(BarState.Off)
  }
  if (this.showTip) {                  // Toast 提示
    Text(this.tipText).position(...)
  }
}

7.1 为什么要用 Scroll 嵌套 Column

由于整个页面比屏幕高(三个 Section 加上 Header 接近 1500px),我们必须让最外层支持滚动。但是 List 自身已经能滚动,如果再把 List 放进一个 List,会出现"嵌套滚动"的卡顿。最佳实践是:

  • 外层用 Scroll + Column,负责 Section 之间的滚动;
  • 每个 Section 内部自己管理 List 的高度与滚动。

这种"内嵌滚动容器"的写法在 HarmonyOS 应用中非常常见,关键是要给 List 明确的高度(比如 310、330),否则它会按 0 高度布局,看不到任何内容。

7.2 Stack 的妙用

最外层用 Stack 而不是 Column,是为了支持"覆盖在主内容之上"的 Toast 提示。Stack 的子组件按声明顺序叠放,我们先放主体 Column,再放 Toast 的 Text 组件,Toast 自然就浮在内容之上。.position({ x: '50%', y: 80 }) 让 Toast 居于屏幕顶部 80px 位置,markAnchor({ x: '50%', y: '50%' }) 让 Toast 自身的中心点对齐到 position 坐标,实现"真正的水平居中"。

7.3 顶部 Header 的渐变设计

.linearGradient({
  angle: 135,
  colors: [['#5B8CFF', 0], ['#7DA4FF', 1]]
})

Header 使用 135 度的线性渐变,从主色蓝紫过渡到稍亮的浅蓝,营造"晨光透过窗帘"般的视觉。angle: 135 表示从左上到右下的渐变方向,这种斜向渐变比纯色块更有"呼吸感",又不会过于花哨。


八、Grid 情绪状态卡片实现

Grid 是 HarmonyOS 提供的网格布局容器,非常适合展示同类元素。这里我们用 3 列 Grid 展示 6 种情绪。

8.1 完整代码

Grid() {
  ForEach(EMOTIONS, (emotion: Emotion) => {
    GridItem() {
      Stack() {
        // 默认浅色背景(15% 透明)
        Column()
          .width('100%').height('100%')
          .backgroundColor(emotion.color)
          .opacity(0.15)
          .borderRadius(14)
          .visibility(this.isSelected(emotion) ? Visibility.None : Visibility.Visible)

        // 选中时的实色背景
        Column()
          .width('100%').height('100%')
          .backgroundColor(emotion.color)
          .borderRadius(14)
          .visibility(this.isSelected(emotion) ? Visibility.Visible : Visibility.None)

        // 内容层
        Column() {
          Text(emotion.emoji).fontSize(34)
          Text(emotion.name)
            .fontColor(this.isSelected(emotion) ? Color.White : $r('app.color.text_primary'))
            .fontWeight(this.isSelected(emotion) ? FontWeight.Bold : FontWeight.Normal)
        }
      }
      .width('100%').height(90)
      .borderRadius(14)
      .border({
        width: this.isSelected(emotion) ? 0 : 1,
        color: $r('app.color.divider_color')
      })
    }
    .onClick(() => { this.selectEmotion(emotion) })
  }, (emotion: Emotion) => emotion.type)
}
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(10).rowsGap(10)
.width('100%').height(200)

8.2 选中态的"双背景"实现

Stack 是 HarmonyOS 中的层叠容器,默认所有子组件对齐到左上角,后写的盖在先写的上面。我们用 Stack 装了三层:

  1. 浅色背景(15% 透明度)
  2. 实色背景(选中态)
  3. 内容(emoji + 文字)

通过 Visibility.None / Visibility.Visible 切换两层背景的显隐,既避免了"把背景颜色变成透明再还原"导致的边框错位问题,也保持了单一职责——背景只负责颜色,内容只负责文字。

8.3 opacity(0.15) vs 字符串拼透明度

很多前端开发者习惯用 '#5B8CFF' + '22' 这种方式拼接 16 进制透明度。在 ArkTS 中,$r('app.color.xxx') 返回的是 Resource 对象,不是字符串,直接拼接会得到 "[object Object]22"

我们用 .opacity(0.15) 修饰符实现"看起来更浅"的视觉效果,语义更清晰,运行时也由框架做 GPU 加速合成,比手算 16 进制更可靠。

8.4 点击反馈

onClick(() => { this.selectEmotion(emotion) })

selectEmotion 方法做了三件事:

  1. 更新 @State currentEmotion,触发 Grid 重渲染;
  2. 设置 showTip = true,弹出 Toast;
  3. 1.5 秒后 setTimeout 关闭 Toast。
selectEmotion(emotion: Emotion): void {
  this.currentEmotion = emotion;
  this.tipText = `已记录:${emotion.name}`;
  this.showTip = true;
  setTimeout(() => { this.showTip = false }, 1500);
}

这里要注意 setTimeout 的回调函数必须用箭头函数,否则 this 会指向 setTimeout 内部,导致 this.showTip 报错。ArkTS 在严格模式下会直接编译失败,所以这是一个非常友好的"前置校验"。


九、List 测评记录实现

测评记录区是一个典型的"列表 + 项 + 分割线 + 滑动操作"组合,几乎所有内容类 App 都会用到这种模式。

9.1 List 基础结构

List() {
  ForEach(this.records, (record: AssessmentRecord) => {
    ListItem() {
      Row() {
        // 左侧情绪图标(50x50 圆形)
        Stack() {
          Column()
            .width(50).height(50)
            .backgroundColor(this.getEmotionColor(record.emotion))
            .opacity(0.15).borderRadius(25)
          Text(this.getEmotionEmoji(record.emotion)).fontSize(28)
        }

        // 中间信息(标题 + 日期 + 结果)
        Column() {
          Text(record.title).fontSize(15).fontWeight(FontWeight.Medium)
          Row() {
            Text(record.date).fontSize(11).fontColor($r('app.color.text_secondary'))
            Text(' · ').fontSize(11)
            Text(record.result).fontSize(11).fontColor(this.getEmotionColor(record.emotion))
          }
        }
        .layoutWeight(1).margin({ left: 12 })

        // 右侧分数
        Column() {
          Text(record.score + '/' + record.totalScore).fontSize(14)
          Text('分数').fontSize(10)
        }
      }
      .width('100%').padding(12)
    }
    .swipeAction({ end: this.DeleteButtonBuilder(record.id) })
  }, (record: AssessmentRecord) => record.id)
}
.width('100%').height(310)
.listDirection(Axis.Vertical)
.divider({
  strokeWidth: 1,
  color: $r('app.color.divider_color'),
  startMargin: 74,
  endMargin: 12
})

9.2 几个易踩的坑

(1) ForEach 第三个参数——必须给 keyGenerator 函数,否则 ArkTS 在数据变更时无法识别"哪条数据被删除/更新"。这里用 record.id 作为唯一键,确保删除/插入操作都能正确触发 UI 更新。

(2) List 高度——List 必须显式设置高度(本例 height(310)),否则它默认会"按 0 高度布局",看不到内容。310 ≈ 60(item 内边距) × 5(条数) + 4(分割线),可以根据实际数据条数动态计算。

(3) layoutWeight 必须在 Row/Column 中使用——.layoutWeight(1) 让中间信息列占据剩余空间。如果不写,三个子组件会按内容宽度平分,看起来很挤。

9.3 滑动删除

.swipeAction({ end: this.DeleteButtonBuilder(record.id) })

swipeAction 是 ListItem 的"侧滑操作"修饰符,end 表示"从右往左滑"时显示的按钮。我们通过 @Builder 定义了一个红色删除按钮:

@Builder
DeleteButtonBuilder(id: string) {
  Button('删除')
    .fontSize(13).fontColor(Color.White)
    .backgroundColor($r('app.color.trend_down'))
    .width(72).height('100%')
    .onClick(() => {
      this.records = this.records.filter(r => r.id !== id);
    })
}

点击后通过 Array.filter 重新生成数组,ArkTS 检测到 @State records 引用变化,自动重渲染 List。height('100%') 让按钮填满 ListItem 高度,看起来更"工业化"。

swipeAction 还支持 start(左滑)和 end(右滑)同时设置,本项目只用了 end,因为单手握持时右滑更符合拇指的运动轨迹。


十、List 情绪趋势实现

情绪趋势是整个应用最有"信息密度"的部分,既要展示柱状图,又要展示每日详情,还要给一个"较前日变化"的小标签。

10.1 柱状图

柱状图本质是一个 Row + ForEach,每个柱体是 Column(layoutWeight(1)),柱体内部又嵌套 Column(layoutWeight(1)) 用于垂直堆叠"分数文字 + 柱体矩形":

Row() {
  ForEach(this.trends, (trend: EmotionTrend) => {
    Column() {
      // 上半部分:分数 + 柱体
      Column() {
        Text(trend.score + '').fontSize(9)
        Column()
          .width(20)
          .height(trend.score * 0.8)
          .backgroundColor(this.getEmotionColor(trend.emotion))
          .borderRadius(6)
      }
      .layoutWeight(1)
      .justifyContent(FlexAlign.End)
      .alignItems(HorizontalAlign.Center)

      // 下半部分:日期
      Text(trend.weekday).fontSize(11)
    }
    .layoutWeight(1)
    .height(120)
  }, (trend: EmotionTrend) => trend.date)
}
.width('100%').height(150)
.alignItems(VerticalAlign.Bottom)

关键点解析:

  • Row.alignItems(VerticalAlign.Bottom):让所有柱体贴着 Row 底部,呈现"从下往上长"的视觉;
  • Column.height(120):给每个柱体限定高度,留出顶部空间给高分柱;
  • height(trend.score * 0.8):分数(0~100)乘 0.8 得到柱体像素高度(0~80px);
  • justifyContent(FlexAlign.End):让"分数文字 + 柱体矩形"整体贴在 Column 底部,文字刚好位于柱体上方。

这种"用 Column 嵌套做柱状图"的方法,虽然没有用 Canvas 那样灵活,但胜在简单直观,而且所有元素都是声明式 UI,自动响应数据变化。

10.2 每日详情 List

每日详情用 List 渲染,每行包含 emoji、日期、进度条、分数四个元素:

List() {
  ForEach(this.trends, (trend: EmotionTrend) => {
    ListItem() {
      Row() {
        // 左侧 emoji(36x36 圆形)
        Stack() { ... }

        // 中间信息(日期 + 进度条)
        Column() {
          Text(trend.date + ' ' + trend.weekday)
          Stack() {  // 进度条
            Row().width('100%').height(6)
              .backgroundColor($r('app.color.divider_color'))
            Row()
              .width(`${Math.min(trend.score, 100)}%`)
              .height(6)
              .backgroundColor(this.getEmotionColor(trend.emotion))
          }
          .width('100%').height(6)
        }
        .layoutWeight(1)

        // 右侧分数
        Text(trend.score + '')
          .fontColor(this.getEmotionColor(trend.emotion))
      }
    }
  }, (trend: EmotionTrend) => trend.date)
}
.width('100%').height(330)

进度条实现技巧:

我们用 Stack 把两层 Row 叠在一起:第一层是 100% 宽的灰色"底槽",第二层是 ${score}% 宽的彩色"进度"。这样做的优点是:

  • 第二层宽度直接用字符串 '${Math.min(score, 100)}%',框架自动算像素值;
  • Math.min 防止分数超过 100 时进度条溢出;
  • 进度条颜色随情绪类型变化,视觉效果丰富。

这种"双 Row 进度条"是 HarmonyOS 中非常常见的轻量实现。如果未来要做更复杂的图表(比如折线、饼图),可以引入第三方图表库或自绘 Canvas。

10.3 趋势变化指示器

getTrendChange(): string {
  if (this.trends.length < 2) return '0';
  const last = this.trends[this.trends.length - 1].score;
  const prev = this.trends[this.trends.length - 2].score;
  const diff = last - prev;
  return `${diff >= 0 ? '+' : ''}${diff}`;
}

getTrendChange 计算"今天减昨天"的差值,正数显示绿色 +N,负数显示红色 -N。这种"环比"小标签在健康管理类应用里非常常见,能让用户一眼看到"状态是变好了还是变差了"。

为了让"趋势变化"更有意义,我们在 UI 上做了三个细节:

  • 正向用 trend_up 绿色,负向用 trend_down 红色,符合用户对"涨/跌"的直觉;
  • 显示"+N"或"-N"格式,带符号的视觉重量更大,比纯数字更显眼;
  • 数字字号 12px,加 Medium 字重,在不喧宾夺主的前提下保持信息密度。

十一、状态管理与数据流

ArkTS 的状态管理是声明式 UI 的核心,本项目用到的状态装饰器并不多,但每个都用在了刀刃上。

装饰器 变量 作用
@State currentEmotion 当前选中的情绪 驱动 Grid 卡片选中态 + Toast 文案
@State records 测评记录列表 驱动 List 渲染 + 删除操作
@State trends 情绪趋势列表 驱动柱状图 + 每日详情 List
@State showTip Toast 是否显示 控制提示层的可见性
@State tipText Toast 文案 显示"已记录:开心"等内容

所有状态都集中在 Index 组件内部,目前没有跨页面共享需求。如果未来增加"测评详情页",再考虑用 @Provide/@Consume 或全局状态管理(类似 Redux)模式。

11.1 为什么不用 @Prop/@Link

@Prop 是单向传递(父 → 子),@Link 是双向同步(父 ↔ 子)。本项目暂时没有"父子组件"的结构,所有 UI 都用 @Builder 内联在 Index 内部,父子组件的边界不明显,所以暂时不需要。

未来如果要做"测评记录项"独立成组件,可以这样写:

@Component
struct RecordItem {
  @Link record: AssessmentRecord;  // 双向同步
  build() { ... }
}

然后在父组件中:

RecordItem({ record: $record })  // $record 创建双向绑定

这种"父持有状态 + 子用 @Link 同步"的模式,在 HarmonyOS 中是官方推荐的"组件通信"方案。

11.2 不可变更新的重要性

注意我们更新 records 列表时,用的是:

this.records = this.records.filter(r => r.id !== id);

而不是:

this.records.splice(index, 1);  // 错误示范

ArkTS 的 @State 装饰器会在"引用变化"时触发 UI 更新。splice 是原地修改,引用没变,UI 不会更新;filter 返回新数组,引用变化,UI 自动重渲染。这和 React 的 setState 思路完全一致——状态更新要"以新换旧",不能原地修改


十二、开发中遇到的问题与解决

开发过程并非一帆风顺,以下是几个值得记录的"踩坑"经验,都是 ArkTS 新手很容易遇到的。

12.1 ResourceColor 不能直接拼字符串

最初想把"情绪色 + 透明度"做成 '#5B8CFF' + '22' 的字符串拼接,但 $r('app.color.xxx') 返回的是 Resource 对象,运行时再求值,直接拼接会得到 "[object Object]22"

解决:改用 opacity(0.15) 修饰符,语义更清晰。

12.2 Stack 嵌套 + Visibility 切换的渲染顺序

Stack 中后写的子组件覆盖在先写的上面。如果把"浅色背景"写在"实色背景"之后,选中态会被盖住,看不到变化。

解决:先写"浅色背景"(默认显示),再写"实色背景"(选中显示),最后写"内容"。这样三层结构既符合"由远及近"的视觉直觉,也方便管理 visibility 切换。

12.3 List 高度为 0

List 不像 Column 那样会"自动撑开",必须显式设置高度。最初我给 List 留了 layoutWeight(1),但它的父容器是已经固定高度的 Column,导致 List 计算出 0 高度,完全看不到内容。

解决:在 Scroll 内部给 List 写死高度(310、330),Scroll 自己负责 Section 间的滚动,List 只负责内部滚动。

12.4 ForEach 缺少 keyGenerator

如果不传 keyGenerator,ArkTS 在控制台会报警告"the array may be updated asynchronously",且 List 滑动/删除操作可能错位。

解决:为每个 ForEach 都加上 (item) => item.id(item) => item.type 作为唯一键。

12.5 setTimeout 的 this 指向

setTimeout 的回调如果写成普通函数,this 会指向 setTimeout 内部对象,而不是组件实例。

解决:用箭头函数 () => { ... },让 this 词法绑定到外层组件。

12.6 columnGap / rowGap 的大小写

ArkTS 早期版本用 columnsGaprowsGap,新版本可能改为 columnGaprowGap(单数)。具体看 SDK 版本。本项目用的是 6.1.1,沿用旧的复数形式 columnsGap


十三、代码组织与可维护性

虽然整个项目只有 2 个 ETS 文件,但我们仍然做了清晰的职责划分:

  • EmotionModel.ets:纯数据,不依赖任何 UI 组件,可以独立编译/测试;
  • Index.ets:纯 UI + 状态管理,所有业务逻辑都封装成 @Builder 方法,build() 函数本身只有 50 行,阅读起来一目了然。

这种"数据层 + 视图层"的分离带来三个好处:

  1. 未来要接入真实 API,只需替换 EmotionModel.ets 内部的数据来源,UI 层零改动;
  2. 数据模型可以在其他页面复用(比如"测评详情页"可以共享 AssessmentRecord 结构);
  3. 单元测试更友好——给 EmotionModel 写测试不需要启动 UI。

13.1 @Builder 的使用哲学

@Builder 是 ArkTS 中的"UI 模板函数",它和 React 的函数组件很像,但有几点不同:

  • 必须依附于 struct:@Builder 函数只能在 struct 内部声明,不能导出;
  • 自动捕获 this:函数体内部可以直接使用组件的状态,无需 props 传递;
  • 编译时展开:调用 @Builder 相当于把 UI 代码内联到调用处,运行时无函数调用开销。

本项目用 @Builder 把每个 Section 都封装成独立方法(EmotionGridBuilderAssessmentListBuilderTrendListBuilder),让 build() 函数保持极简:

build() {
  Stack() {
    Column() {
      this.HeaderBuilder()
      Scroll() {
        Column() {
          this.EmotionGridBuilder()
          this.AssessmentListBuilder()
          this.TrendListBuilder()
        }
      }
    }
  }
}

这种"主入口 + 子方法"的结构,在大型项目里可以无限嵌套(Section1 内部再用 @Builder 拆 SubSection1ASubSection1B),是 HarmonyOS 中组织复杂 UI 的标准做法。


十四、可访问性与深色模式

虽然本项目尚未实现深色模式,但代码已经为它做好了准备。回顾一下配色方案,所有颜色都通过 $r('app.color.xxx') 引用,只要在 resources/dark/element/color.json 中提供同名色值,ArkTS 就会自动切换。

14.1 深色模式可能需要调整的地方

  • 背景色:background_color 需要变成深灰(比如 #121212);
  • 卡片色:card_background 需要变成稍亮的深灰(比如 #1E1E1E);
  • 文字色:text_primary 变浅、text_secondary 变深灰;
  • 情绪色:低饱和度的色块在深色背景下可能显得太暗,需要适当提亮 10~15%;
  • 渐变色:Header 的蓝紫渐变可以保留,但 linearGradient 末端颜色可以稍微压暗。

14.2 可访问性考虑

  • 字号缩放:用 fp 单位替代 vp,可以让用户系统级字号生效;
  • 对比度:所有文字与背景的对比度都满足 WCAG 4.5:1 标准(尤其是 text_secondary);
  • 点击区域:Grid 卡片 90px 高,ListItem 60px 高,都超过 48dp 的最低点击标准;
  • 焦点指示:深色模式下要给可点击元素加一个 2px 的高亮边框,辅助视障用户。

这些细节在 MVP 阶段可以暂缓,但应该列入"未来优化清单"。


十五、未来功能规划

本应用目前只是 MVP 版本,接下来可以沿着"轻量化心理陪伴"这个方向继续迭代:

  1. 数据持久化——用 @ohos.data.preferencesrelationalStore 把每天的情绪记录存到本地,应用卸载前不会丢失。可以让用户看到自己过去 30 天、90 天的情绪分布,甚至导出为 PDF 报告给心理咨询师。

  2. 测评问卷模块——把 SAS/SDS/PSQI 等量表做成"答题 → 算分 → 写记录"的完整流程,真正替代纸质问卷。题目可以拆分成多个 Stepper 步骤,每步展示一道题,降低用户的心理压力。

  3. 趋势曲线——用更专业的图表组件(比如 gantt 或自绘 Canvas)替换柱状图,展示月度/季度趋势。曲线图比柱状图更能体现"持续上升/下降"的连续性。

  4. 提醒与打卡——用 @ohos.reminderAgent 每天定时推送"今天记录心情了吗?",提升用户粘性。同时,设置"连续打卡 7 天奖励一个勋章"这样的游戏化机制,激发用户动力。

  5. 深呼吸/冥想——集成 media 模块播放白噪音,提供"3 分钟冷静一下"的小工具。这是从"记录情绪"到"管理情绪"的关键升级。

  6. 多端协同——借助 HarmonyOS 的"流转"能力,在手机/平板/手表之间无缝切换记录。比如用手机记录、在平板上看趋势,数据通过分布式软总线自动同步。

  7. 隐私保护——所有数据本地加密存储,未来对接云端时优先考虑"端侧加密 + 同步"方案。心理数据是用户最私密的信息之一,任何"为了方便上传"而牺牲隐私的做法都应该被谨慎对待。

  8. AI 情绪解读——接入端侧大模型(比如盘古大模型的端侧版本),根据用户最近 7 天的情绪记录,生成一段温和、有建设性的"周报文字"。这比"分数+柱状图"更"有温度"。


十六、写在最后

ArkTS 是一门年轻但充满潜力的语言,它把 TypeScript 的工程化能力和原生 UI 的性能优势结合在一起。对于前端开发者来说,几乎没有"心智切换"成本;对于鸿蒙生态来说,它是补齐应用生态的关键拼图。

本文展示的"心理树洞"只是一个起点——它没有花哨的动画,没有复杂的算法,核心是 6 个情绪卡片 + 5 条测评记录 + 7 天趋势。但正是这种"小而美"的应用,最容易让人在通勤路上、午休时间、睡前 5 分钟,打开手机,给自己的情绪做一个简单的标注。

最后想分享一个小故事。我做这款应用的初衷,是因为一位朋友跟我讲:"我最近总是睡不好,但我不知道这是’偶尔的失眠’还是’焦虑的信号’。“如果有一款应用,能让他在 30 秒内做完一次 SAS 自评,看到分数,意识到"哦,原来我已经是中度焦虑了,应该去看看医生”,那它就值了。

愿每一份情绪都被看见,愿每一颗心灵都被温柔以待。


附录:常用 ArkTS API 速查

为方便读者查阅,这里整理本次开发中用到的高频 API:

类别 API 用途
容器 Column / Row / Stack / Grid / List / Scroll 基础布局
装饰器 @Entry / @Component / @State / @Builder / @Observed 组件与状态
样式 .width / .height / .backgroundColor / .borderRadius / .fontSize / .fontColor / .fontWeight 视觉修饰
资源 $r('app.color.xxx') / $r('app.string.xxx') 引用资源
事件 .onClick(() => {}) 点击回调
列表 ForEach(arr, (item) => {}, (item) => key) 列表渲染
滑动 .swipeAction({ end: Builder() }) 侧滑操作
反馈 promptAction.showToast() 系统级 Toast(本项目用自定义 Toast)
路由 router.pushUrl({ url: 'pages/Detail' }) 页面跳转
存储 data_preferences.getPreferences(this.context, 'store') 本地持久化

本文基于 HarmonyOS 6.1.1 / ArkTS 编写,源码遵循 MIT 协议,欢迎 fork 和二次开发。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐