一、 划分使用边界:小文件靠 File,大文件靠 Stream

1. 小文件时代的“全家桶”(File 静态类)

如果文件只有几十 KB 或几 MB(比如配置文件、简单的配置日志),直接用官方封装好的 File 静态方法最香。

  • 精确到行:用 File.ReadAllLines(),能把文本按行切分成字符串数组返回,非常适合解析行规则固定的配置。
  • 整体吞吐:用 File.WriteAllText()File.ReadAllText(),直接将整个文本作为一整个 string 返回。
  • 二进制流:用 File.ReadAllBytes()读取多媒体文件(图片、小音频)。

2. 必须切换到 Stream(流)的危险信号

一旦文件规模达到 几十MB甚至上百MB,或者需要高频不间断地写入数据,就必须全面抛弃 File

底层分析:File.ReadAllText 的本质是把文件数据一次性全部加载到堆内存中。如果要读一个 1G 的日志,它就会在内存里瞬间开辟 1G 的空间,直接引发频繁的 GC,甚至抛出 OutOfMemoryException 挂掉程序。

二、 核心底层拆解:FileStream 与字符流的配合

流(Stream)的本质就像一根管道,数据被拆分成一个个字节(byte)在里面流动,我们每次只处理其中一小段,这就是它省内存的核心原因。

1. FileStream 万能字节流的循环读写

FileStream 被称为万能流,因为它只认字节,能读写包括视频、图片、文本在内的任何文件。在复制一个几百兆的大视频文件时,在网上搜到职业级的写法是开辟一个小的缓冲区,通过 while 循环蚂蚁搬家式地读写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void CopyBigFile(string src, string desc) 
{
// 利用 using 确保即使发生异常,也能自动 Close 并 Dispose 流,防止文件被锁死
using (FileStream fsRead = new FileStream(src, FileMode.Open, FileAccess.Read))
using (FileStream fsWriter = new FileStream(desc, FileMode.Create, FileAccess.Write))
{
// 每次只读取 5MB 的数据缓冲,内存占用永远固定在 5MB
byte[] buffer = new byte[1024 * 1024 * 5];

while (true)
{
// Read 返回实际读取到的字节数
int size = fsRead.Read(buffer, 0, buffer.Length);

// 如果返回 0,说明管道里没数据了,读到了文件末尾
if (size == 0) break;

// 必须指定 size,读多少写多少,防止最后一次读取时写入残留数据
fsWriter.Write(buffer, 0, size);
}
}
}

2. 文本处理的高效管道:StreamReader / StreamWriter

FileStream 控底层字节很强,但如果我们要处理的是文本,数字节去拼字符串就太反人类了。于是 .NET 提供了专门针对字符的工具——StreamReaderStreamWriter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// StreamWriter 写入:第二个参数传 true 表示追加文本,默认 false 表示覆盖
using (StreamWriter writer = new StreamWriter("log.txt", true, Encoding.UTF8))
{
writer.WriteLine("2026-07-07 实时日志输出"); // 按行动态写入
}

// StreamReader 按行安全读取大文件,内存毫无压力
using (StreamReader reader = new StreamReader("log.txt", Encoding.UTF8))
{
string? line;
// 边读边处理,直到读完
while ((line = reader.ReadLine()) != null)
{
// 处理单行逻辑
}
}

三、 实际开发中的几个典型大坑

1. 物理文件被强行锁死(忘记关闭或释放流)

文件操作最经典的 Bug 就是:“程序第一次跑正常,第二次跑就报错:文件正在被另一个进程使用。”

  • 原因分析:实例化 File.Create() 或者 new FileStream() 时,操作系统会分配一个文件句柄。如果用完没有显式执行 Close()Dispose(),这根管道就会一直占着物理文件,别人谁也动不了。
  • 规范写法:任何流操作,必须强制写在 using 块里using 在底层会被编译为 try-finally,确保程序无论正常结束还是中途崩溃,文件句柄都能百分之百被释放。

2. 目录不存在引发的直接闪退

在使用 File.WriteAllText(@"D:\\App\\Log\\test.txt", "content") 时,如果 D:\\App\\Log 这个三级文件夹在硬盘上本来不存在,程序不会帮你创建,而是直接抛出 DirectoryNotFoundException 导致系统崩溃。

  • 避坑准则:只要是写操作,必须先用静态类过一遍防火墙:
1
2
3
4
5
6
7
string dirPath = @"D:\App\Log";
// 如果目录不存在,Directory 会自动把所有缺失的多级父目录全部建好
if (!Directory.Exists(dirPath))
{
Directory.CreateDirectory(dirPath);
}
File.WriteAllText(Path.Combine(dirPath, "test.txt"), "content");

3. 跨平台路径分隔符硬编码(Windows 与 Linux/macOS 的冲突)

在拼接路径时,很多习惯写死斜杠:string path = baseDir + @"\\Log\\file.txt";。这在 Windows 下没问题,但如果项目发布到 Linux 容器(如 Docker)里运行,Linux 根本不认\ 反斜杠,程序直接报找不到路径。

  • 职业级写法:彻底抛弃手动拼接字符串,一律使用 Path.Combine。它会根据当前的操作系统(Windows 还是 Linux),动态自动补充正确的目录分隔符。
1
2
// 跨平台安全拼接
string fullPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log", "file.txt");

在做物理文件读写前,第一步永远是确定文件的绝对路径。在 C# 中,千万不要写死盘符(如 C:\Users\…),一律采用相对程序根目录的动态拼接。

  • 获取程序根目录的标准写法:
    1
    2
    // 推荐:获取当前执行程序所在的 bin/Debug 或 bin/Release 根目录(最稳妥,跨平台通用)
    string basePath = AppDomain.CurrentDomain.BaseDirectory;

四、 核心类 1:Path —— 路径处理的大师(静态类)

  • 什么时候用:只要涉及拼接、解析文件路径字符串,必须无条件用它。 它纯粹是在内存里进行字符串运算,完全不影响物理磁盘文件。

  • 最常用的 5 个核心方法:

    1. Path.Combine(params string[] paths):(最高频) 动态拼接多段路径。会自动根据 Windows(反斜杠\)或 Linux(正斜杠 /)系统补齐正确的目录分隔符。
    2. Path.GetFileName(string path):从一长串完整路径里,直接切出带后缀的文件名(如 app.json)。
    3. Path.GetFileNameWithoutExtension(string path):只拿文件名,不要后缀(如输入test.txt,返回 test)。
    4. Path.GetExtension(string path):提取文件的扩展名(包含点,如 .txt.mp4),常用于判断文件类型。
    5. Path.GetDirectoryName(string path):提取文件所属的上一级文件夹的完整路径。

五、 核心类 2:File —— 小文件的快捷全家桶(静态类)

  • 什么时候用:文件体积小(通常在几 MB 以内),比如读写简单的配置文件、生成临时小文本、一次性吞吐。
  • 最常用的 6 个核心方法:
    1. File.Exists(string path):(必用) 检查物理文件在硬盘上是否存在,做防错的第一道关卡。
    2. File.WriteAllText(path, content, encoding):一次性写入一整段字符串。每次写入会直接覆盖原文件。建议显式指定编码(如 Encoding.UTF8)。
    3. File.ReadAllText(path, encoding):一次性读取文件里的全部文本,直接返回一个 string
    4. File.ReadAllLines(path):按行读取文本文件,并返回一个字符串数组(string[]),非常适合解析行规则固定的配置。
    5. File.AppendAllText(path, content):在文件末尾追加新文本,非常适合写简单的日常操作日志。
    6. File.Delete(path):删除指定路径的文件。如果文件不存在,该方法不会报错。
  • 🚨 核心暗坑: 用 File.WriteAllText 写入文件时,如果路径里的多级文件夹(目录)在磁盘上不存在,程序会直接抛出 DirectoryNotFoundException 异常闪退。必须配合下面的 Directory 先把文件夹建好。

