.NET桌面应用自动更新:基于JSON配置的完整实现方案
发布时间:2026/9/16 4:28:44 作者:尧图编辑部 阅读量:1,286

做桌面应用的兄弟应该都有过这种经历客户报了一个 bug你连夜修完然后一个个在群里发安装包让人家手动下载覆盖安装。你以为用户都装上了过两天一查后台日志还在绕着你昨天修复的崩溃点打转。非技术用户根本没有“我要更新”的意识你让他删掉旧版本重装他能问出三百个为什么。这时候你需要的是一个能自己更新的桌面程序。这篇文章我会完整讲一遍我在 .NET 桌面应用里基于 JSON 配置做自动更新的思路从 JSON 配置结构设计、版本号处理、文件校验到客户端检查更新、下载、独立启动器替换主程序的完整流程最后还附带我实际踩过的坑和排查方法。无论是 WinForms 还是 WPF这套方案都能直接拿去改。1. 整体设计思路为什么用 JSON 配置来驱动更新1.1 更新程序的本质先告诉客户端“有什么”再告诉它“怎么拿”自动更新听起来很神秘拆开其实就三件事知道有没有新版本、把新文件拿下来、把旧文件换掉。第三个“换文件”最麻烦因为程序正在运行的时候exe 和 dll 文件通常是被锁定的不能用 Process 直接覆盖所以大部分成熟方案都会引入一个独立的 Updater 进程来干这件事。前两件事的关键在于“知道有没有新版本”这一步。客户端必须从一个固定位置拿到一份描述“当前最新版本”的信息而这份信息用什么格式、包含哪些字段直接决定了后续所有代码怎么写。我用 JSON没有用 XML也没有用自定义的 ini。原因很简单JSON 人类可读、调试方便、跨语言解析成本低而且 .NET 从 Core 3.0 以后内置了System.Text.Json序列化性能好、零额外依赖不需要引第三方 NuGet 包。更重要的是JSON 配置可以由更新服务器、CI 流水线或者一个简单脚本自动生成。我见过有人用数据库接口返回更新信息也可以但桌面应用要的是“简单可靠”一个部署在静态托管上的 JSON 文件比维护一套后端接口省心得多。1.2 一次完整的更新动作是怎样发生的整个更新链路大致是这样走的主程序启动后在一个后台线程里请求更新服务器上的update.json。客户端解析 JSON取出version字段和当前程序集版本号比较。没有新版本直接结束更新流程有新版本则根据force字段决定是弹窗提示用户还是强制更新。按需下载文件列表里的所有文件到本机临时目录。对每个文件计算 SHA256与配置里的哈希值比对不一致就重新下载。下载全部通过后拉起一个独立的 Updater 进程主程序退出。Updater 等待主进程完全退出把临时目录里的文件移动到安装目录替代旧文件。启动主程序更新完成。第 6 到第 8 步是最容易出现问题的阶段。很多人第一次做更新试图在主程序里直接覆盖自身 exe结果在 Windows 上遇到“另一个程序正在使用此文件”的报错。所以我的方案里始终有一个独立 Updater它是一切的兜底。1.3 三个设计决策全量包、更新策略、独立 Updater第一个决策是全量更新还是增量更新。桌面应用更新最稳的方案是下载完整的新版包因为增量更新要处理文件差异、补丁应用失败、跨版本跳跃等一系列头疼的问题。我早期做过一个按文件列表更新的方案服务端只发布变更过的文件客户端逐个下载看起来省流量但一不小心出现文件遗漏、版本混合用户机器上的状态就变得不可预期。现在我的做法折中了一下JSON 里写完整文件清单实际发布时打包成一个 zip 文件客户端下载 zip 后解压替换。这样既有整包一致的优点传输上又比逐个小文件高效。第二个决策是更新策略。我一般区分三种情况普通更新用户点“以后再说”就跳过强烈建议更新每次启动都提醒但允许跳过强制更新弹窗不可关闭必须更新才能继续用。这个策略完全由 JSON 里的force字段和本地“跳过次数”控制。强制更新主要用于后端接口协议不兼容、数据库结构变更、或安全漏洞修复这类场景。第三个决策是独立 Updater 进程。Updater.exe 体积很小在发布时就和主程序放在一起。它干的事非常单一等待主程序退出、覆盖文件、重新启动主程序。因为职责单一它自己几乎不需要更新所以不容易陷入“更新器也要更新自己”的递归陷阱。2. 核心细节解析JSON 配置结构、版本号与文件校验2.1 JSON 配置字段设计一个能跑起来的清单我实际使用的更新配置大概是这个样子的给你一份可以直接抄走改的版本{ version: 1.2.3.0, minVersion: 1.0.0.0, force: false, updateType: zip, packageUrl: https://update.example.com/MyApp/1.2.3.0/update.zip, packageSize: 10485760, packageHash: e5c7a9c1f2c7b0d1be6b8baf05e6e4b2c8e0f1a3d4f5a6b7c8d9e0f1a2b3c4d5, releaseNotes: 1. 修复启动崩溃问题\n2. 优化内存占用, releaseDate: 2025-01-15T10:00:00Z, channels: [stable] }字段设计时要考虑这些东西version服务端最新版本号四段整数和System.Version直接对应。minVersion最低可更新版本。如果客户端版本比minVersion还低说明版本太老可能需要强制更新或者直接提示用户重新安装。force是否强制更新。true 时客户端不显示“跳过”按钮。updateType更新包类型。我用zip也可以扩展成fileList或者不同类型的 macOS/Linux 包。packageUrl更新包下载地址。packageSize压缩包大小用于显示进度和校验。packageHash压缩包的 SHA256下载完先校验整个包解压后再对关键文件做二次校验。releaseNotes更新说明可以带换行符在弹窗里展示。channels更新通道。这个字段可以根据需要扩展比如stable、beta客户端通过命令行参数或本地配置选择通道。为什么把文件列表换成整包首要原因是在多文件更新时要保证所有文件来自同一个发行版本。如果做一个文件一个 URL 的清单配置更新服务器上不同 CDN 节点缓存不一致就可能出现一半新文件一半旧文件。整包下载天然规避了这个问题。2.2 版本号比较四段整数的坑与解法版本号比较看起来简单实际上有暗坑。如果你用string.Compare(1.10.0, 1.9.0)结果会认为1.9.0更大因为字符串比较是从左到右逐字符比的1.1比1.9小。所以版本比较必须转成数值类型。.NET 里直接有System.Version类型它支持四个整数段Major、Minor、Build、Revision。我要求配置里的version必须是四段整数然后客户端这样比较Version serverVersion Version.Parse(manifest.Version); Version localVersion typeof(App).Assembly.GetName().Version; int cmp serverVersion.CompareTo(localVersion); if (cmp 0) { // 服务端版本比本地新可以更新 }但有一个注意点Version的CompareTo方法对缺失段位的版本号默认按 0 处理也就是说1.2和1.2.0.0是相等的。这看起来没什么但如果你的发布脚本从 git tag 生成版本号偶尔少写了一两位就会导致客户端永远认为“本地版本大于等于服务端版本”从而不更新。我的建议是在任何环节都强制生成四段版本号少一段就直接拒绝构建。另外如果你以后计划做语义化版本号比如1.2.3-beta.1System.Version就帮不上忙了需要自己解析预发布标识。桌面应用一般不建议在自动更新里引入预发布版本除非你专门做 beta 通道否则只会增加复杂度。2.3 哈希校验防止文件损坏和替换不完整哈希校验我放在两层下载完整压缩包后先校验packageHash解压安装时对关键可执行文件再校验一次。前者保证压缩包在传输过程中没有损坏后者防止压缩包内部文件缺失或构建机上传时出错。SHA256 的计算代码很简单using System.Security.Cryptography; public static string ComputeSha256(string filePath) { using var stream File.OpenRead(filePath); using var sha256 SHA256.Create(); byte[] hash sha256.ComputeHash(stream); return Convert.ToHexString(hash).ToLowerInvariant(); }校验逻辑我通常放在下载完成后string actualHash ComputeSha256(updateZipPath); if (!string.Equals(actualHash, manifest.PackageHash, StringComparison.OrdinalIgnoreCase)) { File.Delete(updateZipPath); throw new Exception($更新包校验失败期望 {manifest.PackageHash}实际 {actualHash}); }有一种外界干扰情况是杀毒软件拦截正在下载的 exe 或 zip导致文件被截断或隔离哈希自然对不上。遇到这种问题不能轻描淡写地认为“重试就好”要在日志里记录具体的失败文件名、路径和目标目录权限方便后续排查。3. 服务端与资源组织更新包怎么放、配置怎么生成3.1 更新文件怎么托管静态文件服务就够了自动更新的服务端不需要复杂的业务逻辑它本质上是一个静态文件托管加一个 JSON 文件。你完全可以把update.json和update.zip丢到一个 Nginx 目录、阿里云 OSS、腾讯云 COS、GitHub Releases甚至内网共享目录里只要有 HTTP GET 能力就行。我个人推荐优先用云对象存储加 CDN。原因有两个一是带宽成本可控二是有现成的刷新缓存能力。更新包不像网页要高频访问它只在更新时被下载一次流量不大但是一旦发生强制更新大量客户端会同时去拉同一个包没有一个能扛住瞬时并发的静态服务会很尴尬。更新目录结构我习惯按版本号隔离/MyApp/ update.json packages/ 1.2.3.0/ update.zip这样update.json永远指向最新版本但历史版本的安装包仍然可以被手动下载。万一新版本发布后发现重大故障可以直接把update.json回退指向旧版本客户端理论上可以“回滚到旧版”前提是旧版安装包没有被清理。3.2 配置文件的自动生成脚本扫描构建产物手动维护update.json是反人类设计迟早会漏改版本号。我建议把这一步集成到构建发布流程里用脚本扫描构建产物自动生成配置。我写过一段简单的 PowerShell 脚本流程是读取构建产物目录里的主 exe 版本号对 update.zip 计算 SHA256然后把结果写进update.json。伪代码如下$exePath .\dist\MyApp.exe $zipPath .\dist\update.zip $version (Get-Item $exePath).VersionInfo.FileVersion $hash Get-FileHash $zipPath -Algorithm SHA256 $updateJson { version $version minVersion 1.0.0.0 force $false updateType zip packageUrl https://update.example.com/MyApp/packages/$version/update.zip packageHash $hash.Hash.ToLower() releaseNotes $env:RELEASE_NOTES } | ConvertTo-Json $updateJson | Set-Content .\dist\update.json -Encoding UTF8这个脚本跑完后你只需要把dist目录里的update.zip和update.json上传到对象存储对应位置。版本号、哈希值全部由脚本生成人工干预越少越好。这里有个细节JSON 文件编码尽量用 UTF-8 无 BOM。Windows PowerShell 5.1 的Set-Content -Encoding UTF8会写入 BOM某些.NET下的HttpClient解析没问题但一些早期的 XML 组件或纯字符串解析器可能会多出一个\uFEFF字符导致反序列化报“missing field”之类的错误。如果遇到奇怪的 JSON 解析问题先看文件有没有 BOM。3.3 安装目录与临时目录的路径规划路径规划是整个更新方案里容易被忽略但非常重要的一环。按用户安装方式不同主程序可能装在C:\Program Files\MyApp、C:\Users\xxx\AppData\Local\MyApp或自定义目录。安装目录通常有写权限限制尤其是Program Files普通用户无权写入。所以更新操作不能由主进程直接执行这也是独立 Updater 存在的另一个原因它可以向用户请求 UAC 提权或者更简单的方式是让程序安装成 per-user 模式安装在LocalAppData下这样普通权限就能覆盖文件。我建议桌面应用优先选择 per-user 安装目录就是“安装到当前用户”而不是“安装到所有用户”。如果你用 MSIX 打包那另说如果只是 NSIS/Inno Setup 做安装包默认给当前用户安装能省掉大量权限相关的坑。更新临时目录我放在Path.GetTempPath()下的一个独立子目录比如var tempDir Path.Combine( Path.GetTempPath(), MyAppUpdater, Guid.NewGuid().ToString(N) ); Directory.CreateDirectory(tempDir);不要直接往安装目录写临时文件否则杀毒软件和权限问题会放大十倍。用 GUID 子目录是为了避免多个实例同时下载时互相覆盖。更新成功后这个临时目录由 Updater 清理失败时也不要立即删除保留现场方便排查在下次成功更新后再清理过期临时目录。4. 客户端实现检查更新、下载、解压与启动器替换4.1 检查更新模块HttpClient 拉取配置并反序列化更新配置文件的反序列化使用内置的System.Text.Json就够了我一般定义一个和 JSON 字段对应的模型类public class UpdateManifest { public string Version { get; set; } public string MinVersion { get; set; } public bool Force { get; set; } public string UpdateType { get; set; } public string PackageUrl { get; set; } public long PackageSize { get; set; } public string PackageHash { get; set; } public string ReleaseNotes { get; set; } public string ReleaseDate { get; set; } public Liststring Channels { get; set; } }检查更新的代码核心是下面这一段我加了超时控制和 HTTP 状态码判断using var httpClient new HttpClient(); httpClient.Timeout TimeSpan.FromSeconds(15); httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(MyAppUpdater/1.0); try { using var response await httpClient.GetAsync( manifestUrl, HttpCompletionOption.ResponseContentRead, cancellationToken ); response.EnsureSuccessStatusCode(); string json await response.Content.ReadAsStringAsync(cancellationToken); var manifest JsonSerializer.DeserializeUpdateManifest( json, new JsonSerializerOptions { PropertyNameCaseInsensitive true } ); return manifest; } catch (Exception ex) { // 网络异常不能影响主程序启动必须吞掉并记录日志 Logger.LogWarning(检查更新失败: {0}, ex.Message); return null; }检查更新失败的策略要非常克制。我在很多方案里都强调更新检查是辅助功能不能因为更新服务器的网络故障连主程序都打不开。所有异常必须捕获记日志然后静默忽略绝不能让用户感觉“这个软件一打开就报错”。如果你要展示更新失败提示也应该是非阻塞的不打断主流程。4.2 下载更新包与进度显示下载更新包我建议直接用HttpClient.GetAsync配合流写入文件而不是DownloadFileTaskAsync。原因是可以自己做超时、支持取消还能获取下载进度。关键点是设置HttpCompletionOption.ResponseHeadersRead这样能一边接收响应头一边开始写文件而不是等整个响应都缓冲完才动手public async Taskbool DownloadPackageAsync( string url, string destPath, IProgressdouble progress, CancellationToken ct) { using var response await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); response.EnsureSuccessStatusCode(); long totalBytes response.Content.Headers.ContentLength ?? -1; await using var sourceStream await response.Content.ReadAsStreamAsync(ct); await using var targetStream new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None); var buffer new byte[81920]; long totalRead 0; int read; while ((read await sourceStream.ReadAsync(buffer, ct)) 0) { await targetStream.WriteAsync(buffer.AsMemory(0, read), ct); totalRead read; if (totalBytes 0) { progress?.Report(Math.Min(1.0, (double)totalRead / totalBytes)); } } return totalBytes 0 || totalRead totalBytes; }8KB 到 80KB 的缓冲区是性能与内存占用比较平衡的区间。缓冲区太小会导致频繁 IO 调用太大则内存浪费。实测 80KB 是个稳妥值。下载完成后我把这个过程放到一个单独的DownloadAndVerifyAsync方法里先校验哈希再决定是继续安装还是删除重来。校验不通过时最多重试三次三次都不行就放弃本次更新并提示用户检查网络或安全软件。4.3 独立 Updater 启动器覆盖被占用的文件现在讲整个方案里最核心的部分怎么把“正在运行的主程序”替换成新版本。由于主程序进程在运行exe 和所有已加载的 dll 文件都被锁定无法直接覆盖。我的方案是用一个小的命令行程序Updater.exe来完成替换。流程是这样的主程序把下载好的update.zip复制到一个固定的 staging 目录比如C:\Users\xxx\AppData\Local\MyAppUpdater\staging\update.zip。主程序以参数形式启动Updater.exe例如Updater.exe --target C:\Users\xxx\AppData\Local\MyApp --source C:\Users\xxx\AppData\Local\MyAppUpdater\staging\update.zip --mainExe MyApp.exe --backup C:\Users\xxx\AppData\Local\MyAppUpdater\backup主程序收到启动命令后立即退出Environment.Exit(0)不要等 Updater 的结果。Updater 启动后先等待主程序进程完全结束。这一步轮询Process.GetProcessesByName(MyApp)直到返回空数组超时时间设为 30 秒。确认主程序退出后Updater 把整个安装目录复制一份到 backup 目录如果 backup 存在就删了再复制保证可以回滚。解压update.zip到 staging 解压目录然后把安装目录里除Updater.exe和备份文件之外的所有文件删除把新文件一一移动过去。最后用Process.Start启动新的MyApp.exe然后 Updater 自身退出。有一个很关键但容易忽略的点Updater.exe必须放在一个不会被替换的位置。如果它也被更新流程覆盖在替换途中文件被占用或不完整整个机制立刻崩掉。所以我的发布脚本里会明确把Updater.exe排除在update.zip之外它始终保持版本独立。这段 Updater 的核心代码逻辑大致如下// 等待主进程退出 for (int i 0; i 60; i) { if (Process.GetProcessesByName(mainProcessName).Length 0) break; Thread.Sleep(500); } // 备份旧版本 if (Directory.Exists(backupDir)) Directory.Delete(backupDir, true); Directory.Move(targetDir, backupDir); Directory.CreateDirectory(targetDir); // 解压新版本 ZipFile.ExtractToDirectory(zipPath, stagingDir); // 移动文件 foreach (var file in Directory.GetFiles(stagingDir, *, SearchOption.AllDirectories)) { string relativePath Path.GetRelativePath(stagingDir, file); string dest Path.Combine(targetDir, relativePath); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Move(file, dest, true); } Process.Start(Path.Combine(targetDir, mainExeName));实际要处理的东西肯定不止这些比如目标目录里可能有用户生成的配置或数据文件替换时不能全删掉还有多个子目录需要单独排除或保留。我在做的时候会维护一个“排除清单”比如logs/、userdata/、Updater.exe这些路径在删除旧文件时跳过。4.4 强制更新、跳过更新与灰度发布强制更新不能依赖客户端自觉它必须发生在主程序的关键逻辑之前。做法是在主程序初始化阶段就调用更新检查如果force true并且版本号确实比本地新就进入一个模态窗口没有关闭按钮只有“立即更新”按钮。用户更新完成才继续进入主界面。普通更新则要允许用户跳过。我在本地AppSettings.json里记录用户点击跳过的次数和最后跳过版本{ SkippedVersion: 1.2.3.0, SkipCount: 3 }如果用户跳过次数超过三次下次启动就不给“跳过”按钮了直接引导更新。这个策略能在“不打扰用户”和“保证用户用新版本”之间折中。灰度发布我通常通过channels字段和独立的 manifest URL 来做。比如update-stable.json和update-beta.json两个配置客户端启动参数或全局设置里指定走哪个通道。稳定版通道只在确认 beta 版本运行稳定之后才把version提上去。这套机制不复杂却能在发布后问题没暴露之前把影响面控制住。5. 常见问题与排查技巧实录5.1 文件被占用导致替换失败这是自动更新里出现频率最高的错误。症状是 Updater 在删除旧文件时抛出UnauthorizedAccessException或IOException: The process cannot access the file because it is being used by another process。常见原因有三个主程序退出不彻底、有隐藏子进程没有结束、杀毒软件在扫描刚释放的文件。排查方法是在 Updater 里先枚举所有候选进程再查看目标目录下还有哪些文件被占用。Windows 上最简单的定位工具是handle.exe但生产环境不能指望用户装这个。我更推荐在代码里提前规避主程序收到更新指令后先停止后台线程、关闭文件句柄再调用Environment.Exit(0)Updater 端等待进程退出时不要只查主进程名还要检查有没有同名进程或子进程残留。还有一个隐蔽问题如果主程序是以当前用户权限运行的而安装目录在C:\Program Files下Updater 提权重启后可能因为权限不一致无法访问旧文件。所以我前面才强调最好 per-user 安装。如果必须装 Program Files那么更新流程必须设计成“先降权或先拷贝到可写临时目录”而不是直接提权覆盖。5.2 HTTP 缓存导致永远检查不到新版本发布了一个新版本客户端却一直提示“已是最新版本”日志里显示的 server version 还是老版本。这也算我见过的一个高频问题。原因通常是更新服务器或中间代理对update.json做了缓存。静态托管服务一般会自动带上ETag和Last-Modified响应头但如果你用 CDN默认缓存策略可能是缓存几分钟甚至更久。解决办法有两个层面一是发布时主动刷新 CDN 的缓存条目二是在客户端请求update.json时加一个查询参数比如?t当前本地版本号或?tUnix时间戳强制绕过缓存var url ${manifestUrl}?t{DateTimeOffset.Now.ToUnixTimeSeconds()};这个办法简单粗暴但有效。更新包本身也可以加版本参数但没必要因为哈希校验已经保证了内容正确性缓存的主要矛盾在update.json上。5.3 版本号比较错误导致永远不更新或反复更新如果不更新问题排查到这里多半是版本比较逻辑写错了。我说过最容易踩的坑是用字符串比较此外还有一个低概率但很恼人的坑程序集版本号里有AssemblyVersion和FileVersion两个属性它们可能不一致。如果你用FileVersionInfo.FileVersion读版本号而构建脚本从AssemblyVersion生成 JSON两边口径不一样就可能出现“配置里是 1.2.3.0实际程序集版本是 1.2.3.1”的错位。我建议全链路统一使用FileVersion或统一使用AssemblyVersion在发布脚本里强制两个字段来自同一个变量。如果版本号本身没问题但客户端反复提示更新说明客户端本地记录的已更新版本号没有被持久化或主程序启动后没有把当前版本写到注册表/AppSettings。这种情况每次启动都会认为自己是旧版本无限更新。更新完成后的主程序启动逻辑里要执行一步“写入当前版本号到本地记录”下次检查时以此为基准。5.4 安全软件拦截和网络代理问题桌面应用自动更新几乎绕不开杀毒软件的“特殊照顾”。下载一个 zip 再解压里面出现 exe某些杀毒软件会直接隔离或拦截。这个没有百分百绕过的方案因为任何绕过杀毒的操作都等于在帮恶意软件干活。我的做法是从第一天就给所有 exe、dll、zip 做 Authenticode 签名更新流程里校验签名。下载完成、解压完成后对关键 exe 验证AuthenticodeSignature确保文件确实由你的证书签名。主动把安装目录加入杀毒软件白名单这一步可以写在安装流程里尊重用户选择。更新包的哈希校验可以帮你识别“文件被病毒隔离或篡改”的情况哈希不一致时不要硬上提示用户检查。网络代理问题则经常发生在企业内网或用户本机有代理的场景。HttpClient默认会走系统代理如果你没有设置HttpClientHandler.Proxy在一些特殊网络环境里请求会超时或返回 403。我的处理是更新模块继承 IE 代理设置同时增加一个超时值不能影响主程序启动。配置项里还可以放一个可选的proxy字段支持用户手动设置。6. 一些掏心窝的实操建议这套方案不是一蹴而就的第一版我也做得特别粗糙没有独立 Updater直接在主进程里尝试替换 exe结果不用说各种“文件被占用”。做完整版之后我最大的体会是自动更新的技术难点不在“下载”而在“替换失败后怎么让程序还能用”。所以从一开始就保留 backup 目录和清晰日志比任何花哨功能都重要。我的建议是第一次做自动更新不要追求增量更新、断点续传、多通道并行这些高级能力。先把“检查 JSON 配置—下载整包—校验哈希—独立 Updater 替换—失败回滚”这条主链路跑通发布一两个真实版本把日志收集做好再考虑优化。断点续传虽然体验好但对服务端和客户端都有额外要求增量更新更是需要充分考虑兼容性属于进阶玩法。另外更新服务器的日志一定要记录。每次客户端请求update.json和下载更新包都要能够看到有多少请求、多少成功、多少失败。如果你的用户量不大哪怕只是 Nginx 的 access log 也够用。没有真实数据支撑你永远不知道你的 200KB 更新包在用户网络里下载需要多久也不知道有多少人的机器被杀毒软件拦在更新之外。最后说一个小教训我遇到过客户在弱网环境下更新到一半断网主程序已经被 Updater 退出重启后程序目录不完整无法启动。最后靠 backup 目录还原才救回来的。所以回滚机制不是可选项是必须项。你可以在 Updater 启动前判断 backup 目录是否可用在主程序下一次启动时自动检查安装目录关键文件是否完整不完整就提示用户恢复到上一个可用版本。别等到线上翻车再补这个功能。