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

13 KiB
Raw Blame History

横竖屏切换支持方案讨论

需求背景

Doc/EPUB_MAINTENANCE.mdDoc/ARCHITECTURE.md 都把“横竖屏切换支持”列为当前阅读器的高优先级待补能力。

目标不是单纯“屏幕旋转后不崩”,而是让 EPUB 阅读器在横竖屏切换后具备以下稳定行为:

  1. 正文重新按新视口尺寸分页或重排
  2. 阅读位置尽量准确恢复,不明显跳章、跳页
  3. 工具栏、目录、标注、搜索状态不异常
  4. pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll 四种展示模式行为一致
  5. WebView、DTCoreText、fixed layout 三条正文路径都能正确处理

代码事实

已有基础

当前仓库并不是完全没有横竖屏处理基础:

  • RDReaderView.layoutSubviews() 会检测 previousIsLandscapeisLandscape 的变化
    文件: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() 目前有:

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. 统一触发重新分页

所有需要重排正文的场景都应走统一入口,例如:

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. 继续把“正文分页”绑定到“容器方向回调”,抽象层次不够稳定

路线 BRDEPUBReaderController.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. 需要重新梳理 RDReaderViewRDEPUBReaderController 的职责分界
  2. 需要验证 pageCurl 双页重建与正文分页之间的顺序

推荐结论

建议采用:

主路线:路线 BRDEPUBReaderController.viewWillTransition(to:with:) 统一接管正文重分页
保留 RDReaderView 现有方向变化逻辑,但将其职责收敛为容器布局刷新和双页模式内部处理

原因:

  1. 需求本质是“视口变化后的正文重排”,最合理的宿主是控制器,而不是容器内部的 layoutSubviews()
  2. 现有 RDReaderView 回调机制已经能服务内部双页布局,但不适合作为 EPUB 正文分页的唯一入口
  3. viewWillTransition + transitionCoordinator 更容易拿到稳定尺寸,并减少重复分页

推荐方案

方案总览

引入一条统一的横竖屏处理主链路:

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
  • 仅当宽高变化超过阈值时才触发重新分页
  • 防止 viewDidLayoutSubviewsorientation callbackviewWillTransition 连续多次重复触发

推荐结构:

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 四种模式下都不出现明显错页、白页或工具栏错位

结论

横竖屏切换支持不是“从零开始新增一套能力”,而是要把当前分散在 RDReaderViewRDEPUBReaderController 里的半套逻辑收敛成一个稳定闭环。

最推荐的方向是:

  • RDEPUBReaderControllerviewWillTransition(to:with:) 统一接管正文级重分页
  • RDReaderView 保留容器级双页布局和 pageCurl 重建职责
  • viewport 变化而不是 isLandscape 布尔值作为真正的分页触发依据

这样既能复用现有代码,又能把“文档标注为未完成”的横竖屏需求收敛成可实现、可回归、可交付的一阶段方案。