5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Unity】ソースジェネレーターをもうちょっと簡単に使う

Last updated at Posted at 2023-03-18

Unity で Roslyn ソースジェネレーターを使おうと思うと、別プロジェクトで .dll 生成 → ソリューションファイルの編集云々、手間の方が掛かるんじゃ?? という状況ですね。

加えてソースジェネレーターに求めるものは .cs ファイルの更新をフックに別ファイルを生成して partial クラスで宣言を追加するだけ、って場合がほとんど。

それなら Unity の AssetImporter でいっか、と思い至った結果がコレ 👇

ソースジェネレーター雑感

メソッド名を直書きじゃなくリファクタリングに対応できるようにすると、どうしてもコードが長くなってしまう。元ファイルの using 丸ごと持ってくる手もあるけど、クラス内 Enum の宣言が自他ライブラリで結構あって難しい。

結局開発機で実行だから別にええやん、でトリッキーなユーティリティーメソッドを追加することに。本体を作りながらソースジェネレーターも弄る、だとリファクタリング対応はしておくと便利なんだ。

早く fullnameof(...) が欲しい。

$"{nameof(MyNamespace)}.{nameof(MyNamespace.MyClass)}.{nameof(MyNamespace.MyClass.MyProperty)}"

// クラスのフルネームは何とかなるけど、IL言語的にはOK、ユーザーレベルでは使えない名前で出てくることもある。
$"{typeof(MyNamespace.MyClass).FullName}.{nameof(MyNamespace.MyClass.MyProperty)}"

既存のライブラリ・アセット・フレームワークに何か追加したい、と思っても出来ないのももどかしい。[InternalsVisibleTo()] 的なものが欲しいね。[assembly: AllowPartialDefinitionTo("...")] みたいな。

今後ソースジェネレーターが普及すれば、極々たまーーに見かけるクラス修飾子の調整用プリプロセッサシンボルを付けるのがマナーになるか??

namespace ToaruSDK
{
    public
#if TOARU_SDK_ALLOW_SOURCE_GENERATOR
    partial
#endif
    class Honyarara
    {
    }
}

メソッド生成

ターゲットのクラスに Panic() メソッドを追加するサンプル。StringBuilder が渡されるので書き込んで true を返せば context.OutputPath に内容を書き込み、false を返せば書き込みを中止。

public class PanicMethodGenerator
{
    static string OutputFileName() => "PanicMethod.cs";  // 出力先 -> PanicMethod.<TargetClass>.<GeneratorClass>.g.cs

    static bool Emit(USGContext context, StringBuilder sb)
    {
        if (!context.TargetClass.IsClass || context.TargetClass.IsAbstract)
            return false;  // return false to tell USG doesn't write file.

        // コード生成   $@"..." が Unity 2021 で使えるの知らなかった。
        sb.Append($@"
namespace {context.TargetClass.Namespace}
{{
    internal partial class {context.TargetClass.Name}
    {{
        public void Panic() => throw new System.Exception();
    }}
}}
");
        return true;
    }
}

使い方

対象のクラスに [UnitySourceGenerator(typeof(PanicMethodGenerator)] アトリビュートを付ける。

using SatorImaging.UnitySourceGenerator;

namespace Sample
{
    // ジェネレーターの型を指定。
    [UnitySourceGenerator(typeof(PanicMethodGenerator), OverwriteIfFileExists = false)]
    internal partial class MethodGeneratorSample
    {
    }

}

生成されたコード

見たまんま出てきます。<auto-generated/> タグは渡される StringBuilder に最初から入ってるので不要なら sb.Clear()

// <auto-generated>PanicMethodGenerator</auto-generated>

namespace Sample
{
    internal partial class MethodGeneratorSample
    {
        public void Panic() => throw new System.Exception();
    }
}

汎用ジェネレーター

ビルドイベント時に生成するようなものを作る時のサンプル。ランタイム上で使えない UnityEditor.AssetDatabase を使って GUID 一覧を作りたいとか、リソースのハッシュ値を生成したいとか、そういう用途。

using System.Text;
using SatorImaging.UnitySourceGenerator;

// クラス指定が無ければ自分自身をジェネレーターとして使う。
[UnitySourceGenerator(OverwriteIfFileExists = false)]
class MinimalGenerator
{
    static string OutputFileName() => "Test.cs";  // 出力先 -> Test.<ClassName>.g.cs

    static bool Emit(USGContext context, StringBuilder sb)
    {
        // write content into passed StringBuilder.
        sb.AppendLine($"Asset Path: {context.AssetPath}");
        sb.AppendLine($"Hello World from {typeof(MinimalGenerator)}");

        // you can modify output path. initial file name is that USG updated.
        // NOTE: USG doesn't care the modified path is valid or not.
        context.OutputPath += "_MyFirstTest.txt";

        // return true to tell USG to write content into OutputPath. false to do nothing.
        return true;
    }
}

生成されたコード

// <auto-generated>MinimalGenerator</auto-generated>

Asset Path: Assets/Scripts/MinimalGenerator.cs
Hello World from Sample.MinimalGenerator

糖衣構文系メソッド

System.Reflection 系のユーティリティーと StringBuilder の拡張メソッド群。

usg は特殊で、クラス名やら何やらのリファクタリングに強いジェネレーターにすると読みづらくなってしまうのを緩和するためのモノ。

{typeof(MyClass).FullName}.{nameof(MyClass.Property)} どんどん長くなるだけなら良いけどクラス内クラスとか構造体の名前が + 付きの C# 開発時には使えない、不正な状態で出てくる。その他にもジェネリッククラスへの対応(してないけど)とかなんとか、結局何かが必要になる。それならばと可能な限り短く書けるようにした。

インデント系はトリッキーだけど開発機での実行なのでまあ良し。

  • StringBuilder Extentions
    • IndentLine / IndentAppend
    • IndentLevel / IndentChar / IndentSize
    • IndentBegin / IndentEnd
  • USGFullNameOf
    • usg
  • USGReflection
    • GetAllPublicInstanceFieldAndProperty
    • TryGetFieldOrPropertyType
    • GetEnumNamesAndValuesAsDictionary
    • GetEnumNamesAndValuesAsTuple
using static SatorImaging.UnitySourceGenerator.USGFullNameOf;  // usg<T>() to work

// 出力 -> Full.Namespace.To.MyClass.MyStruct.MyField
usg<MyClass>("MyStruct.MyField");

// リファクタリング完全対応版  fullnameof(...) が待たれる。。。
usg<MyClass>(nameof(MyClass.MyStruct), nameof(MyClass.MyStruct.MyField));

// インデント
sb.IndentChar(' ');  // デフォルト
sb.IndentSize(4);    // デフォルト
sb.IndentLevel(3);   // テンプレート初期設定
sb.IndentBegin();    // 字下げを一段上げる
{
    // int -> Enum のキャスト。
    // Enum はクラス内で作られがちで typeof(...).FullName が使えない。
    sb.IndentLine($"MyObject.EnumValue = ({usg<MyEnum>()})intValue");
    // --- or ---
    string CAST_MY_ENUM = "(" + usg(typeVariableCanBePassed) + ")";
    sb.IndentLine($"MyObject.EnumValue = {CAST_MY_ENUM}intValue");
}
sb.IndentEnd();  // 字下げを戻す

書き出し先フォルダー

書き出し先は以下の通り。生成フォルダーとターゲット・ジェネレータークラス名がファイル名に付与される。

  • Assets/Scripts/USG.g/Test.MinimalGenerator.g.cs
  • Assets/Scripts/USG.g/PanicMethod.MethodGeneratorSample.PanicMethodGenerator.g.cs

上記の例だとファイル名を編集しているので Test.MinimalGenerator.g.cs_MyFirstTest.txt が出てくる。

ビルドイベント向けユーティリティー

IPostprocessBuildWithReport も実装しようかと思ったものの、ビルドイベントに勝手に処理を追加するのは訳わからん何故か動かん!の原因だし、BuildReport として渡される全てのファイル名を走査するのは効率も良くない。ということで。

// search by class name if you don't know where it is.
var assetPath = USGUtility.GetAssetPathByName(nameof(MinimalGenerator));

// perform code generation.
USGEngine.IgnoreOverwriteSettingByAttribute = true;  // force overwrite
USGEngine.ProcessFile(assetPath);

インターフェイス

理想はアトリビュートとインターフェイスによるフィルタリングですが、Unity 2021 は C# 9.0 で abstract static を含んだインターフェイスが使えません。

しょうがないのでメソッドのシグネチャを確認して存在しなければエラーをコンソールに出します。

命名規則

  • ジェネレータークラスの名前はファイル名と一致
  • ジェネレータクラスの名前はプロジェクト内で一意
  • クラスが以下で始まる名前のアセンブリで宣言されている場合は対象としない
    • Unity (末尾ドット無し)
    • System.
    • Mono.

インストール

エディター拡張

手動でソースコード生成イベントの発火も可能です。「ジェネレーターのスクリプトファイル」か「生成されたファイル」を選択して、Project ウインドウで ReimportUnity Source Generator 以下のメニューを実行。

ジェネレーターとして参照されているファイルを Reimport した場合は、関連するクラスすべてが再生成されます。Force Generate... はクラスアトリビュートの設定に関わらず強制的に上書き生成します。

TODO

ノンアロケーション中毒者向けに UseCustomWriter をアトリビュートに追加する。

ただ、Unity 2021 でやれることは全部やってるはず。sb.ToString().AsSpan() は Unity が sb.GetChunks() に対応したバージョンまで上げてくれないとどうしようもない(多分)

Unity 2021 より古いバージョンは File.WriteAllText(...)。2021 環境は便利でもう戻れない。2021 LTS は .NET Standard 2.0 ベースだけど 2.1 の一部機能だけバックポートした(らしい)のは Unity さんマジ大英断。

using (var fs = new FileStream(context.OutputPath, FileMode.Create, FileAccess.Write))
{
    Span<byte> buffer = stackalloc byte[BUFFER_LENGTH];  // 61_440
    var span = sb.ToString().AsSpan();
    for (int i = 0; i < span.Length; i += BUFFER_MAX_CHAR_LENGTH)
    {
        var len = BUFFER_MAX_CHAR_LENGTH;
        if (len + i > span.Length) len = span.Length - i;

        int written = info.Attribute.OutputFileEncoding.GetBytes(span.Slice(i, len), buffer);
        fs.Write(buffer.Slice(0, written));
    }
    fs.Flush();
}

"..."u8 UTF-8文字列リテラルと StringBuilderFileStream で無用の長物になるから要らない?? そもそもソースジェネレーターはユーザーのデバイスじゃなくて開発機で実行されるから意味ないかなーー。

おわりに

Roslyn のソースジェネレーターは○○という製品向けのソースジェネレーターを専門で作ってます。という事でもない限り使うのは厳しい印象。手間がちょっとね。

チュートリアルの通りにやればまあ動くんだけども、やってることは文字列リテラル延々と書くだけだし、必要なものと言えば名前空間とクラス名ぐらいのもんでしょう。あとは型から System.Reflection で手繰っていけばどうとでも。

Roslyn ソースジェネレーターを弄りつつ別の場所も弄る、がちょっと辛い。Unity で使えるバージョンがちょっと古いのもつらい。

Visual Studio で保存したときの更新イベントのフックがいまいちな時もあるけど、まあ満足。

--

以上です。お疲れ様でした。

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?