自定义RichTextBox批量追加聊天行UI线程卡顿问题求解
问题描述
我有一个继承自RichTextBox的自定义控件,用于展示从游戏中提取的聊天信息。每条聊天行都存储在ChatLine自定义对象中,该对象除文本外还保存了颜色、时间戳、发送人昵称等元数据,自定义RichTextBox会使用这些元数据对内容做格式化处理。相关代码如下:
public class ChatLine { // 与问题无关的代码已移除 public string Text { get; set; } public Color Color { get; set; } public DateTime Timestamp { get; set; } public string Name { get; set; } // 发送该消息的玩家昵称 } public partial class CustomRichTextbox : RichTextBox { private readonly Object syncRoot = new(); private readonly List<ChatLine> displayedChatLines = new(); internal IReadOnlyCollection<ChatLine> DisplayedChatLines { get { return displayedChatLines.AsReadOnly(); } } public CustomRichTextbox() { InitializeComponent(); } protected override void OnPaint(PaintEventArgs pe) { base.OnPaint(pe); } /// <summary> /// 向文本框追加文本并自动换行,返回追加的第一行位置 /// </summary> /// <remarks> /// 如果你需要按行展示昵称,可以传入第二个参数,即不需要显示的昵称 /// </remarks> internal int AppendLines(IEnumerable<ChatLine> lines, string name = "") { // 文本框为空时行数为0,直接返回即可 // 文本框不为空时返回行数-1 // Math.Max处理两种边界情况 var pos = Math.Max(0, Lines.Length - 1); lock (syncRoot) { base.AppendText(String.Join(String.Empty, lines.Select(FormatLine))); displayedChatLines.AddRange(lines); } // 追加后给行上色(这个循环非常耗时) for (int i = lines.Count() - 1; i >= 0; i--) { var index = pos + i; var line = displayedChatLines[index]; var start = this.GetFirstCharIndexFromLine(index); var timestampEnd = this.Find(">", start, RichTextBoxFinds.None); var textStart = timestampEnd + 2; var lineLength = this.Lines[index].Length; // 给时间戳上色 this.Select(start, timestampEnd - start + 1); this.SelectionColor = this.ForeColor; this.SelectionBackColor = this.BackColor; this.SelectionFont = this.Font; this.SelectionAlignment = HorizontalAlignment.Left; if (String.IsNullOrEmpty(name) || name == line.Name) { // 行内不显示昵称 this.Select(textStart, start + lineLength - textStart + 1); this.SelectionColor = line.Color; this.SelectionBackColor = (line.Color == Color.White) ? Color.DarkGray : this.BackColor; } else { // 行内显示昵称 var nameStart = this.Find($" {line.Name}", start, start + lineLength, RichTextBoxFinds.Reverse); this.Select(textStart, nameStart - textStart); this.SelectionColor = line.Color; this.SelectionBackColor = (line.Color == Color.White) ? Color.DarkGray : this.BackColor; this.Select(nameStart, start + lineLength - nameStart + 1); this.SelectionColor = Color.DarkGray; this.SelectionBackColor = this.BackColor; this.SelectionFont = new Font(this.Font.FontFamily, 8); this.SelectionAlignment = HorizontalAlignment.Right; // 这个设置会导致整行右对齐 } } this.DeselectAll(); // 必须调用,否则恢复滚动位置会失效 return pos; string FormatLine(ChatLine line) { return (String.IsNullOrEmpty(name) || name == line.Name) ? $"<{line.Timestamp: hh:mm:ss tt}> {line.Text}{Environment.NewLine}" : $"<{line.Timestamp: hh:mm:ss tt}> {line.Text} {line.Name}{Environment.NewLine}"; } } }
上述代码在更新行数较少时运行正常,但当需要一次性更新上千行内容时,UI线程会卡顿数秒才能完成全部绘制工作。我已经定位到耗时代码为代码中注释标注的循环部分,注释掉该循环后卡顿问题就会消失。通常我会把耗时逻辑放到BackgroundWorker中执行或者使用async/await模式处理,但问题在于该耗时片段中的所有操作都需要在UI线程上执行,无法将其转移到其他线程运行。请问这种情况下我该如何解决卡顿问题?
解决方案
1. 临时禁用控件重绘,批量操作完成后再恢复
这是性价比最高的优化方案:RichTextBox默认每次修改选择范围、样式属性都会触发重绘,上千次循环就会触发上千次重绘,是卡顿的核心来源。你可以通过Windows消息临时关闭重绘,所有样式设置完成后再开启,全程只触发一次重绘。
首先在自定义控件中引入Win32 API:
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); private const int WM_SETREDRAW = 0x0B;
然后修改循环部分的代码:
// 循环开始前关闭重绘 SendMessage(this.Handle, WM_SETREDRAW, (IntPtr)0, IntPtr.Zero); try { // 原来的格式化循环放在此处执行 } finally { // 所有操作完成后恢复重绘并刷新控件 SendMessage(this.Handle, WM_SETREDRAW, (IntPtr)1, IntPtr.Zero); this.Invalidate(); }
仅这一项优化就能把上千行的处理时间从秒级降到毫秒级。
2. 移除不必要的控件交互操作,预计算偏移
你当前循环中调用的Find、GetFirstCharIndexFromLine、Lines[index]都是调用RichTextBox的非托管接口,单次调用开销远高于普通内存操作,而且你完全可以提前计算出所有偏移,不需要调用这些方法:
- 时间戳格式固定为
<hh:mm:ss tt>,长度固定为14个字符,时间戳结束位置直接就是start + 13,不需要调用Find(">")查找 - 生成每行文本的时候就可以记录昵称的起始位置,不需要反向查找
- 先把输入的
IEnumerable<ChatLine> lines转为数组或List,不要每次循环都调用lines.Count(),避免重复遍历集合
3. 复用资源,减少对象创建
当前代码每次遇到要显示昵称的行都会新建一个8号字体对象,重复创建不仅耗时,还会增加GC压力。可以把这个字体缓存为控件的私有字段,初始化时创建一次,全局复用。
4. (可选)直接拼接RTF内容一次性赋值
如果性能要求更高,可以不用逐行设置选择样式,直接按照RTF格式规范拼接完整的带样式RTF字符串,一次性赋值给控件的Rtf属性,性能会比逐行设置高一个数量级,适合万行以上的批量更新场景。
内容的提问来源于stack exchange,提问作者Setsu

