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