3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-03-24

はじめに

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

この章では1章2章で説明したことを踏まえて実際にチャットアプリを実装していこうと思います。
記事を通じて疑問点があれば、ぜひフィードバックをください。

1章 設計説明編
2章 AppUI編 
3章 MVVM実装編
4章 アプリ実装編
5章 Middleware編

仕様を考える

まずは仕様を考えていきましょう。
主要な機能は以下の通りです。

  1. ユーザーがメッセージを入力する
  2. 送信するとClaudeの返信が来る
  3. 返信に対してさらにユーザーがメッセージを入力する
  4. 送信すると... (以下繰り返し)

今回はかなり単純なものでむしろ考える必要もないくらいですが、規模の大きいアプリの場合ここらへんを整理しておくことは重要だと思います。
なぜならば仕様が整理できていないと設計もできないからですね。

次は実際に設計に移っていきましょう。

設計を考える

1章で説明した通り、MVVM×Reduxを採用して設計していこうと思います。

概念整理

設計するにあたって、仕様から登場する概念を整理していきます。

Viewに必要なもの

まずはViewに何が必要か。

  • メッセージを入力するUI
  • メッセージを送信するボタン
  • システムメッセージを入力するUI
  • temperatureを入力できるスライダー
  • Claudeからのメッセージを出力するUI

メッセージを入出力するUIに関しては複数行あるテキストボックスのようなものを想定しています。

ViewModelに必要なもの

次にViewModelを整理していきましょう。
Viewと1対1で対応するようにします。

  • 入力されたメッセージをセットするCommand
  • メッセージを送信するCommand
  • 入力されたシステムメッセージをセットするCommand
  • 入力されたtemperatureをセットするCommand
  • Claudeから出力されたメッセージを保持するプロパティ

Modelに必要なもの

最後にModelを整理します。
ここはModelというよりは、ReduxのStateとして何が必要かと考えるほうが今回の場合適切かもしれません。

  • 入力されたメッセージ
  • システムメッセージ
  • temperature
  • Claudeから出力されたメッセージ

また、Claudiaを使う際にAPIKeyとAnthropicが必要なためこれも保持しておきます。

  • ApiKey
  • Anthropicのインスタンス

クラス設計

整理した概念から実際にクラス図を作成していこうと思います。
今回は各レイヤーでテストを書きたいと思っているのでいい感じにインターフェースを切り出します。
正しいかどうかを判断することは難しいと思います。こんな感じになったんだなーと読み飛ばしてもらって構いません。

レイヤーで分けると

  • EntryPoint
  • View
  • ViewModel
  • Redux Store
  • AppState (Model)

の5つになりそうです。

整理を参考に要素を追加してあげると...

こんな感じになりそうです。

AppUIも含めて最終的に以下のようになりました。

これに従って実際に実装していきたいと思います。

実装

開発環境

Unity 2022.3.21f1
AppUI 1.0.5
Claudia 1.0.0
NuGetForUnity 4.0.2

セットアップ

AppUIのセットアップ方法は2章で説明していますので割愛します。
今回はClaude APIを使用したいのでこれをサポートしたClaudiaを入れていきます。

ClaudiaはNuGetのパッケージなのでまずはNuGetForUnityをセットアップする必要があります。

NuGetForUnityのセットアップ

NuGetForUnityはNuGetのパッケージをUnityへ導入できるようにするエディタ拡張です。

セットアップ方法は簡単で、
Package Manage → 左上の+ボタン → Add package from git URL から

https://github.com/GlitchEnzo/NuGetForUnity.git?path=/src/NuGetForUnity

を入力することできます。

Claudiaのセットアップ

次に導入したNuGetForUnityを利用してClaudiaを入れていきましょう。

NuGet → manage NuGet Packages をクリック
image.png

NuGetForUnityのウィンドウが開いたらClaudiaで検索してください。
おそらく一番上が今回欲しいパッケージClaudiaになっていると思います。
これをインストールすればセットアップは完了です。
image.png

次から実装に移っていきます。

View

まずはUXMLから実装します。

以下のようにファイルを作成してください。
Assets以下であればどこでも構いません。

フォルダ構造
+ Claudia
+ └── SampleResources
+     ├── Styles
+     │   └── MainPage.uss
+     └── Views
+         └── MainPage.uxml

MainPage.uxmlに記述していきます。
UIはClaudia Blazorのサンプルを参考にしています。
https://github.com/Cysharp/Claudia/blob/main/sandbox/BlazorApp1/Components/Pages/Home.razor
適宜変えてもらって構いません。

MainPage.uxml
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:appui="Unity.AppUI.UI" xsi="http://www.w3.org/2001/XMLSchema-instance"
         editor="UnityEditor.UIElements" noNamespaceSchemaLocation="../UIElementsSchema/UIElements.xsd"
         editor-extension-mode="False">

    <appui:Panel class="claudia-root-panel" scale="large">
        <ui:VisualElement class="claudia-root">
            <appui:Text class="claudia-title" text="UIToolKit Claudia"/>
            <ui:VisualElement class="claudia-main-content">
                <ui:VisualElement class="content">
                    <appui:TextArea class="claudia-text-area"
                                    is-read-only="true"
                                    value="Claudia is a simple UI Toolkit application that demonstrates the use of MVVM and Redux patterns."
                    />
                </ui:VisualElement>
            </ui:VisualElement>
            <ui:VisualElement class="claudia-control">
                <ui:VisualElement class="content">
                    <ui:VisualElement class="claudia-temperature">
                        <appui:Text class="claudia-temperature-title" text="Temperature"/>
                        <appui:SliderFloat class="claudia-temperature-slider" value="1.0" low-value="0"
                                           high-value="1.0" format-string="F2"/>
                    </ui:VisualElement>
                    <ui:VisualElement class="claudia-system">
                        <appui:Text class="claudia-system-title" text="System"/>
                        <appui:TextArea class="claudia-system-text-area"/>
                    </ui:VisualElement>
                </ui:VisualElement>
                <ui:VisualElement class="claudia-form">
                    <appui:TextArea class="claudia-form-text-area"/>
                    <appui:Button class="claudia-form-button" title="Send"/>
                </ui:VisualElement>
            </ui:VisualElement>
        </ui:VisualElement>
    </appui:Panel>
</ui:UXML>

次にMainPage.uxmlを記述していきます。

MainPage.uxml
MainPage {
    width: 100%;
    height: 100%;
}

.claudia-root {
    width: 100%;
    height: 100%;
    padding: var(--appui-spacing-150);
    display: flex;
    flex-direction: column;
}

.claudia-title {
    flex-grow: 0;
    flex-shrink: 0;
    -unity-text-align: middle-center;
}

.claudia-main-content {
    height: 100%;
    width: 100%;
    flex-grow: 1;
    flex-shrink: 1;

    padding: var(--appui-spacing-150);

    display: flex;
    flex-direction: column;
}

.claudia-main-content .content {
    height: 100%;
    background-color: white;
    padding: var(--appui-spacing-25);
    border-radius: var(--appui-border-radius-sm);
}

.claudia-text-area {
    flex-grow: 1;
}

.claudia-control {
    flex-grow: 0;

    width: 100%;

    background-color: white;
    padding: var(--appui-spacing-50);
    border-bottom-left-radius: var(--appui-border-radius-sm);
    border-bottom-right-radius: var(--appui-border-radius-sm);
}

.claudia-control .content {
    display: flex;
    width: 100%;
    flex-direction: row;
}

.claudia-temperature {
    flex-grow: 0;
    flex-shrink: 0;
    padding: var(--appui-spacing-25);
    margin-right: var(--appui-spacing-100);
}

.claudia-temperature-title {
    color: black;
}

.claudia-temperature-slider .unity-text-element {
    color: black;
}

.claudia-system-title {
    color: black;
}

.claudia-system {
    flex-grow: 1;
    flex-shrink: 1;
}

.claudia-system-text-area {
    min-height: auto;
    height: 80%;
}

.claudia-form {
    width: 100%;
    display: flex;
    flex-direction: row;
    align-items: center;
}

.claudia-form-text-area {
    flex-grow: 1;
    flex-shrink: 1;
    min-height: auto;
    max-height: 70%;

    margin-right: var(--appui-spacing-25);
}

.claudia-form-button {
    flex-grow: 0;
    flex-shrink: 0;
}

.appui-textarea__resize-handle {
    display: none;
}

これで一旦Viewの実装は終わりです。

UXMLのテスト

次に作ったUXMLを実際にテストしていきます。
UIDocumentをScene上に置けばできますが、PlayModeに入らないと確認できないため少しだるいです。
そこで2章で説明したStoryBookを使用しましょう。
StoryBookを使用することでEditor上にて確認できます。

次のようにファイルを作成してください。

フォルダ構造
  Claudia
  ├── SampleResources
  │   ├── Styles
  │   │   └── MainPage.uss
  │   └── Views
  │       └── MainPage.uxml
