【Unity】AppUI×ClaudiaでMVVMを意識してチャットアプリをつくる 2章 AppUI編

# はじめに
この記事は1章の続きです。
まだ読んでいない人は1章から読まれることを推奨します。

1章 [設計説明編](https://qiita.com/garume/items/d3edba72548f51108e7c)
2章 [AppUI編](https://qiita.com/garume/items/9e83eb7a3ce7b8c12594)　
3章 [MVVM実装編](https://qiita.com/garume/items/21840bc9edd70f40503b)
4章 [アプリ実装編](https://qiita.com/garume/items/5a26324cbbbeb2d32dc0)
5章 [Middleware編](https://qiita.com/garume/items/3f4c2f4b2de04b602e58)

# AppUI
AppUIとは、「Unity上でアプリ風なUIを構築できるフレームワーク」でありつい先月公開されたものになります。
↓ドキュメント

https://docs.unity3d.com/Packages/com.unity.dt.app-ui@1.0/manual/index.html

このAppUIはかなりサンプルが充実しています。
本章ではこれらのうちの次の4つを解説して3章につなげたいと思います。
- MVVM
- StoryBook
- Redux
- MVVM And Redux

<details><summary>ただの愚痴</summary>

~~まあ正直な話Uitoolkitなんて流行ってないし、マニアックな人しか使ってないし、絶対このAppUIも流行らずに消えるし... というか開発中のGraphTools Foundationどうなっとんねん~~

~~まあ、界隈的にC#に統一する流れが来ているしC#統一委員会下っ端野良会員として誰かの助けになればと思いこの記事を書いています。~~

流行れUIToolKit
</details>

## 開発環境
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から各サンプルを入れることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/704747/81a099e5-b993-11ac-8fe7-060fb973e807.png)

# 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`にサンプルシーンがあるので開いてみましょう。

![bae208dc85964d861e30cb04c75fe9ed.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/704747/20e9dda3-f7db-1e02-fe68-91bc68c22b77.gif)

ボタンを押すと数字がインクリメントされていくもののようです。
次にコンテナ的な役割のAppBuilder,Model,ViewModel,Viewをそれぞれ見ていきます。

## UIToolkitAppBuilder
UIDocumentをホストとしてアプリを構築するためのエントリーポイントのような使い方になるっぽいです。

ライフサイクルとして
- OnConfiguringApp
- OnAppInitialized
- OnAppShuttingDown

をオーバーライドできるようです。
`OnConfiguringApp → OnAppInitialized → OnAppShuttingDown`の順に発火されます。

OnConfiguringAppでは引数にAppBuilderが与えられ、インジェクションしたいクラスを注入して依存関係を解消することができます。
これを利用することでMVVMを実現できるようです。
(ViewModelにModelを注入する等)

今回の場合以下のようになっているようです。
```csharp
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>();
}
```

<details><summary>Editor上で動かす場合</summary>

UIToolkitAppBuilderはMonoBehaviourを継承しているため、Editor上で動かすことができません。
実装を見る感じ、IUIToolkitHostを実現したEditorUIToolkitとEditorUIToolkitを新しく実装する必要がありそうです。
幸運なことにもともとの☆神☆設計のおかげで簡単に実装できそうです。
需要があれば別記事にしてみてもよいと思っています。

</details>

### App
実際に動作するPage(View)を管理するクラスになります。
サンプルではAppを継承したMyAppのコンストラクタ内でmainPageを設定しています。
このmainPageがアプリのViewとして機能するようです。


## View

Viewは`MainPage`にあたります。
本来MVVMのViewはUXMLなどに直接バインドすることが多いですが、AppUIではできないようです。

VisualElementの扱いに関してはここでは説明しません。わからない方は調べてみてください。
さてコードを見てみると、コンストラクタで依存の注入が行われているようです。

```csharp
public MainPage(MainViewModel viewModel)
{
```

MyAppBuilder内の以下のコードでもあるように MainViewModelはMainPageをインスタンス化する際に自動的に注入されます。
```csharp
builder.services.AddSingleton<MainViewModel>();
```

次に`OnBindingContextPropertyChanged`ではViewModelの値をバインドしています。
引数の`PropertyChangedEventArgs`を用いてプロパティ名を取得できるのでこれを使用してバインドします。
そうすることで、ViewModelの値が変わった際にOnBindingContextPropertyChangedが発火されViewに伝わります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/704747/042d8fc8-6e02-f5d0-aee0-2423a1908ff2.png)


最後に`OnButtonClicked`ですが、これはボタンの押下をViewModelに伝えます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/704747/ac13fce8-e91d-78ae-1bc1-61e7c234ee67.png)

## ViewModel
値を通知するために`ObservableObject`を継承しています。
`ObservableObject`には`SetProperty`が実装されていて、この関数が呼ばれた時に`PropertyChanging`、`PropertyChanged`が発火されます。

本サンプルの`MainViewModel`では、値の表示に必要な`counter`のみを保持しています。

```csharp
public int counter
{
    get => m_Counter;
    set => SetProperty(ref m_Counter, value);
}
```

`counter`が変更された際にSetPropertyが呼ばれ、`PropertyChanging`、`PropertyChanged`が発火されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/704747/ac1caad1-b746-3be3-d006-72924c9c44ea.png)

## 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` からウィンドウを開けます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/704747/11bbf9db-8b03-9a66-333c-3adcdcde46c1.png)

## StoryBookPage
StoryBookにページを追加したい場合、StoryBookPageを継承するようです。

`Button.stories.cs`の`ButtonPage`を見てみましょう。

```csharp
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`を見てみましょう。
```csharp
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を動的に変更できるようです。

![b620f30c33d776218b1e1a1b93a823ca.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/704747/f1ac8569-eeff-9845-c39e-89e1058a0381.gif)

## 活用

このStoryBook、ロジックは組み込まれていないためボタン等を押下しても何も起きません。
そのためView単体のテストとしての活用が期待できます。
サンプルのMVVMでのテストを考えてみましょう。

`ViewModel`はそのままインスタンス化すればテスト可能です。
(テスト書くためにMVVMやってるところもあるのでここはすべて書きます。)
```csharp
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`と掘ってください。

```csharp
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を開けるようになっていると思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/704747/ebf0658d-cc76-3df5-d668-45e110e1b7f9.png)



# 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`にサンプルシーンがあるので開いてみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/704747/09862525-821f-c5d3-647b-62f9faad2e08.png)

Reduxのデータフローを追えるようです。
コードからも追っていきましょう。

## Store
主要な箇所を抜き出して解説していきます。

```csharp

// 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つだけでないといけない。
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を定義して使用していますが、わざわざ定義する必要はありません。
以下でも可能です。
```csharp
store.Dispatch(INCREMENT_COUNTER);
store.Dispatch(INCREMENT_COUNTER_BY,10);
```

## State
`CounterState`は以下のようにrecordとして実装されています。
これは不変の型を簡単に作成するためです。
```csharp
public record CounterState(int value)
{
    public int value { get; set; } = value;
}
```

## Reducer
次にActionがDispatchされるときに実際に呼ばれるReducerの中身を見ていきます。
これは結構単純で、新たにStateを作成して返しているだけです。

```csharp
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`にサンプルシーンがあるので開いてみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/704747/c60c02b8-7165-bb17-0fce-2459b0b09e37.png)

TODOを作成するアプリのようです。

今回の場合少し複雑ですが、MVVMのModelがReduxに置き換わっただけです。
なので今まで出てこなかった`Command`のみ解説しようと思います。

## Command
CommandはViewからViewModelのイベントを発火させるために使用されます。
通常の実装はDispatchのみですが、ロジックもここに記載することを想定しているようです。
非同期処理もここに実装しているようです。

(Middlewareのような機能を追加で実装するとよりよくなる気もしますがそのうちやってみたいですね...)


### MainViewModel
```csharp
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
```csharp
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章では、具体的にチャットアプリの開発に取り組んでいきます。