Hardik Shekhat
Hardik Shekhat

Reputation: 1878

SwiftUI Memory leak Issue in ForEach

I have a vertical list in the screen to show the images category wise and each category/list contains list of images which is shown horizontally. (Attached image for reference)

Now when I am scrolling horizontally or vertically then application is crashing due to memory leaking. I guess lots of people facing this issue in the ForEach loop.

I have also try with List instead of ForEach and ScrollView for both vertical/horizontal scrolling but unfortunately facing same issue.

Below code is the main view which create the vertical list :

@ObservedObject var mainCatData = DataFetcher.sharedInstance

var body: some View {
    
    NavigationView {
        VStack {
            ScrollView(showsIndicators: false) {
                LazyVStack(spacing: 20) {
                    ForEach(0..<self.mainCatData.arrCatData.count, id: \.self) { index in
                        self.horizontalImgListView(index: index)
                    }
                }
            }
        }.padding(.top, 5)
        .navigationBarTitle("Navigation Title", displayMode: .inline)
    }
}

I am using below code to create the horizontal list inside each category, I have used LazyHStack, ForEach loop and ScrollView

@ViewBuilder
func horizontalImgListView(index : Int) -> some View {
    
    let dataContent = self.mainCatData.arrCatData[index]

    VStack {
     
        HStack {
            Spacer().frame(width : 20)
            Text("Category \(index + 1)").systemFontWithStyle(style: .headline, design: .rounded, weight: .bold)
            Spacer()
        }
        
        ScrollView(.horizontal, showsIndicators: false) {
            LazyHStack(spacing: 20) {
                ForEach(0..<dataContent.catData.count, id: \.self) { count in
                                                                                    
                    VStack(spacing : 0) {
                        VStack(spacing : 0) {
                            
                            if let arrImgNames = themeContent.catData[count].previewImgName {

                                // Use dynamic image name and it occurs app crash & memory issue and it reached above 1.0 gb memory
                                Image(arrImgNames.first!).resizable().aspectRatio(contentMode: .fit)
                                                                                                                                                                
                               // If I will use the same image name then there is no memory issue and it consumes only 75 mb
                               // Image("Category_Image_1").resizable().aspectRatio(contentMode: .fit)                              
                            }
                        }.frame(width: 150, height: 325).cornerRadius(8.0)
                    }
                }
            }
        }
    }
}

Below is the data model which I am using to fetch images from json file and shows it in the list

class DataFetcher: ObservableObject {
    
    static let sharedInstance = DataFetcher()
    @Published var arrCatData = [CategoryModel]()
     
    init() {
                
        do {
            if let bundlePath = Bundle.main.url(forResource: FileName.CategoryData, withExtension: "json"),
               
               let jsonData = try? Data(contentsOf: bundlePath) {
                
                let decodedData = try JSONDecoder().decode([CategoryModel].self, from: jsonData)
                DispatchQueue.main.async { [weak self] in
                    self?.arrCatData = decodedData
                }
            }
        } catch {
            print("Could not load \(FileName.CategoryData).json data : \(error)")
        }
    }
}

struct CategoryModel : Codable , Identifiable {
    let id: Int
    let catName: String
    var catData: [CategoryContentDataModel]
}

struct CategoryContentDataModel : Codable {
    var catId : Int
    var previewImgName : [String]
}

Crash logs :

malloc: can't allocate region
:*** mach_vm_map(size=311296, flags: 100) failed (error code=3)
(82620,0x106177880) malloc: *** set a breakpoint in malloc_error_break to debug
2021-07-01 18:33:06.934519+0530 [82620:5793991] [framework] CoreUI: vImageDeepmap2Decode() returned 0.
2021-07-01 18:33:06.934781+0530 [82620:5793991] [framework] CoreUI: CUIUncompressDeepmap2ImageData() fails [version 1].
2021-07-01 18:33:06.934814+0530 [82620:5793991] [framework] CoreUI: Unable to decompress 2.0 stream for CSI image block data. 'deepmap2'
(82620,0x106177880) malloc: can't allocate region
:*** mach_vm_map(size=311296, flags: 100) failed (error code=3)
(82620,0x106177880) malloc: *** set a breakpoint in malloc_error_break to debug

Note: All images of category are loading from the assets only and If I will use the static name of the image in the loop then there is no memory pressure and it will consume only 75 mb.

I think there is a image caching issue. Does I have to manage image caching even if I am loading images from assets?

Can anyone assist me to resolve this issue? Any help will be much appreciated. Thanks!!

enter image description here

Upvotes: 9

Views: 3980

Answers (5)

Damien Bell
Damien Bell

Reputation: 41

I've had an issue with this all day. The problem with LazyVGrid/LazyHGrid is that while the View's inside the ForEach loop are not created until needed, there is no automatic deallocation of the View after they scroll offscreen. This is counter intuitive because we'd expect it to work like UICollectionView but presently it just doesn't. So if a cell draws an Image then it will retain that image offscreen. It will also preserve any @State the cell was in. So if you store image data in a @State variable then that's sticking around as well.

My solution was to use .onDisappear to change the state of the cell as it scrolls offscreen. This way the cell saves it's state as something with a smaller memory footprint.

It doesn't feel ideal and I may end up just falling back on a UICollectionView but it does work.

Here's some demo code. In my case I'm loading an array of urls from disk.


struct ImageGridView: View {
   
   let urls: [URL]
   let columns: [GridItem] = Array(repeating: GridItem(.flexible(), spacing: 10), count: 4)
   
   var body: some View {
       ScrollView {
           LazyVGrid(columns: columns, spacing: 20) {
               ForEach(urls, id: \.self) { url in
                   URLImage(url: url)
               }
           }
           .padding()
       }
   }
}

struct URLImage: View {
   
   let url: URL
       
   @State private var isCellVisible:Bool = false
   
   var body: some View {
       Group {
           
           if isCellVisible {
               if let imgdata = FileHelper.syncRead(from: url),
                  let IMG = Image(data: imgdata) {
                   IMG
                       .resizable()
                       .scaledToFit()
               } else {
                   Image(systemName: "circle")
                       .resizable()
                       .scaledToFit()
               }
           }
           else {
               Image(systemName: "circle.hexagonpath.fill")
                   .resizable()
                   .scaledToFit()
           }
       }
       .onAppear {
           isCellVisible = true
       }
       .onDisappear {
           isCellVisible = false
       }
   }
}

Hope this helps, or ideally I hope Apple fixes LazyGrids.

Upvotes: 4

Bilal Bakhrom
Bilal Bakhrom

Reputation: 166

I faced the same problem when building the app using the SwiftUI framework. I fetched ~600 items from the server (200 ms), then tried to show it in UI using ForEach. But it took 3 GBs of RAM. After research, I understand that it's not an issue of SwiftUI. Memory issue happens because of the loop (for-loop).

I found the following:

In the pre-ARC Obj-C days of manual memory management, retain() and release() had to be used to control the memory flow of an iOS app. As iOS's memory management works based on the retain count of an object, users could use these methods to signal how many times an object is being referenced so it can be safely deallocated if this value ever reaches zero.

The following code stays at a stable memory level even though it's looping millions of times.

for _ in 0...9999999 {
    let obj = getGiantSwiftClass()
}

However, it's a different story if your code deals with legacy Obj-C code, especially old Foundation classes in iOS. Consider the following code that loads a big image ton of time:

func run() {
    guard let file = Bundle.main.path(forResource: "bigImage", ofType: "png") else {
        return
    }
    for i in 0..<1000000 {
        let url = URL(fileURLWithPath: file)
        let imageData = try! Data(contentsOf: url)
    }
}

Even though we're in Swift, this will result in the same absurd memory spike shown in the Obj-C example! The Data init is a bridge to the original Obj-C [NSData dataWithContentsOfURL] -- which unfortunately still calls autorelease somewhere inside of it. Just like in Obj-C, you can solve this with the Swift version of @autoreleasepool; autoreleasepool without the @:

autoreleasepool {
    let url = URL(fileURLWithPath: file)
    let imageData = try! Data(contentsOf: url)
}

In your case, use autoreleasepool inside ForEach:

ForEach(0..<dataContent.catData.count, id: \.self) { count in
    autoreleasepool {
        // Code
    }
}

References:

Upvotes: 1

Priyank javia
Priyank javia

Reputation: 167

Try LazyVGrid with only one column instead of using Foreach.

let columns = [GridItem(.flexible(minimum: Device.SCREEN_WIDTH - "Your horizontal padding" , maximum: Device.SCREEN_WIDTH - "Your horizontal padding"))]

ScrollView(.vertical ,showsIndicators: false ){
   LazyVGrid(columns: columns,spacing: 25, content: {
      ForEach(0..< dataContent.catData.count, id: \.self) { index in
         "Your View"
      }
   }
}

Upvotes: 1

Eric Shieh
Eric Shieh

Reputation: 817

Your main problem is that you're using a ScrollView/VStack vs using a List. List is like UITableView which intelligently only maintains content for cells that are showing. ScrollView doesn't assume anything about the structure and so everything within it is retained. The VStack being lazy only means that it doesn't allocate everything immediately. But as it scrolls to the bottom (or HStack to the side), the memory accumulates because it doesn't release the non visible items

You say you tried List, but what did that code look like? You should have replaced both ScrollView and LazyVStack.

Unfortunately, there is no horizonal list at this moment, so you'll either need to roll your own (perhaps based on UICollectionView), or just minimize the memory footprint of your horizonal rows.

What is the size of your images? Image is smart enough to not need to reload duplicate content: the reason why a single image literal works. But if you're loading different images, they'll all be retained in memory. Having said that, you should be able to load many small preview images. But it sounds like your source images may not be that small.

Upvotes: 0

scottquintana
scottquintana

Reputation: 11

Try not using explicit self in your ForEach. I've had some weird leaks in my SwiftUI views and switching to implicit self seemed to get rid of them.

Upvotes: 1

Related Questions