ArkTS × 心理树洞:情绪健康应用实战开发
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.json 和 string.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'
}
用字符串字面量作为枚举值,有两个考虑:
- 序列化稳定性:未来把数据存到 Preferences 或云端时,字符串值不会因为枚举重排而错位;
- 可读性:在日志或调试时,看到
'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 = #1F2937、text_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_CN、en_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 装了三层:
- 浅色背景(15% 透明度)
- 实色背景(选中态)
- 内容(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 方法做了三件事:
- 更新
@State currentEmotion,触发 Grid 重渲染; - 设置
showTip = true,弹出 Toast; - 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 早期版本用 columnsGap 和 rowsGap,新版本可能改为 columnGap 和 rowGap(单数)。具体看 SDK 版本。本项目用的是 6.1.1,沿用旧的复数形式 columnsGap。
十三、代码组织与可维护性
虽然整个项目只有 2 个 ETS 文件,但我们仍然做了清晰的职责划分:
- EmotionModel.ets:纯数据,不依赖任何 UI 组件,可以独立编译/测试;
- Index.ets:纯 UI + 状态管理,所有业务逻辑都封装成
@Builder方法,build() 函数本身只有 50 行,阅读起来一目了然。
这种"数据层 + 视图层"的分离带来三个好处:
- 未来要接入真实 API,只需替换
EmotionModel.ets内部的数据来源,UI 层零改动; - 数据模型可以在其他页面复用(比如"测评详情页"可以共享 AssessmentRecord 结构);
- 单元测试更友好——给 EmotionModel 写测试不需要启动 UI。
13.1 @Builder 的使用哲学
@Builder 是 ArkTS 中的"UI 模板函数",它和 React 的函数组件很像,但有几点不同:
- 必须依附于 struct:@Builder 函数只能在 struct 内部声明,不能导出;
- 自动捕获 this:函数体内部可以直接使用组件的状态,无需 props 传递;
- 编译时展开:调用 @Builder 相当于把 UI 代码内联到调用处,运行时无函数调用开销。
本项目用 @Builder 把每个 Section 都封装成独立方法(EmotionGridBuilder、AssessmentListBuilder、TrendListBuilder),让 build() 函数保持极简:
build() {
Stack() {
Column() {
this.HeaderBuilder()
Scroll() {
Column() {
this.EmotionGridBuilder()
this.AssessmentListBuilder()
this.TrendListBuilder()
}
}
}
}
}
这种"主入口 + 子方法"的结构,在大型项目里可以无限嵌套(Section1 内部再用 @Builder 拆 SubSection1A、SubSection1B),是 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 版本,接下来可以沿着"轻量化心理陪伴"这个方向继续迭代:
-
数据持久化——用
@ohos.data.preferences或relationalStore把每天的情绪记录存到本地,应用卸载前不会丢失。可以让用户看到自己过去 30 天、90 天的情绪分布,甚至导出为 PDF 报告给心理咨询师。 -
测评问卷模块——把 SAS/SDS/PSQI 等量表做成"答题 → 算分 → 写记录"的完整流程,真正替代纸质问卷。题目可以拆分成多个
Stepper步骤,每步展示一道题,降低用户的心理压力。 -
趋势曲线——用更专业的图表组件(比如
gantt或自绘Canvas)替换柱状图,展示月度/季度趋势。曲线图比柱状图更能体现"持续上升/下降"的连续性。 -
提醒与打卡——用
@ohos.reminderAgent每天定时推送"今天记录心情了吗?",提升用户粘性。同时,设置"连续打卡 7 天奖励一个勋章"这样的游戏化机制,激发用户动力。 -
深呼吸/冥想——集成
media模块播放白噪音,提供"3 分钟冷静一下"的小工具。这是从"记录情绪"到"管理情绪"的关键升级。 -
多端协同——借助 HarmonyOS 的"流转"能力,在手机/平板/手表之间无缝切换记录。比如用手机记录、在平板上看趋势,数据通过分布式软总线自动同步。
-
隐私保护——所有数据本地加密存储,未来对接云端时优先考虑"端侧加密 + 同步"方案。心理数据是用户最私密的信息之一,任何"为了方便上传"而牺牲隐私的做法都应该被谨慎对待。
-
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 和二次开发。
更多推荐

所有评论(0)