You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI视图偶发无法更新:加载状态卡死问题求助

SwiftUI视图随机卡在加载界面问题排查与解析

问题描述

在学习SwiftUI时遇到一个随机触发的问题:示例App运行时点击Reload按钮,视图会偶尔卡在加载界面,始终显示ProgressView,不再响应任何状态更新。调试时无法复现,推测和RunLoop周期或SwiftUI视图更新时机有关。已知:

  • 切换到.loading状态始终正常,视图更新符合预期
  • 切换到.error或.loaded状态大多正常,但偶发失效,这些状态切换为异步操作

ViewModel代码

import Foundation
import Combine

public final class TransactionContainerViewModel: ObservableObject {

    public enum ViewState<T> {
        case loading
        case loaded(T)
        case error(message: String)
    }

    @Published public var state: ViewState<[TransactionViewModel]> = .loading

    private var disposable: AnyCancellable?

    public typealias TransactionsPublisher = AnyPublisher<[TransactionItem], Error>

    private let loader: TransactionsPublisher

    public init(loader: TransactionsPublisher) {
        self.loader = loader
    }

    public func loadTransactions() {
        disposable?.cancel()
        state = .loading // 1

        disposable = loader.sink { [weak self] result in
            switch result {
            case .failure:
                self?.state = .error(message: "Some error") // 2

            case .finished: break
            }
        } receiveValue: { [weak self] items in
            let sortedTransactions = items.sorted { $0.bookingDate > $1.bookingDate }
            let transactionsVM = sortedTransactions.map { item in
                TransactionViewModel(transaction: item)
            }
            self?.state = .loaded(transactionsVM) // 3
        }
    }
}

state属性有三处更新点(标注为1、2、3)。

Loader构建代码

let upstreamQueue = DispatchQueue.global()
let client = createMockedHTTPClient()
let mockedLoader: AnyPublisher<[TransactionItem], Error> = client.getPublisher(url: url)
  .subscribe(on: upstreamQueue)
  .delay(for: .seconds(1), tolerance: .milliseconds(500), scheduler: upstreamQueue)
  .tryMap(TransactionsMapper.map)
  .receive(on: RunLoop.main)
  .eraseToAnyPublisher()

View代码

import SwiftUI

struct TransactionContainerView: View {

    @ObservedObject private var vm: TransactionContainerViewModel

    init(viewModel: TransactionContainerViewModel) {
        self.vm = viewModel
    }

    var body: some View {
        VStack {
            switch vm.state {
            case .loading:
                ProgressView()

            case let .loaded(transactions):
                Text("Loaded: \(transactions.count) transactions")
                Button("Reload") {
                    vm.loadTransactions()
                }

            case let .error(message):
                Text(message)
            }
        }.onAppear {
            vm.loadTransactions()
        }
    }
}

更新与疑问

更新:
已定位问题诱因:Loader使用DispatchQueue.global()创建的并发队列会导致问题,改为串行队列后问题解决。排查中发现存在订阅未输出值就触发completed的情况,想了解具体原因。


问题根源解析

问题出在并发队列的竞态条件以及Combine订阅的生命周期管理上:

  1. 并发队列的竞态冲突
    DispatchQueue.global()是全局并发队列,当频繁点击Reload按钮时,loadTransactions()会反复取消旧订阅、创建新订阅。在并发队列上执行的上游操作(如delay、tryMap)可能出现竞态:旧订阅的取消信号和新订阅的执行信号在多线程上交错执行,导致Combine内部的订阅状态混乱,最终出现"未输出值就触发completed"的异常——此时sink仅收到.finished事件,却没有收到receiveValue或.failure,ViewModel的state就一直停留在.loading,视图自然不会更新。

  2. Combine订阅的线程安全约束
    Combine的Publisher在并发队列上运行时,如果没有正确处理线程同步,订阅的生命周期(取消、创建)可能出现异常。串行队列会强制所有上游操作按顺序执行:旧订阅的取消操作完全完成后,才会启动新的订阅,从根本上避免了竞态条件,确保每个订阅的事件(值、完成、失败)都能正确传递到sink。

  3. @Published的事件传递可靠性
    虽然@Published属性本身保证线程安全,但如果上游事件的发送线程存在状态混乱,可能导致更新事件丢失。当并发队列上的订阅异常完成时,state的更新信号根本没有发出,SwiftUI无法感知状态变化,视图就会一直卡在加载界面。

额外优化建议

  • 除了改用串行队列,还可以给loadTransactions()添加原子性保护,避免disposable的并发修改:
private let lock = NSLock()

public func loadTransactions() {
    lock.lock()
    disposable?.cancel()
    state = .loading
    disposable = loader.sink { [weak self] result in
        switch result {
        case .failure:
            self?.state = .error(message: "Some error")
        case .finished:
            // 防御性处理:避免状态永久停留在loading
            if self?.state == .loading {
                self?.state = .error(message: "加载无结果")
            }
        }
    } receiveValue: { [weak self] items in
        let sortedTransactions = items.sorted { $0.bookingDate > $1.bookingDate }
        let transactionsVM = sortedTransactions.map(TransactionViewModel.init)
        self?.state = .loaded(transactionsVM)
    }
    lock.unlock()
}
  • 在sink的.finished分支添加防御逻辑,防止视图因异常完成永久卡在加载状态。

内容的提问来源于stack exchange,提问作者Marcin Kapusta

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.13 20:13:14