+ └── Scripts
+     └── Tests
+         └── Editor
+             ├── MainPage.stories.cs
+             └── ClaudiaApp.Test.Editor.asmdef

ClaudiaApp.Test.Editor.asmdefはUnity上から作成することができます。
Assets → Create → AssemblyDefinitionと辿ってください。
image.png

作成できたら、アセンブリファイルを下図のように設定してください。
image.png

次にMainPage.stories.csを記述していきます。
各々の環境によりGUIDは変わるため、調べて変えてください。

GUIDの確認方法

metaファイルを見れば、guidの欄に書いてあります。
例えばMainPage.uxmlのGUIDを確認したい場合はMainPage.uxml.metaを見てください。

image.png

MainPage.stories.cs
using System;
using Unity.AppUI.Editor;
using UnityEditor;
using UnityEngine.UIElements;

namespace ClaudiaApp.Tests.Editor
{
    public class MainPage : StoryBookPage
    {
        private const string MainPageUxmlGuid = "自分のMainPage.uxmlのGUIDを調べる";
        private const string MainPageUssGuid = "自分のMainPage.ussのGUIDを調べる";

        public MainPage()
        {
            m_Stories.Add(new StoryBookStory("Main", () =>
            {
                var element = new VisualElement();
                var tree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
                    AssetDatabase.GUIDToAssetPath(MainPageUxmlGuid));
                tree.CloneTree(element);

                // StoryBookの表示部分はPanel下に置かれる。
                // MainPage.uxmlもrootがPanelであるため、その下のVisualElementを取り出している
                var root = element.Q<VisualElement>(className: "claudia-root");
                root.styleSheets.Add(
                    AssetDatabase.LoadAssetAtPath<StyleSheet>(AssetDatabase.GUIDToAssetPath(MainPageUssGuid)));

                return root;
            }));
        }

        public override string displayName => "Claudia-MainPage";
        public override Type componentType => null;
    }
}

ここまでできたらStoryBookを確認してみてください。
作成したUXMLが描画されているはずです。

image.png

Viewの実装

次にViewを実装していきます。

以下のようにファイルを作成してください。

フォルダ構造
Claudia
├── SampleResources
│   ├── Styles
│   │   └── MainPage.uss
│   └── Views
│       └── MainPage.uxml
└── Scripts
+    ├── Runtime
+    │   ├── ViewModels
+    │   │   ├── IMainViewModel.cs    
+    │   │   └── MockMainViewModel.cs
+    │   ├── View
+    │   │   └── MainPage.cs
+    │   └── ClaudiaUiToolKit.asmdef
     └── Tests
         └── Editor
             ├── MainPage.stories.cs
             └── Tests.asmdef

ClaudiaUiToolKit.asmdefの設定は以下のようにしてください。
image.png

IMainViewModel.csを記述していきます。

IMainViewModel.cs
using System.Collections.Generic;
using Claudia;
using Unity.AppUI.MVVM;

namespace ClaudiaApp.ViewModels
{
    public interface IMainViewModel: INotifyPropertyChanged
    {
        public RelayCommand<float> SetTemperatureCommand { get; }
        public RelayCommand<string> SetSystemMessageCommand { get; }
        public RelayCommand<string> SetInputMessageCommand { get; }
        public AsyncRelayCommand SendMessageCommand { get; }

        public List<Message> ChatMessage { get; set; }
    }
}

次にMockMainViewModel.cs

MockMainViewModel.cs
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Claudia;
using Unity.AppUI.MVVM;
using UnityEngine;

namespace ClaudiaApp.ViewModels
{
    public class MockMainViewModel : IMainViewModel
    {
        public MockMainViewModel()
        {
            ChatMessage = new List<Message>();
            SetTemperatureCommand = new RelayCommand<float>(v => Debug.Log(v));
            SetSystemMessageCommand = new RelayCommand<string>(Debug.Log);
            SetInputMessageCommand = new RelayCommand<string>(Debug.Log);
            SendMessageCommand = new AsyncRelayCommand(async () =>
            {
                await Task.Delay(1000);
                ChatMessage.Add(new Message { Role = Roles.User, Content = "Message sent" });
                OnPropertyChanged(nameof(ChatMessage));
            });
        }

        public RelayCommand<float> SetTemperatureCommand { get; }
        public RelayCommand<string> SetSystemMessageCommand { get; }
        public RelayCommand<string> SetInputMessageCommand { get; }
        public AsyncRelayCommand SendMessageCommand { get; }

        public List<Message> ChatMessage { get; set; }
        public event PropertyChangedEventHandler PropertyChanged;

        protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

最後にMainPage.cs

Mainpage.cs
using System.ComponentModel;
using System.Linq;
using ClaudiaApp.ViewModels;
using Unity.AppUI.UI;
using UnityEngine.UIElements;
using Button = Unity.AppUI.UI.Button;

namespace ClaudiaApp.View
{
    public class MainPage : VisualElement
    {
        private readonly IMainViewModel _model;
        private readonly VisualTreeAsset _template;
        private TextArea _inputMessage;
        private TextArea _outputMessage;
        private Button _sendButton;

        private SliderFloat _slider;

        private TextArea _systemMessage;

        public MainPage(IMainViewModel model)
        {
            _model = model;
            InitializeComponent();
            _model.PropertyChanged += OnPropertyChanged;
        }

        public MainPage(VisualTreeAsset template, IMainViewModel model)
        {
            _template = template;
            _model = model;
            InitializeComponent();
            _model.PropertyChanged += OnPropertyChanged;
        }

        private void OnPropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            if (e.PropertyName == nameof(_model.ChatMessage))
            {
                var texts = _model.ChatMessage.Select(x => x.Content[0].Text);
                _outputMessage.value = string.Join("\n", texts);
            }
        }

        private void InitializeComponent()
        {
            if (_template == null)
            {
                var template = ClaudiaAppBuilder.Instance.MainPageTemplate;
                template.CloneTree(this);
            }
            else
            {
                _template.CloneTree(this);
            }

            _slider = this.Q<SliderFloat>();
            _systemMessage = this.Q<TextArea>(className: "claudia-system-text-area");
            _inputMessage = this.Q<TextArea>(className: "claudia-form-text-area");
            _outputMessage = this.Q<TextArea>(className: "claudia-text-area");
            _sendButton = this.Q<Button>(className: "claudia-form-button");


            _slider.RegisterValueChangingCallback(OnSliderValueChanged);
            _systemMessage.RegisterValueChangingCallback(OnSystemMessageChanged);
            _inputMessage.RegisterValueChangingCallback(OnInputMessageChanged);
            _sendButton.clicked += OnSendButtonClicked;
        }

        private void OnInputMessageChanged(ChangingEvent<string> evt)
        {
            if (!string.Equals(evt.newValue, evt.previousValue)) _model.SetInputMessageCommand.Execute(evt.newValue);
        }

        private void OnSystemMessageChanged(ChangingEvent<string> evt)
        {
            if (!string.Equals(evt.newValue, evt.previousValue)) _model.SetSystemMessageCommand.Execute(evt.newValue);
        }

        private void OnSliderValueChanged(ChangingEvent<float> evt)
        {
            if (!evt.newValue.Equals(evt.previousValue)) _model.SetTemperatureCommand.Execute(evt.newValue);
        }

        private void OnSendButtonClicked()
        {
            _model.SendMessageCommand.Execute();
        }
    }
}

これでViewのスクリプトは終わりです。
何の解説もせずにここまで来ましたが、ほぼサンプルと同じつくりになっています。詳しくは2章を読んでください。

Viewのテスト

実際に動作するかStoryBookで表示させてみましょう。

MainPage.stories.csに加筆します。

MainPage.stories.cs
using System;
using ClaudiaApp.ViewModels;
using Unity.AppUI.Editor;
using UnityEditor;
using UnityEngine.UIElements;

namespace ClaudiaApp.Tests.Editor
{
    public class MainPage : StoryBookPage
    {
        private const string MainPageUxmlGuid = "77a40617b8f8e6844a325bad240d9287";
        private const string MainPageUssGuid = "9432707a9a7941d48d41636a0df4e3bc";

        public MainPage()
        {
            m_Stories.Add(new StoryBookStory("Main", () =>
            {
                var element = new VisualElement();
                var tree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
                    AssetDatabase.GUIDToAssetPath(MainPageUxmlGuid));
                tree.CloneTree(element);
                var root = element.Q<VisualElement>(className: "claudia-root");
                root.styleSheets.Add(
                    AssetDatabase.LoadAssetAtPath<StyleSheet>(AssetDatabase.GUIDToAssetPath(MainPageUssGuid)));

                return root;
            }));

+            m_Stories.Add(new StoryBookStory("Main with DataBinding", () =>
+            {
+                var template = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
+                    AssetDatabase.GUIDToAssetPath(MainPageUxmlGuid));
+                var style = AssetDatabase.LoadAssetAtPath<StyleSheet>(AssetDatabase.GUIDToAssetPath(MainPageUssGuid));
+                var viewModel = new MockMainViewModel();
+                var page = new View.MainPage(template, viewModel);
+                var root = page.Q<VisualElement>(className: "claudia-root");
+                root.styleSheets.Add(style);
+
+                return root;
+            }));
        }

