Xamarin iOS 到处都是内存泄漏

56

我们在过去的8个月中一直使用Xamarin iOS,并开发了一个具有许多屏幕、功能和嵌套控件的非常规企业应用。我们采用了自己的MVVM架构,跨平台BLL&DAL作为“推荐”方案。我们甚至在我们的Web产品中共享Android代码和BLL/DAL。

除了在项目发布阶段发现Xamarin iOS应用程序存在无法修复的内存泄漏问题外,一切都很好。我们遵循了所有的“指南”来解决这个问题,但实际情况是,C# GC和Obj-C ARC似乎是不兼容的垃圾收集机制,在monotouch平台中的重叠方式也是如此。

我们发现的事实是,本地对象和托管对象之间的硬循环发生,并且对于任何非常规应用程序,经常发生。这很容易发生在任何你使用lambda或手势识别器的地方。加上MVVM的复杂性,几乎是肯定的。只要错过其中一个情况,整个对象图将永远不会被收集。这些图形将引诱其他对象并像癌症一样生长,最终导致iOS的即时和无情的消灭。

Xamarin的回答是对这个问题的不感兴趣的推迟,并且有一个“开发者应该避免这些情况”的不切实际的期望。仔细考虑一下,这表明在Xamarin中,垃圾收集基本上是失效的

我现在意识到的是,在传统的C# .NET意义上,你实际上并没有在Xamarin iOS中得到“垃圾收集”。你需要采用“垃圾维护”模式才能让GC移动并完成其工作,即使这样也永远不会完美 - 非确定性。

我的公司花费了巨资试图阻止我们的应用程序崩溃和/或耗尽内存。我们基本上必须在视野内显式地递归地处理所有东西,并将垃圾维护模式实施到应用程序中,以防止崩溃并拥有可行的产品进行销售。我们的客户支持和容忍,但我们知道这不能一直持续下去。我们希望Xamarin有一个专门的团队致力于解决这个问题,一劳永逸。不幸的是,看起来并不是这样。

问题是,在使用Xamarin编写的非平凡企业级应用程序中,我们的经验是例外还是规则?

更新

查看DisposeEx方法和解决方案的答案。


你有关于“Xamarin中垃圾回收本质上是有问题的”这一说法的参考资料吗?就我个人而言,我认为你是个例外而不是规律。我没有遇到过像你提到的那样的内存问题。我唯一遇到的与生产相关的问题是链接SDK。 - valdetero
如果这是一个游戏应用程序,我不会感到太惊讶,但对于“企业”应用程序,我持怀疑态度。让我们看看你的问题和解决方案的一些示例。 - tia
5
赫曼,我完全同意你的看法,Xamarin.iOS/MonoTouch 上的垃圾回收确实存在问题。一年前我也有过同样的经历,并且甚至像你在这里发布的那个“层次清理程序”一样自己制作了一个。我的应用程序是一个消费者应用程序,几乎有35个控制器,花了我三个多月的时间才解决内存问题。Xamarin 的某位员工告诉我他们正在解决这个问题,但再过了将近一年,我没有看到任何进展,甚至示例代码中仍然充斥着这些反模式的 lambda 表达式。 - asp_net
5个回答

27

我使用下面的扩展方法解决了这些内存泄漏问题。把安德的游戏里的最后一场战斗场景想象成,DisposeEx方法就像那个激光一样,它解除所有视图及其连接的对象,并以递归方式处理和释放它们,而且不会崩溃你的应用程序。

当您不再需要该视图控制器时,只需在UIViewController的主视图上调用DisposeEx()。如果某个嵌套的UIView有特殊的要处理或者您不希望它被处理, 实现ISpecialDisposable.SpecialDispose就行了,该方法将在IDisposable.Dispose的位置被调用。

注意: 这假设您的应用程序中没有共享UIImage实例。如果有,则修改DisposeEx以智能地处理它们的释放。

    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();
    }

