在这里插入图片描述

每日一句正能量

“不要因为未知而退缩,也不要因为困难而止步。”
未知不等于危险,困难不等于绝境。真正拦住你的,往往不是路本身,而是你对路的想象。

摘要

摘要:数据可视化是移动应用从「信息展示」迈向「智能决策」的关键桥梁。在 HarmonyOS NEXT 生态中,虽然第三方图表库(如 McCharts、HarmonyCharts)提供了开箱即用的方案,但面对定制化需求、极致性能与深度交互场景,原生 Canvas 自研仍是不可替代的技术路径。本文基于 HarmonyOS 6(API 23)最新 Canvas 2D 能力,从坐标系映射、贝塞尔曲线平滑、区域渐变填充、触摸命中检测到逐帧动画系统,深度拆解「数脉引擎」原生折线图组件的完整工程实现方案。通过配置驱动架构与渲染器分离设计,打造企业级高性能、可扩展的数据可视化解决方案。


一、前言:为什么需要自研折线图组件?

在鸿蒙应用开发中,数据可视化场景日益丰富:运营报表、健康趋势、股票行情、IoT 设备监控……折线图作为最基础也最重要的图表类型,承担着展示时间序列趋势的核心职责。然而,开发者在实际落地时往往面临以下抉择与痛点:

  • 第三方库的局限:McCharts、HarmonyCharts 等库虽能快速出图,但在自定义交互手势、特殊动画效果、主题深度定制时灵活性不足,且引入额外依赖会增加包体积;
  • 性能瓶颈:长周期数据(如 365 天股票走势)在 WebView 中渲染帧率骤降,而原生 Canvas 直接操作 GPU 渲染管线,性能提升 3~5 倍;
  • 交互深度不足:业务需求常要求「长按显示详细 Tooltip」「双指缩放时间轴」「数据点拖拽编辑」等高级交互,第三方库往往无法直接支持;
  • 多端一致性:同一套图表逻辑需要在手机、平板、车机等多设备上保持一致的渲染效果,自研组件可通过响应式布局统一适配。

本文以「数脉引擎」项目为载体,系统讲解如何基于 ArkTS + Canvas 2D 从零构建一套功能完备、性能优异、交互深度的原生折线图组件。


二、整体架构设计:四层分离,配置驱动

在这里插入图片描述

架构设计遵循「四层分离」原则:

层级职责核心模块
UI 展示层Canvas 画布承载、图例、提示框、坐标轴标签Canvas、Legend、Tooltip、AxisLabel、Title
业务逻辑层图表状态管理、折线渲染调度、坐标计算、命中检测、动画引擎ChartViewModel、LineRenderer、AxisCalculator、HitTester、Animator
渲染层Path2D 路径绘制、贝塞尔曲线、线性渐变、圆形绘制、画布清理Path2D、bezierCurveTo、createLinearGradient、arc、clearRect
数据层时间序列数据、多系列指标、配置参数、主题样式ChartData、SeriesData、ChartConfig、ChartTheme

设计要点:采用「配置驱动」架构,所有视觉表现通过 ChartConfig 对象描述,渲染器只负责执行绘制指令。这种设计使得同一套渲染引擎可以支持折线图、面积图、柱状图等多种图表类型的切换,只需更换配置即可。cite🛠web_search:24#4:~:text=配置驱动、全自定义、纯ArkTS打造柱状图/饼图/折线图


三、Canvas 2D 基础与坐标系映射

3.1 Canvas 组件初始化

HarmonyOS NEXT 提供的 Canvas 组件基于 Skia 图形引擎,支持完整的 2D 渲染 API。基础配置如下:

import { Canvas, CanvasRenderingContext2D, RenderingContextSettings } from '@kit.ArkUI';

@Entry
@Component
struct LineChartPage {
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  private canvasWidth: number = 0;
  private canvasHeight: number = 0;

  build() {
    Column() {
      Canvas(this.ctx)
        .width('100%')
        .height(400)
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        .onReady(() => {
          this.canvasWidth = this.ctx.width;
          this.canvasHeight = this.ctx.height;
          this.renderChart();
        })
        .onTouch((event: TouchEvent) => {
          this.handleTouch(event);
        })
    }
    .width('100%')
    .height('100%')
    .padding(16)
  }
}

关键配置:RenderingContextSettings(true) 开启抗锯齿,确保折线边缘平滑无锯齿。onReady 回调中获取 Canvas 实际宽高,所有绘制坐标基于此计算。cite🛠web_search:24#8:~:text=private settings: RenderingContextSettings = new RenderingContextSettings(true)

3.2 坐标系映射原理

折线图的核心数学问题,是将「数据坐标系」(Data Space)中的数值映射到「Canvas 像素坐标系」(Pixel Space)。

在这里插入图片描述

interface ChartPadding {
  top: number;
  right: number;
  bottom: number;
  left: number;
}

class AxisCalculator {
  private padding: ChartPadding = { top: 40, right: 20, bottom: 40, left: 50 };
  private canvasWidth: number = 0;
  private canvasHeight: number = 0;
  private dataMin: number = 0;
  private dataMax: number = 100;

  setCanvasSize(width: number, height: number): void {
    this.canvasWidth = width;
    this.canvasHeight = height;
  }

  setDataRange(min: number, max: number): void {
    this.dataMin = min;
    this.dataMax = max;
  }

  // 计算绘图区域
  getChartArea(): { left: number; top: number; width: number; height: number } {
    return {
      left: this.padding.left,
      top: this.padding.top,
      width: this.canvasWidth - this.padding.left - this.padding.right,
      height: this.canvasHeight - this.padding.top - this.padding.bottom
    };
  }

  // 数据值 → Canvas Y 坐标(注意Canvas Y轴向下为正,需翻转)
  valueToY(value: number): number {
    const area = this.getChartArea();
    const ratio = (value - this.dataMin) / (this.dataMax - this.dataMin);
    return area.top + area.height * (1 - ratio);
  }

  // 数据索引 → Canvas X 坐标
  indexToX(index: number, totalCount: number): number {
    const area = this.getChartArea();
    const stepX = area.width / (totalCount - 1);
    return area.left + index * stepX;
  }

  // 计算「友好」的Y轴刻度(Nice Number算法)
  calculateNiceTicks(count: number = 5): number[] {
    const range = this.dataMax - this.dataMin;
    const roughStep = range / count;
    const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep)));
    const normalizedStep = roughStep / magnitude;

    let niceStep: number;
    if (normalizedStep <= 1) niceStep = 1 * magnitude;
    else if (normalizedStep <= 2) niceStep = 2 * magnitude;
    else if (normalizedStep <= 5) niceStep = 5 * magnitude;
    else niceStep = 10 * magnitude;

    const niceMin = Math.floor(this.dataMin / niceStep) * niceStep;
    const niceMax = Math.ceil(this.dataMax / niceStep) * niceStep;

    const ticks: number[] = [];
    for (let v = niceMin; v <= niceMax; v += niceStep) {
      ticks.push(v);
    }
    return ticks;
  }
}

Nice Number 算法:Y 轴刻度不能直接使用数据的最小/最大值,否则会出现「137.5」这类不友好的数字。通过上述算法,刻度总是落在 1、2、5 的倍数上,大幅提升可读性。cite🛠web_search:24#4:~:text=坐标轴和网格线绘制逻辑与柱状图类似


四、折线图核心渲染:从网格到数据点

4.1 网格线与坐标轴绘制

class LineChartRenderer {
  private calculator: AxisCalculator;
  private ctx: CanvasRenderingContext2D;

  constructor(ctx: CanvasRenderingContext2D, calculator: AxisCalculator) {
    this.ctx = ctx;
    this.calculator = calculator;
  }

