乌啦呀哈呀哈乌啦!

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

0%

AssetBundle

AssetBundle 本质是一个压缩的二进制文件,内部结构如下:

1
2
3
4
AssetBundle 文件
├── Header(包头:版本、压缩类型、元数据)
├── Manifest(资源清单:资源名、依赖关系、GUID)
└── Data Segment(序列化的资源数据)

支持两种压缩格式:

  • LZMA:压缩率高,但加载时需整包解压,内存占用大
  • LZ4:块压缩,按需解压,加载更快,推荐使用

依赖关系

AB 包之间存在依赖链,必须按顺序加载:

1
2
3
bundle_character.ab
└── 依赖 bundle_texture.ab
└── 依赖 bundle_shader.ab

如果 bundle_texture.ab 未加载,bundle_character.ab 中的资源会出现材质丢失(粉红色)。

打包脚本(Editor)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Editor/AssetBundleBuilder.cs
using UnityEditor;
using System.IO;

public class AssetBundleBuilder
{
[MenuItem("Tools/Build AssetBundles")]
public static void BuildAllAB()
{
string outputPath = "Assets/StreamingAssets/AssetBundles";
if (!Directory.Exists(outputPath))
Directory.CreateDirectory(outputPath);

// BuildAssetBundleOptions.ChunkBasedCompression 使用 LZ4 压缩
BuildPipeline.BuildAssetBundles(
outputPath,
BuildAssetBundleOptions.ChunkBasedCompression,
BuildTarget.StandaloneWindows64
);

AssetDatabase.Refresh();
UnityEngine.Debug.Log("AssetBundle 构建完成");
}
}

运行时加载管理器

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
// Runtime/AssetBundleManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class AssetBundleManager : MonoBehaviour
{
// 缓存已加载的 AB 包,避免重复加载
private Dictionary<string, AssetBundle> _loadedBundles = new();

private string _basePath;

void Awake()
{
_basePath = Application.streamingAssetsPath + "/AssetBundles/";
}

// 同步加载 AB 包(会阻塞主线程,小包可用)
public AssetBundle LoadBundle(string bundleName)
{
if (_loadedBundles.TryGetValue(bundleName, out var cached))
return cached;

// 先加载依赖
LoadDependencies(bundleName);

var bundle = AssetBundle.LoadFromFile(_basePath + bundleName);
if (bundle == null)
{
Debug.LogError($"加载 AB 包失败: {bundleName}");
return null;
}

_loadedBundles[bundleName] = bundle;
return bundle;
}

// 加载依赖包(通过 Manifest 自动解析)
private void LoadDependencies(string bundleName)
{
// Manifest 包名与输出目录同名
var manifestBundle = AssetBundle.LoadFromFile(_basePath + "AssetBundles");
if (manifestBundle == null) return;

var manifest = manifestBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");
string[] deps = manifest.GetAllDependencies(bundleName);

foreach (var dep in deps)
{
if (!_loadedBundles.ContainsKey(dep))
{
var depBundle = AssetBundle.LoadFromFile(_basePath + dep);
if (depBundle != null)
_loadedBundles[dep] = depBundle;
}
}

manifestBundle.Unload(false); // 卸载 Manifest 包,保留已加载资源
}

// 异步加载 AB 包(推荐,不阻塞主线程)
public IEnumerator LoadBundleAsync(string bundleName, System.Action<AssetBundle> onComplete)
{
if (_loadedBundles.TryGetValue(bundleName, out var cached))
{
onComplete?.Invoke(cached);
yield break;
}

var request = AssetBundle.LoadFromFileAsync(_basePath + bundleName);
yield return request;

if (request.assetBundle == null)
{
Debug.LogError($"异步加载 AB 包失败: {bundleName}");
yield break;
}

_loadedBundles[bundleName] = request.assetBundle;
onComplete?.Invoke(request.assetBundle);
}

// 从已加载的包中加载具体资源
public T LoadAsset<T>(string bundleName, string assetName) where T : Object
{
var bundle = LoadBundle(bundleName);
return bundle?.LoadAsset<T>(assetName);
}

// 卸载 AB 包
// unloadAllObjects=true:同时卸载从该包加载的所有资源实例(防内存泄漏)
// unloadAllObjects=false:只卸载包本身,已加载的资源继续存在
public void UnloadBundle(string bundleName, bool unloadAllObjects = false)
{
if (_loadedBundles.TryGetValue(bundleName, out var bundle))
{
bundle.Unload(unloadAllObjects);
_loadedBundles.Remove(bundleName);
}
}

// 卸载所有包
public void UnloadAll(bool unloadAllObjects = false)
{
foreach (var bundle in _loadedBundles.Values)
bundle.Unload(unloadAllObjects);
_loadedBundles.Clear();
}
}

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 使用 AssetBundleManager 加载预制体并实例化
public class GameLoader : MonoBehaviour
{
[SerializeField] private AssetBundleManager _abManager;

IEnumerator Start()
{
// 异步加载包
AssetBundle bundle = null;
yield return _abManager.LoadBundleAsync("characters", b => bundle = b);

if (bundle != null)
{
// 从包中加载预制体
var prefab = bundle.LoadAsset<GameObject>("Hero");
Instantiate(prefab, Vector3.zero, Quaternion.identity);

// 资源实例化后可以卸载包(unloadAllObjects=false 保留实例)
_abManager.UnloadBundle("characters", false);
}
}
}

