前一篇提到,面對一支超過一萬行的 Delphi 程式,我不會再把整個專案一次交給 AI,而是先提供需求、錯誤現象、.pas、.dfm 與必要的欄位說明。
可是,找到事件入口之後,事情並沒有立刻變簡單。
最近我處理一個表單問題:使用者完成查詢後,程式有時會重複刷新目前資料,畫面反應變慢,甚至在某些操作下出現資料衝突。
我一開始把注意力放在查詢按鈕。
畢竟,問題是在「按下查詢」之後發生的,最合理的嫌疑人看起來就是 QueryButtonClick。
但當我把按鈕事件丟給 AI 檢查時,它丟回一個我當時沒想到的方向:
按鈕本身可能只負責開啟 Dataset。真正重複執行的流程,可能是 Dataset 開啟後自動觸發的
AfterScroll。
這句話讓我盯著螢幕愣了一下,然後重新看了一次程式。
結果問題真的不只藏在按鈕裡。
使用者按下一次查詢,程式先開啟資料集;資料集定位到第一筆後,觸發 AfterScroll;而 AfterScroll 裡又呼叫 RefreshRecord。
RefreshRecord 不是粗暴地重新 Open 整個 Dataset,也不代表游標一定會跳回第一筆。它主要是向伺服器重新取得目前這筆記錄的最新狀態,再更新目前列的欄位內容。
但在我這次使用的元件與實際執行紀錄中,這個動作伴隨了游標相關通知,讓 AfterScroll 又默默回頭執行了一次。
從使用者角度看,他只按了一次按鈕。
從程式角度看,事件早就偷偷繞了一圈。
這大概是讀 Delphi Legacy System 最讓人崩潰的地方:畫面上的操作是直線,底下的事件卻常常像張蜘蛛網。
把這幾天踩坑下來的對比整理一下,其實就是這些隱藏支線在搞事:
| 直覺以為的線性流程 | 實際暗藏的事件鏈 |
|---|---|
按下查詢 → Open → 顯示資料 |
按下查詢 → Open → AfterScroll → RefreshRecord → 可能再次進入 AfterScroll |
按下刪除 → Delete → ApplyUpdates |
按下刪除 → Delete → AfterDelete → 加總計算 → ApplyUpdates |
按下儲存 → Post → 寫入資料庫 |
按下儲存 → Post → BeforePost → ApplyUpdates → Provider/AP/資料庫 |
看懂這張表之後,我學乖了:看到按鈕事件先別太早高興,那通常只是入口。
今天就來記錄一下,我怎麼拿著這些線索,和 AI 一步一步把事件流程掀開。
為了避免公開實際系統內容,下面的表單、Dataset、函式與欄位名稱都已改成用途相近的虛構名稱;事件之間的關係則保留原本的問題結構。
使用者按下查詢後,前端大致執行這段程式:
procedure TDocumentForm.QueryButtonClick(Sender: TObject);
begin
DocumentData.Close;
DocumentData.Params.ParamByName('DOCUMENT_ID').AsString :=
DocumentIdEdit.Text;
DocumentData.Open;
end;
只看這段,乾淨俐落:
所以我一開始也是理直氣壯地把這段貼給 AI,要它看看 Close 跟 Open 有沒有被重複觸發。
不出所料,AI 也只能告訴我:按鈕裡看不到迴圈,要不要再去 .dfm 看看 Dataset 還綁了哪些事件?
它沒有直接找出答案,但至少把我從按鈕裡拖了出來。
.dfm 補上程式裡看不到的暗線摸摸鼻子打開 .dfm,果不其然,上面還有這些設定:
object QueryButton: TButton
Caption = '查詢'
OnClick = QueryButtonClick
end
object DocumentData: TClientDataSet
ProviderName = 'DocumentProvider'
BeforePost = DocumentDataBeforePost
AfterScroll = DocumentDataAfterScroll
end
這幾行直接打破了我原本的幻想:
QueryButton 確實會進入 QueryButtonClick。DocumentData 開啟或移動目前列後,可能進入 DocumentDataAfterScroll。Post 前,還會先進入 DocumentDataBeforePost。DocumentProvider 往後端送。我原本以為的終點 DocumentData.Open,其實只是另一場戲的開端:
QueryButtonClick
↓
DocumentData.Open
↓
Dataset 取得第一筆資料
↓ 自動觸發
DocumentDataAfterScroll
當然,這時候還只能算是有根據的推測。
.dfm 證明事件確實綁在 Dataset 上,但這次操作到底跑了幾次,還是得靠 Breakpoint 或執行紀錄來確認。
AfterScroll 裡把 DocumentDataAfterScroll 挖出來後,大致長這樣:
procedure TDocumentForm.DocumentDataAfterScroll(DataSet: TDataSet);
begin
if (not DocumentData.Active) or DocumentData.IsEmpty then
Exit;
CurrentDocumentId :=
DocumentData.FieldByName('DOCUMENT_ID').AsString;
DocumentData.RefreshRecord;
LoadDocumentDetail(CurrentDocumentId);
end;
單看這段很合理:切換單據時抓出目前 ID、更新主檔,再把明細載入。
問題就卡在 RefreshRecord。
前面提過,它不是重開整張表,而是重新取得目前記錄並更新欄位。但在這次實際測試中,我發現執行到這裡後,AfterScroll 又進來了一次。
事件鏈於是變成:
DocumentData.Open
↓
DocumentDataAfterScroll
↓
DocumentData.RefreshRecord
↓
再次進入 DocumentDataAfterScroll
這時候我才拍大腿:難怪在按鈕事件裡怎麼找都找不到迴圈。
不是按鈕自己呼叫 AfterScroll,而是 Dataset 的事件機制在背後接手了流程。
看到 AfterScroll → RefreshRecord → AfterScroll 的瞬間,我和 AI 的第一反應都是:「該不會是無限遞迴吧?」
但真的下中斷點後,我發現情況沒有那麼整齊。
有時候事件會明顯重入,有時候只多跑一兩次。實際次數仍會受到 Dataset 元件、Provider 行為、目前列狀態與資料繫結控制項影響。
所以我沒有直接接受「一定是無限遞迴」這個說法,而是和 AI 重新列出幾個要驗證的問題:
DocumentDataAfterScroll 實際進入幾次?RefreshRecord?LoadDocumentDetail 裡是否又回頭操作 DocumentData?把事實、推測與未知攤開後,除錯才終於不像瞎子摸象。
AfterDelete 帶出的另一場災難同一天,我在另一個明細表單又踩了一個坑。
使用者只要刪掉最後一筆明細,畫面立刻丟出:
Access violation
Read of address 00000010
按鈕程式大致長這樣:
procedure TDetailForm.DeleteButtonClick(Sender: TObject);
begin
DetailData.Delete;
if DetailData.ApplyUpdates(-1) <> 0 then
raise Exception.Create('明細刪除儲存失敗');
SyncTotalToMaster;
end;
表面上看來,Delete 完就準備送出更新。
但打開 .dfm 一看,果然又有一條暗線:DetailData 綁了 AfterDelete,而 AfterDelete 會跑去呼叫加總函式 RecalculateTotal。
真正執行的順序比較接近:
DeleteButtonClick
↓
DetailData.Delete
↓ 自動觸發
DetailDataAfterDelete
↓
RecalculateTotal
↓ 回到按鈕
ApplyUpdates
而崩潰的位置,就在 RecalculateTotal 一開始:
procedure TDetailForm.RecalculateTotal;
var
SavedBookmark: TBookmark;
begin
SavedBookmark := DetailData.GetBookmark;
// 以下開始逐筆加總
end;
刪除一般資料時,Dataset 還有剩餘記錄可以建立 Bookmark。
但刪掉最後一筆時,AfterDelete 執行當下,Dataset 已經空了。沒有目前記錄,程式卻仍然呼叫 GetBookmark,這就是 Access violation 最可疑的來源。
找到線索後,我第一時間的直覺反應,就是先補一個判空:
if (not DetailData.Active) or DetailData.IsEmpty then
Exit;
這樣確實不再當機。
但新的 Bug 馬上出現:明細明明全被刪光了,畫面上的金額與主檔總計,竟然還停在刪除前的數字。
資料沒了,金額卻像幽靈一樣飄在畫面上。
這比直接跳出錯誤訊息還麻煩,因為使用者可能會以為畫面上的金額是正確的。
我把這個結果丟回給 AI,重新問了一次:
如果空資料直接離開,加總函式原本負責維護的狀態會怎麼辦?
這次問題才終於完整。
空資料不只是例外,也代表「現在合計應該是零」。所以我們調整成先把變數與畫面歸零,再安全離開:
procedure TDetailForm.RecalculateTotal;
var
SavedBookmark: TBookmark;
begin
if (not DetailData.Active) or DetailData.IsEmpty then
begin
PositiveAmount := 0;
NegativeAmount := 0;
TotalAmount := 0;
ManagementFee := 0;
PositiveAmountLabel.Caption := '0';
NegativeAmountLabel.Caption := '0';
TotalAmountLabel.Caption := '0';
Exit;
end;
SavedBookmark := DetailData.GetBookmark;
DetailData.DisableControls;
FCalculating := True;
try
// 原本的逐筆加總邏輯
finally
FCalculating := False;
DetailData.EnableControls;
end;
end;
這裡我和 AI 又核對了兩個容易漏掉的地方。
第一個是 DisableControls。
它會暫停 Data-aware 控制項接收 Dataset 的 DataChange 通知,避免 DBGrid 跟著每一筆資料一直更新。除了減少畫面閃爍,也能省掉大量 UI 重繪,對批次加總的效能很有幫助。
可是,如果中途出錯而沒有走到 EnableControls,畫面可能就一直停在舊狀態,看起來像整張表單凍住了。
所以這次我把它放進 try...finally。不是因為教科書說一定要這樣寫,而是我不想修好 Access violation,隔天又收到「Grid 怎麼不會動了」的新回報。
第二個是防重入旗標 FCalculating。
它用來表示加總正在進行,避免事件途中又跑回同一段流程。但如果例外發生後沒有改回 False,後續事件可能每次都因為旗標仍為 True 而直接離開。
因此,FCalculating := False 也放在 finally 裡。這不是為了讓程式看起來工整,而是確保失敗後表單還能繼續工作。
我也一度想再補一個萬用的 State 防護:
if DetailData.State <> dsBrowse then
Exit;
AI 看到這段後反而踩了煞車。
BeforePost 本來就會發生在編輯或新增階段。如果什麼函式都限定只有 dsBrowse 才能執行,有些必要的驗證與計算可能也會一起被跳過。
最後我沒有硬塞一個通用判斷,而是先確認每支函式原本允許在哪些 State 下運作。
這次的經驗讓我發現,防禦性程式不是 Exit 越多越安全。有時候錯誤不見了,只是因為連正常流程也一起消失了。
BeforePost 與 AP 也藏著支線除了查詢與刪除,儲存時也有一條畫面上看不到的路。
前端按下儲存後會呼叫 Post,但 .dfm 綁定的 BeforePost 會先出來攔人:
procedure TDocumentForm.DocumentDataBeforePost(DataSet: TDataSet);
begin
if Trim(DataSet.FieldByName('DOCUMENT_NAME').AsString) = '' then
begin
ShowMessage('請輸入單據名稱');
Abort;
end;
DataSet.FieldByName('UPDATED_AT').AsDateTime := Now;
end;
把這段補進來後,儲存流程才逐漸接完整:
SaveButtonClick → Post → BeforePost → ApplyUpdates → Provider → AP → 資料庫
如果只盯著按鈕,使用者回報「為什麼沒存進資料庫」時,我可能連資料是在 BeforePost 被 Abort,還是卡在 Provider,都分不清楚。
而且 Post 完成,也不等於資料已經寫進資料庫。對 TClientDataSet 而言,它可能只是先完成用戶端 Dataset 內的異動;直到 ApplyUpdates,資料才會送往 Provider。
這也是 AI 在這段流程裡幫我補上的另一塊拼圖:看到 ApplyUpdates 後,還要沿著 ProviderName 繼續往 AP 找,而不是看到 Post 沒報錯就宣布結案。
經過這幾次除錯,我現在找 AI 幫忙時,不會只丟一句「幫我找 Bug」。
我會改成這樣問:
請先不要修改程式碼。
根據這份 .pas、.dfm 與錯誤重現步驟,
幫我列出最值得設定 Breakpoint 的 3~5 個位置。
每個 Breakpoint 請說明:
1. 應該下在哪個事件、函式或程式行。
2. 停住時要觀察哪些 Dataset、State、IsEmpty、欄位或旗標。
3. 這個斷點要驗證哪一項假設。
4. 如果同一個斷點進入兩次,下一步要追哪個呼叫來源。
如果現有資訊不足,請列出還缺少的事件或函式。
針對刪除最後一筆的問題,AI 幫我整理出的驗證順序大致如下:
| Breakpoint 位置 | 停住時觀察 | 要驗證的假設 |
|---|---|---|
DeleteButtonClick 的 DetailData.Delete |
刪除前的 IsEmpty、目前列與總筆數 |
刪除前是否只剩最後一筆 |
DetailDataAfterDelete 第一行 |
IsEmpty、State、相關旗標 |
Delete 後是否自動進入 AfterDelete |
RecalculateTotal 的 GetBookmark 前 |
IsEmpty、目前記錄是否存在 |
Access violation 是否出現在空 Dataset 取得 Bookmark |
SyncTotalToMaster 前 |
準備同步的加總變數 | 傳回主檔的是歸零後數值,還是刪除前的舊值 |
這種問法對我比較有用。
AI 不再只站在程式碼外面評論「這裡看起來可疑」,而是幫我安排一條可以實際走過的除錯路線。
它負責整理地圖,我負責開車去撞斷點。至少撞的是 Breakpoint,不是正式環境。
把這幾次翻車經驗整理後,我現在大致會這樣追:
OnClick。.dfm,看看 Dataset 私底下還綁了哪些事件。Open、Delete、Post 與 ApplyUpdates,把可能的事件支線畫出來。DisableControls 與防重入旗標在例外後能恢復。這不是 Delphi 表單的唯一讀法,只是我在這幾次踩坑後,找到一條比較不容易迷路的路。
Delphi 系統最讓人頭痛的地方,往往不是程式寫得多複雜,而是「我以為只做了一個動作,底下卻自動串起一連串事件」。
這次如果我只盯著 QueryButtonClick,就看不到 AfterScroll;只盯著刪除按鈕,就找不到 AfterDelete → RecalculateTotal → GetBookmark;只在空資料時直接 Exit,又會留下幽靈金額。
AI 在這裡最有價值的地方,不是直接替我產生一份看起來完整的修改版,而是提醒我還有哪些事件沒看、哪些推論需要下斷點,以及修掉例外後,是否還有狀態沒有善後。
不過,AI 畫出來的仍然只是地圖。
流程實際有沒有走過、事件到底進入幾次、資料最後有沒有送到 AP,還是得靠我自己下 Breakpoint、看 Dataset 狀態,然後一段一段驗證。
下一篇,我會繼續記錄另一個常見問題:
當畫面明明有值,資料卻不一定正確時,Delphi 的資料繫結到底發生了什麼?
.pas 看直接呼叫,.dfm 補出 AfterScroll、AfterDelete 與 BeforePost 等暗線。IsEmpty then Exit 能避開 GetBookmark,但還要處理歸零與同步,否則會留下幽靈金額。DisableControls 與防重入旗標使用 try...finally,是為了避免下一次操作開始失靈。