三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity模块化架构实战:用Assembly Definition构建可维护游戏代码

Unity模块化架构实战:用Assembly Definition构建可维护游戏代码

1. 项目概述:为什么Unity项目需要模块化代码架构?

如果你在Unity里做过几个项目,尤其是那种功能越加越多、代码越来越乱的,肯定对“牵一发而动全身”深有体会。改一个UI按钮的逻辑,结果发现游戏核心的战斗系统崩了;想复用上个项目的背包系统,结果发现它跟角色状态管理、网络同步、资源加载的代码搅在一起,根本抽不出来。这种时候,你就需要一个清晰、强制的物理隔离手段,而Unity的Assembly Definition(程序集定义,简称AsmDef)就是为此而生的利器。

简单来说,AsmDef允许你将项目中的脚本(Scripts)划分到不同的“程序集”(Assembly)中。每个程序集都是一个独立的编译单元,相当于给代码建了一堵墙。墙内的代码可以紧密协作(高内聚),墙与墙之间只能通过明确的“门”(即公开的接口和类)来通信(低耦合)。这不仅仅是代码组织上的整洁,更是工程实践上的质变。它能显著减少不必要的编译时间(只编译改动了的程序集),强制你思考模块间的依赖关系,从根本上杜绝循环引用,并且为代码的跨项目复用铺平了道路。这次,我们就抛开理论,直接从零开始,手把手构建一个实战级的模块化代码架构。

2. 核心概念与设计思路拆解

在动手之前,我们必须理解几个核心概念,并确立清晰的设计原则。盲目创建AsmDef文件只会制造新的混乱。

2.1 Assembly Definition 到底是什么?

你可以把一个程序集想象成一个独立的代码库(DLL)。在Unity中,默认所有脚本都编译进一个名为“Assembly-CSharp.dll”的程序集。AsmDef文件(后缀为.asmdef)就是一个配置文件,告诉Unity:“请把我这个文件夹(及其子文件夹)下的所有脚本,单独编译成一个新的DLL文件。”

这个DLL文件在项目中的表现就是:

  1. 独立的命名空间(强烈建议):虽然AsmDef不强制,但最佳实践是为每个程序集配套一个独立的根命名空间,例如MyGame.Core,MyGame.Gameplay
  2. 明确的依赖关系:程序集A如果想使用程序集B里的类,必须在A的AsmDef文件中“引用”(Reference)B。Unity的编译器会严格检查,如果A没引用B却使用了B的代码,直接报错。
  3. 编译隔离:修改程序集A的代码,只会触发A及其依赖链上所有程序集的重新编译。如果你的UI模块(程序集)依赖核心模块(程序集),但核心模块没动,那么修改UI时,核心模块无需重新编译,节省大量时间。

2.2 模块化架构的核心设计原则

基于AsmDef,我们设计架构时需要遵循几个原则:

  1. 单向依赖,禁止循环:依赖关系必须是从上层(具体业务)指向下层(核心抽象),形成一个有向无环图(DAG)。例如:Gameplay(游戏玩法)可以依赖Core(核心系统),但Core绝对不能反向依赖Gameplay。循环依赖会导致编译失败,是架构设计上的“死罪”。
  2. 接口与实现分离:这是实现低耦合的关键。模块之间应尽量通过接口(Interface)或抽象类进行通信,而不是具体的实现类。例如,AudioSystem接口定义在Core中,其具体实现UnityAudioSystem可以放在Infrastructure(基础设施)程序集里。这样,Gameplay模块只知道要播放声音,而不关心是用Unity的AudioSource还是WWise实现的。
  3. 层次清晰,职责单一:常见的分层思路是:
    • Core / Runtime:最底层,定义游戏的核心数据模型、通用接口、工具类、扩展方法。它应该不依赖任何Unity引擎特定的API(或者依赖极少),理想情况下可以脱离Unity环境进行单元测试。
    • Infrastructure / Engine:桥梁层,实现Core中定义的接口,与Unity引擎(或其他第三方服务)打交道。例如网络通信的实现、资源加载的实现、输入系统的实现。
    • Gameplay:游戏玩法层,包含角色、技能、物品、关卡逻辑等。它依赖CoreInfrastructure
    • UI / Presentation:表现层,处理所有用户界面。它依赖Gameplay(为了显示数据)和Core/Infrastructure(为了调用服务)。
  4. 测试驱动:为每个程序集,特别是Core层,创建对应的测试程序集(如Core.Tests)。测试程序集引用被测试的程序集,这样可以方便地进行单元测试。

3. 从零开始:构建一个实战项目结构

假设我们正在开发一个名为“FantasyQuest”的RPG游戏。下面我们来一步步搭建它的代码架构。

3.1 规划程序集与文件夹结构

