
一、开篇:为什么这个仪表盘值得动手写
很多人刚开始接触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属于装饰器变更检测的一部分,能避免重复绘制。 - 实际项目中请使用
Display或size动态获取宽高,这里用固定值占位。
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 模拟。真实场景可使用
SwipeRefresh或PullToRefresh组件(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 内部使用 aboutToAppear 和 aboutToDisappear 管理重绘。但更推荐的方式:将 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 更合适。
六、最佳实践
-
不要在 build() 中创建新对象或执行耗时操作 。比如
this.cardItems每次 build 都重新生成数组,但ForEach依赖id标识,如果id不变则不会重建,性能影响不大。但如果每次 build 都 new DashboardData() 就会导致状态丢失。正确的做法是在@State声明时初始化,通过方法更新属性。 -
自定义组件尽量保持纯展示,状态提升到父组件。比如 StatCard 不应该自己管理数据,而是由 DashboardPage 通过 @Prop 或 @State 传值。这样便于统一刷新和避免多数据源。
-
使用 @Watch 监听数据变化时,注意避免死循环 。比如在
onDataChange中又修改了数据,会再次触发 @Watch。折线图中的重绘函数里不要修改dataPoints。 -
页面路由参数传递使用
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 数据驱动、页面路由与转场。实际项目中