你在不同的Unity项目中,有多少次重复编写相同的系统代码?或者从旧项目中复制整个文件夹,结果却要花费数小时来修改引用关系、重新命名命名空间,并调整代码以使其适应略有不同的架构?

这种重复性行为不仅浪费时间,还会拖慢你的开发进度,还会给后续的项目维护带来麻烦。

本指南将向你介绍一组可重复使用的模块化Unity包,你可以将这些包添加到任何项目中,从而加快开发速度。你只需一次性构建这些包,然后通过Git将它们安装到需要的地方,而无需每次都重新实现相同的系统功能。

本文介绍了四个核心包:

  1. com.core.initializer – 在运行时查找并初始化游戏控制器(采用MVC架构),从而实现启动流程和依赖关系的集中管理。

  2. com.core.data – 使用MemoryPack二进制序列化技术处理本地数据(也可选择使用云存储),同时提供抽象层,使你可以轻松更换或扩展存储后端。

  3. com.core.ui – 以统一的方式管理弹出窗口和全屏界面,这样你就可以在项目中显示对话框、面板和屏幕,而无需重复编写相同的逻辑代码。

  4. com.core.dotween – 将DoTween动画引擎封装成Unity包,这样com.core.ui(以及其他包)就可以利用它来实现动画效果。

在本教程中,我们将一步步构建这四个包。初始化器包、数据包、UI包和DoTween包都配有详细的文档,因此你可以轻松跟随步骤进行学习。所有这些包也都已经上传到了GitHub上,链接可以在文末找到。

目录

你将学到什么

  • 如何使用Package Manager创建Unity包

  • 如何设置包的结构

  • 如何为你的包构建集中的初始化流程

  • 如何利用UniTask在Unity的主线程上实现异步初始化

  • 数据包是如何使用IDataProviderMemoryPack来处理本地数据的保存/加载的

  • UI包是如何结合弹出窗口、屏幕以及DoTween动画来实现界面设计的

先决条件

在开始之前,请确保您已经具备以下条件:

  • 已安装Unity(请使用与所需包兼容的长期支持版本。这些包适用于Unity 6000.3版本)

  • 已安装Git

  • 已安装Jetbrains Rider或Visual Studio Community

  • 了解如何使用Unity游戏引擎

  • 掌握C#语言以及async/await编程模式

您将使用Unity包管理器来创建这些包,然后通过Git将其上传到其他项目中并进行安装。

设置您的开发项目

如果您还没有Unity项目,请先创建一个。我们将使用这个项目作为测试和开发这些包的平台。需要注意的是,这个项目本身才是您的开发环境,而不是那些包。

通过Unity Hub创建新的Unity项目

包1:初始化器

第一个包是com.core.initializer。它会查找所有控制器,在第一个场景加载之前完成它们的异步初始化操作,之后让游戏的其他部分能够通过一个统一的接口来访问这些控制器。

com.core-initializer的作用

  • 提前初始化:在RuntimeInitializeLoadType.BeforeSceneLoad阶段执行初始化操作,因此所有控制器都会在第一个场景加载之前完成初始化。

  • 统一访问接口:根据控制器的类型将它们存储起来,并提供类型安全的访问方法。

  • 完成通知机制:提供了静态事件ControllersInitialized以及UniTaskCompletionSource对象InitializationCompleted,这样您就可以确保所有控制器都完成初始化后再执行后续代码。

创建初始化器包

首先打开“窗口”→“包管理器”,然后点击“+”按钮选择“创建包”。

将这个包命名为com.core.initializer(或您喜欢的名称)。

打开Unity包管理器
创建新包

Unity会自动生成这个包所需的基本文件。

示例包文件夹及文件结构

您可以根据自己的需求编辑package.json文件以及汇编定义文件中的名称。此处没有列出所有可进行的修改内容,最终的包文件可以在GitHub上获取(链接在文末)。

添加 UniTask 依赖项

Unity 在主线程上运行,在这种环境下使用 C# Tasks 可能会遇到问题。例如,C# Tasks 并不了解 Unity 编辑器的播放状态,因此在退出播放模式后仍会继续执行。因此,你需要手动处理这些情况。正因如此,这个包在处理异步操作时使用了 UniTask 而不是 C# Tasks。

基于 Git 的依赖项可以在项目级别进行管理,但在包级别则不行。你可以通过 OpenUPM 添加 UniTask,请按照 手动安装步骤 进行操作。

将 OpenUPM 和 UniTask 添加到相应的注册表中

在初始化包的 package.json 文件中添加 UniTask 作为依赖项:

初始化包的 package.json 文件

"dependencies": {
  "com.cysharp.unitask": "2.5.10"
}

同时还需要在 asmdef 文件中引用 UniTask。

在初始化包的 asmdef 文件中引用 UniTask

对于你后续创建的其他包,也需要遵循这种依赖关系结构。

实现包的结构

这个初始化程序采用了类似 MVC 的架构。控制器既负责初始化工作,也处理游戏逻辑。以后你也可以将初始化功能拆分为单独的服务,而将业务逻辑保留在控制器中(例如采用 MVCS 架构)。不过本教程为了简化讲解过程,仅使用了控制器这一结构。

目标结构如下:

  • 运行时/接口层/IController

  • 运行时/辅助工具层/Creator(用于基于反射创建实例)

  • 运行时层/ControllerHandler(负责协调初始化过程)

初始化包的最终文件结构

所有的控制器都实现了 IController 接口。Initialize 方法会返回一个 UniTask 对象,这样初始化操作就可以在主线程上异步进行。

using Cysharp.Threading.Tasks;

namespace com.core.initializer
{
    public interface IController
    {
        bool IsInitialized { get; }

        UniTask Initialize();
    }
}

创建 Creator 辅助工具类

<你需要找到所有实现了 `IController` 接口的类型,在运行时为这些类型创建实例;同时,还需要找到那些实现了 `IController` 接口的 `MonoBehaviour` 类型(因为这类对象无法通过反射机制来创建)。`Creator` 类能够完成这两项任务。>

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

namespace com.core.initializer
{
public static class Creator
{
///

/// 创建所有继承自 T 接口的类型的实例。
///

public static IEnumerable CreateInstancesOfType(params Type[] exceptTypes)
{
var interfaceType = typeof(T);

var result = AppDomain.CurrentDomain.GetAssemblies().SelectMany(x => x.GetTypes()).Where
(
x => interfaceType.IsAssignableFrom(x) &&
!x.IsInterface &&
!x.IsAbstract &&
exceptTypes.All(type => !x.IsSubclassOf(type) && x != type)
).Select(Activator.CreateInstance);

return result.Cast();
}

public static IEnumerable GetMonoControllers() => UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None).OfType();
}
}

在运行时使用反射会带来一定的开销。你可以在后期通过将反射操作提前在编辑器中完成,然后在运行时使用这些预处理过的结果来优化性能。对于 MonoBehaviours来说,这里使用了 FindObjectsByType 方法,这样做只是为了简化代码。在规模较大的项目中,你可以考虑使用依赖注入框架,比如 VContainerReflex

实现 ControllerHandler

ControllerHandler 类会使用 Creator 来收集所有的 IController 实例(包括通过反射生成的实例以及 MonoBehaviours),按顺序初始化这些实例,并提供一个事件以及一个 UniTaskCompletionSource,这样游戏的其他部分就可以等待初始化操作完成。

