65.9K
CodeProject 正在变化。 阅读更多。
Home

代码优先用户界面库

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.84/5 (33投票s)

2015年8月9日

CPOL

9分钟阅读

viewsIcon

36103

downloadIcon

965

描述了一个“概念验证”项目,用于实现一个代码优先的用户界面库。

Sample Image - maximum width is 600 pixels

引言

在本文中,我将描述一个小型概念验证项目,该项目基于一个长期存在但似乎没有名称的想法。我将其命名为“代码优先用户界面”,以类比 Microsoft Entity Framework 中的代码优先开发。其主要目标是大大减少创建程序用户界面所需的工作量。

在这里,我必须为代码的不完整性和不一致性道歉。我在有限的业余时间里工作了很久,所以我觉得我必须在我老死之前把这篇文章写出来。另外,我想得到一些早期的反馈,看看这个想法是否值得继续。请大家多多包涵。

但我将从一些抱怨开始……

不要重复自己

这样做的冲动只是为了减少实现用户界面所需的代码量。目前,UI 开发似乎需要以多种方式编写相同的内容。例如,最近我不得不在我的专业工作中创建一个小型测试程序。它是一个 WPF 应用程序,用于显示和编辑列表中的项目。我在这里重新创建了一个简化版本。我开始时是这样的类

[Serializable]
public class ProductItem: Notifier
{
    public ProductItem() { }
    public ProductItem(string number, ProductTypeEnum type)
    {
        _productNumber = number;
        _productType = type;
    }
    private string _productNumber;
    public string ProductNumber { get { return _productNumber; } 
	set { SetProperty(ref _productNumber, value); } }
    private ProductTypeEnum _productType;
    public ProductTypeEnum ProductType { get { return _productType; } 
	set { SetProperty(ref _productType, value); } }
    private string _title;
    public string Title { get { return _title; } set { SetProperty(ref _title, value); } }
    private decimal _price;
    public decimal Price { get { return _price; } set { SetProperty(ref _price, value); } }
    private int _stockLevel;
    public int StockLevel { get { return _stockLevel; } set { SetProperty(ref _stockLevel, value); 
    } 
}

我需要显示一个这些对象的列表,所以我不得不定义一个 XAML 中的列表视图,并将其绑定到我的对象的属性

    <listview name="listView1" grid.row="1" itemssource="{Binding}" 
	mousedoubleclick="listView1_MouseDoubleClick" keyup="listView1_KeyUp" allowdrop="True">
        <listview.view>
            <gridview>
                <gridviewcolumn header="Product Number" displaymemberbinding="{Binding ProductNumber}" />
                <gridviewcolumn header="Type" displaymemberbinding="{Binding ProductType}" />
                <gridviewcolumn header="Title" displaymemberbinding="{Binding Title}" />
                <gridviewcolumn header="Price" displaymemberbinding="{Binding Price}" />
                <gridviewcolumn header="Stock Level" displaymemberbinding="{Binding StockLevel}" />
            </gridview>
        </listview.view>
    </listview>

这样我就可以得到一个像这样的表单

我还必须有一个创建和编辑这些对象的表单

    <window x:class="ProductList.ProductItemForm"
            xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
            xmlns:system="clr-namespace:System;assembly=mscorlib"
            xmlns:c="clr-namespace:ProductList"
            title="Product Item" width="478.373" height="194.895" 
		minwidth="478" sizetocontent="WidthAndHeight">
    <window.resources>
        <objectdataprovider methodname="GetValues"
                        objecttype="{x:Type System:Enum}"
                        x:key="ProductTypeEnumValues">
            <objectdataprovider.methodparameters>
                <x:type typename="c:ProductTypeEnum" />
            </objectdataprovider.methodparameters>
        </objectdataprovider>
    </window.resources>
    <stackpanel name="stackPanel1" minwidth="250">
        <grid minwidth="250" height="115">
            <grid.rowdefinitions>
                <rowdefinition height="27" />
                <rowdefinition height="27" />
                <rowdefinition height="27" />
            </grid.rowdefinitions>
            <grid.columndefinitions>
                <columndefinition width="153" />
                <columndefinition width="370*" minwidth="100" />
            </grid.columndefinitions>
            <label grid.row="0" x:name="label10" 
            margin="10,0,38,0" content="Product Number" />
            <textbox grid.row="0" grid.column="1" margin="11,4,19,2" 
		x:name="ProductNumTextBox" text="{Binding ProductNumber, 
		UpdateSourceTrigger=PropertyChanged}" />
            <label grid.row="1" x:name="EventTypeText" 
            margin="10,0,38,0" content="Type" />
            <combobox grid.row="1" margin="9,3,21,3" x:name="ProductType" 
		selectionchanged="EventType_SelectionChanged" 
		selectedvalue="{Binding ProductType}" grid.column="1" 
		minwidth="100" itemssource="{Binding Mode=OneWay, 
		Source={StaticResource ProductTypeEnumValues}}" />
            <label grid.row="2" x:name="label7" 
            margin="10,0,38,0" content="Title" />
            <textbox grid.row="2" margin="9,1,21,5" x:name="TitleTextBox" 
		text="{Binding Title, UpdateSourceTrigger=PropertyChanged}" grid.column="1" />
            <label content="Price" horizontalalignment="Left" margin="10,0,0,0" 
		grid.row="3" verticalalignment="Top" width="79" height="27" />
            <textbox grid.row="3" margin="9,0,21,6" x:name="TitleTextBox_Copy" 
		text="{Binding Price, UpdateSourceTrigger=PropertyChanged}" grid.column="1" />
            <label content="Stock Level" horizontalalignment="Left" 
		margin="10,0,0,0" grid.row="4" 
		verticalalignment="Top" width="79" height="27" />
            <textbox grid.row="4" margin="10,0,20,6" 
            x:name="TitleTextBox_Copy1" 
		text="{Binding StockLevel, UpdateSourceTrigger=PropertyChanged}" 
		grid.column="1" rendertransformorigin="0.498,2.857" />
       </grid>
            :

这看起来像这样:

我觉得这一切都有些机械和重复。而且我们在重复相同的信息——属性的名称、类型等等。如果我们需要为类添加新属性,这一点就很清楚了——我们将在三个地方添加,而不是一个地方。我们必须在类、列表的 XAML 和表单的 XAML 中添加它。我们几乎可以写一个算法

    for each public property of the class
        add a column to the list view and bind it to the property
        add a label and field to the form, and bind it to the property

我觉得我们需要在这里应用两个基本的编程原则

  • 不要重复自己 (DRY)
  • 如果它是重复和机械的,就自动化它。

文件太多

当前用户界面开发遇到的另一个问题是它需要的“杂物”量。例如,如果您启动 Visual Studio Community 并创建一个示例 MVC 项目,您会得到如下内容:

我们最终会得到 191 个文件,分布在 33 个文件夹中,占用超过 9 MB!但这个应用程序的真正本质可以用几个对象来描述——位于Model文件夹中的 TodoItemTodoItemList。那么我的问题是,为什么我需要看到所有这些视图、控制器、数据传输对象、JavaScript 和 CSS 文件?我觉得这使得应用程序开发比它应该的更复杂。

代码优先用户界面

我的提议是,我们采用一种类似于 Microsoft Entity Framework 中代码优先开发的方法来简化用户界面代码。在代码优先方法中,我们可以专注于“域”实体的设计,而无需编写单独的代码将其映射到数据库表。我们只需编写描述应用程序的类,然后使用合理的约定自动完成映射。

映射层(对象关系映射器)负责将我们的模型类转换为关系数据库表。[本文是对该主题的一个很好的介绍:http://msdn.microsoft.com/en-us/data/jj193542。]

代码优先用户界面工作方式相同,但现在映射层(对象-UI 映射器)使用关于模型类的“反射”信息来创建用户界面。

所以我们只需要编写模型类,OUIM 负责创建用户界面。如果我们修改模型类,则不需要进一步更改——映射过程将重新创建 UI。此外,我们可以为不同的 UI 框架创建用户界面,而无需重写我们的大部分应用程序。我们只需更改 OUIM。

这是否有效取决于几个假设

  • 模型对象与其视图之间存在一对一的映射。
  • 模型对象的属性与其列表列或表单字段之间存在一对一的映射。
  • 我们可以通过使用属性的类型信息和少量属性来推断 UI 的外观。

在某些情况下,这些假设可能不成立,但在我的经验中,这涵盖了应用程序用户界面的大部分内容。

现有的“代码优先 UI”系统

这里有两个现有的这种方法的例子,您可能已经熟悉了:Windows Forms 属性网格和动态数据实体。

属性网格

如果我们像上面定义的,将一个 Windows Forms PropertyGrid 放到一个表单上并将其绑定到一个 ProductItem 对象,我们就会得到以下结果:

我们已经非常快速地生成了一个编辑对象的 UI,但我认为我们可以公平地将其描述为“简陋”。我们可以编辑所有字段,但大多数字段都只能使用一个文本框。

动态数据实体

ASP.NET 的动态数据实体允许我们从数据模型快速创建 UI。我们只需定义这个:

public class ProductModel : DbContext
{
    public ProductModel()
        : base("name=ProductModel")
    {
    }
    public virtual DbSet<ProductItem> Products { get; set; }
}

然后,我们就自动获得了列表视图和编辑表单,如下所示:

这正是我们想要的——我们只需使用模型中的类型信息就可以生成 UI。但是

  • 它只适用于 Web
  • 对于数字字段,我们仍然没有获得那些上下控件。
  • 项目中仍然添加了许多额外的文件,我们可能不需要看到这些文件。

示例实现

为了演示代码优先方法,我实现了一个小型演示项目,您可以使用文章顶部的链接下载。在本节中,我将介绍对象-UI 映射的一些主要功能。不过,首先,如果我们用它来显示上面定义的 Product 对象,我们就会得到以下结果。做到这一点所需代码将微不足道。

    ProductItem product = new ProductItem();
    UI.ShowDialog(product);

这是结果(在此版本中我添加了一些更多字段):

希望这比上面显示的属性网格更能被最终用户接受。

现在我将详细介绍一些代码到 UI 映射的示例。

简单的文本字段

最简单的情况就是一个不带任何额外属性的 string 属性:

public string simpleTextField { get; set; }

结果

我们得到了一个标签和一个单行 TextBox。请注意,我们不必为控件指定要使用的标签——它已经通过在每个大写字母处拆分属性名称而派生出来的。因此,如果我们的属性遵循“驼峰式”命名约定,我们将获得可接受的标签。

只显示公共属性

Privatestatic 属性将被忽略。

private string privateProperty { get; set; }
public static string staticProperty { get; set; }

结果

这些属性不会显示任何控件。

覆盖标签

我们可以使用 DiplayName 属性来覆盖标签:

[DisplayName("Another text field")]
public string simpleTextField2 { get; set; }

结果

掩码

我们可以为文本字段指定掩码:

[Mask("(LLL) 000-0000")]
public string maskedTextField { get; set; }

结果

数字字段

数字字段显示为上下控件:

public int integerField { get; set; }
public decimal decimalField { get; set; }
public double doubleField { get; set; }

结果

选择控件

您可以使用 Display 属性来覆盖属性的显示方式:

[Display(ControlType.TextBox)]
public int another_integer { get; set; }

结果

设置范围和步长

您可以使用属性来定义数字字段的范围和增量:

[CodeFirstUIFramework.Range(40.0, 200.0), CodeFirstUIFramework.Increment(0.1)]
public float beatsPerMinute { get; set; }

结果

布尔值

布尔值显示为复选框。

public bool booleanField { get; set; }

结果

Enums

枚举显示为组合框。

public enum TestEnum { Zero, One, Two, Three, Four, Five }
public TestEnum enumField { get; set; }

结果

标志枚举

我们将“flags”字段显示为一组复选框:

[Flags]
public enum TestFlags { Olives=1, Pepperoni=2, Capers=4, ExtraCheese=8, Anchovies=16, Chillies=32 }
public TestFlags flagsField { get; set; }

结果

日期和时间

DateTime 字段显示为日期选择器:

public DateTime a_date_time { get; set; }
// We also support a pure Date class:
public Date a_date { get; set; }

结果

结构体

给定一个像这样的结构定义:

public struct TimeOfDay
{
        public TimeOfDay(UInt16 hours, UInt16 minutes, UInt16 seconds)...
        [Range(0,23)]
        public UInt16 Hours...
        [Range(0, 59)]
        public UInt16 Minutes...
        [Range(0, 59)]
        public UInt16 Seconds...
        public override string ToString()...
}

那么该类型的字段将显示为一个带有弹出编辑器(pop-up editor)的文本字段。

public TimeOfDay timeOfDay { get; set; }

结果

点击按钮会弹出另一个表单:

点击“确定”后,这是字段的样子:

我们可以使用属性来使用一组内联控件(in-line controls)代替:

[Display(LabelOption.Group, ControlType.Inline)]
public TimeOfDay timeOfDay { get; set; }

这将显示如下:

文件名

FileName 属性的 String 会显示文件选择器:

[FileName(Filter = "XML files (*.xml)|*.xml|All files (*.*)|*.*", ForSave = false)]
        public string fileName { get; set; }

结果

点击按钮会弹出 FilePicker 对话框:

选择一个文件并按“打开”后:

对象引用

如果为对象注册了一个列表,那么我们将获得一个列表选择器:

public Country country { get; set; }

结果

点击按钮后:

按“确定”后:

列表

对象列表将显示为一个嵌入式列表控件:

public List<orderline> _orderLines = new List<orderline>
public List<orderline> OrderLines { get { return _orderLines; } }

结果

关注点

实现过程中的一个棘手之处是如何正确处理确定/取消的语义。我们希望以下代码能按预期工作:

if(  UI.ShowDialog(product) )
{
    //The user clicked OK - product has been updated
}
else
{
    // The user clicked cancel - product is unchanged
}

我们可以通过多种方式实现这一点:

  • 数据仅在单击“确定”时从控件复制到对象属性(实际上不使用绑定)。
  • 将控件绑定到属性,但在取消时以某种方式恢复对象的原样。
  • 创建对象的克隆并绑定到它。单击“确定”时,将克隆副本复制回原始对象。

我们希望使用真正的动态绑定,因为我们希望任何“计算”字段在编辑过程中都能更新。

最后(经过各种实验),我选择了最后一个选项。但这带来了一个问题——如果在不知道类型的情况下,C# 中没有简单的方法可以克隆一个对象。这行不通:

        object clone = objectToCopy.MemberwiseClone();

这无法编译,因为 MemberwiseClone() 是一个 protected 成员。最后,我不得不采取这种方法:

public static object CloneObject(object objectToCopy)
{
    //object clone = objectToCopy.MemberwiseClone();        // won't compile
    object returnValue = null;
    Type t = objectToCopy.GetType();
    if (t.IsSerializable)
        returnValue = CopyBySerialization(objectToCopy);
    else
        returnValue = CopyFields(objectToCopy);
    return returnValue;
}

private static object CopyFields(object objectToCopy)
{
    Type t = objectToCopy.GetType();
    object targetObject = Activator.CreateInstance(t);
    // now copy the fields:
    CopyFields(objectToCopy, targetObject);
    return targetObject;
}

private static void CopyFields(object objectToCopy, object targetObject)
{
    Type t = objectToCopy.GetType();
    if (targetObject.GetType() != t)
        throw new ArgumentException("Trying to copy fields of incompatible types");
    var fields = t.GetFields(BindingFlags.Instance|BindingFlags.Public|BindingFlags.NonPublic);
    foreach (FieldInfo info in fields)
    {
        object value = info.GetValue(objectToCopy);
        info.SetValue(targetObject, value);
    }
}

private static object CopyBySerialization(object objectToCopy)
{
    MemoryStream memoryStream = new MemoryStream();
    BinaryFormatter binaryFormatter = new BinaryFormatter();
    binaryFormatter.Serialize(memoryStream, objectToCopy);
    memoryStream.Position = 0;
    object returnValue = binaryFormatter.Deserialize(memoryStream);
    memoryStream.Close();
    memoryStream.Dispose();
    return returnValue;
} 

如果有人知道更好的方法,我很想听听。

未来发展

这段代码还有很多可以改进和发展的地方。以下是我目前想到的一些:

  • 不同的 UI 框架。我已经为 Windows Forms 实现了,因为我最熟悉它。但是,如果我们也能为 Web 创建界面,以及为新的 Microsoft “通用应用”创建界面,那就更好了。
  • 操作。目前我们只支持编辑数据。最好将界面生成扩展到支持操作。例如,标记为 [Action] 属性的 public 方法可以用于在表单上添加一个按钮,该按钮在单击时会调用该方法。
  • 属性。目前,代码只解释自己的属性和 System.ComponentModel 中的一些属性。它应该扩展以从 System.ComponentModel.DataAnnotations 中获取有用的信息。
  • 组。如果属性具有 group 属性,我们或许可以把它们放在不同的选项卡上。

历史

  • 1.0.0 | 初始发布 | 2015 年 8 月 9 日
© . All rights reserved.