How to avoid duplicate code when using similar SDKs

As software engineers, nothing bothers us more than duplicating code within a project. For every line of duplicate code, it potentially doubles the effort required for each bug fix, refactor, and enhancement later in the life of the project. You may have heard of the DRY principle — “don’t repeat yourself” — that most engineers try to follow.

Spiderman identifying a *cough* duplicate bug

Duplicating code for nearly identical SDKs

The two Maps APIs are almost identical, with a few exceptions. The main differences were the namespaces and classes:

  • AmazonMap instead of GoogleMap
  • AmazonMapOptions instead of GoogleMapOptions
  • import package names of com.amazon.geo.mapsv2 instead of com.google.android.gms.maps for classes like LatLng, etc.

Each set of classes came from a different library — the Google Maps library could only be used on Google Android devices and the Amazon Maps library could only be used on Amazon devices.

I planned to use Gradle build variants to manage the two API implementations in the same project, but these slight differences meant that I’d need to duplicate a lot of code with very small differences in the classes. Which meant every time I changed the implementation, I’d need to change it twice. I started doing a manual copy/find/replace for several Amazon classes, but I knew there must be a better way to handle this.

The solution — Gradle to the rescue

I started wondering — what if I could automate the process of applying changes to the Amazon classes? I started thinking about how to read, alter, and apply diffs, but then I realize the solution could be much simpler — I could imitate the manual process I used and execute a copy/find/replace on a file when it changed.

And after experimenting a bit, I came up with this Gradle task:

On each build, when there is a change to a file in the Android Maps API directory (src/google/java/org/onebusaway/android/map/googlemapsv2), Gradle will:

  1. Copy the file to the Amazon directory (src/amazon/java/org/onebusaway/android/map/googlemapsv2)
  2. Replace all Google import and class namespaces (com.google.android.gms.maps) in the Amazon file with the Amazon version (com.amazon.geo.mapsv2)
  3. Replace all references to GoogleMap with AmazonMap
  4. Add a comment block header to each class (from a templateAmazonMapsFileHeader.txt) that says DO NOT EDIT, etc., with an explanation of how those classes are generated (to avoid someone else unfamiliar with the build process from making changes that would get erased)
  5. Exclude a file named ProprietaryMapHelpV2.java from this process. This is a utility class that contains small implementation differences between each Maps API, such as a method isMapsInstalled() that uses a different process to verify if the Maps API library is available on the device. We want these classes to be different for each flavor, so we don’t copy it.

preBuild.dependsOn copyMapsApiV2Classes makes this task run prior to the main build process.

Finally, the dependencies section of the build.gradle file looks like this to manage the dependencies for each flavor:

The full project is on GitHub if you’d like to check it out.

Copying libraries — Android Maps SDK v3 beta

Similar to the situation with Amazon, the v3 beta SDK is mostly similar to v2 but with a few small differences:

  • import package names changed from com.google.android.gms.maps to com.google.android.libraries.maps
  • Different dependencies in build.gradle

Sound familiar? We faced a similar choice — duplicate code with very minor differences across the two versions of the library and maintain both, or figure out a better way to manage this. In this case, maintaining code for multiple SDK versions in parallel would be even more work, as we’ve been working on Kotlin wrappers for both the Android Maps Utils project and the main Android Maps SDK in the Maps Android KTX project. This meant we’d be maintaining 3 copies of classes (plus the original code) where only the difference was the import statements 😵.

So, we decided to dig up the old Gradle task and modify it for these library projects.

However, after it was set up, we hit a few issues with the previous implementation when building releases for the library:

  • the -sources.jar file was empty for the v2 and v3 beta product flavors. Apparently the sourcesJar Gradle task is hardcoded to the /main source set and cannot changed per flavor.
  • the -javadoc.jar file only created docs for one flavor, seemingly also related to limitations of the Gradle task
  • automated publishing code for the generated artifacts became very complex

As a result, for these library projects we’re conceptually using the same process of copying source files and replacing imports, but we’re doing it across separate modules instead of separate product flavors. So, we’ve created several new project modules that hold the generated code. This allows the release and publishing process to execute as it normally would per project module.

The final code to copy the Android Maps Utils code from v2 to v3 is here and looks like this:

And here is a similar Gradle task in Maps Android KTX which looks like this:

And this works well! We modify the source code in one place for the code that depends on the Android Maps API v2 SDK, and that gets copied over to another module for the code that relies on the Maps API v3 beta SDK.

…and back to apps

Tips and tricks

Source of truth

You can see in the initial OneBusAway Android implementation I automatically appended a header from AmazonMapsFileHeader.txt on all copied files that warns contributors not to edit the auto-generated files.

I think an even better solution is the one used in Android Maps Utils, which uses .gitignore to avoid committing the auto-generated files to the versioned repository.

SDK APIs don’t have to be exactly 1:1

You can work around these differences by using a file with the same name in both build variants that has slightly different implementations based on the underlying SDK. You then exclude this file from the copy process so the implementation differences aren’t copied between build flavors.

For example, I created a ProprietaryMapHelpV2.java for the Google SDK in that build source folder that calls the Z index method:

…and a ProprietaryMapHelpV2.java for the Amazon SDK that is just a no-op:

Now your code to implement this feature looks the same across both flavors — ProprietaryMapHelpV2.setZIndex(marker, zIndex) — and can be copied using Gradle. You can use this same approach for code that checks if the SDK is installed on the device, calls APIs that are implemented slightly differently (e.g., geocoding), etc. Check out the Google ProprietaryMapHelpV2.java and Amazon ProprietaryMapHelpV2.java from OneBusAway for additional examples.

Conclusions

I learned during this process that the Gradle copy task works best:

  • between build variants in apps to share resources across the variants
  • between modules in libraries to simplify the release process

Have your tried something similar in your app? And thoughts on how to improve this script? Please comment below!

Acknowledgements

Improving the world, one byte at a time. @sjbarbeau, https://github.com/barbeau, https://www.linkedin.com/in/seanbarbeau/. I work @CUTRUSF. Posts are my own.