大家好,我是 Dmitrii Ivashchenko,我是 MY.GAMES 的一名软件工程师。在本文中,我们将讨论基于元素的状态和标记在 Unity 中开发用户界面。
首先,应该注意的是,我们将在Unity UI (uGUI) 技术的上下文中进行讨论,根据文档,该技术仍然推荐用于运行时。所描述的方法不适用于UI Toolkit 、 IMGUI或其他 UI 构建系统。
大多数情况下,在Unity项目中,您会遇到基于继承自MonoBehaviour
的 View 类构建的 UI 实现,并散布着大量SerializeField
字段。这种方法提供了对 UI 行为的完全控制,但也需要在 View 和 Presenter 级别编写大量代码(取决于所使用的架构)。
通常,随着项目开发的继续,这些类会膨胀到令人难以置信的大小,并且 GameObject 本身的组件被大量指向内部对象的链接所覆盖:
像这样修改组件也很不愉快:要在类中获取对新元素的引用,您需要添加SerializeField
,重新编译代码,在预制组件中找到新字段,并将必要的对象拖入其中。随着项目的增长,编译时间、字段数量、组织预制件的复杂度也依次增加。
结果,我们最终得到了庞大且重载的MonoBehaviour
子类(或大量的小子类,具体取决于您的偏好)。
还值得考虑的是,对此类 UI 的行为进行的任何更改都是程序员的一项任务,该任务伴随着所有相关成本:代码审查、解决合并冲突、代码覆盖测试等。
SerializeField
,然后编写大量代码使其按要求工作.自然地,只有程序员才能处理这个问题,而且实施结果是冗长、昂贵且超级高效的(通常比任何人都注意到的要高效得多)。
现在我们已经强调了使用 uGUI 的一些困难,我想谈谈解决这个问题的不同方法。
在我的一个宠物项目中,我开发了一个用于在 Unity 中进行结构化 UI 开发的库。后来,我和我的团队在生产环境中对其进行了测试,我们对结果很满意。
该库的关键元素是StatefulComponent
组件。该组件放置在每个屏幕的根 GameObject 上,并包含对内部元素的所有必要引用,分布在选项卡中:
每个链接都根据其角色命名。从代码的角度来看,角色集是一个常规的enum
。为每种类型的 UI 元素(按钮、图像、文本等)准备了单独的角色集:
public enum ButtonRole { ... } public enum ImageRole { ... } public enum TextRole { ... } ...
角色直接从组件生成,无需手动编辑enum
。创建角色时也不需要等待重新编译,因为这些enum
元素可以在创建后立即使用。
为了简化合并冲突,枚举值是根据元素的名称计算的:
[StatefulUI.Runtime.RoleAttributes.ButtonRoleAttribute] public enum ButtonRole { Unknown = 0, Start = -1436209294, // -1436209294 == "Start".GetHashCode() Settings = 681682073, Close = -1261564850, Quests = 1375430261, }
如果您和您的同事碰巧同时为不同分支中的按钮创建新角色,这可以让您避免破坏预制件中的序列化值。
每种类型的 UI 元素(按钮、文本、图像)都位于其自己的选项卡上:
通过使用角色,实现了预制件内部所有元素的完整标记。不再需要多组SerializeField
来访问图像和文本,只需引用一个StatefulComponent
并了解所需图像的作用即可,例如,替换其 sprite。
UnityEngine.UI.Text
和TextMeshProUGUI
UnityEngine.UI.InputField
和TMP_InputField
有相应的方法来处理带注释的对象。在代码中,您可以使用对StatefulComponent
引用或从StatefulView
继承类:
public class ExamplePresenter { private StatefulComponent _view; public void OnOpen() { _view.GetButton(ButtonRole.Settings).onClick.AddListener(OnSettingsClicked); _view.GetButton(ButtonRole.Close).onClick.AddListener(OnCloseClicked); _view.GetSlider(SliderRole.Volume).onValueChanged.AddListener(OnVolumeChanged); } } public class ExampleScreen : StatefulView { private void Start() { SetText(TextRole.Title, "Hello World"); SetTextValues(TextRole.Timer, hours, minutes, seconds); SetImage(ImageRole.UserAvatar, avatarSprite); } }
带有文本的选项卡,除了角色和对象链接之外,还包含以下列:
该库不包含用于处理翻译的内置子系统。要连接本地化系统,您需要创建ILocalizationProvider
接口的实现。例如,这可以基于您的后端、ScriptableObjects 或 Google 表格构建。
public class HardcodeLocalizationProvider : ILocalizationProvider { private Dictionary<string, string> _dictionary = new Dictionary<string, string> { { "timer" , "{0}h {1}m {2}s" }, { "title" , "Título do Jogo" }, { "description" , "Descrição longa do jogo" }, }; public string GetPhrase(string key, string defaultValue) { return _dictionary.TryGetValue(key, out var value) ? value : defaultValue; } }
通过单击“复制本地化”按钮,代码和值列的内容将以适合粘贴到 Google 表格中的格式复制到剪贴板。
通常,为了便于重用,UI 的单独部分被提取到单独的预制件中。 StatefulComponent
还允许我们创建组件的层次结构,其中每个组件仅使用其自己的子界面元素。
在内部组件选项卡上,您可以将角色分配给内部组件:
配置角色可以在代码中使用,类似于其他类型的元素:
var header = GetInnerComponent(InnerComponentRole.Header); header.GetButton(ButtonRole.Close).onClick.AddListener(OnCloseClicked); header.SetText(TextRole.Title, "Header Title"); var footer = GetInnerComponent(InnerComponentRole.Footer); footer.GetButton(ButtonRole.Continue).onClick.AddListener(OnContinueClicked); footer.SetText(TextRole.Message, "Footer Message");
要创建类似元素的列表,您可以使用ContainerView
组件。您需要指定用于实例化的预制件和根对象(可选)。在编辑模式下,您可以使用StatefulComponent
添加和删除元素:
使用StatefulComponent
标记实例化预制件的内容很方便。在运行时,您可以使用方法AddInstance<T>
、 AddStatefulComponent
或FillWithItems
来填充容器:
var container = GetContainer(ContainerRole.Players); container.Clear(); container.FillWithItems(_player, (StatefulComponent view, PlayerData data) => { view.SetText(TextRole.Name, data.Name); view.SetText(TextRole.Level, data.Level); view.SetImage(ImageRole.Avatar, data.Avatar); });
如果标准的Object.Instantiate()
不适合您创建对象,您可以覆盖此行为,例如,使用 Zenject 进行实例化:
StatefulUiManager.Instance.CustomInstantiateMethod = prefab => { return _diContainer.InstantiatePrefab(prefab); };
内部组件和容器分别为StatefulComponent
提供静态和动态嵌套。
我们已经考虑了预制件、本地化和实例化的标记。现在是时候进入最有趣的部分了——开发基于状态的 UI。
我们将状态的概念视为对预制件的一组命名更改。本例中的名称是StateRole
枚举中的角色,预制件的更改示例可以是:
可以在“状态”选项卡上配置一组更改(状态描述)。可以直接从检查器应用已配置的状态:
可以使用SetState
方法从代码应用已配置的状态:
switch (colorScheme) { case ColorScheme.Orange: SetState(StateRole.Orange); break; case ColorScheme.Red: SetState(StateRole.Red); break; case ColorScheme.Purple: SetState(StateRole.Purple); break; }
在“工具”选项卡上,启用“启用时应用初始状态”参数时,您可以配置将在对象实例化时立即应用的状态。
使用状态可以显着减少 View 类级别所需的代码量。简单地将屏幕的每个状态描述为StatefulComponent
中的一组更改,并根据游戏情况从代码中应用必要的状态。
实际上,开发基于状态的 UI 非常方便。如此之多,以至于随着时间的推移,它会导致另一个问题——随着项目的发展,单个窗口的状态列表可能会增长到无法控制的长度,因此变得难以导航。此外,有些状态仅在某些其他状态的上下文中才有意义。为了解决这个问题,Statful UI 有另一个工具:State Tree。您可以通过单击“状态”选项卡中的“状态树编辑器”按钮来访问它。
每次调用子状态时都会应用父状态(在此示例中为Reward ):
管理一个已配置的StatefulComponent
归结为最少量的简单易懂的代码,这些代码用必要的数据填充组件并在适当的时候切换状态:
public void ShowIntro() { SetState(StateRole.Intro); } public void ShowReward(IReward reward) { // Update the inner view with the reward reward.UpdateView(GetInnerComponent(InnerComponentRole.Reward)); // Switch on the type of reward switch (reward) { case ICardsReward cardsReward: SetState(StateRole.Cards); break; case IMoneyReward moneyReward: SetState(StateRole.Money); break; case IEmojiReward emojiReward: SetState(StateRole.Emoji); break; } } public void ShowResults(IEnumerable<IReward> rewards) { SetState(StateRole.Results); // Fill the container with the rewards GetContainer(ContainerRole.TotalReward) .FillWithItems(rewards, (view, reward) => reward.UpdateView(view)); }
角色旨在提供一种方便且明确的方式来命名链接和状态,以便稍后在代码中使用。但是,在某些情况下,描述一个状态需要一个太长的名称,并且就此链接指向什么或状态反映的行为留下一个小评论会更方便。对于这种情况, StatefulComponent
中的每个链接和状态都允许您添加描述:
您可能已经注意到每个选项卡上的复制 API 和复制文档按钮——这些是所选部分的复制信息。除此之外,“工具”选项卡中还有类似的按钮——这些按钮可同时复制所有部分的信息。当您单击复制 API 按钮时,用于管理此StatfulComponent
对象的生成代码将被复制到剪贴板。这是我们的奖励窗口的示例:
// Insert the name of the chest here SetText(TextRole.Title, "Lootbox"); // Button to proceed to the reward issuance phase GetButton(ButtonRole.Overlay); // Button to display information about the card GetButton(ButtonRole.Info); // Container for displaying the complete list of awarded rewards GetContainer(ContainerRole.TotalReward); // Insert the card image here SetImage(ImageRole.Avatar, null); // Animated appearance of a chest SetState(StateRole.Intro);
当你点击 Copy Docs 按钮时,这个 prefab 的文档将以 Markdown 格式复制到剪贴板:
### RewardScreen Buttons: - Overlay - Button to proceed to the reward issuance phase - Info - Button to display information about the card Texts: - Title - Insert the name of the chest here Containers: - TotalReward - Container for displaying the complete list of awarded rewards Images: - Avatar - Insert the card image here States: - Intro - Animated appearance of a chest - Cards - Displaying rewards in the form of a card - Money - Displaying rewards in the form of game currency - Emoji - Displaying rewards in the form of an emoji - Results - Displaying a complete list of issued rewards
很明显,在使用如此详细的说明实现此屏幕时出错是相当困难的。您可以轻松地在项目的知识库中维护有关 UI 组织的最新信息。
同时,Stateful UI 允许委托创建 UI 预制件。事实上,基于状态的标记允许在将预制件传递给程序员之前对其行为进行全面测试。这意味着游戏设计师、技术设计师,甚至独立的 UI 开发人员都可以准备预制件。此外,由于在代码和预制件之间创建了一个 API,因此编程和配置预制件可以并行完成!所需要的只是提前制定API。但是,即使配置预制件的任务仍然由程序员完成,使用有状态 UI 也会显着加快这项工作。
正如我们所见,Stateful UI 显着简化了 UI 元素状态的处理。不再需要长周期来创建 SerializeFields、重新编译代码以及在大量 View 类字段中搜索引用。在视图类本身中,不再需要为打开和关闭对象或更改文本颜色等重复操作编写大量代码。
该库允许采用一致的方法来组织项目中的布局、标记预制件中的对象、创建状态、将它们链接到 UI 元素,以及为 UI 管理提供 API 和文档。它还允许委托创建 UI 预制件并加快使用它们的速度。
扩展States的能力,在Description中支持新类型的UI变化,比如新类型的动画,在states中播放声音等
添加对用于为文本和图像着色的调色板的支持
添加对具有 GameObjects 重用的项目列表的支持
支持更多的 Unity UI 元素
自动卸载添加的本地化文本
实施测试框架。由于我们对预制件进行了详尽的标记,因此我们可以使用以下格式创建易于设置的基于 ScriptableObject 的场景:
单击ButtonRole.Settings
按钮
检查TextRole.SomeText
中的文本是否等于“某个值”
检查ImageRole.SomeImage
中的图像以确保它等于某个精灵
一个教程系统。与测试类似,标记布局允许以指令形式创建基于 ScriptableObject 的教程场景,例如“在按钮ButtonRole.UpgradeHero
上显示指针”。
项目源代码在 GitHub 上可用。欢迎您创建问题或为图书馆做出贡献!