【Unity】次世代のUniRx「R3」を導入してみる
Unity 2022.3.16
R3 0.1.2
はじめに
2024年1月9日、Cysharp社からR3というライブラリがプレビュー公開されました。
github.com新しいC#用のRxをプレビューリリースしました!dotnet/reactiveとUniRxの進化系を目指して、抜本的なAPIの見直しと、現代の技術での再実装による飛躍的なパフォーマンス向上、そしてプラットフォーム抽象化により、WPF, Unity, AvaloniaそしてGodotに対応しました!https://t.co/McU0VSOyRk
— neuecc (@neuecc) 2024年1月9日
UnityでReactive Extensions(Rx)を実現するライブラリとしては既にUniRxがありました。UniRxもCysharp社の代表を務められているneueccさんがリリースされているライブラリになっています。
github.com
ただ、最新版である7.1.0のリリースは2020年2月26日とほぼ4年前でそれ以降新機能の追加やUnity・C#の更新に伴うパフォーマンス向上のアップデートは行われておらず、個人的にはUniRxの更新ないしRxの実現を図るハイパフォーマンスなライブラリの登場は待ち望んでいたところでした。R3自体はUnityでの使用に限定されたものではなく、UniRxとの完全な互換性があるわけでもありませんが、Unityに統合するためのパッケージも同時に提供されておりUnityを使用する上ではUniRxの進化版ライブラリといえそうです。
具体的に自分が更新を望んでいた機能の例として、AddTo
という拡張メソッドの存在があります。AddTo
メソッドはIDisposable
に対する拡張メソッドの形で定義されており、ストリームの購読破棄をゲームオブジェクトの破棄タイミングに紐づけて行えるという非常に便利な機能を提供してくれます。以下のサンプルコードはかなり恣意的なものですが、AddTo
メソッドを用いることでsampleStaticSubject
に対して購読と購読破棄をメソッドチェーンの形で記述できています。
using UniRx; using UnityEngine; public sealed class AddToTest : MonoBehaviour { private static readonly Subject<Unit> sampleStaticSubject = new(); private void Start() { sampleStaticSubject // 購読 .Subscribe(_ => Debug.Log("Test")) // 購読破棄をゲームオブジェクトの破棄と同時に行う .AddTo(this); sampleStaticSubject.OnNext(Unit.Default); } }
一方でこのAddTo
メソッドを使用すると、実行時にゲームオブジェクトに対してObservableDestroyTrigger
という購読しているストリームを管理するためのコンポーネントが自動的にアタッチされます。
これは従来のUnityの仕組み上このような形で実装せざるを得なかったと思いますが、できればゲームオブジェクトに余計にコンポーネントを増やすことは避けたいところです。しかしUnity 2022.2からMonoBehaviourにdestroyCancellationToken
というCancellationToken
型のプロパティが追加され、これを用いることでコンポーネントを増やすことなく購読の寿命の紐付けが簡易かつハイパフォーマンスに行えるようになりました。(非同期処理の文脈においてですが、以下のUnity公式の動画でdestroyCancellationToken
について軽く触れられています。)
新しいR3ではここについても現代化が図られています。先ほどのサンプルと同様のコードをR3を使って書くとこんな感じになります。
using R3; using UnityEngine; public sealed class AddToTest : MonoBehaviour { private static readonly Subject<Unit> sampleStaticSubject = new(); private void Start() { sampleStaticSubject // 購読 .Subscribe(_ => Debug.Log("Test")) // 購読破棄をゲームオブジェクトの破棄と同時に行う .AddTo(this); sampleStaticSubject.OnNext(Unit.Default); } }
実際、using UniRx;
がusing R3;
に書き換わっただけで他の部分は完全に同じです。ですがR3の方のAddTo
は以下のように内部的にdestroyCancellationToken
に対する紐付けをする実装に変わっており、管理用のコンポーネントがアタッチされたりすることはないコスパの良いものになっています。
public static CancellationTokenRegistration AddTo(this IDisposable disposable, MonoBehaviour value) { return disposable.AddTo(value.destroyCancellationToken); }
上記であげた例は非常に表層的なもので、実際には抜本的な設計の変更や大幅なパフォーマンス向上、ストリームの購読状況が可視化できるTracker Windowの付属など聞いただけでわくわくするような機能が多く盛りこまれているライブラリになっています。R3のGitHubリポジトリにあるREADMEには詳細についてまとめられているため、そちらを読めば大体の内容がわかると思います。R3の具体的な進化点については調査中のところもあるので、また別記事にまとめさせていただきたいと思います。
R3はUniRxと導入方法(配布形式)が変わっているため、この記事ではUnityにR3を導入するところまでを行ってみます。
注意
R3はプレビュー段階のライブラリです。バグが含まれていたりAPIが大きく変更されたりする可能性が十分にあるので、プロジェクトに導入する際はバックアップを取るなどの準備を行ってからにした方が良いでしょう。
インストール
R3はNuGetパッケージとして配布されています。
ここから直接dllをダウンロードしてくるのも良いのですがアップデートが面倒だったり依存しているパッケージもあったりするので、今回はR3のREADMEで紹介されているようなNuGetForUnityを使ったインストール方法にします。
NuGetForUnityのインストール
NuGetForUnityはNuGetのパッケージをUnityへ簡単に導入することができるようにするライブラリです。
この記事ではOpenUPM経由でインストールしますが、git urlでも問題なく入れることができます。
Project SettingsからPackage Managerのタブを開き、Scoped Registryとして以下のように入力し、Applyを押します。
Name | OpenUPM(任意の名前) |
URL | https://package.openupm.com |
Scope(s) | com.github-glitchenzo.nugetforunity |
これでPackage Managerの選択肢にMy Registriesが追加されます。その後Window/Package Manager
からPackage Managerを開きMy Registriesを選択、表示されているNuGetForUnityをインストールします。
R3(コア部分)のインストール
次にR3のコア部分をインストールします。NuGetForUnityのインストールが成功していればUnityのツールバーにNuGetの表示が増えているはずなので、そこからManage NuGet Packagesを開き検索バーに入力されているSearchの文字をR3に置き換えてSearchを押します。余談ですが、NuGetForUnityを使う際プレースホルダとしてのSearchの文字がラベルではなく実際に入力されている文字なのがいつも気になります。 表示された中からR3 by Cysharpとなっているパッケージを見つけ、インストールします。2024年1月11日現在のバージョンは0.1.2のようです。
R3(Unity統合部分)のインストール
最後にR3のUnityに対する機能を提供する部分をインストールします。まだOpenUPMでの提供は行われていないため(プレビュー版ということもあり今後提供されるかも不明です)、git url経由でインストールします。またWindow/Package Manager
を開き、Add package from git URL...を選択して出た入力欄にhttps://github.com/Cysharp/R3.git?path=src/R3.Unity/Assets/R3.Unity
と入力してAddします。
これでR3のUnity統合部分もインストールされ、使用する準備が整いました。
【Unity】Source GeneratorでMessagePipeのセットアップを行う拡張メソッドを自動生成する
はじめに
MessagePipeというUnityで利用できるメッセージングライブラリがあります。
とても便利なライブラリで自分もよく個人的な開発で使用させていただいているのですが、このライブラリはDI前提の作りになっています。
例えば、以下のようなメッセージMoveInput
をMessagePipe上でやり取りしたいという状況を考えます。
public static class SampleMessages { public struct MoveInput { public UnityEngine.Vector2 Move; } }
メッセージのPublish/Subscribeを行うためには、使用しているDIコンテナでメッセージを配信するためのセットアップを行う必要があります。下記はDIコンテナとしてVContainerを使用した時の例です。
using MessagePipe; using VContainer; using VContainer.Unity; public class SampleLifetimeScope : LifetimeScope { protected override void Configure(IContainerBuilder builder) { var options = builder.RegisterMessagePipe(); builder.RegisterMessageBroker<SampleMessages.MoveInput>(options); } }
そしてセットアップされたDIコンテナからのインジェクションを受けることで、メッセージの送受信を行いたいオブジェクトはIPublisher/ISubscriberのインスタンスを取得することができます。以下は方向キーの入力を受け付けて移動するスクリプトの実装例です。SampleInput
が入力メッセージであるMoveInput
を送信し、SamplePlayer
がMoveInput
を受信して移動処理を行なっています。
using MessagePipe; using UnityEngine; using VContainer; public class SampleInput : MonoBehaviour { [Inject] private IPublisher<SampleMessages.MoveInput> sampleMessagePublisher; private void Update() { var move = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical")); // 方向入力を送信 sampleMessagePublisher.Publish(new SampleMessages.MoveInput { Move = move }); } }
using Cysharp.Threading.Tasks; using MessagePipe; using UnityEngine; using VContainer; public class SamplePlayer : MonoBehaviour { [SerializeField] private float speed = 1f; [Inject] private ISubscriber<SampleMessages.MoveInput> messageDSubscriber; private Vector2 move; private void Start() { messageDSubscriber // 方向入力を受信 .Subscribe(x => move = x.Move) .AddTo(destroyCancellationToken); } private void Update() { transform.position += new Vector3(move.x, 0f, move.y) * (speed * Time.deltaTime); } }
このように、MessagePipeを用いることでメッセージの送信側であるSampleInput
とメッセージの受信側であるSamplePlayer
はお互いのことを一切知らずにメッセージの型であるMoveInput
のみを介してやりとりすることができます。
ただこの送受信するメッセージが増えてくると、コンテナへの登録が徐々に大変になってきます。
public static class SampleMessages { public struct MessageA { } public struct MessageB { public float Value; } public class MessageC { } public class MessageD { public bool Value; } public record MessageE { } }
using MessagePipe; using VContainer; using VContainer.Unity; public class SampleLifetimeScope : LifetimeScope { protected override void Configure(IContainerBuilder builder) { var options = builder.RegisterMessagePipe(); builder.RegisterMessageBroker<SampleMessages.MessageA>(options); builder.RegisterMessageBroker<SampleMessages.MessageB>(options); builder.RegisterMessageBroker<SampleMessages.MessageC>(options); builder.RegisterMessageBroker<SampleMessages.MessageD>(options); builder.RegisterMessageBroker<SampleMessages.MessageE>(options); } }
人力でいちいち登録する処理を記述するのはただ大変なだけではなく、メッセージの登録をうっかり忘れたり間違えたりすることによって本来発行されているはずのメッセージを受け取れないということも発生し得ます。 このようなボイラープレートは何らかの手段で自動生成した方が効率的で間違いを減らせそうです。 そこで今回は、Source Generatorを使い自動でMessageBroker登録用の拡張メソッドを生成しようと思います。
仕様を考える
先ほどのSampleMessages
を例に考えてみます。
public static class SampleMessages { public struct MessageA { } public struct MessageB { public float Value; } public class MessageC { } public class MessageD { public bool Value; } public record MessageE { } }
staticなクラス内に使用するメッセージの型宣言を列挙していますが、このstaticクラスを1つのスコープとみた場合クラス内のメッセージを一括で登録できるようにすれば便利そうです。
public static void RegisterSampleMessages(this IContainerBuilder builder, MessagePipeOptions options) { builder.RegisterMessageBroker<MessageA>(options); builder.RegisterMessageBroker<MessageB>(options); builder.RegisterMessageBroker<MessageC>(options); builder.RegisterMessageBroker<MessageD>(options); builder.RegisterMessageBroker<MessageE>(options); }
このstaticクラスをpartialで宣言し、何らかの属性を付加することでSource Generator側でフックして拡張メソッドを追加するという方針で実装してみます。属性の名前はここではRegistrableMessages
とします。
実装
まず、生成する属性およびpartialクラスのT4テンプレートを作成します。
<#@ template debug="false" hostspecific="false" language="C#" linePragmas="false" #> #pragma warning disable CS8669 #pragma warning disable CS8625 using System; namespace RegisterMessageBrokerGenerator { [AttributeUsage(AttributeTargets.Class)] internal class RegistrableMessagesAttribute : Attribute { } }
<#@ template debug="false" hostspecific="false" language="C#" linePragmas="false" #> #pragma warning disable CS8669 #pragma warning disable CS8625 using MessagePipe; using VContainer; <# if (!string.IsNullOrEmpty(Property.Namespace)) { #> namespace <#= Property.Namespace #> { <# } #> public static partial class <#= Property.ClassName #> { public static void Register<#= Property.ClassName #>(this IContainerBuilder builder, MessagePipeOptions options) { <# foreach (var message in Property.Messages) { #> builder.RegisterMessageBroker<<#= message #>>(options); <# } #> } } <# if (!string.IsNullOrEmpty(Property.Namespace)) { #> } <# } #>
次に、SourceGeneratorの実装を示します。
using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace RegisterMessageBrokerGenerator { [Generator(LanguageNames.CSharp)] public class SourceGenerator : ISourceGenerator { public void Initialize(GeneratorInitializationContext context) { context.RegisterForSyntaxNotifications(SyntaxContextReceiver.Create); } public void Execute(GeneratorExecutionContext context) { var registrableAttr = new RegistrableMessagesAttributeTemplate().TransformText(); context.AddSource("RegistrableMessagesAttribute.g.cs", registrableAttr); if (context.SyntaxContextReceiver is not SyntaxContextReceiver receiver || receiver.ClassDeclarations.Count == 0) { return; } foreach (var classDeclaration in receiver.ClassDeclarations) { var innerClassDeclarations = classDeclaration.Members.OfType<TypeDeclarationSyntax>().ToArray(); var property = new MessagesProperty { Namespace = GetNamespaceFrom(classDeclaration), ClassName = classDeclaration.Identifier.ToString(), Messages = innerClassDeclarations.Select(x => x.Identifier.ToString()).ToArray() }; var generatingClass = new MessagesTemplate(property).TransformText(); context.AddSource($"{classDeclaration.Identifier}.g.cs", generatingClass); } } private static string GetNamespaceFrom(SyntaxNode s) => s.Parent switch { NamespaceDeclarationSyntax namespaceDeclarationSyntax => namespaceDeclarationSyntax.Name.ToString(), null => string.Empty, _ => GetNamespaceFrom(s.Parent) }; } internal class SyntaxContextReceiver : ISyntaxContextReceiver { internal static ISyntaxContextReceiver Create() { return new SyntaxContextReceiver(); } public HashSet<TypeDeclarationSyntax> ClassDeclarations { get; } = new(); public void OnVisitSyntaxNode(GeneratorSyntaxContext context) { var node = context.Node; // 対象nodeが型宣言でない場合は処理しない if (node is not (ClassDeclarationSyntax or StructDeclarationSyntax or RecordDeclarationSyntax or InterfaceDeclarationSyntax)) return; var typeSyntax = (TypeDeclarationSyntax)node; // 対象nodeが属性を持たない場合は処理しない if (typeSyntax.AttributeLists.Count <= 0) return; var attr = typeSyntax.AttributeLists.SelectMany(x => x.Attributes) .FirstOrDefault(x => { // 属性がRegistrableMessagesの場合は処理する var hasRegistrable = x.Name.ToString() is "RegistrableMessages" or "RegistrableMessagesAttribute"; return hasRegistrable; }); if (attr != null) { ClassDeclarations.Add(typeSyntax); } } } }
これをビルドしてRoslynAnalyzer
のタグをつけた上でUnityに追加することで、動作してくれるようになります。