آموزش ناوبری در جت پک کامپوز: راهنمای Compose Navigation

برنامه نویسی اندروید
آموزش ناوبری در جت پک کامپوز: راهنمای Compose Navigation

پاسخ سریع و خلاصه کلیدی (Executive Architecture Summary)

سیستم ناوبری در جت پک کامپوز (Jetpack Compose Navigation) بر اساس الگو های مدرن معماری تک اکتیویتی (Single-Activity Architecture) و مسیریابی مبتنی بر نوع (Type-Safe Routing) از نسخه ۲.۸ کتابخانه رسمی Navigation توسعه یافته است.

در این رویکرد مدرن، تمامی مسیرها و مقاصد (Destinations) به جای استفاده از رشته های خام و ناامن (String-based URLs)، در قالب کلاس های داده ای سریال پذیر (Serializable Data Classes / Objects) تعریف می شوند.

این قابلیت خطاهای زمان اجرا (Runtime Crashes) را به طور کامل حذف کرده و بررسی تطابق انواع داده را به زمان کامپایل (Compile-time Verification) منتقل می کند.

اجزای کلیدی این معماری شامل NavHost به عنوان کانتینر رندر، NavController به عنوان مغز متفکر هدایت استک و متدهای composable<T> هستند که مستقیما با چرخه حیات ViewModel و سیستم تزریق وابستگی Hilt یکپارچه می گردند.

۱. تحول سیستم ناوبری در اندروید: از Fragment Transaction به Jetpack Compose

توسعه دهندگان اندروید سال ها با چالش های سیستم ناوبری سنتی مبتنی بر فرگمنت ها دست به گریبان بودند.

در معماری های کلاسیک، جابجایی میان صفحات مستلزم مدیریت دستی FragmentManager، اجرای تراکنش های سنگین FragmentTransaction، اضافه کردن به BackStack و دست و پنجه نرم کردن با خطاهای معروفی نظیر IllegalStateException: Can not perform this action after onSaveInstanceState بود.

مقایسه ناوبری متنی با سیستم امن تایپ Type-Safe در کامپوز
نمودار ۱: مقایسه معماری ناوبری متنی سنتی با سیستم مدرن Type-Safe بر پایه کاتلین سریالایزیشن و کاهش ۸۵ درصدی خطاهای ران تایم.

همان‌طور که در نمودار ۱ مشاهده می‌کنید، در سیستم سنتی مبتنی بر رشته (Legacy String Routes)، مسیرها به صورت الگو های شکننده URL تعریف می‌شدند. کوچک‌ترین غلط املایی یا ارسال نوع داده اشتباه در Bundle منجر به خطای ران‌تایم می‌گردید.

در رویکرد نوین Type-Safe Navigation، کلاس های داده‌ای @Serializable کاتلین جایگزین رشته ها شده‌اند. این تحول با بررسی صحت داده ها در زمان کامپایل، خطاهای زمان اجرا را تا ۸۵ درصد کاهش داده است.

تداخل چرخه حیات ویو با چرخه حیات فرگمنت، نشت حافظه های مداوم و از بین رفتن وضعیت فیلدهای ورودی در هنگام چرخش صفحه نمایش، بخشی از مشکلات همیشگی این معماری محسوب می شد.

با ظهور فریمورک اعلامی (Declarative UI) جت پک کامپوز، فلسفه رابط کاربری دستخوش تحولی بنیادین شد. در کامپوز دیگر مفهومی فیزیکی به نام Fragment وجود ندارد؛ بلکه کل رابط کاربری از توابع کامپوزبل (Composable Functions) سبک وزن، بدون حالت (Stateless) و قابل ترکیب تشکیل می شود. کامپوننت ها به جای نگه داری وضعیت در حافظه خود، تنها بازتابی ریاضی از وضعیت های جاری برنامه (State Hoisting) هستند.

کتابخانه رسمی Jetpack Navigation با بازطراحی کامل خود برای کامپوز، امکان تعریف گراف ناوبری را به صورت کدهای روان کاتلین فراهم ساخت. در نسخه های اولیه این کتابخانه (Navigation Compose ۲.۴ تا ۲.۷)، مسیرها از الگو های شبه آدرس وب مانند "profile/{userId}?showDetails={showDetails}" تبعیت می کردند. این شیوه اگرچه انعطاف پذیر به نظر می رسید، اما از دو نقص بزرگ رنج می برد:

  • عدم امنیت نوع (Lack of Type-Safety): کوچک ترین غلط املایی در نام کلیدها یا عدم تطابق نوع متغیر (ارسال رشته به جای عدد صحیح) تا لحظه اجرای برنامه در دستگاه کاربر پنهان می ماند و سبب کرش اپلیکیشن در پروداکشن می شد.
  • پیچیدگی در پارس دستی آرگومان ها: تبدیل دستی مقادیر از NavBackStackEntry.arguments و تزریق آن ها به توابع یا لایه های تجاری، مقدار زیادی کد زاید (Boilerplate Code) به ساختار پروژه تحمیل می کرد.

