如何在C# Windows控制台应用程序中更新当前行?

635

在C#中构建Windows控制台应用程序时,是否可以在不扩展当前行或转到新行的情况下写入控制台?例如,如果我想显示一个表示进程完成程度有多接近的百分比,我只想在光标所在的同一行上更新值,而不必将每个百分比放在新行上。

使用“标准”的C#控制台应用程序能否实现这一点?


1
如果你真的对酷炫的命令行界面感兴趣,那么你应该看看curses/ncurses。 - Charles D Pantoga
@CharlesAddis 但是 curses/ncurses 只能在 C++ 中工作吗? - Xam
3
@Xam 在进行 .NET Core 的跨平台编程时,我选择了 curses 库作为示例实现。该库的名称是 dotnet-curses - McGuireV10
18个回答

952

如果你只打印"\r"到控制台,光标将返回当前行的开头,然后你就可以重写它。下面这个代码应该可以解决问题:

for(int i = 0; i < 100; ++i)
{
    Console.Write("\r{0}%   ", i);
}

注意数字后面的几个空格,以确保之前的内容被擦除。
同时注意使用Write()而不是WriteLine(),因为您不想在行末添加"\n"。


11
for(int i = 0; i <= 100; ++i) 的意思是“当 i 等于从 0 到 100 的任何整数时,程序将执行”。这句话表示循环将执行 101 次,直到达到最大值 100。 - string.Empty
19
如果之前的写作长度比新写作长,你该如何处理?有什么方法可以获取控制台的宽度并用空格填充行吗? - Drew Chapin
9
我可以想到两种答案来回答你的问题。它们都涉及先将当前输出保存为字符串,然后填充一定数量的字符,如下所示:Console.Write("\r{0}", strOutput.PadRight(nPaddingCount, ' ')); “nPaddingCount”可以是你自己设置的数字,或者你可以跟踪先前的输出并将nPaddingCount设置为先前和当前输出长度之差加上当前输出长度。如果nPaddingCount为负数,则无需使用PadRight,除非你做abs(prev.len-curr.len)。 - John Odom
1
@malgm 代码组织得很好。如果十几个线程中的任何一个都可以随时写入控制台,那么无论你是否编写新行,这都会给你带来麻烦。 - Mark
2
@JohnOdom 你只需要保留之前(未填充)的输出长度,然后将其作为 PadRight 的第一个参数输入(当然,首先要保存未填充的字符串或长度)。 - Jesper Matthiesen
显示剩余5条评论

316
你可以使用 Console.SetCursorPosition 来设置光标位置,然后在当前位置写入内容。
这里有一个展示简单“旋转器”的示例
static void Main(string[] args)
{
    var spin = new ConsoleSpinner();
    Console.Write("Working....");
    while (true) 
    {
        spin.Turn();
    }
}

public class ConsoleSpinner
{
    int counter;

    public void Turn()
    {
        counter++;        
        switch (counter % 4)
        {
            case 0: Console.Write("/"); counter = 0; break;
            case 1: Console.Write("-"); break;
            case 2: Console.Write("\\"); break;
            case 3: Console.Write("|"); break;
        }
        Thread.Sleep(100);
        Console.SetCursorPosition(Console.CursorLeft - 1, Console.CursorTop);
    }
}
请注意,您需要确保用新输出或空白覆盖任何现有输出。
更新:由于该示例仅将光标向后移动一个字符而受到批评,因此我要补充说明:使用SetCursorPosition函数,您可以将光标定位到控制台窗口中的任何位置。
Console.SetCursorPosition(0, Console.CursorTop);

将光标设置在当前行的开头(或者您可以直接使用Console.CursorLeft = 0)。


