Avoiding Out of Memory Crashes on Mobile

Steff Kelsey

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.