在 SwiftData 中使用 @Bindable 的性能问题

在 SwiftData 中直接使用 @Bindable 绑定并更新值,可能导致性能方面的问题。

在 SwiftData 中使用 @Bindable 的性能问题

@Bindable 是从 iOS17 开始引入的一个新的属性包装器,是新的 Observation 框架的一部分,也适用于 SwiftData。

@Bindable 的核心优势

@Bindable 允许我们直接绑定到引用类型对象的属性,而无需创建中间状态变量,可以显著减少样板代码。

要实现类似下面这样修改数据的功能:

0:00
/0:07

为了使用 TextField 来提供用户输入的功能,我们需要为每个字段创建一个临时变量,来存储用户输入的数据,并在保存时更新到 SwiftData 中:

import SwiftData
import SwiftUI

struct EditMovieTheaterInfoSheet: View {
    @Environment(\.dismiss) private var dismiss
    @Environment(\.modelContext) private var modelContext
    var bookmark: Bookmark

    // 使用临时变量来存储所有字段的值
    @State private var theaterName: String = ""
    @State private var showDate: String = ""
    @State private var showTime: String = ""
    @State private var hall: String = ""
    @State private var seat: String = ""
    @State private var price: String = ""

    // 添加一个日期选择器状态
    @State private var selectedDate: Date = Date()
    @State private var showDatePicker: Bool = false

    var body: some View {
        NavigationStack {
            Form {
                Section("电影院信息") {
                    TextField("电影院名称", text: $theaterName)
                }

                Section("放映信息") {
                    // 日期选择
                    HStack {
                        Text("放映日期")
                        Spacer()
                        Button(showDate.isEmpty ? "点击选择" : showDate) {
                            showDatePicker.toggle()
                        }
                        .foregroundColor(
                            showDate.isEmpty ? .secondary : .primary)
                    }

                    if showDatePicker {
                        DatePicker(
                            "选择日期", selection: $selectedDate,
                            displayedComponents: .date
                        )
                        .datePickerStyle(.graphical)
                        .onChange(of: selectedDate) { oldValue, newValue in
                            let formatter = DateFormatter()
                            formatter.dateFormat = "yyyy/MM/dd"
                            showDate = formatter.string(from: newValue)
                        }
                    }

                    // 时间输入
                    TextField("放映时间 (例如 19:30)", text: $showTime)
                }

                Section("座位信息") {
                    TextField("影厅", text: $hall)
                    TextField("座位", text: $seat)
                    TextField("票价", text: $price)
                        .keyboardType(.decimalPad)
                }
            }
            .navigationTitle("观影记录")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("取消") {
                        dismiss()
                    }
                }

                ToolbarItem(placement: .confirmationAction) {
                    Button("保存") {
                        // 保存所有临时变量到bookmark对象
                        bookmark.movieDetails.theaterName =
                            theaterName.isEmpty ? nil : theaterName
                        bookmark.movieDetails.showDate =
                            showDate.isEmpty ? nil : showDate
                        bookmark.movieDetails.showTime =
                            showTime.isEmpty ? nil : showTime

                        // 更新原有字段
                        bookmark.movieDetails.hall = hall
                        bookmark.movieDetails.seat = seat
                        bookmark.movieDetails.price = price

                        // 保存更改到数据库
                        try? modelContext.save()

                        dismiss()
                    }
                }
            }
            .onAppear {
                // 初始化所有临时变量
                theaterName = bookmark.movieDetails.theaterName ?? ""
                showDate = bookmark.movieDetails.showDate ?? ""
                showTime = bookmark.movieDetails.showTime ?? ""
                hall = bookmark.movieDetails.hall
                seat = bookmark.movieDetails.seat
                price = bookmark.movieDetails.price

                // 如果有日期,尝试解析为Date对象
                if !showDate.isEmpty {
                    let formatter = DateFormatter()
                    formatter.dateFormat = "yyyy/MM/dd"
                    if let date = formatter.date(from: showDate) {
                        selectedDate = date
                    }
                }
            }
        }
    }
}

使用 @Bindable 能让代码更加干净,可以使用 TextField 直接绑定 SwiftData 对象的值即可:

import SwiftData
import SwiftUI

struct EditMovieTheaterInfoSheet: View {
    @Environment(\.dismiss) private var dismiss
    @Bindable var bookmark: Bookmark
    
    // 使用临时变量来存储可选字段的值
    @State private var theaterName: String = ""
    @State private var showDate: String = ""
    @State private var showTime: String = ""
    
    // 添加一个日期选择器状态
    @State private var selectedDate: Date = Date()
    @State private var showDatePicker: Bool = false
    
