使用.receive(on:)能否保证.sink()执行时@Published属性已完成更新?
添加receive(on:)无法从@Published的触发机制上保证.sink()闭包一定在属性值完成更新(didSet执行完毕)后执行,但在大多数主队列调度的场景下,会因为异步调度的特性,让闭包在赋值流程完成后运行——这是调度顺序带来的巧合,而非@Published触发逻辑的改变。
为什么默认.sink()会拿到旧值
@Published的底层实现是在属性的willSet阶段发送新值通知,执行顺序如下:
- 给
@Published属性赋值时,先进入willSet,此时属性的实际值仍是旧值,新值尚未写入 @Published在willSet里向下游发送通知,触发.sink()闭包同步执行- 等
.sink()闭包执行完成,才会完成属性的新值写入,再执行didSet
所以默认情况下,.sink()闭包执行时,直接访问属性本身拿到的还是旧值——这也是示例中不加receive(on:)时表格无法刷新新数据的原因:闭包执行时viewModel.dataSource还没更新。
receive(on:)的实际作用
receive(on:)的唯一作用是指定.sink()闭包在哪个调度器上执行。比如示例中指定RunLoop.main,相当于把闭包的执行异步推迟到当前队列的下一个循环周期。
而属性的整个赋值流程(包括willSet发通知、写入新值、执行didSet)是在当前同步流程里完成的,所以异步调度的.sink()闭包会等整个赋值流程走完才会运行——此时闭包访问属性时,新值已经写入,didSet也执行完毕,表格就能正确刷新。
但要明确:这只是调度顺序带来的结果,@Published依然在willSet阶段发送通知,并没有改变触发逻辑。
为什么它不是“根本解决方案”
这种依赖调度顺序的做法存在失效场景:
- 如果
receive(on:)指定的是并行调度器(比如全局后台队列),而属性赋值在另一个队列执行,.sink()闭包可能和属性的didSet并行运行,此时访问属性可能仍拿不到新值 - 如果当前队列被长时间同步任务阻塞,异步推迟的
.sink()闭包会被延迟执行,但这属于队列阻塞问题,而非属性更新机制的问题
如果要从机制上保证.sink()在属性完成更新后执行,更可靠的方式是:
- 直接使用
.sink()闭包的receiveValue参数(这个参数就是willSet里的新值,无需依赖属性本身的状态) - 放弃
@Published的自动通知,在属性的didSet里手动用自定义PassthroughSubject或CurrentValueSubject发送通知
示例代码对比
不添加receive(on:)时,闭包执行时属性未更新:
override func viewDidLoad() { super.viewDidLoad() viewModel.$dataSource .sink(receiveValue: { [weak self] _ in // 此时viewModel.dataSource仍是旧值 self?.tableView.reloadData() // 刷新旧数据 }) } @IBAction func addItems() { viewModel.dataSource = ["One", "Two", "Three"] }
添加receive(on: RunLoop.main)后,闭包异步执行时属性已更新:
override func viewDidLoad() { super.viewDidLoad() viewModel.$dataSource .receive(on: RunLoop.main) .sink(receiveValue: { [weak self] _ in // 此时viewModel.dataSource已完成赋值,didSet执行完毕 self?.tableView.reloadData() // 刷新新数据 }) } @IBAction func addItems() { viewModel.dataSource = ["One", "Two", "Three"] }
内容的提问来源于Stack Exchange,提问作者meaning-matters