از نسخه 2.8.0 به بعد، گوگل با ادغام رسمی کتابخانه kotlinx.serialization، نسل جدید ناوبری کامپوز تحت عنوان Type-Safe Navigation را معرفی نمود که استاندارد طلایی توسعه اپلیکیشن های مدرن اندروید به شمار می رود.

۲. پیش نیازها و راه اندازی کتابخانه Navigation در گریدل (Gradle Setup)

برای پیاده سازی ناوبری مبتنی بر نوع در پروژه اندروید، باید پلاگین کاتلین سریالایزیشن و وابستگی های به روز کامپوز را در فایل های گریدل (پلاگین ها و کتابخانه ها) تنظیم نمایید. در فایل build.gradle.kts سطح ماژول اپلیکیشن:

// build.gradle.kts (Module :app)
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.kotlin.serialization) // فعال سازی کاتلین سریالایزیشن
}

dependencies {
    // هسته اصلی ناوبری کامپوز نسخه 2.8 یا جدیدتر
    implementation(libs.androidx.navigation.compose)

    // کتابخانه سریالایزیشن کاتلین برای تبدیل کلاس ها به آرگومان های مسیر
    implementation(libs.kotlinx.serialization.json)

    // هماهنگی ناوبری با تزریق وابستگی Hilt و ViewModel
    implementation(libs.androidx.hilt.navigation.compose)
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.androidx.lifecycle.runtime.compose)
}

در فایل libs.versions.toml نیز نسخه های مورد نیاز را به شکل زیر تعریف می کنیم:

[versions]
navigationCompose = "2.8.3"
kotlinxSerialization = "1.7.3"

[libraries]
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }

۳. تعریف مقاصد (Destinations) با کلاس های داده ای سریال پذیر

در رویکرد مدرن، هر مقصد یا صفحه در برنامه به عنوان یک کلاس داده ای (Data Class) یا یک شیء (Object) همراه با انوتیشن @Serializable تعریف می شود. اگر صفحه ای نیاز به هیچ آرگومانی نداشته باشد، از object استفاده می کنیم؛ در صورتی که صفحه پذیرای متغیرها و شناسه ها باشد، از data class بهره می گیریم:

// قراردادهای نوع دار مقاصد ناوبری با کاتلین سریالایزیشن
sealed interface AppDestination {
    @Serializable
    data object HomeScreen : AppDestination

    @Serializable
    data class ProductDetailScreen(val productId: Long, val sourceCampaign: String? = null) : AppDestination

    @Serializable
    data class CheckoutScreen(val cartId: String, val totalAmount: Double) : AppDestination

    @Serializable
    data class UserProfileScreen(val userId: String, val isVerified: Boolean = false) : AppDestination

    // مسیر تو در تو (Nested Graph) احراز هویت
    @Serializable
    data object AuthGraph : AppDestination

    @Serializable
    data object LoginScreen : AppDestination

    @Serializable
    data class RegisterScreen(val referralCode: String? = null) : AppDestination
}

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

۴. پیاده سازی NavHost و متدهای جنریک composable<T>

محل اصلی گردهمایی مسیرها و رندر صفحات، کامپوننت NavHost است. کنترل کننده مرکزی NavHostController وظیفه شنود دستورات حرکت، ثبت صفحات در استک و برقراری ارتباط با دکمه بازگشت سیستم عامل را بر عهده دارد:

@Composable
fun AppNavigationGraph(
    navController: NavHostController,
    modifier: Modifier = Modifier
) {
    NavHost(navController = navController, startDestination = AppDestination.HomeScreen, modifier = modifier) {
        composable<AppDestination.HomeScreen> {
            HomeScreen(
                onNavigateToProduct = { id -> navController.navigate(AppDestination.ProductDetailScreen(productId = id)) },
                onNavigateToProfile = { uid -> navController.navigate(AppDestination.UserProfileScreen(userId = uid)) },
                onNavigateToAuth = { navController.navigate(AppDestination.AuthGraph) }
            )
        }

        composable<AppDestination.ProductDetailScreen> { backStackEntry ->
            val destination: AppDestination.ProductDetailScreen = backStackEntry.toRoute()
            ProductDetailScreen(
                productId = destination.productId,
                campaign = destination.sourceCampaign,
                onBackClicked = { navController.popBackStack() },
                onCheckout = { cartId, total -> navController.navigate(AppDestination.CheckoutScreen(cartId, total)) }
            )
        }

        composable<AppDestination.CheckoutScreen> { backStackEntry ->
            val checkout: AppDestination.CheckoutScreen = backStackEntry.toRoute()
            CheckoutScreen(cartId = checkout.cartId, amount = checkout.totalAmount, onFinish = {
                navController.navigate(AppDestination.HomeScreen) { popUpTo(AppDestination.HomeScreen) { inclusive = true } }
            })
        }
    }
}
⚠️ خطای رایج پروداکشن: پاس دادن NavController

