アプリの中断時に、ユーザーが保存した(と認識している)データをきちんとファイルに保存する方法と、非同期処理に起因する問題、その解決策を解説する。
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を使っている。
*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の日本語版を追加インストールし、オプションダイアログで言語を切り替える必要がある。
本稿では、紛らわしくない限り次の略称を用いる。
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;
}
}
}
この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;
……省略……
}
これで実行してみると、ほとんどの場合には、テキストボックスに入力した文字列が中断時にファイルへ書き出されるはずだ。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());
}
これで実行してみてほしい(デバッグ実行ではなく、スタート画面から起動する)。中断時にファイルへデータが書き出されないはずだ。
なぜそんなことになるのかというと、NavigationHelper_SaveStateメソッドの返値がvoidだからである。Taskオブジェクトを返さないので、呼び出し元は非同期処理の完了を待ってくれないのだ。システムは、このファイル書き込み処理の完了を待たずに、中断状態へアプリを移行させてしまうのである。そのため、上の例のように全く書き込まれなかったり、複数行のファイルが途中で途切れてしまったりするのだ。
この問題を解決するには、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」メソッドがあるものとして書いてある。このメソッドはどのように作ればよいだろうか? いろいろな方法が考えられるが、ここでは排他ロックを使って実装してみよう(次のコード)。
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));
}
}
}
これで実行して、中断時の挙動を確かめてほしい(スタート画面から実行する)。今度は正常にファイルへ書き込まれるはずである。
中断時にデータをファイルへ書き出す場合、画面のコードビハインドで処理を行うと、その非同期処理の完了をシステムが待ってくれないために問題が生じる。解決するには、AppクラスのOnSuspendingメソッドの中でファイル書き込みの完了を待つ工夫をする。
Copyright© Digital Advantage Corp. All Rights Reserved.