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.
Duplicating code for nearly identical SDKs
It’s frustrating to encounter situations where code must be slightly different due to dependencies. The first place I encountered this was the Amazon Maps API. I maintain an open-source Android app called OneBusAway that used the Android Maps API v2, and I wanted to launch a version on the Amazon app store that used the Amazon Maps API instead.
The two Maps APIs are almost identical, with a few exceptions. The main differences were the namespaces and classes:
AmazonMap
instead ofGoogleMap
AmazonMapOptions
instead ofGoogleMapOptions
import
package names ofcom.amazon.geo.mapsv2
instead ofcom.google.android.gms.maps
for classes likeLatLng
, 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 exploring Gradle in more detail, which is an absolutely amazing build system for Android, especially if you remember the old days of Eclipse and Ant scripts.
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:
- Copy the file to the Amazon directory (
src/amazon/java/org/onebusaway/android/map/googlemapsv2
) - Replace all Google import and class namespaces (
com.google.android.gms.maps
) in the Amazon file with the Amazon version (com.amazon.geo.mapsv2
) - Replace all references to
GoogleMap
withAmazonMap
- Add a comment block header to each class (from a template
AmazonMapsFileHeader.txt
) that saysDO 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) - 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 methodisMapsInstalled()
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
I thought the above solution might be a one-off, but when working on the Android Maps Utils library project the team hit the same issue trying to support both the Android Maps SDK v2 and v3 beta in the same project.
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 fromcom.google.android.gms.maps
tocom.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 thesourcesJar
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
We used this copy/replace process yet again for the Google Maps Android Samples project, but, because these are apps and not libraries, we used build flavors instead of separate modules. Using build flavors allows sharing resources, etc. that you can’t easily do across modules. See these links for the Java and Kotlin projects for examples.
Tips and tricks
You should keep a few things in mind when using this technique to copy classes from one place in the project to another.
Source of truth
All code changes MUST be on the source-of-truth. Pick the original code you want to treat as the source of truth, and ONLY edit that code. Make it clear to other contributors, especially if the project is open-source, that edits shouldn’t be made to the copied code.
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
As I mentioned above, you’ll typically have a few small differences in the SDKs when using them within an app. For example, in OneBusAway, I wanted to take advantage of the Google SDK ability to set the marker Z index for better control of the display. However, the Amazon SDK didn’t include a Marker.setZIndex()
method — so a straight copy from the code using the Google SDK wouldn’t build.
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
In summary, when supporting multiple similar SDKs you can use a Gradle copy task to copy source files and use filters to automatically replace simple items like import packages and class names. This saves a lot of time when trying to maintain code that relies on two nearly identical SDKs.
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
- Thanks to Chris Arriola for implementing the Gradle copy task for Android Maps Utils, KTX libraries, and Google Maps Android Samples projects!