Rendering a (mega) PDF in a Xamarin Android app

« Return to Our Notebook

Rendering a (mega) PDF in a Xamarin Android app

Display a PDF in Xamarin.Android

Even on mobile, sometimes you need to show people a PDF. In your Xamarin Android app, for most situations, having the user download the document to view it outside of the app using Android's native document viewer is probably fine. But what if the design specifies displaying the document in the app? And what if that document is 100+ pages long? We recently ran into this here at Infinity Interactive and needless to say, displaying a PDF in your Xamarin Android app is not as straightforward as one might expect.

Get the PDF

First off, we need to retrieve the PDF we want to display. Here’s how we get a PDF that's located in the Assets folder:

Stream inputStream = Assets.Open("837pages.pdf");

using (var outputStream = this.OpenFileOutput("_sample.pdf", Android.Content.FileCreationMode.Private))
{
  inputStream.CopyTo(outputStream);
}

var fileStreamPath = this.GetFileStreamPath("_sample.pdf");

MemoryStream m_memoryStream = new MemoryStream();
File.OpenRead(fileStreamPath.AbsolutePath).CopyTo(m_memoryStream);

// use the PDF fileStreamPath

And here’s how we get a PDF that you're retrieving as a byte array:

byte[] pdfData = null;
FileStream fileStream;
Java.IO.File file;
string filePathTemp;

// get the PDF as a byte[] from your API however you'd like
pdfData = await GetPdfAsByteArray();

if (pdfData != null)
{
  string directory = GetExternalFilesDir(null).ToString();
  filePathTemp = System.IO.Path.Combine(directory, "837pages.pdf");

  fileStream = System.IO.File.Create(filePathTemp);
  fileStream.Write(pdfData, 0, pdfData.Length);
  fileStream.Close();

  file = new Java.IO.File(filePathTemp);

  // use the PDF file
}

Displaying PDFs in Android

To display a PDF on Android you're provided with the PdfRenderer class. As specified in the documentation: "If you want to render a PDF, you create a renderer and for every page you want to render, you open the page, render it, and close the page."

A typical use of the API to render a PDF looks like:

// create a new renderer
 PdfRenderer renderer = new PdfRenderer(getSeekableFileDescriptor());

 // let us just render all pages
 final int pageCount = renderer.getPageCount();
 for (int i = 0; i < pageCount; i++) {
     Page page = renderer.openPage(i);

     // say we render for showing on the screen
     page.render(mBitmap, null, null, Page.RENDER_MODE_FOR_DISPLAY);

     // do stuff with the bitmap

     // close the page
     page.close();
 }

 // close the renderer
 renderer.close();

You can use this along with a pretty basic RecyclerView to display a PDF file. You can render all the pages at the appropriate size, add them to a bitmap list and then pass that list over to your RecyclerView.Adapter.

List<Bitmap> pages;
Bitmap bitmap;

void RenderPages()
{
  var renderer = new PdfRenderer(ParcelFileDescriptor.Open(YOUR_PDF_FILE, ParcelFileMode.ReadOnly));
  var screenWidth = Resources.DisplayMetrics.WidthPixels;

  // render all pages
  pageCount = renderer.PageCount;
  for (int i = 0; i < pageCount; i++)
  {
    page = renderer.OpenPage(i);

    // create bitmap at appropriate size
    var ratio = (float)page.Height / page.Width;
    var newHeight = screenWidth * ratio;
    bitmap = Bitmap.CreateBitmap(screenWidth, (int)newHeight, Bitmap.Config.Argb8888);

    // render PDF page to bitmap
    page.Render(bitmap, null, null, PdfRenderMode.ForDisplay);

    // add bitmap to list
    pages.Add(bitmap);

    // close the page
    page.Close();
  }

  // close the renderer
  renderer.Close();

  // set RecyclerView.Adapter with bitmap list
  recyclerView.SetAdapter(new RecyclerViewAdapter(pages));
}
public class RecyclerViewAdapter : RecyclerView.Adapter
{
  public List<Bitmap> Pages { get; set; }

  public RecyclerViewAdapter(List<Bitmap> pages)
  {
    Pages = pages;
  }

  // Bindings to RecyclerView.ViewHolder etc ...
}

