一步一步学习使用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。

你的项目结构应该像这样:
img

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。

整体的布局大概如下所示:

img

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

4. 右击页面空白处,从弹出的菜单中选择“Bind Visually”进入LiveBindings Designer设计器,按如下步骤完成绑定操作。

虽然看起来LiveBindings是在将数据与UI进行链接,其实到目前为止,所做的工作是在UI与BindSource进行操作,至于BindSource是连接到底层的数据库表还是对象,虽然在本篇中已经说明是对象,但是对于UI控件来说,目前是不清楚底层数据到底是数据库还是对象类型的,也无需顾及。

进入设计器后,可以看到BindNavigator由于指定了DataSource属性,所以设计器已经自动添加了链接。

首先,将bsPeople中的每一个栏位拖动到grdPeople中,不使用*是因为想对每一个列进行调整。而使用*是不可以的。

img

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

最后将3个Edit控件也链接上。
img

可以看到,LiveBindings Designer对于TEdit和TGrid都给了以向数据绑定(链接线2边都有箭头)。即用户在UI上的更改也可以更新回底层数据存储。

现在运行程序,可以看到通过BindNavigator,可以对People进行移动,但是相应的Email并不会发生变化。不用担心,底层的数据操作会完成这个功能。

img

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

img

代码如下所示:

unit BusinessObjectsU;

interface

uses
  System.Generics.Collections;

type
  /// <summary>
  /// Email实体类,仅简单的记录了邮件地址。
  /// <summary>
  TEmail = class
  private
    FAddress: 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 = class
  private
    FLastName: 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;

implementation

uses
  System.SysUtils;

{ TPersona }

constructor TPerson.Create(const FirstName, LastName: string; Age: Integer);
begin
  Create;
  FFirstName := FirstName;
  FLastName := LastName;
  FAge := Age;
end;

// 由LiveBindings调用来插入一个新行。
constructor TPerson.Create;
begin
  inherited Create;
  FFirstName := '<name>';
  //初始化邮件列表
  FEmails := TObjectList<TEmail>.Create(true);
end;

destructor TPerson.Destroy;
begin
  FEmails.Free;
  inherited;
end;

function TPerson.GetEmailsCount: Integer;
begin
  Result := FEmails.Count;
end;

procedure TPerson.SetLastName(const Value: String);
begin
  FLastName := Value;
end;

procedure TPerson.SetAge(const Value: Integer);
begin
  FAge := Value;
end;

procedure TPerson.SetFirstName(const Value: String);
begin
  FFirstName := Value;
end;

{ TEmail }

constructor TEmail.Create(AEmail: String);
begin
  inherited Create;
  FAddress := AEmail;
end;

// 由LiveBindings调用来插入一个新行。
constructor TEmail.Create;
begin
  Create('<email>');
end;

procedure TEmail.SetAddress(const Value: String);
begin
  FAddress := Value;
end;

end.

两个实体类都包含了重载的构造函数,不带参数的构造函数将由LiveBindings调用来生成新的行,而带参数的构造函数将用来生成初始数据,这些数据可以是来自底层的数据库表,也可以是像示例这样,使用了一个随机数单元来生成数据数据。

6. 回到主窗体,开始对主窗体进行编码了。前面的步骤中在主窗体上放了2个TProtoTypeBindSource控件,这2个控件自带数据生成器,它就好像是TAdapterBindSource和TDataGeneratorAdapter的结合体。因此它也提供了OnCreateAdapter事件,通过处理这个事件,来将前面创建的实体数据集合桥接给UI控件。

类似于第5课的代码,首先需要在窗体类的private中添加泛型的集合类FPeople,第1步是添加对实体类单元的引用。

uses
  System.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);
  //将实例赋给 ABindSourceAdapter
  ABindSourceAdapter := bsEmailsAdapter;
end;

现在已经给bsEmails给了列表数据,但是bsPeople还没有指定List,这是在FormCreate事件中完成的,事件代码如下:

procedure TfrmMain.FormCreate(Sender: TObject);
begin
  Randomize;  //初始化随机因子
  //创建List实例
  FPeople := TObjectList<TPerson>.Create(True);
  LoadData;  //加载随机的人员信息
  //为bsPeopleAdapter指定List
  bsPeopleAdapter.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);
begin
  FPeople.Free;   //手动释放FPeople对象
end;

LoadData过程会使用RandomUtilsU.pas单元中定义的随机生成函数,因此建议在Interface区的uses子句中添加RandomUtilsU。

  //添加对业务实体单元的引用
  uses

  BusinessObjectsU,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;
var
  frmMain: TfrmMain;

implementation

procedure TfrmMain.LoadData;  //加载随机的人员信息
var
  I: Integer;
  P: TPerson;
  X: Integer;
begin
  for I := 1 to 100 do
  begin
    //创建随机生成的人员信息
    P := TPerson.Create(GetRndFirstName, GetRndLastName, 10 + Random(50));
    // 随机添加1-3个邮件地址
    for X := 1 to 1 + Random(3) do
    begin
      P.Emails.Add(TEmail.Create(P.FirstName.ToLower + '.' + P.LastName.ToLower
        + '@' + GetRndCountry.Replace(' ', '').ToLower + '.com'));
    end;
    //添加到列表
    FPeople.Add(P);
  end;
end;

感觉到代码实在是有点长,请列位看官多多谅解。

7. 代码主体大致完工,现在可以预览一下是否如预期。

img

现在可以看到,效果如预期,果然Master-Detail效果出现了。

如果你单击“+”号,一个新的人员信息就出现了,邮件列表变为空,很明显UI是进行了数据感知。这是调用到了TPeople的默认的无参数构造函数。

img

最后来一点锦上添花,当用户单击电子邮件的导航栏的“+”号时,弹出一个输入框,允许用户输入电子邮件。

TBindNavigator有一个OnBeforeAction事件,通过实现这个事件来完成这个需求。

procedure TfrmMain.bnEmailBeforeAction(Sender: TObject;
  Button: TBindNavigateBtn);
var
  email: string;
begin
  if Button = TNavigateButton.nbInsert then  //如果用户单击插入按钮。
    if InputQuery('Email', '输入新的邮件地址', email) then
    begin
      bsEmailsAdapter.List.Add(TEmail.Create(email));
      bsEmails.Refresh; // 刷新邮件列表,用来实现UI同步。
      bsPeople.Refresh; // 刷新人员列表,用来实现UI同步。
      Abort; // 中断标准的行为
    end;
end;

再看看效果:

img

好了,已经接近预期了,这里还有一些未完工的细节,限于本篇的篇幅,就不再介绍了。

最后附上RandomUtilsU.pas的代码:

unit RandomUtilsU;

interface

const
  FirstNames: 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;

implementation

function GetRndHouse: String;
begin
  Result := 'Mr.' + GetRndLastName + '''s ' + HouseTypes[Random(10)] + ' (' + GetRndCountry + ')';
end;

function GetRndCountry: String;
begin
  Result := Countries[Random(10)];
end;

function GetRndFirstName: String;
begin
  Result := FirstNames[Random(10)];
end;

function GetRndLastName: String;
begin
  Result := LastNames[Random(10)];
end;

end.

感谢《Delphi Cookbook》的作者Daniele Spinetti,Daniele Teti,Daniele Teti也是Delphi MVC Framework的开发者,多年前我曾与他有过一次Email来往,在我的博文中,有机会将会详细介绍这个框架。

一点点扩展的思考,对于这个案例可以应用于移动应用,比如在BeforeOpen事件中,从Server端获取JOSN数据,转换成实体对象,也可以在beforePost中将对象转换成JSON,然后发送到Server端进行存储。

下一章,将继续一些深入挖掘LiveBindings的应用,请保持关注哦。

posted @ 2025-08-03 08:13  lincats  阅读(114)  评论(0)    收藏  举报