在 iOS 中提供最原生的视频播放体验,使用 AVPlayerController
和他的 view
是很棒的选择。同时 AVPlayer
的素材 AVPlayerItem
支持远端的视频地址,和常见的编码。通常来说 H264/HEVC 编码可以通过 AVPlayer
直接播放。
但是有一些比较 菜 的后台路由,在定位文件的时候并不支持 Content-Range
和系列相关报头,则会导致无法播放。
对于较小的视频流来说,比如朋友圈的视频,我们可以手动下载并缓存,然后通过本地的 GCDWebServer
进行传送。
首先,我们要创建一个基本的 WebServer
let server = GCDWebServer()
currentServer = server
server.addDefaultHandler(
forMethod: "GET",
request: GCDWebServerRequest.self
) { request, completion in
self.handle(request: request, completion: completion)
}
server.start(withPort: 0, bonjourName: nil)
然后,创建一个可编译成 JSON 的类,以便我们将请求的信息嵌入到 URL 中进行处理。
struct ProxyResource: Codable {
let upstream: URL
let size: Int // content-length
let type: String
}
接下来准备两个方法,分别用于嵌入和解析 URL 中的视频信息。
func prepareLocalProxy(from url: URL, contentSize: Int? = nil, contentType: String? = "video/mp4") -> URL
func decodeLocalProxy(from url: URL) -> ProxyResource?
如此一来,我们只需要将这个 URL 塞给 AVPlayer 即可在 WebServer 中接到请求。这里列举必须要填入的报头。
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1/1046987
Content-Length: 2
Content-Type: video/mp4
Connection: keep-alive
AVPlayer 在播放时,会首先发出一个 HEAD 的请求。(该请求实际 METHOD 为 GET,但 Content-Range 为 0-1,需要返回 2 字节)。接下来,便会发送剩余的 buffer 请求。具体请参考代码。由于我们要进行后台下载,不能一次性读取全部的 Range 数据,下面的代码会对文件切块。
func handle(request: GCDWebServerRequest, completion: GCDWebServerCompletionBlock) {
guard let proxyRes = decodeLocalProxy(from: request.url) else {
completion(GCDWebServerErrorResponse(statusCode: 503))
return
}
// 调用下载器进行缓存
prepareDownload(for: proxyRes.upstream)
// 如果没有提供 Range 则使用全部文件长度
var decisionRange = NSRange(location: 0, length: proxyRes.size)
if request.hasByteRange() {
decisionRange = request.byteRange
}
// 处理读取进度
var offset = decisionRange.lowerBound
// 写入的回掉 GCDWebServer 会负责调用我们
// 我们只需要在他调用我们的时候读取并写回数据即可
// 不要重复多次调用 completion
let asyncResponse: GCDWebServerAsyncStreamBlock = { [self] completion in
// 貌似要塞一个空的 Data 回去
guard offset < decisionRange.upperBound else {
completion(Data(), nil)
return
}
// 开始准备数据
var readLength = offset + Self.streamBlockSize
if offset + readLength > decisionRange.upperBound {
readLength = decisionRange.upperBound - offset
}
let range = offset ..< offset + readLength + 1
offset += readLength
// 从缓存中读取数据
prepareData(
for: proxyRes.upstream,
inRange: range
) { result in
switch result {
case let .success(data): completion(data, nil)
case let .failure(error): completion(nil, error)
}
}
}
// 准备返回请求
let response = GCDWebServerStreamedResponse(
contentType: proxyRes.type,
asyncStreamBlock: asyncResponse
)
// 处理必要的报头
response.setValue(proxyRes.type, forAdditionalHeader: "Content-Type") // Content-Type: video/mp4
response.setValue("keep-alive", forAdditionalHeader: "Connection") // Connection: keep-alive
if request.hasByteRange() {
response.statusCode = 206 // HTTP/1.1 206 Partial Content
let byteRangeStr = "\(request.byteRange.lowerBound)-\(request.byteRange.upperBound - 1)"
// Content-Range: bytes 0-1/1046987
response.setValue("bytes \(byteRangeStr)/\(proxyRes.size)", forAdditionalHeader: "Content-Range")
let length = UInt(request.byteRange.length)
response.contentLength = length // Content-Length: 2
}
// 发送响应
completion(response)
}
}
实现效果
对于更加完整的代码,@82flex 明后天会打包目前的实现为 Package,届时更新这里的内容。