از ارسال شیء NavController به عمق کامپوزبل ها اکیداً خودداری کنید. این کار تست پذیری را از بین برده و وابستگی شدید ایجاد می‌کند؛ رویکرد استاندارد، ارسال لامبداهای رخداد به لایه والد است.

۵. یکپارچه سازی ناوبری با ViewModel و تزریق وابستگی Hilt

یکی از ظریف ترین بخش های معماری اندروید، همگام سازی زمان حیات ViewModel با مقصد فعال در گراف ناوبری است. با استفاده از متد hiltViewModel() در کامپوز، طول عمر ویومدل دقیقا مقید به طول عمر NavBackStackEntry مربوط به همان صفحه می گردد.

زمانی که کاربر از صفحه خارج شده و صفحه از استک پاپ می شود، متد onCleared() ویومدل فراخوانی شده و تمامی منابع، شنونده ها و کورتین های فعال به شکل خودکار آزاد می گردند.

علاوه بر این، در سیستم Type-Safe، آرگومان های ورودی صفحه مستقیما از طریق SavedStateHandle به صورت کاملا نوع دار و بدون نیاز به کلیدهای متنی دستی قابل واکشی هستند:

@HiltViewModel
class ProductDetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val productRepository: ProductRepository
) : ViewModel() {
    // استخراج مستقیم و نوع دار آرگومان بدون نیاز به کلید رشته ای
    val routeArgs = savedStateHandle.toRoute<AppDestination.ProductDetailScreen>()

    private val _uiState = MutableStateFlow<ProductUiState>(ProductUiState.Loading)
    val uiState: StateFlow<ProductUiState> = _uiState.asStateFlow()

    init {
        loadProductInformation(routeArgs.productId)
    }

    private fun loadProductInformation(id: Long) {
        viewModelScope.launch {
            try {
                val details = productRepository.getProductById(id)
                _uiState.value = ProductUiState.Success(details)
            } catch (e: Exception) {
                _uiState.value = ProductUiState.Error(e.localizedMessage ?: "Unknown error")
            }
        }
    }
}

۶. ارسال اشیای سفارشی و پیچیده با Custom NavType

در حالی که مقادیر اولیه (مانند Int، String، Long، Float و Boolean) به طور خودکار پشتیبانی می شوند، گاهی نیاز به انتقال یک ساختار داده ای غنی تر (یک مدل داده با چند فیلد یا یک لیست) میان دو صفحه وجود دارد.

بر اساس رهنمودهای رسمی گوگل در معماری اندروید، بهترین عمل مهندسی همواره ارسال تنها شناسه اصلی (ID) و واکشی اطلاعات کامل از دیتابیس محلی (Room) یا کش ریپازیتوری در صفحه مقصد است. با این حال اگر نیاز قطعی به ارسال اشیا داشته باشید، کاتلین سریالایزیشن امکان تعریف Custom NavType را فراهم می سازد:

@Parcelize
@Serializable
data class SearchFilters(val categoryId: Int, val minPrice: Double, val maxPrice: Double, val inStockOnly: Boolean) : Parcelable

// پیاده سازی NavType سفارشی برای سریالایز آبجکت در URL ناوبری
val SearchFiltersNavType = object : NavType<SearchFilters>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): SearchFilters? = bundle.getParcelable(key)
    override fun parseValue(value: String): SearchFilters = Json.decodeFromString(Uri.decode(value))
    override fun serializeAsValue(value: SearchFilters): String = Uri.encode(Json.encodeToString(value))
    override fun put(bundle: Bundle, key: String, value: SearchFilters) = bundle.putParcelable(key, value)
}

// ثبت در گراف ناوبری
@Serializable
data class SearchResultScreen(val filters: SearchFilters) : AppDestination

composable<SearchResultScreen>(
    typeMap = mapOf(typeOf<SearchFilters>() to SearchFiltersNavType)
) { backStackEntry ->
    val searchArgs: SearchResultScreen = backStackEntry.toRoute()
    SearchResultsView(filters = searchArgs.filters)
}

۷. مدیریت استک بازگشت (BackStack) و جلوگیری از ناوبری های تکراری

مدیریت استک صفحات یکی از حیاتی ترین وظایف توسعه دهنده برای تضمین تجربه کاربری عالی است. کاربران انتظار دارند با فشردن دکمه بازگشت (Back Gesture)، به صفحه قبلی بازگردند و در صورت خروج از فرآیندهایی نظیر لاگین، ورود به صفحه خانه نباید با زدن بازگشت دوباره به صفحه لاگین منتهی شود. متد navigate پارامترهای پیشرفته ای برای کنترل این رفتار ارائه می دهد:

کالبدشکافی پشته بازگشت و چرخه حیات NavBackStackEntry در کامپوز
نمودار ۲: ساختار لایه‌ای پشته بازگشت (BackStack)، تفکیک چرخه حیات Scoped ViewModel و مهار نشت حافظه در ناوبری کامپوز.

