肘子的 Swift 记事本

Tips and Considerations for Using Lazy Containers in SwiftUI

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In the SwiftUI framework, lazy layout containers such as List and LazyVStack provide a method for efficiently displaying large datasets. These containers are ingeniously designed to dynamically build and load views only when necessary, thereby significantly optimizing the app’s performance and memory efficiency. This article will explore some practical tips and important considerations aimed at enhancing developers’ ability to leverage SwiftUI’s lazy containers for improved app responsiveness and resource management.

Custom Implementation Conforming to RandomAccessCollection

In some cases, our data source may not be directly compatible with SwiftUI’s ForEach constructor. Since ForEach requires the data source to conform to the RandomAccessCollection protocol to support efficient random access index operations, we need to customize a data type that conforms to this protocol for the incompatible data source, thereby optimizing performance and memory usage.

Taking the OrderedDictionary from the Swift Collection library as an example, it combines the efficient key-value storage of dictionaries with the characteristics of ordered collections. Directly converting it to an array might lead to unnecessary memory increase, especially when dealing with large datasets. By customizing a type that conforms to the RandomAccessCollection protocol, we can effectively manage memory while maintaining efficient data processing capabilities.

Below is an example of implementing and using such a customized collection:

Swift
// Create DictDataSource, the minimal implementation conforming to RandomAccessCollection
final class DictDataSource<Key, Value>: RandomAccessCollection, ObservableObject where Key: Hashable {
    typealias Index = Int
    
    private var dict: OrderedDictionary<Key, Value>
    
    init(dict: OrderedDictionary<Key, Value>) {
        self.dict = dict
    }
    
    var startIndex: Int {
        0
    }
    
    var endIndex: Int {
        dict.count
    }
    
    subscript(position: Int) -> (key: Key, value: Value) {
        dict.elements[position]
    }
}

// Usage
let responses: OrderedDictionary<Int, String> = [
    200: "OK",
    403: "Access forbidden", 
    404: "File not found",
    500: "Internal server error",
]

struct OrderedCollectionsDemo: View {
    @StateObject var dataSource = DictDataSource(dict: responses)
    var body: some View {
        List {
            ForEach(dataSource, id: \.key) { element in
                Text("\(element.key) : \(element.value)")
            }
        }
    }
}

Using this method, we not only optimized the processing efficiency of large datasets but also reduced memory usage, enhancing the overall responsiveness of the application. Besides OrderedDictionary, other custom data structures like linked lists, trees, etc., can also implement a random-access data source adapter in a similar manner, to better cooperate with the use of ForEach.

Implementing Infinite Data Loading

In the development process, we often encounter scenarios that require implementing infinite data loading, meaning dynamically loading new data sets based on user scrolling or demand. This mechanism not only helps optimize memory usage but also significantly enhances user experience, especially when dealing with large amounts of data.

By introducing a custom implementation of RandomAccessCollection, we can embed dynamic loading logic directly at the data source level. The DynamicDataSource class shown below follows the RandomAccessCollection protocol and automatically triggers the loading of more data as it approaches the end of the data.

Swift
struct Item: Identifiable, Sendable {
  let id = UUID()
  let number: Int
}

final class DynamicDataSource: RandomAccessCollection, ObservableObject {
  typealias Element = Item
  typealias Index = Int

  @Published private var items: [Item]
  private var isLoadingMoreData = false
  private let threshold = 10 // Set the threshold to trigger loading more data

  init(initialItems: [Item] = []) {
    items = initialItems
  }

  var startIndex: Int {
    items.startIndex
  }

  var endIndex: Int {
    items.endIndex
  }

  func formIndex(after i: inout Int) {
    i += 1
    // Trigger the loading of more data when accessing elements within the threshold distance from the end of the array
    if i >= (items.count - threshold) && !isLoadingMoreData && !items.isEmpty {
      loadMoreData()
    }
  }

  subscript(position: Int) -> Item {
    items[position]
  }

  private func loadMoreData() {
    guard !isLoadingMoreData else { return }

    isLoadingMoreData = true

    // Simulate asynchronously loading new data
    Task {
      try? await Task.sleep(for: .seconds(1.5))
      let newItems = (0 ..< 30).map { _ in Item(number: Int.random(in: 0 ..< 1000)) }
      await MainActor.run { [weak self] in
        self?.items.append(contentsOf: newItems)
        self?.isLoadingMoreData = false
      }
    }
  }
}

