Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Build bigger, better: Gradle for large projects

Build bigger, better: Gradle for large projects

Avatar for Aurimas Liutikas

Aurimas Liutikas

June 04, 2019
Tweet

More Decks by Aurimas Liutikas

Other Decks in Technology

Transcript

  1. Android Projects are the same • Start as simple monolith

    builds. • Organically grow in size over time. • Each change incrementally makes sense.
  2. AndroidX build growth • 5 (2013) → 240 (today) Gradle

    projects • 91,415 (2013) → 427,746 (today) lines of source code • 1,965 (2013) → 11,033 (last 12 months) git commits
  3. Why? • More parallelism • Compilation avoidance • Fewer cache

    invalidations • Monolithic builds can lead to tangled dependencies. • Per module tests
  4. Java or Kotlin only libraries • Simpler Gradle projects (no

    variants, resources, manifests) • Forced separation from Android APIs • Test can run on the host
  5. Apply only needed plugins • Keep root project light •

    Avoid applying every Gradle plugin to every project (e.g. using allprojects {})
  6. Scope annotation processors • Separate code that requires annotation processors

    into a library • Run annotation processors only on libraries that need it
  7. Modularization side-effects • Increased cost of computing dependency graph •

    Repeated build configuration code which is error-prone and hard to maintain
  8. Root build.gradle (Option 1) ext { androidx = [ appcompat

    : "androidx.appcompat:appcompat:1.0.2", constraintlayout : "androidx.constraintlayout:constraintlayout:1.1.3", test : [ runner : "androidx.test:runner:1.1.1", espresso : "androidx.test.espresso:espresso-core:3.1.1" ] ] firebase = [ appindexing : "com.google.firebase:firebase-appindexing:17.1.0" ] junit = "junit.junit:4.12" }
  9. Project build.gradle (Option 1) dependencies { implementation androidx.appcompat implementation androidx.constraintlayout

    implementation firebase.appindexing testImplementation junit androidTestImplementation androidx.test.runner androidTestImplementation androidx.test.espresso }
  10. Dependencies.kt in buildSrc/ (Option 2) package my.example const val ANDROIDX_APPCOMPAT

    = "androidx.appcompat:appcompat:1.0.2" const val ANDROIDX_TEST_RUNNER = "androidx.test:runner:1.1.1" const val ANDROIDX_TEST_ESPRESSO = "androidx.test.espresso:espresso-core:3.1.1" const val ANDROIDX_CONSTRAINTLAYOUT = "androidx.constraintlayout:constraintlayout:1.1.3" const val FIREBASE_APPINDEXING = "com.google.firebase:firebase-appindexing:17.1.0" const val JUNIT = "junit.junit:4.12"
  11. Project build.gradle (Option 2) import static my.example.DependenciesKt.* dependencies { implementation

    ANDROIDX_APPCOMPAT implementation ANDROIDX_CONSTRAINTLAYOUT implementation FIREBASE_APPINDEXING testImplementation JUNIT androidTestImplementation ANDROIDX_TEST_RUNNER androidTestImplementation ANDROIDX_TEST_ESPRESSO }
  12. MyPlugin.kt in buildSrc/ class MyPlugin : Plugin<Project> { override fun

    apply(project: Project) { project.plugins.all { when(it) { is LibraryPlugin -> { val extension = project.extensions.getByType( LibraryExtension::class.java) extension.configureLibrary() } is AppPlugin -> { ... } } } } }
  13. MyPlugin.kt in buildSrc/ class MyPlugin : Plugin<Project> { override fun

    apply(project: Project) { project.plugins.all { when(it) { is LibraryPlugin -> { val extension = project.extensions.getByType( LibraryExtension::class.java) extension.configureLibrary() } is AppPlugin -> { ... } } } } }
  14. MyPlugin.kt in buildSrc/ private fun LibraryExtension.configureLibrary() { setCompileSdkVersion(28) defaultConfig.apply {

    minSdkVersion(21) versionCode = 1 versionName = "1.0" } compileOptions.apply { setSourceCompatibility(VERSION_1_8) setTargetCompatibility(VERSION_1_8) } }
  15. MyPlugin.kt in buildSrc/ project.plugins.all { when (it) { is JavaPlugin,

    is JavaLibraryPlugin -> { project.convention.getPlugin<JavaPluginConvention>().apply { sourceCompatibility = VERSION_1_8 targetCompatibility = VERSION_1_8 } } } }
  16. Custom configuration in each build.gradle // Module build.gradle apply plugin:

    my.example.MyPlugin apply plugin: 'com.android.application' android { defaultConfig { minSdkVersion 24 } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } }
  17. Example build.gradle in androidx plugins { id("AndroidXPlugin") id("com.android.library") } dependencies

    { ... } android { buildTypes.all { consumerProguardFiles "proguard-rules.pro" } lintOptions.fatal("UnknownNullness") } ...
  18. Example build.gradle in androidx ... androidx { name = 'Android

    Lifecycle Runtime' publish = true mavenVersion = LibraryVersions.LIFECYCLE mavenGroup = LibraryGroups.LIFECYCLE inceptionYear = '2017' description = "Android Lifecycle Runtime" url = AndroidXExtension.ARCHITECTURE_URL failOnDeprecationWarnings = false }
  19. Configuration using custom extensions // Module build.gradle apply plugin: my.example.MyPlugin

    apply plugin: 'com.android.application' myConfig { suppressLintWarnings.set(true) }
  20. Configuration using custom extensions // MyPlugin.kt class MyPlugin : Plugin<Project>

    { override fun apply(project: Project) { val myConfig = project.extensions.create("myConfig", MyPluginExtension::class.java) ...
  21. Configuration using custom extensions abstract class SomeTask : DefaultTask() {

    @get:Input abstract val suppressLintWarnings: Property<Boolean> } tasks.register("myTask", SomeTask::class.java) { suppressLintWarnings.set(myConfig.suppressLintWarnings) }
  22. General Advice Keep debug build type light. For example: create

    no-op tasks for debug that are full tasks for release builds CI should only build relevant variant(s)
  23. open class Task1 : DefaultTask() { @get:OutputDirectory lateinit var outputDir:

    File } open class Task2 : DefaultTask() { @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) lateinit var inputDir: File }
  24. val task1: TaskProvider<Task1> = tasks.register<Task1>("task1") { outputDir = File(project.buildDir, "foo")

    } tasks.register<Task2>("task2") { inputDir = File(project.buildDir, "foo") dependsOn(task1) } meh... !!
  25. val task1: TaskProvider<Task1> = tasks.register<Task1>("task1") { outputDir = File(project.buildDir, "foo")

    } tasks.register<Task2>("task2") { inputDir = File(project.buildDir, "foo") } task2.configure { dependsOn(task1) }
  26. abstract class Task1 : DefaultTask() { @get:OutputDirectory abstract val outputDir:

    DirectoryProperty } abstract class Task2 : DefaultTask() { @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) abstract val inputDir: DirectoryProperty }
  27. val task1: TaskProvider<Task1> = tasks.register<Task1>("task1") { outputDir.set(File(project.buildDir, "foo")) } val

    artifact: Provider<Directory> = task1.flatMap { it.outputDir } tasks.register<Task2>("task2") { inputDir.set(artifact) }
  28. Always Create Custom Task Types Default easy path will lead

    to the inefficient, or wrong, results
  29. defaultConfig { versionCode = 99999 applicationVariants.all { outputs.all { processManifestProvider.configure

    { doLast { val manifest = File( [email protected]().asFile, "AndroidManifest.xml") val content = manifest.readText() manifest.writeText(content.replace("99999", computeVersionCode())) } } } } }
  30. defaultConfig { versionCode = 99999 applicationVariants.all { outputs.all { processManifestProvider.configure

    { doLast { val manifest = File( [email protected]().asFile, "AndroidManifest.xml") val content = manifest.readText() manifest.writeText(content.replace("99999", computeVersionCode())) } } } } }
  31. defaultConfig { versionCode = 99999 applicationVariants.all { outputs.all { processManifestProvider.configure

    { doLast { val manifest = File( [email protected]().asFile, "AndroidManifest.xml") val content = manifest.readText() manifest.writeText(content.replace("99999", computeVersionCode())) } } } } }
  32. defaultConfig { versionCode = 99999 applicationVariants.all { outputs.all { processManifestProvider.configure

    { doLast { val manifest = File( [email protected]().asFile, "AndroidManifest.xml") val content = manifest.readText() manifest.writeText(content.replace("99999", computeVersionCode())) } } } } }
  33. defaultConfig { versionCode = 99999 applicationVariants.all { outputs.all { processManifestProvider.configure

    { doLast { val manifest = File( [email protected]().asFile, "AndroidManifest.xml") val content = manifest.readText() manifest.writeText(content.replace("99999", computeVersionCode())) } } } } }
  34. defaultConfig { versionCode = 99999 applicationVariants.all { outputs.all { processManifestProvider.configure

    { doLast { val manifest = File( [email protected]().asFile, "AndroidManifest.xml") val content = manifest.readText() manifest.writeText(content.replace("99999", computeVersionCode())) } } } } } UP-TO-DATE? CACHING?
  35. defaultConfig { versionCode = 99999 applicationVariants.all { outputs.all { processManifestProvider.configure

    { inputs.property("my_input", Callable{ computeVersionCode()}) doLast { ... manifest.writeText(content.replace("99999", computeVersionCode())) } } } } }
  36. defaultConfig { versionCode = 99999 applicationVariants.all { outputs.all { processManifestProvider.configure

    { val callable = memoize(Callable{ computeVersionCode()}) inputs.property("my_input", callable) doLast { ... manifest.writeText(content.replace("99999", callable.get())) } } } } }
  37. Value in android Model is not to update → Can

    impact other plugins Value vs File as input? doLast don’t use workers. Caveats
  38. abstract class GenerateCode : DefaultTask() { @get:Input abstract val packageName:

    Property<String> @get:Input abstract val className: Property<String> @get:OutputDirectory abstract val outputDir: DirectoryProperty @get:Input abstract val value: Property<String> }
  39. abstract class GenerateCode : DefaultTask() { @TaskAction fun doSomeWork() {

    computeOutputFile().writeText(""" package ${packageName.get()}; public class ${className.get()} { public final static long Something = ${value.get()}; } """.trimIndent()) } }
  40. android.applicationVariants.all { variant -> val taskProvider = project.tasks.register( "generate${variant.name}Code", GenerateCode::class.java

    ) { task -> task.packageName.set("my.package.name.here") task.className.set("CustomConfig") task.outputDir.set( File(project.buildDir, "intermediates/generate-code/${task.name}")) task.value.set(project.provider { computeValue() }) } }
  41. public interface BaseVariant { void registerJavaGeneratingTask( @NonNull Task task, @NonNull

    File... sourceFolders); void registerJavaGeneratingTask( @NonNull Task task, @NonNull Collection<File> sourceFolders); }
  42. android.applicationVariants.all { variant -> val taskProvider = project.tasks.register( "generate${variant.name}Code", GenerateCode::class.java

    ) { task -> ... } val task = taskProvider.get() variant.registerJavaGeneratingTask(task, task.outputDir.get().asFile) }
  43. Build Scans • ./gradlew <my-tasks> --scan ◦ Accept license ◦

    Build information is sent to Gradle’s server ◦ You can delete it after • Will help with: ◦ Non lazy task configuration (or lazy tasks that are force configured) ◦ Dependencies resolved are configuration time
  44. Gradle Memory Limits Default value might not be right for

    your project org.gradle.jvmargs=-Xmx????
  45. Gradle Memory Limits Experiment with the value for optimal speed

    / memory usage • build with --rerun-tasks • kill Gradle daemon in between runs (gradlew --stop) • run the most common “big” tasks • use typical developer computer (CPU / RAM)
  46. Gradle Memory Limits Consider using different values (memory and --max-workers)

    for CI • It runs different tasks • Might have more cores / memory
  47. Aurimas Liutikas Thank you! Software Engineer, Google Xavier Ducrohet Software

    Engineer, Google https://developers.google.com/ google.com/io Helpful resources