درس ۱
ساخت اولین اپلیکیشن واقعی با 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.HomeLoading یعنی برنامه هنوز در حال بررسی 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
خیلی خلاصه:
| SharedPreferences | DataStore |
|---|---|
| قدیمیتر | جدیدتر |
| ساده | حرفهایتر |
| بدون 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
) : InterceptorInterceptor قبل از ارسال هر 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
IOExceptionSocketTimeoutException یعنی سرور در زمان تعیینشده پاسخ نداده. 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 = 0Primary 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_idLEFT 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():
SyncResultoverride یعنی این تابع از قرارداد 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 =
falseconst یعنی این مقدار یک ثابت زمان کامپایل است. 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 Cache | Disk 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.imageLoaderImageLoader بخش اصلی 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 رو تغییر میده و بهصورت داخلی مدیریتشون میکنه.