Files
ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift
T
shenandshen d20196ee34 feat: configurable chapter window & parallel metadata parsing with benchmark
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)
2026-06-03 23:38:11 +08:00

191 lines
6.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import UIKit
final class RDEPUBChapterRuntimeStore {
// MARK: - 子缓存
/// 章节运行时主缓存(等价 WXRead chapterDataCache
private let chapterDataCache = RDEPUBChapterDataCache()
/// 轻量分页结构缓存(等价 WXRead pageCountCache
private let pageCountCache = RDEPUBPageCountCache()
/// 图片缓存(独立 NSCache,等价 WXRead imageCache
let imageCache = NSCache<NSString, UIImage>()
/// 串行加载队列(等价 WXRead com.weread.chapterload
/// QoS .userInitiated:用户主动跳章/开书属于前台交互,需尽快完成
let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated)
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
// MARK: - 窗口状态
/// 当前章 spineIndex
private(set) var currentSpineIndex: Int?
/// 当前窗口内的 spineIndex 集合(以当前章为中心,按配置半径展开)
private(set) var windowSpineIndices: [Int] = []
// MARK: - 请求通道(前台导航 vs 后台预取,语义独立,互不抢占)
/// 前台导航目标(用户主动跳章:目录/书签/搜索/翻章)
/// 仅保留最后一次目标,旧的排队请求可被取消
private var pendingNavigationTarget: Int?
private let navigationLock = NSLock()
/// 后台预取目标集合(±1 相邻章预取)
/// 预取不抢占前台导航通道,预取完成后仅刷新快照,不触发跳章
private var pendingPrefetchTargets: Set<Int> = []
private let prefetchLock = NSLock()
/// 是否有章节正在构建中
private(set) var isBuilding: Bool = false
private let buildingLock = NSLock()
// MARK: - 初始化
init() {
imageCache.countLimit = 50
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
}
func assertNotOnChapterLoadQueue() {
dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue))
}
// MARK: - 缓存查询(线程安全,通过 cache wrapper 的 lock 保护)
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? {
return chapterDataCache[spineIndex]
}
func pageCount(for key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
return pageCountCache[key]
}
// MARK: - 缓存插入
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
chapterDataCache[chapter.spineIndex] = chapter
}
func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) {
pageCountCache[key] = pc
}
// MARK: - 窗口管理
/// 设定当前章,自动计算按半径展开的窗口
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
currentSpineIndex = spineIndex
let radius = max(0, windowRadius)
let lowerBound = max(0, spineIndex - radius)
let upperBound = min(totalSpineCount - 1, spineIndex + radius)
guard lowerBound <= upperBound else {
windowSpineIndices = [spineIndex]
return
}
windowSpineIndices = Array(lowerBound...upperBound)
}
/// 返回窗口外、应该淘汰的 spineIndex
func evictableSpineIndices() -> [Int] {
let windowSet = Set(windowSpineIndices)
return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) }
}
// MARK: - 淘汰
func evict(spineIndex: Int) {
chapterDataCache.remove(spineIndex: spineIndex)
pageCountCache.remove(forSpineIndex: spineIndex)
}
func evictAllExceptCurrent() {
guard let current = currentSpineIndex else {
chapterDataCache.removeAll()
pageCountCache.removeAll()
return
}
let currentChapter = chapterDataCache[current]
chapterDataCache.removeAll()
if let ch = currentChapter {
chapterDataCache[current] = ch
}
// WXRead 语义:内存警告时 pageCountCache 全量清空
pageCountCache.removeAll()
}
// MARK: - 内存警告
func handleMemoryWarning() {
evictAllExceptCurrent()
imageCache.removeAllObjects()
}
// MARK: - 前台导航请求管理(§14.4 取消语义)
/// 注册前台导航目标(用户主动跳章时调用)
/// 仅保留最后一次目标,旧的排队请求可被取消
func setNavigationTarget(spineIndex: Int) {
navigationLock.lock()
pendingNavigationTarget = spineIndex
navigationLock.unlock()
}
/// 消费前台导航目标(章节构建完成后调用,检查是否有更新的目标)
func consumeNavigationTarget() -> Int? {
navigationLock.lock()
let target = pendingNavigationTarget
pendingNavigationTarget = nil
navigationLock.unlock()
return target
}
// MARK: - 后台预取请求管理
/// 注册后台预取目标(±1 相邻章预取时调用)
/// 预取不抢占前台导航通道
func addPrefetchTarget(_ spineIndex: Int) {
prefetchLock.lock()
pendingPrefetchTargets.insert(spineIndex)
prefetchLock.unlock()
}
/// 标记预取目标已完成
func removePrefetchTarget(_ spineIndex: Int) {
prefetchLock.lock()
pendingPrefetchTargets.remove(spineIndex)
prefetchLock.unlock()
}
/// 清空所有预取目标(切章时调用,旧预取结果不再需要)
func clearPrefetchTargets() {
prefetchLock.lock()
pendingPrefetchTargets.removeAll()
prefetchLock.unlock()
}
/// 检查是否有待处理的预取目标
func hasPrefetchTarget(_ spineIndex: Int) -> Bool {
prefetchLock.lock()
let has = pendingPrefetchTargets.contains(spineIndex)
prefetchLock.unlock()
return has
}
func markBuilding(_ building: Bool) {
buildingLock.lock()
isBuilding = building
buildingLock.unlock()
}
// MARK: - P1: 排版参数变化后整体失效(§8.5)
func invalidateAllForSettingsChange() {
chapterDataCache.removeAll()
pageCountCache.removeAll()
imageCache.removeAllObjects()
}
}