software copyright

This commit is contained in:
jiahong
2026-03-22 15:24:40 +08:00
parent e303bb868a
commit 60f336e345
155 changed files with 127262 additions and 0 deletions
@@ -0,0 +1,578 @@
/**
* 自然写互动课堂智慧黑板端应用软件 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}条笔画已加载")
}
}