ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift
shenlei e4e629a06c 长章节内存优化 P0:每页子串改按需构造并新增内存打点
- RDEPUBTextPage.content 由存储属性改为基于 chapterContent+contentRange
  的计算属性,三处构造点不再生成每页子串,章节缓存窗口内每章少一份
  整章文本常驻副本
- 新增 RDEPUBMemoryProbe(--demo-memory-probe 开启),在章节插入、
  每 20 次翻页、设置失效、转屏完成时输出 phys_footprint
- shouldAvoidReaderPageCaching 补注释,锁定放开前置条件为 P1-1
- 新增实施清单文档 LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md 并勾选 P0

验证:Demo 编译通过;UI 回归 9/9 通过(PageNavigation/
ReaderOpenClose/LargeBookOnDemand)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:34:45 +09:00

194 lines
5.8 KiB
Swift

import UIKit
public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
public var href: String
public var title: String
public var pageCount: Int
public var breakReasons: [RDEPUBTextPageBreakReason]
public var attachmentPageCount: Int
public var blockAdjustedPageCount: Int
public var blockKinds: [RDEPUBTextBlockKind]
public var semanticHints: [RDEPUBTextSemanticHint]
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
public var sampleNotes: [String]
}
public struct RDEPUBTextChapterBuildResult {
public var chapter: RDEPUBTextChapter
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public var paginationDiagnostic: RDEPUBTextChapterPaginationDiagnostic
public var performanceSample: RDEPUBTextPerformanceSample
public var cacheHit: Bool
}
public struct RDEPUBTextPage: Equatable {
public var absolutePageIndex: Int
public var chapterIndex: Int
public var spineIndex: Int
public var href: String
public var chapterTitle: String
public var pageIndexInChapter: Int
public var totalPagesInChapter: Int
public var chapterContent: NSAttributedString
/// Page substring derived on demand from `chapterContent` + `contentRange`.
/// Not stored: keeping a per-page substring alive roughly doubles the
/// chapter's resident text memory across the chapter cache window.
public var content: NSAttributedString {
let bounds = NSRange(location: 0, length: chapterContent.length)
let clamped = NSIntersectionRange(contentRange, bounds)
guard clamped.length > 0 else { return NSAttributedString() }
return chapterContent.attributedSubstring(from: clamped)
}
public var contentRange: NSRange
public var pageStartOffset: Int
public var pageEndOffset: Int
public var metadata: RDEPUBTextPageMetadata
}
public struct RDEPUBTextChapter: Equatable {
public var chapterIndex: Int
public var spineIndex: Int
public var href: String
public var title: String
public var attributedContent: NSAttributedString
public var fragmentOffsets: [String: Int]
public var cfiMap: RDEPUBCFIMap?
public var pageBreakReasons: [RDEPUBTextPageBreakReason]
public var pages: [RDEPUBTextPage]
}
public struct RDEPUBTextBook {
public var chapters: [RDEPUBTextChapter]
public var pages: [RDEPUBTextPage]
public let indexTable: RDEPUBTextIndexTable
public var positionConverter: RDEPUBTextPositionConverter {
RDEPUBTextPositionConverter(book: self)
}
public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
self.chapters = chapters
self.pages = pages
self.indexTable = RDEPUBTextIndexTable(chapters: chapters)
}
public static func == (lhs: RDEPUBTextBook, rhs: RDEPUBTextBook) -> Bool {
lhs.chapters == rhs.chapters && lhs.pages == rhs.pages
}
public func chapterData(for href: String) -> RDEPUBChapterData? {
guard let chapter = chapters.first(where: { $0.href == href }) else { return nil }
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
}
public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData? {
guard let chapter = chapters.first(where: { $0.spineIndex == spineIndex }) else { return nil }
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
}
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
guard chapters.indices.contains(index) else { return nil }
return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
}
public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData? {
guard let page = page(at: pageNumber) else { return nil }
return chapterData(forSpineIndex: page.spineIndex)
}
public func chapterData(
for location: RDEPUBLocation,
resolver: RDEPUBResourceResolver,
bookIdentifier: String?
) -> RDEPUBChapterData? {
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier) else {
return nil
}
return chapterData(for: normalizedLocation.href)
}
public var chapterInfos: [EPUBChapterInfo] {
chapters.map { chapter in
EPUBChapterInfo(
spineIndex: chapter.spineIndex,
title: chapter.title,
pageCount: chapter.pages.count
)
}
}
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
return nil
}
return pages[pageNumber - 1]
}
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
let chapterData = chapterData(for: normalizedLocation.href) else {
return nil
}
if let anchor = normalizedLocation.rangeAnchor?.start,
let page = positionConverter.pageNumber(for: anchor) {
return page
}
if let anchor = indexTable.anchor(for: normalizedLocation),
let page = positionConverter.pageNumber(for: anchor) {
return page
}
return chapterData.pageNumber(for: normalizedLocation)
}
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard let chapterData = chapterData(forPageNumber: pageNumber),
let page = page(at: pageNumber) else {
return nil
}
return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
}
}