首先,在项目的Assets/Scripts文件夹下(或直接在Assets下创建Code文件夹),规划出以下结构:

Assets/ └── Scripts/ (或 Code/) ├── Core/ (定义核心接口、数据模型、工具) │ ├── Interfaces/ (如 IAudioService, ISaveSystem) │ ├── Models/ (如 PlayerData, ItemDefinition) │ ├── Utilities/ (如 Extensions, Logger) │ └── FantasyQuest.Core.asmdef ├── Infrastructure/ (引擎与第三方集成) │ ├── Audio/ (实现IAudioService) │ ├── Save/ (实现ISaveSystem,用PlayerPrefs或文件) │ ├── Input/ (封装Unity Input System) │ └── FantasyQuest.Infrastructure.asmdef ├── Gameplay/ (游戏核心逻辑) │ ├── Characters/ │ ├── Skills/ │ ├── Inventory/ │ └── FantasyQuest.Gameplay.asmdef ├── UI/ (用户界面) │ ├── Views/ (MVC中的View,或MVP中的Presenter) │ ├── Widgets/ (可复用UI组件) │ └── FantasyQuest.UI.asmdef └── Tests/ (测试代码,可选) ├── Core.Tests.asmdef └── Gameplay.Tests.asmdef

注意:文件夹名和程序集名不需要完全一致,但保持一致性会让项目更清晰。我习惯用[项目名].[模块名]的格式命名程序集。

3.2 创建与配置Assembly Definition文件

  1. 创建程序集:在Core文件夹右键 ->Create -> Assembly Definition。将其命名为FantasyQuest.Core
  2. 配置基础属性:选中新建的.asmdef文件,在Inspector面板中可以看到以下关键配置:
    • Name: 程序集名称,也是编译后DLL的文件名(如FantasyQuest.Core.dll)。
    • Root Namespace(Unity 2020.1+):强烈建议填写!这里填FantasyQuest.Core。这样,在此程序集内创建的新脚本,其默认命名空间就会是这个,保证了命名空间的整洁。
    • References: 添加此程序集所依赖的其他程序集。Core作为最底层,通常不引用任何其他项目内的程序集,但可以引用.NET StandardUnity自带的程序集(如UnityEngineUnityEngine.UI等)。
    • Define ConstraintsVersion Defines: 高级功能,可用于为特定平台或Unity版本定义编译符号,实现条件编译。
    • Override References: 允许你覆盖项目级别的程序集引用设置,通常不需要动。
    • Auto Referenced: 如果勾选,Unity会自动将此程序集添加到所有其他程序集的引用中(不推荐!这会破坏模块化)。
    • No Engine References: 勾选后,此程序集将无法访问UnityEngineUnityEditor的API。这对于Core层非常有用,可以强制保证核心逻辑与引擎解耦。
    • Allow Unsafe Code: 是否允许使用C#的不安全代码。
  3. Core程序集勾选No Engine References。这迫使Core层的代码必须保持“纯净”,只包含业务逻辑和数据,为未来的跨平台复用或服务器端复用打下基础。
  4. 配置依赖链
    • FantasyQuest.Infrastructure.asmdef: 在References中添加FantasyQuest.Core。因为它需要实现Core中定义的接口。
    • FantasyQuest.Gameplay.asmdef: 在References中添加FantasyQuest.CoreFantasyQuest.Infrastructure(因为玩法逻辑可能需要直接调用某些基础设施服务)。
    • FantasyQuest.UI.asmdef: 在References中添加FantasyQuest.Core,FantasyQuest.Gameplay(用于获取数据显示),可能还有FantasyQuest.Infrastructure(例如调用输入服务)。
    • Core.Tests.asmdef: 在References中添加FantasyQuest.Core以及测试框架(如NUnit)。关键一步:在Assembly Definition References下方,点击+添加Test Assemblies,这会将此程序集标记为测试程序集,其中的测试用例才能在Unity Test Runner中显示和运行。

3.3 编写跨程序集通信的代码示例

让我们用一个简单的音频系统来演示接口分离和依赖注入。

FantasyQuest.Core中定义接口:

// Assets/Scripts/Core/Interfaces/IAudioService.cs namespace FantasyQuest.Core.Interfaces { public interface IAudioService { void PlaySoundEffect(string clipId); void PlayMusic(string musicId); void SetMasterVolume(float volume); } }

FantasyQuest.Infrastructure中实现接口:

// Assets/Scripts/Infrastructure/Audio/UnityAudioService.cs using UnityEngine; using FantasyQuest.Core.Interfaces; // 引用Core程序集 namespace FantasyQuest.Infrastructure.Audio { public class UnityAudioService : IAudioService { public void PlaySoundEffect(string clipId) { // 这里简化处理,实际应从Addressables或Resources加载 var audioSource = FindOrCreateAudioSource(); // ... 播放逻辑 Debug.Log($"Playing SFX: {clipId}"); } // ... 实现其他方法 private AudioSource FindOrCreateAudioSource() { /* ... */ } } }

