频繁发生的数据获取和解析
日常项目开发中,异步从后台等地方获取数据,又或者图片的解码渲染是必不可少的一个环节,这些操作我们不可能全部让主线程去做完,会导致app的卡顿,从而影响用户的体验。
所以我们经常会调用一个获取数据的函数,传递一个或多个逃逸闭包去给子线程加载完数据后作为回调触发使用。
例如:
func getRoomLog(_ completion: ((RoomLog) -> Void)?, _ failed: ((Any?) -> Void)?) {
let urlString = api_path("/xxx/xxxx")
let parameters = ["current": 1, "size": 5, "Id": "xxxxxxx", "startDate": NSDate()] as [String : Any]
SwiftNetWorkManager.shared.get(urlString, parameters, nil) { result in
if let result: [String : Any] = result?["data"] as? [String : Any] {
let json = JSON.init(result)
let logs = RoomLog(json)
completion?(logs)
} else {
let desc = RoomLog.error(nil)
failed?(desc)
}
} _: { result, error in
let desc = RoomLog.failed(error, result)
failed?(desc)
}
}
func fetchRoomLog(for roomID: String, completion: @escaping (RoomLog?, Error?) -> Void) {
let request = api_request(for: id)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(nil, error)
} else if (response as? HTTPURLResponse)?.statusCode != 200 {
completion(nil, FetchError.badID)
} else {
guard let logs = RoomLog(data: data!) else {
completion(nil, FetchError.badData)
return
}
logs.parseLog(of: Date()) { log in
guard let log = log else {
completion(nil, FetchError.badData)
return
}
completion(log, nil)
}
}
}
task.resume()
}
这样在功能的实现上是没有问题的,只是在两个可选值之间我们需要做出多次的判断并且每次都需要记得去触发闭包给回调给上层,否则上层是无法感知请求的结果的。
我们可以把请求结果用Result封装起来:
func fetchRoomLog(for roomID: String, completion: @escaping (Result<RoomLog, Error>)-> Void) {
let request = api_request(for: id)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
} else if (response as? HTTPURLResponse)?.statusCode != 200 {
completion(.failure(FetchError.badID))
} else {
guard let logs = RoomLog(data: data!) else {
completion(.failure(FetchError.badData))
return
}
logs.parseLog(of: Date()) { log in
guard let log = log else {
completion(.failure(FetchError.badData))
return
}
completion(.success(log))
}
}
}
task.resume()
}
虽然用一个闭包就能做结果的返回,但是我们还是需要对返回的结果做和之前相同数量的判断,也要时刻记得给上层提供一个结果的回调。
而在swift5.5之后提供了async/await关键字可以有效减少相同代码的沉余,我们可以把这个函数改造成这样:
func fetchRoomLog(for roomID: String) async throws -> RoomLog {
let request = api_request(for: id)
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badID }
let logs = RoomLog(data: data)
guard let log = await logs.parseLog else { throw FetchError.badData }
return log
}
通过使用async/await关键字,我们把原本23行的代码改写至只需要8行代码即可实现功能;在一切正常的情况下我们会通过子线程获取到后台返回的数据并解析数据模型,回调的通知也从逃逸闭包的调用变成了模型实例对象的返回以及错误的抛出,节约下来的沉余代码数量很客观。
关于Async/Await关键字
在我们平时调用一个函数的时候,线程的走向是这样的:
在请求数据等待返回的时候,这个线程是被我们完整占用的,只有等到数据返回函数结束之后这个线程的控制权才会重新回到系统那。
如果这个时候我们使用的是传统GCD的并发则可能会产生线程爆炸的情况。会让CPU频繁的去更换线程工作而导致过多的上下文切换。
而标记为Async的函数工作方式是这样的:
Async标记一个函数可以被暂停,Await则表明这个函数是个潜在的暂停点。执行过程中,如果函数内是同步代码则线程会是同步执行,不会主动放弃自己的线程,而执行到潜在暂停点时会把线程的控制权交还给系统不会像之前一样在原地等待异步结果,可以由系统决定这个线程去做其他的事情。
等结果返回时函数继续往下走,但是这个时候执行的线程不一定会是原来的那个线程,所以不可以在异步函数内使用锁的操作,会造成死锁。
Async/Await关键字还有一个好处在于线程完全是由Swift控制的,并且会把线程数控制至与CPU线程数相同。
串行获取多个异步结果
有时候我们需要递归或者遍历数组去获取多个异步返回的结果
首先我们在下面定义了一个耗时的异步函数
func getPoints(_ circlePath: UIBezierPath) async throws -> [CGPoint] {
*****
return **
}
我们可以这样遍历数组去获取多个结果
Task {
var points = [CGPoint]()
for _ in 0...10 {
points.append(contentsOf: try await self.getPoints(circlePath))
}
}
- 注:async函数的调用必须在前面加上await关键字并且在非async函数内调用大多数情况下需要使用Task{}在初始化闭包内调用
而在获取的过程中子任务可能会抛出错误,Swift会自动将未等待的任务标记取消,然后等待它完成再退出函数。
任务被标记取消并不意味着停止任务,且在Task闭包里面的异常不被捕获也不会导致程序停止运行,只是通知说忽略该任务的返回值。而在该函数后面的任务将会被停止执行。
这里我们在函数内打印线程后直接抛出一个异常
在Task初始化闭包里循环调用该方法,并且在循环里方法调用后面和循环之后各添加一个打印,打上断点
可以看到断点并没有走进来且只打印了一次当前线程,随后就停止了,而模拟器上的程序依旧在正常运行,此时没有做任何异常捕获
而当我们加了do catch{}之后可以看到,虽然do区间里异常函数后面的方法不会被调用,却会在执行了catch后继续把接下来的事情做完
异步属性
我们可以标记一个属性为get async,方便异步获取
- 注:异步属性只能有get方法
Async的并发使用
像上面在Task使用同步Async函数的情况下都是串行的一个运行模式,而有时候我们想要并发去获取或解码一些数据的时候,我们可以使用withTaskGroup/withThrowingTaskGroup去创建一个TaskGroup,withThrowingTaskGroup去创建一个TaskGroup则是可以抛出异常供我们处理
像这里我们则是在一个TaskGroup里添加三个子任务,把执行结果的返回值添加到数组里,最后打印总个数
函数里面打印了执行的线程,可以看到虽然是把任务添加到group里,但不一定会开辟子线程去完成, 在主线程有空的情况下Swift也会直接让主线程做完工作。
使用可能会抛出异常的函数只能在withThrowingTaskGroup里使用
我们修改一下getPoints函数和Group里Task的使用,让getPoints打印执行线程后直接抛出一个异常
可以看到第二个Task在发生异常后被取消,只打印出了“Task0”和“Task2”,而由于withThrowingTaskGroup本身的异常没被捕获,所以在withThrowingTaskGroup之后的函数也不会被执行,没有打印points.count&“~~~~~~~~~~”。
函数里并行获取多个返回值
在函数里面像这样用let 去await一个返回值是属于串行的一个状态
而使用 async let 可以并行获取多个返回值,但是同样在发生异常时不会继续往下执行
在用async let调用一个可能会抛出异常的函数的时候可以不加try,但是这个参数在使用的时候需要加上try,否则编译器则会报错
- async let适合同时获取多个返回值,节省时间,而串行获取则有利于对单数据进行处理
兼容性
Async/Await 关键字是在WWDC2021的时候提出,虽然最开始只能在iOS15的平台上使用,但是在Xcode13的后续版本中已经优化到iOS13以上的平台就可以使用,不知道后续的Xcode是否会对其系统调用或者汇编指令进行改善,但目前iOS13的target对于大部分App来讲还是太高了
而在低target版本的App上标记Async方法时Xcode会报错,提示你这个方法只能在iOS13及以上版本使用
所以我们在方法的声明和调用都需要打上标记
虽然这样做可能要提供两套数据获取和处理的逻辑,但是却可以在兼容低系统版本用户的同时提供高系统版本用户更好的运行性能
以上则是关于Async/Await的部分内容。总的来说,Async/Await提供了一套更优秀的线程模型,由Swift控制,可以在更有效率调度CPU的情况下完成并发任务并且不会导致线程爆炸情况的发生,异步任务的实现也更加精简;至于在低target版本App上是否需要写两套线程调用逻辑则取决于项目情况需要了。谢谢。