Version: 2021.3
言語: 日本語
アセットの監査
Resources フォルダー

文字列とテキスト

Unity プロジェクトにおけるパフォーマンス問題の一般的な原因のひとつに、文字列とテキストの扱いがあります。C# では、全ての文字列がイミュータブルです。文字列を操作した場合には必ず新しい完全な文字列の割り当てが発生します。これは比較的に負荷が高く、大きな文字列や大きなデータセット、またはタイトなループで文字列の連結を頻繁に行うと、パフォーマンスの問題に繋がり兼ねません。

さらに、N 文字列の連結には N–1 中間文字列の割り当てが必要なため、連結が繰り返されるとマネージメモリに高負荷がかかる大きな原因ともなり得ます。

タイトなループ内で、あるいはフレーム毎に文字列を連結しなければならない場合は、StringBuilder を使用して実際の連結処理を行うようにしてください。StringBuilder インスタンスを使用することで、不要なメモリ割り当てを更に削減することもできます。

Microsoft から、C# で文字列を扱う場合のベストプラクティス一覧が提供されています。MSDN のウェブサイト msdn.microsoft.com を参照してください。

ロケールの強制と序数比較

文字列関連のコードでよく見られる主要なパフォーマンス問題の 1 つは、速度の遅いデフォルト文字列 API を意図せず使用してしまうことです。こういった API はビジネスアプリケーション向けに開発されたもので、テキスト中の文字に関して、多様な文化的、言語的規則に基づく文字列を扱えるように意図されています。

例えば、以下の参考コードは US-English ロケールで実行された場合には true を返しますが、多くの欧州ロケールでは false を返します。

ノート: Unity 5.3 と 5.4 の時点では、 Unity のスクリプト ラインタイムは常にUS English (en-US) ロケールで実行されます。

    String.Equals("encyclopedia", "encyclopædia");

大部分の Unity プロジェクトでは、これは全く不要です。序数に基づく比較を使用したほうが約 10 倍程速くなります。序数に基づく比較は C や C++ のプログラマーには馴染み深い方法で、各バイトが表す文字に関わらず、単純に文字列の一連のバイトを比較します。

序数に基づく文字列比較への切り替えは、StringComparison.Ordinal を最終引数引数として String.Equals に提供するだけで簡単に行えます。

myString.Equals(otherString, StringComparison.Ordinal);

非効率的なビルトイン String API

通常の比較に切り替わるだけでなく、特定の C# String API は非常に非効率であることが知られています。その中には String.FormatString.StartsWithString.EndsWith があります。String.Format を置き換えるのは困難ですが、非効率な文字列比較メソッドは簡単に最適化できます。

Microsoft は、ローカライゼーションのための調整を必要としない文字列比較に StringComparison.Ordinal を渡すことを推奨しています。 Unity のベンチマークは、カスタム実装と比べてこの影響は比較的小さいことを示しています。

方法 100k の短い文字列にかかる時間 (ms/ミリ秒)
String.StartsWith、デフォルトカルチャ 137
String.EndsWith、デフォルトカルチャ 542
String.StartsWith、序数 115
String.EndsWith、序数 34
Custom StartsWith 置き換え 4.5
Custom EndsWith 置き換え 4.5

String.StartsWithString.EndsWith は両方とも、以下のような単純な手書きコード版に置き換えることが可能です。


    public static bool CustomEndsWith(this string a, string b)
        {
            int ap = a.Length - 1;
            int bp = b.Length - 1;
    
            while (ap >= 0 && bp >= 0 && a [ap] == b [bp])
            {
                ap--;
                bp--;
            }
    
            return (bp < 0);
        }

        public static bool CustomStartsWith(this string a, string b)
        {
            int aLen = a.Length;
            int bLen = b.Length;
    
            int ap = 0; int bp = 0;
    
            while (ap < aLen && bp < bLen && a [ap] == b [bp])
            {
                ap++;
                bp++;
            }
    
            return (bp == bLen);
        }

正規表現

正規表現は文字列のマッチや操作を行うにあたって非常に有益な方法ですが、パフォーマンス負荷が著しく高くなる場合があります。さらに、 C# ライブラリの正規表現の実装が原因で、単純なブーリアン IsMatch クエリーでさえも大きな一時的データ構造体を “内部的に” 割り当てます。このような一時的マネージメモリの使い回しは、初期化中を除いては行われないようにする必要があります。

正規表現が必要な場合は、静的な Regex.Match メソッドや Regex.Replace メソッドを使用しないことが強く推奨されます。 Regex.MatchRegex.Replace は正規表現をひとつの文字列パラメーターとして受け取ります。これらのメソッドは正規表現をオンザフライでコンパイルし、生成されたオブジェクトをキャッシュしません。

これは、ごく当たり障りのない 1 行コードです。


Regex.Match(myString, "foo");

しかし、これは実行される度に 5 KB のガベージを生成します。このガベージは簡単なリファクタリングによって無くすことができます。


var myRegExp = new Regex("foo");

myRegExp.Match(myString);

この例では、 myRegExp.Match への 1 回の呼び出しが生成するガベージは 320 バイトのみです。単純な照合処理の場合、これでも高負荷でですが、先の例に比べればかなり改善されます。

したがって、正規表現が不変文字列リテラルであれば、Regex オブジェクトのコンストラクターの最初のパラメーターとして渡して事前コンパイルするほうが格段に効率的です。プリコンパイルされた Regex はその後再使用されます。

XML、 JSON、およびその他の長文式テキスト構文解析

テキストの構文解析は、読み込み時間に発生する処理でしばしば最も重くなるもののひとつです。テキストの構文解析にかかる時間がアセットのロードとインスタンス化にかかる時間を越えることもあります。

この理由は使用されるパーサによって異なります。C# のビルトイン XML パーサーは非常に融通の利くものですが、特定のデータレイアウト向けには最適化できません。

サードパーティのパーサーの多くはリフレクションを基盤に構築されています。リフレクションは、(パーサーがデータレイアウトの変化に素早く適応できるようになるため) 開発中には非常に優れた選択肢ですが、速度が遅いことで知られています。

Unity ビルトインの JSONUtility API によって、部分的にこれを解決することができます。これは、 JSON の入出力を行う Unity のシリアライゼーション システムにインターフェースを提供するものです。この API に関しては、純粋な C# JSON パーサーよりも速いという評価が大部分のベンチマークで出ていますが、 Unity のシリアル化システムに対して、他のインターフェースの場合と同様の制約 (Dictionaries などの複雑なデータタイプの場合は追加コードなしでシリアル化ができない) があります。

ノート: Unity のシリアル化処理中に複雑なデータ型との変換に必要な追加処理を追加する方法については、ISerializationCallbackReceiver インターフェースを参照してください。

テキストデータの構文解析に起因するパフォーマンス問題が発生した場合は、 3 つの解決方法をご検討ください。

[方法 1] ビルド時間で構文解析する

テキスト構文解析の負荷を回避する最善の方法は、ランタイムでのテキスト構文解析を完全に無くすことです。これは基本的に、テキストデータを何かしらのビルド手順によってバイナリ形式に “ベイクする” ことを意味します。

この手法を採るディペロッパーにほとんどは、何らかの ScriptableObject 派生のクラス ヒエラルキーにデータを移動させ、その後にデータをアセットバンドル経由で分配します。 Youtube の Unite 2016 における Richard Fine の 講演 で、 ScriptableObject の使用に関して非常に有益な解説が提供されています。

この方法は最高のパフォーマンスをもたらしますが、動的に生成される必要のないデータにしか適しません。これが最も適しているのはゲームデザインパラメーターなどです。

[方法 2] 分割して遅延読み込みを行う

2 つ目は、構文解析される必要のあるデータを小さく分割する方法です。分割されたデータの構文解析にかかる負荷はいくつかのフレームに分散され得ます。理想的なのは、データの中で、理想のユーザー体験を提供するために必要な部分を特定し、その部分のみを読み込むことです。

簡単な例を挙げると、プラットフォームゲームのプロジェクトであれば、全てのステージのデータをひとつの巨大な塊にまとめてシリアライズする必要はありません。データをステージ毎に個別のアセットに分割し、さらに必要に応じ各ステージをエリア毎に分割すれば、プレイヤーの進行に応じて随時必要なデータを構文解析することができます。

これは簡単そうに聞こえますが、実際にはツールコードに相当な負荷がかかり、またデータ構造の再整理が必要になる可能性もあります。

[方法 3] スレッド

通常の C# オブジェクト内に完全に構文解析されたデータで、 Unity API との対話を一切必要としないものであれば、構文解析処理をワーカースレッドに移動することが可能です。

このオプションは、相当数のコアを持つプラットフォームでは非常に強力なものです。しかしデッドロックやレースコンディションを発生させないよう、注意深くプログラミングする必要があります。

ノート: iOS デバイスは最高で 2 つのコアしか搭載していません。大部分の Android デバイスは 2 - 4 つ搭載しています。この手法が比較的適しているのは、ビルドターゲットがスタンドアロンやコンソールの場合です。

スレッド方式を導入しているプロジェクトでは、ビルトイン C# スレッド クラスや ThreadPool クラス (msdn.microsoft.com を参照) を通常の C# 同期クラスと併用してワーカースレッドを管理します。

アセットの監査
Resources フォルダー