代码优先用户界面库






4.84/5 (33投票s)
描述了一个“概念验证”项目,用于实现一个代码优先的用户界面库。
引言
在本文中,我将描述一个小型概念验证项目,该项目基于一个长期存在但似乎没有名称的想法。我将其命名为“代码优先用户界面”,以类比 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文件夹中的 TodoItem
和 TodoItemList
。那么我的问题是,为什么我需要看到所有这些视图、控制器、数据传输对象、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
。请注意,我们不必为控件指定要使用的标签——它已经通过在每个大写字母处拆分属性名称而派生出来的。因此,如果我们的属性遵循“驼峰式”命名约定,我们将获得可接受的标签。
只显示公共属性
Private
和 static
属性将被忽略。
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 日