SDD(Sleep-Driven Development)

睡眠の重要性!!睡眠の重要性!!

Xamarin.iOSにおける循環参照

この記事は[初心者さん・学生さん大歓迎!] Xamarin その1 Advent Calendar 2017の12日目の記事です。
昨日は@kmz_kappaさんでXamarinでも難読化のすゝめでした。

今回はXamarin.iOSをやる上で気をつけたほうがいいと思ったメモリリークのお話です。
基本的にはXamarin.iOS特有のお話でXamarin.FormsやXamarin.Androidは大丈夫なはず。。。
ただし、Xamarin.FormsでCustomRenderer等を用いてNSObjectを触る場合は注意が必要です。

また、もし書いてあることに誤りがあったときはご指摘をよろしくお願いします🙇

環境

循環参照とは

循環参照とは、以下のコードのようにHogeというオブジェクトとFooというオブジェクトがお互いに参照を持ってしまっている状態です。

class Hoge 
{
    Foo foo;

    public Hoge() 
    {
       foo = new Foo(this);
    }
}

class Foo 
{
    Hoge hoge;

    public Foo(Hoge hoge) 
    {
        this.hoge = hoge;
    }
}

.NET Frameworkでは特に問題ないコードですが、Xamarin.iOSでは特定条件下でオブジェクトが破棄されなくなる可能性があります。

iOSにおける循環参照問題

.NET Frameworkなどのオブジェクトを追跡して破棄するGCを採用している場合は上記コードでも問題は起きないのですが、代入などでオブジェクトの参照が増えたときにカウントを増やし、0になったら破棄をする参照カウント方式のGCを使っているiOSでは問題が発生します。

たとえば

void NewHoge() 
{
    var hoge = new Hoge();
}

としたとき、hogeの参照カウントは+1されます。 また、コンストラクタ内でFooをHogeのフィールドfooに代入してfooの参照カウントは+1されます。 そしてFooにthisを渡しているのでhogeの参照はまた+1されます。

このとき、上記例でメソッドを抜けた場合hogeは破棄されることを期待します。 が、メソッドを抜けたことでhogeの参照カウントは-1されますが、まだhogeのフィールドfooが持っている参照が残っています。 参照カウント方式では参照カウントが0にならないとオブジェクトは破棄されないので0になっていないhogeは破棄されません。 また、foohogeが参照を持っているため破棄されません。

よって永遠にhogefooの参照カウントは0にならず、NewHoge()というメソッドを抜けたあとも生き続け、メモリリークとなるのです。

Xamarin.iOSにおける循環参照問題

上の例ではすぐに循環参照になりそうですが、Monoは優秀で実は上記のようにただフィールドで循環参照した場合ではきちんとオブジェクトは破棄されます。

しかしNSObjectが関わってきた場合は注意が必要です。 たとえば、

public class Hoge : UIView
{
    public Foo Foo { get; set; }
    public Hoge() : base(CGRect.Empty)
    {
        Foo = new Foo {Hoge = this};
    }
}

public class Foo : UIView
{
    public Hoge Hoge { get; set; }
}

上記コードは先程のとおりオブジェクトが使われなくなったときに破棄されます。

しかし、

public class Hoge : UIView
{
    public Hoge() : base(CGRect.Empty)
    {
        var foo = new Foo {Hoge = this};
        AddSubview(foo);
    }
    ...
}
...

のようにUIViewのメソッドであるAddSubviewに循環参照しているfooオブジェクトを渡すとオブジェクトが破棄されなくなります

これはメソッドにかぎらず、プロパティやイベントに循環参照したオブジェクトを渡しても発生します。

特にイベントなどでラムダ式を使うと、注意していても循環参照するときがあります。

たとえば、

public class ViewController : UIViewController
{
    protected ViewController(IntPtr handle) : base(handle)
    {
        // Note: this .ctor should not contain any initialization logic.
    }

    public override void ViewDidLoad()
    {
        base.ViewDidLoad();
        // Perform any additional setup after loading the view, typically from a nib.

        var button = new UIButton();
        button.TouchUpInside += (sender, args) =>
        {
            var viewController = this.Storyboard.InstantiateInitialViewController();
            this.NavigationController.PushViewController(viewController, true);

        };

        View.AddSubview(button);
    }
}

上のようなコードは.NETではよく見そうなコードですが、ViewControllerとbuttonが循環参照してオブジェクトが破棄されません。

これはbutton.TouchUpInsideに渡しているラムダ式内でthisを使うことによってViewControllerの参照がキャプチャされbuttonオブジェクトが持ってしまい、循環参照になってしまうのです。

循環参照の解決方法

循環参照はお互いに参照を持っていることが問題なので適切なタイミングで開放するようにすれば問題ありません。

いくつか方法はあるのですが、ここでは2つ書きます。

WeakReference

1つ目の方法は、オブジェクト同士がお互いに強く参照していることが問題なので片方を弱参照にすれば大丈夫です。

public class Hoge : UIView
{
    public Foo Foo { get; set; }

    public Hoge() : base(CGRect.Empty)
    {
        var weakRef = new WeakReference<Hoge>(this);
        Foo = new Foo(weakRef);
        AddSubview(Foo);
    }
}

public class Foo : UIView
{
    WeakReference<Hoge> weakHoge;

    public Hoge Hoge
    {
        get
        {
            if (weakHoge.TryGetTarget(out var hoge))
            {
                return hoge;
            }

            return null;
        }
    }

    public Foo(WeakReference<Hoge> weakHoge) : base(CGRect.Empty)
    {
        this.weakHoge = weakHoge;
    }
}

AddSubViewをしていますが、上記コードは問題なくオブジェクトが破棄されます。

手動で開放

2つ目はオブジェクトが破棄されるタイミングでも循環参照が残っているのが問題なので、自分で開放してあげます。

public class Hoge : UIView
{
    public Foo Foo { get; set; }

    public Hoge() : base(CGRect.Empty)
    {
        Foo = new Foo(this);
        AddSubview(Foo);
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            Foo.RemoveFromSuperview();
        }
        base.Dispose(disposing);
    }
}

public class Foo : UIView
{
    Hoge hoge;
    public Foo(Hoge hoge) : base(CGRect.Empty)
    {
        this.hoge = hoge;
    }
}

ここで注意なのが、Dispose(bool)メソッドは正しく実装されていた場合オブジェクトが破棄されるタイミングで呼び出されますが、Finalizerからの呼び出しなのでdisposingはfalseになります。 なので上記コードの場合はHogeが必要なくなったタイミングでDispose()メソッドを呼び出して上げる必要があります。

また、イベントの場合、

public class ViewController : UIViewController
{
    protected ViewController(IntPtr handle) : base(handle)
    {
        // Note: this .ctor should not contain any initialization logic.
    }

    public override void ViewDidLoad()
    {
        base.ViewDidLoad();
        // Perform any additional setup after loading the view, typically from a nib.

        var button = new UIButton();
        button.TouchUpInside += (sender, args) =>
        {
            var viewController = Storyboard.InstantiateInitialViewController();
            NavigationController.PushViewController(viewController, true);
        };

        View.AddSubview(button);
    }
}

のような書き方だとラムダ式なのでイベントの購読を解除できず、またViewDidUnloadは非推奨で使用すべきではないので解除するタイミングがありません。

なので、

public class ViewController : UIViewController
{
    UIButton button;
    
    protected ViewController(IntPtr handle) : base(handle)
    {
        // Note: this .ctor should not contain any initialization logic.
    }

    public override void ViewDidLoad()
    {
        base.ViewDidLoad();
        // Perform any additional setup after loading the view, typically from a nib.

        button = new UIButton();
        View.AddSubview(button);
    }

    public override void ViewWillAppear(bool animated)
    {
        base.ViewWillAppear(animated);
        
        button.TouchUpInside += OnTouchUpInside;
    }

    public override void ViewDidDisappear(bool animated)
    {
        base.ViewDidDisappear(animated);

        button.TouchUpInside -= OnTouchUpInside;
    }

    private void OnTouchUpInside(object sender, EventArgs args)
    {
        var viewController = Storyboard.InstantiateInitialViewController();
        NavigationController.PushViewController(viewController, true);
    }
}

のようにViewWillAppearでイベントを購読、ViewDidDisappearでイベント購読解除すれば大丈夫です。

循環参照の調べ方

Xamarin Profilerが便利そうなのですが、Enterpriseライセンスを持ってないので詳細はわかりません。

Xamarin Profiler について - Xamarin 日本語情報

一番簡単な方法は、NSObjectならDispose(bool)メソッドをoverrideしてConsole.WriteLine()なりなんなりする方法です。

循環参照をするとオブジェクトが破棄されなくなるのでFinalizerから呼び出されるDispose(bool)メソッドも呼び出されなくなります。

ただし、iOS Simulatorだと結構頻繁にDisposeメソッドが呼ばれていることが確認できるのですが、iOS実機だとなかなかDisposeメソッドが呼ばれません。

これはiOS SimulatorだとGC.Collect()?が頻繁に呼び出されているからだそうです。
memory management - the garbage collector in Xamarin Ios it not working on devices - Stack Overflow

なので実機ではどこかにGC.Collectを仕込むか、リソースを食う処理を頻繁に起こすか、ひたすら待つかして確認する必要がありそうです。

もっといい方法があれば教えてください。。

まとめ

ここではiOSで初めて開発する人が嵌りそうな循環参照によるメモリリークについて書きました。

C#の世界にいる限り、循環参照によるメモリリークは大丈夫なはずですが、UIView.AddSubviewのようなメソッドやプロパティを通してC#の世界の外に出ると注意しなければメモリリークになってしまいます。

ここいら辺かなりややこしいのでまだまだ罠がありそうですが、

  • 循環しそうなフィールドやプロパティを持つときはWeakRefenrenceを使用する
  • イベントはしっかり購読解除する

などを心がけていれば大丈夫だと思います。

明日は@DevTakasさんです。
よろしくお願いします!