iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
ChatGPT & Codex

AI 救得了祖傳系統嗎?30 天實戰企業 Legacy System × AI 協作開發系列 第 4

Day 4|從按鈕開始追:如何還原 Delphi 表單的事件流程

  • 分享至 

  • xImage
  •  

前言/情境導入

前一篇提到,面對一支超過一萬行的 Delphi 程式,我不會再把整個專案一次交給 AI,而是先提供需求、錯誤現象、.pas.dfm 與必要的欄位說明。

可是,找到事件入口之後,事情並沒有立刻變簡單。

最近我處理一個表單問題:使用者完成查詢後,程式有時會重複刷新目前資料,畫面反應變慢,甚至在某些操作下出現資料衝突。

我一開始把注意力放在查詢按鈕。

畢竟,問題是在「按下查詢」之後發生的,最合理的嫌疑人看起來就是 QueryButtonClick

但當我把按鈕事件丟給 AI 檢查時,它丟回一個我當時沒想到的方向:

按鈕本身可能只負責開啟 Dataset。真正重複執行的流程,可能是 Dataset 開啟後自動觸發的 AfterScroll

這句話讓我盯著螢幕愣了一下,然後重新看了一次程式。

結果問題真的不只藏在按鈕裡。

使用者按下一次查詢,程式先開啟資料集;資料集定位到第一筆後,觸發 AfterScroll;而 AfterScroll 裡又呼叫 RefreshRecord

RefreshRecord 不是粗暴地重新 Open 整個 Dataset,也不代表游標一定會跳回第一筆。它主要是向伺服器重新取得目前這筆記錄的最新狀態,再更新目前列的欄位內容。

但在我這次使用的元件與實際執行紀錄中,這個動作伴隨了游標相關通知,讓 AfterScroll 又默默回頭執行了一次。

從使用者角度看,他只按了一次按鈕。

從程式角度看,事件早就偷偷繞了一圈。

這大概是讀 Delphi Legacy System 最讓人崩潰的地方:畫面上的操作是直線,底下的事件卻常常像張蜘蛛網。

把這幾天踩坑下來的對比整理一下,其實就是這些隱藏支線在搞事:

直覺以為的線性流程 實際暗藏的事件鏈
按下查詢 → Open → 顯示資料 按下查詢 → OpenAfterScrollRefreshRecord → 可能再次進入 AfterScroll
按下刪除 → DeleteApplyUpdates 按下刪除 → DeleteAfterDelete → 加總計算 → ApplyUpdates
按下儲存 → Post → 寫入資料庫 按下儲存 → PostBeforePostApplyUpdates → Provider/AP/資料庫

看懂這張表之後,我學乖了:看到按鈕事件先別太早高興,那通常只是入口。

今天就來記錄一下,我怎麼拿著這些線索,和 AI 一步一步把事件流程掀開。


核心技術解析

一、我先看到的,只有按鈕事件

為了避免公開實際系統內容,下面的表單、Dataset、函式與欄位名稱都已改成用途相近的虛構名稱;事件之間的關係則保留原本的問題結構。

使用者按下查詢後,前端大致執行這段程式:

procedure TDocumentForm.QueryButtonClick(Sender: TObject);
begin
  DocumentData.Close;
  DocumentData.Params.ParamByName('DOCUMENT_ID').AsString :=
    DocumentIdEdit.Text;
  DocumentData.Open;
end;

只看這段,乾淨俐落:

  1. 關閉原本的 Dataset。
  2. 帶入查詢條件。
  3. 重新開啟。

所以我一開始也是理直氣壯地把這段貼給 AI,要它看看 CloseOpen 有沒有被重複觸發。

不出所料,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 → 資料庫

如果只盯著按鈕,使用者回報「為什麼沒存進資料庫」時,我可能連資料是在 BeforePostAbort,還是卡在 Provider,都分不清楚。

而且 Post 完成,也不等於資料已經寫進資料庫。對 TClientDataSet 而言,它可能只是先完成用戶端 Dataset 內的異動;直到 ApplyUpdates,資料才會送往 Provider。

這也是 AI 在這段流程裡幫我補上的另一塊拼圖:看到 ApplyUpdates 後,還要沿著 ProviderName 繼續往 AP 找,而不是看到 Post 沒報錯就宣布結案。

讓 AI 幫忙擬定 Breakpoint,而不是直接猜修法

經過這幾次除錯,我現在找 AI 幫忙時,不會只丟一句「幫我找 Bug」。

我會改成這樣問:

請先不要修改程式碼。

根據這份 .pas、.dfm 與錯誤重現步驟,
幫我列出最值得設定 Breakpoint 的 3~5 個位置。

每個 Breakpoint 請說明:
1. 應該下在哪個事件、函式或程式行。
2. 停住時要觀察哪些 Dataset、State、IsEmpty、欄位或旗標。
3. 這個斷點要驗證哪一項假設。
4. 如果同一個斷點進入兩次,下一步要追哪個呼叫來源。

如果現有資訊不足,請列出還缺少的事件或函式。

針對刪除最後一筆的問題,AI 幫我整理出的驗證順序大致如下:

Breakpoint 位置 停住時觀察 要驗證的假設
DeleteButtonClickDetailData.Delete 刪除前的 IsEmpty、目前列與總筆數 刪除前是否只剩最後一筆
DetailDataAfterDelete 第一行 IsEmptyState、相關旗標 Delete 後是否自動進入 AfterDelete
RecalculateTotalGetBookmark IsEmpty、目前記錄是否存在 Access violation 是否出現在空 Dataset 取得 Bookmark
SyncTotalToMaster 準備同步的加總變數 傳回主檔的是歸零後數值,還是刪除前的舊值

這種問法對我比較有用。

AI 不再只站在程式碼外面評論「這裡看起來可疑」,而是幫我安排一條可以實際走過的除錯路線。

它負責整理地圖,我負責開車去撞斷點。至少撞的是 Breakpoint,不是正式環境。


我現在如何追一個 Delphi 按鈕

把這幾次翻車經驗整理後,我現在大致會這樣追:

  1. 從畫面操作出發,找到對應的 OnClick
  2. 打開 .dfm,看看 Dataset 私底下還綁了哪些事件。
  3. 順著 OpenDeletePostApplyUpdates,把可能的事件支線畫出來。
  4. 請 AI 區分哪些是程式碼事實、哪些是元件行為推測、哪些仍缺資料。
  5. 檢查判空或 State 防護是否順便跳過了必要的業務狀態處理。
  6. 確認 DisableControls 與防重入旗標在例外後能恢復。
  7. 請 AI 幫忙規劃 Breakpoint,再實際跑一次驗證。

這不是 Delphi 表單的唯一讀法,只是我在這幾次踩坑後,找到一條比較不容易迷路的路。


今日小結

Delphi 系統最讓人頭痛的地方,往往不是程式寫得多複雜,而是「我以為只做了一個動作,底下卻自動串起一連串事件」。

這次如果我只盯著 QueryButtonClick,就看不到 AfterScroll;只盯著刪除按鈕,就找不到 AfterDelete → RecalculateTotal → GetBookmark;只在空資料時直接 Exit,又會留下幽靈金額。

AI 在這裡最有價值的地方,不是直接替我產生一份看起來完整的修改版,而是提醒我還有哪些事件沒看、哪些推論需要下斷點,以及修掉例外後,是否還有狀態沒有善後。

不過,AI 畫出來的仍然只是地圖。

流程實際有沒有走過、事件到底進入幾次、資料最後有沒有送到 AP,還是得靠我自己下 Breakpoint、看 Dataset 狀態,然後一段一段驗證。

下一篇,我會繼續記錄另一個常見問題:

當畫面明明有值,資料卻不一定正確時,Delphi 的資料繫結到底發生了什麼?


今日重點

  • 按鈕只是入口.pas 看直接呼叫,.dfm 補出 AfterScrollAfterDeleteBeforePost 等暗線。
  • 修掉當機不代表修完IsEmpty then Exit 能避開 GetBookmark,但還要處理歸零與同步,否則會留下幽靈金額。
  • 例外後也要能復原DisableControls 與防重入旗標使用 try...finally,是為了避免下一次操作開始失靈。
  • 讓 AI 規劃驗證路線:與其直接接受 AI 的修改,不如請它列出 Breakpoint、觀察值與假設,再用執行結果決定下一步。

上一篇
Day 3|面對一萬行 Delphi 程式,我該先把什麼交給 AI?
下一篇
Day 5|畫面有值不代表資料正確:讓 AI 幫我們追出祖傳系統的資料流
系列文
AI 救得了祖傳系統嗎?30 天實戰企業 Legacy System × AI 協作開發5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言