Initially this seems to work fine, especially if the PDF is only a few pages and the device has a decent amount of RAM.

Bitmaps Eat Memory

In the real world, however, you can quickly encounter old and underpowered devices which will throw an Out Of Memory (OOM) error crashing your app if the PDF is a bit larger.

The main cause for the OOM error is the fact that each page of the PDF is rendered as a bitmap. Bitmaps take up quite a bit of memory and if not handled properly things will go boom. When we ran into this issue here at Infinity, we initially only noticed the OOM error on some lower-end devices. Debugging, we found that on rendering of the pages we'd hit OOM around 25 pages on a Nexus 5. At first, we focused on preventing the crash on these devices and tried adding a memory usage check. If we hit a certain limit before all the pages were created, we displayed a dialog to let the user know they weren't seeing the entire document and that they could download it to see the rest.

Here’s how we did the memory usage check:

void RenderPages()
{
  bool lowMemory;

  // render all pages
  for (int i = 0; i < pageGroupCount; i++)
  {
    // render PDF bitmaps ...

    // if free memory is less than the size of two page bitmaps and we still have pages left to load
    // we'll stop loading and then display a message about downloading the full document
    Debug.WriteLine("\nMemory usage " + i + ": " + bitmap.ByteCount + " : " + MemoryAvailable() + "\n");
    if (bitmap.ByteCount * 2 > MemoryAvailable() && i < pageGroupCount - 1)
    {
      lowMemory = true;
      break;
    }
  }

  if (lowMemory)
  {
    // display dialog about downloading the full document
  }
}

long MemoryAvailable()
{
  long memoryUsed = Java.Lang.Runtime.GetRuntime().TotalMemory() - Java.Lang.Runtime.GetRuntime().FreeMemory();
  long memoryAvailable = Java.Lang.Runtime.GetRuntime().MaxMemory() - memoryUsed;
  return memoryAvailable;
}

This helped prevent the crashes on those specific devices for the time being, but in our later testing of larger document sizes (800+ pages) we still saw OOM crashes on newer devices that weren't being caught by the memory usage check due to changes in Android's memory handling.

Paging Doc...ument

Our next shot at a fix was to add some paging functionality and only show the user a few pages at a time. We went with a paging amount of five, but the logic could handle any number. All we had to do on previous or next was dispose of the bitmaps in the list, render the next page group and reset the RecyclerView.Adapter with the updated bitmap list. Disposing the previous bitmaps in the list is crucial here. If you only clear the list without disposing of the bitmaps, you'll still get OOM errors since the previous bitmaps will still be in memory.

// clear previous bitmaps from memory
for (int i = 0; i < pages.Count; i++)
{
  var image = pages[i];
  image.Dispose();
  image = null;
}
pages = null;

This took a little effort to reorganize the page rendering, especially for handling the final page group when it didn't match the paging amount (e.g. 3 pages instead of 5). Everything worked out as expected, and we were able to page through a test PDF of about 800 pages without any OOM error. However, this route forces some UI adjustments because we need to add paging buttons and, since the scroll bar is no longer an indicator in relation to the full document, we added a text element to show the user where they are in the document. All this is easy enough, but it requires design changes and associated approvals. Currently, the app is using a Floating Action Button (FAB) at the bottom right of the PDF screen that opens a submenu with options for sending, downloading and printing the PDF. This is a pretty clean layout. Adding more UI elements for paging is only going to clutter things up and leave less screen space for displaying the PDF pages. We decided to explore our options further.

Endless Scroll

What we really want is the ability to scroll through the entire PDF regardless of its size. This matches the design and is a better user experience. The fix for this is to render only the pages we need as the user scrolls through the document. This can be done if we take the Basic RecyclerView Example and strip it down to only the base RecyclerView layout. Essentially, we're removing PhotoAlbum.cs and the Button randPickButton from Main.axml along with any references to those in MainActivity.cs (full code shared at the end of this post).

We'll replace our initial PDF rendering code with a separate PdfFile.cs class. We'll pass our file to this class to handle rendering our individual pages.

public class PdfFile
{
  PdfRenderer renderer;
  PdfRenderer.Page page;
  Bitmap bitmap;
  List<Bitmap> pages;
  public int ScreenWidth;

  public void RenderPDFPagesIntoImages(Java.IO.File file)
  {
    pages = new List<Bitmap>();

    // create a new renderer
    try
    {
      renderer = new PdfRenderer(ParcelFileDescriptor.Open(file, ParcelFileMode.ReadOnly));
      NumPages = renderer.PageCount;
    }
    catch (Exception)
    {
      // handle any exceptions
    }
  }

  // Return the number of pages in the pdf file:
  public int NumPages { get; private set; }

  // Indexer (read only) for accessing a pdf:
  public Bitmap this[int i]
  {
    get
    {
      page = renderer.OpenPage(i);

      // create bitmap of page
      var ratio = (float)page.Height / page.Width;
      var newHeight = ScreenWidth * ratio;

      bitmap = Bitmap.CreateBitmap(ScreenWidth, (int)newHeight, Bitmap.Config.Argb8888);

      // render for showing on the screen
      page.Render(bitmap, null, null, PdfRenderMode.ForDisplay);

      page.Close();
      page.Dispose();

      return bitmap;
    }
  }
}

With this set up we're able to render our pages on the fly when we bind to our ViewHolder in the RecyclerView.Adapter.

// Adapter to connect the data set (Pdf file) to the RecyclerView:
public class PdfFileAdapter : RecyclerView.Adapter
{
  // Underlying data set (a pdf file):
  public PdfFile mPdfFile;

  // Load the adapter with the data set (pdf file) at construction time:
  public PdfFileAdapter(PdfFile pdfFile)
  {
    mPdfFile = pdfFile;
  }

  // Fill in the contents of the pdf card (invoked by the layout manager):
  public override void OnBindViewHolder(RecyclerView.ViewHolder holder, int position)
  {
    PdfViewHolder vh = holder as PdfViewHolder;

    // Set the ImageView in this ViewHolder's CardView
    // from this position in the pdf file:
    vh.Image.SetImageBitmap(mPdfFile[position]);
  }
  ...

OMG OOM WTH

This new set up gets us near where we want to be, but again, if the document is too big we'll get an OOM error. The RecyclerView "enhances performance by recycling views and by enforcing the view-holder pattern, which eliminates unnecessary layout resource lookups." But it doesn't clear previous content from memory. To do this, we need to override OnViewDetachedFromWindow and recycle our previous bitmaps there.

public override void OnViewDetachedFromWindow(Java.Lang.Object holder)
{
  base.OnViewDetachedFromWindow(holder);

  var vh = holder as PdfViewHolder;
  var bitmapD = vh.Image.Drawable as BitmapDrawable;
  var bitmap = bitmapD.Bitmap;

  vh.Image.SetImageBitmap(null);

  if (bitmap != null)
  {
    bitmap.Recycle();
  }
}

Final Tweaks

Once that bitmap recycling was implemented, scrolling through our large test PDF worked without any OOM error. But it wasn’t quite perfect yet. In some instances we noticed an odd issue where after switching the scrolling direction, the first couple views would be empty. For example: when you first opened the document, you could scroll through any number of pages just fine, but when you stopped and started scrolling back, those first couple of views would be blank. Debugging showed that those views were hitting OnViewDetachedFromWindow and recycling the bitmap, but when they came back on screen they weren't hitting OnBindViewHolder again and instead were going straight to OnViewAttachedToWindow. To stop this possibility, we decided to check for the PDF page bitmap in OnViewAttachedToWindow and if the bitmap wasn't there, we'd add it.

public override void OnViewAttachedToWindow(Java.Lang.Object holder)
{
  base.OnViewAttachedToWindow(holder);

  var vh = holder as PdfViewHolder;
  var bitmapD = vh.Image.Drawable as BitmapDrawable;
  var bitmap = bitmapD.Bitmap;

  if (bitmap == null)
  {
    vh.Image.SetImageBitmap(mPdfFile[vh.LayoutPosition]);
  }
}

PDF Rendered

Though I'm not quite sure who wants to look at an 800 page PDF on their phone in the first place, at least it's possible. Everything discussed here is available on GitHub. As mentioned earlier in passing, there are some other options you may want to give your users when viewing the PDF, such as downloading, sharing and printing the PDF. We'll discuss implementing those in a follow-up post. Cheers!

We solve problems with technology. What can we solve for you?

Reach Out

t: 800.646.0188