【Unity】Addressablesでリソースプロジェクトと本体プロジェクトをローカル環境で分ける方法

# はじめに
- **リソースプロジェクトでアセットバンドルをビルドして、本体プロジェクトではアセットを直接参照しない、というような開発体制に対応できます**
- ゲームでリモートでリソースを取得する必要がない場合などに有用です
    - ゲーム上でリモートでダウンロードする場合、このような処置を取らずに直接ロードするだけでOKです

- **直接アセットを追加せずリソースを切り分けることで、Gitなどの動作も軽量になります。**


# 対処法
**リソースプロジェクトから本体プロジェクトにバンドルファイルとcatalog.jsonをコピーする**
- 結構原始的なやり方ですが、調べた感じほかに手段がなかったので、消去法でこの方法で実装する必要があります


# 作り方
### 準備
- リソースプロジェクトと本体プロジェクトを作成する
    - ディレクトリ構成はこんな感じです
        ```
        AddressablesTest
        ├ ResourceProject
        └ MainProject
        ```

- Unityバージョン、Addressablesバージョンを揃える




### リソースプロジェクト
- リソースプロジェクト側で、アセットをAddressablesに登録してビルドします


### 本体プロジェクト
- 本体プロジェクト側で、エディタ拡張を利用してリソースプロジェクトのバンドル情報をコピーします
- コードについては後ほど紹介します



# 結果
### 実行結果
本体プロジェクト内のAddressables Groupsウィンドウには何も登録されていませんが…
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2419795/0585d049-56ad-c304-39b3-62f6c196fd74.png)

実行すると、リソースプロジェクト側でGroupsに追加したアセットが適用されます！
![AddressablesTest (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2419795/11e6fead-4c5b-0414-07bb-8d5db4ce0954.gif)


### エディタ
こんな感じのエディタウィンドウで操作できます。
ここでバンドルとカタログをコピーすれば、リソースプロジェクトのバンドルデータが含まれます!
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2419795/69e85e24-2a19-1be0-80ae-543776335d50.png)



…が、**ゲームをビルドすると、自動でバンドルの方のビルド処理がされてしまい、コピーされたバンドルファイルが上書きされてしまいます。**

そこで、**ビルド後にコピーするという処理が必要になります。**
以下のコード説明で紹介します






# エディタ拡張コード
### CopyBundleFromResourceProject.cs

<details><summary>コード</summary>

```C#
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using UnityEditor.AddressableAssets.Build;

/// <summary> リソースプロジェクトからバンドルをコピーする </summary>
public class CopyBundleFromResourceProject : EditorWindow
{
    // 設定ファイル
    [SerializeField] CopyBundleSettings settings;
    [SerializeField] bool isAbsolutePath = false;       // 絶対パスフラグ

    // ビルド時の設定
    [SerializeField] bool isCopyOnBuild = true;

    const string CATALOG_FILE_NAME = "catalog.json";

    const int SPACE_SIZE = 32;

    const string SETTING_FILE_PATH = "Assets/ScriptableObjects/CopyBundleSettings.asset";

    //------------------------------------------------------------
    [MenuItem("Addressable/CopyBundleFromResourceProject")]
    static void ShowWindow()
    {
        var window = GetWindow<CopyBundleFromResourceProject>();
        window.titleContent = new GUIContent("CopyBundleFromResourceProject");
        window.Show();

        Debug.Log("ShowWindow");
    }

    void OnEnable()
    {
        BuildScript.buildCompleted = null;
        BuildScript.buildCompleted += OnBuildCompleted;

        Debug.Log("CopyBundleFromResourceProject");
    }

    void OnGUI()
    {
        // 設定ScriptableObject設定
        EditorGUILayout.LabelField("Copy Bundle Settings", EditorStyles.boldLabel);
        settings = EditorGUILayout.ObjectField("Settings", settings, typeof(CopyBundleSettings), false) as CopyBundleSettings;
        if (settings == null || settings.IsNullValue) return;

        // 絶対パスフラグ
        isAbsolutePath = EditorGUILayout.Toggle("Is Absolute Path", isAbsolutePath);

        EditorGUILayout.Space(SPACE_SIZE);
        EditorGUILayout.LabelField("Build Settings", EditorStyles.boldLabel);
        isCopyOnBuild = EditorGUILayout.Toggle("Copy On Build", isCopyOnBuild);

        EditorGUILayout.Space(SPACE_SIZE);
        EditorGUILayout.LabelField("Copy Bundle", EditorStyles.boldLabel);
        // パス指定
        var sourceBundlePath = settings.GetSourceBundlePath(isAbsolutePath);
        GUILayout.Label("Source Bundle Path: " + sourceBundlePath, EditorStyles.boldLabel);

        var destBundlePath = settings.GetDestinationBundlePath(isAbsolutePath);
        GUILayout.Label("Destination Bundle Path: " + destBundlePath, EditorStyles.boldLabel);

        //------------------------------------------------------------
        // バンドルファイルのコピー
        if (GUILayout.Button("Copy"))
        {
            CopyBundles(sourceBundlePath, destBundlePath);
        }

        //------------------------------------------------------------
        EditorGUILayout.Space(SPACE_SIZE);
        EditorGUILayout.LabelField("Copy Catalog", EditorStyles.boldLabel);

        // カタログパス
        var sourceCatalogPath = settings.GetSourceCatalogPath(isAbsolutePath);
        GUILayout.Label("Source Catalog Path: " + sourceCatalogPath, EditorStyles.boldLabel);

        var destCatalogPath = settings.GetDestinationCatalogPath(isAbsolutePath);
        GUILayout.Label("Destination Catalog Path: " + destCatalogPath, EditorStyles.boldLabel);

        // カタログのコピー
        if (GUILayout.Button("Copy Catalog"))
        {
            CopyCatalog(sourceCatalogPath, destCatalogPath);
        }
    }

    //------------------------------------------------------------
    // ビルド完了時の処理
    static void OnBuildCompleted(AddressableAssetBuildResult result)
    {
        // 設定ファイルをAssetDatabaseで読み込む
        var settings = AssetDatabase.LoadAssetAtPath<CopyBundleSettings>(SETTING_FILE_PATH);

        if (settings == null || settings.IsNullValue)
        {
            throw new System.Exception("Not found settings.");
        }

        CopyBundles(settings.GetSourceBundlePath(false), settings.GetDestinationBundlePath(false));
        CopyCatalog(settings.GetSourceCatalogPath(false), settings.GetDestinationCatalogPath(false));

        Debug.Log("CopyBundleFromResourceProject: OnBuildCompleted");
    }

    //------------------------------------------------------------
    // コピー
    static void CopyBundles(string sourcePath, string destPath)
    {
        try
        {
            // コピー元パスチェック
            if (!System.IO.Directory.Exists(sourcePath))
            {
                Debug.LogError("Not found source directory.: " + sourcePath);
                return;
            }

            // コピー先パス取得
            Debug.Log("Copy: " + sourcePath + " -> " + destPath);

            // 指定されたディレクトリ内のファイルをコピー
            System.IO.Directory.CreateDirectory(destPath);
            foreach (var file in System.IO.Directory.GetFiles(sourcePath))
            {
                var fileName = System.IO.Path.GetFileName(file);
                var targetFile = System.IO.Path.Combine(destPath, fileName);
                System.IO.File.Copy(file, targetFile, true);

                Debug.Log("Copy: " + targetFile);
            }
        }
        catch (System.Exception e)
        {
            Debug.LogError(e);
        }
    }

    // catalog.jsonのコピー
    static void CopyCatalog(string sourceCatalogPath, string destPath)
    {
        try
        {
            // コピー元パスチェック
            if (!System.IO.File.Exists(sourceCatalogPath))
            {
                Debug.LogError("Not found catalog file.: " + sourceCatalogPath);
                return;
            }

            // コピー
            System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(destPath));
            System.IO.File.Copy(sourceCatalogPath, destPath, true);

            Debug.Log("Copied catalog: " + sourceCatalogPath + " -> " + destPath);
        }

        catch (System.Exception e)
        {
            Debug.LogError(e);
        }

        AssetDatabase.Refresh();
    }
}

```

</details>


### 設定ScriptableObject
<details><summary>コード</summary>

```C#
using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

[CreateAssetMenu(fileName = "CopyBundleSettings", menuName = "CopyBundleSettings")]
public class CopyBundleSettings : ScriptableObject
{
    [SerializeField] string sourceProjectPath;
    [SerializeField] string catalogDirectoryName;

    const string CATALOG_FILE_NAME = "catalog.json";

    //------------------------------------------------------------
    public bool IsNullValue => string.IsNullOrEmpty(sourceProjectPath) || string.IsNullOrEmpty(catalogDirectoryName);

    //------------------------------------------------------------
    /// <summary> コピー元のバンドルパスを取得 </summary>
    public string GetSourceBundlePath(bool isAbsolutePath, bool checkExist = false)
    {
        var completelyPath = System.IO.Path.Combine(sourceProjectPath, UnityEngine.AddressableAssets.Addressables.RuntimePath, EditorUserBuildSettings.activeBuildTarget.ToString());

        GetPathProcess(ref completelyPath, isAbsolutePath, checkExist);

        return completelyPath;
    }

    /// <summary> コピー先のバンドルパスを取得 </summary>
    public string GetDestinationBundlePath(bool isAbsolutePath, bool checkExist = false)
    {
        var completelyPath = System.IO.Path.Combine(UnityEngine.AddressableAssets.Addressables.RuntimePath, EditorUserBuildSettings.activeBuildTarget.ToString());

        GetPathProcess(ref completelyPath, isAbsolutePath, checkExist);

        return completelyPath;
    }

    /// <summary> コピー元のカタログパスを取得 </summary>
    public string GetSourceCatalogPath(bool isAbsolutePath, bool checkExist = false)
    {
        var targetPath = System.IO.Path.Combine(sourceProjectPath, UnityEngine.AddressableAssets.Addressables.RuntimePath, CATALOG_FILE_NAME);

        GetPathProcess(ref targetPath, isAbsolutePath, checkExist);

        return targetPath;
    }

    /// <summary> コピー先のカタログパスを取得 </summary>
    public string GetDestinationCatalogPath(bool isAbsolutePath, bool checkExist = false)
    {
        var targetPath = System.IO.Path.Combine(System.IO.Path.GetRelativePath(Application.dataPath, Application.streamingAssetsPath), catalogDirectoryName, CATALOG_FILE_NAME);


        GetPathProcess(ref targetPath, isAbsolutePath, checkExist);

        return targetPath;
    }

    //------------------------------------------------------------
    void GetPathProcess(ref string path, bool isAbsolutePath, bool checkExist = false)
    {
        if (checkExist && !System.IO.Directory.Exists(path))
        {
            throw new System.Exception("Not Found Directory: " + path);
        }

        if (isAbsolutePath)
        {
            SetAbsolutePath(ref path);
        }
    }

    void SetAbsolutePath(ref string path)
    {
        path = System.IO.Path.GetFullPath(path);
    }
}

```

</details>

### アセット読み込み処理

<details><summary>コード</summary>

```C#
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.UI;

public class AssetLoader : MonoBehaviour
{
    [SerializeField] Image image;

    [Header("Catalog")]
    [SerializeField] string catalogPath = "Addressables/catalog.json";
    [SerializeField] bool loadCatalog;

    async void Start()
    {
        if (loadCatalog)
        {
            // カタログ追加
            var path = Path.Combine(Application.streamingAssetsPath, catalogPath);

            var catalog = Addressables.LoadContentCatalogAsync(path).Task;
            await catalog;

            if (catalog.Status == System.Threading.Tasks.TaskStatus.RanToCompletion)
            {
                Debug.Log("Catalog loaded successfully");
            }

            Addressables.AddResourceLocator(catalog.Result);
        }

        // アセット読み込み
        var spriteHandler = Addressables.LoadAssetAsync<Sprite>("UnityLogo");
        await spriteHandler.Task;

        image.sprite = spriteHandler.Result;
    }
}

```


</details>




# 説明
### ビルド完了時にコピー処理を走らせる
```C#
    // ビルド完了時の処理
    static void OnBuildCompleted(AddressableAssetBuildResult result)
    {
        // 設定ファイルをAssetDatabaseで読み込む
        var settings = AssetDatabase.LoadAssetAtPath<CopyBundleSettings>(SETTING_FILE_PATH);

        if (settings == null || settings.IsNullValue)
        {
            throw new System.Exception("Not found settings.");
        }

        CopyBundles(settings.GetSourceBundlePath(false), settings.GetDestinationBundlePath(false));
        CopyCatalog(settings.GetSourceCatalogPath(false), settings.GetDestinationCatalogPath(false));

        Debug.Log("CopyBundleFromResourceProject: OnBuildCompleted");
    }
```


```C#
    void OnEnable()
    {
        BuildScript.buildCompleted = null;
        BuildScript.buildCompleted += OnBuildCompleted;
    }
```





- `AssetDatabase`でファイルパスなどが指定された設定用ScriptableObject(CopyBundleSettings)を直接読み込み、パスなどの設定を取得してます
- 設定ファイルがなかったり、値が設定されていなかったら警告を出します
- 問題がなければバンドルとカタログをコピーします
- この処理を`OnEnable`のタイミングで、`BuildScript.buildCompleted`イベントに登録します


### 利用側でカタログを登録して読み込む
##### カタログ追加
```C#
if (loadCatalog)
{
    // カタログ追加
    var path = Path.Combine(Application.streamingAssetsPath, catalogPath);

    var catalog = Addressables.LoadContentCatalogAsync(path).Task;
    await catalog;

    if (catalog.Status == System.Threading.Tasks.TaskStatus.RanToCompletion)
    {
        Debug.Log("Catalog loaded successfully");
    }

    Addressables.AddResourceLocator(catalog.Result);
}
```

- **StreamingAssetsに配置されたカタログを、`Addressables.LoadContentCatalogAsync()`で読み込みます**
- **読み込んだ後に、`Addressables.AddResourceLocator()`でカタログを本プロジェクトに追加します**


##### アセット読み込み
```C#
// アセット読み込み
var spriteHandler = Addressables.LoadAssetAsync<Sprite>("UnityLogo");
await spriteHandler.Task;

image.sprite = spriteHandler.Result;
```

- **読み込み時のキーは、リソースプロジェクト側で設定されたものを指定する必要があります**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2419795/cb8d95af-e1eb-2231-3c1d-d7c5bbc291a0.png)







# さいごに
こんな感じでプロジェクトを分けて、エディタ上の無駄な待機時間などを短縮しましょう～

### 参考
https://docs.unity3d.com/ja/Packages/com.unity.addressables@1.20/manual/MultiProject.html

https://docs.unity3d.com/ja/Packages/com.unity.addressables@1.20/manual/LoadContentCatalogAsync.html

https://tsubakit1.hateblo.jp/entry/2019/03/27/011230