【UniRx】MessageBrokerの使い方

UniRx に付属しているプロセス内の簡易型の広域メッセージ送受信ライブラリのMessageBroker の使い方の紹介です。

最近は MessageBroker の代わりによりモダンな MessagePipe というライブラリもリリースされていますが、Unity で単一シーン、簡単な UI しか持たないアプリに DI だのなんだのを導入するまでもないため、イベント関係で必須の UniRx にも似たような機能がり、簡単に使うにはそれでいいじゃんということで使い方の紹介です。

DI と関係なく使用するためスコープなんか無いですし、インジェクションだのは使用しません。

確認環境

  • Unity 2021.3.16f1
  • Windows11 + VisualStudio2022
  • UniRx 7.1.0

使用方法

交換する型の作成

同じ型どうしでメッセージがやり取りできるのでデータをやり取りする型を作成します。

// classではなくreadonly structのほうがGCAllocが少ない
public readonly struct SamplEventArgs
{
    public readonly int Value;
    public SamplEventArgs(int value) => Value = value;
}

int とかでもやり取りできますが同じ型で登録したハンドラーが全部反応する & 意味不明になるため専用の型を作成します。

イベントを発行する

// (1) フィールドに送信する変数を取ってから送信する
IMessagePublisher b = MessageBroker.Default;
private void Send()
{
    b.Publish(new SamplEventArgs(10));
}

// (2) 直接送信する
private void Send()
{
    MessageBroker.Default.Publish(new SamplEventArgs(20));
}

イベントを購読する

// (1) フィールドに受信する変数を取ってから送信する
IMessageReceiver r = MessageBroker.Default;
private void Receive()
{
    r.Receive<SamplEventArgs>()
        .Subscribe(e => Debug.Log(e.Value)).AddTo(this);
}

// (2) 直接受信を登録する
private void Receive()
{
    MessageBroker.Default.Receive<SamplEventArgs>()
        .Subscribe(e => Debug.Log(e.Value)).AddTo(this);
}

DomainReload対応

static 変数を使ってるので DomainReload を有効にしてる場合、2度目から正常に動かなくなるので以下を修正しておきます。

修正しないと Publish したときにオブジェクトが Destory 済みですみたいなメッセージが表示されます。

MessageBroker.cs

// ★修正前
public class MessageBroker : IMessageBroker, IDisposable
{
    /// <summary>
    /// MessageBroker in Global scope.
    /// </summary>
    public static readonly IMessageBroker Default = new MessageBroker();


// ★修正後
public class MessageBroker : IMessageBroker, IDisposable
{
    /// <summary>
    /// MessageBroker in Global scope.
    /// </summary>
    public static IMessageBroker Default => _default;
    private static IMessageBroker _default = new MessageBroker();

    [UnityEngine.RuntimeInitializeOnLoadMethod(
        UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
    static void OnDidReloadScripts()
    {
        _default = new MessageBroker();
    }

関連記事

MessageBrokerを少し便利にする実装です。

takap-tech.com

【UniRx】MessageBrokerを拡張する(Pub/Subの型指定)

UniRx に付属しているプロセス内の簡易型の広域メッセージ送受信ライブラリのMessageBroker をちょっと拡張してみようと思います。

  • 拡張内容
    • (1) pub/sub は型指定したい
    • (2) 直接 Subscribe したい
    • (3) MessageBroker には触らないで拡張する

確認環境

  • Unity 2021.3.16f1
  • Windows11
  • VisualStudi2022
  • UniRx 7.1.0

実装コード

// IPublisherSubscriber.cs

using System;

namespace UniRx
{
    // 型を指定したpub
    public interface IMessagePublisher<T>
    {
        /// <summary>
        /// Send Message to all receiver.
        /// </summary>
        void Publish(T message);
    }

    // 型を指定したsub
    public interface IMessageReceiver<T>
    {
        /// <summary>
        /// Subscribe typed message.
        /// </summary>
        IObservable<T> Receive();
    }

    // pub/sub共通
    public interface IMessageBroker<T> : IMessagePublisher<T> , IMessageReceiver<T>
    {
        public class DefaultImpl : IMessageBroker<T>
        {
            private readonly IMessageBroker _service;
            public DefaultImpl(IMessageBroker service) => _service = service;
            public void Publish(T message) => _service.Publish(message);
            public IObservable<T> Receive() => _service.Receive<T>();
        }
    }

    // 型の決まったpub/subを取得できるようにメソッドを追加する
    public static class MessageBrokerExtensions
    {
        public static IMessagePublisher<T> GetPublisher<T>(this IMessageBroker self)
        {
            return new IMessageBroker<T>.DefaultImpl(self);
        }
        public static IMessageReceiver<T> GetSubscriber<T>(this IMessageBroker self)
        {
            return new IMessageBroker<T>.DefaultImpl(self);
        }
    }
    
    // 直接Subscribeできるようにメソッドを追加する
    public static class IMessageReceiverExtensions
    {
        public static IDisposable Subscribe<T>(this IMessageReceiver self, Action<T> action)
        {
            return self.Receive<T>().Subscribe(action);
        }
        public static IDisposable Subscribe<T>(this IMessageReceiver<T> self, Action<T> action)
        {
            return self.Receive().Subscribe(action);
        }
    }
}

使い方

フィールドにとるときに型が指定できるようになります。

using UniRx;
using UnityEngine;

// 通知用のクラス
public readonly struct SampleEventArgs
{
    public readonly int Value;
    public SampleEventArgs(int value) => Value = value;
}

// 型を指定して初期化する
IMessagePublisher<SampleEventArgs> _pub = MessageBroker.Default.GetPublisher<SampleEventArgs>();
IMessageReceiver<SampleEventArgs> _sub = MessageBroker.Default.GetSubscriber<SampleEventArgs>();

public void Foo()
{
    // オペレーターを挟む
    _sub.Receive().Take(5).Subscribe(v => Debug.Log(v.Value));

    // 直接購読する
    _sub.Subscribe(v => Debug.Log(v.Value));

    // メッセージを送信する
    _pub.Publish(new SampleEventArgs(10));
}

元々の使い方(参考)

いちおう対比のために元の使い方も記載

using UniRx;
using UnityEngine;

// 型指定しないと若干ガバい
IMessagePublisher _pub0 = MessageBroker.Default;
IMessageReceiver _sub0 = MessageBroker.Default;

public void Bar()
{
    // 使用時に型の指定がないから何でも送信できるので型を間違えたりする可能性あり
    _sub0.Receive<SampleEventArgs>().Subscribe(v => Debug.Log(v.Value));
    _pub0.Publish(new SampleEventArgs(10));

    // 結局こうしたくなる → staticの直接参照はあんまりよくない
    MessageBroker.Default.Receive<SampleEventArgs>().Subscribe(v => Debug.Log(v.Value));
    MessageBroker.Default.Publish(new SampleEventArgs(10));
}
}

【Unity】タッチ処理の実装(エディタ、実機両対応)

【Unity】スマホとPCの両方のタッチに対応する

PCのクリックとスマホのタッチだと実は検出の実装方法が違うとのことで調べてみました。スマホ向けアプリを製作していてエディター上で動作確認しているシーンを想定しています。

// ★PC
// 左クリックが押し込まれたらtrue
bool isClick = Input.GetMouseButtonDown(0);

// ★スマホ
// タッチ開始(画面に触れたら)したらtrue
bool isTouch = Input.touchCount != 0 && Input.GetTouch(0).phase == TouchPhase.Began

既に似たような事を考えて既に管理クラスを実装している方もいるようなので、今回はそれらを参考にして EventTrigger や IPointerDownHandler、IPointerUpHandler、IPointerMoveHandler を使っ場合と似たような動作いなるように少し動きを調整したマネージャーを作成したいと思います。Unity でスマホの画面をタッチ(タップ)した時とデバッグ中に PC で画面をクリックしたときに両方対応した実装です。

で、今回はこのコードをベースに EventTrigger とか IPointerDownHandler、IPointerUpHandler、IPointerMoveHandlerとほぼ同じ動きになるように少し動きを調整したマネージャーを作成したいと思います。Unity でスマホの画面をタッチ(タップ)した時と PC で画面をクリックしたときに両方対応した実装です。

確認環境

確認環境は以下の通りです。

  • Unity 2021.3.16f1
  • Windows11
  • ★UniRx

外部ライブラリとして UniRx を使用します(イベント通知で使用しています)

実装例

// TouchManager.cs

#if UNITY_EDITOR || UNITY_STANDALONE || UNITY_WEBGL || UNITY_WEBPLAYER
#undef IS_MOBILE
#else
#define IS_MOBILE // モバイル(=タッチ用の環境)の時だけ宣言する
#endif

using UnityEngine;

/// <summary>
/// モバイル・PC両対応の画面のタッチを検出・通知するクラス
/// </summary>
public class TouchManager : MonoBehaviour
{
    // InnerTypes

    public enum TouchState { None, Down/*タッチ開始*/, Move/*スワイプ中*/, Up/*タッチ終了*/ }

    // Inspector

    // Moveイベントを発生させるかどうかのフラグ
    // true: Moveイベントが発生する / false: 発生しない
    [SerializeField] bool _useMoveEvent = true;

    // Fields

    // 直前のタッチ位置
    Vector2 _previousPoint;
    Vector2 _startPos;

    // 無効な位置
    public static readonly Vector2 INVALID = new Vector2(float.NaN, float.NaN);

    // Props & Events

    // 凡そ、IPointerDownHandler, IPointerUpHandler, IPointerMoveHandler を実装したときと同じ動作

    /// <summary>
    /// 画面がタッチ or クリックされた時に発生します。
    /// </summary>
    public System.IObservable<TouchInfo> PointerDown => _pointerDown;
    private readonly UniRx.Subject<TouchInfo> _pointerDown = new();

    /// <summary>
    /// 画面が離された時に発生します。
    /// </summary>
    public System.IObservable<TouchInfo> PointerUp => _pointerUp;
    private readonly UniRx.Subject<TouchInfo> _pointerUp = new();

    /// <summary>
    /// 画面をドラッグ or スワイプ中に発生します。
    /// </summary>
    /// <remarks>
    /// 押されている最中のみ発生する。
    /// IPointerMoveHandler.OnPointerMove とは動きが違う。
    ///  → ポインターが画面上をうろうろしてもイベントは発生しない
    /// </remarks>
    public System.IObservable<TouchInfo> PointerMove => _pointerMove;
    private readonly UniRx.Subject<TouchInfo> _pointerMove = new();

    // Unity Impl

    private void Update()
    {
        bool isTouch = Input.GetMouseButtonDown(0);

        var state = GetPointerState();
        switch (state)
        {
            case TouchState.Down:
            {
                Vector2 curret = GetPosition();
                _startPos = curret;
                _previousPoint = curret;
                _pointerDown.OnNext(new TouchInfo(curret, Vector2.zero, curret));
                break;
            }
            case TouchState.Move:
            {
                if (!_useMoveEvent) return;

                Vector2 curret = GetPosition();
                if (curret == _previousPoint) return; // 動いた時だけ発生する
                Vector2 delta = curret - _previousPoint;
                _previousPoint = curret;
                _pointerMove.OnNext(new TouchInfo(curret, delta, _startPos));
                break;
            }
            case TouchState.Up:
            {
                Vector2 curret = GetPosition();
                Vector2 delta = curret - _previousPoint;
                _previousPoint = INVALID;
                _pointerUp.OnNext(new TouchInfo(curret, delta, _startPos));
                break;
            }
        }
    }

    private void OnDestroy()
    {
        using (_pointerDown) { }
        using (_pointerUp) { }
        using (_pointerMove) { }
    }

    // Methods

    /// <summary>
    /// 現在の操作状態を取得します。
    /// </summary>
    private TouchState GetPointerState()
    {
#if IS_MOBILE
            if (Input.touchCount == 0) return TouchState.None;
            return Input.GetTouch(0).phase switch
            {
                TouchPhase.Began => TouchState.Down,
                TouchPhase.Moved or TouchPhase.Stationary => TouchState.Move,
                TouchPhase.Canceled or TouchPhase.Ended => TouchState.Up,
                _ => TouchState.None,
            };
#else
        if (Input.GetMouseButtonDown(0)) return TouchState.Down;
        else if (Input.GetMouseButton(0)) return TouchState.Move;
        else if (Input.GetMouseButtonUp(0)) return TouchState.Up;
        return TouchState.None;
#endif
    }

    /// <summary>
    /// 現在の操作位置を取得します。
    /// </summary>
    private Vector2 GetPosition()
    {
#if IS_MOBILE
        return Input.GetTouch(0).position;
#else
        return GetPointerState() == TouchState.None ?
            Vector2.zero : (Vector2)Input.mousePosition;
#endif
    }
}

/// <summary>
/// タッチ情報
/// </summary>
public readonly struct TouchInfo
{
    /// <summary>
    /// タッチされたスクリーン座標を取得します。
    /// </summary>
    public readonly Vector2 Position;

    /// <summary>
    /// 前回のイベントからの移動量を取得します。
    /// </summary>
    public readonly Vector2 Delta;

    /// <summary>
    /// 開始位置のスクリーン座標を取得します。
    /// </summary>
    public readonly Vector2 PressPosition;

    public TouchInfo(Vector2 screenPoint, Vector2 delta, Vector2 pressPosition)
    {
        Position = screenPoint;
        Delta = delta;
        PressPosition = pressPosition;
    }
}

使用方法

イベント通知に UniRx を使用しているためイベントを登録するときは以下のように記述します。

private void Start()
{
    var mgr = FindObjectOfType<TouchManager>();
    mgr.PointerDown.Subscribe(p =>
    {
        Log.Trace($"Down={p.Position}, delta={p.Delta}", this);
    });
    mgr.PointerUp.Subscribe(p =>
    {
        Log.Trace($"Up={p.Position}, delta={p.Delta}", this);
    });
    mgr.PointerMove.Subscribe(p =>
    {
        Log.Trace($"Move={p.Position}, delta={p.Delta}", this);
    });
}

元のマネージャからの改変として、押しっぱなしにした時は移動したときだけ移動量と一緒にイベントを通知するようにています。スクリーン座標が返ってくるあたりは IPointerXXXHandler の PointerEventData と同じです。

最後に

よく考えたらシングルタッチしか対応しないならスマホもPCも Input.GetMouseButtonDown(0) でいいじゃんと思ったのは秘密です。 何でこんな実装をしたのかと言うと uGUI の EventTrigger や IPointerXXXHandler のメソッドでイベントを受け取ると後ろのコントロールが同時のイベントが発生しないので、画面のタッチ位置を uGUI 上に表示しつつボタンをクリックするという実装は IPointerXXXHandler とか EventTrigger だと難しいためこういったこの実装を使ってみました。

【C#】.NET CoreでMagicOnion+IPC通信を試す

タイトルの通り gRPC のライブラリである MagicOnion を使いつつ IPC(プロセス間) 通信したいと思います。

ちなみに、MagicOnion を使うと WCF を使った RPC の API 呼び出しのワークフローと極めて似た感じで実装できるようになります。特に proto からの型生成を省略できるため使用感が非常に似た感じで使用できます。

今回は単純に RPC(Remote Procedure Call) でメソッドを呼び出して戻り値を取得するのを IPC で実装していきます。

確認環境

今回の確認環境は以下の通りです。

  • Windows11
  • VisualStudio 2022
  • .NET Core 3.1
  • MagicOnion 4.3.1

コンソールアプリで動作検証

環境のセットアップ

とりあえず、公式の Installationの項目 を見れば書いてあるけど、それだとあんまりなので概要だけ以下の通り書いておきます。

VisualStudio 使ってるので IDE から以下をソリューション上に構成します。

  • (1) サーバー
    • Web > ASP.NET Core WebAPIを作成 > 「GrpcService」で作成
    • NuGet で MagicOnion.Server を追加
  • (2) クライアント
    • コンソール > コンソールアプリケーション > 「GrpcClient」で追加
    • NuGet > MagicOnion.Client を追加
  • (3) 共有プロジェクト
    • デスクトップ > 共有プロジェクト > 「SharedProject」で追加

(1)サーバーと(2)クライアントのプロジェクトは共有プロジェクトを参照に追加しておきます。

これで準備完了です。

実装

IPC 通信をするための設定を公式のコードに IPC 通信用の設定を足していきます。

共有プロジェクト

まずはどんなAPIを公開するのかのインターフェース定義を共有プロジェクトに

using System.IO;
using MagicOnion;

namespace SharedProject
{
    public interface ISample : IService<ISample> // MagicOnionのIServiceを継承して定義する
    {
        UnaryResult<int> SumAsync(int x, int y); // サンプルの通り足し算して結果を取得する
    }

    // サーバーへの接続用文字列定義
    public static class SampleDef
    {
        public static string SocketPath => Path.Combine(Path.GetTempPath(), "socket.tmp");
    }
}

サーバー側

Program.cs

サーバー側のメインメソッド

// Program.cs

using System.IO;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace GrpcService
{
    public partial class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args)
        {
            string socketPath = SharedProject1.SampleDef.SocketPath;

            IHostBuilder host = Host.CreateDefaultBuilder(args);
            host.ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>();

                // ★★★IPC用の定義を追加
                webBuilder.ConfigureKestrel(options =>
                {
                    if (File.Exists(socketPath))
                    {
                        File.Delete(socketPath);
                    }
                    options.ListenUnixSocket(socketPath, listenOptions =>
                    {
                        listenOptions.Protocols = HttpProtocols.Http2;
                    });
                });
                
                // ★★既定のコンソール出力は動作速度が大きく低下するので出力を止める(任意)
                webBuilder.ConfigureLogging(logging =>
                {
                    logging.ClearProviders();
                });
            });

            return host;
        }
    }
}
Startup.cs

サーバーの構成定義(公式と同じ)

// Startup.cs

using System.IO;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace GrpcService
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddGrpc();
            services.AddMagicOnion(); // ★リファレンスの通り
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseRouting();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapMagicOnionService(); // ★リファレンスの通り
            });
        }
    }
}
SampleService.cs

サーバーが外部に公開するクラスの定義(公式と同じ)

// SampleService.cs

using System;
using MagicOnion;
using MagicOnion.Server;
using SharedProject;

namespace GrpcService
{
    public class SampleService : ServiceBase<ISample>, ISample
    {
        public async UnaryResult<int> SumAsync(int x, int y)
        {
            Console.WriteLine($"Received:{x}, {y}");
            return x + y;
        }
    }
}

クライアント

UDSConnectionFactory.cs

クライアント側が IPC 通信用の GrpcChannel を作成するために利用するクラス

// UDSConnectionFactory.cs

using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;

namespace GrpcClient
{
    // UnixDomainSocket
    public class UDSConnectionFactory
    {
        private readonly EndPoint _endPoint;

        public UnixDomainSocketConnectionFactory(EndPoint endPoint)
        {
            _endPoint = endPoint;
        }

        public async ValueTask<Stream> 
            ConnectAsync(SocketsHttpConnectionContext _, 
            CancellationToken cancellationToken = default)
        {
            var socket = 
                new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
            try
            {
                await socket.ConnectAsync(_endPoint, cancellationToken).ConfigureAwait(false);
                return new NetworkStream(socket, true);
            }
            catch
            {
                socket.Dispose();
                throw;
            }
        }
    }
}
Program.cs

クライアント側のメインメソッド

// Program.cs

using System;
using System.Net.Http;
using System.Net.Sockets;
using Grpc.Net.Client;
using MagicOnion.Client;
using SharedProject;

namespace GrpcClient
{
    internal class Program
    {
        private static void Main(string[] args)
        {
            Console.WriteLine("Start");

            GrpcChannel channel = CreateChannel();
            var client = MagicOnionClient.Create<ISample>(channel);

            var result = client.SumAsync(10, 20);
            int sum = result.ResponseAsync.Result;
            Console.WriteLine($"Sum={sum}");

            Console.WriteLine("END");
            Console.ReadLine();
        }

        public static GrpcChannel CreateChannel()
        {
            string socketPath = SampleDef.SocketPath;

            var udsEndPoint = new UnixDomainSocketEndPoint(socketPath);
            var connectionFactory = new UDSConnectionFactory(udsEndPoint);
            var socketsHttpHandler = new SocketsHttpHandler
            {
                ConnectCallback = connectionFactory.ConnectAsync
            };

            return GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions
            {
                HttpHandler = socketsHttpHandler
            });
        }
    }
}

実行してみる

(1) サーバーを実行 (2) クライアントを実行

の順に実行します。VisualStudio を2つ起動して1つずつ起動してみます。

サーバーを起動すると出力ウインドウに以下のような出力がされていれば設定が利いています。

// 意訳: Kestrel が http://localhost:5000 or 5001 のアドレスをオーバーライドしています
Microsoft.AspNetCore.Server.Kestrel: Warning: Overriding address(es) 'http://localhost:5000, https://localhost:5001'. Binding to endpoints defined in UseKestrel() instead.
Microsoft.Hosting.Lifetime: Information: Now listening on: http://unix:C:\Users\Xxxxx\AppData\Local\Temp\socket.tmp
Microsoft.Hosting.Lifetime: Information: Application started. Press Ctrl+C to shut down.
Microsoft.Hosting.Lifetime: Information: Hosting environment: Development
Microsoft.Hosting.Lifetime: Information: Content root path: C:\Users\Xxxx\Downloads\MagicOnionSample\GrpcService

この場合 UseKestrel ではなく ConfigureKestrel で指定した内容でバインドしていますと表示されています。実際このメッセージが出てると速度オーバーヘッドが 1.3~.6倍 くらい違ったので確かに UDS(Unix Domain Socket) が使用されているようです。

このメッセージ後にクライアントを実行するとコンソールに以下のように表示されます。

Sum=30

パフォーマンスについて

正確なところは分かりませんが、gRPC の方が WCF より処理が重いようで、自分の環境では下表のとおりの速度となりました。

Item Speed
WCF(IPC Named Pipe) 1.0
gRPC(UnixDomainSocket) x0.6~0.8
gRPC(TCP) x0.4~0.6

トーレスとコンソールへの出力を停止しないと更に速度が低下するためリリース時は状況に応じてOFFる、、と問題が起きた時に困るので、より高速に動作するロガーを設定したほうがいいと思います。速度に関しては何か追加で設定しないといけないのかもしれませんが今はちょっと方法がわかりませんでした。

具体的な数値で言うと WCF だと 1.2ms/リクエストの処理が gRCP では 2ms/リクエスト くらいの速度感でした。このためものすごい細かいデータ断片を単方向に投げまくるという用途にはやや向いていないのかもしれません。これは MagicOnion が悪いのではなく .NET の gRPC の特徴なのかと思います。

Odinでオプションが有効(or 無効)の時だけメニューを表示する

Unity の Editor 拡張が簡単にできる有料アセット「Odin Inspector and Serializer」で、ある bool 型の変数値が true(or false) の時だけインスペクターに表示されるメニューの作成方法です。

以下のスクリプトをオブジェクトにアタッチします。

using Sirenix.OdinInspector;

// サンプル用のクラス
public class OdinSample : MonoBehaviour
{
    // オプションスイッチ
    [SerializeField] bool _optionSwicth;

    // オプションスイッチがtrueの時だけ表示されるメニュー
    [SerializeField, ShowIf(nameof(_optionSwicth))] int _optionTrueValue = 9999;

    // オプションスイッチがfalseの時だけ表示されるメニュー
    [SerializeField, HideIf(nameof(_optionSwicth))] int _optionFalseValue = -256;
属性 説明
ShowIf("boolの変数名"); bool が true の時に表示される
HideIf("boolの変数名"); bool が false の時に表示される

HidIf は true の時に隠している動作が結果的に false だと表示になっています。

自動的に以下の表示になります。

チェックがOFFの時

チェックがONの時

メニューが非表示になるだけでスクリプト側からは変数値が制限なく参照できます。メニューで非表示の時は値を参照しないような仕組みは自分で作成する必要があります。

【Unity】Vector3(構造体)に自分自身の値を変更する拡張メソッドを定義する

Unity の Vector3 (Vector2 など構造体)に自分自身を書き換える処理を追加する拡張メソッドの定義方法の紹介です。

前提として以下のように Vector3 に拡張メソッドを定義して値を変更しようとしても値は変わらない事を確認します。

public static class Vector3Extensions
{
    // 特に意味はないけどXを書き換え拡張メソッド
    public static void SetX(this Vector3 self, float x) => self.x = x;
}

vra vec3 = Vector3.zero;
vec3.SetX(100);

Debug.Log(vec3);
// > (0, 0, 0) ★★★変わってない!

これは、Vector3 の宣言が class ではなく struct になっていることが原因です。Vector3 を確認すると「public struct」となっています。

// ★classではなくstructになってる
public struct Vector3 : IEquatable<Vector3>, IFormattable
{

この部分が、struct になってるとメソッドに渡すと受け取った側は内容がコピーされた別物が渡されて、その値を書き換えたところで別物を書き換えただけで元の値には影響が出ません。

... => self.x = x; // selfは元の値がコピーされた別物

そこで受け取ったときにコピーした別物ではなく、同じものを渡すようにするために、ref キーワードを以下の通り追加します。

public static class Vector3Extensions
{
    // thisの前にrefを追加する
    public static void SetX(ref this Vector3 self, float x) => self.x = x;
}

vra vec3 = Vector3.zero;
vec3.SetX(100);

Debug.Log(vec3);
// > (100, 0, 0) ★★★変わってる

今回は変更した値が表示されます。

Immutableな構造体も書き換える

余談ですが、ref キーワードで構造体を参照渡しする場合、以下のようにインスタンスを再生成する方法でも値が変更できます。こちらは一度作成したら後から値が変更できない構造体 (≒immutable な構造体)の値の変更を拡張メソッドで定義しる時に利用できます。

Vector3 も以下の方法で値が変更できますが少し処理効率が悪く、意味も無いので使用しません。

public static class Vector3Extensions
{
    public static void SetX2(this ref Vector3 self, float x)
    {
        // インスタンスを新規作成して代入しても値が変わる
        self = new Vector3() { x = x, y = self.y, z = self.z };
    }
}

var vec3 = new Vector3();
vec3.SetX2(100);
Console.WriteLine(vec3);
// >(100, 0, 0) ★これでも値が変わる

ちょっとしたことですがこれで拡張メソッド作成の幅が広がると思います。

【Unity】メモリの改ざんを防ぐ機能を実装する

アプリなどでメモリ上に保持してる値って外部ツールで割と簡単に読み取ったり変更されてしまうんですよね。変更したときにどのアドレスに保存されているみたいな位置を特定されると実行中に値を書き換えられてしまいます。

そこでこれらの行為を防止するためにの保護方法として一般的に、フィールドに持つ値を「シード値」XOR「値」=「保持する値」に変換して持っておけば読み取られても「ある程度は」セーフティにすることができます。

そこで今回はメモリ改ざん耐性を向上させる実装例を紹介したいと思います(あくまでクライアント側のアプリ内の話 + これを実装しても全く別のアプローチがされたら意味ないため、まぁ、やっておけば多少効果がある程度で覚えておきましょう)

確認環境

  • Unity 2021.3.14f1
  • VisualStudio 2022
  • Windows11

エディター上で動作を確認しています。

MemoryProtectorクラス

まずプリミティブ型に対して XOR で難読化するために以下のようなクラスを定義します。

各型の変換方法を定義します。

// データ保護機能の機能クラス
public readonly struct MemoryProtector
{
    private readonly long _seed;

    public MemoryProtector(long seed) => _seed = seed;

    //
    // Public Methods
    // - - - - - - - - - - - - - - - - - - - -

    public static MemoryProtector Create()
    {
        return new MemoryProtector((long)Rand.MaxRand() << 32 | (long)Rand.MaxRand(););
    }

    public byte Mask(byte value) => (byte)(value ^ (byte)_seed);
    public char Mask(char value) => (char)(value ^ (char)_seed);
    public short Mask(short value) => (short)(value ^ (short)_seed);
    public ushort Mask(ushort value) => (ushort)(value ^ (ushort)_seed);
    public int Mask(int value) => value ^ (int)_seed;
    public uint Mask(uint value) => value ^ (uint)_seed;
    public long Mask(long value) => value ^ _seed;
    public ulong Mask(ulong value) => value ^ (ulong)_seed;
    public unsafe float Mask(float value)
    {
        int f = *(int*)&value ^ (int)_seed;
        return *(float*)&f;
    }
    public unsafe double Mask(double value)
    {
        long d = *(long*)&value ^ _seed;
        return *(double*)&d;
    }
    public unsafe bool Mask(bool value)
    {
        int b = *(byte*)&value ^ (byte)_seed;
        return *(bool*)&b;
    }

    // stringも難読化できるけど死ぬほどパフォーマンスが悪いので微妙
    public unsafe string Mask(string str)
    {
        char[] _temp = new char[str.Length];

        fixed (char* pstr = str)
        {
            for (int i = 0; i < str.Length; i++)
            {
                _temp[i] = (char)(*(pstr + i) ^ (char)_seed);
            }
        }

        return new string(_temp);
    }
}

使い方

次に使い方です。

フィールドに上記のクラスをメンバーとして保持してプロパティなどで値を入出力するときにMask関数を通します。

初期化するときに各インスタンス毎にに seed 値が変わるため毎回異なる値で XOR をすることになります。従って外から値を追うのは結構大変になると思います。

public class Sample
{
    readonly MemoryProtector _p = MemoryProtector.Create();

    private int _id;
    public int ID
    {
        // 値の入出力時にMaskメソッドを通して変換をかける
        get => _p.Mask(_id);
        set => _id = _p.Mask(value);
    }

    private double _no;
    public double NO
    {
        // 値の入出力時にMaskメソッドを通して変換をかける
        get => _p.Mask(_no);
        set => _no = _p.Mask(value);
    }

    public void Show()
    {
        Console.WriteLine($"_id={_id}");
        Console.WriteLine($"_no={_no}");
    }
}

こんな風にメンバーに保護機能クラスを宣言してMaskを通すと、、

public static void Test()
{
    var s = new Sample
    {
        ID = 100,
        NO = 25.336,
    };

    s.Show();
    // 毎回異なる適当な値が出力される(=中身が難読化されている)
    // _id=383139670
    // _no=7.614587724129174E-247
    
    int id = s.ID;
    double no = s.NO;

    Console.WriteLine($"{id}, {no}");
    // > 100, 25.336
}

分かってるとは思いますが、マスクされた値の _id の値を直接どこかに保存して次にこのオブジェクトにロードしても二度と元に戻せないので注意してください。seed をランダムに決めてると元の seed が分からない限り値が復元できなくなります。保存するときは取り出してその値を別で暗号化するなどして保存しましょう。

あと取り出した値は保護してない変数に入れっぱなしにしてるとそこを特定されたりするので取得した値の扱いは気を付けましょう。全システム共通で seed を持っていてもいいと思いますが、毎回 seed を作成するオーバーヘッドがなくなる代わりに耐性が減るのでトレードオフというか共通 seed にはしないほうがいいかな?

ここら辺はシステムごとにどの程度対応するかは違うと思います。

【C#】メモリにファイルの内容を展開せずにAESで暗号化する

以前にデータを AES 暗号化する方法を紹介しましたが、実装例が何らかのデータをアプリのメモリ上に byte 配列として全部に読み取った内容を展開してから AES 暗号化する方法でした。

この方法だと、例えば 1GB のファイルを暗号化しようとすると1GBぶん全てを一度メモリに内容を全て展開することになります。大きいサイズのファイルを暗号化すると場合によって問題が起きます。

なので今回は、ファイルの内容をメモリに展開することなくファイルtoファイルで暗号化、暗号化を解除する方法を紹介したいと思います。

環境

  • .NET Core 3.1
  • VisualStudio 2022
  • Windows11

コンソールアプリで動作確認しています。

ファイルtoファイルで暗号化する実装

以下、この処理で大切なのは Stream.CopyTo() もしくは Stream.CopyToAsync() を使用する箇所です。

private void EncTest()
{
    // (1) 暗号化する対象のファイル
    string tagetPath = @"e:\src.txt";

    // (2) 暗号化した後のファイル
    string destPath1 = @"e:\dest1.txt";
    
    // (3) 暗号化したファイルの暗号化を解除したファイル(確認用)
    string destPath2 = @"e:\dest2.txt";

    byte[] key = new byte[]
    {
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // 値は適当
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
        0x00,0x00,
    };
    byte[] iv = new byte[]
    {
        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // 値は適当
        0x00,0x00,0x00,0x00,0x00,0x00,
    };

    // どういう方式か?
    using var aes = new AesManaged
    {
        KeySize = 256,
        BlockSize = 128,
        Mode = CipherMode.CBC,
        Padding = PaddingMode.PKCS7,
        Key = key,
        IV = iv,
    };

    // 暗号化
    {
        ICryptoTransform encryptor = aes.CreateEncryptor();

        // 暗号化するファイルのストリーム
        using var fsSrc = new FileStream(tagetPath, FileMode.Open, FileAccess.Read);

        // 暗号化して内容をファイルに書き出すストリーム
        using var fsDest = new FileStream(destPath1, FileMode.Create, FileAccess.Write);
        using var cs = new CryptoStream(fsDest, encryptor, CryptoStreamMode.Write);

        fsSrc.CopyTo(cs); // 非同期の場合は → await CopyToAsync();
    }

    // 暗号化の解除
    {
        ICryptoTransform encryptor = aes.CreateDecryptor();

        // 暗号化を解除するファイルのストリーム
        using var fsSrc = new FileStream(destPath1, FileMode.Open, FileAccess.Read);

        // 解除してファイルに書きだすストリーム
        using var fsDest = new FileStream(destPath2, FileMode.Create, FileAccess.Write);
        using var cs = new CryptoStream(fsDest, encryptor, CryptoStreamMode.Write);

        fsSrc.CopyTo(cs); // 非同期の場合は → await CopyToAsync();
    }

    // 暗号化前と暗号化 → 解除して内容が同じかチェックする
    {
        using var fs1 = new FileStream(tagetPath, FileMode.Open, FileAccess.Read);
        using var fs2 = new FileStream(destPath2, FileMode.Open, FileAccess.Read);

        if (fs1.Length != fs2.Length)
        {
            Console.WriteLine("長さが違います。");
            return;
        }
        else
        {
            for (int i = 0; i < fs1.Length; i++)
            {
                int a = fs1.ReadByte();
                int b = fs2.ReadByte();
                if (a != b)
                {
                    Console.WriteLine($"{i}byte目が違います。");
                    return;
                }
            }
        }
        Console.WriteLine("Check OK"); // 問題なければこれが出力される
    }
}

これでメモリに展開することなく暗号化できました。

関連記事

takap-tech.com

【Unity】StreamingAssetsからテキストを読み取る

Unity の StreamingAssets に置いたファイルを読み取る実装例です。

Android と WebGL は特別な処理が必要だそうで UnityWebRequest を使ってデータを読み取るようです。それ以外のプラットフォーム(iOS, Windows, デバッグ中のエディタ上)では普通にファイルに読み取って大丈夫だそうです。

Android はデータが apk/jar(zip) に圧縮されてるから特別な読み取りをしないといけないのですが何故か UnityWebRequest という名前のクラスで読み取れるのでそれを使用したいと思います。*1。WebRequest といいつつローカルにあるファイルを読み取る機能がある程度実装されているようです。ただ、何でもかんでも使えないのかな?なので、それぞれ向けに実装して両対応してみようと思います。

アプリ側からコールする API はプラットフォームの区別なく読み取れるようにしたいと思います。

という訳で、StreamingAssets から Android/iOS/WebGL/Windowsエディタ上で区別なくファイルを読み取るサンプルの紹介です。

確認環境

  • Uinty 2021.3.14f1
  • VisualStudio 2022
  • Windows 11
  • ★UniTask を使用しています

注意

Editor 上で確認したのでAndroid 部分は後で実機確認次第おかしい所があったら修正します

StreamingAssetsUtilクラス

Android と WebGL、それ以外のプラットフォームで ifdef を切って呼び出し方を変えてバイナリとして値を読みだしています。

コード中のコメントの通りですが StreamingAsset の内容を byte[] もしくは string(UTF-8)で取得する機能をサポートしています。

using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;

/// <summary>
/// StreamingAssetsからデータを読み取る機能を提供します。
/// </summary>
public static class StreamingAssetsUtil
{
    // メモ
    // Assets > StreamingAssets にフォルダを作成して中にファイルが入ってる事
    //
    // バイナリとテキスト以外は対象外
    // TextureやAudioとかのアセットは読み取り方が違うのでここでは扱わない

    /// <summary>
    /// StreamingAssetsからバイナリデータを読み取ります。
    /// </summary>
    public static async UniTask<byte[]>
        ReadStreamingAssetsData(string path, CancellationToken token)
    {
        string realPath = Application.streamingAssetsPath + "/" + path.TrimStart('/');
#if (UNITY_ANDROID || UNITY_WEBGL) && !UNITY_EDITOR
       return await ReadDataAndroid(realPath, token);
#else
        return await ReadData(realPath, token);
#endif
    }

    /// <summary>
    /// StreamingAssetsからテキストを読み取ります。
    /// </summary>
    public static async UniTask<string>
        ReadStreamingAssetsText(string path, CancellationToken token)
    {
        string realPath = Application.streamingAssetsPath + "/" + path.TrimStart('/');
        Log.Trace($"Application.streamingAssetsPath={Application.streamingAssetsPath}");
#if (UNITY_ANDROID || UNITY_WEBGL) && !UNITY_EDITOR
        return await ReadTextSpesific(realPath, token);
#else
        return await ReadText(realPath, token);
#endif
    }

    //
    // Private Methods
    // - - - - - - - - - - - - - - - - - - - -

    // Android + WebGL向けのバイナリ読み取り
    private static async UniTask<byte[]> ReadDataSpesific(string path, CancellationToken token)
    {
        var ret = await ReadSpesific(path, token);
        return ret.data;
    }

    // Android + WebGL向けのテキスト読み取り
    private static async UniTask<string>
        ReadTextSpesific(string path, CancellationToken token)
    {
        var ret = await ReadSpesific(path, token);
        return ret.text;
    }

    // Android + WebGL向けの読み取り処理
    private static async UniTask<DownloadHandler>
        ReadSpesific(string path, CancellationToken token)
    {
        // WebRequestを使って読み取る
        var req = UnityWebRequest.Get(path);
        await req.SendWebRequest().ToUniTask(cancellationToken: token);
        if (req.result != UnityWebRequest.Result.Success) // 
        {
            // エラーどう処理するかは各自決める
            throw new UnityException($"処理に失敗しました。path={path}, code={req.result}");
        }
        return req.downloadHandler;
    }

    // 通常の環境のバイナリ読み取り
    private static async ValueTask<byte[]> ReadData(string path, CancellationToken token)
    {
        // 普通のファイルの読み書き
        using var fs = new FileStream(path, FileMode.Open);
        var array = new byte[fs.Length];
        await fs.ReadAsync(array, 0, (int)fs.Length, token);
        return array;
    }

    // 通常の環境のバイナリ読み取り(UTF-8)
    private static async ValueTask<string> ReadText(string path, CancellationToken _)
    {
        // 普通のファイルの読み書き
        using var fs = new FileStream(path, FileMode.Open);
        using var sr = new StreamReader(fs);
        return await sr.ReadToEndAsync();
    }
}

// ★(1) UnityWebRequest の isNetworkError、isHttpError、isError は非推奨にらしいです
// result プロパティを見て失敗の原因ごとにエラーハンドリングしましょう

// ★(2) GetTexture、GetAudioClipとかも軒並み使用不可になってるみたいです。
// 代替手段がObsoleteの説明に書いてあるのでそっちを使用しましょう

使い方

以下の通り使用できます。

public class Sample : MonoBehaviour
{
    public async void Foo()
    {
        CancellationToken token = this.GetCancellationTokenOnDestroy();
        await StreamingAssetsUtil.ReadStreamingAssetsData("sample.bin", token);
    }
}

もし取得したデータを UTF-8 の文字列として取得したい場合、GetText() メソッドや text プロパティから値を取得できるので適宜拡張する感じになると思います。

*1:WWWクラスを使うのはもう古いらしいです

【Unity】パララックス(多重スクロール)を実装する

Unityの2Dの表現で視差の効果を使ったパララックスのスクロール(Parallax)を実装例を紹介したいと思います。かつてレトロゲームの背景スクロールでよくありましたね。

この表現方法は、スクロールの速度が奥のほうがゆっくりで手前ほど早くスクロールすることで立体感を出す手法です。

Twitter のほうが動画が奇麗に見えるかも。

Unityだとカメラをパースペクティブに設定していれば別にこんなことせずとも自動で視差がついて遠近が出せますが、2Dで作っててカメラの設定が Orthographic の場合に使用できます。

作成環境

  • Unity 2021.3.14f1
  • VisualStudio2022
  • Windows11

エディター上で動作を確認しています。

考え方

基本的に冒頭の動画通り以下しようとします。

  • 視差効果を出すためにレイヤーごとに移動速度を変える
    • 手前のレイヤーほどカメラに追従しない
    • 奥側のレイヤーほどカメラに追従する
  • 左右に無限にスクロールできる

アセットとシーン

まず、使用するアセットは以下を使用しています。

edermunizz.itch.io

シーンは移動速度のグループごとにレイヤーを作成します。今回はアセットが6レイヤー分なので6個作成します。

そして各レイヤー内には子要素に同じ画像を3枚ずつ配置します。

同じ画像を左右に均等に3枚並べて左右均等に画像のつなぎ目が目立たない位置に配置します。

配置したあと Scene ビューでこんな感じになってるとよさそうです。

スクリプト

レイヤーに設定するスクリプト

各レイヤーに設定するスクリプトです。

指定したカメラにどれくらいレイヤーが追従するかを制御します。

// 視差スクロール用のレイヤーに設定するスクリプト
public class ParallaxCameraFlowLayer : MonoBehaviour
{
    // 追従対象のカメラ
    [SerializeField] Transform _cameraTransfrom;
    // カメラに追従する程度(1: カメラと同じ移動量 0: 移動しない)
    [SerializeField] float _followFactor;

    Vector3 _previousCameraPos;

    private void Update()
    {
        Vector3 currentPos = _cameraTransfrom.position;
        var deltaPos = currentPos - _previousCameraPos;
        _previousCameraPos = currentPos;
        var calcedPos = deltaPos * _followFactor;
        transform.AddLocalPos((Vector2)calcedPos);
    }
}

// ユーティリティ
public static class ParallaxCameraFlowLayerExtensions
{
    public static void AddLocalPos(this Transform self, in Vector2 pos)
    {
        Vector3 vec = self.localPosition;
        vec.x += pos.x;
        vec.y += pos.y;
        self.localPosition = vec;
    }
}

_cameraTransfrom にカメラを設定します。2Dなのでメインカメラを手で設定するようにしていますが、Awake で Camera.main を設定してもいいと思います。

_followFactor ですがカメラに追従する度合いを表します。0で完全に追従しない~1でカメラの動きと完全に同じとなります。

Layer Value
Layer_01 [Far] 0.8
Layer_02 0.6
Layer_03 0.4
Layer_04 0.2
Layer_05 0
Layer_06 [Near] -0.2

0.2ずつ増減させて奥に行くほどゆっくり移動するように見えるようになります。

マイナスに設定するとカメラと同じ方向により早く動くようになります。

各画像に設定するスクリプト

無限にスクロールしているように見せたいので、レイヤーの子要素に以下の画像を設定します。

このスクリプトでカメラから画像が一定距離離れると画像が自動的に左右どちらかにローテーションして無限にスクロールしてるように見せることができるようになります。

public class HorizontalDynamicImageRotation : MonoBehaviour
{
    [SerializeField] Transform _cameraTransform;
    // ローテーションするときの1枚の画像の幅
    [SerializeField] float _imageWidth;

    Transform _parentLayer;
    Transform _transformCache;

    private void Awake()
    {
        _parentLayer = this.GetParent().transform;
        _transformCache = transform;

        if (!_cameraTransform)
        {
            Log.Warn("カメラが設定されていません。", this);
            this.enabled = false;
        }
    }

    private void Update()
    {
        // 3枚でローテーションするのでカメラの描画範囲から1.5枚分ずれたらその方向に移動する
        var distance = _cameraTransform.position - _transformCache.position;
        if (Mathf.Abs(distance.x) > _imageWidth * 1.5f)
        {
            float amount = _imageWidth * 3 * (distance.x < 0 ? -1.0f : 1.0f);
            _transformCache.AddLocalPosX(amount);
        }
    }
}

// ユーティリティ
public static class HorizontalDynamicImageRotationExtensions
{
    // 親を取得
    public static GameObject GetParent(this Component self)
    {
        return self.transform.parent.gameObject;
    }
    // X方向に値を足す
    public static void AddLocalPosX(this Transform self, float x)
    {
        Vector3 vec3 = self.localPosition;
        vec3.x += x;
        self.localPosition = vec3;
    }
}

_cameraTransform は画像との距離をとるカメラです。インスペクターに手で設定するようにしていますが、Camera.main でもいいかもしれません。

_imageWidth は画像の幅です。カメラとの距離を Update で各インして一定距離以上、離れたらカメラに近い位置に画像が移動するようになります。

インスペクターに色々と手で配置して制限を付けてるためかなり短いコードでも表現できると思います。これで、Canvasにボタンなどを配置して水平方向の左右にカメラを移動すればパララックスのスクロールがされるようになりました。

以上

JAN,FEB/MAR... 英語の月の略語

英語で各月の略号は3文字の短い形式があります。

以下表の通り各月は3文字の大文字で表されます。

英略
01月 JAN January
02月 FEB February
03月 MAR March
04月 APR April
05月 MAY May
06月 JUN June
07月 JUL July
08月 AUG August
09月 SEP September
10月 OCT October
11月 NOV November
12月 DEC December

C#で略号をDateTimeに変換する方法

C# で上記3文字の略号から月を取得する場合 "MMM" というキーワードを使用します。使い方は以下の通り。

// 略号から月を取得する
using System.Globalization;

public static void Foo()
{
    string key = "SEP"; // September -> 9月
    bool isOk = DateTime.TryParseExact(key, "MMM", 
        CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result);
    if(isOk)
    {
        // 1900/09/01 00:00:00 になってるので月だけ取得する
        int month = result.Month;
        Console.WriteLine(month);
        // > 9
    }
    
    // 以下の書き方でも良いが key が変換できないと例外が発生する
    DateTime result2 = DateTime.ParseExact(key, "MMM", CultureInfo.InvariantCulture);
}

省略しない完全な名前で月を取得する場合は "MMMM" というキーワードを使用します。

// 完全名から月を取得する
using System.Globalization;

public static void Foo()
{
    string key = "December"; // 12月
    bool isOk = DateTime.TryParseExact(key, "MMMM", 
        CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result);
    if(isOk)
    {
        // 1900/12/01 00:00:00 になってるので月だけ取得する
        int month = result.Month;
        Console.WriteLine(month);
        // > 12
    }
    
    // 以下の書き方でも良いが key が変換できないと例外が発生する
    DateTime result2 = DateTime.ParseExact(key, "MMMM", CultureInfo.InvariantCulture);
}

DateTime型から略号を取得する方法

DateTime 型から月の略語を取得するときも MMM を使いますが、OSの設定が日本語だと以下のように「12月」と日本語で出力されます。

string str = DateTime.Now.ToString("MMM");
// 12月

// このプロパティによって出力が決まる
var c = Thread.CurrentThread.CurrentCulture;
// ja-jp

この時どういう出力がされるのかは、ToString の引数の IFormatProvider を省略した場合、今のスレッドの CurrentCulture が参照されます。OS の言語設定次第で変わってしまうのでフランス語設定などにしているとまた全然違う出力になります。

なので、「英語」の「3文字の短縮形」を出力した場合以下のように英語を指定するパラメーターを引数に指定します。

string str = DateTime.Now.ToString("MMM", new CultureInfo("en-us"));
// Dec

ObservableCollectionの要素の変更通知を受け取る

ObservableCollection でコレクションに格納されている要素の変更通知を受け取る方法です。

単純に Add されたときに要素に PropertyChanged を設定するだけでは全く考慮が足りないため現実的に子要素から通知を受け取る実装を考えたいと思います。

確認環境

  • .NET Core 3.1
  • VisualStudio2022
  • Windows11

コンソールアプリで確認

確認用コード

要素クラス

まず ObservableCollection の要素に設定する Item クラスを以下のように定義します。

変更通知を受け取るために INotifyPropertyChanged を継承しています。Value を変更すると通知を送るというような実装になります。

// Item.cs
public class Item : INotifyPropertyChanged
{
    // INotifyPropertyChanged impl --->
    public event PropertyChangedEventHandler PropertyChanged;
    private void RaisePropertyChanged([CallerMemberName] string propertyName = "")
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    // <---

    private string _value;
    public string Value
    {
        get => _value;
        set
        {
            _value = value;
            RaisePropertyChanged();
        }
    }
    public Item(string value) => Value = value;
    public static implicit operator Item(string value) => new Item(value);
    public override string ToString() => Value;
}

バインド用クラス

次に ObservableCollection と要素にイベントを設定するクラスを次のように定義します。

実装がかなり長いです。これは、Reset が発生したときに変更前と変更後の要素を NewItems と OldItems から取得できないという問題があるため、内部で List にキャッシュを持っていてその内容を ObservableCollection が操作されたときに同期する実装となっています。順序も併せて同期するのでさらに長くなっています。

また、新しくコレクションを設定したときに既に子要素が存在する可能性があるので一括でイベントを設定する、要素が追加されたときにイベントを追加、削除されたときにイベントを設定、解除する、コレクションが削除され時に要素のイベントを一括で削除するようにするなどの実装をしています。

特に、単純にコレクションに Add した時にイベントを追加するみたいにしてると要素から取り除かれたオブジェクトにイベントが飛んだり。イベントを設定しっぱなしにするとリソースリークにつながるので除去されたときに確実にイベントを外す実装をしています。

// ObservableCollectionBinding.cs
public class ObservableCollectionBinding<T>
{
    private ObservableCollection<T> _dataSource;
    public ObservableCollection<T> DataSource
    {
        get => _dataSource;
        set => SetDataSource(value);
    }

    // Reset 時に変更された要素を識別できないので内部キャッシュが必要
    private List<T> _cache = new List<T>();

    private void SetDataSource(ObservableCollection<T> collection)
    {
        if (ReferenceEquals(_dataSource, collection))
        {
            return; // 同じ値
        }

        UnBind();
        Bind(collection);
        
        _dataSource = collection;
    }

    // コレクションと子要素にイベントを設定する
    private void Bind(ObservableCollection<T> collection)
    {
        // コレクションの変更通知をバインド
        collection.CollectionChanged += OnCollectionChanged;

        // 各要素の変更通知をバインド
        foreach (var item in collection)
        {
            if (item is INotifyPropertyChanged inpc)
            {
                inpc.PropertyChanged -= OnPropertyChanged;
                inpc.PropertyChanged += OnPropertyChanged;
            }
        }
    }

    // コレクションと子要素からイベントを削除する
    private void UnBind()
    {
        // コレクションの変更通知を解除
        if (_dataSource is INotifyCollectionChanged incc)
        {
            incc.CollectionChanged -= OnCollectionChanged;
        }

        // 各要素の変更通知を全て解除
        if (_dataSource is IEnumerable collection)
        {
            foreach (var item in collection)
            {
                if (item is INotifyPropertyChanged inpc)
                {
                    inpc.PropertyChanged -= OnPropertyChanged;
                }
            }
        }
    }

    // 子要素のプロパティの変更通知を受けるイベントハンドラー
    private void OnPropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        Console.WriteLine($"OnPropertyChanged, PropertyName={e.PropertyName}");
    }

    // コレクションの変更通知を受け取るイベントハンドラー
    private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        switch (e.Action)
        {
            case NotifyCollectionChangedAction.Add: OnCollectionChanged_Add(e); break;
            case NotifyCollectionChangedAction.Remove: OnCollectionChanged_Remove(e); break;
            case NotifyCollectionChangedAction.Replace: OnCollectionChanged_Replace(e); break;
            case NotifyCollectionChangedAction.Move: OnCollectionChanged_Move(e); break;
            case NotifyCollectionChangedAction.Reset: OnCollectionChagned_Reset(e); break;
        }
    }

    // コレクションに要素が追加されたとき
    private void OnCollectionChanged_Add(NotifyCollectionChangedEventArgs e)
    {
        ProcNewItems(e.NewItems);

        if (_cache.Count == e.NewStartingIndex)
        {
            // 1件しかこないと決め打ちする
            _cache.Add((T)e.NewItems[0]);

            // 複数くることなんてある?
            //foreach (var item in e.NewItems)
            //{
            //    _cache.Add((T)item);
            //}
        }
        else
        {
            _cache.Insert(e.NewStartingIndex, (T)e.NewItems[0]);
        }
    }

    // コレクションから要素が削除されたとき
    private void OnCollectionChanged_Remove(NotifyCollectionChangedEventArgs e)
    {
        ProcOldItems(e.OldItems);
        _cache.RemoveAt(e.OldStartingIndex);
    }

    // コレクションの要素が置き換わった時
    private void OnCollectionChanged_Replace(NotifyCollectionChangedEventArgs e)
    {
        ProcOldItems(e.OldItems);
        ProcNewItems(e.NewItems);
        _cache[e.OldStartingIndex] = (T)e.NewItems[0];
    }

    // コレクションの要素が移動した時
    private void OnCollectionChanged_Move(NotifyCollectionChangedEventArgs e)
    {
        var tmp = _cache[e.NewStartingIndex];
        _cache[e.NewStartingIndex] = (T)e.NewItems[0];
        _cache[e.OldStartingIndex] = tmp;
    }

    // コレクションの要素が大幅に変わった時
    private void OnCollectionChagned_Reset(NotifyCollectionChangedEventArgs e)
    {
        // OldItems に通知が来ないからキャッシュ経由でイベントを削除
        ProcOldItems(_cache);
        _cache.Clear();
        ProcNewItems(_dataSource);
        _cache.AddRange(_dataSource);
    }

    // 要素にイベントを設定する
    private void ProcNewItems(IList newItems)
    {
        foreach (var item in newItems)
        {
            if (item is INotifyPropertyChanged inpc)
            {
                inpc.PropertyChanged -= OnPropertyChanged;
                inpc.PropertyChanged += OnPropertyChanged;
            }
        }
    }
    // 要素からイベントを削除する
    private void ProcOldItems(IList oldItems)
    {
        foreach (var item in oldItems)
        {
            if (item is INotifyPropertyChanged inpc)
            {
                inpc.PropertyChanged -= OnPropertyChanged;
            }
        }
    }
}

使い方

次に動作確認用の実装になります。

まず、以下のようにバインド用のオブジェクトをフィールドに配置しておいてあとから ObservableCollection をDataSource プロパティに追加するようにしています。

// Program.cs
internal class Program
{
    private static readonly ObservableCollectionBinding<Item> _b 
        = new ObservableCollectionBinding<Item>();

    private static void Main(string[] args)
    {
        var list = new ObservableCollection<Item>();
        _b.DataSource = list;

        Item item_000 = "000";
        Item item_001 = "001";
        Item item_002 = "002";
        Item item_003 = "003";
        Item item_004 = "004";
        Item item_005 = "005";

        // NotifyCollectionChangedAction.Addが発生する
        list.Add(item_003);
        list.Add(item_002);
        list.Add(item_001);
        list.Add(item_000);
        item_000.Value = "999";

        // NotifyCollectionChangedAction.Addが発生する
        list.Insert(2, item_005);

        // NotifyCollectionChangedAction.Removeが発生する
        var item = list[0];
        list.Remove(item);

        // NotifyCollectionChangedAction.Replaceが発生する
        list[2] = "004";

        // NotifyCollectionChangedAction.Moveが発生する
        list.Move(2, 0);

        // NotifyCollectionChangedAction.Resetが発生する
        list.Clear();
    }
}

以上で子要素の通知を取得することができるようになりました。

ただこれだけだとイベントを受け取るだけで何も具体的な処理が入ってないのでアプリケーションごとに必要な処理を追加していくことになると思います。

関連記事

takap-tech.com