乌啦呀哈呀哈乌啦!

欢迎光临,这里是喵pass的个人博客,希望有能帮到你的地方

0%

LZ 系列(通用数据压缩)

LZ 系列都基于同一个核心思想:找重复的字节序列,用引用替代。

1
2
3
4
5
6
7
8
9
原始数据:
"abcdefabcdef"

压缩后:
"abcdef" + (往前6个字节,复制6个字节)
↑ 这就是一个"引用",比重复存储省空间

压缩结果:
[literal: abcdef] [match: offset=6, len=6]

LZMA (Lempel-Ziv-Markov chain Algorithm)

7-Zip 使用的算法,压缩率极高但速度慢。

  • 在 LZ 基础上加了马尔可夫链概率模型
  • 用范围编码(Range Encoding)替代哈夫曼编码
  • 搜索窗口极大(最大 4GB),能找到更远的重复

普通LZ: 只看前面 32KB 找重复
LZMA: 看前面最多 4GB 找重复 → 压缩率更高,但更慢更耗内存

  • 压缩率:极高(最好)
  • 压缩速度:很慢
  • 解压速度:中等
  • 适合:安装包、资源包(压缩一次,解压多次)

LZ4

专为速度设计,压缩率换速度。

  • 搜索窗口只有 64KB,不追求最优匹配
  • 输出格式极简,解压只需简单的内存拷贝操作
  • 几乎不做概率统计,纯粹的字节匹配
1
2
3
4
5
6
7
8
9
10
11
12
13
LZ4 数据块格式:
[token 1字节] [extra literal长度?] [literal数据] [match偏移 2字节] [extra match长度?]

数据: "abcdeabcde"

序列1: literal="abcde"(5字节) + match(偏移5, 长度4) → 复制"abcd"
序列2: literal="e"(1字节,收尾无match)

但 LZ4 规范明确规定:最后一个序列必须以至少5字节的 literal 结尾,且没有 match 部分。
所以实际上 LZ4 编码器会放弃这个 match,直接把整个 "abcdeabcde" 当 literal 输出:

token = 0xA0 → 高4位=10(literal长度10), 低4位=0
literal = "abcdeabcde"
  • 压缩率:一般
  • 压缩速度:极快
  • 解压速度:极快(接近内存带宽上限)
  • 适合:运行时实时解压、游戏资源热加载

LZ4HC (LZ4 High Compression)

LZ4 的高压缩变体,压缩慢但解压和 LZ4 一样快。

1
2
3
LZ4   → 快速找"够用"的匹配就行
LZ4HC → 穷举搜索"最优"匹配,压缩慢3-10倍
但输出格式完全兼容LZ4,解压速度相同

适合:离线打包资源(压缩慢没关系),运行时快速解压。


DXT Texture(GPU 纹理压缩)

和上面完全不同,DXT 是专门为 GPU 设计的,解压在 GPU 硬件上完成,不占 CPU。

块压缩(Block Compression)

1
2
3
4
5
6
7
8
9
10
把纹理分成 4×4 像素的小块:
┌──┬──┬──┬──┐
│ │ │ │ │
├──┼──┼──┼──┤
│ │ │ │ │ 每个 4×4 块 = 16个像素
├──┼──┼──┼──┤
│ │ │ │ │ 原始大小 = 16 × 4字节(RGBA) = 64字节
├──┼──┼──┼──┤
│ │ │ │ │
└──┴──┴──┴──┘

DXT1 (BC1)

1
2
3
4
5
6
7
8
9
10
每个 4×4 块只存:
- 2个"端点颜色" color0, color1(各16位)
- 16个像素各2位的索引(选0%/33%/66%/100%插值)

存储大小 = 8字节(原来64字节)→ 压缩比 8:1

color0 ──────────────────── color1
| | | |
100% 66% 33% 0%
↑ 每个像素用2bit选择落在哪个位置

Unity 中的压缩格式

AssetBundle有三种压缩模式

1
2
3
4
5
6
7
8
9
AssetBundle 文件:
┌─────────────────────────────┐
│ Header │ 版本、压缩类型、文件信息
├─────────────────────────────┤
│ Catalog/Manifest │ 资源索引表(资源名→文件偏移)
├─────────────────────────────┤
│ Asset Data │ 实际资源数据(压缩块)
│ [block0][block1][block2] │
└─────────────────────────────┘
打包时指定压缩方式
1
2
3
BuildAssetBundles("Assets/AB", BuildAssetBundleOptions.None);              // LZMA
BuildAssetBundles("Assets/AB", BuildAssetBundleOptions.ChunkBasedCompression); // LZ4
BuildAssetBundles("Assets/AB", BuildAssetBundleOptions.UncompressedAssetBundle); // 不压缩

LZMA(整体压缩)

1
2
3
4
5
6
7
整个 AB 文件作为一个流压缩:
┌────────────────────────────────────┐
│ LZMA压缩流(Header+Catalog+Data) │
└────────────────────────────────────┘

加载时必须解压整个文件才能读取任何一个资源
→ 体积最小,但加载慢,内存峰值高

LZ4(块压缩)

1
2
3
4
5
6
7
每个数据块独立压缩:
┌────────┬────────┬────────┬────────┐
│ block0 │ block1 │ block2 │ block3 │ 每块独立LZ4压缩
└────────┴────────┴────────┴────────┘

加载某个资源时只解压它所在的块
→ 体积稍大,但可以随机访问,加载快

下载包 vs 运行时加载

这是两个不同阶段,关注点完全不同:

1
2
3
4
5
6
7
8
9
下载阶段:
用户等待下载 → 关注流量和时间
→ 用 LZMA,体积最小,节省流量和下载时间
→ 下载完存到本地磁盘

运行时加载阶段:
游戏运行中加载资源 → 关注速度和流畅度
→ 用 LZ4,随机访问,解压快,不卡帧
→ 从本地磁盘读取并解压到内存

实际工作流:

1
2
3
4
5
6
7
服务器存 LZMA 包
↓ 用户下载
本地磁盘存 LZMA 包
↓ 首次加载时 Unity 转换(RecompressAssetBundleAsync)
本地缓存存 LZ4 包
↓ 之后每次启动
直接加载 LZ4 包,速度快
  • Unity 不会自动将LZMA转换成LZ4(除非用 UnityWebRequest + 缓存版本号)
  • 转换 = 解压 LZMA + 重压缩 LZ4,是两步操作
  • 推荐在下载完成后立即转换,游戏运行时直接读 LZ4
Unity 提供的重压缩 API
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
IEnumerator DownloadAndRecompress(string url, string savePath, string cachePath)
{
// 下载 LZMA 包
using var req = UnityWebRequestAssetBundle.GetAssetBundle(url);
yield return req.SendWebRequest();

// 存到磁盘
File.WriteAllBytes(savePath, req.downloadHandler.data);

// 立即转换成 LZ4 存到缓存目录
var op = AssetBundle.RecompressAssetBundleAsync(
savePath, cachePath,
BuildCompression.LZ4,
0, ThreadPriority.Low
);
yield return op;

// 删掉原始 LZMA 包(可选)
File.Delete(savePath);

// 之后加载用 cachePath
}

哈夫曼编码

思路:高频字符用短编码,低频字符用长编码,减少总位数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
原始数据:"AAAAABBBCD"
统计频率:A=5, B=3, C=1, D=1

建哈夫曼树:
10
/ \
A 5
(5) / \
B 2
(3) / \
C D
(1) (1)

编码结果:
A → 0 (1位)
B → 10 (2位)
C → 110 (3位)
D → 111 (3位)

原始:10字符 × 8位 = 80位
压缩:5×1 + 3×2 + 1×3 + 1×3 = 17位 → 压缩率 79%

DEFLATE = LZ77 + 哈夫曼(zip/gzip/PNG 都用这个)

先用 LZ 消除重复,再用哈夫曼压缩剩余符号:

1
2
3
4
5
6
7
8
9
10
11
12
13
第一步 LZ77:
原始: "abcdeabcde"
输出: [literal:abcde] [match:5,5]
↓ 转成符号流
a b c d e <match:5,5>

第二步 哈夫曼:
对符号流统计频率,高频符号用短编码
a,b,c,d,e 各出现1次
<match> 出现1次
→ 再压缩一遍

两步叠加,压缩率比单独用任何一种都高

帧同步 (Lockstep / Frame Sync)

核心思想:所有客户端执行相同的输入,保证逻辑完全一致,只同步”操作指令”不同步状态。

  1. 客户端收集本帧输入
  2. 将输入发送给服务器
  3. 服务器等待所有客户端输入(或超时补默认输入)
  4. 服务器广播所有客户端的输入
  5. 所有客户端用相同输入推进同一逻辑帧
  6. 定期做 checksum 校验防止逻辑分叉
帧同步客户端
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
// 输入数据结构
[System.Serializable]
public struct FrameInput
{
public int playerId;
public int frame;
public Vector2 moveDir;
public bool firePressed;
}

// 帧同步管理器
public class LockstepManager : MonoBehaviour
{
public static int CurrentFrame { get; private set; }
private const float FrameInterval = 0.066f; // ~15帧/秒逻辑帧
private float _timer;

// 存储每帧所有玩家的输入
private Dictionary<int, List<FrameInput>> _frameInputBuffer = new();

// 本地玩家输入
private FrameInput _localInput;

void Update()
{
CollectLocalInput();

_timer += Time.deltaTime;
if (_timer >= FrameInterval)
{
_timer -= FrameInterval;
TrySendInput();
}
}

void CollectLocalInput()
{
_localInput = new FrameInput
{
playerId = NetworkManager.LocalPlayerId,
frame = CurrentFrame,
moveDir = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical")),
firePressed = Input.GetButton("Fire1")
};
}

void TrySendInput()
{
// 发送本地输入到服务器
NetworkManager.Send(_localInput);
}

// 服务器广播回来后调用
public void OnReceiveFrameInputs(List<FrameInput> inputs)
{
_frameInputBuffer[CurrentFrame] = inputs;
ExecuteFrame(CurrentFrame, inputs);
CurrentFrame++;
}

void ExecuteFrame(int frame, List<FrameInput> inputs)
{
foreach (var input in inputs)
{
var player = PlayerManager.GetPlayer(input.playerId);
player.ApplyInput(input); // 确定性逻辑,不用 Time.deltaTime
}

// checksum 校验
int checksum = ComputeChecksum();
NetworkManager.SendChecksum(frame, checksum);
}

int ComputeChecksum()
{
// 简单示例:对所有玩家位置做哈希
int hash = 0;
foreach (var player in PlayerManager.AllPlayers)
{
hash ^= player.LogicPosition.GetHashCode();
}
return hash;
}
}

// 玩家逻辑(必须确定性,避免浮点误差用定点数)
public class PlayerLogic : MonoBehaviour
{
public Vector2Int LogicPosition; // 用整数或定点数
private const int Speed = 5;

public void ApplyInput(FrameInput input)
{
// 确定性移动,不依赖 Time.deltaTime
LogicPosition += Vector2Int.RoundToInt(input.moveDir * Speed);
transform.position = new Vector3(LogicPosition.x * 0.01f, 0, LogicPosition.y * 0.01f);
}
}

状态同步

核心思想:服务器是权威,定期将游戏状态广播给所有客户端,客户端做插值表现。

  1. 客户端发送输入/操作到服务器
  2. 服务器执行逻辑,更新权威状态
  3. 服务器定期广播状态快照
  4. 客户端收到状态后做插值/预测修正
  5. 客户端本地预测(可选,减少延迟感)
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
// 状态数据结构
[System.Serializable]
public struct PlayerState
{
public int playerId;
public Vector3 position;
public Vector3 velocity;
public float timestamp;
}

// 服务器端逻辑(Server-side,可用 Mirror/Netcode 等框架)
public class ServerPlayerController : MonoBehaviour
{
private Rigidbody _rb;
private float _broadcastInterval = 0.05f; // 20次/秒
private float _timer;

void FixedUpdate()
{
_timer += Time.fixedDeltaTime;
if (_timer >= _broadcastInterval)
{
_timer = 0;
BroadcastState();
}
}

// 收到客户端输入
public void OnReceiveInput(Vector2 moveDir, int playerId)
{
// 服务器执行权威逻辑
Vector3 force = new Vector3(moveDir.x, 0, moveDir.y) * 10f;
_rb.AddForce(force);
}

void BroadcastState()
{
var state = new PlayerState
{
playerId = GetPlayerId(),
position = transform.position,
velocity = _rb.velocity,
timestamp = Time.time
};
NetworkManager.BroadcastToAll(state);
}
}

// 客户端插值
public class ClientPlayerInterpolation : MonoBehaviour
{
private PlayerState _from;
private PlayerState _to;
private float _lerpT;
private const float InterpDelay = 0.1f; // 100ms 缓冲

private Queue<PlayerState> _stateBuffer = new();

// 收到服务器状态
public void OnReceiveState(PlayerState state)
{
_stateBuffer.Enqueue(state);
}

void Update()
{
float renderTime = Time.time - InterpDelay;

// 找到合适的插值区间
while (_stateBuffer.Count > 1 && _stateBuffer.Peek().timestamp <= renderTime)
{
_from = _stateBuffer.Dequeue();
}

if (_stateBuffer.Count > 0)
{
_to = _stateBuffer.Peek();
float t = Mathf.InverseLerp(_from.timestamp, _to.timestamp, renderTime);
transform.position = Vector3.Lerp(_from.position, _to.position, t);
}
}
}

// 客户端预测(本地玩家)
public class ClientPrediction : MonoBehaviour
{
private Vector3 _predictedPos;
private Rigidbody _rb;

void Update()
{
// 本地立即响应输入
Vector2 input = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical"));
_rb.AddForce(new Vector3(input.x, 0, input.y) * 10f);

// 发送输入到服务器
NetworkManager.SendInput(input);
}

// 服务器校正
public void OnServerCorrection(PlayerState authoritative)
{
float posError = Vector3.Distance(transform.position, authoritative.position);
if (posError > 0.5f)
{
// 误差过大,强制修正
transform.position = authoritative.position;
_rb.velocity = authoritative.velocity;
}
else
{
// 平滑修正
transform.position = Vector3.Lerp(transform.position, authoritative.position, 0.3f);
}
}
}

对比

维度 帧同步 状态同步
核心职责 客户端跑逻辑,服务器转发输入 服务器跑逻辑,客户端渲染状态
网络带宽 低(只传输入指令) 高(传完整状态数据)
服务器压力
延迟敏感度 高,需等待最慢玩家 低,可做客户端预测掩盖延迟
反外挂能力 弱,逻辑在客户端 强,服务器权威不可篡改
断线重连 慢,需回放所有历史帧 快,直接同步当前状态快照
实现复杂度 高(必须保证确定性) 中(需处理插值和预测修正)
适用场景 RTS、MOBA、格斗游戏 FPS、MMO、竞技射击

帧同步(Lockstep)

所有客户端共享相同的初始状态,服务器只负责收集并广播每帧所有玩家的输入指令。每个客户端用完全相同的输入自行推进逻辑,只要逻辑是确定性的,所有客户端的状态就会始终一致。本质是”同步输入,各自计算”。

  • 浮点数在不同平台/编译器结果可能不同,必须用定点数
  • 不能用任何随机函数,需用确定性伪随机(相同种子)
  • 物理引擎(如 Unity PhysX)本身不确定,需自研或用确定性物理库

状态同步(State Sync)

服务器是唯一的权威,负责运行所有游戏逻辑并定期将结果(位置、血量等状态)广播给客户端。客户端只负责展示,不参与权威计算。本质是”服务器计算,同步结果”。

  • 网络延迟导致操作反馈滞后,需要客户端预测
  • 预测结果和服务器权威状态不一致时需要平滑修正(防止位置跳变)
  • 状态数据量大时需要做差量同步(delta compression)降低带宽

为什么帧同步必须用定点数

帧同步的前提是:所有客户端用相同输入,得到完全相同的结果。浮点数破坏这个前提:

浮点数在不同平台/编译器的问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
float a = 1.0f / 3.0f;

// x86 FPU (80位精度中间计算): 0.33333334326744...
// ARM NEON (32位严格计算): 0.33333331346511...
// 结果不同!

// 累积误差示例:同样的逻辑跑1000帧
float pos = 0f;
for (int i = 0; i < 1000; i++)
pos += 0.1f; // 每帧微小误差累积

// 平台A结果: 99.99999...
// 平台B结果: 100.00001...
// 1000帧后两个客户端玩家位置已经不同了
定点数:整数运算,在所有平台结果完全一致
1
2
3
4
5
6
7
Fixed pos = Fixed.From(0);
Fixed step = Fixed.From(0.1f); // 初始化时转换一次

for (int i = 0; i < 1000; i++)
pos += step; // 整数加法,任何平台结果都是同一个整数

// 所有平台:raw = 6553600,ToFloat() = 100.0 ✓

状态同步中客户端收到的是服务器算好的结果,不存在”两台机器算同一件事结果不同”的问题。

定点数 -> 用整数放大 65536 倍模拟小数 -> 牺牲数值范围,换取跨平台一致性

帧同步需要定点数:多个客户端必须算出完全相同的结果,必须保证结果一致 -> 必须用定点数
状态同步不需要:只有服务器一处计算,结果天然唯一 -> 浮点数完全没问题,只有一份唯一结果广播给所有客户端


在Unity中实现状态同步

NetworkVariable — 自动状态同步

使用NetCode库中的 NetworkVariable,在数值变化时自动同步给所有客户端。

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
using Unity.Netcode;
using UnityEngine;

public class PlayerStats : NetworkBehaviour
{
// 服务器修改,自动同步到所有客户端
public NetworkVariable<int> Health = new(100);
public NetworkVariable<Vector3> Position = new(Vector3.zero);

public override void OnNetworkSpawn()
{
// 监听变化
Health.OnValueChanged += OnHealthChanged;
}

void OnHealthChanged(int oldVal, int newVal)
{
// 客户端收到变化后更新 UI
UIManager.UpdateHealthBar(newVal);
if (newVal <= 0) PlayDeathEffect();
}

// 只有服务器能修改
public void TakeDamage(int damage)
{
if (!IsServer) return;
Health.Value = Mathf.Max(0, Health.Value - damage);
}
}

插值 (Interpolation) — 让移动平滑

客户端缓存服务器状态,在两个快照之间做插值,消除网络抖动。

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
public class InterpolatedTransform : NetworkBehaviour
{
private struct TransformSnapshot
{
public float time;
public Vector3 position;
public Quaternion rotation;
}

private Queue<TransformSnapshot> _buffer = new();
private const float BufferDelay = 0.1f; // 100ms 缓冲

// 服务器广播位置
[ClientRpc]
public void UpdateTransformClientRpc(Vector3 pos, Quaternion rot)
{
if (IsOwner) return; // 本地玩家不需要插值
_buffer.Enqueue(new TransformSnapshot
{
time = Time.time,
position = pos,
rotation = rot
});
}

void Update()
{
if (IsOwner || _buffer.Count < 2) return;

float renderTime = Time.time - BufferDelay;

// 找到插值区间
TransformSnapshot from = _buffer.Peek();
TransformSnapshot to = default;

foreach (var snap in _buffer)
{
if (snap.time <= renderTime) from = snap;
else { to = snap; break; }
}

if (to.time == 0) return;

float t = Mathf.InverseLerp(from.time, to.time, renderTime);
transform.position = Vector3.Lerp(from.position, to.position, t);
transform.rotation = Quaternion.Slerp(from.rotation, to.rotation, t);
}
}

客户端预测 (Client Prediction) — 消除操作延迟感

本地玩家立即响应输入,同时发送给服务器,收到服务器校正后平滑修正。

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
public class ClientPrediction : NetworkBehaviour
{
private Rigidbody _rb;
private Vector3 _serverPosition;
private bool _hasCorrection;

void Update()
{
if (!IsOwner) return;

// 1. 本地立即执行输入(预测)
Vector3 input = new Vector3(
Input.GetAxisRaw("Horizontal"), 0,
Input.GetAxisRaw("Vertical")
);
_rb.MovePosition(transform.position + input * 5f * Time.deltaTime);

// 2. 发送输入到服务器
MoveServerRpc(input);

// 3. 收到服务器校正后修正
if (_hasCorrection)
{
float error = Vector3.Distance(transform.position, _serverPosition);
transform.position = error > 1f
? _serverPosition // 误差大:强制修正
: Vector3.Lerp(transform.position, _serverPosition, 0.2f); // 误差小:平滑修正
_hasCorrection = false;
}
}

[ServerRpc]
void MoveServerRpc(Vector3 input)
{
// 服务器执行权威逻辑
_rb.MovePosition(transform.position + input * 5f * Time.deltaTime);
// 广播权威位置给该客户端
CorrectPositionClientRpc(transform.position);
}

[ClientRpc]
void CorrectPositionClientRpc(Vector3 authoritative)
{
if (!IsOwner) return;
_serverPosition = authoritative;
_hasCorrection = true;
}
}

RPC — 触发瞬时事件

RPC 不用于持续状态同步,专门处理”一次性事件”如特效、音效、技能触发。

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 CombatEffects : NetworkBehaviour
{
[SerializeField] private ParticleSystem _hitEffect;
[SerializeField] private AudioClip _hitSound;

// 服务器判定命中后,通知所有客户端播放特效
public void OnHit(Vector3 hitPoint)
{
if (!IsServer) return;
PlayHitEffectClientRpc(hitPoint);
}

[ClientRpc]
void PlayHitEffectClientRpc(Vector3 hitPoint)
{
_hitEffect.transform.position = hitPoint;
_hitEffect.Play();
AudioSource.PlayClipAtPoint(_hitSound, hitPoint);
}

// 客户端请求服务器执行技能
[ServerRpc(RequireOwnership = true)]
public void CastSkillServerRpc(int skillId, Vector3 targetPos)
{
// 服务器验证并执行
SkillManager.Execute(skillId, targetPos, OwnerClientId);
}
}

RPC特性

[ClientRpc] 是 C# 的 Attribute(特性) 语法,是 Unity Netcode for GameObjects (NGO) 框架提供的标记。

标记特性
1
2
[AttributeName]          // 标记在类、方法、字段上
public void MyMethod() { }

本质是一种元数据标签,告诉框架”这个方法有特殊行为”。

NGO 中的 RPC 特性
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 MyNetworkObject : NetworkBehaviour
{
// [ClientRpc] = 服务器调用,在所有客户端执行
// 方法名必须以 ClientRpc 结尾
[ClientRpc]
public void PlayEffectClientRpc(Vector3 pos)
{
// 这段代码在每个客户端上运行
Instantiate(effectPrefab, pos, Quaternion.identity);
}

// [ServerRpc] = 客户端调用,在服务器执行
// 方法名必须以 ServerRpc 结尾
[ServerRpc]
public void RequestFireServerRpc(Vector3 direction)
{
// 这段代码只在服务器上运行
SpawnBullet(direction);
}
}

// 调用方式
void Update()
{
if (IsServer)
PlayEffectClientRpc(transform.position); // 服务器调用,广播到所有客户端

if (IsOwner)
RequestFireServerRpc(aimDirection); // 客户端调用,发送到服务器
}

NGO 2.0 之后推荐统一用 [Rpc]:

NGO 2.0 新写法(Rpc Attribute)
1
2
3
4
5
6
7
8
9
10
11
12
13
// 新写法
[Rpc(SendTo.ClientsAndHost)]
public void PlayEffectRpc(Vector3 pos) { }

[Rpc(SendTo.Server)]
public void RequestFireRpc(Vector3 dir) { }

// 旧写法(仍然支持)
[ClientRpc]
public void PlayEffectClientRpc(Vector3 pos) { }

[ServerRpc]
public void RequestFireServerRpc(Vector3 dir) { }

[ClientRpc] → 方法名必须以 ClientRpc 结尾,否则报错
[ServerRpc] → 方法名必须以 ServerRpc 结尾,否则报错

差量同步 (Delta Compression)

核心思想:只发送”变化的部分”,不发送没变的数据。

最简单的实现:字段级脏标记

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
[System.Flags]
public enum PlayerDirtyFlags : uint
{
None = 0,
Position = 1 << 0,
Rotation = 1 << 1,
Health = 1 << 2,
Mana = 1 << 3,
Animation = 1 << 4,
}

public class PlayerState
{
public Vector3 Position;
public float Rotation;
public int Health;
public int Mana;
public int AnimationId;

private PlayerState _lastSent = new(); // 上次发送的快照
private PlayerDirtyFlags _dirty;

// 修改字段时标记脏
public void SetPosition(Vector3 pos)
{
if (Position == pos) return;
Position = pos;
_dirty |= PlayerDirtyFlags.Position;
}

public void SetHealth(int hp)
{
if (Health == hp) return;
Health = hp;
_dirty |= PlayerDirtyFlags.Health;
}

// 序列化:只写入脏字段
public byte[] SerializeDelta()
{
using var writer = new BinaryWriter(new MemoryStream());

writer.Write((uint)_dirty); // 先写标记位

if (_dirty.HasFlag(PlayerDirtyFlags.Position))
{
writer.Write(Position.x);
writer.Write(Position.y);
writer.Write(Position.z);
}
if (_dirty.HasFlag(PlayerDirtyFlags.Health))
writer.Write(Health);
if (_dirty.HasFlag(PlayerDirtyFlags.Mana))
writer.Write(Mana);
// ... 其他字段

_dirty = PlayerDirtyFlags.None; // 清除脏标记
return ((MemoryStream)writer.BaseStream).ToArray();
}

// 反序列化:根据标记位读取对应字段
public void DeserializeDelta(byte[] data)
{
using var reader = new BinaryReader(new MemoryStream(data));

var flags = (PlayerDirtyFlags)reader.ReadUInt32();

if (flags.HasFlag(PlayerDirtyFlags.Position))
{
Position = new Vector3(
reader.ReadSingle(),
reader.ReadSingle(),
reader.ReadSingle()
);
}
if (flags.HasFlag(PlayerDirtyFlags.Health))
Health = reader.ReadInt32();
if (flags.HasFlag(PlayerDirtyFlags.Mana))
Mana = reader.ReadInt32();
}
}

快照对比法(适合复杂状态)

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
public class SnapshotDelta
{
// 完整快照
public struct WorldSnapshot
{
public Dictionary<int, PlayerSnapshot> Players;
public int Tick;
}

public struct PlayerSnapshot
{
public Vector3 Position;
public int Health;
public int Score;
}

private WorldSnapshot _baseline; // 客户端已确认的基准快照

// 服务器:对比两个快照,生成差量包
public byte[] GenerateDelta(WorldSnapshot from, WorldSnapshot to)
{
using var writer = new BinaryWriter(new MemoryStream());

writer.Write(to.Tick);

foreach (var (id, toPlayer) in to.Players)
{
// 新增玩家
if (!from.Players.TryGetValue(id, out var fromPlayer))
{
writer.Write((byte)DeltaOp.Add);
writer.Write(id);
WriteFullPlayer(writer, toPlayer);
continue;
}

// 对比变化
PlayerDirtyFlags dirty = PlayerDirtyFlags.None;
if (fromPlayer.Position != toPlayer.Position) dirty |= PlayerDirtyFlags.Position;
if (fromPlayer.Health != toPlayer.Health) dirty |= PlayerDirtyFlags.Health;
if (fromPlayer.Score != toPlayer.Score) dirty |= PlayerDirtyFlags.Score;

if (dirty != PlayerDirtyFlags.None)
{
writer.Write((byte)DeltaOp.Update);
writer.Write(id);
writer.Write((uint)dirty);
if (dirty.HasFlag(PlayerDirtyFlags.Position)) WriteVector3(writer, toPlayer.Position);
if (dirty.HasFlag(PlayerDirtyFlags.Health)) writer.Write(toPlayer.Health);
}
}

// 离开的玩家
foreach (var id in from.Players.Keys)
{
if (!to.Players.ContainsKey(id))
{
writer.Write((byte)DeltaOp.Remove);
writer.Write(id);
}
}

return ((MemoryStream)writer.BaseStream).ToArray();
}

enum DeltaOp : byte { Add, Update, Remove }
}

