WPF-UI 坑点记录
WPF-UI 坑点记录
线程和异步相关规则另见 threading-dispatcher-and-async.md。资源、字体和本地化规则另见 resources-localization-and-assets.md。
导航和 DI
后台页面、内置前台窗口、ViewModel 和多数服务都通过 DI 注册。不要绕开:
services.AddBackendPage<MyPage, MyViewModel>();
services.AddFrontedWindow<MyWindow, MyWindowViewModel>();手动 new Page() 或 new Window() 会丢失 DataContext、服务注入、注册表信息和 WPF-UI page provider 集成。插件前台窗口应通过 services.AddFrontedWindow<TWindow,TViewModel>()(XAML 窗口,配合 [FrontedWindowInfo("GUID", "DisplayName", IsBuiltIn = false)])或 services.AddFrontedV3LayoutWindow("WindowId", isBuiltIn: false)(v3 布局窗口,PackageId 由宿主自动注入)注册;窗口元数据由 FrontedWindowRegistration(FrontedXamlWindowRegistration / FrontedV3LayoutWindowRegistration,FrontedWindowRegistrationKind 为 Xaml / V3Layout)承载,统一进入 IFrontedWindowRegistry。
Page 宿主限制
WPF 的 Page 只能由 Window、Frame 或导航宿主承载,不能直接放进 ContentControl、Border、Grid、TabItem、插件内容 host 或模块内容 host。否则运行时会抛出 InvalidOperationException: Page can have only Window or Frame as parent.
需要嵌入到页面内部、插件 host、模块 host、overlay 或 ContentControl 的内容必须实现为 UserControl 或普通 Control。只有真正参与 WPF-UI 导航或 Frame 导航的后台页面才使用 Page,并通过 AddBackendPage<TView,TViewModel>() 注册。
生命周期
当前注册模式中:
- 后台页面是 singleton。
- 后台页面 ViewModel 是 singleton。
- 前台窗口是 singleton。
- 前台窗口 ViewModel 是 singleton。
- 大部分业务服务是 singleton。
这适合导播工具的长期状态,但也意味着页面构造函数、事件订阅和计时器要谨慎。不要在页面构造里做不可重复释放的重操作;如果订阅全局事件,要考虑是否会泄漏或重复处理。
因为页面/ViewModel 是 singleton,构造函数里的事件订阅通常只发生一次;但如果在命令、页面 Loaded、弹窗或临时对象中订阅事件,就要有解绑策略。否则长时间直播中会出现重复响应或对象无法释放。
本地化
项目使用 WPFLocalizeExtension:
Text="{lex:Loc SomeKey}"启动时设置 LocalizeDictionary.Instance.Culture,并将 Application.Current.Resources["CurrentLanguage"] 设为当前 XmlLanguage。
坑点:
- 新增用户可见文本应优先添加到
Locales/Lang.resx、Lang.en-us.resx、Lang.ja-jp.resx。 - 后台代码提示文本应通过
I18nHelper.GetLocalizedString(...)。 - 硬编码中文只适合内部日志、临时调试或明确不本地化的标识。
- 语言切换相关逻辑会监听
Settings.Language和CultureInfo,不要直接改CultureInfo私有 setter。 I18nHelper找不到 key 时会返回 key 本身;如果界面显示类似SomeMissingKey,优先检查 resx 是否缺项或默认字典配置是否正确。
WPF-UI 图标
后台代码中创建 WPF-UI 图标的安全模式可参考主窗口:
new SymbolIcon(SymbolRegular.Info24, 24D)或:
new SymbolIcon { Symbol = SymbolRegular.ArrowExit20 }BackendPageInfo 的图标字段直接使用 SymbolRegular。创建或更新 SymbolIcon 属于 WPF UI 对象操作,应在 UI 线程执行。后台下载、OCR、插件市场回调更新 UI 时,参考 PluginMarketService.RunOnUiThread 或 Application.Current.Dispatcher.Invoke。
不要在后台线程中创建 NavigationViewItem、SymbolIcon、Page 或 Window 并交给 UI 绑定集合。
InfoBar 和 Snackbar
主窗口构造时把控件交给服务:
infoBarService.SetInfoBarControl(InfoBar);
snackbarService.SetSnackbarPresenter(SnbPre);页面或服务应通过 IInfoBarService / ISnackbarService 访问,不要跨层直接找主窗口控件。错误、警告和下载失败等适合 InfoBar;更新后提示使用 Snackbar。
ObservableCollection
绑定到 UI 的 ObservableCollection 必须在 UI 线程更新。插件市场下载队列已经用 Dispatcher 包装;新下载器回调、OCR 回调或捕获回调如果要改集合,应复用同类模式。直接从后台线程 Add/Remove 会导致 WPF 线程异常或间歇性 UI 崩溃。
资源字典和主题
启动时默认深色主题,主题变化会更新 IconThemesDictionary.Theme。如果新增图标资源或主题资源,需要确认它在浅色/深色下都可用。
MainWindow.xaml 大量依赖动态资源和 WPF-UI 样式。修改全局样式前先确认前台窗口是否也引用了同名资源,避免导播输出窗口被后台样式意外影响。
前台窗口透明和背景
部分前台窗口支持透明背景。透明时返回 Transparent,非透明时默认绿色 #00FF00。OBS 场景可能依赖绿幕色或透明窗口,改默认颜色和 AllowsWindowTransparency 行为时需要兼顾直播工作流。
FrontedWindowBase 会把内容自动包进 Viewbox,并设置 Stretch.Fill。前台窗口内部布局应以固定画布和绑定宽高为基础,不要假设窗口内容原样作为根元素存在。
Designer v3 的 Image 控件有 Auto、FillContainer、OverflowCrop 三种 SizingMode。旧 XAML 同时存在 direct fixed-size ui:Image、Border + Image + ClipToBounds、默认 Border 内图片和自定义 MapV2Presenter,迁移时必须逐个按旧结构选择模式。队伍 LOGO 和 MapBp v1 地图通常用 FillContainer;角色裁剪图通常用 OverflowCrop;GameData 求生者表头头像这类旧默认 Image 应保留 Auto。BpWindow 的求生者 pick 使用 OverflowCrop + UniformToFill,监管者 pick 保留旧 XAML 中本地 Stretch="Uniform" 的效果。CornerRadius 只负责圆角裁剪,不应顺手把所有图片改成填满容器。
BpWindow 已由 v3 renderer 生成控件。默认动画通过行为文档中的稳定 BehaviorGuid 查找 SurPick0..3、HunPick,并通过 part:{BehaviorGuid}:PickingBorder 查找内部呼吸边框。修改内置布局时必须同步维护 Resources/FrontedBehaviors/BpWindow.behaviors.json;旧 PickingBorderOverlay 控件已移除。
Fronted Designer 编辑器
独立编辑器的详细规格见 fronted-designer-editor.md。实现时特别注意这些 WPF 坑点:
- 编辑器窗口应使用 WPF-UI
FluentWindow和项目既有CustomTitleBar,标题栏必须单独占一行。不要把 toolbar、preview 或验证面板放到标题栏同一行;否则关闭按钮会被内容覆盖。编辑器默认隐藏CustomTitleBar的主题切换按钮,保留最小化、最大化和关闭。 - 不要把真实前台窗口当作设计 surface。原生标题栏、窗口 chrome 和
FrontedWindowBase的Viewbox包裹会让坐标混入内容区以外的高度,造成纵向偏移。编辑器应使用纯Canvas,尺寸精确等于FrontedCanvasConfig.CanvasWidth/CanvasHeight。 - zoom/pan 修正后,编辑器预览不再用
ViewBox控制 Fit 或手动缩放。结构应为ScrollViewer -> PreviewWorkspace -> PreviewZoomHost -> DesignSurfaceGrid,PreviewZoomHost.LayoutTransform绑定唯一缩放来源ZoomScale,这样放大后ScrollViewer才能获得真实可滚动 extent。 PreviewCanvas负责真实渲染,InteractionLayer负责 hitbox、选择框、拖拽、缩放和键盘微调。两层尺寸都必须等于FrontedCanvasConfig.CanvasWidth/CanvasHeight,鼠标位置使用e.GetPosition(InteractionLayer)得到逻辑 Canvas 坐标,不要乘除 zoom,也不要把窗口标题栏或真实前台窗口尺寸纳入坐标计算。- 透明或空内容控件不可靠。空
Text、Source = null的Image、透明Border和没有当前业务数据的控件都可能难以命中。编辑器应在独立InteractionLayer为每个设计项创建透明 hitbox;hitbox 是 editor-only,不写入 layout JSON。 - v3 JSON 的 root-level 控件 key 就是控件名。该名称同时参与
FrameworkElement.Name和 WPF namescope 注册。不要在 config 类里再加Name字段,也不要让编辑器只改生成控件的Name而忘记 dictionary key。 - 空图片和空文本在 preview 中应通过编辑器 overlay 或设计时 placeholder 辅助识别。placeholder 只属于编辑器预览,不应写入 layout JSON 或运行时设置。
- 的方向键移动步长默认是
0.5。键盘事件应避开TextBox、ComboBox、DataGrid等编辑控件,避免后续 Property Grid 实现后抢输入焦点。
9.,编辑器主区域是左侧控件列表、中间设计 surface、右侧选中/校验面板。左侧列表用于选中被遮挡或低 ZIndex 控件;筛选文本在切换窗口、切换 Canvas 或成功重载布局时清空。 - 鼠标选择语义是“单击选择,拖拽不切换选择”。
MouseLeftButtonDown只记录候选控件和起点;超过 3-5 logical px 阈值后,只有候选项本来就是当前选中项才开始拖拽。拖到未选中控件上不应改变焦点。 - 被选中控件的 hitbox、outline 和 handles 会使用 editor-only 高 ZIndex 放在其他 hitbox 上方,以便拖动重叠下层控件。该值不能写入 v3 JSON,也不能改变 preview/runtime
ZIndex。 - 拖拽和缩放过程中要同步更新生成 preview root element 的
Canvas.Left/Canvas.Top/Width/Height,不要等 mouse-up 重渲染后才看到真实预览移动。mouse-up 可再重渲染一次保证一致。 - 选择边界优先使用显式
Width/Height;缺失时使用渲染 root element 的ActualWidth/ActualHeight;再不可用才回退到40x24。这对无Height的文本控件尤其重要。 Image/BorderedImage的 picking border 和 lock 是内部视觉层:编辑器中不生成普通 hitbox、不进入普通控件列表、不允许直接拖拽或缩放。移动/缩放图片控件时 overlay 自动跟随;picking border 的运行时名称由控件名自动生成。CurrentBanDisplay、BanSlotDisplay和PickingBorderOverlay已移除。新 Ban 位和 pick 图不要再新增专用业务控件,优先使用通用Imagebinding + overlay。- 视口导航优先于选择:Fit 模式根据
ScrollViewerviewport 和 Canvas 尺寸计算ZoomScale;Ctrl + mouse wheel进入手动缩放并保持 25% 到 200%;右键拖拽或Space + left mouse drag只平移ScrollVieweroffset。这些操作不能写回 layout 坐标,也不能改变当前选中控件。 - 的 Property Grid 基于
ItemsControl,编辑的是FrontedControlDesignItem和其Config。Name仍是设计项/JSON key,不能加到 config 类;运行时关键Name只读,被其他控件引用的普通控件在 8E 也阻止改名。
18.,Property Grid 行编辑器通过模板按需实例化,不要恢复成“TextBox、CheckBox、ComboBox 全部创建再用 Visibility 隐藏”的结构;否则切换选中控件时会出现未套样式的原生控件闪烁。 - 属性编辑事件必须避开绑定初始化:ComboBox 用
DropDownClosed提交,TextBox 用LostFocus或 Enter 提交,CheckBox 用 Click 提交,ColorPicker 只在用户更改颜色后提交。属性网格重建和 layout pass 期间应抑制提交,避免 BpWindow / CutSceneWindow 选中控件时递归触发ApplyPropertyEdit。 - 的
FontFamily行使用可编辑 ComboBox,初始化、选中项同步和手写提交都必须尊重同一套提交抑制逻辑。内置字体选项保存 pack URI 原值,预览字体时沿用运行时的Uri + "./#FontName"构造方式,不要把显示名写回布局。 - 中
BindingPath和图片/资源路径仍是显式提交文本框,但旁边会显示 Binding Browser 或 Resource Browser 的...按钮。浏览器选择只能写入FrontedPropertyEditorItem.EditText,不能直接调用ApplyPropertyEdit,也不能写入 config、推 Undo snapshot 或刷新真实前台窗口;用户按 Apply 或 Enter 后才提交。颜色字符串使用项目已有PortableColorPicker,仍按#AARRGGBB存储,并保留文本 fallback;ColorPicker 选色只同步 Hex 编辑缓冲,必须由 Apply 或 Enter 显式提交,避免初始化/选色时绕过显式提交模型。 - 验证详情表不在右侧属性面板常驻显示;右侧应主要保留选中控件摘要和 Property Grid。底部左侧验证摘要可点击打开非模态验证详情窗口。
- 拖拽和缩放 live edit 中不要运行完整校验、不要重建 Property Grid、不要强制完整重渲染。只更新几何、linked overlay、preview element、hitbox/adorner、选中几何摘要和 dirty 状态;mouse-up/commit 后再统一校验和刷新。
- Property Grid 输入控件获得键盘焦点时,方向键不应触发设计 surface 微调。新增编辑器控件后要继续更新
ShouldIgnoreKeyboardInput()的排除列表。 - 的 Add Control 只添加内存设计项并重渲染编辑器 preview,不保存用户布局。新控件应放在当前滚动视口中心附近,并且不应暴露已移除的
CurrentBanDisplay、BanSlotDisplay和PickingBorderOverlay。
26.,Delete Control 只在设计 surface 焦点下响应 Delete 键;焦点位于TextBox、ComboBox、DataGrid、ColorPicker 或属性编辑器内部时必须忽略,避免编辑文本时误删控件。左侧控件列表右键菜单和 Property Grid 底部删除按钮都应调用同一个删除命令,继续复用运行时关键控件和 incoming reference 的删除保护。 Name、BindingPath和普通文本/资源路径属性使用显式提交:文本框绑定EditText,按 Enter 或 Check/Apply 按钮提交。Enter 处理必须直接读取TextBox.Text,不能依赖UpdateSourceTrigger=LostFocus后的Value,否则会提交旧值或空值。- 属性编辑失败时不要重建到丢失输入。应保留
EditText、设置HasEditError/EditError、显示红色边框和行内错误消息;失败提交不应触发 preview render。 FontFamily的可编辑 ComboBox 不应在下拉打开时由 LostFocus 触发提交。下拉选择保存FrontedFontFamilyOption.Value,手写字体按 Enter 或真正失焦保存ComboBox.Text,内置字体 pack URI 不能被显示名替换。- 右侧 Property Grid 面板通过中间
GridSplitter调整宽度。拖动 splitter 只改变编辑器窗口布局,不写回 v3 layout JSON,也不需要在 持久化。 - 设计器 preview 使用独立
DesignerPreviewSharedDataService,只通过FrontedRenderContext.SharedDataServiceOverride传给 renderer。不要为了预览调用真实共享数据服务的NewGame()或修改真实CurrentGame,否则会污染导播运行时状态。 - Undo/Redo 快捷键只在设计 surface、列表或编辑器背景获得焦点时执行布局撤销/重做;焦点在
TextBox、ComboBox、ColorPicker 等属性编辑器内时必须让控件自身处理文本撤销。切换窗口/Canvas 或 reload 必须清空 undo/redo 栈。 - Binding Browser 使用显式 root + attribute 反射 catalog,不应全局扫描任意服务或调用 getter,也不应恢复逐属性手写整树。绑定树节点必须保留真实
ValueType,树过滤和搜索都要使用同一FrontedBindingTypeFilter:文本控件只允许字符串/数字,图片控件只允许ImageSource兼容值,bool overlay 绑定只允许 bool,GameProgressText只允许GameProgress,MapNameText只允许Map/Map?。浏览器选择仍只能写入属性行EditText,不能绕过 Apply/Enter 直接提交 config。Resource Browser 读取Resources/bpui时缩略图必须用BitmapImage.CacheOption=OnLoad等方式避免锁文件;外部绝对路径只引用原文件,不复制到用户布局目录或.bpui包。 - 的吸附开关分为持久
SnapEnabled和临时IsShiftSnapActive。ToggleSwitch 只能绑定SnapEnabled;Shift KeyDown/KeyUp 只更新临时状态和状态文字,不能反向修改 ToggleSwitch,否则拖拽时会造成开关频繁刷新和输入延迟。 - 的保存只写用户 AppData 布局,不能覆盖
Resources/FrontedLayouts。切换窗口/Canvas、reload、reset 和关闭窗口前都要处理 dirty prompt;Save 因校验 Error 失败时必须取消原动作。 - WPF 不允许在窗口已经继续 closing 的过程中调用
Show、ShowDialog、Close或EnsureHandle。编辑器关闭时如果要询问未保存修改,必须先取消Closing,再用 Dispatcher 异步显示提示;确认 Save/Discard 后用强制关闭标记再次关闭。非模态验证详情窗口随父窗口关闭时也要 try/catchInvalidOperationException。 - 编辑器顶部工具栏不能使用单行固定列布局承载全部命令。窗口/Canvas 选择器、缩放、吸附状态和 layout path 应放在可换行容器中;长路径必须限制宽度并省略显示,完整路径放到 tooltip。
插件 UI
插件注册的页面/窗口也进入同一 DI 和 WPF UI 环境。插件作者应:
- 后台页面使用
BackendPageInfo,前台 XAML 窗口使用AddFrontedWindow<TWindow,TViewModel>()配合[FrontedWindowInfo(IsBuiltIn = false)],v3 布局窗口使用AddFrontedV3LayoutWindow("WindowId", isBuiltIn: false)。 - 避免和宿主或其他插件重复 ID。
- 插件 v3 控件应使用稳定控件名和合理默认几何。
- 用户可见文本同样考虑本地化。
NavigationView
WPF-UI 默认的 NavigationView 样式和行为有坑点。Page被加载后外面会自动包一层 ScrollViewer,不要重复包裹,否则就导致页面无法滚动
