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)
191 lines
6.1 KiB
Swift
191 lines
6.1 KiB
Swift
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()
|
||
}
|
||
}
|