معماری MVI یا همون Model View Intent اخیرا توی اندروید خیلی مورد توجه واقع شده و به نظرم همراه با MVVM کارایی خوبی داره. توی این مقاله که اولین تجربه نوشتن من در ویرگول هست میخواهم خیلی مختصر و مفید راجع به پیاده سازی این معماری از طریق Coroutines و Flow صحبت کنم.
این معماری شامل سه بخش هست : Model — View — Intent.
دو مفهوم جدید توسط این معماری داره معرفی میشه. یکی Intent و دیگری State
تمام اتفاقات یا ایونت هایی که از سمت view برای انجام تسک خاصی به سمت viewModel فرستاده میشه، intent هستند. تعاملاتی که کاربر با رابط کاربری داره( مثلا کلیک کردن یک دکمه یا انتخاب یک آیتم از لیست، همه این ها میتونه یک Intent باشه).
اما state ها به چه مفهومی هستند؟ UI برنامه ما ممکنه حالت های مختلفی داشته باشه. مثلا حالت لودینگ، بیکار، لود شده، خطا و... در واقع میشه گفت model ها توی این معماری، state های مارو نگه داری و تولید میکنند.
هر بار که intent جدیدی از سمت view به viewModel بیاد، بعد از عملیات مختلفی که روش صورت میگیره(مثلا خوندن دیتا از لایه Repository) ، یک view state جدید برای UI ما ساخته میشه. تو این معماری بخش view برنامه ما همیشه در حال گوش دادن به state داخل viewModel هست و هر وقت که تغییری در اون ایجاد بشه، view مطابق اون تغییرات، آپدیت میشه.
اگه ما به تصویر بالا نگاه کنیم میفهمیم که تمام جریان دیتای ما همیشه از سمت کاربر شروع میشه و سرانجام به خود کاربر هم ختم میشه. هیچ راهی به غیر از این راه برای |پدیت UI نیست. این چرخه به نوعی همون unidirectional data flow یا به اختصار UDF هست.
معماری MVI میتونه توسط هر کتابخونه یا فریمورک reactive پیاده سازی بشه. اما تو این مقاله من میخوام از Kotlin coroutines استفاده کنم. ابزار مهمی که این کتابخونه برای استفاده از این معماری در اختیار ما قرار داده، StateFlow نام داره.
برای شروع فرض کنیم UI برنامه ما تنها یک دکمه ساده داره که با کلیک کردن روی اون، لیست غذاهای مختلف یک رستوران، لود میشه و در نهایت توسط یک RecyclerView نمایش داده میشه. از اون جایی که نمیخوام خیلی با کدهای مختلف حواسمون پرت بشه و تمرکز روی mvi بمونه، من فقط به دو قسمتی که مربوط به mvi است میپردازم و وارد کد های لایههای domain و repository نمیشم.
??♂️ من کدها رو روی gist گذاشتم. اما بعضی اوقات میبینم ویرگول ادای حال بدارو درمیاره و کدهارو لود نمیکنه، اگه کد کامل رو میخواهید انتهای پروژه لینک repository گذاشتم.
همون طور که گفتیم تمام اتفاقات و تعاملاتی که کاربر با UI برقرار میکنه، در نهایت به صورت یک intent ظاهر میشن. ما تمام intent های ارسالی به viewModel رو از طریق Flow ارسال میکنیم. اما تعاملات کاربر رو چه طوری تبدیل به flow کنیم؟ یکی از راه ها استفاده از کتابخونه FlowBinding هست. چون تو این مقاله ما فقط یک دکمه داریم و نمیخوایم کار خیلی خاصی انجام بدیم، میتونیم از این کتابخونه استفاده نکنیم و به راحتی با نوشتن یک extension function، کلیک های کاربر رو تبدیل به یک flow کنیم. قطعه کد زیر رو میتونید هرجایی که دوست دارید بنویسید و بعد از اون برای هر دکمه ای استفاده کنید:
ما به یک کلاس Intent هم احتیاج داریم که بتونیم flow مورد نظرمون رو از طریقش بسازیم. برای این کار از sealed class ها استفاده میکنیم.کلاس ViewIntent رو ایجاد میکنیم و یک آبجکت به اسم GetData داخلش میسازیم. اینجا کاربر ما فقط یک تعامل با صفحه داره و اون هم درخواست برای گرفتن اطلاعات هست. اگر نیاز داریم که تعاملات بیشتری رو تو صفحه ایجاد کنیم، آبجکت های دیگه ای رو اضافه میکنیم. مثلا برای رفرش کردن صفحه یا تلاش مجدد در صورت ایجاد خط، میتونیم آبجکت های Refresh و Retry رو ایجاد کنیم.
حالا که ViewIntent رو ساختیم، متد ()intents رو میسازیم که خروجی اون Flow ایی از جنس ViewIntent که ساختیم هست. داخل این متد تمام تعاملات کاربر(در اینجا کلیک کردن دکمه و ایجاد Flow ای از جنس این کلیک ها) رو پیاده سازی میکنیم. این متد رو میتونیم داخل onCreate اکتیویتی خودمون یا onViewCreated فرگمنت یا هرجای دیگه از چرخه حیات view مورد نظرمون، از طریق coroutines صدا بزنیم. از اون جایی که تابع intents خروجی از جنس Flow داره، می تونیم هر بار که داده جدیدی ( اینجا View Intent) به جریان میوفته، عملیات مختلفی انجام بدیم. اینجا ما میخواهیم که این Intent ها هر کدوم سمت ViewModel ارسال بشوند تا State جدید برای View ما فراهم کنند. پس داخل onEach، متد processIntents از ViewModel رو صدا میزنیم که این Intent ها رو دریافت کنه، که در ادامه بهش میپردازم.
گفتیم که متد ()processIntents، اینتنت های ارسالی از سمت view رو دریافت میکنه، اما چه جوری؟ برای این کار داخل viewModel، یک MutableSharedFlow ایجاد میکنیم ( در واقع Flow ای از جنس ViewIntent). Intent های ارسالی از سمت View وارد این Flow میشن تا در نهایت یک viewState جدید برای ما ساخته بشه. ما علاوه بر این SharedFlow به یک StateFlow هم احتیاج داریم تا بتونیم تغییراتی که توی ViewState ها ایجاد میشه رو متوجه بشیم. فعلا فقط ایجادش کردیم و انتهای این بخش میبینیم کجا استفاده میشه.
ما به دنبال این بودیم که دیتای مورد نظرمون(در اینجا لیست غذاها) رو توی View خودمون نمایش بدیم. این دیتا میتونه از Remote Data Source ما بیاد یا حتی ممکنه از Local خونده بشه. تو این مقاله این موضوع برای ما مهم نیست و ما فرض میکنیم یک UseCase داریم که دیتای مورد نظر ما رو از Repository فراهم میکنه. از چه طریقی این دیتای به دست آمده از لایه Repository رو تبدیل به View State کنیم؟ برای این کار باید توی میانه راه، Flow ای که از جنس Intent بود رو تبدیل به Flow ای از جنس PartialState کنیم
همون طور که توی قطعه کد بالا میبینید، PartialStateChanges چیزی جز یک Sealed class نیست. داخل این کلاس یک کلاس GetData دیگه ایجاد کردیم که خودش 3 حالت داره. این 3 حالت رو به چه منظوری ایجاد کردیم؟ به ازای تمام اتفاقاتی که از زمان صدا زدن UseCase اتفاق میافتد، باید یک حالت ایجاد کنیم. گفتیم که باید جنس Flow رو توی مسیر عوض کنیم. توی قطعه کد بالا متد toPartialStateChanges که اکستنشن فانکشنی برای Flow است رو ایجاد کردیم تا داخلش UseCase رو صدا بزنیم و حالت های مختلفی که داخل PartialStateChanges ایجاد کردیم رو بسازیم. UseCase مد نظر ما خروجی از جنس Flow داره. در ابتدای شروع این Flow، حالت Loading رو emitکردیم. چون عملیات گرفتن دیتا ممکنه زمان بر باشه و view باید بدونه تو چه وضعیتی هست و چه المنت هایی رو به کاربر نمایش بده. اگر عملیات گرفتن دیتا از لایه Repository دچار خطا بشه ما حالت Failure رو emit میکنیم و در نهایت اگر عملیات موفقیت آمیز باشه، Success رو از طریق دیتای به دست آمده( تو اینجا Food) ایجاد میکنیم. یک تابع دیگه هم داخل PartialStateChanges داریم میبینیم. تابع reduce کارش تبدیل state قدیم view به state جدید هست. اما متد toPartialStateChanges رو کجا استفاده کنیم؟
متد processIntents وظیفه اش این بود Intent ها رو دریافت کنه و به intentFlow_ بسپره. مطابق قطعه کد بالا، داخل بلاک init که viewModel رو مقدار دهی میکنه، با صدا زدن toPartialStateChange، جنس Flow رو از intent تبدیل به PartialStateChanges میکنیم. اما برای اینکه ما با هر بار عبور دیتا از این مسیر، viewState مناسب رو ایجاد کنیم، از scan استفاده میکنیم. scan یک مقدار اولیه برای viewState احتیاج داره که این کار رو با initialVS انجام میدیم. تابع scan برای ما یک accumulator فراهم میکنه که میتونه وضعیت قبلی viewState رو برای ما نگه داره. هر بار که دیتای جدیدی از این مسیر بخواد عبور کنه وارد اسکن میشه، این دیتا همون PartialStateChange ما هست. داخل scan از طریق تابع reduce که قبل تر داخل PartialStateChange ساختیم، حالت قبلی view رو آپدیت میکنیم( چون حالت قبلیش رو accumulator برامون نگه داشته، فقط تغییرات دلخواهمون رو اعمال میکنیم. همون طور که قبل تر داخل تابع reduce دیدید، حالت قبلی رو copy میگرفت و فقط قسمت هایی که میخواست رو تغییر میداد). اما تابع stateIn چی کار میکنه؟
* Converts a _cold_ [Flow] into a _hot_ [StateFlow] that is started in the given coroutine [scope]
مطابق داکیومنت coroutines برای تبدیل Flow به StateFlow از این تابع استفاده میکنیم. حالا تا این مرحله ما داخل stateFlow یک مقدار از viewState رو داریم و از داخل view میتونیم بهش گوش بدیم، StateFlow
تقریبا مثه liveData میمونه، هر بار که مقدارش تغییر کنه میتونیم از داخل view به این تغییر توجه کنیم. حالا میتونیم کل ViewModel رو یک بار با هم ببینیم:
حالا که میتونیم به viewState گوش بدیم، دوباره برمیگردیم و کد view رو تکمیل میکنیم:
توی کد بالا مشخصه که داریم به viewState گوش میدیم. هربار که حالت جدیدی برای view ایجاد بشه، تابع updateUI رو صدا میزنیم تا UI رو برامون آپدیت کنه. اینجا دیگه کاملا سلیقه ای هست و باید مطابق UI ای که طراحی شده رفتار کنیم. برای مثال دیتای داخل viewState رو بریزیم داخل آداپتر تا لیستمون نمایش داده بشه یا اینکه منتظریم دیتا لود شه و باید progressBar برامون نمایش داده باشه یا اینکه عملیات گرفتن دیتا به هر دلیلی دچار مشکل شده و باید خطا رو مدیریت کنیم.
امیدوارم این نوشته اندکی کمک کرده باشه تا با این معماری آشنا بشین و نواقص آماتوری بودن بنده رو ببخشید :)
لینک پروژه کامل: