Diagnosing and Resolving iOS Memory Leaks with different approaches

memoryleaks Dec 13, 2024

At Halodoc, we are committed to delivering a seamless and high-performing user experience by leveraging best practices and advanced troubleshooting techniques. Memory leaks in iOS applications are known to degrade performance, increase the risk of app crashes, and diminish the overall user experience. This guide explores the underlying causes of memory leaks, effective detection methods, and practical strategies to resolve them. Addressing memory leaks is essential to ensure our app remains smooth, responsive, and efficient, meeting Halodoc's high standards for quality and reliability.

Overview

Memory leaks occur when an application retains memory that it no longer needs, either because certain objects weren’t released or because retain cycles prevent automatic deallocation. This results in memory gradually filling up over time, which, in an iOS context, leads to various issues like unexpected slowdowns, and app crashes. If leaks become significant, they can cause apps to be terminated by the system, particularly on memory-constrained devices.

Automatic Reference Counting (ARC)

Swift’s Automatic Reference Counting (ARC) is designed to automatically manage memory by tracking and deallocating unused objects. While ARC generally works well, memory leaks can still arise due to inadvertent programming errors.

In ARC, references to objects are typically categorised into three types:

  • Strong References: These are the default, non-optional references to objects. Each strong reference increases an object’s reference count by one. Strong references work best for simple, parent-to-child hierarchies where ownership is clearly defined.
  • Weak References: Weak references are optional, meaning they automatically set themselves to nil when the referenced object is deallocated. Weak references don’t increase an object’s reference count, making them suitable for avoiding retain cycles in hierarchical relationships where mutual ownership isn’t necessary.
  • Unowned References: Unowned references are non-optional and must always point to a valid instance. Unlike weak references, they don’t become nil when the object is deallocated. Instead, accessing a deallocated unowned reference causes a runtime error. Therefore, they should only be used when there’s a guarantee that the referenced object will outlive the reference itself.

Common Causes of Memory Leaks

1. Strong Reference Cycles

A strong reference cycle happens when two or more objects reference each other strongly, preventing them from being deallocated. For instance, view controllers or views holding strong references to their delegates can create such cycles.

Example: A view controller strongly references its delegate, while the delegate also references the view controller, creating a cycle that prevents deallocation when the view controller is dismissed or no longer needed.

2. Improper Use of Closures

Closures capture variables from their surrounding context. If self is captured strongly within a closure, it may create a retain cycle that prevents both the closure and self from being deallocated.

Example: Capturing self strongly within a completion handler without using [weak self] or [unowned self].

3. Unreleased Resources

Resources such as file handles, network connections, or timers must be released when they are no longer needed. Failing to release them can cause unnecessary memory consumption.

Memory Leak Scenario: If a timer (or similar resource) uses a block/closure, and that block captures self strongly, a retain cycle can form. This means neither self nor the timer can be deallocated, leading to a memory leak.

Performance Impact: If resources like file handles, sockets, or timers are not released but are not causing retain cycles, they won't directly leak memory. However, their continued existence consumes system resources and may degrade app performance by unnecessarily occupying memory or CPU cycles.

Example: Not invalidating a timer after it is no longer needed.

4. Singletons

Singletons, being long-lived objects, can have a significant effect on memory usage and application performance. While they do not inherently cause memory leaks, improper use of singletons can lead to high memory and CPU consumption, potentially resulting in app crashes.

Memory Leak Scenario: Singletons can cause memory leaks if they hold strong references to objects that should otherwise be deallocated, such as view controllers or large data structures. These retained objects cannot be freed, leading to a true memory leak.

Performance Impact: Even without a memory leak, a singleton that retains unnecessary objects can increase memory usage, which may cause the app to crash under low-memory conditions. This occurs due to excessive consumption of system resources, particularly on resource-constrained devices.

Different Approaches to Detect Memory Leaks

1. Detecting Leaks via Xcode Memory Graph Debugger

The Xcode Memory Graph Debugger is a powerful tool for identifying memory management issues visually, providing insight into objects retained in memory longer than needed. Here’s how to use it:

Steps:

  1. Run the App: Start your application in Xcode.
  2. Detect a Memory Leak: If you suspect a leak (through tools like MLeaksFinder or manual inspection), open the Memory Graph Debugger from the Debug Navigator.
  3. Analyze the Graph: Once the memory graph is displayed, you’ll see a list of retained objects on the left side. You can select any object that should no longer be in memory and examine its references. The stack trace on the right will show where the object was allocated and retained.
  4. Enable Malloc Stack Trace: To get even more detailed memory allocation data, go to Edit Scheme → Run → Diagnostics and enable Malloc Stack Logging.

5. Filter for Leaked Objects: To view only the objects that are potentially leaked, use the first filter option in the Memory Graph Debugger (located at the bottom of the object list). This filter will narrow down the display to show only objects that are no longer referenced by active code but are still retained in memory.

6. Exclude Core Data and Show Workspace Contents Only: To simplify the view further, you can use the second filter to display only your workspace’s objects, excluding Core Data or other framework objects. This allows you to focus on issues within your app's own code and ignore retained objects from system frameworks

2. Detecting Leaks via Instruments

Xcode’s Instruments tool is a powerful way to profile your app’s memory usage and pinpoint leaks. Here’s how to use it effectively:

  1. Open Instruments: Search for Instruments (press cmd + space and type "Instruments") and select the Leaks template.
  2. Run and Monitor: Launch your app through Instruments to monitor for memory leaks in real time. Instruments will display red cross marks to highlight detected leaks.

Analyze Leak Sources:

  • Leaks Section: Select the timeframe where leaks occur. You’ll see objects retained in memory, making it easier to trace back to the leaking source.
  • Cycles & Roots: View a visual representation of retain cycles, which can reveal strong reference loops causing memory retention.
  • Call Tree: Inspect the stack trace to understand how in-memory objects were allocated, helping identify code paths that may lead to leaks.

Using these features in Instruments helps you isolate and address memory leaks with clear visibility into your app’s memory behaviour.

3. Tracking Object Deallocation with Symbolic Breakpoints

Symbolic breakpoints are an effective tool for tracking when a view controller or any object is deallocated, which can help detect memory leaks. For instance, to confirm when a view controller is being deallocated, you can set a symbolic breakpoint on -[UIViewController dealloc]. This breakpoint will trigger whenever a view controller is about to be deallocated, allowing you to observe any unexpected retention of view controllers.

To enhance this process:

  • Add Custom Actions: Configure the breakpoint to play a sound whenever it's triggered, providing an audio alert for immediate awareness.
  • Auto-Continue: Enable the "Automatically continue after evaluating actions" checkbox to let the code resume execution right after the breakpoint is hit. This keeps the debugging flow uninterrupted, while still alerting you to deallocations.

This technique is especially helpful for pinpointing instances where a view controller might not be released as expected, aiding in early detection and resolution of memory leaks.

4. Utilizing the leaks Command for Leak Detection

The leaks command is a powerful tool to detect potential memory leaks in both system code and your own internal code.

Steps to Analyse Leaks Using the leaks Command:

  1. First, generate a memory graph for the specific application flow you want to analyse. In Xcode, go to File → Export Memory Graph.
  2. After exporting, obtain the memory graph path.
  3. Use the leaks command with this path to scan your codebase for possible leaks. The command will list any identified memory leaks and retain cycles.                                             cmd: leaks {memory graph path}
  4. To quickly identify the main sources of leaks, search for [ROOT LEAK] in the output. This will lead you to the head of the inverted stack trace, pinpointing where the leak originates.

This process provides a direct trace, helping you locate the exact source of each memory leak in your code.

Preventing and Monitoring Memory Leaks During Development

1. Using Xcode's Debug Navigator for Diagnostics

The Debug Navigator in Xcode is a powerful tool that allows developers to analyse memory, CPU, and disk usage before and after memory leak fixes. It provides a high-level visual representation of memory usage, helping you track memory allocation and deallocation in real-time.

Step-by-Step Memory Analysis with the Debug Navigator

  1. Open the Debug Navigator: In Xcode, click on the "Debug" button in the top-right corner of the IDE or use the shortcut Cmd + 7 to bring up the Debug Navigator.
  2. Monitor Memory Usage: Once the Debug Navigator is open, you can see the memory usage graph. It provides a visual representation of how much memory your app is consuming during runtime.
  3. Identify Memory Trends: Observe the graph as you run specific application flows. You’ll see memory spikes or drops, which indicate when objects are allocated or deallocated.
  4. Compare Memory Before and After Fixes: By observing the memory consumption before and after fixing memory leaks, you can verify whether the optimisations were successful in reducing memory usage.

The Debug Navigator is especially useful for spotting memory leaks that may not be immediately obvious through code inspection alone. By analyzing the trends in memory usage, you can identify potential issues and optimize your app’s performance.

2. Using MLeaksFinder for Memory Leak Detection

MLeaksFinder is a lightweight SDK that helps detect memory leaks in your app by raising alerts whenever a potential issue is found. This tool simplifies identifying and addressing leaks during development.

How to Use MLeaksFinder

  1. Integrate the SDK: Add MLeaksFinder to your project using CocoaPods, Carthage, or manual integration.
  2. Enable Leak Detection: Once integrated, MLeaksFinder automatically starts monitoring your app’s memory usage during runtime.
  3. Monitor Alerts: Run your app and navigate through screens. If a memory leak is detected, MLeaksFinder displays an on-screen alert with details about the leaked object and its source.
  4. Fix Issues: Use the provided information to identify and resolve issues like retain cycles or improper deallocation.

By integrating MLeaksFinder, you can proactively catch and fix memory leaks, ensuring a more stable and efficient app.

3. Preventing Memory Leaks with SwiftLint's weak_delegate Rule

SwiftLint is a tool that helps enforce Swift style and conventions, ensuring code quality and consistency across your project. It checks your code against a set of predefined rules and provides warnings for any violations, making it a valuable addition to your workflow. If you haven't integrated SwiftLint yet, you can follow the SwiftLint Installation Guide to get started.

Once SwiftLint is installed, you can enable the weak_delegate rule to prevent memory leaks. The weak_delegate rule helps enforce best practices by ensuring delegate properties are weakly referenced, minimising the risk of strong reference cycles.

  1. Enable the weak_delegate Rule: In your SwiftLint configuration file (.swiftlint.yml), make sure the weak_delegate rule is enabled. This rule will scan your codebase to identify any delegate properties that lack a weak reference.
  2. Code Review and Warnings: When enabled, SwiftLint will flag delegate properties declared without weak, prompting you to change their declaration. For example:

Typical Memory Leak Use Cases and How to Address Them

1. Avoid Retain Cycles Using Weak or Unowned References

Problem: Retain cycles occur when two objects hold strong references to each other, making deallocation impossible. This often happens when closures, which are reference types in Swift, capture self strongly and hold onto it.

Solution: Use [weak self] or [unowned self] when capturing self inside a closure.

Explanation:

  • [weak self]:
    Creates a weak reference to self, meaning the closure does not increase the reference count. This ensures that when self is no longer needed elsewhere, it can be deallocated.
  • [unowned self]:
    Similar to weak, but assumes self will never be nil during the closure's lifetime. Use this only when you're confident that self will outlive the closure.If self is deallocated before the closure executes, the weak reference prevents crashes.

2. Prevent Retain Cycles with Delegates

Problem: Strong references between a delegate and the delegating object can cause a retain cycle.

Solution:

Explanation: The delegate is marked as weak to avoid a retain cycle between DataFetcher and DataFetchable. Without weak, if the DataFetcher strongly retained the delegate, they would both keep each other alive.

3. Unsubscribe from Observables to Prevent Retain Cycles

Problem: If you subscribe to an observable or listener but do not unsubscribe, the observable retains a reference to the subscriber, preventing its deallocation.

Solution: Unsubscribe from the observable when the subscriber (e.g., the view controller) is deallocated, typically in the deinit method.

Explanation: The deinit method ensures that the subscription is canceled when the view controller is deallocated, preventing the store from retaining the view controller.

4. Avoid Retain Cycles with Closures in Handlers

Problem: Third-party SDKs or custom handlers may retain objects strongly, causing memory leaks if not managed properly.

Solution: Capture self weakly within the handler using [weak self].

Explanation: The es.addInfiniteScrolling handler is marked as [weak self] to ensure that the view controller is not strongly retained by the handler, which could lead to a memory leak if self (the view controller) goes out of scope.

5. Strong References Inside Weak Referenced IBOutlets

Problem: Strong references within weak IBOutlet views can cause memory leaks if not invalidated before deallocation.

Solution:

Explanation: In this example, circularProgressView is a weakly-referenced @IBOutlet to prevent retain cycles with MyViewController. However, the CADisplayLink in CircleProgressView holds a strong reference to its target, potentially causing a memory leak. To avoid this, displayLink is invalidated in deinit, breaking the retain cycle and allowing both MyViewController and CircleProgressView to deallocate properly.

6. Invalidate Long-Lived Objects (Timers, Connections)

Problem: Long-lived objects such as timers, file handles, and network connections can persist in memory even when they are no longer needed. While these objects don't inherently cause retain cycles, a timer using a block can retain its target if the block strongly references self. This can prevent deallocation and lead to a memory leak.

Solution: Always invalidate timers and close connections when they are no longer needed or when the parent object is being deallocated.

Explanation:

  • The [weak self] ensures the timer’s block doesn’t create a strong reference to the view controller.
  • Calling timer.invalidate() in the deinit method ensures the timer stops running and does not continue consuming resources.

7. Improper Use of ObjectScope.container (Singleton Scope) - In Swinject

Problem: In Swinject, ObjectScope.container is used to define a singleton scope where instances are shared across the container’s lifecycle. While this scope doesn't inherently cause memory leaks, improper use can lead to objects being retained longer than necessary. This becomes problematic if these objects form retain cycles with other objects, as they will remain in memory as long as the container exists.

Why Singleton Can Contribute to Memory Leaks

Singletons themselves do not cause memory leaks, but when they hold strong references to other objects (such as view controllers or services), and those objects also hold strong references back to the singleton, a retain cycle occurs. This prevents deallocation, leading to a memory leak.

Explanation

Instance Retention:

  • SomeViewModel and SomeService are registered in the container with .container scope, meaning they will be retained as long as the container exists.
  • If SomeService retains a strong reference to SomeViewModel (via viewModel), and SomeViewModel already retains SomeService, a retain cycle is created.

Impact:

  • Neither SomeViewModel nor SomeService will be deallocated, causing memory to be unnecessarily consumed.

8. UIHostingController Leak

Problem: When using UIHostingController to wrap a SwiftUI view and present it, there is a potential memory leak caused by a retain cycle. For example:

Potential Memory Leak:
UIHostingController holds a strong reference to its rootView, and the leak occurs when the rootView in turn holds a strong reference back to the UIHostingController through a closure, such as the dismiss closure in this example. This circular dependency prevents deallocation of both objects.

Solution: To avoid this retain cycle, use a weak reference within the closure:

Explanation: By using [weak bottomView], the root view does not retain a strong reference to the UIHostingController, preventing a retain cycle.

9. Refactoring Singletons to Minimise Memory Footprint

Problem: Singletons, while convenient for shared resources, can inadvertently retain unnecessary objects for the lifetime of the application. This can lead to increased memory usage or even memory leaks if those retained objects are no longer needed.

Solution: To reduce the memory footprint of singletons, use weak references for any objects the singleton holds that don’t need to persist for the lifetime of the singleton itself. This ensures temporary or reusable objects can be deallocated when no longer in use.

Explanation: Singletons are designed to exist for the entire lifetime of an application. If they hold strong references to other objects, those objects will also persist for the application's lifetime, regardless of whether they’re still needed. This can lead to:

  • Memory bloat when unused objects are unnecessarily retained.
  • Memory leaks if the singleton unintentionally creates retain cycles with its objects.

10. Managing Deallocated Objects to Prevent Premature Removal

Problem: Objects created within a function scope may be deallocated when the function exits, leading to unexpected behaviour if those objects are needed later.

