2.2 随时候场的替补:闭包捕获与逃逸


2.2 随时候场的替补:闭包的捕获与逃逸

本节摘要:闭包是"能捕获上下文、能被传来传去的无名函数",界面代码里所有"以后再执行"的动作都是它。本节讲闭包的三种写法、捕获语义、尾随闭包与 @escaping 逃逸,落点是:闭包会拉住它捕获的东西不放手——这个性质成就了回调,也埋下了第4章循环引用的种子。

台词本(2.1 节)解决了"立刻执行",这一节的替补演员专门接"稍后执行"的戏:网络回来后刷新列表、动画播完后清理资源、用户滑到底后加载下一页。

候场区见闻:闭包的三个形态

闭包的最简形态,就是把函数剥掉名字、把参数和返回值收进花括号:

// 完整形态:{ (参数) -> 返回类型 in 语句 } let full: (Int, Int) -> Int = { (a, b) -> Int in return a + b } // 类型可推断时:省参数类型、省返回类型、省 return let shorter: (Int, Int) -> Int = { a, b in a + b } // 极简形态:$0 $1 指代参数(参数最多三四个时才用,再多伤可读性) let shortest: (Int, Int) -> Int = { $0 + $1 } print(full(3, 4), shorter(3, 4), shortest(3, 4)) // 输出:7 7 7

排片界面里闭包出场最频繁的位置是集合的排序与变换:

let films = ["星际列车", "雾中灯塔", "深海回声"] // 按片名长度排序:闭包是排序的比较器 let byLength = films.sorted { $0.count < $1.count } print(byLength) // 输出:["深海回声", "雾中灯塔", "星际列车"] // map 变换:每个片名包上书名号 let titled = films.map { "《\($0)》" } print(titled) // 输出:["《星际列车》", "《雾中灯塔》", "《深海回声》"]

尾随闭包:界面代码的标志长相

当一个函数的最后一个参数是闭包时,闭包可以拎出括号外——这就是尾随闭包。SwiftUI 的 View 之所以长得像"描述"而不是"调用",全靠它:

// 常规调用:闭包作为参数写在括号里 func loadThen(completion: () -> Void) { print("开始加载排片数据") completion() } loadThen(completion: { print("加载完成,刷新界面") }) // 尾随闭包:拎出去,多行闭包的标配写法 loadThen { print("数据已就位") print("刷新场次列表") } // 多参数时:只保留非闭包参数在括号里 func animate(duration: Double, then: () -> Void) { print("播放 \(duration) 秒转场") then() } animate(duration: 0.3) { print("转场结束,解锁交互") }

捕获:替补演员自己带道具

闭包不是孤立的代码片段,它会把用到的外部变量"拍进随身包"带走。这个行为叫捕获,它是闭包一切魔力的来源,也是一切麻烦的起点:

func makeCounter() -> () -> Int { var count = 0 // 局部变量,函数返回后本该消亡 return { count += 1 // 闭包捕获了 count,把它带出了函数 return count } } let nextShowtime = makeCounter() print(nextShowtime()) // 输出:1 print(nextShowtime()) // 输出:2 print(nextShowtime()) // 输出:3

countmakeCounter 的局部变量,函数返回后理应销毁——但闭包捕获了它的引用,让它活到自己被销毁那天。下面这张图画清了捕获发生时内存里谁拉住了谁:

闭包捕获:随身包里装着谁

闭包捕获:随身包里装着谁

⚠️ 常见坑:在 for 循环里创建闭包并捕获循环变量,以为每个闭包拿到的是当时的快照。Swift 默认捕获的是变量本身,循环结束后闭包们看到的往往是最后一次的值。需要快照时,在循环体内先复制一份再捕获副本。

逃逸:替补比本戏活得还久

闭包作为参数传进函数后,有两种命运:函数返回前就被执行(非逃逸,默认),或被存起来稍后执行(逃逸,必须标注 @escaping)。界面代码里绝大多数回调是后者——网络层把完成闭包存下来,数据几秒后回来才调用,那时发起请求的函数早就返回了:

// 一个极简的网络模拟层 final class BoardStore { var onUpdate: (() -> Void)? // 存起来的闭包:逃逸的藏身处 // @escaping:明说这个闭包会在函数返回后才被调用 func refresh(completion: @escaping () -> Void) { onUpdate = completion // 存进属性 → 必然逃逸 print("请求已发出,等数据回来") // 模拟两秒后数据返回 // DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.fire() } } func fire() { print("数据到达,执行回调") onUpdate?() } } let store = BoardStore() store.refresh { print("刷新排片列表") // 两秒后才会执行的动作 } store.fire() // 输出: // 请求已发出,等数据回来 // 数据到达,执行回调 // 刷新排片列表

@escaping 的意义不只是"告诉编译器"。它同时提醒你:这个闭包的生命周期比当前函数长,它捕获的东西也会跟着活很久——包括 self(第4章会把这一条炸开的雷完整拆掉)。非逃逸闭包没有这个问题:函数返回时它必定已执行完毕或被丢弃,编译器甚至能优化掉部分开销,所以Swift 才把非逃逸设为默认。

落地案:刷新按钮的完整闭包链

背景:排片界面有个刷新按钮,点击后要防连点(2 秒内再点无效),数据回来后刷新并解除限制。操作分三步,全部用闭包串起来:

final class RefreshGate { private var isLocked = false func tap(action: @escaping () -> Void) { guard !isLocked else { print("冷却中,忽略连点") // 2.1 节的 guard 在这里上岗 return } isLocked = true action() // 传进来的闭包稍后解锁 } func unlock() { isLocked = false print("冷却结束,可再次刷新") } } let gate = RefreshGate() gate.tap { print("发起加载……") // 数据回来后才应调用 gate.unlock()——这里先不调用,模拟请求仍在途 } gate.tap { print("这次会被拦截") } // 冷却中,忽略连点 gate.unlock() // 模拟数据到达 gate.tap { print("解锁后的刷新正常执行") } // 输出: // 发起加载…… // 冷却中,忽略连点 // 冷却结束,可再次刷新 // 解锁后的刷新正常执行

结果解读:tap 的参数闭包是逃逸的(它要等到 unlock 才算完成使命);isLocked 被 tap 内部的 guard 守住,闭包链让"防连点"和"解锁"这两件异步发生的事共享同一个状态——解锁的时机由外部事件(数据到达)决定,而不是写死在闭包里。变式:把冷却时间做成参数,或让 tap 接收 (Result<[String], Error>) -> Void 类型的闭包——后者正是 2.3 节的剧情入口。

后台小结

  • 闭包 = 无名函数值:三种形态从完整到极简,按可读性取舍;
  • 尾随闭包是界面代码的标配长相,SwiftUI 全靠它撑起声明式语法;
  • 捕获按引用:闭包改的是原变量,闭包活着,捕获的对象就不退场;
  • @escaping 标注意味着闭包比函数活得久,捕获随之延长——第4章的雷在这里埋下;
  • 回调、比较器、动画完成、防抖,界面里"稍后执行"的事都是闭包的戏份。

台词能推迟了,但推迟回来的可能是坏消息:服务器 500、解析失败、没权限。下一节写演出事故的救场预案——Swift 的错误处理。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U