长章节内存优化 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
parent e4e629a06c
commit c4be426299
7 changed files with 1369 additions and 1343 deletions

View File

@ -61,28 +61,26 @@
**任务**
- [ ] 在章节运行时侧(建议挂在 `RDEPUBRuntimeChapter` 或新建 `RDEPUBChapterDisplayCache`)新增:
- `chapterDisplayContent``typesetAttributedString` 的一次性拷贝)
- `displaySignature`(组成:主题、暗黑图配置、字体设置、本章高亮集合版本)
- [ ] 构建共享内容时一次性注入:全章范围主题色;全章范围高亮/下划线属性(若 P1-2 决定转 overlay 则此项取消)
- [ ] 确定暗黑图策略(二选一):
- a. 构建时全章一次性替换(依赖 `RDEPUBDarkImageAdjuster` 的 NSCache 兜底解码成本)
- b. 移到渲染时按可见附件替换,不触碰共享字符串
- [ ] `RDEPUBTextContentView.configure` 改为接收"共享内容引用 + `page.contentRange`",删除 443 行的整章拷贝
- [ ] 高亮增删、搜索激活、主题/设置变化 → 比对 `displaySignature`,不一致则重建共享内容(禁止原地修改)
- [ ] 章节淘汰(`RDEPUBChapterRuntimeStore.evict` / `handleMemoryWarning` / `invalidateAllForSettingsChange`)同步释放 display cache
- [x] 已新建 `RDEPUBChapterDisplayContentCache`LRU 容量 2主线程限定`RDEPUBReaderController` 持有):`content` + `layouter` + `signature`
- [x] 构建共享内容时一次性注入:全章范围主题色;高亮/下划线因 P1-2 转 overlay不进共享内容也不进签名
- [x] 暗黑图策略选 a构建时全章一次性替换复用 `RDEPUBDarkImageAdjuster` 的 NSCache
- [x] `RDEPUBTextContentView.configure` 已改为接收 `displayCache` 参数并引用共享 entry整章拷贝已删除
- [x] 签名 = 章节内容对象标识 + 长度 + 主题双色 + 暗黑图配置;设置/主题变化经签名自动失效重建(高亮变化不触发重建,仅重建 overlay 装饰)
- [x] 控制器在 `didReceiveMemoryWarning` 清空 display cache设置变更重建 chapterContent 实例后由签名兜底换代
**验收**连续翻页不再出现整章拷贝打点确认翻页延迟下降高亮显示、高亮点击菜单、underline、搜索 currentMatch、附件点击、选区拖拽全部回归通过。
> 2026-07-08 回归记录:高亮/批注/选区/翻页/设置 13 项 UI 用例全过SearchTests 10 项失败,但已在会话前提交 5a41066 与 P0 提交 e4e629a 上复现同样失败(搜索 0 命中),确认为先于本优化存在的独立回归(最后一次已知通过是 2026-06-08 全量跑),需单独排查,与 P1 改动无关。
### P1-2 高亮/下划线剥离为 overlay与 P1-1 绑定)
**现状**:搜索高亮已走 overlay`RDEPUBTextContentView.swift:759` 处 `buildDecorations``highlights: []`,注释明确为避免双画);普通高亮/下划线仍靠写 `kRDEPUBHighlightAttributeName` / `kRDEPUBUnderlineAttributeName` 属性由 render view 绘制。
**任务**
- [ ] `buildDecorations` 改为传入真实 highlights高亮/下划线由 decoration 层绘制
- [ ] 移除 `applyHighlightsToContent``RDEPUBTextContentView.swift:617`)及 render view 对两个自定义属性的绘制路径,确认无双画
- [ ] 高亮命中(`highlight(at:)`与菜单锚点继续走 `interactionController.selectionRects`,坐标保持 chapter-absolute
- [x] `buildDecorations` 已传入真实 highlights高亮进背景层文字下方、下划线进前景层
- [x] `applyHighlightsToContent` 与 render view 的 `drawHighlights`/`computeHighlightRects`/`attributedDisplayContent` 已整体移除,无双画
- [x] 高亮命中与菜单锚点继续走 `interactionController.selectionRects`,坐标保持 chapter-absolute(未改动)
**验收**:高亮/下划线视觉与改前一致(含跨页高亮的页内裁剪);高亮点击弹菜单正常;与搜索高亮叠加时无双画。
@ -92,10 +90,10 @@
**任务**
- [ ] `DTCoreTextLayouter``chapterDisplayContent` 一起放入章节级 display cache与 signature 同生命周期
- [ ] 页面按 `bounds` + `page.contentRange``layoutFrame``RDEPUBTextPageVerticalJustifier.justify` 会修改 layoutFrame因此 **保持 `shouldCacheLayoutFrames = false`**layoutFrame 永远按页新建、不共享
- [ ] bounds 变化(横竖屏/双页)纳入失效条件;确认旧 layoutFrame 随页面视图释放
- [ ] `RDEPUBTextPageBoundaryValidator` 校验路径不变
- [x] `DTCoreTextLayouter` 已随共享内容放入 display cache与 signature 同生命周期
- [x] 页面按 `bounds` + `page.contentRange``layoutFrame``shouldCacheLayoutFrames = false` 保持layoutFrame 按页新建VerticalJustifier 的修改不会污染共享对象)
- [x] framesetter 只依赖字符串、与 bounds 无关bounds 变化无需失效共享对象;layoutFrame 随页面视图释放
- [x] `RDEPUBTextPageBoundaryValidator` 校验路径不变
**验收**:翻页时不再重建 framesetter打点确认横竖屏切换后布局正确`--demo-pagination-validate` 不回升。

File diff suppressed because it is too large Load Diff

View File

@ -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
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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
)

View File

@ -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)
}
}