🔗

UI Toolkit のバインディングを試す - その2 Notyfyでサクサク編

2024/12/01に公開

はじめに

こんにちは。

前回の記事では Unity 6 の UIToolkit での簡単なデータバインディングの方法を紹介しました。
あんまり簡単すぎて感動していましたが、そのままでは毎フレーム不要な処理が走って負荷的に心配がある実装になっていました。
今回はそのあたりの改善方法を紹介する記事になります。

※ この記事は Unity 6 (6000.0.22f1) の内容を記載しています。
※ 現在 Unity 6 は Preview 版なので今後変更が入る可能性があります。

前回の記事はこちらです。
https://zenn.dev/piteki/articles/piteki-unity-uitoolkit-binding

データクラスを修正する

前回の記事で作成したデータクラスを見てみます。

BindTestData.cs
public class BindTestData
{
	public int IntValue = 10;
	public string StringValue = "初期値";
}

簡単に public な field で値を定義したクラスを作って、これを Label とバインディングしていました。
ただこれだと UI からこの値を参照する処理が毎フレーム走ってしまいます。

INotifyBindablePropertyChanged で変更を通知できる

これを回避するには値の変更を通知するためにデータクラスに INotifyBindablePropertyChanged というインターフェイスを実装します。
これは .Net にある INotifyPropertyChanged というインターフェイスの Unity版・・といったところなんでしょうか。
データクラスにこのインターフェイスを実装する事で UI から毎フレームポーリングされる事がなくなり、値が変更した事をこちらから通知する事ができるようになります。

INotifyBindablePropertyChanged の詳しい説明は公式マニュアルに記載されています。
https://docs.unity3d.com/6000.1/Documentation/Manual/UIE-runtime-binding-define-data-source.html

もう一つの通知方法: IDataSourceViewHashProvider

上記のマニュアルにも記載がありますが、 UI に通知する手段としてもう一つ IDataSourceViewHashProvider というものがあるようです。
こちらはプロパティ毎の更新を通知するのではなく、データクラス全体として更新が必要かどうかを表現するもののようです。
マニュアルには IDataSourceViewHashProvider と INotifyBindablePropertyChanged との併用がオススメと書いてありますが、実装方法も割と簡単でマニュアルのサンプルで十分わかりやすいと思いますのでここでは取り上げません。

フィールドをプロパティに変更する

INotifyBindablePropertyChanged を実装するにしても、フィールドのままでは値を変更したタイミングを掴むのは困難です。
まずはフィールドをプロパティに変更してみましょう。

BindTestData.cs
public class BindTestData
{
	// NG。これだとバインディングできない
	public int IntValue { get; set; } = 10;
	public string StringValue { get; set; } = "初期値";
}

単純に自動プロパティに変更しました。でもこの状態で実行するとバインディングができていない状態になっています。

実はバインディングに使われるメンバには プロパティバッグ というものが必要で、これが データと UIElement との橋渡しをしてくれる事になっています。

プロパティバッグは SerializeField 可能なメンバがバインドされる際には自動で作成されるようになっているようで、前回のクラスにあった public なフィールドや [SerializeField] をつけたメンバでは特になにもせずにバインディングが出来ていたというわけです。

では Serialize しないメンバには使えないのかというとそうではなく、プロパティバッグを作成するための [CreateProperty] 属性が用意されています。
この [CreateProperty] をプロパティに定義すると自動プロパティであってもきちんとバインディングが機能するようになります。

BindTestData.cs
public class BindTestData
{
	// OK。プロパティバッグが生成されてバインディング可能
	[CreateProperty] public int IntValue { get; set; } = 10;
	[CreateProperty] public string StringValue { get; set; } = "初期値";
}

実行してみるときちんとバインディングが機能している事が確認できます。
フィールドをプロパティに変えても uxml のバインド設定には変更をしなくてもそのまま機能します。楽ですね。

これでフィールドをプロパティに変更出来ましたが UI から getter が毎フレーム呼ばれる状況は変わりません。

各 property の setter で propertyChanged を呼ぶ

ではいよいよ INotifyBindablePropertyChanged で更新を通知する実装をしてみましょう。
公式マニュアルに記載されているサンプルコードを参考に INotifyBindablePropertyChanged を実装してみるとこんな感じになります。

BindTestData.cs
public class BindTestData : INotifyBindablePropertyChanged
{
	// 通知イベント
	public event EventHandler<BindablePropertyChangedEventArgs> propertyChanged;

	// propertyChanged イベントを発行するメソッド
	private void Notify( [CallerMemberName] string propertyName = null )
	{
		propertyChanged?.Invoke( this, new BindablePropertyChangedEventArgs( propertyName ) );
	}

	private int _IntValue = 10;
	[CreateProperty] public int IntValue 
	{ 
		get => _IntValue; 
		set
		{
			if( _IntValue == value ) { return; }
			_IntValue = value;
			Notify();
		}
	}

	private string _StringValue = "初期値";
	[CreateProperty] public string StringValue 
	{ 
		get => _StringValue; 
		set
		{
			if( _StringValue == value ) { return; }
			_StringValue = value;
			Notify();
		}
	}
}

