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
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
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:
getNameas the unique name for this transform
applyToVariantas 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
getInputTypeis only for classes or resources. We return
CLASSESas we are modifying
isIncrementaltells AGP whether our transform can be executed for incremental build
transformto hold the actual logic for transformation
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 transform
androidx/lifecycle/LiveData.classand 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 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
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.
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
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.
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!