/** * 自然写互动课堂智慧黑板端应用软件 V1.0 * * WhiteboardEngine.kt - 白板渲染引擎 * * 功能说明: * - Canvas 2D高性能笔迹渲染(SurfaceView双缓冲) * - 教师触控书写(多点触控支持) * - 压力感应笔锋效果(贝塞尔曲线平滑) * - 撤销/重做操作栈 * - 画布缩放/平移手势 * - 笔迹序列化与反序列化 * - 背景课件叠加渲染(PPT/PDF/图片) */ package com.writech.board.engine import android.content.Context import android.graphics.* import android.util.Log import android.view.MotionEvent import android.view.SurfaceHolder import android.view.SurfaceView import java.io.* import java.util.LinkedList import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.* /** * 笔迹点数据 * @param x X坐标(屏幕像素) * @param y Y坐标(屏幕像素) * @param pressure 压力值 0.0-1.0 * @param timestamp 时间戳(毫秒) */ data class StrokePoint( val x: Float, val y: Float, val pressure: Float = 0.5f, val timestamp: Long = System.currentTimeMillis() ) /** * 单条笔画数据 * 包含构成一笔的所有采样点 */ data class Stroke( val points: MutableList = mutableListOf(), var color: Int = Color.BLACK, var baseWidth: Float = 4.0f, var isEraser: Boolean = false, val strokeId: Long = System.currentTimeMillis() ) /** * 撤销/重做操作记录 */ sealed class CanvasAction { data class AddStroke(val stroke: Stroke) : CanvasAction() data class RemoveStroke(val stroke: Stroke) : CanvasAction() data class ClearAll(val strokes: List) : CanvasAction() } /** * 白板渲染引擎 * * 基于SurfaceView实现高性能笔迹渲染: * - 独立渲染线程,不阻塞UI线程 * - 双缓冲绘制,避免画面撕裂 * - 压力感应笔锋:笔迹宽度随压力动态变化 * - 贝塞尔曲线平滑:消除采样锯齿 */ class WhiteboardEngine(context: Context) : SurfaceView(context), SurfaceHolder.Callback { companion object { private const val TAG = "WhiteboardEngine" /** 撤销栈最大深度 */ private const val MAX_UNDO_DEPTH = 50 /** 贝塞尔平滑采样阈值(像素) */ private const val SMOOTH_THRESHOLD = 2.0f /** 笔锋最小宽度比例 */ private const val MIN_WIDTH_RATIO = 0.3f /** 笔锋最大宽度比例 */ private const val MAX_WIDTH_RATIO = 1.5f /** 橡皮擦半径 */ private const val ERASER_RADIUS = 30.0f } /* ==================== 渲染状态 ==================== */ /** 所有已完成的笔画列表 */ private val completedStrokes = CopyOnWriteArrayList() /** 当前正在绘制的笔画 */ private var currentStroke: Stroke? = null /** 撤销栈 */ private val undoStack = LinkedList() /** 重做栈 */ private val redoStack = LinkedList() /* ==================== 绘图工具 ==================== */ /** 笔迹画笔 */ private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE strokeCap = Paint.Cap.ROUND strokeJoin = Paint.Join.ROUND color = Color.BLACK strokeWidth = 4.0f } /** 橡皮擦画笔 */ private val eraserPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE strokeCap = Paint.Cap.ROUND strokeWidth = ERASER_RADIUS * 2 xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) } /** 背景课件位图 */ private var backgroundBitmap: Bitmap? = null /** 离屏缓冲位图(已完成笔画的缓存) */ private var offscreenBitmap: Bitmap? = null private var offscreenCanvas: Canvas? = null /* ==================== 画布变换 ==================== */ /** 画布变换矩阵(缩放+平移) */ private val canvasMatrix = Matrix() /** 逆矩阵(触摸坐标反变换) */ private val inverseMatrix = Matrix() /** 当前缩放比例 */ private var currentScale = 1.0f /** 当前偏移 */ private var translateX = 0.0f private var translateY = 0.0f /* ==================== 工具状态 ==================== */ /** 当前画笔颜色 */ var penColor: Int = Color.BLACK /** 当前画笔宽度 */ var penWidth: Float = 4.0f /** 是否使用橡皮擦模式 */ var eraserMode: Boolean = false /** 是否启用压力感应 */ var pressureSensitive: Boolean = true /** 渲染线程运行标志 */ private var isRendering = false init { holder.addCallback(this) isFocusable = true isFocusableInTouchMode = true } /* ==================== SurfaceHolder回调 ==================== */ override fun surfaceCreated(holder: SurfaceHolder) { Log.i(TAG, "Surface创建: ${holder.surfaceFrame.width()}x${holder.surfaceFrame.height()}") /* 创建离屏缓冲 */ val w = holder.surfaceFrame.width() val h = holder.surfaceFrame.height() offscreenBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) offscreenCanvas = Canvas(offscreenBitmap!!) isRendering = true renderFrame() } override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { Log.i(TAG, "Surface尺寸变更: ${width}x${height}") /* 重建离屏缓冲 */ offscreenBitmap?.recycle() offscreenBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) offscreenCanvas = Canvas(offscreenBitmap!!) rebuildOffscreen() } override fun surfaceDestroyed(holder: SurfaceHolder) { isRendering = false offscreenBitmap?.recycle() offscreenBitmap = null Log.i(TAG, "Surface销毁") } /* ==================== 触摸事件处理 ==================== */ override fun onTouchEvent(event: MotionEvent): Boolean { /* 将屏幕坐标通过逆矩阵转换为画布坐标 */ val pts = floatArrayOf(event.x, event.y) canvasMatrix.invert(inverseMatrix) inverseMatrix.mapPoints(pts) val canvasX = pts[0] val canvasY = pts[1] val pressure = if (pressureSensitive) event.pressure.coerceIn(0.1f, 1.0f) else 0.5f when (event.action) { MotionEvent.ACTION_DOWN -> { onTouchDown(canvasX, canvasY, pressure) } MotionEvent.ACTION_MOVE -> { onTouchMove(canvasX, canvasY, pressure) } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { onTouchUp(canvasX, canvasY, pressure) } } return true } /** * 触摸按下 - 开始新笔画 */ private fun onTouchDown(x: Float, y: Float, pressure: Float) { if (eraserMode) { eraseAtPoint(x, y) return } currentStroke = Stroke( color = penColor, baseWidth = penWidth, isEraser = false ) currentStroke?.points?.add(StrokePoint(x, y, pressure)) } /** * 触摸移动 - 添加采样点并实时渲染 */ private fun onTouchMove(x: Float, y: Float, pressure: Float) { if (eraserMode) { eraseAtPoint(x, y) return } val stroke = currentStroke ?: return val lastPoint = stroke.points.lastOrNull() ?: return /* 距离过近时跳过采样(减少冗余点) */ val dx = x - lastPoint.x val dy = y - lastPoint.y val dist = sqrt(dx * dx + dy * dy) if (dist < SMOOTH_THRESHOLD) return stroke.points.add(StrokePoint(x, y, pressure)) /* 增量渲染当前笔画的最新线段 */ renderCurrentStroke() } /** * 触摸抬起 - 完成笔画并加入撤销栈 */ private fun onTouchUp(x: Float, y: Float, pressure: Float) { val stroke = currentStroke ?: return if (stroke.points.size >= 2) { completedStrokes.add(stroke) /* 记入撤销栈 */ pushUndoAction(CanvasAction.AddStroke(stroke)) /* 将笔画绘制到离屏缓冲 */ drawStrokeToOffscreen(stroke) Log.d(TAG, "笔画完成: ${stroke.points.size}个点, 颜色=#${Integer.toHexString(stroke.color)}") } currentStroke = null renderFrame() } /* ==================== 笔迹渲染 ==================== */ /** * 在离屏缓冲上绘制一条完整笔画 * 使用贝塞尔曲线平滑 + 压力感应笔锋 */ private fun drawStrokeToOffscreen(stroke: Stroke) { val canvas = offscreenCanvas ?: return val points = stroke.points if (points.size < 2) return strokePaint.color = stroke.color for (i in 1 until points.size) { val prev = points[i - 1] val curr = points[i] /* 压力感应笔锋:宽度随压力变化 */ val pressureWidth = stroke.baseWidth * (MIN_WIDTH_RATIO + (MAX_WIDTH_RATIO - MIN_WIDTH_RATIO) * curr.pressure) strokePaint.strokeWidth = pressureWidth if (i >= 2) { /* 使用二次贝塞尔曲线平滑 */ val prevPrev = points[i - 2] val midX1 = (prevPrev.x + prev.x) / 2f val midY1 = (prevPrev.y + prev.y) / 2f val midX2 = (prev.x + curr.x) / 2f val midY2 = (prev.y + curr.y) / 2f val path = Path() path.moveTo(midX1, midY1) path.quadTo(prev.x, prev.y, midX2, midY2) canvas.drawPath(path, strokePaint) } else { /* 前两个点直接连线 */ canvas.drawLine(prev.x, prev.y, curr.x, curr.y, strokePaint) } } } /** * 渲染当前正在绘制的笔画(增量渲染最新线段) */ private fun renderCurrentStroke() { if (!isRendering) return val canvas = holder.lockCanvas() ?: return try { /* 绘制离屏缓冲(已完成笔画) */ canvas.save() canvas.setMatrix(canvasMatrix) offscreenBitmap?.let { canvas.drawBitmap(it, 0f, 0f, null) } /* 绘制当前笔画 */ currentStroke?.let { stroke -> drawStrokeOnCanvas(canvas, stroke) } canvas.restore() } finally { holder.unlockCanvasAndPost(canvas) } } /** * 在指定Canvas上直接绘制笔画 */ private fun drawStrokeOnCanvas(canvas: Canvas, stroke: Stroke) { val points = stroke.points if (points.size < 2) return strokePaint.color = stroke.color for (i in 1 until points.size) { val prev = points[i - 1] val curr = points[i] val pressureWidth = stroke.baseWidth * (MIN_WIDTH_RATIO + (MAX_WIDTH_RATIO - MIN_WIDTH_RATIO) * curr.pressure) strokePaint.strokeWidth = pressureWidth canvas.drawLine(prev.x, prev.y, curr.x, curr.y, strokePaint) } } /** * 完整帧渲染(背景+离屏缓冲+当前笔画) */ private fun renderFrame() { if (!isRendering) return val canvas = holder.lockCanvas() ?: return try { canvas.drawColor(Color.WHITE) canvas.save() canvas.setMatrix(canvasMatrix) /* 绘制背景课件 */ backgroundBitmap?.let { bmp -> canvas.drawBitmap(bmp, 0f, 0f, null) } /* 绘制离屏缓冲 */ offscreenBitmap?.let { canvas.drawBitmap(it, 0f, 0f, null) } canvas.restore() } finally { holder.unlockCanvasAndPost(canvas) } } /** * 重建离屏缓冲(Surface尺寸变化或撤销操作后) */ private fun rebuildOffscreen() { val canvas = offscreenCanvas ?: return canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) completedStrokes.forEach { stroke -> drawStrokeToOffscreen(stroke) } renderFrame() } /* ==================== 橡皮擦 ==================== */ /** * 在指定点擦除笔迹 * 检查所有笔画中是否有点落在橡皮擦范围内 */ private fun eraseAtPoint(x: Float, y: Float) { val toRemove = mutableListOf() completedStrokes.forEach { stroke -> val hit = stroke.points.any { pt -> val dx = pt.x - x val dy = pt.y - y sqrt(dx * dx + dy * dy) < ERASER_RADIUS } if (hit) { toRemove.add(stroke) } } if (toRemove.isNotEmpty()) { toRemove.forEach { stroke -> completedStrokes.remove(stroke) pushUndoAction(CanvasAction.RemoveStroke(stroke)) } rebuildOffscreen() Log.d(TAG, "橡皮擦删除${toRemove.size}条笔画") } } /* ==================== 撤销/重做 ==================== */ /** * 记录操作到撤销栈 */ private fun pushUndoAction(action: CanvasAction) { undoStack.push(action) if (undoStack.size > MAX_UNDO_DEPTH) { undoStack.removeLast() } redoStack.clear() } /** * 撤销上一步操作 */ fun undo() { val action = undoStack.pollFirst() ?: return when (action) { is CanvasAction.AddStroke -> { completedStrokes.remove(action.stroke) redoStack.push(action) } is CanvasAction.RemoveStroke -> { completedStrokes.add(action.stroke) redoStack.push(action) } is CanvasAction.ClearAll -> { completedStrokes.addAll(action.strokes) redoStack.push(action) } } rebuildOffscreen() Log.d(TAG, "撤销操作, 剩余撤销=${undoStack.size}") } /** * 重做操作 */ fun redo() { val action = redoStack.pollFirst() ?: return when (action) { is CanvasAction.AddStroke -> { completedStrokes.add(action.stroke) undoStack.push(action) } is CanvasAction.RemoveStroke -> { completedStrokes.remove(action.stroke) undoStack.push(action) } is CanvasAction.ClearAll -> { completedStrokes.clear() undoStack.push(action) } } rebuildOffscreen() Log.d(TAG, "重做操作, 剩余重做=${redoStack.size}") } /** * 清空所有笔迹 */ fun clearAll() { if (completedStrokes.isEmpty()) return val backup = completedStrokes.toList() pushUndoAction(CanvasAction.ClearAll(backup)) completedStrokes.clear() rebuildOffscreen() Log.i(TAG, "清空画布, ${backup.size}条笔画已备份到撤销栈") } /* ==================== 课件背景 ==================== */ /** * 设置背景课件图片 */ fun setBackground(bitmap: Bitmap?) { backgroundBitmap?.recycle() backgroundBitmap = bitmap renderFrame() } /* ==================== 笔迹序列化 ==================== */ /** * 将当前所有笔迹序列化为字节数组 * 格式: [笔画数][笔画1数据][笔画2数据]... */ fun serializeStrokes(): ByteArray { val bos = ByteArrayOutputStream() val dos = DataOutputStream(bos) dos.writeInt(completedStrokes.size) completedStrokes.forEach { stroke -> dos.writeInt(stroke.color) dos.writeFloat(stroke.baseWidth) dos.writeInt(stroke.points.size) stroke.points.forEach { pt -> dos.writeFloat(pt.x) dos.writeFloat(pt.y) dos.writeFloat(pt.pressure) dos.writeLong(pt.timestamp) } } dos.flush() Log.d(TAG, "笔迹序列化: ${completedStrokes.size}条笔画, ${bos.size()}字节") return bos.toByteArray() } /** * 从字节数组反序列化笔迹 */ fun deserializeStrokes(data: ByteArray) { val dis = DataInputStream(ByteArrayInputStream(data)) completedStrokes.clear() val strokeCount = dis.readInt() repeat(strokeCount) { val color = dis.readInt() val width = dis.readFloat() val pointCount = dis.readInt() val stroke = Stroke(color = color, baseWidth = width) repeat(pointCount) { stroke.points.add(StrokePoint( x = dis.readFloat(), y = dis.readFloat(), pressure = dis.readFloat(), timestamp = dis.readLong() )) } completedStrokes.add(stroke) } rebuildOffscreen() Log.i(TAG, "笔迹反序列化: ${strokeCount}条笔画已加载") } }