Windows フォームでドキュメント中心のアプリケーションを作成する (第 1 部)
Chris Sells Microsoft Corporation
September 2, 2003 日本語版最終更新日 : 2004 年 3 月 26 日
概要: 適切な保存機能、ファイル名と状態に基づいたキャプション更新機能、およびコマンド ラインから渡したドキュメントを開く機能を備えた、SDI アプリケーション用の管理ドキュメント処理機能の実装について、概要を説明します。
サンプル ファイル (wfdocs1src.msi) のダウンロード
私にとっては日常茶飯事のことですが、何かを読んでいて内容に疑問があると、それを自分の目で確認するためにコンピュータ プログラムを記述しないわけにはいかなくなります。今回は、ある投資信託が公表しているという、数年分の年間利益に対する年換算利益率が疑問になりました。その筆者の主張は年換算利益率が 24.91% になることが数字からわかるというものではないかと推測しましたが、私はそのことを確認して "平均利益率" と "年換算利益率" の違いを理解する必要がありました (年換算利益は使ってもかまわないが、平均利益率は役に立たないという結論がはじき出されました)。この任務を念頭において、図 1 に示す RatesOfReturn アプリケーションをビルドしました (筆者の提示した数値が設定されています)。

図 1. 動作中の RatesOfReturn アプリケーション
あいにく、このアプリケーションをビルドした後になって、筆者が報告している数値が実際の率ではなく、同時期の S&P 500 の上昇値であることに気づきました。自分を浅はかだと思いましたが、気持ちを他に向けることにしました。
ドキュメント ベースのアプリケーション
図 1 に示したアプリケーションは、データ グリッド、データ セットが変更されたら ListChanged イベントを公開するデータ ビュー、アプリケーションのデータ構造を表わす型指定されたデータ セット、およびそれらのバインドを含めた大部分を Visual Studio® .NET のデザイナ画面でビルドしました。パーセンテージおよび額面を表示する列の形式まで、データ グリッドのテーブル スタイル エディタを使用して定義しました。事実、ドキュメントに関するメニュー項目 ([ファイル] メニューの [開く] など) の実装を除くと、あらかじめデータ セットに最初の行を設定する Form.Load イベント ハンドラと、平均利益率と年換算利益率の計算を実装する DataView.ListChanged イベント ハンドラのコードを記述するだけですみました。
void Form1_Load(object sender, System.EventArgs e) {
// 開始時の元金を追加します
this.periodReturnsSet1.PeriodReturn.AddPeriodReturnRow(
"start", 0M, 1000M);
}
void dataView1_ListChanged(object sender, ListChangedEventArgs e) {
// 平均利益と年換算利益を計算します
...
}
この、宣言による開発が非常に楽しかったのは、新しく設定したデータ セットを後で使用するためにディスクに保存するところまででした。Windows フォームと Visual Studio .NET を使用すれば、データ連結を使用したアプリケーションを簡単に記述するためのあらゆるサポートが提供されますが、ドキュメント ベースのアプリケーションの場合は、どのような状況でも主な MFC (Microsoft Foundation Classes) プログラマにサポートが実際に提供されることはありません。(多種多様なアプリケーションを処理できるだけの十分な柔軟性が MFC に備わっているとはいえ、) ドキュメント ベースではなかったアプリケーションが、MFC モデルに合わせてドキュメント ベースのアプリケーションに変更されることがよくあります。MFC は、そのようなアプリケーションのサポートでは効果的です。
一方、Windows フォームは、データ中心のアプリケーションをビルドするときには優れているのですが、ドキュメント ベースのアプリケーションはほとんどサポートしていません。[ファイル] メニューを配置したり、ファイル ダイアログを表示することを簡単に済ませ、.NET のシリアル化スタックを使用してデータ セットのコンテンツを難なくディスクにダンプしたにもかかわらずです。
using System.IO;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Formatters;
using System.Runtime.Serialization.Formatters.Soap;
...
void fileSaveMenuItem_Click(object sender, EventArgs e) {
if( this.saveFileDialog1.ShowDialog(this) != DialogResult.OK ) {
return;
}
string fileName = this.saveFileDialog1.FileName;
using( Stream stream = new FileStream(
fileName, FileMode.Create, FileAccess.Write) ) {
// オブジェクトをテキスト形式にシリアル化します
IFormatter formatter = new SoapFormatter();
formatter.Serialize(stream, this.periodReturnsSet1);
}
}
この機能は、すべて .NET に組み込まれています。その場所は、System.Windows.Forms 名前空間、System.IO 名前空間、System.Runtime.Serialization 名前空間などに分散していますが、いずれもいつでも利用できます。
しかし、ドキュメント ベースのアプリケーションには、ファイル ダイアログを表示したり、オブジェクトのコンテンツをファイルにダンプする以外にも、多くの機能が必要になります。Microsoft Windows® ユーザーが最低限望む機能を実現するためには、どのような小規模のシングル ドキュメント インターフェイス (SDI) アプリケーションであれ、以下の機能が必要になります。
- 現在読み込まれているドキュメントをフォームのキャプションに表示する機能 (Untitled.txt など)。
- ドキュメントがディスクに保存された状態から変更されていることを、ファイル名の横にアスタリスクを付ける (Untitled.txt*) などして表示する機能。
- ユーザーが変更したドキュメントを保存せずに閉じようとした場合に、保存を尋ねるダイアログを表示する機能。
- 現在のドキュメントを変更するごとにユーザーがファイル名を指定しなくても、変更内容を保存できる機能。たとえば、まずいったん保存した状態で [ファイル] メニューの [保存] を実行するか、[ファイル] メニューの [名前を付けて保存] を実行するかの違いです。
- 新しいドキュメントを作成するときに、現在アクティブなドキュメントをすべて消去する機能。
- 以前に保存したファイルを開く機能。
以上の機能は、プログラマ レベルでは以下のものを組み合わせて実装できます。
- ドキュメントが変更されたときに発生するイベント。
- キャプションを設定したり、保存を尋ねるダイアログを必要に応じて表示するときに使用する、ディスク上のコピーと比較したドキュメントの状態を追跡する、"ダーティ ビット (未保存の変更の有無を示すビット)"。
- キャプションを設定したり、最初の保存の後の [ファイル] メニューの [保存] を実装するときに使用する "現在のファイル名"。
- [ファイル] メニューに関連するさまざまなイベントのハンドラ ([ファイル] メニューの [新規作成]、[ファイル] メニューの [保存] など)。
上記の部品を一括してまとめる機能は、Windows フォームには一切提供されていませんが、SDI サンプルには簡単に追加できます。
ダーティ ビットの追跡
ドキュメントの変更を認識するタイミングは、アプリケーションによって異なります。今回のサンプルの場合、ドキュメント データの変更を DataView.ListChanged イベントで認識しています。
// ダーティ ビットを追跡します
bool dirty = false;
void SetDirty(bool dirty) {
this.dirty = dirty;
SetCaption();
}
// アプリケーション名、ファイル名、ダーティ ビットを基にキャプションを設定します
void SetCaption() {...}
void dataView1_ListChanged(object sender, ListChangedEventArgs e) {
...
// ダーティ ビットを更新します
SetDirty(true);
}
ListChanged イベントが発生したときに、ダーティ フィールドを直に設定するのではなく、SetDirty を呼び出しています。この方法が優れているのは、SetDirty により、更新されたダーティ フィールドで新しいキャプションも設定される点にあります。このことを忘れてダーティ フィールドを直接設定してしまった場合は、SetCaption メソッドも忘れずに呼び出す必要があります。
現在のファイル名の追跡
現在アクティブになっているファイルの名前も、ダーティ ビットと同様にドキュメント ベースのアプリケーションが実行されている間に変更されます。ファイル名は主に、[ファイル] メニューの項目を実装するときに変更されますが、現在のファイル名に対する基本的なサポートは、次のように実装することができます。
// 現在のファイル名を追跡します
string fileName = null;
void SetFileName(string fileName) {
this.fileName = fileName;
SetCaption();
}
static bool Empty(string s) { return s == null || s.Length == 0; }
// アプリケーション名、ファイル名、ダーティ ビットを基にキャプションを設定します
void SetCaption() {
this.Text = string.Format(
"{0} - [{1}{2}]",
Application.ProductName,
Empty(this.fileName) ?
"Untitled.ror" :
Path.GetFileName(this.fileName),
this.dirty ? "*" : "");
}
なお、補足になりますが、次の行に注目してください。
アプリケーション - [ファイル名*]
キャプションをこのような形式にしたのには、理由があります。メモ帳や Office の場合は次のような形式が標準です。
ファイル名* - アプリケーション
前者の形式を選んだ理由は、Windows フォームのマルチ ドキュメント インターフェイス (MDI) の実装形態と一貫性を持たせるためです。MDI にも前者の形式が採用されています。メモ帳や Microsoft Office では逆に、後者の形式が採用されています。ドキュメント中心の MDI アプリケーションについては、本連載の第 2 部で紹介します。
最後に、ファイル名は "Untitled.ror" ではなく Null で始まっていることに注目してください。Null は、ユーザーがまだファイル名を選択していないことを Save ファミリのメソッドに示すサインです。
[ファイル] メニューの [保存] とその仲間
[ファイル] メニューの [保存]、[名前を付けて保存]、[コピーに名前を付けて保存] は、ドキュメント データのコンテンツをディスクに保存する方法が微妙に異なるため、この 3 つを実装する作業は、今回の全体のプロセスの中でも最も複雑です。次に、Windows アプリケーションに期待する動作 (および MFC が公開している動作) を一覧します。
- [保存] の動作は以下のとおりです。
- 現在のファイル名がない場合に、ファイルの保存ダイアログを表示します。
- ドキュメント データをディスクにシリアル化します。
- ダーティ ビットを消去します。
- 現在のファイル名がまだない場合に設定します。
- フォームのキャプションを、ダーティ ビットの新しい状態およびこのとき付けている新しいファイル名で更新します。
- [名前を付けて保存] の動作は以下のとおりです。
- ファイルの保存ダイアログを常に表示します。
- ドキュメント データをディスクにシリアル化します。
- ダーティ ビットを消去します。
- 現在のファイル名を新しいファイル名に設定します。
- フォームのキャプションを更新します。
- [コピーに名前を付けて保存] の動作は以下のとおりです。
- ファイルの保存ダイアログを常に表示します。
- ドキュメント データをディスクにシリアル化します。
- ダーティ ビットの消去は行いません。
- 現在のファイル名を新しいファイル名には設定しません。
- フォームのキャプションは更新しません。
上記の一連の動作を実装する方法の一例を次に示します。
protected enum SaveType {
Save,
SaveAs,
SaveCopyAs,
}
bool SaveDocument(SaveType type) {
// ファイル名を取得します
string newFileName = this.fileName;
if( (type == SaveType.SaveAs) ||
(type == SaveType.SaveCopyAs) ||
Empty(newFileName) ) {
if( !Empty(newFileName) ) {
saveFileDialog1.InitialDirectory =
Path.GetDirectoryName(newFileName);
saveFileDialog1.FileName =
Path.GetFileName(newFileName);
}
else {
saveFileDialog1.FileName = "Untitled.ror";
}
DialogResult res = saveFileDialog1.ShowDialog(this);
if( res != DialogResult.OK ) return false;
newFileName = saveFileDialog1.FileName;
}
// データを書き込みます
try {
using( Stream stream = new FileStream(
newFileName, FileMode.Create, FileAccess.Write) ) {
// オブジェクトをテキスト形式にシリアル化します
IFormatter formatter = new SoapFormatter();
formatter.Serialize(stream, this.periodReturnsSet1);
}
}
catch( Exception e ) {
// エラーの報告...
return false;
}
if( type != SaveType.SaveCopyAs ) {
// ダーティ ビットを消去して、現在のファイル名を設定します
// キャプションが自動的に設定されます
SetDirty(false);
SetFileName(newFileName);
}
// 成功
return true;
}
SaveDocument メソッドは、実行している保存の種類 ([保存]、[名前を付けて保存]、[コピーに名前を付けて保存] のいずれか) を示す引数を受け取ります。[保存] はまだファイル名がない場合にのみ新しいファイル名を指定する必要があります。一方、[名前を付けて保存] および [コピーに名前を付けて保存] は、いずれも毎回ファイル名を指定する必要があります。[保存] および [名前を付けて保存] はダーティ ビットを消去して、新しいファイル名を現在のファイル名として設定しますが、[コピーに名前を付けて保存] は、そのいずれも行いません。ただし、3 種類とも、選択したファイルにドキュメント データを書き込むことは共通です。
さまざまなモードに対応する Save メソッドを配置したら、3 つのメニュー オプションは簡単に実装できます。
void fileSaveMenuItem_Click(object sender, EventArgs e) {
SaveDocument(SaveType.Save);
}
void fileSaveAsMenuItem_Click(object sender, EventArgs e) {
SaveDocument(SaveType.SaveAs);
}
void fileSaveCopyAsMenuItem_Click(object sender, EventArgs e) {
SaveDocument(SaveType.SaveCopyAs);
}
[ファイル] メニューの [新規作成]
ここまで、メニュー項目のクリック ハンドラが SaveDocument メソッドを確認しなくてもブール値が返される理由についての説明がまだでした。[保存] 操作が、現在のデータをアンロードして新しいデータに置き換えるために必要な操作 ([ファイル] メニューの [新規作成] で行われる動作など) の一部になっている場合、[保存] 操作の結果が重要になります。
[新規作成] の動作は以下のとおりです。
- ドキュメント データに未保存の変更がある場合、変更を保存するか、変更を破棄するか、[新規作成] 操作をキャンセルするかを尋ねるダイアログが表示されます。
- ユーザーが保存を選択した場合、Save(Save) が呼び出され、ファイル名を入力するダイアログが必要に応じて表示されます。Save メソッドが失敗した場合、ユーザーによる変更を保ったまま [新規作成] 操作が中止されます。失敗になるのは、ディスクに空きがない場合、またはユーザーがファイル ダイアログで [キャンセル] をクリックした場合です。
- ユーザーが [新規作成] 操作をキャンセルした場合、データは保存されず、新しいドキュメントも作成されません。
- ダーティ ビットが消去されている場合、ユーザーが変更の保存を選択しない場合、変更を保存してある場合のいずれかであれば、ドキュメントは作成時点と同様に "New" 状態にリセットする必要があります。
ユーザーが [ファイル] メニューの [新規作成] を選択したときにダーティ ビットが設定されている場合、ドキュメント データの変更に対する操作を選択する図 2 のようなメッセージ ボックスが表示されます。

図 2. ドキュメントの変更に対する操作を選択するダイアログ ボックス
これから説明する [ファイル] メニューの [開く] をサポートするには、[新規作成] の動作を、2 つのメソッド NewDocument および CloseDocument に分割するのが最も簡単です。
public bool NewDocument() {
// ファイルを閉じることができるかどうかを確認します
if( !CloseDocument() ) return false;
// 既存のデータを消去します
...
// ドキュメント データと状態を初期設定します
this.periodReturnsSet1.PeriodReturn.AddPeriodReturnRow(
"start", 0M, 1000M);
SetDirty(false);
SetFileName(null);
return true;
}
bool CloseDocument() {
// 以下はすべてダーティ ビットに関するロジックです...
if( !this.dirty ) return true;
DialogResult res = MessageBox.Show(
this,
"Save changes?",
Application.ProductName,
MessageBoxButtons.YesNoCancel);
switch( res ) {
case DialogResult.Yes: return SaveDocument(SaveType.Save);
case DialogResult.No: return true;
case DialogResult.Cancel: return false;
default: Debug.Assert(false); return false;
}
}
CloseDocument メソッドはダーティ ビットを確認し、既に消去されている (保存するデータの変更がないことを示しています) 場合は成功と報告します。保存する変更があるときは選択肢を 3 つ表示し、SaveDocument メソッドが使用されている場合は、成功か失敗かに応じて、ドキュメントが正常に保存できるかどうかを判断します。
ドキュメントを閉じることができる場合は、NewDocument メソッドで古いデータを削除し、初期状態の新しいデータ (今回の例のシード行など) を設定し、ダーティ ビットと現在のファイル名を消去し、ユーザーに代わってキャプションを更新します。
NewDocument を適切に配置したら、このメソッドを使用して、メイン フォームのコンストラクタと [ファイル] メニューの [新規作成] メニュー項目を両方とも実装できます。
public RatesOfReturnForm() {
// Windows フォーム デザイナのサポートに必要です
InitializeComponent();
// 新しいドキュメントを作成します
// 注 : Load ではこれを実行できません。実行してしまうと、
// コマンド ラインからドキュメントを開くことが難しくなります
NewDocument();
}
void fileNewMenuItem_Click(object sender, EventArgs e) {
NewDocument();
}
[ファイル] メニューの [開く]
[開く] の動作は以下のとおりです。
- データ ドキュメントを閉じることができるかどうかをチェックし、データに未保存の変更がある場合は、保存するかどうかを尋ねるダイアログを表示します。
- ドキュメントを閉じることができる場合は、ファイルを開くダイアログを表示して、ユーザーが開くファイルを選択できるようにします。
- 選択したファイルをシリアル化解除します。
- ダーティ ビットを消去します。
- 現在のファイル名を設定します。
- キャプションを更新します。
[開く] の基本となるのは、[新規作成] 動作の組み合わせ (現在のデータを閉じて保存できるかどうかを確認し、ユーザーとファイルを選択するための対話処理を行い、選択に応じてキャプションを更新する) です。この動作は OpenDocument で実装します。
public bool OpenDocument(string newFileName) {
// 現在のファイルを閉じることができるかどうかをチェックします
if( !CloseDocument() ) return false;
// 開くファイルを取得します
if( Empty(newFileName) ) {
DialogResult res = openFileDialog1.ShowDialog(this);
if( res != DialogResult.OK ) return false;
newFileName = openFileDialog1.FileName;
}
// データを読み取ります
try {
using( Stream stream = new FileStream(
newFileName, FileMode.Open, FileAccess.Read) ) {
// オブジェクトをテキスト形式からシリアル化解除します
IFormatter formatter = new SoapFormatter();
PeriodReturnsSet
ds = (PeriodReturnsSet)formatter.Deserialize(stream);
// 既存のデータを消去します
...
// データ連結には手を加えずに、新しいデータにマージします
...
}
}
catch( Exception e ) {
// エラーの報告...
return false;
}
// ダーティ ビットを消去して、現在のファイル名を設定します
// キャプションを設定します
SetDirty(false);
SetFileName(newFileName);
// 成功
return true;
}
[ファイル] メニューの [開く] は、OpenDocument メソッドを呼び出す要領で簡単に実装できます。
void fileOpenMenuItem_Click(object sender, EventArgs e) {
OpenDocument(null);
}
OpenDocument 関数は、省略可能なパラメータとしてファイル名を引数に取りますが、この引数が Null の場合はダイアログを表示しないことに注目してください。これまでに紹介した他のメソッドとは異なり、OpenDocument は public にしてあることにも注目してください。この 2 点によって、コマンド ラインから渡したオプション ファイルを使用して、ドキュメントを開くことができます。
コマンド ラインを使用して開く
すべての .NET アプリケーションは、コンソール アプリケーションか Windows アプリケーションかを問わず、最初に起動するときに渡される省略可能なコマンド ライン引数に、同じようにアクセスできます。これらの引数を取得するには、アプリケーションの Main メソッドに渡す文字列配列を使用します。ウィザードにより既定で生成されるコードは以下のようなものであり、コマンド ライン引数は無視されます。
static void Main() {
Application.Run(new Form1());
}
適切に配置された OpenDocument でコマンド ライン引数を処理するコードを以下に示します。
using System.IO;
...
static void Main(string[] args) {
// コマンド ライン引数を考慮してメイン フォームを読み込みます。
RatesOfReturnForm form = new RatesOfReturnForm();
if( args.Length == 1 ) {
form.OpenDocument(Path.GetFullPath(args[0]));
}
Application.Run(form);
}
相対ファイル名である可能性がある文字列を、完全パス名に変換する、System.IO 名前空間の Path.GetFullPath メソッドの使用法に注目してください。ファイル ダイアログと同様に、常に完全パス名を扱うようにするには、このメソッドが役立ちます。
もちろん、コマンド ライン処理が最も役立つのは、カスタムのファイル拡張子がシェルに認識されているときで、その場合、アプリケーションのファイルをエクスプローラ上でダブルクリックするとアプリケーションが起動されて、ファイルが引数として渡されます。Windows フォーム アプリケーションに対してシェルをこのように動作させる方法については、本連載の第 2 部に掲載します。
ファイルを閉じることと、[ファイル] メニューの [終了]
今回の簡易ドキュメント処理機能の締めくくりとして、ユーザーが、ドキュメントを変更してフォームの [閉じる] (右上隅の X) ボタンをクリックするか、アプリケーションを終了した場合に、データが保存されるようにします。
[閉じて終了する] の動作は以下のとおりです。
- データ ドキュメントを閉じることができるかどうかをチェックし、データに未保存の変更がある場合は、保存するかどうかを尋ねるダイアログを表示します。
- ドキュメントを閉じることができる場合は閉じるか終了します。閉じることができない場合は、操作をキャンセルします。
既に NewDocument および NewDocument で使用するためにビルドした CloseDocument メソッドは、(閉じるのをキャンセルするよりも前であれば) フォームの Closing イベントの実装にも十分使用できます。
void RatesOfReturnForm_Closing(object sender, CancelEventArgs e) {
if( !CloseDocument() ) e.Cancel = true;
}
[ファイル] メニューの [終了] は、フォームを閉じて、Closing イベント ハンドラに、フォームを閉じてかまわないかどうかの判断を任せるだけで実装できます。
void fileExitMenuItem_Click(object sender, EventArgs e) {
this.Close();
}
今回のまとめ
読んでいた書籍に登場した数値を誤解していたという事実から始まって、SDI アプリケーション用の簡易ドキュメント処理機能の実装に行き着きました。この処理機能は、New、Save、SaveAs、SaveCopyAs、Open、Close、および Exit という適切な機能を持ち、現在のドキュメントのファイル名と変更状態を基にフォームのキャプションを最新に保ち、コマンド ラインから渡したドキュメントを開くことができます。
本連載の第 2 部では、このモデルを MDI アプリケーションに拡張するために必要な作業の紹介と、カスタムのファイル拡張子など、より深いレベルでのシェル統合、および [スタート] ボタンの [ドキュメント] メニューにファイルを追加する方法に関する解説を掲載します。最後には、Alan Cooper も書いているように、この汎用的なドキュメント処理機能を、再利用可能なコンポーネントとして構成することにより、作業の大部分をデザイナ画面で済ますことができるようになるという利点について論じます。
参考資料
Chris Sells は、MSDN Online の Content Strategist です。現在の担当は、Microsoft の次期オペレーティング システム Longhorn です。主な著書は『Mastering Visual Studio .NET』、『Windows Forms for C# Programmers』などです。空いた時間には、さまざまな会議を催したり、ソース開示プロジェクト Genghis を主宰したり、Rotor を操ったりしていますが、たいていは、ブログの世界で疎ましがられています。Chris と各種プロジェクトの詳しい情報については、http://www.sellsbrothers.com (英語) を参照してください。
|