在Swift中避免回调地狱

能够从事最多样化的项目,这使我有机会与几种类型的开发人员和代码库联系。 除了它们的核心差异外,在此过程中对我而言突出的是,成熟度较低的项目将始终面临类似的问题。

也许他们选择了错误的体系结构,或者缺少单元测试导致一个讨厌的bug潜入了生产环境,但是有一个特定的问题总是引起我的注意-回调地狱。 如果不从一开始就进行处理,当在其他回调或条件中链接回调时,这些可怕的花括号金字塔就会使困扰着代码库的问题陷入千篇一律的不可能的代码审阅和遥远的“此方法到底在干什么?”的尖叫。 ”。

 私有函数requestNetwork (请求:T,完成时间:(结果->无效)?){ 
如果isUserLogged {
做{
让urlRequest =试试request.toRequest()
session.dataTask(with:urlRequest){(数据,响应,错误)在
如果让httpResponse = response为? HTTPURLResponse {
如果acceptedStatuses?.contains(httpResponse.statusCode)!= true {
如果让apiError = errorParser?.possibleError(from:data){
完成(.failure(错误))
返回
}
}
}
preprocess(data){(processedData,error)在
如果让错误=错误{
完成(.failure(错误))
}
如果让加工数据=处理数据{
做{
让结果=尝试request.serialize(processedData)
完成(。成功(结果))
} {
完成(.failure(错误))
}
}其他{
完成(.failure(HTTPError.unknown))
}
}
}
} {
完成(.failure(错误))
}
}其他{
完成(.failure(HTTPError.loggedOut))
}
}

它们难以阅读,几乎无法审查,但不幸的是,它们非常容易编写,巩固了其作为初级开发人员的祸根的地位。

对我们来说幸运的是,Swift提供了几种选择来避免这种行为。 稍有耐心和适当的样式指南,就可以防止这种错误影响您的生产率。 我将使用本文来分享我个人如何避免使用它们,并希望这将帮助您提出自己的解决方案。

(如果您发现在Medium上很难阅读代码,我的博客SwiftRocks.com会以更好的格式显示该帖子!)

条件金字塔很常见,幸运的是更容易处理。 值得一提的是, guard是我Swift的十大功能-尽管它基本上可以作为if语句的倒排,但是它在代码质量方面具有很大的优势。 除了为您提供一种使方法早日返回的方法之外,它还使您可以将方法的“良好”结果与方法本身放在相同的缩进中,从而使您的方法的意图更易于同事理解。 在if语句链中发现改进并不难:

  func foo(){ 
如果一个 {
如果b {
如果c {
//好结果
}其他{
//不良结果3
}
}其他{
//结果差2
}
}其他{
//不良结果1
}
} func foo(){
守卫另一个{
return //不良结果1
}
守卫别
return //不良结果2
}
守卫别
return //不良结果3
}
//好结果
}

如果您怀着这样的心态:将方法的好结果尽可能地靠近方法的缩进,而不良的结果则尽可能地远离缩进,那么您只需看一眼代码,就会发现代码更容易阅读该方法的结尾将足以使某人理解它应该做什么。 使用guards措施将不应该发生的事情隔离开来,并将ifs的使用限制在对于实现良好结果不是必需的事情上,例如根据属性值更改单元格的颜色。

  func updatePromotions(动画:Bool = true){ 
警卫isUserLogged其他{
displayLoginScreen()
返回
}
如果是动画{
委托?.didStartLoading()
}
//好结果:获取促销
}

由于完成处理程序可以包含几乎所有内容,因此由异步调用引起的回调地狱是解决起来比较棘手的问题,但是也有有效的方法来处理它们。

Promises的概念是我管理异步事物的首选解决方案。 如果您以前从未看过它们,那么Promises涉及一种类型的概念,该类型以后可能会也可能不会解析值:

  func getInt()-> Promise  
返回Promise {许诺
//执行异步操作
promise.fulfill(数字)
//或者promise.fail(错误)
}
}
让promise = getInt()。然后{
print(number * 10)//如果成功
} .catch {错误
print(error)//如果失败
}

Promise类型可以接收闭包,该闭包根据解析值的结果来确定如何进行处理,在这种情况下,闭包由then(completion:)catch(completion:) 。 如果您想知道为什么这有助于解决回调难题,那是因为处理程序可以选择接收另一个promise ,从而创建无限的直接操作流:

  func perform (请求:T)-> Promise  
返回Promise {许诺
//在这里执行实际的请求,然后:
promise.fulfill(响应)
}
}

perform(requestOne())。then {responseOne in
perform(requestTwo(responseOne:responseOne))
}。然后{responseTwo in
perform(requestThree(responseTwo:responseTwo))
}。然后{responseThree in
perform(requestFour(responseThree:responseThree))
} .catch {错误
打印(错误)
}。始终{
打印(“完成”)
}

