鸿蒙像素画板开发指南(支持压感笔与跨设备同步) 原创

进修的泡芙
发布于 2025-6-20 13:08
浏览
0收藏

鸿蒙像素画板开发指南(支持压感笔与跨设备同步)

一、系统架构设计

基于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,我们构建了一个功能丰富、响应灵敏的像素画创作工具。

©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任
收藏
回复
举报
回复
    相关推荐