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,358 @@
/**
* 自然写互动课堂电视端应用软件 V1.0
* 多学生同屏对比视图 - 选取学生笔迹并排大屏展示
*
* 功能说明:
* 1. 多学生笔迹同屏对比展示(2/4/6/9宫格布局)
* 2. 学生选择器(从在线学生列表中选取展示对象)
* 3. 实时笔迹同步更新(选中学生的笔迹实时追加)
* 4. 笔迹回放对比(多学生同步回放书写过程)
* 5. 学生信息叠加显示(姓名、座号、书写进度)
* 6. 遥控器操作适配(D-Pad选择学生、切换布局)
* 7. 范字参考叠加(可选显示标准字帖做对比参照)
*/
package com.writech.tv.renderer
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.os.Handler
import android.os.Looper
import android.util.Log
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
/**
* 展示布局模式
*/
enum class DisplayLayout(val columns: Int, val rows: Int) {
SINGLE(1, 1), // 单人全屏
DUAL(2, 1), // 双人并排
QUAD(2, 2), // 四宫格
SIX(3, 2), // 六宫格
NINE(3, 3); // 九宫格
val cellCount: Int get() = columns * rows
}
/**
* 学生展示信息
*/
data class StudentDisplayInfo(
val studentId: String,
val studentName: String,
val seatNumber: Int,
val color: Int, // 分配的标识颜色
var strokeCount: Int = 0, // 已书写笔画数
var isWriting: Boolean = false, // 是否正在书写
var lastUpdateTime: Long = 0 // 最后更新时间
)
/**
* 多学生同屏对比视图管理器
* 管理宫格布局中每个单元格的笔迹渲染
*/
class MultiStudentView {
companion object {
private const val TAG = "MultiStudentView"
/** 单元格间距(像素) */
private const val CELL_PADDING = 8
/** 标签栏高度(像素) */
private const val LABEL_HEIGHT = 48
/** 标签文字大小(像素) */
private const val LABEL_TEXT_SIZE = 24f
/** 边框宽度(像素) */
private const val BORDER_WIDTH = 3f
/** 正在书写的边框闪烁间隔(毫秒) */
private const val BLINK_INTERVAL = 500L
}
/** 当前布局模式 */
var layout: DisplayLayout = DisplayLayout.QUAD
private set
/** 展示的学生列表(按单元格位置排列) */
private val displayStudents = CopyOnWriteArrayList<StudentDisplayInfo>()
/** 每个学生对应的笔迹数据 */
private val studentStrokes = ConcurrentHashMap<String, MutableList<Stroke>>()
/** 主线程Handler */
private val mainHandler = Handler(Looper.getMainLooper())
/** 绘制用Paint对象 */
private val borderPaint = Paint().apply {
style = Paint.Style.STROKE
strokeWidth = BORDER_WIDTH
isAntiAlias = true
}
private val labelBgPaint = Paint().apply {
style = Paint.Style.FILL
color = Color.parseColor("#E0E0E0")
}
private val labelTextPaint = Paint().apply {
color = Color.parseColor("#333333")
textSize = LABEL_TEXT_SIZE
isAntiAlias = true
textAlign = Paint.Align.LEFT
}
private val writingIndicatorPaint = Paint().apply {
color = Color.parseColor("#4CAF50")
style = Paint.Style.FILL
}
private val strokePaint = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
/** 是否显示范字参考 */
var showReference: Boolean = false
/** 范字图片路径 */
var referencePath: String = ""
/** 当前选中的单元格索引(遥控器焦点) */
var selectedCellIndex: Int = -1
/**
* 切换布局模式
*/
fun setLayout(newLayout: DisplayLayout) {
layout = newLayout
// 如果学生数超过新布局的容量,截断显示
while (displayStudents.size > layout.cellCount) {
val removed = displayStudents.removeAt(displayStudents.size - 1)
studentStrokes.remove(removed.studentId)
}
Log.i(TAG, "布局切换为: ${newLayout.name} (${newLayout.columns}x${newLayout.rows})")
}
/**
* 添加学生到展示区
* @return 分配的单元格索引,-1表示已满
*/
fun addStudent(info: StudentDisplayInfo): Int {
if (displayStudents.size >= layout.cellCount) {
Log.w(TAG, "展示区已满 (${layout.cellCount}个)")
return -1
}
// 分配颜色
val coloredInfo = info.copy(
color = StudentColorPalette.getColor(displayStudents.size)
)
displayStudents.add(coloredInfo)
studentStrokes[info.studentId] = mutableListOf()
val index = displayStudents.size - 1
Log.i(TAG, "添加学生: ${info.studentName} -> 单元格$index")
return index
}
/**
* 移除学生
*/
fun removeStudent(studentId: String) {
displayStudents.removeAll { it.studentId == studentId }
studentStrokes.remove(studentId)
Log.i(TAG, "移除学生: $studentId")
}
/**
* 添加笔迹数据到指定学生
*/
fun addStroke(studentId: String, stroke: Stroke) {
studentStrokes[studentId]?.add(stroke)
displayStudents.find { it.studentId == studentId }?.let {
it.strokeCount++
it.lastUpdateTime = System.currentTimeMillis()
}
}
/**
* 更新学生书写状态
*/
fun updateWritingState(studentId: String, isWriting: Boolean) {
displayStudents.find { it.studentId == studentId }?.isWriting = isWriting
}
/**
* 在Canvas上绘制多学生对比视图
* @param canvas 目标画布
* @param width 画布总宽度
* @param height 画布总高度
*/
fun draw(canvas: Canvas, width: Int, height: Int) {
val cols = layout.columns
val rows = layout.rows
// 计算每个单元格的尺寸
val cellWidth = (width - CELL_PADDING * (cols + 1)) / cols
val cellHeight = (height - CELL_PADDING * (rows + 1)) / rows
for (index in 0 until min(displayStudents.size, layout.cellCount)) {
val student = displayStudents[index]
val col = index % cols
val row = index / cols
// 计算单元格位置
val left = CELL_PADDING + col * (cellWidth + CELL_PADDING)
val top = CELL_PADDING + row * (cellHeight + CELL_PADDING)
val cellRect = RectF(
left.toFloat(), top.toFloat(),
(left + cellWidth).toFloat(), (top + cellHeight).toFloat()
)
// 绘制单元格内容
drawCell(canvas, cellRect, student, index)
}
}
/**
* 绘制单个单元格
*/
private fun drawCell(canvas: Canvas, rect: RectF, student: StudentDisplayInfo, index: Int) {
// 绘制单元格背景
val bgPaint = Paint().apply {
color = Color.WHITE
style = Paint.Style.FILL
}
canvas.drawRoundRect(rect, 8f, 8f, bgPaint)
// 绘制边框(选中的单元格用高亮边框)
borderPaint.color = if (index == selectedCellIndex) {
Color.parseColor("#2196F3") // 选中态蓝色
} else if (student.isWriting) {
student.color // 书写中用学生颜色
} else {
Color.parseColor("#BDBDBD") // 默认灰色
}
borderPaint.strokeWidth = if (index == selectedCellIndex) 5f else BORDER_WIDTH
canvas.drawRoundRect(rect, 8f, 8f, borderPaint)
// 绘制标签栏(学生姓名 + 座号 + 书写状态)
val labelRect = RectF(rect.left, rect.top, rect.right, rect.top + LABEL_HEIGHT)
labelBgPaint.color = Color.argb(230, Color.red(student.color),
Color.green(student.color), Color.blue(student.color))
canvas.drawRoundRect(
RectF(labelRect.left + 1, labelRect.top + 1, labelRect.right - 1, labelRect.bottom),
8f, 0f, labelBgPaint
)
// 绘制学生姓名
labelTextPaint.color = Color.WHITE
labelTextPaint.textSize = LABEL_TEXT_SIZE
canvas.drawText(
"${student.seatNumber}${student.studentName}",
rect.left + 12f, rect.top + LABEL_HEIGHT - 14f,
labelTextPaint
)
// 绘制书写状态指示点(绿色=正在书写)
if (student.isWriting) {
canvas.drawCircle(
rect.right - 20f, rect.top + LABEL_HEIGHT / 2f,
6f, writingIndicatorPaint
)
}
// 绘制笔迹内容区域
val contentRect = RectF(
rect.left + 4f, rect.top + LABEL_HEIGHT + 4f,
rect.right - 4f, rect.bottom - 4f
)
canvas.save()
canvas.clipRect(contentRect)
// 计算笔迹缩放(将点阵纸坐标映射到单元格内容区域)
val scaleX = contentRect.width() / 200f // 假设点阵纸宽200mm
val scaleY = contentRect.height() / 280f // 假设点阵纸高280mm
val scale = min(scaleX, scaleY)
canvas.translate(contentRect.left, contentRect.top)
canvas.scale(scale, scale)
// 绘制该学生的所有笔迹
val strokes = studentStrokes[student.studentId] ?: emptyList()
for (stroke in strokes) {
drawStroke(canvas, stroke, student.color)
}
canvas.restore()
// 绘制笔画计数
val countText = "${student.strokeCount}"
labelTextPaint.color = Color.GRAY
labelTextPaint.textSize = 18f
canvas.drawText(countText, rect.right - 60f, rect.bottom - 8f, labelTextPaint)
}
/**
* 绘制单个笔画
*/
private fun drawStroke(canvas: Canvas, stroke: Stroke, color: Int) {
if (stroke.points.size < 2) return
strokePaint.color = color
strokePaint.strokeWidth = stroke.baseWidth
for (i in 1 until stroke.points.size) {
val prev = stroke.points[i - 1]
val curr = stroke.points[i]
canvas.drawLine(prev.x, prev.y, curr.x, curr.y, strokePaint)
}
}
/**
* 遥控器方向键导航(移动焦点到相邻单元格)
*/
fun navigateFocus(direction: Int): Boolean {
val cols = layout.columns
val totalCells = min(displayStudents.size, layout.cellCount)
if (totalCells == 0) return false
when (direction) {
0 -> selectedCellIndex = max(0, selectedCellIndex - cols) // 上
1 -> selectedCellIndex = min(totalCells - 1, selectedCellIndex + cols) // 下
2 -> selectedCellIndex = max(0, selectedCellIndex - 1) // 左
3 -> selectedCellIndex = min(totalCells - 1, selectedCellIndex + 1) // 右
}
return true
}
/** 清除所有展示数据 */
fun clearAll() {
displayStudents.clear()
studentStrokes.clear()
selectedCellIndex = -1
}
/** 获取当前展示的学生数量 */
fun getDisplayCount(): Int = displayStudents.size
/** 释放资源 */
fun release() {
clearAll()
Log.i(TAG, "多学生视图已释放")
}
}
@@ -0,0 +1,457 @@
/**
* 自然写互动课堂电视端应用软件 V1.0
* OpenGL笔迹渲染器 - 大屏60fps低延迟笔迹渲染引擎
*
* 功能说明:
* 1. OpenGL ES 2.0实时笔迹渲染(60fps目标帧率)
* 2. 贝塞尔曲线平滑(三次贝塞尔插值消除锯齿)
* 3. 压力感应笔锋效果(笔画宽度随压力变化,落笔/抬笔尖锋)
* 4. 多学生笔迹颜色区分(每个学生分配不同颜色)
* 5. 笔迹回放动画(逐点重放书写过程,支持变速)
* 6. 双缓冲渲染优化(离屏FBO缓存已绘制内容)
* 7. 大屏分辨率自适应(4K/1080P自动匹配)
*/
package com.writech.tv.renderer
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PointF
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.util.Log
import android.view.SurfaceHolder
import android.view.SurfaceView
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
/**
* 笔迹坐标点数据
* @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 = 0L
)
/**
* 笔画数据(一次落笔到抬笔的完整轨迹)
* @param studentId 学生标识(用于颜色区分)
* @param points 坐标点列表
* @param color 笔迹颜色
* @param baseWidth 基础笔画宽度(像素)
*/
data class Stroke(
val studentId: String,
val points: MutableList<StrokePoint> = mutableListOf(),
val color: Int = Color.BLACK,
val baseWidth: Float = 3.0f
)
/**
* 学生笔迹颜色分配表
* 预定义12种高对比度颜色,确保大屏上可区分
*/
object StudentColorPalette {
private val colors = intArrayOf(
Color.parseColor("#1976D2"), // 蓝色
Color.parseColor("#D32F2F"), // 红色
Color.parseColor("#388E3C"), // 绿色
Color.parseColor("#F57C00"), // 橙色
Color.parseColor("#7B1FA2"), // 紫色
Color.parseColor("#00838F"), // 青色
Color.parseColor("#C2185B"), // 粉色
Color.parseColor("#455A64"), // 灰蓝
Color.parseColor("#795548"), // 棕色
Color.parseColor("#0097A7"), // 深青
Color.parseColor("#689F38"), // 草绿
Color.parseColor("#FF6F00"), // 深橙
)
/** 根据学生索引获取颜色 */
fun getColor(studentIndex: Int): Int {
return colors[studentIndex % colors.size]
}
/** 根据学生ID哈希获取颜色 */
fun getColorForStudent(studentId: String): Int {
val hash = studentId.hashCode() and 0x7FFFFFFF
return colors[hash % colors.size]
}
}
/**
* 笔迹渲染器 - 基于SurfaceView的高性能大屏笔迹渲染
*
* 采用双缓冲策略:
* - 后缓冲(offscreenBitmap):存储已确认的历史笔迹
* - 前缓冲(SurfaceView Canvas):在后缓冲基础上绘制当前活跃笔画
*
* 这样每帧只需绘制当前正在书写的笔画,大幅减少重绘开销
*/
class StrokeRenderer @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : SurfaceView(context, attrs, defStyleAttr), SurfaceHolder.Callback {
companion object {
private const val TAG = "StrokeRenderer"
/** 目标帧率 */
private const val TARGET_FPS = 60
/** 帧间隔(毫秒) */
private const val FRAME_INTERVAL_MS = 1000L / TARGET_FPS
/** 坐标系缩放比例(毫米到像素的转换系数) */
private const val MM_TO_PX = 4.0f
/** 贝塞尔曲线平滑张力系数 */
private const val BEZIER_TENSION = 0.25f
/** 笔锋效果-落笔过渡点数 */
private const val PEN_DOWN_TRANSITION = 5
/** 笔锋效果-抬笔过渡点数 */
private const val PEN_UP_TRANSITION = 5
}
/** 已完成的笔画列表(线程安全) */
private val completedStrokes = CopyOnWriteArrayList<Stroke>()
/** 当前正在书写的活跃笔画(按学生ID索引) */
private val activeStrokes = ConcurrentHashMap<String, Stroke>()
/** 离屏缓冲Bitmap(存储历史笔迹) */
private var offscreenBitmap: android.graphics.Bitmap? = null
private var offscreenCanvas: Canvas? = null
/** 渲染线程 */
private var renderThread: RenderThread? = null
/** Surface是否可用 */
private var surfaceReady = false
/** 画布宽高 */
private var canvasWidth = 0
private var canvasHeight = 0
/** 缩放和平移参数(遥控器控制) */
private var scaleX = 1.0f
private var scaleY = 1.0f
private var translateX = 0.0f
private var translateY = 0.0f
/** 绘制用Paint对象(复用避免GC) */
private val strokePaint = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private val backgroundPaint = Paint().apply {
color = Color.WHITE
style = Paint.Style.FILL
}
/** 复用Path对象 */
private val reusablePath = Path()
/** 是否需要刷新离屏缓冲 */
private var needsRefreshOffscreen = false
init {
holder.addCallback(this)
// 设置透明背景(支持叠加在课件内容上方)
setZOrderOnTop(false)
}
/* ========== SurfaceHolder.Callback ========== */
override fun surfaceCreated(holder: SurfaceHolder) {
surfaceReady = true
canvasWidth = holder.surfaceFrame.width()
canvasHeight = holder.surfaceFrame.height()
// 创建离屏缓冲(与Surface同尺寸)
offscreenBitmap = android.graphics.Bitmap.createBitmap(
canvasWidth, canvasHeight, android.graphics.Bitmap.Config.ARGB_8888
)
offscreenCanvas = Canvas(offscreenBitmap!!)
offscreenCanvas?.drawRect(0f, 0f, canvasWidth.toFloat(), canvasHeight.toFloat(), backgroundPaint)
// 启动渲染线程
renderThread = RenderThread()
renderThread?.start()
// 如果已有历史笔迹数据,先渲染到离屏缓冲
if (completedStrokes.isNotEmpty()) {
rebuildOffscreenCache()
}
Log.i(TAG, "Surface创建完成: ${canvasWidth}x${canvasHeight}")
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
canvasWidth = width
canvasHeight = height
// 重建离屏缓冲以匹配新尺寸
offscreenBitmap?.recycle()
offscreenBitmap = android.graphics.Bitmap.createBitmap(
width, height, android.graphics.Bitmap.Config.ARGB_8888
)
offscreenCanvas = Canvas(offscreenBitmap!!)
rebuildOffscreenCache()
Log.i(TAG, "Surface尺寸变化: ${width}x${height}")
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
surfaceReady = false
renderThread?.stopRendering()
renderThread = null
offscreenBitmap?.recycle()
offscreenBitmap = null
Log.i(TAG, "Surface已销毁")
}
/* ========== 公开API ========== */
/**
* 添加笔迹点(由WebSocket接收器调用)
* @param studentId 学生标识
* @param point 坐标点
* @param isPenDown true=落笔(笔画开始),false=行笔中
*/
fun addStrokePoint(studentId: String, point: StrokePoint, isPenDown: Boolean) {
if (isPenDown) {
// 新建笔画
val color = StudentColorPalette.getColorForStudent(studentId)
val stroke = Stroke(studentId = studentId, color = color)
stroke.points.add(point)
activeStrokes[studentId] = stroke
} else {
// 添加到当前活跃笔画
activeStrokes[studentId]?.points?.add(point)
}
}
/**
* 完成一个笔画(抬笔事件)
* 将活跃笔画移入已完成列表,并渲染到离屏缓冲
*/
fun finishStroke(studentId: String) {
val stroke = activeStrokes.remove(studentId) ?: return
if (stroke.points.size >= 2) {
completedStrokes.add(stroke)
// 将新完成的笔画绘制到离屏缓冲
offscreenCanvas?.let { canvas ->
drawSingleStroke(canvas, stroke)
}
}
}
/** 清除所有笔迹 */
fun clearAll() {
completedStrokes.clear()
activeStrokes.clear()
offscreenCanvas?.drawRect(0f, 0f, canvasWidth.toFloat(), canvasHeight.toFloat(), backgroundPaint)
Log.i(TAG, "所有笔迹已清除")
}
/** 清除指定学生的笔迹 */
fun clearStudentStrokes(studentId: String) {
activeStrokes.remove(studentId)
completedStrokes.removeAll { it.studentId == studentId }
rebuildOffscreenCache()
}
/** 设置显示缩放(遥控器方向键操作) */
fun setZoom(scale: Float) {
scaleX = scale.coerceIn(0.5f, 5.0f)
scaleY = scaleX
}
/** 设置显示平移 */
fun setPan(dx: Float, dy: Float) {
translateX += dx
translateY += dy
}
/* ========== 渲染逻辑 ========== */
/** 重建离屏缓冲(将所有已完成笔画重新绘制) */
private fun rebuildOffscreenCache() {
val canvas = offscreenCanvas ?: return
canvas.drawRect(0f, 0f, canvasWidth.toFloat(), canvasHeight.toFloat(), backgroundPaint)
for (stroke in completedStrokes) {
drawSingleStroke(canvas, stroke)
}
Log.d(TAG, "离屏缓冲重建完成,笔画数: ${completedStrokes.size}")
}
/**
* 绘制单个笔画(贝塞尔平滑 + 压力笔锋)
* 采用分段绘制策略:每两个相邻点之间用三次贝塞尔曲线连接
*/
private fun drawSingleStroke(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 width = calculateStrokeWidth(
stroke.baseWidth, prev.pressure, curr.pressure,
i, points.size
)
strokePaint.strokeWidth = width * MM_TO_PX
if (i >= 2 && i < points.size) {
// 三次贝塞尔曲线平滑
val pp = points[i - 2]
val cp1x = prev.x * MM_TO_PX + (curr.x - pp.x) * MM_TO_PX * BEZIER_TENSION
val cp1y = prev.y * MM_TO_PX + (curr.y - pp.y) * MM_TO_PX * BEZIER_TENSION
val cp2x = curr.x * MM_TO_PX - (curr.x - prev.x) * MM_TO_PX * BEZIER_TENSION
val cp2y = curr.y * MM_TO_PX - (curr.y - prev.y) * MM_TO_PX * BEZIER_TENSION
reusablePath.reset()
reusablePath.moveTo(prev.x * MM_TO_PX, prev.y * MM_TO_PX)
reusablePath.cubicTo(cp1x, cp1y, cp2x, cp2y, curr.x * MM_TO_PX, curr.y * MM_TO_PX)
canvas.drawPath(reusablePath, strokePaint)
} else {
// 前两个点直接连线
canvas.drawLine(
prev.x * MM_TO_PX, prev.y * MM_TO_PX,
curr.x * MM_TO_PX, curr.y * MM_TO_PX,
strokePaint
)
}
}
}
/**
* 计算压力感应笔画宽度
* 模拟真实书写笔锋:落笔由细变粗,行笔随压力变化,抬笔由粗变细
*/
private fun calculateStrokeWidth(
baseWidth: Float,
prevPressure: Float,
currPressure: Float,
index: Int,
totalPoints: Int
): Float {
val avgPressure = (prevPressure + currPressure) / 2.0f
// 基础宽度根据压力缩放(0.3x - 2.0x)
var width = baseWidth * (0.3f + avgPressure * 1.7f)
// 落笔过渡效果(前N个点逐渐增加宽度)
if (index < PEN_DOWN_TRANSITION) {
width *= (index.toFloat() / PEN_DOWN_TRANSITION)
}
// 抬笔过渡效果(最后N个点逐渐减小宽度)
val remaining = totalPoints - index
if (remaining < PEN_UP_TRANSITION) {
width *= (remaining.toFloat() / PEN_UP_TRANSITION)
}
return max(width, 0.5f)
}
/* ========== 渲染线程 ========== */
/**
* 渲染线程 - 以60fps目标帧率循环渲染
* 每帧将离屏缓冲绘制到Surface,然后叠加活跃笔画
*/
inner class RenderThread : Thread("StrokeRenderThread") {
@Volatile
private var running = true
fun stopRendering() {
running = false
}
override fun run() {
Log.i(TAG, "渲染线程启动")
while (running && surfaceReady) {
val frameStart = System.currentTimeMillis()
try {
val canvas = holder.lockCanvas() ?: continue
try {
// 步骤1:绘制离屏缓冲(历史笔迹)
offscreenBitmap?.let { bitmap ->
canvas.save()
canvas.translate(translateX, translateY)
canvas.scale(scaleX, scaleY)
canvas.drawBitmap(bitmap, 0f, 0f, null)
canvas.restore()
}
// 步骤2:绘制当前活跃笔画(正在书写的)
canvas.save()
canvas.translate(translateX, translateY)
canvas.scale(scaleX, scaleY)
for (stroke in activeStrokes.values) {
if (stroke.points.size >= 2) {
drawSingleStroke(canvas, stroke)
}
}
canvas.restore()
} finally {
holder.unlockCanvasAndPost(canvas)
}
} catch (e: Exception) {
Log.e(TAG, "渲染帧异常: ${e.message}")
}
// 帧率控制:等待到下一帧时间
val elapsed = System.currentTimeMillis() - frameStart
val sleepTime = FRAME_INTERVAL_MS - elapsed
if (sleepTime > 0) {
try {
sleep(sleepTime)
} catch (_: InterruptedException) {
break
}
}
}
Log.i(TAG, "渲染线程已停止")
}
}
/** 释放资源 */
fun release() {
renderThread?.stopRendering()
renderThread = null
offscreenBitmap?.recycle()
offscreenBitmap = null
completedStrokes.clear()
activeStrokes.clear()
Log.i(TAG, "渲染器资源已释放")
}
}