قبل ساعتين
مقدمة
تطبيقات Android تواجه تحدياً خاصاً: الشاشة قد تُعاد إنشاؤها عند تدوير الجهاز أو تغيير اللغة، والنظام قد يوقف التطبيق في أي لحظة. إن وضعت المنطق وطلبات الشبكة داخل Activity مباشرة ستفقد البيانات وتكرر الطلبات وتصعب الاختبارات. المعمارية الموصى بها من Google تحل ذلك بفصل المسؤوليات.
أهداف التعلم
- فهم الطبقات: الواجهة (UI)، والنطاق (اختيارية)، والبيانات (Data).
- استخدام
ViewModelلحفظ حالة الشاشة. - تمثيل الحالة بـ
StateFlowوتدفق البيانات في اتجاه واحد (UDF). - معرفة دور المستودع (Repository).
1. الطبقات
UI (Compose / Views) ← حالة الشاشة ← ViewModel
↓ يستدعي
Repository (مصدر الحقيقة)
↙ ↘
مصدر شبكة (Retrofit) مصدر محلي (Room)
- الواجهة تعرض الحالة وترسل أحداث المستخدم فقط.
- ViewModel يحتفظ بحالة الشاشة وينجو من إعادة إنشائها.
- المستودع يقرر من أين تأتي البيانات (شبكة أم تخزين محلي) ويخفي ذلك عن الأعلى.
2. حالة الشاشة ككائن واحد
data class TopicsUiState(
val isLoading: Boolean = false,
val topics: List<Topic> = emptyList(),
val errorMessage: String? = null,
)
كائن واحد يصف الشاشة بالكامل يمنع الحالات المتناقضة (مثلاً: تحميل وخطأ في الوقت نفسه دون قصد).
3. ViewModel مع StateFlow
class TopicsViewModel(
private val repository: TopicsRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(TopicsUiState(isLoading = true))
val uiState: StateFlow<TopicsUiState> = _uiState.asStateFlow()
init { refresh() }
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
try {
val topics = repository.latestTopics()
_uiState.update { it.copy(isLoading = false, topics = topics) }
} catch (e: IOException) {
_uiState.update { it.copy(isLoading = false, errorMessage = "تعذر الاتصال. تحقق من الشبكة.") }
}
}
}
}
viewModelScopeيلغي العمليات تلقائياً عند انتهاء الـ ViewModel، فلا تسريب.- الواجهة تقرأ
uiStateفقط ولا تستطيع تعديله (الاتجاه الواحد).
4. الواجهة بـ Jetpack Compose
@Composable
fun TopicsScreen(viewModel: TopicsViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
when {
state.isLoading -> CircularProgressIndicator()
state.errorMessage != null -> ErrorView(state.errorMessage!!, onRetry = viewModel::refresh)
else -> LazyColumn {
items(state.topics, key = { it.id }) { TopicRow(it) }
}
}
}
collectAsStateWithLifecycle يوقف الجمع عندما يكون التطبيق في الخلفية فيوفر البطارية.
5. المستودع ومصدر الحقيقة
class TopicsRepository(
private val api: ArabCodeApi,
private val dao: TopicDao,
) {
suspend fun latestTopics(): List<Topic> {
val remote = api.latestTopics()
dao.upsertAll(remote) // قاعدة البيانات المحلية مصدر الحقيقة
return dao.latest(limit = 50)
}
}
جعل التخزين المحلي مصدر الحقيقة يسمح بالعرض دون اتصال وتحديث الشاشة عند وصول بيانات جديدة.
تمرين عملي
- أنشئ مشروع Compose وأضف
TopicsViewModelمع مستودع وهمي يعيد قائمة ثابتة بعد تأخيرdelay(1000). - دوّر الجهاز أثناء التحميل وتأكد أن الطلب لا يتكرر.
- اكتب اختباراً للـ ViewModel باستخدام مستودع مزيف يرمي
IOExceptionوتأكد من ظهور رسالة الخطأ.
أخطاء شائعة
- تمرير
ContextأوActivityإلى الـ ViewModel (تسريب ذاكرة). - كشف
MutableStateFlowللواجهة بدلStateFlowللقراءة فقط. - إطلاق طلبات الشبكة من الواجهة مباشرة عند كل إعادة رسم.
- التقاط كل الاستثناءات (
Exception) فتخفي أخطاء برمجية حقيقية.
الخلاصة
افصل الواجهة عن المنطق عن البيانات. اجعل ViewModel يحتفظ بحالة واحدة تُعرض في اتجاه واحد، والمستودع يقرر مصدر البيانات. ستحصل على تطبيق يتحمل تدوير الشاشة ويسهل اختباره.
مصادر مقترحة
- Android Developers: «Guide to app architecture».
- دورة «Android Basics with Compose» من Google.
- توثيق Kotlin Coroutines وFlow.