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

بخش چهارم — پروژه قیمت‌بان


درس بیست‌ویکم

وقتی کاربر روی دکمه ثبت‌نام می‌زنه، پشت صحنه چه اتفاقی می‌افته؟

ثبت‌نام کسب‌وکار

تا اینجا بیشتر درباره قطعات معماری برنامه حرف زدیم. Retrofit رو شناختیم، ViewModel و Repository رو دیدیم، درباره State، Session و Token هم صحبت کردیم. حالا وقتشه این قطعات رو برای اولین‌بار کنار هم قرار بدیم و یک فرایند واقعی بسازیم: ثبت‌نام یک کسب‌وکار در قیمت‌بان.

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

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

مسیر کلی این اتفاق به این صورته:

RegisterScreen

RegisterViewModel

AuthRepository

AuthApi

Server

Access Token

TokenStorage

Home

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

فرم ثبت‌نام چه اطلاعاتی رو نگه می‌داره؟

در Compose، هر کادر ورودی باید یک State داشته باشه تا مقدار فعلی اون نگهداری بشه. لازم نیست کل ظاهر فرم رو بنویسیم. چیزی که در این درس اهمیت داره، نحوه نگهداری اطلاعاتیه که قراره برای ثبت‌نام ارسال بشن.

var businessName by remember { mutableStateOf("") }
var phone by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }

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

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

var businessName by rememberSaveable { mutableStateOf("") }

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

قرار نیست همه Stateهای برنامه داخل ViewModel قرار بگیرن. متن موقتی که کاربر داخل فیلد نوشته، می‌تونه داخل خود صفحه باقی بمونه. اما وضعیت اصلی فرایند، مثل در حال ارسال بودن، موفقیت یا خطا، بهتره داخل ViewModel مدیریت بشه.

کلیک روی دکمه ثبت‌نام

وقتی کاربر روی دکمه می‌زنه، صفحه نباید خودش Retrofit رو صدا بزنه یا Token رو ذخیره کنه. صفحه فقط داده‌ها رو تحویل ViewModel میده:

viewModel.register(
    businessName = businessName.trim(),
    phone = phone.trim(),
    password = password
)

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

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

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

اعتبارسنجی در کجا انجام میشه؟

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

اعتبارسنجی ساده رو میشه قبل از ورود به Repository و داخل ViewModel انجام داد:

if (businessName.isBlank()) {
    showError("نام کسب‌وکار رو وارد کن")
    return
}

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

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

if (password.length < 8) {
    showError("رمز عبور باید حداقل ۸ کاراکتر باشه")
    return
}

این اعتبارسنجی برای تجربه کاربریه. یعنی به جای اینکه درخواست به سرور بره و بعد سرور خطا برگردونه، برنامه سریعاً مشکل رو به کاربر میگه.

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

مدل درخواست ثبت‌نام

سرور معمولاً انتظار داره اطلاعات ثبت‌نام رو در قالب JSON دریافت کنه. مثلاً:

{
  "business_name": "فروشگاه مرکزی",
  "phone": "09121234567",
  "password": "12345678"
}

برای ساختن چنین JSONای، یک مدل درخواست تعریف می‌کنیم:

data class RegisterRequest(
    val businessName: String,
    val phone: String,
    val password: String
)

این کلاس فقط یک ظرف برای انتقال اطلاعاته. نام کسب‌وکار، شماره موبایل و رمز داخلش قرار می‌گیرن و کتابخانه Serialization اون رو به JSON تبدیل می‌کنه. اگر نام فیلدهای API با نام متغیرهای Kotlin یکسان نباشه، باید ارتباط بین اون‌ها رو مشخص کنیم. مثلاً سرور از business_name استفاده می‌کنه، اما در Kotlin طبق استاندارد از businessName استفاده می‌کنیم:

@SerialName("business_name")
val businessName: String

@SerialName میگه هنگام تبدیل آبجکت Kotlin به JSON، این متغیر با نام business_name ارسال بشه. بنابراین داخل برنامه همچنان کد خوانایی مثل businessName داریم، اما قرارداد سرور هم رعایت میشه.

تعریف درخواست در Retrofit

حالا باید به Retrofit بگیم برای ثبت‌نام، چه آدرسی رو صدا بزنه و چه اطلاعاتی رو بفرسته:

@POST("auth/register")
suspend fun register(
    @Body request: RegisterRequest
): RegisterResponse

@POST مشخص می‌کنه نوع درخواست HTTP از نوع POST هست. ثبت‌نام باعث ایجاد یک کاربر یا کسب‌وکار جدید در سرور میشه، به همین دلیل معمولاً از POST استفاده می‌کنیم. عبارت “auth/register” مسیر Endpoint رو مشخص می‌کنه. Retrofit این مسیر رو به Base URL اضافه می‌کنه. مثلاً اگر Base URL این باشه:

https://api.gheymatban.com/

آدرس کامل درخواست به این صورت درمیاد:

https://api.gheymatban.com/auth/register

@Body یعنی آبجکت RegisterRequest باید داخل بدنه درخواست قرار بگیره. Serialization هم اون رو به JSON تبدیل می‌کنه. کلمه suspend به این معنیه که این تابع یک عملیات زمان‌بر انجام میده و باید داخل Coroutine اجرا بشه. درخواست اینترنت ممکنه یک یا چند ثانیه طول بکشه، بنابراین نباید Thread اصلی رابط کاربری رو متوقف کنه.

خروجی تابع هم RegisterResponse هست؛ یعنی Retrofit انتظار داره پاسخ موفق سرور رو به این مدل تبدیل کنه.

پاسخ سرور شامل چیه؟

بعد از ثبت‌نام موفق، سرور ممکنه اطلاعات کسب‌وکار و Tokenهای ورود رو برگردونه:

data class RegisterResponse(
    val accessToken: String,
    val refreshToken: String,
    val business: BusinessDto
)

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

accessToken برای درخواست‌های احراز هویت‌شده استفاده میشه. refreshToken هم زمانی کاربرد داره که Access Token منقضی شده و برنامه می‌خواد بدون درخواست دوباره رمز عبور، Token جدید بگیره. اطلاعات business هم می‌تونه شامل شناسه، نام کسب‌وکار، شماره موبایل و سایر مشخصات حساب ساخته‌شده باشه. این مدل در واقع ترجمه پاسخ JSON سرور به یک آبجکت قابل استفاده داخل Kotlin هست.

Repository؛ جایی که ثبت‌نام واقعاً انجام میشه

ViewModel نباید مستقیماً api.register رو اجرا کنه. این کار رو به Repository می‌سپاریم:

val response = api.register(
    RegisterRequest(
        businessName = businessName,
        phone = phone,
        password = password
    )
)

در اینجا Repository ابتدا اطلاعات جداگانه رو داخل RegisterRequest قرار میده. بعد این مدل رو برای Retrofit می‌فرسته. Retrofit درخواست HTTP رو می‌سازه، JSON رو ارسال می‌کنه و پاسخ سرور رو به RegisterResponse تبدیل می‌کنه.

در صورت موفقیت، Tokenها باید ذخیره بشن:

tokenStorage.saveTokens(
    accessToken = response.accessToken,
    refreshToken = response.refreshToken
)

این بخش باعث میشه کاربر بعد از ثبت‌نام، مستقیماً وارد حسابش بشه. اگر Tokenها ذخیره نشن، ثبت‌نام روی سرور انجام شده، اما برنامه در اجرای بعدی نمی‌دونه کاربر وارد حساب شده. اینجا Repository دو کار مرتبط با داده رو هماهنگ می‌کنه: اول اطلاعات رو از API دریافت می‌کنه و بعد نتیجه لازم رو در حافظه امن گوشی ذخیره می‌کنه. ViewModel لازم نیست بدونه Token دقیقاً در کجا یا با چه روشی ذخیره شده.

در پایان، Repository می‌تونه اطلاعات کسب‌وکار ساخته‌شده رو برگردونه:

return response.business

از اینجا به بعد ViewModel می‌فهمه ثبت‌نام موفق بوده، اما همچنان وارد جزئیات شبکه و Token نشده.

ViewModel چطور وضعیت ثبت‌نام رو مدیریت می‌کنه؟

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

sealed interface RegisterUiState {
    data object Idle : RegisterUiState
    data object Loading : RegisterUiState
    data object Success : RegisterUiState
    data class Error(val message: String) : RegisterUiState
}

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

ViewModel یک StateFlow از این وضعیت نگه می‌داره:

private val _uiState =
    MutableStateFlow<RegisterUiState>(RegisterUiState.Idle)

val uiState = _uiState.asStateFlow()

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

اجرای عملیات ثبت‌نام

بعد از اعتبارسنجی، ViewModel عملیات ثبت‌نام رو داخل Coroutine اجرا می‌کنه:

_uiState.value = RegisterUiState.Loading

viewModelScope.launch {
    repository.register(
        businessName = businessName,
        phone = phone,
        password = password
    )
}

قبل از شروع درخواست، State روی Loading قرار می‌گیره. Compose این تغییر رو می‌بینه و می‌تونه دکمه رو غیرفعال کنه یا یک نشانگر بارگذاری نمایش بده.

از viewModelScope.launch استفاده می‌کنیم چون تابع Repository یک عملیات suspend داره. viewModelScope هم به عمر ViewModel متصله. اگر کاربر صفحه رو ترک کنه و ViewModel از بین بره، Coroutineهای مرتبط با اون هم لغو میشن.

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

_uiState.value = RegisterUiState.Success

اما درخواست اینترنت همیشه موفق نیست. ممکنه اینترنت قطع باشه، سرور جواب نده، شماره موبایل قبلاً ثبت شده باشه یا اطلاعات ارسالی معتبر نباشن. بنابراین باید خطا رو هم مدیریت کنیم:

try {
    repository.register(businessName, phone, password)
    _uiState.value = RegisterUiState.Success
} catch (error: Exception) {
    _uiState.value = RegisterUiState.Error(
        message = error.toUserMessage()
    )
}

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

جلوگیری از چندبار ثبت درخواست

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

Button( enabled = uiState !is RegisterUiState.Loading, onClick = { viewModel.register(businessName, phone, password) } )

خصوصیت enabled مشخص می‌کنه دکمه قابل کلیک باشه یا نه. وقتی State از نوع Loading باشه، نتیجه شرط false میشه و دکمه موقتاً غیرفعال میشه. این یک خط ساده است، اما جلوی خطاهای واقعی زیادی رو می‌گیره؛ از ساخت چند حساب تکراری گرفته تا ارسال چند درخواست هم‌زمان و گیج شدن کاربر. داخل دکمه هم میشه براساس وضعیت، متن یا Loading نمایش داد:

if (uiState is RegisterUiState.Loading) {
    CircularProgressIndicator()
} else {
    Text("ثبت‌نام")
}

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

صفحه چطور موفقیت رو تشخیص میده؟

صفحه باید StateFlow رو مشاهده کنه:

val uiState by viewModel.uiState.collectAsStateWithLifecycle()

این خط StateFlow داخل ViewModel رو به State قابل استفاده در Compose تبدیل می‌کنه. هر بار که ViewModel مقدار uiState رو تغییر بده، Compose متوجه میشه و بخش‌های وابسته رو دوباره اجرا می‌کنه.

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

حالا میشه در صورت موفقیت، کاربر رو وارد صفحه اصلی کرد:

LaunchedEffect(uiState) { if (uiState is RegisterUiState.Success) { navController.navigate(“home”) { popUpTo(“register”) { inclusive = true } } } }

LaunchedEffect برای اجرای یک اثر جانبی در واکنش به تغییر State استفاده میشه. Navigation تغییر مستقیم ظاهر یک Composable نیست؛ یک عمل بیرونی محسوب میشه، به همین دلیل داخل LaunchedEffect قرار می‌گیره. وقتی State به Success تغییر می‌کنه، کاربر وارد صفحه Home میشه. بخش popUpTo(“register”) صفحه ثبت‌نام رو از Back Stack حذف می‌کنه. inclusive = true هم یعنی خود صفحه ثبت‌نام نیز حذف بشه. بنابراین اگر کاربر داخل Home دکمه Back رو بزنه، دوباره به فرم ثبت‌نام برنمی‌گرده.

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

خطا چطور نمایش داده میشه؟

اگر State از نوع Error باشه، صفحه می‌تونه پیام رو نمایش بده:

if (uiState is RegisterUiState.Error) {
    Text(
        text = (uiState as RegisterUiState.Error).message
    )
}

اما این روش برای خواندن State کمی تکراریه. ساختار when معمولاً خواناتر میشه:

when (val state = uiState) {
    RegisterUiState.Idle -> Unit
    RegisterUiState.Loading -> LoadingView()
    RegisterUiState.Success -> Unit
    is RegisterUiState.Error -> ErrorText(state.message)
}

when تمام حالت‌های RegisterUiState رو جدا می‌کنه. چون State به شکل sealed تعریف شده، Kotlin می‌دونه چه حالت‌هایی ممکنه وجود داشته باشن. اگر بعداً حالت جدیدی اضافه کنیم، کامپایلر یادآوری می‌کنه که این قسمت هم باید به‌روزرسانی بشه.

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

خطای شماره موبایل قبلاً ثبت شده

همه خطاها فنی نیستن. گاهی سرور سالمه، اینترنت هم وجود داره، اما درخواست از نظر منطق برنامه قابل قبول نیست. مثلاً شماره موبایل قبلاً برای یک کسب‌وکار ثبت شده. سرور ممکنه در این حالت کد HTTP مثل 409 Conflict برگردونه. Repository یا لایه مدیریت خطا می‌تونه اون رو به یک خطای قابل فهم تبدیل کنه:

if (response.code() == 409) {
    throw DuplicatePhoneException()
}

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

is DuplicatePhoneException ->
    "این شماره موبایل قبلاً ثبت شده"

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

آیا ثبت‌نام در حالت آفلاین ممکنه؟

در درس قبلی درباره Offline First صحبت کردیم. اما همه عملیات‌ها رو نمی‌شه به صورت کامل آفلاین انجام داد. ساخت حساب جدید یکی از همین موارده، چون سرور باید بررسی کنه شماره موبایل تکراری نباشه، شناسه کسب‌وکار ایجاد کنه و Token معتبر صادر کنه.

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

“No internet connection”

البته پیام واقعی برای کاربر فارسی خواهد بود:

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

در اینجا Offline First به معنی ثبت‌نام جعلی نیست. به این معنیه که برنامه در نبود اینترنت رفتار مناسبی داشته باشه، اطلاعات واردشده رو از بین نبره و کاربر رو مجبور نکنه همه چیز رو دوباره تایپ کنه.

بعد از ثبت‌نام چه چیزهایی باید ذخیره بشن؟

مهم‌ترین اطلاعاتی که باید ذخیره بشن، Tokenهای Session هستن. اما ممکنه اطلاعات اصلی کسب‌وکار هم داخل Room ذخیره بشن:

businessDao.insert(
    response.business.toEntity()
)

response.business مدلیه که از API دریافت شده. اما مدل API لزوماً نباید مستقیماً داخل Room ذخیره بشه. تابع toEntity مدل شبکه رو به مدل دیتابیس تبدیل می‌کنه. این جداسازی باعث میشه ساختار JSON سرور و ساختار جدول Room به هم وابسته نشن. اگر API تغییر کرد، مجبور نیستیم کل دیتابیس محلی رو هم تغییر بدیم. بعد از ذخیره Token و اطلاعات کسب‌وکار، برنامه می‌تونه صفحه Home رو از اطلاعات محلی تغذیه کنه. اینجا مفاهیم Repository، Room، Cache و Offline First دوباره به هم وصل میشن.

ثبت‌نام یک دکمه نیست؛ یک فراینده

در ظاهر، کاربر فقط چند فیلد می‌بینه و یک دکمه رو لمس می‌کنه. اما پشت همین دکمه، چند اتفاق هماهنگ رخ میده. ورودی‌ها اعتبارسنجی میشن، State به Loading تغییر می‌کنه، مدل درخواست ساخته میشه، Retrofit JSON رو به سرور می‌فرسته، سرور کسب‌وکار رو ایجاد می‌کنه، Tokenها برمی‌گردن، Repository اون‌ها رو ذخیره می‌کنه و ViewModel وضعیت موفقیت رو به صفحه اعلام می‌کنه. در نهایت Navigation کاربر رو وارد Home می‌کنه.

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

صفحه اطلاعات رو می‌گیره و وضعیت رو نمایش میده. ViewModel فرایند و State رو مدیریت می‌کنه. Repository کارهای مرتبط با داده رو هماهنگ می‌کنه. Retrofit با سرور حرف می‌زنه. TokenStorage اطلاعات ورود رو نگه می‌داره و Room در صورت نیاز اطلاعات کسب‌وکار رو ذخیره می‌کنه.

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

در پایان، مسیر ثبت‌نام قیمت‌بان رو میشه در یک جمله خلاصه کرد: صفحه درخواست ثبت‌نام رو اعلام می‌کنه، ViewModel فرایند رو کنترل می‌کنه، Repository ثبت‌نام رو انجام میده و State نتیجه رو دوباره به صفحه برمی‌گردونه.


درس بیست‌ودوم

وقتی کاربر دوباره وارد برنامه میشه، اپلیکیشن چطور هویت واقعی اون رو تشخیص میده؟

ورود به حساب کاربری

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

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

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

LoginScreen

LoginViewModel

AuthRepository

AuthApi

Server

Access Token + Refresh Token

TokenStorage

Home

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

صفحه ورود چه مسئولیتی داره؟

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

var phone by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }

استفاده از rememberSaveable باعث میشه اگر Activity دوباره ساخته شد، شماره موبایل و رمز واردشده فوراً ناپدید نشن. البته درباره رمز عبور باید کمی محتاط باشیم. حفظ موقت مقدار رمز در State صفحه معمولاً برای فرم ورود عادیه، اما نباید اون رو داخل دیتابیس، Log یا حافظه دائمی ذخیره کنیم. صفحه ورود فقط تا زمانی که کاربر در حال تکمیل فرمه، رمز رو نگه می‌داره. بعد از ورود موفق، دیگر نیازی به حفظ خود رمز وجود نداره؛ چیزی که باید ذخیره بشه Tokenهست، نه Password.

ارسال اطلاعات به ViewModel

وقتی کاربر روی دکمه ورود می‌زنه، اطلاعات فرم به ViewModel تحویل داده میشه:

viewModel.login(
    phone = phone.trim(),
    password = password
)

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

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

اعتبارسنجی اولیه ورود

قبل از ارسال درخواست، باید خطاهای ساده رو داخل برنامه بررسی کنیم. مثلاً شماره موبایل خالی نباشه و رمز عبور هم وارد شده باشه:

if (phone.isBlank() || password.isBlank()) {
    showError("شماره موبایل و رمز عبور رو وارد کن")
    return
}

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

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

سرور معمولاً برای ورود، یک JSON ساده دریافت می‌کنه:

{
  "phone": "09121234567",
  "password": "12345678"
}

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

data class LoginRequest(
    val phone: String,
    val password: String
)

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

تعریف Endpoint ورود

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

@POST("auth/login")
suspend fun login(
    @Body request: LoginRequest
): LoginResponse

@POST یعنی اطلاعات ورود داخل بدنه یک درخواست POST فرستاده میشن. مسیر auth/login هم Endpoint ورود رو مشخص می‌کنه. از GET برای ارسال رمز عبور استفاده نمی‌کنیم، چون پارامترهای GET معمولاً داخل URL قرار می‌گیرن و ممکنه در تاریخچه، Logها یا ابزارهای مانیتورینگ ثبت بشن. ورود باید از یک اتصال امن HTTPS و بدنه درخواست استفاده کنه. کلمه suspend هم به این معنیه که عملیات ورود باید داخل Coroutine اجرا بشه. برنامه نباید هنگام انتظار برای پاسخ سرور، رابط کاربری رو متوقف کنه.

پاسخ ورود چه اطلاعاتی داره؟

پاسخ موفق ورود معمولاً شبیه پاسخ ثبت‌نامه:

data class LoginResponse(
    val accessToken: String,
    val refreshToken: String,
    val business: BusinessDto
)

accessToken برای درخواست‌های روزمره استفاده میشه. مثلاً وقتی کاربر می‌خواد لیست کالاها یا قیمت‌های کسب‌وکار خودش رو دریافت کنه، این Token همراه درخواست فرستاده میشه. refreshToken برای گرفتن Access Token جدید استفاده میشه. چون Access Token معمولاً عمر کوتاه‌تری داره، برنامه نباید بعد از هر انقضا کاربر رو مجبور کنه دوباره شماره و رمز رو وارد کنه. اطلاعات business هم کمک می‌کنه برنامه بعد از ورود بدونه این Session متعلق به کدوم کسب‌وکاره.

ساختار دقیق این پاسخ به Backend قیمت‌بان بستگی داره، اما منطق کلی همین می‌مونه: سرور بعد از تأیید هویت، مدرکی موقت برای ادامه ارتباط صادر می‌کنه.

Repository عملیات ورود رو انجام میده

ViewModel اطلاعات رو به Repository می‌فرسته و Repository درخواست واقعی رو می‌سازه:

val response = api.login(
    LoginRequest(
        phone = phone,
        password = password
    )
)

در این بخش، اطلاعات جداگانه داخل LoginRequest قرار می‌گیرن و به Retrofit تحویل داده میشن. Retrofit درخواست رو می‌فرسته و پاسخ JSON رو به LoginResponse تبدیل می‌کنه.

بعد از ورود موفق، Tokenها ذخیره میشن:

tokenStorage.saveTokens(
    accessToken = response.accessToken,
    refreshToken = response.refreshToken
)

با همین دو Token، Session کاربر ساخته میشه. از این لحظه به بعد، برنامه لازم نیست برای هر درخواست دوباره شماره موبایل و رمز رو بفرسته. این نکته خیلی مهمه: رمز عبور ابزار اثبات اولیه هویته، اما Token مدرک ادامه Session هست. یعنی کاربر یک بار با رمز خودش رو معرفی می‌کنه و بعد سرور Tokenی صادر می‌کنه که در درخواست‌های بعدی نقش کارت ورود رو داره.

آیا اطلاعات کسب‌وکار هم ذخیره میشه؟

بعد از ورود، Repository می‌تونه اطلاعات کسب‌وکار رو داخل Room ذخیره یا به‌روزرسانی کنه:

businessDao.insert(
    response.business.toEntity()
)

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

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

State صفحه ورود

صفحه ورود هم مثل ثبت‌نام چند وضعیت مختلف داره:

sealed interface LoginUiState {
    data object Idle : LoginUiState
    data object Loading : LoginUiState
    data object Success : LoginUiState
    data class Error(val message: String) : LoginUiState
}

Idle یعنی هنوز درخواستی ارسال نشده. Loading یعنی برنامه منتظر پاسخ سروره. Success یعنی هویت کاربر تأیید شده و Tokenها ذخیره شدن. Error هم یعنی ورود کامل نشده. ممکنه در آینده حالت‌های دیگری هم اضافه بشن؛ مثلاً حساب غیرفعال، نیاز به تأیید شماره موبایل یا الزام تغییر رمز. مزیت sealed interface اینه که این وضعیت‌ها به شکل روشن و محدود تعریف میشن.

ViewModel هم StateFlow مربوط به اون رو نگه می‌داره:

private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Idle) val uiState = _uiState.asStateFlow()

صفحه فقط uiState رو می‌خونه. تنها ViewModel اجازه داره مقدار _uiState رو تغییر بده. به این ترتیب، وضعیت ورود از یک نقطه مرکزی کنترل میشه.

اجرای ورود داخل ViewModel

ViewModel قبل از شروع درخواست، وضعیت رو روی Loading قرار میده:

_uiState.value = LoginUiState.Loading

بعد عملیات داخل viewModelScope اجرا میشه:

viewModelScope.launch { repository.login(phone, password) }

این کد باعث میشه عملیات زمان‌بر ورود خارج از روند عادی رابط کاربری اجرا بشه. ViewModel هم مسئول عمر Coroutine باقی می‌مونه.

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

viewModelScope.launch { try { repository.login(phone, password) _uiState.value = LoginUiState.Success } catch (error: Exception) { _uiState.value = LoginUiState.Error( error.toUserMessage() ) } }

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

تفاوت خطای فنی و خطای اطلاعات ورود

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

اینترنت قطع است.
سرور فعلاً پاسخ نمی‌دهد.
شماره موبایل یا رمز عبور اشتباه است.

اگر همه این موارد رو با پیام ورود ناموفق بود نمایش بدیم، کاربر نمی‌فهمه باید چه کاری انجام بده. آیا رمز رو عوض کنه؟ اینترنت رو بررسی کنه؟ یا چند دقیقه صبر کنه؟

بنابراین خطاها باید در لایه مناسب از هم تفکیک بشن. مثلاً اگر سرور کد 401 Unauthorized برگردونه، می‌تونیم اون رو به خطای اطلاعات ورود تبدیل کنیم:

if (response.code() == 401) { throw InvalidCredentialsException() }

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

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

is InvalidCredentialsException -> “شماره موبایل یا رمز عبور اشتباهه”

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

چرا نباید رمز عبور رو Log کنیم؟

هنگام توسعه ممکنه وسوسه بشیم اطلاعات ارسالی رو با Logcat بررسی کنیم:

Log.d(“LOGIN”, “phone=$phone password=$password”)

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

می‌تونیم صرفاً شروع عملیات رو ثبت کنیم:

Log.d(“LOGIN”, “Login request started”)

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

دکمه ورود هنگام Loading

مثل ثبت‌نام، هنگام ارسال درخواست باید دکمه ورود غیرفعال بشه:

enabled = uiState !is LoginUiState.Loading

با این شرط، تا وقتی پاسخ قبلی برنگشته، کاربر نمی‌تونه دوباره روی دکمه ضربه بزنه. هم‌زمان میشه داخل دکمه Loading نمایش داد:

if (uiState is LoginUiState.Loading) {
    CircularProgressIndicator()
} else {
    Text("ورود")
}

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

انتقال کاربر به صفحه Home

صفحه StateFlow رو مشاهده می‌کنه:

val uiState by viewModel.uiState.collectAsStateWithLifecycle()

هر زمان ViewModel مقدار State رو تغییر بده، Compose وضعیت جدید رو دریافت می‌کنه. وقتی ورود موفق شد، Navigation انجام میشه:

LaunchedEffect(uiState) {
    if (uiState is LoginUiState.Success) {
        navController.navigate("home") {
            popUpTo("login") {
                inclusive = true
            }
        }
    }
}

بعد از ورود موفق، صفحه Login از Back Stack حذف میشه. بنابراین کاربر با زدن دکمه Back دوباره وارد فرم ورود نمیشه. در برنامه واقعی ممکنه لازم باشه علاوه بر Login، صفحه‌های Welcome یا Register هم از Back Stack حذف بشن. در این صورت، معمولاً یک مسیر والد مثل auth تعریف می‌کنیم و کل بخش احراز هویت رو از تاریخچه Navigation کنار می‌ذاریم. منطق اصلی اینه که بعد از ایجاد Session، مسیر قبلی احراز هویت دیگر نباید بخشی از روند عادی کاربر باشه.

آیا بعد از ورود باید رمز عبور پاک بشه؟

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

password = “”

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

گزینه مرا به خاطر بسپار چه معنی‌ای داره؟

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

پس Auto Login یعنی:

وجود Token معتبر یا امکان گرفتن Access Token جدید با Refresh Token

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

ورود خودکار در اجرای بعدی برنامه

بعد از اینکه Tokenها ذخیره شدن، اجرای بعدی برنامه نباید مستقیم صفحه Login رو باز کنه. صفحه آغازین یا Splash باید وضعیت Session رو بررسی کنه:

val hasSession = tokenStorage.hasRefreshToken()

اگر Refresh Token وجود داشته باشه، برنامه می‌تونه تلاش کنه Session رو بازیابی کنه. اگر Token معتبر بود، کاربر به Home میره. اگر منقضی یا نامعتبر بود، Tokenها پاک میشن و صفحه Login نمایش داده میشه. صرفاً وجود یک Access Token به معنی معتبر بودن اون نیست. ممکنه Token منقضی شده باشه یا سرور اون رو باطل کرده باشه. بنابراین تصمیم نهایی باید با منطق Session و Refresh Token هماهنگ باشه. ورود کاربر با رمز، فقط یکی از راه‌های ایجاد Session هست. بعد از اون، مدیریت Session باید ادامه مسیر رو بر عهده بگیره.

ورود در حالت آفلاین

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

اما یک وضعیت متفاوت وجود داره. فرض کن کاربر قبلاً وارد شده و اطلاعات Session داخل گوشی وجود داره، اما الان اینترنت قطع شده. در یک معماری Offline First، ممکنه برنامه اجازه بده کاربر به بخش‌هایی از اطلاعات محلی دسترسی داشته باشه. این دیگه ورود با رمز در حالت آفلاین نیست؛ ادامه استفاده از یک Session قبلی و داده‌های محلیه. اگر هیچ Session قبلی وجود نداشته باشه، پیام مناسب اینه:

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

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

امنیت واقعی رمز عبور در سمت سرور

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

اپلیکیشن اندروید مسئول پیاده‌سازی Hash دیتابیس سرور نیست. اما باید بدونیم که ارسال رمز به معنی ذخیره شدن مستقیم اون نیست. امنیت ورود نتیجه همکاری چند بخشه: HTTPS در مسیر انتقال، Hash مناسب در Backend، Token امن برای Session و نگهداری درست Token در گوشی. اگر یکی از این بخش‌ها اشتباه باشه، ظاهر حرفه‌ای صفحه ورود هیچ ارزشی نداره.

تفاوت ورود و ثبت‌نام در معماری

ثبت‌نام و ورود از نظر ساختار برنامه بسیار شبیه هستن. هر دو فرم دارن، به ViewModel وصل میشن، از Repository استفاده می‌کنن، درخواست Retrofit می‌فرستن و Token رو ذخیره می‌کنن. اما هدف اون‌ها فرق داره:

ثبت‌نام: ساخت حساب جدید + ایجاد Session ورود: تأیید حساب موجود + ایجاد Session

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

ورود فقط بررسی رمز نیست

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

صفحه اطلاعات رو دریافت می‌کنه. ViewModel اعتبارسنجی اولیه و State رو مدیریت می‌کنه. Repository درخواست ورود رو به API می‌فرسته. سرور هویت کاربر رو بررسی می‌کنه. Tokenها داخل فضای امن ذخیره میشن. اطلاعات کسب‌وکار در صورت نیاز وارد Room میشن و در نهایت صفحه Home باز میشه.

مهم‌ترین نکته اینه که هیچ‌کدوم از این مسئولیت‌ها نباید داخل یک onClick شلوغ جمع بشن. صفحه نباید Token ذخیره کنه. ViewModel نباید جزئیات Retrofit رو بدونه. Retrofit نباید تصمیم بگیره بعد از ورود به کدوم صفحه بریم و Navigation هم نباید درباره رمز عبور چیزی بدونه. هر بخش فقط کار خودش رو انجام میده و نتیجه رو به بخش بعدی تحویل میده.

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


درس بیست‌وسوم

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

پنل مدیریت کسب‌وکار

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

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

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

مسیر کلی صفحه به این شکله:

DashboardScreen

DashboardViewModel

DashboardRepository

Room + API

DashboardUiState

DashboardScreen

صفحه فقط DashboardUiState رو می‌بینه. لازم نیست بدونه نام کسب‌وکار از Room اومده، آمار کالاها از سرور دریافت شده یا آخرین فعالیت از Cache خونده شده. ViewModel و Repository این پیچیدگی رو پشت یک وضعیت ساده پنهان می‌کنن.

پنل مدیریت چه اطلاعاتی رو نمایش میده؟

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

برای نسخه ابتدایی قیمت‌بان، پنل می‌تونه شامل این اطلاعات باشه:

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

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

ساخت یک مدل واحد برای صفحه

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

data class DashboardData(
    val businessName: String,
    val totalProducts: Int,
    val todayPrices: Int,
    val productsWithoutPrice: Int,
    val lastUpdatedAt: String
)

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

businessName نام فروشگاه رو نگه می‌داره. totalProducts تعداد کل کالاهاست. todayPrices تعداد قیمت‌هایی رو مشخص می‌کنه که امروز ثبت یا ویرایش شدن. productsWithoutPrice تعداد کالاهایی رو نشون میده که هنوز قیمت ندارن و lastUpdatedAt زمان آخرین همگام‌سازی رو نگه می‌داره. دلیل ساختن چنین مدلی اینه که مدل API یا Entity دیتابیس معمولاً براساس نیازهای لایه خودشون طراحی شدن. مثلاً پاسخ سرور ممکنه ده‌ها فیلد داشته باشه که پنل به بیشتر اون‌ها نیازی نداره. DashboardData فقط اطلاعات لازم برای همین صفحه رو نگه می‌داره.

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

State صفحه پنل

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

نسخه ساده State می‌تونه این باشه:

sealed interface DashboardUiState {
    data object Loading : DashboardUiState

    data class Content(
        val data: DashboardData,
        val isRefreshing: Boolean = false
    ) : DashboardUiState

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

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

فیلد isRefreshing نکته مهم این State است. فرض کن اطلاعات کش‌شده داخل Room وجود دارن. صفحه می‌تونه همان اطلاعات رو نمایش بده، در حالی که Repository در پس‌زمینه داده‌های تازه رو از سرور می‌گیره. در این حالت نباید کل صفحه رو با Loading جایگزین کنیم. صفحه همچنان Content داره، اما isRefreshing برابر true میشه. این تفاوت بین بارگذاری اولیه و به‌روزرسانی اطلاعات موجود تجربه کاربری رو خیلی بهتر می‌کنه. کاربر هنگام هر Refresh با صفحه سفید روبه‌رو نمیشه.

پنل ابتدا اطلاعات محلی رو نمایش میده

چون در درس‌های قبلی معماری Offline First رو انتخاب کردیم، صفحه باید تا جای ممکن اطلاعات خودش رو از Room دریافت کنه. Repository می‌تونه اطلاعات محلی رو به شکل یک Flow در اختیار ViewModel قرار بده:

fun observeDashboard(): Flow<DashboardData> {
    return dashboardDao.observeDashboard()
        .map { entity -> entity.toDashboardData() }
}

این تابع قرار نیست یک‌بار اطلاعات رو بخونه و تمام بشه. Flow یعنی هر زمان داده مربوط به پنل در Room تغییر کنه، مقدار جدید به ViewModel ارسال میشه.

dashboardDao.observeDashboard() اطلاعات خام دیتابیس رو مشاهده می‌کنه. تابع map هم هر Entity رو به DashboardData تبدیل می‌کنه؛ یعنی مدل مناسب لایه UI رو می‌سازه.

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

ViewModel اطلاعات پنل رو مشاهده می‌کنه

ViewModel باید Flow مربوط به Repository رو دریافت کنه و اون رو به State قابل استفاده برای Compose تبدیل کنه:

val uiState = repository.observeDashboard()
    .map<DashboardData, DashboardUiState> {
        DashboardUiState.Content(data = it)
    }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = DashboardUiState.Loading
    )

این یکی از مهم‌ترین کدهای این درسه، چون نشون میده اطلاعات چطور از Repository به یک State پایدار داخل ViewModel تبدیل میشن.

تابع map هر DashboardData رو داخل DashboardUiState.Content قرار میده. صفحه قرار نیست مدل خام رو دریافت کنه؛ State مشخصی می‌گیره که میگه محتوای قابل نمایش آماده است. stateIn یک Flow معمولی رو به StateFlow تبدیل می‌کنه. به این ترتیب، همیشه یک مقدار فعلی برای صفحه وجود داره و Compose می‌تونه اون رو مشاهده کنه. scope = viewModelScope یعنی این جریان داده تا زمانی فعال می‌مونه که ViewModel زنده است. initialValue هم مشخص می‌کنه قبل از دریافت اولین داده، صفحه در وضعیت Loading قرار داشته باشه.

بخش زیر رفتار اشتراک‌گذاری جریان رو کنترل می‌کنه:

started = SharingStarted.WhileSubscribed(5_000)

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

دریافت اطلاعات تازه از سرور

نمایش داده‌های محلی کافی نیست. Repository باید در صورت وجود اینترنت، اطلاعات جدید رو هم دریافت کنه:

suspend fun refreshDashboard() {
    val response = api.getDashboard()
    dashboardDao.upsert(response.toEntity())
}

این تابع دو کار انجام میده. ابتدا api.getDashboard() آمار جدید رو از سرور می‌گیره. بعد upsert اطلاعات دریافتی رو داخل Room ذخیره یا جایگزین می‌کنه.

کلمه upsert ترکیبی از insert و update است. یعنی اگر رکورد وجود نداشته باشه، ساخته میشه و اگر قبلاً وجود داشته باشه، به‌روزرسانی میشه. نکته مهم اینه که این تابع چیزی رو مستقیماً به صفحه برنمی‌گردونه. نتیجه داخل Room ذخیره میشه و Flow قبلی تغییرات رو به ViewModel منتقل می‌کنه. این مسیر باعث میشه یک منبع اصلی اطلاعات داشته باشیم:

API

Room

Flow

ViewModel

Compose

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

اجرای Refresh هنگام باز شدن صفحه

وقتی پنل برای اولین‌بار باز میشه، ViewModel می‌تونه همگام‌سازی رو شروع کنه:

init {
    refresh()
}

init هنگام ساخته شدن ViewModel اجرا میشه. اینجا فقط تابع refresh() رو صدا می‌زنیم تا منطق واقعی داخل تابع جداگانه باقی بمونه. خود Refresh داخل Coroutine انجام میشه:

fun refresh() {
    viewModelScope.launch {
        repository.refreshDashboard()
    }
}

چون درخواست API یک عملیات suspend و زمان‌بره، داخل viewModelScope.launch اجرا میشه.

اما این نسخه هنوز مدیریت خطا یا وضعیت Refresh نداره. در پنل واقعی باید بدونیم درخواست جدید در حال اجراست یا شکست خورده.

به‌روزرسانی بدون حذف اطلاعات قبلی

فرض کن صفحه اطلاعات کش‌شده رو نمایش میده و کاربر صفحه رو به پایین می‌کشه تا Refresh انجام بشه. در این لحظه نباید State رو به Loading تغییر بدیم، چون این کار محتوای قبلی رو از صفحه حذف می‌کنه.

بهتره یک State جدا برای وضعیت Refresh داشته باشیم:

private val _isRefreshing = MutableStateFlow(false)
val isRefreshing = _isRefreshing.asStateFlow()

قبل از شروع درخواست، مقدار اون روی true قرار می‌گیره و بعد از پایان عملیات دوباره false میشه:

fun refresh() {
    viewModelScope.launch {
        _isRefreshing.value = true

        try {
            repository.refreshDashboard()
        } finally {
            _isRefreshing.value = false
        }
    }
}

بخش finally چه درخواست موفق باشه و چه خطا اتفاق بیفته، اجرا میشه. بنابراین مطمئن میشیم حالت Refresh برای همیشه روشن باقی نمی‌مونه.

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

بعد میشه اطلاعات اصلی و وضعیت Refresh رو با هم ترکیب کرد:

combine(
    repository.observeDashboard(),
    isRefreshing
) { data, refreshing ->
    DashboardUiState.Content(
        data = data,
        isRefreshing = refreshing
    )
}

combine آخرین مقدار دو Flow رو می‌گیره و از ترکیب اون‌ها State جدید می‌سازه. در اینجا یک Flow اطلاعات پنل رو میده و Flow دوم مشخص می‌کنه به‌روزرسانی در حال انجامه یا نه.

به این ترتیب، صفحه یک State واحد دریافت می‌کنه که هم داده‌ها و هم وضعیت فعلی عملیات رو در خودش داره.

صفحه چطور State رو دریافت می‌کنه؟

در Compose، پنل StateFlow رو به شکل زیر مشاهده می‌کنه:

val uiState by viewModel.uiState.collectAsStateWithLifecycle()

هر بار که داده Room تغییر کنه یا وضعیت Refresh عوض بشه، uiState هم به‌روز میشه و Compose فقط بخش‌های وابسته رو دوباره اجرا می‌کنه.

برای نمایش وضعیت‌های مختلف می‌تونیم از when استفاده کنیم:

when (val state = uiState) {
    DashboardUiState.Loading -> LoadingView()

    is DashboardUiState.Content ->
        DashboardContent(state.data)

    is DashboardUiState.Error ->
        ErrorView(state.message)
}

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

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

نمایش اطلاعات داخل کارت‌ها

برای هر عدد پنل لازم نیست یک Composable پیچیده و جدا بنویسیم. بخش متمایز اینه که مقدارها رو از DashboardData بگیریم، نه اینکه هر کارت خودش از ViewModel یا Repository اطلاعات بخونه:

DashboardStatCard(
    title = "کالاهای بدون قیمت",
    value = data.productsWithoutPrice.toString()
)

این کارت فقط داده دریافت می‌کنه و نمایش میده. نمی‌دونه عدد از API اومده، Room یا Cache.

این نوع Composable رو به اسم Stateless Composable می‌شناسیم؛ یعنی خودش مالک State اصلی نیست و فقط براساس ورودی‌هایی که دریافت می‌کنه UI می‌سازه.

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

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

میان‌برهای پنل چطور کار می‌کنن؟

در پنل قیمت‌بان ممکنه دکمه‌هایی مثل ثبت قیمت جدید یا مشاهده کالاها وجود داشته باشن. Composable داخلی پنل نباید خودش NavController رو بشناسه. بهتره رویدادها از بیرون به اون داده بشن:

DashboardContent(
    data = state.data,
    onAddPriceClick = onAddPriceClick,
    onProductsClick = onProductsClick
)

داخل کارت مربوط به ثبت قیمت فقط Callback اجرا میشه:

Button(onClick = onAddPriceClick) {
    Text("ثبت قیمت جدید")
}

Callback یعنی صفحه اعلام می‌کنه چه اتفاقی افتاده، اما تصمیم نهایی درباره Navigation در لایه بالاتر گرفته میشه.

مثلاً در مقصد Navigation می‌تونیم بنویسیم:

onAddPriceClick = {
    navController.navigate("add-price")
}

علت این جداسازی اینه که DashboardContent وظیفه نمایش محتوای پنل رو داره، نه مدیریت مسیرهای کل برنامه. اگر Navigation رو مستقیماً داخل همه کارت‌ها پخش کنیم، Composableها وابسته و آزمایش اون‌ها سخت میشه.

نمایش نام کسب‌وکار از Session یا Room؟

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

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

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

پس قاعده ساده اینه:

Token برای هویت و مجوز
Room برای اطلاعات قابل نمایش

این دو ممکنه بخشی از داده‌های مشابه رو داشته باشن، اما هدف اون‌ها یکسان نیست.

خطای Refresh نباید کل پنل رو نابود کنه

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

در این وضعیت بهتره محتوای قبلی حفظ بشه و فقط یک پیام کوتاه نمایش داده بشه:

به‌روزرسانی انجام نشد؛ آخرین اطلاعات ذخیره‌شده نمایش داده میشه.

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

private val _events = MutableSharedFlow<DashboardEvent>()
val events = _events.asSharedFlow()

State با Event فرق داره. State وضعیت فعلی و ماندگار صفحه است؛ مثلاً اطلاعات پنل یا Loading. اما Event اتفاقیه که یک‌بار رخ میده؛ مثلاً نمایش Snackbar.

در زمان خطای Refresh می‌تونیم Event بفرستیم:

_events.emit(
    DashboardEvent.ShowMessage(
        "به‌روزرسانی انجام نشد"
    )
)

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

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

خالی بودن پنل با خطا فرق داره

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

این حالت خطا نیست. سرور درست کار کرده و داده‌ها هم دریافت شدن؛ فقط کسب‌وکار هنوز اطلاعاتی ثبت نکرده. به این وضعیت Empty State میگیم.

می‌تونیم داخل مدل صفحه تشخیص بدیم که داده‌ای وجود نداره:

val isEmpty: Boolean
    get() = totalProducts == 0

اگر isEmpty درست باشه، به جای چند کارت صفر میشه یک پیام راهنما نمایش داد:

هنوز کالایی ثبت نکردی.
اولین کالای کسب‌وکارت رو اضافه کن.

بعد هم یک دکمه مستقیم برای افزودن کالا قرار بدیم.

Empty State باید کاربر رو هدایت کنه. صفحه‌ای که فقط میگه اطلاعاتی وجود ندارد عملاً مسئولیت خودش رو نصفه انجام داده.

پس سه وضعیت متفاوت داریم:

Loading:
هنوز نمی‌دونیم اطلاعات چیه.

Error:
نتونستیم اطلاعات قابل استفاده‌ای دریافت کنیم.

Empty:
اطلاعات رو دریافت کردیم، اما هنوز چیزی ثبت نشده.

تفکیک این حالت‌ها باعث میشه صفحه در هر شرایط رفتار مناسبی داشته باشه.

آیا پنل باید هر بار از سرور اطلاعات بگیره؟

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

if (cacheIsExpired()) {
    refreshDashboard()
}

cacheIsExpired() بررسی می‌کنه از آخرین دریافت اطلاعات چقدر گذشته. مثلاً اگر کمتر از پنج دقیقه گذشته باشه، پنل می‌تونه همان داده‌های موجود رو نمایش بده. اگر اطلاعات قدیمی‌تر باشن، Refresh انجام میشه.

البته کاربر باید همیشه امکان به‌روزرسانی دستی رو داشته باشه. یعنی حتی اگر Cache هنوز معتبره، با Pull to Refresh بتونه دریافت اطلاعات جدید رو مجبور کنه.

در اینجا دوباره می‌بینیم که Cache فقط یک محل ذخیره‌سازی نیست؛ تصمیمیه درباره اینکه چه زمانی داده موجود کافی است و چه زمانی باید اطلاعات تازه بگیریم.

پنل و مجوز دسترسی

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

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

مدل صفحه می‌تونه مجوزهای لازم رو دریافت کنه:

val canManageProducts: Boolean
val canViewReports: Boolean

بعد رابط کاربری براساس مجوزها بعضی گزینه‌ها رو نمایش بده:

if (data.canViewReports) {
    ReportsShortcut()
}

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

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

خروج از حساب در پنل

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

suspend fun logout() {
    tokenStorage.clear()
    database.clearUserData()
}

tokenStorage.clear() Tokenها رو حذف می‌کنه تا برنامه دیگر کاربر رو واردشده در نظر نگیره. database.clearUserData() هم اطلاعات محلی مربوط به حساب فعلی رو پاک می‌کنه.

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

بعد از پاک شدن Session، Navigation انجام میشه:

navController.navigate("login") {
    popUpTo("home") {
        inclusive = true
    }
}

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

پنل نقطه اتصال چند درس قبلیه

در این صفحه تقریباً تمام مفاهیم قبلی دوباره کنار هم قرار می‌گیرن. Token ذخیره‌شده باعث میشه درخواست Dashboard با هویت کاربر ارسال بشه. Retrofit اطلاعات جدید رو از سرور می‌گیره. Repository تصمیم می‌گیره داده‌ها چطور ترکیب و ذخیره بشن. Room منبع اصلی اطلاعات صفحه باقی می‌مونه. Flow تغییرات رو منتقل می‌کنه. ViewModel اون‌ها رو به UI State تبدیل می‌کنه و Compose براساس State رابط کاربری رو می‌سازه.

Offline First هم باعث میشه پنل در نبود اینترنت کاملاً خالی نشه. Cache مشخص می‌کنه چه زمانی داده موجود کافی است. Error Handling هم اجازه میده خطای Refresh بدون نابود کردن محتوای قبلی مدیریت بشه.

در ظاهر، کاربر فقط چند کارت و دکمه می‌بینه؛ اما پشت این صفحه، معماری برنامه واقعاً شروع به کار کرده.

پنل مدیریت فقط نمایش اطلاعات نیست

یک Dashboard خوب باید در چند ثانیه به سه سؤال پاسخ بده:

الان وضعیت کسب‌وکار من چطوره؟
چه چیزی نیاز به توجه داره؟
قدم بعدی من چیه؟

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

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

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

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

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


درس بیست‌وچهارم

وقتی کاربر اطلاعات کسب‌وکارش رو تغییر میده، برنامه چطور مطمئن میشه داده جدید درست ذخیره شده و همه‌جای اپ به‌روز شده؟

ویرایش اطلاعات کسب‌وکار

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

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

مسیر کلی ویرایش به این شکله:

EditBusinessScreen

EditBusinessViewModel

BusinessRepository

API

Room

Flow

Dashboard و Profile

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

اطلاعات اولیه فرم از کجا میان؟

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

چون در معماری قیمت‌بان، Room منبع اصلی اطلاعات قابل نمایش حسابه، ViewModel اطلاعات فعلی رو از Repository دریافت می‌کنه:

repository.observeBusiness()

این کد یک جریان از اطلاعات کسب‌وکار برمی‌گردونه. هر زمان اطلاعات داخل Room تغییر کنن، مقدار جدید هم به ViewModel میرسه.

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

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

data class Business(
    val id: Long,
    val name: String,
    val phone: String,
    val address: String
)

این مدل وضعیت فعلی کسب‌وکار رو نمایش میده، اما فرم به State مخصوص خودش نیاز داره:

data class EditBusinessForm(
    val name: String = "",
    val phone: String = "",
    val address: String = ""
)

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

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

چرا فرم باید داخل ViewModel نگهداری بشه؟

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

در اینجا نگهداری State فرم داخل ViewModel می‌تونه کنترل بیشتری ایجاد کنه:

private val _form = MutableStateFlow(EditBusinessForm())
val form = _form.asStateFlow()

نسخه خصوصی _form فقط داخل ViewModel تغییر می‌کنه. صفحه نسخه عمومی form رو مشاهده می‌کنه و مقدار فیلدها رو نمایش میده.

وقتی اطلاعات اصلی کسب‌وکار دریافت میشن، ViewModel اون‌ها رو وارد فرم می‌کنه:

_form.value = EditBusinessForm(
    name = business.name,
    phone = business.phone,
    address = business.address
)

این کد اطلاعات ذخیره‌شده رو به State قابل ویرایش صفحه تبدیل می‌کنه.

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

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

if (!_isFormInitialized) {
    _form.value = business.toEditForm()
    _isFormInitialized = true
}

این شرط باعث میشه اطلاعات اولیه فقط یک‌بار داخل فرم قرار بگیرن. بعد از اون، تغییرات کاربر حفظ میشن و هر انتشار جدید Flow فرم رو بازنشانی نمی‌کنه.

تغییر هر فیلد چطور وارد ViewModel میشه؟

صفحه نباید مستقیماً مقدار StateFlow رو تغییر بده. برای هر تغییر، یک رویداد به ViewModel می‌فرسته:

viewModel.onNameChanged(newValue)

داخل ViewModel فقط همان قسمت فرم تغییر می‌کنه:

fun onNameChanged(value: String) {
    _form.update { current ->
        current.copy(name = value)
    }
}

تابع update مقدار فعلی StateFlow رو می‌گیره و نسخه جدیدی از اون می‌سازه. copy هم فقط فیلد name رو تغییر میده و شماره تماس و آدرس رو دست‌نخورده نگه می‌داره.

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

مثلاً برای شماره تماس می‌تونیم فقط عددها رو بپذیریم:

fun onPhoneChanged(value: String) {
    _form.update {
        it.copy(phone = value.filter(Char::isDigit))
    }
}

filter(Char::isDigit) تمام کاراکترهایی رو که عدد نیستن حذف می‌کنه. بنابراین اگه کاربر فاصله، خط تیره یا حرف وارد کنه، فقط رقم‌ها داخل State باقی می‌مونن.

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

آیا واقعاً چیزی تغییر کرده؟

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

برای تشخیص تغییر، باید نسخه اولیه اطلاعات رو هم نگه داریم:

private var originalForm: EditBusinessForm? = null

وقتی اطلاعات اولیه دریافت میشن، هم فرم فعلی و هم نسخه اصلی رو ذخیره می‌کنیم:

val initialForm = business.toEditForm()

_form.value = initialForm
originalForm = initialForm

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

val hasChanges: Boolean
    get() = _form.value != originalForm

چون EditBusinessForm یک data class است، Kotlin مقدار فیلدهای اون رو مقایسه می‌کنه. اگه حتی یکی از فیلدها تغییر کرده باشه، نتیجه مقایسه متفاوت میشه.

این مقدار می‌تونه برای فعال یا غیرفعال کردن دکمه ذخیره استفاده بشه:

enabled = uiState.hasChanges && !uiState.isSaving

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

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

State صفحه ویرایش

برای این صفحه، فقط Loading و Success کافی نیست. ما هم اطلاعات فرم رو داریم، هم وضعیت بارگذاری اولیه، هم وضعیت ذخیره و هم احتمال خطا.

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

data class EditBusinessUiState(
    val form: EditBusinessForm = EditBusinessForm(),
    val isLoading: Boolean = true,
    val isSaving: Boolean = false,
    val hasChanges: Boolean = false,
    val errorMessage: String? = null
)

در این صفحه چند وضعیت می‌تونن هم‌زمان وجود داشته باشن. مثلاً فرم نمایش داده بشه و هم‌زمان isSaving هم درست باشه. به همین دلیل، برخلاف Login که Stateهای جداگانه مثل Loading و Success داشت، اینجا یک data class می‌تونه مناسب‌تر باشه.

form اطلاعات فعلی فیلدها رو نگه می‌داره. isLoading مربوط به دریافت اطلاعات اولیه است. isSaving نشون میده درخواست ذخیره در حال اجراست. hasChanges برای کنترل دکمه ذخیره استفاده میشه و errorMessage هم خطای فعلی فرم یا سرور رو نگه می‌داره.

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

وقتی حالت‌ها انحصاری هستن، مثل Login، sealed interface خیلی مناسبه. اما وقتی چند ویژگی می‌تونن هم‌زمان فعال باشن، data class معمولاً خواناتر میشه.

اعتبارسنجی قبل از ذخیره

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

if (form.name.isBlank()) {
    showError("نام کسب‌وکار رو وارد کن")
    return
}

isBlank() رشته خالی یا رشته‌ای که فقط فاصله داره رو نامعتبر تشخیص میده.

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

if (form.phone.length < 10) {
    showError("شماره تماس معتبر نیست")
    return
}

این فقط یک نمونه ساده است. قانون دقیق باید براساس نوع شماره‌های قابل قبول در قیمت‌بان نوشته بشه.

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

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

مدل درخواست ویرایش

در ثبت‌نام از RegisterRequest استفاده کردیم. برای ویرایش هم بهتره مدل مخصوص درخواست داشته باشیم:

data class UpdateBusinessRequest(
    val name: String,
    val phone: String,
    val address: String?
)

این مدل قرارداد بین اپلیکیشن و Endpoint ویرایشه.

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

به همین دلیل بهتره هر درخواست مدل مخصوص خودش رو داشته باشه.

اگر سرور برای نام کسب‌وکار از نام متفاوتی استفاده کنه، مثل business_name، دوباره از Annotation مربوط به Serialization استفاده می‌کنیم:

@SerialName("business_name")
val name: String

این خط به Serialization میگه نام Kotlin برابر name است، اما در JSON باید با کلید business_name ارسال بشه.

درخواست Retrofit برای ویرایش

برای ویرایش کامل یک منبع، معمولاً از PUT و برای ویرایش بخشی از اطلاعات، معمولاً از PATCH استفاده میشه. انتخاب دقیق به طراحی API بستگی داره.

مثلاً اگه فقط فیلدهای ارسال‌شده تغییر کنن، Endpoint می‌تونه چنین باشه:

@PATCH("business/profile")
suspend fun updateBusiness(
    @Body request: UpdateBusinessRequest
): BusinessDto

@PATCH یعنی قرار نیست کل موجودیت کسب‌وکار از نو جایگزین بشه؛ فقط بخش‌هایی که در درخواست تعریف شدن تغییر می‌کنن.

خروجی BusinessDto هم نسخه جدید اطلاعات کسب‌وکاره که سرور بعد از ذخیره برمی‌گردونه.

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

بنابراین بهتره نسخه نهایی اطلاعات رو از پاسخ سرور دریافت کنیم.

Repository چطور اطلاعات رو ذخیره می‌کنه؟

بخش متمایز Repository چنین مسیری داره:

val updatedBusiness = api.updateBusiness(
    UpdateBusinessRequest(
        name = form.name.trim(),
        phone = form.phone.trim(),
        address = form.address.trim().ifBlank { null }
    )
)

نام و شماره قبل از ارسال trim() میشن تا فاصله‌های ناخواسته ابتدا و انتهای اون‌ها حذف بشن.

برای آدرس از این بخش استفاده کردیم:

form.address.trim().ifBlank { null }

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

فرق رشته خالی و null در بعضی APIها مهمه. رشته خالی یعنی فیلد وجود داره، اما مقدارش خالیه. null می‌تونه به معنی آدرسی ثبت نشده باشه. قرارداد دقیق این رفتار باید با Backend هماهنگ باشه.

بعد از دریافت پاسخ، نسخه جدید داخل Room ذخیره میشه:

businessDao.upsert(
    updatedBusiness.toEntity()
)

این خط نقطه اصلی هماهنگی برنامه است. سرور اطلاعات رو تغییر داده و حالا نسخه تأییدشده داخل دیتابیس محلی ذخیره میشه.

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

چرا ابتدا Room رو تغییر ندیم؟

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

در بعضی عملیات‌ها Optimistic Update تجربه خیلی خوبی ایجاد می‌کنه، اما برای اطلاعات اصلی حساب باید محتاط باشیم. اگه شماره تماس تکراری باشه یا سرور نام جدید رو رد کنه، مجبور میشیم تغییر محلی رو برگردونیم.

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

فرم
 ↓
سرور
 ↓
پاسخ موفق
 ↓
Room
 ↓
UI

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

برای تغییرات کم‌ریسک، مثل روشن یا خاموش کردن یک تنظیم ظاهری، Optimistic Update می‌تونه مناسب باشه. اما برای اطلاعات هویتی یا حساس، دریافت تأیید سرور معمولاً مطمئن‌تره.

ذخیره اطلاعات داخل ViewModel

هنگام کلیک روی دکمه، ViewModel ابتدا فرم رو می‌خونه و اعتبارسنجی می‌کنه. بعد وضعیت ذخیره رو فعال می‌کنه:

_uiState.update {
    it.copy(
        isSaving = true,
        errorMessage = null
    )
}

isSaving = true باعث میشه دکمه غیرفعال بشه و Loading نمایش داده بشه. errorMessage = null هم خطای قبلی رو پاک می‌کنه تا پیام قدیمی هنگام تلاش جدید روی صفحه باقی نمونه.

سپس درخواست داخل Coroutine اجرا میشه:

viewModelScope.launch {
    try {
        repository.updateBusiness(uiState.value.form)
        onSaveSuccess()
    } catch (error: Exception) {
        onSaveError(error)
    }
}

ViewModel فرم رو به Repository تحویل میده. Repository اون رو به مدل درخواست تبدیل می‌کنه، API رو صدا می‌زنه و پاسخ رو داخل Room ذخیره می‌کنه.

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

بعد از ذخیره موفق چه اتفاقی می‌افته؟

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

originalForm = _form.value

بعد وضعیت ذخیره خاموش میشه و hasChanges هم به false برمی‌گرده:

_uiState.update {
    it.copy(
        isSaving = false,
        hasChanges = false
    )
}

حالا می‌تونیم یک پیام موفقیت نمایش بدیم:

اطلاعات کسب‌وکار ذخیره شد.

این پیام یک اتفاق موقته، نه State دائمی صفحه. بنابراین میشه مثل درس قبل، از SharedFlow برای رویدادها استفاده کرد:

_events.emit(EditBusinessEvent.Saved)

صفحه رویداد Saved رو دریافت می‌کنه و می‌تونه Snackbar نمایش بده یا به صفحه قبل برگرده.

بعد از ذخیره، صفحه بسته بشه یا باز بمونه؟

این تصمیم به تجربه کاربری بستگی داره.

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

navController.popBackStack()

popBackStack() کاربر رو به صفحه قبلی برمی‌گردونه.

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

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

اما تصمیم نباید تصادفی باشه. باید ببینیم کاربر بعد از ذخیره معمولاً چه کاری انجام میده.

مدیریت خطای شماره تماس تکراری

ممکنه کاربر شماره‌ای وارد کنه که قبلاً برای کسب‌وکار دیگری ثبت شده. سرور احتمالاً درخواست رو با کد 409 Conflict رد می‌کنه.

این خطا باید به پیام مشخص تبدیل بشه:

is DuplicatePhoneException ->
    "این شماره تماس قبلاً برای حساب دیگری ثبت شده"

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

برای این کار می‌تونیم خطاهای فیلدها رو داخل State فرم نگه داریم:

data class EditBusinessFormErrors(
    val name: String? = null,
    val phone: String? = null,
    val address: String? = null
)

وقتی خطای شماره رخ میده، فقط خطای همان فیلد تغییر می‌کنه:

errors.copy(
    phone = "این شماره قبلاً ثبت شده"
)

در Compose هم TextField می‌تونه این خطا رو نمایش بده:

isError = state.errors.phone != null

این خط میگه اگه برای شماره تماس پیام خطایی وجود داره، TextField باید در حالت خطا نمایش داده بشه.

پیام واقعی هم از State گرفته میشه:

supportingText = {
    state.errors.phone?.let {
        Text(it)
    }
}

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

خطای سرور نباید تغییرات فرم رو پاک کنه

فرض کن کاربر نام، شماره و آدرس رو تغییر داده و بعد اینترنت قطع میشه. اگه هنگام خطا فرم دوباره از Room مقداردهی بشه، تمام چیزهایی که تایپ کرده از بین میرن.

این رفتار آزاردهنده است. در زمان خطای ذخیره، فرم باید همان‌طور باقی بمونه و فقط پیام خطا نمایش داده بشه:

_uiState.update {
    it.copy(
        isSaving = false,
        errorMessage = error.toUserMessage()
    )
}

در اینجا هیچ تغییری در form انجام نمیدیم. بنابراین کاربر فقط بعد از اتصال اینترنت دوباره روی ذخیره می‌زنه و لازم نیست همه اطلاعات رو از اول وارد کنه.

این یکی از مهم‌ترین تفاوت‌های State فرم و داده ذخیره‌شده است. فرم ممکنه نسخه‌ای هنوز تأییدنشده داشته باشه و باید تا زمان تصمیم کاربر حفظ بشه.

اگه کاربر بدون ذخیره خارج بشه چه کنیم؟

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

چون hasChanges رو داریم، می‌تونیم قبل از خروج بررسی کنیم:

if (uiState.hasChanges) {
    showDiscardChangesDialog()
} else {
    navController.popBackStack()
}

اگه تغییری وجود نداشته باشه، صفحه عادی بسته میشه. اما اگه فرم تغییر کرده باشه، یک Dialog نمایش داده میشه:

تغییرات ذخیره نشدن.
می‌خوای بدون ذخیره از صفحه خارج بشی؟

این Dialog معمولاً دو انتخاب داره: ادامه ویرایش و خروج بدون ذخیره.

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

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

ویرایش در حالت آفلاین

در معماری Offline First، ممکنه به ذهنمون برسه که ویرایش اطلاعات کسب‌وکار رو هم آفلاین انجام بدیم و بعداً با سرور همگام کنیم.

از نظر فنی این کار ممکنه. می‌تونیم تغییرات رو داخل Room ثبت کنیم و یک وضعیت مثل pendingSync روی رکورد قرار بدیم. بعد وقتی اینترنت وصل شد، WorkManager اطلاعات رو به سرور بفرسته.

اما این روش چند مسئله ایجاد می‌کنه. ممکنه شماره جدید در سرور تکراری باشه. ممکنه کاربر هم‌زمان از دستگاه دیگری اطلاعات رو تغییر داده باشه. یا سرور بخشی از داده رو رد کنه.

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

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

برای ذخیره تغییرات، اتصال اینترنت لازمه.
اطلاعاتی که وارد کردی حفظ شده.

این هم رفتار صادقانه‌ایه و هم پیچیدگی Sync و تعارض داده رو وارد نسخه اولیه نمی‌کنه.

تغییر شماره تماس ممکنه با تغییر عادی فرق داشته باشه

گاهی شماره تماس فقط یک فیلد ساده نیست و نقش شناسه ورود کاربر رو هم داره. در این حالت تغییر شماره باید با حساسیت بیشتری انجام بشه.

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

مسیر چنین تغییری می‌تونه به این شکل باشه:

شماره جدید
 ↓
ارسال کد تأیید
 ↓
تأیید کد
 ↓
تغییر شماره در سرور
 ↓
به‌روزرسانی Room

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

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

آیا Token هم باید بعد از ویرایش تغییر کنه؟

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

اما اگه اطلاعاتی مثل نام، شماره یا نقش کاربر داخل Claims مربوط به JWT قرار گرفته باشن، Token قدیمی ممکنه همچنان داده قبلی رو داشته باشه.

به همین دلیل بهتره UI اطلاعات قابل نمایش رو مستقیم از Token نخونه. Token باید برای اثبات هویت و مجوز استفاده بشه، نه برای نگهداری آخرین نسخه پروفایل.

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

response.newAccessToken?.let {
    tokenStorage.saveAccessToken(it)
}

این کد میگه اگه پاسخ ویرایش شامل Access Token جدید بود، نسخه قبلی جایگزین بشه.

اما این رفتار باید فقط وقتی اجرا بشه که Backend واقعاً چنین قراردادی داشته باشه. نباید خودمون بدون نیاز Token جدید بسازیم یا انتظار داشته باشیم.

همه صفحه‌ها چطور به‌روز میشن؟

زیبایی مسیر Room و Flow اینجا مشخص میشه. Repository پاسخ موفق سرور رو داخل Room ذخیره می‌کنه:

businessDao.upsert(updatedBusiness.toEntity())

صفحه پنل از قبل چنین جریانی رو مشاهده می‌کنه:

repository.observeBusiness()

وقتی رکورد Room تغییر می‌کنه، Flow مقدار جدید رو منتشر می‌کنه. DashboardViewModel مقدار جدید رو دریافت می‌کنه و Compose نام جدید رو نمایش میده.

بنابراین لازم نیست بعد از بازگشت از صفحه ویرایش، دستی بگیم:

نام پنل رو تغییر بده.
پروفایل رو Refresh کن.
منوی کناری رو دوباره بساز.

همه این صفحه‌ها به یک منبع مشترک وصل هستن و تغییر از همان منبع منتشر میشه.

این یکی از مزیت‌های مهم معماری واکنشی است:

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

تفاوت منبع داده و State فرم

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

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

مسیر عادی به این شکله:

Room
 ↓
اطلاعات اولیه فرم
 ↓
تغییرات کاربر
 ↓
اعتبارسنجی
 ↓
API
 ↓
پاسخ تأییدشده
 ↓
Room

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

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

ویرایش اطلاعات فقط تغییر چند TextField نیست

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

در این فرایند، صفحه فقط فیلدها و وضعیت رو نمایش میده. ViewModel State فرم، اعتبارسنجی و عملیات ذخیره رو مدیریت می‌کنه. Repository درخواست رو به API می‌فرسته و نسخه تأییدشده رو داخل Room قرار میده. Flow هم تغییر رو به همه بخش‌های برنامه منتقل می‌کنه.

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

در نهایت، مسیر ویرایش اطلاعات کسب‌وکار رو میشه این‌طور خلاصه کرد:

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


درس بیست‌وپنجم

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

آپلود عکس

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

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

انتخاب منبع عکس
      ↓
Camera یا Gallery
      ↓
دریافت Uri
      ↓
Crop
      ↓
Resize و Compress
      ↓
ساخت فایل قابل ارسال
      ↓
Multipart Upload
      ↓
دریافت آدرس عکس
      ↓
ذخیره در Room
      ↓
نمایش در برنامه

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

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

عکس کسب‌وکار از کجا میاد؟

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

گرفتن عکس با دوربین
انتخاب از گالری
حذف عکس فعلی

این گزینه‌ها از نظر تجربه کاربری در کنار هم قرار دارن، اما پشت صحنه مسیر یکسانی ندارن.

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

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

Camera ───┐
          ├──→ Uri عکس اولیه → Crop → Compress → Upload
Gallery ──┘

یعنی بخش Crop و Upload نباید اهمیت بده عکس از دوربین اومده یا گالری. این جداسازی باعث میشه کد پردازش رو دوبار ننویسیم.

Uri دقیقاً چیه؟

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

/uploads/business/logo.jpg

اما در اندروید، عکس انتخاب‌شده همیشه یک مسیر فایل مستقیم نیست. سیستم معمولاً یک Uri تحویل میده:

content://media/picker/...

Uri رو میشه شبیه یک آدرس دسترسی به محتوا در نظر گرفت. این آدرس ممکنه به یک فایل واقعی اشاره کنه، اما برنامه لازم نیست و گاهی اصلاً نباید مسیر فیزیکی اون فایل رو پیدا کنه.

اشتباه رایج اینه که توسعه‌دهنده تلاش می‌کنه هر content:// Uri رو به یک مسیر مثل /storage/emulated/0/... تبدیل کنه. این روش روی همه نسخه‌ها و همه سرویس‌های انتخاب فایل قابل اعتماد نیست. بهتره محتوا رو با ContentResolver مستقیماً از خود Uri بخونیم.

پس در این درس، Uri حلقه اتصال تمام مراحل پردازشه:

عکس انتخاب‌شده → Uri
عکس گرفته‌شده → Uri
خروجی Crop → Uri یا File
فایل فشرده‌شده → File

انتخاب عکس از گالری

برای انتخاب عکس، روش جدیدتر و مناسب‌تر اندروید استفاده از Photo Picker سیستمه. این انتخابگر به کاربر اجازه میده فقط عکس موردنظرش رو در اختیار برنامه بذاره، بدون اینکه اپلیکیشن دسترسی گسترده‌ای به کل گالری بخواد. قرارداد PickVisualMedia یک عکس یا ویدئو رو انتخاب می‌کنه و در صورت انتخاب موفق یک Uri برمی‌گردونه؛ در صورت انصراف کاربر هم مقدار null میده. این قرارداد در دستگاه‌هایی که Photo Picker اصلی رو ندارن، از مسیرهای جایگزین سیستم استفاده می‌کنه.

در Compose، بخش متمایز انتخاب عکس به این شکله:

val galleryLauncher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
    uri?.let(viewModel::onImageSelected)
}

rememberLauncherForActivityResult یک Launcher می‌سازه که می‌تونه یک قابلیت خارج از Compose رو اجرا کنه و نتیجه رو برگردونه.

در اینجا قرارداد ما PickVisualMedia است. وقتی کاربر انتخابگر عکس رو می‌بنده، Callback اجرا میشه. اگه کاربر واقعاً عکسی انتخاب کرده باشه، uri مقدار داره و اون رو به ViewModel تحویل میدیم. اگه کاربر دکمه Back رو زده باشه، مقدار null میشه و کاری انجام نمیدیم.

برای باز کردن گالری، Launcher رو این‌طور اجرا می‌کنیم:

galleryLauncher.launch(
    PickVisualMediaRequest(
        ActivityResultContracts.PickVisualMedia.ImageOnly
    )
)

ImageOnly میگه فقط تصویرها نمایش داده بشن، نه ویدئوها. خود Photo Picker هم خروجی رو به شکل Uri برمی‌گردونه. Uri انتخاب‌شده معمولاً برای خواندن مناسبه، اما نباید فرض کنیم فایل اصلی قابل ویرایش یا نوشتنه.

صفحه در اینجا فقط میگه:

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

اینکه عکس باید برش بخوره، فشرده بشه یا آپلود بشه، وظیفه Launcher گالری نیست.

آیا برای گالری Permission لازم داریم؟

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

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

برای قیمت‌بان که کاربر فقط قراره لوگو یا تصویر کسب‌وکار رو انتخاب کنه، Photo Picker کاملاً متناسب با نیاز ماست.

گرفتن عکس با دوربین

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

val cameraLauncher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.TakePicture()
) { success ->
    if (success) {
        viewModel.onImageSelected(cameraImageUri)
    }
}

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

برای باز کردن دوربین می‌نویسیم:

cameraLauncher.launch(cameraImageUri)

پس تفاوت دوربین و گالری اینه:

Gallery:
سیستم بعد از انتخاب، Uri رو به ما میده.

Camera:
ما قبل از باز کردن دوربین، Uri رو به سیستم میدیم.

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

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

Uri دوربین رو چطور می‌سازیم؟

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

ابتدا یک فایل موقت می‌سازیم:

val imageFile = File.createTempFile(
    "business_photo_",
    ".jpg",
    context.cacheDir
)

این فایل داخل Cache برنامه ساخته میشه. موقتی بودنش مهمه، چون هنوز تصویر نهایی و تأییدشده کاربر نیست.

بعد با FileProvider یک Uri امن برای اون می‌سازیم:

val cameraImageUri = FileProvider.getUriForFile(
    context,
    "${context.packageName}.fileprovider",
    imageFile
)

FileProvider به جای یک آدرس ناامن file://، یک content:// Uri ایجاد می‌کنه و میشه دسترسی موقت لازم رو به برنامه دوربین داد. اندروید استفاده از Content URI و مجوزهای موقت رو برای اشتراک امن فایل بین برنامه‌ها در نظر گرفته است.

خود Provider باید در Manifest معرفی بشه:

<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
</provider>

exported="false" یعنی Provider به‌صورت عمومی در اختیار همه برنامه‌ها قرار نمی‌گیره. grantUriPermissions="true" اجازه میده دسترسی موقتی به Uri مشخص‌شده صادر بشه.

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

مجوز دوربین

برای باز کردن دوربین معمولاً باید مجوز CAMERA در Manifest تعریف بشه:

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

اما وجود این خط به تنهایی کافی نیست. اگه مجوز از نوع Runtime Permission باشه، باید هنگام نیاز از کاربر درخواست بشه.

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

بخش متمایز درخواست Permission می‌تونه چنین باشه:

val cameraPermissionLauncher =
    rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        if (granted) {
            cameraLauncher.launch(cameraImageUri)
        }
    }

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

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

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

fun onImageSelected(uri: Uri) {
    _uiState.update {
        it.copy(sourceImageUri = uri)
    }

    startCrop(uri)
}

این تابع Uri اولیه رو نگه می‌داره و فرایند Crop رو شروع می‌کنه.

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

همین اصل در بسیاری از فرایندهای برنامه‌نویسی مفیده:

مسیرهای مختلف ورودی رو هرچه زودتر به یک مدل مشترک تبدیل کن.

چرا عکس رو Crop می‌کنیم؟

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

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

1 : 1

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

16 : 9

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

فایل واقعی دایره‌ای نیست. دایره فقط شکل نمایش UIه.

Crop جزو کد داخلی اندروید نیست

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

استفاده از یک کتابخانه Crop معتبر

یا

ساخت صفحه Crop اختصاصی

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

cropLauncher.launch(
    CropInput(
        sourceUri = sourceUri,
        aspectRatioX = 1f,
        aspectRatioY = 1f
    )
)

این کد نمونه مفهومی است. میگه ابزار Crop باید Uri اولیه و نسبت موردنیاز رو دریافت کنه.

بعد از پایان Crop، یک Uri یا File جدید تحویل می‌گیریم:

fun onCropFinished(croppedUri: Uri) {
    _uiState.update {
        it.copy(croppedImageUri = croppedUri)
    }
}

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

چرا نباید عکس اصلی کاربر رو تغییر بدیم؟

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

پس Crop باید همیشه خروجی جدا بسازه:

Original Uri
     ↓
Crop
     ↓
New Temporary File

فایل اصلی فقط خوانده میشه. تمام تغییرات روی یک فایل موقت متعلق به برنامه انجام میشن.

Crop و Compress دو کار متفاوتن

گاهی Crop و Compress با هم اشتباه گرفته میشن، اما هدف اون‌ها فرق داره.

Crop بخشی از تصویر رو حذف می‌کنه:

عکس کامل فروشگاه
        ↓
فقط لوگو یا محدوده اصلی

Compress حجم فایل رو کم می‌کنه:

فایل ۶ مگابایتی
        ↓
فایل ۳۰۰ کیلوبایتی

Resize هم ابعاد پیکسلی رو کاهش میده:

4000 × 4000
      ↓
1080 × 1080

یک فرایند حرفه‌ای معمولاً هر سه مرحله رو در نظر می‌گیره:

Crop → Resize → Compress

اگه فقط Compress کنیم اما ابعاد تصویر همچنان ۶۰۰۰ پیکسل باشه، ممکنه فایل هنوز بیش از حد بزرگ بمونه و پردازش حافظه هم سنگین باشه.

چرا قبل از Upload عکس رو کوچک می‌کنیم؟

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

فشرده‌سازی قبل از Upload چند فایده داره:

مصرف کمتر اینترنت
آپلود سریع‌تر
فشار کمتر روی سرور
فضای ذخیره‌سازی کمتر
نمایش سریع‌تر عکس در دفعات بعد

اما نباید صرفاً کیفیت رو تا حد زیادی پایین بیاریم. هدف Compress نابود کردن عکس نیست؛ هدف پیدا کردن تعادل بین کیفیت بصری و حجم فایله.

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

خواندن تصویر از Uri

برای پردازش عکس، باید محتوای Uri رو بخونیم. نکته اصلی اینه که این کار از طریق ContentResolver انجام میشه:

val inputStream =
    context.contentResolver.openInputStream(uri)

openInputStream یک جریان خواندن از محتوا میده. از اینجا می‌تونیم تصویر رو Decode کنیم یا محتوای اون رو داخل فایل موقت کپی کنیم.

نباید فرض کنیم هر Uri یک File مستقیمه. بنابراین کدی مثل این در همه شرایط قابل اعتماد نیست:

File(uri.path!!)

uri.path ممکنه فقط بخشی از شناسه داخلی Content Provider باشه، نه مسیر واقعی روی حافظه.

Resize باید قبل از نگه داشتن Bitmap عظیم انجام بشه

اگه یک تصویر ۸۰۰۰ در ۸۰۰۰ رو با اندازه کامل داخل حافظه Decode کنیم، حتی اگه فایل فشرده فقط چند مگابایت باشه، Bitmap بازشده می‌تونه حافظه بسیار بیشتری مصرف کنه.

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

val resizedBitmap = imageProcessor.decodeAndResize(
    uri = croppedUri,
    maxWidth = 1080,
    maxHeight = 1080
)

decodeAndResize یک تابع فرضی از کلاس ImageProcessor است. وظیفه اون اینه که بدون نگه داشتن بی‌دلیل نسخه غول‌پیکر، Bitmap متناسب با نیاز Upload بسازه.

این پردازش نباید روی Thread اصلی انجام بشه، چون Decode و Compress ممکنه زمان‌بر باشن. مستندات Android هم اشاره می‌کنن که Bitmap.compress() ممکنه چند ثانیه طول بکشه و باید خارج از Thread اصلی اجرا بشه.

فشرده‌سازی تصویر

بعد از Resize، تصویر رو داخل یک فایل موقت فشرده ذخیره می‌کنیم:

outputFile.outputStream().use { stream ->
    resizedBitmap.compress(
        Bitmap.CompressFormat.JPEG,
        82,
        stream
    )
}

Bitmap.CompressFormat.JPEG فرمت خروجی رو مشخص می‌کنه. عدد 82 کیفیت تقریبی JPEG است. در JPEG، مقدار کمتر معمولاً فایل کوچک‌تری می‌سازه و مقدار نزدیک ۱۰۰ کیفیت بیشتری حفظ می‌کنه. مقدار کیفیت باید با آزمایش واقعی روی عکس‌های پروژه انتخاب بشه، نه براساس یک عدد جادویی ثابت.

استفاده از use باعث میشه OutputStream بعد از پایان کار، حتی در صورت رخ دادن خطا، بسته بشه.

برای عکس‌های معمولی فروشگاه، JPEG می‌تونه مناسب باشه. اما اگه لوگو پس‌زمینه شفاف داشته باشه، JPEG شفافیت رو نگه نمی‌داره. در چنین حالتی PNG یا WebP مناسب‌تره. Android برای WebP حالت‌های Lossy و Lossless جداگانه داره و حالت قدیمی عمومی WEBP از API 30 به بعد به نفع نسخه‌های روشن‌تر کنار گذاشته شده است.

پس انتخاب فرمت باید به نوع تصویر وابسته باشه:

عکس واقعی فروشگاه → JPEG یا WebP Lossy

لوگوی دارای شفافیت → PNG یا WebP Lossless

کیفیت و ابعاد رو Backend هم باید کنترل کنه

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

Backend باید دوباره بررسی کنه:

فایل واقعاً تصویر باشه
فرمت مجاز باشه
حجم از حد مشخص بیشتر نباشه
ابعاد غیرعادی نباشن
فایل خراب یا مخرب نباشه

محدود کردن فایل در اندروید برای سرعت و تجربه کاربریه. کنترل نهایی سمت سرور برای امنیت و حفظ سلامت سیستم انجام میشه.

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

اپلیکیشن قبل از ارسال بررسی می‌کنه؛ سرور قبل از اعتماد دوباره بررسی می‌کنه.

State فرایند عکس

آپلود عکس فقط Loading و Success نیست. کاربر ممکنه هنوز عکسی انتخاب نکرده باشه، در حال Crop باشه، فایل در حال پردازش باشه یا Upload در حال اجرا باشه.

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

data class BusinessImageUiState(
    val previewUri: Uri? = null,
    val isProcessing: Boolean = false,
    val uploadProgress: Int? = null,
    val isUploaded: Boolean = false,
    val errorMessage: String? = null
)

previewUri تصویریه که باید قبل از Upload به کاربر نمایش داده بشه. isProcessing برای Crop، Resize یا Compress استفاده میشه. uploadProgress درصد پیشرفت Upload رو نگه می‌داره. isUploaded یعنی نسخه جدید روی سرور ذخیره شده و errorMessage هم خطای فعلی رو نمایش میده.

اینجا یک data class از چند State جدا مناسب‌تره، چون ممکنه هم‌زمان پیش‌نمایش تصویر وجود داشته باشه و Upload هم در حال انجام باشه.

مثلاً:

previewUri ≠ null
isProcessing = false
uploadProgress = 45

یعنی تصویر آماده و قابل نمایش است و در حال حاضر ۴۵ درصد فایل آپلود شده.

پردازش عکس در ViewModel یا Repository؟

ViewModel نباید خودش با Bitmap، InputStream و فایل موقت کار کنه. بهتره یک کلاس مخصوص پردازش تصویر داشته باشیم:

interface ImageProcessor {
    suspend fun prepareForUpload(uri: Uri): File
}

این Interface میگه هر پردازشگری که پیاده‌سازی بشه، یک Uri می‌گیره و در نهایت یک File آماده Upload برمی‌گردونه.

جزئیات داخل پیاده‌سازی پنهان میشن:

خواندن Uri
اصلاح جهت تصویر
Resize
Compress
ساخت فایل موقت

ViewModel فقط این کار رو انجام میده:

val uploadFile =
    imageProcessor.prepareForUpload(croppedUri)

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

چرا جهت عکس ممکنه اشتباه باشه؟

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

بنابراین ImageProcessor باید جهت تصویر رو هم بررسی کنه:

val correctedBitmap =
    imageOrientation.correct(bitmap, uri)

این کد مفهومی است و میگه قبل از Crop نهایی یا Compress باید جهت واقعی تصویر اعمال بشه.

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

ساخت درخواست Upload

برخلاف فرم متنی، فایل تصویر معمولاً داخل JSON معمولی ارسال نمیشه. برای ارسال فایل همراه درخواست HTTP از multipart/form-data استفاده می‌کنیم.

در Retrofit بخش متمایز Endpoint به این شکله:

@Multipart
@POST("business/profile/image")
suspend fun uploadBusinessImage(
    @Part image: MultipartBody.Part
): UploadImageResponse

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

@Part image فایل تصویر رو داخل یکی از همین بخش‌ها قرار میده.

Multipart برای ارسال فایل و فیلدهای همراه اون طراحی شده و OkHttp ساختار MultipartBody و MultipartBody.Part رو برای همین نوع درخواست فراهم می‌کنه. (Square Open Source)

تبدیل File به Multipart

فایل فشرده‌شده باید به RequestBody تبدیل بشه:

val requestBody = file.asRequestBody(
    "image/jpeg".toMediaType()
)

image/jpeg نوع محتوای فایل رو مشخص می‌کنه. سرور با استفاده از MIME Type متوجه میشه این بخش از درخواست چه نوع داده‌ایه.

بعد اون رو داخل Multipart قرار میدیم:

val imagePart = MultipartBody.Part.createFormData(
    name = "image",
    filename = file.name,
    body = requestBody
)

name = "image" باید با نامی که Backend انتظار داره هماهنگ باشه.

filename نام فایل ارسالیه و body محتوای واقعی اون رو نگه می‌داره.

حالا Repository می‌تونه فایل رو ارسال کنه:

val response = api.uploadBusinessImage(imagePart)

پاسخ Upload نباید فقط موفق بود باشه

بعد از Upload، سرور بهتره اطلاعات نسخه ذخیره‌شده رو برگردونه:

data class UploadImageResponse(
    val imageUrl: String,
    val updatedAt: String
)

imageUrl آدرسیه که از این به بعد برای نمایش عکس استفاده میشه. updatedAt هم می‌تونه برای تشخیص نسخه جدید یا مدیریت Cache کاربرد داشته باشه.

Repository بعد از دریافت پاسخ، اطلاعات کسب‌وکار داخل Room رو به‌روزرسانی می‌کنه:

businessDao.updateImageUrl(
    imageUrl = response.imageUrl
)

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

مسیر نهایی دوباره به الگوی Offline First برمی‌گرده:

Upload API
     ↓
Image URL
     ↓
Room
     ↓
Flow
     ↓
Dashboard و Profile

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

نمایش عکس قبل از Upload

بهتره کاربر قبل از ارسال، نتیجه Crop رو ببینه:

AsyncImage(
    model = uiState.previewUri,
    contentDescription = "تصویر کسب‌وکار"
)

این پیش‌نمایش از Uri محلی میاد، نه از سرور. بنابراین بلافاصله بعد از Crop قابل نمایش است.

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

اما باید تفاوت پیش‌نمایش و عکس ذخیره‌شده رو روشن نگه داریم:

Preview:
فایل محلی و هنوز ذخیره‌نشده

Uploaded Image:
نسخه تأییدشده روی سرور

اگه Upload شکست بخوره، نباید برنامه طوری رفتار کنه که انگار عکس واقعاً تغییر کرده.

آپلود خودکار یا دکمه جدا؟

بعد از Crop دو انتخاب داریم.

در روش اول، Upload فوراً شروع میشه:

Select → Crop → Compress → Upload

در روش دوم، ابتدا پیش‌نمایش نمایش داده میشه و کاربر روی ذخیره عکس می‌زنه:

Select → Crop → Preview → Save → Upload

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

بخش متمایز دکمه ذخیره ساده است:

Button(
    enabled = uiState.previewUri != null &&
        !uiState.isProcessing,
    onClick = viewModel::uploadImage
)

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

عملیات کامل داخل ViewModel

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

fun prepareImage(uri: Uri) {
    viewModelScope.launch {
        _uiState.update {
            it.copy(
                isProcessing = true,
                errorMessage = null
            )
        }

        try {
            val file = imageProcessor.prepareForUpload(uri)
            preparedFile = file

            _uiState.update {
                it.copy(
                    previewUri = file.toUri(),
                    isProcessing = false
                )
            }
        } catch (error: Exception) {
            showImageError(error)
        }
    }
}

در آغاز، isProcessing روی true قرار می‌گیره تا صفحه Loading مناسب نمایش بده.

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

اگه پردازش موفق باشه، Uri فایل آماده داخل State قرار می‌گیره تا Preview نمایش داده بشه.

اگه خطایی رخ بده، نباید برنامه Crash کنه. ممکنه فایل انتخاب‌شده خراب، فرمتش نامعتبر یا حافظه گوشی ناکافی باشه.

Upload در Repository

Repository فایل آماده رو دریافت می‌کنه:

suspend fun uploadBusinessImage(file: File) {
    val response = api.uploadBusinessImage(
        file.toMultipartPart("image")
    )

    businessDao.updateImageUrl(response.imageUrl)
}

تابع toMultipartPart جزئیات ساخت RequestBody رو پنهان می‌کنه.

Repository دو مسئولیت مرتبط با داده رو هماهنگ می‌کنه: فایل رو به API می‌فرسته و بعد نتیجه تأییدشده رو داخل Room ذخیره می‌کنه.

در ViewModel فقط نتیجه مهمه:

viewModelScope.launch {
    try {
        repository.uploadBusinessImage(preparedFile)
        onUploadSuccess()
    } catch (error: Exception) {
        onUploadError(error)
    }
}

نمایش درصد Upload

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

در حال آپلود: ۴۷٪

برای این کار، RequestBody سفارشی می‌تونه تعداد بایت‌های ارسال‌شده رو گزارش کنه:

repository.uploadBusinessImage(file) { progress ->
    _uiState.update {
        it.copy(uploadProgress = progress)
    }
}

این کد میگه Repository هنگام ارسال فایل، درصد پیشرفت رو به ViewModel اعلام کنه.

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

جلوگیری از چند Upload هم‌زمان

هنگام Upload، دکمه ذخیره باید غیرفعال بشه:

enabled = uiState.uploadProgress == null

یا در State یک مقدار روشن‌تر داشته باشیم:

val isUploading: Boolean

بعد:

enabled = !uiState.isUploading

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

خود ViewModel هم بهتره محافظ داخلی داشته باشه:

if (_uiState.value.isUploading) return

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

خطاهای احتمالی این فرایند

آپلود عکس می‌تونه در مراحل مختلف شکست بخوره:

دوربین باز نشد
مجوز دوربین رد شد
کاربر انتخاب عکس رو لغو کرد
فایل قابل خواندن نبود
فرمت پشتیبانی نشد
Crop شکست خورد
حافظه کافی نبود
فشرده‌سازی انجام نشد
اینترنت قطع شد
سرور فایل رو رد کرد
Upload نیمه‌کاره موند

این خطاها نباید همگی با پیام خطایی رخ داد نمایش داده بشن.

مثلاً:

امکان خواندن این تصویر وجود نداره.
یک عکس دیگه انتخاب کن.

یا:

آپلود انجام نشد.
اتصال اینترنت رو بررسی کن و دوباره تلاش کن.

اگه Upload شکست بخوره، فایل آماده و Preview باید حفظ بشن تا کاربر فقط دوباره روی تلاش مجدد بزنه.

نباید کاربر رو مجبور کنیم دوباره وارد گالری بشه، Crop رو تکرار کنه و از اول همه مراحل رو انجام بده.

Retry فقط مرحله شکست‌خورده رو تکرار می‌کنه

فرض کن انتخاب، Crop و Compress موفق بودن اما Upload به دلیل قطع اینترنت شکست خورده. در این حالت Retry نباید کل فرایند رو از ابتدا اجرا کنه.

مسیر درست اینه:

preparedFile موجود است
        ↓
Retry
        ↓
فقط Upload دوباره اجرا میشه

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

private var preparedFile: File? = null

تابع Retry هم فقط همون فایل رو می‌فرسته:

fun retryUpload() {
    preparedFile?.let(::uploadFile)
}

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

فایل‌های موقت رو فراموش نکن

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

فایل دوربین
فایل خروجی Crop
فایل Resize و Compress

اگه این فایل‌ها هیچ‌وقت پاک نشن، Cache برنامه به مرور بزرگ میشه.

بعد از Upload موفق می‌تونیم فایل‌های موقت رو حذف کنیم:

preparedFile?.delete()
preparedFile = null

اما باید مراقب باشیم قبل از پایان Upload یا وقتی هنوز Preview به فایل وابسته است، فایل رو پاک نکنیم.

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

preparedFile?.delete()
preparedFile = null

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

حذف عکس فعلی

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

Endpoint می‌تونه چنین باشه:

@DELETE("business/profile/image")
suspend fun deleteBusinessImage()

بعد Repository مقدار محلی رو هم پاک می‌کنه:

api.deleteBusinessImage()
businessDao.updateImageUrl(null)

ترتیب اینجا مهمه. بهتره ابتدا سرور حذف رو تأیید کنه و بعد Room تغییر کنه. اگه حذف فقط در Room انجام بشه، عکس با Refresh بعدی دوباره برمی‌گرده.

مشکل Cache تصویر بعد از Upload

گاهی Upload موفق میشه، اما تصویر قبلی همچنان در UI دیده میشه. دلیلش ممکنه Cache کتابخانه نمایش عکس باشه.

فرض کن URL همیشه اینه:

https://api.example.com/business/image/15.jpg

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

یکی از راه‌ها اینه که Backend برای هر نسخه URL تازه‌ای برگردونه:

/business/image/15_1723456.jpg

یا نسخه رو به Query اضافه کنیم:

/business/image/15.jpg?v=1723456

updatedAt پاسخ Upload می‌تونه برای همین کار استفاده بشه. این مسئله مربوط به خود Upload نیست، اما بدون حل اون ممکنه کاربر تصور کنه تغییر عکس انجام نشده.

آیا Upload باید آفلاین صف‌بندی بشه؟

از نظر فنی میشه فایل رو محلی نگه داشت و با WorkManager بعد از اتصال اینترنت آپلود کرد. این روش برای سیستم‌هایی که عکس‌های زیادی ثبت می‌کنن مفیده.

اما برای تغییر عکس پروفایل کسب‌وکار، نسخه ابتدایی می‌تونه رفتار ساده‌تری داشته باشه:

اینترنت نیست
      ↓
عکس آماده و Preview حفظ میشه
      ↓
کاربر بعداً Retry می‌کنه

صف‌بندی آفلاین فایل پیچیدگی‌هایی مثل نگهداری طولانی فایل، لغو Upload، تکرار درخواست و تشخیص نسخه جدیدتر ایجاد می‌کنه.

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

مسئولیت هر بخش

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

Compose:
باز کردن Camera و Gallery
نمایش Preview و Loading
ارسال رویدادهای کاربر

ViewModel:
نگهداری State
هماهنگ کردن مراحل
مدیریت موفقیت، خطا و Retry

ImageProcessor:
خواندن Uri
اصلاح جهت
Resize
Compress
ساخت فایل نهایی

Repository:
ساخت درخواست Multipart
Upload به API
به‌روزرسانی Room

Backend:
اعتبارسنجی فایل
ذخیره تصویر
ساخت URL نهایی

اگه Compose خودش Bitmap رو فشرده کنه، Multipart بسازه و Room رو تغییر بده، خیلی سریع به صفحه‌ای شلوغ و غیرقابل نگهداری می‌رسیم.

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

آپلود عکس فقط ارسال فایل نیست

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

Photo Picker یک Uri محدود و امن از گالری میده. دوربین تصویر رو داخل Uriای که برنامه از قبل ساخته ذخیره می‌کنه. Crop محدوده مناسب رو از تصویر جدا می‌کنه. ImageProcessor ابعاد و حجم فایل رو کاهش میده. Repository فایل رو به درخواست Multipart تبدیل می‌کنه. سرور نسخه نهایی رو ذخیره می‌کنه و URL جدید رو برمی‌گردونه. Room این URL رو نگه می‌داره و تمام صفحه‌های برنامه به‌صورت خودکار تصویر جدید رو دریافت می‌کنن.

مهم‌ترین نکته این درس اینه که عکس یک داده واحد نیست؛ یک فرایند چندمرحله‌ایه. باید بدونیم در هر مرحله چه نسخه‌ای از تصویر داریم:

عکس اولیه
عکس Crop‌شده
فایل فشرده‌شده
نسخه آپلودشده
آدرس ذخیره‌شده در Room

هر کدوم هدف متفاوتی دارن و نباید با هم اشتباه گرفته بشن.

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

برنامه عکس رو از دوربین یا گالری به شکل Uri دریافت می‌کنه، بدون تغییر فایل اصلی اون رو Crop و فشرده می‌کنه، نسخه آماده رو با Multipart برای سرور می‌فرسته و بعد از دریافت تأیید، آدرس عکس جدید رو داخل Room ذخیره می‌کنه تا تمام بخش‌های برنامه به‌صورت خودکار به‌روز بشن.


درس بیست‌وششم

برنامه چطور موقعیت واقعی کسب‌وکار رو از GPS می‌گیره یا روی نقشه از کاربر دریافت می‌کنه؟

انتخاب موقعیت روی نقشه

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

این فروشگاه دقیقاً کجاست؟

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

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

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

Permission
      ↓
Location Service
      ↓
GPS / Network
      ↓
Latitude + Longitude
      ↓
نمایش روی Map
      ↓
جابجایی Marker
      ↓
ذخیره مختصات
      ↓
API
      ↓
Room

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


Location با GPS فرق داره

یکی از رایج‌ترین اشتباه‌ها اینه که Location و GPS رو یکی بدونیم.

GPS فقط یکی از روش‌های پیدا کردن موقعیته.

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

GPS

دکل‌های مخابراتی

Wi-Fi

Bluetooth

ترکیب همه این‌ها

بنابراین وقتی داخل اندروید می‌نویسیم:

Location

منظور موقعیت جغرافیایی است، نه الزاماً GPS.

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


Location یعنی چه چیزی؟

وقتی برنامه موقعیت رو دریافت می‌کنه، فقط یک نقطه روی نقشه نیست.

کلاس Location اطلاعات بیشتری داره.

مهم‌ترین بخش‌های اون عبارتند از:

Latitude

Longitude

Accuracy

Altitude

Speed

Bearing

Time

اما برای قیمت‌بان تقریباً فقط به دو مقدار نیاز داریم:

Latitude

Longitude

مثلاً:

Latitude:
35.6892

Longitude:
51.3890

این دو عدد محل دقیق فروشگاه روی کره زمین رو مشخص می‌کنن.


چرا آدرس کافی نیست؟

فرض کن کاربر نوشته:

تهران
خیابان آزادی
پلاک ۲۴۵

این آدرس برای انسان قابل فهمه.

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

اما اگر مختصات داشته باشیم:

35.700012

51.347814

برنامه می‌تونه:

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

بنابراین Location زبان مشترک همه سیستم‌های نقشه است.


Permission

برنامه اجازه نداره بدون اطلاع کاربر موقعیت اون رو دریافت کنه.

قبل از هر چیز باید Permission گرفته بشه.

در Manifest فقط بخش متمایز اینه:

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

اگر دقت خیلی بالا لازم نباشه:

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

Fine یا Coarse؟

فرق این دو خیلی مهمه.

ACCESS_FINE_LOCATION

≈ GPS
≈ چند متر دقت

اما

ACCESS_COARSE_LOCATION

≈ تقریبی

مثلاً چند صد متر

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

چون محل فروشگاه باید دقیق ثبت بشه.


درخواست Permission

در Compose مثل Camera از Activity Result API استفاده می‌کنیم.

بخش متمایز:

val permissionLauncher =
    rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->

        if (granted) {
            viewModel.loadCurrentLocation()
        }

    }

این Launcher فقط یک سؤال از کاربر می‌پرسه:

اجازه میدی موقعیتت رو بخونم؟

اگر قبول کنه:

Location شروع میشه.

اگر رد کنه:

کاربر باید خودش نقطه رو روی نقشه انتخاب کنه.

اگر کاربر Permission نداد چه؟

برنامه نباید از کار بیفته.

همیشه باید مسیر جایگزین وجود داشته باشه.

مثلاً:

Permission رد شد

↓

نمایش نقشه

↓

کاربر خودش Marker رو جابه‌جا می‌کنه.

این یکی از اصول مهم طراحی حرفه‌ایه.

هیچ Permission نباید شرط استفاده از کل برنامه باشه.


دریافت موقعیت فعلی

امروزه تقریباً تمام برنامه‌ها از Fused Location Provider استفاده می‌کنن.

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

مثلاً:

  • اگر GPS روشن باشه از GPS.
  • اگر داخل ساختمان باشیم از Wi-Fi.
  • اگر GPS ضعیف باشه از شبکه موبایل.

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


گرفتن آخرین موقعیت

بخش متمایز:

locationClient
    .lastLocation

این کد نمیگه:

GPS روشن کن.

بلکه میگه:

آخرین موقعیتی که سیستم می‌شناسه رو بده.

اگر وجود داشته باشه، خیلی سریع برمی‌گرده.


اگر آخرین موقعیت وجود نداشت؟

ممکنه گوشی تازه روشن شده باشه.

یا GPS هنوز هیچ موقعیتی پیدا نکرده باشه.

در این حالت باید موقعیت جدید درخواست کنیم.

مفهومی:

locationClient
    .getCurrentLocation(...)

این بار سیستم منتظر می‌مونه تا موقعیت واقعی پیدا بشه.


دریافت Location داخل ViewModel

ViewModel نباید خودش LocationManager بسازه.

بهتره Repository این کار رو انجام بده.

ViewModel

↓

LocationRepository

↓

Fused Location Provider

↓

Location

مثل همیشه ViewModel فقط نتیجه رو می‌گیره.


مدل موقعیت

بهتره مدل مستقلی داشته باشیم.

data class GeoPoint(

    val latitude: Double,

    val longitude: Double

)

چرا Location خود اندروید رو همه‌جا استفاده نکنیم؟

چون UI فقط مختصات لازم داره.

وابسته شدن کل برنامه به کلاس Location اندروید باعث میشه تست کردن و نگهداری سخت‌تر بشه.


نمایش نقشه

بعد از اینکه مختصات رو داریم، باید روی نقشه نمایش داده بشه.

Google Map

↓

Marker

↓

Latitude

Longitude

صفحه فقط مختصات رو به Map میده.

خود Map مسئول رسم Marker است.


State صفحه

data class MapUiState(

    val location: GeoPoint? = null,

    val isLoading: Boolean = false,

    val hasPermission: Boolean = false

)

این State تقریباً همه وضعیت‌های صفحه رو پوشش میده.


اگر Location وجود داشت

Map

↓

Move Camera

↓

Zoom

↓

Show Marker

کاربر مستقیم محل فروشگاه رو می‌بینه.


اگر Location وجود نداشت

مثلاً Permission رد شده.

یا GPS خاموشه.

در این حالت:

Map

↓

Default Location

↓

Marker قابل جابه‌جایی

مثلاً تهران رو نمایش می‌دیم تا کاربر خودش محل رو انتخاب کنه.


Marker

Marker همون سنجاق روی نقشه است.

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

Marker

↓

Latitude

Longitude

↓

State

بخش متمایز:

viewModel.onMarkerMoved(
    latitude,
    longitude
)

داخل ViewModel:

_form.update {

    it.copy(

        location = GeoPoint(

            latitude,

            longitude

        )

    )

}

دقیقاً مثل TextField.

فقط این بار مقدار Text تغییر نکرده.

مختصات تغییر کرده.


چرا مختصات داخل فرم ذخیره میشه؟

چون ممکنه کاربر:

  • چند بار Marker رو جابه‌جا کنه.
  • بعد اسم فروشگاه رو تغییر بده.
  • دوباره Marker رو جابه‌جا کنه.
  • بعد ذخیره رو بزنه.

مختصات هم بخشی از فرم ویرایشه.

نه بخشی از خود Map.


ذخیره در سرور

مدل درخواست:

data class UpdateLocationRequest(

    val latitude: Double,

    val longitude: Double

)

Repository:

api.updateLocation(
    request
)

بعد از پاسخ موفق:

businessDao.updateLocation(
    latitude,
    longitude
)

مثل همیشه:

API

↓

Room

↓

Flow

↓

UI

نمایش آدرس

گاهی سرور فقط مختصات رو ذخیره می‌کنه.

اما کاربر دوست داره آدرس متنی هم ببینه.

برای این کار از Reverse Geocoding استفاده میشه.

Latitude

Longitude

↓

Address

مثلاً:

35.7001

51.3432

↓

تهران
خیابان آزادی

نکته مهم اینه که این آدرس فقط برای نمایش به کاربره.

اطلاعات اصلی همون مختصاته.


اگر GPS خاموش بود

برنامه نباید Crash کنه.

پیام مناسب:

برای دریافت موقعیت فعلی،
GPS رو روشن کن.

یا

محل رو روی نقشه انتخاب کن.

باز هم مسیر جایگزین وجود داره.


دقت Location

گاهی Location دقت کمی داره.

مثلاً:

Accuracy

=

350 متر

این یعنی گوشی مطمئن نیست.

اما اگر:

Accuracy

=

5 متر

موقعیت تقریباً دقیق محسوب میشه.

اگر دقت خیلی پایین بود، بهتره قبل از ذخیره از کاربر بخوایم Marker رو بررسی کنه.


آیا همیشه باید GPS بگیریم؟

نه.

فرض کن کاربر می‌خواد شعبه جدیدی در شهر دیگری ثبت کنه.

اگر فقط GPS داشته باشیم:

لوکیشن خود کاربر ثبت میشه.

نه فروشگاه.

در این حالت:

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

بنابراین بهترین تجربه کاربری اینه:

ابتدا GPS

نمایش Marker

امکان جابه‌جایی دستی

این دقیقاً کاریه که بیشتر برنامه‌های حرفه‌ای انجام میدن.


مسئولیت هر بخش

Compose

نمایش نقشه

نمایش Marker

درخواست Permission



ViewModel

مدیریت State

مختصات انتخاب‌شده



LocationRepository
دریافت موقعیت

API
ذخیره Location

Room
نگهداری مختصات

مثل تمام درس‌های قبلی، هیچ بخشی نباید مسئولیت بخش دیگه رو انجام بده.


انتخاب موقعیت فقط نمایش نقشه نیست

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

مهم‌ترین نکته این درس اینه که نقشه فقط یک رابط کاربریه؛ چیزی که واقعاً ارزش ذخیره شدن داره، مختصات جغرافیاییه. نقشه، GPS و آدرس فقط ابزارهایی هستن که به کاربر کمک می‌کنن آن مختصات دقیق رو انتخاب کنه.

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


درس بیست‌وهفتم

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

مشاهده دسته‌بندی‌ها

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

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

نوشیدنی‌ها

لبنیات

مواد شوینده

تنقلات

مواد پروتئینی

...

در نگاه اول، شاید دسته‌بندی فقط یک لیست ساده از اسم‌ها به نظر برسه. اما در واقع، این لیست یکی از مهم‌ترین داده‌های مرجع (Reference Data) برنامه است؛ داده‌ای که تقریباً همه بخش‌های برنامه به اون وابسته هستن.

مسیر کلی دریافت دسته‌بندی‌ها به این شکله:

Server

Category API

Repository

Room

Flow

Category ViewModel

Compose List

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


دسته‌بندی‌ها چرا داخل برنامه ثابت نیستن؟

شاید اولین ایده این باشه که بنویسیم:

val categories = listOf(

    "نوشیدنی",

    "لبنیات",

    "شوینده"

)

برای یک پروژه کوچک شاید جواب بده.

اما فرض کن فردا مدیر سیستم تصمیم بگیره:

  • دسته جدید اضافه کنه.
  • نام یک دسته رو تغییر بده.
  • ترتیب نمایش رو عوض کنه.
  • بعضی دسته‌ها رو حذف کنه.

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


مدل دسته‌بندی

هر دسته فقط یک اسم نیست. مثلاً:

data class Category(

    val id: Long,

    val name: String,

    val icon: String?,

    val parentId: Long?

)

هر فیلد دلیل خودش رو داره.

id
شناسه اصلی دسته است.

name
متنی که کاربر می‌بینه.

icon
ممکنه آدرس آیکون یا نام آن باشه.

parentId
برای دسته‌های تو در تو استفاده میشه.

مثلاً:

مواد غذایی

نوشیدنی

نوشابه


چرا id مهم‌تر از name است؟

فرض کن امروز سرور این اطلاعات رو داره:

id = 8

name = نوشیدنی

فردا مدیر سیستم تصمیم می‌گیره اسمش رو تغییر بده:

id = 8

name = نوشیدنی و آبمیوه

اگر کالاها بر اساس Name ذخیره شده باشن، همه ارتباط‌ها خراب میشه.

اما اگر فقط id ذخیره بشه:

CategoryId = 8

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

بنابراین:

همیشه ارتباط بین داده‌ها با شناسه انجام میشه، نه متن قابل نمایش.


دریافت دسته‌بندی‌ها

Endpoint معمولاً چیزی شبیه اینه:

@GET("categories")
suspend fun getCategories(): List<CategoryDto>

پاسخ سرور:

[
  {
    "id":1,
    "name":"نوشیدنی"
  },
  {
    "id":2,
    "name":"لبنیات"
  }
]

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

response.map {

    it.toEntity()

}

و داخل Room ذخیره می‌کنه:

categoryDao.upsertAll(
    entities
)

مثل همیشه:

API

Room

Flow

UI


چرا Room؟

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


مشاهده دسته‌بندی‌ها

Repository:

fun observeCategories():

Flow<List<Category>>

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

اگر مدیر سیستم دسته جدید اضافه کنه و برنامه Sync انجام بده:
Room تغییر می‌کنه. Flow تغییر می‌کنه. UI تغییر می‌کنه. بدون حتی یک Refresh دستی.


ViewModel

مثل همیشه:

val categories =

repository
    .observeCategories()
    .stateIn(...)

صفحه فقط همین State رو مشاهده می‌کنه.


State صفحه

data class CategoriesUiState(

    val categories:
        List<Category> = emptyList(),

    val isLoading:Boolean=false,

    val error:String?=null

)

دسته‌بندی‌ها فقط یکی از داده‌های صفحه هستن.

Loading و Error هم باید کنار اون وجود داشته باشن.


نمایش لیست

در Compose:

LazyColumn {

    items(

        state.categories

    ) { category ->

        CategoryItem(category)

    }

}

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

اگر هزار دسته وجود داشته باشه، قرار نیست هزار Composable ساخته بشه.

همین دلیل استفاده از LazyColumn است.


چرا LazyColumn؟

فرض کن:
۲۰ دسته

فرقی نداره.

اما:
۲۰۰۰ دسته

اگر از Column معمولی استفاده کنیم:

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


انتخاب دسته

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

viewModel.onCategorySelected(
    category.id
)

دقت کن. id ارسال میشه. نه Name. چون شناسه پایدار است.


داخل ViewModel:

_form.update {

    it.copy(

        categoryId = id

    )

}

دقیقاً مثل TextField. فقط این بار مقدار انتخاب‌شده یک شناسه است.


چرا فقط شناسه ذخیره میشه؟

فرض کن کالا:

کوکاکولا

اگر بنویسیم:

Category:

نوشیدنی

بعداً اگر اسم دسته تغییر کنه: تمام کالاها باید ویرایش بشن. اما اگر:

categoryId = 8

فقط جدول Category تغییر می‌کنه. تمام کالاها خودکار اسم جدید رو نمایش میدن.


جستجو داخل دسته‌ها

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

fun search(

    query:String

)

Repository:

categoryDao.search(query)

به جای اینکه همه دسته‌ها داخل Compose فیلتر بشن.


چرا؟

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


مرتب‌سازی

ممکنه سرور ترتیب نمایش رو مشخص کنه.

مثلاً:

مواد غذایی نوشیدنی لبنیات …

در این حالت بهتره ترتیب رو هم از سرور بگیریم.

مثلاً:

val sortOrder:Int

بعد:

ORDER BY sortOrder

به جای مرتب کردن دستی داخل UI.


دسته‌های تو در تو

گاهی دسته‌بندی فقط یک سطح نیست.

مثلاً:

مواد غذایی

نوشیدنی

آبمیوه

اینجا:

parentId

همین مسئله رو حل می‌کنه.

اگر:

parentId = null

یعنی دسته اصلی.

اگر:

parentId = 5

یعنی زیرمجموعه دسته شماره ۵.


گرفتن زیرمجموعه‌ها

Repository:

observeChildren(
    parentId
)

داخل DAO:

WHERE parentId = :parentId

به جای اینکه کل درخت داخل Compose پردازش بشه.


Refresh

مثل Dashboard:

refreshCategories()

Repository:

API

Room

نه:

API

Compose

منبع اصلی همیشه Room باقی می‌مونه.


اگر اینترنت نبود

چون دسته‌ها داخل Room هستن:

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


اگر هیچ دسته‌ای وجود نداشت

دو حالت وجود داره. اول:

Loading

هنوز اطلاعات نرسیده. دوم:

Empty

اطلاعات دریافت شده. اما واقعاً هیچ دسته‌ای وجود نداره. پیام مناسب:

هنوز هیچ دسته‌بندی‌ای تعریف نشده.
این با Error فرق داره.


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

گاهی افراد داخل فرم می‌نویسن:

selectedCategoryName

و بعد همون رو داخل دیتابیس ذخیره می‌کنن.

این طراحی اشتباهه.

بهتره:

selectedCategoryId

ذخیره بشه.

و هر زمان لازم بود:

Category Table

Name

نمایش داده بشه.


مسئولیت هر بخش

Compose
نمایش لیست
کلیک روی آیتم

ViewModel
مدیریت انتخاب

Repository
Sync

API

Room

مثل همیشه UI نباید چیزی درباره Retrofit یا SQL بدونه.


دسته‌بندی‌ها فقط یک لیست ساده نیستن

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

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

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

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


درس بیست‌وهشتم

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

مشاهده محصولات

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

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

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

Server

Products API

Repository

Room

Flow

ProductsViewModel

Compose List

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


چرا صفحه محصولات با دسته‌بندی فرق داره؟

در درس قبل شاید فقط چند ده دسته‌بندی داشتیم.

مثلاً:

۴۰ دسته

اما محصولات ممکنه این تعداد باشن:

۳۰۰ کالا
۵۰۰۰ کالا
۵۰۰۰۰ کالا

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


مدل محصول

یک محصول فقط اسم نیست.

مثلاً:

data class Product(

    val id: Long,

    val name: String,

    val barcode: String?,

    val categoryId: Long,

    val imageUrl: String?,

    val lastPrice: Long?

)

هر کدوم از این فیلدها دلیل خودشون رو دارن.

id
شناسه محصول.

name
نام کالا.

barcode
بارکد.

categoryId
ارتباط با جدول دسته‌بندی.

imageUrl
تصویر کالا.

lastPrice
آخرین قیمت ثبت‌شده.


چرا قیمت داخل Product است؟

ممکنه بعداً قیمت‌ها تاریخچه داشته باشن. مثلاً:

امروز
دیروز
هفته قبل

در اون حالت شاید آخرین قیمت فقط برای نمایش سریع داخل Product نگهداری بشه و تاریخچه کامل داخل جدول دیگری ذخیره بشه. این یکی از مثال‌های خوب طراحی دیتابیسه.


دریافت محصولات

Endpoint:

@GET("products")
suspend fun getProducts(): List<ProductDto>

پاسخ:

[
 {
   "id":1,
   "name":"کوکاکولا",
   "categoryId":8
 }
]

Repository:

val entities =
    response.map {

        it.toEntity()

    }

productDao.upsertAll(
    entities
)

مثل همیشه:

API

Room

Flow

UI


چرا مستقیم از API نخونیم؟

فرض کن صفحه محصولات هر بار باز میشه. اگر مستقیم API بخونه:

باز شدن صفحه

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

انتظار

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

باز شدن صفحه

Room

نمایش فوری

بعد در پس‌زمینه Sync انجام میشه.


مشاهده محصولات

Repository:

fun observeProducts():

Flow<List<Product>>

ViewModel:

val products =

repository
    .observeProducts()
    .stateIn(...)

صفحه فقط State رو مشاهده می‌کنه.


State صفحه

data class ProductsUiState(

    val products:
        List<Product> = emptyList(),

    val isLoading:Boolean=false,

    val isRefreshing:Boolean=false,

    val error:String?=null

)

دقت کن. Loading و Refresh یکی نیستن.


Loading

اولین ورود

هیچ داده‌ای نداریم

اما Refresh:

لیست وجود داره

داده جدید در حال دریافت است

در Refresh نباید کل صفحه سفید بشه.


نمایش لیست

LazyColumn {

    items(

        state.products,

        key = { it.id }

    ) {

        ProductItem(it)

    }

}

نکته مهم:

key = { it.id }

این یکی از مهم‌ترین بخش‌های LazyColumn است.


چرا key؟

فرض کن:

A
B
C

بعد محصول جدید بالای لیست اضافه بشه.

بدون key:
Compose ممکنه فکر کنه همه آیتم‌ها تغییر کردن.

اما با id:
می‌فهمه فقط یک آیتم جدید اضافه شده.

این باعث اسکرول روان‌تر و Recomposition کمتر میشه.


ProductItem

هر ردیف فقط مسئول نمایش یک محصوله. مثلاً:

ProductItem(

    product,

    onClick = {

        ...

    }

)

داخل ProductItem نباید:

  • Retrofit باشه.
  • SQL باشه.
  • ViewModel ساخته بشه.

فقط UI.


تصویر محصول

تصویر معمولاً از URL نمایش داده میشه.

مثلاً:

https://...

Compose:

AsyncImage(

    model = product.imageUrl,

    contentDescription = product.name

)

AsyncImage خودش دانلود، Cache و نمایش تصویر رو مدیریت می‌کنه. ProductItem لازم نیست بدونه تصویر از اینترنت میاد.


اگر تصویر وجود نداشت

مثلاً:

imageUrl = null

در این حالت: Placeholder نمایش میدیم. نه اینکه Image خالی بمونه.


ارتباط محصول و دسته

داخل Product فقط:

categoryId

وجود داره. نه:

categoryName

چرا؟ چون دسته ممکنه تغییر کنه.


اگر بخوایم اسم دسته رو نمایش بدیم:

categoryId

Category Table

Category Name


آیا باید اسم دسته داخل Product ذخیره بشه؟

نه. این تکرار داده است. اگر:
لبنیات

بشه:
لبنیات و صبحانه

همه محصولات باید تغییر کنن. اما اگر فقط id داشته باشیم: فقط جدول Category تغییر می‌کنه.


مرتب‌سازی

کاربر معمولاً دوست داره:

الفبا آخرین ثبت پرفروش‌ترین …

ببینه.

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

نه داخل Compose.

مثلاً:

observeProductsOrderedByName()

یا

ORDER BY name

Refresh

مثل Dashboard.

refreshProducts()

Repository:

API

Room

نه:

API

Compose


اگر اینترنت قطع بود

چون Room منبع اصلیه: محصولات همچنان نمایش داده میشن.

تنها چیزی که نداریم: آخرین تغییرات سروره.


Empty State

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

بهتره:
هنوز محصولی ثبت نکردی.
اولین محصولت رو اضافه کن.

به همراه دکمه:
افزودن محصول


Error

اگر:

Room خالی

+

API هم شکست خورد

آنگاه:

خطا در دریافت اطلاعات

نمایش داده میشه.

این با Empty فرق داره.


چرا همه محصولات یکجا دانلود نشن؟

فرض کن:

۸۰ هزار کالا

اگر همه رو یکجا بگیریم:

  • دانلود طولانی
  • حافظه زیاد
  • شروع کند برنامه

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

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


اسکرول روان

فرض کن:

۲۰۰۰ محصول

اگر ProductItem سنگین باشه: اسکرول گیر می‌کنه.

بهتره ProductItem فقط اطلاعات ضروری رو نمایش بده:

  • تصویر
  • نام
  • قیمت

نه اینکه داخل هر آیتم:

  • درخواست اینترنت
  • Query دیتابیس
  • محاسبات سنگین

انجام بشه.


کلیک روی محصول

viewModel.onProductClick(
    product.id
)

باز هم:

id

نه کل Product.

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


حذف محصول از لیست

فرض کن یک محصول حذف شد. اگر:

key = { it.id }

وجود داشته باشه، Compose فقط همون آیتم رو حذف می‌کنه. نه اینکه کل لیست دوباره ساخته بشه.


مسئولیت هر بخش

Compose نمایش لیست کلیک روی محصول ↓ ViewModel مدیریت State ↓ Repository Sync ↓ API ↓ Room

همان معماری آشنای MVVM دوباره تکرار میشه.


تفاوت Refresh و Sync

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

پس هر Refresh معمولاً باعث شروع یک Sync میشه، اما Sync فقط به معنی دوباره دانلود کردن همه چیز نیست.


چرا محصولات مهم‌ترین صفحه برنامه هستن؟

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

  • جزئیات محصول
  • ویرایش محصول
  • ثبت قیمت
  • حذف محصول
  • جستجو
  • فیلتر

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

برنامه فهرست محصولات رو از سرور دریافت و داخل Room ذخیره می‌کنه، ViewModel آخرین نسخه محصولات رو به‌صورت Flow دریافت می‌کنه و Compose با استفاده از LazyColumn فقط آیتم‌های قابل مشاهده رو نمایش میده تا حتی با تعداد زیاد محصولات، صفحه سریع، روان و قابل نگهداری باقی بمونه.


درس بیست‌ونهم

برنامه چطور بین هزاران محصول در کمتر از چند لحظه جستجو می‌کنه، بدون اینکه هر بار به سرور درخواست جدید بفرسته؟

جستجو

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

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

در ظاهر فقط یک TextField بالای صفحه دیده میشه، اما پشت همین TextField چند سیستم مختلف با هم همکاری می‌کنن.

TextField

Search Query

ViewModel

Repository

Room Search

Flow

Products List

نکته مهم اینه که در بیشتر مواقع، جستجو نباید مستقیماً به اینترنت وابسته باشه.


چرا جستجو از سرور انجام نمیشه؟

اولین ایده معمولاً اینه:

کاربر تایپ می‌کند

API

نتیجه

مثلاً: کاربر تایپ می‌کنه:

ک

یک درخواست ارسال میشه. بعد:

کو

دوباره درخواست. بعد:

کوک

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


پس جستجو از کجا انجام میشه؟

چون محصولات داخل Room ذخیره شدن، بهترین جا برای جستجو خود دیتابیسه. مسیر به این شکل میشه:

TextField

Room Query

Flow

UI

یعنی تقریباً هیچ درخواست اینترنتی وجود نداره. همه چیز داخل گوشی انجام میشه.


Search Query

اولین چیزی که باید نگهداری کنیم، متن جستجوست.

مثلاً:

private val _query =
    MutableStateFlow("")

نسخه عمومی:

val query =
    _query.asStateFlow()

هر بار کاربر چیزی تایپ می‌کنه:

fun onQueryChanged(
    value:String
){
    _query.value = value
}

دقیقاً مثل TextFieldهای قبلی.


چرا Query داخل ViewModel است؟

ممکنه بگی: با rememberSaveable هم میشه. درسته. اما Query فقط یک TextField نیست. کل لیست محصولات به اون وابسته است. اگر Query داخل ViewModel باشه:

  • صفحه بچرخه.
  • Navigation انجام بشه.
  • صفحه دوباره ساخته بشه.

جستجو از بین نمیره.


Repository

Repository تابع جستجو داره.

fun searchProducts(
    query:String
)

اما این تابع بهتره Flow برگردونه.

Flow<List<Product>>

چون هر بار نتیجه تغییر می‌کنه.


DAO

قسمت مهم جستجو اینجاست.

SELECT *

FROM Product

WHERE name LIKE '%' || :query || '%'

این Query میگه:

هر محصولی که اسمش شامل متن جستجو باشه برگردون.

مثلاً:

Query:

کوک

نتیجه:

کوکاکولا

کوکی شکلاتی

LIKE یعنی چه؟

فرض کن:

کوک

اگر بنویسیم:

WHERE name = :query

فقط محصولی پیدا میشه که دقیقاً اسمش:

کوک

باشه.

اما:

LIKE

جستجوی جزئی انجام میده.


علامت %

در SQL:

%

یعنی:

هر تعداد کاراکتر.

مثلاً:

%کوک%

یعنی:

هر اسمی که داخلش:

کوک

وجود داشته باشه.


جستجو با Flow

Repository:

fun searchProducts(
    query:String
)=
productDao.search(query)

ViewModel:

به جای اینکه هر بار دستی Search رو صدا بزنیم، بهتره Query رو به یک جریان نتایج تبدیل کنیم.

val products =
    query.flatMapLatest {

        repository.searchProducts(it)

    }

این یکی از مهم‌ترین کدهای این درسه.


flatMapLatest

فرض کن کاربر سریع تایپ می‌کنه.

ک

کو

کوک

کوکا

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

ممکنه نتیجه:

ک

بعد از:

کوکا

برسه.

در نتیجه لیست اشتباه نمایش داده میشه.

اما:

flatMapLatest

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


چرا Query خالی هم جستجو میشه؟

وقتی کاربر چیزی ننوشته:

""

دو انتخاب داریم.

اول:

هیچ نتیجه‌ای نمایش ندیم.

دوم:

همه محصولات رو نمایش بدیم.

برای قیمت‌بان، گزینه دوم منطقی‌تره.

پس داخل Repository:

if(query.isBlank()){

    observeProducts()

}
else{

    searchProducts(query)

}

Debounce

فرض کن کاربر خیلی سریع تایپ می‌کنه.

ک

کو

کوک

کوکا

لازم نیست بعد از هر حرف Query اجرا بشه. بهتره چند میلی‌ثانیه صبر کنیم. به این کار میگن:

Debounce

مثلاً:

query

.debounce(300)

یعنی: اگر تا ۳۰۰ میلی‌ثانیه چیزی تایپ نشد، جستجو اجرا بشه.


چرا Debounce؟

اگر نباشه:
سلام

پنج بار جستجو اجرا میشه. اما با Debounce فقط یک بار. این هم مصرف CPU رو کمتر می‌کنه، هم تجربه کاربری رو بهتر.


DistinctUntilChanged

گاهی مقدار Query اصلاً تغییر نکرده.

مثلاً:
کوک

دوباره همون:
کوک

نباید دوباره Query اجرا بشه.

برای همین:

distinctUntilChanged()

استفاده می‌کنیم.


زنجیره کامل جستجو

در نهایت ViewModel چیزی شبیه این داره:

query

.debounce(300)

.distinctUntilChanged()

.flatMapLatest {

    repository.searchProducts(it)

}

این یکی از رایج‌ترین الگوهای Kotlin Flow است.


State صفحه

data class ProductsUiState(

    val query:String="",

    val products:
        List<Product> = emptyList(),

    val isSearching:Boolean=false

)

وقتی Query تغییر می‌کنه:

State هم تغییر می‌کنه.


نمایش TextField

OutlinedTextField(

    value = state.query,

    onValueChange =
        viewModel::onQueryChanged

)

صفحه هیچ اطلاعی از SQL نداره. فقط متن رو به ViewModel میده.


آیا باید جستجو داخل Compose انجام بشه؟

گاهی افراد این کار رو می‌کنن:

products.filter {

    ...

}

داخل Compose.

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

اما اگر:

۵۰۰۰ محصول

وجود داشته باشه،

هر بار تایپ:

کل لیست دوباره پردازش میشه.

در حالی که دیتابیس برای همین کار ساخته شده.


جستجو بر اساس بارکد

گاهی کاربر بارکد رو اسکن می‌کنه.

در این حالت:

Barcode

Search

Product

Repository می‌تونه تابع جدا داشته باشه.

searchByBarcode()

چون بارکد معمولاً دقیقاً برابر است.

نه LIKE.


جستجو روی چند فیلد

گاهی کاربر:

  • اسم کالا
  • بارکد
  • کد محصول

رو جستجو می‌کنه.

در SQL:

WHERE

name LIKE ...

OR

barcode LIKE ...

یعنی: اگر هر کدوم پیدا شد، محصول نمایش داده بشه.


آیا حروف بزرگ و کوچک مهمه؟

در فارسی تقریباً نه. اما در انگلیسی ممکنه مهم باشه. بهتره جستجو به حروف حساس نباشه.

مثلاً:

Milk

milk

MILK

همه یک نتیجه بدن. این رفتار معمولاً در سطح دیتابیس یا Collation تنظیم میشه، نه داخل Compose.


نتیجه نداشتن جستجو

اگر محصولی پیدا نشد: نباید فقط لیست خالی ببینیم.

بهتره:
محصولی پیدا نشد.

و حتی:
عبارت دیگری رو امتحان کن.

نمایش داده بشه.


دکمه پاک کردن جستجو

اگر Query خالی نباشه:
یک دکمه:

×

نمایش میدیم.

کلیک:

viewModel.onQueryChanged("")

بعد: همه محصولات دوباره نمایش داده میشن.


آیا Query باید داخل Room ذخیره بشه؟

خیر.

Query داده دائمی نیست. فقط State صفحه است. بنابراین داخل دیتابیس ذخیره نمیشه.


Search History

بعضی برنامه‌ها آخرین جستجوها رو نگه می‌دارن. مثلاً:

کوکاکولا
شیر
دلستر

این قابلیت کاملاً جدا از خود Search است. اگر بعدها خواستیم اضافه کنیم، یک جدول مستقل خواهد داشت. نه جدول Product.


مسئولیت هر بخش

Compose
نمایش TextField
نمایش لیست

ViewModel
نگهداری Query

Repository
Search

Room
اجرای Query

مثل همیشه، هیچ Query دیتابیسی داخل Compose نوشته نمیشه.


جستجو فقط فیلتر کردن یک لیست نیست

در ظاهر، کاربر فقط چند حرف داخل یک TextField تایپ می‌کنه و نتایج تغییر می‌کنن. اما پشت این رفتار ساده، یک جریان واکنشی کامل وجود داره. متن جستجو داخل ViewModel نگهداری میشه، با debounce از اجرای بی‌دلیل جستجو جلوگیری می‌کنیم، distinctUntilChanged مانع اجرای Queryهای تکراری میشه و flatMapLatest فقط آخرین جستجوی معتبر رو نگه می‌داره. Repository هم جستجو رو به Room می‌سپاره و نتایج به‌صورت Flow دوباره به رابط کاربری برمی‌گردن.

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

در نهایت، مسیر جستجوی محصولات در قیمت‌بان رو میشه این‌طور خلاصه کرد:

کاربر متن جستجو رو وارد می‌کنه، ViewModel اون رو به یک جریان واکنشی تبدیل می‌کنه، Repository جستجو رو داخل Room انجام میده و Compose فقط نتایج نهایی رو نمایش میده؛ بدون اینکه برای هر حرف تایپ‌شده درخواست جدیدی به سرور ارسال بشه.


درس سی‌ام

برنامه چطور محصولات رو بر اساس قیمت یا زمان ثبت مرتب می‌کنه، بدون اینکه منطق مرتب‌سازی وارد رابط کاربری بشه؟

فیلتر و مرتب‌سازی محصولات

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

اما از نظر معماری، باید مشخص کنیم این انتخاب کجا نگهداری میشه، چه کسی اون رو به Query دیتابیس تبدیل می‌کنه و چطور می‌تونیم جستجو و مرتب‌سازی رو هم‌زمان با هم اجرا کنیم. مسیر کلی به این شکله: کاربر گزینه مرتب‌سازی رو انتخاب می‌کنه، ViewModel انتخاب فعلی رو نگه می‌داره، Repository متناسب با اون تصمیم می‌گیره کدوم Query اجرا بشه، Room محصولات رو با ترتیب درست برمی‌گردونه و Compose فقط لیست نهایی رو نمایش میده.

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

فیلتر یا مرتب‌سازی؟

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

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

چرا مرتب‌سازی نباید داخل Compose انجام بشه؟

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

val sortedProducts = products.sortedBy { it.lastPrice }

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

دیتابیس برای مرتب‌سازی ساخته شده. Room می‌تونه قبل از برگردوندن داده‌ها، اون‌ها رو با ORDER BY مرتب کنه. در نتیجه فقط خروجی نهایی و آماده نمایش به برنامه میرسه.

مدل انتخاب مرتب‌سازی

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

enum class ProductSort {
    LOWEST_PRICE,
    HIGHEST_PRICE,
    NEWEST
}

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

نگهداری انتخاب داخل ViewModel

حالت مرتب‌سازی، مثل متن جستجو، بخشی از State صفحه است. بنابراین بهتره داخل ViewModel نگهداری بشه:

private val _sort =
    MutableStateFlow(ProductSort.NEWEST)

val sort = _sort.asStateFlow()

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

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

وقتی کاربر گزینه دیگری رو انتخاب می‌کنه، ViewModel فقط State رو تغییر میده:

fun onSortChanged(sort: ProductSort) {
    _sort.value = sort
}

این تابع هیچ Queryای اجرا نمی‌کنه و هیچ لیستی رو مرتب نمی‌کنه. فقط میگه انتخاب فعلی کاربر تغییر کرده.

Query کمترین قیمت

برای نمایش محصولات از ارزان به گران، Query باید بر اساس قیمت صعودی مرتب بشه:

@Query(
    """
    SELECT * FROM products
    ORDER BY lastPrice ASC
    """
)
fun observeByLowestPrice(): Flow<List<ProductEntity>>

ASC مخفف Ascending است؛ یعنی ترتیب صعودی. قیمت‌های کوچک‌تر در ابتدای لیست قرار می‌گیرن و هرچه پایین‌تر میریم، قیمت بیشتر میشه. این Query داخل ProductDao قرار می‌گیره، چون اجرای SQL مسئولیت DAO است.

Query بیشترین قیمت

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

@Query(
    """
    SELECT * FROM products
    ORDER BY lastPrice DESC
    """
)
fun observeByHighestPrice(): Flow<List<ProductEntity>>

DESC یعنی ترتیب نزولی. قیمت‌های بزرگ‌تر اول نمایش داده میشن.

نکته مهم اینه که Repository یا ViewModel نباید خودش ASC و DESC تولید کنه و به SQL تزریق کنه. Queryهای مشخص و از پیش تعریف‌شده، هم خواناتر هستن و هم احتمال خطا رو کمتر می‌کنن.

Query جدیدترین محصولات

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

val createdAt: Long

این مقدار می‌تونه Timestamp باشه؛ یعنی عددی که زمان ثبت محصول رو نمایش میده. در این حالت Query جدیدترین‌ها به این شکل میشه:

@Query(
    """
    SELECT * FROM products
    ORDER BY createdAt DESC
    """
)
fun observeNewest(): Flow<List<ProductEntity>>

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

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

محصول بدون قیمت چه میشه؟

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

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

می‌تونیم این تصمیم رو داخل Query مشخص کنیم:

@Query(
    """
    SELECT * FROM products
    ORDER BY
        CASE WHEN lastPrice IS NULL THEN 1 ELSE 0 END,
        lastPrice ASC
    """
)
fun observeByLowestPrice(): Flow<List<ProductEntity>>

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

این مثال نشون میده مرتب‌سازی فقط نوشتن ASC یا DESC نیست؛ باید رفتار داده‌های ناقص هم مشخص بشه.

برای بیشترین قیمت هم می‌تونیم همین منطق رو نگه داریم و فقط جهت قیمت رو تغییر بدیم:

@Query(
    """
    SELECT * FROM products
    ORDER BY
        CASE WHEN lastPrice IS NULL THEN 1 ELSE 0 END,
        lastPrice DESC
    """
)
fun observeByHighestPrice(): Flow<List<ProductEntity>>

در هر دو حالت، محصولات بدون قیمت در انتها قرار می‌گیرن.

Repository چطور Query مناسب رو انتخاب می‌کنه؟

ViewModel نباید بدونه کدوم تابع DAO باید اجرا بشه. این تصمیم در Repository گرفته میشه:

fun observeProducts(
    sort: ProductSort
): Flow<List<Product>> {
    val source = when (sort) {
        ProductSort.LOWEST_PRICE ->
            productDao.observeByLowestPrice()

        ProductSort.HIGHEST_PRICE ->
            productDao.observeByHighestPrice()

        ProductSort.NEWEST ->
            productDao.observeNewest()
    }

    return source.map { entities ->
        entities.map(ProductEntity::toDomain)
    }
}

این تابع در ProductRepository قرار می‌گیره. ViewModel فقط نوع مرتب‌سازی رو می‌فرسته و Repository منبع مناسب داده رو انتخاب می‌کنه.

تبدیل Entity به مدل Domain هم در همین مسیر انجام میشه تا UI مستقیماً با مدل دیتابیس کار نکنه.

تبدیل State مرتب‌سازی به لیست محصولات

در ViewModel، با تغییر گزینه مرتب‌سازی باید Flow محصولات هم تغییر کنه. برای این کار دوباره flatMapLatest کاربرد داره:

val products =
    sort.flatMapLatest { selectedSort ->
        repository.observeProducts(selectedSort)
    }

وقتی مقدار sort تغییر می‌کنه، Flow قبلی کنار گذاشته میشه و Repository جریان جدیدی با ترتیب تازه می‌سازه.

برای مثال، صفحه ابتدا NEWEST رو مشاهده می‌کنه. کاربر کمترین قیمت رو انتخاب می‌کنه. جریان جدیدی از Room ساخته میشه که محصولات رو بر اساس lastPrice ASC برمی‌گردونه. Compose هم نتیجه جدید رو دریافت می‌کنه و ترتیب لیست تغییر می‌کنه.

نکته مهم اینه که داده‌ها دوباره از سرور دانلود نمیشن. فقط Query محلی تغییر می‌کنه.

ترکیب جستجو و مرتب‌سازی

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

پس نتیجه نهایی باید به دو State وابسته باشه:

متن جستجو و نوع مرتب‌سازی.

برای ترکیب این دو از combine استفاده می‌کنیم:

val products =
    combine(
        query.debounce(300),
        sort
    ) { queryValue, sortValue ->
        queryValue.trim() to sortValue
    }.flatMapLatest { (queryValue, sortValue) ->
        repository.observeProducts(
            query = queryValue,
            sort = sortValue
        )
    }

combine هر بار که Query یا Sort تغییر کنه، آخرین مقدار هر دو رو کنار هم قرار میده.

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

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

Repository برای جستجو و مرتب‌سازی

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

fun observeProducts(
    query: String,
    sort: ProductSort
): Flow<List<Product>>

اما اینجا یک مسئله طراحی ایجاد میشه. اگر برای هر حالت Query جدا بنویسیم، حداقل به شش Query نیاز داریم:

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

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

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

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

@Query(
    """
    SELECT * FROM products
    WHERE name LIKE '%' || :query || '%'
       OR barcode LIKE '%' || :query || '%'
    ORDER BY
        CASE WHEN lastPrice IS NULL THEN 1 ELSE 0 END,
        lastPrice ASC
    """
)
fun searchByLowestPrice(
    query: String
): Flow<List<ProductEntity>>

هم جستجو داخل Room انجام میشه و هم مرتب‌سازی. Compose هیچ فیلتر یا Sort اضافه‌ای روی نتیجه اجرا نمی‌کنه.

آیا برای مرتب‌سازی باید دوباره API صدا زده بشه؟

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

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

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

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

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

در Compose می‌تونیم گزینه‌ها رو داخل یک Bottom Sheet، Dropdown Menu یا مجموعه‌ای از Chipها نمایش بدیم. انتخاب دقیق رابط به طراحی صفحه بستگی داره، اما خود Composable نباید چیزی درباره SQL بدونه.

بخش مهم اینه که انتخاب کاربر به مقدار ProductSort تبدیل بشه:

FilterChip(
    selected = selectedSort == ProductSort.LOWEST_PRICE,
    onClick = {
        onSortChanged(ProductSort.LOWEST_PRICE)
    },
    label = {
        Text("کمترین قیمت")
    }
)

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

نمایش انتخاب فعلی

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

اما متن قابل نمایش نباید داخل ViewModel تولید بشه. ViewModel فقط ProductSort.LOWEST_PRICE رو نگه می‌داره و UI تصمیم می‌گیره متن فارسی اون چی باشه. این جداسازی برای چندزبانه شدن برنامه هم مهمه. منطق برنامه ثابت می‌مونه و فقط منابع متنی تغییر می‌کنن.

آیا انتخاب مرتب‌سازی باید حفظ بشه؟

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

اما اگر برنامه کاملاً بسته و دوباره باز بشه، حالت پیش‌فرض برمی‌گرده. اینکه انتخاب کاربر برای دفعات بعد هم ذخیره بشه یا نه، یک تصمیم محصولیه. اگر این ترجیح برای کاربر مهم باشه، می‌تونیم مقدار Sort رو داخل DataStore ذخیره کنیم. اما نباید برای یک State ساده صفحه، بدون نیاز واقعی ذخیره‌سازی دائمی اضافه کنیم. برای نسخه اولیه، حفظ شدن انتخاب در همان Session کافی است.

تغییر مرتب‌سازی نباید Loading تمام‌صفحه بسازه

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

ترتیب پایدار برای قیمت‌های برابر

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

@Query(
    """
    SELECT * FROM products
    ORDER BY
        CASE WHEN lastPrice IS NULL THEN 1 ELSE 0 END,
        lastPrice ASC,
        name ASC
    """
)
fun observeByLowestPrice(): Flow<List<ProductEntity>>

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

این موضوع شاید در ظاهر کوچک به نظر برسه، اما باعث میشه لیست بی‌دلیل جابه‌جا نشه و تجربه کاربر پایدارتر باشه.

State صفحه محصولات

حالا State صفحه علاوه بر Query و Products، انتخاب مرتب‌سازی رو هم نگه می‌داره:

data class ProductsUiState(
    val query: String = "",
    val selectedSort: ProductSort = ProductSort.NEWEST,
    val products: List<Product> = emptyList(),
    val isLoading: Boolean = false,
    val isRefreshing: Boolean = false,
    val errorMessage: String? = null
)

selectedSort به UI میگه کدوم گزینه باید انتخاب‌شده نمایش داده بشه. خود Products هم نتیجه نهایی جستجو و مرتب‌سازی هستن.

ViewModel باید این داده‌ها رو از Flowهای جدا ترکیب کنه، نه اینکه صفحه خودش Query، Sort و Products رو کنار هم مدیریت کنه.

تفاوت Refresh با تغییر فیلتر

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

مسئولیت هر بخش

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

ViewModel نوع مرتب‌سازی فعلی رو نگه می‌داره و اون رو با متن جستجو ترکیب می‌کنه.

Repository بر اساس Query و Sort تصمیم می‌گیره کدوم جریان داده استفاده بشه.

Room جستجو و مرتب‌سازی واقعی رو با SQL انجام میده.

API فقط هنگام Sync یا Refresh وارد فرایند میشه و تغییر یک گزینه مرتب‌سازی نباید باعث درخواست شبکه بشه.

فیلتر فقط تغییر ظاهر لیست نیست

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

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

در نهایت، مسیر مرتب‌سازی محصولات در قیمت‌بان رو میشه این‌طور خلاصه کرد: کاربر یکی از گزینه‌های کمترین قیمت، بیشترین قیمت یا جدیدترین رو انتخاب می‌کنه، ViewModel این انتخاب رو با متن جستجو ترکیب می‌کنه، Repository Query مناسب Room رو اجرا می‌کنه و Compose فقط فهرست نهایی رو نمایش میده؛ بدون اینکه برای هر تغییر ترتیب، داده‌ها دوباره از سرور دریافت بشن.

کمترین قیمت

بیشترین قیمت

جدیدترین


درس سی‌ویکم

برنامه چطور هزاران محصول رو کم‌کم بارگذاری می‌کنه، بدون اینکه حافظه گوشی، اینترنت کاربر یا سرور رو بی‌دلیل درگیر کنه؟

Pagination

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

Pagination برای حل همین مسئله به وجود اومده. به جای اینکه کل داده‌ها یک‌جا دریافت بشن، اون‌ها رو به بخش‌های کوچک‌تری به نام Page تقسیم می‌کنیم. برنامه ابتدا صفحه اول رو می‌گیره و وقتی کاربر به انتهای لیست نزدیک شد، صفحه بعدی رو دریافت می‌کنه. مثلاً اگر هر صفحه شامل ۲۰ محصول باشه، برنامه ابتدا محصولات ۱ تا ۲۰ رو می‌گیره. وقتی کاربر پایین میره، محصولات ۲۱ تا ۴۰ دریافت میشن و همین روند ادامه پیدا می‌کنه. از دید کاربر، لیست تقریباً پیوسته است، اما پشت صحنه داده‌ها مرحله‌به‌مرحله بارگذاری میشن.

برای این کار در اندروید از Paging 3 استفاده می‌کنیم. این کتابخانه برای بارگذاری صفحه‌ای از شبکه یا دیتابیس طراحی شده و با Kotlin Flow، Room، ViewModel و Compose هماهنگه. همچنین مدیریت Cache حافظه، جلوگیری از درخواست‌های تکراری، Refresh، Retry و خطاهای بارگذاری صفحات رو تا حد زیادی بر عهده می‌گیره.

چرا LazyColumn به‌تنهایی Pagination نیست؟

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

اما این به معنی Pagination نیست. ممکنه ما ده هزار محصول رو از API دریافت کنیم، همه اون‌ها رو داخل یک List نگه داریم و بعد با LazyColumn نمایش بدیم. در این حالت، ساخت UI بهینه شده، اما همچنان تمام ده هزار محصول دانلود و در حافظه نگهداری شدن.

پس این دو ابزار دو مسئله متفاوت رو حل می‌کنن: LazyColumn تعداد Composableهای ساخته‌شده رو محدود می‌کنه، اما Pagination تعداد داده‌های دریافت‌شده و نگهداری‌شده رو کنترل می‌کنه. در صفحه محصولات قیمت‌بان معمولاً هر دو رو کنار هم استفاده می‌کنیم.

Pagination در Backend چطور کار می‌کنه؟

قبل از اینکه اندروید بتونه داده‌ها رو صفحه‌ای دریافت کنه، Backend هم باید Pagination رو پشتیبانی کنه. یکی از روش‌های ساده اینه که شماره صفحه و تعداد آیتم‌های هر صفحه رو برای سرور بفرستیم. در Retrofit، بخش متمایز Endpoint می‌تونه چنین باشه:

@GET("products")
suspend fun getProducts(
    @Query("page") page: Int,
    @Query("limit") pageSize: Int
): ProductsPageDto

پارامتر page مشخص می‌کنه کدوم صفحه رو می‌خوایم و pageSize تعداد محصولات هر صفحه رو تعیین می‌کنه. مثلاً درخواست صفحه سوم با اندازه ۲۰ یعنی سرور باید بخش سوم داده‌ها رو برگردونه.

پاسخ سرور هم بهتره فقط یک List ساده نباشه، چون برنامه باید بدونه آیا صفحه دیگری وجود داره یا نه:

data class ProductsPageDto(
    val items: List<ProductDto>,
    val currentPage: Int,
    val hasNextPage: Boolean
)

items محصولات همین صفحه رو نگه می‌داره. currentPage مشخص می‌کنه پاسخ مربوط به کدوم صفحه بوده و hasNextPage به برنامه میگه آیا بعد از این صفحه، داده دیگری هم وجود داره یا نه. ممکنه Backend به جای hasNextPage تعداد کل صفحات یا شماره صفحه بعدی رو برگردونه. مهم اینه که قرارداد API روشن باشه و برنامه بتونه پایان فهرست رو تشخیص بده.

Page Number و Offset

Pagination فقط یک مدل نداره. یکی از روش‌ها استفاده از شماره صفحه است؛ مثلاً صفحه ۱، صفحه ۲ و صفحه ۳. روش دیگر استفاده از offset و limit است. در مدل Offset، برنامه میگه از آیتم شماره ۴۰ شروع کن و ۲۰ مورد برگردون. یعنی offset نقطه شروع و limit تعداد آیتم‌هاست. هر دو روش برای پروژه‌های معمولی قابل استفاده‌ان، اما یک ضعف مشترک دارن. اگر هنگام مرور لیست، محصولی در ابتدای داده‌ها اضافه یا حذف بشه، ممکنه بعضی آیتم‌ها تکرار یا جا انداخته بشن؛ چون موقعیت عددی داده‌ها تغییر کرده.

برای سیستم‌های بزرگ‌تر معمولاً از Cursor Pagination استفاده میشه. در این مدل، سرور به جای شماره صفحه یک Cursor برمی‌گردونه و درخواست بعدی از همون نقطه ادامه پیدا می‌کنه. Cursor می‌تونه بر اساس شناسه، تاریخ یا یک کلید مرتب‌سازی‌شده ساخته بشه. برای نسخه فعلی قیمت‌بان، Page Number یا Offset ساده‌تره. اما باید بدونیم Pagination فقط به معنی فرستادن شماره صفحه نیست و نوع Backend می‌تونه روی معماری اندروید اثر بذاره.

اجزای اصلی Paging 3

Paging 3 چند قطعه اصلی داره که هر کدوم در بخشی از معماری قرار می‌گیرن:

PagingSource مشخص می‌کنه داده‌های هر صفحه از کجا و چطور دریافت بشن. Pager جریان صفحه‌بندی رو می‌سازه. PagingData بسته‌ای از داده‌های صفحه‌ایه که به UI میرسه و Compose با collectAsLazyPagingItems اون رو به شکلی قابل استفاده در LazyColumn تبدیل می‌کنه.

مسیر ساده Pagination مستقیم از شبکه به این شکله:

Compose → ViewModel → Pager → PagingSource → API

اما در معماری Offline First قیمت‌بان، UI نباید مستقیماً از API داده بخونه. بنابراین مسیر کامل‌تر به این شکل میشه:

Compose → ViewModel → Pager → Room PagingSource

و وقتی داده‌های محلی کافی نبودن:

RemoteMediator → API → Room

یعنی باز هم Room منبع اصلی نمایش باقی می‌مونه.

PagingSource چیست؟

PagingSource به Paging 3 میگه هر صفحه از داده رو چطور بارگذاری کنه. اگر فقط بخوایم مستقیم از API بخونیم، یک PagingSource اختصاصی می‌سازیم که شماره صفحه رو دریافت و درخواست شبکه رو اجرا می‌کنه.

ساختار اصلی اون به شکل زیره:

class ProductsPagingSource(
    private val api: ProductsApi
) : PagingSource<Int, Product>() {

    override suspend fun load(
        params: LoadParams<Int>
    ): LoadResult<Int, Product> {
        // بارگذاری یک صفحه
    }
}

نوع Int کلید صفحه است؛ چون Backend ما با شماره صفحه کار می‌کنه. نوع Product هم مدل هر آیتم خروجیه.

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

متد load چه چیزی دریافت می‌کنه؟

Paging 3 هنگام نیاز، متد load رو صدا می‌زنه. مقدار params.key مشخص می‌کنه کدوم صفحه باید دریافت بشه و params.loadSize تعداد تقریبی آیتم‌های موردنیازه.

بخش اصلی بارگذاری می‌تونه این‌طور باشه:

val page = params.key ?: 1

val response = api.getProducts(
    page = page,
    pageSize = params.loadSize
)

اگر key مقدار نداشته باشه، یعنی اولین بارگذاری انجام میشه و صفحه اول رو می‌گیریم.

نکته مهم اینه که Paging 3 ممکنه در بارگذاری اولیه تعداد بیشتری از اندازه معمول صفحه درخواست کنه. بنابراین بهتره تا جای ممکن از params.loadSize استفاده کنیم، نه اینکه عدد ۲۰ رو در چند جای برنامه ثابت بنویسیم. البته Backend هم باید بتونه اندازه‌های مختلف صفحه رو قبول کنه.

نتیجه موفق یک صفحه

بعد از دریافت پاسخ، PagingSource باید داده‌های صفحه فعلی و کلید صفحات قبل و بعد رو برگردونه:

return LoadResult.Page(
    data = response.items.map(ProductDto::toDomain),
    prevKey = if (page == 1) null else page - 1,
    nextKey = if (response.hasNextPage) page + 1 else null
)

data محصولات همین صفحه است. prevKey مشخص می‌کنه صفحه قبلی چیه و nextKey شماره صفحه بعد رو نگه می‌داره.

وقتی nextKey برابر null باشه، Paging می‌فهمه به انتهای داده‌ها رسیده و دیگه درخواست جدیدی ارسال نمی‌کنه.

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

مدیریت خطا در PagingSource

اگر درخواست شبکه شکست بخوره، نباید Exception از load خارج بشه و برنامه رو Crash کنه. PagingSource خطا رو به شکل LoadResult.Error برمی‌گردونه:

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

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

این خطا بعداً به UI میرسه و Compose می‌تونه متناسب با نوع بارگذاری، پیام و دکمه Retry نمایش بده.

اما برای قیمت‌بان که Room و Offline First رو داریم، PagingSource مستقیم شبکه مسیر نهایی ما نیست. این نمونه فقط کمک می‌کنه نقش PagingSource رو بفهمیم.

Pager چه کاری انجام میده؟

Pager تنظیمات Pagination و PagingSource رو کنار هم قرار میده و یک Flow<PagingData<Product>> می‌سازه:

fun pagedProducts(): Flow<PagingData<Product>> {
    return Pager(
        config = PagingConfig(
            pageSize = 20
        ),
        pagingSourceFactory = {
            ProductsPagingSource(api)
        }
    ).flow
}

این تابع معمولاً در Repository قرار می‌گیره. PagingConfig تعیین می‌کنه داده‌ها با چه تنظیماتی بارگذاری بشن و pagingSourceFactory هر زمان لازم باشه یک PagingSource جدید می‌سازه.

نکته مهم اینه که نباید یک نمونه PagingSource رو برای همیشه نگه داریم. PagingSource ممکنه Invalid بشه و Pager باید بتونه نمونه تازه‌ای بسازه؛ به همین دلیل Factory دریافت می‌کنه.

pageSize

pageSize تعداد معمول آیتم‌های هر صفحه است:

PagingConfig(
    pageSize = 20
)

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

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

prefetchDistance

لازم نیست Paging صبر کنه کاربر دقیقاً به آخرین آیتم برسه. می‌تونه کمی زودتر صفحه بعدی رو آماده کنه:

PagingConfig(
    pageSize = 20,
    prefetchDistance = 5
)

prefetchDistance = 5 یعنی وقتی کاربر حدود پنج آیتم تا انتهای داده‌های موجود فاصله داره، بارگذاری صفحه بعدی شروع بشه.

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

initialLoadSize

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

PagingConfig(
    pageSize = 20,
    initialLoadSize = 40
)

در این حالت، بارگذاری اولیه می‌تونه ۴۰ محصول دریافت کنه و بارگذاری‌های بعدی با اندازه معمول ۲۰ ادامه پیدا کنن.

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

PagingData در ViewModel

Repository یک Flow<PagingData<Product>> برمی‌گردونه و ViewModel اون رو در اختیار UI قرار میده:

val products =
    repository.pagedProducts()
        .cachedIn(viewModelScope)

cachedIn(viewModelScope) یکی از خطوط مهم Paging است. این تابع داده‌های صفحه‌بندی‌شده رو در محدوده ViewModel Cache می‌کنه تا بازسازی Compose یا تغییرات موقت UI باعث شروع دوباره کل جریان Pagination نشه.

این Cache دائمی نیست و جای Room رو نمی‌گیره. فقط Snapshot صفحه‌بندی‌شده رو در حافظه و در طول عمر ViewModel نگه می‌داره. خود Paging 3 نیز برای داده‌های صفحه‌ای Cache حافظه و جلوگیری از درخواست‌های تکراری فراهم می‌کنه.

نمایش در Compose

Compose، Flow<PagingData<Product>> رو به LazyPagingItems تبدیل می‌کنه:

val products =
    viewModel.products.collectAsLazyPagingItems()

بعد در LazyColumn به جای List معمولی از تعداد آیتم‌های Paging استفاده می‌کنیم:

LazyColumn {
    items(
        count = products.itemCount,
        key = products.itemKey { it.id }
    ) { index ->
        products[index]?.let { product ->
            ProductItem(product)
        }
    }
}

products[index] ممکنه موقتاً null باشه، چون Paging هنوز بعضی داده‌ها رو در حال آماده‌سازی داره. میشه در این حالت Placeholder نمایش داد یا تا رسیدن آیتم چیزی نساخت.

itemKey هم مثل key در لیست معمولی، شناسه پایدار محصول رو به Compose میده تا جابه‌جایی و به‌روزرسانی آیتم‌ها بهتر مدیریت بشه. API رسمی Compose برای Paging هم همین الگو رو برای اتصال PagingData به Lazy layoutها در نظر گرفته است.

سه نوع بارگذاری در Paging

Paging فقط یک Loading نداره. سه نوع عملیات بارگذاری اصلی وجود داره:

Refresh یعنی بارگذاری اولیه یا تازه‌سازی کل جریان داده. Append یعنی بارگذاری داده‌های بعد از انتهای فعلی لیست. Prepend یعنی بارگذاری داده‌هایی که قبل از ابتدای فعلی قرار دارن.

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

شناخت این تفاوت مهمه، چون هر کدوم باید UI متفاوتی داشته باشن.

Loading اولیه

وقتی صفحه برای اولین بار باز میشه و هنوز هیچ محصولی وجود نداره، Loading باید در مرکز صفحه نمایش داده بشه:

when (products.loadState.refresh) {
    is LoadState.Loading -> {
        FullPageLoading()
    }

    is LoadState.Error -> {
        InitialLoadError()
    }

    is LoadState.NotLoading -> {
        ProductsList()
    }
}

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

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

Loading انتهای لیست

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

if (products.loadState.append is LoadState.Loading) {
    item {
        AppendLoading()
    }
}

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

این همون تفاوتی است که قبلاً بین Loading اولیه و Refresh دیدیم، اما در Pagination یک حالت اضافه به نام Append هم داریم.

خطای صفحه بعد

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

پس خطا رو در انتهای فهرست نمایش می‌دیم:

if (products.loadState.append is LoadState.Error) {
    item {
        AppendError(
            onRetry = products::retry
        )
    }
}

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

این رفتار یکی از مزیت‌های مهم Paging 3 است؛ کتابخانه نوع عملیات ناموفق رو می‌شناسه و Retry رو روی همون مرحله اجرا می‌کنه.

Refresh دستی

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

products.refresh()

refresh یک نسل تازه از PagingData می‌سازه و بارگذاری رو از نقطه مناسب دوباره شروع می‌کنه. این با retry فرق داره.

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

چرا Paging مستقیم از API برای قیمت‌بان کافی نیست؟

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

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

برای ترکیب Pagination با Offline First از RemoteMediator استفاده می‌کنیم. Android این قطعه رو دقیقاً برای حالتی در نظر گرفته که UI از دیتابیس محلی بخونه، اما هنگام تمام شدن داده‌های Cache، صفحات جدید از شبکه دریافت و داخل دیتابیس ذخیره بشن.

معماری نهایی Pagination در قیمت‌بان

مسیر نهایی به این شکل میشه:

Compose → PagingData → Pager → Room PagingSource

وقتی Room برای ادامه لیست داده کافی نداشته باشه، Paging به RemoteMediator خبر میده:

RemoteMediator → Products API → Room

بعد Room تغییر می‌کنه، PagingSource نامعتبر میشه و خروجی جدید دیتابیس دوباره به UI میرسه.

نکته بسیار مهم اینه که RemoteMediator داده‌های API رو مستقیماً به Compose تحویل نمیده. فقط اون‌ها رو داخل Room ذخیره می‌کنه. UI همچنان فقط داده‌هایی رو نمایش میده که در دیتابیس محلی قرار گرفتن. این همون Single Source of Truth قبلیه.

PagingSource از Room

وقتی از Room استفاده می‌کنیم، لازم نیست PagingSource رو دستی پیاده‌سازی کنیم. DAO می‌تونه مستقیماً PagingSource برگردونه:

@Query(
    """
    SELECT * FROM products
    ORDER BY createdAt DESC
    """
)
fun pagingSource():
    PagingSource<Int, ProductEntity>

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

این تابع در ProductDao قرار می‌گیره. هر بار داده‌های جدول Product تغییر کنن، Room می‌تونه PagingSource فعلی رو Invalid کنه تا Pager نسخه جدیدی از داده‌ها رو دریافت کنه.

RemoteMediator چه زمانی اجرا میشه؟

RemoteMediator در چند حالت اصلی وارد عمل میشه. هنگام Refresh باید مشخص کنه آیا داده‌ها از ابتدا تازه بشن. هنگام Append، وقتی PagingSource به انتهای داده‌های موجود در Room میرسه، باید صفحه بعدی رو از API دریافت کنه. در حالت Prepend هم می‌تونه داده‌های قبل از ابتدای فعلی رو بارگذاری کنه، هرچند در لیست محصولات معمولاً نیازی به اون نداریم.

ساختار اصلی کلاس به این شکله:

class ProductsRemoteMediator(
    private val api: ProductsApi,
    private val database: AppDatabase
) : RemoteMediator<Int, ProductEntity>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, ProductEntity>
    ): MediatorResult {
        // هماهنگی API و Room
    }
}

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

تشخیص صفحه بعد

در مدل ساده Page Number، RemoteMediator باید بدونه آخرین صفحه دریافت‌شده کدوم بوده. نگه داشتن یک متغیر معمولی داخل کلاس کافی نیست، چون ممکنه برنامه بسته بشه، فرایند از بین بره یا PagingSource دوباره ساخته بشه.

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

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

@Entity(tableName = "product_remote_keys")
data class ProductRemoteKeysEntity(
    @PrimaryKey
    val scope: String,
    val nextPage: Int?,
    val updatedAt: Long
)

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

nextPage صفحه‌ایه که در درخواست بعدی باید دریافت بشه و updatedAt می‌تونه برای تشخیص قدیمی شدن Cache استفاده بشه.

چرا کلید Pagination باید ذخیره بشه؟

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

ذخیره Remote Key داخل Room باعث میشه وضعیت Pagination همراه داده‌های Cache باقی بمونه. همچنین میشه درج محصولات و به‌روزرسانی کلید صفحه رو داخل یک Transaction انجام داد تا دیتابیس هیچ‌وقت در حالت نیمه‌کاره قرار نگیره.

تصمیم‌گیری بر اساس LoadType

داخل load ابتدا مشخص می‌کنیم چه عملیاتی درخواست شده:

val page = when (loadType) {
    LoadType.REFRESH -> 1

    LoadType.APPEND ->
        remoteKeysDao.getNextPage() ?: return
            MediatorResult.Success(
                endOfPaginationReached = true
            )

    LoadType.PREPEND ->
        return MediatorResult.Success(
            endOfPaginationReached = true
        )
}

در Refresh از صفحه اول شروع می‌کنیم. در Append شماره صفحه بعد رو از Remote Keys می‌گیریم. اگر صفحه بعد وجود نداشته باشه، یعنی به انتهای Pagination رسیدیم.

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

این فقط منطق متمایز تصمیم‌گیریه و پیاده‌سازی کامل RemoteMediator نیست.

دریافت و ذخیره اتمیک داده‌ها

بعد از مشخص شدن صفحه، API صدا زده میشه. سپس محصولات و Remote Key باید داخل یک Transaction ذخیره بشن:

database.withTransaction {
    if (loadType == LoadType.REFRESH) {
        productDao.clearAll()
        remoteKeysDao.clear()
    }

    productDao.upsertAll(
        response.items.map(ProductDto::toEntity)
    )

    remoteKeysDao.save(
        nextPage = if (response.hasNextPage) {
            page + 1
        } else {
            null
        }
    )
}

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

در Refresh، پاک کردن کامل محصولات همیشه تصمیم درستی نیست. اگر جدول Product داده‌های محلی و ویرایش‌های ارسال‌نشده هم داشته باشه، حذف کامل ممکنه اطلاعات کاربر رو از بین ببره. در اون شرایط باید Cache سرور از داده‌های محلی کاربر جدا باشه یا Sync هوشمندتری انجام بشه.

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

پایان Pagination

RemoteMediator باید به Paging بگه آیا صفحه دیگری باقی مونده یا نه:

return MediatorResult.Success(
    endOfPaginationReached =
        !response.hasNextPage
)

اگر endOfPaginationReached برابر true باشه، Paging دیگه درخواست Append جدیدی ایجاد نمی‌کنه.

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

Pager با Room و RemoteMediator

Repository هر دو منبع رو کنار هم قرار میده:

fun pagedProducts(): Flow<PagingData<Product>> {
    return Pager(
        config = PagingConfig(
            pageSize = 20,
            prefetchDistance = 5
        ),
        remoteMediator = ProductsRemoteMediator(
            api = api,
            database = database
        ),
        pagingSourceFactory = {
            productDao.pagingSource()
        }
    ).flow.map { pagingData ->
        pagingData.map(ProductEntity::toDomain)
    }
}

pagingSourceFactory داده‌ها رو از Room می‌خونه و remoteMediator هنگام نیاز Room رو از طریق API گسترش میده.

تبدیل Entity به Domain هم روی PagingData انجام میشه، نه اینکه UI مستقیماً با مدل دیتابیس کار کنه.

جستجو و Pagination

در درس جستجو گفتیم Query داخل ViewModel نگهداری میشه و با flatMapLatest جریان نتایج تغییر می‌کنه. در Pagination هم همین اصل باقی می‌مونه، اما Repository به جای Flow<List<Product>>، مقدار Flow<PagingData<Product>> برمی‌گردونه.

val products =
    query
        .debounce(300)
        .distinctUntilChanged()
        .flatMapLatest { queryValue ->
            repository.pagedProducts(
                query = queryValue.trim()
            )
        }
        .cachedIn(viewModelScope)

وقتی Query عوض میشه، جریان Pagination قبلی کنار گذاشته و یک Pager متناسب با جستجوی جدید ساخته میشه.

اما اینجا Backend هم باید جستجوی صفحه‌ای رو پشتیبانی کنه. نمی‌تونیم فقط ۲۰ محصول فعلی رو داخل گوشی فیلتر کنیم و تصور کنیم کل نتایج جستجو رو پیدا کردیم. اگر داده کامل داخل Room موجود نباشه، Query باید به API هم ارسال بشه.

مرتب‌سازی و Pagination

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

پس در معماری صفحه‌ای، Sort باید بخشی از درخواست API باشه:

@GET("products")
suspend fun getProducts(
    @Query("page") page: Int,
    @Query("limit") pageSize: Int,
    @Query("search") query: String?,
    @Query("sort") sort: String
): ProductsPageDto

Repository مقدار داخلی ProductSort رو به مقدار موردانتظار Backend تبدیل می‌کنه. مثلاً LOWEST_PRICE ممکنه به price_asc تبدیل بشه. UI همچنان فقط نوع مرتب‌سازی رو انتخاب می‌کنه و نباید رشته‌های مخصوص API رو بشناسه.

هر Query و Sort یک جریان مستقل است

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

بنابراین ترکیب Query، Sort و Category باید بخشی از هویت جریان Pagination باشه. Remote Keys مربوط به جدیدترین محصولات نباید برای کمترین قیمت استفاده بشن.

میشه یک Scope پایدار ساخت:

val scope =
    "$query|${sort.name}|${categoryId ?: "all"}"

این مقدار مفهومی مشخص می‌کنه Remote Key مربوط به کدوم شرایطه. در پروژه واقعی بهتره ساخت Scope داخل یک کلاس یا تابع مشخص انجام بشه، نه اینکه رشته‌ها در قسمت‌های مختلف دستی ساخته بشن.

تغییر فیلتر چه اتفاقی ایجاد می‌کنه؟

وقتی Query، Sort یا Category تغییر کنه، flatMapLatest Pager قبلی رو کنار می‌ذاره و جریان تازه‌ای می‌سازه. صفحه جدید از شرایط تازه شروع میشه و داده‌های قبلی نباید به‌عنوان ادامه اون در نظر گرفته بشن.

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

Empty State در Pagination

در لیست معمولی، Empty State رو با products.isEmpty تشخیص می‌دادیم. در Paging باید هم تعداد آیتم‌ها صفر باشه و هم Refresh تمام شده باشه:

val isEmpty =
    products.itemCount == 0 &&
    products.loadState.refresh is LoadState.NotLoading

اگر itemCount صفر باشه اما Refresh هنوز Loading باشه، هنوز نمی‌دونیم داده‌ای وجود نداره. فقط دریافت اطلاعات کامل نشده.

اگر Refresh با Error تمام شده باشه، باید Error State نمایش داده بشه، نه پیام محصولی وجود نداره.

Placeholder

Paging می‌تونه جای آیتم‌هایی که هنوز دریافت نشدن Placeholder نگه داره، به شرط اینکه منبع داده تعداد کل آیتم‌ها رو بدونه. اما استفاده از Placeholder برای همه پروژه‌ها ضروری نیست.

در قیمت‌بان می‌تونیم فعلاً اون رو غیرفعال کنیم:

PagingConfig(
    pageSize = 20,
    enablePlaceholders = false
)

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

حذف یا ویرایش محصول در لیست صفحه‌ای

چون UI از Room می‌خونه، بعد از حذف یا ویرایش یک محصول باید فقط Room به‌روزرسانی بشه. Room، PagingSource فعلی رو Invalid می‌کنه و Pager Snapshot تازه‌ای می‌سازه. نباید محصول رو فقط از LazyPagingItems حذف کنیم، چون اون فقط نمایشی از داده‌هاست و منبع اصلی محسوب نمیشه.

مسیر همچنان همونه:

API → Room → PagingSource → Compose

اضافه شدن محصول جدید

اگر کاربر محصول تازه‌ای ثبت کنه و مرتب‌سازی روی جدیدترین باشه، بعد از ذخیره در Room محصول باید در ابتدای لیست ظاهر بشه. ممکنه لازم باشه refresh اجرا بشه تا موقعیت صحیح محصول در مجموعه صفحه‌بندی‌شده دوباره محاسبه بشه. اما اگر Room تغییر رو منتشر کنه، PagingSource هم Invalid میشه و معمولاً جریان تازه‌ای ساخته میشه.

نکته مهم اینه که نباید محصول رو به‌صورت دستی به صفحه اول List بچسبونیم و هم‌زمان اون رو داخل Room هم ذخیره کنیم؛ چون احتمال نمایش تکراری ایجاد میشه.

Cache چه مدت معتبر است؟

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

Remote Key یا جدول Sync می‌تونه زمان آخرین دریافت رو نگه داره:

val cacheExpired =
    currentTime - lastUpdatedAt > cacheDuration

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

این تصمیم به میزان تغییر محصولات و قیمت‌ها بستگی داره. در قیمت‌بان که قیمت‌ها ممکنه زیاد تغییر کنن، زمان اعتبار Cache احتمالاً کوتاه‌تر از داده‌هایی مثل دسته‌بندی‌هاست.

آیا Pagination همیشه لازم است؟

نه. Pagination خودش پیچیدگی داره: Backend باید صفحه‌بندی رو پشتیبانی کنه، Remote Keys لازم میشن، جستجو و Sort باید با سرور هماهنگ باشن و مدیریت Refresh و خطای صفحه بعد هم اضافه میشه. اگر هر فروشگاه نهایتاً ۲۰۰ یا ۳۰۰ محصول داره و دریافت کل اون‌ها سریع و سبک انجام میشه، Pagination ممکنه زودهنگام باشه. اما اگر تعداد محصولات می‌تونه به هزاران مورد برسه یا لیست اصلی تمام محصولات سیستم رو نمایش میده، Pagination از همون ابتدا ارزش داره.

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

مسئولیت هر بخش

Backend داده‌ها رو بر اساس Page یا Cursor برمی‌گردونه و جستجو و مرتب‌سازی سراسری رو انجام میده. RemoteMediator تشخیص میده چه زمانی صفحه جدید از API دریافت بشه و نتیجه رو همراه کلید ادامه داخل Room ذخیره می‌کنه.

Room منبع اصلی نمایش داده‌هاست و PagingSource صفحه‌های لازم رو از دیتابیس می‌خونه.

Repository، Pager و تنظیمات Pagination رو می‌سازه و مدل‌های دیتابیس رو به Domain تبدیل می‌کنه.

ViewModel جریان PagingData رو نگه می‌داره و با cachedIn اون رو در طول عمر صفحه حفظ می‌کنه.

Compose با collectAsLazyPagingItems داده‌ها رو دریافت می‌کنه و Loading اولیه، Loading انتهای لیست، Error، Retry و Empty State رو نمایش میده.

Pagination فقط تقسیم یک List نیست

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

در معماری Offline First قیمت‌بان، RemoteMediator داده‌های جدید رو از API می‌گیره و داخل Room قرار میده، اما UI همچنان فقط از Room می‌خونه. این باعث میشه داده‌های دریافت‌شده در حالت آفلاین هم قابل مشاهده باشن و تمام تغییرات از یک منبع مشترک عبور کنن.

در نهایت، مسیر Pagination محصولات در قیمت‌بان رو میشه این‌طور خلاصه کرد: برنامه به جای دریافت همه محصولات، فقط بخش موردنیاز رو از Room نمایش میده. وقتی داده‌های محلی برای ادامه لیست کافی نباشن، RemoteMediator صفحه بعدی رو از API دریافت و داخل Room ذخیره می‌کنه. PagingSource داده‌های تازه رو دوباره در اختیار ViewModel قرار میده و Compose بدون مدیریت دستی شماره صفحه، لیست رو به‌تدریج گسترش میده.


درس سی‌ودوم

Infinite Scroll چطور باعث میشه کاربر بدون زدن دکمه صفحه بعد، پیوسته بین محصولات حرکت کنه؟

Infinite Scroll

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

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

به این تجربه Infinite Scroll میگن. در Infinite Scroll، کاربر احساس می‌کنه با یک لیست پیوسته و تقریباً بی‌انتها روبه‌روئه؛ اما پشت صحنه، محصولات همچنان به‌صورت صفحه‌ای دریافت میشن. پس Infinite Scroll جایگزین Pagination نیست، بلکه یکی از روش‌های استفاده از Pagination در رابط کاربریه.

مسیر کلی این فرایند چنین است:

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

تفاوت Pagination و Infinite Scroll

این دو مفهوم به هم مرتبط هستن، اما یکی نیستن. Pagination درباره ساختار داده و دریافت مرحله‌ای اطلاعاته. مثلاً سرور محصولات رو در صفحه‌های ۲۰تایی برمی‌گردونه.

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

بنابراین می‌تونیم این تفاوت رو این‌طور ببینیم: Pagination میگه داده‌ها چطور تقسیم و دریافت بشن؛ Infinite Scroll میگه چه زمانی صفحه بعدی درخواست بشه.

چرا کاربر نباید دقیقاً به آخرین آیتم برسه؟

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

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

به این رفتار Prefetch میگن. در Paging 3، مقدار prefetchDistance دقیقاً برای همین هدفه:

PagingConfig(
    pageSize = 20,
    prefetchDistance = 5
)

این تنظیم داخل Repository و هنگام ساخت Pager قرار می‌گیره. pageSize = 20 میگه اندازه معمول هر صفحه ۲۰ محصوله و prefetchDistance = 5 یعنی وقتی دسترسی UI به آیتمی نزدیک به پنج مورد پایانی رسید، Paging می‌تونه بارگذاری ادامه داده‌ها رو شروع کنه.

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

با Paging 3 لازم نیست Scroll رو دستی کنترل کنیم

اگر Paging 3 رو به LazyColumn متصل کرده باشیم، معمولاً لازم نیست خودمون آخرین آیتم قابل مشاهده رو بررسی کنیم یا با رسیدن کاربر به انتهای لیست، شماره صفحه رو افزایش بدیم.

UI داده‌ها رو به شکل LazyPagingItems دریافت می‌کنه:

val products =
    viewModel.products.collectAsLazyPagingItems()

بعد هر آیتم بر اساس Index از مجموعه Paging خوانده میشه:

LazyColumn {
    items(
        count = products.itemCount,
        key = products.itemKey { it.id }
    ) { index ->
        products[index]?.let { product ->
            ProductItem(product)
        }
    }
}

وقتی Compose برای نمایش آیتم‌های نزدیک انتهای داده‌های فعلی به products[index] دسترسی پیدا می‌کنه، Paging با توجه به prefetchDistance تشخیص میده که باید صفحه بعدی آماده بشه. در نتیجه، خود Paging 3 بین حرکت کاربر و بارگذاری Append ارتباط ایجاد می‌کنه و ViewModel لازم نیست چیزی مثل currentPage++ داشته باشه. collectAsLazyPagingItems و اتصال اون به Lazy layoutها، روش رسمی نمایش داده‌های صفحه‌بندی‌شده در Compose است.

چرا مدیریت دستی Infinite Scroll دردسرساز میشه؟

بدون Paging 3 ممکنه با LazyListState آخرین آیتم قابل مشاهده رو پیدا کنیم و هر زمان کاربر به انتهای لیست نزدیک شد، تابعی مثل loadNextPage رو صدا بزنیم.

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

یعنی ViewModel کم‌کم پر از متغیرهایی مثل شماره صفحه فعلی، وضعیت درخواست، پایان داده، Retry و جلوگیری از درخواست تکراری میشه. Paging 3 همین مشکلات رو به‌صورت ساختاری حل می‌کنه. این کتابخانه می‌دونه چه نوع بارگذاری در حال اجراست، آیا درخواست Append قبلی هنوز ادامه داره، آیا به پایان Pagination رسیدیم و Retry باید کدوم عملیات رو دوباره اجرا کنه.

به همین دلیل، در قیمت‌بان که Pagination رو با Paging 3 پیاده می‌کنیم، Infinite Scroll بهتره نتیجه طبیعی همون معماری باشه، نه یک سیستم دستی و جداگانه.

Append چیست؟

وقتی کاربر پایین میره و صفحه بعدی به انتهای لیست اضافه میشه، Paging این عملیات رو Append می‌نامه. فرض کن الان محصولات ۱ تا ۴۰ روی صفحه وجود دارن. کاربر نزدیک انتهای فهرست میشه و محصولات ۴۱ تا ۶۰ دریافت میشن. این داده‌ها جای محصولات قبلی رو نمی‌گیرن؛ به انتهای اون‌ها اضافه میشن.

این با Refresh فرق داره. Refresh کل جریان داده رو از نقطه شروع تازه می‌کنه، اما Append فقط ادامه داده‌های فعلی رو دریافت می‌کنه.

Paging سه وضعیت اصلی بارگذاری داره: refresh برای بارگذاری اصلی یا تازه‌سازی، append برای ادامه انتهای فهرست و prepend برای دریافت داده‌های قبل از ابتدای فعلی. در صفحه محصولات قیمت‌بان، Infinite Scroll بیشتر با وضعیت append سروکار داره.

Loading انتهای لیست

وقتی صفحه بعدی در حال دریافت شدنه، محصولات قبلی باید روی صفحه باقی بمونن. فقط یک Loading کوچک در انتهای لیست نمایش داده میشه:

if (products.loadState.append is LoadState.Loading) {
    item {
        AppendLoading()
    }
}

این بخش داخل LazyColumn قرار می‌گیره. دلیلش اینه که Loading باید بخشی از ادامه طبیعی لیست باشه، نه یک لایه تمام‌صفحه روی محصولات. اگر هنگام Append کل صفحه رو با Spinner بپوشونیم، کاربر نمی‌تونه محصولات قبلی رو ببینه و Infinite Scroll عملاً تبدیل به مجموعه‌ای از توقف‌های آزاردهنده میشه. مستندات رسمی Paging هم برای نمایش وضعیت Append، اضافه کردن یک آیتم Loading یا Error در Footer لیست رو پیشنهاد می‌کنن.

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

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

if (products.loadState.append is LoadState.Error) {
    item {
        AppendError(
            onRetry = products::retry
        )
    }
}

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

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

Retry با Refresh فرق داره

در Infinite Scroll، این دو عملیات نباید اشتباه گرفته بشن. retry آخرین عملیات ناموفق رو دوباره اجرا می‌کنه. اگر دریافت صفحه بعد شکست خورده، همون صفحه دوباره درخواست میشه.

اما refresh کل جریان صفحه‌بندی رو تازه می‌کنه:

products.refresh()

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

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

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

اما Paging وضعیت Append رو خودش نگه می‌داره. تا زمانی که بارگذاری فعلی ادامه داره، دوباره همون Append رو شروع نمی‌کنه. همچنین وقتی endOfPaginationReached اعلام بشه، درخواست جدیدی برای ادامه فهرست ساخته نمیشه.

پس نباید در Compose، علاوه بر سیستم Paging، دوباره یک isLoadingMore مستقل بسازیم و شماره صفحه رو دستی مدیریت کنیم. داشتن دو منبع جدا برای وضعیت بارگذاری، احتمال ناسازگاری رو بالا می‌بره. منبع اصلی وضعیت Infinite Scroll باید products.loadState.append باشه.

پایان واقعی لیست

اسم Infinite Scroll ممکنه این تصور رو ایجاد کنه که لیست واقعاً بی‌نهایته. اما بیشتر فهرست‌ها پایان دارن. مثلاً فروشگاه ممکنه ۲۳۷۰ محصول داشته باشه و بعد از دریافت آخرین صفحه، داده دیگری باقی نمونه.

وقتی API مشخص می‌کنه صفحه بعد وجود نداره، RemoteMediator یا PagingSource پایان Pagination رو اعلام می‌کنه. از اون لحظه، Paging دیگه Append جدیدی اجرا نمی‌کنه.

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

Infinite Scroll و Room

در معماری Offline First قیمت‌بان، صفحه بعدی مستقیماً از API وارد Compose نمیشه. وقتی کاربر به انتهای داده‌های محلی نزدیک میشه، Paging از RemoteMediator می‌خواد ادامه اطلاعات رو دریافت کنه. RemoteMediator محصولات جدید رو داخل Room ذخیره می‌کنه و Room نتیجه بزرگ‌تر رو دوباره از طریق PagingSource منتشر می‌کنه.

بنابراین مسیر واقعی چنین است:

اسکرول کاربر → نیاز به Append → RemoteMediator → API → Room → PagingSource → Compose

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

کاربر سریع اسکرول کنه چه میشه؟

ممکنه کاربر با یک حرکت سریع چندین صفحه پایین بره. Paging سعی می‌کنه براساس نیاز UI و تنظیمات PagingConfig داده‌های لازم رو آماده کنه، اما سرعت واقعی دریافت همچنان به اینترنت، سرور و حجم پاسخ بستگی داره.

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

همچنین اندازه آیتم‌های Placeholder باید به ابعاد واقعی ProductItem نزدیک باشه. اگر Placeholderها بیش از حد کوتاه باشن، صفحه سریع پر نمیشه و RemoteMediator ممکنه چندین بار پشت‌سرهم داده دریافت کنه تا فضای صفحه کامل بشه. مستندات Compose درباره این رفتار هشدار داده‌ان.

Infinite Scroll و جستجو

فرض کن کاربر در حال دیدن تمام محصولات بوده و چند صفحه دریافت شده. بعد داخل Search می‌نویسه شیر. این یک جریان Infinite Scroll تازه است.

نباید محصولات جستجوی شیر به ادامه فهرست قبلی وصل بشن. با تغییر Query، flatMapLatest جریان Paging قبلی رو کنار می‌ذاره و Pager تازه‌ای برای شرایط جدید ساخته میشه:

val products =
    query
        .debounce(300)
        .distinctUntilChanged()
        .flatMapLatest { queryValue ->
            repository.pagedProducts(
                query = queryValue.trim()
            )
        }
        .cachedIn(viewModelScope)

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

Infinite Scroll و مرتب‌سازی

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

پس Query و Sort باید با هم هویت جریان Pagination رو بسازن:

val products =
    combine(
        query.debounce(300),
        sort
    ) { queryValue, sortValue ->
        queryValue.trim() to sortValue
    }.flatMapLatest { (queryValue, sortValue) ->
        repository.pagedProducts(
            query = queryValue,
            sort = sortValue
        )
    }.cachedIn(viewModelScope)

هر بار جستجو یا مرتب‌سازی تغییر کنه، Infinite Scroll از شرایط جدید آغاز میشه. Remote Keys و Cache شبکه هم باید این تفاوت رو بشناسن تا صفحات دو جریان مختلف با هم مخلوط نشن.

بازگشت از صفحه جزئیات محصول

فرض کن کاربر تا محصول شماره ۱۸۰ پایین رفته، وارد جزئیات یک محصول شده و بعد برگشته. انتظار طبیعی اینه که دوباره همون حوالی لیست قرار بگیره، نه اینکه از محصول اول شروع کنه.

cachedIn(viewModelScope) کمک می‌کنه Snapshot صفحه‌بندی‌شده در طول عمر ViewModel حفظ بشه. همچنین LazyListState باید در سطح مناسب نگهداری بشه تا موقعیت اسکرول از بین نره.

بخش اصلی ساخت State لیست اینه:

val listState = rememberLazyListState()

این State به LazyColumn داده میشه. در ساختار Navigation مناسب، با باقی موندن Back Stack و ViewModel، موقعیت اسکرول معمولاً قابل حفظه. اگر صفحه کاملاً از Navigation خارج یا Process برنامه نابود بشه، ممکنه برای بازیابی دقیق‌تر به rememberSaveable یا نگهداری شناسه آیتم فعلی نیاز داشته باشیم.

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

آیا با پایین رفتن باید صفحات قبلی از حافظه حذف بشن؟

اگر کاربر صدها صفحه پایین بره، نگه داشتن تمام داده‌های UI در حافظه می‌تونه سنگین بشه. Paging با تنظیماتی مثل maxSize می‌تونه تعداد آیتم‌هایی رو که در Snapshot حافظه نگه داشته میشن محدود کنه:

PagingConfig(
    pageSize = 20,
    prefetchDistance = 5,
    maxSize = 200
)

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

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

Refresh هنگام حضور در میانه لیست

وقتی کاربر در میانه Infinite Scroll قرار داره و Refresh انجام میده، ترتیب و محتوای داده ممکنه تغییر کنه. محصول جدیدی شاید بالای لیست اضافه شده باشه یا بعضی قیمت‌ها عوض شده باشن. Paging با استفاده از getRefreshKey یا اطلاعات PagingState تلاش می‌کنه نقطه مناسبی برای Refresh پیدا کنه تا کاربر بی‌دلیل به ابتدای لیست پرتاب نشه. در معماری Room و RemoteMediator نیز Refresh باید به شکلی طراحی بشه که Cache تازه بشه، اما تجربه اسکرول تا جای ممکن پایدار بمونه.

نباید هنگام هر Refresh به‌صورت دستی scrollToItem(0) اجرا کنیم، مگر اینکه تصمیم محصول این باشه که کاربر حتماً باید جدیدترین آیتم‌ها رو از بالا ببینه.

چه زمانی Infinite Scroll مناسب نیست؟

Infinite Scroll برای مرور پیوسته محصولات عالیه، اما در همه موقعیت‌ها بهترین انتخاب نیست.

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

همچنین در صفحه‌ای که Footer مهمی داره، Infinite Scroll می‌تونه دسترسی به Footer رو سخت کنه، چون با رسیدن کاربر به پایین، داده‌های جدید اضافه میشن.

اما در قیمت‌بان، کاربر معمولاً دنبال مرور، جستجو و پیدا کردن سریع محصوله. او اهمیتی نمیده در صفحه ۷ قرار داره؛ فقط می‌خواد لیست بدون توقف ادامه پیدا کنه. بنابراین Infinite Scroll برای صفحه محصولات انتخاب مناسبیه.

Infinite Scroll باید پایان و خطا داشته باشه

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

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

Paging 3 این وضعیت‌ها رو در loadState قرار میده و UI فقط باید هر کدوم رو در جای درست نمایش بده.

مسئولیت هر بخش

Compose حرکت کاربر رو از طریق دسترسی به آیتم‌های Lazy list منعکس می‌کنه، محصولات فعلی رو نمایش میده و براساس append، Loading یا Retry انتهای لیست رو می‌سازه. Paging 3 تشخیص میده چه زمانی داده‌های بیشتری لازم هستن، جلوی درخواست‌های تکراری رو می‌گیره و وضعیت Refresh، Append و پایان داده رو نگه می‌داره.

ViewModel جریان PagingData رو دریافت و در محدوده خودش Cache می‌کنه، اما شماره صفحه یا موقعیت آخرین آیتم رو دستی مدیریت نمی‌کنه. Repository، Pager و PagingConfig رو می‌سازه و Query، Sort و Category رو وارد هویت جریان Pagination می‌کنه.

RemoteMediator صفحه بعدی رو از API دریافت و داخل Room ذخیره می‌کنه. Room همچنان منبع اصلی نمایش محصولات باقی می‌مونه.

Infinite Scroll فقط حذف دکمه بعدی نیست

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

مهم‌ترین نکته این درس اینه که Infinite Scroll یک رفتار UI روی معماری Pagination است. صفحه نباید خودش شماره صفحات رو مدیریت کنه. Compose فقط داده‌های Paging رو نمایش میده و Paging 3 براساس دسترسی کاربر به آیتم‌های نزدیک انتهای لیست، ادامه داده‌ها رو درخواست می‌کنه.

در نهایت، مسیر Infinite Scroll محصولات در قیمت‌بان رو میشه این‌طور خلاصه کرد:

کاربر در LazyColumn پایین میره، Paging 3 کمی قبل از رسیدن به انتهای داده‌های فعلی نیاز به صفحه بعد رو تشخیص میده، RemoteMediator محصولات جدید رو از API داخل Room ذخیره می‌کنه و Compose اون‌ها رو به انتهای فهرست اضافه می‌کنه؛ بدون دکمه صفحه بعد، بدون افزایش دستی شماره صفحه و بدون از بین رفتن محصولات قبلی.


درس سی‌وسوم

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

Refresh

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

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

در قیمت‌بان، مسیر Refresh در معماری Offline First به این شکله: کاربر Refresh رو اجرا می‌کنه، Paging یا ViewModel درخواست تازه‌سازی میده، Repository یا RemoteMediator آخرین اطلاعات رو از API می‌گیره، Room به‌روزرسانی میشه و UI نتیجه تازه رو از همون Room دریافت می‌کنه.

پس حتی هنگام Refresh هم API مستقیماً لیست جدید رو وارد Compose نمی‌کنه. منبع اصلی نمایش همچنان Room باقی می‌مونه.

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

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

مثلاً کاربر ساعت ۱۰ صبح فهرست محصولات رو باز کرده. ساعت ۱۰:۳۰ قیمت چند محصول روی سرور تغییر کرده و ساعت ۱۱ کاربر صفحه رو پایین می‌کشه. برنامه باید نسخه تازه رو دریافت و Room رو اصلاح کنه. بعد UI به‌صورت واکنشی قیمت‌های جدید رو نمایش میده.

تفاوت Refresh با اولین Loading

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

به بیان ساده، Loading اولیه میگه هنوز چیزی برای نمایش نداریم، اما Refresh میگه اطلاعات فعلی رو داریم و در حال بررسی نسخه جدیدتر هستیم.

تفاوت Refresh با Append

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

پس Append میگه ادامه داده‌ها رو بده، اما Refresh میگه نسخه فعلی این مجموعه رو دوباره بررسی کن. همین تفاوت باعث میشه وضعیت Loading و Error اون‌ها هم جدا باشه. خطای Append باید در انتهای لیست نمایش داده بشه، اما خطای Refresh معمولاً مربوط به تازه‌سازی کل مجموعه است.

تفاوت Refresh با Retry

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

در Paging، این دو فراخوانی از هم جدا هستن:

products.retry()

این خط زمانی استفاده میشه که یک Load قبلی با خطا روبه‌رو شده و کاربر می‌خواد همان عملیات دوباره انجام بشه.

products.refresh()

این خط یک Refresh تازه روی داده‌های صفحه‌بندی‌شده شروع می‌کنه. Paging وضعیت refresh، append و prepend رو جدا نگه می‌داره و Retry هم آخرین Load ناموفق رو هدف قرار میده.

Pull to Refresh

رایج‌ترین روش اجرای Refresh در موبایل اینه که کاربر وقتی در ابتدای لیست قرار داره، صفحه رو به سمت پایین بکشه. به این رفتار Pull to Refresh میگن.

در Compose، PullToRefreshBox به‌عنوان یک Container دور محتوای قابل اسکرول قرار می‌گیره. دو ورودی اصلی اون isRefreshing و onRefresh هستن؛ اولی مشخص می‌کنه تازه‌سازی در حال اجراست یا نه و دومی زمانی اجرا میشه که کاربر Refresh رو آغاز می‌کنه.

بخش اصلی اتصال اون به لیست به این شکله:

PullToRefreshBox(
    isRefreshing = isRefreshing,
    onRefresh = onRefresh
) {
    LazyColumn {
        // محصولات
    }
}

این کد داخل لایه Compose قرار می‌گیره. PullToRefreshBox فقط حرکت کاربر و Indicator رو مدیریت می‌کنه. خودش نباید API رو صدا بزنه یا Room رو تغییر بده. onRefresh هم بهتره یک Callback باشه که از بیرون دریافت شده، نه اینکه Composable مستقیماً Repository رو بشناسه.

isRefreshing از کجا میاد؟

UI نباید خودش حدس بزنه Refresh چه زمانی تموم شده. این وضعیت باید از عملیات واقعی داده بیاد.

وقتی از Paging 3 استفاده می‌کنیم، وضعیت Refresh داخل loadState.refresh وجود داره:

val isRefreshing =
    products.loadState.refresh is LoadState.Loading

این خط داخل بخش UI قرار می‌گیره، چون فقط وضعیت Paging رو به یک Boolean قابل استفاده برای PullToRefreshBox تبدیل می‌کنه. وقتی Refresh شروع میشه، مقدار true میشه و Indicator نمایش داده میشه. وقتی عملیات موفق یا ناموفق تموم میشه، از حالت Loading خارج میشه و Indicator هم بسته میشه.

نباید داخل Compose یک متغیر مستقل بسازیم، اون رو هنگام کشیدن صفحه true کنیم و بعد با Delay روی false بذاریم. Indicator باید به وضعیت واقعی بارگذاری وابسته باشه، نه یک انیمیشن ساختگی.

اتصال Pull to Refresh به Paging

اتصال اصلی بسیار ساده است:

PullToRefreshBox(
    isRefreshing =
        products.loadState.refresh is LoadState.Loading,
    onRefresh = products::refresh
) {
    ProductsList(products)
}

onRefresh = products::refresh یعنی وقتی کاربر صفحه رو پایین کشید، خود Paging جریان فعلی رو تازه‌سازی کنه.

این بخش داخل Composable صفحه محصولات قرار می‌گیره. در این معماری، ViewModel همچنان جریان PagingData رو فراهم می‌کنه، اما لازم نیست یک تابع جدا فقط برای صدا زدن refresh بسازه؛ چون LazyPagingItems این عملیات رو در سطح UI در اختیار داره. با این حال، اگر Refresh علاوه بر محصولات باید چند داده دیگر رو هم تازه کنه، ممکنه منطقی‌تر باشه Callback به ViewModel فرستاده بشه تا عملیات‌ها رو هماهنگ کنه.

آیا products.refresh مستقیماً API رو صدا می‌زنه؟

نه لزوماً. refresh فقط به سیستم Paging میگه نسل فعلی داده باید تازه بشه. اینکه بعد از اون چه منبعی وارد عمل بشه، به معماری Pager بستگی داره. اگر PagingSource مستقیم به API وصل باشه، درخواست شبکه از همون مسیر انجام میشه. اما در معماری Offline First قیمت‌بان، RemoteMediator درخواست شبکه رو مدیریت می‌کنه و نتیجه رو داخل Room می‌نویسه. UI همچنان داده رو از PagingSource مربوط به Room می‌خونه.

پس مسیر Refresh ما چنین است:

Pull to Refresh → Paging Refresh → RemoteMediator → API → Room → PagingSource → Compose

Refresh در RemoteMediator

وقتی عملیات LoadType.REFRESH وارد RemoteMediator میشه، باید مشخص کنیم از کجا دریافت داده شروع بشه. در ساده‌ترین مدل Page Number، معمولاً صفحه اول نقطه شروعه:

val page = when (loadType) {
    LoadType.REFRESH -> 1
    LoadType.APPEND -> nextPage()
    LoadType.PREPEND -> return endOfPagination()
}

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

اما Refresh همیشه به این معنی نیست که کل دیتابیس پاک بشه. اینکه صفحه اول دوباره دریافت بشه یک تصمیمه و اینکه داده‌های قبلی حذف بشن تصمیمی جداست.

آیا در Refresh باید Room رو پاک کنیم؟

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

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

بنابراین نباید بدون فکر بنویسیم:

if (loadType == LoadType.REFRESH) {
    productDao.clearAll()
}

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

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

Upsert هنگام Refresh

اگر سرور صفحه اول محصولات رو برگردونه، Repository یا RemoteMediator می‌تونه آیتم‌های تازه رو داخل Room درج یا به‌روزرسانی کنه:

productDao.upsertAll(
    response.items.map(ProductDto::toEntity)
)

upsertAll باعث میشه محصول جدید درج و محصول موجود به‌روزرسانی بشه. این عملیات در لایه Data انجام میشه. اما Upsert به‌تنهایی حذف‌های سرور رو تشخیص نمیده. اگر محصولی روی سرور حذف شده باشه، چون دیگه در پاسخ نیست، نسخه قدیمی اون ممکنه همچنان داخل Room باقی بمونه. برای حل این مسئله، Backend باید قرارداد مناسبی داشته باشه؛ مثلاً شناسه محصولات حذف‌شده رو برگردونه، زمان آخرین تغییر رو پشتیبانی کنه یا Refresh کامل یک Scope مشخص رو فراهم کنه.

Refresh کامل یا Incremental Sync؟

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

در Incremental Sync، برنامه به سرور میگه فقط تغییرات بعد از یک زمان یا نسخه مشخص رو برگردون. مثلاً محصولاتی که بعد از آخرین Sync اضافه، ویرایش یا حذف شدن. از نظر مفهومی درخواست می‌تونه به زمان آخرین هماهنگی وابسته باشه:

api.getProductChanges(
    updatedAfter = lastSyncedAt
)

این تابع در API یا Repository قرار می‌گیره و فقط تفاوت‌های جدید رو دریافت می‌کنه. برای نسخه اولیه قیمت‌بان، Refresh صفحه اول یا Refresh معمول Paging ساده‌تره. اما وقتی تعداد محصولات زیاد و تغییرات محدود باشن، Incremental Sync می‌تونه مصرف اینترنت و فشار سرور رو بسیار کمتر کنه.

Refresh در لیست صفحه‌بندی‌شده چه بلایی سر صفحات قبلی میاره؟

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

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

با این حال، حفظ دقیق موقعیت همیشه تضمین‌شده نیست؛ چون اگر داده‌ها یا ترتیب اون‌ها واقعاً تغییر کرده باشن، آیتم قبلی ممکنه جای دیگری قرار گرفته یا حذف شده باشه.

بعد از Refresh به بالای صفحه بریم یا همون‌جا بمونیم؟

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

اما اگر برنامه بخواد بعد از Refresh محصول جدید بالای لیست رو برجسته کنه، می‌تونه پیام کوچکی مثل ۳ محصول جدید اضافه شد نمایش بده و دکمه‌ای برای رفتن به بالا ارائه کنه. اجرای مستقیم scrollToItem(0) بعد از هر Refresh معمولاً تجربه خوبی نیست، مگر اینکه ماهیت صفحه مثل Feed خبری باشه و تصمیم محصول هم دقیقاً همین رفتار رو بخواد.

آیا Refresh باید جستجو و Sort رو پاک کنه؟

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

هویت جریان Paging همچنان از شرایط فعلی ساخته میشه:

repository.pagedProducts(
    query = currentQuery,
    sort = currentSort,
    categoryId = selectedCategoryId
)

این کد داخل ViewModel یا مسیر ساخت Pager قرار می‌گیره. Refresh فقط همین جریان رو دوباره بارگذاری می‌کنه؛ شرایط انتخاب‌شده کاربر تغییر نمی‌کنن.

Refresh و Cache

Room باعث میشه هنگام Refresh داده‌های قبلی همچنان در دسترس باشن. این ویژگی کمک می‌کنه برنامه حتی با اینترنت ضعیف هم صفحه رو خالی نکنه. اما Refresh نباید فقط به دلیل وجود Cache نادیده گرفته بشه. Cache ممکنه قدیمی باشه. بنابراین باید بین نمایش سریع داده قدیمی و تلاش برای دریافت داده تازه تفاوت قائل بشیم.

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

خطای Refresh وقتی داده قبلی داریم

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

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

تشخیص خطای Refresh در Paging

وضعیت Refresh می‌تونه Loading، Error یا NotLoading باشه.

بخش اصلی تصمیم‌گیری می‌تونه این‌طور باشه:

val refreshState = products.loadState.refresh

if (
    refreshState is LoadState.Error &&
    products.itemCount > 0
) {
    ShowRefreshError(refreshState.error)
}

این کد در لایه UI قرار می‌گیره. شرط itemCount > 0 میگه هنوز داده قبلی داریم، پس خطا باید غیرمسدودکننده نمایش داده بشه. اگر تعداد آیتم‌ها صفر باشه، همون خطا ممکنه به Error تمام‌صفحه تبدیل بشه.

نباید خطای Refresh دائماً تکرار بشه

Compose ممکنه چند بار Recompose بشه. اگر در بدنه Composable مستقیماً با دیدن LoadState.Error هر بار Snackbar نمایش بدیم، ممکنه یک خطا چند بار ظاهر بشه.

برای واکنش‌های یک‌باره بهتره از LaunchedEffect یا یک Event کنترل‌شده استفاده بشه:

LaunchedEffect(refreshState) {
    if (
        refreshState is LoadState.Error &&
        products.itemCount > 0
    ) {
        onRefreshError(refreshState.error)
    }
}

این بخش در Compose قرار می‌گیره و تغییر State رو به یک رویداد نمایشی تبدیل می‌کنه. onRefreshError می‌تونه Snackbar رو نمایش بده یا پیام مناسب رو به میزبان صفحه تحویل بده. هدف اینه که State پایدار با Event یک‌باره اشتباه گرفته نشه.

Refresh خودکار هنگام ورود به صفحه

گاهی لازم نیست کاربر دستی صفحه رو پایین بکشه. برنامه می‌تونه هنگام ورود بررسی کنه که Cache چقدر قدیمیه و در صورت نیاز Refresh رو خودکار شروع کنه. مثلاً اگر آخرین Sync چند دقیقه قبل بوده، داده Room مستقیم نمایش داده میشه و هیچ درخواست تازه‌ای لازم نیست. اگر چند ساعت گذشته، برنامه در پس‌زمینه Refresh رو شروع می‌کنه.

منطق تشخیص قدیمی شدن Cache داخل Repository یا RemoteMediator قرار می‌گیره، نه Compose:

val shouldRefresh =
    currentTime - lastSyncedAt > cacheDuration

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

جلوگیری از Refreshهای پیاپی

کاربر ممکنه چند بار پشت‌سرهم صفحه رو پایین بکشه. نباید برای هر حرکت یک درخواست تازه و هم‌زمان ایجاد بشه. وقتی isRefreshing از loadState.refresh گرفته میشه، خود UI می‌دونه عملیات در حال اجراست. Paging هم بارگذاری‌های خودش رو مدیریت می‌کنه و لازم نیست یک سیستم موازی برای قفل کردن Refresh بسازیم.

با این حال، اگه ViewModel چند منبع مختلف رو هم‌زمان Refresh می‌کنه، بهتره محافظ داخلی داشته باشه:

if (_uiState.value.isRefreshing) return

این شرط داخل ViewModel قرار می‌گیره و جلوی شروع دوباره عملیات هماهنگ‌شده رو می‌گیره.

اما وقتی فقط products.refresh رو صدا می‌زنیم، منبع اصلی وضعیت همون LoadState خود Paging است.

Refresh چند بخش صفحه

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

بهتره ViewModel یک عملیات هماهنگ‌کننده داشته باشه:

fun refresh() {
    refreshProducts()
    refreshSummary()
}

این کد داخل ViewModel قرار می‌گیره. هر تابع مسئول بخش خودش باقی می‌مونه، اما ViewModel می‌تونه یک رویداد کاربر رو به چند عملیات مرتبط تبدیل کنه. در چنین شرایطی isRefreshing هم ممکنه ترکیبی از وضعیت Paging و وضعیت Summary باشه. Indicator بهتره زمانی بسته بشه که عملیات اصلی مورد انتظار کاربر تمام شده باشه، نه فقط اولین درخواست.

Refresh موفق چه پیامی لازم داره؟

در بیشتر مواقع، بعد از Refresh موفق نیازی به نمایش پیام با موفقیت به‌روزرسانی شد نیست. بسته شدن Indicator و تغییر داده‌ها خودش بازخورد کافی میده. اما اگر هیچ تغییر ظاهری اتفاق نیفته، میشه زمان آخرین به‌روزرسانی رو نمایش داد یا در شرایط خاص پیام کوتاهی نشون داد.

نباید برای عملیاتی که کاربر بارها انجام میده، هر بار Snackbar موفقیت ایجاد کنیم. پیام موفقیت زمانی ارزش داره که نتیجه بدون اون مبهم باشه.

آخرین زمان به‌روزرسانی

نگهداری زمان آخرین Sync می‌تونه هم برای تصمیم‌گیری Cache و هم برای اطلاع کاربر مفید باشه:

data class SyncMetadata(
    val lastSuccessfulSyncAt: Long?
)

این مدل می‌تونه در یک جدول کوچک Room یا DataStore ذخیره بشه. مهمه که زمان فقط بعد از Sync موفق تغییر کنه. اگر Refresh شکست خورد، نباید lastSuccessfulSyncAt رو روی زمان فعلی بذاریم، چون در اون صورت برنامه به اشتباه تصور می‌کنه داده‌ها تازه هستن.

Refresh هنگام آفلاین بودن

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

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

Refresh و تغییرات محلی ارسال‌نشده

فرض کن کاربر در حالت آفلاین قیمت محصولی رو تغییر داده و این تغییر هنوز به سرور ارسال نشده. حالا Refresh انجام میشه. اگر نسخه سرور بدون توجه به تغییر محلی روی Room نوشته بشه، ممکنه کار کاربر از بین بره. اینجا Refresh وارد مسئله Sync Conflict میشه. داده‌های محلی ارسال‌نشده باید قابل تشخیص باشن؛ مثلاً با وضعیتی مثل PENDING_SYNC. هنگام دریافت نسخه سرور نباید این رکوردها کورکورانه بازنویسی بشن.

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

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

Refresh و حذف‌های سرور

Upsert محصولات جدید و ویرایش‌شده ساده است، اما حذف‌ها نیاز به قرارداد دارن. اگر محصول شماره ۵ روی سرور حذف شده، نبودنش در صفحه اول لزوماً ثابت نمی‌کنه باید از Room حذف بشه؛ شاید فقط در صفحه دیگری قرار گرفته باشه. برای حذف مطمئن، سرور می‌تونه شناسه‌های حذف‌شده رو در Sync Incremental برگردونه، فیلد deletedAt داشته باشه یا نسخه کامل یک Scope رو همراه با راهی برای تشخیص پایان ارائه بده.

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

مسئولیت هر بخش

Compose حرکت Pull to Refresh، Indicator و پیام خطا رو نمایش میده. همچنین refresh یا Callback مربوط به ViewModel رو صدا می‌زنه، اما خودش وارد API و Room نمیشه. Paging وضعیت Refresh رو نگه می‌داره و جریان صفحه‌بندی فعلی رو تازه می‌کنه. ViewModel در صورت نیاز Refresh چند بخش صفحه رو هماهنگ می‌کنه و Eventهای نمایشی رو مدیریت می‌کنه.

RemoteMediator یا Repository داده تازه رو از API دریافت می‌کنه، سیاست Cache و تعارض رو اعمال می‌کنه و Room رو به‌روزرسانی می‌کنه. Room اطلاعات قبلی رو تا رسیدن نسخه تازه حفظ می‌کنه و بعد از تغییر، PagingSource و UI رو به‌صورت واکنشی به‌روز می‌کنه.

Refresh پاک کردن و دانلود دوباره نیست

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

مهم‌ترین نکته این درس اینه که Refresh یعنی تازه‌سازی منبع داده، نه بازسازی دستی رابط کاربری. Compose فقط درخواست تازه‌سازی رو ارسال می‌کنه و وضعیت اون رو نمایش میده. Repository و RemoteMediator سرور و Room رو هماهنگ می‌کنن و UI به تغییر منبع اصلی داده واکنش نشون میده.

در نهایت، مسیر Refresh محصولات در قیمت‌بان رو میشه این‌طور خلاصه کرد:

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


بخش پنجم — قابلیت‌های حرفه‌ای

درس سی‌وچهارم

برنامه چطور حتی وقتی باز نیست، کاربر رو از تغییر قیمت، ثبت محصول جدید یا اتفاق‌های مهم باخبر می‌کنه؟

Notification

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

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

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

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

اعلان محلی و اعلان Push

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

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

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

Notification چه مسئله‌ای رو حل می‌کنه؟

اعلان باید برای اتفاقی استفاده بشه که ارزش قطع کردن توجه کاربر رو داشته باشه. هر تغییری نباید Notification تولید کنه.

برای مثال، این اتفاق‌ها می‌تونن اعلان مناسبی داشته باشن:

  • ثبت کسب‌وکار تأیید شده.
  • یک محصول نیاز به اصلاح داره.
  • قیمت ثبت‌شده رد یا تأیید شده.
  • موجودی یا قیمت محصول مهمی تغییر کرده.
  • یک عملیات طولانی در پس‌زمینه تکمیل شده.

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

Notification با پیام داخل برنامه فرق داره

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

Notification بیشتر زمانی ارزش داره که کاربر داخل برنامه نیست یا در صفحه دیگری قرار داره و باید از یک اتفاق مهم باخبر بشه. پس یک رویداد واحد ممکنه براساس وضعیت برنامه به دو شکل نمایش داده بشه:

وقتی برنامه در پیش‌زمینه است، UI به‌روزرسانی میشه یا پیام داخل برنامه نمایش داده میشه. وقتی برنامه در پس‌زمینه یا بسته است، Notification سیستم نمایش داده میشه.

Firebase Cloud Messaging

برای ارسال Push Notification در اندروید معمولاً از Firebase Cloud Messaging یا FCM استفاده میشه. FCM بین Backend و دستگاه کاربر قرار می‌گیره. سرور قیمت‌بان پیام رو به FCM می‌فرسته و FCM مسئول تحویل اون به اپلیکیشن روی دستگاه میشه.

مسیر کلی به این شکله:

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

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

Token دستگاه چیست؟

برای اینکه FCM بدونه پیام باید به کدوم نصب برنامه برسه، برای هر App Instance یک Registration Token تولید می‌کنه. این Token مثل آدرس موقت اون نصب از برنامه است.

نکته مهم اینه که Token معرف خود انسان نیست. یک کاربر ممکنه روی دو گوشی وارد حسابش شده باشه و هر دستگاه Token جدا داشته باشه. همچنین یک گوشی ممکنه بعد از حذف و نصب مجدد برنامه Token جدیدی بگیره.

پس Backend نباید فقط یک فیلد ساده مثل notificationToken داخل جدول User داشته باشه. بهتره دستگاه‌ها موجودیت جدا داشته باشن.

مثلاً از نظر مفهومی، هر رکورد دستگاه می‌تونه شامل این اطلاعات باشه:

  • شناسه کاربر
  • FCM Token
  • نوع سیستم‌عامل
  • نام یا مدل دستگاه
  • زمان آخرین فعالیت
  • فعال یا غیرفعال بودن Token

این ساختار اجازه میده یک کاربر چند دستگاه داشته باشه و هنگام خروج از حساب فقط Token همان دستگاه از حساب جدا بشه.

دریافت Token در اندروید

بعد از راه‌اندازی Firebase Messaging، می‌تونیم Token فعلی نصب رو دریافت کنیم:

FirebaseMessaging.getInstance().token
    .addOnSuccessListener { token ->
        viewModel.onNotificationTokenReceived(token)
    }

این کد باید در بخشی اجرا بشه که مسئول راه‌اندازی ارتباط Notification است، نه داخل هر Composable یا هر بار Recomposition.

خط مهم اینجا token است. این مقدار باید بعد از Login به Backend ارسال بشه تا سرور بدونه این دستگاه به کدوم حساب کاربری تعلق داره.

ViewModel خودش نباید Retrofit رو صدا بزنه. فقط Token رو به Repository میده:

fun onNotificationTokenReceived(token: String) {
    viewModelScope.launch {
        notificationRepository.registerDevice(token)
    }
}

Repository درخواست ثبت دستگاه رو می‌سازه و برای سرور می‌فرسته.

Token ثابت و دائمی نیست

نباید Token رو یک‌بار بگیریم و تصور کنیم تا ابد معتبره. FCM ممکنه Token رو تغییر بده؛ برای مثال بعد از نصب مجدد برنامه، بازیابی داده روی دستگاه جدید یا برخی تغییرات داخلی سرویس.

برای دریافت Token جدید، سرویسی که از FirebaseMessagingService ارث می‌بره می‌تونه onNewToken رو Override کنه:

override fun onNewToken(token: String) {
    super.onNewToken(token)

    deviceTokenSyncer.sync(token)
}

این کد داخل FirebaseMessagingService قرار می‌گیره. وظیفه‌اش اینه که وقتی Token تغییر کرد، نسخه تازه رو به سیستم Sync دستگاه تحویل بده.

اینجا بهتره سرویس مستقیماً تمام منطق Authentication، Retrofit و Error Handling رو پیاده نکنه. یک کلاس مثل DeviceTokenSyncer یا Repository می‌تونه مسئول هماهنگ کردن Token با Backend باشه.

اگر کاربر هنوز Login نکرده باشد

ممکنه FCM Token قبل از ورود کاربر ساخته بشه. در این حالت نمی‌تونیم اون رو به یک حساب خاص متصل کنیم.

یک طراحی مناسب اینه که Token موقتاً داخل DataStore نگهداری بشه. بعد از Login موفق، Session Manager یا Repository بررسی کنه آیا Token ثبت‌نشده‌ای وجود داره و اون رو برای Backend ارسال کنه.

پس دو اتفاق مستقل داریم:

  • FCM برای این نصب Token می‌سازه.
  • کاربر داخل قیمت‌بان Login می‌کنه.

فقط وقتی هر دو اتفاق رخ داده باشن، Backend می‌تونه رابطه بین User و Device رو ثبت کنه.

Logout و Notification

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

قبل از پاک کردن Session، برنامه می‌تونه به Backend بگه Token فعلی از این کاربر جدا بشه:

suspend fun logout() {
    notificationRepository.unregisterCurrentDevice()
    sessionRepository.clearSession()
    database.clearUserData()
}

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

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

دریافت پیام با FirebaseMessagingService

برای دریافت و کنترل پیام‌ها، یک Service می‌سازیم که از FirebaseMessagingService ارث می‌بره. این سرویس می‌تونه onMessageReceived و onNewToken رو دریافت کنه.

بخش متمایز کلاس چنین است:

class PriceBanMessagingService :
    FirebaseMessagingService() {

    override fun onMessageReceived(
        message: RemoteMessage
    ) {
        notificationHandler.handle(message)
    }

    override fun onNewToken(token: String) {
        deviceTokenSyncer.sync(token)
    }
}

این سرویس نباید به یک فایل چندصدخطی تبدیل بشه. فقط پیام رو دریافت می‌کنه و اون رو به کلاس مسئول تحلیل و ساخت Notification تحویل میده.

notificationHandler مشخص می‌کنه پیام چه نوعیه، داده‌های لازم رو استخراج می‌کنه و Notification مناسب رو می‌سازه.

معرفی Service در Manifest

سرویس Firebase باید در Manifest معرفی بشه:

<service
    android:name=".notification.PriceBanMessagingService"
    android:exported="false">

    <intent-filter>
        <action
            android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>

</service>

exported="false" یعنی سایر برنامه‌ها نمی‌تونن مستقیماً این Service رو اجرا کنن. MESSAGING_EVENT هم به Firebase میگه پیام‌های دریافتی باید به این سرویس تحویل داده بشن.

Notification Message و Data Message

FCM می‌تونه پیام‌ها رو در شکل‌های متفاوتی ارسال کنه. دو نوع مهم، Notification Message و Data Message هستن.

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

Data Message شامل مجموعه‌ای از داده‌هاست و برنامه خودش تصمیم می‌گیره با اون چه کاری انجام بده. مثلاً پیام می‌تونه بگه نوع رویداد PRICE_REJECTED است و شناسه محصول ۸۲۴ است. برنامه بر اساس این اطلاعات عنوان، متن، مسیر کلیک و رفتار مناسب رو می‌سازه.

رفتار FCM براساس نوع پیام و وضعیت Foreground یا Background فرق می‌کنه. برای Notification Message در پس‌زمینه، سیستم معمولاً اعلان رو مستقیماً به System Tray می‌فرسته؛ اما Data Message برای پردازش به onMessageReceived تحویل داده میشه. پیام ترکیبی هم در پس‌زمینه Notification رو به Tray می‌فرسته و Data رو در Intent صفحه آغازین قرار میده.

برای قیمت‌بان که به Deep Link، رفتارهای مختلف و کنترل دقیق نیاز داره، Data Message معمولاً انعطاف بیشتری میده.

Payload پیام باید کوچک و روشن باشد

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

مثلاً مدل داخلی پیام می‌تونه چنین باشه:

data class NotificationPayload(
    val type: NotificationType,
    val entityId: Long?,
    val title: String,
    val body: String
)

type نوع اتفاق رو مشخص می‌کنه، entityId شناسه موجودیت مرتبطه و عنوان و متن برای نمایش استفاده میشن.

اگر اعلان مربوط به محصول باشه، entityId شناسه محصوله. اگر مربوط به کسب‌وکار باشه، می‌تونه شناسه همون کسب‌وکار باشه.

اطلاعات کامل بعد از باز شدن برنامه از Room یا API گرفته میشه. این کار هم Payload رو سبک نگه می‌داره و هم جلوی نمایش اطلاعات قدیمی داخل اعلان رو می‌گیره.

نوع اعلان را با String خام پخش نکنیم

ممکنه Backend مقدارهایی مثل product_updated یا business_approved بفرسته. نباید این Stringها رو در بخش‌های مختلف برنامه دستی مقایسه کنیم.

بهتره اون‌ها در یک Mapper به نوع داخلی تبدیل بشن:

enum class NotificationType {
    BUSINESS_APPROVED,
    PRICE_REJECTED,
    PRODUCT_UPDATED,
    GENERAL
}

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

Notification Channel

از Android 8 به بعد، هر Notification باید داخل یک Notification Channel قرار بگیره. Channel به کاربر اجازه میده نوع‌های مختلف اعلان یک برنامه رو جداگانه کنترل کنه؛ مثلاً اعلان‌های مهم رو روشن نگه داره ولی اعلان‌های تبلیغاتی رو خاموش کنه.

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

  • تغییرات محصولات و قیمت‌ها
  • وضعیت کسب‌وکار
  • اعلان‌های عمومی

نباید برای هر اعلان یک Channel تازه بسازیم. Channel نماینده یک دسته پایدار از اعلان‌هاست.

ساخت Channel باید یک‌بار هنگام راه‌اندازی برنامه انجام بشه:

val channel = NotificationChannel(
    "price_updates",
    "تغییرات قیمت‌ها",
    NotificationManager.IMPORTANCE_DEFAULT
)

notificationManager.createNotificationChannel(channel)

شناسه "price_updates" باید ثابت بمونه، چون سیستم تنظیمات کاربر رو براساس همین شناسه نگه می‌داره. نام فارسی Channel قابل نمایش به کاربره و می‌تونه توضیح واضح‌تری داشته باشه.

Importance یعنی میزان مزاحمت

هنگام ساخت Channel باید میزان اهمیت رو تعیین کنیم. اهمیت بیشتر می‌تونه باعث صدای بیشتر، نمایش برجسته‌تر یا Heads-up Notification بشه.

همه اعلان‌ها نباید IMPORTANCE_HIGH داشته باشن. مثلاً تأیید یا رد یک عملیات مهم ممکنه اهمیت بالاتری داشته باشه، اما تغییر عادی یک محصول شاید با اهمیت Default کافی باشه.

نکته مهم اینه که بعد از ساخته شدن Channel، کنترل اصلی اون در اختیار کاربر و تنظیمات سیستم قرار می‌گیره. برنامه نباید دائماً تلاش کنه انتخاب کاربر رو دور بزنه.

Permission اعلان در Android 13

از Android 13 یا API 33 به بعد، برنامه برای ارسال بیشتر اعلان‌ها باید مجوز Runtime با نام POST_NOTIFICATIONS بگیره. در نصب‌های جدید روی Android 13 به بالا، اعلان‌ها به‌صورت پیش‌فرض خاموش هستن تا زمانی که برنامه مجوز رو درخواست کنه و کاربر اجازه بده.

ابتدا Permission داخل Manifest تعریف میشه:

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

اما این خط فقط Permission رو معرفی می‌کنه. گرفتن اجازه واقعی روی Android 13 به بالا باید در زمان اجرا انجام بشه.

Permission رو همان ابتدای برنامه نخواهیم

بدترین زمان برای درخواست Notification Permission، اولین ثانیه ورود به برنامه است؛ زمانی که کاربر هنوز نمی‌دونه قیمت‌بان چه نوع اعلان مفیدی براش ارسال می‌کنه.

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

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

بعد از این توضیح، درخواست سیستمی نمایش داده میشه.

Android توصیه می‌کنه Permission زمانی درخواست بشه که کاربر با قابلیت برنامه آشنا شده و دلیل نیاز رو درک می‌کنه.

درخواست Permission در Compose

مثل Permissionهای قبلی، از Activity Result API استفاده می‌کنیم:

val notificationPermissionLauncher =
    rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        viewModel.onNotificationPermissionResult(granted)
    }

هنگام اقدام مناسب کاربر:

notificationPermissionLauncher.launch(
    Manifest.permission.POST_NOTIFICATIONS
)

این کد فقط مسئول نمایش Permission Dialog است. ثبت Token، ساخت Channel یا ارسال Notification نباید داخل Callback Launcher انجام بشه.

برای نسخه‌های پایین‌تر از Android 13 نیازی به این Runtime Permission نیست، بنابراین قبل از درخواست باید نسخه سیستم بررسی بشه.

رد شدن Permission

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

می‌تونیم قابلیت اعلان رو غیرفعال نگه داریم و در بخش تنظیمات برنامه توضیح بدیم که اعلان‌ها خاموش هستن. اگر کاربر بعداً خواست، یک دکمه می‌تونه اون رو به تنظیمات Notification برنامه هدایت کنه.

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

بنابراین Token ثبت شده الزاماً به معنی اعلان قابل نمایش است نیست.

ساخت Notification

بعد از دریافت و تحلیل پیام، باید Notification واقعی ساخته بشه. برای سازگاری نسخه‌های مختلف اندروید، از NotificationCompat.Builder استفاده می‌کنیم:

val notification =
    NotificationCompat.Builder(
        context,
        "price_updates"
    )
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle(payload.title)
        .setContentText(payload.body)
        .setAutoCancel(true)
        .setContentIntent(pendingIntent)
        .build()

شناسه Channel باید با Channel ساخته‌شده هماهنگ باشه.

setSmallIcon ضروریه و باید از آیکون مناسب Notification استفاده بشه. setAutoCancel(true) باعث میشه بعد از لمس اعلان، از پنل اعلان‌ها پاک بشه. setContentIntent هم مشخص می‌کنه لمس Notification چه عملی انجام بده.

خود Builder بهتره داخل کلاسی مثل NotificationFactory قرار بگیره. سرویس Firebase فقط Payload رو تحویل میده و Factory ظاهر Notification رو می‌سازه.

Small Icon با آیکون اصلی برنامه فرق دارد

آیکون کوچک Notification معمولاً باید یک شکل ساده و تک‌رنگ با پس‌زمینه شفاف باشه. استفاده مستقیم از لوگوی رنگی کامل برنامه ممکنه در Status Bar خروجی نامناسبی ایجاد کنه.

بهتره یک Drawable اختصاصی مثل ic_notification داشته باشیم که در اندازه کوچک هم خوانا باشه.

Firebase هم اجازه میده آیکون و رنگ پیش‌فرض اعلان‌ها در Manifest تعریف بشن؛ این مقادیر وقتی استفاده میشن که پیام ورودی آیکون یا رنگ اختصاصی تعیین نکرده باشه. (Firebase)

Notification ID

برای نمایش Notification از NotificationManager استفاده می‌کنیم:

NotificationManagerCompat
    .from(context)
    .notify(notificationId, notification)

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

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

پس ID فقط یک عدد تصادفی نیست؛ بخشی از سیاست نمایش اعلان‌هاست.

Deep Link

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

مثلاً مقصد می‌تونه صفحه جزئیات محصول شماره ۸۲۴ باشه.

Intent مقصد، شناسه موجودیت رو همراه خودش داره:

val intent = Intent(
    context,
    MainActivity::class.java
).apply {
    putExtra("notification_type", payload.type.name)
    putExtra("entity_id", payload.entityId)
}

بعد Intent به PendingIntent تبدیل میشه:

val pendingIntent = PendingIntent.getActivity(
    context,
    notificationId,
    intent,
    PendingIntent.FLAG_UPDATE_CURRENT or
        PendingIntent.FLAG_IMMUTABLE
)

PendingIntent اجازه میده سیستم بعداً، هنگام لمس اعلان، Intent تعریف‌شده توسط برنامه رو اجرا کنه.

اما MainActivity نباید خودش با تعداد زیادی if تصمیم بگیره کدوم صفحه باز بشه. بهتره Intent به یک مدل Navigation تبدیل بشه و Navigation Coordinator یا NavHost مقصد رو باز کنه.

Notification نباید داده کامل صفحه را حمل کند

فرض کن اعلان میگه قیمت محصول شماره ۸۲۴ رد شده. وقتی کاربر اعلان رو لمس می‌کنه، برنامه فقط شناسه ۸۲۴ رو از Intent می‌گیره و صفحه جزئیات محصول داده تازه رو از Repository می‌خونه.

نباید کل Product رو به شکل JSON داخل Intent قرار بدیم. ممکنه اطلاعات اعلان قدیمی شده باشه، اندازه Payload زیاد بشه یا ساختار مدل بین نسخه‌های برنامه تغییر کنه.

قاعده همونیه که در Navigation محصولات داشتیم:

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

برنامه بسته باشد چه اتفاقی می‌افتد؟

لمس Notification ممکنه زمانی اتفاق بیفته که برنامه کاملاً بسته است. در این حالت MainActivity از ابتدا ساخته میشه، Session بررسی میشه و بعد Navigation باید مقصد درست رو باز کنه.

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

پس Deep Link باید با Authentication هماهنگ باشه:

  • اگر Session معتبره، مقصد اعلان باز میشه.
  • اگر Session وجود نداره، Login باز میشه.
  • بعد از Login، مقصد معوق اجرا میشه.

این رفتار بخشی از Navigation Architecture است، نه وظیفه Notification Builder.

برنامه در Foreground باشد چه اتفاقی می‌افتد؟

وقتی برنامه باز و فعال است، onMessageReceived می‌تونه پیام رو تحویل بگیره.

اما لزوماً نباید فوراً Notification سیستم بسازیم. فرض کن کاربر همین حالا داخل صفحه محصول شماره ۸۲۴ قرار داره. نمایش یک اعلان جدا درباره همون محصول ممکنه تکراری باشه.

NotificationHandler می‌تونه براساس وضعیت برنامه تصمیم بگیره:

  • فقط Room رو Sync کنه.
  • Event داخل برنامه منتشر کنه.
  • Snackbar نمایش بده.
  • یا اگر رویداد خیلی مهمه، Notification سیستم هم بسازه.

برای تشخیص Foreground یا Background میشه یک App Visibility Tracker داشت، اما نباید Activity یا Composable رو مستقیم داخل Firebase Service نگه داریم.

Notification جای Sync را نمی‌گیرد

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

مثلاً Payload میگه محصول ۸۲۴ تغییر کرده. برنامه می‌تونه بعد از دریافت پیام، Repository رو وادار کنه اطلاعات تازه اون محصول رو از API بگیره و داخل Room ذخیره کنه.

مسیر مناسب اینه:

FCM پیام تغییر محصول رو می‌رسونه، Repository اطلاعات تازه رو دریافت می‌کنه، Room به‌روزرسانی میشه و اگر کاربر داخل برنامه باشه UI خودکار تغییر می‌کنه. اگر برنامه در پس‌زمینه باشه، Notification هم نمایش داده میشه.

پس Notification و Sync دو مسئولیت جدا دارن:

Notification کاربر رو باخبر می‌کنه و Sync منبع داده برنامه رو تازه می‌کنه.

کار طولانی داخل onMessageReceived انجام ندهیم

onMessageReceived زمان محدودی برای اجرا داره. پردازش‌های طولانی مثل دانلود تصویر بزرگ، Sync سنگین یا چند درخواست شبکه پشت‌سرهم نباید مستقیماً داخل اون انجام بشن. Firebase توصیه می‌کنه کارهایی که بیشتر از چند ثانیه طول می‌کشن با WorkManager ادامه پیدا کنن، چون محدودیت‌های اجرای پس‌زمینه ممکنه پردازش طولانی Service رو متوقف کنن. (Firebase)

مثلاً:

override fun onMessageReceived(
    message: RemoteMessage
) {
    if (requiresLongSync(message)) {
        notificationSyncScheduler.enqueue(message.data)
    } else {
        notificationHandler.handle(message)
    }
}

notificationSyncScheduler می‌تونه WorkManager رو برای ادامه کار زمان‌بندی کنه.

نکته اینه که Service فقط تصمیم سریع می‌گیره. کار سنگین رو خودش انجام نمیده.

عکس داخل Notification

بعضی اعلان‌ها می‌تونن تصویر بزرگ داشته باشن، اما دریافت تصویر از اینترنت ممکنه زمان‌بر باشه. نباید داخل onMessageReceived منتظر دانلود طولانی تصویر بمونیم.

اگر تصویر واقعاً برای اعلان ضروریه، WorkManager می‌تونه دانلود رو انجام بده و بعد Notification کامل رو بسازه. اما برای بیشتر اعلان‌های مدیریتی قیمت‌بان، عنوان، متن و آیکون ساده کافی هستن.

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

اعلان‌های تکراری

گاهی یک رویداد ممکنه چند بار از سرور ارسال بشه یا اپلیکیشن بعد از Retry دوباره همون پیام رو پردازش کنه. اگر هر بار Notification تازه‌ای بسازیم، کاربر چند اعلان یکسان می‌بینه.

بهتره هر پیام یک eventId یا notificationId پایدار داشته باشه. برنامه می‌تونه این شناسه رو داخل Room ثبت کنه و قبل از نمایش بررسی کنه آیا قبلاً پردازش شده یا نه.

مدل داخلی می‌تونه چنین فیلدی داشته باشه:

data class NotificationPayload(
    val eventId: String,
    val type: NotificationType,
    val entityId: Long?,
    val title: String,
    val body: String
)

این شناسه باید از Backend بیاد، چون Backend منبع اصلی رویداده. تولید یک UUID تازه در گوشی نمی‌تونه پیام تکراری رو تشخیص بده.

Notification Inbox

اعلان سیستم ممکنه توسط کاربر پاک بشه یا اصلاً به دلیل Permission نمایش داده نشه. اگر اعلان‌ها برای جریان کاری قیمت‌بان مهم هستن، بهتره علاوه بر Push Notification یک صفحه اعلان‌ها داخل خود برنامه هم داشته باشیم.

Backend هر اعلان مهم رو داخل دیتابیس خودش ثبت می‌کنه. Push فقط کاربر رو باخبر می‌کنه. صفحه اعلان‌ها هم لیست کامل رویدادها، وضعیت خوانده‌شده و مقصد هر مورد رو از API یا Room نمایش میده.

در این معماری، FCM منبع اصلی اعلان‌ها نیست. اگر Push گم بشه، کاربر همچنان بعداً می‌تونه اعلان رو داخل Inbox برنامه ببینه.

این جداسازی بسیار مهمه:

  • Push Notification یک روش تحویل فوریه.
  • Notification Inbox سابقه پایدار اتفاق‌هاست.

خوانده‌شده و دیده‌شده

در صفحه اعلان‌های داخلی، بهتره بین تحویل داده‌شده، دیده‌شده و خوانده‌شده تفاوت قائل بشیم.

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

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

فقط پاک شدن Notification از System Tray به معنی خوانده شدن نیست.

Topic Messaging

FCM امکان ارسال پیام به Topic رو هم داره. مثلاً همه کاربران علاقه‌مند به اعلان‌های عمومی می‌تونن عضو Topic مشخصی باشن و سرور یک پیام رو برای همه اعضای اون Topic بفرسته.

اما Topic برای اطلاعات خصوصی مناسب نیست. نباید مثلاً همه صاحبان یک کسب‌وکار رو با Topic عمومی مدیریت کنیم و اطلاعات حساس رو در پیام قرار بدیم.

برای اعلان‌های شخصی مثل رد شدن قیمت یک محصول یا تأیید کسب‌وکار، ارسال به Device Tokenهای مرتبط امن‌تر و دقیق‌تره. Topic بیشتر برای پیام‌های عمومی و غیرحساس مناسبه.

تنظیمات اعلان داخل برنامه

کاربر باید بتونه مشخص کنه چه نوع اعلان‌هایی رو می‌خواد. مثلاً ممکنه اعلان‌های مربوط به رد قیمت رو ضروری بدونه، اما اعلان‌های عمومی رو نخواد.

صفحه تنظیمات قیمت‌بان می‌تونه گزینه‌هایی مثل این داشته باشه:

  • تغییرات قیمت‌ها
  • وضعیت محصولات
  • وضعیت کسب‌وکار
  • اطلاع‌رسانی‌های عمومی

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

Notification Channelهای اندروید هم سطح دیگری از کنترل رو در اختیار کاربر می‌ذارن. بنابراین دو سطح تنظیم داریم:

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

اطلاعات حساس روی Lock Screen

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

مثلاً به جای نمایش اطلاعات کامل مالی یا جزئیات خصوصی، می‌تونیم بنویسیم:

وضعیت یکی از قیمت‌های ثبت‌شده تغییر کرد.

بعد کاربر برای دیدن جزئیات وارد برنامه میشه و Authentication انجام میشه.

میزان جزئیات اعلان باید براساس حساسیت داده و سیاست حریم خصوصی محصول تعیین بشه.

معماری پیشنهادی Notification در قیمت‌بان

برای جلوگیری از شلوغ شدن Service و ViewModel، می‌تونیم مسئولیت‌ها رو این‌طور جدا کنیم.

PriceBanMessagingService پیام Firebase و Token جدید رو دریافت می‌کنه.

NotificationPayloadParser داده خام پیام رو به مدل داخلی قابل اعتماد تبدیل می‌کنه.

NotificationHandler تصمیم می‌گیره پیام نیاز به Sync، اعلان سیستم یا Event داخل برنامه داره.

NotificationFactory ظاهر Notification، Channel، Icon و PendingIntent رو می‌سازه.

NotificationRepository ثبت و حذف Device Token، دریافت Inbox و ثبت Read Status رو مدیریت می‌کنه.

NotificationSyncWorker کارهای طولانی یا قابل Retry رو در پس‌زمینه انجام میده.

ViewModelهای صفحه‌ها فقط داده‌های Notification Inbox یا تنظیمات اعلان رو در اختیار UI قرار میدن. اون‌ها نباید مسئول دریافت مستقیم پیام FCM باشن.

جریان دریافت اعلان قیمت

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

Backend ابتدا وضعیت قیمت رو در دیتابیس تغییر میده. بعد یک Notification Event با نوع PRICE_REJECTED برای صاحب کسب‌وکار می‌سازه. سرور Tokenهای فعال اون کاربر رو پیدا می‌کنه و پیام رو از طریق FCM ارسال می‌کنه.

روی گوشی، FirebaseMessagingService پیام رو دریافت می‌کنه. Parser نوع پیام و شناسه محصول رو استخراج می‌کنه. Repository در صورت نیاز اطلاعات محصول رو Sync می‌کنه و NotificationFactory اعلانی با متن مناسب می‌سازه.

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

این جریان نشون میده Notification فقط آخرین حلقه یک رویداد کسب‌وکاره. اصل اتفاق روی Backend رخ داده و اعلان فقط اون رو به کاربر می‌رسونه.

State اعلان با State صفحه فرق دارد

Notification سیستم چیزی نیست که داخل ProductsUiState یا DashboardUiState نگه داریم. ممکنه اعلان زمانی برسه که هیچ‌کدوم از این صفحه‌ها وجود ندارن.

اعلان یک قابلیت سراسری برنامه است و باید مستقل از Screenهای مشخص مدیریت بشه.

البته نتیجه اعلان می‌تونه Room رو تغییر بده و بعد State صفحه‌ها به‌روزرسانی بشه. مثلاً پیام PRODUCT_UPDATED باعث Sync محصول میشه و چون Room تغییر کرده، صفحه محصولات هم نسخه تازه رو نمایش میده.

این ارتباط از طریق منبع داده مشترک انجام میشه، نه اینکه Notification Service مستقیماً ViewModel صفحه رو پیدا کنه.

Notification تضمین صددرصدی نیست

Push Notification نباید تنها مکانیزم اجرای عملیات حیاتی باشه. ممکنه دستگاه مدت زیادی آفلاین باشه، Token منقضی بشه، اعلان توسط سیستم محدود بشه یا کاربر Permission رو خاموش کرده باشه.

حتی FCM در بعضی شرایط ممکنه پیام‌های معوق رو حذف کنه؛ مستندات رسمی پیشنهاد می‌کنن در چنین حالتی برنامه یک Sync کامل با سرور انجام بده. (Firebase)

بنابراین Backend نباید تصور کنه چون Push ارسال شده، کاربر حتماً پیام رو دیده. اطلاعات مهم باید داخل دیتابیس سرور و در صورت نیاز Notification Inbox ذخیره بشن.

مسئولیت هر بخش

Backend تشخیص میده چه اتفاقی ارزش اعلان داره، مخاطب رو پیدا می‌کنه، تنظیمات اعلان کاربر رو بررسی می‌کنه و پیام رو برای FCM می‌فرسته.

FCM پیام رو به نصب مناسب برنامه تحویل میده.

FirebaseMessagingService پیام یا Token جدید رو دریافت می‌کنه و سریع اون رو به کلاس مسئول تحویل میده.

NotificationHandler نوع پیام رو تشخیص میده و تصمیم می‌گیره Notification، Sync یا WorkManager لازم است.

NotificationFactory ظاهر اعلان و مقصد لمس اون رو می‌سازه.

Repository، Token دستگاه و سابقه اعلان‌ها رو با Backend هماهنگ می‌کنه.

Room می‌تونه Inbox اعلان‌ها، پیام‌های پردازش‌شده و داده‌های تازه‌شده رو نگه داره.

Compose فقط صفحه تنظیمات اعلان، Notification Inbox یا مقصدی رو نمایش میده که کاربر از طریق اعلان واردش شده.

Notification فقط یک پیام بالای گوشی نیست

در ظاهر، کاربر یک عنوان و متن کوتاه می‌بینه. اما پشت همین اعلان، چند سیستم با هم همکاری می‌کنن: Backend رویداد رو تشخیص میده، FCM پیام رو منتقل می‌کنه، Android Permission و Channel رو کنترل می‌کنه، Service پیام رو دریافت می‌کنه، Handler تصمیم مناسب رو می‌گیره و Navigation کاربر رو به صفحه درست می‌رسونه.

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

در نهایت، مسیر Notification در قیمت‌بان رو میشه این‌طور خلاصه کرد:

Backend بعد از یک رویداد مهم، پیام رو برای Tokenهای فعال کاربر به FCM می‌فرسته. اپلیکیشن پیام رو در FirebaseMessagingService دریافت می‌کنه، براساس نوع اون در صورت نیاز داده‌ها رو Sync می‌کنه، Notification مناسب رو داخل Channel درست نمایش میده و با لمس اعلان، کاربر رو با حفظ قواعد Authentication مستقیماً به بخش مرتبط برنامه هدایت می‌کنه.


35. Firebase Cloud Messaging

  • بخش ۱: مقدمه، فلسفه FCM، مشکل ارتباط مستقیم، معماری، Firebase Project، Token

درس سی‌وپنجم

Firebase Cloud Messaging (FCM)

چرا اصلاً به FCM احتیاج داریم؟

تا اینجا توی این کتاب تقریباً تمام ارتباط‌های برنامه با Backend یه ویژگی مشترک داشتن؛ همیشه برنامه درخواست رو شروع می‌کرد. مثلاً کاربر صفحه محصولات رو باز می‌کرد، برنامه یک درخواست به سرور می‌فرستاد و لیست محصولات رو می‌گرفت. یا کاربر دکمه Refresh رو می‌زد و برنامه دوباره اطلاعات جدید رو دانلود می‌کرد. حتی وقتی درباره Pagination یا Infinite Scroll صحبت کردیم، باز هم این خود برنامه بود که تصمیم می‌گرفت چه زمانی به Backend وصل بشه.

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

اینجاست که سؤال اصلی مطرح میشه. اگر برنامه کاملاً بسته باشه، یا اصلاً چند ساعت باشه که اجرا نشده، Backend چطوری باید این خبر رو به گوشی برسونه؟ آیا می‌تونه مستقیماً به دستگاه کاربر وصل بشه؟ جواب کوتاه اینه که تقریباً نه. و دقیقاً همینجاست که Firebase Cloud Messaging وارد داستان میشه.


چرا Backend نمی‌تونه مستقیم به گوشی وصل بشه؟

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

اول از همه، گوشی کاربر IP ثابتی نداره. ممکنه الان روی اینترنت خونه باشه، پنج دقیقه بعد روی اینترنت موبایل، یک ساعت بعد به Wi-Fi محل کار وصل بشه و شب دوباره روی یه شبکه دیگه قرار بگیره. بنابراین Backend اصلاً نمی‌دونه الان گوشی کجاست.

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

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

حالا این سؤال پیش میاد که پس تلگرام، واتساپ یا اینستاگرام چطوری این همه پیام رو لحظه‌ای دریافت می‌کنن؟ آیا برای هر برنامه یک اتصال دائمی به اینترنت باز میشه؟

اگر این اتفاق می‌افتاد، باتری گوشی ظرف چند ساعت خالی می‌شد. تصور کن روی گوشی هر کاربر ۵۰ برنامه نصب باشه و هر کدوم بخوان یک ارتباط دائمی با سرورشون نگه دارن. هم مصرف باتری وحشتناک می‌شد، هم مصرف اینترنت و هم حافظه دستگاه.

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


فلسفه اصلی FCM

ایده FCM خیلی هوشمندانه و در عین حال ساده است.

به جای اینکه هر برنامه یک اتصال دائمی با سرور خودش داشته باشه، فقط یک ارتباط دائمی بین گوشی و سرویس‌های گوگل برقرار میشه. این ارتباط توسط Google Play Services مدیریت میشه و همه برنامه‌هایی که از FCM استفاده می‌کنن، از همین ارتباط مشترک استفاده می‌کنن.

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

در نتیجه:

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

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


معماری FCM

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

اول، Backend متوجه میشه که یک اتفاق مهم افتاده. مثلاً قیمت یک محصول تغییر کرده. Backend خودش مستقیماً با گوشی صحبت نمی‌کنه، بلکه پیام رو برای Firebase Cloud Messaging ارسال می‌کنه.

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

اگر بخوایم این مسیر رو به شکل خیلی خلاصه نمایش بدیم، به این صورته:

Backend


Firebase Cloud Messaging


Google Play Services


Android Device


Your App

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


FCM جای Backend رو نمی‌گیره

یکی از اشتباه‌های رایج اینه که بعضی‌ها بعد از آشنا شدن با Firebase فکر می‌کنن دیگه Backend خیلی اهمیت نداره و گوگل همه کارها رو انجام میده. این برداشت کاملاً اشتباهه.

فرض کن قیمت یکی از محصولات تغییر کرده. این Backendـه که تشخیص میده این تغییر اتفاق افتاده، تصمیم می‌گیره چه کاربرهایی باید از این تغییر باخبر بشن، متن پیام چی باشه و آیا اصلاً لازم هست پیامی ارسال بشه یا نه.

FCM هیچ اطلاعی از این منطق کسب‌وکار نداره. از دید گوگل، همه پیام‌ها فقط چند بایت داده هستن که باید از نقطه A به نقطه B منتقل بشن.

در واقع میشه گفت Backend تصمیم می‌گیره، ولی FCM فقط تحویل میده.


Firebase Project چه نقشی داره؟

قبل از اینکه برنامه بتونه از FCM استفاده کنه، باید داخل Firebase یک Project داشته باشه. شاید اولش این Project فقط شبیه یک پوشه برای دسته‌بندی برنامه‌ها به نظر برسه، ولی در واقع نقش خیلی مهم‌تری داره.

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

به زبان ساده، Project مثل یک ساختمان بزرگه و برنامه تو یکی از واحدهای اون ساختمانه. اگر Backend بخواد پیامی بفرسته، باید دقیقاً بدونه این پیام مربوط به کدوم ساختمان و کدوم واحده.

به همین خاطر موقع راه‌اندازی Firebase معمولاً فایلی به اسم google-services.json داخل پروژه قرار می‌گیره. خیلی از برنامه‌نویس‌های تازه‌کار فقط این فایل رو کپی می‌کنن و جلو میرن، بدون اینکه بدونن اصلاً چرا وجود داره.

این فایل در واقع اطلاعات شناسایی برنامه رو در اختیار سرویس‌های Firebase قرار میده؛ مثل شناسه پروژه، شناسه برنامه و اطلاعاتی که باعث میشه اندروید بدونه این اپلیکیشن عضو کدوم Firebase Project هست.

لازم نیست محتویات این فایل رو حفظ کنی یا حتی تک‌تک گزینه‌هاش رو بشناسی. چیزی که مهمه اینه که بدونی بدون این ارتباط اولیه، برنامه اصلاً نمی‌تونه وارد اکوسیستم Firebase بشه.


Registration Token چیست؟

تا اینجا یک سؤال مهم باقی مونده.

فرض کنیم Backend پیام رو به FCM تحویل داد. حالا FCM از کجا باید بفهمه این پیام متعلق به کدوم گوشیه؟

اینجاست که Registration Token وارد داستان میشه.

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

بهترین مثال برای Token، آدرس پستیه. اگر بخوای برای یک نفر نامه بفرستی، فقط دونستن اسمش کافی نیست؛ باید آدرسش رو هم بدونی. Backend هم برای فرستادن پیام فقط اسم کاربر رو نمی‌شناسه، بلکه باید Token دستگاهش رو هم داشته باشه.

این Token بعداً به Backend ارسال میشه و هر وقت لازم باشه پیامی برای اون دستگاه ارسال بشه، Backend همین Token رو به FCM تحویل میده.


Token متعلق به کاربر نیست

یکی از اشتباه‌های رایج اینه که فکر کنیم هر کاربر فقط یک Token داره.

در واقع Token اصلاً متعلق به کاربر نیست؛ متعلق به یک نصب از برنامه روی یک دستگاه است.

فرض کن علی قیمت‌بان رو روی سه دستگاه نصب کرده:

  • گوشی شخصی
  • تبلت
  • گوشی محل کار

هر کدوم از این نصب‌ها یک Token کاملاً جداگانه دریافت می‌کنن.

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

به همین خاطر Backend نباید داخل جدول User فقط یک ستون به اسم fcm_token داشته باشه. این طراحی از همون روز اول مشکل ایجاد می‌کنه.

طراحی درست معمولاً چیزی شبیه اینه:

User
   │
   ├── Device 1
   │        │
   │        └── Token A
   │
   ├── Device 2
   │        │
   │        └── Token B
   │
   └── Device 3
            │
            └── Token C

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


چرا Token ممکنه تغییر کنه؟

یه اشتباه دیگه اینه که فکر کنیم Token برای همیشه ثابته. در حالی که این شناسه ممکنه به دلایل مختلف تغییر کنه؛ مثلاً کاربر برنامه رو حذف و دوباره نصب کنه، گوشی جدیدی بخره، اطلاعاتش رو Restore کنه یا خود FCM به دلایل امنیتی Token جدیدی تولید کنه.

به همین خاطر برنامه همیشه باید آماده دریافت Token جدید باشه و هر وقت این اتفاق افتاد، نسخه جدیدش رو برای Backend ارسال کنه. اگر Backend همچنان Token قدیمی رو نگه داره، پیام‌ها به مقصد نمی‌رسن، چون FCM دیگه اون Token رو معتبر نمی‌دونه.

همین موضوع باعث میشه Token رو مثل یک آدرس قابل تغییر در نظر بگیری، نه یک شناسه دائمی.


تا اینجا با مهم‌ترین بخش مفهومی FCM آشنا شدیم؛ فهمیدیم چرا Backend نمی‌تونه مستقیم با گوشی ارتباط برقرار کنه، چرا گوگل نقش واسطه رو بازی می‌کنه، Firebase Project چه نقشی داره و Token دقیقاً چیه. در بخش بعدی وارد خود پیام‌ها می‌شیم و تفاوت Notification Message و Data Message رو بررسی می‌کنیم؛ یکی از مهم‌ترین تصمیم‌هایی که موقع طراحی معماری یک اپلیکیشن اندرویدی باید بگیری.

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

Firebase Cloud Messaging (بخش دوم)

Data Message و Notification Message؛ مهم‌ترین تصمیم معماری

تا اینجا فهمیدیم که FCM فقط وظیفه داره پیام رو از Backend به گوشی برسونه. اما هنوز یک سؤال مهم باقی مونده؛ وقتی Backend می‌خواد پیامی ارسال کنه، آیا همه پیام‌ها دقیقاً یک شکل هستن؟ جواب منفیه. داخل FCM چند نوع پیام وجود داره، اما تقریباً تمام پروژه‌های اندرویدی با دو نوع اصلی سروکار دارن؛ Notification Message و Data Message. خیلی از برنامه‌نویس‌ها فکر می‌کنن تفاوت این دو فقط چند فیلد داخل JSONـه، اما واقعیت اینه که این دو، دو فلسفه کاملاً متفاوت برای طراحی اپلیکیشن هستن. قبل از اینکه حتی یک خط کد بنویسی، باید تصمیم بگیری که دوست داری کنترل اعلان دست چه کسی باشه؛ خود سیستم‌عامل یا اپلیکیشنت؟ همین تصمیم روی معماری Backend، نحوه پردازش پیام‌ها و حتی تجربه کاربری برنامه تأثیر می‌ذاره.


Notification Message

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

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


Data Message

Data Message دقیقاً با یک طرز فکر متفاوت طراحی شده. توی این مدل Backend هیچ اعلان آماده‌ای تولید نمی‌کنه و فقط یک بسته اطلاعاتی برای برنامه ارسال می‌کنه. این اطلاعات می‌تونه شامل شناسه محصول، نوع رویداد، زمان تغییر، شناسه کاربر یا هر داده دیگه‌ای باشه که برنامه برای تصمیم‌گیری بهش احتیاج داره. وقتی پیام به گوشی رسید، این بار هیچ‌کس به جز خود اپلیکیشن تصمیم نمی‌گیره که چه اتفاقی بیفته. شاید برنامه یک Notification بسازه، شاید اطلاعات جدید رو از سرور دانلود کنه، شاید دیتابیس داخلی رو به‌روزرسانی کنه و شاید هم اصلاً هیچ چیزی به کاربر نمایش نده.

همین موضوع باعث شده Data Message در پروژه‌های حرفه‌ای محبوب‌تر باشه. چون کنترل کامل در اختیار برنامه قرار می‌گیره و هر سناریویی که بخوای قابل پیاده‌سازیه. در واقع Notification Message بیشتر شبیه اینه که Backend بگه این اعلان رو همین الان به کاربر نشون بده، اما Data Message بیشتر شبیه اینه که Backend بگه این اتفاق افتاده، حالا خودت تصمیم بگیر بهترین واکنش چیه. این تفاوت شاید روی کاغذ کوچک به نظر برسه، ولی در عمل یکی از مهم‌ترین تصمیم‌های معماری هر اپلیکیشن اندرویدیه.


اگر جای ما بودی، برای قیمت‌بان کدوم رو انتخاب می‌کردی؟

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


FCM فقط برای نمایش Notification نیست

یکی از برداشت‌های اشتباهی که بین برنامه‌نویس‌های تازه‌کار خیلی دیده میشه اینه که فکر می‌کنن FCM فقط برای نمایش اعلان ساخته شده. حتی اسمش رو هم اشتباه به خاطر می‌سپارن و بهش میگن Firebase Notification. در حالی که اسم واقعی این سرویس Firebase Cloud Messagingـه. یعنی وظیفه اصلیش انتقال پیامه، نه نمایش Notification.

خیلی وقت‌ها پیام فقط برای این ارسال میشه که برنامه از یک اتفاق باخبر بشه. فرض کن مدیر سیستم قیمت یک کالا رو تغییر داده. برنامه پیام رو دریافت می‌کنه، اطلاعات جدید رو از سرور دانلود می‌کنه، دیتابیس داخلی رو به‌روزرسانی می‌کنه و رابط کاربری رو دوباره Render می‌کنه. کاربر فقط می‌بینه قیمت تغییر کرده و شاید اصلاً هیچ Notificationای روی صفحه ظاهر نشه. یا فرض کن Backend به برنامه اطلاع میده که فایل تنظیمات جدید آماده دانلود شده. برنامه در سکوت فایل رو دانلود می‌کنه و همه چیز بدون کوچک‌ترین مزاحمتی برای کاربر انجام میشه. بنابراین هیچ‌وقت FCM رو مساوی Notification در نظر نگیر؛ Notification فقط یکی از کاربردهای FCMـه، نه تمام اون.


Topic؛ وقتی قرار نیست فقط یک نفر پیام رو دریافت کنه

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

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

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


Priority؛ همه پیام‌ها ارزش یکسانی ندارن

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

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


TTL؛ اگر گوشی خاموش بود چه اتفاقی می‌افته؟

حالا فرض کن Backend یک پیام ارسال کرده، اما گوشی کاربر خاموشه یا به اینترنت دسترسی نداره. آیا پیام فوراً از بین میره؟ نه. FCM می‌تونه پیام رو برای مدت مشخصی نگه داره و هر زمان دستگاه دوباره آنلاین شد، اون رو تحویل بده. این مدت زمان با مفهومی به اسم TTL یا Time To Live مشخص میشه.

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


Backend Design؛ Backend باید رویداد تولید کنه، نه Notification

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

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


جمع‌بندی

تا اینجا تقریباً مهم‌ترین مفاهیم عملی FCM رو شناختیم. دیدیم که Notification Message و Data Message فقط دو قالب مختلف برای ارسال اطلاعات نیستن، بلکه دو روش متفاوت برای طراحی یک اپلیکیشن محسوب میشن. همچنین فهمیدیم که FCM صرفاً مسئول انتقال پیامه و نمایش Notification فقط یکی از کاربردهای اون به حساب میاد. بعد با Topic آشنا شدیم که ارسال پیام‌های عمومی رو ساده‌تر می‌کنه، مفهوم Priority رو بررسی کردیم تا بدونیم همه پیام‌ها ارزش یکسانی ندارن، درباره TTL صحبت کردیم که مشخص می‌کنه یک پیام تا چه مدت ارزش نگهداری داره و در نهایت هم دیدیم که در یک معماری حرفه‌ای، Backend بهتره فقط رویدادها رو تولید کنه و تصمیم درباره نحوه اطلاع‌رسانی به لایه‌های بالاتر سپرده بشه. این نگاه باعث میشه سیستم مقیاس‌پذیرتر، قابل نگهداری‌تر و آماده‌تر برای توسعه‌های آینده باشه.

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

Firebase Cloud Messaging (بخش سوم)

یک اشتباه بزرگ؛ فکر نکن FCM یعنی همگام‌سازی اطلاعات

بعد از اینکه برنامه‌نویس‌ها با FCM آشنا میشن، معمولاً یک اشتباه رایج مرتکب میشن. با خودشون میگن حالا که هر وقت اطلاعات روی سرور تغییر کرد، یک پیام برای برنامه ارسال می‌کنیم، پس دیگه نیازی نیست موقع باز شدن برنامه دوباره اطلاعات رو از سرور بگیریم. این نگاه در ظاهر منطقیه، اما در عمل یکی از رایج‌ترین دلایل ناسازگار شدن اطلاعات بین گوشی و Backend محسوب میشه.

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

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


بهترین روش Sync بعد از دریافت پیام

فرض کن Backend تشخیص داده قیمت یک محصول تغییر کرده و یک Data Message برای برنامه ارسال کرده. شاید اولین ایده این باشه که قیمت جدید هم داخل همون پیام قرار بگیره و برنامه مستقیماً همون مقدار رو داخل دیتابیس ذخیره کنه. این روش در پروژه‌های خیلی کوچک شاید جواب بده، اما هرچه سیستم بزرگ‌تر بشه، مشکلاتش بیشتر خودش رو نشون میده.

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

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


اگر پیام نرسید، برنامه نباید خراب بشه

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

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


بهترین روش استفاده از FCM در پروژه‌های واقعی

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

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

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


معماری پیشنهادی برای قیمت‌بان

اگر بخوام بر اساس چیزهایی که تا اینجا درباره قیمت‌بان ساختیم، معماری نهایی FCM رو پیشنهاد بدم، مسیر کلی تقریباً این شکلیه. هر کاربر بعد از نصب برنامه یک Token دریافت می‌کنه و اون Token داخل Backend ذخیره میشه. هر زمان رویدادی مثل تغییر قیمت، تأیید کسب‌وکار، حذف محصول یا هر اتفاق مهم دیگه‌ای رخ بده، Backend فقط یک Data Message با اطلاعات حداقلی ارسال می‌کنه. برنامه بعد از دریافت پیام، نوع رویداد رو بررسی می‌کنه و تصمیم می‌گیره بهترین واکنش چیه. اگر لازم باشه اطلاعات جدید از سرور دریافت میشه، دیتابیس Room به‌روزرسانی میشه، رابط کاربری دوباره Render میشه و فقط اگر شرایط مناسب باشه، Notification هم برای کاربر نمایش داده میشه.

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


چند اشتباه رایج که بهتره از همون اول مرتکبشون نشی

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

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


جمع‌بندی درس

حالا که این سه بخش رو کنار هم می‌ذاریم، تصویر کامل‌تری از Firebase Cloud Messaging به دست میاد. اول فهمیدیم چرا Backend نمی‌تونه مستقیماً با گوشی ارتباط برقرار کنه و چرا گوگل نقش واسطه رو بین سرور و دستگاه بازی می‌کنه. بعد با Token، Firebase Project، Data Message، Notification Message، Topic، Priority و TTL آشنا شدیم و دیدیم که هر کدوم چه نقشی داخل این اکوسیستم دارن. در نهایت هم به این نتیجه رسیدیم که FCM نباید به‌عنوان جایگزین Backend یا سیستم همگام‌سازی اطلاعات در نظر گرفته بشه، بلکه یک ابزار اطلاع‌رسانی هوشمنده که فقط خبر میده اتفاقی افتاده و برنامه باید تصمیم بگیره بهترین واکنش چیه.

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


درس سی‌وششم

Notification Channel

چرا اندروید Notification Channel رو به وجود آورد؟

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

گوگل از Android 8.0 یا API 26 به بعد Notification Channel رو معرفی کرد تا این اختیار رو از برنامه‌نویس به شکل کامل‌تری به کاربر منتقل بکنه. از این نسخه به بعد، هر Notification باید عضو یک Channel مشخص باشه و رفتارهای اصلی اعلان مثل میزان اهمیت، صدا و ویبره در سطح همون Channel تعریف میشن. کاربر هم می‌تونه وارد تنظیمات گوشی بشه و برای هر Channel جداگانه تصمیم بگیره؛ مثلاً اعلان‌های مهم مربوط به تغییر قیمت رو روشن نگه داره، اما اعلان‌های پیشنهادهای عمومی رو بی‌صدا یا کاملاً غیرفعال بکنه. بنابراین Notification Channel فقط یک الزام فنی نیست؛ در اصل یک قرارداد بین اپلیکیشن، اندروید و کاربره که مشخص می‌کنه هر دسته از اعلان‌ها چه ماهیتی دارن و چقدر اجازه دارن مزاحم کاربر بشن.

این تغییر یک پیام مهم برای برنامه‌نویس‌ها داشت: از Android 8 به بعد دیگه برنامه مالک کامل رفتار اعلان نیست. برنامه فقط موقع ساخت Channel یک رفتار اولیه پیشنهاد میده، اما بعد از ایجاد Channel، کاربر کنترل اصلی رو به دست می‌گیره. این یعنی نمی‌تونی هر بار که یک Notification می‌سازی، دوباره تصمیم بگیری صدای اون بلندتر باشه یا Importance بالاتری داشته باشه. رفتار اعلان از Channel میاد، نه از خود Notification. این نکته پایه‌ای‌ترین مفهوم این درسه و اگر درست فهمیده نشه، بعداً خیلی از رفتارهای عجیب اعلان‌ها نامفهوم به نظر می‌رسن.

Channel رو مثل دسته‌بندی ساده نبین

ممکنه در نگاه اول فکر کنی Notification Channel فقط یک Category ساده است؛ مثلاً اعلان‌های تغییر قیمت رو داخل یک دسته بذاریم و اعلان‌های تبلیغاتی رو داخل دسته دیگه. این برداشت نصفه‌درسته. Channel از نظر مفهومی دسته‌بندی اعلان‌هاست، اما فقط برای نظم دادن به کد ساخته نشده. هر Channel یک موجودیت واقعی داخل تنظیمات سیستم‌عامله و کاربر می‌تونه اون رو ببینه، صدای اون رو تغییر بده، ویبره‌اش رو قطع بکنه، Importance رو پایین بیاره یا کل Channel رو ببنده.

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

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

اجزای اصلی یک Notification Channel

هر Channel حداقل سه مشخصه مهم داره: یک channelId، یک نام قابل‌مشاهده برای کاربر و یک سطح importance. شناسه Channel رو برنامه استفاده می‌کنه، اما نام Channel داخل تنظیمات گوشی به کاربر نمایش داده میشه. مثلاً شناسه می‌تونه price_changes باشه و نام قابل‌نمایش تغییرات قیمت. این دو نقش متفاوتی دارن؛ شناسه بخشی از قرارداد فنی برنامه است، ولی نام بخشی از تجربه کاربریه.

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

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

ساخت Channel در Kotlin

Channel فقط روی Android 8 و بالاتر وجود داره، بنابراین کد ساختش باید با بررسی نسخه اندروید اجرا بشه. روی نسخه‌های قدیمی‌تر کلاس NotificationChannel وجود نداره و رفتار اعلان با priority خود Notification کنترل میشه. ساخت یک Channel ساده می‌تونه به این شکل باشه:

const val PRICE_ALERTS_CHANNEL_ID = "price_alerts"

fun createPriceAlertsChannel(context: Context) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val channel = NotificationChannel(
            PRICE_ALERTS_CHANNEL_ID,
            "تغییرات قیمت",
            NotificationManager.IMPORTANCE_HIGH
        ).apply {
            description = "اعلان‌های مربوط به کاهش یا تغییر قیمت محصولات موردعلاقه"
        }

        val notificationManager =
            context.getSystemService(NotificationManager::class.java)

        notificationManager.createNotificationChannel(channel)
    }
}

اینجا PRICE_ALERTS_CHANNEL_ID شناسه فنی Channelـه. عنوان تغییرات قیمت داخل تنظیمات گوشی نمایش داده میشه و IMPORTANCE_HIGH رفتار اولیه Channel رو مشخص می‌کنه. بعد Channel رو از طریق NotificationManager داخل سیستم ثبت می‌کنیم. نکته مهم اینه که ثبت Channel باید قبل از نمایش اولین Notification انجام شده باشه. اگر برنامه‌ای که Android 8 یا بالاتر رو هدف گرفته، Notification رو بدون Channel معتبر ارسال بکنه، اعلان نمایش داده نمیشه و سیستم خطا ثبت می‌کنه.

ساخت Channel معمولاً باید در نقطه‌ای انجام بشه که مطمئن باشیم قبل از اولین اعلان اجرا شده. این کار می‌تونه هنگام شروع برنامه داخل کلاس Application انجام بشه. لازم نیست قبل از هر Notification نگران باشی که Channel از قبل وجود داره یا نه، چون صدا زدن دوباره createNotificationChannel() برای Channel موجود امنه و در حالت عادی عملیات اضافه‌ای انجام نمیده. بنابراین می‌تونی Channelهای ثابت برنامه رو هر بار هنگام شروع App ثبت بکنی، بدون اینکه نیاز به ذخیره Flag یا بررسی دستی داشته باشی.

class PriceBanApplication : Application() {

    override fun onCreate() {
        super.onCreate()

        createNotificationChannels(this)
    }
}
fun createNotificationChannels(context: Context) {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return

    val channels = listOf(
        NotificationChannel(
            "price_alerts",
            "تغییرات قیمت",
            NotificationManager.IMPORTANCE_HIGH
        ).apply {
            description = "کاهش یا تغییر قیمت محصولات موردعلاقه"
        },

        NotificationChannel(
            "business_status",
            "وضعیت کسب‌وکار",
            NotificationManager.IMPORTANCE_DEFAULT
        ).apply {
            description = "نتیجه بررسی و تغییر وضعیت کسب‌وکار ثبت‌شده"
        },

        NotificationChannel(
            "general_updates",
            "اطلاعیه‌های عمومی",
            NotificationManager.IMPORTANCE_LOW
        ).apply {
            description = "خبرها، پیشنهادها و اطلاعیه‌های غیرضروری برنامه"
        }
    )

    val notificationManager =
        context.getSystemService(NotificationManager::class.java)

    notificationManager.createNotificationChannels(channels)
}

اینجا به جای اینکه هر Channel رو جدا ثبت بکنیم، یک List می‌سازیم و همه رو با createNotificationChannels ثبت می‌کنیم. این مدل برای پروژه‌ای مثل قیمت‌بان تمیزتره، چون Channelها یکجا تعریف میشن و ساختار اعلان‌های برنامه هم واضح‌تر میشه. البته بهتره نام‌ها و توضیحات داخل strings.xml قرار بگیرن تا ترجمه‌پذیر باشن، چون این متن‌ها مستقیم به کاربر نمایش داده میشن.

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

importance تعیین می‌کنه Notification متعلق به یک Channel تا چه اندازه اجازه داره توجه کاربر رو قطع بکنه. خیلی‌ها Importance رو فقط با صدا داشتن یا نداشتن یکی می‌دونن، اما موضوع گسترده‌تره. Importance روی میزان برجستگی اعلان، نمایش صدا، احتمال ظاهر شدن به شکل Heads-up و جایگاه کلی اون در تجربه کاربر تأثیر می‌ذاره. از Android 8 به بعد این سطح در Channel تعریف میشه، در حالی که روی Android 7.1 و پایین‌تر باید priority رو روی خود Notification تنظیم بکنی.

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

اشتباه خطرناک اینه که برای مطمئن شدن از دیده شدن اعلان، همه Channelها رو HIGH تعریف بکنی. برنامه شاید در کوتاه‌مدت توجه بیشتری بگیره، اما خیلی زود کاربر احساس مزاحمت می‌کنه و یا Channel رو می‌بنده، یا کل مجوز اعلان برنامه رو قطع می‌کنه. اهمیت زیاد باید از ماهیت واقعی اتفاق بیاد، نه از علاقه تیم محصول به دیده شدن. راهنمای طراحی اندروید هم تأکید می‌کنه که Importance باید با احترام به زمان و توجه کاربر انتخاب بشه و اطلاعات غیرمهم نباید به شکل اضطراری نمایش داده بشن.

چرا تغییر Importance در کد اثر نمی‌کنه؟

این یکی از جاهایی‌ست که خیلی از برنامه‌نویس‌ها ساعت‌ها درگیرش میشن. فرض کن اول Channel رو با IMPORTANCE_LOW ساختی. بعد متوجه میشی باید صدا داشته باشه و مقدارش رو به IMPORTANCE_HIGH تغییر میدی. برنامه رو دوباره اجرا می‌کنی، اما اعلان همچنان بی‌صداست. کد رو چک می‌کنی، Uninstall و Install می‌کنی و می‌بینی بعد از نصب مجدد درست شده. دلیلش اینه که Channel فقط بار اول با تنظیمات اولیه ساخته میشه. بعد از اون، Channel داخل سیستم ثبت شده و برنامه دیگه نمی‌تونه Importance اون رو به زور تغییر بده.

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

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

Channel و Notification چه رابطه‌ای دارن؟

Channel خودش Notification نیست و چیزی روی صفحه نمایش نمیده. Channel فقط مجموعه‌ای از قوانین و ترجیحات برای اعلان‌هایی‌ست که بعداً از طریق اون ارسال میشن. وقتی Notification رو می‌سازی، باید channelId مناسب رو به NotificationCompat.Builder بدی:

val notification = NotificationCompat.Builder(
    context,
    PRICE_ALERTS_CHANNEL_ID
)
    .setSmallIcon(R.drawable.ic_notification)
    .setContentTitle("قیمت کاهش پیدا کرد")
    .setContentText("قیمت محصول موردعلاقه‌ات ۱۵ درصد کمتر شده")
    .setAutoCancel(true)
    .build()

روی Android 8 به بعد، رفتار اصلی این Notification از Channel با شناسه price_alerts میاد. اگر Channel روی حالت بی‌صدا باشه، اضافه کردن صدا یا Priority بالا به Builder نمی‌تونه انتخاب کاربر رو کنار بزنه. روی نسخه‌های قدیمی‌تر اندروید که Channel وجود نداره، باید Priority رو روی خود Notification مشخص بکنی:

val notification = NotificationCompat.Builder(
    context,
    PRICE_ALERTS_CHANNEL_ID
)
    .setSmallIcon(R.drawable.ic_notification)
    .setContentTitle("قیمت کاهش پیدا کرد")
    .setContentText("قیمت محصول موردعلاقه‌ات ۱۵ درصد کمتر شده")
    .setPriority(NotificationCompat.PRIORITY_HIGH)
    .setAutoCancel(true)
    .build()

وجود channelId در NotificationCompat.Builder برای نسخه‌های قدیمی مشکلی ایجاد نمی‌کنه، اما روی Android 8 به بعد باید Channel واقعاً از قبل ساخته شده باشه. به همین خاطر معمولاً هم Channel Importance رو تعریف می‌کنی و هم Priority خود Notification رو تنظیم می‌کنی تا رفتار مناسب روی نسخه‌های قبل و بعد از Android 8 حفظ بشه.

صدای سفارشی، ویبره و تنظیمات Channel

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

fun createTargetPriceChannel(context: Context) {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return

    val soundUri = Uri.parse(
        "${ContentResolver.SCHEME_ANDROID_RESOURCE}://" +
            "${context.packageName}/${R.raw.price_alert}"
    )

    val audioAttributes = AudioAttributes.Builder()
        .setUsage(AudioAttributes.USAGE_NOTIFICATION)
        .build()

    val channel = NotificationChannel(
        "target_price_alerts",
        "رسیدن قیمت به هدف",
        NotificationManager.IMPORTANCE_HIGH
    ).apply {
        description = "وقتی قیمت محصول به محدوده انتخاب‌شده می‌رسه"
        enableVibration(true)
        setSound(soundUri, audioAttributes)
        setShowBadge(true)
    }

    val manager = context.getSystemService(NotificationManager::class.java)
    manager.createNotificationChannel(channel)
}

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

Notification Channel با مجوز اعلان فرق داره

از Android 13 به بعد، برنامه برای ارسال اعلان‌های معمولی به مجوز Runtime با نام POST_NOTIFICATIONS احتیاج داره. این مجوز با Notification Channel دو مفهوم جداست. Permission مشخص می‌کنه آیا برنامه در سطح کلی اجازه داره اعلان بفرسته یا نه. Channel مشخص می‌کنه هر دسته از اعلان‌های مجاز چه رفتاری داشته باشن. یعنی ممکنه همه Channelها روشن باشن، اما کاربر مجوز کلی اعلان رو رد کرده باشه؛ در این حالت اعلان نمایش داده نمیشه. برعکس، ممکنه مجوز کلی داده شده باشه، اما کاربر Channel تبلیغات رو بسته باشه؛ در این حالت بقیه Channelها کار می‌کنن، ولی تبلیغات نمایش داده نمیشه.

مجوز باید داخل Manifest تعریف بشه:

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

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

از کجا بفهمیم کاربر Channel رو بسته؟

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

می‌تونی Channel رو از NotificationManager بگیری و Importance فعلیش رو بررسی بکنی:

fun isPriceAlertsChannelEnabled(context: Context): Boolean {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
        return NotificationManagerCompat.from(context)
            .areNotificationsEnabled()
    }

    val manager = context.getSystemService(NotificationManager::class.java)
    val channel = manager.getNotificationChannel(PRICE_ALERTS_CHANNEL_ID)

    return channel != null &&
        channel.importance != NotificationManager.IMPORTANCE_NONE
}

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

برای باز کردن مستقیم تنظیمات Channel می‌تونی از Intent زیر استفاده بکنی:

fun openPriceAlertsChannelSettings(context: Context) {
    val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
        putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
        putExtra(Settings.EXTRA_CHANNEL_ID, PRICE_ALERTS_CHANNEL_ID)
    }

    context.startActivity(intent)
}

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

Channel Group با Notification Group یکی نیست

دو مفهوم با اسم شبیه داریم که خیلی راحت با هم قاطی میشن: NotificationChannelGroup و Notification Group. Channel Group فقط برای مرتب کردن Channelها داخل صفحه تنظیمات سیستم استفاده میشه. مثلاً اگر برنامه از چند حساب کاربری پشتیبانی بکنه، می‌تونی Channelهای حساب شخصی و کاری رو در دو Channel Group جدا قرار بدی. این Group روی نحوه نمایش خود Notificationها در Notification Drawer اثری نداره و بیشتر برای نظم دادن به تنظیمات استفاده میشه. مستندات اندروید هم میگن اطلاعات Group برای نمایش و سازمان‌دهیه، نه برای تغییر رفتار Channel.

Notification Group برعکس، چند Notification رو داخل پنل اعلان‌ها کنار هم جمع می‌کنه. مثلاً اگر پنج محصول مختلف کاهش قیمت داشتن، به جای اینکه پنج اعلان پراکنده نمایش داده بشه، می‌تونی اون‌ها رو زیر یک Summary جمع بکنی. این قابلیت از Android 7 به بعد وجود داره و ارتباطی با Channel Group نداره. ممکنه همه اعلان‌های داخل یک Notification Group از یک Channel بیان، اما این دو مفهوم هدف متفاوتی دارن.

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

آیا برای هر محصول باید یک Channel بسازیم؟

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

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

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

حذف Channel و مشکلاتش

می‌تونی یک Channel رو با deleteNotificationChannel() حذف بکنی، اما حذف Channel باید با دقت انجام بشه. اندروید تعداد Channelهای حذف‌شده رو داخل تنظیمات برنامه نگه می‌داره تا جلوی سوءاستفاده و ساخت‌وحذف مداوم Channelها گرفته بشه. این یعنی در زمان توسعه، اگر مرتب IDهای آزمایشی می‌سازی و حذف می‌کنی، ممکنه عدد Channelهای حذف‌شده در تنظیمات بالا بره. با پاک کردن Data برنامه یا نصب مجدد نسخه آزمایشی می‌تونی وضعیت رو از نو شروع بکنی.

fun deleteOldChannel(context: Context) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val manager = context.getSystemService(NotificationManager::class.java)
        manager.deleteNotificationChannel("old_price_updates")
    }
}

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

معماری پیشنهادی Channelها برای قیمت‌بان

برای نسخه فعلی قیمت‌بان، سه Channel اصلی احتمالاً تعادل خوبی بین کنترل کاربر و سادگی ایجاد می‌کنن. Channel اول price_alerts برای تغییرات مهم قیمت و رسیدن قیمت به محدوده‌ایه که کاربر انتخاب کرده. این Channel می‌تونه Importance نسبتاً بالا داشته باشه، چون علت اصلی فعال کردن هشدارها همین اتفاقه. با این حال حتی اینجا هم نباید هر تغییر یک‌درصدی با صدای بلند اعلام بشه؛ منطق Backend یا تنظیمات کاربر باید مشخص بکنه چه تغییری ارزش Notification داره.

Channel دوم business_status برای نتیجه بررسی کسب‌وکار، تغییر وضعیت ثبت، تأیید یا رد اطلاعاته. این اعلان‌ها مهمن، اما معمولاً اضطراری نیستن، بنابراین IMPORTANCE_DEFAULT منطقی‌تره. Channel سوم general_updates برای خبرها، نسخه جدید، پیشنهادها و اطلاعیه‌های عمومی برنامه است و بهتره IMPORTANCE_LOW داشته باشه تا بدون مزاحمت زیاد نمایش داده بشه. حتی می‌تونی این Channel رو Opt-in طراحی بکنی؛ یعنی فقط وقتی کاربر خودش علاقه‌اش رو اعلام کرد، اعلان‌های عمومی براش ارسال بشن.

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

معماری بهتر اینه که شناسه Channel از Backend ارسال نشه. Backend باید Event معنادار بفرسته، مثلاً PRICE_TARGET_REACHED یا BUSINESS_APPROVED، و خود اپلیکیشن تصمیم بگیره این Event به کدوم Channel تعلق داره. اگر Backend مستقیماً channel_id اندروید رو تعیین بکنه، جزئیات پلتفرم وارد منطق Backend میشه و تغییر معماری Android سخت‌تر میشه. بهتره داخل اپ یک Mapper وجود داشته باشه که نوع رویداد رو به Channel مناسب تبدیل بکنه:

enum class NotificationEventType {
    PRICE_CHANGED,
    PRICE_TARGET_REACHED,
    BUSINESS_APPROVED,
    BUSINESS_REJECTED,
    GENERAL_ANNOUNCEMENT,
    SECURITY_ALERT
}
fun NotificationEventType.channelId(): String {
    return when (this) {
        NotificationEventType.PRICE_CHANGED,
        NotificationEventType.PRICE_TARGET_REACHED ->
            "price_alerts"

        NotificationEventType.BUSINESS_APPROVED,
        NotificationEventType.BUSINESS_REJECTED ->
            "business_status"

        NotificationEventType.GENERAL_ANNOUNCEMENT ->
            "general_updates"

        NotificationEventType.SECURITY_ALERT ->
            "security_alerts"
    }
}

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

چند خطای رایج در Notification Channel

یکی از خطاهای رایج اینه که Channel بعد از دریافت پیام FCM ساخته بشه. ممکنه بیشتر مواقع کار بکنه، اما اگر مسیر ساخت Channel اجرا نشه یا Notification زودتر ساخته بشه، اعلان نمایش داده نمیشه. Channelهای ثابت بهتره هنگام شروع برنامه ساخته بشن، نه در آخرین لحظه.

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

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

خطای چهارم اینه که برای هر Notification یک Channel جدید ساخته بشه. Channel موجودیتی پایدار و بلندمدته، نه چیزی که برای هر پیام تولید و حذف بشه. تعداد زیاد Channel تنظیمات برنامه رو شلوغ می‌کنه و تجربه کاربر رو خراب می‌کنه.

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

جمع‌بندی

Notification Channel رو نباید فقط یک پارامتر اجباری برای نمایش Notification در Android 8 به بعد ببینی. Channel در اصل مرزی بین اختیار برنامه و اختیار کاربره. برنامه نوع اعلان‌ها رو تعریف می‌کنه، برای هر نوع یک رفتار اولیه پیشنهاد میده و بعد کنترل نهایی رو به کاربر تحویل میده. از لحظه‌ای که Channel ثبت شد، کاربر می‌تونه صدا، ویبره، Importance و حتی فعال یا غیرفعال بودنش رو تغییر بده و برنامه باید به این انتخاب احترام بذاره.

طراحی درست Channel از شناخت رفتار کاربر شروع میشه، نه از ساختار کلاس‌ها یا جدول‌های Backend. باید ببینی کاربر ممکنه کدوم دسته از اعلان‌ها رو مستقل از بقیه کنترل بکنه. برای قیمت‌بان، تغییر قیمت، وضعیت کسب‌وکار و اطلاعیه‌های عمومی سه نیاز متفاوتن و قرار دادن اون‌ها داخل Channelهای جدا تصمیم منطقی‌تریه. در کنار این طراحی باید تفاوت Channel با Permission، Notification Group و Channel Group رو هم دقیق بدونی تا هر ابزار در جای درست خودش استفاده بشه.

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


درس سی‌وهفتم

Deep Link

Deep Link دقیقاً چه مشکلی رو حل می‌کنه؟

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

Deep Link برای حل همین مسئله ساخته شده. Deep Link لینکیه که به جای باز کردن صرفِ برنامه، کاربر رو مستقیم به یک مقصد مشخص داخل برنامه می‌رسونه. مثلاً لینکی مثل https://priceban.com/products/842 نباید فقط قیمت‌بان رو باز بکنه؛ باید برنامه رو باز بکنه و محصول شماره ۸۴۲ رو نشون بده. از دید کاربر، این تجربه کاملاً طبیعی و یکپارچه است. روی لینک محصول می‌زنه و همون محصول باز میشه. اما پشت این رفتار ساده چند لایه مختلف وجود داره: اندروید باید تشخیص بده این لینک به کدوم برنامه مربوطه، برنامه باید آدرس رو تجزیه بکنه، شناسه محصول رو استخراج بکنه، وضعیت ورود کاربر رو بررسی بکنه، اطلاعات محصول رو دریافت بکنه و در نهایت Back Stack مناسبی بسازه.

اندروید Deep Link رو از طریق سیستم Intent مدیریت می‌کنه. وقتی کاربر روی یک لینک می‌زنه، سیستم یک Intent می‌سازه و بین برنامه‌هایی می‌گرده که اعلام کردن توانایی مدیریت چنین لینکی رو دارن. بنابراین Deep Link یک قابلیت جدا از معماری اندروید نیست، بلکه یکی از کاربردهای مهم همون سیستم Intent محسوب میشه. طبق مستندات رسمی، Deep Link می‌تونه کاربر رو از مرورگر، نتایج جست‌وجو، شبکه‌های اجتماعی، Notification یا برنامه‌های دیگه مستقیماً وارد محتوای مشخصی از برنامه بکنه.

Deep Link فقط یک لینک نیست؛ یک مسیر ورود جدید به برنامه است

یکی از اشتباه‌های رایج اینه که Deep Link رو فقط به چشم یک URL ببینیم. در واقع URL فقط آدرس ورودی محسوب میشه. مسئله اصلی اینه که برنامه از این به بعد چند نقطه ورود داره. قبلاً شاید فرض می‌کردی کاربر همیشه از صفحه Splash یا Home وارد برنامه میشه، اما حالا ممکنه اولین صفحه‌ای که کاربر می‌بینه صفحه جزئیات محصول، صفحه کسب‌وکار، نتیجه سفارش یا تنظیمات هشدار قیمت باشه.

این تغییر روی معماری برنامه اثر جدی می‌ذاره. هر صفحه‌ای که از طریق Deep Link قابل‌دسترسیه باید بتونه تا حد زیادی مستقل راه‌اندازی بشه. مثلاً صفحه جزئیات محصول نباید فرض بکنه که حتماً کاربر قبلاً وارد صفحه لیست محصولات شده و آبجکت کامل محصول رو از صفحه قبل دریافت کرده. Deep Link ممکنه برنامه رو مستقیم با یک productId باز بکنه، بدون اینکه هیچ صفحه قبلی اجرا شده باشه. بنابراین صفحه جزئیات باید با شناسه محصول کار بکنه، اطلاعات لازم رو از Repository بگیره و وضعیت Loading، Error و موفقیت رو خودش مدیریت بکنه.

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

یک مثال واقعی در قیمت‌بان

فرض کن کاربر محصولی رو دنبال کرده و قیمت اون کاهش پیدا کرده. Backend یک پیام FCM ارسال می‌کنه و برنامه Notification زیر رو نمایش میده:

قیمت هدفون Sony کاهش پیدا کرد.

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

https://priceban.com/products/842

وقتی کاربر روی Notification می‌زنه، برنامه این آدرس رو دریافت می‌کنه، بخش products رو به‌عنوان نوع مقصد تشخیص میده و مقدار 842 رو به‌عنوان شناسه محصول استخراج می‌کنه. بعد Navigation کاربر رو به صفحه جزئیات محصول هدایت می‌کنه.

اما سناریو ممکنه پیچیده‌تر باشه. شاید کاربر هنوز وارد حساب نشده باشه. شاید محصول حذف شده باشه. شاید لینک قدیمی باشه. شاید برنامه از قبل باز باشه و کاربر در صفحه دیگری قرار داشته باشه. بنابراین Deep Link فقط navigate("product/842") نیست؛ باید مجموعه‌ای از حالت‌های واقعی برنامه رو مدیریت بکنه.

Custom Scheme و Web Link

در اندروید می‌تونی Deep Link رو با چند نوع آدرس بسازی. یکی از روش‌های قدیمی و ساده، استفاده از یک Scheme اختصاصیه:

priceban://product/842

اینجا قیمت‌بان به جای https قرار گرفته و مشخص می‌کنه این آدرس متعلق به برنامه PriceBanـه. بعد از اون product نوع مقصد و 842 شناسه محصوله. این مدل از نظر پیاده‌سازی ساده است و برای ارتباط داخلی بین برنامه‌ها، تست، Prototype یا بعضی سناریوهای کنترل‌شده کاربرد داره.

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

روش دوم استفاده از Web Linkـه:

https://priceban.com/products/842

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

اینجاست که Android App Links وارد میشه. App Link در اصل یک Web Link تأییدشده است. برنامه داخل Manifest اعلام می‌کنه که لینک‌های دامنه مشخصی رو مدیریت می‌کنه و وب‌سایت هم با یک فایل مخصوص تأیید می‌کنه که این اپلیکیشن واقعاً اجازه مدیریت لینک‌های اون دامنه رو داره. بعد از تأیید، اندروید می‌تونه لینک رو مستقیماً داخل برنامه باز بکنه، بدون اینکه پنجره انتخاب برنامه نمایش داده بشه. برای لینک‌هایی که به دامنه خودت تعلق دارن، مستندات اندروید استفاده از App Links رو پیشنهاد می‌کنن، چون هم تجربه روان‌تری ایجاد می‌کنه و هم جلوی تصاحب لینک توسط برنامه‌های غیرمجاز رو می‌گیره.

تفاوت Deep Link، Web Link و App Link

این سه اصطلاح شبیه هم هستن، اما نباید با هم یکی گرفته بشن. Deep Link مفهوم کلیه؛ یعنی هر لینکی که کاربر رو به مقصد مشخصی داخل برنامه هدایت بکنه. یک لینک با Scheme اختصاصی مثل priceban://product/842 هم Deep Link محسوب میشه.

Web Link نوعی Deep Linkـه که از http یا https استفاده می‌کنه. مثلاً https://priceban.com/products/842 یک Web Linkـه. این لینک هم برای وب معنا داره و هم می‌تونه توسط اپلیکیشن مدیریت بشه.

App Link یک Web Link تأییدشده است. یعنی اندروید از طریق ارتباط بین دامنه و امضای اپلیکیشن مطمئن شده که قیمت‌بان واقعاً مجازه لینک‌های priceban.com رو باز بکنه. App Links از Android 6 به بعد پشتیبانی میشن و بعد از تأیید می‌تونن مستقیماً محتوای مربوطه رو داخل برنامه باز بکنن.

پس رابطه این سه مفهوم تقریباً این شکلیه:

هر App Link یک Web Link و Deep Linkـه، اما هر Deep Link الزاماً Web Link نیست و هر Web Link هم الزاماً App Link تأییدشده نیست.

طراحی URL قبل از نوشتن کد

یکی از مهم‌ترین مراحل Deep Link اصلاً داخل Android Studio انجام نمیشه. قبل از نوشتن Manifest یا Navigation باید ساختار URLهای برنامه رو طراحی بکنی. URL بخشی از قرارداد بلندمدت محصوله. ممکنه داخل پیامک، ایمیل، نتایج گوگل، شبکه‌های اجتماعی و Notificationها منتشر بشه و تا سال‌ها بعد هم کاربران روی نسخه‌های قدیمی اون کلیک بکنن. بنابراین نباید مثل یک Route موقت داخلی باهاش برخورد بکنی.

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

https://priceban.com/products/842
https://priceban.com/businesses/51
https://priceban.com/alerts/179
https://priceban.com/search?q=headphone

این URLها هم برای انسان قابل‌فهم هستن، هم میشه روی وب براشون صفحه ساخت و هم داخل برنامه به مقصد مناسب تبدیلشون کرد. بهتره URL رو به ساختار فعلی کلاس‌ها یا نام Screenها گره نزنی. شاید امروز صفحه‌ای در کد ProductDetailScreen نام داشته باشه و فردا اسمش عوض بشه، اما URL عمومی نباید با هر Refactor تغییر بکنه.

بهتره شناسه‌هایی داخل URL استفاده بشن که پایدار باشن. مثلاً شماره ردیف موقتی یا Index یک List گزینه خوبی نیست. شناسه محصول باید در Backend پایدار بمونه و حتی با تغییر عنوان یا دسته محصول عوض نشه. بعضی سیستم‌ها از Slug خوانا هم استفاده می‌کنن:

https://priceban.com/products/sony-wh-1000xm5

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

تعریف Intent Filter در Manifest

برای اینکه اندروید بدونه MainActivity می‌تونه لینک‌های قیمت‌بان رو مدیریت بکنه، باید یک intent-filter داخل Manifest تعریف بشه. نمونه ساده برای App Link می‌تونه این باشه:

<activity
    android:name=".MainActivity"
    android:exported="true">

    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />

        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />

        <data
            android:scheme="https"
            android:host="priceban.com"
            android:pathPrefix="/products" />
    </intent-filter>

</activity>

VIEW یعنی این Activity می‌تونه محتوایی رو نمایش بده. BROWSABLE اجازه میده لینک از محیطی خارج از برنامه، مثل مرورگر یا پیام‌رسان، وارد Activity بشه. DEFAULT هم باعث میشه Intent بتونه به شکل عادی توسط سیستم Resolve بشه. بخش data مشخص می‌کنه چه URLهایی با این فیلتر مطابقت دارن. اینجا فقط لینک‌هایی که Scheme اون‌ها https، دامنه اون‌ها priceban.com و مسیرشون با /products شروع میشه وارد این Activity میشن. ساختار رسمی App Links هم بر پایه همین Intent Filter و ویژگی android:autoVerify="true" بنا شده.

ویژگی autoVerify از اندروید می‌خواد ارتباط بین برنامه و دامنه رو بررسی بکنه. صرف قرار دادن این ویژگی کافی نیست؛ باید سمت وب‌سایت هم فایل تأیید وجود داشته باشه. اگه تأیید موفق نشه، لینک ممکنه همچنان به‌عنوان Web Link مدیریت بشه، اما تجربه باز شدن مستقیم و قابل‌اعتماد App Link رو نخواهی داشت.

assetlinks.json؛ اثبات مالکیت دامنه و برنامه

برای تأیید App Link، باید فایلی به اسم assetlinks.json روی دامنه قرار بگیره. محل اون باید دقیقاً این مسیر باشه:

https://priceban.com/.well-known/assetlinks.json

این فایل اعلام می‌کنه که اپلیکیشنی با Package Name مشخص و امضای مشخص اجازه داره لینک‌های این دامنه رو مدیریت بکنه. نمونه کلی فایل می‌تونه به این شکل باشه:

[
  {
    "relation": [
      "delegate_permission/common.handle_all_urls"
    ],
    "target": {
      "namespace": "android_app",
      "package_name": "com.priceban.app",
      "sha256_cert_fingerprints": [
        "AA:BB:CC:DD:EE:FF"
      ]
    }
  }
]

package_name باید دقیقاً با Application ID برنامه یکی باشه. sha256_cert_fingerprints هم اثر انگشت Certificateیه که نسخه برنامه باهاش Sign شده. اندروید موقع نصب برنامه فایل سایت رو دریافت می‌کنه و بررسی می‌کنه آیا Package و امضای برنامه با اطلاعات اعلام‌شده در دامنه مطابقت دارن یا نه. اگه مطابقت وجود داشته باشه، ارتباط تأیید میشه و سیستم می‌تونه لینک‌های دامنه رو مستقیم به برنامه بسپره.

اینجا یکی از خطاهای رایج تفاوت امضای Debug و Releaseـه. نسخه‌ای که مستقیم از Android Studio نصب می‌کنی معمولاً با Debug Certificate امضا شده، اما نسخه Play Store ممکنه امضای متفاوتی داشته باشه. اگه فقط Fingerprint نسخه Debug رو در فایل قرار بدی، نسخه منتشرشده تأیید نمیشه. برعکس، اگه فقط امضای Release داخل فایل باشه، تست محلی نسخه Debug ممکنه شکست بخوره. فایل assetlinks.json می‌تونه چند Fingerprint داشته باشه، اما باید دقیقاً بدونی هر نسخه با چه Certificateای امضا شده.

گرفتن داده Deep Link از Intent

وقتی لینک باعث باز شدن Activity میشه، URI داخل Intent.data قرار می‌گیره. در ساده‌ترین حالت می‌تونی اون رو داخل Activity بخونی:

class MainActivity : ComponentActivity() {

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

        val deepLinkUri = intent?.data

        setContent {
            PriceBanApp(
                initialDeepLink = deepLinkUri
            )
        }
    }
}

اگه لینک زیر باز شده باشه:

https://priceban.com/products/842

مقدار deepLinkUri یک Uri خواهد بود که می‌تونی بخش‌های مختلفش رو بخونی:

val host = deepLinkUri?.host
val pathSegments = deepLinkUri?.pathSegments

val section = pathSegments?.getOrNull(0)
val productId = pathSegments?.getOrNull(1)

در این مثال مقدار section برابر products و مقدار productId برابر 842 میشه. اما در یک پروژه واقعی بهتره این Parsing مستقیم داخل Activity پخش نشه. Activity نباید برای هر نوع لینک چندین if و when داشته باشه. بهتره یک لایه مستقل Deep Link رو به یک مقصد داخلی قابل‌فهم تبدیل بکنه.

مثلاً:

sealed interface DeepLinkDestination {

    data class Product(
        val productId: Long
    ) : DeepLinkDestination

    data class Business(
        val businessId: Long
    ) : DeepLinkDestination

    data class Search(
        val query: String
    ) : DeepLinkDestination

    data object Unsupported : DeepLinkDestination
}

بعد یک Parser مسئول تبدیل URI به این مدل‌ها باشه:

fun parseDeepLink(uri: Uri): DeepLinkDestination {
    val segments = uri.pathSegments

    return when (segments.firstOrNull()) {
        "products" -> {
            val id = segments.getOrNull(1)?.toLongOrNull()

            if (id != null) {
                DeepLinkDestination.Product(id)
            } else {
                DeepLinkDestination.Unsupported
            }
        }

        "businesses" -> {
            val id = segments.getOrNull(1)?.toLongOrNull()

            if (id != null) {
                DeepLinkDestination.Business(id)
            } else {
                DeepLinkDestination.Unsupported
            }
        }

        "search" -> {
            val query = uri.getQueryParameter("q")

            if (!query.isNullOrBlank()) {
                DeepLinkDestination.Search(query)
            } else {
                DeepLinkDestination.Unsupported
            }
        }

        else -> DeepLinkDestination.Unsupported
    }
}

مزیت این روش اینه که Navigation با URL خام کار نمی‌کنه. URI ابتدا وارد یک مرز مشخص میشه، اعتبارسنجی میشه و بعد به مدل داخلی برنامه تبدیل میشه. این موضوع هم امنیت رو بهتر می‌کنه، هم تست‌پذیری رو بالا می‌بره و هم اجازه نمیده جزئیات URL در تمام Screenها پخش بشه.

Deep Link در Navigation Compose

Navigation Compose اجازه میده Deep Link رو مستقیماً برای یک Destination تعریف بکنی. در مدل Type-safe جدیدتر می‌تونی مقصد رو با یک کلاس Serializable تعریف بکنی:

@Serializable
data class ProductDetail(
    val productId: Long
)

بعد داخل NavHost مقصد رو همراه با Deep Link معرفی بکنی:

composable<ProductDetail>(
    deepLinks = listOf(
        navDeepLink<ProductDetail>(
            basePath = "https://priceban.com/products"
        )
    )
) { backStackEntry ->

    val destination = backStackEntry.toRoute<ProductDetail>()

    ProductDetailScreen(
        productId = destination.productId
    )
}

ایده اصلی اینه که الگوی URL با مقصد Navigation مرتبط میشه و وقتی Intent مناسب وارد برنامه شد، Navigation می‌تونه Argumentها رو استخراج بکنه و Destination مربوطه رو باز بکنه. مستندات Navigation Compose هم Deep Linkهای implicit رو از طریق تعریف deepLinks روی مقصد پشتیبانی می‌کنن. با این حال برای اینکه لینک از بیرون برنامه وارد بشه، همچنان Intent Filter در Manifest لازمه؛ تعریف Deep Link داخل NavHost به تنهایی برنامه رو در سطح سیستم به‌عنوان Handler لینک ثبت نمی‌کنه.

در بعضی پروژه‌ها تیم ترجیح میده Intent رو خودش Parse بکنه و بعد Navigation رو دستی انجام بده. این روش کنترل بیشتری روی احراز هویت، Analytics، Migration لینک‌های قدیمی و مدیریت خطا میده. در پروژه‌های ساده، اتصال مستقیم Deep Link به Destination راحت‌تره. هیچ‌کدوم ذاتاً همیشه بهتر نیستن؛ انتخاب به میزان پیچیدگی Flow برنامه بستگی داره.

Cold Start و Warm Start

Deep Link همیشه در یک وضعیت یکسان وارد برنامه نمیشه. گاهی برنامه کاملاً بسته است و کاربر با کلیک روی لینک اون رو اجرا می‌کنه. به این حالت معمولاً Cold Start میگیم. در این وضعیت Activity از اول ساخته میشه، Dependencyها راه‌اندازی میشن، دیتابیس آماده میشه، Session کاربر خوانده میشه و بعد باید مقصد Deep Link باز بشه.

حالت دوم زمانیه که برنامه از قبل در حافظه یا روی صفحه بازه. حالا کاربر از Notification یا برنامه دیگه روی یک لینک می‌زنه و همون Activity دوباره Intent تازه‌ای دریافت می‌کنه. بسته به launchMode و وضعیت Task، ممکنه Activity جدید ساخته بشه یا Intent از طریق onNewIntent() به Activity موجود برسه.

این تفاوت خیلی مهمه، چون اگه فقط intent.data رو داخل onCreate() بخونی، شاید Deep Link در Cold Start درست کار بکنه ولی در حالتی که Activity از قبل بازه، Intent جدید نادیده گرفته بشه. وقتی از Navigation با launch mode استاندارد استفاده می‌کنی، بخش زیادی از مدیریت Deep Link خودکار انجام میشه. مستندات اندروید توصیه می‌کنن تا جای ممکن launchMode="standard" حفظ بشه. اگه از حالتی مثل singleTop استفاده بکنی و Activity دوباره استفاده بشه، باید Intent تازه رو داخل onNewIntent() به Navigation تحویل بدی.

نمونه ساده:

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)

    setIntent(intent)
    navController.handleDeepLink(intent)
}

البته محل نگهداری navController در Compose باید با ساختار برنامه هماهنگ باشه و نباید صرفاً برای دسترسی Activity یک Reference نامطمئن بسازی. نکته مفهومی اینه که هر Deep Link یک Event جدیده و برنامه باید هم Intent اولیه و هم Intentهای بعدی رو مدیریت بکنه.

Deep Link و وضعیت ورود کاربر

فرض کن لینک زیر برای کاربر ارسال شده:

https://priceban.com/alerts/179

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

این مفهوم رو میشه Pending Destination یا Deferred Navigation در نظر گرفت. Deep Link به برنامه میگه مقصد نهایی کجاست، اما برنامه ممکنه قبل از رسیدن به اون مقصد چند شرط رو بررسی بکنه. احراز هویت، تکمیل پروفایل، پذیرش قوانین جدید یا انتخاب کسب‌وکار فعال نمونه‌هایی از این شرط‌ها هستن.

معماری نامناسب اینه که Screen مقصد خودش ناگهان تشخیص بده کاربر Login نیست و مستقیم navigate("login") بکنه. این کار خیلی زود Navigation رو پراکنده و غیرقابل‌پیش‌بینی می‌کنه. بهتره یک Coordinator یا لایه مرکزی ورود Deep Link رو بررسی بکنه:

sealed interface DeepLinkResolution {

    data class Open(
        val destination: DeepLinkDestination
    ) : DeepLinkResolution

    data class RequireLogin(
        val pendingDestination: DeepLinkDestination
    ) : DeepLinkResolution

    data object Invalid : DeepLinkResolution
}

بعد از Login، مقصد ذخیره‌شده مصرف میشه و کاربر به جای Home به همون صفحه‌ای میره که از اول قصد دیدنش رو داشته. این جزئیات شاید کوچک به نظر برسه، ولی تفاوت یک Deep Link واقعی با یک لینک نصفه‌کاره همین‌جاست.

Back Stack بعد از Deep Link

فرض کن کاربر از تلگرام مستقیماً وارد صفحه محصول شده. حالا دکمه Back رو می‌زنه. چه اتفاقی باید بیفته؟ آیا باید به تلگرام برگرده یا داخل قیمت‌بان به صفحه لیست محصولات برسه؟ جواب همیشه یکسان نیست و به نوع Deep Link و طراحی تجربه کاربری بستگی داره.

اگه کاربر از بیرون برنامه وارد یک مقصد شده، رفتار طبیعی Back در خیلی از سناریوها اینه که به برنامه قبلی برگرده. چون در واقع مسیر کاربر این بوده:

تلگرام ← لینک محصول ← قیمت‌بان

در این حالت قرار دادن چند صفحه ساختگی داخل Back Stack ممکنه باعث بشه کاربر حس بکنه داخل برنامه گیر افتاده. اما در بعضی Flowها منطقیه یک سلسله‌مراتب داخلی ساخته بشه. مثلاً وقتی Notification داخلی قیمت‌بان صفحه جزئیات سفارش رو باز می‌کنه، شاید Back باید کاربر رو به لیست سفارش‌ها و بعد Home برگردونه.

Navigation برای Explicit Deep Link می‌تونه Back Stack مناسب رو بر اساس ساختار Navigation Graph بسازه. طبق مستندات، وقتی یک Explicit Deep Link از طریق PendingIntent ساخته میشه، مقصدهای شروع Graphهای والد هم می‌تونن به Back Stack اضافه بشن.

مهم اینه که Back Stack رو آگاهانه طراحی بکنی. Deep Link فقط درباره صفحه‌ای که باز میشه نیست؛ درباره مسیری هم هست که کاربر بعد از باز شدن صفحه طی می‌کنه.

Deep Link از Notification

در درس Notification دیدیم که با PendingIntent میشه مشخص کرد کلیک روی اعلان چه کاری انجام بده. یکی از روش‌های تمیز اینه که Notification به جای دانستن Route داخلی Navigation، یک Deep Link معنادار بسازه. مثلاً:

val deepLinkIntent = Intent(
    Intent.ACTION_VIEW,
    Uri.parse("https://priceban.com/products/$productId"),
    context,
    MainActivity::class.java
)

val pendingIntent = PendingIntent.getActivity(
    context,
    productId.toInt(),
    deepLinkIntent,
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

بعد PendingIntent روی Notification قرار می‌گیره:

val notification = NotificationCompat.Builder(
    context,
    PRICE_ALERTS_CHANNEL_ID
)
    .setSmallIcon(R.drawable.ic_notification)
    .setContentTitle("قیمت کاهش پیدا کرد")
    .setContentText("برای دیدن قیمت جدید لمس کن")
    .setContentIntent(pendingIntent)
    .setAutoCancel(true)
    .build()

مزیت این روش اینه که تمام ورودی‌ها یک زبان مشترک دارن. لینک داخل تلگرام، ایمیل، وب‌سایت و Notification همگی می‌تونن از همون URL استفاده بکنن. برنامه هم فقط یک سیستم برای Parse و Resolve کردن لینک‌ها داره. این خیلی بهتر از حالتیه که Notification یک Route داخلی، وب‌سایت یک URL متفاوت و FCM یک مجموعه Key پراکنده بفرسته.

با این حال Backend بهتره فقط URL یا Event معنادار بفرسته و نباید اسم Composable یا Route داخلی Android رو بدونه. عبارتی مثل product_detail/842 یک جزئیات داخلی اپلیکیشن اندرویده، اما https://priceban.com/products/842 بخشی از قرارداد عمومی محصوله و نسخه وب، اندروید و حتی iOS می‌تونن معنای مشترکی ازش داشته باشن.

امنیت؛ هیچ‌وقت داده Deep Link رو قابل‌اعتماد فرض نکن

Deep Link از بیرون برنامه وارد میشه. بنابراین هر مقداری که داخلش قرار گرفته باید ورودی غیرقابل‌اعتماد در نظر گرفته بشه. کاربر یا یک برنامه مخرب می‌تونه هر URIای بسازه و Activity تو رو با اون اجرا بکنه. اگه برنامه فقط انتظار productId=842 رو داشته باشه، ممکنه مقادیر خالی، منفی، بسیار بزرگ یا متن نامعتبر دریافت بکنه.

این یعنی باید همه Argumentها Parse و Validate بشن. toLong() ممکنه برنامه رو Crash بکنه، در حالی که toLongOrNull() اجازه میده لینک نامعتبر رو کنترل بکنی. همچنین نباید فقط به این دلیل که لینک شناسه یک سفارش رو داره، اطلاعات سفارش رو نمایش بدی. Backend باید همچنان بررسی بکنه کاربر جاری اجازه دسترسی به اون سفارش رو داره یا نه. Deep Link نباید هیچ قانون Permission یا Authorization رو دور بزنه.

پارامترهای حساس هم نباید بدون ضرورت داخل URL قرار بگیرن. URL ممکنه داخل History مرورگر، Log، Analytics، Clipboard یا پیام‌های کاربر باقی بمونه. Token ورود، رمز یک‌بارمصرف بلندمدت یا اطلاعات محرمانه نباید مثل یک Argument عادی در Deep Link قرار بگیره. حتی در Flowهایی مثل تأیید ایمیل یا بازیابی رمز، Token باید کوتاه‌عمر، یک‌بارمصرف و در Backend قابل‌ابطال باشه.

لینک نامعتبر یا محتوای حذف‌شده

حتی یک Deep Link کاملاً معتبر ممکنه به محتوایی اشاره بکنه که دیگه وجود نداره. مثلاً محصول حذف شده، کسب‌وکار غیرفعال شده یا هشدار مربوطه منقضی شده. در این حالت نباید برنامه Crash بکنه یا روی Loading بی‌نهایت باقی بمونه.

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

همچنین باید لینک‌های قدیمی رو جدی بگیری. فرض کن نسخه اول قیمت‌بان لینک‌ها رو با /product/842 منتشر کرده و نسخه جدید از /products/842 استفاده می‌کنه. لینک‌های قبلی ممکنه هنوز داخل پیام‌ها و سایت‌ها وجود داشته باشن. Parser می‌تونه برای مدتی هر دو ساختار رو پشتیبانی بکنه و اون‌ها رو به یک Destination داخلی تبدیل بکنه. این کار شبیه Migration دیتابیسه؛ URL عمومی هم Contract داره و تغییرش بدون سازگاری عقب‌رو می‌تونه تجربه کاربران قدیمی رو خراب بکنه.

Deep Link نباید منطق برنامه رو دور بزنه

ممکنه وسوسه بشی برای هر لینک مستقیم یک Screen باز بکنی و تمام بررسی‌های معمول برنامه رو کنار بذاری. اما Deep Link فقط یک مسیر Navigation متفاوته، نه یک مسیر ویژه برای دور زدن معماری. صفحه محصول باید همون ViewModel، Repository و Use Case معمول رو استفاده بکنه. تنها تفاوت اینه که شناسه محصول از Deep Link اومده، نه از کلیک روی لیست.

بهتره Deep Link در نهایت به همون مدل Destination تبدیل بشه که Navigation داخلی هم استفاده می‌کنه. مثلاً کلیک داخل لیست و لینک خارجی هر دو باید به ProductDetail(productId) برسن. اگه دو مسیر جدا برای این دو حالت بسازی، خیلی زود رفتارها متفاوت میشن؛ یکی Authentication رو بررسی می‌کنه و دیگری نه، یکی Analytics ثبت می‌کنه و دیگری نه، یکی Error State داره و دیگری Crash می‌کنه.

Deep Link باید وارد معماری موجود بشه، نه اینکه یک معماری موازی در کنار اون بسازه.

تست Deep Link با ADB

منتظر نباش برای هر تست یک لینک رو داخل تلگرام بفرستی. می‌تونی با ADB مستقیماً Intent مربوطه رو اجرا بکنی:

adb shell am start \
  -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE \
  -d "https://priceban.com/products/842"

این دستور تقریباً همون کاری رو می‌کنه که کلیک روی لینک انجام میده. باید سناریوهای مختلف رو تست بکنی: برنامه کاملاً بسته، برنامه باز، کاربر Login‌شده، کاربر مهمان، شناسه معتبر، شناسه نامعتبر، محتوای حذف‌شده و لینک دارای Query Parameter.

برای بررسی وضعیت تأیید App Links روی Android 12 به بالا هم میشه فرآیند Verification رو دوباره اجرا کرد و نتیجه دامنه‌ها رو دید:

adb shell pm verify-app-links \
  --re-verify com.priceban.app
adb shell pm get-app-links \
  com.priceban.app

دامنه‌ای که درست تأیید شده باشه باید وضعیت verified داشته باشه. مستندات رسمی تأکید می‌کنن که Verification غیرهم‌زمانه و ممکنه بعد از درخواست، مدتی زمان لازم داشته باشه تا نتیجه نهایی آماده بشه.

معماری پیشنهادی Deep Link برای قیمت‌بان

برای قیمت‌بان بهتره Deep Link رو در چند لایه جدا طراحی بکنیم. در اولین لایه، سیستم اندروید قرار داره که از طریق Manifest تشخیص میده چه لینک‌هایی باید وارد برنامه بشن. این لایه فقط درباره Scheme، Host و Pathهای کلی تصمیم می‌گیره.

در لایه دوم، یک DeepLinkParser قرار می‌گیره که URI خام رو می‌گیره و اون رو به یک مدل داخلی تبدیل می‌کنه. این Parser نباید Navigation انجام بده و نباید ViewModel بسازه. فقط باید ورودی رو بخونه، اعتبارسنجی بکنه و نتیجه معنادار برگردونه.

در لایه سوم، یک DeepLinkResolver یا Coordinator تصمیم می‌گیره آیا مقصد قابل‌باز شدنه یا قبل از اون شرط دیگری وجود داره. مثلاً آیا Login لازمه؟ آیا کاربر باید کسب‌وکار فعال انتخاب بکنه؟ آیا لینک نیاز به Migration داره؟ آیا مقصد متعلق به نسخه جدیدتری از برنامه است؟

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

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

URI ورودی → Parse → Validate → بررسی Session و قوانین → تبدیل به Destination → Navigation → دریافت اطلاعات از Backend

این جداسازی باعث میشه تست هر بخش راحت‌تر باشه. می‌تونی Parser رو با ده‌ها URL مختلف Unit Test بکنی، بدون اینکه Compose یا Activity اجرا بشه. می‌تونی Resolver رو با حالت‌های Login و Logout تست بکنی و Navigation هم فقط مقصد نهایی رو دریافت بکنه.

چند اشتباه رایج

اولین اشتباه اینه که فقط حالت Cold Start تست بشه. لینک وقتی برنامه بسته است درست باز میشه، اما وقتی Activity از قبل در حال اجراست Intent جدید نادیده گرفته میشه. همیشه حالت Warm Start و دریافت چند لینک پشت سر هم رو هم تست بکن.

اشتباه دوم اینه که آبجکت کامل رو داخل URL یا Intent قرار بدی. Deep Link بهتره فقط شناسه پایدار مقصد رو حمل بکنه. اطلاعات کامل باید از منبع اصلی یعنی Repository و Backend گرفته بشه.

اشتباه سوم اینه که URL عمومی رو به Route داخلی Compose گره بزنی. Route ممکنه با Refactor تغییر بکنه، اما URL عمومی باید پایدار بمونه.

اشتباه چهارم اینه که Custom Scheme رو برای دامنه‌ای استفاده بکنی که خودت مالکشی. برای لینک‌های وب‌سایت رسمی، App Links تجربه امن‌تر و حرفه‌ای‌تری فراهم می‌کنه.

اشتباه پنجم اینه که Deep Link رو مساوی Navigation بدون شرط ببینی. Login، Permission، موجود بودن محتوا و مجاز بودن دسترسی همچنان باید بررسی بشن.

اشتباه ششم اینه که لینک نامعتبر باعث Crash بشه. هر Argument خارجی باید با حالت امن Parse و Validate بشه.

اشتباه هفتم اینه که Back Stack رو تصادفی رها بکنی. رفتار دکمه Back بعد از ورود از Deep Link باید مثل بخشی از تجربه کاربری طراحی و تست بشه.

جمع‌بندی

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

برای لینک‌های عمومی قیمت‌بان بهتره از URLهای HTTPS پایدار استفاده بکنیم و با Android App Links مالکیت دامنه رو تأیید بکنیم. Intent Filter فقط لینک رو وارد برنامه می‌کنه؛ بعد از اون Parser باید URI رو به مدل داخلی تبدیل بکنه، Resolver باید وضعیت Login و قوانین دسترسی رو بررسی بکنه و Navigation باید مقصد مناسب رو با Back Stack درست باز بکنه. صفحه مقصد هم نباید به اطلاعات داخل لینک اعتماد کامل داشته باشه و باید داده واقعی رو از Backend یا Repository دریافت بکنه.

مهم‌ترین جمله این درس اینه: Deep Link یک میان‌بر ساده به Screen نیست؛ یک مسیر کامل ورود به برنامه است. وقتی با این دید طراحی بشه، لینک داخل Notification، سایت، تلگرام و ایمیل همگی به یک تجربه منسجم می‌رسن. اما اگه فقط به شکل چند خط Route و Manifest پیاده‌سازی بشه، خیلی زود با مشکلاتی مثل لینک‌های خراب، صفحه‌های بدون اطلاعات، Back Stack نامنظم، دور زدن Login و تفاوت رفتار در حالت باز و بسته بودن برنامه روبه‌رو میشی.


درس سی‌وهشتم

Badge

Badge اصلاً چیه و چرا به وجود اومده؟

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

اینجاست که Badge وارد ماجرا میشه.

Badge همون عدد یا نقطه کوچیکی هست که روی آیکن برنامه نمایش داده میشه و به کاربر میگه داخل این برنامه اتفاق جدیدی افتاده که هنوز بهش رسیدگی نکرده.

مثلاً کنار آیکن Gmail عدد ۱۲ دیده میشه یا کنار Telegram عدد ۳. این عدد خودش Notification نیست؛ فقط یک یادآوریه که میگه هنوز چند مورد باقی مونده.

به عبارت دیگه:

  • Notification توجه لحظه‌ای ایجاد می‌کنه.
  • Badge یادآوری بلندمدته.

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


Badge متعلق به Notification نیست

یکی از اشتباه‌های رایج اینه که Badge رو بخشی از Notification بدونیم. در واقع Badge متعلق به Launcher گوشیه، نه Notification. اندروید فقط اطلاعات لازم رو در اختیار Launcher قرار میده و این خود Launcherـه که تصمیم می‌گیره Badge رو نمایش بده یا نه. به همین دلیل ممکنه:

  • روی گوشی سامسونگ Badge عددی نمایش داده بشه.
  • روی Pixel فقط یک نقطه کوچک دیده بشه.
  • روی بعضی Launcherها اصلاً Badge وجود نداشته باشه.

یعنی برنامه نباید فرض بکنه که همه کاربران دقیقاً یک Badge مشابه می‌بینن. این موضوع یکی از تفاوت‌های مهم اندروید با iOS هم هست. در iOS خود برنامه مستقیماً تعداد Badge را کنترل می‌کنه، اما در اندروید رفتار بیشتر به سیستم و Launcher بستگی داره.


Badge از کجا می‌فهمه اتفاق جدیدی افتاده؟

در اکثر گوشی‌های اندرویدی، Badge بر اساس Notificationهای فعال ساخته میشه. فرض کن قیمت‌بان سه Notification فعال داره:

  • کاهش قیمت لپ‌تاپ
  • کاهش قیمت هدفون
  • تأیید کسب‌وکار

تا زمانی که این Notificationها پاک نشن، Launcher ممکنه Badge رو برابر ۳ نمایش بده. اگر کاربر یکی از Notificationها رو باز کنه یا برنامه اون Notification رو Cancel بکنه، Badge هم تغییر می‌کنه. پس در بیشتر مواقع خودت Badge رو مستقیماً تغییر نمیدی. تو فقط Notificationها رو مدیریت می‌کنی و Launcher نتیجه رو به شکل Badge نمایش میده.


هر Notification نباید Badge داشته باشه

فرض کن داخل قیمت‌بان دو نوع اعلان داریم.

اول:
کاهش قیمت محصولی که کاربر دنبالش می‌کنه.

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

آیا هر دو باید Badge ایجاد بکنن؟ احتمالاً نه. اگر هر Notification باعث Badge بشه، خیلی زود همیشه یک عدد روی آیکن برنامه دیده میشه و Badge ارزش خودش رو از دست میده. Badge بهتره فقط برای چیزهایی استفاده بشه که واقعاً نیاز به رسیدگی کاربر دارن.

مثلاً:

  • هشدار کاهش قیمت
  • پیام جدید
  • سفارش جدید
  • درخواست تأیید

اما اعلان‌هایی مثل:

  • خبرها
  • تبلیغات
  • پیشنهادها
  • اطلاعیه‌های عمومی

معمولاً نباید Badge ایجاد بکنن.


ارتباط Badge با Notification Channel

در درس قبل گفتیم هر Notification داخل یک Channel قرار می‌گیره. یکی از تنظیمات هر Channel اینه که آیا Notificationهای این Channel اجازه دارن Badge ایجاد بکنن یا نه. مثلاً برای Channel مربوط به تغییر قیمت:

channel.setShowBadge(true)

اما برای اعلان‌های تبلیغاتی:

channel.setShowBadge(false)

این تصمیم باعث میشه فقط Notificationهای مهم وارد شمارش Badge بشن. دقت کن که این فقط رفتار اولیه Channel هست. کاربر همچنان می‌تونه داخل تنظیمات گوشی Badge مربوط به اون Channel رو تغییر بده.


Badge جایگزین Notification نیست

گاهی برنامه‌نویس‌ها فکر می‌کنن اگر Badge داریم، دیگه لازم نیست Notification نمایش بدیم. این نگاه اشتباهه. Badge هیچ توضیحی به کاربر نمیده. فرض کن کنار آیکن قیمت‌بان عدد ۲ دیده میشه. این عدد به کاربر نمیگه:

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

Badge فقط میگه: “دو اتفاق وجود داره که هنوز بررسیشون نکردی.” بنابراین Badge مکمل Notificationـه، نه جایگزین اون.


Badge همیشگی؛ یک تجربه کاربری بد

یکی از بدترین تجربه‌های کاربری اینه که همیشه یک Badge روی برنامه باقی بمونه. کاربر بعد از مدتی دیگه اصلاً بهش توجه نمی‌کنه. مثلاً فرض کن قیمت‌بان همیشه عدد ۱ روی آیکنش داشته باشه چون یک Notification قدیمی هیچ‌وقت پاک نشده.

بعد از چند روز کاربر دیگه این Badge رو نمی‌بینه. این دقیقاً همون اتفاقیه که برای هشدارهای بیش از حد هم رخ میده. هر چیزی که همیشه حضور داشته باشه، کم‌کم نامرئی میشه.


چه زمانی Badge باید پاک بشه؟

این سؤال جواب واحدی نداره و به منطق محصول بستگی داره. مثلاً در قیمت‌بان اگر Notification مربوط به کاهش قیمت رو کاربر باز کرد، منطقیه که اون Notification حذف بشه و Badge هم کاهش پیدا بکنه.

اما اگر فقط برنامه رو باز کرد و اصلاً صفحه هشدارها رو ندید، شاید هنوز Badge باید باقی بمونه. بنابراین باید اول مشخص کنی: “کاربر چه زمانی این اتفاق را رسیدگی‌شده محسوب می‌کند؟” بعد بر اساس همون Notificationها را Cancel کنی.


Badge در قیمت‌بان

اگر بخوایم برای قیمت‌بان طراحی مناسبی داشته باشیم، احتمالاً فقط Channel مربوط به هشدارهای قیمت Badge خواهد داشت.

Price Alerts
✓ Badge

Business Status
✓ Badge

General Updates
✗ Badge

Promotions
✗ Badge

این طراحی باعث میشه هر عددی که روی آیکن برنامه دیده میشه، واقعاً معنی داشته باشه.

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


چند اشتباه رایج

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


جمع‌بندی

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

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


درس سی‌ونهم

Foreground Service

چرا Foreground Service به وجود آمد؟

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

مشکل Background Service چه بود؟

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

به همین دلیل گوگل از Android 8 (Oreo) محدودیت‌های سختی برای اجرای Background Service اعمال کرد. از اون به بعد، اگر برنامه‌ای می‌خواست مدت نسبتاً طولانی در پس‌زمینه فعالیت کنه، باید دلیل این کار برای کاربر کاملاً شفاف می‌بود. اندروید دیگه اجازه نمی‌داد برنامه‌ها مخفیانه ساعت‌ها در پس‌زمینه فعال بمونن و دقیقاً برای همین، Foreground Service معرفی شد.


Foreground Service دقیقاً چیست؟

Foreground Service در اصل همون Service معمولیه، اما با یک تفاوت اساسی؛ کاربر همیشه باید بدونه که این سرویس در حال اجراست. به همین خاطر، هر Foreground Service حتماً باید یک Notification دائمی نمایش بده. وقتی Google Maps در حال مسیریابیه، Spotify در حال پخش موسیقیه یا برنامه ضبط صدا مشغول ضبطه، همیشه یک اعلان در Notification Bar دیده میشه. این اعلان فقط برای اطلاع‌رسانی نیست، بلکه به اندروید اعلام می‌کنه که این سرویس واقعاً در حال انجام یک کار مهم و قابل مشاهده برای کاربره.

در واقع اندروید و برنامه یک قرارداد با هم دارن. برنامه میگه: من دارم یک عملیات مهم انجام میدم و کاربر هم ازش خبر داره. اندروید هم در پاسخ میگه: پس اجازه میدم این سرویس نسبت به حالت عادی مدت بیشتری اجرا بشه و با اولین فرصت متوقفش نمی‌کنم. بنابراین Notification در Foreground Service یک قابلیت تزئینی نیست؛ بخشی از مکانیزم امنیتی و مدیریت منابع اندرویده.


چرا Notification اجباری است؟

شاید اولین سؤالی که به ذهنت برسه این باشه که چرا گوگل برنامه‌نویس رو مجبور کرده همیشه Notification نمایش بده. دلیلش اینه که اگر چنین اجباری وجود نداشت، دوباره همون مشکل نسخه‌های قدیمی اندروید تکرار می‌شد و برنامه‌ها می‌توانستن به بهانه Foreground Service ساعت‌ها در پس‌زمینه اجرا بشن، بدون اینکه کاربر کوچک‌ترین اطلاعی از این موضوع داشته باشه.

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


چه زمانی باید از Foreground Service استفاده کنیم؟

نکته مهم اینه که Foreground Service برای کارهای طولانی و قابل مشاهده طراحی شده، نه صرفاً کارهای طولانی. فرض کن Google Maps در حال راهنمایی راننده است. حتی اگر صفحه گوشی خاموش بشه یا کاربر وارد برنامه دیگه‌ای بشه، مسیریابی باید ادامه پیدا کنه و در زمان مناسب دستور بعدی رو اعلام کنه. یا تصور کن Spotify در حال پخش موسیقیه؛ طبیعی نیست که با خروج از برنامه موسیقی قطع بشه. همین موضوع درباره برنامه‌های ضبط صدا، تماس تصویری، برنامه‌های ورزشی که مسیر دویدن رو ثبت می‌کنن یا حتی برنامه‌هایی که در حال آپلود یک فایل بسیار بزرگ هستن هم صدق می‌کنه. در همه این مثال‌ها، کاربر کاملاً انتظار داره عملیات حتی بعد از خروج از برنامه هم ادامه پیدا کنه و به همین دلیل وجود یک Notification دائمی برایش منطقیه.


آیا قیمت‌بان به Foreground Service احتیاج دارد؟

اگر امکانات قیمت‌بان رو بررسی کنیم، تقریباً هیچ بخشی وجود نداره که به Foreground Service نیاز داشته باشه. این برنامه قیمت کالاها رو نمایش میده، محصولات رو جستجو می‌کنه، علاقه‌مندی‌ها رو مدیریت می‌کنه و کاهش قیمت‌ها رو از طریق Firebase Cloud Messaging به کاربر اطلاع میده. هیچ‌کدوم از این عملیات‌ها قرار نیست چندین ساعت به صورت مداوم روی گوشی اجرا بشن.

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


Foreground Service یا WorkManager؟

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

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


ساختار ساده یک Foreground Service

نوشتن خود Foreground Service از نظر کدنویسی پیچیدگی زیادی نداره. ابتدا یک کلاس از Service می‌سازیم.

class MusicService : Service() {

    override fun onBind(intent: Intent?) = null

}

بعد داخل onStartCommand() خیلی سریع سرویس رو به حالت Foreground می‌بریم و Notification رو نمایش میدیم.

override fun onStartCommand(
    intent: Intent?,
    flags: Int,
    startId: Int
): Int {

    startForeground(
        1,
        createNotification()
    )

    return START_NOT_STICKY
}

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

وقتی عملیات تموم شد هم باید سرویس متوقف بشه.

stopSelf()

اگر این کار انجام نشه، Notification بی‌دلیل روی گوشی باقی می‌مونه و سرویس هم منابع سیستم رو اشغال می‌کنه.


چند اشتباه رایج

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


جمع‌بندی

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

قبل از اینکه تصمیم بگیری از Foreground Service استفاده کنی، همیشه از خودت بپرس: اگر کاربر از برنامه خارج شود، آیا انتظار دارد این عملیات همچنان ادامه پیدا کند و از ادامه داشتن آن هم باخبر باشد؟ اگر پاسخ مثبت است، احتمالاً Foreground Service انتخاب درستی است. اما اگر پاسخ منفی است، بهتر است به سراغ ابزارهایی مثل WorkManager بروی.

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


درس چهلم

Background Sync

اصلاً Background Sync چیه؟

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

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

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


چرا اندروید خودش زمان Sync رو تعیین می‌کنه؟

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

هر یک دقیقه اطلاعات رو بررسی کن.

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

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


Background Sync دقیقاً چه چیزهایی رو Sync می‌کنه؟

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

  • لیست کالاهای ذخیره‌شده توسط کاربر
  • تغییرات پروفایل
  • علاقه‌مندی‌ها
  • تنظیمات برنامه
  • تاریخچه جستجو (اگر روی سرور نگهداری بشه)
  • اطلاعاتی که هنگام آفلاین بودن کاربر ثبت شده و حالا باید به سرور ارسال بشه

یعنی گاهی Sync فقط دانلود اطلاعاته، گاهی فقط آپلود اطلاعاته و خیلی وقت‌ها هم هر دو با هم انجام میشن.


آیا قیمت‌بان اصلاً به Background Sync احتیاج داره؟

بله، اما نه برای همه چیز.

مثلاً قیمت کالاها دائماً تغییر می‌کنن. آیا منطقیه گوشی هر چند دقیقه همه قیمت‌ها رو دوباره دانلود کنه؟

خیر.

این دقیقاً همون جاییه که Firebase Cloud Messaging که چند درس قبل یاد گرفتیم، انتخاب بهتریه. سرور هر زمان قیمت مهمی تغییر کرد، فقط یک Notification برای کاربر ارسال می‌کنه و دیگه لازم نیست گوشی دائماً از سرور سؤال بپرسه.

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


همیشه لازم نیست اطلاعات کاملاً به‌روز باشن

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

برای خیلی از داده‌ها جواب این سؤاله: تقریباً هیچ اتفاقی.

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


Sync یک خیابون دوطرفه است

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

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


اگر اینترنت قطع باشه چی؟

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

راه درست اینه که تغییرات ابتدا داخل دیتابیس یا حافظه محلی ذخیره بشن و هر زمان شرایط مناسب شد، دوباره برای سرور ارسال بشن. به همین دلیل در بسیاری از برنامه‌های حرفه‌ای، Sync بیشتر شبیه یک صف از کارهای منتظر اجراست تا یک درخواست لحظه‌ای.


Background Sync معمولاً با چه ابزاری انجام میشه؟

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

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


ساختار ساده یک Background Sync

فرض کن می‌خوای علاقه‌مندی‌های کاربر با سرور هماهنگ بشن. یک Worker ساده ممکنه به این شکل باشه:

class SyncWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {

        repository.syncFavorites()

        return Result.success()
    }

}

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


معمولاً چه اشتباه‌هایی تو Background Sync پیش میاد؟

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

اشتباه بعدی اینه که منطق همگام‌سازی داخل Activity نوشته بشه. اگر کاربر Activity رو ببنده، کل عملیات هم از بین میره. بهتره این منطق در لایه Repository و با کمک WorkManager اجرا بشه تا به رابط کاربری وابسته نباشه.

در نهایت، بعضی برنامه‌ها همه اطلاعات رو در هر بار Sync دوباره دانلود می‌کنن، حتی اگر فقط یک رکورد تغییر کرده باشه. این کار هم پهنای باند بیشتری مصرف می‌کنه و هم سرعت برنامه رو کاهش میده. همیشه بهتره فقط داده‌هایی منتقل بشن که واقعاً تغییر کردن.


جمع‌بندی

Background Sync یعنی هماهنگ نگه داشتن اطلاعات بین گوشی و سرور، بدون اینکه کاربر مجبور باشه خودش این کار رو انجام بده. هدف اصلی آن این نیست که اطلاعات همیشه در همان لحظه به‌روز باشن؛ هدف اینه که اطلاعات در زمان مناسب، با کمترین مصرف باتری و اینترنت همگام‌سازی بشن.

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


درس چهل‌ویکم

WorkManager

اصلاً WorkManager چرا به وجود اومد؟

تا چند درس قبل درباره Foreground Service و Background Sync صحبت کردیم. حالا احتمالاً یک سؤال برات پیش اومده؛ اگر قراره بعضی کارها در پس‌زمینه انجام بشن، پس اصلاً WorkManager چه فرقی با Foreground Service داره؟

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

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

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

گوگل برای حل این آشفتگی، WorkManager رو معرفی کرد. ایده اصلی خیلی ساده بود: اگر کاری مهمه و باید حتماً انجام بشه، اون رو به WorkManager بسپار.


WorkManager دقیقاً چیه؟

WorkManager یک کتابخونه از Jetpackـه که وظیفه داره کارهای قابل‌تعویق (Deferrable Tasks) رو مدیریت کنه. منظور از قابل‌تعویق اینه که لازم نیست همین الآن اجرا بشن، اما نباید هم فراموش بشن.

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

در واقع WorkManager بیشتر از اینکه یک ابزار اجرای کد باشه، یک مدیر زمان‌بندی و تضمین اجرای کارهاست.


مهم‌ترین ویژگی WorkManager چیه؟

اگر بخوام فقط یک ویژگی WorkManager رو انتخاب کنم، اون اینه: تضمین می‌کنه کار بالاخره اجرا بشه.

ممکنه کاربر:

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

تا وقتی شرایط لازم فراهم بشه، WorkManager دوباره تلاش می‌کنه اون کار رو انجام بده. به همین دلیل از WorkManager برای کارهایی استفاده می‌کنیم که از دست رفتن اون‌ها قابل قبول نیست.


WorkManager چه کارهایی انجام میده؟

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

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


چه کارهایی رو نباید به WorkManager بسپریم؟

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

به طور کلی، اگر کاربر منتظر نتیجه آنی یک عملیات باشه، WorkManager انتخاب مناسبی نیست.


WorkManager با Foreground Service چه فرقی داره؟

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

اما WorkManager برای کارهاییه که لازم نیست همین الآن انجام بشن. فقط باید مطمئن باشیم بالاخره اجرا میشن. اگر بخوام خیلی خلاصه تفاوتشون رو بگم:

Foreground Service میگه: همین الآن اجرا میشم.
WorkManager میگه: هر وقت شرایط مناسب شد اجرا میشم، ولی حتماً اجرا میشم.

همین تفاوت باعث میشه انتخاب بین این دو معمولاً خیلی ساده باشه.


WorkManager از کجا می‌فهمه الان زمان مناسبی برای اجراست؟

یکی از جذاب‌ترین ویژگی‌های WorkManager اینه که خودش با سیستم‌عامل همکاری می‌کنه. مثلاً می‌تونه صبر کنه تا:

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

به این شرایط، Constraints گفته میشه.

یعنی برنامه نمیگه: الآن اجرا شو.

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


Worker چیه؟

همون‌طور که Activity محل اجرای رابط کاربریه، در WorkManager هم واحد اصلی اجرا Worker نام داره. هر Worker فقط مسئول انجام یک کار مشخصه.

مثلاً:

  • Sync کردن علاقه‌مندی‌ها
  • ارسال گزارش خطا
  • پاک کردن فایل‌های Cache
  • آپلود عکس پروفایل

بهتره هر Worker فقط یک مسئولیت داشته باشه. اگر یک Worker ده کار مختلف انجام بده، هم تست کردنش سخت میشه و هم نگهداریش.


یک Worker ساده

فرض کن می‌خوای علاقه‌مندی‌های کاربر با سرور هماهنگ بشن.

class SyncFavoritesWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {

        repository.syncFavorites()

        return Result.success()
    }

}

تمام کاری که Worker انجام میده داخل تابع doWork نوشته میشه.
اگر عملیات با موفقیت انجام شد، Result.success برمی‌گردونیم.
اگر لازم باشه بعداً دوباره تلاش بشه، از Result.retry استفاده می‌کنیم.
اگر هم عملیات قابل انجام نباشه، Result.failure برگردونده میشه.


چطوری WorkManager رو اجرا کنیم؟

بعد از ساخت Worker، باید اون رو به WorkManager معرفی کنیم.

val request =
    OneTimeWorkRequestBuilder<SyncFavoritesWorker>()
        .build()

WorkManager
    .getInstance(context)
    .enqueue(request)

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


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

همه کارها شبیه هم نیستن. بعضی کارها فقط یک بار انجام میشن. مثلاً:

  • ارسال علاقه‌مندی‌های جدید
  • آپلود یک عکس
  • ارسال گزارش Crash

اما بعضی کارها باید به صورت دوره‌ای اجرا بشن.

مثلاً:

  • پاک کردن Cache
  • Sync کردن اطلاعات
  • بررسی فایل‌های قدیمی

برای این نوع کارها میشه از Periodic Work استفاده کرد تا WorkManager خودش در بازه‌های زمانی مشخص اون‌ها رو اجرا کنه.


اگر اجرای Worker شکست بخوره چی؟

هیچ تضمینی وجود نداره که عملیات همیشه در اولین تلاش موفق باشه. ممکنه:

  • اینترنت قطع باشه.
  • سرور در دسترس نباشه.
  • Timeout اتفاق بیفته.

در این شرایط نباید سریع تسلیم بشیم. اگر احتمال میدی مشکل موقتیه، می‌تونی Result.retry برگردونی تا WorkManager بعداً دوباره تلاش بکنه. این یکی از مهم‌ترین مزیت‌های WorkManager نسبت به اجرای دستی Threadها یا Coroutineهاست.


معمولاً چه اشتباه‌هایی تو WorkManager پیش میاد؟

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

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


جمع‌بندی

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

برای پروژه‌ای مثل قیمت‌بان، WorkManager می‌تونه انتخاب خیلی خوبی برای همگام‌سازی اطلاعات، ارسال تغییرات ذخیره‌شده در حالت آفلاین، پاک‌سازی فایل‌های موقت یا هر عملیات دیگه‌ای باشه که فوریت نداره، اما نباید فراموش بشه. هر وقت با چنین مسئله‌ای روبه‌رو شدی، قبل از اینکه سراغ Service یا Threadهای مختلف بری، اول از خودت بپرس: آیا فقط می‌خوام مطمئن بشم این کار بالاخره انجام میشه؟ اگر جواب مثبت بود، احتمال خیلی زیاد WorkManager همون ابزاریه که دنبالش هستی.


درس چهل‌ودوم

Broadcast Receiver

اصلاً Broadcast Receiver چرا به وجود اومد؟

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

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


Broadcast یعنی چی؟

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

Broadcast در اندروید هم دقیقاً همین ایده رو داره. سیستم‌عامل یک پیام عمومی منتشر می‌کنه و هر برنامه‌ای که برای دریافت اون پیام ثبت شده باشه، اون رو دریافت می‌کنه. در واقع اندروید دنبال برنامه‌ها نمی‌گرده؛ فقط پیام رو منتشر می‌کنه و این Receiverها هستن که به اون گوش میدن.


Broadcast Receiver دقیقاً چیه؟

Broadcast Receiver یک کامپوننت اندرویده که وظیفه‌اش واکنش نشون دادن به یک رویداده. نکته مهم اینه که Receiver قرار نیست خودش کارهای سنگین انجام بده. نقش اون بیشتر شبیه یک نگهبانه؛ منتظر می‌مونه تا اتفاق خاصی بیفته، بعد برنامه رو از اون اتفاق باخبر می‌کنه.

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


Broadcastها از کجا میان؟

همه Broadcastها از یک جا ارسال نمیشن. بعضی از اون‌ها توسط خود سیستم‌عامل منتشر میشن و بعضی دیگه رو خود برنامه‌ها ارسال می‌کنن.

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


قیمت‌بان چه استفاده‌ای از Broadcast Receiver می‌تونه بکنه؟

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

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

یا فرض کن گوشی بعد از خاموش بودن، دوباره روشن میشه. اگر برنامه زمان‌بندی یا تنظیماتی داشته باشه که بعد از بوت شدن باید دوباره آماده بشن، Receiver می‌تونه این رویداد رو دریافت کنه و مقدمات لازم رو فراهم کنه.


آیا Receiver باید کارهای سنگین انجام بده؟

یکی از مهم‌ترین اشتباه‌هایی که برنامه‌نویس‌های تازه‌کار انجام میدن اینه که داخل Receiver شروع به انجام عملیات سنگین می‌کنن؛ مثلاً چندین درخواست شبکه ارسال می‌کنن، فایل‌های بزرگ رو پردازش می‌کنن یا عملیات طولانی اجرا می‌کنن.

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


یک Broadcast Receiver ساده

ساختن یک Receiver از نظر کدنویسی خیلی ساده است. فقط کافیه از کلاس BroadcastReceiver ارث‌بری کنیم و متد onReceive() رو پیاده‌سازی کنیم.

class NetworkReceiver : BroadcastReceiver() {

    override fun onReceive(
        context: Context,
        intent: Intent
    ) {

        // واکنش به رویداد

    }

}

هر زمان Broadcast مربوطه ارسال بشه، متد onReceive() اجرا میشه. چیزی که داخل این متد می‌نویسی، باید کوتاه و سریع باشه.


Receiver از کجا می‌فهمه کدوم Broadcast رو دریافت کنه؟

Receiver فقط زمانی اجرا میشه که برای یک رویداد مشخص ثبت شده باشه. این ثبت شدن یا داخل فایل Manifest انجام میشه یا در زمان اجرای برنامه به صورت داینامیک انجام میشه.

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


ثبت Receiver در Manifest

اگر بخوای Receiver حتی زمانی که برنامه باز نیست هم بعضی Broadcastها رو دریافت کنه، معمولاً اون رو داخل Manifest معرفی می‌کنی.

<receiver
    android:name=".BootReceiver"
    android:exported="true">

    <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED"/>
    </intent-filter>

</receiver>

در این مثال، بعد از اینکه گوشی کاملاً بوت بشه، اندروید این Receiver رو اجرا می‌کنه.


Receiver داینامیک

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

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


Broadcast Receiver یا WorkManager؟

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


معمولاً چه اشتباه‌هایی تو Broadcast Receiver پیش میاد؟

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

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


جمع‌بندی

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

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


درس چهل‌وسوم

Boot Completed

اصلاً Boot Completed یعنی چی؟

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

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


اصلاً چرا چنین Broadcastی وجود داره؟

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

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


قیمت‌بان به Boot Completed احتیاج داره؟

در بیشتر مواقع، خیر. اگر به قابلیت‌های قیمت‌بان نگاه کنیم، تقریباً همه کارها یا با باز شدن برنامه انجام میشن یا از طریق Firebase Cloud Messaging و WorkManager مدیریت میشن. این یعنی بعد از روشن شدن گوشی، معمولاً نیازی نیست برنامه فوراً کاری انجام بده.

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


Boot Completed چه کاری نباید انجام بده؟

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

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

به همین دلیل Boot Completed باید فقط کارهای ضروری رو انجام بده. اگر عملیات سنگینی وجود داره، بهتره فقط اون رو به WorkManager بسپره و خودش خیلی سریع کارش رو تموم کنه.


Boot Completed چه ارتباطی با Broadcast Receiver داره؟

در درس قبل یاد گرفتیم که Broadcast Receiver منتظر یک رویداد می‌مونه و وقتی اون رویداد اتفاق افتاد، اجرا میشه. BOOT_COMPLETED هم دقیقاً یکی از همین Broadcastهاست.

یعنی وقتی گوشی کاملاً بوت شد، اندروید این Broadcast رو منتشر می‌کنه و Receiver مربوط به اون اجرا میشه. در واقع Boot Completed خودش یک کامپوننت جدید نیست؛ فقط یکی از Broadcastهای سیستمیه که توسط یک Broadcast Receiver دریافت میشه.


یک Receiver ساده برای Boot Completed

ابتدا یک Receiver می‌سازیم.

class BootReceiver : BroadcastReceiver() {

    override fun onReceive(
        context: Context,
        intent: Intent
    ) {

        // انجام کارهای ضروری

    }

}

همون‌طور که می‌بینی، این کلاس هیچ تفاوتی با Receiverهای دیگه نداره. تنها تفاوت در Broadcastیه که دریافت می‌کنه.


چطور به اندروید بگیم این Receiver برای Boot Completed هست؟

برای این کار باید Receiver داخل Manifest ثبت بشه.

<receiver
    android:name=".BootReceiver"
    android:exported="true">

    <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED"/>
    </intent-filter>

</receiver>

بعد از این، هر بار که گوشی کاملاً روشن بشه، اندروید این Receiver رو اجرا می‌کنه.


یک Permission هم لازم داریم

دریافت Broadcast مربوط به Boot Completed به Permission احتیاج داره.

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

اگر این Permission داخل Manifest وجود نداشته باشه، Receiver هیچ‌وقت Broadcast مربوط به روشن شدن دستگاه رو دریافت نمی‌کنه.


داخل BootReceiver بهتره چه کاری انجام بدیم؟

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

override fun onReceive(
    context: Context,
    intent: Intent
) {

    val request =
        OneTimeWorkRequestBuilder<SyncWorker>()
            .build()

    WorkManager
        .getInstance(context)
        .enqueue(request)

}

در اینجا BootReceiver فقط متوجه روشن شدن گوشی شده و انجام کار اصلی رو به WorkManager سپرده. این دقیقاً همون تقسیم مسئولیتی هست که در معماری خوب اندروید دنبالش هستیم.


آیا Boot Completed همیشه اجرا میشه؟

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

از طرف دیگه، در نسخه‌های جدید اندروید هم محدودیت‌های بیشتری روی اجرای خودکار برنامه‌ها وجود داره. بنابراین نباید Boot Completed رو به عنوان ابزاری در نظر بگیری که همیشه و در هر شرایطی بدون محدودیت اجرا میشه.


معمولاً چه اشتباه‌هایی تو Boot Completed پیش میاد؟

یکی از اشتباه‌های رایج اینه که برنامه بعد از روشن شدن گوشی شروع به انجام عملیات سنگین می‌کنه. بهتره Receiver فقط کارهای ضروری رو انجام بده و بقیه عملیات رو به WorkManager واگذار کنه. اشتباه بعدی اینه که برنامه برای هر پروژه‌ای Boot Completed اضافه می‌کنه، حتی وقتی هیچ نیازی بهش نداره. هر Receiver اضافی یعنی پیچیدگی بیشتر و مصرف منابع بیشتر. اگر هیچ زمان‌بندی یا عملیات ضروری بعد از بوت وجود نداره، اصلاً نیازی به این Broadcast نیست.

بعضی توسعه‌دهنده‌ها هم فراموش می‌کنن Permission مربوط به RECEIVE_BOOT_COMPLETED رو داخل Manifest قرار بدن و بعد تعجب می‌کنن که چرا Receiver اجرا نمیشه.


جمع‌بندی

Boot Completed یکی از Broadcastهای سیستمی اندرویده که بعد از پایان روشن شدن دستگاه منتشر میشه. برنامه‌هایی که واقعاً لازم دارن بعد از بوت شدن گوشی بعضی تنظیمات یا زمان‌بندی‌ها رو دوباره آماده کنن، می‌تونن از این Broadcast استفاده کنن.

در پروژه‌ای مثل قیمت‌بان احتمالاً استفاده از Boot Completed خیلی محدود یا حتی کاملاً غیرضروریه، چون بیشتر عملیات‌های مهم توسط Firebase Cloud Messaging و WorkManager مدیریت میشن. این خودش یک نکته مهم در طراحی نرم‌افزاره؛ حرفه‌ای بودن فقط به این نیست که همه قابلیت‌های اندروید رو بلد باشی، بلکه باید بدونی کدوم قابلیت واقعاً برای پروژه‌ات لازمه و کدوم فقط پیچیدگی اضافه ایجاد می‌کنه.