こんにちは。カバー株式会社エンジニアのKです。
前回↓は新規プロジェクトのアーキテクチャと、ZenjectやAssembly Definitionを導入した話を書きました。
シリーズ最終回の今回は、今までアプリ開発・運用をしてきた中での反省を踏まえ、これから先どのようにアーキテクチャと付き合っていくべきかについて、自分の考えをまとめたいと思います。
ホロライブアプリのリニューアル
前回、「3D空間から配信するアプリ」として新しいプロジェクトを立ち上げたことを説明しました。このアプリは半年後に無事リリースでき、それ以来多くのタレントに活用していただき、一定の成功を収めることができました。
その中で、従来のホロライブアプリは従来のアーキテクチャ(第一回参照)のまま運用を続けていましたが、従来のアプリにも新機能をつけたいという要望が高まってきました。特に、「3D空間配信アプリ」で、3Dキャラクター向けの新しい表現や機能がいくつか追加されたのですが、それらの機能をホロライブアプリにも逆輸入したくなったのです。また、Live2Dについても、まだまだ実装したい機能がありました。
この要求を叶えるため、ホロライブアプリもリニューアルすることにしました。そして、リニューアル版の実装は「3D空間配信アプリ」のプロジェクト内で共存させる形で進めることに決めました。「3D空間配信アプリ」には既に3Dモデルを動かすための機能が一通り揃っています。Live2Dなどの従来のホロライブアプリの機能をこのプロジェクトに移植することで、2つのアプリの機能を両立しつつ、共通する部分については二度手間を避けて開発できるという狙いです。
実際、この方針は狙い通りに効果を発揮し、無事一つのプロジェクトで2つのアプリの開発を両立させることができました。「3D空間配信アプリ」のリリースからさらに半年後、リニューアルしたホロライブアプリもリリースしました。
こうして一つになったプロジェクトでさらなる開発を進めました。ホロライブアプリにもLive2D 3.0や画像貼り付け機能、モーション機能などたくさんの新機能を実装し、今日に至ります。
アーキテクチャの問題点
前回紹介した上の図がほぼそのまま、現在開発を進めているプロジェクトのアーキテクチャになっています。
とりあえず今日まで無事に開発を進められたという点で、このアーキテクチャは一定の成功を収めたと言えるとは思います。しかし、開発を進めていく中で問題点もいくつか見えてきたので、それらについて説明します。
1. リアルタイム通信モジュールの置き場所
ホロライブアプリには、他のユーザーのアプリと動きを連動できる「コラボ機能」があります。ネットワーク越しにモデルの動きをリアルタイムで送ることで実現しています。
コラボ機能の開発当初、リアルタイム通信部分をPresentationレイヤーの中に配置していました。一旦はその方針で実装が完了し、一通り動くようになりました。しかし、機能追加によって通信で送りたい情報が増えてくると、通信部がPresenterやViewと分離していないことで、非常に読みづらく改修しづらいコードになってきました。
そこで、リアルタイム通信を行うレイヤーを別途設けることにし、このレイヤーを「Collaboration」と名付けました。
Collaborationレイヤーの依存先は、Domainレイヤーのみにすることが理想と考えていました。しかし、アプリの負荷を下げるために、Viewの中でHumanoidのIKを解いた結果をそのまま相手側に送りたいという要求がありました(各クライアントでIKを解き直していると非効率な処理になるため)。これは、CollaborationがPresentationに依存しないと不可能なことです。そこで、CollaborationレイヤーはDomainとPresentationレイヤーに依存するというルールに変更しました。
2. Observableソースの置き場所
本プロジェクトではUniRxを使用しており、主なイベント伝達の起点はSubjectやReactivePropertyなどのObservableソースになっています。これらのソースの置き場所は、UseCaseとViewにしていました。
しかし、開発を進めていく中で、UseCaseにObservableソースを置いていることの問題点が明らかになってきました。それは、ObservableソースのDispose()が確実に行われるようにするのが面倒という問題です。
SubjectとReactivePropertyは、使用後はDispose()することが推奨されています。UniRxの機能により、MonoBehavoiurやCompositeDisposableをAddTo()することで、それらが破棄されたときにObservableソースも連動して破棄されるようになります。
ところが、UseCaseはMonoBehaviourではないPure C#のクラスなので、そのままではAddToすることができません。Zenjectには、IDisposableを実装したクラスを登録するとDispose()を自動で呼んでくれる機能がありますが、これを使うとしても、Observableソースを保持しているUseCaseでCompositeDisposableを宣言し、Dispose()内で忘れずに破棄しなければならず、面倒さは残ります。
このコードは例えば以下のようになります。シンプルな例なのでこれだけと言えばこれだけなのですが、UseCaseのほぼ全てのクラスに適用するとなると結構面倒です。
using Zenject;
using UniRx;
public class HogeUseCase : System.IDisposable, IInitializable
{
private ReactiveProperty<int> _count = new ReactiveProperty<int>();
public IReadOnlyReactiveProperty<int> Count => _count;
private ICountRepository _repository;
private CompositeDisposable _compositeDisposable = new CompositeDisposable()
public HogeUseCase(ICountRepository repository){
_repository = repository;
}
public void Initialize(){
_count.value = _repository.Load();
_count.AddTo(_compositeDisposable);
}
public void Dispose(){
_compositeDisposable.Dispose();
}
}
using Zenject;
public class ARKitFaceTrackerInstaller : MonoInstaller
{
public override void InstallBindings()
{
//IDisposableとIInitializableのBindを忘れずに行う必要がある
Container
.BindInterfacesAndSelfTo<HogeUseCase>()
.AsCached();
}
}
MonoBehaviour禁止のDomainレイヤー内でObservableソースを持つ限りは、この問題は避けられそうにありません。
この問題の解決策を考えていくと、UseCaseにReactivePropertyを持たせるのが良くなかったのではないかということに行き当たりました。UseCaseで主にやっている処理は、IRepositoryからデータを取ってきてIObservableとして公開するというものです。この、Obervableを生成する処理はDataレイヤー側に持たせた方が良かったかなと思っています。そうすることで、Domainレイヤー内にObservableのライフタイム管理というダーティーな仕事を背負わせずに済ませられます。
3. UseCaseの存在意義が薄い
2の問題とも関連するのですが、UseCaseは結局何をするレイヤーなのかということを突き詰めていくと、IRepositoryから受け取ったデータをほぼそのままPresenter向けに公開するだけなのでは?という疑念が生まれてきました。2で提示した「ObsevableソースをDomainからDataに追い出す」というリファクタリングを行った後のことを考えると、余計にUseCaseの意味が薄くなります。
UseCaseで扱うデータ構造はDomain内でクラスとして定義されています。IRepositoryからデータを受け取るときには既にDomain側のデータ構造に整形されているので、UseCaseは整形を行わなくて済みます。Presenter側に公開するときもそのままDomainのデータ構造として渡すことが多いため、UseCase内ではデータ構造の変換処理すら、ほとんど行わない実態になっています。
つまり突き詰めると、UseCaseが行う処理はデータを左から右に流しているだけであり、ほとんど中身のない処理をやっていることになります。DataStore → Repository → IRepository → UseCase → Presenter のデータフローは、UseCase無しでも一応成り立ってしまうということです。
では、UseCaseを削除して、PresenterからIRepositoryが直接データを取ってくるという方針でプログラムを書き換えるといいのでしょうか?
そうとも限らないのが、難しい問題です。直接IRepositoryを参照するようにすればコードを書く量を減らせます。しかし、全てのUseCaseが無くなるわけではないので、場所によってPresenterがUseCaseを参照するのかIRepositoryを参照するのかがマチマチになってしまいます。書き方の一貫性が下がってしまい、チーム開発ではあまり好ましくありません。
将来的には、Unity2021から使用できるようになった「Source Generator」を使ってメタプログラミングを行うことで、一貫性とコードの作業量の軽減を両立できたらいいなー、など思っています。
もし、次に一からプロジェクトを作るなら
最後に、これまでの運用経験を踏まえて、もし今後に同じようなアプリをUnityで作るならこうしたいという見解を書きます。
- ObservableソースはDomainレイヤーに置かない
- 特別な事情が無い限りは、MonoBehaviourにAddToしてライフタイムを管理するのが楽そう。
- Controlレイヤーは廃止する(行っていた処理の内容はPresentationに移行する)
- Controlレイヤーでは外部からの入力(トラッキングの入力など)の処理などを行っていたが、Domainを経由しないとPresentationにデータを渡せないのが不便で、デメリットの方が大きいと感じた。
- 他にも、毎フレーム行うような処理(インゲーム的な処理)については、割り切ってMonoBehavoiurの組み合わせで実装してしまった方が良さそう。
- Assembly Definition の「Auto Referenced」をできるだけfalseに設定してコンパイル時間を減らす
- プロジェクトのコード量が増えるにつれて、コンパイルの時間がかなり増えてしまった。
- Assembly DefinitionのAuto Referencedをfalseにしておくことで、コンパイル時間を減らせるというテクニックがある。( 参照→https://docs.unity3d.com/ja/2021.3/Manual/class-AssemblyDefinitionImporter.html )
- ただし、Auto ReferencedをfalseにするとAssembly-CSharpからの参照が不可能になるというデメリットがあるので、「Assembly Definitionを極力配置する」などの社内ルールを制定しないと、徹底は難しいかもしれません。
まとめ
このシリーズでは3回に渡って、ホロライブアプリにおけるこれまでのアーキテクチャについて説明してきました。3年を超える開発・運用経験を一度振り返っておきたいという思いから、このシリーズを書きました。かなりニッチな内容も多かったので、決して多くの人に楽しんでもらえるような内容ではなかったと思いますが、もし同じような悩み・課題を抱えている方の参考になるのであれば幸いです。
ここまでお付き合いいただきありがとうございました!