Xamarin iOS memory leaks everywhere
I have shipped a non-trivial app written with Xamarin. Many others have as well.
"Garbage collection" isn't magic. If you create a reference that is attached to the root of your object graph and never detach it, it will not be collected. That's not only true of Xamarin, but of C# on .NET, Java, etc.
button.Click += (sender, e) => { ... }
is an anti-pattern, because you don't have a reference to the lambda and you can never remove the event handler from the Click
event. Similarly, you have to be careful that you understand what you're doing when you create references between managed and unmanaged objects.
As for "We've done our own MVVM arch", there are high profile MVVM libraries (MvvmCross, ReactiveUI, and MVVM Light Toolkit), all of which take reference/leak issues very seriously.
iOS and Xamarin have a slightly troubled relationship. iOS uses reference counts to manage and dispose of its memory. The reference count of an object gets incremented and decremented when references are added and removed. When the reference count goes to 0, the object is deleted and the memory freed. Automatic Reference Counting in Objective C and Swift help with this, but it’s still difficult to get 100% right and dangling pointers and memory leaks can be a pain when developing using native iOS languages.
When coding in Xamarin for iOS, we have to bear reference counts in mind as we will be working with iOS native memory objects. In order to communicate with the iOS operating system, Xamarin creates what are known as Peers which manage the reference counts for us. There are two types of Peers – Framework Peers and User Peers. Framework Peers are managed wrappers around well-known iOS objects. Framework Peers are stateless and therefore hold no strong references to the underlying iOS objects and can be cleaned up by the garbage collectors when required – and don’t cause memory leaks.
User Peers are custom managed objects that are derived from Framework Peers. User Peers contain state and are therefore kept alive by the Xamarin framework even if your code has no references to them – e.g.
public class MyViewController : UIViewController
{
public string Id { get; set; }
}
We can create a new MyViewController, add it to the view tree, then cast a UIViewController to a MyViewController. There may be no references to this MyViewController, so Xamarin needs to ‘root’ this object to keep this it alive whilst the underlying UIViewController is alive, otherwise we will lose the state information.
The problem is that if we have two User Peers that reference each other then this creates a reference cycle that cannot be automatically broken – and this situation happens often!
Consider this case:-
public class MyViewController : UIViewController
{
public override void ViewDidAppear(bool animated)
{
base.ViewDidAppear (animated);
MyButton.TouchUpInside =+ DoSomething;
}
void DoSomething (object sender, EventArgs e) { ... }
}
Xamarin creates two User Peers that reference each other – one for MyViewController and another for MyButton (because we have an event handler). So, this will create a reference cycle that will not be cleared up by the garbage collector. In order to have this cleared up, we must unsubscribe the event handler, and this is usually done in the ViewDidDisappear handler – e.g.
public override void ViewDidDisappear(bool animated)
{
ProcessButton.TouchUpInside -= DoSomething;
base.ViewDidDisappear (animated);
}
Always unsubscribe to your iOS event handlers.
How to diagnose these memory leaks
A good way to diagnose these memory problems is to add some code in debug to the Finalisers of the classes derived from iOS wrapper classes – such as UIViewControllers
. (Although only put this in your debug builds and not in release builds because it’s reasonably slow.
public partial class MyViewController : UIViewController
{
#if DEBUG
static int _counter;
#endif
protected MyViewController (IntPtr handle) : base (handle)
{
#if DEBUG
Interlocked.Increment (ref _counter);
Debug.WriteLine ("MyViewController Instances {0}.", _counter);
#endif
}
#if DEBUG
~MyViewController()
{
Debug.WriteLine ("ViewController deleted, {0} instances left.",
Interlocked.Decrement(ref _counter));
}
#endif
}
So, Xamarin’s memory management is not broken in iOS, but you do have to be aware of these ‘gotchas’ which are specific to running on iOS.
There is an excellent page by Thomas Bandt called Xamarin.iOS Memory Pitfalls that goes into this in more detail and also provides some very useful hints and tips.
I used the below extension methods to solve these memory leak issues. Think of Ender's Game final battle scene, the DisposeEx method is like that laser and it disassociates all views and their connected objects and disposes them recursively and in a way that shouldn't crash your app.
Just call DisposeEx() on UIViewController's main view when you no longer need that view controller. If some nested UIView has special things to dispose, or you dont want it disposed, implement ISpecialDisposable.SpecialDispose which is called in place of IDisposable.Dispose.
NOTE: this assumes no UIImage instances are shared in your app. If they are, modify DisposeEx to intelligently dispose.
public static void DisposeEx(this UIView view) {
const bool enableLogging = false;
try {
if (view.IsDisposedOrNull())
return;
var viewDescription = string.Empty;
if (enableLogging) {
viewDescription = view.Description;
SystemLog.Debug("Destroying " + viewDescription);
}
var disposeView = true;
var disconnectFromSuperView = true;
var disposeSubviews = true;
var removeGestureRecognizers = false; // WARNING: enable at your own risk, may causes crashes
var removeConstraints = true;
var removeLayerAnimations = true;
var associatedViewsToDispose = new List<UIView>();
var otherDisposables = new List<IDisposable>();
if (view is UIActivityIndicatorView) {
var aiv = (UIActivityIndicatorView)view;
if (aiv.IsAnimating) {
aiv.StopAnimating();
}
} else if (view is UITableView) {
var tableView = (UITableView)view;
if (tableView.DataSource != null) {
otherDisposables.Add(tableView.DataSource);
}
if (tableView.BackgroundView != null) {
associatedViewsToDispose.Add(tableView.BackgroundView);
}
tableView.Source = null;
tableView.Delegate = null;
tableView.DataSource = null;
tableView.WeakDelegate = null;
tableView.WeakDataSource = null;
associatedViewsToDispose.AddRange(tableView.VisibleCells ?? new UITableViewCell[0]);
} else if (view is UITableViewCell) {
var tableViewCell = (UITableViewCell)view;
disposeView = false;
disconnectFromSuperView = false;
if (tableViewCell.ImageView != null) {
associatedViewsToDispose.Add(tableViewCell.ImageView);
}
} else if (view is UICollectionView) {
var collectionView = (UICollectionView)view;
disposeView = false;
if (collectionView.DataSource != null) {
otherDisposables.Add(collectionView.DataSource);
}
if (!collectionView.BackgroundView.IsDisposedOrNull()) {
associatedViewsToDispose.Add(collectionView.BackgroundView);
}
//associatedViewsToDispose.AddRange(collectionView.VisibleCells ?? new UICollectionViewCell[0]);
collectionView.Source = null;
collectionView.Delegate = null;
collectionView.DataSource = null;
collectionView.WeakDelegate = null;
collectionView.WeakDataSource = null;
} else if (view is UICollectionViewCell) {
var collectionViewCell = (UICollectionViewCell)view;
disposeView = false;
disconnectFromSuperView = false;
if (collectionViewCell.BackgroundView != null) {
associatedViewsToDispose.Add(collectionViewCell.BackgroundView);
}
} else if (view is UIWebView) {
var webView = (UIWebView)view;
if (webView.IsLoading)
webView.StopLoading();
webView.LoadHtmlString(string.Empty, null); // clear display
webView.Delegate = null;
webView.WeakDelegate = null;
} else if (view is UIImageView) {
var imageView = (UIImageView)view;
if (imageView.Image != null) {
otherDisposables.Add(imageView.Image);
imageView.Image = null;
}
} else if (view is UIScrollView) {
var scrollView = (UIScrollView)view;
// Comment out extension method
//scrollView.UnsetZoomableContentView();
}
var gestures = view.GestureRecognizers;
if (removeGestureRecognizers && gestures != null) {
foreach(var gr in gestures) {
view.RemoveGestureRecognizer(gr);
gr.Dispose();
}
}
if (removeLayerAnimations && view.Layer != null) {
view.Layer.RemoveAllAnimations();
}
if (disconnectFromSuperView && view.Superview != null) {
view.RemoveFromSuperview();
}
var constraints = view.Constraints;
if (constraints != null && constraints.Any() && constraints.All(c => c.Handle != IntPtr.Zero)) {
view.RemoveConstraints(constraints);
foreach(var constraint in constraints) {
constraint.Dispose();
}
}
foreach(var otherDisposable in otherDisposables) {
otherDisposable.Dispose();
}
foreach(var otherView in associatedViewsToDispose) {
otherView.DisposeEx();
}
var subViews = view.Subviews;
if (disposeSubviews && subViews != null) {
subViews.ForEach(DisposeEx);
}
if (view is ISpecialDisposable) {
((ISpecialDisposable)view).SpecialDispose();
} else if (disposeView) {
if (view.Handle != IntPtr.Zero)
view.Dispose();
}
if (enableLogging) {
SystemLog.Debug("Destroyed {0}", viewDescription);
}
} catch (Exception error) {
SystemLog.Exception(error);
}
}
public static void RemoveAndDisposeChildSubViews(this UIView view) {
if (view == null)
return;
if (view.Handle == IntPtr.Zero)
return;
if (view.Subviews == null)
return;
view.Subviews.ForEach(RemoveFromSuperviewAndDispose);
}
public static void RemoveFromSuperviewAndDispose(this UIView view) {
view.RemoveFromSuperview();
view.DisposeEx();
}
public static bool IsDisposedOrNull(this UIView view) {
if (view == null)
return true;
if (view.Handle == IntPtr.Zero)
return true;;
return false;
}
public interface ISpecialDisposable {
void SpecialDispose();
}
Couldn't be agree more with the OP that "Garbage Collection is essentially broken in Xamarin".
Here's an example shows why you have to always use a DisposeEx() method as suggested.
The following code leaks memory:
Create a class the inherits UITableViewController
public class Test3Controller : UITableViewController { public Test3Controller () : base (UITableViewStyle.Grouped) { } }
Call the following code from somewhere
var controller = new Test3Controller (); controller.Dispose (); controller = null; GC.Collect (GC.MaxGeneration, GCCollectionMode.Forced);
Using Instruments you will see that there are ~ 274 persistent objects with 252 KB never collected.
Only way to fix this is add DisposeEx or similar functionality to the Dispose() function and call Dispose manually to ensure disposing == true.
Summary: Creating a UITableViewController derived class and then disposing/nulling will always cause the heap to grow.