Debugging LiveData changes made easy

Photo by Markus Spiske from Unsplash

Have you ever got frustrated to debug LiveData changes by adding numerous log statements or breakpoints? As the core data structure of Android Architecture Components, LiveData is used widely in many apps to hold observable data. However, its debugging experience could still be a pain point after these many years.

Observing LiveData changes at scale

There are two traditional yet powerful methods for observing LiveData changes: Debugging and logging.

When it comes to debugging, we mostly interact with our IDE and do not need to touch any code. We can simply click "Debug” or "Attach Debugger to Android Process” in Android Studio and expect the process to be paused at the preset debug breakpoints.

Debugging in observers

In terms of logging, we can add logging statements to our observers to know when they are called. When we rerun the app, we should be able to view those log statements through Logcat while interacting with the app.

Logging in observers

Both debugging and logging are effective when we have simple LiveData observing chains. While we are iterating our app with new features and business logics, we may find it necessary to scale up our app into the recommended app architecture illustrated below. As you can tell, LiveData becomes more universally used as the data holder type across different components. In other words, LiveData observing chains merely get longer and more complex.

App Architecture using LiveData

When debugging our app at scale, logging might be less effective, because we do not want to change code at multiple places to debug. Alternatively, we could also build an abstract class LoggingObserver<T> : Observer<T>that adds log statements in onChanged(data: T) , and require all observers to inherit from LoggingObserver. This plausible approach is cumbersome since it requires large-scale refactoring of our app code.

Using debugging may not be effective either: We need to manually enable all relevant breakpoints in Android Studio. If we miss any, we need to restart the app flow; If we click “Resume” too fast, we may also need to restart. We may get exhausted by repeating restart! Compared to logging, debugging only presents information reflecting the current state and does not keep a record of states in the past.

The requirements are clear now: the debugging solution to easily observe LiveData changes should require the minimum amount of code changes and should preserve historical information.

Bytecode transformation at play

When Observer.onChanged() is called, its immediate caller is the LiveData.considerNotify(). If we were able to manipulate LiveData.considerNotify()and add logging with helpful debug information there, that would be a reasonable solution.

A straightforward approach is to modify the source code directly: LiveData is defined in androidx.lifecycle:lifecycle-livedata-core, so we could build a custom version of the library and use Gradle dependency substitution rule to swap out the library with our own. This works but we need to keep updating our custom library whenever we upgrade androidx.lifecycle, either directly or transitively.

This story narrates a different method: Bytecode transformation. It means that we are going to modify the bytecode LiveData.class in the build process. Compared to the dependency substitution, this is pragmatically a stronger bet. Although LiveData.considerNotify()is a private method and is an implementation detail, its implementation is actually less frequently changed than the new version. When searching on the history, LiveData.considerNotify()’s method and implementation has not fundamentally changed since this commit in 2017.

Below are the key components in this solution and we will expand them one by one.

Transform API

AGP Transform API is still unstable as of today. It allows custom processing intermediary build artifacts (project content and external libraries). This AGP orchestrates the compilation first, then custom transformation, and dexing at last.

The code above registers our transformation for AGP 4.1.3. Note that we are targeting applications only because library targets are not runnable and it does not make sense to add transformation there.

To implement the actual Transform , we need to supply the following:

  • getName as the unique name for this transform
  • applyToVariant as a predicate to apply transform on the given variant. We do not want excessive logging in the release build, so we will return true only for debug build
  • getInputType is only for classes or resources. We return CLASSESas we are modifying LiveData.class
  • isIncremental tells AGP whether our transform can be executed for incremental build
  • transform to hold the actual logic for transformation

The TransformInvocation will give us all external jars as the input

  • For any input jar that does not match androidx.lifecycle:lifecycle-livedata-core:.* in its name, directly copy to input to the output.
  • For the input jar matchingandroidx.lifecycle:lifecycle-livedata-core:.* in its name, unzip its content, use ASM to transform androidx/lifecycle/LiveData.class and write out the content into the output jar.

To keep this story short, you can visit here for the actual implementation.

ASM Bytecode Transformation

Rather than modifying the raw Java bytecode, we are usually more comfortable interacting with API. The bytecode manipulation framework we are using here is ASM, which is also used by OpenJDK, Gradle, and Kotlin etc.

Following the well-written user guide, to read the bytecode and write the new bytecode, we need to wire up aClassReader, a ClassWriter, and a custom ClassVisitor. Decorator pattern and visitor pattern can be recognized here.

ClassVisitor is for visiting class-level bytecode. This includes metadata, annotation, fields, and inner-class. But method-level bytecode is not what ClassVisitor handles. In order to do that, we need to override the returned MethodVisitor for the visitMethod()method:

For the LiveData.considerNotify(), what we would like to achieve is to insert the logging bytecode after the last line, equivalent to the Java code:

Log.i("LiveData", "considerNotify() called with LiveData = " + this + " Observer = " + observer.mObserver + " Data = " + this.mData);

Can we find the returninstruction and insert the code before? Probably not that easy, because there are other return statements in the method. A better anchor for our code is to check if the previous instruction is observer.mObserver.onChanged((T) mData) .

The remaining problem now is to write the bytecode equivalent to the Log.i()Java code. It seems quite daunting to write ASM code to generate bytecode ourselves. Fortunately, there are available IntelliJ Plugins like ASM Bytecode Viewer that come in handy. ASM Bytecode Viewer is built on top of ASMifier provided by ASM, and allows us to easily view the bytecode and ASMified code.

ASM Bytecode Viewer

With ASM Bytecode Viewer, we can write some Java code and translate it to ASM code with a few clicks. Below is an illustration of SM Bytecode Viewer. We added the extra log statement to our LiveData2.javaas a private copy of LiveData.java, clicked “Code” → “ASM Bytecode Viewer” to pop up the ASMPlugin window at the right, then click “ ASMified” tab at the top.

Left: Java code RIght: ASM code

This is quite relieving! Let’s copy the ASM code and fill them into LoggingLiveDataClassVisitor.visitMethodInsn. There is one last thing we need to remember, we need to make sure visitMaxs is called. This is because additional bytecode instructions may increase the MAXSTACK and MAXLOCALS for the target method. In our case, the MAXSTACK is actually increased from 2 to 3.

This implementation is now published as a Gradle plugin to maven central. For the targeting applications, we only need to apply the Gradle plugin and no code is required.

apply plugin: 'io.github.chao2zhang.livedatadebugger'

Now if we run the app, we can observe live data changes with sufficient information to help us debug our code further. The following is the logcat output:

Example logcat output

We now can see all Observer.onChanged() its timestamp, LiveData type and instance, Observer type and instance, and the data representation. Try it in your app and you will be amazed by the sheer number of Observer.onChanged() calls happening.

Afterword

My first encounter acquaintance of bytecode transformation is from Dagger Hilt, which uses the bytecode transformation to avoid writing boilerplate dagger declaration java code. Although this method is powerful, it may also introduce risks like debuggability because the actual code executed on the device may not be the exact same source code. We should be extremely cautious so that bytecode transformation is not abused.

On the other hand, Transform API was announced in AGP 1.5 but is still experimental and unstable. Some private announcement goes that Transform API will be deprecated soon and AGP 7.0 will probably provide a replacement.

Last but not least, Kotlin coroutine gains more popularity nowadays. The solution in this story is a bit outdated: It is only for LiveDatain Java. This solution is not applicable to Flow, because Kotlin Compiler Plugin allows us to manipulate Kotlin IR directly and it also works across platforms.

Stay tuned for future updates!

Android Developer@LinkedIn

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store