看我是如何用C#编写一个小于8KB的贪吃蛇游戏的

译者注:这是Michal Strehovský大佬的一篇文章,他目前在微软.NET Runtime团队工作,主要是负责.NET NativeAOT功能的开发。我在前几天看到这篇文章,非常喜欢,虽然它的内容稍微有点过时(还是使用的.NET Core 3.0),不过其中的一些编程技巧和思维方式很受用,特意找到Michal大佬要到了授权,翻译给大家看。

作者:Michal Strehovský
译者:InCerry
原文链接:https://medium.com/@MStrehovsky/building-a-self-contained-game-in-c-under-8-kilobytes-74c3cf60ea04

作为一个在1.44MB软盘和56kbit调制解调器时代长大的人,我一直喜欢小程序。我可以在随身携带的软盘上装下许多小程序。如果一个程序不能放在我的软盘上,我就开始思考为什么-它有大量的图形吗?有音乐吗?这个程序能做很多复杂的事情吗?还是它根本就是臃肿的?

图片来自 Brett Jordan Unsplash

现在,磁盘空间变得如此便宜(巨大的闪存盘无处不在),人们放弃了对程序大小的优化。

有一个场景的大小仍然很重要,那就是传输:当在线路上传输一个程序时,每秒只能传递兆字节的数据。一个快速的100MBit连接在最好的情况下每秒只能传输12MB。如果在线路的另一端是一个等待下载完成的人,五秒和一秒之间的差异会对他们的体验产生很大的影响。

此人可能直接(用户通过网络下载程序)或间接(部署Severless服务以响应 Web 请求)暴露在传输时间中。

人们通常认为任何快于0.1秒的东西都是即时的,3.0秒大约是用户的流量保持不间断的极限,而你很难在10秒后让用户保持参与。

虽然更小一点程序不再是必须的,但它仍然是更好的。

这篇文章是作为一个实验而出现的,目的是找出一个有用的自包含运行时的C#可执行文件可以有多小。C#应用程序能否达到用户会认为瞬间就能下载完毕的大小?它是否能使C#被用于现在还没有被使用的场景?

究竟什么是 “自包含”?

一个自包含的应用程序是指包括在操作系统的虚构安装上运行所需的一切。

C#编译器属于一组以虚拟机为目标的编译器(Java和Kotlin是该组的另一个知名的语言):C#编译器的输出是一个可执行文件,需要某种虚拟机(VM)来执行。人们不能只安装一个裸机操作系统,并期望能够在上面运行由C#编译器产生的程序。

至少在Windows上,过去人们可以依靠在整个机器上安装.NET Framework来运行C#编译器的输出。现在,有许多Windows SKU不再携带.NET Framework(物联网、Nano Server、ARM64......)。.NET Framework也不支持C#语言的最新增强功能。它有点像在走下坡路。

为了使C#应用程序自成一体,它需要包括运行时和它使用的所有类库。在我们的计划中,要把很多东西装进只有8KB的预算中!这是很重要的。

8KB的游戏

我们要创建一个克隆版的贪吃蛇游戏,下面是完成后的演示:

如果你对游戏机制不感兴趣,请随意跳到有趣的部分,我们在9个步骤中将游戏从65MB缩小到8KB(向下滚动到你看到图形的地方)。

游戏将在文本模式下运行,我们将使用框画字符来画蛇。我相信Vulcan或DirectX会更有趣,但我们会用System.Console来搞定。

一个无分配的游戏

我们将建立一个无分配的游戏 - 我所说的无分配并不是指C#游戏开发者中常见的 "不要在游戏循环中分配"。我的意思是 "在整个代码库中禁止使用引用类型的new关键字"。其原因将在缩小游戏的最后阶段变得明显。

有了这样的限制,人们可能会想,使用C#到底有没有意义:没有new关键字,我们就不会使用垃圾收集器,我们就不能抛出异常,等等 - 像C语言一样,也可以工作。

使用C#的一个原因是 "因为我们可以"。另一个原因是可测试性和代码共享 - 虽然游戏整体上是无分配的,但这并不意味着它的一部分不能在没有这种限制的不同项目中重复使用。例如,游戏的部分内容可以包含在xUnit项目中,以获得单元测试覆盖。如果选择C语言来构建游戏,那么即使代码从其他地方被重用,事情也必须受到C语言所能做到的限制。但由于C#提供了高水平和低水平结构的良好组合,我们可以遵循"默认为高水平,必要时为低水平(译者注:也就是说C#语言下限很低,上限很高的意思,99%的情况可以直接编写简单的高抽象的代码,1%的情况可以直接写类似C++低级代码)"的哲学。

为了达到8KB的部署大小,低级别的部分将是必要的。

游戏结构

让我们从一个表示帧缓冲器的结构体开始。帧缓冲器是一个组件,用来保存要绘制到屏幕上的像素(或者在这里是字符):

unsafe struct FrameBuffer
{
    public const int Width = 40;
    public const int Height = 20;
    public const int Area = Width * Height;

    fixed char _chars[Area];

    public void SetPixel(int x, int y, char character)
    {
        _chars[y * Width + x] = character;
    }

    public void Clear()
    {
        for (int i = 0; i < Area; i++)
            _chars[i] = ' ';
    }

    public readonly void Render()
    {
        Console.SetCursorPosition(0, 0);

        const ConsoleColor snakeColor = ConsoleColor.Green;

        Console.ForegroundColor = snakeColor;

        for (int i = 1; i <= Area; i++)
        {
            char c = _chars[i - 1];

            if (c == '*' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))
            {
                Console.ForegroundColor = c == '*' ? ConsoleColor.Red : ConsoleColor.White;
                Console.Write(c);
                Console.ForegroundColor = snakeColor;
            }
            else
                Console.Write(c);

            if (i % Width == 0)
            {
                Console.SetCursorPosition(0, i / Width - 1);
            }
        }
    }
}

我们提供了一些方法来设置各个像素,清除帧缓冲区,并将帧缓冲区的内容渲染到System.Console中。渲染步骤对几个字符进行了特殊处理,这样我们就可以得到彩色的输出,而不需要对帧缓冲区的每个像素进行颜色跟踪。

需要指出的一个有趣的事情是fixed _chars[Area]字段:这是C#的语法,用于声明一个固定数组。固定数组是一个数组,其各个元素是结构的一部分。您可以将其视为一组字段char _char_0, _char_1, _char_2, _char_3,...的快捷方式。_char_Area,可以作为一个数组访问。这个数组的大小需要是一个编译时的常数,以便整个结构的大小是固定的。

我们不能过分追求固定数组的大小,因为作为结构的一部分,数组需要住在堆栈中,而堆栈往往被限制在很小的字节数内(通常每个线程1MB)。但是,40*20*2字节(width*height*sizeof(char))应该没问题。

接下来我们需要的是一个随机数发生器。.NET自带的随机数发生器是一个引用类型(有很好的理由!),我们禁止自己使用new关键字 - 我们不能使用它。一个简单的结构就可以了。

struct Random
{
    private uint _val;

    public Random(uint seed)
    {
        _val = seed;
    }

    public uint Next() => _val = (1103515245 * _val + 12345) % 2147483648;
}

这个随机数发生器不是很好,但我们不需要任何复杂的东西。

现在,我们只需要一些东西来包装蛇的逻辑。是时候建立一个 "蛇"结构了。

struct Snake
{
    public const int MaxLength = 30;

    private int _length;

    // 身体是一个打包的整数,打包了X坐标、Y坐标和字符。
    // 为蛇的身体。
    // 只有原始类型可以使用C#的`固定`,因此这是一个`int`。
    private unsafe fixed int _body[MaxLength];

    private Direction _direction;
    private Direction _oldDirection;

    public Direction Course
    {
        set
        {
            if (_oldDirection != _direction)
                _oldDirection = _direction;

            if (_direction - value != 2 && value - _direction != 2)
                _direction = value;
        }
    }

    public unsafe Snake(byte x, byte y, Direction direction)
    {
        _body[0] = new Part(x, y, DirectionToChar(direction, direction)).Pack();
        _direction = direction;
        _oldDirection = direction;
        _length = 1;
    }

    public unsafe bool Update()
    {
        Part oldHead = Part.Unpack(_body[0]);
        Part newHead = new Part(
            (byte)(_direction switch
            {
                Direction.Left => oldHead.X == 0 ? FrameBuffer.Width - 1 : oldHead.X - 1,
                Direction.Right => (oldHead.X + 1) % FrameBuffer.Width,
                _ => oldHead.X,
            }),
            (byte)(_direction switch
            {
                Direction.Up => oldHead.Y == 0 ? FrameBuffer.Height - 1 : oldHead.Y - 1,
                Direction.Down => (oldHead.Y + 1) % FrameBuffer.Height,
                _ => oldHead.Y,
            }),
            DirectionToChar(_direction, _direction)
            );

        oldHead = new Part(oldHead.X, oldHead.Y, DirectionToChar(_oldDirection, _direction));

        bool result = true;

        for (int i = 0; i < _length - 1; i++)
        {
            Part current = Part.Unpack(_body[i]);
            if (current.X == newHead.X && current.Y == newHead.Y)
                result = false;
        }

        _body[0] = oldHead.Pack();

        for (int i = _length - 2; i >= 0; i--)
        {
            _body[i + 1] = _body[i];
        }

        _body[0] = newHead.Pack();

        _oldDirection = _direction;

        return result;
    }

    public unsafe readonly void Draw(ref FrameBuffer fb)
    {
        for (int i = 0; i < _length; i++)
        {
            Part p = Part.Unpack(_body[i]);
            fb.SetPixel(p.X, p.Y, p.Character);
        }
    }

    public bool Extend()
    {
        if (_length < MaxLength)
        {
            _length += 1;
            return true;
        }
        return false;
    }

    public unsafe readonly bool HitTest(int x, int y)
    {
        for (int i = 0; i < _length; i++)
        {
            Part current = Part.Unpack(_body[i]);
            if (current.X == x && current.Y == y)
                return true;
        }

        return false;
    }

    private static char DirectionToChar(Direction oldDirection, Direction newDirection)
    {
        const string DirectionChangeToChar = "│┌?┐┘─┐??└│┘└?┌─";
        return DirectionChangeToChar[(int)oldDirection * 4 + (int)newDirection];
    }

    // 帮助结构来打包和解压_body中打包的整数。
    readonly struct Part
    {
        public readonly byte X, Y;
        public readonly char Character;

        public Part(byte x, byte y, char c)
        {
            X = x;
            Y = y;
            Character = c;
        }

        public int Pack() => X << 24 | Y << 16 | Character;
        public static Part Unpack(int packed) => new Part((byte)(packed >> 24), (byte)(packed >> 16), (char)packed);
    }

    public enum Direction
    {
        Up, Right, Down, Left
    }
}

蛇需要跟踪的状态是。代表蛇的身体的每个像素的坐标:

  • 蛇的当前长度。
  • 蛇的当前方向。
  • 蛇的过去方向(以备我们需要画 "弯 "字而不是直线)。

蛇提供了一些方法来"延长"蛇的长度(如果蛇已经长到一定长度则返回false),用蛇的身体来 "测试"一个像素,"绘制"蛇到一个 "FrameBuffer"中,以及"更新"蛇的位置,作为对游戏tick的响应(如果蛇吃了自己则返回false)。还有一个属性用于设置蛇的当前"路线"。

我们使用与帧缓冲区相同的固定数组技巧来保持蛇的无分配。这意味着蛇的最大长度必须是一个编译时常数。

我们需要的最后一件事是游戏循环:

struct Game
{
    enum Result
    {
        Win, Loss
    }

    private Random _random;

    private Game(uint randomSeed)
    {
        _random = new Random(randomSeed);
    }

    private Result Run(ref FrameBuffer fb)
    {
        Snake s = new Snake(
            (byte)(_random.Next() % FrameBuffer.Width),
            (byte)(_random.Next() % FrameBuffer.Height),
            (Snake.Direction)(_random.Next() % 4));

        MakeFood(s, out byte foodX, out byte foodY);

        long gameTime = Environment.TickCount64;

        while (true)
        {
            fb.Clear();

            if (!s.Update())
            {
                s.Draw(ref fb);
                return Result.Loss;
            }

            s.Draw(ref fb);

            if (Console.KeyAvailable)
            {
                ConsoleKeyInfo ki = Console.ReadKey(intercept: true);
                switch (ki.Key)
                {
                    case ConsoleKey.UpArrow:
                        s.Course = Snake.Direction.Up; break;
                    case ConsoleKey.DownArrow:
                        s.Course = Snake.Direction.Down; break;
                    case ConsoleKey.LeftArrow:
                        s.Course = Snake.Direction.Left; break;
                    case ConsoleKey.RightArrow:
                        s.Course = Snake.Direction.Right; break;
                }
            }

            if (s.HitTest(foodX, foodY))
            {
                if (s.Extend())
                    MakeFood(s, out foodX, out foodY);
                else
                    return Result.Win;
            }

            fb.SetPixel(foodX, foodY, '*');

            fb.Render();

            gameTime += 100;

            long delay = gameTime - Environment.TickCount64;
            if (delay >= 0)
                Thread.Sleep((int)delay);
            else
                gameTime = Environment.TickCount64;
        }
    }

    void MakeFood(in Snake snake, out byte foodX, out byte foodY)
    {
        do
        {
            foodX = (byte)(_random.Next() % FrameBuffer.Width);
            foodY = (byte)(_random.Next() % FrameBuffer.Height);
        }
        while (snake.HitTest(foodX, foodY));
    }

    static void Main()
    {
        Console.SetWindowSize(FrameBuffer.Width, FrameBuffer.Height);
        Console.SetBufferSize(FrameBuffer.Width, FrameBuffer.Height);
        Console.Title = "See Sharp Snake";
        Console.CursorVisible = false;

        FrameBuffer fb = new FrameBuffer();

        while (true)
        {
            Game g = new Game((uint)Environment.TickCount64);
            Result result = g.Run(ref fb);

            string message = result == Result.Win ? "You win" : "You lose";

            int position = (FrameBuffer.Width - message.Length) / 2;
            for (int i = 0; i < message.Length; i++)
            {
                fb.SetPixel(position + i, FrameBuffer.Height / 2, message[i]);
            }

            fb.Render();

            Console.ReadKey(intercept: true);
        }
    }
}

我们使用随机数发生器生成蛇的随机位置和方向,我们随机地将食物放在游戏表面,确保它不与蛇重叠,然后开始游戏循环。

在游戏循环中,我们要求蛇更新它的位置并检查它是否吃了自己。然后我们画出蛇,检查键盘的输入,用食物对蛇进行测试,并将所有内容渲染到控制台。

这就差不多了。让我们看看我们在尺寸方面的情况。

.NET Core 3.0 贪吃蛇的大小

我把游戏放在GitHub repo中,这样你就可以跟着做了。该项目文件将根据传递给publishMode属性,以不同的配置制作游戏。要用CoreCLR生成默认配置,请运行:

dotnet publish -r win-x64 -c Release

这将产生一个单一的EXE文件,其容量高达65MB。产生的EXE包括游戏、.NET运行时和作为.NET标准部分的基础类库。你可能会说 "仍然比Electron好",但让我们看看我们是否能做得更好。

IL Linker

IL Linker是一个随.NET Core 3.0出厂的工具 - 该工具通过扫描整个程序并删除未被引用的程序集来删除你的应用程序中未使用的代码。要在项目中使用它,需要传递一个PublishTrimmed属性来发布。像这样:

dotnet publish -r win-x64 -c Release /p:PublishTrimmed=true

在这种设置下,游戏缩减到25MB。这是一个很好的开端,带来了60%的缩减,但离我们10KB的目标还很远。

IL Linker有更积极的设置,但没有公开,它们可以进一步降低这个数字,最终,我们将受到CoreCLR运行时本身coreclr.dll(5.3MB的限制)。我们可能已经在通往8KB游戏的道路上走到了死胡同。

曲线救国: Mono

Mono是另一个.NET运行时,对很多人来说是Xamarin的同义词。为了用C#贪吃蛇构建一个可执行文件,我们可以使用Mono自带的mkbundle工具。

mkbundle SeeSharpSnake.dll --simple -o SeeSharpSnake.exe

这将产生一个12.3MB的可执行文件,它依赖于mono-2.0-sgen.dll,它本身有5.9MB - 所以我们看到总共有18.2MB。当试图启动它时,我碰到了 "错误的映射文件:mono_file_map_error失败",但是除了这个错误之外,还会有其它问题,mono最终的结果是18.2 MB。

与CoreCLR不同,Mono还依赖于Visual C++运行时再分配库,而该库在默认的Windows安装中是不可用的:为了保持应用程序自成一体的目标,我们需要将该库与应用程序一起携带。这使应用程序的占用空间又增加了一兆字节左右。

我们有可能通过添加IL链接器来缩小体积,但我们会遇到与CoreCLR相同的问题-运行时(mono-2.0-sgen.dll)的大小为5.9MB(加上它上面的C++运行时库的大小),它代表了任何可能的IL级优化可能带给我们的底限。

我们可以把运行时拿掉吗?

很明显,为了达到接近8KB的目标,我们需要把运行时从应用程序中剥离出来。唯一可以做到这一点的.NET运行时是CoreRT。虽然人们通常称CoreRT为"运行时",但它更接近于一个"运行时库"。它不是像CoreCLR或Mono那样的虚拟机 - CoreRT的运行时只是一组函数,支持由CoreRT的AOT编译器产生的本地代码。

CoreRT自带的库使CoreRT看起来像其他的.NET运行时:有一个添加GC的库,添加支持反射的库,添加JIT的库,添加解释器的库,等等。但所有这些库都是可选的(包括GC)。

更多关于CoreRT与CoreCLR和Mono的不同之处在这篇文章。当我在阅读D语言的运行时间时,它让我想起了CoreRT的很多内容。这篇文章也是一个有趣的阅读。

让我们看看我们在默认的CoreRT配置下的情况:

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT

这降到了4.7MB。这是迄今为止最小的,但仍然不够好。

在CoreRT中设置节省级别为中等

CoreRT-AOT编译器提供了大量影响代码生成的设置。默认情况下,编译器试图以牺牲生成的可执行文件的大小为代价,最大限度地提高生成代码的速度和与其他.NET运行机制的兼容性。

编译器有一个内置的链接器,可以删除未使用的代码。我们在Snake项目中定义的 "CoreRT-Moderate "设置放宽了对删除未使用代码的一个限制,允许更多的删除。我们还要求编译器用程序速度换取一些额外的字节。大多数.NET程序在这种模式下都能正常工作。

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-Moderate

我们现在是4.3 MB。

在CoreRT中设置节省级别为高

我把另外几个编译选项归纳为"高"模式。这个模式将删除对许多会影响到应用程序的东西的支持,但Snake(作为低级别的东西)不会有问题。

我们将删除:

  • 框架实施细节的堆栈跟踪数据
  • 框架产生的异常中的异常信息
  • 对非英语区的支持
  • EventSource工具化
dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-High

我们已经达到了3.0MB。这是我们开始时的5%,但CoreRT还有一招。

关闭反射

CoreRT运行时库的很大一部分是用于实现.NET的反射。因为CoreRT是一个提前编译的基于运行时库的.NET实现,它不需要典型的基于虚拟机的运行时(如CoreCLR和Mono)需要的大部分数据结构。这些数据包括诸如类型、方法、签名、基础类型等的名称。CoreRT嵌入这些数据是因为使用.NET反射的程序需要它,但不是因为运行时需要它。我把这些数据称为 "反射开销",因为它对运行时来说就是这样的。

CoreRT支持一种无反射模式,可以避免这种开销。你可能会觉得很多.NET代码在没有反射的情况下无法工作,你可能是对的,但有很多东西确实可以工作,令人惊讶。Gui.cs、System.IO.Pipelines,甚至是一个基本的WinForms应用程序。贪吃蛇肯定会工作,所以让我们把这个模式打开。

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-ReflectionFree

我们现在是1.2MB。反映反射开销是相当大的!

来点骚操作

现在我们已经走到了.NET SDK可能实现的尽头,我们需要来点骚操作。我们现在要做的事情已经开始变得很荒谬了,我不指望其他人能做到这一点。我们要依靠CoreRT编译器和运行时的实现细节。

正如我们前面所看到的,CoreRT是一套运行时库,加上一个超前的编译器。如果我们用一个最小的重新实现来取代运行时库呢?我们已经决定不使用垃圾收集器,这使得这项工作更加可行。

让我们从简单的事情开始:

namespace System.Threading
{
    static class Thread
    {
        [DllImport("api-ms-win-core-synch-l1-2-0")]
        public static extern void Sleep(int delayMs);
    }
}

namespace System
{
    static class Environment
    {
        [DllImport("api-ms-win-core-sysinfo-l1-1-0")]
        private static extern long GetTickCount64();

        public static long TickCount64 => GetTickCount64();
    }
}

在这里我们重新实现了Thread.SleepEnvironment.TickCount64(用于Windows),同时避免了对现有运行时库的所有依赖。

让我们对游戏使用的System.Console子集做同样的事情:

namespace System
{
    static class Console
    {
        private enum BOOL : int
        {
            FALSE = 0,
            TRUE = 1,
        }

        [DllImport("api-ms-win-core-processenvironment-l1-1-0")]
        private static unsafe extern IntPtr GetStdHandle(int c);

        private readonly static IntPtr s_outputHandle = GetStdHandle(-11);

        private readonly static IntPtr s_inputHandle = GetStdHandle(-10);

        [DllImport("api-ms-win-core-console-l2-1-0.dll", EntryPoint = "SetConsoleTitleW")]
        private static unsafe extern BOOL SetConsoleTitle(char* c);
        public static unsafe string Title
        {
            set
            {
                fixed (char* c = value)
                    SetConsoleTitle(c);
            }
        }

        [StructLayout(LayoutKind.Sequential)]
        struct CONSOLE_CURSOR_INFO
        {
            public uint Size;
            public BOOL Visible;
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleCursorInfo(IntPtr handle, CONSOLE_CURSOR_INFO* cursorInfo);

