- 新增 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>
115 lines
3.8 KiB
Swift
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
|
|
}
|