AppDataに閉じ込めていた設定ファイルを持ち出せるようにするまで、何度も手を滑らせました。
失敗。
保存先を変更する処理を書いても、気づけば未保存フラグが点灯したままで別の例外が飛ぶ。これはマズい。
AppSettingsManagerを配布できる状態にするために、ウィンドウ寸法とテーマ反映、ReadyToRun三兄弟まで泣きながら整えた記録です。
対象読者
- MVVMで作ったWPF設定画面をAppData以外にも保存したいデスクトップ開発者
- コマンドパターンと未保存変更フラグの扱いにまだ迷いがある人
- 実行時コンパイルの一部をビルド時に済ませるオプションを有効にした単一ファイルをx64/x86/arm64で並行ビルドしたい人
記事に書いてあること
- テーママネージャーで色を即時差し替える理由とカラーパレットの翻訳方法
- 設定サービスの保存先変更処理がファイルコピー→保存→古いファイル削除の順で動く仕掛け
- ビルドスクリプトで同じプロジェクトを3アーキテクチャに単一ファイルとして発行する際のサイズ計測
前提にしたこと
- .NET 10 SDKとWPFの基本構造(App.xaml + ViewModels + Models)が分かっていること
%AppData% 配下にJSONを保存する典型的なWindowsアプリの構成
- PowerShellで
dotnet publishを複数回回したことがある程度の経験
UIの2カラムを維持するしかなかった理由
ScrollViewerの中で左右2カラムを維持すると、幅400〜2000px/高さ300〜1500pxのスライダーを全体で把握できます。
Grid.Columnを左右に分けて Theme/Language + Window サイズを左、Font/AutoSave/Path を右へ。視線の流れを崩さないために 20px の余白と Card 背景を固定しました。
App.xamlのDynamicResourceだけで完結させたかったので、Window.Resourcesにブラシを定義したうえで ThemeManager で Application.Resources を直接触っています。
未保存変更フラグの手触り
ThemeやWindowWidthを触った瞬間に 未保存変更フラグをtrueに設定、コマンドの実行可否判定も即座に更新します。
この状態でWindowを閉じると、MainWindow.OnClosingが メッセージボックス で Yes/No/Cancel を投げ、Cancelなら キャンセルフラグをtrueに設定して落ち着くまで閉じません。
意外だ。テレメトリがなくても、コマンドの状態変化だけでユーザーの心理的安全を確保できる。
保存処理の実装(SettingsViewModel.cs 254-262行目):
private void ExecuteSave(object? parameter)
{
SettingsService.SaveSettings(_settings);
HasUnsavedChanges = false;
((RelayCommand)SaveCommand).RaiseCanExecuteChanged();
OnPropertyChanged(nameof(SettingsFilePath));
OnPropertyChanged(nameof(SettingsFilePathDisplay));
OnPropertyChanged(nameof(LastUpdated));
}
保存ボタンを押すと、設定サービスに保存を委譲し、未保存フラグをfalseに戻します。その後、コマンドの実行可否変更イベントを手動で発火させて、Saveボタンが無効化されるようにしています。最後に、設定ファイルパスや最終更新日時などの表示用プロパティを更新して、UIに反映させます。
ThemeManagerをリソース辞書に頼らず書き換える
ResourceDictionaryをXAMLに追加する代わりに、Application.Current.Resources["BackgroundColor"] などのキーを直接差し替える方式にしました。
Lightテーマでは BackgroundColor = #FFFFFF、Darkになると #202020 へ。ListBoxItemのHoverは #F0F0F0 ではなく #3C3C3C で反転。
マウス操作でぱっと色が変わる方が「設定を編集している感」を残せたので、この実装に落ち着いています。
最初はXAMLのResourceDictionaryを動的に読み込む方式を試しました。しかし、テーマ切り替えのたびにResourceDictionaryを再読み込みする必要があり、パフォーマンスが気になりました。また、XAMLファイルを外部に置く必要があり、配布時のファイル構成が複雑になる懸念もありました。
そこで、Application.Current.Resourcesの辞書を直接操作する方式に切り替えました。この方式なら、テーマ切り替え時に必要なキーだけを更新できるため、パフォーマンス面でも有利です。さらに、コード内で色の定義を一元管理できるため、メンテナンスも楽になりました。
WindowGeometryとAppDataのJSON
WindowWidth/HeightのSliderは TickFrequency=100 にして Snap をON。
閉じる瞬間に SettingsService.LoadSettings → Width/Height を上書き → SaveSettings で %AppData%\AppSettingsManager\settings.json に記録されます。
起動直後は CenterScreen に出したいので MainWindow コンストラクタで SettingsService.LoadSettings を2回呼び、まず ViewModel に渡し、次に Windowサイズ反映。冪等なメソッドに助けられました。
設定の保存処理(SettingsService.cs 94-142行目):
public static void SaveSettings(AppSettings settings)
{
try
{
string settingsFilePath = GetSettingsFilePath(settings);
string settingsDirectory = Path.GetDirectoryName(settingsFilePath) ?? DefaultSettingsDirectory;
// ディレクトリが存在しない場合は作成
if (!Directory.Exists(settingsDirectory))
{
Directory.CreateDirectory(settingsDirectory);
}
// 最終更新日時を更新
settings.LastUpdated = DateTime.Now;
var options = new JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var json = JsonSerializer.Serialize(settings, options);
File.WriteAllText(settingsFilePath, json);
// デフォルトパスと異なる場合は、デフォルトパスにも保存先パスの情報を保存
if (settingsFilePath != DefaultSettingsFilePath)
{
// デフォルトディレクトリが存在しない場合は作成
if (!Directory.Exists(DefaultSettingsDirectory))
{
Directory.CreateDirectory(DefaultSettingsDirectory);
}
// デフォルトパスに保存先パスの情報のみを保存
var defaultSettings = new AppSettings
{
SettingsFilePath = settingsFilePath
};
var defaultJson = JsonSerializer.Serialize(defaultSettings, options);
File.WriteAllText(DefaultSettingsFilePath, defaultJson);
}
}
catch (Exception ex)
{
MessageBox.Show($"設定の保存に失敗しました: {ex.Message}",
"エラー", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
この処理では、まず保存先のディレクトリが存在するか確認し、なければ作成します。次に、最終更新日時を現在時刻に更新し、JSON形式でシリアライズしてファイルに書き込みます。特に重要なのは、カスタムパスに保存する場合でも、デフォルトパス(AppData)に保存先パスの情報を残す点です。これにより、次回起動時にカスタムパスから設定を読み込めるようになります。
AutoSaveとフッター警告の同期
AutoSaveトグルをオフにするだけで Slider も disable になるよう、Bindingの IsEnabled="{Binding AutoSave}" を先に仕込みました。
AutoSaveIntervalは 10〜300秒を10秒刻み。HasUnsavedChanges と組み合わせると「AutoSave切っているのに未保存警告が消えない」状態も再現できるので、ユーザーへの説明もしやすいです。
フッターは ListBox ではなく Grid で左側に LastUpdated、右側に Reset/Import/Export/Save の4ボタンを横並び。幅900pxでも崩れないことを手元で確認しています。
設定ファイルの住民票を移す
保存先を変更する処理は File.Copy → settings.SettingsFilePath更新 → SaveSettings → 旧ファイル削除(デフォルト以外の場合)という順番。
MessageBoxで上書き確認を挟み、成功後は _hasUnsavedChanges = false に戻すことで Saveボタンが消灯します。
パスをDドライブのPortableフォルダへ移しても落ちないよう、Directory.CreateDirectory で初期化してから書き込むようにしました。
設定ファイルの保存先を変更する処理(SettingsService.cs 150-211行目):
public static bool ChangeSettingsFilePath(AppSettings settings, string newFilePath)
{
try
{
string oldFilePath = GetSettingsFilePath(settings);
// 新しいディレクトリが存在しない場合は作成
string newDirectory = Path.GetDirectoryName(newFilePath) ?? string.Empty;
if (!string.IsNullOrEmpty(newDirectory) && !Directory.Exists(newDirectory))
{
Directory.CreateDirectory(newDirectory);
}
// 既存の設定ファイルが存在する場合は移動
if (File.Exists(oldFilePath) && oldFilePath != newFilePath)
{
// 新しい場所に既にファイルが存在する場合は上書き確認
if (File.Exists(newFilePath))
{
var result = MessageBox.Show(
$"新しい保存先に既にファイルが存在します。上書きしますか?\n{newFilePath}",
"確認",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (result != MessageBoxResult.Yes)
{
return false;
}
}
File.Copy(oldFilePath, newFilePath, true);
}
// 設定の保存先パスを更新
settings.SettingsFilePath = newFilePath;
// 新しい場所に保存
SaveSettings(settings);
// 古いファイルがデフォルトパスでない場合は削除(オプション)
if (oldFilePath != DefaultSettingsFilePath && File.Exists(oldFilePath))
{
try
{
File.Delete(oldFilePath);
}
catch
{
// 削除に失敗しても続行
}
}
return true;
}
catch (Exception ex)
{
MessageBox.Show($"設定ファイルの保存先変更に失敗しました: {ex.Message}",
"エラー", MessageBoxButton.OK, MessageBoxImage.Error);
return false;
}
}
この処理の流れは、まず新しいディレクトリが存在するか確認し、なければ作成します。次に、既存の設定ファイルがある場合は新しい場所にコピーします。新しい場所に既にファイルがある場合は、ユーザーに上書き確認を求めます。その後、設定オブジェクトの保存先パスを更新し、新しい場所に保存します。最後に、古いファイルがデフォルトパスでない場合は削除します。この順序を守ることで、データの損失を防ぎながら、安全に保存先を変更できます。
最初の実装では、古いファイルを先に削除してから新しい場所に保存していました。しかし、新しい場所への保存に失敗した場合、古いファイルも失われてしまう問題がありました。そこで、コピー→保存→削除の順序に変更し、安全性を確保しました。
実行時コンパイルの一部をビルド時に済ませるオプションを有効にした三兄弟で配布を割り切る
ビルドスクリプトは dist を削除 → win-x64 / win-x86 / win-arm64 の順で publish → dist直下へ単一ファイルをコピー、という定番の流れ。
x64版 AppSettingsManager-x64.exe は 68,787,071 bytes(約65.6MB)。x86版は約60.1MB、arm64版は61.5MB。
自己完結型 + 実行時コンパイルの一部をビルド時に済ませるオプション + ネイティブライブラリの自己展開 を全部ONにしたままでも、PowerShell 1本で終わる形に収めています。
最初はx64版だけをビルドしていました。しかし、ユーザーから「x86版も欲しい」「arm64版も欲しい」という要望があり、3つのアーキテクチャに対応することにしました。PowerShellスクリプトでループ処理を書けば、同じコマンドを3回実行するだけで済みます。
最初は、実行時コンパイルの一部をビルド時に済ませるオプションを有効にしていませんでした。でも、起動速度が遅いことに気づき、有効にしてみました。結果、起動時間が約2秒から約1秒に短縮されました。ファイルサイズは約5MB増えましたが、起動速度の向上は明らかでした。
各アーキテクチャでビルドする際のポイントは、-rオプションでランタイム識別子を指定することです。win-x64、win-x86、win-arm64の3つを順番に指定し、それぞれの成果物をdistフォルダにコピーします。ファイルサイズは、x64版が最も大きく、x86版とarm64版はやや小さめです。これは、各アーキテクチャで必要なネイティブライブラリのサイズが異なるためです。
CTA
実際に試した3ケース
- Default→Dドライブへの移行:
%AppData%\AppSettingsManager\settings.json から D:\PortableSettings\ へ移したところ、MessageBoxで上書き確認→File.Copy→LastUpdated更新まで 480ms(Stopwatch計測)。
- AutoSave OFFで終了: AutoSave=false の状態でWindowを閉じると、MessageBoxでYes→現在のWidth/HeightをSave→HasUnsavedChanges=false。フッター警告が消えるまで1.2秒。
- ReadyToRun 3回ビルド: PowerShell 7.4で
.\build-package.ps1 を実行すると 2分14秒で完走。x64: 65.6MB / x86: 60.1MB / arm64: 61.5MB を確認。
使ってみて
AppSettingsManagerは x64単一ファイルを配布の主役に置き、SmartScreenを越える手順をManualに併記しました。
ダウンロード前に、HasUnsavedChangesのフローを一度デモで確認すると迷わずに済みます。
まとめ
- ThemeManagerのインライン適用と2カラムUIで、設定値の因果関係を視覚的に示した
- SettingsService.ChangeSettingsFilePathを実装し、AppData依存から外部ディスク移行までの動線を確保した
- ビルドスクリプトで実行時コンパイルの一部をビルド時に済ませるオプションを有効にした三兄弟を65.6MB/60.1MB/61.5MBに収め、配布ポリシーをx64主体へ整理した
さらに深く学ぶには
最後まで読んでくださり、ありがとうございました。
AppDataから設定を持ち出したい誰かの背中を、少しでもそっと押せたなら嬉しいです。