🔝 الدليل الرئيسي: Kotlin وJetpack Compose في 2026
بناء UI Compose ثابت مُرضٍ ليومين. ثم يأتي الوقت الذي يجب فيه فصل منطق الأعمال عن العرض: نريد حالة تنجو من الدوران، استدعاءات غير متزامنة لا تُحطّم الشاشة، وكودًا قابلًا للاختبار مستقلًا عن UI. الإجابة القياسية في 2026 تُسمى ViewModel + StateFlow + collectAsStateWithLifecycle، وتُشكّل أساس كل هندسة Android حديثة. يُفصّل هذا الدرس الإعداد الكامل، من تبعيات Gradle إلى التدفّق أحادي الاتجاه القابل للمراقبة، مع مثال ملموس لقائمة مقالات مُحمَّلة من repository.
المتطلبات
- مشروع Compose وظيفي بشاشات بسيطة (راجع أول شاشات Compose)
- أساسيات coroutines Kotlin (suspend، scope، launch)
- Android Studio Otter 3، Kotlin 2.3.21، Compose BOM 2026.05.00
- الوقت المُقدَّر: 75 دقيقة
الخطوة 1 — لماذا ViewModel
الـ Activity والـ Composable ليسا مصمَّمين لاستضافة حالة الأعمال. Activity تُدمَّر وتُعاد إنشاؤها عند كل دوران. Composable يمكن أن يُعاد تركيبه عدة مرات في الثانية. إذا عاشت الحالة هناك، كل دوران يُعيد تنفيذ طلب الشبكة، وكل إعادة تركيب قد تُعيد إطلاقه. ViewModel يحل هاتين المشكلتين: ينجو من تغييرات الإعداد ويُوفّر viewModelScope coroutines يُلغى تلقائيًا عند الدمار النهائي.
ViewModel لا يعرف Android أبدًا. لا يستقبل Context، ولا يعتمد على Compose. يكشف حالة قابلة للمراقبة ويستقبل intents (نقرات، إدخالات) كأساليب. هذا الاستقلال يجعله قابلًا للاختبار بسهولة: نُنشئ ViewModel في اختبار JVM بحت، نستدعي الأساليب، نتحقق من الحالة المكشوفة.
الخطوة 2 — إضافة تبعيات Gradle
[versions]
lifecycleRuntimeKtx = "2.10.0"
lifecycleViewmodelCompose = "2.10.0"
[libraries]
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
في app/build.gradle.kts: implementation(libs.androidx.lifecycle.viewmodel.compose) وimplementation(libs.androidx.lifecycle.runtime.compose). الأولى تُوفّر viewModel()، الثانية تُوفّر collectAsStateWithLifecycle().
الخطوة 3 — نمذجة حالة UI ثابتة
data class ListeArticlesUiState(
val articles: List<Article> = emptyList(),
val isLoading: Boolean = false,
val erreur: String? = null
)
كل حقل له افتراضي، مما يجعل الإنشاء الأولي بسيطًا. ميزة data class الثابت: Compose يكتشف فوريًا أن كائنًا جديدًا وصل ويُعيد التركيب. لا حاجة لتعليقات @Stable صريحة. القاعدة الذهبية: StateFlow<UiState> واحد لكل شاشة.
الخطوة 4 — كتابة ViewModel بـ MutableStateFlow
class ListeArticlesViewModel(
private val repository: ArticlesRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ListeArticlesUiState(isLoading = true))
val uiState: StateFlow<ListeArticlesUiState> = _uiState.asStateFlow()
init {
charger()
}
fun charger() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, erreur = null) }
try {
val articles = repository.listerArticles()
_uiState.update {
it.copy(articles = articles, isLoading = false)
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, erreur = e.message ?: "Erreur inconnue")
}
}
}
}
}
خمس نقاط للفهم. private val _uiState = MutableStateFlow(...) يحتفظ بالكتابة على جانب ViewModel. val uiState: StateFlow<...> = _uiState.asStateFlow() يكشف للقراءة فقط. viewModelScope هو CoroutineScope مربوط بدورة حياة ViewModel؛ يُلغي coroutines عند الدمار. _uiState.update { it.copy(...) } يُطبّق تحديثًا ذريًا. الـ try/catch يلتقط الأخطاء.
الخطوة 5 — ربط ViewModel بـ Composable
@Composable
fun EcranListeArticles(
viewModel: ListeArticlesViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when {
uiState.isLoading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
uiState.erreur != null -> {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center
) {
Text("Une erreur est survenue : " + uiState.erreur)
Spacer(Modifier.height(12.dp))
Button(onClick = { viewModel.charger() }) { Text("Réessayer") }
}
}
else -> {
ListeArticles(articles = uiState.articles, onArticleClick = {})
}
}
}
الـ when الهرمي يخدم كآلة حالة تعريفية. عندما ينتقل ViewModel من isLoading=true إلى articles=[...]، composable يتبدّل تلقائيًا من spinner إلى القائمة. شاشة الخطأ تكشف زر « Réessayer » يستدعي viewModel.charger().
الخطوة 6 — إدارة الأحداث الفورية (Snackbar، تنقّل)
الحالة التفاعلية مناسبة لما يجب أن يكون مرئيًا في أي وقت. لكن للأحداث الفورية (عرض Snackbar « مقال محذوف »، التنقّل بعد نجاح)، StateFlow ليس مثاليًا. الحل الحديث: كشف Channel أو SharedFlow للأحداث one-shot. للشاشات البسيطة، حفظ قائمة Snackbars في UiState وتركها للـ composable ليُفرّغها بعد العرض يكفي.
data class UiState(
val articles: List<Article> = emptyList(),
val messages: List<String> = emptyList()
)
fun afficherMessage(message: String) {
_uiState.update { it.copy(messages = it.messages + message) }
}
fun messageAffiche(message: String) {
_uiState.update { it.copy(messages = it.messages - message) }
}
الخطوة 7 — حقن repository عبر المُنشئ
ViewModel يستقبل ArticlesRepository كمعامل مُنشئ. لكي يعرف viewModel() كيف يُنشئ ViewModel مع repo، خياران. الطريق القصير لمشروع صغير: ViewModelProvider.Factory صريح. الطريق الحديث المتين: Hilt مع @HiltViewModel.
@HiltViewModel
class ListeArticlesViewModel @Inject constructor(
private val repository: ArticlesRepository
) : ViewModel() {
// ... même code que précédemment
}
@Composable
fun EcranListeArticles(
viewModel: ListeArticlesViewModel = hiltViewModel()
) { /* ... */ }
Hilt يُولّد Factory تلقائيًا عند التجميع عبر KSP. أضف hilt-android، hilt-compiler (KSP)، hilt-navigation-compose، علّق Application بـ @HiltAndroidApp، وكل Activity بـ @AndroidEntryPoint.
أخطاء شائعة
| العَرَض | السبب | الحل |
|---|---|---|
| الحالة لا تُحدَّث في UI | قراءة عبر .value بدل collectAsStateWithLifecycle() |
دائمًا راقب Flow، لا تقرأ .value في composable |
| طلب شبكة يُعاد إطلاقه عند كل دوران | المنطق في composable بدل ViewModel | انقل إلى init أو viewModelScope.launch |
| تعطّل بعد الدمار | Coroutine في GlobalScope | دائمًا viewModelScope.launch |
| Snackbar يظهر مزدوجًا بعد الدوران | حدث مُخزَّن في boolean | استخدم قائمة messages أو Channel |
NoSuchMethodError viewModel() |
lifecycle-viewmodel-compose مفقود | أضف تبعية Gradle |
| UiState لا يُعرَف كمستقر | حقل List<X> قابل للتعديل |
استخدم persistentListOf أو List ثابت |
اختبار ViewModel
الفائدة الرئيسية للنمط: نختبر بدون Android. أنشئ اختبار JVM في src/test/java. احقن FakeArticlesRepository يُرجع قائمة ثابتة، أنشئ ViewModel، وراقب uiState عبر turbine.
@Test
fun chargementReussiExposeListeArticles() = runTest {
val fakeRepo = FakeArticlesRepository(listOf(Article(1, "Test", "Résumé")))
val viewModel = ListeArticlesViewModel(fakeRepo)
viewModel.uiState.test {
assertEquals(true, awaitItem().isLoading)
val final = awaitItem()
assertEquals(false, final.isLoading)
assertEquals(1, final.articles.size)
}
}
هذا الاختبار يعمل في أقل من 50 ms، دون محاكي، دون Compose. يمكن كتابة عشرات لتغطية كل الحالات.
الخطوة 8 — دمج عدة StateFlow بـ combine
private val filtre = MutableStateFlow<Categorie?>(null)
private val recherche = MutableStateFlow("")
private val articles = repository.observerArticles()
val uiState: StateFlow<ListeArticlesUiState> = combine(
articles, filtre, recherche
) { liste, cat, q ->
val filtrees = liste
.filter { cat == null || it.categorie == cat }
.filter { q.isBlank() || it.titre.contains(q, ignoreCase = true) }
ListeArticlesUiState(articles = filtrees, isLoading = false)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ListeArticlesUiState(isLoading = true)
)
الـ stateIn يُحوّل Flow بارد إلى StateFlow ساخن. WhileSubscribed(5000) يبقي Flow نشطًا 5 ثوانٍ بعد آخر مغادرة — حل وسط ينجو من الدوران القصير دون إعادة تحميل البيانات.
الأسئلة الشائعة
StateFlow أم LiveData؟
StateFlow منهجيًا لكل كود جديد. LiveData يبقى مفيدًا لصيانة قواعد قديمة.
ViewModel لكل شاشة أم لكل وظيفة؟
لكل شاشة افتراضيًا. إذا تشاركت عدة شاشات حالة (wizard متعدد المراحل)، ViewModel مشترك في نطاق NavGraph عبر hiltViewModel.
Hilt أم Koin؟
Hilt للمشاريع المهنية (تكامل Google رسمي، أمان وقت التجميع). Koin للمشاريع الصغيرة.
كيف نُشارك state بين composables بدون ViewModel؟
للحالة المحلية: state hoisting إلى الأب. للحالة العامة العابرة: CompositionLocal. عندما يوجد منطق أعمال، ViewModel.
StateFlow أم SharedFlow؟
StateFlow له دائمًا قيمة راهنة وconflate. SharedFlow مُصمَّم لأحداث عابرة، بدون قيمة أولية، يبث لعدة collectors. لـ UI state: StateFlow؛ للأحداث الفورية: SharedFlow أو Channel.
هل نحتاج ViewModel لشاشة بدون حالة؟
لا. شاشة ثابتة (صفحة « حول »، شاشة تحميل) لا تحتاج ViewModel. النمط يُطبَّق عند وجود منطق أعمال أو طلب غير متزامن.