通过使您的异步操作返回Promise类型而不是接收完成处理程序,您将能够将它们中的任意数量链接在一起,形成漂亮的直线代码。 当您的操作依赖于先前操作返回的内容时,它们特别有用,因为功能更强大的Promise实现还将包含多个用于转换值的选项。

我个人使用PromiseKit,因为它包含大量功能,但是网络上有轻量级的库,您当然可以自己开发一个简单的Promise实现。

您也会看到人们为此推荐RxSwift之类的东西-我个人不会这样做,因为我认为长期将整个项目当作人质的任何事情都是死刑(例如,您所做的每一件事都有以考虑RxSwift的体系结构才能工作),但这是我个人的观点,如果您知道自己在做什么,就可以使用它。

如果Promises不是您的事情,因为您希望以Apple的方式解决问题,则可以使用Foundation的本机解决方案来管理顺序操作。

OperationQueue是Apple DispatchQueue的抽象,它包含其他功能以更好地支持同步和取消操作。 如果您的操作不依赖先前操作中的数据,则Operation系列类可以解决问题。 对于同步操作,这仅是对自定义操作进行排队的问题:

 让队列= OperationQueue() 
queue.maxConcurrentOperationCount = 1

func doLotsOfStuff(completion:(()-> Void)?){
let firstOperation:操作= FirstOperation()
let secondOperation:Operation = SecondOperation()
secondOperation.completionBlock = {
完成吗?
}
secondOperation.addDependency(firstOperation)
queue.addOperation(firstOperation)
queue.addOperation(secondOperation)
}

但是,对于异步操作而言,事情比较棘手。 为了使队列等待操作真正完成,您要么必须使用诸如DispatchGroups类的线程阻塞机制,要么为此目的创建/使用一种自定义AsynchronousOperation类型来管理Operation的状态。

如果您需要一项操作来将数据传递给另一个OperationQueue ,则无法使用OperationQueue找到干净的解决方案,因为无法保证在下一个操作开始运行之前将调用操作的completionBlock 。 有一些技巧可以帮助您实现这些目标–您可以将所有需要的数据包装在外部引用类型中,所有操作均可访问该引用类型:

  func doLotsOfStuff(completion:(()-> Void)?){ 
让数据= NeededOperationData()
//数据具有所有操作所需的所有属性
//每个操作都会获取并设置
//下一个操作所需。
让一个= OperationOne(数据)
让两个= OperationTwo(data)
two.addDependency(一个)
让三= OperationThree(数据)
三.addDependency(两个)

queue.addOperation(一个)
queue.addOperation(两个)
queue.addOperation(三)
}

另外,您可以将必要的数据存储在操作的依赖项中,并通过将操作子类化并在执行时获取其依赖项来访问它们。

  class SecondOperation:AsynchronousOperation { 
var数据:数据?

覆盖func main(){
super.main()
让firstOperation =依靠。 首次运营
数据= firstOperation.result
//运行操作
}
}

我不喜欢在任何地方处理可选属性,因此,如果我的操作依赖于其他操作提取的数据,那么我个人不会使用OperationQueue

如果您想在不使用其他数据结构的情况下执行此操作,则可以通过应用更好的编码实践和函数式编程中的某些原则,仅使用Swift来处理回调地狱。 因为闭包是类型,所以它们可以作为参数传递给方法-通常作为完成处理程序。 事实是,Swift方法只是美化的闭包,因此您可以将整个方法作为闭包参数传递。 这个确切的概念可用于减少方法中嵌套闭包的数量:

 让sum = array.reduce(0,+) 
// reduce()这是((Int,(((Int,Int)-> Int))-> Int)
// +运算符是func +(lhs:Int,rhs:Int)-> Int,
// ...或(((Int,Int)-> Int),所以不需要定义reduce的闭包。

为了了解这是如何应用的,我们假设有一种方法可以从网络上下载图片,在另一个线程中对其本地应用棕褐色调滤镜,然后将其作为用户的个人资料图片上传:

  func applySepiaFilterAndUpload(picUrl:URL,完成:((User)-> Void)?){ 
session.perform(downloadPictureRequest(url:picUrl)){数据在
filterQueue.async {
让filteredPicture = applySepiaFilterTo(picData:数据)
session.perform(uploadUserPictureRequest(data:filteredPicture)){用户在
完成吗?(用户)
}
}
}
}

为了使本文更容易理解,我省略了任何类型的错误管理,但是与任何经典的回调地狱问题一样,这里的第一个问题很明显:此方法的工作量太大。 在开始考虑关闭之前,让我们首先应用单一责任原则,并将此工作流的每个部分划分为单独的方法:

  func downloadPicture(fromUrl url:URL,完成:((数据)->无效)?){ 
session.perform(downloadPictureRequest(url:url)){数据在
完成吗?(数据)
}
}

