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

373 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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