  // 绘制网格线
  drawGrid(ticks: number[], labels: string[]): void {
    const area = this.calculator.getChartArea();
    this.ctx.lineWidth = 1;

    // 横向网格线(对应Y轴刻度)
    ticks.forEach(tick => {
      const y = this.calculator.valueToY(tick);
      this.ctx.beginPath();
      this.ctx.strokeStyle = '#EEEEEE';
      this.ctx.setLineDash([4, 4]);  // 虚线
      this.ctx.moveTo(area.left, y);
      this.ctx.lineTo(area.left + area.width, y);
      this.ctx.stroke();
    });

    // 纵向网格线(对应X轴标签)
    labels.forEach((_, index) => {
      const x = this.calculator.indexToX(index, labels.length);
      this.ctx.beginPath();
      this.ctx.strokeStyle = '#EEEEEE';
      this.ctx.setLineDash([4, 4]);
      this.ctx.moveTo(x, area.top);
      this.ctx.lineTo(x, area.top + area.height);
      this.ctx.stroke();
    });

    this.ctx.setLineDash([]);  // 恢复实线
  }

  // 绘制坐标轴
  drawAxes(ticks: number[], labels: string[]): void {
    const area = this.calculator.getChartArea();
    this.ctx.lineWidth = 1.5;
    this.ctx.strokeStyle = '#BDBDBD';

    // X轴
    this.ctx.beginPath();
    this.ctx.moveTo(area.left, area.top + area.height);
    this.ctx.lineTo(area.left + area.width, area.top + area.height);
    this.ctx.stroke();

    // Y轴
    this.ctx.beginPath();
    this.ctx.moveTo(area.left, area.top);
    this.ctx.lineTo(area.left, area.top + area.height);
    this.ctx.stroke();

    // Y轴标签
    this.ctx.fillStyle = '#757575';
    this.ctx.font = '12px sans-serif';
    this.ctx.textAlign = 'right';
    this.ctx.textBaseline = 'middle';
    ticks.forEach(tick => {
      const y = this.calculator.valueToY(tick);
      this.ctx.fillText(tick.toString(), area.left - 8, y);
    });

    // X轴标签
    this.ctx.textAlign = 'center';
    this.ctx.textBaseline = 'top';
    labels.forEach((label, index) => {
      const x = this.calculator.indexToX(index, labels.length);
      this.ctx.fillText(label, x, area.top + area.height + 8);
    });
  }
}

4.2 折线绘制:直线与贝塞尔平滑

折线图的视觉核心在于线条的绘制。基础版本使用 lineTo 连接各点,进阶版本使用三次贝塞尔曲线实现平滑效果。

interface DataPoint {
  label: string;
  value: number;
}

// 计算贝塞尔曲线控制点(Catmull-Rom 样条转 Cubic Bezier)
private calculateControlPoints(
  points: { x: number; y: number }[],
  tension: number = 0.5
): [Array<{ x: number; y: number }>, Array<{ x: number; y: number }>] {
  const cp1: Array<{ x: number; y: number }> = [];
  const cp2: Array<{ x: number; y: number }> = [];
  const n = points.length;
  const k = tension / 3;

  for (let i = 0; i < n - 1; i++) {
    const p0 = points[Math.max(0, i - 1)];
    const p1 = points[i];
    const p2 = points[Math.min(n - 1, i + 1)];
    const p3 = points[Math.min(n - 1, i + 2)];

    // 后向控制点 (CP1)
    cp1.push({
      x: p1.x + (p2.x - p0.x) * k,
      y: p1.y + (p2.y - p0.y) * k
    });

    // 前向控制点 (CP2)
    cp2.push({
      x: p2.x - (p3.x - p1.x) * k,
      y: p2.y - (p3.y - p1.y) * k
    });
  }
  return [cp1, cp2];
}

// 绘制折线(支持平滑模式)
drawLine(data: DataPoint[], smooth: boolean = true, color: string = '#2196F3'): void {
  if (data.length < 2) return;

  const points = data.map((item, index) => ({
    x: this.calculator.indexToX(index, data.length),
    y: this.calculator.valueToY(item.value)
  }));

  this.ctx.beginPath();
  this.ctx.strokeStyle = color;
  this.ctx.lineWidth = 2.5;
  this.ctx.lineCap = 'round';
  this.ctx.lineJoin = 'round';

  this.ctx.moveTo(points[0].x, points[0].y);

  if (smooth && points.length > 2) {
    const [cp1, cp2] = this.calculateControlPoints(points, 0.5);
    for (let i = 0; i < points.length - 1; i++) {
      this.ctx.bezierCurveTo(
        cp1[i].x, cp1[i].y,
        cp2[i].x, cp2[i].y,
        points[i + 1].x, points[i + 1].y
      );
    }
  } else {
    for (let i = 1; i < points.length; i++) {
      this.ctx.lineTo(points[i].x, points[i].y);
    }
  }

  this.ctx.stroke();
}

