【C#】文字列から一部分を取り出す(Substringの使い方)

C# で文字列から一部分を取り出す処理は Substring メソッドを使用します。

この記事では、SubString の使い方と注意点、便利な使い方を紹介したいと思います。

確認環境

  • .NET 6
  • VisualStudio 2022
  • Windows 11

この記事は C# であればどのバージョンでも使用できます。

string.Substringメソッド

Substring には引数が異なる2種類のメソッドが存在します。

using System;

// 17文字の文字列
string str = "0123456789ABCDEFG";

// (1) 5文字目から最後まで取り出す
string parts1 = str.Substring(5);
Console.WriteLine(parts1);
// > 56789ABCDEFG

// (2) 3文字目から5文字分取り出す
string parts2 = str.Substring(3, 5);
Console.WriteLine(parts2);
// > 34567

指定する数字は配列のインデックスと同じく 0 から始まりのため指定した数字の後ろから切り取られます。

切り取り元の文字列は変化しません。

注意点

指定した数値が範囲外の場合、例外が発生します。そのため、Substring メソッドを呼び出す前に文字列の長さをチェックし、範囲を制限する必要があります。

// 17文字の文字列
string str = "0123456789ABCDEFG";

// ★(1)で18文字目を指定
string parts1 = str.Substring(18);
// 例外が発生する
// System.ArgumentOutOfRangeException:
// 'startIndex cannot be larger than length of string. Arg_ParamName_Name'

// ★(1)で17文字(最後の文字)目を指定
string parts1 = str.Substring(17);
Console.WriteLine(parts1);
// > (何も表示されない) ★17文字目から最後までは何も取得できない

// ----------

// ★(2)で18文字目を指定
string parts2 = str.Substring(18, 5);
// 例外が発生する
// System.ArgumentOutOfRangeException:
// 'startIndex cannot be larger than length of string. Arg_ParamName_Name'

// ★(2)で5文字目から範囲外の20文字切り出そうとする
string parts2 = str.Substring(5, 20);
// System.ArgumentOutOfRangeException:
// 'Index and length must refer to a location within the string. Arg_ParamName_Name'

このため事前に文字列の長さをチェックして切り取る範囲を制限する必要があります。

// 与える引数が範囲内かチェックする

int startIndex = 5;
int length = 10;

if (startIndex > str.Length)
{
    Console.WriteLine("startIndexが文字列より大きい");
}
else if (startIndex + length > str.Length)
{
    Console.WriteLine("lengthが文字列より大きい");
}
else
{
    string parts2 = str.Substring(startIndex, length);
}

例外を出したくない場合、このように事前に範囲内かチェックしてメソッドを呼び出すかどうか確認する必要があります。

安全に文字列を切り出す

ただ、文字列の長さは分からないけどどうしても切り出さないといけない時の方が多いので安全に取り出せるように処理を考えます。

public static class StringExtensions
{
    // 可能な限り文字を切り出す、無理な場合空文字を返す
    public static string SafeSubString(this string self, int startIndex, int length)
    {
        if (string.IsNullOrEmpty(self) || startIndex > self.Length)
        {
            return "";
        }

        int _len = length;
        if (startIndex + _len > self.Length)
        {
            return self.Substring(startIndex);
        }
        return self.Substring(startIndex, _len);
    }
}


// 範囲外でも例外を起こさず文字が取り出せる
string str = "0123456789ABCDEFG";
string parts = str.SafeSubString(12, 17);
Console.WriteLine(parts);
// > CDEFG

【C#】タスクトレイに常駐するアプリの実装Tips

Windows Forms で作成したソフトをタスクトレイに常駐させてメインウインドウを表示しないときの実装方法の紹介です。

条件は以下の通り。

  • Windows Forms
  • タスクバーにアイコンを表示しない
  • タスクトレイの常駐させる
  • 起動したときにウインドウ(フォーム)を表示しない

確認環境

以下環境で確認しています。

  • Windows10
  • VisualStudio2019
  • .NET Frameowrk 4.8.1

実装方法

