WPFアプリケーションでデータベース検索を行う際、検索処理が長引くと画面がフリーズしてしまうことがあります。
ユーザーの操作性を考えるなら、UIとは別のスレッドで検索処理を行い、その結果を再びUIに反映させたいところですが、単純に別スレッドからUIを更新するだけでは、エラーになってしまいます。
本記事では、「データやファイルの読み込み中であっても、画面がフリーズすることなく操作ができるようにしたい」という目標を達成するため、別スレッドからUIを操作する方法を紹介します。
スレッドの扱いが初めての方でも実装できるよう、SQLiteを使った具体的な例とサンプルプログラムを交えて分かり易く解説します。
解決すべき課題
UIから別スレッドを起動して、そのスレッドの処理結果を再びUIにセットすると、上記のエラーが発生します。
これは、「別スレッドの中から画面のコントロールにアクセスできない」という制限があるためです。
解決方法
UIの中から別スレッドを呼び出し、スレッドの処理が完了した時点で、 Dispatcher.Invoke を使ってUIに結果を返すことで解決できます。
// 別タスクを起動し、結果をUIに返す例
Task.Run(() =>
{
// ~~ここに時間の掛かる処理を記述~~
Application.Current.Dispatcher.Invoke(() =>
{
// ~~ここにUIに結果を返す処理を記述~~
});
});
下記は、非同期に対応していない ReadAllLines を別タスクで実行し、読み込んだテキストを MyTextBox という名前のUIコントロールにセットするサンプルです。
public void Load(string filename)
{
// テキストファイルを非同期に読み込む
Task.Run(() =>
{
// ファイルを同期的に読み込む
var lines = File.ReadAllLines(filename);
// 読み込んだ結果を TextBox にセット
Application.Current.Dispatcher.Invoke(() =>
{
MyTextBox.Text = string.Join(Environment.NewLine, lines);
});
});
}
ReadAllLines の非同期版として ReadAllLinesAsync が用意されており、こちらを使う方がパフォーマンスや安全性は高まりますが、ちょっとした非同期処理であれば ReadAllLines でも問題ありません。
ちなみに、正しく非同期にしたい場合は次の様に書きます。
Task.Run(async () =>
var lines = await File.ReadAllLinesAsync(filename);
・・・以下省略
別タスクの中でイベントを発火させUIを更新するには
先ほどのサンプルでは、別スレッドの処理が終わったタイミングで Dispatcher.Invoke を呼び出していましたが、処理の最後にイベントを発行させてUIを更新する方法もあります。
この方法は、呼び出されるクラスにイベントが用意されていることが前提ですが、うまく使えば処理の進捗状況を逐次UIに反映させる場合に便利です。ちなみに、後ほど紹介するサンプルプログラムでは、この方法を使っています。
// 別スレッドで実行したいクラスのサンプル
public class Hoge
{
//イベントハンドラの定義
public event EventHandler<EventArgs> TaskCompleted = delegate { };
//メソッド
public void Execute(int val)
{
~中略~
TaskCompleted(this, new EventArgs());
}
}
画面側は次の様に記述します。
var hoge = Hoge();
hoge.TaskCompleted += (sender,e) => {
// ~~ここにUIを更新する処理を記述~~;
};
//HogeクラスのインスタンスのExecuteメソッドに引数10000を渡して、別スレッドで実行するサンプル
Task.Run(() => Hoge.Execute(1000));
Dispatcher.Invokeの呼び出す時の2つの方法
これまでの説明では、Application.Current.Dispatcher.Invoke() という呼び出し方を使っていましたが、this を使って少しだけ省略することができます。
// 記述方法1
Application.Current.Dispatcher.Invoke()
//記述方法2
this.Dispatcher.Invoke()
Application.Current.Dispatcher は、アプリケーション全体の UI スレッドにアクセスできるため、どのウィンドウやコントロールからでも使えます(別のウィンドウにある UI にもアクセスできます)。
一方、this.Dispatcher はそのウィンドウやコントロールの UI だけにしかアクセスできません。
つまり、Application.Current はアプリ全体に通じていて、他のウィンドウにもアクセス可能ですが、this は自分自身の画面にしかアクセスできないところが違います。
後程紹介するサンプルプログラムでは、 this を使っています。
別スレッドからコントロールにアクセスする
ただし注意点があって、別タスクの検索結果をそのままコントロールにセットすると、下記のエラーが発生します。
これは、「別スレッドの中から画面のコントロールにアクセスできない」という制限があるためです。
イベントハンドラに記述した処理は別スレッドから呼び出されるので、結果的に上記の制限に引っかかってエラーが発生します。
このエラー回避の回避策として、 this.Dispatcher.Invoke を使って次のように記述する方法があります。
this.Dispatcher.Invoke((Action)(() => { 処理; }));
サンプルプログラムでは、この方法を使って別スレッドの中から画面のコントロールに検索結果をセットしています。
サンプルプログラムのソースコード
サンプルプログラムの画面
今回紹介するサンプルプログラムは、SQLiteに対して検索を行っている間もUIが操作でき、かつ別スレッドの処理が完了したら結果を画面に表示するといった内容になります。
サンプルプログラムの構成
MainWindowに設置した「検索」ボタンをクリックすると、SQLiteLibクラスを呼び出し、SQLiteからデータを取得するようになっています。
取得したデータは、UIに結果を戻すために用意した TaskCompletedEventArgs クラスに格納し、完了イベントを発火させています。
SQLiteLib | SQLiteに対してSQLの実行や結果の取得を行うクラス |
---|---|
TaskCompletedEventArgs | SQLiteLibの検索メソッドで取得したデータ(検索結果はDataTableに格納)を 呼び出し元(MainWindow.xaml.cs)に返すためのクラス |
サンプル プログラムのダウンロードURL
ソースコードはこちらからダウンロードできます。
TaskSampleのソースコード一式(SQLiteのライブラリ含まず)
尚、SQLiteのライブラリは含んでいませんので、ビルド&実行したい場合は、NuGetからパッケージをインストールして下さい。
インストール方法の詳細についてお知りになりたい方は、こちらの記事をどうぞ。
ソースコードの紹介
まず最初にMainWindow.xamlです。
<Window x:Class="TaskSample.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:TaskSample"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<Grid>
<DataGrid x:Name="uxDataGrid" ItemsSource="{Binding}" Margin="0,59,0,0"/>
<Button Content="検索" HorizontalAlignment="Right" Margin="0,15,132,0" VerticalAlignment="Top" Height="30" Width="80" Click="FindButton_Click"/>
<Button Content="中止" Margin="0,15,17,0" VerticalAlignment="Top" Height="30" HorizontalAlignment="Right" Width="80" Click="StopButton_Click"/>
</Grid>
</Window>
次に、 MainWindow.xaml.cs です。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.IO;
namespace TaskSample
{
/// <summary>
/// MainWindow.xaml の相互作用ロジック
/// </summary>
public partial class MainWindow : Window
{
//テストデータの件数
int COUNT = 1000000;
//テストDBのファイル名
string DB_NAME = "test.db";
//SQLiteLibのインスタンス格納用変数
SQLiteLib _sqlite;
/// <summary>
/// コンストラクタ
/// </summary>
public MainWindow()
{
InitializeComponent();
//インスタンス生成
_sqlite = new SQLiteLib(DB_NAME);
//DBの作成とテストデータの登録
init_db();
//処理完了のイベントハンドラを登録
_sqlite.TaskCompleted += (sender, e) =>
this.Dispatcher.Invoke((Action)(() => {
//検索結果をDataGridにセット
uxDataGrid.DataContext = e.Result;
//マウスカーソルを元に戻す
this.Cursor = Cursors.Arrow;
}));
}
/// <summary>
/// 検索の実行
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void FindButton_Click(object sender, RoutedEventArgs e)
{
//マウスカーソルを待機アイコンにする
this.Cursor = Cursors.Wait;
//DataGridの表示をクリアする
uxDataGrid.DataContext = null;
//DBの検索処理を別スレッドで実行
Task.Run(() => _sqlite.ExecuteQuery("select * from Test"));
}
/// <summary>
/// 処理の中断
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void StopButton_Click(object sender, RoutedEventArgs e)
{
_sqlite.Stop();
}
/// <summary>
/// テストDBの初期化
/// </summary>
private void init_db()
{
//テスト用DBが無ければ作成する
if (! File.Exists(DB_NAME))
{
//マウスカーソルを待機アイコンに変更
this.Cursor = Cursors.Wait;
//テーブル削除、テーブル作成、テストデータ登録様SQLを生成
List<string> sqls = new List<string>();
sqls.Add("drop table if exists Test");
sqls.Add("create table Test(name text, val1, real val2)");
for (int i = 0; i < COUNT; i++)
{
sqls.Add(string.Format("insert into Test values('Item_{0}',{1},{2})", i, i, i * i));
}
//
_sqlite.ExecuteNoneQuery(sqls.ToArray());
//マウスカーソルを元に戻す
this.Cursor = Cursors.Arrow;
}
}
}
}
最後に、SQLiteLibとTaskCompletedEventArgs のクラスです。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Data.SQLite;
using System.Data;
namespace TaskSample
{
/// <summary>
/// 呼び出し元に検索結果を返すためのデータ保持クラス
/// </summary>
public class TaskCompletedEventArgs : EventArgs
{
public object Result { get; set; }
}
public class SQLiteLib
{
/// <summary>
/// 完了イベントハンドラ
/// </summary>
public event EventHandler<TaskCompletedEventArgs> TaskCompleted = delegate { };
/// <summary>
/// 読み込み中止フラグ
/// </summary>
private bool _stopFlag = false;
/// <summary>
/// 接続文字列
/// </summary>
public string ConnectString { get; set; }
/// <summary>
/// コンストラクタ
/// </summary>
/// <param name="connectString"></param>
public SQLiteLib(string connectString)
{
ConnectString = "DataSource="+ connectString;
}
/// <summary>
/// SQLの実行
/// </summary>
/// <param name="sqls"></param>
public void ExecuteNoneQuery(string[] sqls)
{
using (SQLiteConnection connection = new SQLiteConnection(ConnectString))
{
connection.Open();
using (SQLiteTransaction trans = connection.BeginTransaction())
{
try
{
foreach (string sql in sqls)
{
using (SQLiteCommand cmd = connection.CreateCommand())
{
cmd.Transaction = trans;
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
cmd.Dispose();
}
}
trans.Commit();
//処理完了イベント
TaskCompleted(this, new TaskCompletedEventArgs() { Result = true });
}
catch
{
trans.Rollback();
throw;
}
}
}
}
/// <summary>
/// Queryの実行
/// </summary>
/// <param name="sql"></param>
/// <param name="param"></param>
/// <returns></returns>
public DataTable ExecuteQuery(string sql, Dictionary<string, object> param = null)
{
DataTable dt = null;
_stopFlag = false;
using (SQLiteConnection connection = new SQLiteConnection(ConnectString))
{
connection.Open();
using (SQLiteCommand cmd = connection.CreateCommand())
{
//SQLの設定
cmd.CommandText = sql;
//パラメータの設定
if (param != null)
{
foreach (KeyValuePair<string, object> kvp in param)
{
cmd.Parameters.Add(new SQLiteParameter(kvp.Key, kvp.Value));
}
}
//レコードの読み出し
using (SQLiteDataReader reader = cmd.ExecuteReader())
{
while (reader.Read())
{
if (_stopFlag)
{
break;
}
if(dt == null)
{
dt = new DataTable();
Enumerable.Range(0,reader.FieldCount)
.Select(i=>dt.Columns.Add(reader.GetName(i),reader.GetFieldType(i))).ToArray();
}
else
{
var dr = dt.NewRow();
dr.ItemArray = Enumerable.Range(0, reader.FieldCount)
.Select(i => reader.GetValue(i)).ToArray();
dt.Rows.Add(dr);
}
}
}
}
}
//処理完了イベント
TaskCompleted(this, new TaskCompletedEventArgs() { Result = dt });
return dt;
}
/// <summary>
/// 読み込みの強制中止
/// </summary>
public void Stop()
{
_stopFlag = true;
}
}
}
まとめ
今回は、WPF の画面から別タスクでデータベースに検索を行い、その結果を画面に表示する一連の流れをご紹介しました。
今回の題材はデータベース検索ですが、この記事で紹介した方法は、CSV の読み書きやデータの入力待ちなど、時間がかかる処理においても応用できます。このような場合、画面をフリーズさせずに処理を進めることが可能です。
サンプルプログラムは必要最小限の機能しか持たせていませんので、例えば「検索」ボタンを押してから検索が終わるまでの間、ボタンの色を変えたり、処理完了後にメッセージを表示したりといった機能は追加していません。
これらの機能は、お好みに合わせてカスタマイズしてみてください。
この記事が皆様のプログラミングの一助となれば幸いです。れば幸いです。
コメント