        public static unsafe bool CursorVisible
        {
            set
            {
                CONSOLE_CURSOR_INFO cursorInfo = new CONSOLE_CURSOR_INFO
                {
                    Size = 1,
                    Visible = value ? BOOL.TRUE : BOOL.FALSE
                };
                SetConsoleCursorInfo(s_outputHandle, &cursorInfo);
            }
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleTextAttribute(IntPtr handle, ushort attribute);

        public static ConsoleColor ForegroundColor
        {
            set
            {
                SetConsoleTextAttribute(s_outputHandle, (ushort)value);
            }
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct KEY_EVENT_RECORD
        {
            public BOOL KeyDown;
            public short RepeatCount;
            public short VirtualKeyCode;
            public short VirtualScanCode;
            public short UChar;
            public int ControlKeyState;
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct INPUT_RECORD
        {
            public short EventType;
            public KEY_EVENT_RECORD KeyEvent;
        }

        [DllImport("api-ms-win-core-console-l1-2-0", EntryPoint = "PeekConsoleInputW", CharSet = CharSet.Unicode)]
        private static unsafe extern BOOL PeekConsoleInput(IntPtr hConsoleInput, INPUT_RECORD* lpBuffer, uint nLength, uint* lpNumberOfEventsRead);

        public static unsafe bool KeyAvailable
        {
            get
            {
                uint nRead;
                INPUT_RECORD buffer;
                while (true)
                {
                    PeekConsoleInput(s_inputHandle, &buffer, 1, &nRead);

                    if (nRead == 0)
                        return false;

                    if (buffer.EventType == 1 && buffer.KeyEvent.KeyDown != BOOL.FALSE)
                        return true;

                    ReadConsoleInput(s_inputHandle, &buffer, 1, &nRead);
                }
            }
        }

        [DllImport("api-ms-win-core-console-l1-2-0", EntryPoint = "ReadConsoleInputW", CharSet = CharSet.Unicode)]
        private static unsafe extern BOOL ReadConsoleInput(IntPtr hConsoleInput, INPUT_RECORD* lpBuffer, uint nLength, uint* lpNumberOfEventsRead);

        public static unsafe ConsoleKeyInfo ReadKey(bool intercept)
        {
            uint nRead;
            INPUT_RECORD buffer;
            do
            {
                ReadConsoleInput(s_inputHandle, &buffer, 1, &nRead);
            }
            while (buffer.EventType != 1 || buffer.KeyEvent.KeyDown == BOOL.FALSE);

            return new ConsoleKeyInfo((char)buffer.KeyEvent.UChar, (ConsoleKey)buffer.KeyEvent.VirtualKeyCode, false, false, false);
        }

        struct SMALL_RECT
        {
            public short Left, Top, Right, Bottom;
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleWindowInfo(IntPtr handle, BOOL absolute, SMALL_RECT* consoleWindow);

        public static unsafe void SetWindowSize(int x, int y)
        {
            SMALL_RECT rect = new SMALL_RECT
            {
                Left = 0,
                Top = 0,
                Right = (short)(x - 1),
                Bottom = (short)(y - 1),
            };
            SetConsoleWindowInfo(s_outputHandle, BOOL.TRUE, &rect);
        }

        [StructLayout(LayoutKind.Sequential)]
        struct COORD
        {
            public short X, Y;
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleScreenBufferSize(IntPtr handle, COORD size);

        public static void SetBufferSize(int x, int y)
        {
            SetConsoleScreenBufferSize(s_outputHandle, new COORD { X = (short)x, Y = (short)y });
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleCursorPosition(IntPtr handle, COORD position);

        public static void SetCursorPosition(int x, int y)
        {
            SetConsoleCursorPosition(s_outputHandle, new COORD { X = (short)x, Y = (short)y });
        }

        [DllImport("api-ms-win-core-console-l1-2-0", EntryPoint = "WriteConsoleW")]
        private static unsafe extern BOOL WriteConsole(IntPtr handle, void* buffer, int numChars, int* charsWritten, void* reserved);

        public static unsafe void Write(char c)
        {
            int dummy;
            WriteConsole(s_outputHandle, &c, 1, &dummy, null);
        }
    }
}

让我们用这个替换框架重建游戏:

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-ReflectionFree /p:IncludePal=true

不出所料,这并没有为我们节省多少。我们要替换的API已经是相对轻量级的了,重写它们只获得了几千字节,不值得一提。但这是通往我们旅程中最后一步的重要垫脚石。

替换所有的运行时库

在Snake游戏中剩下的1.2MB的代码和数据是用来支持我们看不到的东西,但却在那里 - 在我们需要它们的时候准备好了。有垃圾收集器,对异常处理的支持,当发生未处理的异常时格式化和打印堆栈痕迹到控制台的代码,以及许多其他隐藏在底层的东西。

编译器可以检测到这些都不需要,并避免生成它们,但我们要做的事情非常奇怪,不值得添加编译器功能来支持它。避免这种情况的方法是简单地提供一个替代的运行时库。

让我们从重新定义一个最小版本的基本类型开始:

namespace System
{
    public class Object
    {
        // 对象的布局是与编译器的契约.
        public IntPtr m_pEEType;
    }
    public struct Void { }

    // 原始类型的布局是特例,因为它将是递归的。
    // 这些真的不需要任何字段来工作。
    public struct Boolean { }
    public struct Char { }
    public struct SByte { }
    public struct Byte { }
    public struct Int16 { }
    public struct UInt16 { }
    public struct Int32 { }
    public struct UInt32 { }
    public struct Int64 { }
    public struct UInt64 { }
    public struct IntPtr { }
    public struct UIntPtr { }
    public struct Single { }
    public struct Double { }

    public abstract class ValueType { }
    public abstract class Enum : ValueType { }

    public struct Nullable<T> where T : struct { }
    
    public sealed class String
    {
        // 字符串类型的布局是与编译器的契约。
        public readonly int Length;
        public char _firstChar;

        public unsafe char this[int index]
        {
            [System.Runtime.CompilerServices.Intrinsic]
            get
            {
                return Internal.Runtime.CompilerServices.Unsafe.Add(ref _firstChar, index);
            }
        }
    }
    public abstract class Array { }
    public abstract class Delegate { }
    public abstract class MulticastDelegate : Delegate { }

    public struct RuntimeTypeHandle { }
    public struct RuntimeMethodHandle { }
    public struct RuntimeFieldHandle { }

    public class Attribute { }
}

namespace System.Runtime.CompilerServices
{
    internal sealed class IntrinsicAttribute : Attribute { }

    public class RuntimeHelpers
    {
        public static unsafe int OffsetToStringData => sizeof(IntPtr) + sizeof(int);
    }
}

namespace System.Runtime.InteropServices
{
    public enum CharSet
    {
        None = 1,
        Ansi = 2,
        Unicode = 3,
        Auto = 4,
    }

    public sealed class DllImportAttribute : Attribute
    {
        public string EntryPoint;
        public CharSet CharSet;
        public DllImportAttribute(string dllName) { }
    }

    public enum LayoutKind
    {
        Sequential = 0,
        Explicit = 2,
        Auto = 3,
    }

    public sealed class StructLayoutAttribute : Attribute
    {
        public StructLayoutAttribute(LayoutKind layoutKind) { }
    }
}
namespace Internal.Runtime.CompilerServices
{
    public static unsafe partial class Unsafe
    {
        // 这个方法的主体是由编译器生成的。
        // 它将做Unsafe.Add应该做的事情。只是不可能用C#来表达它。
        [System.Runtime.CompilerServices.Intrinsic]
        public static extern ref T Add<T>(ref T source, int elementOffset);
    }
}

在这一点上,让我们放弃项目文件和dotnet CLI,直接启动各个工具。我们首先启动C#编译器(CSC)。我建议从 "x64 Native Tools Command Prompt for VS 2019 "启动这些命令 - 如果你安装了Visual Studio,它就在你的开始菜单中。正确的工具版本在该窗口的PATH上。

/noconfig/nostdlib/runtimemetadataversion是编译定义System.Object的东西需要的神奇开关。我选择了.lexe文件扩展名而不是.exe,因为.exe将被用于成品。

csc.exe /debug /O /noconfig /nostdlib /runtimemetadataversion:v4.0.30319 MiniBCL.cs Game\FrameBuffer.cs Game\Random.cs Game\Game.cs Game\Snake.cs Pal\Thread.Windows.cs Pal\Environment.Windows.cs Pal\Console.Windows.cs /out:zerosnake.ilexe /langversion:latest /unsafe

这将成功地用C#编译器编译出游戏的IL字节码版本。我们仍然需要某种运行时来执行它。

让我们尝试将其送入CoreRT提前编译器,从IL中生成本地代码。如果你按照上面的步骤,你会在你的NuGet软件包缓存中找到ilc.exe,即CoreRT提前编译器(类似于%USERPROFILE%\.nuget\packages\runtime.win-x64.microsoft.dotnet.ilcompiler\1.0.0-alpha-27402-01\Tools的地方)。

ilc.exe zerosnake.ilexe -o zerosnake.obj --systemmodule zerosnake --Os -g

这将会以 "预期类型'Internal.Runtime.CompilerHelpers.StartupCodeHelpers'未在模块'zerosnake'中找到"的异常而崩溃。事实证明,除了一个托管的开发者所期望的明显的最低限度外,还有一个CoreRT编译器编译输入的最低限度。

让我们跳到后面去,添加需要的东西:

namespace Internal.Runtime.CompilerHelpers
{
    // 编译器寻找的一个类,它有帮助器来初始化
    // 进程。编译器可以优雅地处理不存在的帮助器。
    // 但是类本身不存在则无法处理。让我们添加一个空类。
    class StartupCodeHelpers
    {
    }
}

namespace System
{
    // 一种特殊的类型,编译器用它来实现通用接口
    // (例如IEnumerable<T>)的数组。我们的数组将不会实现任何通用接口。
    class Array<T> : Array { }
}

namespace System.Runtime.InteropServices
{
    // 自定义属性,标志着一个类具有特殊的"调用"。
    // 编译器有特殊的逻辑处理类型,有这个属性。
    internal class McgIntrinsicsAttribute : Attribute { }
}

namespace System.Runtime.CompilerServices
{
    // 一个负责运行静态构造函数的类。编译器将调用这个
    //代码以确保静态构造函数的运行,并且只运行一次。
    [System.Runtime.InteropServices.McgIntrinsics]
    internal static class ClassConstructorRunner
    {
        private static unsafe IntPtr CheckStaticClassConstructionReturnNonGCStaticBase(ref StaticClassConstructionContext context, IntPtr nonGcStaticBase)
        {
            CheckStaticClassConstruction(ref context);
            return nonGcStaticBase;
        }

        private static unsafe void CheckStaticClassConstruction(ref StaticClassConstructionContext context)
        {
            // 非常简化的类构造函数运行器。在现实世界中,类构造器运行器
            // 需要能够处理潜在的多个线程竞相初始化的问题。
            // 一个单一的类,并需要能够处理潜在的死锁
            // 类构造函数之间的潜在死锁。

            // 如果该类已经被初始化,我们就完成了。
            if (context.initialized == 1)
                return;

            // 将该类标记为初始化。
            context.initialized = 1;

            // 运行类的构造函数。
            Call<int>(context.cctorMethodAddress);
        }

        // 这是一个特殊的编译器内在因素,调用pfn所指向的方法。
        // 编译器会为此生成代码,我们只需将其标记为 "extern"。
        // 一旦C#得到适当的函数指针支持(计划在C#9中),就不需要这个了。
        [System.Runtime.CompilerServices.Intrinsic]
        private static extern T Call<T>(System.IntPtr pfn);
    }

    // 这个数据结构是与编译器的契约。它持有一个静态
    // 构造函数的地址,以及一个指定该构造函数是否已经执行的标志。
    [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
    public struct StaticClassConstructionContext
    {
        // 指向静态类构造方法代码的指针。这是由
        // 绑定器/运行时。
        public IntPtr cctorMethodAddress;

        // 该类的初始化状态。这被初始化为0
        // 时,运行时都会调用类库的CheckStaticClassConstruction,并使用这个上下文。
        //结构,除非初始化==1。这个检查是特定的,以允许类库为每一个Cctor存储更多的
        // 比二进制状态更多,如果它想这样做的话。
        public int initialized;
    }
}

让我们用这些新添加的代码重建IL字节码,并重新运行ILC。

csc.exe /debug /O /noconfig /nostdlib /runtimemetadataversion:v4.0.30319 MiniRuntime.cs MiniBCL.cs Game\FrameBuffer.cs Game\Random.cs Game\Game.cs Game\Snake.cs Pal\Thread.Windows.cs Pal\Environment.Windows.cs Pal\Console.Windows.cs /out:zerosnake.ilexe /langversion:latest /unsafeilc.exe zerosnake.ilexe -o zerosnake.obj --systemmodule zerosnake --Os -g

现在我们有了zerosnake.obj - 一个标准的对象文件,与其他本地编译器(如C或C++)产生的对象文件没有区别。最后一步是连接它。我们将使用link.exe工具,它应该在我们的 "x64本地工具命令提示符 "的PATH中(你可能需要在Visual Studio中安装C/C++开发工具)。

link.exe /debug:full /subsystem:console zerosnake.obj /entry:__managed__Main

__managed__Main符号名称是与编译器的契约 - 它是ILC创建的程序的托管入口的名称。

但它并没有发挥作用:

error LNK2001: unresolved external symbol RhpPInvoke
error LNK2001: unresolved external symbol SetConsoleTextAttribute
error LNK2001: unresolved external symbol WriteConsoleW
error LNK2001: unresolved external symbol GetStdHandle
...
fatal error LNK1120: 17 unresolved externals

其中一些符号看起来很熟悉 - 链接器不知道在哪里寻找我们调用的Windows API。让我们来添加这些的导入库:

link.exe /debug:full /subsystem:console zerosnake.obj /entry:__managed__Main kernel32.lib ucrt.lib

这看起来更好 - 只有4个未解决的符号:

error LNK2001: unresolved external symbol RhpPInvoke
error LNK2001: unresolved external symbol RhpPInvokeReturn
error LNK2001: unresolved external symbol RhpReversePInvoke2
error LNK2001: unresolved external symbol RhpReversePInvokeReturn2
fatal error LNK1120: 4 unresolved externals

其余缺失的符号是编译器希望在运行时库中找到的辅助工具。它们的缺失只有在链接时才会被发现,因为这些辅助工具通常是在汇编中实现的,而且编译器只用它们的符号名称来指代它们(而不是我们上面提供的其他编译器需要的类型和方法)。

当本机代码调用到托管代码,以及托管代码调用到本机代码时,这些帮助程序会建立和拆除堆栈框架。这对于GC的运行是必要的。由于我们没有GC,让我们用一段C#和另一个编译器能理解的神奇属性来存根它们。

namespace System.Runtime
{
    // 编译器理解的自定义属性,指示它
    // 在给定的符号名称下导出方法。
    internal sealed class RuntimeExportAttribute : Attribute
    {
        public RuntimeExportAttribute(string entry) { }
    }
}

namespace Internal.Runtime.CompilerHelpers
{
    class StartupCodeHelpers
    {
        // 这些方法的包含类型并不重要。
        // 让我们把它们放在StarupCodeHelpers中。
        
        [System.Runtime.RuntimeExport("RhpReversePInvoke2")]
        static void RhpReversePInvoke2(System.IntPtr frame) { }
        [System.Runtime.RuntimeExport("RhpReversePInvokeReturn2")]
        static void RhpReversePInvokeReturn2(System.IntPtr frame) { }
        [System.Runtime.RuntimeExport("RhpPInvoke")]
        static void RhpPinvoke(System.IntPtr frame) { }
        [System.Runtime.RuntimeExport("RhpPInvokeReturn")]
        static void RhpPinvokeReturn(System.IntPtr frame) { }
    }
}

在用这些修改重建C#源代码并重新运行ILC后,链接终于会成功。

我们现在已经只有27KB,而且游戏还能正常运行!

扰乱链接器

剩余的几千字节可以通过使用本地开发者用来缩小其本地应用程序的技巧来削减。

我们要做的是

  • 禁用增量链接
  • 剥离重定位信息
  • 合并可执行文件中的类似部分
  • 将可执行文件中的内部对齐设置为一个小值
link.exe /debug:full /subsystem:console zerosnake.obj /entry:__managed__Main kernel32.lib ucrt.lib /merge:.modules=.rdata /merge:.pdata=.rdata /incremental:no /DYNAMICBASE:NO /filealign:16 /align:16

成功! 最后只有8176字节,不到8KB !

游戏仍然可以运行,有趣的是,它仍然是完全可调试的 - 请在Visual Studio中打开EXE(文件->打开解决方案),打开作为游戏一部分的一个C#文件,在其中设置一个断点,点击F5启动EXE,并看到断点被击中。你可以在ILC中禁用优化,使可执行文件更容易被调试 - 只要放弃--Os参数。

我们可以把它编译得更小吗?

可执行文件仍然携带着一些并非必要的数据 - ILC编译器只是没有提供命令行选项来禁止其生成。

其中一个被生成但我们不需要的数据结构是各个方法的GC信息。CoreRT有一个精确的垃圾收集器,它要求每个方法描述GC堆的引用在方法主体的每个指令中的位置。由于我们在Snake游戏中没有垃圾收集器,这些数据是不必要的。其他运行时(例如Mono)使用保守的垃圾收集器,不需要这些数据(它只是假设堆栈和CPU寄存器的任何部分都可能是GC引用)- 保守的垃圾收集器以GC性能换取额外的大小节省。CoreRT中使用的精确的垃圾收集器也可以在保守模式下运行,但它还没有被连接起来。这是一个潜在的未来补充,我们可以利用它来使程序编译得更小。

也许有一天,我们可以使我们的游戏的简化版本适合于512字节的启动扇区。在那之前,祝你黑客行动愉快.

.NET性能优化交流群

相信大家在开发中经常会遇到一些性能问题,苦于没有有效的工具去发现性能瓶颈,或者是发现瓶颈以后不知道该如何优化。之前一直有读者朋友询问有没有技术交流群,但是由于各种原因一直都没创建,现在很高兴的在这里宣布,我创建了一个专门交流.NET性能优化经验的群组,主题包括但不限于:

  • 如何找到.NET性能瓶颈,如使用APM、dotnet tools等工具
  • .NET框架底层原理的实现,如垃圾回收器、JIT等等
  • 如何编写高性能的.NET代码,哪些地方存在性能陷阱

希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET性能问题和宝贵的性能分析优化经验。目前一群已满,现在开放二群,可以直接扫码进入。

如果提示已经达到200人,可以加我微信,我拉你进群: ls1075
微信长按下图即可加群

另外也创建了QQ群,群号: 687779078,欢迎大家加入。

image-20230107220536830

 

出处:https://www.cnblogs.com/InCerry/p/building-a-self-contained-game-in-c-under-8-kilobytes-74c3cf60ea04.html

=======================================================================================

8KB的C#贪吃蛇游戏热点答疑和.NET7版本

在之前的一篇文章《看我是如何用C#编写一个小于8KB的贪吃蛇游戏》中,介绍了在.NET Core 3.0的环境下如何将贪吃蛇游戏降低到8KB。不过也有很多小伙伴提出了一些疑问和看法,主要是下面这几个方面:

  • .NET Core 3.0可以做到这么小,那么.NET7表现会不会更好?
  • 不敢在生产中用这样的方式,我看CoreRT这个仓库我看已经归档了。
  • 这样子弄太麻烦了,有没有更简单的办法?

今天笔者就给大家一一解答这些问题。

.NET7下的贪吃蛇游戏、

我们知道在.NET7中已经发布了NativeAOT正式的支持,经过.NET5、.NET6的迭代,NativeAOT已经基本成熟可用,那么在.NET7中重新编译这个游戏,有没有什么进步呢?让我们来看一看。

有外网条件的朋友可以看下方的这个GITHUB链接的代码,这个代码就是提交了升级.NET7 NativeAOT的实现:
https://github.com/MichalStrehovsky/SeeSharpSnake/pull/24

使用.NET7单文件发布

为了达到我们的目的,对于这个项目的csproj文件需要有一些小的改动。首先就是将对应的TargetFramework修改为net7.0版本。

此时就已经完成.NET Core 3.1到NET7.0的迁移了,我们运行下面的命令,可以获得一个65MB大小的程序,这个和之前.NET Core 3.1没有什么区别。

dotnet publish -r win-x64 -c Release

开启IL Linker

另外后面的.NET版本支持更好的程序集剪裁,也就是IL Linker工具,我们运行命令行时/p:PublishTrimmed=true选项就可以启用。

dotnet publish -r win-x64 -c Release /p:PublishTrimmed=true

此时我们可以发现,只有11MB大小了,比.NET Core 3.0时代的25MB降低了一半多。

使用NativeAOT功能

然后我们就要开始使用.NET7的NativeAOT功能,需要在项目文件中加入<PublishAot>true</PublishAot>选项。我们加入了一个条件,在平时不开启,只有输入不同Mode的时候才开启。

dotnet publish -r win-x64 -c Release /p:Mode=NativeAOT

此时可以获得一个2.86MB大小的程序,比.NET Core 3.0时代的4.7MB要小了快一半。

使用Moderate模式

继续修改csproj文件,让它支持Moderate模式,也就是使用<IlcGenerateCompleteTypeMetadata>false</IlcGenerateCompleteTypeMetadata>不生成完整的类型元数据,另外也用<IlcOptimizationPreference>Size</IlcOptimizationPreference>让编译器为程序大小进行优化,而不是速度。由于后面的模式也需要支持这个,所以加入了很多条件编译的选项。

dotnet publish -r win-x64 -c Release /p:Mode=NativeAOT-Moderate

结果和上面的一样的2.86MB,也就是说现在NativeAOT应该默认就是Moderate模式。

进一步移除无关数据

接下来我们进一步移除无关的数据。

  • 使用<EventSourceSupport>false</EventSourceSupport>关闭对EventSource的支持
  • 使用<UseSystemResourceKeys>true</UseSystemResourceKeys>删除 System.* 程序集的异常消息。
  • 使用<InvariantGlobalization>true</InvariantGlobalization>删除全球化特定的代码和数据。
dotnet publish -r win-x64 -c Release /p:Mode=NativeAOT-High

此时我们再次发布,可以看到大小已经降低到了2.15MB,比.NET Core 3.0时的3.0MB降低了快30%。

继续移除无关数据

  • 通过<IlcGenerateStackTraceData>false</IlcGenerateStackTraceData>移除堆栈跟踪数据
  • 通过<IlcInvariantGlobalization>true</IlcInvariantGlobalization>移除其它语言的支持
  • 通过<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>将相同的方法体进行合并。

只为我们省下了几百KB,此时大小来到了1.88MB

关闭反射

接下来我们可以继续使用<IlcDisableReflection>true</IlcDisableReflection>来关闭反射,移除掉一些反射的元数据。

dotnet publish -r win-x64 -c Release /p:Mode=NativeAOT-ReflectionFree

关闭反射后,大小来到了1.21MB,这应该是不用骚操作能达到的最小大小了。

和.NET Core 3.0的对比

下图是.NET7和.NET Core 3.0在不同模式下大小的对比,可以看到经过.NET 5.0、.NET 6.0的发展,NativeAOT变得更加成熟了。

模式.NET Core 3.0.NET7.0幅度
单文件发布 65MB 65MB 0%
IL Linker剪裁 25MB 11MB -56%
NativeAOT 4.7MB 2.86MB -40%
NativeAOT-High 3.0MB 1.88MB -38%
关闭反射 1.21MB 1.21MB 0%

关于CoreRT

在博客园的评论中,看到有一位朋友留言,说不敢在生产环境中使用,而且CoreRT已经归档。其实大可放心的使用,CoreRT关闭的原因也正如下面链接仓库里面说的一样,是代码已经合并到runtimelab/nativeaot项目中。
https://github.com/dotnet/corert

而NativeAOT已经从实验室中毕业,合并到dotnet/runtime中了,也就是.NET7看到的<PublishAot>选项,可以关注下面的微软文档。
https://learn.microsoft.com/zh-cn/dotnet/core/deploying/native-aot/

NoRuntime用起来很折腾

另外看到评论区大家吐槽的点就是后面那些骚操作看起来很麻烦,有没有更简单的方式?这个其实是有的,上篇文章的作者推出了bflat这个项目。

bflat是Roslyn(生成.NET可执行文件的"官方"C#编译器)和NativeAOT(née CoreRT)的混合物,NativeAOT(née CoreRT)是基于CoreCLR的.NET的提前编译器。因此,您可以使用高性能 CoreCLR GC 和本机代码生成器 (RyuJIT) 访问最新的 C# 功能。

bflat 将两个组件合并到一个用于 C# 的提前交叉编译器和运行时中。bflat目前可以针对:

  • x64/arm64 基于 glibc 的 Linux(x64 (~CentOS 7) 上为 2.17 或更高版本,arm64 (~Ubuntu 18.04)上为 2.27 或更高版本)
  • arm64 基于 bionic 的 Linux(Android API 级别 21)
  • x64/arm64 Windows (Windows 7 或更高版本)
  • x64 UEFI(仅适用于--stdlib:zero)

对基于 musl 的 Linux 的支持正在开发中。bflat 可以生成本机可执行文件,也可以生成可通过 FFI 从其他语言调用的本机共享库,下面是它的开源地址:
https://github.com/bflattened/bflat

使用NoRuntime模式最小可以做到4KB大小,而且支持无操作系统裸机UEFI启动。

总结

我们可以惊喜的看到NativeAOT经过几年的发展已经逐步走向成熟,另外还有裸机可运行的C#程序,这给了我们很多的想象空间,可能有那么一天C#程序会运行在只有几百KB内存的物联网终端设备上,UEFI启动程序使用C#编写等等。

.NET性能优化交流群

相信大家在开发中经常会遇到一些性能问题,苦于没有有效的工具去发现性能瓶颈,或者是发现瓶颈以后不知道该如何优化。之前一直有读者朋友询问有没有技术交流群,但是由于各种原因一直都没创建,现在很高兴的在这里宣布,我创建了一个专门交流.NET性能优化经验的群组,主题包括但不限于:

  • 如何找到.NET性能瓶颈,如使用APM、dotnet tools等工具
  • .NET框架底层原理的实现,如垃圾回收器、JIT等等
  • 如何编写高性能的.NET代码,哪些地方存在性能陷阱

希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET性能问题和宝贵的性能分析优化经验。目前一群已满,现在开放二群,可以直接扫码进入。

如果提示已经达到200人,可以加我微信,我拉你进群: ls1075

 

另外也创建了QQ群,群号: 687779078,欢迎大家加入。

 

 

出处:https://www.cnblogs.com/InCerry/p/csharp-8kb-snake-qa-1.html

posted on 2023-02-07 11:06  jack_Meng  阅读(92)  评论(0编辑  收藏  举报

导航