OITA: Oika's Information Technological Activities

@oika 情報技術的活動日誌。

ボタン連打時に多重にリクエストを溜め込まない

ボタンクリックイベント等のハンドラの中で
時間のかかる処理をしてUIを固めてしまうと、
その間に連続してクリックした分がメッセージキューに溜め込まれ
UIが動き出してから何度も連続でクリックイベントが発生するような動きになる。

単純に↓こんなふうに書いて連打してみれば意味がわかると思う。

        private void button1_Click(object sender, RoutedEventArgs e) {
            
            Thread.Sleep(1000); //時間のかかる処理の代わり

            Console.WriteLine("{0} 完了", DateTime.Now);
        }

つまらない問題に見えて、意外に対処が面倒だったりしたのでメモ。

ちょっと調べると、Windows フォームアプリの場合の対処法として、
↓のページとかでは、アプリがアイドル状態になったタイミングで
処理中フラグを落として受け入れ再開するような方法を紹介してる。
[VB, C#] ボタンの 2 度押しを防止する  

よーしそれならWPFでも!ってことですけども、 WPFだとApplication.Idleイベントがない*1ので、 Dispatcherに非同期で処理を登録しておくことにする。
        volatile bool isProcessing;

        private void button1_Click(object sender, RoutedEventArgs e) {

            if (isProcessing) return;   //処理中なら何もしない

            isProcessing = true;

            //アイドル状態になってから実行させる処理を登録
            this.Dispatcher.BeginInvoke(new Action(() => {
                isProcessing = false;

            }), DispatcherPriority.ApplicationIdle);
 
            
            Thread.Sleep(1000); //時間のかかる処理の代わり

            Console.WriteLine("{0} 完了", DateTime.Now);
        }

これでとりあえず、処理中に何度連打しても初回の分しか実行されなくなる。

ただ、いちいち全部のハンドラでこんなことしてらんないので
もうボタン自体に機能を持たせてカスタムコントロールにしたいなぁと思う。
そんでまあ↓こんなコントロールを作ってみたけど、これはいまいちです。

    public class MyButton : Button {
        /// <summary>
        /// コンストラクタ
        /// </summary>
        public MyButton() {
            this.Click += MyButton_Click;
        }
        /// <summary>
        /// 処理中フラグ
        /// </summary>
        volatile bool isProcessing;

        /// <summary>
        /// クリックイベント処理
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void MyButton_Click(object sender, RoutedEventArgs e) {
            //前回イベントの処理中ならこのイベントを処理済にして終了
            if (isProcessing) {
                e.Handled = true;
                return;
            }
            
            isProcessing = true;

            this.Dispatcher.BeginInvoke(new Action(() => {
                isProcessing = false;

            }), DispatcherPriority.ApplicationIdle);
        }
    }

フラグ管理の方法はおんなじで、処理中フラグが立っている場合は
続くイベント処理をキャンセルして終了する。

これの何がいまいちかというと、クリックイベントを使ってる分には良いのだけど、
CommandプロパティにICommand実装をバインドして使う場合に、
連打での多重呼び出しが抑止されない。

Buttonコントロール内部でのコマンド処理ってどうなってるんだろうと考えたんだけど、
コマンドの実体が何かというと、これはICommandSourceの実装なわけだ。

このインタフェースを実装しているコントロールは、
内部で任意のタイミングで(Buttonならクリック時)Commandプロパティのオブジェクトの
Executeメソッドを呼び出しているということだろう。たぶん。
となると、Clickイベントのハンドラでキャンセルしたのでは既に遅いので、
プロテクトなOnClickメソッドをオーバーライドして先に判定を入れるべし!

    public class MyButton : Button {

        volatile bool isProcessing;

        protected override void OnClick() {
            if (isProcessing) return;   //処理中ならOnClickを実行せずに終了

            isProcessing = true;

            this.Dispatcher.BeginInvoke(new Action(() => {
                isProcessing = false;

            }), DispatcherPriority.ApplicationIdle);

            base.OnClick();
        }
    }

今のところこれで問題なく動いているようです。
本当はそもそもUIを固めねーで裏でやれよって話だったりするんですけども。  

 

*1)ComponentDispatcher.ThreadIdleとか使ってもできるのかも