Kuinka yksinkertaistaa Android-sovelluksesi arkkitehtuuria: Yksityiskohtainen opas koodinäytteillä

Yksittäiset ohjelmoijat kehittävät mobiilisovelluksiaan näkemyksensä mukaan lukien ideoitaan ja näkemyksiään eri tehtävien suorittamisesta. Joskus he saattavat jättää huomioimatta olio- tai toiminnallisen ohjelmoinnin pääperiaatteet, mikä voi johtaa hämmennykseen kehittäjien keskuudessa.

Tämä on huono - he eivät pysty käsittelemään koodiaan. Ja seuraava kehittäjä, joka tarvitsee ylläpitää projektia tai muokata sitä, voi mennä hulluksi. On parempi rakentaa tällaiset projektit alusta alkaen, koska ylläpidosta tulee monimutkainen prosessi.

Kunnes Google julkaisi ensimmäisen tuetun arkkitehtuurinsa, melkein kaikki ohjelmistokehitysyritykset käyttivät omaa arkkitehtuuriaan. Tämä auttoi heitä selventämään koodiaan ja mahdollisti vaihtamisen projektista toiseen. Mutta jos kehittäjä vaihtaisi yrityksiä, heidän vie jonkin aikaa oppia uusi arkkitehtuuri uuden projektin mukana.

Tällä hetkellä Android-kehittäjille on tarjolla 16 erilaista arkkitehtuuria Googlen ansiosta:

  • 6 stabiilia näytettä (Java);
  • 2 stabiilia näytettä (Kotlin):
  • 4 ulkoista näytettä;
  • 3 vanhentunutta näytettä;
  • 1 näyte käynnissä.

Käytettävä arkkitehtuuri riippuu erityisestä tarkoituksestasi, lähestymistavastasi ja erilaisten työkalupakettien käytöstä erilaisten toimintojen toteuttamiseen. Ja se riippuu ohjelmointikielestä.

Kaikilla näillä arkkitehtuureilla on kuitenkin yksi yhteinen arkkitehtoninen perusta, joka jakaa lähes yhtä hyvin logiikan verkkojen, tietokantojen, riippuvuuksien ja takaisinsoittojen käsittelyyn.

Prosessin aikana käytettävät työkalut

Tutkittuani kaikki nämä arkkitehtuurit, rakensin yksinkertaistetun lähestymistavan ja keksin arkkitehtuurin, jossa oli vähemmän kerroksia. Näytän sinulle, kuinka yksinkertainen Android-sovellus voidaan toteuttaa, joka lataa uutisluettelon, jonka avulla voit tallentaa tarinoita Suosikkeihin ja poistaa sitten tarvittaessa lähestymistavallani.

Tässä on yhteenveto käyttämästäni tekniikasta:

  • Kotlin kehittää sovellusta yhdessä AndroidX- kirjaston kanssa
  • Huone SQLite tietokantana
  • Stetho selata tietoja tukiasemissa
  • Retrofit2 ja RxJava2 auttavat palvelinpyyntöjen kirjaamisessa ja palvelinvastausten saamisessa.
  • Liukua käsittelemään kuvia
  • Android-arkkitehtuurikomponentit (LiveData, ViewModel, Room) ja ReactiveX (RxJava2, RxKotlin и RxAndroid) riippuvuuksien rakentamiseen, dynaamiseen datamuutokseen ja asynkronian käsittelyyn.  

Joten tämä on mobiilisovellustekniikan pino, jota käytin projektissani.

Aloitetaan

Ensiaskeleet

Yhdistä AndroidX . Vuonna gradle.properties App tasolla, kirjoittaa seuraavaa:

android.enableJetifier=true android.useAndroidX=true

Nyt on välttämätöntä korvata build.gradlen riippuvuudet sovellusmoduulitasolla Androidista AndroidX: ään. Sinun tulisi purkaa kaikki riippuvuudet laajennettavaksi, kuten näet esimerkistä Kotlinin valmiista versioinnista build.gradle -sovellustasolla. Ja sitten lisätään Gradle-versiot sinne:

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 } }

Kaikille muille riippuvuuksille rakennan sen ext- tiedoston, johon lisätään ehdottomasti kaikki riippuvuudet, mukaan lukien SDK-versiot, jakamalla versiot ja luomalla riippuvuusjoukot, jotka toteutetaan edelleen build.gradlessa sovellustasolla. Se näyttää tältä:

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" ] }

