HarmonyOS 7 实战:ArkTS + Canvas 2D 打造情绪可视化 AI 应用

当大语言模型遇上 Canvas 2D 渲染,情绪不再只是一行文字记录,而会长成一棵有枝叶、有花果、会随风摇摆的树。本文以"情绪树 Mood Tree"项目为完整案例,深入剖析 HarmonyOS 7 下 ArkTS/ArkUI 的声明式开发范式、Canvas 高性能渲染、多端协同 AI 架构,以及从 0 到 1 的工程化落地全过程。

情绪树记录页

图 1:情绪树 App 核心记录页 —— 七档情绪标签 + 自由文本 + AI 一键生成


一、为什么是"情绪树"?

心理健康类应用的核心矛盾在于:情绪是模糊的、流动的、难以量化的,而用户需要的是确定性的反馈与陪伴感

传统情绪日记产品大多停留在"打标签 + 写日记 + 看折线图"的阶段。折线图能告诉用户"你这周焦虑上升了",却无法回答"那又怎样"。

“情绪树"给出的答案是一种具身化隐喻(Embodied Metaphor):把每一次情绪记录,转化为一棵独一无二的树。喜悦高时花开满枝,压力重时枝叶枯萎,平静久时树干挺拔。当 30 天的记录累积成一片"情绪森林”,用户看到的不是冷冰冰的数据,而是自己内心的四季流转。

这种设计背后有三层技术挑战:

  1. 情绪到视觉的映射:如何把多维情绪数据(喜悦/平静/活力/压力/情感倾向)稳定、可解释地映射为树的视觉状态?
  2. 渲染性能:树的枝干是递归生成的,一棵树的绘制可能涉及数百个图元,30 棵树同时渲染如何保持 60fps?
  3. AI 协同架构:大模型推理不应阻塞主线程,更不能把 API Key 写进客户端——如何设计安全的端云协同链路?

下面逐一拆解。


二、架构总览:端云协同的三层模型

"情绪树"采用经典的前后端分离 + 端云协同架构:

在这里插入图片描述

关键设计决策

  • App 不直接持有大模型 API Key。ArkTS 侧只调用本地局域网内的 FastAPI 服务,Key 保存在后端环境变量,避免逆向破解导致密钥泄露。
  • AI 失败可降级。网络异常时,App 自动切换到本地启发式算法(offlineAnalyze),保证核心体验不中断——这就是为什么你在图 2 的反馈里偶尔会看到"(离线模式,连接服务器获取更精准分析)"的提示。
  • 渲染与数据解耦。情绪维度(MoodDimension)是纯数据结构,树的生成(TreeGenerator)和绘制(TreeRenderer)完全独立,便于单元测试与逻辑复用。

三、ArkTS 声明式 UI:从状态到界面的单向流动

HarmonyOS 7 的 ArkUI 采用彻底声明式的开发范式。与传统命令式"找到 View → 修改属性"不同,ArkTS 的核心是状态驱动 UI:当被 @State@Prop@Link 等装饰器标记的数据变化时,框架自动 diff 并更新最小化的 UI 节点。

3.1 记录页的声明式表达

记录页(图 1)的核心交互是"七档情绪选择 + 文本输入 + 触发生成"。用 ArkTS 表达极为简洁:

@Component
export struct RecordTab {
  @State selectedMood: string = '';
  @State story: string = '';
  @State isAnalyzing: boolean = false;

  // 七档情绪的定义(标签 + emoji + 权重值)
  private readonly moods: MoodOption[] = [
    { label: '狂喜', emoji: '😍', value: 1.0 },
    { label: '开心', emoji: '😄', value: 0.8 },
    { label: '平静', emoji: '😌', value: 0.5 },
    { label: '一般', emoji: '😐', value: 0.3 },
    { label: '焦虑', emoji: '😟', value: -0.4 },
    { label: '低落', emoji: '😢', value: -0.7 },
    { label: '崩溃', emoji: '😭', value: -1.0 },
  ];

  build() {
    Column() {
      Text('今天感觉怎么样?')
        .fontSize(20)
        .fontColor('#E8E8F0')
        .margin({ top: 24, bottom: 16 })

      // 情绪标签网格
      Wrap({ space: 12 }) {
        ForEach(this.moods, (m: MoodOption) => {
          this.MoodChip(m)
        })
      }

      // 文本输入
      TextArea({ text: this.story, placeholder: '今天发生了什么?你的感受是...' })
        .onChange((v: string) => { this.story = v; })
        .margin({ top: 20 })

      // 生成按钮
      Button(this.isAnalyzing ? '正在种树...' : '让 AI 种一棵树')
        .enabled(!this.isAnalyzing)
        .onClick(() => this.onPlant())
    }
  }

  @Builder MoodChip(m: MoodOption) {
    Row() {
      Text(m.emoji).fontSize(22)
      Text(m.label).fontSize(14).fontColor('#E8E8F0')
    }
    .padding({ left: 14, right: 14, top: 8, bottom: 8 })
    .borderRadius(20)
    // 选中态通过状态驱动样式,无需手动操作 DOM
    .backgroundColor(this.selectedMood === m.label ? '#4ECB71' : '#2A2A4A')
    .onClick(() => { this.selectedMood = m.label; })
  }
}

这里有几个 ArkTS 的关键点值得强调

  • @State 声明的 selectedMood 一旦变化,MoodChip 的背景色会自动重算,开发者不需要写任何"if selected then set red"的命令式代码。
  • ForEach 是 ArkUI 的列表渲染原语,它要求每项有稳定的 key(默认用数组下标,复杂场景应传 keyGenerator),否则在增删时会导致错误的节点复用。
  • @Builder 装饰的方法相当于"局部 UI 片段函数",用于消除重复布局代码,是 ArkTS 中组织复杂界面的核心手段。

3.2 状态管理的层次

随着页面增多,"情绪树"出现了跨页面共享状态:记录页生成的新树,需要实时反映到"我的树"和"森林"页。ArkTS 提供了从局部到全局的多级状态管理:

装饰器 作用域 典型用途
@State 组件内 局部 UI 状态(如选中态)
@Prop 父→子单向 子组件接收不可变快照
@Link 父↔子双向 子组件需要回写父状态
@Provide/@Consume 跨层级 祖先与后代组件共享,跳过中间层
AppStorage 全局单例 跨页面、跨 Ability 的持久状态
LocalStorage Ability 级 同一 Ability 内多页面共享

"情绪树"把用户的全部情绪记录列表放在 AppStorage 中,这样任何页面刷新都无需层层透传参数:

// 写入
AppStorage.setOrCreate('moodRecords', records);
// 任意页面读取
@StorageLink('moodRecords') records: MoodRecord[];

四、Canvas 2D 渲染:把情绪画成一棵树

这是项目最硬核的部分。HarmonyOS 7 的 ArkUI 提供了 Canvas 组件,通过 CanvasRenderingContext2D 暴露了与 Web Canvas 高度一致的 2D 绘图 API。

AI 生成结果

图 2:AI 返回的五维情绪分析 + 温暖心理解读,标签与文案均由大模型生成

4.1 情绪到视觉状态的映射函数

树的"长相"由一个纯函数 generateTreeState(dim, dayIndex) 决定。输入是五维情绪向量,输出是树的视觉参数:

export function generateTreeState(dim: MoodDimension, dayIndex: number): TreeVisualState {
  const joy = dim.joy;        // 0~1 喜悦
  const calm = dim.calm;      // 0~1 平静
  const energy = dim.energy;  // 0~1 活力
  const stress = dim.stress;  // 0~1 压力

  // 喜悦高 → 花朵多、叶片翠绿
  // 平静高 → 树干挺拔
  // 活力高 → 分支多、叶密
  // 压力高 → 枯萎因子上升

  const trunkHeight = 80 + calm * 60 + energy * 30;
  const branchCount = Math.floor(3 + energy * 4 + joy * 2);
  const leafCount = Math.floor(15 + joy * 30 + energy * 25 - stress * 15);

  // 叶色:喜悦→翠绿,低落→暗紫,压力→枯黄
  let leafColor = '#4ECB71';
  if (joy > 0.7) leafColor = '#5DD962';
  else if (stress > 0.6) leafColor = '#8B7355';
  else if (joy < 0.3) leafColor = '#7A6BB8';

  const flowerCount = joy > 0.5 ? Math.floor(joy * 12) : 0;
  const witherFactor = Math.min(1, stress * 0.7 + (1 - joy) * 0.3);
  const glowIntensity = Math.min(1, joy * 0.5 + calm * 0.3);

  return { trunkHeight, branchCount, leafCount, leafColor,
           flowerCount, witherFactor, glowIntensity, /* ... */ };
}

设计亮点:映射函数是确定性的——同样的情绪输入永远生成同样的树。这带来两个好处:一是用户的树具有"身份感"(不会每次打开都变样),二是森林视图中每棵树都代表某一天的真实状态,可回溯、可对比。

4.2 递归生成树拓扑

树的枝干结构通过递归算法生成。为避免每次绘制都产生不同的随机树,项目实现了一个带种子的伪随机数生成器(SeededRandom)

class SeededRandom {
  private seed: number;
  constructor(seed: number) { this.seed = seed; }
  next(): number {
    this.seed = (this.seed * 9301 + 49297) % 233280;
    return this.seed / 233280;
  }
}

以日期(如 2026-07-16 → 20260716)作为种子,保证"7 月 16 日的树"在任何设备上、任何时间生成的拓扑完全一致。递归 generateChildren 从树干出发,按 branchCountbranchDepth 逐层分裂子枝,最终在叶子节点上分布式地分配叶片、花朵、果实。

我的树

图 3:"我的树"页 —— 单日情绪的具象化呈现,树干挺拔、枝叶分布由情绪维度驱动

4.3 渲染管线与性能优化

renderTree 是绘制入口,按"地面 → 枝干 → 叶 → 花 → 果"的顺序分层绘制:

export function renderTree(ctx, state, seed, config): void {
  const root = generateTreeTopology(state, seed);
  drawGround(ctx);
  drawBranches(ctx, root, state, config);  // 递归绘制所有枝干
  drawLeaves(ctx, root, state, config);    // 递归绘制叶片
  drawFlowers(ctx, root, state, config);
  drawFruits(ctx, root, state, config);
}

性能关键点

  1. 摇摆动画的低成本实现。树的"随风摇摆"不是重新生成拓扑,而是在绘制时给每个节点叠加一个与 depthswayPhase 相关的水平偏移 swayX。这样每一帧只需重绘,无需重建数据结构:
let swayX = 0;
if (config.showSway) {
  swayX = Math.sin(config.swayPhase + node.depth * 0.3)
          * state.swayAmplitude * (node.depth / (state.branchDepth + 1));
}
ctx.lineTo(node.endX + swayX, node.endY);
  1. 发光效果的按需开启。Canvas 的 shadowBlur 非常耗性能。代码里只在 glowIntensity > 0.3 时才开启阴影,绘制完立即 shadowBlur = 0 关闭,避免污染后续绘制。

  2. 森林视图的缩放绘制。30 棵树同时渲染时,每棵小树通过 ctx.save() → translate → scale(0.6) → renderTree → ctx.restore() 实现缩放复用,避免为森林单独写一套绘制逻辑。

树的状态面板

图 4:树的量化状态面板 —— 叶片数、花朵数、枯萎率、花期、光辉度,将视觉参数透明化展示给用户


五、AI 协同:五维情绪分析的后端架构

前端的渲染再精美,也需要"灵魂"——即大模型对情绪的深层理解。项目后端是一个不到 200 行的 FastAPI 服务,核心职责是把用户的自由文本 + 情绪标签,转换为结构化的五维向量 + 温度恰好合适的心理解读文案

5.1 分析 Prompt 的设计

大模型不是"直接回答用户",而是被要求输出严格的结构化 JSON

SYSTEM_PROMPT = """你是一位温柔而专业的心理陪伴师。
请根据用户的情绪标签和描述,输出 JSON:
{
  "joy": 0~1, "calm": 0~1, "energy": 0~1, "stress": 0~1, "sentiment": 0~1,
  "analysis": "不超过60字的心理解读,温柔、不评判",
  "keywords": ["2-4个情绪标签,带#"]
}"""

把情绪维度量化为 0~1 的连续值,是为了让前端映射函数能平滑插值——用户从"开心"滑到"狂喜",树的花朵数会连续增长,而不是跳变。

5.2 客户端如何安全调用

ArkTS 侧通过 http 模块发起请求,URL 指向局域网内的后端(开发期用 Mac 局域网 IP,生产可替换为 HTTPS 域名):

import { http } from '@kit.NetworkKit';

async function analyzeMood(moodLabel: string, story: string): Promise<MoodDimension> {
  const req = http.createHttp();
  const resp = await req.request(SERVER_BASE_URL + '/api/mood/analyze', {
    method: http.RequestMethod.POST,
    header: { 'Content-Type': 'application/json' },
    extraData: JSON.stringify({ mood_label: moodLabel, description: story }),
  });
  return JSON.parse(resp.result as string);
}

安全红线:永远不要把大模型 API Key 打包进 App。ArkTS 代码最终会被编译,Key 可被逆向提取。正确做法是通过自己的后端中转,Key 仅存在于后端环境变量或密钥管理服务中。

5.3 离线降级:体验的兜底网

网络永远不可靠。当请求超时或后端不可达时,App 不应崩溃或白屏,而是调用本地启发式算法:

try {
  const dim = await analyzeMood(this.selectedMood, this.story);
  // 用 AI 结果生成树
} catch (e) {
  // 降级:基于情绪标签的本地映射,保证核心功能可用
  const dim = offlineAnalyze(this.selectedMood);
  promptAction.showToast({ message: '离线模式,连接服务器获取更精准分析' });
}

这正是图 2 中那行"(离线模式)"提示的来源——它是设计好的优雅降级,而非 bug。

情绪森林

图 5:情绪森林 —— 30 天情绪轨迹,每棵树都是一天的缩影,左侧繁茂的树代表积极情绪积累


六、本地持久化:Preferences 的正确姿势

鸿蒙提供了 @ohos.data.preferences 轻量级 KV 存储。但在 ArkTS 严格模式下,有几个坑需要避开:

坑 1:getPreferencesSync 的第二个参数在 API 12+ 变成了 Options 对象,而非字符串。

// ❌ 旧写法(API 11 及以前)
prefStore = preferences.getPreferencesSync(ctx, 'mood_tree_store');

// ✅ 新写法(HarmonyOS 7 / API 23)
prefStore = preferences.getPreferencesSync(ctx, { name: 'mood_tree_store' });

坑 2:globalThisgetContext 已被标记为 deprecated。 不应在工具类里依赖全局上下文,而应把 Context 作为参数显式传入:

// 推荐:首次使用时传入 UIAbility 的 context
StorageUtil.init(getContext(this));

坑 3:同步 API 虽方便但有抛异常风险。 编译器会警告"Function may throw exceptions",生产代码应包裹 try/catch 或在调用处加 try 块。


七、工程化:从 DevEco Studio 到真机

7.1 SDK 版本对齐

项目的 build-profile.json5 必须声明与已安装 SDK 匹配的 compatibleSdkVersion

{
  "app": {
    "products": [{
      "compatibleSdkVersion": "6.1.0(23)",
      "targetSdkVersion": "6.1.0(23)",
      "runtimeOS": "HarmonyOS"
    }]
  }
}

版本不匹配会直接导致 Configuration Error。通过 hdc 查看已安装系统镜像的 apiVersion 可快速定位正确版本号。

7.2 构建与安装命令

纯命令行构建 HAP(适合 CI 或远程开发):

# 设置 SDK 与 JDK 路径(避免 IDE 环境变量污染)
export DEVECO_SDK_HOME="/Applications/DevEco-Studio.app/Contents/sdk"
export JAVA_HOME="/Applications/DevEco-Studio.app/Contents/jbr/Contents/Home"

# 用 hvigor 构建(注意:需在独立终端中运行,避开外部注入的环境变量)
node hvigorw.js assembleHap --mode module -p module=entry@default

# 通过 hdc 安装到设备
hdc -t 127.0.0.1:5555 install entry/build/default/outputs/default/entry-default-unsigned.hap

实战经验:在 macOS 上若从某些桌面应用启动终端,可能会被注入 NODE_OPTIONS 等环境变量,导致 hvigor 的 Node worker 崩溃。最稳妥的方式是从 Finder/Spotlight 独立启动 DevEco Studio,或在命令前 unset NODE_OPTIONS

7.3 真机/模拟器调试链路

开发期,后端跑在 Mac 上(端口 18081),模拟器通过局域网 IP 直接访问,绕过失效的端口转发:

模拟器 App (http://192.168.1.35:18081)
        │
        ▼
Mac 上的 FastAPI (0.0.0.0:18081)
        │
        ▼
大模型服务 (兼容 OpenAI 协议)

注意:模拟器访问 127.0.0.1 指向的是模拟器自己,要让 App 连到宿主机的后端,必须使用宿主机的局域网 IP,并确保 Mac 防火墙放行对应端口。

关于页

图 6:关于页 —— 完整技术栈标注:HarmonyOS 7 · ArkTS/ArkUI · Canvas 2D 渲染 · 大语言模型 · FastAPI


八、设计哲学:技术服务于情感

回顾整个项目,技术选型的每一处都不是炫技,而是服务于"让情绪被看见"这一核心体验:

  • 选用 Canvas 2D 而非预渲染图片:因为每棵树都是数据驱动的独特存在,图片无法表达情绪的连续性。
  • 确定性伪随机:让用户的树具有身份感和可追溯性。
  • 离线降级:心理类产品最忌讳"我想记录时它挂了",降级是基本尊重。
  • 端云分离 + Key 隔离:既享受了大模型的能力,又守住了安全底线。

情绪记录页

图 7:从一句话到一棵树 —— 记录、生成、可视化,构成情绪树完整的体验闭环


九、结语与延伸

"情绪树"证明了 HarmonyOS 7 + ArkTS 完全能够承载"重交互 + AI 协同 + 高性能渲染"的复杂应用场景。它不依赖任何第三方 UI 框架,纯用原生 ArkUI 与 Canvas 2D 就实现了细腻的视觉表达。

如果想进一步打磨这个项目,以下几个方向值得探索:

  1. 动效升级:引入 Particle 粒子系统,让花瓣飘落、星光闪烁更具沉浸感。
  2. 多模态情绪输入:接入 Core Vision Kit 或语音识别,让用户通过自拍表情或语音语调辅助情绪判断。
  3. 社交森林:在合规与隐私前提下,把单用户的森林扩展为可分享、可共鸣的社区情绪地图。
  4. 端侧推理:未来可将轻量大模型部署到端侧(如通过 NPU 加速),彻底摆脱网络依赖,实现真正的离线 AI 陪伴。

种一棵树最好的时间是十年前,其次是现在。而记录一种情绪最好的方式,也许是——看它长成一棵树。


技术栈:HarmonyOS 7 · ArkTS / ArkUI · Canvas 2D 渲染 · 大语言模型 · FastAPI

项目结构

mood-tree-demo/
├── entry/src/main/ets/
│   ├── pages/          # ArkUI 页面(记录/我的树/森林/关于)
│   ├── utils/          # TreeGenerator / TreeRenderer / StorageUtil / AIService
│   ├── common/         # Constants(情绪维度、Canvas 常量)
│   └── entryability/   # EntryAbility 入口
└── server/             # FastAPI 后端(五维情绪分析 + 大模型中转)

本文基于真实项目"情绪树 Mood Tree v1.0"创作,所有界面截图均来自 DevEco Studio 模拟器实机运行。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