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

SwiftUI中EnvironmentObject关联的任务进度UI无法及时更新求助

Swift克隆任务UI刷新问题及struct改造报错解决

问题描述

开发Git GUI客户端时,实现仓库克隆功能并通过Popover查看所有克隆任务及进度,遇到两个问题:

  1. 点击Start按钮更新任务进度后,UI无法及时刷新,仅当新增任务时才会更新旧任务的UI(推测数据已更新)
  2. 将CloneTaskModel从class改为struct后,出现报错:无法在不可变值上使用可变成员'start()'(原报错英文:Cannot use mutating member on immutable value: 'task' is a 'let' constant)

原Class版本代码

class CloneTaskModel: Identifiable, ObservableObject {
  var id: String = UUID().uuidString
  
  var title: String
  @Published var progress: CGFloat = 0
  
  init(title: String) {
    self.title = title
  }
  
  func start() {
    self.progress = 50
  }
}

class CloneTaskViewModel: ObservableObject {
  @Published var tasks: [CloneTaskModel] = []
  
  func clone(title: String) {
    self.tasks.append(CloneTaskModel(title: title))
  }
  
  func deleteCloneTask(indexSet: IndexSet) {
    self.tasks.remove(atOffsets: indexSet)
  }
}

struct ObservableObjectBootcamp: View {
  @StateObject var cloneTaskViewModel: CloneTaskViewModel = CloneTaskViewModel()
  
  var body: some View {
    NavigationSplitView {
      List {
        ForEach(0..<10, id: \.self) { item in
          HStack {
            Text("\(item)")
            
            Spacer()
            
            Button("Clone", action: {
              cloneTaskViewModel.clone(title: "Task \(item)")
            })
          }
        }
      }
    } detail: {
      CloneTaskPopover()
        .environmentObject(cloneTaskViewModel)
    }
   
  }
}

struct CloneTaskPopover: View {
  @EnvironmentObject var cloneTaskViewModel: CloneTaskViewModel
  
  var body: some View {
    List {
      ForEach(cloneTaskViewModel.tasks) { task in
        HStack {
          Text(task.title)
            .font(.title)
          
          ProgressView(value: task.progress, total: 100)
          
          Button("Start") {
            task.start()
          }
        }
      }
      .onDelete(perform: cloneTaskViewModel.deleteCloneTask)
    }
    .navigationTitle("Clone Tasks")
  }
}

修改后的Struct版本代码(报错版本)

struct CloneTaskModel: Identifiable {
  var id: String = UUID().uuidString
  
  var title: String
  var progress: CGFloat = 0
  
  init(title: String) {
    self.title = title
  }
  
  mutating func start() {
    self.progress = 50
  }
}

问题分析与解决

1. Class版本UI不刷新的解决

问题原因

CloneTaskViewModel中的tasks数组通过@Published修饰,但数组内的CloneTaskModel对象属性更新时,数组本身的引用并未改变,@Published只会在数组的元素增删、整体替换时触发视图刷新,单个对象的属性变化无法触发通知。

修复代码

在ViewModel中添加更新任务进度的方法,手动触发视图刷新:

class CloneTaskViewModel: ObservableObject {
  @Published var tasks: [CloneTaskModel] = []
  
  func clone(title: String) {
    self.tasks.append(CloneTaskModel(title: title))
  }
  
  func deleteCloneTask(indexSet: IndexSet) {
    self.tasks.remove(atOffsets: indexSet)
  }
  
  // 新增:通过任务ID更新进度
  func updateTaskProgress(for taskId: String) {
    guard let taskIndex = tasks.firstIndex(where: { $0.id == taskId }) else { return }
    tasks[taskIndex].progress = 50
    // 手动发送变化通知,强制UI刷新
    objectWillChange.send()
  }
}

// 修改Popover中的按钮点击逻辑
struct CloneTaskPopover: View {
  @EnvironmentObject var cloneTaskViewModel: CloneTaskViewModel
  
  var body: some View {
    List {
      ForEach(cloneTaskViewModel.tasks) { task in
        HStack {
          Text(task.title)
            .font(.title)
          
          ProgressView(value: task.progress, total: 100)
          
          Button("Start") {
            cloneTaskViewModel.updateTaskProgress(for: task.id)
          }
        }
      }
      .onDelete(perform: cloneTaskViewModel.deleteCloneTask)
    }
    .navigationTitle("Clone Tasks")
  }
}

2. Struct版本报错的解决

问题原因

ForEach循环中取出的task是不可变的let常量,而struct的start()是mutating方法,无法直接在不可变实例上调用。同时struct是值类型,直接修改实例不会同步到数组中,需要通过ViewModel替换数组内的元素来触发@Published的刷新。

修复代码

struct CloneTaskModel: Identifiable {
  var id: String = UUID().uuidString
  var title: String
  var progress: CGFloat = 0
  
  init(title: String) {
    self.title = title
  }
  
  mutating func start() {
    self.progress = 50
  }
}

class CloneTaskViewModel: ObservableObject {
  @Published var tasks: [CloneTaskModel] = []
  
  func clone(title: String) {
    self.tasks.append(CloneTaskModel(title: title))
  }
  
  func deleteCloneTask(indexSet: IndexSet) {
    self.tasks.remove(atOffsets: indexSet)
  }
  
  // 新增:启动指定任务
  func startTask(for taskId: String) {
    guard let taskIndex = tasks.firstIndex(where: { $0.id == taskId }) else { return }
    var updatedTask = tasks[taskIndex]
    updatedTask.start()
    // 替换数组中的元素,触发@Published刷新
    tasks[taskIndex] = updatedTask
  }
}

// 修改Popover中的按钮点击逻辑
struct CloneTaskPopover: View {
  @EnvironmentObject var cloneTaskViewModel: CloneTaskViewModel
  
  var body: some View {
    List {
      ForEach(cloneTaskViewModel.tasks) { task in
        HStack {
          Text(task.title)
            .font(.title)
          
          ProgressView(value: task.progress, total: 100)
          
          Button("Start") {
            cloneTaskViewModel.startTask(for: task.id)
          }
        }
      }
      .onDelete(perform: cloneTaskViewModel.deleteCloneTask)
    }
    .navigationTitle("Clone Tasks")
  }
}

总结

  • Class版本:需通过ViewModel统一修改任务属性,并手动发送变化通知,确保UI感知到更新
  • Struct版本:需通过ViewModel替换数组内的任务实例,利用值类型的特性触发@Published的刷新,同时避免直接在循环中调用mutating方法

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.15 18:03:08