3
0

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.

CYBIRDAdvent Calendar 2023

Day 17

【Unity】自動生成したコードを自動的にコンパイルして実行する

Last updated at Posted at 2023-12-16

CYBIRD Advent Calendar 2023 17日目担当の@cy-tatsuya-sakaiです。
16日目は@cy_hinano_imaiさんの「VRoid Studioで3Dモデルを作成して、あわよくばバ美肉する」でした。
バ美肉したかったねぇ!?

はじめに

皆様、Unityでソースコードの自動生成はしますでしょうか?
自動生成と言っても単にコードのテキストファイルを出力する原始的なやつです。
LIGHT11 【Unity】【エディタ拡張】スクリプトからスクリプトファイル(.cs)を生成する

では、生成したコードをそのまま自動実行したいと思ったことはありますでしょうか?
生成したコードを実行するにはコンパイルが必要なので、

  • コード生成する
  • コード生成後、スクリプトからコンパイルを実行する
    • コンパイル後、後続の処理を実行する
  • 後続の処理で、生成したコードを呼び出す

といった手順が必要なります。

…なぜそんなことを?コード生成とコード実行を分けて行ったとしてもさほど手間は変わらないのでは?と思ったあなた、僕もそう思います…

でも調べたときはそう思わなかった!
この記事では、Unityエディタから何らかコード生成を伴う操作をする際の、マウスのクリック回数を2回から1回に減らせるかもしれない、そんな情報をお届けします。

先にまとめ

スクリプトからコンパイル + 後続の処理を実行する

  • A. TestRunnerのRecompileScriptsを使う
  • B. CompilationPipeline.RequestScriptCompilation()でコンパイル、[DidReloadScripts]属性でフックする。状態はScriptableSingletonで制御する

自動生成したコードを呼び出す

  • A. partialメソッドを使う
  • B. リフレクションを使う

きっかけ

まず先人の素晴らしい記事をどうぞ。
Techbot!PrefabUtilityとEditorGUILayoutでViewクラス自動生成&自動アタッチする

uGUI要素を自動列挙 & 自動アタッチ。最初見たときは感心しながら写経してました。

上記の記事では、コード生成以外は手動で行うフローになっています。
全く問題無いです。むしろ意図せずプレハブに変更掛からないので良いまであります。

が、ある日突然『1クリックで全UIプレハブのコード生成、自動アタッチまで出来たら超気持ち良いのでは』という欲求が溢れ出し…?

成果物

https://github.com/cy-tatsuya-sakai/ViewBinding
こんな感じになりました。
結構時間掛かってますね…!1クリックだからと言って、作業が早くなるわけでは無さそうです。。

画面収録-2023-12-15-17.26.38_out.gif

上記の成果物では、

  • CompilationPipeline.RequestScriptCompilation()でコンパイル、[DidReloadScripts]属性でフック
  • partialメソッドで生成したコード実行

のパターンで実装しました。
もしご興味ありましたらリンク先のUnityプロジェクトご確認ください。

実装

スクリプトからコンパイル + 後続の処理を実行する

今回の調査では

  1. TestRunnerを使う
  2. [DidReloadScripts]属性でコンパイルをフックする

の2パターン見つかりました。

① TestRunner + RecompileScripts

TestRunnerのテストからRecompileScriptsクラスでコンパイルを実行。
テストの実行順序を[Order]属性で指定することで、コンパイルを実行した後のテストから生成したコードを実行できます。

using System.Collections;
using System.IO;
using System.Text;
using NUnit.Framework;
using UnityEditor;
using UnityEngine.TestTools;

/// <summary>
/// TestRunnerを使用してコード生成→コンパイル→コード実行
/// </summary>
public static partial class TestCompile_TestRunner
{
    private const string FILE_PATH = "Assets/Tests/__Test.cs";

    // 出力するコードの雛形
    private static readonly string CODE_TEMPLATE = $@"
public static partial class {nameof(TestCompile_TestRunner)}
{{
    static partial void TestLog()
    {{
        UnityEngine.Debug.Log(""#LOG_MESSAGE#"");
    }}
}}";

