[Не совсем]-MVC-подход к разработке пользовательских интерфейсов в Delphi. Часть 2. Списки

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

[Не совсем] MVC подход к разработке пользовательских интерфейсов в Delphi. Часть 2. Списки

Предыдущая статья была посвящена всего одной галочке. Пора переходить к чему-то чуть более серьезному. Сегодняшняя тема — представление списков и связь GUI-списков с внутренними данными. Статья предназначена для Delphi-разработчиков.

С чего начать

Чтобы не лить воду, перейду сразу к живому примеру, приведенному на рисунке выше. Допустим, вам нужно создать примитивную форму настройки прав пользователей.
В левой части окна показывается список всех пользователей системы, а в правой — список прав и ролей текущего выбранного пользователя. Логика окна заключается в том, чтобы при выборе пользователя в левой части окна обновлялся список прав и ролей в правой части. Также в правой части есть кнопки «Добавить»/ «Удалить», позволяющие либо добавить пользователю новую роль или удалить выбранные существующие роли. При добавлении новых ролей появляется всплывающее окно справочника ролей, в котором можно выбрать добавляемые роли. Вот, собственно, и все.

Модель

Допустим, что внутреннее представление данных состоит из класса TUser, описывающего сотрудника, и справочника ролей, который умеет по числовому ID'у возвращать название роли. Заводить классы для ролей нецелесообразно, т.к. это слишком простая сущность:

uses
  Generics.Collections; // Чтобы можно было использовать типизированный TObjectList

type
  TIntList = array of Integer; // Данный тип объявлен в отдельном общем модуле

  TUser = class
  strict private
    FID: Integer;
    FFullFio: String;
    FRoles: TIntList;
  public
    property ID: Integer read FID;
    property FullFio: String read FFullFio;
    property Roles: TIntList read FRoles write SetRoles;
  end;

  TUsersList = class(TObjectList<TUser>)
  public
    function UserByID(const aID: Integer): TUser;
  end;

Видно, что роли пользователя представлены крайне простым образом — списком ID'ов.

Добавляю соответствующие поля классу формы:

TfmUserRights = class(TForm)
...
  lbUsers: TListBox;
  lbRoles: TListBox;    
private
  FUsers: TUsersList; 
public
  property Users: TUsersList read FUsers;
end;

Обратите внимание, что я использовал типизированный TObjectList. До Delphi 2009 такой возможности не было и TObjectList хранил всегда просто TObject'ы. При каждом обращении к элементу списка приходилось его приводить к корректному классу: FUsers[i] as TUser (ну или вариант для камикадзе: TUser(FUsers[i])). Это было неудобно и легко было допустить ошибку, выполнив преобразование не к тому классу. С появлением обобщенных типов (generics) теперь можно использовать жестко типизированный TObjectList. Это невероятно удобно! Обращаясь к элементам такого списка через FUsers[i] мы сразу получаем объект класса TUser.

Я не буду приводить код получения списка сотрудников, т.к. в каждой системе в зависимости от ее архитектуры он будет свой. Это может быть SQL-запрос к базе, обращение к какому-то клиентскому кэшу или обращение к серверу приложений (в многозвенной архитектуре). Предположим просто, что у вас есть возможность откуда-то получить этот список.

Отображение элементов списка

Итак, мы хотим получить список сотрудников и отобразить его на экране:

procedure TfmUserRights.FormCreate(Sender: TObject);
begin
  FillUsers;
end;

Метод Fill предназначен для простого [пере]заполнения списка пользователей:

procedure TfmUserRights.FillUsers;
var
  i: Integer;
begin
  FUsers.Free; // Удаляю старый список, если он был
  FUsers := GetUsers;

  lbUsers.Items.BeginUpdate;
  try
    lbUsers.Items.Clear;

    for i := 0 to Users.Count-1 do
      lbUsers.AddItem(FUsers[i].FullFio, FUsers[i]); 

    // Добавляемый элемент списка сразу получает связь с объектом FUsers[i], 
    // хотя в моем случае хватило бы и связи с ID'ами (позже вы увидите, почему)
  finally
    lbUsers.Items.EndUpdate;
  end;
end;

Простого заполнения списка сотрудников недостаточно. Нужно еще показать роли текущего выбранного сотрудника. А для этого нужно научиться определять, какой сотрудник сейчас выбран? Неопытные программисты начинают активно обращаться из разных мест к lbUsers.Items.Objects[lbUsers.ItemIndex]. Однако, если вы читали предыдущую часть статьи, то уже догадываетесь, что мы пойдем другим путем. Мы заведем у класса формы свойство, возвращающее и устанавливающее текущего выбранного сотрудника. Возвращать можно либо сам объект TUser, либо числовой ID пользователя. Возвращать ID мне показалось удобнее, хотя с этим можно поспорить.

TfmUserRights = class(TForm)
private
  FSelUserID: Integer;
public
  property SelUserID: Integer read FSelUserID write SetSelUserID;
end;

procedure TfmUserRights.SetSelUserID(const Value: Integer);
begin
  if FSelUserID <> Value then
  begin
    FSelUserID := Value;
    UpdateSelUser; // !!!
  end;
end;

Ключевой момент здесь в методе UpdateSelUser, который приводит интерфейс в состояние, при котором выбран заданный пользователь:

procedure TfmUserRights.UpdateSelUser;
var
  vSelInd: Integer;
  i: Integer;
begin
  vSelInd := -1;
  with lbUsers do
    for i := 0 to Items.Count-1 do
      if (Items.Objects[i] as TUser).ID = SelUserID then
      begin
        vSelInd := i;
        Break;
      end;
   lbUsers.ItemIndex := vSelInd;
   if SelUserID <= 0 then
      gbRoles.Caption := 'Права и роли:'
  else
      gbRoles.Caption := 'Права и роли: ' + Users.UserByID(SelUserID).FullFio

  FillUserRoles; // !!!
end;

Мы видим, что метод установки текущего пользователя всегда вызывает перезаполнение списка ролей (FillUserRoles).

Как и в предыдущей статье, раз мы реализовали направление синхронизации Модель->Представление, то нам нужна и обратная синхронизация. Поэтому в событии OnClick списка lbUsers добавим такой код:

procedure TfmUserRights.lbUsersClick(Sender: TObject);
begin
  SelUserID := (lbUsers.Items.Objects[lbUsers.ItemIndex] as TUser).ID;
end;

При задании SelUserID, если раньше был выбран другой пользоваль, то set-метод вызовет UpdateSelUser, который в свою очередь полностью синхронизирует представление с моделью, а именно обновит список ролей. Т.е. мне уже не нужно вызывать метод обновления списка ролей изнутри обработчика lbUsersClick, все произойдет автоматически.

Приведу метод заполнения списка ролей (он тривиален):

procedure TfmUserRights.FillUserRoles;
var
  i: Integer;
  vSelUser: TUser;
begin
  lbRoles.BeginUpdate;
  try
    lbRoles.Clear;
    
    if SelUserID <= 0 then 
      Exit;

    vSelUser := Users.UserByID(SelUserID); 
    for i := 0 to High(vSelUser.Roles) do
      lbRoles.AddItem(DictRoles.NameByID(vSelUser.Roles[i]), TObject(vSelUser.Roles[i])); 
      // Тут я сделал небольшую хитрость и привязал к элементам списка не объекты, а сами ID'ы, использовав приведение их к TObject'у (это допустимо)

  finally
    SomeList.EndUpdate;
  end;
end;

Код инициализации формы я дополню ициниализацией первого пользователя в списке:

procedure TfmUserRights.FormCreate(Sender: TObject);
begin
  FUsers := GetUserList;
  FillUsers;
  FSelUserID := -2; // Хочу, чтобы сработал Set-метод
  SelUserID := -1; // По умолчанию не выбираю никакого пользователя
end;

Что мы получили? Теперь обращаться к текущему выбарнному пользователю можно через SelUserID. Причем как при программной установке значения свойства SelUserID, так и при выборе пользователя через GUI-список будет автоматически обновляться список ролей.

Для работы с ролями (добавление, удаление) можно завести у класса формыеще свойство SelRoles. Его проще сделать полностью виртуальным (не заводить для него отдельное поле):

property SelRoles: TIntList read GetSelRoles write SetSelRoles;

function TfmUserRights.GetSelRoles: TIntList;
var
  i: Integer;
begin
  Result := nil;
  for i := 0 to lbRoles.Items.Count-1 do
    if lbRoles.Selected[i] then
      AddIntToList(Integer(lbRoles.Items.Objects[i]), Result); 
   // Помните про вышеописанную хитрость? На самом деле в Objects'ах  
   // сидят не объекты, а ID'ы ролей, поэтому смело привожу их к Integer
end;

procedure TfmReportMain.SetSelRoles(const aSelRoles: TIntList);
var
  i: Integer;
begin
  lbRoles.Items.BeginUpdate;
  try
    for i := 0 to lbRoles.Items.Count-1 do
      lbRoles.Selected[i] := IntInList(Integer(lbRoles.Items.Objects[i]), aSelRoles);
  finally
    lbRoles.Items.EndUpdate;
  end;
  UpdateSelRoles; // Этого метода может и не быть. В нем можно разместить код, к примеру, выводящий фразу "Выбрано N ролей" на статус баре или где-то еще
end;

Методы IntInList и AddIntToList соответственно проверяют вхождение элемента в массив и добавляют новый элемент в массив.

Добавление и удаление ролей

Добавление ролей:

procedure TfmUserRights.btAddRoleClick(Sender: TObject);
var
  vSelUser: TUser;
  vRoles: TIntList;
  vAddRoles: TIntList;
  i: Integer;
begin
  vAddRoles := nil;
  vAddRoles := TfmDictionary.GelDictIDs(DictRoles); // Получаю список ID'ов выбранных ролей из всплывающего окна справочника

  vSelUser :=  Users.UserByID(SelUserID);
  vRoles := vSelUser.Roles;
  for i := 0 to High(vAddRoles) do
    AddIntToList(vAddRoles[i], vRoles); 
  vSelUser.Roles := vRoles;

  // После добавления новых ролей сразу выделяю их в списке ролей (визуально это удобно)
  SelRoles := vAddRoles;
end;

Удаление ролей:

procedure TfmUserRights.btDelRoleClick(Sender: TObject);
var
  vSelUser: TUser;
  vDelRoles: TIntList;
  vRoles: TIntList;
  vNewRoles: TIntList;
  i, vInd: Integer;
begin
  if lbAllowRightsRoles.SelCount = 0 then
    raise Exception.Create('Необходимо выделить в списке удаляемые роли.');

  vDelRoles := SelRoles;
  vSelUser :=  Users.UserByID(SelUserID);
  vRoles := vSelUser.Roles;
  SetLength(vNewRoles, Length(vRoles)); // размер завожу про запас
  // В vNewRoles переношу только те роли, которые не входят в список удаляемых
  vInd := 0;
  for i := 0 to High(vRoles) do
  begin
    if IntInList(vRoles[i], vDelRoles) then
      Continue;

    vNewRoles[vInd] := vRoles[i];
    inc(vInd);
  end;
  SetLength(vNewRoles, vInd); // усекаю до корректного размера

  vSelUser.Roles := vNewRoles;
end;

В каком месте осуществлять сохранение изменений объекта TUser в БД решать вам. Кто-то, возможно, захочет делать это немедленно, прямо внутри SetRoles класса TUser (чтобы все изменения отражались в базе мгновенно). Кто-то реализует сохранение измененных объектов TUser при нажатии на кнопку OK в окне. Третьим вариантом является сохранение по кнопке ОК, а также при попытке переключения между пользователями, если роли текущего пользователя были изменены (т.к. приведенный выше интерфейс окна не позволяет визуально отследить, у каких сотрудников роли поменялись, а у каких — нет, при переключении с одного сотрудника на другого, что может привести к ошибке).

Итог

Получилось окно управления правами пользователей. Окно реализует следующую логику:
1) Запрос списка сотрудников.
2) Отображение списка сотрудников.
3) Получение ID'а текущего выбранного сотрудника через SelUserID.
4) Установка выбранного сотрудника по ID'у с автоматическим обновлением списка его ролей.
5) Получение списка выбранных ролей сотрудника через SelRoles.
6) Добавление и удаление ролей.

Дополнение. Обновление списка с сохранением выбранного элемента

Здесь можно было бы и остановиться, но все-таки хочется показать, как можно обновлять и сам список сотрудников, не теряя при этом текущего выбранного сотрудника. Функциональность ручного обновления списка сотрудников может быть полезной, если добавление сотрудников производится через другое окно, а механизм автоматической нотификации окна изменения прав о добавлении нового сотрудника не реализован. Также нового сотрудника может добавить другой пользователь системы на другой машине, а вам не хочется перезаходить в окно настройки прав, чтобы добавленный пользователь появился в списке.

Итак, допустим вы добавили еще кнопку «Обновить список сотрудников» в окно настройки прав. Очевидно, что она должна приводить к простому вызову метода FillUsers. Но ведь тогда текущий выбранный сотрудник потеряется (т.к. GUI список будет очищен и переазполнен заново), что будет очень неудобно и странно для пользователя.

procedure TfmUserRights.FillUsers;
var
  i: Integer;
  vSavedSelUserID: Integer;
begin  
  // Перед перестроением списка запоминаю текущего выбранного пользователя
  if SelUserID > 0 then
    vSavedSelUserID := SelUserID
  else
   vSavedSelUserID := -1;

  ...
  // переполучаю данные FUsers и перезаполняю список
  ...

  // Устанавливаю заново текущего пользователя
  if vSavedSelUserID > 0 then
  begin
    FSelUserID := -1;
    SelUserID := vSavedSelUserID;
  end
  else
     SelUserID := -1;
end;

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

procedure TfmUserRights.FillUsers(const aSelUserID: Integer = -1);
var
  i: Integer;
  vNeedSelUserID: Integer;
begin  
  if aSelUserID > 0 then // Если передано на ком позиционироваться, то на нем
    vNeedSelUserID := aSelUserID 
  else if SelUserID > 0 then // Иначе если выбран текущий - то на текущем
    vNeedSelUserID := SelUserID
  else
    vNeedSelUserID := -1;

  ...
  // переполучаю данные FUsers и перезаполняю список
  ...

  if vNeedSelUserID> 0 then
  begin
    FSelUserID := -1;
    SelUserID := vNeedSelUserID;
  end
  else
     SelUserID := -1;
end;

При этом FormCreate поменяется на

procedure TfmUserRights.FormCreate(Sender: TObject);
begin
  FillUsers(Config.RightsFormSavedUserID);
end;

а FormDestroy на

procedure TfmUserRights.FormCreate(Sender: TObject);
begin
  Config.RightsFormSavedUserID := SelUserID;
end;

Большая часть вышеприведенного кода придумана из головы, не судите слишком строго за опечатки и неточности. Он очень похож на реальный проект, но на самом деле в реальном проекте гораздо больше деталей, о которых сейчас говорить не хочется.

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

Удачи!

Автор: alan008

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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js