【问题标题】:SwiftUI creates destination views before the user navigates to themSwiftUI 在用户导航到它们之前创建目标视图
【发布时间】:2019-11-11 05:58:05
【问题描述】:

我很难在 SwiftUI 中创建一个在 UIKit 中非常常见的用例。

这里是场景。假设我们要创建一个主/从应用程序,用户可以在其中从列表中选择一个项目并导航到具有更多详细信息的屏幕。

为了摆脱 Apple 教程和 WWDC 视频中常见的 List 示例,应用需要从 REST API 获取每个屏幕的数据。

问题:SwiftUI 的声明式语法会导致在 List 中的行出现时创建所有目标视图。

这是一个使用 Stack Overflow API 的示例。第一个屏幕中的列表将显示问题列表。选择一行将导致第二个屏幕显示所选问题的正文。完整的 Xcode 项目是on GitHub)

首先,我们需要一个代表问题的结构。

struct Question: Decodable, Hashable {
    let questionId: Int
    let title: String
    let body: String?
}

struct Wrapper: Decodable {
    let items: [Question]
}

(需要 Wrapper 结构,因为 Stack Exchange API 将结果包装在 JSON 对象中)

然后,我们为第一个屏幕创建一个BindableObject,它从 REST API 获取问题列表。

class QuestionsData: BindableObject {
    let didChange = PassthroughSubject<QuestionsData, Never>()

    var questions: [Question] = [] {
        didSet { didChange.send(self) }
    }

    init() {
        let url = URL(string: "https://api.stackexchange.com/2.2/questions?site=stackoverflow")!
        let session = URLSession(configuration: .default, delegate: nil, delegateQueue: .main)
        session.dataTask(with: url) { [weak self] (data, response, error) in
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            let wrapper = try! decoder.decode(Wrapper.self, from: data!)
            self?.questions = wrapper.items
        }.resume()
    }
}

同样,我们为详细信息屏幕创建了第二个BindableObject,它获取所选问题的正文(为简单起见,请原谅网络代码的重复)。

class DetaildData: BindableObject {
    let didChange = PassthroughSubject<DetaildData, Never>()

    var question: Question {
        didSet { didChange.send(self) }
    }

    init(question: Question) {
        self.question = question
        let url = URL(string: "https://api.stackexchange.com/2.2/questions/\(question.questionId)?site=stackoverflow&filter=!9Z(-wwYGT")!
        let session = URLSession(configuration: .default, delegate: nil, delegateQueue: .main)
        session.dataTask(with: url) { [weak self] (data, response, error) in
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            let wrapper = try! decoder.decode(Wrapper.self, from: data!)
            self?.question = wrapper.items[0]
            }.resume()
    }
}

两个 SwiftUI 视图很简单。

  • 第一个在NavigationView 中包含List。每个 行包含在指向详细信息的NavigationButton 中 屏幕。

  • 第二个视图只是在多行中显示问题的正文 Text 查看。

每个视图都有一个@ObjectBinding 指向上面创建的相应对象。

struct QuestionListView : View {
    @ObjectBinding var data: QuestionsData

    var body: some View {
        NavigationView {
            List(data.questions.identified(by: \.self)) { question in
                NavigationButton(destination: DetailView(data: DetaildData(question: question))) {
                    Text(question.title)
                }
            }
        }
    }
}

struct DetailView: View {
    @ObjectBinding var data: DetaildData

    var body: some View {
        data.question.body.map {
            Text($0).lineLimit(nil)
        }
    }
}

如果您运行该应用程序,它就可以工作。

但问题是每个NavigationButton 都想要一个目标视图。鉴于 SwiftUI 的声明性质,当填充列表时,会立即为每一行创建一个 DetailView

有人可能会说 SwiftUI 视图是轻量级结构,所以这不是问题。问题是这些视图中的每一个都需要一个DetaildData 实例,它在创建时立即启动网络请求,然后用户点击一行。您可以在其初始化程序中放置断点或print 语句来验证这一点。

当然,可以通过将网络代码提取到单独的方法中来延迟 DetaildData 类中的网络请求,然后我们使用 onAppear(perform:) 调用该方法(您可以在 GitHub 上的最终代码中看到)。

但这仍然会导致创建多个DetaildData 实例,这些实例从未使用过,而且浪费内存。此外,在这个简单的示例中,这些对象是轻量级的,但在其他情况下,它们的构建成本可能很高。

这就是 SwiftUI 应该如何工作的吗?还是我遗漏了一些关键概念?

【问题讨论】:

标签: ios list networking swiftui navigationview


【解决方案1】:

正如您所发现的,List(或ForEach)在向List 询问其主体时为其每一行创建行视图。具体来说,在这段代码中:

struct QuestionListView : View {
    @ObjectBinding var data: QuestionsData

    var body: some View {
        NavigationView {
            List(data.questions.identified(by: \.self)) { question in
                NavigationButton(destination: DetailView(data: DetailData(question: question))) {
                    Text(question.title)
                }
            }
        }
    }
}

当 SwiftUI 向 QuestionListView 询问其 body 时,QuestionListView body 访问器将立即为 data.questions 中的每个 Question 创建一个 DetailView 和一个 DetailData

然而,在屏幕上出现DetailView 之前,SwiftUI 确实不会DetailView 询问其body。因此,如果您的 List 在屏幕上有 12 行的空间,SwiftUI 只会询问前 12 个 DetailViews 的 body 属性。

所以,不要在DetailDatainit 中启动dataTask。在DetailDataquestion 访问器中懒洋洋地启动它。这样,在 SwiftUI 向 DetailView 询问其 body 之前,它不会运行。

【讨论】:

  • 我想这不是一个错误,它的设计是这样的,我相信你必须使视图尽可能轻量级
  • 我认为这不是一个错误,但我也认为他们以后可能会改进它。 ListForEach 都要求 Data 符合 RandomAccessCollection,并且都将 rowContent 闭包作为 @escaping。这为它们留出了空间,可以更改为仅按需请求可见行的行内容视图。
【解决方案2】:

请参阅 MwcsMac 接受的答案以获得真正干净的解决方案:

SwiftUI NavigationLink loads destination view immediately, without clicking

编辑:如果您只是有很多目标视图(例如选项列表,全部用 NavigationLink 包装),这将非常有用。对于正在执行实际代码的情况(如上面的原始网络获取示例),那么重构以使用更正确的延迟加载策略会更有效。

【讨论】:

  • 谢谢,但我想说这是一种使用类似于我们为 UIKit 使用的旧思维模型的解决方法。我在 SwiftUI 发布后不久写了这个问题,这也是我的心智模型。在使用框架很长时间后,我得出的结论是,这种行为符合预期。我们在 UIKit 视图控制器的初始化程序中所做的不应该被转换为 SwiftUI 视图,而应该移动到其他地方。
  • 是的,你可能是对的。在使用(或者我应该说尝试使用)SwiftUI 几个月后,我放弃并改用 UIKit 重写我的整个应用程序。 SwiftUI 是错误的,Combine 和 SwiftUI 的糟糕混搭与完全可怕的文档实在是太多了。我希望我现在从未听说过。我需要性能,而 SwiftUI 没有。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多