        public override string displayName => "Claudia-MainPage";
        public override Type componentType => null;
    }
}

これでViewのテストができるはずです。
本来はViewModelが必要ですが、テスト用のMockViewModelをインスタンス化し注入しています。

StoryBookを見てみましょう。
以下のすべてが満たされていればテスト成功です。

  • TextAreaに文字を入力したときにLogがでる
  • Sliderを変更したときにLogがでる
  • ボタンを押下すると1秒後にメッセージが出力されるか

image.png

d7854a1d14e7c2261729be9933810edd.gif

成功していそうですね!
これにてViewの実装は終わりです。

Modelの実装

次にModelを実装していきます。
先にViewModelを実装してもよいのですが、少し複雑になるので後に回します。
次のようにファイルを作成してください。

フォルダ構造
Claudia
├── SampleResources
│   ├── Styles
│   │   └── MainPage.uss
│   └── Views
│       └── MainPage.uxml
└── Scripts
    ├── Runtime
    │   ├── Models
+   │   │   ├── Actions.cs
+   │   │   ├── AppState.cs
+   │   │   └── Reducers.cs
    │   ├── ViewModels
    │   │   ├── IMainViewModel.cs
    │   │   └── MockMainViewModel.cs
    │   ├── View
    │   │   └── MainPage.cs
    │   └── ClaudiaUiToolKit.asmdef
    └── Tests
        ├── Editor
        │   ├── ClaudiaApp.Test.Editor.asmdef
        │   └── MainPage.stories.cs
        └── Runtime
+           ├── Models
+           │   └── ReducerTest.cs
+           └── ClaudiaApp.Test.asmdef

ClaudiaApp.Test.asmdefは作成したら以下の設定にしてください。
image.png

Actions.csを記述していきます。

Actions.cs
namespace ClaudiaApp.Models
{
    public static class Actions
    {
        public const string SliceName = "app";
        public const string SetTemperature = SliceName + "/SetTemparature";
        public const string SetInputMessage = SliceName + "/SetInputMessage";
        public const string SetSystemString = SliceName + "/SetSystemString";
        public const string SetChatMessage = SliceName + "/SetChatMessage";
    }
}

ここの文字列はなんでもよいです。Stateが不変であることを考えるとSetという命名はすこしよくない気もしますが... 気持ち悪いと思った方は自由に変えてもらって構いません。

AppState.csを記述していきます。

AppState.cs
using System;
using System.Collections.Generic;
using Claudia;
using UnityEngine;

namespace ClaudiaApp.Models
{
    [Serializable]
    public record AppState
    {
        [SerializeField] public float temperatureValue;
        [SerializeField] public string inputMessage;
        [SerializeField] public string systemString;
        [SerializeField] public string apiKey;

        [SerializeField] public Anthropic Anthropic = new()
        {
            ApiKey ="ClaudeのAPIKeyを入れる"
        };

        [SerializeField] public List<Message> ChatMessages = new();
    }
}

APIKeyは残念ながら有料です。(ここで言うなよ)
以下の記事で詳しく説明していますので参照してください。

最後にReducers.csを記述していきます。

Reducers.cs
using System.Collections.Generic;
using Claudia;
using Unity.AppUI.Redux;

namespace ClaudiaApp.Models
{
    public class Reducers
    {
        public static AppState SetTemperatureValueReducer(AppState state, Action<float> action)
        {
            return state with { temperatureValue = action.payload };
        }

        public static AppState SetInputMessageReducer(AppState state, Action<string> action)
        {
            return state with { inputMessage = action.payload };
        }

        public static AppState SetSystemStringReducer(AppState state, Action<string> action)
        {
            return state with { systemString = action.payload };
        }

        public static AppState SetChatMessage(AppState state, Action<List<Message>> action)
        {
            return state with { ChatMessages = action.payload };
        }
    }
}

Modelの実装はここでいったん終了です。

Modelのテスト

正常に動くかテストを書いていきます。
全てテストコードを書いてもよいですが、今回はロジック部分のReducerのみ書いていこうと思います。

ReducerTest.csを記述していきます。