9
使用 \r 可能可以解决这个问题,但使用 SetCursorPosition (或 CursorLeft)可以更灵活地解决问题,例如不只在行的开头写入,向窗口上方移动等等。因此,这是一种更通用的方法,可以用于输出自定义进度条或 ASCII 图形等内容。 - Dirk Vollmar
15
因为超出职责范围,你用尽了口才并表现得非常卖力,所以我要给你一个加分("+1")的评价。感谢你提供这样优秀的内容,真是太好了。 - Copas
1
+1 表示展示了另一种方法。其他人都使用 \r,但如果 OP 只是更新百分比,那么他可以只更新值而无需重写整行代码。 OP 实际上从未说过他想要移动到行首,只是想要在光标所在的同一行上更新某些内容。 - Andy
5
请确认行长不会导致控制台换行,否则可能会导致内容溢出控制台窗口,造成问题。 - Mandrake
1
4 之后将计数器重置为 0 可能是一个好主意,这样数字就永远不会溢出并导致意外行为。 - SeinopSys
显示剩余11条评论

106

到目前为止,我们有三种竞争性的替代方案来完成这个任务:

Console.Write("\r{0}   ", value);                      // Option 1: carriage return
Console.Write("\b\b\b\b\b{0}", value);                 // Option 2: backspace
{                                                      // Option 3 in two parts:
    Console.SetCursorPosition(0, Console.CursorTop);   // - Move cursor
    Console.Write(value);                              // - Rewrite
}

我一直使用Console.CursorLeft = 0,这是第三种选项的变体,所以我决定进行一些测试。这是我使用的代码:

public static void CursorTest()
{
    int testsize = 1000000;

    Console.WriteLine("Testing cursor position");
    Stopwatch sw = new Stopwatch();
    sw.Start();
    for (int i = 0; i < testsize; i++)
    {
        Console.Write("\rCounting: {0}     ", i);
    }
    sw.Stop();
    Console.WriteLine("\nTime using \\r: {0}", sw.ElapsedMilliseconds);

    sw.Reset();
    sw.Start();
    int top = Console.CursorTop;
    for (int i = 0; i < testsize; i++)
    {
        Console.SetCursorPosition(0, top);        
        Console.Write("Counting: {0}     ", i);
    }
    sw.Stop();
    Console.WriteLine("\nTime using CursorLeft: {0}", sw.ElapsedMilliseconds);

    sw.Reset();
    sw.Start();
    Console.Write("Counting:          ");
    for (int i = 0; i < testsize; i++)
    {        
        Console.Write("\b\b\b\b\b\b\b\b{0,8}", i);
    }

    sw.Stop();
    Console.WriteLine("\nTime using \\b: {0}", sw.ElapsedMilliseconds);
}

在我的机器上,我得到了以下结果:

  • 退格键: 25.0秒
  • 回车: 28.7秒
  • SetCursorPosition: 49.7秒

此外,SetCursorPosition会导致明显的闪烁,而我在其他两种方法中没有观察到。因此,教训是尽可能使用退格键或回车键,并且感谢Stack Overflow教给我一种更快的方法!


更新: 在评论中,Joel表明,相对于移动距离,SetCursorPosition是恒定的,而其他方法是线性的。进一步测试证实了这一点,但是恒定时间和缓慢仍然是缓慢的。在我的测试中,向控制台写入一长串退格键比SetCursorPosition快,直到大约60个字符左右。所以,对于替换长度小于60个字符(左右)的行部分,使用退格键更快,而且不会闪烁,因此我仍然支持使用\b而非\r和SetCursorPosition


4
这个操作的效率其实并不重要,它应该在用户察觉不到的时间内迅速完成。不必要的微小优化是不好的。 - Malfist
1
我同意这是微小的优化(即使运行一百万次并花费50秒仍然是非常短的时间),对于结果加1,这肯定是非常有用的知识。 - Andy
6
基准测试存在根本性缺陷。可能会出现这样的情况:无论光标移动多远,SetCursorPosition() 方法所需时间相同,而其他选项所需时间则视控制台需要处理的字符数而异。 - Joel Coehoorn
1
这是对不同选项的非常好的总结。然而,我在使用\r时也看到了闪烁。使用\b时显然没有闪烁,因为固定文本(“Counting:”)没有被重写。如果您添加其他\b并重写固定文本,则也会出现闪烁,就像\b和SetCursorPosition一样。关于Joel的评论:Joel基本上是正确的,但是在非常长的行上\r仍将优于SetCursorPosition,但差异会变小。 - Dirk Vollmar
Stack Overflow,即使是光标移动也会被基准测试... - JJS
显示剩余2条评论

