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.
For strategies on choosing between
List
andLazyVStack
, it is recommended to read List or LazyVStack: Choosing the Right Lazy Container in SwiftUI.
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:
// 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.
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:
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:
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.
In the
ScrollViewReader
+List
scenario, it is advisable to ensure that the data iterated over byForEach
conforms to both theIdentifiable
andHashable
protocols. This helps to avoid the need to explicitly mark identifiers usingid
.
While the id
modifier impacts the lazy loading feature of List, its use in other lazy layout containers like LazyVStack
, LazyVGrid
is safe.
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:
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:
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:
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:
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.
In Lists, the Top-Level Structure Type _ConditionalContent Can Break Lazy Loading
When using List
in SwiftUI, many developers conditionally render different content based on the data provided by the data source. This approach is common but, if not handled properly, can disrupt the lazy loading optimization mechanism of List
. For example, consider the following code snippet:
struct ConditionViewDemo: View {
var body: some View {
List {
ForEach(0 ..< 100) { i in
ListSubView(i: i)
}
}
}
}
struct ListSubView: View {
let i: Int
init(i: Int) {
self.i = i
print("init \(i)")
}
var body: some View {
if i % 2 == 0 {
Text("\(i) is even")
} else {
Text("\(i) is odd")
}
}
}
In this code, the content displayed by the view changes based on the parity of the data. Theoretically, List
should only construct views for a little more than the amount of data needed to fill the screen. However, in practice, views for all data items are constructed.
From practical observations, this issue arises whenever the top-level structure type of the subviews in a List
is _ConditionalContent
.
In the article ViewBuilder Research: Creating a ViewBuilder Imitation, we explained how if
or switch
commands in views ultimately compile into the _ConditionalContent
type. Consequently, the following code also disrupts the lazy loading mechanism:
// Using a switch statement
switch i % 2 {
case 0:
Text("\(i) is even")
default:
Text("\(i) is odd")
}
// if statement without an else branch
if i % 2 == 0 {
Text("\(i) is even")
}
The reasons for this behavior are not clear yet.
However, the solution is relatively simple: just add a layout container outside the subview structure, like VStack
:
ForEach(0 ..< 100) { i in
VStack {
ListSubView(i: i)
}
}
It is important to note:
- The layout container should be added on the outside of the subview structure; adding it inside is ineffective.
- Only containers with layout capabilities, like
VStack
, are effective;Group
does not work. - This issue only occurs within
List
and does not affect other lazy containers.
With these adjustments, the lazy loading mechanism of List
should function normally again.
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.