事前にメインフォーム上にNotifyIconコントロールを配置して、Iconプロパティに何かアイコンを指定しておきます(Icon に何も指定しないとタスクトレイにアイコンが表示されません)

public Form_Main()
{
    this.InitializeComponent();
    this.WindowState = FormWindowState.Minimized;
    // コンストラクタ内もしくはGUIの設定でMinimizedを指定しておく
    // これを指定しないと起動した瞬間一瞬だけ表示されてしまう
}

private void Form_Main_Shown(object sender, EventArgs e)
{
    this.Visible = false; // ShownもしくはActivatedイベントでVisibleにfalseを指定
}

情報が錯綜しまくってますが、以下の注意点です。

Alt + Tab でフォームが表示できたり、不必要な指定があったりするので以下参照ください。

// ★(1)これだけだとAlt + Tabで表示されてしまう
WindowState = FormWindowState.Minimized;
ShowInTaskbar = false;

// ★(2)Visibleを指定した場合は指定不要
Visible = false
// ShowInTaskbar = false; // ★←こっちは指定不要

// ★(3)Opacityは指定不要
//Opacity = 0; いらない

あと CreateParams をオーバーライドしろとかたまにありますが、何情報かわからないですが Visibl eを false にするだけで十分なので必要ありません。

【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】パララックス(多重スクロール)を実装する

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

ObservableCollectionの変更イベントの挙動を確認する

ObservableCollection は List に似た機能を持ってるクラスで、要素を追加したり削除したときにイベントが発生する機能を持つクラスです。この発生するイベントは、WPF とか UWP みたいな XAML 環境は MVVM が標準でサポートされているので、各コントロールにある DataContext に設定するだけで、イベントを受け取っていい感じに動作してくれます。

実際には追加したり削除した時にオブジェクトからイベントが発生 → コントロールが受け取ってそのイベントをいい感じに処理してくれてます。なので今回は ObservableCollection を操作したときに発生するイベントの CollectionChanged の動作を確認したいと思います。

CollectionChanged はリストの中身が変更されたときに発生します。イベントの種類はそれぞれ以下の5種類を受け取ることができます。

  • 追加 → Add
  • 削除 → Remove
  • 置き換え → Replace
  • 位置の移動 → Move
  • 中身が大幅に変更された(クリアとかで) → Reset

CollectionChanged のシグネチャーは以下の通りです。

public delegate void 
    NotifyCollectionChangedEventHandler(object sender, NotifyCollectionChangedEventArgs e);
// NotifyCollectionChangedEventArgs.Actionで変更の種類を受け取れる

確認環境

  • .NET Core 3.1
  • VisualStudio2022
  • Windows11

コンソールアプリで確認

確認用コード

まずは ObservableCollection に格納するクラスを定義します。

// Item.cs
public class Item
{
    public string Value { get; set; }
    public Item(string value) => Value = value;
    public static implicit operator Item(string value) => new Item(value);
    public override string ToString() => Value;
}

次に検証用は以下の通りです。

