📘 WPF 幂等性学习笔记
核心一句话:同一个操作,执行一次和执行 N 次,对系统产生的最终影响必须完全一样。
一、概念篇
1.1 定义
幂等(Idempotent)源自数学:f(f(x)) = f(x),即函数重复作用于自身结果不变。
在软件工程中:同一操作被执行一次与被执行多次,系统的最终状态完全相同。
1.2 两个视角
| 视角 | 说明 |
|---|---|
| 数学 | f(f(x)) = f(x),重复应用结果不变 |
| HTTP | GET/PUT/DELETE天然幂等;POST不幂等(重复提交会创建多份资源) |
1.3 生活类比
| 例子 | 是否幂等 | 原因 |
|---|---|---|
| 电梯"关门"按钮 | ✅ | 按 1 次和按 10 次,门只关一次 |
| 手机音量设为 50% | ✅ | 设多少次都是 50%(Set 语义) |
| 遥控器"开/关"键 | ❌ | 按两次灯又灭了(Toggle 语义) |
| 银行转账扣款 100 元 | ❌ | 重复执行会扣多次,必须靠机制保证 |
1.4 天然幂等 vs 非幂等操作
| 操作类型 | 示例 | 是否幂等 |
|---|---|---|
| 查询 Read | SELECT/ 读取属性 | ✅ |
| 删除 Delete | 删除id=5的记录(再删结果一样) | ✅ |
| 整体赋值 Set | x = 5/UPDATE name='a' | ✅ |
| 自增 Increment | x = x + 1 | ❌ |
| 追加 Append | 日志追加 /List.Add | ❌ |
| 创建 Create | INSERT新订单 | ❌(需幂等键保护) |
1.5 WPF 客户端为什么需要幂等
- 用户手抖双击按钮→ 重复提交订单
- 网络超时重试→ 重复下单
- 事件被多次订阅→ 处理器执行 N 次
- 窗口被重复打开→ 出现多个实例
- 定时器 / 异步回调重入→ 状态错乱
- 多端并发保存→ 数据互相覆盖
二、WPF 经典示例篇
示例 1:按钮防重复提交(最经典)⭐
场景:点击"提交订单",请求耗时 2 秒,用户等不及连点 5 下 → 生成 5 笔订单。
// ❌ 非幂等:双击 = 提交两次privateasyncvoidSubmit_Click(objectsender,RoutedEventArgse){await_orderService.CreateOrderAsync();}// ✅ 幂等:状态守卫 + 按钮禁用,重复点击只生效一次privatebool_isSubmitting;privateasyncvoidSubmit_Click(objectsender,RoutedEventArgse){if(_isSubmitting)return;// 幂等守卫:进行中直接忽略_isSubmitting=true;BtnSubmit.IsEnabled=false;// UI 层再兜底一层try{await_orderService.CreateOrderAsync();}finally{_isSubmitting=false;BtnSubmit.IsEnabled=true;}}流程图:
要点:finally保证异常时也能复位;逻辑守卫是主防线,禁用按钮只是 UI 兜底。
示例 2:ICommand.CanExecute —— 框架级幂等防线
场景:MVVM 模式下,WPF 命令系统在CanExecute返回false时自动禁用按钮,命令根本不会被触发。
publicclassAsyncRelayCommand:ICommand{privatebool_isExecuting;publicboolCanExecute(objectparameter)=>!_isExecuting;// 执行中 → 按钮自动禁用 → 重复触发无效publicasyncvoidExecute(objectparameter){_isExecuting=true;RaiseCanExecuteChanged();// 通知 UI 刷新按钮状态try{awaitExecuteAsync();}finally{_isExecuting=false;RaiseCanExecuteChanged();}}}要点:把幂等守卫封装进 Command 基类,所有 ViewModel 复用,一劳永逸。
示例 3:窗口单例 —— 防止重复打开窗口
场景:连点"打开设置"按钮,弹出 5 个设置窗口。
// ❌ 非幂等:点几次开几个privatevoidOpenSettings_Click(objectsender,RoutedEventArgse)=>newSettingsWindow().Show();// ✅ 幂等:无论点多少次,永远只有一个设置窗口privatevoidOpenSettings_Click(objectsender,RoutedEventArgse){varexisting=Application.Current.Windows.OfType<SettingsWindow>().FirstOrDefault();if(existing!=null){existing.Activate();// 已存在 → 激活置顶即可return;}newSettingsWindow().Show();}要点:Show()换成ShowDialog()时更要注意,模态窗口重复打开会锁死 UI 线程。
示例 4:事件订阅"先减后加" ⭐
场景:页面每次加载都+=一次,返回再进入后事件触发时处理器执行 2 次、3 次……还会导致内存泄漏。
// ❌ 非幂等:重复订阅protectedoverridevoidOnLoaded(RoutedEventArgse){_service.DataChanged+=OnDataChanged;// 每次加载都叠加一份}// ✅ 幂等:先减后加,订阅多少次都只注册一份protectedoverridevoidOnLoaded(RoutedEventArgse){_service.DataChanged-=OnDataChanged;// 先解除_service.DataChanged+=OnDataChanged;// 再订阅}要点:CommunityToolkit.Mvvm的Messenger同理 —— 先Unregister再Register。
示例 5:属性 Setter —— 值不变则跳过
场景:给Title赋相同值,PropertyChanged被反复触发,绑定 UI 无效刷新。
// ✅ 幂等:值没变就不做任何事privatestring_title;publicstringTitle{get=>_title;set{if(_title==value)return;// 幂等守卫_title=value;OnPropertyChanged(nameof(Title));}}要点:CommunityToolkit.Mvvm生成的SetProperty已内置此判断 —— 这就是幂等思想的框架化。
示例 6:初始化只执行一次
场景:多个页面都调用"确保数据库已初始化",初始化逻辑被跑多次。
// ✅ Lazy<Task<T>>:无论调用多少次,初始化只真正执行一次privatestaticreadonlyLazy<Task<DatabaseService>>_instance=new(()=>DatabaseService.InitAsync());publicstaticTask<DatabaseService>GetServiceAsync()=>_instance.Value;要点:Lazy<T>默认线程安全;缓存的是Task而不是结果,避免并发时重复初始化。
示例 7:集合刷新 —— 整体覆盖代替增量追加
场景:点"刷新"按钮重新拉取列表,若用Add追加,数据越刷越多。
// ✅ 幂等:Clear + Add = Set 语义,刷新 100 次结果一致publicasyncTaskRefreshAsync(){vardata=await_api.GetItemsAsync();Items.Clear();foreach(varitemindata)Items.Add(item);}要点:刷新类操作一律用"覆盖"语义;数据量大时可整体替换集合引用以减少 UI 闪烁。
示例 8:定时器 / 异步回调重入保护
场景:DispatcherTimer每 1 秒触发一次,但工作耗时 3 秒 → 多个工作同时跑,状态错乱。
privateint_isRunning;privateasyncvoidOnTimerTick(objectsender,EventArgse){// 原子操作:0 换 1 成功才继续,防止重入if(Interlocked.CompareExchange(ref_isRunning,1,0)!=0)return;try{awaitDoWorkAsync();}finally{Interlocked.Exchange(ref_isRunning,0);}}要点:bool判断在多线程下不可靠,必须用Interlocked原子操作。
示例 9:本地数据库 Upsert —— 唯一键覆盖写
场景:WPF 客户端同步服务端数据到本地 SQLite,网络重试导致重复插入。
-- ✅ 幂等:无论同步多少次,本地只有一条 id=1001 的记录INSERTORREPLACEINTOOrders(Id,Name,Amount)VALUES(1001,'订单A',99);要点:靠唯一业务键 + 覆盖写实现幂等,而不是靠"祈祷不重复"。
示例 10:幂等键 + 乐观锁(与服务端配合)⭐
场景 A:创建订单超时,不知道成没成功,重试怕重复下单。
// ✅ 幂等键:草稿单号在提交前生成,重试时复用同一个 keypublicasyncTaskSubmitWithRetryAsync(OrderDraftdraft){varidempotencyKey=draft.DraftId.ToString();// 关键:重试不换 keyfor(intattempt=0;attempt<3;attempt++){try{await_api.CreateOrderAsync(draft,idempotencyKey);return;// 服务端按 key 去重}catch(TimeoutException){/* 用同一个 key 继续重试 */}}}场景 B:多人同时编辑同一条数据,后保存的覆盖先保存的。
// ✅ 乐观锁:保存时带上读取时的版本号await_api.SaveAsync(order.Id,order,version:order.Version);// 服务端:UPDATE ... WHERE Id=@id AND Version=@version// 影响行数为 0 → 数据已被别人改过 → 提示冲突重新加载三、幂等设计套路速查表
| 套路 | 做法 | WPF 对应场景 |
|---|---|---|
| 状态守卫 | if (isRunning) return; | 防重复点击、防重入 |
| 框架防线 | CanExecute返回 false | ICommand 按钮禁用 |
| 单例 / Lazy | 实例只创建一次 | 窗口单例、服务初始化 |
| 先减后加 | -=再+= | 事件 / Messenger 订阅 |
| 值不变则跳过 | if (field == value) return; | 属性 Setter 通知 |
| 整体覆盖 Set | Clear + Add/ 替换引用 | 集合刷新 |
| 原子交换 | Interlocked.CompareExchange | 定时器 / 多线程重入 |
| 唯一键 Upsert | INSERT OR REPLACE | 本地 SQLite 缓存同步 |
| 幂等键 | 业务唯一 ID 随请求发送 | 调接口防重复下单 |
| 乐观锁 | version 校验 | 并发保存冲突检测 |
四、常见误区
- 幂等 ≠ 只执行一次:幂等是"执行多次效果同一次",可以"重复执行 + 去重",也可以"直接拒绝重复执行",两条路都算幂等。
- 禁用按钮不是真正的幂等:绕过 UI(快捷键、代码直接调命令)仍可能重复,逻辑层守卫才是主防线。
+=订阅忘记-=:是 WPF 内存泄漏 + 处理器重复执行的双重根源。async void事件处理器无法被等待:重入风险最高,务必加守卫标志。bool标志在多线程下不可靠:跨线程场景用Interlocked或SemaphoreSlim(1,1)。