ByteNoteByteNote
sink 要留住句柄,闭包里用 weak self
字

字节笔记本

2026年10月7日 · 约 8 分钟读完

sink 要留住句柄,闭包里用 weak self

API中转
¥120

一个 macOS 上的 AI 对话窗口,消息从模型那边持续到来。SwiftUI 这边用 Combine 订阅。订阅写起来就是 sink,但闭包里一写 self,对象和订阅就会互相抓住。Swift 没有垃圾回收器,引用计数不会在这种环里自动变 0。

订阅句柄必须留下来

sink 是订阅,返回值要留着

sink 用来接一个发布者的值和结束事件,不用自己写订阅者类型。数组就能变成发布者:

swift
let cancellable = [1, 2, 3].publisher.sink(
    receiveCompletion: { completion in
        if case .finished = completion {
            print("发布完成")
        }
    },
    receiveValue: { value in
        print(value)
    }
)

receiveValue 是每来一个值叫一次。receiveCompletion 是结束,要么正常完成,要么失败。返回的 AnyCancellable 必须被持有。放进局部变量、函数一返回就释放的话,订阅会立刻取消,你只会以为发布者没发东西。聊天窗口通常把 cancellable 存在视图模型的属性里,或者放进一个 Set<AnyCancellable>。

从 JavaScript 的角度看,sink 接近 subscribe,而 AnyCancellable 接近退订函数。没有人持有退订句柄,并不等于订阅会一直活着。Combine 相反:没有人持有 cancellable,订阅就结束。

没有垃圾回收,环就拆不掉

Swift 用自动引用计数。每个对象有一个计数,多一个强引用加一,少一个减一,到 0 就销毁。这不是隔一段时间扫描一遍的垃圾回收。两个对象互相强引用时,计数都不会到 0。对话里的比喻是两个人互相拉着手,谁也不先放开。

weak 不增加计数。闭包捕获列表写成 [weak self],闭包不把对象留住。对象先走了,闭包里的 self 是可选的,要用 guard let self else { return } 再继续。聊天请求还在飞、窗口已经关了,就应该直接返回,不要往一个已经销毁的界面写消息。

什么时候需要它:闭包可能比对象活得更久。网络完成回调、定时器、sink 都算。按钮的同步动作里立刻用 self,一般不用 weak,因为闭包不会留下来。

escaping 和 weak 是一对

@escaping 表示这个闭包会在函数返回之后才调用。fetchData 把完成回调丢给两秒后的队列,函数本身早就返回了。这样的闭包如果强引用 self,而 self 又把这次任务存下来,环就出现了。

所以逃逸闭包里访问自己的属性,捕获列表用 weak。不是语法上必须两个关键字挨着写,是它们解决的时间问题叠在一起:闭包活过了当前函数,对象却可能先消失。非逃逸闭包在函数返回前一定调用完,编译器也不允许它逃出,那里通常不必 weak。

JavaScript 没有这套引用计数。闭包抓住对象是常态,回收靠可达性。把 JS 的习惯直接搬进 sink,窗口关了消息还在追加,或者界面已不在、任务却把视图模型留在内存里。看是不是环,看的是谁持有 cancellable、cancellable 的闭包又抓住了谁。

聊天窗口里,视图模型持有订阅,订阅的闭包更新消息数组。这就是典型的一对。写成 [weak self],窗口释放时订阅可以跟着取消。在 onDisappear 或析构里 cancel(),比等到进程压力大了再发现内存不掉更直接。

句柄留住,对象用弱引用

sink 的返回值要放在属性里,否则订阅立刻没了。闭包如果会在对象销毁之后还被调用,用 weak self。Swift 靠引用计数,不靠垃圾回收,互相强引用的两边都不会自己放开。逃逸闭包是最常和 weak 一起出现的地方,因为完成回调本来就会比函数活得久。

弱引用用来拆开互相抓住的环

完成事件和失败不要混在一个 print 里

sink 的 completion 是一个枚举。finished 表示发布者正常结束。failure 带错误。聊天流如果把断线和“模型说完了”都打印成同一句完成,界面会在网络失败时显示成对话结束。分支至少要分开。值的闭包里只追加消息,完成闭包里再改状态,例如停止加载指示。

发布者可能在后台队列发值。往 SwiftUI 的已发布属性写,要回到主队列。不切回主线程,症状是偶发的界面不刷新或运行时警告,而不是编译错误。sink 本身不保证在主线程。窗口这个例子里,模型令牌如果从网络回调进来,receiveValue 里用主队列包一层再改数组。

Set 收集多个 cancellable 时,用 store(in:)。聊天既订阅模型输出,又订阅输入框的去抖,就是两个订阅。只把最后一个赋给同一个变量,前一个会在赋值时取消。输入去抖突然失效,常常是变量被后一次 sink 覆盖了。

弱引用之后,guard 失败就 return。不要在失败时强制解包。窗口已经没了还强制用 self,会直接崩,比内存泄漏更明显。取消订阅可以放在对象销毁时。视图模型如果比视图活得更久,要在视图消失时明确取消,否则模型还在收令牌、弱引用一直是空,请求却还占着连接。

JavaScript 里发布订阅通常是 on 和 off。off 要自己记得调用。Combine 的退订是丢掉或 cancel 那个 cancellable。两种都容易漏,漏的方向相反:JS 漏 off 会一直收到旧回调,Combine 漏保存句柄会立刻收不到。对照着记,比背关键字有用。

闭包语法上,捕获列表写在参数列表前面。写成函数参数的默认值是无效的。从语法看,它是这段闭包开始时如何捕获外部名字的声明,不是调用时再传的实参。weak 只影响 self 的引用方式,不改变 receiveValue 的参数类型。 聊天列表如果用发布者合并历史消息和流式增量,sink 里替换整段文本时不要在弱引用失败后继续用旧数组。guard 失败就放弃这一段增量。下一次打开窗口会重新订阅,不需要把半截令牌写进一个已经释放的模型。

测试泄漏可以用很笨的办法:反复打开关闭窗口,看内存是否阶梯上升。上升通常是 cancellable 或闭包还抓着视图模型。 Instruments 能确认,但第一眼看阶梯就够判断 weak 有没有写上。写了 weak 仍上升,检查是不是另一个没写捕获列表的闭包,例如定时器。

Combine 以外的回调,规则相同。URLSession 的完成处理、动画结束、通知中心的闭包,只要是逃逸的,就按同一标准看要不要 weak。不要只在 sink 这一个 API 上记这个关键字。 接收值的闭包要短。把网络解析、磁盘和界面更新堆在一个 sink 里,弱引用的范围会大到很难审查。解析可以在订阅外做好,sink 只负责把结果赋给已发布属性。属性赋值仍然要在主队列。这样 weak 只保护这一次赋值,不会包住整个请求栈。

取消是幂等的。多调用一次 cancel 不应成为错误处理的分支。窗口消失和视图模型销毁都调用,写两次也没问题。怕的是一次都不调用,同时又强引用 self。 句柄放在属性里,闭包用弱引用,完成和失败分开处理。这三件做到,对话窗口的订阅就不会因为一次关闭而把界面留在内存里。

相关文章

分享: