ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C# WinForms仿IM聊天界面:无边框窗体与自绘气泡源码拆解

C# WinForms仿IM聊天界面:无边框窗体与自绘气泡源码拆解

简介:这份源代码包以C#实现仿QQ聊天IM界面,面向具备一定C#基础、希望练习Windows桌面应用开发的初学者与进阶者,可用于学习窗体布局、控件使用、事件处理及聊天类应用的基本交互流程。压缩包共413个文件,约8.34MB,包含39个cs源码、13个resx与26个resources资源文件、236个png界面素材,以及dll、config、db、csproj、sln等工程与运行所需文件,结构覆盖窗体代码、资源、数据库和项目配置,便于在Visual Studio中打开后对照调试。已有2214人学习,说明其作为练习案例具有一定参考价值。读者可从中了解好友列表展示、聊天窗口切换、消息发送接收、数据绑定、后台线程处理与用户状态更新等实现思路,并在此基础上进行二次开发,适合作为C# GUI与即时通讯入门实践的参考代码。

1. 一份能直接跑的 C# 仿 IM 聊天界面源码,到底值不值得拆

上周有个做内部工单系统的朋友问我,有没有现成的 C# 聊天界面能直接套,他们那套 WinForms 工具界面还停在上古时代,客户一看就觉得是十年前的产物。我翻出这份 C# 仿主流即时通讯软件的聊天 IM 界面源代码给他看,他当场就要走了。这东西的价值不在"像不像",而在于它把无边框窗体、自绘气泡、滚动消息列表这几个 WinForms 里最容易翻车的点,全部用可编译的代码摊开写了一遍。适合三类人:赶课程设计要交一个能演示的聊天窗口的、给内部工具套壳想提升观感的、以及想认真学一遍 GDI+ 自绘的 C# 开发者。不适合指望它直接提供生产级通信能力的人,它重点在界面层。

2. 窗体骨架:无边框加三栏布局怎么搭才不塌

2.1 为什么这类界面几乎不用系统标题栏

标准 WinForms 窗体的标题栏由系统主题绘制,深色配色下会露出一条刺眼的浅灰,而且标题栏高度、按钮圆角、关闭按钮的悬停色全都不受你控制。仿 IM 界面基本都会把FormBorderStyle设成None,然后自己画一条 64 像素左右的顶栏,左上角放头像和昵称,右上角放最小化、最大化、关闭三个自绘按钮。代价是系统原本免费提供的三件事全部要自己补回来:拖动窗体、边缘缩放、最大化时不遮挡任务栏。

网上流传最广的拖动写法是ReleaseCapture()配合SendMessage(Handle, WM_NCLBUTTONDOWN, HTCAPTION, 0),代码只有两行,抄起来很爽。但我在这份源码里看到的是另一套:重写WndProc处理WM_NCHITTEST。原因很实在,ReleaseCapture方案本质是"骗"系统进入拖动模式,拖动过程中窗体的鼠标捕获会转移给系统,一旦用户拖到屏幕边缘触发 Aero Snap 再松手,或者拖动中途按了 Alt+Tab,捕获状态就可能回不来,表现为窗体粘在鼠标上跟着跑。WM_NCHITTEST是系统在鼠标移动时主动来问"这个坐标属于哪个区域",你回答HTCAPTION,系统就按标题栏处理,拖动、双击最大化、右键系统菜单全都是原生行为。

2.2 用 WndProc 一次性解决拖动与八向缩放

下面是我从源码里提炼并做过精简的核心片段,直接放进窗体类即可。要点是把边缘热区和标题栏区域在同一个命中测试里一起返回,系统会自动把后续的拖动和缩放接管过去。

public partial class ChatShellForm : Form { private const int WM_NCHITTEST = 0x0084; private const int WM_GETMINMAXINFO = 0x0024; private const int HTCLIENT = 1, HTCAPTION = 2; private const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13, HTTOPRIGHT = 14, HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17; // 边缘热区基准值(按 96 DPI 设计),实际使用时要乘 DPI 系数 private const int ResizeBorder = 6; private int ScaledBorder => (int)(ResizeBorder * DeviceDpi / 96f); public ChatShellForm() { FormBorderStyle = FormBorderStyle.None; StartPosition = FormStartPosition.CenterScreen; MinimumSize = new Size(880, 600); AutoScaleMode = AutoScaleMode.Dpi; // 顶层容器必须开双缓冲,否则拉伸时会看到大片白底闪一下 SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true); DoubleBuffered = true; } protected override void WndProc(ref Message m) { if (m.Msg == WM_NCHITTEST) { base.WndProc(ref m); // 先让系统给出默认结果 if ((int)m.Result != HTCLIENT) return; // 已经命中子控件就别抢 Point p = PointToClient(new Point( (short)(m.LParam.ToInt32() & 0xFFFF), (short)((m.LParam.ToInt32() >> 16) & 0xFFFF))); int b = ScaledBorder; bool left = p.X <= b; bool right = p.X >= ClientSize.Width - b; bool top = p.Y <= b; bool bottom = p.Y >= ClientSize.Height - b; // 先判四个角,再判四条边,顺序反了角上会退化成一个方向 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 <= ScaledTitleHeight) m.Result = (IntPtr)HTCAPTION; // 顶部当标题栏 } else if (m.Msg == WM_GETMINMAXINFO) { base.WndProc(ref m); MaximizeFix.Apply(m, this); // 见 2.3 的说明 } else base.WndProc(ref m); } }

m.LParam里打包的是屏幕坐标,低 16 位是 X、高 16 位是 Y,必须先用PointToClient换成客户区坐标,否则多显示器或者窗体不在原点时判定会整体偏移。边缘热区不能写死 6 像素,DeviceDpi在 150% 缩放下是 144,热区要跟着放大到 9 像素,不然高分屏用户根本抓不住边框。角优先于边这条顺序是血泪经验,早期版本先判边后判角,结果四个角只能单向缩放,被测试的同事吐槽了半天才定位到。

2.3 三栏布局的参数选型与最大化遮挡

布局上这份源码用了TableLayoutPanel而不是纯Dock。左栏会话列表固定 260 像素,中间用SplitterPanel允许拖拽,右侧聊天区Fill,底部输入区固定最小高度 120 像素。Dock方案的问题在于嵌套三层以上之后,停靠顺序(ControlCollection的 Z 序)会变得非常反直觉,改一个面板宽度可能导致另一个面板被挤出可视区。

区域基准尺寸(96 DPI)缩放方式备注
顶部标题栏高 64按 DPI 乘系数自绘按钮 32×32,热区外扩 4
左侧会话列表宽 260,最小 200用户可拖拽超过 320 时头像区留白明显
会话项高度72字号联动含头像 48 + 上下各 12
消息气泡最大宽容器宽 × 0.62运行时计算超过后阅读回扫成本明显上升
输入区最小高 120可拖高内含工具栏 36
圆角半径6(气泡)/ 8(头像)按 DPI 乘系数半径超过控件一半要做钳制

最大化遮挡任务栏是自绘标题栏的经典坑:无边框窗体被系统最大化时会按整个屏幕矩形铺满,任务栏被盖住。解决办法是处理WM_GETMINMAXINFO,把MONITORINFO.rcWork(工作区)写回MINMAXINFO.ptMaxSize和ptMaxPosition,用MonitorFromWindow拿到窗体所在那块屏。多显示器下一定要按"窗体当前所在显示器"取工作区,而不是主屏,否则副屏最大化会跑到主屏去。

3. 消息气泡自绘:MeasureText、圆角路径与头像裁剪

3.1 一个消息控件该管的四件事

很多人第一版是这么写的:每条消息 new 一个UserControl,里面塞两个Label一个PictureBox,然后丢进FlowLayoutPanel。能跑,但撑不过两百条消息。这份源码的做法是自绘控件,职责划得很清楚:布局计算(这条消息占多高、气泡多宽)、绘制(气泡底、文字、头像、时间戳)、命中测试(点哪里选中、点哪里弹菜单)、以及对外暴露的消息模型。控件本身不保存业务数据,只持有一个MessageModel的引用。

这里有个容易忽略的点:气泡高度必须在测量阶段就确定下来,因为滚动条的总高度依赖所有消息的高度之和。如果等到OnPaint里再去算,滚动条就会在滚动过程中不断跳动。所以正确的顺序是:宽度确定后先跑一遍测量,把每条消息的Height缓存进模型,OnPaint只做绘制不做测量。

3.2 测量和绘制必须共用同一套 TextFormatFlags

WinForms 里有两条文字渲染路径,Graphics.DrawString走 GDI+,TextRenderer.DrawText走 GDI。前者支持抗锯齿和更多排版特性,但小字号下会发虚;后者用的是系统文本渲染,清晰度接近原生控件。这份源码选的是TextRenderer,配套用TextRenderer.MeasureText测量,这条路是对的,但有个致命细节:测量和绘制的TextFormatFlags必须完全一致,差一个WordBreak就会出现"测量时按整词换行、绘制时按字符换行"的错位,表现为最后一行的字被气泡裁掉。

// 统一的测量入口:任何地方算气泡尺寸都只走这一个方法 private const TextFormatFlags BubbleFlags = TextFormatFlags.WordBreak // 按词换行,中英文混排必需 | TextFormatFlags.NoPadding // 去掉 GDI 默认的 3px 内边距 | TextFormatFlags.TextBoxControl; // 与 TextBox 的换行规则对齐 private Size MeasureBubble(string text, Font font, int maxWidth) { // maxWidth 传的是纯文字可用宽度,已扣除左右内边距和尖角占位 Size s = TextRenderer.MeasureText(text, font, new Size(maxWidth, int.MaxValue), BubbleFlags); return new Size(s.Width, s.Height); } protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; int padX = 12, padY = 9, tail = 8; // 内边距与尖角宽度 int maxTextW = (int)(Parent.ClientSize.Width * 0.62f) - padX * 2 - tail - AvatarSize; Size textSize = MeasureBubble(Model.Text, Model.Font, maxTextW); // 气泡矩形:自己发的靠右,对方发的靠左,这里以对方为例 var bubble = new Rectangle( AvatarSize + 12, TopPadding, textSize.Width + padX * 2 + tail, textSize.Height + padY * 2); using (var path = BuildBubblePath(bubble, radius: 6, tail: tail, isMine: false)) using (var brush = new SolidBrush(Model.IsMine ? MineColor : OtherColor)) { g.FillPath(brush, path); // 文字区域要扣掉尖角,否则文字会压到尖角上 var textRect = new Rectangle( bubble.X + padX, bubble.Y + padY, textSize.Width, textSize.Height); TextRenderer.DrawText(g, Model.Text, Model.Font, textRect, Model.IsMine ? Color.White : Color.FromArgb(32, 32, 32), BubbleFlags); } }

NoPadding这个标志很关键。GDI 的TextRenderer默认会在文本周围留 3 像素内边距,测量结果会比实际绘制大一圈,不加这个标志你会看到气泡比文字宽出一截,怎么调内边距都调不齐。中英文混排场景下WordBreak也是必需的,纯中文时加不加看不出差别,一旦出现连续的英文单词或 URL,没有WordBreak就会按字符硬切,单词被拦腰截断。

3.3 圆角气泡路径与头像裁剪的缓存策略

气泡形状由"圆角矩形 + 一个三角形尖角"合并而成。用GraphicsPath分别构建再AddPath合并,注意尖角三角形要和矩形的某条边重叠一到两个像素,否则抗锯齿下会看到一条接缝。

private GraphicsPath BuildBubblePath(Rectangle r, int radius, int tail, bool isMine) { var path = new GraphicsPath(); int d = radius * 2; // 顺时针四段圆弧 + 四条直线,拼出圆角矩形 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(); // 尖角:贴着气泡内侧边缘,向内重叠 2px 消除接缝 var tailPath = new GraphicsPath(); if (isMine) tailPath.AddPolygon(new[] { new Point(r.Right - 2, r.Y + 14), new Point(r.Right + tail, r.Y + 22), new Point(r.Right - 2, r.Y + 30) }); else tailPath.AddPolygon(new[] { new Point(r.X + 2, r.Y + 14), new Point(r.X - tail, r.Y + 22), new Point(r.X + 2, r.Y + 30) }); path.AddPath(tailPath, false); return path; } // 头像圆角:用路径裁剪比 TextureBrush 更好控,注意用完必须 ResetClip private void DrawAvatar(Graphics g, Image img, Rectangle bounds, int radius) { using (var path = RoundedRect(bounds, radius)) { var old = g.Clip; g.SetClip(path, CombineMode.Intersect); g.DrawImage(img, bounds); // 建议提前把头像缩放到 bounds 尺寸并缓存 g.Clip = old; } }

头像这里有一个性能陷阱:如果每次都DrawImage把原图缩放一遍,GDI+ 的双三次插值在高频重绘下开销不小,滚动时能明显感觉到掉帧。常见做法是维护一个Dictionary<string, Bitmap>,键用"用户ID + 目标尺寸",首次加载时把原图按目标尺寸缩放一次并存下来,之后直接DrawImage无缩放版本。缓存要设上限,比如最多 200 张,超了按 LRU 淘汰,否则群里几百个人发言,内存会一路涨上去。还有一个细节:SetClip之后务必恢复原来的 clip,不要图省事写g.ResetClip(),因为父容器可能已经设置了裁剪区(比如滚动容器的可视区裁剪),直接 Reset 会把父级的裁剪也一起清掉,导致内容画到容器外面去。

4. 消息列表的滚动与重绘:从卡顿到顺滑的三次改动

4.1 控件堆叠和单控件自绘的取舍

第一版用FlowLayoutPanel+ 每条消息一个UserControl,50 条以内很流畅,到 500 条时拖动滚动条开始明显卡顿,1000 条时窗口拖动都有延迟。原因不难理解:每个UserControl都是一个独立的窗口句柄,1000 条就是 1000 个 HWND,FlowLayoutPanel每次滚动都要对整个子控件集合重排,复杂度是 O(n),而 Windows 消息循环还要为每个句柄维护状态。

第二版改成单个ScrollableControl自绘所有消息,OnPaint里foreach一遍全部绘制。句柄问题解决了,但绘制量还是 O(n),滚动一次要画 1000 条消息。第三版才是最终形态:只画可见区间。这在思路上就是最简单的虚拟列表,只不过 WinForms 里没有现成控件,得自己算。

方案1000 条滚动帧率内存占用实现复杂度适用场景
FlowLayoutPanel + UserControl明显掉帧高(每控件含句柄)低消息少于 100 条
单控件全量自绘中等,滚动时有拖影低中500 条以内
单控件可见区自绘顺滑低中高无上限,推荐

4.2 可见区计算与滚动偏移的实现

核心是三件事:维护每条消息的 Y 坐标和高度、根据滚动偏移算出第一条可见消息、OnPaint只画这个区间。高度在宽度变化时重算一次并缓存,平时直接查表。

public class MessageListView : ScrollableControl { private readonly List<MessageModel> _items = new(); private readonly List<int> _tops = new(); // 每条消息的顶部 Y(累加) private int _contentHeight; // 全部消息总高度 private int _scrollY; // 当前滚动偏移(>=0) public MessageListView() { SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.ResizeRedraw, true); AutoScroll = false; // 自己管滚动偏移,不交给基类 } // 宽度变化后必须重算所有高度,因为气泡宽度依赖容器宽度 public void RebuildLayout() { _tops.Clear(); int y = 0; foreach (var m in _items) { _tops.Add(y); y += MeasureItemHeight(m) + ItemGap; // 高度含气泡与上下留白 } _contentHeight = y; _scrollY = Math.Max(0, _contentHeight - ClientSize.Height); // 新消息到底部 Invalidate(); } protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.Clear(BackColor); // 二分查找第一条可见消息,避免从 0 开始线性扫描 int first = LowerBound(_tops, _scrollY); for (int i = first; i < _items.Count; i++) { int top = _tops[i] - _scrollY; if (top > ClientSize.Height) break; // 越过可视区直接停 DrawItem(g, _items[i], new Rectangle(0, top, ClientSize.Width, 0)); } DrawScrollBar(g); // 自绘滚动条,见下 } protected override void OnMouseWheel(MouseEventArgs e) { int step = SystemInformation.MouseWheelScrollLines * 48; // 一行约 48px SetScroll(_scrollY - Math.Sign(e.Delta) * step); base.OnMouseWheel(e); } private void SetScroll(int value) { int max = Math.Max(0, _contentHeight - ClientSize.Height); value = Math.Clamp(value, 0, max); if (value == _scrollY) return; _scrollY = value; Invalidate(); // 简单可靠;追求极致可只 Invalidate 变化区域 } }

LowerBound用二分是因为消息量大以后线性扫描虽然只有几千次比较,但它在每次重绘时都跑一遍,叠加 60fps 就不划算了。滚动偏移的上限是内容高度 - 视口高度,这个钳制不能省,否则滚轮一甩就滚过头,出现大片空白。

自绘滚动条要注意滑块高度公式:thumbHeight = max(24, 视口高度 / 内容高度 × 轨道高度)。设 24 像素下限是因为消息特别多时按比例算出来的滑块只有两三像素,鼠标根本点不中。另外拖动滑块时要按比例换算偏移量,不能用鼠标位移直接当偏移量,否则内容越长滑块越"飘"。

4.3 双缓冲、局部失效与一个被忽略的重绘来源

OptimizedDoubleBuffer加AllPaintingInWmPaint是标配,但仅开这两个还不够,滚动时如果背景一直在擦除,仍会看到一条白边闪。原因是ScrollableControl的基类在滚动时会发WM_ERASEBKGND。处理方式是在WndProc里拦截这个消息直接返回 1,表示"背景我已经在OnPaint里画了,你别擦"。

局部失效的收益在长列表下很明显:滚动 10 像素,理论上只需要重绘整个视口,但把Invalidate()换成Invalidate(new Rectangle(0, 0, Width, Height))其实没区别,真正省的是"新消息追加"场景。追加一条消息时只需要Invalidate底部那一条的高度区域,而不是全屏重绘。不过要注意,一旦滚动条位置变化,全屏重绘就不可避免了。

还有一处容易漏掉的重绘来源:字体。OnPaint里如果每次new Font(...),GDI 对象的创建和销毁会直接拖垮帧率,而且在高频重绘下 GC 压力很大。字体、画刷、画笔这些都应该在控件级别缓存成字段,随控件生命周期释放。我见过一个版本在DrawItem里 new 了六个SolidBrush,消息一多帧率直接掉到个位数。

5. 避坑记录:五个让界面看着对、用着崩的细节

5.1 渲染层面的三个坑

气泡文字被裁掉最后一行。现象是短消息正常,超过三行的消息最后一行只露出上半截。原因是测量时用了TextFormatFlags.WordBreak,绘制时手滑写成了默认值,两次换行结果不同。解决方式是把TextFormatFlags提成常量,测量和绘制共用一个方法,任何人不得单独调TextRenderer。我在代码里干脆把MeasureBubble设成private,外部只能通过缓存的高度取值,从源头堵住。

150% 缩放下整体错位、字体发虚。现象是开发机 100% 缩放显示正常,客户机器 125% 或 150% 下气泡整体偏移,文字边缘发毛。原因有两个:一是AutoScaleMode设成了Font,字号被系统放大后布局跟着二次缩放;二是代码里大量硬编码像素值,比如气泡内边距写死 12。解决方式是AutoScaleMode.Dpi加app.manifest里声明 PerMonitorV2,所有像素参数统一走(int)(value * DeviceDpi / 96f),并且处理OnDpiChangedAfterParent重新布局。

滚动时整块区域闪烁。现象是拖滚动条时背景忽白忽灰。原因是没开双缓冲,或者开了但没拦WM_ERASEBKGND。解决方式是三件套:SetStyle里加OptimizedDoubleBuffer | AllPaintingInWmPaint | UserPaint,重写WndProc拦截WM_ERASEBKGND返回 1,以及确保OnPaintBackground不做任何绘制。

5.2 交互与工程层面的两个坑

无边框窗体最大化盖住任务栏。现象是点最大化后窗体铺满整屏,任务栏被遮住,用户切不了窗口。原因是系统对无边框窗体的最大化按屏幕矩形算,而不是工作区。解决办法是处理WM_GETMINMAXINFO,用MonitorFromWindow拿到当前显示器,取MONITORINFO.rcWork的宽高和左上角坐标,写回MINMAXINFO的ptMaxSize和ptMaxPosition。多屏环境下千万别用主屏的工作区,否则副屏最大化会跳到主屏。

internal static class MaximizeFix { public static void Apply(Message m, Form form) { var mmi = Marshal.PtrToStructure<MINMAXINFO>(m.LParam); IntPtr hMon = MonitorFromWindow(form.Handle, 2 /* MONITOR_DEFAULTTONEAREST */); var mi = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() }; if (GetMonitorInfo(hMon, ref mi)) { // 工作区相对屏幕原点的偏移 mmi.ptMaxPosition.X = mi.rcWork.Left - mi.rcMonitor.Left; mmi.ptMaxPosition.Y = mi.rcWork.Top - mi.rcMonitor.Top; mmi.ptMaxSize.X = mi.rcWork.Right - mi.rcWork.Left; mmi.ptMaxSize.Y = mi.rcWork.Bottom - mi.rcWork.Top; Marshal.StructureToPtr(mmi, m.LParam, true); } } }

输入框里按回车,输入法候选词没上屏就发出去了。现象是用中文输入法打字,选词时按回车确认候选,结果消息直接发出去,输入框清空。原因是KeyDown里无脑判断e.KeyCode == Keys.Enter就发送。解决方式是判断当前是否处于输入法组合状态,常见做法是检查ImeMode或者监听WM_IME_COMPOSITION,更简单的做法是在KeyDown里先看e.KeyCode == Keys.ProcessKey,是就放行给输入法。同时约定Shift+Enter换行、Ctrl+Enter发送,把回车让给输入法。改完之后一定要让用五笔和拼音的同事各测一遍,不同输入法的行为差异比想象中大。

6. 进阶:把界面层和消息模型拆开,接上你自己的通信层

这份源码的界面部分写得比较整,但消息模型和绘制逻辑还缠在一起。想真正用起来,第一件事是把MessageModel抽出来,让渲染层只认模型不认业务。我的做法是定义一个渲染接口,OnPaint里按消息类型分发,以后要加图片消息、文件消息、语音条,只需新增一个实现类,不用动主绘制流程。

public interface IMessageRenderer { MessageKind Kind { get; } // 该渲染器负责的消息类型 int MeasureHeight(MessageModel m, int availableWidth); // 布局阶段调用 void Draw(Graphics g, MessageModel m, Rectangle bounds); // 绘制阶段调用 } // 注册表模式,避免 OnPaint 里写一长串 switch private readonly Dictionary<MessageKind, IMessageRenderer> _renderers = new() { [MessageKind.Text] = new TextMessageRenderer(), [MessageKind.Image] = new ImageMessageRenderer(), [MessageKind.File] = new FileMessageRenderer(), };
消息类型测量要点绘制要点常见坑
文本按可用宽度算换行后的高度气泡路径 + TextRenderer测量绘制 flags 不一致
图片按最大宽高等比缩放后取高先画缩略图再画气泡底原图未缓存导致滚动掉帧
文件固定卡片高度图标 + 文件名 + 大小 + 进度条进度刷新触发全列表重绘
系统提示单行居中,无气泡灰色小字混入普通消息导致对齐错乱

接通信层的时候只有一个铁律:任何来自TcpClient、WebSocket 或后台线程的消息,都必须Invoke回 UI 线程再改_items并触发重绘。我踩过一次,后台线程直接往里Add,十次有三次抛集合修改异常,剩下七次是滚动条算错位置。稳妥做法是通信层只管往一个ConcurrentQueue里塞,UI 侧用Timer每 50 毫秒批量取出、批量追加、批量失效,既能避免频繁Invalidate,也顺手解决了高频消息下的界面抖动。

最后说个验证习惯。这类自绘界面最怕"开发机好看、客户机难看",所以我现在每次改完绘制代码,都会强制在三档缩放下各截一张图对比:100%、150%、200%,并且在副屏上再拖一遍窗口。从那以后,气泡错位和字体发虚的问题基本在提交前就被我自己拦下来了。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表