Unityで非ゲームアプリをクリーンアーキテクチャを意識して作ってみた話

# はじめに

普段はクラス設計の話をよくしているのですが、今回はより大局的な「アーキテクチャ設計」の話です。

この記事では「非ゲームアプリケーションを、クリーンアーキテクチャを意識してUnityで作るとどんなアーキテクチャとなるのか」を紹介します。

前回「[VoicevoxClientSharp: C#やUnityからVOICEVOXで音声合成するライブラリの紹介](https://qiita.com/toRisouP/items/11bc12583e2460afe0cb)」という記事を投稿しました。
「VoicevoxClientSharp」というライブラリをせっかく作ったのだから、それを使ったサンプルアプリケーションを作ってみようと考えました。そしてサンプルアプリを実際に作っている途中で「これ`VoicevoxClientSharp`の使用例で終わらせずに、とことん設計をこだわってみてそれをネタにしたほうが面白いじゃん」と思い、この記事を書くに至りました。

**もともとサンプル実装用途だった小さいプロダクトを全力で真面目に設計するとどうなるのか？** という内容です。


# 作ったサンプルアプリケーションについて

![app.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/52adfea7-effd-ebef-8be2-2397b3918d17.gif)

![3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/27f6d335-9d6e-6b1b-de0d-4a8d7cfb510e.jpeg)

![2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/f573aa6d-614b-1460-f72e-97884c3579a8.jpeg)



* VRMアバターを読み込み、VOICEVOXで発話しながらアバターをリップシンクさせるアプリです
  * REST APIでテキストデータを送り込むことができます。コメントビューアなどと連携して生放送コメントの読み上げなどに使う想定


## 概要

### リポジトリ

- [AvatarSpeaker](https://github.com/TORISOUP/AvatarSpeaker)

MITライセンスです。適当にforkして機能拡張したり、それを公開してもらって構いません。

### 依存ライブラリ

- VoicevoxClientSharp
- UniVRM
- R3
- UniTask
- NuGetForUnity
- VContainer
- UnitySimpleFileBrowser

## 機能要件

今回、`VoicevoxClientSharp`のサンプル実装が一応の話の起点だったため、次のようなアプリケーションを想定して作りました。

* **Unityで作成するデスクトップ向けアプリケーション**
  * 配信者がアバターを画面に表示し、生放送のコメントをアバターに読み上げさせるような状況を想定
    * 自由にローカルの[VRM](https://vrm.dev/)を読み込み表示することができる
    * [VOICEVOX](https://voicevox.hiroshiba.jp/)を用いてテキストの読み上げができる
    * REST APIを公開し、HTTP経由で外部から読み上げテキストデータを送り込むことができる
    * クロマキー合成ができるように背景色を変更できる
    * カメラの操作が行える

## 設計思想

規模としてはかなり小さいアプリケーションです。そのため抽象化を挟まずに`MonoBehaviour`ベタ書きで書いてもなんとかなるレベルではあります。
しかしそれだけでは味気ないので次のよう条件を付けて設計・実装することにしました。

* **将来的に機能拡張があると想定し備える**
  * 例
    * ローカルからVRMを読み込むだけでなく、サーバーから読み込む可能性がある
    * VOICEVOX以外の音声合成ソフトウェアに対応する可能性がある
    * 画面の切り替え・複数アバターの同時展開を行う可能性がある
    * 背景画像を自由に設定できるようにする可能性がある
    * 設定したパラメータを保存できるようにする可能性がある
      * などなど…

* **クリーンアーキテクチャの思想に則る**
  * パッケージ間の関係性・レイヤ構造・抽象度・安定度を意識する

* **コア部分はUnityにできるだけ非依存とする**
  * GameObjectやMonoBehaviourを意識した実装はコア部分では禁止する
    * ただし、UnityEngineそのものへの依存は禁止しない（理由は後述）

---

以前Unityにおける設計については過去に語ったことがあるのですが、この定義でいうところの「レベル5」を今回は目指しています。

* [Unityにおける「設計レベル」を定義してみた](https://qiita.com/toRisouP/items/79b97c472e588bb91c52)


### 余談:クリーンアーキテクチャ

<details><summary>話が脱線するので折りたたみ。</summary>

端的にいえば、クリーンアーキテクチャとは「**クリーンなアーキテクチャを作るための思想**」のことを指しています。
具体的なアーキテクチャの構成図があるのではなく、「こういうときはこういう考え方で作ると上手くいくことが多いよ」というテクニックを語っているのがクリーンアーキテクチャです。
なので「クリーンアーキテクチャ指向」って言い方をしたほうがしっくりきますね。

クリーンアーキテクチャについて学習するのであれば、まずは書籍を読むことを推奨します。
またAtsushi Nakamuraさんがまとめてくださった記事もわかりやすくてお勧めです。

* [Clean Architecture　達人に学ぶソフトウェアの構造と設計 ](https://asciidwango.jp/post/176293765750/clean-architecture)
* [世界一わかりやすいClean Architecture](https://www.nuits.jp/entry/easiest-clean-architecture-2019-09)


ちなみに私なりにクリーンアーキテクチャをまとめると次のようになります。

* **うまくいっているアーキテクチャは本質的に同じテクニックを使っている**
  * 安定度・抽象度が高いものを中心に依存を整理せよ
  * データフローと依存関係は分離して考えるべきである

* **クリーンアーキテクチャは「採用する」ものではなく「作っていったら自然とそうなる」ものである**
  * 設計・アーキテクチャにおける原則や思想を積み重ねていった結果、帰着するのがクリーンアーキテクチャである
    * 有名な設計に「SOLID原則」があるが、それもクリーンアーキテクチャの土台の１つである

* **パッケージ・レイヤー構成はプロダクト都合にあわせて自由に設計してよい**
  * 決まり切ったパッケージ・レイヤー構造は存在しない
    * 本に書いてある同心円状のレイヤー構造をまるっと再現しようとして失敗している人が多いが、本質はそこではない
    * レイヤー構造やパッケージは自由に決めてよい
  * スモールスタートし、プロダクトの成長に合わせて随時アーキテクチャを組み直しても全然よい
    * 最初から完璧なアーキテクチャを作るのではなく「アーキテクチャを育てていく」のも大事

自分はクリーンアーキテクチャを考えながらも、多少それを崩して使うことが多いです。
**開発において「キレイな設計やアーキテクチャを維持すること」は目的ではありません。それは「長期的に安定した運用を行う状況を作る」ための手段です。**
アーキテクチャのキレイさにこだわって逆に保守しにくい、チームメンバーがついてこれない、運用コストが大きい状態になったら本末転倒です。

クリーンアーキテクチャを神格化するのもよくないので、ほどよく距離を取って使うくらいがちょうどよいと思います。

</details>

# 設計

ではここからが本題。どのようなことを考え、どのような設計をしたのか。
上手くいった部分、上手くいかなかった部分などをまとめていきます。

## 全体のパッケージ構成

全体のパッケージ構成は次のようになっています。

![all_class2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/c49f0237-5673-32b2-d120-c094e56561c0.jpeg)


パッケージはそれぞれが`asmdef`で分割されています（たとえば`Core`パッケージは`AvatarSpeaker.Core.asmdef`が定義されています）。

それぞれのパッケージの概要は次です。

|パッケージ名|責務|
|:---|:---|
|[Core](https://github.com/TORISOUP/AvatarSpeaker/tree/master/Assets/AvatarSpeaker/Scripts/Core)|このアプリにおける中核となるモデル、データ構造、インタフェース定義が配置される。GameObjectやMonoBehaviourをここで意識することは禁止とする。|
|[Core.UnityAdapter](https://github.com/TORISOUP/AvatarSpeaker/tree/master/Assets/AvatarSpeaker/Scripts/Core.UnityAdapter)|CoreでGameObjectやMonoBehaviourを意識することは禁止したが、かといってUnityで作る以上はどうしても意識せざるを得ないので、妥協としてCoreからUnityに強く依存する部分だけを分割したパッケージ。|
|[Infrastructures](https://github.com/TORISOUP/AvatarSpeaker/tree/master/Assets/AvatarSpeaker/Scripts/Infrastructures)|Core（Core.UnityAdapter）を実装する場所。アプリケーションの具体的な振る舞い方法がここで実装される。|
|[Views](https://github.com/TORISOUP/AvatarSpeaker/tree/master/Assets/AvatarSpeaker/Scripts/Views)|Core（Core.UnityAdapter）のUnity上での見た目を提供する場所。GameObjectへバリバリに依存することになる。|
|[UseCases](https://github.com/TORISOUP/AvatarSpeaker/tree/master/Assets/AvatarSpeaker/Scripts/UseCases)|Coreが定義するモデルやインタフェースを組み合わせて一連の手続きを提供するパッケージ。|
|[UIs](https://github.com/TORISOUP/AvatarSpeaker/tree/master/Assets/AvatarSpeaker/Scripts/UIs)|ユーザーとの情報表示や操作の受付を行う場所。ほぼ`uGUI`の実装場所。|
|[Http](https://github.com/TORISOUP/AvatarSpeaker/tree/master/Assets/AvatarSpeaker/Scripts/Http)|REST APIを提供するためのHTTPサーバー実装などが置かれる場所。|


また図には登場してませんが、`StartUp`と`DI`という特殊なパッケージも存在します。

|パッケージ名|責務|
|:---|:---|
|[StartUp](https://github.com/TORISOUP/AvatarSpeaker/tree/master/Assets/AvatarSpeaker/Scripts/StartUp)|アプリケーションの動作の起点となる操作を行う場所。シーン起動時に一瞬だけ稼働してそれ以降は何もしない。|
|[DI](https://github.com/TORISOUP/AvatarSpeaker/tree/master/Assets/AvatarSpeaker/Scripts/DI)|DIコンテナへのバインド設定を定義する場所。今回はVContainerを使っているため、`LifetimeScope`の定義場所となっている。|

## モデル定義

アプリケーションを成立させるための登場人物（オブジェクト）を定義する必要があります。今回は次のような定義としました。

|モデル名|責務|
|:---|:---|
|`RoomSpace`|「空間」を表す概念。`Speaker`や`RoomCamera`はこの`RoomSpace`に属することとなる。|
|`Speaker`|「発話するアバター」を表す概念。実装上はVRMモデルと紐づいており、VOICEVOXを用いて発話する。|
|`SpeakerCamera`|「カメラ」の概念。`Speaker`を撮影し画面に投影するカメラの位置姿勢などを扱う。|

とくに重要な登場人物はこの３つであり、すべて`Core`に定義されています。アプリケーションの中核となる概念です。

## 各パッケージの解説

### Core

`Core`はこのアプリケーションを動作させる上での中核となる概念・モデル・インタフェースが定義される場所となっています。このアプリケーションにおいてもっとも安定度と抽象度が高い領域となっています。さきほどの`Speaker`などのモデルはここに定義されています。

* [Speaker](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Core/Speaker.cs)

```cs
#nullable enable
using System;
using System.Threading;
using AvatarSpeaker.Core.Models;
using Cysharp.Threading.Tasks;
using R3;
using UnityEngine; // UnityEngineへの依存を許す

namespace AvatarSpeaker.Core
{
    /// <summary>
    /// 発話者の概念を表すクラス
    /// </summary>
    public abstract class Speaker : IDisposable, IEquatable<Speaker>
    {
        private readonly ReactiveProperty<IdlePose> _currentIdlePose = new(IdlePose.Pose1);

        private readonly ReactiveProperty<SpeakParameter> _currentSpeakParameter = new(SpeakParameter.Default);

        /// <summary>
        /// 現在のポーズ
        /// </summary>
        public ReadOnlyReactiveProperty<IdlePose> CurrentIdlePose => _currentIdlePose;

        /// <summary>
        /// 現在の発話パラメータ
        /// </summary>
        public ReadOnlyReactiveProperty<SpeakParameter> CurrentSpeakParameter => _currentSpeakParameter;

        /// <summary>
        /// 現在発話中のテキスト
        /// </summary>
        public abstract ReadOnlyReactiveProperty<string> CurrentSpeakingText { get; }

        /// <summary>
        /// SpeakerのID
        /// </summary>
        public abstract string Id { get; }

        /// <summary>
        /// Speakerの顔の位置
        /// </summary>
        public abstract Vector3 FacePosition { get; }

        /// <summary>
        /// 体の前方を表すベクトル
        /// </summary>
        public abstract Vector3 BodyForward { get; }


        /// <summary>
        /// Dispose時に発火するUniTask
        /// </summary>
        public abstract UniTask OnDisposeAsync { get; }


        public void Dispose()
        {
            _currentIdlePose.Dispose();
            _currentSpeakParameter.Dispose();
            OnDisposed();
        }

        public bool Equals(Speaker other)
        {
            if (other is null) return false;

            if (ReferenceEquals(this, other)) return true;

            return Id == other.Id;
        }

        /// <summary>
        /// 現在Speakerが保持する設定値で発話させる
        /// </summary>
        public abstract UniTask SpeakAsync(string text, CancellationToken ct);

        /// <summary>
        /// Speakerに発話させる
        /// </summary>
        public abstract UniTask SpeakAsync(string text, SpeakParameter speakParameter, CancellationToken ct);


        /// <summary>
        /// 現在のポーズを変更する
        /// </summary>
        public void ChangeIdlePose(IdlePose idlePose)
        {
            _currentIdlePose.Value = idlePose;
        }

        /// <summary>
        /// 発話パラメータを変更する
        /// </summary>
        public void ChangeSpeakParameter(SpeakParameter speakParameter)
        {
            // 無効な値の場合は変更しない
            if (!speakParameter.Validate()) return;

            _currentSpeakParameter.Value = speakParameter;
        }

        protected virtual void OnDisposed()
        {
            //
        }
    }
}
```

他にも、`ISpeakerSource`という`Speaker`のリソース先を表す概念を用いて、実際の`Speaker`を提供する`ISpeakerProvider`などが定義されています。

* [ISpeakerSource](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Core/Interfaces/ISpeakerSource.cs)

```cs
using System.Threading;
using Cysharp.Threading.Tasks;

namespace AvatarSpeaker.Core.Interfaces
{
    /// <summary>
    /// Speakerのロード元を表す
    /// </summary>
    public interface ISpeakerSource
    {
        UniTask<T> Accept<T>(ISpeakerSourceVisitor<T> visitor, CancellationToken ct);
    }

    /// <summary>
    /// VisitorパターンでISpeakerSourceを処理するためのインタフェース
    /// </summary>
    /// <typeparam name="T"></typeparam>
    public interface ISpeakerSourceVisitor<T>
    {
        UniTask<T> Visit(LocalSpeakerSource source, CancellationToken ct);
    }

    /// <summary>
    /// ローカルファイルからSpeakerをロードするための実装
    /// </summary>
    public readonly struct LocalSpeakerSource : ISpeakerSource
    {
        public string Path { get; }

        public LocalSpeakerSource(string path)
        {
            Path = path;
        }

        public UniTask<T> Accept<T>(ISpeakerSourceVisitor<T> visitor, CancellationToken ct)
        {
            return visitor.Visit(this, ct);
        }

        public override string ToString()
        {
            return $"{nameof(Path)}: {Path}";
        }
    }
}
```

* [ISpeakerProvider](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Core/Interfaces/ISpeakerProvider.cs)

```cs
using System.Threading;
using Cysharp.Threading.Tasks;

namespace AvatarSpeaker.Core.Interfaces
{
    /// <summary>
    /// Speakerを提供する
    /// </summary>
    public interface ISpeakerProvider
    {
        /// <summary>
        /// SpeakerSourceを元にSpeakerを提供する
        /// </summary>
        UniTask<Speaker> LoadSpeakerAsync(ISpeakerSource source, CancellationToken ct);
    }
}
```

（Visitorパターンを使っているが、これについてはあとで話します）

---

そして今回、「**CoreがUnityEngineを参照してよいのか？**」ですごく悩みました。

UnityEngineから完全に切り離す場合、「このアプリケーションは本質的にはUnity非依存です」という強い意志表示ができます。もし今回のアプリケーションが本当にUnity非依存であるのであれば「`Core`をUnityEngine非依存にする」という選択は正しいでしょう。

ですが今回においてはそこまで強い意志表示をするメリットが存在しません。だってUnity以外で実装することはほぼありえないわけですし。また、UnityEngine非依存にすると`Vector3`や`Quaternion`といった構造体すら`Core`で扱えなくなってしまいます。これは相当に不便です。

そこで今回は「CoreはUnityEngineを**部分的に**使ってよい」というルールにしました。「**UnityEngineへの依存を完全に禁止するとあんまり旨味がない上に手間だけが増える。あとアドベントカレンダーの執筆が間に合わなくなるから。**」というのが理由で妥協しました。


* **ルール**

     * `Core`が扱っていいUnityEngineのオブジェクトは`Vector3`や`Quaternion`といった「データ構造」程度に留める
    * `GameObject`や`MonoBehaviour`への依存は禁止する（Unityのライフサイクルが`Core`に入りこまないように）


#### Core.UnityAdapter

`Core.UnityAdapter`は「`Core`を装飾し、Unityの概念を`Core`モジュールに追加する」という場所です。

「`Core`は部分的にUnityEngineを触ることができるが、`GameObject`や`MonoBehaviour`はアクセスしない」というルールを課しました。これは`Core`の要素にUnityのライフサイクルが入りこまないように防御する意味で重要な判断でした。

**ですが実装を進めるうちに、`Core`が`GameObject`を知らないとどうしようもない**という状況に遭遇しました。

そこで苦肉の策として生まれたのがこの`Core.UnityAdapter`です。

「`Core`が`GameObject`を知らないとどうしようもない」とはどのような状況なのか。
それは`Infrastructures`と`Views`の存在が関係してきます。

```plantuml
@startuml

package Core #FFDDDD {
    abstract class Speaker{
    }
}

note right of Core.Speaker
 「発話するアバター」を抽象化したもの
endnote

package Infrastructures #DDDDFF {
    class VoicevoxVrmSpeaker{
    }
}

package Views #DDDDFF {
    class SpeakerView{
    }
}

package VRM #DDDDDD {
    class VRM10Instance{}
}

package UnityEngine #DDDDDD {
    class GameObject{}
}

note right of UnityEngine.GameObject
 VoicevoxClientSharpを使う都合上、
 InfrastructuresでGameObject(VRM)を生成する必要がある。
 だがここで生成したGameObjectはViewでも共通して使う必要がある。

 どうやってInfraとViewをまたいで同じGameObjectを参照する？
endnote

VRM.VRM10Instance --u> UnityEngine.GameObject

Infrastructures.VoicevoxVrmSpeaker --u|> Core.Speaker
Views.SpeakerView --u> Core.Speaker

Infrastructures.VoicevoxVrmSpeaker --d> UnityEngine.GameObject : 生成
Views.SpeakerView --d> UnityEngine.GameObject : 参照

@enduml
```

今回、`VoicevoxClientSharp`を使う都合上、`Infrastructures`でVRMを展開し`Instantiate`する必要があります。しかし見た目を管理する`Views`でも、この生成したVRMを参照して管理する必要があります。となると、どうにかして同じGameObjectを`Infrastructures`から`Views`に教える必要があります。この両者のつなぐものは`Core.Speaker`しかありません。しかし`Core`では`GameObject`を扱うことは禁止です。 **詰みです。** どうにかするしかありません。

いろいろと案を考えては見ました。たとえば次のように「外側に別のパッケージを切ってそこを経由してがんばる」といった方法。

```plantuml
@startuml

package Core #FFDDDD {
    abstract class Speaker{
       + int ObjectId
    }
}

note right of Core.Speaker
 Speakerに謎の「ObjectId」というIdを追加
 これを介してInfraからViewに伝える
endnote

package Infrastructures #DDDDFF {
    class VoicevoxVrmSpeaker{
    }
}


package Views #DDDDFF {
    class SpeakerView{
    }
}

package VRM #DDDDDD {
    class VRM10Instance{}
}

package UnityEngine #DDDDDD {
    class GameObject{}
}

package External{
    class GameObjectRepository{
        + void Set(int id, GameObject gameObject)
        + GameObject Get(int id)
        + void Delete(int id)
    }
}

note bottom of External.GameObjectRepository
 Idを使ってGameObjectを登録しておく場所。
 IdをCoreを介して共有し、Infraがここに登録してViewが取りにくる。
endnote

VRM.VRM10Instance --u> UnityEngine.GameObject

Infrastructures.VoicevoxVrmSpeaker --u|> Core.Speaker
Views.SpeakerView --u> Core.Speaker

Infrastructures.VoicevoxVrmSpeaker --d> UnityEngine.GameObject : 生成
Views.SpeakerView --d> UnityEngine.GameObject : 参照

Infrastructures.VoicevoxVrmSpeaker --> External.GameObjectRepository : Set
Views.SpeakerView  --> External.GameObjectRepository : Get
External.GameObjectRepository --l> UnityEngine.GameObject

@enduml
```

これは**ボツ**です。「`Core.ObjectId`」という存在が謎すぎます。そもそも`Core`に置かれるものは「アプリケーションの成立において必須である要素」である必要があります。ですが`Speaker`が`Speaker`として振る舞うためにこの`ObjectId`は必須ではありません。実装都合で`Speaker`という存在が歪められたように見えてしまい、違和感が強いため却下しました。


そして次に考えたのが、今回採用した`UnityAdapter`を挟むというアイデアです。

```plantuml
@startuml

package Core #FFDDDD {
    abstract class Speaker{
    }
}

package Core_UnityAdapter  #FFEEEE {
    class GameObjectSpeaker{
        + GameObject gameObject
    }
}

Core_UnityAdapter --u|> Core.Speaker

note right of Core_UnityAdapter.GameObjectSpeaker
Core.UnityAdapterはCoreとUnityEngineを仲介する。
Coreを外から装飾することでCoreを汚さずにUnityEngineを
扱えるようにする。
endnote

Core_UnityAdapter.GameObjectSpeaker --> UnityEngine.GameObject

package Infrastructures #DDDDFF {
    class VoicevoxVrmSpeaker{
        + GameObject gameObject
    }
}

package Views #DDDDFF {
    class SpeakerView{
    }
}

note right of Views
 ViewやInfraはCoreとCore.UnityAdapterを参照する。
 これによりGameObjectをどう共有するか問題を解消した。
endnote

package VRM #DDDDDD {
    class VRM10Instance{}
}

package UnityEngine #DDDDDD {
    class GameObject{}
}


VRM.VRM10Instance --u> UnityEngine.GameObject

Infrastructures.VoicevoxVrmSpeaker --u|> Core_UnityAdapter.GameObjectSpeaker
Views.SpeakerView --u> Core_UnityAdapter.GameObjectSpeaker

Infrastructures.VoicevoxVrmSpeaker --d> UnityEngine.GameObject : 生成
Views.SpeakerView --d> UnityEngine.GameObject : 参照
@enduml
```

やってることはシンプルで「`Core`が`GameObject`を参照できないなら、`GameObject`を参照したい部分だけ切り出せばいいじゃん」というものです。

UnityEngineというフレームワークと事実上結婚はしているもののべったりはしたくない、なので**別居婚**にすればいいじゃん、という発想です。`Core.UnityAdapter`自体は非常に薄いパッケージでありロジックなどは一切持っておらず、「`Core`を装飾する」以上のことをさせていません。

この方法は（少なくとも今回は）上手くいきました。`Core`を汚さずに、事実上`GameObject`を`Core`に持たせたように見える、という点においては成功しています。

それってクリーンアーキテクチャとしてどうなの？と思うかもしれませんが、自分はこれでもよいと考えています。そもそも唯一無二の正解となるアーキテクチャは存在しません。「開発者同士がアーキテクチャの意図を理解し、破綻なく長期運用ができる」のであれば何でもいいと思ってます。

「今回はこれで破綻なく上手く回っている。**だから問題ない。**」と言い切ってしまいます。

### Infrastructures

`Infrastructures`は`Core`の実装を行うパッケージです。`Core`に定義されていたインタフェースやモデルの実装を行い、アプリケーションが動作するように肉付けするのがこのパッケージです。

たとえばさきほど`Core`の例で出した`ISpeakerProvider`の実装は次のようになっています。

* [VoicevoxSpeakerProvider](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Infrastructures/VoicevoxSpeakers/VoicevoxSpeakerProvider.cs)

```cs
using System;
using System.Threading;
using AvatarSpeaker.Core;
using AvatarSpeaker.Core.Interfaces;
using AvatarSpeaker.Infrastructures.Voicevoxes;
using Cysharp.Threading.Tasks;
using UniVRM10;
using Object = UnityEngine.Object;

namespace AvatarSpeaker.Infrastructures.VoicevoxSpeakers
{
    /// <summary>
    /// VOICEVOXを利用したSpeaker実装を提供する
    /// </summary>
    public sealed class VoicevoxSpeakerProvider : ISpeakerProvider, ISpeakerSourceVisitor<Speaker>, IDisposable
    {
        private readonly VoicevoxProvider _voicevoxProvider;

        public VoicevoxSpeakerProvider(VoicevoxProvider voicevoxProvider)
        {
            _voicevoxProvider = voicevoxProvider;
        }

        public void Dispose()
        {
            // do nothing
        }

        public UniTask<Speaker> LoadSpeakerAsync(ISpeakerSource source, CancellationToken ct)
        {
            // VisitorパターンでISpeakerSourceを処理する
            return source.Accept(this, ct);
        }

        /// <summary>
        /// LocalSpeakerSourceに対する実装
        /// </summary>
        public async UniTask<Speaker> Visit(LocalSpeakerSource source, CancellationToken ct)
        {
            // ローカルパスからVRMをロードしてInstantiate
            var vrmInstance = await Vrm10.LoadPathAsync(source.Path, ct: ct);
            
            // LoadPathAsyncでのctの扱い方が不明なので
            // ここでキャンセルされていたら破棄して例外を投げる
            if (ct.IsCancellationRequested)
            {
                Object.Destroy(vrmInstance.gameObject);
                ct.ThrowIfCancellationRequested();
            }
            
            // VoicevoxSpeakerを生成
            var speaker = new VoicevoxSpeaker(vrmInstance, _voicevoxProvider);
            return speaker;
        }
    }
}
```

`VoicevoxSpeaker`は今回の一番のキモとなる実装です。「VRM」と「VOICEVOX」を使ってアバターが発話する「`Speaker`」を実現しています。

* [VoicevoxSpeaker](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Infrastructures/VoicevoxSpeakers/VoicevoxSpeaker.cs)

```cs
using System;
using System.Threading;
using System.Threading.Tasks;
using AvatarSpeaker.Core.Models;
using AvatarSpeaker.Core.UnityAdapter.VRM;
using AvatarSpeaker.Infrastructures.Voicevoxes;
using Cysharp.Threading.Tasks;
using R3;
using UnityEngine;
using UniVRM10;
using VoicevoxClientSharp;
using VoicevoxClientSharp.Unity;
using Object = UnityEngine.Object;

namespace AvatarSpeaker.Infrastructures.VoicevoxSpeakers
{
    /// <summary>
    /// SpeakerのVOICEVOXとVRM実装
    /// </summary>
    public class VoicevoxSpeaker : VrmSpeaker
    {
        private readonly CancellationTokenSource _cancellationTokenSource = new();
        private readonly ReactiveProperty<string> _currentSpeakingText = new("");

        private readonly UniTaskCompletionSource _onDisposeUniTaskCompletionSource = new();

        private readonly Subject<(ValueTask<SynthesisResult>, AutoResetUniTaskCompletionSource, CancellationToken)>
            _speechRegisterSubject =
                new();

        private readonly VoicevoxProvider _voicevoxProvider;
        private readonly GameObject _vrmGameObject;

        public VoicevoxSpeaker(Vrm10Instance vrm10Instance, VoicevoxProvider provider)
        {
            GameObject = vrm10Instance.gameObject;
            Vrm10Instance = vrm10Instance;

            // SpeakerのIDを設定
            Id = $"voicevox_vrm_{vrm10Instance.gameObject.GetInstanceID().ToString()}";

            _voicevoxProvider = provider;
            _vrmGameObject = vrm10Instance.gameObject;

            // AudioSourceを追加
            var audioSource = _vrmGameObject.AddComponent<AudioSource>();
            // VRMをリップシンクするためのコンポーネントを追加
            var lipSync = _vrmGameObject.AddComponent<VoicevoxVrmLipSyncPlayer>();

            // 音声合成の再生を行うコンポーネントを追加
            var voicevoxSpeakPlayer = _vrmGameObject.AddComponent<VoicevoxSpeakPlayer>();

            // 紐づける
            voicevoxSpeakPlayer.AudioSource = audioSource;
            voicevoxSpeakPlayer.AddOptionalVoicevoxPlayer(lipSync);

            // 音声合成の再生依頼が流れてくるので、ここで非同期的に逐次処理する
            // VOICEVOXに事前に音声合成リクエストを投げておき、終わったら音声再生を実行する
            // 音声再生が終わったら次の音声合成リクエストが完了するのを待つ、を繰り返す
            _speechRegisterSubject
                .SubscribeAwait(async (values, ct) =>
                {
                    if (voicevoxSpeakPlayer == null) return;

                    var (task, autoResetUniTaskCompletionSource, ctsToken) = values;
                    try
                    {
                        // 音声合成のタスクが完了するまで待機
                        var result = await task;
                        ct.ThrowIfCancellationRequested();

                        // 現在の発話中のテキストを更新
                        _currentSpeakingText.Value = result.Text;

                        await voicevoxSpeakPlayer.PlayAsync(result, ctsToken);
                        autoResetUniTaskCompletionSource.TrySetResult();
                    }
                    catch (OperationCanceledException)
                    {
                        autoResetUniTaskCompletionSource.TrySetCanceled();
                    }
                    catch (Exception e)
                    {
                        autoResetUniTaskCompletionSource.TrySetException(e);
                    }
                    finally
                    {
                        // 発話中のテキストをクリア
                        _currentSpeakingText.Value = "";
                    }
                })
                .RegisterTo(_cancellationTokenSource.Token);
        }

        // GameObjectのIDをSpeakerのIDとして利用
        public sealed override string Id { get; }

        public override GameObject GameObject { get; }
        public override Vrm10Instance Vrm10Instance { get; }

        public override ReadOnlyReactiveProperty<string> CurrentSpeakingText => _currentSpeakingText;

        /// <summary>
        /// 目の位置をSpeakerの顔の位置として利用する
        /// </summary>
        public override Vector3 FacePosition => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.position;

        /// <summary>
        /// 腰の位置をSpeakerの前方向として利用する
        /// </summary>
        public override Vector3 BodyForward => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.forward;

        public override UniTask OnDisposeAsync => _onDisposeUniTaskCompletionSource.Task;


        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, CancellationToken ct)
        {
            var speakParameter = CurrentSpeakParameter.CurrentValue;
            await SpeakAsync(text, speakParameter, ct);
        }

        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, SpeakParameter speakParameter, CancellationToken ct)
        {
            using var lcts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cancellationTokenSource.Token);
            var autoResetUniTaskCompletionSource = AutoResetUniTaskCompletionSource.Create();

            var synthesiser = _voicevoxProvider.Synthesizer.CurrentValue;

            // Voicevoxの音声合成を開始
            var task = synthesiser.SynthesizeSpeechAsync(
                text: text,
                styleId: speakParameter.Style.Id,
                speedScale: (decimal)speakParameter.SpeedScale,
                pitchScale: (decimal)speakParameter.PitchScale,
                volumeScale: (decimal)speakParameter.VolumeScale,
                cancellationToken: lcts.Token);

            // Observableを非同期処理を行えるQueueとして利用
            _speechRegisterSubject.OnNext((task, autoResetUniTaskCompletionSource, lcts.Token));

            // 読み上げが完了するまで待機
            await autoResetUniTaskCompletionSource.Task;
        }

        protected override void OnDisposed()
        {
            _cancellationTokenSource.Cancel();
            _cancellationTokenSource.Dispose();
            _speechRegisterSubject.Dispose(true);
            _currentSpeakingText.Dispose();

            Object.Destroy(_vrmGameObject);
            _onDisposeUniTaskCompletionSource.TrySetResult();
        }
    }
}
```

`Core`が定義した振る舞いを実装するのがこの`Infrastructures`の責務です。
ここの実装を差し替えることで、たとえばVOICEVOX以外の音声合成ソフトウェアに対応したり、ローカル以外からのVRMの読み込みにも対応したりと、`Core`の実装を変更することなくいろいろと拡張や修正が可能となります。


### UseCases

`UseCases`は「アプリケーションに対する操作(`Core`に対する操作)をまとめ、Easyに扱える形式で下位パッケージに提供すること」を責務としています。

「`Speaker`を生成して`RoomSpace`に配置する」という操作を行いたい場合、必要な手続きやルールがありそれを遵守して制御する必要があります。
この手続きをメソッドひとつにまとめ、下位パッケージからは単にメソッドを１つ呼ぶだけで必要な処理が完了することができるのです。


たとえば次のメソッドは`RoomSpaceUseCase`に定義されている、「`Speaker`を生成して`RoomSpace`に登録する」というメソッドです。

* [RoomSpaceUseCase.LoadNewSpeakerAsync](https://github.com/TORISOUP/AvatarSpeaker/blob/f344be503f31f19035d8879a4c7103a2e0133f90/Assets/AvatarSpeaker/Scripts/UseCases/RoomSpaceUseCase.cs#L50)

```cs
/// <summary>
/// Speakerを読み込みRoomSpaceに配置する
/// すでにSpeakerが配置されている場合は、そのSpeakerを削除して新しいSpeakerを配置する
/// </summary>
public async UniTask LoadNewSpeakerAsync(ISpeakerSource speakerSource, CancellationToken ct)
{
    // SpeakerをRoomSpaceに配置する
    // すでにSpeakerが配置されている場合は、そのSpeakerを削除して新しいSpeakerを配置する
    var roomSpace = await _roomSpaceProvider.CurrentRoomSpace
        .FirstAsync(x => x != null, ct);

    if (roomSpace == null) return;
    
    // 現在のSpeakerがRoomSpaceに配置されている場合は削除する
    var currentSpeaker = roomSpace.CurrentSpeaker.CurrentValue;
    if (currentSpeaker != null) roomSpace.RemoveSpeaker(currentSpeaker.Id);

    // Speakerをロードする
    var speaker = await _speakerProvider.LoadSpeakerAsync(speakerSource, ct);

    // 新しいSpeakerをRoomSpaceに配置する
    roomSpace.RegisterSpeaker(speaker);

    // カメラを現在のSpeakerの顔にフォーカスする
    _speakerCameraUseCase.FocusOnCurrentSpeakerFace();
}
```

下位パッケージ、たとえば`UIs`パッケージからはボタン操作に応じてこの`LoadNewSpeakerAsync`を呼び出せば`Speaker`の生成が実行できるようになるのです。

* [LoadVrmUiPresenter](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/UIs/Presenters/LoadVrmUiPresenter.cs)

`Core`はとにかく**Simple**に単純に「各モデルのがやるべきこと」のみ集中して定義し、それらを外から扱うときは`UseCases`経由で**Easy**に扱う、という思想です。
もし挙動を少し変えたくなったのであれば、まず`Core`ではなく`UseCases`でそれを吸収できるかを考えます。それでも難しいとなった場合に`Core`へ手をいれる、という流れになります。

### Views

`Views`は`Core`のモデルを`UnityEngine`を使って表現する場所です。`Speaker`や`SpeakerCamera`を`GameObject`や`Animator`、`Camera`などに紐づけて扱います。

`Views`の責務は「`Core`に定義されたモデルを人間が分かる形で可視化・表現すること」です。`Views`自体は複雑なロジックをもたず、`Core`の状態を反映したり、逆に`Views`のできごとを`Core`に伝えるのみです。

たとえば`Core`に定義されている`SpeakerCamera`は次のような定義となっています。

* [SpeakerCamera](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Core/SpeakerCamera.cs)

```cs
using System;
using R3;
using UnityEngine;

namespace AvatarSpeaker.Core
{
    /// <summary>
    /// RoomSpace内でSpeakerを表示するカメラ
    /// </summary>
    public sealed class SpeakerCamera : IDisposable
    {
        /// <summary>
        /// 位置
        /// </summary>
        public ReactiveProperty<Vector3> Position { get; } = new(Vector3.zero);

        /// <summary>
        /// 姿勢
        /// </summary>
        public ReactiveProperty<Quaternion> Rotation { get; } = new(Quaternion.identity);


        public Action OnDispose { get; set; }


        public void Dispose()
        {
            OnDispose?.Invoke();
        }

        /// <summary>
        /// 指定した位置を向く
        /// </summary>
        public void LookAt(Vector3 target)
        {
            var direction = target - Position.Value;
            Rotation.Value = Quaternion.LookRotation(direction);
        }
    }
}
```

`SpeakerCamera`自体は「カメラ」の概念を抽象化したものであり、これ自体はピュアなC#クラスです。
これを実際のUnityの`Camera`コンポーネントと紐づけ、画面上の描画を制御するクラスが`SpeakerCameraView`です。

`SpeakerCameraView`自体はPrefabとなっており`Camera`コンポーネントを含んでいます。


![SpeakerCameraView.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/2d7258d4-e7f9-f911-9388-f39176daf9f9.jpeg)


* [SpeakerCameraView](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Views/RoomSpaces/SpeakerCameraView.cs)

```cs
using AvatarSpeaker.Core;
using R3;
using UnityEngine;

namespace AvatarSpeaker.Views.RoomSpaces
{
    /// <summary>
    /// SpeakerCameraの表示を管理するView
    /// </summary>
    public sealed class SpeakerCameraView : MonoBehaviour
    {
        // カメラコンポーネント
        [SerializeField] private Camera _camera;

        private SpeakerCamera _speakerCamera;

        public Camera Camera => _camera;

        /// <summary>
        /// 初期化
        /// </summary>
        public void Initialize(SpeakerCamera speakerCamera)
        {
            _speakerCamera = speakerCamera;
            _speakerCamera.OnDispose = () => { Destroy(gameObject); };


            // SpeakerCameraの位置と姿勢を実際のカメラに反映する
            _speakerCamera.Position
                .Subscribe(position => transform.position = position).AddTo(this);
            
            _speakerCamera.Rotation
                .Subscribe(rotation => transform.rotation = rotation).AddTo(this);
        }
    }
}
```

また今回は`RoomSpaceView`がシーン上の親となり、`RoomSpace`に紐づいた`View`はその下に属するようにしています。
（`SubtitleView`は`RoomSpace`とは独立しているので外に出ている）

![views.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/83b05b2d-661a-81ec-a0bf-afa032416e9a.jpeg)


### UIs

`UIs`は「人間からの操作受付や、人間への情報提示を行う場所」です。名前のとおり「ユーザーインタフェース」を担当します。
具体的には`uGUI`の実装がここに置かれています。

基本的に[Model-View-(Reactive)Presenterパターン](https://qiita.com/toRisouP/items/5365936fc14c7e7eabf9)を採用しています。

### Http

`Http`は本アプリケーションが提供する「REST API」を実装する場所です。
`HttpServer`を起動し、外部からのHTTPリクエストを受け付け、`UseCase`を介して`Core`を操作します。

たとえば、`Speaker`に発話させるAPI `/api/v1/speakers/current/speak` はこのように定義されています。

* [SpeakerController](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Http/SpeakerController.cs)

```cs
/// <summary>
/// 発話する
/// </summary>
[Post("/api/v1/speakers/current/speak")]
public async ValueTask SpeakAsync(
    HttpListenerRequest request,
    HttpListenerResponse response,
    CancellationToken ct)
{
    try
    {
        var data = await ReadAsJson<SpeakRequestDto>(request, response);

        if (data.Text == null)
        {
            response.StatusCode = (int)HttpStatusCode.BadRequest;
            return;
        }

        // SpeakerUseCaseを用いて発話命令を送る
        // HTTPレスポンスはすぐに返したいのでForget
        _speakerUseCase
            .SpeakByCurrentSpeakerAsync(data.Text, data.Parameters.ToCore(), default)
            .Forget();

        response.StatusCode = (int)HttpStatusCode.OK;
    }
    catch (Exception)
    {
        response.StatusCode = (int)HttpStatusCode.BadRequest;
    }
}
```

* [RequestsAndResponses](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Http/Models/RequestsAndResponses.cs)

```cs
[DataContract(Name = "SpeakRequest")]
public class SpeakRequestDto
{
    [JsonPropertyName("text")] public string Text { get; set; }
    [JsonPropertyName("Parameters")] public SpeakParametersDto Parameters { get; set; }
}

[DataContract(Name = "SpeakParameters")]
public class SpeakParametersDto
{
    [JsonPropertyName("style")] public int Style { get; set; }
    [JsonPropertyName("speedScale")] public float SpeedScale { get; set; }
    [JsonPropertyName("pitchScale")] public float PitchScale { get; set; }
    [JsonPropertyName("volumeScale")] public float VolumeScale { get; set; }

    public SpeakParameter ToCore()
    {
        return new SpeakParameter(new SpeakStyle(Style, ""), SpeedScale, PitchScale, VolumeScale);
    }
}
```

### DI

`DI`は`VContainer`の`LifetimeScope`が定義される場所です。

### StartUp

`StartUp`はアプリケーションの起動処理を実行する場所です。
`VContainer`に`RegisterEntryPoint`として登録されており、アプリ起動時に`RoomSpace`の初期化と`HttpServer`の起動を実行します。

* [ApplicationStartUp](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/StartUp/ApplicationStartUp.cs)

```cs
using AvatarSpeaker.Http.Server;
using AvatarSpeaker.UseCases;
using VContainer.Unity;

namespace AvatarSpeaker.StartUp
{
    /// <summary>
    /// アプリケーションの動作の起点
    /// </summary>
    public sealed class ApplicationStartUp : IStartable
    {
        private readonly HttpServerRunner _httpServerRunner;
        private readonly RoomSpaceUseCase _roomSpaceUseCase;

        public ApplicationStartUp(RoomSpaceUseCase roomSpaceUseCase, HttpServerRunner httpServerRunner)
        {
            _roomSpaceUseCase = roomSpaceUseCase;
            _httpServerRunner = httpServerRunner;
        }

        public void Start()
        {
            // HTTPサーバーを起動する
            _httpServerRunner.Start();

            // 新しいRoomSpaceを作成する
            _roomSpaceUseCase.CreateNewRoomSpace();
        }
    }
}
```

## 動作シーケンス

### 起動~RoomSpaceの初期化

```plantuml
@startuml

box StartUp #DDDDDD
participant ApplicationStartUp as asu #FFFFFF
box UseCases #FFFFDD
participant RoomSpaceUseCase as rsu #FFFFFF
end box
box Core #FFDDDD
participant IRoomSpaceProvider as isp #FFFFFF
participant IRoomSpaceRegister as isr #FFFFFF
participant RoomSpace as rs #FFFFFF
end box

asu -> asu : 起動
asu -> rsu: CreateNewRoomSpace()
rsu -> isp : RoomSpaceの確認
isp -> rsu
rsu -> rs : 生成
activate rs
rs -> rsu
rsu -> isr : RoomSpaceを登録
isr -> rsu
rsu -> asu : 生成完了

@enduml
```

### RoomSpaceの生成後にViewを生成

```plantuml
@startuml

box UseCases #FFFFDD
participant RoomSpaceUseCase as rsu #FFFFFF
end box
box Core #FFDDDD
participant IRoomSpaceRegister as isr #FFFFFF
participant IRoomSpaceProvider as isp #FFFFFF
end box
box Views #DDDDFF
participant RoomSpaceViewBinder as binder #FFFFFF
participant RoomSpaceView as rsview #FFFFFF
participant SpeakerCameraView as scview #FFFFFF
participant BackgroundView as bview #FFFFFF
end box

binder ->> isp : RoomSpaceの生成を待機
rsu -> isr : RoomSpaceを登録
activate isr
isr -> isp : 生成イベント
isp -->> binder : 生成イベント
binder -> rsview : 生成
activate rsview
binder -> scview : 生成&SpeakerCameraを紐づけ
activate scview
binder -> bview : 生成&RoomSpaceを紐づけ
activate bview
binder -> rsview : もろもろ登録
@enduml
```

### UIを操作してSpeakerをRoomSpaceに追加する

```plantuml
@startuml

actor User

box UIs #DDFFDD
participant Buttonとか as button #FFFFFF
participant LoadVrmUiPresenter as presenter #FFFFFF
end box

box UseCases #FFFFDD
participant RoomSpaceUseCase as rsu #FFFFFF
end box
box Core #FFDDDD
participant IRoomSpaceProvider as isp #FFFFFF
participant RoomSpace as roomSpace #FFFFFF
participant Speaker as speaker #FFFFFF
end box
box Views #DDDDFF
participant RoomSpaceViewBinder as binder #FFFFFF
participant RoomSpaceView as rsview #FFFFFF
participant VrmSpeakerView as sv #FFFFFF
end box

activate roomSpace
activate rsview

binder --> roomSpace: RoomSpaceを監視
User -> button : VRMの読み込み命令
button -->>  presenter: 操作受付
presenter --> rsu : LoadNewSpeakerAsync
rsu ->  isp : RoomSpaceを取得
isp -> roomSpace
roomSpace -> isp
isp -> rsu
rsu -> speaker : 生成
activate speaker
speaker -> rsu
rsu -> roomSpace : Speakerを登録
roomSpace -->> binder : Speaker登録イベント
binder -> sv : 生成
activate sv
binder -> sv : Speakerを紐づけ
speaker <<- sv  : 監視開始
binder -> rsview : SpeakerViewを紐づけ
rsview -> sv

roomSpace -> rsu
rsu -->> presenter

@enduml
```

## データフロー

人間が操作して実際にアプリケーションへ反映されるまでのデータフローですが、次のようになっています。

![dataflow.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/85e03b58-c3a5-783b-865c-6381595f9c09.jpeg)


すべての操作は最終的に`Core`に反映され、その`Core`の変動を`Views`が監視し反映するというフローになっています。`UIs`から直接`Views`を操作することはありません。必ず`Core`を介し、`Core`の状態変更という形でそれが`Views`に反映されるフローとしています。


これはまさに[The Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)で語られていたデータフローと同じですね。

![dataflow2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/e8a107c5-f417-9811-6a7e-467845a2437d.jpeg)


## 各種解説

全体の構成概要は解説したので、続いて細かい部分や要所ごとの説明をします。


### CoreとViewsの紐づけ:Binder

`Views`は`Core`で定義したモデルをUnityの上での「実体」として表現することを責務としています。
基本的には`Core`のモデル定義に対応する形でその`Views`定義が置かれています。

* `RoomSpace` <-> `RoomSpaceView`
* `Speaker` <-> `VrmSpeakerView`
* `SpeakerCamera` <-> `SpeakerCameraView`

`Views`はPrefab化されており、`Core`の要素の変動に合わせて`Instantiate`/`Destroy`されます。
この「`Core`の要素に応じて`Views`の`Instantiate`/`Destroy`を行い、`Core`の要素と`Views`を連携させる」ことを責務とした存在が`Binder`です。

`Binder`は`Core`を監視し、`RoomSpace`の生成や`Speaker`の生成を待ち受けます。
これらイベントが発生したタイミングで対応した`Views`を生成し紐づける処理を行います。

* [RoomSpaceViewBinder](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Views/ViewBinder/RoomSpaceViewBinder.cs)

```cs
using System;
using System.Threading;
using AvatarSpeaker.Core;
using AvatarSpeaker.Core.Configurations;
using AvatarSpeaker.Core.Interfaces;
using AvatarSpeaker.Core.UnityAdapter.VRM;
using AvatarSpeaker.Views.RoomSpaces;
using Cysharp.Threading.Tasks;
using R3;
using UnityEngine;
using VContainer.Unity;
using Object = UnityEngine.Object;

namespace AvatarSpeaker.Views.ViewBinder
{
    /// <summary>
    /// RoomSpace内で生成されたオブジェクトをViewにバインドする
    /// </summary>
    public sealed class RoomSpaceViewBinder : IInitializable, IDisposable
    {
        private readonly IConfigurationRepository _configurationRepository;
        private readonly CancellationTokenSource _cts = new();
        private readonly IRoomSpaceProvider _roomSpaceProvider;
        private readonly RuntimeAnimatorController _speakerAnimatorController;
        private readonly SpeakerCameraView _speakerCameraViewPrefab;
        private readonly SubtitleView _subtitleViewPrefab;
        private readonly UguiRoomSpaceBackgroundView _uguiRoomSpaceBackgroundViewPrefab;

        private RoomSpaceView _currentRoomSpaceView;
        private SubtitleView _subtitleView;

        public RoomSpaceViewBinder(SpeakerCameraView speakerCameraViewPrefab,
            IRoomSpaceProvider roomSpaceProvider,
            UguiRoomSpaceBackgroundView uguiRoomSpaceBackgroundViewPrefab,
            RuntimeAnimatorController speakerAnimatorController,
            SubtitleView subtitleViewPrefab,
            IConfigurationRepository configurationRepository)
        {
            _speakerCameraViewPrefab = speakerCameraViewPrefab;
            _roomSpaceProvider = roomSpaceProvider;
            _uguiRoomSpaceBackgroundViewPrefab = uguiRoomSpaceBackgroundViewPrefab;
            _speakerAnimatorController = speakerAnimatorController;
            _subtitleViewPrefab = subtitleViewPrefab;
            _configurationRepository = configurationRepository;
        }

        public void Dispose()
        {
            _cts.Cancel();
            _cts.Dispose();
        }

        public void Initialize()
        {
            // SubtitleViewは常に1つ存在していてよいのでここで作る
            _subtitleView = Object.Instantiate(_subtitleViewPrefab);

            // RoomSpaceが生成されたらRoomSpaceViewとSpeakerCameraViewを生成
            _roomSpaceProvider.CurrentRoomSpace
                .Where(r => r != null)
                .SubscribeAwait(async (r, ct) => await CreateRoomSpaceViews(r, ct))
                .RegisterTo(_cts.Token);

            // RoomSpaceのSpeakerが変更されたらSpeakerViewを更新
            // ただしここで生成できるものはVrmSpeakerに限る
            _roomSpaceProvider.CurrentRoomSpace
                .Where(r => r != null)
                .SelectMany(r => r.CurrentSpeaker)
                .OfType<Speaker, VrmSpeaker>()
                .Subscribe(speaker =>
                {
                    // Speakerが更新されたとき
                    
                    // SpeakerViewを生成
                    // 実体はInfrastructures.VoicevoxSpeakersで生成されたGameObjectなので、
                    // SpeakerViewはそれをネストして保持するだけの空オブジェクト
                    var speakerViewObject = new GameObject("VrmSpeakerView");
                    var speakerView = speakerViewObject.AddComponent<VrmSpeakerView>();
                    speakerView.SetVrmSpeaker(speaker, _speakerAnimatorController);

                    // ヒエラルキー上の位置を調整
                    speakerViewObject.transform.SetParent(_currentRoomSpaceView.Root.transform);

                    // SubtitleViewにSpeakerをセット
                    _subtitleView.SetUp(speaker, _configurationRepository.IsSubtitleEnabled);
                })
                .RegisterTo(_cts.Token);
        }

        /// <summary>
        /// RoomSpaceが生成されたときの処理
        /// </summary>
        private async UniTask CreateRoomSpaceViews(RoomSpace roomSpace, CancellationToken ct)
        {
            if (roomSpace == null) return;

            // RoomSpaceViewを生成
            var roomSpaceView = RoomSpaceView.Create();

            // SpeakerCameraViewを生成
            var speakerCameraView = Object.Instantiate(_speakerCameraViewPrefab);
            speakerCameraView.Initialize(roomSpace.SpeakerCamera);

            // UguiRoomSpaceBackgroundViewを生成
            // 背景の実装はいくつか考えられるが、今回は「uGUI実装版」を使うことにする
            var backgroundView = Object.Instantiate(_uguiRoomSpaceBackgroundViewPrefab);
            backgroundView.Initalize(roomSpace, speakerCameraView.Camera);

            // RoomSpaceViewに登録
            roomSpaceView.Initalize(roomSpace, backgroundView, speakerCameraView);

            // 現在のRoomSpaceViewを登録
            _currentRoomSpaceView = roomSpaceView;

            // RoomSpaceがDisposeされたらRoomSpaceViewを破棄
            await roomSpace.OnDisposeAsync.AttachExternalCancellation(ct);

            // 現在のRoomSpaceViewの参照を破棄
            _currentRoomSpaceView = null;

            // RoomSpaceViewを破棄
            // 同時にRoomSpaceViewに紐づいているSpeakerCameraViewなどもまとめて破棄される
            Object.Destroy(roomSpaceView.Root);
        }
    }
}
```

今回の実装では`RoomSpaceViewBinder`に責務が集中してしまっています。
まだなんとかなる規模ではありますが、これ以上ややこしくなりそうならクラスの分割を検討したいです。

### Visitorパターンの試用

* [Visitorパターンで型によるswitchやif判定を消す](https://qiita.com/toRisouP/items/d96a09fab827af17fb37)

過去に書いた記事でも紹介していますが、型による分岐を消すテクニックとしてVisitorパターンが存在します。

今回はこれを`ISpeakerSource`で採用してみました。
`ISpeakerSource`は「`Speaker`（事実上のVRM）を読み込むために必要な情報」を抽象化したものです。
今回は実装としては`LocalSpeakerSource`しか存在しませんが、たとえば将来的に`VRoidHubSpeakerSource`みたいなのが増える可能性があります。

* [ISpeakerSource](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Core/Interfaces/ISpeakerSource.cs)

```cs
using System.Threading;
using Cysharp.Threading.Tasks;

namespace AvatarSpeaker.Core.Interfaces
{
    /// <summary>
    /// Speakerのロード元を表す
    /// </summary>
    public interface ISpeakerSource
    {
        UniTask<T> Accept<T>(ISpeakerSourceVisitor<T> visitor, CancellationToken ct);
    }

    /// <summary>
    /// VisitorパターンでISpeakerSourceを処理するためのインタフェース
    /// </summary>
    /// <typeparam name="T"></typeparam>
    public interface ISpeakerSourceVisitor<T>
    {
        UniTask<T> Visit(LocalSpeakerSource source, CancellationToken ct);
    }

    /// <summary>
    /// ローカルファイルからSpeakerをロードするための実装
    /// </summary>
    public readonly struct LocalSpeakerSource : ISpeakerSource
    {
        public string Path { get; }

        public LocalSpeakerSource(string path)
        {
            Path = path;
        }

        public UniTask<T> Accept<T>(ISpeakerSourceVisitor<T> visitor, CancellationToken ct)
        {
            return visitor.Visit(this, ct);
        }

        public override string ToString()
        {
            return $"{nameof(Path)}: {Path}";
        }
    }
}
```

* [VoicevoxSpeakerProvider](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Infrastructures/VoicevoxSpeakers/VoicevoxSpeakerProvider.cs)

```cs
using System;
using System.Threading;
using AvatarSpeaker.Core;
using AvatarSpeaker.Core.Interfaces;
using AvatarSpeaker.Infrastructures.Voicevoxes;
using Cysharp.Threading.Tasks;
using UniVRM10;
using Object = UnityEngine.Object;

namespace AvatarSpeaker.Infrastructures.VoicevoxSpeakers
{
    /// <summary>
    /// VOICEVOXを利用したSpeaker実装を提供する
    /// </summary>
    public sealed class VoicevoxSpeakerProvider : ISpeakerProvider, ISpeakerSourceVisitor<Speaker>, IDisposable
    {
        private readonly VoicevoxProvider _voicevoxProvider;

        public VoicevoxSpeakerProvider(VoicevoxProvider voicevoxProvider)
        {
            _voicevoxProvider = voicevoxProvider;
        }

        public void Dispose()
        {
            // do nothing
        }

        public UniTask<Speaker> LoadSpeakerAsync(ISpeakerSource source, CancellationToken ct)
        {
            // VisitorパターンでISpeakerSourceを処理する
            return source.Accept(this, ct);
        }

        /// <summary>
        /// LocalSpeakerSourceに対する実装
        /// </summary>
        public async UniTask<Speaker> Visit(LocalSpeakerSource source, CancellationToken ct)
        {
            // ローカルパスからVRMをロードしてInstantiate
            var vrmInstance = await Vrm10.LoadPathAsync(source.Path, ct: ct);
            
            // LoadPathAsyncでのctの扱い方が不明なので
            // ここでキャンセルされていたら破棄して例外を投げる
            if (ct.IsCancellationRequested)
            {
                Object.Destroy(vrmInstance.gameObject);
                ct.ThrowIfCancellationRequested();
            }
            
            // VoicevoxSpeakerを生成
            var speaker = new VoicevoxSpeaker(vrmInstance, _voicevoxProvider);
            return speaker;
        }
    }
}
```

Visitorパターンをつかった感想としては「**`Core`で抽象化したのに、その具象を同じ`Core`に置く必要があり、`Infrastructures`を用意した意味が薄れてしまうから失敗だった** 」です。

「`Core`で抽象化して`Infrastructures`で実装する」という思想なのに、実際は`LocalSpeakerSource`を`Core`に配置する必要が出てきてしまいました。

というのもVisitorパターンはダブルディスパッチ、つまり「オブジェクト同士を相互参照させる」ことで堅牢さを作り出すテクニックです。そのため「どんな具象が存在するのかを抽象化した場所で知っていないといけない」ということになり、そもそも今回の`Core`と`Infrastructures`に分ける思想と噛み合ってませんでした。

今回Visitorパターンを使った理由は「`ISpeakerSource`への操作を型安全に行いたい」からです。これが実現できるのであれば別にVisitorパターンである必要はありません。なので`Code Analyzer`などを使って型安全性が保てるのならそっちでもいいかなとは思います。

### VoicevoxSpeaker実装の解説

`VoicevoxSpeaker`は`VOICEVOX`を使って発話する`Speaker`実装です。継承元として`Core.UnityAdapter.VrmSpeaker`を挟んでいます。

* [VoicevoxSpeaker](https://github.com/TORISOUP/AvatarSpeaker/tree/master/Assets/AvatarSpeaker/Scripts/Infrastructures/VoicevoxSpeakers)

```cs
using System;
using System.Threading;
using System.Threading.Tasks;
using AvatarSpeaker.Core.Models;
using AvatarSpeaker.Core.UnityAdapter.VRM;
using AvatarSpeaker.Infrastructures.Voicevoxes;
using Cysharp.Threading.Tasks;
using R3;
using UnityEngine;
using UniVRM10;
using VoicevoxClientSharp;
using VoicevoxClientSharp.Unity;
using Object = UnityEngine.Object;

namespace AvatarSpeaker.Infrastructures.VoicevoxSpeakers
{
    /// <summary>
    /// SpeakerのVOICEVOXとVRM実装
    /// </summary>
    public class VoicevoxSpeaker : VrmSpeaker
    {
        private readonly CancellationTokenSource _cancellationTokenSource = new();
        private readonly ReactiveProperty<string> _currentSpeakingText = new("");

        private readonly UniTaskCompletionSource _onDisposeUniTaskCompletionSource = new();

        private readonly Subject<(ValueTask<SynthesisResult>, AutoResetUniTaskCompletionSource, CancellationToken)>
            _speechRegisterSubject =
                new();

        private readonly VoicevoxProvider _voicevoxProvider;
        private readonly GameObject _vrmGameObject;

        public VoicevoxSpeaker(Vrm10Instance vrm10Instance, VoicevoxProvider provider)
        {
            GameObject = vrm10Instance.gameObject;
            Vrm10Instance = vrm10Instance;

            // SpeakerのIDを設定
            Id = $"voicevox_vrm_{vrm10Instance.gameObject.GetInstanceID().ToString()}";

            _voicevoxProvider = provider;
            _vrmGameObject = vrm10Instance.gameObject;

            // AudioSourceを追加
            var audioSource = _vrmGameObject.AddComponent<AudioSource>();
            // VRMをリップシンクするためのコンポーネントを追加
            var lipSync = _vrmGameObject.AddComponent<VoicevoxVrmLipSyncPlayer>();

            // 音声合成の再生を行うコンポーネントを追加
            var voicevoxSpeakPlayer = _vrmGameObject.AddComponent<VoicevoxSpeakPlayer>();

            // 紐づける
            voicevoxSpeakPlayer.AudioSource = audioSource;
            voicevoxSpeakPlayer.AddOptionalVoicevoxPlayer(lipSync);

            // 音声合成の再生依頼が流れてくるので、ここで非同期的に逐次処理する
            // VOICEVOXに事前に音声合成リクエストだけ投げておき、終わったら音声再生を実行する
            // 音声再生が終わったら次の音声合成リクエストが完了するのを待つ、を繰り返す
            _speechRegisterSubject
                .SubscribeAwait(async (values, ct) =>
                {
                    if (voicevoxSpeakPlayer == null) return;

                    var (task, autoResetUniTaskCompletionSource, ctsToken) = values;
                    try
                    {
                        // 音声合成のタスクが完了するまで待機
                        var result = await task;
                        ct.ThrowIfCancellationRequested();

                        // 現在の発話中のテキストを更新
                        _currentSpeakingText.Value = result.Text;

                        await voicevoxSpeakPlayer.PlayAsync(result, ctsToken);
                        autoResetUniTaskCompletionSource.TrySetResult();
                    }
                    catch (OperationCanceledException)
                    {
                        autoResetUniTaskCompletionSource.TrySetCanceled();
                    }
                    catch (Exception e)
                    {
                        autoResetUniTaskCompletionSource.TrySetException(e);
                    }
                    finally
                    {
                        // 発話中のテキストをクリア
                        _currentSpeakingText.Value = "";
                    }
                })
                .RegisterTo(_cancellationTokenSource.Token);
        }

        // GameObjectのIDをSpeakerのIDとして利用
        public sealed override string Id { get; }

        public override GameObject GameObject { get; }
        public override Vrm10Instance Vrm10Instance { get; }

        public override ReadOnlyReactiveProperty<string> CurrentSpeakingText => _currentSpeakingText;

        /// <summary>
        /// 目の位置をSpeakerの顔の位置として利用する
        /// </summary>
        public override Vector3 FacePosition => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.position;

        /// <summary>
        /// 腰の位置をSpeakerの前方向として利用する
        /// </summary>
        public override Vector3 BodyForward => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.forward;

        public override UniTask OnDisposeAsync => _onDisposeUniTaskCompletionSource.Task;


        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, CancellationToken ct)
        {
            var speakParameter = CurrentSpeakParameter.CurrentValue;
            await SpeakAsync(text, speakParameter, ct);
        }

        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, SpeakParameter speakParameter, CancellationToken ct)
        {
            using var lcts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cancellationTokenSource.Token);
            var autoResetUniTaskCompletionSource = AutoResetUniTaskCompletionSource.Create();

            var synthesiser = _voicevoxProvider.Synthesizer.CurrentValue;

            // Voicevoxの音声合成を開始
            var task = synthesiser.SynthesizeSpeechAsync(
                text: text,
                styleId: speakParameter.Style.Id,
                speedScale: (decimal)speakParameter.SpeedScale,
                pitchScale: (decimal)speakParameter.PitchScale,
                volumeScale: (decimal)speakParameter.VolumeScale,
                cancellationToken: lcts.Token);

            // Observableを非同期処理を行えるQueueとして利用
            _speechRegisterSubject.OnNext((task, autoResetUniTaskCompletionSource, lcts.Token));

            // 読み上げが完了するまで待機
            await autoResetUniTaskCompletionSource.Task;
        }

        protected override void OnDisposed()
        {
            _cancellationTokenSource.Cancel();
            _cancellationTokenSource.Dispose();
            _speechRegisterSubject.Dispose(true);
            _currentSpeakingText.Dispose();

            Object.Destroy(_vrmGameObject);
            _onDisposeUniTaskCompletionSource.TrySetResult();
        }
    }
}
```

発話のために[VoicevoxClientSharp](https://qiita.com/toRisouP/items/11bc12583e2460afe0cb)を使っています（もともとこのライブラリを使った実装サンプルという体でしたし）。

こだわっている点をあげると読み上げのリクエストをキューに追加しつつ非同期で処理している点です。
`VoicevoxClientSharp`で読み上げを行う流れは次の2ステップとなっています。

1. VOICEVOXに音声合成を依頼してwavを生成してもらう
2. Unity上でwavを再生する

この 1. についてはレスポンスに数秒待たされることがあります。そのため「1と2」を1つのタスクとして扱ってしまうと読み上げのテンポが悪くなってしまいます。
そのため今回の実装では「1」のVOICEVOXへのwav合成依頼は先に発行し非同期TaskをQueueに追加し、終わってたら音声合成を行うという流れにしました。

また、非同期Queueの実装として`R3.Obseravble`を使い、それを[SubscribeAwait](https://qiita.com/toRisouP/items/e7be5a5a43058556db8f#subscribeawait)で処理するようにしています。

似たような「非同期Queue」っぽい概念として`AsyncEnumerable`があり、実際これも選択肢として考えました。
ですが今回のような「外から発話リクエストが送り込まれる」というシチュエーションはPush型として捌いた方が素直です。
なのでPull型の`AsyncEnumerable`よりもPush型の`Observable`を選択しました。

<details><summary>AsyncEnumerableで実装してみる場合</summary>

`R3.Observable`を使わない場合は、次の２とおりの実装ができます。

A. `UniTaskAsyncEnumerable`だけを使って実装する
B. `UniRx.Obseravble`と`UniTaskAsyncEnumerable`を併用する

---

`UniTaskAsyncEnumerable`だけを使って実装する場合のパターン。
`Channel`を使えば実現できます。

```cs
using System;
using System.Threading;
using System.Threading.Tasks;
using AvatarSpeaker.Core.Models;
using AvatarSpeaker.Core.UnityAdapter.VRM;
using AvatarSpeaker.Infrastructures.Voicevoxes;
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq; // UniTask.Linqへの参照が必要
using R3;
using UnityEngine;
using UniVRM10;
using VoicevoxClientSharp;
using VoicevoxClientSharp.Unity;
using Object = UnityEngine.Object;

// UniTaskAsyncEnumerableだけを使って実装するパターン
namespace AvatarSpeaker.Infrastructures.VoicevoxSpeakers
{
    /// <summary>
    /// SpeakerのVOICEVOXとVRM実装
    /// </summary>
    public class VoicevoxSpeaker : VrmSpeaker
    {
        private readonly CancellationTokenSource _cancellationTokenSource = new();
        private readonly ReactiveProperty<string> _currentSpeakingText = new("");

        private readonly UniTaskCompletionSource _onDisposeUniTaskCompletionSource = new();

        private readonly
            ChannelWriter<(ValueTask<SynthesisResult>, AutoResetUniTaskCompletionSource, CancellationToken)>
            _channelWriter;

        private readonly VoicevoxProvider _voicevoxProvider;
        private readonly GameObject _vrmGameObject;

        public VoicevoxSpeaker(Vrm10Instance vrm10Instance, VoicevoxProvider provider)
        {
            GameObject = vrm10Instance.gameObject;
            Vrm10Instance = vrm10Instance;

            // SpeakerのIDを設定
            Id = $"voicevox_vrm_{vrm10Instance.gameObject.GetInstanceID().ToString()}";

            _voicevoxProvider = provider;
            _vrmGameObject = vrm10Instance.gameObject;

            // AudioSourceを追加
            var audioSource = _vrmGameObject.AddComponent<AudioSource>();
            // VRMをリップシンクするためのコンポーネントを追加
            var lipSync = _vrmGameObject.AddComponent<VoicevoxVrmLipSyncPlayer>();

            // 音声合成の再生を行うコンポーネントを追加
            var voicevoxSpeakPlayer = _vrmGameObject.AddComponent<VoicevoxSpeakPlayer>();

            // 紐づける
            voicevoxSpeakPlayer.AudioSource = audioSource;
            voicevoxSpeakPlayer.AddOptionalVoicevoxPlayer(lipSync);

            // Channelを作成
            var channel = Channel
                .CreateSingleConsumerUnbounded<(ValueTask<SynthesisResult>, AutoResetUniTaskCompletionSource,
                    CancellationToken)>();

            // ChannelWriterを取得
            _channelWriter = channel.Writer;

            // 音声合成の再生依頼が流れてくるので、ここで非同期的に逐次処理する
            // VOICEVOXに事前に音声合成リクエストだけ投げておき、終わったら音声再生を実行する
            // 音声再生が終わったら次の音声合成リクエストが完了するのを待つ、を繰り返す
            channel.Reader
                .ReadAllAsync()
                .SubscribeAwait(async (values, ct) =>
                {
                    if (voicevoxSpeakPlayer == null) return;

                    var (task, autoResetUniTaskCompletionSource, ctsToken) = values;
                    try
                    {
                        // 音声合成のタスクが完了するまで待機
                        var result = await task;
                        ct.ThrowIfCancellationRequested();

                        // 現在の発話中のテキストを更新
                        _currentSpeakingText.Value = result.Text;

                        await voicevoxSpeakPlayer.PlayAsync(result, ctsToken);
                        autoResetUniTaskCompletionSource.TrySetResult();
                    }
                    catch (OperationCanceledException)
                    {
                        autoResetUniTaskCompletionSource.TrySetCanceled();
                    }
                    catch (Exception e)
                    {
                        autoResetUniTaskCompletionSource.TrySetException(e);
                    }
                    finally
                    {
                        // 発話中のテキストをクリア
                        _currentSpeakingText.Value = "";
                    }
                })
                .RegisterTo(_cancellationTokenSource.Token);
        }

        // GameObjectのIDをSpeakerのIDとして利用
        public sealed override string Id { get; }

        public override GameObject GameObject { get; }
        public override Vrm10Instance Vrm10Instance { get; }

        public override ReadOnlyReactiveProperty<string> CurrentSpeakingText => _currentSpeakingText;

        /// <summary>
        /// 目の位置をSpeakerの顔の位置として利用する
        /// </summary>
        public override Vector3 FacePosition => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.position;

        /// <summary>
        /// 腰の位置をSpeakerの前方向として利用する
        /// </summary>
        public override Vector3 BodyForward => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.forward;

        public override UniTask OnDisposeAsync => _onDisposeUniTaskCompletionSource.Task;


        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, CancellationToken ct)
        {
            var speakParameter = CurrentSpeakParameter.CurrentValue;
            await SpeakAsync(text, speakParameter, ct);
        }

        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, SpeakParameter speakParameter, CancellationToken ct)
        {
            using var lcts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cancellationTokenSource.Token);
            var autoResetUniTaskCompletionSource = AutoResetUniTaskCompletionSource.Create();

            var synthesiser = _voicevoxProvider.Synthesizer.CurrentValue;

            // Voicevoxの音声合成を開始
            var task = synthesiser.SynthesizeSpeechAsync(
                text: text,
                styleId: speakParameter.Style.Id,
                speedScale: (decimal)speakParameter.SpeedScale,
                pitchScale: (decimal)speakParameter.PitchScale,
                volumeScale: (decimal)speakParameter.VolumeScale,
                cancellationToken: lcts.Token);

            // Channelに音声合成のタスクを書き込む
            _channelWriter.TryWrite((task, autoResetUniTaskCompletionSource, lcts.Token));

            // 読み上げが完了するまで待機
            await autoResetUniTaskCompletionSource.Task;
        }

        protected override void OnDisposed()
        {
            _cancellationTokenSource.Cancel();
            _cancellationTokenSource.Dispose();
            _channelWriter.TryComplete();
            _currentSpeakingText.Dispose();

            Object.Destroy(_vrmGameObject);
            _onDisposeUniTaskCompletionSource.TrySetResult();
        }
    }
}
```

---

`UniRx.Obseravble`と`UniTaskAsyncEnumerable`を併用するパターン。
`Obseravable`を作って、それを`UniTaskAsyncEnumerable`に変換してしまえばOKです。

```cs
using System;
using System.Threading;
using System.Threading.Tasks;
using AvatarSpeaker.Core.Models;
using AvatarSpeaker.Core.UnityAdapter.VRM;
using AvatarSpeaker.Infrastructures.Voicevoxes;
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using UniRx;
using UnityEngine;
using UniVRM10;
using VoicevoxClientSharp;
using VoicevoxClientSharp.Unity;
using Object = UnityEngine.Object;

// UniRx.ObseravbleとUniTaskAsyncEnumerableを併用するパターン
namespace AvatarSpeaker.Infrastructures.VoicevoxSpeakers
{
    /// <summary>
    /// SpeakerのVOICEVOXとVRM実装
    /// </summary>
    public class VoicevoxSpeaker : VrmSpeaker
    {
        private readonly CancellationTokenSource _cancellationTokenSource = new();
        private readonly ReactiveProperty<string> _currentSpeakingText = new("");

        private readonly UniTaskCompletionSource _onDisposeUniTaskCompletionSource = new();

        private readonly Subject<(ValueTask<SynthesisResult>, AutoResetUniTaskCompletionSource, CancellationToken)>
            _speechRegisterSubject =
                new();

        private readonly VoicevoxProvider _voicevoxProvider;
        private readonly GameObject _vrmGameObject;

        public VoicevoxSpeaker(Vrm10Instance vrm10Instance, VoicevoxProvider provider)
        {
            GameObject = vrm10Instance.gameObject;
            Vrm10Instance = vrm10Instance;

            // SpeakerのIDを設定
            Id = $"voicevox_vrm_{vrm10Instance.gameObject.GetInstanceID().ToString()}";

            _voicevoxProvider = provider;
            _vrmGameObject = vrm10Instance.gameObject;

            // AudioSourceを追加
            var audioSource = _vrmGameObject.AddComponent<AudioSource>();
            // VRMをリップシンクするためのコンポーネントを追加
            var lipSync = _vrmGameObject.AddComponent<VoicevoxVrmLipSyncPlayer>();

            // 音声合成の再生を行うコンポーネントを追加
            var voicevoxSpeakPlayer = _vrmGameObject.AddComponent<VoicevoxSpeakPlayer>();

            // 紐づける
            voicevoxSpeakPlayer.AudioSource = audioSource;
            voicevoxSpeakPlayer.AddOptionalVoicevoxPlayer(lipSync);

            // 音声合成の再生依頼が流れてくるので、ここで非同期的に逐次処理する
            // VOICEVOXに事前に音声合成リクエストだけ投げておき、終わったら音声再生を実行する
            // 音声再生が終わったら次の音声合成リクエストが完了するのを待つ、を繰り返す
            _speechRegisterSubject
                // IObservableはSubscribeAwaitがない
                // ただし、UniTaskAsyncEnumerableに変換することでSubscribeAwaitを利用できる
                // 今回のシチュエーションでは変換しても全く問題ない
                .ToUniTaskAsyncEnumerable()
                .SubscribeAwait(async (values, ct) =>
                {
                    if (voicevoxSpeakPlayer == null) return;

                    var (task, autoResetUniTaskCompletionSource, ctsToken) = values;
                    try
                    {
                        // 音声合成のタスクが完了するまで待機
                        var result = await task;
                        ct.ThrowIfCancellationRequested();

                        // 現在の発話中のテキストを更新
                        _currentSpeakingText.Value = result.Text;

                        await voicevoxSpeakPlayer.PlayAsync(result, ctsToken);
                        autoResetUniTaskCompletionSource.TrySetResult();
                    }
                    catch (OperationCanceledException)
                    {
                        autoResetUniTaskCompletionSource.TrySetCanceled();
                    }
                    catch (Exception e)
                    {
                        autoResetUniTaskCompletionSource.TrySetException(e);
                    }
                    finally
                    {
                        // 発話中のテキストをクリア
                        _currentSpeakingText.Value = "";
                    }
                })
                .AddTo(_cancellationTokenSource.Token);
        }

        // GameObjectのIDをSpeakerのIDとして利用
        public sealed override string Id { get; }

        public override GameObject GameObject { get; }
        public override Vrm10Instance Vrm10Instance { get; }

        public override IReadOnlyReactiveProperty<string> CurrentSpeakingText => _currentSpeakingText;

        /// <summary>
        /// 目の位置をSpeakerの顔の位置として利用する
        /// </summary>
        public override Vector3 FacePosition => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.position;

        /// <summary>
        /// 腰の位置をSpeakerの前方向として利用する
        /// </summary>
        public override Vector3 BodyForward => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.forward;

        public override UniTask OnDisposeAsync => _onDisposeUniTaskCompletionSource.Task;


        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, CancellationToken ct)
        {
            var speakParameter = CurrentSpeakParameter.CurrentValue;
            await SpeakAsync(text, speakParameter, ct);
        }

        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, SpeakParameter speakParameter, CancellationToken ct)
        {
            using var lcts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cancellationTokenSource.Token);
            var autoResetUniTaskCompletionSource = AutoResetUniTaskCompletionSource.Create();

            var synthesiser = _voicevoxProvider.Synthesizer.CurrentValue;

            // Voicevoxの音声合成を開始
            var task = synthesiser.SynthesizeSpeechAsync(
                text: text,
                styleId: speakParameter.Style.Id,
                speedScale: (decimal)speakParameter.SpeedScale,
                pitchScale: (decimal)speakParameter.PitchScale,
                volumeScale: (decimal)speakParameter.VolumeScale,
                cancellationToken: lcts.Token);

            // Observableを非同期処理を行えるQueueとして利用
            _speechRegisterSubject.OnNext((task, autoResetUniTaskCompletionSource, lcts.Token));

            // 読み上げが完了するまで待機
            await autoResetUniTaskCompletionSource.Task;
        }

        protected override void OnDisposed()
        {
            _cancellationTokenSource.Cancel();
            _cancellationTokenSource.Dispose();
            _speechRegisterSubject.Dispose();
            _currentSpeakingText.Dispose();

            Object.Destroy(_vrmGameObject);
            _onDisposeUniTaskCompletionSource.TrySetResult();
        }
    }
}
```

---

挙動はどっちで書いてもだいたい同じです。使いこなせる自信のあるやり方を採択すればOK。

</details>


### UIsのPresenter実装解説

`UIs`パッケージは基本的に[MV(R)Pパターン](https://qiita.com/toRisouP/items/5365936fc14c7e7eabf9)をベースにしています。`uGUI`やキーボードからの入力操作を`UseCases`に渡し、アプリケーションを外から操作することができるようにしています。

例として、シンプルな「カメラをリセットするボタン」まわりの実装を紹介します。

![resetcamera.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/09144e49-c361-bac3-562b-3f2e0f92db44.gif)


* [ResetCameraPresenter](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/UIs/Presenters/ResetCameraPresenter.cs)

```cs
using AvatarSpeaker.UseCases;
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using UnityEngine;
using UnityEngine.UI;
using VContainer;

namespace AvatarSpeaker.UIs.Presenters
{
    public class ResetCameraPresenter : MonoBehaviour
    {
        [SerializeField] private Button _resetCameraButton;

        private SpeakerCameraUseCase _speakerCameraUseCase;

        [Inject]
        public void Inject(SpeakerCameraUseCase speakerCameraUseCase)
        {
            _speakerCameraUseCase = speakerCameraUseCase;

            _resetCameraButton
                .OnClickAsAsyncEnumerable(destroyCancellationToken)
                .Subscribe(_ => _speakerCameraUseCase.FocusOnCurrentSpeakerFace());
        }
    }
}
```

やってることは単純で、`Button`のイベントがきたら`SpeakerCameraUseCase.FocusOnCurrentSpeakerFace()`という処理を呼び出しているだけです。
`FocusOnCurrentSpeakerFace`は`Speaker`の位置を参照してカメラを正面に再配置するメソッドです。
`Views`は基本このような「`uGUI`のイベントをうけて`UseCases`を触る」という処理が記述されています。

---

そして今回の実装では`UniTask`の`UniTaskAsyncEnumerable`を使って`uGUI`のイベントを扱う方向で統一しています。

```cs
// UniTaskAsyncEnumerableで書いた場合
_resetCameraButton
    .OnClickAsAsyncEnumerable(destroyCancellationToken)
    .Subscribe(_ => _speakerCameraUseCase.FocusOnCurrentSpeakerFace());

// R3.Observableで書いた場合
_resetCameraButton
    .OnClickAsObservable()
    .Subscribe(_ => _speakerCameraUseCase.FocusOnCurrentSpeakerFace())
    .AddTo(this);
```

挙動としてはだいたい同じなのでどっちを使っても問題はないです。

ですが、今回は明確な理由があって`UniTaskAsyncEnumerable`を使っています。
それは「`R3`が`TextMeshPro`のUIコンポーネントの`Observable`化をサポートしていなかったから」です。

```cs
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using R3;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace AvatarSpeaker.UIs.Presenters
{
    public class SamplePresenter : MonoBehaviour
    {
        [SerializeField] private InputField _normalInputField;

        [SerializeField] private TMP_InputField _textMeshProInputField;

        private void Start()
        {
            // R3, 通常のInputField
            _normalInputField
                .OnEndEditAsObservable()
                .Subscribe(_ => { })
                .AddTo(this);

            // R3, TextMeshProのInputField
            // 存在しないためエラーになる
            // _textMeshProInputField
            //     .OnEndEditAsObservable()
            //     .Subscribe(_ =>
            //     {
            //
            //     })
            //     .AddTo(this);

            // UniTask 通常のInputField
            _normalInputField
                .OnEndEditAsAsyncEnumerable(destroyCancellationToken)
                .Subscribe(_ => { });
            
            // UniTask TextMeshProのInputField
            // （UniTask.TextMeshProへの参照が必要）
            _textMeshProInputField
                .OnEndEditAsAsyncEnumerable(destroyCancellationToken)
                .Subscribe(_ => { });
        }
    }
}
```

`UniTask`であれば`TextMeshPro`コンポーネントの`UniTaskAsyncEnumerable`化ができるためこちらを使用しました。
`Observable`と`UniTaskAsyncEnumerable`が混ざってしまうのも嫌なので、今回は`UniTaskAsyncEnumerable`で統一することにしています。

### HttpServerの解説

REST APIとしてHTTPで`Speaker`に発話させたいため、簡易なHTTPサーバーを実装しています。

C#の`HttpListener`を使ってサーバー本体の実装を行い、各リクエストに応じたハンドリングを自作した`Controller`クラスに任せるという仕組みになっています。
`Controller`クラスは`Attribute`でHTTPメソッドとRouteを定義できるようにしており、そこそこの拡張性を持たせています。
（例： `[Post("/api/v1/speakers/current/speak")]` と`Attribut`をメソッド定義に追加するだけでREST APIとして公開ができる）

* [HttpServer](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Http/Server/HttpServer.cs)

```cs
using System;
using System.Collections.Generic;
using System.Net;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace AvatarSpeaker.Http.Server
{
    /// <summary>
    /// HTTPサーバー
    /// </summary>
    public sealed class HttpServer : IDisposable
    {
        private delegate ValueTask Handler(HttpListenerRequest req, HttpListenerResponse res, CancellationToken ct);

        private readonly HttpListener _httpListener = new();

        private readonly Dictionary<(string, string), Handler> _routes = new();
        private CancellationTokenSource _cts = new();
        private bool _isDisposed;

        public void Dispose()
        {
            if (_isDisposed) return;

            Stop();
            ((IDisposable)_httpListener)?.Dispose();
            _isDisposed = true;
        }

        private void AddGet(string localPath, Handler handler) => Method("GET", localPath, handler);
        private void AddPost(string localPath, Handler handler) => Method("POST", localPath, handler);
        private void AddPut(string localPath, Handler handler) => Method("PUT", localPath, handler);
        private void AddDelete(string localPath, Handler handler) => Method("DELETE", localPath, handler);

        private void Method(string httpMethod, string localPath, Handler handler)
        {
            _routes.Add((httpMethod, localPath), handler);
        }


        public void Start(int port)
        {
            if (_isDisposed) throw new ObjectDisposedException(nameof(HttpServer));

            Stop();
            _cts = new CancellationTokenSource();

            _httpListener.Prefixes.Clear();
            _httpListener.Prefixes.Add($"http://127.0.0.1:{port}/");

            _httpListener.Start();
            _ = HandleRequestsAsync(_cts.Token);
        }

        public void Stop()
        {
            if (_isDisposed) return;

            _cts?.Cancel();
            _cts?.Dispose();
            _cts = null;

            _httpListener.Stop();
        }

        private async ValueTask HandleRequestsAsync(CancellationToken ct)
        {
            while (!ct.IsCancellationRequested)
            {
                try
                {
                    var context = await _httpListener.GetContextAsync().ConfigureAwait(false);
                    ct.ThrowIfCancellationRequested();
                    var request = context.Request;
                    var response = context.Response;
                    try
                    {
                        if (_routes.TryGetValue((request.HttpMethod, request.Url.LocalPath), out var handler))
                        {
                            // メインスレッドに切り替えてから処理
                            await UniTask.SwitchToMainThread();
                            await handler(request, response, ct);
                        }
                        else
                        {
                            response.StatusCode = (int)HttpStatusCode.NotFound;
                        }
                    }
                    catch (Exception e)
                    {
                        Debug.LogException(e);
                        response.StatusCode = (int)HttpStatusCode.InternalServerError;
                    }
                    finally
                    {
                        response.Close();
                    }
                }
                catch (ObjectDisposedException)
                {
                    // ignore
                }
                catch (Exception ex) when (ex is not OperationCanceledException)
                {
                    Debug.LogException(ex);
                }
            }
        }

        /// <summary>
        /// Controllerを登録する
        /// Reflectionを使っているので、パフォーマンスに注意
        /// </summary>
        public void RegisterController(BaseController baseController)
        {
            var type = baseController.GetType();
            var methods = type.GetMethods(BindingFlags.Instance | BindingFlags.Public);

            foreach (var method in methods)
            {
                var h = method.GetCustomAttribute<RouterAttribute>();
                if (h == null) continue;

                var handler = new Handler((req, res, ct) =>
                    (ValueTask)method.Invoke(baseController, new object[] { req, res, ct }));

                switch (h)
                {
                    case Get:
                        AddGet(h.LocalPath, handler);
                        break;
                    case Post:
                        AddPost(h.LocalPath, handler);
                        break;
                    case Put:
                        AddPut(h.LocalPath, handler);
                        break;
                    case Delete:
                        AddDelete(h.LocalPath, handler);
                        break;
                }
            }
        }
    }
}
```

* [SpeakerController](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Http/SpeakerController.cs)

```cs
using System;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AvatarSpeaker.Http.Models;
using AvatarSpeaker.Http.Server;
using AvatarSpeaker.UseCases;
using Cysharp.Threading.Tasks;

namespace AvatarSpeaker.Http
{
    /// <summary>
    /// /api/v1/speakers/
    /// </summary>
    public sealed class SpeakerController : BaseController
    {
        private readonly SpeakerUseCase _speakerUseCase;

        public SpeakerController(SpeakerUseCase speakerUseCase)
        {
            _speakerUseCase = speakerUseCase;
        }

        /// <summary>
        /// 現在のSpeakerの設定で発話する
        /// </summary>
        [Post("/api/v1/speakers/current/speak_current_parameters")]
        public async ValueTask SpeakCurrentParamsAsync(
            HttpListenerRequest request,
            HttpListenerResponse response,
            CancellationToken ct)
        {
            try
            {
                var data = await ReadAsJson<SpeakRequestCurrentParamsDto>(request, response);

                if (data.Text == null)
                {
                    response.StatusCode = (int)HttpStatusCode.BadRequest;
                    return;
                }

                // HTTPレスポンスはすぐに返したいのでForget
                _speakerUseCase.SpeakByCurrentSpeakerAsync(data.Text, default).Forget();

                response.StatusCode = (int)HttpStatusCode.OK;
            }
            catch (Exception)
            {
                response.StatusCode = (int)HttpStatusCode.BadRequest;
            }
        }


        /// <summary>
        /// 発話する
        /// </summary>
        [Post("/api/v1/speakers/current/speak")]
        public async ValueTask SpeakAsync(
            HttpListenerRequest request,
            HttpListenerResponse response,
            CancellationToken ct)
        {
            try
            {
                var data = await ReadAsJson<SpeakRequestDto>(request, response);

                if (data.Text == null)
                {
                    response.StatusCode = (int)HttpStatusCode.BadRequest;
                    return;
                }

                // SpeakerUseCaseを用いて発話命令を送る
                // HTTPレスポンスはすぐに返したいのでForget
                _speakerUseCase
                    .SpeakByCurrentSpeakerAsync(data.Text, data.Parameters.ToCore(), default)
                    .Forget();

                response.StatusCode = (int)HttpStatusCode.OK;
            }
            catch (Exception)
            {
                response.StatusCode = (int)HttpStatusCode.BadRequest;
            }
        }

        /// <summary>
        /// 現在の設定値を取得する
        /// </summary>
        [Get("/api/v1/speakers/current/parameters")]
        public async ValueTask GetParameters(
            HttpListenerRequest request,
            HttpListenerResponse response,
            CancellationToken ct)
        {
            try
            {
                var speaker = _speakerUseCase.GetCurrentSpeaker();
                if (speaker == null)
                {
                    response.StatusCode = (int)HttpStatusCode.NotFound;
                    return;
                }

                var parameters = speaker.CurrentSpeakParameter.CurrentValue;
                var dto = new SpeakParametersDto
                {
                    Style = parameters.Style.Id,
                    SpeedScale = parameters.SpeedScale,
                    PitchScale = parameters.PitchScale,
                    VolumeScale = parameters.VolumeScale
                };

                await SuccessAsJson(response, dto);
            }
            catch (Exception)
            {
                response.StatusCode = (int)HttpStatusCode.BadRequest;
            }
        }

        /// <summary>
        /// 現在の設定値を上書きする
        /// </summary>
        [Put("/api/v1/speakers/current/parameters")]
        public async ValueTask PutParameters(
            HttpListenerRequest request,
            HttpListenerResponse response,
            CancellationToken ct)
        {
            try
            {
                var speaker = _speakerUseCase.GetCurrentSpeaker();
                if (speaker == null)
                {
                    response.StatusCode = (int)HttpStatusCode.NotFound;
                    return;
                }

                var dto = await ReadAsJson<SpeakParametersDto>(request, response);
                var parameters = dto.ToCore();

                if (!parameters.Validate())
                {
                    response.StatusCode = (int)HttpStatusCode.BadRequest;
                    return;
                }

                speaker.ChangeSpeakParameter(parameters);

                response.StatusCode = (int)HttpStatusCode.OK;
            }
            catch (Exception)
            {
                response.StatusCode = (int)HttpStatusCode.BadRequest;
            }
        }
    }
}
```

* [HttpServerRunner](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Http/Server/HttpServerRunner.cs)

```cs
using System;
using System.Collections.Generic;
using System.Threading;
using AvatarSpeaker.Core.Configurations;
using R3;

namespace AvatarSpeaker.Http.Server
{
    /// <summary>
    /// HTTPServerの実行を管理する
    /// </summary>
    public sealed class HttpServerRunner : IDisposable
    {
        private readonly IConfigurationRepository _configurationRepository;

        private readonly IEnumerable<BaseController> _controllers;
        private readonly CancellationTokenSource _cts = new();
        private readonly HttpServer _currentHttpServer;
        
        public HttpServerRunner(IEnumerable<BaseController> controllers,
            IConfigurationRepository configurationRepository)
        {
            _currentHttpServer = new HttpServer();
            _controllers = controllers;
            _configurationRepository = configurationRepository;
        }


        public void Dispose()
        {
            _cts.Cancel();
            _cts.Dispose();
            _currentHttpServer?.Dispose();
        }

        public void Start()
        {
            foreach (var controller in _controllers)
            {
                // Controllerを登録する
                _currentHttpServer.RegisterController(controller);
            }

            _configurationRepository
                .HttpServerSettings
                .Subscribe(x =>
                {
                    if (x.IsEnabled)
                        _currentHttpServer.Start(x.Port);
                    else
                        _currentHttpServer.Stop();
                })
                .RegisterTo(_cts.Token);
        }
    }
}
```

## テストについて

今回テストは書けていません。単純にアドベントカレンダーに間に合わせるために急いで実装していたのもあり、テストについて気が回っていませんでした。ここは明確な反省点です。

ただ`HumbleObject`パターンの思想に則ると、「`UseCase`のテスト」は比較的書きやすくなっていると考えます。
`Infra`や`Views`や`UIs`のテストは実装が難しい割に旨味があまりないのでコスパが悪いとは思います。

## DIについて

今回は[VContainer](https://github.com/hadashiA/VContainer)を使ってDIを実現しています。

それ以上は特に説明することはないです。

## その他

その他、ぼんやりと考えていた設計思想を書きます。

* `RoomSpace`は再生成可能にしたかった
  * 動的に破棄・生成をしてもアプリケーションが動作するように
  * 「現在の状態を保存する/読み込む」みたいな機能を想定するとほしい
* `Speaker`は複数体配置できるようにしたかった
  * 実装上は現状1体しか`RoomSpace`に配置しない形式にはしたが、拡張自体は容易なはず
* `Views`や`UIs`が多少汚いのは許容する
  * `Core`さえキレイならその汚れは下位レイヤーが引き受けてよい、という考え
  * DDDには腐敗防止層という概念があったりするし、「本質的に面倒くさい処理はどうやっても泥臭くなる」という割り切りが自分の中にある


# 今回の設計で実装を進めた感想

### 微妙だと思ったところ

* **動作確認が可能になるまでが長い**
  * `Core`、`Infra`、`Views`、`UIs`をすべて定義・実装しないと動作確認までもっていけない
  * 「雑に実装してとりあえず動きを見る」という方向性と相性が悪い

* **機能を増やすときにいじるべき場所が多い**
  * 機能を1つ実装するにしても複数パッケージをまたぐ必要があり手間が多い

* **Coreをキレイに定義するのが非常に難しい**
  * 中核となる`Core`をどれだけキレイに定義して運用するかにすべてがかかっている
  * ここが上手くできるのであればこの設計は凄く上手にワークする
    * 逆にここが適当だと効果は薄い

* **具象に引っ張られてすぎて、抽象化した意味が薄い部分が出ている**
  * 今回は「VOICEVOX」と「VRM」を使うことを目的にしつつ、そこを抽象化して扱っているため手段と目的が一致しないことになっていた
    * 「抽象的な設計で実装する」は手段であり、その目的は「将来的な仕様変更や機能拡張に柔軟に対応できるようにするため」である。
    * 一方で「VOICEVOXを使う」「VRMを使う」というほぼ実装決め打ちのサンプルプログラムなので、微妙に噛み合ってない
    * 結果として「抽象化して扱っている割に実装がVRM/VOICEVOXしかないので、回りくどいことだけやって終わった」という感じになっている

* **UIsやViewsやInfraに”汚れ”が溜まりやすい**
  * `Core`と`UseCases`をキレイにしようとした場合、その辻褄合わせが下位レイヤーに寄りがちになってしまう
    * [RoomSpaceViewBinder](https://github.com/TORISOUP/AvatarSpeaker/blob/master/Assets/AvatarSpeaker/Scripts/Views/ViewBinder/RoomSpaceViewBinder.cs)が結構キタナイ
    * 逆に「`Core`と`UseCases`がキレイならそれでいいじゃん」ともいえる
      * 少なくともこの2つのパッケージさえキレイであればアプリケーションの根幹には影響を与えることはないため「下位レイヤーを捨てて再実装する」という選択肢が取れる

* **ドメインモデル貧血症になりつつある**
  * `Core`がほとんどロジックをもたず、`UseCases`や`Infra`に知識が漏れ出している感が否めない
  * 根幹の「テキストの読み上げ処理」が`Infra`にまるっと実装されており`Core.Speaker`がスカスカになっている
    * 非同期Queueを使った読み上げタイミングの管理は`Core`に実装するべきだったように思える
      * 抽象化を工夫すれば`Core`に持ってこれる気がする、あとで直すかも

### 良かったところ

* **要所要所で考えるべきポイントが明確化される**
  * パッケージの責務が明確なので、どの機能をどこに作るべきかがハッキリしており迷うことが少ない
    * 逆に迷った場合は「このパッケージの本来の意図はなんだったっけ？」に立ち返ると判断しやすくなる
      * たとえば、`UIs`パッケージ外でUIに関心を持ったコードが登場したらそれは「間違い」と断言できる
  * 「要件を抽象化してどう`Core`で扱うか」の部分だけは非常に難しくここだけスキルが要求されるが、それさえできるのであればかなり扱いやすい
    * いじるべきパッケージが多いというデメリットもあるが、逆にいうと「実装はただの作業であって考えることがあんまりない」というメリットがあるともいえる
  
* **後半になるほど実装速度が加速する**
  * 序盤の動作確認が可能な状況にするまでが長いが、いったん動作確認ができるようなればそれ以降の機能追加は非常に早い

 
* **機能拡張や保守が容易**
  * `Core`で抽象化しているため、実装ライブラリを差し替えたり追加が比較的容易にできる(はず)
  * パッケージごとの役割を把握すればコードが読めるので、チーム開発で効果を発揮する


### 全体を通しての感想

今回は「`VoicevoxClientSharp`というライブラリを作ったからこれのサンプル実装を作って公開しよう」という目的から始まり、実装していくうちに「これガチガチに設計したら面白いんじゃない？」と目的がズレていきました。それもあってか抽象化が上手くできてない部分があるような気もするのですが、全体としてはまぁまぁよく設計できたと思ってます。

この設計で実装を始めた当初は「**失敗した！この規模のプロダクトにこれはやりすぎだ！手間がかかりすぎる！**」と後悔していました。まずどんなパッケージ構造にするかを決め、`Core`にいろいろインタフェースを用意して、`Views`と`Infra`でどう`GameObject`を扱うかを考え、`UIs`を実装し、それらが全部終わってやっと動作確認ができるという状況でした。そのため実装開始から動作確認ができるようになるまでに数日かかってしまいました。

とまぁ、実装当初は後悔していましたが、後半になるにつれて「**むしろこの設計やっぱり正解では？**」と考えが変わりました。
骨組みができるまでは大変ですが一度そこができてしまえばあとは流れ作業で機能追加ができました。後から「字幕機能を追加しちゃおう」とか考えられる時間的な余裕と、それを容易に受け入れられる設計の余地を作ることもできました。~~余裕があったならテストも書け。~~

ということで、最終的な感想としては「**本当に使い捨て前提のプロトタイピング以外では、基本的にクリーンアーキテクチャを意識したほうがよい**」です。
（実装スピードが最優先のプロトタイピングで、ガチガチのアーキテクチャを組むのはやり過ぎ感が否めません）。

ただ、ゲーム開発ではクリーンアーキテクチャが上手く活用できない領域があることも理解しています。とくにアクション系のゲームのインゲームをクリーンアーキテクチャで構築するのはコスパが非常に悪いでしょう。その場合は[以前の記事で紹介した](https://qiita.com/toRisouP/items/79b97c472e588bb91c52#%E5%A4%A7%E4%BA%8B%E3%81%AA%E3%81%93%E3%81%A82)ように、インゲーム部分はUnityベタで書いて、アウトゲーム部分だけでもクリーンさを意識する、みたいなハイブリットさを検討してもよいと思います。

# まとめ

* クリーンアーキテクチャを意識して非ゲームアプリケーションを作ってみた
* 実装は出だしのスピード感は遅いが、後半になるにつれて実装速度は加速する
* キレイな抽象化は非常に難しいが、それさえ達成できれば全体としてはとてもクリーンになる（その抽象化がそうそうできない、という話ではあるが…）

### おまけ

- [バーチャルキャストの舞台裏 :メタバースの長期運用を実現する技術と戦略](https://www.docswell.com/s/torisoup/5YWX2E-xrkaigi2023)

私が会社で開発しているプロダクトも似たようなアーキテクチャで組んでいます。こちらも興味があれば御覧ください。

今回の設計はそのアーキテクチャの簡易版みたいなものでした。