Статья посвящена ускорению скорости сборки флейворов и разных типов сборки монолитного проекта с помощью многомодульности и кастомного файла конфигурации.

Возник запрос. Готов ответ

Имелась следующая проблема: довольно продолжительное выполнение сборки монолитного приложения на каждый флейвор и тип сборки.

Происходит это из-за BuildConfig, в котором задаются различные константы: адреса серверов, переключатели фич и т.п. Для каждого отдельного флейвора и типа сборки формируется свой BuildConfig, поэтому смена или использование констант в различных кусках кода из него вызывает почти полную пересборку проекта.

Для решения этой задачи было принято решение по созданию модуля fastapp, который будет заниматься только конфигурированием сборки и вследствие этого иметь высокую скорость сборки из-за маленького размера модуля. Константы конфигурации будут предоставляться другим модулям в runtime с помощью GlobalConfig на основе BuildConfig. “Константы” в GlobalConfig – это переменные var, использование которых не приводит к полной перекомпиляции монолитного модуля app, а приводит только к пересборке модуля fastapp. Также мы вынесем сервисы Google и Huawei из монолита в отдельные модули.

Создание модулей

Создаем модуль fastapp и переносим туда все, что связано с конфигурацией проекта, делаем его главным application-модулем, а монолитный модуль app – Android-библиотекой. Во всех модулях, кроме fastapp, отключаем buildConfig с помощью buildFeatures для того, чтобы при сборке проекта не генерировался класс BuildConfig. Также в процессе переноса сервисов Google и Huawei из пакетов в отдельные модули появилась необходимость в виде общих интерфейсов и реализаций, поэтому создаем еще один модуль – common, от которого зависит модуль app и от которого зависят модули с сервисами. Ниже представлены итоговые файлы build.gradle с конфигурацией модулей и зависимостями между ними.

fastapp/build.gradle

apply plugin: 'com.android.application'
// [...]

android {
    buildFeatures {
        buildConfig = true
    }

    buildTypes {
        debug {
            buildConfigField 'boolean', 'PRINT_LOGS', 'true'
        }
        qa {
            buildConfigField 'boolean', 'PRINT_LOGS', 'true'
        }
        release {
            buildConfigField 'boolean', 'PRINT_LOGS', 'false'
        }
    }

    flavorDimensions 'market', 'server'

    productFlavors {
        google {
            dimension 'market'
            buildConfigField 'String', 'PLATFORM', '"google"'
        }
        huawei {
            dimension 'market'
            buildConfigField 'String', 'PLATFORM', '"huawei"'
        }

        dev {
            dimension 'server'
            buildConfigField 'String', 'BASE_API_URL', "\"${apiUrl}\""
        }
        prod {
            dimension 'server'
            buildConfigField 'String', 'BASE_API_URL', "\"${apiUrl}\""
        }
    }
}

app/build.gradle

apply plugin: 'com.android.library'
// [...]

android {
    defaultConfig {
        // [...]

        buildConfigField 'int', 'DB_VERSION', '14'
    }

    buildFeatures {
        buildConfig = true
    }
}

dependencies {
    implementation project(":common")

    // [...]
}

common/build.gradle

apply plugin: 'com.android.library'
// [...]

android {
    buildFeatures {
        buildConfig = false
    }
}

dependencies {
    // [...]
}

build.gradle модулей сервисов Google и Huawei

apply plugin: 'com.android.library'
// [...]

android {
    buildFeatures {
        buildConfig = false
    }
}

dependencies {
    implementation project(":common")

    // [...]
}

Создание глобального конфига

GlobalConfig в данном случае – обычный object с var-полями, которые предоставляются остальным модулям в runtime. Названия полей аналогичны названиям полей в build.gradle модуля fastapp.

object GlobalConfig {
    // [...]

    var PRINT_LOGS: Boolean by singleAssign()
    var PLATFORM: String by singleAssign()
    var BASE_API_URL: String by singleAssign()
}

Для присвоения поля единожды используется singleAssign делегат.

SingleAssignDelegate.kt
internal inline fun <reified T> singleAssign() = SingleAssignDelegate<T>()

internal class SingleAssignDelegate <T> : ReadWriteProperty<Any?, T> {
    private var savedValue: T? = null

    override fun getValue(
        thisRef: Any?,
        property: KProperty<*>
    ): T = savedValue!!

    override fun setValue(
        thisRef: Any?,
        property: KProperty<*>,
        value: T
    ) {
        if (savedValue == null) {
            savedValue = value
        }
    }
}

Настройка DI и Application класса

Чтобы инициализация производилась автоматически при старте приложения, используется dummy-объект, который предоставляется модулем common. Для этого инжектим dummy-объект в классе приложения чтобы "потрогать" Hilt-модуль, в блоке init которого выполнится присвоение значений полей для GlobalConfig на основе соответствующих значений полей из BuildConfig. Выставление полей делегируется объекту ConfigModule, чтобы не делать реализацию конструктора приложения слишком длинной.

Dummy.kt

object Dummy

App.kt

@HiltAndroidApp
class App : Application() {
    // [...]

    @Inject
    lateinit var dummy: Dummy

    override fun onCreate() {
        super.onCreate()

        if (GlobalConfig.PRINT_LOGS) {
            Timber.plant(Timber.DebugTree())
        }

        // [...]
    }
}

ConfigModule.kt

@Module
@InstallIn(SingletonComponent::class)
object ConfigModule {
    init {
        with(GlobalConfig) {
            // [...]

            PRINT_LOGS = BuildConfig.PRINT_LOGS
            PLATFORM = BuildConfig.PLATFORM
            BASE_API_URL = BuildConfig.BASE_API_URL
        }
    }

    @Provides
    @Singleton
    fun provideDummy() = Dummy
}

Альтернативный вариант без Dummy и ConfigModule: присвоение полей в блоке init класса App без необходимости создавать Hilt-модуль и инжектить dummy-объект в классе приложения.

App.kt
class App : Application() {
    init {
        with(GlobalConfig) {
            // [...]

            PRINT_LOGS = BuildConfig.PRINT_LOGS
            PLATFORM = BuildConfig.PLATFORM
            BASE_API_URL = BuildConfig.BASE_API_URL
        }
    }

    override fun onCreate() {
        super.onCreate()

        if (GlobalConfig.PRINT_LOGS) {
            Timber.plant(Timber.DebugTree())
        }

        // [...]
    }
}

Рассмотрим 4 кейса, с которыми пришлось столкнуться в процессе решения задачи

Кейс 1/4: Room и версия базы данных в аннотации

Как известно, версия базы данных указывается в аннотации и необходима препроцессору во время компиляции. Поэтому оставляем поле с версией в defaultConfig и генерируем BuildConfig для модуля app. Так как версия базы данных меняется не очень часто, то это не будет приводить к частой перекомпиляции модуля app.

AppRoomDatabase.kt

@Database(
    entities = [
        // [...]
    ],
    version = BuildConfig.DB_VERSION
)
abstract class AppRoomDatabase : RoomDatabase() {
    // [...]
}

app/build.gradle

android {
    defaultConfig {
        // [...]

        buildConfigField 'int', 'DB_VERSION', '14'
    }
}

Кейс 2/4: Retrofit Service Interface и ключи в запросе

Кейс аналогичен предыдущему, то есть препроцессинг:  в аннотации Retrofit используется константа из BuildConfig.

@POST("api/${BuildConfig.API_KEY}")
suspend fun query(@Body request: RequestBody): JsonObject

В данном случае ключ зависит от собираемого флейвора и типа сборки, такое же решение как выше для базы данных не подходит. Поэтому подставляем ключ через @Path аннотацию.

@POST("api/{key}")
suspend fun query(
    @Path("key") key: String,
    @Body request: RequestBody
): JsonObject

При вызове запроса задаем ключ с помощью GlobalConfig: api.query(GlobalConfig.ANOTHER_KEY, request).

Кейс 3/4: BarcodeAnalyzer фабрика

При переносе сервисов Google и Huawei из пакетов в модули и вынесении общих классов в модуль common обнаружилось, что класс BarcodeAnalyzerImpl создается в ScanSession, отсюда появилась проблема, что реализации неизвестны, потому что модуль common ничего не знает модулях с сервисами.

class ScanSession(
    private val scanWindowArea: ScanWindowArea,
    private val type: BarcodeAnalyzerType? = QR,
    private val onBarcodeScanned: ((String) -> Unit)? = null,
) {
    private val barcodeAnalyzer by lazy {
        BarcodeAnalyzerImpl(type, scanWindowArea, displaySize) { code ->
            onBarcodeScanned?.invoke(code)
        }
    }
    private val displaySize by lazy { /* [...] */ }

    // [...]
}

После раздумий, как это разрулить, особо не затрагивая и не переписывая много кода, родилось решение в виде фабрики: фабрика возвращает нужную реализацию в зависимости от используемого сервиса.

Модуль common

class ScanSession(
    private val barcodeAnalyzerFactory: BarcodeAnalyzerFactory,
    private val scanWindowArea: ScanWindowArea,
    private val type: BarcodeAnalyzerType? = QR,
    private val onBarcodeScanned: ((String) -> Unit)? = null,
) {
    private val barcodeAnalyzer by lazy {
        barcodeAnalyzerFactory.create(type, scanWindowArea, displaySize) { code ->
            onBarcodeScanned?.invoke(code)
        }
    }
    private val displaySize by lazy { /* [...] */ }

    // [...]
}

interface BarcodeAnalyzerFactory {
    fun create(
        type: BarcodeAnalyzerType?,
        scanArea: ScanWindowArea,
        displaySize: Size,
        onBarcodeScanned: ((String) -> Unit)? = null
    ): BarcodeAnalyzer
}

Модули сервисов Google и Huawei

@Provides
@Singleton
fun provideBarcodeAnalyzerFactory(): BarcodeAnalyzerFactory {
    return object : BarcodeAnalyzerFactory {
        override fun create(
            type: BarcodeAnalyzerType?,
            scanArea: ScanWindowArea,
            displaySize: Size,
            onBarcodeScanned: ((String) -> Unit)?
        ): BarcodeAnalyzer {
            return BarcodeAnalyzerImpl(type, scanArea, displaySize, onBarcodeScanned)
        }
    }
}

Кейс 4/4: Пренебрежение ISP (Interface Segregation Principle, принцип разделения интерфейса)

Проблема аналогична предыдущей в плане переноса сервисов и зависимостей между модулями. Имеется обширный интерфейс, расположенный в модуле common. Этот интерфейс зависит от классов данных, расположенных в модуле app, а так же этот интерфейс нужен модулям с сервисами.

Модуль common

interface Repository {
    val notModel: Boolean
    var model: Model // common модуль не знает о Model в app модуле
}

Решается эта проблема разделением на два интерфейса, то есть реализация и интерфейс с моделями остаются в app, а всё, что не зависит от моделей, перемещается в отдельный интерфейс в модуль common.

Модуль app

interface Repository2 {
    // [...]
    var model: Model
}

class RepositoryImpl(
    // [...]
) : Repository, Repository2

Модуль common

interface Repository {
    val notModel: Boolean
    // [...]
}

Benchmarks

На картинке представлено время сборки проекта до изменений и после. Как видно, до изменений время сборки в отдельных случаях доходило до 1 часа. После описанного рефакторинга время сборки сократилось в разы и стало выглядеть более правдоподобно.

Чек-лист

  • Перенести связанные со сборкой файлы в модуль конфигурации fastapp.

  • Отключить во всех модулях, кроме fastaapp, генерацию BuildConfig. (Исключение можно сделать для случая, когда у вас есть база данных, версия которой задается в аннотации и меняется редко.)

  • Заменить, где необходимо, BuildConfig на GlobalConfig (если учитывать предыдущий пункт, то компилятор подскажет, где именно).

  • Не забыть перенести все поля и поправить название модуля app на fastapp в конфигурации CI.

  • Заменить [google/huawei]Implementation на implementation в модулях с сервисами, потому что их можно использовать только в com.android.application модуле.

Результаты и выводы

Перед всеми изменениями был один большой модуль и большое количество флейворов и типов сборки, которые конфигурировались константами из BuildConfig, что приводило к постоянной пересборке большого модуля.

Теперь есть один основной и маленький модуль fastapp. В маленьком модуле инициализируется только конфигурация сборки посредством GlobalConfig. Поэтому скорость сборки теперь зависит только от скорости сборки небольшого модуля fastapp.

Комментарии (0)