简介:这是一套基于WPF ModernUI构建的通用界面框架源码,面向需要快速搭建现代化桌面应用的开发者,尤其适合希望以较低配置成本实现插件化功能集成的中高级WPF使用者。框架支持三级菜单、皮肤更换与字体调整,并提供将自定义功能注册到页面的扩展方式,配合OSGi.NET插件机制可组织较复杂的模块化界面,对通用后台管理、工具软件等场景有较强参考价值。压缩包共253个文件,约3.49MB,以C#源码和XAML界面文件为主,同时包含DLL、XML配置、项目文件、资源图片与少量可执行文件,覆盖界面控件、外观管理、插件模型及示例配置等层面,便于直接运行、拆解和二次开发。已有4443人学习,适合借鉴其ModernUI控件实现、菜单导航、换肤与模块注册思路,并在此基础上搭建自己的WPF应用框架。
1. 漂亮的 WPF 界面框架不是换皮:先看它把哪些脏活收进了壳里
很多 WPF 界面框架看起来漂亮,真正落地时却卡在主题资源、导航壳和插件加载这三件事上。这套资源把 ModernUI 风格的主题字典、Shell 窗口、导航服务和插件契约收进一个可运行工程,不是单纯换皮。
它适合需要快速搭后台工具、设备管理端、内部运营台的人:新手能先跑示例页,熟手能直接看插件边界和资源分层。你要判断的不是“好不好看”,而是它有没有把脏活收进壳里,让你改主题时不碰业务、加页面时不改主窗口。
如果这套壳的Themes、Contracts、Plugins三层分得清楚,后面接业务页面和第三方插件会省很多返工;分不清楚,再漂亮的界面也只是个演示窗口。
2. ModernUI 壳的骨架:资源字典、控件样式与导航容器怎么分层
2.1 先判断这套壳是“主题库拼装”还是“自绘控件体系”
拿到一套 WPF 界面框架,我一般先不急着跑,而是看它的 XAML 分布。主题库拼装的壳,通常大量合并第三方资源字典,控件样式以BasedOn为主;自绘控件体系会在ControlTemplate里重写按钮、列表框、滚动条和窗口边框。两者的维护成本不一样:前者升级主题库时容易被动,后者改一处颜色可能牵动十几个模板。判断这一步不是为了评判优劣,而是决定你后面改样式时该找哪个入口。
可以用 PowerShell 先扫一遍目录,重点看Themes、Controls、Views三个位置的文件分布。不要一上来就全量打开,先看命名和层级,能快速判断这套壳有没有把基础色、控件样式、页面布局分开。
Get-ChildItem -Recurse -Depth 2 | Where-Object { $_.Extension -in '.xaml','.cs','.csproj' } | Select-Object FullName这段命令只做目录侦察,-Depth 2控制递归深度,避免解决方案一大就输出刷屏。看结果时重点盯三个信号:Themes下是否有独立的颜色字典和控件字典;Controls下是否有大量自定义ControlTemplate;Views下页面是否直接写死颜色和边距。如果页面里到处是Background="#FFFFFF",说明这套壳的 Design Token 没收口,后面换主题会翻车。
提示:先看
App.xaml的MergedDictionaries,它基本暴露了这套 WPF 界面框架的样式入口。入口越集中,后面越好改。
2.2 把资源字典按“基础色、控件样式、页面级资源”拆开
ModernUI 风格的壳最怕资源字典互相依赖成一团。合格的分层是:基础色和字体只定义值,不引用控件;控件样式只引用基础资源,不引用页面资源;页面级资源只服务当前页面。这样你换品牌色时只动第一层,按钮、卡片、导航栏会自动跟着变。很多“漂亮但难维护”的 WPF 界面,问题就出在控件模板里直接写了颜色值,主题切换时只有一半控件变色。
资源合并顺序要固定:基础色在前,控件样式在后,布局资源最后。StaticResource在 XAML 加载时解析,顺序错了会直接抛找不到资源;DynamicResource在运行时查找,适合需要切换主题的画刷。我的习惯是颜色、字体、圆角这类会变的资源统一用DynamicResource,尺寸和结构类资源可以用StaticResource。
<Application.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <ResourceDictionary Source="pack://application:,,,/Themes/Colors.xaml" /> <ResourceDictionary Source="pack://application:,,,/Themes/Controls.xaml" /> <ResourceDictionary Source="pack://application:,,,/Themes/Layout.xaml" /> </ResourceDictionary.MergedDictionaries> </ResourceDictionary> </Application.Resources>这段App.xaml的关键在pack://application:,,,/这个 URI。三段分别表示应用资源、当前程序集和资源路径,少了斜杠或程序集名写错,设计器可能正常、运行时直接白屏。Colors.xaml必须先于Controls.xaml,因为控件样式里的DynamicResource要在运行时找得到 key。Layout.xaml放页面间距、卡片圆角这类布局值,放在最后是为了让页面级覆盖有更高的查找优先级。
注意:如果资源字典放在独立类库,
pack://application:,,,/后面的程序集名不能省。很多“设计时好看、运行时样式全丢”的问题,都是 URI 写成了相对路径。
2.3 导航容器只做三件事:区域、路由、返回栈
WPF 界面框架的 Shell 不需要什么都管。导航容器只做三件事:提供一个内容区域、把页面 Key 映射到页面实例、维护返回栈。菜单、工具栏、状态栏是 Shell 的外围,不要和导航逻辑耦合。常见做法是用ContentControl作为导航宿主,配合DataTemplate把 ViewModel 映射到 View;页面自己不new Window,也不直接操作主窗口。这样插件注册进来的页面和内置页面走同一条导航链路,后面加权限过滤、页面缓存、返回逻辑都有地方挂。
下面这个导航服务是最小可用版本,Register负责登记页面工厂,Navigate负责切换内容,GoBack负责回退。页面工厂用Func<object>而不是直接传实例,是为了让页面在真正打开时才创建,避免启动时把所有页面都初始化一遍。
public interface INavigationService { void Register(string pageKey, Func<object> factory); void Navigate(string pageKey); bool GoBack(); } public sealed class NavigationService : INavigationService { private readonly ContentControl _host; private readonly Dictionary<string, Func<object>> _factories = new Dictionary<string, Func<object>>(StringComparer.OrdinalIgnoreCase); private readonly Stack<object> _backStack = new Stack<object>(); public NavigationService(ContentControl host) { _host = host; } public void Register(string pageKey, Func<object> factory) { _factories[pageKey] = factory; } public void Navigate(string pageKey) { if (!_factories.TryGetValue(pageKey, out var factory)) return; if (_host.Content != null) _backStack.Push(_host.Content); _host.Content = factory(); } public bool GoBack() { if (_backStack.Count == 0) return false; _host.Content = _backStack.Pop(); return true; } }_host是 Shell 里的ContentControl,导航服务不关心它放在窗口哪一侧。pageKey建议用模块名.页面名,避免内置页面和插件页面撞键。_backStack只存页面对象,不存参数;如果页面需要恢复状态,参数要单独放到页面上下文里。StringComparer.OrdinalIgnoreCase让页面 Key 不区分大小写,减少插件注册时的低级错误。
XAML 侧只需要一个宿主和一组DataTemplate:
<ContentControl x:Name="PageHost" /> <DataTemplate DataType="{x:Type vm:HomeViewModel}"> <views:HomeView /> </DataTemplate>ContentControl.Content赋成 ViewModel 时,WPF 会按DataType找对应的DataTemplate;赋成 View 时直接显示。两种方式都能用,但团队里最好统一一种。我的习惯是导航服务只认页面对象,ViewModel 到 View 的映射交给DataTemplate,这样插件返回 ViewModel 也能接入,不会把 Shell 绑死在某个视图类型上。
3. 把插件接进 WPF 界面:接口契约、发现、加载与卸载边界
3.1 插件契约先定薄:页面、菜单、导航入口三件套
插件化最容易犯的错,是让插件直接引用 Shell 的具体窗口和导航类。这样插件一多,Shell 改一个属性,所有插件都要重编译。正确做法是单独放一个契约程序集,里面只有接口、元数据和少量 DTO,Shell 和插件都引用它,但插件不引用 Shell 实现。契约要薄:插件能登记导航入口、能拿到一个受控的上下文、能声明自己的元数据,就够了。文件读写、数据库连接、权限判断这些能力通过上下文接口暴露,不要直接塞进插件。
下面这组接口是常见的最小契约。IPlugin是插件入口,PluginMetadata描述插件身份,IPluginContext是 Shell 给插件的受控能力。AddNavigation的viewFactory返回页面对象,Shell 负责把它放进导航宿主,插件不碰主窗口。
public interface IPlugin { PluginMetadata Metadata { get; } void Register(IPluginContext context); } public sealed class PluginMetadata { public string Id { get; set; } public string DisplayName { get; set; } public int Order { get; set; } public string[] Permissions { get; set; } } public interface IPluginContext { void AddNavigation(string pageKey, string title, Func<object> viewFactory); }Id用来做插件唯一标识,建议带上命名空间前缀;DisplayName是菜单显示名;Order控制菜单排序,空值给默认值,不要留 0 导致所有插件挤在一起;Permissions是给后续权限过滤留的口,不要在插件里自己判断角色。viewFactory延迟创建页面,插件加载时只注册工厂,不初始化页面,启动速度会好很多。
提示:契约程序集不要被插件目录自带一份。Shell 和插件必须引用同一个契约程序集,否则
IPlugin类型对不上,加载时会出现“明明实现了接口却转不过去”的玄学问题。
3.2 插件发现与加载:目录扫描加 AssemblyLoadContext 隔离
插件发现不要全盘扫描。固定一个插件根目录,按子目录或*.dll扫描,每个插件独立加载上下文。目标框架支持AssemblyLoadContext时,优先用它做隔离;老 .NET Framework 项目常见做法是 MEF,但 MEF 的目录扫描和依赖解析边界不一样,不要两边代码硬套。这里按支持AssemblyLoadContext的工程写,核心思路是:共享契约交给默认上下文,插件私有依赖留在插件上下文。
public sealed class PluginLoadContext : AssemblyLoadContext { private readonly AssemblyDependencyResolver _resolver; private readonly HashSet<string> _shared; public PluginLoadContext(string pluginPath, IEnumerable<string> sharedAssemblies) : base(isCollectible: true) { _resolver = new AssemblyDependencyResolver(pluginPath); _shared = new HashSet<string>(sharedAssemblies, StringComparer.OrdinalIgnoreCase); } protected override Assembly Load(AssemblyName assemblyName) { if (_shared.Contains(assemblyName.Name)) return null; // 共享契约交回默认上下文,避免类型重复 var path = _resolver.ResolveAssemblyToPath(assemblyName); return path == null ? null : LoadFromAssemblyPath(path); } }AssemblyDependencyResolver的基准路径是插件 DLL 的完整路径,它依赖插件旁边的.deps.json解析依赖。_shared里放契约程序集和 Shell 公共程序集名,命中后返回null,让默认加载上下文处理,这样插件和 Shell 拿到的是同一个类型。isCollectible: true表示支持卸载,但卸载不是免费的:插件注册的静态事件、后台线程、WPF 资源引用没清掉,上下文就回收不了。血泪经验是先把加载跑通,再谈卸载;不要一上来就追求热插拔。
扫描加载时,单个插件失败不能拖垮 Shell。下面这段代码按 DLL 逐个加载,异常只记日志,插件入口类型用IsAssignableFrom判断:
public IReadOnlyList<IPlugin> LoadFrom(string pluginRoot) { var result = new List<IPlugin>(); foreach (var dll in Directory.EnumerateFiles(pluginRoot, "*.dll", SearchOption.AllDirectories)) { try { var context = new PluginLoadContext(dll, new[] { "Contracts", "Shell" }); var assembly = context.LoadFromAssemblyPath(Path.GetFullPath(dll)); foreach (var type in assembly.GetTypes() .Where(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsAbstract)) { result.Add((IPlugin)Activator.CreateInstance(type)); } } catch (Exception ex) { Debug.WriteLine($"{dll}: {ex.Message}"); } } return result; }pluginRoot用绝对路径,避免工作目录变化导致扫描不到。SearchOption.AllDirectories适合插件按子目录放的工程;如果插件目录很大,改成顶层扫描再按清单加载。GetTypes()可能抛ReflectionTypeLoadException,生产环境要单独捕获并把LoaderExceptions记下来,否则你只能看到一句“加载失败”,没有后悔药。日志里至少记录 DLL 路径、插件 ID、异常类型,后面排查依赖冲突全靠它。
3.3 插件页面注册到 ModernUI 导航:菜单项和路由的合并
插件加载完不要立刻刷新菜单。先把所有插件的导航项收集起来,按Order和标题排序,再去重合并,最后一次性更新菜单。每加载一个插件就刷新一次 UI,插件一多会看到菜单闪烁,启动也慢。去重键用插件ID.页面Key,不要只用页面标题;标题会重复,Key 不会。
public sealed class NavigationRegistry { private readonly List<NavigationItem> _items = new List<NavigationItem>(); private readonly HashSet<string> _keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase); public void Add(string key, string title, int order, Func<object> factory) { if (!_keys.Add(key)) return; // 冲突先到先得,同时写诊断日志 _items.Add(new NavigationItem(key, title, order, factory)); } public IReadOnlyList<NavigationItem> Build() { return _items .OrderBy(i => i.Order) .ThenBy(i => i.Title, StringComparer.CurrentCulture) .ToList(); } }NavigationItem里存 Key、标题、排序和页面工厂,Shell 只消费Build()的结果。Order默认给 1000,避免插件没填时排到最前面。ThenBy用当前区域文化排序,中文菜单不会按拼音乱跳。注册时机放在 Shell 初始化之后、主窗口显示之前,太早宿主还没创建,太晚菜单已经绑定完,都会出现“页面能打开但菜单没有”的尴尬。
4. 视觉参数落地:颜色、字体、圆角、阴影与动效的可维护写法
4.1 先把“漂亮”拆成可调的 Design Token
ModernUI 的漂亮不是靠某个按钮的渐变,而是靠一套稳定的 Design Token:品牌色、表面色、文字色、边框色、圆角、间距、字号、阴影。Token 不统一,页面就会越写越花,最后没人敢改。我的建议是先定一张小表,只放最常用的十几项,页面里只允许引用 Token,不允许写十六进制颜色。下面这张表可以直接照着改成项目自己的值。
| Token | 建议值 | 用在哪 | 修改注意 |
|---|---|---|---|
Brand.Brush | #1F6FEB | 主按钮、选中态、链接 | 换品牌色只改这一处 |
Surface.Brush | #FFFFFF | 卡片、面板背景 | 暗色主题要提供对应值 |
Text.Primary.Brush | #1F2328 | 标题、正文 | 对比度至少 4.5:1 |
Text.Secondary.Brush | #656D76 | 辅助说明 | 不要用在关键操作上 |
Card.CornerRadius | 8 | 卡片、按钮、输入框 | 统一圆角,别混用 4 和 12 |
Font.Size.Body | 14 | 正文 | 跟系统 DPI 一起测 |
Space.Page | 16 | 页面内边距 | 和卡片间距保持倍数关系 |
这张表的价值在于约束。比如圆角,卡片用 8,按钮也用 8,输入框还用 8,视觉上才像一个体系;按钮 4、卡片 12、弹窗 16,单看都行,放一起就散。阴影同理,要么统一用一层浅阴影,要么统一用边框,不要一半卡片有阴影一半没有。WPF 里阴影用DropShadowEffect会影响渲染性能,列表项多的时候尽量用边框或背景色分层。
4.2 用资源字典和附加属性收口,不在页面里散写颜色
资源字典里定义画刷、圆角、间距和字号,页面和控件模板通过DynamicResource引用。这样换主题时只需要替换字典,不用逐页改。下面这段是最小资源定义,sys:Double需要引入System命名空间,CornerRadius和Thickness直接作为资源使用。
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:sys="clr-namespace:System;assembly=mscorlib"> <SolidColorBrush x:Key="Brand.Brush" Color="#1F6FEB" /> <SolidColorBrush x:Key="Surface.Brush" Color="#FFFFFF" /> <SolidColorBrush x:Key="Text.Primary.Brush" Color="#1F2328" /> <CornerRadius x:Key="Card.CornerRadius">8</CornerRadius> <Thickness x:Key="Card.Padding">16</Thickness> <sys:Double x:Key="Font.Size.Body">14</sys:Double> </ResourceDictionary>命名用区域.用途.类型,比如Brand.Brush、Text.Primary.Brush,比BlueColor这种名字好维护。SolidColorBrush会被DynamicResource引用,主题切换时替换字典里的画刷,控件自动更新。CornerRadius和Thickness是值类型,DynamicResource也能用,但不要在模板里用TemplateBinding绑到不存在的属性上。
控件样式里也要用DynamicResource,不要只在页面里用。下面这个按钮样式把背景、圆角、内边距都收进模板:
<Style x:Key="ModernButton" TargetType="Button"> <Setter Property="Background" Value="{DynamicResource Brand.Brush}" /> <Setter Property="Foreground" Value="White" /> <Setter Property="Padding" Value="14,8" /> <Setter Property="BorderThickness" Value="0" /> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="Button"> <Border Background="{TemplateBinding Background}" CornerRadius="{DynamicResource Card.CornerRadius}" Padding="{TemplateBinding Padding}"> <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" /> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style>TemplateBinding把控件属性传进模板,DynamicResource让圆角和背景跟随主题。很多人只在Setter里写DynamicResource,模板里又写死#1F6FEB,结果换主题时按钮背景变了、边框没变,看起来像半成品。样式写完后,页面里只写Style="{StaticResource ModernButton}",不要再补颜色。
注意:
DropShadowEffect在大列表里很吃性能,卡片阴影能用边框替代就用边框。动效也不要给每个列表项加,滚动时会明显掉帧。
4.3 动效只留三处:页面切换、加载态、提示条
WPF 界面框架的动效不需要多,三处就够:页面切换的淡入和轻微位移、加载态的旋转或进度条、提示条从边缘滑入。时长控制在 120 到 200 毫秒,超过 300 毫秒用户会觉得卡。动画属性优先选Opacity和RenderTransform,不要动Width、Height、Margin这类触发布局的属性。下面这个页面进入动画只动透明度和TranslateTransform.Y,不会触发重新测量。
<Storyboard x:Key="PageEnter"> <DoubleAnimation Storyboard.TargetProperty="Opacity" From="0" To="1" Duration="0:0:0.16" /> <DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.Y)" From="8" To="0" Duration="0:0:0.16"> <DoubleAnimation.EasingFunction> <CubicEase EasingMode="EaseOut" /> </DoubleAnimation.EasingFunction> </DoubleAnimation> </Storyboard>Duration="0:0:0.16"是 160 毫秒,From="8"是 8 像素位移,按 DPI 缩放后仍然自然。CubicEase的EaseOut让动画收尾变慢,比线性动画舒服。使用前要确保目标元素有RenderTransform,并且RenderTransformOrigin设置合理,否则位移方向会偏。页面切换动画只在导航宿主上跑一次,不要在每个用户控件里各跑一套,动效一多就变成干扰。
5. 避坑与排查:ModernUI 壳和插件化最容易翻车的 5 个点
5.1 现象:启动后样式全丢,控件回到默认灰
现象很典型:设计器里 ModernUI 样式正常,运行后按钮变成系统默认灰色,圆角、颜色、字体全没了。原因通常有三个:App.xaml的资源字典路径写错;StartupUri直接打开了某个窗口,绕过了App.xaml的资源合并;资源字典放在类库,pack://application:,,,/后面的程序集名漏了。解决时先看App.xaml有没有被正确编译成ApplicationDefinition,再看输出目录里的资源文件是否存在。
<ResourceDictionary Source="pack://application:,,,/Themes/Colors.xaml" />pack://application:,,,/是应用资源的标准前缀,后面跟程序集名和路径。当前程序集可以省略程序集名,但独立类库不能省。改完路径后清理一次bin和obj,WPF 的资源编译缓存有时会让你怀疑人生。
5.2 现象:插件加载后主窗口卡死或启动变慢
插件一多,启动时同步扫描目录、同步加载所有 DLL、在插件构造函数里连数据库,主窗口就会白屏几秒。原因不是 WPF 慢,是 UI 线程被占住了。解决思路是把扫描和加载放到后台线程,插件构造函数只做注册,不做重活;页面工厂保持懒加载,等用户点菜单再创建页面。
var plugins = await Task.Run(() => _loader.LoadFrom(pluginRoot)); foreach (var plugin in plugins) { plugin.Register(_pluginContext); }Task.Run只包住加载,插件注册回到 UI 线程执行,因为注册可能碰导航集合。如果插件注册本身很重,再拆成“收集元数据”和“应用注册”两段。不要在后台线程直接改ObservableCollection,WPF 的集合绑定会抛跨线程异常。
5.3 现象:插件页面能打开但导航菜单不显示或重复
页面能通过代码打开,菜单却没有,或者同一个插件出现两次。原因通常是注册时机晚于菜单绑定、插件 ID 冲突、Order没给默认值。解决时先把导航项收集到NavigationRegistry,去重后再一次性Build(),菜单只绑定最终结果。页面 Key 用插件ID.页面Key,不要只用标题。
if (!keys.Add($"{plugin.Metadata.Id}.{pageKey}")) { Debug.WriteLine($"重复导航项:{plugin.Metadata.Id}.{pageKey}"); return; }去重时记录日志,不要静默丢弃。重复项往往是插件复制了一份或者旧版本没删干净,日志能帮你快速定位。
5.4 现象:切换主题后部分控件不跟着变
换主题后主界面变了,某些第三方控件、图表、弹窗还是旧颜色。原因是这些控件用了StaticResource,或者在模板里写死了颜色,或者第三方控件根本没有用DynamicResource。排查时先在页面里搜StaticResource和#,把颜色引用换成DynamicResource;第三方控件要看它的模板是否暴露了背景、前景属性,没暴露就只能在外层包一层容器做适配。
<!-- 不跟随主题 --> <Border Background="{StaticResource Surface.Brush}" /> <!-- 跟随主题 --> <Border Background="{DynamicResource Surface.Brush}" />StaticResource在加载时解析一次,DynamicResource在运行时查找。主题切换靠的是替换字典,所以会变的资源必须用DynamicResource。但也不要全量替换,结构类资源用StaticResource性能更好。
5.5 现象:发布后插件目录找不到或加载失败
开发环境能加载,发布后插件全部失败。原因常见于插件目录没有复制到输出、AssemblyDependencyResolver的基准路径不对、插件私有依赖和 Shell 依赖版本冲突。解决时先确认发布目录里插件 DLL 和.deps.json都在,再在加载器里记录每个插件的解析路径和异常。共享契约程序集必须走默认上下文,插件私有依赖留在插件上下文,两者不要混。
<ItemGroup> <None Include="Plugins\**\*.*" CopyToOutputDirectory="PreserveNewest" /> </ItemGroup>CopyToOutputDirectory确保插件目录跟着输出。发布后先跑一次冷启动,看日志里每个插件的加载耗时和失败原因。不要凭肉眼判断“应该没问题”,插件加载的黑匣子只有日志能打开。
6. 进阶验证:插件隔离、启动诊断与发布前自检
插件化跑通之后,最容易忽略的是验证。我的习惯是给加载器加一段诊断日志,记录每个插件的 DLL 路径、加载耗时、入口类型、注册的导航项数量。下面这段代码不复杂,但能在发布前把大部分依赖冲突和重复注册暴露出来。
var sw = Stopwatch.StartNew(); var plugins = _loader.LoadFrom(pluginRoot); foreach (var plugin in plugins) { var before = sw.ElapsedMilliseconds; plugin.Register(_pluginContext); Debug.WriteLine($"插件 {plugin.Metadata.Id} 注册耗时 {sw.ElapsedMilliseconds - before}ms"); } Debug.WriteLine($"插件总加载耗时 {sw.ElapsedMilliseconds}ms,数量 {plugins.Count}");Stopwatch放在加载前后,能看出是扫描慢、加载慢还是注册慢。单个插件注册超过 200 毫秒就要查它是不是在构造函数里做了 IO。日志里不要只打“成功”,要打插件 ID 和导航项数量,重复注册和空注册一眼就能看出来。
发布前我会跑一张自检表,不靠记忆:
| 自检项 | 通过标准 | 失败先看 |
|---|---|---|
| 冷启动 | 主窗口 2 秒内可见 | 插件是否同步加载 |
| 主题切换 | 按钮、卡片、导航栏同步变色 | 是否用了StaticResource |
| 插件加载 | 每个插件都有 ID 和导航项日志 | 契约程序集是否重复 |
| 导航去重 | 同一页面 Key 只出现一次 | 插件旧版本是否残留 |
| 发布目录 | 插件 DLL 和 deps 文件齐全 | csproj 复制配置 |
这套 WPF 界面框架真正的价值,不是让你少写几个按钮样式,而是把主题、导航、插件三层的边界先划出来。边界清楚,后面加页面和换主题都是局部改动;边界不清楚,每加一个插件都在赌。从那以后我每次接新插件都先跑一遍冷启动诊断,不再凭肉眼判断“应该没问题”。希望帮到你。
本文还有配套的精品资源,点击获取