آشنایی با معماری Clean Architecture در برنامه‌نویسی اندروید

نویسنده : سید ایوب کوکبی ۲۰ فروردین ۱۳۹۸

آشنایی با معماری Clean Architecture در برنامه‌نویسی اندروید

بعید است برنامه‌نویس باشید و اسم رابرت سی مارتین (Robert C. Martin) به گوشتان نخورده باشد! رابرت معروف به عمو باب (Uncle Bob) توسعه‌دهندۀ کهنه‌کاری است که با نوشتن کتاب‌های Clean Code و Clean Coder به شهرت جهانی رسید. آنکل باب در کتاب جدیدش Clean Architecture (معماری تمییز) به بحث مهمِ معماری نرم‌افزار پرداخته است. معماری نرم‌افزار همان چیزی است که هنگام شلوغ شدن کدها و فایل‌های پروژه دنبالش می‌گردید. تقریباً هر توسعه‌دهنده‌ای حتی آن دسته از افرادی که اسم معماری نرم‌افزار را نشنیده باشند، خواه ناخواه برای سامان‌دهی کدهای خود از معماری ساده‌ای استفاده می‌کنند . این معماری‌ها در طی دهه‌‌ها زمان، بالغ و بالغ‌تر شده‌اند و با هدف ماژولار کردن برنامه و مدیریت بهتر آن معرفی و استفاده شده‌اند. هر معماری مزایا و معایب خاص خودش را دارد؛ برخی با معایب بیشتر، برخی کمتر.

هر معماری با روش متفاوتی پیاده‌سازی می‌شود ولی هدف نهایی همۀ آن‌ها یکسان است. رابرت سی مارتین با بررسی خصوصیات مشترک و خوبِ معماری‌های متعدد به فکر ابداع یک معماری جدید می‌افتد که ویژگی‌های مشترک و خوب همۀ این معماری‌ها را در دل خود داشته باشد؛ و این کار را هم می‌کند. اسمش هست معماری Clean Architecture در کتابی با همین عنوان. خواندن این کتاب را به تمام برنامه‌نویسان توصیه می‌کنیم. بند بند این کتاب ثمرۀ سال‌ها تجربه است.

آقای Yossi Segev مفاهیم معماری تمییز را در قالب یک اپلیکیشن اندرویدی به صورت عملی پیاده‌سازی و توضیحات آن را در مدیوم منتشر کرده‌اند که بد ندیدیم متن فارسی آن را در اسکارپ منتشر کنیم. توجه کنید سطح این پست کمی بالاست و بیشتر به درد توسعه‌دهندگان حرفه‌ای و با تجربه می‌خورد. ولی افراد مبتدی هم با خواندن مطلب چیزهای جدیدی یاد می‌گیرند؛ لااقل از چیزهایی که باید یاد بگیرند آگاه می‌شوند. توصیه می‌کنیم برای درک بهتر توضیحات ابتدا کدها را از گیت‌هاب دانلود کرده و بعد از اجرا و بررسی اولیه کد، مطلب حاضر را بخوانید.

در این پروزه از مفاهیم زیر استفاده شده است:

  • زبان برنامه‌نویسی کاتلین؛
  • معماری تمییز (Clean Architecture)؛
  • توسعۀ آزمون محور (TDD: Test Driven Development)؛
  • Dagger2؛
  • کامپوننت‌های مورد استفاده در معماری اندروید (ViewModel, LiveData, Room و …)؛
  • RxJava؛
  • RxAndroid؛
  • Retrofit؛
  • AndroidTagView؛
  • Picasso؛
  • Leakcanary؛
  • و …

توصیه می‌کنیم برای درک بهتر کدها و توضیحات این پست، هر جایی به مطالب قبلی لینک کرده بودیم مطالعه کنید. مثلاً بحث TDD را به صورت عملی در این پست آموزش داده‌ایم. یا با جستجوی کلمه کاتلین در اسکارپ مقالات زیادی می‌توانید پیدا کنید. دقت داشته باشید که توضیحات این پست صرفاً بخش‌های کلیدی را تحت پوشش قرار می‌دهد و اینطور نیست که خط به خط کدها تفسیر شود. در واقع فقط بخش‌هایی توضیح داده شده که ممکن است برای شما مبهم به نظر برسد. ضمناً این تصور که Clean Architecture بهترین معماری است و باید در همۀ پروزه‌ها از آن استفاده کرد نیز از پایه اشتباه است. یک معماری خوب می‌تواند در شرایط ویژه‌ای، خیلی بد به نظر برسد یا معماری دیگری که همه از بدی‌های آن می‌گویند در یک پروژۀ بسیار عالی ظاهر شود. معماری معرفی شده توسط آنکل باب نیز از این قاعده مستثنی نیست. در مباحث مهندسی نرم‌افزار نمی‌توان یک نسخۀ واحد پیچید. باید همه چیز را خواند و مطابق با شرایط و فاکتورهای مختلف بهترین تصمیم را گرفت.

