1. Configurable chapter window size (onDemandChapterWindowSize: 3-15) - Parameterized window radius in RDEPUBChapterRuntimeStore - Updated RDEPUBChapterWindowCoordinator to use configurable radius - RDEPUBChapterWindowSnapshot.from() accepts chapter array instead of fixed prev/next - Even numbers round up to odd (4→5), min 3, max 15 2. Configurable metadata parsing concurrency (metadataParsingConcurrency) - Default equals CPU core count - Parallel execution via OperationQueue in paginateMetadataOnly - Each worker creates independent builder instance - NSLock protects result aggregation 3. Per-chapter and total wall-clock timing instrumentation - Separated render vs I/O timing per chapter - Summary log with wallClockMs, renderTotalMs, writeTotalMs, avgRenderMs - Timing stored in RDEPUBReaderContext for test access 4. UI automation test infrastructure - Added --demo-window-size, --demo-concurrency, --demo-clear-cache launch args - DemoReaderState exposes windowSize, parseMs, parseConcurrency - ConfigurableWindowTests: 5 test cases for window size 3/5/15 - ConcurrentParsingTests: 4 test cases for concurrency 2/4 - MetadataParseBenchmarkTests: serial vs parallel benchmark 5. Bug fixes - Fixed page snap-back during background parsing (isUserInteracting check) - Reduced BookPageMap refresh frequency from 16 to 32 chapters - Moved waitForReadingInteractionToSettle outside operation loop 6. Design doc: dual-layer PageMap (estimated + precise mixed)
460 lines
19 KiB
Swift
460 lines
19 KiB
Swift
//
|
||
// RDURLReaderController.swift
|
||
// RDReaderDemo
|
||
//
|
||
// 文件职责:URL 阅读器入口控制器,根据文件类型自动选择阅读器。
|
||
// 该文件是 ReadViewSDK 的最简使用入口:
|
||
// - .epub 文件:直接创建 RDEPUBReaderController
|
||
// - 其他文本文件:通过 RDPlainTextBookBuilder 分页后创建 RDEPUBReaderController
|
||
// - 分页失败时回退到纯文本 UITextView 展示
|
||
//
|
||
// 架构位置:RDReaderView 四层架构中第四层(读者 UI)的入口控制器。
|
||
//
|
||
|
||
import UIKit
|
||
|
||
/// URL 阅读器入口控制器
|
||
/// 接收一个书籍文件 URL,根据文件类型自动创建对应的阅读器控制器:
|
||
/// - EPUB 文件:直接使用 RDEPUBReaderController
|
||
/// - 纯文本文件:先通过 RDPlainTextBookBuilder 分页,再使用 RDEPUBReaderController
|
||
/// - 分页失败:回退到纯文本 UITextView 展示
|
||
///
|
||
/// 使用方式:
|
||
/// ```swift
|
||
/// let controller = RDURLReaderController(bookURL: fileURL)
|
||
/// navigationController?.pushViewController(controller, animated: true)
|
||
/// ```
|
||
public final class RDURLReaderController: UIViewController {
|
||
private struct PendingDemoPageRequest {
|
||
let pageNumber: Int
|
||
let animated: Bool
|
||
var attemptCount: Int
|
||
}
|
||
|
||
/// 书籍文件 URL
|
||
private let bookURL: URL
|
||
/// EPUB 阅读器配置(字体、行距、主题等)
|
||
private let epubConfiguration: RDEPUBReaderConfiguration
|
||
/// 内嵌的阅读器控制器
|
||
private var embeddedController: UIViewController?
|
||
private let demoStateLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.accessibilityIdentifier = "demo.reader.state"
|
||
label.font = .systemFont(ofSize: 1)
|
||
label.textColor = .clear
|
||
label.alpha = 0.01
|
||
label.isAccessibilityElement = true
|
||
label.text = "reader=opening"
|
||
return label
|
||
}()
|
||
private var pendingDemoPageRequest: PendingDemoPageRequest?
|
||
private var isRetryingPendingDemoPage = false
|
||
private let maxPendingDemoPageAttempts = 24
|
||
private let pendingDemoPageRetryDelay: TimeInterval = 0.25
|
||
private var demoStateTimer: Timer?
|
||
private var lastEmittedDemoState = ""
|
||
|
||
/// 初始化方法
|
||
/// - Parameters:
|
||
/// - bookURL: 书籍文件的本地 URL
|
||
/// - epubConfiguration: EPUB 阅读器配置,默认使用标准配置
|
||
public init(
|
||
bookURL: URL,
|
||
epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration()
|
||
) {
|
||
self.bookURL = bookURL
|
||
self.epubConfiguration = epubConfiguration
|
||
super.init(nibName: nil, bundle: nil)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
/// 视图加载完成后,设置背景色、标题并嵌入阅读器控制器
|
||
public override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
view.backgroundColor = .systemBackground
|
||
title = bookURL.deletingPathExtension().lastPathComponent
|
||
embedReaderController()
|
||
installDemoStateLabel()
|
||
}
|
||
|
||
public override func viewDidAppear(_ animated: Bool) {
|
||
super.viewDidAppear(animated)
|
||
startDemoStateTimerIfNeeded()
|
||
refreshDemoState()
|
||
}
|
||
|
||
public override func viewDidDisappear(_ animated: Bool) {
|
||
super.viewDidDisappear(animated)
|
||
stopDemoStateTimer()
|
||
}
|
||
|
||
deinit {
|
||
stopDemoStateTimer()
|
||
}
|
||
|
||
/// 切换阅读器的翻页模式(Demo 用)
|
||
/// - Parameter displayType: 目标翻页模式
|
||
public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) {
|
||
readerController?.configuration.displayType = displayType
|
||
emitDemoState(prefix: "display=\(displayType.demoArgumentValue)")
|
||
}
|
||
|
||
/// 跳转到指定页码(Demo 用)
|
||
/// - Parameters:
|
||
/// - pageNumber: 目标页码
|
||
/// - animated: 是否动画过渡
|
||
/// - Returns: 是否成功跳转
|
||
@discardableResult
|
||
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
|
||
let moved = performDemoPageNavigation(pageNumber, animated: animated)
|
||
if moved {
|
||
pendingDemoPageRequest = nil
|
||
isRetryingPendingDemoPage = false
|
||
} else {
|
||
queuePendingDemoPageNavigation(pageNumber, animated: animated)
|
||
}
|
||
return moved
|
||
}
|
||
|
||
/// 执行翻页模式切换序列(Demo 用)
|
||
/// 按照指定顺序和延迟依次切换翻页模式
|
||
/// - Parameters:
|
||
/// - displayTypes: 翻页模式数组
|
||
/// - initialPageNumber: 可选的初始跳转页码
|
||
/// - stepDelay: 每步之间的延迟时间(秒)
|
||
public func runDemoDisplaySequence(
|
||
_ displayTypes: [RDReaderView.DisplayType],
|
||
initialPageNumber: Int? = nil,
|
||
stepDelay: TimeInterval = 1.0
|
||
) {
|
||
let normalizedDelay = max(stepDelay, 0.1)
|
||
if let initialPageNumber {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + normalizedDelay) { [weak self] in
|
||
_ = self?.goToDemoPage(initialPageNumber)
|
||
}
|
||
}
|
||
|
||
for (index, displayType) in displayTypes.enumerated() {
|
||
let delay = normalizedDelay * Double(index + 1 + (initialPageNumber == nil ? 0 : 1))
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||
self?.applyDemoDisplayType(displayType)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 嵌入阅读器控制器到当前视图层级
|
||
/// 根据文件类型选择合适的阅读器控制器,并通过 Child View Controller 方式嵌入
|
||
private func embedReaderController() {
|
||
let controller: UIViewController
|
||
if bookURL.pathExtension.lowercased() == "epub" {
|
||
controller = RDEPUBReaderController(
|
||
epubURL: bookURL,
|
||
configuration: epubConfiguration
|
||
)
|
||
} else {
|
||
let bookIdentifier = bookURL.lastPathComponent
|
||
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
|
||
let pageSize = currentTextPageSize()
|
||
let renderStyle = currentTextRenderStyle()
|
||
let builder = RDPlainTextBookBuilder(
|
||
layoutConfig: RDEPUBTextLayoutConfig(
|
||
frameWidth: pageSize.width,
|
||
frameHeight: pageSize.height,
|
||
edgeInsets: epubConfiguration.reflowableContentInsets,
|
||
numberOfColumns: 1,
|
||
columnGap: 20,
|
||
avoidOrphans: false,
|
||
avoidWidows: false,
|
||
avoidPageBreakInsideEnabled: true,
|
||
hyphenation: true,
|
||
imageMaxHeightRatio: 0.85
|
||
)
|
||
)
|
||
if let textBook = try? builder.build(textFileURL: bookURL, pageSize: pageSize, style: renderStyle) {
|
||
controller = RDEPUBReaderController(
|
||
textBook: textBook,
|
||
bookIdentifier: bookIdentifier,
|
||
title: bookTitle,
|
||
textFileURL: bookURL,
|
||
configuration: epubConfiguration
|
||
)
|
||
} else {
|
||
// 分页失败时回退到纯文本展示
|
||
let fallback = UIViewController()
|
||
let textView = UITextView()
|
||
textView.isEditable = false
|
||
textView.text = rd_decodeTextFile(url: bookURL)
|
||
fallback.view = textView
|
||
controller = fallback
|
||
}
|
||
}
|
||
embeddedController = controller
|
||
addChild(controller)
|
||
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||
view.addSubview(controller.view)
|
||
NSLayoutConstraint.activate([
|
||
controller.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||
controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||
controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||
])
|
||
controller.didMove(toParent: self)
|
||
readerController?.delegate = self
|
||
emitDemoState()
|
||
}
|
||
|
||
/// 获取内嵌的 EPUB 阅读器控制器(如果存在)
|
||
private var readerController: RDEPUBReaderController? {
|
||
embeddedController as? RDEPUBReaderController
|
||
}
|
||
|
||
@discardableResult
|
||
private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool {
|
||
guard let readerController else { return false }
|
||
guard pageNumber > 0 else { return false }
|
||
|
||
let knownPages = readerController.readerContext.bookPageMap?.totalPages
|
||
?? readerController.textBook?.pages.count
|
||
?? readerController.activePages.count
|
||
let numPages = readerController.readerView.numberOfPages()
|
||
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): knownPages=\(knownPages), readerView.numberOfPages=\(numPages), currentPage=\(readerController.readerView.currentPage)")
|
||
let moved = readerController.go(toPageNumber: pageNumber, animated: animated)
|
||
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): moved=\(moved), after currentPage=\(readerController.readerView.currentPage)")
|
||
if moved {
|
||
emitDemoState(prefix: "page=\(pageNumber)")
|
||
}
|
||
return moved
|
||
}
|
||
|
||
private func queuePendingDemoPageNavigation(_ pageNumber: Int, animated: Bool) {
|
||
if let pendingDemoPageRequest,
|
||
pendingDemoPageRequest.pageNumber == pageNumber,
|
||
pendingDemoPageRequest.animated == animated {
|
||
return
|
||
}
|
||
|
||
pendingDemoPageRequest = PendingDemoPageRequest(
|
||
pageNumber: pageNumber,
|
||
animated: animated,
|
||
attemptCount: 0
|
||
)
|
||
retryPendingDemoPageIfNeeded()
|
||
}
|
||
|
||
private func retryPendingDemoPageIfNeeded() {
|
||
guard !isRetryingPendingDemoPage else { return }
|
||
guard pendingDemoPageRequest != nil else { return }
|
||
|
||
isRetryingPendingDemoPage = true
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
|
||
self?.attemptPendingDemoPageNavigation()
|
||
}
|
||
}
|
||
|
||
private func attemptPendingDemoPageNavigation() {
|
||
guard var pendingDemoPageRequest else {
|
||
isRetryingPendingDemoPage = false
|
||
return
|
||
}
|
||
|
||
if performDemoPageNavigation(pendingDemoPageRequest.pageNumber, animated: pendingDemoPageRequest.animated) {
|
||
self.pendingDemoPageRequest = nil
|
||
isRetryingPendingDemoPage = false
|
||
return
|
||
}
|
||
|
||
pendingDemoPageRequest.attemptCount += 1
|
||
if pendingDemoPageRequest.attemptCount >= maxPendingDemoPageAttempts {
|
||
self.pendingDemoPageRequest = nil
|
||
isRetryingPendingDemoPage = false
|
||
print("[ReadViewDemo] automation page=\(pendingDemoPageRequest.pageNumber) failed after \(pendingDemoPageRequest.attemptCount) attempts")
|
||
return
|
||
}
|
||
|
||
self.pendingDemoPageRequest = pendingDemoPageRequest
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
|
||
self?.attemptPendingDemoPageNavigation()
|
||
}
|
||
}
|
||
|
||
/// 输出当前阅读器状态日志(Demo 调试用)
|
||
private func logDemoState(prefix: String) {
|
||
guard let readerController else { return }
|
||
let location = readerController.currentLocation
|
||
let href = location?.href ?? "nil"
|
||
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
|
||
let page = readerController.currentPageNumber.map(String.init) ?? "nil"
|
||
print("[ReadViewDemo] automation \(prefix) -> page \(page) href \(href) progression \(progression)")
|
||
}
|
||
|
||
private func installDemoStateLabel() {
|
||
view.addSubview(demoStateLabel)
|
||
demoStateLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
demoStateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
demoStateLabel.topAnchor.constraint(equalTo: view.topAnchor),
|
||
demoStateLabel.widthAnchor.constraint(equalToConstant: 1),
|
||
demoStateLabel.heightAnchor.constraint(equalToConstant: 1)
|
||
])
|
||
}
|
||
|
||
private func startDemoStateTimerIfNeeded() {
|
||
guard demoStateTimer == nil else { return }
|
||
demoStateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||
self?.refreshDemoState(logIfChanged: false)
|
||
}
|
||
if let demoStateTimer {
|
||
RunLoop.main.add(demoStateTimer, forMode: .common)
|
||
}
|
||
}
|
||
|
||
private func stopDemoStateTimer() {
|
||
demoStateTimer?.invalidate()
|
||
demoStateTimer = nil
|
||
}
|
||
|
||
private func emitDemoState(prefix: String? = nil) {
|
||
refreshDemoState(logPrefix: prefix, logIfChanged: true)
|
||
}
|
||
|
||
private func refreshDemoState(logPrefix: String? = nil, logIfChanged: Bool = true) {
|
||
let page = readerController?.currentPageNumber.map(String.init) ?? "nil"
|
||
let display = readerController?.configuration.displayType.demoArgumentValue ?? epubConfiguration.displayType.demoArgumentValue
|
||
let toolbar = readerController?.readerView.isShowToolView == true ? "visible" : "hidden"
|
||
let highlights = readerController?.highlights.count ?? 0
|
||
let selection = readerController?.currentSelection == nil ? 0 : 1
|
||
let location = readerController?.currentLocation
|
||
let href = encodedDemoLocationHref(location?.href)
|
||
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
|
||
let mapSnapshot = demoPaginationSnapshot()
|
||
let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
|
||
let state = [
|
||
"reader=opened",
|
||
"page=\(page)",
|
||
"display=\(display)",
|
||
"toolbar=\(toolbar)",
|
||
"highlights=\(highlights)",
|
||
"selection=\(selection)",
|
||
"href=\(href)",
|
||
"progression=\(progression)",
|
||
"mode=\(mapSnapshot.mode)",
|
||
"pagination=\(mapSnapshot.phase)",
|
||
"knownPages=\(mapSnapshot.knownPages)",
|
||
"knownChapters=\(mapSnapshot.knownChapters)",
|
||
"buildableChapters=\(mapSnapshot.buildableChapters)",
|
||
"avoidWidows=\(layoutConfig?.avoidWidows == true ? 1 : 0)",
|
||
"avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)",
|
||
"windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)",
|
||
"parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)",
|
||
"parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)"
|
||
].joined(separator: " ")
|
||
demoStateLabel.text = state
|
||
if let logPrefix {
|
||
logDemoState(prefix: logPrefix)
|
||
} else if logIfChanged, state != lastEmittedDemoState {
|
||
print("[ReadViewDemo] automation \(state)")
|
||
}
|
||
lastEmittedDemoState = state
|
||
}
|
||
|
||
private func encodedDemoLocationHref(_ href: String?) -> String {
|
||
guard let href, !href.isEmpty else { return "nil" }
|
||
return href.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? href.replacingOccurrences(of: " ", with: "%20")
|
||
}
|
||
|
||
private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
|
||
guard let readerController else {
|
||
return ("unavailable", "none", 0, 0, 0)
|
||
}
|
||
|
||
if let bookPageMap = readerController.readerContext.bookPageMap {
|
||
let buildableChapters = readerController.publication?.spine.filter {
|
||
$0.linear && ($0.mediaType.contains("html") || $0.mediaType.contains("xhtml"))
|
||
}.count ?? 0
|
||
let phase = buildableChapters > 0 && bookPageMap.totalChapters >= buildableChapters ? "full" : "partial"
|
||
return ("bookPageMap", phase, bookPageMap.totalPages, bookPageMap.totalChapters, buildableChapters)
|
||
}
|
||
|
||
if let textBook = readerController.textBook {
|
||
return ("textBook", "full", textBook.pages.count, textBook.chapters.count, textBook.chapters.count)
|
||
}
|
||
|
||
let snapshotChapters = readerController.activeChapters.count
|
||
let snapshotPages = readerController.activePages.count
|
||
return ("snapshot", snapshotPages > 0 ? "full" : "none", snapshotPages, snapshotChapters, snapshotChapters)
|
||
}
|
||
|
||
/// 计算文本分页的页面尺寸。
|
||
/// 分页器使用完整 viewport,真实可绘制区域由 layoutConfig.edgeInsets 控制,
|
||
/// 以保持与页面展示时的内容几何一致。
|
||
private func currentTextPageSize() -> CGSize {
|
||
UIScreen.main.bounds.size
|
||
}
|
||
|
||
/// 根据当前配置生成文本渲染样式
|
||
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||
let font = epubConfiguration.fontChoice.font(ofSize: epubConfiguration.fontSize)
|
||
let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4)
|
||
return RDEPUBTextRenderStyle(
|
||
font: font,
|
||
lineSpacing: lineSpacing,
|
||
textColor: epubConfiguration.theme.contentTextColor,
|
||
backgroundColor: epubConfiguration.theme.contentBackgroundColor
|
||
)
|
||
}
|
||
|
||
/// 解码文本文件内容
|
||
/// 尝试 UTF-8 编码,失败则尝试 GBK 和 GB2312 编码
|
||
private func rd_decodeTextFile(url: URL) -> String {
|
||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||
return content
|
||
}
|
||
if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String {
|
||
return content
|
||
}
|
||
if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String {
|
||
return content
|
||
}
|
||
return ""
|
||
}
|
||
}
|
||
|
||
extension RDURLReaderController: RDEPUBReaderDelegate {
|
||
public func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {
|
||
retryPendingDemoPageIfNeeded()
|
||
emitDemoState()
|
||
}
|
||
|
||
public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {
|
||
retryPendingDemoPageIfNeeded()
|
||
emitDemoState()
|
||
}
|
||
|
||
public func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) {
|
||
emitDemoState(prefix: "selection=\(selection == nil ? 0 : 1)")
|
||
}
|
||
|
||
public func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) {
|
||
emitDemoState(prefix: "highlights=\(highlights.count)")
|
||
}
|
||
}
|
||
|
||
/// 翻页模式扩展:提供 Demo 命令行参数值
|
||
private extension RDReaderView.DisplayType {
|
||
/// 返回翻页模式对应的 Demo 命令行参数字符串
|
||
var demoArgumentValue: String {
|
||
switch self {
|
||
case .pageCurl:
|
||
return "pageCurl"
|
||
case .horizontalScroll:
|
||
return "horizontalScroll"
|
||
case .verticalScroll:
|
||
return "verticalScroll"
|
||
}
|
||
}
|
||
}
|