Advanced debugging in Android (part 2)

Furkan Yurdakul
Appcent
Published in
9 min readMar 28, 2024

In 1st part we’ve discussed what debugging is and how we can start using the feature called “Debugger” in Android Studio. We’ve also talked about how to evaluate expressions, changing variables on the fly to meet certain criteria, and how to investigate bugs.

In this 2nd part, we’re gonna delve in with more advanced approaches where the debugger itself may not be enough, and we might need certain stops, cases, conditions to trigger bugs themselves or catch them when they happen without manual manipulation.

I want to attach the debugger later. Is it possible?

Yes, it is. For example, we want the app to run without the debugger interfering and want to activate the debugger before we click a button. We can start the app normally by clicking the “Run” button.

“Run” button at the top toolbar

Then, after we run the application, we need to click another button which is located here:

The attach debugger button in the same toolbar

Once this button is clicked, it will show the running debuggable processes that we can attach the debugger to. The window looks like this:

The list of debuggable processes window

If the app is not shown here, we can click “Show all processes” button but it is unlikely that we’ll find our app here. Only processes which are “Debug” builds are debuggable. Others are not. For example, you can’t debug an app downloaded by Google Play Store.

After attaching the debugger, a window from the bottom of Android Studio will show up with a similar information like this:

Here, you can click the “X” button next to “Java Only (5518)” text to detach the debugger in the future once it is not needed. If it asks for disconnect or terminate, we can select “Disconnect” to keep the process alive, or select “Terminate” to completely kill the app.

Can we stop the code without a debugger, then attach the debugger?

This is also possible. There is a one-line code that we can write at a specific point where it will block the underlying thread, effectively stopping the app’s execution on that point, and wait until the debugger gets attached. The code is called Debug.waitForDebugger() which comes from android.os package.

Continuing from the previous part’s examples, let’s add the line.

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
DebuggingExampleTheme {
Debug.waitForDebugger() // wait until debugger is connected
val class1Variable = Class1()
val class2Variable = Class2()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Greeting(class1Variable.text)
}
}
}
}
}

Now, we need to set a breakpoint right under the Debug.waitForDebugger() line which is val class1Variable = Class1() .

Afterwards, we can connect the debugger using the button mentioned above (namely Attach a debugger to Android Process button) and attach our debugger. Once our debugger is attached, the code will continue and the breakpoint will be hit.

Caution! Using this method in a production build should NOT be done! The best practice here is determining whether the build is a debuggable build such as checking the BuildConfig.DEBUG variable and then using this line of code. Using it without the check can result blocking the code in production, which is something we really don’t want for a very bad user experience, a.k.a a completely hanged application.

The debugger does not consistently stop in suspend functions. What to do?

It is a usual case where the debugger can hang or may not stop in a suspend function where we expect. That’s because effectively a suspend function that has suspending calls (for example an API call using Retrofit, or a preferenceDataStore call) are converted into functions that has callbacks. While we write the code in a single function without adding a callback, the function is able to suspend using this callback logic. And, the code effectively continues in the middle of the function after a suspending function does its job, or returns its result.

For example, let’s have a look at this code.

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
val scope = rememberCoroutineScope()
Text(
text = "Hello $name!",
modifier = modifier.clickable {
scope.launch {
val result = getResult() // suspending function
println("Received result is: $result")
}
}
)
}

private suspend fun getResult(): Int {
delay(2.seconds) // simulate work
return Random.nextInt(0, 100) // simulate result
}

In this code block, after clicking the text, it starts a job and returns a result after some time. Now, if we set a breakpoint at val result = getResult() line and attach a debugger, after clicking the button the breakpoint is going to be hit. If we press F8 which stands for “Step Over” that executes one line and “should” goto the next one a.k.a println("Received result is: $result") , you will notice that it will not stop at the line that has “println”. That’s because the line will be called through a callback, not directly. The easiest way to debug this is to add another breakpoint at the line that starts with println and press F9 which stands for “Resume Program” instead.

Let’s see what the stack trace becomes when we enter and exit the suspend functions, and what happens in the suspend functions themselves.

In getResult function at delay(2.seconds) line, if we add a breakpoint and stop the code there, this is how the stack trace looks like:

Now, you can see the caller function which is MainActivity’s Greeting function at the highlighted call, and the first line is getResult:55 which corresponds to 55th line in the file.

