ساخت اپلیکیشن اندروئید (چهارم)

بخش هشتم — عملکرد

درس پنجاه‌وچهارم

Memory

Memory در اندروید دقیقاً به چی اشاره می‌کنه؟

وقتی در اندروید از Memory حرف می‌زنیم، معمولاً منظورمون حافظه RAMیه که برنامه هنگام اجرا مصرف می‌کنه؛ یعنی حافظه‌ای که Objectها، تصویرها، صفحه‌های Compose، داده‌های موقت، Cacheها و نتیجه درخواست‌های شبکه داخلش قرار می‌گیرن. این حافظه با Storage فرق داره. Storage همون فضای دائمی گوشیه که فایل‌ها، دیتابیس Room و DataStore داخلش باقی می‌مونن، اما اطلاعات RAM معمولاً فقط تا وقتی فرایند برنامه زنده است وجود دارن.

اندروید برای هر برنامه یک فرایند جدا و مقدار محدودی Heap در نظر می‌گیره. اندازه دقیق این حافظه روی همه دستگاه‌ها یکسان نیست و به RAM، نسخه اندروید و تنظیمات سازنده بستگی داره. اگه قیمت‌بان بیشتر از ظرفیت مجازش حافظه درخواست کنه، ممکنه با OutOfMemoryError متوقف بشه. از طرف دیگر، وقتی حافظه سیستم کم میشه، اندروید می‌تونه فرایند برنامه‌های پس‌زمینه رو از بین ببره تا برای برنامه‌های مهم‌تر حافظه آزاد کنه.

این یعنی نباید تصور کنیم چون Kotlin و Android دارای Garbage Collector هستن، دیگه هیچ مسئولیتی درباره حافظه نداریم. Garbage Collector آبجکت‌هایی رو پاک می‌کنه که برنامه دیگه راهی برای دسترسی بهشون نداره. اما اگه ما ناخواسته Reference یک Activity، Bitmap یا List بزرگ رو نگه داشته باشیم، Garbage Collector تصور می‌کنه هنوز به اون نیاز داریم و نمی‌تونه حافظه‌اش رو آزاد کنه.

Garbage Collector چطور تصمیم می‌گیره چی رو پاک کنه؟

وقتی یک Object می‌سازیم، فضای لازم برای اون داخل Heap برنامه گرفته میشه:

val product = Product( id = 42, title = “Laptop”, price = 58_000_000 )

تا وقتی متغیر یا Object دیگری به product دسترسی داشته باشه، این Object زنده محسوب میشه. وقتی دیگه هیچ مسیر قابل‌دسترسی‌ای به اون وجود نداشته باشه، Garbage Collector می‌تونه حافظه‌اش رو پس بگیره.

مثلاً در این تابع، بعد از پایان اجرا معمولاً Reference محلی از بین میره:

fun printTemporaryProduct() {
    val product =
        Product(
            id = 42,
            title = "Laptop",
            price = 58_000_000
        )

    println(product.title)
}

اما اگه همون Object رو داخل یک Collection سراسری نگه داریم، همچنان قابل‌دسترسی باقی می‌مونه:

object ProductMemoryCache {

    private val products =
        mutableListOf<Product>()

    fun add(product: Product) {
        products.add(product)
    }
}

تا وقتی products پاک نشه، تمام Objectهای داخلش هم در حافظه باقی می‌مونن. Garbage Collector نمی‌تونه تشخیص بده که ما از نظر منطقی دیگه به اون‌ها نیاز نداریم؛ فقط می‌بینه هنوز یک Reference معتبر بهشون وجود داره.

Android Runtime از Heap نسل‌بندی‌شده استفاده می‌کنه. Objectهای تازه معمولاً در نسل جوان قرار می‌گیرن و Objectهایی که مدت بیشتری زنده می‌مونن به نسل‌های قدیمی‌تر منتقل میشن. ساخت تعداد زیادی Object کوتاه‌عمر باعث اجرای بیشتر Garbage Collection میشه و اگه این اتفاق وسط Animation یا اسکرول رخ بده، می‌تونه باعث افت فریم و کندی رابط کاربری بشه.

Memory Leak یعنی حافظه گم شده؟

اسم Memory Leak ممکنه این تصور رو ایجاد کنه که حافظه واقعاً از سیستم ناپدید شده، اما در برنامه‌های Kotlin و Java معمولاً مشکل اینه که یک Reference بیشتر از زمان لازم زنده مونده. مثلاً Activity بسته شده، اما یک Singleton هنوز Reference اون Activity رو نگه داشته. در نتیجه Activity همراه تمام Viewها، تصویرها و Stateهای خودش قابل پاک شدن نیست.

نمونه اشتباه:

object AnalyticsManager {

    private var context: Context? = null

    fun initialize(context: Context) {
        this.context = context
    }
}

اگه این متد رو از Activity صدا بزنیم:

AnalyticsManager.initialize(this)

Singleton تا پایان عمر فرایند زنده می‌مونه و Activity رو هم همراه خودش نگه می‌داره. حتی اگه کاربر Activity رو ببنده یا گوشی بچرخه، Activity قبلی ممکنه در حافظه باقی بمونه.

راه بهتر اینه که فقط ApplicationContext رو نگه داریم:

object AnalyticsManager {

    private lateinit var applicationContext: Context

    fun initialize(context: Context) {
        applicationContext =
            context.applicationContext
    }
}

ApplicationContext تقریباً به اندازه فرایند برنامه عمر می‌کنه، بنابراین نگهداری اون داخل Singleton معمولاً باعث Leak شدن Activity نمیشه. اصل کلی اینه: Object کوتاه‌عمر نباید توسط Object بلندعمر نگه داشته بشه.

Activity و Fragment عمر محدودی دارن، اما Singleton، Repository سراسری، Coroutine بدون Scope مناسب و Callback ثبت‌شده ممکنه مدت خیلی بیشتری زنده بمونن.

یک Memory Leak واقعی در قیمت‌بان

فرض کن برای دریافت تغییر قیمت‌ها یک Listener داریم:

interface PriceUpdateListener {

    fun onPriceUpdated(
        productId: Long,
        newPrice: Long
    )
}

Repository هم Listenerها رو نگه می‌داره:

class PriceRepository {

    private val listeners =
        mutableSetOf<PriceUpdateListener>()

    fun addListener(
        listener: PriceUpdateListener
    ) {
        listeners.add(listener)
    }

    fun removeListener(
        listener: PriceUpdateListener
    ) {
        listeners.remove(listener)
    }

    fun notifyPriceChanged(
        productId: Long,
        newPrice: Long
    ) {
        listeners.forEach { listener ->
            listener.onPriceUpdated(
                productId,
                newPrice
            )
        }
    }
}

حالا Activity خودش رو به عنوان Listener ثبت می‌کنه:

class ProductActivity :
    AppCompatActivity(),
    PriceUpdateListener {

    override fun onCreate(
        savedInstanceState: Bundle?
    ) {
        super.onCreate(savedInstanceState)

        priceRepository.addListener(this)
    }

    override fun onPriceUpdated(
        productId: Long,
        newPrice: Long
    ) {
        // بروزرسانی صفحه
    }
}

مشکل اینه که اگه Activity از بین بره ولی Listener حذف نشه، Repository همچنان Reference اون رو نگه می‌داره. پس باید در زمان مناسب ثبت رو لغو کنیم:

override fun onDestroy() {
    priceRepository.removeListener(this)

    super.onDestroy()
}

این روش کار می‌کنه، اما به حافظه و دقت برنامه‌نویس وابسته است. فراموش کردن فقط یک removeListener() می‌تونه Leak ایجاد کنه. به همین دلیل در معماری‌های جدید معمولاً Flow و Lifecycle رو جایگزین Listenerهای دستی می‌کنیم.

Flow رو براساس Lifecycle جمع‌آوری می‌کنیم

فرض کن Repository تغییر قیمت‌ها رو با StateFlow منتشر می‌کنه:

class PriceRepository {

    private val _priceUpdates =
        MutableStateFlow<Map<Long, Long>>(
            emptyMap()
        )

    val priceUpdates:
        StateFlow<Map<Long, Long>> =
        _priceUpdates.asStateFlow()

    fun updatePrice(
        productId: Long,
        newPrice: Long
    ) {
        _priceUpdates.update { current ->
            current + (
                productId to newPrice
            )
        }
    }
}

داخل Activity نباید یک Coroutine بدون توجه به Lifecycle بسازیم:

lifecycleScope.launch {
    priceRepository.priceUpdates.collect {
        // ممکنه حتی وقتی صفحه قابل‌مشاهده نیست ادامه پیدا کنه
    }
}

روش بهتر استفاده از repeatOnLifecycle است:

override fun onCreate(
    savedInstanceState: Bundle?
) {
    super.onCreate(savedInstanceState)

    lifecycleScope.launch {
        repeatOnLifecycle(
            Lifecycle.State.STARTED
        ) {
            priceRepository
                .priceUpdates
                .collect { prices ->
                    renderPrices(prices)
                }
        }
    }
}

وقتی Activity به وضعیت STARTED میرسه، Collection شروع میشه. وقتی صفحه متوقف میشه، Coroutine داخلی لغو میشه و وقتی صفحه دوباره شروع شد، Collection تازه‌ای ساخته میشه. به این ترتیب نه لازم است Listener رو دستی حذف کنیم و نه پردازش بی‌دلیل در پس‌زمینه ادامه پیدا می‌کنه.

در Compose هم بهتره از API سازگار با Lifecycle استفاده کنیم:

@Composable
fun ProductRoute(
    viewModel: ProductViewModel
) {
    val uiState by
        viewModel.uiState
            .collectAsStateWithLifecycle()

    ProductScreen(
        uiState = uiState
    )
}

این روش بهتر از collectAsState خامه، چون Collection رو با Lifecycle رابط کاربری هماهنگ می‌کنه.

Coroutine هم می‌تونه باعث نگهداری حافظه بشه

Coroutine ذاتاً Memory Leak نیست، اما اگه داخل Scope اشتباه اجرا بشه، ممکنه Objectها رو بیش از حد لازم زنده نگه داره. نمونه خطرناک:

class ProductActivity :
    AppCompatActivity() {

    override fun onCreate(
        savedInstanceState: Bundle?
    ) {
        super.onCreate(savedInstanceState)

        GlobalScope.launch {
            val products =
                repository.loadProducts()

            runOnUiThread {
                showProducts(products)
            }
        }
    }
}

GlobalScope به Lifecycle Activity وابسته نیست. اگه کاربر صفحه رو ببنده، Coroutine همچنان ادامه پیدا می‌کنه و چون داخل Lambda به Activity دسترسی داریم، Activity ممکنه تا پایان عملیات زنده بمونه. علاوه بر اون، ممکنه بعد از بسته شدن صفحه تلاش کنیم UI رو تغییر بدیم.

نسخه درست:

lifecycleScope.launch {
    val products =
        repository.loadProducts()

    showProducts(products)
}

Coroutine مربوط به lifecycleScope وقتی Lifecycle نابود بشه لغو میشه. در ViewModel هم از viewModelScope استفاده می‌کنیم:

class ProductViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    private val _uiState =
        MutableStateFlow(
            ProductUiState()
        )

    val uiState =
        _uiState.asStateFlow()

    fun loadProducts() {
        viewModelScope.launch {
            _uiState.update {
                it.copy(
                    isLoading = true
                )
            }

            val result =
                repository.loadProducts()

            _uiState.update {
                it.copy(
                    isLoading = false,
                    products =
                        result.getOrDefault(
                            emptyList()
                        )
                )
            }
        }
    }
}

وقتی ViewModel برای همیشه پاک بشه، Coroutineهای viewModelScope هم لغو میشن.

ViewModel نباید Activity رو نگه داره

ViewModel ممکنه هنگام چرخش صفحه زنده بمونه، در حالی که Activity قبلی نابود شده و Activity جدید ساخته میشه. بنابراین نباید Context مربوط به Activity، View، NavController یا Binding رو داخل ViewModel نگه داریم.

نمونه اشتباه:

class ProductViewModel(
    private val activity: Activity
) : ViewModel() {

    fun showMessage() {
        Toast.makeText(
            activity,
            "قیمت بروزرسانی شد",
            Toast.LENGTH_SHORT
        ).show()
    }
}

اگه Activity بچرخه، ViewModel همچنان Activity قبلی رو نگه می‌داره و باعث Leak میشه. ViewModel بهتره فقط State و Event تولید کنه:

data class ProductUiState(
    val products: List<Product> =
        emptyList(),
    val message: String? = null
)
class ProductViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    private val _uiState =
        MutableStateFlow(
            ProductUiState()
        )

    val uiState =
        _uiState.asStateFlow()

    fun priceUpdated() {
        _uiState.update {
            it.copy(
                message =
                    "قیمت بروزرسانی شد"
            )
        }
    }

    fun messageShown() {
        _uiState.update {
            it.copy(
                message = null
            )
        }
    }
}

خود UI پیام رو نمایش میده و بعد به ViewModel خبر میده که Event مصرف شده. در مواردی که واقعاً Context لازم داریم، بهتره وابستگی رو تا حد امکان به کلاس‌های بیرون ViewModel منتقل کنیم. اگه Context برنامه لازم شد، میشه ApplicationContext رو با Dependency Injection وارد کرد؛ نه Activity رو.

چرا تصویرها معمولاً بیشترین Memory رو مصرف می‌کنن؟

Bitmapها یکی از رایج‌ترین دلایل مصرف بالای RAM هستن. اندازه فایل تصویر روی دیسک با مقدار حافظه‌ای که بعد از Decode مصرف می‌کنه یکسان نیست. مثلاً یک فایل JPEG ممکنه فقط ۵۰۰ کیلوبایت باشه، اما وقتی به Bitmap با ابعاد ۴۰۰۰ در ۳۰۰۰ تبدیل میشه، چند ده مگابایت RAM مصرف کنه.

تقریب ساده مصرف Bitmap معمولاً این شکلیه:
عرض × ارتفاع × تعداد بایت هر Pixel

برای Bitmap نوع ARGB_8888 هر Pixel تقریباً چهار بایت مصرف می‌کنه. پس تصویر ۴۰۰۰ در ۳۰۰۰ تقریباً چنین حافظه‌ای می‌خواد:

4000 × 3000 × 4 = 48,000,000 bytes

یعنی حدود ۴۸ مگابایت فقط برای یک تصویر. اگه چند تصویر بزرگ هم‌زمان داخل صفحه باشن، رسیدن به OutOfMemoryError خیلی دور از ذهن نیست. نباید تصویر اصلی رو با ابعاد کامل Decode کنیم و بعد داخل یک Image کوچک ۱۲۰ پیکسلی نمایش بدیم. کتابخانه‌هایی مثل Coil تصویر رو براساس اندازه مقصد Decode و Cache می‌کنن.

در Compose:

@Composable
fun ProductImage(
    imageUrl: String,
    contentDescription: String
) {
    AsyncImage(
        model =
            ImageRequest.Builder(
                LocalContext.current
            )
                .data(imageUrl)
                .crossfade(true)
                .size(
                    width = 300,
                    height = 300
                )
                .build(),
        contentDescription =
            contentDescription,
        modifier =
            Modifier.size(120.dp),
        contentScale =
            ContentScale.Crop
    )
}

در خیلی از حالت‌ها خود Coil اندازه مقصد رو تشخیص میده، اما تعیین اندازه مشخص کمک می‌کنه تصویر غول‌پیکر بی‌دلیل Decode نشه. همچنین نباید Bitmap رو داخل ViewModel، rememberSaveable یا Singleton ذخیره کنیم. بهتره URL یا شناسه تصویر رو نگه داریم و اجازه بدیم کتابخانه تصویر Cache رو مدیریت کنه.

Cache باید محدود باشه

Cache باعث میشه داده‌ها سریع‌تر نمایش داده بشن، اما Cache نامحدود یعنی Memory Leak طراحی‌شده. نمونه بد:

object ProductCache {

    val products =
        mutableMapOf<Long, ProductDetails>()
}

اگه کاربر هزاران محصول رو باز کنه، این Map دائماً بزرگ‌تر میشه و هیچ چیزی از اون حذف نمیشه. برای Cache حافظه بهتره یک محدودیت مشخص داشته باشیم. LruCache وقتی ظرفیت پر میشه، قدیمی‌ترین موارد کم‌استفاده رو حذف می‌کنه:

class ProductMemoryCache {

    private val cache =
        object : LruCache<Long, ProductDetails>(
            100
        ) {}

    fun put(
        product: ProductDetails
    ) {
        cache.put(
            product.id,
            product
        )
    }

    fun get(
        productId: Long
    ): ProductDetails? {
        return cache.get(productId)
    }

    fun remove(
        productId: Long
    ) {
        cache.remove(productId)
    }

    fun clear() {
        cache.evictAll()
    }
}

اینجا حداکثر ۱۰۰ محصول نگه داشته میشه. البته بهتره ظرفیت براساس هزینه واقعی Objectها انتخاب بشه، نه یک عدد تصادفی. اگه ProductDetails شامل چند Bitmap یا متن بسیار بزرگه، حتی ۱۰۰ مورد هم ممکنه زیاد باشه.

برای داده‌هایی که باید مدت بیشتری باقی بمونن، Cache حافظه همیشه انتخاب مناسبی نیست. داده رو داخل Room ذخیره می‌کنیم و فقط بخش موردنیاز صفحه رو وارد RAM می‌کنیم.

لیست خیلی بزرگ رو یک‌جا وارد حافظه نکن

فرض کن سرور قیمت‌بان صد هزار رکورد قیمت داره. این کار اشتباهه:

@GET("products")
suspend fun getAllProducts():
    List<ProductResponse>

چون پاسخ کامل باید دریافت، Parse و داخل حافظه نگهداری بشه. بعد ممکنه هم نسخه JSON، هم مدل شبکه، هم Entity و هم مدل UI برای مدتی هم‌زمان در RAM وجود داشته باشن. راه بهتر Pagination است:

interface ProductApi {

    @GET("products")
    suspend fun getProducts(
        @Query("page")
        page: Int,
        @Query("limit")
        limit: Int
    ): ProductPageResponse
}

مدل پاسخ:

data class ProductPageResponse(
    val items: List<ProductResponse>,
    val currentPage: Int,
    val hasNextPage: Boolean
)

در پروژه بزرگ‌تر می‌تونیم از Paging 3 استفاده کنیم تا فقط صفحات موردنیاز کاربر بارگذاری بشن:

class ProductPagingSource(
    private val api: ProductApi
) : PagingSource<Int, Product>() {

    override suspend fun load(
        params: LoadParams<Int>
    ): LoadResult<Int, Product> {
        val page =
            params.key ?: 1

        return try {
            val response =
                api.getProducts(
                    page = page,
                    limit = params.loadSize
                )

            LoadResult.Page(
                data =
                    response.items.map {
                        it.toDomain()
                    },
                prevKey =
                    if (page == 1) {
                        null
                    } else {
                        page - 1
                    },
                nextKey =
                    if (
                        response.hasNextPage
                    ) {
                        page + 1
                    } else {
                        null
                    }
            )
        } catch (
            exception: Exception
        ) {
            LoadResult.Error(exception)
        }
    }

    override fun getRefreshKey(
        state:
            PagingState<Int, Product>
    ): Int? {
        val anchorPosition =
            state.anchorPosition
                ?: return null

        val anchorPage =
            state.closestPageToPosition(
                anchorPosition
            )

        return anchorPage?.prevKey
            ?.plus(1)
            ?: anchorPage?.nextKey
                ?.minus(1)
    }
}

Repository:

class ProductRepository(
    private val api: ProductApi
) {

    fun getProducts():
        Flow<PagingData<Product>> {
        return Pager(
            config =
                PagingConfig(
                    pageSize = 20,
                    prefetchDistance = 5,
                    enablePlaceholders =
                        false
                ),
            pagingSourceFactory = {
                ProductPagingSource(api)
            }
        ).flow
    }
}

ViewModel:

class ProductListViewModel(
    repository: ProductRepository
) : ViewModel() {

    val products =
        repository
            .getProducts()
            .cachedIn(viewModelScope)
}

Compose:

@Composable
fun ProductListRoute(
    viewModel: ProductListViewModel
) {
    val products =
        viewModel.products
            .collectAsLazyPagingItems()

    LazyColumn {
        items(
            count = products.itemCount,
            key = { index ->
                products[index]?.id
                    ?: index
            }
        ) { index ->
            val product =
                products[index]

            if (product != null) {
                ProductItem(product)
            }
        }
    }
}

Paging هم مصرف شبکه رو کم می‌کنه و هم اجازه نمیده کل دیتاست یک‌جا وارد حافظه بشه.

State رابط کاربری رو بیش از حد بزرگ نکن

گاهی خود ViewModel تبدیل به انبار اطلاعات میشه:

data class ProductUiState(
    val allProducts:
        List<Product>,
    val allPriceHistory:
        Map<Long, List<PricePoint>>,
    val allImages:
        Map<Long, Bitmap>,
    val allSearchResults:
        List<Product>
)

هر بار که State جدید ساخته میشه، ممکنه Collectionهای بزرگی کپی یا برای مدت طولانی نگهداری بشن. بهتره State فقط اطلاعات لازم برای صفحه فعلی رو داشته باشه.

data class ProductDetailsUiState(
    val isLoading: Boolean = false,
    val product:
        ProductDetails? = null,
    val recentPrices:
        List<PricePoint> =
        emptyList(),
    val errorMessage:
        String? = null
)

اگه تاریخچه قیمت هزاران نقطه داره، فقط بازه قابل‌نمایش رو می‌گیریم:

interface PriceHistoryDao {

    @Query(
        """
        SELECT *
        FROM price_history
        WHERE product_id = :productId
        ORDER BY created_at DESC
        LIMIT :limit
        """
    )
    suspend fun getRecentPrices(
        productId: Long,
        limit: Int
    ): List<PriceHistoryEntity>
}

remember و rememberSaveable رو درست استفاده کن

در Compose، remember مقدار رو تا وقتی Composable در Composition باقی مونده نگه می‌داره:

val expanded =
    remember {
        mutableStateOf(false)
    }

rememberSaveable علاوه بر Recomposition، تلاش می‌کنه مقدار رو هنگام بازسازی Activity هم نگه داره:

var searchQuery by
    rememberSaveable {
        mutableStateOf("")
    }

اما نباید Objectهای بزرگ رو داخل rememberSaveable قرار بدیم، چون داده باید وارد Bundle یا Saved State بشه و علاوه بر مصرف حافظه ممکنه با محدودیت اندازه State روبه‌رو بشیم.

نمونه بد:

val products =
    rememberSaveable {
        mutableStateOf(
            hugeProductList
        )
    }

بهتره فقط شناسه یا ورودی کوچک رو ذخیره کنیم:

var selectedProductId by
    rememberSaveable {
        mutableLongStateOf(-1L)
    }

خود اطلاعات محصول از Repository یا ViewModel دوباره دریافت میشه. همچنین نباید Activity یا View رو داخل remember نگه داریم:

val activity =
    remember {
        context as Activity
    }

معمولاً همون Context فعلی رو مستقیم از LocalContext.current می‌گیریم و فقط در لحظه نیاز استفاده می‌کنیم.

Callbackهای Compose می‌تونن مقدار قدیمی رو نگه دارن

فرض کن یک عملیات زمان‌دار داریم:

@Composable
fun PriceMessage(
    onTimeout: () -> Unit
) {
    LaunchedEffect(Unit) {
        delay(3000)
        onTimeout()
    }
}

اگه onTimeout در Recomposition تغییر کنه، Coroutine قبلی ممکنه هنوز Callback قدیمی رو نگه داشته باشه. برای نگه داشتن آخرین نسخه Callback از rememberUpdatedState استفاده می‌کنیم:

@Composable
fun PriceMessage(
    onTimeout: () -> Unit
) {
    val currentOnTimeout by
        rememberUpdatedState(
            newValue = onTimeout
        )

    LaunchedEffect(Unit) {
        delay(3000)
        currentOnTimeout()
    }
}

این مورد همیشه یک Memory Leak بزرگ ایجاد نمی‌کنه، اما جلوی نگهداری ناخواسته State یا Closure قدیمی رو می‌گیره و رفتار Composable رو قابل‌پیش‌بینی‌تر می‌کنه.

Resourceهایی که Garbage Collector نمی‌بنده

Garbage Collector حافظه Objectها رو مدیریت می‌کنه، اما Resourceهایی مثل فایل، Cursor، Stream، Socket و بعضی Objectهای Native باید صریحاً بسته بشن. بهتره از use استفاده کنیم تا حتی در صورت Exception هم Resource آزاد بشه.

خواندن فایل:

fun readTextFile(
    file: File
): String {
    return file
        .bufferedReader()
        .use { reader ->
            reader.readText()
        }
}

خواندن Cursor:

fun readProductNames(
    cursor: Cursor
): List<String> {
    return cursor.use {
        val result =
            mutableListOf<String>()

        val nameIndex =
            it.getColumnIndexOrThrow(
                "name"
            )

        while (it.moveToNext()) {
            result.add(
                it.getString(nameIndex)
            )
        }

        result
    }
}

اگه Stream یا Cursor بسته نشن، فقط RAM مصرف نمیشه؛ ممکنه File Descriptorهای برنامه هم تمام بشن و برنامه با خطاهای غیرمنتظره روبه‌رو بشه.

برنامه هنگام کمبود حافظه چه کار می‌کنه؟

وقتی حافظه سیستم کم میشه، Android می‌تونه فرایندهای Cacheشده رو از بین ببره. بنابراین نباید فرض کنیم Singletonها، Cache حافظه یا متغیرهای سراسری برای همیشه زنده می‌مونن. State مهم باید در محل پایدارتری مثل Room، DataStore یا Saved State ذخیره بشه.

برای آزاد کردن Cacheهای غیرضروری می‌تونیم به onTrimMemory واکنش نشون بدیم. Android این Callback رو زمانی صدا می‌زنه که فرصت مناسبی برای کاهش مصرف حافظه وجود داشته باشه.

class GheimatBanApplication :
    Application(),
    ComponentCallbacks2 {

    lateinit var productMemoryCache:
        ProductMemoryCache

    override fun onCreate() {
        super.onCreate()

        productMemoryCache =
            ProductMemoryCache()
    }

    override fun onTrimMemory(
        level: Int
    ) {
        super.onTrimMemory(level)

        when {
            level >=
                ComponentCallbacks2
                    .TRIM_MEMORY_RUNNING_CRITICAL -> {
                productMemoryCache.clear()
                imageCache.clearMemory()
            }

            level >=
                ComponentCallbacks2
                    .TRIM_MEMORY_RUNNING_LOW -> {
                productMemoryCache.trimToHalf()
            }

            level >=
                ComponentCallbacks2
                    .TRIM_MEMORY_UI_HIDDEN -> {
                temporaryScreenCache.clear()
            }
        }
    }
}

Cache رو کمی کامل‌تر می‌کنیم:

class ProductMemoryCache {

    private val maximumSize = 100

    private val cache =
        LruCache<Long, ProductDetails>(
            maximumSize
        )

    fun put(
        product: ProductDetails
    ) {
        cache.put(
            product.id,
            product
        )
    }

    fun get(
        productId: Long
    ): ProductDetails? {
        return cache.get(productId)
    }

    fun trimToHalf() {
        cache.trimToSize(
            maximumSize / 2
        )
    }

    fun clear() {
        cache.evictAll()
    }
}

نباید در onTrimMemory اطلاعات مهم کاربر رو پاک کنیم یا عملیات طولانی انجام بدیم. اینجا فقط Resourceهایی رو آزاد می‌کنیم که دوباره قابل‌ساختن یا دریافت هستن.

OutOfMemoryError رو با Try/Catch حل نکن

ممکنه وسوسه بشیم چنین کاری انجام بدیم:

try {
    val bitmap =
        loadHugeBitmap()
} catch (
    error: OutOfMemoryError
) {
    System.gc()
}

این راه‌حل قابل‌اعتماد نیست. وقتی برنامه به OutOfMemoryError رسیده، وضعیت حافظه ممکنه به‌شدت ناپایدار باشه و حتی ساخت Object لازم برای نمایش پیام خطا هم شکست بخوره. فراخوانی System.gc هم تضمین نمی‌کنه حافظه کافی آزاد بشه، چون مشکل ممکنه Objectهای همچنان قابل‌دسترسی یا Allocation بسیار بزرگ باشه.

راه درست پیشگیریه: تصویر رو در اندازه مناسب Decode کنیم، لیست‌ها رو صفحه‌بندی کنیم، Cache رو محدود کنیم، Referenceهای اضافی رو آزاد کنیم و Memory Leakها رو با ابزار پیدا کنیم.

Memory Profiler رو چطور استفاده کنیم؟

Android Studio ابزار Memory Profiler داره که مصرف حافظه برنامه رو در طول زمان نمایش میده. می‌تونیم ببینیم حافظه Java/Kotlin، Native، Graphics و Stack چطور تغییر می‌کنه، Garbage Collection چه زمانی رخ میده و چه تعداد Object ساخته شده. همچنین میشه Heap Dump گرفت و Objectهایی رو پیدا کرد که نباید هنوز زنده باشن.

برای بررسی Leak یک سناریوی مشخص اجرا می‌کنیم:

  1. صفحه جزئیات محصول رو باز می‌کنیم.
  2. از صفحه خارج میشیم.
  3. این کار رو چند بار تکرار می‌کنیم.
  4. Garbage Collection رو از Profiler اجرا می‌کنیم.
  5. Heap Dump می‌گیریم.
  6. دنبال چند نمونه از ProductActivity یا صفحه قبلی می‌گردیم.

اگه بعد از خروج همچنان چند Activity قدیمی زنده باشن، Retaining Path رو بررسی می‌کنیم تا بفهمیم چه Objectی Reference اون‌ها رو نگه داشته. ممکنه Singleton، Listener، Handler، Coroutine یا Callback یک SDK عامل Leak باشه.

Android Studio همچنین امکان بررسی Allocationها رو میده؛ یعنی می‌تونیم بفهمیم یک کلاس از کدوم خط ساخته شده و چرا تعداد زیادی نمونه از اون وجود داره.

LeakCanary رو به نسخه Debug اضافه می‌کنیم

LeakCanary کتابخانه‌ایه که بعد از نابود شدن Activity، Fragment یا View بررسی می‌کنه آیا Object همچنان در حافظه باقی مونده یا نه. این کتابخانه رو فقط به نسخه Debug اضافه می‌کنیم:

dependencies {
    debugImplementation(
        "com.squareup.leakcanary:" +
            "leakcanary-android:" +
            "CURRENT_VERSION"
    )
}

بعد از اضافه شدن، معمولاً نیاز به Initialization دستی نداره. برنامه رو اجرا می‌کنیم و صفحه‌ها رو باز و بسته می‌کنیم. اگه Leak شناسایی بشه، LeakCanary زنجیره Referenceها رو نمایش میده.

مثلاً ممکنه چنین مسیری پیدا بشه:

AnalyticsManager ↓ activityContext ↓ ProductActivity ↓ ComposeView ↓ Composition

این مسیر نشون میده AnalyticsManager باعث شده Activity قابل پاک شدن نباشه. خود عدد مصرف حافظه به تنهایی همیشه علت مشکل رو نشون نمیده؛ زنجیره Referenceهاست که به ما میگه کدوم Object، Object قبلی رو زنده نگه داشته.

مستندات Android Studio هم LeakCanary رو به عنوان یکی از ابزارهای مفید تشخیص Memory Leak معرفی می‌کنن.

اطلاعات حساس در Memory چه وضعیتی دارن؟

در درس قبلی داده‌ها رو با Android Keystore رمز کردیم، اما وقتی Token برای ارسال درخواست استفاده میشه، باید برای لحظه‌ای به صورت رمزگشایی‌شده وارد RAM بشه. هیچ راهی وجود نداره که برنامه از یک مقدار استفاده کنه ولی اون مقدار هیچ‌وقت در حافظه قابل‌دسترسی نباشه. روی دستگاه Rootشده یا فرایند Hookشده، مهاجم ممکنه Token رو در همین لحظه استخراج کنه.

پس هدف ما حذف کامل اطلاعات حساس از RAM نیست؛ هدف کاهش زمان و تعداد نسخه‌های اون در حافظه است. نباید Token رو داخل State رابط کاربری، Log، Singleton عمومی، Bundle یا چند مدل مختلف کپی کنیم.

نمونه بد:

data class AuthUiState(
    val email: String = "",
    val accessToken: String = "",
    val refreshToken: String = ""
)

UI هیچ نیازی به Token نداره. State بهتر:

data class AuthUiState(
    val email: String = "",
    val isAuthenticated: Boolean =
        false,
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

Token فقط در لایه احراز هویت استفاده میشه:

class AuthHeaderProvider(
    private val tokenStore:
        SecureTokenStore
) {

    suspend fun createHeader():
        String? {
        val token =
            tokenStore
                .getAccessToken()
                ?: return null

        return "Bearer $token"
    }
}

بعد از اینکه String ساخته شد، JVM زمان دقیق پاک شدن حافظه اون رو در اختیار ما نمی‌ذاره. به همین دلیل نباید امنیت رو بر «پاک کردن کامل RAM» بنا کنیم. Access Token کوتاه‌عمر، Refresh Token قابل‌لغو و اعتبارسنجی سمت سرور همچنان دفاع اصلی هستن.

برای Secretهایی که خودمون تولید می‌کنیم و API اجازه میده، CharArray یا ByteArray از String کنترل‌پذیرتره، چون میشه محتوای آرایه رو بعد از استفاده بازنویسی کرد:

fun processSecret(
    secret: CharArray
) {
    try {
        authenticate(secret)
    } finally {
        secret.fill('\u0000')
    }
}

اما این کار هم تضمین نمی‌کنه هیچ نسخه دیگری توسط Keyboard، کتابخانه یا Runtime ساخته نشده باشه. فقط زمان ماندگاری نسخه‌ای رو که خودمون کنترل می‌کنیم کاهش میده.

یک Memory Policy برای قیمت‌بان

برای اینکه مدیریت حافظه پراکنده و سلیقه‌ای نباشه، بهتره یک سیاست ساده برای پروژه داشته باشیم. داده‌های عمومی قیمت می‌تونن داخل Room باقی بمونن و فقط صفحه فعلی وارد RAM بشه. تصاویر توسط Coil و با اندازه مقصد بارگذاری بشن. لیست‌های بزرگ با Paging دریافت بشن. Cacheهای حافظه محدود و قابل‌پاک شدن باشن. Activity و View داخل Singleton، Repository یا ViewModel نگهداری نشن. Coroutineها به lifecycleScope یا viewModelScope متصل باشن. Listenerها حتماً حذف بشن یا با Flow جایگزین بشن.

اطلاعات حساس مثل Access Token فقط در لایه احراز هویت رمزگشایی بشن و وارد UI State نشن. همچنین برنامه باید آماده باشه که فرایندش هر لحظه در پس‌زمینه متوقف بشه؛ بنابراین Cache حافظه نباید تنها نسخه اطلاعات مهم باشه.

جمع‌بندی

Memory در اندروید فقط یک عدد داخل Profiler نیست. هر Objectی که می‌سازیم، هر تصویر، هر List، هر Coroutine و هر Callback بخشی از RAM برنامه رو مصرف می‌کنه. Garbage Collector Objectهایی رو آزاد می‌کنه که دیگه قابل‌دسترسی نیستن، اما اگه Reference اون‌ها رو داخل Singleton، Listener، ViewModel یا Coroutine بلندعمر نگه داریم، حافظه آزاد نمیشه و Memory Leak شکل می‌گیره.

در این درس دیدیم که Activity Context نباید داخل Singleton نگهداری بشه، Collectionهای Flow باید با Lifecycle هماهنگ بشن، Coroutineها باید Scope مناسبی داشته باشن و ViewModel نباید Activity یا View رو نگه داره. تصاویر باید در ابعاد واقعی رابط کاربری Decode بشن، Cacheها باید محدود باشن و لیست‌های بزرگ باید با Pagination یا Paging 3 دریافت بشن. همچنین با onTrimMemory می‌تونیم Cacheهای غیرضروری رو هنگام فشار حافظه کم کنیم و با Memory Profiler و LeakCanary علت Leakها رو پیدا کنیم.

اصل مهم این درس اینه: Garbage Collector فقط آبجکت‌های بدون Reference رو پاک می‌کنه؛ این ما هستیم که باید تصمیم بگیریم هر داده و هر Object تا چه زمانی واقعاً باید زنده بمونه.


درس پنجاه‌وپنجم

Image Cache

Image Cache چیست؟

فرض کن داخل قیمت‌بان صفحه‌ای داریم که صد کالا را نمایش می‌دهد و کنار هر کالا هم تصویر آن قرار گرفته است. اگر هر بار که کاربر این صفحه را باز کند، برنامه مجبور باشد تمام تصاویر را دوباره از اینترنت دانلود کند، صفحه دیرتر باز می‌شود، مصرف اینترنت بالا می‌رود، اسکرول کردن روان نخواهد بود و حتی فشار بیشتری به سرور وارد می‌شود. به همین دلیل تقریباً تمام برنامه‌های حرفه‌ای دنیا از مفهومی به نام Image Cache استفاده می‌کنند. ایده آن هم بسیار ساده است؛ اگر قبلاً یک تصویر را دانلود کرده‌ایم، دفعه بعد به جای دانلود دوباره، همان نسخه ذخیره‌شده را نمایش می‌دهیم.

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


Image Cache چگونه کار می‌کند؟

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

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

Image Request
      │
      ▼
Memory Cache
      │
      ├── پیدا شد → نمایش تصویر
      │
      ▼
Disk Cache
      │
      ├── پیدا شد → نمایش تصویر
      │
      ▼
Network
      │
      ▼
دانلود تصویر
      │
      ▼
ذخیره در Disk Cache
      │
      ▼
ذخیره در Memory Cache
      │
      ▼
نمایش تصویر

به همین دلیل معمولاً اولین بار که صفحه باز می‌شود کمی زمان می‌برد، اما دفعات بعد تقریباً همه چیز بلافاصله نمایش داده می‌شود.


Memory Cache و Disk Cache چه تفاوتی دارند؟

Memory Cache داخل RAM قرار دارد و سریع‌ترین نوع Cache است. اگر کاربر بین چند صفحه جابه‌جا شود و دوباره به همان تصویر نیاز داشته باشد، معمولاً تصویر مستقیماً از RAM خوانده می‌شود و حتی نیازی به دسترسی به حافظه گوشی هم نیست. البته این Cache دائمی نیست و هر زمان سیستم به حافظه بیشتری نیاز داشته باشد یا برنامه بسته شود، ممکن است پاک شود.

در مقابل، Disk Cache داخل حافظه داخلی گوشی قرار می‌گیرد. سرعت آن از RAM کمتر است، اما همچنان چندین برابر سریع‌تر از دانلود دوباره تصویر از اینترنت است. مزیت مهم Disk Cache این است که حتی اگر برنامه را ببندیم و دوباره اجرا کنیم، تصاویر دانلودشده همچنان در اختیار برنامه خواهند بود.

به همین دلیل این دو Cache مکمل یکدیگر هستند؛ Memory Cache برای سرعت و Disk Cache برای ماندگاری.


چرا خودمان Image Cache نمی‌نویسیم؟

از نظر تئوری می‌توانیم تصاویر را خودمان دانلود کنیم، داخل فایل ذخیره کنیم و هر بار از همان فایل استفاده کنیم. اما وقتی پروژه کمی بزرگ‌تر شود، متوجه می‌شویم این کار بسیار پیچیده‌تر از چیزی است که به نظر می‌رسد.

مثلاً باید تصمیم بگیریم اگر حافظه RAM پر شد، کدام تصاویر حذف شوند. باید تصاویر خراب یا ناقص را مدیریت کنیم، اندازه مناسب برای هر تصویر را انتخاب کنیم، دانلودهای هم‌زمان را کنترل کنیم، از دانلود تکراری یک تصویر جلوگیری کنیم و حواسمان به مصرف حافظه هم باشد تا برنامه با OutOfMemoryError مواجه نشود. به همین دلیل تقریباً هیچ پروژه حرفه‌ای این سیستم را از صفر پیاده‌سازی نمی‌کند و از کتابخانه‌های تخصصی استفاده می‌کند.


چرا Coil بهترین انتخاب برای پروژه‌های Compose است؟

اگر پروژه با Jetpack Compose نوشته شده باشد، یکی از بهترین انتخاب‌ها کتابخانه Coil است. Coil کاملاً برای Kotlin و Compose طراحی شده، سبک است، از Coroutine استفاده می‌کند و تقریباً تمام کارهای مربوط به دانلود و مدیریت تصاویر را به صورت خودکار انجام می‌دهد.

ابتدا کتابخانه را اضافه می‌کنیم.

implementation("io.coil-kt:coil-compose:2.7.0")

حالا نمایش تصویر فقط به چند خط کد نیاز دارد.

AsyncImage(
    model = product.imageUrl,
    contentDescription = product.name
)

شاید این کد خیلی ساده به نظر برسد، اما پشت همین چند خط، Coil کارهای زیادی انجام می‌دهد. اگر تصویر قبلاً داخل Memory Cache باشد، همان را نمایش می‌دهد. اگر داخل Disk Cache باشد، آن را از حافظه می‌خواند. اگر هیچ نسخه‌ای وجود نداشته باشد، تصویر را دانلود می‌کند، Decode می‌کند، اندازه آن را متناسب با View تغییر می‌دهد و در نهایت داخل هر دو Cache ذخیره می‌کند.


نمایش Placeholder هنگام دانلود

یکی از اشتباه‌های رایج این است که تا زمان دانلود تصویر، یک فضای خالی نمایش دهیم. این کار باعث می‌شود صفحه مدام چشمک بزند و حس کندی به کاربر منتقل شود. Coil اجازه می‌دهد هنگام دانلود یک Placeholder نمایش دهیم و اگر دانلود با خطا مواجه شد، تصویر دیگری را جایگزین کنیم.

AsyncImage(
    model = ImageRequest.Builder(LocalContext.current)
        .data(product.imageUrl)
        .crossfade(true)
        .build(),
    placeholder = painterResource(R.drawable.loading),
    error = painterResource(R.drawable.no_image),
    contentDescription = product.name
)

به این ترتیب کاربر همیشه چیزی روی صفحه می‌بیند و تجربه کاربری روان‌تر خواهد بود.


اگر تصویر روی سرور تغییر کرد چه؟

فرض کن کاربر عکس پروفایلش را عوض کرده است، اما URL تصویر همان قبلی است. در این حالت ممکن است برنامه همچنان نسخه Cacheشده را نمایش دهد. رایج‌ترین راه‌حل این است که نسخه فایل را داخل URL قرار دهیم.

https://example.com/avatar.jpg?v=2

یا

https://example.com/avatar.jpg?updated=1723456

از دید Coil این یک آدرس جدید است، بنابراین تصویر دوباره دانلود می‌شود و Cache هم به‌روزرسانی خواهد شد. این روش بسیار ساده‌تر و مطمئن‌تر از پاک کردن دستی Cache است.


چه تصاویری را بهتر است Cache نکنیم؟

همه تصاویر نباید وارد Cache شوند. بعضی تصاویر فقط یک بار استفاده می‌شوند یا اطلاعات حساسی دارند. برای مثال Captcha، QR Codeهای موقت، تصاویر احراز هویت یا فایل‌هایی که فقط چند دقیقه اعتبار دارند، معمولاً گزینه مناسبی برای Cache شدن نیستند. در این موارد بهتر است اجازه دهیم هر بار تصویر جدید از سرور دریافت شود.


جمع‌بندی

Image Cache یکی از مهم‌ترین بخش‌های هر برنامه اندرویدی است، چون مستقیماً روی سرعت، مصرف اینترنت، مصرف باتری و تجربه کاربری تأثیر می‌گذارد. تقریباً تمام برنامه‌های حرفه‌ای ابتدا Memory Cache، سپس Disk Cache و در نهایت Network را بررسی می‌کنند تا از دانلودهای غیرضروری جلوگیری شود. در پروژه‌های مبتنی بر Compose نیز کتابخانه Coil تقریباً تمام این پیچیدگی‌ها را به صورت خودکار مدیریت می‌کند؛ بنابراین به جای اینکه زمان زیادی برای پیاده‌سازی یک سیستم Cache صرف کنیم، بهتر است از ابزارهای استاندارد استفاده کنیم و تمرکزمان را روی توسعه قابلیت‌های اصلی برنامه بگذاریم.


درس پنجاه‌وششم

Lazy Loading

Lazy Loading چیست؟

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

اینجاست که مفهوم Lazy Loading وارد می‌شود. ایده آن بسیار ساده است؛ فقط چیزی را بارگذاری کن که همین حالا به آن نیاز داری و بقیه اطلاعات را زمانی دریافت کن که واقعاً لازم شوند. به همین دلیل تقریباً تمام اپلیکیشن‌های بزرگ دنیا، از اینستاگرام و یوتیوب گرفته تا دیجی‌کالا و آمازون، از Lazy Loading استفاده می‌کنند.


تفاوت Lazy Loading و Eager Loading

فرض کن سرور هزار محصول دارد. در روش Eager Loading برنامه از همان ابتدا هر هزار محصول را دریافت می‌کند؛ حتی اگر کاربر فقط ده محصول اول را ببیند.

اما در Lazy Loading ابتدا فقط اطلاعات موردنیاز، مثلاً بیست محصول اول، دریافت می‌شود. وقتی کاربر به انتهای لیست نزدیک شد، بیست محصول بعدی دانلود می‌شوند و این روند تا زمانی ادامه پیدا می‌کند که کاربر واقعاً به آن اطلاعات نیاز داشته باشد. در نتیجه، برنامه سریع‌تر اجرا می‌شود، اینترنت کمتری مصرف می‌کند و حافظه نیز بی‌دلیل اشغال نمی‌شود.


Lazy Loading در Compose

در Jetpack Compose یکی از ساده‌ترین ابزارها برای پیاده‌سازی Lazy Loading، کامپوننت LazyColumn است.

LazyColumn {
    items(products) { product ->

        ProductItem(product)

    }
}

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


دریافت صفحه بعدی اطلاعات

فرض کن هر درخواست API فقط بیست محصول برمی‌گرداند.

اولین درخواست:

GET /products?page=1

کاربر اسکرول می‌کند و به انتهای لیست نزدیک می‌شود. در این لحظه درخواست بعدی ارسال می‌شود.

GET /products?page=2

بعد از آن:

GET /products?page=3

و همین روند تا پایان لیست ادامه پیدا می‌کند. به این روش Pagination هم گفته می‌شود و معمولاً همراه با Lazy Loading استفاده می‌شود.


تشخیص نزدیک شدن به انتهای لیست

برای اینکه بدانیم چه زمانی باید اطلاعات صفحه بعد را دریافت کنیم، می‌توانیم وضعیت اسکرول را بررسی کنیم.

val listState = rememberLazyListState()

LazyColumn(
    state = listState
) {

    items(products) { product ->

        ProductItem(product)

    }

}

حالا می‌توانیم بررسی کنیم که کاربر به انتهای لیست نزدیک شده است یا نه.

LaunchedEffect(listState) {

    snapshotFlow {

        listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index

    }.collect { lastVisibleIndex ->

        if (lastVisibleIndex != null &&
            lastVisibleIndex >= products.size - 5
        ) {

            viewModel.loadNextPage()

        }

    }

}

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


مراقب درخواست‌های تکراری باشید

یکی از اشتباه‌های رایج این است که هنگام اسکرول، چندین بار متوالی متد loadNextPage فراخوانی شود. در این حالت ممکن است هم‌زمان چند درخواست مشابه به سرور ارسال شوند و هم پهنای باند و هم منابع سرور هدر برود. به همین دلیل معمولاً داخل ViewModel یک متغیر برای وضعیت بارگذاری نگه می‌داریم.

private var isLoading = false

suspend fun loadNextPage() {

    if (isLoading) return

    isLoading = true

    try {

        repository.loadNextPage()

    } finally {

        isLoading = false

    }

}

به این ترتیب تا زمانی که درخواست قبلی تمام نشده باشد، درخواست جدیدی ارسال نخواهد شد.


نمایش Loading در انتهای لیست

اگر دریافت صفحه بعد چند ثانیه طول بکشد، بهتر است به کاربر اطلاع دهیم که برنامه در حال بارگذاری اطلاعات جدید است.

LazyColumn {

    items(products) { product ->

        ProductItem(product)

    }

    if (isLoading) {

        item {

            CircularProgressIndicator()

        }

    }

}

این کار باعث می‌شود کاربر تصور نکند برنامه متوقف شده است و تجربه کاربری بهتری داشته باشد.


Paging 3 یا Lazy Loading؟

اگر لیست نسبتاً کوچک باشد، پیاده‌سازی Lazy Loading به همین شکل کاملاً کافی است. اما وقتی با هزاران یا میلیون‌ها رکورد سروکار داریم، بهتر است از Paging 3 استفاده کنیم. این کتابخانه تقریباً تمام کارهای مربوط به Pagination، مدیریت حافظه، Retry، Refresh و هماهنگی با Compose را انجام می‌دهد و برای پروژه‌های بزرگ گزینه استاندارد اندروید محسوب می‌شود.


جمع‌بندی

Lazy Loading یکی از مهم‌ترین تکنیک‌های بهینه‌سازی عملکرد در اپلیکیشن‌های اندرویدی است. به جای اینکه همه اطلاعات را از همان ابتدا دریافت کنیم، فقط داده‌هایی بارگذاری می‌شوند که کاربر در همان لحظه به آن‌ها نیاز دارد. این کار باعث کاهش مصرف اینترنت، کاهش مصرف حافظه، افزایش سرعت اجرای برنامه و روان‌تر شدن اسکرول می‌شود. در پروژه‌های کوچک می‌توان Lazy Loading را با LazyColumn و چند درخواست صفحه‌بندی پیاده‌سازی کرد، اما برای پروژه‌های بزرگ، استفاده از Paging 3 بهترین و استانداردترین راهکار است.


درس پنجاه‌وهفتم

Performance

چرا Performance اهمیت دارد؟

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

Performance یعنی برنامه بتواند با کمترین مصرف منابع، سریع‌ترین و روان‌ترین تجربه ممکن را در اختیار کاربر قرار دهد. این موضوع فقط به سرعت پردازنده مربوط نمی‌شود؛ مصرف حافظه، تعداد درخواست‌های شبکه، نحوه رسم رابط کاربری و حتی تعداد Recompositionهای Compose هم روی Performance تأثیر می‌گذارند.


همیشه گلوگاه را پیدا کن

یکی از اشتباه‌های رایج این است که قبل از پیدا کردن مشکل، شروع به بهینه‌سازی کنیم. فرض کن صفحه محصولات کند باز می‌شود. شاید اولین حدس این باشد که مشکل از Recycler یا LazyColumn است، در حالی که بعد از بررسی متوجه می‌شوی ۹۰ درصد زمان صرف دانلود تصاویر از اینترنت می‌شود.

به همین دلیل همیشه قبل از هر تغییری، گلوگاه برنامه را پیدا کن. Android Studio ابزارهای بسیار خوبی مثل Profiler در اختیارمان قرار می‌دهد تا ببینیم زمان اجرای برنامه دقیقاً کجا مصرف می‌شود. اگر بدون اندازه‌گیری شروع به بهینه‌سازی کنیم، ممکن است ساعت‌ها روی بخشی کار کنیم که اصلاً مشکلی ندارد.


عملیات سنگین را روی Main Thread اجرا نکن

رابط کاربری اندروید روی Main Thread اجرا می‌شود. اگر این Thread چند ثانیه درگیر یک کار سنگین شود، برنامه دیگر نمی‌تواند به لمس کاربر پاسخ دهد و حتی ممکن است پیام Application Not Responding (ANR) نمایش داده شود.

فرض کن داخل قیمت‌بان هنگام ورود به صفحه محصولات، هزار رکورد را پردازش یا یک فایل بزرگ را از حافظه می‌خوانیم. اگر این کار روی Main Thread انجام شود، رابط کاربری کاملاً متوقف می‌شود.

راه‌حل این است که عملیات زمان‌بر را داخل Coroutine و Dispatcher مناسب اجرا کنیم.

viewModelScope.launch {

    val products = withContext(Dispatchers.IO) {

        repository.loadProducts()

    }

    _uiState.value = products

}

به این ترتیب، عملیات شبکه یا خواندن فایل روی Thread دیگری اجرا می‌شود و رابط کاربری همچنان روان باقی می‌ماند.


از Recompositionهای غیرضروری جلوگیری کن

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

فرض کن فقط تعداد محصولات داخل سبد خرید تغییر کرده است. اگر کل صفحه به همان State وابسته باشد، ممکن است صدها Composable دوباره اجرا شوند، در حالی که فقط آیکون سبد خرید نیاز به به‌روزرسانی داشته است.

به همین دلیل همیشه Stateها را تا حد ممکن کوچک و مستقل نگه دار و از ایجاد Stateهای بزرگ و همه‌کاره خودداری کن.


از LazyColumn به جای Column استفاده کن

اگر تعداد آیتم‌ها زیاد است، استفاده از Column انتخاب مناسبی نیست، چون تمام آیتم‌ها را یک‌جا می‌سازد؛ حتی آن‌هایی که هنوز روی صفحه دیده نمی‌شوند.

LazyColumn {

    items(products) { product ->

        ProductItem(product)

    }

}

LazyColumn فقط آیتم‌هایی را می‌سازد که کاربر در همان لحظه می‌بیند. به همین دلیل هم حافظه کمتری مصرف می‌شود و هم اسکرول بسیار روان‌تر خواهد بود.


درخواست‌های شبکه را کمتر کن

گاهی مشکل Performance اصلاً از کدهای اندروید نیست. فرض کن برای نمایش هر محصول، یک درخواست جداگانه برای تصویر، یک درخواست برای قیمت و یک درخواست دیگر برای موجودی کالا ارسال کنیم. اگر صفحه بیست محصول داشته باشد، ممکن است شصت درخواست HTTP ارسال شود.

بهتر است APIها طوری طراحی شوند که اطلاعات موردنیاز هر صفحه را تا حد ممکن در یک درخواست برگردانند. این کار هم سرعت برنامه را بیشتر می‌کند و هم فشار روی سرور را کاهش می‌دهد.


تصاویر را با اندازه مناسب نمایش بده

یکی از اشتباه‌های رایج این است که یک تصویر ۴۰۰۰ در ۳۰۰۰ پیکسلی را برای یک آیکون کوچک دانلود کنیم. در این حالت هم اینترنت بیشتری مصرف می‌شود و هم Decode کردن تصویر زمان بیشتری می‌برد.

کتابخانه‌هایی مثل Coil می‌توانند تصویر را متناسب با اندازه View دریافت و پردازش کنند. به همین دلیل همیشه بهتر است از این کتابخانه‌ها استفاده کنیم و تصاویر را با اندازه واقعی موردنیاز نمایش دهیم.


از Cache استفاده کن

در درس قبل درباره Image Cache صحبت کردیم. Cache فقط مخصوص تصاویر نیست. هر داده‌ای که مرتب استفاده می‌شود و تغییرات کمی دارد، می‌تواند Cache شود.

مثلاً لیست دسته‌بندی محصولات، تنظیمات برنامه یا اطلاعات پروفایل کاربر معمولاً لازم نیست هر بار از سرور دریافت شوند. اگر این داده‌ها را Cache کنیم، هم سرعت برنامه بیشتر می‌شود و هم تعداد درخواست‌های شبکه کاهش پیدا می‌کند.


ابزارهای اندازه‌گیری را فراموش نکن

اندروید ابزارهای متعددی برای بررسی Performance در اختیار توسعه‌دهنده قرار می‌دهد. CPU Profiler میزان مصرف پردازنده را نشان می‌دهد، Memory Profiler وضعیت حافظه را بررسی می‌کند و Network Inspector تعداد و زمان درخواست‌های شبکه را نمایش می‌دهد.

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


جمع‌بندی

Performance فقط به سریع بودن برنامه خلاصه نمی‌شود؛ بلکه مجموعه‌ای از تصمیم‌های درست در طراحی، مدیریت حافظه، ارتباط با شبکه و پیاده‌سازی رابط کاربری است. اگر عملیات سنگین را از Main Thread خارج کنیم، از Lazy Loading و Cache استفاده کنیم، درخواست‌های غیرضروری شبکه را حذف کنیم و Stateهای Compose را به‌درستی مدیریت کنیم، حتی روی گوشی‌های ضعیف هم می‌توان برنامه‌ای روان و لذت‌بخش ساخت. تفاوت یک اپلیکیشن معمولی با یک اپلیکیشن حرفه‌ای، معمولاً در همین جزئیات دیده می‌شود.


درس پنجاه‌وهشتم

Battery Optimization

چرا Battery Optimization اهمیت دارد؟

یکی از اشتباه‌های رایج بین برنامه‌نویس‌های تازه‌کار این است که فکر می‌کنند اگر برنامه از نظر ظاهری خوب کار کند، دیگر همه چیز تمام است. اما از دید کاربر، اگر یک اپلیکیشن باعث شود باتری گوشی زودتر خالی شود، خیلی سریع حذف خواهد شد؛ حتی اگر امکانات فوق‌العاده‌ای داشته باشد.

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

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


باتری بیشتر از پردازنده مصرف می‌شود یا شبکه؟

بیشتر توسعه‌دهنده‌ها تصور می‌کنند مصرف باتری فقط به پردازنده مربوط است، در حالی که ماجرا پیچیده‌تر از این است. روشن شدن آنتن شبکه، فعال شدن GPS، استفاده مداوم از دوربین، بلوتوث، Wi-Fi و حتی روشن کردن مکرر CPU، همگی روی مصرف باتری تأثیر می‌گذارند.

مثلاً اگر قیمت‌بان هر ده ثانیه یک بار برای بررسی قیمت کالاها به سرور درخواست بفرستد، شاید حجم اینترنت مصرفی ناچیز باشد، اما همین بیدار کردن مداوم رادیوی شبکه باعث مصرف قابل توجه باتری می‌شود.

به همین دلیل، تعداد دفعات انجام یک کار معمولاً از خود آن کار مهم‌تر است.


از Polling مداوم خودداری کن

فرض کن می‌خواهیم قیمت دلار یا موجودی یک کالا را به‌روز نگه داریم. ساده‌ترین راه این است که هر چند ثانیه یک درخواست جدید به سرور ارسال کنیم.

هر 10 ثانیه


ارسال درخواست


دریافت پاسخ


تکرار…

این روش شاید پیاده‌سازی ساده‌ای داشته باشد، اما برای باتری و حتی سرور اصلاً مناسب نیست.

اگر اطلاعات باید به‌صورت لحظه‌ای تغییر کنند، معمولاً WebSocket انتخاب بسیار بهتری است، چون یک اتصال برقرار می‌شود و تا زمانی که لازم باشد همان اتصال حفظ می‌شود. اگر هم اطلاعات فقط هر چند ساعت یک بار تغییر می‌کنند، بهتر است از WorkManager استفاده کنیم تا اندروید خودش بهترین زمان اجرای کار را انتخاب کند.


اجازه بده اندروید زمان‌بندی را مدیریت کند

یکی از دلایل معرفی WorkManager همین موضوع بود. سیستم‌عامل بهتر از ما می‌داند چه زمانی گوشی در حال شارژ است، چه زمانی اینترنت مناسب وجود دارد و چه زمانی اجرای یک کار کمترین تأثیر را روی باتری می‌گذارد.

اگر قرار است قیمت‌بان هر شش ساعت یک بار قیمت کالاها را همگام‌سازی کند، بهتر است این کار را به WorkManager بسپاریم، نه اینکه خودمان یک سرویس دائمی اجرا کنیم. این کار هم مصرف باتری را کمتر می‌کند و هم باعث می‌شود برنامه با سیاست‌های جدید اندروید سازگار باشد.


از Wake Lock فقط در مواقع ضروری استفاده کن

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


از Location بی‌دلیل استفاده نکن

اگر برنامه واقعاً به موقعیت مکانی نیاز ندارد، هر


بخش نهم — انتشار


درس پنجاه‌ونهم

Build Variant

Build Variant چیست؟

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

اینجاست که Build Variant به کمکمون میاد. Build Variant بهت اجازه میده از یک پروژه، چند نسخه مختلف بسازی؛ مثلاً یک نسخه برای توسعه، یک نسخه برای تست و یک نسخه برای انتشار نهایی. هر کدوم از این نسخه‌ها می‌تونن تنظیمات، آدرس سرور، آیکون، نام برنامه یا حتی قابلیت‌های متفاوتی داشته باشن، بدون اینکه مجبور بشی چند پروژه جدا نگه داری.


چرا به Build Variant نیاز داریم؟

فرض کن داخل قیمت‌بان دو سرور داری. یکی سرور اصلی که کاربران واقعی ازش استفاده می‌کنن و یکی هم سرور آزمایشی که برنامه‌نویس‌ها هر روز روش تغییر ایجاد می‌کنن.

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

به همین خاطر تقریباً تمام تیم‌های حرفه‌ای، محیط‌های مختلف رو از هم جدا می‌کنن و Build Variant دقیقاً برای همین کار ساخته شده.


رایج‌ترین Variantها

معمولاً پروژه‌ها حداقل سه Variant دارن.

Development نسخه‌ایه که برنامه‌نویس‌ها هر روز باهاش کار می‌کنن. توی این نسخه معمولاً لاگ‌ها فعاله، ابزارهای دیباگ وجود دارن و برنامه به سرور تست وصل میشه.

Staging تقریباً همون نسخه نهاییه، اما هنوز برای کاربران منتشر نشده. تیم تست قبل از انتشار، همه قابلیت‌ها رو روی این نسخه بررسی می‌کنه.

Release همون نسخه‌ایه که داخل Google Play منتشر میشه. این نسخه معمولاً سریع‌تره، لاگ‌های اضافی حذف شدن، کدها Obfuscate شدن و فقط به سرور اصلی وصل میشه.


Build Type با Product Flavor فرق داره

یکی از جاهایی که خیلی‌ها اول کار اشتباه می‌کنن، تفاوت بین Build Type و Product Flavor هست. Build Type مشخص می‌کنه برنامه چطوری ساخته بشه. مثلاً Debug یا Release.

اما Product Flavor مشخص می‌کنه اصلاً کدوم نسخه از برنامه ساخته بشه. مثلاً نسخه رایگان، نسخه پولی یا نسخه مخصوص یک کشور. فرض کن قیمت‌بان هم نسخه رایگان داره و هم نسخه حرفه‌ای. این تفاوت با Product Flavor مشخص میشه، نه Build Type. در عمل، این دو مفهوم با هم ترکیب میشن و Variantهای مختلف رو می‌سازن.

مثلاً:

freeDebug
freeRelease

proDebug
proRelease

یعنی چهار نسخه مختلف از یک پروژه.


تعریف Build Type

داخل فایل Gradle می‌تونیم Build Typeهای مختلف رو تعریف کنیم.

android {

    buildTypes {

        debug {

            applicationIdSuffix = ".debug"

            versionNameSuffix = "-DEBUG"

        }

        release {

            isMinifyEnabled = true

        }

    }

}

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


تعریف Product Flavor

حالا فرض کن می‌خوای نسخه رایگان و حرفه‌ای برنامه رو از یک پروژه بسازی.

android {

    flavorDimensions += "version"

    productFlavors {

        create("free") {

            dimension = "version"

        }

        create("pro") {

            dimension = "version"

        }

    }

}

حالا اندروید استودیو خودش Variantهای مختلف رو می‌سازه و هر بار می‌تونی مشخص کنی دقیقاً کدوم نسخه Build بشه.


تنظیمات متفاوت برای هر Variant

یکی از جذاب‌ترین قابلیت‌های Build Variant اینه که هر نسخه می‌تونه تنظیمات مخصوص خودش رو داشته باشه.

مثلاً داخل نسخه Debug آدرس API این باشه:

https://test.gheimatban.com

ولی داخل نسخه Release برنامه به این آدرس وصل بشه:

https://api.gheimatban.com

یا مثلاً داخل نسخه Debug همه Logها فعال باشن، اما داخل Release هیچ اطلاعاتی داخل Logcat نوشته نشه. حتی می‌تونی اسم برنامه رو هم تغییر بدی تا موقع نصب راحت تشخیص بدی کدوم نسخه رو اجرا کردی.

مثلاً:

GheimatBan

و

GheimatBan Debug

Variant مناسب رو انتخاب کن

بالای Android Studio پنجره‌ای به نام Build Variants وجود داره. از همونجا مشخص می‌کنی که پروژه با کدوم Variant اجرا یا Build بشه. اگر روی Debug باشی، نسخه توسعه ساخته میشه. اگر Release رو انتخاب کنی، همون نسخه‌ای ساخته میشه که آماده انتشار روی Google Play هست. به همین خاطر قبل از گرفتن فایل APK یا AAB همیشه یک بار چک کن که اشتباهی روی Debug نباشی.


جمع‌بندی

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


درس شصتم

Signing

اصلاً چرا باید برنامه رو Sign کنیم؟

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

دقیقاً همین اتفاق در اندروید هم میفته.

هر اپلیکیشنی که روی گوشی نصب میشه، باید یک امضای دیجیتال (Digital Signature) داشته باشه. این امضا مشخص می‌کنه برنامه توسط چه کسی ساخته شده و آیا از زمان ساخته شدن تا الان کسی فایلش رو دستکاری کرده یا نه. اگر فایل APK یا AAB امضا نشده باشه، اندروید اصلاً اجازه نصبش رو نمیده. پس Signing فقط یک مرحله برای انتشار برنامه نیست؛ بلکه بخشی از سیستم امنیتی اندرویده.


امضای دیجیتال یعنی چی؟

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

اگر حتی یک بایت از فایل APK تغییر کرده باشه، امضا دیگه معتبر نیست و اندروید متوجه میشه فایل دستکاری شده.

یعنی Signing فقط برای شناسایی سازنده نیست؛ بلکه تضمین می‌کنه فایل دقیقاً همون چیزیه که برنامه‌نویس منتشر کرده.


چرا اندروید این‌قدر روی Signing حساسه؟

فرض کن نسخه ۱.۰ برنامه قیمت‌بان رو منتشر کردی و هزاران نفر نصبش کردن. شش ماه بعد نسخه ۲.۰ آماده میشه. حالا تصور کن یک نفر فایل APK خودش رو با همون Package Name منتشر کنه و کاربران هم اشتباهی اون نسخه رو نصب کنن.

اگر اندروید فقط Package Name رو بررسی می‌کرد، هر کسی می‌توانست خودش را جای توسعه‌دهنده اصلی جا بزند و نسخه جعلی منتشر کند. به همین خاطر اندروید علاوه بر Package Name، امضای دیجیتال را هم بررسی می‌کند. اگر نسخه جدید با همان کلیدی امضا شده باشد که نسخه قبلی با آن امضا شده بود، اندروید متوجه می‌شود این همان توسعه‌دهنده است و اجازه Update می‌دهد.

اما اگر امضا فرق کرده باشد، حتی اگر تمام فایل‌ها و Package Name یکسان باشند، اندروید آن را یک برنامه متفاوت می‌داند و اجازه جایگزین شدن نسخه قبلی را نمی‌دهد. این یکی از مهم‌ترین لایه‌های امنیتی اندروید است.


Debug Key و Release Key

وقتی اولین پروژه اندرویدت رو اجرا می‌کنی، احتمالاً هیچ Keyای نساختی. با این حال برنامه بدون مشکل روی گوشی نصب میشه. دلیلش اینه که Android Studio به‌صورت خودکار یک Debug Key برای توسعه تولید می‌کنه. این کلید فقط برای زمان برنامه‌نویسیه و هیچ‌وقت نباید باهاش برنامه منتشر بشه.

وقتی می‌خوای قیمت‌بان رو داخل Google Play منتشر کنی، باید یک Release Key مخصوص خودت داشته باشی. از اون لحظه به بعد، تمام نسخه‌های آینده برنامه باید با همین Key امضا بشن.


Keystore چیست؟

حالا سؤال مهم اینه که این Key کجا نگهداری میشه؟

اندروید فایل مخصوصی به نام Keystore داره.

Keystore مثل یک گاوصندوقه که کلیدهای رمزنگاری داخلش نگهداری میشن.

داخل این فایل اطلاعات مهمی وجود داره؛ مثل:

  • کلید خصوصی
  • Certificate
  • Alias
  • رمز عبور Keystore
  • رمز عبور خود Key

در واقع وقتی می‌گی «کلید برنامه»، معمولاً منظورت همون اطلاعاتیه که داخل فایل Keystore قرار داره.


اگر Keystore رو گم کنیم چه اتفاقی میفته؟

این یکی از بزرگ‌ترین اشتباه‌هاییه که بعضی توسعه‌دهنده‌ها مرتکب میشن. فرض کن امروز نسخه ۱.۰ قیمت‌بان رو منتشر کردی. یک سال بعد نسخه ۲.۰ آماده شده، اما متوجه میشی فایل Keystore رو پاک کردی و هیچ نسخه پشتیبانی هم نداری. حالا چی میشه؟

دیگه نمی‌تونی نسخه جدید رو با همون امضا منتشر کنی. یعنی کاربران فعلی دیگه نسخه جدید رو به‌عنوان Update دریافت نمی‌کنن. در گذشته این اتفاق تقریباً فاجعه بود و بعضی توسعه‌دهنده‌ها مجبور می‌شدن یک برنامه کاملاً جدید با Package Name جدید منتشر کنن.

امروزه Google Play قابلیتی به نام Play App Signing ارائه کرده که بخش زیادی از این ریسک رو کم کرده، اما همچنان از دست دادن Keystore یا اطلاعات مربوط بهش می‌تونه دردسرهای جدی ایجاد کنه. به همین خاطر، از همون روز اول باید چند نسخه پشتیبان امن از Keystore تهیه کنی و در محل‌های مختلف نگه داری.


Android Studio چطور برنامه رو Sign می‌کنه؟

وقتی آماده انتشار باشی، از منوی Build گزینه ساخت نسخه Release رو انتخاب می‌کنی. در مراحل بعدی، Android Studio ازت اطلاعات زیر رو می‌خواد:

  • فایل Keystore
  • رمز Keystore
  • Alias
  • رمز Alias

بعد از وارد کردن این اطلاعات، برنامه با همان کلید امضا می‌شود و فایل نهایی APK یا AAB ساخته خواهد شد.

اگر اطلاعات اشتباه باشند، Build انجام نمی‌شود.


امضای خودکار در Gradle

در پروژه‌های واقعی معمولاً نمی‌خوان هر بار موقع Build کردن، رمزها رو دستی وارد کنن. به همین خاطر اطلاعات Signing داخل Gradle تعریف میشه.

نمونه ساده:

android {

    signingConfigs {

        create("release") {

            storeFile = file("release.jks")

            storePassword = "******"

            keyAlias = "gheimatban"

            keyPassword = "******"

        }

    }

}

بعد Build Type مربوط به Release از همین تنظیمات استفاده می‌کنه.

buildTypes {

    release {

        signingConfig = signingConfigs.getByName("release")

    }

}

البته در پروژه‌های حرفه‌ای هیچ‌وقت رمزها داخل کد نوشته نمی‌شن. معمولاً از فایل‌های محلی، Environment Variableها یا سیستم CI/CD استفاده می‌کنن تا اطلاعات حساس وارد مخزن Git نشه.


آیا میشه یک برنامه رو با کلید جدید منتشر کرد؟

اگر برنامه هنوز منتشر نشده، بله. هر زمان بخوای می‌تونی Keystore رو عوض کنی. اما بعد از اینکه نسخه اول منتشر شد و کاربران اون رو نصب کردن، داستان کاملاً فرق می‌کنه. از اون لحظه به بعد، تمام نسخه‌های بعدی باید با همان کلید امضا بشن؛ وگرنه اندروید اون‌ها رو نسخه جدید همون برنامه نمی‌شناسه.

به همین خاطر، Keystore یکی از ارزشمندترین دارایی‌های هر پروژه اندرویده. اگر سورس پروژه رو از دست بدی، شاید با داشتن نسخه پشتیبان بتونی دوباره بسازیش؛ اما اگر Keystore رو از دست بدی، ممکنه امکان انتشار نسخه‌های بعدی برای همان اپلیکیشن رو از دست بدی.


Play App Signing دقیقاً چه مشکلی رو حل می‌کنه؟

گوگل چند سالیه قابلیتی به نام Play App Signing رو معرفی کرده تا مسئولیت نگهداری کلید اصلی تا حد زیادی از دوش توسعه‌دهنده برداشته بشه. در این روش، اولین بار که برنامه رو منتشر می‌کنی، کلید اصلی امضا نزد گوگل نگهداری میشه. بعد از اون، تو معمولاً نسخه‌های جدید رو با یک Upload Key امضا می‌کنی و گوگل قبل از انتشار، فایل رو با کلید اصلی دوباره امضا می‌کنه.

مزیتش اینه که اگر Upload Key رو گم کنی، معمولاً امکان بازیابی یا جایگزین کردنش وجود داره؛ در حالی که در روش قدیمی، گم شدن کلید اصلی تقریباً به معنی پایان امکان انتشار نسخه‌های جدید بود.

البته این به معنی بی‌اهمیت شدن امنیت کلیدها نیست. همچنان باید هم Keystore و هم اطلاعات مربوط بهش رو با دقت نگهداری کنی، چون امنیت حساب توسعه‌دهنده و فرآیند انتشار برنامه به اون‌ها وابسته است.


جمع‌بندی

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


درس شصت‌ویکم

ProGuard

اصلاً ProGuard چه مشکلی رو حل می‌کنه؟

وقتی روی پروژه‌ای مثل قیمت‌بان چند ماه یا چند سال کار می‌کنی، کم‌کم حجم کدها زیاد میشه. کتابخانه‌های مختلف اضافه میشن، کلاس‌های زیادی ساخته میشن و بخشی از کدها هم به مرور بلااستفاده میشن. در نهایت، وقتی نسخه Release رو Build می‌کنی، ممکنه فایل APK یا AAB خیلی بزرگ‌تر از چیزی باشه که واقعاً لازمه.

اما حجم برنامه فقط یکی از مشکلاته.

فرض کن یک نفر فایل APK برنامه رو از روی گوشی استخراج کنه. اگر هیچ محافظتی وجود نداشته باشه، می‌تونه فایل رو Decompile کنه و بخش زیادی از ساختار برنامه رو ببینه؛ اسم کلاس‌ها، متدها، مدل‌های داده و حتی منطق برنامه تا حد زیادی قابل تشخیص خواهد بود. شاید نتونه دقیقاً سورس اصلی Kotlin رو به دست بیاره، اما اطلاعاتی که استخراج می‌کنه گاهی برای فهمیدن ساختار برنامه کاملاً کافیه.

اینجاست که ProGuard وارد ماجرا میشه. البته اگر بخوای دقیق‌تر بگیم، از نسخه‌های جدید Android Gradle Plugin ابزار اصلی R8 هست، نه ProGuard. با این حال، چون سال‌هاست برنامه‌نویس‌ها به این فرایند میگن «فعال کردن ProGuard»، هنوز هم این اسم بین توسعه‌دهنده‌های اندروید رایجه. در واقع، امروز R8 کارهایی رو انجام میده که قبلاً ProGuard انجام می‌داد و حتی امکانات بیشتری هم داره.


اگر ProGuard وجود نداشت چه اتفاقی می‌افتاد؟

فرض کن داخل پروژه قیمت‌بان کلاس‌هایی با اسم‌های زیر داری:

ProductRepository

UserManager

PaymentService

DiscountCalculator

ApiSecurityManager

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

اما وقتی R8 یا همان چیزی که همه به اسم ProGuard می‌شناسن فعال باشه، ممکنه همین کلاس‌ها به چیزی شبیه این تبدیل بشن:

a

b

c

d

e

و متدها هم اسم‌هایی مثل این پیدا کنن:

a()

b()

c()

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


ProGuard دقیقاً چه کارهایی انجام میده؟

بیشتر برنامه‌نویس‌ها فکر می‌کنن ProGuard فقط اسم کلاس‌ها رو عوض می‌کنه، در حالی که این فقط یکی از وظایفشه.

اولین کاری که انجام میده Code Shrinking هست. یعنی کدهایی که هیچ‌وقت استفاده نمی‌شن رو حذف می‌کنه. فرض کن سه کتابخانه به پروژه اضافه کردی، اما فقط از ده درصد قابلیت‌های اون‌ها استفاده می‌کنی. R8 بخش زیادی از کدهای بلااستفاده رو حذف می‌کنه تا حجم برنامه کمتر بشه.

دومین کار Resource Shrinking هست. گاهی داخل پروژه عکس‌ها، آیکون‌ها یا فایل‌هایی وجود دارن که دیگه هیچ بخشی از برنامه ازشون استفاده نمی‌کنه. این فایل‌ها هم می‌تونن حذف بشن و حجم نهایی برنامه رو کمتر کنن. سومین کار Optimization هست. بعضی از کدها می‌تونن ساده‌تر یا سریع‌تر اجرا بشن. R8 در بعضی شرایط این بهینه‌سازی‌ها رو هم انجام میده.

و در نهایت مهم‌ترین بخشی که بیشتر توسعه‌دهنده‌ها باهاش آشنا هستن، Obfuscation یا مبهم‌سازی کده؛ یعنی تغییر اسم کلاس‌ها، متدها و فیلدها تا مهندسی معکوس برنامه سخت‌تر بشه.


آیا ProGuard برنامه رو غیرقابل هک می‌کنه؟

نه. این یکی از رایج‌ترین سوءتفاهم‌هاست. بعضی‌ها فکر می‌کنن اگر ProGuard رو فعال کنن، دیگه هیچ‌کس نمی‌تونه برنامه‌شون رو مهندسی معکوس کنه. واقعیت اینه که ProGuard فقط کار مهاجم رو سخت‌تر می‌کنه، نه غیرممکن.

فرض کن می‌خوای وارد یک ساختمان بشی. اگر درِ ساختمان قفل نداشته باشه، هر کسی به راحتی وارد میشه. اما اگر چند قفل، دوربین و سیستم امنیتی وجود داشته باشه، ورود خیلی سخت‌تر میشه. با این حال، هنوز هم از نظر تئوری ممکنه یک مهاجم حرفه‌ای وارد بشه. ProGuard هم دقیقاً همین نقش رو داره. هدفش این نیست که امنیت مطلق ایجاد کنه؛ هدفش اینه که زمان، هزینه و سختی مهندسی معکوس رو تا جای ممکن بالا ببره.


فعال کردن ProGuard

در نسخه Release معمولاً R8 به همین شکل فعال میشه:

buildTypes {

    release {

        isMinifyEnabled = true

        isShrinkResources = true

        proguardFiles(

            getDefaultProguardFile(
                "proguard-android-optimize.txt"
            ),
            "proguard-rules.pro"
        )

    }

}

هر کدوم از این تنظیمات یک وظیفه دارن. isMinifyEnabled باعث فعال شدن فرایند کوچک‌سازی، بهینه‌سازی و Obfuscation میشه. isShrinkResources هم منابع بلااستفاده مثل تصاویر و فایل‌های اضافی رو حذف می‌کنه. فایل proguard-rules.pro هم جاییه که قوانین مخصوص پروژه خودت رو داخلش می‌نویسی.


چرا بعضی برنامه‌ها بعد از فعال کردن ProGuard خراب میشن؟

این اتفاق بیشتر از چیزی که فکر می‌کنی رایجه. فرض کن یک کتابخانه با Reflection کار می‌کنه. یعنی اسم کلاس یا متدها رو در زمان اجرا پیدا می‌کنه. اگر R8 اسم اون کلاس‌ها رو تغییر بده، کتابخانه دیگه نمی‌تونه پیداشون کنه و برنامه Crash می‌کنه.

یا مثلاً بعضی کتابخانه‌ها از Annotationها یا Serialization استفاده می‌کنن. اگر قوانین مناسب براشون نوشته نشه، ممکنه بخشی از کلاس‌ها حذف بشن و برنامه دیگه درست کار نکنه.

به همین دلیل، فعال کردن ProGuard فقط زدن یک گزینه نیست. بعد از فعال کردنش باید نسخه Release رو کامل تست کنی، چون ممکنه مشکلی به وجود بیاد که در نسخه Debug اصلاً وجود نداشت.


فایل proguard-rules.pro چیست؟

این فایل در واقع دفترچه دستورهای تو برای R8 هست. اینجا مشخص می‌کنی چه کلاس‌هایی حذف نشن، چه کلاس‌هایی تغییر نام ندن و چه بخش‌هایی دست‌نخورده باقی بمونن. مثلاً اگر بخوای یک کلاس خاص هیچ‌وقت Obfuscate نشه، می‌تونی قانونی شبیه این بنویسی:

-keep class com.gheimatban.network.ApiService {
    *;
}

یعنی این کلاس و تمام اعضای داخلش باید بدون تغییر باقی بمونن. در پروژه‌های واقعی، به مرور زمان ممکنه ده‌ها یا حتی صدها قانون داخل این فایل نوشته بشه؛ مخصوصاً وقتی از کتابخانه‌های مختلف استفاده می‌کنی.


آیا همیشه باید ProGuard رو فعال کنیم؟

برای نسخه Debug معمولاً نه. دلیلش ساده است. وقتی در حال توسعه برنامه هستی، می‌خوای پیام‌های خطا واضح باشن، اسم کلاس‌ها تغییر نکرده باشه و دیباگ کردن راحت انجام بشه. اما برای نسخه Release تقریباً همیشه توصیه میشه R8 فعال باشه؛ چون هم حجم برنامه رو کمتر می‌کنه، هم اجرای بعضی بخش‌ها رو بهینه‌تر می‌کنه و هم مهندسی معکوس رو سخت‌تر می‌کنه.

به همین خاطر، اگر فایل Gradle اکثر پروژه‌های حرفه‌ای اندروید رو ببینی، متوجه میشی که isMinifyEnabled فقط برای Release فعال شده و نسخه Debug بدون این مرحله Build میشه.


جمع‌بندی

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


درس شصت‌ودوم

انتشار اپلیکیشن در کافه‌بازار و گوگل‌پلی

تا اینجا برنامه قیمت‌بان رو ساختیم، نسخه Release رو از نسخه Debug جدا کردیم، امضای دیجیتال برنامه رو شناختیم و با R8 یا همون ProGuard کاری کردیم که کدهای اضافی حذف بشن و مهندسی معکوس برنامه سخت‌تر بشه. حالا باید خروجی نهایی رو از محیط توسعه بیرون بیاریم و در اختیار کاربران واقعی قرار بدیم. این مرحله فقط آپلود کردن یک فایل داخل سایت نیست. برای انتشار موفق باید نسخه برنامه رو درست تنظیم کنیم، خروجی مناسب هر مارکت رو بسازیم، مطمئن بشیم فایل با کلید صحیح امضا شده، برنامه رو روی نسخه Release آزمایش کنیم، صفحه معرفی برنامه رو کامل کنیم و بعد هم نسخه رو ابتدا در مسیر آزمایشی و سپس در مسیر عمومی منتشر کنیم.

در این درس فرض می‌کنیم نسخه اول قیمت‌بان آماده شده و می‌خوایم اون رو هم در کافه‌بازار و هم در Google Play منتشر کنیم. بخش زیادی از آماده‌سازی فنی برای هر دو مارکت مشترکه، اما نوع فایل، شیوه امضا و بعضی مراحل پنل انتشار تفاوت دارن. Google Play برای برنامه‌های جدید از Android App Bundle استفاده می‌کنه و خود گوگل از داخل فایل AAB، فایل‌های APK مناسب هر دستگاه رو تولید می‌کنه. در مارکت‌های دیگه ممکنه APK یا AAB پذیرفته بشه، اما باید قبل از هر انتشار، فرمت‌های پذیرفته‌شده و قوانین روز همون مارکت رو داخل پنل رسمی بررسی کنی؛ چون جزئیات پنل‌ها و سیاست‌های انتشار ممکنه تغییر کنن.

قبل از انتشار، نسخه Release واقعی رو آماده می‌کنیم

اولین اشتباه خطرناک اینه که برنامه رو در حالت Debug تست کنیم و تصور کنیم نسخه Release هم دقیقاً همون رفتار رو خواهد داشت. نسخه Release ممکنه از آدرس سرور متفاوتی استفاده کنه، لاگ‌هاش غیرفعال باشن، R8 کلاس‌ها رو حذف یا Obfuscate کنه، امضای متفاوتی داشته باشه و حتی بعضی سرویس‌ها مثل ورود گوگل، نقشه، Firebase یا APIهای دارای محدودیت امضا در اون کار نکنن. بنابراین چیزی که باید آزمایش بشه، دقیقاً همون فایل امضاشده‌ایه که قراره در مارکت منتشر بشه.

در فایل build.gradle.kts ماژول app باید مشخصات نسخه، شناسه برنامه و تنظیمات Release رو بررسی کنیم. نمونه زیر یک پیکربندی معقول برای نسخه اول قیمت‌بان هست:

android {
    namespace = "com.gheimatban.app"
    compileSdk = 36

    defaultConfig {
        applicationId = "com.gheimatban.app"

        minSdk = 24
        targetSdk = 36

        versionCode = 1
        versionName = "1.0.0"
    }

    buildTypes {
        debug {
            applicationIdSuffix = ".debug"
            versionNameSuffix = "-debug"

            isDebuggable = true
            isMinifyEnabled = false
            isShrinkResources = false
        }

        release {
            isDebuggable = false
            isMinifyEnabled = true
            isShrinkResources = true

            proguardFiles(
                getDefaultProguardFile(
                    "proguard-android-optimize.txt"
                ),
                "proguard-rules.pro"
            )
        }
    }
}

مقدار applicationId هویت فنی برنامه در اندروید و مارکت‌هاست. کاربر معمولاً این مقدار رو نمی‌بینه، اما Google Play و کافه‌بازار برنامه رو با همین شناسه می‌شناسن. بنابراین com.gheimatban.app با com.gheimatban.android دو برنامه کاملاً متفاوت محسوب میشن. بعد از اینکه اولین نسخه رو در یک مارکت ثبت کردی، نباید شناسه برنامه رو تغییر بدی؛ چون نسخه جدید دیگه به‌عنوان به‌روزرسانی نسخه قبلی شناخته نمیشه و عملاً یک برنامه تازه به وجود میاد. Google Play هم Package Name رو یک شناسه یکتا و دائمی می‌دونه و بعد از بارگذاری فایل، نمی‌تونی اون رو برای همون برنامه عوض کنی.

وجود applicationIdSuffix = ".debug" هم باعث میشه نسخه Debug با شناسه زیر نصب بشه:

com.gheimatban.app.debug

اما نسخه Release با شناسه اصلی نصب میشه:

com.gheimatban.app

این کار بسیار مفیده، چون می‌تونی نسخه توسعه و نسخه واقعی GheimatBan رو هم‌زمان روی یک گوشی نصب داشته باشی. اگر چنین پسوندی نداشته باشی، نصب نسخه Debug ممکنه نسخه واقعی برنامه رو جایگزین کنه یا به دلیل تفاوت امضا با خطای نصب روبه‌رو بشه.

تفاوت versionCode و versionName

برای انتشار برنامه باید دو مقدار نسخه رو تنظیم کنیم:

defaultConfig {
    versionCode = 1
    versionName = "1.0.0"
}

versionName همون شماره‌ایه که کاربر می‌بینه. برای مثال می‌تونه 1.0.0، 1.1.0 یا 2.0.0 باشه. این مقدار بیشتر برای ارتباط با کاربر و تیم توسعه استفاده میشه. اما versionCode یک عدد صحیح داخلیه که اندروید و مارکت‌ها با اون تشخیص میدن کدوم نسخه جدیدتره. هر فایل جدیدی که منتشر می‌کنی باید versionCode بزرگ‌تری از تمام نسخه‌های قبلی داشته باشه. استفاده دوباره از یک versionCode قبلی باعث میشه Google Play فایل جدید رو نپذیره.

برای نسخه اول می‌تونیم این تنظیم رو داشته باشیم:

versionCode = 1
versionName = "1.0.0"

اگر یک باگ کوچک رو برطرف کردیم:

versionCode = 2
versionName = "1.0.1"

اگر یک قابلیت تازه مثل هشدار کاهش قیمت اضافه کردیم:

versionCode = 3
versionName = "1.1.0"

و اگر معماری یا تجربه کاربری برنامه به‌صورت اساسی تغییر کرد:

versionCode = 10
versionName = "2.0.0"

لازم نیست versionCode شبیه versionName باشه. می‌تونی نسخه 3.7.2 رو با versionCode = 48 منتشر کنی. نکته اصلی اینه که versionCode همیشه افزایش پیدا کنه. برای پروژه‌های تیمی بهتره افزایش این عدد بخشی از فرایند انتشار باشه تا کسی فراموشش نکنه.

اطلاعات حساس امضا رو داخل Git قرار نمی‌دیم

در درس Signing کلید انتشار رو ساختیم. حالا باید مطمئن بشیم اطلاعات Keystore داخل فایل Gradle و مخزن Git منتشر نشده. یک راه معمول اینه که اطلاعات رو داخل فایل محلی جداگانه قرار بدیم. برای مثال، در ریشه پروژه فایلی با نام keystore.properties می‌سازیم:

storeFile=../keys/gheimatban-release.jks
storePassword=YOUR_STORE_PASSWORD
keyAlias=gheimatban
keyPassword=YOUR_KEY_PASSWORD

این فایل رو داخل .gitignore قرار می‌دیم:

keystore.properties
*.jks
*.keystore

سپس در build.gradle.kts ماژول app اطلاعات رو می‌خونیم:

import java.util.Properties

val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("keystore.properties")

if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(
        keystorePropertiesFile.inputStream()
    )
}

حالا Signing Config رو می‌سازیم:

android {
    signingConfigs {
        create("release") {
            storeFile = file(
                keystoreProperties["storeFile"] as String
            )

            storePassword =
                keystoreProperties["storePassword"] as String

            keyAlias =
                keystoreProperties["keyAlias"] as String

            keyPassword =
                keystoreProperties["keyPassword"] as String
        }
    }

    buildTypes {
        release {
            signingConfig = signingConfigs.getByName("release")

            isDebuggable = false
            isMinifyEnabled = true
            isShrinkResources = true

            proguardFiles(
                getDefaultProguardFile(
                    "proguard-android-optimize.txt"
                ),
                "proguard-rules.pro"
            )
        }
    }
}

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

Google Play برای برنامه‌های جدید از Play App Signing استفاده می‌کنه. در این ساختار، فایل AAB رو با Upload Key امضا می‌کنی و برای گوگل می‌فرستی؛ سپس Google Play فایل‌های نهایی تحویلی به کاربران رو با App Signing Key امضا می‌کنه. بنابراین Upload Key و App Signing Key دو نقش متفاوت دارن.

ساخت فایل AAB برای Google Play

فایل AAB مستقیماً مثل APK روی گوشی نصب نمیشه. این فایل مجموعه‌ای از کدها و منابع برنامه‌ست که Google Play بر اساس مدل دستگاه، معماری پردازنده، زبان و تراکم صفحه، APKهای مناسب رو از داخل اون تولید می‌کنه. برای برنامه‌های جدید Google Play، انتشار با Android App Bundle الزام اصلیه.

برای ساخت فایل از داخل Android Studio به مسیر زیر می‌ریم:

Build
→ Generate Signed Bundle / APK

سپس گزینه زیر رو انتخاب می‌کنیم:

Android App Bundle

در مرحله بعد، فایل Keystore، رمز، Alias و رمز کلید رو انتخاب می‌کنیم. Build Variant رو روی release قرار می‌دیم و ساخت فایل رو شروع می‌کنیم. بعد از پایان فرایند، معمولاً فایل در مسیری مشابه این قرار می‌گیره:

app/build/outputs/bundle/release/app-release.aab

Android Studio برای ساخت خروجی امضاشده، Wizard مخصوص Generate Signed Bundle / APK رو ارائه میده و خروجی Bundle رو داخل پوشه build/outputs/bundle/ قرار میده.

همین کار رو می‌تونیم از Terminal هم انجام بدیم. در ویندوز:

gradlew.bat bundleRelease

در macOS یا Linux:

./gradlew bundleRelease

بعد از موفقیت Build، فایل رو در این مسیر پیدا می‌کنیم:

app/build/outputs/bundle/release/app-release.aab

اگر چند Flavor داشته باشیم، نام Task تغییر می‌کنه. مثلاً اگر Flavor نهایی برنامه production باشه، ممکنه Task این باشه:

./gradlew bundleProductionRelease

و فایل خروجی در مسیری مشابه این ساخته بشه:

app/build/outputs/bundle/productionRelease/

ساخت فایل APK برای تست و مارکت‌های دیگر

برای انتشار مستقیم روی وب‌سایت، ارسال فایل به تسترها یا مارکتی که APK می‌پذیره، باید APK امضاشده Release بسازیم. از داخل Android Studio دوباره وارد این مسیر می‌شیم:

Build
→ Generate Signed Bundle / APK

این بار گزینه زیر رو انتخاب می‌کنیم:

APK

بعد از انتخاب Keystore و Variant نسخه Release، فایل معمولاً در این مسیر ساخته میشه:

app/build/outputs/apk/release/app-release.apk

از خط فرمان هم می‌تونیم بنویسیم:

./gradlew assembleRelease

در ویندوز:

gradlew.bat assembleRelease

دقت کن دکمه Run در Android Studio خروجی مناسب انتشار عمومی تولید نمی‌کنه. خروجی Run ممکنه دارای testOnly باشه و فقط از طریق ADB نصب بشه. برای فایل قابل توزیع باید از Build APK یا Generate Signed Bundle / APK استفاده کنیم.

قبل از آپلود، فایل Release رو واقعاً نصب می‌کنیم

فایل AAB رو مستقیماً نمی‌تونیم مثل APK باز کنیم، اما APK Release رو باید قبل از انتشار روی یک گوشی تمیز نصب کنیم. بهتره ابتدا نسخه Debug رو حذف کنیم تا مطمئن بشیم داده‌های باقی‌مانده، ورود قبلی یا تنظیمات توسعه باعث مخفی شدن باگ‌ها نشده. سپس APK رو با ADB نصب می‌کنیم:

adb install app/build/outputs/apk/release/app-release.apk

اگر نسخه قبلی قیمت‌بان با همون امضا روی گوشی نصب شده و فقط می‌خوایم آپدیتش کنیم:

adb install -r app/build/outputs/apk/release/app-release.apk

اگر با خطایی شبیه INSTALL_FAILED_UPDATE_INCOMPATIBLE روبه‌رو شدیم، معمولاً نسخه نصب‌شده با کلید متفاوتی امضا شده. مثلاً نسخه Debug روی گوشی نصبه، اما حالا APK Release رو با همون applicationId نصب می‌کنیم. در این حالت یا باید نسخه قبلی حذف بشه یا برای Debug از applicationIdSuffix استفاده کنیم.

نسخه Release رو فقط باز نمی‌کنیم و بگیم «اجرا شد، پس سالمه». باید مسیرهای اصلی رو کامل آزمایش کنیم: ثبت‌نام، ورود، خروج، افزودن محصول، دریافت قیمت، اعلان کاهش قیمت، باز کردن Deep Link، حالت بدون اینترنت، منقضی شدن Token، آپلود عکس، اجرای برنامه بعد از بسته شدن کامل، دریافت اعلان در Foreground و Background و رفتن از اعلان به صفحه درست. اگر R8 فعاله، باید بخش‌هایی که با Reflection، Serialization یا کتابخانه‌های خاص کار می‌کنن هم بررسی بشن؛ چون احتمال داره نسخه Debug سالم باشه اما Release Crash کنه.

کنترل لاگ‌ها و آدرس سرور در نسخه نهایی

نباید نسخه نهایی قیمت‌بان به سرور تست یا Localhost متصل باشه. یکی از بهترین راه‌ها اینه که آدرس API رو بر اساس Build Type تنظیم کنیم:

android {
    buildTypes {
        debug {
            buildConfigField(
                "String",
                "BASE_URL",
                "\"https://staging-api.gheimatban.com/\""
            )
        }

        release {
            buildConfigField(
                "String",
                "BASE_URL",
                "\"https://api.gheimatban.com/\""
            )
        }
    }

    buildFeatures {
        buildConfig = true
    }
}

بعد در Retrofit از همین مقدار استفاده می‌کنیم:

val retrofit = Retrofit.Builder()
    .baseUrl(BuildConfig.BASE_URL)
    .addConverterFactory(
        GsonConverterFactory.create()
    )
    .build()

برای لاگ‌ها هم می‌تونیم یک Wrapper ساده داشته باشیم:

object AppLogger {

    fun d(tag: String, message: String) {
        if (BuildConfig.DEBUG) {
            Log.d(tag, message)
        }
    }

    fun e(
        tag: String,
        message: String,
        throwable: Throwable? = null
    ) {
        if (BuildConfig.DEBUG) {
            Log.e(tag, message, throwable)
        }
    }
}

البته خطاهای واقعی نسخه Release رو نباید کاملاً نادیده بگیریم. به‌جای چاپ Token، شماره تلفن یا پاسخ کامل سرور در Logcat، بهتره Crashها و خطاهای کنترل‌شده رو با ابزاری مثل Firebase Crashlytics ثبت کنیم. موضوع Crash Reporting رو در درس جداگانه بررسی می‌کنیم.

بررسی شناسه برنامه، مجوزها و Manifest نهایی

قبل از انتشار باید Manifest ادغام‌شده رو هم بررسی کنیم. کتابخانه‌ها می‌تونن Permissionها، Serviceها، Receiverها و Providerهای خودشون رو به Manifest اضافه کنن. بنابراین فقط نگاه کردن به فایل AndroidManifest.xml پروژه کافی نیست. در Android Studio فایل Manifest رو باز می‌کنیم و تب Merged Manifest رو می‌بینیم.

برای قیمت‌بان ممکنه این مجوزها لازم باشن:

<uses-permission
    android:name="android.permission.INTERNET" />

<uses-permission
    android:name="android.permission.ACCESS_NETWORK_STATE" />

<uses-permission
    android:name="android.permission.POST_NOTIFICATIONS" />

<uses-permission
    android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

اما اگر Permission حساسی می‌بینی که برنامه واقعاً بهش نیاز نداره، نباید فقط برای راحتی نگهش داری. مارکت‌ها ممکنه درباره دسترسی‌هایی مثل موقعیت مکانی، SMS، تماس‌ها، فایل‌ها، دوربین، میکروفون یا نصب برنامه‌های ناشناس توضیح و مستندات بیشتری بخوان. علاوه بر اون، جمع‌آوری داده باید با عملکرد واقعی برنامه، صفحه Privacy Policy و فرم‌های Data Safety هماهنگ باشه. Google Play مسئولیت اعلام درست داده‌های جمع‌آوری‌شده توسط خود برنامه و SDKهای جانبی رو بر عهده توسعه‌دهنده قرار میده.

ساخت حساب و برنامه در Google Play Console

بعد از ساخت حساب توسعه‌دهنده، وارد Play Console می‌شیم و گزینه Create app رو انتخاب می‌کنیم. در این مرحله زبان اصلی، نام نمایشی برنامه، نوع App یا Game، رایگان یا پولی بودن برنامه و ایمیل پشتیبانی رو تعیین می‌کنیم. همچنین باید قوانین توسعه‌دهندگان، مقررات صادرات و شرایط Play App Signing رو تأیید کنیم.

برای پروژه ما نام برنامه می‌تونه این باشه:

قیمت‌بان

Package Name از داخل فایل AAB خونده میشه و بعد از اولین بارگذاری به برنامه متصل خواهد شد:

com.gheimatban.app

نام نمایشی بعداً قابل ویرایشه، اما Package Name رو نباید انتخاب موقت در نظر بگیری. برای مثال اگر فایل آزمایشی رو با شناسه زیر بارگذاری کنی:

com.example.gheimatban

بعداً نمی‌تونی همون برنامه ثبت‌شده رو به این شناسه تبدیل کنی:

com.gheimatban.app

مجبور میشی برنامه تازه‌ای در پنل بسازی.

صفحه معرفی برنامه در Google Play

قبل از انتشار عمومی باید Store Listing رو کامل کنیم. صفحه معرفی فقط یک فرم اداری نیست؛ ویترین قیمت‌بان محسوب میشه. کاربر قبل از نصب، نام، آیکون، توضیحات، تصاویر، امتیاز و اطلاعات حریم خصوصی رو می‌بینه و بر اساس اون‌ها تصمیم می‌گیره.

توضیح کوتاه می‌تونه چیزی شبیه این باشه:

قیمت کالاها رو دنبال کن و از کاهش قیمت‌ها باخبر شو.

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

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

اسکرین‌شات‌ها هم نباید فقط تصاویر خام و نامفهوم باشن. بهتره هر تصویر یک قابلیت مشخص رو نمایش بده: صفحه پیگیری محصولات، نمودار تغییر قیمت، ساخت هشدار، اعلان کاهش قیمت و صفحه جزئیات محصول. متن روی تصویر باید کوتاه و خوانا باشه. آیکون، تصاویر و ابعاد موردنیاز ممکنه در طول زمان تغییر کنن، بنابراین عددها و محدودیت‌های دقیق رو موقع انتشار از خود Play Console بگیر، نه از یک مقاله قدیمی.

Privacy Policy و حذف حساب کاربری

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

طبق سیاست فعلی Google Play، همه برنامه‌ها باید لینک Privacy Policy رو در Play Console ثبت کنن و متن یا لینک اون رو داخل خود برنامه هم در دسترس قرار بدن. حتی برنامه‌ای که داده شخصی جمع نمی‌کنه هم باید فرم مربوط رو تکمیل و Privacy Policy ارائه کنه. لینک باید عمومی، فعال و قابل دسترسی باشه.

می‌تونیم داخل صفحه تنظیمات قیمت‌بان گزینه‌ای برای باز کردن Privacy Policy قرار بدیم:

fun openPrivacyPolicy(context: Context) {
    val intent = Intent(
        Intent.ACTION_VIEW,
        Uri.parse(
            "https://gheimatban.com/privacy-policy"
        )
    )

    context.startActivity(intent)
}

و در Compose:

TextButton(
    onClick = {
        openPrivacyPolicy(context)
    }
) {
    Text("سیاست حفظ حریم خصوصی")
}

اگر کاربر بتونه داخل برنامه حساب بسازه، Google Play الزام می‌کنه مسیر قابل‌دسترسی برای درخواست حذف حساب و داده‌های مرتبط وجود داشته باشه؛ این مسیر باید هم داخل برنامه و هم از طریق یک منبع وب در دسترس باشه. غیرفعال کردن موقت حساب جای حذف واقعی رو نمی‌گیره.

برای مثال، در تنظیمات برنامه دکمه حذف حساب داریم:

interface AccountApi {

    @DELETE("account")
    suspend fun deleteAccount(): Response<Unit>
}

و در ViewModel:

class SettingsViewModel(
    private val accountApi: AccountApi,
    private val tokenStorage: TokenStorage
) : ViewModel() {

    private val _deleteState =
        MutableStateFlow<DeleteAccountState>(
            DeleteAccountState.Idle
        )

    val deleteState =
        _deleteState.asStateFlow()

    fun deleteAccount() {
        viewModelScope.launch {
            _deleteState.value =
                DeleteAccountState.Loading

            try {
                val response =
                    accountApi.deleteAccount()

                if (response.isSuccessful) {
                    tokenStorage.clear()

                    _deleteState.value =
                        DeleteAccountState.Success
                } else {
                    _deleteState.value =
                        DeleteAccountState.Error(
                            "حذف حساب انجام نشد."
                        )
                }
            } catch (exception: Exception) {
                _deleteState.value =
                    DeleteAccountState.Error(
                        "ارتباط با سرور برقرار نشد."
                    )
            }
        }
    }
}

و State مربوط:

sealed interface DeleteAccountState {

    data object Idle : DeleteAccountState

    data object Loading : DeleteAccountState

    data object Success : DeleteAccountState

    data class Error(
        val message: String
    ) : DeleteAccountState
}

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

تکمیل Data Safety در Google Play

داخل Play Console باید بخش Data Safety رو براساس رفتار واقعی قیمت‌بان پر کنیم. برای مثال باید بررسی کنیم آیا این داده‌ها جمع میشن یا نه:

نام و نام خانوادگی ایمیل یا شماره تلفن شناسه کاربر اطلاعات خرید محصولات ذخیره‌شده تاریخچه جست‌وجو شناسه دستگاه Crash Log اطلاعات عملکرد برنامه

وجود یک SDK هم می‌تونه پاسخ فرم رو تغییر بده. مثلاً اگر Firebase Crashlytics، Analytics، تبلیغات یا سرویس Push داده‌ای رو دریافت می‌کنه، نمی‌تونی فقط کدهایی رو که خودت نوشته‌ای بررسی کنی. Google Play صریحاً اعلام می‌کنه اطلاعات SDKهای شخص ثالث هم باید در فرم Data Safety لحاظ بشن و مسئولیت صحت پاسخ‌ها با توسعه‌دهنده‌ست.

اشتباه خطرناک اینه که برای سریع‌تر تمام شدن فرم، همه پاسخ‌ها رو روی «No» بگذاری. اگر عملکرد واقعی برنامه، Manifest، SDKها یا ترافیک شبکه با فرم مغایرت داشته باشه، برنامه ممکنه رد یا بعداً تعلیق بشه. فرم Data Safety، Privacy Policy و رفتار واقعی برنامه باید سه روایت یکسان از شیوه مدیریت داده ارائه بدن.

انتشار آزمایشی در Google Play

بهتره اولین فایل رو مستقیم وارد Production نکنیم. Google Play مسیرهای مختلفی برای تست داره:

Internal Testing برای تست سریع توسط گروه کوچکی از اعضای تیم یا افراد مورد اعتماد مناسبه و حداکثر تا ۱۰۰ تستر رو پشتیبانی می‌کنه. Closed Testing برای گروه کنترل‌شده بزرگ‌تره، Open Testing اجازه میده افراد بیشتری وارد آزمایش عمومی بشن و Production نسخه‌ایه که برای کاربران عمومی کشورهای انتخاب‌شده منتشر میشه.

برای اولین تست قیمت‌بان وارد مسیر Internal Testing می‌شیم، یک Release جدید می‌سازیم و فایل زیر رو آپلود می‌کنیم:

app-release.aab

بعد Release Notes رو وارد می‌کنیم:

نسخه آزمایشی اولیه قیمت‌بان

• افزودن و پیگیری محصولات
• مشاهده آخرین قیمت
• تعیین قیمت هدف
• دریافت اعلان کاهش قیمت

سپس ایمیل تسترها رو اضافه می‌کنیم و لینک عضویت در تست رو برای اون‌ها می‌فرستیم. مزیت این روش اینه که برنامه تقریباً از همون مسیر واقعی Google Play نصب میشه و به امضای Play، سرویس‌های خرید، محدودیت‌های دستگاه و فرایند تحویل واقعی نزدیک‌تره. Google Play پیشنهاد می‌کنه تست داخلی قبل از مسیرهای گسترده‌تر انجام بشه.

یک نکته مهم برای حساب‌های شخصی جدید وجود داره. براساس شرایط فعلی، حساب‌های شخصی ساخته‌شده بعد از ۱۳ نوامبر ۲۰۲۳ باید پیش از دسترسی به انتشار Production، یک Closed Test با حداقل ۱۲ تستر داشته باشن که دست‌کم ۱۴ روز پیوسته در تست عضو مونده باشن. بعد از برآورده شدن این شرط، توسعه‌دهنده باید برای Production Access درخواست بده و درباره فرایند تست و آمادگی برنامه توضیح ارائه کنه. این قانون ممکنه در آینده تغییر کنه، بنابراین مقدار دقیق شرط رو در Dashboard حساب خودت بررسی کن.

ساخت Release در Google Play

بعد از تکمیل بخش‌های اجباری برنامه وارد مسیر موردنظر می‌شیم:

Test and release
→ Testing
→ Internal testing

یا برای انتشار نهایی:

Test and release
→ Production

سپس گزینه ساخت Release جدید رو می‌زنیم، فایل AAB رو آپلود می‌کنیم، هشدارها رو بررسی می‌کنیم و Release Notes رو می‌نویسیم. Google Play بعد از آپلود Bundle، اطلاعاتی مثل دستگاه‌های پشتیبانی‌شده، نسخه، سطح API و فایل‌های تولیدشده رو تحلیل می‌کنه. هر Release می‌تونه در وضعیت Draft، Active یا Archived قرار داشته باشه.

اگر هشدار زیر رو دیدیم:

Version code 1 has already been used

باید Gradle رو تغییر بدیم:

versionCode = 2
versionName = "1.0.1"

بعد دوباره خروجی بگیریم:

./gradlew clean bundleRelease

اگر خطای Package Name دیدیم، باید بررسی کنیم فایل مربوط به Variant درست رو ساخته‌ایم. ممکنه به‌جای نسخه Production، فایل Flavor آزمایشی رو با شناسه زیر بارگذاری کرده باشیم:

com.gheimatban.app.staging

در حالی که برنامه ثبت‌شده این شناسه رو انتظار داره:

com.gheimatban.app

اگر خطای Signing دیدیم، معمولاً Bundle با Upload Key صحیح امضا نشده یا برنامه‌ای که قبلاً ثبت شده از کلید دیگری استفاده می‌کنه.

انتشار در کافه‌بازار

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

بسته‌ای که بارگذاری می‌کنی باید نسخه Release امضاشده باشه، نه APK Debug. اگر پنل برای برنامه تو APK بخواد، فایل مناسب اینه:

app/build/outputs/apk/release/app-release.apk

اگر AAB پذیرفته بشه و بخوای Bundle منتشر کنی، فایل اینه:

app/build/outputs/bundle/release/app-release.aab

کافه‌بازار پشتیبانی از انتشار App Bundle رو اعلام کرده، اما چون قابلیت‌ها و الزامات پنل ممکنه تغییر کنن، انتخاب نهایی رو براساس گزینه‌های فعال در پیشخان انجام بده.

برای توضیح کوتاه بازار می‌تونیم بنویسیم:

با قیمت‌بان، تغییر قیمت محصولات رو دنبال کن و هنگام رسیدن به قیمت دلخواه باخبر شو.

توضیح کامل فارسی هم باید خوانا، دقیق و بدون وعده غیرواقعی باشه. اگر قیمت‌بان قیمت‌ها رو با تأخیر به‌روزرسانی می‌کنه، نباید بنویسیم «تمام قیمت‌های بازار در هر ثانیه به‌صورت قطعی و لحظه‌ای». توضیحات صفحه مارکت بخشی از تعهد محصول به کاربره.

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

بهتره نسخه Google Play و کافه‌بازار از یک Package Name استفاده کنن تا از دید اندروید یک برنامه باشن:

com.gheimatban.app

اما باید با دقت درباره مدل امضا تصمیم بگیری. Google Play در حالت Play App Signing، APK نهایی کاربران رو با App Signing Key خودش امضا می‌کنه. اگر نسخه کافه‌بازار رو با کلید محلی متفاوتی امضا کنی، کاربری که نسخه یک مارکت رو نصب کرده نمی‌تونه نسخه مارکت دیگه رو به‌عنوان آپدیت روی همون نصب دریافت کنه؛ چون امضاها متفاوتن. در بعضی سناریوها این موضوع مهم نیست، چون هر کاربر از همون مارکت خودش آپدیت می‌گیره. اما اگر می‌خوای امکان جابه‌جایی بین مارکت‌ها وجود داشته باشه، باید راهبرد امضا رو از ابتدا آگاهانه طراحی کنی.

یک راه دیگه اینه که برای هر مارکت Application ID متفاوت تعریف کنیم:

android {
    flavorDimensions += "store"

    productFlavors {
        create("googlePlay") {
            dimension = "store"
            applicationId = "com.gheimatban.app"
        }

        create("bazaar") {
            dimension = "store"
            applicationId = "com.gheimatban.app.bazaar"
        }
    }
}

در این حالت دو برنامه مستقل ایجاد میشن و حتی می‌تونن هم‌زمان روی گوشی نصب بشن. اما این کار معایبی هم داره: آمار نصب، Deep Link، Firebase، OAuth، اعلان‌ها، تنظیمات Backend و مدیریت نسخه‌ها از هم جدا میشن. برای بیشتر برنامه‌ها بهتره بدون دلیل جدی، Package Name رو بین مارکت‌ها جدا نکنیم.

می‌تونیم Flavorها رو فقط برای وابستگی‌های مخصوص مارکت استفاده کنیم و Application ID رو یکسان نگه داریم:

android {
    flavorDimensions += "store"

    productFlavors {
        create("googlePlay") {
            dimension = "store"

            buildConfigField(
                "String",
                "STORE_NAME",
                "\"Google Play\""
            )
        }

        create("bazaar") {
            dimension = "store"

            buildConfigField(
                "String",
                "STORE_NAME",
                "\"Cafe Bazaar\""
            )
        }
    }
}

حالا اگر بخوایم صفحه برنامه در مارکت مربوط رو باز کنیم، می‌تونیم براساس Flavor رفتار متفاوتی داشته باشیم:

fun openStorePage(context: Context) {
    val packageName = context.packageName

    val uri = when (BuildConfig.STORE_NAME) {
        "Cafe Bazaar" -> {
            Uri.parse(
                "bazaar://details?id=$packageName"
            )
        }

        else -> {
            Uri.parse(
                "market://details?id=$packageName"
            )
        }
    }

    val intent = Intent(
        Intent.ACTION_VIEW,
        uri
    )

    context.startActivity(intent)
}

برای اینکه در صورت نصب نبودن مارکت برنامه Crash نکنه، نسخه مقاوم‌تری می‌نویسیم:

fun openStorePage(context: Context) {
    val packageName = context.packageName

    val primaryUrl = when (BuildConfig.STORE_NAME) {
        "Cafe Bazaar" -> {
            "bazaar://details?id=$packageName"
        }

        else -> {
            "market://details?id=$packageName"
        }
    }

    val fallbackUrl = when (BuildConfig.STORE_NAME) {
        "Cafe Bazaar" -> {
            "https://cafebazaar.ir/app/$packageName"
        }

        else -> {
            "https://play.google.com/store/apps/details?id=$packageName"
        }
    }

    try {
        context.startActivity(
            Intent(
                Intent.ACTION_VIEW,
                Uri.parse(primaryUrl)
            )
        )
    } catch (exception: ActivityNotFoundException) {
        context.startActivity(
            Intent(
                Intent.ACTION_VIEW,
                Uri.parse(fallbackUrl)
            )
        )
    }
}

انتشار نسخه دوم قیمت‌بان

فرض کن نسخه 1.0.0 منتشر شده و کاربران گزارش دادن بعد از قطع و وصل شدن اینترنت، بعضی قیمت‌ها تازه‌سازی نمیشن. مشکل رو اصلاح کردیم و حالا می‌خوایم نسخه جدید منتشر کنیم. اول نسخه رو افزایش می‌دیم:

defaultConfig {
    versionCode = 2
    versionName = "1.0.1"
}

سپس تست‌ها رو اجرا می‌کنیم:

./gradlew test

اگر تست‌های Instrumented داریم:

./gradlew connectedAndroidTest

بعد خروجی Release رو می‌سازیم:

./gradlew clean bundleRelease
./gradlew assembleRelease

AAB رو برای Google Play و APK یا AAB موردنیاز رو برای کافه‌بازار آپلود می‌کنیم. Release Notes نسخه دوم باید برای کاربر قابل‌فهم باشه:

تغییرات نسخه ۱.۰.۱ • برطرف شدن مشکل به‌روزرسانی قیمت پس از اتصال مجدد اینترنت • بهبود سرعت نمایش فهرست محصولات • رفع چند خطای جزئی

نوشتن عبارت‌هایی مثل «Bug Fix» یا «بهبودهای مختلف» برای تمام نسخه‌ها اطلاعات مفیدی به کاربر نمیده. لازم نیست جزئیات فنی بنویسیم، اما باید مشخص باشه نسخه چه چیزی رو بهتر کرده.

خطاهای رایج زمان انتشار

یکی از خطاهای متداول اینه که نسخه Release به API وصل نمیشه، در حالی که Debug درست کار می‌کنه. معمولاً آدرس BASE_URL اشتباهه، فایل تنظیمات Release ناقصه، Certificate Pinning با گواهی واقعی هماهنگ نیست یا سرور درخواست‌های نسخه نهایی رو رد می‌کنه.

خطای دوم مربوط به Firebaseه. اگر امضای Release با Debug فرق داشته باشه، ممکنه سرویس‌هایی مثل Google Sign-In درست کار نکنن. باید SHA-1 و SHA-256 کلید مربوط رو استخراج کنیم:

keytool -list -v \
-keystore gheimatban-release.jks \
-alias gheimatban

در ویندوز هم دستور به همین صورته و فقط مسیر فایل ممکنه فرق کنه. اثر انگشت‌های لازم رو در تنظیمات Firebase یا سرویس مربوط ثبت می‌کنیم. در Google Play باید توجه کنیم که گواهی App Signing نهایی ممکنه با Upload Key متفاوت باشه؛ پس برای بعضی سرویس‌ها باید SHA گواهی App Signing موجود در Play Console هم اضافه بشه.

خطای سوم، Crash شدن نسخه Release به دلیل R8 هست. در این حالت باید Stack Trace رو با فایل Mapping تحلیل کنیم. فایل Mapping معمولاً در این مسیر ساخته میشه:

app/build/outputs/mapping/release/mapping.txt

این فایل رو برای هر نسخه نگه می‌داریم. اگر Stack Trace مبهم‌شده داشته باشیم، می‌تونیم اون رو Retrace کنیم. حذف این فایل باعث میشه تحلیل Crashهای Obfuscateشده بسیار سخت‌تر بشه.

خطای چهارم، فراموش کردن افزایش versionCode هست. پیش از هر Build انتشار باید این مقدار رو بررسی کنیم. خطای پنجم هم آپلود کردن Variant اشتباهه؛ مثلاً stagingRelease به‌جای productionRelease. نام سرور، Package Name و حتی آیکون برنامه می‌تونه در این دو Variant متفاوت باشه.

چک‌لیست عملی قبل از انتشار

قبل از اینکه نسخه قیمت‌بان رو برای بررسی نهایی بفرستیم، این موارد رو یک‌بار کامل کنترل می‌کنیم:

  • applicationId نهایی و صحیح باشه.
  • versionCode از تمام نسخه‌های قبلی بزرگ‌تر باشه.
  • versionName درست نمایش داده بشه.
  • فایل با Keystore صحیح امضا شده باشه.
  • نسخه Release روی گوشی واقعی نصب و تست شده باشه.
  • ورود، خروج، ثبت‌نام و حذف حساب کار کنن.
  • API نسخه نهایی به سرور Production متصل باشه.
  • Log شامل Token، رمز، شماره تلفن یا پاسخ حساس سرور نباشه.
  • R8 باعث Crash یا حذف مدل‌ها نشده باشه.
  • اعلان‌ها در Foreground و Background کار کنن.
  • Deep Linkها به صفحه درست برن.
  • آیکون و نام Release صحیح باشن.
  • Permission اضافی داخل Merged Manifest وجود نداشته باشه.
  • Privacy Policy با رفتار واقعی برنامه هماهنگ باشه.
  • Data Safety براساس SDKها و داده‌های واقعی تکمیل شده باشه.
  • لینک حذف حساب فعال باشه.
  • اسکرین‌شات‌ها و توضیحات مارکت نهایی شده باشن.
  • mapping.txt نسخه Release ذخیره شده باشه.
  • Keystore و رمزهای اون پشتیبان امن داشته باشن.
  • Release Notes نوشته شده باشه.
  • نسخه ابتدا در مسیر آزمایشی منتشر شده باشه.

جمع‌بندی

انتشار اپلیکیشن صرفاً گرفتن یک APK و گذاشتن اون داخل یک مارکت نیست. ما باید هویت ثابت برنامه رو با applicationId مشخص کنیم، نسخه‌ها رو با versionCode مدیریت کنیم، فایل Release رو با کلید درست امضا کنیم، AAB مناسب Google Play و خروجی مناسب کافه‌بازار رو بسازیم، دقیقاً همون نسخه نهایی رو روی دستگاه واقعی آزمایش کنیم و اطلاعات حریم خصوصی، حذف حساب، داده‌های جمع‌آوری‌شده و صفحه معرفی برنامه رو با رفتار واقعی محصول هماهنگ نگه داریم.

برای انتشار نسخه اول قیمت‌بان، مسیر مطمئن اینه که ابتدا APK Release رو روی چند دستگاه آزمایش کنیم، AAB امضاشده رو در Internal Testing گوگل‌پلی قرار بدیم، تسترها مسیرهای اصلی برنامه رو بررسی کنن، سپس شرایط Closed Testing و Production حساب رو تکمیل کنیم. هم‌زمان نسخه مناسب کافه‌بازار رو در پیشخان بارگذاری می‌کنیم و بعد از بررسی اطلاعات، اون رو برای انتشار می‌فرستیم. از نسخه دوم به بعد هم هر بار versionCode رو افزایش می‌دیم، خروجی تازه می‌سازیم، Release Notes می‌نویسیم و به‌جای انتشار عجولانه برای تمام کاربران، ابتدا نسخه رو روی گروه کوچکی آزمایش می‌کنیم. این فرایند باعث میشه انتشار برنامه از یک اتفاق پرریسک به یک روند کنترل‌شده و قابل‌تکرار تبدیل بشه.

درس شصت‌وسوم

مقیاس‌پذیری و مدیریت کاربران هم‌زمان

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

مقیاس‌پذیری یعنی سیستم با افزایش تعداد کاربران و حجم درخواست‌ها، همچنان بتونه عملکرد قابل‌قبولی داشته باشه. این تعریف به این معنا نیست که یک سرور باید بی‌نهایت درخواست رو تحمل کنه. هیچ سیستم واقعی ظرفیت نامحدود نداره. مسئله اینه که معماری برنامه طوری طراحی بشه که با رشد کاربران، بتونیم ظرفیت رو به‌شکل کنترل‌شده افزایش بدیم و از تولید درخواست‌های غیرضروری جلوگیری کنیم. مدیریت چند هزار کاربر هم‌زمان فقط مسئولیت Backend نیست؛ اپلیکیشن اندروید هم می‌تونه با Cache، Pagination، Debounce، Retry صحیح و همگام‌سازی هوشمند، بخش بزرگی از فشار غیرضروری روی سرور رو کم کنه.

«کاربر هم‌زمان» دقیقاً یعنی چی؟

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

فرض کن ده هزار کاربر تقریباً در یک زمان برنامه رو باز می‌کنن و صفحه اصلی قیمت‌بان هنگام اجرا سه API رو فراخوانی می‌کنه:

GET /profile
GET /tracked-products
GET /notifications/unread-count

در این حالت، ده هزار کاربر می‌تونن در مدت کوتاهی سی هزار درخواست تولید کنن. اگر WorkManager هم هم‌زمان قیمت‌ها رو Sync کنه و هر محصول جداگانه از سرور درخواست بشه، تعداد درخواست‌ها خیلی بیشتر میشه. مثلاً اگر هر کاربر پنجاه محصول ذخیره‌شده داشته باشه و برنامه برای هر محصول یک درخواست بفرسته، ده هزار کاربر می‌تونن پانصد هزار درخواست ایجاد کنن:

10,000 users × 50 products = 500,000 requests

مشکل اصلی همیشه تعداد کاربران نیست؛ گاهی طراحی اشتباه API و رفتار اپلیکیشن باعث میشه یک کاربر به‌اندازه ده‌ها کاربر به سرور فشار وارد کنه. به همین دلیل قبل از خرید سرور قوی‌تر باید بفهمیم درخواست‌ها از کجا تولید میشن و آیا واقعاً ضروری هستن یا نه.

اولین قانون مقیاس‌پذیری: درخواست غیرضروری رو اصلاً ایجاد نکن

بهینه‌ترین درخواست، درخواستی نیست که سرور اون رو در ده میلی‌ثانیه پاسخ بده؛ بهینه‌ترین درخواست، درخواستیه که اصلاً لازم نباشه به سرور ارسال بشه. فرض کن کاربر در صفحه جست‌وجوی قیمت‌بان عبارت «لپ‌تاپ» رو تایپ می‌کنه. اگر با وارد شدن هر حرف فوراً API جست‌وجو فراخوانی بشه، تایپ همین یک کلمه می‌تونه چند درخواست تولید کنه:

ل
لپ
لپ‌
لپ‌ت
لپ‌تا
لپ‌تاپ

پیاده‌سازی ساده ممکنه به این شکل باشه:

fun onSearchTextChanged(query: String) {
    viewModelScope.launch {
        repository.searchProducts(query)
    }
}

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

راه مناسب استفاده از StateFlow، debounce و distinctUntilChanged هست:

class ProductSearchViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    private val searchQuery =
        MutableStateFlow("")

    val searchResult =
        searchQuery
            .map { query ->
                query.trim()
            }
            .debounce(500)
            .distinctUntilChanged()
            .flatMapLatest { query ->
                if (query.length < 2) {
                    flowOf(emptyList())
                } else {
                    repository.searchProducts(query)
                }
            }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    fun onSearchTextChanged(query: String) {
        searchQuery.value = query
    }
}

debounce(500) صبر می‌کنه تا کاربر برای پانصد میلی‌ثانیه تایپ رو متوقف کنه. distinctUntilChanged() اجازه نمیده عبارت یکسان دوباره پردازش بشه و flatMapLatest هم وقتی عبارت جدیدی وارد میشه، دریافت نتیجه قبلی رو کنار می‌گذاره. بنابراین تایپ یک کلمه معمولاً به‌جای شش درخواست، فقط یک درخواست نهایی تولید می‌کنه. این تغییر روی یک گوشی ساده به نظر می‌رسه، اما در مقیاس ده‌ها هزار کاربر می‌تونه میلیون‌ها درخواست اضافی رو حذف کنه.

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

data class PriceUiState(
    val isLoading: Boolean = false,
    val products: List<Product> = emptyList(),
    val errorMessage: String? = null
)
class PriceViewModel(
    private val repository: PriceRepository
) : ViewModel() {

    private val _uiState =
        MutableStateFlow(PriceUiState())

    val uiState =
        _uiState.asStateFlow()

    fun refreshPrices() {
        if (_uiState.value.isLoading) {
            return
        }

        viewModelScope.launch {
            _uiState.update {
                it.copy(
                    isLoading = true,
                    errorMessage = null
                )
            }

            runCatching {
                repository.refreshPrices()
            }.onSuccess { products ->
                _uiState.value = PriceUiState(
                    isLoading = false,
                    products = products
                )
            }.onFailure {
                _uiState.update { state ->
                    state.copy(
                        isLoading = false,
                        errorMessage =
                            "به‌روزرسانی قیمت‌ها انجام نشد."
                    )
                }
            }
        }
    }
}

در رابط Compose هم وضعیت دکمه رو به isLoading متصل می‌کنیم:

Button(
    onClick = viewModel::refreshPrices,
    enabled = !uiState.isLoading
) {
    if (uiState.isLoading) {
        CircularProgressIndicator()
    } else {
        Text("به‌روزرسانی قیمت‌ها")
    }
}

این کنترل فقط برای تجربه کاربری نیست؛ بخشی از حفاظت ظرفیت سرور محسوب میشه.

همه اطلاعات رو یک‌جا دریافت نکن

فرض کن یک کاربر در قیمت‌بان پنج هزار محصول رو دنبال می‌کنه. اگر API تمام محصولات رو در یک پاسخ برگردونه، سرور باید پنج هزار ردیف رو از دیتابیس بخونه، اون‌ها رو تبدیل به JSON کنه و روی شبکه بفرسته. اپلیکیشن هم باید کل پاسخ رو در حافظه نگه داره، Parse کنه و در دیتابیس محلی ذخیره کنه؛ در حالی که کاربر در لحظه فقط چند محصول اول رو می‌بینه.

API نامناسب ممکنه این‌طور طراحی شده باشه:

GET /tracked-products

و بدون محدودیت، همه رکوردها رو برگردونه. API مناسب باید Pagination داشته باشه:

GET /tracked-products?page=1&pageSize=30

یا در سیستم‌های بزرگ‌تر از Cursor استفاده کنه:

GET /tracked-products?cursor=eyJpZCI6MTUwMH0=&limit=30

در Retrofit می‌تونیم Endpoint صفحه‌بندی‌شده تعریف کنیم:

interface ProductApi {

    @GET("tracked-products")
    suspend fun getTrackedProducts(
        @Query("page") page: Int,
        @Query("pageSize") pageSize: Int
    ): ProductPageDto
}
data class ProductPageDto(
    val items: List<ProductDto>,
    val page: Int,
    val pageSize: Int,
    val hasNextPage: Boolean
)

اگر از Paging 3 استفاده کنیم، برنامه فقط صفحه‌های موردنیاز رو دریافت می‌کنه:

class TrackedProductPagingSource(
    private val api: ProductApi
) : PagingSource<Int, Product>() {

    override suspend fun load(
        params: LoadParams<Int>
    ): LoadResult<Int, Product> {
        val page = params.key ?: 1

        return try {
            val response = api.getTrackedProducts(
                page = page,
                pageSize = params.loadSize
            )

            LoadResult.Page(
                data = response.items.map {
                    it.toDomain()
                },
                prevKey = if (page == 1) {
                    null
                } else {
                    page - 1
                },
                nextKey = if (response.hasNextPage) {
                    page + 1
                } else {
                    null
                }
            )
        } catch (exception: Exception) {
            LoadResult.Error(exception)
        }
    }

    override fun getRefreshKey(
        state: PagingState<Int, Product>
    ): Int? {
        val anchorPosition =
            state.anchorPosition ?: return null

        val anchorPage =
            state.closestPageToPosition(
                anchorPosition
            )

        return anchorPage?.prevKey?.plus(1)
            ?: anchorPage?.nextKey?.minus(1)
    }
}

در Repository یک Pager می‌سازیم:

class ProductRepository(
    private val api: ProductApi
) {

    fun getTrackedProducts():
        Flow<PagingData<Product>> {

        return Pager(
            config = PagingConfig(
                pageSize = 30,
                prefetchDistance = 5,
                enablePlaceholders = false
            ),
            pagingSourceFactory = {
                TrackedProductPagingSource(api)
            }
        ).flow
    }
}

و در ViewModel جریان داده رو Cache می‌کنیم:

class ProductListViewModel(
    repository: ProductRepository
) : ViewModel() {

    val products =
        repository
            .getTrackedProducts()
            .cachedIn(viewModelScope)
}

این معماری هم مصرف حافظه اپلیکیشن رو کاهش میده و هم فشار واردشده به API و دیتابیس رو کنترل می‌کنه. البته Pagination فقط زمانی مؤثره که Backend هم Query درست داشته باشه. اگر سرور برای برگرداندن سی محصول، ابتدا تمام پنج هزار رکورد رو بخونه و بعد سی مورد رو جدا کنه، فقط پاسخ شبکه کوچک شده و فشار دیتابیس همچنان باقی مونده.

یک درخواست برای چند محصول، نه یک درخواست برای هر محصول

یکی از اشتباه‌های رایج در اپلیکیشن‌های قیمت و فروشگاه اینه که برای هر آیتم یک API جداگانه فراخوانی بشه:

products.forEach { product ->
    repository.getLatestPrice(product.id)
}

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

POST /products/latest-prices

بدنه درخواست:

{
  "productIds": [101, 102, 103, 104, 105]
}

پاسخ:

{
  "prices": [
    {
      "productId": 101,
      "price": 42500000
    },
    {
      "productId": 102,
      "price": 18600000
    }
  ]
}

در Retrofit:

data class LatestPriceRequest(
    val productIds: List<Long>
)
interface PriceApi {

    @POST("products/latest-prices")
    suspend fun getLatestPrices(
        @Body request: LatestPriceRequest
    ): List<LatestPriceDto>
}
suspend fun loadLatestPrices(
    products: List<Product>
): List<LatestPrice> {

    if (products.isEmpty()) {
        return emptyList()
    }

    return priceApi
        .getLatestPrices(
            LatestPriceRequest(
                productIds = products.map {
                    it.id
                }
            )
        )
        .map {
            it.toDomain()
        }
}

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

suspend fun loadLatestPrices(
    productIds: List<Long>
): List<LatestPrice> {

    return productIds
        .chunked(100)
        .flatMap { chunk ->
            priceApi
                .getLatestPrices(
                    LatestPriceRequest(chunk)
                )
                .map {
                    it.toDomain()
                }
        }
}

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

Cache فقط برای سرعت نیست؛ برای حفظ ظرفیت سیستمه

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

DAO محصولات:

@Dao
interface ProductDao {

    @Query(
        """
        SELECT *
        FROM tracked_products
        ORDER BY updatedAt DESC
        """
    )
    fun observeProducts():
        Flow<List<ProductEntity>>

    @Insert(
        onConflict = OnConflictStrategy.REPLACE
    )
    suspend fun insertAll(
        products: List<ProductEntity>
    )

    @Query(
        """
        DELETE FROM tracked_products
        WHERE id NOT IN (:activeIds)
        """
    )
    suspend fun deleteMissing(
        activeIds: List<Long>
    )
}

Repository می‌تونه دیتابیس محلی رو منبع اصلی رابط کاربری قرار بده:

class ProductRepository(
    private val api: ProductApi,
    private val productDao: ProductDao,
    private val database: GheimatBanDatabase
) {

    fun observeProducts():
        Flow<List<Product>> {

        return productDao
            .observeProducts()
            .map { entities ->
                entities.map {
                    it.toDomain()
                }
            }
    }

    suspend fun syncProducts() {
        val remoteProducts =
            api.getTrackedProducts(
                page = 1,
                pageSize = 100
            ).items

        database.withTransaction {
            productDao.insertAll(
                remoteProducts.map {
                    it.toEntity()
                }
            )

            productDao.deleteMissing(
                remoteProducts.map {
                    it.id
                }
            )
        }
    }
}

وقتی صفحه باز میشه، ViewModel منتظر Network نمی‌مونه:

class ProductViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    val products =
        repository
            .observeProducts()
            .stateIn(
                scope = viewModelScope,
                started =
                    SharingStarted
                        .WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    init {
        viewModelScope.launch {
            runCatching {
                repository.syncProducts()
            }
        }
    }
}

اما Cache نباید به این معنا باشه که در هر بار باز شدن صفحه، بدون توجه به زمان آخرین Sync دوباره شبکه رو صدا بزنیم. می‌تونیم برای داده‌ها زمان اعتبار در نظر بگیریم:

class SyncPolicy(
    private val preferences:
        SharedPreferences
) {

    fun shouldSync(
        maxAgeMillis: Long
    ): Boolean {
        val lastSync =
            preferences.getLong(
                "products_last_sync",
                0L
            )

        return System.currentTimeMillis() -
            lastSync > maxAgeMillis
    }

    fun markSynced() {
        preferences
            .edit()
            .putLong(
                "products_last_sync",
                System.currentTimeMillis()
            )
            .apply()
    }
}

استفاده در Repository:

suspend fun syncProductsIfNeeded() {
    val fiveMinutes =
        5 * 60 * 1000L

    if (!syncPolicy.shouldSync(fiveMinutes)) {
        return
    }

    syncProducts()
    syncPolicy.markSynced()
}

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

جلوگیری از درخواست‌های هم‌زمان تکراری

گاهی چند بخش برنامه هم‌زمان یک داده مشترک رو درخواست می‌کنن. مثلاً صفحه اصلی، Notification Worker و صفحه جزئیات هر سه می‌خوان پروفایل کاربر رو تازه‌سازی کنن. اگر Repository هیچ کنترلی نداشته باشه، سه درخواست یکسان ارسال میشه. می‌تونیم با Mutex مطمئن بشیم در هر لحظه فقط یک Sync اجرا میشه:

class UserRepository(
    private val api: UserApi,
    private val userDao: UserDao
) {

    private val syncMutex = Mutex()

    suspend fun syncUser() {
        syncMutex.withLock {
            val user =
                api.getCurrentUser()

            userDao.insert(
                user.toEntity()
            )
        }
    }
}

اما این کد باعث میشه درخواست دوم منتظر پایان درخواست اول بمونه و بعد دوباره همون Sync رو اجرا کنه. برای جلوگیری کامل از اجرای تکراری، می‌تونیم زمان آخرین Sync رو هم بررسی کنیم یا یک Deferred مشترک نگه داریم:

class UserRepository(
    private val api: UserApi,
    private val userDao: UserDao,
    private val externalScope:
        CoroutineScope
) {

    private val requestMutex = Mutex()

    private var activeRequest:
        Deferred<User>? = null

    suspend fun getFreshUser(): User {
        val request =
            requestMutex.withLock {
                activeRequest
                    ?.takeIf {
                        it.isActive
                    }
                    ?: externalScope
                        .async {
                            api
                                .getCurrentUser()
                                .toDomain()
                        }
                        .also {
                            activeRequest = it
                        }
            }

        return try {
            request.await().also {
                userDao.insert(
                    it.toEntity()
                )
            }
        } finally {
            requestMutex.withLock {
                if (
                    activeRequest === request &&
                    request.isCompleted
                ) {
                    activeRequest = null
                }
            }
        }
    }
}

در این مدل اگر سه بخش برنامه هم‌زمان getFreshUser() رو اجرا کنن، هر سه منتظر یک درخواست مشترک می‌مونن.

Retry اشتباه می‌تونه سرور نیمه‌خراب رو کاملاً از کار بندازه

وقتی درخواست شکست می‌خوره، اولین واکنش خیلی از برنامه‌نویس‌ها اینه که چند بار سریع دوباره تلاش کنن:

repeat(3) {
    runCatching {
        api.getPrices()
    }
}

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

یک تابع Exponential Backoff می‌تونه این‌طور نوشته بشه:

suspend fun <T> retryWithBackoff(
    maxAttempts: Int = 4,
    initialDelayMillis: Long = 1_000,
    maxDelayMillis: Long = 10_000,
    block: suspend () -> T
): T {
    var currentDelay =
        initialDelayMillis

    repeat(maxAttempts - 1) {
        try {
            return block()
        } catch (
            exception:
            IOException
        ) {
            delay(currentDelay)

            currentDelay =
                (currentDelay * 2)
                    .coerceAtMost(
                        maxDelayMillis
                    )
        }
    }

    return block()
}

استفاده:

val products =
    retryWithBackoff {
        productApi
            .getTrackedProducts(
                page = 1,
                pageSize = 30
            )
    }

فاصله تلاش‌ها تقریباً به این شکل میشه:

تلاش اول: بلافاصله تلاش دوم: بعد از ۱ ثانیه تلاش سوم: بعد از ۲ ثانیه تلاش چهارم: بعد از ۴ ثانیه

در سیستم‌های بزرگ بهتره کمی مقدار تصادفی یا Jitter هم به تأخیر اضافه بشه. دلیلش اینه که اگر ده هزار گوشی دقیقاً هم‌زمان درخواست ناموفق دریافت کنن و همگی دقیقاً یک ثانیه بعد Retry کنن، یک موج تازه از ده هزار درخواست ایجاد میشه. با Jitter، زمان Retry دستگاه‌ها پخش میشه:

suspend fun <T> retryWithJitter(
    maxAttempts: Int = 4,
    initialDelayMillis: Long = 1_000,
    block: suspend () -> T
): T {
    var currentDelay =
        initialDelayMillis

    repeat(maxAttempts - 1) {
        try {
            return block()
        } catch (
            exception:
            IOException
        ) {
            val jitter =
                Random.nextLong(
                    0,
                    currentDelay / 2 + 1
                )

            delay(
                currentDelay + jitter
            )

            currentDelay =
                (currentDelay * 2)
                    .coerceAtMost(30_000)
        }
    }

    return block()
}

همه خطاها هم نباید Retry بشن. خطای 401 معمولاً به Token مربوطه، 403 یعنی دسترسی وجود نداره، 404 ممکنه منبع حذف شده باشه و بسیاری از خطاهای 400 با تلاش دوباره حل نمیشن. Retry بیشتر برای خطاهای شبکه، Timeout و بعضی پاسخ‌های موقت 5xx مناسبه.

WorkManager رو برای همه کاربران در یک زمان اجرا نکن

فرض کن قیمت‌بان هر روز ساعت ۸ صبح قیمت محصولات رو Sync کنه. اگر صد هزار دستگاه دقیقاً ساعت ۸ درخواست بفرستن، یک Traffic Spike شدید ایجاد میشه. حتی اگر میانگین ظرفیت سرور کافی باشه، همین هجوم کوتاه می‌تونه سیستم رو از کار بندازه.

به‌جای زمان‌بندی کاملاً یکسان، بهتره اجرای کار در بازه زمانی پخش بشه. WorkManager ذاتاً اجرای دقیق ثانیه‌ای رو تضمین نمی‌کنه، اما می‌تونیم برای اولین اجرای One-Time Work تأخیر تصادفی تعیین کنیم:

fun schedulePriceSync(
    context: Context
) {
    val randomDelayMinutes =
        Random.nextLong(
            from = 0,
            until = 60
        )

    val constraints =
        Constraints.Builder()
            .setRequiredNetworkType(
                NetworkType.CONNECTED
            )
            .build()

    val request =
        OneTimeWorkRequestBuilder<
            PriceSyncWorker
        >()
            .setConstraints(constraints)
            .setInitialDelay(
                randomDelayMinutes,
                TimeUnit.MINUTES
            )
            .build()

    WorkManager
        .getInstance(context)
        .enqueueUniqueWork(
            "price_sync",
            ExistingWorkPolicy.KEEP,
            request
        )
}

enqueueUniqueWork هم مانع میشه چند بخش برنامه چند Work یکسان ایجاد کنن. برای کار دوره‌ای:

val request =
    PeriodicWorkRequestBuilder<
        PriceSyncWorker
    >(
        6,
        TimeUnit.HOURS
    )
        .setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(
                    NetworkType.CONNECTED
                )
                .build()
        )
        .setBackoffCriteria(
            BackoffPolicy.EXPONENTIAL,
            30,
            TimeUnit.SECONDS
        )
        .build()

WorkManager
    .getInstance(context)
    .enqueueUniquePeriodicWork(
        "periodic_price_sync",
        ExistingPeriodicWorkPolicy.KEEP,
        request
    )

کار Background نباید بدون نیاز همه اطلاعات رو دریافت کنه. اگر سرور می‌تونه فقط تغییرات بعد از آخرین Sync رو برگردونه، باید زمان یا Cursor آخرین همگام‌سازی ارسال بشه:

GET /price-changes?since=2026-07-19T08:30:00Z

به این مدل Delta Sync گفته میشه. به‌جای دریافت دوباره هزاران رکورد، فقط داده‌های تغییرکرده منتقل میشن.

Polling دائمی یا Push؟

اگر اپلیکیشن هر سی ثانیه از سرور بپرسه «قیمت تغییر کرده؟»، حتی وقتی هیچ تغییری وجود نداره درخواست تولید میشه:

while (isActive) {
    repository.refreshPrices()
    delay(30_000)
}

اگر ده هزار کاربر آنلاین باشن، این طراحی حدود ۳۳۳ درخواست در هر ثانیه فقط برای پرسیدن یک سؤال تکراری تولید می‌کنه:

10,000 ÷ 30 ≈ 333 requests per second

در بسیاری از سناریوها بهتره Backend هنگام وقوع تغییر، از FCM برای کاربر اعلان بفرسته. برنامه بعد از دریافت اعلان می‌تونه فقط اطلاعات مرتبط رو به‌روزرسانی کنه. برای داده‌هایی که باید لحظه‌ای داخل صفحه نمایش داده بشن، WebSocket یا Socket.IO ممکنه مناسب باشه، اما اتصال دائمی هم رایگان نیست. هر اتصال حافظه، File Descriptor و ظرفیت شبکه مصرف می‌کنه. نباید فقط به‌خاطر جذاب بودن فناوری، برای صفحه‌ای که هر چند دقیقه تغییر می‌کنه WebSocket بسازیم.

برای قیمت‌بان معماری منطقی می‌تونه این باشه:

تغییر قیمت مهم و خارج از برنامه: FCM Notification به‌روزرسانی معمولی فهرست: API + Cache + Delta Sync نمایش تغییرات بسیار سریع در صفحه باز: WebSocket

Rate Limiting؛ هر کاربر نباید ظرفیت نامحدود داشته باشه

حتی اگر اپلیکیشن اندروید درست طراحی شده باشه، نباید Backend به هر Client اعتماد کامل داشته باشه. کاربر می‌تونه نسخه دستکاری‌شده برنامه بسازه، Script بنویسه یا یک Endpoint رو هزاران بار فراخوانی کنه. سرور باید برای Endpointهای حساس Rate Limit داشته باشه.

برای مثال:

جست‌وجو: حداکثر ۳۰ درخواست در دقیقه برای هر کاربر ورود: حداکثر ۵ تلاش در ۱۰ دقیقه ارسال کد تأیید: حداکثر ۳ درخواست در ساعت به‌روزرسانی دستی قیمت: حداکثر ۱۰ درخواست در دقیقه

اگر محدودیت رد بشه، سرور معمولاً پاسخ 429 Too Many Requests میده و ممکنه هدر Retry-After رو هم ارسال کنه:

HTTP/1.1 429 Too Many Requests
Retry-After: 60

اپلیکیشن نباید در پاسخ به 429 فوراً Retry کنه:

class RateLimitInterceptor :
    Interceptor {

    override fun intercept(
        chain: Interceptor.Chain
    ): Response {
        val response =
            chain.proceed(
                chain.request()
            )

        if (response.code == 429) {
            val retryAfter =
                response.header(
                    "Retry-After"
                )

            Log.w(
                "RateLimit",
                "Retry after: $retryAfter"
            )
        }

        return response
    }
}

در سطح Repository بهتره وضعیت قابل‌فهمی به UI برگرده:

sealed interface RefreshResult {

    data class Success(
        val products: List<Product>
    ) : RefreshResult

    data class RateLimited(
        val retryAfterSeconds: Long?
    ) : RefreshResult

    data class Failure(
        val message: String
    ) : RefreshResult
}
suspend fun refreshProducts():
    RefreshResult {

    val response =
        api.getProductsResponse()

    return when {
        response.isSuccessful -> {
            RefreshResult.Success(
                response.body()
                    .orEmpty()
                    .map {
                        it.toDomain()
                    }
            )
        }

        response.code() == 429 -> {
            RefreshResult.RateLimited(
                retryAfterSeconds =
                    response.headers()
                        ["Retry-After"]
                        ?.toLongOrNull()
            )
        }

        else -> {
            RefreshResult.Failure(
                "دریافت اطلاعات انجام نشد."
            )
        }
    }
}

سمت سرور کجا گلوگاه ایجاد میشه؟

اپلیکیشن اندروید فقط بخشی از ماجراست. اگر Backend برای هر درخواست یک Query سنگین اجرا کنه، Cache نداشته باشه یا تعداد اتصال‌های دیتابیس مدیریت نشده باشه، کاهش درخواست‌های Client هم به‌تنهایی کافی نیست. معمولاً گلوگاه در یکی از این بخش‌ها ایجاد میشه:

CPU: تبدیل داده، رمزنگاری سنگین، پردازش تصویر یا الگوریتم‌های پرهزینه CPU رو اشغال می‌کنن.
RAM: نگهداری پاسخ‌های بزرگ، Sessionهای زیاد یا Memory Leak باعث پر شدن حافظه میشه.
Database: Query بدون Index، Join سنگین، Lock طولانی و اتصال‌های زیاد معمولاً یکی از اصلی‌ترین عوامل کندی هستن.
Network: پاسخ‌های حجیم، تصاویر بدون فشرده‌سازی و انتقال داده‌های تکراری پهنای باند رو مصرف می‌کنن.
سرویس خارجی: ممکنه خود API سریع باشه، اما برای هر درخواست منتظر یک سرویس پرداخت، پیامک یا قیمت‌گیری خارجی بمونه.

فرض کن Endpoint صفحه محصولات برای هر محصول یک Query جدا اجرا کنه. ابتدا سی محصول گرفته میشه و بعد برای قیمت هر محصول یک Query جدا اجرا میشه:

1 Query برای محصولات
30 Query برای قیمت‌ها

این مشکل به N+1 Query معروفه. یک درخواست کاربر به ۳۱ Query تبدیل میشه. اگر هزار درخواست در دقیقه داشته باشیم، دیتابیس با سی‌ویک هزار Query روبه‌رو میشه. راه‌حل معمول استفاده از Join، Batch Query یا Precomputed Data هست.

Cache سمت سرور و Redis

بعضی داده‌ها برای هزاران کاربر یکسان هستن. مثلاً فهرست دسته‌بندی‌ها، اطلاعات عمومی فروشگاه‌ها یا قیمت یک محصول محبوب ممکنه در مدت کوتاهی بارها درخواست بشه. اجرای Query یکسان برای هر کاربر اتلاف منابعه. Backend می‌تونه نتیجه رو چند ثانیه یا چند دقیقه داخل Redis نگه داره:

Request 1:
Database → Redis → Response

Request 2 تا 1000:
Redis → Response

Cache نباید بدون سیاست باشه. باید مشخص کنیم:

Cache Key چیست؟ داده تا چه مدت معتبره؟ پس از تغییر قیمت چطور Cache حذف میشه؟ اگر Redis در دسترس نبود چه اتفاقی می‌افته؟

برای مثال کلید قیمت محصول:

product:101:latest-price

زمان اعتبار:

TTL = 60 seconds

وقتی قیمت محصول تغییر می‌کنه، Backend می‌تونه Cache مربوط رو حذف یا مقدار تازه رو جایگزین کنه. این مدل باعث میشه هزاران درخواست خواندن، به‌جای دیتابیس اصلی از حافظه سریع Redis پاسخ داده بشن.

Load Balancer و چند سرور

اگر یک Backend به سقف ظرفیت خودش رسیده باشه، دو راه کلی داریم. در Vertical Scaling منابع همون سرور رو افزایش می‌دیم؛ مثلاً CPU رو از چهار هسته به شانزده هسته و RAM رو از هشت گیگابایت به سی‌ودو گیگابایت می‌رسونیم. این روش ساده‌ست، اما محدودیت داره و یک خرابی می‌تونه کل سرویس رو از دسترس خارج کنه.

در Horizontal Scaling چند نمونه از برنامه سرور اجرا می‌کنیم:

Client Requests
       ↓
Load Balancer
   ↓    ↓    ↓
Server 1
Server 2
Server 3

Load Balancer درخواست‌ها رو بین سرورها تقسیم می‌کنه. برای اینکه این معماری درست کار کنه، Backend بهتره Stateless باشه؛ یعنی اطلاعات ضروری Session فقط در حافظه یک سرور خاص نگهداری نشه. اگر درخواست ورود به Server 1 و درخواست بعدی به Server 3 بره، Server 3 هم باید بتونه کاربر رو شناسایی کنه. Tokenهای قابل‌اعتبارسنجی، Redis Session Store یا دیتابیس مشترک می‌تونن این مشکل رو حل کنن.

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

Queue برای کارهای سنگین و غیرضروری در پاسخ فوری

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

بهتره عملیات اصلی سریع انجام بشه و کارهای سنگین وارد Queue بشن:

POST /tracked-products

ذخیره درخواست

202 Accepted

Queue

Price Worker
Image Worker
Notification Worker

در این مدل API می‌تونه پاسخ بده:

{
  "id": 8472,
  "status": "processing"
}

و اپلیکیشن وضعیت پردازش رو نمایش بده:

enum class TrackingStatus {
    PROCESSING,
    ACTIVE,
    FAILED
}

صف‌هایی مثل RabbitMQ، Kafka، SQS یا ابزارهای Job Queue باعث میشن کارهای سنگین با سرعت کنترل‌شده پردازش بشن. اگر ناگهان ده هزار محصول ثبت بشه، درخواست‌ها گم نمیشن؛ داخل صف قرار می‌گیرن و Workerها به‌اندازه ظرفیت سیستم اون‌ها رو انجام میدن.

Timeout و لغو درخواست

هیچ درخواست شبکه‌ای نباید بی‌نهایت منتظر بمونه. اگر سرور پاسخ نمیده، اتصال‌های معلق هم منابع گوشی و هم منابع سرور رو مصرف می‌کنن. در OkHttp باید Timeoutهای معقول داشته باشیم:

val okHttpClient =
    OkHttpClient.Builder()
        .connectTimeout(
            10,
            TimeUnit.SECONDS
        )
        .readTimeout(
            20,
            TimeUnit.SECONDS
        )
        .writeTimeout(
            20,
            TimeUnit.SECONDS
        )
        .callTimeout(
            30,
            TimeUnit.SECONDS
        )
        .build()

اعداد مناسب به نوع API بستگی دارن. Endpoint دریافت چند داده متنی نباید یک دقیقه معطل بمونه، اما Upload فایل ممکنه به زمان بیشتری نیاز داشته باشه. همچنین وقتی کاربر از صفحه خارج میشه، درخواست غیرضروری باید لغو بشه. استفاده از viewModelScope و Retrofit Coroutine معمولاً باعث میشه با پاک شدن ViewModel، Coroutine و Call مربوط هم لغو بشن.

نباید عملیات مهمی مثل ثبت پرداخت رو فقط به‌خاطر بسته شدن صفحه بدون طراحی درست لغو کنیم. برای عملیات حساس باید Idempotency و وضعیت سمت سرور در نظر گرفته بشه.

Idempotency؛ یک درخواست تکراری نباید دو عملیات ایجاد کنه

فرض کن کاربر دکمه «دنبال کردن محصول» رو می‌زنه، درخواست به سرور می‌رسه اما پاسخ در مسیر شبکه گم میشه. اپلیکیشن تصور می‌کنه عملیات شکست خورده و دوباره درخواست رو ارسال می‌کنه. اگر Backend برای هر درخواست یک رکورد تازه بسازه، محصول دو بار ثبت میشه.

برای عملیات حساس می‌تونیم یک Idempotency Key ایجاد کنیم:

interface TrackingApi {

    @POST("tracked-products")
    suspend fun trackProduct(
        @Header("Idempotency-Key")
        idempotencyKey: String,

        @Body
        request: TrackProductRequest
    ): TrackProductResponse
}
suspend fun trackProduct(
    productUrl: String
): TrackProductResponse {

    val requestId =
        UUID.randomUUID().toString()

    return api.trackProduct(
        idempotencyKey = requestId,
        request = TrackProductRequest(
            url = productUrl
        )
    )
}

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

چطور بفهمیم سیستم واقعاً ظرفیت چند کاربر رو داره؟

با حدس زدن نمی‌تونیم بگیم «این سرور ده هزار کاربر رو جواب میده». باید Load Test انجام بشه. در تست بار، تعداد مشخصی Client مجازی Endpointهای واقعی رو فراخوانی می‌کنن و معیارهایی مثل این‌ها اندازه‌گیری میشن:

Requests Per Second
Average Response Time
P95 Latency
P99 Latency
Error Rate
CPU Usage
Memory Usage
Database Connections
Queue Length

میانگین زمان پاسخ به‌تنهایی کافی نیست. ممکنه میانگین ۲۰۰ میلی‌ثانیه باشه، اما پنج درصد کاربران بیشتر از پنج ثانیه منتظر بمونن. P95 = 5s یعنی ۹۵ درصد درخواست‌ها در کمتر از پنج ثانیه پاسخ گرفتن و پنج درصد کندتر بودن. در سیستم واقعی همین پنج درصد می‌تونن هزاران کاربر ناراضی باشن.

Load Test باید روی محیطی شبیه Production و با داده‌ای نزدیک به حجم واقعی انجام بشه. Queryای که روی جدول هزارردیفی سریع اجرا میشه، ممکنه روی جدول صد میلیون ردیفی رفتار کاملاً متفاوتی داشته باشه.

یک معماری منطقی برای قیمت‌بان

برای نسخه واقعی قیمت‌بان می‌تونیم جریان اطلاعات رو این‌طور طراحی کنیم:

Android App

Local Room Cache

API Gateway / Load Balancer

Backend Instances

Redis Cache

Primary Database

Queue and Workers

Price Crawlers / Notification Service

اپلیکیشن ابتدا داده‌های Room رو نمایش میده و فقط در صورت نیاز Sync می‌کنه. درخواست‌های جست‌وجو Debounce میشن، فهرست‌ها با Paging دریافت میشن و قیمت چند محصول به‌صورت Batch گرفته میشه. Load Balancer درخواست‌ها رو بین چند Backend تقسیم می‌کنه. Backend داده‌های پرتکرار رو از Redis می‌خونه و عملیات سنگین رو وارد Queue می‌کنه. دیتابیس Index مناسب و Connection Pool محدود داره. اعلان تغییر قیمت از طریق FCM ارسال میشه و برنامه برای فهمیدن تغییر، دائماً Polling انجام نمیده.

در چنین معماری‌ای هر لایه بخشی از فشار رو کنترل می‌کنه. اگر فقط یک VPS بسیار قوی بخریم اما اپلیکیشن برای هر محصول درخواست جدا بفرسته، مشکل دیر یا زود برمی‌گرده. اگر فقط Cache سمت اندروید داشته باشیم اما Backend برای هر درخواست Queryهای سنگین اجرا کنه، باز هم سیستم شکننده می‌مونه. مقیاس‌پذیری نتیجه یک تکنیک جادویی نیست؛ حاصل حذف کارهای تکراری، محدود کردن مصرف منابع و جدا کردن مسئولیت‌ها در تمام مسیر Client تا Database هست.

چک‌لیست مدیریت کاربران هم‌زمان

قبل از اینکه ادعا کنیم قیمت‌بان برای تعداد زیادی کاربر آماده‌ست، باید این موارد رو بررسی کنیم:

  • جست‌وجو با هر حرف یک درخواست ایجاد نکنه.
  • لمس چندباره دکمه، درخواست‌های موازی تکراری نسازه.
  • فهرست‌های بزرگ Pagination داشته باشن.
  • برای هر آیتم یک درخواست جدا ارسال نشه.
  • داده‌های قابل‌استفاده داخل Room Cache بشن.
  • Cache زمان اعتبار مشخص داشته باشه.
  • Syncهای یکسان با Mutex یا Unique Work کنترل بشن.
  • Retry فقط برای خطاهای موقت انجام بشه.
  • Retry از Exponential Backoff و Jitter استفاده کنه.
  • کارهای پس‌زمینه همه دستگاه‌ها هم‌زمان شروع نشن.
  • تغییرات کوچک با Delta Sync دریافت بشن.
  • برای اعلان تغییرات از Push استفاده بشه، نه Polling بی‌وقفه.
  • Backend برای Endpointهای حساس Rate Limit داشته باشه.
  • پاسخ 429 در اپلیکیشن درست مدیریت بشه.
  • Timeoutهای شبکه مشخص باشن.
  • عملیات تکرارناپذیر Idempotency Key داشته باشن.
  • APIهای سنگین وارد Queue بشن.
  • Queryهای دیتابیس Index مناسب داشته باشن.
  • Cache سمت سرور برای داده‌های پرتکرار وجود داشته باشه.
  • سیستم با Load Test واقعی آزمایش شده باشه.
  • زمان پاسخ P95 و P99 اندازه‌گیری بشه، نه فقط میانگین.
  • CPU، RAM، اتصال‌های دیتابیس و نرخ خطا مانیتور بشن.

جمع‌بندی

مدیریت چند هزار کاربر هم‌زمان از جایی شروع نمیشه که سرور رو بزرگ‌تر کنیم؛ از جایی شروع میشه که بفهمیم هر کاربر چند درخواست تولید می‌کنه و کدوم درخواست‌ها اصلاً لازم نیستن. در قیمت‌بان با Debounce جست‌وجو، جلوگیری از کلیک تکراری، Batch Request، Paging، Cache محلی و Delta Sync می‌تونیم فشار ایجادشده توسط اپلیکیشن رو به‌شدت کاهش بدیم. با Retry کنترل‌شده، Exponential Backoff، Jitter و مدیریت پاسخ 429 هم اجازه نمیدیم اختلال موقت سرور به موجی از درخواست‌های تازه تبدیل بشه.

در سمت Backend هم مقیاس‌پذیری به معنای افزودن بی‌پایان سرور نیست. Queryهای دیتابیس باید بهینه باشن، داده‌های پرتکرار Cache بشن، عملیات سنگین وارد Queue بشن و درخواست‌ها در صورت نیاز بین چند نمونه Backend تقسیم بشن. بعد از همه این تغییرات هم باید سیستم رو زیر بار واقعی آزمایش کنیم؛ چون بدون اندازه‌گیری، هر عددی درباره ظرفیت سیستم فقط حدسه. معماری حرفه‌ای کاری می‌کنه که با افزایش کاربران، به‌جای فروپاشی ناگهانی سیستم، بتونیم گلوگاه رو شناسایی کنیم و ظرفیت همون بخش رو به‌شکل کنترل‌شده افزایش بدیم.


درس شصت‌وچهارم

Push حرفه‌ای؛ از ارسال یک اعلان ساده تا ساخت سیستم اعلان واقعی

در درس‌های قبلی با Firebase Cloud Messaging آشنا شدیم، Token دستگاه رو گرفتیم، FirebaseMessagingService ساختیم و یک اعلان ساده نمایش دادیم. اما چیزی که در یک پروژه آزمایشی به اسم Push Notification می‌شناسیم، با چیزی که در یک برنامه واقعی مثل قیمت‌بان نیاز داریم تفاوت زیادی داره. در نسخه ساده، سرور یک عنوان و متن می‌فرسته و گوشی هم یک اعلان نمایش میده. در نسخه حرفه‌ای باید مشخص باشه اعلان برای کدوم کاربر و کدوم دستگاهه، با لمس اعلان چه صفحه‌ای باز میشه، اگر کاربر چند دستگاه داشته باشه چه اتفاقی می‌افته، Token عوض‌شده چطور به سرور می‌رسه، اعلان تکراری چطور حذف میشه و اگر دستگاه چند ساعت آفلاین بود، آیا اعلان قدیمی هنوز ارزش نمایش دادن داره یا نه.

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

Push فقط یک اعلان نیست

اول باید سه مفهوم رو از هم جدا کنیم: پیام Push، اعلان سیستم و رویداد داخل برنامه. پیام Push داده‌ایه که از سرور به FCM و سپس به یک نمونه نصب‌شده از برنامه می‌رسه. اعلان سیستم همون چیزی‌ست که کاربر در Notification Shade می‌بینه. رویداد داخل برنامه هم تغییریه که بعد از دریافت پیام در داده یا رابط کاربری اتفاق می‌افته. این سه مورد می‌تونن با هم استفاده بشن، اما یکی نیستن.

برای مثال، سرور می‌تونه فقط به برنامه بگه اطلاعات محصول شماره ۸۴۷۲ تغییر کرده:

{
  "type": "PRICE_CHANGED",
  "productId": "8472"
}

برنامه بعد از دریافت پیام، اطلاعات تازه رو از API می‌گیره و Room رو به‌روزرسانی می‌کنه، بدون اینکه الزاماً چیزی به کاربر نمایش بده. در سناریوی دیگه، سرور می‌تونه اطلاعات لازم برای ساخت اعلان رو هم ارسال کنه:

{
  "type": "PRICE_DROP",
  "eventId": "evt_986215",
  "productId": "8472",
  "title": "قیمت لپ‌تاپ کاهش پیدا کرد",
  "body": "قیمت از ۴۲٬۵۰۰٬۰۰۰ به ۳۹٬۹۰۰٬۰۰۰ تومان رسید.",
  "imageUrl": "https://cdn.gheimatban.com/products/8472.jpg"
}

در این حالت برنامه می‌تونه بلافاصله اعلان رو بسازه و بعد در پس‌زمینه اطلاعات کامل محصول رو Sync کنه. این طراحی معمولاً از مدلی مطمئن‌تره که برنامه ابتدا یک پیام خالی دریافت کنه، بعد منتظر پاسخ سرور بمونه و فقط در صورت موفق بودن درخواست شبکه اعلان رو نمایش بده؛ چون onMessageReceived() زمان محدودی برای پردازش داره و انجام کار طولانی یا تماس شبکه‌ای داخل اون می‌تونه به اعلان ناقص یا ازدست‌رفته منجر بشه. برای کارهای طولانی‌تر باید از WorkManager استفاده کنیم.

Notification Message یا Data Message؟

FCM دو مدل اصلی پیام داره. Notification Message دارای فیلدهای استانداردی مثل title و body هست و در زمانی که برنامه در پس‌زمینه قرار داره، SDK می‌تونه اون رو خودکار در Notification Tray نمایش بده. Data Message فقط شامل کلیدها و مقادیریه که خودمون تعریف کرده‌ایم و مسئولیت پردازش کاملش با برنامه‌ست. هر دو مدل در حالت معمول حداکثر ۴۰۹۶ بایت Payload دارن؛ بنابراین Push جای انتقال اطلاعات سنگین یا تصویر نیست.

یک Notification Message ساده چنین ساختاری داره:

{
  "message": {
    "token": "DEVICE_TOKEN",
    "notification": {
      "title": "کاهش قیمت",
      "body": "یکی از محصولات شما ارزان‌تر شده."
    }
  }
}

یک Data Message به این شکل ارسال میشه:

{
  "message": {
    "token": "DEVICE_TOKEN",
    "data": {
      "type": "PRICE_DROP",
      "eventId": "evt_986215",
      "productId": "8472",
      "title": "کاهش قیمت",
      "body": "قیمت محصول به ۳۹٬۹۰۰٬۰۰۰ تومان رسید."
    }
  }
}

تفاوت این دو فقط در ظاهر JSON نیست. رفتار دریافت پیام بر اساس وضعیت برنامه تغییر می‌کنه. وقتی برنامه در Foreground قرار داره، پیام‌های Notification، Data و ترکیبی وارد onMessageReceived() میشن. اما وقتی برنامه در Background قرار گرفته، Notification Message معمولاً به‌صورت خودکار توسط سیستم نمایش داده میشه و onMessageReceived() برای نمایش اون فراخوانی نمیشه. اگر پیام هم‌زمان بخش notification و data داشته باشه، در حالت Background اعلان توسط سیستم نمایش داده میشه و Data معمولاً وقتی کاربر اعلان رو لمس می‌کنه از طریق Intent ورودی Activity در دسترس قرار می‌گیره.

همین رفتار باعث میشه برای یک سیستم حرفه‌ای، استفاده بی‌برنامه از پیام ترکیبی دردسرساز بشه. ممکنه در Foreground اعلان رو با کد خودمون بسازیم، اما در Background ظاهر اعلان رو FCM تعیین کنه. در نتیجه Channel، آیکون، Group، رفتار کلیک و منطق حذف اعلان در دو وضعیت متفاوت میشه. برای قیمت‌بان که می‌خوایم روی ظاهر، مقصد، گروه‌بندی، شناسه اعلان و منطق تکراری نبودن کنترل کامل داشته باشیم، Data Message معمولاً انتخاب مناسب‌تریه.

البته Data Message به معنی تضمین اجرای نامحدود کد در پس‌زمینه نیست. اندروید همچنان محدودیت‌های Background و Doze رو اعمال می‌کنه. بنابراین باید Payload برای نمایش فوری اعلان کافی باشه و کارهای سنگین‌تر به WorkManager سپرده بشن.

قرارداد پیام رو قبل از نوشتن کد طراحی می‌کنیم

یکی از اشتباه‌های رایج اینه که Backend هر بار چند فیلد تصادفی ارسال کنه و اپلیکیشن هم با مجموعه‌ای از ifهای پراکنده حدس بزنه با پیام چه کاری باید انجام بده. Push هم مثل هر API دیگه به یک قرارداد پایدار نیاز داره. بهتره همه پیام‌های قیمت‌بان ساختار مشترکی داشته باشن:

{
  "type": "PRICE_DROP",
  "schemaVersion": "1",
  "eventId": "evt_986215",
  "entityId": "8472",
  "title": "قیمت لپ‌تاپ کاهش پیدا کرد",
  "body": "قیمت جدید ۳۹٬۹۰۰٬۰۰۰ تومان است.",
  "channel": "price_alerts",
  "route": "product/8472",
  "imageUrl": "https://cdn.gheimatban.com/products/8472.jpg",
  "createdAt": "2026-07-19T18:25:00Z"
}

type مشخص می‌کنه چه اتفاقی افتاده. schemaVersion نسخه قرارداد پیام رو نگه می‌داره تا اگر بعداً ساختار Payload تغییر کرد، نسخه‌های قدیمی برنامه همچنان قابل مدیریت باشن. eventId شناسه یکتای رویداده و برای جلوگیری از نمایش تکراری استفاده میشه. entityId شناسه موجودیت مرتبطه؛ برای مثال شناسه محصول. channel تعیین می‌کنه اعلان باید در کدوم Notification Channel قرار بگیره و route مقصد داخلی برنامه رو مشخص می‌کنه.

وجود title و body داخل Push باعث میشه برای نمایش اولیه به شبکه وابسته نباشیم. در مقابل، نباید اطلاعات حساس یا کامل رو داخل Payload قرار بدیم. ارتباط FCM رمزنگاری‌شده‌ست، اما پیام‌ها به‌صورت پیش‌فرض End-to-End Encrypted نیستن؛ بنابراین Token ورود، رمز، اطلاعات پرداخت یا داده‌ای که نمایش اون روی صفحه قفل خطرناکه نباید داخل Push قرار بگیره.

در سمت اندروید یک مدل مشخص برای Payload می‌سازیم:

data class PushPayload(
    val type: PushType,
    val schemaVersion: Int,
    val eventId: String,
    val entityId: Long?,
    val title: String,
    val body: String,
    val channel: String,
    val route: String?,
    val imageUrl: String?,
    val createdAt: Instant?
)

نوع پیام‌ها رو هم به‌جای Stringهای پراکنده، در یک Enum نگه می‌داریم:

enum class PushType {
    PRICE_DROP,
    PRICE_TARGET_REACHED,
    PRODUCT_UNAVAILABLE,
    PRODUCT_AVAILABLE,
    ACCOUNT_WARNING,
    GENERAL_ANNOUNCEMENT,
    UNKNOWN
}

یک Parser مرکزی مسئول تبدیل Map دریافتی به مدل میشه:

class PushPayloadParser {

    fun parse(
        data: Map<String, String>
    ): PushPayload? {
        val eventId =
            data["eventId"]
                ?.takeIf { it.isNotBlank() }
                ?: return null

        val title =
            data["title"]
                ?.takeIf { it.isNotBlank() }
                ?: return null

        val body =
            data["body"]
                ?.takeIf { it.isNotBlank() }
                ?: return null

        val type = runCatching {
            PushType.valueOf(
                data["type"].orEmpty()
            )
        }.getOrDefault(
            PushType.UNKNOWN
        )

        val createdAt = runCatching {
            data["createdAt"]
                ?.let(Instant::parse)
        }.getOrNull()

        return PushPayload(
            type = type,
            schemaVersion =
                data["schemaVersion"]
                    ?.toIntOrNull()
                    ?: 1,
            eventId = eventId,
            entityId =
                data["entityId"]
                    ?.toLongOrNull(),
            title = title,
            body = body,
            channel =
                data["channel"]
                    ?: NotificationChannels.GENERAL,
            route = data["route"],
            imageUrl = data["imageUrl"],
            createdAt = createdAt
        )
    }
}

با این طراحی، FirebaseMessagingService مسئول تفسیر جزئی تمام پیام‌ها نیست. سرویس فقط پیام رو تحویل Parser و Router میده و هر بخش مسئولیت مشخص خودش رو داره.

ساخت FirebaseMessagingService حرفه‌ای

Service اصلی رو در Manifest ثبت می‌کنیم:

<service
    android:name=".push.GheimatBanMessagingService"
    android:exported="false">

    <intent-filter>
        <action
            android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>

</service>

حالا Service رو می‌سازیم:

class GheimatBanMessagingService :
    FirebaseMessagingService() {

    private val payloadParser by lazy {
        PushPayloadParser()
    }

    private val pushHandler by lazy {
        PushHandler(
            context = applicationContext
        )
    }

    override fun onMessageReceived(
        message: RemoteMessage
    ) {
        super.onMessageReceived(message)

        val payload =
            payloadParser.parse(message.data)
                ?: return

        pushHandler.handle(
            payload = payload,
            originalPriority =
                message.originalPriority,
            deliveredPriority =
                message.priority
        )
    }

    override fun onNewToken(
        token: String
    ) {
        super.onNewToken(token)

        PushTokenSyncScheduler.enqueue(
            context = applicationContext,
            token = token
        )
    }

    override fun onDeletedMessages() {
        super.onDeletedMessages()

        FullSyncScheduler.enqueue(
            applicationContext
        )
    }
}

onNewToken() فقط زمان نصب اولیه اجرا نمیشه. Token ممکنه بر اثر بازیابی برنامه روی دستگاه جدید، حذف و نصب دوباره، پاک شدن داده‌های برنامه یا تغییرات داخلی سرویس عوض بشه. بنابراین Token رو یک مقدار ثابت و دائمی در نظر نمی‌گیریم. هر بار Token جدید ایجاد شد باید اون رو به Backend منتقل کنیم. Firebase هم توصیه می‌کنه سرور زمان ثبت یا آخرین به‌روزرسانی Registration رو نگه داره و Registrationهای قدیمی یا نامعتبر رو پاک‌سازی کنه.

onDeletedMessages() زمانی اهمیت پیدا می‌کنه که پیام‌های ذخیره‌شده دستگاه از حد قابل نگهداری عبور کنن یا FCM نتونه همه پیام‌ها رو تحویل بده. ترتیب تحویل پیام‌ها هم تضمین‌شده نیست. بنابراین Push نباید تنها منبع حقیقت برنامه باشه؛ وقتی احتمال ازدست‌رفتن پیام وجود داره باید Full Sync از سرور انجام بشه.

Token متعلق به دستگاهه، نه کاربر

فرض کن ایمان با یک حساب روی سه دستگاه وارد قیمت‌بان شده:

User 42
├── Phone A → Token A
├── Phone B → Token B
└── Tablet → Token C

سرور نباید داخل جدول کاربران فقط یک ستون با نام fcm_token داشته باشه؛ چون ورود از دستگاه جدید Token قبلی رو جایگزین می‌کنه و اعلان فقط به آخرین دستگاه می‌رسه. مدل درست اینه که هر نمونه نصب‌شده برنامه یک Registration مستقل داشته باشه:

push_registrations

id
user_id
installation_id
fcm_token
platform
app_version
device_model
notifications_enabled
last_seen_at
created_at
updated_at
revoked_at

در سمت اندروید، Token و مشخصات لازم رو به API می‌فرستیم:

data class RegisterPushTokenRequest(
    val token: String,
    val installationId: String,
    val platform: String = "android",
    val appVersion: String,
    val deviceModel: String,
    val notificationsEnabled: Boolean
)
interface PushApi {

    @POST("push/registrations")
    suspend fun register(
        @Body request:
            RegisterPushTokenRequest
    )

    @DELETE(
        "push/registrations/{installationId}"
    )
    suspend fun unregister(
        @Path("installationId")
        installationId: String
    )
}

عملیات ثبت باید Upsert باشه؛ یعنی اگر Installation قبلاً ثبت شده، Token و زمان آخرین فعالیت اون به‌روزرسانی بشه و اگر وجود نداره، رکورد تازه ساخته بشه. Token نباید به‌عنوان شناسه اصلی کاربر یا راه احراز هویت استفاده بشه. Token فقط آدرس ارسال پیام به یک نمونه برنامه‌ست و ممکنه تغییر کنه.

برای Sync مطمئن Token از WorkManager استفاده می‌کنیم؛ چون ممکنه onNewToken() زمانی اجرا بشه که اینترنت در دسترس نیست:

object PushTokenSyncScheduler {

    private const val TOKEN_KEY =
        "push_token"

    fun enqueue(
        context: Context,
        token: String
    ) {
        val inputData = workDataOf(
            TOKEN_KEY to token
        )

        val constraints =
            Constraints.Builder()
                .setRequiredNetworkType(
                    NetworkType.CONNECTED
                )
                .build()

        val request =
            OneTimeWorkRequestBuilder<
                PushTokenSyncWorker
            >()
                .setInputData(inputData)
                .setConstraints(constraints)
                .setBackoffCriteria(
                    BackoffPolicy.EXPONENTIAL,
                    30,
                    TimeUnit.SECONDS
                )
                .build()

        WorkManager
            .getInstance(context)
            .enqueueUniqueWork(
                "sync_push_token",
                ExistingWorkPolicy.REPLACE,
                request
            )
    }

    fun readToken(
        workerParameters: WorkerParameters
    ): String? {
        return workerParameters
            .inputData
            .getString(TOKEN_KEY)
    }
}

Worker:

class PushTokenSyncWorker(
    appContext: Context,
    workerParameters: WorkerParameters,
    private val pushRepository:
        PushRepository
) : CoroutineWorker(
    appContext,
    workerParameters
) {

    override suspend fun doWork():
        Result {

        val token =
            PushTokenSyncScheduler
                .readToken(
                    workerParameters
                )
                ?: return Result.failure()

        return try {
            pushRepository
                .registerToken(token)

            Result.success()
        } catch (
            exception: IOException
        ) {
            Result.retry()
        } catch (
            exception: HttpException
        ) {
            if (
                exception.code() in
                500..599
            ) {
                Result.retry()
            } else {
                Result.failure()
            }
        }
    }
}

بعد از Login هم باید Token فعلی رو با حساب کاربر مرتبط کنیم. اگر onNewToken() قبل از ورود اجرا شده باشه، ممکنه Token روی دستگاه موجود باشه اما هنوز کاربری برای اون ثبت نشده باشه:

suspend fun syncCurrentToken() {
    val token =
        FirebaseMessaging
            .getInstance()
            .token
            .await()

    registerToken(token)
}

بعد از Logout هم باید Registration همون دستگاه از حساب قبلی جدا بشه. حذف محلی Token به‌تنهایی کافی نیست؛ Backend هم باید بدونه این دستگاه دیگه متعلق به کاربر قبلی نیست، وگرنه ممکنه اعلان‌های خصوصی حساب قبلی روی همون گوشی نمایش داده بشن.

suspend fun logout() {
    runCatching {
        pushApi.unregister(
            installationIdProvider.get()
        )
    }

    authStorage.clear()
    localDatabase.clearUserData()
}

اجازه اعلان رو در زمان درست درخواست می‌کنیم

در Android 13 و بالاتر، اعلان‌های معمولی به مجوز Runtime با نام POST_NOTIFICATIONS نیاز دارن و اعلان برنامه تازه‌نصب‌شده به‌صورت پیش‌فرض غیرفعاله تا کاربر اجازه بده. اگر برنامه Android 13 یا بالاتر رو Target کنه، زمان نمایش دیالوگ در اختیار خود برنامه‌ست؛ بنابراین بهتره مجوز رو در لحظه‌ای درخواست کنیم که کاربر ارزش اعلان رو درک کرده، نه بلافاصله بعد از اولین اجرا.

در Manifest:

<uses-permission
    android:name=
        "android.permission.POST_NOTIFICATIONS" />

در Compose:

@Composable
fun NotificationPermissionRequest(
    shouldAsk: Boolean,
    onResult: (Boolean) -> Unit
) {
    if (
        Build.VERSION.SDK_INT <
        Build.VERSION_CODES.TIRAMISU
    ) {
        return
    }

    val permissionLauncher =
        rememberLauncherForActivityResult(
            contract =
                ActivityResultContracts
                    .RequestPermission(),
            onResult = onResult
        )

    LaunchedEffect(shouldAsk) {
        if (shouldAsk) {
            permissionLauncher.launch(
                Manifest.permission
                    .POST_NOTIFICATIONS
            )
        }
    }
}

اما بهتره قبل از دیالوگ سیستمی، یک صفحه توضیح کوتاه داشته باشیم:

وقتی قیمت محصولی کاهش پیدا کنه، قیمت‌بان می‌تونه همون لحظه بهت خبر بده. [فعال کردن اعلان کاهش قیمت] [فعلاً نه]

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

وضعیت Permission رو هم می‌تونیم بررسی کنیم:

fun areNotificationsAllowed(
    context: Context
): Boolean {
    val permissionGranted =
        if (
            Build.VERSION.SDK_INT >=
            Build.VERSION_CODES.TIRAMISU
        ) {
            ContextCompat.checkSelfPermission(
                context,
                Manifest.permission
                    .POST_NOTIFICATIONS
            ) ==
                PackageManager
                    .PERMISSION_GRANTED
        } else {
            true
        }

    return permissionGranted &&
        NotificationManagerCompat
            .from(context)
            .areNotificationsEnabled()
}

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

fun openNotificationSettings(
    context: Context
) {
    val intent = Intent(
        Settings
            .ACTION_APP_NOTIFICATION_SETTINGS
    ).apply {
        putExtra(
            Settings.EXTRA_APP_PACKAGE,
            context.packageName
        )
    }

    context.startActivity(intent)
}

Channelها رو براساس نوع نیاز کاربر جدا می‌کنیم

همه اعلان‌ها اهمیت یکسانی ندارن. کاهش قیمت هدف‌گذاری‌شده، اطلاعیه عمومی، هشدار امنیت حساب و وضعیت Sync نباید داخل یک Channel قرار بگیرن. کاربر باید بتونه بعضی نوع‌ها رو خاموش و بعضی رو روشن نگه داره.

object NotificationChannels {

    const val PRICE_ALERTS =
        "price_alerts"

    const val ACCOUNT_SECURITY =
        "account_security"

    const val GENERAL =
        "general"

    const val BACKGROUND_SYNC =
        "background_sync"
}

ساخت Channelها:

class NotificationChannelFactory(
    private val context: Context
) {

    fun createAll() {
        if (
            Build.VERSION.SDK_INT <
            Build.VERSION_CODES.O
        ) {
            return
        }

        val manager =
            context.getSystemService(
                NotificationManager::class.java
            )

        val priceAlerts =
            NotificationChannel(
                NotificationChannels
                    .PRICE_ALERTS,
                "هشدارهای قیمت",
                NotificationManager
                    .IMPORTANCE_HIGH
            ).apply {
                description =
                    "کاهش قیمت و رسیدن محصول به قیمت هدف"

                enableVibration(true)
            }

        val accountSecurity =
            NotificationChannel(
                NotificationChannels
                    .ACCOUNT_SECURITY,
                "امنیت حساب",
                NotificationManager
                    .IMPORTANCE_HIGH
            ).apply {
                description =
                    "ورود جدید و هشدارهای مهم امنیتی"
            }

        val general =
            NotificationChannel(
                NotificationChannels.GENERAL,
                "اطلاعیه‌های عمومی",
                NotificationManager
                    .IMPORTANCE_DEFAULT
            ).apply {
                description =
                    "خبرها و اطلاعیه‌های قیمت‌بان"
            }

        val backgroundSync =
            NotificationChannel(
                NotificationChannels
                    .BACKGROUND_SYNC,
                "همگام‌سازی",
                NotificationManager
                    .IMPORTANCE_LOW
            ).apply {
                description =
                    "وضعیت عملیات پس‌زمینه"
            }

        manager.createNotificationChannels(
            listOf(
                priceAlerts,
                accountSecurity,
                general,
                backgroundSync
            )
        )
    }
}

Channel رو باید زود، مثلاً داخل Application بسازیم:

class GheimatBanApplication :
    Application() {

    override fun onCreate() {
        super.onCreate()

        NotificationChannelFactory(
            this
        ).createAll()
    }
}

بعد از ساخته شدن Channel، کاربر روی تنظیمات صدا، لرزش و اهمیت اون کنترل داره. برنامه نباید تصور کنه هر زمان خواست می‌تونه Importance یک Channel موجود رو تغییر بده. اگر بعداً نوع اعلان واقعاً متفاوتی داریم، بهتره Channel تازه‌ای با شناسه جدید بسازیم، نه اینکه معنای Channel قدیمی رو عوض کنیم.

Push Handler؛ مرکز تصمیم‌گیری اعلان

حالا یک Handler می‌سازیم که قبل از نمایش اعلان چند تصمیم بگیره: آیا نوع پیام شناخته‌شده‌ست؟ آیا اعلان قبلاً نمایش داده شده؟ آیا کاربر این نوع اعلان رو فعال کرده؟ آیا پیام هنوز تازه‌ست؟ آیا برنامه در Foreground قرار داره؟ آیا باید Notification ساخته بشه یا فقط Sync انجام بشه؟

class PushHandler(
    private val context: Context,
    private val deduplicator:
        PushDeduplicator =
            PushDeduplicator(context),
    private val preferences:
        PushPreferenceStore =
            PushPreferenceStore(context),
    private val notificationFactory:
        AppNotificationFactory =
            AppNotificationFactory(context)
) {

    fun handle(
        payload: PushPayload,
        originalPriority: Int,
        deliveredPriority: Int
    ) {
        if (
            payload.schemaVersion >
            SUPPORTED_SCHEMA_VERSION
        ) {
            FullSyncScheduler.enqueue(
                context
            )
            return
        }

        if (
            deduplicator
                .wasAlreadyHandled(
                    payload.eventId
                )
        ) {
            return
        }

        if (isExpired(payload)) {
            deduplicator.markHandled(
                payload.eventId
            )
            return
        }

        if (
            !preferences.isEnabled(
                payload.type
            )
        ) {
            deduplicator.markHandled(
                payload.eventId
            )
            return
        }

        notificationFactory.show(
            payload
        )

        deduplicator.markHandled(
            payload.eventId
        )

        PushAnalytics.logReceived(
            payload = payload,
            originalPriority =
                originalPriority,
            deliveredPriority =
                deliveredPriority
        )

        scheduleRelatedSync(payload)
    }

    private fun isExpired(
        payload: PushPayload
    ): Boolean {
        val createdAt =
            payload.createdAt
                ?: return false

        val maxAge =
            when (payload.type) {
                PushType.PRICE_DROP,
                PushType.PRICE_TARGET_REACHED ->
                    Duration.ofHours(12)

                PushType.ACCOUNT_WARNING ->
                    Duration.ofDays(2)

                else ->
                    Duration.ofDays(1)
            }

        return createdAt
            .plus(maxAge)
            .isBefore(Instant.now())
    }

    private fun scheduleRelatedSync(
        payload: PushPayload
    ) {
        when (payload.type) {
            PushType.PRICE_DROP,
            PushType.PRICE_TARGET_REACHED,
            PushType.PRODUCT_AVAILABLE,
            PushType.PRODUCT_UNAVAILABLE -> {
                payload.entityId?.let {
                    ProductSyncScheduler
                        .enqueue(
                            context = context,
                            productId = it
                        )
                }
            }

            else -> Unit
        }
    }

    private companion object {
        const val SUPPORTED_SCHEMA_VERSION =
            1
    }
}

در این طراحی، نمایش اعلان فقط یکی از خروجی‌های Push هست. بعد از نمایش فوری، WorkManager می‌تونه اطلاعات محصول رو تازه کنه تا وقتی کاربر وارد صفحه شد، داده جدید از قبل در Room موجود باشه.

جلوگیری از اعلان تکراری

FCM تضمین نمی‌کنه که منطق برنامه هرگز یک رویداد رو دوبار دریافت نکنه. Backend هم ممکنه به‌دلیل Retry یک پیام رو دوباره ارسال کنه. اگر فقط از productId به‌عنوان شناسه استفاده کنیم، دو کاهش قیمت متفاوت برای یک محصول اشتباهاً یکی شناخته میشن. به همین دلیل هر رویداد باید eventId یکتا داشته باشه.

برای نمونه ساده، شناسه‌های پردازش‌شده رو در DataStore نگه می‌داریم:

class PushDeduplicator(
    context: Context
) {

    private val preferences =
        context.getSharedPreferences(
            "push_deduplication",
            Context.MODE_PRIVATE
        )

    fun wasAlreadyHandled(
        eventId: String
    ): Boolean {
        return preferences.contains(
            eventId
        )
    }

    fun markHandled(
        eventId: String
    ) {
        preferences
            .edit()
            .putLong(
                eventId,
                System.currentTimeMillis()
            )
            .apply()

        removeExpiredEntries()
    }

    private fun removeExpiredEntries() {
        val expirationTime =
            System.currentTimeMillis() -
                TimeUnit.DAYS
                    .toMillis(7)

        val editor =
            preferences.edit()

        preferences.all
            .filterValues { value ->
                value is Long &&
                    value < expirationTime
            }
            .keys
            .forEach(editor::remove)

        editor.apply()
    }
}

برای حجم بالاتر بهتره یک جدول Room داشته باشیم:

@Entity(
    tableName = "handled_push_events"
)
data class HandledPushEventEntity(
    @PrimaryKey
    val eventId: String,
    val handledAt: Long
)
@Dao
interface HandledPushEventDao {

    @Query(
        """
        SELECT EXISTS(
            SELECT 1
            FROM handled_push_events
            WHERE eventId = :eventId
        )
        """
    )
    suspend fun exists(
        eventId: String
    ): Boolean

    @Insert(
        onConflict = OnConflictStrategy.IGNORE
    )
    suspend fun insert(
        event:
            HandledPushEventEntity
    )

    @Query(
        """
        DELETE FROM handled_push_events
        WHERE handledAt < :before
        """
    )
    suspend fun deleteOlderThan(
        before: Long
    )
}

اعلان تکراری رو باید در هر دو طرف کنترل کنیم. Backend باید برای یک رویداد کسب‌وکاری، فقط یک Event بسازه و Client هم در برابر ارسال یا تحویل تکراری مقاوم باشه.

ساخت اعلان با مقصد دقیق

برای هر Push باید مشخص باشه لمس اعلان کجا میره. در قیمت‌بان، اعلان کاهش قیمت باید صفحه همون محصول رو باز کنه، نه اینکه فقط صفحه اصلی برنامه نمایش داده بشه.

یک Route داخلی استاندارد تعریف می‌کنیم:

gheimatban://product/8472

ساخت PendingIntent:

class PushPendingIntentFactory(
    private val context: Context
) {

    fun create(
        payload: PushPayload
    ): PendingIntent {
        val destination =
            payload.route
                ?.let {
                    Uri.parse(
                        "gheimatban://$it"
                    )
                }
                ?: Uri.parse(
                    "gheimatban://home"
                )

        val intent = Intent(
            Intent.ACTION_VIEW,
            destination,
            context,
            MainActivity::class.java
        ).apply {
            putExtra(
                EXTRA_EVENT_ID,
                payload.eventId
            )

            flags =
                Intent.FLAG_ACTIVITY_CLEAR_TOP or
                Intent.FLAG_ACTIVITY_SINGLE_TOP
        }

        return PendingIntent.getActivity(
            context,
            stableRequestCode(
                payload.eventId
            ),
            intent,
            PendingIntent.FLAG_UPDATE_CURRENT or
                PendingIntent.FLAG_IMMUTABLE
        )
    }

    private fun stableRequestCode(
        value: String
    ): Int {
        return value.hashCode()
            .and(0x7fffffff)
    }

    private companion object {
        const val EXTRA_EVENT_ID =
            "push_event_id"
    }
}

استفاده از یک Request Code ثابت برای همه اعلان‌ها ممکنه باعث بشه PendingIntent اعلان جدید، اطلاعات اعلان قبلی رو جایگزین کنه. به همین دلیل از Hash شناسه رویداد استفاده کردیم. FLAG_IMMUTABLE هم اعلام می‌کنه PendingIntent بعد از ساخته شدن نباید توسط بخش دیگری تغییر داده بشه.

در Navigation Compose:

composable(
    route = "product/{productId}",
    arguments = listOf(
        navArgument("productId") {
            type = NavType.LongType
        }
    ),
    deepLinks = listOf(
        navDeepLink {
            uriPattern =
                "gheimatban://product/{productId}"
        }
    )
) { backStackEntry ->
    val productId =
        backStackEntry
            .arguments
            ?.getLong("productId")
            ?: return@composable

    ProductDetailsScreen(
        productId = productId
    )
}

مقصد Push نباید مستقیماً به وجود داده محلی وابسته باشه. اگر محصول هنوز داخل Room نیست، صفحه باید Loading نمایش بده و اطلاعات رو از Repository بگیره. همچنین اگر کاربر از حساب خارج شده، مقصد خصوصی باید ابتدا Login رو نمایش بده و بعد از ورود، مسیر اولیه رو ادامه بده.

ساخت Notification Factory

حالا ساخت ظاهری اعلان رو از Service جدا می‌کنیم:

class AppNotificationFactory(
    private val context: Context
) {

    private val notificationManager =
        NotificationManagerCompat
            .from(context)

    private val pendingIntentFactory =
        PushPendingIntentFactory(context)

    fun show(
        payload: PushPayload
    ) {
        if (
            !areNotificationsAllowed(
                context
            )
        ) {
            return
        }

        val notification =
            NotificationCompat.Builder(
                context,
                resolveChannel(payload)
            )
                .setSmallIcon(
                    R.drawable.ic_notification
                )
                .setContentTitle(
                    payload.title
                )
                .setContentText(
                    payload.body
                )
                .setStyle(
                    NotificationCompat
                        .BigTextStyle()
                        .bigText(
                            payload.body
                        )
                )
                .setContentIntent(
                    pendingIntentFactory
                        .create(payload)
                )
                .setAutoCancel(true)
                .setCategory(
                    resolveCategory(payload)
                )
                .setPriority(
                    resolveCompatPriority(
                        payload
                    )
                )
                .setGroup(
                    resolveGroup(payload)
                )
                .setOnlyAlertOnce(true)
                .build()

        notificationManager.notify(
            notificationId(payload),
            notification
        )
    }

    private fun resolveChannel(
        payload: PushPayload
    ): String {
        return when (payload.type) {
            PushType.PRICE_DROP,
            PushType.PRICE_TARGET_REACHED,
            PushType.PRODUCT_AVAILABLE,
            PushType.PRODUCT_UNAVAILABLE ->
                NotificationChannels
                    .PRICE_ALERTS

            PushType.ACCOUNT_WARNING ->
                NotificationChannels
                    .ACCOUNT_SECURITY

            else ->
                NotificationChannels.GENERAL
        }
    }

    private fun resolveCategory(
        payload: PushPayload
    ): String {
        return when (payload.type) {
            PushType.ACCOUNT_WARNING ->
                NotificationCompat
                    .CATEGORY_WARNING

            else ->
                NotificationCompat
                    .CATEGORY_STATUS
        }
    }

    private fun resolveCompatPriority(
        payload: PushPayload
    ): Int {
        return when (payload.type) {
            PushType.PRICE_TARGET_REACHED,
            PushType.ACCOUNT_WARNING ->
                NotificationCompat
                    .PRIORITY_HIGH

            else ->
                NotificationCompat
                    .PRIORITY_DEFAULT
        }
    }

    private fun resolveGroup(
        payload: PushPayload
    ): String {
        return when (payload.type) {
            PushType.PRICE_DROP,
            PushType.PRICE_TARGET_REACHED,
            PushType.PRODUCT_AVAILABLE,
            PushType.PRODUCT_UNAVAILABLE ->
                GROUP_PRICE_ALERTS

            else ->
                GROUP_GENERAL
        }
    }

    private fun notificationId(
        payload: PushPayload
    ): Int {
        return when {
            payload.entityId != null -> {
                payload.entityId
                    .hashCode()
            }

            else -> {
                payload.eventId
                    .hashCode()
            }
        }
    }

    private companion object {
        const val GROUP_PRICE_ALERTS =
            "group_price_alerts"

        const val GROUP_GENERAL =
            "group_general"
    }
}

اینجا برای اعلان‌های مربوط به یک محصول از entityId به‌عنوان Notification ID استفاده کردیم. نتیجه اینه که اگر قیمت همون محصول چند بار در فاصله کوتاه تغییر کنه، اعلان قبلی با اطلاعات تازه جایگزین میشه و Notification Shade پر از اعلان‌های تکراری یک محصول نمیشه. اما اگر می‌خوایم هر تغییر قیمت تاریخچه مستقلی داشته باشه، باید از eventId استفاده کنیم. انتخاب شناسه اعلان بخشی از طراحی محصوله، نه صرفاً یک تصمیم فنی.

setOnlyAlertOnce(true) هم باعث میشه وقتی همون Notification ID با محتوای تازه Update میشه، کاربر دوباره صدا و لرزش دریافت نکنه.

گروه‌بندی چند اعلان

اگر پنج محصول کاربر هم‌زمان کاهش قیمت پیدا کنن، نمایش پنج اعلان مستقل بدون Summary می‌تونه شلوغ باشه. می‌تونیم همه اعلان‌های قیمت رو داخل یک Group قرار بدیم و یک اعلان خلاصه بسازیم:

fun showPriceSummary(
    count: Int
) {
    val summary =
        NotificationCompat.Builder(
            context,
            NotificationChannels
                .PRICE_ALERTS
        )
            .setSmallIcon(
                R.drawable.ic_notification
            )
            .setContentTitle(
                "$count تغییر قیمت جدید"
            )
            .setContentText(
                "محصولات دنبال‌شده رو بررسی کن."
            )
            .setStyle(
                NotificationCompat
                    .InboxStyle()
                    .setSummaryText(
                        "$count اعلان قیمت"
                    )
            )
            .setGroup(
                GROUP_PRICE_ALERTS
            )
            .setGroupSummary(true)
            .setAutoCancel(true)
            .setContentIntent(
                createPriceListPendingIntent()
            )
            .build()

    notificationManager.notify(
        PRICE_SUMMARY_ID,
        summary
    )
}

برای ساخت Summary دقیق باید تعداد اعلان‌های فعال رو نگه داریم یا از دیتابیس اعلان‌های برنامه بخونیم. فقط افزایش یک شمارنده محلی ممکنه بعد از حذف اعلان توسط کاربر نادرست بشه. در پروژه حرفه‌ای بهتره هر Push در جدول Inbox برنامه ثبت بشه و Summary براساس اعلان‌های خوانده‌نشده ساخته بشه.

Notification Inbox داخل برنامه

اعلان سیستم دائمی نیست. کاربر ممکنه اون رو کنار بزنه، گوشی رو عوض کنه یا اعلان‌ها رو از سیستم غیرفعال کرده باشه. اگر رویدادهایی مثل کاهش قیمت برای کاربر ارزش تاریخی دارن، باید علاوه بر Push در Backend ذخیره بشن و داخل برنامه یک Inbox داشته باشیم:

notifications

id
user_id
type
entity_id
title
body
created_at
read_at

در این معماری Push فقط خبر میده که رویداد جدیدی وجود داره؛ منبع اصلی داده API و دیتابیس سروره:

Price changed

Create notification record

Send FCM push

Device displays system notification

App syncs notification inbox

API:

interface NotificationApi {

    @GET("notifications")
    suspend fun getNotifications(
        @Query("cursor")
        cursor: String?,

        @Query("limit")
        limit: Int = 30
    ): NotificationPageDto

    @PATCH(
        "notifications/{id}/read"
    )
    suspend fun markAsRead(
        @Path("id")
        notificationId: Long
    )
}

با این طراحی، اگر Push به هر دلیلی نرسه، اعلان داخل Inbox برنامه باقی می‌مونه. Push یک کانال تحویل Best Effort محسوب میشه و نباید جای دیتابیس کسب‌وکار رو بگیره.

اولویت Normal و High

در Android برای پیام‌های FCM دو اولویت اصلی وجود داره: normal و high. پیام Normal در حالت معمول تحویل داده میشه، اما هنگام Doze ممکنه برای حفظ باتری به تأخیر بیفته. پیام High برای محتوای زمان‌حساس و قابل‌مشاهده برای کاربره و FCM تلاش می‌کنه اون رو سریع‌تر تحویل بده و در صورت نیاز دستگاه خوابیده رو برای پردازش محدود بیدار کنه.

برای قیمت‌بان می‌تونیم چنین سیاستی داشته باشیم:

رسیدن محصول به قیمت هدف:
High

هشدار ورود مشکوک:
High

کاهش معمولی قیمت:
High یا Normal براساس تنظیم کاربر

Sync کردن تعداد اعلان‌های خوانده‌نشده:
Normal

خبرنامه و اطلاعیه عمومی:
Normal

نباید همه پیام‌ها رو High بفرستیم. اگر FCM تشخیص بده پیام‌های High به اعلان قابل‌مشاهده برای کاربر منجر نمیشن، ممکنه ارسال‌های بعدی اون نمونه برنامه رو به Normal تنزل بده. تصمیم FCM با نگاه به رفتار پیام‌های چند روز اخیر برای هر App Instance انجام میشه.

نمونه ارسال با Admin SDK در Node.js:

import {
  getMessaging
} from "firebase-admin/messaging";

export async function sendPriceDrop({
  token,
  eventId,
  productId,
  title,
  body
}) {
  const message = {
    token,

    data: {
      type: "PRICE_DROP",
      schemaVersion: "1",
      eventId,
      entityId: String(productId),
      title,
      body,
      channel: "price_alerts",
      route: `product/${productId}`,
      createdAt: new Date().toISOString()
    },

    android: {
      priority: "high",
      ttl: 12 * 60 * 60 * 1000,
      collapseKey: `price_${productId}`
    }
  };

  return getMessaging().send(message);
}

نکته مهم اینه که تمام مقادیر داخل data باید String باشن. برای همین productId و schemaVersion رو به String تبدیل کردیم.

TTL؛ اعلان قدیمی تا چه زمانی ارزش داره؟

TTL یا Time To Live مشخص می‌کنه FCM تا چه مدت تلاش کنه پیام تحویل‌نشده رو نگه داره. فرض کن گوشی کاربر دو روز خاموش بوده و قیمت محصول دیروز کاهش پیدا کرده، اما امروز دوباره افزایش یافته. نمایش اعلان قدیمی «قیمت کاهش پیدا کرد» بعد از روشن شدن گوشی می‌تونه گمراه‌کننده باشه.

برای هر نوع رویداد باید TTL متناسب تعیین کنیم:
کاهش قیمت سریع:
۶ تا ۱۲ ساعت

رسیدن به قیمت هدف:
۱۲ تا ۲۴ ساعت

هشدار امنیتی حساب:
چند روز

خبرنامه:
یک تا سه روز

درخواست Sync:
کوتاه و Collapsible

TTL کوتاه به این معنی نیست که رویداد از Inbox برنامه حذف بشه. فقط میگه اعلان Push قدیمی دیگه نباید به دستگاه تحویل داده بشه. تاریخچه واقعی همچنان می‌تونه از API دریافت بشه.

Collapsible و Non-collapsible

در پیام Non-collapsible هر پیام اهمیت مستقل داره و باید جداگانه تحویل داده بشه؛ مثلاً دو پیام چت یا دو هشدار امنیتی مستقل. در پیام Collapsible، پیام جدیدتر می‌تونه پیام تحویل‌نشده قدیمی رو جایگزین کنه؛ مثلاً چند پیام پشت سر هم که فقط میگن «اطلاعات محصولات رو Sync کن». FCM ترتیب تحویل رو تضمین نمی‌کنه و برای پیام‌های Collapsible هر Registration در یک زمان فقط تعداد محدودی Collapse Key متفاوت نگه می‌داره.

برای Sync عمومی:

const message = {
  token,

  data: {
    type: "SYNC_PRODUCTS",
    schemaVersion: "1",
    eventId
  },

  android: {
    priority: "normal",
    collapseKey: "sync_products",
    ttl: 15 * 60 * 1000
  }
};

اگر سه پیام sync_products در مدت آفلاین بودن دستگاه ایجاد بشه، فقط تازه‌ترین پیام اهمیت داره؛ چون برنامه بعد از دریافت اون، وضعیت نهایی رو از API می‌گیره.

اما برای هشدار امنیتی مستقل از Collapse Key مشترک استفاده نمی‌کنیم:

const message = {
  token,

  data: {
    type: "ACCOUNT_WARNING",
    schemaVersion: "1",
    eventId,
    title: "ورود جدید به حساب",
    body: "ورود تازه‌ای از یک دستگاه ناشناس ثبت شد.",
    route: "security/sessions",
    createdAt: new Date().toISOString()
  },

  android: {
    priority: "high",
    ttl: 3 * 24 * 60 * 60 * 1000
  }
};

تصویر بزرگ رو داخل Service دانلود نمی‌کنیم

برای نمایش BigPictureStyle ممکنه بخوایم تصویر محصول رو دانلود کنیم. اما onMessageReceived() برای پردازش کوتاه طراحی شده و تماس شبکه‌ای طولانی داخل اون قابل‌اعتماد نیست. توصیه رسمی اینه که اعلان اصلی رو سریع نمایش بدیم و اگر پردازش طولانی‌تری مثل دریافت تصویر لازمه، WorkManager رو اجرا کنیم. برای پیام High میشه بلافاصله یک Expedited Work درخواست کرد و برای پیام Normal از Work معمولی استفاده کرد.

اول اعلان متنی رو نمایش می‌دیم، سپس Worker تصویر رو می‌گیره و همون Notification ID رو Update می‌کنه:

fun enqueueImageWorker(
    context: Context,
    payload: PushPayload
) {
    val imageUrl =
        payload.imageUrl ?: return

    val inputData = workDataOf(
        "event_id" to payload.eventId,
        "entity_id" to payload.entityId,
        "title" to payload.title,
        "body" to payload.body,
        "image_url" to imageUrl
    )

    val request =
        OneTimeWorkRequestBuilder<
            NotificationImageWorker
        >()
            .setInputData(inputData)
            .setExpedited(
                OutOfQuotaPolicy
                    .RUN_AS_NON_EXPEDITED_WORK_REQUEST
            )
            .build()

    WorkManager
        .getInstance(context)
        .enqueueUniqueWork(
            "push_image_${payload.eventId}",
            ExistingWorkPolicy.KEEP,
            request
        )
}

Worker:

class NotificationImageWorker(
    appContext: Context,
    workerParameters: WorkerParameters,
    private val imageLoader:
        PushImageLoader
) : CoroutineWorker(
    appContext,
    workerParameters
) {

    override suspend fun doWork():
        Result {

        val imageUrl =
            inputData.getString(
                "image_url"
            ) ?: return Result.failure()

        val title =
            inputData.getString(
                "title"
            ).orEmpty()

        val body =
            inputData.getString(
                "body"
            ).orEmpty()

        val eventId =
            inputData.getString(
                "event_id"
            ) ?: return Result.failure()

        val bitmap = try {
            imageLoader.load(imageUrl)
        } catch (
            exception: IOException
        ) {
            return Result.retry()
        }

        val notification =
            NotificationCompat.Builder(
                applicationContext,
                NotificationChannels
                    .PRICE_ALERTS
            )
                .setSmallIcon(
                    R.drawable.ic_notification
                )
                .setContentTitle(title)
                .setContentText(body)
                .setStyle(
                    NotificationCompat
                        .BigPictureStyle()
                        .bigPicture(bitmap)
                        .bigLargeIcon(null as Bitmap?)
                        .setSummaryText(body)
                )
                .setOnlyAlertOnce(true)
                .setAutoCancel(true)
                .build()

        NotificationManagerCompat
            .from(applicationContext)
            .notify(
                eventId.hashCode(),
                notification
            )

        return Result.success()
    }
}

در نسخه کامل باید همون Channel، PendingIntent، Group و Notification ID اعلان اولیه رو دوباره استفاده کنیم تا اعلان تازه‌ای ساخته نشه و فقط ظاهر قبلی Update بشه.

تنظیمات اعلان در دو سطح

تنظیمات اعلان دو سطح دارن. سطح اول تنظیمات داخل قیمت‌بان هست:

هشدار کاهش قیمت: روشن رسیدن به قیمت هدف: روشن موجود شدن محصول: خاموش اطلاعیه‌های عمومی: خاموش

سطح دوم تنظیمات سیستم اندرویده؛ یعنی Permission کلی و Channelها. فعال بودن گزینه داخل برنامه تضمین نمی‌کنه Channel یا Permission سیستم هم فعال باشه.

مدل تنظیمات:

data class PushPreferences(
    val priceDropsEnabled: Boolean,
    val targetReachedEnabled: Boolean,
    val availabilityEnabled: Boolean,
    val generalEnabled: Boolean
)

API:

interface PushPreferenceApi {

    @GET("push/preferences")
    suspend fun getPreferences():
        PushPreferencesDto

    @PUT("push/preferences")
    suspend fun updatePreferences(
        @Body preferences:
            UpdatePushPreferencesRequest
    )
}

تنظیمات باید روی Backend هم ذخیره بشن، نه فقط روی گوشی. اگر کاربر «اطلاعیه‌های عمومی» رو خاموش کرده، بهتره سرور اصلاً این نوع Push رو تولید نکنه؛ نه اینکه پیام رو بفرسته و Client بعد از مصرف شبکه اون رو دور بندازه. Client همچنان باید برای دفاع نهایی تنظیمات محلی رو بررسی کنه، اما فیلتر اصلی باید سمت Backend باشه.

Topic برای اطلاعات عمومی، نه پیام خصوصی

Topic Messaging برای ارسال اطلاعات عمومی به تعداد زیادی دستگاه مناسبه؛ مثلاً اطلاعیه نسخه جدید یا خبر عمومی. Topicها برای Throughput بهینه شدن، نه تحویل کم‌تأخیر به یک کاربر یا گروه کوچک. برای پیام خصوصی و سریع بهتره Registrationهای مشخص کاربر هدف قرار بگیرن. هر نمونه برنامه هم حداکثر می‌تونه در ۲۰۰۰ Topic عضو باشه.

عضویت در Topic:

suspend fun subscribeToGeneralNews() {
    FirebaseMessaging
        .getInstance()
        .subscribeToTopic(
            "general_news_fa"
        )
        .await()
}

لغو عضویت:

suspend fun unsubscribeFromGeneralNews() {
    FirebaseMessaging
        .getInstance()
        .unsubscribeFromTopic(
            "general_news_fa"
        )
        .await()
}

ارسال از Backend:

const message = {
  topic: "general_news_fa",

  notification: {
    title: "نسخه جدید قیمت‌بان",
    body: "امکان مقایسه تاریخچه قیمت اضافه شد."
  },

  data: {
    type: "GENERAL_ANNOUNCEMENT",
    route: "updates/42"
  },

  android: {
    priority: "normal"
  }
};

await getMessaging().send(message);

اما نباید Topicهایی مثل این بسازیم:

user_42
user_583
account_1024

Topic سازوکار کنترل دسترسی برای داده خصوصی نیست. برنامه Client می‌تونه عضویت Topic رو درخواست کنه و نام Topic هم نباید به‌عنوان مرز امنیتی در نظر گرفته بشه. پیام شخصی باید براساس Registrationهای ثبت‌شده و مجاز همون حساب از Backend ارسال بشه.

پاک‌سازی Tokenهای نامعتبر

وقتی Backend برای Token نامعتبر پیام می‌فرسته، Admin SDK خطایی برمی‌گردونه. Tokenهایی که به‌صورت قطعی نامعتبر یا ثبت‌نشده هستن باید غیرفعال یا حذف بشن؛ وگرنه با رشد کاربران، سرور بخش بزرگی از منابع ارسال رو صرف دستگاه‌هایی می‌کنه که دیگه وجود ندارن. Firebase هم توصیه می‌کنه Registrationها همراه Timestamp نگهداری و داده‌های قدیمی به‌شکل فعال پاک‌سازی بشن.

نمونه ارسال و غیرفعال‌سازی:

import {
  getMessaging
} from "firebase-admin/messaging";

export async function sendToRegistration(
  registration,
  messageData,
  registrationRepository
) {
  try {
    const response =
      await getMessaging().send({
        token: registration.fcmToken,
        data: messageData,
        android: {
          priority: "high",
          ttl: 12 * 60 * 60 * 1000
        }
      });

    await registrationRepository
      .markSuccessful(
        registration.id,
        new Date()
      );

    return response;
  } catch (error) {
    const invalidTokenCodes = new Set([
      "messaging/registration-token-not-registered",
      "messaging/invalid-registration-token"
    ]);

    if (invalidTokenCodes.has(error.code)) {
      await registrationRepository
        .revoke(
          registration.id,
          new Date()
        );

      return null;
    }

    throw error;
  }
}

خطاهای موقت مثل محدودیت ظرفیت یا خطای داخلی رو نباید به حذف Token تعبیر کنیم. برای این موارد Retry همراه Exponential Backoff و Jitter لازمه. در ارسال‌های حجیم هم نباید ناگهان صدها هزار درخواست رو در یک ثانیه به FCM شلیک کنیم. FCM برای HTTP v1 سهمیه و مدل Token Bucket داره و توصیه می‌کنه ترافیک ارسال به‌تدریج پخش بشه و در مواجهه با خطاهای قابل‌تلاش، Backoff رعایت بشه.

Push رو مستقیماً از اپلیکیشن اندروید ارسال نمی‌کنیم

کلید Service Account یا اعتبارنامه ارسال FCM نباید داخل APK قرار بگیره. هر چیزی که داخل برنامه اندروید قرار بگیره، دیر یا زود قابل استخراج یا سوءاستفاده‌ست. معماری درست اینه:

Android App

Authenticated API Request

GheimatBan Backend

Business Rules

Firebase Admin SDK / HTTP v1

FCM

Target Devices

برای مثال کاربر نمی‌تونه مستقیماً بگه «به Token فلان دستگاه این متن رو بفرست». Backend ابتدا مجوز و رویداد کسب‌وکاری رو بررسی می‌کنه، سپس خودش Payload معتبر می‌سازه. محیط ارسال باید قابل‌اعتماد باشه؛ مثل Backend یا Cloud Functions.

حتی عنوان و متن Pushهای حساس هم بهتره توسط Backend از Templateهای کنترل‌شده ساخته بشن. قبول کردن متن آزاد از Client می‌تونه سیستم اعلان رو به ابزار Spam یا فیشینگ تبدیل کنه.

وضعیت Foreground رو متفاوت مدیریت می‌کنیم

وقتی کاربر همین حالا داخل صفحه محصول قرار داره، نمایش یک اعلان سیستمی برای همون تغییر ممکنه آزاردهنده باشه. می‌تونیم در Foreground به‌جای Notification، Room رو Sync کنیم و Snackbar نمایش بدیم. اما تشخیص Foreground باید مرکزی باشه:

class AppVisibilityTracker :
    DefaultLifecycleObserver {

    @Volatile
    var isForeground: Boolean = false
        private set

    override fun onStart(
        owner: LifecycleOwner
    ) {
        isForeground = true
    }

    override fun onStop(
        owner: LifecycleOwner
    ) {
        isForeground = false
    }
}

ثبت در Application:

class GheimatBanApplication :
    Application() {

    val visibilityTracker =
        AppVisibilityTracker()

    override fun onCreate() {
        super.onCreate()

        ProcessLifecycleOwner
            .get()
            .lifecycle
            .addObserver(
                visibilityTracker
            )
    }
}

بعد در Handler:

if (
    appVisibilityTracker.isForeground
) {
    inAppEventBus.emit(payload)
} else {
    notificationFactory.show(payload)
}

با این حال، Foreground بودن برنامه به‌تنهایی کافی نیست. اگر کاربر در صفحه‌ای نامرتبطه، ممکنه نمایش Notification همچنان مناسب باشه. تصمیم حرفه‌ای باید براساس نوع رویداد، صفحه فعلی و ترجیح کاربر انجام بشه.

وضعیت خوانده شدن و هماهنگی چند دستگاه

فرض کن اعلان به گوشی و تبلت کاربر رسیده. کاربر روی گوشی اعلان رو باز می‌کنه. بهتره Backend اعلان رو Read ثبت کنه، اما حذف اعلان تبلت به‌صورت خودکار ساده نیست. می‌تونیم یک پیام Sync کم‌اولویت به دستگاه‌های دیگه بفرستیم یا هنگام باز شدن برنامه وضعیت Inbox رو Sync کنیم.

رویداد لمس اعلان:

suspend fun handlePushOpen(
    eventId: String
) {
    notificationRepository
        .markEventAsRead(eventId)

    analyticsRepository
        .trackPushOpened(eventId)
}

سمت Backend:

PATCH /notifications/events/evt_986215/read

بعد Backend می‌تونه یک Data Message با نوع NOTIFICATION_STATE_CHANGED برای Registrationهای دیگه کاربر بفرسته:

{
  "type": "NOTIFICATION_STATE_CHANGED",
  "eventId": "evt_986215",
  "state": "READ"
}

دستگاه دوم با دریافت اون می‌تونه اعلان مربوط رو Cancel کنه:

fun cancelNotification(
    context: Context,
    eventId: String
) {
    NotificationManagerCompat
        .from(context)
        .cancel(
            eventId.hashCode()
        )
}

این هماهنگی همیشه لحظه‌ای و تضمینی نیست، اما تجربه چنددستگاهی رو منظم‌تر می‌کنه.

ثبت رویدادهای دریافت، نمایش و باز شدن

فقط اینکه send() در Backend موفق بوده به معنی دیده شدن اعلان نیست. زنجیره Push چند مرحله داره:

Event Created
Message Requested
FCM Accepted
Device Received
Notification Displayed
User Opened
Target Screen Viewed
Business Action Completed

برای هر مرحله یک رویداد قابل‌اندازه‌گیری تعریف می‌کنیم:

push_created
push_send_accepted
push_send_failed
push_received
push_displayed
push_opened
push_destination_viewed
price_alert_converted

در Client:

object PushAnalytics {

    fun logReceived(
        payload: PushPayload,
        originalPriority: Int,
        deliveredPriority: Int
    ) {
        Firebase.analytics.logEvent(
            "push_received"
        ) {
            param(
                "event_id",
                payload.eventId
            )

            param(
                "push_type",
                payload.type.name
            )

            param(
                "original_priority",
                originalPriority.toLong()
            )

            param(
                "delivered_priority",
                deliveredPriority.toLong()
            )
        }
    }

    fun logOpened(
        payload: PushPayload
    ) {
        Firebase.analytics.logEvent(
            "push_opened"
        ) {
            param(
                "event_id",
                payload.eventId
            )

            param(
                "push_type",
                payload.type.name
            )
        }
    }
}

نباید اطلاعات حساس یا متن کامل اعلان رو بی‌دلیل وارد Analytics کنیم. eventId، نوع Push، نسخه برنامه و نتیجه پردازش معمولاً برای تحلیل فنی کافی هستن.

تست Push در وضعیت‌های واقعی

اعلان فقط در حالی که Android Studio بازه و گوشی به اینترنت پرسرعت وصله تست نمیشه. برای قیمت‌بان باید حداقل این وضعیت‌ها رو بررسی کنیم:

  • برنامه در Foreground باشه.
  • برنامه در Background باشه.
  • برنامه از Recent Apps بسته شده باشه.
  • دستگاه در Doze قرار گرفته باشه.
  • اینترنت هنگام ارسال قطع و بعداً وصل بشه.
  • Permission اعلان رد شده باشه.
  • Channel هشدار قیمت خاموش شده باشه.
  • کاربر از حساب خارج شده باشه.
  • Token هنگام آفلاین بودن عوض شده باشه.
  • یک Event دوبار ارسال بشه.
  • چند رویداد برای یک محصول ارسال بشه.
  • Payload ناقص یا دارای نسخه ناشناخته باشه.
  • کاربر از Notification وارد صفحه محصول حذف‌شده بشه.
  • چند حساب به‌ترتیب روی یک دستگاه Login و Logout کنن.
  • یک حساب روی چند دستگاه فعال باشه.
  • نسخه قدیمی برنامه Payload نسخه جدید رو دریافت کنه.
  • اعلان تصویر داشته باشه اما دانلود تصویر شکست بخوره.

برای ورود به Doze می‌تونیم روی دستگاه تست از ADB استفاده کنیم:

adb shell dumpsys deviceidle force-idle

خروج از Doze:

adb shell dumpsys deviceidle unforce

فعال کردن دوباره دستگاه:

adb shell dumpsys battery reset

در تست پیام High باید مطمئن بشیم اعلان واقعاً برای کاربر نمایش داده میشه؛ استفاده مداوم از High برای کارهای پنهان یا Sync خام می‌تونه باعث کاهش اولویت تحویل بشه.

معماری نهایی Push در قیمت‌بان

در نسخه حرفه‌ای، جریان کاهش قیمت به این شکل درمیاد:

Price Worker detects a price drop

Backend validates the event

Notification record is created

User preferences are checked

Active registrations are selected

FCM data messages are created

Invalid registrations are removed

Android receives the payload

Payload schema is validated

Duplicate and expired events are rejected

Notification is displayed immediately

WorkManager refreshes product data

User taps the notification

Product screen opens

Notification is marked as read

Analytics records the result

در این معماری، FCM فقط یکی از اجزای سیستم محسوب میشه. منبع اصلی رویداد Backendه، تنظیمات اعلان روی سرور بررسی میشن، هر دستگاه Registration مستقل داره، Push قرارداد نسخه‌دار داره، پیام تکراری روی Client حذف میشه و Inbox برنامه اجازه میده رویدادهای مهم فقط به Notification Shade وابسته نباشن.

چک‌لیست Push حرفه‌ای

پیش از انتشار نسخه جدید قیمت‌بان، سیستم Push باید این شرایط رو داشته باشه:

  • Payload قرارداد مشخص و schemaVersion داشته باشه.
  • هر رویداد eventId یکتا داشته باشه.
  • اطلاعات حساس داخل Push قرار نگیره.
  • پیام‌های نیازمند کنترل کامل به‌صورت Data Message ارسال بشن.
  • Token هر نصب جدا از حساب کاربر نگهداری بشه.
  • یک کاربر بتونه چند Registration فعال داشته باشه.
  • onNewToken اطلاعات تازه رو با WorkManager به سرور بفرسته.
  • Login و Logout ارتباط Registration با حساب رو درست تغییر بدن.
  • Tokenهای نامعتبر از Backend پاک یا غیرفعال بشن.
  • Permission اعلان در زمان معنادار درخواست بشه.
  • وضعیت Permission و Channelهای سیستم بررسی بشه.
  • تنظیمات اعلان روی Backend و Client وجود داشته باشه.
  • اعلان‌های مختلف Channelهای جدا داشته باشن.
  • لمس اعلان مقصد دقیق و قابل‌اعتماد باز کنه.
  • PendingIntent هر اعلان اطلاعات درست خودش رو حفظ کنه.
  • رویداد تکراری دوباره نمایش داده نشه.
  • اعلان‌های یک محصول در صورت نیاز Update بشن.
  • اعلان‌های متعدد Group و Summary داشته باشن.
  • پیام‌های زمان‌حساس High و بقیه Normal باشن.
  • High Priority فقط برای محتوای قابل‌مشاهده استفاده بشه.
  • TTL متناسب با عمر واقعی رویداد تعیین بشه.
  • پیام‌های Sync در صورت مناسب بودن Collapsible باشن.
  • کار طولانی داخل onMessageReceived انجام نشه.
  • دانلود تصویر و Sync سنگین به WorkManager سپرده بشه.
  • Topic فقط برای اطلاعات عمومی استفاده بشه.
  • Push جای دیتابیس اعلان‌ها رو نگیره.
  • دریافت، نمایش، باز شدن و نتیجه Push اندازه‌گیری بشه.
  • سیستم در Foreground، Background، Doze و حالت آفلاین تست بشه.

جمع‌بندی

Push حرفه‌ای یعنی سرور فقط یک متن به گوشی نفرسته، بلکه یک رویداد نسخه‌دار، قابل‌اندازه‌گیری و دارای مقصد مشخص تولید کنه. در قیمت‌بان هر کاهش قیمت باید ابتدا به‌عنوان یک رویداد واقعی در Backend ثبت بشه، تنظیمات کاربر بررسی بشه و بعد پیام به تمام Registrationهای فعال همون حساب ارسال بشه. Client هم نباید هر Payloadی رو بدون بررسی نمایش بده؛ باید نسخه قرارداد، تازگی پیام، تنظیمات کاربر و تکراری نبودن eventId رو کنترل کنه.

Notification Message برای اعلان‌های ساده مفیده، اما Data Message کنترل بیشتری روی ظاهر، Channel، گروه‌بندی، Deep Link و رفتار Foreground و Background میده. Token هم متعلق به نمونه نصب‌شده برنامه‌ست، نه خود کاربر؛ بنابراین یک حساب می‌تونه چند Token داشته باشه و هر Token ممکنه در طول زمان عوض یا نامعتبر بشه. در کنار این‌ها، Priority، TTL و Collapse Key باید براساس ماهیت رویداد انتخاب بشن، نه اینکه همه پیام‌ها با تنظیمات یکسان ارسال بشن.

در نهایت Push نباید تنها منبع حقیقت باشه. ممکنه پیام دیر برسه، تکرار بشه، به‌دلیل خاموش بودن اعلان نمایش داده نشه یا اصلاً تحویل داده نشه. داده اصلی باید روی Backend و داخل Notification Inbox باقی بمونه و Push فقط کاربر رو از تغییر باخبر کنه. وقتی این مرزها درست طراحی بشن، سیستم اعلان قیمت‌بان از یک قابلیت تزئینی به بخشی قابل‌اعتماد از معماری محصول تبدیل میشه.

درس شصت‌وپنجم

Event-Driven UI؛ رابط کاربری رویدادمحور

در برنامه‌های ساده معمولاً جریان کار مستقیمه. کاربر دکمه‌ای رو لمس می‌کنه، برنامه همون‌جا یک API رو صدا می‌زنه، نتیجه رو می‌گیره و چند متغیر داخل صفحه رو تغییر میده. تا وقتی صفحه کوچیکه و فقط دو یا سه عملیات داره، این روش ظاهراً مشکلی ایجاد نمی‌کنه. اما با بزرگ‌تر شدن پروژه، هر بخش صفحه شروع می‌کنه به تغییر دادن بخش‌های دیگه. دکمه Refresh مستقیماً Loading رو روشن می‌کنه، Repository نتیجه رو داخل یک متغیر می‌ریزه، یک Callback جدا Snackbar رو نمایش میده و یک شرط دیگه هم Navigation رو اجرا می‌کنه. بعد از مدتی دیگه مشخص نیست چه کسی وضعیت صفحه رو تغییر داده و چرا رابط کاربری به شکل فعلی درآمده.

در رابط کاربری رویدادمحور، به‌جای اینکه اجزای صفحه مستقیماً همدیگه رو کنترل کنن، هر اتفاق به‌عنوان یک Event وارد یک جریان مشخص میشه. ViewModel رویداد رو دریافت می‌کنه، منطق مربوط رو اجرا می‌کنه و یک وضعیت جدید می‌سازه. رابط کاربری هم فقط وضعیت جدید رو نمایش میده. این جریان باعث میشه تغییرات صفحه از یک مسیر قابل‌پیش‌بینی عبور کنن:

User Event

ViewModel

Business Logic

New UI State

Compose Recomposition

در معماری پیشنهادی اندروید، State همیشه وجود داره، اما Event اتفاق می‌افته و عمر محدودی داره. Event ورودی فرایند تولید State محسوب میشه و خروجی اون فرایند، وضعیتیه که رابط کاربری نمایش میده.

در این درس صفحه محصولات دنبال‌شده در قیمت‌بان رو به یک رابط کاربری کاملاً رویدادمحور تبدیل می‌کنیم. این صفحه باید محصولات رو نمایش بده، جست‌وجو کنه، مرتب‌سازی رو تغییر بده، قیمت‌ها رو تازه‌سازی کنه، محصول رو حذف کنه، Snackbar نمایش بده و در صورت لمس محصول، وارد صفحه جزئیات بشه. هدف فقط نوشتن چند کلاس Event و State نیست؛ می‌خوایم بفهمیم چه چیزی واقعاً Event محسوب میشه، چه چیزی باید State باشه، Navigation و Snackbar چطور مدیریت میشن و چرا استفاده بی‌قاعده از SharedFlow گاهی رویدادها رو از بین می‌بره.

رابط کاربری دستوری چه مشکلی داره؟

فرض کن صفحه فهرست محصولات رو به شکل زیر پیاده کرده‌ایم:

@Composable
fun TrackedProductsScreen(
    viewModel: TrackedProductsViewModel,
    navController: NavController
) {
    val products by viewModel.products.collectAsState()
    val isLoading by viewModel.isLoading.collectAsState()
    val error by viewModel.error.collectAsState()

    if (error != null) {
        Toast.makeText(
            LocalContext.current,
            error,
            Toast.LENGTH_LONG
        ).show()
    }

    Button(
        onClick = {
            viewModel.isLoading.value = true

            viewModel.refresh {
                viewModel.isLoading.value = false
            }
        }
    ) {
        Text("به‌روزرسانی")
    }

    LazyColumn {
        items(products) { product ->
            Text(
                text = product.title,
                modifier = Modifier.clickable {
                    navController.navigate(
                        "product/${product.id}"
                    )
                }
            )
        }
    }
}

این کد چند مشکل معماری داره. اول اینکه UI مستقیماً State داخلی ViewModel رو تغییر میده:

viewModel.isLoading.value = true

پس ViewModel دیگه تنها مسئول تولید وضعیت نیست. دوم اینکه Callback پایان Refresh داخل خود Composable کنترل شده و اگر منطق Refresh تغییر کنه، UI هم باید تغییر کنه. سوم اینکه Toast مستقیماً هنگام Composition نمایش داده میشه. Recomposition ممکنه چند بار اتفاق بیفته و همون Toast دوباره نمایش داده بشه. چهارم اینکه محصولات، Loading و Error در سه جریان جدا قرار دارن و ممکنه ترکیب‌هایی به وجود بیاد که از نظر منطقی ناسازگار باشن؛ مثلاً isLoading = false باشه اما فهرست هنوز خالی و خطا هم null باشه، بدون اینکه معلوم باشه صفحه در وضعیت اولیه‌ست یا واقعاً هیچ محصولی وجود نداره.

در Compose، رابط کاربری از State ساخته میشه. وقتی State تغییر می‌کنه، بخش‌های وابسته دوباره Compose میشن. الگوی جریان یک‌طرفه داده هم دقیقاً با همین مدل هماهنگه: State از بالا به پایین میاد و Event از UI به سمت State Holder بالا میره.

معماری مطلوب ما اینه:

UI reads state
UI sends event
ViewModel changes state
UI renders new state

UI نباید خودش تصمیم بگیره Refresh چه زمانی تمام شده، حذف محصول چه اثری روی فهرست داره یا خطا چطور ذخیره بشه. UI فقط اعلام می‌کنه چه اتفاقی افتاده:

RefreshClicked
SearchQueryChanged
ProductClicked
DeleteProductClicked
RetryClicked

ViewModel هم براساس این اتفاق‌ها، وضعیت جدید تولید می‌کنه.

تفاوت Event، State و Effect

برای ساخت Event-Driven UI باید سه مفهوم رو از هم جدا کنیم:

Event
State
Effect

Event اتفاقیه که وارد سیستم میشه:

کاربر روی Refresh زد.
متن جست‌وجو تغییر کرد.
اتصال اینترنت برگشت.
Repository داده تازه منتشر کرد.

State تصویری از وضعیت فعلی صفحه‌ست:

فهرست محصولات چیست؟
آیا صفحه در حال بارگذاری است؟
عبارت جست‌وجوی فعلی چیست؟
کدام نوع مرتب‌سازی انتخاب شده؟
آیا پیام خطایی باید نمایش داده شود؟

Effect عملی یک‌باره‌ست که معمولاً توسط UI اجرا میشه:

رفتن به صفحه جزئیات
باز کردن مرورگر
نمایش Snackbar
باز کردن صفحه تنظیمات سیستم

اما همین‌جا یک نکته معماری مهم وجود داره. در توصیه فعلی Android، اتفاقی که از ViewModel منشأ می‌گیره بهتره تا جای ممکن به UI State تبدیل بشه، نه اینکه فقط به‌عنوان یک Event یک‌بارمصرف ارسال بشه. دلیلش اینه که State بعد از چرخش صفحه، توقف Lifecycle یا بازسازی UI قابل بازیابی و مشاهده باقی می‌مونه، اما Event لحظه‌ای ممکنه زمانی منتشر بشه که هیچ Collector فعالی وجود نداره و از دست بره.

مثلاً به‌جای اینکه ViewModel این رو ارسال کنه:

NavigateToProduct(42)

می‌تونیم در State بنویسیم:

selectedProductId = 42

UI این State رو می‌بینه، Navigation رو انجام میده و بعد اعلام می‌کنه که مقصد مصرف شده:

viewModel.onEvent(
    TrackedProductsEvent.NavigationHandled
)

این روش از نظر قابلیت بازتولید مطمئن‌تره. البته در پروژه‌های واقعی هنوز برای بعضی Effectهای کاملاً گذرا از SharedFlow یا Channel استفاده میشه. مسئله این نیست که SharedFlow ممنوعه؛ مسئله اینه که آگاهانه بدونیم کدوم اتفاق در صورت ازدست‌رفتن، مشکل جدی ایجاد می‌کنه.

تعریف Eventهای صفحه

اول تمام ورودی‌های صفحه رو مشخص می‌کنیم. یک sealed interface می‌سازیم تا مجموعه Eventها محدود و قابل‌ردیابی باشه:

sealed interface TrackedProductsEvent {

    data object ScreenStarted :
        TrackedProductsEvent

    data object RefreshClicked :
        TrackedProductsEvent

    data object RetryClicked :
        TrackedProductsEvent

    data class SearchQueryChanged(
        val query: String
    ) : TrackedProductsEvent

    data class SortChanged(
        val sort: ProductSort
    ) : TrackedProductsEvent

    data class ProductClicked(
        val productId: Long
    ) : TrackedProductsEvent

    data class DeleteProductClicked(
        val productId: Long
    ) : TrackedProductsEvent

    data object DeleteDialogConfirmed :
        TrackedProductsEvent

    data object DeleteDialogDismissed :
        TrackedProductsEvent

    data object UserMessageShown :
        TrackedProductsEvent

    data object NavigationHandled :
        TrackedProductsEvent
}

نام Event باید چیزی رو توصیف کنه که اتفاق افتاده، نه اینکه ViewModel باید چه کاری انجام بده. برای مثال این نام مناسب نیست:

LoadProducts

چون یک دستور به ViewModelه. نام زیر بهتره:

ScreenStarted

این Event فقط واقعیت رو بیان می‌کنه: صفحه شروع شده. ViewModel تصمیم می‌گیره آیا باید اطلاعات رو بارگذاری کنه، از Cache استفاده کنه یا اصلاً کاری انجام نده.

این تفاوت در پروژه‌های کوچک خیلی مهم به نظر نمی‌رسه، اما در سیستم بزرگ باعث میشه Eventها به Intent واقعی کاربر نزدیک بمونن. برای مثال:

DeleteProductClicked(productId)

به این معنی نیست که محصول باید فوراً حذف بشه. ViewModel ممکنه ابتدا Dialog تأیید رو نمایش بده. اگر Event رو DeleteProduct نام‌گذاری کنیم، از قبل درباره نتیجه تصمیم گرفته‌ایم.

تعریف State کامل صفحه

حالا باید مشخص کنیم UI برای Render شدن به چه اطلاعاتی نیاز داره:

data class TrackedProductsUiState(
    val products: List<TrackedProductUiModel> =
        emptyList(),

    val searchQuery: String = "",

    val selectedSort: ProductSort =
        ProductSort.LAST_UPDATED,

    val isInitialLoading: Boolean = true,

    val isRefreshing: Boolean = false,

    val deletingProductId: Long? = null,

    val productPendingDeletion:
        TrackedProductUiModel? = null,

    val userMessage: UiMessage? = null,

    val navigationTarget:
        TrackedProductsNavigation? = null,

    val isOffline: Boolean = false
) {
    val showEmptyState: Boolean
        get() =
            !isInitialLoading &&
            products.isEmpty() &&
            searchQuery.isBlank()

    val showNoSearchResult: Boolean
        get() =
            !isInitialLoading &&
            products.isEmpty() &&
            searchQuery.isNotBlank()
}

State باید تمام اطلاعات لازم برای نمایش صفحه رو در اختیار UI قرار بده. Composable نباید مجبور باشه از چند منبع مختلف حدس بزنه که صفحه در چه وضعیتی قرار داره.

برای پیام‌ها یک مدل مستقل تعریف می‌کنیم:

sealed interface UiMessage {

    data class Text(
        val value: String
    ) : UiMessage

    data class Resource(
        @StringRes
        val resourceId: Int
    ) : UiMessage
}

Navigation هم مدل مشخص خودش رو داره:

sealed interface TrackedProductsNavigation {

    data class ProductDetails(
        val productId: Long
    ) : TrackedProductsNavigation
}

مرتب‌سازی:

enum class ProductSort {
    LAST_UPDATED,
    LOWEST_PRICE,
    HIGHEST_DISCOUNT,
    ALPHABETICAL
}

مدل UI بهتره از مدل دیتابیس یا API جدا باشه:

data class TrackedProductUiModel(
    val id: Long,
    val title: String,
    val formattedPrice: String,
    val formattedPreviousPrice: String?,
    val discountPercent: Int?,
    val imageUrl: String?,
    val hasPriceDrop: Boolean
)

Repository ممکنه قیمت رو به‌صورت Long و تاریخ رو به‌صورت Instant بده، اما UI فقط اطلاعات آماده نمایش رو دریافت می‌کنه. این کار باعث میشه Formatting و تصمیم‌های نمایشی در یک نقطه انجام بشن.

یک Event Handler مرکزی یا چند تابع جدا؟

دو سبک رایج برای دریافت Event وجود داره. در سبک اول، ViewModel برای هر عملیات تابع مستقل داره:

fun refresh()
fun search(query: String)
fun deleteProduct(id: Long)
fun openProduct(id: Long)

در سبک دوم، تمام Eventها از یک ورودی عبور می‌کنن:

fun onEvent(
    event: TrackedProductsEvent
)

هر دو روش می‌تونن درست باشن. Android در مثال‌های رسمی معمولاً توابع رو براساس فعل نام‌گذاری می‌کنه، مثل refresh یا login، و UI رویداد رو از طریق Callbackهایی مثل onRefreshClicked بالا می‌فرسته.

در این درس از ورودی مرکزی استفاده می‌کنیم، چون برای آموزش Event-Driven UI جریان رو واضح‌تر می‌کنه:

fun onEvent(
    event: TrackedProductsEvent
) {
    when (event) {
        TrackedProductsEvent.ScreenStarted -> {
            handleScreenStarted()
        }

        TrackedProductsEvent.RefreshClicked -> {
            refreshProducts()
        }

        TrackedProductsEvent.RetryClicked -> {
            refreshProducts()
        }

        is TrackedProductsEvent
            .SearchQueryChanged -> {
            updateSearchQuery(
                event.query
            )
        }

        is TrackedProductsEvent
            .SortChanged -> {
            updateSort(
                event.sort
            )
        }

        is TrackedProductsEvent
            .ProductClicked -> {
            selectProduct(
                event.productId
            )
        }

        is TrackedProductsEvent
            .DeleteProductClicked -> {
            requestProductDeletion(
                event.productId
            )
        }

        TrackedProductsEvent
            .DeleteDialogConfirmed -> {
            confirmProductDeletion()
        }

        TrackedProductsEvent
            .DeleteDialogDismissed -> {
            dismissDeleteDialog()
        }

        TrackedProductsEvent
            .UserMessageShown -> {
            clearUserMessage()
        }

        TrackedProductsEvent
            .NavigationHandled -> {
            clearNavigationTarget()
        }
    }
}

مزیت این مدل اینه که تمام ورودی‌های صفحه از یک درگاه عبور می‌کنن. برای Logging، تست و بررسی جریان رفتار هم ساده‌تره. اما نباید onEvent تبدیل به یک تابع هزارخطی بشه. این تابع فقط Event رو به Handler مناسب هدایت می‌کنه و منطق هر عملیات داخل تابع جدا قرار می‌گیره.

ساخت ViewModel رویدادمحور

حالا ViewModel اصلی رو می‌سازیم:

class TrackedProductsViewModel(
    private val observeTrackedProducts:
        ObserveTrackedProductsUseCase,

    private val refreshTrackedProducts:
        RefreshTrackedProductsUseCase,

    private val deleteTrackedProduct:
        DeleteTrackedProductUseCase,

    private val productUiMapper:
        TrackedProductUiMapper,

    private val savedStateHandle:
        SavedStateHandle
) : ViewModel() {

    private val searchQuery =
        savedStateHandle.getStateFlow(
            key = SEARCH_QUERY_KEY,
            initialValue = ""
        )

    private val selectedSort =
        savedStateHandle.getStateFlow(
            key = SORT_KEY,
            initialValue =
                ProductSort
                    .LAST_UPDATED
                    .name
        )

    private val internalState =
        MutableStateFlow(
            InternalState()
        )

    val uiState:
        StateFlow<TrackedProductsUiState> =
        combine(
            observeTrackedProducts(),
            searchQuery,
            selectedSort,
            internalState
        ) {
            products,
            query,
            sortName,
            internal ->

            val sort =
                runCatching {
                    ProductSort.valueOf(
                        sortName
                    )
                }.getOrDefault(
                    ProductSort.LAST_UPDATED
                )

            val visibleProducts =
                products
                    .filterByQuery(query)
                    .sortedBy(sort)
                    .map(
                        productUiMapper::map
                    )

            TrackedProductsUiState(
                products =
                    visibleProducts,

                searchQuery =
                    query,

                selectedSort =
                    sort,

                isInitialLoading =
                    internal
                        .isInitialLoading,

                isRefreshing =
                    internal
                        .isRefreshing,

                deletingProductId =
                    internal
                        .deletingProductId,

                productPendingDeletion =
                    internal
                        .pendingDeletion
                        ?.let(
                            productUiMapper::map
                        ),

                userMessage =
                    internal.userMessage,

                navigationTarget =
                    internal
                        .navigationTarget,

                isOffline =
                    internal.isOffline
            )
        }
            .stateIn(
                scope = viewModelScope,
                started =
                    SharingStarted
                        .WhileSubscribed(5_000),
                initialValue =
                    TrackedProductsUiState()
            )

    init {
        observeInitialData()
    }
}

در اینجا State نهایی از چند منبع ساخته میشه:

Room data
Search query
Selected sort
Internal operation state

رابط کاربری فقط یک uiState دریافت می‌کنه، اما ViewModel در داخل می‌تونه چند جریان رو با هم ترکیب کنه. این دقیقاً نقش UI Layerه: داده‌های مختلف رو به شکلی تبدیل کنه که رابط کاربری بتونه مستقیماً Render کنه. Android هم توصیه می‌کنه ViewModel وضعیت قابل‌مشاهده رو از طریق ساختاری مثل StateFlow در اختیار UI قرار بده.

State داخلی:

private data class InternalState(
    val isInitialLoading: Boolean = true,
    val isRefreshing: Boolean = false,
    val deletingProductId: Long? = null,
    val pendingDeletion:
        TrackedProduct? = null,
    val userMessage: UiMessage? = null,
    val navigationTarget:
        TrackedProductsNavigation? = null,
    val isOffline: Boolean = false
)

ممکنه بپرسی چرا مستقیم یک MutableStateFlow<TrackedProductsUiState> نساختیم. چون بخشی از State از Room، Search و Sort محاسبه میشه. اگر هر بار همه این داده‌ها رو دستی داخل یک State کپی کنیم، احتمال ناسازگاری بالا میره. با combine، State نهایی از Sourceهای مشخص مشتق میشه.

پیاده‌سازی شروع صفحه

وقتی ViewModel ساخته میشه، داده‌های Room از طریق observeTrackedProducts() به‌صورت Flow دریافت میشن. در کنار اون می‌خوایم یک Refresh اولیه هم انجام بدیم:

private fun observeInitialData() {
    viewModelScope.launch {
        refreshTrackedProducts()
            .onSuccess {
                internalState.update {
                    it.copy(
                        isInitialLoading =
                            false,
                        isOffline = false
                    )
                }
            }
            .onFailure { throwable ->
                internalState.update {
                    it.copy(
                        isInitialLoading =
                            false,
                        isOffline =
                            throwable
                                is IOException,
                        userMessage =
                            throwable
                                .toUiMessage()
                    )
                }
            }
    }
}

در این معماری، حتی اگر Network شکست بخوره، داده Cacheشده Room همچنان داخل UI نمایش داده میشه. isInitialLoading فقط زمانی روشنه که هنوز اولین تلاش برای آماده‌سازی صفحه تمام نشده.

دقت کن که ScreenStarted و init رو نباید هم‌زمان برای یک کار تکراری استفاده کنیم. اگر init Refresh رو انجام میده، فرستادن ScreenStarted از Composable می‌تونه درخواست دوم ایجاد کنه. یکی از این دو مدل رو انتخاب می‌کنیم:

ViewModel init

یا:

LaunchedEffect(Unit) → ScreenStarted

برای عملیاتی که باید دقیقاً یک بار در عمر ViewModel اجرا بشن، init معمولاً ساده‌تره. برای عملیاتی که وابسته به حضور UI یا پارامتر صفحه‌ان، Event شروع صفحه می‌تونه مناسب‌تر باشه.

مدیریت Refresh

Refresh یک Event کاربره:

private fun refreshProducts() {
    if (
        internalState.value
            .isRefreshing
    ) {
        return
    }

    viewModelScope.launch {
        internalState.update {
            it.copy(
                isRefreshing = true,
                userMessage = null
            )
        }

        refreshTrackedProducts()
            .onSuccess {
                internalState.update {
                    it.copy(
                        isRefreshing = false,
                        isOffline = false,
                        userMessage =
                            UiMessage.Resource(
                                R.string
                                    .products_updated
                            )
                    )
                }
            }
            .onFailure { throwable ->
                internalState.update {
                    it.copy(
                        isRefreshing = false,
                        isOffline =
                            throwable
                                is IOException,
                        userMessage =
                            throwable
                                .toUiMessage()
                    )
                }
            }
    }
}

UI فقط Event می‌فرسته:

onEvent(
    TrackedProductsEvent
        .RefreshClicked
)

اینکه هنگام Refresh چه چیزی پاک بشه، چه پیام موفقیتی نمایش داده بشه و آیا درخواست تکراری رد بشه، همگی تصمیم ViewModel هستن.

جست‌وجو به‌عنوان Event

عبارت جست‌وجو State محسوب میشه، اما تغییر اون با Event اتفاق می‌افته:

private fun updateSearchQuery(
    query: String
) {
    savedStateHandle[
        SEARCH_QUERY_KEY
    ] = query
}

چون Query داخل SavedStateHandle ذخیره شده، بعد از بازسازی Process هم امکان بازیابی اون وجود داره. البته فقط Stateهایی رو باید ذخیره کنیم که کوچک و قابل‌Serialize باشن. فهرست کامل محصولات متعلق به Room یا Repositoryه و نباید داخل SavedStateHandle ذخیره بشه.

فیلتر محصولات:

private fun List<TrackedProduct>
    .filterByQuery(
        query: String
    ): List<TrackedProduct> {

    val normalizedQuery =
        query.trim()

    if (normalizedQuery.isBlank()) {
        return this
    }

    return filter { product ->
        product.title.contains(
            other = normalizedQuery,
            ignoreCase = true
        )
    }
}

مرتب‌سازی:

private fun List<TrackedProduct>
    .sortedBy(
        sort: ProductSort
    ): List<TrackedProduct> {

    return when (sort) {
        ProductSort.LAST_UPDATED -> {
            sortedByDescending {
                it.updatedAt
            }
        }

        ProductSort.LOWEST_PRICE -> {
            sortedBy {
                it.currentPrice
            }
        }

        ProductSort.HIGHEST_DISCOUNT -> {
            sortedByDescending {
                it.discountPercent
                    ?: 0
            }
        }

        ProductSort.ALPHABETICAL -> {
            sortedBy {
                it.title
            }
        }
    }
}

تغییر Sort:

private fun updateSort(
    sort: ProductSort
) {
    savedStateHandle[
        SORT_KEY
    ] = sort.name
}

Dialog حذف هم بخشی از Stateه

نمایش Dialog نباید یک فرمان مستقیم مثل این باشه:

_showDeleteDialog.emit(product)

Dialog در هر لحظه یا بازه یا بسته‌ست. بنابراین وضعیت اون رو میشه به‌طور طبیعی داخل UI State نگه داشت:

private fun requestProductDeletion(
    productId: Long
) {
    val product =
        observeTrackedProducts
            .currentProducts()
            .firstOrNull {
                it.id == productId
            }
            ?: return

    internalState.update {
        it.copy(
            pendingDeletion =
                product
        )
    }
}

در معماری بهتر، Repository یا یک State Cache می‌تونه محصول فعلی رو در اختیار ViewModel قرار بده. یا Event می‌تونه مدل UI موردنیاز رو حمل کنه:

data class DeleteProductClicked(
    val product:
        TrackedProductUiModel
) : TrackedProductsEvent

اما بهتره Event فقط شناسه رو حمل کنه و ViewModel موجودیت اصلی رو از منبع معتبر پیدا کنه. داده UI ممکنه قدیمی یا تغییرکرده باشه. بستن Dialog:

private fun dismissDeleteDialog() {
    internalState.update {
        it.copy(
            pendingDeletion = null
        )
    }
}

تأیید حذف:

private fun confirmProductDeletion() {
    val product =
        internalState.value
            .pendingDeletion
            ?: return

    if (
        internalState.value
            .deletingProductId != null
    ) {
        return
    }

    viewModelScope.launch {
        internalState.update {
            it.copy(
                pendingDeletion = null,
                deletingProductId =
                    product.id
            )
        }

        deleteTrackedProduct(
            product.id
        )
            .onSuccess {
                internalState.update {
                    it.copy(
                        deletingProductId =
                            null,
                        userMessage =
                            UiMessage.Text(
                                "محصول حذف شد."
                            )
                    )
                }
            }
            .onFailure { throwable ->
                internalState.update {
                    it.copy(
                        deletingProductId =
                            null,
                        userMessage =
                            throwable
                                .toUiMessage()
                    )
                }
            }
    }
}

اگر حذف Optimistic باشه، می‌تونیم محصول رو فوری از Room حذف کنیم و در صورت خطا برگردونیم. اما این تصمیم مربوط به Data و Domain Layerه، نه Composable.

Snackbar؛ Event یک‌باره یا State مصرف‌شونده؟

Snackbar موقته، اما وضعیت «یک پیام در انتظار نمایش وجود داره» قابل‌مدل‌سازی به‌عنوان State هست:

val userMessage: UiMessage?

وقتی پیام ایجاد میشه، State تغییر می‌کنه. UI پیام رو نمایش میده و بعد Event زیر رو می‌فرسته:

TrackedProductsEvent.UserMessageShown

ViewModel پیام رو پاک می‌کنه:

private fun clearUserMessage() {
    internalState.update {
        it.copy(
            userMessage = null
        )
    }
}

Composable:

@Composable
private fun ObserveUserMessage(
    message: UiMessage?,
    snackbarHostState:
        SnackbarHostState,
    onMessageShown: () -> Unit
) {
    val context =
        LocalContext.current

    LaunchedEffect(message) {
        val currentMessage =
            message ?: return@LaunchedEffect

        val text =
            when (currentMessage) {
                is UiMessage.Text -> {
                    currentMessage.value
                }

                is UiMessage.Resource -> {
                    context.getString(
                        currentMessage
                            .resourceId
                    )
                }
            }

        snackbarHostState
            .showSnackbar(text)

        onMessageShown()
    }
}

استفاده:

ObserveUserMessage(
    message =
        uiState.userMessage,

    snackbarHostState =
        snackbarHostState,

    onMessageShown = {
        onEvent(
            TrackedProductsEvent
                .UserMessageShown
        )
    }
)

این الگو یک مزیت مهم داره. فرض کن پیام درست زمانی تولید بشه که Activity به Background رفته. چون پیام داخل State باقی می‌مونه، وقتی UI دوباره فعال شد اون رو می‌بینه. در مقابل، اگر پیام با SharedFlow(replay = 0) منتشر شده بود، ممکن بود هیچ Collector فعالی وجود نداشته باشه و پیام برای همیشه از بین بره.

توصیه رسمی Android هم تأکید می‌کنه اتفاق‌هایی که از ViewModel آغاز میشن بهتره به تغییر UI State منتهی بشن تا در برابر تغییر Configuration یا وقفه Lifecycle قابل‌بازتولید باقی بمونن.

Navigation در معماری رویدادمحور

Navigation خودش منطق UI محسوب میشه و ViewModel نباید NavController رو بشناسه. اما ViewModel می‌تونه Stateی تولید کنه که نشان بده کاربر یک محصول رو انتخاب کرده:

private fun selectProduct(
    productId: Long
) {
    internalState.update {
        it.copy(
            navigationTarget =
                TrackedProductsNavigation
                    .ProductDetails(
                        productId
                    )
        )
    }
}

Composable مقصد رو مشاهده می‌کنه:

@Composable
private fun ObserveNavigation(
    target:
        TrackedProductsNavigation?,

    onNavigateToProduct:
        (Long) -> Unit,

    onNavigationHandled:
        () -> Unit
) {
    LaunchedEffect(target) {
        when (target) {
            is TrackedProductsNavigation
                .ProductDetails -> {

                onNavigateToProduct(
                    target.productId
                )

                onNavigationHandled()
            }

            null -> Unit
        }
    }
}

بعد از Navigation، ViewModel مقصد رو پاک می‌کنه:

private fun clearNavigationTarget() {
    internalState.update {
        it.copy(
            navigationTarget = null
        )
    }
}

در صفحه اصلی:

ObserveNavigation(
    target =
        uiState.navigationTarget,

    onNavigateToProduct = {
        productId ->

        navController.navigate(
            "product/$productId"
        )
    },

    onNavigationHandled = {
        onEvent(
            TrackedProductsEvent
                .NavigationHandled
        )
    }
)

این مدل از ارسال مستقیم NavController به ViewModel جلوگیری می‌کنه و Navigation رو قابل‌تست نگه می‌داره.

چرا SharedFlow همیشه انتخاب درستی برای UI Event نیست؟

یکی از الگوهای بسیار رایج اینه:

private val _events =
    MutableSharedFlow<UiEvent>()

val events =
    _events.asSharedFlow()

و بعد:

_events.emit(
    UiEvent.ShowSnackbar(
        "خطا رخ داد."
    )
)

این روش در بعضی سناریوها خوبه، اما باید رفتار SharedFlow رو بشناسیم. SharedFlow ذاتاً Hot Streamه و فقط Collectorهای فعال می‌تونن انتشارهای جاری رو دریافت کنن، مگر اینکه Replay یا Buffer مشخصی تنظیم شده باشه. StateFlow در مقابل همیشه مقدار فعلی رو نگه می‌داره و Collector جدید بلافاصله آخرین State رو دریافت می‌کنه.

فرض کن:

ViewModel emits event
Activity is stopped
Collector is inactive
Event disappears

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

MutableSharedFlow<UiEvent>(
    replay = 1
)

چون Collector جدید ممکنه Event قبلی رو دوباره مصرف کنه و Navigation تکراری اتفاق بیفته. بعد مجبور میشیم Replay Cache رو پاک کنیم یا Wrapperهای پیچیده بسازیم.

بنابراین قاعده عملی ما اینه:

چیزی که UI باید در هر لحظه بداند:
StateFlow

اتفاقی که از دست رفتنش مهم نیست:
SharedFlow

عمل یک‌باره‌ای که باید تضمین شود:
State مصرف‌شونده یا Queue پایدار

چه زمانی SharedFlow مناسب است؟

SharedFlow همچنان کاربردهای واقعی داره. مثلاً یک Event سراسری برای اطلاع دادن تغییر Theme به چند بخش برنامه، رخدادهای Telemetry، Refresh عمومی یا تغییر اتصال WebSocket می‌تونه با SharedFlow مناسب باشه.

مثال:

class AppEventBus {

    private val _events =
        MutableSharedFlow<AppEvent>(
            extraBufferCapacity = 16
        )

    val events:
        SharedFlow<AppEvent> =
        _events.asSharedFlow()

    fun publish(
        event: AppEvent
    ) {
        _events.tryEmit(event)
    }
}
sealed interface AppEvent {

    data object SessionExpired :
        AppEvent

    data class ProductChanged(
        val productId: Long
    ) : AppEvent
}

اما Event Bus نباید به راه فرار معماری تبدیل بشه. اگر هر Feature بدون قرارداد مشخص Event عمومی منتشر کنه، بعد از مدتی پیدا کردن منشأ رفتارها بسیار سخت میشه:

ProductRepository publishes event
HomeViewModel receives event
NotificationService receives event
ProfileScreen unexpectedly refreshes

تا جای ممکن، داده پایدار رو از Repository به‌صورت Flow منتشر می‌کنیم. مثلاً اگر محصول تغییر کرده، Room به‌روزرسانی میشه و تمام Observerها داده جدید رو دریافت می‌کنن؛ نیازی نیست Event جداگانه ProductChanged به همه جا شلیک کنیم.

صفحه Stateless و Stateful

بهتره Composable اصلی رو به دو بخش تقسیم کنیم:

Route Composable
Screen Composable

Route با ViewModel، Navigation و Lifecycle کار می‌کنه:

@Composable
fun TrackedProductsRoute(
    viewModel:
        TrackedProductsViewModel,
    onNavigateToProduct:
        (Long) -> Unit
) {
    val uiState by
        viewModel.uiState
            .collectAsStateWithLifecycle()

    val snackbarHostState =
        remember {
            SnackbarHostState()
        }

    ObserveUserMessage(
        message =
            uiState.userMessage,

        snackbarHostState =
            snackbarHostState,

        onMessageShown = {
            viewModel.onEvent(
                TrackedProductsEvent
                    .UserMessageShown
            )
        }
    )

    ObserveNavigation(
        target =
            uiState.navigationTarget,

        onNavigateToProduct =
            onNavigateToProduct,

        onNavigationHandled = {
            viewModel.onEvent(
                TrackedProductsEvent
                    .NavigationHandled
            )
        }
    )

    TrackedProductsScreen(
        state = uiState,
        snackbarHostState =
            snackbarHostState,
        onEvent =
            viewModel::onEvent
    )
}

استفاده از collectAsStateWithLifecycle باعث میشه جمع‌آوری State با Lifecycle رابط کاربری هماهنگ باشه. الگوی رسمی Android هم توصیه می‌کنه UI State به‌شکل Lifecycle-aware مصرف بشه.

Screen فقط State و Event Callback دریافت می‌کنه:

@Composable
fun TrackedProductsScreen(
    state:
        TrackedProductsUiState,

    snackbarHostState:
        SnackbarHostState,

    onEvent:
        (TrackedProductsEvent) -> Unit
) {
    Scaffold(
        snackbarHost = {
            SnackbarHost(
                hostState =
                    snackbarHostState
            )
        },

        topBar = {
            TrackedProductsTopBar(
                selectedSort =
                    state.selectedSort,

                onSortChanged = {
                    onEvent(
                        TrackedProductsEvent
                            .SortChanged(it)
                    )
                }
            )
        }
    ) { paddingValues ->

        TrackedProductsContent(
            modifier =
                Modifier.padding(
                    paddingValues
                ),

            state = state,

            onSearchQueryChanged = {
                onEvent(
                    TrackedProductsEvent
                        .SearchQueryChanged(
                            it
                        )
                )
            },

            onRefreshClicked = {
                onEvent(
                    TrackedProductsEvent
                        .RefreshClicked
                )
            },

            onRetryClicked = {
                onEvent(
                    TrackedProductsEvent
                        .RetryClicked
                )
            },

            onProductClicked = {
                onEvent(
                    TrackedProductsEvent
                        .ProductClicked(it)
                )
            },

            onDeleteClicked = {
                onEvent(
                    TrackedProductsEvent
                        .DeleteProductClicked(
                            it
                        )
                )
            }
        )
    }

    state.productPendingDeletion
        ?.let { product ->

            DeleteProductDialog(
                productTitle =
                    product.title,

                onConfirm = {
                    onEvent(
                        TrackedProductsEvent
                            .DeleteDialogConfirmed
                    )
                },

                onDismiss = {
                    onEvent(
                        TrackedProductsEvent
                            .DeleteDialogDismissed
                    )
                }
            )
        }
}

حالا TrackedProductsScreen هیچ اطلاعی از ViewModel، Repository یا NavController نداره. بنابراین Preview و تست کردن اون ساده‌ست.

Render کردن وضعیت‌های مختلف

محتوای صفحه فقط براساس State تصمیم می‌گیره:

@Composable
private fun TrackedProductsContent(
    modifier: Modifier = Modifier,
    state:
        TrackedProductsUiState,
    onSearchQueryChanged:
        (String) -> Unit,
    onRefreshClicked:
        () -> Unit,
    onRetryClicked:
        () -> Unit,
    onProductClicked:
        (Long) -> Unit,
    onDeleteClicked:
        (Long) -> Unit
) {
    Column(
        modifier = modifier
            .fillMaxSize()
    ) {
        ProductSearchField(
            query =
                state.searchQuery,

            onQueryChanged =
                onSearchQueryChanged
        )

        if (state.isOffline) {
            OfflineBanner(
                onRetryClicked =
                    onRetryClicked
            )
        }

        when {
            state.isInitialLoading -> {
                InitialLoadingContent()
            }

            state.showEmptyState -> {
                EmptyTrackedProductsContent()
            }

            state.showNoSearchResult -> {
                NoSearchResultContent(
                    query =
                        state.searchQuery
                )
            }

            else -> {
                TrackedProductList(
                    products =
                        state.products,

                    isRefreshing =
                        state.isRefreshing,

                    deletingProductId =
                        state
                            .deletingProductId,

                    onRefreshClicked =
                        onRefreshClicked,

                    onProductClicked =
                        onProductClicked,

                    onDeleteClicked =
                        onDeleteClicked
                )
            }
        }
    }
}

هیچ متغیر مستقلی داخل Composable وجود نداره که با State اصلی ناسازگار بشه. تمام تصمیم‌های صفحه از یک Snapshot واحد گرفته میشن.

State محلی UI رو بی‌دلیل به ViewModel منتقل نکن

Event-Driven UI به این معنی نیست که هر وضعیت کوچیکی باید داخل ViewModel قرار بگیره. مثلاً باز و بسته بودن یک بخش توضیحات یا وضعیت Focus یک TextField ممکنه فقط به همون Composable مربوط باشه:

@Composable
fun ProductDescription(
    description: String
) {
    var expanded by rememberSaveable {
        mutableStateOf(false)
    }

    Column {
        Text(
            text = description,
            maxLines =
                if (expanded) {
                    Int.MAX_VALUE
                } else {
                    3
                }
        )

        TextButton(
            onClick = {
                expanded = !expanded
            }
        ) {
            Text(
                if (expanded) {
                    "نمایش کمتر"
                } else {
                    "نمایش بیشتر"
                }
            )
        }
    }
}

این Event فقط رفتار یک عنصر UI رو تغییر میده و Business Logic نداره، بنابراین خود UI می‌تونه اون رو مدیریت کنه. Android هم بین Business Logic و UI Behavior Logic تفاوت می‌ذاره: اتفاقی مثل Refresh باید به ViewModel بره، اما باز و بسته شدن یک عنصر نمایشی می‌تونه مستقیماً در UI مدیریت بشه.

قاعده عملی:
اگر State روی منطق کسب‌وکار، چند صفحه یا بازیابی داده اثر دارد:
ViewModel

اگر State فقط شکل موقت یک عنصر را کنترل می‌کند:
Composable

Eventهای خارجی

همه Eventها از لمس کاربر ایجاد نمیشن. تغییر داده در Room، دریافت Push، برگشت اتصال اینترنت و منقضی شدن Session هم می‌تونن منبع تغییر State باشن.

فرض کن Connection Observer این Flow رو منتشر می‌کنه:

interface ConnectivityObserver {

    val status:
        Flow<ConnectivityStatus>
}

ViewModel می‌تونه اون رو به UI State تبدیل کنه:

private fun observeConnectivity() {
    viewModelScope.launch {
        connectivityObserver
            .status
            .collectLatest { status ->
                internalState.update {
                    it.copy(
                        isOffline =
                            status ==
                                ConnectivityStatus
                                    .Unavailable
                    )
                }
            }
    }
}

یا Session Repository اعلام می‌کنه کاربر از حساب خارج شده:

authRepository
    .sessionState
    .collectLatest { session ->
        when (session) {
            SessionState.Expired -> {
                internalState.update {
                    it.copy(
                        navigationTarget =
                            TrackedProductsNavigation
                                .Login
                    )
                }
            }

            else -> Unit
        }
    }

در همه این حالت‌ها، منبع Event متفاوته اما نتیجه همچنان تولید State جدیده.

Reducer؛ وقتی State پیچیده‌تر میشه

در صفحه‌های بزرگ ممکنه ده‌ها Event وجود داشته باشه. برای جلوگیری از تغییر مستقیم State در نقاط مختلف، می‌تونیم یک Reducer بسازیم. Reducer State فعلی و Result تازه رو دریافت می‌کنه و State بعدی رو می‌سازه:

sealed interface TrackedProductsResult {

    data object RefreshStarted :
        TrackedProductsResult

    data object RefreshSucceeded :
        TrackedProductsResult

    data class RefreshFailed(
        val message: UiMessage,
        val isOffline: Boolean
    ) : TrackedProductsResult

    data class DeleteStarted(
        val productId: Long
    ) : TrackedProductsResult

    data class DeleteSucceeded(
        val productId: Long
    ) : TrackedProductsResult

    data class DeleteFailed(
        val message: UiMessage
    ) : TrackedProductsResult
}

Reducer:

class TrackedProductsReducer {

    fun reduce(
        current:
            TrackedProductsUiState,

        result:
            TrackedProductsResult
    ): TrackedProductsUiState {

        return when (result) {
            TrackedProductsResult
                .RefreshStarted -> {

                current.copy(
                    isRefreshing = true,
                    userMessage = null
                )
            }

            TrackedProductsResult
                .RefreshSucceeded -> {

                current.copy(
                    isRefreshing = false,
                    isOffline = false
                )
            }

            is TrackedProductsResult
                .RefreshFailed -> {

                current.copy(
                    isRefreshing = false,
                    isOffline =
                        result.isOffline,
                    userMessage =
                        result.message
                )
            }

            is TrackedProductsResult
                .DeleteStarted -> {

                current.copy(
                    deletingProductId =
                        result.productId
                )
            }

            is TrackedProductsResult
                .DeleteSucceeded -> {

                current.copy(
                    deletingProductId =
                        null,
                    userMessage =
                        UiMessage.Text(
                            "محصول حذف شد."
                        )
                )
            }

            is TrackedProductsResult
                .DeleteFailed -> {

                current.copy(
                    deletingProductId =
                        null,
                    userMessage =
                        result.message
                )
            }
        }
    }
}

این مدل شبیه معماری MVI میشه:

Intent/Event

Processor

Result

Reducer

State

Reducer باید Pure Function باشه؛ یعنی Network، Database یا Logging انجام نده. فقط State فعلی و Result رو به State جدید تبدیل کنه. این ویژگی تست کردن رو بسیار ساده می‌کنه.

برای همه صفحه‌ها نیازی به Reducer مستقل نداریم. صفحه کوچک با MutableStateFlow.update خواناتر می‌مونه. Reducer زمانی ارزش داره که تغییرات State زیاد، وابسته و پیچیده شده باشن.

جلوگیری از پردازش Event تکراری

گاهی کاربر چند بار سریع روی دکمه حذف یا Refresh می‌زنه. بخشی از کنترل در UI انجام میشه:

Button(
    enabled =
        !state.isRefreshing,

    onClick = onRefreshClicked
)

اما ViewModel هم باید از خودش محافظت کنه:

if (
    internalState.value
        .isRefreshing
) {
    return
}

UI قابل‌اعتماد کامل نیست؛ Event ممکنه از تست، Accessibility، چند Composable یا مسیر دیگری وارد بشه. قانون کسب‌وکار باید در ViewModel یا Use Case هم کنترل بشه. برای Eventهای متنی مثل جست‌وجو هم می‌تونیم ورودی رو به Flow تبدیل کنیم:

private val events =
    MutableSharedFlow<
        TrackedProductsEvent
    >(
        extraBufferCapacity = 64
    )

و فقط Search Eventها رو Debounce کنیم:

private fun observeSearchEvents() {
    events
        .filterIsInstance<
            TrackedProductsEvent
                .SearchQueryChanged
        >()
        .map { it.query }
        .debounce(400)
        .distinctUntilChanged()
        .onEach { query ->
            savedStateHandle[
                SEARCH_QUERY_KEY
            ] = query
        }
        .launchIn(viewModelScope)
}

اما برای TextField معمولاً بهتره مقدار نمایش‌داده‌شده فوراً تغییر کنه و فقط درخواست Network با Debounce اجرا بشه. وگرنه تایپ کاربر با تأخیر روی صفحه دیده میشه. بنابراین دو State داریم:

Raw query for UI
Debounced query for API

مثال:

private val searchQuery =
    MutableStateFlow("")

private val debouncedQuery =
    searchQuery
        .debounce(400)
        .distinctUntilChanged()

Event بلافاصله مقدار UI رو تغییر میده:

private fun updateSearchQuery(
    query: String
) {
    searchQuery.value = query
}

اما API فقط از debouncedQuery استفاده می‌کنه.

تست Event-Driven ViewModel

یکی از مهم‌ترین مزیت‌های این معماری، تست‌پذیریه. می‌تونیم Event بفرستیم و State خروجی رو بررسی کنیم. مثال تست Refresh موفق:

@Test
fun refreshClicked_setsLoading_thenSuccess() =
    runTest {

        val repository =
            FakeTrackedProductsRepository()

        val viewModel =
            createViewModel(
                repository =
                    repository
            )

        viewModel.onEvent(
            TrackedProductsEvent
                .RefreshClicked
        )

        advanceUntilIdle()

        val state =
            viewModel.uiState.value

        assertFalse(
            state.isRefreshing
        )

        assertFalse(
            state.isOffline
        )

        assertNotNull(
            state.userMessage
        )
    }

تست جلوگیری از Refresh تکراری:

@Test
fun repeatedRefresh_doesNotStartSecondRequest() =
    runTest {

        val repository =
            FakeTrackedProductsRepository(
                delayMillis = 1_000
            )

        val viewModel =
            createViewModel(
                repository
            )

        viewModel.onEvent(
            TrackedProductsEvent
                .RefreshClicked
        )

        viewModel.onEvent(
            TrackedProductsEvent
                .RefreshClicked
        )

        advanceUntilIdle()

        assertEquals(
            expected = 1,
            actual =
                repository.refreshCallCount
        )
    }

تست Navigation:

@Test
fun productClicked_setsNavigationTarget() =
    runTest {

        val viewModel =
            createViewModel()

        viewModel.onEvent(
            TrackedProductsEvent
                .ProductClicked(
                    productId = 42
                )
        )

        assertEquals(
            TrackedProductsNavigation
                .ProductDetails(42),

            viewModel
                .uiState
                .value
                .navigationTarget
        )
    }

تست مصرف Navigation:

@Test
fun navigationHandled_clearsTarget() =
    runTest {

        val viewModel =
            createViewModel()

        viewModel.onEvent(
            TrackedProductsEvent
                .ProductClicked(42)
        )

        viewModel.onEvent(
            TrackedProductsEvent
                .NavigationHandled
        )

        assertNull(
            viewModel
                .uiState
                .value
                .navigationTarget
        )
    }

چون UI فقط State و Event Handler دریافت می‌کنه، تست Screenshot و Compose هم ساده‌تر میشه:

composeTestRule.setContent {
    TrackedProductsScreen(
        state =
            TrackedProductsUiState(
                products =
                    sampleProducts
            ),

        snackbarHostState =
            SnackbarHostState(),

        onEvent = {}
    )
}

Logging رویدادها

در زمان توسعه می‌تونیم تمام Eventها رو ثبت کنیم:

fun onEvent(
    event: TrackedProductsEvent
) {
    if (BuildConfig.DEBUG) {
        Log.d(
            "TrackedProducts",
            "Event: $event"
        )
    }

    when (event) {
        // handlers
    }
}

برای Production نباید Eventهایی که اطلاعات حساس دارن کامل Log بشن. مثلاً Query جست‌وجو یا URL خصوصی محصول ممکنه داده شخصی کاربر محسوب بشه. بهتره فقط نوع Event و شناسه‌های غیرحساس ثبت بشن. یک Event Logger جدا:

interface UiEventLogger {

    fun log(
        screen: String,
        eventName: String
    )
}

استفاده:

private fun logEvent(
    event: TrackedProductsEvent
) {
    uiEventLogger.log(
        screen =
            "tracked_products",

        eventName =
            when (event) {
                TrackedProductsEvent
                    .RefreshClicked ->
                    "refresh_clicked"

                is TrackedProductsEvent
                    .ProductClicked ->
                    "product_clicked"

                is TrackedProductsEvent
                    .DeleteProductClicked ->
                    "delete_clicked"

                else ->
                    event::class
                        .simpleName
                        .orEmpty()
            }
    )
}

Event-Driven UI مشاهده رفتار کاربر و Debug کردن جریان‌ها رو ساده‌تر می‌کنه، چون هر ورودی نام مشخصی داره.

اشتباه‌های رایج

یکی از اشتباه‌های رایج اینه که تمام اتفاق‌ها رو فقط برای شیک شدن معماری داخل یک UiEvent قرار بدیم، اما State همچنان در چند متغیر جدا و نامرتبط پخش باشه. Event-Driven UI فقط داشتن Sealed Class نیست؛ باید جریان تغییر State هم یک‌طرفه و قابل‌ردیابی باشه.

اشتباه دوم اینه که UI مستقیماً State ViewModel رو تغییر بده:

viewModel.uiState.value.isLoading = true

State باید Immutable و فقط از طریق Event Handler تغییر کنه. Compose هم توصیه می‌کنه مقادیر Immutable و Callbackهای Event به Composable داده بشن تا احتمال تغییر مستقیم State و مشکلات هم‌زمانی کمتر بشه. اشتباه سوم اینه که هر Snackbar و Navigation با SharedFlow ارسال بشه، بدون اینکه درباره Lifecycle و احتمال ازدست‌رفتن رویداد فکر کنیم. اشتباه چهارم اینه که تمام Stateهای کوچک UI به ViewModel منتقل بشن و ViewModel از جزئیات نمایشی پر بشه:

TextField focus
Animation progress
Expanded card
Scroll indicator

اگر این Stateها ارزش کسب‌وکاری یا نیاز به ماندگاری ندارن، بهتره محلی باقی بمونن. اشتباه پنجم اینه که یک Event کلی و مبهم بسازیم:

data class Click(
    val id: Int
)

بعد با عددها حدس بزنیم کدوم دکمه لمس شده. Event باید معنای روشنی داشته باشه:

RefreshClicked
ProductClicked
DeleteProductClicked

اشتباه ششم اینه که Event Handler مستقیماً هزاران خط منطق داشته باشه. onEvent باید Router باشه و منطق هر عملیات به تابع، Use Case یا Reducer مناسب منتقل بشه. اشتباه هفتم هم اینه که Stateهای ناممکن بسازیم. مثلاً هم‌زمان:

isInitialLoading = true
showEmptyState = true

Stateهای مشتق‌شده مثل showEmptyState بهتره از State اصلی محاسبه بشن تا تناقض ایجاد نشه.

معماری نهایی صفحه قیمت‌بان

جریان کامل صفحه محصولات به این شکل درمیاد:

User types query

SearchQueryChanged

ViewModel updates query state

Combined products are filtered

New UiState is emitted

Compose renders filtered list

Refresh:

User taps refresh

RefreshClicked

ViewModel sets isRefreshing

Use case refreshes repository

Room is updated

Product Flow emits new data

UiState changes

Compose renders fresh data

حذف:

DeleteProductClicked

State contains pending product

UI displays confirmation dialog

DeleteDialogConfirmed

Use case deletes product

Room emits updated list

Snackbar message enters state

UI shows Snackbar

UserMessageShown

Message is cleared

Navigation:

ProductClicked

navigationTarget enters state

UI performs navigation

NavigationHandled

navigationTarget is cleared

در این معماری، UI هیچ منطق کسب‌وکاری انجام نمیده. ViewModel هم هیچ Composable، SnackbarHost یا NavController رو نمی‌شناسه. ارتباط دو طرف فقط با State و Event انجام میشه.

چک‌لیست Event-Driven UI

پیش از نهایی کردن هر صفحه در قیمت‌بان، این موارد رو بررسی می‌کنیم:

  • تمام ورودی‌های مهم صفحه Event مشخص داشته باشن.
  • Event نام اتفاق رو توصیف کنه، نه دستور پیاده‌سازی رو.
  • UI State تمام اطلاعات لازم برای Render رو داشته باشه.
  • State از بیرون Immutable باشه.
  • ViewModel تنها محل تغییر State کسب‌وکاری باشه.
  • UI فقط State رو بخونه و Event بفرسته.
  • داده از بالا به پایین و Event از پایین به بالا حرکت کنه.
  • Stateهای مشتق‌شده از مقادیر اصلی محاسبه بشن.
  • Loading اولیه با Refresh از هم جدا باشن.
  • Snackbar مهم به‌عنوان State مصرف‌شونده مدیریت بشه.
  • Navigation مهم در برابر وقفه Lifecycle از بین نره.
  • بعد از مصرف پیام یا Navigation، Event پاک‌سازی ارسال بشه.
  • SharedFlow فقط با شناخت رفتار Hot Stream استفاده بشه.
  • وضعیت‌های محلی و صرفاً نمایشی داخل Composable باقی بمونن.
  • ViewModel به NavController، Context یا Composable وابسته نباشه.
  • Screen Stateless از Route دارای ViewModel جدا باشه.
  • Flowها با Lifecycle مناسب Collect بشن.
  • Event تکراری یا کلیک چندباره در ViewModel هم کنترل بشه.
  • عملیات Async داخل Use Case یا Repository انجام بشه.
  • Event Handler مرکزی به تابع غول‌پیکر تبدیل نشه.
  • تغییرات State قابل‌تست باشن.
  • Eventهای حساس بدون فیلتر وارد Log یا Analytics نشن.

جمع‌بندی

Event-Driven UI یعنی رابط کاربری به‌جای تغییر مستقیم اجزای مختلف برنامه، اتفاق‌ها رو به شکل Event اعلام کنه. ViewModel این اتفاق‌ها رو دریافت می‌کنه، منطق لازم رو اجرا می‌کنه و State تازه‌ای تولید می‌کنه. UI هم فقط State رو Render می‌کنه. نتیجه این جریان، رابط کاربری قابل‌پیش‌بینی‌تر، تست‌پذیرتر و مقاوم‌تر در برابر پیچیدگیه.

در قیمت‌بان، اتفاق‌هایی مثل لمس Refresh، تغییر عبارت جست‌وجو، انتخاب محصول، حذف و Retry همگی Event هستن. فهرست محصولات، وضعیت Loading، عبارت جست‌وجو، Dialog حذف و پیام در انتظار نمایش، State محسوب میشن. Navigation و Snackbar هم نباید بدون فکر به‌عنوان پیام‌های لحظه‌ای فرستاده بشن؛ چون ممکنه هنگام نبود Collector از بین برن. برای عملیات مهم، State مصرف‌شونده معمولاً انتخاب مطمئن‌تریه.

این معماری به این معنا نیست که هر کلیک ساده باید از پنج لایه عبور کنه. باز و بسته شدن یک بخش نمایشی می‌تونه داخل خود Composable باقی بمونه. مرز اصلی اینه: UI Logic محلی در UI مدیریت میشه، اما اتفاقی که Business Logic رو فعال می‌کنه یا روی وضعیت پایدار صفحه اثر می‌ذاره، باید به ViewModel منتقل بشه.

با این ساختار، هر صفحه به یک ماشین قابل‌ردیابی تبدیل میشه: Event وارد میشه، State تغییر می‌کنه و UI دوباره براساس State ساخته میشه. همین چرخه ساده، پایه بسیاری از معماری‌های مدرن مثل UDF و MVI محسوب میشه و اجازه میده قیمت‌بان بدون تبدیل شدن به مجموعه‌ای از Callbackها و متغیرهای پراکنده، به‌مرور بزرگ‌تر بشه.

درس شصت‌وششم

طراحی برای توسعه آینده (Designing for Future Growth)

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

اما مسئله اینجاست که نرم‌افزار برای امروز نوشته نمیشه؛ برای شش ماه، یک سال یا حتی چند سال بعد نوشته میشه. فرض کن امروز قیمت‌بان فقط قیمت محصولات دیجی‌کالا رو بررسی می‌کنه. شش ماه بعد کارفرما میگه:

  • ترب هم اضافه کن.
  • آمازون هم اضافه کن.
  • امکان مقایسه قیمت فروشگاه‌ها رو هم اضافه کن.
  • سیستم اعلان ایمیلی هم میخوایم.
  • نسخه iOS هم قراره ساخته بشه.
  • پنل مدیریت هم لازم داریم.
  • API عمومی هم میخوایم.

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


بزرگ‌ترین اشتباه برنامه‌نویس‌ها

خیلی‌ها فکر می‌کنن طراحی برای آینده یعنی از همون روز اول همه چیز رو پیچیده طراحی کنیم.

مثلاً برای پروژه‌ای که فقط یک دیتابیس داره، این ساختار رو میسازن:

RepositoryFactory
AbstractRepository
RepositoryProvider
RepositoryManager
RepositoryAdapter
RepositoryStrategy
RepositoryRegistry

در حالی که پروژه فقط یک Repository داره! این اسمش طراحی آینده نیست. این اسمش Over Engineering یا مهندسی بیش از اندازه است. معماری خوب، ساده‌ترین معماری‌ایه که تغییرات آینده رو ممکن می‌کنه. نه ساده‌تر. نه پیچیده‌تر.


آینده دقیقاً چه چیزی را تغییر می‌دهد؟

اگر پروژه‌های بزرگ دنیا رو بررسی کنیم، تقریباً همه تغییرات آینده داخل چند دسته قرار می‌گیرن. مثلاً:

دیتابیس عوض می‌شود.
API تغییر می‌کند.
UI تغییر می‌کند.
Business Rule تغییر می‌کند.
Feature جدید اضافه می‌شود.
Platform جدید اضافه می‌شود.
تعداد کاربران زیاد می‌شود.
Performance مهم‌تر می‌شود.

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


معماری باید تغییر را ارزان کند

فرض کن امروز Repository این شکلیه:

class ProductRepository {

    suspend fun getProducts(): List<Product> {
        ...
    }

}

بعداً تصمیم می‌گیری Cache اضافه کنی. اگر کل برنامه مستقیم با Retrofit کار کرده باشه، باید صدها فایل رو تغییر بدی. اما اگر همه از Repository استفاده کرده باشن، فقط Repository تغییر می‌کنه. این یعنی:

Cost of Change ↓

هزینه تغییر کم شده. این مهم‌ترین وظیفه معماریه.


قانون Open / Closed

یکی از معروف‌ترین قوانین SOLID همین موضوع رو توضیح میده.

میگه کلاس‌ها باید:

Open for Extension

Closed for Modification

یعنی:

بتونی قابلیت جدید اضافه کنی…

بدون اینکه مجبور بشی کدهای قبلی رو خراب کنی.

فرض کن امروز قیمت‌بان فقط این نوع اعلان رو داره:

sealed interface NotificationType {

    data object PriceDrop : NotificationType

}

شش ماه بعد:

sealed interface NotificationType {

    data object PriceDrop : NotificationType

    data object ProductAvailable : NotificationType

    data object PriceTargetReached : NotificationType

}

ما چیزی از سیستم قبلی خراب نکردیم.

فقط قابلیت جدید اضافه کردیم.


وابستگی به پیاده‌سازی، دشمن آینده است

فرض کن ViewModel مستقیم Retrofit رو صدا بزنه.

class HomeViewModel {

    val api = Retrofit....

}

بعداً اگر API عوض بشه…

تمام ViewModelها باید تغییر کنن.

اما اگر ViewModel فقط این رو بدونه:

interface ProductRepository {

    suspend fun getProducts()

}

دیگه براش مهم نیست اطلاعات از کجا میان.

ممکنه:

Retrofit

Room

GraphQL

Firebase

Local JSON

Mock Server

همه پشت همین Interface قرار بگیرن.


Feature محور طراحی کن، نه Layer محور

یکی از مشکلات پروژه‌های قدیمی اینه:

activities

fragments

viewmodels

repositories

models

adapters

utils

helpers

network

database

وقتی صفحه Product رو باز می‌کنی باید بین ده پوشه بچرخی.

در پروژه بزرگ این ساختار شکنجه است.

ساختار بهتر:

features

    product

        ui

        domain

        data

    profile

    search

    login

حالا هر Feature تقریباً مستقل شده.

بعداً اگر بخوای Feature جدید اضافه کنی، تقریباً هیچ چیز قبلی تغییر نمی‌کنه.


از Utility های غول‌پیکر فرار کن

تقریباً همه پروژه‌های قدیمی یه فایل دارن به اسم:

Utils.kt

بعد داخلش:

Date

Price

Bitmap

Validation

Toast

Permission

File

Network

Json

Image

Color

همه چیز ریخته شده داخلش. شش ماه بعد:

Utils.kt

4200 Lines

و هیچ‌کس جرئت تغییرش رو نداره. بهتره هر موضوع Utility خودش رو داشته باشه. مثلاً:

PriceFormatter

DateFormatter

NetworkChecker

PermissionManager

FileDownloader

Configuration را از کد جدا کن

مثلاً این اشتباهه:

const val BASE_URL =
    "https://api.gheimatban.com"

بعداً برای تست:

Stage

QA

Production

Development

همه جا باید تغییر بدی. بهتره Configuration مستقل باشه. مثلاً:

BuildConfig

Remote Config

Gradle

Flavor

عددهای جادویی ننویس

این کد:

delay(3000)

سه ماه بعد: هیچ‌کس نمی‌دونه چرا سه ثانیه. بهتره:

const val RETRY_DELAY =
    3.seconds

یا:

NetworkConfig.RetryDelay

Enum بهتر از String است

مثلاً:

if(status=="ACTIVE")

بعداً:

ACTIVE

Active

active

Activated

همه پروژه به هم میریزه. بهتر:

enum class ProductStatus {

    ACTIVE,

    INACTIVE,

    DELETED

}

قراردادها را نسخه‌بندی کن

فرض کن امروز Push این شکلیه:

{
    "type":"PRICE_DROP"
}

سال بعد:

{
    "type":"PRICE_DROP",

    "discount":20
}

نسخه بعد:

{
    "schemaVersion":2
}

نسخه‌بندی باعث میشه نسخه‌های قدیمی برنامه هم قابل مدیریت باشن.


همیشه نقطه ورود مشخص داشته باش

مثلاً Login. این اشتباهه:

LoginActivity

Api

Room

Preference

Firebase

Analytics

همه جا مستقیماً همدیگه رو صدا میزنن. بهتره:

LoginViewModel

LoginUseCase

Repositories

همه چیز از یک نقطه عبور کنه.


Feature Flag

یکی از بهترین ابزارهای توسعه آینده Feature Flagها هستن. مثلاً:

if(featureFlags.newSearch)

بدون انتشار نسخه جدید میشه قابلیت رو روشن یا خاموش کرد. حتی برای درصدی از کاربران.


وابستگی‌ها را قابل تعویض نگه دار

اگر Image Loader عوض شد…

نباید کل پروژه تغییر کنه. مثلاً:

interface ImageLoader {

    suspend fun load(url:String)

}

امروز:

Coil

فردا:

Glide

پس‌فردا:

Picasso

UI هیچ تغییری نمی‌کنه.


همیشه برای حذف آماده باش

بیشتر برنامه‌نویس‌ها فقط به اضافه کردن فکر می‌کنن. در حالی که حذف Feature هم اتفاق میفته. اگر حذف یک قابلیت باعث خراب شدن پنجاه کلاس بشه، یعنی معماری مشکل داره.


مستندسازی معماری

قرار نیست فقط کد بنویسی. چند فایل ساده ارزش فوق‌العاده‌ای دارن. مثلاً:

Architecture.md

Folder Structure.md

API Contract.md

Navigation.md

Modules.md

وقتی تیم بزرگ بشه، این فایل‌ها از هزاران خط کد ارزشمندتر میشن.


از God Object فرار کن

این کلاس‌ها خطرناکن:

AppManager

ApplicationManager

DataManager

SystemManager

MainManager

بعد داخلشون:

Authentication

Network

Database

Push

Cache

Preference

Analytics

Location

Permission

Notification

این کلاس‌ها معمولاً چند هزار خط میشن. هیچ مسئولیت مشخصی هم ندارن.


Dependency Injection آینده را ارزان می‌کند

فرض کن امروز:

val repository =
    ProductRepository()

همه جا این رو ساختی. بعداً: Repository پنج وابستگی جدید پیدا می‌کنه. تمام پروژه باید تغییر کنه. اما اگر DI داشته باشی:

class ProductViewModel(

    private val repository:
        ProductRepository

)

تنها Container تغییر می‌کنه.


کدهای قابل حذف بنویس

یکی از معیارهای معماری خوب اینه:

اگر امروز این Feature حذف بشه…

چند فایل باید تغییر کنن؟ اگر جواب: 120 فایل باشه… یعنی Coupling خیلی زیاده.


YAGNI را فراموش نکن

یکی از معروف‌ترین قوانین مهندسی نرم‌افزار:

You Aren't Gonna Need It

یعنی:

چیزی رو فقط وقتی بساز که واقعاً بهش نیاز داری.

نه اینکه شاید دو سال بعد لازم بشه.

این قانون دقیقاً تعادل بین:

Future Design

و

Over Engineering

رو ایجاد می‌کنه.


معماری قیمت‌بان بعد از یک سال

هدف ما این نیست که آینده رو پیش‌بینی کنیم. هدف اینه که اگر فردا این قابلیت‌ها اضافه شدن:

Amazon

Torob

Digikala

Notification Center

AI Price Prediction

Widget

Wear OS

Desktop

Public API

Admin Panel

Email Notification

SMS Notification

Coupon System

User Ranking

Price History

Charts

قرار نباشه کل پروژه رو از اول بنویسیم.

بلکه فقط Feature جدید رو اضافه کنیم.


چک‌لیست طراحی برای آینده

قبل از اینکه هر Feature جدید رو داخل قیمت‌بان پیاده‌سازی کنی، این سؤال‌ها رو از خودت بپرس:

  • اگر API عوض بشه، چند فایل تغییر می‌کنن؟
  • اگر دیتابیس تغییر کنه، UI متوجه میشه؟
  • اگر یک Feature حذف بشه، چند بخش پروژه آسیب می‌بینن؟
  • آیا این کلاس فقط یک مسئولیت داره؟
  • آیا وابستگی‌ها از طریق Interface تعریف شدن؟
  • آیا Feature جدید بدون تغییر Featureهای قبلی اضافه میشه؟
  • آیا Configuration از کد جدا شده؟
  • آیا Magic Number وجود داره؟
  • آیا Stringهای ثابت باید Enum باشن؟
  • آیا قراردادهای شبکه نسخه‌بندی شدن؟
  • آیا Featureها مستقل از هم هستن؟
  • آیا Utility غول‌پیکر ساختی؟
  • آیا این پیچیدگی واقعاً لازمه یا فقط حدس می‌زنی شاید یک روز لازم بشه؟

جمع‌بندی

طراحی برای توسعه آینده یعنی امروز طوری کد بنویسیم که تغییرات فردا ارزان باشن، نه اینکه آینده رو پیش‌بینی کنیم. معماری خوب قرار نیست همه سناریوهای ممکن رو از قبل پیاده‌سازی کنه؛ قرار است در برابر تغییر انعطاف داشته باشه. هرچه وابستگی بین بخش‌های مختلف کمتر باشه، هر Feature مستقل‌تر باشه و قراردادها شفاف‌تر تعریف شده باشن، اضافه کردن قابلیت‌های جدید با ریسک و هزینه کمتری انجام میشه.

در قیمت‌بان هم ممکنه امروز فقط چند فروشگاه، چند صفحه و چند API داشته باشیم، اما اگر از همین حالا مرز بین Featureها، Repositoryها، قراردادهای شبکه و لایه‌های مختلف پروژه رو درست طراحی کنیم، اضافه شدن فروشگاه‌های جدید، سیستم اعلان‌های پیشرفته، نسخه iOS، پنل مدیریت یا حتی سرویس‌های هوش مصنوعی، به معنی بازنویسی کل پروژه نخواهد بود. این دقیقاً ارزش واقعی معماریه؛ کم کردن هزینه تغییر، نه زیاد کردن تعداد کلاس‌ها.

درس شصت‌وهفتم

Logging و Crash Reporting (بخش اول)

تا اینجا پروژه قیمت‌بان تقریباً به یک اپلیکیشن واقعی تبدیل شده است. از Room استفاده کردیم، با Retrofit به سرور وصل شدیم، WorkManager نوشتیم، Push Notification اضافه کردیم، معماری MVVM ساختیم، Stateها را مدیریت کردیم و روی Performance هم کار کردیم. اما یک سؤال مهم باقی مانده است.

اگر فردا همین برنامه را در Google Play منتشر کنی و هزاران نفر آن را نصب کنند، از کجا می‌فهمی داخل گوشی کاربران چه اتفاقی می‌افتد؟

وقتی خودت برنامه را اجرا می‌کنی، Android Studio باز است، Logcat را می‌بینی و اگر خطایی رخ بدهد، Stack Trace جلوی چشمت ظاهر می‌شود. اما کاربران Android Studio ندارند. اگر برنامه روی گوشی آن‌ها خراب شود، تو هیچ چیزی نمی‌بینی. تنها چیزی که شاید دریافت کنی، یک پیام کوتاه باشد: برنامه بسته شد. همین.

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

به همین دلیل، یکی از مهم‌ترین تفاوت‌های یک برنامه آموزشی با یک برنامه Production همین Logging و Crash Reporting است. برنامه حرفه‌ای فقط کدی نیست که درست کار کند؛ برنامه حرفه‌ای باید وقتی مشکلی هم پیش آمد، بتواند آن مشکل را برای توسعه‌دهنده توضیح بدهد.


برنامه بدون Logging مثل رانندگی در شب بدون چراغ است

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

Logging دقیقاً همین چراغ است. قرار نیست برنامه را سریع‌تر کند. قرار نیست قابلیت جدیدی اضافه کند. قرار است اتفاقات داخل برنامه را قابل مشاهده کند.


Logging چیست؟

به زبان ساده، Logging یعنی ثبت کردن اتفاقاتی که داخل برنامه رخ می‌دهند. مثلاً:

  • کاربر وارد برنامه شد.
  • درخواست API ارسال شد.
  • پاسخ سرور دریافت شد.
  • دانلود تصویر شروع شد.
  • دانلود تصویر تمام شد.
  • کاربر محصولی را حذف کرد.
  • خطایی رخ داد.
  • Worker اجرا شد.

همه این اتفاق‌ها می‌توانند ثبت شوند.


اولین اشتباه برنامه‌نویس‌ها

تقریباً همه ما در ابتدای کار Logging را این شکلی یاد گرفته‌ایم:

Log.d("TAG", "Button Clicked")

یا:

Log.d("API", response.body().toString())

یا حتی:

println("Success")

این کدها برای پروژه‌های آموزشی بد نیستند. اما اگر همین روش را داخل یک پروژه بزرگ ادامه بدهی، بعد از چند ماه صدها Log خواهی داشت که هیچ ارزشی ندارند. مثلاً Logcat چیزی شبیه این می‌شود:

Done

Clicked

Success

API Success

Error

Loading

Loading Finished

Response Received

Clicked Again

این Logها هیچ اطلاعات مفیدی به تو نمی‌دهند.


یک Log خوب چه ویژگی‌هایی دارد؟

فرض کن کاربری گزارش داده که هنگام ثبت محصول، برنامه خطا می‌دهد. کدام Log مفیدتر است؟

Error

یا

Failed to save product

یا

Save Product Failed

ProductId = 281

UserId = 54

Database = Room

Reason = Duplicate Primary Key

واضح است که مورد سوم ارزش واقعی دارد. یک Log خوب باید بتواند به این سؤال‌ها پاسخ بدهد:

  • چه اتفاقی افتاد؟
  • کجا اتفاق افتاد؟
  • برای چه کسی اتفاق افتاد؟
  • چرا اتفاق افتاد؟
  • نتیجه چه بود؟

Logging فقط برای خطا نیست

یکی از اشتباه‌های رایج این است که فکر کنیم فقط هنگام Exception باید Log بنویسیم. در واقع Logging چهار کاربرد اصلی دارد.

۱. بررسی رفتار برنامه

مثلاً ببینی کاربر واقعاً وارد صفحه تاریخچه قیمت شده یا نه.

Navigate -> PriceHistory

۲. بررسی عملکرد

مثلاً ببینی دانلود اطلاعات چند میلی‌ثانیه طول کشیده است.

Products Loaded

Time = 482 ms

۳. بررسی خطا

مثلاً درخواست API شکست خورده است.

Network Request Failed

Code = 500

۴. تحلیل رفتار کاربران

مثلاً بفهمی بیشتر کاربران از کدام قابلیت استفاده می‌کنند.

Search Executed

انواع Log

در اندروید پنج سطح اصلی برای Log وجود دارد.

Verbose

ریزترین سطح Log است. معمولاً هنگام Debug کردن بخش خاصی از برنامه استفاده می‌شود. مثلاً:

RecyclerView Item Created

Debug

رایج‌ترین نوع Log. برای توسعه استفاده می‌شود. مثلاً:

User Loaded From Room

Info

وقتی یک اتفاق مهم ولی طبیعی رخ داده است. مثلاً:

User Logged In Successfully

Warning

برنامه هنوز کار می‌کند اما یک وضعیت غیرعادی رخ داده است. مثلاً:

Image Not Found

Using Placeholder

Error

وقتی برنامه نتوانسته کاری را انجام دهد. مثلاً:

Database Insert Failed

آیا باید همه جا Log بنویسیم؟

خیر. این یکی از بدترین کارهایی است که می‌توان انجام داد. مثلاً:

fun addProduct(product: Product) {

    Log.d("APP","Step 1")

    Log.d("APP","Step 2")

    Log.d("APP","Step 3")

    Log.d("APP","Step 4")

}

این کد فقط Logcat را شلوغ می‌کند. به جای آن باید فقط اتفاق‌های مهم ثبت شوند. مثلاً:

Log.i(
    "ProductRepository",
    "Saving Product ${product.id}"
)

یا:

Log.e(
    "Network",
    "Request Failed"
)

مشکل بزرگ استفاده مستقیم از Log

فرض کن در کل پروژه ۱۸۰۰ بار این کد را نوشته‌ای:

Log.d(...)

شش ماه بعد تصمیم می‌گیری دیگر از Logcat استفاده نکنی و همه Logها را به Crashlytics بفرستی. چه اتفاقی می‌افتد؟ باید ۱۸۰۰ فایل را ویرایش کنی. به همین دلیل پروژه‌های حرفه‌ای تقریباً هیچ‌وقت مستقیماً از Log استفاده نمی‌کنند. آن‌ها یک لایه واسط می‌سازند. مثلاً:

interface Logger {

    fun d(tag: String, message: String)

    fun i(tag: String, message: String)

    fun w(tag: String, message: String)

    fun e(
        tag: String,
        message: String,
        throwable: Throwable? = null
    )

}

حالا داخل برنامه به جای اینکه بنویسیم:

Log.d(...)

می‌نویسیم:

logger.d(
    "Repository",
    "Loading Products"
)

در نگاه اول تفاوت زیادی ندارد. اما حالا کل برنامه فقط Logger را می‌شناسد. نه Logcat را. نه Timber را. نه Crashlytics را. اگر فردا تصمیم بگیری Logger را عوض کنی، فقط یک کلاس تغییر می‌کند و بقیه پروژه بدون هیچ تغییری به کارش ادامه می‌دهد.

این دقیقاً همان اصل مهمی است که بارها در فصل‌های قبل درباره آن صحبت کردیم: کلاس‌ها باید به انتزاع (Interface) وابسته باشند، نه به پیاده‌سازی.


پیاده‌سازی AndroidLogger

حالا اولین پیاده‌سازی Logger را می‌نویسیم.

class AndroidLogger : Logger {

    override fun d(
        tag: String,
        message: String
    ) {
        Log.d(tag, message)
    }

    override fun i(
        tag: String,
        message: String
    ) {
        Log.i(tag, message)
    }

    override fun w(
        tag: String,
        message: String
    ) {
        Log.w(tag, message)
    }

    override fun e(
        tag: String,
        message: String,
        throwable: Throwable?
    ) {
        Log.e(tag, message, throwable)
    }

}

از این لحظه به بعد هیچ کلاس دیگری نباید مستقیماً Log.d یا Log.e را صدا بزند. همه کلاس‌ها فقط با Logger کار می‌کنند.


چرا این کار ارزش دارد؟

فرض کن فردا بخواهیم از Timber استفاده کنیم. فقط همین کلاس تغییر می‌کند. یا اگر بخواهیم تمام Errorها را علاوه بر Logcat به Firebase Crashlytics هم ارسال کنیم، باز هم فقط همین کلاس تغییر می‌کند. Repository، ViewModel، Worker، Service و بقیه قسمت‌های برنامه حتی متوجه این تغییر نمی‌شوند. این دقیقاً همان معماری‌ای است که باعث می‌شود پروژه بعد از دو یا سه سال هم قابل نگهداری باقی بماند.


جمع‌بندی بخش اول

در این بخش یاد گرفتیم که Logging فقط چاپ چند متن داخل Logcat نیست، بلکه بخشی از معماری یک برنامه Production است. دیدیم که یک Log خوب باید اطلاعات مفیدی درباره اتفاقات برنامه ثبت کند، نه اینکه فقط چند کلمه مبهم چاپ کند. همچنین فهمیدیم وابسته کردن کل پروژه به کلاس Log تصمیم درستی نیست و بهتر است از یک Interface مانند Logger استفاده کنیم تا بعدها بتوانیم بدون تغییر کل پروژه، روش ثبت Log را عوض کنیم.

در بخش دوم، Logger را حرفه‌ای‌تر می‌کنیم، با Timber آشنا می‌شویم، یاد می‌گیریم چگونه Logهای ساختاریافته (Structured Logging) بنویسیم، Performance برنامه را اندازه‌گیری کنیم و Logging شبکه (Network Logging) را به شکلی امن پیاده‌سازی کنیم.

درس شصت‌وهفتم

Logging و Crash Reporting (بخش دوم)

در بخش قبل فهمیدیم که Logging فقط چند تا Log.d نیست، بلکه بخشی از معماری برنامه است. همچنین یک Interface به نام Logger ساختیم تا کل پروژه به جای وابسته شدن به کلاس Log، فقط با یک انتزاع کار کند. حالا می‌خواهیم این Logger را یک قدم حرفه‌ای‌تر کنیم. چون هنوز یک مشکل بزرگ وجود دارد.


مشکل Logcat

فرض کن برنامه قیمت‌بان را منتشر کرده‌ای و یکی از کاربران گزارش می‌دهد که برنامه هنگام مشاهده تاریخچه قیمت‌ها کرش می‌کند. اولین کاری که می‌کنی چیست؟ برنامه را اجرا می‌کنی. وارد Logcat می‌شوی. اما چیزی شبیه این می‌بینی:

RecyclerView Attached

GC Freed 12MB

Insets Changed

Surface Updated

Activity Resumed

OpenGL Warning

Skipped Frames

Database Loaded

Window Focus Changed

Network Callback

مشکل اینجاست که Logcat فقط Logهای برنامه تو را نشان نمی‌دهد. کل سیستم اندروید هم در حال Log نوشتن است. سرویس‌های گوگل هم Log می‌نویسند. کتابخانه‌های مختلف هم Log می‌نویسند. در نتیجه پیدا کردن Logهای خودت کم‌کم سخت می‌شود.


Tag چیست؟

به همین دلیل هر Log یک Tag دارد. مثلاً:

Log.d(
    "ProductRepository",
    "Products Loaded"
)

اینجا:

ProductRepository

Tag است. با استفاده از Tag می‌توانی داخل Logcat فقط Logهای مربوط به همان قسمت را ببینی. مثلاً:

ProductRepository

یا:

Network

یا:

PriceHistory

Tagهای خوب

بدترین Tag ممکن:

TAG

یا:

APP

یا:

TEST

این Tagها هیچ اطلاعاتی نمی‌دهند. بهتر است Tag دقیقاً نام همان بخش باشد. مثلاً:

ProductRepository
PriceHistoryWorker
NotificationManager
AuthApi

وقتی شش ماه بعد دوباره Logها را ببینی، فوراً متوجه می‌شوی هر Log متعلق به کدام قسمت است.


مشکل Logger فعلی

Logger ما هنوز ساده است. اگر این کد را بنویسیم:

logger.e(
    "Repository",
    "Insert Failed"
)

تنها چیزی که ثبت می‌شود همین متن است. اما اگر Exception داشته باشیم چه؟

مثلاً:

try {

    repository.save(product)

} catch (e: Exception) {

}

اگر فقط بنویسیم:

logger.e(
    "Repository",
    "Insert Failed"
)

علت واقعی خطا را از دست می‌دهیم. به همین دلیل متد e() یک پارامتر دیگر هم دارد.

override fun e(
    tag: String,
    message: String,
    throwable: Throwable?
)

حالا می‌توانیم Exception را هم ارسال کنیم.

catch (e: Exception) {

    logger.e(
        "Repository",
        "Insert Failed",
        e
    )

}

در نتیجه Stack Trace هم ثبت می‌شود.


هر Exception را Catch نکن

یکی از اشتباه‌های رایج این است:

try {

    ...

} catch (e: Exception) {

}

و تمام. هیچ Logی هم نوشته نمی‌شود. در نتیجه برنامه خراب نمی‌شود، اما خطا هم برای همیشه گم می‌شود. اگر واقعاً لازم است Exception را Catch کنی، حداقل آن را ثبت کن.

catch (e: Exception){

    logger.e(
        "Repository",
        "Unexpected Error",
        e
    )

}

Structured Logging

فرض کن این Log را ببینی:

Login Failed

خوب است؟ نه.

چون سؤال‌های زیادی باقی می‌ماند. کدام کاربر؟ کدام صفحه؟ کدام API؟ کدام نسخه برنامه؟ به جای آن بهتر است اطلاعات بیشتری ثبت کنیم.

مثلاً:

Screen = Login

Action = Login

UserId = 548

Api = Auth

Reason = Unauthorized

به این روش Structured Logging می‌گویند. یعنی Log فقط یک جمله نیست. بلکه مجموعه‌ای از اطلاعات مرتبط است. این مدل باعث می‌شود بعداً بتوانی راحت‌تر مشکل را پیدا کنی.


Performance Logging

Logging فقط برای خطا نیست. گاهی می‌خواهیم سرعت قسمت‌های مختلف برنامه را اندازه بگیریم. مثلاً: ارسال درخواست شبکه. لود شدن Room. بارگذاری تصویر. باز شدن صفحه. همه این‌ها قابل اندازه‌گیری هستند. مثلاً:

val start = System.currentTimeMillis()

repository.loadProducts()

val end = System.currentTimeMillis()

logger.i(
    "Performance",
    "Loading took ${end-start} ms"
)

اگر خروجی این باشد:

Loading took 185 ms

مشکلی نیست. اما اگر بنویسد:

Loading took 6120 ms

فوراً متوجه می‌شوی یک جای کار مشکل دارد.


اندازه‌گیری API

مثلاً هنگام دریافت قیمت‌ها.

val start = System.currentTimeMillis()

api.getProducts()

val end = System.currentTimeMillis()

logger.i(

    "Network",

    "Products API = ${end-start} ms"

)

بعد از مدتی متوجه می‌شوی:

Products API

185 ms

ولی:

Price History API

4200 ms

پس مشکل احتمالاً از همان API است.


اندازه‌گیری Room

فقط شبکه نیست. Database هم می‌تواند کند باشد. مثلاً:

val start = System.currentTimeMillis()

dao.getProducts()

val end = System.currentTimeMillis()

logger.i(

    "Room",

    "Query = ${end-start} ms"

)

اگر یک Query پنج ثانیه طول بکشد، قبل از اینکه کاربر شکایت کند خودت متوجه می‌شوی.


Logging شبکه

یکی از مهم‌ترین قسمت‌های هر برنامه، شبکه است. فرض کن API جواب نمی‌دهد. آیا مشکل از سرور است؟ یا اینترنت؟ یا Timeout؟ یا JSON؟ یا Authentication؟ برای پیدا کردن این مشکلات، معمولاً از Logging Interceptor استفاده می‌شود. مثلاً هنگام ارسال درخواست، اطلاعاتی مثل این ثبت می‌شود:

GET

/api/products

و بعد:

Response

200 OK

یا:

404

یا:

500

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


اما یک اشتباه بسیار خطرناک

بعضی برنامه‌نویس‌ها همه اطلاعات Request را Log می‌کنند.

مثلاً:

Authorization:

Bearer eyJhb...

یا:

Refresh Token

یا:

Password

یا:

OTP

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


چه چیزهایی را هرگز نباید Log کنیم؟

این لیست را همیشه به خاطر بسپار. هیچ‌وقت این اطلاعات را داخل Log ننویس:

  • Password
  • Access Token
  • Refresh Token
  • JWT
  • OTP
  • شماره کارت
  • CVV2
  • Cookie
  • Session ID
  • کلیدهای API
  • اطلاعات پزشکی
  • آدرس دقیق کاربر
  • اطلاعات شخصی محرمانه

اگر هم مجبور شدی چیزی را ثبت کنی، آن را Mask کن.

مثلاً:

به جای:

AccessToken:

eyJhbGciOi...

بنویس:

AccessToken:

*************

یا فقط چند کاراکتر اول را نشان بده.

eyJh******

BuildConfig.DEBUG

همه Logها نباید داخل نسخه Release اجرا شوند. فرض کن در هر صفحه ده‌ها Log نوشته‌ای. اگر همین‌ها داخل نسخه نهایی هم اجرا شوند:

  • سرعت کمی کاهش پیدا می‌کند.
  • حجم Logها زیاد می‌شود.
  • اطلاعات اضافه تولید می‌شود.

به همین دلیل معمولاً Logger بررسی می‌کند که آیا برنامه در حالت Debug است یا خیر. اگر Debug باشد، Log چاپ می‌شود. اگر Release باشد، بسیاری از Logها اصلاً اجرا نمی‌شوند. در نتیجه نسخه‌ای که دست کاربران می‌رسد هم سریع‌تر است و هم امن‌تر.


Timber؛ محبوب‌ترین کتابخانه Logging

تا اینجا از کلاس استاندارد Log استفاده کردیم، اما در پروژه‌های واقعی معمولاً کتابخانه Timber جای آن را می‌گیرد. دلیل محبوبیت Timber این نیست که فقط چند متد جدید اضافه می‌کند، بلکه کارهایی را ساده می‌کند که با Log خام باید خودت انجام بدهی. مثلاً Tag مناسب را خودش تشخیص می‌دهد، نوشتن Logها خواناتر می‌شود و می‌توانی رفتار Logging را برای نسخه Debug و Release به شکل متفاوتی مدیریت کنی.

نکته مهم این است که اگر از همان Interface Logger که در بخش قبل ساختیم استفاده کرده باشی، مهاجرت از Log به Timber بسیار ساده است. فقط کافی است پیاده‌سازی AndroidLogger را تغییر بدهی و هیچ‌یک از Repositoryها، ViewModelها یا Workerهای پروژه نیاز به تغییر نخواهند داشت.

این دقیقاً یکی از مزیت‌های طراحی مبتنی بر Interface است؛ وابستگی‌ها را پشت یک لایه انتزاع پنهان می‌کنی تا در آینده بتوانی فناوری‌های زیرساختی را بدون دست زدن به منطق برنامه عوض کنی.


جمع‌بندی بخش دوم

در این بخش یاد گرفتیم که یک سیستم Logging حرفه‌ای فقط چاپ چند پیام داخل Logcat نیست. دیدیم که انتخاب Tag مناسب، ثبت Exceptionها، استفاده از Structured Logging، اندازه‌گیری Performance، ثبت درخواست‌های شبکه و محافظت از اطلاعات حساس، همگی بخشی از یک سیستم Logging استاندارد هستند. همچنین با نقش BuildConfig.DEBUG و مزیت استفاده از کتابخانه‌هایی مثل Timber آشنا شدیم.

در بخش سوم وارد Crash Reporting می‌شویم؛ تفاوت Crash و ANR را بررسی می‌کنیم، Firebase Crashlytics را به پروژه اضافه می‌کنیم، Non-Fatal Exceptionها، Breadcrumbها، گزارش خطاهای Coroutine، Worker و FirebaseMessagingService را پیاده‌سازی می‌کنیم و یاد می‌گیریم چطور بعد از انتشار برنامه، علت کرش کاربران را از راه دور پیدا کنیم.

درس شصت‌وهفتم

Logging و Crash Reporting (بخش سوم)

تا اینجا یاد گرفتیم چطور یک سیستم Logging حرفه‌ای طراحی کنیم؛ فهمیدیم چرا نباید مستقیماً از Log استفاده کنیم، چرا وجود یک Logger مستقل اهمیت دارد و چطور Logهایی بنویسیم که بعداً واقعاً به درد بخورند. اما هنوز یک سؤال مهم باقی مانده است. اگر برنامه واقعاً Crash کند چه؟

تا قبل از انتشار برنامه، جواب این سؤال ساده است. برنامه را داخل Android Studio اجرا می‌کنی، روی دکمه اجرا می‌زنی و اگر Exception رخ بدهد، همان لحظه Stack Trace را می‌بینی. اما بعد از انتشار، دیگر هیچ دسترسی مستقیمی به گوشی کاربران نداری. ممکن است برنامه روی ده هزار گوشی بدون مشکل کار کند، اما روی پنجاه گوشی خاص به دلیل یک شرایط خاص کرش کند. اگر نتوانی اطلاعات آن Crashها را دریافت کنی، عملاً راهی برای برطرف کردن مشکل نخواهی داشت. به همین دلیل تقریباً تمام اپلیکیشن‌های حرفه‌ای دنیا از یک سیستم Crash Reporting استفاده می‌کنند.

اول از همه باید تفاوت Crash و Exception را بدانیم. خیلی از Exceptionها اصلاً باعث بسته شدن برنامه نمی‌شوند. فرض کن هنگام خواندن اطلاعات از سرور، اینترنت کاربر قطع شده باشد و Retrofit یک Exception تولید کند. اگر این Exception را مدیریت کنی و پیام مناسبی به کاربر نشان بدهی، برنامه همچنان به کارش ادامه می‌دهد. اما اگر همان Exception بدون مدیریت باقی بماند، Thread مربوطه متوقف می‌شود و اگر روی Thread اصلی باشد، برنامه Crash می‌کند. بنابراین هر Crash تقریباً همیشه نتیجه یک Exception مدیریت‌نشده است، اما هر Exception الزاماً Crash نیست.

یک مفهوم مهم دیگر هم وجود دارد که خیلی از برنامه‌نویس‌ها آن را با Crash اشتباه می‌گیرند و آن ANR یا Application Not Responding است. در Crash، برنامه ناگهان بسته می‌شود، اما در ANR برنامه بسته نمی‌شود؛ فقط برای چند ثانیه هیچ پاسخی به لمس کاربر نمی‌دهد. فرض کن داخل Thread اصلی یک فایل بسیار بزرگ را بخوانی یا یک Query سنگین دیتابیس اجرا کنی. در این مدت صفحه فریز می‌شود و اندروید بعد از چند ثانیه پیغام معروف «Application isn’t responding» را نشان می‌دهد. از نگاه کاربر، ANR تقریباً به اندازه Crash آزاردهنده است، حتی اگر برنامه بسته نشود.

برای اینکه بعد از انتشار بتوانیم این مشکلات را پیدا کنیم، معمولاً از Firebase Crashlytics استفاده می‌کنیم. Crashlytics تمام Crashهای برنامه را جمع‌آوری می‌کند و داخل پنل Firebase نمایش می‌دهد. هر بار که برنامه روی گوشی یک کاربر Crash کند، اطلاعاتی مثل مدل گوشی، نسخه اندروید، نسخه برنامه، Stack Trace، تعداد دفعات رخ دادن خطا و زمان وقوع آن برای ما ارسال می‌شود. به این ترتیب دیگر لازم نیست از کاربر بخواهیم اسکرین‌شات Logcat بگیرد یا مراحل رخ دادن خطا را با جزئیات توضیح بدهد.

اما قدرت واقعی Crashlytics فقط ثبت Crash نیست. فرض کن یکی از کاربران گزارش داده که هنگام ذخیره محصول، برنامه بسته می‌شود. اگر فقط Stack Trace را داشته باشی، شاید بفهمی خطا داخل Repository رخ داده است، اما هنوز نمی‌دانی کاربر دقیقاً در کدام صفحه بوده، روی کدام محصول کار می‌کرده یا آخرین عملی که انجام داده چه بوده است. به همین دلیل Crashlytics اجازه می‌دهد اطلاعات اضافی هم همراه Crash ارسال کنیم.

فرض کن قبل از ذخیره محصول این اطلاعات را ثبت کرده‌ای:

Screen = AddProduct

ProductId = 281

Category = Mobile

UserId = 154

اگر چند ثانیه بعد برنامه Crash کند، تمام این اطلاعات کنار Stack Trace نمایش داده می‌شوند. حالا دیگر فقط نمی‌دانی که NullPointerException رخ داده، بلکه می‌دانی این خطا هنگام ذخیره محصول شماره ۲۸۱ و داخل صفحه افزودن محصول اتفاق افتاده است. همین چند اطلاعات ساده گاهی ساعت‌ها زمان Debug را کاهش می‌دهند.

یکی دیگر از قابلیت‌های بسیار مهم Crashlytics ثبت Non-Fatal Exception است. همیشه لازم نیست برنامه Crash کند تا متوجه وجود یک مشکل شوی. فرض کن هنگام همگام‌سازی اطلاعات با سرور، JSON دریافتی خراب باشد. می‌توانی Exception را مدیریت کنی، پیام مناسبی به کاربر نشان بدهی و برنامه را باز نگه داری، اما در عین حال همان Exception را برای Crashlytics ارسال کنی تا بعداً بررسی‌اش کنی. به این خطاها Non-Fatal گفته می‌شود، چون باعث بسته شدن برنامه نشده‌اند، اما همچنان ارزش بررسی دارند.

در پروژه قیمت‌بان این قابلیت می‌تواند بسیار مفید باشد. فرض کن WorkManager هر شب قیمت محصولات را به‌روزرسانی می‌کند. اگر یکی از APIها پاسخ نامعتبر برگرداند، ضرورتی ندارد برنامه Crash کند، چون کاربر حتی داخل برنامه نیست. اما اگر این خطا را ثبت نکنی، شاید هفته‌ها متوجه نشوی که همگام‌سازی اطلاعات روی بعضی گوشی‌ها شکست می‌خورد. بنابراین Worker می‌تواند Exception را مدیریت کند، عملیات را متوقف کند و همزمان گزارش آن را برای Crashlytics بفرستد.

همین موضوع درباره FirebaseMessagingService هم صدق می‌کند. فرض کن یک Push Notification جدید دریافت شده اما ساختار JSON آن با نسخه جدید برنامه سازگار نیست. اگر Exception مدیریت نشود، ممکن است سرویس Push کرش کند و کاربر دیگر هیچ اعلان جدیدی دریافت نکند. در چنین شرایطی بهتر است Exception را ثبت کنیم، Push را نادیده بگیریم و اجازه بدهیم برنامه به کار خودش ادامه دهد.

یکی از امکانات جالب Crashlytics قابلیتی به نام Breadcrumb است. Breadcrumb را می‌توان ردپای حرکت کاربر قبل از Crash در نظر گرفت. فرض کن کاربر این مسیر را طی کرده است:

Home

Search

Product Details

Price History

Add Alert

Crash

اگر فقط Stack Trace را ببینی، شاید متوجه شوی خطا داخل صفحه هشدار قیمت رخ داده است، اما نمی‌دانی کاربر چطور به آنجا رسیده است. Breadcrumb دقیقاً این مسیر را ثبت می‌کند و هنگام بررسی Crash نشان می‌دهد. این اطلاعات مخصوصاً برای باگ‌هایی که فقط بعد از چند مرحله خاص رخ می‌دهند فوق‌العاده ارزشمند هستند.

در پروژه قیمت‌بان می‌توانیم قبل از هر Navigation مهم یک Breadcrumb ثبت کنیم. مثلاً هنگام ورود به صفحه تاریخچه قیمت، هنگام باز شدن صفحه افزودن محصول یا هنگام شروع همگام‌سازی اطلاعات. اگر بعداً Crash رخ بدهد، کل مسیر حرکت کاربر در اختیارمان خواهد بود.

نکته مهم دیگری که نباید فراموش کنیم این است که اطلاعات حساس کاربران نباید وارد گزارش‌های Crash شوند. همان‌طور که قبلاً گفتیم، Password، Access Token، Refresh Token، Cookie، OTP، اطلاعات بانکی یا هر داده محرمانه دیگری نباید داخل Log یا Crash Report ثبت شود. اگر نیاز به شناسایی کاربر داری، فقط یک شناسه داخلی یا یک UserId کافی است. هدف Crash Reporting پیدا کردن علت خطاست، نه ذخیره اطلاعات خصوصی کاربران.

در نهایت باید این ذهنیت را داشته باشی که بعد از انتشار برنامه، دیگر مهم‌ترین ابزار Debug تو Android Studio نیست؛ بلکه Logging و Crash Reporting هستند. یک برنامه Production برنامه‌ای نیست که هیچ‌وقت Crash نکند، چون چنین چیزی تقریباً غیرممکن است. برنامه حرفه‌ای برنامه‌ای است که اگر Crash هم کرد، توسعه‌دهنده بتواند ظرف چند دقیقه علت آن را پیدا کند، آن را بازتولید کند و در نسخه بعدی برطرفش کند. دقیقاً به همین دلیل است که Logging و Crash Reporting را باید بخشی از معماری برنامه دانست، نه صرفاً چند ابزار کمکی برای Debug کردن.

درس شصت‌وهفتم

Logging و Crash Reporting (بخش چهارم)

تا اینجا تقریباً تمام اجزای یک سیستم حرفه‌ای Logging و Crash Reporting را شناختیم. اما هنوز یک سؤال مهم باقی مانده است؛ این ابزارها دقیقاً باید کجای معماری پروژه قرار بگیرند؟ اگر هر کلاس مستقیماً Log بنویسد یا خودش با Crashlytics ارتباط برقرار کند، بعد از مدتی پروژه به همان آشفتگی‌ای دچار می‌شود که در فصل‌های قبلی درباره آن صحبت کردیم.

در پروژه قیمت‌بان بهتر است همه چیز از یک نقطه مرکزی عبور کند. ViewModel، Repository، Worker، Service یا حتی کلاس‌های مربوط به شبکه نباید بدانند که پشت صحنه از Logcat، Timber یا Firebase Crashlytics استفاده می‌شود. آن‌ها فقط باید یک Interface به نام Logger را بشناسند. در نتیجه اگر روزی تصمیم بگیری Timber را حذف کنی، یا به جای Crashlytics از سرویس دیگری استفاده کنی، فقط پیاده‌سازی Logger تغییر می‌کند و بقیه پروژه حتی متوجه این تغییر نخواهد شد. این دقیقاً همان مزیتی است که قبلاً درباره وابستگی به Interface و کاهش Coupling یاد گرفتیم.

به مرور زمان متوجه می‌شوی Logging فقط برای پیدا کردن باگ نیست، بلکه به تصمیم‌گیری‌های فنی هم کمک می‌کند. فرض کن کاربران مرتب گزارش می‌دهند که صفحه تاریخچه قیمت دیر باز می‌شود. اگر هیچ Logی نداشته باشی، باید حدس بزنی مشکل از اینترنت است، یا دیتابیس، یا رندر شدن Compose. اما اگر از قبل زمان اجرای قسمت‌های مختلف را ثبت کرده باشی، خیلی سریع متوجه می‌شوی که مثلاً درخواست شبکه فقط ۱۸۰ میلی‌ثانیه طول کشیده، Query دیتابیس ۴۰ میلی‌ثانیه بوده، اما تبدیل داده‌ها به مدل‌های رابط کاربری بیش از دو ثانیه زمان برده است. حالا به جای حدس زدن، دقیقاً می‌دانی باید کدام قسمت را بهینه کنی.

همین موضوع درباره Crashها هم صادق است. فرض کن بعد از انتشار نسخه جدید، Crashlytics نشان می‌دهد که هزاران کاربر با یک NullPointerException مواجه شده‌اند. اگر فقط Stack Trace را ببینی، شاید چند ساعت طول بکشد تا علت را پیدا کنی، اما اگر کنار آن Breadcrumbها، اطلاعات صفحه فعلی، نسخه برنامه و چند Custom Key هم ثبت شده باشند، ممکن است ظرف چند دقیقه متوجه شوی این خطا فقط روی اندروید ۱۳ و فقط هنگام باز کردن صفحه هشدار قیمت رخ می‌دهد. تفاوت بین یک تیم حرفه‌ای و یک تیم آماتور دقیقاً در همین سرعت پیدا کردن و رفع مشکلات است.

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

همچنین هیچ‌وقت از Logging به عنوان جایگزین مدیریت خطا استفاده نکن. بعضی برنامه‌نویس‌ها هر Exception را Catch می‌کنند، داخل Log می‌نویسند و بعد هیچ کار دیگری انجام نمی‌دهند. این کار فقط باعث می‌شود برنامه ظاهراً سالم به نظر برسد، در حالی که بخشی از آن دیگر درست کار نمی‌کند. Logging باید مکمل مدیریت خطا باشد، نه جایگزین آن. اگر یک عملیات قابل بازیابی است، باید مدیریت شود و در کنار آن گزارش هم ثبت شود. اگر قابل بازیابی نیست، باید اجازه دهی Crashlytics آن را ثبت کند تا در نسخه بعدی برطرف شود.

در پروژه قیمت‌بان تقریباً تمام بخش‌هایی که تاکنون ساخته‌ایم می‌توانند از Logger استفاده کنند. هنگام اجرای WorkManager می‌توان شروع و پایان همگام‌سازی را ثبت کرد. هنگام دریافت Push Notification می‌توان نوع اعلان را ثبت کرد. Repositoryها می‌توانند مدت زمان اجرای Queryها را ثبت کنند. ViewModelها می‌توانند رویدادهای مهم رابط کاربری را ثبت کنند و لایه شبکه هم می‌تواند فقط اطلاعات ضروری درخواست‌ها و پاسخ‌ها را ثبت کند، بدون اینکه هیچ داده محرمانه‌ای وارد Log شود. وقتی همه این اطلاعات کنار هم قرار بگیرند، تصویری کامل از رفتار برنامه در اختیار خواهی داشت.

اگر بخواهم فقط یک توصیه برای برنامه‌نویسی حرفه‌ای بدهم، این است که هیچ‌وقت منتظر گزارش کاربران نمان. سعی کن برنامه خودش قبل از کاربران به تو بگوید چه مشکلی دارد. Logging و Crash Reporting دقیقاً برای همین ساخته شده‌اند. هرچه اطلاعات دقیق‌تر و منظم‌تری ثبت کنی، زمان کمتری صرف پیدا کردن باگ‌ها خواهی کرد و کیفیت نسخه‌های بعدی برنامه بیشتر خواهد شد.

در پایان این فصل، بد نیست چند اشتباه رایج را هم مرور کنیم. استفاده مستقیم از Log.d در سراسر پروژه، ثبت Password یا Token داخل Log، نوشتن صدها Log بی‌ارزش، Catch کردن Exception بدون ثبت یا مدیریت آن، ارسال تمام Logها در نسخه Release، وابسته کردن کل پروژه به Crashlytics و نداشتن یک لایه مرکزی برای Logging، از رایج‌ترین اشتباه‌هایی هستند که در پروژه‌های واقعی دیده می‌شوند. اگر از همان ابتدا یک معماری تمیز برای Logging طراحی کنی، تقریباً همه این مشکلات به‌صورت خودکار برطرف خواهند شد.

با پایان این فصل، یکی از مهم‌ترین تفاوت‌های بین یک پروژه آموزشی و یک پروژه Production را یاد گرفتی. تا قبل از این شاید فکر می‌کردی انتشار برنامه پایان کار است، اما در واقع انتشار، تازه آغاز مرحله اصلی توسعه است. از این لحظه به بعد، کیفیت برنامه دیگر فقط با تعداد قابلیت‌های آن سنجیده نمی‌شود؛ بلکه با این سنجیده می‌شود که هنگام رخ دادن مشکل، چقدر سریع می‌توانی علت را پیدا کنی، آن را تحلیل کنی و نسخه‌ای پایدارتر منتشر کنی. Logging و Crash Reporting دقیقاً ابزارهایی هستند که این چرخه را ممکن می‌کنند و به همین دلیل تقریباً در تمام اپلیکیشن‌های حرفه‌ای، از ساده‌ترین برنامه‌ها گرفته تا بزرگ‌ترین شبکه‌های اجتماعی دنیا، جایگاهی ثابت و حیاتی دارند.