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

درس ۱
ساخت اولین اپلیکیشن واقعی با Jetpack Compose

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

ساختار اولیه Compose

در پروژه‌های جدید اندروید، رابط کاربری را با Compose می‌سازیم. برخلاف روش قدیمی XML، دیگر فایل Layout جدا نداریم. در Compose، UI فقط یک تابع Kotlin است. مثلاً:

@Composable
fun Greeting() {

    Text(
        text = "سلام اندروید"
    )

}

اینجا یک Composable ساخته‌ایم. هر چیزی که با @Composable مشخص شود، می‌تواند بخشی از رابط کاربری باشد.


ساخت اولین صفحه

داخل MainActivity:

class MainActivity : ComponentActivity() {

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

        setContent {

            ProfileScreen()

        }
    }
}

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


ساخت Composable صفحه

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

@Composable
fun ProfileScreen(){

    Text(
        text = "پروفایل کاربران"
    )

}

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


Column

وقتی می‌خواهیم عناصر را عمودی زیر هم قرار بدهیم از Column استفاده می‌کنیم. مثلاً:

Column {

    Text(
        text = "علی"
    )

    Text(
        text = "برنامه نویس اندروید"
    )

}

خروجی:
علی
برنامه نویس اندروید

یعنی:

Column

Text

Text

Text

همه زیر هم قرار می‌گیرند.


Row

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

Row {

    Text(
        text = "علی"
    )

    Text(
        text = "تهران"
    )

}

خروجی:

علی تهران


Box

گاهی می‌خواهیم عناصر روی هم قرار بگیرند. مثلاً عکس پروفایل که یک علامت آنلاین روی آن قرار دارد. برای این کار:

Box {

    Image(
        painter = painterResource(
            id = R.drawable.profile
        ),
        contentDescription = null
    )

    Text(
        text = "Online"
    )

}

اینجا Text روی Image قرار می‌گیرد.

painterResource چیست؟

وقتی بخوای یک فایل تصویری که داخل پوشه res قرار داره نمایش بدی، از painterResource استفاده می‌کنی. این تابع فایل تصویر رو از منابع پروژه می‌خونه و آماده نمایش داخل Composableهایی مثل Image می‌کنه.

R.drawable.profile1 از کجا میاد؟

حرف R مخفف Resources است. اندروید به صورت خودکار برای تمام فایل‌های داخل پوشه res یک کلاس به نام R می‌سازه. بنابراین وقتی فایل profile1.png را داخل res/drawable قرار می‌دی، اندروید خودش این شناسه را تولید می‌کنه:

R.drawable.profile1

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

چرا پسوند فایل را نمی‌نویسیم؟

لازم نیست پسوند فایل مثل .png یا .jpg را بنویسیم، چون اندروید خودش از روی منابع پروژه تشخیص می‌ده فایل مربوط به کدوم تصویره.
بنابراین فقط اسم فایل را می‌نویسیم R.drawable.profile1
نه R.drawable.profile1.png

آیا contentDescription اجباری است؟

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


ساخت کارت کاربر با Card

حالا می‌خواهیم یک کارت واقعی بسازیم.

@Composable
fun UserCard(){

    Card {

        Column {

            Text(
                text = "علی احمدی"
            )

            Text(
                text = "Android Developer"
            )

        }

    }

}

Card خودش یک Container آماده است که ظاهر کارت مانند ایجاد می‌کند. مثلاً برای:

  • پروفایل
  • محصول
  • خبر
  • تنظیمات

خیلی استفاده می‌شود.


اضافه کردن Image

برای نمایش عکس، اول عکس را داخل پوشه:

res/drawable

قرار می‌دهیم. مثلاً:

profile.jpg

بعد:

Image(

    painter = painterResource(
        id = R.drawable.profile
    ),

    contentDescription = "Profile Image"

)

painterResource عکس را از منابع پروژه می‌خواند.


ساخت کارت کامل‌تر

حالا همه چیز را کنار هم می‌گذاریم:

@Composable
fun UserCard(){

    Card {

        Row {

            Image(
                painter = painterResource(
                    id = R.drawable.profile
                ),
                contentDescription = null
            )


            Column {

                Text(
                    text = "علی احمدی"
                )

                Text(
                    text = "Android Developer"
                )

                Button(
                    onClick = {

                    }
                ){

                    Text(
                        text = "مشاهده"

                    )

                }

            }

        }

    }

}

Button و onClick

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


Spacer برای ایجاد فاصله بین عناصر

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


Modifier چیست؟

یکی از مهم‌ترین مفاهیم Compose همین Modifier است.
Modifier یعنی تغییر دادن ظاهر یا رفتار یک Composable

مثلاً:

  • اندازه
  • فاصله
  • رنگ
  • کلیک
  • شکل
  • پس‌زمینه

padding

برای فاصله دادن استفاده می‌شود. مثلاً:

Text(

    text = "Ali",

    modifier = Modifier.padding(
        16.dp
    )

)

یعنی متن از اطراف ۱۶dp فاصله داشته باشد.


fillMaxSize

برای پر کردن فضای موجود:

Column(

    modifier = Modifier
        .fillMaxSize()

){

}

یعنی Column کل صفحه را بگیرد.


background

تغییر رنگ پس‌زمینه:

Column(

modifier = Modifier
    .background(Color.Gray)
){

}

size

تعیین اندازه:

Image(

modifier = Modifier
    .size(80.dp)
)

یعنی عکس ۸۰ در ۸۰ باشد.


clip

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

Image(

modifier = Modifier
    .size(80.dp)
    .clip(
        CircleShape
    )

)

حالا عکس گرد می‌شود.


clickable

اضافه کردن کلیک:

Card(

modifier = Modifier
    .clickable {

        println("Clicked")

    }

){

}

حالا کل کارت قابل کلیک است.


weight برای تقسیم فضای باقی‌مانده

گاهی داخل یک Row یا Column می‌خواهیم یک بخش، فضای خالی باقی‌مانده را اشغال کند. برای این کار از weight استفاده می‌کنیم. مثلاً:

modifier = Modifier.weight(1f)

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

تغییر رنگ Card با containerColor

شاید این سؤال پیش بیاد که چرا برای تغییر رنگ کارت از Modifier.background استفاده نکردیم. دلیلش اینه که Card خودش یک کامپوننت آماده‌ی Material است و رنگ داخلیش را از طریق پارامتر colors کنترل می‌کنه:

colors = CardDefaults.cardColors(
    containerColor = Color.White
)

اگر از Modifier.background استفاده کنیم، فقط پشت کارت رنگ می‌گیره و ممکنه با گوشه‌های گرد RoundedCornerShape یا ظاهر خود کارت هماهنگ نباشه. بنابراین برای کامپوننت‌هایی مثل Card، Button و TextField معمولاً از تنظیمات داخلی خودشون برای تغییر رنگ استفاده می‌کنیم، نه از Modifier.background.


راست‌چین کردن کل برنامه (RTL)

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

        setContent {

            CompositionLocalProvider(
                LocalLayoutDirection provides LayoutDirection.Rtl
            ) {

                ProfileScreen()

            }
        }

به این ترتیب، همه‌ی Composableهای داخل برنامه مثل Row، Column، Card و سایر اجزا به‌صورت خودکار راست‌به‌چپ نمایش داده می‌شوند و دیگر لازم نیست برای هر صفحه یا هر کارت جداگانه تنظیم RTL را تکرار کنیم. این روش هم تمیزتر است و هم در پروژه‌های بزرگ نگهداری کد را خیلی ساده‌تر می‌کند.


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

حالا یک صفحه با چند کاربر می‌سازیم:

@Composable
fun ProfileScreen(){

    Column(

        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)

    ){

        UserCard()

        UserCard()

        UserCard()

    }

}

استفادهٔ مجدد از Composableها

یکی از مهم‌ترین مزیت‌های Compose اینه که هر Composable مثل یک قطعهٔ آماده عمل می‌کنه. یک بار UserCard() رو می‌سازیم و بعد هر جا که بخوایم، فقط با نوشتن UserCard() دوباره از همون کارت استفاده می‌کنیم. فعلاً چون اطلاعات داخل کارت ثابت نوشته شده، همه‌ی کارت‌ها دقیقاً شبیه هم هستن. در درس‌های بعدی یاد می‌گیریم اطلاعات هر کارت رو از بیرون دریافت کنیم تا هر کدوم محتوای متفاوتی داشته باشن.

اما مشکل چیست؟ اگر هزار کاربر داشته باشیم، نباید همه را یکجا بسازیم. اینجاست که LazyColumn وارد می‌شود.


LazyColumn

LazyColumn مثل RecyclerView قدیمی عمل می‌کند. یعنی فقط آیتم‌هایی که روی صفحه دیده می‌شوند را می‌سازد. مثلاً:

LazyColumn {

    items(100){

        UserCard()

    }

}

اینجا ۱۰۰ کارت داریم، ولی Compose فقط موارد لازم را می‌سازد. برای لیست‌های بزرگ همیشه از LazyColumn استفاده می‌کنیم.


ساخت مدل کاربر

به جای اطلاعات ثابت:

Text(
    text = "علی"
)

بهتر است مدل داشته باشیم:

data class User(

    val name:String,

    val job:String

)

حالا:

val users = listOf(

    User(
        "علی",
        "Developer"
    ),

    User(
        "رضا",
        "Designer"
    )

)

نمایش لیست کاربران

LazyColumn {

    items(users){

        user ->

        UserCard(
            user
        )

    }

}

و کارت:

@Composable
fun UserCard(
    user:User
){

    Card {

        Text(
            text = user.name
        )

    }

}

حالا UI ما از داده جدا شده.


درس ۲
ساخت لیست حرفه‌ای و نمایش داده از JSON

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

Text(
    text = "Ali"
)

اما در یک اپلیکیشن واقعی، اطلاعات کاربران داخل کد قرار نمی‌گیرد. معمولاً اطلاعات از یکی از این منابع می‌آید:

  • فایل JSON داخل پروژه
  • دیتابیس
  • API سرور

در این درس فعلاً با ساده‌ترین حالت شروع می‌کنیم. یعنی یک فایل JSON داخل خود پروژه می‌سازیم، اطلاعات کاربران را از آن می‌خوانیم و داخل LazyColumn نمایش می‌دهیم.

ساختاری که در پایان داریم تقریباً شبیه این است:

JSON File

Kotlin Object

LazyColumn

UserCard


ساخت فایل JSON

اول داخل پروژه یک پوشه می‌سازیم:

app

 └── src

      └── main

           └── assets

داخل آن یک فایل ایجاد می‌کنیم:

users.json

محتوای فایل:

[
  {
    "id":1,
    "name":"Ali",
    "job":"Android Developer"
  },
  {
    "id":2,
    "name":"Reza",
    "job":"Designer"
  },
  {
    "id":3,
    "name":"Sara",
    "job":"Product Manager"
  }
]

اینجا یک آرایه JSON داریم که چند کاربر داخل آن قرار گرفته است. هر کاربر سه ویژگی دارد
id name job


ساخت Model در Kotlin

حالا باید یک کلاس Kotlin بسازیم که بتواند این اطلاعات را نگهداری کند. یک فایل بساز:

User.kt

و داخل آن:

data class User(

    val id:Int,

    val name:String,

    val job:String

)

این کلاس مدل داده ماست. یعنی هر آبجکت User نماینده یک کاربر است. مثلاً:

User(
    id = 1,
    name = "Ali",
    job = "Developer"
)

چرا از Data Class استفاده می‌کنیم؟

در Kotlin وقتی یک کلاس فقط برای نگهداری داده است، معمولاً از data class استفاده می‌کنیم. چون Kotlin به صورت خودکار چیزهایی مثل:

  • مقایسه آبجکت‌ها
  • نمایش متن
  • کپی کردن

را برای ما می‌سازد.

مثلاً:

val user2 = user1.copy(
    name = "Reza"
)

خواندن فایل JSON

حالا باید فایل JSON را از assets بخوانیم. یک تابع ساده می‌سازیم:

fun loadJson(
    context: Context
): String {

    return context.assets
        .open("users.json")
        .bufferedReader()
        .use {
            it.readText()
        }

}

این کد چه کار می‌کند؟ اول:

context.assets

به پوشه assets دسترسی می‌گیرد. بعد:

.open("users.json")

فایل JSON را باز می‌کند. بعد:

.readText()

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

[
 {
  "id":1,
  "name":"Ali"
 }
]

اما هنوز این فقط یک متن است. باید تبدیلش کنیم به Objectهای Kotlin.


تبدیل JSON به Object Kotlin

برای این کار معمولاً از کتابخانه Gson یا Kotlin Serialization استفاده می‌کنیم. فعلاً برای ساده بودن از Gson استفاده می‌کنیم. در Gradle:

implementation(
    "com.google.code.gson:gson:2.10.1"
)

حالا:

val gson = Gson()

val users = gson.fromJson(
    json,
    Array<User>::class.java
)

حالا JSON تبدیل شده به:

Array<User>

یعنی:

[
 User(
   1,
   "Ali",
   "Developer"
 ),

 User(
   2,
   "Reza",
   "Designer"
 )
]

ساخت Repository ساده

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

UserRepository.kt

می‌سازیم:

class UserRepository(

    private val context: Context

){

    fun getUsers():List<User>{

        val json =
            context.assets
                .open("users.json")
                .bufferedReader()
                .readText()


        return Gson()
            .fromJson(
                json,
                Array<User>::class.java
            )
            .toList()

    }

}

حالا UI ما مستقیم کاری به JSON ندارد. فقط می‌گوید:

repository.getUsers()

ساخت LazyColumn

حالا می‌خواهیم کاربران را نمایش دهیم.

@Composable
fun UserList(
    users:List<User>
){

    LazyColumn {

        items(users){

            user ->

            UserCard(
                user
            )

        }

    }

}

اینجا:

items(users)

یعنی برای هر User یک آیتم بساز. مثلاً کاربر اول:

UserCard(Ali)

کاربر دوم:

UserCard(Reza)

ساخت Card قابل استفاده مجدد

یکی از نکات مهم Compose این است که UI را به قطعات کوچک تقسیم کنیم. به جای اینکه داخل LazyColumn همه چیز را بنویسیم:

LazyColumn {

    Text()

    Image()

    Button()

}

یک Composable جدا می‌سازیم.

@Composable
fun UserCard(
    user:User
){

    Card(

        modifier =
        Modifier
            .padding(8.dp)

    ){

        Column(

            modifier =
            Modifier.padding(16.dp)

        ){

            Text(
                text = user.name
            )

            Text(
                text = user.job
            )

        }

    }

}

حالا هر جا در پروژه نیاز داشتیم:

UserCard(user)

را صدا می‌زنیم.


LazyRow چیست؟

LazyColumn برای لیست عمودی است. مثلاً:

Ali

Reza

Sara

اما گاهی لیست افقی می‌خواهیم. مثلاً:

[ عکس ][ عکس ][ عکس ][ عکس ]

اینجا:

LazyRow

استفاده می‌کنیم. مثلاً:

LazyRow {

    items(users){

        user ->

        UserCard(user)

    }

}

فرق LazyColumn و LazyRow

خیلی ساده:

LazyColumn

لیست عمودی

LazyRow

لیست افقی

مثلاً صفحه اصلی فروشگاه:

محصولات ویژه

[ محصول ][ محصول ][ محصول ]


همه محصولات

محصول

محصول

محصول

معمولاً بالا:

LazyRow

پایین:

LazyColumn

استفاده می‌شود.


اضافه کردن عکس پروفایل

حالا می‌خواهیم چند عکس داخل پروژه داشته باشیم. مثلاً:

drawable

profile1.png

profile2.png

profile3.png

profile4.png

داخل Model:

data class User(

    val id:Int,

    val name:String,

    val job:String,

    val image:Int

)

اما ما در JSON نمی‌توانیم مستقیم بنویسیم:

"image":R.drawable.profile1

چون JSON از Resource اندروید خبر ندارد. پس یک راه ساده استفاده می‌کنیم.


انتخاب تصادفی عکس

یک لیست از عکس‌ها می‌سازیم:

val images = listOf(

    R.drawable.profile1,

    R.drawable.profile2,

    R.drawable.profile3,

    R.drawable.profile4

)

بعد هنگام ساخت User:

User(

    id = json.id,

    name = json.name,

    job = json.job,

    image =
    images.random()

)

تابع:

random()

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

Ali → profile2

بار دوم:

Ali → profile4

نمایش عکس داخل کارت

حالا UserCard:

@Composable
fun UserCard(
    user:User
){

    Card {

        Row {

            Image(

                painter =
                painterResource(
                    user.image
                ),

                contentDescription = null,

                modifier =
                Modifier
                    .size(70.dp)
                    .clip(
                        CircleShape
                    )

            )


            Column {

                Text(
                    text = user.name
                )


                Text(
                    text = user.job
                )

            }

        }

    }

}

حالا هر کارت:

  • عکس دارد
  • نام دارد
  • شغل دارد
  • ظاهر مرتب دارد

ساختار نهایی پروژه

در پایان این درس ساختار ما تقریباً این شکلی شده:

app

 ├── assets

 │    └── users.json

 ├── model

 │    └── User.kt

 ├── repository

 │    └── UserRepository.kt

 ├── ui

 │    ├── UserCard.kt

 │    └── UserList.kt

 └── MainActivity.kt

نکات نهایی

Context چیست؟

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

در Jetpack Compose برای گرفتن Context فعلی از دستور زیر استفاده می‌کنیم:

val context = LocalContext.current

این دستور Context مربوط به همین صفحه را در اختیار ما قرار می‌دهد تا بتوانیم از امکانات سیستم اندروید استفاده کنیم.

val context = LocalContext.current

در واقع داره میگه برنامه‌ی اندروید فعلی رو به من بده یا دقیق‌تر Context فعلی برنامه رو در اختیارم قرار بده.

پس چرا اینجا لازمش داریم؟
داخل Repository می‌خواد فایل users.json رو از پوشه‌ی assets بخونه.

context.assets.open(“users.json”)

LocalContext.current یعنی Context مربوط به همین صفحه یا همین Composable را به من بده.


کدام فایل نیاز به import دارد

  • اگر دو فایل داخل یک پکیج باشند → نیازی به import نیست
  • اگر داخل دو پکیج متفاوت باشند → باید import شوند

مثلاً:

com.iman.helloiman
├── MainActivity.kt
├── ProfileScreen.kt
└── UserCard.kt

اینجا هیچ‌کدام برای استفاده از هم import لازم ندارند. اما اگر این ساختار را داشته باشیم:

com.iman.helloiman
├── MainActivity.kt

model
└── User.kt

repository
└── UserRepository.kt

آن وقت هر جا بخواهی از User یا UserRepository استفاده کنی، باید آن‌ها را import کنی.

import com.iman.helloiman.model.User


چرا از remember استفاده کردیم؟

این کد رو ببین:

val users = remember {

    UserRepository(context)
        .getUsers()

}

فرض کن remember وجود نداشت و اینطوری می‌نوشتیم:

val users = UserRepository(context)
    .getUsers()

مشکل اینجاست که در Jetpack Compose، هر بار که صفحه دوباره رسم Recompose بشه، این خط دوباره اجرا میشه. یعنی دوباره Repository ساخته میشه و دوباره فایل users.json خونده میشه و دوباره Gson اجرا میشه.

اما وقتی می‌نویسیم:

val users = remember {

    UserRepository(context)
        .getUsers()

}

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


چرا با اینکه Repository خودش فایل را می‌خواند، باز هم Context را به آن دادیم؟

ما باید Context را به Repository بدهیم تا Repository بتواند به امکانات سیستم عامل دسترسی پیدا کند. مثلاً:

class UserRepository(
    private val context: Context
)

یعنی این Context را بگیر، چون بعداً به آن احتیاج داری. بعد داخل Repository:

context.assets
    .open("users.json")

حالا می‌داند باید فایل users.json را از assets همین برنامه باز کند.


map و items دو کار متفاوت انجام میدن

map

return jsonUsers.map { jsonUser ->

    User(
        id = jsonUser.id,
        name = jsonUser.name,
        job = jsonUser.job,
        image = images.random()
    )

}

هدف ما ساختن یک لیست جدید است. یعنی هر JsonUser تبدیل میشه به یک User جدید. در نهایت map یک لیست جدید برمی‌گردونه.

items

items(users) { user ->

    UserCard(user)

}

ما اصلاً قصد ساختن لیست جدید نداریم. لیست users از قبل آماده است. فقط می‌خوایم برای هر کاربر یک UI بسازیم.

درس ۳
طراحی ظاهر حرفه‌ای اپلیکیشن

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

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

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

Theme

 ├── Colors

 ├── Typography

 └── Components

       ├── AppText

       ├── AppButton

       └── UserCard

اضافه کردن فونت فارسی به پروژه

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

res

 └── font

      ├── iransans_bold.ttf

      └── iransans_light.ttf

ساخت FontFamily

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

Type.kt

کد:

val IranSans = FontFamily(

    Font(
        R.font.iransans_light,
        FontWeight.Light
    ),

    Font(
        R.font.iransans_bold,
        FontWeight.Bold
    )

)

اینجا ما یک خانواده فونت ساختیم. یعنی حالا Compose می‌داند که وقتی وزن Light خواستی FontWeight.Light از فایل iransans_light.ttf استفاده کن.


ساخت Typography اختصاصی

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

fontSize = 18.sp

یا

fontWeight = FontWeight.Bold

بهتر است همه را یکجا تعریف کنیم. مثلاً:

val AppTypography = Typography(

    titleLarge = TextStyle(

        fontFamily = IranSans,

        fontWeight = FontWeight.Bold,

        fontSize = 22.sp

    ),


    bodyMedium = TextStyle(

        fontFamily = IranSans,

        fontWeight = FontWeight.Light,

        fontSize = 16.sp

    )

)

حالا دو مدل متن داریم، عنوان‌ها titleLarge متن‌های معمولی bodyMedium اگر بعداً فونت یا اندازه را تغییر بدهیم، فقط همین فایل تغییر می‌کند.


ساخت Theme اختصاصی

در Compose معمولاً رنگ‌ها و فونت‌ها داخل Theme قرار می‌گیرند. یک فایل Theme.kt بساز و محتویات داخل آن:

@Composable
fun MyAppTheme(
    content: @Composable () -> Unit
){
    MaterialTheme(
        typography = AppTypography,
        content = content
    )
}

حالا در MainActivity قبلاً:

setContent {
    ProfileScreen()
}

داریم. تبدیلش می‌کنیم به:

setContent {
    MyAppTheme {
        ProfileScreen()
    }
}

از اینجا به بعد تمام برنامه داخل Theme ما اجرا می‌شود.


ساخت رنگ‌های برنامه

رنگ‌ها هم نباید پراکنده باشند. مثلاً این اشتباه است:

Text(

color = Color.Red

)

چون بعداً اگر بخواهی رنگ اصلی برنامه را عوض کنی باید ده‌ها فایل را تغییر بدهی. بهتر است Color.kt بسازیم. مثلاً:

val PrimaryColor =  Color(0xFF2563EB)

val BackgroundColor =  Color(0xFFF5F5F5)

حالا:

Card(
colors = CardDefaults.cardColors(
    containerColor =  BackgroundColor
)
)

استفاده می‌کنیم.


ساخت Component قابل استفاده مجدد

یکی از قدرت‌های Compose همین است. در پروژه‌های واقعی نباید هر بار یک Text را از صفر بسازیم. مثلاً اگر همه عنوان‌های برنامه:

  • فونت Bold داشته باشند
  • اندازه ۲۰ باشند
  • رنگ مشخص داشته باشند

یک Component می‌سازیم.


ساخت AppText

فایل:

AppText.kt
@Composable
fun AppText(
    text:String
){

    Text(

        text = text,
        style = MaterialTheme.typography.bodyMedium

    )
}

حالا به جای:

Text(
    text = "Ali"
)

می‌نویسیم:

AppText(
    "Ali"
)

مزیت چیست؟ اگر فردا فونت یا اندازه متن تغییر کرد، فقط یک فایل تغییر می‌کند.


ساخت AppButton

همین کار را برای Button انجام می‌دهیم. فایل:

AppButton.kt
@Composable
fun AppButton(

    text:String,
    onClick:()->Unit

){
    Button(
        onClick = onClick
    ){
        Text(
            text = text
        )
    }
}

حالا در کل پروژه:

AppButton(
    text = "مشاهده",
    onClick = {
    }
)

داریم.


حرفه‌ای کردن UserCard

حالا کارت کاربران را اصلاح می‌کنیم. قبلاً:

Text(
    text = user.name
)

داشتیم. حالا:

AppText(
    text = user.name
)

کد کامل‌تر:

@Composable
fun UserCard(

    user:User

){

    Card(
        modifier =
        Modifier
            .padding(8.dp)
    ){

        Row(

            modifier =
            Modifier
                .padding(16.dp)

        ){

            Image(

                painter =
                painterResource(
                    user.image
                ),

                contentDescription = null,

                modifier =
                Modifier
                    .size(70.dp)
                    .clip(
                        CircleShape
                    )
            )


            Column(

                modifier =
                Modifier
                    .padding(
                        start = 16.dp
                    )

            ){

                AppText(
                    user.name
                )


                AppText(
                    user.job
                )


                AppButton(
                    text = "مشاهده",
                    onClick = {

                    }
                )

            }

        }

    }

}

حالا UserCard خودش از Componentهای اختصاصی ما استفاده می‌کند.


ساخت Component برای Avatar

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

@Composable
fun Avatar(

    image:Int

){

    Image(

        painter =
        painterResource(image),

        contentDescription = null,

        modifier =
        Modifier
            .size(70.dp)
            .clip(
                CircleShape
            )
    )
}

حالا:

Avatar(
    user.image
)

چرا Component سازی مهم است؟

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


ساختار پروژه بعد از این درس

حالا پروژه ما مرتب‌تر شده:

ui

 ├── components

 │     ├── AppText.kt

 │     ├── AppButton.kt

 │     ├── Avatar.kt

 │     └── UserCard.kt


theme

 ├── Color.kt

 ├── Type.kt

 └── Theme.kt


model

 └── User.kt


repository

 └── UserRepository.kt

فرمت مناسب فونت در اندروید

اندروید به‌صورت مستقیم از فایل‌های فونت TTF و OTF پشتیبانی می‌کند و کافی است آن‌ها را داخل پوشه res/font قرار بدهیم. اما فرمت‌های WOFF و WOFF2 مخصوص مرورگرهای وب هستند و در پروژه‌های اندرویدی قابل استفاده نیستند. بنابراین اگر فونتی را از اینترنت دانلود کردی و نسخه‌های مختلف آن را دیدی، برای پروژه‌های اندروید همیشه نسخه TTF یا OTF را انتخاب کن.


فایل Type.kt چه کاری انجام می‌دهد؟

فایل Type.kt محل تعریف فونت‌ها و استایل متن‌های برنامه است. اول با استفاده از FontFamily، فونت‌هایی که داخل پوشه res/font قرار داده‌ایم را با یک نام دلخواه، مثل IranSans، به Compose معرفی می‌کنیم. بعد با استفاده از Typography، استایل‌های مختلف متن را می‌سازیم؛ مثلاً titleLarge برای عنوان‌های بزرگ، titleMedium برای تیترهای کوچک‌تر و bodyMedium برای متن‌های معمولی. از این به بعد هر جا به یک عنوان یا متن معمولی نیاز داشته باشیم، فقط کافی است از همین استایل‌ها استفاده کنیم و اگر بعدها بخواهیم فونت، اندازه یا وزن متن‌ها را تغییر بدهیم، فقط همین فایل را ویرایش می‌کنیم.

معنی Unit در تابع‌هایی که به‌عنوان آرگومان می‌فرستیم

وقتی این‌طوری می‌نویسیم:

content: @Composable () -> Unit

یعنی آرگومان content باید یک تابع Composable باشه که هیچ ورودی‌ای نمی‌گیره و هیچ مقدار خاصی هم برنمی‌گردونه. علامت () یعنی ورودی نداره و Unit یعنی خروجی کاربردی نداره. پس این آرگومان در اصل یک تکه رابط کاربریه که بعداً داخل Theme اجراش می‌کنیم:

@Composable

fun MyAppTheme(

    content: @Composable () -> Unit

) {

    MaterialTheme(

        typography = AppTypography,

        content = content

    )

}

بعد موقع استفاده، این بخش:

MyAppTheme {

    ProfileScreen()

}

همون تابعی میشه که داخل content قرار می‌گیره. برای دکمه هم همین منطق رو داریم:

@Composable

fun AppButton(

    text: String,

    onClick: () -> Unit

)

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

AppButton(

    text = "مشاهده",

    onClick = {

        println("Button clicked")

    }

)

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


MyAppTheme چه کاری انجام می‌دهد؟

تابع MyAppTheme یک تکه از رابط کاربری (Composable) را از ما می‌گیرد و آن را داخل MaterialTheme اجرا می‌کند. اینجا ما AppTypography را به MaterialTheme معرفی کرده‌ایم؛ یعنی از این به بعد هر Composable که داخل MyAppTheme قرار بگیرد، می‌تواند از همین تایپوگرافی‌ها استفاده کند. مثلاً هر جا بنویسیم:

MaterialTheme.typography.bodyMedium

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


چرا رنگ‌ها را این‌طوری تعریف می‌کنیم؟

در Jetpack Compose رنگ‌ها را معمولاً به این شکل می‌نویسیم:

Color(0xFF2563EB)

شاید اولش عجیب به نظر برسد، اما این همان کد رنگ Hex است که در فتوشاپ یا سایت‌ها می‌بینیم. مثلاً رنگ:

#2563EB

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

0xFF2563EB

تفاوتش فقط این است که اول کد رنگ، دو رقم دیگر هم اضافه می‌شود. این دو رقم میزان شفافیت (Opacity) رنگ را مشخص می‌کنند. مقدار FF یعنی رنگ کاملاً دیده شود و هیچ شفافیتی نداشته باشد. اگر این مقدار را کمتر کنیم، رنگ هم به همان نسبت شفاف‌تر می‌شود. مثلاً:

Color(0x802563EB)

اینجا 80 یعنی رنگ تقریباً ۵۰ درصد شفاف است.

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

چرا اول کد رنگ 0x می‌نویسیم؟

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


maxLines و overflow چه کاری انجام می‌دهند؟

گاهی ممکنه متن از فضایی که براش در نظر گرفته‌ایم بزرگ‌تر باشه. با maxLines مشخص می‌کنیم متن حداکثر در چند خط نمایش داده بشه. مثلاً:

maxLines = 1

یعنی متن فقط در یک خط نمایش داده شود. اگر متن طولانی‌تر باشد، خصوصیت overflow مشخص می‌کند با ادامه‌ی متن چه کار کنیم. مثلاً:

overflow = TextOverflow.Ellipsis

یعنی اگر متن جا نشد، انتهای آن به‌جای ادامه پیدا کردن، با سه نقطه (…) نمایش داده شود. این کار باعث می‌شود ظاهر برنامه مرتب بماند و متن‌های طولانی، چیدمان رابط کاربری را به هم نریزند.

چرا عکس رو به‌صورت Int می‌گیریم؟

در اندروید هر فایل داخل drawable یک شناسه عددی داره. به همین خاطر آدرس عکس‌هایی مثل:

R.drawable.profile1

در اصل از نوع Int هستن. پس داخل Avatar عکس رو این‌طوری می‌گیریم:

image: Int

بعد این شناسه رو به painterResource می‌دیم:

painterResource(
    id = image
)

painterResource با استفاده از همین ID می‌ره فایل واقعی عکس رو از منابع برنامه پیدا می‌کنه و داخل Image نمایش می‌ده. یعنی ما خود فایل عکس رو نمی‌فرستیم؛ فقط شناسه‌ش رو می‌فرستیم.

نکته: R.drawable.profile1 یک عکس نیست؛ یک شناسه عددی (Resource ID) است که اندروید به آن عکس اختصاص داده است.

چرا داخل items از key استفاده می‌کنیم؟

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

key = { user ->
    user.id
}


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

تفاوت sp و dp چیست؟

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

درس ۴
منوی کناری و Assetهای پروژه

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

  • منوی کناری
  • آیکن‌های اختصاصی
  • لوگوی برنامه
  • صفحه شروع برنامه

در این درس می‌خواهیم پروژه را شبیه یک داشبورد واقعی کنیم.

Asset چیست؟

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

  • عکس‌ها
  • فونت‌ها
  • آیکن‌ها
  • SVG
  • فایل‌های JSON
  • لوگو

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

res

 ├── drawable

 ├── font

 ├── mipmap

 └── values

اضافه کردن SVG به پروژه

فرض کن برای منوی برنامه یک آیکن داریم:

menu.svg

در Compose می‌توانیم از SVG استفاده کنیم. اما اندروید مستقیماً فایل SVG خام را نمی‌خواند. باید آن را به Vector Drawable تبدیل کنیم.


اضافه کردن Vector Asset

در Android Studio:

res ↓ drawable ↓ New ↓ Vector Asset

حالا:

  • فایل SVG را انتخاب می‌کنیم
  • نام می‌دهیم

مثلاً ic_menu. بعد Android Studio یک فایل می‌سازد:

ic_menu.xml

داخل res/drawable قرار می‌گیرد.


استفاده از Icon

حالا در Compose:

Icon(

    painter =
    painterResource(
        R.drawable.ic_menu
    ),
    contentDescription = null
)

اینجا:

painterResource()

فایل Vector را از پروژه می‌خواند.


ساخت Component برای Icon

همان کاری که برای Text و Button انجام دادیم، برای Icon هم انجام می‌دهیم. مثلاً:

@Composable
fun AppIcon(

    icon:Int,
    onClick:()->Unit

){

    Icon(

        painter =
        painterResource(icon),

        contentDescription = null,

        modifier =
        Modifier
            .clickable {
                onClick()
            }
    )
}

حالا هر جا نیاز داشتیم:

AppIcon(
    icon = R.drawable.ic_menu,
    onClick = {
    }
)

ساخت Dashboard

حالا صفحه اصلی برنامه را می‌سازیم. ساختار:

Dashboard

 ├── TopBar

 ├── UserList

 └── Drawer

فعلاً فقط صفحه اصلی:

@Composable
fun DashboardScreen(){

    Column {

        Text(
            text = "Dashboard"
        )
        UserList()
    }
}

حالا باید منوی کناری را اضافه کنیم.


Hamburger Menu چیست؟

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

----------------
| Profile      |
| Users        |
| Settings     |
| Logout       |
----------------

ModalNavigationDrawer

در Compose برای این کار یک کامپوننت آماده داریم:

ModalNavigationDrawer

ساختار آن:

ModalNavigationDrawer(

    drawerContent = {

    }

){

}

دو قسمت دارد، قسمت اول drawerContent محتویات پنل کناری است و قسمت دوم بدنه اصلی برنامه است.


ساخت Drawer ساده

@Composable
fun DashboardScreen(){

    ModalNavigationDrawer(

        drawerContent = {

            Column {

                Text(
                    text = "Profile"
                )

                Text(
                    text = "Settings"
                )

                Text(
                    text = "Logout"
                )

            }

        }

    ){
        UserList()
    }
}

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


کنترل باز و بسته شدن Drawer

برای کنترل Drawer از DrawerState استفاده می‌کنیم. مثلاً:

val drawerState =
    rememberDrawerState(
            initialValue = DrawerValue.Closed
    )

یعنی درشروع برنامه Drawer بسته است.


اضافه کردن Coroutine

باز و بسته شدن Drawer یک عملیات suspend است. پس:

val scope =  rememberCoroutineScope()

حالا، باز کردن:

scope.launch {
    drawerState.open()
}

بستن:

scope.launch {

    drawerState.close()

}

ساخت TopBar با Hamburger

حالا یک Header می‌سازیم.

Row(

modifier =
Modifier
.fillMaxWidth()
.padding(16.dp)

){

    Icon(

        painter =
        painterResource(
            R.drawable.ic_menu
        ),

        contentDescription = null,

        modifier =
        Modifier.clickable {

            scope.launch {

                drawerState.open()

            }

        }

    )


    Text(
        text = "Users"
    )

}

حالا وقتی روی آیکن کلیک کنیم، Drawer باز می‌شود.


ساخت آیتم‌های منو

به جای اینکه مستقیم Text بنویسیم:

Text(
    "Profile"
)

یک Component می‌سازیم.

@Composable
fun DrawerItem(

    title:String,

    icon:Int,

    onClick:()->Unit

){

    Row(

        modifier =
        Modifier
            .fillMaxWidth()
            .clickable {

                onClick()

            }
            .padding(16.dp)

    ){

        Icon(

            painter =
            painterResource(icon),

            contentDescription = null

        )


        Text(
            text = title
        )

    }

}

حالا:

DrawerItem(

    title = "Settings",

    icon = R.drawable.ic_settings,

    onClick = {

    }

)

ساخت Drawer حرفه‌ای‌تر

حالا:

ModalNavigationDrawer(

drawerContent = {


Column {


DrawerItem(

"Profile",

R.drawable.ic_profile

){


}


DrawerItem(

"Settings",

R.drawable.ic_settings

){


}


DrawerItem(

"Logout",

R.drawable.ic_logout

){


}



}


}

){

DashboardContent()

}

ساختار ما:

ModalNavigationDrawer

 ├── Drawer

 │     ├── Profile

 │     ├── Settings

 │     └── Logout


 └── Content

       └── Users

ساخت Splash Screen

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


اضافه کردن لوگو

لوگو را داخل:

drawable

قرار می‌دهیم. مثلاً:

app_logo.png

فعال کردن Splash Screen

داخل Gradle:

implementation(
    "androidx.core:core-splashscreen:1.0.1"
)

استفاده در Activity

داخل:

MainActivity
override fun onCreate(
    savedInstanceState:Bundle?
){

    val splash =  installSplashScreen()


    super.onCreate(
        savedInstanceState
    )


    setContent {

        MyAppTheme {

            DashboardScreen()

        }

    }

}

تنظیم ظاهر Splash

داخل Theme:

<style name="Theme.App.Starting">

    <item name="windowSplashScreenAnimatedIcon">
        @drawable/app_logo
    </item>

</style>

حالا هنگام باز شدن برنامه لوگو نمایش داده می‌شود.


ساختار پروژه ما حالا تقریباً شبیه یک اپلیکیشن واقعی شده:

App

 ├── Theme

 ├── Components

 ├── Dashboard

 ├── Drawer

 ├── Assets

 └── UserList

فایل themes.xml به چه درد می‌خوره؟

تا الان هر چی Theme داشتیم داخل فایل Theme.kt بود و فکر می‌کردیم کار تمومه. ولی یه نکته هست. قبل از اینکه اصلاً کدهای Kotlin اجرا بشن، خود اندروید باید بدونه برنامه با چه ظاهری شروع بشه. مثلاً موقع Splash Screen هنوز Theme.kt اصلاً اجرا نشده. اینجاست که فایل themes.xml وارد کار میشه. این فایل تنظیمات ظاهری خود سیستم اندرویده؛ یعنی چیزهایی که باید قبل از اجرای کدهای Compose مشخص باشن. خلاصه اگر بخوای ظاهر خود برنامه بعد از اجرا رو کنترل کنی، میری سراغ Theme.kt، ولی اگر بخوای خود اندروید موقع روشن شدن برنامه یا Splash Screen یه ظاهری نشون بده، باید از themes.xml استفاده کنی.

این Style جدید چی کار می‌کنه؟

قبلاً فقط یک Theme داشتیم که بعد از اجرا شدن برنامه استفاده می‌شد. ولی حالا یه Style جدید به اسم:

Theme.HelloIman.Starting

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

Theme.HelloIman

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

postSplashScreenTheme چه کاری انجام می‌ده؟

بعد از اینکه Splash Screen تموم شد، اندروید باید بدونه برنامه با چه Themeای ادامه پیدا کنه. دقیقاً کار postSplashScreenTheme همینه؛ به اندروید میگه بعد از صفحه شروع، از این Theme برای کل برنامه استفاده کن. توی پروژه خودمون هم اول Theme.HelloIman.Starting فقط برای نمایش Splash Screen اجرا میشه و به محض اینکه Splash تموم شد، اندروید خودش به Theme.HelloIman برمی‌گرده و بقیه برنامه با همون Theme اصلی نمایش داده میشه. حتماً. اینم با همون سبکی که گفتی، محاوره‌ای، شیرفهم و مناسب جزوه:


SystemClock و uptimeMillis به چه درد می‌خورن؟

برای اینکه بفهمیم Splash Screen چند ثانیه روی صفحه مونده، باید یه جایی زمان شروع رو ذخیره کنیم. برای همین از SystemClock استفاده می‌کنیم. این کلاس ساعت داخلی خود سیستم اندرویده و می‌تونه زمان سپری‌شده از روشن شدن گوشی رو به ما بده. تابع:

SystemClock.uptimeMillis()

هم دقیقاً همین عدد رو برمی‌گردونه؛ یعنی تعداد میلی‌ثانیه‌هایی که از زمان روشن شدن دستگاه گذشته. ما هم موقع شروع برنامه این عدد رو داخل startTime ذخیره کردیم.


چرا دوباره uptimeMillis رو صدا زدیم؟

بعد از اینکه زمان شروع رو ذخیره کردیم، باید مدام بررسی کنیم چند ثانیه از اون گذشته. برای همین دوباره SystemClock.uptimeMillis رو صدا می‌زنیم و زمان فعلی رو با startTime مقایسه می‌کنیم:

SystemClock.uptimeMillis() - startTime < 3000

اگر اختلافش کمتر از 3000 میلی‌ثانیه (۳ ثانیه) باشه، یعنی هنوز باید Splash Screen روی صفحه بمونه. ولی به محض اینکه این اختلاف به ۳ ثانیه یا بیشتر برسه، شرط false میشه و اندروید Splash Screen رو می‌بنده و وارد برنامه میشه. یعنی توی پروژه خودمون، این چند خط کد فقط یک تایمر ۳ ثانیه‌ای برای صفحه شروع ساختن.

rememberDrawerState به چه درد می‌خوره؟

منوی کناری فقط دو حالت داره؛ یا بازه یا بسته. rememberDrawerState دقیقاً وظیفه داره این وضعیت رو داخل حافظه نگه داره. ما هم اول برنامه اینجوری نوشتیم:

rememberDrawerState(
    initialValue = DrawerValue.Closed
    )

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


چرا remember استفاده کردیم؟

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


Coroutine یعنی چی؟

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

  • باز شدن Drawer
  • انیمیشن‌ها
  • دانلود اطلاعات
  • خواندن فایل
  • درخواست به سرور

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


rememberCoroutineScope به چه درد می‌خوره؟

خود عمل باز شدن Drawer یک کار زمان‌بر (Suspend Function) محسوب میشه. برای همین نمی‌تونیم مستقیم بنویسیم:

drawerState.open()

باید داخل یک Coroutine اجرا بشه. برای همین اول یک Scope ساختیم:

val scope = rememberCoroutineScope()

بعد هر وقت روی دکمه منو کلیک شد گفتیم:

scope.launch {
    drawerState.open()
    }

یعنی «یک Coroutine جدید اجرا کن و داخلش Drawer رو باز کن.» در واقع Scope محیطیه که Coroutine داخل اون اجرا میشه.


چرا open داخل launch نوشته شده؟

تابع open یک Suspend Function هست. یعنی فقط داخل Coroutine اجازه اجرا داره. به همین خاطر این غلطه:

drawerState.open()

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

scope.launch {
    drawerState.open()
    }

ModalNavigationDrawer چیست؟

ModalNavigationDrawer یکی از کامپوننت‌های آماده Material Design هست. هر وقت بخوای منوی کشویی کنار صفحه داشته باشی، معمولاً از همین استفاده می‌کنی. این کامپوننت همه چیز رو خودش مدیریت می‌کنه:

  • باز شدن
  • بسته شدن
  • انیمیشن
  • کشیدن با انگشت
  • تار شدن محتوای پشت منو

ما فقط مشخص می‌کنیم داخل Drawer چی باشه.


چرا اینجوری نوشتیم؟

ModalNavigationDrawer(
drawerState = drawerState,

    drawerContent = {

    }

) {

}

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

drawerState = drawerState

یعنی پارامتر drawerState این کامپوننت، از متغیر drawerState خودمون مقدار بگیره. این باعث میشه کد خیلی خواناتر بشه.


drawerContent چیست؟

drawerContent یک قسمت خالیه که خودمون مشخص می‌کنیم داخل منوی کناری چه چیزهایی نمایش داده بشه. مثلاً:

  • پروفایل
  • تنظیمات
  • خروج

همه اینها داخل همین بخش قرار می‌گیرن.


ModalDrawerSheet چیست؟

خود ModalNavigationDrawer فقط مسئول مدیریت باز و بسته شدن Drawer هست. اما ظاهر خود منو را ModalDrawerSheet می‌سازه. یعنی:

  • رنگ پس‌زمینه
  • شکل منو
  • فضای داخلی
  • آیتم‌های منو

همه داخل ModalDrawerSheet قرار می‌گیرن. به زبان ساده:

ModalNavigationDrawer = مدیر کل Drawer
ModalDrawerSheet = خود پنل سفید منوی کناری که کاربر می‌بینه

statusBarsPadding به چه درد می‌خوره؟

این Modifier به اندازه ارتفاع نوار بالای گوشی (Status Bar) به بالای صفحه فاصله اضافه می‌کنه تا محتوای برنامه زیر ساعت، باتری و آنتن قرار نگیره. توی پروژه خودمون هم با اضافه کردن statusBarsPadding، آیکن منوی همبرگری از زیر Status Bar خارج شد و به‌درستی نمایش داده شد و قابل کلیک شد.

درس ۵
Navigation در Compose

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

Home

Product List

Product Detail

Payment

یا در اپلیکیشن کاربران ما:

Home

Profile

User Detail

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


Navigation چیست؟

Navigation یعنی مدیریت حرکت بین صفحات برنامه. مثلاً وقتی روی یک کارت کاربر کلیک می‌کنیم:

UserCard

Profile Screen

یا وقتی روی یک کاربر خاص کلیک می‌کنیم:

User ID = 15

Detail Screen

Navigation مسئول این جابه‌جایی است.


نصب Navigation Compose

اول باید Dependency مربوط به Navigation را اضافه کنیم. داخل:

build.gradle
implementation(
    "androidx.navigation:navigation-compose:2.7.7"
)

بعد از Sync کردن پروژه، Navigation آماده استفاده است.


مفهوم NavController

قلب Navigation در Compose چیزی به نام NavController است. NavController مسئول:

  • رفتن به صفحه جدید
  • برگشتن به صفحه قبلی
  • مدیریت Stack صفحات

است. مثلاً:

Home

Profile

Detail

NavController این مسیر را به خاطر می‌سپارد. اگر کاربر Back بزند:

Detail

Profile

Home

برمی‌گردد.


ساخت NavController

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

@Composable
fun AppNavigation(){

    val navController =
        rememberNavController()
}

اینجا rememberNavController یک NavController می‌سازد که در طول اجرای برنامه حفظ می‌شود.


NavHost چیست؟

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

NavHost(

    navController = navController,

    startDestination = "home"

){

}

اینجا گفتیم صفحه شروع برنامه home باشد.


ساخت اولین صفحه

حالا داخل NavHost:

NavHost(

    navController = navController,
    startDestination = "home"

){

    composable(
        "home"
    ){

        HomeScreen()

    }
}

این یعنی وقتی مسیر home باز شد، این Composable را نمایش بده:

HomeScreen()

اضافه کردن صفحه Profile

حالا یک صفحه دیگر:

NavHost(

    navController = navController,
    startDestination = "home"

){

    composable(
        "home"
    ){

        HomeScreen()

    }


    composable(
        "profile"
    ){

        ProfileScreen()

    }

}

حالا برنامه دو مسیر دارد:

home

profile

رفتن از یک صفحه به صفحه دیگر

فرض کن داخل Home یک Button داریم:

Button(

    onClick = {

        navController.navigate(
            "profile"
        )

    }

){

    Text(
        "Profile"
    )

}

وقتی کلیک شود:

Home

Profile

اتفاق می‌افتد.


مشکل معماری

اما یک مشکل وجود دارد. اگر داخل هر Composable این را بنویسیم:

navController.navigate()

کم‌کم همه صفحات به Navigation وابسته می‌شوند. راه بهتر این است که فقط Event ارسال کنیم. مثلاً:

HomeScreen(

    onProfileClick = {

        navController.navigate(
            "profile"
        )

    }

)

و داخل Home:

Button(

onClick = {

    onProfileClick()

}

)

حالا Home نمی‌داند Navigation چیست. فقط می‌گوید کاربر کلیک کرد. این ساختار بعداً در MVVM خیلی مهم می‌شود.


پروژه کاربران

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

UserCard

ProfileScreen


اضافه کردن کلیک به UserCard

قبلاً:

@Composable
fun UserCard(
    user:User
)

داشتیم. حالا:

@Composable
fun UserCard(

    user:User,
    onClick:()->Unit

)

اضافه می‌کنیم.

بعد:

Card(

modifier =
Modifier
    .clickable {
        onClick()
    }
){
}

حالا کارت قابل کلیک است.


ارسال User ID به صفحه مقصد

فرض کن کاربر شماره ۱۵ را انتخاب کرده است. ما نمی‌خواهیم کل User Object را منتقل کنیم. معمولاً فقط ID را می‌فرستیم. مثلاً profile/15 یعنی صفحه Profile برای کاربر شماره ۱۵.


ساخت Route با پارامتر

در NavHost:

composable(

    route = "profile/{userId}"

){
    ProfileScreen()
}

قسمت {userId} یعنی یک مقدار قرار است بعداً دریافت شود.


ارسال ID

حالا هنگام کلیک:

onClick = {
    navController.navigate(
        "profile/${user.id}"
    )
}

مثلاً اگر:

user.id = 15

باشد، مسیر تبدیل می‌شود به:

profile/15

دریافت ID در صفحه مقصد

داخل Profile:

composable(
    route = "profile/{userId}"
){

    backStackEntry ->

    val userId =
        backStackEntry
            .arguments
            ?.getString(
                "userId"
            )


    ProfileScreen(
        userId
    )
}

اینجا arguments اطلاعات ارسال شده را می‌گیرد. نتیجه:

userId = 15

نمایش اطلاعات کاربر

حالا:

@Composable
fun ProfileScreen(
    userId:String?
){

    Text(
        text = "User ID : $userId"
    )
}

خروجی:

User ID : 15

ساخت Detail Screen

حالا سه صفحه داریم:

Home ↓ Profile ↓ Detail

Routeها:

home

profile/{userId}

detail/{userId}

ساختار:

NavHost(

navController,

startDestination = "home"

){

composable("home"){

}


composable("profile/{userId}"){

}


composable("detail/{userId}"){

}


}

Back کردن

برای برگشت:

navController.popBackStack()

مثلاً:

IconButton(
onClick = {
    navController.popBackStack()
}
){
}

یعنی صفحه فعلی را ببند و قبلی را نشان بده.


انتقال Object کامل یا ID؟

یک سؤال مهم، چرا User کامل را ارسال نکنیم؟
مثلاً

navigate(user)

چون:

  • حجم داده بیشتر می‌شود
  • مدیریت State سخت‌تر می‌شود
  • اگر اطلاعات کاربر تغییر کند، داده قدیمی داریم

روش استاندارد:

ارسال ID

گرفتن اطلاعات از Repository

مثلاً:

profile/15 ↓ Repository ↓ Database/API ↓ User


ساختار پروژه بعد از Navigation

حالا پروژه:

ui

 ├── navigation

 │     └── AppNavigation.kt


 ├── home

 │     └── HomeScreen.kt


 ├── profile

 │     └── ProfileScreen.kt


 └── detail

       └── DetailScreen.kt

فایل Routes.kt

برای اینکه همه Routeهای برنامه در یک محل قرار بگیرند، یک فایل به نام Routes.kt ساختیم و داخل آن یک object تعریف کردیم. در کاتلین، object یعنی فقط یک نمونه از آن در کل برنامه وجود دارد و می‌توانیم از هر جایی به Routeها دسترسی داشته باشیم.

داخل این object، مسیرهای ثابت برنامه را با const val تعریف کردیم. چون این مسیرها در طول اجرای برنامه تغییر نمی‌کنند، بهتر است به‌صورت مقدار ثابت نگهداری شوند تا هم کد خواناتر شود و هم اگر بعداً نام یک Route عوض شد، فقط همان یک خط را تغییر بدهیم.

تابع detail() هم یک Route کامل می‌سازد. مثلاً اگر شناسه کاربر 15 باشد، خروجی این تابع رشته detail/15 خواهد بود که بعداً به NavController داده می‌شود تا صفحه جزئیات همان کاربر باز شود.

تابع AppNavigation

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

rememberNavController()

اینجا یک NavController می‌سازیم:

val navController = rememberNavController()

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

NavHost

NavHost محل تعریف تمام صفحه‌ها و مسیرهای برنامه است. این بخش با کمک NavController وضعیت Navigation و صفحه فعلی رو مدیریت می‌کنه. دو مقدار اصلی به آن می‌دیم:

navController = navController

startDestination = Routes.DASHBOARD

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

تعریف هر مسیر با composable

داخل NavHost برای هر صفحه یک composable تعریف می‌کنیم. هر composable میگه وقتی وارد یک Route شدیم، چه صفحه‌ای باید نمایش داده بشه. مثلاً:

composable(

    route = Routes.DASHBOARD

) {

    DashboardScreen(...)

}

یعنی وقتی Route برابر dashboard بود، صفحه DashboardScreen نمایش داده بشه.

پارامتر userId

در این قسمت:

onUserClick = { userId ->

userId اسم پارامتر این Lambda است و می‌شد اسم دیگری هم برای آن انتخاب کرد. وقتی کاربر روی یکی از افراد کلیک می‌کنه، شناسه همان فرد از DashboardScreen به این قسمت فرستاده میشه. بعد با این کد وارد مسیر جزئیات می‌شیم:

navController.navigate(
    Routes.detail(userId)
)

مثلاً اگر شناسه کاربر 15 باشه، Route نهایی میشه:

detail/15

Back Stack یعنی چی؟

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

Dashboard
Detail

صفحه Detail بالای پشته قرار می‌گیره و الان نمایش داده میشه. وقتی برگردیم، صفحه جزئیات از پشته حذف میشه و دوباره داشبورد نمایش داده میشه.

backStackEntry چیست؟

در این قسمت:

composable(

    route = Routes.DETAIL

) { backStackEntry ->

خود Navigation اطلاعات مربوط به صفحه فعلی رو در اختیار ما قرار میده. این اطلاعات داخل یک شیء از نوع NavBackStackEntry قرار دارد. اسم backStackEntry رو خودمون انتخاب کردیم و می‌تونستیم مثلاً بنویسیم:

) { entry ->

اما خود این شیء رو ما نمی‌سازیم؛ Navigation آن را به Lambda می‌فرسته. این شیء اطلاعاتی مثل Route و آرگومان‌های صفحه فعلی رو نگهداری می‌کنه.

دریافت userId از Route

وقتی وارد مسیری مثل detail/15 می‌شیم، شناسه کاربر داخل آرگومان userId قرار می‌گیره. با این کد آن را می‌گیریم:

val userId = backStackEntry

    .arguments

    ?.getString("userId")

    ?.toIntOrNull()

ابتدا وارد آرگومان‌های صفحه فعلی می‌شیم، بعد مقدار userId رو به‌صورت String دریافت می‌کنیم و در آخر آن را به عدد تبدیل می‌کنیم. toIntOrNull باعث میشه اگر مقدار قابل تبدیل به عدد نبود، برنامه خطا نده و به‌جای آن null برگردونه.

ارسال شناسه به DetailScreen

بعد شناسه‌ای که از Route گرفتیم، به صفحه جزئیات می‌دیم:

DetailScreen(
    userId = userId
)

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

popBackStack

با اجرای این کد:

navController.popBackStack()

صفحه فعلی از بالای Back Stack حذف میشه و برنامه به صفحه قبلی برمی‌گرده. در پروژه ما یعنی صفحه جزئیات بسته میشه و دوباره داشبورد نمایش داده میشه.

درس ۶
ساخت Backend ساده با PHP و JSON

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

Android App

API Server

Database

یعنی برنامه اندروید فقط درخواست می‌دهد که لیست کاربران را به من بده. سرور اطلاعات را از دیتابیس می‌گیرد و در قالب JSON برمی‌گرداند. در این درس یک سرور خیلی ساده با PHP می‌سازیم تا همین جریان را یاد بگیریم.


API چیست؟

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

Android

MySQL Database

چون:

  • اطلاعات دیتابیس لو می‌رود
  • امنیت پایین می‌آید
  • کنترل درخواست‌ها سخت می‌شود

روش درست:

Android

PHP API

Database


ساخت اولین API

فرض کنیم یک پروژه PHP داریم:

server

 └── users.php

داخل فایل:

<?php

echo "Hello";

?>

اگر این فایل را روی سرور اجرا کنیم:

https://example.com/users.php

خروجی Hello خواهد بود. یعنی همین الان اولین Endpoint خودمان را ساخته‌ایم.


برگرداندن JSON

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

[
 {
  "id":1,
  "name":"Ali"
 },
 {
  "id":2,
  "name":"Reza"
 }
]

برای ساخت این خروجی در PHP:

<?php

$users = [

    [
        "id" => 1,
        "name" => "Ali"
    ],

    [
        "id" => 2,
        "name" => "Reza"
    ]

];


echo json_encode($users);


?>

تابع json_encode آرایه PHP را تبدیل به JSON می‌کند.


تست API

حالا اگر مرورگر را باز کنیم:

https://example.com/users.php

خروجی:

[
 {
  "id":1,
  "name":"Ali"
 },
 {
  "id":2,
  "name":"Reza"
 }
]

می‌بینیم. یعنی سرور آماده است.


Header چیست؟

وقتی یک سرور پاسخ می‌دهد، فقط خود داده مهم نیست. باید به کلاینت بگوید این داده چه نوعی است؟
مثلاً:

  • متن
  • HTML
  • JSON
  • تصویر

برای این کار از Header استفاده می‌کنیم. بالای فایل PHP:

header(
    "Content-Type: application/json"
);

قرار می‌دهیم. حالا سرور اعلام می‌کند من دارم JSON برمی‌گردانم.


نسخه بهتر users.php

حالا API ما:

<?php

header(
    "Content-Type: application/json"
);


$users = [

    [
        "id"=>1,
        "name"=>"Ali",
        "job"=>"Android Developer"
    ],

    [
        "id"=>2,
        "name"=>"Sara",
        "job"=>"Designer"
    ]

];


echo json_encode($users);

?>

حالا یک API واقعی‌تر داریم.


Status Code چیست؟

هر پاسخ HTTP یک وضعیت دارد. مثلاً، موفق:

200 OK

خطا:

500 Server Error

یا:

404 Not Found

در PHP:

http_response_code(200)

ساخت API با پارامتر

فرض کنیم فقط یک کاربر خاص را می‌خواهیم. مثلاً:

users.php?id=2

در PHP:

$id = $_GET["id"];

حالا مقدار 2 را داریم. مثلاً:

<?php

header(
"Content-Type: application/json"
);


$id = $_GET["id"];


$user = [

    "id"=>$id,

    "name"=>"Ali"

];


echo json_encode($user);

?>

اگر درخواست:

users.php?id=5

باشد، خروجی:

{
"id":"5",
"name":"Ali"
}

می‌شود.


ارتباط Android با API

حالا سمت اندروید را داریم. جریان:

User App

HTTP Request

users.php

JSON Response

Kotlin Object

در این مرحله هنوز Retrofit را وارد نکرده‌ایم. فقط مفهوم ارتباط را بفهمیم.


استفاده از URL

مثلاً API ما:

https://example.com/users.php

است. برنامه اندروید یک درخواست GET ارسال می‌کند. یعنی:

GET
/users.php

سرور جواب می‌دهد:

[
 {
  "id":1,
  "name":"Ali"
 }
]

ساختار واقعی‌تر API

در پروژه‌های واقعی معمولاً خروجی فقط یک Array نیست. مثلاً:

{
 "success":true,
 "message":"Users loaded",
 "data":[
   {
    "id":1,
    "name":"Ali"
   }
 ]
}

چرا؟ چون می‌توانیم:

  • وضعیت درخواست
  • پیام خطا
  • اطلاعات اضافی

را هم ارسال کنیم.


مثال خطا

فرض کن کاربر وجود ندارد. به جای [] بهتر است:

{
 "success":false,
 "message":"User not found"
}

برگردانیم.


اتصال به پروژه کاربران

حالا پروژه ما تغییر می‌کند. قبلاً:

users.json

Android

LazyColumn

داشتیم. حالا:

PHP API

Android

LazyColumn

یعنی UI هیچ تغییری نمی‌کند. فقط منبع داده عوض شده است. این موضوع خیلی مهم است. چون بعداً با Repository دقیقاً همین کار را حرفه‌ای‌تر می‌کنیم.


ساختار پروژه سرور

یک ساختار ساده:

server

 ├── users.php

 ├── config.php

 └── database.php

فعلاً users.php تنها API ماست. بعداً:

  • اتصال دیتابیس
  • Login
  • JWT
  • Session

را اضافه می‌کنیم.


نکات امنیتی اولیه

حتی در یک API ساده چند نکته مهم وجود دارد، هیچ‌وقت اطلاعات حساس را مستقیم ارسال نکن.

مثال بد:

{
"password":"123456"
}

مثال خوب:

{
"name":"Ali"
}

همچنین:

  • ورودی‌ها را اعتبارسنجی کن
  • خطای دیتابیس را مستقیم به کاربر نشان نده
  • اطلاعات سرور را لو نده

توضیح فایل config.php

با استفاده از header به برنامه اندروید اعلام می‌کنیم که خروجی این فایل از نوع JSON و با کدگذاری UTF-8 است. تابع sendResponse هم پاسخ‌های API رو یکدست می‌کنه. بنابراین لازم نیست در هر قسمت دوباره json_encode و http_response_code بنویسیم. این تابع چهار مقدار می‌گیره:

success
message
data
statusCode

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

{

    "success": true,

    "message": "Users loaded",

    "data": []

}

تفاوت ApiUser و User

ApiUser دقیقاً داده‌ای رو نمایش میده که از سرور می‌گیریم. اما کلاس User مدل اصلی داخل برنامه ماست و ممکنه اطلاعات مخصوص اندروید مثل شناسه تصویر Drawable رو هم داشته باشه. جدا کردن این دو مدل باعث میشه ساختار سرور مستقیماً به تمام قسمت‌های برنامه وابسته نشه.

HttpURLConnection چیست؟

HttpURLConnection یکی از ابزارهای خود Java برای ارسال درخواست‌های HTTP است. ما در این درس از آن استفاده می‌کنیم تا بدون وارد کردن Retrofit، جریان اصلی ارتباط با API رو ببینیم. هر نمونه از آن برای انجام یک درخواست HTTP استفاده میشه.


چرا تابع‌ها suspend هستند؟

دریافت اطلاعات از اینترنت ممکنه چند ثانیه طول بکشه. بنابراین نباید این کار روی Thread اصلی برنامه انجام بشه؛ چون رابط کاربری قفل میشه. با suspend و Dispatchers.IO عملیات اینترنتی رو روی Thread مناسب دیگه اجرا می‌کنیم:

withContext(Dispatchers.IO)


Result چه کاری انجام میده؟

تابع Repository ممکنه موفق بشه یا به علت قطع اینترنت، خطای سرور یا JSON خراب شکست بخوره. به همین دلیل نتیجه رو داخل Result برمی‌گردونیم:

Result<List<User>>

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


runCatching

این قسمت:

runCatching {

کد داخل خودش رو اجرا می‌کنه. اگر همه‌چیز درست باشه، یک نتیجه موفق می‌سازه و اگر Exception رخ بده، نتیجه شکست‌خورده برمی‌گردونه. پس لازم نیست در این مرحله چندین try/catch جدا بنویسیم.

سه وضعیت اصلی رابط کاربری

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

Loading
Success
Error

در حالت Loading یک Progress نمایش می‌دیم. اگر درخواست ناموفق باشه، پیام خطا نشون داده میشه و اگر موفق باشه، کاربران داخل LazyColumn نمایش داده میشن.

Generic یا T چیست؟

در این کلاس:

data class ApiResponse<T>(

    val success: Boolean,

    val message: String,

    val data: T?

)

حرف T یک Generic Type است. یعنی از قبل مشخص نکرده‌ایم که data چه نوع اطلاعاتی قرار است نگه دارد و بعداً هنگام استفاده، نوع آن را تعیین می‌کنیم.


چرا از T استفاده کردیم؟

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


T از کجا مقدار می‌گیرد؟

خود T به‌تنهایی معنی خاصی ندارد. زمانی که از کلاس استفاده می‌کنیم، نوع واقعی را داخل < > مشخص می‌کنیم. مثلاً برای دریافت یک کاربر:

ApiResponse<ApiUser>

در این حالت، T برابر ApiUser می‌شود. یا برای دریافت لیست کاربران:

ApiResponse<List<ApiUser>>

در اینجا T برابر List<ApiUser> خواهد بود. پس T به کلاس ApiUser وابسته نیست؛ هر نوعی که داخل < > قرار بدهیم، جایگزین T می‌شود.

Box چیست؟

Box یکی از Layoutهای Jetpack Compose است که می‌تواند چندین ویو را روی هم یا داخل یک فضای مشترک قرار بدهد. ما اینجا از Box استفاده کردیم تا در حالت‌های مختلف، مثل Loading یا Error، محتوا دقیقاً وسط صفحه نمایش داده شود. وقتی هم اطلاعات با موفقیت دریافت شد، LazyColumn داخل همین Box نمایش داده می‌شود.


LaunchedEffect چیست؟

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


چرا از when استفاده کردیم؟

در این صفحه سه حالت مختلف داریم:

  • در حال دریافت اطلاعات (Loading)
  • دریافت اطلاعات با خطا (Error)
  • دریافت موفق اطلاعات (Success)

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

Result چیست؟

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

suspend fun getUsers(): Result<List<User>>

یعنی اگر همه‌چیز درست پیش برود، یک لیست از کاربران (List<User>) برمی‌گرداند. اما اگر اینترنت قطع باشد، سرور پاسخ ندهد یا هر خطای دیگری اتفاق بیفتد، به‌جای اینکه برنامه کرش کند، آن خطا داخل Result قرار می‌گیرد. به همین دلیل بعداً می‌توانیم خیلی راحت بنویسیم:

repository

    .getUsers()

    .onSuccess {

    }

    .onFailure {

    }

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


TypeToken چه کاری انجام می‌دهد؟

وقتی اطلاعات از سرور برمی‌گردد، همه‌چیز فقط یک متن JSON است. اما Gson باید بداند این متن را دقیقاً به چه نوع کلاسی تبدیل کند. اینجا به Gson می‌گوییم: «این JSON را به یک ApiResponse تبدیل کن که داخلش یک List<ApiUser> قرار دارد.»

val responseType =

    object :

        TypeToken<ApiResponse<List<ApiUser>>>() {}.type

خروجی این کد خودِ کاربران نیست؛ بلکه فقط نوع داده را می‌سازد و داخل متغیر responseType قرار می‌دهد. بعد در خط بعدی همین نوع را به Gson می‌دهیم:

val response =

    Gson().fromJson(

        json,

        responseType

    )

اگر TypeToken را ننویسیم، Gson نمی‌تواند تشخیص بدهد که داخل data قرار است یک List<ApiUser> قرار بگیرد و ممکن است اطلاعات را اشتباه تبدیل کند یا حتی خطا بدهد. به زبان ساده، TypeToken فقط یک راهنماست که به Gson می‌گوید «این JSON را دقیقاً به چه نوع شیئی تبدیل کن

orEmpty دقیقاً چه کاری می‌کند؟

اینجا response.data ممکنه لیست کاربران باشه، ولی چون در ApiResponse نوشتیم:

val data: T?

پس data می‌تونه null هم باشه. حالا این کد:

response.data
    .orEmpty()

می‌گه: «اگر data مقدار داشت، همون رو بده؛ اگر null بود، به‌جاش یک لیست خالی بده.»

یعنی در عمل:

response.data.orEmpty()

تقریباً معادل اینه:

response.data ?: emptyList()

بعد روی لیستی که به دست اومده map اجرا می‌شه:

.map { apiUser ->

    apiUser.toUser()

}

اگر data شامل کاربرها باشه، همه‌شون تبدیل می‌شن. اگر data برابر null باشه، orEmpty() یک لیست خالی می‌ده و map هم روی لیست خالی اجرا می‌شه؛ در نتیجه خروجی هم یک لیست خالی می‌شه و برنامه خطا نمی‌ده.

connectTimeout و readTimeout چه فرقی دارن؟

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

connection.requestMethod = “GET”

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

connectTimeout

این زمان مربوط به برقرار شدن ارتباط با سروره. یعنی برنامه میگه: «اگر تا ۱۰ ثانیه نتونستی به سرور وصل بشی، بی‌خیالش شو و خطا بده

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


readTimeout

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

درخواست دقیقاً کی ارسال میشه؟

تا اینجا:

connection.requestMethod = "GET"

connection.connectTimeout = 10_000

connection.readTimeout = 10_000

فقط داریم درخواست رو آماده می‌کنیم. هنوز هیچ درخواستی به سرور ارسال نشده. حالا سؤال اینه که پس کی ارسال شد؟ اینجا:

val statusCode = connection.responseCode

همین لحظه. وقتی برای اولین بار responseCode رو می‌خونی، جاوا اگر هنوز ارتباط برقرار نشده باشه، خودش درخواست رو به سرور ارسال می‌کنه، منتظر جواب می‌مونه و بعد کد وضعیت (مثلاً 200 یا 404) رو برمی‌گردونه. یعنی این خط، فقط «خواندن» نیست؛ در عمل باعث میشه درخواست هم ارسال بشه.

اگر قبلش connection.connect هم صدا زده باشی، ارتباط زودتر برقرار میشه؛ ولی باز تا وقتی جواب نخوای (مثل responseCode یا inputStream) چیزی از سرور دریافت نمی‌کنی.


inputStream چیه؟

فرض کن به یک رستوران سفارش غذا دادی. سفارش رو فرستادی، حالا غذا رو برات آوردن. inputStream هم دقیقاً همون غذای رسیده است؛ یعنی اطلاعاتی که سرور برای ما فرستاده. مثلاً سرور این JSON رو فرستاده:

[

  {

    "id":1,

    "name":"Ali"

  }

]

این JSON داخل inputStream قرار می‌گیره. بعد ما با این کد:

responseStream

    .bufferedReader()

    .readText()

کل اون اطلاعات رو به صورت متن می‌خونیم.


errorStream چیه؟

اگر سرور خطا بده، معمولاً باز هم یه پیام برای ما می‌فرسته. مثلاً:

{

    "success": false,

    "message": "User not found"

}

یا 404 Not Found این اطلاعات دیگه داخل inputStream نیست. داخل errorStream قرار می‌گیره. برای همین نوشتیم:

if (statusCode in 200..299) {

    connection.inputStream

} else {

    connection.errorStream

}

یعنی:

  • اگر درخواست موفق بود، جواب رو از inputStream بخون.
  • اگر درخواست خطا داشت، پیام خطا رو از errorStream بخون.

چرا جلوی remember آکولاد باز کردیم؟

اینجا نوشتیم:

val repository =

    remember {

        UserRepository()

    }

شاید با خودت بگی چرا اینجوری نوشتیم؟ چرا مثلاً اینو ننوشتیم؟

remember(UserRepository())

دلیلش اینه که remember یک تابع هست که یه تکه کد رو از ما می‌گیره و فقط اولین بار اجراش می‌کنه. اون آکولاد در واقع همون تکه کده. یعنی اینجا داریم به remember می‌گیم: «فقط اولین بار این کد رو اجرا کن و یه UserRepository بساز. دفعات بعدی همون قبلی رو برگردون.» اگر صفحه دوباره Recompose بشه، دیگه UserRepository از نو ساخته نمی‌شه. به طور کلی، هر وقت بخوایم remember یه مقدار رو محاسبه یا ایجاد کنه، از آکولاد استفاده می‌کنیم.


by اینجا چه کاری انجام می‌ده؟

اینجا نوشتیم:

var users by remember {

    mutableStateOf(emptyList<User>())

}

در واقع remember یه MutableState برمی‌گردونه. یعنی اگر by ننویسیم، باید این شکلی کار کنیم:

val users = remember {

    mutableStateOf(emptyList<User>())

}

بعد هر بار بخوایم مقدارش رو بخونیم یا تغییر بدیم باید بنویسیم:

users.value

و

users.value = loadedUsers

اما by این .value رو از دید ما مخفی می‌کنه. در نتیجه می‌تونیم خیلی ساده بنویسیم:

users = loadedUsers

و

Text(users.size.toString())

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


چرا سه تا State تعریف کردیم؟

اینجا سه تا وضعیت مختلف صفحه رو نگه می‌داریم.

users
لیست کاربران

isLoading
آیا هنوز منتظر دریافت اطلاعات هستیم یا نه

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


onSuccess و onFailure از کجا اومدن؟

این دو تا از کلاس Result میان. یادت هست داخل Repository گفتیم:

suspend fun getUsers(): Result<List<User>>

یعنی getUsers یک شیء از نوع Result برمی‌گردونه. کلاس Result داخل خود Kotlin وجود داره و دو تا تابع خیلی کاربردی داره:

onSuccess { … }

اگر عملیات موفق بود، این قسمت اجرا می‌شه. و

onFailure { … }

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

if (...) {

    ...

} else {

    ...

}

خود Result این کار رو برامون ساده کرده.

return@LaunchedEffect یعنی چی؟ اینجا اول بررسی می‌کنیم که آیا userId معتبره یا نه.

if (userId == null) {

    errorMessage = "شناسه کاربر معتبر نیست"

    isLoading = false

    return@LaunchedEffect

}

اگر userId برابر null باشه، اصلاً ادامه‌ی کد نباید اجرا بشه. چون پایین‌تر این کد وجود داره:

repository.getUser(userId)

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

return@LaunchedEffect

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


چرا فقط return ننوشته؟

چون الان داخل یک LaunchedEffect هستیم. اگر فقط بنویسیم returnکاتلین متوجه نمی‌شه که منظورت خروج از کدوم بخشه. برای همین اسم بلاکی که می‌خوای ازش خارج بشی رو می‌نویسیم:

return@LaunchedEffect

یعنی «از همین LaunchedEffect خارج شو و ادامه‌ی کدهای داخلش رو اجرا نکن


return@Column یعنی چی؟

اینجا قبلش بررسی کردیم:

user != null

پس طبیعتاً انتظار داریم user مقدار داشته باشه. اما کاتلین هنوز صددرصد مطمئن نیست که user نال نیست. چون user یک State هست و از نظر کاتلین ممکنه بین این خط و خط بعدی مقدارش تغییر کنه.

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

val selectedUser =

    user ?: return@Column

یعنی اگر user مقدار داشت، داخل selectedUser قرارش بده. اما اگر به هر دلیلی user نال بود:

return@Column

یعنی «دیگه ادامه‌ی کدهای داخل Column رو اجرا نکن.» در نتیجه این کدها Avatar(…) Text(…) Text(…) اصلاً اجرا نمی‌شن.

درس ۷
اتصال اپلیکیشن به API و آشنایی با MVVM

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

@Composable
fun UserScreen(){

    // درخواست API

    // تبدیل JSON

    // مدیریت خطا

    // نمایش UI
    
}

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


مشکل اصلی چیست؟

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

  • دریافت اطلاعات از API
  • ذخیره در دیتابیس
  • مدیریت خطا
  • نمایش Loading
  • نمایش Error
  • تغییر UI

همه با هم نوشته شده باشند. نتیجه:

  • یک فایل بزرگ
  • 1000 خط کد
  • تغییر سخت
  • تست سخت

MVVM می‌آید تا مسئولیت‌ها را جدا کند.


MVVM چیست؟

MVVM مخفف:

Model
View
ViewModel

است. ایده اصلی این است که UI نباید بداند اطلاعات از کجا می‌آیند. یعنی Compose نباید بداند:

  • API چیست
  • Retrofit چیست
  • Database چیست

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


ساختار کلی:

Compose UI

ViewModel

Repository

API


View چیست؟

در Compose همان UI ماست. مثلاً:

@Composable
fun UserScreen(){
}

View فقط مسئول نمایش است. مثلاً:

  • نشان دادن لیست
  • نمایش Loading
  • نمایش خطا

ولی نباید اطلاعات را خودش دریافت کند.


ViewModel چیست؟

ViewModel رابط بین UI و منطق برنامه است. یعنی UI به ViewModel می‌گوید کاربران را بده. ViewModel تصمیم می‌گیرد:

  • از Repository بخواند
  • Loading نشان بدهد
  • خطا را مدیریت کند

مثلاً:

class UserViewModel : ViewModel(){

}

Repository چیست؟

Repository مدیر داده‌های برنامه است. مثلاً امروز:

Repository

API

اما فردا:

Repository

Room Database
+
API

ViewModel هیچ چیزی درباره این تغییر نمی‌فهمد.


ساختار پروژه

حالا پروژه را مرتب می‌کنیم:

app

├── data

│    ├── api

│    │    └── UserApi.kt

│    │

│    └── repository

│         └── UserRepositoryImpl.kt


├── domain

│    ├── model

│    │    └── User.kt

│    │

│    └── repository

│         └── UserRepository.kt


└── presentation

     ├── user

     │    ├── UserScreen.kt

     │    └── UserViewModel.kt

لایه Domain

Domain قلب منطق برنامه است. اینجا نباید وابسته به Retrofit یا Android باشد. مثلاً مدل User:

data class User(

    val id:Int,

    val name:String,

    val job:String

)

ساخت Repository Interface

داخل Domain:

interface UserRepository {

    suspend fun getUsers():

    List<User>

}

این فقط قرارداد است. یعنی هر کسی Repository باشد باید بتواند کاربران را بدهد. اما نمی‌گوید چطور.؟؟


پیاده‌سازی Repository

حالا در Data:

class UserRepositoryImpl(

    private val api:UserApi

):UserRepository{

    override suspend fun getUsers():

    List<User>{

        return api.getUsers()

    }
}

اینجا Domain می‌گوید من User می‌خواهم. Data می‌گوید باشه، من از API می‌گیرم.


ساخت API Interface

فعلاً ساده:

interface UserApi {

    suspend fun getUsers():
    List<User>

}

بعداً Retrofit آن را واقعی می‌کند.


ساخت UI State

یک مشکل دیگر داریم. وقتی API درخواست می‌زند سه حالت داریم.
حالت اول، در حال دریافت:

Loading

حالت دوم، موفق:

Users

حالت سوم، خطا:

Error

پس یک State می‌سازیم.


UserUiState

sealed class UserUiState {

    object Loading : UserUiState()

    data class Success(

        val users:List<User>

    ) : UserUiState()


    data class Error(

        val message:String

    ) : UserUiState()

}

حالا وضعیت صفحه مشخص است.


ساخت ViewModel

class UserViewModel(

    private val repository: UserRepository

):ViewModel(){

}

ViewModel Repository را دریافت می‌کند.


State داخل ViewModel

private val _state =
MutableStateFlow<UserUiState>(

    UserUiState.Loading

)


val state =
_state.asStateFlow()

یعنی یک وضعیت داریم که UI آن را مشاهده می‌کند.


گرفتن اطلاعات کاربران

داخل ViewModel:

fun loadUsers(){


    viewModelScope.launch {


        try {


            _state.value =
            UserUiState.Loading



            val users =
            repository.getUsers()



            _state.value =
            UserUiState.Success(
                users
            )


        }

        catch(e:Exception){


            _state.value =
            UserUiState.Error(
                e.message ?: "Error"
            )

        }
    }
}

جریان:

ViewModel

Repository

API

Users

State Update


اتصال Compose به ViewModel

حالا UI فقط State را می‌بیند.

@Composable
fun UserScreen(

    viewModel:UserViewModel

){


    val state by
    viewModel.state.collectAsState()

}

نمایش حالت‌های مختلف

when(state){

    is UserUiState.Loading -> {

        CircularProgressIndicator()

    }


    is UserUiState.Success -> {

        LazyColumn{

        }
    }


    is UserUiState.Error -> {


        Text(
            "Error"
        )
    }
}

حالا UI کاملاً وابسته به State است.


جریان کامل برنامه

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

UserScreen

ViewModel.loadUsers()

Repository.getUsers()

API Request

JSON Response

Convert User

Update State

Compose Recompose

Show List


چرا این معماری بهتر است؟

فرض کن فردا تصمیم بگیری اطلاعات کاربران را از Room بخوانی. در حالت بد:

UserScreen

API

باید UI را تغییر بدهی. اما در MVVM:

UserScreen

ViewModel

Repository

Room

فقط Repository تغییر می‌کند.


یک مثال واقعی

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

Repository

Server

فردا:

Repository

Room Cache
+
Server

ViewModel و Compose هیچ تغییری نمی‌کنند. این قدرت معماری درست است.


UserRepository Interface

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


UserRepositoryImpl

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


UserUiState

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


MutableStateFlow

MutableStateFlow شبیه mutableStateOf عمل می‌کنه، با این تفاوت که بیشتر برای ViewModel استفاده میشه. هر بار مقدارش تغییر کنه، تمام قسمت‌هایی که دارن اون رو مشاهده می‌کنن، از تغییرش باخبر میشن.


StateFlow

ما نمی‌خوایم صفحه بتونه مقدار State رو تغییر بده. فقط باید بتونه اون رو بخونه. برای همین نسخه‌ی فقط‌خواندنی StateFlow رو در اختیار UI قرار میدیم و خود MutableStateFlow رو خصوصی نگه میداریم.


viewModelScope

قبلاً داخل LaunchedEffect درخواست API رو ارسال می‌کردیم. اما حالا این کار داخل ViewModel انجام میشه. viewModelScope یک CoroutineScope مخصوص ViewModel هست که تا زمانی که ViewModel زنده باشه، Coroutineها هم اجرا میشن و وقتی ViewModel از بین بره، خودش همه اون‌ها رو متوقف می‌کنه.


init

تابع init اولین باری که ViewModel ساخته میشه، فقط یک بار اجرا میشه. ما هم از همین فرصت استفاده کردیم تا بلافاصله اطلاعات کاربران رو دریافت کنیم. بنابراین دیگه لازم نیست از داخل Compose خودمون loadUsers() رو صدا بزنیم.


collectAsStateWithLifecycle

قبلاً از remember و mutableStateOf استفاده می‌کردیم، اما حالا اطلاعات داخل ViewModel قرار دارن. collectAsStateWithLifecycle() مقدار StateFlow رو دریافت می‌کنه و اون رو به یک State قابل استفاده در Compose تبدیل می‌کنه. هر بار که مقدار State داخل ViewModel تغییر کنه، Compose هم به صورت خودکار دوباره صفحه رو رسم می‌کنه. علاوه بر این، چون Lifecycle رو هم در نظر می‌گیره، وقتی صفحه دیگه فعال نباشه، مشاهده State هم متوقف میشه و منابع بیهوده مصرف نمیشن.


ProfileViewModelFactory

ViewModel ما سازنده‌ی معمولی نداره و برای ساخته شدن به UserRepository نیاز داره. اما تابع viewModel فقط می‌تونه ViewModelهایی رو بسازه که سازنده‌ی خالی داشته باشن. برای حل این مشکل از ViewModelFactory استفاده می‌کنیم تا قبل از ساخت ViewModel، Repository رو بهش ارسال کنیم.


viewModel

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

suspend

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


withContext(Dispatchers.IO)

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


toUser

اطلاعاتی که از API دریافت می‌کنیم، همیشه همون شکلی نیستن که داخل برنامه بهشون نیاز داریم. برای همین بعد از دریافت اطلاعات، اون‌ها رو به مدل اصلی برنامه تبدیل می‌کنیم. تابع toUser دقیقاً همین کار رو انجام میده و یک ApiUser رو به یک User تبدیل می‌کنه. این کار باعث میشه اگر بعداً ساختار API تغییر کرد، فقط همین قسمت رو ویرایش کنیم.


Extension Function

تابع toUser یک Extension Function هست. Extension Function به ما اجازه میده بدون اینکه کلاس اصلی رو تغییر بدیم، یک تابع جدید بهش اضافه کنیم. برای همین می‌تونیم بنویسیم apiUser.toUser؛ انگار این تابع از اول داخل خود کلاس ApiUser وجود داشته.


asStateFlow

داخل ViewModel از MutableStateFlow استفاده می‌کنیم، چون باید مقدار State رو تغییر بدیم. اما نمی‌خوایم صفحه هم بتونه مقدارش رو تغییر بده. برای همین با asStateFlow یک نسخه فقط‌خواندنی از همون State می‌سازیم و در اختیار UI قرار میدیم.


تغییر مقدار State

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


try و catch

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


isAssignableFrom

داخل ViewModelFactory باید بررسی کنیم که اندروید دقیقاً درخواست ساخت کدوم ViewModel رو داده. متد isAssignableFrom همین بررسی رو انجام میده. اگر ViewModel درخواستی از نوع ProfileViewModel باشه، Factory اون رو می‌سازه و Repository رو هم بهش ارسال می‌کنه.

collectAsStateWithLifecycle

مقدار state داخل ViewModel از نوع StateFlow هست، اما Compose نمی‌تونه مستقیماً با StateFlow کار کنه. برای همین از collectAsStateWithLifecycle استفاده می‌کنیم تا مقدار StateFlow به یک State قابل استفاده در Compose تبدیل بشه. از این لحظه به بعد، هر وقت مقدار state داخل ViewModel تغییر کنه، Compose هم به صورت خودکار صفحه رو دوباره رسم می‌کنه. علاوه بر این، چون Lifecycle رو هم در نظر می‌گیره، وقتی صفحه دیگه فعال نباشه، مشاهده‌ی State هم متوقف میشه و منابع برنامه بیهوده مصرف نمیشن.


چرا از by استفاده کردیم؟

اگر by رو ننویسیم، مقدار برگشتی collectAsStateWithLifecycle خودش یک شیء از نوع State هست و برای دسترسی به مقدارش باید هر بار بنویسیم state.value اما وقتی از by استفاده می‌کنیم، خود Kotlin مقدار داخل State رو برامون برمی‌داره و دیگه لازم نیست .value بنویسیم. بنابراین فقط می‌نویسیم state.

چرا داخل when نوشتیم val currentState = state؟

اینجا یک متغیر جدید به اسم currentState نساختیم که بخوایم از state جداش کنیم. فقط همون مقدار state رو داخل when با یک اسم جدید در اختیار گرفتیم. فایده‌ی این کار اینه که وقتی وارد هر کدوم از حالت‌های when می‌شیم، کاتلین دقیقاً می‌فهمه currentState از چه نوعیه. مثلاً اگر وارد بخش Success بشیم، دیگه لازم نیست دوباره state رو تبدیل نوع (Cast) کنیم و مستقیماً می‌تونیم به اطلاعات داخل currentState دسترسی داشته باشیم.

مثلاً اگر این حالت وجود داشته باشه:

is DetailUiState.Success -> {

    Text(currentState.user.name)

}

کاتلین خودش می‌فهمه که currentState از نوع DetailUiState.Success هست و بنابراین به user دسترسی داریم. اگر فقط می‌نوشتیم:

when (state) {

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

چرا از sealed class استفاده کردیم؟

داخل این پروژه، صفحه‌ی جزئیات فقط می‌تونه در یکی از چند حالت مشخص قرار بگیره؛ یا اطلاعات در حال دریافت باشه، یا با موفقیت دریافت شده باشه، یا هنگام دریافت اطلاعات خطایی رخ داده باشه. به همین دلیل یک sealed class به نام DetailUiState ساختیم و این سه حالت را داخل آن تعریف کردیم. در واقع Loading، Success و Error همگی فرزندهای DetailUiState هستند و متغیر state همیشه یکی از این سه حالت را در خودش نگه می‌دارد.


ارتباط DetailUiState با when

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

when (val currentState = state) {

در واقع از کاتلین می‌خواهیم بررسی کند که مقدار فعلی state کدام‌یک از حالت‌های DetailUiState است. اگر مقدار آن Loading باشد، لودینگ نمایش داده می‌شود. اگر از نوع Success باشد، اطلاعات کاربر نمایش داده می‌شود و اگر از نوع Error باشد، پیام خطا نمایش داده می‌شود. به همین دلیل هر شاخه‌ی when مخصوص یکی از حالت‌های DetailUiState است و رابط کاربری بر اساس وضعیت فعلی برنامه تغییر می‌کند.


چرا در بعضی حالت‌ها is نوشتیم ولی برای Loading نه؟

Loading با کلمه‌ی object ساخته شده است؛ یعنی فقط یک نمونه از آن در کل برنامه وجود دارد. بنابراین می‌توانیم مستقیماً آن را مقایسه کنیم:

DetailUiState.Loading

اما Success و Error از نوع data class هستند و هر بار که ساخته می‌شوند، ممکن است اطلاعات متفاوتی داخل خودشان داشته باشند؛ مثلاً یک User یا یک message جدید. به همین دلیل در when از کلمه‌ی is استفاده می‌کنیم تا فقط نوع آن‌ها بررسی شود و سپس بتوانیم به داده‌های داخلشان مانند currentState.user یا currentState.message دسترسی داشته باشیم.

درس ۸
مدیریت State در Compose

تا اینجا در پروژه کاربران، ما بیشتر روی ساخت ظاهر برنامه و دریافت اطلاعات تمرکز کردیم. اما یک موضوع خیلی مهم در Compose وجود دارد که تقریباً در تمام برنامه‌های واقعی با آن سر و کار داریم: State.

در Compose تمام رابط کاربری بر اساس State ساخته می‌شود. یعنی UI خودش چیزی را نگه نمی‌دارد. بلکه همیشه بر اساس وضعیت فعلی برنامه دوباره ساخته می‌شود. مثلاً:

  • متن داخل یک TextField
  • باز یا بسته بودن یک منو
  • وضعیت Loading
  • اطلاعات دریافت شده از API
  • انتخاب شدن یک گزینه

همه این‌ها State هستند.


مفهوم State در Compose

فرض کن یک شمارنده داریم. ابتدا:

Count = 0

روی دکمه کلیک می‌کنیم:

Count = 1

وقتی مقدار State تغییر کند، Compose متوجه می‌شود و UI را دوباره رسم می‌کند.
این فرایند را:

Recomposition

می‌نامیم. یعنی:

State تغییر می‌کند

Compose متوجه می‌شود

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


remember

ساده‌ترین نوع State در Compose، remember است. فرض کنیم می‌خواهیم یک متن را نمایش دهیم و با دکمه مخفی کنیم. کد:

@Composable
fun ShowTextExample(){

    var visible =
        remember {
            mutableStateOf(true)
        }


    Column {

        Button(

            onClick = {

                visible.value =
                    !visible.value

            }

        ){

            Text(
                "تغییر وضعیت"
            )

        }


        if(visible.value){

            Text(
                "سلام Compose"
            )

        }

    }

}

اینجا چه اتفاقی افتاد؟
اول:

remember {

}

یعنی این مقدار را هنگام Recomposition دوباره از اول نساز. چون Compose ممکن است چندین بار تابع UI را اجرا کند. اگر بدون remember می‌نوشتیم:

var visible =
    mutableStateOf(true)

هر بار UI دوباره ساخته می‌شد، مقدار دوباره true می‌شد. یعنی هیچ وقت تغییر باقی نمی‌ماند.


mutableStateOf چیست؟

remember فقط مقدار را نگه می‌دارد. اما چیزی که باعث می‌شود Compose تغییر را بفهمد:

mutableStateOf()

است. مثلاً:

var name =
    mutableStateOf("Ali")

یعنی یک State داریم که مقدار اولیه آن Ali است. وقتی:

name.value = "Reza"

شود، Compose متوجه تغییر می‌شود.


ساده‌تر کردن با by

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

var username by remember {

    mutableStateOf("")

}

حالا به جای username.value می‌نویسیم username و برای تغییر:

username = "Ali"

این فقط یک Syntax راحت‌تر است.


مثال واقعی: فرم ورود

حالا یک فرم ساده Login می‌سازیم. دو State داریم:

  • نام کاربری
  • رمز عبور
@Composable
fun LoginForm(){


    var username by remember {

        mutableStateOf("")

    }


    var password by remember {

        mutableStateOf("")

    }



    Column {


        TextField(

            value = username,

            onValueChange = {

                username = it

            }

        )



        TextField(

            value = password,

            onValueChange = {

                password = it

            }

        )


        Button(

            onClick = {

                println(username)

            }

        ){

            Text(
                "Login"
            )

        }


    }


}

اینجا وقتی کاربر تایپ می‌کند. مثلاً:

A

State تغییر می‌کند:

username = "A"

Compose دوباره اجرا می‌شود و TextField مقدار جدید را نمایش می‌دهد.


مشکل remember

حالا یک سؤال مهم آیا remember همیشه کافی است؟ نه. remember فقط تا زمانی زنده است که Composable در حافظه باشد. مثلاً:

  • چرخش صفحه
  • بسته شدن Activity
  • حذف شدن Screen

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


State Hoisting چیست؟

یکی از مفاهیم مهم Compose همین است. یعنی State را از داخل Composable بیرون بکشیم. مثلاً این:

@Composable
fun Login(){

    var username by remember {
        mutableStateOf("")
    }

}

بهتر است تبدیل شود به:

@Composable
fun Login(

    username:String,

    onUsernameChange:(String)->Unit

){

}

یعنی خود Login دیگر State نگه نمی‌دارد. فقط مقدار را دریافت می‌کند و تغییر را اعلام می‌کند.


چرا State Hoisting مهم است؟

چون باعث می‌شود:

  • تست راحت‌تر شود
  • UI قابل استفاده مجدد شود
  • ViewModel راحت‌تر وصل شود

مثلاً در پروژه واقعی:

Compose

ViewModel State

اتصال راحت‌تر می‌شود.


State در ViewModel

در پروژه‌های واقعی معمولاً State داخل ViewModel نگهداری می‌شود. مثلاً:

class LoginViewModel : ViewModel(){


}

ViewModel از UI جدا است.


استفاده از StateFlow

برای نگهداری State در ViewModel معمولاً از StateFlow استفاده می‌کنیم. مثلاً:

private val _state =

MutableStateFlow(
    LoginState()
)


val state =

_state.asStateFlow()

ساخت LoginState

به جای چند State جدا:

username

password

loading

error

معمولاً یک State کامل می‌سازیم. مثلاً:

data class LoginState(

    val username:String = "",

    val password:String = "",

    val loading:Boolean = false,

    val error:String? = null

)

حالا کل وضعیت صفحه یک جا قرار دارد.


تغییر State در ViewModel

مثلاً کاربر تایپ می‌کند:

fun changeUsername(
    value:String
){

    _state.value =
        _state.value.copy(

            username = value

        )

}

اینجا State قبلی را کپی می‌کنیم. فقط username تغییر می‌کند.


اتصال UI به StateFlow

داخل Compose:

val state by viewModel.state
    .collectAsState()

این یعنی هر وقت StateFlow تغییر کرد، UI را به‌روز کن.


نمایش اطلاعات

مثلاً:

TextField(

value = state.username,

onValueChange = {

    viewModel.changeUsername(it)

}

)

جریان:

کاربر تایپ می‌کند

TextField

ViewModel

StateFlow تغییر می‌کند

collectAsState

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


مثال کامل‌تر

ViewModel:

class CounterViewModel : ViewModel(){


private val _count =

MutableStateFlow(0)


val count =

_count.asStateFlow()



fun increase(){

    _count.value++

}


}

UI:

@Composable
fun CounterScreen(

viewModel:CounterViewModel

){


val count by

viewModel.count.collectAsState()



Column {


Text(

text = "$count"

)


Button(

onClick = {

viewModel.increase()

}

){

Text(
"+"
)

}


}


}

تفاوت remember، mutableStateOf و StateFlow

remember

برای State کوچک داخل همان UI. مثلاً:

  • باز و بسته شدن منو
  • نمایش Dialog
  • انتخاب Tab

mutableStateOf

برای State ساده داخل Composable. مثلاً:

  • متن فرم
  • انتخاب گزینه
  • Checkbox

StateFlow

برای State اصلی برنامه. مثلاً:

  • اطلاعات کاربران
  • نتیجه API
  • وضعیت Login
  • Loading
  • Error

معمولاً داخل ViewModel.


در پروژه قیمت‌بان چه استفاده‌ای داریم؟

مثلاً صفحه قیمت‌ها:

ViewModel

state:

loading = true


↓

API


↓

state:

prices = list

loading = false


↓

Compose نمایش می‌دهد

یا صفحه Login:

username

password

error

token

همه داخل State مدیریت می‌شوند.


Recomposition در عمل

در Compose رابط کاربری بر اساس مقدارهای State ساخته میشه. هر وقت یکی از این مقدارها تغییر بکنه، Compose بخش‌هایی از UI رو که از آن مقدار استفاده می‌کنن دوباره اجرا می‌کنه. مثلاً وقتی مقدار شمارنده تغییر می‌کنه، لازم نیست خودمون به Text دستور بدیم عدد جدید رو نمایش بده. فقط State رو تغییر می‌دیم و Compose متن شمارنده رو با مقدار جدید دوباره می‌سازه؛ به این فرایند Recomposition می‌گیم.

چرا دو فرم ورود ساختیم؟

در این تمرین عمداً دو فرم ورود داریم تا تفاوت State محلی و State اصلی برنامه رو ببینیم. فرم اول State رو با remember داخل خود Composable نگه می‌داره؛ بنابراین اطلاعاتش فقط متعلق به همان بخش از UI است. فرم دوم اطلاعات رو داخل ViewModel و StateFlow نگه می‌داره؛ به همین دلیل چند Composable مختلف می‌تونن به همان اطلاعات دسترسی داشته باشن و با برگشتن از صفحه ورود، نام کاربری همچنان باقی می‌مونه.

ساخت ViewModel با ViewModelProvider

در MainActivity از ViewModelProvider استفاده کردیم تا ViewModel رو به Activity وصل کنیم:

val viewModel =

    ViewModelProvider(this)[AppViewModel::class.java]

کلمه‌ی this در اینجا یعنی مالک این ViewModel همین Activity است. ViewModelProvider بررسی می‌کنه که آیا قبلاً نمونه‌ای از این ViewModel ساخته شده یا نه؛ اگر وجود داشته باشه همان نمونه رو برمی‌گردونه و اگر وجود نداشته باشه یک نمونه جدید می‌سازه. به همین دلیل ViewModel با هر Recomposition دوباره ساخته نمیشه و اطلاعات اصلی صفحه داخلش باقی می‌مونه.

معنی ::class.java

در این قسمت:

AppViewModel::class.java

داریم خود کلاس ViewModel رو به ViewModelProvider معرفی می‌کنیم تا بدونه باید از چه کلاسی نمونه بسازه. ::class اطلاعات کلاس رو در اختیار کاتلین قرار می‌ده و .java آن را به شکلی تبدیل می‌کنه که API مربوط به ViewModelProvider بتونه از آن استفاده بکنه.

چرا یک ViewModel رو به هر دو صفحه دادیم؟

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

collectAsState

state داخل ViewModel از نوع StateFlow است، اما Compose برای به‌روزرسانی UI به یک State قابل مشاهده نیاز داره. collectAsState() مقدار StateFlow رو دریافت می‌کنه و آن را به State قابل استفاده در Compose تبدیل می‌کنه:

val state by
    viewModel.state.collectAsState()

از این لحظه هر وقت مقدار StateFlow تغییر بکنه، Compose هم متوجه میشه و بخش‌های وابسته به state رو دوباره می‌سازه. در درس قبلی نسخه‌ی collectAsStateWithLifecycle() رو هم دیدیم که همین کار رو با درنظرگرفتن Lifecycle صفحه انجام می‌ده.

تغییر State با copy

State اصلی ما یک data class است و چند مقدار مثل نام کاربری، رمز عبور، Loading و پیام خطا رو با هم نگه می‌داره. برای تغییر یکی از این مقدارها از copy() استفاده کردیم:

_state.value =

    _state.value.copy(

        username = value

    )

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

چرا مقدار State رو داخل currentState ریختیم؟

در ابتدای تابع ورود نوشتیم:

val currentState =

    _state.value

یعنی مقدار فعلی State رو یک بار می‌گیریم و با نام currentState نگه می‌داریم. بعد برای بررسی نام کاربری و رمز عبور از همین مقدار استفاده می‌کنیم. این کار هم کد رو خواناتر می‌کنه و هم لازم نیست چند بار پشت سرهم _state.value بنویسیم.

isBlank

برای بررسی خالی بودن نام کاربری و رمز عبور از isBlank() استفاده کردیم:

currentState.username.isBlank()

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

چرا بعد از خطا return نوشتیم؟

اگر نام کاربری یا رمز عبور خالی باشه، پیام خطا رو داخل State قرار می‌دیم و بعد return می‌نویسیم:

return

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

loading، error و message

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

delay برای شبیه‌سازی درخواست سرور

در این تمرین هنوز فرم ورود رو به API واقعی وصل نکردیم، اما می‌خواستیم وضعیت Loading رو در عمل ببینیم. برای همین از:

delay(1200)

استفاده کردیم. این دستور اجرای Coroutine رو حدود ۱۲۰۰ میلی‌ثانیه متوقف می‌کنه و بعد ادامه می‌ده. در این فاصله loading برابر true است و چرخنده‌ی Loading نمایش داده میشه. این تأخیر فقط برای شبیه‌سازی درخواست شبکه است و بعداً API واقعی جای آن را می‌گیره.

جلوگیری از چند بار کلیک با enabled

وقتی عملیات ورود در حال انجام است، دکمه ورود رو غیرفعال کردیم:

enabled = !loading

علامت ! مقدار Boolean رو برعکس می‌کنه. یعنی وقتی loading برابر true است، مقدار !loading برابر false میشه و دکمه غیرفعال میشه. این کار جلوی چند بار کلیک کردن کاربر و اجرای هم‌زمان چند درخواست ورود رو می‌گیره. TextFieldها و دکمه بازگشت رو هم به همین دلیل هنگام Loading غیرفعال کردیم.

نمایش شرطی Loading و پیام‌ها

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

if (loading) {
      CircularProgressIndicator()
}

وقتی loading برابر true باشه، چرخنده نمایش داده میشه. پیام خطا و پیام موفقیت هم فقط زمانی نمایش داده میشن که مقدارشان null نباشه. بنابراین UI ثابت نیست و بر اساس وضعیت فعلی State شکل می‌گیره.

State Hoisting در LoginForm

LoginForm خودش نام کاربری، رمز عبور، Loading یا پیام‌ها رو نگه نمی‌داره. این مقدارها از بیرون به آن داده میشن:

username: String

password: String

loading: Boolean

همچنین وقتی کاربر چیزی رو تغییر می‌ده، LoginForm فقط رویداد رو از طریق توابعی مثل onUsernameChange و onLoginClick به بخش بالاتر اعلام می‌کنه. به بیرون کشیدن State از داخل Composable و سپردن فقط نمایش و اعلام رویدادها به آن، State Hoisting می‌گیم. این کار باعث میشه LoginForm به ViewModel خاصی وابسته نباشه و در بخش‌های دیگر برنامه هم قابل استفاده باشه.

چرا رویدادها رو به شکل تابع فرستادیم؟

پارامترهایی مثل:

onUsernameChange: (String) -> Unit
onLoginClick: () -> Unit

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

تغییر صفحه با State

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

var showLoginScreen by remember {

    mutableStateOf(false)

}

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

PasswordVisualTransformation

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

KeyboardOptions و KeyboardType.Password

با این کد:

KeyboardOptions(

    keyboardType = KeyboardType.Password

)

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

verticalScroll و rememberScrollState

چون محتوای صفحه ممکنه در گوشی‌های کوچک یا هنگام باز شدن کیبورد کامل دیده نشه، Column رو قابل اسکرول کردیم:

.verticalScroll(

    rememberScrollState()

)

verticalScroll اجازه می‌ده صفحه به صورت عمودی حرکت بکنه و rememberScrollState وضعیت فعلی اسکرول رو نگه می‌داره. در نتیجه کاربر می‌تونه به بخش‌های پایین‌تر فرم دسترسی داشته باشه و با هر Recomposition موقعیت اسکرول بی‌دلیل از اول شروع نمیشه.

statusBarsPadding و navigationBarsPadding

محتوای Compose ممکنه زیر نوار وضعیت بالای گوشی یا نوار پایین سیستم قرار بگیره. برای جلوگیری از این اتفاق از:

.statusBarsPadding()

.navigationBarsPadding()

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

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

برای اینکه تمام صفحه‌های زیر MainActivity راست‌چین باشن، جهت چیدمان رو از ریشه برنامه روی RTL قرار دادیم:

CompositionLocalProvider(
    LocalLayoutDirection provides LayoutDirection.Rtl
)

هر Composable که داخل این بخش ساخته بشه، جهت راست‌به‌چپ رو دریافت می‌کنه. به این ترتیب لازم نیست در تک‌تک صفحه‌ها دوباره RTL رو تنظیم کنیم و چیدمان متن‌ها، Rowها و فاصله‌های start و end متناسب با زبان فارسی انجام میشه.

درس ۹
Retrofit و ارتباط حرفه‌ای با API

در درس قبل پروژه را وارد معماری MVVM کردیم. الان ساختار ما این شکلی است:

Compose UI

ViewModel

Repository

API

اما یک بخش هنوز کامل نشده است. ما داخل Repository گفتیم api.getUsers ولی هنوز واقعاً نمی‌دانیم این API چطور باید ساخته شود. در این درس می‌خواهیم ارتباط واقعی بین اپلیکیشن اندروید و سرور PHP که ساختیم را با Retrofit ایجاد کنیم. در پایان این درس جریان کامل ما این خواهد بود:

Android App

Retrofit

HTTP Request

PHP API

JSON Response

Kotlin Object

ViewModel

Compose


Retrofit چیست؟

Retrofit یک کتابخانه برای ارتباط با API است. یعنی کارهایی مثل:

  • ساخت Request
  • ارسال HTTP
  • دریافت Response
  • تبدیل JSON به Object Kotlin

را برای ما ساده می‌کند. بدون Retrofit باید خودمان:

  • URL بسازیم
  • Connection باز کنیم
  • Response بخوانیم
  • JSON تبدیل کنیم

اما Retrofit این کارها را مدیریت می‌کند.


نصب Retrofit

داخل build.gradle وابستگی‌ها را اضافه می‌کنیم:

implementation(
    "com.squareup.retrofit2:retrofit:2.9.0"
)


implementation(
    "com.squareup.retrofit2:converter-gson:2.9.0"
)

اولی خود Retrofit است. دومی تبدیل JSON به Kotlin Object را انجام می‌دهد.


Converter Gson چیست؟

فرض کن سرور این JSON را برگرداند:

[
 {
  "id":1,
  "name":"Ali",
  "job":"Developer"
 }
]

اندروید باید این را تبدیل کند به:

User(
    id = 1,
    name = "Ali",
    job = "Developer"
)

این تبدیل را Gson انجام می‌دهد. یعنی:

JSON

Gson

Kotlin Object


ساخت Model

همان User قبلی را داریم:

data class User(

    val id:Int,

    val name:String,

    val job:String

)

حالا Retrofit می‌داند JSON را باید به این کلاس تبدیل کند.


ساخت Interface API

یک فایل UserApi.kt بساز و داخل آن:

interface UserApi {

}

اینجا قرارداد ارتباط ما با سرور قرار می‌گیرد.


تعریف اولین Request

سرور ما:

https://example.com/users.php

است. ما می‌خواهیم:

GET /users.php

ارسال کنیم. پس:

interface UserApi {


    @GET("users.php")
    suspend fun getUsers():

    List<User>


}

حالا این کد چه می‌گوید؟ این قسمت:

@GET("users.php")

یعنی یک درخواست GET به این مسیر بفرست و این:

suspend fun getUsers()

یعنی این درخواست شبکه است و باید داخل Coroutine اجرا شود.


چرا suspend؟

در اندروید نباید درخواست شبکه را روی Thread اصلی اجرا کنیم. مثلاً این کار اشتباه است:

button.setOnClickListener {

    api.getUsers()

}

چون اگر اینترنت کند باشد، UI قفل می‌شود. به جای آن:

viewModelScope.launch {

    api.getUsers()

}

استفاده می‌کنیم. suspend یعنی این تابع می‌تواند متوقف شود و بعداً ادامه پیدا کند، بدون اینکه UI را متوقف کند.


ساخت Retrofit Instance

حالا باید Retrofit را بسازیم. مثلاً:

object RetrofitInstance {


    private val retrofit = Retrofit.Builder()

        .baseUrl(
            "https://example.com/"
        )

        .addConverterFactory(
            GsonConverterFactory.create()
        )

        .build()



    val api =
        retrofit.create(
            UserApi::class.java
        )

}

بررسی این کد

قسمت Retrofit.Builder یعنی، یک Retrofit جدید بساز.


قسمت:

.baseUrl(
    "https://example.com/"
)

آدرس اصلی سرور را مشخص می‌کند. مثلاً:

https://example.com/

بعد داخل API:

@GET("users.php")

قرار می‌گیرد. یعنی:

https://example.com/users.php

قسمت:

.addConverterFactory(
    GsonConverterFactory.create()
)

می‌گوید هر JSON که از سرور آمد، با Gson تبدیل کن.


قسمت:

retrofit.create(UserApi::class.java)

از Interface ما یک نمونه واقعی می‌سازد.


استفاده در Repository

حالا Repository ما واقعی می‌شود. قبلاً:

class UserRepositoryImpl(

    private val api:UserApi

)

داشتیم. حالا:

class UserRepositoryImpl(

    private val api:UserApi

):UserRepository{


override suspend fun getUsers():

List<User>{


    return api.getUsers()


}


}

اینجا:

api.getUsers()

واقعاً درخواست HTTP ارسال می‌کند.


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

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

UserScreen

ViewModel

loadUsers()

Repository

api.getUsers()

Retrofit

GET users.php

PHP Server

JSON

Gson

List<User>

StateFlow

Compose


مدیریت Response

در مثال قبلی مستقیم گفتیم:

List<User>

برگردد. اما در پروژه واقعی معمولاً Response کامل داریم. مثلاً سرور:

{
 "success":true,
 "data":[
   {
    "id":1,
    "name":"Ali"
   }
 ]
}

پس مدل:

data class UserResponse(

    val success:Boolean,

    val data:List<User>

)

می‌شود و API:

@GET("users.php")
suspend fun getUsers():

UserResponse

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

در دنیای واقعی همیشه موفق نیستیم. ممکن است:

  • اینترنت قطع شود
  • سرور خاموش باشد
  • پاسخ اشتباه باشد

مثلاً:

try {


    val users =
        api.getUsers()


}

catch(e:Exception){


}

بعداً در درس مدیریت خطا کامل‌تر روی این کار می‌کنیم.


اضافه کردن Timeout

گاهی سرور خیلی دیر جواب می‌دهد. برای جلوگیری از انتظار بی‌نهایت، OkHttp را تنظیم می‌کنیم. مثلاً:

val client =
OkHttpClient.Builder()

    .connectTimeout(
        20,
        TimeUnit.SECONDS
    )

    .build()

Logging درخواست‌ها

برای Debug کردن API خیلی مهم است. مثلاً ببینیم چه URLای ارسال شد؟ چه JSONای برگشت؟ برای این کار از:

HttpLoggingInterceptor

استفاده می‌کنیم. مثلاً:

val logging =
HttpLoggingInterceptor()


logging.level =
HttpLoggingInterceptor.Level.BODY

بعد:

OkHttpClient.Builder()

.addInterceptor(logging)

.build()

حالا داخل Logcat می‌بینی:

GET /users.php


Response 200


[
 {
  "id":1
 }
]

اما در Release مراقب باش

این قسمت Level.BODY در نسخه نهایی خطرناک است. چون ممکن است:

  • Token
  • اطلاعات کاربر
  • داده حساس

داخل Log بیفتد. معمولاً فقط در Debug فعالش می‌کنیم.


ساختار پروژه بعد از Retrofit

حالا پروژه ما:

app

├── data

│   ├── api

│   │    └── UserApi.kt

│   │
│   ├── retrofit

│   │    └── RetrofitInstance.kt

│   │
│   └── repository


├── domain

│   └── model

│        └── User.kt


└── presentation

    └── UserViewModel.kt

وابستگی Retrofit

برای اینکه بتونیم از داخل اپلیکیشن درخواست HTTP به سرور بفرستیم، خود کتابخانه Retrofit رو به پروژه اضافه کردیم:

implementation(

    "com.squareup.retrofit2:retrofit:2.9.0"

)

Retrofit بخش اصلی ارتباط با API رو مدیریت می‌کنه. یعنی ما فقط آدرس سرور، نوع درخواست و مدل پاسخ رو مشخص می‌کنیم و Retrofit کارهایی مثل ساخت Request، ارسال آن و دریافت Response رو انجام می‌ده. بدون Retrofit مجبور بودیم اتصال اینترنت، خواندن پاسخ و مدیریت جزئیات HTTP رو دستی پیاده کنیم.

وابستگی Converter Gson

پاسخی که از سرور PHP دریافت می‌کنیم به شکل JSON است، اما داخل برنامه می‌خواهیم با Objectهای کاتلین مثل User و UserResponse کار کنیم. برای همین این وابستگی رو اضافه کردیم:

implementation(

    "com.squareup.retrofit2:converter-gson:2.9.0"

)

این Converter پاسخ JSON رو می‌خونه و براساس نام فیلدها، آن را به کلاس‌های کاتلین تبدیل می‌کنه. مثلاً فیلدهای id، name و job داخل JSON به ویژگی‌های هم‌نام داخل کلاس User منتقل می‌شن.

وابستگی Logging Interceptor

برای اینکه هنگام توسعه ببینیم دقیقاً چه درخواستی به سرور فرستاده شده و چه پاسخی برگشته، این کتابخانه رو اضافه کردیم:

implementation(

    "com.squareup.okhttp3:logging-interceptor:4.12.0"

)

Logging Interceptor اطلاعات درخواست‌های شبکه رو داخل Logcat نمایش می‌ده. این ابزار هنگام رفع خطا خیلی مفیده، چون می‌تونیم URL، نوع درخواست، کد پاسخ و JSON برگشتی رو ببینیم و متوجه بشیم مشکل از اپلیکیشن است یا سرور.

مجوز INTERNET

برای اینکه برنامه اجازه ارتباط با اینترنت یا سرور داخل شبکه رو داشته باشه، این مجوز داخل AndroidManifest.xml قرار می‌گیره:

<uses-permission

    android:name="android.permission.INTERNET" />

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

چرا usesCleartextTraffic را اضافه کردیم؟

سرور محلی XAMPP ما از آدرس http استفاده می‌کنه، نه https. نسخه‌های جدید اندروید به‌صورت پیش‌فرض ارتباط‌های رمزنگاری‌نشده‌ی HTTP رو محدود می‌کنن. برای همین داخل تگ application نوشتیم:

android:usesCleartextTraffic=”true”

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

چرا از 10.0.2.2 استفاده کردیم؟

داخل مرورگر کامپیوتر، سرور XAMPP رو با این آدرس باز می‌کنیم:

http://localhost/

اما داخل شبیه‌ساز اندروید، localhost به خود گوشی مجازی اشاره می‌کنه، نه کامپیوتری که XAMPP روی آن اجرا شده. اندروید برای دسترسی شبیه‌ساز به کامپیوتر میزبان یک آدرس مخصوص در اختیارمون می‌ذاره:

10.0.2.2

برای همین آدرس Retrofit رو این‌طوری نوشتیم:

“http://10.0.2.2/helloiman_api/”

این آدرس فقط برای Android Emulator است. اگر برنامه رو روی گوشی واقعی اجرا کنیم، باید از IP کامپیوتر داخل شبکه محلی یا آدرس واقعی سرور استفاده کنیم.

چرا انتهای Base URL علامت / دارد؟

آدرس اصلی Retrofit باید به علامت / ختم بشه:

private const val BASE_URL =

    "http://10.0.2.2/helloiman_api/"

Retrofit این آدرس اصلی رو با مسیر نوشته‌شده داخل @GET ترکیب می‌کنه:

@GET(“users.php”)

در نتیجه آدرس کامل درخواست میشه:

http://10.0.2.2/helloiman_api/users.php

اگر / انتهای Base URL وجود نداشته باشه، Retrofit هنگام ساختن نمونه خطا می‌ده، چون نمی‌تونه مسیر پایه و مسیر Request رو درست به هم متصل کنه.

مدل UserResponse

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

{

  "success": true,

  "data": [],

  "message": null

}

برای همین علاوه بر کلاس User، کلاس دیگری به نام UserResponse ساختیم:

data class UserResponse(

    val success: Boolean,

    val data: List<User>,

    val message: String? = null

)

این کلاس نماینده کل پاسخ سرور است. success مشخص می‌کنه عملیات موفق بوده یا نه، data اطلاعات کاربران رو نگه می‌داره و message هم می‌تونه پیام خطا یا توضیح سرور رو در خودش داشته باشه. ساختار کلاس باید با ساختار JSON هماهنگ باشه تا Gson بتونه تبدیل رو درست انجام بده.

Interface مربوط به API

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

interface UserApi

ما داخل این Interface توضیح می‌دیم که چه Requestهایی داریم، هر Request به چه مسیری فرستاده میشه و پاسخ آن از چه نوعیه. خود Interface درخواست واقعی رو ارسال نمی‌کنه؛ Retrofit بعداً براساس همین قرارداد یک پیاده‌سازی واقعی از آن می‌سازه.

دستور @GET

بالای تابع دریافت کاربران نوشتیم:

@GET(“users.php”)

این Annotation به Retrofit می‌گه تابع زیر آن باید یک درخواست از نوع GET به مسیر users.php بفرسته. Retrofit این مسیر رو به Base URL اضافه می‌کنه و آدرس کامل Request رو می‌سازه. اگر بعداً فایل دیگری مثل user_detail.php داشته باشیم، برای آن هم یک تابع جدید با @GET جدا تعریف می‌کنیم.

چرا خروجی API از نوع UserResponse است؟

تابع API رو این‌طوری تعریف کردیم:

suspend fun getUsers(): UserResponse

چون JSON سرور شامل success، data و message است، خروجی تابع هم باید کلاسی باشه که همین سه بخش رو داشته باشه. Gson پاسخ JSON رو دریافت می‌کنه و یک نمونه از UserResponse می‌سازه. لیست کاربران هم داخل ویژگی data قرار می‌گیره.

Retrofit.Builder

برای ساختن Retrofit از Builder استفاده کردیم:

Retrofit.Builder()

Builder یعنی تنظیمات موردنیاز رو مرحله‌به‌مرحله روی Retrofit قرار می‌دیم و در پایان با build() نمونه نهایی رو می‌سازیم. در اینجا آدرس سرور، OkHttpClient و Converter مربوط به Gson رو مشخص کردیم.

addConverterFactory

این قسمت به Retrofit می‌گه پاسخ‌های سرور رو با Gson تبدیل کنه:

.addConverterFactory(

    GsonConverterFactory.create()

)

Retrofit خودش نمی‌دونه متن JSON باید به چه شکلی به Object کاتلین تبدیل بشه. با اضافه کردن GsonConverterFactory این مسئولیت رو به Gson می‌سپاریم. Gson هم براساس نوع خروجی تابع، مثلاً UserResponse، کلاس مناسب رو می‌سازه.

retrofit.create

بعد از ساخت Retrofit نوشتیم:

retrofit.create(

    UserApi::class.java

)

ما فقط یک Interface به نام UserApi ساخته بودیم و داخلش هیچ کد واقعی برای ارسال Request وجود نداشت. retrofit.create براساس Annotationهایی مثل @GET یک نمونه واقعی از این Interface می‌سازه. بعد از آن وقتی api.getUsers رو صدا می‌زنیم، Retrofit واقعاً درخواست HTTP رو ارسال می‌کنه.

چرا RetrofitInstance را با object ساختیم؟

کلاس Retrofit رو به شکل object تعریف کردیم:

object RetrofitInstance

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

HttpLoggingInterceptor

برای مشاهده درخواست و پاسخ شبکه یک Logging Interceptor ساختیم:

val loggingInterceptor =

    HttpLoggingInterceptor()

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

استفاده از apply

Logging Interceptor رو این‌طوری تنظیم کردیم:

HttpLoggingInterceptor().apply {

    level =

        HttpLoggingInterceptor.Level.BODY

}

apply اجازه می‌ده بلافاصله بعد از ساختن یک Object، ویژگی‌های همان Object رو تنظیم کنیم. داخل این بلوک، this همان HttpLoggingInterceptor ساخته‌شده است؛ بنابراین می‌تونیم مستقیم ویژگی level رو تغییر بدیم و در پایان هم خود Object برگردونده میشه.

سطح Logging با Level.BODY

با این کد:

HttpLoggingInterceptor.Level.BODY

مشخص کردیم کامل‌ترین اطلاعات درخواست و پاسخ داخل Logcat نمایش داده بشه. این اطلاعات می‌تونه شامل Headerها و بدنه JSON هم باشه. این سطح برای Debug مناسبه، اما در نسخه نهایی برنامه نباید فعال بمونه، چون ممکنه Token، رمز، اطلاعات کاربر یا داده‌های حساس رو وارد Log بکنه.

OkHttpClient.Builder

Retrofit برای ارسال واقعی Requestها از OkHttp استفاده می‌کنه. با این کد یک Client قابل تنظیم ساختیم:

OkHttpClient.Builder()

تنظیماتی مثل Timeout و Logging روی همین Client قرار می‌گیرن. بعد Client نهایی رو به Retrofit می‌دیم تا تمام درخواست‌های برنامه با این تنظیمات ارسال بشن.

تفاوت Timeoutها

برای Client سه Timeout مشخص کردیم. connectTimeout حداکثر زمانی است که برنامه برای برقرار شدن اتصال با سرور صبر می‌کنه. readTimeout مدت زمانی است که برنامه برای دریافت اطلاعات از سرور منتظر می‌مونه و writeTimeout مدت مجاز برای فرستادن اطلاعات به سرور است. اگر هرکدام از این زمان‌ها بیشتر از حد مشخص‌شده طول بکشه، درخواست متوقف میشه و Exception ایجاد میشه؛ در نتیجه برنامه برای همیشه منتظر یک سرور خراب یا کند باقی نمی‌مونه.

TimeUnit.SECONDS

در تنظیم Timeout نوشتیم:

20,

TimeUnit.SECONDS

عدد 20 به‌تنهایی مشخص نمی‌کنه منظور ۲۰ ثانیه، میلی‌ثانیه یا دقیقه است. TimeUnit.SECONDS واحد این مقدار رو تعیین می‌کنه و به OkHttp می‌گه حداکثر ۲۰ ثانیه منتظر بمونه.

اضافه کردن Interceptor به Client

بعد از ساخت Logging Interceptor، آن را به OkHttpClient اضافه کردیم:

.addInterceptor(

    loggingInterceptor

)

صرفاً ساختن Interceptor کافی نیست. تا وقتی آن را با addInterceptor() به Client متصل نکنیم، هیچ Request یا Responseای رو مشاهده نمی‌کنه و چیزی داخل Logcat نمایش داده نمیشه.

دادن OkHttpClient به Retrofit

Client تنظیم‌شده رو با این کد به Retrofit وصل کردیم:

.client(

    client

)

از این لحظه Retrofit برای تمام درخواست‌ها از همین Client استفاده می‌کنه. بنابراین Timeoutها و Loggingهایی که روی Client گذاشتیم، روی درخواست getUsers() هم اعمال می‌شن.

Repository ساده

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

class UserRepository(

    private val api: UserApi =

        RetrofitInstance.api

)

هدف Repository اینه که ViewModel مستقیماً درگیر جزئیات Retrofit نشه. ViewModel فقط از Repository می‌خواد کاربران رو دریافت کنه و Repository درخواست رو به API می‌فرسته. اینجا برای جلوگیری از زیاد شدن فایل‌ها، Interface و Implementation جدا نساختیم.

مقدار پیش‌فرض پارامتر api

داخل سازنده Repository نوشتیم:

private val api: UserApi =

    RetrofitInstance.api

یعنی اگر هنگام ساخت Repository هیچ APIای به آن ندیم، به‌صورت پیش‌فرض از RetrofitInstance.api استفاده می‌کنه:

UserRepository()

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

تفاوت موفقیت HTTP با response.success

ممکنه درخواست از نظر HTTP موفق باشه و سرور پاسخ 200 برگردونه، اما عملیات داخل برنامه ناموفق باشه. برای همین بعد از دریافت پاسخ بررسی می‌کنیم:

if (response.success)

این success بخشی از JSON خودمون است و PHP آن را تعیین می‌کنه. پس موفق بودن ارتباط شبکه لزوماً به معنی موفق بودن عملیات برنامه نیست. مثلاً سرور می‌تونه پاسخ معتبر بده، اما داخل آن بنویسه success: false.

localizedMessage

وقتی Exception رخ می‌ده، برای نمایش توضیح آن از این ویژگی استفاده کردیم:

exception.localizedMessage

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

عملگر Elvis یعنی ?:

در این قسمت:

exception.localizedMessage
    ?: "ارتباط با سرور برقرار نشد"

عملگر ?: می‌گه اگر مقدار سمت چپ null نبود، همان رو استفاده کن؛ اما اگر null بود، مقدار سمت راست رو قرار بده. به این ترتیب حتی اگر Exception پیام مشخصی نداشت، کاربر بدون پیام رها نمیشه.

when بدون مقدار مشخص

در UserScreen از این شکل when استفاده کردیم:

when {

    state.loading -> { }

    state.error != null -> { }

    else -> { }

}

اینجا when روی یک متغیر مشخص اجرا نشده، بلکه هر شاخه یک شرط جدا داره. کاتلین شرط‌ها رو از بالا به پایین بررسی می‌کنه و اولین شرطی که true باشه اجرا میشه. ابتدا Loading بررسی میشه، بعد Error و اگر هیچ‌کدام نبود، محتوای کاربران نمایش داده میشه.

دکمه تلاش دوباره

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

onRetry = {

    viewModel.loadUsers()

}

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

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

در حالت موفق هم دکمه‌ای برای Refresh کردن اطلاعات داریم:

onRefresh = {

    viewModel.loadUsers()

}

این دکمه دوباره اطلاعات رو از خود سرور می‌گیره. بنابراین اگر محتوای users.php تغییر کرده باشه، با زدن این دکمه لیست جدید داخل برنامه نمایش داده میشه.

item و items داخل LazyColumn

داخل LazyColumn برای قرار دادن یک آیتم ثابت مثل عنوان و دکمه از:

item {

}

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

items(

    items = users

)

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

درس ۱۰
ساخت سیستم Login با JWT

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

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

برای این کار باید مفهوم Authentication را یاد بگیریم.


Authentication چیست؟

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

Username
+
Password

Server

User تایید شد

سرور بررسی می‌کند: آیا این اطلاعات درست است؟ اگر درست بود، اجازه ورود می‌دهد.


تفاوت Authentication و Authorization

این دو مفهوم خیلی با هم اشتباه می‌شوند.

Authentication

یعنی تو کی هستی؟ مثلاً Ali وارد شد.


Authorization

یعنی چه کاری اجازه داری انجام بدهی؟
مثلاً: Admin اجازه حذف کاربران دارد. User معمولی اجازه ندارد.

اول باید Authentication انجام شود، بعد Authorization.


مشکل Session قدیمی

در گذشته سایت‌ها معمولاً با Session کار می‌کردند. مثلاً کاربر Login می‌کند. سرور:

Session ID = 12345

می‌سازد. بعد کاربر در درخواست‌های بعدی همان Session را ارسال می‌کند. اما برای اپلیکیشن‌های موبایل یک روش محبوب‌تر وجود دارد: JWT.


JWT چیست؟

JWT مخفف:

JSON Web Token

است. یک Token است که سرور بعد از Login به کاربر می‌دهد. مثلاً:

{
 "token":
 "eyJhbGciOiJIUzI1..."
}

این Token مثل کارت شناسایی موقت کاربر است.


جریان Login با JWT

کل فرایند:

Android

Username + Password

PHP Login API

بررسی اطلاعات

ساخت JWT

ارسال Token

Android ذخیره می‌کند

درخواست‌های بعدی با Token


ساخت Backend ساده با PHP

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

<?php


header(
"Content-Type: application/json"
);



$username =
$_POST["username"];


$password =
$_POST["password"];



if(

$username == "ali"

&&

$password == "1234"

){


echo json_encode([

"success"=>true,

"token"=>"abc123xyz"

]);


}

else{


echo json_encode([

"success"=>false,

"message"=>"Wrong username"

]);


}



?>

اگر اطلاعات درست باشد، درخواست:

username = ali

password = 1234

پاسخ:

{
 "success":true,
 "token":"abc123xyz"
}

JWT واقعی چگونه ساخته می‌شود؟

در پروژه واقعی Token یک رشته تصادفی ساده نیست. JWT سه بخش دارد:

Header.Payload.Signature

مثلاً:

xxxxx.yyyyy.zzzzz

Header

اطلاعات الگوریتم:

{
 "alg":"HS256",
 "typ":"JWT"
}

Payload

اطلاعات کاربر:

{
 "userId":15,
 "name":"Ali"
}

Signature

برای جلوگیری از دستکاری Token استفاده می‌شود.


نکته مهم JWT رمزگذاری نیست. یعنی نباید اطلاعات محرمانه داخل Payload قرار بدهیم. مثلاً این کار اشتباه است:

{
 "password":"123456"
}

ساخت صفحه Login در Compose

حالا سمت Android. مدل درخواست:

data class LoginRequest(

    val username:String,

    val password:String

)

مدل پاسخ:

data class LoginResponse(

    val success:Boolean,

    val token:String?,

    val message:String?

)

ساخت فرم Login

@Composable
fun LoginScreen(){


var username by remember {

    mutableStateOf("")

}


var password by remember {

    mutableStateOf("")

}


Column {


TextField(

value=username,

onValueChange={

    username = it

}

)



TextField(

value=password,

onValueChange={

    password = it

}

)



Button(

onClick={


}

){

Text(
"Login"
)

}


}


}

ساخت API با Retrofit

حالا API:

interface AuthApi {


@POST("login.php")
suspend fun login(

@Body request:LoginRequest

):LoginResponse



}

اینجا @POST یعنی یک درخواست POST ارسال کن.


POST چیست؟

در GET اطلاعات معمولاً داخل URL است. مثلاً:

users.php?id=15

اما در POST اطلاعات داخل Body ارسال می‌شود. مثلاً:

{
"username":"ali",
"password":"1234"
}

برای اطلاعات حساس مثل Login از POST استفاده می‌کنیم.


ViewModel مربوط به Login

مثل قبل UI نباید مستقیم API را صدا بزند. ساختار:

LoginScreen

LoginViewModel

AuthRepository

Retrofit

PHP


LoginViewModel

class LoginViewModel(

private val repository:
AuthRepository

):ViewModel(){



fun login(

username:String,

password:String

){


viewModelScope.launch {


val response =
repository.login(

LoginRequest(
username,
password
)

)



if(response.success){


}

}



}



}

مدیریت وضعیت Login

مثل درس State بهتر است یک State بسازیم.

data class LoginState(

val loading:Boolean = false,

val success:Boolean = false,

val error:String? = null

)

حالا UI می‌تواند بداند:

  • در حال ارسال است
  • موفق شده
  • خطا رخ داده

دریافت Token

وقتی Login موفق شد، پاسخ:

{
"token":"abc123"
}

داخل ViewModel:

if(response.success){

    val token =
    response.token

}

حالا باید این Token را ذخیره کنیم. فعلاً فقط مفهوم را می‌بینیم. در درس بعدی درباره Session و نگهداری وضعیت Login صحبت می‌کنیم.


استفاده از Token در درخواست‌های بعدی

فرض کن بعد از Login می‌خواهیم:

profile.php

را صدا بزنیم. سرور می‌گوید اول ثابت کن چه کسی هستی. پس درخواست:

Authorization:

Bearer abc123

ارسال می‌شود.


Header Authorization

در Retrofit:

@GET("profile.php")
suspend fun profile(

@Header("Authorization")
token:String

):User

مثلاً:

Bearer abc123

ارسال می‌شود.


چرا Token بهتر از فرستادن Username و Password است؟

فرض کن هر درخواست:

{
"username":"ali",
"password":"1234"
}

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

Password

Token

بعد:

Token

Requests


ساختار پروژه بعد از Login

حالا پروژه:

data

 ├── auth

 │    ├── AuthApi.kt

 │    ├── LoginRequest.kt

 │    └── LoginResponse.kt


domain

 └── AuthRepository.kt


presentation

 └── login

      ├── LoginScreen.kt

      └── LoginViewModel.kt

مدل LoginRequest

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

data class LoginRequest(

    val username: String,

    val password: String

)

وقتی این کلاس رو به Retrofit می‌دیم، Gson اون رو به JSON تبدیل می‌کنه. مثلاً اگر کاربر ali و 1234 رو وارد بکنه، بدنه‌ی درخواست به این شکل ساخته میشه:

{

  "username": "ali",

  "password": "1234"

}

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

مدل LoginResponse

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

data class LoginResponse(

    val success: Boolean,

    val token: String? = null,

    val expiresAt: Long? = null,

    val message: String? = null

)

ویژگی success نتیجه‌ی ورود رو مشخص می‌کنه، token کارت شناسایی موقت کاربره، expiresAt زمان انقضای Token رو نگه می‌داره و message هم پیام موفقیت یا خطای سروره. فیلدها Nullable هستن چون در ورود ناموفق، طبیعتاً Token و زمان انقضا وجود ندارن.

تفاوت ProfileUser و ProfileResponse

برای پاسخ پروفایل دو مدل ساختیم. ProfileUser فقط اطلاعات خود کاربر رو نگه می‌داره، اما ProfileResponse نماینده‌ی کل پاسخ سروره:

data class ProfileResponse(

    val success: Boolean,

    val data: ProfileUser? = null,

    val message: String? = null

)

این جداسازی به این دلیله که پاسخ API معمولاً علاوه بر داده‌ی اصلی، اطلاعات دیگری مثل نتیجه‌ی عملیات و پیام سرور هم داره. اگر Token نامعتبر باشه، data برابر null میشه و پیام خطا داخل message قرار می‌گیره.

درخواست POST با @POST

برای Login از درخواست POST استفاده کردیم:

@POST("login.php")

suspend fun login(...)

@POST به Retrofit می‌گه این تابع باید یک درخواست POST به login.php بفرسته. تفاوت مهمش با GET اینه که اطلاعات ورود داخل URL قرار نمی‌گیرن و داخل Body درخواست فرستاده میشن. البته POST به‌تنهایی رمزنگاری ایجاد نمی‌کنه؛ چیزی که اطلاعات رو در مسیر رمزنگاری می‌کنه، استفاده از HTTPS است.

ارسال JSON با @Body

کنار پارامتر درخواست نوشتیم:

@Body

request: LoginRequest

@Body به Retrofit می‌گه این Object رو به بدنه‌ی HTTP Request تبدیل کن. چون Gson Converter رو قبلاً به Retrofit اضافه کردیم، LoginRequest به JSON تبدیل میشه و برای PHP فرستاده میشه. بدون @Body، Retrofit نمی‌فهمید این Object باید داخل بدنه‌ی درخواست قرار بگیره.

استفاده از Response<T>

در درس قبل خروجی Retrofit رو مستقیماً به شکل UserResponse دریافت می‌کردیم، اما این بار نوشتیم:

Response<LoginResponse>

دلیلش اینه که برای Login فقط بدنه‌ی پاسخ مهم نیست؛ کد HTTP هم مهمه. با Response می‌تونیم بفهمیم سرور 200، 401، 405 یا 422 برگردونده. خود LoginResponse فقط محتوای JSON رو نگه می‌داره، اما Response<LoginResponse> اطلاعات کامل HTTP رو هم در اختیارمون می‌ذاره.

isSuccessful، body و code

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

response.isSuccessful

response.body()

response.code()

isSuccessful بررسی می‌کنه کد HTTP بین ۲۰۰ تا ۲۹۹ هست یا نه. body بدنه‌ی تبدیل‌شده‌ی JSON رو برمی‌گردونه و code خود کد HTTP مثل 401 یا 422 رو در اختیارمون می‌ذاره. با این اطلاعات Repository می‌تونه برای خطاهای مختلف، پیام مناسب‌تری بسازه.

کدهای 401، 405 و 422

در این پروژه هر کد HTTP معنی مشخصی داره. 401 یعنی کاربر احراز هویت نشده یا اطلاعات ورود و Token معتبر نیست. 405 یعنی Endpoint با نوع Request اشتباه صدا زده شده؛ مثلاً login.php باید POST دریافت بکنه. 422 یعنی درخواست از نظر ساختار قابل فهمه، اما اطلاعات لازم مثل نام کاربری یا رمز عبور داخلش کامل نیست. خود login.php هم دقیقاً براساس همین شرایط این کدها رو برمی‌گردونه.

Header مربوط به Authorization

برای دریافت پروفایل نوشتیم:

@Header("Authorization")

authorization: String

این یعنی مقدار پارامتر authorization داخل Header درخواست با نام Authorization قرار بگیره. برخلاف اطلاعات Login، Token معمولاً داخل Body یا URL فرستاده نمیشه؛ در Header استاندارد Authorization قرار می‌گیره تا سرور قبل از تحویل اطلاعات محافظت‌شده، هویت کاربر رو بررسی بکنه.

معنی Bearer $token

در Repository این مقدار رو فرستادیم:

“Bearer $token”

کلمه‌ی Bearer نوع روش احراز هویت رو مشخص می‌کنه و بعد از اون خود Token قرار می‌گیره. شکل نهایی Header تقریباً اینه:

Authorization: Bearer eyJhbGciOiJIUzI1Ni…

سرور اول کلمه‌ی Bearer رو جدا می‌کنه و بعد رشته‌ی JWT رو برای اعتبارسنجی می‌فرسته. بنابراین فرستادن خود Token بدون پیشوند Bearer با قراردادی که Backend نوشته شده هماهنگ نیست.

چرا پروفایل یک Endpoint محافظت‌شده است؟

profile.php بدون دریافت Token اطلاعات کاربر رو تحویل نمی‌ده. ابتدا Header رو می‌خونه، بعد JWT رو اعتبارسنجی می‌کنه و فقط در صورت معتبر بودن Token پاسخ موفق می‌فرسته. اگر Token ارسال نشده باشه یا اعتبار نداشته باشه، پاسخ 401 برمی‌گرده. این بخش در عمل تفاوت بین یک API عمومی و یک API محافظت‌شده رو نشون می‌ده. login.php برای گرفتن Token استفاده میشه، اما profile.php فقط به کسی پاسخ می‌ده که قبلاً وارد شده و Token معتبر داره.

نگهداری Token داخل LoginState

بعد از ورود موفق، Token رو داخل State قرار دادیم:

val token: String? = null

تا وقتی مقدارش null است، UI فرم ورود رو نمایش می‌ده. وقتی Token دریافت میشه، فرم کنار میره و دکمه‌ی دریافت پروفایل نمایش داده میشه. در این درس Token فقط داخل ViewModel نگهداری میشه؛ بنابراین اگر Process برنامه کاملاً از بین بره، Token هم پاک میشه. ذخیره‌ی دائمی و امن Token موضوع درس بعدیه.

بررسی Token با isNullOrBlank

فقط success = true برای معتبر دانستن پاسخ کافی نیست. باید مطمئن بشیم Token واقعاً داخل پاسخ وجود داره:

!receivedToken.isNullOrBlank()

این شرط هم null بودن و هم خالی بودن Token رو بررسی می‌کنه. اگر سرور به‌اشتباه پاسخ موفق بده ولی Token نفرسته، برنامه نباید کاربر رو واردشده در نظر بگیره.

چرا قبل از دریافت پروفایل Token را بررسی کردیم؟

در تابع loadProfile ابتدا مقدار Token رو از State می‌گیریم و بررسی می‌کنیم:

if (currentToken.isNullOrBlank()) {

    return

}

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

خروج از حساب با ساختن State جدید

برای خروج نوشتیم:

_state.value = LoginState()

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

خواندن JSON در PHP با php://input

Retrofit اطلاعات ورود رو به شکل JSON داخل Body می‌فرسته. در PHP این JSON با این کد خونده میشه:

file_get_contents(“php://input”)

php://input دسترسی به بدنه‌ی خام Request رو می‌ده. بعد با json_decode(…, true) متن JSON به آرایه‌ی PHP تبدیل میشه. مقدار true باعث میشه خروجی به شکل Associative Array باشه و بتونیم با عبارتی مثل $requestBody[“username”] به داده‌ها دسترسی پیدا کنیم. اگر بدنه خالی یا JSON نامعتبر باشه، Backend پاسخ خطای مناسب برمی‌گردونه.

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

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

password_verify(

    $password,

    $practiceUser["passwordHash"]

);

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

JWT_SECRET

برای امضای JWT یک Secret روی سرور تعریف کردیم:

define(

    "JWT_SECRET",

    "..."

);

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

JWT_ISSUER

مقدار JWT_ISSUER مشخص می‌کنه چه سیستمی Token رو صادر کرده:

define(

    "JWT_ISSUER",
    "helloiman-api"

);

این مقدار داخل Payload با کلید iss قرار می‌گیره. هنگام بررسی Token، سرور مطمئن میشه Token متعلق به همین API است، نه Tokenای که توسط سیستم دیگری ساخته شده.

JWT_TTL

با JWT_TTL مدت اعتبار Token رو تعیین کردیم:

define(

    "JWT_TTL",
    3600

);

عدد 3600 یعنی Token از زمان ساخته شدن، یک ساعت معتبره. زمان انقضا با اضافه کردن این مقدار به زمان فعلی ساخته میشه. بعد از رسیدن به اون زمان، حتی اگر Signature درست باشه، Token دیگه پذیرفته نمیشه.

Payload و Claimهای JWT

داخل Payload این اطلاعات رو قرار دادیم:

“iss”

“sub”

“iat”

“exp”

“username”

“name”

“role”

iss صادرکننده Token است، sub شناسه‌ی کاربر رو نگه می‌داره، iat زمان ساخته شدن Token و exp زمان انقضای اون است. نام و نقش کاربر هم برای این تمرین داخل Payload قرار گرفتن. رمز عبور نباید داخل Payload قرار بگیره، چون JWT در این پروژه امضا شده اما رمزگذاری نشده و محتوای Header و Payload قابل مشاهده است.

ساختار سه‌بخشی JWT

JWT از سه قسمت تشکیل میشه:

Header.Payload.Signature

در Backend، Header و Payload جداگانه به Base64 URL تبدیل میشن، بعد با نقطه به هم می‌چسبن و Signature به انتهای اون‌ها اضافه میشه. خروجی نهایی همون رشته‌ی بلندی است که اپلیکیشن بعد از Login دریافت می‌کنه.

تفاوت Base64 URL با Base64 معمولی

JWT از نسخه‌ای از Base64 استفاده می‌کنه که برای قرار گرفتن داخل URL و Header مناسب‌تره. در تابع base64UrlEncode علامت‌های + و / با – و _ جایگزین میشن و علامت‌های = انتهای رشته حذف میشن. تابع Decode هم این تغییرات رو برعکس می‌کنه. دلیل این کار اینه که Token بدون دردسر داخل Headerهای HTTP جابه‌جا بشه.

ساخت Signature با HS256

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

hash_hmac(
"sha256",

    $signingInput,

    JWT_SECRET,

    true

)

signingInput از Header و Payload ساخته شده و با JWT_SECRET امضا میشه. اگر کسی حتی یک حرف از Payload رو تغییر بده، امضای جدید با Signature قبلی برابر نخواهد بود و سرور Token رو رد می‌کنه. این Signature مانع دیده شدن Payload نمیشه؛ فقط اجازه نمی‌ده Token بدون اطلاع سرور دست‌کاری بشه.

اعتبارسنجی JWT

در تابع verifyJwt ابتدا Token با نقطه به سه قسمت تقسیم میشه. بعد Header، Payload و Signature Decode میشن. سرور بررسی می‌کنه الگوریتم HS256 باشه، Signature رو دوباره می‌سازه، صادرکننده رو کنترل می‌کنه و در آخر زمان انقضا رو می‌سنجه. اگر هر کدوم از این مراحل نامعتبر باشه، تابع null برمی‌گردونه و دسترسی به پروفایل رد میشه.

hash_equals

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

hash_equals(

    $expectedSignature,
    $receivedSignature

)

از نظر ظاهری می‌شد از === استفاده کرد، اما hash_equals برای مقایسه‌ی داده‌های امنیتی ساخته شده و مقایسه رو به شکلی انجام می‌ده که اطلاعات کمتری از طریق زمان اجرای مقایسه لو بره. اگر دو Signature برابر نباشن، Token دست‌کاری شده یا با Secret دیگری ساخته شده.

دریافت Bearer Token در PHP

تابع getBearerToken ابتدا دنبال Header زیر می‌گرده:

Authorization: Bearer TOKEN

در PHP این Header معمولاً از $_SERVER[“HTTP_AUTHORIZATION”] خونده میشه. چون بعضی تنظیمات Apache اون رو مستقیماً در اختیار PHP قرار نمی‌دن، کد یک مسیر جایگزین هم با apache_request_headers() داره. بعد با یک عبارت منظم، کلمه‌ی Bearer جدا میشه و فقط خود Token برگردونده میشه.

ساخت پروفایل از Payload تأییدشده

بعد از اینکه JWT معتبر تشخیص داده شد، profile.php اطلاعات کاربر رو از Payload استخراج می‌کنه:

$profile = [

    "id" => (int) $payload["sub"],

    "username" => $payload["username"],

    "name" => $payload["name"],

    "role" => $payload["role"]

];

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

درس ۱۱
Session Management

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

  • این کاربر قبلاً وارد شده؟
  • هنوز Token معتبر دارد؟
  • باید صفحه اصلی را نشان بدهد یا صفحه Login؟

اینجاست که مفهوم Session Management وارد می‌شود.


Session چیست؟

Session یعنی برنامه وضعیت فعلی کاربر را به خاطر داشته باشد. مثلاً بعد از Login:

Username + Password

↓

Server

↓

JWT Token

↓

Save Token

↓

User Logged In

حالا هر بار برنامه باز می‌شود:

App Start

↓

Check Token

↓

Token موجود است؟

↓

بله → ورود به Home

خیر → نمایش Login

جریان کامل Session

ساختار کلی:

Login Screen

↓

Send Credentials

↓

Receive Token

↓

Save Token

↓

Close App

↓

Open App Again

↓

Read Token

↓

Check Session

↓

Go To Home

ذخیره وضعیت Login

بعد از دریافت Token باید آن را جایی نگه داریم. در اندروید چند روش داریم:

  • SharedPreferences
  • DataStore
  • Encrypted DataStore

فعلاً با DataStore آشنا می‌شویم چون روش جدیدتر است.


اضافه کردن DataStore

داخل Gradle:

implementation(
    "androidx.datastore:datastore-preferences:1.1.1"
)

ساخت Token Storage

یک فایل بساز TokenStorage.kt و این کد را داخلش وارد کن:

class TokenStorage(

    private val context:Context

){

    private val dataStore =
        context.createDataStore(
            name = "session"
        )


}

ایده این کلاس تمام کارهای مربوط به ذخیره Token فقط اینجا باشد. نه داخل ViewModel.


ذخیره Token

مثلاً بعد از Login موفق:

suspend fun saveToken(
    token:String
){

    dataStore.edit { preferences ->


        preferences[TOKEN_KEY]
            =
            token


    }

}

اینجا TOKEN_KEY کلیدی است که Token با آن ذخیره می‌شود.


ساخت Token Key

مثلاً:

private val TOKEN_KEY =
    stringPreferencesKey(
        "jwt_token"
    )

یعنی داخل DataStore:

jwt_token

↓

eyJhbGc...

ذخیره می‌شود.


خواندن Token

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

suspend fun getToken():

String?
{

    return dataStore.data
        .map {

            preferences ->

            preferences[TOKEN_KEY]

        }
        .first()

}

اگر Token وجود داشته باشد eyJhbGc… برمی‌گردد و اگر نباشد null


ساخت SessionManager

بهتر است تمام منطق Session یک جا باشد. مثلاً:

SessionManager.kt
class SessionManager(

    private val tokenStorage:
    TokenStorage

){


suspend fun isLoggedIn():

Boolean{


    return tokenStorage
        .getToken()
        != null


}


}

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


Logout

Logout یعنی Token را حذف کنیم. مثلاً:

suspend fun logout(){

    dataStore.edit {

        preferences ->

        preferences.remove(
            TOKEN_KEY
        )

    }

}

بعد از Logout:

Token

Delete

User Logged Out


User State چیست؟

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

data class UserState(

    val isLoggedIn:Boolean,

    val token:String?

)

این وضعیت مشخص می‌کند برنامه الان در چه شرایطی است.


ساخت AuthViewModel

معمولاً یک ViewModel مخصوص Authentication داریم. مثلاً:

class AuthViewModel(

private val sessionManager:
SessionManager

):ViewModel(){


}

کارهای آن:

  • بررسی Login بودن
  • Logout
  • مدیریت User State

App Startup Check

یکی از مهم‌ترین قسمت‌ها همین است. وقتی برنامه باز می‌شود، اول نباید مستقیم Login Screen را نشان بدهیم. باید بررسی کنیم. جریان:

App Start

↓

Auth Check

↓

Token Exists؟

↓

Yes → Home

No → Login

ساخت Splash / Startup Screen

یک State ساده:

sealed class StartDestination{


object Loading:
StartDestination()


object Login:
StartDestination()


object Home:
StartDestination()


}

بررسی وضعیت هنگام شروع

داخل ViewModel:

fun checkSession(){


viewModelScope.launch {


val loggedIn =
sessionManager
.isLoggedIn()



if(loggedIn){


_state.value =
StartDestination.Home


}

else{


_state.value =
StartDestination.Login


}



}


}

اتصال به Navigation

حالا در برنامه:

when(state){


Home -> {

navController.navigate(
"home"
)

}



Login -> {


navController.navigate(
"login"
)


}


}

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

فرض کن کاربر قبلاً وارد شده. دفعه اول:

Login

Token دریافت می‌شود

Token ذخیره می‌شود

روز بعد:

باز کردن برنامه

بررسی Token

Token وجود دارد

ورود مستقیم به صفحه قیمت‌ها

کاربر حتی صفحه Login را نمی‌بیند.


مشکل امنیتی Token

DataStore معمولی رمزنگاری کامل ندارد. برای اطلاعات حساس‌تر بهتر است از Encrypted DataStore یا Encrypted SharedPreferences استفاده کنیم. چون Token مثل کلید ورود کاربر است. اگر کسی آن را بدزدد، ممکن است بتواند خودش را جای کاربر جا بزند.


ساختار پروژه بعد از Session

حالا پروژه:

app

├── data

│   ├── auth

│   │    ├── TokenStorage.kt

│   │    └── SessionManager.kt


├── presentation

│   ├── login

│   │    └── LoginViewModel.kt

│   │
│   └── startup

│        └── AuthViewModel.kt


└── navigation

    └── AppNavigation.kt

وابستگی DataStore

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

implementation(

    "androidx.datastore:datastore-preferences:1.2.1"

)

در درس قبل Token فقط داخل ViewModel بود؛ بنابراین با بسته‌شدن کامل برنامه پاک می‌شد. DataStore اطلاعات رو داخل حافظه دائمی برنامه ذخیره می‌کنه. به همین دلیل کاربر می‌تونه امروز وارد بشه، برنامه رو ببنده و فردا دوباره بدون دیدن فرم Login وارد صفحه اصلی بشه.

ساخت DataStore با preferencesDataStore

برای ساخت فضای ذخیره‌سازی Session نوشتیم:

private val Context.sessionDataStore by

    preferencesDataStore(

        name = "session"

    )

این کد یک DataStore به نام session می‌سازه و اون رو به Context اضافه می‌کنه. از این به بعد هر جا Context داشته باشیم، می‌تونیم با context.sessionDataStore به همین فضای ذخیره‌سازی دسترسی پیدا کنیم. این DataStore فقط یک بار ساخته میشه و تمام عملیات ذخیره و خواندن Token از همان نمونه انجام میشه.

چرا DataStore را بیرون کلاس تعریف کردیم؟

این قسمت رو بیرون TokenStorage قرار دادیم:

private val Context.sessionDataStore by

    preferencesDataStore(

        name = "session"

    )

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

کلیدهای Token و زمان انقضا

DataStore اطلاعات رو به شکل کلید و مقدار نگه می‌داره. برای همین دو کلید ساختیم:

private val tokenKey =

    stringPreferencesKey(

        "jwt_token"

    )

private val expiresAtKey =

    longPreferencesKey(

        "jwt_expires_at"

    )

tokenKey مخصوص متن JWT است و به همین دلیل از stringPreferencesKey استفاده کردیم. expiresAtKey زمان انقضا رو به شکل عدد Long نگه می‌داره؛ بنابراین برای اون از longPreferencesKey استفاده کردیم. اسم‌هایی مثل jwt_token همون نام‌هایی هستن که مقدارها با اون‌ها داخل DataStore ذخیره میشن.

مدل StoredSession

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

data class StoredSession(

    val token: String,

    val expiresAt: Long

)

Session فقط وجود یک Token نیست. اگر Token منقضی شده باشه، نباید کاربر رو واردشده در نظر بگیریم. به همین دلیل Token و زمان انقضای اون رو کنار هم نگه می‌داریم و هنگام خواندن Session، هر دو مقدار رو داخل یک StoredSession برمی‌گردونیم.

ذخیره اطلاعات با edit

برای ذخیره Token و زمان انقضا نوشتیم:

context

    .sessionDataStore

    .edit { preferences ->

        preferences[tokenKey] =

            token

        preferences[expiresAtKey] =

            expiresAt

    }

بلوک edit اجازه می‌ده مقدارهای داخل DataStore رو تغییر بدیم. متغیر preferences نماینده اطلاعات فعلی ذخیره‌شده است. در اینجا Token رو با کلید tokenKey و زمان انقضا رو با کلید expiresAtKey ذخیره کردیم. این عملیات داخل تابع suspend انجام میشه، چون نوشتن روی حافظه دائمی ممکنه کمی زمان ببره و نباید Thread اصلی رو متوقف بکنه.

خواندن فقط اولین مقدار با first

اطلاعات DataStore به شکل Flow در اختیارمون قرار می‌گیره:

context

    .sessionDataStore

    .data

اما داخل تابع getSession() نمی‌خواستیم دائم تغییرات DataStore رو دنبال کنیم؛ فقط می‌خواستیم همین الان Token ذخیره‌شده رو بخونیم. برای همین نوشتیم:

val preferences =

    context

        .sessionDataStore

        .data

        .first()

first اولین مقدار موجود در Flow رو می‌گیره و بعد خواندن رو متوقف می‌کنه. در نتیجه می‌تونیم Session فعلی رو یک بار بخونیم و درباره Login بودن کاربر تصمیم بگیریم.

چرا نبودن یکی از مقدارها Session را نامعتبر می‌کند؟

بعد از خواندن اطلاعات نوشتیم:

if (

    token.isNullOrBlank() ||

    expiresAt == null

) {

    null

}

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

حذف Session با remove

هنگام خروج از حساب این دو مقدار رو از DataStore حذف کردیم:

preferences.remove(

    tokenKey

)

preferences.remove(

    expiresAtKey

)

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

تفاوت TokenStorage و SessionManager

TokenStorage فقط می‌دونه چطور اطلاعات رو ذخیره، خوانده یا حذف بکنه. اما درباره معتبر بودن Session تصمیم نمی‌گیره. این تصمیم رو داخل SessionManager گذاشتیم:

class SessionManager(

    private val tokenStorage:

        TokenStorage

)

یعنی مسئولیت‌ها رو جدا کردیم. TokenStorage با DataStore کار می‌کنه، اما SessionManager می‌گه Session معتبر هست یا نه، Token منقضی شده یا نه و هنگام Logout چه اتفاقی بیفته. این جداسازی باعث میشه منطق Session داخل ViewModel پخش نشه.

بررسی اعتبار زمانی Token

داخل getValidToken() زمان فعلی رو با زمان انقضای ذخیره‌شده مقایسه کردیم:

if (

    currentTime >= session.expiresAt

) {

    tokenStorage.clearSession()

    return null

}

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

تبدیل میلی‌ثانیه به ثانیه

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

System.currentTimeMillis

اما این مقدار برحسب میلی‌ثانیه است، در حالی که expiresAt تولیدشده توسط PHP برحسب ثانیه بود. برای هماهنگ کردن واحدها نوشتیم:

val currentTime =

    System.currentTimeMillis()

        .div(1000)

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

چرا فقط وجود Token را بررسی نکردیم؟

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

suspend fun getValidToken():

    String?

این تابع هم Token رو می‌خونه و هم تاریخ انقضاش رو بررسی می‌کنه. اگر Token وجود نداشته باشه یا منقضی شده باشه، null برمی‌گردونه. بنابراین بخش‌های دیگر برنامه لازم نیست هر بار منطق زمان انقضا رو تکرار کنن؛ فقط از getValidToken یک Token معتبر درخواست می‌کنن.

وضعیت‌های شروع برنامه

هنگام اجرای برنامه نمی‌تونیم بلافاصله Login یا Home رو نمایش بدیم، چون خواندن DataStore کمی زمان می‌بره. برای همین سه وضعیت ساختیم:

StartDestination.Loading

StartDestination.Login

StartDestination.Home

Loading یعنی برنامه هنوز در حال بررسی Session است. Login یعنی Token معتبری پیدا نشده و باید فرم ورود نمایش داده بشه. Home هم یعنی Session معتبر است و کاربر می‌تونه وارد صفحه اصلی بشه. این حالت میانی Loading جلوی نمایش لحظه‌ای و اشتباه صفحه Login رو می‌گیره.

بررسی Session هنگام شروع برنامه

داخل ViewModel و هنگام ساخته شدن اون، checkSession اجرا میشه:

init {

    checkSession()

}

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

نقش return@launch

در بعضی بخش‌ها نوشتیم:

return@launch

چون کد داخل بلوک viewModelScope.launch قرار داره، return@launch فقط اجرای همین Coroutine رو متوقف می‌کنه. مثلاً اگر Token ذخیره‌شده پیدا نشه، مقصد Login تنظیم میشه و بعد با return@launch اجازه نمی‌دیم کدهای مربوط به Home و دریافت پروفایل اجرا بشن.

ذخیره Session بلافاصله بعد از Login

بعد از ورود موفق و دریافت Token، قبل از نمایش Home این کد اجرا میشه:

sessionManager.saveLogin(

    token =

        receivedToken,

    expiresAt =

        receivedExpiresAt

)

این ترتیب مهمه. اگر فقط State صفحه رو روی Home قرار می‌دادیم ولی Token رو ذخیره نمی‌کردیم، کاربر در همان اجرای برنامه وارد می‌شد، اما بعد از بستن و باز کردن دوباره برنامه Session از بین می‌رفت. با ذخیره Token و زمان انقضا، ورود کاربر دائمی میشه تا وقتی Token منقضی یا حذف بشه.

بررسی محلی و بررسی سمت سرور

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

repository.getProfile(

    token = token

)

بررسی محلی فقط برای تصمیم سریع‌تره و امنیت نهایی رو تأمین نمی‌کنه. ممکنه Token دست‌کاری شده باشه، Secret اشتباه باشه یا سرور اون رو معتبر ندونه. به همین دلیل سرور دوباره Signature و تاریخ انقضای JWT رو بررسی می‌کنه. تصمیم نهایی درباره معتبر بودن Token همیشه با سروره.

چرا هنگام شروع، پروفایل را دوباره دریافت می‌کنیم؟

وقتی Token از DataStore خوانده میشه، فقط می‌فهمیم یک Session ذخیره‌شده وجود داره. برای مطمئن شدن از اعتبار واقعی اون، بلافاصله profile.php رو صدا می‌زنیم. اگر سرور پروفایل رو برگردونه، Session معتبره. اگر سرور پاسخ 401 بده، یعنی Token دیگه قابل قبول نیست و باید کاربر دوباره وارد بشه.

استفاده از applicationContext

برای ساخت TokenStorage نوشتیم:

TokenStorage(

    context =

        applicationContext

)

DataStore باید به Contextای وصل باشه که به عمر یک Activity محدود نباشه. applicationContext تا زمانی که خود برنامه زنده است وجود داره و با چرخش صفحه یا ساخته‌شدن دوباره Activity از بین نمیره. به همین دلیل برای ابزارهای ذخیره‌سازی دائمی مثل DataStore انتخاب مناسب‌تریه.

انتخاب Login یا Home در AppContent

رابط کاربری با توجه به مقصد فعلی ساخته میشه:

when (

    state.destination

) {

    StartDestination.Loading -> {

        StartupScreen()

    }

    StartDestination.Login -> {

        LoginScreen(...)

    }

    StartDestination.Home -> {

        HomeScreen(...)

    }

}

اینجا Navigation پیچیده‌ای نساختیم. فقط State مشخص می‌کنه کدام صفحه باید نمایش داده بشه. وقتی Session بررسی میشه یا کاربر Login و Logout می‌کنه، مقدار destination تغییر می‌کنه و Compose صفحه مناسب رو دوباره می‌سازه.

چرا DataStore معمولی امنیت کامل ایجاد نمی‌کند؟

Preferences DataStore اطلاعات رو دائمی و منظم ذخیره می‌کنه، اما خودش به معنی رمزنگاری کامل Token نیست. در این درس هدف یادگیری Session Management و حفظ Login بود. در پروژه واقعی، JWT مثل کلید ورود کاربره و بهتره با یک روش امن‌تر نگهداری بشه. بنابراین DataStore معمولی برای تمرین مناسبه، اما برای Token حساس باید ذخیره‌سازی امن و متناسب با طراحی امنیتی پروژه در نظر گرفته بشه.

درس ۱۲
ذخیره Token داخل گوشی

در درس قبل یاد گرفتیم بعد از Login، سرور یک JWT Token به ما می‌دهد و ما باید آن را نگه داریم تا کاربر مجبور نباشد هر بار دوباره Login کند. اما یک سؤال مهم وجود دارد: Token را کجا ذخیره کنیم؟

اگر Token فقط داخل یک متغیر معمولی باشد:

var token = "abc123"

با بسته شدن برنامه از بین می‌رود. پس باید آن را داخل حافظه دائمی گوشی ذخیره کنیم. در این درس با دو روش آشنا می‌شویم:

  • SharedPreferences
  • DataStore

و یاد می‌گیریم:

  • Token را ذخیره کنیم
  • اطلاعات کاربر را نگه داریم
  • تنظیمات برنامه را ذخیره کنیم
  • هنگام باز شدن برنامه اطلاعات را بازیابی کنیم

SharedPreferences چیست؟

SharedPreferences یک حافظه ساده داخلی اندروید است. برای ذخیره داده‌های کوچک استفاده می‌شود. مثلاً:

  • Token
  • تنظیمات برنامه
  • آخرین انتخاب کاربر

ساختارش شبیه:

Key

Value

مثلاً:

jwt_token

eyJhbGc…


ساخت SharedPreferences

مثلاً:

val prefs =
    context.getSharedPreferences(
        "app_settings",
        Context.MODE_PRIVATE
    )

اینجا app_settings اسم فایل ذخیره‌سازی ماست.


ذخیره Token با SharedPreferences

fun saveToken(
    token:String
){

    prefs.edit()
        .putString(
            "TOKEN",
            token
        )
        .apply()

}

اینجا putString یعنی یک مقدار متنی ذخیره کن. نتیجه:

TOKEN

↓

abc123xyz

خواندن Token

fun getToken():String?{


    return prefs.getString(

        "TOKEN",

        null

    )


}

اگر Token وجود داشته باشد abc123xyz برمی‌گردد و اگر نباشد null


حذف Token هنگام Logout

fun clearToken(){

    prefs.edit()

        .remove(
            "TOKEN"
        )

        .apply()

}

بعد از Logout:

TOKEN

Delete


مشکل SharedPreferences چیست؟

SharedPreferences ساده است، اما محدودیت‌هایی دارد:

  • API قدیمی‌تر است
  • Flow ندارد
  • برای داده‌های حساس طراحی نشده
  • تغییرات همزمان را خوب مدیریت نمی‌کند

برای پروژه‌های جدید معمولاً DataStore پیشنهاد می‌شود.


DataStore چیست؟

DataStore نسخه جدیدتر ذخیره‌سازی تنظیمات در اندروید است. مزیت‌ها:

  • مدرن‌تر است.
  • با Coroutine کار می‌کند.
  • با Flow هماهنگ است.
  • مدیریت خطا بهتر است.

اضافه کردن DataStore

در Gradle:

implementation(
    "androidx.datastore:datastore-preferences:1.1.1"
)

ساخت DataStore

یک فایل DataStoreManager.kt می‌سازیم. ابتدا:

private val Context.dataStore by preferencesDataStore(
    name = "user_settings"
)

حالا برنامه یک DataStore دارد:

user_settings

data


تعریف Key برای Token

در DataStore باید کلید تعریف کنیم.

val TOKEN_KEY =
    stringPreferencesKey(
        "access_token"
    )

یعنی اسم ذخیره‌سازی access_token باشد.


ذخیره Token با DataStore

suspend fun saveToken(
    token:String
){

    context.dataStore.edit {

        preferences ->


        preferences[TOKEN_KEY]
            =
            token

    }

}

اینجا edit یعنی اطلاعات ذخیره‌شده را تغییر بده.


خواندن Token با DataStore

val tokenFlow =
context.dataStore.data.map {

    preferences ->


    preferences[TOKEN_KEY]

}

خروجی مورد زیر است. یعنی هر وقت Token تغییر کند، مقدار جدید ارسال می‌شود.

Flow<String?>


تفاوت SharedPreferences و DataStore

خیلی خلاصه:

SharedPreferencesDataStore
قدیمی‌ترجدیدتر
سادهحرفه‌ای‌تر
بدون Flowهمراه Flow
مناسب پروژه‌های کوچکمناسب پروژه‌های جدید

برای پروژه‌های Compose + MVVM معمولاً DataStore انتخاب بهتری است.


ذخیره اطلاعات کاربر

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

{
 "id":15,
 "name":"Ali",
 "email":"ali@test.com"
}

ساخت UserPreferences

مثلاً:

data class UserPreferences(

    val token:String?,

    val userId:Int?,

    val name:String?

)

حالا وضعیت کاربر را یکجا داریم.


ذخیره User Info

مثلاً:

suspend fun saveUser(

    id:Int,

    name:String

){

    context.dataStore.edit {

        it[USER_ID] = id

        it[USER_NAME] = name

    }

}

ذخیره تنظیمات برنامه

DataStore فقط برای Login نیست. مثلاً، تنظیمات:

Dark Mode

Language

Notification

را هم می‌توانیم ذخیره کنیم. مثلاً:

val DARK_MODE_KEY =
booleanPreferencesKey(
    "dark_mode"
)

ذخیره:

preferences[DARK_MODE_KEY]
=
true

بازیابی هنگام باز شدن برنامه

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

App Start

↓

Read DataStore

↓

Token موجود است؟

↓

Yes → Home

No → Login

اتصال به ViewModel

مثلاً:

class AuthViewModel(

private val storage:
TokenStorage

):ViewModel()

در شروع:

fun checkLogin(){

viewModelScope.launch {


val token =
storage.getToken()


if(token != null){

    // User Logged In

}
else{

    // Show Login

}


}

}

استفاده از Token در API

بعد از اینکه Token ذخیره شد، درخواست‌های بعدی باید آن را ارسال کنند. مثلاً:

Authorization: Bearer TOKEN

افزودن Token به Retrofit

معمولاً این کار با Interceptor انجام می‌شود. مثلاً:

class AuthInterceptor(

private val storage:TokenStorage

):Interceptor{


override fun intercept(

chain:Interceptor.Chain

):Response{


val token =
runBlocking {

storage.getToken()

}



val request =
chain.request()
.newBuilder()
.addHeader(

"Authorization",

"Bearer $token"

)
.build()



return chain.proceed(request)


}


}

حالا هر درخواست:

API Request

Token اضافه می‌شود

Server


نکته امنیتی مهم

JWT Token مثل رمز عبور نیست، اما ارزش زیادی دارد. چون هر کسی که Token را داشته باشد ممکن است بتواند خودش را جای کاربر معرفی کند. پس برای اطلاعات حساس بهتر است از Encrypted DataStore استفاده کنیم. همچنین:

  • Token را داخل Log چاپ نکنیم
  • داخل فایل معمولی ذخیره نکنیم
  • داخل کد Hardcode نکنیم

ساختار پروژه بعد از این درس

app

├── data

│   ├── storage

│   │    ├── TokenStorage.kt

│   │    └── UserPreferences.kt

│   │
│   └── network

│        └── AuthInterceptor.kt


├── domain

│   └── SessionManager.kt


└── presentation

    └── AuthViewModel.kt

بعد از اینکه نام کاربری ali و رمز 1234 رو وارد می‌کنی و روی «ورود» می‌زنی، باید این اتفاق‌ها پشت‌سرهم بیفته:

LoginScreen

ارسال username و password به login.php

دریافت JWT از سرور

ذخیره Token و زمان انقضا در DataStore

دریافت profile.php با Token

ذخیره اطلاعات کاربر در DataStore

نمایش HomeScreen

یعنی بعد از ورود موفق، فرم ورود باید کامل کنار بره و صفحه‌ای با عنوان «ذخیره اطلاعات داخل گوشی» نمایش داده بشه. داخل صفحه اصلی باید اطلاعات ذخیره‌شده‌ی کاربر مثل نام، نام کاربری، شناسه، نقش و بخشی از Token دیده بشه. همچنین Switch حالت تاریک، دکمه دریافت دوباره پروفایل، بخش آزمایش SharedPreferences و دکمه خروج هم وجود داره.

مهم‌ترین آزمایش درس اینه که بعد از ورود موفق، برنامه رو کاملاً ببندی و دوباره باز کنی. این بار نباید فرم Login رو ببینی. برنامه باید Token رو از DataStore بخونه، اعتبار زمانی اون رو بررسی کنه، با Interceptor برای profile.php بفرسته و مستقیم وارد Home بشه:

باز شدن دوباره برنامه

خواندن DataStore

Token معتبر است

ورود مستقیم به HomeScreen

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

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

ساخت SharedPreferences با getSharedPreferences

برای دیدن روش قدیمی ذخیره اطلاعات، این کد رو نوشتیم:

context.getSharedPreferences(

    "shared_preferences_demo",

    Context.MODE_PRIVATE

)

عبارت shared_preferences_demo اسم فایل ذخیره‌سازی ماست. MODE_PRIVATE هم یعنی فقط خود همین اپلیکیشن اجازه دسترسی مستقیم به این فایل رو داره. SharedPreferences برای اطلاعات کوچک مثل تنظیمات ساده مناسبه، ولی در این پروژه فقط برای مقایسه آموزشی ازش استفاده کردیم و JWT اصلی رو داخل اون نگذاشتیم.

ذخیره مقدار با putString و apply

برای ذخیره مقدار در SharedPreferences نوشتیم:

preferences

    .edit()

    .putString(

        "SAMPLE_TOKEN",

        "sample_token_123456"

    )

    .apply()

اول با edit وارد حالت ویرایش می‌شیم، بعد با putString یک مقدار متنی رو با یک کلید مشخص ذخیره می‌کنیم. apply تغییر رو ثبت می‌کنه، اما منتظر کامل شدن نوشتن روی حافظه نمی‌مونه؛ بنابراین رابط کاربری رو متوقف نمی‌کنه.

خواندن و حذف اطلاعات SharedPreferences

برای خواندن مقدار نوشتیم:

preferences.getString(

    "SAMPLE_TOKEN",

    null

)

اگر مقدار ذخیره شده باشه، همون مقدار برمی‌گرده و اگر وجود نداشته باشه، مقدار پیش‌فرض یعنی null دریافت میشه. برای حذفش هم از remove استفاده کردیم. این مثال عمداً جدا از Session اصلی قرار گرفت تا تفاوت SharedPreferences و DataStore رو در عمل ببینیم.

ساخت Preferences DataStore در سطح فایل

DataStore رو این‌طوری ساختیم:

private val Context.userDataStore by

    preferencesDataStore(

        name = "user_settings"

    )

این یک قابلیت جدید به Context اضافه می‌کنه که با context.userDataStore قابل دسترسیه. اون رو بیرون کلاس و در سطح فایل تعریف کردیم تا برای فایل user_settings فقط یک نمونه DataStore ساخته بشه. ساخت چند DataStore برای یک فایل می‌تونه باعث تداخل و خطا بشه.

مدل UserPreferences

به‌جای اینکه Token، اطلاعات کاربر و تنظیمات رو جداگانه در تمام برنامه جابه‌جا کنیم، یک مدل کامل ساختیم:

data class UserPreferences(

    val token: String? = null,

    val expiresAt: Long? = null,

    val userId: Int? = null,

    val username: String? = null,

    val name: String? = null,

    val role: String? = null,

    val darkMode: Boolean = false

)

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

کلیدهای نوع‌دار DataStore

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

stringPreferencesKey("access_token")

longPreferencesKey("expires_at")

intPreferencesKey("user_id")

booleanPreferencesKey("dark_mode")

DataStore باید بدونه هر کلید چه نوع مقداری نگه می‌داره. مثلاً Token متن است، شناسه کاربر Int است و تنظیم حالت تاریک Boolean. این کلیدها نقش آدرس هر مقدار داخل فایل DataStore رو دارن.

preferencesFlow

اطلاعات DataStore رو فقط یک بار نمی‌خونیم؛ یک Flow از تغییرات اون می‌سازیم:

val preferencesFlow: Flow<UserPreferences>

هر وقت Token، اطلاعات کاربر یا حالت تاریک تغییر کنه، DataStore مقدار جدیدی منتشر می‌کنه. در نتیجه ViewModel می‌تونه تغییرات رو مشاهده کنه و UI هم بدون خواندن دستی دوباره به‌روز بشه.

تبدیل Preferences خام با map

خروجی اصلی DataStore یک مجموعه از کلید و مقدارهاست. برای تبدیل اون به UserPreferences از map استفاده کردیم:

.map { preferences ->

    UserPreferences(

        token = preferences[tokenKey],

        userId = preferences[userIdKey],

        darkMode = preferences[darkModeKey] ?: false

    )

}

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

مدیریت خطای خواندن DataStore با catch

هنگام خواندن DataStore ممکنه خطای مربوط به فایل یا حافظه رخ بده. برای همین نوشتیم:

.catch { exception ->

    if (exception is IOException) {

        emit(

            emptyPreferences()

        )

    } else {

        throw exception

    }

}

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

emptyPreferences

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

emptyPreferences()

در نتیجه اگر فایل DataStore قابل خواندن نباشه، برنامه طوری رفتار می‌کنه که انگار هنوز هیچ Token یا تنظیماتی ذخیره نشده. این کار جلوی Crash شدن برنامه در یک خطای ساده ذخیره‌سازی رو می‌گیره.

مشاهده دائمی تغییرات با collect

در ViewModel نوشتیم:

sessionManager

    .preferencesFlow

    .collect { preferences ->

        _state.value =

            _state.value.copy(

                preferences = preferences

            )

    }

این Coroutine تا وقتی ViewModel فعاله، تغییرات DataStore رو دنبال می‌کنه. مثلاً وقتی کاربر Dark Mode رو روشن می‌کنه یا اطلاعات پروفایل ذخیره میشه، مقدار جدید به ViewModel می‌رسه و UI دوباره ساخته میشه. این تفاوت مهم DataStore با روش ساده SharedPreferences است.

ذخیره Token و پروفایل در دو مرحله

بعد از Login ابتدا فقط این اطلاعات ذخیره میشن:

Token

expiresAt

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

چرا هنگام Logout حالت تاریک حذف نمی‌شود؟

در clearSession فقط اطلاعات مربوط به کاربر رو حذف کردیم:

Token

expiresAt

userId

username

name

role

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

اضافه شدن خودکار Token با Interceptor

در درس قبل Token رو هنگام صدا زدن API به‌صورت دستی می‌فرستادیم. این بار یک AuthInterceptor ساختیم تا Token رو خودکار به درخواست‌ها اضافه بکنه:

class AuthInterceptor(

    private val storage: DataStoreManager

) : Interceptor

Interceptor قبل از ارسال هر Request اجرا میشه. Token رو از DataStore می‌خونه، Header مربوط به Authorization رو اضافه می‌کنه و بعد Request رو به سمت سرور می‌فرسته. بنابراین Repository دیگه لازم نیست در هر تابع Token رو دستی دریافت و ارسال بکنه.

چرا درخواست Login نباید Token بگیرد؟

داخل Interceptor مسیر درخواست رو بررسی کردیم:

val isLoginRequest =

    originalRequest

        .url

        .encodedPath

        .endsWith(

            "/login.php"

        )

برای گرفتن Token هنوز وارد حساب نشدیم، پس منطقی نیست درخواست Login خودش Header مربوط به Token داشته باشه. به همین دلیل اگر مسیر login.php باشه، Interceptor Token رو به Request اضافه نمی‌کنه.

خواندن Token در Interceptor با runBlocking

تابع intercept یک تابع معمولی و غیر suspend است، اما خواندن DataStore یک عملیات Suspend است. برای اتصال این دو از:

runBlocking {

    storage.getToken()

}

استفاده کردیم. runBlocking تا زمان دریافت Token اجرای همین Thread شبکه رو متوقف می‌کنه. این روش برای فهم جریان آموزشی ساده است، ولی نباید داخل Thread اصلی UI استفاده بشه. اینجا Interceptor روی Thread شبکه OkHttp اجرا میشه.

ساخت Request جدید با newBuilder

Requestهای OkHttp بعد از ساخته شدن مستقیماً قابل تغییر نیستن. برای اضافه کردن Header نوشتیم:

val requestBuilder =

    originalRequest

        .newBuilder()

این Builder یک نسخه قابل ویرایش از Request قبلی ایجاد می‌کنه. بعد Header رو روی اون قرار می‌دیم و در پایان Request جدید رو با build می‌سازیم.

اضافه کردن Header با header

Token با این کد به Request اضافه میشه:

requestBuilder.header(

    "Authorization",

    "Bearer $token"

)

از header استفاده کردیم تا مقدار Header با همین نام تنظیم یا جایگزین بشه. در نتیجه اگر قبلاً Header دیگری با نام Authorization وجود داشته باشه، چند مقدار تکراری ساخته نمیشه.

ادامه درخواست با chain.proceed

بعد از آماده شدن Request جدید باید اجازه بدیم درخواست واقعاً ارسال بشه:

return chain.proceed(

    requestBuilder.build()

)

اگر chain.proceed رو صدا نزنیم، جریان شبکه همون‌جا متوقف میشه و هیچ Requestای به سرور نمی‌رسه. Interceptor فقط درخواست رو آماده می‌کنه؛ ارسال واقعی با proceed ادامه پیدا می‌کنه.

حذف @Header از تابع پروفایل

چون Interceptor خودش Token رو اضافه می‌کنه، تابع API ساده‌تر شد:

@GET("profile.php")

suspend fun getProfile():

    Response<ProfileResponse>

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

@Header("Authorization")

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

مخفی کردن Authorization در Logcat

چون Logging روی سطح BODY فعاله، ممکنه Headerها هم داخل Logcat دیده بشن. برای جلوگیری از چاپ شدن JWT نوشتیم:

redactHeader(

    "Authorization"

)

این کار مقدار Header مربوط به Token رو در Log مخفی می‌کنه. خود Request همچنان Token رو برای سرور می‌فرسته، اما مقدار واقعی اون داخل Logcat نمایش داده نمیشه.

اهمیت ترتیب Interceptorها

ابتدا AuthInterceptor و بعد Logging Interceptor رو اضافه کردیم:

.addInterceptor(

    authInterceptor

)

.addInterceptor(

    loggingInterceptor

)

Interceptor احراز هویت اول Token رو روی Request قرار می‌ده و Logging بعدش درخواست نهایی رو مشاهده می‌کنه. چون Authorization رو Redact کردیم، Logging می‌فهمه Header وجود داره اما مقدار حساس اون رو نمایش نمی‌ده.

کلاس AppContainer

برای اینکه ساخت DataStore، SessionManager، Interceptor، Retrofit و Repository داخل MainActivity پخش نشه، یک کلاس ساده ساختیم:

class AppContainer(

    context: Context

)

این کلاس Objectهای اصلی برنامه رو یک بار می‌سازه و به هم وصل می‌کنه. مثلاً همان DataStoreManager به SessionManager و AuthInterceptor داده میشه. بنابراین تمام بخش‌ها به یک فضای ذخیره‌سازی مشترک وصل هستن.

چرا از applicationContext استفاده کردیم؟

داخل Container نوشتیم:

context.applicationContext

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

اضافه کردن httpCode به مدل پاسخ

در مدل‌های پاسخ یک فیلد داخلی اضافه کردیم:

val httpCode: Int? = null

این فیلد مستقیماً از JSON سرور نمیاد. Repository بعد از دریافت Response، کد HTTP رو با copy داخل مدل قرار می‌ده. به این ترتیب ViewModel می‌تونه بفهمه خطا مثلاً 401 بوده و براساس اون Session رو پاک بکنه.

پاک کردن Session هنگام پاسخ 401

اگر درخواست پروفایل پاسخ 401 بگیره، یعنی Token دیگه معتبر نیست:

if (

    response.httpCode == 401

) {

    sessionManager.logout()

}

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

ذخیره Dark Mode در DataStore

برای اینکه DataStore فقط محدود به Token نباشه، حالت تاریک رو هم ذخیره کردیم:

preferences[darkModeKey] =

    enabled

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

تغییر Theme با مقدار ذخیره‌شده

در ریشه UI براساس مقدار DataStore تصمیم می‌گیریم کدوم Color Scheme استفاده بشه:

if (

    state.preferences.darkMode

) {

    darkColorScheme()

} else {

    lightColorScheme()

}

چون ViewModel تغییرات DataStore رو مشاهده می‌کنه، تغییر Switch باعث به‌روزرسانی State و Recomposition میشه و Theme برنامه همون لحظه عوض میشه.

تفاوت ذخیره Session و ذخیره امن Token

با وجود عنوان درس، باید دقیق بدونیم که Preferences DataStore به‌تنهایی Token رو رمزنگاری نمی‌کنه. در این پروژه Token رو دائمی، مرتب و قابل مشاهده با Flow ذخیره کردیم و از چاپ شدنش داخل Log جلوگیری کردیم؛ اما فایل ذخیره‌سازی خودش رمزنگاری‌شده نیست. برای یک پروژه حساس، باید رمزنگاری واقعی با Android Keystore یا راهکار امنیتی مناسب پروژه اضافه بشه.

درس ۱۳
مدیریت خطا در اپلیکیشن

تا اینجا ما یک مسیر کامل ساختیم:

Compose

ViewModel

Repository

Retrofit

API Server

اما در دنیای واقعی همیشه همه چیز موفق نیست. گاهی:

  • اینترنت کاربر قطع است
  • سرور خاموش است
  • Token کاربر منقضی شده
  • درخواست خیلی طول می‌کشد
  • سرور یک خطای داخلی می‌دهد

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


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

فرض کن داخل Repository:

override suspend fun getUsers()
: List<User>{

    return api.getUsers()

}

داریم. اگر اینترنت قطع شود چه اتفاقی می‌افتد؟ Retrofit یک Exception پرتاب می‌کند. مثلاً:

java.io.IOException

اگر آن را مدیریت نکنیم App Crash اتفاق می‌افتد.


انواع خطا در ارتباط با API

در برنامه‌های واقعی چند دسته خطا داریم.


۱. خطاهای HTTP Server

این‌ها پاسخ‌هایی هستند که خود سرور برمی‌گرداند.

مثلاً:

200

موفق:
Request موفق بود


401 Unauthorized

یعنی کاربر اجازه دسترسی ندارد.

معمولاً:

  • Token اشتباه است.
  • Token منقضی شده.

مثلاً:

{
 "message":"Token expired"
}

404 Not Found

یعنی مسیر API پیدا نشد. مثلاً users.php وجود ندارد.


500 Server Error

یعنی مشکل از خود سرور است. مثلاً:

  • خطای دیتابیس
  • Exception در PHP

۲. IOException

این خطا مربوط به ارتباط شبکه است. مثلاً کاربر اینترنت ندارد.

Android

X

Server

در اینجاRetrofit خطای IOException می‌دهد.


۳. Timeout

فرض کن درخواست ارسال شده اما سرور خیلی دیر جواب می‌دهد. مثلاً:

Request
↓
Waiting...
↓
20 seconds
↓
Timeout

در این حالت باید به کاربر بگوییم اتصال به سرور طول کشید.


استفاده از Try Catch

ساده‌ترین روش مدیریت خطا:

try {

    val users =
        api.getUsers()

}

catch(e:Exception){


}

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


مشکل Try Catch ساده

فرض کن:

catch(e:Exception){

    showError()

}

این کافی نیست. چون همه خطاها یکی نیستند. مثلاً این‌ها متفاوت هستند:

Internet قطع شده
Server خراب است
Token منقضی شده

پس بهتر است خطاها را دسته‌بندی کنیم.


ساخت Result اختصاصی

یک مدل استاندارد می‌سازیم:

sealed class Result<out T>{


    data class Success<T>(

        val data:T

    ):Result<T>



    data class Error(

        val message:String

    ):Result<Nothing>


    object Loading:

    Result<Nothing>()

}

حالا سه وضعیت داریم:

Loading
درخواست در حال اجراست

Success
موفق

Success(
 users
)

Error:
خطا

Error(
 "Internet Error"
)

استفاده در Repository

قبلاً:

suspend fun getUsers():

List<User>

داشتیم. حالا:

suspend fun getUsers():

Result<List<User>>

پیاده‌سازی Repository

override suspend fun getUsers():

Result<List<User>>{


return try {


    val response =
    api.getUsers()



    Result.Success(
        response
    )


}


catch(e:IOException){


    Result.Error(
        "Internet Error"
    )


}


catch(e:Exception){


    Result.Error(
        "Unknown Error"
    )


}


}

بررسی HTTP Error

Retrofit برای خطاهای HTTP یک Exception به نام:

HttpException

می‌دهد. مثلاً:

catch(e:HttpException){


    when(e.code()){


        401 -> {

        }


        500 -> {

        }


    }

}

ساخت Error Handler

برای اینکه Repositoryها تکراری نشوند، بهتر است یک کلاس جدا بسازیم. مثلاً:

NetworkErrorHandler.kt

کد:

class NetworkErrorHandler{


fun handle(
e:Exception
):String{


return when(e){


is IOException ->

"Internet connection failed"



is HttpException ->

when(e.code()){


401 ->
"Please Login Again"


500 ->
"Server Error"


else ->
"Unknown Server Error"


}



else ->
"Something went wrong"



}


}

}

حالا همه Repositoryها از همین استفاده می‌کنند.


مدیریت خطا در ViewModel

ViewModel فقط State را تغییر می‌دهد. مثلاً:

fun loadUsers(){


viewModelScope.launch {


_state.value =
UiState.Loading



when(
val result =
repository.getUsers()

){


is Result.Success -> {


_state.value =
UiState.Success(
result.data
)


}



is Result.Error -> {


_state.value =
UiState.Error(
result.message
)


}


}


}

}

ساخت UI State

مثلاً:

sealed class UiState{


object Loading:
UiState()



data class Success(

val users:List<User>

):UiState()



data class Error(

val message:String

):UiState()


}

نمایش خطا در Compose

حالا UI:

when(state){


is UiState.Loading -> {


CircularProgressIndicator()


}



is UiState.Success -> {


UserList(
state.users
)


}



is UiState.Error -> {


Text(
state.message
)


}



}

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

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

GET /prices

حالت اول، سرور جواب می‌دهد:

{
"price":60000
}

نمایش قیمت.


حالت دوم اینترنت قطع است. نتیجه IOException نمایش اتصال اینترنت برقرار نیست.

حالت سوم Token منقضی شده 401 نمایش لطفاً دوباره وارد شوید.

حالت چهارم سرور مشکل دارد 500 نمایش مشکلی در سرور رخ داده است.


ساختار پروژه بعد از این درس

data

 ├── network

 │    ├── ApiService

 │    └── ErrorHandler


 └── repository

      └── UserRepository



domain

 └── Result.kt



presentation

 └── UiState.kt

شبیه‌سازی چند وضعیت با پارامتر mode

برای اینکه مجبور نباشیم برای هر نوع خطا یک فایل PHP جدا بسازیم، یک پارامتر به نام mode برای API تعریف کردیم:

$mode =

    isset($_GET["mode"])

    ? $_GET["mode"]

    : "success";

مثلاً اگر آدرس این باشه:

error_demo.php?mode=server_error

مقدار mode برابر server_error میشه. اگر هیچ مقداری ارسال نشده باشه، حالت پیش‌فرض success در نظر گرفته میشه. دلیل استفاده از این روش اینه که بتونیم با یک Endpoint، پاسخ موفق، خطای ۴۰۱، خطای ۴۰۴، خطای ۵۰۰ و Timeout رو جداگانه آزمایش کنیم.

استفاده از switch برای ساخت پاسخ‌های مختلف

در فایل PHP از switch استفاده کردیم:

switch ($mode) {

    case "success":

        // پاسخ 200

        break;

    case "unauthorized":

        // پاسخ 401

        break;

    case "server_error":

        // پاسخ 500

        break;

}

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

ایجاد Timeout واقعی با sleep

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

sleep(

    8

);

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

هدف از sleep این نیست که واقعاً سرور رو کند کنیم؛ فقط می‌خوایم ببینیم برنامه در برابر یک سرور کند چه رفتاری داره.

تعریف سناریوها با enum class

برای انواع آزمایش‌ها این Enum رو ساختیم:

enum class ErrorScenario(

    val apiValue: String?

) {

    Success("success"),

    Unauthorized("unauthorized"),

    NotFound("not_found"),

    ServerError("server_error"),

    Timeout("timeout"),

    NetworkError(null)

}

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

ویژگی apiValue هم مقداریه که باید برای PHP فرستاده بشه. مثلاً:

ErrorScenario.ServerError.apiValue

برابر اینه:

server_error

چرا apiValue می‌تواند null باشد؟

برای حالت قطع اینترنت نوشتیم:

NetworkError(

    apiValue = null

)

چون این حالت قرار نیست به سرور فرستاده بشه. خطای قطع اینترنت قبل از اجرای Request داخل خود Repository شبیه‌سازی میشه. بنابراین برای این سناریو اصلاً مقدار mode نداریم و apiValue می‌تونه null باشه.

ارسال Query Parameter با @Query

داخل Retrofit نوشتیم:

@GET("error_demo.php")

suspend fun getUsers(

    @Query("mode")

    mode: String

): UsersResponse

@Query(“mode”) به Retrofit میگه مقدار پارامتر mode رو به انتهای URL اضافه کن. مثلاً وقتی مقدار تابع server_error باشه، Retrofit این آدرس رو می‌سازه:

error_demo.php?mode=server_error

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

مدل استاندارد نتیجه با AppResult

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

sealed class AppResult<out T> {

    data class Success<T>(

        val data: T

    ) : AppResult<T>()

    data class Error(

        val message: String

    ) : AppResult<Nothing>()

    object Loading :

        AppResult<Nothing>()

}

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

Loading

Success

Error

این ساختار باعث میشه ViewModel و UI دقیقاً بدونن در هر لحظه باید چه چیزی رو نمایش بدن.

معنی <out T>

در تعریف AppResult نوشتیم:

AppResult<out T>

T نوع داده موفقیته. مثلاً در این پروژه نوع نهایی اینه:

AppResult<List<User>>

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

چرا Error و Loading از Nothing استفاده می‌کنند؟

حالت خطا هیچ داده موفقی نداره:

AppResult.Error(

    message = "خطا"

)

حالت Loading هم هنوز داده‌ای دریافت نکرده. برای همین هر دو از این نوع استفاده می‌کنن:

AppResult<Nothing>

Nothing یعنی در این حالت اصلاً مقداری از نوع داده اصلی وجود نداره. با این حال، Error و Loading همچنان می‌تونن داخل Stateای مثل این قرار بگیرن:

AppResult<List<User>>

چرا نام کلاس را AppResult گذاشتیم؟

خود Kotlin کلاسی به نام Result داره. اگر کلاس خودمون رو هم Result می‌نامیدیم، هنگام Import و خواندن کد ممکن بود مشخص نباشه منظور کدام کلاس است.

برای جلوگیری از این تداخل نام، از:

AppResult

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

چرا Repository دیگر Exception را بیرون نمی‌فرستد؟

خروجی Repository این شکلیه:

suspend fun getUsers(

    scenario: ErrorScenario

): AppResult<List<User>>

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

AppResult.Success(…)

یا:

AppResult.Error(…)

در نتیجه ViewModel لازم نیست با IOException، HttpException یا SocketTimeoutException درگیر بشه. ViewModel فقط نتیجه استاندارد Repository رو داخل State قرار می‌ده.

شبیه‌سازی IOException

برای دیدن مسیر خطای قطع اینترنت نوشتیم:

if (

    scenario ==

    ErrorScenario.NetworkError

) {

    throw IOException(

        "Simulated network error"

    )

}

این کد عمداً یک خطای شبکه ایجاد می‌کنه. بعد catch اون رو می‌گیره و به NetworkErrorHandler می‌فرسته.

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

Error Handler مشترک

به‌جای اینکه داخل هر Repository برای خطاها چندین catch و when تکراری بنویسیم، یک کلاس جدا ساختیم:

class NetworkErrorHandler

وظیفه این کلاس اینه که Exception فنی رو دریافت بکنه و اون رو به یک پیام قابل‌فهم برای کاربر تبدیل بکنه:

SocketTimeoutException

پاسخ سرور بیش از حد طول کشید
یا:
UnknownHostException

آدرس سرور پیدا نشد یا اینترنت قطع است

به این ترتیب منطق تشخیص خطا فقط یک جا نوشته میشه و تمام Repositoryها می‌تونن از همون استفاده کنن.

تفاوت خطاهای شبکه

در NetworkErrorHandler چند نوع خطای شبکه رو جدا بررسی کردیم:

SocketTimeoutException

UnknownHostException

ConnectException

IOException

SocketTimeoutException یعنی سرور در زمان تعیین‌شده پاسخ نداده. UnknownHostException معمولاً یعنی دامنه پیدا نشده، DNS مشکل داره یا اینترنت قطع شده. ConnectException یعنی آدرس پیدا شده، اما اتصال به سرور برقرار نشده. IOException هم دسته عمومی‌تری از خطاهای ارتباطی و خواندن و نوشتن شبکه است.

جدا کردن این موارد باعث میشه به‌جای یک پیام مبهم، توضیح دقیق‌تری به کاربر نمایش بدیم.

چرا خطاهای خاص قبل از IOException بررسی شدند؟

در when ابتدا این موارد قرار گرفتن:

is SocketTimeoutException

is UnknownHostException

is ConnectException

و بعد:

is IOException

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

HttpException چیست؟

وقتی ارتباط با سرور برقرار میشه، اما سرور یک کد ناموفق مثل 401، 404 یا 500 برمی‌گردونه، Retrofit می‌تونه یک HttpException ایجاد بکنه:

is HttpException -> {

    handleHttpError(

        code = exception.code()

    )

}

این خطا با قطع اینترنت فرق داره. در HttpException درخواست واقعاً به سرور رسیده و سرور هم پاسخ داده، اما کد پاسخ موفق نبوده. مثلاً 404 یعنی ارتباط شبکه برقرار بوده، ولی مسیر API پیدا نشده.

چرا Retrofit در این پروژه HttpException ایجاد می‌کند؟

خروجی تابع API رو مستقیم از نوع زیر تعریف کردیم:

UsersResponse

نه:

Response<UsersResponse>

در این حالت اگر کد HTTP موفق نباشه، Retrofit به‌جای تحویل مستقیم پاسخ، HttpException پرتاب می‌کنه. به همین دلیل کدهای 401، 404 و 500 داخل NetworkErrorHandler قابل تشخیص هستن. اگر خروجی تابع از نوع Response<UsersResponse> بود، باید خودمون response.isSuccessful و response.code رو بررسی می‌کردیم.

جدا کردن مدیریت کدهای HTTP

داخل Error Handler یک تابع جدا ساختیم:

private fun handleHttpError(

    code: Int

): String

این تابع فقط مسئول تبدیل کدهای HTTP به پیام مناسب است. مثلاً:

401 ->

“نشست شما معتبر نیست؛ دوباره وارد شوید”

404 ->

“مسیر API پیدا نشد”

500 ->

“یک مشکل داخلی در سرور رخ داده است”

این جداسازی باعث میشه تابع اصلی handle بیش از حد شلوغ نشه و کدهای HTTP همگی در یک قسمت مشخص قرار بگیرن.

تفاوت connectTimeout و readTimeout

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

.connectTimeout(…)

.readTimeout(…)

connectTimeout مدت زمانی است که برنامه برای برقرار شدن اتصال اولیه با سرور صبر می‌کنه. اما readTimeout مدت زمانی است که بعد از اتصال، برای دریافت پاسخ از سرور منتظر می‌مونه. در سناریوی sleep(8) اتصال برقرار شده، ولی سرور پاسخ رو دیر می‌فرسته. بنابراین چیزی که باعث خطا میشه readTimeout سه‌ثانیه‌ایه، نه connectTimeout.

چرا Timeout روی سه ثانیه قرار گرفت؟

در پروژه‌های واقعی معمولاً Timeout رو متناسب با شرایط سرور تنظیم می‌کنیم. اما در این تمرین عمداً نوشتیم:

.readTimeout(

    3,

    TimeUnit.SECONDS

)

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

نقش Loading در ViewModel

قبل از اجرای Repository نوشتیم:

_state.value =

    AppResult.Loading

بعد نتیجه Repository رو مستقیماً داخل State قرار دادیم:

_state.value =

    repository.getUsers(

        scenario = scenario

    )

این ترتیب باعث میشه به محض کلیک روی هر دکمه، UI وارد حالت Loading بشه. بعد از پایان درخواست، State یا Success میشه یا Error. بنابراین Loading یک Exception یا نتیجه شبکه نیست؛ یک وضعیت رابط کاربریه که ViewModel قبل از شروع عملیات تنظیم می‌کنه.

ذخیره State داخل خود AppResult

در این پروژه StateFlow مستقیماً این نوع رو نگه می‌داره:

MutableStateFlow<

    AppResult<List<User>>

>

یعنی به‌جای ساختن یک UserUiState جدا با فیلدهای loading، users و error، خود AppResult نقش UI State رو هم بازی می‌کنه. این روش برای پروژه کوچک این درس ساده و مناسبه، چون در هر لحظه فقط یکی از سه وضعیت Loading، Success یا Error رو داریم.

when همراه با ساخت متغیر

در Compose نوشتیم:

when (

    val currentState = state

) {

اینجا هم‌زمان مقدار فعلی state رو داخل currentState قرار می‌دیم و نوعش رو بررسی می‌کنیم. بعد در شاخه موفق می‌تونیم بنویسیم currentState.data و در شاخه خطا currentState.message این کار باعث میشه یک نسخه مشخص از State رو در تمام همان when استفاده کنیم و کد هم خواناتر بشه.

چرا UI فقط پیام Error Handler را نمایش می‌دهد؟

Composable از نوع واقعی Exception اطلاعی نداره. UI فقط این مقدار رو دریافت می‌کنه:

currentState.message

یعنی UI نمی‌دونه خطا IOException بوده یا HttpException. تشخیص فنی خطا داخل لایه Network و Repository انجام شده و UI فقط پیام آماده رو نمایش می‌ده. این جداسازی مهمه، چون رابط کاربری نباید با جزئیات فنی شبکه درگیر بشه:

Exception فنی

NetworkErrorHandler

پیام قابل‌فهم

AppResult.Error

Compose

درس ۱۴
ذخیره اطلاعات داخل گوشی با SQLite / Room مقدماتی

تا اینجا اطلاعات برنامه ما تقریباً همیشه از اینترنت می‌آمد. مثلاً:

Android

Retrofit

PHP API

JSON

اما یک سؤال مهم اگر کاربر اینترنت نداشته باشد چه؟ مثلاً:

  • اپلیکیشن یادداشت
  • پیام‌رسان
  • اپلیکیشن بانکی
  • برنامه قیمت‌ها

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


Database چیست؟

Database یعنی یک سیستم برای ذخیره و مدیریت اطلاعات. به جای اینکه اطلاعات را داخل متغیر نگه داریم:

val notes = listOf()

که با بسته شدن برنامه از بین می‌رود، آن را داخل حافظه دائمی ذخیره می‌کنیم. مثلاً:

Phone Storage

Database

Notes


SQLite چیست؟

SQLite یک دیتابیس کوچک و داخلی است که داخل خود اندروید وجود دارد. یعنی نیازی به نصب سرور جداگانه ندارد. ساختار:

Android Device

 └── SQLite Database

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


مشکل کار مستقیم با SQLite

اندروید اجازه می‌دهد مستقیم با SQLite کار کنیم. مثلاً:

CREATE TABLE Notes

اما کار با آن سخت است:

  • Queryهای زیاد
  • تبدیل Cursor
  • مدیریت خطا
  • کدهای تکراری

برای همین گوگل یک لایه بالاتر ساخته به نام Room.


Room چیست؟

Room یک کتابخانه روی SQLite است. یعنی:

Room

SQLite

Storage گوشی

ما با کلاس‌های Kotlin کار می‌کنیم و Room خودش SQL را مدیریت می‌کند.


اضافه کردن Room

داخل:

build.gradle

:

implementation(
"androidx.room:room-runtime:2.6.1"
)


kapt(
"androidx.room:room-compiler:2.6.1"
)


implementation(
"androidx.room:room-ktx:2.6.1"
)

پروژه دفترچه یادداشت

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

  • یادداشت اضافه کند
  • یادداشت‌ها را ببیند
  • ویرایش کند
  • حذف کند

ساختار:

Note App

User

Add Note

Save Database

Show Notes


Entity چیست؟

Entity یعنی یک جدول داخل Database. مثلاً جدول یادداشت‌ها:

Notes Table


id | title | text

1  | خرید | شیر بخر

2  | کار  | پروژه را انجام بده

در Room هر Entity یک Data Class است.


ساخت Note Entity

@Entity(
    tableName = "notes"
)
data class Note(

    @PrimaryKey(
        autoGenerate = true
    )
    val id:Int = 0,


    val title:String,


    val description:String

)

بررسی کد

@Entity یعنی این کلاس تبدیل به جدول می‌شود.

@PrimaryKey یعنی این ستون شناسه اصلی جدول است.


autoGenerate = true

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

Note 1

Note 2

Note 3

DAO چیست؟

DAO مخفف Data Access Object است. یعنی جایی که عملیات دیتابیس را تعریف می‌کنیم. مثلاً:

  • Insert
  • Update
  • Delete
  • Query

ساخت NoteDao

@Dao
interface NoteDao{


}

Insert کردن اطلاعات

برای اضافه کردن:

@Insert
suspend fun insertNote(
    note:Note
)

حالا:

Note(

title="Shopping",

description="Buy Milk"

)

داخل دیتابیس ذخیره می‌شود.


گرفتن اطلاعات

Query:

@Query(
"SELECT * FROM notes"
)
fun getNotes():

Flow<List<Note>>

یعنی تمام یادداشت‌ها را بده.

خروجی:

[
 Note1,

 Note2,

 Note3
]

حذف کردن

@Delete
suspend fun deleteNote(
    note:Note
)

آپدیت کردن

@Update
suspend fun updateNote(
    note:Note
)

ساخت Database

حالا باید خود Database را بسازیم.

@Database(

entities =
[
    Note::class
],

version = 1

)

abstract class AppDatabase:

RoomDatabase(){


abstract fun noteDao():

NoteDao


}

ساخت Repository

مثل معماری MVVM قبلی، ما مستقیم از UI دیتابیس را صدا نمی‌زنیم. ساختار:

Compose

ViewModel

Repository

Room Database


Repository

class NoteRepository(

private val dao:
NoteDao

){


fun getNotes():

Flow<List<Note>>{


return dao.getNotes()


}



suspend fun insert(
note:Note
){

dao.insertNote(note)

}


}

ViewModel

حالا ViewModel:

class NoteViewModel(

private val repository:
NoteRepository

):ViewModel(){


val notes =
repository.getNotes()


fun addNote(
note:Note
){

viewModelScope.launch {


repository.insert(note)


}


}


}

اتصال Compose

داخل UI:

val notes by viewModel.notes
.collectAsState(
initial = emptyList()
)

حالا هر تغییری در دیتابیس اتفاق بیفتد:

Room

Flow

ViewModel

Compose Update


مثال واقعی

فرض کن کاربر یک یادداشت می‌نویسد.

عنوان
خرید
متن
شیر و نان بخر
کلیک

Save Button

ViewModel

Repository

Room

SQLite

بعد حتی اگر برنامه بسته شود یا گوشی Restart شود، اطلاعات باقی می‌ماند.


تفاوت Database آنلاین و آفلاین

آنلاین:

Server

API

App

آفلاین:

Phone

Room

App

معمولاً برنامه‌های حرفه‌ای هر دو را دارند. مثلاً:

Open App

Room

Show Old Data

API

Update Data


کاربرد در قیمت‌بان

در پروژه اصلی ما، مثلاً قیمت‌ها، سرور:

API

Latest Prices

ولی گوشی:

Room

Last Saved Prices

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


ساختار پروژه بعد از Room

data

 ├── database

 │    ├── Note.kt

 │    ├── NoteDao.kt

 │    └── AppDatabase.kt


 └── repository

      └── NoteRepository.kt



presentation

 └── notes

      ├── NoteScreen.kt

      └── NoteViewModel.kt

چرا هم روی سرور دیتابیس داریم و هم داخل گوشی؟

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

جریان پروژه اینه:

PHP و MySQL

Retrofit

ذخیره در Room

خواندن از Room

Compose

چرا در MySQL دو جدول users و articles ساختیم؟

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

Foreign Key در جدول مقالات

داخل جدول articles این ارتباط رو تعریف کردیم:

FOREIGN KEY (user_id)

REFERENCES users(id)

این یعنی مقدار user_id هر مقاله باید به یک کاربر واقعی داخل جدول users اشاره بکنه. مثلاً اگر مقاله‌ای user_id = 2 داشته باشه، باید کاربری با شناسه ۲ داخل جدول کاربران وجود داشته باشه. این محدودیت جلوی ثبت مقاله‌ای با نویسنده نامعتبر رو می‌گیره.

معنی ON DELETE CASCADE

در انتهای Foreign Key نوشتیم:

ON DELETE CASCADE

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

استفاده از ON DUPLICATE KEY UPDATE

برای وارد کردن اطلاعات نمونه نوشتیم:

ON DUPLICATE KEY UPDATE

این قسمت باعث میشه فایل SQL رو بشه چند بار Import کرد. اگر رکوردی با همون شناسه یا ایمیل قبلاً وجود داشته باشه، MySQL به‌جای ایجاد خطای Duplicate، اطلاعات قبلی رو به‌روزرسانی می‌کنه. بدون این قسمت، اجرای دوباره فایل ممکن بود به خاطر تکراری بودن شناسه‌ها متوقف بشه.

چرا از utf8mb4 استفاده کردیم؟

در تنظیمات جدول و اتصال PHP نوشتیم:

utf8mb4

این Character Set برای ذخیره درست متن فارسی، حروف زبان‌های مختلف و حتی Emoji مناسبه. اگر تنظیم کدگذاری دیتابیس و اتصال PHP درست نباشه، ممکنه متن‌های فارسی به‌صورت علامت سؤال یا نویسه‌های خراب ذخیره و نمایش داده بشن.

ساخت اتصال دیتابیس با PDO

در PHP اتصال به MySQL رو با PDO ساختیم:

$pdo = new PDO(…)

PDO یک روش استاندارد برای کار با دیتابیس در PHP است. مزیتش اینه که مدیریت خطا، اجرای Query و بعداً استفاده از Prepared Statement رو مرتب‌تر و امن‌تر می‌کنه. فایل room_config.php فقط مسئول ساخت همین اتصال است تا اطلاعات اتصال در فایل‌های مختلف تکرار نشن.

تنظیم PDO::ERRMODE_EXCEPTION

داخل تنظیمات PDO نوشتیم:

PDO::ATTR_ERRMODE =>

    PDO::ERRMODE_EXCEPTION

این یعنی اگر اتصال یا Query دیتابیس مشکل داشت، PDO یک Exception ایجاد بکنه. بعد می‌تونیم خطا رو داخل catch مدیریت کنیم و به‌جای نمایش خطای خام PHP یا خراب شدن خروجی JSON، یک پاسخ کنترل‌شده با کد 500 به اندروید برگردونیم.

تنظیم PDO::FETCH_ASSOC

با این گزینه:

PDO::ATTR_DEFAULT_FETCH_MODE =>

    PDO::FETCH_ASSOC

نتیجه Query به شکل آرایه‌هایی با نام ستون‌ها برمی‌گرده. مثلاً به‌جای کار با شماره ستون، می‌تونیم بنویسیم:

$article[“title”]

این حالت برای ساخت پاسخ JSON خواناتر و مناسب‌تره.

تبدیل updated_at به Unix Timestamp

داخل Query مقالات نوشتیم:

UNIX_TIMESTAMP(updated_at) AS updated_at

ستون updated_at داخل MySQL از نوع تاریخ و زمانه، اما در اندروید ذخیره یک عدد Long ساده‌تره. این تابع تاریخ MySQL رو به تعداد ثانیه‌های گذشته از Unix Epoch تبدیل می‌کنه. بعد اندروید همین عدد رو داخل updatedAt ذخیره می‌کنه و می‌تونه برای مرتب‌سازی یا نمایش زمان ازش استفاده بکنه.

چرا عددهای PHP را به int تبدیل کردیم؟

داده‌های برگشتی از PDO ممکنه حتی برای ستون‌های عددی به شکل String دریافت بشن. برای همین نوشتیم:

$article["id"] =

    (int) $article["id"];

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

“id”: 1

نه متن:

“id”: “1”

در نتیجه Gson هم می‌تونه مقدار رو بدون مشکل داخل ویژگی Int مدل کاتلین قرار بده.

تفاوت مدل API و Entity

در پروژه هم ApiArticle داریم و هم ArticleEntity. این دو هدف متفاوتی دارن. ApiArticle دقیقاً ساختار JSON سرور رو نمایش می‌ده، اما ArticleEntity ساختار جدول داخل Room رو مشخص می‌کنه.

ApiArticle = داده دریافت‌شده از اینترنت

ArticleEntity = داده ذخیره‌شده داخل SQLite گوشی

این جداسازی اجازه می‌ده ساختار سرور و دیتابیس محلی کاملاً به هم قفل نشن. مثلاً ویژگی isLocal فقط داخل Room کاربرد داره و اصلاً از PHP دریافت نمیشه.

استفاده از @SerializedName

نام بعضی فیلدهای JSON به شکل Snake Case بود:

“user_id”

“updated_at”

اما در کاتلین معمولاً Camel Case می‌نویسیم:

userId

updatedAt

برای وصل کردن این دو نام از @SerializedName استفاده کردیم:

@SerializedName("user_id")

val userId: Int

این Annotation به Gson میگه مقدار user_id داخل JSON رو داخل ویژگی userId قرار بده.

@Entity چیست؟

وقتی روی یک Data Class می‌نویسیم:

@Entity(

    tableName = "articles"

)

به Room اعلام می‌کنیم که این کلاس باید به یک جدول SQLite تبدیل بشه. هر ویژگی کلاس معمولاً یک ستون جدول میشه و هر نمونه از کلاس هم یک ردیف دیتابیس رو نمایش می‌ده.

چرا نام جدول را با tableName مشخص کردیم؟

اگر tableName رو تعیین نکنیم، Room معمولاً از نام خود کلاس به‌عنوان نام جدول استفاده می‌کنه. ما عمداً نام‌های ساده users و articles رو انتخاب کردیم تا Queryهای SQL خواناتر باشن و ساختار دیتابیس محلی به ساختار سرور نزدیک بمونه.

@PrimaryKey و autoGenerate

برای مقاله‌های محلی نوشتیم:

@PrimaryKey(

    autoGenerate = true

)

val id: Int = 0

Primary Key شناسه یکتای هر ردیفه. autoGenerate = true یعنی وقتی مقاله جدیدی با id = 0 وارد Room می‌کنیم، خود SQLite یک شناسه جدید براش می‌سازه. بنابراین لازم نیست برای مقاله‌های محلی خودمون دنبال آخرین شماره بگردیم.

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

@ColumnInfo

گاهی نام ویژگی کاتلین با نام ستون SQLite فرق داره. مثلاً:

@ColumnInfo(

    name = "user_id"

)

val userId: Int

کد کاتلین با نام خواناتر userId کار می‌کنه، اما ستون دیتابیس user_id نام داره. @ColumnInfo این ارتباط رو برای Room مشخص می‌کنه.

ساخت Index برای user_id

داخل Entity مقاله نوشتیم:

indices = [

    Index(

        value = ["user_id"]

    )

]

ما در Queryها مرتب مقاله‌ها رو با کاربران از طریق user_id وصل می‌کنیم. Index باعث میشه SQLite برای پیدا کردن مقاله‌های مربوط به یک نویسنده مجبور نباشه هر بار تمام جدول رو ردیف‌به‌ردیف جست‌وجو بکنه. در دیتابیس کوچک تفاوت زیادی دیده نمیشه، اما با زیاد شدن اطلاعات می‌تونه سرعت Query رو بهتر بکنه.

ویژگی isLocal

این ویژگی مشخص می‌کنه مقاله از کجا اومده:

val isLocal: Boolean = false

اگر مقاله از سرور دریافت شده باشه:

isLocal = false

و اگر کاربر داخل گوشی ساخته باشه:

isLocal = true

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

مدل ArticleWithAuthor

جدول articles فقط userId رو نگه می‌داره، اما UI به نام نویسنده هم نیاز داره. برای نتیجه Query یک مدل جدا ساختیم:

data class ArticleWithAuthor(…)

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

DAO چیست؟

DAO جاییه که عملیات مجاز روی دیتابیس رو تعریف می‌کنیم:

@Dao

interface ContentDao

UI و ViewModel نباید Query SQL رو مستقیم اجرا کنن. تمام عملیات خواندن، افزودن، ویرایش و حذف داخل DAO قرار می‌گیرن و Room براساس همین Interface کد اجرایی لازم رو تولید می‌کنه.

Query ترکیبی با LEFT JOIN

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

FROM articles

LEFT JOIN users

    ON users.id = articles.user_id

LEFT JOIN یعنی تمام مقاله‌ها رو برگردون، حتی اگر کاربر متناظری داخل جدول کاربران پیدا نشد. اگر از INNER JOIN استفاده می‌کردیم، مقاله‌های محلی ما که userId = 0 دارن و کاربری با شناسه صفر ندارن، اصلاً در نتیجه نمایش داده نمی‌شدن.

نقش CASE و COALESCE در Query

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

CASE

    WHEN articles.is_local = 1

        THEN 'مقاله محلی'

    ELSE COALESCE(

        users.name,

        'نویسنده نامشخص'

    )

END AS authorName

اگر مقاله محلی باشه، مستقیم عنوان «مقاله محلی» رو برمی‌گردونیم. اگر مقاله سروری باشه، نام نویسنده از جدول کاربران گرفته میشه. COALESCE هم میگه اگر نام نویسنده null بود، عبارت «نویسنده نامشخص» رو جایگزین کن. در پایان نتیجه با نام authorName داخل مدل ArticleWithAuthor قرار می‌گیره.

اهمیت نام‌های AS در Query

داخل Query برای ستون‌ها Alias تعیین کردیم:

articles.user_id AS userId

articles.updated_at AS updatedAt

نام این Aliasها باید با ویژگی‌های مدل ArticleWithAuthor هماهنگ باشه. Room براساس همین نام‌ها تشخیص می‌ده هر ستون نتیجه باید داخل کدام ویژگی کلاس قرار بگیره.

چرا خروجی Query از نوع Flow است؟

تابع دریافت مقاله‌ها این خروجی رو داره:

Flow<List<ArticleWithAuthor>>

یعنی Room فقط یک بار لیست رو تحویل نمی‌ده؛ هر وقت جدول کاربران یا مقاله‌ها تغییر بکنه، لیست جدیدی منتشر می‌کنه. مثلاً با اضافه کردن یا حذف مقاله، ViewModel خودکار نسخه تازه لیست رو دریافت می‌کنه و Compose هم دوباره UI رو می‌سازه. لازم نیست بعد از هر عملیات دستی دوباره Query رو صدا بزنیم.

OnConflictStrategy.REPLACE

برای اطلاعات دریافتی از سرور نوشتیم:

@Insert(

    onConflict =

        OnConflictStrategy.REPLACE

)

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

چرا insertLocalArticle یک Long برمی‌گرداند؟

Room برای Insertهایی که شناسه خودکار دارن، شناسه ردیف ساخته‌شده رو به شکل Long برمی‌گردونه:

suspend fun insertLocalArticle(…): Long

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

استفاده از @Update و @Delete

در @Update، Room با استفاده از Primary Key ردیف موردنظر رو پیدا و مقدارهای جدیدش رو ذخیره می‌کنه. در @Delete هم با توجه به Primary Key همون ردیف رو حذف می‌کنه. به همین دلیل هنگام ویرایش، ابتدا نسخه اصلی مقاله رو از دیتابیس می‌گیریم، با copy() تغییرش می‌دیم و دوباره به DAO تحویل می‌دیم.

چرا قبل از ویرایش از findArticle استفاده کردیم؟

UI یک ArticleWithAuthor در اختیار داره، اما عملیات @Update و @Delete روی ArticleEntity انجام میشن. به همین دلیل اول مقاله اصلی رو از جدول پیدا می‌کنیم:

dao.findArticle(

    articleId = articleId

)

بعد مطمئن می‌شیم واقعاً محلیه و در صورت مجاز بودن، عملیات Update یا Delete رو انجام می‌دیم. این بررسی داخل Repository مهمه؛ چون صرفاً مخفی کردن دکمه ویرایش در UI برای جلوگیری از تغییر مقاله سرور کافی نیست.

@Database

با این Annotation مشخص کردیم دیتابیس Room شامل چه جدول‌هایی است:

@Database(

    entities = [

        UserEntity::class,

        ArticleEntity::class

    ],

    version = 1

)

entities فهرست جدول‌های دیتابیس رو تعیین می‌کنه. version = 1 نسخه فعلی ساختار دیتابیسه. اگر بعداً ستونی اضافه یا حذف کنیم، باید نسخه رو افزایش بدیم و معمولاً Migration هم تعریف کنیم.

چرا exportSchema = false گذاشتیم؟

Room می‌تونه نسخه‌های مختلف ساختار دیتابیس رو داخل فایل Schema ذخیره بکنه. در این تمرین ساده برای جلوگیری از تنظیمات اضافه نوشتیم:

exportSchema = false

در پروژه حرفه‌ای بهتره Schemaها ذخیره بشن، چون برای نوشتن Migration و بررسی تغییرات دیتابیس مفیدن.

ساخت Singleton برای Database

ساختن چند نمونه از Room Database غیرضروری و مشکل‌سازه. برای همین یک نمونه مشترک نگه داشتیم:

private var instance:

    LocalDatabase? = null

تابع getInstance بررسی می‌کنه Database قبلاً ساخته شده یا نه. اگر ساخته شده باشه، همون رو برمی‌گردونه؛ در غیر این صورت یک نمونه جدید ایجاد می‌کنه. بنابراین تمام Repositoryها و DAOها از یک اتصال مشترک به دیتابیس استفاده می‌کنن.

نقش @Volatile

روی متغیر Database نوشتیم:

@Volatile

private var instance:

    LocalDatabase? = null

@Volatile کمک می‌کنه اگر چند Thread به این متغیر دسترسی داشتن، آخرین مقدار نوشته‌شده رو ببینن. بدون اون ممکن بود یک Thread هنوز مقدار قدیمی null رو ببینه و تصور بکنه Database ساخته نشده است.

نقش synchronized

قسمت ساخت دیتابیس داخل این بلوک قرار گرفت:

synchronized(this) {

}

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

Room.databaseBuilder

خود دیتابیس SQLite با این کد ساخته میشه:

Room.databaseBuilder(

    context.applicationContext,

    LocalDatabase::class.java,

    "offline_articles.db"

)

پارامتر اول Context برنامه است، پارامتر دوم کلاس Database رو معرفی می‌کنه و پارامتر سوم اسم فایل SQLite داخل گوشی است. اطلاعات کاربران و مقالات در همین فایل offline_articles.db ذخیره میشن.

تبدیل مدل‌های API به Entity

اطلاعاتی که Retrofit دریافت می‌کنه مستقیماً داخل Room قرار نمی‌گیره. ابتدا مدل‌ها رو تبدیل می‌کنیم:

response.users.map { user ->

    UserEntity(

        id = user.id,

        name = user.name,

        email = user.email

    )

}

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

چرا UI همیشه از Room می‌خواند؟

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

repository

    .articles

    .collect { ... }

وقتی Sync اطلاعات رو داخل Room قرار می‌ده، Flow خودکار لیست جدید رو منتشر می‌کنه. این الگو باعث میشه فقط یک منبع برای نمایش UI داشته باشیم:

سرور

Room

UI

نه اینکه گاهی UI از سرور بخونه و گاهی از Room.

رفتار برنامه هنگام قطع اینترنت

اگر syncFromServerبه خاطر IOException ناموفق بشه، Repository فقط یک پیام خطا برمی‌گردونه و اطلاعات قبلی Room رو حذف نمی‌کنه. چون ViewModel همچنان Flow محلی رو مشاهده می‌کنه، مقاله‌های ذخیره‌شده قبلی روی صفحه باقی می‌مونن. این دقیقاً پایه رفتار Offline-first است.

ذخیره زمان فعلی مقاله محلی

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

System.currentTimeMillis()

    .div(1000)

currentTimeMillis زمان رو برحسب میلی‌ثانیه می‌ده، اما زمان سرور برحسب ثانیه بود. با تقسیم بر هزار، هر دو منبع زمان واحد یکسانی پیدا می‌کنن و Query می‌تونه مقالات محلی و سروری رو کنار هم درست مرتب کنه.

متغیر editingArticleId

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

val editingArticleId:

    Int? = null

اگر null باشه، با زدن دکمه یک مقاله تازه Insert میشه. اگر یک شناسه داخلش باشه، Repository همون مقاله رو پیدا و Update می‌کنه. بنابراین با یک فرم مشترک هم افزودن و هم ویرایش رو انجام دادیم.

چرا فقط مقاله‌های محلی قابل ویرایش‌اند؟

قبل از ویرایش یا حذف بررسی کردیم:

if (

    !article.isLocal

) {

    return

}

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

Sync یک‌طرفه در این پروژه

در این تمرین Sync فقط از سرور به گوشی انجام میشه:

MySQL

PHP

Retrofit

Room

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

نقش KAPT و Room Compiler

فقط اضافه کردن room-runtime کافی نیست. Room از Annotationهایی مثل @Entity، @Dao و @Database استفاده می‌کنه و باید هنگام Build براساس اون‌ها کد واقعی Database و DAO رو تولید بکنه. این کار رو room-compiler انجام می‌ده و kapt پردازش Annotationهای کاتلین رو فعال می‌کنه:

kapt(

    "androidx.room:room-compiler:$roomVersion"

)

اگر Compiler یا KAPT اضافه نشده باشه، کلاس‌های Room تولید نمی‌شن و پروژه Build نخواهد شد.

نقش room-ktx

این Dependency پشتیبانی بهتر Room از Coroutine و Flow رو فراهم می‌کنه:

implementation(

    "androidx.room:room-ktx:$roomVersion"

)

به کمک اون می‌تونیم تابع‌های DAO رو suspend تعریف کنیم و Queryهای قابل مشاهده رو به شکل Flow دریافت کنیم؛ یعنی دقیقاً همون چیزی که برای اتصال Room به ViewModel و Compose لازم داریم.

در مورد کتابخانه ها و پلاگین های اضافه شده

dependencies {

    val roomVersion = "2.8.4"

    implementation("androidx.room:room-runtime:$roomVersion")

    implementation("androidx.room:room-ktx:$roomVersion")

    kapt("androidx.room:room-compiler:$roomVersion")

پلاگین

    id("org.jetbrains.kotlin.kapt")

}


متغیر roomVersion

val roomVersion = “2.8.4”

نسخه‌ی هر سه کتابخانه Room باید با هم هماهنگ باشه. برای همین به‌جای اینکه عدد نسخه رو سه بار جداگانه بنویسیم، یک متغیر به نام roomVersion ساختیم و همه‌ی Dependencyهای Room رو به همون وصل کردیم. این کار هم کد رو مرتب‌تر می‌کنه و هم بعداً برای تغییر نسخه فقط کافیه همین یک خط رو عوض کنیم. اگر نسخه‌ی room-runtime، room-ktx و room-compiler با هم متفاوت باشه، ممکنه هنگام Build یا اجرای دیتابیس با ناسازگاری روبه‌رو بشیم.

وابستگی room-runtime

implementation(

    "androidx.room:room-runtime:$roomVersion"

)

room-runtime کتابخانه‌ی اصلی Room است.
کلاس‌ها و Annotationهای مهمی مثل RoomDatabase، @Entity، @Dao، @Database، @Insert، @Update، @Delete و @Query از طریق همین Dependency در اختیار پروژه قرار می‌گیرن. در واقع بدون room-runtime اصلاً نمی‌تونستیم جدول‌ها، DAO و دیتابیس محلی برنامه رو تعریف کنیم. این کتابخانه همچنین کدهای تولیدشده توسط Room رو هنگام اجرای برنامه به SQLite داخلی اندروید متصل می‌کنه.

وابستگی room-ktx

implementation(

    "androidx.room:room-ktx:$roomVersion"

)

room-ktx امکانات Room رو برای کاتلین کامل‌تر می‌کنه و باعث میشه Room راحت‌تر با Coroutine و Flow کار بکنه. در این پروژه تابع‌های افزودن، ویرایش و حذف رو به شکل suspend نوشتیم و لیست مقاله‌ها رو به‌صورت Flow دریافت کردیم:

fun observeArticles():

    Flow<List<ArticleWithAuthor>>

پشتیبانی مناسب از همین ساختارهای کاتلینی با room-ktx فراهم میشه. بنابراین می‌تونیم عملیات دیتابیس رو بدون قفل کردن رابط کاربری انجام بدیم و هر تغییری که داخل جدول‌ها اتفاق می‌افته، به‌صورت خودکار از طریق Flow به ViewModel و Compose برسونیم.

وابستگی room-compiler

kapt(

    "androidx.room:room-compiler:$roomVersion"

)

ما داخل کد فقط با Annotationهایی مثل @Entity، @Dao و @Database توضیح دادیم که دیتابیس چه ساختاری داشته باشه؛ اما پشت این Annotationها هنوز کد اجرایی واقعی وجود نداره. room-compiler هنگام Build پروژه، کدها رو بررسی می‌کنه و براساس اون‌ها پیاده‌سازی واقعی DAO و Database رو می‌سازه. مثلاً ما فقط این Interface رو نوشتیم:

@Dao

interface ContentDao

اما Room Compiler پشت صحنه کلاسی تولید می‌کنه که Queryها رو واقعاً روی SQLite اجرا می‌کنه. همچنین Queryهای SQL رو هنگام Build بررسی می‌کنه؛ بنابراین اگر نام جدول، ستون یا خروجی Query اشتباه باشه، خیلی از خطاها قبل از اجرای برنامه مشخص می‌شن.

چرا room-compiler را با kapt اضافه کردیم؟

برخلاف کتابخانه‌های معمولی که با implementation اضافه می‌شن، room-compiler باید هنگام کامپایل کد اجرا بشه و Annotationهای پروژه رو پردازش بکنه. برای همین اون رو با این شکل اضافه کردیم:

kapt(

    "androidx.room:room-compiler:$roomVersion"

)

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

پلاگین KAPT

id(

    "org.jetbrains.kotlin.kapt"

)

KAPT مخفف Kotlin Annotation Processing Tool است. این پلاگین به Gradle اجازه می‌ده Annotationهای نوشته‌شده در کد کاتلین رو به ابزارهایی مثل Room Compiler تحویل بده. وقتی Room با Annotationهایی مثل @Entity و @Dao روبه‌رو میشه، KAPT اون‌ها رو جمع‌آوری می‌کنه و برای تولید کدهای لازم به room-compiler می‌فرسته.

جریان پشت صحنه تقریباً این شکلیه:

@Entity و @Dao و @Database

KAPT

Room Compiler

تولید کدهای واقعی DAO و Database

اتصال Room به SQLite

اگر room-compiler رو اضافه کنیم ولی پلاگین KAPT فعال نباشه، Annotationهای Room پردازش نمی‌شن و کلاس‌های موردنیاز دیتابیس تولید نخواهند شد. در نتیجه پروژه هنگام Build با خطا روبه‌رو میشه.

چرا هر سه Dependency لازم‌اند؟

این سه مورد هرکدوم مسئولیت جداگانه‌ای دارن:

room-runtime

کلاس‌ها و امکانات اصلی Room در زمان اجرای برنامه
room-ktx

هماهنگی بهتر Room با Kotlin، Coroutine و Flow
room-compiler + KAPT

پردازش Annotationها و تولید کدهای واقعی دیتابیس هنگام Build

پس هیچ‌کدوم جای دیگری رو نمی‌گیره. room-runtime ابزارهای اصلی رو فراهم می‌کنه، room-ktx کار با ساختارهای مدرن کاتلین رو راحت می‌کنه و room-compiler همراه KAPT کدهای پشت صحنه‌ی Room رو تولید می‌کنه.

درس ۱۵
Repository Pattern

در درس قبل ما با Room آشنا شدیم و اطلاعات را داخل دیتابیس گوشی ذخیره کردیم. ساختار ما در آن حالت این بود:

Compose

ViewModel

Database(Room)

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

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

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


Repository چیست؟

Repository یک لایه بین ViewModel و منابع داده است. یعنی ViewModel دیگر نمی‌داند اطلاعات از کجا می‌آید. ساختار جدید:

Compose

↓

ViewModel

↓

Repository

↓

Database

یا

API

یا

Cache

مشکل ساختار قبلی

فرض کنیم:

class NoteViewModel(

private val dao:NoteDao

)

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

ViewModel

DAO

Retrofit

Cache

کم‌کم ViewModel تبدیل می‌شود به یک فایل بزرگ با مسئولیت‌های زیاد.


Repository مشکل را حل می‌کند

حالا ViewModel فقط می‌گوید یادداشت‌ها را بده. مثلاً:

viewModel.loadNotes()

و خودش نمی‌داند:

  • از Room آمد؟
  • از API آمد؟
  • از Cache آمد؟

این تصمیم با Repository است.


ساختار پروژه

بعد از اضافه کردن Repository:

app


presentation

 └── NoteViewModel



domain

 └── NoteRepository



data

 ├── Room

 │    └── NoteDao


 └── Repository

      └── NoteRepositoryImpl

ساخت Interface Repository

در لایه Domain:

interface NoteRepository{


    fun getNotes():

    Flow<List<Note>>



    suspend fun insertNote(
        note:Note
    )



    suspend fun deleteNote(
        note:Note
    )


}

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


پیاده‌سازی Repository

در لایه Data:

class NoteRepositoryImpl(

private val dao:NoteDao

):NoteRepository{


override fun getNotes():

Flow<List<Note>>{


return dao.getNotes()


}



override suspend fun insertNote(

note:Note

){


dao.insertNote(note)


}



override suspend fun deleteNote(

note:Note

){


dao.deleteNote(note)


}



}

تغییر ViewModel

قبل:

class NoteViewModel(

private val dao:NoteDao

)

بعد:

class NoteViewModel(

private val repository:
NoteRepository

)

حالا ViewModel فقط Repository را می‌شناسد.


دریافت اطلاعات

ViewModel:

val notes = repository.getNotes()

تمام. ViewModel کاری ندارد که Room API Cache کجا هستند.


چرا Repository مهم است؟

۱. جدا شدن منبع داده

امروز:

Repository

Room

فردا:

Repository

API

ViewModel هیچ تغییری نمی‌کند.


۲. تست راحت‌تر

فرض کن می‌خواهیم ViewModel را تست کنیم. بدون Repository:

ViewModel

Database واقعی

باید دیتابیس واقعی بسازیم. اما با Repository یک نمونه تستی می‌دهیم:

FakeNoteRepository()

که اطلاعات آزمایشی برمی‌گرداند.


مثال Fake Repository

class FakeNoteRepository:

NoteRepository{


override fun getNotes():

Flow<List<Note>>{


return flowOf(

listOf(

Note(
1,
"Test",
"Hello"
)

)

)

}


}

حالا بدون دیتابیس واقعی می‌توانیم ViewModel را تست کنیم.


آماده شدن برای Cache

مهم‌ترین دلیل استفاده از Repository همین است. فرض کن اپلیکیشن قیمت‌ها را دارد. حالت ساده:

ViewModel

API

مشکل اگر اینترنت قطع شود هیچ اطلاعاتی نداریم.


با Repository:

ViewModel

↓

Repository

↓

Room Cache

+
API

جریان:

App Open

↓

Repository

↓

Room

↓

نمایش اطلاعات قدیمی

↓

API

↓

دریافت اطلاعات جدید

↓

ذخیره Room

مثال واقعی دفترچه یادداشت

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

Repository

Room

نسخه دوم:

Repository

Room
+
API

نسخه سوم:

Repository

Cache

Room

API

ViewModel در هر سه حالت یکی است.


Repository در پروژه قیمت‌بان

مثلاً صفحه قیمت دلار بدون Repository:

ViewModel

Retrofit

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

  • آخرین قیمت را ذخیره کنیم
  • آفلاین کار کنیم
  • تاریخچه قیمت بسازیم

باید ViewModel را تغییر دهیم.


با Repository:

ViewModel

Repository

Room
+
Retrofit

تمام تغییرات داخل Repository انجام می‌شود.


جریان کامل معماری ما

تا اینجا:

Compose

↓

ViewModel

↓

Repository

↓

Data Source


        ↙        ↘

     Room       API

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

مهم‌ترین تغییر داخل ContentRepository.kt بود. قبلاً فقط یک کلاس داشتیم:

class ContentRepository(

    private val api: ContentApi,
    private val dao: ContentDao

)

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

interface ContentRepository

که قرارداد Repository است.

class ContentRepositoryImpl :

    ContentRepository

که پیاده‌سازی واقعی با Room و Retrofit است.

class FakeContentRepository :

    ContentRepository

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

تغییر ContentViewModel

منطق ViewModel تقریباً همون قبلی باقی موند، اما نوع Repository مهم شد:

class ContentViewModel(

    private val repository: ContentRepository

)

اینجا ViewModel به Interface وابسته است، نه به کلاس واقعی:

ContentRepositoryImpl

به همین دلیل می‌تونیم بدون تغییر ViewModel، Repository واقعی یا Fake رو بهش بدیم.

تغییر MainActivity

در MainActivity انتخاب می‌کنیم برنامه از کدوم Repository استفاده بکنه:

val repository: ContentRepository =

    if (USE_FAKE_REPOSITORY) {

        FakeContentRepository()

    } else {

        ContentRepositoryImpl(

            api = RoomNetwork.api,

            dao = database.contentDao()

        )

    }

این بخش، تفاوت اصلی عملی درس ۱۵ است. با تغییر فقط یک مقدار:

USE_FAKE_REPOSITORY = true

برنامه بدون Room و API اجرا میشه، ولی ViewModel و UI هیچ تغییری نمی‌کنن.

کدوم فایل‌ها تقریباً تغییر نکردن؟

این فایل‌ها واقعاً نیاز به تغییر خاصی نداشتن:

RoomModels.kt

ContentApi.kt

RoomNetwork.kt

LocalDatabase.kt

ContentScreen.kt

چون مدل‌ها، API، Room و ظاهر برنامه همون قبلی بودن. درس ۱۵ درباره قابلیت جدید نبود؛ درباره این بود که چه کسی باید با Room و API ارتباط داشته باشه.

پس چرا تغییر کم بود؟

چون ساختار درس ۱۴ ما از قبل این بود:

Compose

ViewModel

ContentRepository

Room + API

در حالی که طبق متن درس، بهتر بود درس ۱۴ این‌طوری باشه:

Compose

ViewModel

DAO

و تازه در درس ۱۵ Repository اضافه بشه. بنابراین مرز بین دو درس کمی جلو افتاده بود. درس ۱۵ در پروژه ما Repository رو از یک کلاس ساده به یک Pattern کامل‌تر تبدیل کرد:

Interface

Implementation واقعی

Implementation آزمایشی

تزریق به ViewModel

Repository Pattern دقیقاً چه تغییری در پروژه ایجاد کرد؟

در درس قبل ContentRepository یک کلاس عادی بود که ViewModel مستقیم همون کلاس رو می‌شناخت. اما در این درس Repository رو به یک قرارداد و چند پیاده‌سازی تقسیم کردیم:

ContentRepository

ContentRepositoryImpl
یا
FakeContentRepository

حالا ViewModel فقط می‌دونه یک Repository در اختیار داره که می‌تونه مقاله‌ها رو دریافت، ذخیره، حذف و همگام‌سازی بکنه. ViewModel دیگه نمی‌دونه این Repository از Room و Retrofit استفاده می‌کنه یا اطلاعات آزمایشی رو از حافظه می‌ده. این جداسازی، اصل اصلی Repository Pattern در همین پروژه است.

ساخت Interface برای Repository

قرارداد Repository رو به شکل یک Interface تعریف کردیم:

interface ContentRepository {

    fun observeArticles():

        Flow<List<ArticleWithAuthor>>

    suspend fun syncFromServer():

        SyncResult

    suspend fun saveLocalArticle(

        articleId: Int?,

        title: String,

        body: String

    )

    suspend fun deleteLocalArticle(

        articleId: Int

    )

}

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

چرا Interface خودش کد اجرایی ندارد؟

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

suspend fun deleteLocalArticle(

    articleId: Int

)

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

پیاده‌سازی Interface با : ContentRepository

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

class ContentRepositoryImpl(

    private val api: ContentApi,

    private val dao: ContentDao

) : ContentRepository

قسمت ContentRepository

یعنی این کلاس قرارداد ContentRepository رو پذیرفته و موظفه تمام تابع‌های اون رو پیاده‌سازی بکنه. اگر حتی یکی از تابع‌های Interface رو داخل این کلاس ننویسیم، پروژه Build نمیشه. بنابراین Interface مطمئن میشه تمام پیاده‌سازی‌ها قابلیت‌های یکسانی دارن.

چرا اسم کلاس واقعی ContentRepositoryImpl است؟

کلمه‌ی Impl مخفف Implementation یعنی «پیاده‌سازی» است. ContentRepository قرارداد کلیه و ContentRepositoryImpl نسخه واقعی اون قرارداده که به Retrofit و Room وصل میشه:

ContentRepositoryImpl

ContentApi
+
ContentDao

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

کاربرد override در Repository

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

override suspend fun syncFromServer():

    SyncResult

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

چرا ViewModel فقط ContentRepository را دریافت می‌کند؟

سازنده ViewModel این شکلیه:

class ContentViewModel(

    private val repository:

        ContentRepository

) : ViewModel()

اینجا نوع پارامتر رو ContentRepositoryImpl ننوشته‌ایم، بلکه خود Interface یعنی ContentRepository رو نوشتیم. دلیلش اینه که ViewModel نباید به یک پیاده‌سازی خاص وابسته باشه. هر کلاسی که قرارداد ContentRepository رو رعایت بکنه، می‌تونه به ViewModel داده بشه.

پس هر دو مورد زیر برای ViewModel قابل قبول هستن:

ContentRepositoryImpl(…)

و

FakeContentRepository()

وابستگی به قرارداد به‌جای جزئیات

ViewModel فقط این دستورات رو می‌شناسه:

repository.observeArticles()

repository.syncFromServer()

repository.saveLocalArticle(…)

repository.deleteLocalArticle(…)

اما از داخل این توابع خبر نداره. مثلاً نمی‌دونه syncFromServer() با Retrofit اجرا میشه یا فقط یک نتیجه آزمایشی برمی‌گردونه. این یعنی ViewModel به «قابلیت‌ها» وابسته است، نه به جزئیات اجرای اون‌ها.

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

در حالت واقعی، این خط:

repository.observeArticles()

در نهایت به این مسیر می‌رسه:

ContentRepositoryImpl

ContentDao

Room

اما در حالت Fake، دقیقاً همین خط به این مسیر می‌رسه:

FakeContentRepository

MutableStateFlow

اطلاعات آزمایشی

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

Fake Repository چیست؟

FakeContentRepository یک پیاده‌سازی جایگزین از قرارداد Repository است:

class FakeContentRepository :

    ContentRepository

این کلاس به Room، Retrofit، اینترنت یا سرور متصل نیست. اطلاعات آزمایشی رو خودش داخل حافظه نگه می‌داره و همون توابع Repository واقعی رو ارائه می‌کنه. بنابراین می‌تونیم رفتار ViewModel و UI رو بدون آماده بودن سرور یا دیتابیس واقعی آزمایش کنیم.

فرق Fake Repository با داده Hardcode شده داخل UI

اطلاعات آزمایشی رو مستقیماً داخل ContentScreen یا ViewModel قرار ندادیم. اون‌ها رو داخل یک Repository جدا گذاشتیم:

FakeContentRepository

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

نگهداری اطلاعات Fake با MutableStateFlow

داخل Fake Repository نوشتیم:

private val fakeArticles =

    MutableStateFlow(

        listOf(...)

    )

چون Repository واقعی مقاله‌ها رو به شکل Flow از Room می‌ده، Fake Repository هم باید خروجی مشابهی داشته باشه. MutableStateFlow اجازه می‌ده یک لیست آزمایشی داخل حافظه نگه داریم و هر وقت مقاله‌ای اضافه، ویرایش یا حذف شد، مقدار جدید رو منتشر کنیم. در نتیجه ViewModel و Compose دقیقاً مثل حالت Room به‌روز می‌شن.

چرا Fake Repository از asStateFlow خروجی می‌دهد؟

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

override fun observeArticles():

    Flow<List<ArticleWithAuthor>> {

    return fakeArticles

        .asStateFlow()

}

خود fakeArticles قابل تغییره و فقط Fake Repository باید اجازه تغییرش رو داشته باشه. بیرون کلاس فقط نسخه قابل مشاهده اون تحویل داده میشه. در نتیجه ViewModel می‌تونه تغییرات رو دریافت بکنه، اما نمی‌تونه مستقیماً لیست داخلی Repository رو عوض بکنه.

متغیر nextId

چون Fake Repository به SQLite وصل نیست، دیتابیسی وجود نداره که شناسه مقاله جدید رو خودکار بسازه. برای همین یک شمارنده ساده تعریف کردیم:

private var nextId = 1003

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

id = nextId

nextId++

این کار فقط نقش autoGenerate دیتابیس رو در محیط آزمایشی شبیه‌سازی می‌کنه.

اضافه کردن مقاله به ابتدای لیست Fake

برای اضافه کردن مقاله جدید نوشتیم:

fakeArticles.value =

    listOf(

        newArticle

    ) +

    fakeArticles.value

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

ویرایش اطلاعات Fake با map

برای ویرایش یک مقاله داخل Fake Repository از map استفاده کردیم:

fakeArticles.value =

    fakeArticles.value.map { article ->

        if (

            article.id == articleId &&

            article.isLocal

        ) {

            article.copy(

                title = title,

                body = body,

                updatedAt = currentTime

            )

        } else {

            article

        }

    }

map تمام اعضای لیست رو بررسی می‌کنه. اگر شناسه مقاله با شناسه موردنظر برابر باشه، نسخه ویرایش‌شده اون رو برمی‌گردونه؛ در غیر این صورت همان مقاله قبلی رو نگه می‌داره. خروجی map یک لیست جدید است که بعد داخل StateFlow قرار می‌گیره.

حذف اطلاعات Fake با filterNot

برای حذف مقاله نوشتیم:

fakeArticles.value =

    fakeArticles.value.filterNot { article ->

        article.id == articleId &&

        article.isLocal

    }

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

چرا Fake Repository بعد از بسته شدن برنامه پاک می‌شود؟

اطلاعات Fake فقط داخل MutableStateFlow و حافظه موقت برنامه قرار دارن. بنابراین با بسته شدن کامل برنامه از بین میرن. این رفتار طبیعیه، چون Fake Repository قرار نیست جای دیتابیس دائمی رو بگیره؛ فقط برای تست و تمرین جداسازی منبع داده ساخته شده.

در مقابل، اطلاعات ContentRepositoryImpl داخل Room ذخیره میشن و بعد از بسته شدن برنامه باقی می‌مونن.

رفتار syncFromServer در Fake Repository

داخل Fake Repository نوشتیم:

override suspend fun syncFromServer():

    SyncResult {

    return SyncResult.Success

}

Fake Repository اصلاً به سرور وصل نمیشه، اما چون قرارداد ContentRepository تابع syncFromServer() رو الزامی کرده، باید اون رو پیاده‌سازی بکنه. در این نسخه فقط نتیجه موفق برمی‌گردونه تا ViewModel همون جریان معمول رو ادامه بده.

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

انتخاب Repository واقعی یا Fake

در MainActivity یک متغیر ساده قرار دادیم:

private const val USE_FAKE_REPOSITORY =

    false

اگر مقدار false باشه، برنامه از Room و API استفاده می‌کنه. اگر true باشه، برنامه از Fake Repository استفاده می‌کنه. این متغیر فقط برای تمرین ساخته شده تا تغییر منبع داده رو با یک خط ببینیم.

چرا const val استفاده کردیم؟

مقدار USE_FAKE_REPOSITORY از ابتدا مشخصه و هنگام اجرای برنامه تغییر نمی‌کنه. به همین دلیل اون رو با const val تعریف کردیم:

private const val USE_FAKE_REPOSITORY =

    false

const یعنی این مقدار یک ثابت زمان کامپایل است. private هم یعنی فقط همین فایل می‌تونه از اون استفاده بکنه.

چرا نوع متغیر Repository را صریح نوشتیم؟

داخل MainActivity نوشتیم:

val repository:

    ContentRepository =

این نوع صریح خیلی مهمه. چون یک شاخه if مقدار FakeContentRepository می‌ده و شاخه دیگه ContentRepositoryImpl می‌سازه. هر دو کلاس متفاوتن، اما هر دو قرارداد مشترک ContentRepository رو اجرا می‌کنن.

پس با این نوع می‌گیم مهم نیست کلاس واقعی Repository چیست. فقط باید ContentRepository باشد.

انتخاب پیاده‌سازی با if

Repository رو این‌طوری ساختیم:

val repository:

    ContentRepository =

    if (

        USE_FAKE_REPOSITORY

    ) {

        FakeContentRepository()

    } else {

        ContentRepositoryImpl(

            api = RoomNetwork.api,

            dao = database.contentDao()

        )

    }

در کاتلین if می‌تونه یک مقدار برگردونه. اگر حالت Fake فعال باشه، نمونه Fake ساخته میشه و در غیر این صورت Repository واقعی ساخته میشه. هر کدوم انتخاب بشه، در نهایت داخل متغیری از نوع ContentRepository قرار می‌گیره.

چرا Factory هم فقط Interface را دریافت می‌کند؟

Factory این پارامتر رو می‌گیره:

private val repository:

    ContentRepository

بنابراین Factory هم به Repository واقعی وابسته نیست. هر Repositoryای که قرارداد رو رعایت بکنه، وارد Factory میشه و بعد به ViewModel تحویل داده میشه:

MainActivity

Repository واقعی یا Fake

ContentViewModelFactory

ContentViewModel

Dependency Injection ساده در این پروژه

وقتی Repository رو بیرون ViewModel می‌سازیم و از طریق سازنده به اون تحویل می‌دیم:

ContentViewModel(

    repository = repository

)

در واقع داریم یک شکل ساده از Dependency Injection رو اجرا می‌کنیم. یعنی ViewModel خودش نمی‌نویسه:

val repository =

    ContentRepositoryImpl(...)

بلکه وابستگی موردنیازش از بیرون به اون داده میشه. همین موضوع امکان فرستادن Repository واقعی یا Fake رو فراهم کرده.

چرا ساخت Repository داخل ViewModel اشتباه بود؟

اگر ViewModel خودش این کد رو می‌نوشت:

private val repository =

    ContentRepositoryImpl(

        api = ...,

        dao = ...

    )

دیگه ViewModel مستقیم به Room و Retrofit وابسته می‌شد و امکان فرستادن FakeContentRepository وجود نداشت. همچنین برای ساخت ViewModel مجبور بودیم دیتابیس و شبکه واقعی رو هم آماده کنیم. پس Repository باید بیرون ساخته و از طریق سازنده وارد ViewModel بشه.

منبع داده واقعی پشت Repository

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

Remote Data Source

= ContentApi

Local Data Source

= ContentDao

تابع syncFromServer() از API اطلاعات می‌گیره و اون‌ها رو داخل DAO ذخیره می‌کنه. تابع observeArticles() هم داده‌ها رو از DAO تحویل می‌ده. ViewModel فقط Repository رو می‌بینه و از این رفت‌وآمد داخلی اطلاعی نداره.

Repository به‌عنوان محل تصمیم‌گیری درباره منبع داده

در این پروژه Repository تصمیم می‌گیره:

نمایش مقاله‌ها
← از Room

دریافت اطلاعات تازه
← از API

ذخیره نسخه تازه
← داخل Room

افزودن و حذف محلی
← از طریق DAO

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

چرا UI هم با عوض شدن Repository تغییر نکرد؟

ContentScreen فقط State ساخته‌شده توسط ViewModel رو دریافت می‌کنه. اون نه ContentRepositoryImpl رو می‌شناسه و نه FakeContentRepository رو. بنابراین عوض شدن Repository هیچ اثری روی کد UI نداره:

Repository تغییر می‌کند

ViewModel همان است

State همان ساختار را دارد

Compose همان است

این آزمایش نشون می‌ده جداسازی Repository فقط برای مرتب شدن فایل‌ها نیست؛ واقعاً اجازه می‌ده بخش داده رو بدون دست‌کاری ViewModel و UI جایگزین کنیم.

درس ۱۶
Cache و مدیریت داده آفلاین

تا اینجا ما یاد گرفتیم چطور:

  • از API اطلاعات بگیریم
  • اطلاعات را داخل Room ذخیره کنیم
  • با Repository منبع داده را جدا کنیم

اما یک سؤال مهم باقی مانده:
اگر کاربر اینترنت نداشته باشد چه؟ مثلاً کاربر صبح با اینترنت وارد برنامه اخبار می‌شود:

id="9q2x7m"
API

↓

لیست اخبار

↓

Room

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


Cache چیست؟

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


در اپلیکیشن:

API

↓

Cache

↓

UI

یا:

Database

↓

UI

استفاده می‌شود.


چرا Cache مهم است؟

بدون Cache:

باز کردن برنامه

↓

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

↓

صبر کاربر

هر بار باید منتظر بمانیم. اما با Cache:

باز کردن برنامه

نمایش سریع اطلاعات قبلی

آپدیت در پس‌زمینه

تجربه کاربر بهتر می‌شود.


انواع Cache

ما معمولاً سه نوع Cache داریم:

۱. Memory Cache

۲. Disk Cache

۳. Network Cache


Memory Cache

Memory Cache یعنی ذخیره اطلاعات داخل RAM گوشی. مثلاً:

var newsList = listOf<News>()

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


مثال کاربر صفحه اخبار را باز می‌کند. اول:

API

newsList

داده داخل RAM قرار می‌گیرد. وقتی دوباره همان صفحه باز شود:

Memory

UI

خوانده می‌شود.


مزیت Memory Cache

خیلی سریع است. چون:

RAM
>
Storage

سرعت بیشتری دارد.


مشکل Memory Cache

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

مثلاً:

Close App

RAM پاک می‌شود

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


Disk Cache

Disk Cache یعنی ذخیره اطلاعات روی حافظه گوشی. مثلاً:

  • Room
  • SQLite
  • فایل

در پروژه اندروید معمولاً:

Room

SQLite

Phone Storage

است.


مثال اخبار، داده از API:

[
 {
  "title":"Android 16 Released"
 }
]

داخل Room ذخیره می‌شود. بعد:

Room

↓

News List

نمایش داده می‌شود.


مقایسه Memory و Disk Cache

Memory CacheDisk Cache
سریع‌ترکندتر
موقتدائمی
داخل RAMداخل حافظه گوشی
با بسته شدن پاک می‌شودباقی می‌ماند

ساخت پروژه اخبار

مدل خبر:

data class News(

    val id:Int,

    val title:String,

    val description:String

)

Entity برای Room

چون می‌خواهیم Cache دائمی داشته باشیم:

@Entity(
    tableName = "news"
)
data class NewsEntity(

    @PrimaryKey
    val id:Int,

    val title:String,

    val description:String

)

API

سمت سرور news.php. خروجی:

[
 {
  "id":1,
  "title":"New Android Version"
 }
]

Retrofit:

interface NewsApi {


    @GET("news.php")
    suspend fun getNews():

    List<News>


}

Repository با Cache

حالا Repository مهم‌ترین نقش را دارد. ساختار:

ViewModel

Repository

—————-

API
Room
—————-


روش اول: Network First

در این روش اول اینترنت را امتحان می‌کنیم. جریان:

Request

Network

Success؟

Save Cache

Show Data


کد ساده:

class NewsRepository(

private val api:NewsApi,

private val dao:NewsDao

){


suspend fun getNews()
{


val news =
api.getNews()


dao.deleteAll()


dao.insertAll(news)



}

}

یعنی، اول API را صدا بزن. بعد Room را به‌روز کن.


اگر اینترنت قطع باشد؟

اینجا مشکل داریم. پس:

try {

    api.getNews()

}

catch(e:Exception){

    dao.getNews()

}

یعنی اگر اینترنت جواب نداد، از Cache بخوان.


Network First کامل‌تر

suspend fun getNews():

List<News>{


return try {


    val remote =
    api.getNews()


    dao.insertAll(
        remote
    )


    remote


}

catch(e:Exception){


    dao.getNews()


}

}

جریان:

Internet موجود

API

Save Room

Show


Internet قطع

Room

how


روش دوم: Cache First

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

Room

Data موجود؟

Show

Update From API


مثلاً کاربر برنامه را باز می‌کند. اول آخرین اخبار ذخیره شده

نمایش داده می‌شود. بعد:

API

اخبار جدید

می‌آید.


کد:

fun getNews():

Flow<List<News>>{

return dao.getNews()

}

UI سریع اطلاعات قبلی را می‌گیرد.


بعد:

suspend fun refresh(){


val remote =
api.getNews()


dao.insertAll(
remote
)


}

مقایسه Network First و Cache First

Network First

Internet

Cache

UI

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


Cache First

Cache

UI

Network Update

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


کدام بهتر است؟

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

اخبار

معمولاً:

Cache First

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


قیمت ارز

مثلاً قیمت‌بان معمولاً:

Network First

یا ترکیبی بهتر است. چون قیمت جدید مهم است.


پیام‌رسان

ترکیبی:

Memory
+
Room
+
Network


ساختار نهایی معماری Cache

حالا معماری ما:

Compose

↓

ViewModel

↓

Repository

↓

----------------

Remote API

Local Room

Memory Cache

----------------

مثال واقعی قیمت‌بان

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

Room

آخرین قیمت ذخیره شده

نمایش فوری

بعد:

API

قیمت جدید

Update Room

Update UI

اگر اینترنت نبود:

Room

آخرین قیمت موجود

نمایش داده می‌شود.


چرا Room منبع اصلی نمایش اخبار است؟

در این پروژه حتی وقتی اطلاعات از سرور دریافت میشن، لیست سرور مستقیماً به Compose داده نمیشه. Repository خبرها رو داخل Room ذخیره می‌کنه و UI همیشه Flow مربوط به Room رو مشاهده می‌کنه:

val cachedNews =

    dao.observeNews()

جریان اصلی برنامه این شکلیه:

API

Repository

Room

Flow

ViewModel

Compose

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

Memory Cache در Repository

برای ساخت Cache موقت داخل RAM این متغیر رو قرار دادیم:

private var memoryCache:

    List<NewsEntity> =

    emptyList()

این لیست فقط تا وقتی Process برنامه زنده است باقی می‌مونه. وقتی خبرها از اینترنت دریافت میشن یا از Room خونده میشن، Memory Cache هم پر میشه. دفعه بعد Repository می‌تونه بدون Query دوباره روی دیتابیس، اطلاعات موجود در RAM رو استفاده بکنه. اگر برنامه کاملاً بسته بشه یا سیستم Process اون رو از حافظه حذف بکنه، این Cache از بین میره.

تفاوت Memory Cache خبرها با Cache تصاویر

در این پروژه دو Memory Cache جدا داریم. متغیر memoryCache داخل Repository، اطلاعات متنی خبرها مثل عنوان، توضیحات و URL تصویر رو نگه می‌داره. Coil هم Memory Cache خودش رو داره و فایل پردازش‌شده‌ی تصاویر رو داخل RAM نگه می‌داره.

Repository Memory Cache → اطلاعات خبرها

Coil Memory Cache → خود تصاویر آماده نمایش

پاک کردن یکی از این دو، لزوماً دیگری رو پاک نمی‌کنه؛ به همین دلیل هنگام پاک کردن Cacheها هر دو بخش رو جدا مدیریت کردیم.

Disk Cache خبرها و تصاویر

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

Room Disk Cache → عنوان، توضیحات، نام نویسنده و URL عکس

Coil Disk Cache → فایل واقعی عکس نویسنده

به همین دلیل در حالت آفلاین، Room اطلاعات خبر رو تحویل می‌ده و Coil تصویر مربوط به URL ذخیره‌شده رو از Disk Cache خودش پیدا می‌کنه.

چرا فایل تصویر را داخل Room ذخیره نکردیم؟

داخل Room فقط آدرس تصویر رو ذخیره کردیم:

val authorImageUrl: String?

خود فایل تصویر رو به شکل ByteArray یا Blob داخل SQLite نگذاشتیم، چون تصاویر حجم زیادی دارن و ذخیره مستقیم اون‌ها می‌تونه دیتابیس رو سنگین و Queryها رو کند بکنه. کتابخانه‌ای مثل Coil برای دانلود، تغییر اندازه، Decode کردن و Cache تصاویر ساخته شده و این کار رو خیلی بهتر از ذخیره دستی تصویر داخل Room انجام می‌ده.

ستون profile_image روی سرور

در جدول نویسندگان این ستون رو اضافه کردیم:

profile_image VARCHAR(255)

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

images/profiles/ali.jpg

ذخیره مسیر نسبی به‌جای URL کامل باعث میشه اگر دامنه یا پوشه اصلی API تغییر کرد، لازم نباشه تمام رکوردهای دیتابیس رو تغییر بدیم. PHP می‌تونه مسیر نسبی رو هنگام ساخت پاسخ به URL کامل تبدیل بکنه.

ساخت URL کامل تصویر در PHP

داخل Query سرور از CONCAT استفاده کردیم:

CONCAT(

    'https://gym.yummybrands.studio/helloiman_api/',

    news_authors.profile_image

)

AS author_image_url

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

https://gym.yummybrands.studio/helloiman_api/images/profiles/ali.jpg

کنترل نبودن تصویر با CASE

ممکنه بعضی نویسنده‌ها عکس پروفایل نداشته باشن. برای همین در Query نوشتیم:

CASE

    WHEN profile_image IS NULL

         OR profile_image = ''

    THEN NULL

    ELSE ...

END

اگر مسیر تصویر وجود نداشته باشه، PHP مقدار null می‌فرسته. این بهتر از ساختن یک URL خراب است. سمت اندروید هم authorImageUrl رو Nullable تعریف کردیم تا برنامه برای کاربران بدون عکس دچار خطا نشه.

اضافه کردن Coil Compose

برای نمایش تصاویر در Compose این Dependency رو اضافه کردیم:

implementation(

    "io.coil-kt.coil3:coil-compose:3.5.0"

)

این کتابخانه Composableهایی مثل AsyncImage رو در اختیارمون قرار می‌ده. AsyncImage URL رو دریافت می‌کنه، تصویر رو به‌صورت غیرهم‌زمان بارگذاری می‌کنه و بعد نتیجه رو داخل UI نمایش می‌ده. بنابراین لازم نیست دانلود تصویر، ساخت Bitmap و مدیریت Coroutine رو خودمون دستی انجام بدیم.

اضافه کردن شبکه OkHttp برای Coil

در Coil 3 بخش دانلود تصاویر از شبکه داخل Dependency جدا قرار گرفته:

implementation(

    "io.coil-kt.coil3:coil-network-okhttp:3.5.0"

)

coil-compose رابط کاربری و نمایش تصویر رو فراهم می‌کنه، اما coil-network-okhttp به Coil امکان می‌ده URLهای اینترنتی رو با OkHttp دانلود بکنه. اگر فقط Dependency مربوط به Compose رو اضافه کنیم، ممکنه نمایش Resourceهای محلی کار بکنه ولی Coil نتونه عکس HTTPS سرور رو دریافت بکنه.

@SerializedName برای URL تصویر

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

“author_image_url”

اما در کاتلین از نام Camel Case استفاده کردیم:

authorImageUrl

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

@SerializedName(

    "author_image_url"

)

val authorImageUrl: String?

به این ترتیب Gson می‌فهمه مقدار author_image_url داخل JSON باید داخل ویژگی authorImageUrl قرار بگیره.

ذخیره URL تصویر داخل Room

داخل NewsEntity این ستون رو اضافه کردیم:

@ColumnInfo(

    name = "author_image_url"

)

val authorImageUrl: String?

اگر برنامه بسته و دوباره باز بشه، JSON قبلی دیگه در حافظه نیست؛ اما URL تصویر همراه خبر داخل Room باقی مونده. Compose همون URL رو دوباره به Coil می‌ده و Coil ابتدا Disk Cache خودش رو بررسی می‌کنه. بنابراین هم اطلاعات خبر و هم راه دسترسی به تصویر برای اجرای بعدی برنامه حفظ میشن.

چرا نسخه Database را افزایش دادیم؟

قبلاً جدول news_cache ستون author_image_url نداشت. بعد از اضافه کردن ستون جدید، ساختار Entity عوض شد. برای همین نسخه دیتابیس رو از ۱ به ۲ تغییر دادیم:

version = 2

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

استفاده از fallbackToDestructiveMigration

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

.fallbackToDestructiveMigration()

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

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

ویژگی cachedAt

داخل Entity این زمان رو ذخیره کردیم:

val cachedAt: Long

publishedAt زمان انتشار خود خبره، اما cachedAt مشخص می‌کنه این نسخه از خبر چه زمانی داخل گوشی ذخیره شده. این دو با هم فرق دارن:

publishedAt → خبر چه زمانی منتشر شده؟

cachedAt → برنامه چه زمانی خبر را دریافت و Cache کرده؟

در نسخه‌های کامل‌تر برنامه می‌تونیم با cachedAt تصمیم بگیریم Cache قدیمی شده یا هنوز تازه است.

تبدیل پاسخ سرور به Cache محلی

بعد از دریافت اخبار، مدل‌های شبکه رو به Entity تبدیل کردیم:

val newsEntities =

    response.data.map { news ->

        NewsEntity(

            id = news.id,

            title = news.title,

            description = news.description,

            authorName = news.authorName,

            authorImageUrl = news.authorImageUrl,

            publishedAt = news.publishedAt,

            cachedAt = cachedAt

        )

    }

مدل ApiNews نماینده پاسخ سروره، اما NewsEntity نماینده داده ذخیره‌شده داخل گوشی است. در همین تبدیل، زمان cachedAt رو هم اضافه می‌کنیم؛ چون این فیلد از سرور نمیاد و فقط برای Cache محلی معنی داره.

جایگزینی کامل Cache با replaceAll

در DAO این تابع رو ساختیم:

@Transaction

open suspend fun replaceAll(

    news: List<NewsEntity>

) {

    deleteAll()

    insertAll(news)

}

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

نقش @Transaction

عملیات replaceAll از دو مرحله تشکیل شده:

حذف همه خبرها

درج اخبار تازه

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

OnConflictStrategy.REPLACE

داخل Insert نوشتیم:

OnConflictStrategy.REPLACE

اگر خبری با همون شناسه قبلاً داخل Room وجود داشته باشه، نسخه قدیمی با نسخه جدید جایگزین میشه. شناسه هر خبر از سرور میاد و Primary Key است؛ بنابراین Sync دوباره باعث ساخته شدن چند نسخه تکراری از یک خبر نمی‌شه.

مدل CacheSource

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

enum class CacheSource {

    Network,
    Memory,
    Disk

}

این مقدار به UI اجازه می‌ده به کاربر بگه داده‌ای که الان می‌بینه از سرور، RAM یا Room اومده. علاوه بر نمایش آموزشی، چنین اطلاعاتی در Debug کردن رفتار Cache هم خیلی مفیده.

مدل CacheResult

عملیات Cache فقط موفق یا ناموفق نیست؛ در حالت موفق باید بدونیم منبع داده چه بوده:

data class Success(

    val source: CacheSource,
    val message: String

)

مثلاً دو عملیات می‌تونن هر دو موفق باشن، اما یکی از سرور و دیگری از Room نتیجه گرفته باشه. با CacheResult، ViewModel هم نتیجه عملیات و هم منبع داده رو یکجا دریافت می‌کنه.

استراتژی Cache First

در این روش ابتدا Cache بررسی میشه:

val availableCache =

    findAvailableCache()

اگر Memory یا Room داده داشته باشه، UI از قبل اون اطلاعات رو نمایش می‌ده، چون Flow مربوط به Room دائماً در حال مشاهده است. سپس Repository تلاش می‌کنه نسخه جدید رو از سرور دریافت بکنه:

downloadAndSaveNews()

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

استراتژی Network First

در loadNetworkFirst ابتدا مستقیم سرور امتحان میشه:

downloadAndSaveNews()

اگر دریافت موفق بود، جدیدترین اخبار ذخیره میشن. اگر خطا رخ داد، Repository بعداً Memory و Room رو بررسی می‌کنه:

findAvailableCache()

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

حالت Offline Only

تابع زیر اصلاً API رو صدا نمی‌زنه:

loadOfflineOnly()

این تابع فقط Memory Cache و Room رو بررسی می‌کنه. هدفش اینه که رفتار آفلاین رو بدون خاموش کردن واقعی اینترنت هم آزمایش کنیم. اگر Memory خالی باشه اما Room داده داشته باشه، اخبار از Disk Cache خونده میشن؛ اگر هر دو خالی باشن، خطای نبود Cache نمایش داده میشه.

تابع findAvailableCache

منطق انتخاب Cache رو داخل یک تابع مشترک قرار دادیم:

private suspend fun findAvailableCache():

    Pair<List<NewsEntity>, CacheSource?>

این تابع ابتدا Memory Cache رو بررسی می‌کنه، چون سریع‌تره. اگر خالی بود، Room رو می‌خونه. اگر Room داده داشت، همون اطلاعات رو داخل Memory Cache قرار می‌ده تا درخواست بعدی سریع‌تر انجام بشه.

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

Memory Cache
↓ اگر خالی بود
Room Cache
↓ اگر خالی بود
بدون Cache

چرا خروجی findAvailableCache از نوع Pair است؟

این تابع دو اطلاعات رو هم‌زمان برمی‌گردونه:

خود لیست خبرها + منبع Cache

برای همین از Pair استفاده کردیم:

Pair(

    diskCache,

    CacheSource.Disk

)

مقدار first لیست خبرهاست و مقدار second منبع Cache. برای یک پروژه بزرگ‌تر می‌شد به‌جای Pair یک Data Class با نام‌های واضح‌تر ساخت، اما برای تمرین کوچک این ساختار جمع‌وجور باقی مونده.

پر کردن Memory Cache از Room

وقتی Memory Cache خالیه ولی Room اطلاعات داره، نوشتیم:

memoryCache =

    diskCache

این یعنی اولین بار اطلاعات از حافظه دائمی گوشی خونده میشن، اما درخواست‌های بعدی در همان اجرای برنامه می‌تونن نسخه RAM رو استفاده بکنن. این همون ترکیب Disk Cache و Memory Cache است:

اجرای اول
Room → Memory
درخواست بعدی
Memory → Repository

چرا Memory Cache مستقیماً به UI داده نمی‌شود؟

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

دانلود و ذخیره یکجای اخبار

تابع downloadAndSaveNews() تمام عملیات دریافت و Cache رو یکجا انجام می‌ده:

دریافت API

تبدیل به Entity

پر کردن Memory Cache

ذخیره در Room

دانلود و Cache تصاویر

به این ترتیب Cache First و Network First هر دو از یک منطق مشترک برای دریافت و ذخیره استفاده می‌کنن و کد تکراری داخل Repository ایجاد نمیشه.

گرفتن ImageLoader مشترک Coil

برای مدیریت تصاویر نوشتیم:

val imageLoader =

    context.imageLoader

ImageLoader بخش اصلی Coil است که درخواست‌های تصویر، Decode، Memory Cache و Disk Cache رو مدیریت می‌کنه. استفاده از context.imageLoader باعث میشه هم Preload تصاویر و هم AsyncImage از ImageLoader مشترک برنامه استفاده کنن و به یک Cache واحد دسترسی داشته باشن.

چرا Repository به Context نیاز پیدا کرد؟

قبل از اضافه کردن Cache تصاویر، Repository فقط با API و DAO کار می‌کرد. اما برای ساخت ImageRequest و دسترسی به ImageLoader به Context نیاز داریم:

class NewsRepository(

    private val context: Context,
    private val api: NewsApi,
    private val dao: NewsDao

)

در MainActivity هم applicationContext رو به Repository دادیم. این Context به عمر Activity وابسته نیست و برای ابزارهایی مثل ImageLoader و Database مناسب‌تره.

پیدا کردن URLهای معتبر با mapNotNull

برای گرفتن URL عکس‌ها نوشتیم:

newsList.mapNotNull { news ->

    news.authorImageUrl

}

چون ممکنه authorImageUrl برابر null باشه، mapNotNull هم مقدارها رو تبدیل می‌کنه و هم موارد null رو از نتیجه کنار می‌ذاره. بنابراین برای کاربرانی که تصویر ندارن، درخواست خراب ساخته نمیشه.

حذف URLهای خالی

بعد از حذف nullها، این شرط رو هم اضافه کردیم:

.filter { imageUrl ->

    imageUrl.isNotBlank()

}

ممکنه سرور به‌جای null یک String خالی بفرسته. با isNotBlank() فقط URLهایی باقی می‌مونن که واقعاً مقدار قابل استفاده دارن.

حذف درخواست‌های تکراری با distinct

ممکنه یک نویسنده چند خبر داشته باشه و URL عکسش چند بار داخل لیست تکرار شده باشه. برای همین نوشتیم:

.distinct()

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

ساخت ImageRequest

برای دانلود هر تصویر از این Builder استفاده کردیم:

ImageRequest

    .Builder(context)

    .data(imageUrl)

    .build()

data منبع تصویر رو مشخص می‌کنه که اینجا یک URL اینترنتیه. Builder اجازه می‌ده تنظیمات مربوط به Cache، اندازه، Placeholder و موارد دیگر رو قبل از ساخت درخواست مشخص بکنیم.

فعال کردن Memory و Disk Cache تصویر

داخل درخواست تصویر نوشتیم:

.memoryCachePolicy(
    CachePolicy.ENABLED
)


.diskCachePolicy(
    CachePolicy.ENABLED
)

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

Preload تصاویر با imageLoader.execute

برای Cache کردن تصاویر قبل از نمایش نوشتیم:

imageLoader.execute(
    request
)

این درخواست Target تصویری نداره؛ یعنی هدفش نمایش مستقیم داخل یک Composable نیست. Coil تصویر رو دانلود و پردازش می‌کنه و نتیجه رو داخل Cache قرار می‌ده. بعداً وقتی AsyncImage همون URL رو بخواد، Coil می‌تونه تصویر آماده رو از Cache پیدا بکنه. این کار باعث میشه تمام عکس‌های نویسندگان موجود در پاسخ سرور Cache بشن، نه فقط عکس‌هایی که کاربر تا اون لحظه روی صفحه دیده است.

چرا cacheAuthorImages تابع suspend است؟

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

private suspend fun cacheAuthorImages(…)

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

ترتیب ذخیره خبرها و Cache تصاویر

ابتدا اطلاعات متنی رو داخل Memory و Room قرار می‌دیم و بعد تصاویر رو Preload می‌کنیم:

memoryCache =

    newsEntities

dao.replaceAll(

    news = newsEntities

)

cacheAuthorImages(

    newsList = newsEntities

)

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

نمایش تصویر با AsyncImage

در Compose از این Composable استفاده کردیم:

AsyncImage(

    model = news.authorImageUrl,

    contentDescription = "...",

    modifier = ...

)

model همون URL ذخیره‌شده در Room است. AsyncImage ابتدا Memory Cache رو بررسی می‌کنه، بعد Disk Cache و در نهایت اگر تصویر پیدا نشد، سرور رو صدا می‌زنه. این روند پشت صحنه انجام میشه و Compose بعد از آماده شدن تصویر خودش Recompose میشه.

برش تصویر با ContentScale.Crop

برای تصویر پروفایل نوشتیم:

contentScale =

    ContentScale.Crop

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

دایره‌ای کردن عکس با clip

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

Modifier

    .size(52.dp)

    .clip(CircleShape)

size اندازه ثابت تصویر رو تعیین می‌کنه و clip بخش‌های بیرون شکل دایره رو حذف می‌کنه. ترتیب Modifierها مهمه؛ ابتدا اندازه مشخص میشه و بعد همان محدوده به شکل دایره برش می‌خوره.

نقش contentDescription

داخل AsyncImage نوشتیم:

contentDescription =

    "تصویر ${news.authorName}"

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

پاک کردن Memory Cache خبرها و تصاویر

تابع پاک‌سازی Memory Cache به این شکل تغییر کرد:

memoryCache =

    emptyList()

context

    .imageLoader

    .memoryCache

    ?.clear()

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

پاک کردن تمام Cacheها

برای پاک‌سازی کامل این موارد رو حذف کردیم:

Memory Cache خبرها
Room Cache خبرها
Coil Memory Cache تصاویر
Coil Disk Cache تصاویر

کد مربوط به Coil:

imageLoader

    .memoryCache

    ?.clear()

imageLoader

    .diskCache

    ?.clear()

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

چرا Cacheها با ?.clear پاک می‌شوند؟

ویژگی‌های memoryCache و diskCache ممکنه Nullable باشن:

memoryCache?.clear()

عملگر ?. میگه فقط اگر Cache واقعاً وجود داشت، تابع clear() رو اجرا کن. اگر ImageLoader بدون یکی از این Cacheها ساخته شده باشه، برنامه به‌جای Crash کردن از این خط عبور می‌کنه.

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

Room فقط URL رو تحویل می‌ده، اما Coil برای نمایش تصویر سراغ Cache خودش میره. اگر تصویر قبلاً Preload یا نمایش داده شده باشه، داخل Disk Cache وجود داره و بدون اینترنت نمایش داده میشه.

Room

URL تصویر
Coil

بررسی Memory Cache

بررسی Disk Cache

نمایش تصویر

اگر تصویر هیچ‌وقت دانلود نشده باشه یا Disk Cache پاک شده باشه، داشتن URL داخل Room به‌تنهایی کافی نیست و تصویر در حالت آفلاین نمایش داده نمیشه.

تفاوت Cache First خبر و Cache خود Coil

استراتژی Cache First مربوط به تصمیم Repository درباره منبع اطلاعات خبرهاست:

Memory خبرها
یا
Room
یا
Network

اما Coil برای هر تصویر به‌صورت داخلی استراتژی خودش رو اجرا می‌کنه:

Memory تصویر

Disk تصویر

Network تصویر

این دو سیستم جدا هستن، ولی در کنار هم تجربه آفلاین کامل‌تری می‌سازن. Repository متن و URL خبر رو مدیریت می‌کنه و Coil فایل تصویری مربوط به اون URL رو مدیریت می‌کنه.

جریان کامل Cache در پروژه

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

MySQL

PHP

JSON شامل خبر و URL تصویر

Retrofit

Memory Cache خبرها

Room Cache خبرها و URL تصاویر

Coil دانلود تصاویر

Memory و Disk Cache تصاویر

Compose
هنگام باز شدن دوباره برنامه بدون اینترنت:
Room

خبرها و URL تصاویر

Compose
Coil Disk Cache

تصاویر نویسندگان

Compose

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

دیدن فایل‌های Cache با Device Explorer

فایل‌هایی که Room و Coil داخل گوشی ذخیره می‌کنن، معمولاً توی پوشه‌های عمومی مثل Download یا Pictures قرار نمی‌گیرن؛ بلکه داخل فضای خصوصی خود اپلیکیشن ذخیره می‌شن. به همین دلیل با File Manager معمولی گوشی نمی‌تونیم اون‌ها رو ببینیم. برای بررسی این فایل‌ها داخل Android Studio از مسیر View → Tool Windows → Device Explorer وارد Device Explorer می‌شیم و پوشه مربوط به برنامه رو باز می‌کنیم. مسیر برنامه معمولاً یکی از این دو حالته:

/data/data/com.iman.helloiman

یا:

/data/user/0/com.iman.helloiman

داخل پوشه databases فایل دیتابیس Room رو می‌بینیم:

databases/news_cache.db

ممکنه کنار اون فایل‌های کمکی مثل news_cache.db-wal و news_cache.db-shm هم وجود داشته باشن. اطلاعاتی مثل عنوان خبر، توضیحات، نام نویسنده و آدرس عکس داخل همین دیتابیس ذخیره می‌شن. برای دیدن ردیف‌ها و ستون‌های دیتابیس، استفاده از App Inspection → Database Inspector راحت‌تره، چون محتویات جدول Room رو مستقیم و مرتب نشون می‌ده.

فایل واقعی عکس‌هایی که Coil دانلود و Cache کرده، معمولاً داخل پوشه cache برنامه قرار می‌گیره:

/data/data/com.iman.helloiman/cache

اسم فایل‌های عکس داخل این پوشه معمولاً مثل profile1.png نیست و ممکنه به‌صورت رشته‌های نامفهوم یا Hashشده دیده بشه، چون Coil فایل‌ها رو با سیستم داخلی خودش مدیریت می‌کنه. پس Room آدرس عکس رو نگه می‌داره، اما خود فایل تصویر داخل Disk Cache مربوط به Coil ذخیره می‌شه. این فایل‌ها فقط Cache هستن و اندروید در صورت کمبود حافظه می‌تونه اون‌ها رو پاک کنه؛ بنابراین نباید Cache رو محل نگهداری دائمی اطلاعات مهم بدونیم.

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

com.iman.helloiman
└── cache
    └── coil3_disk_cache

همون Disk Cache مربوط به Coil هست. فایل‌هایی با اسم‌های بلند و نامفهوم مثل:

137cae0f693d...
24c2f9a848835...
698fa0631505...

همون عکس‌های Cacheشده هستن. Coil فایل رو با اسم اصلی مثل profile1.png ذخیره نمی‌کنه؛ URL تصویر رو به یک کلید Hashشده تبدیل می‌کنه تا خودش بتونه Cache رو مدیریت کنه. برای همین اینجا عکس رو با اسم اصلیش نمی‌بینی. فایل journal هم دفتر مدیریت Cacheـه و Coil داخلش ثبت می‌کنه چه فایل‌هایی وجود دارن و آخرین بار چه زمانی استفاده شدن.

چرا هر اسم دو بار دیده میشه؟

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

137cae0f693d....0
137cae0f693d....1

یکی اطلاعات جانبی Cache و دیگری داده واقعی تصویر است.

چطور خود عکس Cacheشده رو ببینی؟

روی یکی از فایل‌های Hashشده کلیک کن و از نوار بالای Device Explorer روی آیکن دانلود بزن، یا راست‌کلیک کن و گزینه‌ای شبیه این رو انتخاب کن:

Save As
Export
Download

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

.png

مثلاً:

137cae0f693d...

تبدیل بشه به:

137cae0f693d....png

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

چیزی که تصاویرت ثابت می‌کنن

در تصویر اول، این فایل‌ها رو داریم:

databases
├── news_cache.db
├── news_cache.db-shm
└── news_cache.db-wal

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

cache
└── coil3_disk_cache
    ├── چند فایل Hashشده
    └── journal

یعنی خود تصاویر هم وارد Disk Cache مربوط به Coil شدن. پس پروژه الان هر دو بخش Cache رو درست انجام داده:

Room
→ متن خبر، نویسنده و URL تصویر

Coil Disk Cache
→ فایل واقعی تصاویر

در واقع ندیدن فایل‌هایی با اسم profile1.png طبیعی است؛ چون Coil عمداً اسم فایل‌های Cache رو تغییر می‌ده و به‌صورت داخلی مدیریتشون می‌کنه.