Z
Toggle Nav

iOS 14 SwiftUI List Lazyload

在WWDC2020中,Apple为SwiftUI添加了很多新特性,其中LazyVStackStateObject是实现列表懒加载的最重要的两个特性,通常我们开发列表懒加载的实现思路是:列表底部放一个占位,当占位出现在屏幕中时请求新数据,并将结果插入到现有列表中,等下次占位出现在屏幕中时,重复这个过程。

列表懒加载

按照这个思路,我们可以使用ScrollView做列表滚动容器,使用一个高度为0的Rectangle做占位,当它的onAppear方法被调用后,继续请求新的数据。但在SwiftUI1.0中,ScrollView会将它其中的元素一次性全部渲染出来,这就导致列表刚一渲染,Rectangle的onAppear方法就会被调用,而不是预期的出现在屏幕内才会被调用,这显然是不符合预期的。

新的LazyVStack解决了这个问题,它会在只有LazyVStack中的元素开始出现在屏幕时才会渲染它,屏幕范围之外的元素将不会被渲染,这样配合Rectangle的onAppear方法,我们就能知道什么时候该去取新数据。

ScrollView {
    LazyVStack {
        VStack {
            ForEach(data) { item in
                Text(item)
            }
        }
    }
}

一个典型的结构是这样的,data是我们的列表数据来源,通过ForEach来遍历数据生成一个个Text。注意LazyVStack中还包含一个VStack,这是因为如果LazyVStack内直属的子元素是根据数据动态生成的,通过append方法更新数据后会导致app crash,比如这样:

ScrollView {
    LazyVStack {
        ForEach(data) { item in
            Text(item)
        }
    }
}

在请求一次数据后,我们调用data.append(...)向data内插入新请求回来的数据,由于LazyVStack下直接通过ForEach遍历data生成Text,这会在append调用后,SwiftUI开始刷新时导致app crash,在Xcode 12.0 beta 4 + iOS 14 Beta 4中会出现这个问题,解决办法就是在LazyVStack中套一个VStack。

数据管理

为了提升代码整洁度,我将SwiftUI与请求数据管理分成了两个文件,避免数据请求管理逻辑与UI逻辑混在一个struct中,简要的代码是这样:

class SearchManager: ObservableObject {
    // 对外暴露的搜索结果数据,SwiftUI会根据这个数据来渲染列表
    @Published var data: [SearchResultItem]?

    // 根据关键字搜索数据
    func search(keyword: String) {
        // ...
    }

    // 对当前关键字请求翻页数据
    func next() {
        // ...
    }
}
struct Search: View {
    @ObservedObject private var searchManager = SearchManager()

    var body: some View {
        ScrollView {
            LazyVStack {
                VStack {
                    ForEach(searchManager.data) { item in
                        SearchItemCard(item)
                    }
                    Rectangle()
                        .frame(height: 0)
                        .onAppear {
                            searchManager.next()
                        }
                }
            }
        }
    }
}

基本的逻辑是我实现了一个ObservableObject的class: SearchManager,它用来搜索与存储搜索数据,search或者next调用后会更新data属性,同时data使用@Published标记,表示这是一个可被观察的数据来源,当data变动后,SwiftUI会使用新数据渲染列表。

到目前为止,如果是一个不会被重新创建的View,这么做没有问题,每次滚到底部后都能正常刷新数据。但如果界面中有TextField作为搜索框,点击TextField会调起键盘,当输入完成后,键盘并不会自动收起,一般我们会通过这样的方法来隐藏键盘:

func endEditing() {
    sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}

当endEditing调用后,会导致View重新生成,因为@ObservedObject只是在View和Model添加了订阅关系,所以这并不会影响存储,当View被重新生成时,View内的searchManager也会重新初始化,这样searchManager.data就会变成nil,导致数据丢失。表现就是一旦出现滚动(将endEditing放在DragGesture中来隐藏键盘),列表数据就会被清空。

在iOS14中Apple带来的新的@StateObject可以解决这个问题,它确保对象只会被创建一次,不受View重新创建的影响。使用方法也很简单,直接将@ObservedObject替换为@StateObject即可。

@StateObject private var searchManager = SearchManager()

基本上所有在View内创建的ObservableObject对象,都可以使用@StateObject。这样就能确保隐藏键盘导致的View刷新并不会影响数据,懒加载就能正常工作了。