修复阅读器资源、朗读与持久化稳定性
阅读器在多会话朗读、加密 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:
@@ -25,12 +25,22 @@ extension RDEPUBParser {
|
||||
}
|
||||
|
||||
func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
|
||||
try Self.extractionQueue.sync {
|
||||
try extractArchiveIfNeededLocked(epubURL: epubURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// 同一缓存目录在解压完成前不能被其他 parser 复用。串行化解压阶段后,复用
|
||||
/// 分支看到的 `container.xml` 才能代表完整书籍,而不是另一个 parser 的半成品。
|
||||
private func extractArchiveIfNeededLocked(epubURL: URL) throws -> URL {
|
||||
let fileManager = FileManager.default
|
||||
let extractionURL = temporaryExtractionDirectory(for: epubURL)
|
||||
|
||||
if fileManager.fileExists(atPath: extractionURL.path) {
|
||||
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
|
||||
if fileManager.fileExists(atPath: containerURL.path) {
|
||||
// 复用即视为一次访问,刷新时间戳,淘汰时才是真正的 LRU 而非 FIFO
|
||||
Self.touchExtractionDirectory(extractionURL)
|
||||
return extractionURL
|
||||
}
|
||||
try? fileManager.removeItem(at: extractionURL)
|
||||
@@ -67,6 +77,8 @@ extension RDEPUBParser {
|
||||
throw error
|
||||
}
|
||||
|
||||
Self.touchExtractionDirectory(extractionURL)
|
||||
Self.pruneExtractionCache(keeping: extractionURL)
|
||||
return extractionURL
|
||||
}
|
||||
|
||||
@@ -107,9 +119,7 @@ extension RDEPUBParser {
|
||||
}
|
||||
|
||||
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
|
||||
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
let baseURL = Self.extractionCacheRootURL
|
||||
let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path)
|
||||
let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0"
|
||||
let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
|
||||
@@ -133,6 +143,97 @@ extension RDEPUBParser {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 解压缓存淘汰
|
||||
|
||||
extension RDEPUBParser {
|
||||
|
||||
private static let extractionQueue = DispatchQueue(label: "com.ssreaderview.epub.extraction-cache")
|
||||
|
||||
/// 解压缓存总量上限;每次成功解压后超出即按最近使用时间淘汰最旧的书。
|
||||
public static var extractionCacheMaximumTotalBytes: UInt64 = 512 * 1024 * 1024
|
||||
|
||||
/// 解压目录的最长保留时长,超过即淘汰(默认 30 天)。
|
||||
public static var extractionCacheMaximumAge: TimeInterval = 30 * 24 * 60 * 60
|
||||
|
||||
public static var extractionCacheRootURL: URL {
|
||||
let fileManager = FileManager.default
|
||||
return fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
?? fileManager.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
}
|
||||
|
||||
/// 清空全部解压缓存。宿主收到低存储信号或需要「清理阅读数据」时调用。
|
||||
public static func clearExtractionCache() {
|
||||
try? FileManager.default.removeItem(at: extractionCacheRootURL)
|
||||
}
|
||||
|
||||
/// 按「先过期、再总量」两级策略淘汰解压目录。
|
||||
/// - Parameter keeping: 本次正在使用的目录,任何情况下都不淘汰。
|
||||
public static func pruneExtractionCache(keeping survivor: URL? = nil) {
|
||||
let fileManager = FileManager.default
|
||||
let root = extractionCacheRootURL
|
||||
guard let children = try? fileManager.contentsOfDirectory(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
let survivorPath = survivor?.standardizedFileURL.path
|
||||
var entries: [(url: URL, size: UInt64, modifiedAt: Date)] = []
|
||||
|
||||
for child in children {
|
||||
let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .contentModificationDateKey])
|
||||
guard values?.isDirectory == true else { continue }
|
||||
let modifiedAt = values?.contentModificationDate ?? .distantPast
|
||||
|
||||
if child.standardizedFileURL.path == survivorPath {
|
||||
continue
|
||||
}
|
||||
if Date().timeIntervalSince(modifiedAt) > extractionCacheMaximumAge {
|
||||
try? fileManager.removeItem(at: child)
|
||||
continue
|
||||
}
|
||||
entries.append((child, directorySize(at: child), modifiedAt))
|
||||
}
|
||||
|
||||
// survivor 也占额度,否则刚解压的大书会把其他书全部挤掉却仍然超限
|
||||
var total = entries.reduce(UInt64(0)) { $0 + $1.size }
|
||||
if let survivor {
|
||||
total += directorySize(at: survivor)
|
||||
}
|
||||
guard total > extractionCacheMaximumTotalBytes else { return }
|
||||
|
||||
for entry in entries.sorted(by: { $0.modifiedAt < $1.modifiedAt }) {
|
||||
try? fileManager.removeItem(at: entry.url)
|
||||
total = total > entry.size ? total - entry.size : 0
|
||||
if total <= extractionCacheMaximumTotalBytes { return }
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate static func touchExtractionDirectory(_ url: URL) {
|
||||
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path)
|
||||
}
|
||||
|
||||
private static func directorySize(at url: URL) -> UInt64 {
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: url,
|
||||
includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey],
|
||||
options: []
|
||||
) else {
|
||||
return 0
|
||||
}
|
||||
var total: UInt64 = 0
|
||||
for case let fileURL as URL in enumerator {
|
||||
let values = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey])
|
||||
let size = values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0
|
||||
total += UInt64(size)
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
|
||||
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
|
||||
|
||||
private(set) var rootFilePath: String?
|
||||
|
||||
Reference in New Issue
Block a user