WESL_UserSettingをC#から参照する

Windows OS 搭載端末をシンクライアント化(キオスク モード化)するようなシェルランチャーを指定する場合などには WESL_UserSetting を使用します。

これを C# の ManagementObject を通じて設定を参照する方法の紹介です。

前提条件として OS の「シェル ランチャー機能」が有効であることが必須です。

この記事内のコードをシェルランチャーが無効の状態で実行した場合以下例外が発生するので注意してください。

// シェル ランチャー機能が無効の場合エラーが発生する
System.Management.ManagementException: 無効なクラスです
   場所 System.Management.ManagementException.ThrowWithExtendedInfo(ManagementStatus errorCode)
   場所 System.Management.ManagementObjectCollection.ManagementObjectEnumerator.MoveNext()
   場所 System.Management.ManagementObjectCollection.get_Count()
   場所 ConsoleApp12.AppMain.Main(String[] args)

実装例

using System;
using System.Management;

var searcher = new ManagementObjectSearcher(
    "root\\standardcimv2\\embedded", "SELECT * FROM WESL_UserSetting");
foreach (ManagementObject obj in searcher.Get())
{
    Console.WriteLine("[SystemProperties]");
    foreach (PropertyData sp in obj.SystemProperties)
    {
        Console.WriteLine($"  {sp.Name}={sp.Value}");
    }
    Console.WriteLine("[Properties]");
    foreach (PropertyData p in obj.Properties)
    {
        Console.WriteLine($"  {p.Name}={p.Value}");
    }
    Console.WriteLine("[IsEnabled]");
    {
        // シェル起動ツールが有効か無効かを示す値を取得する
        ManagementBaseObject inParams = obj.GetMethodParameters("IsEnabled");
        ManagementBaseObject outParams = obj.InvokeMethod("IsEnabled", inParams, null);
        Console.WriteLine($"  Enabled={outParams["Enabled"]}");
    }
    Console.WriteLine("[GetCustomShell]");
    {
        // 既定のシェル起動ツールの構成を取得する
        ManagementBaseObject inParams = obj.GetMethodParameters("GetCustomShell");
        var account = new NTAccount("xxxxx");
        var idRef = account.Translate(typeof(SecurityIdentifier));

        string sid = idRef.Value;
        
        inParams["Sid"] = sid;

        ManagementBaseObject outParams = obj.InvokeMethod("GetCustomShell", inParams, null);
        Console.WriteLine($"  Shell={outParams["Shell"]}");
    }
}

//  [SystemProperties]
//    __GENUS=2
//    __CLASS=WESL_UserSetting
//    __SUPERCLASS=
//    __DYNASTY=WESL_UserSetting
//    __RELPATH=WESL_UserSetting.Sid="***"
//    __PROPERTY_COUNT=5
//    __DERIVATION=System.String[]
//    __SERVER=LASERMEISTER
//    __NAMESPACE=root\standardcimv2\embedded
//    __PATH=\\LASERMEISTER\root\standardcimv2\embedded:WESL_UserSetting.Sid="****"
//  [Properties]
//    CustomReturnCodes=
//    CustomReturnCodesAction=
//    DefaultAction=0
//    Shell=X:\*****\****\***.exe
//    Sid=****
//  [IsEnabled]
//    Enabled=True
//  [GetCustomShell]
//    Shell=X:\*****\****\***.exe

ManagementObjectSearcher で検索するのがポイント。直アクセスしようとすると無効なオブジェクトと例外が出る(もしかしたら取れる指定があるかもしれないが現状不明)

どうやら、IsEnabled はシェル ランチャー機能を有効にしている場合常にtrue、RemoveCustomShell してもいちど SetCustomShell したら Shell に値が残るようなので有効・無効状態を何らかの値で判定することができないようです。

なので RemoveCustomShell する前に SetCustomShell(${sid}, "", null, null) と指定してから RemoveCustomShell すると Shell の有無が判定できるようになるのかもしれませんが確認していません。

参考資料

MSDN: WESL_UserSetting

https://learn.microsoft.com/ja-jp/windows-hardware/customize/enterprise/wesl-usersetting

MSDN: シェル ランチャーを使って Windows クライアント キオスクを作成する

https://learn.microsoft.com/ja-jp/windows/configuration/kiosk-shelllauncher

StackOverflow: Setting Custom Shell via WMI

https://stackoverflow.com/questions/35345116/setting-custom-shell-via-wmi

【C#】TimeSpanの書式指定方法

System.TimeSpan 型の書式指定の方法です。

DateTime 型とは ToString の書式の指定が異なるので同じ感覚で記述すると以下の「System.FormatException」が発生します。

// こんな感じのエラーが出る
'span.ToString(@"dd\d\a\y mm\:ss\.fff")' は型 'System.FormatException' の例外をスローしました
    Data: {System.Collections.ListDictionaryInternal}
    HResult: -2146233033
    HelpLink: null
    InnerException: null
    Message: "Input string was not in a correct format."
    Source: "System.Private.CoreLib"
    StackTrace: "
        場所 System.Globalization.TimeSpanFormat.FormatCustomized(TimeSpan value, ReadOnlySpan`1 format, DateTimeFormatInfo dtfi, StringBuilder result)
        場所 System.Globalization.TimeSpanFormat.Format(TimeSpan value, String format, IFormatProvider formatProvider)
        場所 System.TimeSpan.ToString(String format)"
    TargetSite: {System.Text.StringBuilder FormatCustomized(System.TimeSpan, System.ReadOnlySpan`1[System.Char], System.Globalization.DateTimeFormatInfo, System.Text.StringBuilder)}

正しい記述方法は以下の通りです。

var span = new TimeSpan(1, 2, 3, 4, 5); // 1日 2時間 3分 4秒 5ms

// ★(1)
string str1 = span.ToString("dd\d\a\y\ mm\:ss\.fff");
// > 01day 03:04.005

// ★(2)
// 文字列補完で書式を指定する場合も同じ
string str2 = $@"{span:dd\d\a\y\ mm\:ss\.fff}";
// > 01day 03:04.005

書式指定中で書式指定文字以外を使用する場合、文字ごとに「\」を入れる必要があります。

  • 「day」と出力したい場合「\d\a\y」
  • 空白スペースも「(空白)]
  • コロンやドットも「:.」

時間を表すHHだけhhに変える必要がありますが、あとはDateTimeの書式指定と指定方法は同じです。

上記を認識した状態でMSDNのリファレンスを読めば書式指定で例外が減ると思います。

TweenCancelBehaviourの指定ごとの挙動

DOTween + UniTask 環境で ToUniTask に渡す TweenCancelBehaviour の識別子ごとの挙動の説明です。

前提として、DOTween を UniTask で await するときに挙動を指定する ToUniTask メソッドには TweenCancelBehaviour を渡すことでキャンセル時の挙動を指定することができます。デフォルトではキャンセルすると(例外などが発生せずに)次のステップが実行されます。これだと都合が割ることが多いため、終了後に UniTask で判定することもできますが毎回それをすると実装の負担が大きいです。そのため、ToUniTask に TweenCancelBehaviour を指定することで挙動を制御することができます。

TweenCancelBehaviour の指定によってどういう挙動になるのか見ていきたいと思います。

確認環境

  • Unity 2021.3.5f1
  • Windows 11 + VisalStudio 2022
  • DOTween
  • UniTask 2.3.1

TweenCancelBehaviour

識別子ごとの挙動は以下の通りです。

キャンセルすると例外が発生する指定と、発生しない指定の 2 つが組であります。キャンセル時に発生する例外は OperationCanceledException です。

識別子 挙動
Kill (デフォルト) tween.Kill(false) + 例外なし
KillAndCancelAwait tween.Kill(false)+キャンセルで例外
KillWithCompleteCallback tween.Kill(true)+例外なし
KillWithCompleteCallbackAndCancelAwait tween.Kill(true)+キャンセルで例外
Complete tween.Complete(false)+例外なし
CompleteAndCancelAwait tween.Complete(false)+キャンセルで例外
CompleteWithSequenceCallback tween.Complete(true)+例外なし
CompleteWithSequenceCallbackAndCancelAwait tween.Complete(true)+キャンセルで例外
CancelAwait キャンセルで例外、例外は出るが実行は停止しない(詳細不明

Tween の各メソッドの挙動は以下の通りです。

メソッド 説明
tween.Kill(false) その場で即時終了、OnComplete が呼ばれない
tween.Kill(true) その場で即時終了、OnComplete が呼ばれる
tween.Complete(fasle) 最終位置にジャンプ、OnCompleteが呼ばれる
tween.Complete(true) 最終位置にジャンプ、途中のコールバックと OnComplete を全部呼ぶ

確認用のコード

今回の挙動は以下のコンポーネントをワールドに配置した SpriteRebderer に配置して確認しています。

// 確認用のコンポーネント
public class Square : MonoBehaviour
{
    [SerializeField] TweenCancelBehaviour _type;

    Tween tween;
    CancellationTokenSource _cts;

    //[Button]
    public async void Exec()
    {
        using (_cts) { }
        _cts = new CancellationTokenSource();

        tween.Kill();
        transform.SetLocalPosX(-1.0f);

        try
        {
            Log.Trace("開始しました --->");

            await transform.DOLocalMoveX(1.0f, 5.0f).ToUniTask(_type, _cts.Token);

            Log.Trace("終了しました <---");
        }
        catch (System.Exception ex)
        {
            Log.Warn($"【★例外発生】{ex.Message}");
        }
    }

    //[Button]
    public void Cancel()
    {
        _cts.Cancel();
    }
}

余談ですが Qiita の「DOTWeenでToUniTaskを使う時に~」という記事は例外が出る or 出ないの表記が誤ってるのでご注意ください。

【C#/Unity】A*(A-Star)で経路探索を実装する

Unity の場合、3D, 2D ともに NavMesh とエージェントを使用すれば経路探索を自力で実装する機会はないかもしれませんが、経路探索をA*(A-Star)というアルゴリズムを自分で実装する場合の考え方と実装の紹介をしたいと思います。

グリッドベースの A* の経路探索は解説がいくつか見られるため、今回はノードベース (WayPoint) を用いた A* による経路探索を紹介したいと思います。

ちなみに、2D で NavMesh 以前記事を書いたのですが NavMesh の 2D 用を使用すると以下のリンク先の通り経路探索を利用することができます。

takap-tech.com

確認環境

  • Unity 2021.3
  • VisualStudio 2022
  • Windows11

Editor 上で動作を確認しています。

できること

通行可能なある地点を「WayPoint」(赤丸の個所)として、任意の位置に配置した WayPoint どうしを連結したものを経路情報として利用します。この WayPoint どうしの一連のつながりを A*(A-Star) という経路探索アルゴリズムで処理していきます。

実際に経路探索している様子は以下の通りです。

処理は、「初期化」 → 「経路探索を1Stepずつ進める(青文字)」 → 「求めたルートを表示(赤文字)」 → 「赤いルートに沿って緑のオブジェクトを移動」という順に進んでる様子が確認できると思います。

処理の説明

使用するデータ構造

まず以下のような WayPoint が網目状に接続されたグラフデータ構造を準備します。

正面から見ると平坦ですが、Z方向に高さを持つため横から見るとこのように高低差があります。NavMesh だとこの高低差がありすぎると経路として使用しない設定があると思いますが今回の実装では考慮していません。

考え方

見づらいので、以降は以下の通り、平面の図を使用していきます。カッコ内の図宇治は (X, Y, Z) の位置情報です。

まず Start と隣接するセルの各情報を調べて「Open(黄色)」状態とします。ノードは Open したときに以下の情報を記録します。そして Open済みのノードのうち最小の Score のノードを次のノードとして選択し(水色)、元の位置は「Close(灰色)」とします。

Total = 開始位置からそのノードまでの合計移動距離 Estimated = 終了位置からの直線距離 Score = Total + Estimated Parent = 直前のノード(表示していませんが関係を持たせています)

次のステップで選択したノードの隣接ノードのうち、まだ Open していないノードの情報を調べて「Open済み」とします。そして Open 済みのノードの中から最小 Score のノードを選択します。また、選択したノードはOpen対象から除外するので「Close」に変更します。

次のステップも同様の手順になります。Open済みのノードから最小 Score を選ぶ → 周囲を Open する → 元の位置を Close する、です。

そして、何度もこの処理を繰り返すと、Open したものの中に Goal のノードが現れます。この時点で処理を打ち切って探索を終了します。

最後に Goal から直前のノードを辿って Start までのノードを逆順に追うと Start → Goal の経路が得られます。

今回は狭い範囲で検索を行っていますが、実際はかなり広い領域に大量の WayPoint がある状態で処理を行うと思います。この時、遠すぎる位置を一度に検索するとかなり時間がかるため、ゲーム中では数フレームに分割したり、遠すぎる経路を探索から除外するなどの処理量を制限する仕組を別途設ける必要があります。

例えば、以下は考慮する必要があると思います。

  • ステップごとに処理を分割できるようにしておく
  • Total がある値より大きい場合経路判定から除外する
  • ある程度計算して到達できなかったら移動不能とする

実装コード

では実装を見ていきたいと思います。

使用するクラスは以下の通りです。

クラス名 説明
WayPoint 画面上で移動可能な地点を表すクラス
AStarNode WayPoint ごとに存在する経路探索中の情報を格納するクラス
AStar WayPointを使用した経路探索処理を行うクラス

WayPointクラス

移動可能な地点を表すUnityのコンポーネントです。GameObjectにアタッチして使用します。

隣あった移動可能なノードを「_relations」リストにインスペクターから設定してきます。

// WayPoint.cs

using System.Collections.Generic;
using System.Linq;
using UnityEngine;

namespace Takap.Utility.Algorithm
{
    /// <summary>
    /// ゲーム中のある地点を表します。
    /// </summary>
    [ExecuteAlways]
    public partial class WayPoint : MonoBehaviour
    {
        // Inspector

        // 移動可能な隣接ノード
        [SerializeField] List<WayPoint> _relations;
        // 通行可能かどうか
        [SerializeField] bool _canMove = true;

        // Fields

        // 直前のノード隣接ノード状態を記録するリスト
        readonly List<WayPoint> _previousList = new List<WayPoint>();

        // Props

        // 移動可能な隣接ノードのリストを取得する
        public List<WayPoint> Rerations => _relations;

        // この地点が移動可能かどうかを取得します。
        public bool CanMove => _canMove;

        // Rintime impl

        private void Awake()
        {
            SynchronizeRelationsToPrevious();
            GizmoText = name;
        }

#if UNITY_EDITOR
        public void OnValidate()
        {
            if (_relations is null || _previousList is null)
            {
                return;
            }

            // 接続先の情報を更新する
            if (_relations.Count > _previousList.Count)
            {
                // 相手に自分を追加する(復路登録)
                foreach (var p in _relations.Except(_previousList))
                {
                    //Log.Trace(p.name);
                    if (!p.Rerations.Contains(this))
                    {
                        p.Rerations.Add(this);
                        p.SynchronizeRelationsToPrevious();
                    }
                }
            }
            else if (_relations.Count < _previousList.Count)
            {
                foreach (var p in _previousList.Except(_relations))
                {
                    p.Rerations.Remove(this); // 相手から自分を削除
                    p.SynchronizeRelationsToPrevious();
                }
            }

            SynchronizeRelationsToPrevious();

            GizmoText = name;
        }
#endif
        // Methods

        public void SynchronizeRelationsToPrevious()
        {
            _previousList.Clear();
            foreach (var p in _relations)
            {
                _previousList.Add(p);
            }
        }
    }
}

処理に関係ないデバッグ用の Gizmo 描画は以下のクラスに分割しています。

// WayPoint.Gizmo.cs

using UnityEngine;

#if UNITY_EDITOR
using UnityEditor;
#endif

namespace Takap.Utility.Algorithm
{
    // Gizmo描画処理
    public partial class WayPoint
    {
        // Gizmoで表示するテキスト
        public string GizmoText { get; set; }
        // Gizmoの線の色
        public Color GizmoColor { get; set; } = Color.white;

#if UNITY_EDITOR
        private void OnDrawGizmos()
        {
            GizmoDrawer.DispCrossMark(this, 0.2f, GizmoColor);
            Handles.Label(transform.localPosition, GizmoText);

            if (_relations is null || _relations.Count == 0)
            {
                return;
            }

            foreach (var next in _relations)
            {
                if (next is null) continue;

                // ポイント間に線を引く
                Color previous = Gizmos.color;
                if (GizmoText.Contains("Step") && next.GizmoText.Contains($"Step"))
                {
                    Gizmos.color = Color.red;
                }
                else if (GizmoText.Contains($"{NodeStatus.Open}")|| 
                           next.GizmoText.Contains($"{NodeStatus.Open}") &&
                         !(GizmoText.Contains($"{NodeStatus.None}") || 
                           next.GizmoText.Contains($"{NodeStatus.None}")))
                {
                    Gizmos.color = Color.yellow;
                }
                else if (GizmoText.Contains($"{NodeStatus.Close}") || 
                           next.GizmoText.Contains($"{NodeStatus.Close}"))
                {
                    Gizmos.color = Color.blue;
                }
                else if (GizmoText.Contains($"{NodeStatus.Exclude}") || 
                           next.GizmoText.Contains($"{NodeStatus.Exclude}"))
                {
                    Gizmos.color = Color.gray;
                }
                Gizmos.DrawLine(transform.localPosition, next.transform.localPosition);
                Gizmos.color = previous;
            }
        }
#endif
    }
}

冒頭のノードの配置で Point1 には以下のようにノードが設定されています。

このリストにインスペクター上で WayPoint を追加すると相手のノードのリストにも自分の情報が復路として自動で設定されます(一方通行の場合もあるかもしれませんがここでは考慮せず双方向としています)

AStarNodeクラス

経路探索を行っている最中の情報を記録するためのクラスです。単に情報を格納するだけのクラスです。

// AStarNode.cs

namespace Takap.Utility.Algorithm
{
    public class AStarNode
    {
        // 親ノード
        public AStarNode Parent { get; private set; }

        // 対応する位置
        public readonly WayPoint WayPoint; // Derived from Monobehaiour

        // ノードのステータス
        public NodeStatus Status { get; set; }

        // 開始地点からの総移動距離
        public float Total { get; private set; }

        // ゴールまでの推定移動距離
        public float Estimated { get; private set; }

        // ノードのスコア
        public float Score => Total + Estimated;

        // Constructors

        public AStarNode(WayPoint wayPoint)
        {
            WayPoint = wayPoint;
        }

        // Public Methods

        public void Open(AStarNode previous, AStarNode goal)
        {
            if (previous is not null)
            {
                Total = previous.Total + 
                    UnityEngine.Vector3.Distance(previous.WayPoint.transform.position, 
                        WayPoint.transform.position);
            }
            Estimated = UnityEngine.Vector3.Distance(WayPoint.transform.position, 
                goal.WayPoint.transform.position);

            Parent = previous;
            Status = NodeStatus.Open;
        }
    }

    public enum NodeStatus
    {
        None = 0,
        Open,
        Close,
        Exclude, // 探索対象にしない
    }
}

AStarWayPointクラス

WayPoint を AStarNode クラスに格納してノードの情報を使用してA*を使用して経路探索を行うためのクラスです。

コンストラクタで対象の WayPoint と開始位置、終了位置を指定した後、SearchOneStep メソッドの戻り値が SearchState.Completed になるまで繰り返しメソッドを呼び出します。探索する量が多くなると1フレーム内では終わらないので任意の位置で探索を打ち切って次のフレームで続きを実行していきます。

それが必要ない場合 SearchAll メソッドを呼び出して一気に経路探索を行います。

SearchState.Completed になったら GetRoute メソッドを呼び出して経路情報を配列として取得します。

// AStarWayPoint.cs

using System;
using System.Collections.Generic;
using System.Linq;

namespace Takap.Utility.Algorithm
{
    public class AStar
    {
        // Fields

        // オープン済みのノードを記憶するリスト
        readonly List<AStarNode> _openList = new();
        // 開始ノード
        public readonly AStarNode StartPoint;
        // 終了ノード
        public readonly AStarNode EndPoint;
        // 現在の検索処理状態
        SearchState _searchState;

        // Props

        // 探索対象のデータ管理
        public readonly Dictionary<WayPoint, AStarNode> NodeTable = new();

        // Constructors

        public AStar(IEnumerable<WayPoint> wayPoints, WayPoint startPoint, WayPoint endPoint)
        {
            foreach (var p in wayPoints)
            {
                if (p is null)
                {
                    continue;
                }

                AStarNode sn = new(p);
                if (!p.CanMove)
                {
                    sn.Status = NodeStatus.Exclude;
                }
                NodeTable[p] = sn;
            }

            StartPoint = NodeTable[startPoint];
            EndPoint = NodeTable[endPoint];

            if (!NodeTable.ContainsKey(startPoint))
            {
                throw new ArgumentException($"{nameof(wayPoints)}の中に" +
                    $"{nameof(startPoint)}が含まれていません。");
            }

            // 最初のタイルをOpenに設定する
            AStarNode node = NodeTable[startPoint];
            node.Open(null, NodeTable[endPoint]);
            _openList.Add(node);
        }

        // Public Methods

        // ルート検索を全て実行する
        public SearchState SearchAll()
        {
            SearchState state;
            while (true)
            {
                state = SearchOneStep();
                if (state != SearchState.Searching)
                {
                    break;
                }
            }

            return state;
        }

        // ルート検索を1ステップ実行する
        public SearchState SearchOneStep()
        {
            // 処理が完了している場合ステータスを返して検索処理しない
            if (_searchState != SearchState.Searching)
            {
                return _searchState;
            }

            AStarNode parentNode = GetMinCostNode();
            if (parentNode is null)
            {
                // 次にオープンするものが検索完了する前になくなった場合
                _searchState = SearchState.Incomplete;
                return _searchState;
            }

            // オープン済みリストからノードを削除して対象外にする
            parentNode.Status = NodeStatus.Close;
            _openList.Remove(parentNode);

            if (parentNode.WayPoint == EndPoint.WayPoint)
            {
                // 次に開いたノードがゴール地点だった
                _searchState = SearchState.Completed;
                return _searchState;
            }

            // 隣接ノードをすべてオープンする
            foreach (WayPoint aroundPoint in parentNode.WayPoint.Rerations)
            {
                if (!NodeTable.ContainsKey(aroundPoint))
                {
                    // コンストラクタで指定したポイントの中に隣接ノードが含まれていなかったらエラー
                    _searchState = SearchState.Error;
                    return _searchState;
                }

                AStarNode tmpNode = NodeTable[aroundPoint];
                if (tmpNode.Status != NodeStatus.None)
                {
                    continue;
                }

                tmpNode.Open(parentNode, EndPoint);

                _openList.Add(tmpNode);

                // 開いたノードが終点だったらその場で処理を打ち切って処理終了
                if (tmpNode.WayPoint == EndPoint.WayPoint)
                {
                    tmpNode.Status = NodeStatus.Close;
                    _openList.Remove(tmpNode);

                    _searchState = SearchState.Completed;
                    return _searchState;
                }
            }

            return _searchState;
        }

        // 検索結果のルートを取得する
        public WayPoint[] GetRoute()
        {
            if (_searchState != SearchState.Completed)
            {
                throw new InvalidOperationException("検索未完了ではルートを取得できません。");
            }

            AStarNode tmpNode = EndPoint;
            IEnumerable<WayPoint> f()
            {
                while (tmpNode.Parent != null)
                {
                    yield return tmpNode.WayPoint;
                    tmpNode = tmpNode.Parent;
                }

                yield return StartPoint.WayPoint;
            }

            var resultArray = f().ToArray();
            Array.Reverse(resultArray);
            return resultArray;
        }

        // オープン済みのノードリストから最小コストのノードを取得する 
        public AStarNode GetMinCostNode()
        {
            if (_openList.Count == 0)
            {
                return null;
            }

            // リストから最小コストのノードを選択する
            AStarNode minCostNode = _openList[0];
            if (_openList.Count > 1)
            {
                for (int i = 1; i < _openList.Count; i++)
                {
                    AStarNode tmpNode = _openList[i];
                    if (minCostNode.Score > tmpNode.Score)
                    {
                        minCostNode = tmpNode;
                    }
                }
            }
            return minCostNode;
        }
    }
}

テスト用のコード

参考程度ですが、テスト用のクラスです。以下のようにヒエラルキーを構成して Route にコンポーネントを配置します。

// AstarTest.cs

namespace Takap.Utility.Algorithm
{
    public class AstarTest : MonoBehaviour
    {
        [SerializeField] WayPoint _start; // 開始位置
        [SerializeField] WayPoint _end; // 終了位置
        [SerializeField] Transform _charactor;

        AStarWayPoint _astar;

        //[Button]
        public void StartAStar()
        {
            if (_start is null)
            {
                return;
            }

            if (_end is null)
            {
                return;
            }

            // 子要素のWayPointをすべて取得して経路情報に設定する
            _pointList = GetComponentsInChildren<WayPoint>();
            _astar = new AStarWayPoint(_pointList, _start, _end);
        }

        WayPoint[] _pointList;

        //[Button]
        public void StepNext()
        {
            SearchState status = _astar.SearchOneStep();
            Log.Trace($"AStar status={status}");

            UpdatePointsTextAndColor();
        }

        //[Button]
        public void ShowRute()
        {
            WayPoint[] routePoint = _astar.GetRoute();
            for (int i = 0; i < routePoint.Length; i++)
            {
                WayPoint p = routePoint[i];
                p.GizmoText = $"Step={i}\r\n{p.GizmoText}";
                p.GizmoColor = Color.red;
            }
        }

        //[Button]
        public async void MoveCharactor()
        {
            WayPoint[] routePoint = _astar.GetRoute();
            _charactor.transform.position = routePoint[0].transform.position;
            for (int i = 1; i < routePoint.Length; i++)
            {
                await _charactor.DOLocalMove(routePoint[i].transform.localPosition, 0.5f)
                    .SetEase(Ease.Linear).AsyncWaitForKill();
            }
        }

        private void UpdatePointsTextAndColor()
        {
            if (_pointList is null)
            {
                return;
            }

            foreach (AStarNode nodeInfo in _astar.NodeTable.Values)
            {
                string text = $"{nodeInfo.WayPoint.name}({nodeInfo.Status})\r\n"
                    + $"C={nodeInfo.C:F2}, H={nodeInfo.H:F2}";
                nodeInfo.WayPoint.GizmoText = text;

                switch (nodeInfo.Status)
                {
                    case NodeStatus.None: nodeInfo.WayPoint.GizmoColor = Color.white; break;
                    case NodeStatus.Open: nodeInfo.WayPoint.GizmoColor = Color.yellow; break;
                    case NodeStatus.Close: nodeInfo.WayPoint.GizmoColor = Color.blue; break;
                    case NodeStatus.Exclude: nodeInfo.WayPoint.GizmoColor = Color.gray; break;
                    default:
                        break;
                }
            }
        }
    }
}

これで経路網を設定した後 AStarWayPoint クラスの各メソッドを呼び出すと冒頭の経路探索ができるようになりました。

隣接ノードの選択の優先順位は、「自分と次の地点の距離 + 終点からの距離」が一番小さくなる順となっています。最小のノードを選択しくためきぞんの範囲からじわじわと検索済みが広がっていくような動作になります。途中で壁があったりすると検索量がはねあがったりします。

グリッドベースと違いノードが連結されていれば任意の位置同士を接続できるのが WayPoint ベースの検索の強みだと思います。

【C#】Tupleで値を入れ替える時の処理速度

かなり前に Tuple で値を入れ替える方法を紹介しましたが、普通に入れ替えるのと、Tuple を使用して値を入れ替えるので処理速度がどの程度違うのか確認します。

takap-tech.com

確認環境

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

  • .NET6 + C# 10.0
  • VisualStudio 2022
  • Windows11
  • BenchmarkDotNet
  • Ryzen 5900X
  • Relese ビルドをコンソールから実行して確認

確認用のコード

確認用のコードは以下の通りです。1000件の配列の値を逆順に入れ替えるために「通常の変数を使用した入れ替え」と「Tuple を使用した入れ替え」の2種類の速度を BenchmarkDotNet を使用して計測します。

using System;
using System.Linq;
using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

namespace Takap.Performance
{
    public class AppMain
    {
        public static void Main(string[] args)
        {
            BenchmarkRunner.Run<ArrayReverseTest>();
        }
    }

    public class ArrayReverseTest
    {
        // (1) 一時変数を利用した通常の入れ替え
        [Benchmark]
        public void ArrayReverseSelf()
        {
            int[] testData = GetData();
            ArrayUtil.Reverse(testData);
        }

        // (2) Tuple を使用した入れ替え
        [Benchmark]
        public void ArrayReverseSelf_v2()
        {
            int[] testData = GetData();
            ArrayUtil.ReverseTuple(testData);
        }

        // テストデータの生成
        int[] _data;
        public int[] GetData()
        {
            return _data ??= Enumerable.Range(0, 1000).ToArray();
        }
    }

    public static class ArrayUtil
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public static void Reverse<T>(T[] array)
        {
            if (array is null) throw new ArgumentException(nameof(array));

            int top = 0;
            int tail = array.Length - 1;
            T tmp = default;
            while (top < tail)
            {
                tmp = array[top];
                array[top] = array[tail];
                array[tail] = tmp;
                top++;
                tail--;
            }
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public static void ReverseTuple<T>(T[] array)
        {
            if (array is null) throw new ArgumentException(nameof(array));

            int top = 0;
            int tail = array.Length - 1;
            T tmp = default;
            while (top < tail)
            {
                (array[top], array[tail]) = (array[tail], array[top]);
                top++;
                tail--;
            }
        }
    }
}

とくに以下の個所の処理速度の違いを確認します。

// (1) 一時変数を利用した通常の入れ替え
T tmp = default;
while (top < tail)
{
    tmp = array[top];
    array[top] = array[tail];
    array[tail] = tmp;

// (2) Tuple を使用した入れ替え
(array[top], array[tail]) = (array[tail], array[top]);

計測結果

計測結果は以下の通りです。

速度は同じです

|            Method |     Mean |   Error |  StdDev |
|------------------ |---------:|--------:|--------:|
|      ArrayReverse | 276.5 ns | 3.44 ns | 3.05 ns |
| ArrayReverseTuple | 276.0 ns | 2.43 ns | 2.28 ns |

件数を増やしても優位な差が出なかったので本当に同じなのだと思います。

参考までに、Tuple の値の入れ替えは以下のように展開され、変数がひとつ多く取られますがこの程度は差異にならないようです。

(array[top], array[tail]) = (array[tail], array[top]);

// ★こんな感じに展開される
// int num3 = num;
// int num4 = num2;
// T val = array[num2];
// T val2 = array[num];
// array[num3] = val;
// array[num4] = val2;

【C#】Timerで1日1回任意の時間に処理を実行する

C# で Tiemr を使って1日1回任意の時間に処理を実行する方法を紹介したと思います。

タスクスケジューラーにC#のプログラムを登録すれば、特別な実装をしなくてもOSが自動で処理を起動してくれるます。が、その手段が取れない or 長時間起動しているプロセス中で自分で好きな時間に処理を日に1回だけ起動したいなどの用途を想定しています。

確認環境

  • .NET6 + C# 10.0
  • VisualStudio2022
  • Windows11

考え方

実装の考え方は以下の通りです。

  • System.Timers.Timer を使用する
  • Timer のイベントを1秒に1回起動してタイミングかどうかを確認する
  • ある時刻を過ぎていたら任意の処理を起動する

使用方法

先に使用方法の紹介です。

実行時間と確認周期はインスタンスを作成する際に指定します。変更する場合新しい値でインスタンスを作り直してください。実際に時間が来た時に処理する内容は Elapsed にtタイマーを開始する前にイベントを登録します。

namespace Takap
{
    internal class AppMain
    {
        public static void Main(string[] args)
        {
            // 内部で1秒に1回時刻をチェックするように指定してオブジェクトを作成
            var timer = 
                new ProcessOnceDayTimer(
                    TimeSpan.FromSeconds(1), // タイマーが時間を確認する間隔
                    DateTimeUtil.GetTime(16, 10)); // この時間を超えると処理を実行する
            
            timer.Elapsed += () =>
            {
                // 時間が来たら実行する処理を指定する
                Console.WriteLine($"[{DateTime.Now:yyyy/MM/dd HH:mm:ss}] Execute!!");
            };

            timer.Start();

            Console.WriteLine("Wait...");
            Console.ReadLine();

            // 16:10を超えると以下が表示される
            // >[2022/07/12 16:10] Execute!

            // 次の日の16:10を超えると以下が表示される
            // >[2022/07/13 16:10] Execute!

            timer.Dispose(); // 使い終わったらDisposeを呼び出して破棄する
        }
    }
}

実装コード

実装コードは以下の通りです。

using System;
using System.Timers;

namespace Takap
{
    /// <summary>
    /// 1日1回だけ指定した時刻に処理を実行するタイマークラス
    /// </summary>
    public class ProcessOnceDayTimer : IDisposable
    {
        // Fields - - - - - - - - - - - - - - - - - - - -

        // 定周期処理用のタイマー
        private Timer _timer;
        // 最後に処理を実行した時刻
        DateTime _nextExecTime;
        // 排他制御用
        private readonly object _logckObj = new object();

        
        // Events - - - - - - - - - - - - - - - - - - - -

        /// <summary>
        /// 一日一回指定した時刻に実行する処理を設定するハンドラー
        /// </summary>
        /// <remarks>
        /// この通知は環境によって Rx とか MessagePipe に変更しても良い。
        /// </remarks>
        public event Action Elapsed;


        // Props - - - - - - - - - - - - - - - - - - - -

        /// <summary>
        /// 処理が実行される時間を取得します。
        /// <para>時刻部分しか参照しません。日付は無視されます。</para>
        /// </summary>
        public DateTime ExectionTime { get; init; }


        // Constructors & Methods - - - - - - - - - - - - - - - - - - - -

        /// <summary>
        /// タイマーの実行間隔を指定してオブジェクトを作成します。
        /// </summary>
        public ProcessOnceDayTimer(TimeSpan interval, DateTime execTime)
        {
            _timer = new Timer(interval.TotalMilliseconds);
            _timer.Elapsed += OnElapsed;
            ExectionTime = execTime;
        }

        /// <summary>
        /// タイマーを開始します。
        /// </summary>
        public void Start()
        {
            if (_timer == null) throw new ObjectDisposedException("This object is disposed.");
            lock (_logckObj) _timer.Start();

            // 次の日時を作成する

            var now = DateTime.Now;
            var next = 
                new DateTime(now.Year, now.Month, now.Day, 
                    ExectionTime.Hour, ExectionTime.Minute, ExectionTime.Second);
            if (now > next)
            {
                // 起動予定時刻を過ぎていたら次の日を設定
                next = next.AddDays(1);
            }
            _nextExecTime = next;
        }

        /// <summary>
        /// タイマーを停止します。
        /// </summary>
        public void Stop()
        {
            if (_timer == null) throw new ObjectDisposedException("This object is disposed.");
            lock (_logckObj) _timer.Stop();
        }

        // IDisposable の実装
        public void Dispose()
        {
            if (_timer == null) return; // 解放済み

            lock (_logckObj)
            {
                Elapsed = null;
                _timer.Stop();
                using (_timer) { }
                GC.SuppressFinalize(this);
            }
        }

        // 定周期で実行されるメソッド
        private void OnElapsed(object sender, ElapsedEventArgs e)
        {
            lock (_logckObj)
            {
                try
                {
                    _timer.Stop();

                    var now = DateTime.Now;
                    if(_nextExecTime > now)
                    {
                        return; // 1日1回まで
                    }

                    if (_timer == null)
                    {
                        return; // 実行前にもう一度オブジェクトが有効か確認する
                    }

                    Elapsed?.Invoke();

                    _nextExecTime = _nextExecTime.AddDays(1); // 次回実行時刻を設定
                }
                finally
                {
                    _timer.Start();
                }
            }
        }
    }

    // 時刻作成用のヘルパークラス
    public static class DateTimeUtil
    {
        /// <summary>
        /// 有効な時間と分の組み合わせを取得します。
        /// </summary>
        public static DateTime GetTime(int hour, int min)
        {
            var time = DateTime.MinValue;
            return new DateTime(time.Year, time.Month, time.Day, hour, min, 0);
        }
    }
}

これで1日1回、指定した時間に Elapsed で指定した処理が1回だけ実行されるようになります。Timer は割としっかり動くので長期間放置していてもしっかり動きます。

開始したときに指定した時刻を過ぎていた場合は、その日は処理が実行されずに次の日に時間が来たら処理が実行されます。

【C#】コンソールアプリでMessagePipeを使う

前回コンソールアプリ上で「Microsoft.Extensions.DependencyInjection」を使用した DI を環境を構築しましたが、今回は、この環境を使って Cysharp がリリースしているメッセージングライブラリの「MessagePipe」をコンソールアプリに導入して動作を確認します。

MessagePipe はメッセージングライブラリと書きましたが、簡単に説明すると、お互いに依存関係の無い別のオブジェクトに対してブロードキャスト的にイベントを発行すための仕組みを提供するライブラリです。

特に、同じプロセス内で使用する分にはかなり簡単にメッセージを飛ばすことができます。特に、Unity などで使用する場合ゲームは1つのプロセス内で動くことになるので簡単にメッセージをクラスやコンポーネント間などでやり取りすることができます。

確認環境

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

  • VisualStudio2022
  • .NET6 + C#10.0
  • Microsoft.Extensions.DependencyInjection 6.0.0
  • Install-Package MessagePipe -Version 1.7.4

環境作成

パッケージマネージャーコンソールで以下コマンドを打ちます。

// メニュー
ツール > NuGet パッケージ マネージャー > パッケージ マネージャー コンソール

// コマンド(1)
// https://www.nuget.org/packages/Microsoft.Extensions.DependencyInjection
> Install-Package Microsoft.Extensions.DependencyInjection -Version 6.0.0

// コマンド(2)
// https://www.nuget.org/packages/MessagePipe/
> Install-Package MessagePipe -Version 1.7.4

これで、DI 用の「ServiceCollection」と、MessagePipe 用の「IPublisher」クラスが使用可能となります。

動作確認

MessagePipe と検証用の実装の登場人物は以下の通りです。

名前 説明
IPublisher<T> メッセージを発行するインターフェース
ISubscriber<T> メッセージを受け取るためのインターフェース
EventData 各<T>でメッセージでやり取りするデータ
Service IPublisher を使ってメッセージを発行するクラス
Client ISubscriber を使ってメッセージを受信するクラス

まずは、イベントで受け渡す型を以下の通り定義します。

// EventData.cs

// 受け渡し用のデータクラス
public readonly struct EventData
{
    // データの種類
    public readonly EventType Type;
    // データの値
    public readonly int Value;

    public EventData(EventType eventType, int value)
    {
        Type = eventType;
        Value = value;
    }

    // フィルター用の型
    public enum EventType
    {
        Type1,
        Type2,
    }
}

次に、イベントの発行側と受け取り側を以下の通り実装します。

IPublisher と ISubscriber は DI のコンストラクタインジェクションで受け取るようにします。

// イベント発行側の実装
public class Service
{
    readonly IPublisher<EventData> _publisher;

    public Service(IPublisher<EventData> publisher) // コンストラクタインジェクションで受け取り
    {
        _publisher = publisher;
    }

    public void Send()
    {
        _publisher.Publish(new EventData(EventData.EventType.Type2, 100));
    }
}

// イベント受け取り側の実装
public class Client : IDisposable
{
    readonly ISubscriber<EventData> _subscriber;
    readonly DisposableBagBuilder _bag = DisposableBag.CreateBuilder();

    public Client(ISubscriber<EventData> subscriber) // コンストラクタインジェクションで受け取り
    {
        _subscriber = subscriber;

        // ★全てのイベントを受け取る
        subscriber.Subscribe(OnEvent).AddTo(_bag);

        // ★条件に一致したイベントだけ受け取る
        subscriber.Subscribe(OnEvent,
            data => data.Type == EventData.EventType.Type2).AddTo(_bag);
    }

    // オブジェクトの開放処理
    public void Dispose()
    {
        using (_bag.Build()) { }
        GC.SuppressFinalize(this);
    }

    public void OnEvent(EventData data)
    {
        Console.WriteLine($"OnEvent. Type={data.Type} Value={data.Value}");
    }
}

実際に使用するときは、複数の種類のメッセージを扱うと思いますが、メッセージの種類は、(1) 違う型を使用する、(2) 同じ型だけど内容でフィルターするのどちらかが選択できます。(2) の実装の場合★の個所で Subscribe の第二引数に条件を記述することで、内容によって受信するかしないかをフィルターすることができます。

最後に、DI の設定とオブジェクトの使用をメインメソッドに以下の通り実装します。

using MessagePipe;
using Microsoft.Extensions.DependencyInjection;

internal class AppMain
{
    static readonly IServiceCollection _services = new ServiceCollection();
    static ServiceProvider _provider;
    
    private static void Main(string[] args)
    {
        // DIコンテナにMessagePipeの準備
        _services.AddMessagePipe();

        // DIコンテナに送受信するオブジェクトの準備
        _services.AddSingleton<Service>();
        _services.AddSingleton<Client>();

        // 依存関係を解決してくれるオブジェクトを取得
        _provider = _services.BuildServiceProvider();

        // 生成目的に読み捨て
        _provider.GetRequiredService<Client>();

        // 動作確認
        var service = _provider.GetRequiredService<Service>();
        service.Send();
        // Serviceでメッセージを発行するとClientが受信して以下メッセージが表示される
        // >OnEvent. Value=100
    }
}

これで以上です。DI は各実行環境で違うと思うので適宜変更する必要がありますが、メッセージを送信 → 受信するだけならこれで実装できます。

【C#】コンソールアプリでDIを使う

.NET 6 でコンソールアプリに DI (=Dependency Injection) 環境を作成して、オブジェクトに依存関係を注入するところまでを確認したいと思います。

使用するライブラリは、「Microsoft.Extensions.DependencyInjection」です。

確認環境

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

  • VisualStudio2022
  • .NET6 + C#10.0
  • Microsoft.Extensions.DependencyInjection 6.0.0

環境作成

パッケージマネージャーコンソールで以下コマンドを打ちます。

// メニュー
ツール > NuGet パッケージ マネージャー > パッケージ マネージャー コンソール

// コマンド
// https://www.nuget.org/packages/Microsoft.Extensions.DependencyInjection
> Install-Package Microsoft.Extensions.DependencyInjection -Version 6.0.0

そうすると以下のように「ServiceCollection」クラスが使用可能になるのでこれで DI 環境がもう作れています。

動作確認

以下サンプルの登場人物は以下の通りです。

名前 説明
ISample インジェクションするインターフェース
Sample インジェクションする実装クラス
Service 依存関係を注入されるサービスクラス

シングルトンとして登録したISampleをコンストラクタインジェクションするサンプルです。

using System;
using Microsoft.Extensions.DependencyInjection; // 追加で宣言する

namespace ConsoleApp1
{
    internal class AppMain
    {
        static readonly IServiceCollection _services = new ServiceCollection();
        static ServiceProvider _provider;

        private static void Main(string[] args)
        {
            // インジェクションするほう
            _services.AddSingleton<ISample, Sample>();
            // インジェクションされるほう
            _services.AddSingleton<Service>();
            
            // 解決してくれるやつ
            _provider = _services.BuildServiceProvider();

            // DI済みのインスタンスを取得(1)
            var service1 = _provider.GetRequiredService<Service>();
            service1.Update();
            service1.Dump();
            // > ID=4d418ae4-def4-44e8-8a3e-a12898d99c40

            // DI済みのインスタンスを取得(2)
            var service2 = _provider.GetService<Service>();
            service2.Dump();
            // > ID=4d418ae4-def4-44e8-8a3e-a12898d99c40
            service2.Update();
            service2.Dump();
            // > ID=3ae70caf-3bf8-4440-b9a2-d67ca7fafd01
        }
    }

    public interface ISample
    {
        string ID { get; set; }
    }

    public class Sample : ISample
    {
        public string ID { get; set; }
    }

    public class Service
    {
        ISample _sample;

        public Service(ISample sample) // コンストラクタインジェクションで解決される
        {
            _sample = sample;
        }

        public void Update()
        {
            _sample.ID = Guid.NewGuid().ToString(); // サンプルで一意のIDを発行する
        }

        public void Dump()
        {
            Console.WriteLine($"ID={_sample.ID}"); // オブジェクトの内容をコンソールに出す
        }
    }
}

コンソールだと「Scoped」がどういう単位か判然としないので、「AddTransient」か「AddSingleton」を使用することになると思います。

【C#】連続で処理が失敗した時に何度も同じような処理をしない

例えば定周期処理などで、エラーが出ても処理を継続するような場合に、最初の一回は例外処理を行うけど、処理が連続で失敗した場合2回目以降は、処理を行わないようにする実装パターンの紹介です。

この仕組みを使用して実装例ではログを何度も出力しないようにしています。

// 処理が最後まで進んだかどうかのフラグ
// true: 進んだ / false: それ以外
private static bool isDone = true;

public static void Foo()
{
    while (true)
    {
        try
        {
            Thread.Sleep(1000);

            // 何かの処理

            isDone = true;
        }
        catch (Exception ex)
        {
            if (isDone) // こうすることで何度も同じような出力にならないようにする
            {
                Trace.WriteLine(ex.ToString());
                isDone = false;
            }
        }
    }
}

isDone フラグは初期値および、処理が完了すると true となりますが、処理が完了するまでは変化しません。

  • 「何らかの処理」で例外が発生する
  • catch ブロックに飛ぶ
  • if (isDone ) に入ってログを出力する
  • isDone が false になる

で、次のループでまたエラーが発生した場合

  • 「何らかの処理」で例外が発生する
  • catch ブロックに飛ぶ
  • if (isDone ) に入らない → ログは出ない

となります。そして

  • 「何らかの処理」が成功する
  • isDone は true になる

となった場合、再度「何らかの処理」で例外が発生したら、上述の最初からやり直しになり、また最初に1回だけ処理が走って例外が出力されるようになります。

【C#】ViewModelの実装をスニペットで軽減する

WPF/UWP などの XAML 系実装で使用する ViewModel は OSS(Livet, Prism ReactiveProperty) などを使用しない場合、INotifyPropertyChanged 周りの実装が冗長で、繰り返しが面倒なので軽減策の紹介したいと思います。

アプローチ方法は以下の通りです。

  • 共通基底クラスを使用
  • プロパティはスニペット化

確認環境

  • VisualStudio2019
  • C# 9.0

Bindableクラス

まず ViewModel の共通基底クラスとして Bindableクラスを以下のように宣言します。

using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;

/// <summary>
/// ViewModel の共通基底クラス
/// </summary>
public abstract class Bindable : INotifyPropertyChanged, IDisposable
{
    // INotifyPropertyChanged impl
    public event PropertyChangedEventHandler PropertyChanged;

    /// <summary>
    /// 指定した名称で <see cref="INotifyPropertyChanged.PropertyChanged"/> を呼び出します。
    /// </summary>
    protected virtual void RaisePropertyChanged([CallerMemberName] string propertyName = "")
    {
        if (this.PropertyChanged == null)
        {
            return;
        }
        this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }

    #region IDisposable

    private bool disposedValue;

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!disposedValue)
        {
            if (disposing)
            {
                PropertyChanged = null;
            }
            disposedValue = true;
        }
    }

    #endregion
}

スニペットの作成

次に以下のコードを propvm.snippet としてファイルに保存し、以下のメニューからスニペットを VisualStudio にインポートします。

// インポート方法
> 画面上部の ツール > コード スニペット マネージャー
  > [言語] > CSharp を選択 > インポートボタンを押す

スニペットは以下の通りです。

<?xml version="1.0" encoding="utf-8" ?>
<CodeSnippets  xmlns="http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet">
  <CodeSnippet Format="1.0.0">
    <Header>
      <Title>Bindable Property</Title>
      <Shortcut>propvm</Shortcut>
      <Description>Property for INotifyPropertyChanged</Description>
      <Author>Takap</Author>
    </Header>
    <Snippet>
      <Declarations>
        <Literal>
          <ID>valueType</ID>
          <Default>int</Default>
          <ToolTip>公開する変数の型で置き換えます。</ToolTip>
        </Literal>
        <Literal>
          <ID>propName</ID>
          <Default>Sample</Default>
          <ToolTip>公開するプロパティの名前で置き換えます。</ToolTip>
        </Literal>
        <Literal>
          <ID>fieldName</ID>
          <Default>sample</Default>
          <ToolTip>内部で使用する変数の名前で置き換えます。</ToolTip>
        </Literal>
      </Declarations>
      <Code Language="csharp"><![CDATA[private $valueType$ _$fieldName$;
public $valueType$ $propName$
{
    get => _$fieldName$;
    set
    {
        if ($valueType$.Equals(_$fieldName$, value))
        {
            _$fieldName$ = value;
            this.RaisePropertyChanged();
        }
    }
}$end$]]></Code>
    </Snippet>
  </CodeSnippet>
</CodeSnippets>

使い方

Bindable が継承されているクラス上で使用する前提です。

インポート後にエディター上で propvm > Tab > Tab と入力すると以下のようにコードが展開されるので必要個所を入力します。

public class MyViewModel : Bindable // Bindable を継承していること
{ 

    // propvm > Tab > Tab で展開されるコード
    
    // 型、フィールド名、プロパティ名の順に入力する
    private int _sample;
    public int Sample
    {
        get => _sample;
        set
        {
            if (int.Equals(_sample, value))
            {
                _sample = value;
                this.RaisePropertyChanged(); // Bindable を継承してないとエラー
            }
        }
    }

以上

【C#】WMIでOSのメモリ使用量を取得する

WMI(Windows Management Infrastructure)という機能を使うとWindowsのシステムの各種情報を取得することができます。今回はこの機能を使用して

確認環境

  • Windows10
  • VisualStudio 2019
  • .NET Framework 4.7.2 / .NET 5

プロジェクトの設定

WMI の機能を使用するために System.Management.dll をプロジェクトの参照に追加する必要があります。

.NET Framework系

.NET Framework のプロジェクトの場合以下操作でアセンブリを追加します。

ソリューションエクスプローラー > 対象のプロジェクト > 参照 > 参照の追加

表示されるウインドウで

アセンブリ > System.Management

にチェックを入れ「OK」を選択します。

.NET 系

Windows 専用の機能のため標準のライブラリには入っていないため nuget から別途入手します。

.NET Core 及び .NET 5 以降だと System.Management は nuget から別途導入します。

VisualStudio の GUI から操作する場合、以下手順で導入できます。

ソリューションエクスプローラー > 対象のプロジェクト > 依存関係 > NuGet パッケージの管理

表示されるタブに「System.Management」と入力し「インストール」を選択します。

パッケージマネージャー経由の場合、以下の操作でコンソールを表示し

ツール > NuGet パッケージマネージャー > パッケージ マネージャー コンソール

コンソールに以下を入力してパッケージを導入します。

// .NET 5
Install-Package System.Management -Version 5.0.0

// .NET 6
Install-Package System.Management -Version 6.0.0

メモリ使用量を取得する

以下、メモリ使用量を WMI から1秒ごとに取得する場合の実装例となります。

using System;
using System.Management;
using System.Threading;


internal class Program
{
    private static void Main(string[] args)
    {
        using (var wt = new WmiTrance())
        {
            wt.Init();

            while (true)
            {
                WmiMemoryInfo info = wt.GetInfo();
                Console.Write($"{info.TotalMemory}, ");
                Console.Write($"{info.FreePhysicalMemory}, ");
                Console.Write($"{info.TotalVirtualMemorySize}, ");
                Console.WriteLine($"{info.FreeVirtualMemory}");
                Thread.Sleep(1000);
            }
        }
    }
}

/// <summary>
/// WMIからトレース用にPCの統計情報出力します。
/// </summary>
public class WmiTrance : IDisposable
{
    private ManagementClass mc;

