# 横竖屏切换支持方案讨论 ## 需求背景 `Doc/EPUB_MAINTENANCE.md` 与 `Doc/ARCHITECTURE.md` 都把“横竖屏切换支持”列为当前阅读器的高优先级待补能力。 目标不是单纯“屏幕旋转后不崩”,而是让 EPUB 阅读器在横竖屏切换后具备以下稳定行为: 1. 正文重新按新视口尺寸分页或重排 2. 阅读位置尽量准确恢复,不明显跳章、跳页 3. 工具栏、目录、标注、搜索状态不异常 4. pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll 四种展示模式行为一致 5. WebView、DTCoreText、fixed layout 三条正文路径都能正确处理 ## 代码事实 ### 已有基础 当前仓库并不是完全没有横竖屏处理基础: - `RDReaderView.layoutSubviews()` 会检测 `previousIsLandscape` 与 `isLandscape` 的变化 文件:`Sources/RDReaderView/RDReaderView.swift` - `RDReaderView.orientationChanged(isNowLandscape:)` 已能: - 通知 `readerViewOrientationWillChange` - 更新 `layout.isLandscapeDualPage` - `pageCurl` 模式下重建 `UIPageViewController` - 滚动模式下 `invalidateLayout + reloadData + setContentOffset` - `RDEPUBReaderController.readerViewOrientationWillChange(...)` 当前会直接调用 `repaginatePreservingCurrentLocation()` - `RDEPUBReaderController.repaginatePreservingCurrentLocation()` 已具备“先记录当前位置,再重新分页,再恢复位置”的主链路 - `RDEPUBLocation` 已采用 `href + progression + lastProgression + fragment` 模型,本身适合应对字号变化和尺寸变化后的重定位 结论: - 基础设施已经存在 - 真正缺的是“时机是否稳定、入口是否统一、三条正文路径是否都覆盖、是否会重复触发” ### 当前实现存在的问题 #### 问题 1:方向变化回调被 `landscapeDualPageEnabled` 绑定 `RDReaderView.layoutSubviews()` 目前有: ```swift guard landscapeDualPageEnabled, bounds.width > 0, bounds.height > 0 else { return } ``` 这意味着: - 只有开启 `landscapeDualPageEnabled` 时才会进入方向变化检测 - 如果宿主关闭横屏双页,但正文仍然会因为宽高变化而需要重排,此时不会触发回调 这对以下场景不正确: 1. `textReflowable` 文本书籍在横竖屏切换后,行宽必然变化,应重新分页 2. `webInteractive` 可重排正文在横竖屏切换后,列分页宽度变化,应重新分页 3. fixed layout 在 spread 模式下也可能因视口变化而需要重新生成页面模型 #### 问题 2:重新分页触发时机偏早 当前 `readerViewOrientationWillChange` 是从 `RDReaderView.layoutSubviews()` 中异步抛出。 这有几个风险: 1. 此时上层 VC 的 `view.bounds`、safe area、sheet 布局动画可能还在变化中 2. `paginatePublication()` 如果过早读取 `currentLayoutContext().viewportSize`,可能拿到中间态尺寸 3. `RDReaderView` 自己已经在同一轮变化里做了 `reloadData` / `transitionToPage`,而 `RDEPUBReaderController` 又会发起新一轮分页,容易出现双重刷新 #### 问题 3:职责边界不够清晰 目前横竖屏变化同时由两层在做事: - `RDReaderView`:处理双页布局、pageCurl 容器重建、滚动模式 offset 恢复 - `RDEPUBReaderController`:处理 EPUB 重新分页 但两层之间没有一个明确的“统一入口”来协调: 1. 是否真的需要重分页 2. 什么时候用最终尺寸重分页 3. 何时忽略重复触发 4. 何时只刷新布局,不重做完整分页 #### 问题 4:文档状态与代码状态不一致 文档把横竖屏切换标为“未实现”,但代码里已经有半套逻辑。 这说明当前更准确的描述应当是: - “已有局部实现” - “尚未形成稳定、完整、可验收的横竖屏支持闭环” ## 需求拆解 为了让“横竖屏切换支持”真正完成,建议把需求拆成四部分: ### 1. 稳定感知视口变化 不仅要感知“横屏/竖屏布尔值变化”,还要感知: - 宽高尺寸变化 - safe area 变化 - iPad 分屏、多窗口、sheet 尺寸变化 因此,真正要监听的不是“orientation”,而是“阅读视口发生了足以影响分页的变化”。 ### 2. 统一触发重新分页 所有需要重排正文的场景都应走统一入口,例如: ```swift handleViewportChange(reason: .orientationTransition) ``` 由它统一负责: 1. 读取当前位置 2. 比较旧 viewport 与新 viewport 3. 防抖与去重 4. 发起分页 5. 恢复定位 ### 3. 区分“容器布局刷新”和“正文重分页” 不是所有变化都必须触发完整分页,但横竖屏切换通常都需要: - `textReflowable`:重建 `RDEPUBTextBook` - `webInteractive`:重跑 `RDEPUBPaginator` - `webFixedLayout`:重建 fixed spread snapshot 而 `RDReaderView` 自己的 pageCurl 双页容器重建仍可保留,但不应和正文分页时机互相打架。 ### 4. 建立回归验收矩阵 横竖屏切换是高联动场景,至少要验证: 1. 无选区、无工具栏时切换 2. 有目录面板、设置面板、标注列表时切换 3. 搜索命中页、标注页、末页时切换 4. `landscapeDualPageEnabled = true / false` 5. pageCurl / scroll 系列模式 ## 技术路线对比 ### 路线 A:继续依赖 `RDReaderView.readerViewOrientationWillChange` 思路: - 保留现有 `layoutSubviews() -> orientationChanged -> delegate` - 在 `RDEPUBReaderController` 内增强去重、防抖和最终尺寸判断 优点: 1. 改动范围小 2. 能延续现有 `RDReaderView` 双页逻辑 风险: 1. 触发时机仍然偏依赖 `layoutSubviews()` 2. 仍容易与 VC 生命周期中的尺寸变化打架 3. 继续把“正文分页”绑定到“容器方向回调”,抽象层次不够稳定 ### 路线 B:由 `RDEPUBReaderController.viewWillTransition(to:with:)` 统一接管 思路: 1. 在 `RDEPUBReaderController` 实现 `viewWillTransition(to:with:)` 2. 利用 `transitionCoordinator` 等待旋转动画接近完成 3. 在 completion 中读取最终 `viewportSize` 4. 统一调用 `handleViewportChange(reason:)` 5. `RDReaderView.readerViewOrientationWillChange` 只保留容器级双页重建职责,不再直接触发正文分页 优点: 1. 分页时机更接近最终尺寸 2. 更符合 UIKit 对旋转和尺寸变化的生命周期 3. 更容易做重复触发抑制 4. 更适合以后扩展到 iPad 分屏、多窗口 风险: 1. 需要重新梳理 `RDReaderView` 与 `RDEPUBReaderController` 的职责分界 2. 需要验证 pageCurl 双页重建与正文分页之间的顺序 ### 推荐结论 建议采用: **主路线:路线 B,由 `RDEPUBReaderController.viewWillTransition(to:with:)` 统一接管正文重分页** **保留 `RDReaderView` 现有方向变化逻辑,但将其职责收敛为容器布局刷新和双页模式内部处理** 原因: 1. 需求本质是“视口变化后的正文重排”,最合理的宿主是控制器,而不是容器内部的 `layoutSubviews()` 2. 现有 `RDReaderView` 回调机制已经能服务内部双页布局,但不适合作为 EPUB 正文分页的唯一入口 3. `viewWillTransition` + `transitionCoordinator` 更容易拿到稳定尺寸,并减少重复分页 ## 推荐方案 ### 方案总览 引入一条统一的横竖屏处理主链路: ```swift viewWillTransition(to:with:) -> scheduleViewportTransition(reason: .orientation) -> handleViewportChangeIfNeeded(finalViewportSize) -> repaginatePreservingCurrentLocation() -> finishPagination(restoreLocation:) ``` 同时把 `RDReaderView` 内部职责收敛为: 1. 更新 `layout.isLandscapeDualPage` 2. pageCurl 模式下重建 `UIPageViewController` 3. 滚动模式刷新 collectionView 布局 不再让 EPUBUI 直接在 `readerViewOrientationWillChange` 中立即重新分页。 ### 关键改动建议 #### Task O1:增加控制器级旋转入口 文件: - `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` 改动: - 实现 `viewWillTransition(to:with:)` - 在 `transitionCoordinator` completion 中比较新旧 viewport - 统一调 `handleViewportChange(reason:)` 完成定义: - 旋转后分页使用最终尺寸,而不是中间态尺寸 #### Task O2:引入 viewport 去重与防抖 文件: - `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` 改动: - 保存最近一次已应用的 `viewportSignature` - 仅当宽高变化超过阈值时才触发重新分页 - 防止 `viewDidLayoutSubviews`、`orientation callback`、`viewWillTransition` 连续多次重复触发 推荐结构: ```swift private struct RDEPUBViewportSignature: Equatable { let width: CGFloat let height: CGFloat let safeTop: CGFloat let safeBottom: CGFloat } ``` 完成定义: - 一次横竖屏切换只触发一次有效正文重分页 #### Task O3:调整 `readerViewOrientationWillChange` 职责 文件: - `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` - 如有必要 `Sources/RDReaderView/RDReaderView.swift` 改动: - `RDEPUBReaderController.readerViewOrientationWillChange` 不再直接 `repaginatePreservingCurrentLocation()` - 保留它作为兼容钩子,或仅做轻量状态标记 完成定义: - 容器内部布局刷新与正文分页不再相互打架 #### Task O4:放宽 `RDReaderView` 的方向检测前置条件 文件: - `Sources/RDReaderView/RDReaderView.swift` 改动: - 去掉 `layoutSubviews()` 中对 `landscapeDualPageEnabled` 的硬性 guard - 方向变化检测应独立于“是否启用横屏双页” 完成定义: - 即使 `landscapeDualPageEnabled == false`,视口变化仍可被上层感知并触发重分页 注意事项: - 双页布局本身仍然只在 `landscapeDualPageEnabled == true` 时开启 - 但“是否需要感知尺寸变化”不能绑定到这个开关 #### Task O5:验证三条正文路径的恢复策略 文件: - `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` - `Sources/RDReaderView/EPUBCore/RDEPUBReadingSession.swift` - `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` 改动: - 验证 `restoreReadingLocation()` 在以下路径都稳定: - `textReflowable` - `webInteractive` - `webFixedLayout` 完成定义: - 横竖屏切换后,恢复位置不明显跳页、跳章 #### Task O6:补文档与验收矩阵 文件: - `Doc/EPUB_MAINTENANCE.md` - `Doc/ARCHITECTURE.md` - 如有必要 `Doc/EPUBUI_功能实现逻辑.md` 改动: - 将“横竖屏切换未做”更新为更准确的实现状态 - 补充最终采用的触发时机与回归清单 完成定义: - 文档与代码状态一致 ## 风险点 1. **pageCurl 双页重建顺序** `RDReaderView` 自己会重建 `UIPageViewController`,如果 `RDEPUBReaderController` 同时触发分页,需要验证两者先后顺序 2. **重复分页** 旋转时 UIKit 常伴随多次 layout / safe area 变化,如果没有 `viewportSignature`,很容易多次重排 3. **iPad 场景复杂度** 分屏、多窗口、Stage Manager 下不一定发生“方向变化”,但一定发生“视口变化”,所以方案必须以 viewport 为核心 4. **fixed layout spread 恢复** 横屏双页和竖屏单页之间切换时,恢复到哪一页的左页 / 右页,需要以 `RDEPUBLocation` 为准而不是旧页号 ## 推荐实施顺序 1. 先做 `O1 + O2`,把控制器级 viewport 变化入口和防抖建起来 2. 再做 `O3 + O4`,把旧的 delegate 触发职责收窄 3. 然后验证 `O5`,重点跑三条正文路径和四种展示模式 4. 最后做 `O6`,同步文档与验收矩阵 ## 验收标准 1. 横竖屏切换后,`textReflowable` 路径会按新尺寸重新分页,并恢复到接近原阅读位置 2. 横竖屏切换后,`webInteractive` 路径会重新分页,并恢复到接近原阅读位置 3. 横竖屏切换后,`webFixedLayout` 路径会按新 spread 规则重建页面模型,并恢复到正确资源 4. `landscapeDualPageEnabled == false` 时,横竖屏切换仍然会触发必要的重排 5. 一次旋转只触发一次有效正文重分页,不出现连续多次闪烁刷新 6. pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll 四种模式下都不出现明显错页、白页或工具栏错位 ## 结论 横竖屏切换支持不是“从零开始新增一套能力”,而是要把当前分散在 `RDReaderView` 和 `RDEPUBReaderController` 里的半套逻辑收敛成一个稳定闭环。 最推荐的方向是: - `RDEPUBReaderController` 用 `viewWillTransition(to:with:)` 统一接管正文级重分页 - `RDReaderView` 保留容器级双页布局和 pageCurl 重建职责 - 以 `viewport` 变化而不是 `isLandscape` 布尔值作为真正的分页触发依据 这样既能复用现有代码,又能把“文档标注为未完成”的横竖屏需求收敛成可实现、可回归、可交付的一阶段方案。