从零打造商场背景音乐与紧急广播系统(二):技术选型与架构
一、先定三条底线
需求聊清楚后,我列了三条技术底线,后续所有选型都不能违背。
底线一:必须单机运行。
商场内网,不能依赖公网。断网了,背景音乐要照放,找人要照播,消防联动要照触发。云广播 SaaS 直接排除。
底线二:必须访问声卡。
多分区独立输出,Web 做不到,Electron 通过 Node 也能做但延迟和稳定性不如原生。必须用桌面技术。
底线三:必须能常驻后台。
商场营业 12 小时,软件要无人值守。Windows Service 是标配,不能依赖用户手动打开界面。
三条底线一划,技术路线基本就定了。
二、为什么是 WPF + NAudio
为什么不用 Electron
Electron 看起来很美:Web 技术栈,界面漂亮,跨平台。
但商场场景有几个致命问题:
| 问题 | 说明 |
|---|---|
| 多声卡输出 | Electron 通过 Node 调用系统音频,多设备切换不如原生稳定 |
| 音频延迟 | Chromium 音频栈有额外缓冲,找人播报要求低延迟 |
| 内存占用 | 一个 Electron 应用至少 200MB,常驻不划算 |
| 后台运行 | Electron 依赖主窗口,最小化到托盘体验差 |
| 安装包 | 至少 100MB 起步,用户下载意愿低 |
结论:Electron 适合做"看起来像桌面应用的 Web 应用",不适合做"真正调用系统能力的工具"。
为什么不用 WinForms
WinForms 更简单,但:
-
主题和动画:深色主题、圆角卡片、微动效,WinForms 做起来痛苦十倍。
-
数据绑定:MVVM 让 UI 和逻辑解耦,WinForms 靠事件和手动赋值,代码容易乱。
-
高 DPI:WinForms 需要额外配置,WPF 原生支持。
-
长期维护:WPF 是微软主推的 Windows 桌面框架,WinForms 维护模式。
结论:如果只做单机简单播放器,WinForms 也行。但要做成产品,WPF 更合适。
为什么是 NAudio
音频库的选择不多:
| 库 | 评价 |
|---|---|
| NAudio | 成熟稳定,多声卡支持好,社区活跃 |
| BASS | 商业授权,功能强大但收费 |
| CSCore | 已停止维护 |
| Windows Media Player API | 老旧,多声卡支持差 |
| SoundPlayer(BCL) | 只能播 WAV,无法多声卡 |
NAudio 的核心优势:
-
多声卡支持:
WaveOutEvent.DeviceNumber直接指定输出设备。 -
格式支持:MP3、WAV、AIFF 原生,M4A/AAC 通过 MediaFoundation。
-
淡入淡出:
FadeInOutSampleProvider开箱即用。 -
音量控制:
VolumeSampleProvider支持分区独立调节。 -
免费开源:MIT 协议,商用无忧。
结论:NAudio 是商场广播场景的最优解。
TTS 选型
紧急找人的核心是"文字转语音",选型对比:
| 方案 | 音质 | 费用 | 离线 |
|---|---|---|---|
| Edge TTS | 优秀 | 免费 | 需要联网 |
| Azure TTS | 最佳 | 按量付费 | 需要联网 |
| System.Speech | 一般 | 免费 | 离线 |
| 讯飞 TTS | 优秀 | 按量付费 | 需要联网 |
结论:主用 Edge TTS(免费、音质好),离线时降级到 System.Speech。
三、架构分层:一次痛苦但值得的重构
第一版:一团乱麻
最初的项目结构很简单:
MallBroadcast/
├── MainWindow.xaml.cs # 所有逻辑都在这
├── Program.cs
└── AudioPlayer.cs
MainWindow.xaml.cs 有 3000 行:定时、播放、配置、UI、TTS 全混在一起。改一个 bug 引发三个新 bug。想加 TTS,发现要动的地方太多,怕改坏。
第二版:分层清晰
痛定思痛,花了一周重构:
MallBroadcast.sln
├── src/
│ ├── MallBroadcast.Abstractions/ # 契约层:接口、DTO、事件(零依赖)
│ ├── MallBroadcast.Core/ # 核心实现:调度、播放、分区、优先级
│ ├── MallBroadcast.App/ # WPF 主程序
│ ├── MallBroadcast.Service/ # Windows Service 宿主
│ ├── MallBroadcast.Api/ # ASP.NET Core 类库(手机端调用)
│ └── MallBroadcast.Plugin.Sample/ # 示例插件
└── tests/
├── MallBroadcast.Core.Tests/
├── MallBroadcast.Audio.Tests/
└── MallBroadcast.Integration.Tests/
依赖方向严格单向
Abstractions ← Core ← App/Service/Api Abstractions ← Plugin.Sample Abstractions ← Tests
规则:
-
Abstractions 不引用任何项目
-
Core 只引用 Abstractions
-
App/Service/Api 引用 Core + Abstractions
-
Plugin 只引用 Abstractions
-
禁止反向引用
为什么要有 Abstractions
这是重构中最关键的一层。
问题:如果插件直接引用 Core,插件就会带上 Core 的所有依赖(NAudio、NLog、MiniExcel),体积大、版本冲突风险高。
解决:把所有接口抽到 Abstractions,插件只引用 Abstractions。
// Abstractions/Audio/IPlaybackService.cs public interface IPlaybackService { Task PlayAsync(PlaybackRequest request, CancellationToken ct = default); Task StopAsync(string zoneId, CancellationToken ct = default); Task StopAllAsync(CancellationToken ct = default); } // Abstractions/Events/IPlaybackEvent.cs public class PlaybackStartedEvent { public string ZoneId { get; set; } public string ProgramId { get; set; } public PlaybackPriority Priority { get; set; } public DateTime StartTime { get; set; } = DateTime.Now; }
Core 实现这些接口,App/Service/Api 通过 DI 注入使用。
收益:
-
插件零依赖,体积小
-
App/Service/Api 共享契约
-
未来换实现(如 JSON 换 SQLite)不影响上层
-
测试可以 Mock 接口,不依赖真实实现
各层职责
| 项目 | 职责 | 关键点 |
|---|---|---|
| Abstractions | 定义契约 | 零依赖,只有接口、DTO、事件、枚举 |
| Core | 业务实现 | 调度、播放、分区、优先级、TTS、配置 |
| App | WPF UI | 只调用 ICoreService,不写业务逻辑 |
| Service | Windows Service 宿主 | 托管调度、播放、Api(可选) |
| Api | REST API | 只调用 ICoreService,不直接操作文件或音频 |
| Plugin.Sample | 示例插件 | 只依赖 Abstractions |
这次重构值不值
花了一周,但后期开发效率提升三倍:
-
加 TTS:只改 Core 和 App,不影响 Service
-
加优先级:只改 Core,接口不变
-
加手机端:Api 只调 ICoreService,一行业务逻辑都不用写
-
加插件:只引用 Abstractions,编译快、体积小
结论:重构痛苦一周,收益持续几个月。值。
四、核心接口设计
播放服务
调度服务
TTS 服务
事件总线
核心服务(唯一入口)
App、Service、Api 都通过 ICoreService 调用业务逻辑,不绕过它。
五、核心模型
六、依赖注入与生命周期
用 Microsoft.Extensions.DependencyInjection 统一管理:
// App.xaml.cs services.AddSingleton<ICoreService, CoreService>(); services.AddSingleton<IPlaybackService, PlaybackService>(); services.AddSingleton<IScheduleService, ScheduleService>(); services.AddSingleton<IEventBus, EventBus>(); services.AddSingleton<ILicenseService, LicenseService>(); services.AddSingleton<ITtsService, EdgeTtsService>(); services.AddSingleton<IAudioDeviceService, NAudioDeviceService>(); services.AddSingleton<IExcelExportService, ExcelExportService>(); services.AddSingleton<ILoggerFactory, LoggerFactory>();
生命周期选择:
| 服务 | 生命周期 | 理由 |
|---|---|---|
| ICoreService | Singleton | 全局唯一 |
| IPlaybackService | Singleton | 持有播放状态 |
| IScheduleService | Singleton | 后台常驻 |
| IEventBus | Singleton | 全局事件总线 |
| ILicenseService | Singleton | 许可证只加载一次 |
| ITtsService | Singleton | 缓存复用 |
| ViewModel | Transient | 每次新建 |
| View | Singleton | 主窗口唯一 |
七、配置管理
配置结构
版本迁移
配置带 version 字段,启动时自动迁移:
每加一个功能就升版本,写好迁移器。 老用户升级后配置不丢。
原子写入
直接 File.WriteAllText 有风险:写到一半断电,配置损坏。改用原子写入:
防抖
用户连续操作 UI 时,可能触发多次保存。加防抖:
500ms 内的多次保存合并为一次。
八、日志
用 NLog,但业务代码只用 ILogger<T> 抽象:
NLog 配置:异步写入、分级输出、文件滚动、keepFileOpen="true"。
<targets async="true"> <target name="file" xsi:type="File" fileName="Logs/${shortdate}.log" archiveAboveSize="10485760" maxArchiveFiles="30" keepFileOpen="true" encoding="utf-8"/> </targets> <rules> <logger name="*" minlevel="Info" writeTo="file"/> <logger name="MallBroadcast.Scheduling.*" minlevel="Debug" writeTo="file"/> </rules>
九、产品截图
[图 1:主界面整体布局]
截图说明:深色主题,顶部工具栏(作息表切换、今日调整、立即播放、暂停今日、设置、紧急广播),左侧功能导航(定时播放、节目库、分区管理、作息表、节假日、设置、日志),中间是今日任务列表,右侧是运行状态面板(当前播放、优先级、下一任务、总音量、分区状态),底部状态栏显示运行状态、声卡数量、已执行任务数、版本号。
界面分五个区域:
-
顶部工具栏:作息表切换、今日调整、立即播放、暂停今日、设置、紧急广播
-
左侧导航:功能入口
-
中间工作区:任务列表(今日视图、列表视图、周视图、时间轴视图)
-
右侧状态面板:当前播放、优先级、下一任务、总音量、分区状态
-
底部状态栏:运行状态、声卡数、今日执行数、版本
[图 2:首次启动向导]
截图说明:首次打开软件时,选择使用场景(幼儿园、学校、商场/超市、工厂/车间、军营、消防站),自动创建示例配置,降低上手门槛。
首次启动向导解决"新用户不知道从哪开始"的问题。选择场景后,自动创建示例节目、时间点、分区,用户只需微调即可使用。
[图 3:浅色主题主界面]
截图说明:同一界面切换到浅色主题,背景变白,文字变深色,主色和状态色保持一致。顶部工具栏有主题切换按钮。
主题切换通过 ResourceDictionary 实现,所有颜色用 DynamicResource 绑定,运行时替换即可。
十、项目结构一览
十一、小结
本文讲了技术选型和架构设计:
-
三条底线:单机运行、访问声卡、常驻后台。
-
选型:WPF + NAudio + Edge TTS,不用 Electron、WinForms。
-
分层:Abstractions / Core / App / Service / Api / Plugin,依赖方向单向。
-
核心:Abstractions 零依赖,插件只引用接口。
-
配置:版本迁移、原子写入、防抖。
-
日志:NLog + ILogger 抽象。
最核心的决策是 Abstractions 层。它让插件零依赖、让测试可 Mock、让未来换实现不影响上层。虽然重构花了一周,但后期开发效率提升三倍。
下一篇,我们聊多分区与优先级:如何用 20 元的 USB 声卡实现 4 个分区独立播放,如何设计四级优先级,如何实现 Ducking 让找人广播时背景音乐自动压低。



浙公网安备 33010602011771号