top

SwiftUI 的协调器父级的好奇案例

我在 SwiftUI 方面还是个初学者。我偶尔尝试过,但大部分时间我真的不知道自己在做什么。最近,我有机会再试一次。对于这个特定的实验,我想将一个现有的视图封装起来,以便我可以从 SwiftUI 访问它。这部分是相当标准的东西。然而,我遇到了一个有趣的问题,我敢打赌我不是唯一一个。

问题

当我学习一个新的 API 时,我喜欢从一个新的项目开始。这可以帮助我专注于任务,避免与更大项目集成时的复杂性。我的目标是将 NSTableView 包装起来,并将其嵌入到 SwiftUI 的视图层次结构中。所以,我创建了一个新项目,并根据苹果的 SwiftUI 教程 Interfacing with UIKit 进行操作,按照需要将其适配为 AppKit 和我的问题。

我感觉挺不错的,因为我很快就实现了基本功能。这个实现遵循了 View-NSView-Coordinator 的模式,结构在仔细研究之后很容易理解。

以下是我编写的代码:

struct EventsTableView: NSViewRepresentable {
    var events: [String]

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeNSView(context: Context) -> NSScrollView {
        let column = NSTableColumn(identifier: .exampleColumn)
        column.width = 100.0

        let tableView = NSTableView()

        tableView.headerView = nil
        tableView.addTableColumn(column)
        tableView.delegate = context.coordinator
        tableView.dataSource = context.coordinator

        let view = NSScrollView()

        view.documentView = tableView

        return view
    }

    func updateNSView(_ nsView: NSScrollView, context: Context) {
        let tableView = nsView.documentView as! NSTableView

        tableView.reloadData()
    }
}

extension EventsTableView {
    final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate {
        var parent: EventsTableView

        init(_ parent: EventsTableView) {
            self.parent = parent
        }

        func numberOfRows(in tableView: NSTableView) -> Int {
            return parent.events.count
        }

        func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
            let reusedView = tableView.makeView(withIdentifier: .exampleColumn, owner: self)
            let view = reusedView as? NSTextField ?? NSTextField(labelWithString: "")

            view.stringValue = parent.events[row]

            return view
        }
    }
}

我之前提到一切都在正常运行。但是,当我进一步实验时,很快发现即使我在更新时重新加载表视图,其内容也始终没有变化。

当然,事后看来,问题显而易见。你可能已经注意到了。如果没有发现,也别感到糟糕,因为我花了不少时间调试和思考,才完全明白问题出在哪里。

父级?

SwiftUI 的互操作性围绕着一个包装视图 EventsTableView 及其协调器 Coordinator 展开。这个协调器实例会在视图需要更改时作为上下文传递回去。包装视图和协调器之间的这种关系至关重要,理解它们的生命周期关系也同样重要。

SwiftUI 的视图在正常执行期间会非常频繁地被重新创建,这很正常。但你的协调器实例只会创建一次,并尽可能地被重用。这其实是非常标准的值类型/引用类型的行为。当协调器将包装视图作为初始化参数时,它会拷贝那个视图的第一次实例化的版本。而当 SwiftUI 包装视图被重新创建时,这个拷贝并不会更新。

我的问题在于,Coordinator 创建时,父级属性及其相关事件只被复制了一次,然后就再也没有更新过。当我意识到这一点时,我觉得自己有点蠢。视图是值类型,当然它不会更新!但我也不想对自己太苛刻,因为这种协调器捕获父级视图的模式非常普遍。许多关于这一主题的博客文章中都存在这种模式。包括苹果的(表面上是权威的)教程,都使用了这种父级模式。我猜这就是问题的根源。

捕获属性

在我的案例中,解决方案其实很简单。关键是要在协调器实例中捕获并更新数据。

struct EventsTableView: NSViewRepresentable {
    var events: [String]

    func makeCoordinator() -> Coordinator {
        Coordinator(events: events) // <- 这里!
    }

    // 省略部分代码...

    func updateNSView(_ nsView: NSScrollView, context: Context) {
        context.coordinator.events = events // <- 这里也是!

        let tableView = nsView.documentView as! NSTableView
        tableView.reloadData()
    }
}

当我完全意识到协调器实例是长期存在的时,捕获父视图就毫无意义了。但是,它确实让我误以为我有一个引用类型的关系,而这种关系是可行的。如果这个属性被称为 firstParent 或者 creatingView,也许我就不会犯这个错误了。

永远不要捕获父视图

经历了这一切后,我不确定是否能想到任何一个你需要捕获 Coordinator 创建视图副本的理由。如果你知道这种父视图-协调器模式可能有用的原因,请告诉我。不过即便有一些合适的场景,我仍然认为这种模式太容易让人困惑,就像我一样,因此最好还是避免使用。

👉 https://www.massicotte.org/swiftui-coordinator-parent


🔗 查看链接投稿/推荐/自荐Quick RSS#41@jaywcjlove