ReadViewSDK/.planning/phases/08-pagination-quality-cache-performance/08-RESEARCH.md

26 KiB

Phase 8: 分页质量、缓存与性能采样 - Research

Researched: 2026-05-23 Domain: iOS/Swift EPUB reader - pagination caching, quality improvement, performance sampling Confidence: HIGH

Summary

Phase 8 builds on Phase 6 (page geometry) and Phase 7 (custom attribute closure) to address three concerns: pagination caching, pagination quality for complex image-heavy chapters, and performance diagnostics.

Critical finding: Several capabilities assumed to be "not yet integrated" in CONTEXT.md and ARCHITECTURE-CONTEXT.md are actually already implemented:

  • RDEPUBTextBookBuilder.build() already calls content.rd_paginatedFrames(size:) (not ss_pageRanges). The integration is DONE.
  • CSS <link> inlining is already implemented via inlineLinkedStyleSheets() in RDEPUBTextRendererSupport.
  • The 5-layer CSS cascade is already implemented via makeStyleSheetLayers() producing RDEPUBTextStyleSheetPackage layers.
  • RDEPUBTextPage.metadata is already populated with RDEPUBTextPageMetadata from frame.metadata.

What actually needs building:

  1. Pagination cache (QUAL-01): Serialize RDEPUBTextBook to disk, keyed by layout parameters
  2. Image/attachment rules (QUAL-03): Formalize sizing, dark mode, and page-break-around-image rules
  3. Performance sampling (QUAL-04): Instrument render/paginate/build timings
  4. Pagination quality (QUAL-02): Implement avoidPageBreakInside line-removal, orphan/widow control, image fit enforcement

Primary recommendation: Focus Phase 8 on cache serialization (the largest new code), performance instrumentation (straightforward), and incremental pagination quality improvements. Do NOT re-implement CSS <link> inlining or 5-layer cascade -- they already work.

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Pagination cache storage API / Backend (file system) -- Cache is local disk persistence, no UI involvement
Cache key generation API / Backend -- Pure computation from layout parameters
Performance timing API / Backend -- CFAbsoluteTimeGetCurrent at build/render points
Image sizing rules API / Backend (renderer) Browser/Client (display) Rules applied during DTCoreText rendering
avoidPageBreakInside enforcement API / Backend (layouter) -- CoreText line-level calculation
Orphan/widow control API / Backend (layouter) -- CoreText line-level calculation
Pagination diagnostic output API / Backend Browser/Client (logging) Diagnostic data flows to console/log

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01 [P0]: Integrate RDEPUBTextLayouter into RDEPUBTextBookBuilder -- ALREADY DONE (see finding above)
  • D-02 [P0]: Expose metadata: RDEPUBTextPageMetadata on RDEPUBTextPage -- ALREADY DONE
  • D-03 [P0]: Pagination cache by bookID + fontSize + lineHeightMultiple + contentInsets, cache full RDEPUBTextBook to disk
  • D-04 [P0]: CSS <link> inline processing -- ALREADY DONE
  • D-05 [P0]: 5-layer CSS cascade -- ALREADY DONE
  • D-06 [P1]: Image/attachment processing rules with diagnostics
  • D-07 [P1]: Performance sampling -- output stable sampling data

Claude's Discretion

  • Cache storage format (file system plist/JSON vs SQLite) -- implementer decides
  • CSS cascade replace.css content -- reference WXRead but don't copy exactly
  • Performance sampling threshold values -- determine at implementation time

Deferred Ideas (OUT OF SCOPE)

  • CoreText direct drawing migration (v1.2/v2.0)
  • Font system (embedded font set, future version)
  • Character-level position precision (P2)
  • Chapter data model cohesion (P2)
  • TTS / DRM / Pencil / multi-column layout (P3)

<phase_requirements>

Phase Requirements

