Profiling your app with Android Studio

Optimize your Android app’s memory consumption with profiling in Android Studio

What is App Profiling, and how can it help?

One question app developers must ask themselves is: What am I going to do when users communicate that they feel laggy while using a given app? The answer isn’t always immediately clear, but most of the time it has to do with CPU-intensive tasks that block the main thread, and there are also cases where these kinds of performance issues are related to memory.

You can, of course, print some logs to help you troubleshoot, but you need to be quite familiar with the codebase in order to put the log in the appropriate places.

If you want to explore another option that does not rely solely on logs, take a look at Android Profiler, which was introduced in Android Studio 3.0. Developers can use this tool to monitor CPU usage and memory utilization, intercept network responses, and even observe energy consumption.

Based on metrics data provided by Android Profiler, we’re able to have a better understanding of how our applications make use of the CPU and other memory resources. These are the breadcrumbs that can eventually lead us to the root cause of the issue.

In this article, we’re going to discuss methods for solving performance related issues in a systematic manner using Android Profiler.

Memory Problems

Let’s revisit Android memory management so we can understand why inappropriate use of memory can contribute to performance issues. An Android application, like any other client software application, runs in a memory-constrained environment, where the Android OS assigns a limited but flexible portion of the memory heap for each launched application.

Throughout the lifetime of an application, the Android OS allocates memory for the application to store instructions and program data. The amount of required memory varies in different application use cases. For instance, an application needs more memory to display a full-screen bitmap picture than full-screen text.

When a piece of memory is no longer needed, the Android OS will automatically reclaim this memory resource so it can be reused to serve the new memory allocation request. This process is commonly known as Garbage Collection.

Garbage Collection doesn’t usually affect the application’s performance, as the application pause time caused by one Garbage Collection process is negligible. However, if too many Garbage Collection events happen in a short period of time, users will start having a sluggish user experience in the application.

Memory Profiling with Android Profiler

The pre-requisites of Android Profile are a copy of Android Studio 3.0 or above and a connected test device or emulator running at least Android SDK Level 26. Once you have these initial pieces ready to go, click on the Profiler tab at the bottom panel to launch the Android Profiler.

Run your application in debug mode, and you’ll see the Android Profiler displaying real-time metrics for CPU, Memory, Network, and Energy.

Click on the Memory section to see detailed memory usage metrics—Android Profiler provides a visualized overview of memory usages over time.

As you can see in the above diagram, there is an initial spike when the application is first launched, then followed by a drop, and eventually a flat line. This is the typical behavior of a simple hello world application, as there isn’t much going on here.

A flat memory graph means stable memory utilization, and it’s the ideal memory situation that we want to achieve. Reading a memory graph is like analyzing a stock chart; but instead, dives are preferred over climbs.

Android Profiler in Action

Let’s look at a few graph patterns that signal memory issues. You can use the source code from this GitHub repo to reproduce these problems.

1. A growing graph

If you observe a trend line that only keeps going up and seldom goes down, it could be due to a memory leak, which means some pieces of memory cannot be freed. Or there’s simply not enough memory to cope with the application. When the application has reached its memory limit and the Android OS isn’t able to allocate any more memory for the application, an OutOfMemoryError will be thrown.

This issue can be reproduced in the High Memory Usage example from the demo app. This example basically creates a huge number of rows (100k) and adds these rows into a LinearLayout. (I know, it’s not a common thing to do in Android, but I just want to show an extreme case where numerous view creation can cause memory problems, as shown in the source code below.)

/***
 * In order to stress the memory usage,
 * this activity creates 100000 rows of TextView when user clicks on the start button
 */
class HighMemoryUsageActivity : AppCompatActivity() {

    val NO_OF_TEXTVIEWS_ADDED = 100000

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_high_memory_usage)

        supportActionBar?.setDisplayHomeAsUpEnabled(true)
        supportActionBar?.setTitle(R.string.activity_name_high_memory_usage)
        btn_start.setOnClickListener {
            addRowsOfTextView()
        }
    }

    override fun onSupportNavigateUp(): Boolean {
        onBackPressed()
        return true
    }

    /**
     * Add rows of text views to the root LinearLayout
     */
    private fun addRowsOfTextView() {
        val linearLayout = findViewById<LinearLayout>(R.id.linear_layout)

        val textViewParams = LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.MATCH_PARENT,
            LinearLayout.LayoutParams.WRAP_CONTENT
        )

        val textViews = arrayOfNulls<TextView>(NO_OF_TEXTVIEWS_ADDED)

        for (i in 0 until NO_OF_TEXTVIEWS_ADDED) {
            textViews[i] = TextView(this)
            textViews[i]?.layoutParams = textViewParams
            textViews[i]?.text = i.toString()
            textViews[i]?.setBackgroundColor(getRandomColor())
            linearLayout.addView(textViews[i])
            linearLayout.invalidate()
        }
    }

    /**
     * Creates a random color for background color of the text view.
     */
    private fun getRandomColor(): Int {
        val r = Random()
        val red = r.nextInt(255)
        val green = r.nextInt(255)
        val blue = r.nextInt(255)

        return Color.rgb(red, green, blue)
    }
}

This activity doesn’t use any AdapterView or RecyclerView to recycle the item views; therefore 100k view creations are needed in order to finish the execution of addRowsOfTextView().

Now, click the start button and monitor the memory usage in Android Studio. The memory usage keeps increasing and eventually the application crashes. This is the expected behavior.

The solution to fix this problem is straightforward. Simply adopt RecyclerView that reuses the item views, and we can significantly reduce the number of view creations and thus the memory footprint. The diagram below is the memory usage of presenting the same 100k item views, and you can see the improvement in the Low Memory Usage With RecyclerView example.

2. Turbulence within a short period of time

Turbulence is an indicator of instability, and this applies to Android memory usage as well. When we observe this kind of pattern, usually a lot of expensive objects are created and thrown away in their short lifecycles.

The CPU is wasting a lot of cycles in performing Garbage Collection, without performing the actual work for the application. Users might experience a sluggish UI, and we should definitely optimize our memory usage in this case.

To reproduce this issue, run the Numerous GCs example from the demo app. This example uses a RecyclerView to display two alternative bitmap images: one big bitmap image of resolution 1000 x 1000, and a smaller one of 256 x 256. Scroll the RecyclerView, and you’ll see obvious turbulence in the Memory Profiler and a sluggish user experience in the mobile application.

class NumerousGCActivity: AppCompatActivity() {

    val NO_OF_VIEWS = 100000


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_numerous_gc)
        btn_start.setOnClickListener {
            setupRecyclerView()
        }
    }

    private fun setupRecyclerView() {
        val numbers = arrayOfNulls<Int>(NO_OF_VIEWS).mapIndexed { index, _ -> index }
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = NumerousGCRecyclerViewAdapter(numbers)
    }
}

class NumerousGCRecyclerViewAdapter(private val numbers: List<Int>): RecyclerView.Adapter<NumerousGCViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NumerousGCViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_numerous_gc, parent, false)
        return NumerousGCViewHolder(view)
    }

    override fun getItemCount(): Int {
        return numbers.size
    }

    override fun onBindViewHolder(vh: NumerousGCViewHolder, position: Int) {
        vh.textView.text = position.toString()

        //Create bitmap from resource
        val bitmap = if(position % 2 == 0)
                BitmapFactory.decodeResource(vh.imageView.context.resources, R.drawable.big_bitmap)
            else
                BitmapFactory.decodeResource(vh.imageView.context.resources, R.drawable.small_bitmap)
        vh.imageView.setImageBitmap(bitmap)
    }
}

class NumerousGCViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    var textView: TextView = itemView.findViewById(R.id.text_view)
    var imageView: ImageView = itemView.findViewById(R.id.image_view)
}

In this case, the example code is using a proper RecyclerView implementation, and yet we still encounter memory issues. Even though RecyclerView is the solution to the previous memory problem, it isn’t a silver bullet that solves all memory issues. To find out the root cause, we need more information to analyze.

Click on the Record button in the Memory Profiler, scroll the RecyclerView for a while, and click on the stop button. The Profiler will present you a detailed memory usage list classified by object type.

Sort the list by Shallow size, and the top item is the byte array, so we know that most of the memory allocations are attributed to byte array creations. There are only 32 allocations for byte array, and its total size is 577,871,888 bits, which is 72.23MB.

To dig out more info, click on one of the instances in the instance view and see the allocation call stack. The highlighted method is the onBindViewHolder() of NumerousGCRecyclerViewAdapter, but we don’t create any byte array explicitly with this method.

Let’s look beyond the onBindViewHolder() method in the call stack. The method calls goes from decodeResource() -> decodeResourceStream() -> decodeStream() -> nativeDecodeAsset() and finally arrives in newNonMovableArray(). The document of this method says Returns an array allocated in an area of the Java heap where it will never be moved. This is used to implement native allocations on the Java heap, such as DirectByteBuffers and Bitmaps.

Therefore, we can conclude that the numerous amount of byte arrays are created right here using the method nativeDecodeAsset().

Every time we call BitmapFactory.decodeResource(), a new instance of a bitmap object is created, and thus its underlying byte array data. If we can reduce the frequency of the invocations of BitmapFactory.decodeResource(), we can avoid the need for additional memory allocation and therefore decrease the occurrence of Garbage Collection.

class LessNumerousGCRecyclerViewAdapter(private val context: Context,
                                         private val numbers: List<Int>): RecyclerView.Adapter<NumerousGCViewHolder>() {

    val bitBitmap = BitmapFactory.decodeResource(context.resources, R.drawable.big_bitmap)
    val smallBitmap = BitmapFactory.decodeResource(context.resources, R.drawable.small_bitmap)

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NumerousGCViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_numerous_gc, parent, false)
        return NumerousGCViewHolder(view)
    }

    override fun getItemCount(): Int {
        return numbers.size
    }

    override fun onBindViewHolder(vh: NumerousGCViewHolder, position: Int) {
        vh.textView.text = position.toString()

        //Reuse bitmap
        val bitmap = if(position % 2 == 0) bitBitmap else smallBitmap
        vh.imageView.setImageBitmap(bitmap)
    }
}

This is an improved version of RecyclerViewAdapter that creates bitmap instances only once, caches them, and reuses these bitmaps for the imageView in the onBindViewHolder() method. Any other unnecessary memory allocation is avoided. Let’s take a loohahak at the memory graph after the improvement.

We see a flat memory usage graph, and there’s only one allocation for a byte array, and its size is negligible. Also, the application can now scroll smoothly without any sluggishness. We’ve just fixed our memory issue 🙂

Summary

I hope this article has given you an idea of how to use the profiling tool to analyze a performance issue. Always pay attention to memory usage in your application and avoid allocating unnecessary memory resources. Just remember, the goal is to make your memory usage graph as flat as possible.

Discuss this post on Hacker News.

Avatar photo

Fritz

Our team has been at the forefront of Artificial Intelligence and Machine Learning research for more than 15 years and we're using our collective intelligence to help others learn, understand and grow using these new technologies in ethical and sustainable ways.

Comments 0 Responses

Leave a Reply

Your email address will not be published. Required fields are marked *