Windows MAUI の .NET 9 アップグレードで XML 設定ファイルが読めない原因と対処:MSIX の PackageFamilyName と FileSystem.AppDataDirectory 完全ガイド

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 にそのまま到達できます。

保存先の違いを俯瞰

配布形態/APIOS代表的な実体パスID 変更の影響備考
FileSystem.AppDataDirectoryWindows(MSIX)%LOCALAPPDATA%\Packages\<PFN>\LocalState大(PFN 変化で別フォルダ)推奨。OS 準拠で安全
FileSystem.AppDataDirectoryAndroidアプリ専用領域(内部ストレージ)小従来から安定
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 ステップが基本です。

  1. FileSystem.AppDataDirectory に目的の XML が存在するか確認。
  2. 無ければ 旧パス候補(%LOCALAPPDATA% 直下の自前フォルダや、過去 PFN の LocalState)を探索。
  3. 見つかったら 新パスへコピーし、必要に応じてバックアップや検証を行う。

シンプル版:アンパッケージ時代からの移行(旧コードが 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 を合わせるか、初回起動時のマイグレーションで 「もう片方」を探索・コピーする仕組みを用意します。

確認・再現の手順(安全に切り分ける)

  1. 現在の保存先をアプリ起動直後にログ出力:
    Path.Combine(FileSystem.AppDataDirectory, "SomeYourConfigFileName.xml") を表示。
  2. そのパスに実際にファイルがあるかを エクスプローラーで確認。
  3. 無ければ %LOCALAPPDATA%\Packages を開き、旧 Identity Name の接頭辞で始まるディレクトリを探す(例:com.contoso.myapp_*)。その LocalState 直下に旧 XML があるか確認。
  4. アンパッケージ時代に自前で保存していたなら、%LOCALAPPDATA% 直下の当時のフォルダを確認。
  5. 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 を揃えるか、初回マイグレーションで相互コピー

テスト戦略(失敗しないためのチェック)

  1. .NET 8 版で設定を保存し、XML の中身を記録。
  2. Identity を変更しない状態で .NET 9 へ上げて起動。既存設定が参照されるか確認。
  3. 意図的に Publisher を変更したビルドを作り、マイグレーションが働くか確認(ACL による失敗も評価)。
  4. XML を破損させ、復旧パス(バックアップ&初期化)が機能するか確認。
  5. 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/サポート手順あり
ログとテレメトリ成功/失敗/例外を把握可能

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次