رویکرد تمییز یا Clean

پروژۀ MovieNight شدیداً مبتنی بر مفاهیم معرفی شده در کتاب Clean Architecture عموم باب است. بنابراین لازم است قبل از هر کاری با مفاهیم پایه و اصلی آن آشنا شویم. این معماری در شروع کار ممکن است کمی سخت و پیچیده به نظر برسد ولی بعد از فهمیدنِ آن متوجه خواهید شدی که چندان پیچیده نیست و به راحتی در هر پروژه‌ای می‌توان پیاده‌سازی کرد.

اصول کلیدی رویکرد تمییز:

  • کدهای برنامه باید در لایه‌های مختلف قرار بگیرند. این مفهوم در واقع مبتنی بر قاعدۀ تفکیک دغدغه‌ها (Seperation Of Concern) است؛
  • هر لایه‌ای فقط با لایه‌های پایین‌تر از خودش باید ارتباط داشته باشد؛
  • هرچقدر به سمت لایه‌های زیرین حرکت می‌کنیم، عمومیت کدها بشتر می‌شوند. لایه‌های پایین اغلب سیاست‌ها و قواعد کلی را دیکته می‌کنند و هرچقدر به سمت لایه‌های بالاتر حرکت می‌کنیم میزان این عمومیت کاهش یافته و بیشتر به بحث پیاده‌سازی پرداخته می‌شود. مثلاً چیزهایی مثل دیتابیس، مدیریت شبکه، کار با واسط کاربری و … .

 Clean Architecture (معماری تمییز)

مطابق تصویر بالا:

  • لایۀ C به لایۀ B و A دسترسی دارد؛
  • لایۀ B به هر چیزی در لایۀ A واقف است ولی از لایۀ C هیچ اطلاعاتی ندارد. در واقع حتی از وجود لایۀ C هم با خبر نیست؛
  • لایۀ A پایین‌ترین لایه است و هیچ اطلاعی از لایه‌های دیگر ندارد. در واقع یک لایۀ کاملاً کور و نابیناست.

اصل ماجرا همین است. مابقی جزئیات در خدمت این سه اصل کلیدی است.

چرا باید از رویکرد تمییز استفاد کنیم؟

نیازمندی‌های پروژه همواره در حال تغییر است. این تغییرات اغلب در جزئیات پیاده‌سازی رخ می‌دهد. با انتقال جزئیات پیاده‌سازی به لایه‌های فوقانی می‌توان تغییرات احتمالی آینده را از قابلیت‌های اصلی برنامه جدا کرد. این ایزوله‌سازی باعث می‌شود تا کدهایی مستقل و تست‌پذیر بنویسیم طوری که بدون خراب شدن قسمت‌های اصلی برنامه بتوان تغییرات لازم را به سرعت اعمال کرد. خب با این رویکرد سروقت پروژۀ MovieNight برویم.

لایه‌های معماری برنامه

برنامۀ ما سه لایه دارد: لایۀ دامین (domain layer)، لایۀ داده (data layer)، لایۀ نمایش (presentation layer)

همانطور که می‌بینید هر یک از این لایه‌ها در ماژول مجزایی قرار گرفته‌اند.

ساختار فوق تنها یکی از روش‌های ممکن است. راه‌های دیگری نیز هست که هر کس بنا به شرایط و حتی سلیقۀ شخصی می‌تواند در پروژه خود از آن استفاده کند. قرار گرفتن هر لایه در ماژولی مستقل جلوی نشتی‌هایی که به صورت اتفاقی رخ می‌دهند را می‌گیرد چرا که ناچاریم فایل build.gradle را به صورت جداگانه در هر ماژول تعریف کنیم.

بخوانید  Clean Code - کتابی که هر توسعه‌دهنده‌ای باید بخواند

نمای کلی از هر لایه و محتویات آن:

نگران ابهامات ذهنی‌تان نباشید. جلوتر توضیح می‌دهیم. بررسی را از پایین یعنی لایۀ دامین شروع می‌کنیم و به ترتیب لایه‌های فوقانی را توضیح خواهیم داد.

لایه دامین (Domain Layer)

لایۀ دامین ماهیت اصلی یک نرم‌افزار است. در واقع جوهرۀ برنامه، اینکه چه هست و چه کاری انجام می‌دهد در این لایه تعریف شده است. (جلوتر با یک مثال واقعی توضیح می‌دهیم). اصل سومِ رویکرد تمییز این بود که هرقدر به لایه‌های پایین‌تر حرکت می‌کنیم، عمومیت کدها بیشتر می‌شود؛ بنابراین لایۀ دامین که در پایین‌ترین قسمت قرار گرفته باید حاوی عمومی‌تر کدهای برنامه باشد که اینطور هم هست. کدهای این لایه در نهایت عمومیت و کلی بودن قرار دارند. پیاده‌سازی جزئیات بر عهدۀ لایه‌های بالاتر است. این لایه مطلقاً اطلاعی از لایه‌های بالاتر ندارد و اساساً کاری به SDK اندروید ندارد. تقریباً یک کد خالص کاتلین. اما چرا؟ چون سایر کدها، ربطی به حوزۀ وظایف این لایه ندارند. لایۀ دامین حاوی موجودیت‌های دامین (domain entities)، اینترفیس‌ها و کلاس‌های بخصوصی تحت عنوان use case است.

Domain entities

هر برنامه‌ای حاوی قطعات سازنده‌ای است که به هر یک موجودیت (Entity) گفته می‌شود. در برنامۀ MovieNight موجودیت‌هایی مثل فیلم، جزئیات فیلم، نظرات کاربران، ویدیو، ژانر وجود دارد. هر موجودیت در قالب یک کلاس، ساختار داده‌ای اولیۀ موجودیت‌ها را تعریف می‌کند که معمولاً مصون از تغییرات خارجی است. یعنی اگر کلاس Movie در لایه‌های بالاتر تغییر کند در لایۀ پایین هیچ تغییری اعمال نمی‌شود. این پکیج موجودیت‌های پروژه است:

منظور از این گفته که لایۀ دامین، ماهیت و چیستی برنامه را تعریف می‌کند همین بود. با نگاهی به موجودیت‌های پروژه به راحتی می‌توان فهمید که این برنامه با فیلم، جزئیات فیلم، ویدیو و چیزهایی مثل آن سروکار دارد.

data class MovieEntity(

        var id: Int = 0,
        var title: String,
        var overview: String? = null,
        var voteCount: Int = 0
        // ...
)

چیز عجیبی در کدهای بالا وجود ندارد. این کلاس قرار است یک data container باشد، به همین خاطر در قالب یک کلاس دادۀ کاتلین تعریف شده است. مابقی کدها را در سورس برنامه ببینید.

Use Case

هر یوزکیس تنها یک وظیفۀ پایه و ابتداییِ قابل اجرا را تعریف می‌کند. این یوزکیس‌ها بعدها در لایه‌های بالاتر استفاده می‌شوند. تصویر پایین، پکیج یوزکیس‌های پروژه را نشان می‌دهد:

همانطور که می‌بینید اینجا عملکرد برنامه را توضیح داده‌ایم. در بخش موجودیت‌ها فهمیدیم برنامه کلاً در چه ارتباطی است. و در این بخش کارهایی که برنامه می‌تواند انجام دهد را مشخص می‌کنیم؛ مثلاً جستجوی فیلم، ذخیره فیلم‌های مورد علاقه، دریافت فیلم‌های محبوب، بررسی وضعیت فیلم و … . یوزکیس‌ها همه از اکشن‌های برنامه صحبت می‌کنند.

تمام یوزکیس‌ها یک کلاس انتزاعی را extend می‌کنند:

abstract class UseCase<T>(private val transformer: Transformer<T>) {

    abstract fun createObservable(data: Map<String, Any>? = null): Observable<T>

    fun observable(withData: Map<String, Any>? = null): Observable<T> {
        return createObservable(withData).compose(transformer)
    }
}

و این هم پیاده‌سازی یوزکیس GetPopularMovies:

class GetPopularMovies(transformer: Transformer<List<MovieEntity>>,
                       private val moviesRepository: MoviesRepository) : UseCase<List<MovieEntity>>(transformer) {
  
    override fun createObservable(data: Map<String, Any>?): Observable<List<MovieEntity>> {
        return moviesRepository.getMovies()
    }
}

چند نکته هست که باید توضیح دهیم:

  • خروجی همۀ یوزکیس‌ها Observable است. لازم به ذکر نیست که Observableها اجازه می‌دهند تا کدهایی reactive بنویسیم؛
  • تمام یوزکیس‌ها باید آبجکت Transformer را در سازندۀ خود دریافت کنند. Transformer یک کلاس ساده از نوع ObservableTransformer است. با استفاده از این کلاس می‌توانیم به صورت داینامیک مشخص کنیم کدام ترد برنامه اجرا شود که کاربرد ویژه‌ای در نوشتن تست‌ها دارد. مثلاً وقتی می‌خواهیم یوزکیس فهرست فیلم‌های محبوب را اجرا کنیم هدفمان اجرا روی worker thread است نه ترد اصلی که باعث هنگ کردن برنامه می‌شود. ولی موقع تست، هدف ترد اصلی است. معمولاً به جزء  اهداف تست، دلیل دیگری نیست که یوزکیس‌ها را بر روی ترد اصلی اجرا کنیم؛
  • در هنگام استفاده از یوزکیس‌ها می‌توانیم دادۀ سفارشی به آن‌ها منتقل کنیم. گاهی لازم است داده‌هایی را به یوزکیس‌ها منتقل کنیم. از آنجایی که نمی‌خواهیم در شرایط نبودِ داده، مقادیر null انتقال یابد به راحتی می‌توانیم از قابلیت مقادیر اختیاری و پیش‌فرض کاتلین استفاده کنیم.

