在Unity3D中我们如何进行反应式链接

图片



今天,我将谈谈Pixonic的一些项目如何成为整个全球前端长期以来的规范-反应式链接。



我们的绝大多数项目都是用Unity 3D编写的。而且,如果其他具有反应性的客户端技术(MVVM,Qt和数百万个JS框架)表现良好,并且这是理所当然的,则Unity没有任何内置或普遍接受的绑定。



这时候有人可能会问:“为什么?我们不使用它,我们生活得很好”。



有原因。更确切地说,存在问题,解决方案之一就是使用这种方法。结果,它成为一个。而且细节在削减。



首先,关于项目,其问题需要这样的解决方案。当然,我们谈论的是War Robots,这是一个庞大的项目,拥有许多不同的开发,支持,营销等团队。我们现在仅对其中两个感兴趣:客户程序员团队和用户界面团队。在下文中,为简单起见,我们将它们称为“代码”和“布局”。碰巧的是,有些人从事UI的设计和布局,而另一些人则对所有这些进行“振兴”。这是合乎逻辑的,以我的经验,我遇到了许多类似的团队组织示例。



我们注意到,随着项目功能的增加,代码和布局之间的交互成为死锁和瓶颈的地方。程序员正在等待现成的工作部件,布局设计师-对代码进行一些修改。是的,在交互过程中发生了很多事情。简而言之,有时它变成混乱和拖延。



现在让我解释一下。看一下经典的简单小部件示例-尤其是RefreshData方法。我刚刚添加的样板文件的其余部分是为了提高可信度,因此不值得特别注意。



public class PlayerProfileWidget : WidgetBehaviour
{
  [SerializeField] private Text nickname;
  [SerializeField] private Image avatar;
  [SerializeField] private Text level;
  [SerializeField] private GameObject hasUpgradeMark;
  [SerializeField] private Button upgradeButton;

  public void Initialize(ProfileService profileService)
  {
 	RefreshData(profileService.Player);

 	upgradeButton.onClick
    	.Subscribe(profileService.UpgradePlayer)
    	.DisposeWith(Lifetime);

 	profileService.PlayerUpgraded
    	.Subscribe(RefreshData)
    	.DisposeWith(Lifetime);
  }

  private void RefreshData(in PlayerModel player)
  {
 	nickname.text = player.Id;
 	avatar.overrideSprite = Resources.Load<Sprite>($"Avatars/{player.Avatar}_Small");
 	level.text = player.Level.ToString();
 	hasUpgradeMark.SetActive(player.HasUpgrade);
  }
}


这是静态自顶向下链接的示例。在上部(在层次结构中)GameObject的组件中,您链接了相应类型的下部对象的组件。这里的一切都非常简单,但并不十分灵活。



随着新功能的出现,小部件的功能正在不断扩展。想象一下。现在在化身周围应该有一个边框,其外观取决于玩家的等级。好的,让我们添加一个到框架图像的链接,并将对应于该关卡的子画面浸入其中,然后添加用于匹配关卡和框架的设置,并将其全部提供给布局。做完了



一个月过去了。现在,如果玩家是成员,则氏族图标会出现在玩家的小部件中。而且您还需要注册他在那里的标题。如果升级,则昵称需要涂成绿色。另外,我们现在正在使用TextMeshPro。并且 ...



反正你懂这个意思。代码变得越来越多,它变得越来越复杂,并且在各种情况下都变得不知所措。



这里有几种选择。例如,程序员修改小部件代码,对布局进行更改。他们添加组件并将其链接到新字段。反之亦然:布局可能会提前到达,程序员自己将链接所需的所有内容。通常,有更多的修复程序迭代。无论如何,这个过程不是并行的。两个贡献者都在使用相同的资源。合并预制件或场景仍然是一种享受。



对于工程师而言,一切都很简单:如果发现问题,请尝试解决问题。所以我们尝试了。结果,我们认为有必要缩小两支球队之间的接触面。反应式模式将这一前沿缩小到一个点-通常称为“视图模型”。对我们来说,它是代码和布局之间的契约。当我深入了解细节时,合同的含义将会变得清楚,以及为什么它不会阻碍两个团队的并行运作。



在我们考虑所有这些时,有几种第三方解决方案。我们正在寻找Unity Weld,Peppermint数据绑定,DisplayFab。他们都有自己的优点和缺点。但是对我们来说,致命的缺点之一是常见的-表现不佳。它们可能在简单的接口上可以正常工作,但是到那时我们无法避免接口的复杂性。



由于这项任务似乎并不困难,甚至没有相关经验,因此决定在工作室内部实施反应式装订系统。



任务如下:



  • 性能。传播更改的机制本身必须是快速的。还希望减少GC的负载,以便即使在冻结效果都不令人满意的游戏过程中也可以使用所有这些负载。
  • 方便的创作。这是必需的,以便UI团队的成员可以使用该系统。
  • 方便的API。
  • 可扩展性。




从上到下或一般描述



任务很明确,目标很明确。让我们从“合同”开始-ViewModel。任何人都应该能够形成它,这意味着ViewModel的实现应该尽可能简单。基本上,这只是确定当前显示状态的一组属性。



为简单起见,我们将具有值的属性类型集尽可能限制为bool,int,float和string。这是由以下几个因素决定的:



  • 在Unity中序列化这些类型很容易。
  • , -, . , Sprite -, PlayerModel , ;
  • , .


所有属性均处于活动状态,并通知订户有关其值的更改。这些价值并不总是存在-业务逻辑中只是需要以某种方式可视化的事件。在这种情况下,存在没有值-事件的属性类型。



当然,您也不能没有接口中的集合。因此,还有一个收集属性类型。该集合会通知订阅者其组成的任何更改。集合元素也是具有特定结构或架构的ViewModel。编辑时在合同中也描述了该方案。



ViewModel在编辑器中如下所示:







应该注意的是,可以直接在检查器中即时编辑属性。这使您可以查看小部件(或窗口,场景或其他内容)在运行时的行为,即使没有代码也是如此,这在实践中非常方便。



如果ViewModel是我们的绑定系统的顶部,则底部是所谓的涂抹器。这些是完成所有工作的ViewModel属性的最终订阅者:



  • 通过更改boolean属性的值来启用/禁用GameObject或单个组件;
  • 根据string属性的值更改字段中的文本;
  • 启动动画器,更改其参数;
  • 通过索引或字符串键从集合中替换所需的精灵。


我将在这里停止,因为应用程序的数量仅受想象力和您要解决的任务范围的限制。



这是某些应用程序在编辑器中的外观:









为了获得更大的灵活性,可以在属性和应用程序之间使用适配器。这些是在应用之前用于转换属性的实体。也有许多不同的:



  • 布尔值-例如,当您需要转换布尔值属性或根据不同类型的值返回true或false时(当级别高于15时,我需要金色边框)。
  • 算术这里没有评论。
  • 集合上的操作:反转,仅使用集合的一部分,按键排序等等。


同样,可以有多种不同的适配器选项,因此我将不再继续。











实际上,尽管不同的涂药器和适配器的总数很大,但是到处使用的基本套件非常有限。处理内容的人员需要首先学习此内容,这会稍微增加培训时间。但是,您需要为此花时间一次,这样在这里就不会有大问题了。此外,我们有关于此问题的食谱和文档。



当布局缺少某些内容时,程序员将添加必要的组件。同时,大多数涂药器和适配器都是通用的,并且正在积极地重复使用。另外,应该指出的是,我们仍然有一些通过UnityEvent进行反射的贴标器。它们适用于尚未实施所需涂药器或实施不切实际的情况。



这无疑增加了布局团队的工作。但就我们而言,他们甚至对他们从程序员那里获得的自由和独立程度感到满意。而且,如果从布局的角度增加工作量,那么从代码的角度来看,现在一切都将变得更加容易。



让我们回到PlayerProfileWidget示例。这就是现在在我们的假设项目中作为演示者的样子,因为我们不再需要Widget作为组件,并且我们可以从ViewModel中获取所有内容,而不必直接链接所有内容:



public class PlayerProfilePresenter : Presenter
{
  private readonly IMutableProperty<string> _playerId;
  private readonly IMutableProperty<string> _playerAvatar;
  private readonly IMutableProperty<int> _playerLevel;
  private readonly IMutableProperty<bool> _playerHasUpgrade;

  public PlayerProfilePresenter(ProfileService profileService, IViewModel viewModel)
  {
 	_playerId = viewModel.GetString("player/id");
 	_playerAvatar = viewModel.GetString("player/avatar");
 	_playerLevel = viewModel.GetInteger("player/level");
 	_playerHasUpgrade = viewModel.GetBoolean("player/has-upgrade");

 	RefreshData(profileService.Player);

 	viewModel.GetEvent("player/upgrade")
    	.Subscribe(profileService.UpgradePlayer)
    	.DisposeWith(Lifetime);

 	profileService.PlayerUpgraded
    	.Subscribe(RefreshData)
    	.DisposeWith(Lifetime);
  }

  private void RefreshData(in PlayerModel player)
  {
 	_playerId.Value = player.Id;
 	_playerAvatar.Value = player.Avatar;
 	_playerLevel.Value = player.Level;
 	_playerHasUpgrade.Value = player.HasUpgrade;
  }
}


在构造函数中,您可以看到代码从ViewModel获取属性。是的,在此代码中,为了简化起见,省略了检查,但是如果某些方法找不到所需的属性,则会抛出异常。另外,我们有几个工具可以很好地保证必填字段的存在。它们基于资产验证,您可以在此处阅读



我将不涉及实现细节,因为这将花费大量文本和时间。如果有公开询问,最好在单独的文章中发布。我只会说实现与同一个Rx并没有太大区别,只是一切都稍微简单了一点。



下表显示了基准测试的结果,该基准创建了500个表单,其中InputField,Text和Button与一个属性模型和一个动作函数相关联。







结论是,我可以报告上述目标已经实现。比较基准显示相对于上述选项,在内存和时间上都有所提高。随着布局团队和其他负责内容的部门的人员越来越熟悉,摩擦和阻碍也越来越少。代码的效率和质量已经提高,现在许多事情不需要程序员干预。



All Articles