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 → 顯示資料 按下查詢 → 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;

只看這段,乾淨俐落:

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

所以我一開始也是理直氣壯地把這段貼給 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 幫忙擬定 Breakpoint,而不是直接猜修法

經過這幾次除錯,我現在找 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,不是正式環境。


我現在如何追一個 Delphi 按鈕

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

  1. 從畫面操作出發,找到對應的 OnClick。
  2. 打開 .dfm,看看 Dataset 私底下還綁了哪些事件。
  3. 順著 Open、Delete、Post 與 ApplyUpdates,把可能的事件支線畫出來。
  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 補出 AfterScroll、AfterDelete 與 BeforePost 等暗線。
  • 修掉當機不代表修完:IsEmpty then Exit 能避開 GetBookmark,但還要處理歸零與同步,否則會留下幽靈金額。
  • 例外後也要能復原:DisableControls 與防重入旗標使用 try...finally,是為了避免下一次操作開始失靈。
  • 讓 AI 規劃驗證路線:與其直接接受 AI 的修改,不如請它列出 Breakpoint、觀察值與假設,再用執行結果決定下一步。

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

尚未有邦友留言

立即登入留言