ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift
shenlei 83dfa40299 完善搜索定位与内存监测回归验证
本次提交围绕搜索链路的稳定性、定位恢复体验以及长章节内存优化的验证能力进行了补强。

主要改动:

1. 调整阅读器搜索栏、搜索协调器与定位恢复逻辑,改善搜索结果跳转、状态同步与相关 UI 行为。

2. 补充内存探针接入点与上下文记录,便于跟踪阅读过程中的内存占用变化。

3. 更新 RDURLReaderController、ReaderContext 与工具视图相关实现,使调试与观测链路更完整。

4. 新增 MemoryFootprintTests,并同步更新 DemoReaderState 与 SearchTests,用 UI 测试覆盖搜索与内存相关回归场景。
2026-07-09 18:46:04 +09:00

421 lines
13 KiB
Swift

import UIKit
/// Captures layout parameters on the main thread for safe use on background queues.
/// Create via `RDEPUBReaderContext.makeLayoutSnapshot()` before dispatching work off the main thread.
struct RDEPUBLayoutSnapshot {
let pageSize: CGSize
let style: RDEPUBTextRenderStyle
let layoutConfig: RDEPUBTextLayoutConfig
let renderSignature: String
}
final class RDEPUBReaderContext {
private let activityLock = NSLock()
private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
weak var controller: RDEPUBReaderController?
weak var readerView: RDReaderView?
let state: RDEPUBReaderState
let environment: RDEPUBReaderEnvironment
let services: RDEPUBReaderServices
var dependencies: RDEPUBReaderDependencies {
get { services.dependencies }
set {
services.dependencies = newValue
environment.displayEnvironment = newValue.environment
}
}
var runtime: RDEPUBReaderRuntime? {
controller?.runtime
}
var parser: RDEPUBParser? {
get { state.parser }
set { state.parser = newValue }
}
var publication: RDEPUBPublication? {
get { state.publication }
set { state.publication = newValue }
}
var readingSession: RDEPUBReadingSession? {
get { state.readingSession }
set { state.readingSession = newValue }
}
var textBook: RDEPUBTextBook? {
get { state.textBook }
set { state.textBook = newValue }
}
var bookPageMap: RDEPUBBookPageMap? {
get { state.bookPageMap }
set { state.bookPageMap = newValue }
}
var activeBookmarks: [RDEPUBBookmark] {
get { state.activeBookmarks }
set { state.activeBookmarks = newValue }
}
var activeHighlights: [RDEPUBHighlight] {
get { state.activeHighlights }
set { state.activeHighlights = newValue }
}
var currentBookIdentifier: String? {
get { state.currentBookIdentifier }
set { state.currentBookIdentifier = newValue }
}
var paginationToken: UUID {
get { state.paginationToken }
set { state.paginationToken = newValue }
}
var searchState: RDEPUBSearchState? {
get { state.searchState }
set { state.searchState = newValue }
}
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] {
get { state.pendingPageMapUpdates }
set { state.pendingPageMapUpdates = newValue }
}
var lastTextPaginationPageSize: CGSize? {
get { state.lastTextPaginationPageSize }
set { state.lastTextPaginationPageSize = newValue }
}
var lastMetadataParseWallClockMs: Int {
get { state.lastMetadataParseWallClockMs }
set { state.lastMetadataParseWallClockMs = newValue }
}
var lastMetadataParseConcurrency: Int {
get { state.lastMetadataParseConcurrency }
set { state.lastMetadataParseConcurrency = newValue }
}
var currentSelection: RDEPUBSelection? {
get { state.currentSelection }
set { state.currentSelection = newValue }
}
var selectionState: RDEPUBSelectionState {
get { state.selectionState }
set { state.selectionState = newValue }
}
var configuration: RDEPUBReaderConfiguration = .default
var persistence: RDEPUBReaderPersistence?
var epubURL: URL = URL(string: "about:blank")!
var isRepaginating: Bool {
get { state.isRepaginating }
set { state.isRepaginating = newValue }
}
var didStartInitialLoad: Bool {
get { state.didStartInitialLoad }
set { state.didStartInitialLoad = newValue }
}
var isExternalTextBook: Bool {
get { state.isExternalTextBook }
set { state.isExternalTextBook = newValue }
}
var textFileURL: URL? {
get { state.textFileURL }
set { state.textFileURL = newValue }
}
var textBookCache: RDEPUBTextBookCache { state.textBookCache }
init(controller: RDEPUBReaderController) {
self.controller = controller
self.readerView = controller.readerView
let state = RDEPUBReaderState()
self.state = state
self.environment = RDEPUBReaderEnvironment(
controller: controller,
readerView: controller.readerView,
displayEnvironment: RDEPUBUIScreenEnvironment()
)
self.services = RDEPUBReaderServices(dependencies: .live)
}
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
environment.currentLayoutContext(configuration: configuration)
}
func currentPreferences() -> RDEPUBPreferences {
let safeInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
return configuration.makePreferences(safeAreaInsets: safeInsets)
}
/// Captures all layout parameters needed for background chapter loading.
/// Must be called on the main thread. The returned snapshot is safe to use on any thread.
func makeLayoutSnapshot() -> RDEPUBLayoutSnapshot {
dispatchPrecondition(condition: .onQueue(.main))
let pageSize = currentTextPageSize()
let style = currentTextRenderStyle()
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
let renderSignature = renderSignature(style: style, pageSize: pageSize, layoutConfig: layoutConfig)
return RDEPUBLayoutSnapshot(
pageSize: pageSize,
style: style,
layoutConfig: layoutConfig,
renderSignature: renderSignature
)
}
func currentTextPageSize() -> CGSize {
// M-04: Use dispatchPrecondition instead of assert so it's enforced in Release builds too.
dispatchPrecondition(condition: .onQueue(.main))
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
if let readerView, let pageNum {
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
if resolvedSize.width > 0, resolvedSize.height > 0 {
return resolvedSize
}
}
let viewportSize = currentLayoutContext().viewportSize
if viewportSize.width > 0, viewportSize.height > 0 {
return viewportSize
}
if let lastTextPaginationPageSize,
lastTextPaginationPageSize.width > 0,
lastTextPaginationPageSize.height > 0 {
return lastTextPaginationPageSize
}
return environment.fallbackViewportSize
}
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
dispatchPrecondition(condition: .onQueue(.main))
return environment.currentTextRenderStyle(configuration: configuration)
}
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
dispatchPrecondition(condition: .onQueue(.main))
return environment.currentTextLayoutConfig(configuration: configuration, pageSize: pageSize)
}
func resolvedTextRenderer() -> RDEPUBTextRenderer {
services.resolvedTextRenderer(configuration: configuration)
}
var activePages: [EPUBPage] {
state.activePages
}
var activeChapters: [EPUBChapterInfo] {
state.activeChapters
}
var currentBrightness: CGFloat {
get { environment.currentBrightness }
set { environment.currentBrightness = newValue }
}
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
state.replaceActiveSnapshot(snapshot)
}
func clearActiveSnapshot() {
state.clearActiveSnapshot()
}
func makeParser() -> RDEPUBParser {
services.makeParser()
}
func makePaginator() -> RDEPUBPaginator {
services.makePaginator()
}
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
services.makeTextBookBuilder(
configuration: configuration,
cache: textBookCache,
layoutConfig: layoutConfig
)
}
func makeChapterSummaryDiskCache() -> RDEPUBChapterSummaryDiskCache {
services.makeChapterSummaryDiskCache(bookIdentifier: currentBookIdentifier)
}
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
let contentHash: String
if let parser,
let publication,
publication.spine.indices.contains(spineIndex) {
let href = publication.spine[spineIndex].href
contentHash = parser.htmlString(forRelativePath: href)?.rd_sha256Hex ?? ""
} else {
contentHash = ""
}
return chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: contentHash,
renderSignature: currentRenderSignature()
)
}
func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey {
chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: precomputedContentHash,
renderSignature: currentRenderSignature()
)
}
func chapterCacheKey(
forSpineIndex spineIndex: Int,
precomputedContentHash: String,
renderSignature: String
) -> RDEPUBChapterCacheKey {
RDEPUBChapterCacheKey(
bookID: currentBookIdentifier ?? "",
spineIndex: spineIndex,
renderSignature: renderSignature,
chapterContentHash: precomputedContentHash
)
}
func currentRenderSignature() -> String {
dispatchPrecondition(condition: .onQueue(.main))
let style = currentTextRenderStyle()
let pageSize = currentTextPageSize()
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
return renderSignature(style: style, pageSize: pageSize, layoutConfig: layoutConfig)
}
private func renderSignature(
style: RDEPUBTextRenderStyle,
pageSize: CGSize,
layoutConfig: RDEPUBTextLayoutConfig
) -> String {
[
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))
}
func normalizedSpineIndex(for location: RDEPUBLocation) -> Int? {
guard let publication else { return nil }
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
guard let normalizedHref = publication.resourceResolver.normalizedHref(normalizedLocation.href) else {
return nil
}
return publication.spine.firstIndex {
publication.resourceResolver.normalizedHref($0.href) == normalizedHref
}
}
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
services.makePlainTextBookBuilder(
configuration: configuration,
layoutConfig: layoutConfig
)
}
func currentVisibleLocation() -> RDEPUBLocation? {
controller?.currentVisibleLocation()
}
func persistenceLocation() -> RDEPUBLocation? {
guard let currentBookIdentifier else { return nil }
return persistence?.loadLocation(for: currentBookIdentifier)
}
func persist(location: RDEPUBLocation) {
guard let currentBookIdentifier else { return }
persistence?.saveLocation(location, for: currentBookIdentifier)
}
func markUserNavigationActivity() {
activityLock.lock()
lastUserNavigationTimestamp = CFAbsoluteTimeGetCurrent()
activityLock.unlock()
}
func secondsSinceLastUserNavigation() -> CFAbsoluteTime {
activityLock.lock()
let timestamp = lastUserNavigationTimestamp
activityLock.unlock()
guard timestamp > 0 else { return .greatestFiniteMagnitude }
return CFAbsoluteTimeGetCurrent() - timestamp
}
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
guard let textBook else { return nil }
// External text books (e.g. plain .txt) have no publication; their
// chapter hrefs are matched verbatim.
let normalizedHref = publication?.resourceResolver.normalizedHref(href) ?? href
return textBook.chapters.lazy
.first(where: { (publication?.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref })
.flatMap { textBook.chapterData(for: $0.href) }
}
func showLoading() {
controller?.showLoading()
}
func hideLoading() {
controller?.hideLoading()
}
func handle(error: Error) {
controller?.handle(error: error)
}
func updateReaderChrome() {
controller?.updateReaderChrome()
}
func refreshVisibleContentPreservingLocation() {
controller?.refreshVisibleContentPreservingLocation()
}
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
controller?.restoreReadingLocation(location, animated: animated) ?? false
}
func repaginatePreservingCurrentLocation() {
controller?.repaginatePreservingCurrentLocation()
}
func applyReaderViewConfiguration() {
controller?.applyReaderViewConfiguration()
}
}