[UWP] WinRT 与 .NET 的 Stream , Buffer 之使用的 class 与内存两三事

做下载文件的过程,WinRT native type 和 .NET type 在 stream 与 buffer 的方面遇到问题,才一路思考并修改然后有效率的使用内存


缘起:

从 WinRT 开始,有了一组新的 API 处理文件及串流,其中就引进了几个与原来 .NET 不同的 classes (IRandomAccessStream, IInputStream, IOutputStream, IBuffer 等等). 那时候并没有因为这些新的 API 而有特别的感受,只觉得有点麻烦,因为原先的程序都是用 .NET type (byte[], Stream) 来做处理的,好在 WinRT 也有提供了一组 extension method 来做型态的转换,所以当初就用那些 extension method 把 WinRT Stream 就直接转成 .NET Stream 来做程序的沿用

好景不长 最近在写 UWP 的 Background Task 透过 Windows.Web.Http.HttpClient  下载文件的时候,因为 Background Task 在使用资源上的限制,所以就凸显了问题,简单来说就是遇到了 OOM (Out Of Memory) 啦!此时才开始认真的研究要怎么减少内存的使用的方式,下面就用心路历程来说明吧~

写在前面:
其实会遇到这状况,其中一个原因是针对下载的内容,我是需要将他的内容物抓出来做解密处理,所以会有 WinRT type <=> .NET Type 的问题,但如果没有这种需求,其实是完全可以不用转换的,不过这也是我后来才"发觉"到的就是XD


Step 1. 使用 .NET Stream 与 同样的读取 buffer (byte[])


一开始做,因为之前的程序及习惯上都是用 .NET Stream 来处理从网络抓到的东西,所以写法就会变成像下面这样

首先我使用一个 BufferPool 的 class 来使用同一块内存来做下载的 buffer,会这么做是避免造成内存的破碎,虽然 .NET 会 GC 就是

class BufferPool
{
	private Dictionary bufferMap = new Dictionary();
	
	public byte[] Get(string bufferName, int bufferSize)
	{
		if (!bufferMap.ContainsKey(bufferName))
		{
			return bufferMap[bufferName];
		}

		var buffer = new byte[bufferSize];
		bufferMap.Add(bufferName, buffer);
		return buffer;
	}
}

这是我同时间只有一个东西在下载时候可以这样简单的写,如果同时间有多个下载的话,就要有另外的机制让不同的 Task (or Thread) 取道自己专属的 buffer ,否则你文件肯定会有问题

下面这段就是主要拿来下载的及处理 Buffer 的程序 (请容许我忽略解密的程序 XD)

class HttpDownloader
{
	private static BufferPool bufferPool = new BufferPool();

	private const int DownloadBuffeSize = 16384; // 16KB

	public async Task Download(Uri downloadUri, string filePath)
	{
		var buffer = bufferPool.Get("Download", DownloadBuffeSize);

		// use Windows.Web.Http.HttpClient to get remote respone
		var httpClient = new HttpClient();
		var httpRespone = await httpClient.GetAsync(downloadUri);
		var contentLength = Convert.ToInt64(httpRespone.Content.Headers.ContentLength);
		var inputStream = await httpRespone.Content.ReadAsInputStreamAsync();
		// use System.IO.WindowsRuntimeStreamExtensions class to wrapper WinRT native stream type to .NET type
		var sourceStream = inputStream.AsStreamForRead();
		
		// open local file to save remote stream
		StorageFile destinationFile = await ApplicationData.Current.LocalFolder.CreateFileAsync(filePath, CreationCollisionOption.ReplaceExisting);
		IRandomAccessStream destinationRandomStream = await destinationFile.OpenAsync(FileAccessMode.ReadWrite);
		// use System.IO.WindowsRuntimeStreamExtensions class to wrapper WinRT native stream type to .NET type
		Stream destinationStream = destinationRandomStream.AsStream();

		int readLength = 0;
		int totalReadLength = 0;

		while (true)
		{
			readLength = await sourceStream.ReadAsync(buffer, 0, DownloadBuffeSize);
			if (readLength == 0)
			{
				break;
			}

			totalReadLength += readLength;
			if (totalReadLength == contentLength)
			{
				break;
			}

			Decrypt(buffer, readLength);

			await destinationStream.WriteAsync(buffer, 0, readLength);
			// flush right away to reduce memory (but incroeace I/O loading)
			await destinationStream.FlushAsync();
		}

		destinationStream.Dispose();
		destinationRandomStream.Dispose();
		sourceStream.Dispose();
		inputStream.Dispose();
		httpRespone.Dispose();
	}

