Sådan forenkles din Android-apps arkitektur: en detaljeret guide med kodeeksempler

Individuelle programmører udvikler deres mobilapps i henhold til deres vision, herunder deres ideer og synspunkter om, hvordan man udfører forskellige opgaver. Nogle gange ser de måske bort fra hovedprincipperne for objektorienteret eller funktionel programmering, hvilket kan føre til desorientering blandt udviklerne.

Dette er dårligt - de kan ikke håndtere deres kode. Og den næste udvikler, der har brug for at vedligeholde projektet eller ændre det, kan blive skør. Det er bedre at genopbygge sådanne projekter fra bunden, da vedligeholdelse bliver en kompliceret proces.

Indtil Google udgav sin første understøttede arkitektur, brugte næsten alle softwareudviklingsfirmaer sin egen arkitektur. Dette hjalp dem med at gøre deres kode klarere og gjorde det muligt at skifte mellem projekter. Men hvis en udvikler skiftede virksomheder, ville det tage dem noget tid at lære den nye arkitektur sammen med et nyt projekt.

I øjeblikket er der 16 forskellige arkitekturer til Android-udviklere takket være Google:

  • 6 stabile prøver (Java);
  • 2 stabile prøver (Kotlin):
  • 4 eksterne prøver;
  • 3 forældede prøver
  • 1 prøve i gang.

Uanset hvilken arkitektur du bruger, afhænger af dit specifikke formål, tilgang og anvendelse af forskellige værktøjssæt til implementering af forskellige funktioner. Og det afhænger af programmeringssproget.

Imidlertid har alle disse arkitekturer et fælles arkitektonisk fundament, der næsten lige opdeler logikken for at arbejde med netværk, databaser, afhængigheder og behandling af tilbagekald.

Værktøjer, der bruges under processen

Efter at have studeret alle disse arkitekturer byggede jeg en forenklet tilgang og kom op med en arkitektur med færre lag. Jeg vil vise dig, hvordan du implementerer en simpel Android-app, der indlæser en nyhedsliste, giver dig mulighed for at gemme historier til favoritter og derefter slette om nødvendigt ved hjælp af min tilgang.

Her er et resumé af den teknologi, jeg brugte:

  • Kotlin udvikler appen sammen med AndroidX- biblioteket
  • Rum SQLite som en database
  • Stetho for at gennemse dataene i baser
  • Eftermonter2 sammen med RxJava2 for at hjælpe med at logge serveranmodninger og få serverresponser.
  • Glid for at behandle billeder
  • Android Architecture Components (LiveData, ViewModel, Room) og ReactiveX (RxJava2, RxKotlin og RxAndroid) til opbygning af afhængigheder, dynamiske dataændringer og behandling af asynkronisering.  

Så dette er den mobile app-teknologiestak, jeg brugte til mit projekt.

Lad os komme igang

Første trin

Tilslut AndroidX . Skriv følgende i gradle.properties på app-niveau:

android.enableJetifier=true android.useAndroidX=true

Nu er det nødvendigt at erstatte afhængighederne i build.gradle på app-modulniveau fra Android til AndroidX. Du skal udtrække alle afhængigheder for at udvide, som du kan se i eksemplet med Kotlin out-of-the-box versionering i build.gradle på app-niveau. Og så tilføjer jeg Gradle-versionen der:

buildscript { ext.kotlin_version = '1.3.0' ext.gradle_version = '3.2.1' repositories { google() jcenter() maven { url '//jitpack.io' } mavenCentral() } dependencies { classpath "com.android.tools.build:gradle:$gradle_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } }

For alle andre afhængigheder vil jeg opbygge dens ext- fil, hvor jeg tilføjer absolut alle afhængigheder inklusive SDK-versioner, opdeling af versionering og oprettelse af afhængighedsmassiver, der implementeres yderligere i build.gradle på app-niveau. Det vil se ud som følger:

ext { compileSdkVersion = 28 minSdkVersion = 22 buildToolsVersion = '28.0.3' targetSdkVersion = 28 appcompatVersion = '1.0.2' supportVersion = '1.0.0' supportLifecycleExtensionsVersion = '2.0.0' constraintlayoutVersion = '1.1.3' multiDexVersion = "2.0.0" testJunitVersion = '4.12' testRunnerVersion = '1.1.1' testEspressoCoreVersion = '3.1.1' testDependencies = [ junit : "junit:junit:$testJunitVersion", runner : "androidx.test:runner:$testRunnerVersion", espressoCore: "androidx.test.espresso:espresso-core:$testEspressoCoreVersion" ] supportDependencies = [ kotlin : "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version", appCompat : "androidx.appcompat:appcompat:$appcompatVersion", recyclerView : "androidx.recyclerview:recyclerview:$supportVersion", design : "com.google.android.material:material:$supportVersion", lifecycleExtension: "androidx.lifecycle:lifecycle-extensions:$supportLifecycleExtensionsVersion", constraintlayout : "androidx.constraintlayout:constraintlayout:$constraintlayoutVersion", multiDex : "androidx.multidex:multidex:$multiDexVersion" ] }

Version- og massivnavne implementeres tilfældigt. Derefter implementerer vi afhængigheder i build.gradle på app-niveau som følger:

apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' android { compileSdkVersion rootProject.ext.compileSdkVersion as Integer buildToolsVersion rootProject.ext.buildToolsVersion as String
dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) //Test testImplementation testDependencies.junit androidTestImplementation testDependencies.runner androidTestImplementation testDependencies.espressoCore //Support implementation supportDependencies.kotlin implementation supportDependencies.appCompat implementation supportDependencies.recyclerView implementation supportDependencies.design implementation supportDependencies.lifecycleExtension implementation supportDependencies.constraintlayout implementation supportDependencies.multiDex

Glem ikke at angive multiDexEnabled sand i standardkonfigurationerne. I de fleste tilfælde når du grænsen for antallet af metoder, der bruges hurtigt.

På samme måde skal du erklære alle afhængigheder i appen. Lad os tilføje tilladelser til at forbinde vores app med internettet:

Hvis der ikke er tilføjet noget navn i manifestet, skal du gøre det, da Stetho ikke kan se den navnløse app, og du ikke kan se ind i databasen.

Grundlæggende komponentbygning

Det er værd at bemærke, at MVVM (Model-View-ViewModel) -mønsteret blev brugt som grundlag for opbygningen af ​​denne arkitektur.

Lad os starte udviklingen. Den første ting, du skal gøre, er at oprette en klasse, der arver Application (). I denne klasse giver vi adgang til appkonteksten til videre brug.

@SuppressWarnings("all") class App : Application() { companion object { lateinit var instance: App private set } override fun onCreate() { super.onCreate() instance = this Stetho.initializeWithDefaults(this) DatabaseCreator.createDatabase(this) } }

Det andet trin er at oprette grundlæggende app-komponenter, der starter med ViewModel, som jeg vil bruge til hver aktivitet eller fragment.

abstract class BaseViewModel constructor(app: Application) : AndroidViewModel(app) { override fun onCleared() { super.onCleared() } }

Denne app har ikke kompliceret funktionalitet. Men i den grundlæggende ViewModel vil vi sætte 3 hoved LiveData :

  • fejlbehandling
  • indlæser behandling med statuslinjen vist
  • og da jeg har en app med lister, behandler jeg kvittering og datatilgængelighed i adapteren som en pladsholder, der vises i deres fravær.
val errorLiveData = MediatorLiveData() val isLoadingLiveData = MediatorLiveData() val isEmptyDataPlaceholderLiveData = MediatorLiveData()

For at overføre resultaterne af funktionsimplementeringen til LiveData bruger jeg   Consumer .

For at behandle fejl et vilkårligt sted i appen skal du oprette en forbruger, der overfører Throwable.message- værdien til errorLiveData .

I den grundlæggende VewModel skal du også oprette en metode, der modtager en LiveData-liste for at vise statuslinjen under implementeringen.

Vores grundlæggende ViewModel vil se sådan ud:

abstract class BaseViewModel constructor(app: Application) : AndroidViewModel(app) { val errorLiveData = MediatorLiveData() val isLoadingLiveData = MediatorLiveData() val isEmptyDataPlaceholderLiveData = MediatorLiveData() private var compositeDisposable: CompositeDisposable? = null protected open val onErrorConsumer = Consumer { errorLiveData.value = it.message } fun setLoadingLiveData(vararg mutableLiveData: MutableLiveData) { mutableLiveData.forEach { liveData -> isLoadingLiveData.apply { this.removeSource(liveData) this.addSource(liveData) { this.value = false } } } } override fun onCleared() { isLoadingLiveData.value = false isEmptyDataPlaceholderLiveData.value = false clearSubscription() super.onCleared() } private fun clearSubscription() { compositeDisposable?.apply { if (!isDisposed) dispose() compositeDisposable = null } } }

I vores app giver det ikke mening at oprette et par aktiviteter til to skærme (skærmbilledet nyhedsliste og favoritliste). Men da denne prøve viser implementeringen af ​​optimal og let udvidelig arkitektur, opretter jeg en grundlæggende app.  

Vores app er bygget på 1 aktivitet og 2 fragmenter, som vi vil pumpe op i containeraktivitet. XML-filen for vores aktivitet vil være følgende:

where include_placeholder and include_progressbar will look like this:

Our BaseActivity will look like this:

abstract class BaseActivity : AppCompatActivity(), BackPressedCallback, ProgressViewCallback, EmptyDataPlaceholderCallback { protected abstract val viewModelClass: Class protected abstract val layoutId: Int protected abstract val containerId: Int protected open val viewModel: T by lazy(LazyThreadSafetyMode.NONE) { ViewModelProviders.of(this).get(viewModelClass) } protected abstract fun observeLiveData(viewModel: T) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(layoutId) startObserveLiveData() } private fun startObserveLiveData() { observeLiveData(viewModel) } }

Let’s implement how to display possible errors in the processes of all future Activities. I will do it in the form of usual Toast for simplicity.

protected open fun processError(error: String) = Toast.makeText(this, error, Toast.LENGTH_SHORT).show()

and send this error text to the display method:

protected open val errorObserver = Observer { it?.let { processError(it) } }

In the basic Activity I will start keep up with the changes of errorLiveData value that is located in the basic View Model. The startObserveLiveData() method will mutate as follows:

private fun startObserveLiveData() { observeLiveData(viewModel) with(viewModel) { errorLiveData.observe([email protected], errorObserver) } }

Now using onErrorConsumer of the basic ViewModel as the onError processor, you will see the message about the implemented method error.

Create a method that allows you to replace Fragments in Activity with the ability to add to Back Stack.

protected open fun replaceFragment(fragment: Fragment, needToAddToBackStack: Boolean = true) { val name = fragment.javaClass.simpleName with(supportFragmentManager.beginTransaction()) { replace(containerId, fragment, name) if (needToAddToBackStack) { addToBackStack(name) } commit() } }

Let’s create interfaces for displaying progress and placeholder in the required app spots.

interface EmptyDataPlaceholderCallback { fun onShowPlaceholder() fun onHidePlaceholder() }
interface ProgressViewCallback { fun onShowProgress() fun onHideProgress() }

Implement them in basic Activity. I created functions of the ID setting to the progress bar and placeholder, and also initialized these Views.

protected open fun hasProgressBar(): Boolean = false protected abstract fun progressBarId(): Int protected abstract fun placeholderId(): Int private var vProgress: View? = null private var vPlaceholder: View? = null
override fun onShowProgress() { vProgress?.visibility = View.VISIBLE } override fun onHideProgress() { vProgress?.visibility = View.GONE } override fun onShowPlaceholder() { vPlaceholder?.visibility = View.VISIBLE } override fun onHidePlaceholder() { vPlaceholder?.visibility = View.INVISIBLE } public override fun onStop() { super.onStop() onHideProgress() }

And finally in the onCreate method I set an ID for View:

if (hasProgressBar()) { vProgress = findViewById(progressBarId()) vProgress?.setOnClickListener(null) } vPlaceholder = findViewById(placeholderId()) startObserveLiveData()

I have spelled out the creation of the basic ViewModel and Basic Activity. The Basic Fragment will be created following the same principle.

When you create each separate screen, if you're considering further extension and possible changes, you need to create a separate Fragment with its ViewModel.

Note: in the case when Fragments can be combined in one cluster, and business logic doesn’t imply a massive complexity, several Fragments may use one ViewModel.

Switching between Fragments happens because of interfaces that are implemented in Activity. To do this, each Fragment should have a companion object{ } with the method of Fragment object building with the ability of arguments transfer to Bundle:

companion object { fun newInstance() = FavoriteFragment().apply { arguments = Bundle() } }

Architecture solutions

When basic components are created, it is time to focus on architecture. Schematically it will look like the clean architecture made by the famous Robert C. Martin or Uncle Bob. But since I use RxJava2, I got rid of the Boundaries interfaces (as the way to ensure the Dependency Rule execution) in favor of the standard Observable and Subscriber.

Apart from this, using RxJava2 toolsI have integrated data conversion for more flexible work with it. It concerns both working with server responses and with databases.

In addition to the primary model, I will create a server response model and separate table model for Room. Converting data between these two models, you can make any changes during the conversion process, convert server responses, and save the necessary data to the base before it displays on the UI and so on.

Fragments are responsible for the UI, and ViewModel Fragments are responsible for business logic execution. If business logic concerns the whole activity, then ViewModel Activity.

ViewModels get data from a provider by its initialization via val … by lazy{}, if you need an invariable object, or lateinit var, if vice versa. After the business logic's execution, if you need to transfer data to change the UI, you create new MutableLiveData in the ViewModel that you will use in the observeLiveData() method of our Fragment.

It sounds quite easy. Implementation is straightforward as well.

An essential component of our architecture is a data converter based on a simple conversion from one data type to another. For conversion of RxJava data stream, SingleTransformer or FlowableTransformer are used depending on the type. In the case of our app, interface and abstract class of converter look like the following:

interface BaseDataConverter { fun convertInToOut(inObject: IN): OUT fun convertOutToIn(outObject: OUT): IN fun convertListInToOut(inObjects: List?): List? fun convertListOutToIn(outObjects: List?): List? fun convertOUTtoINSingleTransformer(): SingleTransformer fun convertListINtoOUTSingleTransformer(): SingleTransformer
    
      } abstract class BaseDataConverterImpl : BaseDataConverter { override fun convertInToOut(inObject: IN): OUT = processConvertInToOut(inObject) override fun convertOutToIn(outObject: OUT): IN = processConvertOutToIn(outObject) override fun convertListInToOut(inObjects: List?): List = inObjects?.map { convertInToOut(it) } ?: listOf() override fun convertListOutToIn(outObjects: List?): List = outObjects?.map { convertOutToIn(it) } ?: listOf() override fun convertOUTtoINSingleTransformer() = SingleTransformer { it.map { convertInToOut(it) } } override fun convertListINtoOUTSingleTransformer() = SingleTransformer
     
       { it.map { convertListOutToIn(it) } } protected abstract fun processConvertInToOut(inObject: IN): OUT protected abstract fun processConvertOutToIn(outObject: OUT): IN }
     
    

In this example, I use basic conversions such as model-model, list of models - list of models, and the same combinations but only using SingleTransformer for processing of server responses and requests in the database.

Let’s start with network - with RestClient. retrofitBuilder method will be the following:

fun retrofitBuilder(): Retrofit = Retrofit.Builder() .baseUrl(BASE_URL) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .addConverterFactory(NullOrEmptyConverterFactory().converterFactory()) .addConverterFactory(GsonConverterFactory.create(createGsonBuilder())) .client(createHttpClient()) .build()
//base url const val BASE_URL = "//newsapi.org"

Using third-party APIs, there is always a chance to get an absolute null response from the server, and there may be plenty of reasons for it. That is why an additional NullOrEmptyConverterFactory will help handle the situation. This is how it looks:

class NullOrEmptyConverterFactory : Converter.Factory() { fun converterFactory() = this override fun responseBodyConverter(type: Type?, annotations: Array, retrofit: Retrofit): Converter? { return Converter { responseBody -> if (responseBody.contentLength() == 0L) { null } else { type?.let { retrofit.nextResponseBodyConverter(this, it, annotations)?.convert(responseBody) } } } } }

To create models, it is necessary to build on an API. As an example, I will use the free of charge APU for non-commercial use from newsapi.org. It has a rather extensive list of requested functionality, but I will use a small part for this example. After a quick registration, you get access to the API and your api key which is required for each request.

As the endpoint, I will use //newsapi.org/v2/everything. From the suggested query I choose the following: q - search query, from - sorting from date, to - sorting to date, sortBy - sorting by selected criterion, and must-have apiKey.

After RestClient creation, I create an API interface with the selected Query for our app:

interface NewsApi { @GET(ENDPOINT_EVERYTHING) fun getNews(@Query("q") searchFor: String?, @Query("from") fromDate: String?, @Query("to") toDate: String?, @Query("sortBy") sortBy: String?, @Query("apiKey") apiKey: String?): Single }
//endpoints const val ENDPOINT_EVERYTHING = "/v2/everything"

We will receive this response in NewsNetworkModel:

data class NewsNetworkModel(@SerializedName("articles") var articles: List? = listOf())
data class ArticlesNetworkModel(@SerializedName("title") var title: String? = null, @SerializedName("description") var description: String? = null, @SerializedName("urlToImage") var urlToImage: String? = null)

These data from the whole response will be enough to display a list with a picture, title and news description.

For the implementation of our architectural approach, let’s create general models:

interface News { var articles: List? } class NewsModel(override var articles: List? = null) : News
interface Article { var id: Long? var title: String? var description: String? var urlToImage: String? var isAddedToFavorite: Boolean? var fragmentName: FragmentsNames? } class ArticleModel(override var id: Long? = null, override var title: String? = null, override var description: String? = null, override var urlToImage: String? = null, override var isAddedToFavorite: Boolean? = null, override var fragmentName: FragmentsNames? = null) : Article

Since the Article model will be used for the connection with the database and data displaying in the adapter, we need to add 2 margins that I will use for changing the UI elements in the list.

When everything is ready for the request, I create converters for network models that we will use in the query of news receiving via NetworkModule.

Converters are created in reverse order from nesting, and they convert in direct order accordingly. So the first one I create on Article, the second one on News:

interface ArticlesBeanConverter class ArticlesBeanDataConverterImpl : BaseDataConverterImpl(), ArticlesBeanConverter { override fun processConvertInToOut(inObject: ArticlesNetworkModel): Article = inObject.run { ArticleModel(null, title, description, urlToImage, false, FragmentsNames.NEWS) } override fun processConvertOutToIn(outObject: Article): ArticlesNetworkModel = outObject.run { ArticlesNetworkModel(title, description, urlToImage) } }
interface NewsBeanConverter class NewsBeanDataConverterImpl : BaseDataConverterImpl(), NewsBeanConverter { private val articlesConverter by lazy { ArticlesBeanDataConverterImpl() } override fun processConvertInToOut(inObject: NewsNetworkModel): News = inObject.run { NewsModel(articles?.let { articlesConverter.convertListInToOut(it) }) } override fun processConvertOutToIn(outObject: News): NewsNetworkModel = outObject.run { NewsNetworkModel(articles?.let { articlesConverter.convertListOutToIn(it) }) } }

As you can see above, during the News object conversion, the conversion of the Article objects list is also executed.

Once converters for network models are created, let’s proceed to the creation of the module (repository network). Since there are usually more than 1 or 2 interface APIs, you need to create BaseModule, typed API, Network Module, and ConversionModel.

This is how it looks:

abstract class BaseNetworkModule(val api: A, val dataConverter: BaseDataConverter)

Accordingly, it will be the following on NewsModule:

interface NewsModule { fun getNews(fromDate: String? = null, toDate: String? = null, sortBy: String? = null): Single } class NewsModuleImpl(api: NewsApi) : BaseNetworkModule(api, NewsBeanDataConverterImpl()), NewsModule { override fun getNews(fromDate: String?, toDate: String?, sortBy: String?): Single = api.getNews(searchFor = SEARCH_FOR, fromDate = fromDate, toDate = toDate, sortBy = sortBy, apiKey = API_KEY) .compose(dataConverter.convertOUTtoINSingleTransformer()) .onErrorResumeNext(NetworkErrorUtils.rxParseError()) }

For this API, the API key is a crucial parameter for requesting by any suggested endpoints. That is why you need to make sure that optional parameters won’t be specified beforehand, and you need to nullify them by default.

As you can see above, I applied the data conversion during response processing.

Let’s work with the database. I create the app database, call it AppDatabase and inherit from RoomDatabase().

For database initialization, it is necessary to create DatabaseCreator, which should be initialized in App class.

object DatabaseCreator { lateinit var database: AppDatabase private val isDatabaseCreated = MutableLiveData() private val mInitializing = AtomicBoolean(true) @SuppressWarnings("CheckResult") fun createDatabase(context: Context) { if (mInitializing.compareAndSet(true, false).not()) return isDatabaseCreated.value = false Completable.fromAction { database = Room.databaseBuilder(context, AppDatabase::class.java, DB_NAME).build() } .compose { completableToMain(it) } .subscribe({ isDatabaseCreated.value = true }, { it.printStackTrace() }) } }

Now in the onCreate() method of the App class I initialize Stetho and database:

override fun onCreate() { super.onCreate() instance = this Stetho.initializeWithDefaults(this) DatabaseCreator.createDatabase(this) }

When the database is created, I create a basic Dao with a single insert() method inside:

@Dao interface BaseDao
    
      { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(obj: I) }
    

Based on the idea of our app, I will save news I like, get the list of saved articles, delete saved news by its ID, or delete all news from the table. Our NewsDao will be the following:

@Dao interface NewsDao : BaseDao { @Query("SELECT * FROM $NEWS_TABLE") fun getNews(): Single
    
      @Query("DELETE FROM $NEWS_TABLE WHERE id = :id") fun deleteNewsById(id: Long) @Query("DELETE FROM $NEWS_TABLE") fun deleteFavoriteNews() }
    

And news table will be the following:

@Entity(tableName = NEWS_TABLE) data class NewsDatabase(@PrimaryKey var id: Long?, var title: String?, var description: String?, var urlToImage: String?)

When the table is created, let’s link it with a database:

@Database(entities = [NewsDatabase::class], version = DB_VERSION) abstract class AppDatabase : RoomDatabase() { abstract fun newsDao(): NewsDao }

Now we can work with the database, save and extract data from it.

As for the module (repository network), I will create a model converter - database table model:

interface NewsDatabaseConverter class NewsDatabaseDataConverterImpl : BaseDataConverterImpl(), NewsDatabaseConverter { override fun processConvertInToOut(inObject: Article): NewsDatabase = inObject.run { NewsDatabase(id, title, description, urlToImage) } override fun processConvertOutToIn(outObject: NewsDatabase): Article = outObject.run { ArticleModel(id, title, description, urlToImage, true, FragmentsNames.FAVORITES) } }

BaseRepository is available for working with various tables. Let’s write it. It will look like the following in its simplest version which is enough for the app:

abstract class BaseRepository { protected abstract val dataConverter: BaseDataConverter protected abstract val dao: BaseDao }

After creating BaseRepository, I will create NewsRepository:

interface NewsRepository { fun saveNew(article: Article): Single fun getSavedNews(): Single
    
      fun deleteNewsById(id: Long): Single fun deleteAll(): Single } object NewsRepositoryImpl : BaseRepository(), NewsRepository { override val dataConverter by lazy { NewsDatabaseDataConverterImpl() } override val dao by lazy { DatabaseCreator.database.newsDao() } override fun saveNew(article: Article): Single = Single.just(article) .map { dao.insert(dataConverter.convertInToOut(it)) } .map { article } override fun getSavedNews(): Single
     
       = dao.getNews().compose(dataConverter.convertListINtoOUTSingleTransformer()) override fun deleteNewsById(id: Long): Single = Single.just(dao.deleteNewsById(id)) override fun deleteAll(): Single = Single.just(dao.deleteFavoriteNews()) }
     
    

When permanent repositories and modules are created, data should flow from an app provider that will request data either from the network or database depending on the requirements. A provider should combine both repositories. Considering the capabilities of various models and repositories, I will create BaseProvider:

abstract class BaseProvider { val repository: DBR = this.initRepository() val networkModule: NM = this.initNetworkModule() protected abstract fun initRepository(): DBR protected abstract fun initNetworkModule(): NM }

Then NewsProvider will look like the following:

interface NewsProvider { fun loadNewsFromServer(fromDate: String? = null, toDate: String? = null, sortBy: String? = null): Single fun saveNewToDB(article: Article): Single fun getSavedNewsFromDB(): Single
    
      fun deleteNewsByIdFromDB(id: Long): Single fun deleteNewsFromDB(): Single } object NewsProviderImpl : BaseProvider(), NewsProvider { override fun initRepository() = NewsRepositoryImpl override fun initNetworkModule() = NewsModuleImpl(RestClient.retrofitBuilder().create(NewsApi::class.java)) override fun loadNewsFromServer(fromDate: String?, toDate: String?, sortBy: String?) = networkModule.getNews(fromDate, toDate, sortBy) override fun saveNewToDB(article: Article) = repository.saveNew(article) override fun getSavedNewsFromDB() = repository.getSavedNews() override fun deleteNewsByIdFromDB(id: Long) = repository.deleteNewsById(id) override fun deleteNewsFromDB() = repository.deleteAll() }
    

Now we will get the list of news easily. In NewsViewModel we will declare all methods of our provider for further use:

val loadNewsSuccessLiveData = MutableLiveData() val loadLikedNewsSuccessLiveData = MutableLiveData
    
     () val deleteLikedNewsSuccessLiveData = MutableLiveData() private val loadNewsSuccessConsumer = Consumer { loadNewsSuccessLiveData.value = it } private val loadLikedNewsSuccessConsumer = Consumer
     
       { loadLikedNewsSuccessLiveData.value = it } private val deleteLikedNewsSuccessConsumer = Consumer { deleteLikedNewsSuccessLiveData.value = true } private val dataProvider by lazy { NewsProviderImpl } init { isLoadingLiveData.apply { addSource(loadNewsSuccessLiveData) { value = false } }
     
    
@SuppressLint("CheckResult") fun loadNews(fromDate: String? = null, toDate: String? = null, sortBy: String? = null) { isLoadingLiveData.value = true isEmptyDataPlaceholderLiveData.value = false dataProvider.loadNewsFromServer(fromDate, toDate, sortBy) .compose(RxUtils.ioToMainTransformer()) .subscribe(loadNewsSuccessConsumer, onErrorConsumer) } @SuppressLint("CheckResult") fun saveLikedNew(article: Article) { Single.fromCallable { Unit } .flatMap { dataProvider.saveNewToDB(article) } .compose(RxUtils.ioToMainTransformerSingle()) .subscribe({}, { onErrorConsumer }) } @SuppressLint("CheckResult") fun removeLikedNew(id: Long) { Single.fromCallable { Unit } .flatMap { dataProvider.deleteNewsByIdFromDB(id) } .compose(RxUtils.ioToMainTransformerSingle()) .subscribe({}, { onErrorConsumer }) } @SuppressLint("CheckResult") fun loadLikedNews() { Single.fromCallable { Unit } .flatMap { dataProvider.getSavedNewsFromDB() } .compose(RxUtils.ioToMainTransformerSingle()) .subscribe(loadLikedNewsSuccessConsumer, onErrorConsumer) } @SuppressLint("CheckResult") fun removeLikedNews() { Single.fromCallable { Unit } .flatMap { dataProvider.deleteNewsFromDB() } .compose(RxUtils.ioToMainTransformerSingle()) .subscribe(deleteLikedNewsSuccessConsumer, onErrorConsumer) }

Having declared all methods which executed business logic in ViewModel, we will call them back from Fragment where in observeLiveData() results of each declared LiveData will be processed.

To implement it easily, in SEARCH_FOR parameters I randomly chose Apple, and further sorting will be performed by the popularity tag. If necessary, you can add minimum functionality for changing these parameters.

Since newsapi.org doesn’t provide you with a news ID, I accept the element index as ID. Sorting by popularity tag is also implemented via the API. But to avoid data rewriting with the same IDs in the base during sorting by popularity, I will verify data availability in the base before news list loading. If the base is empty - the new list is loading, if not - notification is shown.

Let’s call in the onViewCreated() method of NewsFragment the following method:

private fun loadLikedNews() { viewModel.loadLikedNews() }

Since our base is empty, method loadNews() will be launched. In the observeLiveData method I will use our loading LiveData - viewModel.loadNewsSuccessLiveData.observe(..){news →}, where we will receive the list of news articles if the request is successful, and then transfer it to the adapter:

isEmptyDataPlaceholderLiveData.value = news.articles?.isEmpty() with(newsAdapter) { news.articles?.toMutableList()?.let { clear() addAll(it) } notifyDataSetChanged() } loadNewsSuccessLiveData.value = null

Having launched the app, you will see the following result:

In the toolbar menu on the right side, you can see 2 options - sorting and favorites. Let’s sort the list by popularity and get the following result:

If you go to Favorites, you will see a Placeholder only, since there is no data in the base. The Favorites screen will look like the following:

UI fragment of Favorites has a screen for the displaying of the list of liked news and only one option in the toolbar for database cleaning. When you save data clicking on “Like”, screens will look like the following:

As I wrote above, in the standard model 2 additional margins were added to the general model, and these margins are used for data displaying in the adapter. Now you can see that elements of saved news lisst have no option to add to Favorites.

var isAddedToFavorite: Boolean? var fragmentName: FragmentsNames?

If you click “Like” again, the saved element will be removed from the base.

Wrapping up

Thus, I showed you a simple and clear approach to Android app development. We kept up with the main principles of Clean Architecture but simplified it as much as possible.

What is the difference between the architecture I provided you with and Clean Architecture from Mr. Martin? In the very beginning, I have noted that my architecture is similar to CA since it is used as the basis. Here is the CA scheme below:

The event goes to Presenter, and then to Use Case. Use Case requests Repository. Repository receives data, created Entity, and transfers it to UseCase. Thus, Use Case receives all the necessary Entities. After the implementation of business logic, you get the result that comes back to Presenter, and it, in turn, transfers the result to UI.

In the scheme below, Controller calls methods from InputPort that implements UseCase, and the OutputPort interface receives this response and Presenter implements it. Instead of UseCase direct depending on Presenter, it depends on the interface in its layers, and it doesn’t contradict with Dependency Rule, and Presenter should implement this interface.

Thus, processes that are implemented in the the external layer don’t affect processes in the internal layer. What is Entity in Clean Architecture? In fact, it is everything that doesn’t depend on a specific app, and it will be a general concept for many apps. But in the mobile development process Entity is business objects of the app, which contain general and high-level rules (app business logic).

What about Gateways? As I see it, Gateways is a repository for working with the database and a module for working with a network. We got rid of the controller since initially Clean Architecture was created for structuring business apps of high complexity, and data converters perform its functions in my app. ViewModels transfer data to Fragments for UI processing replacing Presenters.

I min tilgang holder jeg også nøje med Dependency Rule, og logikken med arkiver, moduler, modeller og udbydere er indkapslet, og adgang til dem er mulig via grænseflader. Således påvirker ændringer i eksterne lag ikke interne lag. Og implementeringsprocessen ved hjælp af RxJava2 , KotlinRx og Kotlin LiveData gør udviklerens opgaver lettere, klarere, og koden bliver læst og let udvidelig.