
鸿蒙5健康数据仪表盘开发指南 原创
鸿蒙5健康数据仪表盘开发指南
一、项目概述
本文将基于HarmonyOS 5的原子化服务技术,开发一个健康数据仪表盘应用,借鉴《鸿蒙跨端U同步》中游戏多设备同步的技术原理,实现健康数据的可视化展示和跨设备同步功能。该应用将使用Canvas绘制动态图表,并利用分布式能力实现多设备数据共享。
二、技术架构
±--------------------+ ±--------------------+
健康数据原子服务 <-----> 设备能力共享服务
(Atomic Service) (CapabilityService)
±---------±---------+ ±---------±---------+
±---------v----------+ ±---------v----------+
图表渲染引擎 分布式数据管理
(ChartEngine) (DistributedData)
±---------±---------+ ±---------±---------+
±---------v-----------------------------v----------+
HarmonyOS 5 核心框架 |
±--------------------------------------------------+
三、核心代码实现
健康数据模型
// src/main/ets/model/HealthDataModel.ts
export class HealthData {
userId: string;
deviceId: string;
timestamp: number;
steps: number;
heartRate: number;
bloodPressure: {
systolic: number;
diastolic: number;
};
sleepHours: number;
calories: number;
constructor(userId: string) {
this.userId = userId;
this.deviceId = ‘’;
this.timestamp = Date.now();
this.steps = 0;
this.heartRate = 0;
this.bloodPressure = { systolic: 0, diastolic: 0 };
this.sleepHours = 0;
this.calories = 0;
static generateMockData(userId: string): HealthData {
const data = new HealthData(userId);
data.steps = Math.floor(Math.random() * 20000);
data.heartRate = 60 + Math.floor(Math.random() * 60);
data.bloodPressure = {
systolic: 100 + Math.floor(Math.random() * 40),
diastolic: 60 + Math.floor(Math.random() * 20)
};
data.sleepHours = 4 + Math.random() * 6;
data.calories = 1500 + Math.floor(Math.random() * 1500);
return data;
toJson(): string {
return JSON.stringify({
userId: this.userId,
deviceId: this.deviceId,
timestamp: this.timestamp,
steps: this.steps,
heartRate: this.heartRate,
bloodPressure: this.bloodPressure,
sleepHours: this.sleepHours,
calories: this.calories
});
static fromJson(jsonStr: string): HealthData {
const json = JSON.parse(jsonStr);
const data = new HealthData(json.userId);
data.deviceId = json.deviceId;
data.timestamp = json.timestamp;
data.steps = json.steps;
data.heartRate = json.heartRate;
data.bloodPressure = json.bloodPressure;
data.sleepHours = json.sleepHours;
data.calories = json.calories;
return data;
}
图表渲染引擎
// src/main/ets/engine/ChartEngine.ts
import { drawing } from ‘@ohos.drawing’;
import { display } from ‘@ohos.display’;
export class ChartEngine {
private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private width: number = 0;
private height: number = 0;
private dpr: number = 1;
constructor(canvas: HTMLCanvasElement) {
this.initCanvas(canvas);
private async initCanvas(canvas: HTMLCanvasElement): Promise<void> {
this.canvas = canvas;
this.ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
// 获取设备像素比
const displayInfo = await display.getDefaultDisplay();
this.dpr = displayInfo.densityPixels / displayInfo.width;
// 设置canvas实际尺寸
this.width = canvas.width;
this.height = canvas.height;
canvas.style.width = ${this.width}px;
canvas.style.height = ${this.height}px;
canvas.width = this.width * this.dpr;
canvas.height = this.height * this.dpr;
this.ctx.scale(this.dpr, this.dpr);
public drawDashboard(data: HealthData): void {
if (!this.ctx || !this.canvas) return;
// 清空画布
this.ctx.clearRect(0, 0, this.width, this.height);
// 绘制背景
this.drawBackground();
// 绘制步数图表
this.drawStepsChart(data.steps, 15000);
// 绘制心率图表
this.drawHeartRateChart(data.heartRate);
// 绘制血压图表
this.drawBloodPressureChart(data.bloodPressure);
// 绘制睡眠图表
this.drawSleepChart(data.sleepHours);
// 绘制卡路里图表
this.drawCaloriesChart(data.calories, 2500);
private drawBackground(): void {
if (!this.ctx) return;
// 绘制渐变背景
const gradient = this.ctx.createLinearGradient(0, 0, this.width, this.height);
gradient.addColorStop(0, '#1a2a6c');
gradient.addColorStop(1, '#b21f1f');
this.ctx.fillStyle = gradient;
this.ctx.fillRect(0, 0, this.width, this.height);
// 绘制网格线
this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
this.ctx.lineWidth = 1;
const gridSize = 20;
for (let x = 0; x < this.width; x += gridSize) {
this.ctx.beginPath();
this.ctx.moveTo(x, 0);
this.ctx.lineTo(x, this.height);
this.ctx.stroke();
for (let y = 0; y < this.height; y += gridSize) {
this.ctx.beginPath();
this.ctx.moveTo(0, y);
this.ctx.lineTo(this.width, y);
this.ctx.stroke();
}
private drawStepsChart(steps: number, maxSteps: number): void {
if (!this.ctx) return;
const centerX = this.width * 0.2;
const centerY = this.height * 0.3;
const radius = 50;
const percentage = Math.min(steps / maxSteps, 1);
// 绘制背景圆
this.ctx.beginPath();
this.ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
this.ctx.lineWidth = 10;
this.ctx.stroke();
// 绘制进度圆
this.ctx.beginPath();
this.ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + Math.PI 2 percentage);
this.ctx.strokeStyle = '#4CAF50';
this.ctx.lineWidth = 10;
this.ctx.stroke();
// 绘制文本
this.ctx.fillStyle = 'white';
this.ctx.font = 'bold 16px sans-serif';
this.ctx.textAlign = 'center';
this.ctx.fillText('步数', centerX, centerY - 30);
this.ctx.font = '20px sans-serif';
this.ctx.fillText(steps.toString(), centerX, centerY + 10);
private drawHeartRateChart(rate: number): void {
if (!this.ctx) return;
const centerX = this.width * 0.5;
const centerY = this.height * 0.3;
const radius = 50;
const normalizedRate = Math.min(Math.max((rate - 40) / 120, 0), 1);
// 绘制心跳动画
this.ctx.save();
this.ctx.translate(centerX, centerY);
// 绘制心电图背景
this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
this.ctx.lineWidth = 2;
this.drawHeartLine(0.5);
// 绘制心电图
this.ctx.strokeStyle = '#FF5252';
this.ctx.lineWidth = 3;
this.drawHeartLine(normalizedRate);
this.ctx.restore();
// 绘制文本
this.ctx.fillStyle = 'white';
this.ctx.font = 'bold 16px sans-serif';
this.ctx.textAlign = 'center';
this.ctx.fillText('心率', centerX, centerY - 30);
this.ctx.font = '20px sans-serif';
this.ctx.fillText(${rate} BPM, centerX, centerY + 50);
private drawHeartLine(intensity: number): void {
if (!this.ctx) return;
const points = 20;
const amplitude = 30 * intensity;
this.ctx.beginPath();
for (let i = 0; i < points; i++) {
const x = (i - points / 2) * 10;
let y = 0;
// 模拟心电图波形
if (i > 2 && i < 5) {
= -amplitude * (i - 2) / 2;
else if (i === 5) {
= -amplitude * 1.5;
else if (i > 5 && i < 8) {
= -amplitude * (8 - i) / 2;
else if (i > 10 && i < 13) {
= amplitude 0.7 (i - 10) / 2;
else if (i === 13) {
= amplitude * 0.8;
else if (i > 13 && i < 16) {
= amplitude 0.7 (16 - i) / 2;
if (i === 0) {
this.ctx.moveTo(x, y);
else {
this.ctx.lineTo(x, y);
}
this.ctx.stroke();
private drawBloodPressureChart(bp: { systolic: number; diastolic: number }): void {
if (!this.ctx) return;
const centerX = this.width * 0.8;
const centerY = this.height * 0.3;
const barWidth = 20;
const maxPressure = 180;
// 计算柱状图高度
const systolicHeight = (bp.systolic / maxPressure) * 80;
const diastolicHeight = (bp.diastolic / maxPressure) * 80;
// 绘制收缩压柱
this.ctx.fillStyle = '#FF9800';
this.ctx.fillRect(centerX - barWidth - 5, centerY - systolicHeight, barWidth, systolicHeight);
// 绘制舒张压柱
this.ctx.fillStyle = '#FFC107';
this.ctx.fillRect(centerX + 5, centerY - diastolicHeight, barWidth, diastolicHeight);
// 绘制文本
this.ctx.fillStyle = 'white';
this.ctx.font = 'bold 16px sans-serif';
this.ctx.textAlign = 'center';
this.ctx.fillText('血压', centerX, centerY - 30);
this.ctx.font = '14px sans-serif';
this.ctx.fillText(收缩压: ${bp.systolic}, centerX - barWidth, centerY + 30);
this.ctx.fillText(舒张压: ${bp.diastolic}, centerX + barWidth, centerY + 30);
private drawSleepChart(hours: number): void {
if (!this.ctx) return;
const centerX = this.width * 0.3;
const centerY = this.height * 0.7;
const maxHours = 12;
const percentage = Math.min(hours / maxHours, 1);
const radius = 50;
// 绘制背景圆
this.ctx.beginPath();
this.ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
this.ctx.lineWidth = 10;
this.ctx.stroke();
// 绘制进度圆
this.ctx.beginPath();
this.ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + Math.PI 2 percentage);
this.ctx.strokeStyle = '#2196F3';
this.ctx.lineWidth = 10;
this.ctx.stroke();
// 绘制月亮图标
this.ctx.fillStyle = '#FFEB3B';
this.ctx.beginPath();
this.ctx.arc(centerX - 15, centerY - 10, 8, 0, Math.PI * 2);
this.ctx.fill();
this.ctx.beginPath();
this.ctx.arc(centerX - 10, centerY - 10, 8, 0, Math.PI * 2);
this.ctx.fillStyle = '#1a2a6c';
this.ctx.fill();
// 绘制文本
this.ctx.fillStyle = 'white';
this.ctx.font = 'bold 16px sans-serif';
this.ctx.textAlign = 'center';
this.ctx.fillText('睡眠', centerX, centerY - 30);
this.ctx.font = '20px sans-serif';
this.ctx.fillText(${hours.toFixed(1)} 小时, centerX, centerY + 10);
private drawCaloriesChart(calories: number, target: number): void {
if (!this.ctx) return;
const centerX = this.width * 0.7;
const centerY = this.height * 0.7;
const width = 100;
const height = 20;
const percentage = Math.min(calories / target, 1);
// 绘制背景条
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
this.ctx.fillRect(centerX - width / 2, centerY - height / 2, width, height);
// 绘制进度条
this.ctx.fillStyle = '#FF5722';
this.ctx.fillRect(centerX - width / 2, centerY - height / 2, width * percentage, height);
// 绘制火焰图标
this.ctx.fillStyle = '#FF9800';
this.drawFlameIcon(centerX, centerY - 30);
// 绘制文本
this.ctx.fillStyle = 'white';
this.ctx.font = 'bold 16px sans-serif';
this.ctx.textAlign = 'center';
this.ctx.fillText('卡路里', centerX, centerY - 50);
this.ctx.font = '20px sans-serif';
this.ctx.fillText({calories} / {target}, centerX, centerY + 30);
private drawFlameIcon(x: number, y: number): void {
if (!this.ctx) return;
this.ctx.save();
this.ctx.translate(x, y);
// 绘制火焰形状
this.ctx.beginPath();
this.ctx.moveTo(0, -15);
this.ctx.bezierCurveTo(5, -20, 10, -15, 5, -5);
this.ctx.bezierCurveTo(15, -10, 10, 5, 0, 10);
this.ctx.bezierCurveTo(-10, 5, -15, -10, -5, -5);
this.ctx.bezierCurveTo(-10, -15, -5, -20, 0, -15);
this.ctx.closePath();
// 添加渐变
const gradient = this.ctx.createRadialGradient(0, 0, 5, 0, 0, 15);
gradient.addColorStop(0, '#FFEB3B');
gradient.addColorStop(1, '#FF5722');
this.ctx.fillStyle = gradient;
this.ctx.fill();
this.ctx.restore();
}
设备能力共享服务
// src/main/ets/service/CapabilityService.ts
import { distributedData, DistributedData } from ‘@ohos.data.distributedData’;
import { BusinessError } from ‘@ohos.base’;
import { HealthData } from ‘…/model/HealthDataModel’;
import { deviceManager } from ‘@ohos.distributedDeviceManager’;
export class CapabilityService {
private static instance: CapabilityService;
private kvManager: distributedData.KVManager;
private kvStore: distributedData.KVStore;
private readonly STORE_ID = ‘health_data_store’;
private readonly SYNC_KEY_PREFIX = ‘health_’;
private constructor() {
this.initDistributedData();
public static getInstance(): CapabilityService {
if (!CapabilityService.instance) {
CapabilityService.instance = new CapabilityService();
return CapabilityService.instance;
private initDistributedData(): void {
const config: distributedData.KVManagerConfig = {
bundleName: 'com.example.healthdashboard',
userInfo: {
userId: '0',
userType: distributedData.UserType.SAME_USER_ID
};
try {
distributedData.createKVManager(config, (err: BusinessError, manager: distributedData.KVManager) => {
if (err) {
console.error(Failed to create KVManager. Code: {err.code}, message: {err.message});
return;
this.kvManager = manager;
const options: distributedData.Options = {
createIfMissing: true,
encrypt: true,
backup: false,
autoSync: true,
kvStoreType: distributedData.KVStoreType.SINGLE_VERSION,
schema: '',
securityLevel: distributedData.SecurityLevel.S2
};
this.kvManager.getKVStore(this.STORE_ID, options, (err: BusinessError, store: distributedData.KVStore) => {
if (err) {
console.error(Failed to get KVStore. Code: {err.code}, message: {err.message});
return;
this.kvStore = store;
this.registerDataListener();
});
});
catch (e) {
console.error(An unexpected error occurred. Code: {e.code}, message: {e.message});
}
private registerDataListener(): void {
try {
this.kvStore.on(‘dataChange’, distributedData.SubscribeType.SUBSCRIBE_TYPE_ALL, (data: distributedData.ChangeData) => {
if (data.key.startsWith(this.SYNC_KEY_PREFIX)) {
const healthData = HealthData.fromJson(data.value.value as string);
this.notifyDataUpdate(healthData);
});
catch (e) {
console.error(Failed to register data listener. Code: {e.code}, message: {e.message});
}
private notifyDataUpdate(data: HealthData): void {
// 通知所有设备更新健康数据
AppStorage.setOrCreate(health_{data.userId}_{data.timestamp}, data);
// 如果当前设备是数据来源设备,更新本地显示
deviceManager.getLocalDeviceInfo((err: BusinessError, info) => {
if (!err && data.deviceId === info.deviceId) {
// 触发UI更新
postMessage({ type: 'healthDataUpdate', data });
});
public syncHealthData(data: HealthData): void {
if (!this.kvStore) {
console.error('KVStore is not initialized');
return;
// 设置设备ID
deviceManager.getLocalDeviceInfo((err: BusinessError, info) => {
if (!err) {
data.deviceId = info.deviceId;
try {
const key = this.SYNC_KEY_PREFIX + data.userId + '_' + data.timestamp;
this.kvStore.put(key, data.toJson(), (err: BusinessError) => {
if (err) {
console.error(Failed to put data. Code: {err.code}, message: {err.message});
});
catch (e) {
console.error(An unexpected error occurred. Code: {e.code}, message: {e.message});
}
});
public async getHealthHistory(userId: string): Promise<HealthData[]> {
return new Promise((resolve) => {
if (!this.kvStore) {
resolve([]);
return;
try {
const query: distributedData.Query = {
prefixKey: this.SYNC_KEY_PREFIX + userId
};
this.kvStore.getEntries(query, (err: BusinessError, entries: distributedData.Entry[]) => {
if (err) {
console.error(Failed to get entries. Code: {err.code}, message: {err.message});
resolve([]);
return;
const history = entries
.map(entry => HealthData.fromJson(entry.value.value as string))
.sort((a, b) => b.timestamp - a.timestamp);
resolve(history);
});
catch (e) {
console.error(An unexpected error occurred. Code: {e.code}, message: {e.message});
resolve([]);
});
}
原子服务主页面实现
// src/main/ets/pages/Index.ets
import { HealthData } from ‘…/model/HealthDataModel’;
import { CapabilityService } from ‘…/service/CapabilityService’;
import { ChartEngine } from ‘…/engine/ChartEngine’;
@Entry
@Component
struct Index {
@State currentData: HealthData = new HealthData(‘user_123’);
@State historyData: HealthData[] = [];
@State selectedTab: number = 0;
@State canvasRef: CanvasRenderingContext2D | null = null;
private chartEngine: ChartEngine | null = null;
aboutToAppear(): void {
// 初始化服务
CapabilityService.getInstance();
// 加载模拟数据
this.loadMockData();
// 加载历史数据
this.loadHistoryData();
private loadMockData(): void {
this.currentData = HealthData.generateMockData('user_123');
CapabilityService.getInstance().syncHealthData(this.currentData);
private async loadHistoryData(): Promise<void> {
this.historyData = await CapabilityService.getInstance().getHealthHistory('user_123');
private onCanvasReady(event: { context: CanvasRenderingContext2D }): void {
this.canvasRef = event.context;
const canvas = document.getElementById('healthCanvas') as HTMLCanvasElement;
this.chartEngine = new ChartEngine(canvas);
this.updateChart();
private updateChart(): void {
if (this.chartEngine) {
this.chartEngine.drawDashboard(this.currentData);
}
private refreshData(): void {
this.loadMockData();
this.updateChart();
private showHistoryData(data: HealthData): void {
this.currentData = data;
this.updateChart();
this.selectedTab = 0;
build() {
Column() {
// 标题和刷新按钮
Row() {
Text('健康数据仪表盘')
.fontSize(24)
.fontWeight(FontWeight.Bold)
Button('刷新数据')
.margin({left: 20})
.onClick(() => this.refreshData())
.width(‘100%’)
.justifyContent(FlexAlign.Center)
.margin({bottom: 20})
// 选项卡
Tabs({ barPosition: BarPosition.Start, index: this.selectedTab }) {
TabContent() {
// 主仪表盘
Column() {
// Canvas图表
Canvas(this.canvasRef)
.id('healthCanvas')
.width('100%')
.height('60%')
.onReady((event) => this.onCanvasReady(event))
.backgroundColor('#1a2a6c')
// 数据摘要
Row() {
Column() {
Text('今日步数')
.fontSize(16)
Text(${this.currentData.steps})
.fontSize(20)
.fontColor('#4CAF50')
.margin({right: 15})
Column() {
Text('心率')
.fontSize(16)
Text(${this.currentData.heartRate} BPM)
.fontSize(20)
.fontColor('#FF5252')
.margin({right: 15})
Column() {
Text('血压')
.fontSize(16)
Text({this.currentData.bloodPressure.systolic}/{this.currentData.bloodPressure.diastolic})
.fontSize(20)
.fontColor('#FF9800')
.margin({right: 15})
Column() {
Text('睡眠')
.fontSize(16)
Text(${this.currentData.sleepHours.toFixed(1)} 小时)
.fontSize(20)
.fontColor('#2196F3')
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
.margin({top: 20})
}
.tabBar('仪表盘')
TabContent() {
// 历史数据列表
List({ space: 10 }) {
ForEach(this.historyData, (item: HealthData) => {
ListItem() {
Column() {
Row() {
Text(new Date(item.timestamp).toLocaleString())
.fontSize(16)
.fontWeight(FontWeight.Bold)
Text(${item.steps} 步)
.fontSize(14)
.margin({left: 10})
Row() {
Text(心率: ${item.heartRate} BPM)
.fontSize(12)
.margin({right: 10})
Text(血压: {item.bloodPressure.systolic}/{item.bloodPressure.diastolic})
.fontSize(12)
.margin({top: 5})
.width(‘100%’)
.padding(10)
.borderRadius(10)
.backgroundColor('#f5f5f5')
.onClick(() => this.showHistoryData(item))
})
}
.tabBar('历史记录')
.width(‘100%’)
.height('100%')
.width(‘100%’)
.height('100%')
.padding(10)
}
四、与游戏同步技术的结合点
实时数据同步:类似游戏中的玩家状态同步,实现健康数据的多设备实时同步
设备能力共享:借鉴游戏多设备共享技术,实现健康数据的跨设备访问
分布式数据管理:使用与游戏相同的分布式数据管理机制存储健康历史数据
状态更新通知:采用类似游戏事件通知机制,实现数据变化的实时响应
安全通信:利用游戏中的安全通信机制保障健康数据的隐私和安全
五、原子化服务关键特性应用
Canvas动态渲染:使用Canvas API实现高性能图表渲染
免安装体验:用户无需安装完整应用即可查看健康数据
设备能力检测:自动适配不同设备的显示能力
跨设备协同:基于分布式能力实现多设备数据同步
安全存储:对敏感健康数据进行加密存储和传输
六、项目扩展方向
真实健康数据接入:连接智能手环、手表等设备获取真实数据
趋势分析:增加健康数据趋势图表和分析功能
健康建议:基于数据分析提供个性化健康建议
多人数据共享:实现家庭成员间健康数据共享
异常预警:设置阈值实现健康异常预警
七、总结
本健康数据仪表盘原子服务实现了以下核心功能:
健康数据的可视化展示(步数、心率、血压、睡眠、卡路里)
Canvas动态图表渲染
历史数据存储和查询
多设备间的数据同步
响应式UI设计
通过借鉴游戏中的同步技术,我们构建了一个直观、高效的健康数据可视化服务。该项目展示了HarmonyOS 5原子化服务和Canvas能力的强大组合,为开发者提供了实现数据可视化应用的参考方案。
