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
可能会删除您的 Item
的 id
不,很遗憾,似乎不是这样。将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,则与我的完全相同——事实上,其他subscribe
、DispatchQueue
、withAnimation
似乎都不重要——这只是去抖窗口的事实更长(因此可能在初始动画完成之后)。你能确认当你用 0.2 尝试时会发生同样的情况吗?【参考方案2】:
@Asperi 的建议让我在思考会调用多少个withAnimation
事件时走上正轨。在我最初的问题中,receive(on:)
或 debounce
使用时,filteredItems
和 chosenItems
将在 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:如何将数据分配给带有动画的模型