避免在SwiftUI视图中重新计算数值的方法

422 阅读3分钟

一般来说,使用计算属性可以是一种很好的方式来模拟我们希望在需要时按需创建的特定视图数据--尤其是在SwiftUI视图中,因为每个这样的视图已经使用了一个计算属性(body)来实现其实际的用户界面。

例如,在这里,我们使用两个计算属性来确定UserSessionView ,根据应用程序的当前LoginState ,应该呈现什么样的标题和按钮文本:

struct UserSessionView: View {
    var buttonAction: () -> Void
    @Environment(\.loginState) private var state

    var body: some View {
        VStack {
            Text(title)
                .font(.headline)
                .foregroundColor(.white)
            Button(buttonText, action: buttonAction)
                .padding()
                .background(Color.white)
                .cornerRadius(10)
        }
        .padding()
        .background(Color.blue)
        .cornerRadius(15)
    }

    private var title: String {
        switch state {
        case .loggedIn(let user):
            return "Welcome back, \(user.name)!"
        case .loggedOut:
            return "Not logged in"
        }
    }

    private var buttonText: String {
        switch state {
        case .loggedIn:
            return "Log out"
        case .loggedOut:
            return "Log in"
        }
    }
}

像这样使用多个计算属性来实现一个 SwiftUI 视图,通常是一种很好的方法--因为它让我们尽可能地保持我们的body 实现简单,而且因为它让我们清楚地了解我们是如何计算一个给定视图将呈现的不同内容的。

重新计算的属性

但是,我们也必须记住,计算的属性就是这样--计算的--没有任何形式的缓存或其他类型的内存存储,这意味着每次访问这些值时都会被重新计算。

在我们的第一个例子中,这不是一个问题,因为我们的每个属性都可以以恒定的(或O(1)时间复杂性快速计算出来。然而,现在让我们看看另一个例子,这个例子在性能特征上有很大不同,因为我们现在是通过对一个集合进行排序来计算一个属性。

struct RemindersList: View {
    var items: [Reminder.ID: Reminder]

    var body: some View {
        List(sortedItems) { reminder in
            ...
        }
    }

    private var sortedItems: [Reminder] {
        items.values.sorted(by: {
            $0.dueDate < $1.dueDate
        })
    }
}

一方面,如果传递的items 字典最终包含了大量的项目,那么上述的实现就会变得很有问题,因为每次我们访问sortedItems 属性时,这个集合都会被重新排序。但是,另一方面,它是一个私有属性,只能从我们的视图的body 中访问,而且由于我们的视图没有任何类型的可变状态,我们的属性不太可能被频繁地访问。

然而,如果我们给我们的视图添加任何类型的本地状态,这种情况就会迅速改变--例如,让用户使用内联TextField ,添加一个新的Reminder

struct RemindersList: View {
    var items: [Reminder.ID: Reminder]
    var newItemHandler: (Reminder) -> Void

    @State private var newItemName = ""

    var body: some View {
        VStack {
            List(sortedItems) { reminder in
                ...
            }
            HStack {
                TextField("Add a new reminder", text: $newItemName)
                Button("Add") {
                    newItemHandler(Reminder(name: newItemName))
                }
                .disabled(newItemName.isEmpty)
            }
            .padding()
        }
    }

    private var sortedItems: [Reminder] {
        items.values.sorted(by: {
            $0.dueDate < $1.dueDate
        })
    }
}

现在,每当用户在文本字段中输入一个新字符时,我们的sortedItems 属性就会被调用,我们的items 字典就会被重新排序。虽然这在最初可能不会造成任何明显的性能下降,但这是一个非常低效的实现,而且很可能在某一时刻造成问题,特别是对于有大量提醒的用户。

重要的是要记住,尽管SwiftUI确实使用了一种基于类型的差异算法来确定每次状态变化时要重绘哪些底层视图,并尽其所能确保我们的用户界面保持高性能,但它并不是魔法--如果我们写了效率很低的代码,SwiftUI也没有什么办法来修复。

回到源头

那么,我们如何才能解决这个问题呢?一种方法是回到我们的items 数据的根源,并更新它以在前面执行必要的排序。这样做有两个主要的好处--第一,它可以让我们只执行一次操作,而不是在每次视图更新时执行;第二,它把本质上是模型级的操作移到我们的实际模型层。大赢家!如果我们使用Combine通过某种形式的模型控制器来加载我们的项目,那会是什么样子:

func loadItems() -> AnyPublisher<[Item], Error> {
    controller
        .loadReminders()
        .map { items in
            items.values.sorted(by: {
                $0.dueDate < $1.dueDate
            })
        }
        ...
        .eraseToAnyPublisher()
}

有了上述变化,我们现在可以简单地让我们的RemindersList 视图接受一个预先排序的提醒数组,我们可以按原样渲染:

struct RemindersList: View {
    var items: [Reminder]
    ...

    var body: some View {
        VStack {
            List(items) { reminder in
                ...
            }
            ...
        }
    }
}

然而,虽然上述方法在很多情况下是比较好的,但是我们可能有很好的理由将我们的核心模型集合建模为一个字典,而不是使用一个数组,而且改变这种情况可能不是那么简单,甚至根本不可行。所以我们也来探讨一下其他的选择。

初始化逻辑

我们可以在视图层面上解决我们的问题的一个方法是,仍然让我们的RemindersList 视图接受一个字典,就像以前一样,但是在我们视图的初始化器中执行我们的排序,而不是使用一个计算的属性--例如像这样:

struct RemindersList: View {
    var items: [Reminder]
    var newItemHandler: (Reminder) -> Void

    init(items: [Reminder.ID: Reminder],
         newItemHandler: @escaping (Reminder) -> Void) {
        self.items = items.values.sorted(by: {
            $0.dueDate < $1.dueDate
        })
        self.newItemHandler = newItemHandler
    }

    @State private var newItemName = ""

    ...
}

有了上述改变,我们现在只需对每个RemindersList 实例进行一次排序,而不是在每次按键后进行排序,而实际上不必改变我们创建视图的方式或我们应用程序的数据管理方式。因此,虽然我的一般建议是让初始化器专注于简单的设置工作,而不是执行数据突变,但在这种情况下,我们可能愿意做出这样的折衷。

不过,值得注意的是,如果我们的RemindersList 视图的父类确实更新了(或者更具体地说,如果该父类的body 属性被重新评估),那么我们的视图的一个新实例很可能会被创建,这意味着我们将再次执行我们的item 排序操作。

基本上,当在SwiftUI视图中编写代码时,几乎不可能完全控制代码的执行时间和方式。毕竟,像SwiftUI这样的声明式UI框架设计的一个核心部分是,该框架负责为我们协调所有的UI更新。

因此,如果我们想改善我们对实际模型逻辑的生命周期的控制,那么一个更好的方法可能是将该逻辑从我们的视图实现中移出,并移入我们可以完全控制的对象中。

专用的模型逻辑

一种方法是使用类似视图模型的东西来封装与处理我们的items 阵列有关的逻辑。如果我们把这个视图模型变成一个ObservableObject ,那么我们就能轻松地观察它,并在我们的SwiftUI视图中连接到它:

class RemindersListViewModel: ObservableObject {
    @Published private(set) var items: [Reminder]

    init(items: [Reminder.ID: Reminder]) {
        self.items = items.values.sorted(by: {
            $0.dueDate < $1.dueDate
        })
    }

    func addItem(named name: String) {
        ...
    }
}

有了以上这些,我们现在可以相当程度地简化我们的视图,而且我们还可以自由地选择我们自己要如何管理上述RemindersListViewModel ,而不必考虑视图更新和其他SwiftUI实现细节。

struct RemindersList: View {
    @ObservedObject var viewModel: RemindersListViewModel
    @State private var newItemName = ""

    var body: some View {
        VStack {
            List(viewModel.items) { reminder in
                ...
            }
            HStack {
                TextField("Add a new reminder", text: $newItemName)
                Button("Add") {
                    viewModel.addItem(named: newItemName)
                }
                .disabled(newItemName.isEmpty)
            }
            .padding()
        }
    }
}

非常好!当然,这并不意味着我们应用中的每一个视图现在都需要有一个视图模型。只是在这个特定的案例中,视图模型被证明是解决我们问题的一个很好的办法,因为它使我们能够把与视图相关的模型逻辑从我们的视图层次结构中移出来(这也大大地提高了代码的可测试性)。

总结

每次SwiftUI视图更新时,重新计算一些与视图相关的值通常不是问题。毕竟,这就是每个视图的body 属性的工作方式,只要这些计算能够快速发生(最好是以恒定的时间复杂度),那么我们就不太可能遇到任何类型的重大性能问题。

然而,情况并不总是如此,有时我们可能需要特别注意在视图中消耗模型数据的方式,特别是如果这样做涉及到任何可能会降低我们整体UI性能的繁重操作。

谢谢你的阅读!