我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Godot C#架构升级:Chickensoft状态机与依赖注入实战

Godot C#架构升级:Chickensoft状态机与依赖注入实战 1. 项目概述为什么要在Godot里引入Chickensoft如果你和我一样从Unity或者其他现代游戏引擎转战Godot尤其是选择了C#作为主力开发语言可能会经历一个短暂的“蜜月期”后陷入一些架构上的纠结。Godot的节点Node和场景Scene系统非常灵活上手快但项目规模稍微大一点各种脚本Script之间的通信、状态管理和依赖关系就会变得一团乱麻。你可能会发现自己的代码里充满了GetNodeMyScript(../SomePath)这样的硬编码或者为了同步一个状态需要在好几个脚本里手动触发信号Signal维护起来简直是噩梦。这正是我当初遇到的情况。直到我发现了Chickensoft这个开源架构库它像是一套为Godot C#量身定制的“企业级”开发范式。简单来说Chickensoft不是一个新引擎而是一套运行在Godot之上的架构理念和工具集它强制性地将你的游戏逻辑与Godot的节点树进行了解耦。它的核心武器有两个LogicBlocks状态机和AutoInject依赖注入。前者让你能用状态机的思维清晰、无副作用地管理游戏逻辑后者则彻底解决了节点间“找对象”的耦合问题。这次实战我就带你从零开始在一个Godot C#项目中集成Chickensoft并重点攻克状态管理与依赖注入这两个最核心、也最能提升代码质量的环节。你会发现经过这番改造你的Godot项目在可测试性、可维护性和团队协作效率上会有质的飞跃。2. 环境准备与项目初始化2.1 创建Godot C#项目与导入Chickensoft首先确保你安装了支持.NET 6或更高版本的Godot 4.x以及对应的C#开发环境如Visual Studio 2022或Rider。创建一个新的Godot项目记得在项目设置里选择.NET作为脚本语言。接下来是引入Chickensoft。最推荐的方式是通过NuGet。在你的项目根目录下找到.csproj文件用文本编辑器打开在ItemGroup标签内添加以下包引用ItemGroup PackageReference IncludeChickensoft.AutoInject Version* / PackageReference IncludeChickensoft.LogicBlocks Version* / PackageReference IncludeChickensoft.GodotNodeInterfaces Version* / /ItemGroup你也可以使用dotnet add package命令来添加。使用通配符*会自动获取最新稳定版但为了项目稳定我建议在初期锁定一个具体版本号比如4.0.0。保存文件后Godot编辑器可能会自动检测到变化并开始还原NuGet包。如果没有在终端里进入项目目录运行dotnet restore命令。注意Chickensoft的版本与Godot版本有较强的关联性请务必查阅其官方文档或GitHub仓库的Release说明选择与你的Godot 4.x小版本兼容的Chickensoft版本。盲目使用最新版可能导致不可预知的API冲突。2.2 理解Chickensoft的核心概念与项目结构在写第一行业务代码之前我们需要在脑子里建立起Chickensoft倡导的项目结构。它和传统的“一个节点对应一个脚本脚本里啥都写”的模式截然不同。逻辑Logic与表现Presentation分离这是核心原则。游戏的状态、规则、计算Logic应该完全独立于Godot的节点、场景、动画、声音Presentation。Logic部分不引用任何Godot的API如Node,Sprite2D因此可以轻松进行单元测试。状态State驱动游戏或某个复杂实体如玩家、敌人、UI菜单的所有行为都由其当前所处的“状态”决定。状态是一个包含了所有相关数据的不可变Immutable对象。状态改变行为随之改变。依赖注入Dependency Injection, DI对象的依赖比如玩家的“背包服务”、“音频管理器”不是由对象自己创建或查找而是由外部一个“依赖提供者”在创建对象时“注入”给它。这极大地降低了耦合度。基于此一个典型的Chickensoft项目文件夹结构可能如下MyGodotGame/ ├── src/ │ ├── Logic/ # 核心游戏逻辑纯C#不依赖Godot │ │ ├── Player/ │ │ │ ├── IPlayerModel.cs # 接口 │ │ │ ├── PlayerLogic.cs # 状态机主逻辑 │ │ │ └── PlayerState.cs # 各种状态定义 │ │ └── Services/ # 全局服务接口定义 │ ├── Presentation/ # Godot节点和场景负责“表现”逻辑 │ │ ├── Player/ │ │ │ ├── PlayerNode.cs # 依赖注入的节点 │ │ │ └── PlayerScene.tscn │ │ └── UI/ │ └── DependencyInjection/ # 依赖注入容器配置 │ └── AppDependencyContainer.cs └── MyGodotGame.csproj这个结构看起来比简单的Godot项目复杂但它强制了良好的关注点分离是应对复杂项目的利器。接下来我们就从最核心的状态管理开始。3. 核心状态管理LogicBlocks实战解析LogicBlocks是Chickensoft实现状态机的库。它基于“有限状态机FSM”和“分层状态机HFSM”的概念但用起来更像一个现代化的、类型安全的响应式状态容器。3.1 定义状态State与输入Input我们以一个简单的玩家角色为例。玩家可能有这些状态闲置Idle、行走Walking、跳跃Jumping、攻击Attacking。状态切换由“输入”触发比如按下移动键、按下跳跃键。首先在src/Logic/Player/下定义状态和输入的接口及具体类。状态State是一个包含了该状态下所有数据的不可变记录Record。// IPlayerState.cs - 所有玩家状态的基接口 public interface IPlayerState : IStateLogicIPlayerState, IPlayerInput { } // PlayerState.cs - 具体状态实现 public abstract record PlayerState : IPlayerState { // 所有状态共有的数据比如玩家位置逻辑坐标非像素坐标 public Vector2 LogicPosition { get; init; } public float Health { get; init; } // ... 其他公共属性 // 每个具体状态可以有自己的特定数据 public record Idle : PlayerState; public record Walking : PlayerState { public Vector2 MoveDirection { get; init; } } public record Jumping : PlayerState { public float JumpVelocityY { get; init; } public float JumpStartTime { get; init; } } public record Attacking : PlayerState { public int AttackComboStep { get; init; } } }注意我们使用了C# 9.0引入的record类型。record默认就是不可变的并且提供了基于值的相等比较这非常适合用来表示状态。状态切换时我们会创建一个全新的状态对象而不是修改旧对象这避免了难以追踪的副作用。接下来是输入Input它代表了意图或事件。// IPlayerInput.cs public interface IPlayerInput : IInputIPlayerState { } // PlayerInput.cs public abstract record PlayerInput : IPlayerInput { public record MoveInput(Vector2 Direction) : PlayerInput; public record JumpInput : PlayerInput; public record AttackInput : PlayerInput; public record LandInput : PlayerInput; // 落地 public record TakeDamageInput(float Damage) : PlayerInput; }3.2 构建状态机逻辑Logic现在创建状态机的核心——PlayerLogic类。它继承自LogicBlockState, Input你需要重写GetInitialState和OnInput方法。// PlayerLogic.cs public class PlayerLogic : LogicBlockIPlayerState, IPlayerInput { // 1. 定义初始状态 public override IPlayerState GetInitialState() new PlayerState.Idle { LogicPosition Vector2.Zero, Health 100f }; // 2. 处理输入返回新状态 public override IPlayerState OnInput(IPlayerState state, IPlayerInput input) { // 使用 switch 表达式进行模式匹配非常清晰 return (state, input) switch { // 从任何状态都可以受到伤害 (_, PlayerInput.TakeDamageInput damage) state with { Health state.Health - damage.Damage }, // 闲置状态下收到移动输入 - 切换到行走状态 (PlayerState.Idle idle, PlayerInput.MoveInput move) new PlayerState.Walking { LogicPosition idle.LogicPosition, Health idle.Health, MoveDirection move.Direction }, // 行走状态下松开移动输入Direction为0 - 切换回闲置 (PlayerState.Walking walking, PlayerInput.MoveInput move) when move.Direction Vector2.Zero new PlayerState.Idle { LogicPosition walking.LogicPosition, Health walking.Health }, // 行走或闲置状态下收到跳跃输入 - 切换到跳跃状态 (PlayerState.Idle or PlayerState.Walking, PlayerInput.JumpInput) new PlayerState.Jumping { LogicPosition state.LogicPosition, Health state.Health, JumpVelocityY 10f, // 初始跳跃速度 JumpStartTime Time.GetTicksMsec() // 需要注入时间服务稍后讲 }, // 跳跃状态下落地 - 根据是否有移动输入决定是行走还是闲置 (PlayerState.Jumping jumping, PlayerInput.LandInput) { var newPos jumping.LogicPosition with { Y 0 }; // 假设地面Y0 // 这里需要判断是否在移动简化处理假设有水平速度就行走 return new PlayerState.Walking { LogicPosition newPos, Health jumping.Health, MoveDirection Vector2.Right }; }, // 默认情况输入在当前状态下不被处理返回原状态 _ state }; } }这个OnInput方法就是整个状态机的“大脑”。它接收当前状态和一个输入然后通过模式匹配决定下一个状态是什么。所有游戏逻辑的规则都集中在这里一目了然没有任何副作用比如直接播放动画、修改节点属性。逻辑变得极其纯粹且可测试。实操心得在编写OnInput时一个常见的坑是忘记处理所有可能的状态-输入组合。虽然最后的_ state兜底了但最好还是为每个重要的无效转换添加日志或断言便于调试。另外状态record的属性尽量设计得精简只包含真正影响逻辑的数据避免状态对象过于臃肿。3.3 在表现层监听与响应状态变化逻辑部分搞定了但它现在只是一个“孤岛”。我们需要在Godot的节点表现层里创建这个状态机并监听它的变化从而驱动动画、声音、节点位置等。首先我们需要一个能提供依赖比如时间服务的上下文。这就要用到Chickensoft的另一个核心依赖注入。我们稍后再详细配置容器这里先假设我们已经有一个可以获取ITimeService的依赖提供者。在表现层节点例如PlayerNode.cs中public partial class PlayerNode : Node2D { // 1. 声明对Logic的依赖通过依赖注入获得 [Dependency] public IPlayerLogic PlayerLogic DependOnIPlayerLogic(); [Dependency] public ITimeService TimeService DependOnITimeService(); // 2. 状态机实例和状态绑定 private PlayerLogic? _logic; private IPlayerState? _currentState; private bool _isJumping false; public override void _Ready() { // 3. 依赖注入会自动在此之后填充 PlayerLogic 和 TimeService // 4. 初始化状态机 _logic new PlayerLogic(); _currentState _logic.Value; // 获取初始状态 // 5. 订阅状态变化 _logic.StateChanged OnPlayerStateChanged; } private void OnPlayerStateChanged(IPlayerState newState) { _currentState newState; // 6. 根据新状态更新表现 UpdatePresentation(newState); } private void UpdatePresentation(IPlayerState state) { // 这是一个纯表现层更新不包含游戏规则 switch (state) { case PlayerState.Idle: _animationPlayer.Play(idle); break; case PlayerState.Walking walking: _animationPlayer.Play(walk); // 根据 walking.MoveDirection 更新Sprite朝向 _sprite.FlipH walking.MoveDirection.X 0; // 计算并更新节点的像素位置需要将逻辑坐标转换为像素坐标 Position walking.LogicPosition * PixelsPerUnit; break; case PlayerState.Jumping jumping: _animationPlayer.Play(jump); // 模拟跳跃物理仅表现真实物理应在逻辑层模拟或使用Godot物理 // 这里只是示例实际跳跃轨迹应由逻辑状态中的速度、时间计算得出 break; // ... 处理其他状态 } // 更新UI比如血条 _healthBar.Value state.Health; } // 7. 处理Godot输入转化为逻辑输入 public override void _Process(double delta) { if (_logic null || _currentState null) return; Vector2 moveInput Input.GetVector(move_left, move_right, move_up, move_down); if (moveInput ! Vector2.Zero) { _logic.Input(new PlayerInput.MoveInput(moveInput.Normalized())); } if (Input.IsActionJustPressed(jump)) { _logic.Input(new PlayerInput.JumpInput()); } if (Input.IsActionJustPressed(attack)) { _logic.Input(new PlayerInput.AttackInput()); } // 模拟逻辑更新例如跳跃状态下的位置更新 // 更复杂的逻辑更新可能需要在逻辑层有一个专门的“UpdateInput”或使用定时器 if (_currentState is PlayerState.Jumping jumping) { // 这里只是简单示例实际应将时间计算放在逻辑层 float elapsed (TimeService.GetTicksMsec() - jumping.JumpStartTime) / 1000f; float newY jumping.LogicPosition.Y jumping.JumpVelocityY * elapsed - 0.5f * 9.8f * elapsed * elapsed; // 创建一个新的“位置更新”输入或者有更好的方式。这引出了“副作用”和“服务”的概念。 } } }这里的关键是表现层只做两件事1) 将用户输入或游戏事件转化为逻辑输入_logic.Input(...)2) 监听逻辑状态变化并更新视觉/听觉表现UpdatePresentation。所有“能不能跳”、“跳多高”、“受伤扣多少血”的规则都在纯C#的PlayerLogic里。4. 依赖注入AutoInject深度集成现在来解决上面代码中留下的悬念[Dependency]属性是怎么工作的ITimeService从哪里来这就是AutoInject的舞台。4.1 理解依赖注入容器与生命周期Chickensoft的AutoInject提供了一个轻量级的依赖注入容器。核心概念是依赖提供者DependencyProvider一个全局或场景级的对象负责创建和管理各种服务、逻辑组件的实例。依赖Dependency被注入的对象通常是一个接口。生命周期实例是单例整个应用一个还是场景单例每个场景一个或者是每次请求都新建。我们的目标是让PlayerNode不需要知道PlayerLogic和ITimeService具体怎么来的只需要声明“我需要它们”容器就会在合适的时机通常是_Ready之前自动提供。4.2 配置全局依赖容器首先在src/DependencyInjection/下创建我们的容器配置类。// AppDependencyContainer.cs public class AppDependencyContainer : DependencyContainer { public override void Configure() { // 注册单例服务 AddSingletonITimeService, SystemTimeService(); // 实现一个基于系统时间的服务 AddSingletonIAudioService, GodotAudioService(); // 实现一个调用Godot AudioServer的服务 AddSingletonISaveGameService, JsonSaveGameService(); // 注册逻辑组件通常也是单例或根据场景创建 // IPlayerLogic 是一个接口PlayerLogic 是它的默认实现。 // 这里注册为工厂方法因为PlayerLogic可能需要参数如玩家ID或者我们想控制其生命周期。 AddScopedIPlayerLogic(provider { // 假设我们需要从某个上下文获取玩家ID var playerId player_1; // 实际中可能来自游戏设置或网络 return new PlayerLogic(/* 可以传入参数 */); }); // 注册其他逻辑... // AddScopedIEnemyLogic, EnemyLogic(); } }AddSingleton表示整个应用生命周期内只有一个实例。AddScoped表示在其作用域比如一个游戏关卡场景内是单例作用域结束则释放。这里我们将IPlayerLogic注册为Scoped意味着每个游戏会话或关卡会有独立的玩家逻辑实例。4.3 在Godot节点中启用依赖注入要让PlayerNode能自动接收到依赖我们需要做两件事让节点继承自AutoInjectNodeChickensoft提供了AutoInjectNode这个基类它内部集成了依赖注入的查找逻辑。public partial class PlayerNode : AutoInjectNode { // 改为继承自 AutoInjectNode // ... 其他代码不变 }在场景根节点设置依赖提供者通常我们会在游戏的入口场景如Main.tscn的根节点上挂一个脚本负责创建和设置全局的依赖提供者。// Main.cs (挂在Main场景根节点) public partial class Main : Node { private DependencyProvider _provider; public override void _Ready() { // 创建并配置容器 _provider new DependencyProvider(); _provider.AddContainer(new AppDependencyContainer()); _provider.Initialize(); // 将提供者设置为全局可访问通过一个静态类或Godot的Autoload // Chickensoft通常使用一个叫 Dependency 的静态类或类似机制。 // 这里假设我们有一个全局的访问点 AppDependencies.Provider _provider; // 然后才加载玩家场景 var playerScene GD.LoadPackedScene(res://src/Presentation/Player/PlayerScene.tscn); var playerInstance playerScene.InstantiatePlayerNode(); AddChild(playerInstance); } public override void _ExitTree() { _provider?.Dispose(); } }AutoInjectNode的魔法当PlayerNode被实例化并添加到场景树后在其_Ready方法被Godot调用之前AutoInjectNode的基类逻辑会先执行。它会查找当前节点或其父节点链上是否有设置了依赖提供者的节点通常就是Main节点然后根据[Dependency]属性从提供者那里解析出对应的实例并赋值给那些属性。所以在PlayerNode._Ready()中PlayerLogic和TimeService属性已经被自动赋值了你可以直接使用。注意事项依赖注入的属性 ([Dependency]) 必须是public且有get; set;访问器。AutoInject通过反射来设置它们。确保你的依赖如ITimeService已经在容器中正确注册否则在解析时会抛出异常。调试时如果发现依赖为null首先检查注册代码是否执行以及依赖的生命周期Scoped/Singleton是否符合预期。4.4 解决循环依赖与复杂对象构建在更复杂的游戏中你可能会遇到A依赖BB又依赖A的循环依赖问题。Chickensoft的容器在默认情况下不支持循环依赖这实际上是件好事它迫使你重新审视设计。通常的解决方案是引入第三方中介创建一个新的服务CA和B都依赖C由C来协调A和B的交互。使用接口与延迟加载将依赖改为接口并通过LazyT或工厂方法在需要时才获取。重组职责检查A和B的职责是否划分清晰也许有些功能应该移到另一个类中。对于需要复杂参数构建的对象比如需要从配置文件读取数据的敌人逻辑可以在注册时使用工厂方法AddScopedIEnemyLogic(provider { var configLoader provider.ResolveIConfigService(); var enemyConfig configLoader.LoadEnemyConfig(goblin); var audioService provider.ResolveIAudioService(); return new EnemyLogic(enemyConfig, audioService); });这样容器会自动将IConfigService和IAudioService解析出来作为参数传递给工厂方法。5. 状态管理与依赖注入的进阶应用与调试5.1 状态序列化与网络同步LogicBlocks的状态是不可变的record这带来了一个巨大优势序列化极其简单。由于状态只包含数据没有方法引用你可以轻松地使用System.Text.Json或Newtonsoft.Json将整个状态序列化为JSON字符串用于保存游戏或网络同步。// 保存状态 IPlayerState state _logic.Value; string json JsonSerializer.Serialize(state, new JsonSerializerOptions { WriteIndented true }); File.WriteAllText(save.json, json); // 加载状态 string loadedJson File.ReadAllText(save.json); var loadedState JsonSerializer.DeserializeIPlayerState(loadedJson); // 注意反序列化后需要手动设置回状态机LogicBlocks本身不直接提供“设置状态”的方法。 // 一种模式是将加载的状态视为一个特殊的“输入”触发一个状态恢复逻辑。 _logic.Input(new LoadStateInput(loadedState));对于网络游戏你可以定期将关键实体的状态序列化后发送给服务器或其他客户端实现状态同步。由于状态是不可变的你还可以配合操作转移Operational Transformation或状态差分State Diff算法来优化网络流量。5.2 单元测试与模拟这是采用Chickensoft架构后收益最大的地方。你的核心游戏逻辑PlayerLogic不依赖任何Godot API因此可以像测试普通C#类一样进行单元测试。[Test] public void Player_Jump_From_Idle_Transitions_To_Jumping_State() { // 1. Arrange var logic new PlayerLogic(); var initialState logic.Value; // 应该是Idle状态 // 2. Act logic.Input(new PlayerInput.JumpInput()); var newState logic.Value; // 3. Assert Assert.IsInstanceOfPlayerState.Jumping(newState); var jumpingState (PlayerState.Jumping)newState; Assert.AreEqual(10f, jumpingState.JumpVelocityY); // 验证跳跃初速度 }对于依赖的服务如ITimeService你可以在测试中注入一个模拟Mock对象完全控制其行为实现真正意义上的隔离测试。5.3 调试与可视化调试状态机时查看状态的历史变化非常有帮助。LogicBlocks内部维护了一个状态历史需要配置开启你可以订阅事件来记录或显示状态流。_logic.StateChanged (newState) { GD.Print($State changed to: {newState.GetType().Name}); // 可以将状态变化记录到一个列表用于调试界面 _stateHistory.Add((Time.GetTicksMsec(), newState)); };更高级的做法是创建一个调试UI实时显示当前状态、状态历史栈甚至能够回放状态变化这对于复现复杂Bug至关重要。5.4 性能考量与最佳实践状态对象大小record在每次状态变更时都会创建新对象。如果状态非常庞大包含大量数组或集合频繁变更可能导致GC压力。对于集合类数据考虑使用不可变集合如System.Collections.Immutable或采用更细粒度的状态拆分。依赖注入开销反射注入在启动时有一次性的开销对于性能关键的代码路径如每帧执行的_Process应避免在内部频繁解析依赖。最佳实践是在_Ready中通过[Dependency]解析并持有引用。信号Signals与事件Godot的信号系统与C#的事件系统可以共存。对于纯粹的表现层内部通信如一个UI按钮点击触发另一个UI动画继续使用Godot信号很方便。但对于从表现层到逻辑层的通信强烈建议统一通过LogicBlock.Input()方法进行保持架构清晰。6. 常见问题与排查技巧实录在实际项目中踩过一些坑这里总结一下问题1依赖注入的属性在_Ready中仍然是null。检查1确保你的节点继承自AutoInjectNode或AutoInjectControl等。检查2确保节点所在的场景树中某个父节点通常是根节点已经设置好了DependencyProvider并且该提供者已经Initialize()。检查3检查[Dependency]修饰的属性是否是public且有setter。检查4确认你尝试注册的类型如IPlayerLogic和属性声明的类型完全一致包括泛型参数。检查5依赖的生命周期。如果你注册的是Scoped但尝试在作用域外解析也会失败。问题2状态切换没有按预期发生。排查1在LogicBlock.OnInput方法中大量使用GD.Print或调试器断点打印当前状态和输入检查switch表达式是否进入了正确的分支。排查2检查你的输入PlayerInput是否被正确创建和传递。特别是使用Input.GetVector时确保输入映射在项目设置中已正确配置。排查3状态是不可变的。在OnInput中你必须返回一个新的状态对象。常见的错误是尝试修改传入的state参数然后返回它这是无效的。必须使用with表达式或new关键字创建新实例。问题3在状态处理中需要访问外部服务如时间、随机数但不想破坏逻辑层的纯洁性。解决方案这是依赖注入在逻辑层的用武之地。你的PlayerLogic构造函数可以接受这些服务接口。public class PlayerLogic : LogicBlock... { private readonly ITimeService _timeService; private readonly IRandomService _randomService; public PlayerLogic(ITimeService timeService, IRandomService randomService) { _timeService timeService; _randomService randomService; } // ... 在OnInput中使用 _timeService.GetTicksMsec() }然后在依赖容器中注册PlayerLogic时容器会自动解析ITimeService和IRandomService并注入。这样逻辑层依然不依赖Godot但可以访问外部能力并且保持了可测试性测试时可以注入模拟服务。问题4如何处理连续的状态变化比如跳跃过程中的每一帧位置更新方案A推荐在逻辑层内部使用一个“时钟”或“更新”输入。表现层每帧或在固定时间间隔向状态机发送一个UpdateInput(deltaTime)。状态机的OnInput方法处理这个输入根据当前状态如Jumping和经过的时间计算新的位置并返回一个新的Jumping状态。这保持了逻辑的纯粹性。方案B如果物理计算非常复杂可以借助Godot的物理引擎。这时跳跃状态可能只包含一个“正在跳跃”的标记而具体的垂直速度、位置计算由Godot的CharacterBody2D处理。逻辑层只负责触发跳跃和结束跳跃。这种混合模式需要仔细界定逻辑和表现的边界。问题5项目大了状态机OnInput方法变得非常庞大和复杂。重构策略使用分层状态机HFSM概念。LogicBlocks支持通过组合多个小的状态机来构建复杂行为。例如你可以有一个MovementLogic状态机处理移动相关状态Idle, Walking, Running一个CombatLogic状态机处理战斗状态Normal, Attacking, Blocking。然后一个顶层的PlayerLogic状态机包含这两个子状态机并协调它们之间的交互。这需要更深入的学习Chickensoft的LogicBlocks高级特性。从传统的Godot脚本模式切换到Chickensoft架构初期会有一定的学习和重构成本。但一旦适应你会发现代码的组织性、可读性、尤其是可测试性得到了巨大的提升。它迫使你思考“什么是核心逻辑”、“什么是表现”这种分离对于长期维护和团队协作来说价值是无法估量的。尤其是在开发涉及复杂状态如角色技能、UI流程、游戏模式切换的游戏时LogicBlocks提供了一种清晰、可靠的管理方式。而AutoInject则像一根结实的线将所有这些解耦的模块优雅地串联起来让整个项目结构既灵活又稳固。
返回列表