ID Description Research Support
QUAL-01 Cache mechanism for layout frames/pagination results to avoid redundant full-layout Cache key design from WXRead WRChapterPageCount.currentCacheKeyWithBookId:, serialization strategy for RDEPUBTextBook
QUAL-02 Improve pagination quality for complex image-heavy chapters -- reduce bad breaks, orphan/widow lines, image whitespace issues, code/table truncation WXRead avoidPageBreakInsideByRemovingLastLinesIfNeeded pattern, WRCoreTextLayoutConfig widow/orphan settings
QUAL-03 Image/attachment sizing rules, dark mode adaptation, page background info with verifiable rules WXRead wr-vertical-center-style, DTPageBreakInsideAvoid, existing prepareHTMLElementForReaderRendering
QUAL-04 No significant degradation of first-screen time, repagination time, or interaction fluidity; output stable diagnostic/sampling data Performance sampling points at render/paginate/build, CFAbsoluteTimeGetCurrent pattern
</phase_requirements>

Standard Stack

Core (no new external dependencies)

This phase uses only existing project dependencies and Apple frameworks:

Library Version Purpose Why Standard
Foundation iOS SDK NSKeyedArchiver, JSONEncoder, FileManager System framework, no alternative
CoreText iOS SDK CTFramesetter, CTFrame line inspection Already used by RDEPUBTextLayouter
DTCoreText (existing) HTML-to-NSAttributedString Already integrated
CryptoKit / CommonCrypto iOS SDK SHA256 for cache key hashing System framework

Supporting

Library Version Purpose When to Use
os.signpost iOS 15+ Performance instrumentation with Instruments integration If structured performance logging desired

Alternatives Considered

Instead of Could Use Tradeoff
NSKeyedArchiver for cache JSONEncoder JSON is human-readable but can't handle NSAttributedString natively; NSKeyedArchiver handles it but produces opaque binary
File system cache SQLite File system is simpler for whole-object caching; SQLite adds complexity for no gain when caching complete objects
SHA256 cache key Simple string concatenation SHA256 produces fixed-length keys safe for filenames; raw string could exceed filesystem limits

Installation: No new packages needed.

Package Legitimacy Audit

No external packages are installed in this phase. All dependencies are existing project dependencies or Apple system frameworks.

Package Registry Age Downloads Source Repo slopcheck Disposition
(none) -- -- -- -- -- N/A

Architecture Patterns

Current Data Flow (already working)

RDEPUBReaderController.paginate(restoreLocation:)
  -> RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:)
    -> for each spine item:
      -> RDEPUBTextRendererSupport.makeChapterRenderRequest(...)
        -> inlineLinkedStyleSheets()          [CSS <link> inlining - DONE]
        -> makeStyleSheetLayers()             [5-layer cascade - DONE]
        -> injectPaginationSemanticMarkers()  [semantic markers - DONE]
      -> renderer.renderChapter(request:)     [DTCoreText render]
      -> content.rd_paginatedFrames(size:)    [4-level semantic pagination - DONE]
      -> RDEPUBTextPage with metadata         [metadata exposure - DONE]
    -> RDEPUBTextBook(chapters:, pages:)
  -> applyTextBook(restoreLocation:)
RDEPUBReaderController.paginate(restoreLocation:)
  -> compute cacheKey from bookID + layout params
  -> if cached RDEPUBTextBook exists for key:
       -> applyTextBook(cachedBook, restoreLocation:)   [CACHE HIT - skip build]
  -> else:
       -> builder.build(...)                             [existing flow]
       -> save RDEPUBTextBook to cache                   [CACHE WRITE]
       -> applyTextBook(textBook, restoreLocation:)
Sources/RDReaderView/EPUBTextRendering/
├── RDEPUBTextBookBuilder.swift        # existing - add cache integration
├── RDEPUBTextBookCache.swift          # NEW - cache key, read/write, eviction
├── RDEPUBTextLayouter.swift           # existing - add avoidPageBreakInside enforcement
├── RDEPUBTextLayoutFrame.swift        # existing - no changes needed
├── RDEPUBTextPaginationSupport.swift  # existing - no changes needed
├── RDEPUBTextRenderer.swift           # existing - no changes needed
├── RDEPUBTextRendererSupport.swift    # existing - add image rule formalization
├── RDEPUBDTCoreTextRenderer.swift     # existing - no changes needed
├── RDEPUBTextPerformanceSampler.swift # NEW - timing instrumentation
└── RDPlainTextBookBuilder.swift       # existing - no changes needed

