Files
ReadViewSDK/Doc/FeatureSolution/横竖屏切换支持方案讨论.md
T
2026-05-21 19:40:51 +08:00

373 lines
13 KiB
Markdown

# 横竖屏切换支持方案讨论
## 需求背景
`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` 布尔值作为真正的分页触发依据
这样既能复用现有代码,又能把“文档标注为未完成”的横竖屏需求收敛成可实现、可回归、可交付的一阶段方案。