Skip to content

Commit a41e3a1

Browse files
committed
优化: 收敛缓存加载与代理运行状态
- 默认缓存目录迁移到系统 Caches 并懒加载缓存索引 - 补充空间不足结构化错误,便于调用方识别处理 - 调整 NIO 代理线程数与运行状态维护逻辑
1 parent 2c5db60 commit a41e3a1

9 files changed

Lines changed: 224 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
### 改进
66

7+
- 默认缓存目录调整为系统 Caches 下的 `HTTPMediaCache`,不再使用 Documents 作为媒体缓存目录。
8+
- 缓存索引改为首次访问时懒加载磁盘缓存,避免初始化阶段同步扫描大缓存目录。
9+
- 空间不足错误新增结构化 `CacheError.insufficientCacheSpace(requiredLength:maxCacheLength:)`,便于调用方按错误类型处理。
10+
- 本地代理 NIO EventLoop 默认按处理器数量配置线程数,并收敛代理运行状态维护。
11+
- 简化预加载完成、取消和失败路径的公共清理逻辑。
712
- 缓存空间淘汰策略升级为 LRU:成功写入、读取缓存数据和获取完整缓存文件路径会刷新缓存单元的最近访问时间;缓存状态查询不改变淘汰顺序。
813
- 拆分代理路由内部职责,提取请求解析、缓存响应、回源下载、响应校验、HLS playlist 响应和代理响应写入适配,降低 `HTTPRequestRouter` 维护复杂度。
914
- 拆分 `URLSessionDownloader` 内部职责,提取 streaming delegate、下载配置、响应校验、重试策略和后台任务协调。

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,10 @@ let maxLength = await HTTPMediaCache.maxCacheLength()
322322

323323
当写入新数据会超过最大缓存空间时,缓存索引会按最近访问时间淘汰未使用中的旧缓存,并跳过正在读取或写入的缓存单元。成功写入、读取缓存数据和获取完整缓存文件路径都会刷新缓存单元的最近访问时间;单纯查询 `cacheItem` 或缓存列表不会改变淘汰顺序。
324324

325+
默认缓存目录为系统 `Caches/HTTPMediaCache`。系统可按平台策略清理该目录,业务侧仍可通过 `deleteCache``deleteAllCaches` 主动清理缓存。
326+
327+
如果单次写入长度超过最大缓存空间,相关预加载、读取或代理链路会抛出 `CacheError.insufficientCacheSpace(requiredLength:maxCacheLength:)`,调用方可以按需单独处理该错误。
328+
325329
## 下载配置
326330

327331
可以配置下载超时、透传请求头、额外请求头和可接受的响应类型:

Sources/HTTPMediaCache/API/CacheError.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ public enum CacheError: Error, Equatable {
1919
case unsupportedContentType(String?)
2020
/// 网络请求失败。
2121
case networkFailure(String)
22+
/// 请求写入长度超过当前最大缓存空间。
23+
case insufficientCacheSpace(requiredLength: Int64, maxCacheLength: Int64)
2224
/// 缓存文件或索引读写失败。
2325
case storageFailure(String)
2426
}