Pattern 1: Cache Key Generation

What: Generate a deterministic cache key from layout parameters that change pagination results. When to use: Before every builder.build() call. Example:

// Reference: WXRead WRChapterPageCount.currentCacheKeyWithBookId:
// WXRead encodes: bookId + fontSize + lineSpacing + pageWidth + pageHeight

struct RDEPUBTextBookCacheKey {
    let bookID: String
    let fontSize: CGFloat
    let lineHeightMultiple: CGFloat
    let contentInsets: UIEdgeInsets
    let pageSize: CGSize

    var stringValue: String {
        let raw = "\(bookID)|\(fontSize)|\(lineHeightMultiple)|\(contentInsets.top)|\(contentInsets.left)|\(contentInsets.bottom)|\(contentInsets.right)|\(pageSize.width)|\(pageSize.height)"
        // SHA256 for safe filename
        let data = Data(raw.utf8)
        let hash = SHA256.hash(data: data)
        return hash.map { String(format: "%02x", $0) }.joined()
    }
}

Source: [VERIFIED: Doc/WXRead/decompiled/WRChapterPageCount.m lines 64-90]

Pattern 2: avoidPageBreakInside Line Removal

What: When a block marked avoidPageBreakInside would cross a page boundary, remove trailing lines from the current page to push the entire block to the next page. When to use: During RDEPUBTextLayouter.adjustedRange() after the 4-level semantic break detection. Example:

// Reference: WXRead WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded
// If the proposed page end falls inside an avoidPageBreakInside block:
//   1. Find the block's start location
//   2. If block start >= minimumEnd, break at block start
//   3. If block start < minimumEnd (block is too large), fall through to frameLimit

Source: [VERIFIED: Doc/WXRead/decompiled/WRCoreTextLayoutFrame.h line 280]

Pattern 3: Performance Sampling

What: Measure wall-clock time for render, paginate, and full-build operations. When to use: At the entry/exit of builder.build(), renderer.renderChapter(), and content.rd_paginatedFrames(). Example:

struct RDEPUBTextPerformanceSample {
    var chapterHref: String
    var renderDuration: TimeInterval    // DTHTMLAttributedStringBuilder
    var paginateDuration: TimeInterval  // CTFramesetter pagination
    var totalDuration: TimeInterval     // end-to-end
    var attributedStringLength: Int
    var pageCount: Int
    var cacheHit: Bool
}

// Measurement pattern:
let start = CFAbsoluteTimeGetCurrent()
// ... operation ...
let duration = CFAbsoluteTimeGetCurrent() - start

Source: [ASSUMED] -- standard iOS performance measurement pattern

Anti-Patterns to Avoid

  • Storing NSAttributedString via JSONEncoder: NSAttributedString is not Codable. Use NSKeyedArchiver or store the raw HTML + re-render parameters instead.
  • Cache key that includes theme colors: Theme (dark/light) changes CSS output but the pagination structure (NSRanges) is the same for the same font/size/insets. However, the attributed string content differs. Cache should store the full RDEPUBTextBook including styled content, so theme must be part of the key OR cache should store structure only and re-render content.
  • Measuring on main thread: All build/paginate work happens on DispatchQueue.global(qos: .userInitiated). Timing must be captured there, not dispatched back to main.
  • Infinite cache growth: Must implement eviction (LRU or size-based) to prevent disk space issues.

Don't Hand-Roll

Problem Don't Build Use Instead Why
SHA256 hashing Custom hash function CryptoKit or CommonCrypto Cryptographic correctness, no collision risk
File system cache directory Custom path logic FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) Standard iOS cache location, auto-managed by OS
Thread-safe cache access Manual locks DispatchQueue or NSLock Standard concurrency pattern
Orphan/widow detection Line counting heuristics CTFrame line origins + paragraph range analysis CoreText provides exact line positions

Key insight: The hardest part of this phase is NOT the algorithms -- it's serializing RDEPUBTextBook which contains NSAttributedString (not Codable) and NSRange (not natively Codable in Swift). The serialization strategy is the most consequential design decision.

Common Pitfalls

Pitfall 1: NSAttributedString Serialization

What goes wrong: Attempting to JSONEncode an RDEPUBTextBook fails silently because NSAttributedString and NSRange are not Codable. Why it happens: RDEPUBTextPage.content is NSAttributedString, and RDEPUBTextPageMetadata contains [NSRange] fields. How to avoid: Use NSKeyedArchiver for the whole object, OR decompose into Codable parts (store HTML + parameters, re-render on load). The second approach is more resilient to code changes but slower on cache load. Warning signs: JSONEncoder.encode() returns nil or throws.

Pitfall 2: Cache Invalidation on Code Changes

What goes wrong: After updating the pagination engine code, cached results are stale but the cache key hasn't changed. Why it happens: Cache key only encodes user-facing parameters (font, size, insets), not code version. How to avoid: Include a schema version number in the cache key. Bump the version when pagination logic changes. Warning signs: Pagination quality doesn't improve after code changes until cache is manually cleared.

Pitfall 3: NSRange Codable in Metadata

What goes wrong: RDEPUBTextPageMetadata conforms to Codable but contains NSRange fields (blockRange, attachmentRanges) which don't have native Codable conformance. Why it happens: NSRange is an Objective-C struct, not a Swift Codable type. How to avoid: Add Codable conformance for NSRange via an extension that encodes as {"location": Int, "length": Int}, or use NSKeyedArchiver for the whole metadata. Warning signs: Compiler error or runtime crash when encoding metadata.

Pitfall 4: Image Pages Crossing Boundaries

What goes wrong: A large image that doesn't fit on the current page causes the page to have excessive whitespace or the image gets truncated. Why it happens: CoreText treats NSTextAttachment as inline content and will break the line at the attachment, but doesn't check if the image height exceeds remaining page space. How to avoid: Before pagination, check attachment heights against remaining page space. If an image won't fit, break the page before the image paragraph. Warning signs: Pages with only 1-2 lines of text followed by a large gap.

Pitfall 5: Cache Thread Safety

What goes wrong: Concurrent reads/writes to the cache directory cause file corruption or crashes. Why it happens: builder.build() runs on a background queue, and multiple paginate() calls can overlap (pagination token pattern handles cancellation but not serialization). How to avoid: Use a serial dispatch queue for all cache operations, or use NSFileCoordinator for file-level coordination. Warning signs: Intermittent crashes on NSKeyedUnarchiver.unarchiveObject.

Code Examples

Cache Key Construction

// Based on WXRead WRChapterPageCount.currentCacheKeyWithBookId:
// Source: Doc/WXRead/decompiled/WRChapterPageCount.m lines 64-90
import CryptoKit

struct RDEPUBTextBookCacheKey: Hashable {
    let bookID: String
    let fontSize: CGFloat
    let lineHeightMultiple: CGFloat
    let contentInsets: UIEdgeInsets
    let pageSize: CGSize
    let schemaVersion: Int  // bump when pagination logic changes

    var filename: String {
        let components = [
            bookID,
            String(format: "%.1f", fontSize),
            String(format: "%.2f", lineHeightMultiple),
            String(format: "%.0f", contentInsets.top),
            String(format: "%.0f", contentInsets.left),
            String(format: "%.0f", contentInsets.bottom),
            String(format: "%.0f", contentInsets.right),
            String(format: "%.0f", pageSize.width),
            String(format: "%.0f", pageSize.height),
            "v\(schemaVersion)"
        ]
        let raw = components.joined(separator: "_")
        let hash = SHA256.hash(data: Data(raw.utf8))
        return hash.map { String(format: "%02x", $0) }.joined() + ".cache"
    }
}

NSKeyedArchiver Serialization for RDEPUBTextBook

// NSAttributedString supports NSCoding via NSKeyedArchiver
// RDEPUBTextBook/Chapter/Page need NSCoding conformance or wrapper