private static void Check()
{
    var list = new ObservableCollection<Item>();
    list.CollectionChanged += Show;

    // (1) NotifyCollectionChangedAction.Addが発生する
    list.Add("003");
    list.Add("002");
    list.Add("001");
    list.Add("000");

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

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

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

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

// 
private static void Show(object sender, NotifyCollectionChangedEventArgs e)
{
    NotifyCollectionChangedAction action = e.Action;
    // Add
    // Remove
    // Replace
    // Move
    // Reset
    IList oldItems = e.OldItems;
    int oldStartingIndex = e.OldStartingIndex;
    IList newItems = e.NewItems;
    int newStartingIndex = e.NewStartingIndex;
    Console.WriteLine($"[{action}]");
    Console.WriteLine($"  /OldItems={ToString(oldItems)}");
    Console.WriteLine($"  /OldStartingIndex={oldStartingIndex}");
    Console.WriteLine($"  /NewItems={ToString(newItems)}");
    Console.WriteLine($"  /NewStartingIndex={newStartingIndex}");
}

// IListの中身を文字列に変換する
private static string ToString(IList list)
{
    if (list is null) return "";
    object[] array = new object[list.Count];
    list.CopyTo(array, 0);
    return string.Join(", ", array);
}

イベント説明

各操作の実行結果を見ていきます。各操作でイベントの引数の中身が結構違うことが確認できます。

なので、(自作する場合)以下の内容を踏まえてイベントの処理を記述することになります。

(1) Add

まずは追加した時です。Add メソッドで要素を追加した場合に発生します。

// NotifyCollectionChangedAction.Addが発生する
list.Add("003");
list.Add("002");
list.Add("001");
list.Add("000");
// [Add]
//   /OldItems=
//   /OldStartingIndex=-1
//   /NewItems=003
//   /NewStartingIndex=0
// [Add]
//   /OldItems=
//   /OldStartingIndex=-1
//   /NewItems=002
//   /NewStartingIndex=1
  • OldItems には値が入っていない
  • OldStartingIndex は無効値の -1 が設定される

(2) Remove

要素を削除したときの動作です。Remove メソッドで削除した場合に発生します。

// NotifyCollectionChangedAction.Removeが発生する
var item = list[0];
list.Remove(item);
// [Remove]
//   /OldItems=003
//   /OldStartingIndex=0
//   /NewItems=
//   /NewStartingIndex=-1
  • OldItems には削除された要素が設定される
  • OldStartingIndex は削除した位置が設定される
  • NewItems には値が入っていない
  • NewStartingIndex は無効値の -1 が設定される

(3) Replace

要素を置き換えた場合の動作です。[N] = xx という風にインデックスを指定して値を置き換えた場合に発生します。

// NotifyCollectionChangedAction.Replaceが発生する
list[2] = "004";
// [Replace]
//   /OldItems=000
//   /OldStartingIndex=2
//   /NewItems=004
//   /NewStartingIndex=2
  • OldItems には古い要素が設定される
  • OldStartingIndex は置き換えが発生した位置が設定される
  • NewItems には新しい要素が設定される
  • NewStartingIndex は OldStartingIndex と同じ値が設定される

(4) Move

要素を移動したときの動作です。Move メソッドで移動したときに発生します。

// NotifyCollectionChangedAction.Moveが発生する
list.Move(2, 0);
// [Move]
//   /OldItems=004
//   /OldStartingIndex=2
//   /NewItems=004
//   /NewStartingIndex=0
  • OldItems には移動元の要素が設定される
  • OldStartingIndex 移動前の位置が設定される
  • NewItems は OldItems と同じ値が設定される
  • NewStartingIndex 移動先の位置が設定される

(5) Reset

コレクションが大幅に変更されたときの動作です。全部クリアしたときも Reset になります。

自作のオブジェクトで複数項目を一括で追加・削除した場合などは自分で Reset を指定する必要があります。今回は、Clear メソッドで中身を全部削除したときに発生します。

// NotifyCollectionChangedAction.Resetが発生する
list.Clear();
// [Reset]
//   /OldItems=
//   /OldStartingIndex=-1
//   /NewItems=
//   /NewStartingIndex=-1
  • OldItems には値が入っていない
  • OldStartingIndex は無効値の -1 が設定される
  • NewItems には値が入っていない
  • NewStartingIndex は無効値の -1 が設定される

並び替えをする場合

一つ注することがあって、イベントを設定した状態で ObservableCollection の中身を Move メソッドを使用したりして並び替えを行うとイベントがものすごい発生するので処理速度が遅くなったり問題が発生します。

これを回避するには ObservableCollection の内容を直接並び替えるのではなく表示用の中間オブジェクト View を作成してこれを並び替えて画面に表示したりします。この時 ObservableCollection は並び替えません。

// 元を並び替えるのではなくViewを作成してそっちを使用する
var view = list.OrderBy(i => int.Parse(i.Value));
foreach (Item i in view)
{
    Console.WriteLine(i);
}

関連記事

takap-tech.com

PowerShellからC#を実行する

PowerShell 構文を使って .NET のライブラリ使いながらスクリプト書と多少面倒な時があり C# で書けないかと思ったのでそのやり方の紹介です。PowerShell の中で C# のコードを書いてスクリプトを実行する方法の紹介です。

また別のところで作成した .NET の DLL 内のクラス、メソッドを呼び出してみたいと思います。

確認環境

  • Windows11
  • VisualStudio2022
  • .NET Core 3.1

PowerShellにC#のコードを書く

先ず PowerShell から C# のコードを実行する方法です。

以下のように指定すれば普通に C# のコードが PowerShell 上に記述できます。

# sample1.ps1

# コードをHere-String(ヒアストリング)という形式で記述する
$source =  @"
using System;

public static class SampleClass
{
    public static string Foo(string message, int count)
    {
        string[] result = new string[count];
        for (int i = 0; i < count; i++)
        {
            result[i] = message;
        }
        return string.Join(", ", result);
    }
}
"@
Add-Type -TypeDefinition $source
[SampleClass]::Foo("OK", 3);
#> OK, OK, OK

古いバージョンでは使えないらしいですが、まぁ今現在気にすることは無いでしょう。

自作のライブラリの処理を呼び出す

もう少し踏み込んで自作の .NET の DLL の中身を呼び出す方法です。

簡単な呼び出しなら直接 PowerShell でインスタンスを扱ってもいいですが、大抵の場合複数のメソッドを呼び出したりするため DLL 参照を追加して一連の処理を呼び出してみます。

// Sample1.dll
namespace Takap.Sample1
{
    public class SampleClass1
    {
        public string GetString(string message)
        {
            return $"{message}, {message}";
        }
    }
}

// Sample2.dll
namespace Takap.Sample2
{
    public class SampleClass2
    {
        public string GetString(string message, int count)
        {
            return $"[{count}] {message}";
        }
    }
}

上記DLLが以下に配置されているとします。

  • D:\Sample1.dll
  • D:\Sample2.dll

次に PowerShell は以下のように記述します。

# sample2.ps1

$asm = @("D:\Sample1.dll", `
         "D:\Sample2.dll")

Add-Type -Path $asm

$source =  @"
using System;
using Takap.Sample1;
using Takap.Sample2;

public static class Func
{
    public static string Bar(string message, int count)
    {
        var s1 = new SampleClass1();
        string str = s1.GetString(message);

        var s2 = new SampleClass2();
        return s2.GetString(str, count);
    }
}
"@
Add-Type -TypeDefinition $source -ReferencedAssemblies $asm
[Func]::Bar("OK", 3);
# > [3] OK, OK

これくらいならわざわざ C# で書くことは無いですが、参考までに。

大切なのは Add-Type で DLL のパスを指定すれば参照可能になる点ですが、Path と ReferencedAssemblies を両方同じ値で指定が必要です。どちらかが無いとエラーが発生します。

Lib の処理ではライブラリのメソッドを使用して処理を組み立てるだけにしています。Here-Stringで書いたコードはステップでデバッグができないので小さく使うのがポイントかと思います。

まぁ、、C# 書ける環境なら Windows向けの exe 作ったほうがいいんじゃないという話はさておきですね。

【C#】リフレクションでオブジェクトの値を列挙する

C# のリフレクションという機能を使用して任意の型の内容、public なフィールドとプロパティをすべて列挙してみようと思います。

リフレクションを使用するので、特定の型のメンバーを認識したうえでフィールドやプロパティの名前を指定して列挙するのではなく、どのオブジェクトでも自由に列挙することが可能です。

確認環境

  • .NET core 3.1
  • VisualStudio2022
  • Windows11

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

実装コード

Sampleクラス

まず内容を表示するための以下クラスを作成します。

// Sample.cs

public class Sample
{
    public string Foo { get; set; }
    public string Bar { get; set; }
    public readonly string Mes = "asdf";
    public int A = 0;
} 

ObjectReaderクラス

次にオブジェクトの列挙する処理を実装するクラスです。

// ObjectReader.cs

using System;
using System.Collections.Generic;
using System.Reflection;

public class ObjectReader
{
    public IEnumerable<object[]> ReadObjects<T>(IEnumerable<T> items)
    {
        Type type = typeof(T);

        // ここで列挙する内容を指定する
        PropertyInfo[] pis = type.GetProperties(BindingFlags.Instance | BindingFlags.Public);
        FieldInfo[] fis = type.GetFields(BindingFlags.Instance | BindingFlags.Public);
        foreach (T item in items)
        {
            yield return ReadObject(item, type, pis, fis);
        }
    }

    public object[] ReadObject<T>(T obj)
    {
        Type type = typeof(T);

        // ここで列挙する内容を指定する
        PropertyInfo[] pis = type.GetProperties(BindingFlags.Instance | BindingFlags.Public);
        FieldInfo[] fis = type.GetFields(BindingFlags.Instance | BindingFlags.Public);
        return ReadObject(obj, type, pis, fis);
    }

    private object[] ReadObject(object obj, Type type, PropertyInfo[] pis, FieldInfo[] fis)
    {
        object[] retItems = new object[pis.Length + fis.Length];

        // プロパティの列挙
        for (int i = 0; i < pis.Length; i++)
        {
            PropertyInfo pi = pis[i];
            retItems[i] = pi.GetValue(obj);
        }

        // フィールドの列挙
        for (int i = 0; i < fis.Length; i++)
        {
            FieldInfo fi = fis[i];
            retItems[pis.Length + i] = fi.GetValue(obj);
        }

        return retItems;
    }
}

使用方法

使い方は以下の通りです。

private static void Test()
{
    ObjectReader r = new ObjectReader();

    // 単体のオブジェクトの内容の列挙
    var a = new Sample()
    {
        Bar = "bar",
        Foo = "foo",
    };
    object[] values = r.ReadObject(a);
    Console.WriteLine(string.Join(", ", values));

    // 複数オブジェクトの内容の列挙
    var items = new List<Sample>()
    {
        a,
        new Sample() { Bar = "bar2", Foo = "foo2" },
        new Sample() { Bar = "bar3", Foo = "foo3" },
    };
    var result = r.ReadObjects(items);
    foreach (var item in result) 
    {
        Console.WriteLine(string.Join(", ", item));
    }
}

privateメンバーも表示する

上記実装例は public のみ取得していましたが、private メンバーも取得するには BindingFlags の指定を変更します。

// publicメンバーだけ取得
PropertyInfo[] pis = type.GetProperties(BindingFlags.Instance | BindingFlags.Public);
FieldInfo[] fis = type.GetFields(BindingFlags.Instance | BindingFlags.Public);

// privateメンバーも取得する
PropertyInfo[] pis = 
    type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
FieldInfo[] fis = 
    type.GetFields(BindingFlags.Instance | BindingFlags.Public| BindingFlags.NonPublic);

特定のメンバーだけ取得しない

System.Runtime.Serialization.IgnoreDataMemberAttribute という属性がついているメンバーの値を取得すしないようにするためには以下のような実装にします。

ObjectReader の ReadObject を以下の通り特定の属性を持っているかどうかに変更します。

private object[] ReadObject(object obj, Type type, PropertyInfo[] pis, FieldInfo[] fis)
{
    var retList = new List<object>(); // 量が分からないのでListに変更

    // プロパティの列挙
    for (int i = 0; i < pis.Length; i++)
    {
        PropertyInfo pi = pis[i];
        if (pi.IsDefined(IgnoreType, false)) // 指定した属性がついてる?
        {
            continue;
        }
        retList.Add(pi.GetValue(obj));
    }

    // フィールドの列挙
    for (int i = 0; i < fis.Length; i++)
    {
        FieldInfo fi = fis[i];
        if (fi.IsDefined(IgnoreType, false)) // 指定した属性がついてる?
        {
            continue;
        }
        retList.Add(fi.GetValue(obj));
    }

    return retList.ToArray();
}

【C#】SQLiteでクエリー結果の列名を取得する

SQLを発行した結果の列名を取得する方法です。

DbDataRecordからSchemaInfoを取得して列名と型を取得しようと思います。

C#でSQLをダイレクトに実行する場合、コネクションを取得しコマンドを発行した後、SqliteDataReader で結果を読み取るのが一般的です。その後、列を foreach で DbDataRecord にして回したりしますが、結果の列名が何か気になるときが時があると思います。

この時、DbDataRecord 型を使用していますが、実際は System.Data.Common.DataRecordInternal 型が返ってきていて、この DataRecordInternal の内部にスキーマの情報が SchemaInfo型の配列として保持されています。ただ DataRecordInternal は internal 型のため通常外部からはアクセスできません。同じくスキーマ情報が記録されている SchemaInfo 型も internal なのでアクセスできません。

なのでリフレクションを使用してオブジェクトの内部データを強引に引き抜きたいと思います。

確認環境

  • VisualStudio2017
  • .NET 4.6.2
  • SQLite3

実装コード

まず、結果を入れるために SchemaInfo を以下のように定義します。

// SchemaInfo.cs
using System;

public class SchemaInfo
{
    public readonly Type Type;
    public readonly string Name = "";
    public readonly string TypeName = "";

    public SchemaInfo(Type type, string name, string typeName)
    {
        Type = type;
        Name = name;
        TypeName = typeName;
    }
}

次にデータを引き抜くための拡張メソッドを以下の通り定義します。

// DbDataRecordExtensions.cs

using System;
using System.Data.Common;
using System.Reflection;

public static class DbDataRecordExtensions
{
    public static SchemaInfo[] GetSchemaInfo(this DbDataRecord self)
    {
        // (1) System.Data.Common.SchemaInfo[] を取得する
        Type type = self.GetType();
        FieldInfo field = type.GetField("_schemaInfo",
            BindingFlags.InvokeMethod | BindingFlags.NonPublic | BindingFlags.Instance);

        // (2) 配列を順番にアクセスしてフィールドを自作のオブジェクトに詰める
        Array schemaInfoArray = field.GetValue(self) as Array;
        SchemaInfo[] retArray = new SchemaInfo[schemaInfoArray.Length];
        Type t = null;
        for (int i = 0; i < schemaInfoArray.Length; i++)
        {
            object item = schemaInfoArray.GetValue(i);
            if (t == null) t = item.GetType();

            FieldInfo fieldName = t.GetField("name");
            string name = fieldName.GetValue(item) as string;

            FieldInfo fieldTypeName = t.GetField("typeName");
            string typeName = fieldTypeName.GetValue(item) as string;

            FieldInfo fieldType = t.GetField("type");
            Type scType = fieldType.GetValue(item) as Type;

            retArray[i] = new SchemaInfo(scType, name, typeName);
        }

        return retArray;
    }
}

実際の使用方法ですが、任意のレコードを取得する以下の実装があったとします。

internal void QueryTest(string dbPath)
{
    // テーブル定義は以下の通り
    // a (int) | b (int) | c (string)
    string query = "SELECT * FROM _table";
    
    using (var connection = new SqliteConnection(GetConnectionString(dbPath)))
    {
        connection.Open();
        
        using (SqliteCommand command = connection.CreateCommand())
        {
            command.CommandText = query;
            command.ExecuteNonQuery();

            using (SqliteDataReader reader = command.ExecuteReader())
            {
                bool once = false;
                foreach (DbDataRecord item in reader)
                {
                    if (!once) // 何度も実行しない
                    {
                        once = true;
                        // ★ここでスキーマ情報を取得する
                        SchemaInfo[] infoArray = item.GetSchemaInfo();
                        // 以下のように情報が取得できる
                        // [0] INTEGER, a
                        // [1] INTEGER, b
                        // [2] TEXT,  c
                    }
                    
                    for (int i = 0; i < item.FieldCount; i++)
                    {
                        var temp = item[i];
                        Console.Write($"{temp}, ");
                    }
                }
            }
        }
    }
}

これで列名が取得できるようになりました。