Sources/HTTPMediaCache/API/HTTPMediaCache.swift

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ public enum HTTPMediaCache {
6161
let task = Task<Void, Error> {
6262
await startGate.wait()
6363
var didEnterPreloadQueue = false
64+
func finishPreload() async {
65+
if didEnterPreloadQueue {
66+
await PreloadCoordinator.shared.leave()
67+
}
68+
await PreloadCoordinator.shared.unregister(id: taskID)
69+
progress.finish()
70+
}
6471
do {
6572
try await withTaskCancellationHandler {
6673
try await PreloadCoordinator.shared.enter(id: taskID)
@@ -78,27 +85,15 @@ public enum HTTPMediaCache {
7885
progress: progress
7986
)
8087
await CacheLogStore.shared.cleanError(for: request.url)
81-
if didEnterPreloadQueue {
82-
await PreloadCoordinator.shared.leave()
83-
}
88+
await finishPreload()
8489
} catch is CancellationError {
85-
if didEnterPreloadQueue {
86-
await PreloadCoordinator.shared.leave()
87-
}
88-
await PreloadCoordinator.shared.unregister(id: taskID)
89-
progress.finish()
90+
await finishPreload()
9091
throw CancellationError()
9192
} catch {
92-
if didEnterPreloadQueue {
93-
await PreloadCoordinator.shared.leave()
94-
}
9593
await addError(error, for: request.url)
96-
await PreloadCoordinator.shared.unregister(id: taskID)
97-
progress.finish()
94+
await finishPreload()
9895
throw error
9996
}
100-
await PreloadCoordinator.shared.unregister(id: taskID)
101-
progress.finish()
10297
}
10398
await PreloadCoordinator.shared.register(id: taskID, task: task)
10499
await startGate.open()

Sources/HTTPMediaCache/Core/CacheRuntime.swift

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ actor CacheRuntime {
1111
static let shared = CacheRuntime()
1212

1313
private(set) var server = NIOProxyServer()
14-
private(set) var cacheIndex = CacheIndex(rootDirectory: CacheRuntime.defaultRootDirectory)
14+
private(set) var cacheIndex = CacheIndex(rootDirectory: CacheRuntime.defaultRootDirectoryURL)
1515
private(set) var downloader: any CacheDownloading = URLSessionDownloader()
1616
private var hlsContentHandler: (@Sendable (String) -> String)?
1717
private var hlsVariantStreamSelectionHandler: HLSVariantStreamSelectionHandler?
@@ -95,9 +95,12 @@ actor CacheRuntime {
9595
return renditions.first { $0.isDefault } ?? renditions.first
9696
}
9797

98-
private func defaultVariantStream(from variants: [HLSVariantStreamItem]) -> HLSVariantStreamItem {
98+
private func defaultVariantStream(from variants: [HLSVariantStreamItem]) -> HLSVariantStreamItem? {
99+
guard let fallback = fallbackVariantStream(from: variants) else {
100+
return nil
101+
}
99102
guard variants.contains(where: { $0.codecs != nil || $0.videoRange != nil || $0.averageBandwidth > 0 }) else {
100-
return variants[variants.count / 2]
103+
return fallback
101104
}
102105

103106
return variants.min { lhs, rhs in
@@ -107,7 +110,14 @@ actor CacheRuntime {
107110
return left < right
108111
}
109112
return effectiveBandwidth(lhs) < effectiveBandwidth(rhs)
110-
} ?? variants[variants.count / 2]
113+
} ?? fallback
114+
}
115+
116+
private func fallbackVariantStream(from variants: [HLSVariantStreamItem]) -> HLSVariantStreamItem? {
117+
guard !variants.isEmpty else {
118+
return nil
119+
}
120+
return variants[variants.count / 2]
111121
}
112122

113123
private func variantCompatibilityScore(_ variant: HLSVariantStreamItem) -> Int {
@@ -116,19 +126,19 @@ actor CacheRuntime {
116126
var score = 0
117127

118128
if videoRange == "PQ" || videoRange == "HLG" {
119-
score += 100
129+
score += VariantCompatibilityScore.hdrPenalty
120130
}
121131
if codecs.contains("dvh") || codecs.contains("dvhe") {
122-
score += 100
132+
score += VariantCompatibilityScore.dolbyVisionPenalty
123133
}
124134
if codecs.contains("ec-3") || codecs.contains("ac-3") {
125-
score += 20
135+
score += VariantCompatibilityScore.surroundAudioPenalty
126136
}
127137
if codecs.contains("mp4a") {
128-
score -= 10
138+
score += VariantCompatibilityScore.commonCodecBonus
129139
}
130140
if codecs.contains("avc1") {
131-
score -= 10
141+
score += VariantCompatibilityScore.commonCodecBonus
132142
}
133143

134144
return score
@@ -157,10 +167,20 @@ actor CacheRuntime {
157167
await cacheIndex.setCacheIdentifierProvider(provider)
158168
}
159169

160-
private static var defaultRootDirectory: URL {
161-
let baseDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first ??
162-
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
163-
return baseDirectory
164-
.appendingPathComponent("HTTPMediaCache", isDirectory: true)
170+
static func defaultRootDirectory(cachesDirectory: URL, documentsDirectory _: URL) -> URL {
171+
cachesDirectory.appendingPathComponent("HTTPMediaCache", isDirectory: true)
172+
}
173+
174+
private static var defaultRootDirectoryURL: URL {
175+
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
176+
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first ?? cachesDirectory
177+
return defaultRootDirectory(cachesDirectory: cachesDirectory, documentsDirectory: documentsDirectory)
165178
}
166179
}
180+
181+
private enum VariantCompatibilityScore {
182+
static let hdrPenalty = 100
183+
static let dolbyVisionPenalty = 100
184+
static let surroundAudioPenalty = 20
185+
static let commonCodecBonus = -10
186+
}

Sources/HTTPMediaCache/Proxy/NIOProxyServer.swift

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,39 @@ import NIOHTTP1
1616

1717
/// 基于 SwiftNIO 的本地 HTTP 代理服务。
1818
public actor NIOProxyServer {
19-
private let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
19+
let eventLoopThreadCount: Int
20+
private let group: MultiThreadedEventLoopGroup
2021
private var channel: Channel?
2122
private var codec: ProxyURLCodec?
22-
private var wantsRunning = false
23-
private var requestedPort: UInt16 = 0
23+
private var state = ServerState.stopped
2424
private var foregroundObserver: ForegroundRestartObserver?
2525

2626
/// 代理服务是否正在运行。
27-
public private(set) var isRunning = false
27+
public var isRunning: Bool {
28+
if case .running = state {
29+
return true
30+
}
31+
return false
32+
}
2833

2934
/// 创建本地代理服务。
30-
public init() {}
35+
public init() {
36+
self.init(eventLoopThreadCount: Self.defaultEventLoopThreadCount)
37+
}
38+
39+
init(eventLoopThreadCount: Int) {
40+
let resolvedThreadCount = max(1, eventLoopThreadCount)
41+
self.eventLoopThreadCount = resolvedThreadCount
42+
group = MultiThreadedEventLoopGroup(numberOfThreads: resolvedThreadCount)
43+
}
44+
45+
deinit {
46+
try? group.syncShutdownGracefully()
47+
}
3148

3249
/// 启动代理服务。
3350
public func start(port: UInt16) async throws {
34-
wantsRunning = true
35-
requestedPort = port
51+
state = .starting(requestedPort: port)
3652
if foregroundObserver == nil {
3753
foregroundObserver = ForegroundRestartObserver { [weak self] in
3854
Task {
@@ -41,6 +57,7 @@ public actor NIOProxyServer {
4157
}
4258
}
4359
guard channel == nil else {
60+
state = .running(requestedPort: port)
4461
return
4562
}
4663

@@ -52,20 +69,26 @@ public actor NIOProxyServer {
5269
}
5370
}
5471

55-
let bound = try await bootstrap.bind(host: "0.0.0.0", port: Int(port)).get()
56-
channel = bound
57-
let actualPort = bound.localAddress?.port ?? Int(port)
58-
codec = ProxyURLCodec(port: UInt16(actualPort))
59-
isRunning = true
72+
do {
73+
let bound = try await bootstrap.bind(host: "0.0.0.0", port: Int(port)).get()
74+
channel = bound
75+
let actualPort = bound.localAddress?.port ?? Int(port)
76+
codec = ProxyURLCodec(port: UInt16(actualPort))
77+
state = .running(requestedPort: port)
78+
} catch {
79+
channel = nil
80+
codec = nil
81+
state = .stopped
82+
throw error
83+
}
6084
}
6185

6286
/// 停止代理服务。
6387
public func stop() async {
64-
wantsRunning = false
88+
state = .stopped
6589
try? await channel?.close().get()
6690
channel = nil
6791
codec = nil
68-
isRunning = false
6992
}
7093

7194
/// 将原始 URL 转成当前代理服务可处理的 URL。
@@ -85,7 +108,7 @@ public actor NIOProxyServer {
85108
}
86109

87110
private func restartAfterForegroundIfNeeded() async {
88-
guard wantsRunning else {
111+
guard case let .running(requestedPort) = state else {
89112
return
90113
}
91114

@@ -96,7 +119,7 @@ public actor NIOProxyServer {
96119
try? await channel?.close().get()
97120
channel = nil
98121
codec = nil
99-
isRunning = false
122+
state = .stopped
100123
try? await start(port: requestedPort)
101124
}
102125

@@ -118,6 +141,16 @@ public actor NIOProxyServer {
118141
return false
119142
}
120143
}
144+
145+
private static var defaultEventLoopThreadCount: Int {
146+
max(1, ProcessInfo.processInfo.activeProcessorCount)
147+
}
148+
}
149+
150+
private enum ServerState {
151+
case stopped
152+
case starting(requestedPort: UInt16)
153+
case running(requestedPort: UInt16)
121154
}
122155

123156
private final class ForegroundRestartObserver: NSObject, @unchecked Sendable {

0 commit comments

Comments
 (0)