Unity中FairyGUI使用MVVM的一些尝试

发布时间:2025-11-05

在 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 元素自动更新内容文本。

其他阅读

管道技术——中间件的灵魂

在现代Web开发中,中间件技术使用越来越广泛,本文带大家了解中间件的基础,同时也是中间件的灵魂所在,管道技术。在C#中,依赖于委托,我们可以很容易就实现一个中间件管道。所以在阅读本文前,请确保你已经学会了什么是委托,包括但不限于Delegate,Action,Func。除此之外,本文还会使用到反射相关知识,请确保你已经学会了什么是反射。

查看原文

TypeScript中的数组操作

我们在编码时,总会用到数组/列表这种类型,用于在单个对象中存储多个内容。在 TypeScript 中,也已经内置了该类型,方便我们来使用。

查看原文

解决sqlite依赖无法打包单文件的问题

在一次WPF开发中,选用了sqlite作为内嵌数据库,使用 ystem.Data.SQLite 库来调用,在使用 Fody 进行单文件打包时,发现打包文成后会出现 x86 和 x64 两个特定的文件夹,分别对应着32位和64位的 SQLite.Interop.dll,本文介绍修改项目文件来实现将 sqlite 通信库一起打包成单文件的方法。

查看原文

电脑版微信支持抢红包和发朋友圈了

微信迎来史诗级加强——支持抢红包,微信迎来史诗级加强——支持发布朋友圈。

查看原文

asp.net core实现一个反向代理

本文将向你展示如何在C#和ASP.NET Core中实现一个反向代理功能。

查看原文