位置压缩(进一步降低带宽)

位置数据是最频繁变化的,可以额外压缩:

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 static class PositionCompressor
{
// 全量:Vector3 = 12字节
// 压缩后:只发变化量,用short存,约4-6字节

private const float Precision = 0.01f; // 1cm精度
private const float MaxDelta = 32767 * Precision; // short最大值对应的距离

// 发送位置差值而不是绝对值
public static short[] CompressDelta(Vector3 from, Vector3 to)
{
Vector3 delta = to - from;
return new short[]
{
(short)(delta.x / Precision),
(short)(delta.y / Precision),
(short)(delta.z / Precision)
};
// 3个short = 6字节,比12字节少一半
}

public static Vector3 DecompressDelta(Vector3 baseline, short[] delta)
{
return baseline + new Vector3(
delta[0] * Precision,
delta[1] * Precision,
delta[2] * Precision
);
}
}

带宽对比

1
2
3
4
5
6
7
8
100个玩家,每人10个字段,20次/秒广播:

全量同步:
100 × 10字段 × 4字节 × 20次 = 800 KB/s

差量同步(假设每帧平均20%字段变化):
4字节标记位 + 变化字段
100 × (4 + 2字段×4字节) × 20次 ≈ 176 KB/s → 节省约78%

在实际游戏中,应该根据不同的数据类型需要使用不同的差量压缩机制:

  • 移动中的玩家:位置每帧变,用压缩差值
  • 静止玩家:位置不变,完全不发
  • 血量/状态:只在变化时发一次,不需要每帧发
  • 大地图场景:加上 AOI(Area of Interest),只同步视野范围内的玩家,带宽再降一个数量级

核心流程总结

1
2
3
4
5
6
7
客户端输入
↓ ServerRpc
服务器执行权威逻辑
↓ NetworkVariable 自动同步 / ClientRpc 触发事件
所有客户端收到状态

插值平滑表现 + 本地预测修正

NetworkVariable 管持续状态,RPC 管瞬时事件,预测+插值管体验,三者配合就是状态同步在 Unity 里的完整实现。

HTTP

The undelying network protocol for communication on the web. It defines methods like GET, POST, PUT, and DELETE.
When you visit a website, your browser sends HTTP requests and the server sends back HTTP responses.

HTTP 消息是客户端和服务器之间通信的基础,它们由一系列的文本行组成,遵循特定的格式和结构。
HTTP消息分为两种类型:请求消息和响应消息。
一个 HTTP 客户端是一个应用程序(Web 浏览器或其他任何客户端),通过连接到服务器达到向服务器发送一个或多个 HTTP 的请求的目的。
一个 HTTP 服务器 同样也是一个应用程序(通常是一个 Web 服务,如 Nginx、Apache 服务器或 IIS 服务器等),通过接收客户端的请求并向客户端发送 HTTP 响应数据。

Client Side Request

请求行(Request Line):

  • 方法:如 GET、POST、PUT、DELETE等,指定要执行的操作。
  • 请求 URI(统一资源标识符):请求的资源路径,通常包括主机名、端口号(如果非默认)、路径和查询字符串。
  • HTTP 版本:如 HTTP/1.1 或 HTTP/2。
    请求行的格式示例:GET /index.html HTTP/1.1

请求头(Request Headers):

  • 包含了客户端环境信息、请求体的大小(如果有)、客户端支持的压缩类型等。
  • 常见的请求头包括Host、User-Agent、Accept、Accept-Encoding、Content-Length等。

空行:

请求头和请求体之间的分隔符,表示请求头的结束。

请求体(可选):

在某些类型的HTTP请求(如 POST 和 PUT)中,请求体包含要发送给服务器的数据。

Server Side Response

状态行(Status Line):

  • HTTP 版本:与请求消息中的版本相匹配。
  • 状态码:三位数,表示请求的处理结果,如 200 表示成功,404 表示未找到资源。
  • 状态信息:状态码的简短描述。
    状态行的格式示例:HTTP/1.1 200 OK

响应头(Response Headers):

  • 包含了服务器环境信息、响应体的大小、服务器支持的压缩类型等。
  • 常见的响应头包括Content-Type、Content-Length、Server、Set-Cookie等。

空行:

响应头和响应体之间的分隔符,表示响应头的结束。

响应体(可选):

包含服务器返回的数据,如请求的网页内容、图片、JSON数据等。

FormData

  • multipart/form-data is an encoding type (media or content type) used in HTTP requests to send data to a server, primarily for forms that include file uploads.
  • In HTML, you specify this encoding by setting the enctype attribute of the <form> tag to multipart/form-data when the method is POST. This is mandatory if your form includes an <input type=”file”> element.
1
2
3
4
5
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="text" name="username" />
<input type="file" name="profile_picture" />
<button type="submit">Upload</button>
</form>
  • When using modern web APIs like the Fetch API or XMLHttpRequest in JavaScript, the browser’s FormData object automatically handles the complex process of structuring the request in the multipart/form-data format.
  • WWWForm is a Unity class used to create a standard web form. It structures data into the multipart/form-data or x-www-form-urlencoded format, which is a standard format for submitting form data, including file uploads, to web servers.

REST API

  • A set of architectural constraints (like statelessness and uniform interface) that define how to build scalable and standardized web services, primarily using HTTP.
  • Guidelines for designing a server’s endpoints in a logical, resource-oriented way, specifically origanize resources in URIs like https://exmaple.com/api/v3/users
  • For example, a REST API would use GET /users to retrieve users, POST /users to create a new user, and leverage standard HTTP status codes.

Fetch API

  • A modern, promise-based JavaScript interface built into web browsers that allows developers to make HTTP requests programmatically.
  • Client-side tool used to consume a service that might be RESTful (or any other kind of HTTP API).
  • You use the Fetch API in JavaScript in your web browser or a server environment like Node.js to send the HTTP requests defined by the API’s design.

In summary, you use the Fetch API to send HTTP requests to a server that is structured as a REST API.

XHR

XML Http Request (XHR) is a JavaScript API to create HTTP requests. Its methods provide the ability to send network requests between the browser and a server. The Fetch API is the modern replacement for XMLHttpRequest


ASP.NET

Microsoft’s open-source framework for building web applications and and services using .NET and C#; it is fundamentally built on top of the HTTP protocol.

  • HTTP/2 & HTTP/3: Modern versions of ASP.NET Core support HTTP/2 and HTTP/3 for improved performance.
  • Status Codes: The framework provides built-in methods to return standard HTTP status codes, such as Ok() (200), CreatedAtAction() (201), or NotFound() (404).

Handling Incoming HTTP Requests (Server-Side)

  • HTTP Servers: ASP.NET Core uses Kestrel, a cross-platform HTTP server, as the default to listen for requests.
  • HttpContext: Every request is encapsulated in an HttpContext object, which provides access to the Request (headers, body, query strings) and the Response.
  • Routing and HTTP Methods: Controllers use attributes like [HttpGet], [HttpPost], and [HttpPut] to map specific HTTP verbs to C# methods.
Creating an HTTP Endpoint
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Simple GET endpoint returning a string
app.MapGet("/", () => "Hello World!");

// GET endpoint with a route parameter
app.MapGet("/products/{id}", (int id) => $"Returning product {id}");

// POST endpoint that accepts a JSON object (Todo item)
app.MapPost("/todoitems", (Todo todo) =>
Results.Created($"/todoitems/{todo.Id}", todo));

app.Run();

// Data model for the POST example
public record Todo(int Id, string Name, bool IsComplete);

Making Outgoing Requests (Client-Side)

To consume other web services or APIs from within your ASP.NET application, you use the HttpClient class.

Controller or Page Model
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
public class MyService
{
private readonly IHttpClientFactory _httpClientFactory;

public MyService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}

public async Task<string> GetExternalDataAsync()
{
// 1. Create the client
var client = _httpClientFactory.CreateClient();

// 2. Make the GET request
var response = await client.GetAsync("https://api.example.com");

// 3. Ensure success and read content
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync();
}

return "Error fetching data";
}
}

Credits

https://www.runoob.com/http/http-messages.html


synchronized:在需要同步的对象中加入此控制,synchronized可以加在方法上,也可以加在特定代码块中,括号中表示需要锁的对象。
lock:一般使用ReentrantLock类做为锁。在加锁和解锁处需要通过lock()和unlock()显示指出。所以一般会在finally块中写unlock()以防死锁。

  1. ReentrantLock支持非阻塞的方式获取锁,能够响应中断,而synchronized不行
  2. ReentrantLock必须手动获取和释放锁,而synchronized不需要
  3. ReentrantLock可以是公平锁或者非公平锁,而synchronized只能是非公平锁
    • 公平锁 (Fair Lock):线程申请锁的顺序与获取锁的顺序完全一致,FIFO (First Input First Output)。先排队的线程先获得锁。new ReentrantLock(true)。其内部不仅看锁状态,还会判断 hasQueuedPredecessors(),即等待队列中是否有前驱线程,有则不抢。
    • 非公平锁 (Non-Fair Lock):不保证顺序。多个线程尝试获取锁时,可能新到的线程直接加塞抢到锁,而不按排队顺序。new ReentrantLock(false)(默认)。调用 lock() 时直接通过 CAS 尝试获取锁,抢不到再进入队列。
  4. ReentrantLock在发生异常时,如果没有通过unlock去释放锁,很有可能造成死锁,因此需要在finally块中释放锁
    • 而synchronized在发生异常的时候,会自动释放线程占有的锁
  5. synchronized和ReentrantLock都是可重入锁(某个线程已经获得某个锁,可以再次获取锁而不会出现死锁,即re-entrant)

为什么要使用非公平锁?

  • 公平锁维护等待队列增加上下文切换,性能较慢;非公平锁利用 CAS 抢锁,在高并发场景下提升性能效率高。
    • 当获取公平锁时,先将线程自己添加到等待队列的队尾并休眠,当某线程用完锁之后,会去唤醒等待队列中队首的线程尝试去获取锁,锁的使用顺序也就是队列中的先后顺序,在整个过程中,线程会从运行状态切换到休眠状态,再从休眠状态恢复成运行状态,但线程每次休眠和恢复都需要从用户态转换成内核态,而这个状态的转换是比较慢的,所以公平锁的执行速度会比较慢。
    • 当获取非公平锁时,会先通过 CAS 尝试获取锁,如果获取成功就直接拥有锁,如果获取锁失败才会进入等待队列,等待下次尝试获取锁。这样做的好处是,获取锁不用遵循先到先得的规则,从而避免了线程休眠和恢复的操作
  • 减少线程切换:被唤醒的线程还需要排队等待,不如让当前运行的线程(持有 CPU)直接抢,能更快完成任务。
  • 提高吞吐量:即使有先来后到,非公平锁利用空闲时间直接抢锁,能让整体效率更高。
  • 公平锁适用于需要精确控制执行顺序的业务,如金融转账、需要严格时间戳的日志记录。

Synchronized

synchronized能够保证多个线程在同时执行被synchronized包裹的同一代码块时,有且仅有一个线程能执行相应的代码操作,而其他的线程会被阻塞等待。

synchronized 关键字包裹不同的目标
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 SyncTest {
private Integer p = 0;

// synchronized 修饰整个方法
// 当多个线程同时调用该方法,只有一个能打印出 “准备获得属性p” 并返回p,之后别的线程再去竞争方法的使用权。
private synchronized int getP(){
System.out.println("准备获得属性p");
return p;
}

// synchronized 包裹属性p
// 当多个线程同时调用该方法时,会一起显示 ”准备属性p加一“ ,但同一时间段只有一个线程能够执行该操作。
private void pAndOne(){
System.out.println("准备属性p加一");
synchronized (this.p){
this.p +=1;
}
}

// synchronized 包裹对象本身
// 只要有一个线程执行根据对象本身同步的代码块,那么这部分代码块以及pAndOne的同步代码块,别的线程都是不能执行的。
private void reset(){
System.out.println("重置属性p");
synchronized (this){
this.p = 0;
}
}
}

实现原理

JAVA 虚拟机类加载机制和字节码执行引擎会在类和方法上添加访问标志这一块内容,用来标记类是否是静态是否是public,方法是否是public等等。

对于方法的同步,是通过方法的访问标志 ACC_SYNCHRONIZED 来控制的,即执行指定方法前会通过访问标志来判断是否需要和其他线程同步。
而对于针对对象的同步,则是通过字节指令来实现的,即先引入对象引用到当前栈,使用 monitorenter 字节指令告诉虚拟机该引用需要同步, monitorexist 字节指令表示退出。

lock(CAS)

synchronized会一直获取执行权限直到执行完毕,即每个线程在执行相关代码块时都要与其他线程同步确认是否可以执行代码,容易影响性能。

lock可以帮我们实现尝试立刻获取锁,在指定时间内尝试获取锁,一直获取锁等操作,而semaphore信号量可以帮我们实现允许最多指定数量的线程获取锁。

Unsafe

unsafe中的方法能够帮我们获取到一个对象的属性位于对象开始位置的相对距离,也就是说对象属性所在的地址与对象起始地址的差值。同时,还能获取一个对象指定相对距离后的数据,例如long,int,byte等等。最重要的是可以给一个特定的地址设置上数据。

Compare And Swap

在unsafe类中,支持这样一个方法,compareAndSwapInt。它的含义是给一个对象var1(开始位置+指定长度var2)的地址写入一个int值var4,如果这个地方原来的值是var5。成功返回true,不成功返回false。

这个操作是本地方法调用,而具体一点,这个方法会直接调用cpu的compare_and_swap指令,这个指令是原子性的,即操作内存中一个地址上的值不会被中断。而且多核cpu间都是可见的。

借由这样的一个本地方法调用,jdk实现了一系列轻量级的非阻塞锁以及相关应用,例如ReentrantLock,Semaphore,ConcurrentHashMap,AtomicInteger等等。

性能问题

  • 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
  • 一个线程持有锁会导致其它所有需要此锁的线程挂起。
  • 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。
  • volatile是不错的机制,但是volatile不能保证原子性。因此对于同步最终还是要回到锁机制上来。
    • Java 的 volatile 关键字是轻量级的同步机制,当一个线程修改了 volatile 修饰的变量,新值会立即刷新到主内存,当其他线程读取该变量时,会强制从主内存重新获取,确保读到最新值。
    • volatile 无法保证复合操作的原子性。例如 i++ 实际上包含“读取-修改-写入”三个步骤,使用 volatile 仍可能在多线程环境下产生冲突

实现原理

CAS是解决多线程并行情况下使用锁造成性能损耗的一种机制。

  • CAS操作包含三个操作数——内存位置(V)、预期原值(A)、新值(B)。
  • 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。
  • 无论哪种情况,它都会在CAS指令之前返回该位置的值。
  • CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。

假设内存中的原数据V,旧的预期值A,需要修改的新值B

  • 比较 A 与 V 是否相等
  • 如果比较相等,将 B 写入 V
  • 返回操作是否成功

具体算法

  • 在对变量进行计算之前(如 ++ 操作),首先读取原变量值,称为 旧的预期值 A
  • 然后在更新之前再获取当前内存中的值,称为 当前内存值 V
    • 如果 A==V 则说明变量从未被其他线程修改过,此时将会写入新值 B
    • 如果 A!=V 则说明变量已经被其他线程修改过,当前线程应当什么也不做。

Java CAS使用示例

使用 AtomicInteger 实现无锁计数器
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
import java.util.concurrent.atomic.AtomicInteger;

public class CasExample {
// 1. 初始化原子整型变量
private static AtomicInteger counter = new AtomicInteger(0);

public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
// 自旋CAS操作,直到更新成功
while (true) {
int oldValue = counter.get(); // 预期值E
int newValue = oldValue + 1; // 新值N

// 2. 比较并交换:如果当前值等于oldValue,则赋值为newValue
if (counter.compareAndSet(oldValue, newValue)) {
break; // 成功则退出循环
}
// 失败说明被其他线程修改,自动下一次循环获取最新值再试
}
}
};

Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();

System.out.println("最终计数器结果: " + counter.get()); // 预期为 2000
}
}

使用 CAS 需要注意的问题

  • ABA问题
    因为CAS需要在操作值的时候,检查值有没有发生变化,没有发生变化才去更新。
    但是如果一个值原来是A变成了B,又变成了A,CAS检查会判断该值未发生变化,实际却变化了。
    解决思路:增加版本号,每次变量更新时把版本号+1,A-B-A就变成了1A-2B-3A。JDK5之后的atomic包提供了AtomicStampedReference来解决ABA问题,它的compareAndSet方法会首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志。全部相等,才会以原子方式将该引用、该标志的值设置为更新值。

  • 时间长、开销大
    自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。

  • 只能保证一个共享变量的原子操作
    对一个共享变量执行操作时,可以循环CAS方式确保原子操作。
    但是对多个共享变量,就不灵了。
    这里可以使用锁,或把多个共享变量合并为1个共享变量,如i=2,j=a,合并为ij=2a。然后用CAS操作ij。在JDK5后,提供了AtomicReference类来保证对象间的原子性,可以把多个共享变量放在一个对象里进行CAS操作。

synchronized性能优化

synchronized是托管给 JVM 执行的, 而lock是java写的控制锁的代码。

在Java1.5中,synchronize是性能低效的。因为这是一个重量级操作,需要调用操作接口,导致有可能加锁消耗的系统时间比加锁以外的操作还多。相比之下使用Java提供的Lock对象,性能更高一些。

但从Java1.6开始。synchronize在语义上很清晰,可以进行很多优化,有适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在Java1.6上synchronize的性能并不比Lock差。官方也表示,他们也更支持synchronize,在未来的版本中还有优化余地。

机制区别

synchronized原始采用的是CPU悲观锁机制,即线程获得的是独占锁。独占锁意味着其他线程只能依靠阻塞来等待线程释放锁。而在CPU转换线程阻塞时会引起线程上下文切换,当有很多线程竞争锁的时候,会引起CPU频繁的上下文切换导致效率很低。

而Lock用的是乐观锁方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁实现的机制就是CAS操作(Compare and Swap )。我们可以进一步研究ReentrantLock的源代码,会发现其中比较重要的获得锁的一个方法是compareAndSetState。这里其实就是调用的CPU提供的特殊指令。

现代的CPU提供了指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet() 就用这些代替了锁定。这个算法称作非阻塞算法,意思是一个线程的失败或者挂起不应该影响其他线程的失败或挂起的算法。

悲观锁 synchronized

  • 假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。
  • 悲观锁的实现,往往依靠底层提供的锁机制。
  • 悲观锁会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。

乐观锁

  • 假设不会发生并发冲突,每次不加锁而是假设没有冲突而去完成某项操作,只在提交操作时检查是否违反数据完整性。
  • 如果因为冲突失败就重试,直到成功为止。
  • 乐观锁大多是基于数据版本记录机制实现。
  • 为数据增加一个版本标识,比如在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。
  • 此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。
  • 乐观锁的缺点是不能解决脏读的问题。
  • 在实际生产环境里边,如果并发量不大且不允许脏读,可以使用悲观锁解决并发问题。
  • 如果系统的并发非常大的话,悲观锁定会带来非常大的性能问题,所以我们就要选择乐观锁定的方法。

用途区别

在非常复杂的同步应用中,请考虑使用ReentrantLock,特别是遇到下面几种种需求的时候。

1.某个线程在等待一个锁的控制权的这段时间需要中断
2.需要分开处理一些wait-notify,ReentrantLock里面的Condition应用,能够控制notify哪个线程
3.具有公平锁功能,每个到来的线程都将排队等候


Credits

https://cloud.tencent.com/developer/article/1622173
https://blog.csdn.net/qq_27828675/article/details/115372519
https://cloud.tencent.com/developer/article/1997322


进程:一个程序一个进程。
进程是计算机中正在运行的程序实例的抽象,它是资源分配的基本单位。每个进程都有自己独立的地址空间,包括代码段、数据段、堆栈段等,这意味着不同进程之间的内存资源是相互隔离的,不会相互干扰。例如,当你同时打开一个文本编辑器和一个浏览器,它们就分别是两个独立的进程,文本编辑器进程无法直接访问浏览器进程所占用的内存区域。

线程:共享堆资源,利用多核处理器可以实现并行处理。
线程是进程内部的一条执行路径,是 CPU 调度和执行的基本单位。一个进程可以包含多个线程,这些线程共享进程的地址空间(包括代码段、数据段、堆等)以及系统资源(如打开的文件、网络连接等),但每个线程都有自己独立的栈空间用于保存局部变量、函数调用的上下文等信息。比如在一个文字处理软件进程中,可能有一个线程负责接收用户的键盘输入,另一个线程负责实时进行拼写检查,它们都在同一个进程的环境下协同工作。

协程:用户态,单线程中实现并发,程序控制执行时机,I/O操作不用CPU。
协程是一种比线程更加轻量级的存在,它可以看作是用户态的轻量级线程,是在单线程内实现的并发机制。协程不像线程那样由操作系统内核进行调度,而是由程序员自行控制或者由编程语言提供的特定库来进行调度切换。例如在 Python 语言中,通过 asyncio 库就能方便地定义和调度协程。协程在执行过程中可以主动暂停(yield),将执行权交给其他协程,之后又可以在合适的时候恢复执行。


调度方式

  • 进程:由操作系统内核进行调度,切换时需要保存和恢复所有的CPU状态和内存空间。
  • 线程:同样由操作系统进行调度,但由于线程共享进程的内存空间,切换时只需保存和恢复CPU寄存器和栈指针。
  • 协程:由程序员在用户态显式调度,无需操作系统参与,切换时只需保存和恢复少量上下文信息。

资源消耗

  • 进程:创建和销毁进程需要较多的资源,尤其是内存和CPU时间。
  • 线程:创建和销毁线程比进程轻量,但仍然需要一定的资源。
  • 协程:由于在用户态执行,创建和销毁协程非常轻量,对系统资源的消耗最小。

隔离性

  • 进程:完全隔离,进程之间的内存空间独立,安全性高。
  • 线程:共享进程的内存空间,不同线程可以直接访问共享数据,隔离性差。
  • 协程:在同一线程内执行,协程之间共享内存空间。

通信方式

进程间通信(IPC)是指不同进程之间交换数据或信号的机制,常见的 IPC 方法包括:

  • 管道(Pipe):用于单向或双向数据流,常用于父子进程之间的通信。
  • 消息队列(Message Queue):允许进程通过消息传递进行通信,消息按照一定的顺序排队。
  • 共享内存(Shared Memory):多个进程共享同一段内存,速度快,但需要同步机制来避免竞争条件。
  • 信号量(Semaphore):用于进程间的同步,控制多个进程对共享资源的访问。
  • 信号(Signal):用于异步通知进程某个事件的发生。
  • 套接字(Socket):通常用于网络通信,也可以用于同一主机上进程之间的通信。

线程间通信由于共享同一进程的内存空间(堆资源),主要依赖同步机制来管理共享数据的访问:

  • 共享变量:线程可以直接通过共享变量进行通信,但需要同步机制来避免竞争条件。
  • 互斥锁(Mutex):用于保护共享资源,确保同一时刻只有一个线程可以访问。
  • 条件变量(Condition Variable):用于线程之间的等待和通知机制,线程可以等待某个条件的变化。
  • 信号量(Semaphore):用于控制线程对共享资源的访问,特别适用于限制资源数量的场景。
  • 事件(Event):用于线程间的信号传递,线程可以等待事件的发生。

协程之间的通信通常是通过共享数据结构或消息传递机制来实现的,具体方法包括:

  • 共享变量:协程在同一线程内,可以直接访问共享变量,但仍需小心数据一致性问题。
  • 消息传递:许多编程语言提供了内置的消息传递机制,如通道(Channel)或队列(Queue),用于协程之间的通信。
  • 异步回调:协程常用于异步编程,回调机制可以用于协程之间的通信。
  • 未来(Future)和承诺(Promise):用于在协程之间传递异步计算的结果。

适用场景

  • 进程:适用于需要高隔离性和安全性、任务相对独立的场景,如多用户系统、独立的服务模块。进程间通信通常较复杂,需要权衡性能和隔离性。
  • 线程:适用于需要高并发和共享资源的场景,如Web服务器、数据库系统。需要关注线程安全和同步问题,以避免死锁和竞争条件。
  • 协程:适用于大规模并发、IO密集型操作,尤其是在异步编程中,如异步网络请求、实时数据处理。协程的轻量级特性使其在处理大量并发操作时非常高效,但协程的调度和错误处理需要仔细设计。

优缺点对比

进程是资源分配最小单位,独立稳定但开销大;线程是系统调度最小单位(操作系统分割成的时间片),共享资源但安全复杂;协程是用户级的轻量并发编程,切换效率极高,适合高并发I/O,但统一线程下的多个协程不能利用多核并行处理。

进程的优点

  • 隔离性和稳定性:每个进程拥有独立的地址空间,这意味着它们之间的内存是隔离的。这种隔离性提高了系统的稳定性,因为一个进程的崩溃不会直接影响其他进程。
  • 安全性:由于进程之间的资源是隔离的,这为应用程序提供了更高的安全性,防止一个进程无意中修改另一个进程的数据。
  • 容错性:如果某个进程失败,不会影响其他进程的运行。操作系统可以通过重启进程来恢复服务。

进程的缺点

  • 资源消耗大:进程的创建和销毁需要分配和回收大量的资源,包括内存和文件句柄。进程的上下文切换也比线程开销更大,因为需要切换独立的地址空间。
  • 通信复杂:由于进程之间的内存是隔离的,进程间通信(IPC)需要使用复杂的机制,如管道、消息队列、共享内存等,这增加了编程的复杂性。
  • 启动速度慢:启动一个新进程比启动一个新线程需要更多的时间,因为需要为进程分配独立的资源。

线程的优点

  • 轻量级:线程是比进程更轻量级的执行单位,创建和销毁线程的开销相对较小。线程的上下文切换比进程更快,因为线程共享进程的内存空间。
  • 共享资源:线程可以共享进程的内存和资源,这使得线程之间的数据交换更加直接和高效。
  • 并发性:线程可以在多核处理器上实现真正的并行执行(多对多模型),充分利用多核系统的优势,提高程序的执行效率。

线程的缺点

  • 安全性和稳定性:由于线程共享进程的地址空间,一个线程的错误(如非法内存访问)可能会影响整个进程的稳定性。
  • 同步复杂性:线程之间共享数据,需要使用同步机制(如互斥锁、条件变量)来避免竞争条件和死锁,这增加了编程的复杂性。
  • 调试困难:多线程程序的调试比单线程程序复杂得多,因为线程的调度和切换往往是不确定的,可能导致难以重现的错误。

协程的优点

  • 极低的切换开销:协程在用户态执行,切换时只需保存和恢复少量上下文信息,比线程和进程切换都要快得多。
  • 简单的并发模型:协程通过显式调用进行调度,程序员可以精确控制协程的执行顺序,避免了线程调度带来的不确定性。
  • 适合IO密集型任务:协程非常适合用于处理大量IO操作,因为它们可以在等待IO操作时主动让出控制权,从而提高系统的整体吞吐量。
  • 资源消耗小:协程是非常轻量级的,创建和销毁协程的开销极低。

协程的缺点

  • 不支持多核并行:大多数协程实现是在单线程上运行的,因此无法利用多核处理器进行并行计算。
  • 调度责任在程序员:协程的调度由程序员显式控制,这虽然提供了灵活性,但也意味着程序员需要负责协程的正确调度和资源管理。
  • 错误传播:在协程中,错误的传播和处理需要仔细设计,否则可能导致系统的不稳定。

超线程

  • 超线程技术通过硬件指令将单个物理核心模拟为多个逻辑核心
  • 硬件层面的功能,需要CPU支持
  • 操作系统无法直接区分物理核心与逻辑核心的差异,只能通过统计逻辑核心数来管理任务分配

MVC

  • Model:纯数据容器(如:PlayerState),不包含 UI 或游戏逻辑。
  • View:负责渲染数据(如:UI 文本、生命值条),通常直接监听(观察) Model 的变化。
  • Controller:处理用户输入(如:点击按钮)并修改 Model。

特点:View 和 Model 之间仍有直接关联(View 需要知道 Model 的存在以进行监听),这使得 View 难以独立测试。

MVP

  • Model:纯数据,不包含业务逻辑,不依赖其他层。
  • View:被动视图,不直接访问 Model。它只负责将用户操作传给 Presenter,并提供更新 UI 的公共接口。
  • Presenter:核心中间层。它监听 Model 的变化,格式化数据后手动调用 View 的接口更新 UI;同时监听 View 的事件来修改 Model。

特点:View 和 Model 完全解耦。由于 View 非常“薄”,你可以轻松编写 Presenter 的单元测试。

MVC 和 MVP 的区别

MVC(模型-视图-控制器)和MVP(模型-视图-表示器)的核心区别在于View与Model的解耦程度和通信方式。
MVC中View可直接访问Model,耦合度较高;而MVP通过接口将View与Model完全隔绝,所有交互由Presenter中转,使代码更易于测试和维护。

View 与 Model 的关系

  • MVC: View 可以直接读取 Model 数据(View 依赖 Model),View 中可能包含部分业务逻辑。
  • MVP: View 与 Model 彻底解耦。View 只能通过接口与 Presenter 通信,无法感知 Model。

交互逻辑

  • MVC: Controller 接收用户请求,更新 Model,View 监听 Model 变化更新界面(有时 C 直接更新 V)。
  • MVP: Presenter 作为中介,不仅处理 UI 事件,还负责从 Model 获取数据并通过接口更新 View。View 是被动接收渲染。

测试性

  • MVC: 较难进行单元测试,因为 UI 逻辑与数据逻辑存在耦合。
  • MVP: 极易测试,Presenter 逻辑与具体 UI 实现分离,可通过 Mock 接口进行纯逻辑测试。

MVVM

MVVM 是从 WPF 等前端技术引入的,核心在于 数据绑定 (Data Binding)。

  • Model(模型):纯数据,不包含业务逻辑,不依赖其他层。
  • View(视图):UI 组件,通过数据绑定 (Data Binding)直接映射 ViewModel 的属性。
  • ViewModel(视图模型):它是 View 的抽象。它将 Model 的数据转换为 View 可以直接使用的格式(如将 float 转换为 string),并通过属性变更通知(如 OnPropertyChanged)自动驱动 View 更新。

特点:双向/单向数据绑定,View 不知道 ViewModel 的存在
优点:自动化程度最高。Model 改变时,View 会通过绑定自动更新,无需像 MVP 那样手动写代码控制。只需“订阅”数据变化,实现了极高的解耦。

Unity 中的 UI-Model 设计架构

UGUI:以 MVP 为核心的实现

UGUI 本身不提供自动化的数据绑定,因此最契合的模式是 MVP (Model-View-Presenter)。通过 Presenter 手动协调数据与界面的同步。

  • Model: 纯 C# 类或 ScriptableObject。定义 Action 或 UnityEvent,在数据变化时发出通知。
  • View: 继承 MonoBehaviour 的 UI 脚本。包含对 Text、Slider 等组件的引用,并提供简单的公共方法(如 UpdateHealthBar(int value))供外部调用。
  • Presenter: 用于连接/同步 Model 和 View 的中间层。
    • 监听 Model: 订阅 Model 的事件,当数据改变时,调用 View 的更新方法。
    • 监听 UI: 接收来自 View 的 UI 事件(如 Button.onClick),处理逻辑后修改 Model。

UI Toolkit:以 MVVM 为核心的实现

UI Toolkit(尤其是 Unity 6 以后)原生支持 数据绑定 (Data Binding),这使得实现 MVVM 变得非常自然且高效。

  • Model: 基础数据结构,C#原生类型变量。
  • View: 使用 UXML(结构)和 USS(样式)文件定义。在 UI Builder 中,你可以直接通过 Attributes 检查器 将 UI 元素的属性(如 Label 的 Text)绑定到数据源路径。
  • ViewModel: 负责“翻译”数据。
    • 实现通知: 属性通常需要实现 INotifyPropertyChanged 接口或使用特定属性标签(如 [ObservableProperty]),以便在值变化时自动触发 UI 更新。
    • 命令绑定: 使用 ICommand 或 RelayCommand 绑定按钮点击等行为,无需在脚本中手动查找 VisualElement。
  • 代码解耦: View 和 ViewModel 之间不需要显式的引用关系,完全通过绑定路径 (Binding Path) 链接。


AVL树

  1. 空二叉树是一个 AVL 树
  2. 如果 T 是一棵 AVL 树,那么其左右子树也是 AVL 树,并且 |ℎ(𝑙𝑠) −ℎ(𝑟𝑠)| ≤1,h 是其左右子树的高度
  3. 树高为 𝑂(log ⁡𝑛)

平衡因子:右子树高度 - 左子树高度


红黑树

首先,红黑树是一个二叉搜索树,它在每个节点增加了一个存储位记录节点的颜色,可以是RED,也可以是BLACK
通过任意一条从根到叶子简单路径上颜色的约束,红黑树保证最长路径不超过最短路径的二倍,因而近似平衡(最短路径就是全黑节点,最长路径就是一个红节点一个黑节点,当从根节点到叶子节点的路径上黑色节点相同时,最长路径刚好是最短路径的两倍)

红黑树满足以下特性:

  1. 节点是红色或黑色
  2. 根是黑色
  3. 叶子节点(外部节点,空节点)都是黑色,这里的叶子节点指的是最底层的空节点(外部节点),即null节点才是叶子节点,null节点的父节点在红黑树里不将其看作叶子节点
  4. 红色节点的子节点都是黑色,红色节点的父节点都是黑色
  5. 从根节点到叶子节点的所有路径上不能有 2 个连续的红色节点
  6. 从任一节点到叶子节点的所有路径都包含相同数目的黑色节点

并查集

并查集是一种用于管理元素所属集合的数据结构,实现为一个森林,其中每棵树表示一个集合,树中的节点表示对应集合中的元素

并查集支持以下两种操作:

  • 合并(Unite):合并两个元素所属集合(合并对应的树)。
  • 查询(Find):查询某个元素所属集合(查询对应的树的根节点),这可以用于判断两个元素是否属于同一集合。

路经压缩

如果查询过程中经过的每个元素都属于该集合,我们可以将其直接连到根节点以加快后续查询。

1
2
3
// assignment expression 
// size_t a; size_t b = (a = 10);
size_t dsu::find(size_t x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }

优点

  1. 全局唯一实例:单例模式确保在整个应用程序中只有一个实例存在,可以提供一个全局的访问点,方便对实例的管理和调用。
  2. 节省资源:由于只有一个实例存在,可以避免重复创建实例,节省了系统资源。
  3. 避免竞态条件:在多线程环境下,使用单例模式可以避免由于竞态条件而导致的问题,如资源争夺、数据不一致等。
  4. 实现了懒加载:某些单例模式的实现方式(如懒汉式)在需要时才创建实例,实现了延迟加载,节省了内存空间。
  5. 继承monobehaviour的单例有unity生命周期函数和也可以调用协程,可以用来控制没有继承monobehaivour的脚本组件

缺点

  1. 可能引入全局状态:由于单例模式提供了全局访问点,可能会导致多个部分之间共享了同一个状态,增加了系统的耦合性。
  2. 可能造成性能瓶颈:在高并发环境下,单例模式的实现需要考虑线程安全性,可能会引入锁机制,导致性能下降。
  3. 隐藏了依赖关系:单例模式的使用会隐藏类的依赖关系,增加了代码的复杂性和理解难度。
  4. 不利于扩展和测试:单例模式一般是通过静态方法获取实例,难以进行扩展和替换,也不利于单元测试。
  5. 可能造成内存泄漏:如果实例被长时间持有而不释放,可能会造成内存泄漏,特别是在移动端或长时间运行的服务中。

实例化方法

懒汉式(Lazy Initialization):在第一次使用时创建实例。

1
2
3
4
5
6
7
8
9
10
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

饿汉式(Eager Initialization):在类加载时就创建实例。

1
2
3
4
5
6
7
public class Singleton {
private static final Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}

观察者模式

通过事件实现的观察者模式
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
using UnityEngine;
using System;

public class Publisher : MonoBehaviour
{
// 声明一个事件
public static event Action OnEventPublished;

void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
// 发布事件
OnEventPublished?.Invoke();
}
}
}

public class Subscriber : MonoBehaviour
{
private void OnEnable()
{
// 订阅事件
Publisher.OnEventPublished += HandleEvent;
}

private void OnDisable()
{
// 取消订阅事件
Publisher.OnEventPublished -= HandleEvent;
}

// 事件处理方法
private void HandleEvent()
{
Debug.Log("Event received!");
}
}

委托

委托是个类,分为Delegate自定义委托类型,Func有返回值的委托类型,Action无返回值的委托类型

Func和Action的底层原理便是用Delegate声明一个委托类型(有返回值和无返回值),并且通过泛型参数(最多十六个)来实现自定义参数类型和参数
其中,Func委托类型的最后一个参数为返回值

委托需要先定义后使用

1
delegate void IntMethodInvoker(int x);

如上定义了一个委托InMethodInvoker,这个委托可以指向一个 int类型参数,返回值为void 的方法

Action委托 和 Func委托

Action委托引用了一个void返回类型的方法,T表示方法参数

1
2
3
4
Action
Action<in T>
Action<in t1,in t2>
Action<in t1,in t2,···,t16>

Func引用了一个带有一个返回值的方法,它可以传递0或者多到16个参数类型,和一个返回值类型

1
2
3
Func<out TResult>
Func<in t,out TResult>
Function<in t1,in t2,···,in t16,out TResult>

多播委托

前面是用的委托都只包含一个方法调用,但是委托也可以包含多个方法,这种委托叫做多播委托。使用多播委托可以按照顺序调用多个方法,多播委托只能得到调用的最后一个方法结果,一般我们把多播委托的返回值类型声明为void。
多播委托包含一个逐个调用的委托集合,如果通过委托调用的其中一个方法抛出异常,整个迭代就会停止。

使用匿名方法给委托赋值

前面使用委托都是先定义一个方法,然后把方法给委托的实例。但还有另外一种使用委托的方式,不用去定义一个方法,直接使用匿名方法(lambda expression)

1
2
3
4
5
6
7
8
9
10
Func<int,int,int> plus = delegate(int a, int b)
{
int temp = a+b;
return temp;
}
int res = plus(34,34)
Console.WriteLine(res);

//上述代码可以换成下面一行
Func<int, int, int> plus = (a, b) => { return a + b; };

事件

用event关键词修饰的字段,由观察者拥有,一种类型成员,有能力使一个类或者对象去通知其他类、对象们

事件是基于委托的,委托是事件的“底层基础”,事件是委托的“上层建筑”
委托类型定义了事件的有无返回值和参数类型,事件处理器必须和事件的有无返回值和参数类型一致,即双方都要遵守同一个约定(有无返回值和参数类型),我们把这叫做事件和事件处理器必须是匹配的

自定义事件

先声明该事件的委托类型,再声明事件

  • 在声明委托类型的时候,如果这个委托,是为了声明某个事件而准备的委托,那么这个委托的名字,就要去使用:事件名+EventHandler的格式,由于委托是一种引用类型,所以事件名首字母要大写
  • 在定义事件参数的时候,即在定义该事件的委托类型的参数的类型的时候,要遵循:类型名+EventArgs这个格式
事件声明示例
1
2
3
4
5
// 声明该事件的委托类型:事件名 + EventHandler
public delegate void OrderEventHandler(Customer customer, OrderEventArgs e);//声明一个委托类型

// 声明事件:访问修饰符 + event + 事件处理器(委托类型的实例、字段)+ 事件名称(一定要注意命名规范:On+事件名)
public event OrderEventHandler OnOrder;

微软提供了一个EventHandler委托类型

其中用来传递事件数据的类EventArgs,凡是用来传递事件数据的类,都是从这个类派生出来的
让自定义的传递事件数据的类继承EventArgs,就可以作为参数传入EventHandler委托类型了
将Object类型的变量转换为Customer类型的变量,我们可以用as操作符

1
2
3
public delegate void EventHandler(object? sender, EventArgs e);

Customer customer1 = _sender as Customer;

事件和委托的区别

  • 事件其实是委托类型字段的包装器、限制器,限制外界对委托类型字段的访问。
  • 外界只能通过“+=”和“-=”两个操作符对事件进行添加事件处理器和移除事件处理器的操作,并不能去赋值和触发事件。
  • 事件是用来阻挡非法操作的“蒙版”,它绝对不是委托字段的本身
  • 类似的情况有字段和属性,属性是字段的包装器。字段能做的,属性都能做;属性能做的,字段不一定都能做

总结:事件是用来“阉割”委托实例的,事件只能添加、删除事件处理器,不能赋值。外界只能用“+=”和“-=”去访问它,不能=,不能从外部触发事件,也就是说,事件包含了委托类型字段的所有功能,但只是对外部暴露了“+=”和“-=”操作符。

事件和委托的关系

委托类型规定了事件拥有者和事件响应者通知和接收的消息必须是同一类型的消息
约束了添加和移除事件时必须要使用与之匹配(同样类型)的事件处理器,即使用与之匹配(同样类型)的方法来处理响应这个事件


完整示例(顾客点单)
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
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
/// <summary>
/// 事件的拥有者:顾客
/// 事件:点单
/// 事件的响应者:服务员
/// 事件处理器:计算最后金额
/// 事件订阅(+=操作符)
/// </summary>
namespace EventDemo
{
//声明一个委托类型
public delegate void OrderEventHandler(Customer customer, OrderEventArgs e);

class EventExample
{
public static Customer customer = new Customer();
public static Customer customer2 = new Customer();
public static Waiter waiter = new Waiter();
static void Main()
{
customer.OnOrder += waiter.CalculateBill;
customer2.OnOrder += waiter.CalculateBill;

//使用事件,事件只能由事件拥有者触发,不能在外部去触发
//顾客1点了超大杯摩卡15+6 = 21
customer.Order("摩卡", 15, OrderEventArgs.CoffeeSizeEnum.Venti);
//顾客1点了中杯拿铁20
customer.Order("拿铁", 20, OrderEventArgs.CoffeeSizeEnum.Tall);
//顾客2点了中杯卡布奇诺
customer2.Order("卡布奇诺", 25, OrderEventArgs.CoffeeSizeEnum.Tall);
customer.PayTheBill();
customer2.PayTheBill();

/*//如果使用委托,不使用事件,委托类型的字段可以在外部进行调用,意味着顾客2可以把自己点的东西记在顾客1的账单上
//顾客1点了超大杯摩卡15+6 = 21
OrderEventArgs e1 = new OrderEventArgs();
e1.CoffeeName = "摩卡";
e1.CoffeePrice = 15;
e1.CoffeeSize = OrderEventArgs.CoffeeSizeEnum.Venti;
customer.OnOrder(customer, e1);
//顾客1点了超大杯拿铁20+6 = 26,并记在了倒霉蛋顾客1的账单上
OrderEventArgs e2 = new OrderEventArgs();
e2.CoffeeName = "拿铁";
e2.CoffeePrice = 20;
e2.CoffeeSize = OrderEventArgs.CoffeeSizeEnum.Venti;
customer2.OnOrder(customer, e2);
customer.PayTheBill();
customer2.PayTheBill();*/


Console.Read();
}
}

public class Customer
{
//声明一个点单事件
public event OrderEventHandler OnOrder;

public float Bill { get; set; }
public void PayTheBill()
{
Console.WriteLine("I have to pay : " + Bill);
}

/// <summary>
/// 点餐(咖啡名称、价格、大小)
/// </summary>
/// <param name="coffeeName"></param>
/// <param name="coffeePrice"></param>
/// <param name="coffeeSize"></param>
public void Order(string coffeeName,float coffeePrice, OrderEventArgs.CoffeeSizeEnum coffeeSize)
{
//语法糖:如果事件不为空(因为简略声明事件时委托类型的字段被隐藏了)
if (OnOrder != null)
{
OrderEventArgs e = new OrderEventArgs();
e.CoffeeName = coffeeName;
e.CoffeePrice = coffeePrice;
e.CoffeeSize = coffeeSize;
//事件只能由事件拥有者触发:限制只能自己给自己点单
OnOrder(this, e);
}
}
}

public class Waiter
{
//计算账单金额
public void CalculateBill(Customer customer, OrderEventArgs e)
{
float finalPrice = 0;
switch (e.CoffeeSize)
{
case OrderEventArgs.CoffeeSizeEnum.Tall:
finalPrice = e.CoffeePrice; //中杯,原价
break;
case OrderEventArgs.CoffeeSizeEnum.Grand:
finalPrice = e.CoffeePrice + 3; //大杯:原价+3元
break;
case OrderEventArgs.CoffeeSizeEnum.Venti:
finalPrice = e.CoffeePrice + 6; //超大杯:原价+6元
break;
}
customer.Bill += finalPrice;
}
}

public class OrderEventArgs
{
// 咖啡是大杯、中杯还是小杯
public enum CoffeeSizeEnum { Tall,Grand,Venti}//默认为静态
public CoffeeSizeEnum CoffeeSize { get; set; }
// 咖啡价格
public float CoffeePrice { get; set; }
// 咖啡名称
public string CoffeeName { get; set; }
}
}

Credits

https://blog.csdn.net/Hotgun2222/article/details/139901041

线性:一头一尾,每个元素只有一个前驱和一个后驱,比如栈/队列
非线性:分支、分层关系,比如树/图


物理结构和逻辑结构

数组和链表可以看做物理存储的概念。
数组是用一段连续的内存存储,可以随机访问,支持随机访问( O(1) ),但增删需移动元素( O(n) )。
链表不要求连续的内存,元素分散存储,通过指针连接,增删无需移动元素( O(1) ),但不支持随机访问( O(n) )。

而数据结构都是逻辑层的概念,线性表,非线性表,栈,队列,树,图等等。
线性表这些逻辑层的概念,底层既可以用数组实现,也可以用链表实现。线性表用数组实现就叫做顺序表。

“顺序表是在计算机内存中以数组的形式保存的线中以数组的形式保存的线性表。” 简而言之,是线性表的一种实现方式。显然,这里的“数组”指物理结构,“线性表”指逻辑结构。这个解释应该还是合理的。