MVC approach to user interface implementation in Delphi. Part 3. Objects
- Tutorial

In previous parts of this article ( 1 , 2 ), I have shown how it is possible to organize the work with internal data applications and user interfaces through a single point of entry - model. Model changes are automatically reflected in the user interface. At the same time, to simplify, as a model, I used simple property of the form class, the setter of which can bring the GUI interface to the current state of the model. In this part of the article, I will show how the interface can respond to changes in the objects themselves within the application.
I would like to start this article by examining the error, or rather, with the inaccuracy made in the previous part of the article. The code for adding and removing roles for the current selected user correctly changed the internal state of the object, but did not update the user interface. More precisely, the interface could be updated only after switching from one user to another and vice versa. As correctly noted in the comments, to correct this defect it was enough to insert a call to the FillUserRoles method in the btAddRoleClick, btDelRoleClick procedure code. It will work, but this is not at all what we need. This method is bad because in all places where employee roles can potentially change, you need to insert a call to update the user interface each time. And I want to forget once and for all about the need to do something with the GUI in those places where we work with the object. I want the GUI to react to changes to the object itself and redraw itself when I change the fields of the object.
To do this, I will extend the TUser class as follows:
TUser = class
private
...
FOnChangeRoles: TNotifyEvent;
protected
...
procedure DoChangeRoles;
public
...
property OnChangeRoles: TNotifyEvent read FOnChangeRoles write FOnChangeRoles;
end;
procedure TUser.DoChangeRoles;
begin
if Assigned(FOnChangeRoles) then
FOnChangeRoles(Self);
end;
I added a simple notifying event to the TUser object that will notify us of a change in the list of employee roles. In this case, the SetRoles method of the TUser class will take the following form:
procedure TUser.SetRoles(Value: TIntList);
begin
if not SameIntLists(Roles, Value) then
begin
Roles := Value;
DoChange; // Вызываю событие
end;
end;
Until the TUser class OnChangeRoles event is overridden (FOnChangeRoles is nil by default), calling DoChangeRoles just does nothing. In order to be able to somehow respond to this event, you need to assign the appropriate handler to the TUser objects.
It is logical to get this handler in the form class:
procedure TfmUserRights.ProcRolesChanged(Sender: TObject);
begin
FillUserRoles;
end;
Now we need to hang this event handler on objects of the TUser class:
procedure TfmUserRights.FillUsers;
var
i: Integer;
begin
FUsers.Free; // Удаляю старый список, если он был
FUsers := GetUsers;
for i := 0 to FUsers.Count-1 do
FUsers[i].OnChangeRoles := ProcRolesChanged;
...
end;
That's just about it :). Now, when changing the roles of an object, the OnChangeRoles event will fire, the designated handler of which will call FillUserRoles and update the GUI (replenish the list of roles). With these changes, the code from the previous article will work correctly.
Could it have been better?
1) In the context of the previous article, I needed to respond only to a change in the list of roles, so I brought up a specific event that responds only to a change in the Roles field of the TUser class. Often, you need to respond to changes in not one, but several (or maybe all) fields of an object. In this case, it was better to start the event, not OnChangeRoles, but simply OnChange, though the processor in this case should not only rebuild the list of roles, but also update any other information about the user that could be displayed in the window at that time. Accordingly, the DoChange call would be located not only in SetRoles, but also in the setters of the remaining fields of the TUser object, whose changes we would like to track. And here the main task is not to forget to add this DoChange call when adding a new field to the object, because skipping it is pretty easy.
2) Based on the principles of safe programming, if we register an event handler (as they say, “subscribe” to an event), then we must then remove this subscription (“unregister” the processor), i.e. return OnChangeRoles to its original state or at worst to nil. Whether it is necessary to perform this deregistration is decided individually in each case. First of all, it depends on the ratio of the lifetime of TUser objects and the shape object. If the form lives longer than TUser, then, in principle, re-registration is not required. If, on the contrary, TUser can still live after the form is destroyed, then of course you need to register something in the form in OnDestroy
for i := 0 to FUsers.Count-1 do
FUsers[i].OnChangeRoles := nil;
If this is not done, then when you try to change the TUser object after the form is destroyed, TUser can try to call an event handler that refers to the method of the already destroyed object (form), and in the best case, we will get Access Violation.
3) When we work with lists of objects, assigning a handler to each object is not always convenient. If the elements of the list know about the list itself (for example, refer to it through Owner), you can make the DoChange of TUser objects simply call Owner.DoChange, and set up a custom event (property FOnChange) at the list itself (in TObjectList) . Although this in general does not change anything in meaning.
The considered method can be considered a subscription to notifications with one subscriber. However, this is not yet a full subscription to notifications. Notifications are good because you can subscribe to as many subscribers as you like. Now we will look at how this is done. Let's switch to another task.
Multi-Subscriber Notifications
This template is very often used in high-quality MDI applications (and indeed in any multi-window applications). The template is used when the same data can be displayed in several windows of the system and when changing this data through one window, it is necessary that they be updated synchronously in all windows. However, these windows are not necessarily instances of a window of the same class and do not necessarily have the same user interface. On the contrary, windows can be completely different. They only display the same information. For example, in one window a list of employees is displayed, and in another - a card of this employee, where you can change some of his characteristics. It is required that by clicking the "Save" button in the employee card, the data would be updated both in the employee card and in the general list of employees.
The multiple notification subscription template is convenient to use if you have a long-lived object. His lifetime should be deliberately longer than the lifetime of those objects that subscribe to notifications from him. Suppose we have some class manager responsible for working with employees (in particular, saving changes to TUser objects to the database):
TUsersMngr = class
public
procedure SaveUser(aUser: TUser);
end;
All windows in which any information related to the employee may be displayed want to respond to SaveUser calls. In this case, the TUserMngr class will have to store links to all handlers that can subscribe to the employee save event:
TUsersMngr = class
private
FNotifiers: array of TNotifyEvent;
public
procedure RegChangeNotifier(const aProc: TNotifyEvent);
procedure UnregChangeNotifier(const aProc: TNotifyEvent);
function NotifierRegistered(const aProc: TNotifyEvent): Boolean;
end;
procedure TUsersMngr.RegChangeNotifier(const aProc: TNotifyEvent);
var
i: Integer;
begin
if NotifierRegistered(aProc) then
Exit;
i := Length(FNotifiers);
SetLength(FNotifiers, i+1);
FNotifiers[i] := aProc;
end;
procedure TUsersMngr.UnregChangeNotifier(const aProc: TNotifyEvent);
var
i: Integer;
vDel: Boolean;
begin
// Пользуясь фактом, что дублей обработчиков быть не может (эта проверка реализуется в RegChangeNotifier), слегка оптимизирую операцию удаления
vDel := False;
for i := 0 to High(FNotifiers) do
if vDel then
FNotifiers[i-1] := FNotifiers[i]
else
if (TMethod(aProc).Code = TMethod(FNotifiers[i]).Code) and
(TMethod(aProc).Data = TMethod(FNotifiers[i]).Data) then
vDel := True;
if vDel then
SetLength(FNotifiers, Length(FNotifiers) - 1);
end;
function TUsersMngr.NotifierRegistered(
const aProc: TNotifyEvent): Boolean;
var
i: Integer;
begin
// Методы объектов вполне допустимо приводить к TMethod для сравнения
for i := 0 to High(FNotifiers) do
if (TMethod(aProc).Code = TMethod(FNotifiers[i]).Code) and
(TMethod(aProc).Data = TMethod(FNotifiers[i]).Data) then
begin
Result := True;
Exit;
end;
Result := False;
end;
With this functionality, you can easily subscribe to changes of objects of interest to you from any window:
procedure TUsersListForm.FormCreate(Sender: TObject);
begin
...
UsersMngr.RegChangeNotifier(ProcUsersChanges);
end;
procedure TUsersListForm.FormDestroy(Sender: TObject);
begin
UsersMngr.UnregChangeNotifier(ProcUsersChanges);
...
end;
procedure TUsersListForm.ProcUsersChanged(Sender: TObject);
begin
RefillUsersList;
end;
Now that we have understood how this will be used, we will return directly to the moment of notification, i.e. by the time the event is triggered:
procedure TUsersMngr.SaveUser(aUser: TUser);
begin
if aUser.Changed then
begin
...
// Сохраняем изменения aUser в БД
...
DoUserChangeNotify; // Вызываю событие
end;
end;
procedure TUsersMngr.DoUserChangeNotify;
var
i: Integer;
begin
for i := 0 to High(FNotifiers) do
FNotifiers[i](Self);
end;
Now, when a TUser object is saved, all forms will be notified about this if they do not forget to subscribe to the corresponding event.
Handler Lockout
The above code is good until operations on a large number of objects appear immediately in the system. Perhaps not the best example: a group of employees went through training and each of them received some kind of certificate that was the same for everyone. We select 10 employees in the list, click "Add Certificate". Next, a call to UserMngr.Save takes place for each of these 10 employees. In this case, after saving each employee, the DoUserChangeNotify change event is triggered, which leads to a rebuild of the employee list in all open windows (and each rebuild will also result in a reboot of the employee list from the database or from the application server). As a result, changes will be saved for 10 employees sooooo slowly and in addition we will get a lot of blinks in the open application windows (the lists will be rebuilt 10 times). Now I will describe a simple way to avoid this:
TUsersMngr = class
private
FLock: Integer;
FChanged: Boolean;
public
procedure BeginUpdate;
procedure EndUpdate;
end;
procedure TUsersMngr.Create;
begin
...
FLock := 0;
FChanged := False;
end;
procedure TUsersMngr.BeginUpdate;
begin
Inc(FLock);
end;
procedure TUsersMngr.EndUpdate;
begin
Assert(FLock > 0);
Dec(FLock);
if (FLock = 0) and Changed then
DoUserChangeNotify(Self);
end;
The notification method will also change:
procedure TUsersMngr.DoUserChangeNotify;
var
i: Integer;
begin
if FLock > 0 then // Мы в режиме подавления событий
begin
FChanged := True; // Запоминаем, что есть подавленное событие
Exit;
end;
FChanged := False;
for i := 0 to High(FNotifiers) do
FNotifiers[i](Self);
end;
The blocking level is monitored via FLock (nested calls to BeginUpdate..EndUpdate are allowed). FChanged is a flag that allows us to remember whether at least once an event was triggered within the blocking session. If it really happened, then at the moment of exiting the blocking session (i.e. at the moment of calling EndUpdate of the highest level), the event will be automatically called.
Thus, the code for changing many objects can be easily protected from excessive events:
UsersMngr.BeginUpdate;
try
for i := 0 to FSomeUsers[i] do
UsersMngr.Save(FSomeUsers[i]);
finally
UsersMngr.EndUpdate;
end;
It is convenient to use such a lock in other cases, for example, when you need to transfer an object from one state to another, while changing not one but several of its fields. Moreover, some intermediate states of the object (some combinations of field values) may be considered invalid from the point of view of the GUI. Accordingly, you must not allow the GUI to know at all that the object has passed through such states. In this case, the change of the object is also carried out inside the session of updating it when the triggering of events about the change of this object is blocked.
Total
Events are one of the good tricks for communicating objects with a GUI. This template is used not only for GUI programming, but also in many other cases. In the article, we examined the options for implementing a subscription to notifications with one and with multiple subscribers. With this, a series of articles on GUI programming in MVC-style is likely to be completed. If someone still has questions about the approaches to implementing the GUI in Delphi, please leave them in the comments and, perhaps, this series of articles will be successfully continued. I also suggest in the comments (or maybe in separate articles!) To share my tricks for the successful implementation of typical tasks in Delphi. And do not bury any stewardesses , Delphi will still live;) Have a
nice day!
PS Links to previous parts of the article:
Part 1. Checkmark.
Part 2. Lists.