よく UI でスライダーで値が変更されたときに発生する値の変更イベントに処理を実装すると、ユーザーがスライダーを変更した時に超高頻度でイベントが発生してそれに伴って処理を実行してしまうと表示にガタつきが発生したりします。なので UI の操作が落ち着いた一定時間後に最後の値で処理を実行したい時が割とあります。
このような制限(遅延)した処理の実行をデバウンス(debounce)と呼びます。
この処理、かなり定型的なのでライブラリ化して二度と同じような実装をしないようにしたいと思います。
確認環境
- .NET 8
- Visual Studio 2022
- Windows11
実装例
DebounceActionクラス
最後に Invoke した要求以降一定時間、Invoke が呼び出されなかった場合、Action デリゲートが実行されます。
/// <summary> /// 短時間内に関数が高頻度で発行されないようにデバウンスで処理を実行するためのクラス /// </summary> public class DebounceAction : IDisposable { readonly TimeSpan _interval; readonly ILogger _logger; readonly object _lockObj = new(); // UIスレッドで実行する必要がある場合 SynchronizationContext? _uiContext; // 遅延実行用のタイマー Timer? _timer; // 遅延実行対象の Action? _currentAction; // 破棄されたかどうかのフラグ // true: 破棄済み / false: まだ volatile bool _disposed; // 遅延実行するまでの時間を指定してオブジェクトを作成する public DebounceAction(TimeSpan interval, ILogger logger) { if (interval <= TimeSpan.Zero) { throw new ArgumentException($"{nameof(interval)} is 0", nameof(interval)); } ArgumentNullException.ThrowIfNull(logger); _interval = interval; _logger = logger; } public DebounceAction AttachUI() { _uiContext = SynchronizationContext.Current ?? throw new InvalidOperationException("Call it from the UI thread."); return this; } // 指定したデリゲートをを遅延実行する public void Invoke(Action action) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(action); lock (_lockObj) { _currentAction = action; if (_timer == null) { _timer = new(OnTimerCallback, null, _interval, Timeout.InfiniteTimeSpan); } else { _timer.Change(_interval, Timeout.InfiniteTimeSpan); } } } // 次に実行する処理を取り出す Action? PopAction() { lock (_lockObj) { Action? action = _currentAction; _currentAction = null; return action; } } void OnTimerCallback(object? state) { Action? action = PopAction(); if (action == null) { return; // 念のため避けておく } try { ObjectDisposedException.ThrowIf(_disposed, this); if (_uiContext == null) { action(); } else { // こっちはUIスレッド上で実行する _uiContext.Post(state => { try { action(); } catch (Exception ex) { _logger.LogError(ex, "Failed to action."); } }, null); } } catch (Exception ex) { _logger.LogError(ex, "Failed to debounce action."); } } // IDisposableの実装 public void Dispose() { lock (_lockObj) { _disposed = true; _timer?.Dispose(); _timer = null; } GC.SuppressFinalize(this); } }
補足ですが、Dispose と Timer ハンドラの実行はあまり厳密に呼び出し順を考慮していません。もしこのデバウンスオブジェクト自体を高頻度に生成・破棄される環境ではもう少し追加の実装が必要です。UI のライフサイクル < デバウンスオブジェクト程度の環境での使用を想定しています。
使い方
static void Main(string[] args) { // あらかじめ1秒遅延でオブジェクトを作成しておく // + 内部でタイマーを使ってるので破棄は確実にすること using DebounceAction debounce = new(TimeSpan.FromSeconds(0.2), NullLogger.Instance); Console.WriteLine("Start"); int i = 0; while (true) { try { int j = i; Console.ReadKey(); Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}, Entry={j}"); // デバウンスしたい処理をここに書く debounce.Invoke(() => { Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}, Execute={j}"); }); } finally { i++; } } }
もしUIで使用する場合 AttachUI メソッドを以下の通り呼び出します。
そうすると内部的に action デリゲートは UI スレッド上で実行されるようになるのでいちいちディスパッチャーを使用しないでも処理が行えるようになります。
// もしUIで使う場合その旨を初期化で指定する using DebounceAction debounce = new(TimeSpan.FromSeconds(1), NullLogger.Instance). AttachUI(); // ★UIで使用することをを明示する
こうすることによって1秒以内にキー入力があると直前の処理は破棄され最後の処理が上書き登録されていき、操作が 1秒以上なくなると最後の処理が実行されコンソールに Execute の出力がされます。
これをスライダーやテキストボックスのイベント処理に適用すれば高頻度の処理の実行が抑制できます。