iosswiftmemory-managementmemory-leaksinstruments

Swift Memory Leaks with UIDiffableDataSource, CFString, Malloc Blocks, and others


I've been writing an application for a couple months now and just started checking for memory leaks - it turns out I have a lot of them - 25 unique types (purple exclamation point) and over 500 total if I navigate through my app enough.

The Memory Graph in Xcode is pointing mostly to 1.) different elements of UIDiffableDataSource, 2.) "CFString"/"CFString (Storage)", and 3.) "Malloc Blocks". The Leaks Instrument is giving me similar feedback, saying about 90% of my memory leaks are these "Malloc Blocks" and also says the Responsible Frame is either newJSONString or newJSONValue. In many of the leaks there is a "4-node-cycle" involved which contains a Swift Closure Context, My UIDiffableDataSource<Item, Section> object, and __UIDiffableDataSource. The CFString ones just have one object - CFString, and nothing else. I'll try to add images showing the 2 examples, but StackO is restricting my ability to add them.

This leads me to believe I'm creating some type of memory leak within my custom dataSource closure for the UICollectionViewDiffableDataSource. I've tried making the DataSource a weak var & I've tried using weak self and unowned self in each of my closures -- especially the closure creating my datasource, but it's had no impact.

any additional info or additional resources would be super helpful. So far I've found iOS Academy & Mark Moeykens YouTube videos helpful in understanding the basics, but having trouble applying it to my app. I can provide code blocks if that helps, but there is a lot of code that could be causing it and not really sure what all to dump in here.

overview of errors

4 node cycle (diffableDataSource)

CFString (Storage)

I was able to find some additional info after posting this. Based on this post I was able to use the Backtrace pane and 95% of the memory leaks are pointing back to my createDataSource() and my applySnapshotUsing(sectionIDs, itemsBySection) methods [dropped those below].

I feel like I've figured out WHERE the leaks are originating, but still stumped on HOW or IF I should fix the leaks... I've tried making closures 'weak self' and any possible variables as weak, to no avail. Any help would be appreciated :)

Backtrace1 Backtrace2

func applySnapshotUsing(sectionIDs: [SectionIdentifierType], itemsBySection: [SectionIdentifierType: [ItemIdentifierType]],animatingDifferences: Bool, sectionsRetainedIfEmpty: Set<SectionIdentifierType> = Set<SectionIdentifierType>()) {
    var snapshot = NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>()
    for sectionID in sectionIDs {
        guard let sectionItems = itemsBySection[sectionID], sectionItems.count > 0 || sectionsRetainedIfEmpty.contains(sectionID) else { continue }
        snapshot.appendSections([sectionID])
        
        snapshot.appendItems(sectionItems, toSection: sectionID)
    }
    
    self.apply(snapshot, animatingDifferences: animatingDifferences)
}
func createDataSource() -> DataSourceType {
    //use DataSourceType closure provided by UICollectionViewDiffableDataSource class to setup each collectionViewCell
    let dataSource = DataSourceType(collectionView: collectionView) { [weak self] (collectionView, indexPath, item) in
        //loops through each 'Item' in itemsBySection (provided to the DataSource snapshot) and expects a UICollectionView cell to be returned
        
        //unwrap self
        guard let self = self else {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "SeeMore", for: indexPath)
            return cell
        }
        
        //figure out what type of ItemIdentifier we're dealing with
        switch item {
        case .placeBox(let place):
            //most common cell -> will display an image and Place name in a square box
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Restaurant", for: indexPath) as! RestaurantBoxCollectionViewCell
            
            //Place object stored in each instance of ViewModel.Item and can be used to configure the cell
            cell.placeID = place.ID
            cell.segment = self.model.segment
            
            //fetch image with cells fetchImage function
            //activity indicator stopped once the image is returned
            cell.imageRequestTask?.cancel()
            cell.imageRequestTask = nil
            cell.restaurantPic.image = nil
            
            cell.fetchImage(imageURL: place.imageURL)
            
            //image task will take time so animate an activity indicator to show activity in progress
            cell.activityIndicator.isHidden = false
            cell.activityIndicator.startAnimating()
            
            cell.restaurantNameLabel.text = place.name
            
            //setup long press gesture recognizer - currently contains 1 context menu item (<3 restaurant)
            let lpgr : UILongPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.handleLongPress))
            lpgr.minimumPressDuration = 0.5
            lpgr.delegate = cell as? UIGestureRecognizerDelegate
            lpgr.delaysTouchesBegan = true
            self.collectionView?.addGestureRecognizer(lpgr)

            //return cell for each item
            return cell
        case .header:
            //top cell displaying the city's header image
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Header", for: indexPath) as! CityHeaderCollectionViewCell
            
            self.imageRequestTask = Task {
                if let image = try? await ImageRequest(path: self.city.headerImageURL).send() {
                    cell.cityHeaderImage.image = image
                }
                self.imageRequestTask = nil
            }
            
            return cell
        }
    }
    
    dataSource.supplementaryViewProvider = { (collectionView, kind, indexPath) in
        //Setup Section headders
        let header = collectionView.dequeueReusableSupplementaryView(ofKind: "SectionHeader", withReuseIdentifier: "HeaderView", for: indexPath) as! NamedSectionHeaderView
        
        //get section from IndexPath
        let section = dataSource.snapshot().sectionIdentifiers[indexPath.section]
        //find the section we're currently in & set the section header label text according
        switch section {
        case .genreBox(let genre):
            header.nameLabel.text = genre
        case .neighborhoodBox(let neighborhood):
            header.nameLabel.text = "The best of \(neighborhood)"
        case .genreList(let genre):
            header.nameLabel.text = genre
        case .neighborhoodList(let neighborhood):
            header.nameLabel.text = "The best of \(neighborhood)"
        default:
            print(indexPath)
            header.nameLabel.text = "favorites"
        }
        
        return header
    }
    
    return dataSource
}

Solution

  • After months and months of debugging I think I finally found the issue. For some reason I don't totally understand the section headers in my collection view (supplementaryViewProvider) were causing many of my objects to be caught in memory and not get deallocated.

    Adding this line to my CollectionViewController's deinit{} completely eliminated the memory leaks in my app (validated with Memory Graph Debugger & Leaks Instrument):

    deinit { dataSource.supplementaryViewProvider = nil }

    To reach this conclusion I rebuilt the relevant parts of my app piece by piece and tested each time I added new code to identify where the leaks were being introduced.

    Any additional clarity on why this caused leaks would be great too.