GrabDuck

MVC-подход к разработке пользовательских интерфейсов в Delphi. Часть 1. Галочка

:


Не буду писать красивых предисловий, потому что статья не развлекательная, а скорее техническая. В ней я хочу обратиться к приемам программирования пользовательского интерфейса классических desktop-приложений Delphi в MVC-стиле. Это вводная статья из последующей серии.
Тех немногих, кто еще пользуется этой средой разработки, прошу под кат.

Под классическими приложениями я подразумеваю десктопные GUI-приложения для Windows на основе VCL. Про фреймворк FireMonkey, появившийся в новых версиях Delphi, пусть напишет статью кто-нибудь другой.
Пользовательские интерфейсы очень многообразны. И если в вебе их вообще полный зверинец, то в настольных приложениях все происходит более консервативно. Конечно, разработчики некоторых приложений (см. Skype, Mikogo, Office 2010) продолжают придумывать всяческие визуальные ухищрения, которые призваны еще более повысить удобство использования, для большинства из нас (людей старой закалки) все-таки привычнее стандартные Windows и VCL контролы, придуманные, наверное, еще во времена Windows 3.1:
— кнопка (TButton)
— галочка (TCheckBox)
— переключатель (радиокнопка, TRadioButton)
— однострочное поле ввода (TEdit)
— многострочное поле ввода (TMemo или TRichEdit)
— всяческие комбинированные контролы (TSpinEdit, TDateTimeEdit)
— многостраничные контролы (TPageControl, TTabControl)
— гриды
— панели, групбоксы, бевелы, шейпы, ImageBox'ы и т.д.

Основная задача при создании пользовательского интерфейса придумать такое представление внутренних данных программы с помощью вышеназванных элементов, чтобы пользователю было удобно с этими данными работать. Ну или с другой стороны, придумать такой набор элементов, с помощью которого пользователь мог бы сказать программе то, что программа от него должна услышать.
Проектирование интерфейса с точки зрения компоновки элементов это уже само по себе является довольно сложной и важной задачей. Интерфейс — это лицо и основной способ по взаимодействию пользователя с вашей программой (не считая ее снятия по Ctrl+Alt+Del :) ). Именно поэтому нужно всегда заниматься проектированием интерфейса итеративно, получая каждый раз фидбэк от юзеров, удобно ли им работать с вашей программой, не приходится ли по 200 раз кликать мышкой, заходить во вложенные окна (которые в последний момент оказываются еще и модальными), по 10 раз вводить одни и те же данные из-за того, что они не сохраняются при повторном входе в окно и т.д. и т.п. (я уверен, искушенный читатель может привести еще массу примеров, каким не должен быть пользовательский интерфейс :) ).

Говорят, что умные ребята за океаном, которые подходят к проектированию интерфейсов серьезно и обстоятельно, применяют и такие технологии, как отслеживание движений мыши при работе с программой (чтобы зря не возили туда обратно), подсчет кликов и даже слежение за направлением взгляда пользователя (если глаза начинают бегать в случайных направлениях, это уже должно настораживать).
Но в данной статье речь пойдет не об этих чудесах. Я позволю себе предположить, что вы уже придумали, как будет выглядеть то или иное окно. Вам лишь нужно как-то связать это представление с внутренними данными программы. И именно о способах этой связи я хотел бы поговорить.

Начнем с примитива. Галочка.

Предположим, у вас в какой-то окне есть галочка (TCheckBox), отражающая выбор одного из двух вариантов. Чтобы говорить не о сферических конях в вакууме, придадим ей какой-то смысл. Пусть это будет окно импорта каких-то данных из файлов определенного каталога в базу данных. Галочка будет отражать, нужно ли удалять импортированные файлы после завершения операции. Тогда так и назовем нашу галочку
cbNeedDeleteFiles: TCheckBox;

Кстати, давать префиксы именам контролов на основе типа контрола очень удобно. Например, cb для TCheckBox, rb для TRadioButton, bt для TButton. Если вы установите в Delphi пакет расширений cnPack, то при размещении очередного контрола на форме будет выскакивать окошечко с предложением сразу переименовать этот контрол в соответствии с вашими правилами. Это позволяет избежать засилья валяющихся на форме Button87, CheckBox32 и т.п.

Как правило, после размещения контрола в нужном месте формы программист вздыхает с облегчением и успокаивается. Теперь он может из любого места программы обращаться к cbNeedDeleteFiles.Checked, чтобы узнать, выставлена галочка или нет. Вероятно, обращаться к свойству Checked программист будет не в единственном месте: при создании окна он может захотеть выставить умолчальное состояние данного свойства или сохраненное его значение, затем в основном месте (где выполняется импорт) нужно снова проверить этот атрибут, и, наконец, можно куда-то сохранить значение этого атрибута при закрытии окна, чтобы восстановить состояние галочки при следующем открытии окна. Также может потребоваться программно изменять значение этого атрибута на основе каких-то других условий. Например, пользователь может попросить, чтобы данная галочка всегда выставлялась автоматически при выборе каталога со входными файлами, если в данном каталоге содержатся только файлы одного строго определенного типа или если имена всех файлов каталога удовлетворяют определенной маске. И вот, в куче мест программы у нас появляется что-то подобное:

if cbNeedDeleteFiles.Checked then ...

if Something then
  cbNeedDeleteFiles.Checked := True;

if SomethingElse then
  cbNeedDeleteFiles.Checked := False;

На первый взгляд ничего плохого тут нет. Но как показывает многолетняя практика, это УЖАСНО. Это и называется жесткой завязкой кода на пользовательский интерфейс. Допустим, впоследствии вам придется заменить TCheckBox на две радиокнопки: «Удалить импортированные файлы» и «Не удалять импортированние файлы». В этом не очень много смысла, но вы можете это сделать для лучшей визуализации или в рамках рефакторинга перед добавлением третьего состояния данной настройки типа «Удалить файлы только при отсутствии ошибок импорта». И в этот момент вам придется в куче мест, где раньше вы обращались к cbNeedDeleteFiles.Checked вставить какой-то код по работе с RadioButton'ами.
Как этого избежать?

В сети давно и много трубят про MVC, MVP, MVVM. Будто это такие чудодейственные методики, следуя которым можно запрограммировать пользовательский интерфейс «правильно» и не иметь того гемора, который описан выше. На самом деле это лишь подходы, которые действительно помогают, но которые можно реализовать абсолютно по-разному в разных языках программирования и даже в одном языке. Т.е. это скорее советы, с какой стороны лучше подходить к программированию пользовательского интерфейса.
Если еще раз посмотреть на аббревиатуры, то видно, что во всех трех есть буквы M (Model, модель) и V (View, представление). Если говорить очень простым языком, то модель — это внутренние данные программы, а представление — внешние (пользовательский интерфейс). Возвращаясь к галочке, очевидно, что внутренним преставлением данной галочки является булево значение. Решение о том, в атрибуте какого класса должно храниться это значение принимается в каждом конкретном случае индивидуально. Например, это может быть класс TConfig, предоставляющий доступ к настройкам программы. Однако в простых случаях вполне достаточно создать соответствующий атрибут просто у класса формы:
TfmImport = class(TForm)
...
private
  ...
  FNeedDeleteFiles: Boolean;
public
  ...
  property NeedDeleteFiles: Boolean read FNeedDeleteFiles write SetNeedDeleteFiles;
end;

Далее необходимо связать состояние свойства NeedDeleteFiles с состоянием визуального компонента (TCheckBox'а) cbNeedDeleteFiles. Это удобно сделать через set метод свойства:

procedure TfmImport.SetNeedDeleteFiles(const Value: Boolean);
begin
  if FNeedDeleteFiles <> Value then
  begin
    FNeedDeleteFiles := Value;
    cbNeedDeleteFiles.Checked := FNeedDeleteFiles;    
  end;
end;

Зачем нужно условие FNeedDeleteFiles <> Value я поясню чуть позже. Главное, что теперь при присвоении значения свойству NeedDeleteFiles у нас будет автоматически выставляться галочка (это уже почти модель MVC — мы меняем значение элемента модели, а представление изменяется автоматически). Но это связь лишь в одну сторону — от внутренних данных к интерфейсу. Нужно еще добиться обратной связи — от представления (т.е. от галочки) к модели. Для этого в обработчике OnClick нашего чекбокса напишем такой код:

procedure TfmImport.cbNeedDeleteFilesClick(Sender: TObject);
begin
  NeedDeleteFiles := cbNeedDeleteFiles.Checked;
end;


Т.е. действие над предствлением (в данном случает щелчок по галочке) приведет модель в соответствие с текущим состянием представления. Однако модель никогда не доверяет представлению и поэтому вызовет повторное приведение состояния представления к состоянию модели (принудительно выставит cbNeedDeleteFiles.Checked := FNeedDeleteFiles. Ничего страшного при этом не произойдет. И мы даже дополнительно застраховались проверкой if FNeedDeleteFiles <> Value от ситуации, что визуальный контрол снова вызовет обработчик OnClick. На самом деле он этого не сделает, т.к. там стоит аналогичная проверка:
procedure TCustomCheckBox.SetChecked(Value: Boolean);
begin
  if Value then 
    State := cbChecked 
  else 
    State := cbUnchecked;
end;

procedure TCustomCheckBox.SetState(Value: TCheckBoxState);
begin
  if FState <> Value then
  begin
    FState := Value;
    ...
  end;
end;

Теперь у нас состояние свойства TfmImport.NeedDeleteFiles синхронизировано с состоянием галки cbNeedDeleteFiles.Checked в обе стороны. Во всех местах программы, где мы раньше обращались к cbNeedDeleteFiles.Checked теперь следует обращаться к свойству NeedDeleteFiles. Это позволяет нам полностью забыть о том, что представлением элемента NeedDeleteFiles является CheckBox. Вы даже не представляете, насколько это замечательно. Впоследствии мы можем заменить CheckBox на две радиокнопки или на что угодно и переписать нужно будет только set-метод SetNeedDeleteFiles (направление Model -> View) и обработчик, срабатывающий при изменении состояния представления, т.е. визульных компонентов (направление View -> Model).

Я пропустил такой важный момент как первоначальную синхронизацию значения свойства NeedDeleteFiles с состоянием визуального компонента. Конечно, если при открытии окна ваша галка будет либо всегда выставлена, либо всегда снята, можно просто выставить правильное состояние в DesignTime, а соответствующее значение полю FNeedDeleteFiles присвоить в OnCreate класса формы. Однако это не очень надежно (за этим надо следить, легко допустить расхождение), поэтому в OnCreate у класса формы лучше разместить следующий код:

procedure TfmImport.FormCreate(Sender: TObject);
begin
  FNeedDeleteFiles := False; // Намеренно присваиваю значение, отличающееся от того, которое хочу задать, чтобы сработал set-метод
  NeedDeleteFiles := True; // Тут сработает set-метод и синхронизирует GUI с присвоенным значением (выставит галочку)
end;

В следующей части статьи я постараюсь рассказать о более сложных случаях: работе в MVC-стиле со списками элементов (TListBox, TCheckListBox, TComboBox) и о подводных камнях при запоминании состояния визуальных элементов окна при его закрытии.

UPD. Добавлена вторая часть статьи
UPD. Добавлена третья часть статьи