複数のウィンドウに同じデータを表示するには?[Windows 8.1ストアアプリ開発]:WinRT/Metro TIPS
アプリから複数のウィンドウを表示した場合、各ウィンドウは異なるUIスレッドで動作するためマルチスレッドに関する問題が起きることがある。その解決方法を解説する。
powered by Insider.NET
前回、アプリから複数のウィンドウ(アプリビュー)を表示する方法を解説する中で、「それぞれのウィンドウ(アプリビュー)は異なるUIスレッドで動作している」と書いた。しかし、データバインディングの仕組みはそのようなマルチスレッドでの動作を想定していないので、問題が起きることもある。本稿では、発生し得る問題点とその解決方法を解説する。なお、本稿のサンプルは「Windows Store app samples:MetroTips #72」からダウンロードできる。
事前準備
Windows 8.1(以降、Win 8.1)用のWindowsストアアプリを開発するには、Win 8.1とVisual Studio 2013(以降、VS 2013)が必要である。本稿では64bit版Windows 8.1 Pro(日本語版)*1とVisual Studio Express 2013 for Windows(日本語版)*2を使用している。また、本稿では、前回のコードをベースにしている。
*1 Win 8.1 Update(2014年4月)を適用済み。なお、このアップデートは必須とされている。
*2 マイクロソフト公式サイトの「Microsoft Visual Studio Express 2013 for Windows」から無償で入手できる。
バインディングソースを用意する
本稿ではバインディングソースとして、次のコードに示す「Clock」クラスを使う。
この「Clock」クラスは、現在時刻を表す文字列のプロパティ「NowTime」を持っており、1秒ごとに「NowTime」プロパティが変化すると同時にPropertyChangedイベントを発生するようになっている。さらに、「NowTime」プロパティの文字列の末尾には、そのインスタンスの連番が付加される。つまり、「Clock」オブジェクトを生成するたびに、末尾の番号が増えるようになっているのだ。
public class Clock : System.ComponentModel.INotifyPropertyChanged
{
// 現在時刻を表す文字列のプロパティ "HH:mm:ss [{連番}]"
// 注意:今回、このプロパティは別スレッドから任意のタイミングで読み出される可能性がある。
// すなわち、必要に応じてスレッド間で排他制御しなければならない。
// 以下は、ReaderWriterLockSlimクラスを使ってスレッド間排他制御を行う例
private string _nowTime;
private System.Threading.ReaderWriterLockSlim _rwLock
= new System.Threading.ReaderWriterLockSlim();
public string NowTime
{
get
{
_rwLock.EnterReadLock();
try
{
return _nowTime;
}
finally
{
_rwLock.ExitReadLock();
}
}
private set
{
_rwLock.EnterWriteLock();
try
{
_nowTime = value;
}
finally
{
_rwLock.ExitWriteLock();
}
}
}
public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;
private static int _instanceIndex = 0; // インスタンスごとに連番を振るために使う
private string _instanceSuffix; // 時刻の後ろに付ける文字列「[{連番}]」
private Windows.UI.Xaml.DispatcherTimer _timer; // 生成時のUIスレッドで割り込みを発生させるタイマー
public Clock()
{
_instanceSuffix = string.Format("[{0}]", _instanceIndex++);
Run();
}
private void Run()
{
_timer = new Windows.UI.Xaml.DispatcherTimer();
_timer.Interval = TimeSpan.FromMilliseconds(50.0);
_timer.Tick += _timer_Tick;
_timer.Start();
}
private DateTimeOffset _lastTime;
// タイマーで定期的に呼び出されるメソッド
void _timer_Tick(object sender, object e)
{
var nowTime = DateTimeOffset.Now;
if (_lastTime.Second != nowTime.Second)
{
_lastTime = nowTime;
// 秒が変わったら、プロパティに時刻をセットし、イベントを発火させる
this.NowTime = string.Format("{0} {1}", nowTime.ToString("HH:mm:ss"), _instanceSuffix);
var eventHandler = this.PropertyChanged;
if (eventHandler != null)
eventHandler(this, new System.ComponentModel.PropertyChangedEventArgs("NowTime"));
}
}
}
Public Class Clock
Implements INotifyPropertyChanged
' 現在時刻を表す文字列のプロパティ "HH:mm:ss [{連番}]"
' 注意:今回、このプロパティは別スレッドから任意のタイミングで読み出される可能性がある。
' すなわち、必要に応じてスレッド間で排他制御しなければならない。
' 以下は、ReaderWriterLockSlimクラスを使ってスレッド間排他制御を行う例
Private _nowTime As String
Private _rwLock As System.Threading.ReaderWriterLockSlim _
= New System.Threading.ReaderWriterLockSlim()
Public Property NowTime As String
Get
_rwLock.EnterReadLock()
Try
Return _nowTime
Finally
_rwLock.ExitReadLock()
End Try
End Get
Private Set(value As String)
_rwLock.EnterWriteLock()
Try
_nowTime = value
Finally
_rwLock.ExitWriteLock()
End Try
End Set
End Property
Public Event PropertyChanged(sender As Object, e As PropertyChangedEventArgs) _
Implements INotifyPropertyChanged.PropertyChanged
Private Shared _instanceIndex As Integer = 0 ' インスタンスごとに連番を振るために使う
Private _instanceSuffix As String ' 時刻の後ろに付ける文字列「[{連番}]」
Private _timer As Windows.UI.Xaml.DispatcherTimer ' 生成時のUIスレッドで割り込みを発生させるタイマー
Public Sub New()
_instanceSuffix = String.Format("[{0}]", _instanceIndex)
_instanceIndex += 1
Run()
End Sub
Private Sub Run()
_timer = New Windows.UI.Xaml.DispatcherTimer()
_timer.Interval = TimeSpan.FromMilliseconds(50.0)
AddHandler _timer.Tick, AddressOf _timer_Tick
_timer.Start()
End Sub
Private _lastTime As DateTimeOffset
' タイマーで定期的に呼び出されるメソッド
Private Sub _timer_Tick(sender As Object, e As Object)
Dim nowTime = DateTimeOffset.Now
If (_lastTime.Second <> nowTime.Second) Then
_lastTime = nowTime
' 秒が変わったら、プロパティに時刻をセットし、イベントを発火させる
Me.NowTime = String.Format("{0} {1}", nowTime.ToString("HH:mm:ss"), _instanceSuffix)
RaiseEvent PropertyChanged(Me, New System.ComponentModel.PropertyChangedEventArgs("NowTime"))
End If
End Sub
End Class
アプリケーションリソースにバインディングソースを置いてみよう
前回のコードをベースにして、アプリケーションリソースにバインディングソースを置いてみよう。
データバインディングを行う場合、バインディングソースをアプリケーションリソースに配置するとデザイン画面でもバインドできて便利である。そのため、この方法はよく使われていると思われる。まず、「App.xaml」ファイルに「Clock」クラスのインスタンスを定義する(次のコード)。
<Application
x:Class="MetroTips072CS.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:MetroTips072CS">
<Application.Resources>
<local:Clock x:Key="AppResourceClock" />
……省略……
</Application.Resources>
</Application>
「App.xaml」ファイルに太字の部分を追加する。
上で定義した「AppResourceClock」オブジェクトを、まず「MainPage」画面(前回参照)にバインドしてみよう。次のようなTextBlockコントロール(Windows.UI.Xaml.Controls名前空間)を2つ配置し、それぞれに「AppResourceClock」オブジェクトの「NowTime」プロパティをバインドする(次のコードと画像)。
<TextBlock ……省略…… Foreground="Firebrick"
Text="{Binding Path=NowTime, Source={StaticResource AppResourceClock}}"
/>
このTextBlockコントロールを、画面内に2つ配置する(次の画像を参照)。2つ配置するのは、同一画面内では同一のバインディングソースが使われることを確かめるためである。
アプケーションリソースの「AppResourceClock」オブジェクトを「MainPage」画面にバインド(VS 2013)
デザイン画面では、時刻の末尾に「[1]」/「[2]」と異なる番号が表示されている。別々の「Clock」インスタンスが生成されて、それぞれにバインドされているのである。デザイン時には異なるインスタンスであっても問題はないだろう。
これで実行して「MainPage」画面だけを見てみよう(次の画像)。
アプケーションリソースの「AppResourceClock」オブジェクトを「MainPage」画面にバインド(実行結果)
時刻の末尾にはともに「[0]」と同じ番号が表示されている。実行時には(デザイン時とは異なり)、同一の「Clock」インスタンスがバインドされているのである。
確かに同一の「Clock」インスタンスが2箇所のデータバインディングに使われている。
ここまでは、確認である。同じ画面の中でデータバインディングしているオブジェクトが異なるようでは、画面内のデータ表示の整合性を、バインディングソースを介して取れなくなってしまう。実行時にはこのように同じインスタンスが使われるのである。
アプリケーションリソースはウィンドウごとにインスタンスが作られる!
それでは複数ウィンドウ(アプリビュー)のときはどうだろうか? 「SecondaryPage」画面(前回参照)にも「MainPage」画面と同様にTextBlockコントロールを配置してバインドしてみよう(次の画像)。
アプケーションリソースの「AppResourceClock」オブジェクトを「SecondaryPage」画面にバインド(VS 2013)
先ほど「MainPage」画面に追加したのと同様なXAMLコードを、「SecondaryPage」画面にも追加する。
これで実行し、今度は複数のウィンドウ(アプリビュー)を開いてみよう(次の画像)。
アプケーションリソースの「AppResourceClock」オブジェクトを複数のウィンドウにバインドした(実行結果)
新しいウィンドウ(アプリビュー)を開くたびに、時刻の末尾に付加された番号が増えていく。 なお、3つのウィンドウ(アプリビュー)が開いているこのモニターは1920×1080ピクセルである。
すると、新しいウィンドウ(アプリビュー)を開くたびに時刻の末尾に付加された番号が増えていく。ウィンドウ(アプリビュー)ごとに、新しい「AppResourceClock」オブジェクトのインスタンスが生成されているのだ。
アプリケーションリソースに宣言したバインディングソースは、ウィンドウ(アプリビュー)ごとにインスタンス化される。この挙動は、双方向バインディングを使っている場合に問題になるだろう。例えば、双方向バインディングを使ってエンドユーザーの選択を設定ファイルに書き出している場合を考えてみてほしい。2つのウィンドウ(アプリビュー)で別々に設定を変更したら、設定ファイルに書き出されるデータはどうなるだろうか? また、一方向バインディングであっても、大本のデータが変わったときに複数のウィンドウ(アプリビュー)に表示されるデータをそろえるためには、ウィンドウ(アプリビュー)ごとにバインディングソースを更新しなければならない。
スタティック変数にしてコードビハインドからバインドすれば……!?
では、バインディングソースのオブジェクトをアプリ全体で唯一となるようにして、コードビハインドからバインドしてやればどうだろうか? 結論からいえば、このアイデアは失敗する。以下、確認しておこう。
まず「App.xaml.cs」ファイルの「App」クラスで、唯一のインスタンスとして「Clock」クラスのオブジェクトを保持するようにし、それを「TheClock」プロパティとして公開する(次のコード)。
public static Clock TheClock { get { return _clock; } }
private static Clock _clock;
static App()
{
_clock = new Clock();
}
Public Shared ReadOnly Property TheClock As Clock
Get
Return _clock
End Get
End Property
Private Shared _clock As Clock
Shared Sub New()
_clock = New Clock()
End Sub
画面には、「MainPage」画面と「SecondaryPage」画面の両方に「ClockText」と名前を付けたTextBlockコントロールを追加し、コードビハインドのコンストラクターで上記の「TheClock」オブジェクトをバインドする(次のコード)。
<TextBlock x:Name="ClockText" ……省略…… Foreground="White"
Text="{Binding Path=NowTime}"
/>
このTextBlockコントロールを、「MainPage」画面と「SecondaryPage」画面の両方にそれぞれ1つずつ追加する。
public MainPage()
{
……省略……
this.ClockText.DataContext = App.TheClock;
}
Public Sub New()
……省略……
Me.ClockText.DataContext = App.TheClock
End Sub
このコードは「MainPage」画面のものだが、「SecondaryPage」画面も同様にする。
これで実行してみると、最初に「MainPage」画面が表示されるところまではよいのだが、新しいウィンドウ(アプリビュー)を開いて「SecondaryPage」画面を表示したところで例外が発生してしまう。「TheClock」オブジェクトは最初のウィンドウ(アプリビュー)のUIスレッドに結び付いており(これはApp.xaml.cs/vbファイルでTheClockオブジェクトを生成している部分と、MainPage.xaml.cs/vbのMainPageクラスのコンストラクターにブレークポイントを設定してアプリをデバッグ実行し、実行がブレークされたところで[デバッグの場所]ツールバーの[スレッド]欄をチェックすれば確認できる)、そのPropertyChangedイベントも最初のウィンドウ(アプリビュー)のUIスレッドで発生する。2つ目以降のウィンドウ(アプリビュー)はそれとは異なるスレッドで動いているため(これも同様にして確認できる)、最初のウィンドウ(アプリビュー)のUIスレッドで発生したイベントによってUIを書き換えるのは許されないのだ(例外になる)。
1つのバインディングソースを複数のウィンドウに表示するには?
ではどうしたらよいのだろうか? 端的にいえば「MとVMを分離せよ」となる。
「Windowsストアアプリのアーキテクチャは『MVVM』にすべし」といわれていても、簡単なアプリではM(=モデル)とVM(=ビューモデル)を同じものとすることが多いだろう(それをアプリケーションリソースに置く)。しかし、ウィンドウ(アプリビュー)が複数ある場合には、MとVMをちゃんと分離しておいて、モデルで発生した変更を、ウィンドウ(アプリビュー)ごとに持っているビューモデルに反映するようにするべきということだ。
この「モデル側で発生した変更を、ウィンドウ(アプリビュー)ごとに存在するビューモデルに反映する」方法はいろいろと考えられる。今回はプロキシクラスを作って、それをビューモデル代わりに使ってみよう。考え方は単純で、「Clock」オブジェクトで発生したPropertyChangedイベントをプロキシクラスで受け取ったら、そのウィンドウ(アプリビュー)のスレッドであらためてイベントを発火してやればよいのだ(次のコード)。
class ClockProxy : System.ComponentModel.INotifyPropertyChanged
{
// 現在時刻を表す文字列のプロパティ "HH:mm:ss [{連番}]"
public string NowTime { get { return _baseClock.NowTime; } }
// このイベントは、ウィンドウ(アプリビュー)ごとのUIスレッドで発火させたい!
public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;
private Clock _baseClock;
private Windows.UI.Core.CoreDispatcher _currentDispatcher;
// コンストラクト時に、Clockオブジェクトを受け取る
public ClockProxy(Clock baseClock)
{
_baseClock = baseClock;
_baseClock.PropertyChanged += _baseClock_PropertyChanged;
// コンストラクト時に、そのUIスレッドのディスパッチャーを取得して保持しておく
_currentDispatcher = Windows.UI.Xaml.Window.Current.Dispatcher;
}
// ClockオブジェクトのPropertyChangedイベントで呼び出されるメソッド
async void _baseClock_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
// このオブジェクトのPropertyChangedイベントをあらためて発火させる
var eventHandler = this.PropertyChanged;
if (eventHandler != null)
{
var eventArgs = new System.ComponentModel.PropertyChangedEventArgs(e.PropertyName);
try
{
// このメソッドは、Clockオブジェクトのスレッドで呼び出されている。
// しかし、このオブジェクトが属するUIスレッドでイベントを発火させねばならない
await _currentDispatcher.RunAsync(
Windows.UI.Core.CoreDispatcherPriority.Normal,
() => eventHandler(this, eventArgs)
);
}
catch { }
}
}
}
Public Class ClockProxy
Implements INotifyPropertyChanged
' 現在時刻を表す文字列のプロパティ "HH:mm:ss [{連番}]"
Public ReadOnly Property NowTime As String
Get
Return _baseClock.NowTime
End Get
End Property
' このイベントは、ウィンドウ(アプリビュー)ごとのUIスレッドで発火させたい!
Public Event PropertyChanged(sender As Object, e As PropertyChangedEventArgs) _
Implements INotifyPropertyChanged.PropertyChanged
Private _baseClock As Clock
Private _currentDispatcher As Windows.UI.Core.CoreDispatcher
' コンストラクト時に、Clockオブジェクトを受け取る
Public Sub New(baseClock As Clock)
_baseClock = baseClock
AddHandler _baseClock.PropertyChanged, AddressOf _baseClock_PropertyChanged
' コンストラクト時に、そのUIスレッドのディスパッチャーを取得して保持しておく
_currentDispatcher = Windows.UI.Xaml.Window.Current.Dispatcher
End Sub
' ClockオブジェクトのPropertyChangedイベントで呼び出されるメソッド
Private Async Sub _baseClock_PropertyChanged(sender As Object, e As PropertyChangedEventArgs)
' このオブジェクトのPropertyChangedイベントをあらためて発火させる
Dim eventArgs = New System.ComponentModel.PropertyChangedEventArgs(e.PropertyName)
Try
' このメソッドは、Clockオブジェクトのスレッドで呼び出されている。
' しかし、このオブジェクトが属するUIスレッドでイベントを発火させねばならない
Await _currentDispatcher.RunAsync( _
Windows.UI.Core.CoreDispatcherPriority.Normal,
Sub()
RaiseEvent PropertyChanged(Me, eventArgs)
End Sub
)
Catch ex As Exception
'(コード無し)
End Try
End Sub
End Class
この「ClockProxy」クラスを、ウィンドウ(アプリビュー)ごとのバインディングソースとして使えばよい。まず、先ほど失敗した例と同じく、「App.xaml.cs」ファイルの「App」クラスで唯一のインスタンスとして「Clock」クラスのオブジェクトを保持するようにし、それを「TheClock」プロパティとして公開する(次のコード)。
public static Clock TheClock { get { return _clock; } }
private static Clock _clock;
static App()
{
_clock = new Clock();
}
Public Shared ReadOnly Property TheClock As Clock
Get
Return _clock
End Get
End Property
Private Shared _clock As Clock
Shared Sub New()
_clock = New Clock()
End Sub
前述の、失敗して例外が出てしまった例と同じコードである。
画面には、先ほど失敗した例と同様に「ClockText」と名前を付けたTextBlockコントロールを追加する。そして今度は、コードビハインドでバインドする際に、唯一の「Clock」オブジェクトを与えて「ClockProxy」クラスのインスタンスを生成して使う(次のコード)。
public MainPage()
{
……省略……
this.ClockText.DataContext = new ClockProxy(App.TheClock);
}
Public Sub New()
……省略……
Me.ClockText.DataContext = New ClockProxy(App.TheClock)
End Sub
このコードは「MainPage」画面のものだが、「SecondaryPage」画面も同様にする。
このようにプロキシクラスを画面にバインドしてやれば、唯一のバインディングソースのデータを複数のウィンドウ(アプリビュー)に表示できる(次の画像)。
プロキシクラス「ProxyClock」のオブジェクトを複数のウィンドウにバインドした(実行結果)
下段の白い文字で表示されているTextBlockコントロールが、「ProxyClock」オブジェクトをバインドしたもの。想定通りに、どのウィンドウ(アプリビュー)でも同じ連番(ここでは「[0]」)になっている。
まとめ
1つのバインディングソースのデータを複数のウィンドウ(アプリビュー)に表示するには、MとVMを分離すればよい。分離する方法の1つとして、プロキシクラスを作る方法を紹介した。
Windows 8.1を扱う大規模カンファレンスのご紹介
5月29日(木)〜5月30日(金)、マイクロソフトの最新技術情報(例えば本稿で解説したような内容)を日本語で日本人向けに提供するカンファレンス「de:code」が日本マイクロソフト主催で開催される。このカンファレンスは、米国時間で4月2〜4日に開催された「Build 2014」の内容をベースに、さらに日本向けのプラスアルファを含めたものになる。詳しい内容は(セッション内容は開催日までに決定していくとのこと)、リンク先を参照されたい。
Copyright© Digital Advantage Corp. All Rights Reserved.