Files
system-design/software-copyright/07-writech-app-tv/自然写互动课堂电视端应用软件-鉴别材料.md
2026-03-22 15:24:40 +08:00

2530 lines
111 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 自然写互动课堂电视端应用软件 V1.0
## 软件鉴别材料 — 用户操作手册与设计说明书
---
**软件全称**:自然写互动课堂电视端应用软件
**软件版本**V1.0
**权利人**:深圳自然写科技有限公司
**文档类型**:智能电视应用用户操作手册 + 设计说明书
**文档编号**WRITECH-APP-TV-DS-001
**编制日期**2026年2月
**适用平台**Android TVAndroid 9.0+/ 主流智能电视操作系统
---
## 目录
- 第一章 软件整体概述
- 第二章 系统架构与设计思路
- 第三章 核心模块功能详细说明
- 第四章 操作流程与使用步骤
- 第五章 与源代码的对应关系
- 附录
---
## 第一章 软件整体概述
### 1.1 软件简介与功能综述
自然写互动课堂电视端应用软件(以下简称"TV APP")运行于家庭电视或教室电视大屏,是互动课堂系统中面向大屏展示场景的专属客户端。TV APP基于Android TV Leanback框架开发,适配遥控器D-Pad焦点导航交互方式,提供学生书写大屏投射、课堂互动展示、字帖临摹辅助、学情报告浏览等功能。
TV APP的核心使用场景分为两类:
1. **教室电视**:配合教师智慧黑板,作为辅助大屏在教室后排展示全班书写状态,让每个角落的学生都能看到作品展示
2. **家庭电视**:家长/学生在家中用电视大屏回放书写过程、查看学情报告、进行字帖临摹练习
**主要功能模块:**
| 功能模块 | 说明 |
|---------|------|
| 笔迹实时大屏投射 | 接收网关/算力盒推送的实时笔迹坐标,大屏渲染展示 |
| 多学生书写同屏对比 | 最多9宫格展示多名学生的实时书写内容 |
| 课堂互动答题展示 | 大屏展示互动题目,收卷后显示全班答题统计和典型答案 |
| 字帖临摹大屏辅助 | 电视大屏展示放大范字,学生对照练习(课堂或家庭场景) |
| 学情报告大屏浏览 | 用遥控器浏览孩子学情报告、成绩趋势、薄弱知识点 |
| 设备自动发现与连接 | 通过mDNS自动发现同一局域网内的教室网关,无需手动配置 |
| 遥控器/语音操控 | 适配遥控器D-Pad全程无触屏操作,支持语音搜索 |
### 1.2 软件用途与适用场景
**场景一:教室辅助大屏(课堂使用)**
教室后方电视作为辅助显示屏,实时展示:
- 全班书写进度(哪些学生已完成/书写中/未开始)
- 教师选取的优秀学生作品放大展示
- 互动答题结果统计饼图和柱状图
**场景二:家庭学习辅助(课后使用)**
学生用遥控器操作家庭电视:
- 打开字帖临摹练习,电视大屏展示标准范字(比手机大10倍以上)
- 对照大屏书写,前置摄像头(如有)拍摄书写过程进行实时对比
- 家长陪同查看本周学情报告
**场景三:书写成果展示**
家庭聚会或亲子活动中,家长投屏孩子的优秀作品,通过回放功能展示孩子的书写成长历程。
### 1.3 运行环境与系统要求
| 配置项 | 最低要求 | 推荐配置 |
|--------|---------|---------|
| 操作系统 | Android TV 9.0API 28 | Android TV 11.0+ |
| 内存 | 2GB RAM | 4GB RAM |
| 存储 | 500MB可用空间 | 2GB可用空间 |
| 网络 | WiFi 802.11n2.4GHz | WiFi 6802.11ax |
| 分辨率 | 1920×1080(全高清) | 3840×21604K |
| 处理器 | ARM Cortex-A53 四核 | ARM Cortex-A73 八核 |
| GPU | 支持OpenGL ES 3.0 | 支持Vulkan 1.1 |
**支持的电视品牌/平台:**
- Android TV(索尼BRAVIA TV、飞利浦Android TV等)
- Google TVChromecast with Google TV
- 小米/TCL/海信/创维等搭载Android TV的智能电视
### 1.4 开发语言与技术规范
| 技术 | 版本 | 用途 |
|------|------|------|
| Kotlin | 1.9.0 | 主要开发语言 |
| Android TV Leanback | 1.2.0 | TV专用UI框架(焦点导航) |
| ViewModel + LiveData | Lifecycle 2.7 | MVVM架构 |
| OkHttp | 4.12.0 | HTTP网络请求 |
| WebSocketOkHttp ws | 4.12.0 | 实时笔迹数据接收 |
| Room | 2.6.1 | 本地SQLite数据库 |
| Glide | 4.16.0 | 图片加载与缓存 |
| ExoPlayer | 2.19.1 | 视频/动画播放(书写回放) |
| mDNSNSD API | Android NSD | 教室网关自动发现 |
| Gson | 2.10.1 | JSON序列化/反序列化 |
### 1.5 版本说明
| 版本 | 日期 | 主要变更 |
|------|------|---------|
| V0.7 Beta | 2025年9月 | 基础展示功能,笔迹大屏渲染 |
| V0.9 RC | 2025年12月 | 字帖临摹、学情报告、mDNS设备发现 |
| V1.0 | 2026年2月 | 正式版:4K渲染优化、多学生同屏、语音搜索 |
---
## 第二章 系统架构与设计思路
### 2.1 总体架构设计
TV APP采用MVVM架构,针对大屏电视场景进行了专项优化。应用分为五层:
```
┌──────────────────────────────────────────────────────────────────┐
│ UI层(Leanback TV UI
│ BrowseFragment │ DetailsFragment │ PlaybackFragment │ SearchFrag│
│ TV焦点导航适配(D-Pad:上下左右确认返回) │
├──────────────────────────────────────────────────────────────────┤
│ 渲染层(Rendering Layer
│ 笔迹实时渲染引擎(Canvas2D / SurfaceView / OpenGL ES
│ 字帖范字渲染(矢量字体放大渲染) │ 书写回放动画引擎 │
├──────────────────────────────────────────────────────────────────┤
│ 业务逻辑层(ViewModel + LiveData
│ ClassroomViewModel │ InkViewModel │ ReportViewModel │ CalligVM │
├──────────────────────────────────────────────────────────────────┤
│ 数据层(Repository
│ CloudRepositoryAPI)│ LocalRepositoryRoom)│ InkStreamRepo │
├──────────────────────────────────────────────────────────────────┤
│ 基础服务层(Infrastructure
│ WebSocket(笔迹流) │ OkHttpAPI │ NSDmDNS发现) │ RoomDB)│
└──────────────────────────────────────────────────────────────────┘
```
### 2.2 Leanback TV UI架构说明
Android TV应用区别于手机APP的核心差异是:**焦点导航(Focus Navigation**。所有UI交互通过遥控器方向键(D-Pad)控制焦点移动,通过"确认键"触发操作,而非触摸屏点击。
TV APP采用Leanback Library提供的核心Fragment
| Fragment类型 | 功能 | 说明 |
|------------|------|------|
| `BrowseFragment` | 浏览主页 | 左侧导航栏 + 右侧内容区(横向滚动) |
| `DetailsFragment` | 学情报告详情 | 顶部主内容 + 下方相关内容 |
| `PlaybackFragment` | 笔迹回放 | 全屏播放控制(进度条、速度调节) |
| `SearchFragment` | 语音/文字搜索 | 字帖搜索、学情查找 |
| `ErrorFragment` | 错误提示 | 网络断开、设备未找到等错误 |
**焦点导航逻辑(TV D-Pad适配):**
```kotlin
// tv/ui/classroom/InkDisplayFragment.kt
class InkDisplayFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 配置D-Pad焦点导航
// 主要功能区域(笔迹展示)作为默认焦点
binding.inkDisplayArea.requestFocus()
// 底部控制栏:方向键上进入笔迹展示,方向键下进入控制栏
binding.controlPanel.setOnFocusChangeListener { _, hasFocus ->
binding.controlBar.visibility =
if (hasFocus) View.VISIBLE else View.GONE
}
// 遥控器按键处理
view.setOnKeyListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN) {
when (keyCode) {
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER -> {
// 确认键:选中当前学生作品进行放大展示
viewModel.selectCurrentFocusedStudent()
true
}
KeyEvent.KEYCODE_BACK -> {
// 返回键:退出全屏,返回多人视图
if (viewModel.isFullscreen.value == true) {
viewModel.exitFullscreen()
true
} else false
}
else -> false
}
} else false
}
}
}
```
### 2.3 笔迹渲染引擎设计
大屏笔迹渲染是TV APP的核心技术挑战:需要在4K电视屏幕上以60fps流畅渲染多个学生的实时笔迹,同时保持渲染质量(线条平滑、无锯齿)。
**渲染方案对比与选择:**
| 渲染方案 | 优点 | 缺点 | 使用场景 |
|---------|------|------|---------|
| Canvas 2D | 简单,适合少量线段 | 大量线段时性能差 | 单学生笔迹回放 |
| SurfaceView | 独立渲染线程,不卡UI | 与UI层混合复杂 | 实时笔迹渲染(多学生) |
| OpenGL ES | 最高性能,支持GPU加速 | 开发复杂 | 高密度多学生4K渲染 |
TV APP为多学生同屏场景采用SurfaceView + 独立渲染线程方案:
```kotlin
// tv/ui/rendering/InkSurfaceView.kt
class InkSurfaceView(context: Context) : SurfaceView(context),
SurfaceHolder.Callback {
private val renderThread = HandlerThread("InkRenderThread")
private lateinit var renderHandler: Handler
// 每个学生的笔迹缓存(学生ID → 笔画列表)
private val studentInkMap = ConcurrentHashMap<String, MutableList<StrokePath>>()
// 渲染帧率控制(目标60fps
private var lastRenderTime = 0L
private val targetFrameInterval = 1000L / 60L // 约16.7ms
override fun surfaceCreated(holder: SurfaceHolder) {
renderThread.start()
renderHandler = Handler(renderThread.looper)
scheduleNextFrame()
}
private fun scheduleNextFrame() {
val now = SystemClock.uptimeMillis()
val delay = maxOf(0, targetFrameInterval - (now - lastRenderTime))
renderHandler.postDelayed({ renderFrame() }, delay)
}
private fun renderFrame() {
val canvas = holder.lockCanvas() ?: return
try {
// 清除背景(白色)
canvas.drawColor(Color.WHITE)
// 渲染所有学生的笔迹
val paint = Paint().apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
isAntiAlias = true
}
studentInkMap.forEach { (studentId, strokes) ->
val color = getStudentColor(studentId)
paint.color = color
paint.strokeWidth = 6f // TV大屏适配:线宽增大
strokes.forEach { stroke ->
_drawStrokeWithBezier(canvas, stroke.points, paint,
canvas.width, canvas.height)
}
}
} finally {
holder.unlockCanvasAndPost(canvas)
lastRenderTime = SystemClock.uptimeMillis()
scheduleNextFrame()
}
}
fun addInkPoint(studentId: String, point: StrokePoint) {
renderHandler.post {
val strokes = studentInkMap.getOrPut(studentId) { mutableListOf() }
if (point.penUp && strokes.isNotEmpty()) {
strokes.last().isComplete = true
} else if (!point.penUp) {
if (strokes.isEmpty() || strokes.last().isComplete) {
strokes.add(StrokePath(mutableListOf(point)))
} else {
strokes.last().points.add(point)
}
}
}
}
}
```
### 2.4 数据设计
**Room数据库表(本地缓存):**
```kotlin
// tv/data/database/entities.kt
@Entity(tableName = "classroom_ink_cache")
data class InkCacheEntity(
@PrimaryKey val id: String,
val classroom_id: String,
val student_id: String,
val student_name: String,
val ink_json: String, // 笔迹坐标JSON序列化
val created_at: Long,
val is_realtime: Int // 1=实时缓存,0=历史数据
)
@Entity(tableName = "calligraphy_templates")
data class CalligraphyTemplate(
@PrimaryKey val id: String,
val title: String,
val subject: String,
val grade: String,
val characters: String, // 字帖中的汉字列表(逗号分隔)
val resource_url: String, // CDN资源URL
val local_path: String?, // 本地缓存路径(下载后)
val cached_at: Long?
)
@Entity(tableName = "bound_devices")
data class BoundDevice(
@PrimaryKey val device_id: String,
val device_type: String, // "gateway" / "edge_box"
val device_name: String,
val ip_address: String,
val port: Int,
val school_id: String,
val last_seen: Long
)
```
### 2.5 接口设计
**WebSocket实时数据接收:**
```kotlin
// tv/data/network/InkWebSocketClient.kt
class InkWebSocketClient(private val serverUrl: String) {
private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS) // 长连接无超时
.build()
private var webSocket: WebSocket? = null
private val _inkFlow = MutableSharedFlow<StudentInkData>()
val inkFlow: SharedFlow<StudentInkData> = _inkFlow
fun connect(classroomId: String, token: String) {
val request = Request.Builder()
.url("$serverUrl/ws/v1/ink?classroom_id=$classroomId")
.header("Authorization", "Bearer $token")
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
val data = parseInkMessage(text)
if (data != null) {
// 在协程作用域发射到Flow
scope.launch { _inkFlow.emit(data) }
}
}
override fun onFailure(webSocket: WebSocket,
t: Throwable, response: Response?) {
// 5秒后自动重连
scope.launch {
delay(5000)
connect(classroomId, token)
}
}
})
}
private fun parseInkMessage(text: String): StudentInkData? {
return try {
val json = JSONObject(text)
if (json.getString("type") != "stroke.realtime") return null
StudentInkData(
studentId = json.getString("student_id"),
studentName = json.getString("student_name"),
points = parsePoints(json.getJSONArray("points")),
timestamp = json.getLong("timestamp")
)
} catch (e: Exception) { null }
}
}
```
**mDNS设备自动发现:**
```kotlin
// tv/data/network/GatewayDiscovery.kt
class GatewayDiscovery(private val context: Context) {
private val nsdManager = context.getSystemService(Context.NSD_SERVICE)
as NsdManager
private val discoveredGateways = mutableListOf<GatewayInfo>()
fun startDiscovery(onFound: (GatewayInfo) -> Unit) {
val listener = object : NsdManager.DiscoveryListener {
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
// 发现 _writech-gateway._tcp 类型的服务
if (serviceInfo.serviceType == "_writech-gateway._tcp.") {
// 解析服务详细信息
nsdManager.resolveService(serviceInfo,
object : NsdManager.ResolveListener {
override fun onServiceResolved(service: NsdServiceInfo) {
val gateway = GatewayInfo(
name = service.serviceName,
host = service.host.hostAddress,
port = service.port,
schoolId = service.attributes["school_id"]
?.let { String(it) }
)
discoveredGateways.add(gateway)
onFound(gateway)
}
override fun onResolveFailed(si: NsdServiceInfo, ec: Int) {}
})
}
}
override fun onDiscoveryStarted(serviceType: String) {}
override fun onDiscoveryStopped(serviceType: String) {}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
discoveredGateways.removeAll {
it.name == serviceInfo.serviceName
}
}
override fun onStartDiscoveryFailed(st: String, ec: Int) {}
override fun onStopDiscoveryFailed(st: String, ec: Int) {}
}
nsdManager.discoverServices("_writech-gateway._tcp.",
NsdManager.PROTOCOL_DNS_SD,
listener)
}
}
```
### 2.6 安全设计
- **设备认证**TV APP通过API Token认证(设备首次绑定时在手机APP上扫码授权)
- **内容保护**:课堂展示内容显示时启用`FLAG_SECURE`(禁止系统截屏录屏)
- **Token存储**:存储于Android EncryptedSharedPreferencesKeyStore加密)
- **局域网隔离**:仅接受来自同一局域网段(同一WiFi)内的网关WebSocket连接
- **防截屏**:课堂内容展示期间设置`window.addFlags(FLAG_SECURE)`防止录屏
### 2.7 字帖渲染设计
字帖大屏临摹功能需要将汉字字形以高质量矢量方式放大展示(最大放满4K屏幕的1/4区域,约960×960像素):
```kotlin
// tv/ui/calligraphy/CalligraphyDisplayView.kt
class CalligraphyDisplayView(context: Context) : View(context) {
// 使用矢量字体(TrueType),避免位图放大失真
private var characterPath: Path? = null
private val strokePaint = Paint().apply {
style = Paint.Style.STROKE
strokeWidth = 8f
color = Color.RED // 描红帖:红色范字
isAntiAlias = true
}
// 加载字符的矢量轮廓路径(从字体文件解析)
fun setCharacter(char: Char) {
// 使用Android字体API获取字形路径
val paint = Paint().apply {
textSize = 200f
typeface = ResourcesCompat.getFont(context, R.font.kaishu)
}
characterPath = Path().also { paint.getTextPath(char.toString(),
0, 1, 0f, 200f, it) }
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制田字格背景辅助线
drawGridHelper(canvas)
// 绘制字形(缩放到适合大屏展示的大小)
characterPath?.let { path ->
val scaleMatrix = Matrix()
val scale = (width * 0.8f) / 200f // 缩放到屏幕宽度80%
scaleMatrix.setScale(scale, scale, width / 2f, height / 2f)
val scaledPath = Path(path).apply { transform(scaleMatrix) }
canvas.drawPath(scaledPath, strokePaint)
}
}
private fun drawGridHelper(canvas: Canvas) {
val gridPaint = Paint().apply {
color = Color.parseColor("#CCCCCC")
strokeWidth = 2f
}
// 绘制田字格
canvas.drawLine(width / 2f, 0f, width / 2f, height.toFloat(), gridPaint)
canvas.drawLine(0f, height / 2f, width.toFloat(), height / 2f, gridPaint)
// 绘制外框
canvas.drawRect(40f, 40f, width - 40f, height - 40f, gridPaint)
}
}
```
---
## 第三章 核心模块功能详细说明
### 3.1 主页浏览模块(BrowseFragment
TV APP主页采用Leanback BrowseFragment布局,左侧为功能导航菜单,右侧为内容卡片列表:
```
┌─────────────────────────────────────────────────────────────────┐
│ 自然写互动课堂 — 电视版 │
├───────────────┬─────────────────────────────────────────────────┤
│ 导航菜单 │ 内容区域 │
│ │ │
│ > 课堂展示 │ [今日课堂] │
│ 字帖练习 │ ┌──────┐ ┌──────┐ ┌──────┐ │
│ 学情报告 │ │张三作│ │李四作│ │王五作│ ··· │
│ 书写回放 │ │品展示│ │品展示│ │品展示│ │
│ 设置 │ └──────┘ └──────┘ └──────┘ │
│ │ │
│ │ [字帖推荐] │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ │人教版│ │北师大│ │苏教版│ │
│ │ │二年级│ │二年级│ │二年级│ │
│ │ └──────┘ └──────┘ └──────┘ │
└───────────────┴─────────────────────────────────────────────────┘
```
**BrowseFragment配置:**
```kotlin
// tv/ui/main/MainBrowseFragment.kt
class MainBrowseFragment : BrowseFragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// TV主题配置
headersState = HEADERS_ENABLED
isHeadersTransitionOnBackEnabled = true
// 品牌颜色
brandColor = ContextCompat.getColor(requireContext(), R.color.writech_blue)
title = "自然写互动课堂"
}
private fun setupRows() {
val rowsAdapter = ArrayObjectAdapter(ListRowPresenter())
// 课堂展示行
val classroomRow = buildClassroomRow()
rowsAdapter.add(classroomRow)
// 字帖推荐行
val calligraphyRow = buildCalligraphyRow()
rowsAdapter.add(calligraphyRow)
// 孩子学情行
val reportRow = buildReportRow()
rowsAdapter.add(reportRow)
adapter = rowsAdapter
}
}
```
### 3.2 实时笔迹大屏展示模块
**多学生同屏展示(九宫格布局):**
```
┌────────────────────────────────────────────────────────────┐
│ 课堂实时书写 — 二年级一班 已连接:38支笔 │
├────────────────┬───────────────┬───────────────────────────┤
│ 张三 [书写中] │ 李四 [已完成]│ 王五 [书写中] │
│ [笔迹图] │ [笔迹图] │ [笔迹图] │
├────────────────┼───────────────┼───────────────────────────┤
│ 赵六 [书写中] │ 陈七 [未开始]│ 周八 [书写中] │
│ [笔迹图] │ (空白) │ [笔迹图] │
├────────────────┼───────────────┼───────────────────────────┤
│ 吴九 [已完成] │ ··· (+29人) │ [查看全部] │
│ [笔迹图] │ │ │
└────────────────┴───────────────┴───────────────────────────┘
```
**单学生作品全屏展示(按确认键放大):**
```
┌────────────────────────────────────────────────────────────┐
│ 张三的作答 — 第5课生字练习 [×]关闭 │
├────────────────────────────────────────────────────────────┤
│ │
│ [学生书写笔迹全屏大图显示] │
│ │
│ 一 大 天 地 水 火 山 石 │
│ │
│ AI评分:92分 笔顺正确率:96% 书写规范度:88% │
│ │
├────────────────────────────────────────────────────────────┤
│ [上一个学生 ◀] [书写回放 ▶] [▶ 下一个学生] │
└────────────────────────────────────────────────────────────┘
```
### 3.3 字帖临摹大屏辅助模块
**字帖展示界面(教学场景):**
```
┌────────────────────────────────────────────────────────────┐
│ 人教版二年级上册 — 第5课生字 [×] [◀上一字] [下一字▶] │
├─────────────────────────────┬──────────────────────────────┤
│ 范字(电视左半屏展示) │ 学生实时书写(右半屏) │
│ │ │
│ ┌────────────────────────┐ │ ┌──────────────────────────┐ │
│ │ │ │ │ │ │
│ │ 美 │ │ │ [学生书写笔迹实时显示] │ │
│ │ (红色描红楷书体) │ │ │ │ │
│ │ │ │ └──────────────────────────┘ │
│ └────────────────────────┘ │ │
│ │ 笔顺:9笔 ✓(正确书写中) │
│ 笔顺演示:[▶ 播放] │ 规范度:87% │
└─────────────────────────────┴──────────────────────────────┘
```
### 3.4 互动答题结果展示模块
教师在PC或黑板端发题、收卷后,TV APP接收到结果推送,以大屏动画形式展示统计数据:
```
┌────────────────────────────────────────────────────────────┐
│ 答题结果 — 题目:2+3=? │
├────────────────────────────────────────────────────────────┤
│ │
│ 全班作答情况(38人) │
│ │
│ ████████████████████████████░░░░░░░░ 正确:30人(79%) │
│ ███████░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 错误:8人(21%) │
│ │
│ 典型正确答案: │
│ [张三: 5] [李四: 5] [王五: 5] │
│ │
│ 典型错误答案: │
│ [陈七: 4] [周八: 6] — 教师:重点讲解加法概念 │
│ │
└────────────────────────────────────────────────────────────┘
```
### 3.5 学情报告大屏浏览模块
家长用遥控器浏览孩子学情报告(Leanback DetailsFragment):
```
┌────────────────────────────────────────────────────────────┐
│ 张小明的学情报告 — 本周(2/10-2/14) │
├──────────────────────────────────────────┬─────────────────┤
│ 本周总结 │ 成长轨迹图 │
│ ● 完成作业:5/5 ✓ │ 分数 │
│ ● 平均得分:88.5分 │ 100│ * │
│ ● 书写规范:↑提升3% │ 90│ * * │
│ ● 笔顺正确:92% │ 80│* * * │
│ │ 70└──────────│
│ 薄弱知识点: │ 第1 2 3 4 5周│
│ ⚠ 数学应用题 ⚠ 多音字辨析 │ │
├──────────────────────────────────────────┴─────────────────┤
│ 本周优秀作品 [浏览 ▶] │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │第一│ │第二│ │第三│ │
│ │次作│ │次作│ │次作│ │
│ └────┘ └────┘ └────┘ │
└────────────────────────────────────────────────────────────┘
```
### 3.6 设备绑定与设置模块
**设备绑定流程(首次使用):**
```
TV APP首次打开:
├─ 显示"扫码绑定"界面
│ ┌────────────────────────────┐
│ │ 请用手机APP扫描此二维码 │
│ │ 完成设备绑定 │
│ │ [二维码图案(128×128)] │
│ │ 二维码有效期:5分钟 │
│ └────────────────────────────┘
├─ 家长/教师在手机APP中扫描
│ → 手机APP发送授权请求到云端
│ → 云端验证并绑定TV设备ID
└─ TV APP收到绑定成功通知
→ 自动登录,跳转主页
```
---
## 第四章 操作流程与使用步骤
### 4.1 安装与首次启动
**通过应用商店安装:**
1. 在电视遥控器按主页键,进入电视应用商店
2. 搜索"自然写互动课堂"或"Writech"
3. 选择下载安装(约120MB
4. 安装完成后从应用列表启动
**通过U盘旁加载(支持离线安装):**
1. 将APK文件复制到U盘
2. 在电视文件管理器中找到APK文件,点击安装
3. 允许"安装未知来源应用"(首次安装需开启此选项)
### 4.2 基本遥控器操作说明
| 遥控器按键 | 功能 |
|-----------|------|
| 方向键(上/下/左/右) | 移动焦点 |
| 确认键 / OK | 选中当前元素(进入/播放/展开) |
| 返回键 | 返回上一页 |
| 主页键 | 返回电视主页(后台运行APP) |
| 菜单键 | 打开当前页面的更多选项 |
| 语音键(如有) | 唤起语音搜索 |
| 数字键(0-9) | 快速输入数字(设置页面使用) |
### 4.3 课堂展示使用流程(教室TV场景)
```
操作步骤:
1. 开启课堂前,教室TV开机,APP自动发现教室网关(mDNS)
2. 教师在黑板或PC端开始课堂,TV APP收到WebSocket通知自动进入课堂模式
3. 大屏自动切换到"实时书写"界面,展示学生书写状态
4. 教师按遥控器方向键选择学生,按确认键放大查看
5. 教师在黑板端收卷后,TV大屏自动展示答题统计结果(动画呈现)
6. 课堂结束,TV APP退出课堂模式,显示"课堂已结束"提示
```
### 4.4 字帖练习使用流程(家庭TV场景)
```
操作步骤:
1. 打开APP,在主页选择"字帖练习"
2. 按方向键选择年级(一年级/二年级/···)
3. 选择课本版本(人教版/北师大版/苏教版)
4. 选择本周字帖内容(APP自动推荐与作业关联的字帖)
5. 字帖内容加载,大屏左侧显示范字,右侧为学生书写区
6. 学生手持点阵笔在字帖纸上书写,实时书写内容显示在电视右半屏
7. 每个字书写完成后,AI给出笔顺和规范度评分
8. 家长用遥控器翻页到下一个字
```
### 4.5 学情报告浏览流程
```
操作步骤:
1. 在主页选择"学情报告"
2. 选择孩子(多孩子家庭可切换)
3. 按方向键选择报告类型(周报/月报/学期报)
4. 用方向键上下滚动浏览报告内容
5. 方向键右进入"成长轨迹"图表(可交互查看每周数据)
6. 按菜单键可选择"分享"(截图发给亲戚)或"收藏"
```
### 4.6 连接问题排查
| 问题 | 排查步骤 |
|------|---------|
| 找不到教室网关 | ① 确认TV与网关在同一WiFi ② 关闭再开启WiFi ③ 手动输入网关IP |
| 实时笔迹卡顿 | ① 检查WiFi信号强度 ② 将TV通过网线连接路由器 ③ 降低并发展示学生数量 |
| 二维码扫描失败 | ① 确保手机APP版本≥V1.0 ② 二维码5分钟有效,刷新后重试 |
---
## 第五章 与源代码的对应关系
### 5.1 模块与源代码文件对应表
| 功能模块 | 源代码路径 | 说明 |
|---------|----------|------|
| 主页浏览 | `tv/ui/main/MainBrowseFragment.kt` | Leanback BrowseFragment主界面 |
| 焦点导航配置 | `tv/ui/main/FocusNavigationManager.kt` | D-Pad焦点路由规则 |
| 笔迹实时展示 | `tv/ui/classroom/InkDisplayFragment.kt` | 实时笔迹大屏展示Fragment |
| 笔迹渲染引擎 | `tv/ui/rendering/InkSurfaceView.kt` | SurfaceView多学生并发渲染 |
| 多学生同屏 | `tv/ui/classroom/MultiStudentGridView.kt` | 九宫格学生作品展示 |
| 字帖临摹模块 | `tv/ui/calligraphy/CalligraphyFragment.kt` | 字帖大屏展示与临摹辅助 |
| 字形渲染 | `tv/ui/calligraphy/CalligraphyDisplayView.kt` | 矢量字形放大渲染 |
| 答题结果展示 | `tv/ui/classroom/QuizResultFragment.kt` | 互动答题统计大屏展示 |
| 学情报告 | `tv/ui/report/ReportDetailsFragment.kt` | Leanback DetailsFragment学情报告 |
| 书写回放 | `tv/ui/replay/StrokePlaybackFragment.kt` | Leanback PlaybackFragment书写回放 |
| 设备发现 | `tv/data/network/GatewayDiscovery.kt` | mDNS教室网关自动发现 |
| WebSocket客户端 | `tv/data/network/InkWebSocketClient.kt` | 实时笔迹数据流接收 |
| 设备绑定 | `tv/ui/binding/DeviceBindingFragment.kt` | 二维码扫码绑定流程 |
| 本地数据库 | `tv/data/database/` | Room数据库实体与DAO |
| 主题配置 | `tv/ui/theme/TVTheme.kt` | TV大屏专用视觉主题 |
### 5.2 核心类与方法说明
| 类名 | 所在文件 | 功能说明 |
|------|---------|---------|
| `MainBrowseFragment` | `main/MainBrowseFragment.kt` | TV应用主界面,Leanback浏览体验 |
| `InkSurfaceView` | `rendering/InkSurfaceView.kt` | 多学生笔迹并发渲染(SurfaceView |
| `CalligraphyDisplayView` | `calligraphy/CalligraphyDisplayView.kt` | 汉字矢量放大渲染,田字格辅助 |
| `InkWebSocketClient` | `network/InkWebSocketClient.kt` | WebSocket笔迹流接收与解析 |
| `GatewayDiscovery` | `network/GatewayDiscovery.kt` | Android NSD mDNS网关发现 |
| `ClassroomViewModel` | `viewmodel/ClassroomViewModel.kt` | 课堂展示状态管理 |
| `InkViewModel` | `viewmodel/InkViewModel.kt` | 实时笔迹数据处理 |
| `ReportViewModel` | `viewmodel/ReportViewModel.kt` | 学情报告数据管理 |
---
## 附录A 界面设计稿(GUI Mockup
本附录以TV横屏线框图形式呈现电视APP各核心界面的设计稿,遵循10英尺UI设计规范(适配2米以上观看距离)。
---
### A.1 应用首页(Home
```
┌──────────────────────────────────────────────────────────────────────────────────┐
│ 自 然 写 智 能 课 堂 · 电 视 端 │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 欢迎,李老师 · 今天是2026年2月14日 星期六 │
│ │
│ ┌────────────────────────────┐ ┌─────────────────────────────────────────┐ │
│ │ │ │ 快捷入口(遥控器导航) │ │
│ │ 📺 今日课堂 │ │ │ │
│ │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ 下一节:10:00 数学 │ │ │ │ │ │ │ │ │ │
│ │ 高一(3)班 · 45人 │ │ │ 📚作业 │ │ 📊报表 │ │ 🎬回放 │ │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ [▶ 开始课堂] ← 焦点高亮 │ │ └─────────┘ └─────────┘ └─────────┘ │ │
│ │ │ │ │ │
│ └────────────────────────────┘ └─────────────────────────────────────────┘ │
│ │
│ 最近作业 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 2月14日语文 │ │ 2月13日数学 │ │ 2月12日英语 │ │ 2月11日物理 │ │
│ │ 待批改: 38 │ │ 已批改: 45 │ │ 待批改: 12 │ │ 已批改: 45 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
├──────────────────────────────────────────────────────────────────────────────────┤
│ 🏠 首页 📚 作业 📊 报表 🎬 回放 ⚙️ 设置 👤 李老师 · 退出 │
└──────────────────────────────────────────────────────────────────────────────────┘
```
---
### A.2 课堂互动界面(教师投屏大屏)
```
┌──────────────────────────────────────────────────────────────────────────────────┐
│ 📡 直播中 · 数学课堂 · 高一(3)班 ⏱ 00:23:45 [结束课堂] [暂停] │
├──────────────────────────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────┐ ┌───────────────────────────────┐ │
│ │ 本题:解方程 2x + 5 = 13 │ │ 班级提交状态 │ │
│ │ │ │ │ │
│ │ ┌──────────────────────────────────┐ │ │ 已提交 ████████████ 38人 │ │
│ │ │ │ │ │ 书写中 ██ 7人 │ │
│ │ │ [ 答题区域 / 学生回答展示 ] │ │ │ 未开始 0人 │ │
│ │ │ │ │ │ │ │
│ │ │ x = 4 (AI识别结果) │ │ │ 总人数:45 · 出勤:45/45 │ │
│ │ │ ✅ 正确率:84.4% │ │ ├───────────────────────────────┤ │
│ │ │ │ │ │ 常见错误 │ │
│ │ └──────────────────────────────────┘ │ │ x = 9 ████ 5人(移项错误) │ │
│ │ │ │ x = 3 ██ 2人(计算错误) │ │
│ │ [收卷] [点名] [展示典型答案] [投票] │ │ x = -4 █ 1人 │ │
│ └────────────────────────────────────────┘ └───────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────────────────────┤
│ ← 上一题 题目 3 / 8 下一题 → [激光笔模式] │
└──────────────────────────────────────────────────────────────────────────────────┘
```
---
### A.3 书写回放界面
```
┌──────────────────────────────────────────────────────────────────────────────────┐
│ ◀ 返回 🎬 书写回放 · 2月14日语文作业 · 王小花 │
├──────────────────────────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ │ [ 书写回放画布区域(A4比例)] │ │
│ │ │ │
│ │ 春眠不觉晓, │ │
│ │ 处处闻啼鸟。 │ │
│ │ 夜来风雨声, │ │
│ │ 花落知多少。 │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ ◀◀ ▶ ▶▶ ════════════════════●══════════ 00:01:23 / 00:03:45 │ │
│ │ 速度: [×0.5] [×1.0] [×2.0] [×4.0] [截图] [导出视频] │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
│ 批改结果:✅ 正确 · 教师评语:"字迹工整,内容正确,继续保持!" │
└──────────────────────────────────────────────────────────────────────────────────┘
```
---
### A.4 答题统计报告界面
```
┌──────────────────────────────────────────────────────────────────────────────────┐
│ ◀ 返回 📊 答题统计 · 2月14日数学课 · 高一(3)班 [导出报告] │
├──────────────────────────────────────────────────────────────────────────────────┤
│ ┌──────────────────────────────────────┐ ┌───────────────────────────────────┐ │
│ │ 课堂概览 │ │ 各题正确率 │ │
│ │ │ │ │ │
│ │ 参与人数:45/45 100% │ │ 题1 ████████████████████ 95% │ │
│ │ 平均用时:3分22秒 │ │ 题2 ██████████████████ 88% │ │
│ │ 总互动次数:312 │ │ 题3 ████████████████ 78% │ │
│ │ 课堂效率指数:★★★★☆ │ │ 题4 ████████████ 62% │ │
│ │ │ │ 题5 █████████ 45% ⚠️│ │
│ ├──────────────────────────────────────┤ │ 题6 ████████████████████ 90% │ │
│ │ 学生表现分布 │ │ 题7 ████████████ 60% │ │
│ │ │ │ 题8 ███████████████████ 84% │ │
│ │ 优秀(90%+) ████████████ 18人 │ └───────────────────────────────────┘ │
│ │ 良好(75-89%) █████████ 15人 │ │
│ │ 一般(60-74%) ██████ 8人 │ ⚠️ 需关注知识点: │
│ │ 待提高(<60%) ████ 4人 │ · 题5「分数乘法」正确率仅45% │
│ └──────────────────────────────────────┘ · 建议课后加强练习 │
└──────────────────────────────────────────────────────────────────────────────────┘
```
---
## 附录B 遥控器按键映射表
| Android KeyCode | 遥控器按键 | TV APP行为 |
|----------------|-----------|----------|
| KEYCODE_DPAD_UP | 方向↑ | 焦点上移 |
| KEYCODE_DPAD_DOWN | 方向↓ | 焦点下移 |
| KEYCODE_DPAD_LEFT | 方向← | 焦点左移 / 上一项 |
| KEYCODE_DPAD_RIGHT | 方向→ | 焦点右移 / 下一项 |
| KEYCODE_DPAD_CENTER | 确认 | 选中/播放/展开 |
| KEYCODE_BACK | 返回 | 退出当前页面 |
| KEYCODE_HOME | 主页 | 挂起APP,返回TV主页 |
| KEYCODE_MENU | 菜单 | 展开更多选项 |
| KEYCODE_MEDIA_PLAY_PAUSE | 播放/暂停 | 书写回放播放控制 |
| KEYCODE_MEDIA_FAST_FORWARD | 快进 | 回放速度加快 |
| KEYCODE_MEDIA_REWIND | 快退 | 回放进度后退 |
---
## 附录B 术语表
| 术语 | 说明 |
|------|------|
| Android TV | Android系统的TV版本,适配大屏遥控器操作 |
| Leanback Library | Android TV官方UI框架,提供BrowseFragment等TV专用组件 |
| D-Pad | Directional Pad,遥控器方向键(上下左右确认) |
| 焦点导航 | TV应用中通过方向键移动"焦点"来操作UI的交互方式 |
| NSD | Network Service DiscoveryAndroid mDNS实现 |
| mDNS | Multicast DNS,局域网内零配置服务发现协议 |
| SurfaceView | Android高性能绘图组件,拥有独立渲染线程(适合游戏/视频/实时笔迹) |
| FLAG_SECURE | Android窗口标志,设置后禁止系统截图和录屏 |
---
*文档编制:深圳自然写科技有限公司 Android TV研发团队*
*文档版本:V1.0*
*最后更新:2026年2月14日*
*版权所有 © 2026 深圳自然写科技有限公司*
---
## 附录C 核心技术实现详述
### C.1 SurfaceView双缓冲渲染架构
Android TV应用中,笔迹实时渲染采用SurfaceView双缓冲机制。主线程负责业务逻辑,独立渲染线程(RenderThread)负责Canvas绘图,避免UI阻塞。
#### C.1.1 渲染线程设计
```java
// TvInkSurfaceView.java - 双缓冲渲染核心实现
public class TvInkSurfaceView extends SurfaceView implements SurfaceHolder.Callback {
private static final String TAG = "TvInkSurfaceView";
private static final int TARGET_FPS = 60;
private static final long FRAME_INTERVAL_NS = 1_000_000_000L / TARGET_FPS;
private RenderThread mRenderThread;
private final Object mLock = new Object();
private volatile boolean mRunning = false;
// 双缓冲:前台缓冲(显示)和后台缓冲(绘制)
private Bitmap mFrontBuffer;
private Bitmap mBackBuffer;
private Canvas mBackCanvas;
// 笔迹数据队列(生产者-消费者模型)
private final ConcurrentLinkedQueue<InkPacket> mInkQueue = new ConcurrentLinkedQueue<>();
// 所有学生笔迹路径缓存 key=studentId
private final ConcurrentHashMap<String, StudentInkState> mStudentInks = new ConcurrentHashMap<>();
public TvInkSurfaceView(Context context) {
super(context);
getHolder().addCallback(this);
setZOrderMediaOverlay(true);
}
@Override
public void surfaceCreated(@NonNull SurfaceHolder holder) {
int width = getWidth();
int height = getHeight();
mFrontBuffer = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
mBackBuffer = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
mBackCanvas = new Canvas(mBackBuffer);
mRunning = true;
mRenderThread = new RenderThread();
mRenderThread.start();
}
@Override
public void surfaceDestroyed(@NonNull SurfaceHolder holder) {
mRunning = false;
try {
mRenderThread.join(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/**
* 渲染线程主循环
* 以60fps稳定帧率绘制所有学生笔迹
*/
private class RenderThread extends Thread {
@Override
public void run() {
long lastFrameTime = System.nanoTime();
while (mRunning) {
long now = System.nanoTime();
long elapsed = now - lastFrameTime;
if (elapsed < FRAME_INTERVAL_NS) {
try {
Thread.sleep((FRAME_INTERVAL_NS - elapsed) / 1_000_000);
} catch (InterruptedException ignored) {}
continue;
}
lastFrameTime = now;
processInkQueue();
drawFrame();
swapBuffers();
}
}
/** 消费队列中的笔迹数据包,更新各学生的Path */
private void processInkQueue() {
InkPacket packet;
while ((packet = mInkQueue.poll()) != null) {
StudentInkState state = mStudentInks.computeIfAbsent(
packet.studentId, k -> new StudentInkState(k));
state.addPoint(packet.x, packet.y, packet.pressure, packet.isPenUp);
}
}
/** 将所有学生笔迹绘制到后台缓冲区 */
private void drawFrame() {
mBackCanvas.drawColor(Color.WHITE);
for (StudentInkState state : mStudentInks.values()) {
mBackCanvas.drawPath(state.getCurrentPath(), state.getPaint());
for (Path historicalPath : state.getHistoricalPaths()) {
mBackCanvas.drawPath(historicalPath, state.getPaint());
}
}
}
/** 交换缓冲区并提交到SurfaceHolder */
private void swapBuffers() {
synchronized (mLock) {
Bitmap temp = mFrontBuffer;
mFrontBuffer = mBackBuffer;
mBackBuffer = temp;
mBackCanvas = new Canvas(mBackBuffer);
}
Canvas canvas = getHolder().lockCanvas();
if (canvas != null) {
synchronized (mLock) {
canvas.drawBitmap(mFrontBuffer, 0, 0, null);
}
getHolder().unlockCanvasAndPost(canvas);
}
}
}
/** 外部调用:向渲染队列投递笔迹数据包 */
public void pushInkPacket(InkPacket packet) {
mInkQueue.offer(packet);
}
/** 清除指定学生笔迹 */
public void clearStudentInk(String studentId) {
mStudentInks.remove(studentId);
}
/** 清除全部笔迹 */
public void clearAllInk() {
mStudentInks.clear();
}
}
```
#### C.1.2 学生笔迹状态管理
```java
// StudentInkState.java - 单学生笔迹状态
public class StudentInkState {
private static final float BASE_STROKE_WIDTH = 4.0f;
private static final float PRESSURE_SCALE = 3.0f;
private final String studentId;
private final Paint paint;
private Path currentPath;
private final List<Path> historicalPaths = new ArrayList<>();
// 最近两个点,用于贝塞尔平滑
private float prevX = -1, prevY = -1;
private float prevMidX, prevMidY;
public StudentInkState(String studentId) {
this.studentId = studentId;
this.paint = new Paint(Paint.ANTI_ALIAS_FLAG);
this.paint.setStyle(Paint.Style.STROKE);
this.paint.setStrokeCap(Paint.Cap.ROUND);
this.paint.setStrokeJoin(Paint.Join.ROUND);
// 根据studentId哈希分配不同颜色(最多30种颜色)
this.paint.setColor(StudentColorPalette.getColor(studentId));
this.currentPath = new Path();
}
/**
* 添加笔迹点,使用二次贝塞尔曲线平滑
* @param x 归一化x坐标 [0,1]
* @param y 归一化y坐标 [0,1]
* @param pressure 压力值 [0,1]
* @param isPenUp 抬笔标志
*/
public void addPoint(float x, float y, float pressure, boolean isPenUp) {
// 将归一化坐标转换为屏幕像素坐标(此处假设渲染尺寸已注入)
float screenX = x * RenderConfig.CANVAS_WIDTH;
float screenY = y * RenderConfig.CANVAS_HEIGHT;
if (isPenUp) {
// 抬笔:保存当前笔画,开始新路径
if (currentPath != null && !currentPath.isEmpty()) {
historicalPaths.add(currentPath);
if (historicalPaths.size() > 500) {
historicalPaths.remove(0); // 防止内存溢出
}
}
currentPath = new Path();
prevX = -1;
prevY = -1;
return;
}
float strokeWidth = BASE_STROKE_WIDTH + pressure * PRESSURE_SCALE;
paint.setStrokeWidth(strokeWidth);
if (prevX < 0) {
// 第一个点:直接moveTo
currentPath.moveTo(screenX, screenY);
} else {
// 后续点:通过中点进行贝塞尔平滑
float midX = (prevX + screenX) * 0.5f;
float midY = (prevY + screenY) * 0.5f;
currentPath.quadTo(prevX, prevY, midX, midY);
}
prevX = screenX;
prevY = screenY;
}
public Path getCurrentPath() { return currentPath; }
public List<Path> getHistoricalPaths() { return historicalPaths; }
public Paint getPaint() { return paint; }
}
```
### C.2 mDNS网关自动发现实现
Android TV通过NSDNetwork Service Discovery)实现局域网内网关设备的自动发现,无需手动配置IP地址。
#### C.2.1 NSD服务发现核心代码
```java
// TvGatewayDiscoveryManager.java
public class TvGatewayDiscoveryManager {
private static final String SERVICE_TYPE = "_writech._tcp.";
private static final String SERVICE_NAME_PREFIX = "WritechGateway-";
private static final int DISCOVERY_TIMEOUT_MS = 30_000;
private final NsdManager mNsdManager;
private NsdManager.DiscoveryListener mDiscoveryListener;
private NsdManager.ResolveListener mResolveListener;
// 发现到的网关列表(ip -> GatewayInfo
private final ConcurrentHashMap<String, GatewayInfo> mGateways = new ConcurrentHashMap<>();
private final List<OnGatewayDiscoveredListener> mListeners = new CopyOnWriteArrayList<>();
private volatile boolean mDiscovering = false;
private final Handler mTimeoutHandler = new Handler(Looper.getMainLooper());
public TvGatewayDiscoveryManager(Context context) {
mNsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE);
}
/** 开始扫描局域网内的Writech网关设备 */
public void startDiscovery() {
if (mDiscovering) return;
mDiscovering = true;
mGateways.clear();
mDiscoveryListener = new NsdManager.DiscoveryListener() {
@Override
public void onDiscoveryStarted(String serviceType) {
Log.d(TAG, "NSD discovery started: " + serviceType);
// 设置超时:30秒后停止扫描
mTimeoutHandler.postDelayed(() -> stopDiscovery(), DISCOVERY_TIMEOUT_MS);
}
@Override
public void onServiceFound(NsdServiceInfo serviceInfo) {
if (serviceInfo.getServiceName().startsWith(SERVICE_NAME_PREFIX)) {
Log.d(TAG, "Gateway found: " + serviceInfo.getServiceName());
resolveService(serviceInfo);
}
}
@Override
public void onServiceLost(NsdServiceInfo serviceInfo) {
Log.d(TAG, "Gateway lost: " + serviceInfo.getServiceName());
// 移除已下线的网关
mGateways.values().removeIf(gw ->
gw.serviceName.equals(serviceInfo.getServiceName()));
notifyGatewayLost(serviceInfo.getServiceName());
}
@Override
public void onDiscoveryStopped(String serviceType) {
mDiscovering = false;
}
@Override
public void onStartDiscoveryFailed(String serviceType, int errorCode) {
Log.e(TAG, "Discovery failed: " + errorCode);
mDiscovering = false;
}
@Override
public void onStopDiscoveryFailed(String serviceType, int errorCode) {
Log.e(TAG, "Stop discovery failed: " + errorCode);
}
};
mNsdManager.discoverServices(SERVICE_TYPE,
NsdManager.PROTOCOL_DNS_SD, mDiscoveryListener);
}
/** 解析服务获取IP和端口 */
private void resolveService(NsdServiceInfo serviceInfo) {
mResolveListener = new NsdManager.ResolveListener() {
@Override
public void onResolveFailed(NsdServiceInfo info, int errorCode) {
Log.e(TAG, "Resolve failed for " + info.getServiceName() + ": " + errorCode);
// 1秒后重试解析
mTimeoutHandler.postDelayed(() -> resolveService(serviceInfo), 1000);
}
@Override
public void onServiceResolved(NsdServiceInfo info) {
String ip = info.getHost().getHostAddress();
int port = info.getPort();
String classroomId = extractClassroomId(info);
GatewayInfo gateway = new GatewayInfo(
info.getServiceName(), ip, port, classroomId);
mGateways.put(ip, gateway);
Log.i(TAG, "Gateway resolved: " + ip + ":" + port
+ " classroom=" + classroomId);
notifyGatewayDiscovered(gateway);
}
};
mNsdManager.resolveService(serviceInfo, mResolveListener);
}
/** 从TXT记录中提取教室ID */
private String extractClassroomId(NsdServiceInfo info) {
Map<String, byte[]> attrs = info.getAttributes();
byte[] classroomBytes = attrs.get("classroom_id");
if (classroomBytes != null) {
return new String(classroomBytes, StandardCharsets.UTF_8);
}
return "UNKNOWN";
}
public void stopDiscovery() {
mTimeoutHandler.removeCallbacksAndMessages(null);
if (mDiscovering && mDiscoveryListener != null) {
try {
mNsdManager.stopServiceDiscovery(mDiscoveryListener);
} catch (Exception e) {
Log.e(TAG, "Stop discovery error", e);
}
}
mDiscovering = false;
}
public List<GatewayInfo> getDiscoveredGateways() {
return new ArrayList<>(mGateways.values());
}
}
```
### C.3 WebSocket课堂数据流实现
TV应用通过WebSocket与网关建立长连接,实时接收全班学生的笔迹数据流。
#### C.3.1 WebSocket连接管理
```java
// TvClassroomWebSocketClient.java
public class TvClassroomWebSocketClient {
private static final int RECONNECT_DELAY_MS = 3000;
private static final int MAX_RECONNECT_ATTEMPTS = 10;
private static final int PING_INTERVAL_MS = 30_000;
private WebSocket mWebSocket;
private final OkHttpClient mHttpClient;
private final String mGatewayUrl;
private final String mClassroomId;
private final String mDeviceToken;
private int mReconnectAttempts = 0;
private volatile boolean mConnected = false;
private volatile boolean mIntentionalClose = false;
private final Handler mReconnectHandler = new Handler(Looper.getMainLooper());
private InkDataListener mInkDataListener;
public TvClassroomWebSocketClient(String gatewayIp, int port,
String classroomId, String deviceToken) {
this.mGatewayUrl = "ws://" + gatewayIp + ":" + port + "/ws/classroom";
this.mClassroomId = classroomId;
this.mDeviceToken = deviceToken;
this.mHttpClient = new OkHttpClient.Builder()
.pingInterval(PING_INTERVAL_MS, TimeUnit.MILLISECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // 长连接不超时
.build();
}
public void connect() {
mIntentionalClose = false;
mReconnectAttempts = 0;
doConnect();
}
private void doConnect() {
Request request = new Request.Builder()
.url(mGatewayUrl)
.addHeader("X-Classroom-Id", mClassroomId)
.addHeader("X-Device-Token", mDeviceToken)
.addHeader("X-Device-Type", "TV_DISPLAY")
.build();
mWebSocket = mHttpClient.newWebSocket(request, new WebSocketListener() {
@Override
public void onOpen(@NonNull WebSocket webSocket, @NonNull Response response) {
mConnected = true;
mReconnectAttempts = 0;
Log.i(TAG, "WebSocket connected to gateway");
// 发送注册消息
sendRegisterMessage();
}
@Override
public void onMessage(@NonNull WebSocket webSocket, @NonNull ByteString bytes) {
// 解析二进制笔迹数据包
parseInkPacket(bytes.toByteArray());
}
@Override
public void onMessage(@NonNull WebSocket webSocket, @NonNull String text) {
// 解析JSON控制消息(开始上课、下课、清屏等)
parseControlMessage(text);
}
@Override
public void onClosed(@NonNull WebSocket webSocket, int code, @NonNull String reason) {
mConnected = false;
Log.w(TAG, "WebSocket closed: " + code + " " + reason);
if (!mIntentionalClose) {
scheduleReconnect();
}
}
@Override
public void onFailure(@NonNull WebSocket webSocket,
@NonNull Throwable t, @Nullable Response response) {
mConnected = false;
Log.e(TAG, "WebSocket failure", t);
if (!mIntentionalClose) {
scheduleReconnect();
}
}
});
}
/**
* 解析二进制笔迹数据包
* 数据格式:[版本:1B][学生ID:4B][x:2B][y:2B][压力:1B][时间戳:4B][标志:1B] = 15字节/点
*/
private void parseInkPacket(byte[] data) {
if (data.length < 15) return;
int offset = 0;
int version = data[offset++] & 0xFF;
if (version != 0x01) return; // 不支持的协议版本
int studentId = ((data[offset] & 0xFF) << 24) | ((data[offset+1] & 0xFF) << 16)
| ((data[offset+2] & 0xFF) << 8) | (data[offset+3] & 0xFF);
offset += 4;
while (offset + 10 <= data.length) {
float x = (((data[offset] & 0xFF) << 8) | (data[offset+1] & 0xFF)) / 65535.0f;
float y = (((data[offset+2] & 0xFF) << 8) | (data[offset+3] & 0xFF)) / 65535.0f;
float pressure = (data[offset+4] & 0xFF) / 255.0f;
long timestamp = (long)(data[offset+5] & 0xFF) << 24
| (long)(data[offset+6] & 0xFF) << 16
| (long)(data[offset+7] & 0xFF) << 8
| (data[offset+8] & 0xFF);
boolean isPenUp = (data[offset+9] & 0x01) != 0;
offset += 10;
InkPacket packet = new InkPacket(String.valueOf(studentId),
x, y, pressure, timestamp, isPenUp);
if (mInkDataListener != null) {
mInkDataListener.onInkPacketReceived(packet);
}
}
}
private void scheduleReconnect() {
if (mReconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
Log.e(TAG, "Max reconnect attempts reached, giving up");
return;
}
long delay = RECONNECT_DELAY_MS * (1L << Math.min(mReconnectAttempts, 4));
mReconnectAttempts++;
Log.i(TAG, "Reconnecting in " + delay + "ms (attempt " + mReconnectAttempts + ")");
mReconnectHandler.postDelayed(this::doConnect, delay);
}
private void sendRegisterMessage() {
JSONObject msg = new JSONObject();
try {
msg.put("type", "REGISTER");
msg.put("deviceType", "TV_DISPLAY");
msg.put("classroomId", mClassroomId);
msg.put("timestamp", System.currentTimeMillis());
} catch (JSONException e) {
Log.e(TAG, "JSON error", e);
}
mWebSocket.send(msg.toString());
}
public void disconnect() {
mIntentionalClose = true;
mReconnectHandler.removeCallbacksAndMessages(null);
if (mWebSocket != null) {
mWebSocket.close(1000, "Normal closure");
}
}
public boolean isConnected() { return mConnected; }
}
```
### C.4 TV焦点导航引擎
Android TV应用基于Leanback库实现标准TV焦点导航,同时针对自然写课堂场景进行了自定义优化。
#### C.4.1 自定义焦点遍历策略
```java
// WritechTvFocusHelper.java
public class WritechTvFocusHelper {
/**
* 为学生列表GridView配置焦点导航
* 循环焦点:最后一个→回到第一个,第一个→跳到最后一个
*/
public static void setupStudentGridFocus(RecyclerView recyclerView,
int studentCount, int columnsPerRow) {
recyclerView.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS);
// 配置方向焦点跳转
recyclerView.setOnKeyListener((v, keyCode, event) -> {
if (event.getAction() != KeyEvent.ACTION_DOWN) return false;
RecyclerView.LayoutManager lm = recyclerView.getLayoutManager();
if (!(lm instanceof GridLayoutManager)) return false;
GridLayoutManager glm = (GridLayoutManager) lm;
int currentPos = getCurrentFocusedPosition(recyclerView);
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_RIGHT:
// 行末:跳到下一行第一个
if ((currentPos + 1) % columnsPerRow == 0) {
int nextRowFirst = currentPos + 1;
if (nextRowFirst >= studentCount) nextRowFirst = 0;
focusPosition(recyclerView, nextRowFirst);
return true;
}
break;
case KeyEvent.KEYCODE_DPAD_LEFT:
// 行首:跳到上一行末
if (currentPos % columnsPerRow == 0) {
int prevRowLast = currentPos - 1;
if (prevRowLast < 0) prevRowLast = studentCount - 1;
focusPosition(recyclerView, prevRowLast);
return true;
}
break;
case KeyEvent.KEYCODE_DPAD_DOWN:
// 最后一行:跳回第一行同列
int col = currentPos % columnsPerRow;
int lastRow = (studentCount - 1) / columnsPerRow;
if (currentPos / columnsPerRow == lastRow) {
focusPosition(recyclerView, col);
return true;
}
break;
}
return false;
});
}
private static int getCurrentFocusedPosition(RecyclerView rv) {
View focusedChild = rv.getFocusedChild();
if (focusedChild == null) return 0;
return rv.getChildAdapterPosition(focusedChild);
}
private static void focusPosition(RecyclerView rv, int position) {
rv.scrollToPosition(position);
rv.post(() -> {
RecyclerView.ViewHolder vh = rv.findViewHolderForAdapterPosition(position);
if (vh != null) {
vh.itemView.requestFocus();
}
});
}
}
```
### C.5 书写回放功能实现
TV端支持课后查看任意学生的书写回放,帧动画逐步重现学生书写过程。
#### C.5.1 回放控制器
```java
// TvStrokeReplayController.java
public class TvStrokeReplayController {
private static final int REPLAY_FPS = 30;
private static final long FRAME_INTERVAL_MS = 1000 / REPLAY_FPS;
// 回放速度倍率
public enum ReplaySpeed {
SLOW(0.5f), NORMAL(1.0f), FAST(2.0f), VERY_FAST(4.0f);
public final float multiplier;
ReplaySpeed(float m) { this.multiplier = m; }
}
private final TvInkSurfaceView mSurfaceView;
private List<InkPoint> mAllPoints; // 全部笔迹点(按时间戳排序)
private int mCurrentIndex = 0;
private long mStartRealTime;
private long mStartStrokeTime;
private ReplaySpeed mSpeed = ReplaySpeed.NORMAL;
private volatile boolean mPlaying = false;
private volatile boolean mPaused = false;
private final Handler mReplayHandler = new Handler(Looper.getMainLooper());
public TvStrokeReplayController(TvInkSurfaceView surfaceView) {
this.mSurfaceView = surfaceView;
}
/**
* 加载回放数据并开始播放
* @param points 已按时间戳排序的笔迹点列表
*/
public void startReplay(List<InkPoint> points) {
mAllPoints = new ArrayList<>(points);
mCurrentIndex = 0;
mSurfaceView.clearAllInk();
if (mAllPoints.isEmpty()) return;
mStartRealTime = SystemClock.elapsedRealtime();
mStartStrokeTime = mAllPoints.get(0).timestamp;
mPlaying = true;
mPaused = false;
scheduleNextFrame();
}
private void scheduleNextFrame() {
if (!mPlaying || mPaused) return;
mReplayHandler.postDelayed(this::advanceFrame, FRAME_INTERVAL_MS);
}
private void advanceFrame() {
if (!mPlaying || mPaused || mCurrentIndex >= mAllPoints.size()) {
if (mCurrentIndex >= mAllPoints.size()) {
onReplayFinished();
}
return;
}
long realElapsed = SystemClock.elapsedRealtime() - mStartRealTime;
long strokeElapsed = (long)(realElapsed * mSpeed.multiplier);
long targetStrokeTime = mStartStrokeTime + strokeElapsed;
// 推送所有时间戳 <= targetStrokeTime 的点
while (mCurrentIndex < mAllPoints.size()) {
InkPoint pt = mAllPoints.get(mCurrentIndex);
if (pt.timestamp > targetStrokeTime) break;
mSurfaceView.pushInkPacket(new InkPacket(
pt.studentId, pt.x, pt.y, pt.pressure,
pt.timestamp, pt.isPenUp));
mCurrentIndex++;
}
scheduleNextFrame();
}
public void pause() {
mPaused = true;
mReplayHandler.removeCallbacksAndMessages(null);
}
public void resume() {
if (!mPaused) return;
// 重新校准时间基准
if (mCurrentIndex < mAllPoints.size()) {
mStartRealTime = SystemClock.elapsedRealtime();
mStartStrokeTime = mAllPoints.get(mCurrentIndex).timestamp;
}
mPaused = false;
scheduleNextFrame();
}
public void setSpeed(ReplaySpeed speed) {
this.mSpeed = speed;
}
public void seekTo(float progress) {
// progress: [0.0, 1.0]
int targetIndex = (int)(progress * mAllPoints.size());
targetIndex = Math.max(0, Math.min(targetIndex, mAllPoints.size() - 1));
// 清屏并重新绘制到目标位置
mSurfaceView.clearAllInk();
for (int i = 0; i <= targetIndex; i++) {
InkPoint pt = mAllPoints.get(i);
mSurfaceView.pushInkPacket(new InkPacket(
pt.studentId, pt.x, pt.y, pt.pressure,
pt.timestamp, pt.isPenUp));
}
mCurrentIndex = targetIndex;
if (!mPaused && mCurrentIndex < mAllPoints.size()) {
mStartRealTime = SystemClock.elapsedRealtime();
mStartStrokeTime = mAllPoints.get(mCurrentIndex).timestamp;
}
}
public void stop() {
mPlaying = false;
mPaused = false;
mReplayHandler.removeCallbacksAndMessages(null);
mSurfaceView.clearAllInk();
}
private void onReplayFinished() {
mPlaying = false;
Log.i(TAG, "Replay finished, total points: " + mAllPoints.size());
}
public boolean isPlaying() { return mPlaying && !mPaused; }
public int getProgress() {
if (mAllPoints == null || mAllPoints.isEmpty()) return 0;
return (int)(100.0f * mCurrentIndex / mAllPoints.size());
}
}
```
### C.6 课件展示模块
TV端支持展示PPT/PDF课件,与教师端投影内容同步。
#### C.6.1 课件展示Activity
```java
// TvCoursewareActivity.java
public class TvCoursewareActivity extends Activity {
private static final String EXTRA_COURSEWARE_URL = "courseware_url";
private static final String EXTRA_COURSEWARE_TYPE = "courseware_type"; // PDF/IMAGE
private ViewPager2 mSlidePager;
private TvCoursewareAdapter mAdapter;
private List<Bitmap> mSlides = new ArrayList<>();
private int mCurrentSlide = 0;
private int mTotalSlides = 0;
// 监听教师端翻页指令
private WebSocketCommandListener mCommandListener;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_tv_courseware);
// 全屏无状态栏
getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
mSlidePager = findViewById(R.id.slide_pager);
mAdapter = new TvCoursewareAdapter(mSlides);
mSlidePager.setAdapter(mAdapter);
String coursewareUrl = getIntent().getStringExtra(EXTRA_COURSEWARE_URL);
String type = getIntent().getStringExtra(EXTRA_COURSEWARE_TYPE);
if ("PDF".equals(type)) {
loadPdfCourseware(coursewareUrl);
} else {
loadImageCourseware(coursewareUrl);
}
setupRemoteControl();
setupCommandListener();
}
/**
* 异步加载PDF课件,使用PdfRenderer逐页渲染为Bitmap
*/
private void loadPdfCourseware(String url) {
new Thread(() -> {
try {
// 下载PDF到本地缓存
File pdfFile = downloadToCache(url);
ParcelFileDescriptor pfd = ParcelFileDescriptor.open(
pdfFile, ParcelFileDescriptor.MODE_READ_ONLY);
PdfRenderer renderer = new PdfRenderer(pfd);
int pageCount = renderer.getPageCount();
for (int i = 0; i < pageCount; i++) {
PdfRenderer.Page page = renderer.openPage(i);
int width = 1920; // TV 1080p宽度
int height = (int)(1.0f * page.getHeight() / page.getWidth() * width);
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565);
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY);
page.close();
final int index = i;
runOnUiThread(() -> {
mSlides.add(bitmap);
mAdapter.notifyItemInserted(mSlides.size() - 1);
if (index == 0) {
mTotalSlides = pageCount;
updateSlideCounter(1, pageCount);
}
});
}
renderer.close();
} catch (Exception e) {
Log.e(TAG, "PDF load failed", e);
runOnUiThread(() -> showErrorHint("课件加载失败,请检查网络连接"));
}
}).start();
}
/**
* 响应教师端翻页控制指令
*/
private void setupCommandListener() {
mCommandListener = new WebSocketCommandListener() {
@Override
public void onCommand(String command, JSONObject payload) {
switch (command) {
case "SLIDE_NEXT":
runOnUiThread(() -> nextSlide());
break;
case "SLIDE_PREV":
runOnUiThread(() -> prevSlide());
break;
case "SLIDE_GOTO":
int page = payload.optInt("page", 1);
runOnUiThread(() -> gotoSlide(page - 1));
break;
case "ANNOTATION_SYNC":
// 同步教师标注(叠加到当前幻灯片上层)
runOnUiThread(() -> syncAnnotation(payload));
break;
}
}
};
}
private void nextSlide() {
if (mCurrentSlide < mTotalSlides - 1) {
mCurrentSlide++;
mSlidePager.setCurrentItem(mCurrentSlide, true);
updateSlideCounter(mCurrentSlide + 1, mTotalSlides);
}
}
private void prevSlide() {
if (mCurrentSlide > 0) {
mCurrentSlide--;
mSlidePager.setCurrentItem(mCurrentSlide, true);
updateSlideCounter(mCurrentSlide + 1, mTotalSlides);
}
}
private void gotoSlide(int index) {
if (index >= 0 && index < mTotalSlides) {
mCurrentSlide = index;
mSlidePager.setCurrentItem(index, false);
updateSlideCounter(index + 1, mTotalSlides);
}
}
private void updateSlideCounter(int current, int total) {
TextView counter = findViewById(R.id.slide_counter);
counter.setText(current + " / " + total);
}
/** 遥控器左右键翻页 */
private void setupRemoteControl() {
mSlidePager.setOnKeyListener((v, keyCode, event) -> {
if (event.getAction() != KeyEvent.ACTION_DOWN) return false;
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_RIGHT:
case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD:
nextSlide(); return true;
case KeyEvent.KEYCODE_DPAD_LEFT:
case KeyEvent.KEYCODE_MEDIA_REWIND:
prevSlide(); return true;
}
return false;
});
}
}
```
### C.7 TV主界面布局与Leanback框架集成
```java
// TvMainFragment.java - 使用Leanback BrowseSupportFragment
public class TvMainFragment extends BrowseSupportFragment {
private static final String HEADER_CLASSROOM = "课堂";
private static final String HEADER_HOMEWORK = "作业";
private static final String HEADER_REPLAY = "回放";
private static final String HEADER_REPORT = "报告";
private ArrayObjectAdapter mRowsAdapter;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setupUIElements();
loadRows();
}
private void setupUIElements() {
setTitle("自然写互动课堂");
setHeadersState(HEADERS_ENABLED);
setHeadersTransitionOnBackEnabled(true);
setBrandColor(getResources().getColor(R.color.writech_primary));
setSearchAffordanceColor(getResources().getColor(R.color.writech_accent));
}
private void loadRows() {
mRowsAdapter = new ArrayObjectAdapter(new ListRowPresenter());
// 课堂功能行
HeaderItem classroomHeader = new HeaderItem(0, HEADER_CLASSROOM);
ArrayObjectAdapter classroomAdapter = new ArrayObjectAdapter(new CardPresenter());
classroomAdapter.add(new CardItem("进入课堂", R.drawable.ic_classroom, CardItem.TYPE_CLASSROOM));
classroomAdapter.add(new CardItem("全班展示", R.drawable.ic_all_students, CardItem.TYPE_ALL_DISPLAY));
classroomAdapter.add(new CardItem("答题收集", R.drawable.ic_answer, CardItem.TYPE_ANSWER_COLLECT));
classroomAdapter.add(new CardItem("白板模式", R.drawable.ic_whiteboard, CardItem.TYPE_WHITEBOARD));
mRowsAdapter.add(new ListRow(classroomHeader, classroomAdapter));
// 作业功能行
HeaderItem homeworkHeader = new HeaderItem(1, HEADER_HOMEWORK);
ArrayObjectAdapter homeworkAdapter = new ArrayObjectAdapter(new CardPresenter());
homeworkAdapter.add(new CardItem("批改作业", R.drawable.ic_grade, CardItem.TYPE_GRADE));
homeworkAdapter.add(new CardItem("作业统计", R.drawable.ic_stats, CardItem.TYPE_STATS));
mRowsAdapter.add(new ListRow(homeworkHeader, homeworkAdapter));
// 回放功能行
HeaderItem replayHeader = new HeaderItem(2, HEADER_REPLAY);
ArrayObjectAdapter replayAdapter = new ArrayObjectAdapter(new CardPresenter());
replayAdapter.add(new CardItem("课堂回放", R.drawable.ic_replay, CardItem.TYPE_REPLAY));
replayAdapter.add(new CardItem("学生对比", R.drawable.ic_compare, CardItem.TYPE_COMPARE));
mRowsAdapter.add(new ListRow(replayHeader, replayAdapter));
setAdapter(mRowsAdapter);
}
@Override
public void onItemViewClickedListener() {
setOnItemViewClickedListener((itemViewHolder, item, rowViewHolder, row) -> {
if (item instanceof CardItem) {
CardItem card = (CardItem) item;
handleCardClick(card);
}
});
}
private void handleCardClick(CardItem card) {
Intent intent;
switch (card.getType()) {
case CardItem.TYPE_CLASSROOM:
intent = new Intent(getActivity(), TvClassroomActivity.class);
startActivity(intent);
break;
case CardItem.TYPE_REPLAY:
intent = new Intent(getActivity(), TvReplayActivity.class);
startActivity(intent);
break;
default:
Toast.makeText(getActivity(), "功能开发中", Toast.LENGTH_SHORT).show();
}
}
}
```
---
## 附录D 完整操作手册
### D.1 安装与初始配置
#### D.1.1 安装步骤
1. 将自然写TV应用APK文件通过U盘拷贝至电视USB接口,或通过学校内网推送安装。
2. 打开电视"应用管理"→"安装外部应用",选择APK文件。
3. 安装完成后,应用图标出现在电视应用列表中(图标:蓝色书写笔图案)。
4. 首次启动时,系统提示申请以下权限:
- 网络访问权限(必需)
- 局域网服务发现权限(必需)
- 存储读写权限(用于课件缓存)
5. 点击"全部允许"完成权限授权。
#### D.1.2 网络配置要求
| 配置项 | 要求 |
|-------|------|
| 网络类型 | WiFi或有线以太网(推荐有线) |
| 网络带宽 | ≥ 10Mbps(保障全班笔迹实时传输) |
| 网络类型 | 与网关设备处于同一局域网 |
| 防火墙 | 开放UDP 5353mDNS)、TCP 8765WebSocket |
| IP分配 | 建议电视设备使用静态IP,避免DHCP变更影响连接稳定性 |
#### D.1.3 首次登录
1. 打开自然写TV应用,进入账号登录页面。
2. 显示两种登录方式:
- **账号登录**:输入学校管理员账号和密码
- **扫码登录**:手机打开自然写APP扫描TV屏幕二维码授权
3. 登录成功后,系统自动扫描局域网内的网关设备(约5-15秒)。
4. 发现网关后,TV应用自动绑定当前教室,进入主界面。
### D.2 课堂功能操作
#### D.2.1 进入课堂模式
1. 在主界面选择"课堂"栏目,遥控器确认键进入。
2. 选择"进入课堂",TV屏幕切换为班级笔迹显示界面。
3. 界面布局:
- 顶部状态栏:教室名称、在线学生数、当前时间
- 主区域:4×8网格显示32个学生的实时笔迹区域(每格显示学生姓名)
- 底部工具栏:切换视图/全屏展示/清屏/课件/退出
#### D.2.2 全班笔迹展示操作
| 操作 | 遥控器按键 | 说明 |
|------|----------|------|
| 切换至单生全屏 | 选中格子 → 确认键 | 放大显示选中学生笔迹 |
| 返回全班视图 | 返回键 | 从单生全屏返回网格视图 |
| 标记优秀作品 | 选中格子 → 菜单键 → 标记 | 为该学生笔迹添加星标 |
| 全班清屏 | 工具栏选"清屏" → 确认 | 清除所有学生当前笔迹 |
| 单生清屏 | 选中格子 → 菜单键 → 清屏 | 仅清除选中学生笔迹 |
#### D.2.3 答题收集操作
1. 在课堂界面底部工具栏选择"答题收集"。
2. 设置答题参数:
- 答题时限(1-10分钟,默认3分钟)
- 是否允许修改(倒计时结束前可重复书写)
3. 确认后TV屏幕显示倒计时,所有学生Pad/智能笔进入答题锁定模式。
4. 答题时限到达后,TV屏幕自动展示全班答题结果网格视图。
5. 教师可通过遥控器浏览各学生答案,按"菜单键"可操作:
- 标记正确/错误
- 展示到投影(全屏单生)
- 添加批注(手写板或遥控键盘输入)
#### D.2.4 白板模式
1. 选择"白板模式"后,TV进入纯白背景白板界面。
2. 此模式支持通过网关接收教师智能笔书写内容实时展示。
3. 工具栏支持:
- 画笔颜色(8种颜色)
- 画笔粗细(细/中/粗/超粗)
- 橡皮擦(选中后遥控器确认键点击区域)
- 撤销/重做(遥控器媒体键控制)
- 清屏
- 截图保存(保存到本地存储用于分享)
### D.3 回放功能操作
#### D.3.1 查看课堂回放
1. 主界面选择"回放"栏目,确认进入。
2. 显示历史课堂列表(按日期排序),选择要回放的课堂。
3. 进入回放界面:
- 顶部:回放进度条(可左右键拖动)
- 主区域:全班笔迹动态重现
- 右侧:学生列表(可单选/全选)
- 底部控制栏:播放/暂停/速度/截图
#### D.3.2 回放控制操作
| 操作 | 遥控器按键 | 说明 |
|------|----------|------|
| 播放/暂停 | 确认键 或 播放/暂停键 | 切换播放状态 |
| 快进 | 快进键 / 右键长按 | 速度×2,再按×4 |
| 快退 | 快退键 / 左键长按 | 速度×0.5 |
| 跳到开头 | 左键×3快按 | 从头开始回放 |
| 跳到结尾 | 右键×3快按 | 快速预览最终结果 |
| 进度跳转 | 上键选中进度条 → 左右键 | 拖动进度条跳转 |
### D.4 报告查看
#### D.4.1 学情报告展示
1. 主界面选择"报告"栏目,确认进入。
2. 支持查看:
- 班级整体报告(正确率分布图、作业完成率等)
- 单生报告(选择学生姓名后展示)
3. TV端报告为只读展示模式,大字体适配远距离观看。
4. 可通过遥控器上下键翻页浏览报告内容。
### D.5 常见问题处理
| 现象 | 可能原因 | 处理方法 |
|------|---------|---------|
| 启动后无法发现网关 | 网关未上电 或 WiFi网络不同 | 检查网关LED指示灯,确认TV与网关同一WiFi |
| 学生笔迹显示卡顿 | 网络带宽不足 | 切换至有线以太网,或减少同时连接的学生设备数 |
| 课件加载失败 | 云端存储连接超时 | 检查网络,或使用U盘本地课件 |
| 回放数据缺失 | 课堂数据未上传完成 | 等待数据同步完成(状态栏显示同步进度) |
| 遥控器无响应 | 焦点丢失 | 按HOME键回到主界面重新进入 |
| 画面撕裂 | GPU渲染性能不足 | 在"设置→显示"中降低渲染分辨率至1080p |
---
## 附录E 源代码详细对应关系
### E.1 完整源代码文件清单
| 源文件 | 路径 | 功能说明 |
|--------|------|---------|
| TvMainActivity.java | app/src/main/java/.../tv/TvMainActivity.java | TV主界面ActivityLeanback入口 |
| TvMainFragment.java | .../tv/TvMainFragment.java | BrowseSupportFragment,主导航 |
| TvClassroomActivity.java | .../classroom/TvClassroomActivity.java | 课堂模式主Activity |
| TvInkSurfaceView.java | .../view/TvInkSurfaceView.java | 双缓冲笔迹渲染SurfaceView |
| StudentInkState.java | .../model/StudentInkState.java | 单学生笔迹状态管理 |
| TvClassroomWebSocketClient.java | .../network/TvClassroomWebSocketClient.java | WebSocket课堂连接 |
| TvGatewayDiscoveryManager.java | .../network/TvGatewayDiscoveryManager.java | mDNS网关发现 |
| TvStrokeReplayController.java | .../replay/TvStrokeReplayController.java | 书写回放控制器 |
| TvCoursewareActivity.java | .../courseware/TvCoursewareActivity.java | 课件展示Activity |
| WritechTvFocusHelper.java | .../util/WritechTvFocusHelper.java | 焦点导航工具类 |
| CardPresenter.java | .../presenter/CardPresenter.java | Leanback卡片Presenter |
| StudentColorPalette.java | .../util/StudentColorPalette.java | 学生笔迹颜色分配 |
| InkPacket.java | .../model/InkPacket.java | 笔迹数据包模型 |
| GatewayInfo.java | .../model/GatewayInfo.java | 网关设备信息模型 |
| RenderConfig.java | .../config/RenderConfig.java | 渲染配置常量 |
| TvAnswerCollectFragment.java | .../answer/TvAnswerCollectFragment.java | 答题收集Fragment |
| TvWhiteboardActivity.java | .../whiteboard/TvWhiteboardActivity.java | 白板模式Activity |
| TvReportActivity.java | .../report/TvReportActivity.java | 学情报告展示Activity |
| TvSettingsActivity.java | .../settings/TvSettingsActivity.java | 设置界面Activity |
### E.2 核心功能函数说明
| 函数名 | 所属类 | 说明 |
|--------|--------|------|
| surfaceCreated() | TvInkSurfaceView | SurfaceView创建时初始化双缓冲 |
| processInkQueue() | RenderThread | 消费笔迹队列,更新Path |
| drawFrame() | RenderThread | 绘制当前帧所有学生笔迹 |
| swapBuffers() | RenderThread | 前后缓冲区交换并提交 |
| addPoint() | StudentInkState | 添加笔迹点(贝塞尔平滑) |
| startDiscovery() | TvGatewayDiscoveryManager | 启动mDNS网关扫描 |
| resolveService() | TvGatewayDiscoveryManager | 解析服务获取IP/端口 |
| connect() | TvClassroomWebSocketClient | 建立WebSocket长连接 |
| parseInkPacket() | TvClassroomWebSocketClient | 解析二进制笔迹数据包 |
| scheduleReconnect() | TvClassroomWebSocketClient | 指数退避重连调度 |
| startReplay() | TvStrokeReplayController | 启动书写回放 |
| advanceFrame() | TvStrokeReplayController | 推进回放帧 |
| seekTo() | TvStrokeReplayController | 进度条跳转 |
| setupStudentGridFocus() | WritechTvFocusHelper | 配置网格焦点循环导航 |
| loadPdfCourseware() | TvCoursewareActivity | 异步加载PDF课件 |
### E.3 数据模型说明
```java
// InkPacket.java - 笔迹数据包
public class InkPacket {
public final String studentId; // 学生ID(对应学生座位编号)
public final float x; // 归一化x坐标 [0.0, 1.0]
public final float y; // 归一化y坐标 [0.0, 1.0]
public final float pressure; // 压力值 [0.0, 1.0]
public final long timestamp; // 毫秒时间戳
public final boolean isPenUp; // true=抬笔(笔画结束)
public InkPacket(String studentId, float x, float y,
float pressure, long timestamp, boolean isPenUp) {
this.studentId = studentId;
this.x = x;
this.y = y;
this.pressure = pressure;
this.timestamp = timestamp;
this.isPenUp = isPenUp;
}
}
// GatewayInfo.java - 网关设备信息
public class GatewayInfo {
public final String serviceName; // mDNS服务名称
public final String ipAddress; // 网关IP地址
public final int port; // WebSocket端口
public final String classroomId; // 教室ID(从TXT记录获取)
public long lastSeenTime; // 最近一次发现时间
public GatewayInfo(String serviceName, String ipAddress,
int port, String classroomId) {
this.serviceName = serviceName;
this.ipAddress = ipAddress;
this.port = port;
this.classroomId = classroomId;
this.lastSeenTime = System.currentTimeMillis();
}
}
```
### E.4 AndroidManifest关键配置
```xml
<!-- AndroidManifest.xmlTV相关关键配置) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.writech.tv">
<!-- TV应用必需权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- 声明为TV应用 -->
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<application
android:name=".WritechTvApplication"
android:label="@string/app_name"
android:icon="@drawable/ic_launcher"
android:banner="@drawable/ic_banner"
android:theme="@style/Theme.Leanback">
<!-- TV主入口 -->
<activity
android:name=".TvMainActivity"
android:exported="true"
android:screenOrientation="landscape"
android:configChanges="keyboard|keyboardHidden|navigation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- 课堂Activity -->
<activity
android:name=".classroom.TvClassroomActivity"
android:exported="false"
android:screenOrientation="landscape"
android:launchMode="singleTask"
android:keepScreenOn="true" />
<!-- 白板Activity -->
<activity
android:name=".whiteboard.TvWhiteboardActivity"
android:exported="false"
android:screenOrientation="landscape"
android:launchMode="singleTask" />
</application>
</manifest>
```
---
## 附录F 性能测试数据
### F.1 渲染性能指标
| 测试场景 | 平均帧率 | P99延迟 | 内存占用 |
|---------|--------|--------|--------|
| 空载(无学生连接) | 60fps | 2ms | 128MB |
| 10名学生同时书写 | 60fps | 8ms | 198MB |
| 20名学生同时书写 | 58fps | 15ms | 267MB |
| 32名学生同时书写 | 55fps | 28ms | 356MB |
| 32名学生+课件展示 | 50fps | 35ms | 412MB |
测试设备:Xiaomi Mi Box 4KAmlogic S905X42GB RAM),Android TV 11
### F.2 网络延迟指标
| 测试场景 | 端到端延迟(笔点→TV显示) | 丢包率 |
|---------|------------------------|------|
| 有线千兆局域网 | < 20ms | 0% |
| WiFi 5GHz局域网 | < 35ms | < 0.1% |
| WiFi 2.4GHz局域网 | < 60ms | < 0.5% |
| 跨WiFi路由器 | < 80ms | < 1% |
### F.3 稳定性测试
| 测试项目 | 测试时长 | 结果 |
|---------|--------|------|
| 连续课堂运行 | 8小时 | 无崩溃,内存无泄漏 |
| 网络中断重连 | 100次 | 100%成功重连,平均重连时间3.2秒 |
| 压力测试(32生高频书写) | 30分钟 | 帧率保持≥50fps,无数据丢失 |
---
*文档编制:深圳自然写科技有限公司 Android TV研发团队*
*文档版本:V1.0(附录更新)*
*最后更新:2026年2月14日*
*版权所有 © 2026 深圳自然写科技有限公司*
---
## 附录G 性能与兼容性
### G.1 兼容设备清单
| 品牌 | 型号 | 芯片 | Android TV版本 | 测试结果 |
|------|------|------|--------------|---------|
| 小米 | Mi Box 4K | Amlogic S905X4 | Android TV 11 | 完全兼容 |
| 索尼 | X85J | MT5896 | Android TV 10 | 完全兼容 |
| 飞利浦 | PUS8536 | MT9950 | Android TV 11 | 完全兼容 |
| 创维 | E5 Pro | Mstar 6A928 | Android TV 9 | 基本兼容 |
| 海信 | U7H | MT9620 | Android TV 11 | 完全兼容 |
### G.2 Android TV与手机版差异对比
| 功能 | TV版 | 手机/Pad版 |
|------|------|-----------|
| 交互方式 | 遥控器D-Pad导航 | 触摸屏手势 |
| 布局设计 | 大字体、高对比度、焦点突出 | 正常字体、密度信息 |
| 笔迹输入 | WebSocket接收全班笔迹(只展示) | BLE直接接收本机智能笔 |
| 白板书写 | 通过网关接收教师智能笔输入 | 直接触摸或BLE智能笔 |
| 课件展示 | PDF/PPT投影+同步翻页 | 学生视角课件浏览 |
| 数据上传 | 仅展示,不上传 | 直接上传笔迹和作业 |
### G.3 TV应用权限说明
| 权限 | 用途 | 是否必需 |
|------|------|---------|
| INTERNET | 网络连接、WebSocket通信 | 必需 |
| ACCESS_NETWORK_STATE | 检测网络状态 | 必需 |
| ACCESS_WIFI_STATE | 获取WiFi信息 | 必需 |
| CHANGE_NETWORK_STATE | 切换网络配置 | 可选 |
| READ_EXTERNAL_STORAGE | 读取U盘课件 | 可选 |
| WRITE_EXTERNAL_STORAGE | 保存课堂截图 | 可选 |
| RECORD_AUDIO | 课堂音频监听(可选功能) | 可选 |
| RECEIVE_BOOT_COMPLETED | 开机自动启动 | 可选 |
---
*本文档版权归深圳自然写科技有限公司所有,仅用于软件著作权登记鉴别,请勿用于其他商业用途。*
---
## 附录H 源代码目录结构
```
app/src/main/java/com/writech/tv/
├── TvApplication.java # Application初始化
├── TvMainActivity.java # TV主入口Activity
├── TvMainFragment.java # Leanback BrowseSupportFragment
├── classroom/
│ ├── TvClassroomActivity.java # 课堂主Activity
│ ├── TvInkSurfaceView.java # 双缓冲笔迹渲染SurfaceView
│ ├── StudentInkState.java # 单学生笔迹状态
│ ├── TvAnswerCollectFragment.java # 答题收集Fragment
│ └── TvWhiteboardActivity.java # 白板模式Activity
├── network/
│ ├── TvClassroomWebSocketClient.java # WebSocket课堂连接
│ └── TvGatewayDiscoveryManager.java # mDNS网关发现
├── courseware/
│ └── TvCoursewareActivity.java # 课件展示Activity
├── replay/
│ └── TvStrokeReplayController.java # 书写回放控制器
├── report/
│ └── TvReportActivity.java # 学情报告展示
├── presenter/
│ └── CardPresenter.java # Leanback卡片Presenter
├── model/
│ ├── InkPacket.java # 笔迹数据包模型
│ ├── GatewayInfo.java # 网关信息模型
│ └── CardItem.java # 主界面卡片数据模型
├── util/
│ ├── WritechTvFocusHelper.java # TV焦点导航工具
│ └── StudentColorPalette.java # 学生颜色分配工具
└── settings/
└── TvSettingsActivity.java # 设置页面
app/src/main/res/
├── layout/
│ ├── activity_tv_classroom.xml # 课堂界面布局(全屏)
│ ├── activity_tv_courseware.xml # 课件展示布局
│ └── fragment_tv_main.xml # 主界面布局
├── drawable/
│ ├── ic_launcher.xml # 应用图标
│ └── ic_banner.xml # TV端横幅(320×180dp
└── values/
├── strings.xml # 字符串资源
└── themes.xml # Leanback主题配置
```
### H.1 Gradle依赖配置
```groovy
// build.gradle (app)
dependencies {
implementation 'androidx.leanback:leanback:1.2.0'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-ws:4.12.0'
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'com.github.bumptech.glide:glide:4.16.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
}
```
---
*本文档版权归深圳自然写科技有限公司所有,技术细节仅用于软件著作权登记鉴别。*
---
## 附录F 补充技术规格
### F.1 Focus焦点导航引擎
Android TV应用不依赖触摸屏,完全通过遥控器D-Pad操控,焦点管理是核心技术:
```kotlin
// FocusNavigationManager.kt
class FocusNavigationManager(private val activity: AppCompatActivity) {
// 自定义焦点遍历顺序
fun setupStudentGridFocus(gridLayout: GridLayout) {
val children = (0 until gridLayout.childCount).map {
gridLayout.getChildAt(it)
}
val cols = gridLayout.columnCount
children.forEachIndexed { index, view ->
val row = index / cols
val col = index % cols
// 设置四个方向的焦点目标
view.nextFocusUpId = children.getOrNull(index - cols)?.id ?: View.NO_ID
view.nextFocusDownId = children.getOrNull(index + cols)?.id ?: View.NO_ID
view.nextFocusLeftId = if (col > 0) children[index - 1].id else View.NO_ID
view.nextFocusRightId = if (col < cols - 1) children[index + 1].id else View.NO_ID
view.isFocusable = true
view.setOnFocusChangeListener { v, hasFocus ->
v.animate().scaleX(if (hasFocus) 1.1f else 1.0f)
.scaleY(if (hasFocus) 1.1f else 1.0f)
.setDuration(150).start()
}
}
}
// 记住并恢复焦点位置
fun saveFocusState(): Bundle {
val focused = activity.currentFocus
return Bundle().apply {
putInt("focused_id", focused?.id ?: View.NO_ID)
}
}
fun restoreFocusState(state: Bundle) {
val id = state.getInt("focused_id")
if (id != View.NO_ID) {
activity.findViewById<View>(id)?.requestFocus()
}
}
}
```
### F.2 4K分辨率适配
```kotlin
// DisplayAdapter.kt
object DisplayAdapter {
fun getOptimalLayoutConfig(context: Context): LayoutConfig {
val dm = context.resources.displayMetrics
val widthPx = dm.widthPixels
val heightPx = dm.heightPixels
return when {
widthPx >= 3840 -> LayoutConfig( // 4K UHD
studentCols = 8,
studentRows = 5,
fontSize = 28f,
inkScaleFactor = 4.0f,
boardPadding = 48
)
widthPx >= 1920 -> LayoutConfig( // 1080p Full HD
studentCols = 6,
studentRows = 4,
fontSize = 22f,
inkScaleFactor = 2.0f,
boardPadding = 32
)
else -> LayoutConfig( // 720p HD
studentCols = 4,
studentRows = 3,
fontSize = 18f,
inkScaleFactor = 1.5f,
boardPadding = 24
)
}
}
}
data class LayoutConfig(
val studentCols: Int,
val studentRows: Int,
val fontSize: Float,
val inkScaleFactor: Float,
val boardPadding: Int
)
```
### F.3 课堂互动答题统计
```kotlin
// AnswerStatisticsEngine.kt
class AnswerStatisticsEngine {
data class AnswerStats(
val optionCounts: Map<String, Int>, // 各选项人数
val correctRate: Float, // 正确率
val avgSubmitTime: Long, // 平均提交耗时(ms
val totalStudents: Int,
val submittedCount: Int
)
fun compute(answers: List<StudentAnswer>, correctAnswer: String): AnswerStats {
val counts = answers.groupingBy { it.answer }.eachCount()
val correct = counts[correctAnswer] ?: 0
val avgTime = if (answers.isEmpty()) 0L
else answers.sumOf { it.submitTime - it.questionStartTime } / answers.size
return AnswerStats(
optionCounts = counts,
correctRate = if (answers.isEmpty()) 0f
else correct.toFloat() / answers.size,
avgSubmitTime = avgTime,
totalStudents = answers.size,
submittedCount = answers.count { it.submitted }
)
}
// 生成答题分布柱状图数据(用于Canvas绘制)
fun buildChartData(stats: AnswerStats): List<BarEntry> {
val options = listOf("A", "B", "C", "D")
return options.mapIndexed { i, opt ->
BarEntry(i.toFloat(), (stats.optionCounts[opt] ?: 0).toFloat())
}
}
}
```
---
*本文档版权归深圳自然写科技有限公司所有,技术细节仅用于软件著作权登记鉴别。*