Cómo simplificar la arquitectura de su aplicación de Android: una guía detallada con ejemplos de código

Los programadores individuales desarrollan sus aplicaciones móviles de acuerdo con su visión, incluidas sus ideas y puntos de vista sobre cómo realizar diversas tareas. A veces, pueden ignorar los principios fundamentales de la programación funcional o orientada a objetos, lo que puede generar desorientación entre los desarrolladores.

Esto es malo, no podrán lidiar con su código. Y el próximo desarrollador que necesite mantener el proyecto o modificarlo puede volverse loco. Es mejor reconstruir estos proyectos desde cero, ya que el mantenimiento se convierte en un proceso complicado.

Hasta que Google lanzó su primera arquitectura compatible, casi todas las empresas de desarrollo de software usaban su propia arquitectura. Esto les ayudó a aclarar su código y les permitió cambiar entre proyectos. Pero si un desarrollador cambia de empresa, le llevará algún tiempo aprender esa nueva arquitectura junto con un nuevo proyecto.

Por el momento, hay 16 arquitecturas diferentes para desarrolladores de Android, gracias a Google:

  • 6 muestras estables (Java);
  • 2 muestras estables (Kotlin):
  • 4 muestras externas;
  • 3 muestras en desuso;
  • 1 muestra en curso.

Cualquiera que sea la arquitectura que utilice depende de su propósito, enfoque y aplicación específicos de varios conjuntos de herramientas para la implementación de diversas funcionalidades. Y depende del lenguaje de programación.

Sin embargo, todas estas arquitecturas tienen una base arquitectónica común que divide casi por igual la lógica para trabajar con redes, bases de datos, dependencias y procesar devoluciones de llamada.

Herramientas utilizadas durante el proceso

Después de estudiar todas estas arquitecturas, construí un enfoque simplificado y se me ocurrió una arquitectura con menos capas. Le mostraré cómo implementar una aplicación de Android simple que carga una lista de noticias, le permite guardar historias en Favoritos y luego eliminarlas si es necesario usando mi enfoque.

Aquí hay un resumen de la tecnología que utilicé:

  • Kotlin para desarrollar la aplicación junto con la biblioteca de AndroidX
  • Room SQLite como base de datos
  • Stetho para navegar por los datos en bases
  • Retrofit2 junto con RxJava2 para ayudar a registrar las solicitudes del servidor y obtener respuestas del servidor.
  • Deslizar para procesar imágenes
  • Componentes de la arquitectura de Android (LiveData, ViewModel, Room) y ReactiveX (RxJava2, RxKotlin и RxAndroid) para crear dependencias, cambios dinámicos de datos y procesar asincronía.  

Así que esta es la pila de tecnología de aplicaciones móviles que usé para mi proyecto.

Empecemos

Primeros pasos

Conecta AndroidX . En gradle.properties en el nivel de la aplicación, escriba lo siguiente:

android.enableJetifier=true android.useAndroidX=true

Ahora es necesario reemplazar las dependencias en build.gradle en el nivel del módulo de la aplicación de Android a AndroidX. Debe extraer todas las dependencias a ext, como puede ver en el ejemplo de control de versiones listo para usar de Kotlin en build.gradle en el nivel de la aplicación. Y luego agrego el control de versiones de Gradle allí:

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

Para todas las demás dependencias, construiré su archivo ext , donde agregaré absolutamente todas las dependencias, incluidas las versiones del SDK, dividiendo el control de versiones y creando dependencias macizas que se implementarán más adelante en build.gradle a nivel de la aplicación. Se verá así:

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

Los nombres de versión y macizo se implementan de forma aleatoria. Después de eso, implementaremos las dependencias en build.gradle a nivel de la aplicación de la siguiente manera:

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

No olvide especificar multiDexEnabled true en las configuraciones predeterminadas. En la mayoría de los casos, alcanzará el límite de la cantidad de métodos utilizados rápidamente.

De la misma manera, debe declarar todas las dependencias de la aplicación. Agreguemos permisos para conectar nuestra aplicación a Internet:

Si no hay un nombre agregado en el manifiesto, debe hacerlo ya que Stetho no verá la aplicación sin nombre y no podrá buscar en la base de datos.

Construcción de componentes básicos

Vale la pena señalar que el patrón MVVM (Model-View-ViewModel) se utilizó como base para construir esta arquitectura.

Comencemos el desarrollo. Lo primero que debe hacer es crear una clase que heredará Application (). En esta clase, daremos acceso al contexto de la aplicación para su uso posterior.

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

El segundo paso es crear componentes básicos de la aplicación comenzando con ViewModel, que usaré para cada actividad o fragmento.

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

Esta aplicación no tiene una funcionalidad complicada. Pero en el ViewModel básico pondremos 3 LiveData principales :

  • procesamiento de errores
  • cargando el proceso con la barra de progreso mostrada
  • y, como tengo una aplicación con listas, procesando el recibo y la disponibilidad de datos en el adaptador como un marcador de posición que se muestra en su ausencia.
val errorLiveData = MediatorLiveData() val isLoadingLiveData = MediatorLiveData() val isEmptyDataPlaceholderLiveData = MediatorLiveData()

Para transferir los resultados de la implementación de la función a LiveData, usaré   Consumer .

To process errors in any place in the app, you need to create a Consumer that will transfer Throwable.message value to errorLiveData.

Also, in the basic VewModel, you will need to create a method that will receive a LiveData list to display the progress bar during their implementation.

Our basic ViewModel will look like this:

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

In our app it doesn't make sense to create a few Activities for two screens (news list screen and favorites list screen). But since this sample shows the implementation of optimal and easily extensible architecture, I will create a basic app.  

Our app will be built on 1 Activity and 2 Fragments which we will inflate in Container activity. The XML file of our Activity will be the following:

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.

In my approach, I also keep up with Dependency Rule strictly, and the logic of repositories, modules, models, and providers is encapsulated, and access to them is possible via interfaces. Thus, changes in external layers don’t affect internal layers. And the implementation process using RxJava2, KotlinRx, and Kotlin LiveData makes the developer’s tasks easier, clearer, and code becomes well-read and easily extensible.