功能需求
Apple 系统中(iOS、MacOS、WatchOS等等)读取文件是一个平常的不能再平常的需求,不过当文件很大时,同步读取文件会导致 UI 的挂起,这是不能让用户接受的。
所以,要想读取文件内容的同时保持界面操作丝般顺滑,只有使用异步文件读取技术来拯救我们了!
在本篇博文中,我们将会用 4 种方法来实现文件内容的异步读取。
在读取大小为 1.58GB 的文件时,最快方法比最慢方法快了将近 50 倍,仅需 0.13 秒,而这仅是在 Xcode(SwiftUI) 预览中的结果,真机还会更快!
So,废话少叙!
Let‘s work it out!!!😉
功能分析
1. 准备测试代码
我们准备一个大小为 1.58GB 的 big.zip 文件,将它放入 Xcode 项目的资源目录中,之后所有的测试都会读取该文件。
下面是测试代码,在每个后续测试里我们大部分的修改都在 ContentView 的 read() 方法中:
// AnimView 用来观察界面的挂起
struct AnimView: View {
var body: some View {
TimelineView(.animation) { context in
let value = secondsValue(for: context.date)
ZStack {
Circle()
.stroke(Color.gray.opacity(0.66), lineWidth: 12.0)
Circle()
.trim(from: 0, to: value)
.stroke(style: StrokeStyle(lineWidth: 15.0, lineCap: .round, lineJoin: .round, miterLimit: 0.0, dash: [], dashPhase: 0.0))
Text("\(Calendar.current.component(.second, from: context.date))")
.font(.largeTitle.weight(.black))
}
}
}
private func secondsValue(for date: Date) -> Double {
let seconds = Calendar.current.component(.second, from: date)
return Double(seconds) / 60
}
}
struct ContentView: View {
@State var isFileLoading = false
@State var fileSize = 0
@State var readingBeginTime = Date()
@State var elapsedSeconds: TimeInterval = 0.0
var sizeString: String {
ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file)
}
let fileUrl = Bundle.main.url(forResource: "big", withExtension: "zip")!
private func reset() {
isFileLoading = true
fileSize = 0
elapsedSeconds = 0.0
readingBeginTime = Date()
}
private func read() {
reset()
// 我们实际文件读取代码将会在此...
}
var body: some View {
NavigationView {
VStack {
AnimView()
.frame(width: 200, height: 200)
.foregroundStyle(Color.red.gradient)
.padding()
Text("\(String(format: "耗时 %0.2f 秒", elapsedSeconds))")
.font(.title3.weight(.black))
.foregroundStyle(Color.blue.gradient)
List(0..<200){ i in
Text("Item \(i)")
.font(.title3)
}
.listStyle(.plain)
.safeAreaInset(edge: .bottom){
Button(action: {
read()
}){
Text("读取文件")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.tint(.green)
.shadow(radius: 5.0)
.padding(.horizontal)
}
}
.navigationTitle("文件读取DEMO")
.toolbar {
ToolbarItem(placement: .navigationBarLeading){
Text("大熊猫侯佩 @ csdn")
.foregroundColor(.gray)
}
ToolbarItem(placement: .primaryAction){
if isFileLoading {
HStack {
Text("文件读取中")
// 在文件读取时显示进度小圆环
ProgressView()
}
}else{
Text("文件大小 \(sizeString)")
}
}
}
}
}
}
2. 同步文件读取的弊端
同步文件读取很简单,在文件比较小时很给力,但如果文件很大则必然挂起 UI:
// 同步读取,可能会挂起界面操作
private func read() {
reset()
if let data = try? Data(contentsOf: fileUrl) {
elapsedSeconds = Date.now.timeIntervalSince(readingBeginTime)
fileSize = data.count
}
isFileLoading = false
}
可以看到在同步读取 big.zip 文件内容时,挂起界面大约 2 秒多,而且我们准备的等待进度小圆环(右上角的 ProgressView)根本没机会显示。
3. 简单的异步读取
一种非常简单的异步读取方法是:直接将整个读取代码放到后台线程中,在读取完成后再切换回主线程。
private func read() {
reset()
func in_main(_ blk: @escaping () -> ()) {
DispatchQueue.main.async {
blk()
}
}
DispatchQueue.global(qos: .background).async {
defer {
in_main {
isFileLoading = false
}
}
if let data = try? Data(contentsOf: fileUrl) {
in_main {
elapsedSeconds = Date.now.timeIntervalSince(readingBeginTime)
fileSize = data.count
}
}
}
}
可以看到异步读取文件时,界面中的动画、进度环显示和用户操作都不会受到影响。
4. 基于 async / await 并发模型异步读取
从 Swift 5.5 开始,Apple 为我们带来了新的 async / await 并发模型,我们可以使用它来时实现文件异步读取:
func readData(url: URL) async throws -> Data {
try await withCheckedThrowingContinuation { c in
DispatchQueue.global(qos: .background).async {
do {
let data = try Data(contentsOf: url)
c.resume(returning: data)
}catch{
c.resume(throwing: error)
}
}
}
}
private func read() {
reset()
Task {
defer {
isFileLoading = false
}
do {
let data = try await readData(url: fileUrl)
elapsedSeconds = Date.now.timeIntervalSince(readingBeginTime)
fileSize = data.count
}catch{
print("ERR: \(error.localizedDescription)")
}
}
}
注意在上面代码中,由于任务(Task)的层级体系(Hierarchy)特性,在 read() 方法的 Task 闭包默认会在主线程中执行,所以无需切换。
5. 基于异步序列(AsyncSequence)的读取
除了直接通过的 async / await 并发模型,我们还可以基于其中的异步序列(AsyncSequence)来实现文件的异步读取:
func createStream(url: URL, bufSize: Int = 4096 * 1024) throws -> AsyncThrowingStream<Data,Error> {
let handle = try FileHandle(forReadingFrom: url)
return AsyncThrowingStream {
if let data = try handle.read(upToCount: bufSize), !data.isEmpty {
return data
}else{
try handle.close()
return nil
}
}
}
func readDataFromStream(url: URL, bufSize: Int = 4096 * 1024) async throws -> Data {
var tmp = Data()
let stream = try createStream(url: url, bufSize: bufSize)
for try await data in stream {
tmp.append(data)
}
return tmp
}
private func read() {
reset()
Task {
defer {
isFileLoading = false
}
do {
let data = try await readDataFromStream(url: fileUrl)
elapsedSeconds = Date.now.timeIntervalSince(readingBeginTime)
fileSize = data.count
}catch{
print("ERR: \(error.localizedDescription)")
}
}
}
注意,在上面代码中我们使用了 FileHandle 的文件分块读取机制(可以调整分块的大小:bufSize)来产生异步序列的内容,所以会比文件整个内容的一次性读取耗时久一些。
不过,如果我们的需求是按条件读取文件头部的一小块内容,这种方式无疑是效率最好的(需要修改部分代码)。
6. 基于 UIDocument 的异步读取
其实 Apple 早就为我们考虑到了文件异步读取的场景,我们可以基于 UIDocument 抽象文档类,来定制自己的文档模型。
首先,需要继承 UIDocument 类(需要导入 UIKit),并实现其中 load 和 contents 两个方法:
import UIKit
class UniversalFile: UIDocument {
var fileData: Data? // 文件内容保存在此
override func load(fromContents contents: Any, ofType typeName: String?) throws {
fileData = contents as? Data
}
override func contents(forType typeName: String) throws -> Any {
if let data = fileData {
return data
}
return Data()
}
}
其中,load() 在文件被读取时调用,contents() 方法则在文件被保存时调用,如果仅仅是读取文件(比如本例中)可以不实现 contents 方法。
接着,我们在 ContentView 的 load() 方法中,使用 UIDocument#open() 方法来处理文件的异步打开。若成功,系统会调用 UniversalFile#load() 方法来保存数据(到 UniversalFile 的 fileData 属性中)以便我后续的读取:
private func read() {
reset()
let file = UniversalFile(fileURL: fileUrl)
Task {
// 如果文件可以被打开且其数据不为空则可以继续操作
if await file.open(), let data = await file.fileData {
elapsedSeconds = Date.now.timeIntervalSince(readingBeginTime)
fileSize = data.count
}
isFileLoading = false
}
}
UIDocument 类除了简单的文件异步打开读取功能外,还提供了很多高级功能,有机会我们可以单开一篇来介绍。
可以看到用 UIDocument 类读取文件非常快,还没开始就已经结束了,上面打开 1.58GB 大小的文件内容仅需 0.13 秒,可谓十分惊人。
至此,我们实现了所有 4 种文件异步读取方式,任君选择。💯🚀
总结
在本篇博文中,我们讨论了同步读取大文件的弊端,并逐一实现了 4 种异步读取文件的方法,其中最快方法在打开 1.58 GB 的文件仅用时 0.13 秒,希望这些方法抛砖引玉可以启发到大家。
那么,最后还得问一下小伙伴:你们学会了么? 😎
结束语
Hi,我是大熊猫侯佩,一名非自由App开发者,希望我的文章可以解决你的痛点、难点问题。
如果还有问题欢迎在下面一起讨论吧 😉
感谢观赏,再会。