アセットストアのソースコードを書き換えたいとき、ADF(AssemblyDefinitionFiles)の壁はinterfaceを使って超えよう


投稿日:2024年12月22日 | 最終更新日:2024年12月25日

(この記事は、Unity Advent Calendar 2024のシリーズ2、25日目の記事です)

概要

アセットストアから取得したアセットを使っていると、ちょっとだけソースコードを改変して使いたいということがあります。

基本的には非推奨

一応アセット製作者としては「基本的には書き換えは非推奨」ということは前もってお伝えしておきたいです。
というのも、アセットをバージョンアップする時は新しいバージョンの内容で「上書き」されるため、書き換えた部分が元に戻ってしまうためです。
またバージョンアップの内容によっては、再度同じようにプログラムを書き換えても動かなくなってしまう可能性もあります。
アセットによっては独自の拡張方法を用意している場合もあるので、なるべく書き換えずに実装しましょう。

どうしても書き換えたいとき

とはいえ、アセットストアのアセットは改変可能というのも売りの一つなので、自己責任で書き換えること自体は自由です。
簡単な改変であれば、直接ソースコードを書き換えるだけで可能です。
ただ、自分のプロジェクト内で定義したコンポーネントなどを使おうとするときは、エラーになって使えなくなる時があり、これを解決するには少し工夫が必要になります。

アセット側のコードを書き換えて自分のプロジェクトのコンポーネントを使いたいけどエラーになるコードのサンプル

        //自作のコンポーネントを参照設定しようとしてもコンパイルエラーになる
        [SerializeField] MyComponent myComponent;

        //自作のコンポーネント処理を呼び出す部分
        void SampleMethod()
        {
            myComponent.Method1();
            myComponent.Method2("arg1");
        }

MyComponentが自分のプロジェクトのコンポーネントですが、アセット側では未定義なクラスとなってしまい次のようなエラーになります。
error CS0246: The type or namespace name 'MyComponent' could not be found (are you missing a using directive or an assembly reference?)
これは、アセット側がADF(AssemblyDefinitionFiles)を使っている時に起きます。
ADFを使っているとアセンブリが分かれてしまい、ADFを使っていないプログラムを扱えなくなります。
(ADFを使っている同士ならアセンブリの依存関係を適切に設定すればいいのですが、アセットストアのアセットから独自定義のアセンブリへの依存関係を持つのは、ADFの設定も変更する必要がある上に親子関係が逆転したりと余計ややこしくなりがちであまりお勧めしません)

ADF(AssemblyDefinitionFiles)の壁はinterfaceを使って超えよう

ADF間をまたぐ実装をしたい場合は、C#のinterfaceの機能を使うことで解決できます。

サンプル

アセット側

アセット側のフォルダ以下(改変したいソースコードと同じ場所など)に、ICustom.cs を追加して、以下のように定義する。
(ICustomは、サンプルとしてのとりあえずの名前なので、実際の名前は好きにつけてください)

    //カスタム処理のインターフェースを定義
    public interface ICustom
    {
        //アセット側から呼び出したい、自作コンポーネントのメソッド宣言のみする
        void Method1();
        void Method2(string arg1);
    }

アセット側で改変したいコードでは、次のようにinterfaceを使って自作コンポーネントのメソッドを呼び出す形にする。

        //自作のコンポーネントをAddComponentしているGameObjectを設定
        [SerializeField] GameObject customTarget;

        //自作のコンポーネントをインターフェース経由で取得
        ICustom Custom
        {
            get
            {
                if (custom == null)
                {
                    custom = customTarget.GetComponent<ICustom>();
                }
                return custom;
            }
        }
        ICustom custom;

        //自作のコンポーネント処理を呼び出す部分
        void SampleMethod()
        {
            Custom.Method1();
            Custom.Method2("arg1");
        }

自分のプロジェクトのコード

自作のコードでICustomを継承して、メソッド内の具体的な機能を実装します。

    //自作のコードにICustomを継承
    //ICustomの各メソッドの中身を実装する
    public class MyComponent : MonoBehaviour, ICustom
    {
        //ICustomで宣言したメソッドの具体的な実装を行う。

        public void Method1()
        {
            Debug.Log("Method1");
        }

        public void Method2(string arg1)
        {
            Debug.Log("Method2: " + arg1);
        }
    }

UPMパッケージの改変は基本的には諦めよう

アセットストアのアセットは基本的にはAssetsフォルダ以下にファイルそのものがあるため、それを直接書き換えることが可能です。
Unity公式の拡張パッケージなどのPackagesフォルダ以下にあるもの(UPMパッケージ)は、ファイルの実体は別の場所にあり、書き換えてもUnityを再起動したら元に戻ってしまいます。
そのため、UPMパッケージを書き換えるのは基本的には諦めたほうが良いでしょう。
(一応無理をすればなんとかなるはずですが、ここでは割愛します)

補足 UnityとC#のinterfaceは相性が良い

上記の手法は、本来はアセットストアのアセットを書き換えるためにあるものではなく、疎結合(最小限の依存関係)を実装するために使う手法の一つです。

今回の例に限らず、実はUnityの仕組みとC#のinterfaceはとても相性が良いです。
Unityはシーンファイルやプレハブ、ScriptableObject経由で参照設定が可能なため、次のような形で実装が可能だからです。

  1. プログラム的には依存関係はinterfaceのみに絞る
  2. 具体的な処理はintefaceを継承して実装する
  3. interfaceを使った依存性の注入(依存関係を外部から設定すること)を、インスペクターウィンドウで設定したりGameObjectの階層構造を使って行う。

1と2は一般的なプログラムの概念で、3がUnity特有の便利な点です。

指定したinterfaceを継承しているコンポーネントを、GetComponent系のメソッドで取得できる点が特に使いやすく、外部から依存関係を手軽に設定できます。
また、GameObjectの階層構造に頼るアプローチを採用するなら、事前の初期化や参照設定などを省いてさらに簡単に実装が可能です。
たとえば上記のサンプルも、目的のコンポーネントが自身と子以下のGameObjectにある前提ならcustomTargetは必要なくなりGetComponentInChildrenをするだけでよくなります。
親オブジェクトにある前提ならGetComponentInParentをするだけでよくなります。

        //自身か子以下のGameObjectにある、自作のコンポーネントをインターフェース経由で取得
        ICustom Custom
        {
            get
            {
                if (custom == null)
                {
                    custom = GetComponentInChildren<ICustom>(true);
                }
                return custom;
            }
        }
        ICustom custom;

インスペクターウィンドウを使って依存関係を設定するのは手作業が必要なため、プログラマーの設計方針よっては避けたいケースとして扱われることもありますが、
GameObjectの階層構造に頼るアプローチであれば、プログラムのみで半自動的に行えるため手作業での設定は大幅に減ります。
AddComponentはプログラムでも行えるため、プログラムのみで任意のタイミングで設定することも可能です。
(ただし、現実的には手作業でAddComponentするケースが多いため、厳格に行うのであればTestRunnerなどでシーンやプレハブ内のGameObjectの階層構造をチェックする仕組みを作ることが必要になるかと思います)

Unity UI(UGUI)などはうまくinterfaceとGameObjectの階層構造を使って拡張をしやすい仕組みとして設計されているため、ソースコードを読んで参考にしてみるのもよいかもしれません。

[SerilaizeFiled]にinterfaceは使えない

interfaceに慣れてくると、SerilaizeFiledにはコンポーネントではなくinterfaceを設定したくなるかもしれません。
ですが、これは不可能です。

        //SerializeFieldにインターフェースを設定しても、コンパイルエラーにはならないが、インスペクターウィンドウでは表示できず設定ができない。
        [SerializeField] ICustom custom;

[SerializeReference]という一見インターフェースを参照できそうなものはあるのですが、用途が違うため使用できません。
これはコンポーネント等のUnityEinge.Objectへの参照を設定するためのものではなく、Unityのシリアライズでポリモーフィズムを使う(シリアライズして埋め込む[Serializable]なC#クラスの型を、抽象クラスやインターフェースでも設定可能にする)ためのものです。

エディタ拡張が得意であれば、PropertyDrawer等のUnityエディタ拡張機能を使って、「指定のインターフェースを継承するコンポーネントを持つGameObjectのみ設定可能にする」という制限をかける機能を作ってみるのもよいかもしれません。