想像這個場景:使用者按下批次儲存,等了一會兒,畫面仍然沒有反應。
「到底有沒有存進去?我可以再按一次嗎?」
這是情境示意,卻點出一個很實際的困境:使用者看不見進度,就難以判斷自己應該等待,還是重試。
上一篇,我處理的是一個儲存效能問題:80 多筆資料,竟然可能要等將近五分鐘。
我把流程拆開,觀察 SQL、Dataset、事件與 UI 的成本,再逐步減少重複工作。
但等待時間之外,還有另一個很容易讓人急著動手的問題:
儲存還在執行,畫面卻像當掉一樣。
按鈕沒有反應,視窗沒有正常重畫,連進度文字都停在同一個數字。
這時候,很容易想到一個 Delphi 老朋友:
Application.ProcessMessages;
只要在迴圈裡加上這一行,畫面就有機會重新回應訊息。
看起來很划算。
不用重構、不用改 SQL,也不用立刻引入 Thread。
可是,在資料儲存流程裡,我需要多問一句:
畫面恢復反應之後,使用者的其他操作,會不會也跟著進來?
這篇沿用前一篇的批次儲存情境,以匿名化的示意程式拆解風險;以下的重複儲存、重新查詢與關閉情境,是分析與測試案例,不代表已確認發生在前一篇的實際系統。
一般 VCL 桌面程式的畫面操作與事件處理,大多發生在主執行緒。
如果儲存事件一直忙著查資料、計算與更新,主執行緒就無法照平常的節奏處理視窗訊息。
這時呼叫 Application.ProcessMessages,會處理訊息佇列中的訊息,直到佇列清空,再返回原本流程。官方文件也說明,長時間作業期間呼叫它,可以讓程式回應重畫及其他訊息。
關鍵是最後那幾個字:其他訊息。
它不是只幫進度文字重畫,也沒有把儲存工作自動搬到背景執行緒。
SQL 還是那些 SQL,計算還是那些計算;新增的訊息處理甚至可能增加總耗時。
所以這裡需要分開看兩件事:
| 問題 | 要觀察的結果 |
|---|---|
| 效能 | 同一批工作實際花多少時間? |
| 回應性 | 工作執行期間,畫面能否回應? |
前一篇處理的是工作成本。這一行主要改變的,則是工作期間的訊息處理方式。
先看一段容易出現的示意寫法:
procedure TBatchForm.btnSaveClick(Sender: TObject);
begin
dtDetail.First;
while not dtDetail.Eof do
begin
SaveCurrentDetail; // 示意:計算並儲存目前明細
Application.ProcessMessages;
dtDetail.Next;
end;
end;
評估 AI 建議時,我不能只看它有沒有讓進度文字更新,還要要求它列出:這次訊息處理可能執行哪些事件,以及那些事件會改變哪些共用狀態。判斷修改是否可接受,需要同時追蹤畫面行為與儲存流程。
假設儲存按鈕仍然可以操作,使用者在等待時又按了一次。
當程式走到 ProcessMessages,就可能處理這次點擊,再次進入 btnSaveClick。
這叫做 重入(Reentrancy):第一次呼叫還沒完成,同一段流程就又被進入了。
sequenceDiagram
participant S as 第一輪儲存
participant M as 訊息處理
participant E as 第二輪儲存
S->>M: ProcessMessages
M->>E: 處理再次點擊
E->>E: First、計算、儲存
E-->>M: 返回
M-->>S: 繼續原本迴圈
這裡不需要兩條執行緒同時跑。
同一條主執行緒,也能在第一次呼叫尚未返回時,巢狀執行另一個事件。
如果兩輪共用 dtDetail,第二輪的 First、Next 或其他定位操作,就可能改變第一輪以為自己仍然掌握的游標。
第二輪返回後,第一輪仍會繼續執行 dtDetail.Next。
但此時「目前這筆」未必還是原來那筆。
把它縮成一個只有三筆明細的示意案例,就更容易看見差異。假設單筆處理不移動游標、沒有其他事件干擾,第二輪也能正常走完:
| 步驟 | 無旗標保護 | 有旗標保護 |
|---|---|---|
| 第一輪處理 A 後 | 停在 A,進入訊息處理 | 停在 A,進入訊息處理 |
| 第二次儲存事件進入 | 再次 First,依序處理 A、B、C |
發現作業進行中,立即返回 |
| 返回第一輪 | 共用游標已在 EOF,原迴圈失去預期位置 | 游標仍在 A,下一步移到 B |
| 處理結果 | A 處理兩次;B、C 由第二輪處理 | 第一輪依序處理 A、B、C |
這不是「明細一定會少一半」的保證,而是指出:兩次操作的處理範圍已經交錯。 若單筆處理只是覆寫相同值,畫面可能暫時看不出差異;若包含累加或新增紀錄,重複處理就可能留下不同結果,仍需追到實際寫入才能確認。
實際結果取決於儲存邏輯,可能是漏處理、重複處理、狀態錯誤,也可能當次剛好沒有明顯異常。不能只憑這一行,就斷言一定會重複寫入。
真正需要驗證的是:原本流程依賴的狀態,有沒有在中途被其他事件改變。
即使儲存按鈕不能再按,也可能有其他入口動到同一批資料。
| 中途進入的事件 | 可能破壞的前提 |
|---|---|
| 重新查詢 | Dataset 被 Close/Open,資料集合改變 |
| 切換案件 | 目前案件與儲存開始時不同 |
| 刪除明細 | 迴圈正在處理的資料消失 |
| Timer 自動刷新 | Dataset 或畫面狀態被更新 |
| 關閉表單 | 元件生命週期改變,後續程式仍可能存取它們 |
關閉表單不一定立刻造成 Access Violation;是否隱藏、釋放或延後釋放,仍要看表單設定與關閉處理。
但只要儲存尚未完成,元件的生命週期就不能留給運氣決定。
因此我檢查的範圍,不能只停在 btnSave.Enabled := False。
我需要找出:所有會改變這次作業前提的入口。
前一篇使用了:
dtDetail.DisableControls;
它用來暫停 Dataset 對資料感知控制項的更新,不是整張表單的操作鎖,也不是防重入機制。
同樣地,資料庫 Transaction 可以定義資料異動的提交與回滾邊界,卻不會自動阻止查詢按鈕、Timer 或關閉事件進來。
如果第一次作業已開啟交易,第二個事件又共用同一個 Connection,還可能干擾交易狀態。實際行為需要依資料存取元件與既有交易管理方式確認。
這些保護處理的是不同問題:
| 機制 | 主要責任 |
|---|---|
DisableControls |
減少資料感知控制項更新 |
| 作業中的狀態旗標 | 阻止衝突入口進入 |
| Transaction | 管理資料庫異動的提交與回滾 |
| 關閉保護 | 維持作業所需元件的生命週期 |
不能因為其中一項已經存在,就認為其他邊界也一起受到保護。
如果現在只需要讓使用者看見處理進度,我會先評估移除不必要的 ProcessMessages,針對進度元件重畫,並為衝突入口加上作業狀態檢查。
下面是 Delphi 2009 可採用的示意骨幹。元件與函式名稱已匿名化,SaveCurrentDetail 代表既有的單筆處理,不包含完整的儲存與交易實作。
type
TBatchForm = class(TForm)
// 既有元件與事件宣告省略
private
FBusy: Boolean; // 同一張表單的批次作業狀態
end;
procedure TBatchForm.btnSaveClick(Sender: TObject);
var
DoneCount: Integer;
OldSaveEnabled: Boolean;
OldQueryEnabled: Boolean;
begin
// 先擋重入;旗標要在任何可能讓出控制權的動作之前設定
if FBusy then
Exit;
if not dtDetail.Active then
Exit;
if dtDetail.IsEmpty then
Exit;
OldSaveEnabled := btnSave.Enabled;
OldQueryEnabled := btnQuery.Enabled;
FBusy := True;
try
btnSave.Enabled := False;
btnQuery.Enabled := False;
dtDetail.DisableControls;
try
DoneCount := 0;
dtDetail.First;
while not dtDetail.Eof do
begin
SaveCurrentDetail; // 示意:計算並儲存目前明細
Inc(DoneCount);
// 降低重畫頻率;不在每一筆都處理整個訊息佇列
if (DoneCount mod 10) = 0 then
begin
lblProgress.Caption :=
Format('已處理 %d 筆', [DoneCount]);
lblProgress.Repaint;
end;
dtDetail.Next;
end;
lblProgress.Caption :=
Format('處理完成,共 %d 筆', [DoneCount]);
lblProgress.Repaint;
finally
dtDetail.EnableControls;
end;
finally
// 發生例外時,也要恢復進入作業前的狀態
try
btnSave.Enabled := OldSaveEnabled;
btnQuery.Enabled := OldQueryEnabled;
finally
FBusy := False;
end;
end;
end;
這個迴圈還有一項前提:SaveCurrentDetail 返回後,Dataset 必須維持外層預期的資料位置。若單筆處理本身會重新查詢、刪除或移動游標,就需要另外定義迭代方式;防重入旗標不會修正這類問題。
範例每 10 筆重畫一次,是為了簡單地降低更新頻率;但每筆耗時不同,實際更新間隔也會不同。若單筆耗時差異很大,可再評估依時間間隔更新。
這段刻意保存原本的 Enabled 值,避免結束後一律設成 True,反而把原先因權限或資料狀態停用的按鈕打開。
Repaint 的用途是讓指定元件重畫,不是讓整個應用程式恢復完整互動。長時間的 SQL 呼叫期間,這段程式仍然可能無法更新進度或回應操作。
此外,重畫可能執行繪圖相關程式;若自訂繪圖事件會查詢或修改資料,也需要檢查其副作用。
這是一個收斂風險的局部修改,不是完整的背景作業架構。
停用按鈕可以提供視覺提示,但真正的限制仍應放在事件或共用作業入口。
procedure TBatchForm.btnQueryClick(Sender: TObject);
begin
if FBusy then
Exit;
ReloadDetail; // 示意:重新查詢資料
end;
procedure TBatchForm.RefreshTimerTimer(Sender: TObject);
begin
if FBusy then
Exit;
ReloadDetail; // 示意:重新查詢資料
end;
procedure TBatchForm.FormCloseQuery(
Sender: TObject; var CanClose: Boolean);
begin
if FBusy then
begin
CanClose := False;
Exit;
end;
// 接續既有的未存檔檢查與關閉規則
end;
這些事件必須確認已綁定;案件切換、刪除、快捷鍵、Action 與其他直接呼叫路徑,也要依實際程式補齊。
FormCloseQuery 只處理正常關閉流程,無法保護繞過它直接釋放表單的程式。若系統有這類路徑,還需要另外檢查。
而 FBusy 的保護範圍,也只限於遵守這個旗標的同一張表單。它不會阻止另一台電腦或另一個程式更新相同資料。
有些既有流程暫時無法移除它,例如仍依賴訊息處理接收取消操作。
這時我會先確認:
不能把取消按鈕直接寫成 dtDetail.Close,讓正在使用 Dataset 的迴圈下一行才發現資料已經消失。
若某一筆 SQL 本身就阻塞數十秒,只在兩筆之間呼叫 ProcessMessages,也無法讓那段等待立刻可取消。
長時間作業可以評估搬離主執行緒,但在 Legacy System 裡,這通常需要先拆開責任。
背景執行緒不能直接操作 VCL 控制項;資料存取元件與 Connection 也不能在未確認支援方式時,直接與 UI 共用。
至少需要設計工作輸入、資料存取的執行緒歸屬、UI 更新方式,以及取消與表單關閉的生命週期。
在沒有好好分層的祖傳系統裡,一個儲存函式可能同時讀取畫面欄位、移動綁定控制項的 Dataset,再透過事件重新查詢其他資料。搬動其中一段,往往就牽動整條事件鏈。
因此,當 AI 建議「放到背景執行緒就好」時,我會先要求它盤點這些依賴:哪些輸入可以先取得快照?哪些資料存取元件需要由背景作業獨立管理?哪些結果必須回到主執行緒呈現?
背景作業是值得規劃的方向,但局部修補也需要有清楚的保護範圍。 先減少不必要的訊息處理、守住衝突入口與元件生命週期,再依可驗證的範圍逐步拆開 UI 與儲存邏輯,是一種工程取捨。
今天先處理的是事件邊界。若只是把原本的整個儲存事件包進 Thread,裡面的 Dataset、控制項與事件耦合並不會自行消失。
正常按一次儲存,再等到完成,只能驗證最順利的路徑。
驗證時要區分兩種版本。保留 ProcessMessages 的版本,需要確認其他事件進入時,會被作業旗標擋住;移除它的版本,則需要確認流程中是否仍有其他訊息處理點,以及完成後是否觸發額外操作。
若主執行緒持續執行同步儲存,而且沒有其他訊息處理點,點擊或 Timer 事件可能根本沒有在儲存途中被派送。沒有觀察到重入,不等於已證明防重入機制有效。
因此,在測試環境中刻意於儲存期間操作時,除了看結果,也要記錄事件實際進入的時間。下表中的「作業中進入」,指事件確實在第一輪結束前被呼叫;若當時未派送,則另外確認完成後是否觸發額外操作:
| 測試情境 | 要確認的結果 |
|---|---|
| 再次觸發儲存 | 作業中進入應被擋住;完成後無額外儲存 |
| 查詢或切換案件 | 作業中進入應被擋住;資料集合與案件不變 |
| Timer 到期 | 記錄實際執行時間;作業中擋住刷新 |
| 要求關閉表單 | 作業中拒絕關閉;完成後依既有規則處理 |
| 單筆處理拋出例外 | 旗標、控制項與交易依既有規則恢復 |
| 正常完成 | 筆數、金額與必要副作用符合 Baseline |
為了確認是否真的發生巢狀呼叫,可以在進入、拒絕進入、離開作業的位置記錄時間、作業識別碼與呼叫深度。
以下示範的是:作業期間,第二個儲存入口確實被呼叫時,預期留下的 Log。這是示意 Log,並非實測紀錄:
Operation=A SaveEnter Depth=1
Operation=B SaveRejected Reason=Busy
Operation=A SaveLeave Depth=0
在保留 ProcessMessages 的版本中,若第二次入口在第一輪尚未完成時進入,且沒有被擋住,Call Stack 則可能看到:
btnSaveClick(第二輪)
訊息派送相關呼叫
Application.ProcessMessages
btnSaveClick(第一輪)
實際堆疊名稱會依版本與除錯資訊不同,但判斷重點相同:第一輪還在堆疊裡,第二輪已經進入。
你現在的角色是 Delphi Legacy System 維護協作者。
目前問題:批次儲存時畫面無法回應。
程式中存在 Application.ProcessMessages。
請先不要增加更多 ProcessMessages,也不要直接改成 Thread。
請完成:
1. 找出呼叫位置,以及當時正在使用的 Dataset、Connection、
交易、表單與狀態變數。
2. 列出訊息處理期間可能進入的儲存、查詢、切換、刪除、
Timer、快捷鍵與關閉事件。
3. 說明這些事件是否可能改變外層流程依賴的狀態。
4. 區分已確認的重入、可能的風險與待驗證事項。
5. 評估移除呼叫、局部重畫或保留呼叫所需的保護。
6. 提出最小修改,保留商業規則與既有交易語意。
7. 說明例外時如何恢復狀態,以及旗標的保護範圍。
8. 提供 Call Stack、Log 與操作情境的驗證方法,區分保留與
移除 ProcessMessages 的版本,記錄事件實際進入的時間;
不要把沒有觀察到重入,直接視為旗標有效的證據。
Application.ProcessMessages 看起來只有一行,但它會讓原本尚未完成的流程,在中途處理其他訊息。
對批次儲存來說,我真正需要確認的是:
控制權交出去之後,回來時,我依賴的狀態還在嗎?
Dataset 是否仍然開啟?
案件是否仍然相同?
交易是否仍由這次作業管理?
表單與元件是否仍然存在?
這也改變了我向 AI 提問的方式。
除了「怎麼讓畫面不要卡」,我還會要求它說明:這項修改會讓哪些事件有機會進來,又會破壞哪些原本成立的前提。
修改的行數很少,不代表改變的系統行為也很少。
讓使用者看見進度,是讓等待變得可以理解;守住儲存流程的狀態,則是讓這次操作可以信任。
畫面恢復反應時,儲存流程也必須守得住狀態。讓使用者安心等待,不能以資料正確性作為代價。