5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Unityエンジニアのための設計入門① 設計を学ぶ意義

Last updated at Posted at 2025-06-03

シリーズ目次

Unityエンジニアのための設計入門① 設計を学ぶ意義
Unityエンジニアのための設計入門② 複雑な分岐にState, 要求と実行の分離にCommand

今回の目的

  • 主にジュニアエンジニア向けに設計を学ぶ意義を共有します
  • 「設計勉強したい!」って思ってもらえたら目標達成

設計とは何か

上流工程で呼ばれる「設計」の意味

  • 要件定義を満たす全体構造(アーキテクチャ)の策定
  • システム化範囲の決定、データ/通信方式、リスクとコストの見積もりなど
  • 大規模・長期案件での「設計書」はこちらのニュアンスが強い

下流工程で呼ばれる「設計」の意味 ← 今回の話はこっち

  • ソースコードを変更しやすい形に構造化すること
  • クラス設計・責務分割・テスト容易性・依存関係の制御など
  • どんな開発なのかによっても、どれほど設計に力を入れるべきかは変わります
    • 長期的な開発, 多数の開発者, 複雑な実装であるほど、設計のメリットは大きいです
    • すぐ捨てるコードなど速度優先の場合は設計を考慮しすぎない方が良い時もあります

設計の考慮がメリットになる具体的な事例

機能追加・機能変更の工数を下げる

設計しないとどう困るのか

設計を行わず、MonoBehaviourに何でも書いてしまうなどすると、小さな変更でも広範囲を書き換えることに繋がります。
そうなると、バグが混入しやすく、レビューやテストの負荷も増えます。

Before - いわゆる“MonoBehaviour何でも屋”スクリプト

プロフィール画面の表示と保存を1クラスに押し込めています。
この実装だと「ユーザー名をクラウド保存したい」「名前のバリデーションを追加したい」だけで、スクリプト全体を調べる必要が出てきます。

public class ProfileView : MonoBehaviour
{
    [SerializeField] private TMP_Text nameText;
    [SerializeField] private TMP_InputField input;
    [SerializeField] private Button saveButton;

    private void Start()
    {
        Load();
        saveButton.onClick.AddListener(Save);
    }

    private void Load()
    {
        var name = PlayerPrefs.GetString("UserName", "Guest");
        nameText.text = name;
        input.text = name;
    }

    private void Save()
    {
        var name = input.text;
        PlayerPrefs.SetString("UserName", name);
        nameText.text = name;
    }
}

上記はまだ軽い実装かもしれませんが、複雑な実装になるほど、バグが混入しやすくなります。

After - 責任を分けて“差し替え可能”にする

変更点:

  1. ユーザー名の保存先をIUserNameRepositoryインターフェースで抽象化
  2. 表示(View)と処理(Presenter)を分離し、UI変更が他へ波及しないようにする
  3. 将来DIコンテナを導入したら、「クラウド保存版リポジトリクラス」を実装し、DIで差し込むだけでクラウド保存の実装が完了するようにする
// Repository
public interface IUserNameRepository
{
    string Load();
    void Save(string name);
}

public class LocalUserNameRepository : IUserNameRepository
{
    private const string Key = "UserName";

    public string Load() => PlayerPrefs.GetString(Key, "Guest");
    public void Save(string name) => PlayerPrefs.SetString(Key, name);
}

// Presenter
public class ProfilePresenter
{
    private readonly ProfileView _view;
    private readonly IUserNameRepository _repo;

    // 将来DIコンテナの導入で、IUserNameRepositoryを継承するクラウド保存クラスの差し込みが可能に
    public ProfilePresenter(ProfileView view, IUserNameRepository repo)
    {
        _view = view;
        _repo = repo;
    }

    public void Initialize()
    {
        // ユーザー名の初期表示
        var name = _repo.Load();
        _view.Show(name);

        _view.SaveRequested += OnSaveRequested;
    }

    private void OnSaveRequested(string newName)
    {
        // nameのバリデーションはここに追加すれば完了
    
        _repo.Save(newName);
        _view.Show(newName);
    }

    public void Dispose()
        => _view.SaveRequested -= OnSaveRequested;
}

// View (NOTE: 制御の関係を考慮して、Presenterを関知しない実装にしています)
public class ProfileView : MonoBehaviour
{
    [SerializeField] private TMP_Text nameText;
    [SerializeField] private TMP_InputField input;
    [SerializeField] private Button saveButton;

    public event Action<string> SaveRequested;

    private void Awake()
    {
        saveButton.onClick.AddListener(() => SaveRequested?.Invoke(input.text));
    }

    public void Show(string userName)
    {
        nameText.text = userName;
        input.text = userName;
    }

    private void OnDestroy()
    {
        saveButton.onClick.RemoveAllListeners();
    }
}

実装は長くなっていますが(速度優先の場合は考慮しすぎない方がいい理由はこれ)、
クラウド保存や名前のバリデーションなどの機能追加およびレビュー・テストが楽に出来るようになっています。
UIの文言を変える時も、Viewを変更するだけで済みます。

役割分担がしやすくなる

設計しないとどう困るのか

同時並行で開発に関わる人数が増えると、役割分担が必要になります。

ネットワーク通信とUIの実装が同じファイルにあると
「通信担当がデータ形式を変えた」→「UI担当のコードがGitでコンフリクト」
のように互いの変更を正しく噛み合わせることが難しくなります。

Before - 1クラスに全部入り

public class HighScoreManager : MonoBehaviour
{
    [SerializeField] private TMP_Text scoreText;

    IEnumerator Start()
    {
        // サーバーから取得GET
        using var req = UnityWebRequest.Get("https://api.example.com/highscore");
        yield return req.SendWebRequest();

        var score = int.Parse(req.downloadHandler.text);
        scoreText.text = score.ToString();

        // ボタンが押されたらPOST
        // …UI と通信コードが混在…
    }
}

After - レイヤを分けて “担当の境界線” をつくる

  • Service:通信だけを担当
  • Presenter:Serviceの結果をUIに橋渡し
  • View:UI表示と入力検知のみ
// Service
public interface IHighScoreService
{
    UniTask<int> FetchAsync();
    UniTask SubmitAsync(int score);
}

public class RestHighScoreService : IHighScoreService
{
    private const string Url = "https://api.example.com/highscore";

    public async UniTask<int> FetchAsync()
    {
        using var req = UnityWebRequest.Get(Url);
        await req.SendWebRequest();
        
        return int.Parse(req.downloadHandler.text);
    }

    public async UniTask SubmitAsync(int score)
    {
        var json = JsonUtility.ToJson(new { score });
        
        using var req = new UnityWebRequest(Url, "POST")
        {
            uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(json)),
            downloadHandler = new DownloadHandlerBuffer()
        };
        req.SetRequestHeader("Content-Type", "application/json");
        
        await req.SendWebRequest();
    }
}

// Presenter
public class HighScorePresenter : IDisposable
{
    private readonly HighScoreView      _view;
    private readonly IHighScoreService  _service;

    public HighScorePresenter(HighScoreView view, IHighScoreService service)
    {
        _view    = view;
        _service = service;

        _view.SubmitClicked += OnSubmitClicked;
    }

    public async UniTask InitializeAsync()
    {
        var score = await _service.FetchAsync();
        _view.Show(score);
    }

    private async void OnSubmitClicked(int score)
    {
        await _service.SubmitAsync(score);
        _view.Show(score);
    }

    public void Dispose() => _view.SubmitClicked -= OnSubmitClicked;
}

// View
public class HighScoreView : MonoBehaviour
{
    [SerializeField] private TMP_Text scoreText;
    [SerializeField] private Button   submitButton;

    public event Action<int> SubmitClicked;

    // 本当はhighScoreの持たせ方は要考慮。説明の本筋から外れるので省略。
    private int _highScore;

    private void Awake()
        => submitButton.onClick.AddListener(
               () => SubmitClicked?.Invoke(_highScore));

    public void Show(int score) => scoreText.text = score.ToString();
}

通信仕様が変わっても、Serviceクラスだけ直せば、UI担当のコードは触らずに済みます。
こうしてUI担当と通信担当が同時並行で作業でき、コンフリクトを回避できます。

テストしやすくなる

テストの意義

Unityの仕事現場でよく起きる「ヒヤッと体験」を思い浮かべてください。

ケース 何が起こる? テストがあれば…
広告SDKを最新版へ差し替え シーン遷移後クラッシュ
直前のコミットでは問題なし
回帰テストが失敗し「広告SDKまわりの変更が原因」と即判明。修正に集中できる
昔の機能を後日復活 記憶を頼りに復活 → バグ連発
当時の担当は退職済み
テスト駆動開発 (TDD)で残していた振る舞いテストが仕様書代わり。新人でも安全に改修
大規模リファクタ 100ファイル以上の移動・削除
動くかどうか確かめるのに1人日
EditModeテストが数秒で完走。自動テストが成功するなら「まだ壊れていない」と確信し、作業が半日で終了

つまりテストは

  • エンジニア → 自信を持って変更できる
  • 会社 → バグ修正コストとリリース遅延を最小化できる

という保険であり、開発速度の加速にも繋がります。

現実にはテストコードを実装する時間を確保できない場面や正しく自動テスト出来ない場面もありますが、やるのが望ましいのは間違いないかなと思います。

テストで用いられるUnity Test Frameworkに関しては少し古いですが、以下が素晴らしい記事かと感じました。
https://qiita.com/riekure/items/b0f89280ecfcfa626f7b

テストしやすくなる設計への3Tips

① ロジックを純C#クラスに切り出す

MonoBehaviourStart, Updateなどランタイムのイベントに縛られるため、EditorでPlay(TestFrameworkのPlayModeテスト含む)しないと動きません。
そうではなくEditModeテストで短い時間に完了すると、多数のテストケースがある時に省略できる時間が大きくなりますし、テスト駆動開発も苦でなくなります。
そのため、ロジックは純C#クラスに切り出すことが望ましいです。

② 必要に応じて依存をインターフェースに置き換える

ネットワーク、時間待ち、プラットフォーム依存APIはモックに差し替え出来るように設計します。
この設計で、テストでは即時レスポンス、本番では実サービスと切り替えられるようになります。
また、サーバー側の実装が完了していなくても、クライアントのテストを行うことが出来るというメリットもあります。

③ DI(依存性注入)で本番とテストを切り替える

VContainerなどのコンテナを使うと、テスト用の実装を注入するだけで同じユースケースを別環境で検証できます。

サンプル実装 - 高スコア送信ユースケース

ロジック(テスト対象)

public interface IHighScoreService
{
    UniTask<int> FetchAsync();
    UniTask SubmitAsync(int score);
}

public class HighScoreUseCase
{
    private readonly IHighScoreService _service;
    public HighScoreUseCase(IHighScoreService service) => _service = service;

    /// <summary>
    /// currentの値がサーバー保存値より高ければ送信し、送信したかどうかを返す
    /// </summary>
    public async UniTask<bool> SubmitIfTopAsync(int current)
    {
        var latest = await _service.FetchAsync();
        if (current <= latest) return false;

        await _service.SubmitAsync(current);
        return true;
    }
}

テストコード(Unity Test Framework / EditMode)

public class HighScoreUseCaseTests
{
    [UnityTest]
    public IEnumerator SubmitIfTopAsync_HigherScore_IsSent() => UniTask.ToCoroutine(async () =>
    {
        // サービスをモック化
        var mock = Substitute.For<IHighScoreService>();
        mock.FetchAsync().Returns(UniTask.FromResult(1000));

        // ユースケースに注入
        var useCase = new HighScoreUseCase(mock);

        // メソッドを呼び出し
        var didSend = await useCase.SubmitIfTopAsync(1500);

        // 振る舞いを検証
        Assert.IsTrue(didSend, "1500は1000より高いので送信されるはず");
        await mock.Received(1).SubmitAsync(1500);
    });
}

設計を学ぶのにおすすめの書籍(ジュニアエンジニア向け)

まとめ

  • 下流工程の設計 = コードを後で変更しやすくする
  • 設計しておくと
    • 機能追加がとても楽
    • チーム開発で担当の境界線が明確になる
    • 自動テストが書きやすく、安心してリファクタ出来る
  • 今日紹介した例は設計の入口です。以下を行ってみるのをおすすめします!
    1. MonoBehaviour1ファイルに全部入りのコードを探す
    2. UI・ロジック・インフラをインターフェースで切り分ける
    3. DIかFactoryでクラスの差し替えを体験する

次回はStateパターン,Commandパターンの記事を書くかも。
お楽しみに!(忙しくて書けなかったらごめんなさい)

5
5
1

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
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?