using System;
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace com.core.initializer
{
public class ControllerHandler
{
public static readonly UniTaskCompletionSource InitializationCompleted = new();

public static event Action ControllersInitialized;

private static Dictionary _controllers = new();

[RuntimeInitializeOnLoadMethod(Runtime InitializeLoadType.BeforeSceneLoad)]
public static async void Initialize()
{
var controllers = Creator.CreateInstancesOfType(typeof(MonoBehaviour)).ToList();
controllers.AddRange(Creator.GetMonoControllers();

Debug.Log("正在初始化控制器");

foreach (var controller in controllers)
{
Debug.Log($"正在初始化 {controller.GetType().Name}");
await controller.Initialize();
_controllers.Add(controller.GetType(), controller);
Debug.Log($"初始化完成:{controller.GetType().Name}");
}

Debug.Log("所有控制器均已初始化");
ControllersInitialized?.Invoke();
InitializationCompleted?.TrySetResult();
}

public static T GetController() where T : class, IController => _controllers[typeof(T)] as T;
}
}

如何测试初始化器

  1. 创建一个实现 IController 接口的类(或一个实现该接口的 MonoBehaviour)。

  2. 使用相应的设置逻辑来实现 Initialize() 方法(你可以使用 await 和 UniTask)。

  3. 初始化完成后,可以通过以下方式获取控制器:
    var myController = ControllerHandler.GetController();

你也可以订阅 ControllerHandlerControllersInitialized 事件,或者等待 ControllerHandler.InitializationCompleted.Task 完成执行,以便在所有控制器都准备好之后再运行代码。

不过这种方法也存在一些局限性。首先,处理不同控制器之间的依赖关系可能会比较困难;其次,也很容易出现循环依赖的问题。

如果你查看 GitHub仓库,会发现可能已经实现了某种依赖注入框架来解决这些限制。如果你需要这个功能,请务必提出问题。

包2:数据包

com.core.data 使用二进制序列化机制来实现数据的本地保存。该包采用了 MemoryPack 来进行快速的二进制序列化操作,并定义了 IDataProvider 接口,这样你就可以根据需要使用不同的数据提供者(无论是本地的、云端的还是混合型的)。

包依赖项: com.cysharp.memorypack(版本 1.10.0)。请通过 OpenUPM 将它添加到你的项目中,并在 com.core.data 的 package.json 文件及 asmdef 文件中指定其依赖关系。

创建一个新的包,将其命名为 com.core.data。其目录结构可以如下所示:

  • Runtime/Interface/IDataProvider

  • Runtime/Providers/LocalDataProvider

  • Runtime/ – 可选示例:实现 IController 接口并使用 IDataProviderDataController 类(位于你的游戏程序中)。

将 MemoryPack 添加到作用域注册表中
数据包的最终文件夹及文件结构

所有的数据提供者都必须实现 IDataProvider 接口。

using Cysharp.Threading.Tasks;

namespace com.core.data
{
    public interface IDataProvider
    {
        bool     IsInitialized { get; }
        void     Save(string key, T data);
        T        Load(string key, T defaultValue = default);
        bool     HasKey(string key);
        string[] GetKeys();
        UniTask  Delete(string key);
        UniTask  DeleteAll();
    }
}

在数据包中也需要使用UniTask。由于IController和IDataProvider都需要使用它,因此请将“com.cysharp.unitask”添加到package.json文件中。

以下是DataController的实现代码:

using System;
using System.Collections.Generic;
using System.Text;
using com.core.data;
using com.core.initializer;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace com.core.data
{
public class DataController : IController
{
private const string PLAYER_VERSION_KEY = "player-version-key";

private IDataProvider _provider;

public bool IsInitialized { get; private set; }

public UniTask Initialize()
{
_provider = new LocalDataProvider();
SaveUserVersion();
IsInitialized = true;
return UniTask_completedTask;
}

private void SaveUserVersion()
{
var versionHistory = Load(PLAYER_VERSION_KEY, new List();
var sb = new StringBuilder();
versionHistory.ForEach(vh => sb.Append(vh + Environment.NewLine));
Debug.Log($"[DataController] 玩家版本历史记录: {sb});");

if (!versionHistory.Contains(Application.version))
{
Debug.Log($"[DataController] 玩家的当前版本为: {Application.version};")
versionHistory.Add(Application VERSION);
Save(PLAYER_VERSION_KEY, versionHistory);
}
}

public void Save(string key, T data) => _provider.Save(key, data);

public T Load(string key, T defaultValue = default) => _provider.Load(key, defaultValue);

public bool HasKey(string key) => _provider.HasKey(key);
public string[] GetKeys() => _provider.GetKeys();
public UniTask Delete(string key) => _provider.Delete(key);
public UniTask DeleteAll() => _provider.DeleteAll();
public List GetVersionHistory() => Load(PLAYER_VERSION_KEY, new List();
}
}

该控制器将所有逻辑委托给LocalDataProvider,后者会使用MemoryPack对数据进行处理,并将这些字节写入位于Application.persistentDataPath路径下的“Data”文件夹中。此外,该控制器还会记录用户的应用程序版本历史记录,这些信息对于提示用户进行升级或阻止使用旧版本非常有用。

以下是LocalDataProvider的完整实现代码:

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Cysharp.Threading.Tasks;
using MemoryPack;
using UnityEngine;

namespace com.core.data
{
public class LocalDataProvider : IDataProvider
{
private const string StorageFolderName = "Data";
private readonly string _storagePath;

private static readonly UTF8Encoding KeyEncoding = new false;

public bool IsInitialized => true;

public LocalDataProvider()
{
_storagePath = Path.Combine(Application.persistentDataPath, StorageFolderName);
EnsureStorageDirectoryExists();
}

public void Save(string key, T data)
{
if (string.IsNullOrEmpty(key)) { Debug.LogWarning("[LocalDataProvider] 调用Save方法时,key参数为null或空字符串。"); return; }
if (data == null) { Debug.LogWarning("[LocalDataProvider] 调用Save方法时,data参数为null。"); Delete(key).Forget(); return; }
try
{
byte[] bin = MemoryPackSerializer.Serialize(data);
WriteBytes(key, bin);
}
catch (Exception ex) { Debug.LogError($"[LocalDataProvider] 保存数据失败,键为'{key}': {ex.Message}"); throw; }
}

public T Load(string key, T defaultValue = default)
{
if (string.IsNullOrEmpty(key)) { Debug.LogWarning("[LocalDataProvider] 调用Load方法时,key参数为null或空字符串。"); return defaultValue; }
if (!ReadBytes(key, out byte[] bin)) return defaultValue;
try { return MemoryPackSerializer.Deserialize(bin) ?? default; }
catch (Exception ex) { Debug.LogError($"[LocalDataProvider] 读取数据失败,键为'{key}': {ex.Message}"); return default; }
}

public bool HasKey(string key) => !string.IsNullOrEmpty(key) && File.Exists(GetFilePath(key));

public UniTask Delete(string key)
{
if (string.IsNullOrEmpty(key)) return UniTask_completedTask;
try { var filePath = GetFilePath(key); if (File Exists(filePath)) File.Delete(filePath); }
catch (Exception ex) { Debug.LogError($"[LocalDataProvider] 删除文件失败,键为'{key}': {ex.Message}"); }
return UniTaskCompletedTask;
}

public UniTask DeleteAll()
{
try
{
if (!Directory.Exists(_storagePath)) return UniTask_completedTask;
foreach (string file in Directory.GetFiles(_storagePath))
{
try { File.Delete(file); }
catch (Exception ex) { Debug.LogWarning($"[LocalDataProvider] 无法删除文件 '{file}': {ex.Message}"); }
}
}
catch (Exception ex) { Debug.LogError($"[LocalDataProvider] 删除所有文件失败:{ex.Message}"); }
return UniTaskCompletedTask;
}

public string[] GetKeys()
{
if (!Directory.Exists(_storagePath)) return Array.Empty();
var keys = new List();
foreach (string file in Directory.GetFiles(_storagePath))
{
try { string key = DecodeKeyFromFileName(Path.GetFileName(file)); if (key != null) keys.Add(key); }
catch { Debug.LogWarning($"[LocalDataProvider] 无法读取文件 '{file}'."); }
}
return keys.ToArray();
}

private void EnsureStorageDirectoryExists() {
if (!Directory.Exists(_storagePath)) Directory.CreateDirectory(_storagePath);
}

private void WriteBytes(string key, byte[] bin) {
EnsureStorageDirectory Exists();
File.WriteAllBytes(GetFilePath(key), bin);
}

private bool ReadBytes(string key, out byte[] bin)
{
string filePath = GetFilePath(key);
if (!File.Exists(filePath)) { bin = null; return false; }
bin = File.ReadAllBytes(filePath);
return true;
}

private string GetFilePath(string key) => Path.Combine(_storagePath, EncodeKeyToFileName(key));

private static string EncodeKeyToFileName(string key)
{
byte[] bytes = KeyEncoding.GetBytes(key);
string base64 = Convert.ToBase64String(bytes);
return base64.Replace('+', '-').Replace('/', '_');
}

private static string DecodeKeyFromFileName(string fileName)
{
try {
string base64 = fileName.Replace('-', '+').Replace('_', '/');
return KeyEncoding.GetString(Convert.FromBase64String(base64)); }
catch { return null; }
}
}
}

你可以使用另一个IDataProvider(例如通过NewtonsoftPlayerPrefs来获取JSON数据),并将其替换到你的DataController中。

在实际开发中,你可以同时使用本地存储和云存储服务,并根据玩家的在线状态来同步数据。com.core.data的GitHub仓库可能会更新相关功能,以支持云存储和数据同步。如果你需要这些功能,可以提交问题请求帮助。

包3:com.core.dotween

com.core.dotween是一个封装了DOTween技术的Unity插件包。DOTween是一款被广泛使用的、适用于生产环境的动画引擎;而com.core.ui插件则利用它来实现弹出窗口的显示/隐藏动画效果。

由于DOTween并不包含在OpenUPM项目中,因此你需要从Unity Asset Store下载它,并将其导入到你的项目中(通常放在Assets/Plugins目录下)。

导入完成后,系统会自动弹出设置窗口,点击“Create ASMDEF”按钮即可生成汇编定义文件,这样其他插件就可以引用这些文件了。

1d2a1ea1-1cc4-4bc0-95b8-5e23d22d3f17

接下来,创建一个新的插件包(例如com.core.dotween),然后将Demigiant文件夹从Assets/Plugins目录复制到新插件包的相应目录中,这样com.core.ui插件就可以通过Package Manager或Git来依赖com.core.dotween了。

Dotween插件包的结构

完成上述步骤后,需要在com.core.ui插件的asmdef文件中引用com.core.dotween;你可以在创建UI插件包时进行这些设置,这样在BasePopup及其他UI脚本中就可以使用DGTweening命名空间了。

com.core.dotween本身没有外部依赖项,它只是简单地封装了DOTween技术而已。

包4:UI插件包

com.core.ui插件负责管理弹出窗口及全屏界面的显示逻辑。它包含以下内容:

  • 弹出窗口——包括模态和非模态对话框、警告框以及表单等,这些组件都使用统一的API进行开发,因此无需在每个界面中重复编写相同的代码。弹出窗口会按堆叠顺序关闭,最顶层的窗口会首先被关闭。

  • 全屏界面——用于显示加载界面、主菜单等内容。同一时间内只会有一个全屏界面处于活动状态,切换界面时当前界面会被隐藏,新的界面会替换它。

创建一个名为com.core.ui的新插件包,其目录结构可以如下所示:

  • Runtime/Interface/——IUI

  • Runtime/UIs/——BasePopup, BaseScreen

  • Runtime/——UIController, UIParent

  • Runtime/Resources/UIPrefabs/——UIParent预制件,以及Popups和Screens子文件夹

UI包文件夹及文件

在`package.json`文件以及`.asmdef`文件中,将`com.core.dotween`、`com.core.initializer`和`com.cysharp.unitask`添加为依赖项。

弹窗的显示方式类似于堆叠:每个弹窗都会显示在前一个弹窗的上方,并且会从上到下依次关闭。屏幕系统采用单屏激活机制:同一时间内只会有一个屏幕处于可见状态。当显示另一个屏幕时,`UIController`会隐藏当前正在显示的屏幕。`UIController`是管理所有屏幕和弹窗的核心组件,它负责处理堆叠顺序、缓存机制以及背景显示等相关功能。

IUI接口

`BasePopup`和`BaseScreen`都实现了`IUI`接口:

using System;
using Cysharp.Threading.Tasks;

namespace com.core.ui
{
    public interface IUI
    {
        event Action Showed;
        event Action Hidden;

        UniTask ShowAsync();
        UniTask HideAsync();
    }
}

`IUI`是一个非常基础的接口,它包含了两个异步的`Show/Hide`方法,以及这两个方法执行完成时会触发的两个事件。

BasePopup

`BasePopup`使用`DoTween`来实现显示/隐藏时的缩放动画效果,并提供了相应的事件以及可重写的动画处理方法:

using System;
using Cysharp.Threading.Tasks;
using DG Tweening;
using UnityEngine;

namespace com.core.ui
{
    public abstract class BasePopup : MonoBehaviour, IUI
    {
        public event Action StartedShowing;
        public event Action Showed;
        public event Action Hidden;

        [SerializeField] private float openingTime = 0.35f;
        [SerializeField] private float closingTime = 0.25f;
        [SerializeField] private Vector3 initialScale = new(0.75f, 0.75f, 0.75f);
        [SerializeField] private Ease openingEase = Ease.OutBack;
        [SerializeField] private Ease closingEase = Ease.InBack;

        protected virtual Tweener PlayShowAnimation(Action completedCallback) => 
            transform.DOScale(Vector3.one, openingTime).SetEase(openingEase).SetUpdate(true).OnComplete(() => 
                completedCallback?.Invoke());

        protected virtual Tweener PlayHideAnimation(Action completedCallback) => 
            transform.DOScale(initialScale, closingTime).SetEase(closingEase).SetUpdate(true).OnComplete(() => 
                completedCallback?.Invoke());

        public UniTask ShowAsync()
        {
            StartedShowing?.Invoke(this);
            transform.SetAsLastSibling();
            var utcs = new UniTaskCompletionSource();
            transform.localScale = initialScale;
            gameObject.SetActive(true);

            PlayShowAnimation(() => OnAnimationShowed(utcs));
            return utcs.Task;
        }

        public void Show() => ShowAsync().Forget();

        public UniTask HideAsync()
        {
            var utcs = new UniTaskCompletionSource();
            PlayHideAnimation(() => OnAnimationCompleted(utcs));
            return utcs.Task;
        }

        private void OnAnimationShowed(UniTaskCompletionSource utcs)
        {
            Showed?.Invoke(this);
            utcs.TrySetResult();
        }

        private void OnAnimationCompleted(UniTaskCompletionSource utcs)
        {
            transform.SetAsFirstSibling();
            gameObject.SetActive(false);
            Hidden?.Invoke(this);
            utcs.TrySetResult();
        }

        public void Hide() => HideAsync().Forget();
    }
}

你可以看到,DoTween是这个类中的核心组件,许多参数都是可以配置的,比如显示/隐藏的时间以及过渡效果。请注意,是如何利用DoTween的完成回调来触发“Showed”和“Hidden”事件的。

这里还有一个重要的点,那就是在动画中使用SetUpdate(true)这一设置。这样就可以让动画忽略时间轴的限制。我发现,在大多数情况下,让动画忽略时间轴确实是个不错的选择,但如果你不希望这样做,也可以随时进行调整。

BaseScreen

BaseScreen的作用仅仅是启用或禁用相应的GameObject,并触发相应的事件。它本身并不包含任何动画效果:

using System;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace com.core.ui
{
    public abstract class BaseScreen : MonoBehaviour, IUI
    {
        public event Action Showed;
        public event Action Hidden;

        public virtual UniTask ShowAsync()
        {
            gameObject.SetActive(true);
            Showed?.Invoke(this);
            return UniTask_completedTask;
        }

        public UniTask HideAsync()
        {
            gameObject.SetActive(false);
            Hidden?.Invoke(this);
            return UniTask_completedTask;
        }
    }
}

你会注意到,BaseScreen并不需要使用UniTask,因为它并没有执行任何异步操作。不过,使用UniTask也不会带来任何开销,而且如果我们以后需要在某些屏幕上添加动画效果(比如弹出窗口),这样的准备也是很有必要的。

UIController

UIController实现了IController接口,从Resources中加载UIParent实例,并提供了用于推送/弹出弹出窗口以及显示/隐藏屏幕的API。它还会按类型缓存弹出窗口和屏幕实例,并从Resources中加载预制资源:

  • UIParentResources/UIPrefabs/UIParent

  • PopupsResources/UIPrefabs/Popups/.prefab

  • ScreensResources/UIPrefabs/Screens/.prefab

using System;
using System.Collections.Generic;
using com.core.initializer;
using Cysharp.Threading.Tasks;
using UnityEngine;
using Object = UnityEngine.Object;

namespace com.core.ui
{
    public class UIController : IController
    {
        private const string UI_PARENT_PATH = "UIPrefabs/UIParent";
        private const string POPUPS-resources = "UIPrefabs/Popups";
        private const string SCREENS_resources = "UIPrefabs/Screens";

        private readonly Stack _popupStack = new();
        private readonly Dictionary _popupCache = new();
        private readonly Dictionary>Type, BaseScreen> _screenCache = new();

        private BaseScreen _currentScreen;
        private UIParent _uiParent;

        public bool IsInitialized { get; private set; }

        public UniTask Initialize()
        {
            var uiParentPrefab = Resources.Load(UI_PARENT_PATH);
            if (uiParentPrefab == null)
            {
                Debug.LogError($"[UIController] 在{UI_parent_PATH}处未找到UIParent预制资源。请确认该资源存在于Resources文件夹中。」);
                return UniTask_completedTask;
            }

            _uiParent = Object.Instantiate(uiParentPrefab);
            _uiParent.name = "UIParent";

            Object.DontDestroyOnLoad(_uiParent.gameObject);

            IsInitialized = true;
            return UniTask CompletedTask;
        }

        public async UniTask PushPopupAsync() where TPopup : BasePopup
        {
            var popup = GetOrCreatePopup();
            _popupStack.Push(popup);
            var popupTask = popup.ShowAsync();
            UpdateBackgroundForTopPopup();
            await popupTask;
            return popup;
        }

        public void PushPopup() where TPopup : BasePopup => PushPopupAsync().Forget();

        public async UniTask PopPopupAsync()
        {
            if (_popupStack.Count == 0)
            {
                Debug.LogWarning("[UIController] 调用了PopPopup方法,但弹出窗口堆栈为空。");
                return;
            }

            var top = _popupStack.Pop();
            await top HideAsync();
            UpdateBackgroundForTopPopup();
        }

        public void PopPopup() => PushPopupAsync().Forget();

        public BasePopup PeekPopup() => _popupStack.Count > 0 ? _popupStack.Peek() : null;

        public async UniTask ShowScreenAsync() where TScreen : BaseScreen
        {
            var screen = GetOrCreateScreen();

            if (_currentScreen == screen && screen.gameObject.activeSelf) return screen;
            if (_currentScreen != null && _currentScreen != screen && _currentScreengameObject.activeSelf) await _currentScreen HideAsync();

            _currentScreen = screen;
            await screen.ShowAsync();
            return screen;
        }

        public async UniTask HideScreenAsync() where TScreen : BaseScreen
        {
            var type = typeof(TScreen);
            if (!_screenCache.TryGetValue(type, out var screen))
            {
                Debug.LogWarning($"[UIController] 调用了HideScreenAsync<{type.Name}>方法,但找不到对应的屏幕实例(缓存中不存在)。");
                return;
            }

            await screen HideAsync();

            if (_currentScreen == screen) _currentScreen = null;
        }

        public void ShowScreen() where TScreen : BaseScreen => ShowScreenAsync().Forget();
        public void HideScreen() where TScreen : BaseScreen => HideScreenAsync.Forget();

        private TPopup GetOrCreatePopup() where TPopup : BasePopup
        {
            var type = typeof(TPopup);
            if (_popupCache.TryGetValue(type, out var cached) && cached != null) return (TPopup)cached;

            var prefab = LoadPopupPrefab();
            if (prefab == null)
            {
                Debug.LogError($"[UIController] 未找到类型为{type.Name}的弹出窗口预制资源。请确认该资源存在于Resources文件夹中。」);
                return null;
            }

            var instance = UnityEngine.Object.Instantiate(prefab, _uiParent PopupParent);
            instance.gameObject.SetActive(false);

            var popup = instance.GetComponent();
            _popupCache[type] = popup;

            return popup;
        }

        private TScreen Get>CreateScreen() where TScreen : BaseScreen
        {
            var type = typeof(TScreen);
            if (_screenCache.TryGetValue(type, out var cached) && cached != null) return (TScreen)cached;

            var prefab = LoadScreenPrefab();
            if (prefab == null)
            {
                Debug.LogError($"未找到类型为{type.Name}的屏幕预制资源。请确认该资源存在于Resources文件夹中。」);
                return null;
            }

            var instance = UnityEngine.Object.Instantiate(prefab, _uiParent.ScreenParent);
            instance.gameObject.SetActive(false);

            var screen = instance.GetComponent();
            _screenCache[type] = screen;

            return screen;
        }

        private T Popup LoadPopupPrefab() where TPopup : BasePopup => LoadViewPrefab(POPUPS-resources);
        private TScreen LoadScreenPrefab() where TScreen : BaseScreen => LoadViewPrefab(SCREENS_resources);

        private static T LoadViewPrefab(string resourcesPath) where T : MonoBehaviour
        {
            var type = typeof(T);
            var path =($"{resourcesPath}/{type.Name}";
            var go = Resources.Load(path);
            return go != null ? go.GetComponent() : null;
        }

        private void UpdateBackgroundForTopPopup()
        {
            var backgroundGO = _uiParent.BackgroundGO;
            if (backgroundGO == null) return;

            if (_popupStack.Count > 0)
            {
                var topPopup = _popupStack.Peek();
                if (topPopup != null && topPopup.gameObject.activeSelf)
                {
                    // 将背景元素放置在最顶层的弹出窗口之后
                    var topPopupIndex = topPopup.transform.GetSiblingIndex();
                    backgroundGO.SetActive(true);
                    backgroundGO.transform.SetSiblingIndex(Mathf.Max(0, topPopupIndex - 1));
                }
                else
                {
                    // 如果最顶层的弹出窗口处于非活动状态,就隐藏背景元素
                    backgroundGO.SetActive(false);
                }
            }
            else
            {
                // 如果弹出窗口堆栈为空,就隐藏背景元素
                backgroundGO.SetActive(false);
            }
        }
    }
}

使用方法:

var ui = ControllerHandler.GetController(); 
await ui.PushPopupAsync(); 
await ui.PopPopupAsync(); 
await ui.ShowScreenAsync(); 
await ui HideScreenAsync(); 

UIController还会更新背景 GameObject,使其显示在顶部弹窗的下方。

它使用 GetOrCreatePopup() / Get>CreateScreen() 方法从 Resources/UIPrefabs/Popups/Resources/UIPrefabs/Screens/ 目录中加载并缓存预制资源,然后将弹窗添加到堆栈中,最后通过异步方法来显示或隐藏屏幕。

UIParent 是一个 MonoBehaviour,它包含了对 ScreenParent、PopupParent 以及 BackgroundGO 的引用:

using UnityEngine;

namespace com.core.ui
{
    public class UIParent : MonoBehaviour
    {
        [SerializeField] private Transform screensParent;
        [SerializeField] private Transform popupsParent;
        [SerializeField] private GameObject backgroundGO;

        public Transform ScreenParent => screensParent;
        public Transform PopupParent => popupsParent;
        public GameObject BackgroundGO => backgroundGO;
    }
}

UIParent预制组件的层次结构

UIParent预制件包含一个 Canvas元素以及 UIParent 脚本:

UIParent预制件的组件结构

不过这里也存在一些限制:首先,弹窗和屏幕必须保存在 Resources/UIPrefabs/Popups 和 Resources/UIPrefabs/Screens 目录下;其次,预制件的名称必须与其对应的脚本或类型名称相匹配(例如,如果脚本名为 TestPopup,那么预制件的名称也必须是 TestPopup)。

总结

在本文中,你搭建了一个 Unity 项目,并学习了如何创建四个可重用的插件包:

  • com.core.initializer – 你创建了这个插件包,添加了 UniTask 类型,定义了 IController 接口,使用 Creator 辅助工具来查找并创建控制器(包括 MonoBehaviours),同时还实现了 ControllerHandler 类,以便在 BeforeSceneLoad 阶段执行初始化操作,并通过 GetController() 方法暴露各种控制器。

  • com.core.data – 你使用了 IDataProviderLocalDataProvider 类,结合 MemoryPack 实现了二进制数据的本地保存/加载功能;同时,还创建了实现 IController 接口的 DataController 类来管理数据版本历史。

  • com.core.dotween – 你将 DoTween 资源封装成了一个插件包,这样其他插件包(如 com.core.ui)就可以利用它来处理动画效果了。

  • com.core.ui – 你创建了一个模块化的 UI 插件包,可以在游戏中用来管理弹窗和全尺寸屏幕界面;未来还可以在此基础上添加其他类型的 UI 元素。

将这些组件作为独立的包来开发,可以使你的代码结构更加模块化,从而加快各个项目的开发进度。你可以通过 Git 将这些包安装到任何 Unity 项目中。

以下是一个示例 manifest.json 文件,用于配置这些组件的安装信息:

{
  "dependencies"    : {
    "com.core.data"       : "https://github.com/TalhaCagatay/com.core.data.git#v0.1.0",
    "com.core.initializer": "https://github.com/TalhaCagatay/com.core-initializer.git#v0.1.0",
    "com.core.dotween"    : "https://github.com/TalhaCagatay/com.core.dotween.git#v0.1.0",
    "com.core.ui"         : "https://github.com/TalhaCagatay/com.core.ui.git#v0.1.0"
  },
  "scopedRegistries": [
    {
      "name"  : "package.openupm.com",
      "url"   : "https://package.openupm.com",
      "scopes": [
        "com.cysharp.unitask",
        "com.cysharp.memorypack"
      ]
    }
  ]
}

相关资源:

  • com.core.initializer 的官方仓库位于 GitHub

  • com.core.data 的官方仓库位于 GitHub

  • com.core.dotween 的官方仓库位于 GitHub

  • com.core.ui 的官方仓库位于 GitHub

  • 我开发的一款示例游戏:点击此处查看。这款游戏中也运用了许多与本文中介绍的类似的模块化设计系统。

Comments are closed.