贝塞尔曲线原理:bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) 使用两个控制点定义曲线形状。tension = 0.5 时,控制点位于相邻线段中点连线的 1/6 处,生成最自然的平滑效果。cite🛠web_search:24#8:~:text=计算后向控制点 (CP1)…计算前向控制点 (CP2)


五、区域填充与渐变效果

5.1 渐变区域填充

在折线图下方填充半透明渐变区域,可以直观展示数据量的「厚度」,是专业数据可视化的标配。

// 绘制区域填充(折线下方)
drawArea(data: DataPoint[], color: string = '#2196F3'): void {
  if (data.length < 2) return;

  const area = this.calculator.getChartArea();
  const points = data.map((item, index) => ({
    x: this.calculator.indexToX(index, data.length),
    y: this.calculator.valueToY(item.value)
  }));

  // 创建线性渐变(从上到下,颜色渐变为透明)
  const gradient = this.ctx.createLinearGradient(0, area.top, 0, area.top + area.height);
  gradient.addColorStop(0, this.hexToRgba(color, 0.3));
  gradient.addColorStop(1, this.hexToRgba(color, 0.0));

  this.ctx.beginPath();
  this.ctx.moveTo(points[0].x, area.top + area.height);  // 左下角

  // 沿折线路径绘制
  for (let i = 0; i < points.length; i++) {
    this.ctx.lineTo(points[i].x, points[i].y);
  }

  this.ctx.lineTo(points[points.length - 1].x, area.top + area.height);  // 右下角
  this.ctx.closePath();
  this.ctx.fillStyle = gradient;
  this.ctx.fill();
}

// 十六进制颜色转 rgba 字符串
private hexToRgba(hex: string, alpha: number): string {
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

渐变方向:createLinearGradient(0, top, 0, bottom) 创建垂直渐变,折线顶部颜色最深(alpha=0.3),底部完全透明(alpha=0.0),形成自然的「淡出」效果。cite🛠web_search:24#8:~:text=创建渐变填充…gradient.addColorStop(0, ‘rgba(135, 206, 235, 0.3)’)


六、触摸交互:命中检测与 Tooltip

6.1 命中检测算法

用户触摸 Canvas 时,需要判断触摸点「命中」了哪个数据点。核心算法是计算触摸点与所有数据点的欧氏距离,取最小值并与阈值比较。

class HitTester {
  private pointPositions: Array<{ x: number; y: number }> = [];
  private hitRadius: number = 15;  // 命中半径(像素)

  // 缓存数据点坐标(每次重绘后更新)
  cachePoints(points: Array<{ x: number; y: number }>): void {
    this.pointPositions = points;
  }

  // 命中测试:返回命中的数据点索引,未命中返回 -1
  hitTest(touchX: number, touchY: number): number {
    let minDistance = Infinity;
    let hitIndex = -1;

    for (let i = 0; i < this.pointPositions.length; i++) {
      const pt = this.pointPositions[i];
      // 欧氏距离 = sqrt(dx^2 + dy^2)
      const distance = Math.hypot(touchX - pt.x, touchY - pt.y);
      if (distance < this.hitRadius && distance < minDistance) {
        minDistance = distance;
        hitIndex = i;
      }
    }
    return hitIndex;
  }
}

6.2 Tooltip 绘制

命中数据点后,需要在对应位置绘制一个信息气泡,展示详细数据。

// 绘制高亮效果 + Tooltip
drawHighlight(index: number, data: DataPoint[], color: string = '#2196F3'): void {
  if (index < 0 || index >= this.pointPositions.length) return;

  const pt = this.pointPositions[index];

  // 1. 放大高亮数据点
  this.ctx.fillStyle = color;
  this.ctx.beginPath();
  this.ctx.arc(pt.x, pt.y, 8, 0, 2 * Math.PI);
  this.ctx.fill();
  this.ctx.strokeStyle = '#FFFFFF';
  this.ctx.lineWidth = 2;
  this.ctx.beginPath();
  this.ctx.arc(pt.x, pt.y, 8, 0, 2 * Math.PI);
  this.ctx.stroke();

  // 2. 绘制虚线圆环(强调效果)
  this.ctx.strokeStyle = color;
  this.ctx.lineWidth = 1;
  this.ctx.setLineDash([3, 3]);
  this.ctx.beginPath();
  this.ctx.arc(pt.x, pt.y, 14, 0, 2 * Math.PI);
  this.ctx.stroke();
  this.ctx.setLineDash([]);

  // 3. 绘制 Tooltip 气泡
  const text = `${data[index].label}: ${data[index].value}`;
  this.ctx.font = 'bold 13px sans-serif';
  const textWidth = this.ctx.measureText(text).width;
  const bubbleW = textWidth + 24;
  const bubbleH = 32;
  const bubbleX = pt.x - bubbleW / 2;
  const bubbleY = pt.y - 50;

  // 气泡背景(圆角矩形)
  this.drawRoundRect(this.ctx, bubbleX, bubbleY, bubbleW, bubbleH, 6);
  this.ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
  this.ctx.fill();

  // 气泡文字
  this.ctx.fillStyle = '#FFFFFF';
  this.ctx.textAlign = 'center';
  this.ctx.textBaseline = 'middle';
  this.ctx.fillText(text, pt.x, bubbleY + bubbleH / 2);

  // 4. 绘制指向数据点的小三角
  this.ctx.beginPath();
  this.ctx.moveTo(pt.x - 6, bubbleY + bubbleH);
  this.ctx.lineTo(pt.x, bubbleY + bubbleH + 6);
  this.ctx.lineTo(pt.x + 6, bubbleY + bubbleH);
  this.ctx.closePath();
  this.ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
  this.ctx.fill();
}

// 绘制圆角矩形路径
private drawRoundRect(
  ctx: CanvasRenderingContext2D,
  x: number, y: number, w: number, h: number, r: number
): void {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + w - r, y);
  ctx.quadraticCurveTo(x + w, y, x + w, y + r);
  ctx.lineTo(x + w, y + h - r);
  ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
  ctx.lineTo(x + r, y + h);
  ctx.quadraticCurveTo(x, y + h, x, y + h - r);
  ctx.lineTo(x, y + r);
  ctx.quadraticCurveTo(x, y, x + r, y);
  ctx.closePath();
}

交互设计要点:命中区域扩大到 hitRadius = 15 像素(数据点半径仅 4 像素),大幅提升触摸命中率。Tooltip 采用黑色半透明气泡 + 白色文字,确保在任何背景色下都清晰可读。cite🛠web_search:24#4:~:text=命中测试:计算触摸点与所有缓存数据点的距离…扩大命中区域,提升交互体验


七、动画系统:让折线「生长」起来

7.1 逐帧动画引擎

折线图的入场动画采用「从左到右生长」的效果,通过控制可见数据点的数量实现。

class ChartAnimator {
  private startTime: number = 0;
  private duration: number = 1000;  // 动画时长(ms)
  private isRunning: boolean = false;
  private animationFrameId: number = -1;

  start(
    onProgress: (progress: number) => void,
    onComplete: () => void,
    duration: number = 1000
  ): void {
    this.duration = duration;
    this.startTime = Date.now();
    this.isRunning = true;

    const frame = () => {
      if (!this.isRunning) return;

      const elapsed = Date.now() - this.startTime;
      const rawProgress = Math.min(elapsed / this.duration, 1);

      // 应用缓动函数:EaseInOutCubic
      const progress = this.easeInOutCubic(rawProgress);
      onProgress(progress);

      if (rawProgress < 1) {
        this.animationFrameId = requestAnimationFrame(frame);
      } else {
        this.isRunning = false;
        onComplete();
      }
    };

    this.animationFrameId = requestAnimationFrame(frame);
  }

  // EaseInOutCubic 缓动:开始慢→中间快→结束慢
  private easeInOutCubic(t: number): number {
    return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
  }

  stop(): void {
    this.isRunning = false;
    if (this.animationFrameId !== -1) {
      cancelAnimationFrame(this.animationFrameId);
    }
  }
}

7.2 动画与渲染联动

// 在 Chart 组件中整合动画
private startAnimation(): void {
  this.animator.start(
    (progress) => {
      // 根据进度计算可见数据点数量
      const visibleCount = Math.max(2, Math.ceil(this.data.length * progress));
      this.visibleDataCount = visibleCount;
      this.redraw(progress);
    },
    () => {
      // 动画结束,确保绘制完整图形
      this.redraw(1);
    },
    this.config.animationDuration ?? 1000
  );
}

// 重绘方法
private redraw(progress: number): void {
  if (this.canvasWidth === 0 || this.canvasHeight === 0) return;

  // 1. 清空画布
  this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);

  // 2. 绘制背景
  this.ctx.fillStyle = this.config.backgroundColor ?? '#FFFFFF';
  this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);

  // 3. 绘制网格与坐标轴
  const ticks = this.calculator.calculateNiceTicks();
  this.renderer.drawGrid(ticks, this.labels);
  this.renderer.drawAxes(ticks, this.labels);

  // 4. 绘制折线(仅绘制可见部分)
  const visibleData = this.data.slice(0, this.visibleDataCount);
  this.renderer.drawLine(visibleData, this.config.smooth ?? true, this.config.lineColor);

  // 5. 绘制区域填充
  if (this.config.fillArea) {
    this.renderer.drawArea(visibleData, this.config.lineColor);
  }

  // 6. 绘制数据点
  this.renderer.drawPoints(visibleData, this.config.lineColor);

  // 7. 绘制高亮(如果有选中的数据点)
  if (this.selectedIndex !== -1 && this.selectedIndex < this.visibleDataCount) {
    this.renderer.drawHighlight(this.selectedIndex, this.data, this.config.lineColor);
  }
}

动画原理:visibleCount = Math.ceil(n * progress) 控制每帧绘制的数据点数量,配合 EaseInOutCubic 缓动函数,折线呈现出「先快后慢」的自然生长效果,比线性动画更具视觉吸引力。cite🛠web_search:24#4:~:text=动画系统…根据动画进度,决定绘制多少个点


八、多系列折线图与图例

8.1 多系列数据渲染

interface SeriesData {
  name: string;
  color: string;
  data: DataPoint[];
}

// 绘制多系列折线图
renderMultiSeries(seriesList: SeriesData[]): void {
  // 1. 计算所有系列的数据范围,统一Y轴刻度
  let globalMin = Infinity;
  let globalMax = -Infinity;
  seriesList.forEach(series => {
    series.data.forEach(item => {
      globalMin = Math.min(globalMin, item.value);
      globalMax = Math.max(globalMax, item.value);
    });
  });
  this.calculator.setDataRange(globalMin, globalMax);

  // 2. 绘制每个系列
  seriesList.forEach(series => {
    if (this.config.fillArea) {
      this.renderer.drawArea(series.data, series.color);
    }
    this.renderer.drawLine(series.data, this.config.smooth ?? true, series.color);
    this.renderer.drawPoints(series.data, series.color);
  });
}

// 绘制图例
@Builder
LegendView(seriesList: SeriesData[]) {
  Row() {
    ForEach(seriesList, (series: SeriesData) => {
      Row() {
        Circle({ width: 10, height: 10 })
          .fill(series.color)
        Text(series.name)
          .fontSize(12)
          .fontColor('#424242')
          .margin({ left: 4 })
      }
      .margin({ right: 16 })
    })
  }
  .width('100%')
  .justifyContent(FlexAlign.Center)
  .margin({ top: 8, bottom: 8 })
}

九、性能优化与最佳实践

Canvas 2D 渲染虽然性能优异,但在大数据量场景下仍需针对性优化:

优化项具体方案预期效果
坐标缓存数据点坐标在首次计算后缓存,避免每帧重复计算减少 40% CPU 占用
脏矩形渲染仅重绘变化区域(如 Tooltip 区域),而非全量 clearRect大数据量场景帧率提升 2 倍
数据降采样超过 200 个数据点时,使用 LTTB 算法降采样保持视觉形状的同时减少 80% 绘制点
离屏 Canvas静态元素(网格、坐标轴)预渲染到离屏 Canvas每帧只需绘制动态折线
避免状态触发动画过程中不更新 @State,直接操作 Canvas绕过 ArkUI 状态管理开销
// LTTB(Largest Triangle Three Buckets)降采样算法
private lttbDownsample(data: DataPoint[], threshold: number): DataPoint[] {
  if (data.length <= threshold) return data;

  const sampled: DataPoint[] = [];
  let sampledIndex = 0;
  const a = 0;  // 始终保留第一个点
  sampled[sampledIndex++] = data[a];

  for (let i = 0; i < threshold - 2; i++) {
    const avgRangeStart = Math.floor((data.length - 1) * i / (threshold - 2)) + 1;
    const avgRangeEnd = Math.floor((data.length - 1) * (i + 1) / (threshold - 2)) + 1;
    const avgRangeLength = avgRangeEnd - avgRangeStart;

    let avgX = 0, avgY = 0;
    for (let j = avgRangeStart; j < avgRangeEnd; j++) {
      avgX += j;
      avgY += data[j].value;
    }
    avgX /= avgRangeLength;
    avgY /= avgRangeLength;

    const rangeOffs = Math.floor((data.length - 1) * i / (threshold - 2)) + 1;
    const rangeTo = Math.floor((data.length - 1) * (i + 1) / (threshold - 2)) + 1;

    let maxArea = -1;
    let maxIdx = rangeOffs;
    for (let j = rangeOffs; j < rangeTo; j++) {
      const area = Math.abs(
        (data[a].value - avgY) * (j - avgX) - (data[a].value - data[j].value) * (avgRangeStart - avgX)
      );
      if (area > maxArea) {
        maxArea = area;
        maxIdx = j;
      }
    }

    sampled[sampledIndex++] = data[maxIdx];
    a = maxIdx;
  }

  sampled[sampledIndex++] = data[data.length - 1];  // 保留最后一个点
  return sampled;
}

十、触摸交互与动画系统流程

在这里插入图片描述


十一、完整折线图 UI 效果

在这里插入图片描述


十二、总结与展望

本文从架构设计、Canvas 2D 基础、坐标系映射、折线渲染、贝塞尔平滑、区域渐变填充、触摸命中检测、Tooltip 绘制到逐帧动画系统,完整拆解了 HarmonyOS 6 下「数脉引擎」原生折线图组件的开发全流程。核心要点回顾:

  1. 配置驱动架构:ChartConfig 统一描述所有视觉表现,渲染器只负责执行,实现图表类型快速切换;
  2. 坐标映射精准:Nice Number 算法生成友好刻度,数据值到像素坐标的线性映射确保视觉准确;
  3. 贝塞尔平滑自然:Catmull-Rom 样条转 Cubic Bezier, tension 参数控制平滑度,折线过渡如丝般顺滑;
  4. 交互体验细腻:15 像素命中半径 + 虚线圆环高亮 + 黑色 Tooltip 气泡,触摸反馈即时精准;
  5. 动画系统流畅:EaseInOutCubic 缓动 + 可见点数量控制,折线生长动画自然优雅;
  6. 性能优化到位:坐标缓存、脏矩形渲染、LTTB 降采样、离屏 Canvas,保障大数据量场景下的 60fps。

未来可进一步探索的方向包括:HarmonyOS 分布式能力实现「手机查看趋势、平板深度分析」的跨设备图表联动、基于端侧 AI 的异常数据点自动标注与趋势预测、以及接入 ArkGraphics 3D 能力打造沉浸式 3D 折线地形图。


转载自:https://blog.csdn.net/u014727709/article/details/163449638
欢迎 👍点赞✍评论⭐收藏,欢迎指正

Logo

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

更多推荐