六、 核心类 3:Directory —— 文件夹的安全管家(静态类)

  • 什么时候用:创建、删除、移动、遍历磁盘上的文件夹(目录)。
  • 最常用的 5 个核心方法:
    1. Directory.Exists(string path):检查文件夹是否存在。
    2. Directory.CreateDirectory(string path):(非常智能) 如果要建 D:\\App\\Log\\2026 目录,哪怕中间的 AppLog 都不存在,它也会一口气把多级父目录全部自动建好。
    3. Directory.Delete(path, recursive):删除文件夹。第二个参数如果传 true,会进行递归删除,即连带文件夹里的所有子文件和子目录一起拔掉(如果传 false 且文件夹非空,会报错)。
    4. Directory.GetFiles(path, searchPattern):获取当前文件夹下的所有文件名(支持通配符过滤,如 *.txt)。
    5. Directory.GetDirectories(path):获取当前文件夹下的所有子文件夹的完整路径。
  • 避坑级标准写操作套路:

七、 核心类 4:FileStream —— 大文件与高频读写的万能管道(实例类)

  • 什么时候用:文件很大(几十 MB 以上,如音视频、历史大日志),或者需要高频不间断地往文件里写数据(比如上位机高频循环记录传感器数据)。它是字节流,不关心文件格式,万能通用。
  • 最常用的 2 种组合应用:

方案 A:原生字节流循环搬运(适合大文件、多媒体拷贝)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 必须用 using 包裹,不管中途是否发生报错,退出 using 块时流会自动关闭并释放锁
using (FileStream fsRead = new FileStream("source.mp4", FileMode.Open, FileAccess.Read))
using (FileStream fsWrite = new FileStream("copy.mp4", FileMode.Create, FileAccess.Write))
{
byte[] buffer = new byte[1024 * 1024 * 4]; // 每次搬运 4MB 缓冲区
int actualReadSize;

// 循环读取,当实际读到的字节数大于 0 时持续写入
while ((actualReadSize = fsRead.Read(buffer, 0, buffer.Length)) > 0)
{
fsWrite.Write(buffer, 0, actualReadSize); // 读到多少,精确写入多少
}
}

方案 B:FileStream 配合包装字符流(适合大文本、日志按行吞吐)

如果面对的是上百兆的文本日志,数字节太痛苦,可以用 StreamReader/Writer 套在 FileStream 外面使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
string logFile = "big_history.log";

// 1. 高效按行动态写入
using (FileStream fs = new FileStream(logFile, FileMode.Append, FileAccess.Write))
using (StreamWriter writer = new StreamWriter(fs, Encoding.UTF8))
{
writer.WriteLine("写入一条新生成的历史记录...");
}

// 2. 高效按行安全读取,内存毫无压力(每次只在内存里留一行)
using (FileStream fs = new FileStream(logFile, FileMode.Open, FileAccess.Read))
using (StreamReader reader = new StreamReader(fs, Encoding.UTF8))
{
string? line;
while ((line = reader.ReadLine()) != null)
{
// 动态处理每一行数据,读完就释放一行,绝对不撑爆内存
}
}

个人复盘总结(快速记忆口诀)

  1. 路不熟,找Path:只要牵扯到字符串路径的裁剪拼接,不要用 + 去凑,一律交给 Path.Combine 跨平台。
  2. 体积小,找File:小文件、配置文件、几行小日志,直接用 File 的静态全家桶一步到位,省时省力。
  3. 有危险,找Exists:动手动笔写文件前,先用 Directory.Exists 扫一遍文件夹,没有就 Create 垫后,彻底干掉路径缺失的闪退 Bug
  4. 体积大,找Stream:大文件、视频、或者要在 while 循环里高频写数据的,必须用 FileStream 开辟缓冲区单向流通,且永远不要忘记加using释放句柄