长章节内存优化 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:
shenlei
2026-07-08 16:09:40 +09:00
co-authored by Claude Fable 5
parent e4e629a06c
commit c4be426299
7 changed files with 1369 additions and 1343 deletions
@@ -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)
}
}