The demonstration of its use in the view is as follows: as the user scrolls, new data will be dynamically loaded into the list:

Swift
struct DynamicDataLoader: View {
  @StateObject var dataSource = DynamicDataSource(initialItems: (0 ..< 50).map { _ in Item(number: Int.random(in: 0 ..< 1000)) })
  var body: some View {
    List {
      ForEach(dataSource) { item in
        Text("\(item.number)")
      }
    }
  }
}

Through the aforementioned method, we have successfully decoupled the dynamic data loading logic from the view rendering logic, making the code structure clearer and easier to maintain. The applicability of this implementation pattern is very broad; it can be used for data simulation and can be easily extended to accommodate more practical needs, such as loading data from the internet or implementing pagination of data.

The Impact of the id Modifier on List’s Lazy Loading Mechanism

In SwiftUI, the List view relies on a lazy loading mechanism to optimize performance and user experience. The lazy loading ensures that instances of the subviews are only created and loaded into the view hierarchy when they are about to appear in the visible area. However, in certain situations, this optimization mechanism might be unintentionally disrupted, especially when the id modifier is used on the subviews.

Consider the following example, where we have added an id modifier to ItemSubView. As a result, the List instantiates all the child views immediately, although it only renders the content (evaluates the body) of the currently visible subviews:

Swift
struct LazyBrokenView: View {
  @State var items = (0 ..< 50).map { Item(number: $0) }
  var body: some View {
    List {
      ForEach(items) { item in
        ItemSubView(item: item)
          .id(item.id)
      }
    }
  }
}

struct ItemSubView: View {
  let item: Item
  init(item: Item) {
    self.item = item
    print("\(item.number) init")
  }

  var body: some View {
    let _ = print("\(item.number) update")
    Text("\(item.number)")
      .frame(height: 30)
  }
}

Although the lazy loading mechanism of List only renders the visible views, instantiating all child views immediately in the case of large datasets can cause significant performance overhead, severely affecting the app’s initial loading efficiency.

If the use of id is unavoidable, such as when using ScrollViewReader or in other scenarios where unique identification of views is required, consider adopting alternative methods to mitigate the loss of lazy loading features. For more optimization tips, refer to Demystifying SwiftUI List Responsiveness: Best Practices for Large Datasets.

While the id modifier impacts the lazy loading feature of List, its use in other lazy layout containers like LazyVStack, LazyVGrid is safe.

Moreover, the new features of ScrollView introduced in WWDC2023, such as scrollPosition(id:), can trigger similar effects as id in Lists, and should be used cautiously with large datasets. For more details, consider reviewing Deep Dive into the New Features of ScrollView in SwiftUI 5.

In summary, when leveraging the advantages of SwiftUI’s lazy containers, we must be aware of these potential pitfalls, especially in scenarios requiring the display of large datasets, as they could significantly impact the app’s performance and user experience.

SwiftUI Only Retains the Top-Level State of ForEach Subviews in Lazy Containers

A crucial detail to note when using SwiftUI’s lazy containers, especially when the subviews are composed of multi-layered view structures created through ForEach, is that SwiftUI only retains the state of the top-level views when they reappear after leaving the visible area.

This means, in the following code example, where RootView is a subview within a ForEach loop containing another view ChildView, according to SwiftUI’s design, only the state located in RootView will be maintained when it re-enters the visible area, while the state nested inside ChildView will be reset:

Swift
struct StateLoss: View {
  var body: some View {
    List {
      ForEach(0 ..< 100) { i in
        RootView(i: i)
      }
    }
  }
}

struct RootView: View {
  @State var topState = false
  let i: Int
  var body: some View {
    VStack {
      Text("\(i)")
      Toggle("Top State", isOn: $topState)
      ChildView()
    }
  }
}

struct ChildView: View {
  @State var childState = false
  var body: some View {
    VStack {
      Toggle("Child State", isOn: $childState)
    }
  }
}

Initially, I thought this behavior was a bug, but according to feedback from exchanges with Apple engineers, it is actually an intentional design decision. From the design philosophy of SwiftUI, this choice is rational. To strike a balance between rendering efficiency and resource consumption, maintaining the state of all subviews in the view hierarchy of a lazy container with a complex view structure and numerous subviews could significantly reduce efficiency. Hence, the decision to only preserve the state of the top-level subviews is considered a sensible design choice.

Therefore, for structures with multi-layered subviews and rich state, the recommended practice is to elevate all relevant states to the top-level view, ensuring the correct maintenance of the state:

Swift
struct RootView: View {
  @State var topState = false
  @State var childState = false
  let i: Int
  var body: some View {
    VStack {
      Text("\(i)")
      Toggle("Top State", isOn: $topState)
      ChildView(childState: $childState)
    }
  }
}

struct ChildView: View {
  @Binding var childState: Bool
  var body: some View {
    VStack {
      Toggle("Child State", isOn: $childState)
    }
  }
}

Such a practice not only adheres to SwiftUI’s design principles but also ensures that the application’s state is correctly managed and preserved, even in complex view hierarchies.

SwiftUI’s Passive Memory Resource Release for Specific State Types

In the common understanding of Swift developers, setting an optional value type variable to nil should trigger the system to reclaim the resources occupied by the original data. Therefore, some developers attempt to use this mechanism to save memory when dealing with lazy views that might consume significant resources. The following example illustrates this approach:

Swift
struct MemorySave: View {
  var body: some View {
    List {
      ForEach(0 ..< 100) { _ in
        ImageSubView()
      }
    }
  }
}

struct ImageSubView: View {
  @State var image: Image?
  var body: some View {
    VStack {
      if let image {
        image
          .resizable()
          .frame(width: 200, height: 200)
      } else {
        Rectangle().fill(.gray.gradient)
          .frame(width: 200, height: 200)
      }
    }
    .task {
      // Simulate loading different images
      if let uiImage = createImage() {
        image = Image(uiImage: uiImage)
      }
    }
    .onDisappear {
      // Set to nil when leaving the visible area
      image = nil
    }
  }

  func createImage() -> UIImage? {
    let colors: [UIColor] = [.black, .blue, .yellow, .cyan, .green, .magenta]
    let color = colors.randomElement()!
    let size = CGSize(width: 1000, height: 1000)
    UIGraphicsBeginImageContextWithOptions(size, false, 0)
    color.setFill()
    UIRectFill(CGRect(origin: .zero, size: size))
    let image = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()
    return image
  }
}

Even though the state is set to nil, in lazy containers, certain types of data (such as Image, UIImage, etc.) do not immediately release the memory space they occupy unless they completely exit the lazy container view. In this case, by changing the state type, the eagerness of resource recycling can be improved:

Swift
struct ImageSubView: View {
  @State var data:Data?
  var body: some View {
    VStack {
      if let data,let uiImage = UIImage(data: data) {
        Image(uiImage: uiImage)
          .resizable()
          .frame(width: 200, height: 200)
      } else {
        Rectangle().fill(.gray.gradient)
          .frame(width: 200, height: 200)
      }
    }
    .task {
      // Simulate loading different images, converting them into the Data type
      if let uiImage = await createImage() {
        data = uiImage
      }
    }
    .onDisappear {
      data = nil
    }
  }

  func createImage() async -> Data? {
    let colors: [UIColor] = [.black, .blue, .yellow, .cyan, .green, .magenta]
    let color = colors.randomElement()!
    let size = CGSize(width: 1000, height: 1000)
    UIGraphicsBeginImageContextWithOptions(size, false, 0)
    color.setFill()
    UIRectFill(CGRect(origin: .zero, size: size))
    let image = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()
    return image?.pngData()
  }
}

In the code mentioned above, by changing the state to be held as Optional<Data>, the system can properly reclaim the resources occupied once the state is set to nil, thereby improving memory usage.

For a detailed analysis and discussion of the issue, refer to Memory Optimization Journey for a SwiftUI + Core Data App.

With this approach, we can effectively control memory usage, particularly when large amounts of data or resources are loaded by lazy views, ensuring the application’s performance and responsiveness.

Some readers might consider using the feature of lazy containers, which only retains the top-level state, to address memory usage issues. Unfortunately, for these specific types, even if the state is “forgotten,” the memory they occupy is not released promptly. Therefore, this method is not suitable for resolving the issue of memory not being actively released for certain types.

Conclusion

While SwiftUI offers great convenience to developers, making it easier to create dynamic and responsive user interfaces, understanding and leveraging the working mechanism of its lazy layout containers is still key to developing efficient, high-performance SwiftUI applications. This article aims to help developers better master these techniques, optimize their SwiftUI applications, and ensure they can provide rich functionality while maintaining a smooth user experience and efficient resource usage.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways