はじめに
Unityerのみなさん、AppUI使っていますか?
おそらく使っていないでしょう...
AppUIはUnity上でアプリが作れるというフレームワークです。
AppUI 公式ページ
https://docs.unity3d.com/Packages/com.unity.dt.app-ui@1.0/manual/index.html
ですが、このAppUIできないことが少なくない数あります。
今回はそのうちの一つである「AppUI.Reduxでビジネスロジックをドメイン層に書けない」問題を改善するライブラリUnityReduxMiddlewareを開発しました。
リポジトリ
というか...
もうAppUI消えそうな予感が...
警告
Unity内部でのみ使用できます。このフレームワークはサポートされていません。
v1.0.6でアップデート終了ということなんでしょうか...
UnityReduxMiddleware
このライブラリは本家ReduxのMiddlewareを参考にしています。
なのでまずはMiddlewareとはなんぞやというところから説明します。
Reduxの説明については以下の記事を参考にしてください。
Middleware
Middlewareとは
通常のdispatch処理では、
1. Actionをdispatchする。
2. ActionをもとにReducerがStateを更新する。
https://www.infinijith.com/blog/redux/redux-middleware
といった流れになると思います。
ここでAPI通信をしたい!となるとdispatchはStateを更新するしかできないわけですからdispatchを呼ぶ側がなんとかしないといけません。
となると、通信処理を書く場所としては、
- MVVM
- dispatchを呼ぶであろうViewModel
- MVP
- dispatchを呼ぶであろうPresenter (実態はView?)
になるでしょう。(プロジェクトにもよるので断言はできません)
どちらにしても処理をプレゼンテーション層に書いていることになります。
そこで次のようなdispatch処理ができると便利なわけです。
1. Actionをdispatchする。
2. Actionによって副作用(API通信やらロジックやら)を起こす。
3. ActionをもとにReducerがStateを更新する。
https://www.infinijith.com/blog/redux/redux-middleware
この2に相当する機能がMiddleware
になります。
MiddlewareにはActionが毎回流れてきて、任意の処理を実行することができます。
また、Middlewareは完全に独立した存在であるためテストが容易です。
Middlewareの作成
dispatchの前後でログを出力するLoggerMiddleware
を作成してみましょう。
以下のようになります。
using UnityEngine;
using UnityReduxMiddleware;
public class LoggerMiddleware
{
public static MiddlewareDelegate Create()
{
return store => next => async (action, token) =>
{
// action: dispatchされた際のActionが渡される。
// token: Cancellation Token
// next: 次のmiddleware
var appName = action.type.Split('/')[0]; // アプリ名をアクションから取得する。
Debug.Log(store.GetState()[appName]); // 現在の状態をログに出力する。
await next(action, token); // 次のミドルウェアを呼び出す。 次のミドルウェアがない場合はdispatchが呼ばれる。
Debug.Log(store.GetState()[appName]); // dispatch後の状態をログに出力する。
};
}
}
Middlewareは複数適用することができるため、next()
を呼ぶと次のMiddlewareに値が渡ります。次のMiddlewareがない場合はdispatchが走りStateの更新が起こります。
Middlewareを適用する
作成したLoggerMiddleware
を適用してみましょう。
適用する際はStore
ではなくMiddlewareStore
を使用してください。
using Unity.AppUI.Redux;
using UnityReduxMiddleware;
public class Sample
{
public void Main()
{
var store = new MiddlewareStore(); // Middlewareを使用する際はStoreではなくMiddlewareStoreを使用する。
store.AddMiddleware(LoggerMiddleware.Create()); // LoggerMiddlewareを追加する。
}
}
このstore
に対してdispatchを呼ぶことでLoggerMiddleware
内の処理も実行されます。(実際はCreateSliceも記述してください)
また、Middlewareはあくまで処理を記述する薄い層という位置づけなのでMiddlewareStoreはMiddlewareの例外を処理しません。
その代わりに例外処理をするExceptionMiddleware
を提供しています。
基本的にExceptionMiddleware
を適用しましょう。
store.AddMiddleware(ExceptionMiddleware.Create());
store.AddMiddleware(LoggerMiddleware.Create());
非同期用Dispatchメソッド
MiddlewareはTaskを返すので、これに対応する形でDispatchAsync
が用意されています。
Task DispatchAsync(Action action, CancellationToken token = default)
ですが、個人的にこれを利用することはあまりないのではないかとも思っています。
基本的にボタン押下時にdispatchしたいといったPush挙動が主であり、dispatchを待機したいといった場面があまり思いつきません。例えば通信処理を待機してdispatch完了後にまた別のdispatchを発火したいといった場合は、最初のdispatch内のMiddlewareに書けばいいですし... Fire and Forget(投げっぱなし)で問題なく動作するはずです。Stateの変更検知もObservableObjectが拾ってくれるためより存在価値が薄くなってきます。
ただ、戻り値がTaskであるのにそれを待機できないことはまずいですのでDispatchAsyncを用意しています。
以上がMiddlewareに関する説明になります。
Epic
ここまでMiddlewareはロジック・非同期処理を書けるといってきましたが、Rxでも記述したいという欲がでてくる人もいるのではないでしょうか。
理由 (浅いです)(議論の余地あります)
ReduxはPush挙動に近いと個人的に思う。
(Actionが流れてきて処理をする)
ObservableもPush挙動であるため、そろえたほうがデータフロー的に自然...?
また、ロジック処理に限って言えば投げっぱなしでよいことのほうが多い。(同期処理Onlyの非同期メソッド)
そうあるならばわざわざasync/awaitを使用せずにLINQで処理できるRxで記述したほうがよいのではないか。
その要求に応えるためにEpicという概念を持ち込んで実現可能にしました。
redux-observableを参考にしています。
Epicとは
Epic とは Observableを受け取り、Observableを返す関数のことを言います。
redux-observableではこれを「Actions in, actions out.」と呼んでいます。
https://makeitnew.io/epic-reactive-programming-with-redux-observable-eff4d3fb952f
dispatchのたびにEpicへObservable<Action>
が流れてきます。
このObservable<Action>
に処理を加えることで任意の処理実行を実現します。
またEpicはMiddlewareと同様に各々が完全に独立しているためテストが容易です。(リアクティブスパゲッティにもなりにくい...?)
セットアップ
Epicを利用するためにはR3の導入が必須です。
以下を参考に導入してください。
Epicの作成
Observable<Action>
が流れてきた際に現在のStateを出力するEpicを作ってみましょう。
Epicを作成する際はStateを定義しておく必要があります。
public static Epic<AppState> CreateEpic()
{
return Epic.Create<AppState>((action, state) =>
{
return action.Do(_ => Debug.Log($"State: {state.CurrentValue}")); // 現在のStateを出力
});
}
public record AppState
{
public int Count { get; set; }
}
Epicの適用
Epicを適用するため専用のMiddlewareであるEpicMiddleware
を以下のように利用します。
public void Main()
{
var store = new MiddlewareStore();
var epicMiddleware = EpicMiddleware.Default<AppState>(); // エピックミドルウェアを作成する。
store.CreateSlice("app", new AppState(), builder =>{ }); // スライスを作成する。
store.AddMiddleware(epicMiddleware.Create()); // エピックミドルウェアを追加する。
epicMiddleware.Run(CreateEpic()); // エピックを実行する。
store.Dispatch(new Action("")); // アクションをディスパッチする。
}
Run
をすることで、EpicとMiddlewareStoreを紐づけることができます。
Actionの再発行
専用のオペレータを用いることで特定のActionが流れたときに別のActionを流すといった処理も書けます。
public static Epic<AppState> RequestMessageEpic()
{
return Epic.Create<AppState>((action, state) =>
action.OfAction("App/RequestMessage") // "App/RequestMessage"のActionのみ通す。
.Dispatch(new Action<string>("App/SetMessage", "Hello World"))); // 新しくActionを作成して返す。
}
このEpicは"App/RequestMessage"というActionがdispatchされたときに"App/SetMessage"というActionを"Hello World"の値付きで作成して返すというものです。
フィルタリング
実際のユースケースにおいて「特定のActionがdispatchされたときに処理したい。」といったものがほとんどだと思います。
そのためフィルターしたいActionをあらかじめ指定することができます。
上で作成したRequestMessageEpic
を書き換えてみます。
// 1 Epic.Createを使う方法
Epic.Create<AppState>("App/RequestMessage",(action, state) =>
action.Dispatch(new Action<string>("App/SetMessage", "Hello World")));
// 2 stringの拡張メソッドを使う方法
"App/RequestMessage".CreateEpic<AppState>((action, state) =>
action.Dispatch(new Action<string>("App/SetMessage", "Hello World")));
// 3 ActionCreatorの拡張メソッドを使う方法
var actionCreator = Unity.AppUI.Redux.Store.CreateAction<string>("App/RequestMessage");
actionCreator.CreateEpic<AppState>((action, state) =>
action.Dispatch(new Action<string>("App/SetMessage", "Hello World")));
この方法ではActionをトリガーのように扱えるためより自然に記述することができると思います。
Combine
Epic.Combine
を使用して、複数のEpicを1つにまとめることができます。
また、EpicBuilder
を利用して以下のように使用することもできます。
public Epic<State> RootEpic()
{
var builder = Epic.CreateBuilder<State>();
Actions.SendRequest.CreateEpic<State>((action, state) =>
action.Dispatch(Actions.SendAction.Invoke("Requesting..."))
).AddTo(ref builder);
Actions.SendRequest.CreateEpic<State>((action, state) =>
action.Delay(TimeSpan.FromSeconds(2))
.Dispatch(Actions.SendAction.Invoke("Hello, Unity!"))
).AddTo(ref builder);
return builder.Build();
}
内部的にはObservableをMergeしているだけです。
依存の注入
各Epicに任意の型を持たせることができます。
例えばEnemyManager
クラスを注入したい場合は
public class EnemyManager
{
...
}
以下のようにEpicを作成できます。
public Epic<AppState,EnemyManager> CreatEnemyEpic()
{
// こういった使い方が正しいかは置いておいて
return (action, state, dependency) => action
.OfAction(Actions.CreateEnemy)
.Do((x) =>
{
dependency.CreateEnemy();
});
}
以上。
さいごに
UnityReduxMiddlewareについて紹介しました。
全く目的用途が異なりますがVitalRouterとロジック処理を挟むという点で立ち位置的には似てるかなとも思っています。
もう消えそうなAppUIですがなんとか頑張ってほしいところです(切実)