// Option A: Store as NSCoding-compatible wrapper
final class RDEPUBTextBookArchive: NSObject, NSCoding {
    let chapters: [RDEPUBTextChapterArchive]

    init(chapters: [RDEPUBTextChapterArchive]) { self.chapters = chapters }

    required init?(coder: NSCoder) {
        guard let chapters = coder.decodeObject(forKey: "chapters") as? [RDEPUBTextChapterArchive] else { return nil }
        self.chapters = chapters
    }

    func encode(with coder: NSCoder) {
        coder.encode(chapters, forKey: "chapters")
    }
}

// Each RDEPUBTextChapterArchive stores:
//   - attributedContent as NSData (NSKeyedArchiver)
//   - page ranges as [[location, length]]
//   - metadata as JSON Data

Performance Sample Collection

// No existing pattern in codebase -- standard iOS approach
final class RDEPUBTextPerformanceSampler {
    struct Sample {
        let chapterHref: String
        let renderDuration: TimeInterval
        let paginateDuration: TimeInterval
        let pageCount: Int
        let attributedStringLength: Int
        let cacheHit: Bool
    }

    private(set) var samples: [Sample] = []

    func record(_ sample: Sample) {
        samples.append(sample)
        print("[PERF] \(sample.chapterHref): render=\(String(format: "%.1f", sample.renderDuration*1000))ms paginate=\(String(format: "%.1f", sample.paginateDuration*1000))ms pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
    }

    func summary() -> String {
        let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
        let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
        let hitRate = samples.filter(\.cacheHit).count
        return "[PERF] chapters=\(samples.count) render=\(String(format: "%.0f", totalRender*1000))ms paginate=\(String(format: "%.0f", totalPaginate*1000))ms cacheHits=\(hitRate)/\(samples.count)"
    }
}

State of the Art

Old Approach (assumed) Current Approach (actual) When Changed Impact
ss_pageRanges for pagination rd_paginatedFrames via RDEPUBTextLayouter Phase 7 4-level semantic break engine already active
No CSS <link> handling inlineLinkedStyleSheets() in renderer support Phase 7 or earlier EPUB external stylesheets already resolved
Flat CSS injection 5-layer cascade via RDEPUBTextStyleSheetPackage Phase 7 default/replace/dark/epub/user layers already built
No page metadata RDEPUBTextPageMetadata on every page Phase 7 breakReason, blockKinds, semanticHints already available

Deprecated/outdated in CONTEXT.md:

  • D-01 (integrate layouter): Already done. RDEPUBTextBookBuilder line 196 calls content.rd_paginatedFrames(size:).
  • D-02 (expose metadata): Already done. RDEPUBTextPage.metadata is populated from frame.metadata.
  • D-04 (CSS <link> inline): Already done. RDEPUBTextRendererSupport.inlineLinkedStyleSheets() handles this.
  • D-05 (5-layer cascade): Already done. makeStyleSheetLayers() produces the 5-layer package.

Assumptions Log

# Claim Section Risk if Wrong
A1 NSKeyedArchiver can serialize NSAttributedString with DTCoreText custom attributes Cache serialization Cache would fail silently; fallback to re-render every time
A2 CFAbsoluteTimeGetCurrent() has sufficient precision for millisecond-level timing Performance sampling Sub-millisecond operations may show as 0ms
A3 Cache key schema version bump is sufficient for invalidation on code changes Cache invalidation Stale cache could persist if version is forgotten
A4 CTFrame line origins can detect orphan/widow conditions Pagination quality Orphan/widow control would need different approach

Open Questions (RESOLVED)

  1. Cache storage format: NSKeyedArchiver vs decomposed JSON? RESOLVED

    • Decision: Use NSKeyedArchiver first. Create private NSCoding wrapper classes for RDEPUBTextBook/RDEPUBTextChapter/RDEPUBTextPage/RDEPUBTextPageMetadata.
    • Rationale: NSAttributedString and NSRange both support NSCoding natively. DTCoreText custom attributes are standard NSAttributedString attribute keys (string-typed) and survive archiver round-trip.
    • Fallback: If runtime test reveals attribute loss, fall back to decomposed JSON (store HTML + render parameters, re-render on cache load).
  2. Should cache store full attributed content or just page ranges? RESOLVED

