【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です。
明示的にDisposeしなければOnCompletedは呼ばれずOnNextが呼ばれ続けます。

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

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

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を削除するとObservableが残り、その中で参照しているテクスチャ100個も当然残ったままになります。

こうして立派にメモリリークが完成しました。
このようなソースコードは行く行くはアプリのクラッシュに繋がります。

AddToでメモリリークを防ぐ

前節のようなメモリリークは、下記のようにAddToを使うことで防げます。

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も破棄する
    }
}

このように書くと、このコンポーネントが破棄された時にObservableも破棄されます。
(もちろんその後にResources.UnloadUnusedAssets()を呼ばないとテクスチャはメモリからなくなりませんが)

ちなみにGameObjectに対してAddToすることもできます。

Observable.IntervalFrame(1)
    .Subscribe(x => {
        Debug.Log(textures.Length);
    })
    .AddTo(gameObject); // GameObjectが破棄されたらこのObservableも破棄する

この場合はGameObjectが破棄されたらObservableも破棄されます。

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

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

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についても同様です。
下記のようなソースコードメモリリークを引き起こします。

using UniRx;
using UnityEngine;

public class Example : MonoBehaviour
{
    private Subject<Unit> _subject;

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

        _subject = new Subject<Unit>();
        // Subjectを購読
        _subject.Subscribe(x => Debug.Log(textures.Length));
        // Subjectに値を流す
        _subject.OnNext(Unit.Default);
    }
}

このようなケースでは、まずはしっかりSubjectがいらなくなった時点でDisposeする必要があります。

using UniRx;
using UnityEngine;

public class Example : MonoBehaviour
{
    private Subject<Unit> _subject;

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

        _subject = new Subject<Unit>();
        _subject.Subscribe(x => Debug.Log(textures.Length));
        _subject.OnNext(Unit.Default);
    }

    private void OnDestroy()
    {
        // いらなくなったらDispose
        _subject.Dispose();
    }
}

また購読側でもAddToなどでしっかり購読期間を管理しておく必要があります。

// 購読側も生存期間を管理する
_subject.Subscribe(x => Debug.Log(textures.Length)).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);
}

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