Combine + SwiftUI Form + RunLoop 导致 table 视图呈现不可预测
Combine + SwiftUI Form + RunLoop causes table view to render unpredictably
我有一个 Combine 函数,用于搜索项目列表和 return 匹配项。它不仅跟踪向用户显示与搜索词匹配的项目,还跟踪用户标记为“已选择”的项目。
该功能运行良好,包括动画,直到我在 Combine 发布者链中添加 .debounce(for: .seconds(0.2), scheduler: RunLoop.main)
或 .receive(on: RunLoop.main)
。那时,View
中结果的呈现变得莫名其妙——项目标题开始显示为 header 视图,项目重复等。
您可以在随附的 GIF 中看到结果。
GIF 版本正在使用 .receive(on: RunLoop.main)
。请注意,我什至没有在这里使用搜索词,尽管它 也 会导致有趣的结果。还可能值得注意的是,如果 withAnimation { }
被删除,一切正常 和 问题行。
我希望能够使用 debounce
,因为列表最终可能会非常大,我不想在每次击键时过滤整个列表。
如何让 table 视图在这些情况下正确呈现?
示例代码(请参阅代码的难点和解释的内联注释。它应该运行写得很好,但是如果两个相关行中的任何一行未被注释):
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 { [=11=].name.localizedStandardContains(term) }, chosen: chosen)
}
}
.map { (filtered,chosen) in
(filtered: filtered.filter { !chosen.contains([=11=]) }, 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: [=11=]) }
}
}
}
struct Item : Identifiable, Hashable {
var id = UUID()
var name : String
}
处理异步处理的问题...在您的默认情况下,所有操作都在一个 (!) 动画块内同步执行 ,因此一切正常。但是在第二种情况下(通过在发布者链中引入任何调度程序)一些操作是同步执行的(比如删除)来启动动画,但是当动画已经在进行时来自发布者的操作是异步的,并且改变模型打破了运行 动画给出不可预知的结果。
解决这个问题的可能方法是通过不同的块将启动和结果操作分开,并使发布者通道真正异步,但在后台处理并在主队列中检索结果。
这里是修改后的发布者链。使用 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 { [=10=].name.localizedStandardContains(term) }, chosen: chosen)
}
}
.map { (filtered,chosen) in
(filtered: filtered.filter { !chosen.contains([=10=]) }, 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
}
}
}
@Asperi 的建议让我走上了正确的轨道,思考有多少 withAnimation { }
事件会被调用。在我原来的问题中,filteredItems
和chosenItems
会在使用receive(on:)
或debounce
时在RunLoop的不同迭代中发生变化,这似乎是不可预测的根本原因布局行为。
通过将 debounce
时间更改为更长的值,这可以防止出现此问题,因为一个动画将在 在 另一个动画完成后完成,但是有问题的解决方案,因为它依赖于动画时间(如果未发送明确的动画时间,则可能是幻数)。
我设计了一个有点俗气的解决方案,它使用 PassThroughSubject
代替 chosenItems
,而不是直接分配给 @Published
属性。通过这样做,我可以将 @Published
值的所有赋值移动到 sink
,结果只发生 一个 动画块。
我对这个解决方案并不满意,因为它感觉像是一个不必要的 hack,但它似乎确实解决了问题:
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 { [=10=].name.localizedStandardContains(term) }, chosen: chosen)
}
}
.map { (filtered,chosen) in
(filtered: filtered.filter { !chosen.contains([=10=]) }, 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: [=10=]) }
}
}
}
struct Item : Identifiable, Hashable, Equatable {
var id = UUID()
var name : String
}
我有一个 Combine 函数,用于搜索项目列表和 return 匹配项。它不仅跟踪向用户显示与搜索词匹配的项目,还跟踪用户标记为“已选择”的项目。
该功能运行良好,包括动画,直到我在 Combine 发布者链中添加 .debounce(for: .seconds(0.2), scheduler: RunLoop.main)
或 .receive(on: RunLoop.main)
。那时,View
中结果的呈现变得莫名其妙——项目标题开始显示为 header 视图,项目重复等。
您可以在随附的 GIF 中看到结果。
GIF 版本正在使用 .receive(on: RunLoop.main)
。请注意,我什至没有在这里使用搜索词,尽管它 也 会导致有趣的结果。还可能值得注意的是,如果 withAnimation { }
被删除,一切正常 和 问题行。
我希望能够使用 debounce
,因为列表最终可能会非常大,我不想在每次击键时过滤整个列表。
如何让 table 视图在这些情况下正确呈现?
示例代码(请参阅代码的难点和解释的内联注释。它应该运行写得很好,但是如果两个相关行中的任何一行未被注释):
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 { [=11=].name.localizedStandardContains(term) }, chosen: chosen)
}
}
.map { (filtered,chosen) in
(filtered: filtered.filter { !chosen.contains([=11=]) }, 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: [=11=]) }
}
}
}
struct Item : Identifiable, Hashable {
var id = UUID()
var name : String
}
处理异步处理的问题...在您的默认情况下,所有操作都在一个 (!) 动画块内同步执行 ,因此一切正常。但是在第二种情况下(通过在发布者链中引入任何调度程序)一些操作是同步执行的(比如删除)来启动动画,但是当动画已经在进行时来自发布者的操作是异步的,并且改变模型打破了运行 动画给出不可预知的结果。
解决这个问题的可能方法是通过不同的块将启动和结果操作分开,并使发布者通道真正异步,但在后台处理并在主队列中检索结果。
这里是修改后的发布者链。使用 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 { [=10=].name.localizedStandardContains(term) }, chosen: chosen)
}
}
.map { (filtered,chosen) in
(filtered: filtered.filter { !chosen.contains([=10=]) }, 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
}
}
}
@Asperi 的建议让我走上了正确的轨道,思考有多少 withAnimation { }
事件会被调用。在我原来的问题中,filteredItems
和chosenItems
会在使用receive(on:)
或debounce
时在RunLoop的不同迭代中发生变化,这似乎是不可预测的根本原因布局行为。
通过将 debounce
时间更改为更长的值,这可以防止出现此问题,因为一个动画将在 在 另一个动画完成后完成,但是有问题的解决方案,因为它依赖于动画时间(如果未发送明确的动画时间,则可能是幻数)。
我设计了一个有点俗气的解决方案,它使用 PassThroughSubject
代替 chosenItems
,而不是直接分配给 @Published
属性。通过这样做,我可以将 @Published
值的所有赋值移动到 sink
,结果只发生 一个 动画块。
我对这个解决方案并不满意,因为它感觉像是一个不必要的 hack,但它似乎确实解决了问题:
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 { [=10=].name.localizedStandardContains(term) }, chosen: chosen)
}
}
.map { (filtered,chosen) in
(filtered: filtered.filter { !chosen.contains([=10=]) }, 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: [=10=]) }
}
}
}
struct Item : Identifiable, Hashable, Equatable {
var id = UUID()
var name : String
}