【听见课堂 HarmonyOS NEXT 实战系列 09】本机优先不是一句口号:课堂无障碍应用的隐私数据流设计
【听见课堂 HarmonyOS NEXT 实战系列 09】本机优先不是一句口号:课堂无障碍应用的隐私数据流设计
课堂无障碍应用会接触麦克风、相机、字幕、板书、任务和学习记录。只在隐私页写一句“数据仅保存在本机”并不够:工程上必须能回答每一种输入何时采集、在哪里处理、是否落盘、谁能导出、如何删除,以及错误日志里会不会留下内容。
听见课堂把“本机优先”拆成一组可检查的约束:权限按功能触发,原始音频默认不落盘,结构化字幕/板书/任务进入本机 RelationalStore,导出由用户主动复制 JSON,删除需要二次确认并用事务清理核心表。本文从 P01 隐私说明、P11 设置与隐私、module.json5、ClassroomService.exportClassroomData() 和 Repository 实现逐段还原这条数据流。

一、本机优先先回答六个问题
设计数据流前,不妨对每类数据做一次六问:
- 来源是什么? 麦克风、相机、图库、用户编辑还是模型推断;
- 何时开始? 打开应用、进入页面,还是用户点击开始;
- 处理在哪里? 内存、系统 Kit、端侧模型、本机数据库还是网络;
- 保存什么? 原始媒体、临时 URI、结构化文本还是状态值;
- 谁能带走? 后台自动上传、系统分享,还是用户主动导出;
- 如何消失? 暂停、资源释放、单项删除、全部删除还是卸载应用。
“本机优先”不是所有数据永远不流动,而是默认最小化采集和持久化,把跨边界动作交给用户明确触发,并让删除路径真实可用。
二、Manifest 声明权限,不等于启动时立即索取
项目的 entry/src/main/module.json5 只声明了当前核心能力需要的麦克风和相机权限,并把使用场景限定为 EntryAbility 运行期间:
"requestPermissions": [
{
"name": "ohos.permission.MICROPHONE",
"reason": "$string:microphone_permission_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
},
{
"name": "ohos.permission.CAMERA",
"reason": "$string:camera_permission_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
}
]
声明只说明应用可能使用该能力。真正的隐私体验还取决于申请时机。P01 明确告诉用户:麦克风“开始时询问”、相机“使用时询问”;实时字幕也只有在用户点击开始、页面取得 UIAbilityContext 后才调用 liveService.start()。
这避免了两个常见问题:用户刚打开应用就面对无法理解的权限弹窗;用户拒绝后,整个应用都不可使用。
三、拒绝权限必须有可用状态,而不是死循环弹窗
实时字幕把 permission_denied、unsupported 和 error 设计为独立会话状态。权限被拒绝时,主按钮显示“授权”,页面展示原因和“前往系统设置”;用户也仍可浏览已有字幕、板书和任务。
if (this.liveState === LiveSessionState.PERMISSION_DENIED) {
await this.recoverLiveMicrophonePermission();
return;
}
const hostContext: Context | undefined = this.getUIContext().getHostContext();
if (hostContext === undefined) {
this.liveFeedback = '无法取得页面上下文,暂时不能申请麦克风权限。';
this.liveState = LiveSessionState.ERROR;
return;
}
在设置页点击“管理”只更新权限状态,不会自动开始听写。这个细节能防止用户只是想检查授权,却意外触发录音采集。
隐私设计的正确目标不是让用户尽量授权,而是让“不同意”成为一条稳定、可理解、可恢复的产品路径。
四、按数据类型决定生命周期
项目没有把所有输入一股脑写进数据库,而是根据可追溯性与隐私成本分别处理:

| 数据 | 处理与保存策略 | 用户控制 |
|---|---|---|
| 原始麦克风音频 | 当前版本用于会话处理,不创建录音文件 | 可暂停/结束;“保存原始音频”开关不可用且保持关闭 |
| 实时字幕 | 结构化为时间、说话人、文本、重点状态 | 结束课堂后保存到本机;可导出、可删除 |
| 拍照/选图 | 通过 CameraPicker/PhotoViewPicker 取得输入并进行解码/OCR | 由用户主动发起;图片与 OCR 结果进入复核流程 |
| 板书 OCR | 保存复核后的结构化文字、置信度和来源 | 用户校对后写入;可导出、可删除 |
| AI 任务候选 | 保存标题、截止时间、来源与确认状态 | 编辑、拒绝、确认;未确认不进任务中心 |
| 正式任务 | RelationalStore 中的已确认结构化记录 | 完成、恢复、撤销确认、导出、删除 |
| 日志 | 记录生命周期、状态和错误摘要 | 不应写入原始音频、图片字节、字幕全文或密钥 |
不同生命周期能减少“为了以后可能有用”而长期保留原始材料的冲动。
五、原始音频默认不落盘要成为可验证事实
UserPreferences 中虽然存在 keepRawAudio,默认值为 false;P01 与 P11 的开关都处于不可用状态,并清楚显示“当前版本不创建录音文件 · 默认关闭 · 0 B”。这不是一个尚未实现的可选功能,而是当前版本的隐私边界。
导出模型又用 rawAudioIncluded 单独声明是否包含原始音频:
const payload: ClassroomDataExport = new ClassroomDataExport(
2,
new Date().toISOString(),
this.storageMode,
course,
transcript,
scans,
tasks,
false
);
return JSON.stringify(payload);
最后一个参数固定为 false,设置页也回读提示“不包含原始音频”。
若未来真的增加录音保存,不能只把开关改成可点。还必须补齐保存目录、文件生命周期、容量上限、加密、后台行为、删除覆盖范围、导出策略、权限说明和版本迁移,再单独进行隐私审查。
六、结构化结果进入本机 RelationalStore
应用启动时,EntryAbility 先初始化课堂数据;成功时使用 RelationalClassroomRepository,失败则保留内存降级。数据库名为 heard_classroom.db,安全等级配置为 S2。
四类核心表的职责清晰:
| 表 | 保存内容 | 不保存内容 |
|---|---|---|
courses |
课程标题、教师、教室、时间、进度、颜色 | 麦克风或图片原始流 |
transcript_segments |
时间、说话人、字幕文本、重点标记 | 原始音频帧 |
scan_notes |
板书标题、复核文本、置信度、来源 | 图片字节 |
tasks |
标题、截止时间、来源、确认与完成状态 | 模型中间张量 |
这套设计保留了用户真正需要回顾和行动的结构化内容,同时避免把所有原始输入永久化。需要注意的是,“本机保存”不自动等于“零风险”:设备解锁、备份策略、剪贴板和调试日志仍是独立边界,不能因为使用了 RelationalStore 就停止审查。
七、导出必须由用户主动触发,并让内容可检查
P11 的“导出 JSON 到剪贴板”不会在后台定时执行。用户点击后,页面调用 Service 聚合课程、字幕、板书和任务,构造带 schemaVersion、exportedAt、storageMode 的 JSON,再写入系统剪贴板:
const exportText: string = await this.service.exportClassroomData();
const data: pasteboard.PasteData = pasteboard.createData(
pasteboard.MIMETYPE_TEXT_PLAIN,
exportText
);
await pasteboard.getSystemPasteboard().setData(data);
this.notice = '课堂数据已导出为 JSON 并复制到剪贴板;不包含原始音频';
这种设计的优点是导出格式透明、可被用户检查,也方便备份与迁移。风险是剪贴板不属于应用私有存储:用户复制后,内容可能被粘贴到其他应用,部分系统环境也可能展示剪贴板历史。
因此生产版应继续补充:导出前显示数据范围、敏感提示和记录数量;必要时改用受控文件并允许用户选择保存位置;失败时保证课堂数据不变;绝不在后台静默复制。
八、删除全部数据需要二次确认与事务
删除入口先检查是否有数据,再进入确认态;真正删除只有在 allDataDeletionConfirmation 为真且当前没有其他数据操作时才执行。成功后还会重置实时会话、候选隐藏态、编辑态、最近确认 ID 和各页面选中证据,再重新读取数据。
Repository 对四张核心表使用同一个事务:
store.beginTransaction();
try {
await store.executeSql(`DELETE FROM ${TABLE_TASKS}`);
await store.executeSql(`DELETE FROM ${TABLE_SCANS}`);
await store.executeSql(`DELETE FROM ${TABLE_TRANSCRIPT}`);
await store.executeSql(`DELETE FROM ${TABLE_COURSES}`);
store.commit();
return true;
} catch (error) {
store.rollBack();
throw new Error('Failed to clear classroom data.');
}
事务保证不会只删掉任务却留下字幕,或只删课程导致其他表成为孤立记录。UI 同时明确说明:课程、字幕、板书和任务将永久删除;设置偏好保留;系统权限和应用外文件不受影响。
这里也要诚实区分“数据库删除”和“设备级不可恢复擦除”。当前实现能证明核心表记录被事务删除,不能自动证明底层闪存块被安全覆盖,也不能删除用户已经复制到剪贴板或导出到应用外的副本。
九、日志只记录状态,不记录课堂内容
隐私数据很容易从一条调试日志泄漏。项目当前可见的 hilog 调用主要记录 Ability 生命周期、数据初始化模式、页面加载、权限结果、相机/骨骼能力状态和错误摘要,没有主动格式化字幕全文、OCR 文本、图片字节或任务内容。
团队仍应建立强制规则:
- 不输出原始音频缓冲区或其 Base64;
- 不输出图片 URI、图片字节和完整 OCR 结果;
- 不输出字幕全文、任务标题、用户输入和剪贴板内容;
- 不输出 token、证书密码、签名材料或设备身份数据;
- 生产构建对错误对象做白名单映射,避免三方异常夹带敏感字段;
- 调试问题使用记录数量、状态枚举、耗时和匿名 ID,而不是内容本身。
“代码里没看到 console.log(text)”只是起点。每次引入新 Kit、Provider 或网络 SDK,都要重新审计它的默认日志行为。
十、没有上传代码,不等于可以写“永不联网”
当前 module.json5 的权限清单只看到麦克风和相机,课堂 Repository 也是本机 RelationalStore/内存实现,P11 文案说明课堂内容不在后台上传。基于现有代码,可以说“当前课堂数据主链路没有实现后台上传”。
但不宜把它扩大成“应用任何情况下绝不联网”:系统 Kit 的实现条件、未来依赖、应用更新、崩溃分析或后续云同步都可能改变边界。更严谨的公开表述是:
当前版本的课堂字幕、板书和任务由本机 Repository 保存;应用未实现课堂内容后台上传,导出只在用户点击后发生,且不包含原始音频。
如果未来增加云同步,必须把网络 Repository 作为新的数据源边界,补充服务端合约、鉴权、传输加密、删除同步、冲突处理、未成年人/教育场景合规和关闭后的本机降级,而不是在现有 Service 里悄悄发请求。
十一、从 P01 到 P11,要让承诺前后一致
隐私页负责“采集前说明”,设置页负责“使用中管理与退出”。两页承诺必须由同一组真实能力支撑:
| P01 的承诺 | P11 的控制 | 工程实现 |
|---|---|---|
| 点击开始后才申请麦克风 | 权限状态与管理入口 | LiveTranscriptionService 状态机 |
| 原始音频默认不保存 | 不可用的关闭开关、0 B | keepRawAudio = false、导出标记 false |
| 结构化结果仅写入本机 | 本机记录数量与存储模式 | RelationalClassroomRepository |
| 可暂停、拒绝权限 | 授权恢复但不自动开始 | permission_denied 与恢复路径 |
| 可删除全部课堂数据 | 二次确认、取消、永久删除 | Repository 事务清表 |
如果 P01 写了“随时删除”,P11 就不能只放一个无效按钮;如果导出实际包含音频,P01 也不能继续显示“0 B”。隐私文案不是营销层,它是需要被代码、状态和回读共同验证的产品合约。
十二、验收本机优先数据流的最小清单
发布前至少应做以下验证:
- 首次打开不主动申请麦克风或相机;
- 点击实时字幕后才进入麦克风授权;
- 拒绝权限后页面可继续使用,并能前往系统设置恢复;
- 权限管理不会自动开始听写;
- 暂停、结束、后台切换后采集资源正确释放;
- 文件系统中没有新建原始录音文件;
- 导出 JSON 的
rawAudioIncluded为false; - 导出只包含约定的结构化字段,失败不修改数据库;
- 删除前必须二次确认,取消后数据保持不变;
- 删除成功后课程、字幕、板书、任务都为 0,重启也不恢复种子数据;
- 设置偏好按产品约定保留,应用外文件和系统权限不被误删;
- hilog 中不出现真实字幕、OCR 全文、图片 URI、密钥或导出 JSON。
其中“没有录音文件”“重启不恢复”“日志无敏感内容”都需要真机文件、重启和日志回读,不能只看代码下结论。
十三、当前项目的已验证与未验证边界
从当前源码可以确认:
- 麦克风和相机权限用途已声明,页面按功能触发对应流程;
- 隐私说明、拒绝状态、授权恢复和设置管理形成可见路径;
- 原始音频保存开关默认关闭且不可用,导出明确标记不含原始音频;
- 课程、字幕、板书、任务由 RelationalStore/内存降级 Repository 管理;
- JSON 导出由用户点击触发,全部删除经过二次确认和数据库事务;
- 当前课堂主链路没有实现后台上传。
仍不能仅凭代码宣称:所有目标设备都不会产生临时媒体文件、底层删除达到物理不可恢复、所有系统 Kit 永不联网、所有异常对象都绝不夹带敏感字段。它们需要目标设备、系统版本、真实权限分支、文件系统和 hilog 的运行验收。
总结
本机优先是一条端到端数据流,而不是页面上的一句口号。它从采集前说明开始,经由按需权限、内存处理、结构化本机存储、人工确认、主动导出、二次确认删除和日志脱敏,最终让用户知道数据在哪里、何时产生、怎样带走、如何清理。
听见课堂当前最清晰的边界是:原始音频默认不落盘,课堂结构化记录进入本机 Repository,AI 候选未经确认不进入正式任务中心,导出和删除都由用户触发。下一篇将进入实时字幕会话状态机,讨论麦克风权限、开始、暂停、继续、结束和异常恢复如何保持一致。
更多推荐



所有评论(0)