[win8] OpenStreamForWriteAsync は上書きモードで開くので注意せよ

StorageFile の Open は、通常の Open/Close とちょっと違う | Moonmile Solutions Blog
http://www.moonmile.net/blog/archives/3880

の続きです。

Windows ストア アプリでは、fopen/fclose のようにファイル名を指定して、ファイルの読み書きができません。サンドボックス的なセキュリティを維持するために常に StorageFile クラスを使います。このクラスは、アプリ ローカルのフォルダから直接作るか、ピッカー(FileSavePicker など)を使って作ります。

ってのが前回の話で、FileSavePicker から戻ってきたときには、ファイルが既に作られています、ってのがちょっとした「落とし穴」なのです。

■ピッカーで保存先のファイルを選択

ピッカーを使って、デスクトップに保存するファイルを選んでね、ってのが次のコードです。

private StorageFile _file;
/// <summary>
/// ファイル選択のみ
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private async void Button_Click_0(object sender, RoutedEventArgs e)
{
	// FileSavePicker を用意する
	var picker = new FileSavePicker();
	picker.CommitButtonText = "データを保存する";
	picker.DefaultFileExtension = ".txt";
	picker.FileTypeChoices.Add("テキスト形式", new string[] { ".txt" });
	picker.SuggestedStartLocation = PickerLocationId.Desktop;
	picker.SuggestedFileName = "Sample.txt";
	// FileSavePicker を出して、ファイルを指定する。
	StorageFile file = await picker.PickSaveFileAsync();
	if (file == null)
	{
		// キャンセルされたときは何もしない
		return;
	}
	_file = file;
}

ピッカーの PickSaveFileAsync メソッドの戻り値は、StorageFile オブジェクトなので、キープしておくと、あとから利用できます。いわゆる2度目に「保存」ボタンを押したときには、ユーザーに問い合わせをしない、ことが可能です。上書き保存ってやつです。

ここで、保存先のファイルってのは新規ファイルとは限らなくて、存在するファイル(中身があるファイル)でも ok なわけです。まあ、そうですよね。なので PickSaveFileAsync メソッドからの戻りは、

1.PickSaveFileAsync が作った空ファイル(サイズ 0 のファイル)
2.元からある、いくらかのサイズのファイル。
3.元からある、サイズ 0 のファイル

ここで、1 と 3 の区別(新規ファイルなのか、既存ファイルなのか)がつかないのが問題…になるかもしれんッ!!! と思ったわけですが、これから上書きしようとするわけだし、どっちでも構わないんじゃない?というのが設計思想のようです。

■保存するモデル

アプリのデータを DataModel にしておきます。

public class DataModel : BindableBase
{
	private int _id;
	public int ID
	{
		get { return _id; }
		set { _id = value; this.SetProperty(ref this._id, value); }
	}
	private string _name;
	public string Name
	{
		get { return _name; }
		set { _name = value; this.SetProperty(ref this._name, value); }
	}
}

画面のほうには、適宜 Binding を書く、というパターンですね。
クラスにしておくとシリアライズが楽なので。Windows ストア アプリの場合は、DataContractSerializer を使うと便利です。

■DataContractSerializer を使う

モデルデータを、そのままシリアライズするのが一番楽です。既に用意されている DataContractSerializer を使って、WriteObject メソッドを使って XML 形式で書き込めば okですね。

private async void Button_Click_1(object sender, RoutedEventArgs e)
{
	StorageFile file = this._file;	// 選択済み
	var data = new DataModel()
	{
		ID = 10,
		Name = "masuda tomoaki"
	};
	// これは正常に動く
	using (var stream = await file.OpenStreamForWriteAsync())
	{
		var serializer = new DataContractSerializer(typeof(DataModel));
		serializer.WriteObject(stream, data);
	}
}

ただ、DataContractSerializer クラスで書き出す XML って、改行とかインデントがついていなくて、デバッグに不都合があるのです。
なので、XmlWriter を使えば整形できるのでは?と思ったのが、ハマリどころです。

■XmlWriter を使う

XmlWriter を使って整形(改行やインデント)を使いながら、書き出します。

private async void Button_Click_2(object sender, RoutedEventArgs e)
{
	StorageFile file = this._file;	// 選択済み

	var data = new DataModel()
	{
		ID = 10,
		Name = "masuda tomoaki"
	};
	// これは正常に動く
	using (var stream = await file.OpenStreamForWriteAsync())
	{
		var serializer = new DataContractSerializer(typeof(DataModel));
		// ストリームを切り詰める
		stream.SetLength(0);		// これが必須
		// インデント等を設定する
		var st = new XmlWriterSettings();
		st.Indent = true;
		st.IndentChars = "\t";
		st.Async = false;        // 書き込み時に async を使わない
		using (var xw = XmlWriter.Create(stream, st))
		{
			serializer.WriteObject(xw, data);
		}
	}
}

この中で「stream.SetLength(0)」ってのがあります。これ、ファイルは「新規だ」と思っていると、実は違っていて「既存のファイル」かもしれないのです。で、既存のファイルを OpenStreamForWriteAsync メソッドで開くと、中身はそのままオープンって具合なわけです。

なので、もし既存のファイルよりも短い XML を書き込んだ場合、最後の閉じタグの後ろにゴミが残るんですよね…これはかなりはまりました。
fopen 関数だと、新規オープンなのか、追加オープンなのかの選択があるのですが、実は OpenStreamForWriteAsync には、その選択肢がないのです。さらに、PickSaveFileAsync では、新規のファイルでも 0 バイトのファイルができるし。
OpenStreamForWriteAsync の場合は、ファイルをオープンすると、先頭にカーソル位置が置かれるのですが、これは既存ファイルをオープンしたときも先頭にカーソルがあります。ってのが落とし穴ですね。

なので、この変な状態を解決するために「新規ファイル」として扱うためには「stream.SetLength(0)」のように、ストリームの長さを 0 に初期化する必要があるのです。

■テキストファイルに保存

そんな訳で、メモ帳のようなテキストファイルを扱うアプリを作ると更にはまります。

private async void Button_Click_3(object sender, RoutedEventArgs e)
{
	StorageFile file = this._file;	// 選択済み
	// 実は上書きモードでオープンしている
	using (var stream = await file.OpenStreamForWriteAsync())
	{
		// 新規扱いの場合は、サイズを 0 にする必要あり
		stream.SetLength(0);

		using (var writer = new StreamWriter(stream))
		{
			// テキストをそのまま書き込み
			writer.Write(text1.Text);
		}
	}
}

毎回、全体を保存するから、StreamWriter クラスの Write メソッドで書き出せば ok … と思いきや、この「stream.SetLength(0)」を忘れると、もともとあったファイル長さよりも文章(text1.Text)の方が短い場合には、後ろにごみが残るのです。

■Stream にも StreamWriter にも Close がない

で、Stream も StreamWriter も Close がありません。Open したら Close したくなるものなのですが、まあ、開きっぱなしでいいみたいです。というか、using で範囲を決めないとダメかもしれませんね。
テキストの書き込みだと、StreamWriter クラスの using で抜けるときに、同時に Stream のほうも閉じてくれます…と云いますか、閉じてしまいます。これは、以前からの仕様だったような気がします。

なので、ファイルの後ろを切り詰めようとして次のように書くとエラーになります。

private async void Button_Click_3(object sender, RoutedEventArgs e)
{
	StorageFile file = this._file;	// 選択済み
	// 実は上書きモードでオープンしている
	using (var stream = await file.OpenStreamForWriteAsync())
	{
		using (var writer = new StreamWriter(stream))
		{
			// テキストをそのまま書き込み
			writer.Write(text1.Text);
		}
		// 現在位置で切り詰める
		stream.SetLength( stream.Position );	// ★
	}
}

★の位置では、stream が閉じられちゃっているので、SetLength できません。
下記のように、StreamWriter のブロックで SetLength するとうまく動きます。

private async void Button_Click_3(object sender, RoutedEventArgs e)
{
	StorageFile file = this._file;	// 選択済み
	// 実は上書きモードでオープンしている
	using (var stream = await file.OpenStreamForWriteAsync())
	{
		using (var writer = new StreamWriter(stream))
		{
			// テキストをそのまま書き込み
			writer.Write(text1.Text);
			// 現在位置で切り詰める
			stream.SetLength( stream.Position );	// ★
		}
	}
}

これもなんか変な仕様ですよね…と思いつつ、まぁ、いいかと。

OpenStreamForWriteAsync メソッドに、新規バージョンと追加バージョンがあればわかりやすいんですがね。

・OpenStreamForNewWriteAsync()
・OpenStreamForAppendWriteAsync()

のような名前にすればよかったのに。。。まあ、拡張メソッドを2,3行書けば、済むはなしなんですが。

カテゴリー: C#, windows 8 パーマリンク

[win8] OpenStreamForWriteAsync は上書きモードで開くので注意せよ への1件のコメント

  1. masuda のコメント:

    ちなみに、後ろにごみが残る現象(上書きなので正常な動作です)は、DataContractSerializer だけ使った場合には発生しません。なぜか DataContractSerializer+XmlWriterの組み合わせでだと発生します。これ、ハマるよッ!!!
    DataContractSerializer だけの場合も、前のファイルのごみが残ってもいいのだが、ごみが残らない。不思議です。

コメントは停止中です。