# 自然写互动课堂电视端应用软件 V1.0
## 软件鉴别材料 — 用户操作手册与设计说明书

---

**软件全称**：自然写互动课堂电视端应用软件  
**软件版本**：V1.0  
**权利人**：深圳自然写科技有限公司  
**文档类型**：智能电视应用用户操作手册 + 设计说明书  
**文档编号**：WRITECH-APP-TV-DS-001  
**编制日期**：2026年2月  
**适用平台**：Android TV（Android 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.0（API 28） | Android TV 11.0+ |
| 内存 | 2GB RAM | 4GB RAM |
| 存储 | 500MB可用空间 | 2GB可用空间 |
| 网络 | WiFi 802.11n（2.4GHz） | WiFi 6（802.11ax） |
| 分辨率 | 1920×1080（全高清） | 3840×2160（4K） |
| 处理器 | ARM Cortex-A53 四核 | ARM Cortex-A73 八核 |
| GPU | 支持OpenGL ES 3.0 | 支持Vulkan 1.1 |

**支持的电视品牌/平台：**
- Android TV（索尼BRAVIA TV、飞利浦Android TV等）
- Google TV（Chromecast 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网络请求 |
| WebSocket（OkHttp ws） | 4.12.0 | 实时笔迹数据接收 |
| Room | 2.6.1 | 本地SQLite数据库 |
| Glide | 4.16.0 | 图片加载与缓存 |
| ExoPlayer | 2.19.1 | 视频/动画播放（书写回放） |
| mDNS（NSD 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）                            │
│  CloudRepository（API）│ LocalRepository（Room）│ InkStreamRepo  │
├──────────────────────────────────────────────────────────────────┤
│                  基础服务层（Infrastructure）                      │
│  WebSocket（笔迹流） │ OkHttp（API） │ NSD（mDNS发现） │ Room（DB）│
└──────────────────────────────────────────────────────────────────┘
```

### 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 EncryptedSharedPreferences（KeyStore加密）
- **局域网隔离**：仅接受来自同一局域网段（同一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 Discovery，Android 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通过NSD（Network 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 5353（mDNS）、TCP 8765（WebSocket） |
| 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主界面Activity，Leanback入口 |
| 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.xml（TV相关关键配置） -->
<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 4K（Amlogic S905X4，2GB 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())
        }
    }
}
```

---

*本文档版权归深圳自然写科技有限公司所有，技术细节仅用于软件著作权登记鉴别。*