Versio- ja massanimet toteutetaan satunnaisesti. Sen jälkeen toteutamme riippuvuudet rakenteessa build.gradle sovellustasolla seuraavasti:

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

Älä unohda määrittää multiDexEnabled true -asetusta oletusasetuksissa. Useimmissa tapauksissa saavutat nopeasti käytettyjen menetelmien lukumäärän.

Samalla tavalla sinun on ilmoitettava kaikki sovelluksen riippuvuudet. Lisätään oikeudet yhdistää sovelluksemme Internetiin:

Jos luettelossa ei ole nimeä, sinun on tehtävä se, koska Stetho ei näe nimetöntä sovellusta etkä voi etsiä tietokantaa.

Peruskomponenttien rakentaminen

On syytä huomata, että MVVM-mallia (Model-View-ViewModel) käytettiin tämän arkkitehtuurin rakentamisen perustana.

Aloitetaan kehitys. Ensimmäinen asia, joka sinun on tehtävä, on luoda luokka, joka perii Application (). Tässä luokassa annamme pääsyn sovelluskontekstiin sen jatkokäyttöä varten.

@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) } }

Toinen vaihe on luoda sovelluksen peruskomponentit, jotka alkavat ViewModelilla, joita käytän kullekin toiminnalle tai fragmentille.

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

Tällä sovelluksella ei ole monimutkaisia ​​toimintoja. Mutta perus ViewModeliin laitamme 3 LiveDataa :

  • virheen käsittely
  • ladataan käsittelyä etenemispalkin ollessa näkyvissä
  • ja koska minulla on sovellus, jossa on luetteloita, kuitin ja tietojen saatavuuden käsittely adapterissa paikkamerkkinä, joka näkyy niiden poissa ollessa.
val errorLiveData = MediatorLiveData() val isLoadingLiveData = MediatorLiveData() val isEmptyDataPlaceholderLiveData = MediatorLiveData()

Jos haluat siirtää toiminnon toteutuksen tulokset LiveDataan, käytän Consumer- sovellusta   .

Jos haluat käsitellä virheitä missä tahansa sovelluksen kohdassa, sinun on luotava kuluttaja, joka siirtää Throwable.message- arvon errorLiveDataan .

Lisäksi VewModel-perussovelluksessa sinun on luotava menetelmä, joka saa LiveData-luettelon edistymispalkin näyttämiseksi niiden toteuttamisen aikana.

Perus ViewModel näyttää tältä:

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 } } }

Sovelluksessamme ei ole järkevää luoda muutama aktiviteetti kahdelle näytölle (uutisluettelonäyttö ja suosikkiluettelonäyttö). Mutta koska tämä näyte osoittaa optimaalisen ja helposti laajennettavan arkkitehtuurin toteutuksen, luon perussovelluksen.  

Sovelluksemme rakennetaan yhteen toimintaan ja kahteen osaan, jotka täytetään kontti-toiminnossa. Toimintamme XML-tiedosto on seuraava:

missä include_placeholder ja include_progress -palkki näyttävät tältä:

BaseActivity näyttää tältä:

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) } }

Otetaan käyttöön, kuinka mahdolliset virheet näytetään kaikkien tulevien toimintojen prosesseissa. Teen sen tavallisen paahtoleivän muodossa yksinkertaisuuden vuoksi.

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

ja lähetä tämä virheteksti näyttötavaan:

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

Aloitan perustoiminnossa pysymään mukana perusnäkymämallissa olevan errorLiveData- arvon muutoksissa . StartObserveLiveData () menetelmä muuttua seuraavasti:

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

Kun nyt käytät ViewModel- perusohjelman onErrorConsumeria onError- prosessorina, näet viestin toteutetusta menetelmävirheestä.

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.

Lähestymistavassani pysyn myös riippuvuussäännön kanssa tiukasti, ja arkistojen, moduulien, mallien ja palveluntarjoajien logiikka on kapseloitu, ja pääsy niihin on mahdollista rajapintojen kautta. Siten ulkoisten kerrosten muutokset eivät vaikuta sisäisiin kerroksiin. Ja toteutusprosessi RxJava2: n , KotlinRx : n ja Kotlin LiveDatan avulla tekee kehittäjän tehtävistä helpompia, selkeämpiä, ja koodista tulee helposti luettava ja helposti laajennettavissa.