中断時にユーザーデータをファイルに保存するには?[ユニバーサルWindowsアプリ開発]:WinRT/Metro TIPS
アプリの中断時に、ユーザーが保存した(と認識している)データをきちんとファイルに保存する方法と、非同期処理に起因する問題、その解決策を解説する。
powered by Insider.NET
Windowsランタイムアプリ*1では、中断時にデータを保存すべしという。プロジェクトテンプレートに従ってセッションデータを保存する方法は、それほど難しくなかった。ところが、独自のファイルにデータを保存しようとすると、非同期処理に起因するトラブルに見舞われることがある。本稿では、その問題と解決方法を解説する。なお、本稿のサンプルは「Windows Store app samples:MetroTips #98」からダウンロードできる。
*1 このMSDNのWebページは、2014年12月下旬に改訂された。以前は「ストアアプリ」と記述されていた部分が「Windowsランタイムアプリ」に変更されている。
事前準備
ユニバーサルプロジェクトを使ってユニバーサルWindowsアプリを開発するには、以下の開発環境が必要である。本稿では、無償のVisual Studio Community 2013 with Update 4を使っている。
- SLAT対応のPC*2
- 2014年4月のアップデート*3適用済みの64bit版Windows 8.1 Pro版以上*4
- Visual Studio 2013 Update 2(またはそれ以降)*5を適用済みのVisual Studio 2013(以降、VS 2013)*6
*2 SLAT対応ハードウェアは、Windows Phone 8.1エミュレーターの実行に必要だ。ただし未対応でも、ソースコードのビルドと実機でのデバッグは可能だ。SLAT対応のチェック方法はMSDNブログの「Windows Phone SDK 8.0 ダウンロードポイント と Second Level Address Translation (SLAT) 対応PCかどうかを判定する方法」を参照。なお、SLAT対応ハードウェアであっても、VM上ではエミュレーターが動作しないことがあるのでご注意願いたい。
*3 事前には「Windows 8.1 Update 1」と呼ばれていたアップデート。スタート画面の右上に検索ボタンが(環境によっては電源ボタンも)表示されるようになるので、適用済みかどうかは簡単に見分けられる。ちなみに公式呼称は「the Windows RT 8.1, Windows 8.1, and Windows Server 2012 R2 update that is dated April, 2014」というようである。
*4 Windows Phone 8.1エミュレーターを使用しないのであれば、32bit版のWindows 8.1でもよい。
*5 マイクロソフトのダウンロードページから誰でも入手できる(このURLはUpdate 4のもの)。
*6 本稿に掲載したコードを試すだけなら、無償のExpressエディションやCommunityエディションで構わない。Visual Studio Express 2013 with Update 4 for Windows(製品版)はマイクロソフトのページから無償で入手できる。Expressエディションはターゲットプラットフォームごとに製品が分かれていて紛らわしいが、Windowsランタイムアプリの開発には「for Windows」を使う(「for Windows Desktop」はデスクトップで動作するアプリ用)。また、昨年11月12日(米国時間)に新しくリリースされたVisual Studio Community 2013 with Update 4(製品版)もマイクロソフトのページから無償で入手できる。Communityエディションは本稿執筆時点では英語版だけなので、同じ場所にあるVisual Studio 2013 Language Packの日本語版を追加インストールし、オプションダイアログで言語を切り替える必要がある。
用語
本稿では、紛らわしくない限り次の略称を用いる。
- Windows:Windows 8.1とWindows RT 8.1(2014年4月のアップデートを適用済みのもの)
- Phone:Windows Phone 8.1
サンプルコードについて
Visual Studio 2013 Update 2(Update 3/4も)では、残念なことにVB用のユニバーサルプロジェクトのテンプレートは含まれていない*7。そのため、本稿で紹介するコードはC#のユニバーサルプロジェクトだけとさせていただく。
*7 VB用のユニバーサルプロジェクトは、今年にリリースされるといわれているVisual Studio 2015(開発コード「Visual Studio 14」)からの提供となるようだ。「Visual Studio UserVoice」(英語)のリクエストに対する、2014年6月18日付けの「Visual Studio team (Product Team, Microsoft)」からの回答による。
中断時にデータをファイルに書き出してみよう
本稿では、「WinRT/Metro TIPS:中断時にセッションデータを保存するには?[ユニバーサルWindowsアプリ開発]」(以降、「前回」と呼ぶ)のサンプルコードを出発点とする。前回をご覧になっていない方は、先にお読みいただきたい。
中断時にデータをファイルに書き出す場合でも、その処理を行う場所は前回と同じである。すなわち、アプリ全体のデータを保存するならAppクラスのOnSuspendingメソッドで、画面ごとのデータを保存するならそのコードビハインドのNavigationHelper_SaveStateメソッドで行う。
前回はテキストボックスに入力した文字列をセッションデータ(アプリが中断されたときの状態を復元するのに必要なデータ)として中断時に保存したが、これをユーザーデータ(ユーザーが保存したと考えているデータ)として独自のファイルに書き出すように変更してみよう。
まず、データを保持しファイルに読み書きする「UserData」クラスを追加する(次のコード)。実際には複数のプロパティを扱う複雑なものになるはずだが、ここではサンプルということでプロパティは一つだけとした。
using System;
using System.Threading.Tasks;
using Windows.Storage;
namespace MetroTips098CS
{
public class UserData
{
// データを保存するファイル名
const string FileName = "UserData.txt";
// データを保存するフォルダー(LocalFolder)
private static StorageFolder DataFolder { get { return ApplicationData.Current.LocalFolder; } }
// テキストボックスの文字列
public static string TextBox1Text { get; set; }
// ファイルからデータを読み出す
public static async Task LoadAsync()
{
var file = await DataFolder.CreateFileAsync(FileName, CreationCollisionOption.OpenIfExists);
Deserialize(await FileIO.ReadTextAsync(file));
}
// データをデシリアライズする
private static void Deserialize(string fileText)
{
// このサンプルコードではファイルの中身がそのままTextBox1Textの値になっている
TextBox1Text = fileText;
}
// データをファイルに保存する
public static async Task SaveAsync()
{
var file = await DataFolder.CreateFileAsync(FileName, CreationCollisionOption.OpenIfExists);
// データをシリアライズして一度に書き出す
await FileIO.WriteTextAsync(file, Serialize());
}
// データをシリアライズする
static string Serialize()
{
// このサンプルコードではTextBox1Textの値がそのままファイルの中身になる
return TextBox1Text;
}
}
}
このクラスは共有プロジェクトに置く。
簡単にするため、以下のような点で実用的なコードとは異なっている。
使い勝手からは、プロパティはデータバインドに対応させた方がよい。ただしそうすると、プロパティに値をセットしたときにSaveAsyncメソッドを呼び出すという作りにせざるを得なくなる。すると、値に変化があったときだけに保存する処理や、短い時間間隔で(例えばテキストボックスでキーが押されるたびに)ファイル書き込みを発生させないためのタイマー処理などが必要になってくる。なお、そのような設計にしたとしても、後述する中断時にファイル書き込み処理が打ち切られるという問題は発生し得る(例えば、テキストボックスでキーを押すとほぼ同時にスタートボタンをタップしたときなど)。むしろ、タイマー処理を入れると、中断時のファイル書き込みに失敗する可能性が高くなるだろう。
データのシリアライズ/デシリアライズは、このコードでは簡略化している。実際にはXmlSerializerクラス(System.Xml.Serialization名前空間)やDataContractJsonSerializerクラス(System.Runtime.Serialization.Json名前空間)などを利用する。
ここではローカルフォルダーに保存しているが、ローミングフォルダーに保存する場合には、他のデバイスからデータがローミングされてきたときのイベントに対応する必要がある。そのイベントでデータを読み込み直し、データが変更されたことをUIに対して通知するためのイベントを発火させることになる。
なお、ファイルの読み書きに使っているFileIOクラス(Windows.Storage名前空間)は、Windows Phoneでも8.1から使えるようになったものだ(Windows Phone 8.0のときは使えなかった)。
このUserDataクラスを使うように画面のコードビハインドを変更する(次のコード)。
private async void NavigationHelper_SaveState(object sender, SaveStateEventArgs e)
{
// 前回のコード
//e.PageState["TextBox1Text"] = this.DefaultViewModel["TextBox1Text"] as string;
// 独自のデータファイルに保存する
UserData.TextBox1Text = this.DefaultViewModel["TextBox1Text"] as string;
await UserData.SaveAsync();
}
private async void NavigationHelper_LoadState(object sender, LoadStateEventArgs e)
{
……省略……
// 前回のコード
//if (e.PageState != null)
//{
// if (e.PageState.Keys.Contains("TextBox1Text"))
// {
// this.DefaultViewModel["TextBox1Text"] = e.PageState["TextBox1Text"] as string;
// }
//}
// 独自のデータファイルから取り出す
await UserData.LoadAsync();
this.DefaultViewModel["TextBox1Text"] = UserData.TextBox1Text;
……省略……
}
前回のコードからの変更点を太字で示す。これは画面のコードビハインドである。
UserDataクラスのLoadAsyncメソッドは、アプリの起動時に1回だけ呼び出せばよい。コードの簡素化のため、この場所に書いている。
NavigationHelper_SaveStateメソッドのシグネチャには、asyncキーワードの追加が必要だ。
これで実行してみると、ほとんどの場合には、テキストボックスに入力した文字列が中断時にファイルへ書き出されるはずだ。Windowsの場合は簡単にファイルを見られるので、確認してほしい。ローカルフォルダーの「UserData.txt」ファイルである(ローカルフォルダーの場所はWindows.Storage.ApplicationData.Current.LocalFolder.Pathプロパティで取得できる)。
非同期処理に起因するトラブル!
上のコードではファイルに書き出す処理がごく短時間で済んでいるため、あまり失敗することはないだろう。しかし運悪く失敗することがある。そのとき、何が起きるのだろうか?
ファイルに書き出す処理が長引いたときに何が起きるかを確認するため、UserDataクラスのSaveAsyncメソッドに一定時間待機するコードを追加する(次のコード)。
public static async Task SaveAsync()
{
var file = await DataFolder.CreateFileAsync(FileName, CreationCollisionOption.OpenIfExists);
// 書き出し処理が長引いたときの挙動を見るため、しばらく待機する
await Task.Delay(1000);
await FileIO.WriteTextAsync(file, Serialize());
}
ファイルを書き込む前に1秒間だけ待機するようにした(太字の部分)。ただし、awaitしているので、このメソッドの呼び出し元へは直ちに制御が返る。
これで実行してみてほしい(デバッグ実行ではなく、スタート画面から起動する)。中断時にファイルへデータが書き出されないはずだ。
なぜそんなことになるのかというと、NavigationHelper_SaveStateメソッドの返値がvoidだからである。Taskオブジェクトを返さないので、呼び出し元は非同期処理の完了を待ってくれないのだ。システムは、このファイル書き込み処理の完了を待たずに、中断状態へアプリを移行させてしまうのである。そのため、上の例のように全く書き込まれなかったり、複数行のファイルが途中で途切れてしまったりするのだ。
AppクラスのOnSuspendingメソッド内で待機して解決する
この問題を解決するには、AppクラスのOnSuspendingメソッドの中でファイル書き込みの完了を待てばよい(次のコード)。OnSuspendingメソッドでは、非同期処理の完了を待つようにシステムへ指示できるからだ。
private async void OnSuspending(object sender, SuspendingEventArgs e)
{
// テンプレートに記述されたGetDeferral〜deferral.Completeは、
// 非同期処理の完了を待つようにシステムへ指示する
var deferral = e.SuspendingOperation.GetDeferral();
……省略……
// テンプレートで生成されたコードのSaveAsyncメソッド呼び出しにより、
// 各画面のNavigationHelper_SaveStateメソッドが呼び出される
await SuspensionManager.SaveAsync();
// 画面のNavigationHelper_SaveStateメソッドで開始されたデータ保存処理が終わるのを待機する
await UserData.WaitFileIOAsync();
// 非同期処理が完了したことをシステムに通知する
deferral.Complete();
}
太字の部分を追加した。このUserDataクラスのWaitFileIOAsyncメソッドについては、すぐ次で説明する。
プロジェクトテンプレートで自動生成された部分に、非同期処理の完了をシステムに待たせる処理が入っている。引数eのSuspendingOperationプロパティのGetDeferralメソッドを呼び出すことで、このOnSuspendingメソッドが非同期処理を行うことをシステムに通知している。そして、メソッド末尾に置かれたCompleteメソッドの呼び出しで、非同期処理の完了をシステムに知らせているのだ。ただし、OnSuspendingメソッドに許された時間は5秒までであり(前回参照)、それを超えるとアプリは強制終了させられる(デバッグ実行時を除く)。
なお、画面遷移時にはユーザーデータを保存しない(=中断時に保存するだけでよい)というのであれば、UserDataクラスのSaveAsyncメソッドの呼び出しを、画面のNavigationHelper_SaveStateメソッドではなく、このAppクラスのOnSuspendingメソッドで行えばよい。その場合、WaitFileIOAsyncメソッドは必要ない。
上のコードは、UserDataクラスにファイル書き込みの完了を待つ「WaitFileIOAsync」メソッドがあるものとして書いてある。このメソッドはどのように作ればよいだろうか? いろいろな方法が考えられるが、ここでは排他ロックを使って実装してみよう(次のコード)。
public class UserData
{
// 以下に示したコード以外は前と同じ
// ファイル読み書きを排他制御するためのロック
private static AsyncLock _lock = new AsyncLock();
// ファイルの読み書きが完了するのを待機するメソッド
public static async Task WaitFileIOAsync()
{
using (await _lock.LockAsync())
{
return;
}
}
// ファイルに書き出す処理をロックで囲む。
// こちらでロックを保持している間(=usingブロックから抜け出すまで)は、
// 上のWaitFileIOAsyncメソッドはロック確保待ちとなりリターンできない。
public static async Task SaveAsync()
{
using(await _lock.LockAsync())
{
var file = await DataFolder.CreateFileAsync(FileName, CreationCollisionOption.OpenIfExists);
// 書き出し処理が長引いたときの挙動を見るため、しばらく待機する
await Task.Delay(1000);
await FileIO.WriteTextAsync(file, Serialize());
}
}
// ファイルからの読み出し処理もロックで囲んでおく。
// 画面遷移時にSaveAsyncメソッドが呼び出され、そのファイル書き込み処理が完了する前に、
// 再び元の画面へ戻す操作が行われる(すると、このLoadAsyncメソッドが呼び出される)可能性がある。
public static async Task LoadAsync()
{
using (await _lock.LockAsync())
{
var file = await DataFolder.CreateFileAsync(FileName, CreationCollisionOption.OpenIfExists);
Deserialize(await FileIO.ReadTextAsync(file));
}
}
}
太字の部分を追加した。
排他ロックに使っているAsyncLockクラスについては、「.NET TIPS:非同期:awaitを含むコードをロックするには?(AsyncLock編)[C#、VB]」を参照していただきたい。
これで実行して、中断時の挙動を確かめてほしい(スタート画面から実行する)。今度は正常にファイルへ書き込まれるはずである。
まとめ
中断時にデータをファイルへ書き出す場合、画面のコードビハインドで処理を行うと、その非同期処理の完了をシステムが待ってくれないために問題が生じる。解決するには、AppクラスのOnSuspendingメソッドの中でファイル書き込みの完了を待つ工夫をする。
Copyright© Digital Advantage Corp. All Rights Reserved.