@ThomasdeRoo:只需删除那个(我的自定义扩展方法)即可。 - Herman Schoenfeld
2
view.Subviews.Update(RemoveFromSuperviewAndDispose); Update不存在。 - Casey Hancock
1
你能提供一个安卓版本吗? - gonzobrains
1
我现在得去看《安德的游戏》了。 - gonzobrains
1
这个怎么在Xamarin Forms中实现? - Talal Yousif
显示剩余3条评论

27

我已经使用Xamarin编写了一个非平凡的应用程序,许多其他人也这样做。

"垃圾回收"并不神奇。如果你创建了一个与你的对象图根附着并从未脱离的引用,它将不会被收集。这不仅适用于Xamarin,还适用于.NET上的C#,Java等。

button.Click += (sender, e) => { ... }是一种反模式,因为你没有对lambda表达式进行引用,所以永远无法从Click事件中删除事件处理程序。同样,当你创建托管和非托管对象之间的引用时,你必须小心理解自己正在做什么。

至于"我们自己完成了MVVM架构",有一些知名的MVVM库(MvvmCrossReactiveUIMVVM Light Toolkit),他们都非常重视引用/泄漏问题。


1
还有MvvmLight,现在已经支持Xamarin。所以实际上是3个! - Cheesebaron
3
基本上,每当处理比玩具应用程序更严肃的东西时,您都必须查看如何保留对对象的引用。特别是Bitmap是内存占用量大的东西,需要小心处理。我建议每个人都研究一下ReactiveUI的内容。如果您不喜欢它,您将不得不查看弱引用,即使在EventHandler上也是如此,它们可以让生活变得轻松得多。弱引用构成了MvvmCross的很大一部分,甚至ReactiveUI也依赖于它。要么这样做,要么确保您在对象的生命周期中的某个时刻释放每个对象。 - Cheesebaron
9
@anthony:在 Xamarin 中,本地对象和托管对象之间的孤立循环引用永远不会被回收。这实际上就是问题所在。如果 Xamarin 应该像 C# .NET 一样工作,那么它应该收集这些循环引用。要求开发者显式地打破这些循环引用不仅不合理,而且表明 GC 在 Xamarin 中无法像在任何正常的托管环境中那样正常工作。 - Herman Schoenfeld
3
如果发布者(按钮)的生命周期超过订阅者,那么您提到的 button.Click 示例只是一种反模式。但我同意,很容易产生循环引用,这可能很难打破。 - Krumelur
2
我们也遇到了内存问题,就像OP所说的那样,故事基本相同,唯一的区别是我们没有重新实现MVVM框架。在阅读了大量关于Xamarin与本地代码交互的文章后,似乎简单的.NET模式并不适用于iOS——我从iOS 3开始就是一名iOS开发者。首先,没有垃圾回收!我强烈建议任何遇到内存问题的人阅读这篇文章http://krumelur.me/2015/04/27/xamarin-ios-the-garbage-collector-and-me/。不对错误的位置进行评判,希望像前面这篇文章/博客这样的帖子能够有所帮助。 - feelingweird

13

我非常赞同楼主的观点,即“在Xamarin中,垃圾回收实质上是有问题的”。

以下示例说明了为什么您必须始终使用建议的DisposeEx()方法。

以下代码会导致内存泄漏:

  1. 创建一个继承自UITableViewController的类

  2. public class Test3Controller : UITableViewController
    {
        public Test3Controller () : base (UITableViewStyle.Grouped)
        {
        }
    }
    
  3. 从某处调用以下代码

  4. var controller = new Test3Controller ();
    
    controller.Dispose ();
    
    controller = null;
    
    GC.Collect (GC.MaxGeneration, GCCollectionMode.Forced);
    
  5. 使用Instruments工具,您将看到有大约274个持久对象占用了252 KB的内存从未被回收。

  6. 唯一的解决方法是在Dispose()函数中添加DisposeEx或类似的功能,并手动调用Dispose以确保disposing == true。

总结:创建一个UITableViewController派生类,然后释放/设置为空将始终导致堆增长。


1
你是如何发现剩余的252kb没有被释放的? - mr5

10

iOS和Xamarin有稍微有点棘手的关系。iOS使用引用计数来管理和处理内存。对象的引用计数在添加和删除引用时会被增加和减少。当引用计数变为0时,对象将被删除并释放内存。Objective C和Swift中的自动引用计数可以帮助解决这个问题,但在使用本地iOS语言开发时,仍然很难做到百分之百正确,可能会出现悬空指针和内存泄漏。

在Xamarin中为iOS编码时,我们必须考虑到引用计数,因为我们将使用iOS本地内存对象。为了与iOS操作系统通信,Xamarin创建了所谓的Peers来为我们管理引用计数。有两种类型的Peers-框架Peers和用户Peers。框架Peers是围绕着众所周知的iOS对象的托管包装器。框架Peers是无状态的,因此不会对底层iOS对象保持强引用,并且可以在需要时由垃圾收集器清理-不会导致内存泄漏。

用户Peers是从框架Peers派生的自定义托管对象。用户Peers包含状态,因此即使您的代码没有对它们的引用,也会被Xamarin框架保持活动状态。例如:

public class MyViewController : UIViewController
{
    public string Id { get; set; }
}
我们可以创建一个新的MyViewController,将其添加到视图树中,然后将UIViewController转换为MyViewController。可能没有对这个MyViewController的引用,因此Xamarin需要“根”据此对象,以使其在基础的UIViewController存在时保持活动状态,否则将丢失状态信息。
问题在于,如果我们有两个相互引用的用户对等体,则会创建一个无法自动断开的引用循环-而这种情况经常发生!
考虑以下情况:
public class MyViewController : UIViewController
{
    public override void ViewDidAppear(bool animated)
    {
        base.ViewDidAppear (animated);
        MyButton.TouchUpInside =+ DoSomething;
    }

    void DoSomething (object sender, EventArgs e) { ... }
}

Xamarin创建两个相互引用的用户对等体 – 一个用于MyViewController,另一个用于MyButton(因为我们有一个事件处理程序)。因此,这将创建一个引用循环,垃圾收集器无法清除。为了清除它,我们必须取消订阅事件处理程序,通常在ViewDidDisappear处理程序中完成 -例如:

public override void ViewDidDisappear(bool animated)
{
    ProcessButton.TouchUpInside -= DoSomething;
    base.ViewDidDisappear (animated);
}

总是取消订阅iOS事件处理程序。

如何诊断这些内存泄漏

诊断这些内存问题的好方法是在从iOS包装类派生的类的finalizer中添加一些代码,例如UIViewControllers。(尽管只将其放在调试版本中而不是发布版本中,因为它会导致速度变慢)。

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
}

因此,Xamarin在iOS上的内存管理并没有问题,但是您必须注意这些针对iOS运行的特定“小技巧”。

Thomas Bandt有一篇名为“Xamarin.iOS内存陷阱”的优秀页面,详细介绍了这个问题,并提供了一些非常有用的提示和技巧。


3
我在使用Sprite Kit的iOS应用程序中遇到了严重的内存泄漏问题,解决方法是在ViewDidDisappear中取消订阅事件处理程序。由于存在许多TouchUpInside事件的lambda表达式等,实施过程非常耗时,但完成后就没有内存泄漏问题了。 - MattjeS

5

我注意到在您的DisposeEx方法中,在销毁集合视图之前,您对集合视图源和表格视图源进行了处理。当我进行调试时,我发现可见单元格属性被设置为空数组,因此当您开始处理可见单元格时,它们不再“存在”,因此变成了一个零元素的数组。

另一件我注意到的事情是,如果您不将参数视图从其父视图中移除,您将遇到不一致异常,特别是在设置集合视图的布局方面。

除此之外,我们这边也不得不实现类似的功能。


3
那段代码很老旧。我已发布回答,应该能解决你提出的大部分问题。 - Herman Schoenfeld

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接