From 95cead5863d1bba9dc9642f0d04e407a3ece43cb Mon Sep 17 00:00:00 2001 From: shenlei Date: Fri, 24 Jul 2026 14:40:02 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20PDF=20=E9=98=85?= =?UTF-8?q?=E8=AF=BB=E4=BA=A4=E4=BA=92=E7=A8=B3=E5=AE=9A=E6=80=A7=E5=B9=B6?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=A1=B9=E7=9B=AE=E6=8A=80=E8=83=BD=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF 阅读器在慢速双指缩放、竖向滚动页高异步更新、页面请求超时和绘画橡皮擦等场景中,存在视口跳动、迟到结果被错误丢弃、缓存边界不明确及笔迹显示与持久化不同步的问题;同一本书的标注读取和并发写入也会产生不必要的磁盘访问或覆盖风险。\n\n调整缩放 inset 计算和竖向阅读锚点恢复,避免缩放变换被重复计入、真实页高到达时改变当前阅读位置。页面请求改为带 token 的超时重试机制,可在缓存窗口内接收有效迟到结果并提供失败重试入口;页面大图、OCR、笔迹和描述缓存统一按当前页前后两页收敛,PDF 标识改为完整内容 SHA-256,避免同名或相近文件复用错误阅读状态。\n\n绘画会话内按路径实时重绘,退出会话后使用按图层派生的位图缓存,确保橡皮擦即时作用于已提交笔迹;同时为笔迹持久化增加版本控制、为标注读写增加同步保护和内存快照,降低复用与并发场景下的状态错乱。\n\n将项目技能统一迁入 .agents/skills,并以 .claude/skills 相对软链接供 Claude Code 读取;新增详细 Git 提交技能,自动审查改动、生成中文提交说明并约束安全推送。 验证:所有项目技能通过 quick_validate;执行 xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace -scheme ReadViewDemo -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build,构建成功。 --- .agents/skills/commit-git-changes/SKILL.md | 92 ++++++ .../commit-git-changes/agents/openai.yaml | 4 + .../discuss-sdk-feature-solution/SKILL.md | 44 +++ .../agents/openai.yaml | 4 + .../generate-module-code-style-doc/SKILL.md | 23 ++ .../agents/openai.yaml | 4 + .../SKILL.md | 34 ++ .../agents/openai.yaml | 4 + .../generate-sdk-code-style-doc/SKILL.md | 26 ++ .../agents/openai.yaml | 4 + .agents/skills/review-swift-ios-sdk/SKILL.md | 48 +++ .../review-swift-ios-sdk/agents/openai.yaml | 4 + .../start-sdk-feature-dev-lite/SKILL.md | 41 +++ .../agents/openai.yaml | 4 + .agents/skills/start-sdk-feature-dev/SKILL.md | 43 +++ .../start-sdk-feature-dev/agents/openai.yaml | 4 + .claude/skills | 1 + .claude/skills/discuss-feature-solution.md | 286 ---------------- .claude/skills/start-sdk-feature-dev-lite.md | 213 ------------ .claude/skills/start-sdk-feature-dev.md | 309 ------------------ .gitignore | 3 +- AGENTS.md | 68 +++- .../Sources/RDPDFKitPageProvider.swift | 55 +++- .../Sources/RDPDFReaderContainerView.swift | 64 +++- .../RDPDFReaderDrawingCanvasView.swift | 117 ++++++- .../RDPDFReaderImageTextRecognizer.swift | 36 +- .../Sources/RDPDFReaderPageView.swift | 45 ++- .../RDPDFReaderPanelPresentation.swift | 4 + .../Sources/RDPDFReaderPersistenceStore.swift | 100 +++++- .../Sources/RDPDFReaderViewController.swift | 218 ++++++++++-- 30 files changed, 995 insertions(+), 907 deletions(-) create mode 100644 .agents/skills/commit-git-changes/SKILL.md create mode 100644 .agents/skills/commit-git-changes/agents/openai.yaml create mode 100644 .agents/skills/discuss-sdk-feature-solution/SKILL.md create mode 100644 .agents/skills/discuss-sdk-feature-solution/agents/openai.yaml create mode 100644 .agents/skills/generate-module-code-style-doc/SKILL.md create mode 100644 .agents/skills/generate-module-code-style-doc/agents/openai.yaml create mode 100644 .agents/skills/generate-module-implementation-logic/SKILL.md create mode 100644 .agents/skills/generate-module-implementation-logic/agents/openai.yaml create mode 100644 .agents/skills/generate-sdk-code-style-doc/SKILL.md create mode 100644 .agents/skills/generate-sdk-code-style-doc/agents/openai.yaml create mode 100644 .agents/skills/review-swift-ios-sdk/SKILL.md create mode 100644 .agents/skills/review-swift-ios-sdk/agents/openai.yaml create mode 100644 .agents/skills/start-sdk-feature-dev-lite/SKILL.md create mode 100644 .agents/skills/start-sdk-feature-dev-lite/agents/openai.yaml create mode 100644 .agents/skills/start-sdk-feature-dev/SKILL.md create mode 100644 .agents/skills/start-sdk-feature-dev/agents/openai.yaml create mode 120000 .claude/skills delete mode 100644 .claude/skills/discuss-feature-solution.md delete mode 100644 .claude/skills/start-sdk-feature-dev-lite.md delete mode 100644 .claude/skills/start-sdk-feature-dev.md diff --git a/.agents/skills/commit-git-changes/SKILL.md b/.agents/skills/commit-git-changes/SKILL.md new file mode 100644 index 0000000..c38e95c --- /dev/null +++ b/.agents/skills/commit-git-changes/SKILL.md @@ -0,0 +1,92 @@ +--- +name: commit-git-changes +description: 审查当前 Git 工作区,安全选择本次任务文件,执行必要校验,生成包含问题背景、根因、修改方案和验证结果的详细中文提交说明,并提交及按用户要求推送到 Git 服务器。用户要求“提交代码”“提交并推送”“同步到 Git”“上传当前修改”或要求生成详细 commit message 时使用。 +--- + +# 提交 Git 改动 + +## 目标 + +在不混入无关修改、不泄露敏感信息、不改写远端历史的前提下,完成“审查—验证—暂存—提交—推送—确认”闭环,并用提交说明解释为什么修改以及如何解决,而不只是罗列文件。 + +## 工作流 + +1. 读取仓库级 `AGENTS.md`、当前分支、远端和工作区状态。 +2. 检查暂存与未暂存差异: + - 使用 `git status --short` 确认全部改动。 + - 使用 `git diff` 和 `git diff --cached` 理解真实行为变化。 + - 必要时查看相关源码和测试,不能仅依据文件名编写提交说明。 +3. 划定本次提交范围: + - 只纳入当前用户任务直接相关的文件。 + - 保留用户已有或其他任务产生的无关修改。 + - 优先显式 `git add `;仅在确认所有改动均属本次提交时使用 `git add -A`。 + - 发现密钥、令牌、证书、个人配置、大型生成物或疑似敏感数据时停止提交并说明。 +4. 根据改动风险执行项目约定的构建、测试或静态检查。 + - 不为通过校验而擅自扩大修改范围。 + - 不能运行的校验必须如实记录原因,不得声称已通过。 +5. 暂存后再次检查: + - 执行 `git diff --cached --check`。 + - 执行 `git diff --cached --stat` 和 `git diff --cached`。 + - 确认暂存区没有无关文件、调试残留和意外格式变化。 +6. 编写详细中文提交说明并创建提交。 +7. 用户要求提交到服务器或同步远端时推送: + - 有 upstream 时推送当前分支。 + - 没有 upstream 且远端、目标分支明确时,使用 `git push -u `。 + - 远端或目标分支不明确时先询问,不猜测。 + - 禁止 force push;除非用户明确要求且风险已说明。 +8. 提交后确认提交哈希、提交标题、当前分支、推送结果和剩余未提交改动。 + +## 提交说明规范 + +提交说明使用“标题 + 空行 + 详细正文 + 验证结果”的结构。 + +### 标题 + +- 用一句中文概括用户可感知的问题和修复结果。 +- 优先写清触发条件、异常表现和结果,例如: + `修复无训练点普通课程完成后重进播放页回退为未完成` +- 使用明确动词,如“修复”“新增”“调整”“重构”“移除”。 +- 不使用“修改代码”“优化问题”“更新若干内容”等空泛描述。 +- 保持单行,不以句号结尾,不添加无依据的工单号或模块前缀。 + +### 正文 + +用完整段落解释以下内容: + +1. **问题背景与影响**:什么场景触发、用户看到什么、影响哪些路径。 +2. **根因**:原有数据流、状态条件或实现约束为什么导致问题。 +3. **修改方案**:关键行为如何改变,为什么选择该方案,必要时说明兼容和边界处理。 +4. **验证结果**:实际运行了哪些构建、测试或检查;未运行时写明原因。 + +正文应描述行为和因果关系,不要把 `git diff --stat` 改写成文件清单。多个紧密相关的修改可以分段说明,但不要堆砌逐文件 bullet。 + +### 示例 + +```text +修复无训练点普通课程完成后重进播放页回退为未完成 + +课件完成态本地记录的保存不区分训练点,但读取端仅在存在 +trainPointModel 时才创建合并器,导致班级、训练和项目均为空的普通课程 +重进播放页后无法读取本地完成记录。 + +改为始终创建完成态合并器:无训练点时使用空维度合并;由于该场景没有 +服务端完成态可供对账,将本地记录作为唯一持久来源并跳过超期回收,保持 +重新进入播放页后的完成状态稳定。 + +验证:完成目标场景回归,并通过 Debug 模拟器构建。 +``` + +根据真实改动改写示例内容,禁止照抄未发生的场景或验证结论。 + +## 提交与推送约束 + +- 不使用 `git reset --hard`、`git checkout --`、`git clean` 等破坏性命令处理工作区。 +- 不使用 `--amend`、rebase、历史改写或强制推送,除非用户明确授权。 +- 不跳过 hooks;hook 失败时先分析原因,不使用 `--no-verify` 绕过。 +- 提交失败后保留暂存区,修复可控问题并重试;不要重复创建等价提交。 +- 推送被拒绝时先获取并解释分支差异,不自动合并、变基或覆盖远端。 +- 若没有可提交差异,直接说明,不创建空提交。 + +## 交付 + +报告提交哈希和标题、推送的远端分支、验证结果,以及仍留在工作区的未提交修改。若只完成本地提交而未推送,必须明确说明。 diff --git a/.agents/skills/commit-git-changes/agents/openai.yaml b/.agents/skills/commit-git-changes/agents/openai.yaml new file mode 100644 index 0000000..016d7d0 --- /dev/null +++ b/.agents/skills/commit-git-changes/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "提交 Git 代码" + short_description: "安全审查改动并生成详细中文说明,完成 Git 提交与远端推送" + default_prompt: "使用 $commit-git-changes 审查当前改动,生成详细中文提交说明并提交到 Git 服务器。" diff --git a/.agents/skills/discuss-sdk-feature-solution/SKILL.md b/.agents/skills/discuss-sdk-feature-solution/SKILL.md new file mode 100644 index 0000000..30f35c7 --- /dev/null +++ b/.agents/skills/discuss-sdk-feature-solution/SKILL.md @@ -0,0 +1,44 @@ +--- +name: discuss-sdk-feature-solution +description: 讨论 ReadViewSDK 的新功能、修复或重构方案,核验 EPUB/PDF 阅读器源码,比较实现路径并收敛为可直接开发的计划。用户说“先讨论”“先给方案”“比较方案”“不要改代码”,或接口、缓存、渲染路径、持久化兼容存在会改变实现方式的灰区时使用;路径已明确且用户要求直接修改时不要使用。 +--- + +# SDK 功能方案讨论 + +## 目标 + +在编码前用仓库事实收敛决策,输出最小、可验证、能交给开发 Skill 直接执行的方案。本 Skill 默认不修改业务代码。 + +## 工作流 + +1. 检查工作区并定位目标模块、入口、相邻实现和对应文档。 +2. 区分已锁定决定、阻塞决定、实现假设、后续项与非范围。 +3. 按根目录 `AGENTS.md` 读取所需文档;结论必须由真实路径、类型、方法和调用链支撑。 +4. 方案未定时给出 2–4 个可行路径,比较改动面、公开 API、缓存/持久化、渲染路径、性能、回归和维护成本,并明确推荐一个。 +5. 用户已选定方案时只细化该方案,不继续横向发散。 +6. 方案至少覆盖: + - EPUB:`.textReflowable`、`.webInteractive`、`.webFixedLayout` 中受影响的路径 + - PDF:宿主图片 Provider 与内置 PDFKit Provider 中受影响的路径 + - 公共 API、Codable/磁盘格式、缓存版本和 CocoaPods 集成影响 + - 异步乱序、取消、生命周期、内存压力和失败降级 +7. 收敛为文件级任务、数据/状态流、成功标准、验证方式、风险和回滚点。 + +## 输出 + +默认在对话中给出: + +- 需求、范围与非范围 +- 已确认决定、阻塞项和必要假设 +- 方案对比或已选方案拆解 +- 涉及文件、调用链、复用点和兼容策略 +- 风险、回滚点与验证清单 +- 建议交给 `start-sdk-feature-dev-lite` 或 `start-sdk-feature-dev` 的执行摘要 + +用户要求落文档,或方案需要跨会话执行时,写入 `Doc/FeatureSolution/`,并在存在文档索引时同步 `Doc/index.md`。 + +## 边界 + +- 不为未确认的未来需求预埋抽象。 +- 必要改动、可选优化和后续治理必须分开。 +- 未核验的系统 API、第三方能力、文件格式或缓存行为不得写成事实。 +- 文档与源码冲突时以当前源码为事实,并明确指出文档待同步项。 diff --git a/.agents/skills/discuss-sdk-feature-solution/agents/openai.yaml b/.agents/skills/discuss-sdk-feature-solution/agents/openai.yaml new file mode 100644 index 0000000..040e39e --- /dev/null +++ b/.agents/skills/discuss-sdk-feature-solution/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "SDK 功能方案讨论" + short_description: "核验当前仓库事实,比较实现路径并输出可直接开发的完整方案" + default_prompt: "Use $discuss-sdk-feature-solution to compare implementation paths and produce an executable handoff." diff --git a/.agents/skills/generate-module-code-style-doc/SKILL.md b/.agents/skills/generate-module-code-style-doc/SKILL.md new file mode 100644 index 0000000..c717ff9 --- /dev/null +++ b/.agents/skills/generate-module-code-style-doc/SKILL.md @@ -0,0 +1,23 @@ +--- +name: generate-module-code-style-doc +description: 基于真实源码生成或更新 ReadViewSDK 指定模块的代码组织、命名、分层、UI、状态、缓存和复用规范,适用于 EPUBCore、EPUBTextRendering、ReaderView、EPUBUI、RDPDFReaderView 或 Demo。全局规范使用 generate-sdk-code-style-doc。 +--- + +# 生成模块代码规范 + +## 工作流 + +1. 确定目标模块、关注范围和输出文件;优先增量更新 `Doc/` 中现有对应文档。 +2. 读取目标模块源码、对应 code reference、`Doc/CONVENTIONS.md` 和必要架构章节。 +3. 抽样入口、协议、模型、View/Cell、Controller、Coordinator/Service、缓存和 Extension;不存在的层级不要补造。 +4. 提炼真实且重复出现的模式,并区分当前约定、特殊例外及原因、尚未落地的建议。 +5. 记录目录职责、命名、可见性、依赖方向、状态管理、布局、回调、缓存、失败处理、公共 API 和跨模块边界。 +6. 对 EPUB 模块说明适用的渲染路径;对 PDF 模块说明自定义 Provider 与 PDFKit 路径差异。 +7. 增量更新文档,检查与全局规范、podspec、架构和源码是否冲突。 +8. 文档新增、移动或重命名时同步 `Doc/index.md`。 + +## 边界 + +- 只描述目标模块,不把局部模式提升为全局事实。 +- 不虚构理想分层、协议或不存在的调用链。 +- 默认不修改业务代码;发现问题时作为建议或 finding 单独列出。 diff --git a/.agents/skills/generate-module-code-style-doc/agents/openai.yaml b/.agents/skills/generate-module-code-style-doc/agents/openai.yaml new file mode 100644 index 0000000..bdea1dc --- /dev/null +++ b/.agents/skills/generate-module-code-style-doc/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "生成模块代码规范" + short_description: "基于真实源码生成指定 EPUB、PDF 或 Demo 模块的局部规范文档" + default_prompt: "Use $generate-module-code-style-doc to document the coding patterns of this ReadViewSDK module." diff --git a/.agents/skills/generate-module-implementation-logic/SKILL.md b/.agents/skills/generate-module-implementation-logic/SKILL.md new file mode 100644 index 0000000..98819ec --- /dev/null +++ b/.agents/skills/generate-module-implementation-logic/SKILL.md @@ -0,0 +1,34 @@ +--- +name: generate-module-implementation-logic +description: 从 ReadViewSDK 真实源码生成或更新指定模块或场景的入口、调用链、数据流、状态流、渲染流、缓存、持久化、异步回调和异常分支文档。用户要求梳理实现逻辑、调用链、分页或缓存流程时使用;代码风格规范改用对应 code-style Skill。 +--- + +# 生成模块实现逻辑 + +## 工作流 + +1. 确定目标模块、用户场景和输出路径;优先复用 `Doc/` 中现有专题或 code reference。 +2. 读取目标入口、现有文档,以及 `AGENTS.md` 路由出的架构、API 或测试资料。 +3. 用引用搜索沿真实关系追踪: + - 页面、控制器、协议或服务入口及触发条件 + - 数据模型、状态所有者和线程/队列 + - EPUB 解析/排版/分页/章节窗口,或 PDF Provider/渲染/OCR/标注 + - 内存与磁盘缓存、键、版本、驱逐和恢复 + - 通知、回调、delegate、持久化和跨模块依赖 + - 空值、失败、取消、重试、超时、迟到结果和兼容分支 +4. 对关键链路至少双向核验一次:从入口跟到结果,再从结果或回调查回调用方。 +5. 区分当前实现、历史兼容层、仅文档中的计划和待确认事实。 +6. 增量更新文档,保留正确内容,删除或标记失效链路。 +7. 结论引用真实文件、类型和方法;不确定信息明确标为待确认。 +8. 文档新增、移动或重命名时同步 `Doc/index.md`。 + +## 推荐结构 + +- 适用范围与模块职责 +- 入口和主要对象 +- 主流程与数据/状态/渲染变化 +- 缓存、持久化和并发模型 +- 异常、取消、重试和兼容处理 +- 维护风险与验证要点 + +纯文档任务不修改业务代码;源码暴露的缺陷应单独报告,不把建议写成已实现事实。 diff --git a/.agents/skills/generate-module-implementation-logic/agents/openai.yaml b/.agents/skills/generate-module-implementation-logic/agents/openai.yaml new file mode 100644 index 0000000..a0e21f0 --- /dev/null +++ b/.agents/skills/generate-module-implementation-logic/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "生成模块实现逻辑" + short_description: "梳理指定阅读器模块的入口、调用链、状态流、缓存及异常分支" + default_prompt: "Use $generate-module-implementation-logic to document this module's verified implementation flow." diff --git a/.agents/skills/generate-sdk-code-style-doc/SKILL.md b/.agents/skills/generate-sdk-code-style-doc/SKILL.md new file mode 100644 index 0000000..dbb3dec --- /dev/null +++ b/.agents/skills/generate-sdk-code-style-doc/SKILL.md @@ -0,0 +1,26 @@ +--- +name: generate-sdk-code-style-doc +description: 基于 ReadViewSDK 当前 EPUB、PDF 和 Demo 源码生成或更新全局代码规范、目录职责、命名、分层、UIKit/SnapKit、并发、缓存、公共 API 与测试约定。用户要求项目级编码规范或更新 Doc/CONVENTIONS.md 时使用;单模块规范改用 generate-module-code-style-doc。 +--- + +# 生成 SDK 全局代码规范 + +## 工作流 + +1. 明确输出路径;未指定时增量更新 `Doc/CONVENTIONS.md`。 +2. 读取现有目标文档、`Doc/ARCHITECTURE.md`、`Doc/index.md` 和两个 podspec。 +3. 从 EPUBCore、EPUBTextRendering、ReaderView、EPUBUI、RDPDFReaderView 与 Demo/UITests 抽样真实源码。 +4. 把结论分为: + - 多模块源码验证的现行约定 + - 仅适用于 EPUB、PDF 或 Demo 的局部模式 + - 明确标记为建议、尚未成为项目事实的待统一项 +5. 覆盖目录职责、命名与可见性、公共 API、UIKit/SnapKit、异步与取消、缓存/持久化、错误处理、测试、注释和 CocoaPods 约定。 +6. 增量保留仍正确内容,删除失效描述;使用真实路径、类型和方法作为证据。 +7. 新增、移动或重命名文档时同步 `Doc/index.md`,最后检查链接和源码一致性。 + +## 边界 + +- 不把 EPUB 的局部模式提升为 PDF 的全局规范,反之亦然。 +- 不以旧 `.claude/skills` 或过期文档替代当前源码事实。 +- 不把个人偏好写成现行规范。 +- 文档任务默认不修改业务代码;发现缺陷时单独报告。 diff --git a/.agents/skills/generate-sdk-code-style-doc/agents/openai.yaml b/.agents/skills/generate-sdk-code-style-doc/agents/openai.yaml new file mode 100644 index 0000000..5dc4e13 --- /dev/null +++ b/.agents/skills/generate-sdk-code-style-doc/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "生成 SDK 全局规范" + short_description: "从当前 EPUB 与 PDF 源码证据生成或更新项目级代码规范文档" + default_prompt: "Use $generate-sdk-code-style-doc to update the project-wide coding conventions from source evidence." diff --git a/.agents/skills/review-swift-ios-sdk/SKILL.md b/.agents/skills/review-swift-ios-sdk/SKILL.md new file mode 100644 index 0000000..e60b007 --- /dev/null +++ b/.agents/skills/review-swift-ios-sdk/SKILL.md @@ -0,0 +1,48 @@ +--- +name: review-swift-ios-sdk +description: 对 ReadViewSDK 的 Swift/iOS 代码进行专项审查,覆盖并发、主线程、生命周期、内存、缓存、渲染性能、可访问性、安全、公共 API 和持久化兼容。用户要求继续检查隐藏问题、代码审查、性能或内存审计时使用;普通功能开发和纯文档任务不要单独使用。 +--- + +# Swift/iOS SDK 专项审查 + +## 工作流 + +1. 明确审查范围和修改授权;只要求审查时不得修改代码。 +2. 检查工作区和最近改动,读取目标代码、相邻实现及 `AGENTS.md` 路由出的文档。 +3. 沿入口、异步回调、持久化和渲染结果双向追踪,不只检查单个函数。 +4. 仅报告能由当前源码证明、可触发且值得行动的问题。 + +## 检查维度 + +### 并发与生命周期 + +- UI 是否回到主线程,迟到结果是否会覆盖新状态。 +- token、重试、超时和取消是否保证恰好一次完成。 +- 闭包、Task、通知、定时器、观察者、WKWebView 和 Vision 请求是否释放。 +- PDFKit、缓存字典和持久化文件是否在正确队列或锁内访问。 + +### 性能与内存 + +- 滚动、缩放、绘画和分页热点是否包含同步 IO、重复解码或重复排版。 +- EPUB 章节窗口、页图缓存、PDF 页面位图和绘画缓存是否有明确边界。 +- 内存警告后可见内容能否恢复,驱逐后迟到回调是否重新撑大缓存。 +- 图片像素成本、NSCache 限额、autoreleasepool 和大对象生命周期是否合理。 + +### SDK 兼容性 + +- `public` API、协议要求、初始化方法和默认行为是否意外 breaking。 +- Codable、文件名、缓存键、book identifier 和 schema version 是否可迁移。 +- EPUB 三种渲染路径和 PDF 两种 Provider 路径是否行为一致。 +- podspec 是否包含新增源码、资源、系统 framework 和依赖。 + +### 交互、安全与可访问性 + +- 缩放锚点、Cell 复用、双页布局、手势竞争和绘画图层是否稳定。 +- 图标按钮、自定义控件、动态字体和 VoiceOver 语义是否完整。 +- 日志、文件路径和错误信息是否泄露敏感数据。 + +## 输出 + +按严重程度列出 findings;每条包含位置、触发条件、用户影响、根因和最小修复建议。没有发现问题时明确说明,并列出尚未覆盖的运行时验证空白。 + +若用户同时要求修改,沿用对应开发 Skill 的实现、验证和交付规则。 diff --git a/.agents/skills/review-swift-ios-sdk/agents/openai.yaml b/.agents/skills/review-swift-ios-sdk/agents/openai.yaml new file mode 100644 index 0000000..ea33424 --- /dev/null +++ b/.agents/skills/review-swift-ios-sdk/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Swift/iOS SDK 专项审查" + short_description: "审查并发、生命周期、性能、内存及公开 API 风险" + default_prompt: "Use $review-swift-ios-sdk to audit this ReadViewSDK code for iOS-specific risks." diff --git a/.agents/skills/start-sdk-feature-dev-lite/SKILL.md b/.agents/skills/start-sdk-feature-dev-lite/SKILL.md new file mode 100644 index 0000000..c7006a8 --- /dev/null +++ b/.agents/skills/start-sdk-feature-dev-lite/SKILL.md @@ -0,0 +1,41 @@ +--- +name: start-sdk-feature-dev-lite +description: 实现 ReadViewSDK 的单模块功能、Bug 修复、UI 调整、局部重构、编译修复或小范围 CocoaPods 变更,并完成必要验证。作为默认开发入口;跨 EPUB/PDF 模块、改变公共协议或持久化格式、需要迁移回滚或严格执行完整方案时使用 start-sdk-feature-dev。 +--- + +# SDK 轻量开发 + +## 目标 + +用最小改动完成边界清晰的任务,形成“实现—验证—交付”闭环。 + +## 工作流 + +1. 检查工作区,识别并保留用户已有修改。 +2. 定位目标源码、现有相邻模式和 `AGENTS.md` 路由出的文档。 +3. 若用户提供方案,完整读取并执行已锁定决定;源码事实使方案不可行时先说明冲突。 +4. 明确 In Scope、Out of Scope、成功标准和短执行清单。 +5. 按模块边界实施最小修改: + - EPUB 解析与资源:`Sources/RDEpubReaderView/EPUBCore` + - EPUB 文本排版:`Sources/RDEpubReaderView/EPUBTextRendering` + - EPUB 翻页容器:`Sources/RDEpubReaderView/ReaderView` + - EPUB 成品 UI:`Sources/RDEpubReaderView/EPUBUI` + - PDF 阅读器:`Sources/RDPDFReaderView` + - 集成与回归入口:`ReadViewDemo` +6. 自检空值、失败分支、异步乱序、取消语义、主线程、生命周期、Cell 复用、缓存边界、内存警告和可访问性。 +7. 检查公共 API、持久化模型、缓存键/版本、资源路径及 podspec 是否被意外改变。 +8. 按风险执行构建或定向 UI 测试;编译错误应修复并重试。 +9. 代码行为显著变化时更新最接近的 `Doc/` 文档。 + +## 升级条件 + +出现以下任一情况时切换到 `start-sdk-feature-dev` 并说明: + +- 同时改变 EPUB 与 PDF 阅读器,或改变多个 EPUB 层级的职责。 +- 修改宿主可见协议、公共初始化方法或需要兼容迁移。 +- 改变持久化格式、缓存架构、分页主流程或跨模块状态流。 +- 用户要求逐项执行完整开发方案。 + +## 交付 + +最终简洁说明完成内容、关键取舍、主要文件、验证结果、未覆盖风险和同步文档;不强制套固定模板。 diff --git a/.agents/skills/start-sdk-feature-dev-lite/agents/openai.yaml b/.agents/skills/start-sdk-feature-dev-lite/agents/openai.yaml new file mode 100644 index 0000000..caff868 --- /dev/null +++ b/.agents/skills/start-sdk-feature-dev-lite/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "SDK 轻量开发" + short_description: "完成局部功能、Bug 修复、小范围重构及对应构建验证闭环" + default_prompt: "Use $start-sdk-feature-dev-lite to implement this scoped ReadViewSDK change and verify it." diff --git a/.agents/skills/start-sdk-feature-dev/SKILL.md b/.agents/skills/start-sdk-feature-dev/SKILL.md new file mode 100644 index 0000000..1dfa1b8 --- /dev/null +++ b/.agents/skills/start-sdk-feature-dev/SKILL.md @@ -0,0 +1,43 @@ +--- +name: start-sdk-feature-dev +description: 实现 ReadViewSDK 的跨层或跨阅读器改造、分页/缓存/持久化系统性重构、公共 API 演进、复杂联调,或严格执行用户提供的完整方案文档。单模块功能、UI 微调、Bug 修复和局部改动优先使用 start-sdk-feature-dev-lite。 +--- + +# SDK 完整开发 + +## 目标 + +对复杂任务建立可追溯计划,控制接口兼容、依赖、迁移和回滚风险,完成实现、联调、验证与文档同步。 + +## 工作流 + +1. 检查工作区,区分本任务改动和用户已有改动。 +2. 明确主改动模块、受影响模块、In Scope、Out of Scope、联调依赖和完成标准。 +3. 按 `AGENTS.md` 读取架构、公共 API、目标子系统和测试文档,并用源码复核。 +4. 用户提供方案时: + - 完整读取并提取锁定决定、任务清单、文件清单、限制和验收标准。 + - 逐项执行,不自行替换技术路径、数据模型或文件布局。 + - 方案与源码冲突时暂停,说明影响和最小修订建议。 +5. 没有方案时制定最小可落地计划,明确执行顺序、数据/状态流、风险、回滚点和验证。 +6. 实施时区分计划内修改、不可避免的“必要补齐”和可选后续项;会改变方案的偏差先确认。 +7. 做跨模块自检: + - EPUB 四层依赖方向和三种渲染路径 + - PDF 自定义 Provider 与 PDFKit 直读路径 + - 公共 API 的源码兼容与行为兼容 + - Codable/磁盘格式迁移、缓存键、版本和失效策略 + - 异步 token、取消、迟到回调、主线程和生命周期 + - 大图、排版产物、章节窗口和内存警告处理 + - CocoaPods source/resource/framework 配置和 Demo 集成 +8. 同步架构、API、子系统或测试文档;文档新增或移动时更新索引。 +9. 执行构建和与风险相称的定向回归,记录无法自动覆盖的联调项。 + +## 方案控制 + +- 用户确认的方案不能被“更简单的建议”静默覆盖。 +- 不以顺手治理为由扩大重构范围。 +- 涉及线上数据、缓存迁移或公共 API 时必须写明兼容、回滚或降级方式。 +- 新增依赖、资源或公开类型前必须确认 podspec 和宿主集成影响。 + +## 交付 + +最终说明范围和计划完成情况、关键实现与偏差、改动模块、构建/测试结果、兼容与回滚策略、未覆盖风险和同步文档。 diff --git a/.agents/skills/start-sdk-feature-dev/agents/openai.yaml b/.agents/skills/start-sdk-feature-dev/agents/openai.yaml new file mode 100644 index 0000000..0febc9a --- /dev/null +++ b/.agents/skills/start-sdk-feature-dev/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "SDK 完整开发" + short_description: "执行跨阅读器模块改造、复杂联调、兼容评估与完整验证交付" + default_prompt: "Use $start-sdk-feature-dev to implement this cross-module ReadViewSDK plan with full verification." diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.claude/skills/discuss-feature-solution.md b/.claude/skills/discuss-feature-solution.md deleted file mode 100644 index 0df7f99..0000000 --- a/.claude/skills/discuss-feature-solution.md +++ /dev/null @@ -1,286 +0,0 @@ ---- -name: "Discuss SDK Feature Solution" -description: "方案讨论优先 skill:用于 ReadViewSDK 新需求的实现方案分析、路径对比、取舍沟通、已定方案细化和实现交接。" -argument-hint: "粘贴需求、目标模块、约束、已知备选方案;可附加:是否已确定方案 / 是否需要推荐方案 / 是否需要落方案文档。" ---- - -# Discuss SDK Feature Solution - -## Purpose - -面向 ReadViewSDK 的"实现方案讨论入口" skill。 -用于在正式开发前,先把需求、约束、可选实现路径和关键取舍聊透;若方案已经确定,则只围绕已选方案继续细化,再交接给开发 skill 进入实现。 - -该 skill 借鉴 spec-driven 工作流思想:讨论阶段专门捕获灰区决策,计划阶段必须经过代码 / 文档事实核验,最终输出能直接喂给开发 skill 的结构化方案文档,避免"聊完还是不能开发"。 - -该 skill 的职责固定为: -- 先理解需求与约束 -- 捕获灰区问题和实现假设 -- 用现有代码 / 文档核验方案可行性 -- 未定方案时:输出 2-4 个可行实现方案,并做对比 -- 已定方案时:只围绕确认方案展开实现讨论,不再继续扩展其他方案 -- 最后给出交接到开发 skill 的明确指令 -- 最终必须产出一份可直接供开发 skill 使用的开发详细文档,或一段可直接执行的开发详细描述 - -## Core Discussion Rules - -- Rule 1 — Think Before Coding - - 在提出方案前先核对代码和文档事实,不静默假设 - - 必须显式写出关键假设、主要取舍和已知风险 - - 遇到会改变实现路径的关键灰区,先提出并请求确认,不靠猜测补完整个方案 - - 若存在更简单且满足目标的实现路径,必须主动指出并优先推荐 -- Rule 2 — Simplicity First - - 推荐方案优先选择最小可落地路径,而不是概念上更"完整"的设计 - - 不为了未来可能性预埋推测性扩展,不为单次使用引入抽象 - - 若某个方案明显过度设计,必须直接指出并解释为什么不推荐 -- Rule 3 — Surgical Changes - - 方案只覆盖完成当前目标所必需的改动面 - - 不把无关重构、顺手统一风格或额外治理动作夹带进推荐方案 - - 若确有相邻改动依赖,必须明确标注"必要改动"与"可选优化"的边界 -- Rule 4 — Goal-Driven Execution - - 讨论输出必须先定义成功标准和验证方式,再给实现交接建议 - - 不把讨论步骤本身当结果,重点是产出可执行、可验证的方案 - - 交接给开发 skill 时,必须让对方清楚"做到什么算完成" - -## When To Use - -当用户提出以下类型需求时使用: -- "先讨论一下这个需求怎么实现" -- "这个功能可能有多种实现方式,先分析方案" -- "先别写代码,先帮我想实现路径" -- "先比较几种方案,再决定怎么做" - -适用场景: -- 同一个需求可以通过多种技术路径实现 -- 需要权衡 SDK 分层(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI)、复用、维护成本、回归影响 -- 需要在实现前先明确推荐方案和待确认细节 -- 已经明确采用某个方案,但还需要继续细化实现边界、拆解落地路径和交接开发 - -不适用场景: -- 用户已经明确要直接实现,且实现路径基本单一时,优先使用开发类 skill -- 用户只想生成文档,不需要方案讨论时,优先使用文档类 skill - -## Inputs - -推荐输入: -- 需求描述 -- 目标模块或 Feature(属于哪一层:EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI / Legacy) -- 已知约束(兼容性、性能、API 兼容、CocoaPods 发布等) -- 已有备选方案(如有) -- 已确认方案(如已决定) -- 是否需要推荐方案 -- 是否需要最终落方案文档 - -若信息不足: -- 先通过代码和文档补齐可发现事实 -- 再围绕真正影响方案选择的灰区进行澄清 -- 若用户不想继续问答,必须显式列出"实现假设",并把假设写入方案或交接摘要 - -若用户已经明确指定"采用方案 X / 就按这个方案做": -- 视为进入"已定方案模式" -- 后续输出禁止继续罗列其他候选方案、备选实现或横向对比 -- 仅允许在必要时补充"当前方案的风险、前提、边界和实现细化" - -## Scope / Required Context - -默认先读: -1. `Doc/ARCHITECTURE.md` — 四层架构、数据流、分页模式、位置模型、已知限制 -2. `Doc/CODING_STYLE.md` — 命名规范(RD/RDEPUB 前缀)、分层规则、extension 拆分规则、SS→RD 迁移计划 -3. `Doc/EPUB_MAINTENANCE.md` — 文件职责表、DTCoreText 渲染管线、常见排查场景 - -按需再读: -- 涉及 EPUB 解析时:`Sources/RDReaderView/EPUBCore/` 下相关文件 -- 涉及文本渲染时:`Sources/RDReaderView/EPUBTextRendering/` 下相关文件 -- 涉及阅读器容器时:`Sources/RDReaderView/RDReaderView.swift` 及同级文件 -- 涉及 UI 层时:`Sources/RDReaderView/EPUBUI/` 下相关文件 -- 涉及遗留代码时:`Sources/RDReaderView/LegacyRDReaderController/` 下相关文件 -- 涉及 JS 桥接时:`Sources/RDReaderView/Resources/epub-bridge.js` - -若文档与代码不一致: -- 以代码事实为准做方案讨论 -- 在推荐方案中指出不一致点和可能影响 - -方案文档落地目录约束: -- 讨论型方案文档统一落到 `Doc/FeatureSolution/`(若目录不存在则创建) -- 不要把方案讨论文档写入其他目录 - -## SDK-Specific Discussion Rules - -讨论时必须考虑的 SDK 特有约束: - -- **分层边界**:方案必须明确落在哪一层,不得跨层引入反向依赖(上层依赖下层允许,反之不允许) -- **Public API 兼容性**:若改动涉及公共接口(`RDReaderView`、`RDReaderDataSource`、`RDURLReaderController`、`RDEPUBReaderController` 等),必须评估对宿主 App 的 breaking change 影响 -- **渲染路径差异**:方案必须考虑三种渲染路径(`webFixedLayout` / `webInteractive` / `textReflowable`)的适用性,不能只覆盖单一路径 -- **CocoaPods 发布影响**:若方案涉及新增依赖、资源文件或模块结构调整,必须评估 podspec 变更 -- **RD 前缀规范**:所有新增类型必须使用 `RD` 前缀,EPUB 相关使用 `RDEPUB` 前缀 -- **Legacy 层边界**:不在 Legacy 层新增功能;若方案涉及 Legacy 代码,必须明确是迁移还是在新层实现 - -## GSD-Inspired Discussion Rules - -讨论阶段要像 `discuss -> plan -> verify` 闭环一样,先捕获决策,再验证计划,而不是直接跳到实现建议。 - -必须执行: -- 灰区捕获:先识别布局、接口形态、数据结构、错误态、路由、持久化、兼容性、回归范围等不明确点 -- 事实核验:方案落地前必须用本仓库代码和 `Doc/` 文档确认入口、复用点、约束和风险 -- 假设显式化:不能确认的问题必须写成 `Assumption`,不得藏在方案正文里 -- 阻塞分级:会改变实现路径的问题标记为 `Blocking Decision`,不会改变路径的问题标记为 `Follow-up` -- 审计留痕:若落方案文档,必须包含"讨论结论 / 关键决策 / 假设 / 非范围 / 验证清单 / 开发交接指令" -- 计划可执行:最终方案必须足够小,能被开发 skill 直接逐项执行 - -禁止行为: -- 不得只输出宽泛建议,必须落到文件、类、方法、数据流或协议层面的执行点 -- 不得在用户已确认方案后继续展开新方案,除非用户明确要求重新比较 -- 不得把未经核验的包、SDK、接口能力写成已确认事实 -- 不得把需要用户拍板的关键决策伪装成默认实现 - -## Required Workflow - -### Phase 1 — 识别需求、范围、约束、现状 - -- 明确目标价值、影响模块(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI / Legacy)、In Scope / Out of Scope -- 识别当前项目里已有的实现模式、相邻能力和复用点 -- 判断这是单层需求还是跨层需求 -- 建立灰区清单,区分 `Blocking Decision` 与 `Follow-up` - -### Phase 2 — 判断讨论模式 - -- 若方案未定:进入"多方案对比模式" -- 若方案已定:进入"单方案细化模式" -- 一旦用户已确认方案,后续同一轮讨论默认保持"单方案细化模式",除非用户明确要求重新打开方案比较 - -### Phase 3 — Research / Verify:核验代码与文档事实 - -- 对照当前仓库确认真实入口、调用链、数据模型、复用的 cell / view / handler / controller / protocol -- 若方案涉及新增文件,必须确认所属目录层级和是否需要更新 podspec 的 source_files -- 若方案涉及接口或数据字段,必须明确字段来源、空值策略和兼容策略 -- 若方案涉及 JS 桥接,必须确认 epub-bridge.js 的交互契约 -- 若发现文档和代码不一致,必须标注为风险或阻塞项 -- 若核验结果推翻原方案,必须暂停说明,不得继续包装成可执行方案 - -### Phase 4A — 多方案对比模式:列出多种实现路径 - -- 至少提出 2 个可行方案,推荐 2-4 个方案 -- 每个方案都要有清晰的实现方向,而不是抽象建议 -- 优先从现有项目模式和复用能力出发 - -### Phase 4B — 多方案对比模式:比较方案优缺点和影响范围 - -- 比较每个方案的: - - 核心思路 - - 落在哪一层(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI) - - 适用前提 - - 优点 - - 风险 / 成本 -- 对 SDK 分层、Public API、podspec、回归范围的影响 -- 明确哪些差异会真正影响后续实现和维护 - -### Phase 5A — 多方案对比模式:提出推荐方案,并列出待确认细节 - -- 必须明确推荐一个默认方案,不能只平铺选项 -- 待确认项只保留真正影响实现的关键决策 -- 默认产物为对话输出;若用户明确要求,再可选落到 `Doc/FeatureSolution/` 下方案文档 - -### Phase 5B — 单方案细化模式:围绕确认方案展开 - -- 不再输出"方案 A / 方案 B / 方案 C"式内容 -- 不再补充"其他也可以这样做"的备选实现,除非用户明确要求回到方案比较 -- 只讨论以下内容: - - 当前方案的实现拆解(文件 / 类 / 方法 / 协议) - - SDK 分层内的模块边界与职责分配 - - 关键数据流 / 状态流 / 渲染流 - - 复用点、依赖点、回归点 - - 对三种渲染路径的覆盖情况 - - 风险、前提、灰度方式、验证要点 - - 交接给开发 skill 的实现摘要 - -### Phase 6 — 输出实现交接建议 - -- 在方案确认后,明确下一步应交给哪个开发 skill: - - 小中型、单层、MVP 优先:轻量开发 skill - - 跨层、复杂联调、完整交付:完整开发 skill -- 无论是否落文档,最终都必须产出开发交接载体,且二选一不能缺失: - - 完整开发 skill:产出可直接执行的开发详细文档,默认落到 `Doc/FeatureSolution/` - - 轻量开发 skill:产出可直接粘贴执行的开发详细描述,覆盖实现目标、范围、代码落点、关键步骤、验收标准和自测要点 -- 给出可直接粘贴给开发 skill 的实现摘要;若已有更完整的详细文档/描述,则摘要必须引用它而不是只给一句话概括 -- 若落方案文档,必须让开发指令引用该文档的真实路径,并提醒开发 skill 严格按计划执行 - -## Output Contract - -默认输出结构按模式区分: - -若方案未定: -- A. 需求理解 -- B. 灰区问题与实现假设 -- C. 当前实现与约束 -- D. 可选方案对比 -- E. 推荐方案 -- F. 待确认实现细节 -- G. 实现交接建议 - -若方案已定: -- A. 需求理解 -- B. 灰区问题与实现假设 -- C. 当前实现与约束 -- D. 确认方案拆解 -- E. 实现风险与关键细节 -- F. 实现交接建议 - -其中要求: -- 未定方案时: - - `B. 灰区问题与实现假设` 必须区分 Blocking Decision / Follow-up / Assumption - - `D. 可选方案对比` 至少给出 2 个方案,推荐 2-4 个 - - `E. 推荐方案` 必须明确推荐一个默认方案 - - `F. 待确认实现细节` 只保留会改变实现路径的关键问题 -- `G. 实现交接建议` 必须明确下一步交给哪个开发 skill -- `G. 实现交接建议` 必须附带可供开发 skill 直接使用的交接内容: - - 若下一步是完整开发 skill,必须提供开发详细文档路径或完整文档正文 - - 若下一步是轻量开发 skill,必须提供开发详细描述正文 -- 已定方案时: - - `B. 灰区问题与实现假设` 必须列出阻塞项、假设和后续项 - - `D. 确认方案拆解` 只能围绕确认方案展开,禁止附带其他方案信息 - - `E. 实现风险与关键细节` 只讨论该方案落地所需信息 - - `F. 实现交接建议` 必须明确下一步交给哪个开发 skill,并附带可直接执行的详细文档或详细描述 - -若用户要求落方案文档,文档必须包含: -- 需求目标 -- 讨论结论 -- 关键决策 -- 实现假设 -- 非范围 -- SDK 分层落点(文件 / 类 / 方法 / 协议) -- 数据流 / 状态流 / 渲染流 -- 开发任务清单 -- 验收标准 -- 自测清单 -- 待确认项 -- 给开发 skill 的执行指令 - -若未落方案文档,但下一步要交给轻量开发 skill,则最终输出中的"开发详细描述"至少必须包含: -- 实现目标 -- In Scope / Out of Scope -- SDK 分层落点(文件 / 类 / 方法 / 协议) -- 实现步骤 -- 关键约束与复用点 -- 对三种渲染路径的覆盖说明 -- 验收标准 -- 自测清单 -- 待确认项或实现假设 - -## Validation - -- 本 skill 默认只做方案讨论,不直接进入代码实现 -- 默认不修改仓库文件;只有用户明确要求时,才可选落方案文档到 `Doc/FeatureSolution/` -- 即使不落方案文档,最终回复也必须包含一份可直接交给开发 skill 使用的详细交接内容,不能只停留在高层方案结论 -- 若本次调用只做讨论或只新增方案文档,可不执行构建验证 -- 若新增或移动方案文档,建议同步更新目录索引 -- 若后续进入代码实现,则由对应开发 skill 负责执行项目默认构建验证(`xcodebuild` 或 CocoaPods 集成验证) -- 若用户已确认方案,则默认进入单方案细化模式,除非用户明确要求重新比较备选方案 - -## Maintenance Rules - -- 该 skill 的职责是"讨论后交接实现",不和开发 skill、文档类 skill 重叠 -- 优先复用 `Doc/` 作为知识源,不新增独立 references 体系 -- 方案文档目录固定为 `Doc/FeatureSolution/` -- 持续保持 `discuss -> research/verify -> plan handoff` 的轻量闭环,避免退化成只聊天不交付的建议列表 -- 若该 skill 的默认输出结构、交接规则或适用边界调整,必须同步更新对应文档 -- SDK 四层架构(EPUBCore → EPUBTextRendering → RDReaderView → EPUBUI)是方案讨论的核心参照框架,任何方案都必须明确标注落点层级 diff --git a/.claude/skills/start-sdk-feature-dev-lite.md b/.claude/skills/start-sdk-feature-dev-lite.md deleted file mode 100644 index e56ba03..0000000 --- a/.claude/skills/start-sdk-feature-dev-lite.md +++ /dev/null @@ -1,213 +0,0 @@ ---- -name: "Start SDK Feature Dev Lite" -description: "轻量开发主控 skill:用于 ReadViewSDK 的小中型功能开发、单层改动、局部重构、文档同步与编译修复。" -argument-hint: "粘贴需求、目标层级、验收标准;可附加:仅 MVP / 禁止新增依赖 / 指定渲染路径。" ---- - -# Start SDK Feature Dev Lite - -## Purpose - -面向 ReadViewSDK 的轻量开发入口 skill。 -用于在现有项目约束下完成"小到中型"需求,遵循"先识别范围、再按需读文档、后实现、再验证、最后交付"的闭环。 - -该 skill 是默认开发入口,优先覆盖: -- 单层内的新功能 MVP 实现 -- 小范围重构或代码整理 -- 文档同步更新 -- 编译错误定位与修复 -- 单个模块的 bug 修复或行为调整 -- podspec 小幅调整 - -默认吸收项目 `Doc/CODING_STYLE.md` 中的 Swift/iOS 通用规则;若任务重点落在并发、性能、可访问性或安全,再额外展开该专项的检查项。 - -## Core Execution Rules - -- Rule 1 — Think Before Coding - - 先核对代码和文档事实,不静默假设 - - 必须显式写出关键假设、主要取舍和已知风险 - - 遇到会改变实现路径的关键灰区,先暂停确认,不靠猜测继续推进 - - 若存在更简单且满足目标的实现路径,必须主动指出并优先采用 -- Rule 2 — Simplicity First - - 只做满足需求和验收标准的最小实现 - - 不增加推测性功能,不为单次使用引入抽象 - - 若方案让资深工程师也会觉得过度设计,必须继续简化 -- Rule 3 — Surgical Changes - - 只修改完成当前需求所必需的文件和代码 - - 不顺手重构无关代码,不扩散到相邻层做"顺便优化" - - 非必要不改注释、格式或既有结构,新增代码保持现有风格 -- Rule 4 — Goal-Driven Execution - - 先定义成功标准,再围绕成功标准实施和验证 - - 不给自己堆步骤,重点是闭环达到验收结果 - - 修改后必须验证,未验证通过前不能视为完成 - -## When To Use - -当用户提出以下类型需求时使用: -- "使用 start-sdk-feature-dev-lite 完成这个功能" -- 在 ReadViewSDK 中开发单一功能或单层变更 -- 需要修复局部编译错误或行为问题 -- 需要同步更新文档 -- 需要小幅调整 podspec - -不适用场景: -- 需求跨多个 SDK 层级、涉及多阶段联调或需要完整项目级方案时,改用 `start-sdk-feature-dev` -- 目标是只生成文档而不改业务代码时,优先使用文档类 skill -- 目标是方案讨论而不进入实现时,优先使用 `discuss-sdk-feature-solution` - -## Inputs - -推荐输入: -- 功能名称 -- 目标 / 用户价值 -- 范围(In Scope) -- 非范围(Out of Scope) -- 目标层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI) -- 详细需求 -- 验收标准 -- 约束(兼容性 / 性能 / 禁止新增依赖 / 指定渲染路径) - -若信息不足: -- 先基于代码和文档补齐可发现事实 -- 再列最多 5 条关键假设 -- 基于假设继续推进 MVP,并在交付中标注待确认项 - -## Scope / Required Context - -仅针对 ReadViewSDK 现有架构执行。 - -SDK 四层架构参照: -- Layer 1 — EPUBCore(`Sources/RDReaderView/EPUBCore/`):EPUB 解析引擎 -- Layer 2 — EPUBTextRendering(`Sources/RDReaderView/EPUBTextRendering/`):文本渲染路径 -- Layer 3 — RDReaderView(`Sources/RDReaderView/` 根目录):分页阅读器容器 -- Layer 4 — EPUBUI(`Sources/RDReaderView/EPUBUI/`):开箱即用 UI -- Legacy(`Sources/RDReaderView/LegacyRDReaderController/`):遗留代码,不在其上新增功能 - -分层依赖规则(上→下允许,下→上禁止): -- EPUBUI → RDReaderView → EPUBTextRendering → EPUBCore - -默认先读: -1. `Doc/ARCHITECTURE.md` — 四层架构、数据流、分页模式、位置模型 -2. `Doc/CODING_STYLE.md` — 命名规范、分层规则、extension 拆分规则 -3. `Doc/EPUB_MAINTENANCE.md` — 文件职责表、渲染管线、排查场景 - -按需再读: -- `Doc/FeatureSolution/*.md`(若有方案文档) -- 涉及哪一层就读该层目录下的相关源码 -- 涉及 JS 桥接时:`Sources/RDReaderView/Resources/epub-bridge.js` -- 涉及 podspec 时:`RDReaderView.podspec` - -若文档与代码不一致: -- 以代码事实为准完成本次实现 -- 在交付中标注不一致点,并指出建议更新的文档 - -## Required Workflow - -### Phase 1 — 识别需求和范围 - -- 明确目标价值、影响层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI)、In Scope / Out of Scope -- 优先做最小可落地实现,不做需求外重构 -- 若需求信息不足,先通过仓库事实补齐,再基于假设推进 - -### Phase 2 — 按需读取 Doc 与代码事实 - -- 先读默认 Doc(ARCHITECTURE / CODING_STYLE / EPUB_MAINTENANCE) -- 根据需求类型补读对应层级的源码 -- 用源码确认真实入口、调用链和复用点,不只依赖文档 - -### Phase 3 — 形成最小实现方案 - -- 保持分层边界: - - EPUB 解析逻辑在 EPUBCore - - 文本渲染逻辑在 EPUBTextRendering - - 分页容器逻辑在 RDReaderView - - 开箱即用 UI 在 EPUBUI - - 不在 Legacy 层新增功能 -- 命名、注释、日志风格遵循 `Doc/CODING_STYLE.md` -- Swift / iOS 默认规则: - - 新增 Swift 类型遵循 `RD` / `RDEPUB` 前缀和层级目录归属 - - 避免新增强制解包;确需使用时必须先收敛前置条件 - - 不为"现代化"而重写稳定代码;仅在本次需求明确受益时再迁移系统 API - - 当前项目默认保持 UIKit + Auto Layout + 既有组件风格,SDK 层不使用 SnapKit - - 注释和日志使用中文 - - Debug 输出放在 `#if DEBUG` 守卫下 - - 不在日志中输出敏感信息 -- 异步闭包默认 `[weak self]`,UI 更新回到主线程 -- 通知 / 定时器 / 回调在生命周期结束时必须清理 -- 数据模型用 `struct` + `Codable` + `Equatable`,服务对象用 `final class` -- 可见性按最小原则:`private` > `fileprivate` > `internal` > `public` -- 错误使用 `enum` + `LocalizedError`,禁止 force unwrap -- 单文件若预计超过 600 行,需主动拆分扩展文件 - -### Phase 4 — 执行修改与文档同步 - -- 所有代码改动遵循最小改动原则 -- 修改代码时必须补充必要注释,重点说明关键逻辑、边界条件和不直观处理;不要省略应有注释,也不要添加无信息量的描述性注释 -- 默认补做轻量 SDK 自检: - - 新增类型是否使用正确前缀和层级归属 - - 本次改动涉及的渲染路径是否正常工作 - - Public API 是否有意外 breaking change - - podspec 是否需要更新(新增文件时) - - 生命周期、通知/定时器/回调清理是否完整 - - 异步闭包是否考虑 `[weak self]` -- 修改代码后必须同步更新受影响文档,不能只停留在代码实现 -- 若本次改动涉及 EPUB 维护相关,必须回写 `Doc/EPUB_MAINTENANCE.md` -- 若本次是方案讨论文档交接落地,方案类文档统一放到 `Doc/FeatureSolution/` -- 若新增、重命名或移动文档,必须同步更新目录索引 - -### Phase 5 — 构建验证与交付 - -- 完成修改后,按项目默认命令执行编译验证 -- 若出现编译错误,自动修复并重编译 -- 若遇构建锁问题,自动重试 -- 交付时固定输出: - - A. 需求理解 - - B. 开发计划 - - C. 开发实施 - - D. 验证结果 - - E. 交付摘要 - -## Output Contract - -最终回复必须完整输出以下 5 个板块,标题保持一致: -- A. 需求理解 -- B. 开发计划 -- C. 开发实施 -- D. 验证结果 -- E. 交付摘要 - -各板块内容要求: -- A:3-6 条,覆盖目标价值、范围、目标层级、限制 -- B:按"方案对齐 / MVP 实现 / 验证与交付"三阶段组织 -- C:描述实际改动、关键实现取舍,以及本次补充了哪些关键代码注释 -- D:给出构建结果、关键路径验证、未覆盖风险 -- E:列出改动文件、关键取舍、已同步文档与具体文档落点、后续建议 - -## Validation - -若本次调用修改了任何 Swift / Objective-C / 工程配置 / podspec 文件,必须执行构建验证。 - -验证方式: -- 若 Demo 工程可用: - ```bash - xcodebuild build -workspace ReadViewSDKDemo/ReadViewSDKDemo.xcworkspace -scheme ReadViewSDKDemo -sdk iphonesimulator -derivedDataPath /private/tmp/readview-sdk-derived - ``` -- 若仅有 SDK 源码(无 workspace): - ```bash - pod lib lint RDReaderView.podspec --allow-warnings - ``` - -验证规则: -- 编译失败时必须自行修复并重试 -- 遇到 `database is locked` 等锁问题时自动重试,建议最多 5 次 -- 直到 `BUILD SUCCEEDED` 或 `pod lib lint passed` 才可交付 -- 如果本次仅修改文档或 skill 文件,可跳过构建,但要在交付中明确说明原因 - -## Maintenance Rules - -- 优先复用 `Doc/`,不要把项目级规则复制进多个 skill 造成双份维护 -- 新增项目约束时,优先更新 `Doc/CODING_STYLE.md`、`Doc/ARCHITECTURE.md` 等主文档,再调整 skill -- 轻量与完整要保持"轻重不同、规则不冲突",其中完整版应在轻量版闭环基础上扩展跨层与联调要求,而不是另起一套风格 -- 变更默认流程、输出格式或适用场景时,必须同步更新相关文档 -- 本 skill 持续作为默认开发入口,保持"轻量、清晰、可直接执行" -- SDK 四层架构是执行的核心参照框架,单层改动也必须明确标注落点层级 diff --git a/.claude/skills/start-sdk-feature-dev.md b/.claude/skills/start-sdk-feature-dev.md deleted file mode 100644 index cac3869..0000000 --- a/.claude/skills/start-sdk-feature-dev.md +++ /dev/null @@ -1,309 +0,0 @@ ---- -name: "Start SDK Feature Dev" -description: "完整开发主控 skill:用于 ReadViewSDK 的跨层功能开发、多阶段联调、结构性重构与完整交付。" -argument-hint: "粘贴完整需求,建议包含:背景、目标、范围、目标层级、交互说明、数据约束、验收标准、渲染路径覆盖要求。" ---- - -# Start SDK Feature Dev - -## Purpose - -面向 ReadViewSDK 的完整开发主控 skill。 -用于复杂度高于单层修改的需求,遵循"先识别范围、再按需读文档、后对齐既定开发计划、再严格执行、再验证、最后交付"的闭环,并补充跨层联调、风险控制、回滚点和完整交付要求。 - -该 skill 优先覆盖: -- 跨多个 SDK 层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI)的功能开发 -- 多阶段联调与完整交付 -- 结构性重构或系统性收敛 -- 涉及 EPUB 解析、渲染管线、阅读器容器、UI 层协同改造的需求 -- 需要更完整风险说明、文档同步和验收闭环的开发任务 - -默认吸收项目 `Doc/CODING_STYLE.md` 中的 Swift/iOS 通用规则;若任务重点落在并发、性能、可访问性或安全,再额外展开该专项的检查项。 - -## Core Execution Rules - -- Rule 1 — Think Before Coding - - 先核对代码、文档和计划事实,不静默假设 - - 必须显式写出关键假设、主要取舍和已知风险 - - 遇到会改变计划或实现路径的关键灰区,先暂停确认,不靠猜测继续推进 - - 若存在更简单且满足目标的实现路径,只能作为建议提出;有既定计划时不得自行改用 -- Rule 2 — Simplicity First - - 只做满足需求、计划和验收标准的最小实现 - - 不增加推测性功能,不为单次使用引入抽象 - - 若方案让资深工程师也会觉得过度设计,必须继续简化或回到计划边界内 -- Rule 3 — Surgical Changes - - 只修改完成当前需求和计划项所必需的文件与代码 - - 不顺手重构无关代码,不扩散到相邻层做"顺便优化" - - 非必要不改注释、格式或既有结构,新增代码保持现有风格 -- Rule 4 — Goal-Driven Execution - - 先定义成功标准,再围绕成功标准实施和验证 - - 计划只是约束,不是目标本身;目标是按计划完成验收闭环 - - 修改后必须验证,未验证通过前不能视为完成 - -## Plan Execution Rule - -若用户提供了开发计划文档、方案文档或明确引用 `Doc/FeatureSolution/*.md` 中的开发方案,本 skill 必须把该文档视为本次实现的主计划来源。 - -严格执行规则: -- 必须先完整读取用户指定的开发计划文档,再开始代码修改 -- 必须从计划中抽取任务清单、文件清单、实现边界、非范围和验收标准 -- 必须按计划逐项实现,不得自行更换方案、合并步骤、替换文件布局、改变数据模型设计或引入计划外抽象 -- 必须遵守计划中的"不做 / 不新增 / 不使用 / 暂不实现"等限制项 -- 若计划与代码事实冲突,或计划中某项无法直接落地,必须暂停并向用户说明冲突点、影响和可选处理方式;不得自行选择替代方案继续实现 -- 若发现更优实现方式,只能作为"建议"在交付或暂停说明中提出,不得在本次实现中自由采用 -- 若计划未覆盖某个必要细节,只允许做最小补齐;补齐内容必须在交付中明确标注为"计划未写明,按最小必要实现补齐" -- 若用户要求"严格按照开发计划执行 / 不要自由发挥",必须把偏离计划视为阻塞项处理 - -## When To Use - -当用户提出以下类型需求时使用: -- "使用 start-sdk-feature-dev 实现这个需求" -- 一个需求影响多个 SDK 层级(跨 EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI) -- 需要完整计划、实现、联调、回归和文档交付 -- 需要跨 EPUB 解析、渲染、阅读器容器、UI 的协同改造 -- 需要明确阶段性计划、回滚点和完整验收说明 - -Lite 与 Full 的边界: -- 轻量开发 skill:单层、小中型、MVP 优先 -- 本 skill(start-sdk-feature-dev):跨层、复杂联调、需要更完整方案和验收 - -## Inputs - -推荐输入模板: -- 功能名称 -- 背景与目标 -- 用户故事 -- 详细需求 -- 非范围 -- 目标层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI / Legacy) -- 交互说明(含空态 / 错误态 / 加载态) -- 渲染路径覆盖要求(webFixedLayout / webInteractive / textReflowable / 不限) -- 数据与接口约束 -- 验收标准(Given-When-Then) -- 兼容性要求(Public API 是否 breaking、podspec 变更等) -- 性能与安全要求 -- 发布时间或优先级 - -若信息不足: -- 先基于代码和文档补齐可发现事实 -- 再列最多 5 条关键假设 -- 若没有既定开发计划,可基于假设继续推进最小可落地实现,并在交付中标注待确认项 -- 若已有开发计划,信息不足时不得自行扩展方案;只能做计划内实现或暂停确认 - -## Scope / Required Context - -仅针对 ReadViewSDK 现有架构执行。 - -SDK 四层架构参照: -- Layer 1 — EPUBCore(`Sources/RDReaderView/EPUBCore/`):EPUB 解析引擎,ZIP 解压、OPF 解析、manifest/spine/TOC、资源 URL 解析、离屏 WKWebView 分页、阅读会话状态机、JS 桥接 -- Layer 2 — EPUBTextRendering(`Sources/RDReaderView/EPUBTextRendering/`):文本渲染路径,DTCoreText 转 NSAttributedString 后按字符范围分页 -- Layer 3 — RDReaderView(`Sources/RDReaderView/` 根目录 6 个文件):分页阅读器容器 UIView,支持 pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll 四种模式 -- Layer 4 — EPUBUI(`Sources/RDReaderView/EPUBUI/`):开箱即用 UI,工具栏、目录面板、高亮管理、设置面板、阅读位置持久化 -- Legacy(`Sources/RDReaderView/LegacyRDReaderController/`):遗留代码,不在其上新增功能 - -分层依赖规则: -- 上层可依赖下层,下层不得依赖上层 -- EPUBUI 可依赖 RDReaderView、EPUBTextRendering、EPUBCore -- RDReaderView 可依赖 EPUBTextRendering、EPUBCore -- EPUBTextRendering 可依赖 EPUBCore -- EPUBCore 不依赖任何上层 - -若确需跨层改动: -- 必须在计划中写明影响范围 -- 必须在交付中写明回滚点或回退策略 - -默认先读: -1. `Doc/ARCHITECTURE.md` — 四层架构、数据流、分页模式、位置模型、已知限制 -2. `Doc/CODING_STYLE.md` — 命名规范(RD/RDEPUB 前缀)、分层规则、extension 拆分、SS→RD 迁移计划 -3. `Doc/EPUB_MAINTENANCE.md` — 文件职责表、DTCoreText 渲染管线、常见排查场景 - -按需再读: -- `Doc/FeatureSolution/*.md`(若有方案文档) -- 涉及 EPUB 解析时:`Sources/RDReaderView/EPUBCore/` 下相关文件 -- 涉及文本渲染时:`Sources/RDReaderView/EPUBTextRendering/` 下相关文件 -- 涉及阅读器容器时:`Sources/RDReaderView/RDReaderView.swift` 及同级文件 -- 涉及 UI 层时:`Sources/RDReaderView/EPUBUI/` 下相关文件 -- 涉及遗留代码时:`Sources/RDReaderView/LegacyRDReaderController/` 下相关文件 -- 涉及 JS 桥接时:`Sources/RDReaderView/Resources/epub-bridge.js` -- 涉及 podspec 时:`RDReaderView.podspec` - -若文档与代码不一致: -- 以代码事实为准落地 -- 在交付中说明不一致点和建议更新的文档项 - -若开发计划文档与代码不一致: -- 不得直接以代码事实替换计划继续开发 -- 必须先判断不一致是否影响计划执行 -- 会影响计划执行时,暂停并向用户说明冲突点和建议调整项 -- 不影响计划执行时,按计划继续,并在交付中标注该不一致点 - -## SDK-Specific Execution Rules - -### 命名与可见性 -- 所有新增类型必须使用 `RD` 前缀,EPUB 相关使用 `RDEPUB` 前缀 -- 新增类型归属正确层级目录,不得随意放置 -- 可见性按最小原则:`private` > `fileprivate` > `internal` > `public` -- 数据模型用 `struct` + `Codable` + `Equatable` -- 服务对象用 `final class` - -### 代码组织 -- 单文件超过 600 行需主动拆分 `TypeName+Feature.swift` 扩展文件 -- 大型 Controller 超过 1000 行必须拆分 -- extension 文件承担独立子职责,不只做"行数搬运" - -### 内存与并发 -- 异步闭包默认 `[weak self]` -- UI 更新必须回到主线程 -- 通知 / 定时器 / 回调在 deinit 或生命周期结束时必须清理 - -### 渲染路径覆盖 -- 新增功能必须评估对三种渲染路径的影响: - - `webFixedLayout`:固定版式 EPUB(漫画、绘本)— WKWebView 渲染 - - `webInteractive`:可重排 + 交互脚本 EPUB — WKWebView 渲染 - - `textReflowable`:纯文本可重排 EPUB — DTCoreText 渲染 -- 若功能仅适用于部分路径,必须在交付中明确标注适用范围和不适用路径的处理方式 - -### Public API 兼容性 -- 涉及公共接口(`RDReaderView`、`RDReaderDataSource`、`RDURLReaderController`、`RDEPUBReaderController` 等)的改动,必须评估 breaking change -- 若存在 breaking change,必须在交付中标注并给出迁移指引 -- 新增 public API 需考虑 Objective-C 互操作(`@objc`、`@objcMembers`) - -### CocoaPods 发布影响 -- 新增源文件:确认 podspec `source_files` glob 是否已覆盖 -- 新增资源文件:确认 podspec `resource` / `resource_bundles` 是否已覆盖 -- 新增依赖:确认 podspec `dependency` 是否已声明,评估对宿主 App 的依赖传递影响 -- 模块目录结构调整:必须同步更新 podspec - -### JS 桥接 -- 涉及 `epub-bridge.js` 的改动,必须确认 JS ↔ Swift 消息契约的一致性 -- JS 侧新增消息类型时,Swift 侧必须有对应处理分支 -- 修改已有消息类型时,必须评估向后兼容 - -### 错误处理 -- 使用 `enum` + `LocalizedError` 定义错误类型 -- 禁止 force unwrap(`!`),确需使用时必须先收敛前置条件 -- 关键路径的错误必须向调用方传递,不得静默吞掉 - -## Required Workflow - -### Phase 1 — 识别需求和范围 - -- 明确目标价值、影响层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI / Legacy)、In Scope / Out of Scope -- 判断是否涉及跨层、跨渲染路径、跨模块联调 -- 若用户提供开发计划文档,先确认本次执行的唯一计划来源,并提取计划任务清单 -- 优先做计划内最小可落地实现,不做需求外重构 -- 若需求信息不足,先通过仓库事实补齐,再基于假设推进 - -### Phase 2 — 按需读取 Doc 与代码事实 - -- 若用户指定开发计划文档,必须先完整读取该文档 -- 先读默认 Doc(ARCHITECTURE / CODING_STYLE / EPUB_MAINTENANCE) -- 根据需求类型补读各层源码和相关文档 -- 用源码确认真实入口、调用链、数据流、状态流和复用点,不只依赖文档 - -### Phase 3 — 对齐并锁定开发计划 - -- 若已有开发计划,必须按计划锁定实现路径,不重新设计方案 -- 若没有开发计划,才允许形成最小可落地方案:先复用现有模式与能力,再考虑新增抽象 -- 保持分层边界: - - EPUB 解析逻辑在 EPUBCore - - 文本渲染逻辑在 EPUBTextRendering - - 分页容器逻辑在 RDReaderView - - 开箱即用 UI 在 EPUBUI - - 不在 Legacy 层新增功能 -- 命名、注释、日志风格遵循 `Doc/CODING_STYLE.md` -- Swift / iOS 默认规则: - - 新增 Swift 类型遵循 `RD` / `RDEPUB` 前缀和层级目录归属 - - 避免新增强制解包;确需使用时必须先收敛前置条件 - - 不为"现代化"而重写稳定代码;仅在本次需求明确受益时再迁移系统 API - - 当前项目默认保持 UIKit + Auto Layout + 既有组件风格,SDK 层不使用 SnapKit - - 注释和日志使用中文 - - Debug 输出放在 `#if DEBUG` 守卫下 - - 不在日志中输出敏感信息 -- 单文件预计超过 600 行需主动拆分,超过 1000 行必须拆分 -- 若为跨层需求,计划中必须写清: - - 主改动层级 - - 被影响层级 - - 渲染路径覆盖范围 - - 联调依赖点 - - 关键风险 - - 回滚点或降级方式 -- 开始编码前必须形成内部执行清单,清单项必须能追溯到开发计划;计划外项只能标记为"必要补齐"或"待确认",不能直接实施 - -### Phase 4 — 执行修改与文档同步 - -- 所有代码改动遵循计划内最小改动原则 -- 严格按开发计划清单逐项修改;不得临时改换实现方式或增加计划外功能 -- 若执行中需要偏离计划,必须暂停确认,不能先改后说明 -- 修改代码时必须补充必要注释,重点说明关键逻辑、边界条件和不直观处理 -- 默认补做 SDK 自检: - - 检查新增类型是否使用正确前缀和层级归属 - - 检查三种渲染路径的覆盖情况 - - 检查 Public API 是否有意外 breaking change - - 检查 podspec 是否需要更新 - - 检查 JS 桥接消息契约是否一致 - - 关键页面或组件检查生命周期、通知/定时器/回调清理是否完整 - - 新增 UI 检查长文本、图标按钮语义、重要状态是否只靠颜色表达 - - 异步闭包默认考虑 `[weak self]`,UI 更新回到主线程 -- 修改代码后必须同步更新受影响文档,不能只停留在代码实现 -- 若本次改动涉及 EPUB 维护相关,必须回写 `Doc/EPUB_MAINTENANCE.md` -- 若本次是方案讨论文档交接落地,方案类文档统一放到 `Doc/FeatureSolution/` -- 若新增、重命名或移动文档,必须同步更新目录索引 - -### Phase 5 — 构建验证与交付 - -- 完成修改后,按项目默认命令执行编译验证 -- 若出现编译错误,自动修复并重编译 -- 若遇构建锁问题,自动重试(建议最多 5 次) -- 若是跨层需求,除构建外还应补充关键联调路径说明 -- 交付时固定输出: - - A. 需求理解 - - B. 开发计划 - - C. 开发实施 - - D. 验证结果 - - E. 交付摘要 - -## Output Contract - -最终回复必须完整输出以下 5 个固定板块: -- A. 需求理解 -- B. 开发计划 -- C. 开发实施 -- D. 验证结果 -- E. 交付摘要 - -各板块内容要求: -- A:3-8 条,覆盖目标价值、范围、目标层级、渲染路径覆盖、交互流、限制 -- B:按"计划来源 / 计划任务清单 / 执行顺序 / 验证与交付"组织;若有开发计划文档,必须逐条对应计划项 -- C:描述实现过程、复用点、关键取舍,以及本次补充了哪些关键代码注释;若有计划未写明但必须补齐的内容,必须单独标注 -- D:给出编译结果、关键路径验证、自动重试/自动修复情况、未覆盖风险 -- E:列出改动文件、计划符合情况、关键取舍、回滚点或降级方式、已同步文档与具体文档落点、后续建议 - -## Validation - -若本次调用修改了任何 Swift / Objective-C / 工程配置 / podspec 文件,必须执行构建验证。 - -验证方式: -- 若 Demo 工程可用: - ```bash - xcodebuild build -workspace ReadViewSDKDemo/ReadViewSDKDemo.xcworkspace -scheme ReadViewSDKDemo -sdk iphonesimulator -derivedDataPath /private/tmp/readview-sdk-derived - ``` -- 若仅有 SDK 源码(无 workspace): - ```bash - pod lib lint RDReaderView.podspec --allow-warnings - ``` - -验证规则: -- 若出现编译错误,必须自动修复并重编译 -- 若遇到 `database is locked` 等构建锁问题,自动重试,建议最多 5 次 -- 直到 `BUILD SUCCEEDED` 或 `pod lib lint passed` 才可进入交付阶段 -- 若本次只改文档或 skill 文件,可跳过编译,但要在交付中明确说明 - -## Maintenance Rules - -- 优先复用 `Doc/`,不要把项目级规则复制进多个 skill 造成双份维护 -- 新增项目约束时,优先更新 `Doc/CODING_STYLE.md`、`Doc/ARCHITECTURE.md` 等主文档,再调整 skill -- 轻量与完整要保持"轻重不同、规则不冲突",其中完整版应在轻量版闭环基础上扩展跨层与联调要求,而不是另起一套风格 -- 变更默认流程、输出格式或适用边界时,必须同步更新相关文档 -- 本 skill 持续作为完整开发入口,保持"可执行、可联调、可回滚、可交付" -- SDK 四层架构(EPUBCore → EPUBTextRendering → RDReaderView → EPUBUI)是执行的核心参照框架,所有改动必须明确标注落点层级和依赖方向 diff --git a/.gitignore b/.gitignore index 0a13d00..3a632aa 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,8 @@ ReadViewDemo/build/ # AI tool output _ssoft-output/ -.claude/ +.claude/* +!.claude/skills # Python bytecode __pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 1a9b266..1d5ca15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,67 @@ -# AGENTS.md +# ReadViewSDK AI 开发规则 -- If using XcodeBuildMCP, use the installed XcodeBuildMCP skill before calling XcodeBuildMCP tools. +## 适用范围与语言 + +- 默认处理当前仓库的 EPUB SDK、PDF SDK、Demo 和测试。 +- 计划、交付说明、`Doc/` 与项目 Markdown 使用中文。 +- 代码标识符、文件名、API 和配置键保持英文;代码注释、Docstring 和提交信息使用中文。 +- 只做当前目标所需的最小改动,保留工作区内与任务无关的未提交修改。 +- 任何写文件的 agent 必须先读取 `CONTEXT.md`。 + +## 自动选择项目 Skill + +根据用户意图选择 `.agents/skills/` 中最匹配的 Skill: + +- 先讨论、比较方案、不要改代码:`discuss-sdk-feature-solution` +- 单模块功能、Bug 修复、UI 调整、局部重构、构建修复:`start-sdk-feature-dev-lite` +- 跨层/跨阅读器改造、公共 API 或持久化迁移、复杂联调、完整方案执行:`start-sdk-feature-dev` +- 隐藏问题、并发、生命周期、性能、内存、缓存或兼容性审查:`review-swift-ios-sdk` +- 全局代码规范:`generate-sdk-code-style-doc` +- 单模块代码规范:`generate-module-code-style-doc` +- 模块调用链、状态流、分页或缓存实现逻辑:`generate-module-implementation-logic` +- 审查改动、生成详细中文提交说明并提交或推送 Git:`commit-git-changes` + +不要为简单任务叠加多个 Skill。用户明确指定 Skill 时优先使用;任务边界扩大时再切换或补充,并说明原因。 + +## 文档按需读取 + +先定位源码,再读取直接相关文档,不默认全文加载整个 `Doc/`: + +| 任务 | 必读资料 | +| --- | --- | +| 任意代码修改 | `CONTEXT.md`、`Doc/CONVENTIONS.md` 相关章节 | +| 分层或跨模块改造 | `Doc/ARCHITECTURE.md` | +| 公共 API | `Doc/API_REFERENCE.md` 与真实 public 声明 | +| EPUB 解析/排版/分页/UI | 对应 `Doc/*_CODE_REFERENCE.md` 或专题文档 | +| PDF 阅读器 | `Sources/RDPDFReaderView` 源码;现有文档不足时以源码为准 | +| 测试与回归 | `Doc/TESTING.md` | +| 用户指定方案 | 指定文档,必须完整读取 | + +`Doc/index.md` 用于路由。文档与源码冲突时以当前源码为事实,并指出待同步项。 + +## 模块边界 + +- EPUB:`EPUBCore` → `EPUBTextRendering` → `ReaderView` → `EPUBUI`,禁止下层反向依赖上层。 +- PDF:通用分页容器位于 `Sources/RDPDFReaderView/ReaderView`,成品能力位于 `Sources/RDPDFReaderView/Sources`。 +- Demo 和 UITests 只承担集成、样例与回归,不承载 SDK 核心业务。 +- 保持 UIKit、SnapKit 和当前 CocoaPods 结构;新增依赖、资源或系统 framework 时同步检查对应 podspec。 +- 新增类型遵循现有 `RD`、`RDEPUB`、`RDPDF` 前缀和最小可见性。 + +## SDK 兼容与质量 + +- 修改 public API 前评估宿主源码兼容、默认行为和迁移方式。 +- 修改 Codable、持久化文件、缓存键或 book identifier 时提供版本与失效/迁移策略。 +- UI 更新回主线程;异步任务处理取消、超时、迟到结果、恰好一次完成和生命周期。 +- EPUB 改动检查 `.textReflowable`、`.webInteractive`、`.webFixedLayout` 的影响。 +- PDF 改动检查宿主图片 Provider 与内置 PDFKit Provider 的影响。 +- 滚动、缩放、分页和绘画热点避免同步 IO、重复解码、无界缓存和大对象长期驻留。 + +## 验证 + +修改 Swift、工程配置或 podspec 后默认执行: + +`xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace -scheme ReadViewDemo -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build` + +按风险补充 `Doc/TESTING.md` 中的定向 UI 测试。仅修改文档、规则或 Skill 时可跳过 Xcode 构建,但必须执行结构、引用和格式检查。 + +如果使用 XcodeBuildMCP,调用其工具前必须先读取已安装的 XcodeBuildMCP Skill,并先检查 session defaults。 diff --git a/Sources/RDPDFReaderView/Sources/RDPDFKitPageProvider.swift b/Sources/RDPDFReaderView/Sources/RDPDFKitPageProvider.swift index 795e92e..040b53a 100644 --- a/Sources/RDPDFReaderView/Sources/RDPDFKitPageProvider.swift +++ b/Sources/RDPDFReaderView/Sources/RDPDFKitPageProvider.swift @@ -1,5 +1,6 @@ import UIKit import PDFKit +import CryptoKit public enum RDPDFKitPageProviderError: LocalizedError { case cannotOpen(URL) @@ -28,6 +29,8 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut private let descriptor: RDPDFReaderBookDescriptor private let renderQueue = DispatchQueue(label: "com.readviewsdk.pdfkit.render", qos: .userInitiated) private let pageCache = NSCache() + /// 仅在 `renderQueue` 上访问,用于让 NSCache 的非确定性淘汰之外仍遵守阅读窗口。 + private var cachedPageIndexes = Set() private let thumbnailCache = NSCache() private let maximumPagePixelDimension: CGFloat private let screenScale: CGFloat @@ -85,7 +88,7 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut profile: "cropbox-normalized-1" ) : nil - pageCache.countLimit = 6 + pageCache.countLimit = 5 pageCache.totalCostLimit = 96 * 1024 * 1024 thumbnailCache.countLimit = 80 thumbnailCache.totalCostLimit = 24 * 1024 * 1024 @@ -116,6 +119,7 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut let image = autoreleasepool { self.renderPage(page) } if let image { self.pageCache.setObject(image, forKey: NSNumber(value: index), cost: image.rdEstimatedMemoryCost) + self.cachedPageIndexes.insert(index) } let runs = self.cachedTextRuns(for: index, page: page) DispatchQueue.main.async { @@ -159,9 +163,26 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut } public func removeCachedImages() { - pageCache.removeAllObjects() - thumbnailCache.removeAllObjects() - renderQueue.async { [weak self] in self?.textRunsCache.removeAll(keepingCapacity: false) } + renderQueue.async { [weak self] in + self?.pageCache.removeAllObjects() + self?.thumbnailCache.removeAllObjects() + self?.cachedPageIndexes.removeAll(keepingCapacity: false) + self?.textRunsCache.removeAll(keepingCapacity: false) + } + } + + /// 页面大图只保留当前页前后的阅读窗口。被移除的页面不会写入磁盘,下次访问时 + /// 直接从 PDF 重绘;磁盘缓存仅保存 OCR/PDFKit 文本坐标。 + public func trimPageImages(around pageIndex: Int, radius: Int) { + let safeRadius = max(0, radius) + renderQueue.async { [weak self] in + guard let self else { return } + let stalePages = self.cachedPageIndexes.filter { abs($0 - pageIndex) > safeRadius } + stalePages.forEach { + self.pageCache.removeObject(forKey: NSNumber(value: $0)) + self.cachedPageIndexes.remove($0) + } + } } /// 与控制器的页面图片缓存窗口保持一致,避免原生文本结果在长 PDF 中无限累积。 @@ -209,21 +230,25 @@ public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut return runs } - /// 身份签名只取文件名、大小和头部内容。绝对路径在 iOS 容器迁移(应用更新、 - /// 备份恢复)后会变化,修改时间在重新下载后会变化;它们参与签名会让以 - /// identifier 为 key 的书签、笔迹和阅读进度整体失效。 + /// 使用完整文件内容生成稳定身份。按块读取避免大 PDF 一次性进入内存;绝对路径、 + /// 修改时间不参与签名,因此应用容器迁移或重新下载不会丢失阅读状态。 private static func defaultBookIdentifier(for url: URL) -> String { let fileURL = url.standardizedFileURL let fileSize = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]))?.fileSize - var signature = "\(fileURL.lastPathComponent)|\(fileSize.map(String.init) ?? "")" - if let handle = try? FileHandle(forReadingFrom: fileURL) { - let head = handle.readData(ofLength: 64 * 1024) - handle.closeFile() - if head.isEmpty == false { - signature += "|\(RDPDFReaderTextRunDiskCache.stableIdentifier(for: head))" - } + guard let handle = try? FileHandle(forReadingFrom: fileURL) else { + let fallback = "\(fileURL.lastPathComponent)|\(fileSize.map(String.init) ?? "")" + return "pdf.\(RDPDFReaderTextRunDiskCache.stableIdentifier(for: fallback))" } - return "pdf.\(RDPDFReaderTextRunDiskCache.stableIdentifier(for: signature))" + defer { handle.closeFile() } + + var hasher = SHA256() + while true { + let chunk = handle.readData(ofLength: 1024 * 1024) + guard chunk.isEmpty == false else { break } + hasher.update(data: chunk) + } + let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined() + return "pdf.sha256.\(digest)" } private func outlineItemsLocked() -> [RDPDFReaderOutlineItem] { diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderContainerView.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderContainerView.swift index 9855c6f..c11e319 100644 --- a/Sources/RDPDFReaderView/Sources/RDPDFReaderContainerView.swift +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderContainerView.swift @@ -143,6 +143,12 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate { /// 手机横屏宽度适配下,远跳的目标页可能先按占位高度布局,待真实页面数据 /// 到达后需要重新将目标页锚定到顶部。 var pendingWidthFitJumpPage: Int? + /// 宽度适配页的真实高度异步到达时,用页内相对位置恢复视口,避免前序 cell + /// 从占位高度扩展后把正在阅读的内容顶走。 + private struct VerticalReadingAnchor { + let page: Int + let offsetFromPageTop: CGFloat + } private var reusableTypes: [String: UIView.Type] = [:] public override init(frame: CGRect) { @@ -314,29 +320,52 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate { /// 宿主拿到页面数据时调用此方法刷新布局。 public func invalidateWidthFitLayoutIfNeeded(updatedPage: Int? = nil) { guard usesWidthFitVerticalScroll else { return } + let readingAnchor = verticalReadingAnchor() collectionView.collectionViewLayout.invalidateLayout() - // 仅修正由目录、缩略图、书签等显式跳页建立的锚点。普通阅读中页面 - // 异步加载不应把用户正在浏览的页内位置强制拉回顶部。 - guard let updatedPage, updatedPage == pendingWidthFitJumpPage else { return } DispatchQueue.main.async { [weak self] in - guard let self, - self.usesWidthFitVerticalScroll, - self.currentPage == updatedPage, - self.pendingWidthFitJumpPage == updatedPage else { return } - // 页面数据到达时用户可能已经按住屏幕拖动;此时手指拥有位置,放弃锚点回写。 - guard self.collectionView.isTracking == false else { - self.pendingWidthFitJumpPage = nil + guard let self, self.usesWidthFitVerticalScroll else { return } + // 手指或惯性滚动拥有位置时不回写,避免与用户输入竞争。 + guard !self.collectionView.isTracking, + !self.collectionView.isDragging, + !self.collectionView.isDecelerating else { + // 用户已接管显式跳页后的滚动位置,不能把遗留锚点留给后续任意 + // 页面加载,否则松手后会被意外拉回目标页顶部。 + if updatedPage == self.pendingWidthFitJumpPage { + self.pendingWidthFitJumpPage = nil + } return } self.collectionView.layoutIfNeeded() - self.collectionView.setContentOffset( - CGPoint(x: 0, y: self.clampedVerticalOffset(self.anchorY(forItem: updatedPage))), - animated: false - ) - self.pendingWidthFitJumpPage = nil + if let updatedPage, + updatedPage == self.pendingWidthFitJumpPage, + self.currentPage == updatedPage { + self.collectionView.setContentOffset( + CGPoint(x: 0, y: self.clampedVerticalOffset(self.anchorY(forItem: updatedPage))), + animated: false + ) + self.pendingWidthFitJumpPage = nil + } else if let readingAnchor { + let targetY = self.anchorY(forItem: readingAnchor.page) + readingAnchor.offsetFromPageTop + self.collectionView.setContentOffset( + CGPoint(x: 0, y: self.clampedVerticalOffset(targetY)), + animated: false + ) + } } } + private func verticalReadingAnchor() -> VerticalReadingAnchor? { + guard currentDisplayType == .verticalScroll, + collectionView.contentSize.height > 0 else { return nil } + let visibleY = max(0, collectionView.contentOffset.y) + let point = CGPoint(x: collectionView.bounds.midX, y: visibleY + 1) + guard let indexPath = collectionView.indexPathForItem(at: point) else { return nil } + return VerticalReadingAnchor( + page: indexPath.item, + offsetFromPageTop: visibleY - anchorY(forItem: indexPath.item) + ) + } + public func setPagingEnabled(_ enabled: Bool) { isPagingEnabled = enabled; updatePagingState() } /// 画笔/橡皮被选中时调用。放大层随之把单指手势让给页面画布,只保留双指拖动与捏合。 @@ -708,7 +737,10 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR } private func updateInsets() { - let size = canvasView.frame.size + // 缩放时 UIScrollView 会给 canvasView 加 transform,`frame` 已经是缩放后的 + // 视觉尺寸。若再乘 zoomScale,会在每个 pinch 回调中把 inset 多算一遍, + // UIKit 随之修正 contentOffset,慢速捏合时看起来就像书页在跳动。 + let size = canvasView.bounds.size scrollView.contentInset = UIEdgeInsets( top: max(0, (bounds.height - size.height * scrollView.zoomScale) / 2), left: max(0, (bounds.width - size.width * scrollView.zoomScale) / 2), diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderDrawingCanvasView.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderDrawingCanvasView.swift index 5b3084b..7667eb6 100644 --- a/Sources/RDPDFReaderView/Sources/RDPDFReaderDrawingCanvasView.swift +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderDrawingCanvasView.swift @@ -94,19 +94,23 @@ public struct RDPDFReaderDrawingDocument: Codable { public let pageNo: Int public let paths: [RDPDFReaderDrawingPath] public let layers: [RDPDFReaderDrawingLayer] + /// 乐观锁版本。旧文件缺少该字段时按 0 处理。 + public let revision: Int - public init(pageNo: Int, paths: [RDPDFReaderDrawingPath], layers: [RDPDFReaderDrawingLayer] = []) { + public init(pageNo: Int, paths: [RDPDFReaderDrawingPath], layers: [RDPDFReaderDrawingLayer] = [], revision: Int = 0) { self.pageNo = pageNo self.paths = paths self.layers = layers + self.revision = max(0, revision) } - private enum CodingKeys: String, CodingKey { case pageNo, paths, layers } + private enum CodingKeys: String, CodingKey { case pageNo, paths, layers, revision } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) pageNo = try container.decode(Int.self, forKey: .pageNo) paths = try container.decode([RDPDFReaderDrawingPath].self, forKey: .paths) layers = try container.decodeIfPresent([RDPDFReaderDrawingLayer].self, forKey: .layers) ?? [] + revision = max(0, try container.decodeIfPresent(Int.self, forKey: .revision) ?? 0) } } @@ -124,6 +128,14 @@ public final class RDPDFReaderDrawingCanvasView: UIView { public var strokeColor: UIColor = .black public var lineWidth: CGFloat = 2 public var currentPage = 0 + /// 绘画会话内使用路径作为唯一渲染来源;退出会话后改用派生位图缓存。 + public var usesPathRendering = false { + didSet { + guard oldValue != usesPathRendering else { return } + if !usesPathRendering { finishCurrentStroke() } + setNeedsDisplay() + } + } public var pathsChangedHandler: (([RDPDFReaderDrawingPath]) -> Void)? /// 将本页持续中的笔势交给展开页路由器。路由器可在书脊处把后续触点转发给相邻页。 public var strokeEventHandler: ((CGPoint, RDPDFReaderDrawingStrokePhase) -> Void)? @@ -136,6 +148,15 @@ public final class RDPDFReaderDrawingCanvasView: UIView { private var undoStack: [DrawingAction] = [] private var redoStack: [DrawingAction] = [] private let maxUndoCount = 50 + /// 退出绘画会话后,已提交笔迹按图层缓存为位图,供普通阅读快速显示。 + private let committedLayerImages: NSCache = { + let cache = NSCache() + cache.countLimit = 4 + cache.totalCostLimit = 48 * 1024 * 1024 + return cache + }() + private var documentRevision = 0 + private var cachedRenderingSize = CGSize.zero public override init(frame: CGRect) { super.init(frame: frame) @@ -152,6 +173,7 @@ public final class RDPDFReaderDrawingCanvasView: UIView { public func load(document: RDPDFReaderDrawingDocument) { paths = document.paths layers = document.layers + documentRevision = document.revision if layers.isEmpty { let defaultLayer = RDPDFReaderDrawingLayer(name: "图层 1") layers = [defaultLayer] @@ -173,9 +195,18 @@ public final class RDPDFReaderDrawingCanvasView: UIView { currentPath = nil undoStack.removeAll() redoStack.removeAll() + invalidateCommittedLayerImages() setNeedsDisplay() } + public override func layoutSubviews() { + super.layoutSubviews() + // 页面缩放或旋转改变了 referencePageSize 的映射,旧缓存不能复用。 + guard cachedRenderingSize != bounds.size else { return } + cachedRenderingSize = bounds.size + invalidateCommittedLayerImages() + } + /// 兼容旧调用方,未分层笔迹自动归入“图层 1”。 public func loadPaths(_ paths: [RDPDFReaderDrawingPath]) { load(document: .init(pageNo: currentPage, paths: paths)) } @@ -186,7 +217,11 @@ public final class RDPDFReaderDrawingCanvasView: UIView { commitCurrentStroke() activeDrawingTouch = nil } - public func drawingDocument() -> RDPDFReaderDrawingDocument { .init(pageNo: currentPage, paths: paths, layers: layers) } + public func drawingDocument() -> RDPDFReaderDrawingDocument { + .init(pageNo: currentPage, paths: paths, layers: layers, revision: documentRevision) + } + public func acknowledgePersistedRevision(_ revision: Int) { documentRevision = max(documentRevision, revision) } + public func releaseRenderingCache() { invalidateCommittedLayerImages() } public func drawingLayers() -> [RDPDFReaderDrawingLayer] { layers } public func selectedDrawingLayerID() -> UUID? { activeLayerID } @@ -194,7 +229,7 @@ public final class RDPDFReaderDrawingCanvasView: UIView { let layer = RDPDFReaderDrawingLayer(name: "图层 \(layers.count + 1)") layers.insert(layer, at: 0) activeLayerID = layer.id - didChangePaths() + didChangePaths(affectedLayerIDs: [layer.id]) return layer } @@ -208,7 +243,7 @@ public final class RDPDFReaderDrawingCanvasView: UIView { guard let index = layers.firstIndex(where: { $0.id == id }) else { return } layers[index].isVisible = isVisible if activeLayerID == id, !isVisible { activeLayerID = layers.first(where: \.isVisible)?.id } - didChangePaths() + didChangePaths(affectedLayerIDs: [id]) } public func deleteLayer(id: UUID) { @@ -216,7 +251,7 @@ public final class RDPDFReaderDrawingCanvasView: UIView { paths.removeAll { $0.layerID == id } layers.remove(at: index) if activeLayerID == id { activeLayerID = layers.first(where: \.isVisible)?.id ?? layers.first?.id } - didChangePaths() + didChangePaths(affectedLayerIDs: [id]) } public func clearAll() { @@ -251,12 +286,22 @@ public final class RDPDFReaderDrawingCanvasView: UIView { public override func draw(_ rect: CGRect) { guard let context = UIGraphicsGetCurrentContext() else { return } + if usesPathRendering { + drawUsingPaths(in: context) + return + } + drawUsingCommittedImages() + } + + private func drawUsingPaths(in context: CGContext) { // 图层必须隔离合成:橡皮擦只清除所属图层,不能穿透到下方图层。 layers.filter(\.isVisible).forEach { layer in context.saveGState() context.beginTransparencyLayer(auxiliaryInfo: nil) - paths.filter { $0.layerID == layer.id }.forEach { draw(path: $0, in: context) } - if let currentPath, currentPath.layerID == layer.id { draw(path: currentPath, in: context) } + paths.lazy.filter { $0.layerID == layer.id }.forEach { draw(path: $0, in: context) } + if let currentPath, currentPath.layerID == layer.id { + draw(path: currentPath, in: context) + } context.endTransparencyLayer() context.restoreGState() } @@ -265,6 +310,16 @@ public final class RDPDFReaderDrawingCanvasView: UIView { if let currentPath, currentPath.layerID == nil { draw(path: currentPath, in: context) } } + private func drawUsingCommittedImages() { + layers.filter(\.isVisible).forEach { layer in + committedLayerImage(for: layer)?.draw(in: bounds) + } + // 极旧数据通常会在 load 时迁入默认图层;这里保留安全兜底。 + if paths.contains(where: { $0.layerID == nil }), let context = UIGraphicsGetCurrentContext() { + paths.filter { $0.layerID == nil }.forEach { draw(path: $0, in: context) } + } + } + public override func touchesBegan(_ touches: Set, with event: UIEvent?) { guard activeTouchCount(in: event) == 1, let touch = touches.first else { cancelCurrentStroke(); return } activeDrawingTouch = touch @@ -317,7 +372,12 @@ public final class RDPDFReaderDrawingCanvasView: UIView { case .clear(let saved): if isUndo { paths = saved } else { paths.removeAll() } } - didChangePaths() + let affectedLayers: Set + switch action { + case .add(let path): affectedLayers = Set([path.layerID].compactMap { $0 }) + case .remove(let paths), .clear(let paths): affectedLayers = Set(paths.compactMap(\.layerID)) + } + didChangePaths(affectedLayerIDs: affectedLayers.isEmpty ? nil : affectedLayers) } private func appendPoints(from touch: UITouch, event: UIEvent?) { @@ -336,10 +396,45 @@ public final class RDPDFReaderDrawingCanvasView: UIView { paths.append(path) record(.add(path)) currentPath = nil - didChangePaths() + if let layerID = path.layerID { + didChangePaths(affectedLayerIDs: [layerID]) + } else { + didChangePaths() + } } private func cancelCurrentStroke() { currentPath = nil; activeDrawingTouch = nil; setNeedsDisplay() } - private func didChangePaths() { setNeedsDisplay(); pathsChangedHandler?(paths) } + private func didChangePaths(affectedLayerIDs: Set? = nil) { + if let affectedLayerIDs { + affectedLayerIDs.forEach { committedLayerImages.removeObject(forKey: $0 as NSUUID) } + } else { + invalidateCommittedLayerImages() + } + setNeedsDisplay() + pathsChangedHandler?(paths) + } + + private func invalidateCommittedLayerImages() { committedLayerImages.removeAllObjects() } + + private func committedLayerImage(for layer: RDPDFReaderDrawingLayer) -> UIImage? { + let key = layer.id as NSUUID + if let image = committedLayerImages.object(forKey: key) { return image } + guard bounds.width > 0, bounds.height > 0 else { return nil } + let format = UIGraphicsImageRendererFormat.default() + format.opaque = false + // 单图层最多约 250 万像素;NSCache 再限制总成本,避免 iPad 多图层占满内存。 + let basePixels = max(1, bounds.width * bounds.height) + let maximumScale = sqrt(2_500_000 / basePixels) + format.scale = max(1, min(contentScaleFactor, maximumScale)) + let image = UIGraphicsImageRenderer(size: bounds.size, format: format).image { rendererContext in + let layerContext = rendererContext.cgContext + layerContext.beginTransparencyLayer(auxiliaryInfo: nil) + paths.lazy.filter { $0.layerID == layer.id }.forEach { draw(path: $0, in: layerContext) } + layerContext.endTransparencyLayer() + } + let cost = image.cgImage.map { $0.bytesPerRow * $0.height } ?? 0 + committedLayerImages.setObject(image, forKey: key, cost: cost) + return image + } private func draw(path: RDPDFReaderDrawingPath, in context: CGContext) { guard !path.points.isEmpty else { return } diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderImageTextRecognizer.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderImageTextRecognizer.swift index 5b9fa35..dbff1c2 100644 --- a/Sources/RDPDFReaderView/Sources/RDPDFReaderImageTextRecognizer.swift +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderImageTextRecognizer.swift @@ -21,7 +21,12 @@ public final class RDPDFReaderImageTextRecognizer { qos: .userInitiated ) private let requestLock = NSLock() - private var pendingRequests: [UUID: DispatchWorkItem] = [:] + private struct PendingRequest { + let workItem: DispatchWorkItem + let completion: Completion + } + + private var pendingRequests: [UUID: PendingRequest] = [:] /// 已进入 `perform` 的请求。`DispatchWorkItem.cancel()` 中断不了它们, /// 必须对 VNRequest 本身调用 `cancel()`。 private var activeVisionRequests: [UUID: VNRecognizeTextRequest] = [:] @@ -57,7 +62,7 @@ public final class RDPDFReaderImageTextRecognizer { let request = VNRecognizeTextRequest { request, _ in let observations = request.results as? [VNRecognizedTextObservation] ?? [] let runs = Self.makeTextRuns(from: observations) - guard self.removeRequest(requestID) else { return } + guard let completion = self.takeCompletion(for: requestID) else { return } self.deliver(runs, to: completion) } request.recognitionLevel = configuration.recognitionLevel @@ -73,27 +78,30 @@ public final class RDPDFReaderImageTextRecognizer { } self.unregisterActiveVisionRequest(requestID) if performFailed { - guard self.removeRequest(requestID) else { return } + guard let completion = self.takeCompletion(for: requestID) else { return } self.deliver([], to: completion) } } requestLock.lock() - pendingRequests[requestID] = workItem + pendingRequests[requestID] = PendingRequest(workItem: workItem, completion: completion) requestLock.unlock() processingQueue.async(execute: workItem) return requestID } - /// 丢弃尚未开始的识别请求,并忽略正在执行请求的完成结果。 - /// 用于快速翻页后优先让当前停留页进入串行 OCR 队列。 + /// 取消尚未完成的识别请求。每个被取消的请求仍会以空结果完成一次,保证回调和 + /// Swift Concurrency 调用方不会因快速翻页永久挂起。 public func cancelAllRequests() { requestLock.lock() - let queued = pendingRequests.values - let inFlight = activeVisionRequests.values + let cancelled = Array(pendingRequests.values) + let inFlight = Array(activeVisionRequests.values) pendingRequests.removeAll() activeVisionRequests.removeAll() requestLock.unlock() - queued.forEach { $0.cancel() } + cancelled.forEach { + $0.workItem.cancel() + deliver([], to: $0.completion) + } // 让正在执行的识别尽快返回,当前停留页不必等整页识别跑完才能入队。 inFlight.forEach { $0.cancel() } } @@ -211,7 +219,7 @@ public final class RDPDFReaderImageTextRecognizer { private func registerActiveVisionRequest(_ request: VNRecognizeTextRequest, for requestID: UUID) -> Bool { requestLock.lock() defer { requestLock.unlock() } - guard pendingRequests[requestID]?.isCancelled == false else { return false } + guard pendingRequests[requestID]?.workItem.isCancelled == false else { return false } activeVisionRequests[requestID] = request return true } @@ -222,13 +230,11 @@ public final class RDPDFReaderImageTextRecognizer { requestLock.unlock() } - /// 仅活跃请求能取得完成权;被取消或已替代的请求结果必须丢弃。 - @discardableResult - private func removeRequest(_ requestID: UUID) -> Bool { + /// 仅活跃请求能取得一次完成权;取消与 Vision 回调竞争时不会重复完成。 + private func takeCompletion(for requestID: UUID) -> Completion? { requestLock.lock() defer { requestLock.unlock() } - guard let request = pendingRequests.removeValue(forKey: requestID), !request.isCancelled else { return false } - return true + return pendingRequests.removeValue(forKey: requestID)?.completion } } diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderPageView.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderPageView.swift index 80c331c..fa37a6a 100644 --- a/Sources/RDPDFReaderView/Sources/RDPDFReaderPageView.swift +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderPageView.swift @@ -60,12 +60,19 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG private let drawingCanvas = RDPDFReaderDrawingCanvasView() private let contentAccessibilityView = UIView() private let selectionLoupe = RDPDFReaderSelectionLoupeView() + private let pageLoadFailureButton = UIButton(type: .system) private var tappedHighlight: RDPDFReaderAnnotation? + private var configuredDrawingPageIndex: Int? + private var pageLoadRetryHandler: (() -> Void)? /// 画笔层属于单个实际 PDF 页,而不是横屏双页容器;因此笔迹不能越过书脊。 /// 画笔面板已打开时,禁用文本选区以保证单指手势可以用于拖动画面。 public var isDrawingSessionActive = false { didSet { + guard oldValue != isDrawingSessionActive else { return } + // 退出会话前先提交最后一笔;随后普通阅读只显示由最终路径派生的缓存图片。 + if !isDrawingSessionActive { drawingCanvas.finishCurrentStroke() } + drawingCanvas.usesPathRendering = isDrawingSessionActive textLayer.isSelectionEnabled = !isDrawingSessionActive if isDrawingSessionActive { textLayer.clearSelection() } } @@ -148,8 +155,14 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG document: RDPDFReaderDrawingDocument, documentChanged: @escaping (RDPDFReaderDrawingDocument) -> Void ) { - drawingCanvas.currentPage = pageIndex - drawingCanvas.load(document: document) + // OCR、主题或标注刷新会反复配置同一个页面。相同页不能重新 load, + // 否则会清掉正在采集的 currentPath 和撤销栈。 + if configuredDrawingPageIndex != pageIndex { + drawingCanvas.finishCurrentStroke() + drawingCanvas.currentPage = pageIndex + drawingCanvas.load(document: document) + configuredDrawingPageIndex = pageIndex + } drawingCanvas.pathsChangedHandler = { [weak drawingCanvas] _ in guard let drawingCanvas else { return } documentChanged(drawingCanvas.drawingDocument()) @@ -175,6 +188,14 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG public func selectDrawingLayer(id: UUID) { drawingCanvas.selectLayer(id: id) } public func setDrawingLayerVisibility(id: UUID, isVisible: Bool) { drawingCanvas.setLayerVisibility(id: id, isVisible: isVisible) } public func deleteDrawingLayer(id: UUID) { drawingCanvas.deleteLayer(id: id) } + func acknowledgeDrawingRevision(_ revision: Int) { drawingCanvas.acknowledgePersistedRevision(revision) } + func releaseDrawingRenderingCache() { drawingCanvas.releaseRenderingCache() } + + func setPageLoadFailed(_ failed: Bool, retryHandler: (() -> Void)? = nil) { + pageLoadRetryHandler = retryHandler + pageLoadFailureButton.isHidden = !failed + pageLoadFailureButton.isEnabled = failed + } func containsDrawingPoint(_ point: CGPoint, from sourceView: UIView) -> Bool { drawingCanvas.bounds.contains(sourceView.convert(point, to: drawingCanvas)) @@ -244,6 +265,15 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG addSubview(selectionLoupe) + pageLoadFailureButton.setTitle("页面加载失败,点击重试", for: .normal) + pageLoadFailureButton.titleLabel?.font = .preferredFont(forTextStyle: .body) + pageLoadFailureButton.backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.94) + pageLoadFailureButton.layer.cornerRadius = 10 + pageLoadFailureButton.accessibilityIdentifier = "pdf.reader.page.retry" + pageLoadFailureButton.addTarget(self, action: #selector(retryPageLoad), for: .touchUpInside) + pageLoadFailureButton.isHidden = true + addSubview(pageLoadFailureButton) + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) tap.delegate = self tap.require(toFail: zoomView.doubleTapZoomGestureRecognizer) @@ -261,6 +291,10 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG zoomView.layoutIfNeeded() textLayer.frame = zoomView.contentView.bounds drawingCanvas.frame = zoomView.contentView.bounds + pageLoadFailureButton.sizeToFit() + pageLoadFailureButton.bounds.size.width += 32 + pageLoadFailureButton.bounds.size.height += 24 + pageLoadFailureButton.center = CGPoint(x: bounds.midX, y: bounds.midY) } // MARK: - 点击分发 @@ -279,6 +313,13 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG readerContentTapHandler?(gesture.location(in: self)) } + @objc private func retryPageLoad() { pageLoadRetryHandler?() } + + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + // 重试按钮拥有自己的点击语义,不能同时触发页面点击/工具栏显隐。 + !(touch.view?.isDescendant(of: pageLoadFailureButton) ?? false) + } + private func highlightAt(_ point: CGPoint) -> RDPDFReaderAnnotation? { guard bounds.width > 0, bounds.height > 0 else { return nil } let normalizedPoint = CGPoint( diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderPanelPresentation.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderPanelPresentation.swift index 80211c7..9cbc32f 100644 --- a/Sources/RDPDFReaderView/Sources/RDPDFReaderPanelPresentation.swift +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderPanelPresentation.swift @@ -18,6 +18,10 @@ public enum RDPDFReaderPanelPresenter { layout: RDPDFReaderPanelLayout, hidesNavigationBar: Bool = true ) { + // 工具栏连续点击或异步目录回调可能在同一转场窗口内重复到达;UIKit 不支持 + // 同一 presenter 同时呈现两个控制器。 + guard presenter.presentedViewController == nil, + !presenter.isBeingDismissed else { return } let transitionDelegate = RDPDFReaderPanelTransitionDelegate(layout: layout) objc_setAssociatedObject(controller, &transitionDelegateKey, transitionDelegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) controller.setNavigationBarHidden(hidesNavigationBar, animated: false) diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderPersistenceStore.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderPersistenceStore.swift index 54c56b4..f79e3f6 100644 --- a/Sources/RDPDFReaderView/Sources/RDPDFReaderPersistenceStore.swift +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderPersistenceStore.swift @@ -7,6 +7,7 @@ public enum RDPDFReaderAnnotationPersistenceError: LocalizedError { case invalidDocument(URL, Error) case unsupportedDocumentVersion(URL, Int) case duplicateAnnotationID(String) + case drawingConflict(URL, expectedRevision: Int, actualRevision: Int) case encodingFailed(Error) case writeFailed(URL, Error) @@ -20,6 +21,8 @@ public enum RDPDFReaderAnnotationPersistenceError: LocalizedError { return "PDF 标注来自更新版本,当前版本无法安全保存。" case .duplicateAnnotationID: return "检测到重复的 PDF 标注标识。" + case .drawingConflict: + return "笔迹已在另一阅读器中更新,请重新载入后再试。" case .encodingFailed: return "无法编码 PDF 标注。" case .writeFailed: @@ -36,8 +39,8 @@ public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting } private static let annotationDocumentVersion = 1 - /// 同一进程内的多个阅读器可能指向同一本书;将读改写串行化,避免最后一次写入 - /// 覆盖掉另一实例刚创建的标注。 + /// 同一进程内的多个阅读器可能指向同一本书;将本地状态文件的读写串行化, + /// 避免原子替换过程中出现竞争或读取半完成文件。 private static let annotationPersistenceLock = NSLock() private let rootURL: URL private let drawingsURL: URL @@ -56,6 +59,12 @@ public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting } public func drawingDocument(pageNo: Int) -> RDPDFReaderDrawingDocument { + Self.annotationPersistenceLock.lock() + defer { Self.annotationPersistenceLock.unlock() } + return drawingDocumentLocked(pageNo: pageNo) + } + + private func drawingDocumentLocked(pageNo: Int) -> RDPDFReaderDrawingDocument { let url = drawingsURL.appendingPathComponent("\(pageNo).json") guard let data = try? Data(contentsOf: url), let document = try? JSONDecoder().decode(RDPDFReaderDrawingDocument.self, from: data) else { return .init(pageNo: pageNo, paths: []) @@ -63,30 +72,90 @@ public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting return document } - public func saveDrawingPaths(_ paths: [RDPDFReaderDrawingPath], pageNo: Int) { + @discardableResult + public func saveDrawingPaths(_ paths: [RDPDFReaderDrawingPath], pageNo: Int) -> Result { saveDrawingDocument(.init(pageNo: pageNo, paths: paths), pageNo: pageNo) } - public func saveDrawingDocument(_ document: RDPDFReaderDrawingDocument, pageNo: Int) { - guard let data = try? JSONEncoder().encode(document) else { return } - write(data, to: drawingsURL.appendingPathComponent("\(pageNo).json")) + @discardableResult + public func saveDrawingDocument(_ document: RDPDFReaderDrawingDocument, pageNo: Int) -> Result { + Self.annotationPersistenceLock.lock() + defer { Self.annotationPersistenceLock.unlock() } + let url = drawingsURL.appendingPathComponent("\(pageNo).json") + do { + let data = try JSONEncoder().encode(document) + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + return .success(()) + } catch let error as RDPDFReaderAnnotationPersistenceError { + return .failure(error) + } catch { + return .failure(.writeFailed(url, error)) + } + } + + /// 成品阅读器使用的乐观锁保存。多实例持有同一页旧快照时拒绝覆盖,调用方可提示 + /// 用户重新载入;成功后返回递增 revision 供后续编辑继续提交。 + @discardableResult + public func saveDrawingDocumentVersioned( + _ document: RDPDFReaderDrawingDocument, + pageNo: Int + ) -> Result { + Self.annotationPersistenceLock.lock() + defer { Self.annotationPersistenceLock.unlock() } + let url = drawingsURL.appendingPathComponent("\(pageNo).json") + let existing = drawingDocumentLocked(pageNo: pageNo) + guard document.revision == existing.revision else { + return .failure(.drawingConflict( + url, + expectedRevision: document.revision, + actualRevision: existing.revision + )) + } + let saved = RDPDFReaderDrawingDocument( + pageNo: pageNo, + paths: document.paths, + layers: document.layers, + revision: existing.revision + 1 + ) + do { + let data = try JSONEncoder().encode(saved) + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + return .success(saved) + } catch { + return .failure(.writeFailed(url, error)) + } } public func highlights(pageIndex: Int) -> [RDPDFReaderHighlight] { allHighlights().filter { $0.pageIndex == pageIndex } } public func allHighlights() -> [RDPDFReaderHighlight] { - guard let data = try? Data(contentsOf: highlightsURL) else { return [] } - return (try? JSONDecoder().decode([RDPDFReaderHighlight].self, from: data)) ?? [] + Self.annotationPersistenceLock.lock() + defer { Self.annotationPersistenceLock.unlock() } + return loadHighlightsLocked() } public func addHighlight(pageIndex: Int, selectedText: String, color: String, normalizedRect: CGRect) -> RDPDFReaderHighlight { - let item = RDPDFReaderHighlight(id: Int(Date().timeIntervalSince1970 * 1000), pageIndex: pageIndex, selectedText: selectedText, color: color, normalizedRect: normalizedRect) - var items = allHighlights() + Self.annotationPersistenceLock.lock() + defer { Self.annotationPersistenceLock.unlock() } + var items = loadHighlightsLocked() + let timestamp = Int(Date().timeIntervalSince1970 * 1000) + var uniqueID = timestamp + let existingIDs = Set(items.map(\.id)) + while existingIDs.contains(uniqueID), uniqueID < Int.max { uniqueID += 1 } + let item = RDPDFReaderHighlight(id: uniqueID, pageIndex: pageIndex, selectedText: selectedText, color: color, normalizedRect: normalizedRect) items.append(item) - saveHighlights(items) + saveHighlightsLocked(items) return item } - public func deleteHighlight(id: Int) { saveHighlights(allHighlights().filter { $0.id != id }) } + public func deleteHighlight(id: Int) { + Self.annotationPersistenceLock.lock() + defer { Self.annotationPersistenceLock.unlock() } + saveHighlightsLocked(loadHighlightsLocked().filter { $0.id != id }) + } /// 返回当前书籍的全部高亮/注释记录。此兼容方法在文件不可读时返回空数组; /// 新代码若需要区分“没有标注”和“文件不可读”,请使用 `loadAnnotations()`。 @@ -194,7 +263,12 @@ public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting try saveAnnotationsLocked([]) } - private func saveHighlights(_ highlights: [RDPDFReaderHighlight]) { + private func loadHighlightsLocked() -> [RDPDFReaderHighlight] { + guard let data = try? Data(contentsOf: highlightsURL) else { return [] } + return (try? JSONDecoder().decode([RDPDFReaderHighlight].self, from: data)) ?? [] + } + + private func saveHighlightsLocked(_ highlights: [RDPDFReaderHighlight]) { guard let data = try? JSONEncoder().encode(highlights) else { return } write(data, to: highlightsURL) } diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderViewController.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderViewController.swift index da8f458..9159c76 100644 --- a/Sources/RDPDFReaderView/Sources/RDPDFReaderViewController.swift +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderViewController.swift @@ -23,9 +23,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS public var nativeTextDiskCacheVersion = 1 /// 未提供文字且 OCR 关闭时,页面仍可使用区域标注。 public var missingTextSource: RDPDFReaderAnnotationSource = .region - /// SDK 直读 PDF 时,控制器保留当前页前后页面描述的半径,避免长 PDF 阅读时 - /// 持续持有已离开的页面图片。宿主 Provider 的原有缓存行为保持不变。 - public var pageDescriptorCacheRadius = 3 + /// 控制器默认只保留当前页及前后各两页的页面描述。SDK 直读 PDF 时,内置 + /// Provider 的页面大图缓存也会同步收窄到该窗口;离开窗口的页面按需重绘。 + public var pageDescriptorCacheRadius = 2 /// 内存中保留当前页前后的 OCR 结果数量;默认沿用页面描述缓存半径。 public var ocrMemoryCacheRadius: Int? /// 宿主或内置 Provider 提供页面后,可在此进行反色或其它主题渲染。 @@ -46,14 +46,25 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS private var book: RDPDFReaderBookDescriptor private var currentTheme: RDPDFReaderThemeOption private var pageDescriptors: [Int: RDPDFReaderPageDescriptor] = [:] + /// Provider 不要求自行去重;同一页在旋转、cell 重建期间只能保留一个请求。 + private var pageDescriptorRequestTokens: [Int: UUID] = [:] + private var pageDescriptorRequestTimeouts: [Int: DispatchWorkItem] = [:] + private var pageDescriptorRequestAttempts: [Int: Int] = [:] + private var pageLoadFailedPages = Set() + private let maximumPageDescriptorRequestAttempts = 3 + /// 避免 OCR、主题和标注刷新同一页面时反复同步读取笔迹 JSON。 + private var drawingDocuments: [Int: RDPDFReaderDrawingDocument] = [:] private var ocrRuns: [Int: [RDPDFReaderTextRun]] = [:] private var recognizingPages = Set() /// 每页的逻辑请求标识。快速翻页取消旧请求后,迟到回调不能覆盖当前状态。 private var ocrRequestTokens: [Int: UUID] = [:] private var bookmarks = Set() + private var annotations = [RDPDFReaderAnnotation]() + private var annotationsByPage: [Int: [RDPDFReaderAnnotation]] = [:] private weak var topToolbar: RDPDFReaderKitTopToolView? private weak var bottomToolbar: RDPDFReaderKitBottomToolView? private var navigationLoadingIndicator: UIActivityIndicatorView? + private var navigationRequestToken: UUID? private let drawingToolbar = RDPDFReaderDrawingToolbar() private var isDrawingMode = false /// `nil` 时处于画笔面板内的浏览状态,可单指拖动画面。 @@ -132,6 +143,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS readerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] readerView.dataSource = self readerView.delegate = self + reloadAnnotationCache() readerView.currentDisplayType = configuration.displayType readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled // 手机横屏竖滑的 cell 高度 = 安全区内可用宽度按页面纵横比换算的纸张高度。 @@ -152,6 +164,12 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS bookmarks = Set(persistence?.loadBookmarks(for: book.identifier).map(\.pageIndex) ?? []) } + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + // 宿主或另一阅读器实例可能已修改同一本书的标注;重新出现时刷新快照。 + reloadAnnotations() + } + public override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() cancelOutstandingOCRRequests() @@ -160,7 +178,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS let visibleIndexes = Set(visiblePageViews().map(\.tag)) pageDescriptors = pageDescriptors.filter { visibleIndexes.contains($0.key) } ocrRuns = ocrRuns.filter { visibleIndexes.contains($0.key) } + drawingDocuments = drawingDocuments.filter { visibleIndexes.contains($0.key) } (pageProvider as? RDPDFKitPageProvider)?.removeCachedImages() + visiblePageViews().forEach { $0.releaseDrawingRenderingCache() } visibleIndexes.sorted().forEach { startOCRIfNeeded($0) } } @@ -169,6 +189,13 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS applyDisplayTypeForCurrentInterface() } + /// 重新读取宿主的标注数据,并刷新当前可见页。宿主完成云同步或外部编辑后可调用。 + public func reloadAnnotations() { + reloadAnnotationCache() + guard isViewLoaded else { return } + visiblePageViews().forEach { configure($0, at: $0.tag) } + } + public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) let forcePhoneLandscape = traitCollection.userInterfaceIdiom == .phone && size.width > size.height @@ -245,6 +272,11 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS radius: configuration.ocrMemoryCacheRadius ?? configuration.pageDescriptorCacheRadius ) trimPageDescriptorCache(around: pageNum, radius: configuration.pageDescriptorCacheRadius) + trimDrawingDocumentCache(around: pageNum, radius: configuration.pageDescriptorCacheRadius) + (pageProvider as? RDPDFKitPageProvider)?.trimPageImages( + around: pageNum, + radius: configuration.pageDescriptorCacheRadius + ) (pageProvider as? RDPDFKitPageProvider)?.trimTextRuns( around: pageNum, radius: configuration.pageDescriptorCacheRadius @@ -258,10 +290,61 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS private func trimPageDescriptorCache(around pageIndex: Int, radius: Int) { let safeRadius = max(0, radius) - let stalePages = pageDescriptors.keys.filter { abs($0 - pageIndex) > safeRadius } - for cachedPage in stalePages { - pageDescriptors.removeValue(forKey: cachedPage) + let visiblePages = Set(visiblePageViews().map(\.tag)) + let isStale: (Int) -> Bool = { abs($0 - pageIndex) > safeRadius && !visiblePages.contains($0) } + pageDescriptors.keys.filter(isStale).forEach { pageDescriptors.removeValue(forKey: $0) } + + // Provider 本身无法取消时,以 token 失效保证迟到回调不能把远页重新塞回缓存。 + pageDescriptorRequestTokens.keys.filter(isStale).forEach { clearPageDescriptorRequest(for: $0) } + pageDescriptorRequestAttempts.keys.filter(isStale).forEach { pageDescriptorRequestAttempts.removeValue(forKey: $0) } + pageLoadFailedPages = pageLoadFailedPages.filter { !isStale($0) } + } + + private func trimDrawingDocumentCache(around pageIndex: Int, radius: Int) { + let safeRadius = max(0, radius) + drawingDocuments = drawingDocuments.filter { abs($0.key - pageIndex) <= safeRadius } + } + + private func shouldRetainPageDescriptor(_ pageIndex: Int) -> Bool { + if visiblePageViews().contains(where: { $0.tag == pageIndex }) { return true } + let currentPage = readerView.currentPage + return currentPage >= 0 && abs(pageIndex - currentPage) <= max(0, configuration.pageDescriptorCacheRadius) + } + + private func drawingDocument(for pageIndex: Int) -> RDPDFReaderDrawingDocument { + if let cached = drawingDocuments[pageIndex] { return cached } + let document = (annotationPersistence as? RDPDFReaderPersistenceStore)?.drawingDocument(pageNo: pageIndex) + ?? .init(pageNo: pageIndex, paths: []) + drawingDocuments[pageIndex] = document + return document + } + + private func cacheDrawingDocument(_ document: RDPDFReaderDrawingDocument, pageIndex: Int) { + drawingDocuments[pageIndex] = document + } + + private func removePageDescriptorRequestState(for index: Int) { + clearPageDescriptorRequest(for: index) + pageDescriptorRequestAttempts.removeValue(forKey: index) + pageLoadFailedPages.remove(index) + } + + private func acceptPageDescriptor(_ descriptor: RDPDFReaderPageDescriptor, at index: Int) { + removePageDescriptorRequestState(for: index) + pageDescriptors[index] = descriptor + // 宽度适配竖滑下,占位的一屏高 cell 要按真实纸张比例重新排版。 + readerView.invalidateWidthFitLayoutIfNeeded(updatedPage: index) + // 初始请求的 cell 可能已经因旋转或复用离开屏幕;只刷新仍显示的实际页面。 + refreshVisiblePage(index) + } + + private func handleInvalidPageDescriptor(at index: Int, token: UUID, attempt: Int) { + guard pageDescriptorRequestTokens[index] == token else { return } + clearPageDescriptorRequest(for: index) + if attempt >= maximumPageDescriptorRequestAttempts { + pageLoadFailedPages.insert(index) } + refreshVisiblePage(index) } private func trimOCRCache(around pageIndex: Int, radius: Int) { @@ -282,9 +365,17 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS page.isDrawingMode = isDrawingMode && currentDrawingTool != nil page.configureDrawing( pageIndex: index, - document: (annotationPersistence as? RDPDFReaderPersistenceStore)?.drawingDocument(pageNo: index) ?? .init(pageNo: index, paths: []), - documentChanged: { [weak self] document in - (self?.annotationPersistence as? RDPDFReaderPersistenceStore)?.saveDrawingDocument(document, pageNo: index) + document: drawingDocument(for: index), + documentChanged: { [weak self, weak page] document in + guard let self, + let store = self.annotationPersistence as? RDPDFReaderPersistenceStore else { return } + switch store.saveDrawingDocumentVersioned(document, pageNo: index) { + case .success(let saved): + self.cacheDrawingDocument(saved, pageIndex: index) + page?.acknowledgeDrawingRevision(saved.revision) + case .failure(let error): + self.delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) + } } ) if let currentDrawingTool { @@ -294,18 +385,70 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS if phase == .began { self?.activeDrawingPage = source } self?.routeDrawingStroke(from: source, point: point, phase: phase) } - guard descriptor == nil else { startOCRIfNeeded(index); return } - pageProvider.readerPage(at: index) { [weak self, weak page] descriptor in - DispatchQueue.main.async { [weak self, weak page] in - guard let self, descriptor.index == index else { return } - self.pageDescriptors[index] = descriptor - // 宽度适配竖滑下,占位的一屏高 cell 要按真实纸张比例重新排版。 - self.readerView.invalidateWidthFitLayoutIfNeeded(updatedPage: index) - if let page { self.configure(page, at: index) } + guard descriptor == nil else { + page.setPageLoadFailed(false) + startOCRIfNeeded(index) + return + } + if pageLoadFailedPages.contains(index) { + page.setPageLoadFailed(true) { [weak self, weak page] in + guard let self, let page else { return } + self.pageLoadFailedPages.remove(index) + self.pageDescriptorRequestAttempts[index] = 0 + page.setPageLoadFailed(false) + self.configure(page, at: index) + } + return + } + page.setPageLoadFailed(false) + guard pageDescriptorRequestTokens[index] == nil else { return } + let token = UUID() + let attempt = (pageDescriptorRequestAttempts[index] ?? 0) + 1 + pageDescriptorRequestAttempts[index] = attempt + pageDescriptorRequestTokens[index] = token + let timeout = DispatchWorkItem { [weak self] in + guard let self, self.pageDescriptorRequestTokens[index] == token else { return } + self.clearPageDescriptorRequest(for: index) + if attempt >= self.maximumPageDescriptorRequestAttempts { + self.pageLoadFailedPages.insert(index) + } + // 前两次超时会重新请求,第三次停止并展示手动重试入口。 + self.refreshVisiblePage(index) + } + pageDescriptorRequestTimeouts[index] = timeout + let timeoutSeconds = pow(2.0, Double(attempt - 1)) * 8.0 + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: timeout) + pageProvider.readerPage(at: index) { [weak self] descriptor in + DispatchQueue.main.async { [weak self] in + guard let self else { return } + guard descriptor.index == index else { + self.handleInvalidPageDescriptor(at: index, token: token, attempt: attempt) + return + } + // 超时只代表响应慢,不代表结果失效。只要页面仍在缓存窗口且尚无更新 + // 结果,就接受迟到的有效描述;若新重试已开始,它会随 token 一并失效。 + guard self.pageDescriptors[index] == nil else { + if self.pageDescriptorRequestTokens[index] == token { + self.clearPageDescriptorRequest(for: index) + } + return + } + guard self.shouldRetainPageDescriptor(index) else { + if self.pageDescriptorRequestTokens[index] == token { + self.clearPageDescriptorRequest(for: index) + } + return + } + self.acceptPageDescriptor(descriptor, at: index) } } } + private func clearPageDescriptorRequest(for index: Int) { + pageDescriptorRequestTokens.removeValue(forKey: index) + pageDescriptorRequestTimeouts.removeValue(forKey: index)?.cancel() + } + private func renderedImage(_ image: UIImage?) -> UIImage? { guard let image else { return nil } return configuration.pageImageTransform?(image, currentTheme) ?? image @@ -373,9 +516,22 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS .filter { $0.tag >= 0 } } - private func annotations(for index: Int) -> [RDPDFReaderAnnotation] { - do { return try annotationPersistence?.loadAnnotations().filter { $0.pageIndex == index } ?? [] } - catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error); return [] } + private func annotations(for index: Int) -> [RDPDFReaderAnnotation] { annotationsByPage[index] ?? [] } + + private func reloadAnnotationCache() { + guard let annotationPersistence else { + annotations = [] + annotationsByPage = [:] + return + } + do { + annotations = try annotationPersistence.loadAnnotations() + annotationsByPage = Dictionary(grouping: annotations, by: \.pageIndex) + } catch { + annotations = [] + annotationsByPage = [:] + delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) + } } private func toggleBookmark() { @@ -389,10 +545,14 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS private func showNavigation() { if let asyncOutlineProvider = pageProvider as? RDPDFReaderAsyncOutlineProviding { + guard navigationLoadingIndicator == nil else { return } + let token = UUID() + navigationRequestToken = token showNavigationLoading() asyncOutlineProvider.readerOutlineItems { [weak self] outline in DispatchQueue.main.async { guard let self else { return } + guard self.navigationRequestToken == token else { return } self.hideNavigationLoading() self.presentNavigation(outline: outline) } @@ -432,6 +592,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS navigationLoadingIndicator?.stopAnimating() navigationLoadingIndicator?.removeFromSuperview() navigationLoadingIndicator = nil + navigationRequestToken = nil } private func showSettings() { @@ -587,8 +748,8 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS } private func showAnnotations() { - guard let annotationPersistence else { return } - let panel = RDPDFReaderAnnotationListViewController { (try? annotationPersistence.loadAnnotations()) ?? [] } + guard annotationPersistence != nil else { return } + let panel = RDPDFReaderAnnotationListViewController { [weak self] in self?.annotations ?? [] } panel.onSelectAnnotation = { [weak self] item in self?.readerView.transitionToPage(pageNum: item.pageIndex, animated: false) } panel.onDeleteAnnotation = { [weak self] item in self?.delete(item) } RDPDFReaderPanelPresenter.present(UINavigationController(rootViewController: panel), from: self, layout: .navigation, hidesNavigationBar: false) @@ -609,12 +770,20 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS private func add(_ selection: RDPDFReaderImageTextSelection, page: Int, color: String = RDPDFReaderPageView.defaultHighlightColor, note: String?) { guard let annotationPersistence else { return } - do { _ = try annotationPersistence.addAnnotation(.init(pageIndex: page, selectedText: selection.text, normalizedRects: selection.normalizedRects, color: color, note: note, source: selection.source)); refreshVisiblePage(page) } + do { + _ = try annotationPersistence.addAnnotation(.init(pageIndex: page, selectedText: selection.text, normalizedRects: selection.normalizedRects, color: color, note: note, source: selection.source)) + reloadAnnotationCache() + refreshVisiblePage(page) + } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } } private func delete(_ annotation: RDPDFReaderAnnotation) { - do { _ = try annotationPersistence?.deleteAnnotation(id: annotation.id); refreshVisiblePage(annotation.pageIndex) } + do { + _ = try annotationPersistence?.deleteAnnotation(id: annotation.id) + reloadAnnotationCache() + refreshVisiblePage(annotation.pageIndex) + } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } } @@ -638,6 +807,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS item.note = note do { _ = try self?.annotationPersistence?.updateAnnotation(item) + self?.reloadAnnotationCache() self?.refreshVisiblePage(item.pageIndex) } catch { if let self { self.delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } From c61115cab37909a9f3a6283f5b946cee9de7d76f Mon Sep 17 00:00:00 2001 From: shenlei Date: Mon, 27 Jul 2026 14:16:53 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=8F=90=E4=BA=A4git=20s?= =?UTF-8?q?kill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/commit-git-changes/SKILL.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/.agents/skills/commit-git-changes/SKILL.md b/.agents/skills/commit-git-changes/SKILL.md index c38e95c..584c724 100644 --- a/.agents/skills/commit-git-changes/SKILL.md +++ b/.agents/skills/commit-git-changes/SKILL.md @@ -1,13 +1,13 @@ --- name: commit-git-changes -description: 审查当前 Git 工作区,安全选择本次任务文件,执行必要校验,生成包含问题背景、根因、修改方案和验证结果的详细中文提交说明,并提交及按用户要求推送到 Git 服务器。用户要求“提交代码”“提交并推送”“同步到 Git”“上传当前修改”或要求生成详细 commit message 时使用。 +description: 审查当前 Git 工作区,安全选择本次任务文件,生成包含问题背景、根因和修改方案的详细中文提交说明,并提交及按用户要求推送到 Git 服务器。默认不执行构建或测试;用户要求“提交代码”“提交并推送”“同步到 Git”“上传当前修改”或要求生成详细 commit message 时使用。 --- # 提交 Git 改动 ## 目标 -在不混入无关修改、不泄露敏感信息、不改写远端历史的前提下,完成“审查—验证—暂存—提交—推送—确认”闭环,并用提交说明解释为什么修改以及如何解决,而不只是罗列文件。 +在不混入无关修改、不泄露敏感信息、不改写远端历史的前提下,完成“审查—暂存—提交—推送—确认”闭环,并用提交说明解释为什么修改以及如何解决,而不只是罗列文件。 ## 工作流 @@ -21,9 +21,7 @@ description: 审查当前 Git 工作区,安全选择本次任务文件,执 - 保留用户已有或其他任务产生的无关修改。 - 优先显式 `git add `;仅在确认所有改动均属本次提交时使用 `git add -A`。 - 发现密钥、令牌、证书、个人配置、大型生成物或疑似敏感数据时停止提交并说明。 -4. 根据改动风险执行项目约定的构建、测试或静态检查。 - - 不为通过校验而擅自扩大修改范围。 - - 不能运行的校验必须如实记录原因,不得声称已通过。 +4. 不默认执行构建、测试或项目级验证;仅在用户明确要求时执行。 5. 暂存后再次检查: - 执行 `git diff --cached --check`。 - 执行 `git diff --cached --stat` 和 `git diff --cached`。 @@ -38,7 +36,7 @@ description: 审查当前 Git 工作区,安全选择本次任务文件,执 ## 提交说明规范 -提交说明使用“标题 + 空行 + 详细正文 + 验证结果”的结构。 +提交说明使用“标题 + 空行 + 详细正文”的结构。 ### 标题 @@ -56,10 +54,11 @@ description: 审查当前 Git 工作区,安全选择本次任务文件,执 1. **问题背景与影响**:什么场景触发、用户看到什么、影响哪些路径。 2. **根因**:原有数据流、状态条件或实现约束为什么导致问题。 3. **修改方案**:关键行为如何改变,为什么选择该方案,必要时说明兼容和边界处理。 -4. **验证结果**:实际运行了哪些构建、测试或检查;未运行时写明原因。 正文应描述行为和因果关系,不要把 `git diff --stat` 改写成文件清单。多个紧密相关的修改可以分段说明,但不要堆砌逐文件 bullet。 +若用户明确要求并实际执行了构建、测试或其他验证,可以在正文末尾如实补充;未执行时不需要添加“验证:未执行”之类的说明。 + ### 示例 ```text @@ -73,7 +72,6 @@ trainPointModel 时才创建合并器,导致班级、训练和项目均为空 服务端完成态可供对账,将本地记录作为唯一持久来源并跳过超期回收,保持 重新进入播放页后的完成状态稳定。 -验证:完成目标场景回归,并通过 Debug 模拟器构建。 ``` 根据真实改动改写示例内容,禁止照抄未发生的场景或验证结论。 @@ -89,4 +87,4 @@ trainPointModel 时才创建合并器,导致班级、训练和项目均为空 ## 交付 -报告提交哈希和标题、推送的远端分支、验证结果,以及仍留在工作区的未提交修改。若只完成本地提交而未推送,必须明确说明。 +报告提交哈希和标题、推送的远端分支,以及仍留在工作区的未提交修改。若只完成本地提交而未推送,必须明确说明。