13/09/2024

Tech Guru

Trusted Source Technology

Make a monorepo for your Android projects

Make a monorepo for your Android projects

This submit describes how you can share code concerning your aspect tasks without having code duplication, remote internet hosting or versioning. Build your very own Android monorepo repository making use of the Gradle establish process and some wizardry!

What is a monorepo?

In version control techniques, a monorepo (“mono” indicating ‘single’ and “repo” currently being limited for ‘repository’) is a software program development technique where by code for lots of initiatives is stored in the exact same repository.

https://en.wikipedia.org/wiki/Monorepo

As builders, we can have many (unfinished) aspect initiatives and a ton of the time we are performing the same issues more than and in excess of all over again. Most applications want logging, most apps hit community endpoints, most applications load visuals and so on etc.

Our facet initiatives generally require duplicate and pasting around code, then in the newest model modifying that code to strengthen it additional (or producing a library and web hosting it). At times I’ll then go again to an old venture and have to copy paste back my current options. Other situations I’ll overlook the amazing feature I included in the final undertaking, (these as exponential back off for network requests) and the new aspect undertaking will not be as “fancy” in that spot.

A monorepo can aid. When starting off a new aspect task, if you make every little thing a module, you contain the modules you want and you are then up and managing and concentrating on what’s new about this new facet project, as an alternative of scaffolding and reinventing your individual wheel each time.

With no further ado, let’s get started off.

I consider it is most effective to start by searching at the close, from what we want to achieve, set that expectation and then operate toward it. All code is readily available in the Git Repo in this article. This is the case in point layout from the GitHub Repo. It’s what you’ll have when you use a monorepo:

/monorepo
    /mono-libraries            << Shared app(s) code
        /logging
        /http
    /mono-build-logic          << Shared build code
        /android-plugins
        /android-k-plugins
        /tut-app-1-plugins
        /tut-app-2-plugins
    /tut-app-1                 << Specific app code
        /app 
        /libraries 
    /tut-app-2                 << Specific app code
        /app  
        /libraries 

Now you understand how a real world example will look. We can take a look what the project structure will be with some explanatory naming. Hopefully seeing both will let you understand and map between to the two when doing it for yourself. This is what our monorepo folder structure will look like, as well as the location of the Gradle files involved, when it’s complete:

/monorepo
    /mono-libraries                << Shared app(s) code
        /shared-library-1
        /shared-library-2
        /shared-library-N
            build.gradle.kts
    /mono-build-logic              << Shared build code
        settings.gradle.kts
        /shared-plugins
            build.gradle.kts
            /src/main/kotlin/plugin-shared-android.gradle.kts
        /side-project-1-plugins
        /side-project-2-plugins
        /side-project-N-plugins
            build.gradle.kts
            /src/main/kotlin/plugin-side-project-N-app.gradle.kts
    /side-project-1                << Specific app code
    /side-project-2                << Specific app code
    /side-project-N                << Specific app code
        build.gradle.kts
        settings.gradle.kts 
        /libraries
        /app
            build.gradle.kts   

For comparison this is what a ‘typical’ side project single app project would look like:

/side-project
/app
build.gradle.kts
/module1
/module2
/moduleN
build.gradle.kts
build.gradle.kts
settings.gradle.kts

We can see that modules in a solo side-project becomes shared-libraries in a monorepo. The Gradle build logic usually in app & moduleN folders moves to its own ‘project’. In Gradle speak this is known as a Composite Build, and is the crux of how your monorepo will work.

A monorepo starts with a root folder, the root folder holds 3 things:
1) Side Projects
– each of the apps (side projects) you have worked/working on (1 folder each)
2) Gradle builds logic
– the build logic for our monorepo & apps (1 folder with subfolders for each app)
– shared build logic modules between apps
3) Shared Libraries
-the libraries you want to share between projects (1 folder for each library)

A composite build is simply a build that includes other builds. In many ways a composite build is similar to a Gradle multi-project build, except that instead of including single projects, complete builds are included.

https://docs.gradle.org/current/userguide/composite_builds.html

The rest of this blog post is quite a long explanation (we’re going to be creating/modifying 12 Gradle build files in total!) but stick with it and at the end you will have a monorepo that you can use again and again for every side project. Alternatively, if you just want to get going fork the GitHub repo, scroll to the bottom, read the conclusion and you can be up and running straight away, skipping the step by step understanding. 🙂

Prerequisite: Folder Structure

Create a folder called monorepo, Inside this folder (i.e. in the root monorepo folder) create another folder called mono-libraries and another called mono-build-logic. Now we can start to fill out these folders with Gradle configuration files.

/monorepo
    /mono-libraries
    /mono-build-logic
    

If you want to add this monorepo to version control make sure you call git init from the monorepo folder and not from any of the subfolders.

1) Side Project creation

Use the Android Studio wizard to start a new project (or do it however you usually create a new project). Move this project inside of your folder called monorepo and this is where we will start from. From now on we’ll call this tut-app-1.

/monorepo
    /mono-libraries
    /mono-build-logic
    /tut-app-1
        /app
        /libs
        gradlew
        build.gradle
        ...etc

IMPORTANT NOTE
With a monorepo, the idea is to have one instance of the IDE (Android Studio) open per side-project (per app). The IDE works with “gradle roots” and we’re going to make each side project app a Gradle root. i.e. if your monorepo has two projects and you want to work on both, then you should have two instances of your IDE running.
(You technically can work on both in the same IDE instance, but only one will be on the classpath and therefore only one will be compiling/have IDE assistance. I would see this as a good thing, as you are not compiling code you are not working on saving yourself compile time.)

/tut-app-1/settings.gradle.kts

Edit /monorepo/tut-app-1/settings.gradle.kts and inside the pluginManagement block add an includeBuild method call, this is called a Composite Build by the Gradle build system and will allow us to separate our Android build logic from the Android apps we are making. The includeBuild will reference our mono-build-logic folder. Like so:

pluginManagement 
    repositories 
        gradlePluginPortal()
        google()
        mavenCentral()
    
    includeBuild("../mono-build-logic")

Next we’ll have our project include our shared-library-1 (think http or logging from the Git repo). This is what you’d do normally with the include method, and you can see the app module is included like that. For a monorepo the libraries have to be included slightly differently, but if you create a helper method (monoInclude) it can easily be achieved. Like so:

include("app")
monoInclude("shared-library-1")

fun monoInclude(name: String) 
    include(":$name")
    project(":$name").projectDir = File("../mono-libraries/$name")

We’ve declared that we want to build monorepo/mono-libraries/shared-library-1 but we haven’t created that module yet. That happens later on (below).

That’s the /monorepo/app/settings.gradle file done. Next is to declare the tut-app-1‘s project build file.

/tut-app-1/build.gradle.kts

The project build file does not have any changes out of the ordinary for a monorepo setup, except for one. Here we will declare a project extra property for the compose_version. This will allow us to reference the version number for compose in all dependencies in our app later on. If you where to take your monorepo to the next level, you’d likely move this versioning into the mono-build-logic.

buildscript 
    extra.apply 
        set("compose_version", "1.0.5")
    

    repositories 
        google()
        mavenCentral()
    
    dependencies 
       classpath("com.android.tools.build:gradle:7.0.4")
       classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31")
    

That’s the /tut-app-1/build.gradle.kts file done. Next is to declare our app module build file, whilst delegating most the work to our mono-build-logic.

/tut-app-1/app/build.gradle.kts

This file is usually where your app’s build logic is, however we are going to be delegating that to the mono-build-logic project with the idea of reducing duplication and repetition, didn’t I just say that?

Your file ends up looking like this:

plugins 
    id("tut-app-1")


dependencies 
    val implementation by configurations
    val debugImplementation by configurations

    implementation(project(":logging"))

    val composeVersion = rootProject.extra["compose_version"]
    implementation("androidx.core:core-ktx:1.7.0")
    implementation("androidx.appcompat:appcompat:1.4.1")
    implementation("com.google.android.material:material:1.5.0")
    implementation("androidx.compose.ui:ui:$composeVersion")
    // + other dependencies see git repo
    debugImplementation("androidx.compose.ui:ui-tooling:$composeVersion")

Notice that we don’t declare a dependency on the id("com.android.application") plugin like a usual stand alone application would. We’re going to use our own plugin tut-app-1 this will help reduce duplication and we will declare the dependency on the android plugin in there (no magic, just movement!).

To have the tut-app-1 module depend on the shared-library-1(i.e. logging module in the Git repo), we use the implementation(project( nested method call. Like so:

dependencies 
 ...
 implementation(project(":shared-library-1"))
 ...

Notice that the dependencies block uses:

val implementation by configurations

This is because we have moved the Gradle build logic from the app to the composite build plugins (to avoid duplication in the monorepo, keep reading it is shown next) and as such, the Gradle Kotlin DSL File can’t know at script compilation time that these configurations will be available when the script is applied. Hence the Kotlin extensions (implementation()) for these configuration not being available at script compilation time. You have to reference the configurations by name at runtime.

That’s the /tut-app-1/app/build.gradle.kts file done and that is also the tut-app-1 setup complete as well. Next is to recreate the build logic we have omitted from this file, by creating plugins in our mono-build-logic project.

/monorepo
    /mono-libraries
    /mono-build-logic               < UP NEXT
    /tut-app-1                      < COMPLETE
        build.gradle.kts
        settings.gradle.kts
        /app
            build.gradle.kts
            /src

2) Sharing Gradle Build Logic

Our build logic project is where we can declare how our build runs. (the good news is this section is a one off, so its a lot but we won’t need to do it again!) We want to declare that our project uses Android, that it’s an app and has libraries available, and we declare the usual things like minSDK version and application version etc.

Think of this build logic folder as a project in itself (because it is), it gives us the capability to make individual Gradle plugins and have each of our app project modules use these plugins. As such we will have a plugin for Android Libraries, a plugin for Android Apps and then app specific plugins that can then customise this shared build logic for the individual app. Makes sense? Doesn’t make sense? Read on to find out more.

First thing for this build logic project is to create the file structure. We already have a mono-build-logic folder. Inside that folder make three sub-folders, naming them android-plugins, android-plugins-k, tut-app-1-plugins. These are going to be the modules in this project.

/monorepo
    /mono-libraries
    /mono-build-logic
        /android-plugins
        /android-plugins-k
        /tut-app-1-plugins
    /tut-app-1

/mono-build-logic/android-plugins/build.gradle.kts

First up, create a new file in the android-plugins folder called build.gradle.kts. This is the file for declaring how this module (android-plugins) is built. For this module we are going to use the Groovy language for the plugin, the rest of the monorepo is all in the Kotlin language. We use Groovy here as it has a much more powerful reflection syntax, so we can configure the Android build system without having to depend on the Android plugins ourself. id("groovy-gradle-plugin") enables us to write Groovy Gradle plugins. Your file should end up looking like this:

plugins 
    id("groovy-gradle-plugin") // This enables src/main/groovy


dependencies 
    implementation("com.android.tools.build:gradle:7.0.4")

That’s the /mono-build-logic/android-plugins/build.gradle.kts file done. Now we have a project to create groovy plugins in. Next is to declare a Groovy plugin in this module that will setup the defaults of all our Android builds.

/mono-build-logic/android-plugins/src/main/groovy/android-module.gradle

Create a android-module.gradle in the android-plugins/src/main/groovy folder (you need to create that path). This is going to be a script plugin that forms the basis of every Android module in any side project (app) that’ll be included in the monorepo.

When you have script plugins like this, they use their filename as the name when referencing them by id. ie. using this plugin would look like id("android-module") because the filename is android-module.build.kts.

As you can see below, we’re using afterEvalute to check if the project (module) this plugin is being attached to is an Android project. If it is we configure the android closure with all the defaults we don’t want to keep duplicating in every app.

We are using a Groovy plugin here as it has a much more powerful reflection syntax, so we can configure the Android build system without having to depend on the Android app or library plugins directly (because we want it to work for both).

afterEvaluate { project ->
    if (project.hasProperty("android")) {
        android 
            compileSdk 31

            defaultConfig 
                minSdk 28
                targetSdk 30

                vectorDrawables 
                    useSupportLibrary correct
                

                testInstrumentationRunner "androidx.exam.runner.AndroidJUnitRunner"
            

            testOptions 
                unitTests.returnDefaultValues = genuine
            
            composeOptions 
                kotlinCompilerExtensionVersion compose_edition
            
            packagingOptions 
                methods 
                    excludes += "/META-INF/AL2.,LGPL2.1"
                
            
            compileOptions 
                sourceCompatibility JavaVersion.Version_11
                targetCompatibility JavaVersion.Version_11
            
            kotlinOptions 
                jvmTarget = "11"
                useIR = true
            
            buildFeatures 
                compose correct
            
        
    }
}

It is fully up to you what defaults you have in this article, I would say commence with this and migrate / overwrite qualities as you uncover a specific differentiation in a selected application (such as minSDK).

That’s the /mono-build-logic/android-plugins/src/main/groovy/android-module.gradle file carried out. We now have a reusable plugin available that declares some rudimentary shared android houses. Next we will generate a different shared plugin task, so that we can declare specific plugins that can be employed exclusively for an android library or android application module.

/mono-develop-logic/android-plugins-k/create.gradle.kts

We have made the android-plugins make logic venture, now we have to have to produce yet another venture android-plugins-k. Make a file named make.gradle.kts within of the /mono-establish-logic/android-plugins-k/ folder. The only variation will be this module residences Kotlin script plugins and the former module housed a Groovy script plugin.

This challenge (module) is dependent on the kotlin-dsl plugin and this will permit us to publish Kotlin script plugins. Recognize we have also included an api dependency on our Groovy android-plugins module. This permits the present module to entry the plugins of the latter module and to make them readily available to any dependent projects.

plugins 
    `kotlin-dsl` // This permits src/principal/kotlin


dependencies 
    api(undertaking(":android-plugins"))
    implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31")
    implementation("com.android.tools.create:gradle:7..4")

That’s the /mono-make-logic/android-plugins-k/construct.gradle.kts file carried out. Now we have a Kotlin job to create plugins in. Upcoming is to declare two Kotlin plugins in this module that will setup the defaults for our Android library & Android app builds.

/mono-develop-logic/android-plugins-k/src/major/kotlin/application-android-module.gradle.kts

Build the /src/principal/kotlin folder composition and within make a file termed application-android-module.gradle.kts. This will be our plugin that we use for each individual Android app. We will declare particulars to do with application modules listed here, but we will not have particulars to do with any just one individual application however, that arrives up coming.

This plugin is dependent on the android-module plugin we have already created. It also declares dependencies on com.android.software (since this plugin is predicted to be utilised for android programs), and kotlin-android (simply because we are making use of Kotlin in our apps).

The plugin then declares two build varieties for any android application that will count on it. The plugin makes certain the launch create kind has minification turned on and the debug establish has “debug” added to its software id (i.e. install package).

plugins 
    id("android-module")
    id("com.android.application")
    id("kotlin-android")


android 
    buildTypes 
        getByName("release") 
            isMinifyEnabled = legitimate
            proguardFiles(getDefaultProguardFile("proguard-android-improve.txt"), "proguard-policies.pro")
        
        getByName("debug") 
            isDebuggable = accurate
            applicationIdSuffix = ".debug"
        
    

Which is the /mono-develop-logic/android-plugins-k/src/main/kotlin/app-android-module.gradle file accomplished. We now have a reusable android software plugin obtainable that declares application module specific shared android properties. Upcoming we will develop a further shared plugin, this time for shared android library modules.

/mono-develop-logic/android-plugins-k/src/main/kotlin/library-android-module.gradle.kts

Together with the app plugin, inside src/key/kotlin, develop a file identified as library-android-module.gradle.kts. This will be our plugin that we use for just about every Android library module. We will declare specifics to do with library modules below, but we will not have details to do any just one individual library – that would be in the shared library by itself.

This plugin is dependent on the android-module plugin we have by now designed. It also declares dependencies on com.android.library (due to the fact this plugin is anticipated to be utilised for android library modules), and kotlin-android (mainly because we are utilizing Kotlin in our applications).

We do not have any other excess configuration in this plugin for the library modules.

plugins 
    id("android-module")
    id("com.android.library")
    id("kotlin-android")

That’s the /mono-develop-logic/android-plugins-k/src/major/kotlin/library-android-module.gradle file accomplished. We now have a reusable android library plugin available that declares library module specific shared android homes. Next we will build a precise plugin for our tut-application-1 aspect job to count on.

/mono-construct-logic
        /android-plugins                      << COMPLETE
            android-module.gradle
        /android-plugins-k                    << COMPLETE
            app-android-module.gradle.kts
            library-android-module.gradle.kts
        /tut-app-1-plugins                    << UP NEXT

/mono-build-logic/tut-app-1-plugins/build.gradle.kts

Create a new file in the tut-app-1 folder called build.gradle.kts. This is the file for declaring how this module (tut-app-1) is built. We’re going to use this module to create an app specific build script, allowing us to separate the build logic into its own separate testable project and simplifying the side-project-1‘s Gradle file. Notice we have also added an implementation dependency on our Kotlin android-plugins-k module. This allows the current module to access the plugins of the latter module (and its api declared groovy module).

plugins 
    `kotlin-dsl` // This enables src/main/kotlin


dependencies 
    implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31")
    implementation("com.android.tools.build:gradle:7.0.4")
    implementation(project(":android-k-plugins"))

That’s the /mono-build-logic/tut-app-1-plugins/build.gradle.kts file done. Now we have a build logic project to create an app specific plugin in. Next is to create the plugin itself for tut-app-1.

/mono-build-logic/tut-app-1-plugins/src/main/kotlin/tut-app-1.gradle.kts

Create a new file tut-app-1.gradle.kts inside of src/main/kotlin/, here is where we will depend on the app-android-module (because this plugin is for Android app modules).

We set up the details that are specific to this side-project (app). Therefore we declare what the installation applicationId is and the app version.

The rest of the Android build logic is handled up stream in the plugins: app-android-module and android-module (which we configured previously). If you are wondering, the only thing left to declare (coming up soon) is the dependencies block. This will be handled in the side project (app) itself.

plugins 
    id("app-android-module")


android 
    defaultConfig 
        applicationId = "com.blundell.tut1"
        versionCode = 1
        versionName = "1.0.0"
    

That’s the /mono-build-logic/tut-app-1-plugins/src/main/kotlin/tut-app-1.gradle.kts file done. Now we have an app specific plugin that allows us to abstract the complexity of building the project from building the app. Next is to update the settings.gradle.kts file to enable this project (mono-build-logic) to build each of the modules (projects) we have just created.

/mono-build-logic/settings.gradle.kts

Now let’s enable all the above modules we have create to be recognised and compiled by Gradle in this project. Create a settings.gradle.kts in the /mono-build-logic root folder. Once created, sync your IDE and it will recognise all the modules declared.

dependencyResolutionManagement 
    repositories 
        gradlePluginPortal()
        mavenCentral()
        google()
    


include("android-plugins")
include("android-k-plugins")
include("tut-app-1-plugins")

That’s the /mono-build-logic/settings.gradle.kts file done and that is also the mono-build-logic setup complete as well. Next is the final part, creating shared libraries that will be available to your side projects (apps).

/monorepo
  /mono-shared-libraries            << UP NEXT
  /mono-build-logic                 << COMPLETE
        settings.gradle.kts
        /android-plugins
            build.gradle.kts
            /src/main/groovy/android-module.gradle
        /android-plugins-k
            build.gradle.kts
            /src/main/kotlin/app-android-module.gradle.kts
            /src/main/kotlin/library-android-module.gradle.kts
        /side-project-1-plugins
        /side-project-2-plugins
        /side-project-N-plugins
            build.gradle.kts
            /src/main/kotlin/tut-app-1.gradle.kts
  /tut-app-1                        << COMPLETE

3) Sharing libraries

Use the Android Studio wizard to create an Android Library project (or move one that you already have), ensure it is created in the /mono-repo/mono-libraries folder. From now on we’ll call this shared-library-1 (in the GitHub repo example we have a http and a logging module they work exactly the same way).

/monorepo
  /mono-shared-libraries            << COMPLETE
      /shared-library-1

/mono-libraries/shared-library-1/build.gradle.kts

I’m sure you get the pattern by now. First up we have to enable this module to use the Gradle build system. Create a build.gradle.kts file inside of shared-library-1.

Here we’re wanting to have our module build an Android Library therefore we depend on our newly created library-android-module plugin. This allows the targetSDK, testOptions, compileOptions, packingExcludes etc etc to be inherited from our shared-build-logic plugins.

The only thing left to do for every library module now is to declare what dependencies you want to use. As below:

plugins 
    id("library-android-module")


dependencies 
    val implementation by configurations
    val testImplementation by configurations

    implementation("androidx.core:core-ktx:1.7.0")
    // + other dependencies see git repo
    implementation("com.squareup.okhttp3:okhttp:4.9.1")

    testImplementation("junit:junit:latest.release")

That’s it! That’s the monorepo/mono-libraries/logging/build.gradle.kts file written, and I hope you can see that that is also the template for any libraries you want to create or add in the future. Fore each shared library, it is a one line addition to the build file to depend on our library-android-module plugin, declare your dependencies and you are up and running.

/monorepo
  /mono-shared-libraries            << COMPLETE
      /shared-library-1
          build.gradle.kts
          /src/main/
  /mono-build-logic                 << COMPLETE
  /tut-app-1                        << COMPLETE

Recap

You now have a side project tut-app-1 with a concise build.gradle.kts file that only declares its dependencies and then delegates via Gradle plugins for the rest of the Gradle configuration.

tut-app-1 depends on the app-android-module plugin.

tut-app-1 depends on our shared libraries using a declaration such as: implementation(project(":shared-library-1"))

The shared-library-1 shared library module, matches the simplicity of the app build.gradle.kts file, in that it only declares its dependencies and then delegates via Gradle plugins for the rest of the Gradle configuration.

shared-library-1 depends on library-android-module.

app-android-module declares any build configuration specific to your application build (such as the versionCode).

library-android-module declares any build configuration specific to all shared libraries (such as a constant build config field).

app-android-module and library-android-module both depend on the android-module plugin.

Finally the android-module plugin declares all shared Gradle configuration that is typically duplicated between side-projects / apps and modules.

Conclusion

Whilst the initial setup of a monorepo is more in-depth than a single project. Once it is setup, it gives you a powerful mechanism for sharing code between projects and for separating the responsibilities of build configuration from app development.

A couple of things to be wary of with monorepo’s:

  1. With a monorepo, the idea is to have one instance of the IDE (Android Studio) open per side-project (per app). The IDE works with “gradle roots” and each side project app is a Gradle root. i.e. if your monorepo has two projects and you want to work on both, then you should have two instances of your IDE running.
  2. Your shared libraries are shared between apps. This means if you change the public API in one of your libraries, the other apps using that API will be broken and will need fixing. This isn’t so much of a problem if you can fix projects as you open them or ignore your old broken projects. An alternative solution is, when you find a shared library is used and updated a lot between projects it is a good candidate to be separated into its own build and versioned for release.

I hope you’ve enjoyed this walk through, all code is available on the GitHub repo and any feedback is always appreciated!