HarmonyOS APP开发:Canvas实战项目——手绘白板应用
HarmonyOS APP开发:Canvas实战项目——手绘白板应用
📌 核心要点:基于HarmonyOS Canvas组件构建完整的手绘白板应用,涵盖笔触效果、撤销重做、导出分享等核心功能,打造流畅自然的绘画体验。
一、背景与动机
你有没有这样的经历?开会时想随手画个流程图说明思路,却发现手边只有手机;给孩子讲数学题,想画个辅助线却找不到纸笔;灵感突然来了,想快速勾勒一个UI草图,却打开绘图软件就花了五分钟。
手绘白板应用,就是解决这类"随手画"需求的利器。它不需要你成为画家,也不需要复杂的专业工具——打开就能画,画完就能分享,就是这么简单直接。
在HarmonyOS生态中,Canvas组件为我们提供了强大的2D绘图能力。但说实话,光会画线、画圆还远远不够。一个真正好用的白板应用,得让用户感觉"笔跟手走"——线条流畅、笔触自然、撤销重做丝滑、导出分享方便。这背后涉及到绘制引擎设计、笔触算法、状态管理等一系列技术挑战。
今天这篇文章,我们就从零开始,一步步搭建一个完整的手绘白板应用。别担心,我会把每个关键环节都掰开了揉碎了讲清楚。
二、核心原理
2.1 手绘白板整体架构
一个白板应用的核心架构可以拆解为三层:交互层负责捕获用户手势,绘制引擎层负责将手势数据转化为图形,数据管理层负责维护画布状态和历史记录。
graph TD
A[用户手势输入]:::primary --> B[交互层:手势识别与坐标采集]:::info
B --> C[绘制引擎层:笔触计算与Canvas渲染]:::warning
C --> D[数据管理层:路径存储与历史栈]:::info
D --> E[功能层:撤销/重做/导出/分享]:::primary
D -->|状态恢复| C
E -->|操作反馈| D
classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
classDef error fill:#F44336,stroke:#D32F2F,color:#fff
classDef info fill:#2196F3,stroke:#1976D2,color:#fff
2.2 笔触效果原理
为什么手绘的线条和鼠标画的线条感觉完全不同?关键在于速度和压力。
人在纸上写字时,写得快的地方线条细,写得慢的地方线条粗——这是笔尖与纸张接触时间的自然结果。而数字白板要模拟这种效果,就需要根据手指移动速度来动态调整线宽:
- 速度快 → 线宽小(笔触细)
- 速度慢 → 线宽大(笔触粗)
计算公式:lineWidth = baseWidth * (1 / (1 + speed * factor))
其中 speed 是相邻两点的距离除以时间差,factor 是灵敏度系数。
2.3 撤销重做原理
撤销重做的核心是命令模式(Command Pattern)。每一次绘制操作都是一个"命令"对象,包含执行和撤销两个方法:
- 撤销栈(undoStack):存放已执行的命令,撤销时弹出并执行其撤销方法
- 重做栈(redoStack):存放已撤销的命令,重做时弹出并重新执行
新操作入栈时,清空重做栈——这和你在编辑器里的行为一致:撤销后输入新内容,之前的重做历史就没了。
三、代码实战
3.1 基础用法:Canvas绘制引擎核心
先搭建绘制引擎的基础骨架,定义路径数据结构和核心绘制方法:
// 路径点数据结构
interface DrawPoint {
x: number; // X坐标
y: number; // Y坐标
timestamp: number; // 时间戳(用于速度计算)
pressure: number; // 压力值(0-1)
}
// 笔触路径
interface StrokePath {
points: DrawPoint[]; // 路径点集合
color: string; // 笔触颜色
baseWidth: number; // 基础线宽
toolType: ToolType; // 工具类型
}
// 工具类型枚举
enum ToolType {
PEN = 'pen', // 画笔
HIGHLIGHTER = 'highlighter', // 荧光笔
ERASER = 'eraser' // 橡皮擦
}
// 绘制引擎核心类
class DrawingEngine {
private paths: StrokePath[] = []; // 所有路径
private currentPath: StrokePath | null = null; // 当前正在绘制的路径
private settings: DrawingSettings = { // 绘制设置
color: '#000000',
baseWidth: 3,
toolType: ToolType.PEN
};
// 开始新路径
startPath(x: number, y: number): void {
const point: DrawPoint = {
x: x,
y: y,
timestamp: Date.now(),
pressure: 0.5
};
this.currentPath = {
points: [point],
color: this.settings.color,
baseWidth: this.settings.baseWidth,
toolType: this.settings.toolType
};
}
// 追加路径点
addPoint(x: number, y: number): void {
if (!this.currentPath) return;
const point: DrawPoint = {
x: x,
y: y,
timestamp: Date.now(),
pressure: 0.5
};
this.currentPath.points.push(point);
}
// 结束当前路径
endPath(): StrokePath | null {
if (!this.currentPath) return null;
const path = this.currentPath;
this.paths.push(path);
this.currentPath = null;
return path;
}
// 获取所有路径
getPaths(): StrokePath[] {
return this.paths;
}
// 更新绘制设置
updateSettings(settings: Partial<DrawingSettings>): void {
this.settings = { ...this.settings, ...settings };
}
}
interface DrawingSettings {
color: string;
baseWidth: number;
toolType: ToolType;
}
3.2 进阶用法:笔触效果与撤销重做
接下来实现笔触效果计算和撤销重做机制:
// 笔触效果计算器
class StrokeCalculator {
// 根据速度计算线宽
static calcWidthBySpeed(
prev: DrawPoint,
curr: DrawPoint,
baseWidth: number,
factor: number = 0.003
): number {
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
const dt = Math.max(curr.timestamp - prev.timestamp, 1);
const distance = Math.sqrt(dx * dx + dy * dy);
const speed = distance / dt;
// 速度越快线越细,速度越慢线越粗
const width = baseWidth * (1 / (1 + speed * factor * 100));
// 限制线宽范围
return Math.max(baseWidth * 0.3, Math.min(baseWidth * 2.5, width));
}
// 贝塞尔曲线平滑:用中点作为控制点
static smoothPoints(points: DrawPoint[]): DrawPoint[] {
if (points.length < 3) return points;
const smoothed: DrawPoint[] = [points[0]];
for (let i = 1; i < points.length - 1; i++) {
const prev = points[i - 1];
const curr = points[i];
const next = points[i + 1];
// 中点平滑
const midX = (curr.x + next.x) / 2;
const midY = (curr.y + next.y) / 2;
smoothed.push({
x: midX,
y: midY,
timestamp: curr.timestamp,
pressure: (curr.pressure + next.pressure) / 2
});
}
smoothed.push(points[points.length - 1]);
return smoothed;
}
}
// 撤销重做管理器
class UndoRedoManager {
private undoStack: StrokePath[] = []; // 撤销栈
private redoStack: StrokePath[] = []; // 重做栈
private maxStackSize: number = 50; // 最大栈深度
// 执行绘制操作(入撤销栈)
pushStroke(path: StrokePath): void {
this.undoStack.push(path);
// 新操作清空重做栈
this.redoStack = [];
// 超出上限时移除最早的记录
if (this.undoStack.length > this.maxStackSize) {
this.undoStack.shift();
}
}
// 撤销操作
undo(): StrokePath | null {
if (this.undoStack.length === 0) return null;
const path = this.undoStack.pop()!;
this.redoStack.push(path);
return path;
}
// 重做操作
redo(): StrokePath | null {
if (this.redoStack.length === 0) return null;
const path = this.redoStack.pop()!;
this.undoStack.push(path);
return path;
}
// 是否可以撤销
canUndo(): boolean {
return this.undoStack.length > 0;
}
// 是否可以重做
canRedo(): boolean {
return this.redoStack.length > 0;
}
// 清空所有历史
clear(): void {
this.undoStack = [];
this.redoStack = [];
}
}
3.3 完整示例:手绘白板应用
下面是完整的白板应用实现,包含工具栏、画布、撤销重做、导出等全部功能:
import { componentUtils } from '@kit.ArkUI';
@Entry
@Component
struct WhiteboardApp {
// 绘制引擎
private engine: DrawingEngine = new DrawingEngine();
private undoRedo: UndoRedoManager = new UndoRedoManager();
// 状态变量
@State currentColor: string = '#1a1a1a';
@State currentWidth: number = 3;
@State currentTool: ToolType = ToolType.PEN;
@State canUndo: boolean = false;
@State canRedo: boolean = false;
@State strokeCount: number = 0;
// 画布上下文
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: RenderingContext = new RenderingContext(this.settings);
// 颜色面板
private colorPalette: string[] = [
'#1a1a1a', '#e74c3c', '#e67e22', '#f1c40f',
'#2ecc71', '#3498db', '#9b59b6', '#ecf0f1'
];
build() {
Column() {
// 顶部工具栏
this.Toolbar()
// 画布区域
Stack() {
Canvas(this.context)
.width('100%')
.height('100%')
.backgroundColor('#ffffff')
.onReady(() => {
this.initCanvas();
})
.gesture(
PanGesture({ fingers: 1, distance: 1 })
.onActionStart((event: GestureEvent) => {
this.onDrawStart(event);
})
.onActionUpdate((event: GestureEvent) => {
this.onDrawMove(event);
})
.onActionEnd(() => {
this.onDrawEnd();
})
)
}
.layoutWeight(1)
// 底部颜色选择器
this.ColorPicker()
}
.width('100%')
.height('100%')
.backgroundColor('#f5f5f5')
}
// 顶部工具栏组件
@Builder
Toolbar() {
Row() {
// 撤销按钮
Button('撤销')
.enabled(this.canUndo)
.fontSize(14)
.fontColor(this.canUndo ? '#333333' : '#999999')
.backgroundColor(Color.Transparent)
.onClick(() => this.handleUndo())
// 重做按钮
Button('重做')
.enabled(this.canRedo)
.fontSize(14)
.fontColor(this.canRedo ? '#333333' : '#999999')
.backgroundColor(Color.Transparent)
.onClick(() => this.handleRedo())
Blank()
// 工具切换
Row() {
ForEach([
{ type: ToolType.PEN, icon: '✏️', label: '画笔' },
{ type: ToolType.HIGHLIGHTER, icon: '🖍️', label: '荧光笔' },
{ type: ToolType.ERASER, icon: '🧹', label: '橡皮' }
], (item: { type: ToolType; icon: string; label: string }) => {
Button(item.label)
.fontSize(13)
.fontColor(this.currentTool === item.type ? '#ffffff' : '#333333')
.backgroundColor(this.currentTool === item.type ? '#3498db' : '#e0e0e0')
.borderRadius(16)
.margin({ left: 4, right: 4 })
.onClick(() => {
this.currentTool = item.type;
this.engine.updateSettings({ toolType: item.type });
})
}
)
}
Blank()
// 线宽调节
Row() {
Text('粗细')
.fontSize(12)
.fontColor('#666666')
Slider({
value: this.currentWidth,
min: 1,
max: 20,
step: 1,
style: SliderStyle.InSet
})
.width(100)
.onChange((value: number) => {
this.currentWidth = value;
this.engine.updateSettings({ baseWidth: value });
})
}
// 导出按钮
Button('导出')
.fontSize(14)
.fontColor('#ffffff')
.backgroundColor('#27ae60')
.borderRadius(16)
.margin({ left: 8 })
.onClick(() => this.exportCanvas())
}
.width('100%')
.height(56)
.padding({ left: 12, right: 12 })
.backgroundColor('#ffffff')
.shadow({ radius: 2, color: '#10000000', offsetY: 1 })
}
// 底部颜色选择器
@Builder
ColorPicker() {
Row() {
ForEach(this.colorPalette, (color: string) => {
Stack() {
Circle()
.width(32)
.height(32)
.fill(color)
if (this.currentColor === color) {
Circle()
.width(36)
.height(36)
.fillOpacity(0)
.stroke('#3498db')
.strokeWidth(2.5)
}
}
.margin({ left: 8, right: 8 })
.onClick(() => {
this.currentColor = color;
this.engine.updateSettings({ color: color });
})
}
)
}
.width('100%')
.height(52)
.justifyContent(FlexAlign.Center)
.backgroundColor('#ffffff')
.shadow({ radius: 2, color: '#10000000', offsetY: -1 })
}
// 初始化画布
private initCanvas(): void {
this.context.lineWidth = 2;
this.context.strokeStyle = '#1a1a1a';
this.context.lineCap = 'round';
this.context.lineJoin = 'round';
}
// 绘制开始
private onDrawStart(event: GestureEvent): void {
const x = event.fingerList[0].localX;
const y = event.fingerList[0].localY;
this.engine.updateSettings({
color: this.currentColor,
baseWidth: this.currentWidth,
toolType: this.currentTool
});
this.engine.startPath(x, y);
}
// 绘制移动
private onDrawMove(event: GestureEvent): void {
const x = event.fingerList[0].localX;
const y = event.fingerList[0].localY;
this.engine.addPoint(x, y);
// 实时绘制当前笔触
const currentPath = this.engine.getCurrentPath();
if (currentPath && currentPath.points.length >= 2) {
this.drawStrokeSegment(currentPath);
}
}
// 绘制结束
private onDrawEnd(): void {
const path = this.engine.endPath();
if (path) {
this.undoRedo.pushStroke(path);
this.canUndo = this.undoRedo.canUndo();
this.canRedo = this.undoRedo.canRedo();
this.strokeCount++;
}
}
// 绘制笔触片段(增量绘制,性能更好)
private drawStrokeSegment(path: StrokePath): void {
const points = path.points;
const len = points.length;
if (len < 2) return;
const prev = points[len - 2];
const curr = points[len - 1];
this.context.beginPath();
// 根据工具类型设置绘制样式
if (path.toolType === ToolType.ERASER) {
this.context.globalCompositeOperation = 'destination-out';
this.context.lineWidth = path.baseWidth * 5;
} else if (path.toolType === ToolType.HIGHLIGHTER) {
this.context.globalCompositeOperation = 'source-over';
this.context.globalAlpha = 0.3;
this.context.lineWidth = path.baseWidth * 3;
} else {
this.context.globalCompositeOperation = 'source-over';
this.context.globalAlpha = 1.0;
// 速度感应线宽
const width = StrokeCalculator.calcWidthBySpeed(prev, curr, path.baseWidth);
this.context.lineWidth = width;
}
this.context.strokeStyle = path.color;
this.context.lineCap = 'round';
this.context.lineJoin = 'round';
// 使用二次贝塞尔曲线平滑
if (len >= 3) {
const pprev = points[len - 3];
const cpx = prev.x;
const cpy = prev.y;
this.context.moveTo((pprev.x + prev.x) / 2, (pprev.y + prev.y) / 2);
this.context.quadraticCurveTo(cpx, cpy, (prev.x + curr.x) / 2, (prev.y + curr.y) / 2);
} else {
this.context.moveTo(prev.x, prev.y);
this.context.lineTo(curr.x, curr.y);
}
this.context.stroke();
this.context.closePath();
}
// 重绘全部路径
private redrawAll(): void {
this.context.clearRect(0, 0, 9999, 9999);
const paths = this.engine.getPaths();
for (const path of paths) {
this.drawFullPath(path);
}
}
// 绘制完整路径
private drawFullPath(path: StrokePath): void {
const points = StrokeCalculator.smoothPoints(path.points);
if (points.length < 2) return;
this.context.beginPath();
// 设置绘制样式
if (path.toolType === ToolType.ERASER) {
this.context.globalCompositeOperation = 'destination-out';
this.context.lineWidth = path.baseWidth * 5;
} else if (path.toolType === ToolType.HIGHLIGHTER) {
this.context.globalCompositeOperation = 'source-over';
this.context.globalAlpha = 0.3;
this.context.lineWidth = path.baseWidth * 3;
} else {
this.context.globalCompositeOperation = 'source-over';
this.context.globalAlpha = 1.0;
this.context.lineWidth = path.baseWidth;
}
this.context.strokeStyle = path.color;
this.context.lineCap = 'round';
this.context.lineJoin = 'round';
this.context.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length - 1; i++) {
const cpx = points[i].x;
const cpy = points[i].y;
const endX = (points[i].x + points[i + 1].x) / 2;
const endY = (points[i].y + points[i + 1].y) / 2;
this.context.quadraticCurveTo(cpx, cpy, endX, endY);
}
// 连接最后一个点
const last = points[points.length - 1];
this.context.lineTo(last.x, last.y);
this.context.stroke();
this.context.closePath();
}
// 撤销操作
private handleUndo(): void {
const path = this.undoRedo.undo();
if (path) {
this.engine.removeLastPath();
this.redrawAll();
this.canUndo = this.undoRedo.canUndo();
this.canRedo = this.undoRedo.canRedo();
this.strokeCount--;
}
}
// 重做操作
private handleRedo(): void {
const path = this.undoRedo.redo();
if (path) {
this.engine.addPath(path);
this.redrawAll();
this.canUndo = this.undoRedo.canUndo();
this.canRedo = this.undoRedo.canRedo();
this.strokeCount++;
}
}
// 导出画布为图片
private exportCanvas(): void {
try {
// 获取画布快照
const pixelMap = this.context.getPixelMap();
if (pixelMap) {
// 保存到相册
const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(getContext(this));
const uri = phAccessHelper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'png');
if (uri) {
const file = fs.openSync(uri, fs.OpenMode.WRITE_ONLY);
const packer = image.createImagePacker(file.fd);
packer.packSync(pixelMap, { format: 'image/png', quality: 100 });
packer.release();
fs.closeSync(file);
// 提示导出成功
promptAction.showToast({ message: '白板已保存到相册' });
}
}
} catch (err) {
promptAction.showToast({ message: '导出失败,请检查权限' });
}
}
}
// 扩展DrawingEngine方法
class DrawingEngine {
// ... 前面定义的属性和方法
private paths: StrokePath[] = [];
private currentPath: StrokePath | null = null;
private settings: DrawingSettings = {
color: '#000000',
baseWidth: 3,
toolType: ToolType.PEN
};
// 获取当前路径
getCurrentPath(): StrokePath | null {
return this.currentPath;
}
// 移除最后一条路径
removeLastPath(): void {
if (this.paths.length > 0) {
this.paths.pop();
}
}
// 添加路径(用于重做)
addPath(path: StrokePath): void {
this.paths.push(path);
}
startPath(x: number, y: number): void {
const point: DrawPoint = { x, y, timestamp: Date.now(), pressure: 0.5 };
this.currentPath = {
points: [point],
color: this.settings.color,
baseWidth: this.settings.baseWidth,
toolType: this.settings.toolType
};
}
addPoint(x: number, y: number): void {
if (!this.currentPath) return;
this.currentPath.points.push({ x, y, timestamp: Date.now(), pressure: 0.5 });
}
endPath(): StrokePath | null {
if (!this.currentPath) return null;
const path = this.currentPath;
this.paths.push(path);
this.currentPath = null;
return path;
}
getPaths(): StrokePath[] {
return this.paths;
}
updateSettings(settings: Partial<DrawingSettings>): void {
this.settings = { ...this.settings, ...settings };
}
}
四、踩坑与注意事项
坑点1:PanGesture与Canvas的坐标偏移
Canvas组件在使用PanGesture时,fingerList[0].localX/localY 返回的是相对于Canvas组件的局部坐标。但如果Canvas外层有padding或其他布局偏移,坐标可能不准确。务必确保Canvas的宽高与实际绘制区域一致,或者在计算坐标时加上偏移量。
坑点2:globalCompositeOperation重置问题
使用橡皮擦时设置了 destination-out 混合模式,绘制完毕后必须重置为 source-over,否则后续所有绘制都会变成"擦除"效果。同样,荧光笔的 globalAlpha 也需要在绘制完毕后恢复为 1.0。
坑点3:增量绘制与全量重绘的闪烁问题
实时绘制时使用增量绘制(只画新增的线段),性能好但可能导致线条连接处有细微断裂。撤销重做时需要全量重绘,此时如果先 clearRect 再逐条绘制,会出现短暂闪烁。解决方案:使用双缓冲Canvas,先在离屏Canvas上绘制完成,再一次性拷贝到显示Canvas。
坑点4:高频触摸事件导致性能下降
手指在屏幕上滑动时,触摸事件频率非常高(可达每秒120次以上),每次都触发Canvas绘制会导致性能问题。建议做节流处理:当两个采样点距离小于2像素时跳过,既减少绘制次数又不影响线条平滑度。
坑点5:导出图片时画布内容为空
getPixelMap() 只能获取Canvas上已渲染的内容。如果在 onReady 之前就尝试绘制,或者绘制后没有调用 stroke()/fill(),导出的图片可能是一片空白。确保所有绘制操作都在 onReady 回调之后执行,并且每次路径都正确关闭。
坑点6:撤销重做与橡皮擦的冲突
橡皮擦使用 destination-out 混合模式,本质上是"擦除已有像素"。但撤销时我们只是移除了最后一条路径并重绘,橡皮擦的擦除效果无法通过简单重绘来恢复。解决方案:将橡皮擦也当作普通路径存储,重绘时按顺序执行所有操作(包括擦除),这样撤销时重新执行整个路径列表即可正确恢复。
五、HarmonyOS 6适配说明
API差异
| API | HarmonyOS 5.0 | HarmonyOS 6.0 | 迁移建议 |
|---|---|---|---|
| Canvas.onReady | 组件挂载后触发 | 组件挂载后触发,时机更早 | 无需修改,但注意6.0中更早触发可能影响初始化顺序 |
| RenderingContext.getPixelMap | 返回PixelMap | 返回Promise<PixelMap> | 需改用await/then异步调用 |
| PanGesture回调参数 | GestureEvent | GestureEvent(新增force字段) | 可利用force字段实现压感笔触 |
| image.createImagePacker | 同步创建 | 返回ImagePacker(新增packAsync) | 推荐使用packAsync避免阻塞UI线程 |
| photoAccessHelper.createAsset | 同步返回uri | 异步返回Promise<string> | 需改用await/then异步调用 |
行为变更
- Canvas硬件加速默认开启:HarmonyOS 6.0中Canvas默认启用硬件加速,绘制性能提升约30%,但
globalCompositeOperation的某些模式(如destination-out)行为可能略有差异,建议充分测试橡皮擦功能 - 触摸事件采样率提升:6.0的触摸采样率从120Hz提升至240Hz,高频事件更多,需要加强节流处理
- getPixelMap异步化:导出图片必须使用异步方式,同步调用会抛出异常
适配代码
// HarmonyOS 6适配:异步导出画布
private async exportCanvasV6(): Promise<void> {
try {
// 6.0中getPixelMap返回Promise
const pixelMap = await this.context.getPixelMap();
if (!pixelMap) {
promptAction.showToast({ message: '获取画布数据失败' });
return;
}
const context = getContext(this);
const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
// 6.0中createAsset异步化
const assetUri = await phAccessHelper.createAsset(
photoAccessHelper.PhotoType.IMAGE, 'png'
);
const file = fs.openSync(assetUri, fs.OpenMode.WRITE_ONLY);
const packer = image.createImagePacker(file.fd);
// 6.0推荐使用packAsync
await packer.packAsync(pixelMap, { format: 'image/png', quality: 100 });
packer.release();
fs.closeSync(file);
promptAction.showToast({ message: '白板已保存到相册' });
} catch (err) {
promptAction.showToast({ message: '导出失败:' + (err as Error).message });
}
}
// HarmonyOS 6适配:利用force字段实现压感笔触
private onDrawStartV6(event: GestureEvent): void {
const finger = event.fingerList[0];
const x = finger.localX;
const y = finger.localY;
// 6.0新增force字段,支持压感
const pressure = finger.force !== undefined ? finger.force : 0.5;
this.engine.startPath(x, y);
// 更新当前路径第一个点的压力值
const currentPath = this.engine.getCurrentPath();
if (currentPath) {
currentPath.points[0].pressure = pressure;
}
}
六、总结
| 维度 | 评价 |
|---|---|
| 学习难度 | ⭐⭐⭐⭐ |
| 使用频率 | ⭐⭐⭐⭐ |
| 重要程度 | ⭐⭐⭐⭐⭐ |
手绘白板应用看似简单,实则暗藏玄机。从绘制引擎的架构设计到笔触效果的算法实现,从撤销重做的状态管理到导出分享的权限处理,每一个环节都需要精心打磨。本文我们从零搭建了完整的白板应用,核心收获有三点:
第一,绘制引擎要分层设计。 交互层、引擎层、数据层各司其职,才能让代码清晰可维护。别把所有逻辑都塞进手势回调里,那样后期扩展会非常痛苦。
第二,笔触效果是白板应用的灵魂。 速度感应线宽 + 贝塞尔曲线平滑,这两招组合拳就能让数字笔触拥有"纸笔感"。如果设备支持压感(HarmonyOS 6.0的force字段),效果还能再上一个台阶。
第三,撤销重做要用命令模式。 不要试图用"保存画布快照"的方式实现撤销——那会吃光内存。命令模式只存储操作数据,内存占用小、恢复速度快,才是正道。
白板应用还有很多可以扩展的方向:形状识别(画圆自动变成正圆)、文字输入、多人协作、贴图功能……这些都是在本文基础架构上的自然延伸。掌握了核心原理,剩下的就是创意和时间的问题了。
- 点赞
- 收藏
- 关注作者
评论(0)