feat: add in-reader search and restructure documentation
- Add RDEPUBReaderSearchBarView with animated show/hide, keyword navigation, and match counting integrated into the reader controller - Restructure docs: replace scattered design docs with consolidated BUSINESS_LOGIC.md and UML_CLASS_DIAGRAMS.md; update ARCHITECTURE.md - Add SearchTests and FanrenParseTimeTest; enhance LargeBookOnDemandTests - Add scripts/run_ui_regression.sh and summarize_ui_results.py for automated UI test execution and reporting Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d20196ee34
commit
0e7c0577e3
@@ -111,6 +111,10 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
readerContext.markUserNavigationActivity()
|
||||
updateCurrentSelection(nil)
|
||||
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
||||
|
||||
// 用户开始导航时,应用后台解析完成的完整 map
|
||||
runtime.applyPendingFullPageMapIfNeeded()
|
||||
|
||||
if readerContext.bookPageMap != nil {
|
||||
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
|
||||
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: pageNum + 1)
|
||||
|
||||
@@ -155,6 +155,9 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
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 }
|
||||
@@ -273,6 +276,9 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
currentBrightness = currentBrightness
|
||||
readerAssemblyCoordinator.assembleInterface()
|
||||
readerAssemblyCoordinator.finishExternalTextBookLaunchIfNeeded()
|
||||
readerView.onToolViewVisibilityChanged = { [weak self] isVisible in
|
||||
self?.handleToolViewVisibilityChanged(isVisible: isVisible)
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
@@ -302,4 +308,100 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
runtime.viewportMonitor.viewWillTransition(with: coordinator)
|
||||
}
|
||||
|
||||
// MARK: - 搜索栏管理
|
||||
|
||||
/// 显示搜索栏,将其添加到 readerView 并滑入动画
|
||||
func showSearchBar() {
|
||||
guard !isSearchBarVisible else { return }
|
||||
isSearchBarVisible = true
|
||||
searchBarView.apply(theme: configuration.theme)
|
||||
|
||||
readerView.addSubview(searchBarView)
|
||||
readerView.searchBarView = searchBarView
|
||||
let topToolbarHeight: CGFloat = readerView.safeAreaInsets.top + 52
|
||||
NSLayoutConstraint.activate([
|
||||
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
||||
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
||||
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor, constant: topToolbarHeight),
|
||||
searchBarView.heightAnchor.constraint(equalToConstant: 52)
|
||||
])
|
||||
|
||||
searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
self.searchBarView.transform = .identity
|
||||
}
|
||||
|
||||
searchBarView.onSearchSubmit = { [weak self] keyword in
|
||||
self?.runtime.search(keyword: keyword)
|
||||
self?.updateSearchCount()
|
||||
}
|
||||
searchBarView.onSearchPrevious = { [weak self] in
|
||||
_ = self?.runtime.searchPrevious()
|
||||
self?.updateSearchCount()
|
||||
}
|
||||
searchBarView.onSearchNext = { [weak self] in
|
||||
_ = self?.runtime.searchNext()
|
||||
self?.updateSearchCount()
|
||||
}
|
||||
searchBarView.onClose = { [weak self] in
|
||||
self?.hideSearchBar(clearSearch: true)
|
||||
}
|
||||
|
||||
if let keyword = searchState?.keyword, !keyword.isEmpty {
|
||||
searchBarView.restoreKeyword(keyword)
|
||||
updateSearchCount()
|
||||
}
|
||||
|
||||
// 延迟到下一帧确保视图已布局后再获取焦点
|
||||
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.3, animations: {
|
||||
self.searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
|
||||
}) { _ in
|
||||
self.searchBarView.removeFromSuperview()
|
||||
self.searchBarView.transform = .identity
|
||||
self.readerView.searchBarView = nil
|
||||
}
|
||||
|
||||
if clearSearch {
|
||||
runtime.clearSearch()
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步搜索栏匹配计数
|
||||
private func updateSearchCount() {
|
||||
guard let searchState else {
|
||||
searchBarView.showNoResults()
|
||||
return
|
||||
}
|
||||
if let index = searchState.currentMatchIndex {
|
||||
searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
|
||||
} else if searchState.matches.isEmpty {
|
||||
searchBarView.showNoResults()
|
||||
}
|
||||
}
|
||||
|
||||
/// 当工具栏可见性变化时同步搜索栏(由 RDReaderView 回调调用)
|
||||
func handleToolViewVisibilityChanged(isVisible: Bool) {
|
||||
if isVisible {
|
||||
if searchState != nil && !isSearchBarVisible {
|
||||
showSearchBar()
|
||||
}
|
||||
} else {
|
||||
if isSearchBarVisible {
|
||||
hideSearchBar(clearSearch: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 搜索栏
|
||||
|
||||
/// 阅读器搜索栏视图
|
||||
/// 提供搜索输入、上一个/下一个匹配导航、匹配计数和关闭功能
|
||||
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
// MARK: 回调闭包
|
||||
|
||||
/// 提交搜索关键词回调
|
||||
var onSearchSubmit: ((String) -> Void)?
|
||||
/// 点击上一个匹配回调
|
||||
var onSearchPrevious: (() -> Void)?
|
||||
/// 点击下一个匹配回调
|
||||
var onSearchNext: (() -> Void)?
|
||||
/// 关闭搜索回调
|
||||
var onClose: (() -> Void)?
|
||||
|
||||
// MARK: UI 组件
|
||||
|
||||
private let containerView: UIView = {
|
||||
let view = UIView()
|
||||
view.layer.cornerRadius = 8
|
||||
view.layer.masksToBounds = true
|
||||
view.isAccessibilityElement = false
|
||||
view.accessibilityElementsHidden = false
|
||||
return view
|
||||
}()
|
||||
|
||||
private let searchIcon: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
|
||||
if #available(iOS 13.0, *) {
|
||||
imageView.image = UIImage(systemName: "magnifyingglass")
|
||||
}
|
||||
imageView.tintColor = .gray
|
||||
return imageView
|
||||
}()
|
||||
|
||||
let textField: UITextField = {
|
||||
let field = UITextField()
|
||||
field.placeholder = "搜索..."
|
||||
field.font = UIFont.systemFont(ofSize: 15)
|
||||
field.returnKeyType = .search
|
||||
field.autocorrectionType = .no
|
||||
field.autocapitalizationType = .none
|
||||
field.clearButtonMode = .whileEditing
|
||||
field.isAccessibilityElement = true
|
||||
if #available(iOS 13.0, *) {
|
||||
field.accessibilityTraits = .searchField
|
||||
}
|
||||
return field
|
||||
}()
|
||||
|
||||
private let previousButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let nextButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
private let countLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13, weight: .medium)
|
||||
label.textAlignment = .center
|
||||
label.setContentHuggingPriority(.required, for: .horizontal)
|
||||
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
return label
|
||||
}()
|
||||
|
||||
private let closeButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
// MARK: 布局常量
|
||||
|
||||
private let horizontalInset: CGFloat = 12
|
||||
private let spacing: CGFloat = 6
|
||||
private let containerHeight: CGFloat = 36
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.search.bar"
|
||||
shouldGroupAccessibilityChildren = false
|
||||
isAccessibilityElement = false
|
||||
setupSubviews()
|
||||
setupConstraints()
|
||||
setupActions()
|
||||
updateNavigationEnabled(false)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: 布局
|
||||
|
||||
override func lineFrame(in bounds: CGRect) -> CGRect {
|
||||
CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5)
|
||||
}
|
||||
|
||||
override func apply(theme: RDEPUBReaderTheme) {
|
||||
super.apply(theme: theme)
|
||||
backgroundColor = theme.toolBackgroundColor
|
||||
containerView.backgroundColor = theme.toolControlBorderUnselectColor
|
||||
searchIcon.tintColor = theme.toolControlTextColor
|
||||
textField.textColor = theme.toolControlTextColor
|
||||
textField.attributedPlaceholder = NSAttributedString(
|
||||
string: "搜索...",
|
||||
attributes: [.foregroundColor: theme.toolControlTextColor.withAlphaComponent(0.5)]
|
||||
)
|
||||
countLabel.textColor = theme.toolControlTextColor
|
||||
previousButton.tintColor = theme.toolControlTextColor
|
||||
nextButton.tintColor = theme.toolControlTextColor
|
||||
closeButton.tintColor = theme.toolControlTextColor
|
||||
}
|
||||
|
||||
// MARK: 公开方法
|
||||
|
||||
/// 更新匹配计数显示
|
||||
func updateMatchCount(current: Int, total: Int) {
|
||||
countLabel.text = "\(current)/\(total)"
|
||||
updateNavigationEnabled(total > 0)
|
||||
}
|
||||
|
||||
/// 显示无结果状态
|
||||
func showNoResults() {
|
||||
countLabel.text = "0/0"
|
||||
updateNavigationEnabled(false)
|
||||
}
|
||||
|
||||
/// 显示搜索中状态
|
||||
func showSearching() {
|
||||
countLabel.text = "搜索中..."
|
||||
updateNavigationEnabled(false)
|
||||
}
|
||||
|
||||
/// 恢复已有的搜索关键词(搜索栏重新显示时)
|
||||
func restoreKeyword(_ keyword: String) {
|
||||
textField.text = keyword
|
||||
}
|
||||
|
||||
// MARK: 私有方法
|
||||
|
||||
private func setupSubviews() {
|
||||
addSubview(containerView)
|
||||
containerView.addSubview(searchIcon)
|
||||
containerView.addSubview(textField)
|
||||
addSubview(previousButton)
|
||||
addSubview(nextButton)
|
||||
addSubview(countLabel)
|
||||
addSubview(closeButton)
|
||||
|
||||
if #available(iOS 13.0, *) {
|
||||
previousButton.setImage(UIImage(systemName: "chevron.up")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
nextButton.setImage(UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
closeButton.setImage(UIImage(systemName: "xmark")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
} else {
|
||||
previousButton.setTitle("▲", for: .normal)
|
||||
nextButton.setTitle("▼", for: .normal)
|
||||
closeButton.setTitle("✕", for: .normal)
|
||||
}
|
||||
|
||||
previousButton.accessibilityIdentifier = "epub.reader.search.previous"
|
||||
nextButton.accessibilityIdentifier = "epub.reader.search.next"
|
||||
closeButton.accessibilityIdentifier = "epub.reader.search.close"
|
||||
countLabel.accessibilityIdentifier = "epub.reader.search.count"
|
||||
textField.accessibilityIdentifier = "epub.reader.search.field"
|
||||
|
||||
[previousButton, nextButton, closeButton].forEach { button in
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||||
button.tintColor = .black
|
||||
button.setTitleColor(.black, for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
[containerView, searchIcon, textField, previousButton, nextButton, countLabel, closeButton].forEach {
|
||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
// 容器(搜索输入区域)
|
||||
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
|
||||
containerView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
containerView.heightAnchor.constraint(equalToConstant: containerHeight),
|
||||
|
||||
// 搜索图标
|
||||
searchIcon.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10),
|
||||
searchIcon.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||
searchIcon.widthAnchor.constraint(equalToConstant: 16),
|
||||
|
||||
// 输入框
|
||||
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 6),
|
||||
textField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
|
||||
textField.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||
textField.heightAnchor.constraint(equalToConstant: containerHeight - 4),
|
||||
|
||||
// 上一个按钮
|
||||
previousButton.leadingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: spacing),
|
||||
previousButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
previousButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
previousButton.heightAnchor.constraint(equalToConstant: 32),
|
||||
|
||||
// 下一个按钮
|
||||
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor, constant: spacing),
|
||||
nextButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
nextButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
nextButton.heightAnchor.constraint(equalToConstant: 32),
|
||||
|
||||
// 计数标签
|
||||
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor, constant: spacing),
|
||||
countLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
countLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
||||
|
||||
// 关闭按钮
|
||||
closeButton.leadingAnchor.constraint(equalTo: countLabel.trailingAnchor, constant: spacing),
|
||||
closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
|
||||
closeButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
closeButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
closeButton.heightAnchor.constraint(equalToConstant: 32)
|
||||
])
|
||||
}
|
||||
|
||||
private func setupActions() {
|
||||
textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
|
||||
previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
|
||||
nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
|
||||
closeButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private func updateNavigationEnabled(_ enabled: Bool) {
|
||||
previousButton.isEnabled = enabled
|
||||
previousButton.alpha = enabled ? 1 : 0.45
|
||||
nextButton.isEnabled = enabled
|
||||
nextButton.alpha = enabled ? 1 : 0.45
|
||||
}
|
||||
|
||||
@objc private func textFieldDidReturn() {
|
||||
guard let keyword = textField.text, !keyword.isEmpty else { return }
|
||||
onSearchSubmit?(keyword)
|
||||
textField.resignFirstResponder()
|
||||
}
|
||||
|
||||
@objc private func previousAction() {
|
||||
onSearchPrevious?()
|
||||
}
|
||||
|
||||
@objc private func nextAction() {
|
||||
onSearchNext?()
|
||||
}
|
||||
|
||||
@objc private func closeAction() {
|
||||
onClose?()
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,11 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
var onBack: (() -> Void)?
|
||||
/// 书签按钮点击回调
|
||||
var onToggleBookmark: (() -> Void)?
|
||||
/// 搜索按钮点击回调
|
||||
var onSearch: (() -> Void)?
|
||||
|
||||
private let backButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let searchButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let bookmarkButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let titleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
@@ -27,14 +30,17 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
accessibilityIdentifier = "epub.reader.topToolbar"
|
||||
self.backgroundColor = .white
|
||||
addSubview(backButton)
|
||||
addSubview(searchButton)
|
||||
addSubview(bookmarkButton)
|
||||
addSubview(titleLabel)
|
||||
|
||||
backButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
searchButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
bookmarkButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside)
|
||||
searchButton.addTarget(self, action: #selector(searchAction), for: .touchUpInside)
|
||||
bookmarkButton.addTarget(self, action: #selector(bookmarkAction), for: .touchUpInside)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
@@ -50,17 +56,26 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
bookmarkButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
bookmarkButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
|
||||
searchButton.trailingAnchor.constraint(equalTo: bookmarkButton.leadingAnchor, constant: -4),
|
||||
searchButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4),
|
||||
searchButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
|
||||
searchButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
searchButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
|
||||
titleLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8),
|
||||
titleLabel.trailingAnchor.constraint(equalTo: bookmarkButton.leadingAnchor, constant: -8),
|
||||
titleLabel.trailingAnchor.constraint(equalTo: searchButton.leadingAnchor, constant: -8),
|
||||
titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor)
|
||||
])
|
||||
|
||||
if #available(iOS 13.0, *) {
|
||||
backButton.setImage(UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
searchButton.setImage(UIImage(systemName: "magnifyingglass")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
} else {
|
||||
backButton.setTitle("返回", for: .normal)
|
||||
searchButton.setTitle("搜索", for: .normal)
|
||||
}
|
||||
backButton.accessibilityIdentifier = "epub.reader.back"
|
||||
searchButton.accessibilityIdentifier = "epub.reader.search"
|
||||
bookmarkButton.accessibilityIdentifier = "epub.reader.bookmark"
|
||||
titleLabel.accessibilityIdentifier = "epub.reader.title"
|
||||
updateBookmarkButtonAppearance()
|
||||
@@ -76,12 +91,14 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
|
||||
override public func apply(theme: RDEPUBReaderTheme) {
|
||||
super.apply(theme: theme)
|
||||
titleLabel.textColor = .black
|
||||
backButton.tintColor = .black
|
||||
bookmarkButton.tintColor = .black
|
||||
titleLabel.textColor = theme.toolControlTextColor
|
||||
backButton.tintColor = theme.toolControlTextColor
|
||||
searchButton.tintColor = theme.toolControlTextColor
|
||||
bookmarkButton.tintColor = theme.toolControlTextColor
|
||||
if #unavailable(iOS 13.0) {
|
||||
backButton.setTitleColor(.black, for: .normal)
|
||||
bookmarkButton.setTitleColor(.black, for: .normal)
|
||||
backButton.setTitleColor(theme.toolControlTextColor, for: .normal)
|
||||
searchButton.setTitleColor(theme.toolControlTextColor, for: .normal)
|
||||
bookmarkButton.setTitleColor(theme.toolControlTextColor, for: .normal)
|
||||
}
|
||||
updateBookmarkButtonAppearance()
|
||||
}
|
||||
@@ -104,6 +121,10 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
onBack?()
|
||||
}
|
||||
|
||||
@objc private func searchAction() {
|
||||
onSearch?()
|
||||
}
|
||||
|
||||
@objc private func bookmarkAction() {
|
||||
onToggleBookmark?()
|
||||
}
|
||||
|
||||
@@ -146,6 +146,19 @@ public final class RDURLReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行搜索(Demo 用)
|
||||
/// 显示搜索栏并提交关键词
|
||||
/// - Parameter keyword: 搜索关键词
|
||||
public func performDemoSearch(keyword: String) {
|
||||
guard let readerController else { return }
|
||||
readerController.showSearchBar()
|
||||
// 等搜索栏动画完成后提交搜索
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
readerController.search(keyword: keyword)
|
||||
readerController.updateSearchCount()
|
||||
}
|
||||
}
|
||||
|
||||
/// 嵌入阅读器控制器到当前视图层级
|
||||
/// 根据文件类型选择合适的阅读器控制器,并通过 Child View Controller 方式嵌入
|
||||
private func embedReaderController() {
|
||||
|
||||
@@ -19,12 +19,15 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
context.controller
|
||||
}
|
||||
|
||||
/// 创建顶部工具栏视图,绑定返回和书签切换回调。
|
||||
/// 创建顶部工具栏视图,绑定返回、搜索和书签切换回调。
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
let toolView = RDEPUBReaderTopToolView()
|
||||
toolView.onBack = { [weak self] in
|
||||
self?.handleBackAction()
|
||||
}
|
||||
toolView.onSearch = { [weak self] in
|
||||
self?.toggleSearchBar()
|
||||
}
|
||||
toolView.onToggleBookmark = { [weak self] in
|
||||
_ = self?.context.runtime?.toggleBookmark()
|
||||
}
|
||||
@@ -76,6 +79,7 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty
|
||||
)
|
||||
controller.updateBookmarkChrome()
|
||||
updateSearchBar()
|
||||
}
|
||||
|
||||
/// 弹出阅读设置面板(字号、字体、行距、分栏、主题、亮度等)。
|
||||
@@ -140,6 +144,29 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
/// 切换搜索栏的显示/隐藏状态。
|
||||
func toggleSearchBar() {
|
||||
guard let controller else { return }
|
||||
if controller.isSearchBarVisible {
|
||||
controller.hideSearchBar()
|
||||
} else {
|
||||
controller.showSearchBar()
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步搜索栏的主题和匹配计数。
|
||||
func updateSearchBar() {
|
||||
guard let controller else { return }
|
||||
controller.searchBarView.apply(theme: controller.configuration.theme)
|
||||
if let searchState = controller.searchState {
|
||||
if let index = searchState.currentMatchIndex {
|
||||
controller.searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
|
||||
} else if searchState.matches.isEmpty {
|
||||
controller.searchBarView.showNoResults()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理返回按钮点击,自动判断 pop 或 dismiss 方式关闭阅读器。
|
||||
func handleBackAction() {
|
||||
guard let controller else { return }
|
||||
|
||||
@@ -50,6 +50,9 @@ final class RDEPUBReaderContext {
|
||||
var paginator: RDEPUBPaginator?
|
||||
/// 全文搜索状态。
|
||||
var searchState: RDEPUBSearchState?
|
||||
/// 后台解析完成的完整 BookPageMap,等待用户下次导航时应用。
|
||||
/// 避免后台解析完成时直接替换 map 导致当前阅读位置跳转。
|
||||
var pendingFullPageMap: RDEPUBBookPageMap?
|
||||
/// 上次文本分页时的页面尺寸,用于检测是否需要重新分页。
|
||||
var lastTextPaginationPageSize: CGSize?
|
||||
/// 后台元数据解析耗时(毫秒),仅包含 OperationQueue 并行阶段。
|
||||
@@ -205,18 +208,6 @@ final class RDEPUBReaderContext {
|
||||
}
|
||||
|
||||
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||||
let style = currentTextRenderStyle()
|
||||
let pageSize = currentTextPageSize()
|
||||
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||||
let renderSignature = [
|
||||
style.font.fontName,
|
||||
"\(style.font.pointSize)",
|
||||
"\(configuration.lineHeightMultiple)",
|
||||
"\(style.lineSpacing)",
|
||||
layoutConfig.cacheSignature,
|
||||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||||
].joined(separator: "|")
|
||||
|
||||
let contentHash: String
|
||||
if let parser,
|
||||
let publication,
|
||||
@@ -226,15 +217,53 @@ final class RDEPUBReaderContext {
|
||||
} else {
|
||||
contentHash = ""
|
||||
}
|
||||
return chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: contentHash,
|
||||
renderSignature: currentRenderSignature()
|
||||
)
|
||||
}
|
||||
|
||||
return RDEPUBChapterCacheKey(
|
||||
/// 使用预计算的 contentHash 构建缓存键,避免重复读取 HTML 和计算 SHA-256。
|
||||
/// 后台批量解析必须走此版本。
|
||||
func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey {
|
||||
chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: precomputedContentHash,
|
||||
renderSignature: currentRenderSignature()
|
||||
)
|
||||
}
|
||||
|
||||
/// 使用固定的渲染签名与预计算 contentHash 构建缓存键。
|
||||
/// 适合后台任务在启动时冻结分页参数后复用,避免 live context 漂移。
|
||||
func chapterCacheKey(
|
||||
forSpineIndex spineIndex: Int,
|
||||
precomputedContentHash: String,
|
||||
renderSignature: String
|
||||
) -> RDEPUBChapterCacheKey {
|
||||
RDEPUBChapterCacheKey(
|
||||
bookID: currentBookIdentifier ?? "",
|
||||
spineIndex: spineIndex,
|
||||
renderSignature: renderSignature,
|
||||
chapterContentHash: contentHash
|
||||
chapterContentHash: precomputedContentHash
|
||||
)
|
||||
}
|
||||
|
||||
/// 当前渲染参数签名,所有章节共享同一值。
|
||||
func currentRenderSignature() -> String {
|
||||
let style = currentTextRenderStyle()
|
||||
let pageSize = currentTextPageSize()
|
||||
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||||
return [
|
||||
style.font.fontName,
|
||||
"\(style.font.pointSize)",
|
||||
"\(configuration.lineHeightMultiple)",
|
||||
"\(style.lineSpacing)",
|
||||
layoutConfig.cacheSignature,
|
||||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||||
].joined(separator: "|")
|
||||
}
|
||||
|
||||
func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? {
|
||||
runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex))
|
||||
}
|
||||
|
||||
+63
-11
@@ -10,6 +10,8 @@ import Foundation
|
||||
/// - 重建外部纯文本图书
|
||||
final class RDEPUBReaderPaginationCoordinator {
|
||||
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
||||
/// 每 N 章刷新一次 pageMap,可通过修改此值实测调优。
|
||||
static var pageMapRefreshInterval: Int = 32
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@@ -80,6 +82,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
guard let controller = context.controller else { return }
|
||||
context.textBook = textBook
|
||||
context.bookPageMap = nil
|
||||
context.pendingFullPageMap = nil
|
||||
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
@@ -99,6 +102,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
guard context.controller != nil else { return }
|
||||
context.textBook = nil
|
||||
context.bookPageMap = nil
|
||||
context.pendingFullPageMap = nil
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
guard !snapshot.pages.isEmpty else {
|
||||
@@ -388,6 +392,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
let pageSize = context.currentTextPageSize()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let style = context.currentTextRenderStyle()
|
||||
let renderSignature = context.currentRenderSignature()
|
||||
let allBuildableIndices = allBuildableSpineIndices(in: publication)
|
||||
let summaryDiskCache = context.runtime?.summaryDiskCache
|
||||
let workerCount = max(1, context.configuration.metadataParsingConcurrency)
|
||||
@@ -397,10 +402,30 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
guard let self else { return }
|
||||
guard context.controller != nil else { return }
|
||||
|
||||
// 预计算所有章节的 contentHash,避免后续重复读盘 + SHA-256
|
||||
let prewarmStart = CFAbsoluteTimeGetCurrent()
|
||||
var contentHashBySpineIndex: [Int: String] = [:]
|
||||
for spineIndex in allBuildableIndices {
|
||||
guard let href = publication.spine.indices.contains(spineIndex)
|
||||
? publication.spine[spineIndex].href : nil,
|
||||
let html = parser.htmlString(forRelativePath: href) else {
|
||||
contentHashBySpineIndex[spineIndex] = ""
|
||||
continue
|
||||
}
|
||||
contentHashBySpineIndex[spineIndex] = html.sha256Hex
|
||||
}
|
||||
let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000)
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "prewarmHashMs=\(prewarmMs) chapters=\(allBuildableIndices.count)")
|
||||
|
||||
let catalog = allBuildableIndices.map { spineIndex in
|
||||
let item = publication.spine[spineIndex]
|
||||
return (
|
||||
key: context.chapterCacheKey(forSpineIndex: spineIndex),
|
||||
key: context.chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: contentHashBySpineIndex[spineIndex] ?? "",
|
||||
renderSignature: renderSignature
|
||||
),
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: item.title
|
||||
@@ -438,6 +463,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
let wallClockStart = CFAbsoluteTimeGetCurrent()
|
||||
var totalRenderMs: Double = 0
|
||||
var totalWriteMs: Double = 0
|
||||
var totalMergeMs: Double = 0
|
||||
var completedChapters = 0
|
||||
var failedChapters = 0
|
||||
let timingLock = NSLock()
|
||||
@@ -447,6 +473,8 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
queue.qualityOfService = .utility
|
||||
queue.maxConcurrentOperationCount = workerCount
|
||||
|
||||
let refreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
|
||||
|
||||
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
|
||||
queue.addOperation {
|
||||
guard context.controller != nil,
|
||||
@@ -471,7 +499,12 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
|
||||
|
||||
let chapter = result.chapter
|
||||
let cacheKey = context.chapterCacheKey(forSpineIndex: spineIndex)
|
||||
let precomputedHash = contentHashBySpineIndex[spineIndex] ?? ""
|
||||
let cacheKey = context.chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: precomputedHash,
|
||||
renderSignature: renderSignature
|
||||
)
|
||||
let summary = RDEPUBChapterSummary(
|
||||
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
@@ -500,17 +533,24 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
|
||||
guard let renderResult else { return }
|
||||
|
||||
var partialMap: RDEPUBBookPageMap?
|
||||
// 锁内只做写入和计数,快照数据后锁外构建 pageMap
|
||||
var snapshot: [Int: RDEPUBChapterSummary]?
|
||||
resultLock.lock()
|
||||
summariesBySpineIndex[spineIndex] = renderResult
|
||||
totalResolvedCount += 1
|
||||
if totalResolvedCount - lastAppliedCount >= 32 || totalResolvedCount == allBuildableIndices.count {
|
||||
if totalResolvedCount - lastAppliedCount >= refreshInterval || totalResolvedCount == allBuildableIndices.count {
|
||||
lastAppliedCount = totalResolvedCount
|
||||
partialMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
|
||||
snapshot = summariesBySpineIndex
|
||||
}
|
||||
resultLock.unlock()
|
||||
|
||||
if let partialMap {
|
||||
if let snapshot {
|
||||
let mergeStart = CFAbsoluteTimeGetCurrent()
|
||||
let partialMap = self.buildPageMap(from: catalog, summaries: snapshot)
|
||||
let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000
|
||||
timingLock.lock()
|
||||
totalMergeMs += mergeElapsed
|
||||
timingLock.unlock()
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
@@ -532,6 +572,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
timingLock.lock()
|
||||
let renderTotal = Int(totalRenderMs)
|
||||
let writeTotal = Int(totalWriteMs)
|
||||
let mergeTotal = Int(totalMergeMs)
|
||||
let rendered = completedChapters
|
||||
let failed = failedChapters
|
||||
timingLock.unlock()
|
||||
@@ -539,7 +580,8 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " +
|
||||
"renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
|
||||
"prewarmHashMs=\(prewarmMs) renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) " +
|
||||
"mergeTotalMs=\(mergeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
|
||||
)
|
||||
context.lastMetadataParseWallClockMs = wallClockMs
|
||||
context.lastMetadataParseConcurrency = workerCount
|
||||
@@ -550,10 +592,12 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
let finalMergeStart = CFAbsoluteTimeGetCurrent()
|
||||
let pageMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
|
||||
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages)"
|
||||
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
@@ -565,15 +609,23 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
|
||||
guard let summaryDiskCache = context.runtime?.summaryDiskCache else {
|
||||
guard let summaryDiskCache = context.runtime?.summaryDiskCache,
|
||||
let parser = context.parser else {
|
||||
return nil
|
||||
}
|
||||
let renderSignature = context.currentRenderSignature()
|
||||
let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in
|
||||
let item = publication.spine[spineIndex]
|
||||
let href = item.href
|
||||
let contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
|
||||
return (
|
||||
key: context.chapterCacheKey(forSpineIndex: spineIndex),
|
||||
key: context.chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: contentHash,
|
||||
renderSignature: renderSignature
|
||||
),
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
href: href,
|
||||
title: item.title
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ final class RDEPUBReaderRuntime {
|
||||
context.readingSession = nil
|
||||
context.textBook = nil
|
||||
context.bookPageMap = nil
|
||||
context.pendingFullPageMap = nil
|
||||
context.activeBookmarks = []
|
||||
context.activeHighlights = []
|
||||
context.searchState = nil
|
||||
@@ -312,31 +313,37 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
|
||||
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
guard let readerView = context.readerView,
|
||||
// 暂存完整 map,等用户下次导航时再应用,避免当前阅读位置跳转
|
||||
// 此时保留旧 map,用户看到的内容和页码完全不变
|
||||
context.pendingFullPageMap = bookPageMap
|
||||
}
|
||||
|
||||
/// 用户导航时检查并应用待处理的完整 BookPageMap
|
||||
func applyPendingFullPageMapIfNeeded() {
|
||||
guard let pendingMap = context.pendingFullPageMap,
|
||||
let readerView = context.readerView,
|
||||
let controller = context.controller else { return }
|
||||
|
||||
let currentPage = max(readerView.currentPage, 0)
|
||||
context.pendingFullPageMap = nil
|
||||
|
||||
// 保存当前位置(在旧 map 下解析)
|
||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||||
|
||||
// 替换 map 和快照
|
||||
context.textBook = nil
|
||||
context.bookPageMap = bookPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
context.bookPageMap = pendingMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: pendingMap))
|
||||
|
||||
// 仅刷新总页数,不重建页面内容(避免后台元数据解析期间刷新掉用户选区)
|
||||
readerView.reloadPageCountOnly()
|
||||
|
||||
// 用户正在选区或正在滑动翻页时跳过页面跳转,避免打断交互
|
||||
let cv = readerView.collectionView
|
||||
let isUserInteracting = cv.isTracking || cv.isDragging || cv.isDecelerating
|
||||
if context.currentSelection == nil, !isUserInteracting, bookPageMap.totalPages > 0 {
|
||||
let maxValidPage = max(bookPageMap.totalPages - 1, 0)
|
||||
if currentPage > maxValidPage {
|
||||
readerView.transitionToPage(pageNum: maxValidPage, animated: false)
|
||||
}
|
||||
}
|
||||
// 用位置在新 map 中重新解析正确的页码
|
||||
if let currentLocation {
|
||||
locationCoordinator.persist(location: currentLocation)
|
||||
} else if let resolvedLocation = controller.resolvedTextLocation(forPageNumber: currentPage + 1) {
|
||||
locationCoordinator.persist(location: resolvedLocation)
|
||||
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
|
||||
let newPage = max(0, newPageNumber - 1)
|
||||
readerView.reloadPageCountOnly()
|
||||
if newPage != readerView.currentPage {
|
||||
readerView.transitionToPage(pageNum: newPage, animated: false)
|
||||
}
|
||||
} else {
|
||||
readerView.reloadPageCountOnly()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,6 +537,7 @@ final class RDEPUBReaderRuntime {
|
||||
func clearOnDemandPageModeState() {
|
||||
chapterRuntimeStore.invalidateAllForSettingsChange()
|
||||
context.bookPageMap = nil
|
||||
context.pendingFullPageMap = nil
|
||||
}
|
||||
|
||||
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
|
||||
|
||||
Reference in New Issue
Block a user