Problem Outline: What I set out to build
I'm trying to build some sort of reader app and I'm running into severe performance issues with several approaches. If you know how to do this, please read the next paragraph and then jump to the "Help, please!" section at the end.
I want to render the items of an array of type [Item] (saved locally) as a scrollable view in SwiftUI, using Text (or rather some custom ItemView, but Text runs into the same problem) to display the individual items, sorted by item.id. The array has around 50,000 items, and about 10 items are onscreen at any given time. For brevity, let's assume the following Item definition and auto-incrementing id's starting at 0:
struct Item {
var id: Int
var text: String
}
Approaches
Approach 1: Initially load small array and fetch dynamically
My first idea was to preload around 100 items (centred around a targetId that I want to display initially) in an itemArr and pass this itemArr to a ForEach, like so:
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(itemArr, id: \.id) { item in
Text(item.text)
}
}
}
.onAppear { proxy.scrollTo(targetId, .center) }
}
Once the user scrolls to within 20 items of either end of itemArr, I would fetch 100 further items, let's call them newItems, by calling fetchItems(from id: Int, to: Int) -> [Item]. I would then prepend/append those items to itemArr.
This worked fine for appending, but prepending crashes the app. This makes sense, as I understand now, because prepending newItems to itemArr makes ForEach's diffing go crazy, as every item changes its position in the array and the scrolling position get's messed up too. (Appending, on the other hand, leaves the items from the original itemArr in place, which means that ForEach's diffing recognises that the currently displayed items are unchanged, and LazyVStack prevents the appended items from being loaded.)
Approach 2: Preload the entire array without the text, and update the array dynamically
Given that I can't prepend, my next thought was to preload the entire array (with n+1 items) into memory, but without the text (for the obvious memory-saving reason), like so:
var itemArr: [Item] = (0...n).map { i in Item(id: i, text: nil) }
Note that this requires a change in the Item definition to:
struct Item {
var id: Int
var text: String?
}
I would then populate around 100 items in itemArr with text and try to render as follows:
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(itemArr, id: \.id) { item in
if let text = item.text {
Text(text)
}
}
}
}
.onAppear { proxy.scrollTo(targetId, .center) }
}
But this performs fairly badly: long initial load and large memory consumption. (And gets worse: for n=500,000, the app doesn't load on an iPhone 11 and consumes 1.5 GB of RAM on the simulator)
Thought 3: Somehow make the array use negative indices
The whole problem is quite ridiculous, really, because all that would be needed is for an array to either allow for negative indices or to tell ForEach's diffing at which indices it should start comparing the new and old arrays, thus avoiding the problem that prepending newArr changes the relative position of the items in itemArr.
Is there some RandomAccessCollection that can be passed to ForEach that allows for this approach?
Approach 3: Custom RandomAccessCollection
I followed up on the above thought and implemented struct NegativeArray conforming to RandomAccessCollection, in the hope that ForEach's diffing algorithm uses the indices of the RandomAccessCollection, but it doesn't.
struct NegativeArray<Element>: Sequence {
var array: [Element]
var firstIndex: Int
func makeIterator() -> NegativeArrayIterator<Element> {
return NegativeArrayIterator(self)
}
}
struct NegativeArrayIterator<Element>: IteratorProtocol {
let negativeArray: NegativeArray<Element>
var currentIndex: Int = 0
init(_ negativeArray: NegativeArray<Element>) {
self.negativeArray = negativeArray
}
mutating func next() -> Element? {
guard currentIndex < negativeArray.array.count else {
return nil
}
let element = negativeArray.array[currentIndex]
currentIndex += 1
return element
}
}
extension NegativeArray: Collection {
var startIndex: Int {
return firstIndex
}
var endIndex: Int {
return array.count + firstIndex
}
func index(after i: Int) -> Int {
return i + 1
}
subscript(position: Int) -> Element {
return array[position - firstIndex]
}
}
extension NegativeArray: BidirectionalCollection {
func index(before i: Int) -> Int {
return i - 1
}
}
extension NegativeArray: RandomAccessCollection {
// Conforms, because the index (Int) conforms to Strideable
}
Help, please!
I'm sorry for the long post, but I wanted to at least outline my thinking and what I've tried so far. I'm new to SwiftUI and hopefully there is an easy solution to this. The avenues that I currently see as promising:
- A
RandomAccessCollectionthat mimics negative array indices. (Nope, see edit "Approach 3" above) - Understanding why the app consumes 1.5 GB of memory on simulator, given that we load an array of 500,000 items using 32 bytes each (as is the case in the above
Itemstruct), which amounts to an array size of 1.5 MB, or three orders of magnitude less. What else is thus happening?
Can you help me in any way? Thanks!
Code example
When I run this on simulator, it consumes 1 GB of memory before rendering the first Text view. Deleting the scrollTo(_:anchor:) has no effect.
struct Item {
var id: Int
var text: String?
}
struct ContentView: View {
var itemArr: [Item] = (0...500_000)
.map { i in Item(id: i, text: nil) }
.map { item in
if (310_000...310_500).contains(item.id) {
return Item(id: item.id, text: "Lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum")
} else {
return item
}
}
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(itemArr, id: \.id) { item in
if let text = item.text {
Text(text)
}
}
}
}
.onAppear { proxy.scrollTo(310_150, anchor: .center) }
}
}
}
LazyVStackloads lazily, but it does not "recycle" views likeList. With LazyVStack initially if your screen holds 10 items, you'll only load 10 items, but scroll to the 1000th item and you'll have 1000 items in memory.ItemView? Maybe that is causing all the item views to be loaded in the LazyVStack. I'm positive that is the case, because in my case, the running instance consumes 30 mb and the view is pretty smoothItemView, the problem persists when swappingItemViewfor a simpleText. (I've now added sample code at the end too.)