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.
- Cần phát tiếng coin khi nhặt vàng? Gọi
SoundManager.Instance.Play("coin"). - Cần cộng điểm khi hạ quái? Gọi
GameManager.Instance.AddScore(100). - Cần lấy máu người chơi trên UI? Gọi
PlayerData.Instance.Hp.
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
-
Static Reference đóng vai trò GC Root (Nguy cơ rò rỉ bộ nhớ - Memory Leak): Trong C# Runtime, biến static
Instancelà một GC Root. Garbage Collector sẽ không bao giờ thu hồi bất kỳ đối tượng nào còn đường dẫn tham chiếu từ GC Root. Nếu Singleton giữ tham chiếu đến các tài nguyên nặng nhưAudioClip,Texture, hoặc đăng ký lắng nghe event từ các GameObject trong Scene mà quên unregister khi đổi Scene, toàn bộ các object đó sẽ bị “treo” lại trên Managed Heap, khiến RAM tăng dần đều sau mỗi màn chơi. -
Xung đột thứ tự khởi tạo (Initialization Race Condition): Unity không cam kết thứ tự chạy
Awake()giữa các GameObject khác nhau trong Scene. NếuSingletonA.Awake()gọi sangSingletonB.Instance, nhưng tại frame đó GameObject của B chưa kịp chạyAwake(), lỗiNullReferenceExceptionsẽ xuất hiện chập chờn tùy thuộc vào việc Unity nạp object nào trước. -
Hiện tượng Zombie Objects khi chuyển Scene: Khi một Scene bị Unload, các GameObject trong Scene đó lần lượt bị hủy. Nếu một script trong hàm
OnDestroy()cố gắng gọiGameManager.Instance.SaveProgress()và Singleton đó dùng cơ chế Lazy Loading (tựnew GameObjectnếu_instance == null), một GameObject mới toanh sẽ được sinh ra ngay giữa quá trình dọn Scene. Đối tượng này trở thành một “Zombie Object” tồn tại lạc lõng ở Scene tiếp theo mà không ai quản lý.
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.
- Khi muốn mang
SoundManagersang game mới, vừa copy file sang là báo đỏ lòm vì thiếuDataManager.Instance. - Bê tiếp
DataManagersang thì lại kéo theoSaveSystem.InstancevàConfig.Instance. - Cuối cùng, muốn dùng lại một file âm thanh nhỏ mà phải bê theo cả nửa project cũ.
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.
- Nhưng nếu trong hàm tính toán lỡ gọi
GameManager.Instance.GetEnemyDefense(), bài test buộc phải khởi tạo cảGameManager(MonoBehaviour). GameManager.Awake()lại kéo theo nạp Resources, kết nối file Save, gọi AudioManager…- Test một phép tính đơn giản mà phải dựng cả hệ thống game trong bộ nhớ. Khi project có hàng trăm bài test, test suite chạy rất chậm và dễ bị lỗi lan truyền khi ai đó sửa
GameManager.
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.
- Biến
scoretrongGameManagerban đầu chỉ cóEnemyControllercộng điểm khi quái chết. - Sau này phát triển thêm:
ComboSystemnhân đôi điểm,QuestManagercộng điểm thưởng,EventManageráp dụng x2 sự kiện. - Khi QA báo lỗi “Điểm số thỉnh thoảng bị nhảy sai bất thường”, việc lần tìm script nào sửa trước, script nào sửa sau trở nên cực kỳ vất vả vì không có class nào chịu trách nhiệm quản lý luồng thay đổi duy nhất.
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ảGameManagervà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ới | Prototype ngắn ngày, Utility không giữ state | Game nhỏ đến vừa, các component cùng Prefab | Game vừa đến lớn, team nhiều người | UI tách biệt, Event toàn cục trong game |
5. Đúc kết kinh nghiệm
- 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.
- 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.
- 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.
- 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é! 😄