まず INotifyBindablePropertyChanged に必須の propertyChanged イベントを定義します。このイベントにプロパティ名が入ったパラメータを渡す事で UI に変更を通知します。

Notify() メソッドは propertyChanged の発行を簡単にするためのメソッドです。このメソッドの引数には [CallerMemberName] 属性がついていて呼び出し元のプロパティ名を引数に受け取る事ができます。それを BindablePropertyChangedEventArgs に渡しています。イベントの受け取り側はこの EventArgs でどのプロパティからの呼び出しかを識別できる仕組みです。

そして各プロパティを自動プロパティではなくバッキングフィールドを使う形に書き換えています。その setter で値に変更があった場合だけ Notify() を呼び出します。

こうする事で UI が毎フレーム値を確認する事はなくなり、propertyChanged が呼ばれたときだけそのプロパティの Label だけが更新されるようになりました!

出来たけど・・、ちょっとダサくない?

できたー!・・と喜びたいところなんですが、上記のコードはちょっと不格好というか見た目に長いですよね。UI のデータ項目はかなりの数になるので、いちいちひとつひとつ丁寧に↑のような実装をするのはすごく面倒です。

それになにやら一見便利そうな Notify() メソッドですが、[CallerMemberName] やら new BindablePropertyChangedEventArgs( propertyName ) やらといったあたりがどうにも処理コスト的に無駄が多そうな感じにも見えます。

そのあたりが何とかならないのかもうちょっといじってみましょう。

INotifyBindablePropertyChanged の処理の汎用化を考える

先ほどのデータクラス実装の問題点を洗い出してみましょう。

  1. Notify メソッドでの [CallerMemberName] や EventArgs の new が毎回呼ばれるコストが気になる
  2. setter の同じ実装を各プロパティに何度も書きたくない
  3. クラスにいちいち propertyChanged イベントを定義するのが手間

なかなかかゆい問題ばかりですね。

ここで真っ先に思いつくのは「基底データクラスを作って処理を汎用化しよう!」という対応ですが、継承はちょっと避けたいところです。
なぜならバインドするデータを持つクラスには ScriptableObject や MonoBehaviour を使うケースも考えられるので、そうした際には基底クラスが使えなくなってしまいます。

ではどうするのが良いでしょうか・・?
ちょっと調べてみたんですが、実はこの問題は INotifyPropertyChanged の実装でもけっこう昔からあれこれ議論されていて、なかなか奥深い問題のようでした。
中には SourceGenerator でプロパティ部分のコードを自動生成するなんて素敵な記事も見かけましたが、今回はおとなしく簡単に出来る方法にとどめたいと思います。(そのうちやってみたいなぁ)

というわけで正攻法でわりと負荷の少ない方法を目指して乗り切ってみようと思います。

BindablePropertyChangedEventArgs をキャッシュ

まずは無駄の多そうな new BindablePropertyChangedEventArgs の削減です。
やはりこの生成を毎回行うのはちょっとコストがかかるようでしたので static メンバにキャッシュしてしまいます。
なおこの方法は .net core のソース内にも登場している由緒正しい方法のようです。

BindTestData.cs
public class BindTestData : INotifyBindablePropertyChanged
{
	internal static class EventArgsCache
	{
		internal static readonly BindablePropertyChangedEventArgs ChangedIntValue = new( nameof( IntValue ) );
		internal static readonly BindablePropertyChangedEventArgs ChangedStringValue = new( nameof( StringValue ) );
	}

こんな感じで inner クラスに static フィールドを用意すれば初回のみのコストで済みますし、インスタンス毎に無駄に生成される事もありません。

setter 部分を共通化

次は setter 部分を共通化します。こんな感じの拡張メソッドを用意しました。

INotifyBindablePropertyChangedExtensions.cs
public static class INotifyBindablePropertyChangedExtensions
{
	/// <summary>
	/// property に値をセットして通知
	/// </summary>
	/// <param name="field"> property の backing field </param>
	/// <param name="value"></param>
	/// <param name="eventArgs"> BindablePropertyChangedEventArgs </param>
	/// <param name="propertyChanged"> propertyChanged event </param>
	/// <param name="forceNotify"> property の値が等価でも更新通知する場合は true </param>
	public static bool SetPropertyWithNotify< T >
	( 
		this INotifyBindablePropertyChanged self, 
		ref T field,
		in T value, 
		in BindablePropertyChangedEventArgs eventArgs,
		EventHandler< BindablePropertyChangedEventArgs > propertyChanged,
		bool forceNotify = false
	){ 
		if( !forceNotify && EqualityComparer<T>.Default.Equals( field, value ) )
		{ return false; }

		field = value;
		propertyChanged?.Invoke( self, eventArgs );
		return true;
	}
}

実装は単純で、フィールドの値が変わったときだけ値の更新と propertyChanged を呼ぶようになっています。
一応 forceNotify を用意してあり true を渡せば値が同じでもイベントを呼ぶ事ができます。

等価比較の個所には EqualityComparer<T>.Default.Equals() を使っています。これは generic では == 演算子が使えず、Equals を使うには null 判定が必要、でも null 判定にはキャストが必要だから alloc が・・のような問題を回避するために使用しています。

中身の処理的には拡張メソッドである必要もないんですが、INotifyBindablePropertyChanged を実装するクラスプロパティの setter から簡単に呼びたいので敢えてそうしています。

改良版データクラス

というわけで改良したデータクラスはこんな感じになりました。

BindTestData.cs
public class BindTestData : INotifyBindablePropertyChanged
{
	// EventArgs のキャッシュ
	internal static class EventArgsCache
	{
		internal static readonly BindablePropertyChangedEventArgs ChangedIntValue = new( nameof( IntValue ) );
		internal static readonly BindablePropertyChangedEventArgs ChangedStringValue = new( nameof( StringValue ) );
	}
	
