The Problem

I keep working with mobile games built using Unity, most of which have a target executable size of 50MB. These games however  tend to have more than 50MB worth of assets, so they pack content up into asset bundles, and download them later using Unity’s WWW class. At this point these games all run into the same problem. On a flaky mobile network downloading a 2MB file can be a challenge, often the download is disrupted or worse yet the player walks into an underground tunnel (or something like that).

The Solution

Abandon the built in WWW class to load it up as an asset bundle. It sounds simple but there are a few issues we will have to deal with. First, if you want a download to be resumable you have to make sure that the file you finish downloading is the same file you started downloading. You also need to know how many bytes you are trying to download, if this information isn’t available you can’t really resume your download. Then there is the issue of coming up with a simple interface of doing a complex task. I’ll try to tackle all these issues in this article.

Why HttpWebRequest?

The short answer is HttpWebRequest does not. There is also the option of using third party libraries for this functionality, but most of them are built on top of the same methods we are going to use, they offer really little added value.

Birds Eye View

We’re going to create a class to handle the loading of an asset for us. We’ll call this class RemoteFile. Ideally you should be able to create a remote file, retrieve information from it and trigger a download if needed. This is the target API:

Simple but powerful. Our class is going to be responsible for downloading files as well as comparing to local files to see if an update is needed. Everything load in the background, not blocking the application. The actual class will look like:

What are those variables?

  • mRemoteFile
    • String that stores the url of the remote file to be downloaded
  • mLocalFile
    • String that stores the uri of the local copy, that remote file is to be downloaded to
  • mRemoteLastModified
    • Timestamp of when the remote file was last modified
    • Used to check if the file needs to be re-dowloaded
  • mLocalLastModified
    • Timestamp of when the local file was last modified
    • Used to check if the file needs to be re-downloaded
  • mRemoteFileSize
    • Size (in bytes) of the remote file
    • Needed for the AddRange method
  • mAsynchResponse
    • HttpWebResponse response token
    • Used to signal when download finishes
    • Used to get file stream that gets written to disk
  • mBundle
    • Convenient access to the local copy of the file (If it’s an assetbundle)

Implementation Details

Lets go trough every method and accessor in this class one at a time.

LocalBundle

LocalBundle is a convenient accessor for the mBundle variable

IsOutdated

IsOutdated is an accessor that compares the last modified time of the server file to the last modified time of the local file. If the server was modified after the local file, our local copy is outdated and needs to be re-downloaded. If the server does not support returning this data (more on that in the constructor) then IsOutdated will always be true.

Constructor

The Constructor takes two strings, the url of the remote file to load, and the path of the local file where the remote file should be saved. The Constructor is responsible for getting the last modified times for the remote and local file, it also gets the size in bytes of the remote file. We get information about the remote file trough an HttpWebRequest with it’s content type set to HEAD. Setting the content type to HEAD ensures that we only get header data, not the whole file.

Setting the requests method to HEAD ensures we don’t get the full file, only header data. Because this is such a small amount of data we retrieve it with a blocking function. Beware, not all servers support the HEAD tag

The only return data that we will use from here is the files size, and last modified time. Much like the HEAD tag there is no guarantee that every server will support providing this information. If not available, the current time will be returned for last modified and zero will be returned for the file size.

Not all hosts support the HEAD command. If your host does not, it will serve up the full file and defeat the purpose of this function. Some hosts like Dropbox will support the HEAD command but will serve up invalid last modified times. If this happens IsOurdated will always be true and your file will always be downloaded, defeating the purpose of this class.

It is your responsibility as the programmer to run this code against your production server and make sure that all data is being returned as expected (HEAD is supported, and the correct last modified time gets returned.

Download

The Download coroutine will download the desired file using an BeginGetResponse takes two arguments, a callback which in our case its the AsynchCallback method and a user object, which will be the response it’s self. Once the download is compleate we just write the result of the stream to disk.

If the local file is large than the remote file we panic and delete the local file. If the remote file was modified more recently than the local file, the local copy is assumed outdated and is deleted.

A few things happen here, first a timeout must be set. If the timeout is not set, the download will just hang. We provide the request with a range of bytes to download. While the documentation claims that given a single argument (the size of the local file) it should download the entire file from that starting point. In my experience this isn’t the case, if a start and end byte is not provided the download just hangs. The method of the request is set to POST, this is because the previous calls set some body data and we can no longer send a get request. We next call BeginResponse, which will trigger the AsynchCallback method. Until AsynchCallback is called mAsynchResponse will be null, so we can yield on this.

Finally we write the downloaded file stream to disk and do some cleanup. It’s important remember that this is a coroutine. If called from another class be sure to call it as such. While IsOutdated is  public, you don’t need to worry about checking it. The Download function will early out and not download if the file is already up to date.

AsynchCallback

The AsynchCallback Gets called from the BeginResponse method when it’s ready. The request object was passed into the BeginResponse method, it will be forwarded to the AsynchCallback callback. From there we can get the HttpWebResponse and let the Download function finish executing.

LoadLocalBundle

LoadLocalBundle is a utility function to load the asset bundle we downloaded from disk. The only thing to note here is that in order to load the file from disk with the WWW class we must add file:// to the beginning.

UnloadLocalBundle

This is the counter to LoadLocalBundle. Managing memory is important, you do not want to have an asset bundle hanging around if it is not absolutely necessary.

Testing

The last thing to do is to test the code, i made a quick utility class for this

My example project is available on github

What next?

It may seem like the hard part is out of the way, but really this was the easy part. It’s up to you to come up with an efficient strategy for managing asset bundles. The real work is in making the system that manages bundles and the memory associated with them. To keep memory sane, download a bundle, load it into memory, instantiate any needed game objects and unload the bundle.