    var body: some View {
        NavigationStack {
            Form {
                Section("电影院信息") {
                    TextField("电影院名称", text: $theaterName)
                }
                
                Section("放映信息") {
                    // 日期选择
                    HStack {
                        Text("放映日期")
                        Spacer()
                        Button(showDate.isEmpty ? "点击选择" : showDate) {
                            showDatePicker.toggle()
                        }
                        .foregroundColor(showDate.isEmpty ? .secondary : .primary)
                    }
                    
                    if showDatePicker {
                        DatePicker("选择日期", selection: $selectedDate, displayedComponents: .date)
                            .datePickerStyle(.graphical)
                            .onChange(of: selectedDate) { oldValue, newValue in
                                let formatter = DateFormatter()
                                formatter.dateFormat = "yyyy/MM/dd"
                                showDate = formatter.string(from: newValue)
                            }
                    }
                    
                    // 时间输入
                    TextField("放映时间 (例如 19:30)", text: $showTime)
                }

                Section("座位信息") {
                    TextField("影厅", text: $bookmark.movieDetails.hall)
                    TextField("座位", text: $bookmark.movieDetails.seat)
                    TextField("票价", text: $bookmark.movieDetails.price)
                        .keyboardType(.decimalPad)
                }
            }
            .navigationTitle("观影记录")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("取消") {
                        dismiss()
                    }
                }

                ToolbarItem(placement: .confirmationAction) {
                    Button("保存") {
                        // 保存临时变量到可选字段
                        bookmark.movieDetails.theaterName = theaterName.isEmpty ? nil : theaterName
                        bookmark.movieDetails.showDate = showDate.isEmpty ? nil : showDate
                        bookmark.movieDetails.showTime = showTime.isEmpty ? nil : showTime
                        
                        dismiss()
                    }
                }
            }
            .onAppear {
                // 初始化临时变量
                theaterName = bookmark.movieDetails.theaterName ?? ""
                showDate = bookmark.movieDetails.showDate ?? ""
                showTime = bookmark.movieDetails.showTime ?? ""
                
                // 如果有日期,尝试解析为Date对象
                if !showDate.isEmpty {
                    let formatter = DateFormatter()
                    formatter.dateFormat = "yyyy/MM/dd"
                    if let date = formatter.date(from: showDate) {
                        selectedDate = date
                    }
                }
            }
        }
    }
}

@Bindable 带来的性能考量

使用上述实现时,在 TextField 中输入会出现明显延迟。这是因为:

  1. 每次对 SwiftData 模型的更新都会触发 SwiftUI 的渲染更新
  2. 当用户在输入框中键入时,每个字符都会导致一次模型更新
  3. 这些频繁更新导致 SwiftUI 重新评估视图层次结构,造成性能瓶颈
0:00
/0:03

因此,在 SwiftData 中使用 Bindable 时,并不适合在 TextField 使用。

选择性使用 @Bindable

并非所有 SwiftUI 组件在使用 @Bindable 时都会遇到性能问题,应当根据组件的交互频率和更新方式来选择。

适合直接使用 @Bindable 的组件

以下组件通常不会引起明显的性能问题,适合直接绑定到 SwiftData 模型:

  • Picker:由于只在用户完成选择时才更新值,不会产生频繁更新
  • Toggle:通常只有两种状态切换,更新频率低
  • Stepper:每次点击才更新一次,更新频率较低
  • DatePicker(带有合理的交互模式):通常用户会一次性选择日期
  • Slider(当设置了 .onEnded 回调时):可以只在滑动结束时更新模型
  • ColorPicker:通常一次性选择颜色,不会频繁更新
// 适合直接使用@Bindable的例子
Picker("类别", selection: $bookmark.category) {
    ForEach(Category.allCases, id: \.self) { category in
        Text(category.rawValue).tag(category)
    }
}

Toggle("收藏", isOn: $bookmark.isFavorite)

Stepper("评分: \(bookmark.rating)", value: $bookmark.rating, in: 1...5)

不适合直接使用 @Bindable 的组件

以下组件在直接绑定到 SwiftData 模型时可能会导致性能问题:

  • TextField:每个字符输入都会触发更新,导致频繁的模型变更和UI刷新
  • TextEditor:和 TextField 类似,会产生高频率的更新
  • 实时滑动的 Slider:如果没有使用 .onEnded 回调,会在整个滑动过程中持续更新
  • 带有拖动手势的组件:如自定义拖动控件,可能会产生大量更新
  • 搜索框:用户快速输入会导致频繁更新

对于这些组件,建议使用中间状态变量:

// 使用中间状态变量的例子
@State private var titleText: String = ""

var body: some View {
    TextField("标题", text: $titleText)
        .onSubmit {
            // 只在提交时更新模型
            bookmark.title = titleText
        }
}

// 初始化
.onAppear {
    titleText = bookmark.title
}