How to show a PDF with Jetpack Compose

What to do when components for reading and displaying PDFs are missing? Declarative programming can come to your rescue.

Graziano Rizzi
Telepass Digital
11 min readOct 31, 2023

--

Jetpack Compose has been around for a few years now, but there are still areas where its usage can be challenging. For instance, official components for viewing PDFs are lacking, and the few third-party libraries available often come with a cost.

In our application, we encounter the need to display PDFs in numerous scenarios. Each service we provide has its own set of terms and conditions, and monthly expense reports are also distributed as PDF documents.

Hence, the concept of creating our custom library for directly viewing PDFs using Compose was conceived. This allows us to bypass the use of WebView or the integration of libraries that rely on classic Views via interoperability.

In this article, we will explore how to leverage the PdfRenderer class from the Android SDK to render and view PDF pages within the Compose framework.

The PdfRenderer class

The PdfRenderer class is part of the android.graphics.pdf package and can be used to render PDF pages and get a Bitmap to display.

Let’s take a look at the class and its methods:

The first method is the constructor of the class, as we can see it requires an instance of the ParcelFileDescriptor class.

The following three methods are:

  • close(): This method is used to close the render after it has been used, closing it is essential to avoid memory leaks but caution must be exercised as after closing it is not possible to call other methods on this instance.
  • getPageCount(): The method signature itself is self-explanatory, it returns the number of pages the PDF is made up of.
  • openPage(int index): This method returns an instance of the Page class corresponding to the passed index.

Now let’s take a look at the Page class:

  • The getWidth() and getHeight() methods, as can be guessed, respectively return the width and height of the current page in points.
  • The getIndex() method simply returns the index of the current page.
  • The close() method is used to close the page when you have finished working with this instance, it is essential to know that you can only keep one page open at a time so you need to close the current page instance by calling this method before you can open another page.

The render() method deserves further study, the main method that will allow us to render the page into a Bitmap.

The input parameters of this method are:

  • Destination Bitmap, the method will load the page render on this instance.
  • destClip: An instance of the Rect class is an optional parameter and can be useful if you want to render the page only on a portion of the destination Bitmap.
  • transform: An instance of the Matrix class, it is an optional parameter useful for implementing a transformation between the page coordinates expressed in points and the Bitmap coordinates expressed in pixels.
  • renderMode is an integer that expresses the render mode, there are two modes and they are the two constant values defined in the Page class: RENDER_MODE_FOR_DISPLAY, RENDER_MODE_FOR_PRINT. In our case we will only use the first one.

An important detail to know is that if the destClip and transform parameters are not passed, the page will be rendered so as to occupy the entire destination Bitmap, this makes it clear that if we want to avoid deformation of the page, the ratio between width and height of the Bitmap destination must match that of the page to be rendered.
As we will see later, the getWidth() and getHeight() methods will be useful for this.

For more details you can consult the official documentation:
https://developer.android.com/reference/android/graphics/pdf/PdfRenderer.Page

A first implementation

Let’s try to lay out a first implementation, the idea behind this is to create a class capable of providing, given a ParcelFileDescriptor as input, a list of Bitmaps as output.
We will then use a LazyColumn and the composable Image to display each single Bitmap in a vertical list.

To be clear, the use of the class, which we will call PdfRender, will be as follows:

The code is self-explanatory but summarizing we can see how first an instance of ParcelFileDescriptor is created which is then passed to the PdfRender class, this will then display the number of pages contained in the pdf and a list of Bitmaps.
At this point, as previously mentioned, a LazyColumn is created and inside it iterates on the Bitmap list and calls the composable Image for each element.

Now let’s see the implementation of the PdfRender class:

First, an instance of the PdfRenderer class is created and the number of pages making up the pdf is exposed via the pageCount field, which directly refers to the pageCount field of the PdfRenderer class.
Subsequently, a list of instances of the Page class and a close() function are created which will be useful for freeing up the memory used and closing both the PdfRenderer and the ParcelFileDescriptor.

Analyzing the Page class we can see how it takes two parameters as input, namely the PdfRenderer instance and the page index.
During the creation of each instance, the pageContent field is populated by calling the createBitmap() function.

In the implementation of this function, the current page is opened as a first step through the openPage method, through the instance provided by this method we can retrieve the size of the current page, then we create an empty Bitmap of the size of the page and then we pass it to the render method of the current page instance.

This method renders the page and populates the passed Bitmap with the render result.

Let’s take a look at the final result

For this test, I utilized a very large PDF file, exceeding 200MB, to illustrate how the rendering time is excessively long. It is evident that we cannot tolerate this level of performance. Furthermore, upon analyzing the app using the Android Studio profiler, we observed that the RAM consumption is also excessive.

Nearly 6GB of RAM consumption for an application that simply opens a PDF is quite excessive. The reason for these subpar performance levels is straightforward: all the pages are rendered upon opening, and all the created bitmaps are retained in memory even when they are not displayed.

However, finding a solution for this issue is more complex than expected. In fact, it is not possible to parallelize the rendering of each page to reduce loading times because, as mentioned earlier, the PdfRenderer class does not allow us to open multiple pages simultaneously.

Additionally, we need to devise a method to render and retain in memory only the pages that are currently displayed, and at most, the immediately preceding and following ones.

Another issue that might not be immediately apparent is the UI freezing; indeed, with the current implementation, the UI remains frozen until all pages are loaded and rendered.

Coroutines to the Rescue

To address the issue of UI freezing and loading times, a practical approach is to shift the rendering process to an asynchronous thread using coroutines. However, while moving to a parallel thread resolves the prior two issues, it gives rise to new challenges:

  • The Bitmap may not have been rendered yet by the time the composable Image is called.
  • However, rendering the images must occur sequentially because only one page can be opened at a time. Nonetheless, if a page is rendered, we want to immediately display it without waiting for the rendering of the others.

To address the issue of asynchronicity between the creation of the composable and the rendering of the page, we can utilize a StateFlow. It allows us to use the collectAsState method within a composable, so when a new value is emitted, the composable is updated.

The idea, therefore, is to transform the pageContent field of the Page class from a simple Bitmap into a MutableStateFlow<Bitmap?>. Consequently, within the composable, we will call the Image function only when the StateFlow is populated with a Bitmap.

Let’s see an example code:

As far as the sequentiality of the render is concerned, given that we are moving its execution to a coroutine, the most immediate solution is to use a Mutex to ensure that there is only one page being rendered at the same time.

From this snippet we can see how we have moved the page render into a function we have called load(), inside it a coroutine is launched on a CoroutineScope which is provided by the PdfRender class so that it is unique for all Page instances.

The execution of the render then takes place inside the callback of the whitLock method which ensures that only one page at a time is opened, rendered and then emitted on the MutableStateFlow pageContent.

At this point we have solved the problem of asynchronicity but the problem related to performance remains, in fact even if we can now move the render to a coroutine the fact remains that by rendering all the pages upon opening we are wasting both computational resources and ram memory.

At this point, however, the solution is immediate, as long as the load() method of each individual Page instance is called only when that page must appear on the screen.

To do this we can use one of the components that Compose offers us or the SideEffects.

As you can see from the snippet we have added a LaunchedEffect with the key1 parameter set to Unit, in this way it will be executed only when the element of the list is created and therefore substantially when it appears on the screen.

When a page disappears from the screen its composable will leave the composition and then be recreated when it comes back and then the LaunchedEffect will be executed again.

However, this could lead to a useless repetition of the page render in the event that the user scrolls the list repeatedly and alternating directions.

The solution is already in the previously shown implementation of the Page class, in fact there is the isLoaded variable which is set to true when the render has been performed and is checked when the load() function is launched, in case the variable results in true is not rendered again.

In the implementation of Page we can already see how the variable isLoaded is set to false in the recycle() method, when will this function be executed? We will see it in the next chapter, meanwhile let’s take a look at the current implementation of the PdfRender class and the result we obtained.

In terms of startup time, the result is remarkable.

But if we look at ram consumption we find that the situation has remained unchanged.

We can see that, unlike before, all memory is not occupied instantly, this is due to the implementation of asynchronous loading. However, although one might have expected that loading would have stopped only on the pages visible on the screen, in reality, as can be seen, it is performed without distinction for all of them. The reason is very simple, when the LazyColumn is created all the elements have no dimension so for the composable they must all be displayed, consequently the load() function is launched on all the elements of the list.

Let’s optimize the use of memory

In order to have better memory management we need to implement two new mechanisms:

  • Give all pages an initial size before they are rendered so that the load() method is only called for the pages actually displayed at that moment.
  • call the recycle() method when a page is no longer displayed to free up memory.

Let’s start from the first point

As you can see from this snippet, we have created a data class that will act as a container for the page dimensions, after which we have added a dimension field that will precisely store the size of the single page that is populated during the initialization phase of each Page class.

Since to do this we have to call the openPage() function we are introducing a small overhead in the creation of Page instances but this will bring us a great advantage in memory management.

We have also added a heightByWidth() function which is used to calculate the height of the page from a maximum width based on the aspect ratio of the page, it will be useful for us to create a composable that occupies the space of the page before it is rendered.

As for the second point, the whole implementation is in the composable PDFReader.

The primary difference compared to the previous implementation is the inclusion of a DisposableEffect. This is employed to facilitate the invocation of the close() function of the PdfRender class when the composable exits the composition. This action is necessary to close the PdfRenderer and recycle all the created Bitmaps. To learn more about how the DisposableEffect works, you can take a look at the official documentation:

https://developer.android.com/jetpack/compose/side-effects#disposableeffect.

The second difference is the utilization of a BoxWithConstraints to encompass each item within the LazyColumn. This is advantageous in determining the maximum width that the elements can occupy.

The next modification involves replacing LaunchedEffect with DisposableEffect. This not only enables us to invoke the load() function only when a page is being displayed but also facilitates the execution of the recycle() function when a page exits the composition. This ensures that the Bitmap associated with that page is recycled, thereby freeing up memory.

The last change addresses the scenario in which the displayed page has not yet been rendered. Instead of displaying nothing, which would result in the component having a height of 0, we create a Box with the maximum possible width and a height calculated based on the width of the Box and the aspect ratio of the page. This approach prevents the LazyColumn from triggering the load() function for all items in the list but only for those currently displayed.

Now, let’s examine the final result.

As we can see, the loading times have remained practically identical even if we have introduced a small overhead due to the calculation of the page size when creating Page instances.

Now let’s take a look at memory usage.

We went from occupying more than 6GB of memory to occupying just over 200MB.

Before declaring ourselves completely satisfied, let’s take a look at what happens to the memory if the user scrolls the list of pages.

As depicted in the graph, the RAM usage exhibits peaks during scrolling, primarily attributed to the initialization of new pages for display. However, these peaks are promptly mitigated thanks to the recycling of pages that exit the composition. This reaffirms the effective management of bitmap and RAM memory recycling.

This article was written by Graziano Rizzi, Android Principal Engineer, and edited by Marta Milasi and Gaetano Matonti, respectively UX Content Lead and Managerial Software Engineer at Telepass. Interested in joining our team? Check out our open roles!

--

--