Introduction

Let’s discuss what Binary Compatibility Validator is, what it’s used for, and how it’s useful. When searching about BCV, you typically only find “How to” guides about usage. The “Why” aspect — why we need to use it — is rarely discussed. For those who are new to BCV, they’ll only understand “Ah, so that’s why we need BCV” after first understanding the problem. Therefore, in this article, before explaining what BCV is and how to use it, I’ll first discuss the problems related to Android Libraries so you can see why we should use Binary Compatibility Validator.

The Problem

First, to understand this, we need to try writing an Android Library. Let me share a problem I encountered when writing an Android Library for company internal use. We wrote the library for two main apps. The problem started when we published the library and began using it in those two apps. What happened was that one of the two apps started throwing a NoSuchMethodException (Runtime Exception). This error only appeared when running the app. When we looked for the cause, as its name suggests — the methods we were calling weren’t present during app runtime. The surprising thing was, where did our methods disappear to during Runtime?

In reality, the methods weren’t missing — they simply no longer existed. These methods disappeared during runtime. What’s interesting is that when we included the library, there were no errors shown during either implementation or compile time. Everything worked normally in the project, and we could call the methods without issues. But why did they disappear only when it reached runtime?

Transitive Dependencies

Before we continue with this topic, we need to understand Transitive Dependencies. Transitive Dependencies refer to indirect dependencies. For example — while we have direct dependencies that we add to build.gradle in our Android projects, these dependencies (libraries) also have their own dependencies. When Gradle downloads dependencies into our app, it automatically includes these libraries along with their dependencies. These dependencies that come indirectly through libraries are called transitive dependencies.

Complexity of Transitive Dependencies

Furthermore, transitive dependencies are not as simple as they seem. Transitive dependencies themselves have their own transitive dependencies. In an Android project, since we need to include many dependencies, if we just count the transitive dependencies that come with each one, the number is truly overwhelming. The main issue here is that these transitive dependencies are also interdependent on each other.

The above image is a warning shown in the readme of Google’s accompanist GitHub repo. From this image, we can clearly see how transitive dependencies can cause problems in your project. Sometimes it would be wrong to assume that you’re using a dependency in your project with only the version you specified. This is because if one of the dependencies you’re using in your project is also included in another dependency, and if that version is higher, your project will automatically use that higher version. Due to situations like this, you’ve probably experienced cases where you get errors even though you’re using a compatible version. Therefore, when incorporating third-party libraries, it’s necessary to be well aware of that library’s transitive dependencies.

Dependency Resolution

Let’s consider just two dependencies in our project as an example. Let’s say both of these dependencies have the same transitive dependencies. What I mean is, even though they both use the same dependency called retrofit, their versions are different. If one is using version 1.0.0, the other might be using 2.0.0. And this is just considering two dependencies. In reality, we often have more than 10 dependencies, and all of their versions may or may not match — some might be the same while others differ.

However, Gradle ultimately has to choose just one version. To solve this problem, Gradle has something called Dependency Resolution. By default, Gradle chooses the latest version. We can customize this behavior if needed. I won’t go into the details here.

Breaking Changes in API

Breaking changes are modifications to a library’s API that break existing functionality in projects using that library. Common examples include changing method signatures, return types, or removing public APIs. These changes are dangerous because they can cause runtime failures even when code compiles successfully, making backward compatibility essential.

1. Method Name Changes

 

// Original version
class UserRepository {
    fun getUserData(): User { ... }
}

// Breaking change
class UserRepository {
    fun fetchUserData(): User { ... } // Method name has changed
}

 

2. Method Parameter Changes

 

// Original version
class PaymentProcessor {
    fun processPayment(amount: Int) { ... }
}

// Breaking changes
class PaymentProcessor {
    // Case 1: Adding new parameter
    fun processPayment(amount: Int, currency: String) { ... }
    // Case 2: Changing parameter type
    fun processPayment(amount: Double) { ... }
    // Case 3: Changing parameter order
    fun processPayment(currency: String, amount: Int) { ... }
}

 

3. Return Type Changes

 

// Original version
class DataFetcher {
    fun fetchData(): List<String> { ... }
}

// Breaking changes
class DataFetcher {
    // Case 1: Complete return type change
    fun fetchData(): Array<String> { ... }
    // Case 2: Becoming nullable
    fun fetchData(): List<String>? { ... }
    // Case 3: Changing generic type
    fun fetchData(): List<Int> { ... }
}

 

Deprecation

You’ve probably noticed that in Android libraries with good backward compatibility, they don’t make such breaking changes directly between versions. They first implement deprecation. They typically warn users with messages like “This method is deprecated and may be removed in the future, please migrate to the new method” and so on. Some libraries even keep deprecated methods until several major versions have passed without removing them. This is done to maintain good backward compatibility for projects where the library usage is extensive and migration would be difficult.

Up to this point, breaking changes have only one user impact. For example — let’s say our library makes breaking changes in the next version without first implementing deprecation warnings. When users upgrade the library version, if they are using code affected by breaking changes in their project, it will definitely cause errors. In this case, users have the choice to either fix the breaking changes if they want to upgrade, or stay on the current version if they’re not ready to make changes yet.

What if another Library is using our Library?

Until now, we’ve only discussed the situation where users add our library as a direct dependency. In such cases, users can detect errors at compile time and make fixes. Now let’s discuss errors that can occur at runtime.

Let’s say another library is using our library. A user is using both our library directly and another library that uses our library. In this scenario, the third-party library is using an old version of our library (before breaking changes), while the user is using the latest version that includes breaking changes. By default, Gradle uses the latest version that the user is using. In this latest version, the code that the third-party library is using no longer exists. Since the user isn’t directly using this code, there’s no error at compile time, but when running the app, it throws a NoSuchMethodException at runtime because the code called through the library no longer exists. By now, you should understand why the code disappears at runtime.

In this situation, the user can choose to use the same version that the third-party library is using. However, in some cases, the user might need to use code from the latest version. In such situations, the user might face difficulties because they need to use both our latest version and the third-party library.

Practical Example

As an example of a situation that occurred in our library, we have a library that uses Google’s Accompanist. The user’s project that uses our library also uses Google’s Accompanist. However, their versions are different. In our library, we were using an old version from before breaking changes were made, while the user was using the latest version that included breaking changes. In this case, Gradle by default chose to use the higher latest version. When packaging the app, our methods were no longer included. This is why we got the NoSuchMethodException. The challenging part was that between two apps, one was using a higher version while the other was using a lower version. In the end, we had to resolve the problem by choosing a middle version that worked for both sides.

Resolution Strategy

A more complex situation arises when a user has more than one third-party library using our library. All these third-party libraries use different versions of our library. In this situation, users can use Gradle’s Resolution Strategy to force a specific version for the transitive dependency. This means instead of using the versions from third-party libraries, they force the use of their preferred version.

However, forcing versions isn’t a simple matter. Users need to choose a version that’s compatible with all the versions used by third-party libraries. Our library needs to support such a version as well. This means the version should include both old code (like deprecated methods) from before breaking changes and new code. This way, when the app runs, third-party libraries will be able to call their respective code.

Therefore, both developers and libraries should always follow up with the latest versions. If they continue using an old version for an extended period, version conflicts will definitely arise between libraries at some point. Making adjustments at that point can become extremely difficult. Additionally, libraries should not quickly make breaking changes, and it’s best if they can maintain backward compatibility between versions for a certain period.

What is Binary Compatibility Validator?

Now we will discuss BCV. To explain this, it’s necessary to first understand the topics we covered above. Only then, you can clearly see and understand how useful and important BCV is. Binary Compatibility Validator is an official tool under JetBrains’ GitHub Kotlin Project that checks for API breaking changes and is extremely useful. As mentioned above, breaking changes are sometimes unavoidable in library development. While it’s easy to say that libraries should not make breaking changes quickly and should maintain good backward compatibility through deprecation and overloaded methods, in practice, keeping track of breaking changes is not easy. While it’s not a big issue for small libraries with few changes, for large libraries or those that require frequent changes, it becomes a significant task for developers to check whether their method modifications might cause breaking changes compared to the previous release. Sometimes they might forget and commit changes, or miss checking for breaks.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

No results found.

Setup

The way BCV works is quite simple. First, if you have a library project, you just need to add it as a plugin in the root project’s build.gradle.kts.

plugins {
    id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.17.0"
}

BCV has two Gradle Tasks: apiDump and apiCheck.

apiDump

When you run apiDump, it automatically creates an api folder in the root level of every module in our library project. Inside this folder, it generates files named after each module (for example stream-chat-android-core.api). These files display the bytecode produced by the Kotlin compiler in a human-readable format, and BCV uses this format to detect and verify breaking changes.

apiCheck

When you run apiCheck, it compares the current code changes’ binaries with the files generated by apiDump. If there are breaking changes, it shows the locations of these changes in the output. This way, developers can easily identify and fix breaking changes.

When Should You Use apiDump vs apiCheck?

At this point, apiDump and apiCheck can be confused. Primarily, apiDump is meant to mark a stable state of the library. Therefore, it doesn’t need to be run frequently during normal development process. apiCheck, on the other hand, checks for breaking changes by comparing code changes against the .api files generated by apiDump. Therefore, you should run apiCheck every time you make code changes before committing. apiDump should be run after releases to mark a final stable state. It should also be run when making intentional breaking changes. For example, when we decide to remove deprecated code from our library after giving it some time, we should run apiDump again after making the code changes. By now, you should understand how to use apiDump and apiCheck.

Best Practices
  • Breaking changes should only be made during major version upgrades
  • Breaking changes should be clearly documented in release notes
  • Migration guides should be provided
  • Important breaking changes should have deprecation warnings added first
Final Thoughts

When using BCV, manually running Gradle tasks can be time-consuming and developers might forget about it. Therefore, instead of doing it manually, automating it with Git Hooks like pre-commit and pre-push can save work and reduce human error.

At this point, you should be able to understand the remaining details by reading the official github repo. The repo also includes customization aspects like how to ignore files.

I first noticed Binary Compatibility Validator through Stream Chat Android. GetStream has written a blog post about it here, though it focuses more on implementation rather than exploring the underlying problems as this article does.

This article is previously published on proandroiddev.com.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Now that Android Studio Iguana is out and stable, I wanted to write about…
READ MORE
blog
Widgets can look great against a home screen wallpaper when they have a solid…
READ MORE
blog
The ModalBottomSheet in Jetpack Compose is easy to use, it just pops up at…
READ MORE
blog
Discussions about accessibility, especially in software development, often center around screen reader accessibility. With…
READ MORE
Menu