Windows MAUI を .NET 9 へアップグレード後に XML 設定ファイルが読めない原因と対処(MSIX の PackageFamilyName 変更/FileSystem.AppDataDirectory への移行/一度きりのマイグレーション手順)
Windows 向け .NET MAUI アプリを .NET 8 から .NET 9 へ上げた直後、これまでローカルに保存していた XML 形式のカスタム設定ファイル(テーマ色、ウィンドウ位置、言語など)が急に読み込めなくなる――この現象は、Android 版では再現せず Windows だけで起きることが多く、開発環境特有なのか本番でも発生するのか判断に迷いがちです。本記事では、原因の本質と再発しない作りへの移行方針、既存ユーザーの設定を失わずに済む移行コード、さらに Package.appxmanifest の注意点まで、実運用で役に立つ粒度でまとめます。
先に結論
- 原因の9割は「保存先フォルダが変わった」ことです。特に Windows を MSIX(パッケージ)配布している場合、
Package.appxmanifestの<Identity Name>または<Identity Publisher>を変更すると PackageFamilyName(PFN) が変わり、アプリが参照するLocalStateの場所も別物になります。 - プラットフォーム間の差を吸収するため、
FileSystem.AppDataDirectoryを一貫して使用し、ID を易々と変えないのが最善です。 - 既存ユーザーを守るには、初回起動時の一度きりマイグレーション(旧パスを探して新パスへコピー)を実装しましょう。
症状の整理
- .NET 8 時代にローカルへ保存していた XML 設定が、.NET 9 へターゲット変更後に Windows でだけ読み込めない。
- Android へ同一ソースをビルドすると問題なし(従来の設定が使える)。
- 既存ユーザーの XML 設定を .NET 9 でもそのまま再利用したい。
原因の深掘り:ID 変更で保存先フォルダが別物になる
Windows の MAUI(WinUI 3/Windows App SDK)を MSIX でパッケージ配布している場合、アプリのローカルデータは次の場所に格納されます。
C:\Users\<ユーザー>\AppData\Local\Packages\<PackageFamilyName>\LocalState\
ここで <PackageFamilyName> は Package.appxmanifest の <Identity Name="..." Publisher="..." /> から生成される値です。どちらか一方でも変えると PFN が変わるため、アプリは「前の自分とは別アプリ」として扱われ、以前の LocalState を見なくなります。結果、XML が「見つからない=読めない」状態になります。
一方、アンパッケージ(クラシックデスクトップ)で、独自に Environment.SpecialFolder.LocalApplicationData 等へ保存していた場合は、パスは基本不変です。従来の XML にそのまま到達できます。
保存先の違いを俯瞰
| 配布形態/API | OS | 代表的な実体パス | ID 変更の影響 | 備考 |
|---|---|---|---|---|
FileSystem.AppDataDirectory | Windows(MSIX) | %LOCALAPPDATA%\Packages\<PFN>\LocalState | 大(PFN 変化で別フォルダ) | 推奨。OS 準拠で安全 |
FileSystem.AppDataDirectory | Android | アプリ専用領域(内部ストレージ) | 小 | 従来から安定 |
Environment.SpecialFolder.LocalApplicationData を直参照 | Windows(アンパッケージ) | %LOCALAPPDATA%\<任意フォルダ> | 小 | 自前パス管理。権限はユーザー任せ |
なぜ Android では起きにくいのか
Android の内部ストレージはアプリ ID にひも付くものの、MAUI では FileSystem.AppDataDirectory を使っておけば .NET のメジャー更新で保存先が突然変わることは稀です。Windows だけが目立って問題化するのは、MSIX の ID とサンドボックス(PFN)が絡むためです。
推奨:FileSystem.AppDataDirectory を用い、ID を保持する
コードは次のように統一します(using Microsoft.Maui.Storage;)。
// 旧コード(.NET 8 で動いていたが、パスが OS 依存)
// ※ Windows ではアンパッケージ時などに使いがち
var legacyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SomeYourConfigFileName.xml");
// 新コード(クロスプラットフォーム推奨)
var newPath = Path.Combine(FileSystem.AppDataDirectory, "SomeYourConfigFileName.xml");
- 利点:各 OS が推奨する安全な保存場所に自動マッピングされ、Android/Windows でコードを共通化できます。
- 注意:Windows を MSIX で配布している場合、
Identity Name/Publisherを むやみに変えないこと。変わると PFN が変わり、別フォルダとして扱われます。
既存ファイルを移行する「一度きり」のワンショット処理
すでに ID を変えてしまい、新 PFN 側の LocalState に XML がない場合は、初回起動時に「旧場所を探し、新場所にコピーする」処理を一度だけ走らせます。次の 3 ステップが基本です。
FileSystem.AppDataDirectoryに目的の XML が存在するか確認。- 無ければ 旧パス候補(
%LOCALAPPDATA%直下の自前フォルダや、過去 PFN のLocalState)を探索。 - 見つかったら 新パスへコピーし、必要に応じてバックアップや検証を行う。
シンプル版:アンパッケージ時代からの移行(旧コードが LocalApplicationData を直参照)
var fileName = "SomeYourConfigFileName.xml";
var newPath = Path.Combine(FileSystem.AppDataDirectory, fileName);
if (!File.Exists(newPath))
{
var oldPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
fileName);
if (File.Exists(oldPath))
{
Directory.CreateDirectory(FileSystem.AppDataDirectory);
File.Copy(oldPath, newPath, overwrite: false);
}
}
実践版:旧 PFN(過去の <Identity>)配下の LocalState を探索してコピー
過去も MSIX で配布しており、Identity を変更してしまったケースでは、旧 PFN を推測して LocalState を探しに行きます。PFN は <Identity Name> に基づく接頭辞を持つため、「Name_*」で候補を列挙するのが実務的です。
using System.Runtime.Versioning;
using Microsoft.Maui.Storage;
public static class SettingsMigrator
{
private const string FileName = "SomeYourConfigFileName.xml";
// Windows 限定:旧 PFN の LocalState から新パスへコピー
[SupportedOSPlatform("windows")]
public static bool TryMigrateFromOldPackageFamily(string identityNamePrefix)
{
try
{
var newPath = Path.Combine(FileSystem.AppDataDirectory, FileName);
if (File.Exists(newPath)) return false; // 既にある
var packagesDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Packages");
if (!Directory.Exists(packagesDir)) return false;
// "com.company.app_*" のように候補を集める
var pattern = identityNamePrefix + "_";
var candidates = Directory.EnumerateDirectories(packagesDir)
.Where(d => Path.GetFileName(d)
.StartsWith(pattern, StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var dir in candidates)
{
var candidateFile = Path.Combine(dir, "LocalState", FileName);
if (File.Exists(candidateFile))
{
Directory.CreateDirectory(FileSystem.AppDataDirectory);
// 念のためバックアップ
var backup = newPath + ".bak";
if (File.Exists(newPath)) File.Copy(newPath, backup, overwrite: true);
File.Copy(candidateFile, newPath, overwrite: false);
return true;
}
}
}
catch (UnauthorizedAccessException)
{
// 環境によっては ACL で拒否される可能性も考慮
// その場合はユーザーに手動インポート UI を提示するなどのフォールバックを用意
}
catch (Exception)
{
// ログに残す。以後はデフォルト設定で起動
}
return false;
}
}
ポイント:他パッケージの LocalState に対するアクセスは、環境の ACL 設定により拒否される場合があります。その場合は 「設定インポート」ダイアログを用意してユーザーに旧ファイルを選択してもらう、または サポート手順としてエクスプローラー経由でコピーしてもらうなどのフォールバック設計を用意しましょう。
起動時に一度だけ走らせる:組み込み例(サービス化)
マイグレーションは「初回のみ」実行できれば十分です。アプリ起動の早い段階で実施し、成功/失敗を記録しましょう。
// App.xaml.cs など(Windows 判定と一度きりの実行)
public partial class App : Application
{
private static bool _migrated = false;
public App()
{
InitializeComponent();
#if WINDOWS
if (!_migrated)
{
// 旧 Identity Name の接頭辞(例)
// <Identity Name="com.contoso.myapp"> だった場合は "com.contoso.myapp"
var identityNamePrefix = "com.contoso.myapp";
var ok = SettingsMigrator.TryMigrateFromOldPackageFamily(identityNamePrefix);
_migrated = true;
}
#endif
}
}
XML の読込/保存ユーティリティ(堅牢化)
「ファイルが見つからない」のか「XML として読めない」のかを切り分けるため、例外を握りつぶさずログ化し、破損時は 安全なデフォルトで起動できるようにします。
public static class XmlSettings
{
public static T LoadOrDefault<T>(string path, Func<T> factory)
{
try
{
if (!File.Exists(path)) return factory();
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var serializer = new System.Xml.Serialization.XmlSerializer(typeof(T));
return (T)serializer.Deserialize(fs);
}
catch (Exception)
{
// ここでログ出力(例:AppCenter、Serilog など)
// 破損時はデフォルトで復旧(上書き保存はユーザー確認後)
return factory();
}
}
public static void Save<T>(string path, T data)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
var serializer = new System.Xml.Serialization.XmlSerializer(typeof(T));
serializer.Serialize(fs, data);
}
}
「ID を変えない」ためのチェックリスト
PackageFamilyName の源流である Package.appxmanifest と、そこへ値が流れ込む .csproj の設定に注意します。名称変更や証明書の再作成を行うと、意図せず PFN が変わります。
| 確認項目 | 影響 | 推奨 |
|---|---|---|
Package.appxmanifest の <Identity Name> | 変えると PFN が変わり LocalState も別物に | できる限り固定。誤って改名しない |
Package.appxmanifest の <Identity Publisher> | 証明書の発行主体が変わると PFN も変化 | デバッグ/本番で Publisher を統一 |
.csproj の ApplicationTitle CompanyName ProductName | ビルド時に manifest へ反映され、結果的に PFN へ波及することがある | 不用意に改名しない。変更時は移行方針を明確に |
| 署名証明書(デバッグ用/本番用) | Publisher 差異で PFN が分かれ、設定の共有不可 | 本番移行前に「同一 Publisher」でビルド検証 |
manifest の該当箇所
<?xml version="1.0" encoding="utf-8"?>
<Package ...>
<Identity Name="com.contoso.myapp"
Publisher="CN=Contoso Software, O=Contoso, L=Tokyo, C=JP"
Version="1.0.0.0" />
...
</Package>
デバッグ版と本番版でフォルダが分かれる問題
デバッグ時に使っている自己署名証明書と、本番のストア署名(または企業署名)で Publisher が違うと、デバッグ版と本番版で PFN が分かれます。その結果、設定ファイルは別々の場所に保存され、環境間の挙動が一致しません。可能なら Publisher を合わせるか、初回起動時のマイグレーションで 「もう片方」を探索・コピーする仕組みを用意します。
確認・再現の手順(安全に切り分ける)
- 現在の保存先をアプリ起動直後にログ出力:
Path.Combine(FileSystem.AppDataDirectory, "SomeYourConfigFileName.xml")を表示。 - そのパスに実際にファイルがあるかを エクスプローラーで確認。
- 無ければ %LOCALAPPDATA%\Packages を開き、旧 Identity Name の接頭辞で始まるディレクトリを探す(例:
com.contoso.myapp_*)。そのLocalState直下に旧 XML があるか確認。 - アンパッケージ時代に自前で保存していたなら、%LOCALAPPDATA% 直下の当時のフォルダを確認。
- XML が存在するのに読めない場合は、XML の中身の破損やエンコーディング(BOM の有無、文字化け)も併せて確認。
XML シリアライザ起因のトラブルも想定する
まれに、モデルの変更(プロパティ名/型の変更や [XmlElement] の追加・削除)が影響し、XML が読み込めないこともあります。切り分けのコツは以下です。
- まず ファイルが見つかるか(存在確認)。
- 見つかるなら 例外内容(
InvalidOperationExceptionのInnerException)をログ化し、XML 側の要素欠落・不整合を疑う。 - 破損時は バックアップ(.bak)を残して初期化。ユーザーが復旧できる導線(「設定を復元」ボタン等)を用意。
移行 UX のベストプラクティス
- 初回起動時に「設定を引き継ぎました/見つかりませんでした」を 明確に通知。
- 失敗時は 手動インポートの UI を出す(ファイルピッカーで XML を選ばせる)。
- コピーの前後で XML スキーマ検証(最低限の構造チェック)を行い、破損ファイルによる起動不能を防ぐ。
- ユーザーの安心感のため、移行後も 旧ファイルはバックアップとして残す(一定期間でクリーンアップ)。
ケース別・対処早見表
| ケース | 想定される原因 | 対処 |
|---|---|---|
| .NET 9 に上げたら Windows だけ読めない | MSIX の PFN 変更により保存先が別フォルダに | FileSystem.AppDataDirectory を使用し、ID を維持。必要なら旧 PFN を探索してコピー |
| Android は読めるが Windows は読めない | Windows の保存先のみ変更/PFN 差異 | Windows 側の保存先をログで確認し、マイグレーション |
| ファイルはあるのに例外で落ちる | XML の破損/モデル不整合 | 例外をログ化し、復旧/バックアップ/初期化フローを実装 |
| デバッグ版は読めるが本番版は読めない | Publisher の相違で PFN が分岐 | Publisher を揃えるか、初回マイグレーションで相互コピー |
テスト戦略(失敗しないためのチェック)
- .NET 8 版で設定を保存し、XML の中身を記録。
- Identity を変更しない状態で .NET 9 へ上げて起動。既存設定が参照されるか確認。
- 意図的に Publisher を変更したビルドを作り、マイグレーションが働くか確認(ACL による失敗も評価)。
- XML を破損させ、復旧パス(バックアップ&初期化)が機能するか確認。
- Android/Windows の双方で
FileSystem.AppDataDirectory一貫運用の挙動を確認。
よくある質問(FAQ)
Q. これは開発機だけの現象? 本番でも起きる? Publisher(署名)がデバッグと本番で異なると PFN が分かれるため、本番でも起きます。ユーザーの設定を守るなら、ID を維持するか、マイグレーションを実装してください。
<dt>Q. <code>FileSystem.AppDataDirectory</code> へ切り替えるだけで直る?</dt>
<dd>「これから保存する場所」は統一できますが、<strong>過去に別場所へ置いた XML は自動では移ってきません</strong>。本記事のワンショット移行を併用しましょう。</dd>
<dt>Q. 旧パッケージの <code>LocalState</code> にアプリからアクセスできないことがある。</dt>
<dd>環境(ACL)により拒否されうるため、その場合は <strong>ユーザー主導の手動インポート</strong>を提供するのが現実的です。</dd>
<dt>Q. 将来的に XML から JSON に切り替えたい。</dt>
<dd>まずは旧 XML を新パスへコピーし、<strong>二段階移行</strong>(<em>XML → オブジェクト → JSON 保存</em>)を行うと安全です。拡張子で現行形式を判定し、XML 残存時のみ読み取り・変換する実装がメンテしやすいです。</dd>
まとめ
- Windows(MSIX)では ID が保存先を決めるため、
<Identity Name>や<Identity Publisher>の変更は慎重に。 - プラットフォーム差を吸収するため、
FileSystem.AppDataDirectoryを標準化する。 - 既存ユーザーを守るには、初回起動時の一度きりマイグレーションを用意。旧 PFN/旧パスを探索し、新パスへコピー。
- XML が読めない場合は 「パス問題」と「XML 破損/不整合」を分けて診断し、ログと復旧導線を整える。
付録:チェックリスト(配布前の最終確認)
| チェック | OK 基準 |
|---|---|
manifest の <Identity Name/Publisher> が .NET 8 時と同じ | 両値一致。PFN 不変 |
保存 API を FileSystem.AppDataDirectory に統一 | プラットフォーム特有の直指定を排除 |
| 初回マイグレーションが用意されている | 旧パス探索→検証→コピー→バックアップ |
| 失敗時のフォールバック UX | 手動インポート UI/サポート手順あり |
| ログとテレメトリ | 成功/失敗/例外を把握可能 |

コメント