Применение фреймов для написания “правильных” приложений

altНе знаю как вы, а я в свое время часто встречался со следующей ситуацией. При разработке довольно больших проектов количество форм с временем разрасталось. И чем далее, тем хуже. Но даже это не было большой проблемой до тех пор, пока я работал сам – все таки себя можно самодисциплинировать – заставить использовать единообразные наименования форм, методов, переменных. Но после того, как я стал работать в команде, проблема стала во весь рост – рефакторинг зачастую стал сводиться к “переписать все”, так как у каждого программиста свое понимание “правильно написаного кода”.После некоторых раздумий я решил создать некий “движок”, который облегчит написания немаленьких проектов. В основу этого движка я поставил такие принципы:
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;
end;
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;
end;
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;
end;
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;
end;
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;
end;
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;
end;
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;
end;
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;
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;
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;
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)^.LEVEL vData.ParentNode := LastNode;
end
else if PTMenuNodes(LastNode.Data)^.LEVEL=vData.LEVEL then
begin
Node := TreeViewMenu.Items.AddChild(LastNode.Parent,vData^.ITEM_CAPTION);
vData.ParentNode := LastNode.Parent;
end
else if PTMenuNodes(LastNode.Data)^.LEVEL>vData.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;
end;
finally
LockWindowUpdate(0);
end;
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;
end;
Аналогично и для других. Кроме того, в главной форме нужно еще создать общие элементы для фильтрации. Это могут быть элементы для фильтрации по датам, по рассчетным счетам и т.д. Однако в обработчике изменения по этим элементам нужно всего-лишь послать сообщение CX_FILTER. Как обработать полученное сообщение, будет решать конкретный фрейм.

Кроме вышеперчисленного, в главной форме нужно будет зарегистрировать все типы фреймов. Делается это в секции initialization функцией RegisterClasses.

Теперь нужно создать фреймы. Фрейм должен наследоваться от созданного выше абстрактного фрейма TFrameBase. После этого нужно переопределить необходимые режимы.

В простейшем случае нужно переопределить конструктор фрейма, к котором переопределить описание фрейма, вызвав SetDesc и определить текущий датасет фрейма, вызвав SetFrameCurrentDataSet. Если на фрейме есть грид, то нужно переопределить функцию GetMainGrid, чтобы она возвращала нужный.

Таким образом для добавления нового режима в простейшем случае нужно прописать всего 9 строчек кода!

Однако его изменение (улучшение) фрейма абсолютно не приведет к никаким правкам главной формы. Функции работы с данными вызывают всего лишь методы соответствующих DataSet. В обработчике которых могут вызываться дополнительные модальные формы или диалоги.

Что дает такой подход? Написав каркас приложения в самом начале разработки проекта, далее все изменения (при появлении новых требований) сводится к включению нового фрейма (унаследованного от базового фрейма), добавлении записи в таблице меню (можно, кстати, создать визуальный редактор этой таблицы) и регистрации типа в в процедуре RegisterClasses. И все! Таким образом режимы могут разрабатываться разными разработчиками, которым не нужно согласовывать свои стили программирования (хотя, все же, это желательно) – все нужные функции определены, нужно их только переопределить и наполнить необходимым содержанием.


Понравилась статья? Поделиться с друзьями: