zoukankan      html  css  js  c++  java
  • 内存包装类 Memory 和 Span 相关类型

    1. 前言

    此文章是官方文档的翻译,由于官方文档中文版是机器翻译的,有些部分有疏漏和错误,所以本人进行了翻译供大家学习,如有问题欢迎指正。

    参考资料
    memory-and-spans --- Microsoft

    2. 简介

    .NET 包含多个相互关联的类型,它们表示任意内存的连续的强类型区域。 这些方法包括:

    • System.Span<T>

      • 用于访问连续的内存区域
      • 得到该类型的实例:
        • 1个T类型的数组
        • 1个String
        • 1个使用 stackalloc 分配的缓冲区
        • 1个指向非托管内存的指针
      • 实例必须存储在堆栈(stack)上,因此有很对限制
        • 类的字段不能是此类型
        • 不能在异步操作中使用
    • System.ReadOnlySpan<T>

      • Span<T> 结构体的不可变版本
    • System.Memory<T>

      • 连续的内存区域的包装器
      • 实例创建
        • T 类型数组
        • String
        • 内存管理器
        • 实例可以存储在托管堆(managed heap)上,所以它没有 Span<T> 的限制
    • System.ReadOnlyMemory<T>

      • Memory<T> 结构的不可变版本。
    • System.Buffers.MemoryPool<T>

      • 它将强类型内存块从内存池分配给所有者
        • IMemoryOwner<T> 实例可以通过调用 MemoryPool<T>.Rent 从池中租用
        • 通过调用 MemoryPool<T>.Dispose() 将其释放回池中
    • System.Buffers.IMemoryOwner<T>

      • 表示内存块的所有者,管理其生命周期
    • MemoryManager<T>

      • 一个抽象基类,可用于替换 Memory<T> 的实现,以便 Memory<T> 可以由其他类型(如安全句柄(safe handles))提供支持
      • MemoryManager<T> 适用于高级方案。
    • ArraySegment<T>

      • 是数组的包装,对应数组中,从特定索引开始的特定数量的一系列元素
    • System.MemoryExtensions

      • 用于将String、数组和数组段(ArraySegment<T>)转换为 Memory<T> 块的扩展方法集

    System.Span<T>System.Memory<T> 及其对应的只读类型被设计为:

    • 避免不必要地复制内存或在托管堆上进行内存分配
    • 通过 Slice 方法或这些类型的的构造函数创建它们, 并不涉及复制底层缓冲(underlying buffers): 只更新相关引用和偏移
      • 形象的说就是,只更新我们可以访问到的内存的位置和范围,而不是将这些内存数据复制出来

    备注:
    对于早期框架,Span<T>Memory<T>System.Memory NuGet 包中提供。

    使用 memory 和 span

    • 由于 memory 和 span 相关类型通常用于在处理 pipeline 中存储数据,因此开发人员在使用 Span<T>Memory<T> 和相关类型时要务必遵循一套最佳做法。 Memory<T>Span<T> 使用准则中介绍了这些最佳做法。

    3. Memory<T>和Span<T>使用准则

    • Span<T>ReadOnlySpan<T>
      • 是可由托管或非托管内存提供支持的轻量级内存缓冲区
    • Memory<T> 及其相关类型
      • 由托管和非托管内存提供支持
      • Span<T> 不同,Memory<T> 可以存储在托管堆上

    Span<T>Memory<T> 都是可用于 pipeline 的结构化数据的缓冲区。

    • 它们设计的目的是将某些或所有数据有效地传递到 pipeline 中的组件,这些组件可以对其进行处理并修改(可选)缓冲区
    • 由于 Memory<T> 及其相关类型可由多个组件或多个线程访问,因此开发人员必须遵循一些标准使用准则才能生成可靠的代码

    3.1. 所有者, 消费者和生命周期管理

    由于可以在各个 API 之间传送缓冲区,以及由于缓冲区有时可以从多个线程进行访问,因此请务必考虑生命周期管理。 下面介绍三个核心概念:

    • 所有权:
      • 缓冲区实例的所有者负责生命周期管理,包括当不再使用缓冲区时将其销毁
      • 所有缓冲区都拥有一个所有者
      • 通常,所有者是创建缓冲区或从工厂接收缓冲区的组件
      • 所有权也可以转让;
        • 组件 A 可以将缓冲区的控制权转让给组件 B,此时组件 A 就无法再使用该缓冲区,组件 B 将负责在不再使用缓冲区时将其销毁。
    • 消费:
      • 允许缓冲区实例的消费者通过读取和写入来使用缓冲区实例
      • 缓冲区一次可以拥有一个消费者,除非提供了某些外部同步机制
      • 缓冲区的活跃消费者不一定是缓冲区的所有者
    • 租约:
      • 租约是指允许特定组件在一个时间长度范围内成为缓冲区消费者

    以下伪代码示例阐释了这三个概念。 它包括:

    • 实例化类型为 CharMemory<T> 缓冲区的
    • 调用 WriteInt32ToBuffer 方法以将整数的字符串表示形式写入缓冲区
    • 然后调用 DisplayBufferToConsole 方法以显示缓冲区的值。
    using System;
    
    class Program
    {
        // Write 'value' as a human-readable string to the output buffer.
        void WriteInt32ToBuffer(int value, Buffer buffer);
    
        // Display the contents of the buffer to the console.
        void DisplayBufferToConsole(Buffer buffer);
    
        // Application code
        static void Main()
        {
            var buffer = CreateBuffer();
            try
            {
                int value = Int32.Parse(Console.ReadLine());
                WriteInt32ToBuffer(value, buffer);
                DisplayBufferToConsole(buffer);
            }
            finally
            {
                buffer.Destroy();
            }
        }
    }
    
    • 所有者
      • Main 方法创建缓冲区(在此示例中为 Span<T> 实例),因此它是其所有者。 因此,Main 将负责在不再使用缓冲区时将其销毁。
    • 消费者
      • WriteInt32ToBufferDisplayBufferToConsole
      • 一次只能有一个消费者
        • 先是 WriteInt32ToBuffer ,然后是 DisplayBufferToConsole
      • 这两个消费者都不拥有缓冲区
      • 此上下文中的“消费者”并不意味着以只读形式查看缓冲区;如果提供了以读/写形式查看缓冲区的权限,则消费者可以像 WriteInt32ToBuffer 那样修改缓冲区的内容
    • 租约
      • WriteInt32ToBuffer 方法在方法调用的开始时间和方法返回的时间之间会租用(能消费的)缓冲区
      • DisplayBufferToConsole 在执行时会租用缓冲区,方法返回时将解除租用
      • 没有用于租约管理的 API,“租用”是概念性内容

    3.2. Memory<T> 和所有者/消费者模型

    .NET Core 支持以下两种所有权模型:

    • 支持单个所有权的模型
      • 缓冲区在其整个生存期内拥有单个所有者。
    • 支持所有权转让的模型
      • 缓冲区的所有权可以从其原始所有者(其创建者)转让给其他组件,该组件随后将负责缓冲区的生存期管理
      • 该所有者可以反过来将所有权转让给其他组件等

    使用 System.Buffers.IMemoryOwner<T> 接口显式的管理缓冲区的所有权。

    • IMemoryOwner<T> 支持上述这两种所有权模型
    • 具有 IMemoryOwner<T> 引用的组件拥有缓冲区
    • 以下示例使用 IMemoryOwner<T> 实例反映 Memory<T> 缓冲区的所有权。
    using System;
    using System.Buffers;
    
    class Example
    {
        static void Main()
        {
            IMemoryOwner<char> owner = MemoryPool<char>.Shared.Rent();
    
            Console.Write("Enter a number: ");
            try {
                var value = Int32.Parse(Console.ReadLine());
    
                var memory = owner.Memory;
    
                WriteInt32ToBuffer(value, memory);
    
                DisplayBufferToConsole(owner.Memory.Slice(0, value.ToString().Length));
            }
            catch (FormatException) {
                Console.WriteLine("You did not enter a valid number.");
            }
            catch (OverflowException) {
                Console.WriteLine($"You entered a number less than {Int32.MinValue:N0} or greater than {Int32.MaxValue:N0}.");
            }
            finally {
                owner?.Dispose();
            }
        }
    
        static void WriteInt32ToBuffer(int value, Memory<char> buffer)
        {
            var strValue = value.ToString();
    
            var span = buffer.Span;
            for (int ctr = 0; ctr < strValue.Length; ctr++)
                span[ctr] = strValue[ctr];
        }
    
        static void DisplayBufferToConsole(Memory<char> buffer) =>
            Console.WriteLine($"Contents of the buffer: '{buffer}'");
    }
    

    也可以使用 using 编写此示例:

    using System;
    using System.Buffers;
    
    class Example
    {
        static void Main()
        {
            using (IMemoryOwner<char> owner = MemoryPool<char>.Shared.Rent())
            {
                Console.Write("Enter a number: ");
                try {
                    var value = Int32.Parse(Console.ReadLine());
    
                    var memory = owner.Memory;
                    WriteInt32ToBuffer(value, memory);
                    DisplayBufferToConsole(memory.Slice(0, value.ToString().Length));
                }
                catch (FormatException) {
                    Console.WriteLine("You did not enter a valid number.");
                }
                catch (OverflowException) {
                    Console.WriteLine($"You entered a number less than {Int32.MinValue:N0} or greater than {Int32.MaxValue:N0}.");
                }
            }
        }
    
        static void WriteInt32ToBuffer(int value, Memory<char> buffer)
        {
            var strValue = value.ToString();
    
            var span = buffer.Slice(0, strValue.Length).Span;
            strValue.AsSpan().CopyTo(span);
        }
    
        static void DisplayBufferToConsole(Memory<char> buffer) =>
            Console.WriteLine($"Contents of the buffer: '{buffer}'");
    }
    

    在此代码中:

    • Main 方法保持对 IMemoryOwner<T> 实例的引用,因此 Main 方法是缓冲区的所有者
    • WriteInt32ToBufferDisplayBufferToConsole 方法接受 Memory<T> 参数作为公共 API。 因此,它们是缓冲区的消费者。 并且它们同一时间仅有一个消费者

    尽管 WriteInt32ToBuffer 方法用于将数据写入缓冲区,但 DisplayBufferToConsole 方法并不如此。

    • 若要反映此情况,方法参数类型可改为 ReadOnlyMemory<T>

    3.3. “缺少所有者” 的Memory<T> 实例

    无需使用 IMemoryOwner<T> 即可创建 Memory<T> 实例。 在这种情况下,缓冲区的所有权是隐式的,并且仅支持单所有者模型。 可以通过以下方式达到此目的:

    • 直接调用 Memory<T> 构造函数之一,传入 T[],如下面的示例所示
    • 调用 String.AsMemory 扩展方法以生成 ReadOnlyMemory<char> 实例
    using System;
    
    class Example
    {
        static void Main()
        {
            Memory<char> memory = new char[64];
    
            Console.Write("Enter a number: ");
            var value = Int32.Parse(Console.ReadLine());
    
            WriteInt32ToBuffer(value, memory);
            DisplayBufferToConsole(memory);
        }
    
        static void WriteInt32ToBuffer(int value, Memory<char> buffer)
        {
            var strValue = value.ToString();
            strValue.AsSpan().CopyTo(buffer.Slice(0, strValue.Length).Span);
        }
    
        static void DisplayBufferToConsole(Memory<char> buffer) =>
            Console.WriteLine($"Contents of the buffer: '{buffer}'");
    }
    
    • 最初创建 Memory<T> 实例的方法是缓冲区的隐式所有者。 无法将所有权转让给任何其他组件, 因为没有 IMemoryOwner<T> 实例可用于进行转让
      • 也可以假设运行时的垃圾回收器拥有缓冲区,全部的方法只消费缓冲区

    3.4. 使用准则

    因为拥有一个内存块,但打算将其传递给多个组件,其中一些组件可能同时在特定的内存块上运行,所以建立使用Memory<T>Span<T>的准则是很必要的,因为:

    • 所有者释放它之后,一个组件还可能会保留对该存储块的引用。
    • 两个组件可能并发的同时在缓冲区上进行操作,从而破坏了缓冲区中的数据。
    • 尽管Span<T>堆栈分配性质优化了性能,而且使Span<T>成为在内存块上运行的首选类型,但它也使Span<T>受到一些主要限制
      • 重要的是要知道何时使用Span<T>以及何时使用 Memory<T>

    下面介绍成功使用 Memory<T> 及其相关类型的建议。 除非另有明确说明,否则适用于 Memory<T>Span<T> 的指南也适用于 ReadOnlyMemory<T>ReadOnlySpan<T>

    规则 1:对于同步 API,如有可能,请使用 Span<T>(而不是 Memory<T>)作为参数。

    Span<T>Memory<T> 更多功能:

    • 可以表示更多种类的连续内存缓冲区
    • Span<T> 还提供比 Memory<T> 更好的性能
    • 无法进行 Span<T>Memory<T> 的转换
    • 可以使用 Memory<T>.Span 属性将 Memory<T> 实例转换为 Span<T>
      • 如果调用方恰好具有 Memory<T> 实例,则它们不管怎样都可以使用 Span<T> 参数调用你的方法

    使用类型 Span<T>(而不是类型 Memory<T>)作为方法的参数类型还可以帮助你编写正确的消费方法实现。 你将自动进行编译时检查,以确保不会企图访问此方法租约之外的缓冲区

    有时,必须使用 Memory<T> 参数(而不是 Span<T> 参数),即使完全同步也是如此。 所依赖的 API 可能仅接受 Memory<T> 参数。 这没有问题,但当使用同步的 Memory<T> 时,应注意权衡利弊

    规则 2:如果缓冲区应为只读,则使用 ReadOnlySpan<T> 或 ReadOnlyMemory<T>

    在前面的示例中,DisplayBufferToConsole 方法仅从缓冲区读取数据;它不修改缓冲区的内容。 方法签名应进行修改如下。

    void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);
    

    事实上,如果我们结合 规则1 和 规则2 ,我们可以做得更好,并重写方法签名如下:

    void DisplayBufferToConsole(ReadOnlySpan<char> buffer);
    

    DisplayBufferToConsole 方法现在几乎适用于每一个能够想到的缓冲区类型:

    • T[]、使用 stackalloc 分配的存储 等等
    • 甚至可以向其直接传递 String

    规则 3:如果方法接受 Memory<T> 并返回 void,则该方法的代码中return之后不得使用 Memory<T> 实例,保证方法结束后对其使用也结束。

    这与前面提到的“租约”概念相关。 返回 void 的方法对 Memory<T> 实例的租用将在进入该方法时开始,并在退出该方法时结束。 请考虑以下示例,该示例会基于控制台中的输入在循环中调用 Log。

    using System;
    using System.Buffers;
    
    public class Example
    {
        // implementation provided by third party
        static extern void Log(ReadOnlyMemory<char> message);
    
        // user code
        public static void Main()
        {
            using (var owner = MemoryPool<char>.Shared.Rent())
            {
                var memory = owner.Memory;
                var span = memory.Span;
                while (true)
                {
                    int value = Int32.Parse(Console.ReadLine());
                    if (value < 0)
                        return;
    
                    int numCharsWritten = ToBuffer(value, span);
                    Log(memory.Slice(0, numCharsWritten));
                }
            }
        }
    
        private static int ToBuffer(int value, Span<char> span)
        {
            string strValue = value.ToString();
            int length = strValue.Length;
            strValue.AsSpan().CopyTo(span.Slice(0, length));
            return length;
        }
    }
    

    如果 Log 是完全同步的方法,则此代码将按预期运行,因为在任何给定时间只有一个活跃的内存实例消费者。 但是,请想象Log具有此实现。

    // !!! INCORRECT IMPLEMENTATION !!!
    static void Log(ReadOnlyMemory<char> message)
    {
        // Run in background so that we don't block the main thread while performing IO.
        Task.Run(() =>
        {
            StreamWriter sw = File.AppendText(@".input-numbers.dat");
            sw.WriteLine(message);
        });
    }
    

    在此实现中,Log 违反租约,因为它在 return 之后仍尝试在后台使用 Memory<T> 实例。 Main 方法可能会在 Log 尝试从缓冲区进行读取时更改缓冲区数据,这可能导致消费者在使用缓存区数据时数据已经被修改。

    有多种方法可解决此问题:

    • Log 方法可以按以下所示,返回 Task,而不是 void。
      // An acceptable implementation.
      static Task Log(ReadOnlyMemory<char> message)
      {
          // Run in the background so that we don't block the main thread while performing IO.
          return Task.Run(() => {
                      StreamWriter sw = File.AppendText(@".input-numbers.dat");
              sw.WriteLine(message);
              sw.Flush();
          });
      }
      
    • 也可以改为按如下所示实现 Log:
      // An acceptable implementation.
      static void Log(ReadOnlyMemory<char> message)
      {
          string defensiveCopy = message.ToString();
          // Run in the background so that we don't block the main thread while performing IO.
          Task.Run(() => {
              StreamWriter sw = File.AppendText(@".input-numbers.dat");
              sw.WriteLine(defensiveCopy);
              sw.Flush();
          });
      }
      

    规则 4:如果方法接受 Memory<T> 并返回某个Task,则在Task转换为终止状态之前不得使用 Memory<T> 实例。

    这个是 规则3 的异步版本。 以下示例是遵守此规则,按上面例子编写的 Log 方法:

    // An acceptable implementation.
    static Task Log(ReadOnlyMemory<char> message)
    {
        // Run in the background so that we don't block the main thread while performing IO.
        return Task.Run(() => {
            string defensiveCopy = message.ToString();
            StreamWriter sw = File.AppendText(@".input-numbers.dat");
            sw.WriteLine(defensiveCopy);
            sw.Flush();
        });
    }
    

    此处的“终止状态”表示任务转换为 completed, faulted, canceled 状态。

    此指南适用于返回 TaskTask<TResult>ValueTask<TResult> 或任何类似类型的方法。

    规则5:如果构造函数接受Memory <T>作为参数,则假定构造对象上的实例方法是Memory<T>实例的消费者。

    请看以下示例:

    class OddValueExtractor
    {
        public OddValueExtractor(ReadOnlyMemory<int> input);
        public bool TryReadNextOddValue(out int value);
    }
    
    void PrintAllOddValues(ReadOnlyMemory<int> input)
    {
        var extractor = new OddValueExtractor(input);
        while (extractor.TryReadNextOddValue(out int value))
        {
          Console.WriteLine(value);
        }
    }
    

    此处的 OddValueExtractor 构造函数接受 ReadOnlyMemory<int> 作为构造函数参数,因此构造函数本身是 ReadOnlyMemory<int> 实例的消费者,并且该实例的所有实例方法也是原始 ReadOnlyMemory<int> 实例的消费者。 这意味着 TryReadNextOddValue 消费 ReadOnlyMemory<int> 实例,即使该实例未直接传递到 TryReadNextOddValue 方法。

    规则 6:如果一个类型具有可写的 Memory<T> 类型的属性(或等效的实例方法),则假定该对象上的实例方法是 Memory<T> 实例的消费者。

    这是 规则5 的变体。之所以存在此规则,是因为假定使用了可写属性或等效方法来捕获并保留输入的 Memory<T> 实例,因此同一对象上的实例方法可以利用捕获的实例。

    以下示例触发了此规则:

    class Person
    {
        // Settable property.
        public Memory<char> FirstName { get; set; }
    
        // alternatively, equivalent "setter" method
        public SetFirstName(Memory<char> value);
    
        // alternatively, a public settable field
        public Memory<char> FirstName;
    }
    

    规则 7:如果具有 IMemoryOwner<T> 的引用,则必须在某些时候对其进行处理或转让其所有权(但不同时执行两个操作)。

    • 由于 Memory<T> 实例可能由托管或非托管内存提供支持,因此在对 Memory<T> 实例执行的工作完成之后,所有者必须调用 MemoryPool<T>.Dispose
    • 此外,所有者可能会将 IMemoryOwner<T> 实例的所有权转让给其他组件,同时获取所有权的组件将负责在适当时间调用 MemoryPool<T>.Dispose
    • 调用 Dispose 方法失败可能会导致非托管内存泄漏或其他性能降低问题
    • 此规则也适用于调用工厂方法的代码(如 MemoryPool<T>.Rent)。 调用方将成为工厂生产的 IMemoryOwner<T> 的所有者,并负责在完成后 Dispose 该实例。

    规则 8:如果 API 接口中具有 IMemoryOwner<T> 参数,即表示你接受该实例的所有权。

    接受此类型的实例表示组件打算获取此实例的所有权。 该组件将负责根据 规则7 进行正确处理。

    在方法调用完成后,将 IMemoryOwner<T> 实例的所有权转让给其他组件,之后该组件将不再使用该实例。

    重要:
    构造函数接受 IMemoryOwner<T> 作为参数的类应实现接口 IDisposable,并且 Dispose 方法中应调用 MemoryPool<T>.Dispose

    规则 9:如果要封装同步的 p/invoke 方法,则应接受 Span<T> 作为参数

    根据 规则1,Span<T> 通常是用于同步 API 的合规类型。 可以通过 fixed 关键字固定 Span<T> 实例,如下面的示例所示。

    using System.Runtime.InteropServices;
    
    [DllImport(...)]
    private static extern unsafe int ExportedMethod(byte* pbData, int cbData);
    
    public unsafe int ManagedWrapper(Span<byte> data)
    {
        fixed (byte* pbData = &MemoryMarshal.GetReference(data))
        {
            int retVal = ExportedMethod(pbData, data.Length);
    
            /* error checking retVal goes here */
    
            return retVal;
        }
    }
    

    在上一示例中,如果输入 span 为空,则 pbData 可以为 Null。 如果 ExportedMethod 方法参数 pbData 不能为 Null,可以按如下示例实现该方法:

    public unsafe int ManagedWrapper(Span<byte> data)
    {
        fixed (byte* pbData = &MemoryMarshal.GetReference(data))
        {
            byte dummy = 0;
            int retVal = ExportedMethod((pbData != null) ? pbData : &dummy, data.Length);
    
            /* error checking retVal goes here */
    
            return retVal;
        }
    }
    

    规则 10:如果要包装异步 p/invoke 方法,则应接受 Memory<T> 作为参数

    由于 fixed 关键字不能在异步操作中使用,因此使用 Memory<T>.Pin 方法固定 Memory<T> 实例,无论实例代表的连续内存是哪种类型。 下面的示例演示了如何使用此 API 执行异步 p/invoke 调用。

    using System.Runtime.InteropServices;
    
    [UnmanagedFunctionPointer(...)]
    private delegate void OnCompletedCallback(IntPtr state, int result);
    
    [DllImport(...)]
    private static extern unsafe int ExportedAsyncMethod(byte* pbData, int cbData, IntPtr pState, IntPtr lpfnOnCompletedCallback);
    
    private static readonly IntPtr _callbackPtr = GetCompletionCallbackPointer();
    
    public unsafe Task<int> ManagedWrapperAsync(Memory<byte> data)
    {
        // setup
        var tcs = new TaskCompletionSource<int>();
        var state = new MyCompletedCallbackState
        {
            Tcs = tcs
        };
        var pState = (IntPtr)GCHandle.Alloc(state);
    
        var memoryHandle = data.Pin();
        state.MemoryHandle = memoryHandle;
    
        // make the call
        int result;
        try
        {
            result = ExportedAsyncMethod((byte*)memoryHandle.Pointer, data.Length, pState, _callbackPtr);
        }
        catch
        {
            ((GCHandle)pState).Free(); // cleanup since callback won't be invoked
            memoryHandle.Dispose();
            throw;
        }
    
        if (result != PENDING)
        {
            // Operation completed synchronously; invoke callback manually
            // for result processing and cleanup.
            MyCompletedCallbackImplementation(pState, result);
        }
    
        return tcs.Task;
    }
    
    private static void MyCompletedCallbackImplementation(IntPtr state, int result)
    {
        GCHandle handle = (GCHandle)state;
        var actualState = (MyCompletedCallbackState)(handle.Target);
        handle.Free();
        actualState.MemoryHandle.Dispose();
    
        /* error checking result goes here */
    
        if (error)
        {
            actualState.Tcs.SetException(...);
        }
        else
        {
            actualState.Tcs.SetResult(result);
        }
    }
    
    private static IntPtr GetCompletionCallbackPointer()
    {
        OnCompletedCallback callback = MyCompletedCallbackImplementation;
        GCHandle.Alloc(callback); // keep alive for lifetime of application
        return Marshal.GetFunctionPointerForDelegate(callback);
    }
    
    private class MyCompletedCallbackState
    {
        public TaskCompletionSource<int> Tcs;
        public MemoryHandle MemoryHandle;
    }
    

    注:
    Memory<T>.Pin 方法返回内存句柄,且垃圾回收器将不会移动此处内存,直到释放该方法返回的 MemoryHandle 对象为止。这使您可以检索和使用该内存地址。


    如果您认为这篇文章还不错或者有所收获,您可以通过右边的"打赏"功能 打赏我一杯咖啡【物质支持】,也可以点击左下角的【好文要顶】按钮【精神支持】,因为这两种支持都是我继续写作,分享的最大动力!

    作者: 大师兄石头
    来源: https://bigbrotherstone.cnblogs.com/
    声明: 原创博客请在转载时保留原文链接或者在文章开头加上本人博客地址,如发现错误,欢迎批评指正。凡是转载于本人的文章,不能设置打赏功能,如有特殊需求请与本人联系!

  • 相关阅读:
    父级设置display:flex;子级宽度无效的解决办法
    小程序注意事项
    css 字体默认有高度 怎么去掉
    小程序长按识别问题
    css 列表多列多行 同行等高样式
    AndroidManifest.xml:90: error: Error: String types not allowed (at 'largeHeap' with value 'auto').
    10.4.2 ListView.ScrollViewChange存在的问题
    【转】UniTreeMenu控件不显示滚动条的解决办法
    Delphi 10.4.2拍照遇到的新问题
    ChinaCock 用CCShortcutBadger组件显示角标
  • 原文地址:https://www.cnblogs.com/BigBrotherStone/p/memory-and-spans.html
Copyright © 2011-2022 走看看