阅读详情页很容易在第一次开发时被当成“长文本容器”:从路由里取一个 ID,找到文章,把标题、摘要和段落放进Scroll,再加一个收藏按钮。页面确实能打开,但用户读到第二段后返回列表,再次进入却只能从顶部开始;朗读服务正在播放哪一段,也可能被误认为视觉阅读进度。功能都有,体验却没有形成闭环。
本文基于化学实验室 1.0.5的真实 ArkTS 源码,分析ReadingListPage.ets、ReadingDetailPage.ets、ReadingArticle.ets、ReadingSpeechService.ets与DataStore.ets。项目当前有 4 篇本地化学文章,每篇 3 个章节,共 12 个章节,预计阅读时长合计 26 分钟。详情页已经支持收藏、两档字号、分段朗读和朗读段落高亮,但没有阅读历史、视觉进度保存、完成状态、上/下一篇,也没有非法文章 ID 的显式错误页。
一个容易被忽略的问题是默认值。页面先把第一篇“实验室安全”赋给article,只有路由 ID 有效时才替换它。若 ID 缺失或无效,页面不会提示错误,而是静默显示第一篇文章。这种兜底看似避免白屏,实际会把路由问题伪装成正常内容。
更可靠的判断是:阅读闭环不是进入详情页就结束,而是打开、定位、阅读、收藏、离开和再次回来都能还原用户意图。文章内容、视觉阅读状态、收藏状态与朗读状态应各自拥有明确边界,页面负责协调,而不是把它们混成一个currentSectionIndex。
本文唯一校验标记:CHEM-LAB-ONE12-READING-PROGRESS-CLOSURE-20260730
本文将完成以下工程拆解:
- 盘点 4 篇文章、12 个章节与现有详情页状态。
- 修复无效 ID 静默打开第一篇文章的问题。
- 用文章 ID、章节 ID 和段内比例保存可恢复的阅读进度。
- 区分视觉滚动进度、阅读历史、收藏与 TTS 播放状态。
- 持久化字号偏好,并减少高频滚动写盘。
- 处理朗读引擎的进入、切段、离开和错误反馈。
- 补齐上/下一篇、完成状态、多设备排版与验证矩阵。
一、先确认真实内容规模与入口
ReadingArticle.ets当前模型如下:
export interface ReadingSection { title: string body: string } export interface ReadingArticle { id: string title: string category: string level: string minutes: number summary: string keywords: string[] sections: ReadingSection[] }四篇文章分别是实验安全、质量守恒、酸碱盐和电化学,每篇恰好三个章节。列表页、全局搜索和收藏页最终都通过同一个路由打开详情:
router.pushUrl({ url: 'views/learning/ReadingDetailPage', params: { id: article.id } })这个入口协议是正确的:调用方只传稳定 ID,详情页再从本地目录读取完整内容。问题不在路由形式,而在详情页对缺失目标、历史记录和离开动作没有完整处理。
二、当前详情页已经具备哪些可用基础
现有页面状态覆盖了文章、字号、朗读和收藏:
@State article: ReadingArticle = getReadingArticles()[0] @State fontScale: number = 0 @State isSpeaking: boolean = false @State currentSectionIndex: number = -1 @State speechStatus: string = '点击朗读,播放当前文章' @State isFavorite: boolean = false它有三个值得保留的工程选择:
- 正文来自静态本地模型,首屏不依赖网络。
- 收藏通过
DataStore.toggleFavorite()写入统一收藏仓库。 ReadingSpeechService独立于页面,TTS 引擎和请求 ID 没有散落在 ArkUI 树中。
短板也很明确:currentSectionIndex是朗读引擎正在播放的段落,不是用户眼睛看到的段落;fontScale只存在于当前页面实例;Scroll没有控制器和进度事件;离开页面时仅移除监听器,没有明确声明朗读是否继续。
三、无效文章 ID 不能静默显示默认文章
当前逻辑先使用第一篇文章作为默认值:
@State article: ReadingArticle = getReadingArticles()[0] aboutToAppear(): void { const params = router.getParams() as ReadingDetailParams | undefined if (params?.id) { const target = getReadingArticleById(params.id) if (target) { this.article = target } } }当全局搜索传错 ID、收藏数据来自旧版本或调试入口漏传参数时,用户会看到“实验室安全”,却不知道目标内容没有找到。更稳的页面状态是显式联合类型:
type ReaderLoadState = 'loading' | 'content' | 'not_found' @State loadState: ReaderLoadState = 'loading' @State article?: ReadingArticle private loadArticle(): void { const params = router.getParams() as ReadingDetailParams | undefined const id = params?.id ?? '' const target = id.length > 0 ? getReadingArticleById(id) : undefined if (!target) { this.article = undefined this.loadState = 'not_found' return } this.article = target this.loadState = 'content' }not_found页面显示“文章不存在或已更新”,提供返回阅读列表的按钮。错误状态可见后,路由契约才真正可调试。
四、章节需要稳定 ID,标题不适合作为身份
现有ForEach使用${article.id}_${section.title}作为 key。只要编辑标题,组件身份就变化;进度记录如果也依赖标题,同样会失效。可以给章节增加 ID:
export interface ReadingSection { id: string title: string body: string }一篇文章的数据变成:
{ id: 'mass_conservation', title: '质量守恒定律:反应前后到底守恒了什么', sections: [ { id: 'atoms_rearrange', title: '质量为什么不会凭空消失', body: '在普通化学反应中,原子只是重新组合……' }, { id: 'open_system', title: '开放体系容易造成错觉', body: '蜡烛燃烧后看起来变少了……' } ] }章节 ID 只表达身份,不需要包含标题、数组下标或正文摘要。标题可以修改,排序可以调整,旧进度仍能寻找同一章节。
五、阅读进度不要只保存像素偏移
直接保存Scroll的yOffset看起来最简单,但字号从 15vp 切到 18vp、窗口从手机变成平板、正文换行变化后,旧像素会定位到完全不同的位置。更稳的记录由“章节锚点 + 段内比例”组成:
export interface ReadingProgress { articleId: string sectionId: string sectionRatio: number completed: boolean lastReadAt: number }sectionRatio限制在0..1,只表示当前章节内部大致读到的位置。恢复时先找到章节,再按新布局计算段内目标,不依赖旧设备的绝对像素。
对于当前每篇只有三个章节的内容,即使第一版只恢复到章节顶部,也比始终回到文章顶部更符合用户预期。
六、收藏、历史、进度和朗读是四种不同状态
这四类状态经常被一个“读过”标签混在一起:
| 状态 | 代表含义 | 是否持久化 | 关键字段 |
|---|---|---|---|
| 收藏 | 用户主动保存内容 | 是 | type + articleId |
| 阅读历史 | 用户曾打开文章 | 是 | articleId + lastReadAt |
| 阅读进度 | 用户读到的位置 | 是 | sectionId + ratio |
| 朗读状态 | TTS 当前播放位置 | 通常否 | requestId + sectionIndex |
收藏不等于读过,打开不等于读完,朗读到第二段也不代表视觉滚动在第二段。只有把它们拆开,列表页才能准确显示“已收藏”“上次读到第二节”和“已完成”。
七、用独立服务封装阅读记录
页面不应直接拼 Preferences JSON。可以在DataStore之上建立窄接口:
export interface ReadingProgressStore { load(articleId: string): Promise<ReadingProgress | undefined> save(progress: ReadingProgress): Promise<void> listRecent(limit: number): Promise<ReadingProgress[]> remove(articleId: string): Promise<void> }具体实现可复用项目已有 Preferences 初始化和字符串读写能力。存储键建议带版本:
const READING_PROGRESS_KEY = 'reading_progress_v1' interface ReadingProgressDocument { version: number items: ReadingProgress[] }版本字段不是为了复杂化,而是为章节 ID 调整、字段新增和旧数据迁移留下入口。解析失败时返回空记录并保留错误日志,不能让一条损坏记录阻止整个阅读页打开。
八、文章打开后先写历史,再恢复锚点
一次阅读会话的启动顺序应稳定:
private async startSession(article: ReadingArticle): Promise<void> { this.article = article this.loadState = 'content' await this.reloadFavorite(article.id) const progress = await this.progressStore.load(article.id) await this.progressStore.save({ articleId: article.id, sectionId: progress?.sectionId ?? article.sections[0].id, sectionRatio: progress?.sectionRatio ?? 0, completed: progress?.completed ?? false, lastReadAt: Date.now() }) this.pendingRestore = progress }先校验文章,再加载收藏与进度,最后把恢复任务交给完成布局后的列表。历史在文章成功打开时写入,非法 ID 不进入最近阅读。
九、用 List 章节锚点比一个大 Scroll 更易恢复
现有页面使用Scroll + Column + ForEach。若要按章节恢复,可以把标题区和章节都放进List,绑定Scroller:
private readerScroller: Scroller = new Scroller() List({ space: 12, scroller: this.readerScroller }) { ListItem() { this.ArticleHeader() } ForEach(this.article?.sections ?? [], (section: ReadingSection, index: number) => { ListItem() { ReadingSectionView({ section: section, number: index + 1, fontMode: this.fontMode }) } }, (section: ReadingSection) => section.id) }标题区占索引 0,章节索引从 1 开始。恢复到某章节时使用scrollToIndex(sectionIndex + 1)。如果必须保留段内比例,再结合章节布局高度做小范围偏移,而不是依赖整页旧像素。
十、进度写入要基于有效变化,不要跟随每个像素
滚动事件可能在一秒内触发多次,直接每次写 Preferences 会制造无意义 I/O。页面先在内存更新候选进度,满足条件再保存:
@State visibleSectionIndex: number = 0 private lastSavedSectionId: string = '' private saveTimer: number = -1 private scheduleProgressSave(sectionId: string, ratio: number): void { if (this.saveTimer >= 0) { clearTimeout(this.saveTimer) } this.saveTimer = setTimeout(() => { this.persistProgress(sectionId, ratio) }, 800) }另外三种时机应立即保存:系统返回、切换上/下一篇、页面aboutToDisappear()。防抖只负责正常滚动,不能取代生命周期兜底。
十一、完成状态需要明确阈值
用户滚到最后一个章节顶部,不代表已经读完。可以在最后章节可见并且段内比例达到 90% 时标记完成:
private isCompleted(sectionIndex: number, ratio: number): boolean { const count = this.article?.sections.length ?? 0 return count > 0 && sectionIndex === count - 1 && ratio >= 0.9 }完成状态只从false变成true,除非用户明确清除阅读记录。再次打开文章时即使恢复到中间位置,也不应自动把“已完成”改回“阅读中”。
十二、字号偏好应持久化,但不要污染文章进度
现有字号只有0和1两档:正文分别为 15vp/18vp,行高为 25vp/30vp。这个选择简单有效,但离开页面后会恢复默认。可以建立单独的全局偏好:
export type ReaderFontMode = 'standard' | 'large' const READER_FONT_MODE_KEY = 'reader_font_mode' @State fontMode: ReaderFontMode = 'standard' private bodyFontSize(): number { return this.fontMode === 'standard' ? 15 : 18 } private bodyLineHeight(): number { return this.fontMode === 'standard' ? 25 : 30 }字号是阅读器偏好,不属于某篇文章,因此不应重复写进每条ReadingProgress。修改字号后重新计算布局,再按章节锚点恢复,避免屏幕突然跳到别处。
十三、收藏切换要有进行中状态和失败反馈
当前收藏逻辑直接等待DataStore.toggleFavorite():
private async toggleFavorite(): Promise<void> { this.isFavorite = await DataStore.toggleFavorite({ id: this.article.id, type: 'reading', title: this.article.title, subtitle: `${this.article.category} · ${this.article.minutes}分钟阅读`, route: 'views/learning/ReadingDetailPage', createdAt: Date.now() }) }更完整的页面需要阻止连续点击,并在失败时保留原状态:
@State favoriteSaving: boolean = false private async toggleFavorite(): Promise<void> { if (!this.article || this.favoriteSaving) return this.favoriteSaving = true try { this.isFavorite = await DataStore.toggleFavorite({ id: this.article.id, type: 'reading', title: this.article.title, subtitle: `${this.article.category} · ${this.article.minutes}分钟阅读`, route: 'views/learning/ReadingDetailPage', createdAt: Date.now() }) } catch (_) { this.showMessage('收藏保存失败,请重试') } finally { this.favoriteSaving = false } }星标动作区至少保持 44vp,并提供“收藏文章/取消收藏文章”的无障碍说明。
十四、朗读段落高亮不等于视觉阅读进度
现有正文卡片根据currentSectionIndex改变背景和边框,这个索引来自 TTS 服务:
.backgroundColor( this.currentSectionIndex === index ? AppColors.PRIMARY_BG : AppColors.CARD_BG )变量应改名为speakingSectionIndex,明确它只表示正在朗读的章节。视觉进度使用visibleSectionIndex。两个索引可以相同,也可以不同:用户可能一边听第一段,一边手动滚到第三段查看内容。
命名清楚后,进度保存不会错误地把 TTS 自动切段当成用户已读位置。
十五、TTS 配置与错误文案必须一致
ReadingSpeechService创建引擎时设置:
ReadingSpeechService.engine = await textToSpeech.createEngine({ language: 'zh-CN', person: 0, online: 1 })但创建失败时显示“当前设备不支持离线朗读”。配置声明在线模式,错误却指向离线能力,排查方向会被带偏。更准确的反馈应该根据错误阶段描述:
private static engineErrorMessage(errorCode?: number): string { if (errorCode !== undefined) { return `朗读服务不可用,错误码:${errorCode}` } return '朗读引擎初始化失败,请检查设备能力与网络状态' }如果产品要求完全离线,则应调整引擎配置并在目标设备上验证离线语音资源,不能只改提示文字。
十六、页面离开时要明确朗读策略
当前aboutToDisappear()只移除监听:
aboutToDisappear(): void { ReadingSpeechService.setListener(undefined) }静态服务仍可能继续播放,只是页面不再接收状态。产品需要在两种策略中明确选择:
| 策略 | 离开详情页 | 适用场景 |
|---|---|---|
| 跟随页面停止 | 调用ReadingSpeechService.stop() | 学习工具、避免后台声音 |
| 允许后台继续 | 保留播放并提供全局控制条 | 专门听读产品 |
当前应用没有全局播放器,采用“离开即停止”更完整:
aboutToDisappear(): void { this.flushProgress() ReadingSpeechService.stop() ReadingSpeechService.setListener(undefined) }无论选择哪种策略,都应写进代码和验收清单,不能由静态服务的生命周期偶然决定。
十七、快速切换朗读要依赖 requestId 隔离旧回调
现有服务使用activeRequestId,在onComplete中忽略旧请求:
onComplete: (requestId: string, response: textToSpeech.CompleteResponse) => { if (requestId !== ReadingSpeechService.activeRequestId) { return } if (response.type === 1 && ReadingSpeechService.isSpeaking) { ReadingSpeechService.playNextSection() } }这是正确的并发边界。用户快速点击停止、重新朗读或切换文章时,旧引擎回调不能推进新文章。后续增加上/下一篇时,也应先停止旧播放、更新文章、生成新请求 ID,最后才开始新章节。
十八、上/下一篇切换前先保存当前会话
四篇文章虽然不多,但详情页没有连续阅读入口。可以按目录顺序计算相邻文章:
private openSibling(offset: number): void { if (!this.article) return const articles = getReadingArticles() const current = articles.findIndex((item: ReadingArticle) => { return item.id === this.article?.id }) const next = current + offset if (next < 0 || next >= articles.length) return this.flushProgress() ReadingSpeechService.stop() router.replaceUrl({ url: 'views/learning/ReadingDetailPage', params: { id: articles[next].id } }) }使用replaceUrl可以避免连续切换后返回栈堆积多份详情页。第一篇隐藏“上一篇”,最后一篇隐藏“下一篇”,不要保留无法点击的迷惑按钮。
十九、列表页要显示“最近阅读”而不是复制收藏
收藏列表表达用户主动保存,阅读列表则更适合展示“继续阅读”:
interface ReadingCardState { article: ReadingArticle progress?: ReadingProgress progressText: string } function progressLabel(progress?: ReadingProgress): string { if (!progress) return '未开始' if (progress.completed) return '已完成' return '继续阅读' }卡片可以显示最近阅读时间、章节标题和完成状态,但不需要展示精确到个位的百分比。章节级内容很短,过度精确的数字会制造虚假感。
二十、阅读正文排版优先保证连续性
当前每个章节都被放进独立白色卡片,适合短知识块,但长文会产生频繁边界。可以保留章节之间的轻分隔,减少每段阴影:
Column() { Text(`${index + 1}. ${section.title}`) .fontSize(18) .fontWeight(AppFonts.WEIGHT_BOLD) .lineHeight(28) Text(section.body) .fontSize(this.bodyFontSize()) .lineHeight(this.bodyLineHeight()) .margin({ top: 10 }) } .width('100%') .alignItems(HorizontalAlign.Start) .padding({ top: 18, bottom: 18 }) .border({ width: { bottom: 1 }, color: AppColors.DIVIDER })朗读章节可以使用左侧细线或浅色背景高亮,不必让所有章节一直像独立功能卡片。阅读器的视觉目标是让眼睛连续移动,而不是让每段都抢夺注意力。
二十一、关键词列表要考虑窄屏溢出
列表页使用Row展示四个关键词,每个标签禁止换行。手机窄屏或系统大字体下,整行可能超出卡片。可以只显示前两个关键词,其余用“+2”表示,或改用支持换行的布局。
如果继续使用单行布局,可以先构建稳定的摘要标签:
private visibleKeywords(article: ReadingArticle): string[] { return article.keywords.slice(0, 2) } private hiddenKeywordCount(article: ReadingArticle): number { return Math.max(0, article.keywords.length - 2) }渲染时给每个关键词设置最大宽度和省略规则,并把剩余数量放在固定宽度的末尾标签中。这样无论文章关键词有 3 个还是 8 个,都不会推动卡片产生横向滚动;完整关键词仍可在详情页展示。
更保守的做法是:标题最多两行,摘要最多三行,元信息只保留分类、阅读时长和状态;关键词进入详情页,不再和阅读进度争夺卡片宽度。
二十二、多设备恢复不能依赖旧窗口尺寸
阅读记录可能在手机竖屏保存,却在平板或 2in1 小窗口恢复。章节锚点方案正是为这种变化服务。适配时至少检查:
| 环境 | 内容宽度 | 字号策略 | 恢复方式 |
|---|---|---|---|
| 手机竖屏 | 页面内边距后满宽 | 15/18vp | 章节锚点 |
| 手机横屏 | 限制最大行宽 | 15/18vp | 章节锚点 |
| 平板 | 居中并限制正文宽度 | 16/19vp 可选 | 锚点 + 段内比例 |
| 2in1 小窗口 | 保持单列 | 跟随阅读偏好 | 锚点优先 |
正文行长不宜无限扩展。平板上可以给阅读列设置最大宽度并居中,避免一行文字横跨整个屏幕。
二十三、状态栏、底部安全区和朗读按钮要一起验证
页面根容器已使用statusBarHeight和bottomBarHeight,这是适配系统区域的基础。新增底部上/下一篇操作时,仍需在内容末尾保留至少 28vp 或系统避让区,不能让按钮进入手势导航区域。
朗读、下一段、字号和收藏都是高频操作,按钮在大字体下要保持可点击,不应因为状态文字过长挤压到零宽。speechStatus可以独占下一行,而不是与两个按钮放在同一个Row中。
二十四、用确定性场景验证进度恢复
阅读状态适合用固定场景验证:
| 场景 | 操作 | 预期结果 |
|---|---|---|
| 首次打开 | 进入mass_conservation | 从标题开始,无旧进度 |
| 中途离开 | 读到第二章节后返回 | 保存第二章节锚点 |
| 再次进入 | 从列表打开同一文章 | 恢复到第二章节 |
| 修改字号 | 标准切换大字 | 仍定位同一章节 |
| 无效 ID | 传入missing_article | 显示不存在状态,不展示第一篇 |
| 收藏 | 收藏后重启应用 | 收藏状态仍为真 |
| TTS 离开 | 朗读中返回列表 | 按产品策略停止播放 |
| 完成阅读 | 最后章节超过 90% | 标记已完成 |
这些场景比只检查“页面能滚动”更能发现状态边界问题。
二十五、常见故障与排查顺序
| 现象 | 优先检查 | 常见原因 | 修复方向 |
|---|---|---|---|
| 打开错误文章却无提示 | 路由加载状态 | 默认第一篇掩盖无效 ID | 增加not_found |
| 切换字号后恢复位置漂移 | 进度格式 | 保存了绝对像素 | 使用章节锚点 |
| 每次滚动都有写盘 | 事件处理 | 在onScroll直接保存 | 防抖 + 生命周期兜底 |
| 朗读段落被当作阅读进度 | 状态命名 | 共用currentSectionIndex | 分离 speaking/visible |
| 离开页面仍有声音 | 服务生命周期 | 只移除监听未停止引擎 | 明确离开策略 |
| TTS 报错文案不准确 | 引擎配置 | 在线模式却提示离线失败 | 按阶段输出错误 |
| 收藏按钮连续闪烁 | 异步操作 | 重复点击并发写入 | 增加 saving 状态 |
| 章节标题修改后进度丢失 | 数据身份 | 以标题作为 key | 增加稳定 sectionId |
| 平板恢复位置错误 | 布局依赖 | 使用旧窗口 yOffset | 锚点恢复 |
排查时先看文章 ID 与加载状态,再看持久化记录,最后检查滚动与 TTS。这样能快速判断问题属于内容目录、阅读会话还是语音引擎。
二十六、按最小风险顺序落地
这次改造可以拆成四个可回归阶段:
- 先增加
not_found状态和稳定sectionId,不改页面外观。 - 增加阅读历史与章节级进度,离开时保存,再次进入恢复。
- 持久化字号,分离视觉章节和朗读章节,明确离开时停止策略。
- 最后加入上/下一篇、完成状态和列表“继续阅读”提示,并做多设备验证。
每个阶段都能独立验收。不要一开始就同时改模型、存储、列表、TTS 和导航,否则出现恢复错位时很难判断是哪一层引起。
二十七、源码事实与建议实现的边界
为了避免把设计建议误写成已经交付的功能,下面把本次源码审阅能够确认的事实与推荐改造明确分开。左侧“当前源码事实”均可在ReadingDetailPage.ets、ReadingArticle.ets、ReadingSpeechService.ets或DataStore.ets中复核;右侧“建议实现”是面向 HarmonyOS 5.0 或以上版本的演进方案。
| 观察维度 | 当前源码事实 | 建议实现 |
|---|---|---|
| 内容规模 | 本地目录包含 4 篇文章,每篇 3 个章节,共 12 个章节 | 保留本地内容目录,同时给文章与章节增加稳定、不可变的业务 ID |
| 路由异常 | 路由 ID 无效时不会显示错误,页面继续使用初始化时的第一篇文章 | 增加loading/content/not_found/error状态,禁止错误 ID 被默认内容掩盖 |
| 章节身份 | ForEach使用article.id + section.title作为 key,标题变化会影响身份稳定性 | 在ReadingSection中加入稳定sectionId,进度记录只引用稳定 ID |
| 页面与进度 | 页面由Scroll + Column渲染全部章节,当前没有视觉阅读进度持久化 | 记录articleId + sectionId + fraction,恢复时先定位章节,再做段内微调 |
| 字号 | fontScale只在当前页面内切换,正文实际采用 15/18 两档字号与 25/30 两档行高 | 把字号偏好作为独立设置持久化,但不要把字号切换误当成阅读进度变化 |
| 收藏 | 收藏通过DataStore.toggleFavorite完成,页面没有明确的保存中与失败状态 | 增加idle/saving/saved/error状态,保存失败时回滚视觉状态并允许重试 |
| 朗读 | ReadingSpeechService监听回调驱动currentSectionIndex,并高亮当前朗读章节 | 将speakingSectionId与visibleSectionId分离,避免朗读位置覆盖视觉阅读位置 |
| 离开页面 | aboutToDisappear移除监听,但当前页面没有显式停止朗读 | 明确“离开停止”或“后台继续”的产品策略,并让服务生命周期与策略一致 |
| 历史与最近阅读 | 当前详情页没有阅读历史、最近阅读和完成状态持久化 | 由ReadingRecordService统一保存进度、最后阅读时间和完成状态 |
| 多设备边界 | 页面已经根据状态栏与底部栏高度设置内边距 | 在手机、平板和 2in1 窗口下验证章节锚点、长文本宽度和底部安全区 |
本文给出的ReadingRecordService、稳定sectionId、章节锚点恢复、写入防抖、完成阈值、上/下一篇以及显式not_found状态,都是建议实现,不是当前仓库已经集成的能力。本次审阅没有执行 DevEco Studio 构建、真机安装或运行时性能测试,因此文中的接口与伪代码需要在实际接入后再通过编译、安装、启动、滚动恢复、收藏失败和朗读生命周期场景验证,不能据此声称功能已经上线。
总结
化学阅读器当前已经具备内容详情、收藏、字号切换和分段朗读,基础功能并不薄弱。真正缺少的是阅读会话:无效 ID 被默认文章掩盖,视觉位置没有保存,字号偏好不持久,朗读段落与用户阅读位置共用近似概念,离开页面后的播放策略也不明确。
更稳的实现是给章节稳定 ID,用“文章 ID + 章节锚点 + 段内比例”记录进度;把收藏、历史、进度和 TTS 状态分别管理;只在有效变化和生命周期节点写盘;恢复时优先找章节而不是旧像素;对无效路由、未知 TTS 错误和收藏失败提供可见反馈。这样用户从列表打开、读到中间、收藏、返回、重启应用再进入时,看到的仍是同一篇文章和真正读过的位置,阅读闭环才算完成。
AI 辅助声明:本文由 AI 辅助完成源码梳理、结构校对与配图设计,技术结论均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布。