Unity を検索

メッセージングを改善して API 利用要件を明確にする

2022年9月12日 カテゴリ: テクノロジー | 9 分 で読めます
Unity stock image of developer at work with light-up headphones
Unity stock image of developer at work with light-up headphones

API を呼び出せる、あるいは呼び出すべき条件が不明確な場合があります。このような情報を容易に入手できない場合、しばしば意図しない副作用のあるコードを書くことになります。

Unity エディターのユースケースが増えれば増えるほど、API の使われ方も多様になっていきます。初期化されていないシステムを API が直接利用しようとすると、不安定になる可能性があります。シーンを開いたまま削除したり、インポートの途中でアセットをロードしようとすると、結果に一貫性がなくなり、最悪の場合クラッシュすることもあります。

アセットをエディター内で操作する際のメッセージングを改善する一環として、私たちは特定の API を特定の条件下で呼び出すことを制限することを始めました。この取り組みにより、修正困難なバグ、一貫性のない結果、整理されていないワークフローが習慣化するのを防ぐことができます。

API 保護

新しい内部属性を作り、Unity エディターに同梱されている API に追加しました。この属性は、エディターのコードをコンパイルする際に、C# と C++ の間のネイティブバインディングレイヤーにコードを自動生成します。

[PreventExecutionInState]

具体的には、次のようなことを実現するために、このソリューションを設計しました。

  • 修飾された関数が呼び出されない状態を属性に指定する。
  • 保護に重大度の指定(例外またはエラーメッセージなど)を与える。
  • この問題を解決する方法について、詳しい説明を提供する。

例えば、AssetDatabase.bindings.cs 内の AssetDatabase.CreateAsset は以下のように修飾されています。

[PreventExecutionInState
(
AssetDatabasePreventExecution.kGatheringDependencies
FromSourceFile, PreventExecutionSeverity.PreventExecution_ManagedException, 
"Assets may not be created during gathering of import dependencies"
)]

コールバック GatherDependenciesFromSourceFile の間に AssetDatabase.CreateAsset を呼び出してアセットを作成することはできません。これは、動的な依存関係のみが AssetDatabase に返されることになっていて、インポートキューのどこにアセットをインポートすべきかを決定できるためです。この属性を配置しておくと、Unity エディターに同梱されているコード生成を駆動するために使用できます。

Unity エディターのソースコード(C# と C++ の両方)をコンパイルすると、すべての C# の関数を定義する C++ のコードレイヤーが自動生成されます。これらの関数は .bindings.cs ファイル内部で「extern」としてマークされている一方で、生成された C++ ファイルは自動生成されたファイルであることが明確にマークされています。

この制御を API に追加できるようにするため、新しい C# 属性を考慮し、C++ 側で実行可能なものに変換できるように Unity のコード自動生成ツールは拡張されました。これは、API を使うべきでないときに使ってしまった場合に備えて、API の使い方についてより良い情報を提供するためのレイヤーとして機能します。

自動生成されたコード

C# コード(AssetDatabase.bindings.cs)

[NativeThrows]
[PreventExecutionInState(
  AssetDatabasePreventExecution.kGatheringDependenciesFromSourceFile,
  PreventExecutionSeverity.PreventExecution_ManagedException,
  "Assets may not be created during gathering of import dependencies"
  )]
extern public static void CreateAsset([NotNull] Object asset, string path);

自動生成されたコードはスタンドアロン C# ツールを介して書かれており、PreventExecutionInStateAttribute に対して、以下のように実行されます。

  • UnityEditor.dll アセンブリのメソッドが PreventExecutionInStateAttribute で修飾されている場合、関連情報を抽出します。
    • 現在の属性が存在するクラスの名前
      • 例:AssetDatabase
    • 属性で修飾されたメソッドの名前。
      • 例:CreateAsset
    • 定数が定義されている enum クラスの名前
      • 例:AssetDatabasePreventExecution
    • enum の名前と値
      • 例:kGatheringDependenciesFromSourceFile
        • リフレクションを使用して名前を抽出します。
private string GetNameFromCustomAttribute(IBindingsPreventExecution curCheck)
{
    var name = curCheck.someObject.GetType().GetProperty("Type").GetValue(curCheck.someObject).ToString();

    //일반적으로 이름은 UnityEditor.AssetDatabasePreventExecution과 유사합니다.
    //그러나 클래스 내부에 UnityEditor.MyClass/MyEnum과 같은 형식의
    //열거형이 있을 수 있으며 여기에서 'MyEnum' 부분을 추출해야 합니다.
    var splitName = name.Split('.');
    var lastEntry = splitName[splitName.Length - 1];
    int start = lastEntry.LastIndexOf("/") + 1;
    var fixedName = lastEntry.Substring(start, lastEntry.Length - start);

    return fixedName;
}
  • kGatheringDependenciesFromSourceFile = 1 << 3
    • また、リフレクションを使用して値を抽出します。
//값을 얻는 방법은 더 간단합니다. 값은 항상 정수일 것이며 더 구체적으로는 어떤 조건이 실패했는지
//보고할 수 있어야 하므로 값은 2의 제곱일 것입니다.
//따라서 조건별로 하나의 비트가 있어야 합니다.
var flag = curCheck.someObject.GetType().GetProperty("Value").GetValue(curCheck.someObject);
int flagValue = int.Parse(flag.ToString());
  • PreventExecutionSeverity パラメータの値。
    • PreventExecution_ManagedException
      • 深刻度を表すために作成した C++ 列挙体を追加しています。PreventExecutionSeverity です。
  • 保護対象のシステムが特定の状態にある場合に、この API を呼び出してはならない理由をメッセージで明示します。
    • たとえば、「Assets may not be created during the gathering of import dependencies.」(インポートの依存関係の情報を集めている間にアセットを作成することはできません)といったものです。

さらに、関数の上に PreventExecutionInStateAttribute のインスタンスが複数ある場合、これらの API を呼び出す際にパフォーマンスへの影響をできるだけ少なくするために、それらの条件は単一のチェックに統合されます。

自動生成されたコードを読みやすくする

すべての情報を入手したら、そこから等価な C++ コードを生成する必要があります。自動生成された C++ コードの各部分は、C# の元の属性定義に遡ることができます(つまり、メソッド CreateAsset は、以下の自動生成 C++ コードの文字列「CreateAsset」 にマッピングされています)。

最後に、すべての情報が抽出されると、変数、名前空間、および PreventExecutionInStateAttribute からの命名を使用するクラスとともに、自動生成コードが作成されます。

自動生成された C++ コード

if(g_AssetDatabasePreventExecutionBitField & AssetDatabasePreventExecutionChecks::kGatheringDependenciesFromSourceFile)
    AssetDatabasePreventExecution::ReportExecutionPrevention(
            "CreateAsset",
            AssetDatabasePreventExecutionChecks::kGatheringDependenciesFromSourceFile,
            PreventExecutionSeverity::PreventExecution_ManagedException,
            "Assets may not be created during gathering of import dependencies",
            &exception
            );

その後、次のようになります。

  • アセットデータベース(g_AssetDatabasePreventExecutionBitField)の状態は、特定の条件(この場合、コールバックGatherDependenciesFromSourceFile を実行しているとき)に対してクエリを実行します。
    • ビットフィールドは、できるだけオーバーヘッドを少なくすると同時に、エンジンコードに降りていく前に、呼び出しを防止すべきかどうかを検出できるようにするために選ばれました。
  • この条件が真であれば、この呼び出しを許可しないことを報告し、その理由を出力します。
  • その後、AssetDatabase::CreateAsset コードの C++ 対応部分を呼び出すことができるようになる前に、Mono を介してマネージド例外を投げるようにします。これにより、特定のアセットの依存関係を Unity に伝えようとしている間にアセットを作成するという望ましくない副作用を防ぐことができます。
  • この条件が偽であれば、通常の条件下にあるときと同様に、ネイティブの AssetDatabase::CreateAsset コードを実行します。

この結果、API に対応する C++ コードは、想定外の時に実行されることはなく、状況を解決するのに役立つであろう妥当なエラーを提供するようになります。

また、g_AssetDatabasePreventExecutionBitField の名前に AssetDatabasePreventExecution が含まれていることに気づかれたかもしれません。これは、Unity 内の他のシステムでも同様に API 保護を定義し、望ましくない API 使用を前もって防ぐことができるようにするためのものです。

各要素を組み合わせる

この API の挙動に対する変更を確実にキャッチするために、私たちは AssetDatabase Test Suite に多数のテストを追加しています。GatherDependenciesFromSourceFile の呼び出しの中から CreateAsset が呼び出されるたびに、マネージド例外がスローされることが予想されます。

Capture of AssetDatabase Test Suite view when showing a call to GatherDependenciesFromSourceFile.

その結果、このシナリオ(のみならず、他のさまざまなシナリオ)に対するテストカバレッジが確保され、ひいては、仕組みがあるべき姿で動くことへの信頼が高まったのです。

変更を取り込む

API 保護の変更を取り込むことは、挙動の変更とみなすことができます。そのため、強いメッセージングを行うように配慮しています。しかし、現在直面している最大の課題は、さまざまなアセットストアパッケージ、ユーザープロジェクト、ライブラリ、API サンプルなどが API に依存しているため、これが私たちのコミュニティにどのような影響を与えるかということです。まず、変更を行う際に内部でどれだけのテストが失敗しているかを調べ、その情報を使って API 保護の変更の影響の大きさを測ります(実際には、あまりにも多くのシステムが現在の動作に依存している場合 、変更を行わないことを検討する可能性もあることに注意してください)。

幸い、GatherDependenciesFromSourceFile は比較的新しいコールバックで、ScriptedImporter でこれを使用しているものは多くありません。しかし、OnPostProcessAllAssets のようなものが AssetDatabase.FindAssets の呼び出しに制限を受けるとしたら(実際にこれを行う予定はないのでご安心ください)、OnPostProcessAllAssets 内から AssetDatabase.FindAssets を呼ぶことはよくあるので、内部のテストやユーザーから多くの反発を受けることになるでしょう。

この記事について議論したい、または記事を読んだ後にアイデアを共有したい場合は、私たちの Asset Database フォーラムのディスカッションスレッドをご覧ください。Twitter で、私のアカウント @jav_dev をぜひフォローしてください。Tech from the Trenches シリーズにはこれから Unity の開発者による技術ブログが続々と追加されるので、こちらもチェックしてください。

2022年9月12日 カテゴリ: テクノロジー | 9 分 で読めます
関連する投稿