579 lines
17 KiB
Kotlin
579 lines
17 KiB
Kotlin
/**
|
|
* 自然写互动课堂智慧黑板端应用软件 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<StrokePoint> = 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<Stroke>) : 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<Stroke>()
|
|
/** 当前正在绘制的笔画 */
|
|
private var currentStroke: Stroke? = null
|
|
/** 撤销栈 */
|
|
private val undoStack = LinkedList<CanvasAction>()
|
|
/** 重做栈 */
|
|
private val redoStack = LinkedList<CanvasAction>()
|
|
|
|
/* ==================== 绘图工具 ==================== */
|
|
|
|
/** 笔迹画笔 */
|
|
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<Stroke>()
|
|
|
|
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}条笔画已加载")
|
|
}
|
|
}
|