UnityでC#を使うときのヒント集

はじめに

こんにちは。カヤックソーシャルゲーム事業部技術基盤チーム所属、Unityエンジニアのと申します。普段はUnity用のツールや共通ライブラリーなどの開発を担当しています。よろしくお願いいたします。

この記事はカヤックUnityアドベントカレンダー2016の7日目の記事になります。

C#言語はいろんな場面でアプリを開発することができますが、プラットフォームごとに挙動の違いや特別な注意事項などがあります。今回はUnityの環境でC#を使うときに注意すべきポイントを紹介します。

NullReferenceExceptionをなくす

NullReferenceExceptionはUnityでゲームを開発するとき一番出やすい例外です。特に実機で発生する場合は、アプリが動かなくなることもよくあります。NullReferenceExceptionは値がnullになってる変数にアクセスしようとするとき発生する例外です。ネットワークエラーやユーザー入力によるエラーなどと違って、NullReferenceExceptionが出た場合は大抵スクリプトのどこかにバグがあります。ですから対応の方針は「処理する」じゃなくて「無くす」です。

nullチェック

ポイントは変数や関数の戻り値などを使う前に、nullの可能性を考えることです。このときはnullに対して、「ありえる」と「ありえない」の2パタンがあります。List<T>.FindGetComponentなど、nullを返す可能性が十分ある場合は、変数を使う前にnullチェックをする必要があります。

例えば

var camera = GetComponent<Camera>();

// nullをチェック、処理する
if (camera == null)
{
    camera = gameObject.AddComponent<Camera>();
}

// 絶対nullじゃないから、cameraを使う...

Assertを使う

逆に、ロジック的に「この関数はnullを返すわけがない」場合もあります、こういうときにnullチェックをしても、バグを隠し、CPUを無駄にするだけなので、チェックせずに「これはバグだ」と大声で警告を出すべきです。こんなときにはUnityのAssertクラスが役に立ちます。

Assertクラスにはいろんなアサーションメソッドが用意されています。使い方は簡単、例えば

var character = CreateCharacter();

// characterはnullではないはずと宣言する
Assert.IsNotNull(character, "CreateCharacter should always return a valid character object.");

// characterを使う...

こうしたら、万が一nullが返された場合には、エラーメッセージがログされ、バグがあることもすぐわかります。そしてAssert.raiseExceptionsを設定したら、スクリプトの実行も止めてくれます。アサーションメソッドは本番ビルドから自動で削除されるので、オーバーヘッドがないのも便利です。

他にもいろんなアサーションメソッドがあるので、積極的に使えば、バグは少なくなるでしょう。

UnityEngine.Object

UnityEngine.ObjectクラスはUnityが管理しているオブジェクトの共通ベースクラスです。CameraMaterialなどの型はもちろん、MonoBehaviourScriptableObjectから派生したスクリプトクラスもUnityEngine.Objectです。UnityEngine.Objectを使うときには特別な注意事項があります。

== nullの挙動

UnityEngine.Objectobj == nullでnullと比較するときの挙動は普通のC#オブジェクトと違って、nullではないのにtrueになる場合もあります。例えば、AssetBundle.Unloadを呼んだら、nullをアサインしなくてもassetBundle == nullの結果もtrueになります。こんなことが起こる原因は、UnityEngine.Object==オペレーターをオーバーライドしているからです。== nullの意味は「破棄されたかどうか」です。

C#のオブジェクトとしてほんとにnullであるかどうかをチェックしたいときには、ReferenceEquals(obj, null)を使えばOKです。

コンストラクタはなぜ使えない

MonoBehaviourとかから派生したスクリプトクラスを書くとき、コンストラクタを使わずにAwakeOnEnableなどを使うのが基本です。C#をよく使う人にとっては不自然ですが、以下のような理由でコンストラクタを使用できません。

  1. プレーモード以外にもスクリプトのインスタンス化が行われています。シーンを編集するときなど、ゲームが実際実行されてない場合にもスクリプトのインスタンスを作っています。初期化ロジックをコンストラクタで書くと、実行されるタイミングは分かりづらいです。
  2. UnityEngine.Objectのコンストラクタは非同期でローディングスレッドで実行されています。Unityのスクリプトエンジンはシングルスレッドベースなので、別スレッドではGetComponentなどのUnity APIは使えないし、同期をとるのも複雑で、間違えやすいです。Awakeなどのメッセージはこういった問題はありません。

GC Allocを減らす

Unityは.NETと同じ、自動メモリー管理が実装されていますが、その性能はCLR標準のGCと比べて結構差があります。注意しないとゲームのパフォーマンスがかなり悪くなります。UnityのGCはヒープ上のオブジェクトが多いほどメモリー回収時間が長くなります。これはゲームのフレームレートが急に下がったりする主な原因です。

メモリー自動回収のタイミングはランダムではなく、動的メモリー確保の時のみです。これはProfiler上のGC Allocで確認できます。最適化方法はUnityマニュアルに書いてありますが、初心者では気づきにくい罠もあります。

配列を返すUnity API

Unityのビルトイン型は一見普通のC#クラスですが、実際にはデータを持っていないエンジン内部オブジェクトのブリッジです。だからGetComponents<T>()Mesh.verticesなど配列を返すAPIは、毎回C#側で新しい配列を確保することになります。Updateの中とかで毎フレームこういうAPIにアクセスすると、GC回収の頻度は大きく上がります。

対策は配列を確保しないAPIを使うか配列をキャッシュして使いまわすかです。例えば

private List<Component> components = new List<Component>();

void Update()
{
    // 配列を確保していない
    GetComponents<Component>(components);
}
// 頂点データのキャッシュ
private List<Vector3> vertices;

void Start()
{
    vertices = new List<Vector3>(mesh.vertices);
}

void Update()
{
    // 頂点データを編集...
    vertices[0] = new Vector3(x, y, z);
    ...

    // データをUnityに渡す
    mesh.SetVertices(vertices);
}

foreachループの問題

C#でよく使われているforeachループも実際にはメモリーを確保しています。Updateなどで毎フレーム実行すると、フレームレートはかなり下がることもあります。過去には標準の.NETもこの問題に引っかかっていましたが、現在は直っています。Unityが使っているMonoコンパイラーが古いので、5.4までのバージョンではまだこの問題が修正されていません。5.5正式版が出るまでは、forループを使うか、手動でforeachを展開するしかありません。

Dictionary<TKey, TValue>の場合の展開方法を紹介します。

void Update()
{
    // Update中にforeach、GCが頻繁に発生
    foreach (var pair in dictionary)
    {
        var key = pair.Key;
        var value = pair.Value;
    }
}

void Update()
{
    // 手動でforeachを展開

    // デフォルトのenumeratorは値型なので、大丈夫
    var enumerator = dictionary.GetEnumerator();

    try
    {
        while (enumerator.MoveNext())
        {
            var pair = enumerator.Current;

            var key = pair.Key;
            var value = pair.Value;

            // ループの中身
        }
    }
    finally
    {
        enumerator.Dispose();
    }
}

IL2CPPとAOTコンパイル

IL2CPPはUnityが作ったC#のAOTコンパイルプラットフォームです。IL2CPPが誕生する前にはMono AOTでiOSなどAOTコンパイルが必要なプラットフォームをサポートしていました。Mono AOTでは、Linqがエラーになりやすいとか、C#のeventが使えないとかいろんな問題がありましたが、IL2CPPの時代では大抵直りました。しかしAOTコンパイルの根本的な制限はずっと存在しているので、注意しないといけません。回避策はUnityマニュアルにありますので、確認しておけば問題ありません。

コードストリッピング

IL2CPPによってもたらされた新しい問題もあります。IL2CPPはILコードをC++コードに変換する技術です。最終的に取得したバイナリコードのサイズはMono AOTよりかなり大きくなりました。アプリのサイズを減らすためにはバイナリコードのストリッピングが必須になります。つまり使われていないコードをアプリから削除するのです。

使われているかどうかは静的解析で判断するので、C#のリフレクションで動的に使われているコードは間違えて削除されるかもしれません。そしてこんなエラーが出ます。

MissingMethodException: Method not found: 'Default constructor not found...ctor() of <SomeType>.'

こういうコードを残すためにIL2CPPにヒントを渡さないといけません。方法は二つあります。

[Preserve] // 削除されないようにする
class ClassOnlyUsedByReflection
{

}

コードサイズ

IL2CPPがコンパイルしたバイナリコードはMonoより大きくなるので、C#上でもコードサイズを意識する必要があります。

  • インターフェースの使用を控えます。
  • ジェネリックと値型の組み合わせに注意します。値型は型ごとにコードが生成されますが、参照型なら使いまわせます。

まとめ

.NET世界の常識がUnityの世界では通用しないこともよくあるので、C#を勉強する以外に、Unityのマニュアルやブログなどをよくチェックするのも大事です。

明日の内容は非同期処理です。担当は清水になります。

お楽しみに。