همانطور که می‌بینید یوزکیس GetPopularMovies در هنگام ساخته شدن آبجکتی از جنس MovieRepository که یک اینترفیس است را دریافت می‌کند.

اینترفیس‌ها

اینترفیس‌های لایۀ دامین، قراردادهایی هستند که لایه‌های بالاتر باید از آن پیروی کنند. این موضوع باعث می‌شود تا اپلیکیشن شما فارغ از جزئیات پیاده‌سازی، قابلیت‌های کلیدی خود را داشته باشد. برای فهم بیشتر بیایید یکبار دیگر یوزکیس GetPopularMovies را بررسی کنیم. این یوزکیس بعد از فراخوانی، مقداری از نوع Observable که داده‌هایش را از اینترفیس MoviesRepository می‌گیرد برمی‌گرداند.

پیاد‌سازی MoviesRepository:

interface MoviesRepository {
    fun getMovies(): Observable<List<MovieEntity>>
    fun search(query: String): Observable<List<MovieEntity>>
    fun getMovie(movieId: Int): Observable<Optional<MovieEntity>>
}

حال چرا این پیاده‌سازی از نوع اینترفیس است؟ چون یوزکیس GetPopularMovies برای عملکرد صحیح نیازی به جزئیات پیاده‌سازی repository ندارد. با نگاهی به خط ۵ کدهای این یوزکیس می‌توان فهمید که برای ()moviesRepository.getMovies اصلاً مهم نیست فیلم‌های دریافتی از طریق یک دیتابیس محلی تأمین می‌شود یا سرور راه دور. مادامی که اینترفیس فوق پیاده‌سازی شود، یوزکیس کارش را دست انجام می‌دهد. قبل از ادامه اجازه دهید کمی از تست صحبت کنیم.

انجام یونیت تست روی یوزکیس‌ها

نابینایی لایۀ دامین و عدم وابستگی آن به پیاده‌سازی واقعی این مزیت را دارد که به راحتی کدهای این لایه را یونیت تست کنیم. مثلاً کد زیر یک آزمون واحد ساده برای یوزکیس GetPopularMovies است:

@Test
fun testGetPopularMovies() {
    val movieRepository = Mockito.mock(MoviesRepository::class.java)
    Mockito.`when`(movieRepository.getMovies()).thenReturn(Observable.just(generateMovieEntityList()))
    val getPopularMovies = GetPopularMovies(TestTransformer(), movieRepository)
    getPopularMovies.observable().test()
            .assertValue { results -> results.size == 5 }
            .assertComplete()
}

به خط ۴ نگاه کنید؛ از آنجایی که MoviewRepository فقط یک اینترفیس است به راحتی می‌توانیم رفتار آن را با استفاده از Mockito تقلید کنیم و به هنگام فرخوانی getMovies یک Observable به همراه داده‌های جعلی یا stub برگردانیم. به خاطر دارید یوزکیس‌ها هنگامی که ساخته می‌شدند مقداری از نوع Transformer دریافت می‌کردند؟ برای اطمینان از اجرای همزمان یوزکیس در خط ۵ از TestTransformer استفاده کرده‌ایم. در خط ۷ و ۸ فرایند تست را با ارزیابی داده‌های منتشر شده از یوزکیس – یعنی همان داده‌هایی که در خط ۴ موک شدند – به پایان رسانده‌ایم.

بخوانید  داستان برنامه‌نویس شدن یک مهندس عمران

خلاصۀ آنچه از لایۀ دامین گفتیم

این لایه در پایین‌ترین قسمت کدبیس ما قرار دارد. در لایۀ دامین موجودیت‌ها، اینترفیس‌ها و یوزکیس‌ها برای لایه‌های بالاتر تعریف می‌شوند. کدهایی که در این لایه می‌نویسیم خیلی عمومی هستند؛ آنقدر عمومی که فقط قابلیت‌های کلی و خط‌ مشی‌های اصلی برنامه را مشخص می‌کند. پیاده‌سازی را به عهدۀ لایه‌های بالاتر می‌گذاریم.

لایۀ داده (Data Layer)

