feat: configurable chapter window & parallel metadata parsing with benchmark

1. Configurable chapter window size (onDemandChapterWindowSize: 3-15)
   - Parameterized window radius in RDEPUBChapterRuntimeStore
   - Updated RDEPUBChapterWindowCoordinator to use configurable radius
   - RDEPUBChapterWindowSnapshot.from() accepts chapter array instead of fixed prev/next
   - Even numbers round up to odd (4→5), min 3, max 15

2. Configurable metadata parsing concurrency (metadataParsingConcurrency)
   - Default equals CPU core count
   - Parallel execution via OperationQueue in paginateMetadataOnly
   - Each worker creates independent builder instance
   - NSLock protects result aggregation

3. Per-chapter and total wall-clock timing instrumentation
   - Separated render vs I/O timing per chapter
   - Summary log with wallClockMs, renderTotalMs, writeTotalMs, avgRenderMs
   - Timing stored in RDEPUBReaderContext for test access

4. UI automation test infrastructure
   - Added --demo-window-size, --demo-concurrency, --demo-clear-cache launch args
   - DemoReaderState exposes windowSize, parseMs, parseConcurrency
   - ConfigurableWindowTests: 5 test cases for window size 3/5/15
   - ConcurrentParsingTests: 4 test cases for concurrency 2/4
   - MetadataParseBenchmarkTests: serial vs parallel benchmark

5. Bug fixes
   - Fixed page snap-back during background parsing (isUserInteracting check)
   - Reduced BookPageMap refresh frequency from 16 to 32 chapters
   - Moved waitForReadingInteractionToSettle outside operation loop

6. Design doc: dual-layer PageMap (estimated + precise mixed)
This commit is contained in:
shen
2026-06-03 23:38:11 +08:00
parent feb05eaf87
commit d20196ee34
17 changed files with 932 additions and 153 deletions
@@ -36,6 +36,9 @@ struct DemoReaderState {
var buildableChapters: Int? { fields["buildableChapters"].flatMap(Int.init) }
var avoidWidows: Int? { fields["avoidWidows"].flatMap(Int.init) }
var avoidOrphans: Int? { fields["avoidOrphans"].flatMap(Int.init) }
var windowSize: Int? { fields["windowSize"].flatMap(Int.init) }
var parseMs: Int? { fields["parseMs"].flatMap(Int.init) }
var parseConcurrency: Int? { fields["parseConcurrency"].flatMap(Int.init) }
}
extension XCUIApplication {
@@ -5,18 +5,30 @@ extension XCUIApplication {
bookTitleQuery: String = "回归验证样本",
displayType: String? = nil,
pageNumber: Int? = nil,
resetsReaderState: Bool = true
resetsReaderState: Bool = true,
windowSize: Int? = nil,
concurrency: Int? = nil,
clearsCache: Bool = false
) {
var args = ["--demo-book-title", bookTitleQuery]
if resetsReaderState {
args.append("--demo-reset-state")
}
if clearsCache {
args.append("--demo-clear-cache")
}
if let displayType {
args += ["--demo-display-type", displayType]
}
if let pageNumber {
args += ["--demo-page", "\(pageNumber)"]
}
if let windowSize {
args += ["--demo-window-size", "\(windowSize)"]
}
if let concurrency {
args += ["--demo-concurrency", "\(concurrency)"]
}
launchArguments = args
launch()
}
@@ -0,0 +1,87 @@
import XCTest
final class ConcurrentParsingTests: XCTestCase {
private let app = XCUIApplication()
private let largeBookQuery = "凡人修仙传"
override func setUpWithError() throws {
continueAfterFailure = false
}
func testConcurrency2ParsingCompletes() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, concurrency: 2)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 15, description: "concurrency=2 首开") { state in
state.mode == "bookPageMap" && (state.page ?? 0) >= 1
}
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "concurrency=2 应可阅读")
let completed = app.waitForDemoReaderState(timeout: 90, description: "concurrency=2 后台解析完成") { state in
state.pagination == "full"
}
XCTAssertEqual(completed.pagination, "full", "concurrency=2 后台解析应最终完成")
}
func testConcurrency4ParsingCompletes() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, concurrency: 4)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 15, description: "concurrency=4 首开") { state in
state.mode == "bookPageMap" && (state.page ?? 0) >= 1
}
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "concurrency=4 应可阅读")
let completed = app.waitForDemoReaderState(timeout: 90, description: "concurrency=4 后台解析完成") { state in
state.pagination == "full"
}
XCTAssertEqual(completed.pagination, "full", "concurrency=4 后台解析应最终完成")
}
func testConcurrentParsingProgresses() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, concurrency: 2)
app.waitForReader(timeout: 20)
let initialState = app.waitForDemoReaderState(timeout: 15, description: "获取初始进度") { state in
state.mode == "bookPageMap" && state.knownChapters != nil
}
let initialKnown = initialState.knownChapters ?? 0
let progressed = app.waitForDemoReaderState(timeout: 60, description: "concurrency=2 后台解析推进") { state in
guard state.mode == "bookPageMap" else { return false }
return state.pagination == "full" || (state.knownChapters ?? 0) > initialKnown
}
XCTAssertTrue(
progressed.pagination == "full" || (progressed.knownChapters ?? 0) > initialKnown,
"concurrency=2 后台解析应推进,初始=\(initialKnown) 当前=\(progressed.knownChapters ?? 0)"
)
}
func testConcurrentParsingWithWindowSize() throws {
app.launchAndOpenSampleBook(
bookTitleQuery: largeBookQuery,
resetsReaderState: true,
windowSize: 5,
concurrency: 2
)
app.waitForReader(timeout: 20)
let state = app.waitForDemoReaderState(timeout: 15, description: "window=5 + concurrency=2") { state in
state.mode == "bookPageMap" && state.knownChapters != nil
}
XCTAssertEqual(state.windowSize, 5, "windowSize 应为 5")
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "组合配置应可阅读")
let completed = app.waitForDemoReaderState(timeout: 90, description: "组合配置后台解析完成") { state in
state.pagination == "full"
}
XCTAssertEqual(completed.pagination, "full", "组合配置后台解析应最终完成")
}
}
@@ -0,0 +1,82 @@
import XCTest
final class ConfigurableWindowTests: XCTestCase {
private let app = XCUIApplication()
private let largeBookQuery = "凡人修仙传"
override func setUpWithError() throws {
continueAfterFailure = false
}
func testWindowSize5PreloadsMoreChapters() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 5)
app.waitForReader(timeout: 20)
let state = app.waitForDemoReaderState(timeout: 15, description: "window=5 预加载更多章节") { state in
state.mode == "bookPageMap" && state.knownChapters != nil
}
XCTAssertEqual(state.windowSize, 5, "windowSize 应为 5")
XCTAssertTrue((state.knownChapters ?? 0) >= 3, "window=5 首开应至少预加载 3 章(当前章 + 相邻),实际=\(state.knownChapters ?? 0)")
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "window=5 首开应可阅读")
}
func testWindowSize3MinimumWorks() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 3)
app.waitForReader(timeout: 20)
let state = app.waitForDemoReaderState(timeout: 15, description: "window=3 最小窗口") { state in
state.mode == "bookPageMap" && state.knownChapters != nil
}
XCTAssertEqual(state.windowSize, 3, "windowSize 应为 3")
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "window=3 应可阅读")
}
func testWindowSize15MaximumWorks() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 15)
app.waitForReader(timeout: 20)
let state = app.waitForDemoReaderState(timeout: 15, description: "window=15 最大窗口") { state in
state.mode == "bookPageMap" && state.knownChapters != nil
}
XCTAssertEqual(state.windowSize, 15, "windowSize 应为 15")
XCTAssertTrue((state.knownChapters ?? 0) >= 3, "window=15 首开应预加载多章")
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "window=15 应可阅读")
}
func testWindowSize5ChapterNavigationSmooth() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 5)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 15, description: "等待首章加载") { state in
state.mode == "bookPageMap" && (state.page ?? 0) >= 1
}
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// window=5
for _ in 0..<6 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
let afterSwipe = app.currentDemoReaderState()
XCTAssertNotNil(afterSwipe, "翻页后应能读取状态")
XCTAssertTrue((afterSwipe?.page ?? 0) > 1, "连续翻页后页码应推进")
}
func testEvenWindowSizeRoundsUp() throws {
// 4 5
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 4)
app.waitForReader(timeout: 20)
let state = app.waitForDemoReaderState(timeout: 15, description: "偶数窗口向上取奇") { state in
state.mode == "bookPageMap" && state.windowSize != nil
}
XCTAssertEqual(state.windowSize, 5, "windowSize=4 应被归一化为 5")
}
}
@@ -0,0 +1,97 @@
import XCTest
final class MetadataParseBenchmarkTests: XCTestCase {
private let app = XCUIApplication()
private let largeBookQuery = "凡人修仙传"
private let parseTimeout: TimeInterval = 900
override func setUpWithError() throws {
continueAfterFailure = false
}
/// parseMs
private func runParseBenchmark(concurrency: Int) throws -> (parseMs: Int, concurrency: Int) {
app.launchAndOpenSampleBook(
bookTitleQuery: largeBookQuery,
resetsReaderState: true,
concurrency: concurrency,
clearsCache: true
)
app.waitForReader(timeout: 20)
//
_ = app.waitForDemoReaderState(timeout: 15, description: "首屏加载 concurrency=\(concurrency)") { state in
state.mode == "bookPageMap" && (state.page ?? 0) >= 1
}
// parseMs OperationQueue pagination=full
let completed = app.waitForDemoReaderState(timeout: parseTimeout, description: "解析完成 concurrency=\(concurrency)") { state in
(state.parseMs ?? 0) > 0
}
let parseMs = completed.parseMs ?? 0
let actualConcurrency = completed.parseConcurrency ?? 0
XCTAssertTrue(parseMs > 0, "parseMs 应大于 0")
XCTAssertEqual(actualConcurrency, concurrency, "实际并发数应等于配置值")
// pagination=full
//
app.showReaderChromeIfNeeded()
if app.buttons[IDs.readerBack].waitForExistence(timeout: 5) {
app.buttons[IDs.readerBack].tap()
}
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 10), "返回书架失败")
return (parseMs, actualConcurrency)
}
func testMetadataParseBenchmark() throws {
let cpuCount = ProcessInfo.processInfo.activeProcessorCount
// 1.
let serial = try runParseBenchmark(concurrency: 1)
print("[Benchmark] concurrency=1 parseMs=\(serial.parseMs)")
// 2. CPU
let parallel = try runParseBenchmark(concurrency: cpuCount)
print("[Benchmark] concurrency=\(cpuCount) parseMs=\(parallel.parseMs)")
// 3.
let speedup = Double(serial.parseMs) / max(Double(parallel.parseMs), 1)
let efficiency = speedup / Double(cpuCount) * 100
print("[Benchmark] ---- 结果 ----")
print("[Benchmark] CPU cores: \(cpuCount)")
print("[Benchmark] serial(1): \(serial.parseMs)ms")
print("[Benchmark] parallel(\(cpuCount)): \(parallel.parseMs)ms")
print("[Benchmark] speedup: \(String(format: "%.2f", speedup))x")
print("[Benchmark] efficiency: \(String(format: "%.1f", efficiency))%")
if efficiency > 70 {
print("[Benchmark] 结论: 渲染受限,并发数=\(cpuCount) 合理")
} else if efficiency > 40 {
print("[Benchmark] 结论: 有 I/O 等待,可试探 concurrency=\(Int(Double(cpuCount) * 1.25))~\(Int(Double(cpuCount) * 1.5))")
} else {
print("[Benchmark] 结论: I/O 或锁竞争严重,建议降低并发数或排查瓶颈")
}
//
XCTAssertTrue(parallel.parseMs < serial.parseMs, "并发解析(\(parallel.parseMs)ms)应快于串行(\(serial.parseMs)ms)")
}
func testMetadataParseScaling() throws {
let cpuCount = ProcessInfo.processInfo.activeProcessorCount
let concurrency1 = try runParseBenchmark(concurrency: 1)
let concurrencyN = try runParseBenchmark(concurrency: cpuCount)
let speedup = Double(concurrency1.parseMs) / max(Double(concurrencyN.parseMs), 1)
print("[Scaling] concurrency=1: \(concurrency1.parseMs)ms")
print("[Scaling] concurrency=\(cpuCount): \(concurrencyN.parseMs)ms")
print("[Scaling] speedup: \(String(format: "%.2f", speedup))x on \(cpuCount) cores")
XCTAssertTrue(speedup >= 1.5, "并发加速比(\(String(format: "%.2f", speedup)))过低,可能存在瓶颈")
}
}