MVVM Pattern

MVVM模式(Model–View–ViewModel):MVVM將程式劃分為 ModelViewViewModel ,它透過觀察者模式將 ViewViewModel 連接在一起, 在 ViewModel 中進行資料綁定(data binding)。當 View Model 的狀態改變時自動通知 View

  • Model:主要負責管理資料邏輯。
  • View:負責顯示資料以及與使用者互動。
  • ViewModel:是連接 ModelView 的橋樑。與 MVC中的 Controller 和 MVP 中的 Presenter 不同在於 ViewModel 它會綁定(Binder) View 要顯示的資料,當資料改變時,自動通知 View 要更新UI。
    • 通常使用資料綁定(Data Binding)機制來實現這一點
graph TD
    A[ View ]
    B[ ViewModel ]
    C[ Model ]
    
    A -->| User Interaction | B
    B -->| Notify Changes | A
    B <-->| Data Binding | A
    B -->| Calls | C
    C -->| Data | B

Unity中,UGUI沒有內建Data Binding,需要自己實作一個,以下是一個簡單的範例

定義一個 Model

1
2
3
4
5
public class Model
{
public string Name { get; set; }
public string Job { get; set;}
}

定義一個Model Service,其職責為從PlayerPrefs拿資料,或是將資料寫入PlayerPrefs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class ModelService
{

public Model GetData()
{
Model m = new Model()
{
Name = PlayerPrefs.GetString("name"),
Job = PlayerPrefs.GetString("job"),
};

return m;
}

public void SaveName(string name)
{
PlayerPrefs.SetString("name", name);
}

public void SaveJob(string job)
{
PlayerPrefs.SetString("job", job);
}
}

定義一個BindableProperty類,它含有一個OnValueChanged事件,當Value的值發生變化(通過set方法)時,就會呼叫 OnValueChanged ,從而通知有註冊這個事件的物件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class BindableProperty<T>
{
public delegate void ValueChangedHandler(T oldValue, T newValue);

public event ValueChangedHandler OnValueChanged;

private T _value;
public T Value
{
get
{
return _value;
}
set
{
if (!object.Equals(_value, value))
{
T oldValue = _value;
_value = value;
// 通知值發生改變
OnValueChanged?.Invoke(oldValue, _value);
}
}
}

public override string ToString()
{
return (Value != null ? Value.ToString() : "null");
}
}

接下來定義一個 ViewModel , 這個 ViewModel 將負責為 View 提供資料,但它不知曉 View 的任何資訊,與 View 解耦,專注於資料的處理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class ViewModel
{

public BindableProperty<string> Name = new BindableProperty<string>();
public BindableProperty<string> Job = new BindableProperty<string>();

private ModelService service = new ModelService();

public ViewModel()
{}

public void GetModel()
{
Model m = service.GetData();
Name.Value = m.Name;
Job.Value = m.Job;

Debug.Log(m.Name + " " + m.Job);
}

public void SaveModel()
{
// do some check
string name = Name.Value;
string job = Job.Value;

if (name == null || name.Length > 0)
{
return;
}
if (job == null || job.Length > 0)
{
return;
}

service.SaveName(Name.Value);
service.SaveJob(Job.Value);
}

}

最後定義一個 View ,這個 View 包含了要操控的UI元件,因此繼承了MonoBehaviour,它不處理資料邏輯,只負責顯示格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class View : MonoBehaviour
{
public TMP_InputField nameInputField;
public TextMeshProUGUI nameMessageText;

public TMP_InputField jobInputField;
public TextMeshProUGUI jobMessageText;

public TextMeshProUGUI resultText;
public Button applyButton;

public ViewModel viewModel;

private void Start()
{
viewModel = new ViewModel();
viewModel.Name.OnValueChanged += OnNameChanged;
nameInputField.onValueChanged.AddListener(OnNameInputChanged);

viewModel.Job.OnValueChanged += OnJobChanged;
jobInputField.onValueChanged.AddListener(OnJobInputChanged);

applyButton.onClick.AddListener(OnApplyButtonClick);

viewModel.GetModel();
}


private void OnDestroy()
{
viewModel.Name.OnValueChanged -= OnNameChanged;
nameInputField.onValueChanged.RemoveListener(OnNameInputChanged);

viewModel.Job.OnValueChanged -= OnJobChanged;
jobInputField.onValueChanged.RemoveListener(OnJobInputChanged);

applyButton.onClick.RemoveListener(OnApplyButtonClick);
}

private void OnNameChanged(string oldVal, string newVal)
{
nameMessageText.text = newVal;
}

private void OnNameInputChanged(string value)
{
viewModel.Name.Value = value;
}

private void OnJobChanged(string oldVal, string newVal)
{
jobMessageText.text = newVal;
}

private void OnJobInputChanged(string value)
{
viewModel.Job.Value = value;
}

private void OnApplyButtonClick()
{
resultText.text = viewModel.Name.Value + ", " + viewModel.Job.Value + " have been saved";
viewModel.SaveModel();
}
}

程式碼: https://github.com/mystudybook/Unity-MVVM

Reference: https://www.cnblogs.com/OceanEyes/p/unity3d_framework_designing_get_started_with_mvvm_part1.html

MVP Pattern

MVC模式(Model-View-Controller):MVC模式在開發UI中是一種非常受歡迎的設計模式。

  • MVC主要想法為:將程式劃分為模型(Model)、視圖(View)和控制器(Controller
    • Model:主要負責管理資料邏輯。Model拿到資料後將資料交給View
      • 在此可以直接與資料庫、API 或其他資料來源交互。
      • 在此不執行遊戲邏輯或是執行運算。
    • View:負責顯示資料。
      • 它從Model中獲取資料,並將這些資料呈現給使用者。View僅關注如何顯示資料。
    • Controller:充當ModelView之間的溝通的橋樑。
      • 它接收使用者的輸入,呼叫Model來處理這些輸入,然後選擇適當的View來顯示處理結果。
      • 可以把它想像為大腦,它處理遊戲資料並在執行期間計算資料是如何改變的。
  • MVC模式符合單一職責原則,每個部分都只負責自己的部分。
  • 在Unity中,UI Toolkit或是Unity UI很自然地負責擔任View
  • 缺點
    • MVC模式中三者相互依賴,一但更新了其中一個,另外兩個也須跟著修改。
    • Controller的程式碼會隨著功能的添加越來越臃腫。
graph TD
    A[ User ] -->| Interacts with | B[ View ]
    B -->| Sends user input to | C[ Controller ]
    C -->| Updates | D[ Model ]
    D -->| Notifies changes to | B[ View ]
    B -->| Displays data from | D[ Model ]

MVP模式(Model-View-Presenter):MVP是MVC的一種變體

  • MVP將Controller改爲Presenter,並改變了通信方向。當Model拿到資料之後,不直接給View更新,而是交給Presenter,之後Presenter再把資料交給ViewView再更新畫面。
    • View:只負責收到使用者回饋,之後呼叫Presenter拿取資料,並在接收到資料時,更新畫面。
    • Model:被動的接收到Presenter命令,拿取資料,並回傳給Presenter
    • PresenterModelView之間的的橋樑,與ViewModel溝通。
  • 從三者相互依賴變成都只依賴Presenter
    • M <=> P <=> V 之間雙向通信但ViewModel不通信
  • Presenter的程式碼會隨著功能的添加越來越臃腫。
graph TD
    A[ User ] -->| Interacts with  | B[ View ]
    B -->| Sends user input to | C[ Presenter ]
    C -->| Updates | D[ Model ]
    D -->| Notifies changes to | C[ Presenter ]
    C -->| Updates | B[ View ]
    B -->| Displays data from | C[ Presenter ]

使用MVP模式製作生命條UI:
Health類

  • 在此Health類的身份是Model,保存真正的生命資料
  • 含有一個HealthChanged event,每當有更改生命值的動作時,都會呼叫這個event。
  • Health類只負責增加,減少生命值,符合符合單一職責原則
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    public class Health : MonoBehaviour
    {
    public event Action HealthChanged;

    private const int minHealth = 0;
    private const int maxHealth = 100;
    private int currentHealth;

    public int CurrentHealth
    {
    get => currentHealth;
    set => currentHealth = value;
    }

    public int MinHealth => minHealth;
    public int MaxHealth => maxHealth;

    public void Increment(int amount)
    {
    currentHealth += amount;
    currentHealth = Mathf.Clamp(currentHealth, minHealth, maxHealth);
    UpdateHealth();
    }

    public void Decrement(int amount)
    {
    currentHealth -= amount;
    currentHealth = Mathf.Clamp(currentHealth, minHealth, maxHealth);
    UpdateHealth();
    }

    public void Restore()
    {
    currentHealth = maxHealth;
    UpdateHealth();
    }

    private void UpdateHealth()
    {
    HealthChanged?.Invoke();
    }
    }

HealthPresenter類

  • HealthPresenter類含有Health類的依賴,用來操控Health
  • 其他物件不會直接操控Health類,而是透過HealthPresenter類暴露的DamageHeal以及Reset來操控
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    public class HealthPresenter : MonoBehaviour
    {
    // 在MVP中的Model
    [SerializeField] private Health health;

    // 在MVP中的View
    [SerializeField] private Slider healthSlider;

    private void Start()
    {
    if (health != null)
    {
    health.HealthChanged += OnHealthChanged;
    }
    UpdateView();
    }

    private void OnDestroy()
    {
    if (health != null)
    {
    health.HealthChanged -= OnHealthChanged;
    }
    }

    public void Damage(int amount)
    {
    health?.Decrement(amount);
    }

    public void Heal(int amount)
    {
    health?.Increment(amount);
    }

    public void Reset()
    {
    health?.Restore();
    }

    public void UpdateView()
    {
    if (health == null) return;

    if (healthSlider != null && health.MaxHealth != 0)
    {
    healthSlider.value = (float)health.CurrentHealth / health.MaxHealth;
    }
    }

    private void OnHealthChanged()
    {
    UpdateView();
    }
    }

ClickDamage類:為使用HealthPresenter的類

  • 不直接操控 Model (Health)與 View (healthSlider)而是透過HealthPresenter
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    [RequireComponent(typeof(HealthPresenter), typeof(Collider))]
    public class ClickDamage : MonoBehaviour
    {
    private Collider collider;
    private HealthPresenter healthPresenter;
    [SerializeField] private LayerMask layerToClick;
    [SerializeField] private int damageValue = 10;

    private void Start()
    {
    collider = GetComponent<Collider>();
    healthPresenter = GetComponent<HealthPresenter>();
    }

    private void Update()
    {
    if (Input.GetMouseButtonDown(0))
    {
    Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    if (Physics.Raycast(ray, Mathf.Infinity, layerToClick))
    {
    healthPresenter?.Damage(damageValue);
    }
    }
    }
    }

Reference: https://github.com/Unity-Technologies/game-programming-patterns-demo/tree/main/Assets/12%20MVP
https://github.com/push-pop/Unity-MVVM/blob/main/Docs/Architechture.md

State Pattern

狀態模式(State pattern):

  • 當物件的內部狀態改變時,物件的行為也會隨之改變,即物件會根據當前狀態執行不同的行為。
  • 每個狀態的行為是獨立定義的。添加新狀態不會影響現有狀態的行為。

在遊戲中會需要不斷地追蹤遊戲中的各種狀態,例如玩家走路(Walking),跳躍(Jumping),待命(IdleStanding)等。若你把它畫成流程圖(flowchart)可能會發現

  • 一次只有一個狀態會被啟用(Active)
  • 狀態會依據不同的條件去轉換到另外一個狀態
  • 當轉換發生時,新的狀態會成為啟用狀態(Active state)
  • 這種狀態轉換圖被稱為有限狀態機(Finite state machineFSM)

以下是一個使用Enum去實作的簡單有限狀態機:

1
2
3
4
5
6
public enum PlayerControllerState
{
Idle,
Walk,
Jump
}

在這個Controller類中,使用switch轉換狀態,很快你會發現當狀態越來越多後,Controller會變得一團亂,例如增加幾個狀態,就必須要不斷的更改Controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class UnrefactoredPlayerController : MonoBehaviour
{
private PlayerControllerState state;
private void Update()
{
GetInput();
switch (state)
{
case PlayerControllerState.Idle:
Idle();
break;
case PlayerControllerState.Walk:
Walk();
break;
case PlayerControllerState.Jump:
Jump();
break;
}
}
private void GetInput()
{
// 處理行走和跳躍控制
}
private void Walk()
{
// 行走邏輯
}
private void Idle()
{
// 待命邏輯
}
private void Jump()
{
// 跳躍邏輯
}
}

使用狀態模式,在遊戲中,你可以想像處理角色狀態需要:

  1. 當條件滿足時,第一次進入(Enter)這個狀態時需要處理一些事。
  2. 在遊戲迴圈中根據這個狀態不斷的更新(Update)角色的值。
  3. 當條件不滿足或是滿足某些離開條件時,離開(Exit)這個狀態時,要在離開前處理一些事。
    因此根據這樣的流程定義一個抽象介面(Interface),IState並定義它有以下方法:
  • Enter:第一次進入這個狀態時需要做的邏輯
  • Update: 在每一幀中需要執行的邏輯(有時也稱ExceuteTick)。它會根據這個狀態在每一幀中不斷的更新角色的值。
  • Exit:在離開這個狀態前要做的邏輯
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public interface IState
    {
    public void Enter()
    {
    // 當第一次進入狀態時運行的邏輯
    }
    public void Update()
    {
    // 每幀邏輯,包括轉換到新狀態的條件
    }
    public void Exit()
    {
    // 當離開狀態時運行的邏輯
    }
    }

行走狀態:在這個狀態中,Update()會不斷地移動這個角色,另外也會判斷是否要切換到其他狀態。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class WalkState : IState
{
private Color meshColor = Color.blue;
public Color MeshColor { get => meshColor; set => meshColor = value; }

private PlayerController player;

// 可以在建構子中傳遞任何需要的參數
public WalkState(PlayerController player)
{
this.player = player;
}

public void Enter()
{
//Debug.Log("Entering Walk State");
}

public void Update()
{
// 如果不再在地面上,轉換到跳躍狀態
if (!player.IsGrounded)
{
player.PlayerStateMachine.TransitionTo(player.PlayerStateMachine.jumpState);
}

// 如果速度降到最低臨界值以內,轉換到待命狀態
if (Mathf.Abs(player.CharController.velocity.x) < 0.1f && Mathf.Abs(player.CharController.velocity.z) < 0.1f)
{
player.PlayerStateMachine.TransitionTo(player.PlayerStateMachine.idleState);
}
}

public void Exit()
{
//Debug.Log("Exiting Walk State");
}

}

待命狀態

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class IdleState : IState
{
private PlayerController player;

private Color meshColor = Color.gray;
public Color MeshColor { get => meshColor; set => meshColor = value; }

public IdleState(PlayerController player)
{
this.player = player;
}

public void Enter()
{
Debug.Log("Entering Idle State");
}

public void Update()
{
// 如果不再在地面上,轉換到跳躍狀態
if (!player.IsGrounded)
{
player.PlayerStateMachine.TransitionTo(player.PlayerStateMachine.jumpState);
}

// 如果速度超過臨界值,轉換到移動狀態
if (Mathf.Abs(player.CharController.velocity.x) > 0.1f || Mathf.Abs(player.CharController.velocity.z) > 0.1f)
{
player.PlayerStateMachine.TransitionTo(player.PlayerStateMachine.walkState);
}
}

public void Exit()
{
Debug.Log("Exiting Idle State");
}
}

StateMachine負責管理這些狀態

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class StateMachine
{
public IState CurrentState { get; private set; } // 目前狀態

// 狀態物件的引用
public WalkState walkState;
public JumpState jumpState;
public IdleState idleState;

// 用於通知其他物件狀態改變的事件
public event Action<IState> stateChanged;

// 由於沒有繼承 MonoBehaviour,因此需要通過建構子將 PlayerController 傳入
public StateMachine(PlayerController player)
{
// 建立每個狀態的實體並傳遞 PlayerController
this.walkState = new WalkState(player);
this.jumpState = new JumpState(player);
this.idleState = new IdleState(player);
}

// 設置初始狀態
public void Initialize(IState state)
{
CurrentState = state;
state.Enter();

// 通知其他物件狀態已改變
stateChanged?.Invoke(state);
}

// 退出目前狀態並進入另一個狀態
public void TransitionTo(IState nextState)
{
CurrentState.Exit();
CurrentState = nextState;
nextState.Enter();

// 通知其他物件狀態已改變
stateChanged?.Invoke(nextState);
}

public void Update()
{
if (CurrentState != null)
{
CurrentState.Update();
}
}
}

在Controller中使用StateMachine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
[RequireComponent(typeof(PlayerInput), typeof(CharacterController))]
public class PlayerController : MonoBehaviour
{
[SerializeField] private PlayerInput playerInput;
private StateMachine playerStateMachine;

[Header("Movement")]
[Tooltip("Horizontal speed")]
[SerializeField] private float moveSpeed = 5f;
[Tooltip("Rate of change for move speed")]
[SerializeField] private float acceleration = 10f;
[Tooltip("Max height to jump")]
[SerializeField] private float jumpHeight = 1.25f;

[Tooltip("Custom gravity for player")]
[SerializeField] private float gravity = -15f;
[Tooltip("Time between jumps")]
[SerializeField] private float jumpTimeout = 0.1f;

[SerializeField] private bool isGrounded = true; // 是否在地面上
[SerializeField] private float groundedRadius = 0.5f; // 檢查是否在地面的半徑
[SerializeField] private float groundedOffset = 0.15f;
[SerializeField] private LayerMask groundLayers;

public CharacterController CharController => charController;
public bool IsGrounded => isGrounded;
public StateMachine PlayerStateMachine => playerStateMachine;

private CharacterController charController;
private float targetSpeed;
private float verticalVelocity;
private float jumpCooldown;

private void Awake()
{
playerInput = GetComponent<PlayerInput>();
charController = GetComponent<CharacterController>();

// 初始化狀態機
playerStateMachine = new StateMachine(this);
}

private void Start()
{
playerStateMachine.Initialize(playerStateMachine.idleState); // 設置初始狀態為 idleState
}

private void Update()
{
// 使用目前狀態更新角色資料
playerStateMachine.Update();
}

private void LateUpdate()
{
CalculateVertical();
Move();
}

private void Move()
{
Vector3 inputVector = playerInput.InputVector;

if (inputVector == Vector3.zero)
{
targetSpeed = 0;
}

float currentHorizontalSpeed = new Vector3(charController.velocity.x, 0.0f, charController.velocity.z).magnitude;
float tolerance = 0.1f;

if (currentHorizontalSpeed < targetSpeed - tolerance || currentHorizontalSpeed > targetSpeed + tolerance)
{
targetSpeed = Mathf.Lerp(currentHorizontalSpeed, targetSpeed, Time.deltaTime * acceleration);
targetSpeed = Mathf.Round(targetSpeed * 1000f) / 1000f;
}
else
{
targetSpeed = moveSpeed;
}

charController.Move((inputVector.normalized * targetSpeed * Time.deltaTime) + new Vector3(0f, verticalVelocity, 0f) * Time.deltaTime);
}

private void CalculateVertical()
{
if (isGrounded)
{
if (verticalVelocity < 0f)
{
verticalVelocity = -2f;
}

if (playerInput.IsJumping && jumpCooldown <= 0f)
{
verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
}

if (jumpCooldown >= 0f)
{
jumpCooldown -= Time.deltaTime;
}
}
else
{
jumpCooldown = jumpTimeout;
playerInput.IsJumping = false;
}

verticalVelocity += gravity * Time.deltaTime;

Vector3 spherePosition = new Vector3(transform.position.x, transform.position.y + groundedOffset, transform.position.z);
isGrounded = Physics.CheckSphere(spherePosition, 0.5f, groundLayers, QueryTriggerInteraction.Ignore);
}

private void OnDrawGizmosSelected()
{
Color transparentGreen = new Color(0.0f, 1.0f, 0.0f, 0.35f);
Color transparentRed = new Color(1.0f, 0.0f, 0.0f, 0.35f);

Gizmos.color = isGrounded ? transparentGreen : transparentRed;

Gizmos.DrawSphere(new Vector3(transform.position.x, transform.position.y + groundedOffset, transform.position.z), groundedRadius);
}
}

Reference: https://github.com/Unity-Technologies/game-programming-patterns-demo/tree/main/Assets/10%20State/Scripts

依賴(dependency)

依賴(Dependency):通常指的是一個類別依賴於另一個類別或介面來完成其功能。

  • 依賴使得類別之間的耦合度增加,讓系統變得難以維護和測試。

依賴關係的方向性可以分為單向依賴(Unidirectional Dependency)和雙向依賴(Bidirectional Dependency)。

  • 單向依賴(Unidirectional Dependency):是指一個類別或模組依賴於另一個類別或模組,但相反的依賴關係不存在。這種依賴關係是單向的,即依賴的方向是單一的,這種依賴的程式碼較容易維護和擴展。

    • 在下面例子中,Car 類別依賴於 Engine 類別,但 Engine 類別並不依賴 Car 類別。這就是一個單向依賴的例子。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      public class Engine
      {
      public void Start()
      {
      Console.WriteLine("Engine started.");
      }
      }

      public class Car
      {
      private Engine _engine;

      public Car(Engine engine)
      {
      _engine = engine;
      }

      public void Start()
      {
      _engine.Start();
      }
      }
  • 雙向依賴(Bidirectional Dependency):是指兩個類別或模組互相依賴,形成了雙向的依賴關係。這種依賴關係會增加系統的耦合度,使得程式碼難以維護和測試。

    • 通常在設計中應該盡量避免雙向依賴,或者使用依賴注入和接口等技術來減少耦合度。
    • 在這個例子中,Engine 類別依賴於 Car 類別,並且 Car 類別也依賴於 Engine 類別,形成了雙向依賴。這種設計會使得類別之間的關係變得複雜,難以維護和測試。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      public class Engine
      {
      private Car _car;

      public Engine(Car car)
      {
      _car = car;
      }

      public void Start()
      {
      Console.WriteLine("Engine started.");
      _car.NotifyEngineStarted();
      }
      }

      public class Car
      {
      private Engine _engine;

      public Car()
      {
      _engine = new Engine(this);
      }

      public void Start()
      {
      _engine.Start();
      }

      public void NotifyEngineStarted()
      {
      Console.WriteLine("Car notified: Engine started.");
      }
      }

      public class Program
      {
      public static void Main(string[] args)
      {
      Car car = new Car();
      car.Start();
      }
      }

Command Pattern

命令模式(Command Pattern):當你想要追蹤一系列特定動作時,可以使用命令模式(command pattern)。如果你玩過帶有撤銷(undo)/重做(redo)功能或是能夠在列表中保留輸入歷史的遊戲,你很可能已經見過命令模式。想像一個策略遊戲,玩家可以在實際執行之前計劃多個動作,這就是命令模式。

在設計模式中,命令模式有以下角色參與其中

  • Command:抽象命令,可能為一個介面(Interface)或是抽象類(Abstract Class),例如ICommand
  • ConcreteCommand:具體命令,即實作抽象命令的Class
  • Invoker:命令的調用者,發起者,管理者
  • Receiver:接收者,實際執行命令者,被Command訪問與操作
  • 使用命令的客戶端(Client)

命令模式特點:

  • 命令模式不直接呼叫方法(method)而是會將單個或多個方法封裝為命令物件(command object)。
  • 命令物件會存放到一個容器(像是Stack或是Queue)中,讓你可以在想要的時間點去執行或是撤銷這些命令物件。
  • 新增命令不會影響其他地方,符合了開閉原則(Open-closed principle)
  • 除了撤銷(undo)/重做(redo)功能,也可以用來實現回放(Play back)功能

接收者(Receiver)類:PlayerMover,在命令物件執行時真正執行動作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class PlayerMover : MonoBehaviour
{
[SerializeField] private LayerMask obstacleLayer;

private const float boardSpacing = 1f;
private PlayerPath playerPath;
public PlayerPath PlayerPath => playerPath;

private void Start()
{
playerPath = gameObject.GetComponent<PlayerPath>();
}

public void Move(Vector3 movement)
{
Vector3 destination = transform.position + movement;
transform.position = destination;
}

public bool IsValidMove(Vector3 movement)
{
return !Physics.Raycast(transform.position, movement, boardSpacing, obstacleLayer);
}
}

定義一個抽象命令介面(ICommand)

1
2
3
4
5
public interface ICommand
{
void Execute();
void Undo();
}

定義一個具體命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MoveCommand : ICommand
{
private PlayerMover _playerMover;
private Vector3 _movement;

public MoveCommand(PlayerMover player, Vector3 moveVector)
{
this._playerMover = player;
this._movement = moveVector;
}

public void Execute()
{
_playerMover?.PlayerPath.AddToPath(_playerMover.transform.position + _movement);
_playerMover.Move(_movement);
}

public void Undo()
{
_playerMover.Move(-_movement);
_playerMover?.PlayerPath.RemoveFromPath();
}
}

定義一個使用命令的命令调用者類CommandInvoker,它只負責執行(ExecuteCommand())和撤銷(UndoCommand())命令。

  • 它含有一個容器(undoStack),用來儲存一系列的命令。
  • 當執行命令時,從容器中取出一個命令物件執行方法(Execute)
  • 當撤消命令時,從容器中取出一個命令物件,並執行撤銷方法(Undo)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    public class CommandInvoker
    {
    private static Stack<ICommand> _undoStack = new Stack<ICommand>();
    private static Stack<ICommand> _redoStack = new Stack<ICommand>();

    public static void ExecuteCommand(ICommand command)
    {
    command.Execute();
    _undoStack.Push(command);
    _redoStack.Clear();
    }

    public static void UndoCommand()
    {
    if (_undoStack.Count > 0)
    {
    ICommand activeCommand = _undoStack.Pop();
    _redoStack.Push(activeCommand);
    activeCommand.Undo();
    }
    }

    public static void RedoCommand()
    {
    if (_redoStack.Count > 0)
    {
    ICommand activeCommand = _redoStack.Pop();
    _undoStack.Push(activeCommand);
    activeCommand.Execute();
    }
    }
    }

使用命令的客戶端(Client)類,InputManager,它透過CommandInvoker執行命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class InputManager : MonoBehaviour
{
[Header("Button Controls")]
[SerializeField] Button forwardButton;
[SerializeField] Button backButton;
[SerializeField] Button leftButton;
[SerializeField] Button rightButton;
[SerializeField] Button undoButton;
[SerializeField] Button redoButton;

[SerializeField] private PlayerMover player;

private void Start()
{
forwardButton.onClick.AddListener(OnForwardInput);
backButton.onClick.AddListener(OnBackInput);
rightButton.onClick.AddListener(OnRightInput);
leftButton.onClick.AddListener(OnLeftInput);
undoButton.onClick.AddListener(OnUndoInput);
redoButton.onClick.AddListener(OnRedoInput);
}

private void RunPlayerCommand(PlayerMover playerMover, Vector3 movement)
{
if (playerMover == null) return;
if (playerMover.IsValidMove(movement))
{
ICommand command = new MoveCommand(playerMover, movement);
CommandInvoker.ExecuteCommand(command);
}
}

private void OnLeftInput() { RunPlayerCommand(player, Vector3.left); }
private void OnRightInput() { RunPlayerCommand(player, Vector3.right); }
private void OnForwardInput() { RunPlayerCommand(player, Vector3.forward); }
private void OnBackInput() { RunPlayerCommand(player, Vector3.back); }
private void OnUndoInput() { CommandInvoker.UndoCommand(); }
private void OnRedoInput() { CommandInvoker.RedoCommand(); }
}

Reference: https://github.com/Unity-Technologies/game-programming-patterns-demo/tree/main/Assets/9%20Command

Singleton pattern

單例模式(Singleton pattern):確保應用程式中只有一個實例,並且提供簡單的全局訪問方式。

然而,單例模式在一些設計模式中被認為弊大於利,被列為反模式(anti-pattern),這是因為單例模式太容易使用了導致開發人員很容易設計出不必要的全域狀態或是全域依賴。

優點

  • 使用簡單,單例是全域可存取的,很容易的就可以取道該單例並在程式中使用。
  • 由於單例是全域可存取因此你不需要去想使否需要暫存它。

缺點

  • 全局存取:單例是全域可存取的,違反了 SOLD原則。
  • 測試困難:單例模式使得測試必須互相獨立變得困難,因為單例可以直接改變狀態。
  • 強依賴性:單例造成的依賴性太強,如果你想更改某個單例,就需要更改所有有使用到它的地方。

以下是一個簡單的單例模式

  • public static Instance用來保存Sence中的單例實體
  • 在Awake()中檢查是否已有這個實例,如果沒有則將其設為實例;如果有則表示有重複設定,需要將它銷毀,以確保在這個場景中只有一個實例。
  • 這個SimpleSingleton有一些缺陷
    • 當載入新Scene時會銷毀這個GameObject(可以使用 DontDestroyOnLoad 告訴Unity不要摧毀它)
    • 在使用時需要將它附加到Scene結構中
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      using UnityEngine;

      public class SimpleSingleton : MonoBehaviour
      {
      public static SimpleSingleton Instance;

      private void Awake()
      {
      if (Instance == null)
      {
      Instance = this;
      }
      else
      {
      Destroy(gameObject);
      }
      }
      }

也可以使用延遲初始化(Lazy initialization)的方式,來建立單例,下面例子中

  • 使用 DontDestroyOnLoad(gameObject) 告訴 Unity 不要銷毀它,因此即使切換場景也不會清除這個單例。
  • 不需要手動附加到Scene結構中,因為使用了 SetupInstance(),如果目前還沒有建立這個單例,它會自動建立一個 GameObject 並附加到它身上。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    public class Singleton : MonoBehaviour
    {
    private static Singleton instance;

    public static Singleton Instance
    {
    get
    {
    if (instance == null)
    {
    SetupInstance();
    }
    return instance;
    }
    }

    private void Awake()
    {
    if (instance == null)
    {
    instance = this;
    DontDestroyOnLoad(this.gameObject);
    }
    else
    {
    Destroy(gameObject);
    }
    }

    private static void SetupInstance()
    {
    instance = FindObjectOfType<Singleton>();
    if (instance == null)
    {
    GameObject gameObj = new GameObject();
    gameObj.name = "Singleton";
    instance = gameObj.AddComponent<Singleton>();
    DontDestroyOnLoad(gameObj);
    }
    }
    }

更進一步地將它一般化。在上面的單例模式中,如果需要多個不同的單例,例如 AudioManager 和 GameManager,那麼會複製很多相同的程式碼來建立單例。因此,可以將這個步驟一般化,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public abstract class MonoSingleton<T> : MonoBehaviour where T : MonoSingleton<T>
{
private static T instance;
public static T Instance
{
get
{
if (instance == null)
{
instance = FindFirstObjectByType<T>();
if (instance == null)
{
SetupInstance();
}
}
return instance;
}
}

public virtual void Awake()
{
RemoveDuplicates();
}
private static void SetupInstance()
{
instance = FindFirstObjectByType<T>();
if (instance == null)
{
GameObject gameObj = new();
gameObj.name = typeof(T).Name;
instance = gameObj.AddComponent<T>();
DontDestroyOnLoad(gameObj);
}
}
private void RemoveDuplicates()
{
if (instance == null)
{
instance = this as T;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
}

之後你在使用時,只需要繼承他即可

1
2
3
4
public class GameManager: Singleton<GameManager>
{
// ...
}

Reference: https://github.com/Unity-Technologies/game-programming-patterns-demo/tree/main/Assets/8%20Singleton

Object Pool pattern

物件池(Object Pool):是一種減輕大量建立並銷毀物件時 CPU 負擔的設計模式。使用物件池時,物件會先被建立並放入池中等待,需要時應用程式不會新建物件,而是從物件池中取得並啟用它。當使用完畢後,物件不會被銷毀,而是被放回物件池中。

改進

  • 可以在程式載入時建立物件池,這樣使用者就不會感到卡頓。
  • 考慮將物件池設為static或是singleton的:這樣可以在所有情況下方便呼叫使用。
  • 使用Dictionary來管理多個物件池:如果有多個物件池,可以使用 Key-Value 的資料結構(如 Dictionary)來管理,只需根據對應的 Key 就能方便地取得所需的物件池。
  • 注意釋放物件池中的物件:確保物件在物件池中時不會被釋放,避免執行期間發生錯誤。
  • 設定物件池上限:物件過多會消耗大量記憶體,因此需要為物件池設定一個上限,避免物件池中物件過多。

以下是一個簡單的物件池,

  • 這個物件池使用Stack
  • SetupPool()用來產生一些物件放到物件池中
  • GetPooledObject()用來啟用物件並取得,當物件不足(stack.Count == 0)時,會再產生一個新物件
  • ReturnToPool()用來將物件停用,並返回物件到物件池
  • 注意不要直接使用,因為這個例子沒有設定Stack上限,可能會產生過多的物件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    public class ObjectPool : MonoBehaviour
    {
    [SerializeField] private uint initPoolSize;
    [SerializeField] private PooledObject objectToPool;
    // 存儲物件池中的物件
    private Stack<PooledObject> stack;
    private void Start()
    {
    SetupPool();
    }
    // 建立物件池(在卡頓不明顯時調用)
    private void SetupPool()
    {
    stack = new Stack<PooledObject>();
    PooledObject instance = null;
    for (int i = 0; i < initPoolSize; i++)
    {
    instance = Instantiate(objectToPool);
    instance.Pool = this;
    instance.gameObject.SetActive(false);
    stack.Push(instance);
    }
    }
    // 從物件池中返回第一個可用的物件
    public PooledObject GetPooledObject()
    {
    // 如果物件不夠,則實例化新的物件
    if (stack.Count == 0)
    {
    PooledObject newInstance = Instantiate(objectToPool);
    newInstance.Pool = this;
    return newInstance;
    }
    // 否則,從物件池中取得下一個物件
    PooledObject nextInstance = stack.Pop();
    nextInstance.gameObject.SetActive(true);
    return nextInstance;
    }
    public void ReturnToPool(PooledObject pooledObject)
    {
    stack.Push(pooledObject);
    pooledObject.gameObject.SetActive(false);
    }
    }

建立一個 PooledObject 類別,讓它依賴 ObjectPool,這樣可以在使用完畢後返回物件池:

1
2
3
4
5
6
7
8
9
public class PooledObject : MonoBehaviour
{
private ObjectPool pool;
public ObjectPool Pool { get => pool; set => pool = value; }
public void Release()
{
pool.ReturnToPool(this);
}
}

在Unity 2021後,有內建 UnityEngine.Pool 物件池,它提供了多種容器的Pool,像是

  • ObjectPool:是一個Stack
  • DictionaryPool<T0,T1>
  • GenericPool
  • HashSetPool
  • LinkedPool
  • ListPool
    使用內建 UnityEngine.Pool 物件池有許多好處,包含
  • 可以快速的使用物件池,不需要重複造輪子
  • 有多種容器供你選用
  • 當到達最大物件數量後會摧毀該物件,不建立過多的物件

以下是UnityEngine.Pool的範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
using UnityEngine.Pool;
public class RevisedGun : MonoBehaviour
{
// 使用基於 Stack 的 ObjectPool
private IObjectPool<RevisedProjectile> objectPool;
// 如果嘗試返回已經存在於池中的物件,則拋出異常
[SerializeField] private bool collectionCheck = true;
// 控制池的預設容量和最大大小
[SerializeField] private int defaultCapacity = 20;
[SerializeField] private int maxSize = 100;

private void Awake()
{
objectPool = new ObjectPool<RevisedProjectile>(CreateProjectile,
OnGetFromPool, OnReleaseToPool, OnDestroyPooledObject,
collectionCheck, defaultCapacity, maxSize);
}

// 在new ObjectPool建構子中使用,主要是先建立物件以填充物件池
private RevisedProjectile CreateProjectile()
{
RevisedProjectile projectileInstance = Instantiate(projectilePrefab);
projectileInstance.ObjectPool = objectPool;
return projectileInstance;
}

// 在new ObjectPool建構子中使用,告訴物件池在將物件返回時要做什麼動作
private void OnReleaseToPool(RevisedProjectile pooledObject)
{
pooledObject.gameObject.SetActive(false);
}

// 在new ObjectPool建構子中使用,告訴物件池在物件池中取出物件時要做什麼初始化動作
private void OnGetFromPool(RevisedProjectile pooledObject)
{
pooledObject.gameObject.SetActive(true);
}

// 在new ObjectPool建構子中使用,告訴物件池在超過最大物件數量時如何銷毀物件
private void OnDestroyPooledObject(RevisedProjectile pooledObject)
{
Destroy(pooledObject.gameObject);
}

private void FixedUpdate()
{
// 省略其他代碼
}
}

與上面簡單的ObjectPool相同,讓RevisedProjectile依賴 IObjectPool<RevisedProjectile>,在使用完畢後呼叫objectPool.Release(this)返回物件池:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class RevisedProjectile : MonoBehaviour
{
// 延遲後停用
[SerializeField] private float timeoutDelay = 3f;

private IObjectPool<RevisedProjectile> objectPool;

// 公共屬性,用於給 projectile 一個 ObjectPool 的引用
public IObjectPool<RevisedProjectile> ObjectPool { set => objectPool = value; }

public void Deactivate()
{
StartCoroutine(DeactivateRoutine(timeoutDelay));
}

private IEnumerator DeactivateRoutine(float delay)
{
yield return new WaitForSeconds(delay);

// 重置移動中的 Rigidbody
Rigidbody rBody = GetComponent<Rigidbody>();
rBody.velocity = Vector3.zero;
rBody.angularVelocity = Vector3.zero;

// 將 projectile 返回到物件池
objectPool.Release(this);
}
}

Reference: https://github.com/Unity-Technologies/game-programming-patterns-demo/tree/main/Assets/7%20Object%20Pool

Factory pattern

工廠模式(Factory pattern):讓一個特別的物件(工廠Factory)去建立其他物件(產品Product)。它封裝了生成其他物件(產品Product)的邏輯,最直接可見的好處是整理了你的程式碼。工廠物件可以有多種子類(subclass),用來產生多種不同的產品。

優點

  • 使用工廠模式的好處是當你增加產品時,你不需要修改先前的程式碼就可以增加產品

缺點

  • 增加程式碼的複雜度

建立一個IProduct介面,訂立產品必須要有的規則

  • 每個產品一定要有自己的名字(ProductName)
  • 每個產品有初始化自己的方式(Initialize())
  • 產品種類比較不會有共用的邏輯,因此將其設計為介面
    1
    2
    3
    4
    5
    public interface IProduct
    {
    public string ProductName { get; set; }
    public void Initialize();
    }

以下建立一個抽象工廠,抽象工廠用來規定工廠必須要有的動作

  • 每個工廠必須要能在對應的位置(Vector3 position)上生產產品(GetProduct())
    1
    2
    3
    4
    5
    6
    public abstract class Factory : MonoBehaviour
    {
    public abstract IProduct GetProduct(Vector3 position);
    // shared method with all factories

    }

建立一個產品A

  • 它含有一個ParticleSystem,並在初始化方法(Initialize)中會播放此ParticleSystem
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class ProductA : MonoBehaviour, IProduct
    {
    [SerializeField] private string productName = "ProductA";
    public string ProductName { get => productName; set => productName = value ; }
    private ParticleSystem particleSystem;
    public void Initialize()
    {
    // any unique logic to this product
    gameObject.name = productName;
    particleSystem = GetComponentInChildren<ParticleSystem>();
    particleSystem?.Stop();
    particleSystem?.Play();
    }
    }

建立一個工廠A

  • 它含有一個產品A的依賴,在GetProduct()方法中建立產品A實體之後,會呼叫產品A實體上的Initialize()方法用來初始化該實體。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class ConcreteFactoryA : Factory
    {
    [SerializeField] private ProductA productPrefab;
    public override IProduct GetProduct(Vector3 position)
    {
    // 使用Prefab在指定的position上建立一個instance
    GameObject instance = Instantiate(productPrefab.gameObject, position, Quaternion.identity);
    ProductA newProduct = instance.GetComponent<ProductA>();
    // 初始化產品A
    newProduct.Initialize();
    return newProduct;
    }
    }

建立一個產品B

  • 它含有一個AudioSource,在初始化方法(Initialize)中會播放此AudioSource
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class ProductB : MonoBehaviour, IProduct
    {
    [SerializeField] private string productName = "ProductB";
    public string ProductName { get => productName; set => productName = value; }

    private AudioSource audioSource;

    public void Initialize()
    {
    // do some logic here
    audioSource = GetComponent<AudioSource>();
    audioSource?.Stop();
    audioSource?.Play();

    }
    }

建立一個工廠B

  • 工廠B與工廠A類似,不過依賴換為產品B
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class ConcreteFactoryB : Factory
    {
    // used to create a Prefab
    [SerializeField] private ProductB productPrefab;

    public override IProduct GetProduct(Vector3 position)
    {
    // create a Prefab instance and get the product component
    GameObject instance = Instantiate(productPrefab.gameObject, position, Quaternion.identity);
    ProductB newProduct = instance.GetComponent<ProductB>();

    // each product contains its own logic
    newProduct.Initialize();

    // add any unique behavior to this factory
    instance.name = newProduct.ProductName;
    Debug.Log(GetLog(newProduct));

    return newProduct;
    }
    }

建立一個使用工廠的類

  • ClickToCreate這個類使用了工廠A與工廠B,
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    public class ClickToCreate : MonoBehaviour
    {
    [SerializeField] private LayerMask layerToClick;
    [SerializeField] private Vector3 offset;
    [SerializeField] Factory[] factories;

    private Factory factory;

    private void Update()
    {
    GetProductAtClick();
    }

    private void GetProductAtClick()
    {
    // check click with raycast
    if (Input.GetMouseButtonDown(0))
    {
    // choose a random factory
    factory = factories[Random.Range(0, factories.Length)];

    // instantiate product at raycast intersection
    Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    RaycastHit hitInfo;

    if (Physics.Raycast(ray, out hitInfo, Mathf.Infinity, layerToClick) && factory != null)
    {
    factory.GetProduct(hitInfo.point + offset);
    }
    }
    }
    }

SOLID principles

SOLID principles是程式設計師必須了解的五個原則

  • 單一職責原則(Single responsibility):確保類只負責一件事。
  • 開閉原則(Open-closed):在不更改現有程式碼的情況下,可以擴展一個類的功能。
  • 里氏替換原則(Liskov substitution):子類可以替代基類,而不影響程式的正確性。
  • 介面隔離原則(Interface segregation):讓介面(interface)盡可能簡單,確保實作類只需要實作其所需的介面方法。
  • 依賴倒置原則(Dependency inversion):高層模組不應依賴於低層模組,兩者都應依賴於抽象。具體實現應依賴於抽象,而不是抽象依賴於具體實現。

這些原則可以讓你的程式設計更具彈性和可維護性,但在不確定是否要使用它們時,請記住KISS(Keep It Simple, Stupid)原則,不要強迫將其應用於程式碼中。

Dependency inversion principle

依賴倒置原則(Dependency inversion principle)有兩個主要部分:

  • 高層模組不應該依賴於低層模組。兩者都應該依賴於抽象。
  • 抽象不應該依賴於具體實現。具體實現應該依賴於抽象。

在軟體設計中,如果一個類(class)使用了另一個類稱為依賴(dependencycoupling),每增加一點依賴,就會增加一些風險,因為當某個類A知道了另一個類B的內容太多的話(稱為高度耦合high degree of coupling),當B更改的話,那麼A也會需要大量的修改,這樣很容易產生錯誤。

在設計中,有些類是high-level的,而有些類是low-level的,high-level的類會依靠low-level的類去完成某些工作。我們在設計時,要考慮依賴倒置原則,減少一些耦合度。

如果要建立一個遊戲,其中角色可以探索房間,並使用開關(switch)將門(dor)打開,在實作時,你可能會想到要建立一個Switch類與一個Door類,其中

  • Switch屬於high-level的類,它負責判斷角色是否移動到對應的位置,並是否觸發對應的行為
  • Door屬於low-level的類,它負責開關門的實際操作邏輯

如果沒有使用依賴倒置原則的話,可能會如下實作

  • Switch依賴於Door,當條件觸發時,呼叫door去開門
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    public class Switch
    {
    public Door door;
    public bool isActivated;

    public void Toggle()
    {
    if (isActivated)
    {
    isActivated = false;
    door.Close();
    }
    else
    {
    isActivated = true;
    door.Open();
    }
    }
    }

    public class Door
    {
    public void Open()
    {
    Debug.Log("The door is open.");
    }

    public void Close()
    {
    Debug.Log("The door is closed.");
    }
    }

這樣的實作沒有問題,但是如果開關(Switch)不只是開門,還可能會觸發一些陷阱的話,就不得不去修改Switch類,這樣違反了開閉原則(Open-closed principle)

你可以將開關這個動作抽象=>ISwitchable。

1
2
3
4
5
6
public interface ISwitchable
{
bool IsActive { get; }
void Activate();
void Deactivate();
}

讓Door去實作這個抽象ISwitchable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Door : MonoBehaviour, ISwitchable
{
private bool isActive;
public bool IsActive => isActive;

public void Activate()
{
isActive = true;
Debug.Log("The door is open.");
}

public void Deactivate()
{
isActive = false;
Debug.Log("The door is closed.");
}
}

而Switch則依賴這個抽象ISwitchable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Switch : MonoBehaviour
{
public ISwitchable client;

public void Toggle()
{
if (client.IsActive)
{
client.Deactivate();
}
else
{
client.Activate();
}
}
}

透過這種方式將上層對底層的依賴剝離至抽象,讓上層可以不需更改程式碼,只需傳入不同的ISwitch實作便可以做到開關不同的物件。