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

目录

  1. 引言
  2. 项目背景与需求分析
  3. 架构设计与数据模型
  4. Canvas 绘图引擎详解
  5. 贝塞尔曲线绘制与算法
  6. 交互式图表:点击选取数据点
  7. UI 设计与布局结构
  8. 知识图谱 Tab 实现
  9. 个人记录 Tab 实现
  10. 响应式布局与适配
  11. 常见问题与编译错误排查
  12. 数据可视化设计原则
  13. 总结与扩展方向

一、引言

1.1 为什么选择数据可视化应用

在前三个阶段中,我们分别构建了栅格布局演示儿童闹钟豆豆计数器。这三个应用分别聚焦于布局系统、状态管理和动画反馈——它们都属于「工具型」应用。

本阶段的睡眠与记忆力对比曲线则迈入了一个全新的领域:数据可视化

数据可视化是移动应用开发中最具挑战性、也最有价值的技能之一。它要求开发者:

  • 理解数据:掌握数据背后的科学含义和呈现逻辑
  • 精通绘图:使用 Canvas 等底层 API 进行自定义绘制
  • 兼顾美学:在信息密度和视觉简洁之间找到平衡
  • 交互设计:让用户能探索数据、发现见解

睡眠与记忆力的关系本身就是一个极具科学价值和普遍兴趣的话题。将两者结合起来,既能展示技术能力,又能传递有用的健康知识。

1.2 睡眠与记忆力的科学背景

大量神经科学研究表明,睡眠与记忆力之间存在明确的倒 U 型曲线关系

  • 睡眠不足(0~4 小时):记忆巩固过程严重受阻,大脑无法将短期记忆转为长期记忆。同时,类淋巴系统无法充分清理代谢废物。
  • 轻度不足(5~6 小时):记忆力部分受损,注意力下降,学习效率降低。
  • 最佳区间(7~9 小时):记忆巩固效率最高,注意力、创造力和问题解决能力均处于峰值。
  • 过量睡眠(10~12 小时):可能导致「睡醉」状态(sleep inertia),大脑代谢节奏紊乱,反而损害认知功能。

这个倒 U 型曲线就是我们应用的核心数据模型。

1.3 技术栈与 API 版本

项目 内容
操作系统 HarmonyOS 4.0+
API 版本 24(6.1.1)
开发语言 ArkTS
绘图 API Canvas + CanvasRenderingContext2D
交互方式 Canvas onClick 事件 + 坐标计算
数据存储 运行时内存(@State + 数组)

二、项目背景与需求分析

2.1 应用目标

构建一个集数据可视化、科学知识普及和个人健康追踪于一体的综合性应用,让用户直观理解睡眠时长与记忆力之间的关系。

2.2 功能需求

编号 功能 描述 优先级
F1 曲线图表 Canvas 绘制睡眠-记忆力倒 U 型曲线 P0
F2 坐标轴 X 轴(睡眠小时)、Y 轴(记忆力评分) P0
F3 最优区高亮 7~9 小时区域绿色背景标注 P0
F4 数据点交互 点击数据点查看详细评分和建议 P0
F5 渐变填充 曲线下方半透明渐变 P1
F6 网格线 水平参考线助读 P1
F7 知识科普 睡眠与记忆的科学解释 P1
F8 助眠建议 实用的改善睡眠 tips P1
F9 个人记录 记录每日睡眠和状态评分 P1
F10 历史列表 按时间倒序展示记录 P2

2.3 用户场景

用户 目标 使用方式
学生 了解最佳睡眠时间以提升学习效率 查看图表 + 科学知识
上班族 改善睡眠质量,提升工作效率 查看建议 + 记录追踪
失眠者 了解睡眠不足的危害 查看低睡眠区的警示信息
健康爱好者 追踪自己的睡眠与状态趋势 每日记录 + 历史回顾

2.4 数据模型

曲线基于 13 个采样点的科学近似数据:

