Debugging LiveData changes made easy
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.
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.
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.
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 from Android Gradle Plugin
- ASM for Java bytecode manipulation
- ASM Bytecode Viewer IntelliJ Plugin to view bytecode and ASMify byte code
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 transformapplyToVariant
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 buildgetInputType
is only for classes or resources. We returnCLASSES
as we are modifyingLiveData.class
isIncremental
tells AGP whether our transform can be executed for incremental buildtransform
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 matching
androidx.lifecycle:lifecycle-livedata-core:.*
in its name, unzip its content, use ASM to transformandroidx/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 return
instruction 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.java
as 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.
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:
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 LiveData
in 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!