func applySepiaFilter(toPicData data:Data,complete:((Data)-> Void)?){
filterQueue.async {
让filteredPicture = applySepiaFilterTo(picData:数据)
完成?(filteredPicture)
}
}

func uploadUserPicture(数据:数据,完成:((用户)->空)?){
session.perform(uploadUserPictureRequest(data:data)){用户在
完成吗?(用户)
}
}

func applySepiaFilterAndUpload(picUrl:URL,完成:((User)-> Void)?){
downloadPicture(fromUrl:picUrl){数据在
applySepiaFilter(toPicData:data){已过滤
uploadUserPicture(数据:已过滤){
完成吗?(用户)
}
}
}
}

尽管回调地狱在这里仍然存在,但至少我们现在有一些可读性。

为了减少嵌套闭包的数量,请分析此方法的工作原理。 您可以在applySepiaFilterAndUpload()看到模式吗? 解决嵌套问题的关键是每个步骤的工作方式:这里的每个方法都以完全相同的方式工作。 downloadPicture接收URL并提供Data完成, applySepiaFilter接收Data并提供另一个Data完成, uploadUserPicture接收Data并提供User完成。 如果将这些类型转换为泛型,您将得到:

  downloadPicture =(T,(U-> Void))->虚空 
applySepiaFilter =(U,(V->虚空))->虚空
uploadUserPicture =(V,(W->虚空))->虚空

因为这些异步操作具有完全相同的结构,并且彼此之间显然相互依赖,所以我们可以通过调整这些方法以将下一个方法作为参数来完全消除使用闭包的必要性。 如果每个方法都有一个显式的返回类型,这将是微不足道的,但是由于我们正在处理完成处理程序,因此我们需要编写一些帮助程序来实现此效果。 首先,我将这种共享行为定义为Operation别名(具有可选属性,因此没有人被迫做任何事情):

  public typealias Operation  =((T,(((U)-> Void)?)-> Void)? 

这样,我们可以定义一种将两个操作“合并”为一个的方法,只要它们具有匹配的参数即可-制作(T, (U -> Void)) -> Void + (U, (V -> Void)) -> Void变为(T, (V -> Void)) -> Void

  func merge (_ lhs:Operation ,to rhs:Operation )-> Operation  { 
返回{(输入,完成)
lhs?(input){输出在
rhs(输出,完成)
}
}
}

此方法返回一个新的闭包 ,该闭包使用给定的输入执行第一个操作方法,使用其输出执行第二个闭包 ,最后为第二个操作的结果执行给定的完成。 如果我们所有的方法都遵循Operation结构,则可以使用merge()逐步将所有步骤合并为一个操作。 我们无法真正逃脱此帮助器中的嵌套,但这允许我们在没有它们的情况下重写主方法:

  func applySepiaFilterAndUpload(picUrl:URL,完成:((User)-> Void)?){ 
let job = merge(merge(downloadPicture,to:applySephiaFilter),to:uploadUserPicture)
工作?(picUrl,完成)
}

因为操作的签名与merge()的闭包参数匹配,所以我们可以通过将方法的签名作为参数传递来跳过定义闭包的步骤。 最后, job成为一个统一的方法,该方法采用URL ,依次执行所有操作,然后最后以最后一个操作的结果执行该方法的完成处理程序。 就像第一个版本一样,但是根本没有嵌套!

现在,如果您正在考虑“但是看起来太糟糕了!” ,那是绝对正确的。 因为我们一次只能合并两个操作,所以我们需要多次调用merge() ,这将导致其内容可能比原始回调地狱更难读。 不过,有一种解决方法–我们可以为merge()的行为定义一个运算符:

 中缀运算符>>->>:LogicalConjunctionPrecedence // &&的优先级 

func >>->>(lhs:操作,rhs:操作)->操作{
返回merge(lhs,rhs)
}

通过使用&&的优先级,操作将从左开始一直逐步合并。 有了这些,我们现在可以将工作流程重写为一个很好的直线操作。

  func applySepiaFilterAndUpload(picUrl:URL,完成:((User)-> Void)?){ 
let job = downloadPicture >>->> applySepiaFilter >>->> uploadUserPicture
工作?(picUrl,完成)
}

如果您喜欢这种东西,那么这种非常具体的合并操作的正式名称就是Kleisi组合。

如果深入了解它,您会注意到,诸如回调地狱之类的事情总会归结为缺乏良好的编码实践。

干净的代码是一个大话题,但是在网络上有很多关于它的资源。 我亲自阅读并强烈推荐Robert C. Martin的“干净代码”书,因为它教您如何从其他开发人员的角度看代码-这是学习如何编写外观更好的代码时的一项出色技能。 如果您是专业开发人员,则绝对应该尝试一下。

在我的Twitter上关注我-@rockthebruno,让我知道您想分享的任何建议和更正。

承诺
克莱西组成
WWDC:高级NSOperations
异步操作


最初发布于 swiftrocks.com