Addressables

Addressables 是对 AssetBundle 的高层封装,核心组件:

1
2
3
4
5
6
7
8
9
10
11
Addressables 系统
├── Catalog(资源目录)
│ └── 地址(string) → 资源位置(本地/远程) 的映射表
├── ResourceLocator(资源定位器)
│ └── 根据 Catalog 找到资源的实际路径
├── ResourceProvider(资源提供者)
│ └── 负责实际的加载逻辑(本地文件/网络下载)
├── AsyncOperationHandle(异步操作句柄)
│ └── 管理加载状态、进度、回调
└── ResourceManager(资源管理器)
└── 引用计数、内存管理

加载流程

1
2
3
4
5
6
7
8
9
10
11
12
13
Addressables.LoadAssetAsync("Hero")

ResourceLocator 查询 Catalog

找到资源位置(本地 StreamingAssets 或远程 CDN URL)

ResourceProvider 加载对应的 AssetBundle

自动加载所有依赖 Bundle

从 Bundle 中提取目标资源

引用计数 +1,返回 AsyncOperationHandle

引用计数机制

Addressables 内部维护引用计数,每次 LoadAssetAsync 计数 +1,每次 Release 计数 -1,归零时自动卸载 Bundle,这是它比原生 AB 包更安全的核心原因。

基础加载与释放

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
// Runtime/AddressablesLoader.cs
using System.Collections;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class AddressablesLoader : MonoBehaviour
{
// 在 Inspector 中直接引用 Addressable 资源(推荐,类型安全)
[SerializeField] private AssetReferenceGameObject _heroPrefabRef;

private AsyncOperationHandle<GameObject> _loadHandle;

void Start()
{
// 方式一:通过 AssetReference(Inspector 拖拽绑定)
LoadViaAssetReference();

// 方式二:通过地址字符串
// LoadViaAddress("Hero");
}

// 通过 AssetReference 加载(推荐)
void LoadViaAssetReference()
{
_loadHandle = _heroPrefabRef.LoadAssetAsync<GameObject>();
_loadHandle.Completed += OnLoadComplete;
}

// 通过地址字符串加载
void LoadViaAddress(string address)
{
_loadHandle = Addressables.LoadAssetAsync<GameObject>(address);
_loadHandle.Completed += OnLoadComplete;
}

private void OnLoadComplete(AsyncOperationHandle<GameObject> handle)
{
if (handle.Status == AsyncOperationStatus.Succeeded)
{
Instantiate(handle.Result, Vector3.zero, Quaternion.identity);
Debug.Log("资源加载成功");
}
else
{
Debug.LogError($"资源加载失败: {handle.OperationException}");
}
}

void OnDestroy()
{
// 必须释放句柄,否则引用计数不归零,内存泄漏
if (_loadHandle.IsValid())
Addressables.Release(_loadHandle);
}
}

异步实例化(更高效,跳过 LoadAsset 步骤)

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 AddressablesInstantiator : MonoBehaviour
{
[SerializeField] private string _address;
private AsyncOperationHandle<GameObject> _instantiateHandle;

IEnumerator Start()
{
// InstantiateAsync 直接加载并实例化,一步到位
_instantiateHandle = Addressables.InstantiateAsync(
_address,
Vector3.zero,
Quaternion.identity
);

// 等待完成并显示进度
while (!_instantiateHandle.IsDone)
{
Debug.Log($"加载进度: {_instantiateHandle.PercentComplete:P0}");
yield return null;
}

if (_instantiateHandle.Status == AsyncOperationStatus.Succeeded)
Debug.Log($"实例化成功: {_instantiateHandle.Result.name}");
}

void OnDestroy()
{
// InstantiateAsync 创建的对象用 ReleaseInstance 释放
if (_instantiateHandle.IsValid())
Addressables.ReleaseInstance(_instantiateHandle);
}
}

批量加载(LoadAssetsAsync)

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
public class AddressablesBatchLoader : MonoBehaviour
{
// 通过 Label 批量加载同类资源
[SerializeField] private string _label = "Enemies";

private AsyncOperationHandle<IList<GameObject>> _batchHandle;

IEnumerator Start()
{
_batchHandle = Addressables.LoadAssetsAsync<GameObject>(
_label,
obj => Debug.Log($"单个资源加载完成: {obj.name}") // 每个资源加载完的回调
);

yield return _batchHandle;

if (_batchHandle.Status == AsyncOperationStatus.Succeeded)
{
foreach (var prefab in _batchHandle.Result)
Instantiate(prefab);
}
}

void OnDestroy()
{
if (_batchHandle.IsValid())
Addressables.Release(_batchHandle);
}
}

场景加载

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
using UnityEngine.ResourceManagement.ResourceProviders;
using UnityEngine.SceneManagement;

public class AddressablesSceneLoader : MonoBehaviour
{
[SerializeField] private string _sceneAddress = "Level_01";
private AsyncOperationHandle<SceneInstance> _sceneHandle;

public IEnumerator LoadScene()
{
_sceneHandle = Addressables.LoadSceneAsync(
_sceneAddress,
LoadSceneMode.Additive, // 叠加模式,不卸载当前场景
activateOnLoad: true
);

while (!_sceneHandle.IsDone)
{
Debug.Log($"场景加载进度: {_sceneHandle.PercentComplete:P0}");
yield return null;
}

Debug.Log("场景加载完成");
}

public IEnumerator UnloadScene()
{
if (_sceneHandle.IsValid())
{
var unloadHandle = Addressables.UnloadSceneAsync(_sceneHandle);
yield return unloadHandle;
}
}
}

热更新检查(远程内容更新)

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
using System.Collections.Generic;
using UnityEngine.AddressableAssets.ResourceLocators;

public class AddressablesUpdater : MonoBehaviour
{
IEnumerator Start()
{
// Step 1:检查 Catalog 是否有更新
var checkHandle = Addressables.CheckForCatalogUpdates(autoReleaseHandle: false);
yield return checkHandle;

if (checkHandle.Status != AsyncOperationStatus.Succeeded)
{
Addressables.Release(checkHandle);
yield break;
}

List<string> catalogsToUpdate = checkHandle.Result;
Addressables.Release(checkHandle);

if (catalogsToUpdate.Count == 0)
{
Debug.Log("无需更新");
yield break;
}

// Step 2:更新 Catalog
var updateHandle = Addressables.UpdateCatalogs(catalogsToUpdate, autoReleaseHandle: false);
yield return updateHandle;

List<IResourceLocator> updatedLocators = updateHandle.Result;
Addressables.Release(updateHandle);

// Step 3:下载更新的内容
foreach (var locator in updatedLocators)
{
var sizeHandle = Addressables.GetDownloadSizeAsync(locator.Keys);
yield return sizeHandle;

long downloadSize = sizeHandle.Result;
Addressables.Release(sizeHandle);

if (downloadSize > 0)
{
Debug.Log($"需要下载: {downloadSize / 1024f / 1024f:F2} MB");

var downloadHandle = Addressables.DownloadDependenciesAsync(locator.Keys);
yield return downloadHandle;
Addressables.Release(downloadHandle);
}
}

Debug.Log("更新完成");
}
}

At startup, the Instance ID cache is initialized with data for all Objects immediately required by the project (i.e., referenced in built Scenes), as well as all Objects contained in the Resources folder. Additional entries are added to the cache when new assets are imported at runtime or when Objects are loaded from AssetBundles. ( Note: An example of an Asset created at runtime would be a Texture2D Object created in script, like so: var myTexture = new Texture2D(1024, 768); )

  • File GUID(资源UID)
    编辑器层面,存在 .meta 文件里,唯一标识磁盘上的一个资源文件,打包后不直接使用

  • Local ID
    一个资源文件内部,每个 Object 的编号
    比如一个 FBX 里有 Mesh、Material、动画,各有自己的 Local ID

  • Instance ID(实例ID)
    运行时层面,Unity 内存中每个 Object 的唯一标识,本质是一个整数,运行时动态分配
    是 File GUID + Local ID 的运行时映射

1
2
3
4
5
磁盘:  File GUID + Local ID  →  唯一定位一个资源
运行时:Instance ID → 指向内存中的 Object

Instance ID Cache = 这个映射表
{ Instance ID → (File GUID, Local ID, 内存地址或null) }
  • Instance ID Cache 的作用
1
2
3
4
5
6
7
8
9
启动时预填充:
- 所有场景直接引用的资源
- Resources 文件夹下的所有资源
→ 这些 Object 的 Instance ID 提前注册,但不一定加载进内存

运行时追加:
- 从 AB 包加载新资源时
- 代码里 new Texture2D() 创建时
→ 新 Instance ID 动态加入缓存

当你持有一个对象引用,Unity 内部就是拿着 Instance ID 去缓存里查:

  1. 查到且有内存地址 → 直接返回
  2. 查到但没加载 → 触发加载
  3. 查不到 → null(Missing)

总结:Instance ID Cache 是运行时的”对象注册表”,把内存中每个 Object 的整数 ID 映射到它的资源来源(GUID+LocalID)和内存位置。
资源 UID(File GUID)是编辑器/磁盘层面的概念,Instance ID 是运行时层面的概念,两者通过这个缓存关联起来。

Resources

Resources文件夹是一个只读的文件夹,通过Resources.Load()来读取对象。
因为这个文件夹下的所有资源都可以运行时来加载,所以Resources文件夹下的所有东西都会被无条件的打到发布包中。
建议这个文件夹下只放Prefab文件,因为Prefab会自动过滤掉对象上不需要的资源。

  • 举个例子我把模型文件还有贴图文件都放在了Resources文件夹下,但是我有两张贴图是没有在模型上用的,那么此时这两张没用的贴图也会被打包到发布包中。
  • 假如这里我用Prefab,那么Prefab会自动过滤到这两张不被用的贴图,这样发布包就会小一些了。
  • 此时,若prefab引用的贴图在resources文件夹下,则贴图仍然会被打进包中,因为resources文件夹对资源无条件打包。
  • 若prefab引用的贴图在AB包中,则实例化prefab之前,必须手动去把AB加载出来。
  • 就算prefab不是在resources文件来而是在不同于它引用的贴图的AB包中,也一样要在实例化前把贴图所在的AB加载出来;但不用把贴图加载出来,prefab会自动去加载它。

Resources.Load

Unity 打包时为每个资源分配 UID,同时建立一张”路径→UID”映射表存在配置文件里。
加载时先查表把路径转成 UID,再用 UID 找到实际数据。

1
2
3
4
5
Assets/Resources/ui/icon.png
↓ 打包
resources.assets(包含所有未被场景引用的Resources资源)
↓ 运行时
Resources.Load("ui/icon", typeof(Sprite))

特殊情况:如果 Resources 下的资源被场景直接引用,会打进 sharedassets[level].assets 而不是 resources.assets,不同目录下的 Resources 文件夹资源路径最好不重名。

卸载资源时用UnloadUnusedAssets(注意:UnloadAsset 对 Resources 无效)

1
2
3
4
5
6
7
8
9
// 加载
Texture2D tex = Resources.Load("ui/icon", typeof(Texture2D)) as Texture2D;

// 卸载
Resources.UnloadUnusedAssets(); // 正确方式
// Resources.UnloadAsset(tex); 只对 AB 包加载的资源有效

// 查找场景中所有同类型对象(包括未激活的)
Resources.FindObjectsOfTypeAll<GameObject>();

优点:

  • 使用极简,一行代码加载
  • 不需要管理 AB 包依赖

缺点:

  • 所有 Resources 资源无论用没用都打进安装包,包体膨胀
  • 无法热更,改资源必须重新发版
  • 大量资源时启动慢(Unity 启动时要建立完整索引表)

StreamingAssets

StreamingAssets文件夹也是一个只读的文件夹,其目录下的文件在打包时完全不处理,原样复制到目标平台,保留原始文件格式和路径结构。

  • Resources文件夹下的资源会进行一次压缩,而且也会加密,不使用点特殊办法是拿不到原始资源的
  • StreamingAssets文件夹下面的所有资源不会被加密,然后是原封不动的打包到发布包中,这样很容易就拿到里面的文件。所以StreamingAssets适合放一些二进制文件,而Resources更适合放一些GameObject和Object文件。
    • 比如 icon.png 是源文件,放进 Resources 后打包时变成 Texture2D Object,使用Resources.Load<Texture2D>(“icon”)可直接返回 Texture2D 对象
    • 放进 StreamingAssets 它还是原始 .png,Unity 不处理它。

各平台存储位置

1
2
3
4
Windows/Editor  → [项目]/Assets/StreamingAssets/
iOS → [.ipa包]/Data/Raw/
Android → [.apk包]/assets/ (压缩存储在zip内)
WebGL → 无法直接访问

Android 特殊处理(存在 APK 压缩包内,不能直接用文件路径):

1
2
3
4
5
6
7
8
9
10
11
12
// Android 必须用 UnityWebRequest
IEnumerator LoadVideoAndroid(string fileName)
{
string path = Path.Combine(Application.streamingAssetsPath, fileName);
using var req = UnityWebRequest.Get(path);
yield return req.SendWebRequest();
byte[] data = req.downloadHandler.data;
}

// 其他平台可以直接用文件路径
string videoPath = Path.Combine(Application.streamingAssetsPath, "intro.mp4");
videoPlayer.url = videoPath;

优点:

  • 保留原始文件格式,适合需要文件路径的第三方库
  • 视频播放器、SQLite 数据库等可以直接访问
  • 不经过 Unity 资源管道,格式不受限

缺点:

  • Android 上访问慢且必须用 UnityWebRequest
  • 无法热更
  • 不适合大量小文件(没有打包优化)

实际用例:

  • 视频文件(需要 VideoPlayer 直接访问路径)
  • SQLite 数据库初始文件(首次运行复制到 PersistentDataPath 再读写)
  • 第三方 SDK 需要的配置文件
  • AB 包的初始版本(首次运行写入缓存)

PersistentDataPath

由于StreamingAssets文件夹里的内容只能读不能写,所以在需要进行写入操作的时候可以使用PersistentDataPath文件夹,此目录下的文件可读可写,但和StreamingAssets的不同点是:在Editor阶段没有,等到手机安装App后在目标设备上自动生成

  • 可以将一些需要读写的文件先放在Streaming Assets,在App安装后通过代码Copy到PersistentDataPath文件夹,比如上面提到的数据库初始文件

AssetBundle

资源在 Inspector 面板右下角设置包名和变体名,相同包名打进同一个 .ab 文件。

1
2
3
包名: characters/hero    变体名: hd
包名: characters/hero 变体名: sd
→ 生成 characters/hero.hd 和 characters/hero.sd 两个变体包

变体包(Variant)是同一资源的不同版本,用于针对不同设备/平台加载不同质量的内容,但代码层面引用名称保持一致。

根据设备性能决定加载哪个变体
1
2
3
4
5
6
7
8
9
string variant = SystemInfo.systemMemorySize > 4096 ? "hd" : "sd";

// 加载对应变体的 AB 包
string bundlePath = $"characters/hero.{variant}";
AssetBundle ab = AssetBundle.LoadFromFile(bundlePath);

// 资源名称不变,Unity 自动从当前加载的变体中取
GameObject hero = ab.LoadAsset<GameObject>("Hero");
Instantiate(hero);

运行游戏时加载方式:

本地加载和从服务器下载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 本地加载
AssetBundle ab = AssetBundle.LoadFromFile(path);
GameObject prefab = ab.LoadAsset<GameObject>("Hero");

// 网络下载(带缓存)
IEnumerator LoadFromCDN(string url, uint version)
{
using var req = UnityWebRequestAssetBundle.GetAssetBundle(url, version, 0);
yield return req.SendWebRequest();
AssetBundle ab = DownloadHandlerAssetBundle.GetContent(req);
var prefab = ab.LoadAsset<GameObject>("Hero");
Instantiate(prefab);
ab.Unload(false);
}

优点:

  • 支持热更新,资源可以独立于安装包更新
  • 按需加载,减少内存占用
  • 支持变体,同一资源针对不同平台/设备加载不同版本
  • 可以精细控制加载和卸载时机

缺点:

  • 需要管理依赖关系,漏加载依赖包会导致资源丢失,或者依赖未正确处理会产生资源冗余(同一资源打进多个包)
  • 开发流程复杂,需要额外的打包和版本管理系统
  • Unload 使用不当会导致内存泄漏或纹理变洋红

实际用例:

  • 手游热更新(关卡、角色、皮肤)
  • DLC 内容下载
  • 大型游戏按需加载地图/场景
  • 多语言资源包(不同语言加载不同 AB)

现代 Unity 项目的推荐方案是用 Addressables,它是对 AssetBundle 的封装,自动处理依赖、缓存和版本管理,大幅降低 AB 包的使用复杂度。

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]); }