بالای لایۀ دامین، لایه داده یا دیتا قرار دارد که وظیفه‌اش تأمین داده‌های واقعی برنامه است. کدهای اینجا دیگر مثل لایۀ دامین عمومی و گیج‌کننده نیستند. در این قسمت انواع دیتاپروایدرهای (data provider) مورد نیاز MovieNight به صورت واقعی پیاده‌سازی می‌شوند. همچنین نوع جدیدی از کلاس یعنی Mapper در اینجا استفاده می‌کنیم که بعداً توضیح می‌دهیم. توجه داشته باشید لایۀ نمایش بالای این لایه قرار دارد. بنابراین دیتاپروایدرهای لایۀ داده اطلاعی ندارند که کی و چگونه توسط لایۀ بالایی یعنی نمایش فرخوانی می‌شوند. این خوب است و باید هم اینطور باشد.

جزئیات پیاده‌سازی دیتاپروایدرها

جزئیات پیاده‌سازی دیتاپروایدرها از قبیل مکانیزم‌های کشینگ، دیتابیس، مدیریت شبکه و اتصالات و … همگی در این بخش قرار می‌گیرند. بنابراین در این لایه ارجاعاتی به کتابخانه‌های Retrofit و Room خواهید داشت. البته این کتابخانه‌ها برای پنهان کردن جزئیات پیاده‌سازی در کلاس‌هایی محصور شده‌اند که مبتنی بر اینترفیس‌های لایۀ دامین هستند. لطفاً به کد پایین توجه کنید. اینترفیس MoviesRepository واقع در لایۀ دامین به MoviesRepositoryImpl در لایۀ دیتا تغییر نام داده است. (نام‌گذاری جالبی است نه؟):

class MoviesRepositoryImpl(api: Api,
                           private val cache: MoviesCache,
                           movieDataMapper: Mapper<MovieData, MovieEntity>,
                           detailedDataMapper: Mapper<DetailsData, MovieEntity>) : MoviesRepository {

    private val memoryDataStore: MoviesDataStore
    private val remoteDataStore: MoviesDataStore

    init {
        memoryDataStore = CachedMoviesDataStore(cache)
        remoteDataStore = RemoteMoviesDataStore(api, movieDataMapper, detailedDataMapper)
    }

    override fun getMovies(): Observable<List<MovieEntity>> {
        return if (!cache.isEmpty()) {
            return memoryDataStore.getMovies()
        } else {
            remoteDataStore.getMovies().doOnNext { cache.saveAll(it) }
        }
    }

    override fun search(query: String): Observable<List<MovieEntity>> {
        return remoteDataStore.search(query)
    }

    override fun getMovie(movieId: Int): Observable<Optional<MovieEntity>> {
        return remoteDataStore.getMovieById(movieId)
    }
}

نکات قابل توجه:

  • MoviesRepositoryImpl پیاده‌سازی اینترفیس MoviesRepository است. بنابراین می‌تواند مورد استفادۀ یوزکیس‌های لایۀ دامین قرار بگیرد؛
  • کلاس فوق همچون کارخانه‌ای است که خط تولید آن را به راحتی می‌توان بین دیتابیس محلی و راه دور سوئیچ کرد. پیاده‌سازی ()getMovies قبل از فرخوانی API ابتدا وجود نسخۀ کش داده‌ها را بررسی می‌کند. سایر متدها مثل ()search به صورت مستقیم API را فراخوانی می‌کنند؛
  • دیتاسورس‌ها به صورت انتزاعی پیاده‌سازی شده‌اند. حتی با وجود اینکه دیتاسورس‌ها در همان لایه‌ای قرار گرفته‌اند که MoviesRepositoryImpl قرار گرفته ولی بازهم جزئیات پیاده‌سازی را از نگاه آن پنهان کرده‌ایم چون دلیلی ندارد MoviesRespositoryImpl از ماهیت دیتاسورس‌ها اطلاعی داشته باشد. به همین خاطر آن‌ها را پشت اینترفیس MoviesDataStore پنهان کرده‌ایم (که در لایۀ دامین تعریف شد‌ه‌اند)؛
  • از Mapper ها استفاده کرده‌ایم که بد نیست در مورد آن صحبت کنیم.

Mappers

مپرها همانطور که از نامشان پیداست کلاس‌هایی هستند که به خوبی می‌دانند چطور کلاس A را به کلاس B نگاشت کنند. تمام Mapperهای MovieNight، کلاس انتزاعی Mapper را extend کرده‌اند.

abstract class Mapper<in E, T> {
    
    abstract fun mapFrom(from: E): T

    fun mapOptional(from: Optional<E>): Optional<T> {
        from.value?.let {
            return Optional.of(mapFrom(it))
        } ?: return Optional.empty()
    }

    fun observable(from: E): Observable<T> {
        return Observable.fromCallable { mapFrom(from) }
    }

    fun observable(from: List<E>): Observable<List<T>> {
        return Observable.fromCallable { from.map { mapFrom(it) } }
    }
}

متدهای زیادی در این کلاس وجود دارد ولی اصل ماجرا در خط ۳ اتفاق می‌افتد که یک کلاس A را می‌گیرد و از طرف دیگر کلاس B را تحویل می‌دهد.

چرا باید از mapper استفاده کنیم؟

domain entities یا موجودیت‌های دامین، ساختارهای ابتدایی داده هستند که در لایۀ دامین تعریف شده‌اند. آن‌ها هیچ اطلاعی از دنیای بیرون خود ندارند. مشکل اینجاست که لایۀ داده حاوی پیاده‌سازی خاصی است که این پیاده‌سازی خاص نیازهای مشخصی هم دارد. مثال بارزش کتابخانۀ Retrofit است. برای اینکه به رتروفیت اجازه دهیم پاسخ‌های شبکه را parse کند عموماً از کتابخانۀ GSON استفاده می‌کنیم. GSON برای اینکه بتواند pars کند مجموعه‌ای از annotationها دارد که به parser دستورات لازم را می‌دهد. وقتی annotationای وجود نداشته باشد یعنی parserای نیست. البته ما نمی‌توانیم با استفاده از GSON موجودیت‌های لایۀ دامین را annotate کنیم چون این لایه اصلاً اطلاعی از ماهیت GSON ندارد.

برای حل این مشکل چه کاری می‌توانیم انجام دهیم؟ می‌توانیم مجموعۀ جدیدی از موجودیت‌ها بسازیم که بتوانند از Retrofit استفاده کنند. این موجودیت‌ها را می‌توان در لایۀ دیتا که دور از لایۀ دامین قرار دارند مستقر نمود. یعنی چنین کلاسی:

@Entity(tableName = "movies")
data class MovieData(

        @SerializedName("id")
        @PrimaryKey
        var id: Int = -1,

        @SerializedName("vote_count")
        var voteCount: Int = 0,

        @SerializedName("vote_average")
        var voteAverage: Double = 0.0
        
        // ...
}

MovieData بسیار مشابه MovieEntity است. تنها فرقشان این است که MovieData حاوی یک پیاده‌سازی است که از Retrofit و Room استفاده می‌کند. اما سوال اینجاست که صِرف اینکه به جای ذخیره کردن آرایه‌ای از MovieEntityها از آبجکت‌های MovieData استفاده کردیم مشکل حل می‌شود؟ خیر! مشکل دیگر یوزکیس‌های مستقر در لایۀ دامین هستند که از ماهیت MovieData اطلاعی ندارند. یوزکیس‌ها فقط MovieEntityها را می‌شناسند و بس! خب چاره چیست؟ ما می‌توانیم هر زمانی که خواستیم MovieData را به MovieEntity مپ یا نگاشت کنیم.

تستِ لایۀ داده

اغلب تست‌های این لایه، یونیت تست هستند (Room به فریم‌ورک اندروید وابسته است، بنابراین برای تست آن از instrumentation استفاده می‌کنیم). و از آنجایی که بخش عمدۀ پیاده‌سازی انتزاعی است به راحتی می‌توانیم قابلیت‌های اصلی را آزمون واحد بگیریم. کد پایین بخش کوچکی از آزمون‌های واحد که برای MoviesRepositoryImpl نوشته شده را نمایش می‌دهد.

@Before
fun before() {
    api = mock(Api::class.java)
    movieCache = TestMoviesCache()
    movieRepository = MoviesRepositoryImpl(api, movieCache, movieDataMapper, detailsDataMapper)
}

@Test
fun testWhenCacheIsNotEmptyGetMoviesReturnsCachedMovies() {

    movieCache.saveAll(generateMovieEntityList())
    movieRepository.getMovies().test()
            .assertComplete()
            .assertValue { movies -> movies.size == 5 }

    verifyZeroInteractions(api)
}

@Test
fun testWhenCacheIsEmptyGetMoviesReturnsMoviesFromApi() {
   val movieListResult = MovieListResult()
   movieListResult.movies = TestsUtils.generateMovieDataList()
   `when`(api.getPopularMovies()).thenReturn(Observable.just(movieListResult))
    movieRepository.getMovies().test()
            .assertComplete()
            .assertValue { movies -> movies.size == 5 }
}

همانطور که می‌بینید، تمام دیتاسورس‌های مورد استفاده، توسط اینترفیس‌ها abstract شده‌اند؛ بنابراین خیلی راحت با کتابخانۀ Mockito می‌توانیم mock کنیم یا با پیاده‌سازی ساده‌ای مکانیزم‌هایی مثل کشینگ را تست کنیم.

بخوانید  آموزش زبان کاتلین – درس 8 (شرط IF)

خلاصۀ چیزهایی که دربارۀ لایه داده گفتیم

لایۀ داده بین لایۀ دامین و نمایش قرار دارد. در این لایه پیاده‌سازی دیتابیس و دیتاپروایدرها قرار گرفته است. همچنین برای اعمال سادۀ تغییرات و سهولت تست برخی از کدهای این لایه به صورت انتزاعی نوشته می‌شود تا وابستگی شدیدی بین کدها پدید نیاید.

خب، به آخرین لایه یعنی لایۀ نمایش می‌رسیم.

لایۀ نمایش (Presentation Layer)

این لایه همان چیزی است که خروجی زحمات ما را به کاربر نشان می‌دهد. تمام لایه‌های پایین‌دست در خدمت این لایه هستند. اینجا با اکتیویتی، فرگمنت، عناصر گرافیکی، presenter، mapper و فریم‌ورک‌های تزریق وابستگی و … سروکار داریم. برای این لایه از چیزی شبیه معماری MVP استفاده کرده‌ایم. در واقع این معماری در دل معماری بزرگتر ما یعنی clean architecture جای گرفته است.

View و ViewState

در MVP، ویو به پیاده‌سازی واسط گرافیکی گفته می‌شود که توسط presenter فرخوانی می‌شود. ولی در معماری شِبه MVP ما، ویو تغییرات ViewState را منعکس می‌کند. این ViewStateها توسط اشیاء LiveData تحویل داده می‌شوند. این اشیاء بخشی از presenter محسوب می‌شوند. لازم به ذکر است که presenter می‌تواند حاوی بیش از یک آبجکت LiveData باشد و ویو می‌تواند به همۀ آن‌ها متصل شود.

ViewState صرفاً یک کانتینر است که تمام اطلاعات مورد نیاز برای رندر ویو را نگه‌داری می‌کند. مثلاً کد پایین برای صفحۀ PopularMoviesViewState است:

data class PopularMoviesViewState(
        var showLoading: Boolean = true,
        var movies: List<Movie>? = null
)

ویو با خواندن اطلاعات ViewState متوجه می‌شود که کدام Movie Object ها را نمایش دهد. برای اینکه بدانید View چگونه اطلاعات ViewState را هندل می‌کند به کد پایین که مربوط به PopularMoviesFragment است توجه کنید:

private lateinit var progressBar: ProgressBar
private lateinit var popularMoviesAdapter: PopularMoviesAdapter

// ...

private fun handleViewState(state: PopularMoviesViewState) {
        progressBar.visibility = if (state.showLoading) View.VISIBLE else View.GONE
        state.movies?.let { popularMoviesAdapter.addMovies(it) }
}

و همانطور که قبلاً اشاره کردیم، ViewState توسط Presenter بروزرسانی می‌شود.

Presenters

presenter موجود در MovieNight مجموعه‌ای از آبجکت‌های ViewModel است. اما چرا باید از ویومدل استفاده کنیم؟

برخی‌ها به این موضوع که presenter باید از فریم‌ورک اندروید دور باشد، اشاره دارند؛ یعنی اعتقادشان این است که presenter نباید به کدهای فریم‌ورک آلوده شود. ولی وقتی از ویومدل استفاده می‌کنیم به Presenter اطلاعاتی دربارۀ ایونت‌های چرخۀ حیات اندروید داده‌ایم که ایده‌آل نیست. چرا با علم به این موضوع همچنان در کدهای MovieNight از آن استفاده کرده‌ایم؟ ساده‌ترین جواب این است که با توجه به شرایط پروژه تشخیص داده شده مقداری از abstraction برنامه کاهش دهیم تا به یکپارچگی بالاتری دست یابیم. مثلاً این کد PopularMoviesViewModel است:

class PopularMoviesViewModel(private val getPopularMovies: GetPopularMovies,
                             private val movieEntityMovieMapper: Mapper<MovieEntity, Movie>):
        BaseViewModel() {

    var viewState: MutableLiveData<PopularMoviesViewState> = MutableLiveData()
    var errorState: SingleLiveEvent<Throwable?> = SingleLiveEvent()

    init {
        viewState.value = PopularMoviesViewState()
    }

    fun getPopularMovies() {
        addDisposable(getPopularMovies.observable()
                .flatMap { movieEntityMovieMapper.observable(it) }
                .subscribe({ movies ->
                    viewState.value?.let {
                        val newState = this.viewState.value?.copy(showLoading = false, movies = movies)
                        this.viewState.value = newState
                        this.errorState.value = null
                    }
                }, {
                    viewState.value = viewState.value?.copy(showLoading = false)
                    errorState.value = it
                }))
    }
}

نکات جالبی در کد بالا وجود دارد:

خط ۵ و ۶: این‌ها اشیاء LiveData هستند که ویو خواهد دید. یکی از آن‌ها حامل آبجکت ViewState است و دیگری یک Throwable اختیاری. توجه کنید errorState نوع خاصی از LiveData تحت عنوان SingleLiveEvent است که بروزرسانی رویدادها را فقط یکبار ارسال می‌کند. این موضوع هنگام بروز تغییرات پیکربندی به درد می‌خورد.

خط ۱۳: ()addDisposable یک متد BaseViewModel است که یک عمل عضویت RxJava را برای CompositeSubscription انجام می‌دهد. BaseViewModel به هنگام فرخوانی OnCleared، متد ()compositeDisposable.clear را فرخوانی می‌کند.

خط ۱۳ تا ۱۹: اینجا Presenter از یوزکیس به عنوان یک مدل استفاده می‌کند:

  • خط ۱۳: presenter در یک یوزکیس observable عضو می‌شود؛
  • خط ۱۴: توسط یک mapper، موجودیت‌های لایۀ دامین به موجودیت‌های لایۀ نمایش مپ می‌شوند؛
  • خط ۱۶ تا ۱۹: مقدار LiveData توسط یک شی ViewState آپدیت می‌شود. به کاربرد متد copy برای کپی کردن پارارمترها به شی جدید توجه کنید. این روش اجازه می‌دهد تا ViewState را درحالت immutable نگه‌داری کنیم که استفاده از آن به مدد کلاس‌های داده‌ای کاتلین آسان‌تر هم می‌شود.

این چند خط کدهای بخش presenter را تشکیل می‌دهند. حال اجازه دهید از تزریق وابستگی یا dependency injection که بخش دیگری از لایۀ نمایش است صحبت کنیم.

تزریق وابستگی در معماری clean architecture

تزریق وابستگی تقریباً هر چیزی که در برنامه وجود دارد را به یکدیگر متصل می‌کند. وظیفۀ آن تأمین پیاده‌سازی لازم برای کد از طریق قواعد absrtaction است.

تزریق وابستگی در MovieNight مبتنی برکتابخانۀ Dagger2 است. اگر با این کتابخانه کار کرده باشید لازم است بگوییم برای کنترل injectionهای مختلف از یک subComponent به همراه یک scope سفارشی استفاده کرده‌ایم. این کار اجازه می‌دهد تا تزریق وابستگی را بسته به نیاز فرخوانی کنیم و آن‌هایی که در حافظه هستند و اکنون به آن‌ها نیاز نداریم آزاد کنیم. بحث تزریق وابستگی خیلی گسترده است و اکنون مجال سخن گفتن در این باره نیست.

تست presenter

تست کردن presenter مثل فرخوانی یک action خاص، آسان است. مثلاً کدهای پایین بخشی از تست PopularMoviesViewModel را نشان می‌دهد:

@Before
@UiThreadTest
fun before() {
    moviesRepository = Mockito.mock(MoviesRepository::class.java)
    val getPopularMoviesUseCase = GetPopularMovies(TestTransformer(), moviesRepository)
    popularMoviesViewModel = PopularMoviesViewModel(getPopularMoviesUseCase, movieEntityMovieMapper)
    viewObserver = mock(Observer::class.java) as Observer<PopularMoviesViewState>
    errorObserver = mock(Observer::class.java) as Observer<Throwable?>
    popularMoviesViewModel.viewState.observeForever(viewObserver)
    popularMoviesViewModel.errorState.observeForever(errorObserver)
}

@Test
@UiThreadTest
fun testShowingMoviesAsExpectedAndStopsLoading() {
    val movieEntities = DomainTestUtils.generateMovieEntityList()
    `when`(moviesRepository.getMovies()).thenReturn(Observable.just(movieEntities))
    popularMoviesViewModel.getPopularMovies()
    val movies = movieEntities.map { movieEntityMovieMapper.mapFrom(it) }

    verify(viewObserver).onChanged(PopularMoviesViewState(showLoading = false, movies = movies))
    verify(errorObserver).onChanged(null)
}

همانطور که می‌بینید در کد بالا برای mock کردن تمام dependencyها از Mockito استفاده شده است.

در این پست تقریباً تمام آن چیزی که باید دربارۀ Clean Architecture می‌گفتیم بیان شد. توصیه می‌کنیم برای فهم بهتر مفاهیم بیان شده، کدهای کامل پروژه را از گیت‌هاب دریافت کرده و در محیط اندروید استودیو بررسی کنید.

سید ایوب کوکبی

نویسنده و مترجم...

0 دیدگاه

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *