Эффективный способ применения интерфейсов в MDI приложениях
Валерий Шер-хан, Королевство Дельфи
В книгах по программированию при рассмотрении различных приёмов и методов приводятся в основном "игрушечные" примеры. Иногда даже можно встретить высказывания автора: "профессиональные программы так не пишут". В самом начале изучения современного объектно-ориентированного программирования я не задумывался над тем, что значит писать профессионально. Задумался, когда стал писать масштабный проект. В этой статье хочу поделиться своим опытом — описать несколько своих решений.
Изначально ставилась задача: разработать модель для построения приложений, ориентированных на работу с базами данных (БД). Под таким приложением подразумевается набор форм, каждая из которых обычно отображает одну таблицу БД. Например, в бухгалтерской или складской программе таблицы "Накладные", "Клиенты", "Товары" удобно расположить на отдельных формах. Несколько таблиц с малым числом строк и столбцов можно было бы расположить на одной форме, например: "Категории товаров", "Типы накладных", "Единицы измерения". Пользователь должен иметь возможность выбирать окно, с которым он хочет работать. Поэтому где-то должно быть меню или список всех или почти всех окон. Понятно, что окно "Накладная" в этом списке отсутствует. Оно будет открываться из списка накладных (окно "Накладные"). Было бы так же удобно открывать последнюю приходную накладную (окно "Накладная") для товара под курсором из окна "Товары". Вот для таких приложений и предназначена описанная в статье модель.
Модель приложения можно свести к абстракции "Окно—>Документ", где Окно — это список Документов, например "Окно—Накладные"—>"Документ—Накладная". Нечто похожее на модель "Master—>Detail", только на разных формах (у нас). В свою очередь Документ может быть Окном, из которого можно открыть другой Документ и т.д., т.е. опять "Окно—>Документ". Например "Окно—Накладная"—>"Документ—Клиенты". И по большому счёту, чем отличается Окно от Документа? Ведь связь может быть и обратной: Документ—>Окно. Под связью понимаем любое действие, инициированное из текущего окна (формы) по отношению к другому окну (форме). Это действие даже может и не требовать отображения того другого окна. Поэтому модель можно упростить ещё: "Документ<=>Документ". Иными словами — множество окон с множеством связей между ними.
Модель будет рассмотрена на примере Delphi, но может быть реализована и на других объектно-ориентированных языках имеющих такие конструкции, как классы, наследование и интерфейсы. Модель построена на основе многооконного интерфейса MDI. На Рис.1 изображено несколько уровней иерархии классов форм. Начальный, наиболее абстрактный уровень — уровень платформы. Под платформой понимается библиотека абстрактных классов и универсальных функций. На этом уровне расположены два базовых класса — класс главной формы TBaseMDIForm и класс дочерней формы TBaseMDIChildForm. Если мы пишем программу складского учёта (для абстрактного заказчика), переходим на другой уровень путём наследования (пунктирные стрелки) необходимых форм от соответствующих базовых классов. Это я называю уровнем схожих проектов. Здесь содержится вся функциональность окон конкретного проекта для абстрактного приложения. Из этих окон уже можно строить полнофункциональное приложение. Но конкретное приложение для конкретного заказчика строится из окон следующего уровня — уровня конкретного приложения. На этом уровне может быть несколько изменён внешний вид окон, переопределены некоторые методы и функции под конкретного заказчика. Для большей ясности приведён Рис.2. Если мы пишем программу для бухгалтерии с базой данных, отличной от базы данных в программе складского учёта, то мы переходим с уровня платформы путём наследования на уровень схожих проектов 2, т.е. это будет параллельная ветвь. И т.д.
Связи между окнами (Рис. 1) показаны сплошными линиями. Т.к. основная функциональность окон находится на уровне схожих проектов, все основные связи между окнами тоже. И сейчас возникает интересный вопрос: как правильно организовать эти связи? Если бы мы строили приложение из окон этого уровня, всё было бы хорошо — каждое окно "знало" бы о других окнах (классах форм) из секции uses. Но мы то строим приложение из наследников этих окон. Получается сложная ситуация — наследники должны "знать" о наследниках. Т.е. часть функциональности, общей для ряда заказчиков, должна уйти на уровень конкретного приложения для конкретного клиента. Это недопустимо, потому что теряется преимущество объектного программирования. Не будем же мы каждый раз после изменений основной функциональности копировать программный код между соседними ветвями уровня конкретного приложения. Вот здесь может помочь использование интерфейсов (специальная конструкция языка). Можно создать отдельные интерфейсы для всех классов окон с нужными свойствами, функциями и методами. Тогда уже окнам будет незачем "знать" друг о друге. Им нужно будет "знать" только об интерфейсах, которые реализуют нужные классы окон. Следовательно, связи между окнами будут находиться там, где и положено, а наследники окон будут нести только функциональность для конкретного приложения (заказчика). И при необходимости смогут иметь свои связи к другим окнам (используя интерфейсы), которых не предусмотрено на уровне выше.
Одно из решений выглядит так. Параллельно с созданием функциональности множества окон надо параллельно создать для каждой группы связей свой интерфейс, содержащий нужные функции, свойства, методы. А при вызове интерфейса надо перебрать все окна в приложении, найти то, которое реализует нужный интерфейс, потом вызвать нужную функцию (свойство). Поскольку функция (свойство) интерфейса может вызываться из многих мест, никто не мешает автоматизировать этот процесс путём создания некого универсального механизма поиска нужного интерфейса среди существующих и "несуществующих"(классов) окон. Дело в том, что окна с нужным интерфейсом в момент его поиска может ещё не существовать. Мы не собираемся при запуске программы создавать сразу все возможные окна. Ведь пользователь может вообще не воспользоваться многими окнами и их интерфейсами в данном сеансе работы с программой. Предположим сейчас, найдено существующее окно "Документ", реализующее связь "Открыть определённый документ". А вдруг пользователь производил там редактирование и не закрыл его (отложил на время). Если мы позволим создать связь с этим окном, оно уже должно будет отображать другой документ и все произведённые пользователем изменения могут пропасть. Значит, необходим некий критерий, позволяющий универсальному механизму поиска определять — можно ли установить связь с окном, либо надо создать другое окно того же класса.
Предлагается способ решить все вышеуказанные сложности весьма простым механизмом. В абстрактной модели "Документ<=>Документ" есть только один объект — Документ. Поэтому достаточно использовать только один интерфейс (IDoc) с одной функцией (ProcessParams), аргументом которой будет массив с любым числом элементов любого типа. Способ обработки этого универсального параметра определяет сам программист без привлечения других интерфейсов, наследования, функций-оболочек. При помощи такого универсального параметра можно организовать создание большого разнообразия связей между формами. Интерфейс IDoc будет реализоваться на уровне платформы классом TBaseMDIChildForm. Поэтому все наследники от этого класса автоматически реализуют этот интерфейс. Поскольку функция ProcessParams должна быть универсальной, тип единственного параметра (Params) используем array of const (array of TVarRec) — массив с любым числом членов любого типа. Таким образом, мы сняли необходимость добавлять новый интерфейс для каждого нового класса формы (или набора действий) и добавлять в него новую функцию при создании новой связи между формами. Интерфейс IDoc мы будем вызывать не напрямую, а посредством вспомогательного объекта DocManager. При запуске программы мы регистрируем (RegisterFormClass) в DocManager классы всех необходимых окон конкретной программы. Регистрация осуществляется с указанием номера класса и заголовка формы. Номер класса уникален для ветви уровня схожих проектов (Рис. 2). Заголовок формы необходим, т.к. предполагается автоматически создавать меню со списком окон без необходимости сразу создавать все окна. При организации связи с другим окном будем пользоваться функциями ShowDoc и ProcessDocParams. В качестве параметров для этих функций нужно задать номер класса и параметр типа array of const (Params). Поэтому для связи с другим окном данное окно должно "знать" только номер класса. Ссылки на класс (вызываемой формы) и интерфейс IDoc не требуются. ShowDoc отображает окно с передачей в него нужного параметра. ProcessDocParams организует обработку параметра без необходимости отображать окно (в фоновом режиме). Обе функции создают при необходимости окно нужного класса и затем вызывают ProcessParams (IDoc) созданного окна.
Этот механизм очень напоминает технологию COM в ОС Windows, только внутри одного приложения.
Рассмотрим один из случаев применения вышеуказанного принципа. Из списка накладных (окно "Накладные") мы хотим увидеть содержимое накладной под курсором. Для этого мы вызываем ShowDoc с указанием номера класса. В качестве параметра Params массив, один из членов которого является уникальным номером накладной из списка накладных. DocManagerst создаёт окно "Накладная" и передаёт туда массив Params с номером накладной (и др. параметрами при необходимости). В окне "Накладная" по этому номеру мы загружаем список товаров соответствующей накладной. А что будет, если пользователь не закрыв это окно, вернётся к списку накладных и опять инициирует открытие окна "Накладная"? Тут возможно два случая — пользователь хочет просмотреть содержимое той же накладной или он хочет просмотреть уже другую накладную. Для таких случаев существует вот какой механизм. IDoc имеет вспомогательные процедуры SetParams для сохранения Params в форме и ParamsIs для определения идентичности с Params, сохранённым через SetParams. При вызове DocManager.ShowDoc если найдена уже существующая форма нужного класса, происходит вызов ParamsIs для проверки равенства Params из ShowDoc и Params существующей формы. Если они равны, показываем существующую форму на переднем плане, если Params`ы не равны, то создаём новую форму на переднем плане с передачей туда нового Params.
В форме TBaseMDIChildForm после вызова SetParams происходит сохранение Params не в виде array of const, а в виде динамического массива типа Variant. Конвертация происходит функцией VarOpenArrayToVarArray в модуле Misc. Там же есть функция VarEqual, которая вызывается из ParamsIs. VarEqual и VarOpenArrayToVarArray построены специальным образом, который определяет степень свободы задания элементов массива Params типа array of const. В нём можно задавать элементы практически любых типов. Ординарные типы, ссылки на объекты, адреса переменных с соответствующим преобразованием при их интерпретации. Даже можно задать в качестве элемента динамический массив типа Variant, элементами которого могут быть тоже массивы типа Variant. При этом VarEqual будет работать корректно (на основе рекурсии). Замеченное ограничение — невозможность передачи строк String со служебными кодами типа 0х0, 0х1, 0х2 и т.д. Ничего с этим пока поделать не смог.
Ещё несколько особенностей. ProcessDocParams не влияет на Params, сохранённый в TBaseMDIChildForm с помощью SetParams (т.е. из ShowDoc). ProcessDocParams не вызывает ParamsIs и SetParams формы. ProcessDocParams и ShowDoc вызывают вспомогательные методы интерфейса IDoc DocInit и ProcessParams. Их можно переопределить в наследниках. DocInit предназначен для инициализации формы, там можно открывать таблицы БД, обрабатывать Params из ShowDoc. А ProcessParams предназначен для обработки Params из ShowDoc и из ProcessDocParams.
В DocManager встроен механизм заполнения пункта меню списком заголовков зарегистрированных классов форм с целью предоставления пользователю способа открытия желаемой формы. Функция CreateMenuItems принимает параметр типа TMenuItem, где хотим создать вышеуказанный список (Обычно это пункт главного меню главной формы). Причём параллельно автоматически заполняется свойство объекта DocManager ActionList типа TActionList. Его можно использовать для заполнения "вручную" (программистом) альтернативного средства выбора окон не меняя код TDocManager.
При регистрации класса окна (DocManager.RegisterFormClass) необходимо указать дополнительный параметр — это тип окна. Есть три типа "Окно", "Документ" и "Отчёт". При вызове CreateMenuItems всё, что зарегистрировано как "Документ" не входит в меню, а то, что помечено как "Отчёт", попадает в конец меню после разделителя. Предполагается, что "Документ" вызывается из других окон (например окно "Накладная"), а количество и порядок "Отчётов" могут часто меняться, поэтому в конце. В качестве пункта меню выбора доступных окон удобно использовать пункт главного меню главной формы.
DocManager создавать вручную не надо, создаётся и уничтожается автоматически при добавлении в проект ссылки на модуль Doc.
Некоторые рекомендации по использованию Params: array of const. Рекомендуется первым элементом массива использовать целое число — номер команды (связи), достаточно сделать уникальным в пределах класса формы на уровне схожих проектов и ниже. Т.о. при вызове ShowDoc и ProcessDocParams, чтобы попасть в нужное место, указываем номер класса (TypeId: Integer), номер команды (Например первый элемент Params: array of const). В нужной форме в ProcessParams анализируем первый элемент массива Value :Variant, в DocInit анализируем первый элемент массива FParams :Variant (поле данных TBaseMDIChildForm). В остальных элементах Params: array of const передаём всё, что необходимо для связи с другой формой.
Рассмотрим один частный случай применения вышеуказанного принципа. Предположим, что мы хотим из нескольких мест программы ("Список документов" "Список товаров") открывать окно "Накладная", в котором находится содержимое соответствующего документа. В качестве параметра при организации связи используем уникальный номер накладной в рамках БД. Всё бы хорошо. Но есть одно "но". Реальная ситуация — от общего родителя "Абстрактный документ" наследовано несколько конкретных: "Приход", "Расход", "Акт переоценки". Это разные классы, имеющие разные номера при регистрации. Т.о. напрямую вызывать ShowDoc можем но это не удобно, нам надо ещё знать тип документа: "Приход", "Расход", "Акт переоценки". Это чтоб выбрать необходимый номер класса. Решение у меня такое. Вызываем окно "Список документов" при помощи ProcessDocParams, с передачей номера документа. В окне "Список документов" в ProcessParams организуем механизм запроса из БД типа документа по его номеру. Далее вызываем ShowDoc с указанием номера класса, который соответствует типу данного документа, и транслируем туда же номер документа (другой элемент массива Params), полученный от другой формы через ProcessDocParams. Что у нас получилось. Допустим, пользователь из "Списка товаров" хочет открыть последний документ, содержащий товар под курсором. Им может оказаться как "Приход", так и "Акт переоценки". После нажатия <Enter> к примеру он сразу увидит нужное окно, а как организован механизм его открытия он может даже и не догадываться. Ну а из "Списка документов" открыть нужный документ можно вызвав напрямую "свой" ProcessParams либо тоже через DocManager (для однообразия). Изящно, не правда ли?
Прилагается рабочий код уровня платформы, демонстрационный код уровня схожих проектов и конкретного приложения. См. комментарии в исходном коде. Необходимо: Delphi 7, BDE. После распаковки запустить Proj1Firm1.dpr, скомпилировать.
Распространение статьи приветствуется, целиком с указанием источника. Использование программного кода и идей приветствуется.
К материалу прилагаются файлы:
- Демонстрационный проект (151 K) обновление от 3/5/2007 3:14:00 AM