修复阅读器资源、朗读与持久化稳定性

阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
This commit is contained in:
shenlei
2026-07-30 20:52:21 +09:00
parent e01cbc169d
commit 3e60bf1869
40 changed files with 3210 additions and 2691 deletions
@@ -12,6 +12,11 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
public static let host = "book"
///
///
/// provider
public static var maximumInMemoryResourceBytes: UInt64 = 64 * 1024 * 1024
private weak var parser: RDEPUBParser?
private let fileManager = FileManager.default
@@ -87,9 +92,18 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
let fileSize = resourceSize(for: fileURL)
// provider
// provider
if let fileSize, fileSize > 524_288, parser.resourceDataProvider == nil {
// Provider nil IO
// provider
//
if parser.resourceDataProvider != nil {
respondWithProvidedDataOrFile(
fileURL: fileURL,
requestURL: requestURL,
taskID: taskID,
urlSchemeTask: urlSchemeTask,
fileSize: fileSize
)
} else if let fileSize, fileSize > 524_288 {
respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
} else {
respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
@@ -121,12 +135,75 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
return size
}
private func respondWithProvidedDataOrFile(
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask,
fileSize: UInt64?
) {
ioQueue.async { [weak self] in
guard let self else { return }
guard self.isTaskActive(taskID) else { return }
if let provided = self.parser?.providedResourceData(at: fileURL) {
guard UInt64(provided.count) <= Self.maximumInMemoryResourceBytes else {
self.respondWithOversizeFailure(
fileURL: fileURL,
requestURL: requestURL,
taskID: taskID,
urlSchemeTask: urlSchemeTask,
byteCount: UInt64(provided.count)
)
return
}
self.respondWithData(provided, fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
} else if let fileSize, fileSize > 524_288 {
self.respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
} else {
self.respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, skipsProvider: true)
}
}
}
private func respondWithOversizeFailure(
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask,
byteCount: UInt64
) {
RDEPUBWebViewDebug.logSchemeTask(
"ResourceScheme",
requestURL: requestURL,
fileURL: fileURL,
event: "rejected-oversize-\(byteCount)"
)
Self.recordFailure()
DispatchQueue.main.async { [weak self] in
guard let self, self.isTaskActive(taskID) else { return }
urlSchemeTask.didFailWithError(
NSError(
domain: NSURLErrorDomain,
code: NSURLErrorDataLengthExceedsMaximum,
userInfo: [NSLocalizedDescriptionKey: "资源超过单资源内存上限(\(byteCount) 字节)"]
)
)
self.clearTask(taskID)
}
}
/// H-08 fix: Dispatch all WKURLSchemeTask completion callbacks to the main queue,
/// ensuring they run on the same serial queue that WebKit calls start() on.
/// Also: cancelled tasks now call didFailWithError(NSURLErrorCancelled) instead
/// of silently returning, satisfying the protocol requirement that every started
/// task must receive a completion callback.
private func respondWithInMemoryData(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask) {
private func respondWithInMemoryData(
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask,
skipsProvider: Bool = false
) {
ioQueue.async { [weak self] in
guard let self else {
DispatchQueue.main.async {
@@ -137,7 +214,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
do {
// provider provider
let data: Data
if let provided = self.parser?.providedResourceData(at: fileURL) {
if !skipsProvider, let provided = self.parser?.providedResourceData(at: fileURL) {
data = provided
} else {
data = try Data(contentsOf: fileURL)
@@ -148,22 +225,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
return
}
let response = URLResponse(
url: requestURL,
mimeType: Self.mimeType(for: fileURL.pathExtension),
expectedContentLength: data.count,
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
)
self.dispatchTaskSuccessCallback(
taskID: taskID,
urlSchemeTask: urlSchemeTask
) {
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
Self.recordInMemoryResponse()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
self.respondWithData(data, fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
return
} catch {
guard self.isTaskActive(taskID) else {
@@ -182,6 +244,31 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
private func respondWithData(
_ data: Data,
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask
) {
let response = URLResponse(
url: requestURL,
mimeType: Self.mimeType(for: fileURL.pathExtension),
expectedContentLength: data.count,
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
)
dispatchTaskSuccessCallback(
taskID: taskID,
urlSchemeTask: urlSchemeTask
) {
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
Self.recordInMemoryResponse()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
}
private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) {
ioQueue.async { [weak self] in
guard let self else {