ReadViewSDK/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.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

214 lines
6.3 KiB
Swift

import UIKit
#if canImport(DTCoreText)
import DTCoreText
final class RDEPUBTextPageRenderView: UIView {
enum SelectionHandle {
case start
case end
}
var layoutFrame: DTCoreTextLayoutFrame? {
didSet {
invalidateStaticContent()
}
}
var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
didSet {
invalidateStaticContent()
}
}
var selectionRects: [CGRect] = [] {
didSet {
setNeedsDisplay()
}
}
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24)
private let selectionHandleColor = UIColor(red: 20 / 255, green: 122 / 255, blue: 1, alpha: 1)
private let selectionHandleStemWidth: CGFloat = 2.5
private let selectionHandleKnobRadius: CGFloat = 7
private let selectionHandleHitSlop: CGFloat = 20
private var cachedStaticImage: UIImage?
private var cachedStaticBoundsSize: CGSize = .zero
private var needsStaticContentRedraw = true
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
contentMode = .redraw
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(),
let layoutFrame else { return }
context.saveGState()
if cachedStaticImage == nil
|| cachedStaticBoundsSize != bounds.size
|| needsStaticContentRedraw {
cachedStaticImage = renderStaticImage(layoutFrame: layoutFrame)
cachedStaticBoundsSize = bounds.size
needsStaticContentRedraw = false
}
if let cachedStaticImage {
cachedStaticImage.draw(in: bounds)
} else {
layoutFrame.draw(in: context, options: drawOptions)
}
drawSelection(in: context)
context.restoreGState()
}
override func layoutSubviews() {
super.layoutSubviews()
if cachedStaticBoundsSize != bounds.size {
invalidateStaticContent()
}
}
private func drawSelection(in context: CGContext) {
guard !selectionRects.isEmpty else { return }
selectionColor.setFill()
for rect in selectionRects {
context.fill(rect)
}
drawSelectionHandles(in: context)
}
func selectionHandle(at point: CGPoint) -> SelectionHandle? {
guard let handleGeometry = selectionHandleGeometry else { return nil }
let startDistance = point.distance(to: handleGeometry.startKnobCenter)
let endDistance = point.distance(to: handleGeometry.endKnobCenter)
let maxDistance = selectionHandleKnobRadius + selectionHandleHitSlop
let startMatched = startDistance <= maxDistance
let endMatched = endDistance <= maxDistance
switch (startMatched, endMatched) {
case (true, true):
return startDistance <= endDistance ? .start : .end
case (true, false):
return .start
case (false, true):
return .end
default:
return nil
}
}
func selectionContains(_ point: CGPoint) -> Bool {
selectionRects.contains { rect in
rect.insetBy(dx: -6, dy: -8).contains(point)
}
}
private var selectionHandleGeometry: (startKnobCenter: CGPoint, endKnobCenter: CGPoint)? {
guard let firstRect = selectionRects.first,
let lastRect = selectionRects.last else {
return nil
}
let startKnobCenter = CGPoint(
x: firstRect.minX,
y: firstRect.minY - selectionHandleKnobRadius
)
let endKnobCenter = CGPoint(
x: lastRect.maxX,
y: lastRect.maxY + selectionHandleKnobRadius
)
return (startKnobCenter: startKnobCenter, endKnobCenter: endKnobCenter)
}
private func drawSelectionHandles(in context: CGContext) {
guard let firstRect = selectionRects.first,
let lastRect = selectionRects.last else {
return
}
context.saveGState()
context.setFillColor(selectionHandleColor.cgColor)
let stemHalfWidth = selectionHandleStemWidth / 2
let startStem = CGRect(
x: firstRect.minX - stemHalfWidth,
y: firstRect.minY - selectionHandleKnobRadius * 2,
width: selectionHandleStemWidth,
height: firstRect.height + selectionHandleKnobRadius * 2
)
context.fill(startStem)
let startKnob = CGRect(
x: firstRect.minX - selectionHandleKnobRadius,
y: firstRect.minY - selectionHandleKnobRadius * 2,
width: selectionHandleKnobRadius * 2,
height: selectionHandleKnobRadius * 2
)
context.fillEllipse(in: startKnob)
let endStem = CGRect(
x: lastRect.maxX - stemHalfWidth,
y: lastRect.minY,
width: selectionHandleStemWidth,
height: lastRect.height + selectionHandleKnobRadius * 2
)
context.fill(endStem)
let endKnob = CGRect(
x: lastRect.maxX - selectionHandleKnobRadius,
y: lastRect.maxY,
width: selectionHandleKnobRadius * 2,
height: selectionHandleKnobRadius * 2
)
context.fillEllipse(in: endKnob)
context.restoreGState()
}
private func invalidateStaticContent() {
cachedStaticImage = nil
needsStaticContentRedraw = true
setNeedsDisplay()
}
private func renderStaticImage(layoutFrame: DTCoreTextLayoutFrame) -> UIImage? {
guard bounds.width > 0, bounds.height > 0 else { return nil }
let format = UIGraphicsImageRendererFormat.default()
format.opaque = false
let renderer = UIGraphicsImageRenderer(size: bounds.size, format: format)
return renderer.image { _ in
guard let staticContext = UIGraphicsGetCurrentContext() else { return }
layoutFrame.draw(in: staticContext, options: drawOptions)
}
}
}
private extension CGPoint {
func distance(to point: CGPoint) -> CGFloat {
hypot(x - point.x, y - point.y)
}
}
#endif