	private void Decrypt(byte[] buffer, int readLength)
	{
		// decrypt code here
	}
}

然后我就遇到 OOM  XD

就是下载了几次文件,就可以 log 到下面这样的错误消息,一开始以为是保存空间不足之类的,但不是,后来去美国的 MSDN 论坛发问候才知道这也是相当于 OOM 的例外
但也会 log 到 OutOfMemory Exception 就是,但下面这种意思也是 OOM

HResult : -2147024888
TypeName : System.Exception, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e
Message : System.Exception: Not enough storage is available to process this command. (Exception from HRESULT: 0x80070008)
   at Windows.Storage.Streams.IInputStream.ReadAsync(IBuffer buffer, UInt32 count, InputStreamOptions options)
   at System.IO.WinRtToNetFxStreamAdapter.BeginRead(Byte[] buffer, Int32 offset, Int32 count, AsyncCallback callback, Object state, Boolean usedByBlockingWrapper)
   at System.IO.WinRtToNetFxStreamAdapter.Read(Byte[] buffer, Int32 offset, Int32 count)
   at System.IO.BufferedStream.Read(Byte[] array, Int32 offset, Int32 count)
   ...

从这个 Exception 可以发现几件事情
1. IInputStream 调用 AsStream 后,会被包装了两层,第一层就是 BufferedStream,然后再来是 WinRTToNetFxStreamAdapter,显然这样其实是不太好的,一来出问题会有点难追
2. AsStream 应该就真的是权宜之计让你能够叫快速的 porting 既有的程序
3. 在 desktop 用 工作管理员 或在 Win 10 Mobile 上 Developer Portal 去间看内存的状况时,其实内存状况是正常的,所以我推在 WinRT 里面使用到的 内存和 .NET 的内存的区块是不一样的?!


Step 2. 初步改善 - 使用 DataReader / DataWriter


OK~ 发现问题就要来改善~当然就希望能够尽量使用 WinRT native type 噜!首先当然是要把 AsStream 拿掉啦!


首先就先来改善读取端和写入端的方式,搜寻了一下,发现官方的 UWP sample 里面有 DataReaderDataWriter sample,所以就依照 sample 来调整了一下

public async Task Download(Uri downloadUri, string filePath)
{
	var buffer = bufferPool.Get("Download", DownloadBuffeSize);

	// use Windows.Web.Http.HttpClient to get remote respone
	var httpClient = new HttpClient();
	var httpRespone = await httpClient.GetAsync(downloadUri);
	var contentLength = Convert.ToInt64(httpRespone.Content.Headers.ContentLength);
	var inputStream = await httpRespone.Content.ReadAsInputStreamAsync();
	// use DataReader to read bytes from IInputStream
	var dataReader = new DataReader(inputStream);
	// you can set InputStreamOptions to None/Partial/ReadAhead. Default value is None
	dataReader.InputStreamOptions = InputStreamOptions.None;

	// open local file to save remote stream
	StorageFile destinationFile = await ApplicationData.Current.LocalFolder.CreateFileAsync(filePath, CreationCollisionOption.ReplaceExisting);
	IRandomAccessStream destinationRandomStream = await destinationFile.OpenAsync(FileAccessMode.ReadWrite);
	// use DataWriter to write bytes to IOutputStream
	// IRandomAccessStream is inherited from IInputStream and IOutputStream
	var dataWriter = new DataWriter(destinationRandomStream);

	uint readLength = 0;
	uint totalReadLength = 0;

	while (true)
	{
		// first we need call LoadAsync to "Load" a range data from IInputStream
		// amount of data reading is based on InputStreamOptions setting to DataReader
		readLength = await dataReader.LoadAsync(DownloadBuffeSize);

		if (readLength == 0)
		{
			break;
		}

		// DataReader.LoadBytes can only read the same or below size bytes less than
		// you can LoadAsync above. So make sure use readLength to determin.
		// or you will receive InavlidRange exception (or error messagee)
		if (readLength >= DownloadBuffeSize)
		{
			dataReader.ReadBytes(buffer);
			Decrypt(buffer, readLength);
			dataWriter.WriteBytes(buffer);
		}
		else
		{
			var tempBuffer = new byte[readLength];
			dataReader.ReadBytes(tempBuffer);
			Decrypt(tempBuffer, readLength);
			dataWriter.WriteBytes(tempBuffer);
		}

		totalReadLength += readLength;
		if (totalReadLength == contentLength)
		{
			break;
		}

		// after call WriteBytes we must call StoreAsync to store things to destination
		await dataWriter.StoreAsync();
		await dataWriter.FlushAsync();
	}

	dataWriter.Dispose();
	destinationRandomStream.Dispose();
	dataReader.Dispose();
	inputStream.Dispose();
	httpRespone.Dispose();
}

OK~ 第一阶段改造完成,其实改不用 Stream 来处理并没有想像中麻烦,因为有 DataReader / DataWriter 协助之下,其实改动的地方并不多
只有几个地方要调整,还有一些地方要注意

1. 用 DataReader 要读东西之前,要先调用 LoadAsync 之后,才能够再 ReadBytes (或其他 Read 的 method)
2. LoadAsync 后要去注意回传的 readLength ,因为使用 ReadBytes 时,只能读取相同大小的的 byte array,ReadBytes 本身是会看你传入的 byte array 大小去读,所以如果你调用 ReadBytes 的时候的长度是大于 LoadAsync 时候给的长度,这时候就会在 visualStudio 的 ouput 窗口出现 InvalidRange 的错误消息
3. DataWriter 使用上也是,在调用 WriteBytes 之后需要调用 StoreAsync 才是真的把东西写到目的地去

好像松了一口气改的不多? 因为透过 DataReader / DataWriter 了,所以我们还是很轻松的只针对 byte array 去做事情而已,但我们是否一定要透过 DataReader / DataWriter 吗?
答案当然是否定的,会用 DataReader / DataWriter 也是方面处理,因为去看看 IInputStream 及 IOutputStream 提供的 method 传入的都是 IBuffer ,也就是说 DataReader / DataWriter 最后应该还是会
帮我们转换成 IBuffer 来读取及写入

当然这边还有这段还有一点令人在意的就是那段 tempBuffer 的地方,因为这样写总是会多产生一段零碎的 byte array ,总有一天会内存破碎严重 XD 还有看起来就很碍眼
不过就一步一步来吧!因为也此时还不确定要怎么让这段零碎的 byte array 的写法去除掉


Step 3. 再次调整 - 直接使用 IInputStream 以及同样的读取 IBuffer

OK~ 这边其实有稍微跳了一点思考及常识的步骤,不过就直接一起写吧!

首先是 BufferPool 的改版,多了一个 GetNative 的 method 回传的就是一块固定的 IBuffer

class BufferPool
{
	private Dictionary bufferMap = new Dictionary();
	private Dictionary nativeBufferMap = new Dictionary();
	
	public byte[] Get(string bufferName, int bufferSize)
	{
		if (!bufferMap.ContainsKey(bufferName))
		{
			return bufferMap[bufferName];
		}

		var buffer = new byte[bufferSize];
		bufferMap.Add(bufferName, buffer);
		return buffer;
	}

	public IBuffer GetNative(string bufferName, uint bufferSize)
	{
		if (!nativeBufferMap.ContainsKey(bufferName))
		{
			return nativeBufferMap[bufferName];
		}

		var buffer = new Windows.Storage.Streams.Buffer(bufferSize);
		nativeBufferMap.Add(bufferName, buffer);
		return buffer;
	}
}

皆下来来看看主要部分是怎么调整的

class HttpDownloader
{
	private static BufferPool bufferPool = new BufferPool();

	private const int DownloadBuffeSize = 16384; // 16KB

	public async Task Download(Uri downloadUri, string filePath)
	{
		const string bufferName = "Download";
		var buffer = bufferPool.Get(bufferName, DownloadBuffeSize);
		var nativeBuffer = bufferPool.GetNative(bufferName, DownloadBuffeSize);

		// use Windows.Web.Http.HttpClient to get remote respone
		var httpClient = new HttpClient();
		var httpRespone = await httpClient.GetAsync(downloadUri);
		var contentLength = Convert.ToInt64(httpRespone.Content.Headers.ContentLength);
		var inputStream = await httpRespone.Content.ReadAsInputStreamAsync();

		// open local file to save remote stream
		StorageFile destinationFile = await ApplicationData.Current.LocalFolder.CreateFileAsync(filePath, CreationCollisionOption.ReplaceExisting);
		IRandomAccessStream destinationRandomStream = await destinationFile.OpenAsync(FileAccessMode.ReadWrite);

		uint readLength = 0;
		uint totalReadLength = 0;

		while (true)
		{
			IBuffer readCompleteBuffer = await inputStream.ReadAsync(nativeBuffer, DownloadBuffeSize, InputStreamOptions.None);
			// MSDN says we must use readCompleteBuffer to read data because nativeBuffer may not as the same
			// as readCompleteBuffer.
			readLength = readCompleteBuffer.Length];

			if (readLength == 0)
			{
				break;
			}

			// we can use System.Runtime.InteropServices.WindowsRuntime.CopyTo extension method
			// copy IBuffe to byte array so we can use the same byte array
			readCompleteBuffer.CopyTo(0, buffer, 0, (int)readLength);

			totalReadLength += readLength;
			if (totalReadLength == contentLength)
			{
				break;
			}

			Decrypt(buffer, readLength);

			// IOuptputStream.WriteAsync can only accpet IBuffer.
			// so we have to transfer byte array back to IBuffer
			// and because readLength may not equal to DownloadBufferSize so we have to pass readLength
			// to transfer correct legnth to IBuffer
			var writeBuffer = buffer.AsBuffer(0, (int)readLength);
			// WriteAsync will write whole IBuffer
			// it will see Length property of IBuffer to determin how much to write
			await destinationRandomStream.WriteAsync(writeBuffer);
			await destinationRandomStream.FlushAsync();
		}

		destinationRandomStream.Dispose();
		inputStream.Dispose();
		httpRespone.Dispose();
	}

	private void Decrypt(byte[] buffer, uint readLength)
	{
		// decrypt code here
	}
}

这边跳过一些步骤,稍微列一下
1. 一开始取得 readCompleteBuffer 的时候,我还是用 DataReader 去把 IBuffer 的内容,也提供大家一个选项,可以调用 DataReader.FromBuffer 的 method 产生一个 DataReader
2. 但 1. 这样其实会让那个 tempBuffer 一样存在,因为一样需要判断独取道的长度,后来才发现 System.Runtime.InteropServices.WindowsRuntime.WindowsRuntimeBufferExtensions 下面有一些 CopyTo 的 method 可以用,所以就可以利用 CopyTo 的 method 并且指定 index 和 长度 把 IBuffer 转成 byte array
3. 所以我们就可以略过那个讨厌的 if 长度判断啦!(洒花

有一个需要注意的地方
IOutputStream.WriteAsync 传入的是 IBuffer 是没有给定要写多长的,一开始有点困扰,但后来发现他其实是会看 IBuffer.Legnth property 而不是真的把整个 IBuffer 的内容写进去

所以这边就衍生了一个想法,是否我们可以把 AsBuffer 给摆脱呢?
使用 AsBuffer extension method 产生 IBuffer 看起来应该是会产生一块一块的 IBuffer ,这又是不愿意见到的地方啦!
所以是不是我们可以去调整 IBuffer Length property 然后一样使用原来只有读取的用的 nativeBuffer 那一块来写入呢?

让我们继续看下去


Step 4. 最后微调 - 省去零碎的 IBuffer

OK~ 经过尝试,结论是可以使用同样一块 nativeBuffer,所以就来看最后的结果吧!

// copy buffer(byte[]) back to nativeBuffer(IBuffer)
buffer.CopyTo(0, nativeBuffer, 0, (int)readLength);
// make sure we change Length property to readLength
nativeBuffer.Length = readLength;
await destinationRandomStream.WriteAsync(nativeBuffer);
await destinationRandomStream.FlushAsync();

这边就只把有更动的地方列出来,就是在上方调用 Decrypt 之后的那一段,不再使用 AsBuffer 而是改用另外一个 extension method,一样是 System.Runtime.InteropServices.WindowsRuntime.WindowsRuntimeBufferExtensions 所提供的 CopyTo
把 byte array 重新 copy 到原先我们已经准备在那边的 IBuffer,这样就避免一直重复产生 IBuffer ,又或者说原先是 WinRT (runtime) 决定的部分改成自己来掌控啰!


写在后面:
呼呼~总算写完人生写最多也写最久也写最多 sample code的一篇技术 Blog,也是第一篇特别写 sample code 重现心路历程的。
然后我要承认 sample code 虽然我是用 visual stuido 来编辑的,肯定是 compile 会过,不过 runtime 时候会不会出事不保证 XD
但是我该要表达及注意的重点之处应该都有标示出来

如果有任何错误或谬误请大家多多指教,谢谢各位看官~