    static partial void TestLog();  // コード生成で実装するメソッド

    [UnityTest, Order(1)]
    [Timeout(3600000)]
    public static IEnumerator Test_001()
    {
        // 何らかコード生成
        var code = CODE_TEMPLATE.Replace("#LOG_MESSAGE#", "コード生成その1");
        File.WriteAllText(FILE_PATH, code, Encoding.UTF8);

        AssetDatabase.Refresh();
        yield return new RecompileScripts(false, true);
    }

    [UnityTest, Order(2)]
    [Timeout(3600000)]
    public static IEnumerator Test_002()
    {
        // 生成したコード実行
        TestLog();

        // 更にコード生成
        var code = CODE_TEMPLATE.Replace("#LOG_MESSAGE#", "コード生成その2");
        File.WriteAllText(FILE_PATH, code, Encoding.UTF8);

        AssetDatabase.Refresh();
        yield return new RecompileScripts(false, true);
    }

    [Test, Order(3)]
    [Timeout(3600000)]
    public static void Test_003()
    {
        // 更に生成したコード実行
        TestLog();
    }
}

以下でコンパイル待ちをしてます。

AssetDatabase.Refresh();
yield return new RecompileScripts(false, true);

RecompileScriptsの引数はどちらもテストの失敗条件で、順に

  • コンパイルが発生しなかった場合にテスト失敗にするか
  • コンパイルエラー発生時にテスト失敗にするか

です。コード生成が行われないケースがある場合は第1引数をfalseにしておくと良さそうです。

[Order]属性でテストの実行順を指定し、コンパイル後に実行されるテストを制御しています。
[Timeout]属性は、デフォルトでテストのタイムアウトが30秒らしいので適当に長くしてみました。

生成したコードの実行は後述のpartialメソッドです。

実行結果は以下。生成したコードのデバッグログが出力されます。
テストに成功マークが付くのも何か嬉しい。
生成したコードの消し忘れをFiles generated by test without cleanup.とワーニング表示してくれるのも中々に親切。
スクリーンショット 2023-12-15 13.01.17.png

また、スクリプトからテストを実行することも出来るようです。

    [MenuItem("Test/TestCompile_TestRunner")]
    public static void BeginTest()
    {
        var filter = new Filter
        {
            testMode       = TestMode.EditMode,
            testNames      = null,
            groupNames     = new string[] { "TestCompile_TestRunner" },
            categoryNames  = null,
            assemblyNames  = null,
            targetPlatform = null,
        };

        var testRunnerApi = ScriptableObject.CreateInstance<TestRunnerApi>();
        testRunnerApi.Execute(new ExecutionSettings(filter));
    }

groupNamesに、クラス名やnamespaceを指定すると一連のテストを実行できそうでした。

② CompilationPipeline.RequestScriptCompilation() + [DidReloadScripts]属性 + ScriptableSingleton

[DidReloadScripts]属性で、コンパイル後に呼ばれるメソッドを定義。
CompilationPipeline.RequestScriptCompilation()メソッドでコンパイルを実行して、定義したメソッドでフックします。

状態の制御にstatic変数を使いたくなりますが、どうやらstatic変数はコンパイル時にクリアされてしまうようなので、コンパイルを跨いだ状態の保持にはScriptableSingletonを使用します。

using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.Compilation;

/// <summary>
/// [DidReloadScripts]属性メソッドでコンパイルをフックしてコード生成→コンパイル→コード実行
/// </summary>
public static partial class TestCompile_DidReloadScripts
{
    private const string FILE_PATH = "Assets/Tests002/__Test.cs";

    // 出力するコードの雛形
    private static readonly string CODE_TEMPLATE = $@"
public static partial class {nameof(TestCompile_DidReloadScripts)}
{{
    static partial void TestLog()
    {{
        UnityEngine.Debug.Log(""#LOG_MESSAGE#"");
    }}
}}";

    static partial void TestLog();  // コード生成で実装するメソッド

    /// <summary>
    /// 状態
    /// </summary>
    private class State : ScriptableSingleton<State>
    {
        public int state = 0;
    }

    /// <summary>
    /// コード生成開始
    /// </summary>
    [MenuItem("Test/TestCompile_DidReloadScripts")]
    public static void BeginTest()
    {
        State.instance.state = 1;
        OnDidReloadScripts();
    }

    /// <summary>
    /// スクリプトのリロード時に呼ばれる。コード生成と実行の順番を制御する
    /// </summary>
    [DidReloadScripts]
    public static void OnDidReloadScripts()
    {
        var inst = State.instance;
        if(inst.state == 0) { return; }

        switch(inst.state)
        {
            case 1:
            {
                // 何らかコード生成
                var code = CODE_TEMPLATE.Replace("#LOG_MESSAGE#", "コード生成その1");
                File.WriteAllText(FILE_PATH, code, Encoding.UTF8);

                AssetDatabase.Refresh();
                CompilationPipeline.RequestScriptCompilation();

                inst.state++;
            }
            break;
            case 2:
            {
                // 生成したコード実行
                TestLog();

                // 何らかコード生成
                var code = CODE_TEMPLATE.Replace("#LOG_MESSAGE#", "コード生成その2");
                File.WriteAllText(FILE_PATH, code, Encoding.UTF8);

                AssetDatabase.Refresh();
                CompilationPipeline.RequestScriptCompilation();

                inst.state++;
            }
            break;
            case 3:
            {
                // 生成したコード実行
                TestLog();

                inst.state = 0;
            }
            break;
        }
    }
}

[DidReloadScripts]属性をメソッドに付けると、スクリプトのリロード時に毎回呼ばれるようになります。
毎回呼ばれるので、ここは適切に制御する必要がありそうです。

[DidReloadScripts]
public static void OnDidReloadScripts()
{
    var inst = State.instance;
    if(inst.state == 0) { return; }

コンパイルは以下で行えました。

AssetDatabase.Refresh();
CompilationPipeline.RequestScriptCompilation();

[DidReloadScripts]属性メソッドにAssetDatabase.Refresh()を書くと、VSCodeだとAvoid Asset operations in LoadAttribute method.(UNT0031)と指摘されます。
解消方法分かりませんでした、、コンパイルを通すのにRefreshは必要な気がするので今回はそっとしておきます。。

[DidReloadScripts]属性メソッド内の分岐をstatic変数でやりたくなりますが、static変数はコンパイル時に値をクリアされてしまうので使用できません。
ScriptableSingletonを使用すると、コンパイルを跨いで値を保持できるようでした。

クラスを定義して

private class State : ScriptableSingleton<State>
{
    public int state = 0;
}

クラス名.instanceで値にアクセスできます。

State.instance.state = 1;

メニューから一連の処理を実行した結果は以下。
コード生成と実行がstateの値によって順に実行されます。

/// <summary>
/// コード生成開始
/// </summary>
[MenuItem("Test/TestCompile_DidReloadScripts")]
public static void BeginTest()
{
    State.instance.state = 1;
    OnDidReloadScripts();
}

スクリーンショット 2023-12-15 14.51.06.png

既存のスクリプトから、自動生成したコードを呼び出す

こちらも今回2パターン見つかりました。

  • partialメソッド
  • リフレクション

partialメソッド

partialクラスの中にpartialを付けたメソッドの宣言を書くと、他のpartialクラスでそのメソッドを実装できます。

public static partial class TestCompile_DidReloadScripts    // ←partialクラス
{
    private const string FILE_PATH = "Assets/Tests002/__Test.cs";

    // 出力するコードの雛形
    private static readonly string CODE_TEMPLATE = $@"
public static partial class {nameof(TestCompile_DidReloadScripts)}
{{
    static partial void TestLog()   // ←コード生成でpartialメソッドを実装している
    {{
        UnityEngine.Debug.Log(""#LOG_MESSAGE#"");
    }}
}}";

    static partial void TestLog();  // ←partialメソッドの宣言

コード生成を行うスクリプト側にはpartialメソッドTestLog()の宣言だけ書いておいて、
コード生成時にその中身を実装しています。

そしてコード生成を行うスクリプト側で、後々コード生成されるであろうメソッドの呼び出しをおもむろに書きます。

// 生成したコード実行
TestLog();

これでコンパイル通ります。

partialメソッドは、

  • 実装が無い場合はその呼び出し自体が無くなる
  • 実装が有る場合は呼び出しが実行される

という挙動のようです。
自分も詳しくないです。記事の最後に参考リンクを載せておくので詳しくはそちらをご参照ください。

リフレクション

今回のようなstaticメソッドをコード生成する場合、Type.GetMethod()で文字列指定で呼び出すこともできます。
partialメソッドはGetMethodで取得できなかったので、生成するコードを通常のメソッドに変更した上で、

public static partial class TestCompile_DidReloadScripts
{
    private const string FILE_PATH = "Assets/Tests002/__Test.cs";

    // 出力するコードの雛形
    private static readonly string CODE_TEMPLATE = $@"
public static partial class {nameof(TestCompile_DidReloadScripts)}
{{
    static public void TestLog()
    {{
        UnityEngine.Debug.Log(""#LOG_MESSAGE#"");
    }}
}}";

以下のように呼び出します。

// 生成したコード実行
var method = typeof(TestCompile_DidReloadScripts).GetMethod("TestLog");
method?.Invoke(null, null);

メソッドが見つからない場合はNullになります。

その他

逐語的文字列と補間文字列の合体

生成するコード文字列をずっと以下のように書いていましたが、よく見ると結構気持ち悪い。

    // 出力するコードの雛形
    private static readonly string CODE_TEMPLATE = $@"
public static partial class {nameof(TestCompile_DidReloadScripts)}
{{
    static partial void TestLog()
    {{
        UnityEngine.Debug.Log(""#LOG_MESSAGE#"");
    }}
}}";

これは、改行等もそのまま文字列出力される@逐語的文字列と、文字列に{}を使って文字列を埋め込める$補間文字列を合体して使っています。
文字列の頭に$@を両方付けるだけです。

改行は複数行のコードを見やすくするため。補間はnameof(TestCompile_DidReloadScripts)でクラス名を埋め込むために使用してみました。

クラス名をnameofで埋め込むと、突然クラス名を変更したくなったときに一括で変換できたり、変換漏れがあった際にコンパイルエラーになってくれるので良いかなと思ってそうしてみました。

まとめ・所感

今回、コード生成と生成したコードを即座に実行するために

  1. スクリプトからコンパイルを実行 → コンパイル後に後続の処理を実行
  2. 後続の処理から生成したコードを実行

する手段を調査して、

コンパイル実行と後続処理を実行する方法として

  • A. TestRunnerを使う + RecompileScriptsでコンパイル
  • B. CompilationPipeline.RequestScriptCompilation()でコンパイル、[DidReloadScripts]でスクリプトのリロードをフックする。状態制御にScriptableSingletonを使う

の2つ。

後続処理から生成したコードを実行する方法として

  • A. partialメソッドを使う
  • B. リフレクションを使う

の2つが見つかりました。

…活用方法が全く浮かびません!
まあでも[DidReloadScripts]とか結構やりたい放題できそうな印象だし、無理して使う必要は無いかも。
また新たな欲求が生まれる日まで、この知識は眠らせておきます…!

参考

LIGHT11 【Unity】【エディタ拡張】スクリプトからスクリプトファイル(.cs)を生成する
Techbot!PrefabUtilityとEditorGUILayoutでViewクラス自動生成&自動アタッチする
LIGHT11 【Unity】Unity Test Runner(Test Framework)の小技色々まとめ
LIGHT11 【Unity】エディタ拡張で「Manager」的なものに使えるScriptableSingleton
C# 文字列リテラル(補間文字列・逐語的文字列)まとめ
partial メソッドの拡張 (C# 9.0 候補機能)
DidReloadScriptsについて

おわりに

いかがでしたでしょうか?何の役に立つのか分からない、ちょっとマニアックな内容でしたね…!
…調べてる最中は個人的に楽しめたので良しとします!

CYBIRD Advent Calendar 2023 18日目は@cy-yuka-WPさんの「Google Apps Scriptを利用したスケジュール管理」です!お楽しみに!

3
0
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
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?