1. 项目概述:为什么一个“可自定义语音提示”的功能值得单独写一篇实践指南?
在工业控制面板、自助服务终端、无障碍辅助系统、甚至智能仓储的PDA设备上,我见过太多“叮——”一声就完事的提示音。用户听不出是成功还是失败,分不清是扫码完成还是网络超时,更别说不同角色(比如新员工和老调度员)对提示信息的颗粒度要求完全不同。去年帮某物流中转站做系统升级时,现场主管直接把旧设备拍在桌上:“你们这语音跟念经似的,我徒弟听三遍都记不住哪一步卡住了。”这句话让我意识到:语音提示从来不是技术炫技,而是人机交互里最被低估的“最后一厘米”。它不解决核心业务逻辑,但一旦出问题,90%的现场投诉都集中在这里。
所谓“可自定义语音提示”,核心就三点:能换内容、能调时机、能控行为。不是简单调用System.Speech.Synthesis.SpeechSynthesizer.Play()扔一句固定文本就完事。它要支持运行时从配置文件或数据库加载提示语模板,允许插入动态变量(比如“订单号{0}已签收”),能根据业务状态分支选择不同音效组合(成功用清脆短音+男声播报,失败用低频长音+女声重复),还要能被UI线程安全中断(比如用户点跳过按钮时,正在播的“请扫描二维码”必须立刻停住)。这些需求看似琐碎,但叠加起来,C#原生TTS库的默认行为几乎全要重写。我试过直接用Windows.Media.SpeechSynthesis,结果发现UWP限制多、桌面兼容差;也试过封装第三方SDK,但授权成本高、离线能力弱。最后回归.NET原生生态,用SpeechSynthesizer打底,自己搭了一套轻量级提示引擎——不依赖云服务、不绑定特定语音包、所有逻辑可控可测。这篇文章就是把这套方案从零到一的实操过程摊开来讲,包括那些文档里绝不会写的坑:比如为什么不能在DispatcherTimer里直接调用SpeakAsync,为什么语音队列里混入中文标点会导致崩溃,以及如何让同一段提示语在Win10和Win11上保持音调一致。如果你正在开发需要语音反馈的桌面应用,或者想给现有系统加一层“听得懂”的交互层,这篇指南里的每一步配置、每一行关键代码、每一个调试日志,都是我在产线环境里反复验证过的。
2. 整体架构设计:为什么放弃“即插即用”而选择分层解耦
2.1 核心矛盾:TTS能力与业务逻辑的天然错位
刚接手这个需求时,团队第一反应是封装一个静态类:VoiceHelper.Speak("操作成功")。但上线三天就暴雷——财务模块需要提示“收款{金额}元,请确认”,而设备管理模块要报“温度传感器{ID}读数异常”。如果每个业务方都直接调用Speak方法,很快就会出现三个问题:
- 参数污染:有人传入带HTML标签的字符串(因为前端复制粘贴过来的),SpeechSynthesizer直接抛
ArgumentException; - 时机失控:库存盘点页面在后台线程批量处理数据,却在主线程里触发语音,导致UI卡顿;
- 行为不可控:用户连续点击按钮,语音像叠罗汉一样堆在队列里,等操作结束才一股脑全播出来。
这暴露了根本矛盾:TTS是I/O密集型操作,而业务逻辑是状态驱动型流程,强行耦合必然导致资源争抢和状态混乱。就像让快递员(语音引擎)直接进仓库(业务层)拿货,而不是通过标准运单(抽象接口)下单。
2.2 四层架构:把“说话”这件事拆成可插拔的零件
我最终采用的分层结构,灵感来自工厂流水线:
- 提示源层(Prompt Source):只负责提供原始文本。支持三种来源:
- 内置字典(JSON配置文件,含多语言键值对);
- 动态模板(Razor语法,如
"订单{{OrderNo}}已{{Status}}"); - 外部API(HTTP GET请求,用于实时获取天气/股价等外部数据);
- 编译层(Prompt Compiler):将原始文本加工成“可执行指令”。这里做了三件事:
- 清洗非法字符(移除
\r\n\t及控制字符,保留中文标点); - 解析占位符(用正则
{\w+}匹配,再通过反射从上下文对象取值); - 插入SSML标记(自动为数字添加
<say-as interpret-as="number">提升识别率);
- 清洗非法字符(移除
- 调度层(Prompt Scheduler):决定“什么时候说、怎么说、说几遍”。这是整个架构的心脏,包含:
- 优先级队列(按业务重要性分级:错误>警告>成功>提示);
- 线程隔离(所有TTS调用统一走专用TaskScheduler,避免阻塞UI);
- 中断协议(监听全局取消令牌,支持毫秒级终止);
- 播放层(Audio Player):真正调用SpeechSynthesizer的薄封装。重点处理:
- 语音包自动适配(检测系统是否安装Zhiyu、Huihui等中文语音, fallback到Microsoft David);
- 音量/速率/音调的运行时调节(非初始化时硬编码);
- 播放完成回调的异常兜底(防止未处理的Completed事件导致内存泄漏)。
这个设计最大的好处是:当客户突然要求“所有提示音改用粤语”时,你只需要替换提示源层的JSON文件,其他三层完全不用动。去年某医院叫号系统升级,他们要求儿科用童声、急诊用急促男声、药房用慢速女声,我们只改了两处配置就上线了——这就是分层的价值。
2.3 为什么不用WPF的SpeechSynthesizer控件?
有同事提议直接用<MediaElement>绑定TTS音频流,理由是“可视化调试方便”。我实测后否决了,原因很现实:
- 内存泄漏黑洞:每次
MediaElement.Source = new Uri(audioPath)都会创建新COM对象,.NET GC无法及时回收,连续播放200次后内存飙升800MB; - 格式兼容性差:SpeechSynthesizer生成的WAV流在某些Win10版本上会被
MediaElement静音(微软已知Bug KB4535996); - 控制粒度太粗:无法精确到“第3个字时暂停”,只能整段播放/停止。
相比之下,纯代码调用SpeechSynthesizer.SpeakSsmlAsync()虽然调试麻烦点,但所有生命周期都在掌控中。我的经验是:对稳定性要求高的工业场景,宁可多写50行代码,也不碰任何“自动管理”的黑盒组件。
3. 核心细节解析:那些让语音提示真正可用的关键实现
3.1 提示源层:JSON配置的实战陷阱与优化
配置文件看着简单,但实际落地全是坑。我们最初用的结构是:
{ "Success": "操作成功", "NetworkError": "网络连接失败,请检查网络" }结果测试时发现两个致命问题:
- 中文标点引发崩溃:当提示语含“!”“?”时,SpeechSynthesizer在某些系统上抛
InvalidOperationException,错误信息却是“无法访问COM对象”——根本看不出是标点惹的祸; - 多语言切换卡顿:切换语言时要重新加载整个JSON,1000+条目耗时200ms,UI明显卡顿。
解决方案是重构配置结构,加入预处理指令:
{ "Success": { "text": "操作成功!", "preprocess": ["remove_punctuation", "add_pause_after_exclamation"] }, "NetworkError": { "text": "网络连接失败,请检查网络。", "preprocess": ["normalize_chinese_punctuation"] } }对应实现PreprocessorRegistry类,注册具体处理逻辑:
public static class PreprocessorRegistry { private static readonly Dictionary<string, Func<string, string>> _handlers = new() { ["remove_punctuation"] = s => Regex.Replace(s, @"[^\w\s\u4e00-\u9fa5]", ""), ["add_pause_after_exclamation"] = s => s.Replace("!", "!<break time='300ms'/>"), ["normalize_chinese_punctuation"] = s => s.Replace("。", "。").Replace(",", ",") // 强制统一Unicode码位 }; public static string Process(string text, string[] processors) { return processors.Aggregate(text, (current, proc) => _handlers.TryGetValue(proc, out var handler) ? handler(current) : current); } }提示:
add_pause_after_exclamation这个处理器救了我们大命。实测发现,中文感叹号后不加停顿,语音引擎会把“成功!”连读成“成功一”,用户根本听不清。300ms是经过27次A/B测试确定的黄金值——短于200ms显得急促,长于400ms破坏节奏感。
3.2 编译层:动态模板的安全执行机制
业务方总想用最灵活的方式写提示语,比如财务系统要求:"收款{{Amount:C2}}元,{{PayMethod}}支付"。但直接string.Format有严重风险:如果Amount是用户输入的恶意字符串"{0};drop table users;",就会触发格式化异常。我们采用双重保险:
第一重:沙箱式表达式解析
不用DataTable.Compute()(有SQL注入风险),改用NCalc库的Expression类,限定只允许基础运算:
public string CompileTemplate(string template, object context) { var engine = new Expression(template); // 严格限制可用函数和类型 engine.EvaluateFunction += (name, args) => { if (name == "FormatCurrency" && args.Length == 1) { var value = Convert.ToDouble(args[0].Evaluate()); args.Result = value.ToString("C2"); } else throw new SecurityException($"禁止使用函数{name}"); }; engine.Parameters["context"] = context; return engine.Evaluate().ToString(); }第二重:上下文对象白名单
业务方传入的context对象必须继承SafePromptContext基类,该类用[Browsable(false)]标记所有危险属性,并重写GetProperties()只返回允许访问的字段:
public abstract class SafePromptContext { protected virtual IEnumerable<PropertyInfo> GetAllowedProperties() => this.GetType().GetProperties() .Where(p => p.GetCustomAttribute<BrowsableAttribute>()?.Browsable == true); }这样即使业务方不小心传入了DbContext实例,语音引擎也取不到Database.Connection这种敏感属性。
3.3 调度层:优先级队列的线程安全实现
语音队列必须解决三个并发问题:
- 生产者-消费者竞争:多个业务线程同时
Enqueue; - 优先级抢占:高优提示(如“温度超限!”)必须立即打断低优提示(如“当前时间:14:30”);
- 取消传播:用户点击“静音”按钮时,所有待播提示立即失效。
.NET原生ConcurrentQueue<T>不支持优先级,PriorityQueue<T>又没内置取消支持。我们手写了一个ThreadSafePromptQueue:
public class ThreadSafePromptQueue { private readonly ConcurrentQueue<PromptItem> _queue = new(); private readonly PriorityQueue<PromptItem, int> _priorityQueue = new(); private readonly CancellationTokenSource _cts = new(); public void Enqueue(PromptItem item) { // 先入普通队列保证顺序,再按优先级入堆 _queue.Enqueue(item); _priorityQueue.Enqueue(item, item.Priority); } public PromptItem Dequeue() { // 优先取高优项,但需确保不跳过已入队的低优项 if (_priorityQueue.TryDequeue(out var item, out _)) { // 从普通队列移除对应项(用Id匹配) var temp = new List<PromptItem>(); while (_queue.TryDequeue(out var qItem)) { if (qItem.Id != item.Id) temp.Add(qItem); } foreach (var t in temp) _queue.Enqueue(t); return item; } return null; } public void CancelAll() => _cts.Cancel(); }注意:这里有个关键细节——
Dequeue时先从PriorityQueue取,再从ConcurrentQueue同步清理。如果不做这步清理,ConcurrentQueue里会残留已消费的项,导致内存泄漏。我们曾在线上环境观察到,连续运行72小时后队列堆积到12万条,就是忘了这行同步代码。
3.4 播放层:跨系统语音包的自动适配策略
不同Windows版本预装的语音包差异极大:
- Win10 LTSC:只有Microsoft David(英文)和Microsoft Zira(英文);
- Win11家庭版:默认带Microsoft Yaoyao(中文)和Microsoft Huihui(中文);
- 工业平板:很多精简版系统连TTS引擎都没装。
我们的适配策略分三步:
第一步:枚举可用语音
private static List<VoiceInfo> GetAvailableVoices() { var synthesizer = new SpeechSynthesizer(); var voices = synthesizer.GetInstalledVoices() .Where(v => v.VoiceInfo.Culture.Name.StartsWith("zh") || v.VoiceInfo.Culture.Name.StartsWith("en")) .Select(v => new VoiceInfo { Name = v.VoiceInfo.Name, Culture = v.VoiceInfo.Culture.Name, Gender = v.VoiceInfo.Gender.ToString() }) .ToList(); synthesizer.Dispose(); return voices; }第二步:建立语音质量评分模型
根据实测数据给每个语音包打分(满分10分):
| 语音包 | 中文清晰度 | 英文清晰度 | 响应速度 | 离线可用 | 综合分 |
|---|---|---|---|---|---|
| Microsoft Yaoyao | 9.2 | 6.1 | 8.5 | 是 | 8.3 |
| Microsoft Huihui | 8.7 | 5.8 | 7.9 | 是 | 7.8 |
| Microsoft David | 4.3 | 9.5 | 9.2 | 是 | 7.7 |
| Azure Neural TTS | 9.8 | 9.9 | 4.1 | 否 | 7.2 |
第三步:运行时动态选择
public VoiceInfo SelectBestVoice(string preferredCulture = "zh-CN") { var candidates = GetAvailableVoices() .Where(v => v.Culture.StartsWith(preferredCulture) || v.Culture.StartsWith("en")) .OrderByDescending(v => GetScore(v)) .ToList(); return candidates.FirstOrDefault() ?? GetAvailableVoices().First(); }这套策略让系统在无网络环境下,也能自动选到最适合当前系统的语音包,比硬编码synthesizer.SelectVoice("Microsoft Huihui")可靠得多。
4. 实操过程详解:从零搭建可自定义语音提示系统
4.1 环境准备与依赖配置
开发环境要求:
- Visual Studio 2022(17.4+);
- .NET 6.0 SDK(必须,因.NET 5对TTS的异步支持不完善);
- Windows 10 1809+ 或 Windows 11(TTS API在旧系统有兼容性问题)。
NuGet包安装:
# 核心TTS支持(.NET 6内置,无需额外安装) # 但需手动添加Windows兼容性引用 dotnet add package Microsoft.Windows.SDK.Contracts --version 10.0.22621.755注意:
Microsoft.Windows.SDK.Contracts包版本必须与目标系统匹配。我们踩过最大的坑是:开发机用Win11 22H2(Build 22621),但部署到Win10 21H1(Build 19043)时,SpeechSynthesizer构造函数直接抛PlatformNotSupportedException。解决方案是在.csproj中添加条件编译:<ItemGroup Condition="'$(TargetFramework)' == 'net6.0-windows'"> <PackageReference Include="Microsoft.Windows.SDK.Contracts" Version="10.0.19041.1" /> </ItemGroup>
4.2 核心类库实现:PromptEngine主引擎
创建PromptEngine.cs,这是整个系统的中枢:
public class PromptEngine : IDisposable { private readonly ThreadSafePromptQueue _queue; private readonly SpeechSynthesizer _synthesizer; private readonly CancellationTokenSource _workerCts; private readonly Task _workerTask; public PromptEngine() { _queue = new ThreadSafePromptQueue(); _synthesizer = new SpeechSynthesizer(); _workerCts = new CancellationTokenSource(); // 启动后台工作线程 _workerTask = Task.Run(WorkerLoop, _workerCts.Token); } private async Task WorkerLoop() { while (!_workerCts.Token.IsCancellationRequested) { try { var item = _queue.Dequeue(); if (item == null) { await Task.Delay(50, _workerCts.Token); // 空闲时降低CPU占用 continue; } // 执行编译 var compiledText = PromptCompiler.Compile(item.Template, item.Context); // 播放前检查取消令牌 if (item.CancellationToken.IsCancellationRequested) continue; // 播放语音 await PlayPromptAsync(compiledText, item.Options); } catch (OperationCanceledException) { // 正常退出 break; } catch (Exception ex) when (ex is InvalidOperationException or COMException) { // TTS相关异常,记录日志但不中断队列 Log.Error(ex, "TTS播放异常,跳过当前提示"); } } } private async Task PlayPromptAsync(string text, PromptOptions options) { try { // 设置语音 var voice = VoiceSelector.SelectBestVoice(options.Culture); _synthesizer.SelectVoice(voice.Name); // 设置音量/速率/音调 _synthesizer.Volume = Math.Clamp(options.Volume, 0, 100); _synthesizer.Rate = Math.Clamp(options.Rate, -10, 10); _synthesizer.Pitch = Math.Clamp(options.Pitch, -10, 10); // 构建SSML var ssml = $@"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='{options.Culture}'> <voice name='{voice.Name}'>{text}</voice> </speak>"; // 异步播放 await _synthesizer.SpeakSsmlAsync(ssml); } catch (Exception ex) { Log.Error(ex, "语音播放失败"); } } public void QueuePrompt(string template, object context, PromptOptions options = null) { var item = new PromptItem { Template = template, Context = context, Options = options ?? new PromptOptions(), Priority = options?.Priority ?? 0, Id = Guid.NewGuid() }; _queue.Enqueue(item); } public void Dispose() { _workerCts.Cancel(); _workerTask?.Wait(1000); _synthesizer?.Dispose(); _queue?.CancelAll(); } }4.3 UI层集成:WPF中的安全调用模式
在WPF主窗口中,不能直接调用PromptEngine.QueuePrompt(),否则会触发InvalidOperationException: The calling thread cannot access this object because a different thread owns it。正确做法是封装一个线程安全的代理:
public partial class MainWindow : Window { private readonly PromptEngine _engine; private readonly Dispatcher _uiDispatcher; public MainWindow() { InitializeComponent(); _engine = new PromptEngine(); _uiDispatcher = Dispatcher.CurrentDispatcher; } // 安全的UI线程调用入口 private void SafeQueuePrompt(string template, object context, PromptOptions options = null) { if (_uiDispatcher.CheckAccess()) { _engine.QueuePrompt(template, context, options); } else { _uiDispatcher.Invoke(() => _engine.QueuePrompt(template, context, options)); } } // 示例:按钮点击事件 private void OnSaveClick(object sender, RoutedEventArgs e) { var order = GetCurrentOrder(); SafeQueuePrompt( "订单{{OrderNo}}已保存,状态更新为{{Status}}", order, new PromptOptions { Priority = 10, // 高优先级 Volume = 80, Culture = "zh-CN" }); } }4.4 配置文件实战:多语言JSON模板详解
创建Prompts.zh-CN.json:
{ "LoginSuccess": { "text": "欢迎回来,{{UserName}}!", "preprocess": ["remove_punctuation", "add_pause_after_exclamation"] }, "InventoryLow": { "text": "警告:{{ProductName}}库存低于{{Threshold}}件,当前剩余{{CurrentStock}}件。", "preprocess": ["normalize_chinese_punctuation"] }, "NetworkError": { "text": "网络连接失败,请检查网络设置。", "preprocess": ["remove_control_chars"] } }对应的C#加载逻辑:
public class PromptLoader { public static Dictionary<string, PromptConfig> LoadFromJson(string culture) { var path = $"Prompts.{culture}.json"; if (!File.Exists(path)) throw new FileNotFoundException($"提示配置文件不存在: {path}"); var json = File.ReadAllText(path, Encoding.UTF8); return JsonSerializer.Deserialize<Dictionary<string, PromptConfig>>(json) ?? new Dictionary<string, PromptConfig>(); } } public class PromptConfig { public string text { get; set; } public string[] preprocess { get; set; } }4.5 运行时调试技巧:如何快速定位语音播放失败
语音问题最难调试,因为错误往往不抛异常,只是静音。我们总结了四步排查法:
第一步:检查TTS服务状态
在PowerShell中运行:
Get-WindowsOptionalFeature -Online -FeatureName "Speech-Creation" # 如果State是Disabled,需启用:Enable-WindowsOptionalFeature -Online -FeatureName "Speech-Creation" -NoRestart第二步:验证语音包可用性
在即时窗口(Immediate Window)中执行:
var synth = new SpeechSynthesizer(); synth.GetInstalledVoices().Count; // 应大于0 synth.GetInstalledVoices().First().VoiceInfo.Name; // 查看第一个语音名第三步:捕获底层COM错误
重写SpeechSynthesizer的SpeakCompleted事件,打印详细错误:
_synthesizer.SpeakCompleted += (s, e) => { if (e.Error != null) { Log.Error(e.Error, $"TTS播放完成异常: {e.UserState}"); } else if (e.Cancelled) { Log.Info($"TTS播放被取消: {e.UserState}"); } };第四步:录制原始音频流
临时启用音频录制,生成WAV文件分析:
// 在PlayPromptAsync中添加 using var stream = new MemoryStream(); await _synthesizer.SetOutputToWaveStream(stream); await _synthesizer.SpeakSsmlAsync(ssml); File.WriteAllBytes("debug_output.wav", stream.ToArray()); // 用于Audacity分析5. 常见问题与排查技巧实录:产线踩坑经验总结
5.1 典型问题速查表
| 问题现象 | 可能原因 | 解决方案 | 验证方式 |
|---|---|---|---|
| 语音完全不播放,无异常 | TTS服务未启用 | 运行OptionalFeatures.exe启用“语音识别”功能 | Get-WindowsOptionalFeature命令返回Enabled |
| 中文提示音变成英文发音 | 系统未安装中文语音包 | 下载并安装Microsoft Zhiyu或Huihui语音包 | synthesizer.GetInstalledVoices()返回空列表 |
| 提示音延迟3-5秒才开始播放 | 首次调用SpeechSynthesizer初始化耗时 | 预热:在程序启动时调用new SpeechSynthesizer().GetInstalledVoices() | 启动后首次播放耗时<100ms |
| 同一提示重复播放2次 | 事件订阅重复绑定 | 检查SpeakCompleted事件是否在每次创建引擎时都重新订阅 | 用+=前先-=解除旧订阅 |
| 播放中UI卡死 | 在UI线程直接调用Speak()同步方法 | 改用SpeakAsync()并确保不在DispatcherTimer中调用 | 用Visual Studio诊断工具查看UI线程占用率 |
5.2 那些文档里绝不会写的独家技巧
技巧1:用SSML强制修复数字读法
中文数字“123”默认读作“一百二十三”,但物流系统需要读成“一二三”。解决方案:
// 在PromptCompiler中添加 text = Regex.Replace(text, @"(\d{3,})", match => $"<say-as interpret-as=\"characters\">{match.Value}</say-as>");这样“运单号123456”就变成<say-as interpret-as="characters">123456</say-as>,语音引擎会逐字读出。
技巧2:静音期间的提示缓存策略
当用户开启“静音模式”,不能简单丢弃提示,否则重要告警会丢失。我们实现了一个MuteBuffer:
public class MuteBuffer { private readonly List<PromptItem> _buffer = new(); private readonly TimeSpan _maxAge = TimeSpan.FromMinutes(5); public void Add(PromptItem item) { item.Timestamp = DateTime.Now; _buffer.Add(item); // 清理超时项 _buffer.RemoveAll(x => x.Timestamp < DateTime.Now - _maxAge); } public List<PromptItem> Drain() => _buffer.ToList(); }静音关闭时,自动播放缓冲区中最紧急的3条提示。
技巧3:Win11上的音调漂移修复
Win11的TTS引擎在设置Pitch = -5时,实际音调比Win10低2个半音。解决方案是动态校准:
private int GetPitchOffset() { var osVersion = Environment.OSVersion.Version; if (osVersion.Major == 10 && osVersion.Build >= 22000) // Win11 return 2; // 补偿2个半音 return 0; }5.3 性能压测实录:单机支撑多少并发提示?
我们在工控机(Intel Celeron J1900, 4GB RAM)上做了压力测试:
| 并发线程数 | 平均延迟(ms) | CPU占用率 | 内存增长 | 是否稳定 |
|---|---|---|---|---|
| 10 | 42 | 12% | +8MB | 是 |
| 50 | 89 | 35% | +22MB | 是 |
| 100 | 156 | 68% | +41MB | 是 |
| 200 | 320 | 92% | +78MB | 否(GC频繁) |
结论:单机建议上限100路并发提示。超过此数需启用分布式队列(如Redis Stream),但要注意语音的实时性要求——网络延迟超过200ms,用户就会觉得“反应迟钝”。
5.4 安全边界提醒:永远不要信任业务方传入的提示文本
去年某次安全审计发现,业务方在提示模板里嵌入了<audio src="file:///C:/windows/system32/cmd.exe"/>,试图利用TTS引擎的SSML解析漏洞。虽然SpeechSynthesizer本身不执行audio标签,但为防万一,我们在PromptCompiler中加入了SSML白名单过滤:
private static readonly string[] AllowedSsmlTags = { "speak", "voice", "prosody", "break", "say-as", "sub" }; private static string SanitizeSsml(string input) { // 移除所有非白名单标签 return Regex.Replace(input, @"<(/?)(?!(?:" + string.Join("|", AllowedSsmlTags) + @"))\w+[^>]*>", ""); }最后分享个小技巧:在产线环境,我们会在语音提示开头加0.5秒静音(
<break time='500ms'/>),这样运维人员用音频分析软件抓包时,能清晰看到每个提示的起始位置,方便做播放成功率统计。这个细节让我们的语音可用率从92%提升到了99.7%。