Now, if we add a breakpoint at 56th line which is right under delay(2.seconds) code and is equal to return Random.nextInt(0, 100) and press F9 a.k.a “Resume Program”, we get a trace like this:

The MainActivity’s Greeting call disappeared! But we’re still in the same function and it is going to return to that method, right? If so why is it not there?

That’s the special thing about the coroutines. It doesn’t always preserve the stack trace because while those can be the same coroutine, they can also do their job in different coroutines, or different methods. That’s why the debugger does not stop at the original println line, the trace disappears in the middle of it.

What can we do about this? Well, it was attempted for the Google team to add a Coroutine debugger to Android studio in 2021, but it was removed due to performance problems afterwards. This way is the only consistent way that worked for me so far.

It works with the debugger but has bugs without it. What can I do?

At that point we can use something called “Logging”. In Android, there is a package called android.os.Log where the information gets dumped with a log string that the Android system provides. When you type something like Log.d("MainActivity", "Task is executed") it gets printed such as 2024–03–24 12:00:00.000 1234–1234 MainActivity mobi.appcent.debuggingexample D Task is executed which looks like a dumped information. However, when viewed in the “Logcat” tab, it looks better.

Here is a screenshot:

The logcat window

For the bug that we want to catch, we add logs and let the app run by itself and do its thing. Once it hits the logs that we’ve written, they are all going to be printed here, and we can maybe determine what went wrong and caused the bug in the first place.

You can examine all device bugs by removing the package:mine entry in the search bar, or add some specific options. They will be visible once we click the filter icon here:

The filter icon right beside “package:mine” text

And there are a lot of options to choose from. For example, if we want to search for a tag, we can use the tag option. For the log above, we’d use something like tag:MainActivity and other logs would get filtered, showing only what we want.

My app gets slower after a while. What can I do?

This is a common issue in mobile apps in general, or all applications if the running computer or hardware is not big enough. In mobile devices, there are limited resources compared to a computer, and it is important for the apps to manage their CPU, and most importantly, memory, which is 90% of the cases why an app gets slower each minute.

In Android, these are referred as “Memory Leak”, and they can happen quite frequently if we are not careful.

How to investigate memory leaks is its another topic, but since this article is related to finding problems, bugs and providing potential solutions, I’m going to share the 2 methods I’ve used so far in my Android development journey.

  • 1) Leak Canary: This is an amazing tool that catches memory leaks from the application directly. You can find it in https://square.github.io/leakcanary/ and it is crazily easy to implement. As of now, you only need to add one line in your app level build.gradle file which is debugImplementation ‘com.squareup.leakcanary:leakcanary-android:2.13 and synchronize the project. Now, the app is eligible for investigating core memory leaks. It will find the leak and notify us when there is something wrong, create a report and save it which then can be opened later with a notification or the “Leaks” app that it creates.
  • 2) Android Studio Profiler.

How do I use Android Studio Profiler?

The profiler is the official way for investigating what’s going on in the app at all times. It can show the usages for memory, battery, CPU and network. It is possible to start an application with the profiler attached to it by clicking the button right here:

The profiler button in Android Studio

We may choose low overhead or complete data depending on our needs, but complete data option can slow down the app more than we expect, so it is usually better to use the “low overhead” option.

Another thing to mention is that, while the debuggable apps are also profileable, it is better to add an AndroidManifest entry called profileable that should look like this:

<profileable
android:shell="true"
android:enabled="true" />

And it should be placed like this in AndroidManifest.xml file:

The profileable attribute for AndroidManifest.xml

Once this is set, we can get a Release Build and can still profile our app using the Android Studio profiler.

I’m not going to dive deep into the usages of the profiler completely as it’d require its own article but in general you can do so many things:

  • See the current memory usage of the app
  • Record the current memory and examine what is happening inside the app
  • See the current CPU usage of the app
  • Record the CPU usage by recording stack traces and see what threads have used CPU cycles the most (a.k.a most power used)
  • See the battery usage
  • See the network usage
  • Manipulate API requests within (may not be possible for cross-platform applications)
  • And more.

Conclusion

It is essential for a developer to know how to investigate an issue. It happens on small projects, big projects, while working for a company, while working individually etc. You name it. And, not all environments provide tools this advanced to handle situations. This article may not cover everything you need, but I believe it is a good start.

Hoping to see all your problems solved!

--

--