一步一步学习使用LiveBindings(6) 实现Master-Detail主从关系的绑定
主从式数据在应用程序的开发中是非常常见的,比如员工和电子邮件地址记录,一个员工可能对应到多个邮件地址,这就形成了一对多的关系。在VCL中,数据控件处理主从式绑定非常方便简洁,在这个示例中,学习如何使用LiveBindings的TProtoTypeBindSource控件来实现对象间的主从式的数据绑定。
注意:这个示例来自《Delphi Cookbook》中的Using master/details with LiveBindings,需要获取详细信息可以参考这本书.
现在请打开Delphi 12.3,按如下的步骤重新实现一个基于主从关系的面向对象的LiveBindings示例。
1. 单击主菜单中的 File > New > Multi-Device Application - Delphi > Blank Application ,创建一个新的多设备应用程序。
建议立即单击工具栏上的Save All按钮,将单元文件保存为uMainForm.pas,将项目保存为LiveBinding_MasterDetail.dproj。
你的项目结构应该像这样:

2. 在表单上放置两个 TGrid 组件,并将它们命名为 grdPeople 和 grdEmails 。将两个组件的 Options.AlternatingRowBackground 属性设置为 True。将 grdPeople 的 Options.RowSelection 设置为 True。在表单上放置两个 TPrototypeBindSource 组件,并将它们命名为 bsPeople 和 bsEmails 。
- 在表单上放置一个 TBindNavigator 组件,并将其 DataSource 属性连接到 bsPeople。
- 在表单上再放置另一个 TBindNavigator 组件,并将其 DataSource 属性连接到 bsEmails。然后,将其 VisibleButtons 属性中的所有元素设置为 False,仅将 nbInsert 和 nbDelete 设置为 True(这将允许您从人员中插入或删除任何电子邮件)。
- 在表单上放置三个 TEdit 组件,并将它们命名为 EditFirstName、EditLastName 和 EditAge。
整体的布局大概如下所示:

3. 接下来分别为bsPeople和bsEmails添加字段和指定数据生成器。双击bsPeople,将打开Fields Editor,添加如下所示的字段:

双击bsEmails,添加如下所示的字段:

4. 右击页面空白处,从弹出的菜单中选择“Bind Visually”进入LiveBindings Designer设计器,按如下步骤完成绑定操作。
虽然看起来LiveBindings是在将数据与UI进行链接,其实到目前为止,所做的工作是在UI与BindSource进行操作,至于BindSource是连接到底层的数据库表还是对象,虽然在本篇中已经说明是对象,但是对于UI控件来说,目前是不清楚底层数据到底是数据库还是对象类型的,也无需顾及。
进入设计器后,可以看到BindNavigator由于指定了DataSource属性,所以设计器已经自动添加了链接。
首先,将bsPeople中的每一个栏位拖动到grdPeople中,不使用*是因为想对每一个列进行调整。而使用*是不可以的。

注意:当将每一列拉到TGrid控件上后,TGrid会自动为每一列生成一个TLinkGridToDataSourceColumn,在设计器的Column Editor中可以编辑列宽,指定每一列的自定义显示格式等等。
最后将3个Edit控件也链接上。

可以看到,LiveBindings Designer对于TEdit和TGrid都给了以向数据绑定(链接线2边都有箭头)。即用户在UI上的更改也可以更新回底层数据存储。
现在运行程序,可以看到通过BindNavigator,可以对People进行移动,但是相应的Email并不会发生变化。不用担心,底层的数据操作会完成这个功能。

5. 现在新建一个实体类,用来存放底存数据和逻辑。如本文开头所述,这里引用了《Delphi Cookbook》中的示例代码,因此将包含示例中的实体类BusinessObjectsU.pas单元引入到了项目中,读者可以新建一个名为BusinessObjectsU.pas的单元,将下面的代码拷进去。
BusinessObjectsU.pas中包含了两个类,TPeople表示是单个个体人,它包含一个泛型的TEmail类型的属性集合Emails,表示一个人可以拥有多个电子邮件地址。

代码如下所示:
unit BusinessObjectsU;interfaceusesSystem.Generics.Collections;type/// <summary>/// Email实体类,仅简单的记录了邮件地址。/// <summary>TEmail = classprivateFAddress: String;procedure SetAddress(const Value: String);public//包含重载的构造函数。constructor Create; overload;constructor Create(AEmail: String); overload;property Address: String read FAddress write SetAddress;end;/// <summary>///  个人实体类,表示单个人,包含多个邮件地址/// </summary>TPerson = classprivateFLastName: String;FAge: Integer;FFirstName: String;//定义一个泛型集合类型,用来包含多个TEmail类。FEmails: TObjectList<TEmail>;procedure SetLastName(const Value: String);procedure SetAge(const Value: Integer);procedure SetFirstName(const Value: String);function GetEmailsCount: Integer;public//包含重载的构造函数,用来初始化属性值。constructor Create; overload;constructor Create(const FirstName, LastName: string; Age: Integer);overload; virtual;destructor Destroy; override;property FirstName: String read FFirstName write SetFirstName;property LastName: String read FLastName write SetLastName;property Age: Integer read FAge write SetAge;property EmailsCount: Integer read GetEmailsCount;property Emails: TObjectList<TEmail> read FEmails;end;implementationusesSystem.SysUtils;{ TPersona }constructor TPerson.Create(const FirstName, LastName: string; Age: Integer);
beginCreate;FFirstName := FirstName;FLastName := LastName;FAge := Age;
end;// 由LiveBindings调用来插入一个新行。
constructor TPerson.Create;
begininherited Create;FFirstName := '<name>';//初始化邮件列表FEmails := TObjectList<TEmail>.Create(true);
end;destructor TPerson.Destroy;
beginFEmails.Free;inherited;
end;function TPerson.GetEmailsCount: Integer;
beginResult := FEmails.Count;
end;procedure TPerson.SetLastName(const Value: String);
beginFLastName := Value;
end;procedure TPerson.SetAge(const Value: Integer);
beginFAge := Value;
end;procedure TPerson.SetFirstName(const Value: String);
beginFFirstName := Value;
end;{ TEmail }constructor TEmail.Create(AEmail: String);
begininherited Create;FAddress := AEmail;
end;// 由LiveBindings调用来插入一个新行。
constructor TEmail.Create;
beginCreate('<email>');
end;procedure TEmail.SetAddress(const Value: String);
beginFAddress := Value;
end;end.
两个实体类都包含了重载的构造函数,不带参数的构造函数将由LiveBindings调用来生成新的行,而带参数的构造函数将用来生成初始数据,这些数据可以是来自底层的数据库表,也可以是像示例这样,使用了一个随机数单元来生成数据数据。
6. 回到主窗体,开始对主窗体进行编码了。前面的步骤中在主窗体上放了2个TProtoTypeBindSource控件,这2个控件自带数据生成器,它就好像是TAdapterBindSource和TDataGeneratorAdapter的结合体。因此它也提供了OnCreateAdapter事件,通过处理这个事件,来将前面创建的实体数据集合桥接给UI控件。
类似于第5课的代码,首先需要在窗体类的private中添加泛型的集合类FPeople,第1步是添加对实体类单元的引用。
usesSystem.SysUtils, System.Types, System.UITypes, System.Classes, System.Variants,FMX.Types, FMX.Controls, FMX.Forms, FMX.Graphics, FMX.Dialogs, System.Rtti,FMX.Grid.Style, Data.Bind.Controls, FMX.Layouts, Fmx.Bind.Navigator,FMX.Controls.Presentation, FMX.ScrollBox, FMX.Grid, Data.Bind.Components,Data.Bind.ObjectScope, FMX.StdCtrls, FMX.Edit, Data.Bind.GenData,Data.Bind.EngExt, Fmx.Bind.DBEngExt, Fmx.Bind.Grid, System.Bindings.Outputs,Fmx.Bind.Editors, Data.Bind.Grid,//添加对业务实体单元的引用BusinessObjectsU,System.Generics.Collections;由于要处理Master-Detail的关系,这里没有像第5课那样直接在OnCreateAdapter事件中创建ABindSourceAdapter的实例,因为要控制ABindSourceAdapter的实例,所以将2个TListBindSourceAdapter的实例定义在了private区。
  private//代表人员信息的泛型集合类FPeople: TObjectList<TPerson>;//用来存储人员信息的Adapter类。bsPeopleAdapter: TListBindSourceAdapter<TPerson>;//用来存储电子邮件地址的Adapter类。bsEmailsAdapter: TListBindSourceAdapter<TEmail>;
接下来给bsPeople的OnCreateAdapter添加事件处理代码,主要用来实例化bsPeopleAdapter,然后给ABindSourceAdapter赋值,这个事件在TProtoTypeBindSource实例化后触发,先于FormCreate事件,代码如下所示:
procedure TfrmMain.bsPeopleCreateAdapter(Sender: TObject;var ABindSourceAdapter: TBindSourceAdapter);
begin//初始化bsPeopleAdapter类,在这里第2个参数为nil,表示并没有为其指定列表数据。bsPeopleAdapter := TListBindSourceAdapter<TPerson>.Create(self, nil, False);//将bsPeopleAdapter赋给ABindSourceAdapter;ABindSourceAdapter := bsPeopleAdapter;//关联AfterScroll事件,在People切换到下一行时触发bsPeopleAdapter.AfterScroll := PeopleAfterScroll;
end;
在这里构建了一个不带List的TListBindSourceAdapter实例,然后赋给ABindSourceAdapter,并且有趣的是,还给TListBindSourceAdapter关联了一个AfterScroll事件,这个事件在VCL的TQuery之类的控件中很常见。
实际上,将它们视为数据集。
所有的适配器类都从TBindSourceAdapter上继承,TBindSourceAdapter实现了接口IBindSourceAdapter,查看TBindSourceAdapter上公开的方法和属性,会发现许多与 TDataset 相似或完全相同的方法,例如:
- 一个状态属性,类型为 TBindSourceAdapterState,其值有 seInactive、* seBrowse、seEdit 和 seInsert。
- ( BOF 和 EOF 属性,以及 Next、Prior、First 和 Last 方法。
- Edit、Insert、Append、Post 和 Cancel 方法。
- Insert、Open、Post、Scroll 等事件的前置和后置事件,等等……
实现Master-Detail的核心就是在PeopleAfterScroll过程中,当切换到下一个记录时,自动给bsEmail控件的ABindSourceAdapter指定List。
代码如下所示:
procedure TMainForm.PeopleAfterScroll(Adapter: TBindSourceAdapter);
begin//得到当前选中的人员的Emails列表bsEmailsAdapter.SetList(bsPeopleAdapter.List[bsPeopleAdapter.CurrentIndex].Emails, False);//将bsEmails.Active设置为True,其实就是在将其内部的InternalAdapter的Active设置为True.bsEmails.Active := True;//上位到第1行记录。bsEmails.First;
end;
在代码里边,调用bsEmailsAdapter的SetList为bsEmailsAdapter指定了列表值,因为类似于bsPeopleCreateAdapter,它也只是实例化了bsEmailsAdapter,并未给出列表。
然后bsEmails就好像是一个TDataSet开始工作了,指定Active激活,调用其First定位到第1条记录,其实是通过设置咱们在OnCreateAdapter中指定的Adapter来工作的,也就是说bsEmails有一个InternalAdapter的属性,它代表在运行时指定的真正的Adapter。
下面是bsEmailsCreateAdapter的代码:
procedure TMainForm.bsEmailsCreateAdapter(Sender: TObject;var ABindSourceAdapter: TBindSourceAdapter);
begin//初始化bsEmailsAdapter类,在这里第2个参数为nil,表示并没有为其指定列表数据。bsEmailsAdapter := TListBindSourceAdapter<TEmail>.Create(self, nil, False);//将实例赋给 ABindSourceAdapterABindSourceAdapter := bsEmailsAdapter;
end;
现在已经给bsEmails给了列表数据,但是bsPeople还没有指定List,这是在FormCreate事件中完成的,事件代码如下:
procedure TfrmMain.FormCreate(Sender: TObject);
beginRandomize;  //初始化随机因子//创建List实例FPeople := TObjectList<TPerson>.Create(True);LoadData;  //加载随机的人员信息//为bsPeopleAdapter指定ListbsPeopleAdapter.SetList(FPeople, False);//激活UI的显示。bsPeople.Active := True;
end;
由于人员信息是随机生成的,因此第1行代码调用了Randomize初始化随机因子,或什么其他的叫法,就是确保随机数很随机。
然后构建了TObjectList的实例,LoadData是一个私有过程,用来生成随机的人员信息,请拉到本篇最后进行代码拷贝。
同样的给bsPeopleAdapter设置列表。
注意SetList的第2个参数AOwnersObject,指定是否接管这个对象的释放,在这里设置为False,表示自己释放,因此在FormDestroy事件中,要添加对FPeople的Free代码。
procedure TMainForm.FormDestroy(Sender: TObject);
beginFPeople.Free;   //手动释放FPeople对象
end;
LoadData过程会使用RandomUtilsU.pas单元中定义的随机生成函数,因此建议在Interface区的uses子句中添加RandomUtilsU。
  //添加对业务实体单元的引用usesBusinessObjectsU,System.Generics.Collections,RandomUtilsU;
LoadData代码如下:
  private{ Private declarations }//代表人员信息的泛型集合类FPeople: TObjectList<TPerson>;//用来存储人员信息的Adapter类。bsPeopleAdapter: TListBindSourceAdapter<TPerson>;//用来存储电子邮件地址的Adapter类。bsEmailsAdapter: TListBindSourceAdapter<TEmail>;procedure PeopleAfterScroll(Adapter: TBindSourceAdapter);procedure LoadData;
varfrmMain: TfrmMain;implementationprocedure TfrmMain.LoadData;  //加载随机的人员信息
varI: Integer;P: TPerson;X: Integer;
beginfor I := 1 to 100 dobegin//创建随机生成的人员信息P := TPerson.Create(GetRndFirstName, GetRndLastName, 10 + Random(50));// 随机添加1-3个邮件地址for X := 1 to 1 + Random(3) dobeginP.Emails.Add(TEmail.Create(P.FirstName.ToLower + '.' + P.LastName.ToLower+ '@' + GetRndCountry.Replace(' ', '').ToLower + '.com'));end;//添加到列表FPeople.Add(P);end;
end;
感觉到代码实在是有点长,请列位看官多多谅解。
7. 代码主体大致完工,现在可以预览一下是否如预期。

现在可以看到,效果如预期,果然Master-Detail效果出现了。
如果你单击“+”号,一个新的人员信息

最后来一点锦上添花,当用户单击电子邮件的导航栏的“+”号时,弹出一个输入框,允许用户输入电子邮件。
TBindNavigator有一个OnBeforeAction事件,通过实现这个事件来完成这个需求。
procedure TfrmMain.bnEmailBeforeAction(Sender: TObject;Button: TBindNavigateBtn);
varemail: string;
beginif Button = TNavigateButton.nbInsert then  //如果用户单击插入按钮。if InputQuery('Email', '输入新的邮件地址', email) thenbeginbsEmailsAdapter.List.Add(TEmail.Create(email));bsEmails.Refresh; // 刷新邮件列表,用来实现UI同步。bsPeople.Refresh; // 刷新人员列表,用来实现UI同步。Abort; // 中断标准的行为end;
end;
再看看效果:

好了,已经接近预期了,这里还有一些未完工的细节,限于本篇的篇幅,就不再介绍了。
最后附上RandomUtilsU.pas的代码:
unit RandomUtilsU;interfaceconstFirstNames: array [0 .. 9] of string = ('Daniele','Debora','Mattia','Jack','James','William','Joseph','David','Charles','Thomas');LastNames: array [0 .. 9] of string = ('Smith','Johnson','Williams','Brown','Jones','Miller','Davis','Wilson','Martinez','Anderson');Countries: array [0 .. 9] of string = ('Italy','New York','Illinois','Arizona','Nevada','UK','France','Germany','Norway','California');HouseTypes: array [0 .. 9] of string = ('Dogtrot house','Deck House','American Foursquare','Mansion','Patio house','Villa','Georgian House','Georgian Colonial','Cape Dutch','Castle');function GetRndFirstName: String;
function GetRndLastName: String;
function GetRndCountry: String;
function GetRndHouse: String;implementationfunction GetRndHouse: String;
beginResult := 'Mr.' + GetRndLastName + '''s ' + HouseTypes[Random(10)] + ' (' + GetRndCountry + ')';
end;function GetRndCountry: String;
beginResult := Countries[Random(10)];
end;function GetRndFirstName: String;
beginResult := FirstNames[Random(10)];
end;function GetRndLastName: String;
beginResult := LastNames[Random(10)];
end;end.
感谢《Delphi Cookbook》的作者Daniele Spinetti,Daniele Teti,Daniele Teti也是Delphi MVC Framework的开发者,多年前我曾与他有过一次Email来往,在我的博文中,有机会将会详细介绍这个框架。
一点点扩展的思考,对于这个案例可以应用于移动应用,比如在BeforeOpen事件中,从Server端获取JOSN数据,转换成实体对象,也可以在beforePost中将对象转换成JSON,然后发送到Server端进行存储。
下一章,将继续一些深入挖掘LiveBindings的应用,请保持关注哦。

 主从式数据在应用程序的开发中是非常常见的,比如员工和电子邮件地址记录,一个员工可能对应到多个邮件地址,这就形成了一对多的关系。在VCL中,数据控件处理主从式绑定非常方便简洁,在这个示例中,学习如何使用LiveBindings的TProtoTypeBindSource控件来实现对象间的主从式的数据绑定。
主从式数据在应用程序的开发中是非常常见的,比如员工和电子邮件地址记录,一个员工可能对应到多个邮件地址,这就形成了一对多的关系。在VCL中,数据控件处理主从式绑定非常方便简洁,在这个示例中,学习如何使用LiveBindings的TProtoTypeBindSource控件来实现对象间的主从式的数据绑定。