3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】AppUI上でロジックをいい感じに書けるUnityReduxMiddleware作ってみた

Posted at

はじめに

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消えそうな予感が...

image.png

DeepL翻訳
警告
Unity内部でのみ使用できます。このフレームワークはサポートされていません。

v1.0.6でアップデート終了ということなんでしょうか...

UnityReduxMiddleware

このライブラリは本家ReduxのMiddlewareを参考にしています。
なのでまずはMiddlewareとはなんぞやというところから説明します。
Reduxの説明については以下の記事を参考にしてください。

Middleware

Middlewareとは

通常のdispatch処理では、

1. Actionをdispatchする。
2. ActionをもとにReducerがStateを更新する。 

image.png
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を更新する。  

image.png
https://www.infinijith.com/blog/redux/redux-middleware

この2に相当する機能がMiddlewareになります。
MiddlewareにはActionが毎回流れてきて、任意の処理を実行することができます。
また、Middlewareは完全に独立した存在であるためテストが容易です。

Middlewareの作成

dispatchの前後でログを出力するLoggerMiddlewareを作成してみましょう。
以下のようになります。

LoggerMiddleware.cs
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が用意されています。

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.」と呼んでいます。

image.png
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ですがなんとか頑張ってほしいところです(切実)

3
3
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?