Solution: To prevent premature deallocation, move objects that need to persist beyond the function's scope to the class level or an appropriate higher scope.

Explanation: Objects created within a function have their lifetime tied to the function’s execution. Once the function completes, the objects are deallocated unless explicitly retained elsewhere. This is problematic when those objects are required later in the program’s lifecycle.

11. Avoiding Memory Leaks in UITextField

Problem: UITextField can cause memory leaks because the keyboard retains the field as it becomes the first responder. Even resigning the first responder status may not fully release the resource.

Solution: Use UISearchBar instead of UITextField where applicable, as UISearchBar avoids the same retention issue.

Explanation: When UITextField becomes the first responder, the system’s keyboard retains it, which can inadvertently cause memory leaks if the field is not properly cleaned up. Even calling resignFirstResponder() doesn’t guarantee release, leading to objects staying in memory longer than necessary.

📈 Key Improvements:

  1. Memory Stability: Reduced memory usage by ~28% during high-intensity operations such as parallel consultations, usage of recommendation cards, and reply template interactions. This optimisation ensures smoother performance and improved resource allocation.
  2. Resolved 30+ Memory Leaks: Systematic identification and resolution of memory leaks, significantly boosting app stability.
  3. Performance Boost: Faster screen transitions and improved responsiveness while switching between chat rooms and navigating the app. Addressing memory leaks reduced system overhead, enabling a more fluid user experience even during complex tasks.
  4. Crash Reduction: Eliminating memory-related crashes has improved overall app stability, leading to fewer interruptions and enhancing user trust and satisfaction.
  5. Improved Battery Efficiency: Lower memory overhead translates into reduced CPU usage and better energy efficiency, resulting in longer battery life during prolonged usage.

Conclusion

Memory leaks, though subtle, can significantly degrade the performance and user experience of an app. At Halodoc, we prioritise identifying and resolving such issues to maintain the high standards of reliability and performance our users expect. By leveraging tools like Xcode's Memory Graph Debugger, Instruments, and frameworks such as MLeaksFinder, developers can detect, analyze, and fix leaks effectively. Employing best practices, including the appropriate use of weak and unowned references, timely resource releases, and adherence to SwiftLint rules, further ensures robust memory management. As we continue to innovate and optimize, addressing memory leaks remains a cornerstone of delivering a seamless, efficient, and crash-free experience for our users.

Through this journey, we’ve reinforced the importance of systematic diagnosis and resolution of memory issues, not just for immediate fixes but as a cornerstone of sustainable app development. By combining advanced tools, best practices, and rigorous testing, we have elevated the reliability and performance of our apps. This blog demonstrates that tackling memory leaks is not merely about code fixes but about delivering a consistently high-quality user experience, ensuring our users can rely on our app without compromise.

Some great references to dig deep into Memory Leaks

Join us

Scalability, reliability and maintainability are the three pillars that govern what we build at Halodoc Tech. We are actively looking for engineers at all levels and  if solving hard problems with challenging requirements is your forte, please reach out to us with your resumé at careers.india@halodoc.com.

About Halodoc

Halodoc is the number 1 all around Healthcare application in Indonesia. Our mission is to simplify and bring quality healthcare across Indonesia, from Sabang to Merauke. We connect 20,000+ doctors with patients in need through our Tele-consultation service. We partner with 3500+ pharmacies in 100+ cities to bring medicine to your doorstep. We've also partnered with Indonesia's largest lab provider to provide lab home services, and to top it off we have recently launched a premium appointment service that partners with 500+ hospitals that allow patients to book a doctor appointment inside our application. We are extremely fortunate to be trusted by our investors, such as the Bill & Melinda Gates Foundation, Singtel, UOB Ventures, Allianz, GoJek, Astra, Temasek, and many more. We recently closed our Series D round and In total have raised around USD$100+ million for our mission. Our team works tirelessly to make sure that we create the best healthcare solution personalised for all of our patient's needs, and are continuously on a path to simplify healthcare for Indonesia.



Download Halodoc app via iOS and Android.

Varun M

iOS devotee with a flair for Android—because a true developer's heart knows no bounds!