#はじめに
Unity2019からガベージコレクション発生時のスパイクを改善する機能[インクリメンタル GC]がリリースされました。
不要なメモリ確保によるガベージコレクション発生時のスパイクは回避可能かもしれませんが、
[マネージヒープの拡張が発生することによるメモリの枯渇]
&
[メモリが確保できなかった場合のクラッシュ]
に関しましては、[不要なメモリを確保しない]ことを意識しなければ、何れ発生します。
今回の記事では、[不要なメモリ割り当て(GC Alloc)]を避ける為の方法を少々雑になりましたが要約いたしました。
皆様の役に立てますと幸いです。。。!
#ガベージコレクションおよびマネージヒープの拡張に関しまして
インスタンスの生成や文字列連結などの要因でオブジェクトが生成される際に[GC Alloc]が発生し、それにより確保されたメモリはマネージヒープという領域に割り当てます。
メモリを確保した際にマネージヒープの空きが存在しない場合、Unity側で下記処理を実行します。
1.ガベージコレクションを実行し、マネージヒープ内の解放可能なヒープメモリの解放処理を実行します。
上記処理実行時にプログラム自体が一時停止(約 1ミリ秒未満~数百ミリ秒)する問題が発生します(俗に言う[スパイク])。
2.もしガベージコレクション実行後に、リクエストされたメモリ領域が確保出来ない場合は、マネージヒープが拡張されます。
(また、インスタンスの「生成」「解放」が繰り返されることにより「メモリの断片化」が発生し、より「マネージヒープの拡張」が発生しやすくなります。)
詳細はUnityのマニュアルの、下記リンクを参照。
マネージヒープ
ガベージコレクション発生時のスパイクに関しましては、Unity2019から追加された[インクリメンタルGC]を使用することにより大きく改善されますが、マネージヒープの拡張自体は[不要なメモリ割り当て]を避けることを徹底し、極力避けなければなりません。
上記マニュアルに記載されている通り、
マネージヒープが拡張する際、そのマネージヒープに割り当てられたメモリページが解放されることは稀です。拡張したヒープの大部分が空であったとしても Unity は拡張したヒープを維持し続けます。
とある為、マネージヒープの拡張が繰り返し発生した場合、拡張する為のメモリが確保されず、ゲームがクラッシュします。
#List、Dictionaryのキャパシティーは事前に設定
List及びDictionaryは、コンストラクタに指定したキャパシティーの上限を超える値を追加した際に、
キャパシティーの上限が動的に追加されGC Allocが発生します。
事前に必要十分なキャパシティーを設定することを推奨します。
#ラムダ式の記述内容を気を付ける
ラムダ式内でローカル変数/関数やメンバー変数/関数を参照した場合、
該当するラムダ式のコードが実行される度にGC Allocが発生します。
キャッシュされるラムダ式の記述を行う、
もしくはインターフェースを使用し[System.Action]を使用しない実装に変えることによって回避可能です。
GC Allocが発生するパターン
private int m_count = 0;
private static int m_countStatic = 0;
private void Update() {
    // ラムダ式内でメンバー変数を参照した場合、GC Allocが発生
    InvokeAction(() => {
        m_count++;
    });
    // ラムダ式内でローカル変数を参照した場合、GC Allocが発生
    int count = 0;
    InvokeAction(() => {
        count++;
    });
    // ラムダ式内でメンバー関数を参照した場合、GC Allocが発生
    InvokeAction(() => {
        IncrementCount();
    });
    // ラムダ式内でメンバー関数を直接指定した場合も、GC Allocが発生
    InvokeAction(IncrementCount);
    // ラムダ式内でstatic関数を直接指定した場合、デリゲートが都度生成される為GC Allocが発生
    InvokeAction(IncrementCountStatic);
}
private void InvokeAction(System.Action action) {
    action.Invoke();
}
private void IncrementCount() {
    m_count++;
}
private static void IncrementCountStatic() {
    m_countStatic++;
}
GC Allocが発生しないパターン
private static int m_countStatic = 0;
private void Update() {
    // ラムダ式内でstatic変数を参照した場合、GC Allocは発生せず
    InvokeAction(() => {
        m_countStatic++;
    });
    // ラムダ式内でstatic関数を参照した場合、GC Allocは発生せず
    InvokeAction(() => {
        IncrementCountStatic();
    });
    // ラムダ式内の引数を参照する場合も、GC Allocは発生せず
    InvokeRandom((int value) => {
        AddContStatic(value);
    });
}
private void InvokeAction(System.Action action) {
    action.Invoke();
}
private static void IncrementCountStatic() {
    m_countStatic++;
}
public void InvokeRandom(System.Action<int> action) {
    int rand = Random.Range(1, 100);
    action.Invoke(rand);
}
private static void AddContStatic(int value) {
    m_countStatic += value;
}
#InGame中はコルーチンを極力使用しない
コルーチンの呼び出しを実行する際に僅かながらGC Allocが発生しますので(40Bほど)、
リソースの読み込みや初期化処理以外のInGame中の処理では実行しないほうが良いでしょう。
また、コルーチン内に[yield return 0]や[yield return new WaitForSeconds(float seconds)]実行時も
GC Allocが発生しますので、コルーチンを用いて時間待ち処理を実装する場合は、
[yield return null]とwhile文を組み合わせる方法を用いた方が良いです。
([yield return 0]はボックス化、[yield return new WaitForSeconds()]はインスタンス生成の理由によりGC Allocが発生)
private int m_count = 0;
private void Update() {
   // コルーチン呼び出しによるGC Allocが発生
   StartCoroutine(CoroutineYieldReturnNull());
   // コルーチン呼び出し+ボックス化によるGC Allocが発生
   StartCoroutine(CoroutineYieldReturnZero());
   // コルーチン呼び出し+インスタンス生成によるGC Allocが発生
   StartCoroutine(CoroutineWaitForSeconds());
}
private IEnumerator CoroutineYieldReturnNull() {
   yield return null;
   m_count++;
}
private IEnumerator CoroutineYieldReturnZero() {
   yield return 0;
   m_count++;
}
private IEnumerator CoroutineWaitForSeconds() {
   yield return new WaitForSeconds(1.0f);
   m_count++;
}
#TextMesh Pro を用いたテキスト表示でのGC Alloc回避方法抜粋
[SerializeField] private TMP_Text m_textStringBuilder = null;
[SerializeField] private TMP_Text m_textNumber = null;
[SerializeField] private string[] m_addTexts = null;
private int m_index = 0;
private StringBuilder m_stringBuilder = new StringBuilder(100);
// Update is called once per frame
private void Update() {
    m_stringBuilder.Clear();
    m_stringBuilder.Append("<color=#00FFFF>[Non Alloc]<color=#FFFFFF>SetText Add Text:");
    m_stringBuilder.Append(m_addTexts[m_index]);
    //----------------------------------------------//
    // StringBuilder.Append(int value)を実行した際に
    // Append関数内で[int.ToString()]が実行される為
    // GC Allocが発生します
    // https://docs.microsoft.com/ja-jp/dotnet/api/system.text.stringbuilder.append?view=netframework-4.8#System_Text_StringBuilder_Append_System_Int32_
    //----------------------------------------------//
    //mStringBuilder.Append("Index:");
    //mStringBuilder.Append(m_index);
    // TMP_Text.SetText()+StringBuilderを使用することにより、GC Allocを発生せずに文字列連結+表示が可能
    // TMP_Text.SetText() によるGCは実機では発生しません
    m_textStringBuilder.SetText(m_stringBuilder);
    // TMP_Text.SetText()+int/floatを使用することにより、GC Allocを発生せずにint/float型の文字列連結+表示が可能
    // TMP_Text.SetText() によるGCは実機では発生しません
    m_textNumber.SetText("Index:{0}", m_index);
    m_index = (m_index + 1) % m_addTexts.Length;
}
#文字列及び配列を返すUnity標準のAPIは要注意
配列を返すUnity標準の関数(Physics.RaycastAll() など)や、
文字列及び配列を返すプロパティ(GameObject.name, Input.touches など)は十中八九GC Allocが発生します。
一度呼び出した後に値をキャッシュする、もしくはGC Allocが発生しない関数に置き換えることによって対処可能です。
(Physics.RaycastNonAlloc(), GameObject.CompareTag(), NavMeshPath.GetCornersNonAlloc() など)
#Profilerを使用したパフォーマンスチェックを実施
実装に区切りがついたタイミングで(リリース前のデバッグ期間ではこまめに)、Profilerを用いたパフォーマンスチェックを行いましょう。
Unity Editor上でも大まかなプロファイリングが可能ですが、[Unity Editor]限定で[GC Alloc発生]や[パフォーマンス低下]が発生するケースがありますので、
実機ビルドを作成してプロファイリングすることをオススメします。
実機ビルド作成時に[Build Settings]の下記項目を有効化することによって、
[Unity EditorのProfilerへの自動接続]+[実機ビルドでのDeep Profiling]がサポートされます。
1.Develop Build
2.Autoconnect Profiler
3.Deep Profiling Support