    public void Init()
    {
        this.mc = new ManagementClass("Win32_OperatingSystem");
    }

    public WmiMemoryInfo GetInfo()
    {
        using (ManagementObjectCollection moc = mc.GetInstances())
        {
            foreach (ManagementObject mo in moc)
            {
                using (mo)
                {
                    // 物理メモリ合計
                    ulong a = (ulong)mo["TotalVisibleMemorySize"];
                    // 物理メモリFree
                    ulong b = (ulong)mo["FreePhysicalMemory"];
                    // 総メモリ合計
                    ulong c = (ulong)mo["TotalVirtualMemorySize"];
                    // 総メモリFree
                    ulong d = (ulong)mo["FreeVirtualMemory"];
                    
                    return new WmiMemoryInfo(a, b, c, d);
                }
            }
        }
        return default;
    }

    public void Dispose()
    {
        using (this.mc) { }
        this.mc = null;
    }
}

/// <summary>
/// WMIから取得したメモリ情報を記録します。
/// </summary>
public struct WmiMemoryInfo
{
    // 物理メモリ合計
    public readonly ulong TotalMemory;
    // 物理メモリ空き容量
    public readonly ulong FreePhysicalMemory;
    // 総メモリ合計
    public readonly ulong TotalVirtualMemorySize;
    // 総メモリ空き容量
    public readonly ulong FreeVirtualMemory;

    public WmiMemoryInfo(ulong totalMemory, ulong freePhysicalMemory, ulong totalVirtualMemorySize, ulong freeVirtualMemory)
    {
        this.TotalMemory = totalMemory;
        this.FreePhysicalMemory = freePhysicalMemory;
        this.TotalVirtualMemorySize = totalVirtualMemorySize;
        this.FreeVirtualMemory = freeVirtualMemory;
    }
}

「new ManagementClass("Win32_OperatingSystem");」でオブジェクトを作成し、「GetInstances」メソッドで情報を取得します。取れる情報はコレクションになっているので foreach などで回す必要があります(ただし今回は複数要素数入って来ることは無いので初回でループを終了します)

ManagementObjectCollection はプロパティ名の文字列でインデクサでアクセスすると対応した値が取得できます。

また、GetInstances メソッドは処理速度がかなり遅いので(実行すると ~50ms 程度かかってしまうので)高頻度の実行は避けましょう。非同期版のメソッドは用意されていないためメインスレッド上で高頻度で実行した場合GUIの応答性が低下する可能性があります。

【C#】Taskのキャンセル

C# 標準の Task のキャンセルの方法です。どちらも同じ方法でキャンセルできます。

標準で CancellationTokenSource から得られる CancellationToken を Task.Run の第2 引数に渡すことでキャンセルをハンドルできるようになります。

Taskのキャンセル

static CancellationTokenSource _cs = new();

private static void Main(string[] args)
{
    Foo(_cs.Token);

    while (true)
    {
        Console.Write(">");
        string input = Console.ReadLine();
        if (string.Compare(input.Trim(), "cancel", true) == 0)
        {
            _cs.Cancel();
        }
    }
}

private static async void Foo(CancellationToken ct)
{
    await Task.Run(() =>
    {
        try
        {
            for (int i = 0; i < 100; i++)
            {
                if (ct.IsCancellationRequested)
                {
                    // 検出されたかどうか?
                    Console.WriteLine("キャンセルを検出しました。");
                }

                // キャンセルされてたら OperationCanceledException を投げる
                ct.ThrowIfCancellationRequested();

                Trace.WriteLine($"{DateTime.Now:HH:mm:ss} [{i}]");

                Task.Delay(1000).Wait();
            }
        }
        catch (OperationCanceledException ex)
        {
            Console.WriteLine(ex);
        }
    }
    , ct);
}

上位側で Cancel() を呼び出しても自動で停止したり、例外が勝手に発生したりはしません。

CancellationToken の IsCancellationRequested を参照することで Task 内でキャンセルを検出することが可能です。検出したいタイミングでプロパティを都度確認します。

キャンセルされたのを検出したら例外を投げる場合 ThrowIfCancellationRequested メソッドを使用すると OperationCanceledException が発生します。

Unityの場合

Unity の場合は、Task.Run(... を UniTask.Run(... に変更する + 戻り値がある場合 Task から UniTask に変更することで対応できます。扱い方は完全に同じです。

以上