1. 入门
想象一下烧水壶。你打开水龙头——水就流出来了。你可以一次接满一壶水,也可以慢慢一点点加。文件也是一样——有时候没法或者不方便一下子把整个文件都读进内存。文件可能很大,有时候数据源甚至不是文件,比如网络连接,数据是慢慢流进来的。
如果我们总是直接用字节数组来处理数据,遇到大文件内存很快就爆了,而且对于“无限”数据流(比如视频或音频流)这种方式根本行不通。这时候,流的概念就派上用场啦!
在.NET里,流就是一种顺序访问数据的抽象:不管背后是文件、网络、内存,还是啥奇葩玩意儿比如压缩包。流让你可以分块(通常是按块或按字节)读写数据。
核心思想:
- 流就是数据传输的通道。它像传送带一样:你可以“放上去”(写入)或者“拿下来”(读取)数据,根本不用管数据到底存哪、怎么存。
- 数据是顺序来的:你只能在读完上一个块后才能读下一个(当然有的流支持倒带)。
- 大多数情况下你不会把所有数据一次性全放进内存(电脑会感谢你的)。
这个抽象几乎是.NET里所有输入输出操作的基础:文件、网络、压缩包,甚至控制台!
2. System.IO.Stream流
继承和架构:System.IO.Stream
.NET里的几乎所有流都继承自抽象类System.IO.Stream。它定义了流的基本读写、定位和管理方法。
classDiagram
class Stream {
+Read()
+Write()
+Seek()
+CanRead
+CanWrite
+CanSeek
+Length
+Position
}
class FileStream
class MemoryStream
class NetworkStream
class CryptoStream
Stream <|-- FileStream
Stream <|-- MemoryStream
Stream <|-- NetworkStream
Stream <|-- CryptoStream
- Stream —— 基础抽象类
- FileStream —— 处理文件用的
- MemoryStream —— 处理内存数据用的
- NetworkStream —— 网络通信用的
- CryptoStream —— 加密/解密用的
流的主要属性和方法速览
| 属性 / 方法 | 说明 |
|---|---|
|
能不能从这个流里读数据 |
|
能不能往这个流里写数据 |
|
能不能在流里定位(不是所有流都支持) |
|
流的长度(如果支持的话——不是所有流都有) |
|
流里的当前位置 |
|
读取数据 |
|
写入数据 |
|
在流里定位 |
|
刷新缓冲区(把积攒的数据都写进流) |
/ |
关闭流并释放资源 |
来看看实际代码里怎么用这些东西。
3. 示例:用Stream读写文件
下面是个很简单的例子,看看流是怎么“干活”的:
// 打开文件准备写入
using var stream = new FileStream("numbers.bin", FileMode.Create);
// 假设我们要把1到10写进文件
for (int i = 1; i <= 10; i++)
{
byte val = (byte)i;
stream.WriteByte(val); // 一次写一个字节
}
// 显式关闭文件,为了后面能读
stream.Close();
// 现在试试把这些数字读出来
using var stream2 = new FileStream("numbers.bin", FileMode.Open);
int value;
while ((value = stream2.ReadByte()) != -1)
{
Console.WriteLine(value); // 会输出1, 2, ... 10
}
这里我们用的是FileStream,它就是个正儿八经的流:你可以分块或按字节读写数据。
流的类型:都能在哪遇到?
流可不只是磁盘文件。下面这些地方也用到流的概念:
- 磁盘文件(比如FileStream——最常见)
- 内存流(MemoryStream——临时或中间数据很方便)
- 网络连接(NetworkStream)
- 压缩/归档(GZipStream、DeflateStream)
- 加密(CryptoStream)
- 控制台输入/输出(没错!)——技术上也是流
这样你写的代码就不用关心数据的具体来源/去向:只要是流,代码就通用!
4. 实用细节
读写其实就是分块传输数据。通常用字节数组和Read、Write方法搞定。
示例:分块读取文件
byte[] buffer = new byte[1024]; // 1KB缓冲区
using var stream = new FileStream("bigfile.bin", FileMode.Open);
int bytesRead;
while ((bytesRead = stream.Read(buffer, 0, buffer.Length)) > 0)
{
// 只处理buffer里实际读到的bytesRead字节
int sum = 0;
for (int i = 0; i < bytesRead; i++)
sum += buffer[i];
Console.WriteLine($"块的和: {sum}");
}
这种方式到处用——杀毒软件、音乐播放器都靠它。
流的定位(Position、Seek)
大多数流(比如文件流)都能在数据里跳转——不只是“下一个块”,还能直接跳到某个位置开始处理。
using var stream = new FileStream("numbers.bin", FileMode.Open);
stream.Position = 5; // 跳到第6个字节(从0开始数)
int value = stream.ReadByte();
Console.WriteLine($"文件里的第6个字节: {value}");
流有只读、只写、或可读可写三种
有些流只支持其中一种操作:
- 只写文件:只能Write()
- 网络数据读取流:只能Read()
- 有些奇葩场景(比如打印机输出流)根本不能“倒带”或定位。
用CanRead、CanWrite、CanSeek属性检查支持哪些操作:
using var stream = new FileStream("myfile.txt", FileMode.OpenOrCreate);
if (stream.CanRead)
Console.WriteLine("支持读取");
if (stream.CanWrite)
Console.WriteLine("支持写入");
if (stream.CanSeek)
Console.WriteLine("可以定位文件");
流的缓冲
几乎所有流内部都有缓冲区来提升性能。缓冲能减少磁盘/网络的访问次数:数据先在内部攒着,然后一次性读/写。
Flush()方法可以手动刷新缓冲区(比如确保数据真的写进磁盘):
using var stream = new FileStream("log.txt", FileMode.Append);
byte[] bytes = Encoding.UTF8.GetBytes("Hello, Stream!\n");
stream.Write(bytes, 0, bytes.Length);
stream.Flush(); // 保证数据真的写进磁盘
如果你写的是很重要的数据(比如支付交易!),记得用Flush()。
5. 用流常见的坑
新手经常会踩这些坑:
忘记关闭流(结果内存泄漏、文件被锁、各种奇葩问题)。
搞混文本流和二进制流——用字节方法写字符串,结果全是乱码。
缓冲区太小(或者根本没缓冲)——操作慢得要死。
以为Read()每次都能读到你要的字节数——其实可能更少;一定要检查返回值。
没注意不是所有流都支持定位(Seek),尤其是网络流。
比如这样:
// 坏例子:读文件时没检查实际读了多少字节
byte[] buffer = new byte[1024];
using (var stream = new FileStream("data.bin", FileMode.Open))
{
int bytesRead = stream.Read(buffer, 0, 1024);
// 如果文件没那么大,bytesRead会小于1024!
}
GO TO FULL VERSION