VCL и доступ к данным
Как устроено VCL-приложение с базой данных: формы и .dfm, наборы данных и поля, библиотеки ADO и FireDAC с вызовом хранимых процедур, фоновые потоки без зависания интерфейса. На этих вопросах проверяют, понимает ли кандидат механизм, а не только умеет раскладывать компоненты по форме.
Перед этой темой: Object Pascal.
Как устроены формы и компоненты VCL?
Заголовок раздела «Как устроены формы и компоненты VCL?»Что это
- Форма — класс
TForm1 = class(TForm)в модуле .pas плюс одноимённый файл .dfm. - .dfm — компоненты формы и значения их свойств, обычно в текстовом виде.
- Событие — свойство-ссылка на метод:
OnClickвызоветButton1Clickпри щелчке.
Жизненный цикл формы
OnCreate— компоненты загружены из .dfm.OnShow— форма появляется на экране.OnClose— параметрActionрешает судьбу формы:caHide— скрыть,caFree— освободить,caNone— не закрывать.OnDestroy— форма уничтожается.
ShowModal ждёт закрытия формы и возвращает ModalResult, а Show сразу возвращает управление.
Частая ошибка
Вызвать Free для формы в её собственном обработчике: он ещё не закончился и обратится к освобождённой памяти. Для этого есть Release и Action := caFree.
Как устроено
- В .dfm попадают published-свойства, значение которых отличается от указанного в директиве
default. - Конструктор формы находит ресурс .dfm (его подключает
{$R *.dfm}) и читает его. - Для каждого
objectсоздаётся компонент нужного класса, свойства присваиваются через RTTI, а одноимённое published-поле формы начинает указывать на него. - Строка
OnClick = btnOkClickсвязывается с published-методом формы по имени. Метод переименовали только в коде — форма не загрузится.
Owner и Parent
| Owner | Parent | |
|---|---|---|
| Отвечает за | освобождение и список Components |
отображение и положение на экране |
| Как задать | Create(AOwner) |
свойство Parent |
| Кто может быть | любой TComponent |
только TWinControl |
Parent тоже освобождает свои дочерние контролы, но его главная роль — визуальная.
Ловушки
- Контрол, созданный в коде без
Parent, не виден. Create(nil)безFree— утечка.Create(Self)для диалога, который создают при каждом вызове, копит объекты до закрытия владельца.Freeформы из её обработчика — нуженRelease: он дождётся окончания обработчиков и сообщений формы.
Правило
Модальный диалог — Create(nil) и ShowModal внутри try..finally Free. Немодальное окно — Action := caFree в OnClose.
Под капотом
TReaderсоздаёт компоненты, присваивает свойства и после чтения всей формы вызывает у каждого компонентаLoaded.- Ссылки между компонентами (
DataSource = dsOrders) разрешаются после чтения всех объектов, поэтому их порядок в .dfm не важен. - Важен порядок создания невизуальных компонентов: сохранённые
Connected = TrueиActive = Trueоткрывают соединение и запросы прямо при создании формы. - Наследник формы хранит в .dfm только отличия от предка (
inherited), фрейм на форме — тоже только отличия (inline).
Цена
Application.CreateFormдля всех форм в .dpr: все .dfm читаются на старте, а формы и их открытые запросы живут до выхода из программы.- Глобальная переменная
Form1поощряет связи «форма читает поля другой формы». Такой код трудно тестировать и переносить. - Бинарный .dfm не сравнивается в Git, текстовый — читается и мержится.
Компромиссы
- Фрейм — переиспользуемый кусок интерфейса с кодом. Изменения фрейма видны на всех формах, кроме свойств, переопределённых на конкретной форме.
- Наследование форм даёт общий каркас: кнопки, проверки, обработку ошибок. Глубокая иерархия хрупка: правка предка ломает потомков.
- Data module (
TDataModule) отделяет соединения и запросы от формы, и их можно использовать без UI.
Как проверить в проде
- Утечки форм и компонентов покажет
ReportMemoryLeaksOnShutdown := Trueили FastMM в режиме FullDebugMode. - Бинарный .dfm начинается с сигнатуры
TPF0— так легко найти файлы, которые стоит перевести в текст.
Итог: форма — сериализованный граф объектов; её временем жизни нужно управлять явно, а не полагаться на автосоздание.
procedure TMainForm.btnEditOrderClick(Sender: TObject);var Dlg: TOrderDialog;begin Dlg := TOrderDialog.Create(nil); // без владельца: освобождаем сами try Dlg.OrderId := 42; if Dlg.ShowModal = mrOk then // ждёт закрытия и возвращает ModalResult RefreshOrders; finally Dlg.Free; end;end;
procedure TMainForm.FormCreate(Sender: TObject);var Btn: TButton;begin Btn := TButton.Create(Self); // Owner = форма, она освободит кнопку Btn.Parent := pnlTools; // без Parent кнопку не видно Btn.Caption := 'Refresh';end;
procedure TReportForm.FormClose(Sender: TObject; var Action: TCloseAction);begin Action := caFree; // немодальная форма освободит себя самаend;object OrderDialog: TOrderDialog Caption = 'Order' OnCreate = FormCreate object btnOk: TButton Caption = 'OK' ModalResult = 1 endendModalResult = 1 — это mrOk: щелчок по кнопке закроет форму, и ShowModal вернёт mrOk.
- Чем Owner отличается от Parent? — Owner освобождает компонент и хранит его в
Components, Parent — оконный контрол, внутри которого контрол рисуется. - Что делает
Application.CreateForm? — Создаёт форму с владельцемApplication; первая созданная так форма становится главной. - Почему нельзя вызвать
Freeв обработчике самой формы? — Обработчик ещё выполняется;Releaseоткладывает уничтожение до конца обработки сообщений. - Чем фрейм отличается от наследования форм? — Фрейм — встраиваемый кусок интерфейса, наследование — общий предок для формы целиком.
- Что будет, если в .dfm остался обработчик, которого нет в коде? — Форма не загрузится: связь идёт по имени published-метода.
Как устроена работа с данными через TDataSet?
Заголовок раздела «Как устроена работа с данными через TDataSet?»Что это
TDataSet — набор строк с курсором: в каждый момент одна запись текущая. От него наследуются TADOQuery, TFDQuery, TClientDataSet и другие наборы.
Как связаны компоненты
- Запрос (
TFDQueryилиTADOQuery) получает строки из базы. TDataSourceсвязывает набор с контролами.TDBGridилиTDBEditсо свойствамиDataSourceиDataFieldпоказывает и редактирует данные.
Как пользоваться
- Перемещение:
First,Next,Prior,Last; конец набора —Eof. - Чтение:
FieldByName('Amount').AsCurrency. - Изменение:
Edit, присвоить поля,Post. Новая запись —AppendилиInsert.
Частая ошибка
Присвоить значение полю без Edit: набор в dsBrowse, и будет исключение «Dataset not in edit or insert mode». Вторая классика — забыть Next в цикле while not Eof и получить вечный цикл.
Как устроено
Openпереводит набор изdsInactiveвdsBrowse.EditдаётdsEdit,InsertилиAppend—dsInsert. Для новой записи срабатываетOnNewRecord— место для значений по умолчанию.Postзаписывает изменения.BeforePost— место проверок: исключение в нём отменяет запись.Cancelоткатывает буфер.- Перемещение вызывает
BeforeScrollиAfterScroll. Если запись редактировалась, перемещение сначала неявно выполняетPost.
Виды полей
| Вид | Откуда значение |
|---|---|
| Динамические | создаются при Open по колонкам результата |
| Постоянные | заданы в Fields Editor и лежат в .dfm |
| Вычисляемые | обработчик OnCalcFields |
| Lookup | другой набор по ключу (LookupDataSet) |
Ловушки
- Если у набора есть постоянные поля, динамические по умолчанию не создаются: новую колонку запроса не найти через
FieldByName. - Тип постоянного поля должен совпадать с типом колонки: после смены типа в базе набор не откроется.
DisableControlsбезtry..finally: после исключения сетка останется замороженной. У master-detail он ещё и останавливает обновление detail-набора.- В
OnCalcFieldsнельзя менять набор: приAutoCalcFields = Trueэто бесконечная рекурсия.
Правило
Проверки — в BeforePost. Массовый проход — между DisableControls и EnableControls в try..finally. Тяжёлой работе не место в AfterScroll.
Под капотом
- Набор держит буфер записей размером с видимую часть сетки, а весь результат хранит конкретная библиотека: клиентский курсор ADO, внутреннее хранилище FireDAC.
- Контролы связаны с набором через
TDataSourceи объектыTDataLink. Набор рассылает им уведомления о смене записи и состояния, аDisableControlsувеличивает счётчик и глушит эту рассылку. OldValueиCurValueполя читаются через временные состоянияdsOldValueиdsCurValue— на них строится разрешение конфликтов.
Кешированные обновления
| Библиотека | Как включить и применить |
|---|---|
| FireDAC | CachedUpdates := True, затем ApplyUpdates и CommitUpdates |
| ADO | LockType := ltBatchOptimistic, затем UpdateBatch |
TClientDataSet |
журнал Delta, ApplyUpdates через провайдер |
ApplyUpdates в FireDAC не бросает исключение, а возвращает число ошибок и сам не открывает транзакцию. Поэтому его оборачивают в StartTransaction и Commit.
Цена
- Обработчик
AfterScrollс запросом к серверу — десятки запросов при пролистывании сетки. - Lookup-поле ищет значение в справочнике для каждой записи. Для большого справочника дешевле JOIN на сервере.
Компромиссы
Кешированные обновления сокращают блокировки и обращения к серверу, но данные устаревают: между чтением и ApplyUpdates их мог изменить другой пользователь. Защита — условие WHERE по ключу или по всем полям (UpdateOptions.UpdateMode в FireDAC).
Как проверить в проде
Сколько запросов порождает работа с формой, видно в SQL Server Profiler или Extended Events, а для FireDAC — в трассировке FDMonitor.
Итог: TDataSet прячет запросы за событиями, а кешированные обновления откладывают конфликты, но не отменяют их.
procedure TOrdersForm.RecalcTotal;var Mark: TBookmark; Total: Currency;begin Total := 0; Mark := qryOrders.Bookmark; // запомнить текущую запись qryOrders.DisableControls; // сетка не перерисовывается на каждой строке try qryOrders.First; while not qryOrders.Eof do begin Total := Total + qryOrdersAmount.AsCurrency; // постоянное поле из .dfm qryOrders.Next; end; qryOrders.Bookmark := Mark; // вернуться к записи пользователя finally qryOrders.EnableControls; // счётчик снова ноль — контролы обновятся end; lblTotal.Caption := CurrToStr(Total);end;
procedure TOrdersForm.qryOrdersBeforePost(DataSet: TDataSet);begin if qryOrdersAmount.AsCurrency <= 0 then raise Exception.Create('Amount must be positive'); // Post отменён, запись в dsEditend;- Чем
TDataSourceотличается отTDataSet? — Набор держит данные, аTDataSourceтолько связывает его с контролами и может отключить их свойствомEnabled. - Что произойдёт при
Nextв состоянииdsEdit? — Набор неявно выполнитPost, если запись изменена, илиCancel, если нет. - Чем вычисляемое поле отличается от lookup? — Вычисляемое считает ваш код в
OnCalcFields, lookup берёт значение из другого набора по ключу. - Зачем
DisableControls, если естьBlockReadSize? —BlockReadSizeтоже не обновляет контролы, но продолжает обновлять detail-наборы. - Как разобрать ошибки после
ApplyUpdatesв FireDAC? — По числу ошибок в результате, затемFilterChangesсrtHasErrorsиRowErrorили обработчикOnReconcileError.
ADO, dbExpress и FireDAC: чем отличаются и как вызывать хранимые процедуры?
Заголовок раздела «ADO, dbExpress и FireDAC: чем отличаются и как вызывать хранимые процедуры?»Что это
| Библиотека | Как работает |
|---|---|
| ADO (dbGo) | компоненты TADO* поверх Microsoft ADO (COM) |
| dbExpress | лёгкие драйверы, наборы только вперёд; устарел |
| FireDAC | компоненты TFD*, один API для многих СУБД |
Как вызвать процедуру
- Положите на форму соединение (
TADOConnectionилиTFDConnection) и компонент процедуры. - Задайте имя:
ProcedureNameу ADO,StoredProcNameу FireDAC. - Получите список параметров:
Parameters.Refreshу ADO,Prepareу FireDAC. - Заполните входные параметры и вызовите
ExecProcилиOpen, если процедура возвращает строки. - После выполнения прочитайте выходные параметры.
Частая ошибка
Вызвать Open для процедуры без набора строк или ExecProc для процедуры, которая его возвращает. FireDAC в обоих случаях сообщит об ошибке.
Как устроено
- ADO —
TADOConnectionсо строкой подключения к OLE DB-провайдеру, наборыTADOQuery,TADOStoredProc,TADOCommand. Курсор по умолчанию клиентский (CursorLocation = clUseClient): весь результат приходит на клиент. - dbExpress —
TSQLConnection,TSQLQuery. Наборы однонаправленные и не редактируются, для сетки нужна связкаTDataSetProviderиTClientDataSet. - FireDAC —
TFDConnectionсDriverID=MSSQL. К SQL Server ходит через ODBC Driver for SQL Server, документация рекомендует версию 18.
Что даёт FireDAC
- Выборка.
FetchOptions.Mode:fmOnDemandпо умолчанию тянет строки порциями поRowsetSize(50),fmAll— всё сразу приOpen. - Array DML. Одна команда с массивами параметров вместо цикла:
Params.ArraySizeиExecute. - Макросы.
&Nameили!Nameподставляют в текст SQL то, что нельзя передать параметром, например имя таблицы. - Мониторинг.
TFDMoniRemoteClientLinkиMonitorBy=Remoteпоказывают в утилите FDMonitor каждую команду и её время.
Ловушки
- Параметры SQL Server в FireDAC называются с
@(@CustomerId).ResourceOptions.UnifyParamsубирает префикс. - FireDAC не поддерживает параметры процедур со значениями по умолчанию: заполняйте их явно.
- Макрос — подстановка текста, поэтому ввод пользователя в нём — SQL-инъекция.
Правило
Новый код — на FireDAC, значения — только параметрами, массовая запись — через Array DML.
Под капотом
TFDStoredProc.PrepareприfiMetaвFetchOptions.Itemsзапрашивает описание параметров у сервера — это отдельное обращение. ADO делает то же вParameters.Refresh. Поэтому компонент готовят один раз, а в горячем коде параметры описывают вручную.- На SQL Server
fmOnDemandиспользует серверный статический курсор, аfmAll— быстрый forward-only и сразу освобождает соединение для следующей команды. - MARS в драйвере MSSQL включён по умолчанию, и документация предупреждает о возможной потере производительности.
- Array DML на SQL Server выполняется нативно, а ошибки собираются по всем строкам массива.
Компромиссы
| Вопрос | ADO | FireDAC |
|---|---|---|
| Зависимости | COM и OLE DB-провайдер | ODBC-драйвер SQL Server |
| Массовая запись | цикл вызовов или batch на клиентском курсоре | Array DML |
| Диагностика | трассировка на сервере | FDMonitor на клиенте и трассировка на сервере |
Цена перехода
Замена ADO на FireDAC — не переименование компонентов: различаются имена и типы параметров, отображение типов полей, курсоры. Это отдельная задача, о ней — в теме Миграция Delphi 6 → 12/13.
Как проверить в проде
- FDMonitor или
TFDMoniFlatFileClientLinkзаписывают текст, параметры и время каждой команды. - На сервере Extended Events или Query Store показывают, какие процедуры вызываются чаще и работают дольше.
Итог: скорость доступа к данным решают метаданные, режим выборки и пакетность, а не выбор компонента.
// FireDAC: имена параметров SQL Server начинаются с @FDStoredProc1.StoredProcName := 'dbo.OrderCreate';FDStoredProc1.Prepare; // запрос списка параметров у сервераFDStoredProc1.ParamByName('@CustomerId').AsInteger := 42;FDStoredProc1.ExecProc; // процедура без набора строкNewOrderId := FDStoredProc1.ParamByName('@OrderId').AsInteger; // OUTPUT-параметр
// ADO: то же самоеADOStoredProc1.ProcedureName := 'dbo.OrderCreate';ADOStoredProc1.Parameters.Refresh; // тоже обращение к серверу за метаданнымиADOStoredProc1.Parameters.ParamByName('@CustomerId').Value := 42;ADOStoredProc1.ExecProc;NewOrderId := ADOStoredProc1.Parameters.ParamByName('@OrderId').Value;
// Array DML: весь массив одной командой вместо цикла с ExecSQLFDQuery1.SQL.Text := 'insert into dbo.ItemTag (ItemId, Tag) values (:ItemId, :Tag)';FDQuery1.Params.ArraySize := Length(Tags);for I := 0 to High(Tags) dobegin FDQuery1.Params[0].AsIntegers[I] := Tags[I].ItemId; FDQuery1.Params[1].AsStrings[I] := Tags[I].Name;end;FDQuery1.Execute(FDQuery1.Params.ArraySize, 0);- Почему набор dbExpress нельзя редактировать в сетке? — Он однонаправленный и не буферизует строки; нужен
TClientDataSetчерезTDataSetProvider. - Как получить RETURN-значение процедуры? — Параметр с
ParamType = ptResult; у SQL Server он называется@RETURN_VALUE. - Чем макрос отличается от параметра? — Параметр передаёт значение отдельно от текста, макрос меняет сам текст SQL до отправки.
- Когда
fmAll, а когдаfmOnDemand? —fmAll— для небольших результатов и чтобы сразу освободить соединение,fmOnDemand— для больших списков, которые листают частями. - Процедура возвращает несколько наборов строк — как их прочитать? —
NextRecordSetв FireDAC,NextRecordsetв ADO.
Как работать с потоками в VCL-приложении и не сломать UI?
Заголовок раздела «Как работать с потоками в VCL-приложении и не сломать UI?»Что это
- Главный поток создаёт окна и обрабатывает их сообщения: щелчки, нажатия клавиш, перерисовку.
- Фоновый поток —
TThreadили задачаTTask. Он делает долгую работу, не останавливая цикл сообщений.
Зачем
Запрос на 10 секунд в обработчике кнопки на 10 секунд замораживает окно. Windows пишет «Не отвечает», а пользователь щёлкает ещё раз.
Как пользоваться
- Запустите работу в фоне:
TThread.CreateAnonymousThread(...).StartилиTTask.Run(...). - Чтобы показать результат, передайте код в
TThread.SynchronizeилиTThread.Queue— он выполнится в главном потоке. - Пока работа идёт, отключите кнопку запуска.
Частая ошибка
Менять Label1.Caption или ProgressBar1.Position прямо из фонового потока. Иногда это «работает», а потом приложение зависает или падает в случайном месте.
Как устроено
Synchronize |
Queue |
|
|---|---|---|
| Фоновый поток | ждёт, пока главный выполнит код | сразу продолжает работу |
| Подходит для | ответа из UI, например выбора в диалоге | прогресса, лога, уведомлений |
Из главного потока Queue выполняет код сразу; ForceQueue всегда ставит его в очередь.
Способы запуска
- Наследник
TThreadс методомExecute— полный контроль:Terminate,WaitFor,OnTerminate, который выполняется в главном потоке. TThread.CreateAnonymousThread— для разовой работы. Поток освобождает себя сам (FreeOnTerminate), послеStartего ссылку трогать нельзя.- PPL (
System.Threading):TTask.Run— задача на пуле потоков,TTask.Future<T>— задача с результатом,TParallel.For— параллельный цикл.
Ловушки
ProcessMessages. Пока он обрабатывает очередь, пользователь может снова нажать кнопку или закрыть форму, и обработчик продолжит работу с освобождённой формой.- Общее соединение. Два потока на одном
TFDConnectionдают AV или ошибку SQL Server «Connection is busy with results for another command». - ADO в потоке — это COM: в начале
ExecuteнуженCoInitialize(nil), в конце —CoUninitialize. - Захват переменной. Анонимный метод захватывает переменную, а не значение:
Queueвнутри цикла может показать уже следующее значение счётчика.
Правило
UI — только из главного потока, у фонового потока — своё соединение, прогресс — через Queue.
Под капотом
SynchronizeиQueueкладут вызов в общую очередь и будят главный поток. Он выполняет её вCheckSynchronize, когда возвращается в цикл сообщений, а до тех пор поток вSynchronizeстоит.TTaskвыполняется на пуле потоков.ITask.Cancelтолько помечает задачу, а код сам вызываетTTask.CurrentTask.CheckCanceled, который броситEOperationCancelled.TTask.WaitForAllи чтениеIFuture<T>.Valueв главном потоке блокируют его так же, как синхронный запрос.
Цена
- Соединение на поток — это сессии на сервере. Пул FireDAC (
Pooled=Trueв определении соединения,POOL_MaximumItemsпо умолчанию 50) переиспользует физические соединения.FDManager.Active := Trueвыставляют до запуска потоков. Queueна каждую строку заваливает очередь главного потока. Прогресс обновляют раз в N строк или по таймеру.
Компромиссы
- Отмена.
Terminatedпроверяется только между командами. Прервать уже выполняемую команду FireDAC позволяетAbortJobу её соединения. - Взаимоблокировка. Главный поток ждёт критическую секцию или событие, а фоновый, держа её, вызывает
Synchronize— оба ждут друг друга. - Закрытие формы. В
OnCloseQueryпотоки останавливают черезTerminateиWaitFor, а невыполненные вызовы снимаютTThread.RemoveQueuedEvents. Иначе запоздалыйQueueобратится к освобождённой форме.
Как проверить в проде
- madExcept и EurekaLog умеют ловить зависание главного потока и сохраняют стеки всех потоков.
- В лог пишите
TThread.Current.ThreadID, чтобы видеть, в каком потоке выполнялся код.
Итог: поток сам по себе не делает приложение отзывчивым: нужны своё соединение, отмена, ожидание при закрытии и никакого UI вне главного потока.
procedure TStockWorker.Execute;var Conn: TFDConnection; Proc: TFDStoredProc; Batch: Integer;begin Conn := TFDConnection.Create(nil); // у потока своё соединение Proc := TFDStoredProc.Create(nil); try Conn.ConnectionDefName := 'Shop_Pooled'; // определение с Pooled=True Proc.Connection := Conn; Proc.StoredProcName := 'dbo.RecalcStock'; Proc.Prepare; for Batch := 1 to 100 do begin if Terminated then Exit; // кооперативная отмена, finally всё освободит Proc.ParamByName('@BatchNo').AsInteger := Batch; Proc.ExecProc; ReportProgress(Batch); end; finally Proc.Free; Conn.Free; end;end;
procedure TStockWorker.ReportProgress(Value: Integer);begin // Value — параметр: у каждого вызова своя копия, а не общий счётчик цикла TThread.Queue(Self, procedure begin MainForm.ProgressBar1.Position := Value; // выполнится в главном потоке end);end;Форма при закрытии вызывает Worker.Terminate и Worker.WaitFor, а затем TThread.RemoveQueuedEvents(Worker), чтобы в очереди не осталось обращений к ней.
- Чем
Synchronizeотличается отQueue? — Первый ждёт выполнения кода в главном потоке, второй ставит код в очередь и не ждёт. - Почему
Application.ProcessMessagesопасен? — Повторный вход: посреди вашего кода выполняются другие обработчики, форму могут закрыть. - Можно ли использовать один
TFDConnectionиз двух потоков? — Только строго поочерёдно; на практике — своё соединение на поток и пул. - Как отменить уже выполняемый запрос? —
AbortJobу соединения FireDAC; флагTerminatedпроверяется только между командами. - Что нужно ADO в фоновом потоке? —
CoInitializeв началеExecuteиCoUninitializeв конце, потому что ADO — это COM.