Применение фреймов для написания “правильных” приложений
Применение фреймов для написания “правильных” приложений
Применение фреймов для написания "правильных" приложений Автор: Жук Андрей Не знаю как вы, а я в свое время часто встречался со следующей ситуацией. При разработке довольно больших проектов количество форм с временем разрасталось. И чем далее, тем хуже. Но даже это не было большой проблемой до тех пор, пока я работал сам - все таки себя можно самодисциплинировать - заставить использовать единообразные наименования форм, методов, переменных. Но после того, как я стал работать в команде, проблема стала во весь рост - рефакторинг зачастую стал сводиться к "переписать все", так как у каждого программиста свое понимание "правильно написаного кода".После некоторых раздумий я решил создать некий "движок", который облегчит написания немаленьких проектов. В основу этого движка я поставил такие принципы: 1. Все без исключения объекты для работы с базами данных должны находится в модулях данных, причем количество объектов в базе данных не должно превышать некий критический предел (для меня - до 50 объектов) - дальше стает сложно ориентироваться; 2. Все операции по работе с данными з БД должны также описываться в модулях данных, в соответствующих событиях или ActionList; 3. В главной форме не должно содержаться кода по работе с режимами, только вызов режима и вызов абстрактных, общих для всех методов, которые будут переопределяться в каждом соответствующем режиме. 4. Пользовательский интерфейс всех режимов должен быть полностью единообразен. 5. Режим должен иметь "право" изменения главного окна. 6. Режим не должен знать о существовании других режимов и и других форм вообще, для режима должно быть доступны только главная форма и модули данных. 7. Модули данных не должны знать о существовании режимов. 8. Режимы должны создаваться динамически, дабы не занимать лишнюю память. Для реализации поставленной задачи наиболее подходили фреймы. Используя фреймы, удалось добиться создания единообразного интерфейса, т.к. у нас была одна главная форма, на которой просто менялись кадры - "фреймы". Однако первая реализация была неудачная, так как для работы со специфическими функциями вынуждала делать все фреймы на главной форме и переключаться между ними с помощью свойства Visible. Кроме того, код главной формы был перегружен функциями в которых определялось, какой режим на данный момент загружен, и соответственно вызывался нужный метод режима. Потому было решено отойти от "тяжелого наследия" :) процедурного программирования и использовать основные принципы ООП. И действительно, оказалось что все фреймы можно (более того, нужно) сделать наследниками некоего базового фрейма. Код базового фрейма приведен ниже. unit UnitFrameBase; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, shellapi, DB, DBGrids, UnitConstTypes_etc; type TFrameBase = class(TFrame) private {описание фрейма - для вывода в заголовке программы} FrameDescription: string; {основной источник данных} FrameCurrentDataSet: TDataSet; {флаг загрузки данных} LoadingData: Boolean; {флаг выполнения длительной работы - например генерации очень большого отчёта} MakeLongTimeWork: Boolean; public { функция, выполняющаяся сразу после создания фрейма} procedure InitialisationFrame(Param1, Param2: Integer); virtual; { виртуальная функция фильтра данных } procedure Filter(Param1, Param2: Integer); virtual; { виртуальная функция показа дополнительной информации} procedure ShowAdditionalInformation(); virtual; { виртуальная функция скрытия/показа наследуемых контролов} procedure ShowContols(); virtual; { виртуальная функция открытия наборов данных (подключения к уже открытым} procedure OpenNeededTables(Param1, Param2: Integer); virtual; {процедуры работы с наборами данных} {добавление} function AddRecord(): TFunctionResult; virtual; {редактирование} function EditRecord(): TFunctionResult; virtual; {удаление} function DeleteRecord(): TFunctionResult; virtual; {Сохранить} function PostRecord(): TFunctionResult; virtual; {Отменить} function CancelRecord(): TFunctionResult; virtual; {Открыть свойства} function GetProperty(): boolean; virtual; {функции экспорта в различные форматы} function ExportData(Parameter: string): TFunctionResult; virtual; {функции импорта из различных форматов} function ImportData(Parameter: string): TFunctionResult; virtual; {Комментарий: функции записи/чтения заголовка фрейма} function GetDesc: string; procedure SetDesc(Description: string); {Процедуры перехода к следующей, предыдущей, первой, последней записи} procedure GotoNextElement; virtual; procedure GotoPrevElement; virtual; procedure GotoFirstElement; virtual; procedure GotoLastElement; virtual; {функция, возвращающая главный Grid фрейма} function GetMainGrid: TDBGrid;virtual; {функции установки состояния загрузки данных} procedure SetLoadingData(LD: boolean); function GetLoadingData: boolean; {функции установки состояния длительного процесса} procedure SetMakeLongTimeWork (LTW: boolean); function GetMakeLongTimeWork : boolean; {функции возвращающие текущий источник данных} procedure SetFrameCurrentDataSet(DS: TDataSet); function GetFrameCurrentDataSet: TDataSet; {виртуальный деструктор} destructor Destroy;override; {процедуры записи/чтения настроек фрейма в файлы} {эти процедуры не вызываются автоматически в конструкторах/деструкторах} {вызов этих функций лучше покласть на процедуру вывода фрейма} function SaveFrameToFile(FileName: TFileName): TFunctionResult; virtual; function LoadFromFileToFrame(FileName: TFileName): TFunctionResult; virtual; published {описание фрейма - для отображения режима в заголовке} property FrameDesc: string read GetDesc write SetDesc; end; implementation uses UnitFormMain, MyDBGrid; {$R *.dfm} procedure TFrameBase.ShowAdditionalInformation(); begin //---в принципе часто нужно что-то помещать в статусбар end; procedure TFrameBase.Filter(Param1, Param2: Integer); begin //---фильтрация текущего набора данных //---два параметра передаются через SendMessage end; procedure TFrameBase.ShowContols(); begin //---чтобы скрывать/отображать нужные в текущем режиме контролы end; procedure TFrameBase.OpenNeededTables(Param1, Param2: Integer); begin // по умолчанию откроем текущий датасет фрейма if Assigned(FrameCurrentDataSet) then FrameCurrentDataSet.Active := true; end; procedure TFrameBase.InitialisationFrame(Param1, Param2: Integer); begin //------------- SetLoadingData(true); //откроем нужные таблицы OpenNeededTables(Param1, Param2); //отфильтруем их Filter(Param1, Param2); //покажем нужные контролы ShowContols; SetLoadingData(false); end; {процедуры работы с наборами данных} {добавление} function TFrameBase.AddRecord(): TFunctionResult; var E: Exception; begin //попытаемся добавить запись в текущий датасет фрейма Result.Successful := false; if Assigned(FrameCurrentDataSet) then begin try FrameCurrentDataSet.Append; Result.Successful := true; except on E:Exception do Result.MessageOnError := E.Message; end; {редактирование} function TFrameBase.EditRecord(): TFunctionResult; var E: Exception; begin //попытаемся изменить запись в текущем датасете фрейма Result.Successful := false; if Assigned(FrameCurrentDataSet)then begin try FrameCurrentDataSet.Edit; Result.Successful := true; except on E:Exception do Result.MessageOnError := E.Message; end; {удаление} function TFrameBase.DeleteRecord(): TFunctionResult; var E: Exception; begin //попытаемся удалить запись из текущего датасета фрейма Result.Successful := false; if Assigned(FrameCurrentDataSet) then begin try FrameCurrentDataSet.Delete; Result.Successful := true; except on E:Exception do Result.MessageOnError := E.Message; end; {Сохранить} function TFrameBase.PostRecord(): TFunctionResult; var E: Exception; begin //попытаемся послать Post Result.Successful := false; if Assigned(FrameCurrentDataSet) then begin try FrameCurrentDataSet.Post; Result.Successful := true; except on E:Exception do Result.MessageOnError := E.Message; end; {Отменить} function TFrameBase.CancelRecord(): TFunctionResult; var E: Exception; begin //попытаемся послать Cancel Result.Successful := false; if Assigned(FrameCurrentDataSet) then begin try FrameCurrentDataSet.Cancel; Result.Successful := true; except on E:Exception do Result.MessageOnError := E.Message; end; {Открыть свойства} function TFrameBase.GetProperty(): boolean; begin Result := false; if Assigned(FrameCurrentDataSet) then if not FrameCurrentDataSet.IsEmpty then Result := true; end; {функции экспорта в различные форматы} function TFrameBase.ExportData(Parameter: string): TFunctionResult; var FResult: TFunctionResult; begin Result.Successful := False; Result.MessageOnError := 'Not Save'; //если передан параметр AsIs //то сохранить текущий грид в xls if (Parameter = 'AsIs') then begin with TSaveDialog.Create(Self) do try begin Filter := 'Файли г_пертексту|*.htm'; Title := 'Вкаж_ть назву файлу'; DefaultExt := 'htm'; Options := Options + [ofPathMustExist]; if Execute then begin FResult := TMyDBGrid(GetMainGrid).SaveToHTML(FileName, false); Result := FResult; ShellExecute(Self.Handle, 'open', PChar(FileName), nil, nil, SW_SHOW); end end; finally Free; end; function TFrameBase.ImportData(Parameter: string): TFunctionResult; begin Result.Successful := False; Result.MessageOnError := 'Not implemented method'; end; {Комментарий: функции записи/чтения заголовка фрейма} function TFrameBase.GetDesc: string; begin Result := FrameDescription end; procedure TFrameBase.SetDesc(Description: string); begin FrameDescription := Description end; procedure TFrameBase.GotoNextElement; begin if Assigned(FrameCurrentDataSet) then if not FrameCurrentDataSet.Eof then FrameCurrentDataSet.Next; end; procedure TFrameBase.GotoPrevElement; begin if Assigned(FrameCurrentDataSet) then if not FrameCurrentDataSet.Bof then FrameCurrentDataSet.Prior; end; procedure TFrameBase.GotoFirstElement; begin if Assigned(FrameCurrentDataSet) then if not FrameCurrentDataSet.Bof then FrameCurrentDataSet.First; end; procedure TFrameBase.GotoLastElement; begin if Assigned(FrameCurrentDataSet) then if not FrameCurrentDataSet.Eof then FrameCurrentDataSet.Last; end; procedure TFrameBase.SetLoadingData(LD: boolean); begin LoadingData := LD; end; function TFrameBase.GetLoadingData: boolean; begin Result := LoadingData; end; function TFrameBase.GetMainGrid: TDBGrid; begin Result := nil; end; procedure TFrameBase.SetMakeLongTimeWork (LTW: boolean); begin MakeLongTimeWork := LTW; end; function TFrameBase.GetMakeLongTimeWork : boolean; begin Result := MakeLongTimeWork; end; {функции возвращающие текущий источник данных} procedure TFrameBase.SetFrameCurrentDataSet(DS: TDataSet); begin FrameCurrentDataSet := DS; end; function TFrameBase.GetFrameCurrentDataSet: TDataSet; begin Result := FrameCurrentDataSet; end; destructor TFrameBase.Destroy; begin if Assigned(FrameCurrentDataSet) then FrameCurrentDataSet.Active := false; inherited Destroy; end; {процедуры записи/чтения настроек фрейма в файлы} function TFrameBase.SaveFrameToFile(FileName: TFileName): TFunctionResult; var E: Exception; ms: TMemoryStream; fs: TFileStream; begin try fs := TFileStream.Create(FileName, fmCreate or fmOpenWrite); ms := TMemoryStream.Create; try ms.WriteComponent(self); ms.Seek(0, soFromBeginning); ObjectBinaryToText(ms, fs); finally ms.Free; fs.free; end; Result.Successful := true; Except on E:Exception do begin Result.Successful := false; Result.MessageOnError := E.Message; end; function TFrameBase.LoadFromFileToFrame(FileName: TFileName): TFunctionResult; var ComponentIdx: integer; ms: TMemoryStream; fs: TFileStream; begin //уничтожим все существующие на фрейме компоненты //чтобы не было конфликтов for ComponentIdx := self.ComponentCount-1 downto 0 do self.Components[ComponentIdx].Free; try //загрузим фрейм из файла ms := TMemoryStream.Create; fs := TFileStream.Create(FileName, fmOpenRead); try ObjectTextToBinary(fs, ms); ms.Seek(0, soFromBeginning); ms.ReadComponent(self); finally ms.Free; fs.free; end; Result.Successful := true; except on E:Exception do begin Result.Successful := false; Result.MessageOnError := E.Message; end; end. Как можно заметить, многие функции базового фрейма возвращают значение типа TFunctionResult. Эта структура определена в модуле UnitConstTypes_etc, в который в будущем будут добавляться другие типы, константы. Функции возвращают флаг успешного завершения операции, а случае возникновения ошибки - текст сообщения об ошибке. Это, понятно, шаблон. В моих нынешних приложениях вышеозначенных функций более чем достаточно. Однако, если вам нужно добавить какой-то специфический метод, в этом понятно не никакой сложности. Кроме того, в процедуре экспорта данных вызывается метод SaveToHTML текущего грида фрейма. Этот метод определён в модуле MyDBGrid. Перейдем к отображению фреймов на главной форме приложения. Главные формы приложений у меня имеют примерно такой вид: слева - дерево меню, внизу лог приложения, сверху - панель инструментов, остальное пространство пустое, его занимает панель, на которой будут отображаться фреймы. Сначала создадим свой тип type TFrameClass = class of TFrameBase; Теперь нужно создать правильно работающую главную форму. В uses добавим использование модуля UnitFrameBase, в раздел public внесем объект MainFrame класса TFrameBase. Теперь нужно написать функцию, которая будет корректно отображать нужный фрейм при открытии нужного режима. function TFormMain.ProcShowFrame(FrameClassName: AnsiString; ParentPanel: TWinControl): TFunctionResult; var FrameClass: TClass; FunctionResult: TFunctionResult; E: Exception; begin Result.Successful := False; FrameClass := GetClass(FrameClassName); if FrameClass = nil then //если такой тип фрейма незарегистрирован begin Result.MessageOnError := Format('Class %s not registered',[FrameClassName]); Exit; end; //запретить прорисовку контейнера фреймов try begin LockWindowUpdate(ParentPanel.Handle); // не будем перерисовывать подложку, чтобы не было мерцаний //если фрейм не пуст, очистим его if Assigned(MainFrame) then if MainFrame.ClassType = FrameClass then begin Result.Successful := true; Exit; //если мы пытамся пересоздать текущий фрейм ним же, то выход end else begin FunctionResult := MainFrame.SaveFrameToFile(Format('%s.dat',[MainFrame.ClassName])); if not FunctionResult.Successful then ListBoxLog.Items.Add('Error on Save Frame: '+FunctionResult.MessageOnError); MainFrame.Destroy; end; //создать фрейм по указанному типу try MainFrame := TFrameClass(FrameClass).Create(FormMain); if FileExists(Format('%s.dat',[MainFrame.ClassName])) then begin FunctionResult := MainFrame.LoadFromFileToFrame(Format('%s.dat',[MainFrame.ClassName])); if not FunctionResult.Successful then ListBoxLog.Items.Add('Error on Load Frame: '+FunctionResult.MessageOnError); end; except on E:Exception do begin Result.MessageOnError := E.Message; MainFrame := nil; Exit; end; MainFrame.Parent := ParentPanel; MainFrame.Align := alClient; end; finally LockWindowUpdate(0); //разрешить прорисовку контейнера фреймов end; Result.Successful := true; end; Как же вызвать данную функцию? Создадим дополнительно сообщение const FILTER_EVENT = WM_USER + 101, - для вызова процедур фильтрации. Param1 и Param2 используются для формирования нужных запросов в однотипных фреймах. Теперь нужно написать обработчик сообщения FILTER_EVENT. procedure TFormMain.CX_FILTER(var Msg: TMessage); begin if Assigned(MainFrame) and (not FormMain.isShutdown) then begin MainFrame.Filter(Msg.wParam, Msg.LParam); MainFrame.ShowAdditionalInformation; end; Перейдем к отображению меню фреймов. В качестве источника хранения структуры древовидного меню можно использовать xml файл (это удобно, если приложение не использует БД), таблицу в используемой в приложении БД или же хранить структуру прямо в приложении (очень удобно это делать в dxTreeList от DevExpress, однако эти компоненты платные). Так как во всех моих приложениях используются базы данных (в основном СУБД Oracle или Firebird), то я выбрал второй вариант. Создадим таблицу следующей структуры CREATE TABLE TECH_APP_MENU ( MENU_ID IDENTIFIER NOT NULL, ITEM_TYPE VARCHAR(10) DEFAULT 'item' NOT NULL, ITEM_CAPTION VARCHAR(32) NOT NULL, FRAME_NAME VARCHAR(32) DEFAULT 'TFrameUnknown' NOT NULL, PARAM1 NONIDENTIFIER, PARAM2 NONIDENTIFIER, PARENT_ID NONIDENTIFIER NOT NULL, ITEM_ICON NONIDENTIFIER ); Кроме того, для обеспечения целостности дерева, с таблицей связано несколько триггеров и ограничений. Полностью структуру таблицы и тексты ограничений вы можете помотреть в исходном тексте БД. Из этой реляционной таблицы довольно легко создать дерево с помощью рекурсивной процедуры, текст которой вы также можете посмотреть в исходном тексте БД. Заметим, что принципы создания такой процедуры взяты из книги "Мир InterBase". Не вдаваясь в детали построения дерева на сервере,.отметим что в клиентском приложении можно построить дерево за один проход по набору данных. Процедура отображения дерева приведена ниже. Сначала определим структуру элементов меню type TMenuNodes = record MENU_ID: integer; ITEM_TYPE: WideString; ITEM_CAPTION: WideString; FRAME_NAME: WideString; PARAM1: integer; PARAM2: integer; PARENT_ID: integer; LEVEL: integer; ISLEAF: boolean; ParentNode: TTreeNode; end; PTMenuNodes = ^TMenuNodes; Естественно, что структура элемента меню совпадает со структурой таблицы в БД. Создадим меню следующей функцией procedure TFormMain.GenerateMenu(Tree: TTreeView); var vData : PTMenuNodes; Node, LastNode : TTreeNode; begin try LockWindowUpdate(Tree.Handle); SendMessage(Tree.Handle, TVM_DELETEITEM, 0, Longint(TVI_ROOT)); //очистим дерево if not DataModuleMain.DBMain.Connected then Exit; with DataModuleMain.GET_MENU do begin LastNode := nil; Active := True; First; while not Eof do begin New(vData); with vData^ do begin MENU_ID := FieldByName('MEM_ID').AsInteger; ITEM_TYPE := FieldByName('ITEM_TYPE').AsString; ITEM_CAPTION := FieldByName('ITEM_CAPTION').AsString; FRAME_NAME := FieldByName('FRAME_NAME').AsString; PARAM1 := FieldByName('PARAM1').AsInteger; PARAM2 := FieldByName('PARAM2').AsInteger; PARENT_ID := FieldByName('MEM_PID').AsInteger; LEVEL := FieldByName('OUTLEVEL').AsInteger; ISLEAF := boolean(FieldByName('IS_LEAF').AsInteger); end; if vData.LEVEL = 1 then begin Node := TreeViewMenu.Items.Add(nil,vData^.ITEM_CAPTION); vData.ParentNode := nil; end else if PTMenuNodes(LastNode.Data)^.LEVELvData.LEVEL then begin while PTMenuNodes(LastNode.Data)^.LEVEL>=vData.LEVEL do LastNode := LastNode.Parent; Node := TreeViewMenu.Items.AddChild(LastNode,vData^.ITEM_CAPTION); vData.ParentNode := LastNode.Parent; end; {здесь компилятор выдает сообщение, что Node может быть неинициализированной. Однако при используемой в программе схеме хранения данных данная переменная обязательно будет инициализирована} Node.Data := vData; Node.ImageIndex := FieldByName('ITEM_ICON').AsInteger; Node.SelectedIndex := FieldByName('ITEM_ICON').AsInteger; LastNode := Node; Next; end; finally LockWindowUpdate(0); end; Обработчик изменения элемента в меню будет иметь следующий вид procedure TFormMain.TreeViewMenuChange(Sender: TObject; Node: TTreeNode); var vData : PTMenuNodes; CurrentNodeIcon: TIcon; FunctionResult: TFunctionResult; begin vData := Node.Data; FunctionResult := ProcShowFrame(vData^.FRAME_NAME,PanelFrame); if FunctionResult.Successful then begin MainFrame.InitialisationFrame(vData^.PARAM1,vData^.PARAM2); Caption := Format('%s - %s',[Application.Title,MainFrame.FrameDesc]); CurrentNodeIcon := TIcon.Create; ImageListApp.GetIcon(Node.SelectedIndex,CurrentNodeIcon); FormMain.Icon := CurrentNodeIcon; CurrentNodeIcon.Free; end else ListBoxLog.Items.Add(Format('Error on show frame %s: %s',[vData^.FRAME_NAME,FunctionResult.MessageOnError])); end; Создадим панель инструментов с кнопками, которые будут выполнять нужные функции. Как мы определили, обязательно нужны функции добавления, изменения, удаления, просмотра, экспорта в Excel и других отчетов, импорта из внешних источников. Код обработчиков нажатия на эти кнопки будет максимально прост. Например код для добавления procedure TFormMain.ToolButtonAddClick(Sender: TObject); var FResult: TFunctionResult; begin if Assigned(MainFrame) then begin FResult := MainFrame.AddRecord; if not FResult.Successful then ListBoxLog.Items.Add('Error on Add: '+FResult.MessageOnError); end; Аналогично и для других. Кроме того, в главной форме нужно еще создать общие элементы для фильтрации. Это могут быть элементы для фильтрации по датам, по рассчетным счетам и т.д. Однако в обработчике изменения по этим элементам нужно всего-лишь послать сообщение CX_FILTER. Как обработать полученное сообщение, будет решать конкретный фрейм. Кроме вышеперчисленного, в главной форме нужно будет зарегистрировать все типы фреймов. Делается это в секции initialization функцией RegisterClasses. Теперь нужно создать фреймы. Фрейм должен наследоваться от созданного выше абстрактного фрейма TFrameBase. После этого нужно переопределить необходимые режимы. В простейшем случае нужно переопределить конструктор фрейма, к котором переопределить описание фрейма, вызвав SetDesc и определить текущий датасет фрейма, вызвав SetFrameCurrentDataSet. Если на фрейме есть грид, то нужно переопределить функцию GetMainGrid, чтобы она возвращала нужный. Таким образом для добавления нового режима в простейшем случае нужно прописать всего 9 строчек кода! Однако его изменение (улучшение) фрейма абсолютно не приведет к никаким правкам главной формы. Функции работы с данными вызывают всего лишь методы соответствующих DataSet. В обработчике которых могут вызываться дополнительные модальные формы или диалоги. Что дает такой подход? Написав каркас приложения в самом начале разработки проекта, далее все изменения (при появлении новых требований) сводится к включению нового фрейма (унаследованного от базового фрейма), добавлении записи в таблице меню (можно, кстати, создать визуальный редактор этой таблицы) и регистрации типа в в процедуре RegisterClasses. И все! Таким образом режимы могут разрабатываться разными разработчиками, которым не нужно согласовывать свои стили программирования (хотя, все же, это желательно) - все нужные функции определены, нужно их только переопределить и наполнить необходимым содержанием. Код тестового приложения, демонстрируещего описанную работу с фреймами находется здесь. Надеюсь, что данная статья поможет кому-либо в создании своих приложений.