feat: chapter runtime refactoring and related updates

- Refactor chapter runtime: replace window coordinator/snapshot with warmup orchestrator
- Update EPUB core: parser, reading session, JS bridge, navigator layout
- Update reader controller: data source, location resolution, persistence
- Update chapter runtime: data cache, loader, runtime store, disk cache, warmup orchestrator
- Remove deprecated navigation state machine and pagination state
- Update text rendering: book cache, HTML normalizer
- Update UI: text content view, dark image adjuster, text selection controller
- Update settings and reader configuration
- Add CODE_REVIEW.md and AUDIT_FINAL.md documentation
- Update pod dependencies (remove SSAlertSwift, SnapKit)
- Update podspec and pod configuration files

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-26 18:50:07 +09:00
co-authored by Claude
parent b8aa10c535
commit 22e7e44220
123 changed files with 3701 additions and 9118 deletions
@@ -3,19 +3,37 @@ import Foundation
final class RDEPUBChapterDataCache {
private var storage: [Int: RDEPUBRuntimeChapter] = [:]
private var accessOrder: [Int] = [] // H-05: LRU tracking for eviction
private let maxEntryCount: Int
private let lock = NSLock()
init(maxEntryCount: Int = 30) {
self.maxEntryCount = maxEntryCount
}
subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? {
get {
lock.lock()
defer { lock.unlock() }
return storage[spineIndex]
guard let chapter = storage[spineIndex] else {
return nil
}
touchLocked(spineIndex)
return chapter
}
set {
lock.lock()
defer { lock.unlock() }
storage[spineIndex] = newValue
if let newValue {
storage[spineIndex] = newValue
touchLocked(spineIndex)
// Evict oldest entries if over limit
evictIfNeededLocked()
} else {
storage.removeValue(forKey: spineIndex)
accessOrder.removeAll { $0 == spineIndex }
}
}
}
@@ -29,11 +47,29 @@ final class RDEPUBChapterDataCache {
lock.lock()
defer { lock.unlock() }
storage.removeValue(forKey: spineIndex)
accessOrder.removeAll { $0 == spineIndex }
}
func removeAll() {
lock.lock()
defer { lock.unlock() }
storage.removeAll()
accessOrder.removeAll()
}
}
/// H-05: Evict least recently used entries when cache exceeds maxEntryCount.
/// Must be called while holding lock.
private func evictIfNeededLocked() {
while storage.count > maxEntryCount, let oldest = accessOrder.first {
storage.removeValue(forKey: oldest)
accessOrder.removeFirst()
}
}
/// Marks an entry as most recently used.
/// Must be called while holding lock.
private func touchLocked(_ spineIndex: Int) {
accessOrder.removeAll { $0 == spineIndex }
accessOrder.append(spineIndex)
}
}
@@ -46,12 +46,29 @@ final class RDEPUBChapterLoader {
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority = .navigation,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
) {
let layoutSnapshot = context?.makeLayoutSnapshot()
loadChapterWithSnapshot(
spineIndex: spineIndex,
store: store,
priority: priority,
layoutSnapshot: layoutSnapshot,
completion: completion
)
}
private func loadChapterWithSnapshot(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority,
layoutSnapshot: RDEPUBLayoutSnapshot?,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
) {
if let cached = store.chapterData(for: spineIndex) {
if let context {
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context),
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
store: store
)
}
@@ -83,15 +100,15 @@ final class RDEPUBChapterLoader {
store.markBuilding(true)
_ = store.beginPendingChapterLoad(for: spineIndex)
store.chapterLoadQueue.async { [self] in
guard let context = self.context else {
store.chapterLoadQueue.async { [weak self] in
guard let self, let context = self.context else {
store.endPendingChapterLoad(for: spineIndex)
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser))
self?.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser))
return
}
let queuePriority = self.pendingPriority(for: spineIndex) ?? priority
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context)
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
@@ -109,7 +126,8 @@ final class RDEPUBChapterLoader {
spineIndex: spineIndex,
availablePageRanges: availablePageRanges,
diskSummary: diskSummary,
context: context
context: context,
layoutSnapshot: layoutSnapshot
)
store.insertChapter(chapter)
@@ -137,7 +155,7 @@ final class RDEPUBChapterLoader {
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: { _ in })
self.loadChapterWithSnapshot(spineIndex: target, store: store, priority: .navigation, layoutSnapshot: layoutSnapshot, completion: { _ in })
return
}
store.markBuilding(false)
@@ -163,7 +181,8 @@ final class RDEPUBChapterLoader {
func loadChapterSynchronouslyForMigration(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore?
store: RDEPUBChapterRuntimeStore?,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
guard let context else {
throw RDEPUBChapterLoadError.missingParser
@@ -173,7 +192,7 @@ final class RDEPUBChapterLoader {
if let store {
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context),
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
store: store
)
}
@@ -202,12 +221,13 @@ final class RDEPUBChapterLoader {
store.assertNotOnChapterLoadQueue()
let snapshot = layoutSnapshot ?? context.makeLayoutSnapshot()
var result: Result<RDEPUBRuntimeChapter, Error>?
let semaphore = DispatchSemaphore(value: 0)
store.chapterLoadQueue.async {
do {
let chapter: RDEPUBRuntimeChapter = try autoreleasepool {
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context)
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: snapshot)
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
if precomputedPageRanges == nil {
@@ -219,7 +239,8 @@ final class RDEPUBChapterLoader {
spineIndex: spineIndex,
availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
diskSummary: diskSummary,
context: context
context: context,
layoutSnapshot: snapshot
)
store.insertChapter(chapter)
let pageCount = RDEPUBRuntimePageCount(
@@ -288,16 +309,28 @@ final class RDEPUBChapterLoader {
spineIndex: Int,
availablePageRanges: [NSRange]?,
diskSummary: RDEPUBChapterSummary? = nil,
context: RDEPUBReaderContext
context: RDEPUBReaderContext,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
guard let parser = context.parser,
let publication = context.publication else {
throw RDEPUBChapterLoadError.missingParser
}
let pageSize = context.currentTextPageSize()
let style = context.currentTextRenderStyle()
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
let pageSize: CGSize
let style: RDEPUBTextRenderStyle
let layoutConfig: RDEPUBTextLayoutConfig
if let snapshot = layoutSnapshot {
pageSize = snapshot.pageSize
style = snapshot.style
layoutConfig = snapshot.layoutConfig
} else {
assert(Thread.isMainThread, "buildChapter() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
pageSize = context.currentTextPageSize()
style = context.currentTextRenderStyle()
layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
}
if let pageRanges = availablePageRanges {
@@ -330,7 +363,8 @@ final class RDEPUBChapterLoader {
spineIndex: spineIndex,
pageSize: pageSize,
layoutConfig: layoutConfig,
context: context
context: context,
layoutSnapshot: layoutSnapshot
)
}
@@ -542,7 +576,8 @@ final class RDEPUBChapterLoader {
spineIndex: Int,
pageSize: CGSize,
layoutConfig: RDEPUBTextLayoutConfig,
context: RDEPUBReaderContext
context: RDEPUBReaderContext,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
let layouter = RDEPUBTextLayouter(
attributedString: chapter.attributedContent,
@@ -559,7 +594,7 @@ final class RDEPUBChapterLoader {
)
let pageRanges = chapter.pages.map { $0.contentRange }
let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context)
let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey)
return RDEPUBRuntimeChapter(
@@ -575,11 +610,22 @@ final class RDEPUBChapterLoader {
)
}
private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext) -> RDEPUBChapterCacheKey {
let style = context.currentTextRenderStyle()
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext, layoutSnapshot: RDEPUBLayoutSnapshot? = nil) -> RDEPUBChapterCacheKey {
let style: RDEPUBTextRenderStyle
let layoutConfig: RDEPUBTextLayoutConfig
let lineHeightMultiple: CGFloat
let lineHeightMultiple = context.configuration.lineHeightMultiple
if let snapshot = layoutSnapshot {
style = snapshot.style
layoutConfig = snapshot.layoutConfig
lineHeightMultiple = context.configuration.lineHeightMultiple
} else {
assert(Thread.isMainThread, "makeCacheKey() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
let pageSize = context.currentTextPageSize()
style = context.currentTextRenderStyle()
layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
lineHeightMultiple = context.configuration.lineHeightMultiple
}
let renderSignature = [
style.font.fontName,
@@ -12,6 +12,10 @@ final class RDEPUBChapterRuntimeStore {
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
// M-02: currentSpineIndex and windowSpineIndices are accessed only from the main thread
// (verified by audit of all 7 access points). They are not protected by locks unlike
// navigationLock/prefetchLock/buildingLock/cfiMapLock, but this is safe as long as
// access remains main-thread-only. Do NOT access from chapterLoadQueue.
private(set) var currentSpineIndex: Int?
private(set) var windowSpineIndices: [Int] = []
@@ -39,6 +43,7 @@ final class RDEPUBChapterRuntimeStore {
init() {
imageCache.countLimit = 50
imageCache.totalCostLimit = 104_857_600 // 100 MB
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
}
@@ -172,7 +172,7 @@ struct RDEPUBChapterSummary: Codable {
let pageMetadataList: [PageMetadataSummary]
static let currentSchemaVersion = 16
static let currentSchemaVersion = 17
struct RangeData: Codable {
@@ -82,8 +82,6 @@ final class RDEPUBChapterWarmupOrchestrator {
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
presentationRuntime.navigationStateMachine.transition(to: .preparingChapter(spineIndex: spineIndex))
if !chapterReady {
guard allowSynchronousLoad else {
scheduleAsynchronousChapterPreparation(
@@ -105,7 +103,6 @@ final class RDEPUBChapterWarmupOrchestrator {
}
markPrepareResolved(pageNumber)
presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
completion?(true)
maybePrefetchUpcomingChapters(aroundAbsolutePageNumber: pageNumber, in: bookPageMap)
scheduleAdjacentChapterPrefetches(for: spineIndex, totalSpineCount: publication.spine.count)
@@ -378,7 +375,6 @@ final class RDEPUBChapterWarmupOrchestrator {
switch result {
case .success:
self.markPrepareResolved(triggerPageNumber)
self.presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
completion?(true)
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
self.refreshVisibleContentIfNeeded(afterPreparing: spineIndex, triggerPageNumber: triggerPageNumber)
@@ -1,246 +0,0 @@
import Foundation
final class RDEPUBChapterWindowCoordinator {
private unowned let context: RDEPUBReaderContext
private let store: RDEPUBChapterRuntimeStore
private let loader: RDEPUBChapterLoader
private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot?
var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)?
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore, loader: RDEPUBChapterLoader) {
self.context = context
self.store = store
self.loader = loader
}
private var restoreChapterOffset: Int?
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) {
let totalSpineCount = context.publication?.spine.count ?? 0
store.setCurrentChapter(
spineIndex: targetSpineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
self.restoreChapterOffset = restoreChapterOffset
isSwitchingChapter = true
store.setNavigationTarget(spineIndex: targetSpineIndex)
store.clearPrefetchTargets()
loadChapterWithFallback(initialSpineIndex: targetSpineIndex, totalSpineCount: totalSpineCount)
}
private func loadChapterWithFallback(initialSpineIndex: Int, totalSpineCount: Int) {
loader.loadChapter(spineIndex: initialSpineIndex, store: store, priority: .navigation) { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let chapter):
self.isSwitchingChapter = false
self.buildSnapshotAroundCurrent(chapter: chapter)
case .failure(let error):
let nextIndex = initialSpineIndex + 1
if nextIndex < totalSpineCount {
self.store.setCurrentChapter(
spineIndex: nextIndex,
totalSpineCount: totalSpineCount,
windowRadius: self.context.configuration.chapterWindowRadius
)
self.store.setNavigationTarget(spineIndex: nextIndex)
self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount)
} else {
self.isSwitchingChapter = false
self.handle(error: error)
}
}
}
}
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
guard let current = store.currentSpineIndex else {
return
}
let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in
if spineIndex == chapter.spineIndex {
return chapter
}
return store.chapterData(for: spineIndex)
}
let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
currentSnapshot = snapshot
isApplyingSnapshot = true
onSnapshotChanged?(snapshot)
isApplyingSnapshot = false
if let offset = restoreChapterOffset,
let chapter = snapshot.chapterForPage(flattenedPageIndex: snapshot.anchorPageOffset),
let pageIndex = chapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
let targetPage = snapshot.anchorPageOffset + pageIndex
context.readerView?.transitionToPage(pageNum: targetPage, animated: false)
} else if snapshot.pageCount > 0 {
context.readerView?.transitionToPage(pageNum: snapshot.anchorPageOffset, animated: false)
}
restoreChapterOffset = nil
prefetchAdjacent(current: current)
}
private func prefetchAdjacent(current: Int) {
for spineIndex in store.windowSpineIndices where spineIndex != current {
guard store.chapterData(for: spineIndex) == nil else { continue }
store.addPrefetchTarget(spineIndex)
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .prefetch) { [weak self] result in
guard let self = self, case .success = result else { return }
self.refreshSnapshot()
}
}
}
func flipToNextChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
guard let current = store.currentSpineIndex else { return }
let next = current + 1
let totalSpineCount = context.publication?.spine.count ?? 0
guard next < totalSpineCount else { return }
flipToChapter(spineIndex: next, completion: completion)
}
func flipToPreviousChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
guard let current = store.currentSpineIndex, current > 0 else { return }
flipToChapter(spineIndex: current - 1, completion: completion)
}
func flipToChapter(
spineIndex: Int,
completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void
) {
let totalSpineCount = context.publication?.spine.count ?? 0
store.setNavigationTarget(spineIndex: spineIndex)
store.clearPrefetchTargets()
isSwitchingChapter = true
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
let evictable = store.evictableSpineIndices()
for idx in evictable {
store.evict(spineIndex: idx)
}
if let cached = store.chapterData(for: spineIndex) {
buildSnapshotAroundCurrent(chapter: cached)
isSwitchingChapter = false
if let snap = currentSnapshot {
completion(.success(snap))
}
return
}
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .navigation) { [weak self] result in
guard let self = self else { return }
self.isSwitchingChapter = false
switch result {
case .success(let chapter):
self.buildSnapshotAroundCurrent(chapter: chapter)
if let snap = self.currentSnapshot {
completion(.success(snap))
}
case .failure(let error):
completion(.failure(error))
}
}
}
func refreshSnapshot() {
guard let current = store.currentSpineIndex,
let currentChapter = store.chapterData(for: current) else { return }
guard isReaderIdle() else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.refreshSnapshot()
}
return
}
let chapters = store.windowSpineIndices.compactMap { store.chapterData(for: $0) }
let newSnapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
if snapshotContentChanged(old: currentSnapshot, new: newSnapshot) {
currentSnapshot = newSnapshot
isApplyingSnapshot = true
onSnapshotChanged?(newSnapshot)
isApplyingSnapshot = false
}
}
func maintainWindow(afterMovingTo spineIndex: Int) {
let totalSpineCount = context.publication?.spine.count ?? 0
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
for idx in store.evictableSpineIndices() {
store.evict(spineIndex: idx)
}
prefetchAdjacent(current: spineIndex)
}
private func handle(error: Error) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.context.hideLoading()
}
}
private func snapshotContentChanged(
old: RDEPUBChapterWindowSnapshot?,
new: RDEPUBChapterWindowSnapshot
) -> Bool {
guard let old = old else { return true }
if old.chapters.count != new.chapters.count { return true }
let oldSpines = old.chapters.map { $0.spineIndex }
let newSpines = new.chapters.map { $0.spineIndex }
if oldSpines != newSpines { return true }
if old.pageCount != new.pageCount { return true }
for (oldCh, newCh) in zip(old.chapters, new.chapters) {
if oldCh.pages.count != newCh.pages.count { return true }
}
if old.anchorChapterIndex != new.anchorChapterIndex
|| old.anchorPageOffset != new.anchorPageOffset { return true }
return false
}
private func isReaderIdle() -> Bool {
guard !store.isBuilding else { return false }
guard !isSwitchingChapter else { return false }
guard !isApplyingSnapshot else { return false }
return true
}
private var isSwitchingChapter: Bool = false
private var isApplyingSnapshot: Bool = false
}
@@ -1,58 +0,0 @@
import Foundation
struct RDEPUBChapterWindowSnapshot {
let chapters: [RDEPUBRuntimeChapter]
let flattenedPages: [RDEPUBTextPage]
let anchorChapterIndex: Int
let anchorPageOffset: Int
let windowStartSpineIndex: Int
static func from(
chapters: [RDEPUBRuntimeChapter],
anchorSpineIndex: Int
) -> RDEPUBChapterWindowSnapshot {
let sortedChapters = chapters.sorted { $0.spineIndex < $1.spineIndex }
let anchorIndex = sortedChapters.firstIndex { $0.spineIndex == anchorSpineIndex } ?? 0
let pageOffset = sortedChapters.prefix(anchorIndex).reduce(0) { $0 + $1.pages.count }
var allPages: [RDEPUBTextPage] = []
for (chIdx, ch) in sortedChapters.enumerated() {
for var page in ch.pages {
page.chapterIndex = chIdx
allPages.append(page)
}
}
let windowStartSpineIndex = sortedChapters.first?.spineIndex ?? anchorSpineIndex
return RDEPUBChapterWindowSnapshot(
chapters: sortedChapters,
flattenedPages: allPages,
anchorChapterIndex: anchorIndex,
anchorPageOffset: pageOffset,
windowStartSpineIndex: windowStartSpineIndex
)
}
func chapterForPage(flattenedPageIndex: Int) -> RDEPUBRuntimeChapter? {
var offset = 0
for ch in chapters {
if flattenedPageIndex < offset + ch.pages.count {
return ch
}
offset += ch.pages.count
}
return nil
}
func spineIndexForPage(flattenedPageIndex: Int) -> Int? {
return chapterForPage(flattenedPageIndex: flattenedPageIndex)?.spineIndex
}
var pageCount: Int { flattenedPages.count }
}