iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
ChatGPT & Codex

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

Day 17|為了讓畫面不要卡,多加這一行 Code,可能讓儲存流程更危險

  • 分享至 

  • xImage
  •  

前言/情境導入

想像這個場景:使用者按下批次儲存,等了一會兒,畫面仍然沒有反應。

「到底有沒有存進去?我可以再按一次嗎?」

這是情境示意,卻點出一個很實際的困境:使用者看不見進度,就難以判斷自己應該等待,還是重試。

上一篇,我處理的是一個儲存效能問題:80 多筆資料,竟然可能要等將近五分鐘。

我把流程拆開,觀察 SQL、Dataset、事件與 UI 的成本,再逐步減少重複工作。

但等待時間之外,還有另一個很容易讓人急著動手的問題:

儲存還在執行,畫面卻像當掉一樣。

按鈕沒有反應,視窗沒有正常重畫,連進度文字都停在同一個數字。

這時候,很容易想到一個 Delphi 老朋友:

Application.ProcessMessages;

只要在迴圈裡加上這一行,畫面就有機會重新回應訊息。

看起來很划算。

不用重構、不用改 SQL,也不用立刻引入 Thread。

可是,在資料儲存流程裡,我需要多問一句:

畫面恢復反應之後,使用者的其他操作,會不會也跟著進來?

這篇沿用前一篇的批次儲存情境,以匿名化的示意程式拆解風險;以下的重複儲存、重新查詢與關閉情境,是分析與測試案例,不代表已確認發生在前一篇的實際系統。

核心技術解析

1. 畫面不卡,不代表工作變快

一般 VCL 桌面程式的畫面操作與事件處理,大多發生在主執行緒。

如果儲存事件一直忙著查資料、計算與更新,主執行緒就無法照平常的節奏處理視窗訊息。

這時呼叫 Application.ProcessMessages,會處理訊息佇列中的訊息,直到佇列清空,再返回原本流程。官方文件也說明,長時間作業期間呼叫它,可以讓程式回應重畫及其他訊息。

關鍵是最後那幾個字:其他訊息。

它不是只幫進度文字重畫,也沒有把儲存工作自動搬到背景執行緒。

SQL 還是那些 SQL,計算還是那些計算;新增的訊息處理甚至可能增加總耗時。

所以這裡需要分開看兩件事:

問題 要觀察的結果
效能 同一批工作實際花多少時間?
回應性 工作執行期間,畫面能否回應?

前一篇處理的是工作成本。這一行主要改變的,則是工作期間的訊息處理方式。

2. 第一輪還沒結束,第二輪就進來了

先看一段容易出現的示意寫法:

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

這不是「明細一定會少一半」的保證,而是指出:兩次操作的處理範圍已經交錯。 若單筆處理只是覆寫相同值,畫面可能暫時看不出差異;若包含累加或新增紀錄,重複處理就可能留下不同結果,仍需追到實際寫入才能確認。

實際結果取決於儲存邏輯,可能是漏處理、重複處理、狀態錯誤,也可能當次剛好沒有明顯異常。不能只憑這一行,就斷言一定會重複寫入。

真正需要驗證的是:原本流程依賴的狀態,有沒有在中途被其他事件改變。

3. 只把儲存按鈕停用,還不夠

即使儲存按鈕不能再按,也可能有其他入口動到同一批資料。

中途進入的事件 可能破壞的前提
重新查詢 Dataset 被 Close/Open,資料集合改變
切換案件 目前案件與儲存開始時不同
刪除明細 迴圈正在處理的資料消失
Timer 自動刷新 Dataset 或畫面狀態被更新
關閉表單 元件生命週期改變,後續程式仍可能存取它們

關閉表單不一定立刻造成 Access Violation;是否隱藏、釋放或延後釋放,仍要看表單設定與關閉處理。

但只要儲存尚未完成,元件的生命週期就不能留給運氣決定。

因此我檢查的範圍,不能只停在 btnSave.Enabled := False。

我需要找出:所有會改變這次作業前提的入口。

4. DisableControls 與 Transaction 各有自己的邊界

前一篇使用了:

dtDetail.DisableControls;

它用來暫停 Dataset 對資料感知控制項的更新,不是整張表單的操作鎖,也不是防重入機制。

同樣地,資料庫 Transaction 可以定義資料異動的提交與回滾邊界,卻不會自動阻止查詢按鈕、Timer 或關閉事件進來。

如果第一次作業已開啟交易,第二個事件又共用同一個 Connection,還可能干擾交易狀態。實際行為需要依資料存取元件與既有交易管理方式確認。

這些保護處理的是不同問題:

機制 主要責任
DisableControls 減少資料感知控制項更新
作業中的狀態旗標 阻止衝突入口進入
Transaction 管理資料庫異動的提交與回滾
關閉保護 維持作業所需元件的生命週期

不能因為其中一項已經存在,就認為其他邊界也一起受到保護。

程式碼實作

1. 先建立作業狀態,再開始處理

如果現在只需要讓使用者看見處理進度,我會先評估移除不必要的 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 呼叫期間,這段程式仍然可能無法更新進度或回應操作。

此外,重畫可能執行繪圖相關程式;若自訂繪圖事件會查詢或修改資料,也需要檢查其副作用。

這是一個收斂風險的局部修改,不是完整的背景作業架構。

2. 其他入口也要遵守同一個狀態

停用按鈕可以提供視覺提示,但真正的限制仍應放在事件或共用作業入口。

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 的保護範圍,也只限於遵守這個旗標的同一張表單。它不會阻止另一台電腦或另一個程式更新相同資料。

3. 如果暫時必須保留 ProcessMessages

有些既有流程暫時無法移除它,例如仍依賴訊息處理接收取消操作。

這時我會先確認:

  • 作業狀態在開始前就已設定,所有衝突入口都有檢查。
  • 不相關的 Timer 與自動刷新不會中途修改資料。
  • 表單在作業結束前不會被釋放。
  • 取消只提出取消請求,由儲存流程在安全位置判斷。
  • 取消後的部分成功、全部回滾或重試行為,有明確定義。

不能把取消按鈕直接寫成 dtDetail.Close,讓正在使用 Dataset 的迴圈下一行才發現資料已經消失。

若某一筆 SQL 本身就阻塞數十秒,只在兩筆之間呼叫 ProcessMessages,也無法讓那段等待立刻可取消。

4. 為什麼不直接叫 AI 改成 Thread?

長時間作業可以評估搬離主執行緒,但在 Legacy System 裡,這通常需要先拆開責任。

背景執行緒不能直接操作 VCL 控制項;資料存取元件與 Connection 也不能在未確認支援方式時,直接與 UI 共用。

至少需要設計工作輸入、資料存取的執行緒歸屬、UI 更新方式,以及取消與表單關閉的生命週期。

在沒有好好分層的祖傳系統裡,一個儲存函式可能同時讀取畫面欄位、移動綁定控制項的 Dataset,再透過事件重新查詢其他資料。搬動其中一段,往往就牽動整條事件鏈。

因此,當 AI 建議「放到背景執行緒就好」時,我會先要求它盤點這些依賴:哪些輸入可以先取得快照?哪些資料存取元件需要由背景作業獨立管理?哪些結果必須回到主執行緒呈現?

背景作業是值得規劃的方向,但局部修補也需要有清楚的保護範圍。 先減少不必要的訊息處理、守住衝突入口與元件生命週期,再依可驗證的範圍逐步拆開 UI 與儲存邏輯,是一種工程取捨。

今天先處理的是事件邊界。若只是把原本的整個儲存事件包進 Thread,裡面的 Dataset、控制項與事件耦合並不會自行消失。

5. 驗證時,要故意在「不方便的時間」操作

正常按一次儲存,再等到完成,只能驗證最順利的路徑。

驗證時要區分兩種版本。保留 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(第一輪)

實際堆疊名稱會依版本與除錯資訊不同,但判斷重點相同:第一輪還在堆疊裡,第二輪已經進入。

可以直接使用的 Prompt 範本

你現在的角色是 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 提問的方式。

除了「怎麼讓畫面不要卡」,我還會要求它說明:這項修改會讓哪些事件有機會進來,又會破壞哪些原本成立的前提。

修改的行數很少,不代表改變的系統行為也很少。

讓使用者看見進度,是讓等待變得可以理解;守住儲存流程的狀態,則是讓這次操作可以信任。

畫面恢復反應時,儲存流程也必須守得住狀態。讓使用者安心等待,不能以資料正確性作為代價。


上一篇
Day 16|80 筆資料存五分鐘:慢的到底是 SQL、Dataset,還是 UI?
系列文
AI 救得了祖傳系統嗎?30 天實戰企業 Legacy System × AI 協作開發 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言