长章节内存优化 P0:每页子串改按需构造并新增内存打点

- RDEPUBTextPage.content 由存储属性改为基于 chapterContent+contentRange
  的计算属性,三处构造点不再生成每页子串,章节缓存窗口内每章少一份
  整章文本常驻副本
- 新增 RDEPUBMemoryProbe(--demo-memory-probe 开启),在章节插入、
  每 20 次翻页、设置失效、转屏完成时输出 phys_footprint
- shouldAvoidReaderPageCaching 补注释,锁定放开前置条件为 P1-1
- 新增实施清单文档 LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md 并勾选 P0

验证:Demo 编译通过;UI 回归 9/9 通过(PageNavigation/
ReaderOpenClose/LargeBookOnDemand)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-07-08 15:34:45 +09:00
co-authored by Claude Fable 5
parent 5a41066b66
commit e4e629a06c
12 changed files with 1475 additions and 1258 deletions
@@ -291,7 +291,6 @@ public final class RDEPUBTextBookBuilder {
pageIndexInChapter: localPageIndex,
totalPagesInChapter: effectiveFrames.count,
chapterContent: chapterAttributedContent,
content: content.attributedSubstring(from: range),
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + max(range.length - 1, 0),
@@ -54,7 +54,15 @@ public struct RDEPUBTextPage: Equatable {
public var chapterContent: NSAttributedString
public var content: NSAttributedString
/// Page substring derived on demand from `chapterContent` + `contentRange`.
/// Not stored: keeping a per-page substring alive roughly doubles the
/// chapter's resident text memory across the chapter cache window.
public var content: NSAttributedString {
let bounds = NSRange(location: 0, length: chapterContent.length)
let clamped = NSIntersectionRange(contentRange, bounds)
guard clamped.length > 0 else { return NSAttributedString() }
return chapterContent.attributedSubstring(from: clamped)
}
public var contentRange: NSRange
@@ -75,7 +75,6 @@ public final class RDPlainTextBookBuilder {
pageIndexInChapter: localPageIndex,
totalPagesInChapter: effectiveFrames.count,
chapterContent: chapterAttributedContent,
content: content.attributedSubstring(from: range),
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + max(range.length - 1, 0),
@@ -345,6 +345,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
}
public func pageNum(readerView: RDReaderView, pageNum: Int) {
RDEPUBMemoryProbe.logPageTurn()
readerContext.markUserNavigationActivity()
updateCurrentSelection(nil)
reconcileTextPaginationSizeIfNeeded(for: pageNum)
@@ -297,6 +297,9 @@ public final class RDEPUBReaderController: UIViewController {
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
runtime.viewportMonitor.viewWillTransition(with: coordinator)
coordinator.animate(alongsideTransition: nil) { _ in
RDEPUBMemoryProbe.log("orientationTransition size=\(Int(size.width))x\(Int(size.height))")
}
}
func showSearchBar() {
@@ -491,7 +491,6 @@ final class RDEPUBChapterLoader {
) -> [RDEPUBTextPage] {
let totalPageCount = pageRanges.count
return pageRanges.enumerated().map { (pageIndex, range) in
let pageContent = typesetString.attributedSubstring(from: range)
let metadata: RDEPUBTextPageMetadata
if let metaList = metadataSource, pageIndex < metaList.count {
@@ -513,7 +512,6 @@ final class RDEPUBChapterLoader {
pageIndexInChapter: pageIndex,
totalPagesInChapter: totalPageCount,
chapterContent: typesetString,
content: pageContent,
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + range.length - 1,
@@ -62,6 +62,7 @@ final class RDEPUBChapterRuntimeStore {
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
chapterDataCache[chapter.spineIndex] = chapter
RDEPUBMemoryProbe.log("chapterLoaded spine=\(chapter.spineIndex) pages=\(chapter.pages.count)")
}
func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) {
@@ -188,6 +189,7 @@ final class RDEPUBChapterRuntimeStore {
}
func invalidateAllForSettingsChange() {
RDEPUBMemoryProbe.log("settingsChangeInvalidateAll")
chapterDataCache.removeAll()
pageCountCache.removeAll()
imageCache.removeAllObjects()
@@ -0,0 +1,43 @@
import Foundation
/// Memory footprint probe for the long-chapter optimization work
/// (Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md, P0-2). Enabled with the
/// `--demo-memory-probe` launch argument; logs phys_footprint at chapter
/// load, every 20 page turns, settings invalidation, and rotation.
enum RDEPUBMemoryProbe {
static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-memory-probe")
private static let pageTurnLogStride = 20
/// Main-thread only (page turns are delivered on main).
private static var pageTurnCount = 0
static func logPageTurn() {
guard isEnabled else { return }
pageTurnCount += 1
guard pageTurnCount % pageTurnLogStride == 0 else { return }
log("pageTurn count=\(pageTurnCount)")
}
static func log(_ event: String) {
guard isEnabled else { return }
let megabytes = Double(currentFootprint()) / 1_048_576
print(String(format: "[EPUB][MemoryProbe] %@ footprint=%.1fMB", event, megabytes))
}
/// phys_footprint matches the value Xcode's memory gauge and Jetsam use.
private static func currentFootprint() -> UInt64 {
var info = task_vm_info_data_t()
var count = mach_msg_type_number_t(
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size
)
let result = withUnsafeMutablePointer(to: &info) { pointer in
pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
}
}
guard result == KERN_SUCCESS else { return 0 }
return info.phys_footprint
}
}
@@ -322,6 +322,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
// layouter alive for the visible page. Avoid reader-level page
// caching so preloading does not retain several full-chapter
// display copies at once.
// Do NOT relax this until chapter-level shared display content
// lands (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1): cached
// pages would each pin their own full-chapter copy again.
return currentPage == nil
|| loadingSpinner.isAnimating
|| coreTextDisplayContent != nil