【Unity】ScriptableObjectにPrefabを入れてしまうのが便利という話

# はじめに

`ScriptableObject`という機能がUnityにあることはご存知でしょうか。
いわゆる、「アセットでありながらインスタンスでもある」といった変わった性質を持つ`ScriptableObject`ですが、これの中に`Prefab`を入れてしまうととても便利だったので、今回はその技を紹介します。

# Prefab in ScriptableObject

`ScriptableObject`ですが、実はこの中に`Prefab`を入れることができます。
こうすることで、「**`Prefab`と、そのメタ情報を一緒に管理する**」ということができるようになります。

例えば次のようなもの。

```cs
using UnityEngine;

namespace Samples
{
    /// <summary>
    /// ItemのPrefabを管理するScriptableObject
    /// </summary>
    class ItemContainer : ScriptableObject
    {
        /// <summary>
        /// 表示名
        /// </summary>
        [SerializeField] public string DisplayName;

        /// <summary>
        /// ID
        /// </summary>
        [SerializeField] public string Id;

        /// <summary>
        /// 生成するオブジェクト
        /// </summary>
        [SerializeField] public GameObject Prefab;
    }
}
```

これを使うとこんな感じで、**ScriptableObjectの中にメタ情報とPrefabをセットで保持することができる**ようになります。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/47146/cc61bc3b-f003-9302-b933-c79b9d1aed68.png)
(Prefabと、それに紐づく名前とIDをセットで保持できる)

# 使用例: AssetBundleからPrefabを読み込んで管理する

この手法ですが、「AssetBundleに入れたPrefabを管理したい」というときに結構便利です。
読み込んだAssetBundleから該当のPrefabのみをフィルタリングしてメタ情報とセットで管理するといったことが簡単にできるようになります。

例として、「**AssetBundleからメタ情報をひっこぬいてUIを構築し、ボタンを押されたら対象のPrefabをインスタンス化する**」というものを作ってみます。
![AB.gif](https://qiita-image-store.s3.amazonaws.com/0/47146/bab17a16-981a-60d9-bbeb-3d0422796699.gif)

## AssetBundleを作る

![image.png](https://qiita-image-store.s3.amazonaws.com/0/47146/7b51b26a-4791-e603-0b83-6dae78f3dd52.png)

このように「*ItemCapsule*」「*SphereItem*」「*CubeItem*」みたいな、3つのアイテムを表すPrefabがあったとして、これらを含んだAssetBundleがあったとします。


## AssetBundleを読み込む

さきほどのをAssetBundleとしてビルドしたものがあったとして、

![image.png](https://qiita-image-store.s3.amazonaws.com/0/47146/3d53b2b3-994b-82b1-018c-26a93950ee41.png)

このAssetBundleから、「Item」に関するPrefabのみを抜き取るようにします。


### AssetBundleローダー

まずはAssetBundleそのものを読み込むローダーを作ります。

```cs:AssetBundleローダー
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using UniRx;
using UnityEngine;
using UnityEngine.Networking;

namespace ItemViewer
{
    /// <summary>
    /// AssetBundleローダ
    /// </summary>
    public class AssetBundleLoader : IDisposable
    {
        Dictionary<string, TaskCompletionSource<AssetBundle>> _cachedTasks =
            new Dictionary<string, TaskCompletionSource<AssetBundle>>();

        List<AssetBundle> _loadeAssetBundles = new List<AssetBundle>();

        CancellationTokenSource _loaderCancellationTokenSource = new CancellationTokenSource();

        /// <summary>
        /// 指定パスのAssetBundleを読み込む
        /// </summary>
        public async Task<AssetBundle> LoadAssetBundleAsync(string path, CancellationToken cancellationToken)
        {
            if (_loaderCancellationTokenSource.IsCancellationRequested)
            {
                throw new ObjectDisposedException("AssetBundlerLoader is disposed");
            }

            var loaderCancellationToken = _loaderCancellationTokenSource.Token;

            //キャッシュ済みTaskがあるならそっちを返す
            TaskCompletionSource<AssetBundle> cachedTask;
            if (_cachedTasks.TryGetValue(path, out cachedTask))
            {
                return await cachedTask.Task;
            }

            var tcs = new TaskCompletionSource<AssetBundle>();
            _cachedTasks[path] = tcs;

            var uwr = UnityWebRequestAssetBundle.GetAssetBundle(path);
            await uwr.SendWebRequest();

            if (loaderCancellationToken.IsCancellationRequested || cancellationToken.IsCancellationRequested)
            {
                // キャンセルされてたら止める
                _cachedTasks[path].TrySetCanceled();
                _cachedTasks.Remove(path);
                throw new OperationCanceledException();
            }

            if (uwr.isHttpError || uwr.isNetworkError)
            {
                var ex = new Exception(uwr.error);
                tcs.SetException(ex);
                _cachedTasks.Remove(path);
                throw ex;
            }
            else
            {
                var result = DownloadHandlerAssetBundle.GetContent(uwr);
                _loadeAssetBundles.Add(result);
                tcs.TrySetResult(result);
                return result;
            }
        }


        public void Dispose()
        {
            _loaderCancellationTokenSource.Cancel();
            _loaderCancellationTokenSource.Dispose();

            foreach (var taskCompletionSource in _cachedTasks)
            {
                taskCompletionSource.Value.TrySetCanceled();
            }

            foreach (var loadeAssetBundle in _loadeAssetBundles)
            {
                loadeAssetBundle.Unload(true);
            }

            _cachedTasks.Clear();
        }
    }
}
```

### ItemContainerローダー

次に、読み込んだAssetBundleからItemContainerを取り出すローダーを作ります。

```cs:ItemContainerローダー
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Samples;
using UniRx;

namespace ItemViewer
{
    class ItemContainerLoader : IDisposable
    {
        private AssetBundleLoader _assetBundleLoader;

        public ItemContainerLoader(AssetBundleLoader assetBundleLoader)
        {
            _assetBundleLoader = assetBundleLoader;
        }

        public async Task<ItemContainer[]> LoadItemContainerAsync(string path, CancellationToken cancellationToken)
        {
            // AssetBundleを読み込む
            var ab = await _assetBundleLoader.LoadAssetBundleAsync(path, cancellationToken);

            if (ab.isStreamedSceneAssetBundle) return new ItemContainer[0]; //Sceneだったら空を返す

            // AssetBundleからItemContainerのみを抜き取る
            var request = ab.LoadAllAssetsAsync<ItemContainer>();
            await request;
            cancellationToken.ThrowIfCancellationRequested();
            return request.allAssets.Cast<ItemContainer>().ToArray();
        }

        public void Dispose()
        {
            // Do nothing
        }
    }
}
```

長々と書いてますが、見てほしいのはここです。

```cs
// AssetBundleからItemContainerのみを抜き取る
var request = ab.LoadAllAssetsAsync<ItemContainer>();
```

このように、興味のあるPrefabを、ScriptableObjectの型でフィルタリングして一括で取得ができます。

### 実際に読み込んでUIに出す

実際にこれを使って、ItemをまとめてAssetBundleから読み込み、UIから選べるようにするこうなります。


```cs:本体
using System;
using System.IO;
using System.Threading;
using UnityEngine;

namespace ItemViewer
{
    /// <summary>
    /// ひっでぇ名前
    /// </summary>
    public class ItemManager : MonoBehaviour
    {
        [SerializeField] private Transform _canvasRoot; // UIのroot
        [SerializeField] private ItemPanelView _itemPanelViewPrefab; // UIのPrefab

        private AssetBundleLoader _assetBundleLoader;
        private ItemContainerLoader _itemContainerLoader;
        private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();

        void Awake()
        {
            _assetBundleLoader = new AssetBundleLoader();
            _itemContainerLoader = new ItemContainerLoader(_assetBundleLoader);
        }

        public async void LoadAsync()
        {
            var path = Path.Combine(Application.streamingAssetsPath, "items");

            // アイテムロード
            var items = await _itemContainerLoader.LoadItemContainerAsync(path, _cancellationTokenSource.Token);

            foreach (var itemContainer in items)
            {
                // ロードしたアイテムをViewに割当
                var view = Instantiate(_itemPanelViewPrefab);
                view.transform.SetParent(_canvasRoot);
                view.Set(itemContainer);
            }
        }


        void OnDestroy()
        {
            _cancellationTokenSource.Cancel();
            _cancellationTokenSource.Dispose();
            _itemContainerLoader?.Dispose();
            _assetBundleLoader?.Dispose();
        }
    }
}
```

```cs:アイテムのUI
using System;
using Samples;
using UniRx;
using UnityEngine;
using UnityEngine.UI;
using Random = UnityEngine.Random;

namespace ItemViewer
{
    /// <summary>
    /// View?
    /// </summary>
    public class ItemPanelView : MonoBehaviour
    {
        [SerializeField] private Text _text;
        [SerializeField] private Button _button;

        private IDisposable _disposable;

        public void Set(ItemContainer itemContainer)
        {
            _text.text = $"{itemContainer.Id}: {itemContainer.DisplayName}";
            _disposable?.Dispose();

            // Viewが直接インスタンス化しててなかなかひどいけどサンプルってことでゆるして
            _disposable = _button.OnClickAsObservable()
                .Subscribe(_ =>
                {
                    Instantiate(itemContainer.Prefab, Random.insideUnitSphere * 5.0f, Quaternion.identity);
                });
        }

        private void OnDestroy()
        {
            _disposable?.Dispose();
        }
    }
}
```

```cs:Loadボタン
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace ItemViewer
{
    public class LoadButton : MonoBehaviour
    {
        [SerializeField] private ItemManager _itemManager;
        [SerializeField] private Button _button;

        void Start()
        {
            _button.OnClickAsObservable()
                .Take(1)
                .Subscribe(_ => _itemManager.LoadAsync());
        }
    }
}
```

![AB.gif](https://qiita-image-store.s3.amazonaws.com/0/47146/bab17a16-981a-60d9-bbeb-3d0422796699.gif)


# まとめ

ScriptableObjectにPrefabを入れることで、剥き身でPrefabを管理するより楽な場面が多いです。