مدریریت وابستگی ها در موبایلت بانک سامان

سلام به همه برنامه‌نویسان اندروید عزیز! 👋

این اولین نوشته از مجموعه مقالاتیه که ما در تیم شرکت راهکار، تصمیم گرفتیم تجربه‌ها و دستاوردهای فنی‌مون رو با شما به اشتراک بذاریم. ما توی راهکار، همیشه به دنبال راه‌هایی برای بهبود فرآیند توسعه و ارتقاء کیفیت پروژه‌هامون هستیم. هدفمون از این مقالات، ایجاد یک فضای دوستانه برای یادگیری و تبادل نظر بین برنامه‌نویسان اندروید ایرانه.

لازم به ذکر هست که این مقاله، حاصل تلاش‌های ارزشمند تیم موبایل راهکار هست، و ما صمیمانه از نقش کلیدی همکار عزیزمون، آقای امیرحسین اسدپور، در توسعه و پیاده‌سازی این راهکار قدردانی می‌کنیم. این پلاگین‌ها توسط امیرحسین اسدپور توسعه داده شده است.

توی این مقاله، می‌خوایم در مورد یکی از چالش‌های اساسی که توی پروژه‌های بزرگ و ماژولار اندروید باهاش دست و پنجه نرم کردیم صحبت کنیم: مدیریت وابستگی‌ها (Dependency Management). اگه شما هم تجربه کار با پروژه‌های چند ماژولی رو داشته باشید، حتماً می‌دونید که تکرار وابستگی‌ها توی ماژول‌های مختلف و انجام تنظیمات مشابه برای هر ماژول، چقدر می‌تونه دردسرساز باشه.

تیم اندروید موبایلت برای حل این مشکل و داشتن یک ساختار منظم‌تر و کارآمدتر، یک راه حل خلاقانه رو پیاده‌سازی کرد: ساخت و استفاده از کانونشن پلاگین‌های Gradle اختصاصی. توی این مقاله، بهتون می‌گیم که چرا این تصمیم رو گرفتیم، چه مزایایی داشت، و چطور چند تا از پلاگین‌های کلیدی‌مون رو ساختیم.

چرا مدیریت وابستگی‌ها توی پروژه‌های ماژولار مهمه؟

قبل از اینکه بریم سراغ اصل مطلب و پلاگین‌هامون رو معرفی کنیم، بذارید یکم در مورد اهمیت مدیریت درست وابستگی‌ها توی پروژه‌های ماژولار صحبت کنیم. وقتی پروژه اندرویدتون از چند تا ماژول تشکیل شده (چه ماژول‌های Feature، چه Library و چه Dynamic Feature)، مدیریت وابستگی‌ها کم کم تبدیل میشه به یه چالش بزرگ.

تصور کنید که یه سری کتابخونه پرکاربرد مثل androidx.core-ktx, appcompat, material یا کتابخونه‌های تست رو توی اکثر ماژول‌هاتون استفاده می‌کنید. اگه یه سیستم مدیریت متمرکز نداشته باشید، مجبورید این وابستگی‌ها رو به صورت جداگانه توی فایل build.gradle هر ماژول تعریف کنید. این کار کلی مشکل به وجود میاره:

  • تکرار کد: هی یه وابستگی رو توی چند تا فایل build.gradle تعریف می‌کنید و این باعث میشه کدتون زیاد بشه و احتمال خطا هم بالا بره.

  • ناسازگاری نسخه‌ها: ممکنه یهو یادتون بره یه کتابخونه رو توی یه ماژول آپدیت کنید و این باعث میشه نسخه‌های کتابخونه‌ها توی ماژول‌های مختلف با هم فرق داشته باشن و کلی مشکل عجیب و غریب موقع اجرا به وجود بیاد.

  • شلوغ شدن فایل‌های build.gradle: فایل‌های build.gradleتون خیلی شلوغ میشن و دیگه خوندنشون سخت میشه.

  • سختی به‌روزرسانی‌ها: اگه بخواید یه کتابخونه رو توی کل پروژه آپدیت کنید، باید کلی فایل build.gradle رو تغییر بدید که هم زمان‌بره و هم ممکنه یه جا یادتون بره و خرابکاری بشه.

  • عدم انسجام توی تنظیمات: علاوه بر وابستگی‌ها، یه سری تنظیمات دیگه هم مثل compileSdk, minSdk, تنظیمات کامپایلر Kotlin و فعال کردن ViewBinding و Compose هستن که ممکنه توی ماژول‌های مختلف تکرار بشن.

تاریخچه مدیریت وابستگی‌ها در Gradle قبل از Convention Plugins

قبل از ظهور Convention Plugins و Version Catalogs، توسعه‌دهندگان اندروید و Gradle از روش‌های مختلفی برای مدیریت وابستگی‌ها و تنظیمات استفاده می‌کردند که هر کدام چالش‌های خاص خود را داشتند.

روش‌های پیشین:

۱. تعریف مستقیم در هر build.gradle:
ابتدایی‌ترین روش، تعریف مستقیم هر وابستگی در فایل build.gradle مربوط به هر ماژول بود. این روش برای پروژه‌های کوچک با چند ماژول قابل مدیریت بود، اما همانطور که در بالا اشاره شد، با بزرگ شدن پروژه و افزایش ماژول‌ها، به سرعت به کابوسی از کد تکراری، ناسازگاری نسخه‌ها و مشکلات به‌روزرسانی تبدیل می‌شد.
مثال کد (قبل):

// app/build.gradle.kts
plugins {
    id("com.android.application")
    kotlin("android")
    kotlin("kapt")
}

android {
    compileSdk = 34
    defaultConfig {
        minSdk = 21
        targetSdk = 34
        // ...
    }
    buildFeatures {
        viewBinding = true
    }
    // ...
}

dependencies {
    implementation("androidx.core:core-ktx:1.10.0")
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("com.google.android.material:material:1.9.0")
    implementation("com.google.dagger:hilt-android:2.44")
    kapt("com.google.dagger:hilt-compiler:2.44")
    // ...
}

// library_feature/build.gradle.kts
plugins {
    id("com.android.library")
    kotlin("android")
    kotlin("kapt")
}

android {
    compileSdk = 34
    defaultConfig {
        minSdk = 21
        // ...
    }
    buildFeatures {
        viewBinding = true
    }
    // ...
}

dependencies {
    implementation("androidx.core:core-ktx:1.10.0")
    implementation("com.google.dagger:hilt-android:2.44")
    kapt("com.google.dagger:hilt-compiler:2.44")
    // ...
}

۲. استفاده از بلاکext در build.gradle روت:
یکی از اولین تلاش‌ها برای متمرکز کردن نسخه‌ها، استفاده از بلاک ext در فایل build.gradle روت پروژه بود. در این روش، نسخه‌ها به عنوان متغیر تعریف می‌شدند و سپس در ماژول‌های مختلف به آن‌ها ارجاع داده می‌شد. این کار تا حدی مشکل ناسازگاری نسخه‌ها را حل می‌کرد، اما هنوز هم نیاز به تعریف وابستگی‌های کامل در هر ماژول وجود داشت.

// build.gradle (Project level)
buildscript {
    ext {
        kotlin_version = "1.9.0"
        hilt_version = "2.44"
        // ...
    }
    // ...
}

// app/build.gradle
dependencies {
    implementation("com.google.dagger:hilt-android:$hilt_version")
    kapt("com.google.dagger:hilt-compiler:$hilt_version")
}

۳. استفاده از برای تعریف Constantها:

با پیشرفت Gradle و نیاز به منطق پیچیده‌تر، بسیاری از پروژه‌ها شروع به استفاده از buildSrc کردند. این پوشه یک پروژه Gradle مستقل است که قبل از بقیه پروژه کامپایل می‌شود و به شما اجازه می‌دهد تا کد Kotlin یا Groovy بنویسید و Constantها و توابع کمکی را تعریف کنید. این روش به شما اجازه می‌داد تا لیست کامل وابستگی‌ها را به عنوان object یا class تعریف کنید و آن‌ها را در build.gradle ماژول‌ها استفاده کنید. این یک گام بزرگ رو به جلو بود، اما هنوز هم برای تنظیمات پیچیده یا اعمال پلاگین‌های متعدد نیاز به کپی‌پیست داشت.


معرفی Convention Plugins و Version Catalogs

این Convention Plugins به عنوان راه حلی قدرتمند برای این مشکلات معرفی شدند. به طور دقیق Gradle در نسخه 6.0 (در سال 2019) مفهوم precompiled script plugins را معرفی کرد که اساس Convention Plugins امروزی است. هدف اصلی این قابلیت، حذف تکرار در فایل‌های build.gradle و متمرکز کردن منطق پیکربندی بود.

با معرفی precompiled script plugins (که بعداً به Convention Plugins شهرت یافتند)، توسعه‌دهندگان می‌توانستند بخش‌های تکراری build.gradle را به پلاگین‌های مجزا تبدیل کنند. این پلاگین‌ها می‌توانستند هم وابستگی‌ها و هم تنظیمات Gradle را به صورت یکپارچه اعمال کنند.

همچنین، با معرفی Version Catalogs در Gradle 7.0 (در سال 2021)، مدیریت نسخه‌های وابستگی‌ها حتی ساده‌تر و متمرکزتر شد. فایل libs.versions.toml که پیش‌تر به آن اشاره شد، بخشی از این سیستم است که به شما امکان می‌دهد تمام وابستگی‌ها، پلاگین‌ها و نسخه‌ها را در یک مکان واحد تعریف کنید.

هدف اصلی Convention Plugins تنها مدیریت وابستگی‌ها نیست، بلکه متمرکز کردن هرگونه پیکربندی و منطق Gradle است که در چندین ماژول تکرار می‌شود. این شامل تنظیمات SDK، گزینه‌های کامپایلر، فعال‌سازی فیچرها (مانند ViewBinding یا Compose)، و حتی اعمال پلاگین‌های دیگر (مثل Hilt یا Lint) می‌شود. این ویژگی آن‌ها را به ابزاری قدرتمند برای ایجاد یکپارچگی و کاهش boilerplate code در پروژه‌های بزرگ و ماژولار تبدیل می‌کند.

راهکار تیم موبایلت: پلاگین‌های Gradle اختصاصی

برای اینکه از شر این مشکلات خلاص بشیم، تیم اندروید موبایلت تصمیم گرفت یه راه حل اساسی برای خودمون پیدا کنیم. ما از قدرت Gradle استفاده کردیم و پلاگین‌های اختصاصی خودمون رو ساختیم. لازم به ذکره که ایده و الهام اصلی این پیاده‌سازی، از پروژه متن‌باز Now in Android گوگل گرفته شده . این پروژه، یک نمونه‌ عالی از پیاده‌سازی Convention Plugins رو داره. و ما با بررسی آن، این راهکار را برای نیازهای پروژه‌های خودمان پیاده سازی کردیم

با این کار تونستیم:

  • وابستگی‌های مشترک رو به صورت متمرکز مدیریت کنیم: همه کتابخونه‌هایی که توی بیشتر ماژول‌هامون استفاده میشن رو یه جا تعریف کردیم و به راحتی توی ماژول‌های مختلف ازشون استفاده کردیم.

  • یه سری تنظیمات پیش‌فرض و مشترک رو اعمال کنیم: تنظیماتی مثل نسخه‌های compileSdk و minSdk, تنظیمات کامپایلر Kotlin، و فعال کردن ViewBinding و Compose رو به صورت خودکار روی ماژول‌هامون اعمال کردیم.

  • حجم فایل‌های build.gradle رو کم کنیم و خوانایی‌شون رو بالا ببریم: با استفاده از پلاگین‌ها، فایل‌های build.gradle ماژول‌هامون خیلی خلوت‌تر و تمیزتر شدن.

  • به‌روزرسانی‌ها رو راحت‌تر کنیم: برای به‌روزرسانی یه کتابخونه یا تغییر یه تنظیم، فقط کافیه پلاگین‌ها رو تغییر بدیم و این تغییرات به صورت خودکار روی همه ماژول‌های مربوطه اعمال میشه.

  • سرعت توسعه رو افزایش بدیم و خطاها رو کم کنیم: با حذف کارهای تکراری و داشتن تنظیمات یکسان، سرعت توسعه‌مون خیلی بیشتر شده و احتمال خطاها هم خیلی کمتر شده.

نگاهی به پلاگین‌های ما:

حالا بیاید چند تا از پلاگین‌های Gradle اختصاصی که توی تیم موبایلت ساختیم رو با هم ببینیم و نحوه کارکرد و مزایاشون رو بررسی کنیم. کدهایی که توی توضیحات قبلی به اشتراک گذاشتید، نمونه‌هایی از این پلاگین‌های قدرتمند هستن.

1. AndroidApplicationConventionPlugin:

این پلاگین برای پیکربندی ماژول‌های Application (همون ماژول اصلی اپلیکیشن) استفاده میشه. همونطور که توی کد می‌بینید:

class AndroidApplicationConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply(libs.findPlugin("android-application").get().get().pluginId)
                apply("kotlin-android")
                apply("kotlin-kapt")
            }
            extensions.configure<ApplicationExtension> {
                configureKotlinAndroid(this)
                defaultConfig.targetSdk = 34
            }
        }
    }
}

این پلاگین اول پلاگین‌های com.android.application، kotlin-android و kotlin-kapt رو اعمال می‌کنه. این کار باعث میشه که قابلیت‌های اصلی توسعه اپلیکیشن اندروید و پشتیبانی از کاتلین و KAPT به ماژول اضافه بشه. بعدش از طریق extensions.configure<ApplicationExtension>، تنظیمات مربوط به ApplicationExtension رو پیکربندی می‌کنه. اینجا، تابع configureKotlinAndroid (که توی فایل ir.mobillet.app تعریف شده) برای اعمال تنظیمات مشترک کاتلین و تنظیم targetSdk به 34 فراخوانی میشه.

2. AndroidLibraryConventionPlugin:

این پلاگین برای پیکربندی ماژول‌های Library (کتابخانه‌های اندروید) استفاده میشه:

class AndroidLibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                listOf(
                    "androidLibrary",
                    "kotlin-android-gradle"
                )
                    .map { libs.findPlugin(it).get().get().pluginId }
                    .forEach { apply(it) }
                apply("mobillet.android.flavors")
                apply("kotlin-android")
                apply("kotlin-kapt")
                apply("kotlin-parcelize")
                apply("mobillet.android.lint")
                apply("mobillet.android.hilt")
            }
            extensions.configure<LibraryExtension> {
                configureKotlinAndroid(this)
                defaultConfig {
                    vectorDrawables.useSupportLibrary = true
                }
                @Suppress("UnstableApiUsage")
                testOptions {
                    unitTests.isReturnDefaultValues = true
                }
                buildFeatures {
                    viewBinding = true
                }
                buildTypes {
                    release {
                        isMinifyEnabled = true
                        proguardFiles(
                            getDefaultProguardFile("proguard-android-optimize.txt"),
                            "proguard-rules.pro"
                        )
                    }
                }
            }
        }
    }
}

این پلاگین یه عالمه پلاگین مورد نیاز برای یه ماژول Library رو اعمال می‌کنه، از جمله com.android.library، kotlin-android-gradle، پلاگین‌های مربوط به Flavorها (mobillet.android.flavors که اینجا جزییاتش نیست)، kotlin-android، kotlin-kapt، kotlin-parcelize، Lint (mobillet.android.lint) و Hilt (mobillet.android.hilt). بعدش تنظیمات مربوط به LibraryExtension رو پیکربندی می‌کنه:

  • فراخوانی configureKotlinAndroid برای تنظیمات مشترک کاتلین.

  • فعال کردن vectorDrawables.useSupportLibrary.

  • تنظیم unitTests.isReturnDefaultValues برای تست‌های واحد.

  • فعال کردن viewBinding.

  • پیکربندی تنظیمات مربوط به buildTypes به خصوص برای Build Type release (فعال کردن Minify و تنظیم فایل‌های Proguard).

3. AndroidHiltConventionPlugin:

این پلاگین برای مدیریت وابستگی‌های مربوط به Hilt (Dependency Injection) توی ماژول‌ها استفاده میشه:

class AndroidHiltConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply(libs.findPlugin("hilt-android").get().get().pluginId)
            }
            dependencies {
                "implementation"(libs.findLibrary("hilt-androidPart").get())
                "kapt"(libs.findLibrary("hilt-compiler").get())
            }
        }
    }
}

این پلاگین به راحتی پلاگین dagger.hilt.android.plugin رو اعمال می‌کنه و بعدش وابستگی‌های مورد نیاز برای Hilt (hilt-android و hilt-compiler) رو با استفاده از libs.findLibrary() (که به libs.versions.toml اشاره داره) به ماژول اضافه می‌کنه. توجه کنید که hilt-compiler توی scope kapt اضافه شده.

4. AndroidApplicationComposeConventionPlugin:

این پلاگین برای پیکربندی ماژول‌هایی که از Jetpack Compose استفاده می‌کنند طراحی شده:

class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            try {
                extensions.configure<LibraryExtension> {
                    configureAndroidCompose(this)
                }
            } catch (e: UnknownDomainObjectException) {
                extensions.configure<ApplicationExtension> {
                    configureAndroidCompose(this)
                }
            }
        }
    }
}

این پلاگین تلاش می‌کنه تا configureAndroidCompose رو روی LibraryExtension اعمال کنه و اگه اون وجود نداشت (که نشون میده ماژول Application هست)، اون رو روی ApplicationExtension اعمال می‌کنه. این نشون میده که تنظیمات Compose هم توی ماژول‌های Feature (که معمولاً Library هستن) و هم توی ماژول اصلی Application قابل استفاده هستن. تابع configureAndroidCompose (که توی فایل ir.mobillet.app تعریف شده) تنظیمات مربوط به Compose رو انجام میده.

البته توی این مورد میتونیم به جای استفاده از try-catch، توی پلاگین جدا، یکی برای Application و دیگری برای Library بسازیم و هر کدوم رو در جای مورد نیازش استفاده کنیم.

5. AndroidLintConventionPlugin:

این پلاگین برای اعمال تنظیمات مربوط به Lint (ابزار آنالیز کد استاتیک) استفاده میشه:

class AndroidLintConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply(libs.findPlugin("ktlint").get().get().pluginId)
            }
        }
    }
}

این پلاگین به سادگی پلاگین ktlint رو اعمال می‌کنه، که به تیم اجازه میده استایل کد کاتلین رو توی کل پروژه به صورت یکنواخت حفظ کنه.

مدیریت نسخه‌ها با libs.versions.toml:

همونطور که توی کد پلاگین AndroidHiltConventionPlugin دیدید، ما از libs.findLibrary() برای دسترسی به وابستگی‌ها استفاده می‌کنیم. این متد از یه فایل به اسم libs.versions.toml میاد که توی ساختار build-logic پروژه تعریف شده. این فایل یه روش متمرکز و سازمان‌یافته برای مدیریت نسخه‌های کتابخونه‌ها و پلاگین‌ها توی کل پروژه فراهم می‌کنه.

فایل dependencyResolutionManagement توی فایل settings.gradle.kts پروژه build-logic نحوه تعریف این کاتالوگ نسخه رو نشون میده:

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
    versionCatalogs {
        create("libs") {
            from(files("../gradle/libs.versions.toml"))
        }
    }
}

و فایل plugins توی build.gradle.kts پروژه build-logic نحوه تعریف Alias برای پلاگین‌ها رو نشون میده:

plugins {
    alias(libs.plugins.android.application) apply false
// ... سایر پلاگین ها
}

با استفاده از این سیستم، به راحتی میشه نسخه‌های کتابخونه‌ها و پلاگین‌ها رو یه جا به‌روزرسانی کرد و از ناسازگاری نسخه‌ها توی ماژول‌های مختلف جلوگیری کرد.

چطوری از پلاگین‌ها توی ماژول‌هامون استفاده می‌کنیم؟

برای استفاده از این پلاگین‌ها توی ماژول‌های مختلف پروژه اندروید، کافیه اون‌ها رو توی بخش plugins فایل build.gradle ماژول مورد نظر اعمال کنیم.

مثال کد (بعد از استفاده از Convention Plugins):

// app/build.gradle.kts
plugins {
    id("mobillet.android.application")
    id("mobillet.android.hilt")
    id("mobillet.android.compose") 
    id("mobillet.android.lint") 
}

android {
    // دیگه نیازی به تنظیمات تکراری compileSdk, minSdk, targetSdk, viewBinding, etc. نیست!
    // همه این‌ها در Convention Plugin مدیریت شده‌اند.
}

dependencies {
    // فقط وابستگی‌های خاص این ماژول که عمومی نیستند
    implementation(project(":feature:some_feature")) // وابستگی به ماژول فیچر
}

// library_feature/build.gradle.kts
plugins {
    id("mobillet.android.library") 
    id("mobillet.android.hilt") 
    id("mobillet.android.compose") 
    id("mobillet.android.lint")  // برای استفاده از Lint
}

android {
    // دیگه نیازی به تنظیمات تکراری نیست!
}

dependencies {
    // فقط وابستگی‌های خاص این ماژول
    implementation(libs.androidx.datastore.preferences) // مثال: یک وابستگی خاص این ماژول
}

همونطور که می‌بینید، فایل build.gradle ماژول‌ها خیلی تمیزتر و متمرکز بر وابستگی‌ها و تنظیمات خاص خود ماژول شده.

مزایای کلی استفاده از پلاگین‌های Gradle اختصاصی:

  • افزایش سرعت بیلد: با کم شدن تنظیمات تکراری، Gradle می‌تونه سریع‌تر بیلد کنه.

  • بهبود سازماندهی کد: فایل‌های build.gradle تمیزتر و خواناتر میشن و تمرکز روی منطق ماژول بیشتر میشه.

  • افزایش قابلیت استفاده مجدد از کد: تنظیمات و وابستگی‌های مشترک به صورت یکپارچه مدیریت میشن.

  • کاهش خطاهای انسانی: با حذف کارهای تکراری، احتمال خطا توی تنظیمات کم میشه.

  • بهبود تجربه توسعه‌دهنده: برنامه‌نویس‌ها می‌تونن بیشتر روی کد تمرکز کنن و کمتر درگیر تنظیمات Gradle بشن.

  • انسجام توی کل پروژه: خیالمون راحته که همه ماژول‌ها از نسخه‌های یکسان کتابخونه‌ها و تنظیمات مشابه استفاده می‌کنن.

نتیجه‌گیری: سرمایه‌گذاری برای آینده‌ای بهتر

تصمیم تیم موبایلت برای ساخت پلاگین‌های Gradle اختصاصی، یه سرمایه‌گذاری ارزشمند برای بهبود کیفیت و کارایی فرآیند توسعه پروژه‌های اندرویدمون بوده. این رویکرد نه تنها مشکلات مدیریت وابستگی‌ها و پیکربندی‌های تکراری رو حل کرده، بلکه باعث افزایش سرعت توسعه، بهبود سازماندهی کد و کاهش خطاهای انسانی شده.

امیدواریم این مقاله برای شما برنامه‌نویسان اندروید هم مفید بوده باشه و بهتون نشون بده که چطور میشه با استفاده از Gradle، مشکلات پیچیده رو به یه راه حل ساده و کارآمد تبدیل کرد. ما توی تیم موبایلت، همیشه مشتاق یادگیری و به اشتراک گذاشتن تجربه‌هامون هستیم. اگه سوالی دارید یا دوست دارید بیشتر در مورد این موضوع صحبت کنیم، حتماً توی بخش نظرات با ما در ارتباط باشید.

در مقالات بعدی، به سراغ موضوعات جذاب دیگه‌ای از دنیای توسعه اندروید خواهیم رفت!