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:
@@ -21,6 +21,10 @@
|
||||
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
|
||||
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
|
||||
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
|
||||
1A2B3C4D0000000BAABBCC01 /* ConfigurableWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */; };
|
||||
1A2B3C4D0000000DAABBCC01 /* ConcurrentParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */; };
|
||||
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */; };
|
||||
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -50,6 +54,10 @@
|
||||
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
|
||||
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigurableWindowTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConcurrentParsingTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MetadataParseBenchmarkTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoReaderState.swift; sourceTree = "<group>"; };
|
||||
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -108,6 +116,7 @@
|
||||
children = (
|
||||
00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */,
|
||||
74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */,
|
||||
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
@@ -172,6 +181,9 @@
|
||||
1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */,
|
||||
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */,
|
||||
1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */,
|
||||
1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */,
|
||||
1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */,
|
||||
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */,
|
||||
);
|
||||
path = ReaderUITests;
|
||||
sourceTree = "<group>";
|
||||
@@ -324,6 +336,7 @@
|
||||
files = (
|
||||
63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */,
|
||||
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */,
|
||||
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */,
|
||||
1A2B3C4D00000001AABBCC01 /* BookmarkTests.swift in Sources */,
|
||||
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */,
|
||||
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */,
|
||||
@@ -334,6 +347,9 @@
|
||||
1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */,
|
||||
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */,
|
||||
1A2B3C4D00000003AABBCC01 /* TableOfContentsTests.swift in Sources */,
|
||||
1A2B3C4D0000000BAABBCC01 /* ConfigurableWindowTests.swift in Sources */,
|
||||
1A2B3C4D0000000DAABBCC01 /* ConcurrentParsingTests.swift in Sources */,
|
||||
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,9 @@ final class ViewController: UIViewController {
|
||||
let resetsReaderState: Bool
|
||||
let displaySequence: [RDReaderView.DisplayType]
|
||||
let stepDelay: TimeInterval
|
||||
let windowSize: Int?
|
||||
let concurrency: Int?
|
||||
let clearsCache: Bool
|
||||
|
||||
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? {
|
||||
switch rawValue.lowercased() {
|
||||
@@ -50,6 +53,9 @@ final class ViewController: UIViewController {
|
||||
raw.split(separator: ",").compactMap { parseDisplayType(String($0)) }
|
||||
} ?? []
|
||||
let stepDelay = value(after: "--demo-step-delay").flatMap(TimeInterval.init) ?? 1.0
|
||||
let windowSize = value(after: "--demo-window-size").flatMap(Int.init)
|
||||
let concurrency = value(after: "--demo-concurrency").flatMap(Int.init)
|
||||
let clearsCache = arguments.contains("--demo-clear-cache")
|
||||
|
||||
return LaunchAutomationPlan(
|
||||
bookTitleQuery: bookTitleQuery,
|
||||
@@ -57,7 +63,10 @@ final class ViewController: UIViewController {
|
||||
pageNumber: pageNumber,
|
||||
resetsReaderState: resetsReaderState,
|
||||
displaySequence: displaySequence,
|
||||
stepDelay: stepDelay
|
||||
stepDelay: stepDelay,
|
||||
windowSize: windowSize,
|
||||
concurrency: concurrency,
|
||||
clearsCache: clearsCache
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -242,11 +251,20 @@ final class ViewController: UIViewController {
|
||||
if launchAutomationPlan.resetsReaderState {
|
||||
resetPersistedReaderState()
|
||||
}
|
||||
if launchAutomationPlan.clearsCache {
|
||||
clearChapterSummaryCache()
|
||||
}
|
||||
|
||||
var configuration = RDEPUBReaderConfiguration.default
|
||||
if let displayType = launchAutomationPlan.displayType {
|
||||
configuration.displayType = displayType
|
||||
}
|
||||
if let windowSize = launchAutomationPlan.windowSize {
|
||||
configuration.onDemandChapterWindowSize = windowSize
|
||||
}
|
||||
if let concurrency = launchAutomationPlan.concurrency {
|
||||
configuration.metadataParsingConcurrency = concurrency
|
||||
}
|
||||
_ = openBook(book, configuration: configuration, automationPlan: launchAutomationPlan)
|
||||
}
|
||||
|
||||
@@ -266,6 +284,14 @@ final class ViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func clearChapterSummaryCache() {
|
||||
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let cacheDir = cachesDirectory.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
|
||||
try? FileManager.default.removeItem(at: cacheDir)
|
||||
print("[ReadViewDemo] cleared chapter summary cache at \(cacheDir.path)")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ViewController: UITableViewDataSource {
|
||||
|
||||
@@ -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)))过低,可能存在瓶颈")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user