Skip to content
ToanLB
Go back

Tại Sao Khi Làm Dự Án Lớn, Chúng Ta Thường Hạn Chế Dùng Singleton?

Note

Lời mở đầu: Bài viết này mình đào sâu từ một câu hỏi phỏng vấn Unity quen thuộc: “Tại sao càng làm dự án lớn, nhiều anh em lại càng hạn chế dùng Singleton mặc dù nó rất tiện?”. Dưới đây là những góc nhìn và kinh nghiệm thực tế mình rút ra sau những lần làm dự án dài hơi, cùng anh em mổ xẻ và bàn luận nhé.


1. Thói quen gõ .Instance tiện tay lúc đầu

Hồi mới bắt đầu làm game với Unity, mình cũng như nhiều anh em, cứ thấy cần data hay gọi hàm ở đâu là gõ .Instance ngay cho nhanh.

Lúc làm game nhỏ hoặc làm prototype trong 1-2 tuần để kịp tiến độ, cách này cực kỳ tiện. Không phải kéo thả reference trên Inspector, không phải truyền tham số qua lại giữa các class, mở script ra là dùng được ngay.

Nhưng khi dự án bắt đầu mở rộng, kéo dài từ vài tháng đến cả năm với nhiều người cùng tham gia, cái “tiện tay” ban đầu đó bắt đầu tích lũy thành technical debt và phát sinh nhiều vấn đề đau đầu.


2. Mổ xẻ nguyên nhân & Cơ chế bên dưới

Để hiểu vì sao Singleton dễ gây lỗi khi dự án phình to, chúng ta cùng nhìn vào cả góc độ kiến trúc phần mềm lẫn cơ chế vận hành bên dưới của Unity Engine và C# Runtime (Mono/IL2CPP).

2.1. Bản chất vận hành trong Unity Engine & C# Runtime

flowchart TD
    subgraph GC["C# Runtime (Mono / IL2CPP)"]
        GCRoot["Static GC Root (Instance)"] -->|Giữ tham chiếu vĩnh viễn| SingObj["Singleton Instance (Heap)"]
        SingObj -->|Giữ tham chiếu| HeavyRes["AudioClips / Textures / Scene Objects"]
    end

    subgraph UnityLife["Vòng đời Unity (Lifecycle)"]
        A["Scene 1 Unload"] -.->|Không thể giải phóng bộ nhớ| HeavyRes
        B["Awake() Race Condition"] -->|A gọi B khi B chưa Awake| NullEx["NullReferenceException ngẫu nhiên"]
        C["OnDestroy() gọi Instance"] -->|Lazy Loading âm thầm tạo mới| Zombie["Zombie Object qua Scene mới"]
    end

2.2. Ba vấn đề kiến trúc khi dự án mở rộng

flowchart TD
    subgraph TightCoupled["Phụ thuộc chằng chịt (Tight Coupling)"]
        UI["UIController"] -->|gọi trực tiếp| SM["SoundManager.Instance"]
        SM -->|kéo theo| DM["DataManager.Instance"]
        DM -->|kéo theo| CF["Config.Instance"]
        Combat["CombatController"] -->|dính chặt| GM["GameManager.Instance"]
    end

    subgraph GlobalState["Mất kiểm soát quyền sửa dữ liệu"]
        Enemy["EnemyController"] -->|Sửa trực tiếp| Score["GameManager.score"]
        Quest["QuestManager"] -->|Sửa trực tiếp| Score
        Combo["ComboSystem"] -->|Sửa trực tiếp| Score
    end

1. Phụ thuộc ngầm (Hidden Dependency) - Khó tái sử dụng module

Khi nhìn vào phần khai báo biến của một class, nếu thấy không có reference nào, chúng ta dễ lầm tưởng nó hoàn toàn độc lập. Nhưng sâu bên trong các hàm con, nó lại gọi rải rác đến 4-5 Singleton khác nhau.

2. Rào cản khi viết Unit Test & Mock dữ liệu

Logic tính toán sát thương (Damage = Attack - Defense) bản chất là một phép tính thuần túy chạy trong chưa đầy 1 micro-giây.

3. Dữ liệu toàn cục (Global State) - Không rõ ai là chủ sở hữu (Ownership)

Singleton cấp quyền đọc/ghi cho bất kỳ script nào ở bất kỳ đâu.


3. Code minh họa: Trước và sau khi bóc tách

Dưới đây là ví dụ thực tế về việc bóc tách logic chiến đấu (CombatController) từ dạng phụ thuộc chặt vào Singleton sang dạng độc lập, dễ viết Unit Test.

🔴 Cách viết tiện tay ban đầu (Thường gặp trong dự án nhỏ/prototype)

using UnityEngine;

// Class vừa lo logic tính toán vừa gọi trực tiếp các Singleton toàn cục
public class CombatController : MonoBehaviour
{
    public void DealDamage(int baseDamage)
    {
        // 1. Phụ thuộc ngầm vào GameManager để lấy chỉ số phòng thủ
        int defense = GameManager.Instance.CurrentEnemyDefense;
        int finalDamage = Mathf.Max(1, baseDamage - defense);

        // 2. Gọi trực tiếp SoundManager
        SoundManager.Instance.PlaySfx("Hit_Sound");

        // 3. Tự ý thay đổi biến điểm số toàn cục
        GameManager.Instance.PlayerScore += finalDamage * 10;
        
        Debug.Log($"Gây {finalDamage} sát thương. Hàm này dính chặt Singleton nên không thể test độc lập.");
    }
}

Điểm hạn chế khi mở rộng: Muốn test hàm DealDamage, bắt buộc phải có Scene Unity chứa cả GameManager và SoundManager. Không thể chạy test độc lập trong môi trường CI/CD thuần C#.


🟢 Cách tiếp cận bóc tách / modular (Phù hợp khi dự án phình to)

Chúng ta tách logic tính toán ra một class thuần C# (POCO - Plain Old C# Object), và sử dụng Interface để truyền các phụ thuộc vào một cách tường minh (Dependency Injection):

using System;
using UnityEngine;

// 1. Khai báo các Interface rõ ràng cho từng nhiệm vụ
public interface IEnemyStatsProvider
{
    int Defense { get; }
}

public interface IAudioService
{
    void PlaySfx(string soundName);
}

public interface IScoreTracker
{
    void AddScore(int amount);
}

// 2. Class tính toán thuần túy C# (không phụ thuộc UnityEngine, chạy cực nhanh)
public class DamageCalculator
{
    public int CalculateDamage(int baseAttack, int targetDefense)
    {
        return Math.Max(1, baseAttack - targetDefense);
    }
}

// 3. Controller nhận các dependency từ bên ngoài truyền vào tường minh
public class CombatController : MonoBehaviour
{
    private DamageCalculator _damageCalculator;
    private IEnemyStatsProvider _enemyStats;
    private IAudioService _audioService;
    private IScoreTracker _scoreTracker;

    // Truyền phụ thuộc tường minh qua hàm khởi tạo (Method/Constructor Injection)
    public void Initialize(
        DamageCalculator damageCalculator,
        IEnemyStatsProvider enemyStats,
        IAudioService audioService,
        IScoreTracker scoreTracker)
    {
        _damageCalculator = damageCalculator;
        _enemyStats = enemyStats;
        _audioService = audioService;
        _scoreTracker = scoreTracker;
    }

    public void DealDamage(int baseDamage)
    {
        if (_damageCalculator == null || _enemyStats == null) return;

        int finalDamage = _damageCalculator.CalculateDamage(baseDamage, _enemyStats.Defense);

        _audioService?.PlaySfx("Hit_Sound");
        _scoreTracker?.AddScore(finalDamage * 10);
    }
}

🧪 Viết Unit Test cho class tính toán (chạy siêu nhanh):

using NUnit.Framework;

[TestFixture]
public class DamageCalculatorTests
{
    [Test]
    public void CalculateDamage_AttackHigherThanDefense_ReturnsCorrectDamage()
    {
        // Chạy thuần C#, không cần Scene Unity, tốn chưa tới 1ms
        var calculator = new DamageCalculator();
        int baseAttack = 100;
        int defense = 30;

        int result = calculator.CalculateDamage(baseAttack, defense);

        Assert.AreEqual(70, result);
    }

    [Test]
    public void CalculateDamage_DefenseHigherThanAttack_ReturnsMinimumOneDamage()
    {
        var calculator = new DamageCalculator();
        int result = calculator.CalculateDamage(10, 50);

        Assert.AreEqual(1, result);
    }
}

4. Sơ đồ kiến trúc bóc tách & Bảng so sánh Trade-off

Sơ đồ luồng dữ liệu sau khi bóc tách

flowchart LR
    subgraph Core["Core Logic (Pure C#)"]
        DC["DamageCalculator"]
    end

    subgraph Interfaces["Abstractions"]
        IStats["IEnemyStatsProvider"]
        IAudio["IAudioService"]
        IScore["IScoreTracker"]
    end

    subgraph Implementations["Concrete Implementations"]
        EnemyMono["EnemyStats (MonoBehaviour)"] -.->|implements| IStats
        AudioMgr["SoundManager (Service)"] -.->|implements| IAudio
        ScoreMgr["ScoreManager (Service)"] -.->|implements| IScore
    end

    Combat["CombatController"] -->|sử dụng| DC
    Combat -->|gọi qua| IStats
    Combat -->|gọi qua| IAudio
    Combat -->|gọi qua| IScore

Bảng so sánh các hướng tiếp cận

Không có giải pháp nào hoàn hảo cho mọi trường hợp, quan trọng là chọn đúng công cụ phù hợp với quy mô dự án:

Tiêu chíSingleton (.Instance)Kéo thả trực tiếp ([SerializeField])Dependency Injection (VContainer / Zenject)Event-driven (ScriptableObject Event)
Thời gian làm quen⚡ Rất nhanh⏱️ Đơn giản⏳ Cần tìm hiểu setup⏱️ Vừa phải
Khả năng Unit Test⚠️ Khó mock✅ Khá tốt🌟 Rất linh hoạt✅ Tốt
Mức độ rõ ràng phụ thuộc❌ Bị ẩn bên trong✅ Thấy rõ trên Inspector✅ Quản lý tập trung✅ Decoupled hoàn toàn
Kiểm soát vòng đời (Lifecycle)⚠️ Dễ dính GC Root / Zombie Object✅ Unity tự giải phóng theo Scene🌟 Container quản lý Scope rõ ràng✅ Dữ liệu tồn tại độc lập
Phù hợp vớiPrototype ngắn ngày, Utility không giữ stateGame nhỏ đến vừa, các component cùng PrefabGame vừa đến lớn, team nhiều ngườiUI tách biệt, Event toàn cục trong game

5. Đúc kết kinh nghiệm

Tip
  1. Singleton không xấu, cách dùng mới là thứ cần cân nhắc: Singleton vẫn dùng rất ổn cho các class tiện ích phi trạng thái (Stateless Helper/Utility). Nhưng với dữ liệu gameplay, việc để trong Singleton dễ làm mất kiểm soát luồng dữ liệu.
  2. Khai báo tường minh: Khi một class cần gì, hãy để nó thể hiện rõ qua tham số truyền vào hoặc Inspector, thay vì gọi ngầm bên trong.
  3. Tách biệt logic tính toán: Càng giữ được các công thức nghiệp vụ ở dạng class C# thuần (không dính MonoBehaviour), việc test và debug càng nhẹ nhàng.
  4. Cẩn trọng với vòng đời và GC: Luôn giải phóng event listener khi đổi Scene để tránh biến static biến thành “chiếc mỏ neo” giữ rác trong bộ nhớ.

6. Góc thảo luận

Trong dự án anh em đang làm, mọi người đang tổ chức dependency theo cách nào? Vẫn dùng Singleton truyền thống, chuyển qua VContainer/Zenject, hay dùng Event qua ScriptableObject?

Cùng chia sẻ kinh nghiệm và góc nhìn của anh em ở phần bình luận nhé! 😄


Share this post: