资讯详情

资讯详情

C#桌面IM框架实战:扫码登录全流程与SignalR实时通信拆解

简介一套基于C#编写的微信客户端框架完整工程面向希望学习桌面端社交应用开发、扫码登录实现及C#网络编程的开发者。工程已实现微信扫码登录闭环覆盖二维码获取、授权校验、设备绑定等环节并包含完整UI界面与多线程/异步通信处理可作为二次开发或课程设计的直接参考。资源包19.81MB共321个文件以94个C#源文件、61个DLL库、46个PNG图片及24个resources/resx资源文件为主另有工程配置、可执行文件与调试符号等目录结构清晰便于按模块阅读。目前已有1979人学习下载。读者可从中了解WPF/Windows Forms界面布局、RESTful API调用、消息收发、本地状态存储等设计思路也可借鉴其错误处理、安全传输和模块扩展方式对进阶C#桌面开发与微信开放平台集成有实际帮助。1. 这个项目能干什么一套把扫码登录跑通的 C# 桌面 IM 框架做即时通讯的开发者都有这种体会扫码登录看着简单真正在自己的系统里完整跑通票据过期、轮询时机、状态同步、断线重连随便哪个环节都能卡上一星期。这个项目是一套用 C# 实现的类微信桌面 IM 框架服务端和 WPF 客户端一个不落扫码登录这条链路已经从生成二维码一路通到了进入主界面。它适合两类人想快速搭一套 IM 原型做课程设计或内部工具的人以及想研究 SignalR 实时推送和扫码登录状态机的 C# 开发者。框架骨架完整聊天消息、好友关系、登录态恢复都有落地实现拿它改比从零开始写轻松太多。2. 整体架构与分层服务端和客户端怎么分工拿到源码第一件事先别急着双击运行把目录结构过一遍。这个项目不是单文件的 Demo而是标准的前后端分离结构服务端负责所有业务逻辑和实时推送客户端只做界面展示和用户交互。2.1 技术选型为什么是 ASP.NET Core SignalR WPF服务端用 ASP.NET Core 是顺理成章的选择跨平台、部署简单.NET 8 的开发体验也好。实时消息用的是 SignalR它底层封装了 WebSocket在开发环境下还能自动降级到 Server-Sent Events 或 Long Polling本地联调时特别省心不用像裸 WebSocket 那样自己处理重连逻辑。数据访问用的 EF Core SQLite开发期零配置连数据库都不用装。客户端的选型是 WPF这是 C# 桌面端最成熟的 UI 框架做聊天列表、消息气泡这种界面非常顺手。MVVM 模式用的 CommunityToolkit.Mvvm比手写 INotifyPropertyChanged 简洁。二维码生成用的 QRCoder一个 NuGet 包搞定。模块技术方案定位服务端框架ASP.NET Core 8HTTP API 实时通信宿主实时通信SignalRWebSocket 封装消息推送数据访问EF Core SQLite零配置本地库客户端WPF CommunityToolkit.Mvvm桌面 IM 界面二维码QRCoder登录二维码生成身份认证JWT登录态签发与校验这套组合的好处是一台 Windows 开发机就能把整个系统跑起来不用额外装 Redis、MySQL 或者 RabbitMQ门槛压到了最低。真要说缺点SQLite 在消息量特别大的时候写入并发会吃力不过对学习项目和中小型内部工具完全够用。2.2 目录结构一眼分清服务端、客户端、共享层整个解决方案分成三个项目IM.Server、IM.Client、IM.Shared。Shared 项目很值得注意前后端公用的 DTO 和枚举都放这里两边引用同一个项目接口参数和返回类型就不会悄悄跑偏。src/ ├── IM.Server/ # 服务端项目 │ ├── Controllers/ # Web API 控制器 │ │ ├── LoginController.cs # 二维码签发、扫码状态、Token 下发 │ │ ├── UserController.cs # 用户资料与好友关系 │ │ └── MessageController.cs # 聊天记录与离线消息 │ ├── Hubs/ │ │ └── ChatHub.cs # SignalR 实时通道消息转发与登录回调 │ ├── Models/ # User / Friend / LoginTicket / Message │ ├── Data/ │ │ └── AppDbContext.cs # EF Core 数据上下文 │ ├── Services/ │ │ ├── TokenService.cs # JWT 生成与解析 │ │ └── QrCodeTicketService.cs # 二维码票据生命周期管理 │ └── Program.cs # 服务注册、中间件、路由映射 ├── IM.Client/ # WPF 客户端项目 │ ├── Views/ # LoginWindow / MainWindow / ChatWindow │ ├── ViewModels/ # LoginViewModel / MainViewModel / ChatViewModel │ ├── Services/ # ApiClient / HubClient / LocalSession │ └── App.xaml.cs # 启动入口读取本地登录态 └── IM.Shared/ # 前后端共享代码 ├── Dto/ # SendMessageDto / LoginResultDto / QrCodeDto └── Enums/ # LoginStatus / MessageTypeControllers 层的三个控制器职责很清晰LoginController 负责扫码登录全链路UserController 处理好友列表和用户资料MessageController 管历史消息和离线消息拉取。Hubs 只有一个 ChatHub但它同时承担两件事一是聊天消息的实时转发二是扫码登录成功后向特定客户端推送已确认事件。这里有个小设计值得学习登录确认事件走了 SignalR 而不是轮询响应速度比传统轮询快不少。客户端这边Views 和 ViewModels 一一对应Services 层把 HTTP 调用和 SignalR 连接封装成独立的 ApiClient 和 HubClient界面代码里不会直接出现 HttpClient 或 HubConnection 的细节后面想换接口地址或者改推送协议只动 Services 层就够了。2.3 本地运行三步从拉代码到看到登录二维码跑起来的顺序很简单三条命令。先启动服务端再启动客户端客户端启动后会自动弹出登录窗口如果服务端没起来窗口上会提示连接失败而不是直接崩溃。# 1. 启动服务端监听本机 5000 端口 cd src/IM.Server dotnet run --urls http://localhost:5000 # 2. 另开一个终端启动 WPF 客户端 cd ../IM.Client dotnet run # 3. 客户端窗口出现二维码后用手机浏览器扫码并进行确认--urls http://localhost:5000是 ASP.NET Core 的监听地址参数默认情况下 Kestrel 只监听 localhost这个参数可以显式指定端口。客户端里的 ApiClient 默认也指向这个地址如果你改了端口记得同步改 IM.Client 里 ApiClient 的 BaseAddress。有一点要提前知道第一次跑服务端代码会在启动时自动创建 SQLite 数据库文件不需要手动执行迁移命令。如果哪天你改了实体类想重新建库删掉 Data 目录下的.db文件重启服务端即可开发期这是最省事的做法。3. 核心模块拆解扫码登录从生成二维码到进入主界面的完整链路扫码登录是这个项目最值得读的模块它本质上是一个三方状态机PC 客户端、手机端、服务端各管一段。PC 客户端向服务端申请一个唯一的登录票据 ticket把 ticket 揉进二维码里展示出来手机端扫到二维码后调确认接口PC 端通过轮询或 SignalR 感知状态变化确认成功后拿 ticket 换 Token。3.1 服务端登录票据的生成与状态流转先看服务端怎么签发二维码。LoginController 里有一个生成接口核心逻辑是创建一个 LoginTicket 记录初始状态是 Waiting同时给票设置一个两分钟的过期时间。二维码内容不是 ticket 本身裸奔而是拼成一个自定义协议的字符串方便后续扩展参数。[HttpPost(api/login/qrcode)] public async TaskIActionResult CreateQrCode() { var ticket Guid.NewGuid().ToString(N); // 登录票据二维码的唯一标识 var now DateTime.Now; await _db.LoginTickets.AddAsync(new LoginTicket { Ticket ticket, Status LoginStatus.Waiting, // 0待扫码 1已扫码待确认 2已确认 3已过期 CreatedAt now, ExpireAt now.AddMinutes(2) // 二维码有效期两分钟 }); await _db.SaveChangesAsync(); var qrContent $imlogin://login?ticket{ticket}sourcepc; return Ok(new { ticket, expireAt now.AddMinutes(2), qrContent }); }这段代码看着简单有一个点容易被忽略LoginTicket 表里存的不是用户信息而是登录会话的凭证。真正常用的属性是 Ticket、Status、ExpireAt 这三个。Ticket 用 Guid 字符串保证全局唯一ExpireAt 控制二维码的有效期Status 记录当前的扫码进度。状态流转规则也很直接Waiting 是初始状态手机端扫码确认后变成 Confirmed如果超时或者用户取消则变成 Expired。整个流转在确认接口里完成。[HttpPost(api/login/confirm)] public async TaskIActionResult Confirm([FromBody] ConfirmRequest req) { var ticket await _db.LoginTickets .FirstOrDefaultAsync(t t.Ticket req.Ticket); if (ticket null || ticket.Status ! LoginStatus.Waiting) return BadRequest(二维码不存在或已失效); if (ticket.ExpireAt DateTime.Now) { ticket.Status LoginStatus.Expired; await _db.SaveChangesAsync(); return BadRequest(二维码已过期); } ticket.Status LoginStatus.Confirmed; ticket.UserId req.UserId; // 记录是哪个用户扫的 await _db.SaveChangesAsync(); // 通过 SignalR 实时通知 PC 客户端登录已确认 await _hub.Clients.Group($login_{ticket.Ticket}) .SendAsync(LoginConfirmed, ticket.Ticket); return Ok(new { success true }); }这里有个值得借鉴的场景二维码状态查询用的轮询但确认结果的通知走的是 SignalR 的 Group 推送。Group 名按 ticket 维度建每个二维码独享一个通道客户端连接时把自己加入这个 Group服务端确认成功后直接往组里发消息PC 端不用傻等轮询间隔。登录状态枚举在 IM.Shared 的 Enums 里定义前后端共用同一个枚举值枚举值名称含义0Waiting二维码已生成等待手机扫码确认1Scanned手机已扫码待用户点击确认2Confirmed用户已确认允许换取 Token3Expired二维码过期或已取消3.2 客户端二维码展示与轮询确认PC 端拿到接口返回的 qrContent 后用 QRCoder 在本地生成二维码图片然后开启一个轮询任务盯着状态变化。这部分是 WPF 的 LoginViewModel 在管。public partial class LoginViewModel : ObservableObject { [ObservableProperty] private ImageSource? qrCodeImage; private readonly ApiClient _api new(); public async Task LoadQrCodeAsync() { var qrDto await _api.GetQrCodeAsync(); // 请求服务端签发票据 QrCodeImage QrCodeGenerator.CreateQrCode(qrDto.QrContent); // 本地生成二维码 StartPolling(qrDto.Ticket, qrDto.ExpireAt); } private async void StartPolling(string ticket, DateTime expireAt) { while (DateTime.Now expireAt) { var status await _api.GetLoginStatusAsync(ticket); if (status LoginStatus.Confirmed) { await CompleteLoginAsync(ticket); // 状态已确认去换 Token return; } if (status LoginStatus.Expired) { ShowQrCodeExpired(); // 二维码过期提示刷新 return; } await Task.Delay(1500); // 每 1.5 秒查一次 } } }轮询间隔 1500 毫秒是个折中值。间隔太短会给服务端造成无谓的压力太长又会让用户扫完码后等很久才跳转。用Task.Delay而不是Thread.Sleep是因为前者不会阻塞 UI 线程等延时期间界面仍然可以响应点击。注意async void的用法这里只能这么写因为事件驱动方法不允许返回 Task但是内部一定要包 try-catch否则轮询中抛异常会直接让进程崩掉。3.3 登录态保持JWT 签发与本地恢复扫码确认通过后PC 端拿 ticket 去调用 Token 接口服务端校验票据状态后签发 JWT。这个 JWT 是整个会话期间客户端访问所有业务接口的凭证。public string GenerateToken(User user) { var tokenHandler new JwtSecurityTokenHandler(); var key new SymmetricSecurityKey( Encoding.UTF8.GetBytes(_config[Jwt:Key])); var tokenDescriptor new SecurityTokenDescriptor { Subject new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.NickName) }), Expires DateTime.Now.AddDays(7), // 有效期 7 天配合本地持久化 Issuer _config[Jwt:Issuer], Audience _config[Jwt:Audience], SigningCredentials new SigningCredentials( key, SecurityAlgorithms.HmacSha256) }; var token tokenHandler.CreateToken(tokenDescriptor); return tokenHandler.WriteToken(token); }Token 有效期设置 7 天是有意为之。桌面 IM 类应用要求关掉重开不用扫码如果 Token 太短用户每天都要重新登录体验割裂。配合客户端的 LocalSession 服务Token 首次获取后写入本地 JSON 文件下次启动时读到 Token 直接尝试连接 SignalR连接成功就进入主界面不再弹出登录窗。4. 消息收发与实时推送一条消息从发出到送达的完整路径扫码登录只是入口聊天才是 IM 的核心。这个项目的消息链路分成三段客户端 A 把消息发给服务端服务端落库后通过 SignalR 推给客户端 B客户端 B 收到后更新界面。整个过程里服务端扮演的是转发中枢的角色。4.1 SignalR Hub连接管理与会话分组ChatHub 是实时消息的入口。客户端登录成功后会建立 SignalR 连接然后调用 Hub 里的 Register 方法把自己注册到两个 Group一个是个人 Group按 UserId一个是好友关系相关的群组 Group。public class ChatHub : Hub { private readonly AppDbContext _db; public ChatHub(AppDbContext db) { _db db; } public async Task Register(Guid userId) { // 每个用户一个独立分组方便定向推送 await Groups.AddToGroupAsync(Context.ConnectionId, $user_{userId}); } public async Task SendMessage(SendMessageDto dto) { var msg new Message { Id Guid.NewGuid(), FromId dto.FromId, ToId dto.ToId, Content dto.Content, MsgType dto.MsgType, CreatedAt DateTime.Now }; _db.Messages.Add(msg); await _db.SaveChangesAsync(); // 单聊发给对方的个人分组 if (dto.ToType user) { await Clients.Group($user_{dto.ToId}) .SendAsync(ReceiveMessage, msg); } else // 群聊发给群分组 { await Clients.Group($group_{dto.ToId}) .SendAsync(ReceiveMessage, msg); } } }SendMessage 里有两个关键设计。第一消息先落库再推送保证服务端重启后消息不丢接收方离线时消息可以从离线表里拉回来。第二Clients.Group的用法区分了单聊和群聊单聊目标是对方的 user 分组群聊目标是 group 分组。如果你要扩展已读回执可以在这里拿到消息 Id 后再推一条MessageRead事件。连接管理上SignalR 自带的OnConnectedAsync钩子可以用来记录在线状态。项目中 BaseHubClient 在客户端启动时建立连接App 退出时DisposeAsync关闭连接。这里要注意一个坑SignalR 的 ConnectionId 每次重连都会变所以不能把 ConnectionId 当业务主键要始终用 UserId 维度的分组来做定位。4.2 消息存储与离线消息拉取每条消息都会实时写入 Message 表字段包括发送人、接收人、消息类型、内容和时间。离线消息逻辑在 MessageController 里接收方上线后去拉取最近 100 条未读消息。[HttpGet(api/message/offline)] public async TaskIActionResult GetOfflineMessages(Guid userId) { var messages await _db.Messages .Where(m m.ToId userId !m.IsRead) .OrderBy(m m.CreatedAt) .Take(100) .ToListAsync(); // 标记为已读避免下次再拉一遍 var ids messages.Select(m m.Id).ToList(); await _db.Messages .Where(m ids.Contains(m.Id)) .ExecuteUpdateAsync(s s.SetProperty(m m.IsRead, true)); return Ok(messages); }关于离线消息的拉取时机常见做法是先拉离线再连推送。客户端进入主界面后立刻调用这个接口拿到离线消息同时建立 SignalR 连接接收实时消息。这样不会漏消息也不会出现实时消息已到但离线消息还在拉的时序错乱。排序和条数限制也有讲究。OrderBy(CreatedAt)保证时间线不乱Take(100)防止某个用户离线太久积压几千条消息一次拉爆内存。如果你的场景需要更严谨的消息完整性协议层可以再加递增的 sequence 字段做去重不过目前这个项目用 Guid 主键加时间戳排序已经够用了。4.3 客户端收发消息的 MVVM 写法WPF 这边ChatViewModel 持有消息集合和 SignalR 连接收到推送后把消息加入集合。这里最需要注意的是界面刷新必须回到 UI 线程。public partial class ChatViewModel : ObservableObject { [ObservableProperty] private ObservableCollectionMessageDto messages new(); private HubClient _hub; public void Initialize(Guid userId, Guid friendId) { _hub new HubClient(); _hub.OnMessageReceived message { // SignalR 回调可能在线程池线程需要切回 UI 线程 App.Current.Dispatcher.Invoke(() { Messages.Add(message); }); }; _hub.StartAsync(); } }App.Current.Dispatcher.Invoke这行是 WPF 开发里血泪换来的经验。SignalR 的消息回调到达时并不在 UI 线程上直接操作 ObservableCollection 会抛调用线程无法访问此对象的异常。用 Dispatcher 把更新动作封送到 UI 线程这也是 MVVM 模式下做实时界面的标准姿势。5. 避坑指南跑通和改造时最常见的 5 个问题这章列的都是实际运行这个项目时大概率会碰到的问题每条都按现象、原因、解决给出来基本覆盖了最常见的翻车点。5.1 现象一本地跑起来后 SignalR 一直连接失败现象服务端正常启动客户端也能拉到二维码但主界面的在线状态一直是连接中ChatHub 日志里不断报 WebSocket 连接失败。原因SignalR 在开发环境下默认尝试 WebSocket但本地直接用 http不是 https时某些浏览器和客户端组件对混合内容或 ws 协议有限制。另外如果客户端地址没配对SignalR 的 negotiate 请求也会失败。解决在 Program.cs 中显式配置 SignalR 的回退机制允许用 Server-Sent Events 或 Long Polling。代码里加一句options.Transport HttpTransportType.LongPolling或者把 WebSocket 放在传输列表最后。如果是跨端口访问还要在 MapHub 时配置AllowedOrigins允许客户端的源地址访问。5.2 现象二扫码确认后客户端没反应日志里没有报错现象手机扫码后点了确认服务端接口返回成功但 PC 端的登录窗一直停在已扫码等待确认的状态等很久才超时。原因排除了接口问题后十有八九是 SignalR 的 Group 没对上。客户端在获取二维码时没有先加入login_{ticket}这个 Group或者加入的是旧 ticket 的 Group服务端推消息时自然找不到目标连接。解决确认客户端在调用GetQrCodeAsync拿到 ticket 后立刻用同一个 ticket 调 SignalR 的 JoinLoginGroup 方法。注意 Join 动作必须在服务端返回二维码内容之后再触发否则 ticket 还没落库Group 创建会失败。5.3 现象三重启客户端后又要重新扫码Token 恢复失效现象明明 Token 有效期设置了 7 天关掉客户端再打开还是回到了登录窗口。原因LocalSession 服务读取本地 JSON 时的路径不对或者 Token 保存时压根没写进文件。常见的情况是写文件用了相对路径启动目录一变文件就找不到了。解决用绝对路径存 TokenPath.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), im, session.json)。启动时先判断 Token 是否为空再调一次CurrentUser接口验证 Token 是否有效无效才回退到登录页。5.4 现象四数据库迁移或实体变更后启动报错现象改了 User 实体加了一个字段重启服务端直接抛异常提示 SQLite 表结构不匹配。原因EF Core 在 SQLite 上不会自动做列变更。开发期你改了实体数据库还是旧结构运行时查询就会报no such column之类的错误。解决开发期最简单的方法就是删除旧的 .db 文件让服务端重建但如果库里已经有测试数据不想丢就要用迁移命令dotnet ef migrations add AddUserAvatar生成迁移脚本后再dotnet ef database update。5.5 现象五消息一多主界面卡顿明显现象聊天记录超过几百条之后滚动消息列表开始掉帧窗口拖拽都变卡。原因WPF 的 ListBox 默认对所有可见和不可见项都创建 UI 容器消息一多内存和渲染开销大幅上升。这是 WPF 虚拟化没生效的经典症状。解决改用 ListBox 并开启虚拟化关键是不要在 ItemsControl 外面套 ScrollViewer否则虚拟化会被禁用。如果消息项里图片很多还要考虑延迟加载缩略图而不是一股脑把原图塞进 UI。6. 二次开发把框架改成你自己的 IM改这个项目我建议从消息类型扩展入手这是成本最低、效果最明显的一步。当前 Message 的 MsgType 是 int基础代码里只处理了 text但枚举值本来就预留了空间。扩展图片消息时客户端 SendMessageDto 里加一个 MediaUrl 字段服务端收到非 text 类型时把 Content 存成 URL接收方 UI 根据 MsgType 切换气泡模板一套逻辑半小时能改完。数据库换型也是常见需求。项目用的是 SQLite想换 MySQL 或 SQL Server 只动注册部分AddDbContext里的UseSqlite换成UseMySql或UseSqlServer同时调整连接字符串。EF Core 的好处是实体映射不用改LINQ 查询语句不用改换完数据库再跑一次迁移即可。多端同步是第三个改造方向。当前架构里一个账号只支持一个 PC 端在线SignalR 连接建立时如果检测到同一 UserId 已有连接可以把旧连接踢下线。要支持多端共存则把 Register 里的 Group 策略从一人一组改成一人多设备组消息推送时同时发给该用户的所有设备。这一步涉及连接管理和消息去重建议作为二期改造来规划。真正做完一遍这些改造我才体会到这个框架最值钱的地方不是某一个功能而是把登录态、实时通道、离线消息、界面绑定这四件事串成了一个可以跑的闭环。从那以后我每次启动新项目都强制自己先打通一个最小闭环再填功能血泪经验告诉我闭环通了后面全是加字段的体力活闭环不通功能再全也是黑匣子。希望这份拆解能帮你把这个框架跑通也少走我当年走过的弯路。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →