深度解析 HarmonyOS NEXT:Canvas 图表与数据架构实战
·
深度解析 HarmonyOS NEXT:Canvas 图表与数据架构实战
从底层原理到最佳实践,深入剖析鸿蒙原生应用的核心技术
前言
在前两篇入门教程之后,本文将从技术深度角度,解析 HarmonyOS NEXT 应用开发的核心难点:
- Canvas 自定义图表:如何高效绑制环形图、柱状图、饼图?
- 数据架构设计:单例模式 + 监听器模式的最佳实践
- 响应式状态管理:ArkTS 的
@State/@Prop/@Watch深度剖析 - 性能优化:列表懒加载、图表缓存、避免过度渲染
项目地址:D:\harmonyos\project\6.4\5\MyApplication
SDK:HarmonyOS NEXT API 23
核心技术:Canvas、Preferences、响应式状态
一、Canvas 图表绑制原理
1.1 Canvas 初始化机制
HarmonyOS NEXT 的 Canvas 基于 HTML5 Canvas API,但有重要差异:
@Component
struct DonutChart {
// 关键:必须在组件级别声明 context
private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D();
build() {
Canvas(this.canvasContext)
.width(220)
.height(220)
.onReady(() => {
// onReady 是 Canvas 初始化完成的唯一可靠时机
this.drawChart();
})
}
}
底层原理:
Canvas 组件生命周期:
┌─────────────┐
│ 创建组件实例 │
└─────────────┘
↓
┌─────────────┐
│ 初始化 Context │ ← canvasContext = new CanvasRenderingContext2D()
└─────────────┘
↓
┌─────────────┐
│ 挂载到 UI 树 │
└─────────────┘
↓
┌─────────────┐
│ onReady 触发 │ ← 此时才能安全调用绑制 API
└─────────────┘
常见错误:
// ❌ 错误:在 aboutToAppear 中绘制
aboutToAppear(): void {
this.drawChart(); // Canvas 尚未初始化,会失败
}
// ✅ 正确:使用状态标志
@State isReady: boolean = false;
build() {
Canvas(this.canvasContext)
.onReady(() => {
this.isReady = true;
this.drawChart();
})
}
1.2 环形图(Donut Chart)实现
数学原理
环形图本质是扇形 + 内圆遮罩:
扇形绑制:
- 起点:(cx, cy)
- 圆弧:从 startAngle 到 endAngle
- 闭合:lineTo(cx, cy)
内圆遮罩:
- 在扇形之上绘制一个白色内圆
- 形成环形视觉效果
核心代码
drawDonutChart(): void {
const ctx = this.canvasContext;
const cx = 110, cy = 110;
const outerRadius = 90;
const innerRadius = 55;
// 清空画布
ctx.clearRect(0, 0, 220, 220);
// 计算总金额
const total = this.breakdown.reduce((sum, item) => sum + item.amount, 0);
if (total === 0) {
this.drawEmptyChart(ctx, cx, cy);
return;
}
// 绘制扇形
let startAngle = -Math.PI / 2; // 从12点钟方向开始
for (let i = 0; i < this.breakdown.length; i++) {
const item = this.breakdown[i];
const sliceAngle = (item.amount / total) * Math.PI * 2;
const endAngle = startAngle + sliceAngle;
// 创建扇形路径
const path = new Path2D();
path.arc(cx, cy, outerRadius, startAngle, endAngle);
path.lineTo(cx, cy);
path.closePath();
// 填充颜色
ctx.fillStyle = CHART_COLORS[i % CHART_COLORS.length];
ctx.fill(path);
startAngle = endAngle;
}
// 绘制内圆(遮罩)
ctx.beginPath();
ctx.arc(cx, cy, innerRadius, 0, Math.PI * 2);
ctx.fillStyle = '#FFFFFF';
ctx.fill();
// 绘制中心文字
ctx.fillStyle = '#333333';
ctx.font = 'bold 16px';
ctx.textAlign = 'center';
ctx.fillText(`¥${total.toFixed(0)}`, cx, cy + 6);
}
Path2D 优化技巧
// ❌ 低效:多次调用 ctx.arc + fill
for (let i = 0; i < data.length; i++) {
ctx.beginPath();
ctx.arc(cx, cy, radius, start, end);
ctx.lineTo(cx, cy);
ctx.closePath();
ctx.fill();
}
// ✅ 高效:使用 Path2D 批量路径
for (let i = 0; i < data.length; i++) {
const path = new Path2D();
path.arc(cx, cy, radius, start, end);
path.lineTo(cx, cy);
ctx.fill(path); // 更高效
}
1.3 柱状图(Bar Chart)实现
坐标系统
Canvas 坐标系:
(0,0) ────────────────→ x
│
│
│
↓ y
柱状图绘制区域:
┌────────────────────────┐
│ paddingTop │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │ │ │ │ │ │ │ drawHeight
│ └────┘ └────┘ └────┘ │
│ 1 2 3 ... n │ paddingBottom
└────────────────────────┘
paddingLeft paddingRight
动态计算柱宽
drawBarChart(): void {
const paddingLeft = 40;
const paddingRight = 10;
const paddingTop = 10;
const paddingBottom = 24;
const drawWidth = this.chartWidth - paddingLeft - paddingRight;
const drawHeight = this.chartHeight - paddingTop - paddingBottom;
// 根据数据量动态计算柱宽
const barWidth = Math.max(3, Math.min(12, (drawWidth / this.data.length) * 0.7));
const gap = drawWidth / this.data.length;
// 计算最大值(用于归一化)
let maxVal = 0;
for (const item of this.data) {
maxVal = Math.max(maxVal, item.income, item.expense);
}
// 绘制柱状
for (let i = 0; i < this.data.length; i++) {
const item = this.data[i];
const x = paddingLeft + i * gap + (gap - barWidth) / 2;
// 支出柱(左半边)
if (item.expense > 0) {
const barH = (item.expense / maxVal) * drawHeight;
ctx.fillStyle = '#FF6B6B';
ctx.fillRect(x, paddingTop + drawHeight - barH, barWidth / 2, barH);
}
// 收入柱(右半边)
if (item.income > 0) {
const barH = (item.income / maxVal) * drawHeight;
ctx.fillStyle = '#4ECDC4';
ctx.fillRect(x + barWidth / 2, paddingTop + drawHeight - barH, barWidth / 2, barH);
}
// X轴标签(每5天显示一次)
if (i % 5 === 0 || i === this.data.length - 1) {
ctx.fillStyle = '#999999';
ctx.font = '9px';
ctx.textAlign = 'center';
ctx.fillText(String(i + 1), x + barWidth / 2, paddingTop + drawHeight + 16);
}
}
}
性能优化:避免重复绘制
@Component
struct BarChart {
@Prop @Watch('onDataChanged') dailyTotals: DailyTotal[] = [];
@State cachedData: DailyTotal[] = [];
@State cachedHash: string = '';
onDataChanged(): void {
// 计算数据哈希
const hash = JSON.stringify(this.dailyTotals);
if (hash === this.cachedHash) {
return; // 数据未变化,不重绘
}
this.cachedHash = hash;
this.cachedData = [...this.dailyTotals];
this.drawChart();
}
}
二、数据架构设计
2.1 单例模式(Singleton)
为什么需要单例?
┌─────────────────────────────────────────┐
│ 应用进程 │
├─────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ │ 首页页面 │ │ 记账页面 │ │ 统计页面 │
│ └──────────┘ └──────────┘ └──────────┘
│ ↓ ↓ ↓
│ └─────────────┼─────────────┘
│ ↓
│ ┌──────────────┐
│ │ 数据服务单例 │ ← 全局唯一实例
│ └──────────────┘
│ ↓
│ ┌──────────────┐
│ │ Preferences │ ← 本地存储
│ └──────────────┘
└─────────────────────────────────────────┘
实现方式
export class FinanceDataService {
private static instance: FinanceDataService | null = null;
// 私有构造函数,防止外部实例化
private constructor() {}
// 静态获取实例方法
static getInstance(): FinanceDataService {
if (FinanceDataService.instance === null) {
FinanceDataService.instance = new FinanceDataService();
}
return FinanceDataService.instance;
}
// 业务方法...
}
初始化时机
// entry/src/main/ets/entryability/EntryAbility.ets
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// ✅ 在 Ability 创建时初始化
FinanceDataService.getInstance().init(this.context);
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => {
// 此时数据服务已初始化完成
});
}
}
2.2 监听器模式(Observer Pattern)
设计意图
实现数据变化自动通知UI更新,解耦数据层和视图层。
type DataChangeCallback = () => void;
export class FinanceDataService {
private listeners: DataChangeCallback[] = [];
// 注册监听器
registerListener(callback: DataChangeCallback): void {
const idx = this.listeners.indexOf(callback);
if (idx < 0) {
this.listeners.push(callback);
}
}
// 注销监听器
unregisterListener(callback: DataChangeCallback): void {
const idx = this.listeners.indexOf(callback);
if (idx >= 0) {
this.listeners.splice(idx, 1);
}
}
// 通知所有监听器
private notifyDataChanged(): void {
for (const cb of this.listeners) {
cb();
}
}
// 保存交易后触发通知
private async saveTransactions(): Promise<void> {
await this.store!.put('transactions', JSON.stringify(this.transactions));
await this.store!.flush();
this.notifyDataChanged(); // ← 关键
}
}
页面中的使用
@Entry
@Component
struct Index {
private service = FinanceDataService.getInstance();
private refreshCallback = () => this.refreshData();
aboutToAppear(): void {
this.service.registerListener(this.refreshCallback);
}
aboutToDisappear(): void {
this.service.unregisterListener(this.refreshCallback); // ← 防止内存泄漏
}
refreshData(): void {
// 重新加载数据,UI 自动更新
this.summary = this.service.getMonthlySummary(this.currentYear, this.currentMonth);
}
}
生命周期安全:
// ❌ 错误:忘记注销监听器
aboutToAppear(): void {
this.service.registerListener(() => this.refreshData());
}
// 页面销毁后,监听器仍在列表中 → 内存泄漏
// ✅ 正确:使用命名函数,便于注销
private refreshCallback = () => this.refreshData();
aboutToAppear(): void {
this.service.registerListener(this.refreshCallback);
}
aboutToDisappear(): void {
this.service.unregisterListener(this.refreshCallback);
}
2.3 Preferences 数据持久化
API 概览
import { preferences } from '@kit.ArkData';
// 获取 Preferences 实例
const store = await preferences.getPreferences(context, 'store_name');
// 存储数据(仅支持 string | number | boolean | Array<string>)
await store.put('key', 'value');
await store.put('number_key', 123);
await store.put('array_key', ['a', 'b', 'c']);
// 读取数据
const value = await store.get('key', 'default_value');
// 持久化到磁盘
await store.flush();
// 删除数据
await store.delete('key');
存储复杂对象
// 存储对象:序列化为 JSON
async saveCategories(): Promise<void> {
const json = JSON.stringify(this.categories);
await this.store.put('categories', json);
await this.store.flush();
}
// 读取对象:反序列化
async loadCategories(): Promise<void> {
const json = await this.store.get('categories', '') as string;
if (json) {
this.categories = JSON.parse(json) as Category[];
}
}
异步特性注意事项
// ❌ 错误:忘记 await
private async saveTransactions(): Promise<void> {
this.store.put('transactions', json); // 未 await
this.store.flush(); // 未 await
// 数据可能未及时写入磁盘,应用崩溃会丢失
}
// ✅ 正确:确保异步完成
private async saveTransactions(): Promise<void> {
await this.store.put('transactions', json);
await this.store.flush(); // 确保写入磁盘
this.notifyDataChanged();
}
三、响应式状态管理
3.1 @State:组件内部状态
@Entry
@Component
struct Index {
@State currentMonth: number = 0;
@State summary: MonthlySummary = { totalIncome: 0, totalExpense: 0, balance: 0 };
build() {
Column() {
Text(`¥${this.summary.balance.toFixed(2)}`)
.fontSize(34)
.fontWeight(FontWeight.Bold)
.fontColor(this.summary.balance >= 0 ? '#FF6B6B' : '#4ECDC4')
}
}
}
响应式机制:
状态变化 → 框架检测 → 触发重新渲染 → UI 更新
this.summary.balance = 100
↓
框架检测到 @State 变化
↓
重新执行 build() 方法
↓
Text 组件更新显示
3.2 @Prop:父子组件通信
@Component
struct DonutChart {
@Prop @Watch('onBreakdownChanged') breakdown: CategoryBreakdown[] = [];
onBreakdownChanged(): void {
// 父组件传递的 breakdown 变化时触发
this.drawChart();
}
}
@Entry
@Component
struct Index {
@State expenseBreakdown: CategoryBreakdown[] = [];
build() {
Column() {
DonutChart({ breakdown: this.expenseBreakdown }) // 传递给子组件
}
}
}
数据流:
Index (父组件)
│
│ @State expenseBreakdown = [...]
│
└──→ DonutChart (子组件)
│
│ @Prop breakdown ← 单向数据流
│
└── 监听变化 → 重绘图表
3.3 @Watch:状态变化监听
@Component
struct DonutChart {
@Prop @Watch('onBreakdownChanged') breakdown: CategoryBreakdown[] = [];
onBreakdownChanged(): void {
if (this.isReady) {
this.drawChart(); // 数据变化时自动重绘
}
}
}
对比传统写法:
// ❌ 传统方式:手动监听
@Component
struct DonutChart {
@Prop breakdown: CategoryBreakdown[] = [];
aboutToAppear(): void {
// 无法监听 @Prop 变化
}
}
// ✅ @Watch 方式:声明式监听
@Component
struct DonutChart {
@Prop @Watch('onBreakdownChanged') breakdown: CategoryBreakdown[] = [];
onBreakdownChanged(): void {
this.drawChart(); // 自动触发
}
}
3.4 数组/对象的响应式陷阱
@State transactions: Transaction[] = [];
// ❌ 错误:直接修改元素,不会触发更新
this.transactions[0].amount = 100;
this.transactions.push(newTx);
// ✅ 正确:重新赋值整个数组
this.transactions = [...this.transactions, newTx];
// ✅ 正确:修改元素后重新赋值
const newTxs = [...this.transactions];
newTxs[0].amount = 100;
this.transactions = newTxs;
原因:ArkTS 的 @State 检测的是引用变化,而非内容变化。
四、列表性能优化
4.1 ForEach vs LazyForEach
ForEach:全量渲染
List() {
ForEach(this.transactions, (tx: Transaction) => {
ListItem() {
TransactionItem({ transaction: tx })
}
}, (tx: Transaction) => String(tx.id))
}
特点:
- 一次性渲染所有列表项
- 适合数据量小(< 100 条)
- 简单易用
LazyForEach:懒加载
class TransactionDataSource implements IDataSource {
private dataArray: Transaction[] = [];
private listeners: DataChangeListener[] = [];
totalCount(): number {
return this.dataArray.length;
}
getData(index: number): Transaction {
return this.dataArray[index];
}
registerDataChangeListener(listener: DataChangeListener): void {
this.listeners.push(listener);
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const idx = this.listeners.indexOf(listener);
if (idx >= 0) {
this.listeners.splice(idx, 1);
}
}
}
@State dataSource: TransactionDataSource = new TransactionDataSource();
List() {
LazyForEach(this.dataSource, (tx: Transaction) => {
ListItem() {
TransactionItem({ transaction: tx })
}
}, (tx: Transaction) => String(tx.id))
}
特点:
- 按需渲染可见区域
- 滚动时动态加载/卸载
- 适合大数据量(> 100 条)
4.2 key 函数的重要性
// ❌ 错误:返回不稳定的 key
ForEach(items, (item) => {
ListItem() { /* ... */ }
}, (item) => Math.random().toString()) // 每次渲染都变化
// ✅ 正确:返回唯一且稳定的 key
ForEach(items, (item) => {
ListItem() { /* ... */ }
}, (item) => String(item.id)) // 稳定的 ID
key 的作用:
渲染过程:
1. 对比旧 key 列表和新 key 列表
2. 识别新增、删除、移动的项
3. 只更新有变化的项
如果 key 不稳定:
→ 框架认为所有项都变化
→ 全部重新渲染
→ 性能浪费
4.3 列表项优化
// ❌ 低效:复杂的列表项
ListItem() {
Column() {
Image($r('app.media.large_image')) // 大图
.width(200)
.height(200)
Video({ src: 'video.mp4' }) // 视频
Web({ src: 'https://...' }) // 网页
}
}
// ✅ 高效:精简的列表项
ListItem() {
Row() {
Text(cat.icon).fontSize(24)
Column() {
Text(cat.name).fontSize(15)
Text(tx.note).fontSize(12).fontColor('#888888')
}
Text(`¥${tx.amount.toFixed(2)}`).fontSize(17)
}
}
五、Canvas 性能优化
5.1 避免频繁重绘
@Component
struct DonutChart {
@Prop @Watch('onBreakdownChanged') breakdown: CategoryBreakdown[] = [];
@State lastDrawTime: number = 0;
onBreakdownChanged(): void {
// 防抖:300ms 内只绘制一次
const now = Date.now();
if (now - this.lastDrawTime < 300) {
return;
}
this.lastDrawTime = now;
this.drawChart();
}
}
5.2 缓存计算结果
@Component
struct BarChart {
@Prop dailyTotals: DailyTotal[] = [];
private cachedMaxVal: number = 0;
private cachedDataHash: string = '';
drawChart(): void {
const hash = JSON.stringify(this.dailyTotals);
if (hash === this.cachedDataHash) {
return; // 数据未变化
}
this.cachedDataHash = hash;
this.cachedMaxVal = Math.max(...this.dailyTotals.map(d => Math.max(d.income, d.expense)));
// 绘制...
}
}
5.3 减少绑定操作
// ❌ 低效:每次循环都设置样式
for (const item of data) {
ctx.fillStyle = item.color;
ctx.beginPath();
ctx.arc(cx, cy, radius, start, end);
ctx.fill();
}
// ✅ 高效:按颜色分组
const colorGroups = new Map<string, { start: number, end: number }[]>();
// 分组...
for (const [color, arcs] of colorGroups) {
ctx.fillStyle = color; // 只设置一次
for (const arc of arcs) {
ctx.beginPath();
ctx.arc(cx, cy, radius, arc.start, arc.end);
ctx.fill();
}
}
六、架构设计最佳实践
6.1 分层架构
┌─────────────────────────────────────────┐
│ Presentation Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ │ Pages │ │Components│ │ Charts │
│ └──────────┘ └──────────┘ └──────────┘
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Business Layer │
│ ┌──────────────────────────────────┐ │
│ │ FinanceDataService (单例) │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Data Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ │Preferences│ │ Models │ │ Utils │
│ └──────────┘ └──────────┘ └──────────┘
└─────────────────────────────────────────┘
6.2 单一职责原则
// ❌ 错误:一个文件包含所有逻辑
// FinanceManager.ets(5000+ 行)
export class FinanceManager {
// 数据存储
// 业务逻辑
// 图表绘制
// UI 组件
}
// ✅ 正确:职责分离
// models/FinanceModels.ets(数据结构)
// services/FinanceDataService.ets(业务逻辑)
// components/DonutChart.ets(图表组件)
// pages/Index.ets(页面组件)
6.3 依赖注入
// ❌ 错误:直接依赖具体实现
@Component
struct Index {
private service = FinanceDataService.getInstance();
}
// ✅ 正确:通过接口解耦(未来扩展)
interface IFinanceService {
getMonthlySummary(year: number, month: number): MonthlySummary;
addTransaction(tx: Transaction): Promise<void>;
}
@Component
struct Index {
private service: IFinanceService = FinanceDataService.getInstance();
}
七、调试技巧
7.1 Canvas 调试
drawChart(): void {
const ctx = this.canvasContext;
// 绘制边界框(调试用)
ctx.strokeStyle = '#FF0000';
ctx.lineWidth = 1;
ctx.strokeRect(0, 0, this.chartWidth, this.chartHeight);
// 绘制中心点(调试用)
ctx.fillStyle = '#FF0000';
ctx.beginPath();
ctx.arc(this.chartWidth / 2, this.chartHeight / 2, 3, 0, Math.PI * 2);
ctx.fill();
console.log(`Canvas size: ${this.chartWidth}x${this.chartHeight}`);
}
7.2 数据流追踪
async addTransaction(tx: Transaction): Promise<void> {
console.log(`[Service] Adding transaction: ${JSON.stringify(tx)}`);
this.transactions.push(tx);
await this.saveTransactions();
console.log(`[Service] Total transactions: ${this.transactions.length}`);
}
refreshData(): void {
console.log(`[Page] Refreshing data for ${this.currentYear}-${this.currentMonth}`);
this.summary = this.service.getMonthlySummary(this.currentYear, this.currentMonth);
console.log(`[Page] Summary: ${JSON.stringify(this.summary)}`);
}
7.3 性能分析
drawChart(): void {
const startTime = Date.now();
// ... 绘制逻辑
const elapsed = Date.now() - startTime;
console.log(`[Performance] Chart drawn in ${elapsed}ms`);
if (elapsed > 16) { // 超过一帧时间(60fps)
console.warn('[Performance] Chart rendering is slow!');
}
}
八、总结与展望

8.1 技术要点回顾
| 技术点 | 关键原理 | 最佳实践 |
|---|---|---|
| Canvas | onReady 时机 | 使用 isReady 标志 |
| 单例模式 | 全局唯一实例 | 在 Ability 初始化 |
| 监听器 | 观察者模式 | 记得注销监听器 |
| @State | 引用检测 | 修改后重新赋值 |
| @Prop | 单向数据流 | 配合 @Watch 监听 |
| LazyForEach | 按需渲染 | 大数据量使用 |
8.2 性能优化清单
✅ Canvas 优化
- 使用 onReady 时机
- 缓存计算结果
- 防抖重绘频率
- 减少样式切换
✅ 列表优化
- 使用 LazyForEach(大数据)
- 提供稳定的 key
- 简化列表项布局
✅ 架构优化
- 单一职责原则
- 分层架构设计
- 依赖注入解耦
8.3 扩展方向
技术深化:
- WebGL 高性能图表
- 动画效果(Lottie)
- 手势交互
功能扩展:
- 云端同步
- 数据导出
- 智能分析
附录:核心代码结构
MyApplication/
├── AppScope/
│ └── app.json5
├── entry/
│ └── src/main/
│ ├── ets/
│ │ ├── entryability/
│ │ │ └── EntryAbility.ets # 初始化数据服务
│ │ ├── models/
│ │ │ └── FinanceModels.ets # 数据结构定义
│ │ ├── services/
│ │ │ └── FinanceDataService.ets # 单例 + 监听器
│ │ └── pages/
│ │ ├── Index.ets # 首页 + 环形图
│ │ ├── AddTransactionPage.ets
│ │ ├── TransactionListPage.ets
│ │ ├── StatisticsPage.ets # 柱状图 + 饼图
│ │ └── CategoryManagePage.ets
│ └── module.json5
└── build-profile.json5
本文深入剖析了 HarmonyOS NEXT 应用开发的核心技术,从 Canvas 绑制原理到数据架构设计,希望能为进阶开发者提供有价值的参考。
更多推荐


所有评论(0)