連載
» 2018年12月12日 05時00分 公開

.NET TIPS:タイマにより一定時間間隔で処理を行うには?(WPFタイマ編)

DispatcherTimerクラスを利用して、WPFアプリにおいて一定間隔で処理を実行し、UIを更新する方法を解説する。

[山本康彦,著]
「.NET TIPS」のインデックス

連載「.NET TIPS」

 .NET Frameworkには一定時間間隔で処理を行う(メソッドを呼び出す)ためのタイマ機能として、以下の4種類のTimerクラスが用意されている。

  1. Windowsタイマ:System.Windows.Forms.Timerクラス(解説TIPSへ
  2. スレッドタイマ:System.Threading.Timerクラス(解説TIPSへ
  3. サーバベースタイマ:System.Timers.Timerクラス(解説TIPSへ
  4. WPFタイマ:System.Windows.Threading.DispatcherTimerクラス

 本稿では4のWPFタイマについて、その基本的な使い方をまとめる。

POINT WPFタイマの使い方

WPFタイマの使い方まとめ WPFタイマの使い方まとめ


 特定のトピックをすぐに知りたいという方は以下のリンクを活用してほしい。

 なお、本稿に掲載したサンプルコードをそのまま試すにはVisual Studio 2017以降が必要である。使っているAPIは基本的に.NET Framework 3.5までのものだが、言語機能はそれ以降のものも利用している。

準備:WPFの画面を用意する

 以降の説明で使うための画面を準備しておこう。WPFのプロジェクトを作ったら、画面に「TextBlock1」という名前を付けたTextBlockコントロールを配置する。その例を次のコードに示す。

<Grid>
  <Viewbox>
    <TextBlock x:Name="TextBlock1" 
                FontFamily="Harlow Solid Italic" FontSize="36"
                TextAlignment="Center" Margin="5,0,10,0"
                Text="00:00:00" FontWeight="Bold" >
      <TextBlock.Foreground>
        <SolidColorBrush
          Color="{DynamicResource {x:Static SystemColors.HighlightColorKey}}"/>
      </TextBlock.Foreground>
    </TextBlock>
  </Viewbox>
</Grid>

WPFの画面の例(XAML)
サーバベースタイマのTIPSのWPF画面と同じものである。

WPFの画面の例(実行時) WPFの画面の例(実行時)

 このあと説明するコードは、この画面のコードビハインドに記述していく。

WPFタイマ:System.Windows.Threading.DispatcherTimerクラスの基本

 WPFのUIスレッドには、UIスレッド上の処理を管理するDispatcherクラス(System.Windows.Threading名前空間)のインスタンスが結び付けられている。Dispatcherクラスは、優先順位に従ってUIスレッドに処理を割り振るだけでなく、内部的にはタイマの機能も持っている(Win32 APIのSetTimer関数を使ったタイマ)。そのタイマ機能を利用するために用意されているのが、DispatcherTimerクラス(System.Windows.Threading名前空間)である*1

*1 このあたりの内部構造に興味のある方は、リファレンスソースを調べてみるのもよいだろう。

  1. DispatcherTimerクラス:DispatcherクラスのAddTimerメソッド/RemoveTimerメソッドを呼び出している
  2. Dispatcherクラス:SafeNativeMethodsクラスのSetTimerメソッド/KillTimerメソッドを呼び出している
  3. SafeNativeMethodsクラス:user32.dllにあるSetTimer関数/KillTimer関数をDllImportしている

 このDispatcherTimerクラスでは、EventHandlerデリゲート(System名前空間)を使用して、タイマにより呼び出されるメソッド(以下、タイマメソッドと記す)のデリゲートを作成し、DispatcherTimerクラスのTickイベントに登録する(VB.NETではWithEvents/Handlesキーワードによりイベントを登録することも可能)。

 DispatcherTimerクラスでは、タイマメソッドは必ずUIスレッドで呼び出される。タイマメソッドの中から安心してUIにアクセスできるのだ。その代わり、タイマメソッドで時間のかかる処理を単純に実行するとUIがフリーズしてしまう(後述)。

 タイマメソッドの呼び出し間隔は、IntervalプロパティにTimeSpan構造体(System名前空間)で指定する。タイマの開始/停止は、Start/Stopメソッドを呼び出して行う(IsEnabledプロパティにtrue/falseを設定してもよい)。

 DispatcherTimerクラスは、インスタンスを作るときにDispatcherPriority列挙体(System.Windows.Threading名前空間)で処理の優先度を指定できる。指定しなかったときの優先度はDispatcherPriority.Backgroundである。これは、UIの処理よりも低い優先度だ。コンストラクタ引数でもっと高い優先度を指定できるが、その際にはUIの動きに影響が出るかもしれないので十分にテストしてほしい。

 以下にWPFタイマを利用したサンプルコードを示す。冒頭に示したサンプル画面のコードビハインドである。MyTimerMethodメソッドがタイマにより一定間隔で実行されるメソッドだ。1秒間隔で呼び出され、現在時刻を画面に表示する。

using System;
using System.ComponentModel;
using System.Windows;
using System.Windows.Threading;
namespace dotNetTips1244CS
{
  public partial class MainWindow : Window
  {
    public MainWindow()
    {
      InitializeComponent();
      SetupTimer();
    }

    // タイマメソッド
    private void MyTimerMethod(object sender, EventArgs e)
    {
      this.TextBlock1.Text = DateTime.Now.ToString("HH:mm:ss");
    }

    // タイマのインスタンス
    private DispatcherTimer _timer;

    // タイマを設定する
    private void SetupTimer()
    {
      // タイマのインスタンスを生成
      _timer = new DispatcherTimer(); // 優先度はDispatcherPriority.Background
      // インターバルを設定
      _timer.Interval = new TimeSpan(0, 0, 1);
      // タイマメソッドを設定
      _timer.Tick += new EventHandler(MyTimerMethod);
      // タイマを開始
      _timer.Start();
      
      // 画面が閉じられるときに、タイマを停止
      this.Closing += new CancelEventHandler(StopTimer);
    }

    // タイマを停止
    private void StopTimer(object sender, CancelEventArgs e)
    {
      _timer.Stop();
    }
  }
}

Imports System.ComponentModel
Imports System.Windows.Threading
Class MainWindow
  Public Sub New()
    InitializeComponent()
    SetupTimer()
  End Sub

  ' タイマメソッド
  Private Sub MyTimerMethod(sender As Object, e As EventArgs)
    Me.TextBlock1.Text = DateTime.Now.ToString("HH:mm:ss")
  End Sub

  ' タイマのインスタンス
  Private _timer As DispatcherTimer

  ' タイマを設定する
  Private Sub SetupTimer()
    ' タイマのインスタンスを生成
    _timer = New DispatcherTimer() ' 優先度はDispatcherPriority.Background
    ' インターバルを設定
    _timer.Interval = New TimeSpan(0, 0, 1)
    ' タイマメソッドを設定
    AddHandler _timer.Tick, New EventHandler(AddressOf MyTimerMethod)
    ' タイマを開始
    _timer.Start()

    ' 画面が閉じられるときに、タイマを停止
    AddHandler Me.Closing, New CancelEventHandler(AddressOf StopTimer)
  End Sub

  ' タイマを停止
  Private Sub StopTimer(sender As Object, e As CancelEventArgs)
    _timer.Stop()
  End Sub
End Class

WPFタイマを使用したサンプルコード(上:C#/下:VB)
冒頭に示したサンプル画面のコードビハインドである。
C#のnamespace宣言「dotNetTips1244CS」は、適切な名前に変更してもらいたい。

 このコードでもちろん問題はないのだが、タイマの処理が3つのメソッドに分かれてしまっている(MyTimerMethodメソッド/SetupTimerメソッド/StopTimerメソッド)。すっきりと1つのメソッドにまとめたいときは、次に説明するようにラムダ式を使う。

ラムダ式で簡潔に記述する

 .NET Framework 3.5からは、デリゲートに替えてラムダ式が使える。タイマメソッドの内容がさほど長くないときは、ラムダ式にすると簡潔に記述できる。上のコードをラムダ式を使って書き直すと、次のコードのようになる。

using System;
using System.ComponentModel;
using System.Windows;
using System.Windows.Threading;
namespace dotNetTips1244CS
{
  public partial class MainWindow : Window
  {
    public MainWindow()
    {
      InitializeComponent();
      SetupTimer();
    }

    private void SetupTimer()
    {
      // タイマのインスタンスを生成
      var timer = new DispatcherTimer(DispatcherPriority.Normal)
      {
        // インターバルを設定
        Interval = TimeSpan.FromSeconds(1.0),
      };
      // タイマメソッドを設定(ラムダ式で記述)
      timer.Tick += (s, e) => 
      {
        this.TextBlock1.Text = DateTime.Now.ToString("HH:mm:ss");
      };
      // タイマを開始
      timer.Start();

      // 画面が閉じられるときに、タイマを停止(ラムダ式で記述)
      this.Closing += (s, e) => timer.Stop();
    }
  }
}

Imports System.ComponentModel
Imports System.Windows.Threading
Class MainWindow
  Public Sub New()
    InitializeComponent()
    SetupTimer()
  End Sub

  Private Sub SetupTimer()
    ' タイマのインスタンスを生成
    Dim timer = New DispatcherTimer(DispatcherPriority.Normal) _
    With {
      .Interval = TimeSpan.FromSeconds(1.0) ' インターバルを設定
    }
    ' タイマメソッドを設定(ラムダ式で記述)
    AddHandler timer.Tick,
      Sub(s, e)
        Me.TextBlock1.Text = DateTime.Now.ToString("HH:mm:ss")
      End Sub
    ' タイマを開始
    timer.Start()

    ' 画面が閉じられるときに、タイマを停止(ラムダ式で記述)
    AddHandler Me.Closing, Sub(s, e) timer.Stop()
  End Sub
End Class

WPFタイマを使用したサンプルコード:ラムダ式バージョン(上:C#/下:VB)
冒頭に示したサンプル画面のコードビハインドである。
C#のnamespace宣言「dotNetTips1244CS」は、適切な名前に変更してもらいたい。
ここでは処理の優先度をDispatcherPriority.Normalにしてみた。タイマメソッド以外にUIにアクセスする処理はないので、特に問題は生じないようだ。
ところで、DispatcherTimerオブジェクトがローカル変数timerに保持されている。SetupTimerメソッドの末尾で破棄されてしまわないのだろうか? ローカル変数timerはラムダ式「(s, e) => timer.Stop()」にキャプチャーされており、そのラムダ式は画面のClosingイベントにセットされている。ということは、画面のClosingイベントに結び付いている限りこのラムダ式は生存し、従ってローカル変数timerも生存し続ける。

タイマメソッドで長時間の処理をする場合

 DispatcherTimerクラスのタイマメソッドは必ずUIスレッドで呼び出されるので、タイマメソッドで時間がかかる処理を単純に実行するとUIがフリーズしてしまう。例えば、上のコードのタイマメソッドに、(長い時間がかかる処理の代わりとして)スレッドを停止するコードを追加してみよう(次のコード)。

timer.Tick += (s, e) => 
{
  // スレッドを1.5秒間だけ止める
  System.Threading.Thread.Sleep(1500);

  this.TextBlock1.Text = DateTime.Now.ToString("HH:mm:ss");
};

AddHandler timer.Tick,
  Sub(s, e)
    ' スレッドを1.5秒間だけ止める
    System.Threading.Thread.Sleep(1500)

    Me.TextBlock1.Text = DateTime.Now.ToString("HH:mm:ss")
  End Sub

長時間かかるタイマメソッドの例(上:C#/下:VB)
太字の部分を追加した。

 DispatcherTimerクラスは、タイマメソッドから制御が返らない間は次のタイマメソッド呼び出しを行わない。そこで、上のようにインターバル(=1秒間)よりもタイマメソッドの処理時間(=1.5秒)が長くなっても、重複してタイマメソッド呼び出されることはない。

 ただし、タイマメソッドから制御を返すまではUIがフリーズしてしまう。タイトルバーをマウスでドラッグして、ウィンドウをぐるぐると動かし続けてみるとよく分かるだろう。

 タイマメソッドで長時間の処理を行うときは、(UIスレッド以外の)スレッドでその処理を非同期に実行するべきである。それには2つの考え方がある。

 1つは、タイマメソッドの全体を別スレッドで実行して、UIにアクセスするときだけUIスレッドに移るという考え方だ。これは、サーバベースタイマのTIPSで紹介した方法である。DispatcherTimerクラスではなく、Timerクラス(System.Timers名前空間)を使う。

 もう1つは、DispatcherTimerクラスを使いつつ、タイマメソッド中の時間がかかる処理だけを切り出して別スレッドで実行するという考え方だ(タイマメソッドからはすぐに制御を返すようにする)。例として、.NET Framework 4.5以降(および、Visual Studio 2012以降)で利用できるasync/awaitキーワードを使った非同期化の例を次のコードに示す。

 タイマメソッドを非同期メソッドにして、長時間の処理をしつつ、制御はすぐに返すようにすると、(制御をDispatcherTimerクラスに返してしまったので)処理が終わっていなくても次のタイマメソッド呼び出しが発生してしまう。そのためタイマメソッドをリエントラント(再入可能)に作らねばならない。ここではセマフォを使い、再入時にはすぐにリターンするようにした。

// 排他制御のためのセマフォオブジェクト
System.Threading.SemaphoreSlim semaphore
  = new System.Threading.SemaphoreSlim(1, 1);

// タイマメソッド
timer.Tick += async (s, e) =>
{
  if (!await semaphore.WaitAsync(0))
  {
    // セマフォは他で使用中のため取得できなかった
    return;
  }
  // セマフォが取得できたので、処理を実行する
  try
  {
    // 長時間の処理を別スレッドで非同期実行
    await Task.Run(() => System.Threading.Thread.Sleep(1500));

    // 非同期実行が完了してから、画面を書き換え
    this.TextBlock1.Text = DateTime.Now.ToString("HH:mm:ss");
  }
  finally
  {
    // 確実にセマフォを解放する(そのためのtry〜finally)
    semaphore.Release();
  }
};

' 排他制御のためのセマフォオブジェクト
Dim semaphore As System.Threading.SemaphoreSlim _
  = New System.Threading.SemaphoreSlim(1, 1)

' タイマメソッド
AddHandler timer.Tick,
  Async Sub(s, e)
    If (Not Await semaphore.WaitAsync(0)) Then
      ' セマフォは他で使用中のため取得できなかった
      Return
    End If
    ' セマフォが取得できたので、処理を実行する
    Try
      ' 別スレッドで長時間の処理を非同期実行
      Await Task.Run(Sub() System.Threading.Thread.Sleep(1500))

      ' 非同期実行が完了してから、画面を書き換え
      Me.TextBlock1.Text = DateTime.Now.ToString("HH:mm:ss")
    Finally
      ' 確実にセマフォを解放する(そのためのtry〜finally)
      semaphore.Release()
    End Try
  End Sub

長時間かかるタイマメソッドを非同期にした例(上:C#/下:VB)
太字の部分を追加した。
このコードの動作には、.NET Framework 4.5以降が必要である。
ここでは、非同期実行される長時間処理が再入可能ではないものと仮定している。もしも、非同期実行される長時間処理が再入可能であるならば、このようなスレッド間の排他処理は必要ない。
SemaphoreSlimクラス(System.Threading名前空間)を使って排他制御する方法については、「.NET TIPS:非同期:awaitを含むコードをロックするには?(SemaphoreSlim編)[C#、VB]」をご覧いただきたい。

カテゴリ:クラスライブラリ 処理対象:タイマ
カテゴリ:WPF 処理対象:スレッド
使用ライブラリ:DispatcherTimerクラス(System.Windows.Threading名前空間)
使用ライブラリ:EventHandlerデリゲート(System名前空間)
関連TIPS:タイマにより一定時間間隔で処理を行うには?(Windowsタイマ編)
関連TIPS:タイマにより一定時間間隔で処理を行うには?(スレッドタイマ編)
関連TIPS:タイマにより一定時間間隔で処理を行うには?(サーバベースタイマ編)
関連TIPS:非同期:awaitを含むコードをロックするには?(SemaphoreSlim編)[C#、VB]


「.NET TIPS」のインデックス

.NET TIPS

Copyright© Digital Advantage Corp. All Rights Reserved.

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。