Observer Pattern Toàn Tập Trong Unity (Event-Driven Architecture)
Minh Khoa
Tác giả
---
📌 MỞ ĐẦU — VẤN ĐỀ THỰC TẾ
Tưởng tượng trong game Sushi Bar Idle:
Khi khách hàng trả tiền → UI tiền phải cập nhật → Analytics phải ghi log → Tutorial phải check → Notification badge phải tắt
Khi upgrade station → UI phải refresh → Sound phải phát → Save phải ghi lại → Particle Effect phải nổ
Nếu bạn code thẳng:
// ❌ Cách tệ — Station biết quá nhiều về người khác
public class Station : MonoBehaviour
{
public void Upgrade()
{
_level++;
UIManager.Instance.RefreshStationUI(this); // coupled!
AudioManager.Instance.PlaySFX("upgrade"); // coupled!
SaveSystem.Instance.Save(); // coupled!
ParticleManager.Instance.PlayUpgrade(pos); // coupled!
AnalyticsManager.Instance.LogUpgrade(_id); // coupled!
}
}
Station không nên biết về UIManager, AudioManager, SaveSystem... Đây là lý do Observer Pattern ra đời — để tách rời người phát sự kiện khỏi người nhận.
Observer Pattern = Publisher không biết Subscriber là ai. Subscriber không biết Publisher là ai. Cả hai chỉ giao tiếp qua một "kênh" trung gian.
🧠 OBSERVER PATTERN LÀ GÌ?
Định nghĩa đơn giản
Observer Pattern là một design pattern thuộc nhóm Behavioral (hành vi). Ý tưởng cốt lõi:
Có một Subject (người phát / Publisher) — nắm giữ danh sách những ai quan tâm.
Có nhiều Observer (người nghe / Subscriber) — đăng ký lắng nghe và phản ứng khi có sự kiện.
Khi Subject thay đổi trạng thái → tự động thông báo cho tất cả Observer.
Ví dụ đời thường
Bạn đăng ký kênh YouTube. Khi YouTuber (Subject) đăng video mới → tất cả subscriber (Observer) nhận được thông báo. YouTuber không cần biết bạn là ai, bạn cũng không cần biết ai khác đang subscribe.
Mô hình hoạt động
Subject (Publisher) Observer (Subscriber)
┌──────────────────┐ ┌────────────────┐
│ - observers[] │ │ + Update() │
│ + Subscribe() │◄─────────│ │
│ + Unsubscribe() │ └────────────────┘
│ + Notify() │ ┌────────────────┐
│ │◄─────────│ + Update() │
└──────────────────┘ └────────────────┘
Khi Subject gọi Notify() → tất cả Observer.Update() được gọi
Tại sao cần Observer Pattern?
Không dùng Observer
Dùng Observer
Station phải import UIManager, AudioManager, SaveSystem...
Station chỉ "hét lên": "Tôi đã upgrade!"
Thêm hệ thống mới = phải sửa Station
Thêm hệ thống mới = chỉ cần đăng ký lắng nghe
Tight Coupling — khó test, khó bảo trì
Loose Coupling — dễ test, dễ mở rộng
Xóa AudioManager → Station bị lỗi compile
Xóa AudioManager → Station không bị ảnh hưởng
🔷 PHẦN 1 — C# DELEGATE & EVENT (NỀN TẢNG CỐT LÕI)
Delegate là gì?
Delegate là một kiểu dữ liệu tham chiếu tới phương thức. Giống như một tấm hợp đồng: "Tôi cần một hàm có dạng như thế này."
// Khai báo delegate — tấm hợp đồng: "cần 1 hàm nhận int, trả void"
public delegate void OnMoneyChanged(int newAmount);
// Gán một hàm vào delegate
OnMoneyChanged handler = (amount) => Debug.Log($"Tiền mới: {amount}");
// Gọi
handler.Invoke(500); // Output: "Tiền mới: 500"
Action, Func — Delegate có sẵn của C#
Thay vì tự khai báo delegate mỗi lần, C# đã cung cấp sẵn:
// Action — hàm KHÔNG trả về giá trị (void)
Action onPlayerDied; // không tham số
Action<int> onMoneyChanged; // 1 tham số int
Action<string, int> onItemCollected; // 2 tham số
// Func — hàm CÓ trả về giá trị (kiểu cuối cùng là return type)
Func<bool> canAfford; // () => bool
Func<int, float> calcDamage; // (int) => float
💡 Mẹo nhớ:Action = làm gì đó (void). Func = tính toán gì đó (có return).
Event keyword — Bảo vệ delegate
event là lớp bảo vệ bọc quanh delegate, ngăn code bên ngoài:
❌ Không được gọi trực tiếp (.Invoke()) — chỉ class chủ sở hữu mới được gọi
❌ Không được gán đè bằng = — chỉ được dùng += và -=
public class CurrencySystem : MonoBehaviour
{
// ✅ Có "event" → an toàn, bên ngoài chỉ += / -= được thôi
public event Action<int> OnMoneyChanged;
private int _money;
public void AddMoney(int amount)
{
_money += amount;
OnMoneyChanged?.Invoke(_money); // ?. để tránh null nếu chưa ai đăng ký
}
}
public class MoneyUI : MonoBehaviour
{
[SerializeField] private CurrencySystem _currency;
private void OnEnable()
{
_currency.OnMoneyChanged += UpdateDisplay; // Đăng ký
}
private void OnDisable()
{
_currency.OnMoneyChanged -= UpdateDisplay; // HỦY đăng ký — CỰC KỲ QUAN TRỌNG!
}
private void UpdateDisplay(int amount)
{
_label.text = $"${amount}";
}
}
Truyền dữ liệu phức tạp — dùng struct
Khi event cần mang theo nhiều thông tin:
// Đóng gói dữ liệu vào struct
public struct UpgradeInfo
{
public int StationId;
public int NewLevel;
public int Cost;
}
public class Station : MonoBehaviour
{
public event Action<UpgradeInfo> OnUpgraded;
public void Upgrade()
{
_level++;
// Bắn event kèm dữ liệu — không cần biết ai sẽ nhận
OnUpgraded?.Invoke(new UpgradeInfo
{
StationId = _id,
NewLevel = _level,
Cost = GetUpgradeCost()
});
}
}
⚠️ Memory Leak — Cái bẫy phổ biến nhất
// ❌ SAI: Đăng ký mà không bao giờ hủy
public class BadUI : MonoBehaviour
{
void Start()
{
station.OnUpgraded += ShowEffect;
// Object bị Destroy nhưng delegate vẫn giữ reference → Memory Leak!
}
}
// ✅ ĐÚNG: OnEnable đăng ký, OnDisable hủy
public class GoodUI : MonoBehaviour
{
void OnEnable() => station.OnUpgraded += ShowEffect;
void OnDisable() => station.OnUpgraded -= ShowEffect;
}
UnityEvent là hệ thống event của Unity cho phép kéo thả kết nối trong Inspector. Designer không cần viết code — chỉ cần kéo component và chọn hàm.
using UnityEngine.Events;
public class Door : MonoBehaviour
{
// Hiện trong Inspector → kéo thả object + chọn hàm
public UnityEvent OnDoorOpened;
public UnityEvent<int> OnDamageReceived; // Có tham số
public void Open()
{
// Mở cửa...
OnDoorOpened?.Invoke();
}
}
Custom UnityEvent (kiểu dữ liệu phức tạp)
// Phải có [Serializable] để hiện trong Inspector
[System.Serializable]
public class CustomerEvent : UnityEvent<Customer> { }
public class CustomerManager : MonoBehaviour
{
public CustomerEvent OnCustomerArrived; // Kéo thả trong Inspector
void SpawnCustomer()
{
var customer = CreateCustomer();
OnCustomerArrived?.Invoke(customer);
}
}
Ưu & Nhược điểm UnityEvent
Ưu điểm ✅
Nhược điểm ❌
Kéo thả trực quan trong Inspector
Chậm hơn C# event 3-5 lần (dùng Reflection)
Designer không cần viết code
Khó debug — connection ẩn trong scene file
Tốt cho prototype, level scripting
Đổi tên hàm → mất kết nối mà không báo lỗi
Lưu được cùng scene/prefab
Không dùng được ngoài MonoBehaviour
💡 Khi nào dùng? UI Button click, Animation Event, Trigger/Collider — những chỗ Designer cần can thiệp.
🟣 PHẦN 3 — SCRIPTABLE OBJECT EVENT SYSTEM
Kiến trúc được Ryan Hipple giới thiệu tại Unite Austin 2017 — cực kỳ thanh lịch.
Vấn đề cần giải quyết
C# event và UnityEvent đều yêu cầu Publisher và Subscriber phải giữ reference đến nhau. Khó khăn khi:
Publisher và Subscriber ở scene khác nhau
Object được Instantiate từ Prefab lúc runtime
Muốn test event mà không cần chạy game
Giải pháp: Event là một Asset file
Ý tưởng: Tạo một file ScriptableObject đại diện cho event. Publisher "hét vào file này", Subscriber "lắng nghe file này". Cả hai không biết nhau tồn tại.
Bước 1 — Tạo GameEvent (file asset):
[CreateAssetMenu(menuName = "Events/Game Event")]
public class GameEvent : ScriptableObject
{
private readonly List<GameEventListener> _listeners = new();
// Publisher gọi hàm này
public void Raise()
{
// Duyệt ngược để tránh lỗi khi listener tự hủy đăng ký
for (int i = _listeners.Count - 1; i >= 0; i--)
_listeners[i].OnEventRaised();
}
public void Register(GameEventListener listener) => _listeners.Add(listener);
public void Unregister(GameEventListener listener) => _listeners.Remove(listener);
}
Bước 2 — Tạo GameEventListener (gắn vào GameObject):
public class GameEventListener : MonoBehaviour
{
[SerializeField] private GameEvent _event; // Kéo thả file event vào
[SerializeField] private UnityEvent _response; // Kéo thả hàm phản hồi
void OnEnable() => _event.Register(this);
void OnDisable() => _event.Unregister(this);
public void OnEventRaised() => _response?.Invoke();
}
Bước 3 — Sử dụng:
// Station.cs — Publisher
public class Station : MonoBehaviour
{
[SerializeField] private GameEvent _onUpgraded; // Kéo file asset vào
public void Upgrade()
{
_level++;
_onUpgraded.Raise(); // Hét lên: "Tôi upgrade rồi!"
}
}
// AudioManager.cs — Subscriber (đăng ký qua code hoặc qua Listener component)
public class AudioManager : MonoBehaviour
{
// Cách 1: Gắn GameEventListener component, kéo thả trong Inspector
// Cách 2: Đăng ký bằng code (xem phần Generic bên dưới)
}
Phiên bản có tham số (Generic)
// Base class cho event có dữ liệu
public abstract class GameEvent<T> : ScriptableObject
{
private readonly List<Action<T>> _listeners = new();
public void Raise(T value)
{
foreach (var listener in _listeners)
listener?.Invoke(value);
}
public void Register(Action<T> listener) => _listeners.Add(listener);
public void Unregister(Action<T> listener) => _listeners.Remove(listener);
}
// Tạo event cụ thể
[CreateAssetMenu(menuName = "Events/Int Event")]
public class IntEvent : GameEvent<int> { }
Ưu điểm SO Event
✅ Zero coupling — Publisher và Subscriber không reference nhau
✅ Dễ test — Click chuột phải vào asset → gọi Raise() ngay trong Editor
✅ Hoạt động xuyên scene — Asset tồn tại trong Project, không thuộc scene nào
✅ Designer-friendly — Kéo thả, không cần code
🟠 PHẦN 4 — EVENT BUS (GLOBAL EVENT SYSTEM)
EventBus là gì?
EventBus là một trung tâm chuyển phát tin nhắn toàn cục. Bất kỳ ai cũng có thể gửi (Publish) hoặc nhận (Subscribe) mà không cần reference đến bất kỳ object nào.
Triển khai đơn giản
// EventBus dùng generic — mỗi kiểu event có "kênh" riêng
public static class EventBus<T> where T : struct
{
private static readonly List<Action<T>> _listeners = new();
public static void Register(Action<T> listener) => _listeners.Add(listener);
public static void Deregister(Action<T> listener) => _listeners.Remove(listener);
public static void Raise(T eventData)
{
// Duyệt ngược phòng trường hợp listener tự hủy đăng ký
for (int i = _listeners.Count - 1; i >= 0; i--)
_listeners[i]?.Invoke(eventData);
}
}
Sử dụng
// 1. Định nghĩa event bằng struct (nhẹ, không GC)
public struct PlayerDiedEvent
{
public Vector3 DeathPosition;
public int Score;
}
// 2. Publisher — bắn event
public class Player : MonoBehaviour
{
void Die()
{
EventBus<PlayerDiedEvent>.Raise(new PlayerDiedEvent
{
DeathPosition = transform.position,
Score = _currentScore
});
}
}
// 3. Subscriber — nhận event
public class ScoreUI : MonoBehaviour
{
void OnEnable()
{
EventBus<PlayerDiedEvent>.Register(OnPlayerDied);
}
void OnDisable()
{
EventBus<PlayerDiedEvent>.Deregister(OnPlayerDied);
}
void OnPlayerDied(PlayerDiedEvent e)
{
Debug.Log($"Game Over! Điểm: {e.Score}");
}
}
⚠️ Cẩn thận với EventBus
EventBus dùng static → listener list không tự reset khi đổi scene
Nếu quên Deregister → memory leak, event gọi trùng nhiều lần
Lạm dụng EventBus → khó debug vì không biết ai đang listen ("God Bus")
💡 Mẹo: Chỉ dùng EventBus cho event cấp game-wide (GameOver, LevelComplete). Event nội bộ module thì dùng C# event thường.
🔵 PHẦN 5 — UNIRX (REACTIVE PROGRAMMING) — NÂNG CAO
UniRx là thư viện Reactive Extensions cho Unity. Thay vì "bắn event → hứng event", UniRx cho phép bạn mô tả luồng dữ liệu và compose các event phức tạp.
ReactiveProperty — Biến tự bắn event khi thay đổi
using UniRx;
public class PlayerStats : MonoBehaviour
{
// Mỗi khi gán giá trị mới → tự động thông báo cho ai đang Subscribe
public ReactiveProperty<int> Money = new(0);
public ReactiveProperty<int> Health = new(100);
}
public class GameUI : MonoBehaviour
{
[SerializeField] private PlayerStats _stats;
void Start()
{
// Subscribe → mỗi khi Money thay đổi, cập nhật UI
_stats.Money
.Subscribe(amount => _moneyLabel.text = $"${amount}")
.AddTo(this); // AddTo(this) → tự hủy khi object Destroy
// Chỉ cảnh báo khi máu dưới 20
_stats.Health
.Where(hp => hp <= 20)
.Subscribe(_ => ShowLowHealthWarning())
.AddTo(this);
}
}
Sức mạnh thực sự: Compose Events
// Debounce Search — chỉ tìm kiếm sau khi người dùng ngừng gõ 0.5 giây
_searchInput.onValueChanged.AsObservable()
.Debounce(TimeSpan.FromSeconds(0.5f))
.Subscribe(text => PerformSearch(text))
.AddTo(this);
Khi nào dùng UniRx?
✅ Logic phụ thuộc thời gian (debounce, throttle, delay, timeout)
✅ Cần combine nhiều điều kiện (chỉ attack khi đủ mana VÀ cooldown xong VÀ không stun)
✅ UI data binding phức tạp
❌ Đừng dùng cho việc đơn giản mà C# event làm được — over-engineering
⚠️ PHẦN 6 — ANTI-PATTERNS & LỖI THƯỜNG GẶP
1. Quên hủy đăng ký (Memory Leak)
// ❌ Đăng ký trong Start() mà không hủy trong OnDisable()
// → Reload scene 5 lần = event gọi 5 lần!
// ✅ Luôn đi theo cặp:
void OnEnable() => SomeEvent.OnFired += Handle;
void OnDisable() => SomeEvent.OnFired -= Handle;
2. Sửa danh sách listener khi đang duyệt
// ❌ Subscriber tự Unregister trong lúc event đang Raise → Crash!
foreach (var listener in _listeners) // InvalidOperationException!
listener.OnEventRaised(); // Listener gọi Unregister() bên trong
// ✅ Duyệt ngược → xóa phần tử cuối không ảnh hưởng phần tử trước
for (int i = _listeners.Count - 1; i >= 0; i--)
_listeners[i].OnEventRaised();
3. Static event gây ghost listeners xuyên scene
// ❌ Static event + MonoBehaviour không hủy đăng ký
public static event Action OnGameOver;
// Reload scene → object mới đăng ký THÊM → event gọi trùng!
// ✅ Luôn -= trong OnDisable
4. Event Spaghetti — quá nhiều event nhỏ lẻ
// ❌ Mỗi stat một event riêng
public event Action<float> OnHealthChanged;
public event Action<float> OnManaChanged;
public event Action<float> OnStaminaChanged;
// ✅ Gom lại thành 1 event
public event Action<PlayerStats> OnStatsChanged;
5. Phụ thuộc thứ tự subscriber
// ❌ Giả định AudioManager nhận event TRƯỚC SaveSystem
// Observer Pattern KHÔNG đảm bảo thứ tự!
// ✅ Nếu cần thứ tự → tách thành nhiều event có phase:
// Phase 1: OnBeforeGameOver (cleanup)
// Phase 2: OnGameOver (main logic)
// Phase 3: OnAfterGameOver (analytics, save)
⚖️ PHẦN 7 — BẢNG SO SÁNH TỔNG HỢP
Tiêu chí
C# Event
UnityEvent
SO Event
EventBus
UniRx
Tốc độ
⚡ Nhanh nhất
🐢 Chậm (Reflection)
⚡ Nhanh
⚡ Nhanh
🔄 Trung bình
Coupling
Cần reference
Kéo thả Inspector
Zero coupling
Zero coupling
Cần reference hoặc global
Xuyên scene
❌
❌
✅
✅
Tùy cách dùng
Designer-friendly
❌
✅✅
✅
❌
❌
Debug dễ
✅
❌ (ẩn trong scene)
✅
❌ (không biết ai listen)
❌ (stream phức tạp)
Type-safe
✅✅
❌ (lỗi runtime)
✅
✅
✅
Phù hợp cho
Giao tiếp nội bộ module
UI, Prototype
Xuyên scene/Prefab
Broadcast game-wide
Logic phức tạp theo thời gian
🎯 KẾT LUẬN — DÙNG ĐẠN ĐÚNG LOẠI SÚNG
Giao tiếp trong một module → C# delegate / event
Designer cần kéo thả → UnityEvent
Giao tiếp xuyên scene / Prefab → ScriptableObject Event
Broadcast toàn game → EventBus
Logic phức tạp theo thời gian → UniRx ReactiveProperty
Trong một dự án thực tế, bạn sẽ dùng nhiều cách cùng lúc, mỗi cái một chỗ phù hợp. Không có công cụ nào "tốt nhất" — chỉ có công cụ phù hợp nhất cho ngữ cảnh.
🔴 Điều quan trọng cuối cùng: Dù dùng cách nào, LUÔN HỦY ĐĂNG KÝ. Đây là nguyên nhân số 1 gây bug hắc ám trong Unity mà không ai muốn debug.
Tham khảo: Unite Austin 2017 — "Game Architecture with Scriptable Objects" (Ryan Hipple), UniRx Documentation, C# in Depth (Jon Skeet)