ReducerTest.cs
using System.Collections.Generic;
using Claudia;
using ClaudiaApp.Models;
using NUnit.Framework;
using Unity.AppUI.Redux;

namespace ClaudiaApp.Tests.Models
{
    [TestFixture]
    public class ReducersTest
    {
        [Test]
        public void ReducersTest_SetTemperatureValueReducer()
        {
            // Arrange
            var state = new AppState();
            var action = Store.CreateAction<float>(Actions.SetTemperature);
            var temperature = 0.5f;

            // Act
            var newState = Reducers.SetTemperatureValueReducer(state, action.Invoke(temperature));

            // Assert
            Assert.That(newState.temperatureValue, Is.EqualTo(temperature));
        }

        [Test]
        public void ReducersTest_SetInputMessageReducer()
        {
            // Arrange
            var state = new AppState();
            var action = Store.CreateAction<string>(Actions.SetInputMessage);
            var inputMessage = "Input message";

            // Act
            var newState = Reducers.SetInputMessageReducer(state, action.Invoke(inputMessage));

            // Assert
            Assert.That(newState.inputMessage, Is.EqualTo(inputMessage));
        }

        [Test]
        public void ReducersTest_SetSystemStringReducer()
        {
            // Arrange
            var state = new AppState();
            var action = Store.CreateAction<string>(Actions.SetSystemString);
            var systemString = "System message";

            // Act
            var newState = Reducers.SetSystemStringReducer(state, action.Invoke(systemString));

            // Assert
            Assert.That(newState.systemString, Is.EqualTo(systemString));
        }

        [Test]
        public void ReducersTest_SetChatMessage()
        {
            // Arrange
            var state = new AppState();
            var action = Store.CreateAction<List<Message>>(Actions.SetChatMessage);
            var chatMessage = new List<Message> { new() { Content = "", Role = Roles.User } };

            // Act
            var newState = Reducers.SetChatMessage(state, action.Invoke(chatMessage));

            // Assert
            Assert.That(newState.ChatMessages, Is.EqualTo(chatMessage));
        }
    }
}

これでテストの実装は終わりです。

さて次に書いたコードを実際に動作させます。
Unityのツールバーから Window → General → TestRunner をクリックしてテスト用のウィンドウを開くことができます。

image.png

開いた後にRun Allを押すことでテストを実行できます。
image.png

全て緑色になれば成功です!

ViewModelの実装

MVVMのラストです。
ViewModelを実装していきます。

以下のようにファイルを作成してください。

Claudia
 ├── SampleResources
 │   ├── Styles
 │   └── Views
 │       └── MainPage.uxml
 └── Scripts
    ├── Runtime
    │   ├── Models
    │   │   ├── Actions.cs
    │   │   ├── AppState.cs
    │   │   └── Reducers.cs
    │   ├── Services
+   │   │   ├── ILocalStorageService.cs
+   │   │   ├── IStoreService.cs
+   │   │   ├── MockStorageService.cs
+   │   │   └── StoreService.cs
    │   ├── ViewModels
    │   │   ├── IMainViewModel.cs
+   │   │   ├── MainViewModel.cs
    │   │   └── MockMainViewModel.cs
    │   ├── Vuiew
    │   │   └── MainPage.cs
    │   └── ClaudiaUiToolKit.asmdef
    └── Tests
        ├── Editor
        │   ├── ClaudiaApp.Test.Editor.asmdef
        │   └── MainPage.stories.cs
        └── Runtime
            ├── ViewModels
            │   └── MainViewModelTest.cs
            ├── Models
            │   └── ReducerTest.cs
            └── ClaudiaApp.Test.asmdef

ILocalStorageService.csを記述していきます。

ILocalStorageService.cs
namespace ClaudiaApp.Services
{
    public interface ILocalStorageService
    {
        T GetValue<T>(string key, T defaultValue = default);
        void SetValue<T>(string key, T value);
    }
}

IStoreService.csを記述していきます。

IStoreService.cs
using Unity.AppUI.Redux;

namespace ClaudiaApp.Services
{
    public interface IStoreService
    {
        Store Store { get; }
    }
}

MockStorageService.csを記述していきます。

MockStorageService.cs
namespace ClaudiaApp.Services
{
    public class MockStorageService : ILocalStorageService
    {
        public T GetValue<T>(string key, T defaultValue = default)
        {
            return defaultValue;
        }

        public void SetValue<T>(string key, T value)
        {
        }
    }
}

StoreService.csを記述していきます。

StoreService.cs
using Unity.AppUI.Redux;

namespace ClaudiaApp.Services
{
    public class StoreService : IStoreService
    {
        public StoreService()
        {
            Store = new Store();
        }

        public Store Store { get; }
    }
}

最後にMainViewModel.csを記述していきます。

MainViewModel
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Claudia;
using ClaudiaApp.Models;
using ClaudiaApp.Services;
using Unity.AppUI.MVVM;
using Unity.AppUI.Redux;
using UnityEngine;

namespace ClaudiaApp.ViewModels
{
    public class MainViewModel : ObservableObject, IMainViewModel
    {
        private readonly ILocalStorageService _localStorageService;
        private readonly IStoreService _storeService;
        private readonly Unsubscriber _unSubscriber;
        private List<Message> _chatMessage;

        private bool _running;

        public MainViewModel(IStoreService storeService, ILocalStorageService localService)
        {
            _storeService = storeService;
            _localStorageService = localService;

            SetTemperatureCommand = new RelayCommand<float>(SetTemperature);
            SetSystemMessageCommand = new RelayCommand<string>(SetSystemMessage);
            SetInputMessageCommand = new RelayCommand<string>(SetInputMessage);
            SendMessageCommand = new AsyncRelayCommand(SendMessage);

            var initialState = localService.GetValue(Actions.SliceName, new AppState());
            _storeService.Store.CreateSlice(Actions.SliceName, initialState, builder =>
            {
                builder
                    .Add<float>(Actions.SetTemperature, Reducers.SetTemperatureValueReducer)
                    .Add<List<Message>>(Actions.SetChatMessage, Reducers.SetChatMessage)
                    .Add<string>(Actions.SetInputMessage, Reducers.SetInputMessageReducer)
                    .Add<string>(Actions.SetSystemString, Reducers.SetSystemStringReducer);
            });

            _chatMessage = initialState.ChatMessages;

            _unSubscriber = storeService.Store.Subscribe<AppState>(Actions.SliceName, OnStateChanged);
            App.shuttingDown += OnShuttingDown;
        }

        public List<Message> ChatMessage
        {
            get => _chatMessage;
            set
            {
                _chatMessage = value;
                OnPropertyChanged();
            }
        }

        public RelayCommand<float> SetTemperatureCommand { get; }
        public RelayCommand<string> SetSystemMessageCommand { get; }
        public RelayCommand<string> SetInputMessageCommand { get; }
        public AsyncRelayCommand SendMessageCommand { get; }

        private void OnStateChanged(AppState state)
        {
            ChatMessage = state.ChatMessages;
        }

        private async Task SendMessage(CancellationToken token)
        {
            if (_running) return;

            _running = true;

            try
            {
                var state = _storeService.Store.GetState<AppState>(Actions.SliceName);
                var anthropic = state.Anthropic;
                ChatMessage.Add(new Message { Role = Roles.User, Content = state.inputMessage });
                var stream = anthropic.Messages.CreateStreamAsync(new MessageRequest
                {
                    Model = Claudia.Models.Claude3Opus,
                    MaxTokens = 1024,
                    Temperature = state.temperatureValue,
                    System = string.IsNullOrEmpty(state.systemString) ? null : state.systemString,
                    Messages = state.ChatMessages.ToArray()
                }, cancellationToken: token);

                var currentMessage = new Message
                {
                    Role = Roles.Assistant,
                    Content = ""
                };

                ChatMessage.Add(currentMessage);


                await foreach (var messageStreamEvent in stream)
                    if (messageStreamEvent is ContentBlockDelta content)
                    {
                        currentMessage.Content[0].Text += content.Delta.Text;
                        _storeService.Store.Dispatch(Actions.SetChatMessage, ChatMessage);
                    }
            }
            catch (Exception e)
            {
                Debug.Log(e);
            }
            finally
            {
                _running = false;
            }
        }

        private void SetInputMessage(string message)
        {
            _storeService.Store.Dispatch(Actions.SetInputMessage, message);
        }

        private void SetSystemMessage(string message)
        {
            _storeService.Store.Dispatch(Actions.SetSystemString, message);
        }

        private void SetTemperature(float temperature)
        {
            _storeService.Store.Dispatch(Actions.SetTemperature, temperature);
        }

        private void OnShuttingDown()
        {
            _localStorageService.SetValue(Actions.SliceName, _storeService.Store.GetState<AppState>(Actions.SliceName));
            App.shuttingDown -= OnShuttingDown;
            _unSubscriber?.Invoke();
        }
    }
}

本来Modelで実装すべきロジック部分をModelがStateに変わった都合でViewModelに移動しています。
希望ではRedux Middlewareに実装したいところですがAppUIが対応していないためやむをえません。
MVVM設計パターンからは逸脱していないので許容です。
いつかRedux Middlewareを実装したいところです。

さて、ViewModelの実装が終わったので、実際に動くかどうかのテストをしていきましょう

ViewModelのテスト

まずはテストコードを書いていきます。
MainViewModelTest.csに以下を記述してください。

MainViewModelTest.cs
using System.Collections;
using System.Threading.Tasks;
using ClaudiaApp.Models;
using ClaudiaApp.Services;
using ClaudiaApp.ViewModels;
using NUnit.Framework;
using UnityEngine.TestTools;

namespace ClaudiaApp.Tests.ViewModels
{
    [TestFixture]
    public class MainViewModelTest
    {
        [SetUp]
        public void SetUp()
        {
            _storeService = new StoreService();
            _localStorageService = new MockStorageService();
            _viewModel = new MainViewModel(_storeService, _localStorageService);
        }

        private IStoreService _storeService;
        private ILocalStorageService _localStorageService;
        private IMainViewModel _viewModel;

        [Test]
        public void MainViewModelTest_SetTemperatureCommand()
        {
            // Arrange
            var temperature = 25.0f;

            // Act
            _viewModel.SetTemperatureCommand.Execute(temperature);

            // Assert
            var state = _storeService.Store.GetState<AppState>(Actions.SliceName);
            Assert.AreEqual(temperature, state.temperatureValue);
        }

        [Test]
        public void MainViewModelTest_SetSystemMessageCommand()
        {
            // Arrange
            var systemMessage = "System message";

            // Act
            _viewModel.SetSystemMessageCommand.Execute(systemMessage);

            // Assert
            var state = _storeService.Store.GetState<AppState>(Actions.SliceName);
            Assert.AreEqual(systemMessage, state.systemString);
        }

        [Test]
        public void MainViewModelTest_SetInputMessageCommand()
        {
            // Arrange
            var inputMessage = "Input message";

            // Act
            _viewModel.SetInputMessageCommand.Execute(inputMessage);

            // Assert
            var state = _storeService.Store.GetState<AppState>(Actions.SliceName);
            Assert.AreEqual(inputMessage, state.inputMessage);
        }

        [UnityTest]
        public IEnumerator MainViewModelTest_SendMessageCommand()
        {
            // Arrange
            var inputMessage = "Hello";
            _viewModel.SetInputMessageCommand.Execute(inputMessage);

            // Act
            var task = Task.Run(() => _viewModel.SendMessageCommand.ExecuteAsync(null));

            while (!task.IsCompleted) yield return null;

            // Assert
            var state = _storeService.Store.GetState<AppState>(Actions.SliceName);
            Assert.AreEqual(2, state.ChatMessages.Count);
            Assert.That(state.ChatMessages[1].Content[0].Text, Is.Not.Null);
        }

        [UnityTest]
        public IEnumerator MainViewModelTest_SendMessageCommand_WhenRunning()
        {
            // Arrange
            var inputMessage = "Hello";
            _viewModel.SetInputMessageCommand.Execute(inputMessage);

            // Act
            var task = Task.Run(() => _viewModel.SendMessageCommand.ExecuteAsync(null));
            var task2 = Task.Run(() => _viewModel.SendMessageCommand.ExecuteAsync(null));

            while (!task.IsCompleted || !task2.IsCompleted) yield return null;

            // Assert
            var state = _storeService.Store.GetState<AppState>(Actions.SliceName);
            Assert.AreEqual(2, state.ChatMessages.Count);
            Assert.That(state.ChatMessages[1].Content[0].Text, Is.Not.Null);
        }
    }
}

記述できたらTestRunnerから実行してみましょう。
全て緑色になれば成功です!
image.png

これにてViewModelの実装は終了です。
次からはアプリの実装に移っていきます。

3章 おわりに

MVVMの実装をやっていきました。
手続き的に実装するよりもはるかにコード量がおおく大変ですが、単位テストができるぶん安心して開発できるのではないかと思います。

4章ではついにチャットアプリが完成します。
がんばっていきましょう。

この章を通じて、MVVMとReduxの強力な組み合わせを見てきました。この知識を使って、皆さん自身のプロジェクトにどのように応用できるかを探ってみてください。何か質問や提案、この記事の間違いがあれば、ぜひシェアしてください!
では!

3
4
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
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?