【Unity】UniRxでメモリリークを引き起こすケースとAddToやTakeUntilDestroyの重要性

UniRxでメモリリークするケースとAddToやTakeUntilDestroyの重要性についてまとめました。

Unty2018.4.6

メモリリークを起こしてみる

まずは悪い例として、以下のようなソースコードを書いてみます。

using UniRx;
using UnityEngine;

public class Example : MonoBehaviour
{
    private void Start()
    {
        Observable.IntervalFrame(1)
            .Subscribe(x => Debug.Log(x));
    }
}

IntervalFrameは指定したフレーム数の間隔でOnNextを呼び続けるObservableです。

これを適当なGameObjectにアタッチして再生します。
そして再生後、そのGameObjectを削除してみます。
すると、GameObjectを破棄したにもかかわらずConsoleにログが出力され続けることが確認できます。

つまり、GameObjectが破棄されたとしてもこの購読自体は生き続けます。
これは例えば下記のようなケースで大きな問題を起こします。

using UniRx;
using UnityEngine;

public class Example : MonoBehaviour
{
    private void Start()
    {
        // テクスチャを100個生成する
        var textures = new Texture2D[100];
        for (int i = 0; i < 100; i++) {
            textures[i] = new Texture2D(1, 1);
        }

        Observable.IntervalFrame(1)
            .Subscribe(x => {
                // OnNextでtexturesを使う
                Debug.Log(textures.Length);
            });
    }
}

上記の例ではテクスチャを100個生成し、OnNextの中でその参照をキャプチャしています。
この状態でGameObjectを削除すると購読が停止されず、その中で参照しているテクスチャ100個も残ったままになります。

こうしてメモリリークが完成しました。

メモリリークの防ぎ方

ではこのようなメモリリークを防ぐにはどうしたらいいのでしょうか。
UniRxにはいくつかの方法が存在するので順に説明していきます。

基本は要らなくなったらDispose

まず、基本は不要になった時点でちゃんとIDisposable.Dispose()を呼んで購読を停止することです。

using System;
using UniRx;
using UnityEngine;

public class Example : MonoBehaviour
{
    private IDisposable _disposable;
    private void Start()
    {
        // テクスチャを100個生成する
        var textures = new Texture2D[100];
        for (int i = 0; i < 100; i++) {
            textures[i] = new Texture2D(1, 1);
        }

        _disposable = Observable.IntervalFrame(1)
            .Subscribe(x => {
                // OnNextでtexturesを使う
                Debug.Log(textures.Length);
            });
    }

    private void OnDestroy()
    {
        _disposable.Dispose();
    }
}
AddToでライフタイムをGameObjectやコンポーネントに依存させる

前節で説明した通り、基本的には使わなくなった時点でIDisposable.Dispose()を呼ぶべきですが、
この方法だと前節の例の通り、コンポーネントのOnDestroyで同じようなDispose処理を大量に書く必要が出てきます。

このような場合にはIDisposable.AddTo()を使うと、その購読の寿命をGameObjectやコンポーネントなどに依存させることができます。
つまり下記のように書けば、このコンポーネントが破棄された時点で購読も停止されます。

using UniRx;
using UnityEngine;

public class Example : MonoBehaviour
{
    private void Start()
    {
        var textures = new Texture2D[100];
        for (int i = 0; i < 100; i++) {
            textures[i] = new Texture2D(1, 1);
        }

        Observable.IntervalFrame(1)
            .Subscribe(x => {
                Debug.Log(textures.Length);
            })
            .AddTo(this); // このコンポーネントが破棄されたらこのObservableも破棄する
            //.AddTo(gameObject); // GameObjectに依存させることもできる
    }
}
TakeUntilDestroyでメモリリークを防ぐ

もう一つの方法として、下記のようにTakeUntilDestroyオペレータを使う方法があります。

using UniRx;
using UnityEngine;

public class Example : MonoBehaviour
{
    private void Start()
    {
        var textures = new Texture2D[100];
        for (int i = 0; i < 100; i++) {
            textures[i] = new Texture2D(1, 1);
        }

        Observable.IntervalFrame(1)
            .TakeUntilDestroy(this)
            .Subscribe(x => {
                Debug.Log(textures.Length);
            });
    }
}

このように書くと、コンポーネントが破棄された時にOnCompletedを呼んでから購読を停止します。

AddTo + CompositeDisposableでメモリリークを防ぐ

前節までのようにコンポーネントやGameObjectにObservableの生存期間を合わせるほかに、
CompositeDisposableを使って任意のタイミングでObservableを破棄する方法があります。

using UniRx;
using UnityEngine;

public class Example : MonoBehaviour
{
    private CompositeDisposable _disposable = new CompositeDisposable();

    private void Start()
    {
        var textures = new Texture2D[100];
        for (int i = 0; i < 100; i++) {
            textures[i] = new Texture2D(1, 1);
        }

        Observable.IntervalFrame(1)
            .Subscribe(x => {
                Debug.Log(textures.Length);
            })
            .AddTo(_disposable);
    }

    private void OnDisable()
    {
        _disposable.Clear();
    }
}

上記のようにCompositeDisposableに対してAddToすることで、
そのCompositeDisposableが破棄された時(Dispose()やClear()が呼ばれた時)にObservableを破棄します。

上記の例ではOnDisableのタイミングでObservableが破棄されます。

必ずOnCompoletedするなら問題ないけどAddToした方が安心

なお、以下のように必ずOnCompletedするようなObservableの場合はリークしません。

using UniRx;
using UnityEngine;

public class Example : MonoBehaviour
{
    private void Start()
    {
        // TimerFrameのようにOnCompletedが呼ばれる場合はリークしない
        Observable.TimerFrame(1)
            .Subscribe(x => Debug.Log("example"));
    }
}

が、AddToしておくほうが安心ではあるでしょう。

SubjectやReactivePropertyを購読するときにも注意

SubjectやReactivePropertyもIObservableの実装なので、これらを扱う際にも上述と同様の注意を払う必要があります。

using UniRx;
using UnityEngine;

public class Example : MonoBehaviour
{
    public static Subject<Unit> SomeStaticSubject { get; set;  }

    private void Start()
    {
        SomeStaticSubject
            .Subscribe(_ => { })
            .AddTo(this);
    }
}

Observable作成時にTakeUntilDestroyを義務付けるのが安全かも

このようにUniRxによるメモリリークは起こりやすく防ぎづらいです。

理想的にはObservableを購読する側でもしっかりAddToして購読期間を管理することですが、
大規模な開発になってくると各自が購読する処理をすべてチェックするのはなかなか難しそうです。

そこで、Observableを作成する側で必ずObservableの生存期間を制御することを徹底すれば最悪メモリリークは防げます。

using UniRx;
using UnityEngine;
using System;

public class Example : MonoBehaviour
{
    private Subject<Unit> _subject = new Subject<Unit>();
    // 購読用にはこちらを公開する
    public IObservable<Unit> Subject => _subject.TakeUntilDestroy(gameObject);
}

大規模開発においてはこのようなルール作りから考えることでメモリリークを防ぐことができそうです。