    • Decision: Cache full RDEPUBTextBook including NSAttributedString per page (matches D-01 decision in CONTEXT.md).
    • Rationale: Re-slicing on load would negate the cache performance benefit. Memory pressure is manageable for typical EPUB books (< 500 pages).
    • Mitigation: Cache eviction on memory warning (didReceiveMemoryWarning notification).
  3. Orphan/widow control: how aggressive? RESOLVED

    • Decision: Add RDEPUBTextLayoutConfig struct with configurable thresholds (avoidOrphans: Bool = true, avoidWidows: Bool = true), defaulting to enabled.
    • Rationale: Follows the same pattern as RDEPUBTextRenderStyle (public struct + Equatable + explicit init). Hard-coding would prevent future tuning.
    • WXRead reference: WRCoreTextLayoutConfig uses the same configurable approach.

Environment Availability

Dependency Required By Available Version Fallback
Xcode / iOS SDK Build & run (system) --
DTCoreText HTML rendering (existing dependency) --
CoreText Pagination (system framework) --
CryptoKit SHA256 for cache key iOS 13+ CommonCrypto
FileManager Cache directory (system framework) --

Missing dependencies with no fallback: None

Missing dependencies with fallback: None

Validation Architecture

Test Framework

Property Value
Framework None detected -- no test target in project
Config file none
Quick run command N/A
Full suite command N/A

Phase Requirements -> Test Map

Req ID Behavior Test Type Automated Command File Exists?
QUAL-01 Cache hit skips rebuild manual/runtime Print cache HIT/MISS in console N/A
QUAL-02 avoidPageBreakInside enforced manual/runtime Inspect page diagnostics for semanticBoundary breaks N/A
QUAL-03 Image sizing rules applied manual/runtime Verify image dimensions in diagnostic output N/A
QUAL-04 Performance samples output manual/runtime Check console for [PERF] lines N/A

Sampling Rate

  • Per task commit: Build succeeds (build_sim)
  • Per wave merge: Runtime log includes [PERF] summary and cache HIT/MISS
  • Phase gate: Full book builds with cache, second open shows HIT; complex chapter pagination quality visibly improved

Wave 0 Gaps

  • No test framework exists. All verification is manual/runtime via console output and simulator build.
  • If automated tests desired, would need to create test target first (out of scope for Phase 8).

Security Domain

No new security concerns introduced. Cache files are stored in the app's caches directory (sandboxed). No network requests, no user data exposure.

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication no N/A
V3 Session Management no N/A
V4 Access Control no N/A
V5 Input Validation no N/A
V6 Cryptography no SHA256 is one-way hash for cache key, not security-sensitive

Sources

Primary (HIGH confidence)

  • Project source code: Sources/RDReaderView/EPUBTextRendering/*.swift -- direct code inspection
  • Doc/WXRead/decompiled/WRChapterPageCount.h/.m -- cache key pattern reference
  • Doc/WXRead/decompiled/WRCoreTextLayouter.h -- layout config reference
  • Doc/WXRead/decompiled/WRCoreTextLayoutFrame.h/.m -- avoidPageBreakInside reference
  • Doc/WXRead/resources/css/replace.css -- CSS rule reference

Secondary (MEDIUM confidence)

  • Doc/架构对比分析_WXRead_vs_ReadViewSDK.md -- gap analysis (some gaps already closed)

Tertiary (LOW confidence)

  • A1 (NSKeyedArchiver + DTCoreText attributes): Needs runtime verification

Metadata

Confidence breakdown:

  • Standard stack: HIGH -- no new dependencies, all system/existing frameworks
  • Architecture: HIGH -- integration points clearly visible in code
  • Pitfalls: HIGH -- serialization issues are well-understood Cocoa patterns
  • Cache design: MEDIUM -- NSKeyedArchiver with DTCoreText custom attributes needs runtime test

Research date: 2026-05-23 Valid until: 2026-06-23 (30 days -- stable codebase, incremental changes)