你在不同的Unity项目中,有多少次重复编写相同的系统代码?或者从旧项目中复制整个文件夹,结果却要花费数小时来修改引用关系、重新命名命名空间,并调整代码以使其适应略有不同的架构?
这种重复性行为不仅浪费时间,还会拖慢你的开发进度,还会给后续的项目维护带来麻烦。
本指南将向你介绍一组可重复使用的模块化Unity包,你可以将这些包添加到任何项目中,从而加快开发速度。你只需一次性构建这些包,然后通过Git将它们安装到需要的地方,而无需每次都重新实现相同的系统功能。
本文介绍了四个核心包:
-
com.core.initializer – 在运行时查找并初始化游戏控制器(采用MVC架构),从而实现启动流程和依赖关系的集中管理。
-
com.core.data – 使用MemoryPack二进制序列化技术处理本地数据(也可选择使用云存储),同时提供抽象层,使你可以轻松更换或扩展存储后端。
-
com.core.ui – 以统一的方式管理弹出窗口和全屏界面,这样你就可以在项目中显示对话框、面板和屏幕,而无需重复编写相同的逻辑代码。
-
com.core.dotween – 将DoTween动画引擎封装成Unity包,这样com.core.ui(以及其他包)就可以利用它来实现动画效果。
在本教程中,我们将一步步构建这四个包。初始化器包、数据包、UI包和DoTween包都配有详细的文档,因此你可以轻松跟随步骤进行学习。所有这些包也都已经上传到了GitHub上,链接可以在文末找到。
目录
你将学到什么
-
如何使用Package Manager创建Unity包
-
如何设置包的结构
-
如何为你的包构建集中的初始化流程
-
如何利用UniTask在Unity的主线程上实现异步初始化
-
数据包是如何使用
IDataProvider和MemoryPack来处理本地数据的保存/加载的 -
UI包是如何结合弹出窗口、屏幕以及DoTween动画来实现界面设计的
先决条件
在开始之前,请确保您已经具备以下条件:
-
已安装Unity(请使用与所需包兼容的长期支持版本。这些包适用于Unity 6000.3版本)
-
已安装Git
-
已安装Jetbrains Rider或Visual Studio Community
-
了解如何使用Unity游戏引擎
-
掌握C#语言以及async/await编程模式
您将使用Unity包管理器来创建这些包,然后通过Git将其上传到其他项目中并进行安装。
设置您的开发项目
如果您还没有Unity项目,请先创建一个。我们将使用这个项目作为测试和开发这些包的平台。需要注意的是,这个项目本身才是您的开发环境,而不是那些包。

包1:初始化器
第一个包是com.core.initializer。它会查找所有控制器,在第一个场景加载之前完成它们的异步初始化操作,之后让游戏的其他部分能够通过一个统一的接口来访问这些控制器。
com.core-initializer的作用
-
提前初始化:在
RuntimeInitializeLoadType.BeforeSceneLoad阶段执行初始化操作,因此所有控制器都会在第一个场景加载之前完成初始化。 -
统一访问接口:根据控制器的类型将它们存储起来,并提供类型安全的访问方法。
-
完成通知机制:提供了静态事件
ControllersInitialized以及UniTaskCompletionSource对象InitializationCompleted,这样您就可以确保所有控制器都完成初始化后再执行后续代码。
创建初始化器包
首先打开“窗口”→“包管理器”,然后点击“+”按钮选择“创建包”。
将这个包命名为com.core.initializer(或您喜欢的名称)。


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

您可以根据自己的需求编辑package.json文件以及汇编定义文件中的名称。此处没有列出所有可进行的修改内容,最终的包文件可以在GitHub上获取(链接在文末)。
添加 UniTask 依赖项
Unity 在主线程上运行,在这种环境下使用 C# Tasks 可能会遇到问题。例如,C# Tasks 并不了解 Unity 编辑器的播放状态,因此在退出播放模式后仍会继续执行。因此,你需要手动处理这些情况。正因如此,这个包在处理异步操作时使用了 UniTask 而不是 C# Tasks。
基于 Git 的依赖项可以在项目级别进行管理,但在包级别则不行。你可以通过 OpenUPM 添加 UniTask,请按照 手动安装步骤 进行操作。

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

"dependencies": {
"com.cysharp.unitask": "2.5.10"
}
同时还需要在 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
{
///
///
public static IEnumerable
{
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
}
}
在运行时使用反射会带来一定的开销。你可以在后期通过将反射操作提前在编辑器中完成,然后在运行时使用这些预处理过的结果来优化性能。对于 MonoBehaviours来说,这里使用了 FindObjectsByType 方法,这样做只是为了简化代码。在规模较大的项目中,你可以考虑使用依赖注入框架,比如 VContainer 或 Reflex。
实现 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
[RuntimeInitializeOnLoadMethod(Runtime InitializeLoadType.BeforeSceneLoad)]
public static async void Initialize()
{
var controllers = Creator.CreateInstancesOfType
controllers.AddRange(Creator.GetMonoControllers
Debug.Log("正在初始化控制器");
foreach (var controller in controllers)
{
Debug.Log($"
await controller.Initialize();
_controllers.Add(controller.GetType(), controller);
Debug.Log($"
}
Debug.Log("
ControllersInitialized?.Invoke();
InitializationCompleted?.TrySetResult();
}
public static T GetController
}
}
如何测试初始化器
-
创建一个实现
IController接口的类(或一个实现该接口的 MonoBehaviour)。 -
使用相应的设置逻辑来实现
Initialize()方法(你可以使用await和 UniTask)。 -
初始化完成后,可以通过以下方式获取控制器:
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接口并使用IDataProvider的DataController类(位于你的游戏程序中)。


所有的数据提供者都必须实现 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
public T Load
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
}
}
该控制器将所有逻辑委托给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
{
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
{
if (string.IsNullOrEmpty(key)) { Debug.LogWarning("[LocalDataProvider] 调用Load方法时,key参数为null或空字符串。"); return defaultValue; }
if (!ReadBytes(key, out byte[] bin)) return defaultValue;
try { return MemoryPackSerializer.Deserialize
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(例如通过Newtonsoft或PlayerPrefs来获取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”按钮即可生成汇编定义文件,这样其他插件就可以引用这些文件了。

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

在`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中加载预制资源:
-
UIParent:
Resources/UIPrefabs/UIParent -
Popups:
Resources/UIPrefabs/Popups/.prefab -
Screens:
Resources/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预制件包含一个 Canvas元素以及 UIParent 脚本:

不过这里也存在一些限制:首先,弹窗和屏幕必须保存在 Resources/UIPrefabs/Popups 和 Resources/UIPrefabs/Screens 目录下;其次,预制件的名称必须与其对应的脚本或类型名称相匹配(例如,如果脚本名为 TestPopup,那么预制件的名称也必须是 TestPopup)。
总结
在本文中,你搭建了一个 Unity 项目,并学习了如何创建四个可重用的插件包:
-
com.core.initializer – 你创建了这个插件包,添加了 UniTask 类型,定义了
IController接口,使用Creator辅助工具来查找并创建控制器(包括 MonoBehaviours),同时还实现了ControllerHandler类,以便在BeforeSceneLoad阶段执行初始化操作,并通过GetController方法暴露各种控制器。() -
com.core.data – 你使用了
IDataProvider和LocalDataProvider类,结合 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"
]
}
]
}
相关资源: