skip to Main Content

I’m currently working on a SwiftUI application that displays media files in a grid with LazyVGrid on GalleryView where there will be 3 media files per row. The media files are loaded from the app’s cache and not from the user’s Photos app. When user scrolls down to see more media data, media files are lazily loaded into memory and the ones hidden from view are not deallocated from memory because LazyVGrid does not have a built-in memory deallocation mechanism. The memory issues become more apparent (n>10 where n is the number of media files) when there exists a large number of images or videos to show on appear of GalleryView:

struct GalleryView: View {
    @Binding var mediaFiles: [Media]
    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                let width = geometry.size.width / 3 - 10
                let gridItemLayout = [GridItem(.fixed(width)), GridItem(.fixed(width)), GridItem(.fixed(width))]
                LazyVGrid(columns: gridItemLayout, spacing: 10) {
                    ForEach(mediaFiles, id: .id) { media in
                        if let image = media.image {
                            Image(uiImage: image)
                                .resizable()
                                .scaledToFit()
                                .frame(width: width)
                        } else if let videoURL = media.videoURL {
                            // Display video thumbnail here
                        }
                    }
                }
            }
        }
    }
}

struct Media: Identifiable {
    let id = UUID()
    let creationTime: Date
    var image: UIImage?
    var videoURL: URL?
}

I have tried a custom UIKit approach to handle the memory deallocation issue:

struct CollectionView: UIViewControllerRepresentable {
    typealias UIViewControllerType = UICollectionViewController

    @Binding var mediaFiles: [Media]
    var width: CGFloat

    func makeUIViewController(context: Context) -> UICollectionViewController {
        let layout = UICollectionViewFlowLayout()
        layout.itemSize = CGSize(width: width / 3, height: width / 3)
        layout.minimumLineSpacing = 10
        layout.minimumInteritemSpacing = 10
        let collectionViewController = UICollectionViewController(collectionViewLayout: layout)
        collectionViewController.collectionView?.register(MediaCell.self, forCellWithReuseIdentifier: "cell")
        collectionViewController.collectionView?.backgroundColor = .white
        return collectionViewController
    }

    func updateUIViewController(_ uiViewController: UICollectionViewController, context: Context) {
        uiViewController.collectionView?.dataSource = context.coordinator
        uiViewController.collectionView?.delegate = context.coordinator
        uiViewController.collectionView?.reloadData()
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegate {
        var parent: CollectionView

        init(_ parent: CollectionView) {
            self.parent = parent
        }

        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            return parent.mediaFiles.count
        }

        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! MediaCell
            let media = parent.mediaFiles[indexPath.row]
            if let image = media.image {
                cell.imageView.image = image
            }
            return cell
        }


        func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
            // Handle selection here
        }
    }
}

so that I can use CollectionView in place of LazyVGrid in GalleryView like this:

CollectionView(mediaFiles: $mediaFiles, width: width)
    .frame(width: geometry.size.width, height: geometry.size.height)
    .onAppear {
        model.loadMediaFiles()
        if let lastMediaIndex = mediaFiles.indices.last {
            scrollView.scrollTo(lastMediaIndex, anchor: .bottom)
        }
    }

But the custom UIKit code didn’t work as expected because GalleryView now shows a blank white screen (on either light/dark mode) instead of showing 3 media items per row. I have also tried using List in place of LazyVGrid, or trying Chris C‘s code to optimize the memory complexity of the problem to O(1) but neither approach worked. I’ve also tried to chunk the data into pieces and create rows with more than one image as suggested in another thread by @baronfac. However, @baronfac‘s suggestion only optimizes the memory issue by from n to n/3, which this linear reduction doesn’t really change the memory complexity of the problem since it’s still O(n).

Is there a simple and intuitive way to deal with this absence of memory deallocation issue with LazyVGrid? If a UIKit implementation is the best approach, could someone help identify what’s wrong with my current UIKit code that’s causing the GalleryView to display a blank screen?

2

Answers


    1. Why not use UIViewRepresentable instead of view controller variant, and use SwiftUI as parent navigation view, where you inject Collection view (UIView)?
    2. Memory mangement issue is broad here, assuming that ONLY your collection view’s cell will be holding AVAsset, UIImage, PHAsset resulting in them getting overwritten every time new cell is resued. With this setup you will only have N realtime instances of assets, where N = number of visible cells.

    Now to do this in SwiftUI you can use onAppear(perform:) & onDisappear(perform:) view modifiers.

    Here is how I would try:

    struct GalleryItemView: View {
        @Binding var media: Media
        @State var asset: AVAsset? = nil
        
        func loadAssetIfNeeded() {
            guard self.asset == nil else { return nil }
            guard let videoURL = media.videoURL else { return }
            self.asset = AVAsset(url: videoURL)
        }
        
        func flushAsset() {
            self.asset = nil
        }
        
        var body: some View {
            VStack {
                if let image = media.image {
                    Image(uiImage: image)
                        .resizable()
                        .scaledToFit()
                        .frame(width: width)
                } else if let asset = self.asset {
                    SomeVideoThumbnailView(previwing: asset)
                }
            }
            .onAppear(perform: {
                self.loadAssetIfNeeded()
            })
            .onDisappear {
                self.flushAsset()
            }
        }
    }
    
    

    then just replace this view in your LazyVGrid‘s ForEach like:

    ForEach($mediaFiles) { $media in
        GalleryItemView(media: $media)
    }
    

    Now this way assets are loaded and stay live in memory as long as the view is visible.

    NOTE: Both onAppear and onDisappear were known to be buggy in earlier iterations of SwiftUI. Read here. But it should be more stable by iOS 14+.

    Login or Signup to reply.
  1. I was just solving this problem a few days ago, and now it’s solved (completely SwiftUI)

    1. Preload (detect current id and calculate whether to load more)

    ***2. Manually release the original memory (this step is very important)
    (Use @StateObject and @Published for real feedback)

    Code: Local database load images, but the memory grows infinitely and memory overflow – SwiftUI

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search