3
5

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

Last updated at Posted at 2024-03-24

はじめに

この記事は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から各サンプルを入れることができます。

image.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

ボタンを押すと数字がインクリメントされていくもののようです。
次にコンテナ的な役割の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に伝わります。

image.png

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

image.png

ViewModel

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

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

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

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

image.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

StoryBookPage

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

Button.stories.csButtonPageを見てみましょう。

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.csButtonComponentを見てみましょう。

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

活用

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

image.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

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

image.png

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

3
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
5