はじめに
この記事は Unity 6 (6000.3) で動作確認しています。
Unity UI Toolkit でコードから要素を取るには、こう書きます。
var submitButton = root.Q<Button>("submit-button");
submitButton.clicked += OnSubmit;
この呼び出しは、次のいずれかが間違っていてもコンパイルが通ってしまいます。
- 要素名
"submit-button"のタイポ - 型引数
<Button>と実体の不一致 - そもそも UXML 側に該当
nameの要素がない
実行時に NullReference や型不整合で初めて気付くことになります。
同じ問題は USS のクラス名(AddToClassList("is-active"))にも共通しています。
そこで、UXML と USS をコンパイル時に解析し、これらをまとめて型安全化する Source Generator を実験的に作りました。 この記事では何ができるか、内部でどう動かしているかを紹介します。
できること
1. UXML 要素への型安全アクセス
UXML はそのまま書きます。
<ui:UXML xmlns:ui="UnityEngine.UIElements">
<ui:VisualElement name="root-container">
<ui:Label name="title-label" text="Sample" />
<ui:TextField name="name-input" label="Name" />
<ui:Button name="submit-button" text="Submit" />
<ui:Label name="result-label" />
</ui:VisualElement>
</ui:UXML>
C# 側は partial class に [UxmlView] 属性を付けるだけです。
[UxmlView("Assets/UI/SampleScreen.uxml")]
public partial class SampleScreenController : MonoBehaviour
{
[SerializeField] private UIDocument _document;
private void OnEnable()
{
InitializeUI(_document);
UI.SubmitButton.clicked += OnSubmit;
UI.TitleLabel.text = "Welcome";
}
private void OnSubmit()
=> UI.ResultLabel.text = $"Hello, {UI.NameInput.value}!";
}
UI.SubmitButton は Button、UI.NameInput は TextField として解決されます。
要素名のタイポはコンパイルエラー、型違いの操作(Label に value を代入する等)も同じくコンパイル時に止まります。
裏で生成されているのは UXML を Q<T>(...) で引くだけの薄いラッパーです。ランタイムの挙動は手書きと同じで、ユーザーが文字列を書かなくて済む点だけが違います。
生成されるコードは 2 ファイルに分かれます。 1つ目は UXML の要素をプロパティとして持つビュークラスです。
// SampleScreenUxmlView.g.cs (自動生成)
public sealed class SampleScreenUxmlView
{
public VisualElement Root { get; }
public VisualElement RootContainer { get; }
public Label TitleLabel { get; }
public TextField NameInput { get; }
public Button SubmitButton { get; }
public Label ResultLabel { get; }
public SampleScreenUxmlView(VisualElement root)
{
Root = root;
RootContainer = root.Q<VisualElement>("root-container");
TitleLabel = root.Q<Label>("title-label");
NameInput = root.Q<TextField>("name-input");
SubmitButton = root.Q<Button>("submit-button");
ResultLabel = root.Q<Label>("result-label");
}
}
2つ目はコントローラの partial 拡張で、UI プロパティと InitializeUI メソッドを追加します。
// SampleScreenController.UxmlView.g.cs (自動生成)
public partial class SampleScreenController
{
protected SampleScreenUxmlView UI { get; private set; } = null!;
protected void InitializeUI(UIDocument document)
=> UI = new SampleScreenUxmlView(document.rootVisualElement);
protected void InitializeUI(VisualElement root)
=> UI = new SampleScreenUxmlView(root);
}
2. USS クラス名の定数化
USS のクラス名やカスタムプロパティを定数化します。
.is-active {
opacity: 1;
}
.card--highlighted {
border-color: yellow;
}
:root {
--primary-color: #3498db;
}
[UssConstants] を付けた static partial class に対して、定数を生成します。
[UssConstants("Assets/UI/Styles/Common.uss")]
internal static partial class CommonStyles { }
// 使用側
element.AddToClassList(CommonStyles.IsActive);
element.AddToClassList(CommonStyles.CardHighlighted);
USS のクラス名をリネームすれば、参照側もコンパイルエラーで気付けます。
仕組み
以降は Roslyn の Incremental Source Generator(IIncrementalGenerator)を前提に書きます。
Source Generator は C# のコンパイル過程に介入して、追加のソースコードを生成する仕組みです。
Incremental 版は入力(属性付きクラス、Compilation、外部ファイルなど)の差分だけを再処理し、ビルド時間への影響を抑えます。
詳しくは 2022年(2024年)のC# Incremental Source Generator開発手法 がわかりやすいです。
UXML をどう読むか
C# 以外のファイルは、Roslyn では AdditionalFiles(コンパイラに渡される追加ファイル)として扱います。
IIncrementalGenerator からは context.AdditionalTextsProvider で参照します。
これは ISourceGenerator の context.AdditionalFiles と同じデータを、Incremental 版のインターフェースで見るものです。
世代に関係なく、ファイルの内容変更まで追跡できます。
ただ、Unity は既定では .uxml / .uss をコンパイラに渡しません。
Filename.[AnalyzerName].additionalfile という命名規則で追加ファイルを渡す仕組みはありますが、UXML をこの規則に合わせてリネームするわけにはいきません。
そこで AssetPostprocessor でプロジェクト内の .uxml / .uss を集め、Assets/csc.rsp に -additionalfile: 行として自動で書き出しています。
これで Roslyn 側の AdditionalTextsProvider に UXML が届きます。
// AssetPostprocessor 側:対象アセットを csc.rsp に -additionalfile: で同期
internal sealed class AdditionalFilesRspSync : AssetPostprocessor
{
private static void OnPostprocessAllAssets(string[] imported, ...)
{
if (HasUxmlOrUssChange(imported)) UpdateRsp(); // csc.rsp を書き換え
}
}
届いた内容は、パスと content-hash だけを持つ小さな値型(IEquatable な record 相当)に落としてから、増分パイプラインに乗せます。
追加ファイルを増分パイプラインに乗せるときの定石です。
この値型だけを入力にすれば、「.uxml のバイトが変わったときだけ後段が動く」形になります。
ただし次節のとおり、実際には型解決のため Compilation も併用するので、この利点は完全には活かせません。
ここでの狙いは、ディスク I/O を避けて最新の内容をスナップショットとして持ち込むことです。
var uxmlTexts = context.AdditionalTextsProvider
.Where(static t => t.Path.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase))
.Select(static (t, ct) => new UxmlText(t.Path, t.GetText(ct)?.ToString() ?? "")) // content-hash な値型
.Collect();
ジェネレータ内で File.ReadAllText を使い、ディスクから直接読む手もあります。
ただし、アナライザやジェネレータでのファイル I/O は RS1035 で禁止されています。同じ入力から異なる出力が生まれると決定性が壊れ、増分キャッシュや分散ビルドの前提が崩れるからです。
additionalfile 経由なら Roslyn が内容変更まで追跡してくれるので、こちらを正道にしています。
コード生成
エントリポイントは IIncrementalGenerator.Initialize です。
[UxmlView] を付けたクラスを ForAttributeWithMetadataName で拾い、前節の UXML(AdditionalTextsProvider 由来)と、型解決に使う Compilation を Combine します。
その結果を RegisterSourceOutput に渡して書き出します。
[Generator(LanguageNames.CSharp)]
public sealed class UxmlViewIncrementalGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var classes = context.SyntaxProvider
.ForAttributeWithMetadataName(
"UIToolkitSourceGenerator.UxmlViewAttribute",
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, ct) => GetUxmlViewInfo(ctx, ct))
.Where(static info => info is not null);
var uxmlTexts = context.AdditionalTextsProvider
.Where(static t => t.Path.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase))
.Select(static (t, ct) => new UxmlText(t.Path, t.GetText(ct)?.ToString() ?? ""))
.Collect();
var input = context.CompilationProvider.Combine(classes.Collect()).Combine(uxmlTexts);
context.RegisterSourceOutput(input,
static (spc, src) => Execute(src.Left.Left, src.Left.Right!, src.Right, spc));
}
}
ForAttributeWithMetadataName は Roslyn 4.3.0 で追加された API です。
属性名による一次絞り込みを Roslyn 側で行ってくれます。
なお 4.3.0 には既知の不具合があるので、自分でパッケージ参照するなら実用上は 4.3.1 以降が無難です。
ここで Compilation を後段まで持ち回っているのには理由があります。UXML の要素名から実際の型(Q<T> の T)を、Roslyn のシンボル解決で確定するためです。
UXML の中身は文字列です。ForAttributeWithMetadataName の transform で得られる意味情報だけでは、そこに書かれた要素名に対応する型までは解決できません。
その代わり、Compilation は C# を1文字編集するたびに別インスタンスになります。
これを終端まで Combine している以上、出力段の増分キャッシュは効きません。
Execute は毎コンパイル走りますし、IDE では毎編集で再実行され得ます。
冒頭で触れた「差分だけ再処理」という理想からは外れますが、これは型検証と引き換えの割り切りです。
前節の content-hash 値型にも、意味はあります。
この制約下でも .uxml の再読み込みをディスク I/O ではなくスナップショットで賄えますし、Combine を外せば本来の増分が効く形に保っておけるからです。
出力は「Q<T>(...) を呼ぶラッパークラス」と「元の partial class を拡張する側」を別ファイルに分けています。こうしておくと、ユーザーがデバッグで生成コードを覗いたときに責務が一目で分かります。
UXML/USS 変更の検知
もう1つ、Unity 特有の壁があります。
Unity は .uxml / .uss などアセットだけが変わっても、C# を再コンパイルしません。
そのままでは、UXML を編集してもジェネレータは動きません。
そこで AssetPostprocessor で対象アセットの変更を検知したら、CompilationPipeline.RequestScriptCompilation() で再コンパイルを促します。
あわせて、内容がタイムスタンプだけのダミー C# ファイル(GeneratorTrigger.cs)を書き換えます。
// GeneratorTrigger.cs — 書き換え後の中身はこれだけ
// Auto-touched when UXML/USS files change.
// Last touch: 2026-04-24T05:49:23.0773340Z
private static void ForceRecompilation()
{
string content = $"// Last touch: {DateTime.UtcNow:O}\n";
File.WriteAllText("Assets/UIToolkitGen/Runtime/GeneratorTrigger.cs", content);
AssetDatabase.ImportAsset("Assets/UIToolkitGen/Runtime/GeneratorTrigger.cs",
ImportAssetOptions.ForceUpdate);
}
ダミーを書き換えるのは、C# 側の入力を実際に変えるためです。 こうすると、Unity が「入力は変わっていない」と判断してコンパイル自体をスキップするのを防げます。
再コンパイルが走ると、Unity は毎回新しいコンパイラプロセスでジェネレータを起動します。
ここには IDE のような常駐状態(プロセスを跨いで残る増分キャッシュ)がありません。
そのため Execute は必ず実行され、AdditionalTextsProvider(=csc.rsp 経由で渡した最新の .uxml / .uss)から更新後の内容を読み直します。
IDE 上での編集についても、前節の content-hash によって「実際に変わった UXML」だけが後段を再実行します。
まとめ
最近は UI Toolkit が World Space にも対応していて XR でも使いやすくなっていたり、コーディングエージェントとの相性の良さから uGUI からの移行を進めたい気持ちがあります。ただ、UI Toolkit は Q<T>("...") や AddToClassList("...") のように文字列で要素を引く API も多く、タイポやリネーム漏れが実行時まで露見しないという辛さもあります。Source Generator で UXML/USS をコンパイル時に解析することで UI Toolkit も型安全に扱えるようになりました。
