paint-brush
StatefulUI:基于状态和标记的 Unity UI 库经过@dmitrii
2,926 讀數
2,926 讀數

StatefulUI:基于状态和标记的 Unity UI 库

经过 Dmitrii Ivashchenko12m2023/05/17
Read on Terminal Reader

太長; 讀書

Dmitrii Ivashchenko 是 MY.GAMES 的一名软件工程师。在本文中,我们将讨论基于元素的状态和标记在 Unity 中开发用户界面。所描述的方法不适用于 [UI Toolkit] 或其他 UI 构建系统。
featured image - StatefulUI:基于状态和标记的 Unity UI 库
Dmitrii Ivashchenko HackerNoon profile picture
0-item
1-item

大家好,我是 Dmitrii Ivashchenko,我是 MY.GAMES 的一名软件工程师。在本文中,我们将讨论基于元素的状态和标记在 Unity 中开发用户界面。

介绍

首先,应该注意的是,我们将在Unity UI (uGUI) 技术的上下文中进行讨论,根据文档,该技术仍然推荐用于运行时。所描述的方法不适用于UI ToolkitIMGUI或其他 UI 构建系统。



大多数情况下,在Unity项目中,您会遇到基于继承自MonoBehaviour的 View 类构建的 UI 实现,并散布着大量SerializeField字段。这种方法提供了对 UI 行为的完全控制,但也需要在 View 和 Presenter 级别编写大量代码(取决于所使用的架构)。


通常,随着项目开发的继续,这些类会膨胀到令人难以置信的大小,并且 GameObject 本身的组件被大量指向内部对象的链接所覆盖:



像这样修改组件也很不愉快:要在类中获取对新元素的引用,您需要添加SerializeField ,重新编译代码,在预制组件中找到新字段,并将必要的对象拖入其中。随着项目的增长,编译时间、字段数量、组织预制件的复杂度也依次增加。


结果,我们最终得到了庞大且重载的MonoBehaviour子类(或大量的小子类,具体取决于您的偏好)。


还值得考虑的是,对此类 UI 的行为进行的任何更改都是程序员的一项任务,该任务伴随着所有相关成本:代码审查、解决合并冲突、代码覆盖测试等。


我想强调具有多个状态的窗口的实现。我见过很多变体,可以分为两种方法:


  1. 首先,使用代码发生窗口状态的任何更改。更改文字颜色、更改图片、播放动画、移动屏幕上的对象——所有涉及的对象和参数都需要一个对应的SerializeField ,然后编写大量代码使其按要求工作.自然地,只有程序员才能处理这个问题,而且实施结果是冗长、昂贵且超级高效的(通常比任何人都注意到的要高效得多)。
  2. 另一种方式可谓是“全能的Animator ”。除了 View 类之外,还创建了一个 Animator Controller 并通过参数进行控制。一个新的 Animator 出现在新窗口中,以此类推,直到显示窗口时的 FPS 开始下降。



现在我们已经强调了使用 uGUI 的一些困难,我想谈谈解决这个问题的不同方法。

有状态的用户界面

在我的一个宠物项目中,我开发了一个用于在 Unity 中进行结构化 UI 开发的库。后来,我和我的团队在生产环境中对其进行了测试,我们对结果很满意。


该库的源代码可在 GitHub 上下载

状态组件

该库的关键元素是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.TextTextMeshProUGUI
  • TextInputs,包括UnityEngine.UI.InputFieldTMP_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); } }

文本和本地化

带有文本的选项卡,除了角色和对象链接之外,还包含以下列:


  • 代码:用于本地化的文本键
  • 本地化复选框:文本字段受本地化影响的指示器
  • 值:对象的当前文本内容
  • Localized:通过 Code 字段中的键找到的当前文本



该库不包含用于处理翻译的内置子系统。要连接本地化系统,您需要创建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>AddStatefulComponentFillWithItems来填充容器:


 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枚举中的角色,预制件的更改示例可以是:


  • 启用和禁用游戏对象
  • 替换 Image 对象的精灵或材质
  • 在屏幕上移动对象
  • 更改文本及其外观
  • 播放动画
  • 依此类推——您可以添加自己的对象操作类型


可以在“状态”选项卡上配置一组更改(状态描述)。可以直接从检查器应用已配置的状态:



可以使用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。您可以通过单击“状态”选项卡中的“状态树编辑器”按钮来访问它。


假设我们需要为宝箱创建一个奖励窗口。该窗口有 3 个阶段:


  • 宝箱动画介绍(状态介绍
  • 从箱子中循环出现三种不同类型的奖励(状态为MoneyEmojiCards ,具体取决于Reward状态,这会触发奖励从箱子中出现的动画)
  • 在单个列表中显示所有奖励(状态结果


每次调用子状态时都会应用父状态(在此示例中为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)); }

有状态 API 和文档

角色旨在提供一种方便且明确的方式来命名链接和状态,以便稍后在代码中使用。但是,在某些情况下,描述一个状态需要一个太长的名称,并且就此链接指向什么或状态反映的行为留下一个小评论会更方便。对于这种情况, 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 的场景:


    1. 单击ButtonRole.Settings按钮

    2. 检查TextRole.SomeText中的文本是否等于“某个值”

    3. 检查ImageRole.SomeImage中的图像以确保它等于某个精灵


  • 一个教程系统。与测试类似,标记布局允许以指令形式创建基于 ScriptableObject 的教程场景,例如“在按钮ButtonRole.UpgradeHero上显示指针”。


项目源代码在 GitHub 上可用。欢迎您创建问题或为图书馆做出贡献!