非同期セーブ、Unity Platform Toolkit対応


投稿日:2026年9月1日 | 最終更新日:2026年9月2日

「宴」の標準のセーブ/ロードは同期I/O前提です。
Unity Platform Toolkit等、非同期のセーブシステムと連携する場合は、以下の手順で対応してください。

導入手順

対象バージョン: 宴4(4.3.0以降)/Unity6以降(Awaitable型を使用します)

1. 既存シーンのコンポーネントを「Convert To Async Save」で更新

シーン内のAdvSaveManagerが付いたGameObject(AdvEngineオブジェクト)を選択し、右クリックのメニュー Utage > Convert To Async Saveを実行してください。
シーン内のコンポーネント構成が以下のように更新されます。

差し替わるコンポーネント(元の値を引き継ぎます)

  • AdvSaveManagerAdvSaveManagerAsync
  • AdvSystemSaveDataAdvSystemSaveDataAsync

新規追加されるコンポーネント

  • AdvAutoSaveControllerAdvSaveManagerAsyncと同じGameObjectに追加されます。オートセーブを制御します)
  • AsyncFileIOQueueFileIOManagerと同じGameObjectに追加されます。読み書きの排他制御を行います)

2. 非同期セーブコンポーネント(IAsyncFileIO実装)を作成

実際のセーブ処理の実装はプロジェクトごとに異なるため、手動で作成する必要があります。
実装方法やサンプルは後述します。
FileIOManagerと同じGameObjectに、作成したコンポーネントを手動で追加してください

同期メソッドを非同期メソッドに(任意)

AdvEngineに非同期版のメソッドが追加されています。
テンプレートのまま使用している場合は内部で自動で同期非同期の呼び出しを分けているのでなにもする必要はありません。
独自にセーブロードのメソッドを呼び出している場合は、非同期処理メソッドを呼び出すように書き換えてください。

API 用途
QuickSaveAsync(CancellationToken) クイックセーブ
QuickLoadAsync(CancellationToken) クイックロード(戻り値でロード成否を返します)
WriteSaveDataAsync(AdvSaveData, CancellationToken) 通常セーブ(スロット指定)

オートセーブ(AdvAutoSaveController)

非同期セーブシステムでは、AdvAutoSaveControllerでオートセーブを制御します。
AdvSaveManager/AdvSystemSaveDataOnApplicationQuit/OnApplicationPauseによるオートセーブ処理は何もしなくなります)
トリガーは4種あり、publicプロパティ(EnableIntervalTrigger等)でプログラムから個別にオン/オフすることもできます。

Interval(一定間隔)

項目 既定値 説明
Enable Interval Trigger OFF 一定間隔でのオートセーブを有効にします
Interval Seconds 60 オートセーブする間隔(秒)です

Page Change(改ページ)

項目 既定値 説明
Enable Page Change Trigger OFF 改ページ時のオートセーブを有効にします
Page Change Interval 1 何ページ進むごとにオートセーブするかを指定します

Application Pause / Quit

項目 既定値 説明
Enable Application Pause Trigger ON アプリの停止時にオートセーブします
Enable Application Quit Trigger ON アプリの終了時にオートセーブします
  • 非同期処理は時間がかかるため、Application Pause / Quitでのオートセーブは発行された瞬間にI/Oの完了が保証されない点に注意してください。改ページ等のトリガーを用意しているのはそれを補うためです。
  • 改ページトリガーの方が確実ですが、スキップ時等に頻繁に発火し負荷が高くなる可能性があるため既定はOFFにしています。PageChangeIntervalで「数ページごとに1回」のように間引くことができます。
  • このコンポーネント自体はシステムから独立していますので、アレンジした自作コードでオートセーブを制御することもできます。

例外処理

非同期セーブシステムの場合、例外処理を前提としたエラー処理が必須となります。
たとえば、Platform Toolkitであればアカウントが無効になったときやセーブシステムが無効になったとき、保存先の容量不足などが例外としてスローされます。
テンプレートの仕組みには例外処理は一律でエラーとして扱う形でデフォルト動作として組み込んであります。

例外処理の独自拡張

独自に例外内容を判別したりUI動作を変更したりしたい場合は、指定のインターフェースを使ったコンポーネントに独自処理を実装し、該当するGameObjectにAddComponentしてください。

public interface IAdvSaveExceptionHandler
{
    void OnSaveDataException(AdvSaveOperationType operation, Exception e);
}

対応表

operation 呼び出し元コンポーネント GameObject
(テンプレートシーンでの例)
ハンドラが無い場合のデフォルト動作
OpenSaveLoadList UtageUguiSaveLoad /Canvas-AdvUI/SaveLoad ログ出力+ガイドメッセージ表示
Save UtageUguiSaveLoad /Canvas-AdvUI/SaveLoad ログ出力+ガイドメッセージ表示
QuickSave UtageUguiMainGame /Canvas-AdvUI/MainGame ログ出力+ガイドメッセージ表示
QuickLoad UtageUguiMainGame /Canvas-AdvUI/MainGame ログ出力+ガイドメッセージ表示
AutoSave AdvAutoSaveController /AdvEngine ログ出力のみ(ゲーム進行に関わらせないため)
WriteSystemData AdvEngine /AdvEngine ログ出力のみ(ゲーム進行に関わらせないため)
Delete 現在未使用 - -
DeleteAllAndQuit SystemUiDebugMenu
(デバッグメニュー)
/Canvas-System UI/DebugMenu ログは出さず例外を再送出します
(ハンドラの有無にかかわらず再送出されます。ハンドラは追加の通知用です)

Deleteはテンプレートでは使用していません。
セーブデータ削除のSampleCustomSaveLoadButtonはサンプルなので、これをもとに直接例外処理を書いたコードを書いてください。

分散配置は意図的な設計です: 呼び出し元ごとにハンドラを別々に置けるようにしている(=1箇所に集約しない)のは、
画面固有の後処理(例外発生時に画面を閉じて前の画面に戻る等)をハンドラ内から直接行いやすくするためです。
実装例はAssets/Utage/Sample/Scripts/SampleSaveExceptionHandler.csを参照してください。
サンプルでは、1コンポーネントで複数のoperationを場合分けしていますが、実際には実装したい処理ごとに独自のコンポーネントを作ることをお勧めします。

起動時のシステムセーブデータ読み込みの例外処理

起動シーケンス中のシステムセーブデータ読み込み失敗は、上記IAdvSaveExceptionHandlerの対象外です。
起動シーケンス自体が非同期のため、例外処理も非同期対応にしているためです。
例外処理をカスタムしたい場合は、IAdvSystemSaveDataRetryHandlerを使ってください。

public interface IAdvSystemSaveDataRetryHandler
{
    // trueならリトライする。falseなら諦めて例外を呼び出し元へ伝播させる
    Awaitable<bool> OnSystemSaveDataReadFailedAsync(Exception e);
}
呼び出し元コンポーネント GameObject(テンプレートシーンでの例) ハンドラが無い場合のデフォルト動作
AdvEngine /AdvEngine SystemUiのデフォルトダイアログでユーザーに通知した上でリトライします
SystemUiも無ければログのみでダイアログ無しにリトライします)

読み書きの排他制御(AsyncFileIOQueue)

すべての非同期読み書き(クイック/通常セーブ・ロード・一覧取得・オートセーブ)は、
FileIOManagerと同じGameObjectのAsyncFileIOQueueAsyncSaveConverterが自動追加する共通フレームワークコード)を経由します。
ファイル単位ではなくセッション単位で排他し、同時には常に1操作しか実行しません(Platform ToolkitのISaveReadable/ISaveWritableは1つでも開いていると他の操作もできなくなる制約を踏まえた設計です)。
現在実行中かどうかはAsyncFileIOQueue.IsRunningで確認できます。
実行中に新たな要求があった場合は失敗にはならず、キューで待機し、実行中のセッションが完了してから優先度順(通常の明示的なセーブが先、オートセーブが後)に処理されます。

非同期セーブコンポーネントの実装方法

上記の拡張はあくまで、宴側の処理を非同期セーブに対応するための下準備です。
実際の非同期セーブの仕組みを実装するには、使いたい仕組みに合わせてセーブファイルの読み書きをする仕組みを実装する必要があります。

IAsyncFileIOを実装したコンポーネントを作成し、FileIOManagerと同じGameObjectに追加すると、セーブの読み書き処理をそのコンポーネントで行うようになります。

    /// <summary>
    /// 実ファイルI/O(非同期)の拡張用インターフェース。
    /// FileIOManagerとは違い、バイナリの生成・符号化は行わず、読み書きだけを行う。
    /// FileIOManagerと同じGameObjectに独立コンポーネントとしてアタッチし、
    /// AsyncFileIOQueue経由で呼ぶ(直接呼ばない)。
    /// バッチ単位API(AsyncFileEntry参照)で、Commit等のライフサイクル管理は実装に隠蔽する。
    /// </summary>
    public interface IAsyncFileIO
    {
        /// <summary>
        /// 指定したパスのファイルをまとめて読み込む(符号化されたままのバイト列、デコードは呼び出し元)。
        /// (事前の存在確認は二重処理を生むため)ファイル不存在は例外を投げず<see cref="AsyncFileEntry.NotFound"/>を返す
        /// それ以外のI/Oエラーは例外をスローすること
        /// </summary>
        Awaitable<IReadOnlyList<AsyncFileEntry>> ReadFilesAsync(
            IReadOnlyCollection<string> paths, CancellationToken cancellationToken);

        /// <summary>
        /// 複数ファイルをまとめて書き込む。
        /// </summary>
        Awaitable WriteFilesAsync(IReadOnlyList<AsyncFileEntry> files, CancellationToken cancellationToken);

        /// <summary>
        /// 複数ファイルをまとめて削除する。存在しないファイルの削除は何もしない。
        /// </summary>
        Awaitable DeleteFilesAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken);
    }

実装時の注意点

複数ファイル同時処理

基本的には複数ファイルを一度に処理する設計になっています。
たとえばオートセーブの場合は、システムセーブデータとオートセーブ(シナリオ用)の2ファイルを同時に処理する設計になっています。
これは、Platform Toolkitなどで1ファイル個別の処理してしまうとファイルシステム全体の開閉が細切れで頻繁になりすぎてしまう問題を避けるためです。

パスは相対パス

また、渡されるパスはApplication.persistentDataPathを含まない論理パス([DirectoryName]/[FileName])になっています。
Platform Toolkitを使う場合はpersistentDataPathは不要で、プラットフォームによってはpersistentDataPathへのアクセス自体がエラーになりうるためです。
もし必要な場合は、SampleAsyncFileIOを参考に独自実装側で処理をしてください。

サンプル(SampleAsyncFileIO)

Utage/Sample/Scripts/SampleAsyncFileIO.csに、ローカルファイルとして非同期のファイルIOを実装しているサンプルがあります。
内容としてはパスのリストを単純にループして読み書きするだけです。
実際には同期版のファイルと同じ場所に保存されるのであえて使用するメリットはないですが、コードの書き方の参考にしてください。

サンプル(Platform Toolkit)

非同期セーブ用の拡張コンポーネントのPlatform Toolkit対応サンプルを以下に記述します。
Platform Toolkitは「セーブ」という単位があり、これ以下に複数のファイルを配置する仕様になっています。
いっぽう宴は一種類のセーブごとに1ファイルしか使わない設計になっています。
Platform Toolkitの「セーブ」を、セーブスロットのように扱う場合は「1セーブごとに1ファイル」版を、ディレクトリのように扱う場合は「1セーブ以下に複数ファイル」版を使用してください。

PlatformToolkitAsyncFileIOPerFileSave(1セーブごとに1ファイル)

using System.Collections.Generic;
using System.Text;
using System.Threading;
using Unity.PlatformToolkit;
using UnityEngine;

namespace Utage
{
    /// <summary>
    /// 非同期セーブ用の拡張コンポーネントのPlatform Toolkit対応サンプルの「1セーブごとに1ファイル」版
    ///
    /// 例えば宴側から次のパスで書き込みが渡された場合、
    /// DirName/system
    /// DirName/saveAuto
    /// DirName/save1
    /// DirName/save2
    /// Platform Toolkit側では、それぞれ独立したセーブとして格納される。
    /// セーブ "dirname-system"   ├── data
    /// セーブ "dirname-saveauto" ├── data
    /// セーブ "dirname-save1"    ├── data
    /// セーブ "dirname-save2"    ├── data
    ///
    /// ファイルごとに別セーブへ個別にコミットするため、複数ファイルをまたいだアトミック性は無い
    /// (一部のファイルだけ書き込みに成功し、他が失敗する状況が起こり得る)。
    /// </summary>
    public class PlatformToolkitAsyncFileIOPerFileSave : MonoBehaviour, IAsyncFileIO
    {
        //セーブの中に常に1つだけ入る固定ファイル名(1ファイル=1セーブのため、名前自体に意味は無い)
        const string FixedFileName = "data";

        static ISavingSystem SavingSystem => PlatformToolkit.LocalSaving;

        /// <summary>
        /// 未初期化なら初期化する
        /// </summary>
        static async Awaitable EnsureInitialized()
        {
            //PlatformToolkit.Initialize()は冪等(初期化済みなら即return)なので、
            //各エントリーポイントの先頭で呼ぶだけで専用のブートストラップ処理なしに安全に使える。
            await PlatformToolkit.Initialize();
        }

        /// <summary>
        /// 指定したパスのファイルをまとめて読み込む。
        /// セーブが存在しない場合は、例外ではなくAsyncFileEntry.NotFoundインスタンスを返す。
        /// </summary>
        public async Awaitable<IReadOnlyList<AsyncFileEntry>> ReadFilesAsync(
            IReadOnlyCollection<string> paths, CancellationToken cancellationToken)
        {
            await EnsureInitialized();
            cancellationToken.ThrowIfCancellationRequested();
            var results = new List<AsyncFileEntry>(paths.Count);
            foreach (string path in paths)
            {
                cancellationToken.ThrowIfCancellationRequested();
                string saveName = SanitizeName(path);
                bool saveExists = await SavingSystem.SaveExists(saveName);
                if (!saveExists)
                {
                    results.Add(AsyncFileEntry.NotFound(path));
                    continue;
                }
                await using ISaveReadable save = await SavingSystem.OpenSaveReadable(saveName);
                byte[] bytes = await save.ReadFile(FixedFileName);
                results.Add(new AsyncFileEntry(path, bytes));
            }
            return results;
        }

        /// <summary>
        /// 複数ファイルをまとめて書き込む。ファイルごとに別セーブへ個別にコミットするため、
        /// このバッチ全体としてのアトミック性は無い(つまりコミットが複数回になる)
        /// </summary>
        public async Awaitable WriteFilesAsync(IReadOnlyList<AsyncFileEntry> files, CancellationToken cancellationToken)
        {
            await EnsureInitialized();
            foreach (AsyncFileEntry file in files)
            {
                cancellationToken.ThrowIfCancellationRequested();
                string saveName = SanitizeName(file.Path);
                await using ISaveWritable save = await SavingSystem.OpenSaveWritable(saveName);
                await save.WriteFile(FixedFileName, file.Bytes);
                await save.Commit();
            }
        }

        /// <summary>
        /// 複数ファイルをまとめて削除する。存在しないファイルの削除は何もしない。
        /// 1ファイル=1セーブなので、削除は常にDeleteSave(セーブごと削除)になる
        /// </summary>
        public async Awaitable DeleteFilesAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
        {
            await EnsureInitialized();
            foreach (string path in paths)
            {
                cancellationToken.ThrowIfCancellationRequested();
                string saveName = SanitizeName(path);
                bool saveExists = await SavingSystem.SaveExists(saveName);
                if (!saveExists) continue;
                await SavingSystem.DeleteSave(saveName);
            }
        }

        //ディレクトリ部分も含めてサニタイズする
        //(異なるディレクトリの同名ファイルが衝突する可能性を避けるため)
        static string SanitizeName(string path)
        {
            string lower = path.ToLowerInvariant();
            var sb = new StringBuilder(lower.Length);
            foreach (char c in lower)
            {
                sb.Append((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ? c : '-');
            }
            return sb.ToString();
        }
    }
}

PlatformToolkitAsyncFileIOOneSave(1セーブ以下に複数ファイル)

using System.Collections.Generic;
using System.Text;
using System.Threading;
using Unity.PlatformToolkit;
using UnityEngine;

namespace Utage
{
    /// <summary>
    /// 非同期セーブ用の拡張コンポーネントのPlatform Toolkit対応サンプルの「1セーブ以下に複数ファイル」版
    ///
    /// 例えば宴側から次のパスで書き込みが渡された場合、
    /// DirName/system
    /// DirName/saveAuto
    /// DirName/save1
    /// DirName/save2
    /// セーブ名"utage-save"(1つだけ)の中に、次のように複数ファイル名で格納される。
    /// dirname-system
    /// dirname-saveauto
    /// dirname-save1
    /// dirname-save2
    /// </summary>
    public class PlatformToolkitAsyncFileIOOneSave : MonoBehaviour, IAsyncFileIO
    {
        //宴の複数ファイルをまとめて格納するPlatform Toolkit側のセーブ名(小文字英数字とハイフンのみ)
        [SerializeField] string saveName = "utage-save";

        static ISavingSystem SavingSystem => PlatformToolkit.LocalSaving;

        /// <summary>
        /// 未初期化なら初期化する
        /// </summary>
        static async Awaitable EnsureInitialized()
        {
            //PlatformToolkit.Initialize()は冪等(初期化済みなら即return)なので、
            //各エントリーポイントの先頭で呼ぶだけで専用のブートストラップ処理なしに安全に使える。
            await PlatformToolkit.Initialize();
        }

        /// <summary>
        /// 指定したパスのファイルをまとめて読み込む。
        /// セーブが存在しない、またはファイルが存在しない場合は、例外ではなくAsyncFileEntry.NotFoundインスタンスを返す。
        /// </summary>
        public async Awaitable<IReadOnlyList<AsyncFileEntry>> ReadFilesAsync(
            IReadOnlyCollection<string> paths, CancellationToken cancellationToken)
        {
            await EnsureInitialized();
            cancellationToken.ThrowIfCancellationRequested();
            var results = new List<AsyncFileEntry>(paths.Count);
            bool saveExists = await SavingSystem.SaveExists(saveName);
            cancellationToken.ThrowIfCancellationRequested();
            if (!saveExists)
            {
                foreach (string path in paths) results.Add(AsyncFileEntry.NotFound(path));
                return results;
            }

            await using ISaveReadable save = await SavingSystem.OpenSaveReadable(saveName);
            foreach (string path in paths)
            {
                cancellationToken.ThrowIfCancellationRequested();
                string fileName = SanitizeName(path);
                if (!await save.ContainsFile(fileName))
                {
                    results.Add(AsyncFileEntry.NotFound(path));
                    continue;
                }

                byte[] bytes = await save.ReadFile(fileName);
                results.Add(new AsyncFileEntry(path, bytes));
            }

            return results;
        }

        /// <summary>
        /// 複数ファイルをまとめて書き込む。
        /// </summary>
        public async Awaitable WriteFilesAsync(IReadOnlyList<AsyncFileEntry> files, CancellationToken cancellationToken)
        {
            await EnsureInitialized();
            cancellationToken.ThrowIfCancellationRequested();

            // CancellationTokenは各WriteFile呼び出し前までしかチェックしない。Commit()はアトミックで
            // キャンセル不可なため、開始後は中断を諦める(途中キャンセルはDisposeAsyncでロールバックされる)。
            await using ISaveWritable save = await SavingSystem.OpenSaveWritable(saveName);
            foreach (AsyncFileEntry file in files)
            {
                cancellationToken.ThrowIfCancellationRequested();
                await save.WriteFile(SanitizeName(file.Path), file.Bytes);
            }
            await save.Commit();
        }

        /// <summary>
        /// 複数ファイルをまとめて削除する。存在しないファイルの削除は何もしない。
        /// 削除後にセーブ内がファイル数0件になる場合はCommitせずDeleteSaveでセーブごと削除する
        /// (Commitはファイル数0のセーブへのコミットに失敗する仕様のため)。
        /// </summary>
        public async Awaitable DeleteFilesAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
        {
            await EnsureInitialized();
            cancellationToken.ThrowIfCancellationRequested();
            bool saveExists = await SavingSystem.SaveExists(saveName);
            cancellationToken.ThrowIfCancellationRequested();
            if (!saveExists) return;

            var targetNames = new HashSet<string>();
            foreach (string path in paths) targetNames.Add(SanitizeName(path));

            var existingTargets = new List<string>();
            bool becomesEmpty;
            await using (ISaveReadable readable = await SavingSystem.OpenSaveReadable(saveName))
            {
                int remaining = 0;
                foreach (string fileName in await readable.EnumerateFiles())
                {
                    if (targetNames.Contains(fileName)) existingTargets.Add(fileName);
                    else remaining++;
                }
                becomesEmpty = remaining == 0;
            }
            cancellationToken.ThrowIfCancellationRequested();

            if (existingTargets.Count == 0) return;

            //DeleteSave/Commitはどちらもキャンセル不可のアトミックなAPIなので直前ではチェックしない
            if (becomesEmpty)
            {
                await SavingSystem.DeleteSave(saveName);
                return;
            }

            await using ISaveWritable save = await SavingSystem.OpenSaveWritable(saveName);
            foreach (string fileName in existingTargets)
            {
                cancellationToken.ThrowIfCancellationRequested();
                await save.DeleteFile(fileName);
            }
            await save.Commit();
        }

        //ディレクトリ部分も含めてサニタイズする
        //(異なるディレクトリの同名ファイルが衝突する可能性を避けるため)
        static string SanitizeName(string path)
        {
            string lower = path.ToLowerInvariant();
            var sb = new StringBuilder(lower.Length);
            foreach (char c in lower)
            {
                sb.Append((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ? c : '-');
            }
            return sb.ToString();
        }
    }
}