private dataPt: DataPoint[] = [
  { h: 0,  s: 8,  l: '0h' },   // 极度缺乏
  { h: 1,  s: 10, l: '1h' },   // 极度缺乏
  { h: 2,  s: 12, l: '2h' },   // 极度缺乏
  { h: 3,  s: 18, l: '3h' },   // 严重不足
  { h: 4,  s: 28, l: '4h' },   // 严重不足
  { h: 5,  s: 50, l: '5h' },   // 轻度不足
  { h: 6,  s: 72, l: '6h' },   // 接近达标
  { h: 7,  s: 88, l: '7h' },   // 最佳区间
  { h: 8,  s: 96, l: '8h' },   // 最佳峰值 🏆
  { h: 9,  s: 85, l: '9h' },   // 最佳区间
  { h: 10, s: 62, l: '10h' },  // 略多
  { h: 11, s: 40, l: '11h' },  // 过量
  { h: 12, s: 25, l: '12h' },  // 过量
];

这些数据虽然不是精确的医学测量值,但准确反映了科学共识中的倒 U 型趋势。


三、架构设计与数据模型

3.1 整体架构

Index.ets(单页面三 Tab)
│
├── 绘图引擎层
│   ├── Canvas 组件
│   ├── CanvasRenderingContext2D
│   ├── drawChart() — 主绘制函数
│   └── onCanvasClick() — 交互处理
│
├── 业务逻辑层
│   ├── dataPt[] — 科学数据
│   ├── getTip() — 选中点建议
│   └── getLevelLabel() — 等级标签
│
├── UI 展示层(@Builder)
│   ├── ChartTab — 图表 + 信息卡 + 关键发现
│   ├── KnowledgeTab — 知识科普 + 睡眠阶段 + 建议
│   └── RecordTab — 记录表单 + 历史列表
│
└── 自定义组件层
    ├── NavBtn — 底部导航项
    ├── KnowledgeCard — 知识卡片
    ├── StageCard — 睡眠阶段卡片
    └── TipRow — 建议行

3.2 数据流

数据层 → Canvas 绘图 → 用户点击 → 坐标计算 → 命中检测
  ↑                                              ↓
  └──────────── @State 更新 ← 选中索引 ← 数据点匹配

3.3 状态变量设计

// Canvas 尺寸(通过 onReady 获取)
@State cw: number = 360;    // Canvas 实际宽度
@State ch: number = 300;    // Canvas 实际高度

// 交互状态
@State selIdx: number = -1; // 当前选中的数据点索引,-1 表示无选中
@State selInfo: string = '点击曲线上的点查看详情'; // 选中信息文字

// 个人记录
@State recSleep: number = 8;  // 记录表单 - 睡眠时长
@State recScore: number = 7;  // 记录表单 - 状态评分
@State records: RecordItem[]; // 历史记录数组

// UI 导航
@State tabIdx: number = 0;  // 当前 Tab 索引

四、Canvas 绘图引擎详解

4.1 Canvas 组件基础

在 ArkUI 中,Canvas 组件是实现自定义绘图的唯一方式。其基本用法如下:

// 1. 创建 CanvasRenderingContext2D 实例
private chartCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D();

// 2. 在 UI 中使用 Canvas 组件并绑定上下文
Canvas(this.chartCtx)
  .width('100%')
  .aspectRatio(1.3)  // 宽高比
  .onReady(() => {
    // Canvas 布局完成,可以开始绘制
    this.drawChart(this.cw, this.ch);
  })

关键要点

  • CanvasRenderingContext2D 必须在组件外部或 private 字段中创建,不能在 build() 中创建
  • .onReady() 回调在布局完成后触发,确保此时 Canvas 已有实际尺寸
  • Canvas 的宽高由父容器布局决定,通过 onReady 获取实际像素值

4.2 坐标映射函数

将数据坐标(睡眠小时 0~12,评分 0~100)映射到 Canvas 像素坐标:

private ml: number = 50;   // 左边距(给 Y 轴标签留空间)
private mr: number = 16;   // 右边距
private mt: number = 20;   // 上边距(给最优区标题留空间)
private mb: number = 40;   // 下边距(给 X 轴标签留空间)

// 绘制区域尺寸
const drawW = w - this.ml - this.mr;
const drawH = h - this.mt - this.mb;

// 小时 → 像素 X
const xPos = (hour: number): number => {
  return this.ml + (hour / 12) * drawW;
};

// 评分 → 像素 Y(注意 Canvas Y 轴向下增长)
const yPos = (score: number): number => {
  return this.mt + drawH - (score / 100) * drawH;
};

Y 轴反转:Canvas 的 Y 轴方向是向下递增的(左上角为原点),而我们的图表中 Y 轴向上递增(分数越高位置越高)。因此需要 drawH - (score/100) * drawH 来反转坐标。

4.3 分层绘制策略

drawChart 函数按照从下到上的顺序逐层绘制,确保正确的叠放顺序:

1. 背景           → fillRect(全屏白色)
2. 最优区高亮      → fillRect(7h~9h 绿色区域)
3. 水平网格线      → stroke(0, 20, 40, 60, 80, 100)
4. Y 轴标签       → fillText(评分)
5. X 轴标签       → fillText(小时)
6. 轴标题         → fillText(睡眠时长 / 记忆力评分)
7. 渐变填充区域    → bezierCurve + fill(半透明蓝色)
8. 曲线            → bezierCurve + stroke(蓝色实线)
9. 数据点          → arc + fill(蓝色/橙色圆点)
10. 选中点高亮     → arc + stroke(橙色外圈) + fillText(数值)

这种分层策略保证了每一层不会被后续绘制覆盖,同时让代码逻辑清晰可维护。

4.4 渐变填充

曲线下方的半透明填充区域增加了图表的视觉深度:

const grad = ctx.createLinearGradient(0, this.mt, 0, h - this.mb);
grad.addColorStop(0, '#3F51B560');  // 顶部:60% 透明度
grad.addColorStop(1, '#3F51B510');  // 底部:10% 透明度
ctx.fillStyle = grad;
ctx.fill();

createLinearGradient 创建垂直方向的线性渐变,顶部颜色深、底部颜色浅,形成「从曲线向下渐隐」的视觉效果。


五、贝塞尔曲线绘制与算法

5.1 三次贝塞尔曲线原理

三次贝塞尔曲线由四个点定义:

  • 起点 (P₀)
  • 控制点 1 (P₁) — 控制起点处的切线方向
  • 控制点 2 (P₂) — 控制终点处的切线方向
  • 终点 (P₃)

公式: B ( t ) = ( 1 − t ) 3 P 0 + 3 ( 1 − t ) 2 t P 1 + 3 ( 1 − t ) t 2 P 2 + t 3 P 3 B(t) = (1-t)^3P₀ + 3(1-t)^2tP₁ + 3(1-t)t²P₂ + t³P₃ B(t)=(1t)3P0+3(1t)2tP1+3(1t)t2P2+t3P3

在我们的图表中,两个相邻数据点之间通过三次贝塞尔曲线连接,控制点取两点之间的中点,使曲线平滑过渡。

5.2 平滑曲线实现

ctx.beginPath();
ctx.strokeStyle = '#3F51B5';
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';

for (let i = 0; i < this.dataPt.length; i++) {
  const x = xPos(this.dataPt[i].h);
  const y = yPos(this.dataPt[i].s);
  if (i === 0) {
    ctx.moveTo(x, y);  // 第一个点:移动到起点
  } else {
    const prevX = xPos(this.dataPt[i - 1].h);
    const prevY = yPos(this.dataPt[i - 1].s);
    const cpx = (prevX + x) / 2;  // 控制点 X = 两点中点
    ctx.bezierCurveTo(cpx, prevY, cpx, y, x, y);
  }
}
ctx.stroke();

控制点策略:取相邻两点的 X 中点作为控制点的 X 坐标,控制点的 Y 坐标分别取前点的 Y 和后点的 Y。这样曲线在穿过数据点时保持水平切线,整体走势平滑自然。

5.3 填充区域路径

填充区域与曲线路径相同,但需要闭合到底部:

ctx.beginPath();
ctx.moveTo(xPos(this.dataPt[0].h), yPos(0));  // 从底部左端开始

// 沿曲线走
for (let i = 0; i < this.dataPt.length; i++) {
  // ... 相同的贝塞尔逻辑 ...
}

ctx.lineTo(xPos(this.dataPt[last].h), yPos(0));  // 到右端底部
ctx.closePath();  // 闭合回起点

5.4 为什么选择贝塞尔而非折线

方式 优点 缺点
折线 (lineTo) 实现简单 视觉生硬,不自然
贝塞尔曲线 (bezierCurveTo) 平滑自然,专业美观 控制点计算稍复杂
Catmull-Rom 样条 通过所有点,平滑 ArkUI Canvas 不支持

对于描述「趋势」的图表,贝塞尔曲线是最佳选择——它传达了数据的整体走向,而非拘泥于每个精确点。


六、交互式图表:点击选取数据点

6.1 点击事件绑定

Canvas(this.chartCtx)
  .onClick((e: ClickEvent) => {
    this.onCanvasClick(e.x, e.y);
  })

ClickEvent 提供了点击位置的 xy 像素坐标,相对于 Canvas 组件自身。

6.2 命中检测算法

onCanvasClick(x: number, y: number): void {
  const drawW = this.cw - this.ml - this.mr;
  const drawH = this.ch - this.mt - this.mb;

  // 坐标映射函数(与 drawChart 中一致)
  const xPos = (hour: number): number => this.ml + (hour / 12) * drawW;
  const yPos = (score: number): number => this.mt + drawH - (score / 100) * drawH;

  let minDist = 30;   // 最大命中距离(像素)
  let found = -1;

  for (let i = 0; i < this.dataPt.length; i++) {
    const dx = x - xPos(this.dataPt[i].h);
    const dy = y - yPos(this.dataPt[i].s);
    const dist = Math.sqrt(dx * dx + dy * dy);
    if (dist < minDist) {
      minDist = dist;
      found = i;
    }
  }

  if (found !== -1) {
    this.selIdx = found;
    const d = this.dataPt[found];
    this.selInfo = `🕐 睡眠 ${d.h}h  →  🧠 记忆力 ${d.s}分\n${this.getTip(d.h, d.s)}`;
    this.drawChart(this.cw, this.ch);  // 重绘图表
  }
}

核心逻辑

  1. 遍历所有 13 个数据点
  2. 计算点击位置与每个数据点像素坐标的欧氏距离
  3. 找到距离最近且小于 30px 的数据点
  4. 如果命中,更新 selIdxselInfo,重绘图表

30px 的容差:考虑到手指点击的精度有限,30px 的命中半径提供了良好的用户体验。用户无需精确点中 5px 的小圆点。

6.3 选中状态可视化

选中数据点后,绘制函数会以特殊样式渲染:

// 更大的圆点
ctx.arc(x, y, isSel ? 7 : 5, 0, Math.PI * 2);
ctx.fillStyle = isSel ? '#FF5722' : '#3F51B5';

// 外圈光晕
if (isSel) {
  ctx.arc(x, y, 11, 0, Math.PI * 2);
  ctx.strokeStyle = '#FF572244';
  ctx.lineWidth = 3;
  ctx.stroke();

  // 数值标签
  ctx.fillStyle = '#FF5722';
  ctx.font = 'bold 13px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText(`${this.dataPt[i].s}`, x, y - 16);
}

选中点变为:

  • 橙色填充(从蓝色变为橙色,形成视觉焦点)
  • 7px 半径(从 5px 放大到 7px)
  • 橙色半透明外圈光晕(11px 半径)
  • 上方显示评分数值标签

6.4 信息反馈卡片

选中点后,图表下方会显示详细的信息卡片:

// 选中信息文本
this.selInfo = `🕐 睡眠 ${d.h}h  →  🧠 记忆力 ${d.s}分\n${this.getTip(d.h, d.s)}`;

// 等级标签
this.getLevelLabel(score: number): string {
  if (score >= 90) return '🏆 优秀';
  if (score >= 70) return '👍 良好';
  if (score >= 50) return '📈 一般';
  if (score >= 30) return '📉 较差';
  return '⚠️ 很差';
}

6.5 个性化建议系统

每个数据区间对应不同的文字建议:

getTip(hour: number, score: number): string {
  if (hour < 4)  return '⚠️ 严重睡眠不足,记忆力极差!';
  if (hour < 6)  return '😴 睡眠不足,大脑无法充分休息';
  if (hour < 7)  return '🌤️ 接近达标,可以多睡一会儿';
  if (hour <= 9) return '🌟 最佳睡眠区间!记忆力表现优秀!';
  if (hour <= 10) return '😊 睡眠充足,但略多可能影响状态';
  return '💤 睡眠过长,可能进入「睡醉」状态';
}

七、UI 设计与布局结构

7.1 整体布局

Column(全屏)
├── TitleBar(56px,#3F51B5 靛蓝色)
├── 内容区域(layoutWeight:1)
│   ├── Tab 0: ChartTab
│   │   ├── Canvas 图表(宽高比 1.3)
│   │   ├── 选中信息卡片(白色圆角)
│   │   └── 关键发现卡片(三条结论)
│   ├── Tab 1: KnowledgeTab(Scroll)
│   │   ├── 睡眠与记忆(3 卡片)
│   │   ├── 睡眠阶段(4 卡片)
│   │   └── 助眠建议(6 条)
│   └── Tab 2: RecordTab
│       ├── 记录表单(Slider × 2 + Button)
│       └── 历史列表(ForEach)
└── BottomNav(56px,三个 Tab)

7.2 色彩方案

以靛蓝色为主色调的学术风格配色:

角色 色值 用途
主色 #3F51B5 标题栏、曲线、按钮
浅蓝 #E8EAF6 图标背景
最优区 #E8F5E9 + #4CAF50 7~9h 高亮区域
选中色 #FF5722 选中数据点、强调文字
背景 #F5F5F5 / #FFFFFF 页面背景 / 卡片
文字 #333 / #666 / #999 主 / 副 / 辅助文字

7.3 Canvas 边距与标签

    ┌────mt=20────┐
    │  🌟 最佳    │
 ml │  ┌────────┐ │ mr
=50 │  │ 曲线区域 │ │ =16
    │  │        │ │
    │  └────────┘ │
    │  0h 4h 8h 12│
    └─────mb=40───┘

精确的边距控制保证了轴标签不会与图表内容重叠。


八、知识图谱 Tab 实现

8.1 内容组织

知识 Tab 以 Scroll 包裹三个知识模块,用户可以上下滑动浏览:

@Builder
KnowledgeTab() {
  Scroll() {
    Column({ space: 16 }) {
      // 模块 1:睡眠与记忆的关系
      ScienceModule()
      // 模块 2:睡眠的四个阶段
      SleepStageModule()
      // 模块 3:科学助眠建议
      TipsModule()
    }
  }
}

8.2 知识卡片组件

@Component
struct KnowledgeCard {
  private icon: string = '';   // 表情图标
  private title: string = '';  // 标题
  private desc: string = '';   // 描述文字

  build() {
    Row({ space: 12 }) {
      Stack() {
        Circle().width(44).height(44).fill('#E8EAF6')
        Text(this.icon).fontSize(22)
      }
      Column({ space: 4 }) {
        Text(this.title).fontSize(15).fontWeight(FontWeight.Medium)
        Text(this.desc).fontSize(13).fontColor('#666').lineHeight(20)
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#FAFAFA')
    .borderRadius(12)
  }
}

设计要点

  • 左侧圆形图标 + 右侧文字的结构清晰易读
  • lineHeight(20) 保证长段文字有舒适的阅读间距
  • 浅灰背景 (#FAFAFA) 产生微妙的层次感

8.3 睡眠阶段卡片

@Component
struct StageCard {
  private stage: string = '';  // 阶段名称
  private time: string = '';   // 占比
  private desc: string = '';   // 描述

  build() {
    Row({ space: 12 }) {
      Column({ space: 2 }) {
        Text(this.stage).fontSize(14).fontWeight(FontWeight.Medium)
        Text(this.time).fontSize(11).fontColor('#999')
      }
      .width(120)
      Text(this.desc).fontSize(13).fontColor('#666').lineHeight(20)
        .layoutWeight(1)
    }
    .padding(10)
    .backgroundColor('#FAFAFA')
    .borderRadius(10)
  }
}

左侧固定 120px 宽显示阶段名称和占比,右侧自适应填充详情描述。

8.4 助眠建议行

@Component
struct TipRow {
  private emoji: string = '';
  private text: string = '';

  build() {
    Row({ space: 10 }) {
      Text(this.emoji).fontSize(18)
      Text(this.text).fontSize(14).fontColor('#555').lineHeight(22)
        .layoutWeight(1)
    }
  }
}

每行一个 Emoji + 一条建议,简洁直观。


九、个人记录 Tab 实现

9.1 数据模型

interface RecordItem {
  date: string;   // 日期,如 "6月7日"
  sleep: number;  // 睡眠时长,如 8
  score: number;  // 状态评分,如 9
}

9.2 记录表单

使用 Slider 组件让用户通过滑动选择数值:

Column({ space: 12 }) {
  Text('📝 记录今天的睡眠与状态')
    .fontSize(16)
    .fontWeight(FontWeight.Bold)

  // 睡眠时长滑块
  Text(`🕐 睡眠时长: ${this.recSleep}h`).fontSize(14)
  Slider({
    value: this.recSleep, min: 0, max: 12, step: 0.5,
    style: SliderStyle.OutSet
  })
    .blockColor('#3F51B5')
    .trackColor('#C5CAE9')
    .selectedColor('#3F51B5')
    .onChange((v: number) => { this.recSleep = v; })

  // 状态评分滑块
  Text(`🧠 今日状态: ${this.recScore} 分 / 10 分`).fontSize(14)
  Slider({
    value: this.recScore, min: 1, max: 10, step: 1,
    style: SliderStyle.OutSet
  })
    .blockColor('#FF5722')
    .trackColor('#FFCCBC')
    .selectedColor('#FF5722')
    .onChange((v: number) => { this.recScore = v; })

  // 保存按钮
  Button('📌 保存记录')
    .backgroundColor('#3F51B5').borderRadius(20)
    .onClick(() => { this.addRecord(); })
}

Slider 参数详解

  • value:当前值,绑定 @State 变量
  • min/max:取值范围
  • step:步进精度(睡眠 0.5h,评分 1 分)
  • style: OutSet:滑块样式为凸出风格
  • blockColor/trackColor/selectedColor:分别控制滑块头、轨道底色、选中段颜色

9.3 历史记录列表

ForEach(this.records, (r: RecordItem) => {
  Row({ space: 12 }) {
    // 左侧图标
    Stack() {
      Circle().width(40).height(40).fill('#E8EAF6')
      Text('🌙').fontSize(20)
    }
    // 中间信息
    Column({ space: 2 }) {
      Text(r.date).fontSize(14).fontWeight(FontWeight.Medium)
      Text(`🕐 ${r.sleep}h 睡眠`).fontSize(12).fontColor('#999')
    }
    .layoutWeight(1)
    // 右侧分数
    Column({ space: 2 }) {
      Text(`${r.score}`)
        .fontSize(18).fontWeight(FontWeight.Bold)
        .fontColor(r.score >= 8 ? '#4CAF50' : r.score >= 5 ? '#FF9800' : '#F44336')
      Text('/ 10').fontSize(10).fontColor('#CCC')
    }
    .alignItems(HorizontalAlign.Center)
  }
  .padding(12)
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .border({ width: 1, color: '#F0F0F0' })
}, (r: RecordItem) => r.date + r.sleep)

评分颜色编码

  • 8~10 分:绿色 #4CAF50 — 优秀
  • 5~7 分:橙色 #FF9800 — 一般
  • 1~4 分:红色 #F44336 — 较差

这种颜色编码让用户一眼就能识别出自己状态的好坏趋势。


十、响应式布局与适配

10.1 Canvas 自适应

Canvas 在 onReady 时获取实际布局尺寸,而非硬编码:

Canvas(this.chartCtx)
  .width('100%')
  .aspectRatio(1.3)
  .onReady(() => {
    setTimeout(() => {
      // 获取实际尺寸后绘制
      this.drawChart(this.cw, this.ch);
    }, 50);
  })

aspectRatio(1.3) 保证 Canvas 的宽高比始终为 1.3:1,在不同宽度的屏幕上保持统一的视觉比例。

10.2 延迟绘制

setTimeout(..., 50) 的 50ms 延迟是为了等布局稳定后再读取尺寸。这个短延迟确保了 onReady 回调时布局计算已彻底完成。

10.3 字体自适应

Canvas 中所有文字都使用相对于画布大小的固定位置,而非像素绝对值。坐标映射函数 xPos / yPos 将数据域映射到像素域,天然支持不同尺寸的画布。

10.4 Scroll 保证可滚动

知识 Tab 和个人记录 Tab 的内容可能超出屏幕高度,外层包裹 Scroll 确保内容可滚动查看。


十一、常见问题与编译错误排查

11.1 overlay builder 类型不匹配

错误信息

Argument of type '{ builder: () => TextAttribute }' is not assignable to
parameter of type 'string | CustomBuilder | ComponentContent<Object>'.

原因:在 Circle 等形状组件上使用 .overlay({ builder: () => Text(...) }) 时,箭头函数返回了 TextAttribute 类型,但 CustomBuilder 需要返回 void 的 @Builder 函数。

解决方案:使用 Stack 包裹 Circle 和 Text 替代 overlay:

// ❌ 错误
Circle().overlay({ builder: () => Text('🌙').fontSize(20) })

// ✅ 正确
Stack() {
  Circle().width(40).height(40).fill('#E8EAF6')
  Text('🌙').fontSize(20)
}

11.2 Canvas 不显示

原因:Canvas 没有设置宽高,或 onReady 未触发。

解决方案

  • 设置 .width('100%').height(300).aspectRatio(1.3)
  • 确保 Canvas 的父容器有明确的高度
  • onReady 中调用绘制函数

11.3 点击坐标不准确

原因:Canvas 的点击事件坐标相对于 Canvas 元素,但未考虑 Canvas 在页面中的滚动偏移。

解决方案:确保 Canvas 不被 Scroll 包裹(或通过 TouchEventgetHistoricalRawPointerEvent 获取绝对坐标)。

11.4 @Builder 内声明变量

错误信息Only UI component syntax can be written here.

原因:在 @Builder 中声明了局部变量。

解决方案:直接调用方法代替变量存储:

// ❌ 错误
@Builder MyBuilder() {
  const msg = this.getText();  // 不允许
  if (msg.length > 0) { Text(msg) }
}

// ✅ 正确
@Builder MyBuilder() {
  if (this.getText().length > 0) { Text(this.getText()) }
}

11.5 Canvas 绘制后未更新

原因drawChart 是直接调用 Canvas API 绘制的,纯命令式操作。如果 @State 变化触发了 UI 重渲染,Canvas 的内容不会自动保留。

解决方案:在 @State 变化后的回调中显式调用 drawChart

onCanvasClick(x: number, y: number): void {
  this.selIdx = found;      // @State 变化
  this.selInfo = '...';      // @State 变化
  this.drawChart(this.cw, this.ch);  // 手动重绘
}

11.6 图表在 Previewer 中不显示

原因:DevEco Studio 的 Previewer 对 Canvas 的支持有限。

解决方案

  • 在真机或模拟器上测试
  • 确认 Canvas 组件有明确的宽高
  • onReady 中添加日志确认触发

十二、数据可视化设计原则

12.1 数据墨水比(Data-Ink Ratio)

数据可视化专家 Edward Tufte 提出的「数据墨水比」原则:图表中用于展示数据的墨水应占绝大部分,装饰性墨水应尽量减少。

在本应用中:

  • 高数据墨水:曲线、坐标轴、数据点、网格线
  • 低数据墨水:渐变填充(半透明,不干扰数据读取)
  • 零冗余:每个视觉元素都有特定的信息承载功能

12.2 图表类型选择

对于展示两个连续变量之间的关系,折线图 是最合适的选择:

关系类型 推荐图表 原因
趋势变化 折线图 突出走向
比例分布 饼图 / 环形图 强调占比
数据对比 柱状图 便于比较
相关性 散点图 + 趋势线 展示分布

我们的数据是「睡眠小时」和「记忆力评分」两个连续变量的关系,折线图既能展示趋势,又能通过曲线平滑度传达「渐变」的含义。

12.3 色彩心理学在图表中的应用

颜色 在图表中的作用 心理暗示
蓝色 #3F51B5 曲线、主色 信任、专业、冷静
绿色 #E8F5E9 最优区高亮 健康、安全、推荐
橙色 #FF5722 选中点强调 注意、焦点、行动
红色 #F44336 低分警示 警告、危险

12.4 交互式探索

静态图表只能传达预设的信息。交互式图表让用户自主探索数据:

  • 选择数据点:查看详细数值和解读
  • 聚焦高亮:被选中的点获得视觉强调
  • 即时反馈:点击后立即显示相关信息

12.5 信息层级

第一层级:曲线整体趋势(一眼看到倒 U 型)
  ↓
第二层级:坐标轴(读取具体数值)
  ↓
第三层级:最优区间(绿色背景强调)
  ↓
第四层级:交互选中详情(点击后获取)

好的数据可视化应该支持「扫描式阅读」——用户从概括到细节逐步深入。


十三、总结与扩展方向

13.1 核心知识点回顾

通过睡眠与记忆力对比曲线应用的开发,我们掌握了以下关键技术:

技术 应用 关键代码
Canvas 绘图 自定义曲线图表 Canvas(ctx).onReady()
CanvasRenderingContext2D 绘制曲线、文字、图形 bezierCurveTo(), arc(), fillText()
贝塞尔曲线 平滑连接数据点 bezierCurveTo(cpx, prevY, cpx, y, x, y)
点击命中检测 数据点选取 欧氏距离遍历
渐变填充 曲线下方颜色渐变 createLinearGradient()
Slider 组件 记录表单数值输入 Slider({ min, max, step })
条件渲染 Tab 切换 if (this.tabIdx === 0)
Scroll 组件 长内容滚动 Scroll() { Column(...) }

13.2 与前三篇博客的对比

应用 核心组件 核心技能 篇幅
栅格布局 GridRow/GridCol 布局系统 ~9500 字
儿童闹钟 Text/Button/Toggle 状态 + 定时器 ~9000 字
豆豆计数器 @Builder/动画 动画 + 里程碑 ~8500 字
睡眠 vs 记忆力 Canvas/图表 数据可视化 ~9000 字

四个应用从布局→交互→动画→可视化,完整覆盖了鸿蒙 ArkUI 应用开发的主要知识领域。

13.3 扩展方向

1. 真实数据源
当前数据是预设的近似值。可以接入健康类 API(如 @ohos.health)获取用户的真实睡眠数据,与自评记忆力对比,生成个性化曲线。

2. 多维度可视化
除了睡眠时长,还可以加入:

  • 入睡时间(早睡 vs 晚睡)
  • 睡眠质量评分(深睡比例)
  • 运动量对记忆力的影响

3. 动画引导
页面加载时曲线从左侧到右侧逐段绘制,增加视觉动感。

4. 长按显示所有数据
长按图表时显示所有数据点的数值标签,松开后恢复简洁模式。

5. 导出分享
将图表导出为图片,分享到社交媒体或保存到相册。

6. 趋势追踪
将历史记录数据叠加到曲线图上,让用户看到自己的状态落在曲线的哪个位置。

13.4 从开发者到数据叙事者

数据可视化不仅仅是「画图表」。好的数据可视化是数据叙事(Data Storytelling)——用视觉语言讲述数据背后的故事。

在这个应用中,我们讲述的故事是:

你的睡眠时长如何影响你的大脑表现?太少不行,太多也不好,7~9 小时是最佳选择。

图表、颜色、交互、文字——所有设计元素都服务于这个核心信息。

13.5 写在最后

从第一个栅格布局到第四个数据可视化应用,我们走过了鸿蒙 ArkUI 开发的完整学习路径。每一步都引入了新的技术概念,每一步都构建了更复杂的交互体验。

睡眠与记忆力对比曲线应用的独特价值在于,它展示了如何用技术解决真实问题——帮助用户理解一个重要的健康概念,并提供实用工具(个人记录)来改善自身习惯。

希望这篇文章能为你在数据可视化领域的探索提供启发和参考。


本文所有代码基于 HarmonyOS API 24(6.1.1),使用 ArkTS 语言编写。完整源码请参考项目中的 Index.ets 文件。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