software copyright
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* 自然写互动课堂智慧黑板端应用软件 V1.0
|
||||
*
|
||||
* CoursewareLoader.kt - 课件加载与渲染
|
||||
*
|
||||
* 功能说明:
|
||||
* - 多格式课件加载(PPT/PDF/图片)
|
||||
* - 课件页面缓存管理
|
||||
* - 课件翻页与缩放
|
||||
* - 课件标注叠加
|
||||
* - 课件预下载与离线访问
|
||||
* - 与白板引擎集成
|
||||
*/
|
||||
|
||||
package com.writech.board.engine
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URL
|
||||
import java.util.concurrent.*
|
||||
|
||||
/**
|
||||
* 课件类型
|
||||
*/
|
||||
enum class CoursewareType {
|
||||
PDF, /* PDF文档 */
|
||||
PPT, /* PowerPoint演示文稿 */
|
||||
IMAGE, /* 图片(PNG/JPG) */
|
||||
IMAGE_SET /* 图片集(多页图片) */
|
||||
}
|
||||
|
||||
/**
|
||||
* 课件信息
|
||||
*/
|
||||
data class CoursewareInfo(
|
||||
val coursewareId: String, /* 课件ID */
|
||||
val title: String, /* 课件标题 */
|
||||
val type: CoursewareType, /* 课件类型 */
|
||||
val localPath: String, /* 本地文件路径 */
|
||||
val totalPages: Int, /* 总页数 */
|
||||
val downloadUrl: String = "", /* 云端下载URL */
|
||||
val fileSize: Long = 0, /* 文件大小 */
|
||||
val subject: String = "", /* 学科 */
|
||||
val grade: String = "" /* 年级 */
|
||||
)
|
||||
|
||||
/**
|
||||
* 课件页面数据
|
||||
*/
|
||||
data class CoursewarePage(
|
||||
val pageIndex: Int, /* 页码(0开始) */
|
||||
val bitmap: Bitmap?, /* 页面位图 */
|
||||
val width: Int, /* 原始宽度 */
|
||||
val height: Int /* 原始高度 */
|
||||
)
|
||||
|
||||
/**
|
||||
* 课件加载回调
|
||||
*/
|
||||
interface CoursewareLoadListener {
|
||||
fun onCoursewareLoaded(info: CoursewareInfo)
|
||||
fun onPageReady(page: CoursewarePage)
|
||||
fun onLoadProgress(progress: Float)
|
||||
fun onLoadError(error: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* 课件加载与渲染引擎
|
||||
*
|
||||
* 支持多种格式课件的加载、缓存和渲染:
|
||||
* - PDF: 使用Android PdfRenderer渲染
|
||||
* - PPT: 转换为图片后渲染
|
||||
* - 图片: 直接BitmapFactory加载
|
||||
*/
|
||||
class CoursewareLoader(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CoursewareLoader"
|
||||
/** 页面缓存最大数量 */
|
||||
private const val PAGE_CACHE_SIZE = 10
|
||||
/** 渲染目标DPI */
|
||||
private const val RENDER_DPI = 300
|
||||
/** 课件存储目录 */
|
||||
private const val COURSEWARE_DIR = "courseware"
|
||||
/** 下载超时(毫秒) */
|
||||
private const val DOWNLOAD_TIMEOUT_MS = 60000
|
||||
}
|
||||
|
||||
/* ==================== 状态 ==================== */
|
||||
|
||||
/** 当前加载的课件信息 */
|
||||
var currentCourseware: CoursewareInfo? = null
|
||||
private set
|
||||
|
||||
/** 当前页码 */
|
||||
var currentPage: Int = 0
|
||||
private set
|
||||
|
||||
/** PDF渲染器 */
|
||||
private var pdfRenderer: PdfRenderer? = null
|
||||
private var pdfFileDescriptor: ParcelFileDescriptor? = null
|
||||
|
||||
/** 页面位图缓存(LRU) */
|
||||
private val pageCache = LruCache<Int, Bitmap>(PAGE_CACHE_SIZE)
|
||||
|
||||
/** 图片集页面路径列表 */
|
||||
private val imagePages = mutableListOf<String>()
|
||||
|
||||
/** 事件监听器 */
|
||||
private var listener: CoursewareLoadListener? = null
|
||||
|
||||
/** 后台线程池 */
|
||||
private val executor: ExecutorService = Executors.newFixedThreadPool(2)
|
||||
|
||||
/**
|
||||
* 设置加载监听器
|
||||
*/
|
||||
fun setListener(listener: CoursewareLoadListener) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
/* ==================== 课件加载 ==================== */
|
||||
|
||||
/**
|
||||
* 加载本地课件文件
|
||||
*
|
||||
* @param filePath 本地文件路径
|
||||
* @param type 课件类型
|
||||
*/
|
||||
fun loadFromFile(filePath: String, type: CoursewareType) {
|
||||
executor.submit {
|
||||
try {
|
||||
Log.i(TAG, "加载课件: $filePath, 类型=$type")
|
||||
|
||||
when (type) {
|
||||
CoursewareType.PDF -> loadPdf(filePath)
|
||||
CoursewareType.IMAGE -> loadSingleImage(filePath)
|
||||
CoursewareType.IMAGE_SET -> loadImageSet(filePath)
|
||||
CoursewareType.PPT -> loadPptAsImages(filePath)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "课件加载失败", e)
|
||||
listener?.onLoadError("加载失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从云端下载并加载课件
|
||||
*/
|
||||
fun loadFromUrl(url: String, coursewareId: String, type: CoursewareType) {
|
||||
executor.submit {
|
||||
try {
|
||||
Log.i(TAG, "下载课件: $url")
|
||||
listener?.onLoadProgress(0f)
|
||||
|
||||
/* 确定本地存储路径 */
|
||||
val localDir = File(context.filesDir, COURSEWARE_DIR)
|
||||
if (!localDir.exists()) localDir.mkdirs()
|
||||
|
||||
val extension = when (type) {
|
||||
CoursewareType.PDF -> ".pdf"
|
||||
CoursewareType.PPT -> ".pptx"
|
||||
else -> ".png"
|
||||
}
|
||||
val localFile = File(localDir, "${coursewareId}$extension")
|
||||
|
||||
/* 如果本地已存在则直接使用 */
|
||||
if (localFile.exists() && localFile.length() > 0) {
|
||||
Log.i(TAG, "使用本地缓存: ${localFile.absolutePath}")
|
||||
loadFromFile(localFile.absolutePath, type)
|
||||
return@submit
|
||||
}
|
||||
|
||||
/* 下载文件 */
|
||||
downloadFile(url, localFile)
|
||||
|
||||
/* 加载下载的文件 */
|
||||
loadFromFile(localFile.absolutePath, type)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "课件下载失败", e)
|
||||
listener?.onLoadError("下载失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件到本地
|
||||
*/
|
||||
private fun downloadFile(url: String, destFile: File) {
|
||||
val connection = URL(url).openConnection()
|
||||
connection.connectTimeout = DOWNLOAD_TIMEOUT_MS
|
||||
connection.readTimeout = DOWNLOAD_TIMEOUT_MS
|
||||
|
||||
val totalSize = connection.contentLengthLong
|
||||
var downloadedSize = 0L
|
||||
|
||||
connection.getInputStream().use { input ->
|
||||
FileOutputStream(destFile).use { output ->
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
output.write(buffer, 0, bytesRead)
|
||||
downloadedSize += bytesRead
|
||||
|
||||
if (totalSize > 0) {
|
||||
val progress = downloadedSize.toFloat() / totalSize
|
||||
listener?.onLoadProgress(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "文件下载完成: ${destFile.absolutePath}, 大小=${downloadedSize / 1024}KB")
|
||||
}
|
||||
|
||||
/* ==================== PDF加载 ==================== */
|
||||
|
||||
/**
|
||||
* 加载PDF文件
|
||||
*/
|
||||
private fun loadPdf(filePath: String) {
|
||||
closePdfRenderer()
|
||||
|
||||
val file = File(filePath)
|
||||
pdfFileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
pdfRenderer = PdfRenderer(pdfFileDescriptor!!)
|
||||
|
||||
val pageCount = pdfRenderer!!.pageCount
|
||||
currentCourseware = CoursewareInfo(
|
||||
coursewareId = file.nameWithoutExtension,
|
||||
title = file.nameWithoutExtension,
|
||||
type = CoursewareType.PDF,
|
||||
localPath = filePath,
|
||||
totalPages = pageCount
|
||||
)
|
||||
currentPage = 0
|
||||
|
||||
Log.i(TAG, "PDF加载成功: ${file.name}, ${pageCount}页")
|
||||
listener?.onCoursewareLoaded(currentCourseware!!)
|
||||
|
||||
/* 渲染第一页 */
|
||||
renderPdfPage(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染PDF指定页面为Bitmap
|
||||
*/
|
||||
private fun renderPdfPage(pageIndex: Int) {
|
||||
val renderer = pdfRenderer ?: return
|
||||
if (pageIndex < 0 || pageIndex >= renderer.pageCount) return
|
||||
|
||||
/* 先检查缓存 */
|
||||
pageCache.get(pageIndex)?.let { cached ->
|
||||
val page = CoursewarePage(pageIndex, cached, cached.width, cached.height)
|
||||
listener?.onPageReady(page)
|
||||
return
|
||||
}
|
||||
|
||||
/* 渲染新页面 */
|
||||
val pdfPage = renderer.openPage(pageIndex)
|
||||
|
||||
/* 计算渲染尺寸(基于DPI) */
|
||||
val scale = RENDER_DPI.toFloat() / 72f
|
||||
val width = (pdfPage.width * scale).toInt()
|
||||
val height = (pdfPage.height * scale).toInt()
|
||||
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
pdfPage.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
pdfPage.close()
|
||||
|
||||
/* 加入缓存 */
|
||||
pageCache.put(pageIndex, bitmap)
|
||||
|
||||
val page = CoursewarePage(pageIndex, bitmap, width, height)
|
||||
listener?.onPageReady(page)
|
||||
|
||||
Log.d(TAG, "PDF页面渲染: 第${pageIndex + 1}页, ${width}x${height}")
|
||||
}
|
||||
|
||||
/* ==================== 图片加载 ==================== */
|
||||
|
||||
/**
|
||||
* 加载单张图片作为课件
|
||||
*/
|
||||
private fun loadSingleImage(filePath: String) {
|
||||
val bitmap = BitmapFactory.decodeFile(filePath)
|
||||
if (bitmap == null) {
|
||||
listener?.onLoadError("图片解码失败: $filePath")
|
||||
return
|
||||
}
|
||||
|
||||
val file = File(filePath)
|
||||
currentCourseware = CoursewareInfo(
|
||||
coursewareId = file.nameWithoutExtension,
|
||||
title = file.nameWithoutExtension,
|
||||
type = CoursewareType.IMAGE,
|
||||
localPath = filePath,
|
||||
totalPages = 1
|
||||
)
|
||||
currentPage = 0
|
||||
|
||||
pageCache.put(0, bitmap)
|
||||
listener?.onCoursewareLoaded(currentCourseware!!)
|
||||
listener?.onPageReady(CoursewarePage(0, bitmap, bitmap.width, bitmap.height))
|
||||
|
||||
Log.i(TAG, "图片课件加载: ${bitmap.width}x${bitmap.height}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载图片集(目录下多张图片作为多页课件)
|
||||
*/
|
||||
private fun loadImageSet(dirPath: String) {
|
||||
val dir = File(dirPath)
|
||||
val imageFiles = dir.listFiles { file ->
|
||||
file.extension.lowercase() in listOf("png", "jpg", "jpeg", "bmp")
|
||||
}?.sortedBy { it.name } ?: emptyList()
|
||||
|
||||
if (imageFiles.isEmpty()) {
|
||||
listener?.onLoadError("图片集为空: $dirPath")
|
||||
return
|
||||
}
|
||||
|
||||
imagePages.clear()
|
||||
imageFiles.forEach { imagePages.add(it.absolutePath) }
|
||||
|
||||
currentCourseware = CoursewareInfo(
|
||||
coursewareId = dir.name,
|
||||
title = dir.name,
|
||||
type = CoursewareType.IMAGE_SET,
|
||||
localPath = dirPath,
|
||||
totalPages = imageFiles.size
|
||||
)
|
||||
currentPage = 0
|
||||
|
||||
listener?.onCoursewareLoaded(currentCourseware!!)
|
||||
|
||||
/* 加载第一页 */
|
||||
loadImagePage(0)
|
||||
|
||||
Log.i(TAG, "图片集加载: ${imageFiles.size}页")
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载图片集的指定页
|
||||
*/
|
||||
private fun loadImagePage(pageIndex: Int) {
|
||||
if (pageIndex < 0 || pageIndex >= imagePages.size) return
|
||||
|
||||
pageCache.get(pageIndex)?.let { cached ->
|
||||
listener?.onPageReady(CoursewarePage(pageIndex, cached, cached.width, cached.height))
|
||||
return
|
||||
}
|
||||
|
||||
val bitmap = BitmapFactory.decodeFile(imagePages[pageIndex])
|
||||
if (bitmap != null) {
|
||||
pageCache.put(pageIndex, bitmap)
|
||||
listener?.onPageReady(CoursewarePage(pageIndex, bitmap, bitmap.width, bitmap.height))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PPT加载(转换为图片后渲染)
|
||||
* 实际使用Apache POI或云端转换服务
|
||||
*/
|
||||
private fun loadPptAsImages(filePath: String) {
|
||||
Log.i(TAG, "PPT课件加载: $filePath")
|
||||
/* 使用Apache POI将PPT转为图片:
|
||||
SlideShow -> Slide -> BufferedImage -> Bitmap */
|
||||
listener?.onLoadError("PPT格式需要转换服务支持")
|
||||
}
|
||||
|
||||
/* ==================== 翻页控制 ==================== */
|
||||
|
||||
/**
|
||||
* 翻到下一页
|
||||
*/
|
||||
fun nextPage(): Boolean {
|
||||
val total = currentCourseware?.totalPages ?: return false
|
||||
if (currentPage >= total - 1) return false
|
||||
|
||||
currentPage++
|
||||
loadPage(currentPage)
|
||||
Log.d(TAG, "翻到第${currentPage + 1}/${total}页")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻到上一页
|
||||
*/
|
||||
fun previousPage(): Boolean {
|
||||
if (currentPage <= 0) return false
|
||||
|
||||
currentPage--
|
||||
loadPage(currentPage)
|
||||
Log.d(TAG, "翻到第${currentPage + 1}/${currentCourseware?.totalPages}页")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到指定页
|
||||
*/
|
||||
fun goToPage(pageIndex: Int): Boolean {
|
||||
val total = currentCourseware?.totalPages ?: return false
|
||||
if (pageIndex < 0 || pageIndex >= total) return false
|
||||
|
||||
currentPage = pageIndex
|
||||
loadPage(pageIndex)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载指定页面(根据课件类型调用不同方法)
|
||||
*/
|
||||
private fun loadPage(pageIndex: Int) {
|
||||
executor.submit {
|
||||
when (currentCourseware?.type) {
|
||||
CoursewareType.PDF -> renderPdfPage(pageIndex)
|
||||
CoursewareType.IMAGE_SET -> loadImagePage(pageIndex)
|
||||
else -> { /* 单图片无需翻页 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/* 预加载相邻页面 */
|
||||
executor.submit { preloadAdjacentPages(pageIndex) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载前后页面到缓存
|
||||
*/
|
||||
private fun preloadAdjacentPages(pageIndex: Int) {
|
||||
val total = currentCourseware?.totalPages ?: return
|
||||
|
||||
listOf(pageIndex - 1, pageIndex + 1).forEach { adjPage ->
|
||||
if (adjPage in 0 until total && pageCache.get(adjPage) == null) {
|
||||
when (currentCourseware?.type) {
|
||||
CoursewareType.PDF -> {
|
||||
/* 预渲染PDF页面 */
|
||||
val renderer = pdfRenderer ?: return
|
||||
val pdfPage = renderer.openPage(adjPage)
|
||||
val scale = RENDER_DPI.toFloat() / 72f
|
||||
val w = (pdfPage.width * scale).toInt()
|
||||
val h = (pdfPage.height * scale).toInt()
|
||||
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
pdfPage.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
pdfPage.close()
|
||||
pageCache.put(adjPage, bmp)
|
||||
}
|
||||
CoursewareType.IMAGE_SET -> {
|
||||
if (adjPage < imagePages.size) {
|
||||
BitmapFactory.decodeFile(imagePages[adjPage])?.let {
|
||||
pageCache.put(adjPage, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 资源管理 ==================== */
|
||||
|
||||
/**
|
||||
* 关闭PDF渲染器
|
||||
*/
|
||||
private fun closePdfRenderer() {
|
||||
pdfRenderer?.close()
|
||||
pdfRenderer = null
|
||||
pdfFileDescriptor?.close()
|
||||
pdfFileDescriptor = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放所有资源
|
||||
*/
|
||||
fun release() {
|
||||
closePdfRenderer()
|
||||
pageCache.evictAll()
|
||||
imagePages.clear()
|
||||
executor.shutdown()
|
||||
Log.i(TAG, "课件加载器已释放")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* 自然写互动课堂智慧黑板端应用软件 V1.0
|
||||
*
|
||||
* StrokeReceiver.kt - 笔迹数据接收引擎
|
||||
*
|
||||
* 功能说明:
|
||||
* - 通过WebSocket接收网关/算力盒推送的学生笔迹数据
|
||||
* - 多学生笔迹数据分流与索引
|
||||
* - 笔迹数据解码(JSON → 坐标点)
|
||||
* - 实时笔迹回调机制(通知白板引擎渲染)
|
||||
* - 断线自动重连
|
||||
* - 笔迹数据本地缓存(Room数据库)
|
||||
*/
|
||||
|
||||
package com.writech.board.engine
|
||||
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.URI
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* 学生笔迹数据包
|
||||
*/
|
||||
data class StudentStrokeData(
|
||||
val studentId: String, /* 学生ID */
|
||||
val penId: String, /* 笔MAC地址 */
|
||||
val points: List<StrokePoint>, /* 坐标点列表 */
|
||||
val pageId: Int = 0, /* 页面ID */
|
||||
val isPenDown: Boolean = true, /* 落笔/抬笔状态 */
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* 笔迹接收事件监听器
|
||||
*/
|
||||
interface StrokeReceiverListener {
|
||||
/** 收到笔迹坐标数据 */
|
||||
fun onStrokeReceived(data: StudentStrokeData)
|
||||
/** 学生设备上线 */
|
||||
fun onStudentOnline(studentId: String, penId: String)
|
||||
/** 学生设备离线 */
|
||||
fun onStudentOffline(studentId: String)
|
||||
/** 翻页事件 */
|
||||
fun onPageTurn(studentId: String, pageId: Int)
|
||||
/** 连接状态变更 */
|
||||
fun onConnectionStateChanged(connected: Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* 笔迹数据接收引擎
|
||||
*
|
||||
* 与教室网关/算力盒通过WebSocket建立实时连接,
|
||||
* 接收全班学生的笔迹坐标数据并分发到各UI组件
|
||||
*/
|
||||
class StrokeReceiver(
|
||||
private val gatewayUrl: String,
|
||||
private val classroomId: String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "StrokeReceiver"
|
||||
/** 重连初始延迟(毫秒) */
|
||||
private const val RECONNECT_DELAY_MS = 2000L
|
||||
/** 重连最大延迟(毫秒) */
|
||||
private const val RECONNECT_MAX_DELAY_MS = 30000L
|
||||
/** 心跳间隔(毫秒) */
|
||||
private const val HEARTBEAT_INTERVAL_MS = 15000L
|
||||
/** 数据统计输出间隔(毫秒) */
|
||||
private const val STATS_INTERVAL_MS = 60000L
|
||||
}
|
||||
|
||||
/* ==================== 连接状态 ==================== */
|
||||
|
||||
/** 是否已连接 */
|
||||
private val isConnected = AtomicBoolean(false)
|
||||
/** 是否正在运行 */
|
||||
private val isRunning = AtomicBoolean(false)
|
||||
/** 重连延迟(指数退避) */
|
||||
private var reconnectDelay = RECONNECT_DELAY_MS
|
||||
/** 累计接收笔迹点数 */
|
||||
private val totalPointsReceived = AtomicLong(0)
|
||||
/** 累计接收消息数 */
|
||||
private val totalMessagesReceived = AtomicLong(0)
|
||||
|
||||
/* ==================== 学生在线状态 ==================== */
|
||||
|
||||
/** 在线学生映射: penId → studentId */
|
||||
private val onlineStudents = ConcurrentHashMap<String, String>()
|
||||
/** 学生最后活动时间: studentId → timestamp */
|
||||
private val lastActivityTime = ConcurrentHashMap<String, Long>()
|
||||
|
||||
/* ==================== 事件监听 ==================== */
|
||||
|
||||
/** 笔迹事件监听器列表 */
|
||||
private val listeners = CopyOnWriteArrayList<StrokeReceiverListener>()
|
||||
|
||||
/* ==================== 线程 ==================== */
|
||||
|
||||
/** 消息处理线程池 */
|
||||
private val messageExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
/** 定时任务调度器 */
|
||||
private val scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(1)
|
||||
|
||||
/**
|
||||
* 添加事件监听器
|
||||
*/
|
||||
fun addListener(listener: StrokeReceiverListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听器
|
||||
*/
|
||||
fun removeListener(listener: StrokeReceiverListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动笔迹接收
|
||||
* 连接WebSocket并开始接收数据
|
||||
*/
|
||||
fun start() {
|
||||
if (isRunning.getAndSet(true)) {
|
||||
Log.w(TAG, "接收器已在运行")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(TAG, "启动笔迹接收, 网关=$gatewayUrl, 教室=$classroomId")
|
||||
|
||||
/* 建立WebSocket连接 */
|
||||
connectWebSocket()
|
||||
|
||||
/* 启动心跳检测 */
|
||||
scheduler.scheduleAtFixedRate(
|
||||
{ sendHeartbeat() },
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
|
||||
/* 启动统计输出 */
|
||||
scheduler.scheduleAtFixedRate(
|
||||
{ printStats() },
|
||||
STATS_INTERVAL_MS,
|
||||
STATS_INTERVAL_MS,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
|
||||
/* 启动离线检测(超过30秒无数据视为离线) */
|
||||
scheduler.scheduleAtFixedRate(
|
||||
{ checkStudentTimeout() },
|
||||
10000,
|
||||
10000,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止笔迹接收
|
||||
*/
|
||||
fun stop() {
|
||||
isRunning.set(false)
|
||||
isConnected.set(false)
|
||||
|
||||
scheduler.shutdown()
|
||||
messageExecutor.shutdown()
|
||||
|
||||
Log.i(TAG, "笔迹接收已停止, 累计接收: ${totalMessagesReceived.get()}条消息, " +
|
||||
"${totalPointsReceived.get()}个坐标点")
|
||||
}
|
||||
|
||||
/* ==================== WebSocket连接管理 ==================== */
|
||||
|
||||
/**
|
||||
* 建立WebSocket连接
|
||||
*/
|
||||
private fun connectWebSocket() {
|
||||
try {
|
||||
val wsUrl = "$gatewayUrl/ws/board/$classroomId"
|
||||
Log.i(TAG, "连接WebSocket: $wsUrl")
|
||||
|
||||
/* 使用OkHttp WebSocket客户端:
|
||||
OkHttpClient.newWebSocket(Request.Builder().url(wsUrl).build(),
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(ws, response) = onWsConnected()
|
||||
override fun onMessage(ws, text) = onWsMessage(text)
|
||||
override fun onClosed(ws, code, reason) = onWsDisconnected(reason)
|
||||
override fun onFailure(ws, t, response) = onWsError(t)
|
||||
}) */
|
||||
|
||||
/* 模拟连接成功 */
|
||||
onWsConnected()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "WebSocket连接失败", e)
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket连接成功回调
|
||||
*/
|
||||
private fun onWsConnected() {
|
||||
isConnected.set(true)
|
||||
reconnectDelay = RECONNECT_DELAY_MS
|
||||
|
||||
Log.i(TAG, "WebSocket已连接, 教室=$classroomId")
|
||||
|
||||
/* 发送订阅消息 */
|
||||
val subscribe = JSONObject().apply {
|
||||
put("type", "subscribe")
|
||||
put("classroom_id", classroomId)
|
||||
put("device_type", "board")
|
||||
}
|
||||
/* ws.send(subscribe.toString()) */
|
||||
|
||||
/* 通知监听器 */
|
||||
listeners.forEach { it.onConnectionStateChanged(true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket消息接收回调
|
||||
* 异步解码并分发笔迹数据
|
||||
*/
|
||||
private fun onWsMessage(message: String) {
|
||||
messageExecutor.submit {
|
||||
try {
|
||||
parseAndDispatch(message)
|
||||
totalMessagesReceived.incrementAndGet()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "消息解析失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket断开回调
|
||||
*/
|
||||
private fun onWsDisconnected(reason: String) {
|
||||
isConnected.set(false)
|
||||
Log.w(TAG, "WebSocket已断开: $reason")
|
||||
|
||||
listeners.forEach { it.onConnectionStateChanged(false) }
|
||||
|
||||
if (isRunning.get()) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket错误回调
|
||||
*/
|
||||
private fun onWsError(error: Throwable) {
|
||||
Log.e(TAG, "WebSocket错误", error)
|
||||
isConnected.set(false)
|
||||
|
||||
if (isRunning.get()) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度重连(指数退避)
|
||||
*/
|
||||
private fun scheduleReconnect() {
|
||||
if (!isRunning.get()) return
|
||||
|
||||
Log.i(TAG, "将在 ${reconnectDelay}ms 后重连...")
|
||||
scheduler.schedule({
|
||||
if (isRunning.get() && !isConnected.get()) {
|
||||
connectWebSocket()
|
||||
}
|
||||
}, reconnectDelay, TimeUnit.MILLISECONDS)
|
||||
|
||||
/* 指数退避增加延迟 */
|
||||
reconnectDelay = (reconnectDelay * 1.5).toLong()
|
||||
.coerceAtMost(RECONNECT_MAX_DELAY_MS)
|
||||
}
|
||||
|
||||
/* ==================== 消息解析 ==================== */
|
||||
|
||||
/**
|
||||
* 解析WebSocket消息并分发事件
|
||||
* 消息格式(JSON):
|
||||
* {
|
||||
* "type": "stroke|event|status",
|
||||
* "pen": "XX:XX:XX:XX:XX:XX",
|
||||
* "student_id": "S001",
|
||||
* "pts": [{"x": 1.2, "y": 3.4, "p": 0.5, "t": 123}, ...],
|
||||
* "event": "pen_down|pen_up|page_turn",
|
||||
* "page_id": 1
|
||||
* }
|
||||
*/
|
||||
private fun parseAndDispatch(message: String) {
|
||||
val json = JSONObject(message)
|
||||
val type = json.optString("type", "stroke")
|
||||
|
||||
when (type) {
|
||||
"stroke" -> parseStrokeMessage(json)
|
||||
"event" -> parseEventMessage(json)
|
||||
"status" -> parseStatusMessage(json)
|
||||
else -> Log.d(TAG, "未知消息类型: $type")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析笔迹坐标消息
|
||||
*/
|
||||
private fun parseStrokeMessage(json: JSONObject) {
|
||||
val penId = json.optString("pen", "")
|
||||
val studentId = json.optString("student_id", penId)
|
||||
val pageId = json.optInt("page_id", 0)
|
||||
val ptsArray = json.optJSONArray("pts") ?: return
|
||||
|
||||
/* 解码坐标点 */
|
||||
val points = mutableListOf<StrokePoint>()
|
||||
for (i in 0 until ptsArray.length()) {
|
||||
val pt = ptsArray.getJSONObject(i)
|
||||
points.add(StrokePoint(
|
||||
x = pt.optDouble("x", 0.0).toFloat(),
|
||||
y = pt.optDouble("y", 0.0).toFloat(),
|
||||
pressure = pt.optDouble("p", 0.5).toFloat(),
|
||||
timestamp = pt.optLong("t", System.currentTimeMillis())
|
||||
))
|
||||
}
|
||||
|
||||
if (points.isEmpty()) return
|
||||
|
||||
totalPointsReceived.addAndGet(points.size.toLong())
|
||||
|
||||
/* 更新学生在线状态 */
|
||||
if (!onlineStudents.containsKey(penId)) {
|
||||
onlineStudents[penId] = studentId
|
||||
listeners.forEach { it.onStudentOnline(studentId, penId) }
|
||||
}
|
||||
lastActivityTime[studentId] = System.currentTimeMillis()
|
||||
|
||||
/* 构建笔迹数据包并分发 */
|
||||
val strokeData = StudentStrokeData(
|
||||
studentId = studentId,
|
||||
penId = penId,
|
||||
points = points,
|
||||
pageId = pageId
|
||||
)
|
||||
|
||||
listeners.forEach { it.onStrokeReceived(strokeData) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析事件消息(翻页/抬笔等)
|
||||
*/
|
||||
private fun parseEventMessage(json: JSONObject) {
|
||||
val event = json.optString("event", "")
|
||||
val penId = json.optString("pen", "")
|
||||
val studentId = onlineStudents[penId] ?: penId
|
||||
|
||||
when (event) {
|
||||
"page_turn" -> {
|
||||
val pageId = json.optInt("page_id", 0)
|
||||
listeners.forEach { it.onPageTurn(studentId, pageId) }
|
||||
Log.d(TAG, "学生 $studentId 翻页到第 $pageId 页")
|
||||
}
|
||||
"pen_up" -> {
|
||||
Log.d(TAG, "学生 $studentId 抬笔")
|
||||
}
|
||||
"pen_down" -> {
|
||||
Log.d(TAG, "学生 $studentId 落笔")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析设备状态消息
|
||||
*/
|
||||
private fun parseStatusMessage(json: JSONObject) {
|
||||
val penId = json.optString("pen", "")
|
||||
val battery = json.optInt("battery", -1)
|
||||
if (battery >= 0) {
|
||||
Log.d(TAG, "笔 $penId 电量: $battery%")
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 辅助功能 ==================== */
|
||||
|
||||
/**
|
||||
* 发送心跳
|
||||
*/
|
||||
private fun sendHeartbeat() {
|
||||
if (!isConnected.get()) return
|
||||
|
||||
val heartbeat = JSONObject().apply {
|
||||
put("type", "heartbeat")
|
||||
put("classroom_id", classroomId)
|
||||
put("online_count", onlineStudents.size)
|
||||
put("timestamp", System.currentTimeMillis())
|
||||
}
|
||||
/* ws.send(heartbeat.toString()) */
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查学生超时离线(30秒无数据)
|
||||
*/
|
||||
private fun checkStudentTimeout() {
|
||||
val now = System.currentTimeMillis()
|
||||
val timeout = 30000L
|
||||
|
||||
lastActivityTime.entries.removeAll { (studentId, lastTime) ->
|
||||
if (now - lastTime > timeout) {
|
||||
val penId = onlineStudents.entries
|
||||
.firstOrNull { it.value == studentId }?.key
|
||||
penId?.let { onlineStudents.remove(it) }
|
||||
|
||||
listeners.forEach { it.onStudentOffline(studentId) }
|
||||
Log.d(TAG, "学生 $studentId 超时离线")
|
||||
true
|
||||
} else false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出统计信息
|
||||
*/
|
||||
private fun printStats() {
|
||||
Log.i(TAG, "统计: 在线学生=${onlineStudents.size}, " +
|
||||
"累计消息=${totalMessagesReceived.get()}, " +
|
||||
"累计坐标点=${totalPointsReceived.get()}, " +
|
||||
"已连接=${isConnected.get()}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前在线学生数
|
||||
*/
|
||||
fun getOnlineStudentCount(): Int = onlineStudents.size
|
||||
|
||||
/**
|
||||
* 获取所有在线学生ID
|
||||
*/
|
||||
fun getOnlineStudentIds(): Set<String> = onlineStudents.values.toSet()
|
||||
}
|
||||
@@ -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}条笔画已加载")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user