はじめに
この記事は1章の続きです。
まだ読んでいない人は1章から読まれることを推奨します。
1章 設計説明編
2章 AppUI編
3章 MVVM実装編
4章 アプリ実装編
5章 Middleware編
AppUI
AppUIとは、「Unity上でアプリ風なUIを構築できるフレームワーク」でありつい先月公開されたものになります。
↓ドキュメント
このAppUIはかなりサンプルが充実しています。
本章ではこれらのうちの次の4つを解説して3章につなげたいと思います。
- MVVM
- StoryBook
- Redux
- MVVM And Redux
ただの愚痴
まあ正直な話Uitoolkitなんて流行ってないし、マニアックな人しか使ってないし、絶対このAppUIも流行らずに消えるし... というか開発中のGraphTools Foundationどうなっとんねん
まあ、界隈的にC#に統一する流れが来ているしC#統一委員会下っ端野良会員として誰かの助けになればと思いこの記事を書いています。
流行れUIToolKit
開発環境
Unity 2022.3.21f1
App UI 1.0.5
AppUIのセットアップ
1.Window > Package ManagerからPackage Managerを開く
2.「+」ボタン > Add package by name をクリック
3. 以下を入力してインストール
com.unity.dt.app-ui
インストール後Samplesから各サンプルを入れることができます。
MVVM
SamplesからMVVM
を入れましょう。
また、MVVMに関する説明は1章でしていますので、まだ読んでいない方はそちらを読んでください。
フォルダ構造
MVVM
├── SampleResources
│ ├── App UI Settings.asset
│ ├── MVVMPanel.asset
│ └── MVVMTheme.tss
├── Scenes
│ └── MVVM.unity
└── Scripts
├── ViewModels
│ └── MainViewModel.cs
├── Views
│ └── MainPage.cs
├── MyApp.cs
├── MyAppBuilder.cs
└── Unity.Replica.AppUI.Samples.MVVM.asmdef
サンプルシーン
Assets/Samples/App UI/1.0.5/MVVM/Scenes/MVVM.unity
にサンプルシーンがあるので開いてみましょう。
ボタンを押すと数字がインクリメントされていくもののようです。
次にコンテナ的な役割のAppBuilder,Model,ViewModel,Viewをそれぞれ見ていきます。
UIToolkitAppBuilder
UIDocumentをホストとしてアプリを構築するためのエントリーポイントのような使い方になるっぽいです。
ライフサイクルとして
- OnConfiguringApp
- OnAppInitialized
- OnAppShuttingDown
をオーバーライドできるようです。
OnConfiguringApp → OnAppInitialized → OnAppShuttingDown
の順に発火されます。
OnConfiguringAppでは引数にAppBuilderが与えられ、インジェクションしたいクラスを注入して依存関係を解消することができます。
これを利用することでMVVMを実現できるようです。
(ViewModelにModelを注入する等)
今回の場合以下のようになっているようです。
protected override void OnConfiguringApp(AppBuilder builder)
{
base.OnConfiguringApp(builder);
Debug.Log("MyAppBuilder.OnConfiguringApp");
// Add services here
// Add ViewModels and Views as services
builder.services.AddSingleton<MainViewModel>();
builder.services.AddSingleton<MainPage>();
}
Editor上で動かす場合
UIToolkitAppBuilderはMonoBehaviourを継承しているため、Editor上で動かすことができません。
実装を見る感じ、IUIToolkitHostを実現したEditorUIToolkitとEditorUIToolkitを新しく実装する必要がありそうです。
幸運なことにもともとの☆神☆設計のおかげで簡単に実装できそうです。
需要があれば別記事にしてみてもよいと思っています。
App
実際に動作するPage(View)を管理するクラスになります。
サンプルではAppを継承したMyAppのコンストラクタ内でmainPageを設定しています。
このmainPageがアプリのViewとして機能するようです。
View
ViewはMainPage
にあたります。
本来MVVMのViewはUXMLなどに直接バインドすることが多いですが、AppUIではできないようです。
VisualElementの扱いに関してはここでは説明しません。わからない方は調べてみてください。
さてコードを見てみると、コンストラクタで依存の注入が行われているようです。
public MainPage(MainViewModel viewModel)
{
MyAppBuilder内の以下のコードでもあるように MainViewModelはMainPageをインスタンス化する際に自動的に注入されます。
builder.services.AddSingleton<MainViewModel>();
次にOnBindingContextPropertyChanged
ではViewModelの値をバインドしています。
引数のPropertyChangedEventArgs
を用いてプロパティ名を取得できるのでこれを使用してバインドします。
そうすることで、ViewModelの値が変わった際にOnBindingContextPropertyChangedが発火されViewに伝わります。
最後にOnButtonClicked
ですが、これはボタンの押下をViewModelに伝えます。
ViewModel
値を通知するためにObservableObject
を継承しています。
ObservableObject
にはSetProperty
が実装されていて、この関数が呼ばれた時にPropertyChanging
、PropertyChanged
が発火されます。
本サンプルのMainViewModel
では、値の表示に必要なcounter
のみを保持しています。
public int counter
{
get => m_Counter;
set => SetProperty(ref m_Counter, value);
}
counter
が変更された際にSetPropertyが呼ばれ、PropertyChanging
、PropertyChanged
が発火されます。
Model
今回のサンプルでは必要がないためかありませんでした。
StoryBook
SamplesからStoryBook
を入れましょう。
StoryBookはView単体をテストするためのものです。
いい感じのWindowが追加されます。
フォルダ構造
StoryBook
└── Editor
├── AppBar.stories.cs
├── BottomNavBar.stories.cs
├── Button.stories.cs
├── Drawer.stories.cs
├── DrawerHeader.stories.cs
├── FAB.stories.cs
├── Link.stories.cs
├── ListViewItem.stories.cs
├── MenuItem.stories.cs
└── TabView.stories.cs
使い方
Window → AppUI → StoryBook
からウィンドウを開けます。
StoryBookPage
StoryBookにページを追加したい場合、StoryBookPageを継承するようです。
Button.stories.cs
のButtonPage
を見てみましょう。
public class ButtonPage : StoryBookPage
{
public override string displayName => "Button";
public override Type componentType => typeof(ButtonComponent);
public ButtonPage()
{
m_Stories.Add(new StoryBookStory("Primary", () => new Button
{
variant = ButtonVariant.Accent,
title = "Primary Style Button"
}));
}
}
displayPage
は表示名
componentType
は型
を表しています。
追加でページを追加したい場合は、コンストラクタ内でm_Stories
に追加してあげることでページを自動生成してくれるようです。
StoryBookComponent
動的に値を変更したい場合にStoryBookComponentを継承したクラスをcomponentType
にセットしてくれるようです。
Button.stories.cs
のButtonComponent
を見てみましょう。
public class ButtonComponent : StoryBookComponent
{
public override Type uiElementType => typeof(Button);
public ButtonComponent()
{
m_Properties.Add(new StoryBookEnumProperty(
nameof(Button.variant),
(btn) => ((Button)btn).variant,
(btn, val) => ((Button)btn).variant = (ButtonVariant)val));
m_Properties.Add(new StoryBookStringProperty(
nameof(Button.title),
(btn) => ((Button)btn).title,
(btn, val) => ((Button)btn).title = val));
}
}
uiElementType
はコンポーネントの型
動的に変更したい値をm_Properties
に追加するようです。
この例では、enumのvariant,stringのtitleを動的に変更できるようです。
活用
このStoryBook、ロジックは組み込まれていないためボタン等を押下しても何も起きません。
そのためView単体のテストとしての活用が期待できます。
サンプルのMVVMでのテストを考えてみましょう。
ViewModel
はそのままインスタンス化すればテスト可能です。
(テスト書くためにMVVMやってるところもあるのでここはすべて書きます。)
using NUnit.Framework;
using Unity.AppUI.Samples.MVVM;
[TestFixture]
public class MainViewModelTests
{
[Test]
public void Constructor_SetsCounterToZero()
{
// Arrange
var viewModel = new MainViewModel();
// Act (Constructor is called during object creation)
// Assert
Assert.That(viewModel.counter, Is.EqualTo(0));
}
[Test]
public void IncrementCounter_IncrementsCounterByOne()
{
// Arrange
var viewModel = new MainViewModel();
// Act
viewModel.IncrementCounter();
// Assert
Assert.That(viewModel.counter, Is.EqualTo(1));
}
[Test]
public void Counter_SetValue_UpdatesCounter()
{
// Arrange
var viewModel = new MainViewModel();
var random = new Random();
int randomValue = random.Next();
// Act
viewModel.counter = randomValue;
// Assert
Assert.That(viewModel.counter, Is.EqualTo(randomValue));
}
}
テストの実行方法は調べてみてください。上のコードはNUnitを使用しています。
では、Viewはどうテストすればよいでしょうか?
そんなときに役立つのがStoryBookだと思っています。
早速使用してテストしてみましょう。
以下のようなスクリプトを書いてください。
Scripts/Editor/MVVM.stories.cs
と掘ってください。
using Unity.AppUI.Editor;
namespace Unity.AppUI.Samples.MVVM
{
public class MVVMStoryPage : StoryBookPage
{
public override string displayName => "MVVM";
public MVVMStoryPage()
{
m_Stories.Add(new StoryBookStory("default", () =>
{
var viewModel = new MainViewModel();
var view = new MainPage(viewModel);
return view;
}));
}
}
}
また、AppUI.Editor
のアセンブリの参照が必要なためUnity.Replica.AppUI.Samples.MVVM.asmdef
を追加してください。
正しく追加できるとエラーがなくなり、StoryBookからMVVMを開けるようになっていると思います。
Redux
SamplesからRedux
を入れましょう。
Reduxに関しては1章で説明していますので、まだ読んでいない方はそちらを読んでください。
フォルダ構造
Redux
├── Scenes
│ └── Redux.unity
└── Scripts
├── CounterState.cs
├── Reducers.cs
├── ReduxSample.cs
└── Unity.Replica.AppUI.Samples.Redux.asmdef
サンプルシーン
Redux/Scenes/Redux.unity
にサンプルシーンがあるので開いてみましょう。
Reduxのデータフローを追えるようです。
コードからも追っていきましょう。
Store
主要な箇所を抜き出して解説していきます。
// State名として"counter"を定義しています
const string COUNTER_SLICE = "counter";
// 数をインクリメントするためのAction名として"counter/Increment"を定義しています。
// この名前は必ず"{State名}/{Action名}"でなければいけません。
const string INCREMENT_COUNTER = "counter/Increment";
const string INCREMENT_COUNTER_BY = "counter/IncrementBy";
// Storeをインスタンス化する。
// これは1つのアプリケーションに1つだけでないといけない。
var store = new Store();
// IncrementするActionを定義している。引数はvoidであるためDispatchする際に引数は必要ない。
var incrementAction = Store.CreateAction(INCREMENT_COUNTER);
// IncrementするActionを定義している。引数はintであるためDispatchする際にintが必要。
var incrementByAction = Store.CreateAction<int>(INCREMENT_COUNTER_BY)
// StoreにStateを追加する。
// State名はCOUNTER_SLICE
// Stateの初期値は new CounterState(0)
// 第3引数でAction名とReducerを紐づける
var slice = store.CreateSlice(
COUNTER_SLICE,
new CounterState(0),
(builder) =>
{
builder.Add(INCREMENT_COUNTER, Reducers.Increment);
});
// Store.GetState(State名)でStateを入手できる。
Debug.Log($"[State]: Counter state is {store.GetState<CounterState>(COUNTER_SLICE)}");
// Dispath()を呼んでStateを更新する。
// Action.Invoke()を呼ぶ必要があることに注意。
store.Dispatch(incrementAction.Invoke());
store.Dispatch(incrementByAction.Invoke(10));
Dispatchする際にActionを定義して使用していますが、わざわざ定義する必要はありません。
以下でも可能です。
store.Dispatch(INCREMENT_COUNTER);
store.Dispatch(INCREMENT_COUNTER_BY,10);
State
CounterState
は以下のようにrecordとして実装されています。
これは不変の型を簡単に作成するためです。
public record CounterState(int value)
{
public int value { get; set; } = value;
}
Reducer
次にActionがDispatchされるときに実際に呼ばれるReducerの中身を見ていきます。
これは結構単純で、新たにStateを作成して返しているだけです。
public static class Reducers
{
public static CounterState Increment(CounterState state, Action action)
{
return state with { value = state.value + 1 };
}
public static CounterState IncrementBy(CounterState state, Action<int> amount)
{
return state with { value = state.value + amount.payload };
}
}
MVVM And Redux
SamplesからMVVM And Redux
を入れましょう。
フォルダ構造
MVVM And Redux
├── SampleResources
│ └── MVVMRedux
│ ├── Styles
│ │ ├── MVVMReduxSamplePanel.asset
│ │ ├── MVVMReduxSampleStyling.uss
│ │ └── MVVMReduxSampleTheme.tss
│ └── Views
│ ├── MainPage.uxml
│ └── TodoItemView.uxml
├── Scenes
│ └── MVVMRedux.unity
└── Scripts
├── Models
│ ├── AppState.cs
│ └── Todo.cs
├── Services
│ ├── ILocalStorageService.cs
│ ├── IStoreService.cs
│ ├── LocalStorageService.cs
│ └── StoreService.cs
├── ViewModels
│ ├── MainViewModel.cs
│ └── TodoItemViewModel.cs
├── Views
│ ├── MainPage.cs
│ └── TodoItemView.cs
├── MVVMReduxApp.cs
├── MVVMReduxAppBuilder.cs
└── Unity.Replica.AppUI.Samples.MVVMRedux.asmdef
サンプルシーン
MVVM And Redux/Scenes/MVVMRedux.unity
にサンプルシーンがあるので開いてみましょう。
TODOを作成するアプリのようです。
今回の場合少し複雑ですが、MVVMのModelがReduxに置き換わっただけです。
なので今まで出てこなかったCommand
のみ解説しようと思います。
Command
CommandはViewからViewModelのイベントを発火させるために使用されます。
通常の実装はDispatchのみですが、ロジックもここに記載することを想定しているようです。
非同期処理もここに実装しているようです。
(Middlewareのような機能を追加で実装するとよりよくなる気もしますがそのうちやってみたいですね...)
MainViewModel
public class MainViewModel : ObservableObject
{
public AsyncRelayCommand<string> searchTodoCommand { get; }
public MainViewModel(IStoreService storeService, ILocalStorageService localStorageService)
{
...
searchTodoCommand = new AsyncRelayCommand<string>(SearchTodo, AsyncRelayCommandOptions.None);
...
}
async Task SearchTodo(string input, CancellationToken cancellationToken)
{
// store the input in the state
m_StoreService.store.Dispatch(Actions.setSearchInput, input);
await SearchInternal(input, cancellationToken);
}
}
AsyncRelayCommandOptions
は並列に実行するかどうかを指定するオプションです。
MainPage
public class MainPage : VisualElement
{
...
void InitializeComponent()
{
...
m_SearchTextField.RegisterValueChangingCallback(OnSearchTextChanged);
}
void OnSearchTextChanged(ChangingEvent<string> evt)
{
if (evt.newValue != evt.previousValue)
{
m_ViewModel.searchTodoCommand.Execute(evt.newValue);
}
}
}
以上のように使用できます。
ComandはCanExecuteをデリゲートとして挟むことができ実行できるか否かも柔軟に指定することができます。
2章 おわりに
AppUIについて、サンプルを通して解説しました。
3章では、具体的にチャットアプリの開発に取り組んでいきます。