长章节内存优化 P1:章节级共享显示内容与 layouter,高亮转 overlay
- 新增 RDEPUBChapterDisplayContentCache(LRU 2,主线程限定,控制器
持有):整章显示串构建时一次性注入全章主题色与暗黑图替换,配套
共享 DTCoreTextLayouter;签名 = 章节内容对象标识 + 主题双色 +
暗黑图配置,设置/主题变化自动失效重建
- RDEPUBTextContentView.configure 改为引用共享 entry,删除每次翻页
的整章可变拷贝与按页属性写入
- 高亮/下划线改走 overlay decoration(高亮背景层、下划线前景层),
移除 applyHighlightsToContent 与 render view 的属性绘制路径,
高亮增删不再触发整章重建
- 内存警告时清空 display cache;shouldAvoidReaderPageCaching 保持
保守(P1-4 待真机数据)
验证:编译通过;高亮/批注/选区/翻页/设置 13 项 UI 回归全过。
SearchTests 10 项失败经二分确认为先存回归(5a41066 与 e4e629a 均
复现,最后已知通过为 2026-06-08),与本次改动无关,待单独排查。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e4e629a06c
commit
c4be426299
@@ -64,7 +64,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
||||
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
||||
highlights: textHighlights(for: resolvedPage.page),
|
||||
searchState: searchState(for: resolvedPage.page)
|
||||
searchState: searchState(for: resolvedPage.page),
|
||||
displayCache: textDisplayCache
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
@@ -108,7 +109,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
|
||||
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
|
||||
highlights: textHighlights(for: page),
|
||||
searchState: searchState(for: page)
|
||||
searchState: searchState(for: page),
|
||||
displayCache: textDisplayCache
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
|
||||
@@ -96,6 +96,10 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
let paginationHostView = UIView()
|
||||
|
||||
/// Chapter-level shared display content/layouter for text pages
|
||||
/// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1/P1-3).
|
||||
let textDisplayCache = RDEPUBChapterDisplayContentCache()
|
||||
|
||||
lazy var readerContext = RDEPUBReaderContext(controller: self)
|
||||
|
||||
var parser: RDEPUBParser? {
|
||||
@@ -292,6 +296,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
public override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
runtime.handleMemoryWarning()
|
||||
textDisplayCache.removeAll()
|
||||
}
|
||||
|
||||
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// Chapter-level shared display content and layouter
|
||||
/// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1 / P1-3).
|
||||
///
|
||||
/// One entry holds the chapter-length display string (theme text color and
|
||||
/// dark-image adjustment injected once over the full chapter at build time)
|
||||
/// plus the DTCoreTextLayouter wrapping its framesetter. Page views reference
|
||||
/// the shared string and request per-page layout frames; they must never
|
||||
/// mutate the shared string — the framesetter and any live layout frame hold
|
||||
/// it. Page-level decoration (highlights, search, selection) is drawn by the
|
||||
/// overlay views instead.
|
||||
///
|
||||
/// Main-thread only.
|
||||
final class RDEPUBChapterDisplayContentCache {
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
struct Entry {
|
||||
let content: NSAttributedString
|
||||
let layouter: DTCoreTextLayouter?
|
||||
fileprivate let signature: Signature
|
||||
}
|
||||
|
||||
fileprivate struct Signature: Equatable {
|
||||
let chapterContentID: ObjectIdentifier
|
||||
let contentLength: Int
|
||||
let textColor: UIColor
|
||||
let backgroundColor: UIColor
|
||||
let darkImageAdjustmentEnabled: Bool
|
||||
let darkImageBlendRatio: CGFloat
|
||||
|
||||
init(page: RDEPUBTextPage, configuration: RDEPUBReaderConfiguration) {
|
||||
chapterContentID = ObjectIdentifier(page.chapterContent)
|
||||
contentLength = page.chapterContent.length
|
||||
textColor = configuration.theme.contentTextColor
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
darkImageAdjustmentEnabled = configuration.darkImageAdjustmentEnabled
|
||||
darkImageBlendRatio = configuration.darkImageBlendRatio
|
||||
}
|
||||
}
|
||||
|
||||
/// Current chapter plus one adjacent chapter during page-curl/scroll
|
||||
/// transitions across a chapter boundary.
|
||||
private static let capacity = 2
|
||||
|
||||
private var entries: [String: Entry] = [:]
|
||||
|
||||
private var accessOrder: [String] = []
|
||||
|
||||
func entry(for page: RDEPUBTextPage, configuration: RDEPUBReaderConfiguration) -> Entry {
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
let signature = Signature(page: page, configuration: configuration)
|
||||
if let cached = entries[page.href], cached.signature == signature {
|
||||
touch(page.href)
|
||||
return cached
|
||||
}
|
||||
let entry = Self.buildEntry(page: page, configuration: configuration, signature: signature)
|
||||
entries[page.href] = entry
|
||||
touch(page.href)
|
||||
evictIfNeeded()
|
||||
RDEPUBMemoryProbe.log("displayContentBuilt href=\(page.href) length=\(entry.content.length)")
|
||||
return entry
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
entries.removeAll()
|
||||
accessOrder.removeAll()
|
||||
}
|
||||
|
||||
private func touch(_ href: String) {
|
||||
accessOrder.removeAll { $0 == href }
|
||||
accessOrder.append(href)
|
||||
}
|
||||
|
||||
private func evictIfNeeded() {
|
||||
while accessOrder.count > Self.capacity {
|
||||
let evicted = accessOrder.removeFirst()
|
||||
entries.removeValue(forKey: evicted)
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildEntry(
|
||||
page: RDEPUBTextPage,
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
signature: Signature
|
||||
) -> Entry {
|
||||
let content = NSMutableAttributedString(attributedString: page.chapterContent)
|
||||
let fullRange = NSRange(location: 0, length: content.length)
|
||||
_ = RDEPUBDarkImageAdjuster.adjustIfNeeded(
|
||||
content,
|
||||
in: fullRange,
|
||||
configuration: configuration
|
||||
)
|
||||
content.addAttribute(
|
||||
.foregroundColor,
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: fullRange
|
||||
)
|
||||
let layouter = DTCoreTextLayouter(attributedString: content)
|
||||
layouter?.shouldCacheLayoutFrames = false
|
||||
return Entry(content: content, layouter: layouter, signature: signature)
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
func removeAll() {}
|
||||
|
||||
#endif
|
||||
}
|
||||
@@ -87,12 +87,16 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
return view
|
||||
}()
|
||||
|
||||
/// Chapter-level shared display string owned by the display cache.
|
||||
/// Read-only from the page's perspective: theme color and dark-image
|
||||
/// adjustment are baked in at build time; page decoration goes through
|
||||
/// the overlay views.
|
||||
private var coreTextDisplayContent: NSAttributedString?
|
||||
|
||||
private var coreTextDisplayRange: NSRange?
|
||||
|
||||
/// Framesetter over the full chapter copy, rebuilt when the display
|
||||
/// content changes; layout frames are recomputed per bounds change.
|
||||
/// Shared chapter-level layouter (owned by the display cache); layout
|
||||
/// frames are recomputed per page range and bounds change.
|
||||
private var coreTextLayouter: DTCoreTextLayouter?
|
||||
#endif
|
||||
|
||||
@@ -397,7 +401,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
chapterCFIMap: RDEPUBCFIMap? = nil,
|
||||
chapterFragmentOffsets: [String: Int] = [:],
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchState: RDEPUBSearchState? = nil
|
||||
searchState: RDEPUBSearchState? = nil,
|
||||
displayCache: RDEPUBChapterDisplayContentCache? = nil
|
||||
) {
|
||||
loadingSpinner.stopAnimating()
|
||||
currentPage = page
|
||||
@@ -437,37 +442,26 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
coverImageView.image = nil
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
// In-context display: keep the full chapter string and lay out only the
|
||||
// page's range. CTTypesetter line breaks are context-sensitive, so
|
||||
// In-context display: lay out the full chapter string and render only
|
||||
// the page's range. CTTypesetter line breaks are context-sensitive, so
|
||||
// re-wrapping a page substring can break ±1 character away from the
|
||||
// paginator's line ends (lone characters spilling onto the last line);
|
||||
// laying out the same chapter string the paginator used cannot.
|
||||
// String indices in the layout frame are therefore chapter-absolute.
|
||||
let displayContent = NSMutableAttributedString(attributedString: page.chapterContent)
|
||||
// The chapter string and layouter are shared per chapter via the
|
||||
// display cache; this view must not mutate the shared string.
|
||||
let display = (displayCache ?? RDEPUBChapterDisplayContentCache())
|
||||
.entry(for: page, configuration: configuration)
|
||||
let pageRange = NSIntersectionRange(
|
||||
page.contentRange,
|
||||
NSRange(location: 0, length: displayContent.length)
|
||||
NSRange(location: 0, length: display.content.length)
|
||||
)
|
||||
_ = RDEPUBDarkImageAdjuster.adjustIfNeeded(
|
||||
displayContent,
|
||||
in: pageRange,
|
||||
configuration: configuration
|
||||
)
|
||||
displayContent.addAttribute(
|
||||
.foregroundColor,
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: pageRange
|
||||
)
|
||||
|
||||
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
|
||||
coreTextContentView.isHidden = false
|
||||
coreTextContentView.backgroundColor = .clear
|
||||
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
|
||||
coreTextDisplayContent = displayContent
|
||||
coreTextDisplayContent = display.content
|
||||
coreTextDisplayRange = pageRange
|
||||
coreTextLayouter = DTCoreTextLayouter(attributedString: displayContent)
|
||||
coreTextLayouter?.shouldCacheLayoutFrames = false
|
||||
coreTextContentView.attributedDisplayContent = displayContent
|
||||
coreTextLayouter = display.layouter
|
||||
updateCoreTextLayoutFrameIfNeeded()
|
||||
#else
|
||||
let selectionContent = normalizedPageContent(from: page)
|
||||
@@ -521,7 +515,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
#if canImport(DTCoreText)
|
||||
coreTextContentView.isHidden = false
|
||||
coreTextContentView.backgroundColor = .clear
|
||||
coreTextContentView.attributedDisplayContent = nil
|
||||
coreTextContentView.layoutFrame = nil
|
||||
coreTextDisplayContent = nil
|
||||
coreTextDisplayRange = nil
|
||||
@@ -615,31 +608,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
return image(from: attachmentValue)
|
||||
}
|
||||
|
||||
/// Marks highlight ranges on the chapter-length display copy. Ranges stay
|
||||
/// chapter-absolute — the in-context layout frame uses the same indices.
|
||||
private func applyHighlightsToContent(
|
||||
_ content: NSMutableAttributedString,
|
||||
highlights: [RDEPUBHighlight],
|
||||
page: RDEPUBTextPage
|
||||
) {
|
||||
for highlight in highlights {
|
||||
guard highlight.location.href == page.href else { continue }
|
||||
guard let rangeInfo = highlight.rangeInfo,
|
||||
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
|
||||
let absoluteRange = info.nsRange else { continue }
|
||||
let overlap = NSIntersectionRange(absoluteRange, page.contentRange)
|
||||
guard overlap.length > 0,
|
||||
overlap.location + overlap.length <= content.length else { continue }
|
||||
|
||||
switch highlight.style {
|
||||
case .highlight:
|
||||
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: overlap)
|
||||
case .underline:
|
||||
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: overlap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
||||
let content = NSMutableAttributedString(attributedString: page.content)
|
||||
guard content.length > 0 else { return content }
|
||||
@@ -759,12 +727,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
||||
|
||||
if let page = currentPage {
|
||||
// Highlights/underlines and search are all drawn as overlay
|
||||
// decorations; the shared chapter display string carries no
|
||||
// page-level annotation attributes.
|
||||
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
||||
page: page,
|
||||
// Text-page highlights/underlines are already painted by the
|
||||
// CoreText render view via display-content attributes. Keep
|
||||
// overlay decorations for search only to avoid double drawing.
|
||||
highlights: [],
|
||||
highlights: currentHighlights,
|
||||
searchState: currentSearchState,
|
||||
interactionController: interactionController
|
||||
)
|
||||
|
||||
@@ -22,12 +22,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
var attributedDisplayContent: NSAttributedString? {
|
||||
didSet {
|
||||
invalidateStaticContent()
|
||||
}
|
||||
}
|
||||
|
||||
var selectionRects: [CGRect] = [] {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
@@ -78,9 +72,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
if let cachedStaticImage {
|
||||
cachedStaticImage.draw(in: bounds)
|
||||
} else {
|
||||
if let attributedDisplayContent {
|
||||
drawHighlights(in: context, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
|
||||
}
|
||||
layoutFrame.draw(in: context, options: drawOptions)
|
||||
}
|
||||
|
||||
@@ -96,59 +87,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
private func drawHighlights(
|
||||
in context: CGContext,
|
||||
attributedString: NSAttributedString,
|
||||
layoutFrame: DTCoreTextLayoutFrame
|
||||
) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
|
||||
attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
|
||||
guard let color = value as? UIColor else { return }
|
||||
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
|
||||
color.setFill()
|
||||
for rect in rects {
|
||||
context.fill(rect)
|
||||
}
|
||||
}
|
||||
|
||||
attributedString.enumerateAttribute(kRDEPUBUnderlineAttributeName, in: fullRange) { value, range, _ in
|
||||
guard let color = value as? UIColor else { return }
|
||||
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
|
||||
color.setStroke()
|
||||
context.setLineWidth(2)
|
||||
for rect in rects {
|
||||
let y = rect.maxY - 1
|
||||
context.move(to: CGPoint(x: rect.minX, y: y))
|
||||
context.addLine(to: CGPoint(x: rect.maxX, y: y))
|
||||
context.strokePath()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func computeHighlightRects(for range: NSRange, layoutFrame: DTCoreTextLayoutFrame) -> [CGRect] {
|
||||
var rects: [CGRect] = []
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else { return rects }
|
||||
|
||||
for line in lines {
|
||||
let lineRange = line.stringRange()
|
||||
let overlap = NSIntersectionRange(range, lineRange)
|
||||
guard overlap.length > 0 else { continue }
|
||||
|
||||
let startX = line.offset(forStringIndex: overlap.location)
|
||||
let endX = line.offset(forStringIndex: overlap.location + overlap.length)
|
||||
let rect = CGRect(
|
||||
x: line.baselineOrigin.x + startX,
|
||||
y: line.baselineOrigin.y - line.ascent,
|
||||
width: endX - startX,
|
||||
height: line.ascent + line.descent
|
||||
)
|
||||
rects.append(rect)
|
||||
}
|
||||
|
||||
return rects
|
||||
}
|
||||
|
||||
private func drawSelection(in context: CGContext) {
|
||||
guard !selectionRects.isEmpty else { return }
|
||||
selectionColor.setFill()
|
||||
@@ -261,9 +199,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
let renderer = UIGraphicsImageRenderer(size: bounds.size, format: format)
|
||||
return renderer.image { _ in
|
||||
guard let staticContext = UIGraphicsGetCurrentContext() else { return }
|
||||
if let attributedDisplayContent {
|
||||
drawHighlights(in: staticContext, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
|
||||
}
|
||||
layoutFrame.draw(in: staticContext, options: drawOptions)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user