# miao-app **Repository Path**: ling1314/miao-app ## Basic Information - **Project Name**: miao-app - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-07-29 - **Last Updated**: 2026-11-04 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # miao 鸿蒙 ArkTS 个人随身内容库,API 12 Stage 模式。 电脑是主要的内容生产与消费端,手机是随身携带的查阅与复习端。 `miao` 围绕「本地文件播放」和「电脑收集 → 手机同步」两条主线,把音乐、视频、文本阅读、单词本、摘录、笔记、录音等功能统一到一个个人内容库里。 ## 三阶段规划 ### 第一阶段:本地文件播放 + WiFi 传输 目标:让手机能独立播放本地文件,并通过 WiFi 与电脑互传文件。 - **文件库**:本地文件索引、分类浏览、Picker 导入、WiFi 上传,支持音乐/视频/文本/笔记等类型;保险箱("锁"标签)密码锁定隐藏文件 ✅。 - **音乐播放器**:扫描本地音频,支持列表、播放、后台播放、LRC 歌词与拖动校准 ✅。 - **视频播放器**:本地视频播放,B 站风格底部控制栏、手势快进/快退、倍速、锁屏、播放列表、本地 SRT 字幕切换 ✅。 - **文本阅读器**:本地 txt / markdown 阅读,支持进度记忆 ✅。 - **WiFi 传输**:局域网内电脑 → 手机上传文件,手机端接收并保存到沙箱 ✅。 ### 第二阶段:电脑收集,手机同步 目标:在电脑上收集的小体量内容,一键同步到手机端复习。 - **代码单词本**:从代码 / 文本中提取英文单词,生成释义、上下文例句。 - **~~句子库~~**:已并入摘录库(2026-10-19 定稿「不分库」:句子/短文/段落与文章共一张表,`sourceType` 轻标记,不再独立建模块)。 - **一键同步**:小 JSON / 文本数据通过 WiFi 增量同步,大体积文件不走同步。 ### 第三阶段:独立工具 目标:补齐一些日常小工具,按需使用。 - **AI 助手**:远程交互终端,WebSocket 连接电脑上的 client-main(miao-pro 体系),手机只做输入和显示。LAN 直连 + 云端中继双通道均已真机验证(含语音输入)。协议契约见 miao-pro《多端交互与协议.md》,开发历史见 `docs/archive/AI助手模块开发历史.md`。 - **备忘录**:文本 + 录音统一记录模块(原「记事本」「录音笔」合并,2026-10-27 定稿:同一功能不同承载方式),录音离线转写、一键移入保险箱、桌面卡片一键录音 ✅。方案见 `docs/记录模块调研与方案.md`。 --- ## 工程结构 ``` D:\00miao\miao-app ├── AppScope/ # 应用级配置 │ ├── app.json5 # bundleName、版本、图标、名称 │ └── resources/base/ │ ├── element/string.json │ └── media/app_icon.png ├── build-profile.json5 # 工程级编译配置(API 12) ├── hvigorfile.ts ├── oh-package.json5 ├── entry/ # 入口模块 │ ├── build-profile.json5 │ ├── hvigorfile.ts │ ├── oh-package.json5 │ └── src/main/ │ ├── module.json5 # 模块配置、权限、Ability、路由表 │ ├── ets/ │ │ ├── entryability/ │ │ │ └── EntryAbility.ets # 应用入口,加载首页 │ │ ├── components/ # 通用 UI 组件(助手导航栏、连接状态栏等) │ │ ├── models/ # 数据模型 │ │ ├── services/ # 业务服务(数据库、文件、播放、同步等) │ │ │ ├── ReadProgressService.ets # 文本阅读进度 │ │ │ └── WifiTransferService.ets # WiFi 文件传输 │ │ └── pages/ # 页面,按模块分子目录 │ └── resources/ │ ├── base/element/string.json │ ├── base/element/color.json │ ├── base/media/startIcon.png │ ├── base/profile/main_pages.json │ └── rawfile/books/ # 示例文本文件 └── README.md ``` ## 已规划模块 | 模块 | 阶段 | 说明 | |---|---|---| | 文件库 | 第一阶段 | 本地文件索引、分类浏览、导入与 WiFi 上传;保险箱(默认密码 111,可在设置中修改)✅ | | 音乐播放器 | 第一阶段 | 本地音频扫描与播放 ✅;LRC 歌词(解析/内嵌 offset/居中高亮)、拖动歌词校准对齐(2026-11-04) | | 视频播放器 | 第一阶段 | 本地视频播放 ✅ | | 文本阅读器 | 第一阶段 | 本地 txt/markdown 阅读,章节识别、分页、进度记忆、搜索、书签、字号与进度条调节 ✅ | | WiFi 传输 | 第一阶段 | 局域网文件上传,自动按类型分类,MD5 去重,手机端显示上传进度 ✅ | | 设置 | 持续完善 | 配置项列表页(点击进子页面);文件库密码修改 ✅ | | 代码单词本 | 第二阶段 | 电脑端提取,手机端复习 ✅(2026-10-24 重构:单一 familiar 六档、学新词/复习分离、拉取全量覆盖+推送进度双按钮、enrich v2 分层展示,详见 `docs/单词本重构方案.md`) | | ~~句子库~~ | 第二阶段 | **已并入摘录库**(2026-10-19 定稿「不分库」:句子/短文/段落作为 sourceType 标记进摘录库同一张表,不独立建模块) | | 摘录库 | 第二阶段 | 书店-书架模型:LAN 拉轻列表 → 勾选拉详情 → 覆盖式入库,离线只读 ✅(2026-10-19,仅 LAN 为定稿决策,真机验证通过) | | 一键同步 | 第二阶段 | 小数据增量同步 | | AI 助手 | 第三阶段 | 远程交互终端:连接管理 + 流式聊天 + 授权卡片 + 历史会话 + 语音输入,LAN 直连与中继双通道均已真机验证(2026-10-01)。协议见 miao-pro《多端交互与协议.md》;开发历史见 `docs/archive/AI助手模块开发历史.md` | | 备忘录 | 第三阶段 | 「记事本」「录音笔」合一(2026-10-27 定稿):文本/录音一张 `memos` 表混排,录音手动转写+可编辑校对+重新转写,移入保险箱(与文件库同密码),桌面 2×2 卡片一键直达自动开录,文本备忘可加密 ✅ | | 密信 | 第三阶段 | 纯本地加解密工具 + 口令管理的密钥库(2026-10-28 定稿):AES/RSA 密钥生成、口令包裹落库、NFC 写入/导入、备忘录文本加密、独立密文解密页;不做触发与发送。方案见 `docs/密信功能调研与方案.md` | ## 技术选型 - **API 版本**:HarmonyOS API 12 - **包名**:`com.hcy.miao` - **显示名称**:`miao` - **图标**:鸿蒙内置 Symbol 图标库(`sys.symbol.xxx`) - **数据库**:`relationalStore` - **状态管理**:页面级 `@State` - **路由**:`@kit.ArkUI` 的 `router` - **音频播放**:`@kit.MediaKit` 的 `AVPlayer` - **视频播放**:`@kit.MediaKit` 的 `AVPlayer` / `Video` 组件 - **录音**:`@kit.AudioKit` 的 `AudioCapturer` - **离线转写**:`@kit.CoreSpeechKit` 的 `speechRecognizer` - **加解密**:`@kit.CryptoArchitectureKit`(AES-256-GCM / RSA-2048-OAEP / PBKDF2) - **NFC**:`@kit.ConnectivityKit` 的 tag 前台读写(NDEF 文本记录) - **网络**:`@kit.NetworkKit` 的 HTTP / WebSocket 能力 ## 如何打开 1. 打开 DevEco Studio。 2. 选择 **Open**,定位到 `D:\00miao\miao-app`。 3. 等待 Hvigor 同步完成。 4. 选择模拟器或真机运行。 ## 构建命令 > 注意:SDK 路径随电脑不同可能不一样。下面给出两台常见环境的示例,请按实际安装位置调整。 > > - 电脑 A:`C:\sdk\DevEco Studio`,独立 JDK 路径为 `C:\sdk\jdk\17\ms-17.0.15` > - 电脑 B:`D:\sdk\DevEco Studio`,使用 DevEco 内置 JBR 路径为 `D:\sdk\DevEco Studio\jbr` > > 若遇 `PackageHap` 阶段报 `spawn java ENOENT`,通常是 hvigor 守护进程缓存了旧的 `JAVA_HOME`,建议加上 `--no-daemon`。 ### 电脑 A(C 盘 SDK + 独立 JDK) ```powershell cd D:\00miao\miao-app $env:DEVECO_SDK_HOME='C:\sdk\DevEco Studio' $env:JAVA_HOME='C:\sdk\jdk\17\ms-17.0.15' $env:PATH=$env:JAVA_HOME+'\bin;'+$env:PATH & 'C:\sdk\DevEco Studio\tools\hvigor\bin\hvigorw.bat' --no-daemon --mode module -p module=entry@default assembleHap ``` ### 电脑 B(D 盘 SDK + DevEco 内置 JBR) ```powershell cd D:\00miao\miao-app $env:DEVECO_SDK_HOME='D:\sdk\DevEco Studio' $env:JAVA_HOME='D:\sdk\DevEco Studio\jbr' $env:PATH=$env:JAVA_HOME+'\bin;'+$env:PATH & 'D:\sdk\DevEco Studio\tools\hvigor\bin\hvigorw.bat' --no-daemon --mode module -p module=entry@default assembleHap ``` ## 后台音频播放:从 Android 到鸿蒙的迁移理解 如果你做过 Android 音乐播放器,鸿蒙的后台播放机制一开始会觉得很别扭。这里把两个平台的核心差异对齐一下,方便借助 Android 经验来理解鸿蒙。 ### Android 的后台播放思路 在 Android 上,后台播放通常靠 **Service + Notification**: 1. 起一个 `Service`(前台服务),在里面放 `MediaPlayer`/`ExoPlayer`。 2. 启动前台服务时挂一个 `Notification`,用户下拉通知栏能看到播放控件。 3. 可选但推荐:接入 `MediaSession`,这样锁屏、蓝牙耳机、系统媒体控制中心都能控制你的 App。 所以 Android 的底线是:**有个前台 Service + Notification,系统就不太会杀你**;`MediaSession` 是锦上添花,但不做通常也能播。 ### 鸿蒙的后台播放思路 鸿蒙没有让你自己起一个长期 Service 的概念(Stage 模型下主要是 `UIAbility`),后台保活靠 **长时任务(Continuous Task)** 和 **AVSession 两者配合**: | 能力 | Android | 鸿蒙 | |---|---|---| | 播放实体 | `Service` 里的 `MediaPlayer`/`ExoPlayer` | `UIAbility` 或单例里的 `AVPlayer` | | 后台保活 | 前台 Service + Notification | `backgroundTaskManager.startBackgroundRunning(AUDIO_PLAYBACK)` | | 通知/控件 | 自己构建 `Notification` + 自定义按钮 | 系统自动生成后台任务通知;播控中心/锁屏由 `AVSession` 提供 | | 系统媒体会话 | `MediaSession`(推荐但非必须) | `AVSession`(后台播放场景下几乎是必须的) | | 配置 | `FOREGROUND_SERVICE` 权限 + `Service` 声明 | `ohos.permission.KEEP_BACKGROUND_RUNNING` + `backgroundModes: ["audioPlayback"]` | ### 最关键的差异:AVSession 不是可选的 在鸿蒙上,**只申请 `audioPlayback` 长时任务并不足以保证后台不被停**。系统需要识别“这个应用正在播放媒体”,而识别的入口就是 `AVSession`。 华为开发者文档的明确说明: > 如果应用未接入 AVSession,系统会在检测到应用退至后台时停止音视频播放。即使接入了 AVSession,还必须申请 AUDIO_PLAYBACK 类型的长时任务,否则应用进程会被挂起导致播放停止。**两者缺一不可。** 所以鸿蒙后台播放的完整链路是: ``` AVPlayer(实际播放) ↓ AVSession(告诉系统:我在播媒体,并接收系统播控指令) ↓ backgroundTaskManager.startBackgroundRunning(AUDIO_PLAYBACK)(申请长时任务,挂后台通知) ``` ### 本项目里的对应实现 - **播放实体**:`services/AudioPlayerService.ets` 中的 `media.AVPlayer` 单例。 - **媒体会话**:同文件中的 `avSession.AVSession` 创建、激活、元数据/状态同步。 - **后台任务**:同文件中 `backgroundTaskManager.startBackgroundRunning(..., BackgroundMode.AUDIO_PLAYBACK, ...)`。 - **权限与模式**:`entry/src/main/module.json5` 中的 `ohos.permission.KEEP_BACKGROUND_RUNNING` 和 `"backgroundModes": ["audioPlayback"]`。 - **回到播放页**:点击后台任务通知时,通过 `WantAgent.parameters.targetPage` 让 `EntryAbility` 重新加载或跳转到 `PlayerPage`。 ### 给 Android 开发者的速记 - 把 `AVSession` 当成 **必须做** 的 `MediaSession`,不做系统真会停你的音频。 - 把 `backgroundTaskManager.startBackgroundRunning` 当成 **前台 Service 的启动**。 - 把系统根据 `AVSession` 自动生成的播控卡片/锁屏界面,当成 Android 上 `MediaStyle Notification` 的鸿蒙自动版。 - 页面离开后播放器继续工作,不是因为页面没销毁,而是因为播放逻辑在全局单例服务里,和 Android 把播放器放 Service 是同一个思路。 ## 文本阅读器进展 - ✅ 本地 txt / markdown 解析与阅读 - ✅ 中文编码自动探测(BOM + UTF-8 严格校验 + GBK 回退) - ✅ 章节识别(多行正则 exec 单趟扫描 + 多规则同趟打分,支持 `第一章`、`第1章`、`Chapter 1` 等;markdown 按 `#` / `##` / `###` 标题切分并跳过代码围栏) - ✅ 章节列表落盘缓存(路径哈希 + 文件大小/mtime 校验,二次打开秒开) - ✅ 章节标题行剥离为居中大标题(h1),正文从第二行起,章节视觉切割清晰 - ✅ MeasureUtils 精确分页(单字符宽度表缓存,分页结果 = 渲染结果) - ✅ 大书友好架构(参考 Legado):章节只记偏移量,内容排版时才 substring;只排当前章 ±1 章滑动窗口,内存与书大小无关 - ✅ 页眉章节名 + 页脚「时间 + 本章 x/y + 全书百分比」(章内页码,不算全局总页数) - ✅ 翻页平移动画(跟手滑动 + 点击翻页),支持关闭动画 - ✅ 阅读进度记忆与恢复 - ✅ 目录、书签面板 - ✅ 字号 A+ / A-、行距、翻页模式调节,设置持久化 - ✅ 四主题:白纸 / 米色 / 护眼绿 / 夜间黑(对比度均 ≥4.5:1) - ✅ 底部百分比进度条,支持拖拽跳转 - ✅ 目录懒加载:LazyForEach + IDataSource 只渲染可视区,当前章二分查找一次定位高亮并自动滚动到位,几千章不卡 - ✅ 相邻章异步预排:翻页后空闲时先分块预热字符宽度(每 200 字让出主线程),再排版进缓存;settleWindow 只同步并入热缓存章节,冷章节不阻塞翻页帧 - ✅ 上一章末尾残留标题剔除:部分采集小说的标题在上一章末尾重复出现一次,排版时若章末最后一行等于下一章标题则剔除(stripTrailingNextTitle) ## 播放器进展 - ✅ 歌词:LRC 解析(一行多时间标签拆行、`[ti:]/[ar:]/[al:]` 元数据兜底显示、文件内 `[offset:±ms]`),当前行大字居中高亮 + 上下渐淡平移滚动 - ✅ 播放进度毫秒化(2026-11-04):`AudioPlayerService` 保留 `currentPositionMs`,歌词定位 = 毫秒进度 + 用户校准偏移(原为整秒 floor,亚秒级调节无响应) - ✅ 歌词拖动校准(2026-11-04):按住歌词上下拖动(拖动中暂停自动跟随、跟手不弹跳),松手以居中歌词行为准计算偏移并写入该首歌设置(`player_settings` Preferences,按歌曲路径区分,±30s 钳制,归零自动删键);校准后歌词区底部提示「已校准 提前/延后 x.xs · 点击重置」 ## 备忘录进展 - ✅ 「记事本」「录音笔」合并为统一备忘录模块(2026-10-27,方案见 `docs/记录模块调研与方案.md`):文本与录音同一张 `memos` 表,列表混排,旧 `notes`/`voice_memos` 数据自动迁移(一次性标记 `vault_config.memo_module_migrated`) - ✅ 文本:列表行 = 编辑页三合一,标题独立可编辑(留空自动取首行兜底),返回/完成自动保存,清空自动删除 - ✅ 标题:文本与语音均支持自定义标题;语音备忘标题留空时默认取备注首行,再兜底「录音 MM-dd HH:mm」;列表/详情/保险箱统一显示逻辑(`TimeUtils.formatRecordingTitle`) - ✅ 录音:WAV 16kHz/16bit/单声道,暂停/继续,实时音量条;保存与转写完全解耦(录音管线上无引擎,点完成秒存) - ✅ 转写:**手动触发**(2026-10-27 调整:录音管线不挂识别引擎,保存秒完成、开始录音也无引擎初始化等待);详情页点「转写」对 WAV 出文字(`transcribeWavFile`),文本可编辑校对;四态 UI(未转写/转写中/已转写/不支持)。2026-10-28 调整:取消「插入备注」(转写=全文、备注=浓缩,两者保持分离),已转写状态可加「重新转写」(二次确认防覆盖人工校对) - ✅ 保险箱:记录可移入保险箱(`in_vault` 标志,与文件库同密码体系),备忘录内保险箱页查看/移回/删除;入口覆盖列表多选、语音详情页、文本编辑页(2026-10-28 补齐,仅编辑已有记录时显示,先落库草稿再置标志位) - ✅ 桌面服务卡片「一键录音」(2×2,Form Kit):点按直达录音页自动开录;锁屏卡片待申请华为「锁屏卡片开放能力」 - ✅ 外部入口参数链路:`EntryAbility` 解析 Want 参数 → `AppLaunchStore` 暂存 → 冷启动直达 / 热启动经首页中转均可用 - ✅ 文本加密(2026-10-28,密信模块联动):编辑页选密钥+口令加密正文(标题明文保留);打开只提示「此备忘已加密」,输口令解锁(不显示密钥名,按密钥本体哈希匹配);解锁后可编辑、退出自动重加密;可解除加密;密钥误删可用 NFC/剪贴板备份重新导入恢复 - ✅ 编辑页键盘体验(2026-11-04,全局生效):键盘避让改 RESIZE 模式(`UIContext.setKeyboardAvoidMode`),弹键盘时布局重排——标题栏固定不被顶出、正文自动收缩到键盘上方(光标行始终可见,不用再按换行顶内容)、底部按钮区不丢失 - ⬜ V2:**一键推送电脑**(下一步,详见 `docs/记录模块调研与方案.md` §8 实施清单;既定约定:加密备忘推送时直接推密文)、转写文本搜索、锁屏卡片、实况窗 - ⬜ V3:AI 助手远程开录、电脑端转写兜底、AI 摘要 ## 密信进展 - ✅ 密钥库(2026-10-28,方案见 `docs/密信功能调研与方案.md`):AES-256 / RSA-2048 生成;口令(1~20 位)经 PBKDF2(10 万次) 派生 KEK + AES-256-GCM 包裹密钥本体落库,口令不落库;备注 ≤50 字;生成后强制引导备份(写入 NFC / 复制剪贴板) - ✅ NFC:NDEF 文本读写(前台 dispatch),密钥导出格式 `MK1..`;写入页明示「标签明文可读,物理占有=信任」 - ✅ 解密密文页(DecryptPage):独立于密钥库——粘贴 MX1 密文(自动识别算法与时间)+ 密钥(粘贴/NFC 碰一碰)→ 明文只读;不问口令不碰库 - ✅ 备忘录文本加密:见「备忘录进展」末条 - ✅ 说明页:三层设计意图 / AES·RSA 怎么选 / 口令机制 / NFC 用法 / FAQ - 密文格式:`MX1..<毫秒时间戳>.`(RSA 为信封加密) - ⬜ **待真机验证(2026-10-29)**:加密/解锁/解除加密闭环;NFC 写入→导入→解密闭环;密钥删除→重新导入恢复解锁;老数据升级迁移。注:2026-10-29 修复 GCM authTag 取值为零的 bug(update+doFinal 输出拼接末 16B 才是 tag,不读 spec 回填),**修复前创建的密钥/加密备忘全部作废需重建** - ⬜ 遗留:NFC 导入的 RSA 密钥只带私钥(可解密不可加密,MK1 未携公钥);NFC 标签内密钥再加密(NFC Safe 模式)留 V2;触发/发送机制(外设检测、AI 助手联动)属外部信号,与本模块解耦 ## 经验:录音开始即报 13900015(2026-10-27) ### 现象 点「开始录音」toast:`开始录音失败:code=13900015 File exists`,录音无法启动。 ### 原因 13900015 是 fileIo 基础错误码("File exists"),抛在 `RecordingService.prepare()` 的 `fileIo.mkdir(filesDir + '/voice', true)`:设备上该目录已由旧录音笔模块创建过,部分机型上 **递归 mkdir 对已存在目录仍抛 13900015**(该 bug 从旧录音笔代码原样继承)。 ### 解决 `prepare()` 改为「`fileIo.access` 先查再建」,建目录异常仅记日志不抛出;同名 PCM 残留先 `unlink` 再用 `TRUNC` 模式打开。关键代码:`services/RecordingService.ets` 的 `prepare()`。 ### 启示 - 错误码前缀即错误域:139000xx = 文件系统,680xxxx/540xxxx = 音频——按域可以直接圈定肇事调用。 - 「创建前先看存在性」对 mkdir/open 类调用是必备防御,尤其在老设备/老数据目录上。 ## 经验:录音三连 bug——双采集器竞态 + WAV 播放 + 转写重复(2026-10-27) ### 现象 录音「暂停后计时仍在走」「保存后音频无法播放」「转写文本几个字几个字重复」。 ### 根因 三个症状同一个根因:**录音 start/resume 无重入保护**,连续点击会创建第二个 AudioCapturer—— 旧采集器被引用覆盖成野实例(停不下来)、`timerId` 被覆盖导致旧计时器泄漏(暂停后时间照走)、 两路相同音频同时喂给识别引擎(转写逐字重复)、两个写句柄并发踩踏同一 PCM 文件(文件损坏)。 ### 修复(`services/RecordingService.ets`,两批) 第一批(busy 锁)未根治计时器问题后,第二批改为**从构造上杜绝**: 1. **唯一计时器**:整个录音生命周期只在 prepare() 创建一个 setInterval,且仅当 `state==='recording'` 才累加;start/pause/resume 不再创建/清除计时器(清除只在 stop)。 这样无论状态机怎么抖,都不可能有多计时器并行——「暂停照走/恢复加速」物理上消失。 2. **readData 缓冲区同步拷贝**:回调里的 ArrayBuffer 是系统复用缓冲,异步写必须用 拷贝件(`new Uint8Array(copy).set(...)`),否则下一段音频会覆盖还没写的内容—— 这是「WAV 损坏无法播放」的真凶(不是双写句柄)。 3. **PCM 写串行链**:`readData` 回调只把拷贝挂到 `writeChain` 按序执行,单块失败 catch 后续续写(链永不死);pause/stop 前 `await writeChain` 冲刷。 4. busy 操作锁(start/pause/resume/stop)+ 页面 `isPreparing` 防连点。 ### 另两个独立 bug - **沙箱音频播放**:`AVPlayer.url = 沙箱裸路径` 真机播不了,必须 `fileIo.open` 后走 `fdSrc`(AVFileDescriptor),释放时关 fd(对齐 AudioPlayerService 的做法)。 - **转写逐字重复**:`SpeechRecognitionResult` 有 `isLast` 字段——引擎对每句话会多次回调 onResult(中间结果是**当前句的完整假设而非增量**,会被下一次覆盖)。正确做法是只落定 `isLast=true` 的句终结果,中间假设替换式暂存、onComplete 兜底落定;相邻句完全相同去重。 直接 append 每个 onResult 就会产生「今天今天天气今天天气不错」式重复。 ### 后续修复(同日第二轮真机反馈) - **计时器根治**:唯一计时器在 prepare() 创建、只在 state==='recording' 累加、只在 stop() 清除; start/resume 不再创建计时器——泄漏从构造上不可能(busy 锁只能防点击竞态,防不了所有路径)。 - **WAV 真凶是 readData 缓冲区复用**:回调里的 ArrayBuffer 离开回调后会被下一段音频覆盖, 异步写文件必须先用 `new Uint8Array(copy).set(new Uint8Array(data))` 同步拷贝。 - **AVPlayer 5400102**:严格对齐 AudioPlayerService 的模式——fd:// 优先,prepare/play 由 stateChange('initialized') 驱动,**不要**在设置 url/fdSrc 后主动调 prepare();error 态回退 fdSrc 重建重试一次。沙箱裸路径直接赋 url 必失败。 - **暂停后续录转写只剩后半截**:`SpeechTranscriber.start()` 会清空累计文本,录音恢复要用 `newSession()`(新引擎会话、保留已落定句子);旧引擎顺手 shutdown 防实例堆积。 ### 启示 - 所有「异步多步 + 用户可连续点击」的操作(录音/暂停/继续/停止)必须配操作锁;状态判断 代替不了锁,因为 await 间隙状态还没变。 - 高频回调(readData)里的数据要么同步消费,要么先同步拷贝再异步处理——系统缓冲区会被复用。 - 接引擎类 API 先查结果回调的语义(增量 vs 全量、中间 vs 终态、会话结束是否清空状态),别默认 append。 - 播放器能跑就不要发明新写法:直接抄项目里已真机验证的 AudioPlayerService 模式。 ## 设置模块进展 - ✅ 配置项列表页:每行一个配置项(上方名称、下方补充信息),点击进入具体配置子页面 - ✅ 文件库密码:系统默认密码 `111`,设置页可修改(验证旧密码 + 两次新密码一致即可);密码 SHA-256 哈希存 `vault_config` 表 - 说明:WiFi 文件传输入口已并入文件库,不再作为设置项 ## 经验:阅读器分页“每页只显示一两句话” ### 现象 文本阅读器里,每页只能看到一两句话,像是按换行在分页。 ### 原因 `onAreaChange` 返回的组件宽高单位是 **vp**,但 `ReaderPage.buildPaginationStyle()` 里把字体、行高、边距全部用 `vp2px()` 转成了 **px**,而 `pageWidth / pageHeight` 仍保持 vp。分页服务拿到一个“vp 为单位的内容宽度”,却用一个“px 为单位的单字宽度”去计算每行字数,结果 `charsPerLine` 只有 5~6 个字符,一页自然只能装下一句话。 ### 解决 统一使用 **vp** 单位,并改用 `MeasureUtils.measureText()` 精确测量替代估算: - `onAreaChange` 返回的宽高本身就是 **vp**,直接使用,不要再 `px2vp()`(二次转换曾导致内容区缩为 1/3,“每页只有几个字”) - `MeasureOptions.fontSize` 传 number 时单位是 **fp**(数值与 vp 一致,不要 `vp2px()`);`measureText` 返回值是 **px**,需 `px2vp()` 换回 vp - 单字符宽度缓存:每个唯一字符只测一次(8 连写取平均),折行纯算术累加 关键代码: - `entry/src/main/ets/pages/reader/ReaderPage.ets` 中的 `buildPaginationStyle()` / `measureTextWidth` - `entry/src/main/ets/services/BookPaginationService.ets` ### 启示 做文本测量、分页、布局计算时,必须先确认每个数值的单位。`Area.width/height` 是 vp,`MeasureUtils` 返回 px、fontSize 入参是 fp,混用会产出非常离谱但很难一眼看穿的 Bug。 ## 经验:大书(10MB+)打开卡死 ### 原因 对全书做了一次性操作:多规则章节识别各 split 一遍全文(12MB 文本 ×6 次 split + 近百万次正则)、parse 时为每章预生成 TextBlock、后台预排全书上万页对象塞进 @State 数组。 ### 解决(参考 Legado) 对全书零一次性操作:章节识别用多行模式正则 exec 循环(不 split、不逐行 trim,零行字符串分配);章节列表落盘缓存(二次打开秒开);章节只记偏移量,排版时才 substring 内容;只排当前章 ±1 章滑动窗口;页码用章内页码 + 全书百分比(按 charOffset),不计算全局总页数。 另:`@Builder` 函数参数按值传递是非响应式快照,页码等状态必须以对象字面量按引用传参(`{ idx: this.topIndex }`),否则翻页后内容不刷新。 ### 已解决(2026-10-19) 章节间连续翻页卡顿已通过「异步预排」解决:不再在章边界同步排版冷章节,而是每次翻页后空闲时预排 ±1 章(先分块预热字符宽度表再排版),settleWindow 只并入已命中缓存的章节;窗口边缘的 extendWindow 保留为同步兜底(此时缓存通常已热)。目录卡顿由「LazyForEach 懒加载 + 当前章二分查找一次定位」解决(原 ForEach 全量建节点 + 每项 O(N) 线性查找是 O(N²) 热点)。 ### 启示 - 长列表必须用 `LazyForEach + IDataSource`;`List + ForEach` 会一次性创建全部子节点。 - 排版/测量只能在 UI 线程(MeasureUtils 限制),「异步」的正确姿势是**主线程拆帧让出**(setTimeout 分块),不是挪线程;预排时机要提前到翻页动画结束后,而不是到窗口边缘才排。 ## 经验:中文 TXT 乱码与编码自动探测 ### 现象 导入部分 txt 电子书后,阅读页显示乱码或方块。 ### 原因 `FileService.readTextFile()` 之前把文件一律按 UTF-8 解码。很多中文网络小说是 **GBK/GB2312/GB18030** 编码,用 UTF-8 解码就会得到乱码。`fatal: false` 还会把无法识别的字节静默替换成 `�`,更难排查。 ### 解决 在 `FileService.readTextFile()` 中增加编码探测: 1. 先根据文件头 BOM 判断 UTF-8 / UTF-16LE / UTF-16BE / UTF-32LE / UTF-32BE。 2. 无 BOM 时,取前 8KB 用 UTF-8 严格模式校验;校验通过则按 UTF-8 解码,否则按 **GBK** 解码。 3. 解码时设置 `ignoreBOM: true`,避免 BOM 字符进入正文。 关键代码:`entry/src/main/ets/services/FileService.ets` 中的 `detectTextEncoding()` 与 `isValidUtf8()`。 ### 启示 文本文件导入不能假设编码。对于中文内容,至少要做好“UTF-8 / GBK”双分支:优先用严格 UTF-8 校验,失败再回退 GBK。 ## 经验:Core Speech Kit TTS 只支持中文引擎(2026-10-03) ### 现象 单词本点小喇叭发音,`textToSpeech.createEngine({ language: 'en-US', person: 0, online: 1 })` 直接抛 **错误码 1002300005(Create engine failed,引擎不存在/资源缺失/初始化超时)**,真机上必现。 ### 原因 华为官方文档明确说明:**Core Speech Kit 的文本转语音当前仅支持中文语种(聆小珊音色)**。设备上根本不存在 en-US 引擎,按英文参数创建必然 1002300005。这不是参数写法问题,是平台能力边界。 ### 解决 `TextSpeechService.createEngine()` 使用**回退链**逐个尝试: ``` en-US online=1 → en-US online=0 → zh-CN online=1 → zh-CN online=0 ``` 中文引擎可以混读英文单词(可能带口音,但能发音),可用性优先于音色。全部失败时把所有尝试的错误码汇总给页面 toast,方便远程定位。关键代码:`entry/src/main/ets/services/TextSpeechService.ets` 中的 `createEngine()`。 ### 启示 - 调华为 AI 类 Kit 前先查「支持的语种/音色」清单,不要假设英文可用。 - 引擎类资源创建要有回退链和重试(`createEngine` 失败后必须清空缓存的 Promise,否则一次失败永久哑)。 - 若设备连 zh-CN 引擎都没有(非华为系 OpenHarmony),系统 TTS 无解,需改为服务端合成音频下发。 ## 经验:BusinessError 不能 JSON.stringify(2026-10-03) ### 现象 `console.error(JSON.stringify(err))` 对系统 API 抛出的 `BusinessError` 只输出 `{}`,日志里看不到任何错误码,远程排障完全瞎猜。 ### 原因 `BusinessError` 的 `code` / `message` 属性**不可枚举**,`JSON.stringify` 序列化不到。 ### 解决 统一用工具方法提取(`err as BusinessError` 后拼 `code=${be.code} ${be.message}`),toast 与日志都用它。本项目里 `TextSpeechService.errText()`、`WordListPage.errText()` 各有一份。 ### 启示 凡是需要用户把错误反馈回来的路径,**第一时间把真实错误码暴露到 UI**(toast 截屏即可回传),否则一个“删除失败”要多花一整轮 构建→重装→反馈 才能定位。 ## 经验:@State 数组直接传给 RDB in() 报 401(2026-10-03) ### 现象 词表多选删除调用 `predicates.in('id', this.selectedIds)`,抛 **401: the value must be a valueType array**。`selectedIds` 明明是 `string[]`。 ### 原因 `@State` 装饰的数组是 ArkUI 的**代理对象**,不是纯 JS 数组;RDB napi 层的参数校验不认可它。 ### 解决 在 service 层(`WordBookService.deletePhysical` / `setMastered`)把 id 逐个 push 进新建的 `relationalStore.ValueType[]`,再传给 `in()`。凡是“@State 数组 → napi 接口”的跨界传递都要这么做。 ## 经验:API 12 SpeakListener 的接口约束(2026-10-03) - `textToSpeech.SpeakListener` 中 **`onStop` 是必选回调**,只写 onStart/onComplete/onError 编译不过。 - `StartResponse` **没有 uuid 字段**(早期文档示例里有,API 12 已移除),照抄示例会编译报错。 ## 经验:AI 助手与 ArkUI 状态管理(2026-09-20 整理,源自已归档的 AI 助手开发文档 §17) > 本节按「现象 → 根因 → 正确做法」组织,全部是真机/编译实踩。核心主线:**ArkUI 的刷新完全由装饰器依赖追踪驱动,任何绕过追踪的写法都会表现为「数据变了 UI 不变」**。 ### 17.1 ForEach 同 key 不刷新(流式聊天卡「…」) - **现象**:AI 回复气泡永远停在占位符「…」,数据其实已经到了。 - **根因**:ForEach 键值相同即假定数组项不变、不重新渲染(官方文档原话:「只要键值相同,数组项的值就假定不变」「数组项值更改,则 ID 必须更改」)。「克隆对象 + 同 key 替换数组项」无效。 - **正确做法**(三选一): 1. 数据项用 `@Observed class`,子组件用 `@ObjectLink` 接收,更新时直接改属性 ← 本项目采用 2. 把版本号编进 key 强制重建(`msg.id + version`) 3. 简单场景直接整体替换数组 ### 17.2 自定义组件成员不加装饰器 = 只接收一次初始值 - **现象**:侧栏历史会话列表永远是空,父组件数据明明加载到了。 - **根因**:`@Component` 里无装饰器的成员变量**只在组件初始化时接收一次父组件传值**,之后父组件 `@State` 更新不会同步。 - **正确做法**:父传子必须加装饰器——`@Prop`(单向拷贝)、`@Link`(双向)、`@ObjectLink`(配合 @Observed class)。 ### 17.3 @Builder 两个限制 - **不能传对象参数**:参数是按值快照,对象重赋值后快照不更新;外层 `if (x !== null)` 条件重算结果不变时分支也不重建。表现为「点了授权按钮卡片不消失」。正确做法:Builder 内部直接读 `this.xxx`,让依赖追踪生效。 - **不能声明变量**:`const req = this.msg.authRequest` 报 `10905209 Only UI component syntax can be written here`。空安全写法:`if (this.msg.xxx !== null)` 守卫后直接读属性(TS 属性收窄生效)。 ### 17.4 @Observed 嵌套对象要整体重赋值 - `@Observed class` 的普通属性直接改即可触发刷新;但嵌套对象/数组字段(如 `authRequest`、`toolCalls`)**必须整体重赋值**才被观测:`this.msg.authRequest = { ...旧字段逐一拷贝..., resolved: true }`。 ### 17.5 键盘避让(聊天页输入框贴键盘) - 默认 `OFFSET` 模式会把整个页面顶上去;聊天页要的是 `RESIZE`(顶栏不动、消息区压缩、输入栏贴键盘上浮)。 - 注意 `KeyboardAvoidMode` 是 **`@kit.ArkUI` 顶层导出**,不是 `window.KeyboardAvoidMode`: ```typescript import { window, KeyboardAvoidMode } from '@kit.ArkUI'; window.getLastWindow(getContext(this)) .then(win => win.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)); // 页面离开时恢复 OFFSET,避免影响其他页面 ``` ### 17.6 SideBarContainer 的 autoHide 不可靠 - 覆盖式侧栏(`SideBarContainerType.Overlay`)设了 `autoHide(true)`,但内容区有 List 等消费触摸的组件时,点空白不收起。 - 解法:内容区包一层 `Stack`,侧栏展开时盖半透明遮罩,`onClick` 收起。 ### 17.7 轻量持久化用 preferences - 存单个键值(如当前会话 id)用 `@kit.ArkData` 的 `preferences`,比 relationalStore 轻得多:`preferences.getPreferences(ctx, { name: 'chat_prefs' })` → `put` → `flush()`(**不 flush 不落盘**)。 ### 17.8 图标优先用系统符号 - `SymbolGlyph($r('sys.symbol.xxx'))` 免资源文件、不怕缺失;配 `renderingStrategy(SymbolRenderingStrategy.SINGLE)` 单色渲染。 ### 17.9 与 Go 后端的 JSON 契约坑 - Go 结构体 `json:",string"` 标签会把数字序列化成**字符串**(如雪花 id、毫秒时间戳),前端模型字段要按 string 声明,展示时间用 `Number(raw)` 转回。 - 跨端字段结构以服务端代码为准,不要猜(toolCalls 落库是 OpenAI 风格 `[{id, function:{name, arguments}}]`)。 ### 17.10 编译验证命令(电脑 B) ```powershell cd D:\00miao\miao-app $env:DEVECO_SDK_HOME='D:\sdk\DevEco Studio' $env:JAVA_HOME='D:\sdk\DevEco Studio\jbr' $env:PATH=$env:JAVA_HOME+'\bin;'+$env:PATH & 'D:\sdk\DevEco Studio\tools\hvigor\bin\hvigorw.bat' --no-daemon --mode module -p module=entry@default assembleHap ``` - `--no-daemon` 必须;`assembleHap` 内含 CompileArkTS;构建需几分钟,建议后台跑。 ### 17.11 ResultSet.rowCount 计数不可靠(2026-10-26) - **现象**:单词本拉取 764 词成功、词表页能列出全部,但首页统计全是 0。 - **根因**:`query(predicates, ['id'])` 后直接读 `resultSet.rowCount`,在「单列投影 + 多谓词」查询下返回 0——而单谓词查询又正常(total 对、newCount 错),极难排查。 - **正确做法**:计数一律遍历(`goToFirstRow()` + `do…while(goToNextRow())`),且与列表查询**同路径**(同样的全列投影),消灭「查询方式不同导致结果不同」的变量。关键代码:`services/WordBookService.ets` 的 `countBy()`。 ### 17.12 ArkTS 结构错误的报错位置偏离真实原因(2026-10-26) - **现象**:调整 UI 嵌套结构后编译报 `10605008 arkts-no-any-unknown`,报错位置指在根 Column 的 `.width()` 修饰链上。 - **根因**:与类型无关——是重构时少删了一个旧 `}`,if/else 链提前闭合、根 Column 被提前关闭,后面悬空的修饰链被编译器当成 any/unknown。 - **正确做法**:遇到 UI 结构类报错**先数括号**(每个组件块开闭配对、每条 if/else 链闭合),别轻信报错的行列号——这类错误的报错位置几乎总在「受害处」而非「肇事处」。 ## 给你的学习提示 - 所有页面都在 `entry/src/main/ets/pages/` 下,按模块分子目录。 - 数据模型在 `models/`,业务服务在 `services/`,跨页面复用的 UI 组件在 `components/`。 - 文件库已接入 `FileService` + `MediaService`,使用沙箱真实文件与数据库索引;支持 Picker 导入、WiFi 上传、扫描本地目录。 - 录音离线转写依赖设备是否内置离线 ASR 引擎;若不支持,转写结果为空,但 WAV 文件仍会保存。 - 若文件库列表为空,请确认安装的是最新 HAP,并检查 Log 中 `LibraryPage` 标签的过滤日志。