《HarmonyOS技术精讲-UI开发》第10篇:综合实战-仪表盘

一、开篇:为什么这个仪表盘值得动手写

很多人刚开始接触ArkTS声明式开发范式时,会觉得写几个按钮、列表很简单。但一旦涉及多个自定义组件协作、@State状态同步、跨页面路由、以及动画转场,代码就容易变得混乱。

这个问题在HarmonyOS开发里比较常见------官方文档示例大多是单一功能,比如单独讲@State如何用、单独讲Grid布局如何写。但实际项目里,我们需要把这几套机制组合起来:一个页面里既要展示统计卡片,又要显示动态折线图,还要支持下拉刷新和跳转详情。而且,还得同时适配手机和平板。

如果直接按照"先写页面、再抽组件"的思路来,很容易出现状态不同步、布局错乱、动画卡顿等问题。本文从一个完整的仪表盘应用出发,把整个实现过程拆解清楚,重点讲清楚自定义组件封装、状态管理、Grid自适应布局、动画与转场。

这篇文章不会教你"什么是@State",而是告诉你:在多组件协作场景下,@State的更新边界在哪里 ,以及如何避免组件重建导致的性能问题

二、功能拆解与架构设计

2.1 需求列表

功能 说明
统计卡片 显示总计用户、活跃用户、订单数等3~4个卡片,支持Grid自适应行
折线图 展示最近7天用户增长趋势,使用Canvas绘制
下拉刷新 手动刷新数据,更新卡片和图表
跳转详情 点击卡片跳转至详细数据页面,并传递参数
动画/转场 卡片入场动画、页面切换过渡动画
多端适配 手机(单列)和平板(多列)卡片布局自适应

2.2 架构决策

  • 为什么不直接写在一个页面里? 随着功能增多,单一组件会变得臃肿,且状态管理容易混乱。我们拆分为独立的自定义组件:StatCard(卡片)、LineChart(折线图)、Dashboard(主容器),这样每个组件职责清晰,便于维护。
  • 为什么用Grid布局而不是Flex? Grid可以更自然地控制列数,并通过columnsTemplate实现自适应:手机1列、平板2~3列,无需额外媒体查询。
  • 为什么用Canvas而不是三方图表库? 对于简单折线图,Canvas实现轻量、可控,避免引入额外依赖和包体积问题。

2.3 数据模型

typescript 复制代码
// model/DashboardData.ets
export class DashboardData {
  totalUsers: number = 0;
  activeUsers: number = 0;
  orders: number = 0;
  chartPoints: number[] = []; // 近7天数据,如[120, 135, 128, 150, 148, 160, 172]
}

三、环境说明

text 复制代码
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机(720p以上) / 平板(1280p以上)

四、核心实现

4.1 创建项目与页面路由配置

先通过 DevEco Studio 创建 Empty Ability 项目,在 pages 目录下建立两个页面:

  • pages/DashboardPage(仪表盘主页)
  • pages/DetailPage(点击卡片后的详情页)

main_pages.json 中注册:

json 复制代码
{
  "src": [
    "pages/DashboardPage",
    "pages/DetailPage"
  ]
}

4.2 数据模拟与状态管理

在主页面 DashboardPage 中,使用 @State 管理整个仪表盘数据。数据需要响应式更新,所以使用类配合 @Observed 装饰符。

typescript 复制代码
// model/DashboardData.ets
@Observed
export class DashboardData {
  totalUsers: number = 0;
  activeUsers: number = 0;
  orders: number = 0;
  chartPoints: number[] = [];

  constructor() {
    this.refresh();
  }

  refresh() {
    // 模拟真实数据
    this.totalUsers = Math.floor(Math.random() * 10000) + 5000;
    this.activeUsers = Math.floor(this.totalUsers * (0.3 + Math.random() * 0.3));
    this.orders = Math.floor(Math.random() * 500) + 100;
    // 生成近7天数据,趋势上升
    this.chartPoints = [];
    let base = 100;
    for (let i = 0; i < 7; i++) {
      base += Math.floor(Math.random() * 30) - 5;
      this.chartPoints.push(base);
    }
  }
}

4.3 自定义组件 StatCard

StatCard 是一个通用卡片组件,接收标题、数值、单位、点击回调等参数。

typescript 复制代码
// components/StatCard.ets
@Component
export struct StatCard {
  private title: string = '';
  private value: number = 0;
  private unit: string = '';
  private backgroundColor: ResourceColor = Color.White;
  @State private opacityValue: number = 0;
  private onClickAction?: () => void;

  aboutToAppear() {
    // 入场动画:渐入 + 上移
    animateTo({ duration: 500, curve: Curve.EaseOut }, () => {
      this.opacityValue = 1;
    });
  }

  build() {
    Column() {
      Text(this.title)
        .fontSize(14)
        .fontColor('#666666')
      Text(`${this.value}`)
        .fontSize(32)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 4 })
      Text(this.unit)
        .fontSize(12)
        .fontColor('#999999')
        .margin({ top: 2 })
    }
    .width('100%')
    .aspectRatio(1.5) // 固定宽高比,适应网格
    .backgroundColor(this.backgroundColor)
    .borderRadius(12)
    .shadow({ radius: 6, color: 'rgba(0,0,0,0.1)' })
    .padding(12)
    .opacity(this.opacityValue)
    .offset({ y: (1 - this.opacityValue) * 20 })
    .onClick(() => {
      this.onClickAction?.();
    })
  }
}

为什么这样写:

  • 使用 @State opacityValue 驱动入场动画,避免在 build 中直接写动画状态,容易导致组件多次重建。
  • aspectRatio(1.5) 让卡片在 Grid 中保持统一比例,布局更美观。
  • 点击回调通过外部传入,保持组件内聚性。

4.4 自定义组件 LineChart(Canvas绘制)

折线图需要根据 chartPoints 数据动态绘制。使用 Canvas 组件,在 onReady 中绘制初始图形,并通过 @Watch 监听数据变化重绘。

typescript 复制代码
// components/LineChart.ets
@Component
export struct LineChart {
  private dataPoints: number[] = [];
  private canvasWidth: number = 300;
  private canvasHeight: number = 200;

  build() {
    Canvas(this.context)
      .width('100%')
      .height(200)
      .onReady(() => {
        this.drawChart();
      })
  }

  // 使用 @Watch 监听 dataPoints 变化
  @Watch('onDataChange')
  private onDataChange() {
    this.drawChart();
  }

  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D();

  private drawChart() {
    if (!this.ctx || this.dataPoints.length === 0) return;
    const ctx = this.ctx;
    const width = this.canvasWidth;
    const height = this.canvasHeight;
    const padding = 20;
    const graphWidth = width - 2 * padding;
    const graphHeight = height - 2 * padding;

    // 清空画布
    ctx.clearRect(0, 0, width, height);

    // 计算最大值和最小值
    let max = Math.max(...this.dataPoints);
    let min = Math.min(...this.dataPoints);
    let range = max - min || 1;

    // 绘制折线
    ctx.beginPath();
    ctx.lineWidth = 2;
    ctx.strokeStyle = '#4A90D9';
    ctx.fillStyle = 'rgba(74,144,217,0.1)';

    this.dataPoints.forEach((value, index) => {
      let x = padding + (index / (this.dataPoints.length - 1)) * graphWidth;
      let y = padding + graphHeight - ((value - min) / range) * graphHeight;
      if (index === 0) {
        ctx.moveTo(x, y);
      } else {
        ctx.lineTo(x, y);
      }
    });
    ctx.stroke();

    // 绘制填充区域(可选)
    ctx.lineTo(padding + graphWidth, padding + graphHeight);
    ctx.lineTo(padding, padding + graphHeight);
    ctx.closePath();
    ctx.fill();
  }
}

注意事项:

  • Canvas 的绘制必须在 onReady 之后进行,否则 ctx 不可用。
  • 数据变化时通过 @Watch('onDataChange') 触发重绘,比在 build 中直接调用更稳定,因为 @Watch 属于装饰器变更检测的一部分,能避免重复绘制。
  • 实际项目中请使用 Displaysize 动态获取宽高,这里用固定值占位。

4.5 主页面 DashboardPage

主页面组合 StatCard 和 LineChart,使用 Grid 布局展示卡片,并处理路由、下拉刷新。

typescript 复制代码
// pages/DashboardPage.ets
import { DashboardData } from '../model/DashboardData';
import { StatCard } from '../components/StatCard';
import { LineChart } from '../components/LineChart';
import router from '@ohos.router';

@Entry
@Component
struct DashboardPage {
  @State private dashboardData: DashboardData = new DashboardData();
  @State private refreshing: boolean = false;

  build() {
    Column() {
      // 标题
      Text('数据仪表盘')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .width('100%')
        .textAlign(TextAlign.Start)
        .padding({ left: 16, top: 16 })

      // 统计卡片 Grid
      Grid() {
        ForEach(this.cardItems, (item: CardItem, index: number) => {
          GridItem() {
            StatCard({
              title: item.title,
              value: item.value,
              unit: item.unit,
              backgroundColor: item.bgColor,
              onClickAction: () => {
                // 携带参数跳转详情页
                router.pushUrl({
                  url: 'pages/DetailPage',
                  params: { cardIndex: index, title: item.title, value: item.value }
                });
              }
            })
          }
        }, (item: CardItem) => item.id)
      }
      .columnsTemplate('1fr 1fr') // 默认两列,手机可改为1fr
      .rowsGap(12)
      .columnsGap(12)
      .padding(16)
      .width('100%')

      // 折线图
      LineChart({ dataPoints: this.dashboardData.chartPoints })
        .padding(16)

      // 刷新按钮
      Button('刷新数据')
        .width('80%')
        .height(40)
        .backgroundColor('#4A90D9')
        .fontColor(Color.White)
        .borderRadius(20)
        .onClick(() => {
          this.refreshing = true;
          setTimeout(() => {
            this.dashboardData.refresh();
            this.refreshing = false;
          }, 500); // 模拟网络延迟
        })
        .margin({ bottom: 16 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  // 卡片数据结构
  get cardItems(): CardItem[] {
    return [
      { id: '1', title: '总用户', value: this.dashboardData.totalUsers, unit: '人', bgColor: '#E3F2FD' },
      { id: '2', title: '活跃用户', value: this.dashboardData.activeUsers, unit: '人', bgColor: '#E8F5E9' },
      { id: '3', title: '订单数', value: this.dashboardData.orders, unit: '笔', bgColor: '#FFF3E0' },
      { id: '4', title: '转化率', value: Math.round((this.dashboardData.activeUsers / (this.dashboardData.totalUsers || 1)) * 100), unit: '%', bgColor: '#F3E5F5' },
    ];
  }
}

interface CardItem {
  id: string;
  title: string;
  value: number;
  unit: string;
  bgColor: ResourceColor;
}

注意点:

  • 这里简化了下拉刷新,仅用 Button 模拟。真实场景可使用 SwipeRefreshPullToRefresh 组件(API 11+),但原理相同。
  • Grid 的 columnsTemplate 写死 '1fr 1fr',后面适配章节会改为动态。

4.6 详情页 DetailPage(路由参数接收)

typescript 复制代码
// pages/DetailPage.ets
import router from '@ohos.router';

@Entry
@Component
struct DetailPage {
  @State private cardIndex: number = -1;
  @State private title: string = '';
  @State private value: number = 0;

  aboutToAppear() {
    const params = router.getParams() as Record<string, Object>;
    this.cardIndex = params['cardIndex'] as number;
    this.title = params['title'] as string;
    this.value = params['value'] as number;
  }

  build() {
    Column() {
      Text(`详情:${this.title}`)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .padding(16)
      Text(`数值:${this.value}`)
        .fontSize(18)
        .padding(16)
      Button('返回')
        .onClick(() => {
          router.back();
        })
        .margin(16)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFF')
  }
}

注意: 参数传递建议使用 Record<string, Object> 接收,避免类型强制转换问题。

4.7 页面转场动画

在 DashboardPage 的跳转处可以配置自定义转场。在 build 中或 onPageShow 中设置。

typescript 复制代码
// 在 DashboardPage 的 aboutToAppear 或 build 中:
pageTransition() {
  PageTransitionEnter({ duration: 300, curve: Curve.Ease })
    .slide(SlideEffect.Right)
  PageTransitionExit({ duration: 300, curve: Curve.Ease })
    .slide(SlideEffect.Left)
}

同样在 DetailPage 中也需要配置返回动画。

4.8 多端适配:手机 vs 平板

使用 window.getWindowProperties() 判断当前窗口宽度,动态设置 Grid 列数。

typescript 复制代码
// 在 DashboardPage 中
import window from '@ohos.window';

@Entry
@Component
struct DashboardPage {
  @State private columnCount: number = 2; // 默认平板2列

  aboutToAppear() {
    this.setColumnCount();
    // 监听窗口尺寸变化(可选)
    window.getLastWindow(this.getContext(), (err, win) => {
      if (err) return;
      win.on('windowSizeChange', (size) => {
        this.setColumnCount(size.width);
      });
    });
  }

  private setColumnCount(width?: number) {
    if (!width) {
      const win = window.getLastWindowSync(this.getContext());
      width = win.getWindowProperties().windowRect.width;
    }
    if (width < 600) { // 手机
      this.columnCount = 1;
    } else {
      this.columnCount = 2;
    }
  }

  // Grid 模板
  get gridTemplate(): string {
    return '1fr '.repeat(this.columnCount).trim();
  }
}

然后在 Grid 中绑定 columnsTemplate(this.gridTemplate)

五、踩坑记录

坑1:Canvas 在组件复用或数据更新后不刷新

现象 :进入页面后图表正常显示,但点击刷新按钮后,dataPoints 变了,图表却没更新。

原因LineChart 组件中的 Canvas 绘制只执行了一次(在 onReady 中),后续数据变化虽然通过 @Watch 触发了 onDataChange,但 onDataChange 中调用 drawChart 时,this.ctx 可能在组件复用后丢失或上下文被清理。

解决方案 :确保 Canvas 的 onReady 回调只执行一次,并且在 @Watch 中重新绘制。但如果组件被 if 条件移除又重新创建,Canvas 会被销毁,需要重新监听。稳妥做法是:在 LineChart 内部使用 aboutToAppearaboutToDisappear 管理重绘。但更推荐的方式:将 Canvas 组件包裹在 @Builder 中,并通过 @State 控制重建 ,不过开销较大。另一种方案:在 DashboardPage 中给 LineChart 添加 key 属性,强制组件重建。这里给出一个修复后的 LineChart

typescript 复制代码
@State private needRedraw: boolean = true;

aboutToAppear() {
  this.needRedraw = true;
}

build() {
  Canvas(this.context)
    .width('100%')
    .height(200)
    .onReady(() => {
      if (this.needRedraw) {
        this.drawChart();
        this.needRedraw = false;
      }
    })
}

@Watch('onDataChange')
onDataChange() {
  this.needRedraw = true;
  // 触发 Canvas 重新 onReady。但 onReady 不会第二次调用,所以需要强制重建。
  // 更简单:使用一个 @State key 强制重建 Canvas。
}

最终我采取强制重建 Canvas 的方法:给 LineChart 传入一个变化的 key 属性,当数据变化时 key 改变,组件整体重建。

typescript 复制代码
// 在 DashboardPage 中
LineChart({ dataPoints: this.dashboardData.chartPoints, key: Date.now() }) // 每次刷新数据时 key 变化

然后在 LineChart 接收 key 并通过 @Prop 装饰。虽然重建组件有点开销,但对于简单的图表可以接受。更好的方案是使用 Canvas2D 的 invalidate() 方法,但 ArkUI Canvas 目前没有提供,需要手动清除并重绘。

坑2:Grid 布局在手机单列模式下出现多余空白

现象 :当 columnsTemplate('1fr') 时,GridItem 的 aspectRatio(1.5) 让每个卡片高度固定,但 Grid 整体高度计算异常,底部出现大量空白。

原因 :Grid 默认会尝试填充所有可用高度,如果子项高度不足以填满,会拉伸最后一项或留白。这里是因为 aspectRatio 导致每个 GridItem 高度固定,但 Grid 容器高度未设置,内部计算方式不同手机端有问题。

解决方案 :给 Grid 显式设置一个合适的高度,或者使用 align(Alignment.Top)。更推荐的做法:不用 Grid 实现单列卡片列表,改用 Column+List。但为了统一两套代码,可以在手机模式下将 Grid 的 rowsTemplate 设置为 'auto' 或使用 Flex 替代。这里我选择在手机模式下改用 Column + FlexWrap 布局。或者设置 Grid 高度为 'auto'(API 11+ 支持)。简单方案:手机模式下直接改为 List,但为了演示简洁,我采用 if 条件判断:

typescript 复制代码
if (this.columnCount === 1) {
  Column() {
    ForEach(this.cardItems, (item) => {
      StatCard({...})
        .width('100%')
        .height(120)
    })
  }
  .padding(16)
} else {
  // Grid 两列布局
}

为什么不建议用 Grid 做单列? Grid 的 columnsTemplate 单列时,每个 GridItem 默认充满整行,但 rowsTemplate 默认 '1fr' 会平分高度,导致每行高度相等,如果卡片高度不一,就会留白。所以单列用 List 更合适。

六、最佳实践

  1. 不要在 build() 中创建新对象或执行耗时操作 。比如 this.cardItems 每次 build 都重新生成数组,但 ForEach 依赖 id 标识,如果 id 不变则不会重建,性能影响不大。但如果每次 build 都 new DashboardData() 就会导致状态丢失。正确的做法是在 @State 声明时初始化,通过方法更新属性。

  2. 自定义组件尽量保持纯展示,状态提升到父组件。比如 StatCard 不应该自己管理数据,而是由 DashboardPage 通过 @Prop 或 @State 传值。这样便于统一刷新和避免多数据源。

  3. 使用 @Watch 监听数据变化时,注意避免死循环 。比如在 onDataChange 中又修改了数据,会再次触发 @Watch。折线图中的重绘函数里不要修改 dataPoints

  4. 页面路由参数传递使用 Record<string, Object> ,不要使用 any。接收时做类型断言,并考虑参数缺失的情况(aboutToAppear 中判断)。

七、FAQ

Q1:为什么真机上 Canvas 绘制比模拟器慢?

A:模拟器使用 GPU 渲染,真机可能受限于屏幕分辨率或 Canvas 的刷新率。可以通过 canvasWidth 设置合理的分辨率,避免过高。另外,fill 操作如果包含透明区域会消耗性能,建议减少填充区域或使用 stroke 代替。

Q2:为什么页面返回后之前的状态丢失了?

A:如果是在同一个 Ability 内部 router.pushUrl,返回后之前的页面不会被销毁(页面栈机制),所以 @State 应该保留。但如果页面使用了 @LazyForEach 或使用了同一个 ViewModel 的全局单例,状态恢复可能出问题。检查 aboutToAppear 中是否重新初始化了数据。建议使用 onPageShow 来重新获取数据。

Q3:Grid 布局在多端适配时如何处理不同屏幕密度?

A:使用 vp 单位可以自动适应密度。另外,columnsTemplate 中的 1fr 是分数单位,能等分容器宽度。平板建议用 '1fr 1fr',手机用 '1fr'。需要注意的是,aspectRatio 在不同宽度下会随容器宽度变化,建议统一使用 height 百分比或固定 vp

八、总结

这个仪表盘虽然功能不多,但涵盖了 ArkUI 声明式开发范式 中最重要的组合模式:自定义组件参数化、@State 和 @Watch 联动、Grid 自适应、Canvas 数据驱动、页面路由与转场。实际项目中