
鸿蒙像素画板开发指南(支持压感笔与跨设备同步) 原创
鸿蒙像素画板开发指南(支持压感笔与跨设备同步)
一、系统架构设计
基于HarmonyOS的像素画板应用,结合手写笔API和分布式能力,实现以下功能:
压感支持:通过@ohos.multimodalInput获取手写笔数据
像素级绘制:精确到单个像素的绘制与编辑
跨设备同步:多设备实时同步画布状态
工具丰富:铅笔、橡皮擦、填充桶等绘图工具
!https://example.com/harmony-pixel-art-arch.png
二、核心代码实现
手写笔服务封装
// StylusService.ets
import multimodalInput from ‘@ohos.multimodalInput’;
class StylusService {
private static instance: StylusService = null;
private stylusListeners: StylusListener[] = [];
private constructor() {
this.initStylusListener();
public static getInstance(): StylusService {
if (!StylusService.instance) {
StylusService.instance = new StylusService();
return StylusService.instance;
private initStylusListener(): void {
try {
multimodalInput.on('stylus', (event) => {
this.handleStylusEvent(event);
});
catch (err) {
console.error('初始化手写笔监听失败:', JSON.stringify(err));
}
private handleStylusEvent(event: multimodalInput.StylusEvent): void {
const stylusData: StylusData = {
x: event.x,
y: event.y,
pressure: event.pressure,
tiltX: event.tiltX,
tiltY: event.tiltY,
deviceId: event.deviceId,
isHovering: event.isHovering,
timestamp: event.timestamp
};
this.stylusListeners.forEach(listener => {
listener.onStylusEvent(stylusData);
});
public addListener(listener: StylusListener): void {
if (!this.stylusListeners.includes(listener)) {
this.stylusListeners.push(listener);
}
public removeListener(listener: StylusListener): void {
this.stylusListeners = this.stylusListeners.filter(l => l !== listener);
}
interface StylusListener {
onStylusEvent(data: StylusData): void;
interface StylusData {
x: number;
y: number;
pressure: number; // 0.0 ~ 1.0
tiltX: number; // -90 ~ 90
tiltY: number; // -90 ~ 90
deviceId: string;
isHovering: boolean;
timestamp: number;
export const stylusService = StylusService.getInstance();
画布绘制服务
// CanvasService.ets
import { stylusService } from ‘./StylusService’;
import distributedData from ‘@ohos.distributedData’;
class CanvasService {
private static instance: CanvasService = null;
private canvasContext: CanvasRenderingContext2D | null = null;
private pixelData: string[][] = [];
private gridSize: number = 32;
private currentColor: string = ‘#000000’;
private currentTool: ‘pen’ ‘eraser’
‘fill’ = ‘pen’;
private dataManager: distributedData.DataManager;
private syncListeners: SyncListener[] = [];
private constructor() {
this.initDataManager();
this.initPixelData();
public static getInstance(): CanvasService {
if (!CanvasService.instance) {
CanvasService.instance = new CanvasService();
return CanvasService.instance;
private initDataManager(): void {
this.dataManager = distributedData.createDataManager({
bundleName: 'com.example.pixelart',
area: distributedData.Area.GLOBAL,
isEncrypted: true
});
this.dataManager.registerDataListener('canvas_sync', (data) => {
this.handleSyncData(data);
});
private initPixelData(): void {
this.pixelData = Array(this.gridSize).fill(null).map(() =>
Array(this.gridSize).fill('transparent')
);
public setupCanvas(context: CanvasRenderingContext2D, width: number, height: number): void {
this.canvasContext = context;
this.pixelSize = Math.min(width, height) / this.gridSize;
this.drawGrid();
public handleStylusInput(data: StylusData): void {
if (!this.canvasContext || data.isHovering) return;
const canvasX = Math.floor(data.x / this.pixelSize);
const canvasY = Math.floor(data.y / this.pixelSize);
if (canvasX < 0 |canvasX >= this.gridSize
canvasY < 0
| canvasY >= this.gridSize) return;
switch (this.currentTool) {
case 'pen':
this.drawPixel(canvasX, canvasY, data.pressure);
break;
case 'eraser':
this.erasePixel(canvasX, canvasY);
break;
case 'fill':
this.fillArea(canvasX, canvasY);
break;
}
private drawPixel(x: number, y: number, pressure: number): void {
if (!this.canvasContext) return;
// 根据压力调整透明度
const alpha = Math.floor(pressure * 255).toString(16).padStart(2, '0');
const color = this.currentColor + alpha;
this.pixelData[y][x] = color;
this.canvasContext.fillStyle = color;
this.canvasContext.fillRect(
-
this.pixelSize,
-
this.pixelSize,
this.pixelSize, this.pixelSize
);
this.syncPixelChange(x, y, color);
private erasePixel(x: number, y: number): void {if (!this.canvasContext) return;
this.pixelData[y][x] = ‘transparent’;
this.canvasContext.clearRect(
-
this.pixelSize,
-
this.pixelSize,
this.pixelSize, this.pixelSize
);
this.drawGridLines(x, y);
this.syncPixelChange(x, y, ‘transparent’);
private fillArea(startX: number, startY: number): void {if (!this.canvasContext) return;
const targetColor = this.pixelData[startY][startX];
if (targetColor === this.currentColor) return;const queue: {x: number, y: number}[] = [{x: startX, y: startY}];
const visited: boolean[][] = Array(this.gridSize).fill(null).map(() =>
Array(this.gridSize).fill(false)
);while (queue.length > 0) {
const {x, y} = queue.shift()!;if (x < 0 |x >= this.gridSize
y < 0
y >= this.gridSize
visited[y][x]
| this.pixelData[y][x] !== targetColor) {
continue;
this.pixelData[y][x] = this.currentColor;
this.canvasContext.fillStyle = this.currentColor;
this.canvasContext.fillRect(
-
this.pixelSize,
-
this.pixelSize,
this.pixelSize, this.pixelSize ); visited[y][x] = true; queue.push({x: x + 1, y}); queue.push({x: x - 1, y}); queue.push({x, y: y + 1}); queue.push({x, y: y - 1});
this.syncCanvasState();
private drawGrid(): void {
if (!this.canvasContext) return;
this.canvasContext.clearRect(0, 0,
this.gridSize * this.pixelSize,
this.gridSize * this.pixelSize
);
// 绘制所有像素
for (let y = 0; y < this.gridSize; y++) {
for (let x = 0; x < this.gridSize; x++) {
if (this.pixelData[y][x] !== 'transparent') {
this.canvasContext.fillStyle = this.pixelData[y][x];
this.canvasContext.fillRect(
-
this.pixelSize,
-
this.pixelSize,
this.pixelSize, this.pixelSize );
}
// 绘制网格线
this.canvasContext.strokeStyle = '#CCCCCC';
this.canvasContext.lineWidth = 1;
for (let i = 0; i <= this.gridSize; i++) {
// 垂直线
this.canvasContext.beginPath();
this.canvasContext.moveTo(i * this.pixelSize, 0);
this.canvasContext.lineTo(i this.pixelSize, this.gridSize this.pixelSize);
this.canvasContext.stroke();
// 水平线
this.canvasContext.beginPath();
this.canvasContext.moveTo(0, i * this.pixelSize);
this.canvasContext.lineTo(this.gridSize this.pixelSize, i this.pixelSize);
this.canvasContext.stroke();
}
private syncPixelChange(x: number, y: number, color: string): void {
this.dataManager.syncData(‘canvas_sync’, {
type: ‘pixel_change’,
x: x,
y: y,
color: color,
timestamp: Date.now()
});
private syncCanvasState(): void {
this.dataManager.syncData('canvas_sync', {
type: 'canvas_state',
pixelData: this.pixelData,
gridSize: this.gridSize,
timestamp: Date.now()
});
private handleSyncData(data: any): void {
if (!data || !this.canvasContext) return;
switch (data.type) {
case 'pixel_change':
this.pixelData[data.y][data.x] = data.color;
if (data.color === 'transparent') {
this.canvasContext.clearRect(
data.x * this.pixelSize,
data.y * this.pixelSize,
this.pixelSize,
this.pixelSize
);
this.drawGridLines(data.x, data.y);
else {
this.canvasContext.fillStyle = data.color;
this.canvasContext.fillRect(
data.x * this.pixelSize,
data.y * this.pixelSize,
this.pixelSize,
this.pixelSize
);
break;
case 'canvas_state':
this.gridSize = data.gridSize;
this.pixelSize = Math.min(this.canvasWidth, this.canvasHeight) / this.gridSize;
this.pixelData = data.pixelData;
this.drawGrid();
break;
}
public addSyncListener(listener: SyncListener): void {
if (!this.syncListeners.includes(listener)) {
this.syncListeners.push(listener);
}
public removeSyncListener(listener: SyncListener): void {
this.syncListeners = this.syncListeners.filter(l => l !== listener);
public clearCanvas(): void {
this.initPixelData();
this.drawGrid();
this.syncCanvasState();
public setColor(color: string): void {
this.currentColor = color;
public setTool(tool: ‘pen’ ‘eraser’
‘fill’): void {
this.currentTool = tool;
public setGridSize(size: number): void {
this.gridSize = size;
this.pixelSize = Math.min(this.canvasWidth, this.canvasHeight) / this.gridSize;
this.initPixelData();
this.drawGrid();
this.syncCanvasState();
}
interface SyncListener {
onPixelChange(x: number, y: number, color: string): void;
onCanvasStateChange(pixelData: string[][], gridSize: number): void;
export const canvasService = CanvasService.getInstance();
主界面实现
// MainScreen.ets
import { canvasService } from ‘./CanvasService’;
import { stylusService } from ‘./StylusService’;
@Component
export struct MainScreen {
@State currentColor: string = ‘#000000’;
@State currentTool: ‘pen’ ‘eraser’
‘fill’ = ‘pen’;
@State showGrid: boolean = true;
@State gridSize: number = 32;
@State connectedDevices: string[] = [];
private canvasRef: CanvasRenderingContext2D | null = null;
build() {
Column() {
// 标题栏
Row() {
Text(‘像素画板’)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.layoutWeight(1)
Button('协同')
.width(80)
.onClick(() => {
this.showDeviceDialog();
})
.padding(10)
.width('100%')
// 画布区域
Stack() {
Canvas(this.canvasRef)
.width('100%')
.height('70%')
.backgroundColor('#FFFFFF')
.onReady((context: CanvasRenderingContext2D) => {
this.canvasRef = context;
canvasService.setupCanvas(context, context.width, context.height);
})
if (this.showGrid) {
// 网格线由CanvasService绘制
}
.height('70%')
.margin({ bottom: 10 })
// 工具面板
Column() {
// 颜色选择器
Row() {
Text('颜色:')
.fontSize(16)
.margin({ right: 10 })
ColorPicker(this.currentColor)
.width(100)
.height(30)
.onChange((color: string) => {
this.currentColor = color;
canvasService.setColor(color);
})
.margin({ bottom: 10 })
// 工具选择
Row() {
Button('铅笔')
.width(80)
.backgroundColor(this.currentTool === 'pen' ? '#DDDDDD' : '#FFFFFF')
.onClick(() => {
this.currentTool = 'pen';
canvasService.setTool('pen');
})
Button('橡皮')
.width(80)
.backgroundColor(this.currentTool === 'eraser' ? '#DDDDDD' : '#FFFFFF')
.onClick(() => {
this.currentTool = 'eraser';
canvasService.setTool('eraser');
})
Button('填充')
.width(80)
.backgroundColor(this.currentTool === 'fill' ? '#DDDDDD' : '#FFFFFF')
.onClick(() => {
this.currentTool = 'fill';
canvasService.setTool('fill');
})
.margin({ bottom: 10 })
// 网格设置
Row() {
Text('网格:')
.fontSize(16)
.margin({ right: 10 })
Toggle({ type: ToggleType.Checkbox, isOn: this.showGrid })
.onChange((isOn: boolean) => {
this.showGrid = isOn;
// 实际网格绘制在CanvasService中处理
})
.margin({ right: 20 })
Text('大小:')
.fontSize(16)
.margin({ right: 10 })
Slider({
value: this.gridSize,
min: 8,
max: 64,
step: 8,
style: SliderStyle.OutSet
})
.onChange((value: number) => {
this.gridSize = value;
canvasService.setGridSize(value);
})
.layoutWeight(1)
Text(this.gridSize.toString())
.fontSize(16)
.width(30)
.margin({ bottom: 10 })
// 操作按钮
Row() {
Button('清空')
.width(100)
.onClick(() => {
canvasService.clearCanvas();
})
Button('保存')
.width(100)
.margin({ left: 10 })
.onClick(() => {
this.saveCanvas();
})
}
.padding(10)
.width('100%')
.backgroundColor('#F5F5F5')
.borderRadius(8)
.margin({ bottom: 20 })
.width(‘100%’)
.height('100%')
.padding(20)
.onAppear(() => {
stylusService.addListener({
onStylusEvent: (data) => {
canvasService.handleStylusInput(data);
});
canvasService.addSyncListener({
onPixelChange: (x, y, color) => {
// 处理同步的像素变化
},
onCanvasStateChange: (pixelData, gridSize) => {
// 处理画布状态同步
});
})
.onDisappear(() => {
stylusService.removeListener({
onStylusEvent: () => {}
});
canvasService.removeSyncListener({
onPixelChange: () => {},
onCanvasStateChange: () => {}
});
})
private showDeviceDialog(): void {
const dialog = new AlertDialog({
title: '选择协同设备',
customComponent: DeviceSelector({
onDeviceSelect: (deviceId) => {
this.connectToDevice(deviceId);
}),
buttons: [
text: ‘取消’,
action: () => {}
]
});
dialog.show();
private async saveCanvas(): Promise<void> {
try {
const pixelData = canvasService.getPixelData();
const gridSize = canvasService.getGridSize();
// 创建放大后的图像
const scale = 10; // 放大倍数
const tempCanvas = new OffscreenCanvas(gridSize scale, gridSize scale);
const tempCtx = tempCanvas.getContext('2d')!;
// 绘制放大后的像素
for (let y = 0; y < gridSize; y++) {
for (let x = 0; x < gridSize; x++) {
if (pixelData[y][x] !== 'transparent') {
tempCtx.fillStyle = pixelData[y][x];
tempCtx.fillRect(
-
scale,
-
scale,
scale, scale );
}
// 转换为Blob并保存
const blob = await tempCanvas.convertToBlob({ type: 'image/png' });
const mediaLib = await mediaLibrary.getMediaLibrary(getContext(this));
const file = await mediaLib.createAsset(
mediaLibrary.MediaType.IMAGE,
pixel_art_${Date.now()}.png,
mediaLibrary.StorageFlag.STORAGE_FLAG_DEFAULT
);
const fd = await file.open('w');
const arrayBuffer = await blob.arrayBuffer();
fs.writeSync(fd, arrayBuffer);
await file.close(fd);
prompt.showToast({ message: '已保存到相册' });
catch (err) {
console.error('保存画布失败:', JSON.stringify(err));
prompt.showToast({ message: '保存失败' });
}
三、项目配置与权限
权限配置
// module.json5
“module”: {
"requestPermissions": [
“name”: “ohos.permission.READ_MEDIA”,
"reason": "读取媒体文件"
},
“name”: “ohos.permission.WRITE_MEDIA”,
"reason": "保存图像到相册"
},
“name”: “ohos.permission.DISTRIBUTED_DATASYNC”,
"reason": "同步画布数据"
},
“name”: “ohos.permission.MULTIMODAL_INPUT”,
"reason": "获取手写笔输入"
],
"abilities": [
“name”: “MainAbility”,
"type": "page",
"visible": true
]
}
四、总结与扩展
本像素画板实现了以下核心功能:
压感支持:精确捕捉手写笔的压力和倾斜数据
像素级绘制:支持精确到单个像素的绘制
跨设备协同:多设备实时同步画布状态
多工具支持:提供多种绘图工具
扩展方向:
图层支持:添加多层绘制功能
动画制作:创建帧动画并导出GIF
笔刷自定义:支持自定义笔刷形状和大小
模板库:提供常用像素画模板
社区分享:上传作品到像素画社区
通过HarmonyOS的分布式能力和手写笔API,我们构建了一个功能丰富、响应灵敏的像素画创作工具。
