Files
ReadViewSDK/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
T
shenandshen 6f75b083f7 feat: EPUB 阅读器搜索、选中注释、书签 chrome 状态及大量重构优化
- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态
- 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试
- 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证)
- 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互
- 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache)
- 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy
- 更新 epub-bridge.js 与 JS bridge 通信协议
- 全面更新现有 UI 测试以适配新的 helper 和状态管理
2026-06-13 22:48:56 +08:00

507 lines
19 KiB
Swift

import UIKit
// MARK: - 文件说明
/// EPUB 阅读器主控制器
/// EPUBUI 层的核心入口,提供开箱即用的完整阅读器体验
/// 调用方只需传入 EPUB 文件 URL 或已构建的 TextBook,即可呈现完整的阅读界面
///
/// **架构位置:** EPUBUI(最顶层)→ Core → Parser → Foundation
///
/// **核心职责:**
/// - EPUB 解析与分页(支持流式排版和固定布局)
/// - 阅读位置的持久化与恢复
/// - 高亮、书签的增删改查
/// - 工具栏、目录、设置等 UI 面板的协调管理
/// - 搜索功能(全文搜索、前后跳转)
///
/// **打开流程:** init(epubURL:) → viewDidLoad → RDEPUBParser.parse → paginatePublication → readerView.reloadData → restoreLocation
// MARK: - 阅读器主控制器
public final class RDEPUBReaderController: UIViewController {
// MARK: - 公开属性
/// 阅读器委托,用于接收阅读状态变更通知
public weak var delegate: RDEPUBReaderDelegate?
/// 阅读器配置,修改后自动判断是否需要重新分页或刷新内容
public var configuration: RDEPUBReaderConfiguration {
didSet {
readerContext.configuration = configuration
applyWebViewDebugPolicy()
persistReaderSettingsIfNeeded()
guard isViewLoaded else { return }
let oldConfiguration = oldValue
applyReaderViewConfiguration()
if isExternalTextBook {
if requiresRepagination(from: oldConfiguration, to: configuration) {
rebuildExternalTextBook()
} else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) {
refreshVisibleContentPreservingLocation()
}
return
}
guard publication != nil else { return }
if requiresRepagination(from: oldConfiguration, to: configuration) {
repaginatePreservingCurrentLocation()
} else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) {
refreshVisibleContentPreservingLocation()
}
}
}
/// 当前阅读位置
public var currentLocation: RDEPUBLocation? {
currentVisibleLocation()
}
/// 当前页码(从 1 开始),nil 表示尚未加载
public var currentPageNumber: Int? {
guard readerView.currentPage >= 0 else { return nil }
return readerView.currentPage + 1
}
/// 当前用户选中的文本(只读),用于创建高亮/批注
public internal(set) var currentSelection: RDEPUBSelection? {
get { readerContext.currentSelection }
set { readerContext.currentSelection = newValue }
}
/// 所有高亮标注
public var highlights: [RDEPUBHighlight] {
activeHighlights
}
/// 所有书签
public var bookmarks: [RDEPUBBookmark] {
activeBookmarks
}
/// 统一标注语义视图,对标 WXRead 的 WRBookmark 主模型。
public var annotations: [RDEPUBAnnotation] {
let merged = activeHighlights.map(\.annotation) + activeBookmarks.map(\.annotation)
return merged.sorted { $0.createdAt < $1.createdAt }
}
/// 原始目录树结构
public var tableOfContents: [EPUBTableOfContentsItem] {
publication?.tableOfContents ?? []
}
/// 展平后的目录列表(用于目录面板展示,每个条目附带页码)
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] {
guard let publication else { return [] }
return flattenedTableOfContentsItems(from: publication.tableOfContents)
}
/// 当前阅读位置所在的目录项
public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? {
resolvedCurrentTableOfContentsItem()
}
// MARK: - 私有属性
/// EPUB 文件 URL
let epubURL: URL
let persistence: RDEPUBReaderPersistence?
let dependencies: RDEPUBReaderDependencies
let readerView = RDReaderView()
let loadingIndicator: UIActivityIndicatorView = {
UIActivityIndicatorView(style: .large)
}()
let errorLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.textAlignment = .center
label.textColor = .darkGray
label.isHidden = true
return label
}()
let paginationHostView = UIView()
lazy var readerContext = RDEPUBReaderContext(controller: self)
var parser: RDEPUBParser? {
get { readerContext.parser }
set { readerContext.parser = newValue }
}
var publication: RDEPUBPublication? {
get { readerContext.publication }
set { readerContext.publication = newValue }
}
var readingSession: RDEPUBReadingSession? {
get { readerContext.readingSession }
set { readerContext.readingSession = newValue }
}
var textBook: RDEPUBTextBook? {
get { readerContext.textBook }
set { readerContext.textBook = newValue }
}
var activePages: [EPUBPage] {
readerContext.activePages
}
var activeChapters: [EPUBChapterInfo] {
readerContext.activeChapters
}
var activeBookmarks: [RDEPUBBookmark] {
get { readerContext.activeBookmarks }
set { readerContext.activeBookmarks = newValue }
}
var activeHighlights: [RDEPUBHighlight] {
get { readerContext.activeHighlights }
set { readerContext.activeHighlights = newValue }
}
lazy var topToolView = runtime.makeTopToolView()
lazy var bottomToolView = runtime.makeBottomToolView()
lazy var searchBarView = RDEPUBReaderSearchBarView()
/// 搜索栏是否当前可见
private(set) var isSearchBarVisible = false
var currentBookIdentifier: String? {
get { readerContext.currentBookIdentifier }
set { readerContext.currentBookIdentifier = newValue }
}
var textBookCache: RDEPUBTextBookCache { readerContext.textBookCache }
var currentBrightness: CGFloat {
get { readerContext.currentBrightness }
set { readerContext.currentBrightness = newValue }
}
var didStartInitialLoad: Bool {
get { readerContext.didStartInitialLoad }
set { readerContext.didStartInitialLoad = newValue }
}
var isRepaginating: Bool {
get { readerContext.isRepaginating }
set { readerContext.isRepaginating = newValue }
}
var lastTextPaginationPageSize: CGSize? {
get { readerContext.lastTextPaginationPageSize }
set { readerContext.lastTextPaginationPageSize = newValue }
}
var isReconcilingTextPaginationSize = false
var paginationToken: UUID {
get { readerContext.paginationToken }
set { readerContext.paginationToken = newValue }
}
var paginator: RDEPUBPaginator? {
get { readerContext.paginator }
set { readerContext.paginator = newValue }
}
var searchState: RDEPUBSearchState? {
get { readerContext.searchState }
set { readerContext.searchState = newValue }
}
var isExternalTextBook: Bool {
get { readerContext.isExternalTextBook }
set { readerContext.isExternalTextBook = newValue }
}
var textFileURL: URL? {
get { readerContext.textFileURL }
set { readerContext.textFileURL = newValue }
}
lazy var readerAssemblyCoordinator = RDEPUBReaderAssemblyCoordinator(context: readerContext)
lazy var runtime = RDEPUBReaderRuntime(context: readerContext)
// MARK: - 初始化
/// 使用 EPUB 文件 URL 初始化阅读器
/// 会自动从持久化存储中恢复用户的阅读偏好设置
/// - Parameters:
/// - epubURL: EPUB 文件的本地 URL
/// - configuration: 阅读器配置,默认使用 .default
/// - persistence: 持久化策略,默认使用 UserDefaults
public init(
epubURL: URL,
configuration: RDEPUBReaderConfiguration = .default,
persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence(),
dependencies: RDEPUBReaderDependencies = .live
) {
let persistedSettings = persistence?.loadReaderSettings()
self.epubURL = epubURL
self.configuration = persistedSettings?.applying(to: configuration) ?? configuration
self.persistence = persistence
self.dependencies = dependencies
let brightness = max(0, min(1, persistedSettings?.brightness ?? dependencies.environment.currentBrightness))
super.init(nibName: nil, bundle: nil)
// Sync context after super.init
readerContext.dependencies = dependencies
readerContext.configuration = self.configuration
readerContext.epubURL = epubURL
readerContext.persistence = persistence
self.currentBrightness = brightness
applyWebViewDebugPolicy()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func applyWebViewDebugPolicy() {
RDEPUBWebViewDebug.applyDebugPolicy(
inspectableEnabled: configuration.allowsInspectableWebViews,
verboseLoggingEnabled: configuration.enablesVerboseWebViewLogging
)
}
/// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程
/// 适用于 TXT 等纯文本文件,调用方需自行构建 TextBook
/// - Parameters:
/// - textBook: 已分页的 TextBook 实例
/// - bookIdentifier: 书籍唯一标识符,用于持久化
/// - title: 书籍标题
/// - textFileURL: 原始文本文件 URL,用于重建 TextBook
/// - configuration: 阅读器配置
/// - persistence: 持久化策略
public convenience init(
textBook: RDEPUBTextBook,
bookIdentifier: String,
title: String,
textFileURL: URL? = nil,
configuration: RDEPUBReaderConfiguration = .default,
persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence(),
dependencies: RDEPUBReaderDependencies = .live
) {
let persistedSettings = persistence?.loadReaderSettings()
let resolvedConfig = persistedSettings?.applying(to: configuration) ?? configuration
self.init(
epubURL: URL(string: "about:blank")!,
configuration: resolvedConfig,
persistence: persistence,
dependencies: dependencies
)
self.isExternalTextBook = true
self.textBook = textBook
self.textFileURL = textFileURL
self.currentBookIdentifier = bookIdentifier
self.title = title
self.didStartInitialLoad = true
}
public override func viewDidLoad() {
super.viewDidLoad()
currentBrightness = currentBrightness
readerAssemblyCoordinator.assembleInterface()
readerAssemblyCoordinator.finishExternalTextBookLaunchIfNeeded()
readerView.onToolViewVisibilityChanged = { [weak self] isVisible in
self?.handleToolViewVisibilityChanged(isVisible: isVisible)
}
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
navigationController?.interactivePopGestureRecognizer?.delegate = self
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController?.setNavigationBarHidden(false, animated: animated)
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startInitialLoadIfNeeded()
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
runtime.viewportMonitor.viewDidLayoutSubviews()
}
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
runtime.viewportMonitor.viewWillTransition(with: coordinator)
}
// MARK: - 搜索栏管理
/// 显示搜索栏。工具栏可见时立即添加到视图层级并滑入动画;否则仅标记状态,
/// 等待 `handleToolViewVisibilityChanged` 在工具栏显示时再安装。
func showSearchBar() {
guard !isSearchBarVisible else { return }
isSearchBarVisible = true
searchBarView.apply(theme: configuration.theme)
searchBarView.onSearchSubmit = { [weak self] keyword in
self?.searchBarView.showSearching()
self?.runtime.search(keyword: keyword)
self?.updateSearchCount()
}
searchBarView.onSearchTextChanged = { [weak self] keyword in
guard let self else { return }
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
if normalizedKeyword.isEmpty {
self.runtime.clearSearch()
} else {
self.searchBarView.showSearching()
self.runtime.search(keyword: normalizedKeyword)
}
self.updateSearchCount()
}
searchBarView.onSearchPrevious = { [weak self] in
_ = self?.runtime.searchPrevious()
self?.updateSearchCount()
}
searchBarView.onSearchNext = { [weak self] in
_ = self?.runtime.searchNext()
self?.updateSearchCount()
}
searchBarView.onSelectMatch = { [weak self] matchIndex in
guard let self else { return }
let didNavigate = self.runtime.selectSearchMatch(at: matchIndex)
self.updateSearchCount()
if didNavigate {
self.hideSearchBar(clearSearch: false)
}
}
searchBarView.onClose = { [weak self] in
self?.hideSearchBar(clearSearch: true)
}
if readerView.isShowToolView {
installSearchBarView()
}
}
/// 将搜索栏添加到 readerView 并执行滑入动画
private func installSearchBarView() {
guard searchBarView.superview == nil else { return }
searchBarView.apply(theme: configuration.theme)
readerView.addSubview(searchBarView)
readerView.searchBarView = searchBarView
let bottomAnchor = bottomToolView.superview == nil
? readerView.bottomAnchor
: bottomToolView.topAnchor
NSLayoutConstraint.activate([
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
searchBarView.alpha = 0
searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
UIView.animate(withDuration: 0.28) {
self.searchBarView.alpha = 1
self.searchBarView.presentedView.transform = .identity
}
if let keyword = searchState?.keyword, !keyword.isEmpty {
searchBarView.restoreKeyword(keyword)
updateSearchCount()
} else {
searchBarView.showNoResults()
}
DispatchQueue.main.async { [weak self] in
self?.searchBarView.textField.becomeFirstResponder()
}
}
/// 隐藏搜索栏,带动画滑出
/// - Parameter clearSearch: 是否同时清除搜索状态
func hideSearchBar(clearSearch: Bool = false) {
guard isSearchBarVisible else { return }
isSearchBarVisible = false
searchBarView.textField.resignFirstResponder()
UIView.animate(withDuration: 0.25, animations: {
self.searchBarView.alpha = 0
self.searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
}) { _ in
self.searchBarView.removeFromSuperview()
self.searchBarView.alpha = 1
self.searchBarView.presentedView.transform = .identity
self.readerView.searchBarView = nil
}
if clearSearch {
runtime.clearSearch()
}
}
/// 同步搜索栏匹配计数
func updateSearchCount() {
guard let searchState else {
searchBarView.showNoResults()
return
}
searchBarView.updateResults(
sections: searchResultSections(for: searchState),
keyword: searchState.keyword,
currentMatchIndex: searchState.currentMatchIndex
)
}
/// 当工具栏可见性变化时同步搜索栏(由 RDReaderView 回调调用)
func handleToolViewVisibilityChanged(isVisible: Bool) {
if isVisible {
if isSearchBarVisible {
installSearchBarView()
} else if searchState != nil {
showSearchBar()
}
} else {
if isSearchBarVisible {
hideSearchBar(clearSearch: false)
}
}
}
}
private extension RDEPUBReaderController {
func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] {
let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in
searchSectionTitle(for: entry.element)
})
let orderedTitles = searchState.matches.reduce(into: [String]()) { titles, match in
let title = searchSectionTitle(for: match)
if titles.last != title, titles.contains(title) == false {
titles.append(title)
}
}
return orderedTitles.compactMap { title in
guard let matches = groupedMatches[title] else { return nil }
let items = matches.map { offset, match in
RDEPUBReaderSearchSection.Item(
matchIndex: offset,
previewText: match.previewText,
isCurrent: offset == searchState.currentMatchIndex
)
}
return RDEPUBReaderSearchSection(title: title, items: items)
}
}
func searchSectionTitle(for match: RDEPUBSearchMatch) -> String {
guard let publication else {
return match.href
}
let normalizedMatchHref = publication.resourceResolver.normalizedHref(match.href) ?? match.href
let tocItems = flattenedTableOfContentsItems(from: publication.tableOfContents, includePageNumbers: false)
if let tocItem = tocItems.last(where: {
let rawHref = $0.href.components(separatedBy: "#").first ?? $0.href
let normalizedItemHref = publication.resourceResolver.normalizedHref(rawHref) ?? rawHref
return normalizedItemHref == normalizedMatchHref
}) {
return tocItem.title
}
if let spineIndex = publication.resourceResolver.spineIndex(forNormalizedHref: normalizedMatchHref),
publication.spine.indices.contains(spineIndex) {
return publication.spine[spineIndex].title
}
return normalizedMatchHref
}
}