30

你可以使用\b(退格)转义序列来备份当前行中特定数量的字符。这只是移动当前位置,不会删除字符。

例如:

string line="";

for(int i=0; i<100; i++)
{
    string backup=new string('\b',line.Length);
    Console.Write(backup);
    line=string.Format("{0}%",i);
    Console.Write(line);
}

这里,line 是要写入控制台的百分比行。关键是为先前的输出生成正确数量的 \b 字符。

\r 方法相比,它的优点是即使你的百分比输出不在行的开头,它也可以工作。


1
+1,这证明了这是最快的方法(请参见下面我的测试评论)。 - Kevin

21

\r用于以下情况。
\r 代表换行符,表示光标返回到行首。
这就是为什么Windows使用\n\r作为其换行标记的原因。
\n将光标向下移一行,而\r将光标返回到行首。


28
除了它实际上是换行符号\r\n。 - Joel Mueller

15

我刚才只是需要使用divo的ConsoleSpinner类。尽管我的代码远不及他的简洁,但让我感到不舒服的是那个类的用户必须自己编写while(true)循环。我希望实现的体验更像这样:

static void Main(string[] args)
{
    Console.Write("Working....");
    ConsoleSpinner spin = new ConsoleSpinner();
    spin.Start();

    // Do some work...

    spin.Stop(); 
}

下面的代码帮助我意识到了这一点。由于我不想让我的Start()方法阻塞,也不希望用户担心编写类似于while(spinFlag)的循环,并且我想同时允许多个旋转器,因此我不得不生成一个单独的线程来处理旋转。这意味着代码必须更加复杂。

另外,我并没有做过太多的多线程编程,所以其中可能会留下一些微妙的 bug。但到目前为止它似乎运行得相当不错:

public class ConsoleSpinner : IDisposable
{       
    public ConsoleSpinner()
    {
        CursorLeft = Console.CursorLeft;
        CursorTop = Console.CursorTop;  
    }

    public ConsoleSpinner(bool start)
        : this()
    {
        if (start) Start();
    }

    public void Start()
    {
        // prevent two conflicting Start() calls ot the same instance
        lock (instanceLocker) 
        {
            if (!running )
            {
                running = true;
                turner = new Thread(Turn);
                turner.Start();
            }
        }
    }

    public void StartHere()
    {
        SetPosition();
        Start();
    }

    public void Stop()
    {
        lock (instanceLocker)
        {
            if (!running) return;

            running = false;
            if (! turner.Join(250))
                turner.Abort();
        }
    }

    public void SetPosition()
    {
        SetPosition(Console.CursorLeft, Console.CursorTop);
    }

    public void SetPosition(int left, int top)
    {
        bool wasRunning;
        //prevent other start/stops during move
        lock (instanceLocker)
        {
            wasRunning = running;
            Stop();

            CursorLeft = left;
            CursorTop = top;

            if (wasRunning) Start();
        } 
    }

    public bool IsSpinning { get { return running;} }

    /* ---  PRIVATE --- */

    private int counter=-1;
    private Thread turner; 
    private bool running = false;
    private int rate = 100;
    private int CursorLeft;
    private int CursorTop;
    private Object instanceLocker = new Object();
    private static Object console = new Object();

