ReadViewSDK/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift
shenlei c4be426299 长章节内存优化 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>
2026-07-08 16:09:40 +09:00

115 lines
3.8 KiB
Swift

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
}