UI ToolkitのQ("...")を手書きしないため、UXMLから型付きElement参照を生成する

UI ToolkitでElementを取得するときは、普通にQ<T>()を使えば十分です。

Q.cs
var startButton = root.Q<Button>("StartButton");

ただし、Element名が文字列なので、UXML側でStartButtonをリネームしてもC#はコンパイルエラーになりません。
その画面を実際に開くまで、名前がズレていることに気づけない可能性があります。
とても嫌ですね。

Element参照をクラスにまとめたい

Windowごとに、使用するElementをまとめるクラスを作ることにしました。

TestWindowElements.cs
public sealed class TestWindowElements
{
    public Button StartButton { get; }
    public Button OptionsButton { get; }
    public Button QuitButton { get; }

    public TestWindowElements(VisualElement root)
    {
        StartButton = root.Q<Button>("StartButton");
        OptionsButton = root.Q<Button>("OptionsButton");
        QuitButton = root.Q<Button>("QuitButton");
    }
}

利用側ではElement名を意識せずに済みます。

TestWindow.cs
var elements = new TestWindowElements(root);

elements.StartButton.clicked += OnStart;
elements.OptionsButton.clicked += OnOptions;

これで文字列がWindowの実装中に散らばることはなくなりました。
しかし、このクラスを手書きするなら結局何も解決していません。
このクラスを自動生成できるようにします。

UXMLから自動生成する

というわけで、nameが付いているElementを取得し、参照クラスを生成するEditorスクリプトを作りました。
仕組みの中心部分はこんな感じです。

GetElements.cs
private static (string Name, Type Type)[] GetElements(string uxmlPath)
{
    var names = XDocument.Load(uxmlPath)
        .Descendants()
        .Select(element => element.Attribute("name")?.Value)
        .Where(name => !string.IsNullOrEmpty(name))
        .ToArray();

    var root = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(uxmlPath).Instantiate();
    return names.Select(name => (name, root.Q(name).GetType())).ToArray();
}

XDocumentから明示的に書かれたname属性を取得します。
型については文字列から推測せず、VisualTreeAssetをInstantiateした結果から取得します。
あとは取得した名前と型からC#コードを生成するだけです。

全体

全体ではこんな感じ。
UXMLを保存したら自動でトリガーがかかるようにしたり、テストでちゃんと生成されてるか確認しても良いでしょう。

WindowElementsGenerator.cs
using System;
using System.IO;
using System.Linq;
using System.Xml.Linq;
using UnityEditor;
using UnityEngine.UIElements;

public static class WindowElementsGenerator
{
    [MenuItem("Tools/UI Toolkit/Generate Window Elements")]
    public static void GenerateAll()
    {
        foreach (var uxmlPath in FindWindowUxmlPaths())
        {
            File.WriteAllText(GetGeneratedPath(uxmlPath), GenerateSource(uxmlPath));
        }
        AssetDatabase.Refresh();
    }

    public static string[] FindWindowUxmlPaths()
    {
        return AssetDatabase.FindAssets("t:VisualTreeAsset", new[] { "Assets" })
            .Select(AssetDatabase.GUIDToAssetPath)
            .Where(path => path.EndsWith("Window.uxml"))
            .ToArray();
    }

    public static string GetGeneratedPath(string uxmlPath)
    {
        return Path.ChangeExtension(uxmlPath, null) + "Elements.cs";
    }

    public static string GenerateSource(string uxmlPath)
    {
        var className = Path.GetFileNameWithoutExtension(uxmlPath) + "Elements";
        var root = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(uxmlPath).Instantiate();
        var elements = XDocument.Load(uxmlPath)
            .Descendants()
            .Select(element => element.Attribute("name")?.Value)
            .Where(name => !string.IsNullOrEmpty(name))
            .Select(name => (Name: name, Type: root.Q(name).GetType()))
            .ToArray();

        return $@"// <auto-generated />
using UnityEngine.UIElements;

public sealed class {className}
{{
{string.Join("\n", elements.Select(element => $"    public {GetTypeName(element.Type)} {element.Name} {{ get; }}"))}

    public {className}(VisualElement root)
    {{
{string.Join("\n", elements.Select(element => $"        {element.Name} = root.Q<{GetTypeName(element.Type)}>(\"{element.Name}\");"))}
    }}
}}
";
    }

    private static string GetTypeName(Type type)
    {
        return string.IsNullOrEmpty(type.Namespace) || type.Namespace == typeof(VisualElement).Namespace ? type.Name : $"global::{type.FullName.Replace('+', '.')}";
    }
}

まとめ

平和な型付ライフを!