    private void Turn()
    {
        while (running)
        {
            counter++;

            // prevent two instances from overlapping cursor position updates
            // weird things can still happen if the main ui thread moves the cursor during an update and context switch
            lock (console)
            {                  
                int OldLeft = Console.CursorLeft;
                int OldTop = Console.CursorTop;
                Console.SetCursorPosition(CursorLeft, CursorTop);

                switch (counter)
                {
                    case 0: Console.Write("/"); break;
                    case 1: Console.Write("-"); break;
                    case 2: Console.Write("\\"); break;
                    case 3: Console.Write("|"); counter = -1; break;
                }
                Console.SetCursorPosition(OldLeft, OldTop);
            }

            Thread.Sleep(rate);
        }
        lock (console)
        {   // clean up
            int OldLeft = Console.CursorLeft;
            int OldTop = Console.CursorTop;
            Console.SetCursorPosition(CursorLeft, CursorTop);
            Console.Write(' ');
            Console.SetCursorPosition(OldLeft, OldTop);
        }
    }

    public void Dispose()
    {
        Stop();
    }
}

不错的修改,虽然示例代码并不是我的。它来自Brad Abrams的博客(请看我回答中的链接)。我认为它只是被写成一个简单的示例来演示SetCursorPosition。顺便说一下,我对关于我认为只是一个简单示例开始的讨论感到惊讶(以积极的方式)。这就是我喜欢这个网站的原因 :-) - Dirk Vollmar

5
    public void Update(string data)
    {
        Console.Write(string.Format("\r{0}", "".PadLeft(Console.CursorLeft, ' ')));
        Console.Write(string.Format("\r{0}", data));
    }

4

如果你想要在行的开头显式地使用回车符 (\r) 而不是(隐式或显式地)使用换行符 (\n),那么这样做应该可以得到你想要的结果。例如:

void demoPercentDone() {
    for(int i = 0; i < 100; i++) {
        System.Console.Write( "\rProcessing {0}%...", i );
        System.Threading.Thread.Sleep( 1000 );
    }
    System.Console.WriteLine();    
}

-1,问题要求使用C#,我将其重写为C#,然后你又将其改回F#。 - Malfist
看起来更像是编辑冲突,而不是他将你的C#改回F#。他的更改比你的晚了一分钟,并且集中在sprintf上。 - Andy
感谢您的编辑。我倾向于使用F#交互模式来测试代码,并且认为重要的部分是BCL调用,这在C#中是相同的。 - James Hugard

1

来自 MSDN 的控制台文档:

您可以通过将 Out 或 Error 属性的 TextWriter.NewLine 属性设置为另一个行终止字符串来解决此问题。例如,C# 语句 Console.Error.NewLine = "\r\n\r\n"; 将标准错误输出流的行终止字符串设置为两个回车和换行序列。然后,您可以显式调用错误输出流对象的 WriteLine 方法,如 C# 语句 Console.Error.WriteLine();

所以 - 我这样做了:

Console.Out.Newline = String.Empty;

然后我就能够自己控制输出了;

Console.WriteLine("Starting item 1:");
    Item1();
Console.WriteLine("OK.\nStarting Item2:");

另一种到达那里的方式。


你可以只使用Console.Write()实现相同的功能,而不重新定义NewLine属性... - Radosław Gers

1
如果你想让生成文件看起来很酷,这个方法就适用。
                int num = 1;
                var spin = new ConsoleSpinner();
                Console.ForegroundColor = ConsoleColor.Green;
                Console.Write("");
                while (true)
                {
                    spin.Turn();
                    Console.Write("\r{0} Generating Files ", num);
                    num++;
                }

这是我从下面某个答案中得到并修改的方法。

public class ConsoleSpinner
    {
        int counter;

        public void Turn()
        {
            counter++;
            switch (counter % 4)
            {
                case 0: Console.Write("."); counter = 0; break;
                case 1: Console.Write(".."); break;
                case 2: Console.Write("..."); break;
                case 3: Console.Write("...."); break;
                case 4: Console.Write("\r"); break;
            }
            Thread.Sleep(100);
            Console.SetCursorPosition(23, Console.CursorTop);
        }
    }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接