はじめに
今回のサンプルコードについては次のリポジトリで公開しています。
また今回の内容は2.0.0
のバージョンを基準に執筆しました。
ObservableCollectionsとは
ObservableCollectionsとは、Cysharp社が提供するMITライセンスのOSSライブラリです。
コレクション(複数データをまとめて扱う機構)をObservable
として扱え、さらにView
との連動機能も提供しているライブラリとなっています。
文脈としては.NET
のライブラリとしてObservableCollection<T>
が存在し、それをベースにさらに機能追加ライブラリがこのObservableCollections
です。
Unityユーザ向けに、すごく簡単にまとめると次になります。
-
UniRx
のReactiveCollection<T>
やReactiveDictionary`の強化版-
UniRx
の提供するObservable
なコレクションと比較するとView
(UI)を組みやすくなっている
-
-
R3との連携機能もあるため、R3には存在しない
ReactiveDictionary
などの代替として利用できる
Unityでの活用
ObservableCollections
自体はUnityの環境下でも用いることができ、View
の構築に利用することもできます。
ですが、もともとObservableCollections
はMVVMパターン
での利用を想定しています。
しかしUnityにはMVVMパターン
を組むような機構は用意されていません。
そのためUnityでObservableCollections
を用いてView
を組む場合は手で諸々を用意する必要があります。
UnityでObservableCollections
を用いたView
の構築例については後に述べます。
導入方法
NugetForUnity
経由での導入してください。
- NugetForUnityを自身のUnityプロジェクトに導入する
- UnityEditor上部のメニュー [Nuget] -> [Manage Nuget Package]を開く
- Search欄に
ObservableCollections
と入力し、Cysharpが提供するObservableCollections
パッケージを導入する - R3と連携する場合は
ObservableCollections.R3
も同時に導入する
ObservableCollectionsが提供するObservableなコレクション一覧
ObservableCollections
は次のObservable
なコレクションを提供しています。
ObservableList<T>
ObservableHashSet<T>
ObservableDictionary<TKey, TValue>
ObservableStack<T>
ObservableQueue<T>
ObservableRingBuffer<T>
ObservableFixedSizeRingBuffer<T>
それぞれObservable
+ベースとなっているコレクション
なので、名前から挙動が類推できます。
これらのオブジェクトはIObservableCollection<T>
を実装しており、このインタフェースを介することでコレクションの変動を検知することができるようになっています。
IObservableCollection<T>
IObservableCollection<T>
はObservableCollections
が提供するコレクションが実装しているインタフェースです。
event
としてNotifyCollectionChangedEventHandler<T>
が定義されており、こちらを用いることでコレクションの変動を検知することができます。
// MIT License
// Copyright (c) 2021 Cysharp, Inc.
// https://github.com/Cysharp/ObservableCollections/blob/master/LICENSE
public interface IObservableCollection<T> : IReadOnlyCollection<T>, IEnumerable<T>, IEnumerable
{
event NotifyCollectionChangedEventHandler<T> CollectionChanged;
object SyncRoot { get; }
ISynchronizedView<T, TView> CreateView<TView>(
Func<T, TView> transform,
bool reverse = false);
}
/// <summary>
/// Contract:
/// IsSingleItem ? (NewItem, OldItem) : (NewItems, OldItems)
/// Action.Add
/// NewItem, NewItems, NewStartingIndex
/// Action.Remove
/// OldItem, OldItems, OldStartingIndex
/// Action.Replace
/// NewItem, NewItems, OldItem, OldItems, (NewStartingIndex, OldStartingIndex = samevalue)
/// Action.Move
/// NewStartingIndex, OldStartingIndex
/// Action.Reset
/// -
/// </summary>
[StructLayout(LayoutKind.Auto)]
public readonly ref struct NotifyCollectionChangedEventArgs<T>
{
public readonly NotifyCollectionChangedAction Action;
public readonly bool IsSingleItem;
public readonly T NewItem;
public readonly T OldItem;
public readonly ReadOnlySpan<T> NewItems;
public readonly ReadOnlySpan<T> OldItems;
public readonly int NewStartingIndex;
public readonly int OldStartingIndex;
// 以下略...
}
動作例
例としてObservableFixedSizeRingBuffer<T>
に要素を追加し、イベントがどのように発行されるのかを見てみます。
using System;
using System.Collections.Specialized;
using ObservableCollections;
using UnityEngine;
namespace Samples
{
public class ObservableFixedSizeRingBufferSample : MonoBehaviour
{
private ObservableFixedSizeRingBuffer<int> _ringBuffer;
private void Start()
{
// Capacity:3 の固定長RingBufferとして生成する
_ringBuffer = new ObservableFixedSizeRingBuffer<int>(3);
// コレクション変動イベントを購読
// NotifyCollectionChangedEventArgs が要素として渡ってくる
_ringBuffer.CollectionChanged += (in NotifyCollectionChangedEventArgs<int> args) =>
{
switch (args.Action)
{
case NotifyCollectionChangedAction.Add:
Debug.Log($"Add:[{args.NewStartingIndex}] = {args.NewItem}");
break;
case NotifyCollectionChangedAction.Move:
Debug.Log($"Move:[{args.OldStartingIndex}] => [{args.NewStartingIndex}]");
break;
case NotifyCollectionChangedAction.Remove:
Debug.Log($"Remove:[{args.OldStartingIndex}] = {args.OldItem}");
break;
case NotifyCollectionChangedAction.Replace:
Debug.Log($"Replace:[{args.OldStartingIndex}] = ({args.OldItem} => {args.NewItem})");
break;
case NotifyCollectionChangedAction.Reset:
Debug.Log("Reset");
break;
default:
throw new ArgumentOutOfRangeException();
}
};
// 末尾に追加
_ringBuffer.AddLast(1);
_ringBuffer.AddLast(2);
_ringBuffer.AddLast(3);
// 末尾削除
_ringBuffer.RemoveLast();
// 先頭に差し込み
_ringBuffer.AddFirst(0);
// 先頭に差し込み(キャパシティを超える)
_ringBuffer.AddFirst(-1);
// 真ん中の要素を書き換え
_ringBuffer[1] = 10;
}
}
}
// AddLast(1,2,3)
Add:[0] = 1
Add:[1] = 2
Add:[2] = 3
// RemoveLast()
Remove:[2] = 3
// AddFirst(0)
Add:[0] = 0
// AddFirst(-1)
Remove:[2] = 2
Add:[0] = -1
// _ringBuffer[1] = 10
Replace:[1] = (0 => 10)
ObservableFixedSizeRingBuffer
は固定長RingBuffer
です。
コレクションの先端/末尾に要素を追加することができ、その際にキャパシティを超えたら反対側から要素が抜けるという挙動になっています。
そしてその要素の変動をCollectionChanged
から購読することができ、リアクティブにコレクションの変動を扱うことができます。
R3のObservableと連携する
次世代RxであるR3とObservableCollections
を連携することができます。
連携するにはObservableCollections.R3という別のパッケージをNugetより導入する必要があります。
とくにR3には、UniRxにはあったReactiveDictionary
などのリアクティブなコレクション実装が存在しません。
その代わりにObservableCollections
と接続することでそれら機能の代替とすることができます(ObservableCollections
の方が多機能なのでむしろUniRxより大幅強化されることになる)
ObservableCollections.R3
が導入された状態であれば、次のようにObservableCollections
の状態通知をR3.Observable
として扱うことができるようになります。
// ObservableDictionaryをUniRx.ReactiveDictionaryみたく扱うことができる
var observableDictionary = new ObservableDictionary<int, string>();
// 新しい要素が追加されたイベント
observableDictionary.ObserveAdd(destroyCancellationToken)
.Subscribe(collectionAddEvent =>
{
var (key, value) = collectionAddEvent.Value;
Debug.Log($"Add [{key}]={value}");
});
observableDictionary.ObserveReplace(destroyCancellationToken)
.Subscribe(replaceEvent =>
{
var key = replaceEvent.NewValue.Key;
var newValue = replaceEvent.NewValue.Value;
var oldValue = replaceEvent.OldValue.Value;
Debug.Log($"Replace [{key}]={oldValue} -> {newValue}");
});
observableDictionary[1] = "hoge";
observableDictionary[2] = "fuga";
observableDictionary[1] = "piyo";
Add [1]=hoge
Add [2]=fuga
Replace [1]=hoge -> piyo
ObservableCollectionsからUnityのViewに繋ぎこむ
ではObservableCollections
からUnityのView(uGUIなど)に繋ぎこんでみましょう。
そのためにまずISynchronizedView<T, TView>
を把握しておく必要があります。
ISynchronizedView<T, TView>
ISynchronizedView<T, TView>
は「ObservableCollections
がもつコレクションの要素」と「その要素に紐づいたView
」を管理するコレクションです。
もう少しざっくりいうと「Model
とView
の対応付け」を保持してくれる存在です。
ISynchronizedView<T, TView>
はIObservableCollection<T>.CreateView
から生成できます。
IObservableCollection<T>.CreateView
を実行することでそれに紐づいたISynchronizedView<T, TView>
を生成できます。
引数で「もとのコレクション要素が増えた時にどうViewへそれを連動させるか」という処理を渡し、コレクションの要素が増えた時にView
が自動で追加されるようにできます。
void Start()
{
// IObservableCollection<T>
var model = new ObservableFixedSizeRingBuffer<int>(10);
// IObservableCollection<T>に連動したISynchronizedViewを生成する
// このとき、デリゲートに「要素が増えた時にViewをどう追加するか」の処理を定義できる
ISynchronizedView<int, GameObject> synchronizedView = model.CreateView(AddView);
}
// T -> TView
private GameObject AddView(int value)
{
// IObservableCollection<T>の要素が追加されたときに実行される
// ここではuGUIのPrefabがあったとして、これをInstantiateして内容を設定する処理をしている
var item = GameObject.Instantiate(prefab, root.transform);
item.GetComponentInChildren<Text>().text = value.ToString();
return item.gameObject;
}
CreateViewでViewを追加する
このサンプルでは実際にCreateView
を使ってViewを追加しています。
ただしこの実装には要素が減った時にViewを削除する処理が入っていません。
そのためIObservableCollection<T>
から要素が減ったとしてもView
が残り続けてしまっています。
using ObservableCollections;
using UnityEngine;
using UnityEngine.UI;
namespace Samples
{
public class SynchronizedViewSample : MonoBehaviour
{
public Button prefab;
public GameObject root;
ObservableFixedSizeRingBuffer<int> _model;
ISynchronizedView<int, GameObject> _synchronizedView;
private readonly int MaxCapacity = 5;
void Start()
{
_model = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);
// Modelに要素が増えたらViewを追加する
_synchronizedView = _model.CreateView(AddView);
}
private GameObject AddView(int value)
{
// IObservableCollection<T>の要素が追加されたときに実行される
var item = Instantiate(prefab, root.transform);
item.GetComponentInChildren<Text>().text = value.ToString();
return item.gameObject;
}
private void Update()
{
// キーを押すごとに要素を1つ追加していく
if (Input.GetKeyDown(KeyCode.A))
{
_model.AddLast(Time.frameCount);
}
}
void OnDestroy()
{
// Destroy時に紐づいたViewも一緒に消す
foreach (var (_, view) in _synchronizedView)
{
if (view != null) Destroy(view);
}
_synchronizedView.Dispose();
}
}
}
(キー入力のたびにObservableFixedSizeRingBuffer
に要素が追加され、それに反応してView(Button)
が生成されている。ObservableFixedSizeRingBuffer
のキャパシティは5なので、要素数が5を超えた時点でリングバッファ側からは古い要素が消えている。そかしこの変動はView
に反映されるべきだが、この実装では反映されておらずView
が増え続けている。)
CreateView
がサポートするのは「コレクション要素が増えた時にView
をどう生成するか」までです。
「生成したView
をどう配置するか」や「要素が減った時にView
をどう消すか」は手動で定義する必要があります。
RoutingCollectionChanged
とCollectionStateChanged
ISynchronizedView<T, TView>
にはRoutingCollectionChanged
とCollectionStateChanged
の2つのイベントが定義されています。
それぞれ元のコレクション(IObservableCollection
側)の要素の変化をイベントとして通知します。
event NotifyCollectionChangedEventHandler<T> RoutingCollectionChanged;
event Action<NotifyCollectionChangedAction> CollectionStateChanged;
両者の違いとしてはRoutingCollectionChanged
はNotifyCollectionChangedEventHandler<T>
を扱うのに対し、CollectionStateChanged
はNotifyCollectionChangedAction
のみを扱います。
ざっくりまとめると次です。
-
RoutingCollectionChanged
: 「どの要素」が「どうなったのか(追加/削除/置換)」がすべて通知される -
CollectionStateChanged
: 「何が起きたのか(追加/削除/置換)」のみが通知される(具体的にどの要素が変動されたかはわからない)
RoutingCollectionChanged
の方が得られる情報は多いです。
コレクションから要素が消えた時にViewも一緒に消すには
IObservableCollection<T>
の要素とView
の対応づけはISynchronizedView<T, TView>
が管理しています。
そのためRoutingCollectionChanged
を監視し、Remove
を受けた時に紐づいているView
を消してあげる処理を追加したとします。
void Start()
{
_model = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);
// Modelに要素が増えたらViewを追加する
_synchronizedView = _model.CreateView(AddView);
_synchronizedView.RoutingCollectionChanged +=
delegate(in NotifyCollectionChangedEventArgs<int> args)
{
// 削除されたデータ本体
var removedData = args.OldItem;
// このデータに紐づいていたViewを探す
var (_, view) =
_synchronizedView.FirstOrDefault(x => x.Value == removedData);
// Viewが見つかったら消す
if (view != null) Destroy(view);
};
}
しかしこれ、上手く動きません。
理由は「元のObservableCollection
側で要素が削除されたとき、ISynchronizedView<T, TView>
側にそれを反映してからRoutingCollectionChanged
が発火する」からです。
つまり「Remove
」のイベントが飛んできた時点で、そのValue
とView
を紐づけた要素がすでにISynchronizedView<T, TView>
から削除されているのです。
(要するに、消したいView
の参照がISynchronizedView<T, TView>
から消えてしまってからイベント通知が来るため、消すべきView
を見失ってしまう)
そのため「ObservableCollection
の要素が変化した時にView
を連動して消す」については別の方法を考える必要があります。
そこでView
を消すための手法の1つとして「ISynchronizedViewFilter<T, TView>
を使う」というやり方を紹介します。
ISynchronizedViewFilter<T, TView>
(上記で「要素の削除にSynchronizedViewFilter<T, TView>
を使う」と話してはいますが、その使い方自体がそもそもイレギュラー感あるので一旦忘れてください。まずは本来のSynchronizedViewFilter<T, TView>
の使い方から紹介します。)
ISynchronizedViewFilter<T, TView>
はISynchronizedView<T, TView>
に適用できるフィルターオブジェクトであり、条件に応じてView
の状態を変更することができます。
そのため本来は「リストの中から特定の属性のアイテムのみに表示したい」みたいな用途で用いるものです。
public interface ISynchronizedViewFilter<T, TView>
{
bool IsMatch(T value, TView view);
void WhenTrue(T value, TView view);
void WhenFalse(T value, TView view);
void OnCollectionChanged(ChangedKind changedKind, T value, TView view, in NotifyCollectionChangedEventArgs<T> eventArgs);
}
OnCollectionChanged
のChangedKind
は「ISynchronizedView<T, TView>
の要素に何が起こったか」がわかります。
一方でeventArgs.Action
を参照すると「IObservableCollection<T>
側で何が発生したか」がわかります。
この2つを組み合わせることで「Replace
が発生したためにView
がAdd
された」といった詳細を把握することができます。
public void OnCollectionChanged(ChangedKind changedKind,
int value,
GameObject view,
in NotifyCollectionChangedEventArgs<int> eventArgs)
{
// eventArgs.Actionは「大本のコレクションで何が起きたか?」
// ここではReplaceに限定する
if (eventArgs.Action != NotifyCollectionChangedAction.Replace) return;
// Replace起因でViewのAddが発生したことがここでわかるので、
// その場合にViewの要素の並び替えを行う
if (changedKind == ChangedKind.Add)
{
view.transform.SetSiblingIndex(eventArgs.NewStartingIndex);
}
}
なおFilterは1つのISynchronizedView<T, TView>
につき同時に1つしか設定できません。
使用例
たとえばISynchronizedViewFilter<T, TView>
を設定し、「Valueが奇数のときのみ表示するフィルター」を反映してみます。
まずはISynchronizedViewFilter<T, TView>
を実装したフィルターを定義します。
// 奇数フィルター
class OddFilter : ISynchronizedViewFilter<int, GameObject>
{
public bool IsMatch(int value, GameObject view)
{
// ヒットする条件
return value % 2 == 1;
}
public void WhenTrue(int value, GameObject view)
{
// 奇数なら表示する
view.SetActive(true);
}
public void WhenFalse(int value, GameObject view)
{
// 偶数なら表示しない
view.SetActive(false);
}
public void OnCollectionChanged(ChangedKind changedKind,
int value,
GameObject view,
in NotifyCollectionChangedEventArgs<int> eventArgs)
{
// 何もしない
}
}
そしてこのフィルターをISynchronizedView<T, TView>.AttachFilter
を用いて適用します。
// たとえば、キーが押されたらフィルターを切り替える
if (Input.GetKeyDown(KeyCode.O))
{
if (_isFilterOn)
{
// フィルター解除
// 解除時に全View要素を表示する
_synchronizedView.ResetFilter((_, view) => view.SetActive(true));
_isFilterOn = false;
}
else
{
// 奇数だけ表示するフィルターを追加
_synchronizedView.AttachFilter(new OddFilter());
_isFilterOn = true;
}
Debug.Log($"Filter:{_isFilterOn}");
}
(全体)
using System.Linq;
using ObservableCollections;
using UnityEngine;
using UnityEngine.UI;
namespace Samples
{
public class SynchronizedViewSample : MonoBehaviour
{
public Button prefab;
public GameObject root;
ObservableFixedSizeRingBuffer<int> _model;
ISynchronizedView<int, GameObject> _synchronizedView;
private readonly int MaxCapacity = 5;
private bool _isFilterOn;
void Start()
{
_model = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);
// Modelに要素が増えたらViewを追加する
_synchronizedView = _model.CreateView(AddView);
}
private GameObject AddView(int value)
{
// IObservableCollection<T>の要素が追加されたときに実行される
var item = Instantiate(prefab, root.transform);
item.GetComponentInChildren<Text>().text = value.ToString();
return item.gameObject;
}
private void Update()
{
// キーを押すごとに要素を1つ追加していく
if (Input.GetKeyDown(KeyCode.A))
{
_model.AddLast(Time.frameCount);
}
// キーが押されたらフィルターを切り替える
if (Input.GetKeyDown(KeyCode.O))
{
if (_isFilterOn)
{
// フィルター解除
// 解除時に全View要素を表示する
_synchronizedView.ResetFilter((_, view) => view.SetActive(true));
_isFilterOn = false;
}
else
{
// 奇数だけ表示するフィルターを追加
_synchronizedView.AttachFilter(new OddFilter());
_isFilterOn = true;
}
Debug.Log($"Filter:{_isFilterOn}");
}
}
void OnDestroy()
{
// Destroy時に紐づいたViewも一緒に消す
foreach (var (_, view) in _synchronizedView)
{
if (view != null) Destroy(view);
}
_synchronizedView.Dispose();
}
}
}
実行すると次のような挙動となります。
フィルターを反映したタイミングでView
が非表示となり、フィルターを解除すると再度表示されます。
フィルターを応用してView
の削除に用いる
さきほどISynchronizedView<T, TView>.RoutingCollectionChanged
を監視しただけではRemove
イベントからView
の削除が連動できないと説明しました。
これを実現するために、ISynchronizedViewFilter<T, TView>
を悪用して応用してみます。
ISynchronizedViewFilter<int, GameObject>
はRemove
イベントが発火したとき、関連付けられたView
も一緒に通知されるため、これを用いることで「Remove
イベントに対応したView
」を知ることができます。
// View削除フィルター
class RemoveFilter : ISynchronizedViewFilter<int, GameObject>
{
public bool IsMatch(int value, GameObject view)
{
// 使わないので適当に返す
return true;
}
public void WhenTrue(int value, GameObject view)
{
}
public void WhenFalse(int value, GameObject view)
{
}
public void OnCollectionChanged(ChangedKind changedKind,
int value,
GameObject view,
in NotifyCollectionChangedEventArgs<int> eventArgs)
{
// 要素が削除されたらこのイベントに紐づいたViewも消す
if (changedKind == ChangedKind.Remove)
{
if (view != null) Object.Destroy(view);
Debug.Log($"{value} is removed.");
}
}
}
_model = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);
// Modelに要素が増えたらViewを追加する
_synchronizedView = _model.CreateView(AddView);
// 要素を消すフィルターを追加
_synchronizedView.AttachFilter(new RemoveFilter());
(全体)
using ObservableCollections;
using UnityEngine;
using UnityEngine.UI;
namespace Samples
{
public class SynchronizedViewSample : MonoBehaviour
{
public Button prefab;
public GameObject root;
ObservableFixedSizeRingBuffer<int> _model;
ISynchronizedView<int, GameObject> _synchronizedView;
private readonly int MaxCapacity = 5;
void Start()
{
_model = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);
// Modelに要素が増えたらViewを追加する
_synchronizedView = _model.CreateView(AddView);
// 要素を消すフィルターを追加
_synchronizedView.AttachFilter(new RemoveFilter());
}
private GameObject AddView(int value)
{
// IObservableCollection<T>の要素が追加されたときに実行される
var item = Instantiate(prefab, root.transform);
item.GetComponentInChildren<Text>().text = value.ToString();
return item.gameObject;
}
private void Update()
{
// キーを押すごとに要素を1つ追加していく
if (Input.GetKeyDown(KeyCode.A))
{
_model.AddLast(Time.frameCount);
}
}
void OnDestroy()
{
// Destroy時に紐づいたViewも一緒に消す
foreach (var (_, view) in _synchronizedView)
{
if (view != null) Destroy(view);
}
_synchronizedView.Dispose();
}
}
}
(ObservableFixedSizeRingBuffer
のキャパシティが5なので要素数が5を超えた分については古い要素が削除されていた。この実装でやっと要素の削除がView
にも反映されるようになった)
ObservableCollectionsを用いたちょっと複雑なサンプル
これら解説した機能を組み合わせて、次のようなサンプルを実装してみました。
- ベースは
MV(R)P
パターン -
Monster
を最大6匹まで保持できる -
Monster
は追加したり、逃したりできる -
Monster
の手持ちをUIに表示できる- このとき
Monster
をフィルターして表示切り替えができる
- このとき
実装は次のリポジトリで公開してます。
実装解説
Model
Model
(データの実体)の定義は次のようになっています。
Monster
using System;
namespace ViewSamples
{
// モンスター
public readonly struct Monster : IEquatable<Monster>
{
// 属性
public MonsterType MonsterType { get; }
// 名前
public string Name { get; }
public Monster(MonsterType monsterType, string name)
{
MonsterType = monsterType;
Name = name;
}
public bool Equals(Monster other)
{
return MonsterType == other.MonsterType && Name == other.Name;
}
public override bool Equals(object obj)
{
return obj is Monster other && Equals(other);
}
public override int GetHashCode()
{
return HashCode.Combine((int)MonsterType, Name);
}
}
// 属性定義
public enum MonsterType
{
Fire,
Water,
Grass
}
}
MonsterBackpack
このMonster
を最大6匹まで保持する概念としてMonsterBackpack
を定義します。
(「手持ちモンスター」を表現する)
このとき、ObservableHashSet<Monster>
をコレクションに利用し手持ちモンスターの増減をView
に反映する準備をしています。
using ObservableCollections;
using UnityEngine;
namespace ViewSamples
{
/// <summary>
/// モンスターを格納するオブジェクト
/// </summary>
public class MonsterBackpack : MonoBehaviour
{
// View構築用にpublicにしておく
public IObservableCollection<Monster> ObservableCollection => _hashSet;
// ObservableなHashSet
// HashSetは要素の重複を許さないコレクション
private readonly ObservableHashSet<Monster> _hashSet = new();
// BackPackの最大容量
private readonly int _maxCapacity = 6;
/// <summary>
/// モンスターを追加する
/// </summary>
public bool TryAddMonster(MonsterType monsterType, string name)
{
var monster = new Monster(monsterType, name);
// 要素を超える場合は追加しない
if (_hashSet.Count >= _maxCapacity) return false;
return _hashSet.Add(monster);
}
/// <summary>
/// モンスターを逃がす
/// </summary>
public void RemoveMonster(Monster monster)
{
_hashSet.Remove(monster);
}
/// <summary>
/// すべてのモンスターを逃がす
/// </summary>
public void RemoveAllMonster()
{
_hashSet.Clear();
}
}
}
View
続いてView
(UI側)です。
MonsterCardView
MonsterCardView
というView
を扱うコンポーネントと、そのPrefab
を用意します。
using System;
using UnityEngine;
using UnityEngine.UI;
namespace ViewSamples.ugui.View
{
/// <summary>
/// Viewを管理するコンポーネント
/// </summary>
public class MonsterCardView : MonoBehaviour
{
[SerializeField] private Text _typeLabel;
[SerializeField] private Text _nameLabel;
[SerializeField] private Image _backgroundImage;
[SerializeField] private Button _removeButton;
// 逃がすボタン
public Button RemoveButton => _removeButton;
/// <summary>
/// Viewのセットアップ
/// </summary>
public void Initialize(Monster monster)
{
// 各種要素を設定
_nameLabel.text = monster.Name;
switch (monster.MonsterType)
{
case MonsterType.Fire:
_typeLabel.text = "ほのお";
_backgroundImage.color = new Color(1f, 0.7f, 0.7f);
break;
case MonsterType.Water:
_typeLabel.text = "みず";
_backgroundImage.color = new Color(0.7f, 0.7f, 1.0f);
break;
case MonsterType.Grass:
_typeLabel.text = "くさ";
_backgroundImage.color = new Color(0.7f, 1, 0.7f);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
}
}
Presenter
このMonsterBackpack
とMonsterCardView
の繋ぎこみをPresenter
で実装します。
ISynchronizedViewFilterの実装
今回はISynchronizedViewFilter
の実装は2つあります。
using ObservableCollections;
using ViewSamples.ugui.View;
namespace ViewSamples.ugui
{
/// <summary>
/// MonsterのTypeに応じて表示を切り替えるフィルター
/// </summary>
internal sealed class MonsterTypeFilter : ISynchronizedViewFilter<Monster, MonsterCardView>
{
private MonsterType MonsterType { get; }
public MonsterTypeFilter(MonsterType monsterType)
{
MonsterType = monsterType;
}
public bool IsMatch(Monster value, MonsterCardView view)
{
// タイプが一致するか
return value.MonsterType == MonsterType;
}
public void WhenTrue(Monster value, MonsterCardView view)
{
view.gameObject.SetActive(true);
}
public void WhenFalse(Monster value, MonsterCardView view)
{
view.gameObject.SetActive(false);
}
public void OnCollectionChanged(ChangedKind changedKind,
Monster value,
MonsterCardView view,
in NotifyCollectionChangedEventArgs<Monster> eventArgs)
{
// do nothing
}
}
}
using ObservableCollections;
using UnityEngine;
using ViewSamples.ugui.View;
namespace ViewSamples.ugui
{
// Remove時に要素を削除するフィルター
internal sealed class RemoveFilter : ISynchronizedViewFilter<Monster, MonsterCardView>
{
public bool IsMatch(Monster value, MonsterCardView view)
{
return true;
}
public void WhenTrue(Monster value, MonsterCardView view)
{
}
public void WhenFalse(Monster value, MonsterCardView view)
{
}
public void OnCollectionChanged(ChangedKind changedKind,
Monster value,
MonsterCardView view,
in NotifyCollectionChangedEventArgs<Monster> eventArgs)
{
if (changedKind == ChangedKind.Remove)
{
if (view != null) Object.Destroy(view.gameObject);
}
}
}
}
このMonsterTypeFilter
とRemoveFilter
を合成するFilterMux
を定義し、2つのフィルターを合成して適用できるようにしておきます。
using System.Linq;
using ObservableCollections;
namespace ViewSamples.ugui
{
/// <summary>
/// 複数のFilterを合成するもの
/// </summary>
public sealed class FilterMux<T, TView> : ISynchronizedViewFilter<T, TView>
{
private readonly ISynchronizedViewFilter<T, TView>[] _filters;
public FilterMux(params ISynchronizedViewFilter<T, TView>[] filters)
{
_filters = filters;
}
public bool IsMatch(T value, TView view)
{
return _filters.All(x => x.IsMatch(value, view));
}
public void WhenTrue(T value, TView view)
{
foreach (var filter in _filters)
{
filter.WhenTrue(value, view);
}
}
public void WhenFalse(T value, TView view)
{
foreach (var filter in _filters)
{
filter.WhenFalse(value, view);
}
}
public void OnCollectionChanged(ChangedKind changedKind,
T value,
TView view,
in NotifyCollectionChangedEventArgs<T> eventArgs)
{
foreach (var filter in _filters)
{
filter.OnCollectionChanged(changedKind, value, view, eventArgs);
}
}
}
}
MonsterPresenter
Presenter
レイヤーでメインとなるのがMonsterPresenter
です。
このオブジェクトがMonsterBackpack
のObservableCollection
を参照し、View
への紐づけを行います。
そしてさきほどのFilterMux
を用いて、2つのフィルターを同時適用しています。
using ObservableCollections;
using UniRx;
using UnityEngine;
using UnityEngine.UI;
using ViewSamples.ugui.View;
namespace ViewSamples.ugui
{
/// <summary>
/// MonsterBackpackの要素をリストへ反映する
/// </summary>
public sealed class MonsterPresenter : MonoBehaviour
{
[SerializeField] private MonsterBackpack _monsterBackpack;
[SerializeField] private MonsterCardView _cardPrefab;
[SerializeField] private Transform _viewRoot;
[SerializeField] private Dropdown _filterDropdown;
private ISynchronizedView<Monster, MonsterCardView> _synchronizedView;
private void Start()
{
// コレクションとViewの連動を定義
_synchronizedView = _monsterBackpack.ObservableCollection.CreateView(m =>
{
// Monsterが追加されたらPrefabより新しいViewを追加
var view = Instantiate(_cardPrefab, _viewRoot.transform);
view.Initialize(m);
// Viewのボタンを購読し、逃がすボタンが押されたら要素から消すように定義
view.RemoveButton.OnClickAsObservable()
.Take(1)
.Subscribe(_ => _monsterBackpack.RemoveMonster(m))
.AddTo(view);
return view;
});
// Dropdownが操作されたらフィルターを設定する
_filterDropdown.OnValueChangedAsObservable()
.Subscribe(x =>
{
switch (x)
{
case 0:
// フィルタを一旦解除し、描画設定を元に戻す
_synchronizedView.ResetFilter((_, v) =>
v.gameObject.SetActive(true));
// 改めて削除フィルタのみ再設定
_synchronizedView.AttachFilter(new RemoveFilter());
break;
case 1: // Fire
_synchronizedView.AttachFilter(
new FilterMux<Monster, MonsterCardView>(
new MonsterTypeFilter(MonsterType.Fire),
new RemoveFilter()));
break;
case 2: // Water
_synchronizedView.AttachFilter(
new FilterMux<Monster, MonsterCardView>(
new MonsterTypeFilter(MonsterType.Water),
new RemoveFilter()));
break;
case 3: // Grass
_synchronizedView.AttachFilter(
new FilterMux<Monster, MonsterCardView>(
new MonsterTypeFilter(MonsterType.Grass),
new RemoveFilter()));
break;
}
})
.AddTo(this);
// 初期設定として削除フィルターを追加
_synchronizedView.AttachFilter(new RemoveFilter());
}
}
}
ControllerMenuPresenter
ControllerMenuPresenter
は「Monster
の追加」や「まとめて逃がす」といった操作を管理します。
操作をModel
(MonsterBackpack
)へ伝えるのみで、このPresenter
自体がView
の操作は行いません。
using System;
using UniRx;
using UnityEngine;
using UnityEngine.UI;
namespace ViewSamples.ugui
{
/// <summary>
/// 各種UIのボタン操作をModelに反映する
/// </summary>
public sealed class ControllerMenuPresenter : MonoBehaviour
{
[SerializeField] private MonsterBackpack _monsterBackpack;
[SerializeField] private Dropdown _typeDropdown;
[SerializeField] private InputField _nameInputField;
[SerializeField] private Button _addButton;
[SerializeField] private Button _removeAllButton;
private void Start()
{
// 名前入力欄が設定されたら追加ボタンを押せるようにする
_nameInputField.OnValueChangedAsObservable()
.Select(x => x.Length > 0)
.SubscribeToInteractable(_addButton)
.AddTo(this);
// 追加ボタンを押したら手持ちにモンスターを加える
_addButton.OnClickAsObservable()
.Subscribe(_ =>
{
var monsterType = _typeDropdown.value switch
{
0 => MonsterType.Fire,
1 => MonsterType.Water,
2 => MonsterType.Grass,
_ => throw new ArgumentOutOfRangeException()
};
_monsterBackpack.TryAddMonster(monsterType, _nameInputField.text);
_nameInputField.text = "";
})
.AddTo(this);
// まとめて逃がすボタン
_removeAllButton.OnClickAsObservable()
.Subscribe(_ => _monsterBackpack.RemoveAllMonster())
.AddTo(this);
}
}
}
まとめ
これらを組みわせることでこのような挙動をするUIを組むことができました。
たしかに複数のデータを扱うUIを組むのにObservableCollections
は便利です。
ObservableCollectionsの考察
ObservableCollections
を使って出てきた疑問を自分なりに調べてまとめてみました。
ObservableCollections
に適したUIの設計パターンは何?
ObservableCollections
はWPF
やBlazor
であればMVVMパターン
での実装が可能です。
ですがUnityはフレームワークとしてMVVM
をサポートしていないため、Model
とView
の紐づけは手動になります。
なので「何というデザインパターンか」と名前を聞かれると答えられないのですが、とりあえず「MV(R)Pパターン
をベースにそれっぽく書く」が最適解だと思います。
IObservableCollection<T>
の要素の削除をViewに反映するには
本文で解説しているとおり、現状はISynchronizedViewFilter
を用いてあげるのが一番ラクです。
(そもそもフィルターで副作用起こす使い方が推奨されるのもアレな話なんですが、現状はこれが一番ラクです…)
// こういうFilterを定義して使い回すとか
using ObservableCollections;
using UnityEngine;
namespace Samples
{
public class GameObjectRemoveFilter<T> : ISynchronizedViewFilter<T, GameObject>
{
public bool IsMatch(T value, GameObject view)
{
return true;
}
public void WhenTrue(T value, GameObject view)
{
}
public void WhenFalse(T value, GameObject view)
{
}
public void OnCollectionChanged(ChangedKind changedKind,
T value,
GameObject view,
in NotifyCollectionChangedEventArgs<T> eventArgs)
{
if (changedKind == ChangedKind.Remove && view != null)
{
Object.Destroy(view);
}
}
}
public class ComponentRemoveFilter<T> : ISynchronizedViewFilter<T, UnityEngine.Component>
{
public bool IsMatch(T value, Component view)
{
return true;
}
public void WhenTrue(T value, Component view)
{
}
public void WhenFalse(T value, Component view)
{
}
public void OnCollectionChanged(ChangedKind changedKind,
T value,
Component view,
in NotifyCollectionChangedEventArgs<T> eventArgs)
{
if (changedKind == ChangedKind.Remove && view != null)
{
Object.Destroy(view);
}
}
}
}
var ringBuffer = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);
// Modelに要素が増えたらViewを追加する
var synchronizedView = ringBuffer.CreateView(AddView);
// 要素を消すフィルターを追加
var synchronizedView.AttachFilter(new GameObjectRemoveFilter<int>());
Replace
をどうViewに反映するか
Replace
とは、たとえば次のような「指定した要素を上書きする」みたいな処理です。
var buffer = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);
// これは「新規追加(Add)」
buffer.AddLast(1);
buffer.AddFirst(2);
// Replaceはこういう指定した箇所の「上書き」
buffer[1] = 0;
IObservableCollection<T>
側で発生したReplace
の発生をどうView
に反映するかですが、現状は「Remove
とAdd
を使ってがんばる」しかありません。つまり既存のView要素を使いまわすことはできず、古いView要素を消して新しいView要素作って適切な場所に再配置するという作業が必要になります。
// こういう「要素の位置をReplace時に配置を調整する」フィルターを定義して
public class ReplaceFilter : ISynchronizedViewFilter<int, GameObject>
{
public bool IsMatch(int value, GameObject view)
{
return true;
}
public void WhenTrue(int value, GameObject view)
{
}
public void WhenFalse(int value, GameObject view)
{
}
public void OnCollectionChanged(ChangedKind changedKind,
int value,
GameObject view,
in NotifyCollectionChangedEventArgs<int> eventArgs)
{
Debug.Log($"Action={eventArgs.Action}\tChangedKind={changedKind}");
// eventArgs.Actionは「大本のコレクションで何が起きたか?」
// ここではReplaceに限定する
if (eventArgs.Action != NotifyCollectionChangedAction.Replace) return;
// Replace起因でViewのAddが発生したことがここでわかるので、
// その場合にViewの要素の並び替えを行う
if (changedKind == ChangedKind.Add)
{
// ヒエラルキーの順序がViewの並び順に一致している
view.transform.SetSiblingIndex(eventArgs.NewStartingIndex);
}
}
}
// あとは通常通り「要素が増えたら新しいViewを作る」「要素が減ったらViewを消す」をまず実行し、
// Replaceは「新しく増えたViewの位置を調整する」で実現する
public class SynchronizedViewWithReplaceSample : MonoBehaviour
{
public Button prefab;
public GameObject root;
ObservableFixedSizeRingBuffer<int> _model;
ISynchronizedView<int, GameObject> _synchronizedView;
private readonly int MaxCapacity = 5;
void Start()
{
_model = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);
// Modelに要素が増えたらViewを追加する
_synchronizedView = _model.CreateView(AddView);
// 要素を消す&Replaceするフィルターを設定
_synchronizedView.AttachFilter(new FilterMux<int, GameObject>(
new GameObjectRemoveFilter<int>(),
new ReplaceFilter()));
}
private GameObject AddView(int value)
{
// IObservableCollection<T>の要素が追加されたときに実行される
var item = Instantiate(prefab, root.transform);
item.GetComponentInChildren<Text>().text = value.ToString();
return item.gameObject;
}
private void Update()
{
// キーを押すごとに要素を1つ追加していく
if (Input.GetKeyDown(KeyCode.A))
{
_model.AddLast(Time.frameCount);
}
if (Input.GetKeyDown(KeyCode.S))
{
// とりあえず真ん中の要素を0で上書きする
_model[2] = 0;
}
}
void OnDestroy()
{
// Destroy時に紐づいたViewも一緒に消す
foreach (var (_, view) in _synchronizedView)
{
if (view != null) Destroy(view);
}
_synchronizedView.Dispose();
}
}
(Replace
発生時にView
のAdd
とRemove
がセットで発生している。このイベントを駆使してReplace
時にView
を適切な場所に移動させる。)
今回はView
の並び替えが簡単に済みましたが、もっとView
の構成が複雑になる場合は「Replace
が来たらView
を全部消してイチから再構築する」もありかもしれません。
とりあえずObservableCollections
は「View
要素の使い回しには対応していない」です。
Viewのソートをする
ObservableCollections
にはSortedView
という機能があります。
こちらはソート機能付きのISynchronizedView<T, TView>
であり、インタフェースはISynchronizedView<T, TView>
のまま変わりありません。
使い方としては、CreateView
の代わりにCreateSortedView
を用いてISynchronizedView<T, TView>
を生成してあげればOKです。
ただし内部でSortedDictionary
が用いられている都合上、「T
のどの要素をKey
として使うか」「T
またTView
の順序比較をどうするか」を指定する必要があります。
例
// なんか適当なオブジェクトがあったとして
// 出席番号 + 名前みたいな?
public readonly struct NameTag : IEquatable<NameTag>
{
public int Index { get; }
public string Name { get; }
public NameTag(int index, string name)
{
Index = index;
Name = name;
}
public bool Equals(NameTag other)
{
return Index == other.Index && Name == other.Name;
}
public override bool Equals(object obj)
{
return obj is NameTag other && Equals(other);
}
public override int GetHashCode()
{
return HashCode.Combine(Index, Name);
}
}
// NameTagの順序を何に基づいて行うか
public sealed class NameTagComparer : IComparer<NameTag>
{
public int Compare(NameTag x, NameTag y)
{
return x.Index - y.Index;
}
}
public class SynchronizedSortedViewSample : MonoBehaviour
{
public Button prefab;
public GameObject root;
ObservableFixedSizeRingBuffer<NameTag> _model;
ISynchronizedView<NameTag, GameObject> _synchronizedView;
private readonly int MaxCapacity = 5;
void Start()
{
_model = new ObservableFixedSizeRingBuffer<NameTag>(MaxCapacity);
// ソート機能付きISynchronizedViewを定義する
_synchronizedView = _model.CreateSortedView(
x => $"{x.Index}:{x.Name}", // NameTagからKeyを生成するロジック
AddView, // NameTag -> View
new NameTagComparer() // NameTagをどのルールに基づいてソートするか
);
// 要素削除フィルターの追加
_synchronizedView.AttachFilter(new GameObjectRemoveFilter<NameTag>());
// ISynchronizedViewの要素が変動したら、順序に一致するようにViewをすべて並び替える
_synchronizedView.CollectionStateChanged += _ =>
{
foreach (var (_, view, index) in _synchronizedView.Select((x, i) =>
(x.Value, x.View, Index: i)))
{
view.transform.SetSiblingIndex(index);
}
};
}
private GameObject AddView(NameTag value)
{
// IObservableCollection<T>の要素が追加されたときに実行される
var item = Instantiate(prefab, root.transform);
item.GetComponentInChildren<Text>().text = $"{value.Index}:{value.Name}";
return item.gameObject;
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.A))
{
// キー入力で適当に生成して詰める
var randomName = new string(Enumerable.Repeat("あいうえおかきくけこ", 3)
.Select(x => x[UnityEngine.Random.Range(0, 10)])
.ToArray());
var randomIndex = UnityEngine.Random.Range(1, 100);
_model.AddLast(new NameTag(randomIndex, randomName));
}
}
void OnDestroy()
{
if (_synchronizedView == null) return;
// Destroy時に紐づいたViewも一緒に消す
foreach (var (_, view) in _synchronizedView)
{
if (view != null) Destroy(view);
}
_synchronizedView.Dispose();
}
}
SortedView
を用いることで、元のコレクションのデータ構造ではなくISynchronizedView<T, TView>
側でソートを実現することができます。
感想
UnityにおいてObservableCollections
を「リアクティブなコレクション」として見た場合は、結構便利に使えます。
UniRx.ReactiveCollection
と比較するとObservableCollections
の方がサポートしているデータ構造が多いため、その点ではベターUniRx
として使えます。
ではUIへの応用はどうかというと、ISynchronizedView<T, TView>
を用いることで複数データを扱うUI周りの実装が結構楽になるという印象です。
とりあえずObservableCollections
を使ってそれに則って組めるようになればチーム開発でも統一した実装パターンにできるのではないかと感じます。
一方で、そもそもUnityがMVVM
パターンをサポートしてくれていないため、ObservableCollections
の真価を完全に引き出せていない感も否めません。
またISynchronizedView<T, TView>
自体に癖があり、若干の扱いづらさもあります。
まとめると「やりたいことを満たす機能は揃って入るが、扱いに癖があり使いこなすのは大変そう」という感想です。
とはいえ、面白さや応用性は感じるので自分は使っていこうと思います。