FantasyQuest.Gameplay中使用服务:

// Assets/Scripts/Gameplay/Characters/Player.cs using FantasyQuest.Core.Interfaces; namespace FantasyQuest.Gameplay.Characters { public class Player { private IAudioService _audioService; // 通过构造函数注入依赖 public Player(IAudioService audioService) { _audioService = audioService; } public void TakeDamage() { // 业务逻辑... _audioService.PlaySoundEffect("player_hurt"); } } }

依赖注入的启动点:我们需要一个地方来创建这些具体的实现类,并将它们注入到需要的地方。这通常在游戏启动时,在一个位于“顶层”的程序集(比如一个不遵循严格分层、用于引导的Bootstrap程序集,或者就在默认的Assembly-CSharp中)里完成。

// 例如,在某个MonoBehaviour的Start方法中 using FantasyQuest.Core.Interfaces; using FantasyQuest.Infrastructure.Audio; using FantasyQuest.Gameplay.Characters; public class GameBootstrapper : MonoBehaviour { void Start() { // 1. 创建基础设施服务实例 IAudioService audioService = new UnityAudioService(); // 2. 创建游戏对象并注入依赖 Player player = new Player(audioService); // 3. 后续可以将player交给其他系统管理... } }

实操心得:在实际中型以上项目中,推荐使用一个轻量级的依赖注入容器(如Zenject(Extenject)、VContainer)来管理这些依赖关系的创建和生命周期,可以大大简化这项繁琐的工作。

4. 高级技巧与实战避坑指南

仅仅创建程序集是不够的,在实际开发中会遇到各种具体问题。

4.1 处理Unity引擎特有的类型与序列化

当你的Core程序集勾选了No Engine References后,里面就不能出现Vector3GameObject这类Unity类型了。那数据模型怎么定义?

方案一:使用纯C#类型。在Core中定义数据时,使用System.Numerics.Vector3或者自定义结构体。

// Core 层 namespace FantasyQuest.Core.Models { public struct Position { public float X; public float Y; public float Z; } public class EntityData { public Position WorldPosition; public int Health; } }

方案二:接口隔离。在Core中定义ITransform接口,在Infrastructure中提供基于UnityEngine.Transform的实现。Core层代码只操作ITransform

关于ScriptableObject:ScriptableObject是Unity用于存储数据的强大工具。如果你想在Core层定义数据模板(如物品配置),但又需要Unity的序列化支持,一个常见模式是:

  1. Core中定义抽象的数据类(不继承ScriptableObject)。
  2. Infrastructure或一个专门的ScriptableObjects程序集中,创建继承自ScriptableObject的包装类,其唯一作用就是持有一个Core数据类的实例并序列化它。

4.2 解决“Internal”可见性问题

默认情况下,一个程序集中的internal类对其他程序集是不可见的。但有时,你可能希望Infrastructure程序集中的某个“内部”实现类,能被同一个模块的测试程序集访问,同时又不暴露给Gameplay层。

这时可以使用InternalsVisibleTo属性。编辑FantasyQuest.Infrastructure程序集的源码文件(或者使用AssemblyInfo.cs)。

// 在 Infrastructure 程序集的任意一个脚本文件中(通常放在Properties/AssemblyInfo.cs) using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("FantasyQuest.Infrastructure.Tests")] // 对测试程序集可见 [assembly: InternalsVisibleTo("FantasyQuest.Gameplay")] // 谨慎使用!这会破坏封装性。

更规范的做法是在FantasyQuest.Infrastructure.asmdef文件的Assembly Definition References里,为需要访问其内部成员的程序集添加引用,但这通常只对测试程序集有效。对于生产代码,应优先考虑通过公共接口暴露功能。

4.3 循环依赖检测与破解

Unity编辑器会严格检查循环依赖。如果A引用B,B又引用A,编译会失败。遇到这种情况,说明你的架构设计有问题。破解方法通常有:

  1. 提取公共部分到第三个程序集(C):将A和B都依赖的代码抽离到新的CommonCore程序集中。
  2. 使用接口进行解耦:将依赖方向改为单向。例如,A依赖B,B需要A的某个功能,则将这个功能抽象成接口IAFunction放在B中(或新的公共程序集),由A来实现它,并通过依赖注入的方式提供给B。
  3. 事件驱动:使用事件总线(Event Bus)或消息系统。A和B不直接相互引用,而是向一个中立的“事件中心”发布和订阅事件。

4.4 程序集与Unity编辑器扩展

为编辑器创建的工具脚本,应该放在独立的程序集中,并且其AsmDef文件要勾选Include Platforms下的Editor,同时取消勾选Runtime平台。这能确保编辑器代码不会被打进游戏运行时包,减小包体。 通常可以创建一个FantasyQuest.Editor程序集,它引用你的CoreGameplay程序集来访问数据模型,但只包含在Unity编辑器中运行的代码。

4.5 性能与编译优化

  • 编译速度:模块化后,编译速度的提升立竿见影。修改UI层代码,核心逻辑层无需重编。确保你的程序集划分合理,避免单个程序集过于庞大。
  • 运行时性能:程序集本身对运行时性能影响微乎其微。但良好的架构带来的清晰依赖关系,有助于你更好地管理资源加载、对象生命周期和内存,间接提升性能。
  • 程序集重命名与移动:移动或重命名AsmDef文件及其所在文件夹需要小心。最好在Unity编辑器内操作(拖拽文件夹、在Project窗口重命名),Unity会自动更新相关引用。如果在资源管理器(如Finder、Explorer)中直接操作,可能会导致引用丢失,需要手动修复。

5. 常见问题排查与解决方案实录

在实际迁移或新建模块化项目时,你肯定会遇到下面这些坑。

5.1 “类型或命名空间名称‘XXX’找不到”

这是最常见的问题,几乎都是由于程序集引用缺失或错误造成的。

  • 检查步骤
    1. 确认脚本位置:确保脚本文件确实放在了目标程序集的文件夹下。有时文件放错了地方。
    2. 检查AsmDef引用:双击报错的脚本,看它顶部using的命名空间来自哪个程序集。然后去该脚本所属的AsmDef文件中,检查References列表里是否添加了那个程序集。
    3. 检查命名空间:确保你using的命名空间,与目标程序集中类的实际命名空间一致。AsmDef的Root Namespace设置会影响新创建脚本的默认命名空间。
    4. 重启Unity或触发编译:有时引用已经添加,但Unity的IDE集成(Rider/VS)没有及时更新。保存所有脚本,在Unity中点击Assets -> Refresh,或者直接重启Unity。

5.2 “循环依赖”错误

错误信息会明确指出是哪两个程序集发生了循环引用。

  • 解决方案
    1. 分析依赖图:画一个简单的框图,理清A和B之间到底是谁需要谁的功能。
    2. 应用“依赖倒置原则”:找到循环链,将其中一个方向上的依赖改为对接口的依赖,并将接口提取到第三方(或层级更高的)程序集。
    3. 使用事件/消息:如果两个模块需要通信但不存在清晰的上下级关系,考虑使用事件总线来解耦。

5.3 编辑器脚本不工作或游戏脚本在编辑器中报错

  • 现象:为编辑器写的工具窗口不显示,或者游戏运行时脚本在编辑器模式下找不到某些类型。
  • 原因:平台包含设置错误。编辑器脚本的程序集必须包含Editor平台,游戏运行时脚本的程序集必须包含目标运行时平台(如Standalone,Android,iOS)。
  • 解决:选中AsmDef文件,在Inspector的Platforms部分仔细检查。编辑器专用程序集只勾选Editor,游戏通用程序集勾选所有需要的运行时平台。

5.4 单元测试无法发现测试用例

  • 现象:在Unity Test Runner窗口里,看不到你写的[Test]方法。
  • 原因:测试程序集没有被正确识别。
  • 解决
    1. 确保测试脚本放在了标记为测试程序集的文件夹下。
    2. 选中测试程序集的AsmDef文件,在Inspector中,确保在Assembly Definition References下方,Test Assemblies列表里包含了该程序集。如果没有,点击+添加。
    3. 检查测试程序集是否引用了正确的NUnit程序集(通常是nunit.framework)。

5.5 从传统“一锅粥”架构迁移到模块化

对于已有项目,迁移是渐进式的,切忌一次性重写所有代码。

  1. 建立新的Core程序集:先创建一个新的Core程序集,勾选No Engine References。将项目中那些最纯粹、不依赖Unity的通用工具类、数据模型、接口定义慢慢移进去。每移一个,就修复原位置的引用错误。
  2. 创建基础设施层:将与Unity强相关的、实现Core接口的类,移到新的Infrastructure程序集。
  3. 拆分业务逻辑:将相对独立的系统(如背包、任务、技能)逐步抽离成独立的Gameplay.XXX程序集。
  4. 耐心与测试:每一步迁移后,都要充分测试,确保功能正常。利用版本控制(如Git)做好提交,方便回退。

迁移的过程很痛苦,但一旦完成,项目代码的清晰度、可维护性和团队协作效率将会获得巨大的提升。这不仅仅是代码组织方式的变化,更是对开发团队工程思维的一次重要训练。当你看到编译时间从几分钟缩短到几十秒,当你能够轻松地将一个系统复用到新项目时,你会觉得这一切都是值得的。

← 返回列表