[Почти]-MVC-подход к реализации пользовательского интерфейса в Delphi. Часть 3. Объекты

в 5:43, , рубрики: Delphi, GUI, mvc, mvp, паттерны проектирования, пользовательские интерфейсы, пользовательский интерфейс, Программирование, разработка, метки: , , , , , ,

[Почти] MVC подход к реализации пользовательского интерфейса в Delphi. Часть 3. Объекты
В предыдущих частях статьи (1, 2) я показал, каким образом можно организовать работу с внутренними данными приложения и пользовательским интерфейсом через одну точку входа — модель. Изменения модели автоматически отражались в пользовательском интерфейсе. При этом для упрощения в качестве модели я использовал простые property класса формы, setter которых может привести GUI интерфейс к текущему состоянию модели. В данной части статья я покажу, как интерфейс может реагировать на изменения самих объектов внутри приложения.

Начать данную статью я бы хотел с рассмотрения ошибки, а точнее с неточности, допущенной в предыдущей части статьи. Приведенный там код добавления и удаления ролей у текущего выбранного пользователя корректно изменял внутреннее состояние объекта, но никак не обновлял при этом пользовательский интерфейс. Точнее интерфейс мог бы обновиться лишь после переключения с одного пользователя на другого и обратно. Как верно подметили в комментариях, для исправления данной недоработки достаточно было вставить в код процедур btAddRoleClick, btDelRoleClick вызов метода FillUserRoles. Работать будет, но это совсем не то, что нам нужно. Такой способ плох тем, что во всех местах, где роли сотрудника могут потенциально меняться, нужно каждый раз вставлять вызов по обновлению пользовательского интерфейса. А хочется раз и навсегда забыть о необходимости что-то делать с GUI в тех местах, где мы работаем с объектом. Я хочу, чтобы GUI реагировал на изменения объекта сам и сам перерисовывался, когда я изменяю поля объекта.

Для этого я расширю класс TUser следующим образом:

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;

Я добавил в объект TUser простейшее нотифицирующее событие, которое будет уведомлять нас об изменении списка ролей сотрудника. При этом метод SetRoles класса TUser примет следующий вид:

procedure TUser.SetRoles(Value: TIntList);
begin
  if not SameIntLists(Roles, Value) then
  begin
    Roles := Value;
    DoChange; // Вызываю событие
  end;
end;

Пока событие OnChangeRoles класса TUser не переопределено (по умолчанию FOnChangeRoles имеет значение nil), вызов DoChangeRoles просто ничего не делает. Для того, чтобы можно было как-то реагировать на данное событие, нужно присвоить объектам TUser соответствующий обработчик.
Этот обработчик логично завести у класса формы:

procedure TfmUserRights.ProcRolesChanged(Sender: TObject);
begin
  FillUserRoles;
end;

Теперь нужно навесить этот обработчик события на объекты класса TUser:

procedure TfmUserRights.FillUsers;
var
  i: Integer;
begin
  FUsers.Free; // Удаляю старый список, если он был
  FUsers := GetUsers;
  for i := 0 to FUsers.Count-1 do
     FUsers[i].OnChangeRoles := ProcRolesChanged;
  ...
end;

Вот вобщем-то и все :). Теперь при изменении ролей объекта будет срабатывать событие OnChangeRoles, назначенный обработчик которого будет вызывать FillUserRoles и обновлять GUI (перезаполнять список ролей). С этими правками код из предыдущей статьи будет работать корректно.

Можно ли было сделать лучше?

1) В контексте предыдущей статьи мне нужно было реагировать только на изменение списка ролей, поэтому я завел конкретное событие, реагирующее только на изменение поля Roles класса TUser. Зачастую реагировать нужно на изменение не одного, а нескольких (а может быть и всех) полей объекта. В этом случае лучше было завести событие не OnChangeRoles, а просто OnChange, правда и обработчик его в этом случае должен не только перестраивать список ролей, но и обновлять любую другую информацию о пользователе, которая могла в это время отображаться в окне. Соответственно и вызов DoChange находился бы не только в SetRoles, а также и в setter'ах остальных полей объекта TUser, изменения которых хотелось бы отслеживать. И здесь главная задача не забыть добавить этот вызов DoChange при добавлении нового поля к объекту, т.к. пропустить его довольно легко.
2) Исходя из принципов безопасного программирования, если мы регистрируем обработчик события (как еще говорят, «подписываемся» на событие), то мы должны потом эту подписку убрать («разрегистрировать» обработчик), т.е. вернуть OnChangeRoles в исходное состояние или на худой конец в nil. Нужно ли выполнять эту разрегистрацию, в каждом случае решается индивидуально. В первую очередь это зависит от соотношения времени жизни объектов TUser и объекта формы. Если форма живет дольше TUser'а, то в принципе разрегистрация не обязательна. Если же, наоборот, TUser может еще пожить и после уничтожения формы, то конечно в OnDestroy у формы нужно прописать что-то в духе

  for i := 0 to FUsers.Count-1 do
     FUsers[i].OnChangeRoles := nil;

Если этого не сделать, то при попытке изменения объекта TUser после уничтожения формы TUser может попытаться вызвать обработчик события, ссылающийся на метод уже уничтоженного объекта (формы) и в лучшем случае мы получим Access Violation.
3) Когда мы работаем со списками объектов, присваивать обработчик каждому объекту не всегда удобно. Если элементы списка знают о самом списке (например, ссылаются на него через Owner'а), можно сделать, чтобы DoChange объектов TUser просто вызывал Owner.DoChange, а настраиваемое событие (property FOnChange) завести уже у самого списка (у TObjectList'а). Хотя это вобщем-то ничего по смыслу не меняет.

Рассмотренный способ можно считать подпиской на уведомления с одним подписчиком. Однако, это еще не полноценная подписка на уведомления. Уведомления хороши тем, что на них можно подписать сколько угодно подписчиков. Сейчас мы рассмотрим, как это делается. Давайте переключимся на другую задачу.

Уведомления с несколькими подписчиками

Данный шаблон очень часто применяется в качественно написанных MDI-приложениях (да и вообще в любых многооконных приложениях). Шаблон используется, когда в нескольких окнах системы могут отображаться одни и те же данные и при изменении этих данных через одно окно нужно чтобы они синхронно обновлялись во всех окнах. При этом данные окна не обязательно являются экземплярами окна одного класса и не обязательно имеют одинаковый пользовательский интерфейс. Напротив, окна могут быть совершенно разными. Они лишь отображают одну и ту же информацию. Например, в одном окне отображается список сотрудников, а в другом — карточка этого сотрудника, где можно изменить какие-то его характеристики. При этом требуется, чтобы по нажатию кнопки «Сохранить» в карточке сотрудника данные обновлялись бы как в карточке сотрудника, так и в общем списке сотрудников.
Шаблон множественной подписки на уведомления удобно применять при наличии долгоживущего объекта. Его время жизни должно быть заведомо больше времени жизни тех объектов, которые подписываются на уведомления от него. Допустим, у нас есть какой-то класс-менеджер, отвечающий за работу с сотрудниками (в частности за сохранение изменений объектов TUser в базу):

TUsersMngr = class
public
  procedure SaveUser(aUser: TUser);
end;

На вызовы SaveUser хотят реагировать все окна, в которых может отображаться какая-либо относящаяся к сотруднику информация. В этом случае классу TUserMngr придется хранить ссылки на все обработчики, которые могут подписаться на событие сохранения сотрудника:

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;

Имея такой функционал, вы можете легко подписаться на изменения интересующих вас объектов из любого окна:

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;

Теперь, когда мы поняли, как это будет использоваться, вернемся непосредственно к моменту нотификации, т.е. к моменту срабатывания события:

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;

Теперь при сохранении объекта TUser все формы будут уведомляться об этом, если они не забыли подписаться на соответствующее событие.

Блокировка срабатывания обработчиков

Приведенный выше код является хорошим до тех пор, пока в системе не появляются операции сразу над большим количеством объектов. Возможно, не самый лучший пример: группа сотрудников прошла обучение и каждый из них получил какой-то одинаковый для всех сертификат. Мы выделяем 10 сотрудников в списке, жмем «Добавить сертификат». Далее поочередно происходит вызов UserMngr.Save для каждого из этих 10 сотрудников. При этом после сохранения каждого сотрудника срабатывает событие изменения DoUserChangeNotify, которое приводит к перестроению списка сотрудников во всех открытых окнах (а каждое перестроение будет еще приводить к перезапросу списка сотрудников из БД или с сервера приложений). В итоге сохранение изменений для 10 сотрудников будет происходить оооочень медленно и вдобавок мы получим массу миганий в открытых окнах приложения (списки будут перестраиваться по 10 раз). Сейчас я опишу простой способ, как этого избежать:

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;

Метод нотификации при этом тоже изменится:

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;

Через FLock отслеживается уровень блокировки (допускаются вложенные вызовы BeginUpdate..EndUpdate). FChanged — это флажок, позволяющий нам запомнить, происходило ли хотя бы один раз срабатывание события внутри сеанса блокировки. Если оно действительно происходило, то в момент выхода из сеанса блокировки (т.е. в момент вызова EndUpdate самого верхнего уровня), событие будет автоматически вызвано.

Таким образом, код изменения множества объектов можно легко защитить от излишних срабатываний событий:

UsersMngr.BeginUpdate;
try
  for i := 0 to FSomeUsers[i] do
    UsersMngr.Save(FSomeUsers[i]);
finally
  UsersMngr.EndUpdate;
end;

Подобную блокировку удобно применять и в других случаях, например, когда нужно перевести объект из одного состояния в другое, изменив при этом не одно, а несколько его полей. При этом некоторые промежуточные состояния объекта (некоторые комбинации значений полей) могут считаться недопустимыми с точки зрения GUI. Соответственно нужно не допустить, чтобы GUI вообще узнал о том, что объект проходил через такие состояния. В таком случае изменение объекта также проводится внутри сеанса его обновления, когда срабатывание событий об изменении этого объекта заблокировано.

Итог

События — один из хороших приемов для связи объектов с GUI. Данный шаблон применяется не только при программировании GUI, но и во многих других случаях. В статье мы рассмотрели варианты реализации подписки на уведомления с одним и с множественными подписчиками. На этом цикл статей о программировании GUI в MVC-стиле скорее всего будет завершен. Если у кого-то остались вопросы именно по подходам к реализации GUI в Delphi, прошу оставлять их в комментариях и, возможно, данный цикл статей будет успешно продолжен. Также предлагаю в комментариях (а может и в отдельных статьях!) делиться своими приемами успешной реализации типовых задач на Delphi. И не надо закапывать никаких стюардесс, Delphi еще поживет ;)

Всем хорошего дня!

PS Ссылки на предыдущие части статьи:
Часть 1. Галочка.
Часть 2. Списки.

Автор: alan008

* - обязательные к заполнению поля