Khi tiếp nhận một dự án game đã chạy live được vài năm, kịch bản quen thuộc nhất mà hầu như lập trình viên nào cũng từng gặp phải là: mở file nào ra cũng thấy GameManager.Instance, SoundManager.Instance, DataManager.Instance.
Các Singleton toàn cục này nằm rải rác ở hàng trăm script. Khi game ngày càng nhiều tính năng, việc sửa một dòng code ở hệ thống này rất dễ vô tình làm gãy một chức năng ở hệ thống khác, sinh ra lỗi hồi quy (regression bug).
Đến một thời điểm, team nhận ra cần phải đưa Unit Test vào CI/CD pipeline để bảo vệ các logic tính toán quan trọng. Nhưng khi bắt tay vào viết test, rào cản xuất hiện ngay lập tức: logic nghiệp vụ dính chặt với GameManager.Instance (kế thừa MonoBehaviour). Muốn test một hàm tính điểm hay tính sát thương, bạn buộc phải khởi tạo cả GameObject, nạp Scene và kéo theo hàng loạt hệ thống râu ria khác.
Lúc này, ý nghĩ đầu tiên nảy ra trong đầu nhiều người là: “Hay là đập đi viết lại toàn bộ kiến trúc, gỡ sạch Singleton và thay bằng Dependency Injection framework như VContainer hay Zenject?”.
Tuy nhiên, trong thực tế sản xuất, không một Product Owner hay Game Producer nào chấp nhận dừng dự án cả tháng chỉ để đội ngũ kỹ thuật “dọn dẹp code cho đẹp”. Game vẫn phải ra bản cập nhật đúng hạn mỗi tuần. Bạn buộc phải tìm cách viết được Unit Test cho các tính năng mới ngay hôm nay, đồng thời từng bước kiểm soát và giảm dần technical debt mà không làm gián đoạn nhịp release.
Dưới đây là chiến lược xử lý dependency theo 3 cấp độ mà mình thường áp dụng, tùy thuộc vào độ gấp của deadline và mức độ can thiệp cho phép vào codebase cũ.
1. Bản chất điểm nghẽn khi viết Unit Test với Singleton
Để hiểu vì sao Singleton lại “kỵ” Unit Test, chúng ta nhìn vào bản chất của một bài test tự động:
- Một Unit Test chuẩn cần thỏa mãn tính chất độc lập (Isolation) và chạy nhanh (dưới vài mili-giây).
- Khi một phương thức gọi trực tiếp biến tĩnh
GameManager.Instance, phương thức đó đang che giấu một phụ thuộc ngầm (hidden dependency). - Vì
GameManagerlà một MonoBehaviour gắn liền với vòng đời Unity, bạn không thể dùng các thư viện mocking (như Moq hay NSubstitute) để giả lập hành vi của nó trong môi trường test thuần C#.
flowchart TD
subgraph Problem["Điểm nghẽn khi Unit Test dính Singleton"]
Test["Unit Test Runner (CI/CD)"] -->|Gọi phương thức cần test| Method["CalculateReward()"]
Method -->|Gọi trực tiếp biến static| GM["GameManager.Instance (MonoBehaviour)"]
GM -->|Phụ thuộc engine| Engine["Unity Scene / GameObject Lifecycle"]
GM -->|Kéo theo tài nguyên nặng| Res["SaveSystem / Audio / Network"]
end
Hệ quả là: muốn chạy một phép tính toán, bạn phải dựng cả một Scene giả lập với đầy đủ GameObject trong bộ nhớ, khiến test suite chạy rất chậm và thường xuyên bị lỗi lan truyền khi ai đó can thiệp vào GameManager.
Để tháo gỡ điểm nghẽn này mà không làm tê liệt tiến độ dự án, chúng ta chia việc tái cấu trúc thành 3 cấp độ.
2. Cấp độ 1: Cách ly hoàn toàn bằng Adapter / Wrapper (Zero Touch Legacy)
Bối cảnh áp dụng
Đây là giải pháp phù hợp khi bạn cần làm gấp một tính năng mới trong sprint hiện tại, hoặc khi chính sách của team là: Tuyệt đối không được đụng chạm vào code cũ của GameManager để tránh mọi rủi ro ngoài ý muốn cho bản build sắp release.
Cách triển khai
Nguyên tắc cốt lõi: Tính năng mới không được phép gọi GameManager.Instance trong logic tính toán của nó. Thay vào đó, tính năng mới sẽ giao tiếp qua một Interface trừu tượng, và chúng ta dùng một class Adapter để làm cầu nối.
flowchart LR
subgraph NewFeature["Feature Mới (Được bảo vệ)"]
Logic["CombatRewardCalculator"]
Interface["IPlayerStatsProvider"]
end
subgraph Production["Môi trường Game (Runtime)"]
Adapter["GameManagerAdapter"]
Legacy["GameManager.Instance"]
end
subgraph Testing["Môi trường Unit Test"]
Mock["FakePlayerStatsProvider"]
end
Logic -->|Phụ thuộc vào| Interface
Adapter -.->|implements| Interface
Adapter -->|Gọi ngầm| Legacy
Mock -.->|implements| Interface
Logic -.->|Inject test data| Mock
Code demo Cấp độ 1: Bọc Adapter cách ly Singleton
Đầu tiên, feature mới tự định nghĩa chính xác những dữ liệu mà nó cần thông qua interface:
// 1. Interface chỉ chứa dữ liệu mà feature mới thực sự quan tâm
public interface IPlayerStatsProvider
{
int CurrentLevel { get; }
int BaseGold { get; }
}
Logic tính toán nghiệp vụ nhận interface này qua constructor:
// 2. Business logic thuần C#, độc lập 100% với Unity
public class CombatRewardCalculator
{
private readonly IPlayerStatsProvider _statsProvider;
public CombatRewardCalculator(IPlayerStatsProvider statsProvider)
{
_statsProvider = statsProvider;
}
public int CalculateGoldReward(int enemyBounty)
{
// Công thức tính thưởng theo level người chơi
int multiplier = Math.Max(1, _statsProvider.CurrentLevel);
return (_statsProvider.BaseGold + enemyBounty) * multiplier;
}
}
Ở môi trường runtime của game, chúng ta tạo một Adapter bọc lại GameManager.Instance:
// 3. Adapter hoạt động như một lớp cách ly (Anti-Corruption Layer)
public class GameManagerStatsAdapter : IPlayerStatsProvider
{
public int CurrentLevel => GameManager.Instance.PlayerLevel;
public int BaseGold => GameManager.Instance.DefaultGoldBonus;
}
Viết Unit Test ở Cấp độ 1
Khi viết test, chúng ta không cần quan tâm GameManager là gì, cũng không cần Scene Unity:
using NUnit.Framework;
[TestFixture]
public class CombatRewardCalculatorTests
{
// Tạo một Fake Provider đơn giản cho bài test
private class FakePlayerStats : IPlayerStatsProvider
{
public int CurrentLevel { get; set; }
public int BaseGold { get; set; }
}
[Test]
public void CalculateGoldReward_Level2_ReturnsDoubledReward()
{
var fakeStats = new FakePlayerStats { CurrentLevel = 2, BaseGold = 100 };
var calculator = new CombatRewardCalculator(fakeStats);
int result = calculator.CalculateGoldReward(enemyBounty: 50);
// (100 + 50) * 2 = 300
Assert.AreEqual(300, result);
}
}
Đánh giá Trade-off
- Cái được: An toàn tuyệt đối 100%. Bạn không sửa một dòng nào trong
GameManager, không có bất kỳ rủi ro nào với code cũ, nhưng vẫn viết được Unit Test chạy độc lập cho logic mới. - Điểm hạn chế: Sinh ra một lớp Adapter trung gian. Nếu tính năng nào cũng làm Adapter mà không có lộ trình dọn dẹp, codebase sẽ tồn tại song song hai thế giới: một bên dùng Singleton và một bên dùng Adapter. Đây là giải pháp “cầm máu” tạm thời để chặn không cho technical debt lan rộng.
3. Cấp độ 2: Trích xuất Interface theo Capability (Can thiệp tối thiểu vào Singleton)
Bối cảnh áp dụng
Khi tính năng bạn đang làm có độ phức tạp trung bình, hoặc dữ liệu cần dùng có khả năng tái sử dụng ở nhiều module khác nhau, và team đồng thuận cho phép chỉnh sửa nhẹ vào file GameManager.cs mà không làm thay đổi behavior hiện tại.
Cách triển khai
Thay vì trích xuất một interface khổng lồ IGameManager (vốn vi phạm nguyên lý Interface Segregation), chúng ta quan sát xem tính năng mới thực sự cần năng lực (capability) gì.
Ví dụ: Nếu hệ thống chiến đấu cần thông số phòng thủ của quái, chúng ta tạo IEnemyStatsProvider. Sau đó, cho chính GameManager implement interface này.
flowchart TD
subgraph LegacySingleton["GameManager.cs (God Class cũ)"]
GM["GameManager"]
end
subgraph Capabilities["Các Interface phân tách theo nhiệm vụ"]
IEnemy["IEnemyStatsProvider"]
IScore["IScoreManager"]
end
GM -.->|implements| IEnemy
GM -.->|implements| IScore
LegacyCode["Code cũ hàng trăm script"] -->|Vẫn gọi trực tiếp| GM
NewCombatFeature["Combat System mới"] -->|Chỉ phụ thuộc vào| IEnemy
Code demo Cấp độ 2: Trích xuất Capability Interface
Trong file GameManager.cs, chúng ta cho class implement thêm interface mà không sửa đổi bất kỳ logic nội bộ nào:
public interface IEnemyStatsProvider
{
int Defense { get; }
float DamageReductionRate { get; }
}
// GameManager implement thêm interface
public class GameManager : MonoBehaviour, IEnemyStatsProvider
{
public static GameManager Instance { get; private set; }
[SerializeField] private int enemyDefense = 20;
[SerializeField] private float damageReduction = 0.1f;
// Implement interface một cách tường minh hoặc tự nhiên
public int Defense => enemyDefense;
public float DamageReductionRate => damageReduction;
private void Awake()
{
if (Instance == null) Instance = this;
else Destroy(gameObject);
}
// Hàng chục hàm cũ khác vẫn giữ nguyên...
public void OldLegacyMethod() { }
}
Trong component mới, chúng ta inject dependency qua hàm Init (hoặc Initialize):
public class DamageSystem : MonoBehaviour
{
private IEnemyStatsProvider _statsProvider;
public void Initialize(IEnemyStatsProvider statsProvider)
{
_statsProvider = statsProvider;
}
public int ComputeDamage(int rawAttack)
{
if (_statsProvider == null) return rawAttack;
return (int)(rawAttack * (1f - _statsProvider.DamageReductionRate)) - _statsProvider.Defense;
}
}
Ở Scene Unity, script bootstrap (hoặc chính GameManager) chỉ cần truyền chính nó vào:
damageSystem.Initialize(GameManager.Instance);
Đánh giá Trade-off
- Cái được: Code cũ vẫn gọi
GameManager.Instancebình thường. Code mới nhận interface nên viết test cực kỳ dễ dàng bằng Mock object. Không cần tạo thêm class Adapter trung gian rườm rà như Cấp độ 1. - Điểm hạn chế: Về bản chất,
GameManagervẫn là một God Class. Nếu mỗi lập trình viên làm một tính năng lại nhét thêm một interface vàoGameManager, sau nửa năm nó có thể implement tới 15 interface khác nhau. Đây là bước đệm tốt, nhưng chưa giải quyết triệt để vấn đề phân tách trách nhiệm.
4. Cấp độ 3: Bóc tách Service và chuyển GameManager thành Facade (Refactor có kiểm soát)
Bối cảnh áp dụng
Khi tính năng bạn đang phát triển chạm vào luồng logic xương sống của game (như Save/Load, Hệ thống tính điểm, Vòng đời màn chơi), hoặc khi team có một khoảng thời gian giữa các release (Hardening Sprint / Refactoring Sprint) để chủ động dọn dẹp technical debt.
Cách triển khai
Chúng ta không cố gắng xóa sổ GameManager ngay lập tức, mà biến nó từ một class “ôm đồm mọi việc” thành một Facade Coordinator mỏng:
- Tách từng nhóm dữ liệu và logic liên quan thành các Service thuần C# riêng biệt (
ScoreService,InventoryService,LevelService). - Các Service này hoàn toàn độc lập, có thể viết Unit Test bao phủ 100%.
GameManagergiữ instance của các Service này và ủy thác (delegate) công việc cho chúng.- Với hàng trăm script cũ đang gọi
GameManager.Instance.AddScore(10), code cũ vẫn chạy bình thường vìGameManagervẫn cung cấp phương thức đó, chỉ khác là nó chuyển tiếp cuộc gọi sang_scoreService.AddScore(10).
flowchart TD
subgraph FacadePattern["GameManager chuyển thành Facade Coordinator"]
GM["GameManager.Instance (MonoBehaviour)"]
end
subgraph PureServices["Các Service thuần C# (Dễ viết Unit Test)"]
ScoreSvc["ScoreService (Pure C#)"]
InventorySvc["InventoryService (Pure C#)"]
StatsSvc["PlayerStatsService (Pure C#)"]
end
GM -->|Sở hữu và điều phối| ScoreSvc
GM -->|Sở hữu và điều phối| InventorySvc
GM -->|Sở hữu và điều phối| StatsSvc
LegacyCode["Script cũ trong dự án"] -->|"Gọi GameManager.Instance.AddScore()"| GM
NewCode["Tính năng mới trong tương lai"] -->|Inject trực tiếp| ScoreSvc
Code demo Cấp độ 3: Bóc tách Service và dựng Facade
Bóc tách service tính điểm sang class thuần C#:
// 1. Service thuần C# hoàn chỉnh, test độc lập cực nhanh
public class ScoreService
{
public int CurrentScore { get; private set; }
public event Action<int> OnScoreChanged;
public void AddScore(int amount)
{
if (amount <= 0) return;
CurrentScore += amount;
OnScoreChanged?.Invoke(CurrentScore);
}
public void Reset()
{
CurrentScore = 0;
OnScoreChanged?.Invoke(CurrentScore);
}
}
Biến GameManager thành Facade ủy thác:
// 2. GameManager đóng vai trò Facade duy trì tính tương thích ngược
public class GameManager : MonoBehaviour
{
public static GameManager Instance { get; private set; }
// Các Service con thuần C#
public ScoreService ScoreService { get; private set; }
private void Awake()
{
if (Instance == null)
{
Instance = this;
InitializeServices();
}
else
{
Destroy(gameObject);
}
}
private void InitializeServices()
{
ScoreService = new ScoreService();
}
// API tương thích ngược cho hàng trăm script cũ:
// Code cũ gọi GameManager.Instance.AddScore() vẫn chạy bình thường!
public void AddScore(int amount)
{
ScoreService.AddScore(amount);
}
public int GetCurrentScore()
{
return ScoreService.CurrentScore;
}
}
Đánh giá Trade-off
- Cái được: Đây là hướng đi sạch sẽ nhất về lâu dài. Logic phức tạp chuyển sang pure C# class giúp test suite chạy trong chớp mắt. Toàn bộ code cũ của dự án không bị gãy một dòng nào (tính tương thích ngược hoàn hảo).
- Điểm hạn chế: Cần thời gian rà soát, viết test kỹ lưỡng trước khi tách code và đòi hỏi sự am hiểu sâu về luồng dữ liệu của toàn bộ dự án.
5. Bảng so sánh Trade-off giữa 3 cấp độ
Không có phương pháp nào là “thượng đẳng” hay hoàn hảo cho mọi thời điểm. Sự chuyên nghiệp nằm ở việc chọn đúng giải pháp phù hợp với hoàn cảnh thực tế:
| Tiêu chí | Cấp độ 1: Adapter / Wrapper | Cấp độ 2: Capability Interface | Cấp độ 3: Service Facade |
|---|---|---|---|
| Mức độ can thiệp vào Singleton cũ | Không đụng một dòng code nào | Sửa nhẹ phần khai báo interface | Tái cấu trúc logic thành các service con |
| Khả năng viết Unit Test | Tốt cho tính năng mới | Rất tốt cho tính năng mới | Tuyệt vời cho cả logic cũ và mới |
| Tốc độ hoàn thành | Rất nhanh (vài giờ) | Nhanh (nửa ngày) | Cần vài ngày lên kế hoạch và test |
| Tác động đến code cũ | Rủi ro bằng 0 | Rủi ro rất thấp | Cần regression test cẩn thận |
| Mức độ triệt tiêu Technical Debt | Chỉ mang tính tạm thời | Trung bình | Rất cao, chuyển đổi kiến trúc căn bản |
| Tình huống nên áp dụng | Deadline gấp, sửa bug nóng, tính năng nhỏ | Tính năng mới cần dùng dữ liệu chung | Refactor sprint, tính năng lớn chạm vào core |
6. Lộ trình thực tế: Nguyên tắc Hướng đạo sinh (Boy Scout Rule)
Nếu bạn vừa tiếp nhận một dự án Unity đầy rẫy Singleton, đừng cố gắng trở thành “người hùng” bằng cách tạo một pull request hàng ngàn dòng code để xóa sạch Singleton trong một đêm. Cách làm đó gần như chắc chắn sẽ dẫn tới thảm họa xung đột code (merge conflict), trễ deadline và tạo ra hàng loạt bug mới.
Thay vào đó, hãy áp dụng Nguyên tắc Hướng đạo sinh (Boy Scout Rule):
“Hãy luôn để khu cắm trại sạch hơn lúc bạn mới đến.”
- Khi có task mới: Đừng gọi thêm bất kỳ
.Instancenào nữa. Áp dụng Cấp độ 1 hoặc Cấp độ 2 để bảo vệ code mới của bạn bằng Unit Test. - Khi phải fix bug ở một module cũ: Trích xuất phần logic tính toán có bug sang một pure C# class, viết Unit Test bao phủ lỗi đó, rồi mới tích hợp ngược lại.
- Khi có thời gian tái cấu trúc: Tách dần các trách nhiệm nặng nề của
GameManagerthành các Service nhỏ theo Cấp độ 3.
Bằng cách đi từng bước vững chắc, bạn vừa đảm bảo tiến độ release sản phẩm cho công ty, vừa từng bước biến một legacy codebase hỗn loạn thành một hệ thống module hóa sạch sẽ, có kiểm thử bảo vệ đàng hoàng.