[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<string, byte[]> bufferMap = new Dictionary<string, byte[]>();
	
	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<string, byte[]> bufferMap = new Dictionary<string, byte[]>();
	private Dictionary<string, IBuffer> nativeBufferMap = new Dictionary<string, IBuffer>();
	
	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
但是我該要表達及注意的重點之處應該都有標示出來

如果有任何錯誤或謬誤請大家多多指教,謝謝各位看官~