You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

自定义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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.04 21:12:01