معمارية MVI في Android مع Data Binding
تعرّفوا إلى نمط التصميم Model-View-Intent، أحدث المعماريات في عالم Android. واكتشفوا كيف يمكن لهذا الإطار المستوحى من cycle.js أن يصبح إضافة قيّمة إلى أدواتكم البرمجية.

مقدمة
يبدو أن المعمارية أمر يهتم به كثيرون منا في عالم Android اهتمامًا بالغًا. ولكل مهندس تفضيلاته بين الأنماط المعمارية المتعددة، مثل MVC (Model-View-Controller) وMVVM (Model-View-ViewModel) وMVI (Model-View-Intent). لكلٍّ منا ما يفضّله بين هذه الثلاثة، ولن أدخل في جدال حول مزايا أحدها على الآخر. ما أريده هو أن أشرح كيف نستخدم معمارية MVI بكفاءة مع data binding.
مفاهيم معمارية MVI
ViewState: جزء من طبقة النموذج (model) في MVI، وتراقب الواجهة (view) هذا النموذج لرصد تغيّرات الحالة. يجب أن يمثّل الحالة الحالية للواجهة في أي لحظة، وأن يكون غير قابل للتغيير (immutable). وفي كل مرة تحتاج فيها الواجهة إلى التحديث نتيجة إجراء من المستخدم، ننشر نسخة معدّلة من ViewState السابقة. وتعتمد ViewState في نتيجتها على MutableLiveData.ViewEffect: جزء آخر من طبقة النموذج في MVI. في تطوير Android توجد إجراءات معيّنة لا نريد أن تُعاد نتائجها كلما أُعيد إنشاء الواجهة، وغالبًا ما يحدث ذلك بسبب تغيّر في الإعدادات (configuration change). من أمثلتها الانتقال إلى شاشة أخرى، أو عرض toast أو dialog. لذلك تراقب الواجهة ViewEffects المبنية على SingleLiveEvent للإجراءات التي تُنفَّذ مرة واحدة دون انتظار نتيجة (fire and forget).Intent: يمثّل جميع إجراءات المستخدم وأحداثه على الواجهة، مثل حدث النقر أو السحب وغيرهما. تُمرَّر هذه الأحداث إلى ViewModel بوصفها نية المستخدم. باختصار، تستقبل ViewModel المدخلات عبر intents، وتُخرج النتائج عبر ViewState وViewEffect.
المكوّنات الأساسية
لنجهّز المكوّنات الأساسية حتى نعيد استخدامها في كل أنحاء قاعدة الكود. أولًا، ننشئ حالة بيانات على شكل sealed class تعمل غلافًا لجميع حالات الواجهة لدينا
//T represents our ViewState
sealed class DataState<out T> {
data class Success<T>(val data: T?) : DataState<T>()
data class Error<T>(val data: T?, val exception: Exception) : DataState<T>()
data class Loading<T>(val data: T?) : DataState<T>()
//Helper methods that make it easy to access the sealed class contents in xml data binding
fun toData(): T? = when (this) {
is Success -> this.data
is Error -> this.data
is Loading -> this.data
}
fun isLoading(): Boolean? = if (this is Loading) true else null
fun toErrorMessage(): String? = if (this is Error) this.exception.message else null
}
ثانيًا، ننشئ BaseViewModel لتكون العمود الفقري الذي يجمع كل مكوّنات MVI معًا
//T represents our ViewState
//U represents our ViewEffect
abstract class BaseViewModel<T, U> : ViewModel() {
//It will be accessed by child classes to update the view states
protected val _dataStates: MutableLiveData<DataState<T>> = MutableLiveData()
//Will be accessed by the view to observe view state updates
val dataStates: LiveData<DataState<T>> = _dataStates
//It will be accessed by child classes to update the view effects
protected val _viewEffects: SingleLiveEvent<U> = SingleLiveEvent()
//It will be accessed by the view to observe view effects updates
val viewEffects: SingleLiveEvent<U> = _viewEffects
//This is a wrapper for making network/room calls (use the latest version of room and retrofit that supports suspend functions)
//This way we don't have to manually set our error and loading states in our child view models.
protected fun launchRequest(block: suspend () -> Unit): Job {
val currentViewState = getViewState()
return <em>viewModelScope</em>.<em>launch </em><strong>{
</strong>try {
_dataStates.postValue(DataState.Loading(currentViewState))
block()
} catch (exception: Exception) {
_dataStates.postValue(DataState.Error(currentViewState, exception))
}
<strong>}
</strong>}
protected fun getViewState() : T? = _dataStates.<em>value</em>?.toData()
}
وهذا مقتطف من SingleLiveEvent التي تسمح لنا بنشر تحديث في liveData مرة واحدة فقط، حتى عند حدوث تغيّر في الإعدادات.
class SingleLiveEvent<T> : MutableLiveData<T>() {
private val mPending = AtomicBoolean(false)
@MainThread
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
if (hasActiveObservers()) {
Log.w(TAG, "Multiple observers registered but only one will be notified of changes.")
}
// Observe the internal MutableLiveData
super.observe(owner, <em>Observer </em><strong>{ </strong>t <strong>->
</strong>if (mPending.compareAndSet(true, false)) {
observer.onChanged(t)
}
<strong>}</strong>)
}
@MainThread
override fun setValue(t: T?) {
mPending.set(true)
super.setValue(t)
}
<em>/**
* Used for cases where T is Void, to make calls cleaner.
*/
</em>@MainThread
fun call() {
<em>value </em>= null
}
operator fun invoke() {
call()
}
operator fun invoke(data: T) {
<em>value </em>= data
}
companion object {
private val TAG = "SingleLiveEvent"
}
}
ما التالي؟
بعد أن أصبحت المكوّنات الأساسية جاهزة، لنفترض أننا نبني واجهة بسيطة فيها 3 أزرار وtextview وrecyclerView. على المستخدم أن ينقر زرًا لعرض قائمة معاملات المحفظة، وأن ينقر زرًا آخر لعرض رصيد المحفظة، ونريده أن ينتقل إلى الشاشة التالية بعد النقر على زر "التالي". لنفترض أن WalletListItem تبدو هكذا:
data class WalletListItem (
val amount: String,
val type: String
)
ولنفترض أن خدمة retrofit تبدو تقريبًا هكذا:
interface WalletService {
@GET
suspend fun getWalletAmount(): String
@GET
suspend fun getWalletList(): List<WalletListItem>
}
سأتجاوز كل الطبقات الأخرى التي قد تحتاجون إلى إضافتها في مسار العمل، مثل use cases وrepositories. يمكننا بعد ذلك تعريف نية المستخدم على هذا النحو:
sealed class WalletIntent {
object GetWalletListEvent(): WalletIntent()
object GetWalletAmountEvent(): WalletIntent()
object OnNextEvent(): WalletIntent()
}
وستبدو ViewState هكذا:
data class WalletViewState(
val walletItems: List<WalletListItem>? = null,
val walletAmount: String? = null
)
وستبدو ViewEffect هكذا:
sealed class WalletViewEffect {
object NavigateToNextScreen : WalletViewEffect()
}
ثم ستبدو ViewModel هكذا:
class WalletViewModel constructor(private val walletService: WalletService) : BaseViewModel<WalletViewState, WalletViewEffect>() {
init { //set default for the view state
_dataStates.postValue(DataState.Success(WalletViewState()))
}
//User related actions will trigger this method
fun setIntent(intent: WalletIntent) {
when (intent) {
is WalletIntent.GetWalletListEvent -> getWalletList()
is WalletIntent.GetWalletAmountEvent -> getWalletAmount()
is WalletIntent.OnNextEvent -> navigateToNextScreen()
}
}
private fun getWalletList() {
val currentViewState = getViewState()
launchRequest <strong>{
</strong>val walletItems = walletService.getWalletList()
val newViewState = currentViewState?.copy(walletItems = walletItems)
_dataStates.postValue(DataState.Success(newViewState))
<strong>}
</strong>}
private fun getWalletAmount() {
val currentViewState = getViewState()
launchRequest <strong>{
</strong>val walletAmount = walletService.getWalletAmount()
val newViewState = currentViewState?.copy(walletAmount = walletAmount)
_dataStates.postValue(DataState.Success(newViewState))
<strong>}
</strong>}
private fun navigateToNextScreen() {
_viewEffects.postValue(WalletViewEffect.<em>NavigateToNextScreen</em>)
}
}
وسيبدو ملف XML هكذا:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<variable
name="viewModel"
type="com.example.WalletViewModel" />
<strong><variable
name="onGetWalletAmountEvent"
type="com.example.WalletIntent.GetWalletAmountEvent" />
<variable
name="onGetWalletListEvent"
type="com.example.WalletIntent.GetWalletListEvent" />
<variable
name="onNextEvent"
type="com.example.WalletIntent.OnNextEvent" /></strong>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.WalletFragment">
<Button
android:id="@+id/button_get_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="28dp"
android:layout_marginTop="40dp"
<strong> android:onClick="@{() -> viewModel.setIntent(onGetWalletAmountEvent)}"
</strong> android:text="Get Amount"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/amountTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="56dp"
<strong> android:text="@{viewModel.dataStates.toData().walletAmount}"
</strong> app:layout_constraintStart_toEndOf="@+id/button_get_amount"
app:layout_constraintTop_toTopOf="@+id/button_get_amount" />
<Button
android:id="@+id/button_get_wallet_list"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="64dp"
<strong> android:onClick="@{() -> viewModel.setIntent(onGetWalletListEvent)}"
</strong> android:text="Get Wallet List"
app:layout_constraintStart_toStartOf="@+id/button_get_amount"
app:layout_constraintTop_toBottomOf="@+id/button_get_amount" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
<strong> app:data="@{viewModel.dataStates.toData().walletItems}"
</strong> app:layout_constraintStart_toStartOf="@+id/button_get_wallet_list"
app:layout_constraintTop_toBottomOf="@+id/button_get_wallet_list" />
<Button
android:id="@+id/button_next"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="128dp"
<strong> android:onClick="@{() -> viewModel.setIntent(onNextEvent)}"
</strong> android:text="Next"
app:layout_constraintStart_toStartOf="@+id/recyclerView"
app:layout_constraintTop_toBottomOf="@+id/recyclerView" />
<TextView
android:id="@+id/label_error"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
<strong> android:text="@{viewModel.dataStates.toErrorMessage()}"
</strong> app:layout_constraintStart_toStartOf="@+id/button_next"
app:layout_constraintTop_toBottomOf="@+id/button_next" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
<strong> android:visibility="@{viewModel.dataStates.isLoading() ? View.VISIBLE : View.GONE}"
</strong> app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
وأخيرًا، سيبدو الـ fragment هكذا:
class WalletFragment : Fragment() {
private val viewModel: WalletViewModel by viewModels()
private lateinit var binding: WalletFragmentBinding
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = WalletFragmentBinding.inflate(inflater, container, false)
binding.<em>lifecycleOwner </em>= <em>viewLifecycleOwner
</em>binding.<em>viewModel </em>= viewModel
<strong>binding.</strong><strong><em>onGetWalletAmountEvent </em></strong><strong>= WalletIntent.</strong><strong><em>GetWalletAmountEvent
</em></strong><strong>binding.</strong><strong><em>onGetWalletListEvent </em></strong><strong>= WalletIntent.</strong><strong><em>GetWalletListEvent
</em></strong><strong>binding.</strong><strong><em>onNextEvent </em></strong><strong>= WalletIntent.</strong><strong><em>OnNextEvent</em></strong><em>
</em>return binding.<em>root
</em>}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
<strong> viewModel.</strong><strong><em>viewEffects</em></strong><strong>.observe(</strong><strong><em>viewLifecycleOwner</em></strong><strong>, Observer { onViewEffectReceived(it) })
</strong> //If you want you can decide to handle your view state changes by simply observing it
<strong> viewModel.</strong><strong><em>dataStates</em></strong><strong>.observe(</strong><strong><em>viewLifecycleOwner</em></strong><strong>, Observer { onDataStateReceived(it) })
</strong> }
private fun onViewEffectReceived(it: WalletViewEffect?) {
when (it) {
is WalletViewEffect.NavigateToNextScreen -> navigateToNextScreen()
}
}
private fun onDataStateReceived(it: DataState<WalletViewState>) {
when (it) {
is DataState.Error -> { }
is DataState.Loading -> { }
is DataState.Success -> { }
}
}
private fun navigateToNextScreen() {}
}
الخلاصة
جرّبوا الكود أعلاه، وحاولوا فعلًا بناء شيء صغير به حتى تروا الفكرة تتحقق أمامكم. وإذا كنتم تحبون العمل في بيئة تهتم بمعمارية البرمجيات وأفضل الممارسات، ففكّروا في الانضمام إلى فريقنا في Oozou.


