まめ - たんたんめん

備忘録 C# / WPF 多め

C# List<T>のforeachループの最速を検証してみる

元ネタはこちらのブログです。 (C#) List<T>からSpan<T>を引き抜いて高速化 - ネコのために鐘は鳴る

知らなかったのですがListのforeachはパフォーマンスが悪いみたいですね。 今回はブログに書いてある手法のパフォーマンスを実測して検証してみようと思います。

今回のテストでは 0~5000の値をリストに入れて、それらの合計を求めるプログラムでパフォーマンスを検証してみます。

検証環境 今回もBenchmarkDotnet / Release , デバッガアタッチ無しです。

リストの定義

        private List<int> _list;
        [GlobalSetup]
        public void Initialize()
        {
            _list = new List<int>(Enumerable.Range(0,5000));
        }

まずは普通にforeachを使って回すパターン。一番ありがちなやつですね。

        [Benchmark]
        public int List()
        {
            int result = 0;
            foreach (var item in _list)
            {
                result += item;
            }

            return result;
        }

結果は11.506 usでした。


次にAsSpanです。 参照元のブログに書かれていますが、Unsafeを使ってTを強引に取り出すという方法です。
注意しないといけないのはIListではなんでも使えるのではなく、Listのようにデータの先頭にT
を保持するようなケースでしか利用できません。

以下、ListのT[]を取り出してSpanに変換します。

    public static class ListEx
    {
        private class list_internal<T>
        {
            internal T[] items;
        }
        public static Span<T> AsSpan<T>(this List<T> list)
        {
            return Unsafe.As<list_internal<T>>(list).items.AsSpan(0, list.Count);
        }
    }

そして同じようにループを回します。

        [Benchmark]
        public int Span()
        {
            int result = 0;
            foreach (var item in _list.AsSpan())
            {
                result += item;
            }

            return result;
        }

結果は...2.529 us !!なんと5倍程度早くなっていることが分かりました。
これはすごいですね。ただ、Unsafe.Asを使うのは今後とも100%安全とは保障できないので必要に応じて自己責任で使うという感じでしょうか。


おまけで、ToArray()での検証もしてみました。
Listのイテレートが遅いのであれば一度T[]に変換してあげるだけでも早くなるのでは?

            int result = 0;
            foreach (var item in _list.ToArray())
            {
                result += item;
            }

            return result;

計測するまでは変換コストが高いからそのまま回すほうが早いだろうと思っていたのですが...
結果はなんと...5.023 us素数が5000程度であればListのまま回すよりは2倍速くなりました!
これは予想外だったのでびっくりしました。ToArray()であれば、普通に使えそうですね。

今回の計測結果をまとめます。

f:id:at12k313:20201217133424p:plain

private なメンバーをC#の黒魔術で高速に取得する

こんばんは。 今日はC#のprivateメンバーを取得してみます。

private なメンバーと言えばReflectionですがいかんせん速度がネックです。

これを.NetCoreから使えるUnsafe.Asを使うとどこまで高速化できるか検証してみました。

今回用意するクラスはこんな感じです。

    public class TopSecret
    {
        private string _field = "体重は80kg";
        private int Property { get; set; } = 80;
    }

このクラスのフィールドをReflectionで取得する場合はこんな感じです。

       var propertyInfo = typeof(TopSecret).GetProperty("Property", BindingFlags.NonPublic|BindingFlags.Instance);
       var value = (int)propertyInfo.GetValue(_topSecret);  // "体重は80kg"

Unsafe.Asは指定した型を別の型にメモリ配置レベルで強制的に変換してしまう恐ろしい黒魔術です。

    public class TopSecretHack
    {
        public string hackedFileld;
        public int HackProperty { get; set; }
        
    }

なのでこのように同じ配置でメンバーをで定義してあげると...

var val = UnSafe.As<TopSecretHack>(_topSecret); // "体重は80kg"

privateだろうがなんだろうが値が取れてしまいます。

それではパフォーマンスを比較してみます。

計測にはBenchMarkDotnetを利用します。

今回はPropertyとFieldのReflectionまた、FieldInfoとPropertyInfoをキャッシュしたものとUnsafeを比較してみます。

テストコード

        [GlobalSetup]
        public void GlobalSetup()
        {
            _cachedFieldInfo = typeof(TopSecret).GetField("_field",BindingFlags.NonPublic | BindingFlags.Instance);
            _cachedPropertyInfo = typeof(TopSecret).GetProperty("Property",BindingFlags.NonPublic|BindingFlags.Instance);
        }
        
        [Benchmark]
        public void ReflectionField()
        {
            var fieldInfo = typeof(TopSecret).GetField("_field",BindingFlags.NonPublic|BindingFlags.Instance);
            var str = (string)fieldInfo.GetValue(_topSecret);
        }
        
        [Benchmark]
        public void ReflectionProperty()
        {
            var propertyInfo = typeof(TopSecret).GetProperty("Property", BindingFlags.NonPublic|BindingFlags.Instance);
            var value = (int)propertyInfo.GetValue(_topSecret);
        }

        [Benchmark]
        public (string,int) UnsafeAs()
        {
            var hack = Unsafe.As<TopSecretHack>(_topSecret);
            return (hack.hackedFileld, hack.HackProperty);
        }
        
        [Benchmark]
        public void CachedReflectionField()
        {
            var str = (string)_cachedFieldInfo.GetValue(_topSecret);
        }
        
        [Benchmark]
        public void CachedReflectionProperty()
        {
            var value = (int)_cachedPropertyInfo.GetValue(_topSecret);
        }

結果 環境はdotnet5 でReleaseビルドデバッガアタッチ無しの結果です。

f:id:at12k313:20201216211248p:plain

string フィールドの場合は40倍差! int プロパティboxingも発生しないのでは80倍の速度差!

キャッシュしていても20倍差と50倍差!

Unsafe圧倒的ですね。

ちなみにBoxingが無くなるようにプロパティもstringにするとこんな感じでした。 f:id:at12k313:20201216212052p:plain

C# async / awaitを利用する上での注意点

おはようございます。 今日は C# の非同期構文である async - await について細かい話は色んなブログとかにあったのですが自分用に完結に要点だけまとめます。 構文とか仕組みについてはこの記事では解説しません。

async void 禁止

  • 例外が補足できない
  • 呼び出し元が非同期であることが分かりづらい
  • どうしても fire and for get したい場合呼び出し側で引数を破棄するなり対応した方がよいです。
_ = HogeAsync(); // これで戻り値を破棄できる
  • eventの場合は使わざるを得ないケースもあるが、それ以外は無い。

コンストラクタでの非同期は避ける

  • ↑に通じますが、コンストラクタではawaitできません。そう書きたくなったら設計を見直したほうが堅実

Wait() Resultの使用禁止

  • 主にGUIツール等、スレッドを復帰するタスクが含まれている場合呼び出し元のスレッドの終了を待つことになりデッドロックする

可能な限りConfigureAwait(false)を

  • スレッドを復帰する必要がない場合はfalseにした方がパフォーマンスが向上する
  • 正し処理内容による。特に待たない場合は変数

そのTask、ValueTaskで良いかも

  • 非同期にならない戻り値パスがある場合は ValueTaskを利用することでアロケーションが抑えられる
  • 標準ではTask.WhenAllでValueTaskは待てないのでそこは注意が必要

タスクを直接returnする場合 async / await は書かない

  • メソッドをasyncにするだけでわずかにコストがかかる

参考 (https://gist.github.com/pierre3/10731634)

// これなら
public async Task HogeAsync()
{
    await FugaAsync();
}
// こう書く
public Task HogeAsync()
{
    return FugaAsync()
}

並列処理できる場合は場合は Task.WhenAllで

// これなら
using (var client = new HttpClient())
{
    var result1 = await client.GetAsync(@"http://Hoge.example.com");
    var result2 = await client.GetAsync(@"http://Fuga.example.com");
    ...
}
// こう書く
using (var client = new HttpClient())
{
    var result1 = client.GetAsync(@"http://Hoge.example.com");
    var result2 = client.GetAsync(@"http://Fuga.example.com");
   await Task.WhenAll(result1,result2);
   ...
}

なるべくTask.Runでラップしただけの偽asyncメソッドは避ける

参考 C# asyncでやってはいけないこと · GitHub - ポリシー的な話だが、スレッド制御はなるべくアプリケーションに近いレイヤーで行うことを意識すべし

fire and for get する場合はContinueWithで例外捕捉を

  • 未処理例外になり、アプリケーションで補足できない
_ DoAsync()
    .ContinueWith(t =>
          {
              if (t.Exception != null)
              {
                  // 例外処理をここでする (ロギング、UIスレッドにディスパッチして通知等)
              }
          });

MVVMにおけるデータ仮想化の実装

こんにちは。 今日は仮想化についての話です。

WPF界隈で仮想化と検索するとVirtulizingStackPanelのようなものViewに関する記事などが多く出てくるとと思いますが 今さら使い方の説明を書いても面白みがないので、あまり記事になっていないデータの仮想化について実装記事を書いてみようと思ったのが経緯です。

WPFを使ったサンプルプログラムを作ったのでそれをもとに具体的なコードを交えながらポイントを解説したいと思います。

概要

データの仮想化に触れる前に少しだけUIの仮想化について触れておきます。 f:id:at12k313:20201215011046p:plain UIの仮想化とは表示領域のUIオブジェクトのみをインスタンスを保持することでパフォーマンスを向上される手法です。。 WPFではVirtulizingStackPanelという機能があり、ItemsControlで利用できます。 データ数のオーダーが数百や数千程度であればUIの仮想化だけで十分事足りるケースが多いです。


データの仮想化イメージ f:id:at12k313:20201215010154p:plain UIの仮想化をしていてもデータの数が数万・数十万を超えてくると表示以外の面でも様々な面でパフォーマンスが落ちます。 多くのGUIフレームワークはシングルスレッドであり、UIによる仮想化だけでは速度が足りないことがあります。

例えばWPFにはBindingを利用してデータとUIの紐づけを行いますが、その際にUIスレッドに依存してしまう特徴があります。

  • DependencyObjectを継承したクラス、及びそれを利用するクラス
  • DependencyPropertyにデータの通知を行うクラス(いわゆるViewModel でデータバインドをしている場合等)

これらのクラスはUIスレッド上でしか操作することができません。つまり、これらのクラスが大量に存在するとワーカースレッドへ処理を逃がすのが難しくなるのでUIスレッドを圧迫し、結果としてアプリケーションの操作性を落とすことにつながります。

もちろんViewModelの実装に気を付けてワーカースレッドで処理を行い、PropertyChangedのイベントだけをUIスレッドで行うような手法もとれますが、 実装として複雑になったり、スレッドの切り替えコストが余計なオーバーヘッドを生みます。

そこで登場するのがデータの仮想化です。 データの仮想化といっても様々な概念や観点があるので一概には言えませんがこの記事では扱うデータの仮想化とは UIスレッドで扱わないといけないクラスやオブジェクトの絶対数をなるべく減らし PropertyChangedやCollectionChangedのイベントを最小限で済ませ、必要に応じて遅延読み込みなどをするような仕組みとうことにしておきます。

具体的な実装があったほうが良いだろうと思い今回はWPF+MVVMを使ったシンプルなデモアプリケーションを作ってみました。

デモアプリ

f:id:at12k313:20201215012911g:plain

githubgithub.com

どういうアプリ?

このアプリはディレクトリををスキャンし、すべてのファイルをフラットにファイル名、ファイルサイズ、拡張子、フルパスを表示します。 また、ファイルパスや拡張子でファイルの検索ができるようになっています。

データを仮想化しない場合、ファイル数が少ないようなディレクトリ等であれば特に問題ありませんが、例えばCドライブを直接指定したりするとどうでしょうか。

数100万以上のファイルが存在するので、すべてをフラットにし、1つのリストビューで扱うのは無謀な話です。

実装にあたっての押さえておきたいポイントをまとめます。

  • Cドライブを検索してもUIが固まらない
  • 起動時間が早い
  • 検索が早い

実装

f:id:at12k313:20201215031647p:plain f:id:at12k313:20201215031744p:plain 本アプリケーションではこのようなデータ設計にしています。 (全部読みたい方はこちらをお読みください)

以下ソースコードです。ポイントとなる VIrtualSource.csについて解説します。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Concurrency;
using System.Reactive.Subjects;
using System.Threading;
using System.Threading.Tasks;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;

namespace SandBox
{
    /// <summary>
    /// 仮想化コレクション
    /// </summary>
    /// <typeparam name="T"></typeparam>
    public class VirtualCollectionSource<T,U> : IVirtualCollectionProvider , IDisposable
    {
        private readonly IEnumerable<T> _dataSource;
        private readonly List<T> _proxy;
        private readonly int _initialSize;
        private readonly Subject<CollectionChanged<T>> _collectionChangedTrigger;
        private Func<T, bool> _filter;
        private CancellationTokenSource _prevCancelToken;
        private IList<IDisposable> Disposables { get; }= new List<IDisposable>();
        public ReadOnlyReactiveCollection<U> Items { get; }
        public int ProxySize => _proxy.Count;
        public int SourceSize => _dataSource.Count();
        public event EventHandler CollectionReset;

        public VirtualCollectionSource(IEnumerable<T> dataSource, Func<T,U> converter ,int initialSize , IScheduler scheduler)
        {
            _dataSource = dataSource;
            _proxy = new List<T>(_dataSource);
            _initialSize = initialSize;

            var source = _proxy.Take(initialSize).ToArray();
            _collectionChangedTrigger = new Subject<CollectionChanged<T>>().AddTo(Disposables);
            Items = source.ToReadOnlyReactiveCollection(_collectionChangedTrigger,converter,scheduler, false ).AddTo(Disposables);
        }

        /// <summary>
        /// 仮想テーブルに対するフィルター関数を登録します。
        /// </summary>
        /// <param name="filter"></param>
        public void SetFilter(Func<T, bool> filter)
        {
            _filter = filter;
        }

        /// <summary>
        /// データソース→プロキシーへデータを同期させます。
        /// データソースの列挙とフィルターは別スレッドで行われます。
        /// </summary>
        /// <returns></returns>
        private async Task UpdateProxyAsync(CancellationToken cancellationToken)
        {
            var array = Array.Empty<T>();
            await Task.Run(() =>
            {
                array = _dataSource.ToArray();
                array = array.Where(x =>
                {
                    cancellationToken.ThrowIfCancellationRequested();
                    return _filter?.Invoke(x) ?? true;
                }).ToArray();
            },cancellationToken);
            
            _proxy.Clear();
            _proxy.AddRange(array);
        }
        
        /// <summary>
        /// ステージされたデータをすべて破棄し、Itemsを再構築します。
        /// 初期要素数はinitialSizeになります。
        /// </summary>
        /// <returns></returns>
        public async Task ResetCollection()
        {
            if (_prevCancelToken != null)
            {
                _prevCancelToken.Cancel();
                _prevCancelToken.Dispose();
            }
            try
            {
                _prevCancelToken = new CancellationTokenSource();
                await UpdateProxyAsync(_prevCancelToken.Token);
            }
            catch(TaskCanceledException)
            {
                return;
            }

            _collectionChangedTrigger.OnNext(CollectionChanged<T>.Reset);
            int i = 0;
            foreach (var item in _proxy.Take(_initialSize).ToArray())
                _collectionChangedTrigger.OnNext(CollectionChanged<T>.Add(i++,item));
            CollectionReset?.Invoke(this,EventArgs.Empty);
            _prevCancelToken.Dispose();
            _prevCancelToken = null;
        }


        /// <summary>
        /// Proxyからn分のデータをItemsにステージします。
        /// </summary>
        /// <param name="n"></param>
        /// <returns>追加があればtrue</returns>
        public bool Stage(int n)
        {
            var currentIndex = Items.Count;
            var fixedProxy = _proxy.ToArray();
            var result = false;
            for (int i = 0; i < n; ++i)
            {
                var index = currentIndex + i;
                if(fixedProxy.Length <= index )
                    break;
                result = true;
                _collectionChangedTrigger.OnNext(
                    CollectionChanged<T>.Add(index,fixedProxy[index]));
            }

            return result;
        }
        public void Dispose()
        {
            foreach (var disposable in Disposables)
            {
                disposable.Dispose();
            }
        }
    }
    
    /// <summary>
    /// interface です。
    /// 今回はScrollViewerから利用するものを抽象化しています。
    /// </summary>
    public interface IVirtualCollectionProvider
    {
        bool Stage(int step);

        event EventHandler CollectionReset;
    }
}

利用例(App.xaml.csより)

var virtualSource = new VirtualCollectionSource<FileModel,FileViewModel>(
                dataSource.Items,                           //  データソースを指定します。図でいうDataSourceクラスです。
                x => new FileViewModel(x),      // Model -> VIewModelへのConverterです。 
                defaultCapacityCount,                    // 初期の要素数です。サンプルでは50にしています。
                ImmediateScheduler.Instance);      // コンバーターの実行スケジューラです。コンバーターはUIスレッドから呼ばれることを想定しているのでImmediateSchedulerを指定します。

VirtualCollectionSource<T,U>は以下のような機能を持ちます。

  1. 入力されてきた IEnumerabe から フィルター関数を使って データモデルであるProxy<T>を生成します。
  2. コンストラクタで指定された要素数だけProxyからItems<U>にコンバートします。
  3. Step()がコールされると追加でProxyからItems<U>にコンバートします。
  4. ResetCollection()がコールされるとDataSource→Proxyへデータが同期されフィルターが再適用されます。

実装のポイント部分解説します。

まず、1の IEnumerable<T> ー> Proxy<T>への変換について考えます。 入力ファイルは膨大なリストになるのでここをワーカースレッドで行います。

VIewModelで利用するのは2.で生成されるItemsですが、この生成はUIスレッドから通知を行う必要が出てきます。 なのでここで数を抑えているのが一つポイントです。サンプルアプリでは50を指定しているので、初回は50個分のオブジェクトの通知イベントしか発行されません。

ただ、このままだと画面には50個のデータしか表示されないされないので画面スクロールで追加読み込みができるようにします。 そこで登場したのがStep()関数です。スクロールに合わせてデータを随時ロードするようにしています。

サンプルではスクロール末尾に到達したときにデータを追加読み込みするような形にしています。

(LazyLoadScrollBehavior.csより)

// スクロール割合を計算
var scrollRatio = (scrollViewer.VerticalOffset + scrollViewer.ViewportHeight) / scrollViewer.ExtentHeight;
if (scrollRatio >= 0.995) // 計算誤差がでることがあるので調整
{
       // 末端あたりまでスクロールした段階で仮想テーブルにデータをロードする
       Provider?.Stage(10); // ProviderはVirtualCollectionSourceのinterfaceです。
}

無限スクロールがアプリによっては合わないケースもあると思うので、その場合は オリジナルのデータソースの数やプロキシーの要素数からスクロールバー等を見かけ上全て読み込まれているようなコントロールを作れば問題ありません。

最後に検索機能ですが実は仮想コレクションの再初期化をしているだけです。初期化してもデータは50個しかロードされないのでそこまで気にならないようになっています。実は1. のEnumerable<T> ー> Proxy<T>の部分で検索ワードなどからデータをフィルタしているのでRessetCollection()が呼ばれた段階で自動的にフィルターがかかるような作りになっています。なのでUIスレッドではフィルターされた結果を新たな要素とみなしているだけです。

(MainWindowVm.csより)

// 仮想Cコンテナにフィルター関数を設定します。
virtualSource.SetFilter( x=> x.Filter(LowerFilterName.Value,LowerFilterExtension.Value) );
            
// 検索ボックスの入力からトリガしてフィルターを実行します。
FilterName
            .Merge(FilterExtension)
            .Throttle(TimeSpan.FromMilliseconds(200), UIDispatcherScheduler.Default)
            .Subscribe(async _ => await VirtualSource.ResetCollection())
            .AddTo(Disposables);

デモで利用したライブラリ

仮想化の実装自体に特別必要なライブラリはありません。 が、今回はRxを使うとすっきり書けるのでReactivePropertyとBehaviorを手軽に記述できるMicrosoft.Xaml.Behaviorを利用しています。

WPF - FontAwesomeの利用方法比較と自前で作ったコントロールの紹介

はじめに

FontAweomeとは素晴らしいアイコンをフォントの形で公開してくれるオープンソースプロジェクトです。 https://fontawesome.com/ Web系の開発などではよく使われていて、もちろんWPFでも利用できます。

フォントファイルをそのまま使う

では、これをWPFで利用するにはどのようにすればよいでしょうか? まずは一番ベーシックなFontとして埋め込む方法を先に紹介します。 1. https://github.com/FortAwesome/Font-Awesome/tree/master/otfs ここからフォントファイルをダウンロードします。 2. Font Awesome 5 Free-Regular-400.otf をコピーしてプロジェクトに配置します。 f:id:at12k313:20201213205119p:plain 3. ResourceにFontFamilyを追加します。前半はパス、#の後の部分はフォント名です。Windowsならダブルクリックでファイルを確認できます。f:id:at12k313:20201213211510p:plain

    <Application.Resources>
        <FontFamily x:Key="FontAwesome">
            /Font Awesome 5 Free-Regular-400.otf#Font Awesome 5 Free Regular
        </FontFamily>
    </Application.Resources>
  1. TextBlockにFontFamilyを指定して、Unicode文字コードを入れます。文字コードがどのアイコンに対応するかはこれは公式サイトなどから調べれます。 f:id:at12k313:20201213211641p:plain
<TextBlock 
    Text="&#xf004;" 
    FontFamily="{StaticResource FontAwesome}"
    Foreground="Red"        
    FontSize="16"/>

問題

簡単に組み込めますがいくつかの問題があります。 - 文字コードになっているのでxamlを見ただけではアイコンの種類が分からない - 単なるTextであるため、大きさの指定(FontSize)が直感的ではない

Font Awesome WPF を使う

次に紹介する方法はWPFに組み込みやすくしてくれたオープンソースを利用することです。 色んなブログなどで紹介されていると思うので改めて紹介するまでもないかもしれないですが これを使えば、文字コードを指定せずにアイコンを指定することができて使いやすいです。

  1. nuget から FontAwesome.WPFをインストール
  2. スキーマxmlns:fa="http://schemas.fontawesome.io/icons/"を追加
  3. コードを記述します。
            <fa:FontAwesome FontSize="16" 
                            Icon="Heart"                            
                            Foreground="Red"/>

問題

バージョンが古い(FontAwesome4系)のでSolidアイコンしか使えない 比べてみてわかったのですが、FontAwesome.WPFを使うと輪郭にエイリアスが出るのとちょっと上にズレていますね。 f:id:at12k313:20201213215406p:plain

YiSA.FontAwesome5.WPFを使う

自分が作ったやつです。ハイ。nuget に公開しているので↑2つと比較してみます。

  1. nuget から YiSA.FontAwesome5.WPFをインストール
  2. スキーマxmlns:yisa="clr-namespace:YiSA.FontAwesome5;assembly=YiSA.FontAwesome5.WPF"を追加
  3. コードを記述します。
            <yisa:GeometryIcon Width="16" Height="16" 
                               Icon="Icon_Heart_solid"
                               Foreground="Red"/>

並べてみました。 f:id:at12k313:20201213220010p:plain こう見るとFontAwesome.WPFが少しずれてるのとジャキーが顕著ですね。

自作版FontAwesomeコントロールの紹介

svg から c# コードの converter を作った副産物でほとんど趣味ですが、あえて理由を挙げるとこんな感じです。 - FontAwesome以外のアイコンを等価的に扱いたい - 必要な使うアイコンだけをオプトインしたり - xaml での拡張性を高めたい

あとはポートフォリオとかに使いたいですね。

ソースコードはこちら。 github.com

SVG から C#への Converterの作成

どういうものかというと、ディレクトリを指定し含まれているsvgを列挙して静的な定義としてC#コードを作成します。 ツールはこれで、 FontAwesomeをコンバートするとこんな感じのcsファイルが作成されます。 https://raw.githubusercontent.com/p4j4dyxcry/YiSA.FontAwesome5.WPF/main/sources/YiSA.FontAwesome5.WPF/fa5.autogen.cs このファイルをプロジェクトに追加して_IconFactory[Icon_hoge].Valueみたいな形でアクセスするとWPFのGeometryデータとして取得することができます。 svgファイルをリアルタイムで読み込むこともできるのですが事前コンバート&参照時に初期化するようにすればアプリケーションの起動時間にかかるコストが抑えることができるのと、 アイコン事にenumの列挙値を埋め込んでいるので直感的にコーディングができます。

描画コントロール

そのままだと使い勝手が悪いのでenumを指定してアイコンを描画できるコントロールを用意しました。 以下デモアプリです。ちょっとしたこだわりポイントとして、アイコンとして使いやすさを上げるために回転と無効色をDepencyPropertyで公開しています。https://github.com/p4j4dyxcry/YiSA.FontAwesome5.WPF/blob/main/resources/screenshots/2.gif?raw=true

最後に

今日は主にライブラリの紹介でしたが、詳しい実装とかは別途記事にしたいと思います。

WPF StoryBoardを使ったアニメーションの拡張

お久しぶりです。久々にやる気になったので技術記事を書きます。 今日はWPFのアニメーションをカスタマイズしてちょっとリッチな表現ができる機能を紹介しようとおもいます。 入門記事やシンプルな組み込み型の記事についてはいろんなブログで紹介されていたりしますが踏み込んだ内容が少なかったので思い切って書いてみる事にしました。 今日扱うのはEasing関数というものです。

本記事で利用するライブラリについて

StoryBoardについて

    <Window.Resources>
        <Storyboard x:Key="Rotate">
            <DoubleAnimation Storyboard.TargetName="Fa5"
                             Storyboard.TargetProperty="Angle"
                             From="0"
                             To="360"
                             RepeatBehavior="Forever"
                             Duration="0:0:1"/>
        </Storyboard>    
    </Window.Resources>
    
    <fontAwesome5:GeometryIcon x:Name="Fa5"
               Foreground="LightBlue" 
               Background="Transparent" 
               VerticalAlignment="Center"
               Icon="Icon_Twitter_brands">

        <!--今回は Loadedにトリガーして再生する-->
        <b:Interaction.Triggers>
            <b:EventTrigger EventName="Loaded">
                <b:ControlStoryboardAction Storyboard="{StaticResource Rotate}"/>
            </b:EventTrigger>
        </b:Interaction.Triggers>

WPF ではアニメーションの仕組みとしてStoryBoardというものがありまして、ここにアニメーションを追加していくような感じです。 使い方に癖があるのですがEventにTriggerしたりして使うケースが多いと思います。 上の例で何をやっているのかというとアイコンを永遠にグルグル回しています。 ぱっとみこれで良さそうなのですが、FromとToを指定して自動的に線形補間されてしまうのでちょっと機械的な回転になってしまい、 角度を指定して20度づつ動かして~みたいなことはちょっとひと手間要ります。 なので今回はこれをどう実現するか考えてみます。

実装例 1

<Storyboard
    BeginTime="00:00:00"
    RepeatBehavior="Forever"
    Storyboard.TargetName="WaitCanvas" 
    Storyboard.TargetProperty="(Canvas.RenderTransform).(TransformGroup.Children)[0].(RotateTransform.Angle)">
    <DoubleAnimationUsingKeyFrames Duration="0:0:2">
        <DoubleKeyFrameCollection>
            <DiscreteDoubleKeyFrame KeyTime="0:0:0.000" Value="0" />
            <DiscreteDoubleKeyFrame KeyTime="0:0:0.125" Value="22.5" />
            <DiscreteDoubleKeyFrame KeyTime="0:0:0.250" Value="45" />
            <DiscreteDoubleKeyFrame KeyTime="0:0:0.375" Value="67.5" />
            <DiscreteDoubleKeyFrame KeyTime="0:0:0.500" Value="90" />
            <DiscreteDoubleKeyFrame KeyTime="0:0:0.625" Value="110.5" />
            <!-- ... -->
        </DoubleKeyFrameCollection>
    </DoubleAnimationUsingKeyFrames>
</Storyboard>

https://stackoverflow.com/questions/1544411/wpf-doubleanimation-animate-in-stepsより引用

何をやっているのかというと回転の値をTo,Fromで指定するのでは無く、何秒になったら値はどうするといったようにキーフレームでアニメーションさせています。 これでもよいのですがxamlの記述量も多いし、管理もしにくいので微妙ではありますが、単体利用とかならこれで十分でしょう。

実装例 2

次にEasingを使って拡張する方法を紹介します。まずはxamlから。

<b:ControlStoryboardAction.Storyboard>
    <Storyboard>
        <DoubleAnimation Storyboard.TargetName="TargetRight"
                                        Storyboard.TargetProperty="Angle"
                                        From="0"
                                        To="360"
                                        RepeatBehavior="Forever"
                                        Duration="0:0:1"
                                        EasingFunction="{markupEx:SnapEasing 8}"/>
    </Storyboard>
</b:ControlStoryboardAction.Storyboard>

ぱっとみ最初のxamlとほとんど違いが無いように見えますがEasingFunction="{markupEx:SnapEasing 8}"が指定されています。 これが何かを簡単に言うと線形補間に利用される正規化されたtの値を変形させることができます。概念コードですがこんな感じのイメージです。

t = easing(T);
val = (1 - t) * x + t * y;

Easingに値を設定するにはIEasingFunctionのinterfaceを実装する必要があります。(といっても関数一つだけ)

今回はこのようなクラスを用意しました。

public class SnapEasing : IEasingFunction
    {
        private readonly int _step;
        private readonly double[] _table;

        public SnapEasing(int step)
        {
            if(step <= 0)
                throw new ArgumentException(nameof(step));

            _step = step;
            _table = new double[step + 1];
            _table[0] = 0d;
            _table[step] = 1d;

            // Constructorで初期化時にstep毎テーブルを作成します。
            var unit = 1.0d / step;
            for(var i = 1; i < step; ++i)
                _table[i] = i * unit;
        }
        
        public double Ease(double t)
        {
            // テーブルの中から一番近い値を選択します。
            // note :この実装では_tableが増えたときにコストが高くなる
            return _table.OrderBy(x => Math.Abs(t - x)).First();
        }
    }

このクラスではstepをコンストラクタで受取り線形補間な t を変換をテーブルへの参照に変更しています。 例えば、step を 8にすると アニメーションに利用する8コマをコンストラクタで決定し、計算時に一番値の近いコマを持ってくるような形です。

次にxamlから参照をするためにマークアップ拡張を書きます。 マークアップ拡張は無くてもよいですが、個人的にxamlを薄くしたかったので用意しました。

    public class SnapEasingExtension : MarkupExtension
    {
        private readonly int _step;
        public SnapEasingExtension(int step)
        {
            _step = step;
        }
        
        public override object ProvideValue(IServiceProvider serviceProvider)
        {
            return new SnapEasing(_step);
        }
    }

この2つを用意することでxamlから簡単に参照ができるようになったのでEasingFunctionにマークアップ拡張で書きます。

    <Storyboard>
        <DoubleAnimation Storyboard.TargetName="TargetRight"
                                        Storyboard.TargetProperty="Angle"
                                        From="0"
                                        To="360"
                                        RepeatBehavior="Forever"
                                        Duration="0:0:1"
                                        EasingFunction="{markupEx:SnapEasing 8}"/>
    </Storyboard>

これで何コマに落とすかxamlから指定して落として再生できるようになりました。

おまけ

筆者は本来ののEasingともコンポジットで組み合わせれるようにもできます。

    public class SnapEasing : IEasingFunction
    {
        private readonly int _step;
        private readonly double[] _table;
        private readonly IEasingFunction? _easingFunction;

        public SnapEasing(int step , IEasingFunction? easingFunction)
        {
            if(step <= 0)
                throw new ArgumentException(nameof(step));

            _step = step;
            _easingFunction = easingFunction;
            _table = new double[step + 1];
            _table[0] = 0d;
            _table[step] = 1d;

            var unit = 1.0d / step;
            for(var i = 1; i < step; ++i)
                _table[i] = i * unit;
        }
        
        public double Ease(double t)
        {
            // easingが設定されればeasingする
            if(_easingFunction != null)
                t = _easingFunction.Ease(t);
            return _table.OrderBy(x => Math.Abs(t - x)).First();
        }
    }

結果

動画なのでそもそもコマ落ちしちゃってて違いがわかりづらいですが一応、 最初はgifで撮ったのですがめちゃくちゃわかりづらかったのでmp4で撮ってtwitterに載せています。

追記

アイキャッチhttps://github.com/p4j4dyxcry/YiSA.FontAwesome5.WPF/blob/main/resources/screenshots/2.gif?raw=true

JetBrains Rider よく使うショートカットキーまとめ

ショートカットキー

  • Ctrl + R + R シンボルリネーム
  • Crtl + T シンボル検索・移動
  • Alt + Arrow タブ移動
  • Ctrl + Shift + F 全文検索
  • 範囲選択 + Shift + { スコープ付与
  • Ctrl + K コメント化
  • Ctrl + U コメント化解除
  • Shift + F12 全参照検索
  • Ctrl + U + U 最後に実行したユニットテストの実行
  • Ctrl + U + D 現在選択中のテストをデバッグ実行
  • Ctrl + U + R 現在選択中のテストを実行