	// 通知イベント
	public event EventHandler<BindablePropertyChangedEventArgs> propertyChanged;

	private int _IntValue = 10;
	[CreateProperty] public int IntValue 
	{ 
		get => _IntValue; 
		set => this.SetPropertyWithNotify( ref _IntValue, value, EventArgsCache.ChangedIntValue, propertyChanged );
	}

	private string _StringValue = "初期値";
	[CreateProperty] public string StringValue 
	{ 
		get => _StringValue; 
		set => this.SetPropertyWithNotify( ref _StringValue, value, EventArgsCache.ChangedStringValue, propertyChanged );
	}
}

・・・ だっさ。
い、いやまぁ、まぁ最初のよりは結構マシになったかなぁーーなんて、ねぇ。そんな感じに見えますよねぇ?・・見えません?

まぁ賛否あると思いますが正直私もあんまり満足できてません。でも正攻法ではこれが限界なんじゃないかなぁと思います。

とはいえ実行時のコストは低めで任意のクラスに UIElement とのデータバインディングができるので、割と使い勝手は良いと思います。
実装の際は EventArgsCache の nameof の中身や SetProperty に渡す引数を間違えないように気を付けて使いましょう。

おまけ: R3.ReactiveProperty を使ってみた

ここまで一生懸命やってみましたがあまり綺麗にかけず悶々としてしまいました。
やっぱりこういうのは ReactiveProperty を使ったほうが綺麗に書けそうな気がしますよね。

ということで、今ウワサの R3 (Cysharp さん提供の UniRx の後継ライブラリ) を導入してやってみる事にしました。

https://github.com/Cysharp/R3

BindTestDataReactive.cs
using System;
using UnityEngine.UIElements;
using R3;

public class BindTestDataReactive : INotifyBindablePropertyChanged, IDisposable
{
	// EventArgs のキャッシュ
	internal static class EventArgsCache
	{
		internal static readonly BindablePropertyChangedEventArgs ChangedIntValue = new( nameof( IntValue ) );
		internal static readonly BindablePropertyChangedEventArgs ChangedStringValue = new( nameof( StringValue ) );
	}

	// 通知イベント
	public event EventHandler<BindablePropertyChangedEventArgs> propertyChanged;

	public ReactiveProperty< int > IntValue = new( 10 );
	public ReactiveProperty< string > StringValue  = new( "初期値" );

	public BindTestDataReactive()
	{
		IntValue.Subscribe( _ => 
		{
			propertyChanged?.Invoke( this, EventArgsCache.ChangedIntValue );
		});

		StringValue.Subscribe( _ => 
		{
			propertyChanged?.Invoke( this, EventArgsCache.ChangedStringValue );
		});

	}

	public void Dispose()
	{
		IntValue?.Dispose();
		StringValue?.Dispose();
	}
}

プロパティのところは 1行で綺麗に書けますね!
でも propertyChanged イベントを発行するにはどうしてもコンストラクタ等で Subscribe するしかなさそうでした。
私は ReactiveProperty などに全然明るくないのでもしかしたら↑の方法が鼻で笑われるほど残念実装で、実はもっと簡単な方法があるのかもしれません。このあたりをご存じの方がいらっしゃいましたら是非ともご教授いただければと思います。

でも MVP とか MVVM とかを意識して書くとなると UIElement だけではなく各クラスと単方向や双方向でのやり取りが頻繁に書かれると思うので、そういう意味でも ReactiveProperty のほうが楽に実装できるような気がします。これについては機会があれば勉強してみようと思います。

おわりに

というわけで UIElement のバインディングを試してみました。
他にもボタンに ICommandEvent でイベントをバインドしたり、Unity Localization の String Event をバインドしたりと、かなり便利な使い方が出来るようです。

UIToolkit を使ってみましたがランタイムでもだいぶ使えるようになってきているなぁと感じました。現在も Shader が使えなかったり Animator が使えなかったりとまだ不足しているものもあるようですが、それらも順次改善予定との事ですので、uGUI に取って代わる日も近いのかもしれませんね。
これからも少しずつ UIToolkit を調べて記事にしていきたいと思います。

Discussion