在 Unity 项目中创建 UI 界面选择比较多,有最新的 UI Toolkit 和 UGUI ,本文介绍另一种全新的解决方案:FairyGUI,本文会探索在 FairyGUI 中使用 MVVM 模式的可行性。
FairyGUI
FairyGUI 是一个跨平台的UI解决方案,它提供了一个可以在Windows和Mac上使用的编辑器和可以在多个游戏引擎中使用的SDK(Unity、Flash、Starling、白鹭、LayaAir等,未来还将支持cocos2d-x、UE4、libgdx等)。自带了 UI 编辑器,美术人员和开发人员都可以快速上手完成界面的制作。
FairyGUI 对比其他方案比较好的特性:
独立的设计器,支持所见即所得,操作简单易上手。
界面和代码分离,可以单独制作出复杂的 UI 组件。
文本控件功能强大,可以支持动态字体,位图字体等,还支持HTML语法。
提供时间轴设计动画效果。
MVVM
MVVM 是 Model-View-ViewModel 的缩写,这是一种软件架构模式,旨在将用户界面于业务逻辑和数据模型分离。通过数据绑定的技术,使得用户界面和数据模型可以自动同步。
MVVM 的优点:
解耦分离了 UI 和业务,更加容易维护。
独立的 ViewModel 处理业务更容易测试。
简化开发,随时可以替换业务代码和UI而不影响程序。
FairyGUI中MVVM的尝试
FairyGUI 在使用上默认是MVP模式,也就是在 UI 设计器中完成 UI 的制作,在 Unity 项目中制作一个表现类来绑定制作好的 UI 。在这个类中接受用户的输入,处理业务逻辑,更新界面显示。整体架构和 Windows Forms App 是一样的。
Component Design(View) <--> Component Class(Presenter) <--> Model
我们来构建一个 FairyGUI 的承接类 MainWindow ,如下所示:
public partial class MainWindow : GComponent
{
public GGraph bg;
public GTextField title;
public GTextField name;
public ToolbarButton createBtn;
public ToolbarButton queryBtn;
public GComponent prop;
public const string URL = "ui://tltq2xdukle80";
public static MainWindow CreateInstance()
{
return (MainWindow)UIPackage.CreateObject("Main", "MainWindow");
}
public override void ConstructFromXML(XML xml)
{
base.ConstructFromXML(xml);
bg = (GGraph)GetChild("bg");
title = (GTextField)GetChild("title");
name = (GTextField)GetChild("name");
createBtn = (ToolbarButton)GetChild("createBtn");
queryBtn = (ToolbarButton)GetChild("queryBtn");
prop = (GComponent)GetChild("prop");
title.text = "默认标题";
createBtn.onClick.Add(UndoBtnClicked);
queryBtn.onClick.Add(QueryBtnClicked);
}
private async void CreateBtnClicked(EventContext context)
{
// ...
// 处理业务逻辑
// ...
title.text = $"标题 - {DateTime.Now}";
}
private void QueryBtnClicked(EventContext context)
{
// ...
// 处理业务逻辑
// ...
Debug.Log(2222);
}
}
可以看出 :使用 MVP 模式在一定程度上已经把 UI 和业务逻辑分离,所有的操作全放在了 Presenter 中,但我们在处理完业务逻辑之后还是需要手动去更新 UI 。
我们修改一下程序的结构,引入 ViewModel 的概念,首先设计一个 ViewModelBase 类,如下
public interface IViewModel : INotifyPropertyChanged { }
public class ViewModelBase<TView> : IViewModel where TView : GObject
{
protected readonly TView view;
public TView View => view;
public event PropertyChangedEventHandler PropertyChanged;
private readonly List<Binding> _bindings = new List<Binding>();
protected ViewModelBase(TView refView)
{
view = refView;
}
protected void SetBinding(string propertyName, string path, GObject target)
{
var binding = new Binding(this, target, propertyName, path);
_bindings.Add(binding);
binding.Setter?.Invoke();
}
protected void SetBinding(string propertyName, Action<object> setter)
{
var binding = new Binding(this, propertyName, setter);
_bindings.Add(binding);
binding.Setter?.Invoke();
}
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
foreach (var binding in _bindings)
{
if (binding.PropertyName == propertyName)
{
binding.Setter?.Invoke();
}
}
foreach (var binding in _input)
{
if (binding.PropertyName == propertyName)
{
binding.Setter?.Invoke();
}
}
}
}
ViewModelBase 类的定义是一个泛型类,在使用的时候,需要指定泛型的类型,该类型做了约束只能是派生自 FairyGUI 中的 GObject 类,有一个只读属性 View 定义了是该类型,也就是说 ViewModel 是对 FairyGUI 对象的包装。
ViewModelBase 完全继承于 IViewModel 接口,而 IViewModel 接口又继承于 INotifyPropertyChanged 接口,这个接口定义了在属性的值变更时可以触发对应的事件: OnPropertyChanged ,为自动更新 UI 提供了可能。
接下来我们定义一个 Binding 类用作数据绑定,定义如下:
internal abstract class BindingBase<T>
{
private readonly IViewModel _viewModel;
public string PropertyName { get; protected set; }
public string Path { get; protected set; }
public T Target { get; protected set; }
public Action Setter { get; protected set; }
protected BindingBase(IViewModel viewModel, Object target, string propertyName, string path)
: this(viewModel, propertyName)
{
Path = path;
Target = (T)target;
var targetType = target.GetType();
var targetPath = targetType.GetProperty(path);
if (targetPath == null)
throw new NullReferenceException("无法找到绑定的对象,请检查路径是否正确");
// 初始化绑定值
var bindingProperty = viewModel.GetType().GetProperty(propertyName);
if (bindingProperty == null)
throw new NullReferenceException("无法找到绑定的属性,请检查属性名称是否正确");
Setter = () => targetPath.SetValue(target, bindingProperty.GetValue(viewModel));
}
protected BindingBase(IViewModel viewModel, string propertyName)
{
_viewModel = viewModel;
PropertyName = propertyName;
}
}
internal class Binding : BindingBase<GObject>
{
internal Binding(IViewModel viewModel, GObject target, string propertyName, string path)
: base(viewModel, target, propertyName, path) { }
internal Binding(IViewModel viewModel, string propertyName, Action<object> setter)
: base(viewModel, propertyName)
{
PropertyName = propertyName;
// 初始化绑定值
var bindingProperty = viewModel.GetType().GetProperty(propertyName);
if (bindingProperty == null)
throw new NullReferenceException("无法找到绑定的属性,请检查属性名称是否正确");
Setter = () => setter.Invoke(bindingProperty.GetValue(viewModel));
}
}
Binding 类派生自 BindingBase 类,BindingBase 类本身也是一个泛型类,可以接受任何形式的对象进行绑定,绑定核心在 BindingBase 中定义,原理是使用表达式树构建出赋值委托来实现。Binding 类对泛型做了约定只能是 GObject 类型,也即是只接受 UI 元素的绑定。
接下来我们修改之前写的 MainWindow 类,并且实现 MainWindowViewModel 类,如下
internal class MainWindowViewModel : ViewModelBase<MainWindow>
{
private readonly Router _router;
private string title;
public string Title
{
get => title;
set
{
if (value == title)
return;
title = value;
OnPropertyChanged();
}
}
internal MainWindowViewModel(Router router)
: base(MainWindow.CreateInstance())
{
_router = router ?? throw new ArgumentNullException(nameof(router));
SetBinding(nameof(Title), nameof(view.title.text), view.title);
view.createBtn.onClick.Add(CreateBtnClicked);
view.queryBtn.onClick.Add(QueryBtnClicked);
Title = "默认标题";
}
private async void CreateBtnClicked(EventContext context)
{
Title = $"标题 - {DateTime.Now}";
}
private void QueryBtnClicked(EventContext context)
{
Debug.Log(1111);
}
}
public partial class MainWindow : GComponent
{
public GGraph bg;
public GTextField title;
public GTextField name;
public ToolbarButton createBtn;
public ToolbarButton queryBtn;
public GComponent prop;
public const string URL = "ui://tltq2xdukle80";
public static MainWindow CreateInstance()
{
return (MainWindow)UIPackage.CreateObject("Main", "MainWindow");
}
public override void ConstructFromXML(XML xml)
{
base.ConstructFromXML(xml);
bg = (GGraph)GetChild("bg");
title = (GTextField)GetChild("title");
name = (GTextField)GetChild("name");
createBtn = (ToolbarButton)GetChild("createBtn");
queryBtn = (ToolbarButton)GetChild("queryBtn");
prop = (GComponent)GetChild("prop");
}
}
把 MainWindow 中所有的业务逻辑全部删除,只留下最原始的设计定义。在 MainWindowViewModel 中定义了一个可通知属性 Title ,在 MainWindowViewModel 构造器中调用 SetBinding 方法进行数据绑定,传入属性名称 Tiltle、绑定路径为 view.title.text,绑定的对象为 view.title 。把业务逻辑加到其中,在业务逻辑 CreateBtnClicked 中只需要对 Title 进行操作即可实现界面中 title 元素自动更新内容文本。