OOZOU
تواصلوا معنا
العودة إلى المدونة

معمارية MVI في Android مع Data Binding

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

22 ديسمبر 2020
7 دقائق للقراءة
معمارية MVI في Android مع Data Binding

مقدمة

يبدو أن المعمارية أمر يهتم به كثيرون منا في عالم 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.

Related Articles

كيف أصمّم برمجيات؟ دليل المبتدئين لأصحاب الأعمال

كيف أصمّم برمجيات؟ دليل المبتدئين لأصحاب الأعمال

تصميم البرمجيات عملية ممتعة لكنها معقدة، تتطلب الإبداع والتفكير المنطقي ومهارات حل المشكلات.

اقرأوا المزيد
مستقبل الحوسبة الطرفية: ما تحتاج الشركات إلى معرفته

مستقبل الحوسبة الطرفية: ما تحتاج الشركات إلى معرفته

يتطور المشهد الرقمي بسرعة، وعلى الشركات أن تتكيّف لتواكب الطلب المتزايد على السرعة والكفاءة والمعالجة في الوقت الفعلي.

اقرأوا المزيد
مستقبل قطاع التجزئة: الذكاء الاصطناعي والواقع المعزّز واتجاهات التحوّل الرقمي

مستقبل قطاع التجزئة: الذكاء الاصطناعي والواقع المعزّز واتجاهات التحوّل الرقمي

يتطور قطاع التجزئة بوتيرة غير مسبوقة بفضل الذكاء الاصطناعي (AI) والواقع المعزّز (AR) وتقنيات التحوّل الرقمي. فلم تعد المتاجر التقليدية مجرد أماكن لبيع المنتجات، بل أصبحت متاجر ذكية وتفاعلية.

اقرأوا المزيد

Have a Project in Mind?

Let's discuss how we can help bring your ideas to life with our expertise in web and mobile development.

ابدأوا المحادثةhello@oozou.com
OOZOU Logo

بانكوك · سنغافورة · هونغ كونغ

X
Facebook
Instagram
LinkedIn
OOZOU Co., Ltd.
548 One City Centre
Ploenchit Road
Bangkok 10330
Thailand
+66 2544 0000
hello@oozou.com

الخدمات

  • تطوير الويب
  • وكلاء الذكاء الاصطناعي والذكاء الاصطناعي التوليدي
  • تطوير تطبيقات الجوال
  • تحليلات البيانات وهندستها
  • تصميم تجربة المستخدم وواجهته (UX/UI) وتصميم المنتجات
  • التحوّل الرقمي

الشركة

  • من نحن
  • الوظائف
  • المدونة
  • دراسات الحالة
  • عروض حية
  • تعلّمت اليوم
  • تواصل معنا

المزيد

  • القطاعات
  • شبكة الشركاء
© 2026 OOZOU. جميع الحقوق محفوظة.
سياسة الخصوصيةمدونة قواعد السلوكسياسة مكافحة الرشوة والفساد