One reason why it is difficult to develop software for mobile devices is that the hardware is not the best compared to deploying to a console or a “real” computer. Resources are limited. One particularly sparse resource is RAM. Out of memory exceptions are common on both Android and iOS if you’re dealing with large files. Recently, when building a Google VR 360 video player, we went over the 1GB of RAM available on older iOS devices pretty quickly.
What Not to Do
One of my big complaints about the Unity manual and many tutorials is they
usually just show you how to do something really quickly and don’t always tell
you the exact use case or how it can just flat out fail. For example, using the
relatively new UnityWebRequest
, you can download a file over HTTP like this:
private IEnumerator loadAsset(string path)
{
using (UnityWebRequest webRequest = new UnityWebRequest(path))
{
webRequest.downloadHandler = new DownloadHandlerBuffer();
webRequest.Send();
while (!webRequest.isDone)
{
yield return null;
}
if (string.IsNullOrEmpty(webRequest.error))
{
FileComplete(this, new FileLoaderCompleteEventArgs(
webRequest.downloadHandler.data));
}
else
{
Debug.Log("error! message: " + webRequest.error);
}
}
}
These are all off the shelf parts from Unity, with the exception of the
FileLoaderCompleteEventArg
but just assume that we use that to pass off the
downloaded bytes as an array eg: byte[]
. Notice this returns an IEnumerator
and utilizes yield
statements so it should be run in a Coroutine
. What
happens here is that the UnityWebRequest
will open up a connection to the
given path, download everything into a byte array contained within the
DownloadHandlerBuffer
. The FileComplete
event will fire if there are no
errors, sending the entire byte array to the subscribing class. Easy, right? For
small files, sure. But we were making a 360 Video player. Our max resolution was
1440p. The first sample files we got for testing were bigger than 400MB. The
iPhone 7, with 2GB of RAM, took it like a champ. The iPhone 6, with 1GB of RAM,
crashed like a piano dropped from a helicopter.
Why Did my App Just Crash?
Let’s look at the guts of these components. The main focus is on the
DownloadHandlerBuffer
object. When it is first created, it will start by
preallocating memory for a small byte array where it will store all the
downloaded bytes. As the bytes come in, it will periodically expand the size of
the array. In our test case, it was expanding the array until it could hold
400MB. And because each additional allocation is a guess, it will most likely
overshot that amount. Note, I am speculating here because I have not looked at
the source code for the DownloadBufferHandler
. There is a chance it allocates
space based on the Content-Length header returned with the HTTP Response. But,
the result is the same; it will use up at least 400MB of RAM. That’s 40% of the
1GB that the iPhone 6 has! We’re already in dangerous territory. I know what
you’re saying, “Steff, why did it crash if we only used 40% of the RAM?” There
are two ways to find the answer. One (and give Unity credit here) is in the
documentation for DownloadHandlerBuffer
.
Note: When accessing DownloadHandler.data or DownloadHandler.text on this subclass, a new byte array or string will be allocated each time the property is accessed.
So, by accessing the data property, Unity allocates an additional 400MB of memory to pass off the byte array into the EventArg. Now we have used 800MB of RAM just on handling this one file. The OS has other services running plus you very likely have RAM allocated for bitmaps and UI and logic. You’re doomed!
Profiling Memory Allocations
If you didn’t read the docs, and they’re long: I get it, you could have found this memory leak by running the application in Unity while using the Profiler AND by running the application on an iOS device while using a valuable free tool from Apple: Instruments. The Allocations instrument captures information about memory allocation for an application. I recommend using the Unity Profiler heavily for testing in the Editor and then continuing performance testing on device for each platform. They all act differently. Using the Profiler in the Editor is only your first line of defense. In this case I only properly understood what was happening when I watched it unfold in a recording using the Allocations instrument.
Streams to the Rescue
There is a way to download large files and save them without using unnecessary
RAM. Streams! Since we plan on immediately saving these large video files in
local storage on device to be ready for offline viewing, we need to send the
downloaded bytes right into a File as they are received. When doing that, we can
reuse the same byte array and never have to allocate more space. Unity outlines
how to do that here, but below is an expanded example that
includes a FileStream
:
public class ToFileDownloadHandler : DownloadHandlerScript
{
private int expected = -1;
private int received = 0;
private string filepath;
private FileStream fileStream;
private bool canceled = false;
public ToFileDownloadHandler(byte[] buffer, string filepath)
: base(buffer)
{
this.filepath = filepath;
fileStream = new FileStream(filepath, FileMode.Create, FileAccess.Write);
}
protected override byte[] GetData() { return null; }
protected override bool ReceiveData(byte[] data, int dataLength)
{
if (data == null || data.Length < 1)
{
return false;
}
received += dataLength;
if (!canceled) fileStream.Write(data, 0, dataLength);
return true;
}
protected override float GetProgress()
{
if (expected < 0) return 0;
return (float)received / expected;
}
protected override void CompleteContent()
{
fileStream.Close();
}
protected override void ReceiveContentLength(int contentLength)
{
expected = contentLength;
}
public void Cancel()
{
canceled = true;
fileStream.Close();
File.Delete(filepath);
}
}
And to use the above in our coroutine:
private IEnumerator loadAsset(string path, string savePath)
{
using (UnityWebRequest webRequest = new UnityWebRequest(path))
{
webRequest.downloadHandler = new ToFileDownloadHandler(new byte[64 * 1024],
savePath);
webRequest.Send();
...
...
}
}
Looking first at our new ToFileDownloadHandler
, we extended Unity’s
DownloadHandlerScript
and have overridden the required methods. The magic
happens in two places. First, we pass in a byte array to the base class via the
constructor. This let’s Unity know that we want to re-use that byte array on
each ReceiveData
callback where we only allocate a small amount of RAM once.
Second, we use a FileStream
object to write the bytes directly to our desired
file. The rest of the code is there to handle canceling the request. Whenever
you deal with FileStream
objects, you must remember to close them out when
you’re done.
Looking at the loadAsset
method, we added a parameter for the path to where
the file will be saved locally and we defined the size of the buffer at 64MB.
This size is dependent on your network speeds. We were focussed on WiFi
connections, so a larger buffer made sense. Too small and you will make the
download take longer than necessary to complete.
Where to Go from Here
Now you have an understanding of one way that your application can eat up RAM. If you only take away one thing from reading this post it’s this: for managing memory allocations, streams are your friends. And you should be constantly performance testing as you develop your application, unless you’re trying to maximize one-star reviews in the App Store.
Gotchyas
One final note on the code above: we did not end up going to production using
UnityWebRequest
on iOS. When we tried using a similar streaming solution
as above, we found that the request was not clearing from memory if it was
canceled due to the user sending the application to the background. Using the
Time Profiler Instrument showed that NSURLSession
objects were not being
cleaned up when the application paused and resumed, so eventually the CPU would
max out and crash. We had to seek an alternative solution for iOS using a native
plugin. However, in the final code we still used HTTP streaming directly into a
file via FileStream
. Just not wrapped up in UnityWebRequest
objects.