πŸ” Debugging and Fixing Memory Leaks in iOS: My Experience with the Fare Estimate Screen in Rapido

Memory leaks are one of the most challenging issues to debug in iOS development. 🧩 In this article, I'll share my experience debugging and fixing memory leaks in the Fare Estimate screen of the Rapido app. Let's dive into the process and learn some valuable lessons along the way. πŸš€

Introduction

As an iOS developer πŸ’», effective memory management is crucial for ensuring smooth performance and a seamless user experience in your apps. Recently, I encountered a memory leak in the new Fare Estimate screen of the Rapido app. This article chronicles my journey of identifying and fixing the leak, sharing insights and techniques that might help fellow developers facing similar issues. These issues often arise with new iOS APIs like `UITableViewDiffableDataSource` and Swift concurrency continuation APIs.

The Challenge

Upon introducing the new Fare Estimate screen, we noticed increased memory usage leading to performance degradation. Additionally, the fare estimate events were being triggered unexpectedly. Identifying and resolving memory leaks can be challenging, especially when the issue isn't immediately apparent.

Tools and Techniques

1. Leaks Instrument

  • πŸ“Š The Leaks Instrument in Xcode is a powerful tool for detecting memory leaks. However, pinpointing the exact location and cause of the leak can be complex.
  • πŸ’‘ Experience: While the Leaks Instrument helped identify that a leak existed, it was difficult to determine exactly where the issue originated. This is often the case with more subtle memory management problems.

2. Memory Debugger

  • πŸ” The Memory Debugger in Xcode provides a visual representation of your app's memory usage, including objects that have strong reference cycles.
  • πŸ’‘ Experience: Using the Memory Debugger, I was able to get hints about what might be causing the issue. The primary problem turned out to be related to strong references ♻️ where weak references should have been used. Specifically, updating closures to use `[weak self]` resolved part of the issue.
  • πŸ“ Learning: Sometimes you might omit `self` when passing a closure, which implicitly captures `self`, making it harder to reason about. While the code looks cleaner with syntactic sugar, it can hide memory leaks.

Example of Strong Reference Cycle in Closures

// Before: Memory leak due to strong reference cycle
class FareEstimateVC {  
    lazy var someProperty = {  
        let view = SomeViewClass(onCancelButton)  
        return view  
    }()   
      
    func onCancelButton() {  
        // Implementation
    }  
}

// After: Fixed with weak self
class FareEstimateVC {  
    lazy var someProperty = {  
        let view = SomeViewClass() { [weak self] in   
            self?.onCancelButton()  
        }  
        return view  
    }()   
      
    func onCancelButton() {  
        // Implementation
    }  
}

Example with UITableViewDiffableDataSource

class MyViewController: UIViewController {  
    var dataSource: UITableViewDiffableDataSource!  
  
    override func viewDidLoad() {  
        super.viewDidLoad()  
  
        dataSource = UITableViewDiffableDataSource(  
            tableView: tableView,  
            cellProvider: { [weak self] tableView, indexPath, item in  
                // Using weak self to prevent memory leak
                let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)  
                // Configure the cell  
                cell.onUpdate = self?.onUpdate()   
                return cell  
            }  
        )  
    }  
}

3. Manual Code Review and Commenting

After fixing these leaks, we noticed additional issues causing leaks in the screen. We manually added print statements in `deinit` and started commenting out code to identify the problem. Eventually, we found the culprit: new Swift concurrency continuation APIs.

Example of Memory Leak with Continuations

import UIKit  
  
class MyViewController: UIViewController {  
    override func viewDidLoad() {  
        super.viewDidLoad()  
        fetchData()  
    }  
  
    func fetchData() {  
        async {  
            let data = await loadData()  
            process(data)  
        }  
    }  
  
    func loadData() async -> String {  
        await withCheckedContinuation { continuation in  
            // Simulate a network call or long-running task  
            DispatchQueue.global().asyncAfter(deadline: .now() + 2) {  
                // Forgot to call continuation.resume(returning:)
                // continuation.resume(returning: "Some data")  
            }  
  
            self.process("data")  
        }  
    }  
  
    func process(_ data: String) {  
        print("Processing data: \(data)")  
    }  
}

Fixed Version with Proper Continuation Handling

import UIKit  
  
class MyViewController: UIViewController {  
    override func viewDidLoad() {  
        super.viewDidLoad()  
        fetchData()  
    }  
  
    func fetchData() {  
        Task {  
            let data = await loadData()  
            process(data)  
        }  
    }  
  
    func loadData() async -> String {  
        await withCheckedContinuation { continuation in  
            // Simulate a network call or long-running task  
            DispatchQueue.global().asyncAfter(deadline: .now() + 2) {  
                continuation.resume(returning: "Some data")  
            }  
        }  
    }  
  
    func process(_ data: String) {  
        print("Processing data: \(data)")  
    }  
}

Memory Management with Continuations

Continuations need to be carefully managed because they hold strong references to the closure and any objects captured by the closure. This means:

  • πŸ”— Continuation Holds the Closure: `withCheckedContinuation` creates a continuation and captures the closure passed to it.
  • πŸ“¦ Captured Objects: Any objects captured by this closure (e.g., `self`, if referenced) are also retained.
  • ⚠️ Memory Leak: Because `continuation.resume(returning:)` is never called, the continuation and its closure remain in memory 🚰. This means `self` is also retained, preventing the view controller from being deallocatedπŸ›.

The Solution

After thorough investigation and debugging, the key issues contributing to the memory leak were:

  • πŸ”— Strong Reference Cycles: Using strong references in closures where weak references were more appropriate.
  • πŸ”„ Retain Cycles: Objects retaining each other, preventing proper deallocation.

By addressing these issues through the techniques mentioned above, the memory leak was resolved, and the Fare Estimate screen now functions smoothly.

Conclusion

Debugging memory leaks requires patience and a methodical approach. While tools like the Leaks Instrument and Memory Debugger are incredibly useful, sometimes manual techniques such as code review and commenting can be just as effective. Sharing experiences and solutions within the developer community helps us all build more efficient and robust applications.

Feedback

I'd love to hear your thoughts and experiences on debugging memory leaks. Have you encountered similar issues? What tools and techniques have you found most effective? Share your feedback and let's learn together! πŸš€