
之前做鸿蒙应用时我一直在想一个问题阅读工具除了“能看书”还能不能帮读者“真正看懂书”尤其是一些大部头、多视角、长线伏笔的小说经常出现“看后面忘前面”的困境。后来我把想法落地成了一个叫“溯阅”的鸿蒙原生应用——导入本地小说后用AI助手自动生成前情提要、人物关系、时间线以及伏笔追踪。比较特别的一点是整个应用坚持“数据本地优先”小说文件、解析结果、AI生成内容都留在用户设备上隐私方面让人安心。这篇文章会从头拆解“溯阅”的核心设计思路和实现方案覆盖文件导入、本地文本解析、AI结构化生成、本地存储等关键环节如果你也在做鸿蒙AI应用或者想了解“本地优先”的产品技术方案这篇应该能给你一个比较完整的落地方案。1. 为什么需要“本地优先”的鸿蒙 AI 阅读助手1.1 背景需求复杂剧情阅读的“记忆负担”科幻、史诗奇幻、群像谍战这类小说动辄几百万字人物几十个时间线跨越数年伏笔埋了数十章才回收。读者阅读时的核心痛点很明确隔一段时间继续读忘记前面的关键情节。人物别名太多分不清谁是谁。时间线跳跃事件前后关系混乱。作者埋的伏笔太多读到后面才想起来前面早有暗示。“溯阅”要解决的问题就是把这些隐性信息显性化让读者在打开书的瞬间就能快速恢复“阅读上下文”。1.2 “溯阅”能做什么“溯阅”的核心能力可以归纳为四个模块模块作用典型输出前情提要生成章节级、卷级摘要“截止第 23 章主角已获得信物但尚未知晓其真实用途”人物关系抽取人物、别名、关系“林澈 → 师父 → 沈渊实际身份为锦鲤族后裔”时间线与伏笔按章节记录重要事件和伏笔状态“第 7 章出现铜铃铛第 34 章铜铃铛再次出现并呼应”剧情问答结合本地内容回答读者疑问“为什么主角不能靠近祖祠因为祖祠封印与血统相关”这些能力并不需要联网才能用反而在“本地优先”模式下AI 可以拿到全文级、高质量的上下文生成结果更连贯。1.3 数据本地优先隐私、可用性、可迁移“数据本地优先”不是不做云端而是把用户数据的主权放在设备端。这个设计有几个直接收益隐私安全小说文件、阅读记录、AI 生成内容都不离开设备避免用户私人阅读数据被平台收集。离线可用地铁、飞机等无网络环境下前情提要和人物关系依然能读取。稳定性不依赖服务端接口服务端抖动不会影响阅读体验。可迁移用户可以通过本地备份导出数据甚至可以自行解析数据库迁移到其他设备。从架构上看“本地优先”也倒逼开发者把数据模型、缓存策略、AI 输出管理做得更规范这对应用长期迭代是有利的。2. 技术方案选型与整体架构2.1 技术栈概览“溯阅”是鸿蒙原生应用技术选型上尽量使用 HarmonyOS NEXT 提供的能力层面选型说明开发语言ArkTS基于 TypeScript 的鸿蒙应用开发语言UI 框架ArkUI声明式 UI 开发范式文件选择系统 FilePicker由用户主动选择小说文件避免扫描全盘本地存储RDB关系型数据库 PreferencesRDB 存结构化数据Preferences 存配置项AI 能力接入层抽象 本地模型/自部署服务通过接口隔离方便替换底层模型并发处理TaskPool / Worker解析长文本时避免阻塞 UI 线程这里需要说明HarmonyOS API 在不同版本中可能存在命名差异本文以设计思路为主代码重点是展示实现方案实际接入时以你本地的 SDK 版本为准。2.2 整体数据流“溯阅”的数据流可以分成三个阶段导入阶段用户通过 FilePicker 选择本地小说文件。解析阶段读取文本识别章节做分块和索引。生成阶段AI 基于章节文本生成结构化信息写入本地数据库。UI 层只负责展示和交互不直接依赖 AI 供应商的具体实现所有 AI 请求都通过IAIService接口转发。2.3 模块拆分从代码组织上我会把工程分成几个模块entry/src/main/ets/ ├── pages/ # 页面与 UI 组件 ├── model/ # 数据模型 ├── service/ # 解析、AI 服务、存储服务 ├── database/ # RDB 建表与访问 └── common/ # 常量、工具函数把服务和 UI 分离后续加功能、换 AI 模型、调整数据库结构影响面都更小。3. 环境准备与工程初始化3.1 开发环境开发“溯阅”需要准备以下环境DevEco Studio鸿蒙应用开发 IDE。HarmonyOS SDK建议使用当前稳定版本。支持 HarmonyOS 的设备或模拟器。如果使用模拟器建议优先在 ARM64 平台运行避免部分能力不支持。版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路不绑定具体版本号。3.2 创建 HarmonyOS 工程在 DevEco Studio 中创建一个 Empty Ability 工程选择“Application” “Empty Ability”。填写项目名称SuYueReader。选择设备类型为 Phone。语言选择 ArkTS。创建完成后会生成entry模块后续主要代码都在entry/src/main/ets下编写。3.3 安装依赖因为“溯阅”主要用系统能力和接口抽象第三方依赖很少。如果需要用关系型数据库直接使用系统提供的kit.ArkData如果后续要接本地模型或其他推理框架再按对应 SDK 文档添加依赖。我这里不建议为了“方便”引入大量第三方库鸿蒙应用包体和权限管理都会更简单。4. 核心功能实现文件导入与本地文本解析4.1 用户主动选择小说文件隐私优先的第一步是文件获取方式。不要做“扫描存储卡所有 txt”这种功能而是让用户通过系统 FilePicker 主动选择文件。在 ArkTS 中可以这样唤起文件选择器import { picker } from kit.CoreFileKit; async function pickNovelFile(): Promisestring { const documentPicker new picker.DocumentViewPicker(); const result await documentPicker.select({ maxSelectNumber: 1, fileSuffixFilters: [.txt, .epub] }); if (result result.length 0) { return result[0].uri; } return ; }代码解释DocumentViewPicker是系统提供的文档选择器用户主动选择后我们才能拿到 URI。fileSuffixFilters用来过滤文件类型这里允许 txt 和 epub。拿到的是 URI不是直接的文件路径后续需要通过文件服务打开。注意不同 SDK 版本的pickerAPI 可能略有差异如果编译报错优先查看当前 SDK 的 kit 模块文档。4.2 读取文件内容与编码处理选择文件后我们拿到 URI需要通过fileIo打开并读取内容。常见问题是中文 txt 乱码这是因为文件编码可能是 UTF-8、GBK、GB18030 等系统默认读取不一定是 UTF-8。一种稳定的做法是先读取前几个字节判断 BOM再根据编码信息尝试解码。伪代码如下import { fileIo } from kit.CoreFileKit; async function readTextFile(uri: string): Promisestring { const file fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY); const stat fileIo.statSync(uri); const buffer new ArrayBuffer(stat.size); fileIo.readSync(file.fd, buffer); fileIo.closeSync(file); return decodeText(buffer); } function decodeText(buffer: ArrayBuffer): string { const bytes new Uint8Array(buffer); // 判断 BOMUTF-8 BOM 为 EF BB BF if (bytes[0] 0xEF bytes[1] 0xBB bytes[2] 0xBF) { return new TextDecoder(utf-8).decode(buffer); } // 如果没有 BOM先按 UTF-8 解码再处理可能的 GBK 内容 const utf8Text new TextDecoder(utf-8).decode(buffer); if (utf8Text.includes(\uFFFD)) { // 存在替换字符说明大概率不是 UTF-8需要按 GBK 处理 return decodeGBK(buffer); } return utf8Text; }这里我不展开 GBK 的完整解码实现实际项目中可以用系统文本转换能力也可以引入支持 GBK 的解码库。关键是要在导入阶段就解决编码问题否则后续 AI 解析会拿到一堆乱码。4.3 章节切分与结构化存储章节识别是“前情提要”的基础。不同小说章节格式不一样常见有“第1章 xxx”“第一章 xxx”“第 12 节”“Chapter 1”“卷一 风起”建议用多个正则规则组合识别而不是写死一个模式。const chapterPatterns [ /^\s*第[0-9一二三四五六七八九十百千零两][章节卷回篇][^\n]*$/m, /^\s*(Chapter|CHAPTER)\s\d[^\n]*$/m, /^\s*[0-9]\.[^\n]{2,30}$/m ]; function splitChapters(content: string): ChapterItem[] { const lines content.split(/\r?\n/); const chapters: ChapterItem[] []; let currentTitle 前言; let currentContent: string[] []; for (const line of lines) { const trimmed line.trim(); if (chapterPatterns.some(pattern pattern.test(trimmed))) { if (currentContent.length 0 || chapters.length 0) { chapters.push({ title: currentTitle, content: currentContent.join(\n) }); } currentTitle trimmed; currentContent []; } else { currentContent.push(line); } } if (currentContent.length 0) { chapters.push({ title: currentTitle, content: currentContent.join(\n) }); } return chapters; }这个方法会返回一个章节数组每个元素包含标题和正文。“前言”作为开头的默认章节方便处理没有明确章节名的内容。4.4 长文本解析的并发处理几十万字的 txt 如果是全量解析在主线程做会卡 UI。推荐用 TaskPool 或 Worker 把解析任务放到后台线程。import { taskpool } from kit.ArkTS; Concurrent function parseTask(content: string): ChapterItem[] { return splitChapters(content); } async function parseInBackground(content: string): PromiseChapterItem[] { const task new taskpool.Task(parseTask, content); return await taskpool.execute(task) as ChapterItem[]; }这里要说明并发任务里的函数需要支持序列化参数适合传字符串和返回简单对象。复杂的类实例不建议直接传。分割完成后再把章节详情写入数据库。5. 核心功能实现AI 助手生成前情提要、人物关系、时间线与伏笔5.1 AI 接入层设计AI 部分是“溯阅”最有价值的地方。考虑到数据本地优先我不会把原始文本直接发给第三方服务而是先做一层接口抽象export interface IAIService { generateSummary(params: SummaryParams): PromiseSummaryResult; extractCharacters(params: CharacterParams): PromiseCharacterItem[]; extractTimeline(params: TimelineParams): PromiseTimelineEvent[]; traceForeshadowing(params: ForeshadowParams): PromiseForeshadowItem[]; }这样做的好处后续可以替换不同模型供应商不影响核心业务代码。如果是本地模型实现类内部直接加载模型不产生网络请求。如果是自部署服务实现类内部添加鉴权、超时、重试逻辑。一个本地模型实现的示例思路export class LocalAIService implements IAIService { async generateSummary(params: SummaryParams): PromiseSummaryResult { const prompt buildSummaryPrompt(params); // 调用本地模型进行推理这里省略具体 SDK 调用 // const output await localModel.infer(prompt); // 解析结构化输出并返回 return new SummaryResult(/* output */); } }实际项目中你可以选择端侧推理框架也可以调用自建的模型服务。只要把提示词工程做好生成的稳定性就能保证。5.2 前情提要与摘要生成策略前情提要不能全量把几十万字塞进模型窗口限制和计算开销都是问题。推荐使用“章节级摘要 滚动压缩”的策略先对每个章节生成 200 字以内的摘要。每 5 章再把章节摘要合并生成卷级摘要。读者打开 App 时优先展示最近阅读位置的摘要。示例提示词结构请根据以下小说章节内容生成一份前情提要 1. 主要事件按顺序列出关键事件。 2. 当前冲突说明目前未解决的矛盾。 3. 名词解释解释本章出现的关键名词、组织、地点。 要求语言简洁不超过 300 字。 章节内容 {chapterText}伪代码function buildSummaryPrompt(chapterText: string): string { return 请根据以下小说章节内容生成一份前情提要 1. 主要事件 2. 当前冲突 3. 名词解释 要求语言简洁不超过 300 字。 章节内容 ${chapterText} ; }如果你是开发者建议把这类提示词模板放到单独的配置文件或常量文件里不要散落在业务代码中方便后续调优。5.3 人物关系抽取与存储人物关系抽取的难点在于“别名识别”和“关系更新”。小说里一个人可能多个称呼本名、绰号、化名、身份称谓。我的方案是先让 AI 抽取本章出现的人物并给出别名字段。将别名合并到已有的实体库中而不是每次新建。关系用“主语—谓语—宾语”三元组表示方便后续可视化。示例数据结构interface CharacterItem { name: string; aliases: string[]; firstAppearance: number; // 章节号 description: string; relations: Relation[]; } interface Relation { target: string; type: string; // 例如师父、敌人、恋人 sourceChapter: number; }生成人物之间的图谱时优先在本地合并减少 AI 重复计算。可以把人物实体和三元组存到 RDB 表中UI 层需要时再拉取渲染。5.4 时间线与伏笔追踪时间线追踪的技术思路和人物抽取类似但需要更强调“事件的时间锚点”。有的小说有明显的时间描述比如“三天后”“次年春天”AI 需要结合上下文推算事件顺序。所以提示词里要明确要求请从章节中抽取出所有重要事件并按发生顺序排序。 事件格式 - 时间描述原文中的表述 - 事件内容 - 涉及人物 - 所在章节伏笔追踪会稍微复杂一些。伏笔不是一次性事件而是一个“未闭合状态”。我采用一种状态管理方式状态含义已埋下出现了可疑的物件、台词、细节被提及该伏笔在后续章节再次出现已回收真相揭示伏笔闭合疑似未收小说尚未完结或读者判断可能没有回收AI 在每章生成时会读取前面的伏笔列表判断本章是否与之前伏笔相关。这一步很考验模型能力为了节省成本可以在生成摘要时附带“伏笔检查”任务而不是单独再调一次。5.5 AI 输出结果缓存与失败重试AI 调用可能失败也可能输出非法格式。所以要封装统一的结果处理和缓存策略class AICacheManager { private cache: Mapstring, CacheEntry new Map(); async getOrCreateT(key: string, create: () PromiseT): PromiseT { const cached this.cache.get(key); if (cached !cached.expired) { return cached.data as T; } try { const data await create(); this.cache.set(key, { data, expired: false }); return data; } catch (e) { // 失败时返回缓存结果如果没有缓存则抛出 if (cached) { return cached.data as T; } throw e; } } }这个设计的好处是即使 AI 服务临时不可用用户依然能读到上一次成功生成的前情提要体验不会突然断裂。生成结果也建议持久化到本地数据库而不是只放内存。6. 数据本地优先的存储方案6.1 数据库表设计“溯阅”使用 RDB 存储结构化数据主要表如下CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, author TEXT, file_uri TEXT NOT NULL, file_path TEXT, chapter_count INTEGER DEFAULT 0, created_at INTEGER, last_read_chapter INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS chapters ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, chapter_index INTEGER NOT NULL, title TEXT, content TEXT, summary TEXT, word_count INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS characters ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, name TEXT NOT NULL, aliases TEXT, description TEXT, first_appearance INTEGER, relation_json TEXT ); CREATE TABLE IF NOT EXISTS timeline_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, chapter_index INTEGER, time_desc TEXT, event_desc TEXT, related_characters TEXT ); CREATE TABLE IF NOT EXISTS foreshadowing_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, chapter_index INTEGER, keyword TEXT, description TEXT, status TEXT DEFAULT 已埋下 );关系型数据库的好处是事务支持好批量写入数据时可以保证一致性。relation_json字段可以存人物关系的 JSON 串展示时再解析避免大量关联查询。6.2 读写封装RDB 访问建议封装成独立 Repository页面层不直接写 SQL。例如export class BookRepository { private store: relationalStore.RdbStore; constructor(store: relationalStore.RdbStore) { this.store store; } async insertBook(book: Book): Promisenumber { const values new relationalStore.ValuesBucket(); values.put(title, book.title); values.put(author, book.author); values.put(file_uri, book.fileUri); values.put(created_at, Date.now()); const rowId await this.store.insert(books, values); return rowId; } async queryChapters(bookId: number): PromiseChapter[] { const predicates new relationalStore.RdbPredicates(chapters); predicates.equalTo(book_id, bookId); predicates.orderByAsc(chapter_index); const resultSet await this.store.query(predicates); // 遍历 resultSet 转成对象 return []; } }注意实际使用时query返回的ResultSet需要遍历并关闭避免资源泄漏。6.3 隐私保护与安全存储数据本地优先不代表不做任何保护。建议至少做到小说内容以明文存储时考虑应用沙箱目录目录权限严格限制。涉及用户笔记、AI 生成的私有信息可以用系统密钥管理能力做加密或者提示用户开启系统级加密。不要在日志中打印小说全文、用户阅读位置等敏感数据。如果未来增加云同步功能要采用端到端加密上传前先脱敏。“数据本地优先”的核心不是“不做云”而是“云不能默认访问用户数据”。这是产品底线也是合规底线。7. 常见问题与排查思路问题现象常见原因解决思路打开 txt 文件后中文乱码文件编码不是 UTF-8判断 BOM 并使用 GBK/GB18030 解码导入大文件时 UI 卡顿解析任务阻塞主线程使用 TaskPool/Worker 后台解析章节标题没有正确识别不同书籍章节格式差异大维护多组正则规则支持用户手动纠正AI 生成结果为空或超时模型服务不可用或上下文过长增加超时重试先展示本地缓存的旧结果人物关系重复混乱别名未合并到同一实体优先查询本地实体库再做实体合并模拟器运行不兼容部分能力仅支持特定架构使用 ARM64 平台模拟器或真机调试数据丢失卸载应用清除了沙箱数据提供本地备份导出功能建议用户定期备份除了表格里的问题我建议你在工程里加一套日志模块专门记录 AI 调用耗时、解析耗时、数据库读写耗时。性能瓶颈往往要靠数据说话不能靠感觉。8. 工程经验与最佳实践8.1 权限最小化原则“溯阅”只需要用户主动选择文件的能力不需要申请“读取所有文件”的权限。这一点在鸿蒙上尤为重要权限越少用户信任度越高上架审核风险也越低。开发时可以先列一个权限清单每个权限都要写出业务理由权限是否需要理由读取全部文件否采用 FilePicker 由用户主动选择网络访问视情况如果接入远程模型才需要后台运行否阅读工具不需要后台频繁活动8.2 AI 提示词工程要版本化生成前情提要和人物关系效果不稳定的根因往往不是模型不行而是提示词写得太随意。建议把提示词当成代码来管理每个功能一个提示词模板文件。模板支持版本号方便对比效果。调优时只改模板不动业务代码。8.3 高性能长文本处理解析和 AI 生成都属于重计算要重点优化解析任务分批处理先解析章节标题再按需加载章节正文。AI 生成结果缓存到本地数据库二次打开不重复调用。章节级摘要可以预生成用户看到前情提要时是“秒开”。8.4 可测试性与异常兜底AI 接口天生不稳定不能把“模型输出”当作可靠输入。建议定义统一的 Schema对 AI 输出做 JSON 校验。解析失败时降级为“原文摘要”或直接展示原文片段。给关系抽取结果做唯一性约束避免同一人物多条记录。8.5 阅读体验优先技术实现最终要服务阅读体验。生成信息要克制不能一打开全是弹窗。我的建议是前情提要在进入书籍详情页时展示默认收起。人物关系以图谱形式展示用户主动点击查看。时间线和伏笔做成可滑动时间轴而不是长文。所有 AI 结果标注“由 AI 生成可能存在误差”避免误导读者。9. 总结与下一步学习方向“溯阅”这个项目给我最大的启发是鸿蒙应用不一定要做成“云端依赖型”产品。通过 FilePicker 获取用户主动选择的文件、用本地解析完成结构化处理、再通过 AI 生成阅读辅助信息整套链路完全可以做到数据不出设备。AI 能力通过接口抽象解耦后后续无论是替换端侧模型还是自建服务都不会影响核心业务。如果你准备自己实现一个类似的鸿蒙本地 AI 阅读工具建议按下面顺序推进先做文件导入和章节解析这是数据基础。再做缓存和本地存储保证数据可持久化。最后做 AI 生成并预留“本地模型 / 远程服务”切换开关。从用户反馈中持续优化提示词和实体合并逻辑。在本地模型效果还不够理想时优先做章节级摘要缓存而不是每次全文重新生成。这样既能保护用户隐私也能明显降低设备耗电和计算压力。数据本地优先不是一句口号它需要从权限设计、存储结构、AI 调用策略每一步都落实一点点把“用户数据属于用户”变成产品现实。