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

阅读器在多会话朗读、加密 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
@@ -20,11 +20,16 @@ public final class RDSpeechReaderController: NSObject {
private var queuedUnits: [RDSpeechTextUnit] = []
private var completedUnits: [RDSpeechTextUnit] = []
private var nextLocation: RDSpeechLocation?
private var isLoadingBatch = false
private var loadingGeneration: UInt?
private var playbackGeneration: UInt = 0
private var activeUtterance: AVSpeechUtterance?
private var isStopping = false
private var shouldResumeAfterInterruption = false
private var sleepTimerTask: Task<Void, Never>?
private var notificationTokens: [NSObjectProtocol] = []
private var remoteCommandTargets: [(command: MPRemoteCommand, token: Any)] = []
private static weak var activeRemoteController: RDSpeechReaderController?
public init(
contentProvider: RDSpeechContentProvider,
@@ -42,24 +47,25 @@ public final class RDSpeechReaderController: NSObject {
deinit {
notificationTokens.forEach(NotificationCenter.default.removeObserver)
MPRemoteCommandCenter.shared().playCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().pauseCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().togglePlayPauseCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().nextTrackCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().previousTrackCommand.removeTarget(nil)
remoteCommandTargets.forEach { $0.command.removeTarget($0.token) }
}
public func start(from location: RDSpeechLocation? = nil) async throws {
stop(clearProgress: false)
playbackGeneration &+= 1
let generation = playbackGeneration
resetPlayback(clearProgress: false)
isStopping = false
completedUnits.removeAll()
claimRemoteControlsIfNeeded()
state = .preparing
do {
if configuration.configuresAudioSession { try configureAudioSession() }
let initialLocation = location ?? progressStore?.restoreSpeechLocation(for: contentProvider.speechBookDescriptor().identifier)
try await loadBatch(startingAt: initialLocation, requiresContent: true)
try await speakNextUnit()
try await loadBatch(startingAt: initialLocation, requiresContent: true, generation: generation)
guard isCurrentPlaybackGeneration(generation) else { return }
try await speakNextUnit(generation: generation)
} catch {
guard isCurrentPlaybackGeneration(generation) else { return }
state = .failed(error.localizedDescription)
throw error
}
@@ -72,31 +78,40 @@ public final class RDSpeechReaderController: NSObject {
public func resume() {
guard synthesizer.isPaused else { return }
claimRemoteControlsIfNeeded()
synthesizer.continueSpeaking()
}
public func stop() { stop(clearProgress: false) }
public func stop(clearProgress: Bool) {
playbackGeneration &+= 1
resetPlayback(clearProgress: clearProgress)
state = .idle
}
private func resetPlayback(clearProgress: Bool) {
isStopping = true
synthesizer.stopSpeaking(at: .immediate)
activeUtterance = nil
currentUnit = nil
queuedUnits.removeAll()
nextLocation = nil
isLoadingBatch = false
loadingGeneration = nil
sleepTimerTask?.cancel()
sleepTimerTask = nil
if clearProgress { progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier) }
state = .idle
}
public func skipToNextSentence() {
guard state.isActive else { return }
let generation = playbackGeneration
stopCurrentUtteranceForNavigation()
Task { [weak self] in
guard let self else { return }
do { try await self.speakNextUnit() }
catch { self.state = .failed(error.localizedDescription) }
do { try await self.speakNextUnit(generation: generation) }
catch where self.isCurrentPlaybackGeneration(generation) { self.state = .failed(error.localizedDescription) }
catch {}
}
}
@@ -143,18 +158,26 @@ public final class RDSpeechReaderController: NSObject {
}
}
private func loadBatch(startingAt location: RDSpeechLocation?, requiresContent: Bool) async throws {
guard !isLoadingBatch, !isStopping else { return }
isLoadingBatch = true
defer { isLoadingBatch = false }
private func loadBatch(
startingAt location: RDSpeechLocation?,
requiresContent: Bool,
generation: UInt
) async throws {
guard loadingGeneration != generation, isCurrentPlaybackGeneration(generation) else { return }
loadingGeneration = generation
defer {
if loadingGeneration == generation {
loadingGeneration = nil
}
}
var requestedLocation = location
var mustContainContent = requiresContent
while !isStopping {
while isCurrentPlaybackGeneration(generation) {
let batch = try await contentProvider.speechContentBatch(
startingAt: requestedLocation,
limit: configuration.batchSize
)
guard !isStopping else { return }
guard isCurrentPlaybackGeneration(generation) else { return }
if !batch.units.isEmpty {
queuedUnits.append(contentsOf: batch.units)
nextLocation = batch.nextLocation
@@ -171,12 +194,12 @@ public final class RDSpeechReaderController: NSObject {
}
}
private func speakNextUnit() async throws {
guard !isStopping else { return }
private func speakNextUnit(generation: UInt) async throws {
guard isCurrentPlaybackGeneration(generation) else { return }
if queuedUnits.isEmpty, let nextLocation {
try await loadBatch(startingAt: nextLocation, requiresContent: false)
try await loadBatch(startingAt: nextLocation, requiresContent: false, generation: generation)
}
guard !isStopping else { return }
guard isCurrentPlaybackGeneration(generation) else { return }
guard !queuedUnits.isEmpty else {
if state != .finished {
state = .finished
@@ -193,6 +216,7 @@ public final class RDSpeechReaderController: NSObject {
let utterance = AVSpeechUtterance(string: unit.text)
utterance.rate = configuration.rate
utterance.voice = resolvedVoice(for: unit)
activeUtterance = utterance
synthesizer.speak(utterance)
}
@@ -208,9 +232,14 @@ public final class RDSpeechReaderController: NSObject {
private func stopCurrentUtteranceForNavigation() {
isStopping = true
synthesizer.stopSpeaking(at: .immediate)
activeUtterance = nil
isStopping = false
}
private func isCurrentPlaybackGeneration(_ generation: UInt) -> Bool {
playbackGeneration == generation && !isStopping
}
private func resolvedVoice(for unit: RDSpeechTextUnit) -> AVSpeechSynthesisVoice? {
if let identifier = configuration.voiceIdentifier,
let voice = AVSpeechSynthesisVoice(identifier: identifier) {
@@ -266,33 +295,43 @@ public final class RDSpeechReaderController: NSObject {
commands.togglePlayPauseCommand.isEnabled = true
commands.nextTrackCommand.isEnabled = true
commands.previousTrackCommand.isEnabled = true
commands.playCommand.addTarget { [weak self] _ in
remoteCommandTargets.append((commands.playCommand, commands.playCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.resume() }
return .success
}
commands.pauseCommand.addTarget { [weak self] _ in
}))
remoteCommandTargets.append((commands.pauseCommand, commands.pauseCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.pause() }
return .success
}
commands.togglePlayPauseCommand.addTarget { [weak self] _ in
}))
remoteCommandTargets.append((commands.togglePlayPauseCommand, commands.togglePlayPauseCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in
guard let self else { return }
self.synthesizer.isPaused ? self.resume() : self.pause()
}
return .success
}
commands.nextTrackCommand.addTarget { [weak self] _ in
}))
remoteCommandTargets.append((commands.nextTrackCommand, commands.nextTrackCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.skipToNextSentence() }
return .success
}
commands.previousTrackCommand.addTarget { [weak self] _ in
}))
remoteCommandTargets.append((commands.previousTrackCommand, commands.previousTrackCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.skipToPreviousSentence() }
return .success
}
}))
}
private func claimRemoteControlsIfNeeded() {
guard configuration.enablesRemoteControls else { return }
Self.activeRemoteController = self
}
private func updateNowPlayingInfo() {
guard configuration.enablesRemoteControls else { return }
guard configuration.enablesRemoteControls, Self.activeRemoteController === self else { return }
let book = contentProvider.speechBookDescriptor()
var info: [String: Any] = [
MPMediaItemPropertyTitle: book.title,
@@ -305,7 +344,7 @@ public final class RDSpeechReaderController: NSObject {
extension RDSpeechReaderController: AVSpeechSynthesizerDelegate {
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidStart() }
Task { @MainActor [weak self] in self?.handleDidStart(utterance) }
}
public nonisolated func speechSynthesizer(
@@ -313,55 +352,58 @@ extension RDSpeechReaderController: AVSpeechSynthesizerDelegate {
willSpeakRangeOfSpeechString characterRange: NSRange,
utterance: AVSpeechUtterance
) {
Task { @MainActor [weak self, characterRange] in self?.handleWillSpeak(characterRange) }
Task { @MainActor [weak self, characterRange] in self?.handleWillSpeak(characterRange, utterance: utterance) }
}
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidPause() }
Task { @MainActor [weak self] in self?.handleDidPause(utterance) }
}
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidContinue() }
Task { @MainActor [weak self] in self?.handleDidContinue(utterance) }
}
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidFinish() }
Task { @MainActor [weak self] in self?.handleDidFinish(utterance) }
}
}
private extension RDSpeechReaderController {
func handleDidStart() {
guard let currentUnit else { return }
func handleDidStart(_ utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, let currentUnit else { return }
progressStore?.saveSpeechLocation(currentUnit.location, for: contentProvider.speechBookDescriptor().identifier)
state = .speaking(currentUnit.location)
}
func handleWillSpeak(_ characterRange: NSRange) {
guard let currentUnit else { return }
func handleWillSpeak(_ characterRange: NSRange, utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, let currentUnit else { return }
var location = currentUnit.location
location.textOffset = currentUnit.textRange.location + characterRange.location
progressStore?.saveSpeechLocation(location, for: contentProvider.speechBookDescriptor().identifier)
delegate?.speechReaderController(self, willSpeak: RDSpeechSpokenRange(unit: currentUnit, range: characterRange))
}
func handleDidPause() {
guard let currentUnit else { return }
func handleDidPause(_ utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, let currentUnit else { return }
state = .paused(currentUnit.location)
}
func handleDidContinue() {
guard let currentUnit else { return }
func handleDidContinue(_ utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, let currentUnit else { return }
state = .speaking(currentUnit.location)
}
func handleDidFinish() {
guard !isStopping, let finished = currentUnit else { return }
func handleDidFinish(_ utterance: AVSpeechUtterance) {
guard utterance === activeUtterance, !isStopping, let finished = currentUnit else { return }
completedUnits.append(finished)
activeUtterance = nil
currentUnit = nil
let generation = playbackGeneration
Task { [weak self] in
guard let self else { return }
do { try await self.speakNextUnit() }
catch { self.state = .failed(error.localizedDescription) }
do { try await self.speakNextUnit(generation: generation) }
catch where self.isCurrentPlaybackGeneration(generation) { self.state = .failed(error.localizedDescription) }
catch {}
}
}
}