در نمودار ۲، کالبدشکافی لایه‌ای پشته بازگشت و نحوه اتصال نمونه های NavBackStackEntry به موتور NavHost نشان داده شده است. هر مقصد درون پشته، یک حوزه اختصاصی (Scope) برای ذخیره سازی وضعیت ایجاد می‌کند.

با بهره‌گیری از SaveableStateHolder، وضعیت اسکرول و فرم ها در رفت‌وبرگشت صفحات حفظ می‌شود. همچنین هنگام حذف صفحه با popUpTo، نمونه ViewModel مربوطه بدون نشت حافظه تخریب می‌گردد.

// هدایت به صفحه خانه پس از اتمام پرداخت و تخلیه کامل پشته تا صفحه خانه
navController.navigate(AppDestination.HomeScreen) {
    // حذف تمام صفحات تا مقصد مشخص شده
    popUpTo(AppDestination.HomeScreen) {
        inclusive = true // خود صفحه خانه نیز بازسازی می شود
    }
    // جلوگیری از ساخت نسخه های تکراری اگر مقصد قبلا در بالای استک باشد
    launchSingleTop = true
}

// حرکت میان تب های پایین صفحه (Bottom Navigation) با حفظ وضعیت (Save State)
fun navigateBottomTab(navController: NavHostController, targetDestination: Any) {
    navController.navigate(targetDestination) {
        popUpTo(navController.graph.findStartDestination().id) {
            saveState = true
        }
        launchSingleTop = true
        restoreState = true // بازیابی خودکار موقعیت اسکرول و وضعیت فرم ها
    }
}
✓ اقدام استاندارد: حفظ وضعیت با rememberSaveable

برای حفظ وضعیت های مهم فرم ها و ورودی های کاربر در تغییر کانفیگ یا رفت‌وبرگشت در پشته ناوبری، همیشه وضعیت های محلی را به جای remember ساده در rememberSaveable ذخیره کنید.

۸. پیاده سازی انیمیشن های جابجایی سفارشی (Animated Transitions)

تجربه کاربری نرم و مدرن به شدت وابسته به ترنزیشن های بصری میان صفحات است. در کامپوز مدرن، تنظیمات انیمیشن های ورود (Enter)، خروج (Exit)، ورود مجدد با پاپ (PopEnter) و خروج با پاپ (PopExit) به شکل مستقیم درون بلاک های composable یا در سطح کلی NavHost قابل تعیین هستند:

NavHost(
    navController = navController,
    startDestination = AppDestination.HomeScreen,
    enterTransition = {
        slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(350, easing = FastOutSlowInEasing)) + fadeIn(animationSpec = tween(350))
    },
    exitTransition = {
        slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(350, easing = FastOutSlowInEasing)) + fadeOut(animationSpec = tween(200))
    },
    popEnterTransition = {
        slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(350, easing = FastOutSlowInEasing)) + fadeIn(animationSpec = tween(350))
    },
    popExitTransition = {
        slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(350, easing = FastOutSlowInEasing)) + fadeOut(animationSpec = tween(200))
    }
) {
    // تمامی صفحات به شکل یکپارچه این ترنزیشن های روان را اعمال می کنند
}

۹. پیکربندی حرفه ای Deep Linking و تطبیق با مانیفست اندروید

دیپ لینک ها ابزار حیاتی بازاریابی بازگشتی و سئوی اپلیکیشن های موبایل هستند. با سیستم Type-Safe Navigation، تعریف الگو های URL منطبق با وب سایت (مانند https://vahidhoseini.ir/products/{productId}) به سادگی و از طریق متد navDeepLink انجام می پذیرد:

معماری پردازش دیپ لینک Deep Link در ناوبری کامپوز
نمودار ۳: خط لوله پردازش پیوندهای عمیق (Deep Links)، تطابق خودکار الگو های URI با کلاس های امن تایپ و بازسازی پشته.

بر اساس آنچه در نمودار ۳ ترسیم شده است، سیستم‌عامل اندروید لینک های خارجی یا نوتیفیکیشن ها را از طریق Intent Filter به NavHost تحویل می‌دهد.

تطبیق دهنده الگو های URI پارامترهای آدرس را استخراج و به کلاس داده‌ای @Serializable نگاشت می‌کند. همزمان، پشته بازگشت مصنوعی (Synthetic BackStack) ساخته می‌شود تا ناوبری بازگشتی کاملاً قابل پیش‌بینی باشد.

import androidx.navigation.navDeepLink

composable<AppDestination.ProductDetailScreen>(
    deepLinks = listOf(
        navDeepLink<AppDestination.ProductDetailScreen>(
            basePath = "https://vahidhoseini.ir/products"
        )
    )
) { backStackEntry ->
    val productArgs: AppDestination.ProductDetailScreen = backStackEntry.toRoute()
    ProductDetailScreen(productId = productArgs.productId)
}

در فایل AndroidManifest.xml تگ <intent-filter> متناظر را برای اکتیویتی اصلی تعریف می کنیم تا سیستم عامل ترافیک های وب را به اپ هدایت کند:

<activity
    android:name=".MainActivity"
    android:exported="true">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="https"
            android:host="vahidhoseini.ir"
            android:pathPrefix="/products" />
    </intent-filter>
</activity>

برای تست صحت عملکرد دیپ لینک از طریق خط فرمان و ابزار ADB می توان دستور زیر را در ترمینال اجرا نمود:

adb shell am start -a android.intent.action.VIEW -d "https://vahidhoseini.ir/products/4281" ir.vahidhoseini.app

۱۰. معماری ناوبری در پروژه های چندماژولار (Multi-Module Feature Navigation)

در پروژه هایی تجاری و سازمانی که بر پایه معماری ماژولار (Modular Architecture) پیاده سازی می‌شوند، ماژول های فیچر (نظیر :feature:home و :feature:checkout) نباید به یکدیگر وابسته باشند تا زمان کامپایل کاهش یافته و وابستگی حلقوی (Circular Dependency) رخ ندهد. برای حل ناوبری میان این ماژول ها، از الگوی Navigation Api Pattern استفاده می‌کنیم:

معماری ناوبری در پروژه های چندماژولار کامپوز
نمودار ۴: الگوی ناوبری ماژولار و تفکیک گراف ناوبری ماژول های Feature با استفاده از قراردادهای مشترک بدون وابستگی مستقیم.

همان‌طور که در نمودار ۴ به تصویر کشیده شده است، در پروژه هایی چندماژولار ماژول های فیچر (نظیر :feature:home و :feature:checkout) هیچ‌گونه وابستگی مستقیمی به یکدیگر ندارند.

قراردادهای ناوبری در ماژول پایه :core:navigation متمرکز شده و هر فیچر تنها گراف درونی خود را می‌سازد. این جداسازی وابستگی، امکان توسعه موازی تیم ها و بیلد سریع برنامه هایی بزرگ را فراهم می‌سازد.

  1. یک ماژول سطح پایه به نام :core:navigation ایجاد کرده و تمامی قراردادهای @Serializable مقاصد را درون آن قرار می‌دهیم.
  2. ماژول های فیچر به جای ارجاع مستقیم به اکتیویتی یا سایر ماژول ها، یک اکستنشن فانکشن روی NavGraphBuilder درون لایه داخلی خود اکسپوز می‌کنند:
    // درون ماژول :feature:product
    fun NavGraphBuilder.productDetailScreen(
        onNavigateBack: () -> Unit,
        onNavigateToCheckout: (String, Double) -> Unit
    ) {
        composable<AppDestination.ProductDetailScreen> {
            ProductDetailRoute(
                onBackClick = onNavigateBack,
                onCheckoutClick = onNavigateToCheckout
            )
        }
    }
  3. ماژول اصلی اپلیکیشن (:app) که به تمامی فیچرها دسترسی دارد، این اکستنشن ها را درون NavHost مرکزی سرهم بندی می‌نماید:
    NavHost(navController = navController, startDestination = AppDestination.HomeScreen) {
        homeScreen(onProductSelected = { id -> navController.navigate(AppDestination.ProductDetailScreen(id)) })
        productDetailScreen(
            onNavigateBack = { navController.popBackStack() },
            onNavigateToCheckout = { cartId, total -> navController.navigate(AppDestination.CheckoutScreen(cartId, total)) }
        )
        checkoutScreen(onDone = { navController.popBackStack(AppDestination.HomeScreen, false) })
    }
💡 نکته معماری: تفکیک مسیرهای ناوبری

به جای تعریف همه مقاصد درون یک تابع حجیم، برای هر فیچر یک اکستنشن فانکشن روی NavGraphBuilder بنویسید (مانند fun NavGraphBuilder.authGraph(...)) تا اصل تک‌مسئولیتی و تفکیک دغدغه ها به بهترین شکل رعایت شود.

۱۱. تست های خودکار ناوبری با TestNavHostController

تست نویسی برای جریان های ناوبری به توسعه دهندگان اطمینان می دهد که با تغییر منطق یا ویوها، جریان حرکتی کاربر بدون شکست باقی می ماند. با استفاده از TestNavHostController در تست های رابط کاربری کامپوز (Compose UI Tests):

import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.navigation.testing.TestNavHostController
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test

class AppNavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    lateinit var navController: TestNavHostController

    @Test
    fun appNavHost_clickProduct_navigatesToDetailScreen() {
        composeTestRule.setContent {
            navController = TestNavHostController(ApplicationProvider.getApplicationContext())
            AppNavigationGraph(navController = navController)
        }

        // کلیک روی دکمه مشاهده محصول
        composeTestRule.onNodeWithText("مشاهده جزئیات گوشی هوشمند").performClick()

        // ارزیابی برابری مقصد جاری با صفحه مورد انتظار
        val currentDestination = navController.currentBackStackEntry?.destination?.route
        assertEquals(AppDestination.ProductDetailScreen::class.qualifiedName, currentDestination)
    }
}

۱۲. جدول مقایسه جامع رویکردهای ناوبری در تاریخچه اندروید

برای درک عمیق تمایزهای معماری، جدول زیر الگو های مختلف پیاده سازی ناوبری در پلتفرم اندروید را مقایسه می کند:

معیار ارزیابی فنی ناوبری سنتی Fragment Transaction کامپوز اولیه (String Routes ۲.۴-۲.۷) کامپوز مدرن (Type-Safe Navigation ۲.۸+)
امنیت در زمان کامپایل (Type Safety) بسیار ضعیف (وابسته به Bundle و Key رشته ای) ضعیف (خطای تایپی و ابهام در پارامترها) ۱۰۰٪ تضمین شده با کلاس های سریالایزیشن
کدهای زاید (Boilerplate) بسیار بالا با FragmentManager و تراکنش ها متوسط حداقلی با Data Object و Data Class
یکپارچگی با ViewModel و Hilt دشوار و نیازمند فکتوری های پیچیده مناسب از طریق NavBackStackEntry بومی و مستقیم با savedStateHandle.toRoute()
انیمیشن های ترنزیشن پیچیده با فایل های انیمیشن XML نیازمند کتابخانه مجزای Accompanist داخلی با توابع اعلامی انیمیشن کامپوز
پشتیبانی از پروژه های چندماژولار بسیار سخت و با خطر وابستگی حلقوی نیازمند قراردادهای رشته ای شکننده فوق العاده تمیز با ماژول مشترک core-navigation

۱۳. الگوی معماری Navigation Event با Channel و SharedFlow در MVI/MVVM

یکی از اشتباهات رایج در هدایت صفحات در کامپوز، پاس دادن مستقیم نمونه NavController به درون توابع کامپوزبل فرزند یا لایه های ViewModel است. این کار معماری نرم افزار را به شدت شکننده می کند، تست پذیری ویومدل را غیرممکن می سازد و کامپوننت های UI را به فریمورک ناوبری مقید می کند. رویکرد استاندارد بر اساس الگوی اعلامی جریان رویدادها (Navigation Event Flow) پیاده سازی می شود:

sealed interface NavigationEvent {
    data class NavigateTo(val destination: Any) : NavigationEvent
    data object NavigateBack : NavigationEvent
    data class PopUpTo(val destination: Any, val inclusive: Boolean) : NavigationEvent
    data class ShowSnackbar(val message: String) : NavigationEvent
}

@HiltViewModel
class CheckoutViewModel @Inject constructor(private val paymentGateway: PaymentGateway) : ViewModel() {
    private val _navigationChannel = Channel<NavigationEvent>(capacity = Channel.BUFFERED)
    val navigationEvents = _navigationChannel.receiveAsFlow()

    fun onPayButtonClicked(orderId: String) {
        viewModelScope.launch {
            val paymentResult = paymentGateway.processPayment(orderId)
            if (paymentResult.isSuccess) {
                _navigationChannel.send(NavigationEvent.NavigateTo(AppDestination.PaymentSuccessScreen(transactionId = paymentResult.txId)))
            } else {
                _navigationChannel.send(NavigationEvent.ShowSnackbar("پرداخت با خطا مواجه شد: ${paymentResult.error}"))
            }
        }
    }
}

در لایه نمایش کامپوز، با بهره گیری از LaunchedEffect مقید به لایف سایکل، به این رویدادها گوش می دهیم:

@Composable
fun CheckoutScreenRoute(
    navController: NavHostController,
    viewModel: CheckoutViewModel = hiltViewModel()
) {
    val context = LocalContext.current
    val snackbarHostState = remember { SnackbarHostState() }

    LaunchedEffect(key1 = Unit) {
        viewModel.navigationEvents.collect { event ->
            when (event) {
                is NavigationEvent.NavigateTo -> {
                    navController.navigate(event.destination)
                }
                is NavigationEvent.NavigateBack -> {
                    navController.popBackStack()
                }
                is NavigationEvent.PopUpTo -> {
                    navController.popBackStack(event.destination, event.inclusive)
                }
                is NavigationEvent.ShowSnackbar -> {
                    snackbarHostState.showSnackbar(event.message)
                }
            }
        }
    }

    CheckoutScreenContent(
        snackbarHostState = snackbarHostState,
        onPayClick = viewModel::onPayButtonClicked
    )
}

۱۴. ناوبری در Bottom Sheet و دیالوگ ها با dialog<T>

در نسخه های مدرن Navigation Compose، علاوه بر صفحات تمام صفحه (Full Screen Destinations)، شما می توانید مقاصد را به صورت دیالوگ های پاپ آپ (Dialog) یا برگه های پایینی (Modal Bottom Sheets) تعریف کنید، بدون آنکه ساختار استک ناوبری دچار شکستگی شود:

import androidx.navigation.compose.dialog

@Serializable
data class ConfirmationDialogDestination(
    val title: String,
    val message: String
) : AppDestination

// در داخل NavHost
dialog<ConfirmationDialogDestination> { backStackEntry ->
    val args: ConfirmationDialogDestination = backStackEntry.toRoute()
    
    AlertDialog(
        onDismissRequest = { navController.popBackStack() },
        title = { Text(args.title) },
        text = { Text(args.message) },
        confirmButton = {
            TextButton(onClick = {
                // ثبت تایید و بازگشت
                navController.popBackStack()
            }) {
                Text("تایید")
            }
        },
        dismissButton = {
            TextButton(onClick = { navController.popBackStack() }) {
                Text("انصراف")
            }
        }
    )
}

استفاده از dialog<T> باعث می شود دیالوگ ها دارای یک ورودی اختصاصی در BackStack باشند؛ بنابراین در صورتی که کاربر دکمه بازگشت فیزیکی یا ژست ناوبری را اجرا کند، دیالوگ به نرمی بسته شده و به صفحه زیرین خود بازمی گردد.

۱۵. استراتژی بازنشانی وضعیت در زمان مرگ پروسس (Process Death & State Restoration)

سیستم عامل اندروید در شرایطی که با کمبود رم مواجه شود، ممکن است پروسس برنامه شما را در زمانی که در پس زمینه قرار دارد متوقف نماید (Process Death). هنگامی که کاربر مجددا به اپ بازمی گردد، سیستم عامل استک صفحات را بازسازی می کند. در سیستم Type-Safe Navigation:

  • تمامی مقادیر تعریف شده در کلاس های @Serializable به صورت خودکار در باندل وضعیت ذخیره شده (Saved State) ذخیره و بازیابی می شوند.
  • اگر از اشیای سفارشی استفاده می کنید، تضمین کنید که کلاس شما حتما Parcelable باشد تا بدون اتلاف داده ها مجددا از حافظه بازخوانی شود.

برای شبیه سازی این وضعیت در محیط توسعه و اطمینان از عدم کرش برنامه، دستور زیر را در ترمینال اجرا کنید:

# فرستادن اپلیکیشن به پس زمینه و سپس کشتن پروسس با ADB
adb shell am kill ir.vahidhoseini.app

۱۶. یکپارچه سازی ناوبری با Bottom Navigation Bar و Scaffold در متریال ۳

در بیشتر اپلیکیشن های مدرن، نوار ناوبری پایینی (NavigationBar) به عنوان شاهراه اصلی دسترسی کاربر به بخش های کلیدی نرم افزار عمل می کند. چالش اصلی، همگام سازی مقصد فعال با آیتم های انتخاب شده در نوار ناوبری و جلوگیری از بارگذاری مجدد صفحات است. با استفاده از currentBackStackEntryAsState():

data class BottomNavItem(val route: Any, val title: String, val icon: ImageVector)
val navItems = listOf(BottomNavItem(AppDestination.HomeScreen, "خانه", Icons.Default.Home), BottomNavItem(AppDestination.CategoriesScreen, "دسته ها", Icons.Default.List), BottomNavItem(AppDestination.ProfileScreen, "پروفایل", Icons.Default.Person))

@Composable
fun MainAppScaffold() {
    val navController = rememberNavController()
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentDest = navBackStackEntry?.destination

    Scaffold(bottomBar = {
        NavigationBar {
            navItems.forEach { item ->
                val isSelected = currentDest?.hasRoute(item.route::class) == true
                NavigationBarItem(
                    selected = isSelected,
                    onClick = {
                        navController.navigate(item.route) {
                            popUpTo(navController.graph.findStartDestination().id) { saveState = true }
                            launchSingleTop = true; restoreState = true
                        }
                    },
                    icon = { Icon(item.icon, contentDescription = item.title) },
                    label = { Text(item.title) }
                )
            }
        }
    }) { padding ->
        AppNavigationGraph(navController = navController, modifier = Modifier.padding(padding))
    }
}

۱۷. الگو های ناوبری تطبیقی برای تبلت ها و صفحات تاشو (Adaptive Navigation)

با گسترش دستگاه های تاشو (Foldable Devices) و تبلت های اندرویدی، طراحی رابط کاربری دیگر به یک مستطیل عمودی ثابت محدود نمی شود. کتابخانه Adaptive Navigation Suite به صورت هوشمند نوع نوار ناوبری را بر اساس ابعاد نمایشگر تغییر می دهد:

  • در گوشی های معمولی: نمایش نوار ناوبری پایینی (Bottom Navigation Bar).
  • در تبلت ها و نمایشگرهای عریض: تبدیل خودکار به نوار ناوبری کناری (Navigation Rail).
  • در دسکتاپ و مانیتورهای بسیار بزرگ: باز شدن دراور ناوبری دائمی (Permanent Navigation Drawer).

این یکپارچگی تطبیقی با حفظ تمامی کلاس های Type-Safe انجام می پذیرد و تجربه ای در کلاس جهانی برای کاربران دستگاه های پرچمدار خلق می نماید.

۱۸. پرسش های متداول ناوبری در جت پک کامپوز (FAQ)

۱. چرا در نسخه های جدید کامپوز از ناوبری Type-Safe استفاده می شود؟

رویکرد تایپ سیف (Type-Safe) خطاهای زمان اجرا ناشی از غلط های املایی در رشته های URL و ارسال آرگومان های اشتباه را در زمان کامپایل شناسایی و به صفر می رساند.

۲. برای ارسال آبجکت های سفارشی پیچیده بین مقاصد کامپوز چه باید کرد؟

طبق راهنمای رسمی گوگل، بهتر است به جای ارسال آبجکت های سنگین با Parcelable، صرفاً شناسه (ID) موجودیت ارسال شده و دیتا از دیتابیس محلی یا ریپازیتوری لود شود.

۳. آیا NavController را باید به کامپوزبل های فرزند پاس داد؟

خیر، بهترین الگو این است که رخدادهای ناوبری به صورت توابع لامبدا (Event Callbacks) به بالا ارسال شده و ناوبری منحصراً در سطح NavHost مدیریت شود.

۴. چگونه از ایجاد نمونه های تکراری در بک استک (Back Stack) جلوگیری کنیم؟

با استفاده از فلگ های launchSingleTop = true و popUpTo با گزینه saveState = true می توان وضعیت صفحات را حفظ کرده و از انباشت صفحات تکراری جلوگیری نمود.

۵. چگونه انیمیشن های ترنزیشن زیبا بین صفحات تعریف کنیم؟

با استفاده از پارامترهای enterTransition، exitTransition، popEnterTransition و popExitTransition در NavHost یا تک تک مقاصد می توان انیمیشن های روان ایجاد کرد.

۶. دیپ لینک (Deep Link) در ناوبری کامپوز چگونه تعریف می شود؟

با استفاده از تابع navDeepLink و تعریف الگو های URI می توان برای هر مقصد ناوبری، دیپ لینک مستقیم برای وب یا پوش نوتیفیکیشن تعریف کرد.

۷. آیا ناوبری کامپوز با معماری چند ماژولی (Multi-Module) سازگار است؟

بله، با تفکیک مسیرها و گراف های ناوبری به توابع اکستنشن در ماژول های فیچر، می توان معماری کاملاً ماژولار و مستقل پیاده سازی کرد.

۸. چگونه وضعیت اسکرول لیست در زمان ناوبری برگشتی حفظ می شود؟

با ترکیب rememberLazyListState و فلگ restoreState = true در ناوبری، موقعیت اسکرول کاربر حتی پس از جابجایی بین صفحات حفظ می گردد.

۹. چگونه از ساخته شدن چندباره یک صفحه در BackStack هنگام کلیک سریع جلوگیری کنیم؟

برای حل این مشکل که تحت عنوان Multiple Navigation شناخته می شود، کافی است هنگام فراخوانی متد navigate از فلگ launchSingleTop = true استفاده نمایید: navController.navigate(destination) { launchSingleTop = true }. این تنظیم تضمین می کند اگر همان صفحه در بالای پشته حضور داشته باشد، مجددا نمونه سازی نگردد.

۱۰. چرا ارسال اشیای حجیم و لیست ها در آرگومان های ناوبری توصیه نمی شود؟

آرگومان های ناوبری درون Bundle سیستم عامل و حافظه هیپ وضعیت ذخیره شده (SavedState) ذخیره می شوند. محدودیت سیستمی تراکنش های بایندر در اندروید حدود ۱ مگابایت است. ارسال مدل های سنگین ممکن است باعث خطای فاجعه بار TransactionTooLargeException شود. همواره باید شناسه (ID) فرستاده شود و داده واقعی از دیتابیس یا کش واکشی گردد.

۱۱. چگونه یک نتیجه بازگشتی (Result) را از صفحه مقصد به صفحه مبدا برگردانیم؟

در کامپوز، با دسترسی به navController.previousBackStackEntry?.savedStateHandle?.set("key", result) می توانید داده نتیجه را ذخیره کنید. در صفحه مبدا، با گوش دادن به این داده از طریق currentBackStackEntry.savedStateHandle.getStateFlow("key", initial) نتیجه به صورت واکنشی دریافت می گردد.

۱۲. آیا برای انتقال به نسخه ۲.۸+ باید تمام گراف ناوبری پروژه را به یکباره بازنویسی کنیم؟

خیر. خوشبختانه Navigation ۲.۸+ سازگاری کامل به عقب (Backward Compatibility) دارد. شما می توانید در یک NavHost به طور همزمان مقاصد قدیمی مبتنی بر رشته (String routes) و مقاصد جدید مبتنی بر شیء نوع دار (Type-safe routes) را در کنار یکدیگر قرار دهید و مهاجرت را صفحه به صفحه انجام دهید.

امتیاز و نظرات

۰.۰ (۰ نظر)
برای ثبت دیدگاه باید وارد حساب کاربری شوید. ورود

هنوز نظری ثبت نشده است. اولین نفر باشید.