ReadViewSDK/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift
shenlei e4e629a06c 长章节内存优化 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>
2026-07-08 15:34:45 +09:00

441 lines
19 KiB
Swift

import UIKit
extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDReaderDelegate {
public func numberOfPages(in readerView: RDReaderView) -> Int {
pageCountOfReaderView(readerView: readerView)
}
public func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView {
pageContentView(readerView: readerView, pageNum: index, containerView: reusableView)
}
public func pageIdentifier(in readerView: RDReaderView, index: Int) -> String? {
pageIdentifier(readerView: readerView, pageNum: index)
}
public func readerViewTopChrome(_ readerView: RDReaderView) -> UIView? {
topToolView(readerView: readerView)
}
public func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView? {
bottomToolView(readerView: readerView)
}
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
}
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
if readerContext.bookPageMap != nil {
_ = runtime.prepareOnDemandChapter(
forAbsolutePageNumber: pageNum + 1,
allowSynchronousLoad: false
)
var resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum)
let shouldAllowSynchronousFallback = (
readerView.currentPage < 0 || readerView.currentPage == pageNum
) && !readerView.isPageCurlTransitioning
if resolvedPage == nil, shouldAllowSynchronousFallback {
_ = runtime.prepareOnDemandChapter(
forAbsolutePageNumber: pageNum + 1,
allowSynchronousLoad: true
)
resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum)
}
if let resolvedPage {
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.configure(
page: resolvedPage.page,
pageNumber: pageNum + 1,
totalPages: pageCountOfReaderView(readerView: readerView),
configuration: configuration,
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
highlights: textHighlights(for: resolvedPage.page),
searchState: searchState(for: resolvedPage.page)
)
return contentView
}
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.configureLoading(
pageNumber: pageNum + 1,
totalPages: pageCountOfReaderView(readerView: readerView),
configuration: configuration
)
return contentView
}
if let textBook, let page = textBook.page(at: pageNum + 1) {
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.configure(
page: page,
pageNumber: pageNum + 1,
totalPages: textBook.pages.count,
configuration: configuration,
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
highlights: textHighlights(for: page),
searchState: searchState(for: page)
)
return contentView
}
guard let publication,
let request = request(for: pageNum) else {
return containerView ?? UIView()
}
let contentView = (containerView as? RDEPUBWebContentView) ?? RDEPUBWebContentView()
contentView.delegate = self
contentView.configure(
publication: publication,
request: request,
pageNumber: pageNum + 1,
totalPages: activePages.count,
theme: configuration.theme
)
return contentView
}
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
(textBook == nil && readerContext.bookPageMap == nil)
? NSStringFromClass(RDEPUBWebContentView.self)
: NSStringFromClass(RDEPUBTextContentView.self)
}
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
if let textBook,
let chapterData = textBook.chapterData(for: page.href) {
return chapterData.highlights(on: page, from: activeHighlights)
}
guard let publication else {
return activeHighlights.filter { $0.location.href == page.href }
}
let pageHref = publication.resourceResolver.normalizedHref(page.href) ?? page.href
return activeHighlights.filter {
(publication.resourceResolver.normalizedHref($0.location.href) ?? $0.location.href) == pageHref
}
}
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
guard let globalSearchState = searchState else { return nil }
let matches: [RDEPUBSearchMatch]
let currentMatchIndex: Int?
if let chapterData = chapterData(for: page),
let resolvedState = resolvedSearchState(
for: page,
chapterData: chapterData,
globalSearchState: globalSearchState
) {
matches = resolvedState.matches
currentMatchIndex = resolvedState.currentMatchIndex
} else {
matches = globalSearchState.matches.filter { searchMatch in
searchMatchBelongsToPage(searchMatch, page: page)
}
currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
matches.firstIndex(of: currentMatch)
}
}
guard !matches.isEmpty || globalSearchState.currentMatch != nil else {
return globalSearchState.matches.isEmpty ? globalSearchState : nil
}
return RDEPUBSearchState(
keyword: globalSearchState.keyword,
matches: matches,
currentMatchIndex: currentMatchIndex
)
}
private func resolvedSearchState(
for page: RDEPUBTextPage,
chapterData: RDEPUBChapterData,
globalSearchState: RDEPUBSearchState
) -> RDEPUBSearchState? {
let normalizedKeyword = globalSearchState.keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
return globalSearchState.matches.isEmpty ? globalSearchState : nil
}
let normalizedHref = normalizedPageHref(for: page)
let exactMatches = exactChapterSearchMatches(
in: chapterData,
keyword: normalizedKeyword,
normalizedHref: normalizedHref
)
let pageMatches = exactMatches.filter { match in
guard let rangeLocation = match.rangeLocation else { return false }
let range = NSRange(location: rangeLocation, length: max(match.rangeLength, 1))
return NSIntersectionRange(range, page.contentRange).length > 0
}
let currentLocalMatchIndex = globalSearchState.currentMatch?.localMatchIndex
let currentMatchIndex = currentLocalMatchIndex.flatMap { localMatchIndex in
pageMatches.firstIndex(where: { $0.localMatchIndex == localMatchIndex })
}
guard !pageMatches.isEmpty || currentMatchIndex != nil else {
return globalSearchState.matches.isEmpty ? globalSearchState : nil
}
return RDEPUBSearchState(
keyword: globalSearchState.keyword,
matches: pageMatches,
currentMatchIndex: currentMatchIndex
)
}
private func exactChapterSearchMatches(
in chapterData: RDEPUBChapterData,
keyword: String,
normalizedHref: String
) -> [RDEPUBSearchMatch] {
let source = chapterData.attributedContent.string as NSString
let fullLength = source.length
guard fullLength > 0 else { return [] }
var matches: [RDEPUBSearchMatch] = []
var localMatchIndex = 0
var searchRange = NSRange(location: 0, length: fullLength)
while searchRange.length > 0 {
let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else { break }
let progressionDenominator = max(fullLength - 1, 1)
let progression = Double(foundRange.location) / Double(progressionDenominator)
matches.append(
RDEPUBSearchMatch(
href: normalizedHref,
progression: progression,
previewText: previewText(in: source, matchRange: foundRange),
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: max(foundRange.length, 1),
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
)
)
localMatchIndex += 1
let nextLocation = foundRange.location + max(foundRange.length, 1)
if nextLocation >= fullLength {
break
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
return matches
}
private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0)
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
let range = NSRange(location: start, length: max(end - start, 0))
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
}
private func chapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData? {
if let textBook,
let chapterData = textBook.chapterData(for: page.href) {
return chapterData
}
guard let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: page.spineIndex) else {
return nil
}
return makeChapterData(from: runtimeChapter, chapterIndex: page.chapterIndex)
}
private func makeChapterData(
from runtimeChapter: RDEPUBRuntimeChapter,
chapterIndex: Int
) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: chapterIndex,
spineIndex: runtimeChapter.spineIndex,
href: runtimeChapter.href,
title: runtimeChapter.title,
attributedContent: runtimeChapter.typesetAttributedString,
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
pages: runtimeChapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool {
if let textBook,
let chapterData = textBook.chapterData(for: page.href),
let range = chapterData.absoluteRange(for: searchMatch) {
return NSIntersectionRange(range, page.contentRange).length > 0
}
let pageHref = normalizedPageHref(for: page)
let matchHref = normalizedSearchHref(searchMatch.href)
guard pageHref == matchHref else { return false }
guard let rangeLocation = searchMatch.rangeLocation else {
return false
}
let range = NSRange(location: rangeLocation, length: searchMatch.rangeLength)
return NSIntersectionRange(range, page.contentRange).length > 0
}
private func normalizedPageHref(for page: RDEPUBTextPage) -> String {
guard let publication else { return page.href }
return publication.resourceResolver.normalizedHref(page.href) ?? page.href
}
private func normalizedSearchHref(_ href: String) -> String {
guard let publication else { return href }
return publication.resourceResolver.normalizedHref(href) ?? href
}
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
readerContext.textChapterData(forNormalizedHref: href)
}
public func topToolView(readerView: RDReaderView) -> UIView? {
topToolView
}
public func bottomToolView(readerView: RDReaderView) -> UIView? {
bottomToolView
}
public func pageNum(readerView: RDReaderView, pageNum: Int) {
RDEPUBMemoryProbe.logPageTurn()
readerContext.markUserNavigationActivity()
updateCurrentSelection(nil)
reconcileTextPaginationSizeIfNeeded(for: pageNum)
guard !isRepaginating else { return }
let previousCurrentPage = readerView.currentPage
let wasPageCurlTransitioning = readerView.isPageCurlTransitioning
runtime.applyPendingFullPageMapIfNeeded()
if wasPageCurlTransitioning {
DispatchQueue.main.async { [weak self, weak readerView] in
guard let self, let readerView, !readerView.isPageCurlTransitioning else { return }
self.runtime.applyPendingFullPageMapIfNeeded()
}
}
let effectivePageNum = readerView.currentPage >= 0 ? readerView.currentPage : pageNum
if previousCurrentPage != readerView.currentPage, effectivePageNum != pageNum {
return
}
if readerContext.bookPageMap != nil {
_ = runtime.prepareOnDemandChapter(
forAbsolutePageNumber: effectivePageNum + 1,
allowSynchronousLoad: false
)
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: effectivePageNum + 1)
}
let totalPages = pageCountOfReaderView(readerView: readerView)
if totalPages > 0, effectivePageNum == totalPages - 1 {
delegate?.epubReaderDidReachEnd(self)
}
if (textBook != nil || readerContext.bookPageMap != nil),
let location = resolvedTextLocation(forPageNumber: effectivePageNum + 1) {
persist(location: location)
synchronizeTextReadingState(pageNumber: effectivePageNum + 1, location: location)
runtime.locationCoordinator.recordPageChangeIfNeeded()
return
}
if let location = fallbackLocation(for: effectivePageNum) {
persist(location: location)
}
runtime.locationCoordinator.recordPageChangeIfNeeded()
}
public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) {
_ = isLandscape
runtime.viewportMonitor.capturePendingPresentationRestoreLocation()
}
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
guard textBook != nil || readerContext.bookPageMap != nil,
!isRepaginating,
!isReconcilingTextPaginationSize,
pageNum >= 0,
let lastTextPaginationPageSize else {
return
}
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
guard resolvedSize.width > 0,
resolvedSize.height > 0 else {
return
}
let sizeChanged = abs(resolvedSize.width - lastTextPaginationPageSize.width) > 0.5
|| abs(resolvedSize.height - lastTextPaginationPageSize.height) > 0.5
guard sizeChanged else {
return
}
isReconcilingTextPaginationSize = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
guard let self else { return }
self.isReconcilingTextPaginationSize = false
guard (self.textBook != nil || self.readerContext.bookPageMap != nil), !self.isRepaginating else { return }
let currentSize = self.readerView.resolvedSinglePageSize(pageNum: self.readerView.currentPage)
guard currentSize.width > 0, currentSize.height > 0 else { return }
guard let previousPageSize = self.lastTextPaginationPageSize else { return }
let stillChanged = abs(currentSize.width - previousPageSize.width) > 0.5
|| abs(currentSize.height - previousPageSize.height) > 0.5
guard stillChanged else { return }
self.repaginatePreservingCurrentLocation()
}
}
}