简介:面向具备一定C#基础、希望练习Windows Forms桌面开发与即时通讯界面的开发者,这套仿QQ聊天IM项目提供可在Visual Studio中打开运行的完整源代码,涵盖登录、好友列表、聊天窗口、消息收发等典型模块。读者可借此理解控件布局、事件驱动、数据绑定、Socket/TCP网络通信、消息序列化、多线程与在线状态管理等实现思路,并在此基础上进行二次开发与调试。压缩包共413个文件,约8.34MB,以png界面素材、cs源码、resources与resx资源、dll依赖、exe及db数据文件等为主,兼顾界面资源、工程配置和可运行程序。已有2214人学习下载,适合用作课程设计、毕业项目参考或桌面IM功能拆解练习,帮助读者从UI搭建到通信流程形成较完整的实践认知。
1. 一个聊天窗口能有多难:难在你得自己当一回 UI 框架
很多人第一次接手 C# 仿即时通讯聊天界面的活儿,心态都一样:左边一列好友、中间一列会话、右边一片消息区、底下搁个输入框,拖几个控件半天就能收工。真动手才发现,WinForms 自带的 ListBox 撑不起一个气泡消息流,TextBox 做不出图文混排,消息堆到几百条界面就开始闪,窗口一拖动还带着一圈白边。这份 C# 仿经典桌面 IM 聊天界面的源代码,价值就在这里:它把无边框窗体、自绘气泡、圆角头像、主题换肤这些平时要翻七八篇零散文章才能凑齐的东西,装进了一个能直接按 F5 跑起来的完整工程里。它适合两类人——想补齐 WinForms 自绘这门手艺的新手,以及要给内部工具嵌一个像样聊天面板的老手。
2. 工程骨架:WinForms + GDI+ 撑起的三栏聊天窗口
拿到源码先别急着点运行,先看工程怎么分层。这类界面项目最怕的就是把所有绘制逻辑写进MainForm,一个MainForm.cs两千行,改个气泡颜色要翻半天。这份工程的做法是把「窗体」「控件」「模型」「服务」切开,各管一摊。
2.1 技术栈与目录结构:三个文件夹管住界面、模型和皮肤
技术栈上没什么冷门货:.NET Framework 4.7.2 或 .NET 6 的 WinForms,绘图全靠System.Drawing(GDI+),没有引入第三方 UI 库。这个选择很实际——自绘 IM 界面一旦上了第三方皮肤库,出了问题你既不懂自己的代码也不懂库的代码,卡在中间最难受。配置和本地缓存的地方,常见做法是挂一个Newtonsoft.Json做主题读取,用 SQLite 或 LiteDB 存历史消息;如果只是想跑通界面,这两样可以完全不要。
目录大致长这样:
IMUI/ ├─ Forms/ │ ├─ LoginForm.cs 登录窗口,也是无边框窗体的最小试验田 │ ├─ MainForm.cs 三栏主窗口,管布局不管绘制 │ └─ ChatForm.cs 独立聊天窗口(双击会话时弹出) ├─ Controls/ │ ├─ SkinButton.cs 自绘按钮,鼠标悬停/按下三态 │ ├─ AvatarBox.cs 圆形头像控件,按 GraphicsPath 裁剪 │ ├─ MessageBubble.cs 消息气泡,整个项目最核心的自绘控件 │ └─ MessageListPanel.cs 气泡容器,负责纵向排列和滚动到底 ├─ Models/ │ ├─ Contact.cs │ ├─ Session.cs │ └─ ChatMessage.cs ├─ Services/ │ ├─ ContactService.cs 好友/会话数据(Demo 里是内存集合) │ ├─ MessageService.cs 消息追加、排序、未读数 │ └─ SkinManager.cs 主题色、字号、圆角半径 ├─ Resources/ │ ├─ avatars/ 头像 png │ ├─ emoji/ 表情 png,按表情名命名 │ └─ skins/ theme.json └─ IMUI.csproj这个结构的判断标准很简单:Forms/里只允许出现布局和事件绑定,Controls/里只允许出现OnPaint和尺寸计算,Services/里不许出现任何System.Drawing。守住这条线,后面接后端、换皮肤都只是改一个目录的事。
2.2 数据模型:ChatMessage / Session / Contact 三个类定生死
界面上看着是一个个气泡,实际上是有数据结构的。这三个类写歪了,后面所有控件都要跟着返工。
namespace IMUI.Models { public enum MessageKind { Text = 0, Image = 1, Emoji = 2, System = 3 } public enum MessageState { Sending = 0, Sent = 1, Failed = 2, Read = 3 } public class ChatMessage { public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string SessionId { get; set; } public string FromId { get; set; } public MessageKind Kind { get; set; } = MessageKind.Text; public MessageState State { get; set; } = MessageState.Sent; public string Content { get; set; } // 文本内容,或图片/表情的资源键 public DateTime SendTime { get; set; } = DateTime.Now; public bool IsSelf { get; set; } // 决定气泡靠左还是靠右 } }Kind和State用枚举而不是bool或者字符串,是这套代码里最省事的一个决定。第一,OnPaint里switch一下就知道该走文本分支还是图片分支;第二,State直接对应气泡右下角那个「发送中/失败重发」的小标记,不用额外加字段。IsSelf单独留一个是因为它决定的是排版方向,和FromId不是一回事——群聊场景里,自己的消息永远靠右,别人的永远靠左,跟谁先说话无关。
会话对象负责聚合:
public class Session { public string SessionId { get; set; } public Contact Target { get; set; } public List<ChatMessage> Messages { get; } = new List<ChatMessage>(); public int UnreadCount { get; set; } public DateTime LastActiveTime { get; set; } = DateTime.Now; // 会话列表里显示的那行摘要,取最后一条 public string Preview => Messages.Count == 0 ? "" : Messages[Messages.Count - 1].Content; }服务层用一个字典把会话存起来,并在追加消息时抛事件,界面只订阅事件、不主动轮询:
public class MessageService { private readonly Dictionary<string, Session> _sessions = new Dictionary<string, Session>(); public event EventHandler<ChatMessage> MessageAppended; public Session GetOrCreate(string sessionId, Contact target) { if (!_sessions.TryGetValue(sessionId, out var s)) { s = new Session { SessionId = sessionId, Target = target }; _sessions[sessionId] = s; } return s; } public void Append(ChatMessage msg) { var s = GetOrCreate(msg.SessionId, null); s.Messages.Add(msg); s.LastActiveTime = msg.SendTime; if (!msg.IsSelf) s.UnreadCount++; // 有会话窗开着就直接画,没开就只更新列表未读数 MessageAppended?.Invoke(this, msg); } }事件里传ChatMessage而不是传整个Session,是刻意的:界面拿到消息后自己决定是塞进气泡列表,还是只把会话列表那一行的未读红点加一。如果传Session,订阅方很容易顺手把整个列表重画一遍,几百条消息的会话直接卡住。
2.3 启动链路:从 Program.cs 到主窗体的装载顺序
Program.cs里那几行经常被忽略,但自绘控件的显示效果和它关系很大:
[STAThread] static void Main() { Application.EnableVisualStyles(); // 关键:false 表示文本渲染走 TextRenderer(GDI),和自绘气泡里的 // TextRenderer.MeasureText 保持同一套度量,否则文字会被裁掉一截 Application.SetCompatibleTextRenderingDefault(false); // 自绘控件里一个数组越界就是整窗白屏,先兜一下 Application.ThreadException += (s, e) => MessageBox.Show(e.Exception.Message, "未处理异常"); AppDomain.CurrentDomain.UnhandledException += (s, e) => MessageBox.Show((e.ExceptionObject as Exception)?.Message, "致命错误"); Application.Run(new MainForm()); }MainForm的构造函数只做三件事:初始化SkinManager、按比例分栏、把MessageService注入到会话列表和聊天面板。分栏不要用SplitContainer,那个东西的拖动条在无边框窗体里很难跟主题色统一,常见做法是用三个Panel加Anchor/Dock手动划比例:左侧好友栏固定 220px,中间会话栏 260px,右侧聊天区自适应。
提示:
MainForm里不要在构造阶段就调用Controls.Add添加大量气泡,那时候句柄还没创建,ScrollControlIntoView会算错滚动位置。历史消息放在OnShown之后再灌。
3. 无边框窗体与换肤:把系统标题栏换成自己画的那一条
仿 IM 界面的第一眼观感,八成来自「没有系统标题栏、没有系统边框」。这一步不复杂,但要做得不漏、不闪、不掉帧,得踩几个点。
3.1 去掉标题栏之后,拖动和缩放得自己接管 WM_NCHITTEST
窗体设成FormBorderStyle = FormBorderStyle.None之后,系统就不再提供拖动和拉伸了。常见的两种补法:一种是在MouseDown里记坐标、MouseMove里改Location,另一种是重写WndProc处理WM_NCHITTEST。前一种写起来简单,但拖动时窗体会「追不上」鼠标,刷新率一高就露馅,而且拉不了边框。推荐后一种:
protected override void WndProc(ref Message m) { const int WM_NCHITTEST = 0x0084; const int HTCLIENT = 1, HTCAPTION = 2; const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13, HTTOPRIGHT = 14, HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17; base.WndProc(ref m); if (m.Msg != WM_NCHITTEST) return; int lp = (int)m.LParam; // 屏幕坐标是 16 位有符号,多屏环境里负坐标很常见,必须走 short int screenX = (short)(lp & 0xFFFF); int screenY = (short)((lp >> 16) & 0xFFFF); var p = PointToClient(new Point(screenX, screenY)); const int grip = 6; // 边缘热区,小于 4 就基本点不中 bool left = p.X <= grip; bool right = p.X >= ClientSize.Width - grip; bool top = p.Y <= grip; bool bottom = p.Y >= ClientSize.Height - grip; if (top && left) m.Result = (IntPtr)HTTOPLEFT; else if (top && right) m.Result = (IntPtr)HTTOPRIGHT; else if (bottom && left) m.Result = (IntPtr)HTBOTTOMLEFT; else if (bottom && right) m.Result = (IntPtr)HTBOTTOMRIGHT; else if (left) m.Result = (IntPtr)HTLEFT; else if (right) m.Result = (IntPtr)HTRIGHT; else if (top) m.Result = (IntPtr)HTTOP; else if (bottom) m.Result = (IntPtr)HTBOTTOM; else if (p.Y <= 64) m.Result = (IntPtr)HTCAPTION; // 顶部 64px 当标题栏 else m.Result = (IntPtr)HTCLIENT; }这段的要点有三个。第一,m.LParam里装的是屏幕坐标,必须先PointToClient转成客户区坐标再判断,否则缩放热区会跟着窗口移动而错位。第二,(short)强转不能省,m.LParam.ToInt32() >> 16在负坐标下会得到错误值,双屏扩展的机器上窗口贴着左屏边缘就会翻车。第三,HTCAPTION的判定区域要放在最后,如果放在最前面,整个客户区都会被当成标题栏,按钮全点不动。
标题栏上那几个最小化/最大化/关闭按钮,用自绘控件(SkinButton)画,MouseEnter/MouseLeave/MouseDown三态各换一张背景色,点击直接调WindowState = FormWindowState.Minimized之类。
3.2 圆角、阴影与「最大化不能盖住任务栏」
圆角有两种做法。一种是把窗体Region设成一个GraphicsPath,简单直接,但边缘是硬切的,没有抗锯齿,放大看有锯齿;另一种是不裁窗体、让窗体背景色等于外层的背景色,内部再画一个圆角矩形面板,看起来就是圆角。工程里一般用后者,因为前者一旦叠上阴影就完全没法看。
阴影更省事的办法是开 DWM 的窗口阴影,但那要求窗体保留系统边框。折中做法是做一个「外壳窗体」:外层Form设BackColor为某个色键、TransparencyKey同色,里面放一个内层面板画阴影和圆角,外壳只负责拖动和缩放。代价是多一层窗口,调试时容易被两个Handle绕晕。
真正容易被忽略的是最大化:
protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); // 无边框窗体最大化默认会铺满整个屏幕,任务栏被压住 var wa = Screen.FromHandle(Handle).WorkingArea; var screen = Screen.FromHandle(Handle).Bounds; MaximizedBounds = new Rectangle( wa.X - screen.X, wa.Y - screen.Y, wa.Width, wa.Height); }MaximizedBounds要的是相对于屏幕左上角的偏移,不是绝对坐标,直接赋WorkingArea在多屏、任务栏在左侧的机器上会偏出一截。另一个方案是拦WM_GETMINMAXINFO改ptMaxSize,效果更稳,代码也更多。
注意:多显示器 + 不同缩放比例时,
Screen.FromHandle取到的是窗体当前所在的那块屏。窗体被拖到另一块屏后,MaximizedBounds需要重算,一般放在LocationChanged里做。
3.3 SkinManager:一套主题配置管住所有颜色
换肤不要在每个控件里硬编码颜色。工程里用一个静态入口收口:
public class SkinManager { public static SkinManager Current { get; } = new SkinManager(); public Color SelfBubble { get; private set; } = Color.FromArgb(0, 160, 233); public Color OtherBubble { get; private set; } = Color.White; public Color SelfFore { get; private set; } = Color.White; public Color OtherFore { get; private set; } = Color.FromArgb(34, 34, 34); public Color ChatBack { get; private set; } = Color.FromArgb(245, 246, 248); public int BubbleRadius { get; private set; } = 8; // 换肤后所有自绘控件在 OnPaint 里读 Current,重绘即生效 public event EventHandler ThemeChanged; public void Apply(SkinTheme theme) { SelfBubble = ColorTranslator.FromHtml(theme.SelfBubble); OtherBubble = ColorTranslator.FromHtml(theme.OtherBubble); ChatBack = ColorTranslator.FromHtml(theme.ChatBack); BubbleRadius = theme.BubbleRadius; ThemeChanged?.Invoke(this, EventArgs.Empty); } }SkinTheme就是一个纯 POCO,用Newtonsoft.Json从Resources/skins/theme.json反序列化。自绘控件订阅ThemeChanged,收到后调Invalidate()。这里有一个顺序问题:SkinManager.Current是静态单例,控件订阅事件后如果不退订,窗体关了还是会持有引用,反复开关聊天窗口就会内存涨。规矩是OnHandleCreated里订阅、OnHandleDestroyed里退订。
4. 消息气泡自绘:一次 OnPaint 里量文字、画圆角、贴头像
整个项目最值得逐行看的就是MessageBubble。它一个控件承担了:算文字尺寸、算气泡宽度、画圆角矩形、画小三角、贴圆头像、处理多行换行。
4.1 MessageBubble:一次 OnPaint 里量文字、画圆角、贴头像
public class MessageBubble : Control { public bool IsSelf { get; set; } public MessageKind Kind { get; set; } = MessageKind.Text; public Image Avatar { get; set; } private const int PadX = 12, PadY = 9, AvatarSize = 36, Gap = 8, TailW = 7; private const int MaxTextWidth = 320; public MessageBubble() { // 三个样式缺一不可:UserPaint 才能自己画,OptimizedDoubleBuffer 防闪, // AllPaintingInWmPaint 把擦背景也省掉一层 SetStyle(ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.ResizeRedraw, true); BackColor = Color.Transparent; Font = new Font("Microsoft YaHei", 9.5f); } protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; // 1) 先量文字。flags 必须和下面 DrawText 完全一致,否则会被裁 var textSize = TextRenderer.MeasureText(Text ?? string.Empty, Font, new Size(MaxTextWidth, int.MaxValue), TextFormatFlags.WordBreak | TextFormatFlags.TextBoxControl); int bubbleW = textSize.Width + PadX * 2; int bubbleH = textSize.Height + PadY * 2; // 2) 定位:自己靠右,对方靠左 int avatarX = IsSelf ? Width - AvatarSize : 0; int bubbleX = IsSelf ? Width - AvatarSize - Gap - TailW - bubbleW : AvatarSize + Gap + TailW; DrawAvatar(g, new Rectangle(avatarX, 0, AvatarSize, AvatarSize)); // 3) 气泡本体 using (var path = RoundedPath(new Rectangle(bubbleX, 0, bubbleW, bubbleH), SkinManager.Current.BubbleRadius, IsSelf)) using (var brush = new SolidBrush(IsSelf ? SkinManager.Current.SelfBubble : SkinManager.Current.OtherBubble)) { g.FillPath(brush, path); } // 4) 文字。用 TextRenderer 而不是 DrawString,ClearType 才对得上 var fore = IsSelf ? SkinManager.Current.SelfFore : SkinManager.Current.OtherFore; TextRenderer.DrawText(g, Text ?? string.Empty, Font, new Rectangle(bubbleX + PadX, PadY, textSize.Width, textSize.Height), fore, TextFormatFlags.WordBreak | TextFormatFlags.TextBoxControl); } private void DrawAvatar(Graphics g, Rectangle rect) { if (Avatar == null) { using (var b = new SolidBrush(Color.FromArgb(200, 205, 212))) g.FillEllipse(b, rect); return; } var state = g.Save(); using (var path = new GraphicsPath()) { path.AddEllipse(rect); g.SetClip(path, CombineMode.Intersect); g.DrawImage(Avatar, rect); // 头像已按 2x 缓存,直接缩放不糊 } g.Restore(state); } }RoundedPath就是四个AddArc加一个CloseFigure,再根据IsSelf拼一个小三角:
private static GraphicsPath RoundedPath(Rectangle r, int radius, bool tailRight) { int d = radius * 2; var path = new GraphicsPath(); path.AddArc(r.X, r.Y, d, d, 180, 90); path.AddArc(r.Right - d, r.Y, d, d, 270, 90); path.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90); path.AddArc(r.X, r.Bottom - d, d, d, 90, 90); path.CloseFigure(); var tail = new GraphicsPath(); if (tailRight) tail.AddPolygon(new[] { new Point(r.Right - 2, r.Y + 14), new Point(r.Right + TailW, r.Y + 20), new Point(r.Right - 2, r.Y + 26) }); else tail.AddPolygon(new[] { new Point(r.X + 2, r.Y + 14), new Point(r.X - TailW, r.Y + 20), new Point(r.X + 2, r.Y + 26) }); path.AddPath(tail, false); return path; }参数上要注意两点。MaxTextWidth = 320是长文本的封顶宽度,超过就换行,不然一段长句会把气泡撑到窗口之外。PadY = 9看着随意,其实是配合TextBoxControl这个 flag 调的——用了它之后文本行高会多出一点,padding 取 9 左右的视觉留白最舒服,取 4 以下会显得字贴着边。
关于头像,Avatar属性拿到的应该已经是一张缓存好的图,不要每次OnPaint都Image.FromFile。Image.FromFile会在整个进程生命周期里锁住文件句柄,跑一会儿就「文件被占用」;正确做法是using (var fs = File.OpenRead(p)) { img = Image.FromStream(fs); }拷贝一份,再按AvatarSize * 2缩一次存进字典。
4.2 宽度自适应:MaximumSize 与重排时机
泡泡宽度不能写死。窗口从 1200 拉到 800,如果气泡还用旧的MaxTextWidth,右侧的消息会被切掉。这个工程的做法是让气泡控件宽度等于容器可用宽度,内部按MaxTextWidth = (int)(Width * 0.62)动态算:
protected override void OnResize(EventArgs e) { base.OnResize(e); // 宽度一变,最大文本宽度跟着变,必须重算高度 MaxTextWidthDynamic = Math.Max(120, (int)(Width * 0.62)); Invalidate(); }0.62这个比例是试出来的:太大会让长消息贴着窗边,太小则短消息左右留白过多。窗口特别窄(小于 400px)时退化成固定最小值 120,否则「你好」两个字也会被折成两行。
高度不是自己说了算的,得让容器能问出来,所以要重写GetPreferredSize:
public override Size GetPreferredSize(Size proposedSize) { var textSize = TextRenderer.MeasureText(Text ?? string.Empty, Font, new Size(MaxTextWidthDynamic, int.MaxValue), TextFormatFlags.WordBreak | TextFormatFlags.TextBoxControl); return new Size(proposedSize.Width, textSize.Height + PadY * 2 + 4); }末尾那+4是给气泡下面留的呼吸位,不加的话相邻两个气泡会贴在一起。MessageListPanel排布时直接用这个值设Height,一处改动全局生效。
4.3 MessageListPanel:把气泡竖着码起来,还要自动滚到底
容器有两种写法。FlowLayoutPanel省事,但它的换行逻辑是按行排的,气泡宽度一变化就容易出现「一条消息占两行」的鬼畜;而且ScrollControlIntoView在AutoScroll容器里行为不太可控。所以这里用手动排布:
public class MessageListPanel : Panel { private const int VSplit = 10; public MessageListPanel() { DoubleBuffered = true; // 容器本身也要双缓冲 AutoScroll = true; BackColor = SkinManager.Current.ChatBack; } public void Append(ChatMessage msg) { var bubble = new MessageBubble { IsSelf = msg.IsSelf, Kind = msg.Kind, Text = msg.Content, Avatar = AvatarCache.Get(msg.FromId), Width = ClientSize.Width - 8 }; Controls.Add(bubble); Relayout(); ScrollControlIntoView(bubble); // 新消息永远在视野底部 } private void Relayout() { int y = 8; foreach (Control c in Controls) // Controls 顺序 = 添加顺序 { c.Left = 0; c.Top = y; c.Width = ClientSize.Width - 8; c.Height = c.GetPreferredSize(new Size(c.Width, 0)).Height; y += c.Height + VSplit; } AutoScrollMinSize = new Size(0, y + 8); } protected override void OnResize(EventArgs e) { base.OnResize(e); Relayout(); // 窗口缩放时全部重排 } }这里有两个性能点。第一,Relayout是 O(n) 的,几百条消息时每次追加都重排一遍会明显卡顿,务实做法是只重排新增的那一条,把Top接到最后一条的底部,只有OnResize和加载历史时才全量重排。第二,Controls.Add每次都会触发一次布局,消息多的时候要把SuspendLayout/ResumeLayout包在外面,否则界面会一顿一顿地跳。
ScrollControlIntoView在AutoScroll = true的Panel上可以正常用,但前提是控件的Top已经设置好、AutoScrollMinSize已经更新,顺序反了就会滚不到位。这也是很多人说「新消息进来但视图没跟着动」的根本原因。
5. 排查与避坑:自绘聊天界面最容易翻车的 5 个现场
自绘 UI 的调试和普通表单完全不是一回事,出错时没有报错、只有一块白,或者一块糊。下面五个是我在这类工程里反复见到的现场。
5.1 五类高频现场:现象、原因、解决
一、消息一多界面就开始闪,滚动时像雪花屏。现象是气泡数量超过一百条后,滚动条一拖就出现大面积白块。原因是Panel默认在WM_ERASEBKGND里刷一遍背景色再让子控件画,两层刷新叠在一起。解决是容器和气泡都开双缓冲:DoubleBuffered = true,气泡再补SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true)。如果是ListView、TreeView这类系统控件,DoubleBuffered是 protected 的,得用反射打开或者干脆SetStyle包一层子类。
二、气泡里的文字右边或下边被切掉一截。现象是短消息正常,一到中英文混排、带标点的长句就露半个字。原因是测量和绘制用了两套引擎——MeasureText走TextRenderer,绘制却用了Graphics.DrawString,两者字形宽度差几个像素,累积起来就裁边。解决是量、画都用TextRenderer,并且TextFormatFlags必须完全一致(WordBreak | TextBoxControl)。另外SetCompatibleTextRenderingDefault(true)也会把这个差异放大,Program.cs里记得传false。
三、125% 或 150% 缩放下,整个界面糊成一片。现象是高 DPI 笔记本上气泡边缘发虚、文字毛边。原因是进程没有声明 DPI 感知,被系统按位图拉伸了。解决是给工程加app.manifest,在<application>里声明dpiAware为true/PM,.NET Framework 4.7以上还可以在app.config里加System.Windows.Forms.ApplicationConfigurationSection打开 PerMonitorV2。声明之后要复查所有硬编码像素——AvatarSize = 36在 150% 下会显得偏小,得按DeviceDpi / 96f缩放。
四、反复打开关闭聊天窗口,内存一路涨。现象是任务管理器里句柄数只增不减,跑半天报 GDI+ 错误。原因通常有两个:OnPaint里new SolidBrush、new GraphicsPath没using,或者头像用Image.FromFile加载后被PictureBox长期持有。解决是画笔、路径一律using,图片走「读成流再拷贝」,并用一个静态字典按 id 缓存,缓存上限到 200 张就淘汰。
五、无边框窗体最大化之后,任务栏被盖住、按钮点不到。现象是点了最大化,窗口铺满整屏,底下的任务栏消失,再点还原按钮也没反应。原因是FormBorderStyle.None下系统不再自动避让工作区。解决是设MaximizedBounds为当前屏的WorkingArea(记得换算成相对坐标),或者拦WM_GETMINMAXINFO改ptMaxSize。还一种情况是任务栏被盖住之后,标题栏按钮的命中区域没跟着重算,需要最大化后强制PerformLayout()。
5.2 上线前的自检清单:DPI、缩放、长消息、空会话
这五项挨个过一遍,能省掉大半返工:
| 检查项 | 怎么测 | 期望结果 |
|---|---|---|
| 高 DPI | 系统缩放切到 150%,重启程序 | 气泡不糊、头像不变形、按钮不重叠 |
| 窗口缩放 | 主窗从 1200 拖到 700 | 气泡重新换行,不出现横向滚动条 |
| 超长消息 | 粘一段 2000 字无空格英文 | 气泡不超出窗口,能断行 |
| 空会话 | 新建一个没有历史的会话 | 不抛异常,输入框可用 |
| 快速刷屏 | 循环Append500 条 | 滚动跟手,CPU 不飙到 50% 以上 |
压测脚本很简单,在调试菜单里挂一个循环把MessageService.Append打五百次就行。这一步别跳过——很多自绘界面的性能问题在小数据量下完全看不出来,一上真实会话就原形毕露。
6. 二次开发:把本地壳子接到真后端上的三条改造路径
这份源码本质是个界面壳子,数据全在内存里,关掉程序就没了。要把它变成能用的东西,改造顺序建议是「先抽接口、再换存储、最后接网络」,别一上来就怼 WebSocket,那样界面和网络会搅在一起,出了问题根本分不清是画错了还是收错了。
第一步,抽IMessageStore:
public interface IMessageStore { void Append(ChatMessage msg); IReadOnlyList<ChatMessage> LoadHistory(string sessionId, int pageSize, int pageIndex); void MarkRead(string sessionId); }MessageService从「自己存」改成「委托给 Store」,内存实现和 SQLite 实现各写一个。改完之后界面代码一行不动,这就是前面分层守住的回报。
第二步,接网络。用一个IChatTransport包住连接层,Demo 阶段可以是Task.Delay加随机回复的假实现,真接后端时换成 WebSocket 或 SignalR 的HubConnection。发送链路要补上MessageState:点发送立刻Append一条Sending状态的消息并画出来,服务端回 ACK 后改成Sent,超时改成Failed并在气泡旁边画个重发按钮。这一套「乐观更新」如果不提前设计,后面补会非常难受,因为MessageBubble的绘制逻辑得跟着改。
第三步,换肤和主题。SkinManager.Apply已经收口了,新增一套深色主题只是多一个theme-dark.json的事。唯一要注意的是文字颜色要跟着变,深色底上还用Color.FromArgb(34,34,34)就完全看不见了。我自己吃过这个亏:换肤只改了背景没改前景,跑起来一片黑,翻了半小时才反应过来。
验证方法就三条:一是把IMessageStore的 SQLite 实现换成内存实现跑一遍,行为要完全一致;二是断网再重连,看Sending状态的消息能不能正确落成Failed并重发;三是在 150% 缩放下把整条链路走一遍,因为缩放问题往往在接完网络之后才暴露,那时候你会怀疑是协议解析错了。
从那以后我每次动这类自绘界面,都强制先跑一遍「150% 缩放 + 500 条消息压测」这两步再写业务,界面上的坑早发现一分钟,后面就少熬一晚上。希望帮到你。
本文还有配套的精品资源,点击获取