Combine + SwiftUI Form + RunLoop 导致表格视图呈现不可预测

Posted

技术标签:

【中文标题】Combine + SwiftUI Form + RunLoop 导致表格视图呈现不可预测【英文标题】:Combine + SwiftUI Form + RunLoop causes table view to render unpredictably 【发布时间】:2021-04-17 00:44:14 【问题描述】:

我有一个合并函数,用于搜索项目列表并返回匹配项。它不仅跟踪要向用户显示的与搜索词匹配的项目,还跟踪哪些项目已被用户标记为“已选择”。

这个功能很好用,包括动画,直到我在合并发布者链中添加.debounce(for: .seconds(0.2), scheduler: RunLoop.main) .receive(on: RunLoop.main)。此时,View 中的结果呈现变得莫名其妙——项目标题开始显示为标题视图、项目重复等等。

您可以在随附的 GIF 中看到结果。

GIF 版本使用的是.receive(on: RunLoop.main)。请注意,我什至不在这里使用搜索词,尽管它会导致有趣的结果。还可能值得注意的是,如果删除了withAnimation ,则所有问题行都可以正常工作。

我希望能够使用debounce,因为列表最终可能会很大,我不想在每次击键时过滤整个列表。

在这些情况下如何让表格视图正确呈现?

示例代码(请参阅内联 cmets 了解代码的痛点和解释。它应该运行良好,但如果两个相关行中的任何一个未注释):


import SwiftUI
import Combine
import UIKit

class Completer : ObservableObject 
    @Published var items : [Item] = [] 
        didSet 
            setupPipeline()
        
    
    @Published var filteredItems : [Item] = []
    @Published var chosenItems: Set<Item> = []
    @Published var searchTerm = ""
    
    private var filterCancellable : AnyCancellable?
    
    private func setupPipeline() 
        filterCancellable =
            Publishers.CombineLatest($searchTerm,$chosenItems) //listen for changes of both the search term and chosen items
            .print()
            // ** Either of the following lines, if uncommented will cause chaotic rendering of the table **
            //.receive(on: RunLoop.main) //<----- HERE --------------------
            //.debounce(for: .seconds(0.2), scheduler: RunLoop.main) //<----- HERE --------------------
            .map  (term,chosen) -> (filtered: [Item],chosen: Set<Item>) in
                if term.isEmpty  //if the term is empty, return everything
                    return (filtered: self.items, chosen: chosen)
                 else  //if the term is not empty, return only items that contain the search term
                    return (filtered: self.items.filter  $0.name.localizedStandardContains(term) , chosen: chosen)
                
            
            .map  (filtered,chosen) in
                (filtered: filtered.filter  !chosen.contains($0) , chosen: chosen) //don't include any items in the chosen items list
            
            .sink  [weak self] (filtered, chosen) in
                self?.filteredItems = filtered
            
    
    
    func toggleItemChosen(item: Item) 
        withAnimation 
            if chosenItems.contains(item) 
                chosenItems.remove(item)
             else 
                searchTerm = ""
                chosenItems.insert(item)
            
        
    


struct ContentView: View 
    @StateObject var completer = Completer()
    
    var body: some View 
        Form 
            Section 
                TextField("Term", text: $completer.searchTerm)
            
            Section 
                ForEach(completer.filteredItems)  item in
                    Button(action: 
                        completer.toggleItemChosen(item: item)
                    ) 
                        Text(item.name)
                    .foregroundColor(completer.chosenItems.contains(item) ? .red : .primary)
                
            
            if completer.chosenItems.count != 0 
                Section(header: HStack 
                    Text("Chosen items")
                    Spacer()
                    Button(action: 
                        completer.chosenItems = []
                    ) 
                        Text("Clear")
                    
                ) 
                    ForEach(Array(completer.chosenItems))  item in
                        Button(action: 
                            completer.toggleItemChosen(item: item)
                        ) 
                            Text(item.name)
                        
                    
                
            
        .onAppear 
            completer.items = ["Chris", "Greg", "Ross", "Damian", "George", "Darrell", "Michael"]
                .map  Item(name: $0) 
        
    


struct Item : Identifiable, Hashable 
    var id = UUID()
    var name : String


【问题讨论】:

id ForEach(Array(completer.chosenItems)) 可能是问题所在 我之前也发生过类似的事情,因为每个项目都没有唯一的 ID - 不确定,但 Array 可能会删除您的 Itemid 不,很遗憾,似乎不是这样。将chosenItems 更改为[Item] 而不是Set,因此我可以直接在ForEach 中使用它(包括显式指定id)无效。另外,它仍然保持其类型 (Item),即 Identifiable 并具有 id 属性,因此没有任何内容被“擦除” 我没有答案,但是如果您取出“withAnimation”,它似乎可以与包含的 .debounce 一起使用。 @nicksarno 是的,我可能应该将其添加到问题中。最初我发现了这个问题,因为一切都很好,然后我添加了 withAnimation 并且它坏了,这导致我将罪魁祸首缩小到 RunLoop 的东西 【参考方案1】:

处理异步处理的问题...在您的默认情况下,所有操作都同步执行在一个(!)动画块内,所以一切正常。但是在第二种情况下(通过在发布者链中引入任何调度程序)一些操作是同步执行的(如删除)以启动动画,但是发布者的操作在动画已经在进行时异步进行,并且更改模型会破坏正在运行的动画给出不可预知的结果。

解决此问题的可能方法是通过不同的块将启动和结果操作分开,并使发布者真正异步但在后台处理并在主队列中检索结果。

这里是修改后的发布者链。使用 Xcode 12.4 / ios 14.4 测试

注意:您也可以调查将所有内容再次包装在一个动画块中的可能性,但在检索结果后已经在 synk 中 - 这将需要更改逻辑,因此仅供参考

private func setupPipeline() 
    filterCancellable =
        Publishers.CombineLatest($searchTerm,$chosenItems)
        .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)   // debounce input
        .subscribe(on: DispatchQueue.global(qos: .background))         // prepare for processing in background
        .print()
        .map  (term,chosen) -> (filtered: [DItem],chosen: Set<DItem>) in
            if term.isEmpty  //if the term is empty, return everything
                return (filtered: self.items, chosen: chosen)
             else  //if the term is not empty, return only items that contain the search term
                return (filtered: self.items.filter  $0.name.localizedStandardContains(term) , chosen: chosen)
            
        
        .map  (filtered,chosen) in
            (filtered: filtered.filter  !chosen.contains($0) , chosen: chosen) //don't include any items in the chosen items list
        
        .receive(on: DispatchQueue.main) // << receive processed items on main queue
        .sink  [weak self] (filtered, chosen) in
            withAnimation 
                self?.filteredItems = filtered      // animating this as well
                
        

【讨论】:

非常有趣——我可能会使用它,因为它确实导致了一个可行的解决方案,但是,在测试时我发现对我来说,你的代码似乎工作如果我将去抖时间改回 0.2 而不是 0.5,则与我的完全相同——事实上,其他 subscribeDispatchQueuewithAnimation 似乎都不重要——这只是去抖窗口的事实更长(因此可能在初始动画完成之后)。你能确认当你用 0.2 尝试时会发生同样的情况吗?【参考方案2】:

@Asperi 的建议让我在思考会调用多少个withAnimation 事件时走上正轨。在我最初的问题中,receive(on:)debounce 使用时,filteredItemschosenItems 将在 RunLoop 的不同迭代中发生变化,这似乎是不可预测的布局行为的根本原因。

通过将 debounce 时间更改为更长的值,这将防止问题发生,因为一个动画将在另一个动画完成后完成,但这是一个有问题的解决方案,因为它依赖于动画时间(如果没有发送明确的动画时间,可能还有幻数)。

我设计了一个有点俗气的解决方案,它使用PassThroughSubject 代替chosenItems,而不是直接分配给@Published 属性。通过这样做,我可以将@Published 值的所有分配移动到sink,从而只发生一个 动画块。

我对这个解决方案并不感兴趣,因为这感觉像是不必要的破解,但它似乎确实解决了问题:


class Completer : ObservableObject 
    @Published var items : [Item] = [] 
        didSet 
            setupPipeline()
        
    
    @Published private(set) var filteredItems : [Item] = []
    @Published private(set) var chosenItems: Set<Item> = []
    @Published var searchTerm = ""
    
    private var chosenPassthrough : PassthroughSubject<Set<Item>,Never> = .init()
    private var filterCancellable : AnyCancellable?
    
    private func setupPipeline() 
        filterCancellable =
            Publishers.CombineLatest($searchTerm,chosenPassthrough)
            .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
            .map  (term,chosen) -> (filtered: [Item],chosen: Set<Item>) in
                if term.isEmpty 
                    return (filtered: self.items, chosen: chosen)
                 else 
                    return (filtered: self.items.filter  $0.name.localizedStandardContains(term) , chosen: chosen)
                
            
            .map  (filtered,chosen) in
                (filtered: filtered.filter  !chosen.contains($0) , chosen: chosen)
            
            .sink  [weak self] (filtered, chosen) in
                withAnimation 
                    self?.filteredItems = filtered
                    self?.chosenItems = chosen
                
            
        chosenPassthrough.send([])
    
    
    func toggleItemChosen(item: Item) 
        if chosenItems.contains(item) 
            var copy = chosenItems
            copy.remove(item)
            chosenPassthrough.send(copy)
         else 
            var copy = chosenItems
            copy.insert(item)
            chosenPassthrough.send(copy)
        
        searchTerm = ""
    
    
    func clearChosen() 
        chosenPassthrough.send([])
    


struct ContentView: View 
    @StateObject var completer = Completer()
    
    var body: some View 
        Form 
            Section 
                TextField("Term", text: $completer.searchTerm)
            
            Section 
                ForEach(completer.filteredItems)  item in
                    Button(action: 
                        completer.toggleItemChosen(item: item)
                    ) 
                        Text(item.name)
                    .foregroundColor(completer.chosenItems.contains(item) ? .red : .primary)
                
            
            if completer.chosenItems.count != 0 
                Section(header: HStack 
                    Text("Chosen items")
                    Spacer()
                    Button(action: 
                        completer.clearChosen()
                    ) 
                        Text("Clear")
                    
                ) 
                    ForEach(Array(completer.chosenItems))  item in
                        Button(action: 
                            completer.toggleItemChosen(item: item)
                        ) 
                            Text(item.name)
                        
                    
                
            
        .onAppear 
            completer.items = ["Chris", "Greg", "Ross", "Damian", "George", "Darrell", "Michael"]
                .map  Item(name: $0) 
        
    


struct Item : Identifiable, Hashable, Equatable 
    var id = UUID()
    var name : String

【讨论】:

【参考方案3】:

我遇到了一个非常相似的问题,尽管我无法将其范围缩小到专门使用 withAnimation。但是,我使用已发布值的陈旧版本进行某些 UI 更新的症状是相同的。我也依赖于许多 debounce/receive 案例。

就我而言,似乎最终的解决办法是始终在DispatchQueue.main而不是Runloop.main上去抖动或接收。

【讨论】:

以上是关于Combine + SwiftUI Form + RunLoop 导致表格视图呈现不可预测的主要内容,如果未能解决你的问题,请参考以下文章

使用 Combine 和 SwiftUI 显示变化值的最简洁方式是啥?

SwiftUI+Combine - 动态订阅发布者的字典

SwiftUI + Combine:如何将数据分配给带有动画的模型

SwiftUI/Combine 发布者是双向的吗?

在需要@Binding 的地方传递@Published(SwiftUI、Combine)

异步下载图像时SwiftUI和Combine工作不顺畅