LoginSignup
23

More than 1 year has passed since last update.

interfaceが理解できない方へ「Unityで使うinterfaceの5つの使い方」

Last updated at Posted at 2022-11-23

interfaceが理解しずらい大きな要因は、道具のような側面 を持つからではないでしょうか。interfaceの使い方を覚える事で理解が進みますので5つの使い方を紹介したいと思います。

[使い方1] 依存性の逆転

抽象に依存させることで大切なデータや処理を外部から守る事ができます。
下記のコードではPlayerオブジェクトはセーブシステムの事を知りません。セーブシステムが変更になってもその影響を受けません。結果、大切なオブジェクトPlayerの安定性が向上します。確認、修正の必要が無いコードが多ければ多いほどバグは少なくなります。

サンプルコード

Playerとセーブシステムを切り離す為のinterface

public interface IPlayerStatusRepository
{
    PlayerStatus Get();
    void Set(PlayerStatus status);
}

Repository
セーブデータの保存/ロードを担当、下記はダミーなので何も保存しない。

/// <summary>
/// 何も保存しないダミー
/// </summary>
public sealed class DummyPlayerStatusRepository : IPlayerStatusRepository
{
    public PlayerStatus Get() => new (10);

    public void Set(PlayerStatus status){}
}

Player
Repositoryが変更になってもPlayerに変更が発生しないのがコードから分かる

using UnityEngine;

public sealed class Player : MonoBehaviour
{
    PlayerStatus _status;
    IPlayerStatusRepository _repository;
    
    /// <summary>
    /// 依存の解決
    /// </summary>
    public void Inject(IPlayerStatusRepository repository)
    {
        _repository = repository;
        _status = _repository.Get();
    }

    /// <summary>
    /// ダメージを受けた
    /// </summary>
    public void TakeDamage(int value)
    {
        // Hpを減らす
        _status.RemoveHealth(value);
        // PlayerStatusに変更が発生したのでRepositoryに設定
        _repository.Set(_status);
    }
}

Resolve 依存の解決
DIを使った方がいいですがここでは適当にAwake()で実行
本番用のRepositoryが完成したらDummyPlayerStatusRepositoryを本番用に書き換える想定

void Awake()
{
    // 依存の解決
    _player.Inject(new DummyPlayerStatusRepository());
}

[使い方2] 実装の宣言

「私は〇〇ができるよ」と外部に宣言できます。interfaceを実装していれば処理を実行するという用途に使えます。
下記のコードでは、EnemyTreasureChest(宝箱)に「私はダメージを受ける事ができるよ」と外部に宣言し外部からダメージを与える処理を実行する例です。

サンプルコード

ダメージを受ける事を宣言するinterface

public interface IDamageable
{
    int Health { get; }
    void TakeDamage(int value);
}

敵はダメージを受けさせたいので実装

using UnityEngine;

/// <summary>
/// 敵
/// </summary>
public sealed class Enemy : MonoBehaviour, IDamageable
{
    public int Health => _health;

    int _health = 10;
    
    public void TakeDamage(int value)
    {
        _health -= value;
        if (_health <= 0)
        {
            // Healthが0になった場合の処理
        }
    }
}

宝箱もダメージを受けさせたいので実装

using UnityEngine;

/// <summary>
/// 宝箱
/// </summary>
public sealed class TreasureChest : MonoBehaviour, IDamageable
{
    public int Health => _health;

    int _health = 1;
    
    public void TakeDamage(int value)
    {
        _health -= value;
        if (_health <= 0)
        {
            // Healthが0になった場合の処理
        }
    }
}

Playerに衝突したオブジェクトにIDamageableが実装されていれば殴る

using UnityEngine;

public sealed class Player : MonoBehaviour
{
    /// <summary>
    /// 衝突
    /// </summary>
    void OnCollisionEnter(Collision collision)
    {
        // IDamageableを実装していたら
        if (collision.gameObject.TryGetComponent<IDamageable>(out var damageable))
        {
            // 殴る
            damageable.TakeDamage(1);
        }
    }
}

[使い方3] 設計の明示

後から実装を追加したい場合や、複数人で開発する際、interfaceにする事で必要な機能が分かりやすくなり便利です。
下記コードはシェア機能の例です。例えばiOSはSwiftが書けるエンジニアに、AndroidはJavaが書けるエンジニアにお願いするとします。各エンジニアはinterface内にある必要な機能を実装するだけで良いので伝える事も最小限で済みます。

サンプルコード

シェアに必要な機能を表すinterface
interfaceを見るだけで「テキストのシェア」と「画像+テキストのシェア」機能が必要な事が分かります。

public interface IShareHandler
{
    /// <summary>
    /// テキストのシェア
    /// </summary>
    void ShareText(string text);
    /// <summary>
    /// 画像とテキストのシェア
    /// </summary>
    void ShareImageAndText(byte[] image, string text);
}

iOSでシェア機能の実装
Swiftが書けるエンジニアにネイティブプラグインを書いてもらう

using UnityEngine;

/// <summary>
/// iOSでのシェア
/// </summary>
public sealed class iOSShare : MonoBehaviour, IShareHandler
{
    public void ShareText(string text)
    {
        // iOSでテキストをシェア
    }

    public void ShareImageAndText(byte[] image, string text)
    {
        // iOSで画像とテキストをシェア
    }
}

Androidでシェア機能の実装
Javaが書けるエンジニアにネイティブプラグインを書いてもらう

using UnityEngine;

/// <summary>
/// Androidでのシェア
/// </summary>
public sealed class AndroidShare : MonoBehaviour, IShareHandler
{
    public void ShareText(string text)
    {
        // Androidでテキストをシェア
    }

    public void ShareImageAndText(byte[] image, string text)
    {
        // Androidで画像とテキストをシェア
    }
}

Editorでシェア機能の実装
エディタは実行を確認できれば良いのでコメントを表示するだけ

using UnityEngine;

/// <summary>
/// エディタでのシェア
/// </summary>
public sealed class EditorShare : IShareHandler
{
    public void ShareText(string text) => Debug.Log($"ShareText : {text}");

    public void ShareImageAndText(byte[] image, string text) => Debug.Log($"ShareImageAndText : {text}");
}

シェアを管理/実行するスクリプト
プラットフォームによって使うスクリプトを変更する

using UnityEngine;

public static class Share
{
    static IShareHandler s_handle;

    /// <summary>
    /// 初期化 ※対応プラットフォームが増えたらここに追加
    /// </summary>
    public static void Initialize()
    {
        if (s_handle != default)
        {
            return;
        }

#if UNITY_EDITOR
        // Editorの場合
        s_handle = new EditorShare();
#elif UNITY_ANDROID
        // Android端末の場合
        var obj = new GameObject().AddComponent<AndroidShare>();
        Object.DontDestroyOnLoad(obj.gameObject);
        s_handle = obj;
#elif UNITY_IOS
        // iOS端末の場合
        var obj = new GameObject().AddComponent<iOSShare>();
        Object.DontDestroyOnLoad(obj.gameObject);
        s_handle = obj;
#endif
    }

    /// <summary>
    /// テキストのシェア
    /// </summary>
    public static void ShareText(string text) => s_handle.ShareText(text);

    /// <summary>
    /// 画像とテキストのシェア
    /// </summary>
    public static void ShareImageAndText(byte[] image, string text) => s_handle.ShareImageAndText(image, text);
}

利用側は、プラットフォームに左右されず同じメソッドを呼び出す

Share.ShareText("テキストのシェア");

[使い方4] グループ化

まったく異なる機能を持つスクリプトにinterfaceを実装する事で管理側でグループに対して処理を実行する事ができます。
下記コードは、Androidのバックボタン対応のスクリプトです。Dialogが開いている時にバックボタンを押された場合、ダイアログを閉じる。StoryPlayerが再生中にバックボタンを押された場合ストーリーを終了するなど、色々なオブジェクトにバックボタン処理を追加する事ができます。

サンプルコード

Androidバックボタン対応を可能にするinterface

/// <summary>
/// Androidバックボタンを利用したいスクリプトに実装
/// </summary>
public interface IAndroidBackButtonHandler
{
    void Close();
}

Dialog
ダイアログを開いたタイミングで管理スクリプトにAddする
ダイアログを閉じるタイミングで管理スクリプトにRemoveする

using UnityEngine;

/// <summary>
/// ダイアログ
/// </summary>
public sealed class Dialog : MonoBehaviour, IAndroidBackButtonHandler
{
    /// <summary>
    /// ダイアログを開く
    /// </summary>
    public void OpenDialog()
    {
        AndroidBackButtonInvoker.Add(this);
        // (省略)
    }

    /// <summary>
    /// ダイアログを閉じる
    /// </summary>
    public void CloseDialog()
    {
        AndroidBackButtonInvoker.Remove(this);
        // (省略)
    }
    
    /// <summary>
    /// Addした場合、AndroidBackButtonInvokerから呼ばれる
    /// </summary>
    public void Close() => CloseDialog();

    /// <summary>
    /// このオブジェクトがDestroyされた場合もRemoveする
    /// </summary>
    void OnDestroy() => AndroidBackButtonInvoker.Remove(this);
}

StoryPlayer
ストーリー再生のタイミングで管理スクリプトにAddする
ストーリー停止のタイミングで管理スクリプトにRemoveする

using UnityEngine;

/// <summary>
/// ストーリープレイヤー
/// </summary>
public sealed class StoryPlayer : MonoBehaviour, IAndroidBackButtonHandler
{
    /// <summary>
    /// ストーリーの再生
    /// </summary>
    public void Play()
    {
        AndroidBackButtonInvoker.Add(this);
        // (省略)
    }

    /// <summary>
    /// ストーリーの停止
    /// </summary>
    public void Stop()
    {
        AndroidBackButtonInvoker.Remove(this);
        // (省略)
    }

    /// <summary>
    /// Addした場合、AndroidBackButtonInvokerから呼ばれる
    /// </summary>
    public void Close() => Stop();

    /// <summary>
    /// このオブジェクトがDestroyされた場合もRemoveする
    /// </summary>
    void OnDestroy() => AndroidBackButtonInvoker.Remove(this);
}

Invoker 管理スクリプト
スクリプトがAddされている状態でAndroidのバックボタンが押されたらAddされたオブジェクトに対してClose()を呼ぶ

using System.Diagnostics;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// Androidバックボタンの管理/実行
/// </summary>
public class AndroidBackButtonInvoker : MonoBehaviour
{
    static AndroidBackButtonInvoker s_instance;
    readonly List<IAndroidBackButtonHandler> _objs = new ();
    
    // エディタとAndroidでのみ初期化可能にする
    [Conditional("UNITY_EDITOR"), Conditional("UNITY_ANDROID")]
    public static void Initialize()
    {
        if (s_instance == default)
        {
            s_instance = new GameObject().AddComponent<AndroidBackButtonInvoker>();
            DontDestroyOnLoad(s_instance.gameObject);
        }
    }

    public static void Add(IAndroidBackButtonHandler androidBackButton)
    {
        if(s_instance != default)
        {
            s_instance._objs.Add(androidBackButton);
        }
    }

    public static void Remove(IAndroidBackButtonHandler androidBackButton)
    {
        if (s_instance != default)
        {
            s_instance._objs.Remove(androidBackButton);
        }
    }

    void Update()
    {
        // Android端末でバックボタンが押されたらCloseを実行する
        if (Input.GetKeyUp(KeyCode.Escape)
            && _objs.Count > 0)
        {
            var lastIdx = _objs.Count - 1;
            var obj = _objs[lastIdx];
            obj.Close();
            // Close後に破棄処理が実行されていない場合、Removeする
            if (_objs.Count - 1 == lastIdx)
            {
                _objs.RemoveAt(lastIdx);
            }
        }
    }
}

[使い方5] 公開の限定

読み取り専用のinterfaceを実装する事で外部からのアクセスを遮断できます。
下記コードではPlayerStatusのHp追加、Hp削除メソッドを使用できるのはPlayerに限定し、外部からはステータスの内容しか確認できないようにする例です。

サンプルコード

読み取り専用を示すinterface

/// <summary>
/// プレイヤーのステータス(読み取り専用)
/// </summary>
public interface IReadOnlyPlayerStatus
{
    int Health { get; }
}

Status
interfaceを実装

/// <summary>
/// プレイヤーのステータス
/// </summary>
public sealed class PlayerStatus : IReadOnlyPlayerStatus
{
    public int Health { get; private set; }

    public PlayerStatus(int health) => Health = health;

    public void AddHealth(int value) => Health += value;
    
    public void RemoveHealth(int value) => Health -= value;
}

Player
外部からAddHealthRemoveHealthを呼んで欲しく無いので、読み取り専用のinterfaceを外部に公開

using UnityEngine;

public sealed class Player : MonoBehaviour
{
    /// <summary>
    /// ステータス (読み取り専用)
    /// </summary>
    public IReadOnlyPlayerStatus Status => _status;
        
    PlayerStatus _status;

    public void SetStatus(PlayerStatus status) => _status = status;

    void Start()
    {
        // 内部からは呼べる
        _status.AddHealth(5);
        _status.RemoveHealth(5);
    }
}

利用側
外部からはステータスを変更するAddHealthRemoveHealthを普通には呼べない

// 呼べない コンパイルエラーでエディタを再生できない
_player.Status.AddHealth(5);

// キャストすれば呼べるけどReadOnlyで隠蔽しているものを相談も無しにキャストして使う人は怒っても良し!
((PlayerStatus) _player.Status).AddHealth(5);

今回は5つの使い方を通してinterfaceの使い方を説明しました。少しでも理解が進みますと幸いです。ここからは、interfaceを使う際の注意点を説明したいと思います。とても大切な内容なのでもう少しお付き合いください。

interfaceを使う際の注意点

必要以上にinterfaceを使わない

interfaceを使い始めると、とりあえずinterfaceにしとくかと思う事がありますがそれは間違いです。interfaceは必要以上に柔軟性を与えたり、変更を難しくしたりと欠点もあります。特に設計をせず実装しながら考える場合、一旦クラスで実装し必要になったタイミングでinterfaceに切り出すのをおすすめします。

※ 複数人開発の場合は、理由があってすべてinterfaceで受け渡すのをルールにしている場合もあるので、そこはプロジェクトに従いましょう。

1つのinterfaceに対して1つの機能

interfaceを作る際、複数の機能に関わるメソッドを追加してはいけません。1つのinterfaceに対して1つの機能を徹底します。もし色々な機能がどうしても必要な場合はinterfaceである必要が無い可能性が高いです。その場合は継承が適しているかもしれません。

モバイル向けゲームはパフォーマンスに注意

interfaceはキャストして使うのが前提になります。モバイルゲームならキャストは最小限に留めるようにした方が良いです。特にUpdate()内でのキャストは使わない方が良いです。
個人的には、ゲーム起動時のキャストはあまりコストを考えず保守性や柔軟性を優先して使う。ゲーム起動時移行は極力キャストが発生しないように実装します。

Monobehaiviorに実装したinterfaceのnullチェックに注意

Unityの == 演算子はオーバーロードされています。下記のコードを見てください。
interfaceの場合オブジェクトがDestroyされても == nullfalseを返す事が分かります。

// interfaceをキャッシュ
Iinterface playerInterface = _player; // _playerはMonoBehaviourを継承しています
// オブジェクトを削除
Destroy(_player.gameObject);
// 削除直後は判定できないので1フレーム待つ
yield return null;
Debug.Log(_player == null); // [true] gameObjectはちゃんとnullになる
Debug.Log(playerInterface == null); // [false] interfaceはnullにならない

特にinterfaceのメソッド内でtransformなどにアクセスしている場合は、Null Reference Exceptionが発生するので注意が必要です。

個人的にはUnityの== nullは高価なので極力使いません。Update()内に関しては1度も使いません。その代わり管理側でオブジェクトがDestroyされた場合、対象から外すように実装したりします。どうしても使いたい場合は、下記のようなメソッドを使う事で対応できます。

public static class UnityExtensions
{
    public static bool IsDestroyed(this object obj) => obj == null || obj.Equals(null);
}
// interfaceをキャッシュ
Iinterface playerInterface = _player; // _playerはMonoBehaviourを継承しています
// オブジェクトを削除
Destroy(_player.gameObject);
// 削除直後は判定できないので1フレーム待つ
yield return null;
Debug.Log(playerInterface.IsDestroyed()); // [true] IsDestroyedメソッドならinterfaceでもnullになる
[おまけ] Unityの == オーバーロードの中身

Unityは == を下記のメソッドでオーバーロードしています。
最大でキャストが4回、内部でのオブジェクトの確認が2回と重い処理なのがコードから分かります。

private static bool CompareBaseObjects(Object lhs, Object rhs)
{
  // 左右のオブジェクトをSystemのobject型にキャストしnullチェック
  bool flag1 = (object) lhs == null;
  bool flag2 = (object) rhs == null;
  // 両方ともnullの場合trueとする
  if (flag2 & flag1)
    return true;
  // 右のオブジェクトがnullで左のオブジェクトがnullでない場合、左のネイティブオブジェクトが死んでいる時trueとする
  if (flag2)
    return !Object.IsNativeObjectAlive(lhs);
  // 左のオブジェクトがnullで右のオブジェクトがnullで無い場合、右のネイティブオブジェクトが死んでいる時trueとする。
  // 左のオブジェクトも右のオブジェクトもnullでは無い場合、インスタンスIDが同じ時trueとする。
  return flag1 ? !Object.IsNativeObjectAlive(rhs) : lhs.m_InstanceID == rhs.m_InstanceID;
}

interfaceを理解するまでの道のり

プログラムを始めた頃interfaceの勉強をしては、いまいち理解できず諦めるという事を何度か繰り返してきました。ある程度理解してからも思うように使えるまで時間が掛かりました。そして今もまだ勉強中です。

理解できなかった理由を今考えてみると、interfaceが実装されたコードを見ても何処か間接的で似たようなコードにしか見えなかったのが大きいです。冒頭で説明した通りinterfaceは道具のようなもので、ある理由があってinterfaceにしているというのがコードだけでは読み取り辛いのが理解が進まない原因だと思いました。

また、使い始めて思うのはinterface設計があってこそ力を発揮します。 設計ができればできるほど、interfaceをうまく使えるようになります。設計しないで書く場合は、まず普通に書いて必要であればinterfaceに切り出していくようにするとうまく実装できるのでおすすめです。

以上となります。
最後まで読んでいただきありがとうございました!

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23