پیاده سازی معماری clean و mvvm در کاتلین

پیاده سازی معماری clean  و mvvm در کاتلین
فهرست مقاله [نمایش]

    در این مقاله به جزئیات نحوه اجرای این الگو در یک برنامه نمونه می پردازیم. برنامه نمونه RestaurantDelivery نام دارد.که از API DoorDash برای داده ها و نمایش لیستی از رستوران های اطراف استفاده می کند.

     

    معماری  clean  چه چیزی به ما میدهد

    • نگهداری آسان کدها و خوانایی بالاتر کدها
    • درک مشترک برنامه نویسان در مورد چگونگی ساختن فیچر ها ، ساده تر کردن بررسی کد
    • کد های قابل تست. تزریق وابستگی به ما این امکان را میدهد که به راحتی با mock  دیتا ها کدها را تست کنیم و unit test  را بنویسیم

     

    برای پیاده سازی معماری clean  ما باید 3 ماژول اصلی را بسازیم data  و domain 

     وapplication  البته این ها ماژول های اصلی هستندو شمابر اساس نیاز میتوانید ماژول های دیگری را هم به پروژه اضافه کنید

     

     یک بررسی کلی از پروژه داشته باشیم

    لایه Data  : در این لایه api ها Database و cache  را پیاده سازی میکنیم.

    لایه Domain  : منطق پروژه در این لایه پیاده سازی میشود این لایه UseCase ها و Model هار را نگه داری میکند.

    لایه Application : تمام اجزای اندروید در این لایه پیاده سازی میشوند Component ها پترن ها (MVVM)  و...

     

     

    پیاده سازی

    هنگام پیاده سازی یک فیچر جدید بهتراست که برای شروع از لایه Data شروع کنیم بعد از آن لایه Domain ودر آخر لایه Application در این آموزش به همین شکل پیاده سازی را انجام میدهیم

     

    لایه Data

    در این لایه api  ها Reposotory ها کلاس های data(dataclassobject) و

    mapper ها را پیاده سازی کرده ایم

     

    InterfaceApi را پیاده سازی میکنیم

    interface RestaurantEndpoint {
    
        @GET("restaurant/")
        fun getRestaurantList(@Query("lat") lat: Double, @Query("lng") lng: Double, @Query("offset") offset: Int, @Query("limit") limit: Int): Single<Response<List<RestaurantResponse>>>
    
        @GET("restaurant/{id}")
        fun getRestaurant(@Path("id") id: Int): Single<Response<RestaurantResponse>>
    
    }
    

    کلاس Api  را به صورت زیر مینویسیم

    class RestaurantApi @Inject constructor(private val restaurantEndpoint: RestaurantEndpoint) {
    
        fun getRestaurantList(lat: Double, lng: Double, offset: Int, limit: Int): Single<List<RestaurantResponse>> {
            return restaurantEndpoint.getRestaurantList(lat, lng, offset, limit)
                .mapResponse()
        }
    
        fun getRestaurant(id: Int): Single<RestaurantResponse> {
            return restaurantEndpoint.getRestaurant(id)
                .mapResponse()
        }
    
    }
    

    کلاس RestaurantApi فقط مسئول برقراری ارتباط با اینترفیس RestaurantEndpoint و هندل کردن خطاهای response است. mapResponse () یک Kotlin extension است که خطا را هندل می کند یا response body را برمی گرداند.

    در گام بعدی به یک interface   نیاز داریم که این داده هارا بازیابی کند پس ما لایه Domain  را به لایه Data  ارتباط میدهیم.

    ما با ایجاد یک interface  در لایه Domain  و implement آن در لایه Data این کار را انجام میدهیم.

    ابتدا در لایه Domain یک interface  را با نام RestaurantRepository میسازیم.

    نکته: نامگذاری در اینجا لزوماً با کلاس های api و endpoint یک به یک نیست. Repository معمولاً بیشتر generic است زیرا می تواند با چندین API ارتباط برقرار کند. سعی کنید توابع موجود در هر Repository را به طور مناسب برای برنامه خود گروه بندی کنید. مثال: AccountRepository می تواند نام یک Repository ما برای به دست آوردن داده های مربوط به حساب کاربری باشد.

    interface RestaurantRepository {
    
        fun getRestaurantList(lat: Double, lng: Double, offset: Int, limit: Int): Single<List<Restaurant>>
    
        fun getRestaurant(id: Int): Single<Restaurant>
    
    }
    

    به لایه Data  برمیگردیم و این Repository  را پیاده سازی میکنیم

    class RestaurantRepositoryImpl(
        private val restaurantApi: RestaurantApi,
        private val restaurantMapper: RestaurantMapper
    ) : RestaurantRepository {
    
        override fun getRestaurantList(lat: Double, lng: Double, offset: Int, limit: Int): Single<List<Restaurant>> {
            return restaurantApi.getRestaurantList(lat, lng, offset, limit)
                .map { restaurantMapper.map(it) }
        }
    
        override fun getRestaurant(id: Int): Single<Restaurant> {
            return restaurantApi.getRestaurant(id)
                .map { restaurantMapper.map(it) }
        }
    }
    

    این یک مثال ساده است اما ما در اینجا درحال بازیابی داده ها و سپس mapp  کردن آن قبل از ارسال به Domain هستیم. اگر ما بخواهیم که یک سری از داده ها به صورت local ذخیره شوند این Repository  دارای کلاس های Dao  یا هرآنچه که برای ارتباط باDB خود استفاده میکنید میباشد. در اینجا ما قبل از انتقال داده ها به یک شئ db میپردازیم.

    restaurantMapper یک کلاس ساده

    با دو متد به نام map است که یکی از متد ها اطالاعات یک رستوران را برمیگرداند و دیگری لیستی از رستوران ها را برمگیرداند.

     

    ادامه پیاده سازی لایه Doamin

    این لایه منحصر به فرد است چون که یک کتابخانه جاوا خالص است و از وجود لایه های دیگر بی خبر است.این امر ذاتا قابل آزمایش است.

    در این لایه Model ها UseCase وRepository interface  را پیاده سازی میکنیم.

    در بالا تر RestaurantRepository را در این لایه نوشتیم

    حالا ما باید یک UseCase ایجاد کنیم که با آن صحبت کند. UseCase یک کلاس تک منظوره است که یک متد public  دارد این کلاس لیست رستوران ها را به ما برمیگرداند.

    class GetRestaurantListUseCase @Inject constructor(private val restaurantRepository: RestaurantRepository) {
    
        sealed class Result {
            object Loading : Result()
            data class Success(val restaurants: List<Restaurant>) : Result()
            data class Failure(val throwable: Throwable) : Result()
        }
    
        fun execute(lat: Double, lng: Double, offset: Int, limit: Int): Observable<Result> {
            return restaurantRepository.getRestaurantList(lat, lng, offset, limit)
                .compose(validate())
                .compose(sort())
                .toObservable()
                .map { Success(it) as Result }
                .onErrorReturn { Failure(it) }
                .startWith(Loading)
        }
    }
    

    اینجا میبینیم که یک لیست میگیریم برخی اعتبار سنجی هارا انجام میدهیم و منطقی را بر اساس آن مرتب میکنیم وسپس آن را در یک result  برمیگردانیم.

    اکنون داده هایمان همه قلاب شده است و UseCase  های ما برای بازیابی این داده هادر دسترس هستند خب این داده ها را وارد لایه application میکنیم.

     

    لایه Application

    برای این لایه فقط Activity و ViewModel را نشان میدهم نمیخواهم خیلی وارد جزئیات بشویم.

    اینجا MainActivity ما است

     

    class MainActivity : BaseActivity() {
    
        @Inject lateinit var viewModel: MainViewModel
        private val disposables = CompositeDisposable()
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            val binding: ActivityMainBinding = DataBindingUtil.setContentView(this, layout.activity_main)
    
            screenComponent.inject(this)
    
            binding.viewModel = viewModel
            viewModel.bound()
        }
    
        // onResume we need to subscribe to our viewModel actions
        override fun onResume() {
            super.onResume()
            viewModel.showErrorGettingRestaurants.observe()
                .subscribe {
                    AlertDialog.Builder(this)
                        .setTitle(getString(string.error_title))
                        .setMessage(getString(string.restaurant_list_error_message))
                        .setNeutralButton(getString(string.ok)) { dialog, _ -> dialog.dismiss() }
                }.addTo(disposables)
        }
    
        // onPause we need to unsubscribe from viewModel actions since the view is now backgrounded
        override fun onPause() {
            disposables.clear()
            super.onPause()
        }
    
        override fun onDestroy() {
            viewModel.unbound()
            super.onDestroy()
        }
    }
    
    @BindingAdapter("adapter")
    fun RecyclerView.setAdapter(viewModel: MainViewModel) {
        val adapter = RestaurantListAdapter(viewModel.restaurantList)
        adapter.onItemClickListener = { viewModel.onRestaurantClicked(it) }
        adapter.onImageClickedListener = { viewModel.onRestaurantImageClicked(it) }
        this.addItemDecoration(DividerItemDecoration(this.context, DividerItemDecoration.VERTICAL))
        this.layoutManager = LinearLayoutManager(this.context)
        this.adapter = adapter
    }
    

    چند نکته قابل ذکر است .. ما ViewModel را با استفاده از Dagger تزریق می کنیم و به هر StickyActions از ViewModel گوش می دهیم تا نمایش را به روز کند.

    StickyAction یک RxJava BehaviorSubject wrapper است

    این به observe اجازه میدهد تغییرات در ViewModel  را مشاهده کند.

    StickyAction  مقادیر null را هندل میکند و در صورت observe مقادیر از آن ها استفاده میکند.

    یک BehavaiorSubject معمولی برای مشاهده هر  observer جدید آخرین مقدار را در خود حفظ میکند.

    مورد دیگری که می خواهم ذکر کنم آداپتور اتصال دهنده است. من آداپتورهای اتصال خود را سبک و خاص نگه میدارم. این وظیفه برای تنظیم اولیه آداپتور و تنظیم observable list است. این آداپتور با تغییر لیست ، خود را به روز می کند. این باعث می شود هرگونه تماس به آداپتور ذخیره شود یا اصلاً نیاز به نگه داشتن reference داشته باشید. فقط لیست را به روز کنید و RecyclerView نیز به روز می شود.
     

     

     به ViewModel میرویم

    class MainViewModel @Inject constructor(
        private val getRestaurantListUseCase: GetRestaurantListUseCase,
        private val mainRouter: MainRouter
    ) {
    
        val disposables = CompositeDisposable()
        val progressVisible = ObservableBoolean()
        val restaurantList = ObservableArrayList<Restaurant>()
        val showErrorGettingRestaurants = StickyAction<Boolean>()
    
        companion object {
            // Hardcoded LatLng for simplicity here
            const val LAT = 37.00
            const val LNG = -122.00
        }
    
        // Called onCreate. Retrieves the list of restaurants
        fun bound() {
            //offset and limit can be used for pagination in the future. This is static for now.
            getRestaurantListUseCase.execute(LAT, LNG, 0, 50)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe { handleGetRestaurantListResult(it) }
                .addTo(disposables)
        }
    
        // Called onDestroy. Clean up method.
        fun unbound() {
            disposables.clear()
        }
    
        // Handles Result from getRestaurantListUseCase
        private fun handleGetRestaurantListResult(result: Result) {
            progressVisible.set(result == Result.Loading)
            when (result) {
                is Result.Success -> {
                    restaurantList.addAll(result.restaurants)
                }
                is Result.Failure -> {
                    showErrorGettingRestaurants.trigger(true)
                }
            }
        }
    
        // Shows restaurant detail screen based on restaurant clicked
        fun onRestaurantClicked(restaurant: Any) {
            mainRouter.navigate(MainRouter.Route.RESTAURANT_DETAIL, Bundle().apply {
                putInt(RestaurantDetailActivity.EXTRA_RESTAURANT_ID, (restaurant as Restaurant).id)
            })
        }
    
        // Shows image detail screen based on restaurant clicked
        fun onRestaurantImageClicked(restaurant: Any) {
            mainRouter.navigate(MainRouter.Route.IMAGE_DETAIL, Bundle().apply {
                putString(ImageDetailActivity.EXTRA_URL, (restaurant as Restaurant).coverImgUrl)
            })
        }
    }
    

    همانطور که میبینید ما داده هارا در تابع bound() میگیریم این تابع در oncreate() فراخوانی میشود.

    وقتی لیست داده ها وارد می شوند ، ما با اضافه کردن آن به لیست ، آن را مدیریت خواهیم کرد. در اینجا ما همچنین برخی از رویدادهای onClick داریم که کاربر را به صفحه دیگری می برد.

    اینجاست که روتر ما در دسترس است. روتر تمام منطق navigation را در خود جای داده است. این امر به سبک شدن View و ViewModel کمک می کند ، و همچنین باعث می شود منطق navigation قابل test باشد.

     

    موارد دیگری هستند که قبلاً مورد بحث قرار ندادم ، مانند تنظیم داگر ، نوشتن unit test با Mockito ، Databinding و ... درمورد بعضی از این موارد آموزش هایی ارائه خواهیم داد.

     

    سوال یا مشکلی داشتید در قسمت کامنت ها بنویسید تا به آن ها پاسخ بدهم.

     


    • نویسنده: میثم بابائی

    ارسال دیدگاه

    برای افزودن دیدگاه خود، نیاز است ابتدا وارد حساب کاربری‌تان شوید


    دیدگاه کاربران