Для лучшего понимания чистой архитектуры давайте создадим примерный проект. Это приложение, на первой странице которого показывается список персонажей из мультсериала «Рик и Морти» с данными. Нажимая на каждого персонаж, на следующей странице можно увидеть серии, в которых эти персонажи появляются.
Поэтому у нас два типа сущностей: персонаж и серия.
Итак, прежде всего разберёмся, почему нам надо использовать чистую архитектуру?
- Разделение обязанностей — разделение кода на части или различные модули, которые имеют определённые обязанности, облегчает его сопровождение и дальнейшие изменения.
- Слабая связанность — в гибкий код можно легко вносить изменения, не меняя всей системы.
- Лёгкость тестирования.
В проекте у нас три слоя: приложение (представление), данные и предметная область.
Данные: в этом слое есть абстрактное определение различных источников данных и как их следует использовать. Мапперы выполняют отображение ответа сервера на модели баз данных. Модели представляют собой модель ответа сервера. Репозиторий существует для реализации вызовов API. Операции с базами данных — для реализации интерфейса «Dao», а пакет API — для определения методов API-вызовов с сервера. В обычном приложении мы, как правило, храним репозиторий и интерфейс репозитория в одном пакете. И можем делать это локально, чтобы везде иметь прямой доступ. Но в этом случае слой данных ни в коем разе не должен знать о других слоях.
Предметная область: этот слой известен как бизнес-логика. Это правила вашего бизнеса. Здесь находится пакет model
, содержащий модели баз данных. А также репозиторий, являющийся лишь интерфейсом, и прецеденты. А что такое прецедент? Как известно, прецеденты выполняют единственную задачу. И в случае с персонажами, когда надо получить данные из базы данных, мы пишем прецедент с этой самой задачей получения данных из базы данных.
Приложение: этот слой взаимодействует только с UI (пользовательским интерфейсом) и содержит фрагменты, activity
(т. е. визуальный интерфейс с отдельным экраном для одного действия пользователя), ViewModel
и Di
. Под Di
подразумевается модуль, предназначенный для этого фрагмента или activity
.
На этом рисунке показано, как слои взаимодействуют друг с другом:
Но закончим уже с текстом и перейдём скорее к коду.
Итак, в проекте мы используем:
Kotlin
Зависимости:
```java
//retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.9.0'
implementation "com.jakewharton.retrofit:retrofit2-rxjava2-adapter:1.0.0"
implementation group: 'com.squareup.retrofit2', name: 'converter-jackson', version: '2.4.0'
//implementation "com.fasterxml.jackson.module:jackson-module-kotlin:2.8.6"
//rx
implementation 'io.reactivex.rxjava2:rxjava:2.2.19'
implementation "io.reactivex.rxjava2:rxandroid:2.1.1"
//jackson
implementation 'com.fasterxml.jackson.core:jackson-core:2.10.1'
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.10.1'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.10.1'
//Hilt
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha02'
kapt 'androidx.hilt:hilt-compiler:1.0.0-alpha02'
//Room
implementation 'androidx.room:room-runtime:2.2.5'
implementation "android.arch.persistence.room:rxjava2:2.2.4"
implementation "androidx.room:room-rxjava2:2.2.5"
kapt "androidx.room:room-compiler:2.2.5"
```
Для этого проекта в базе мы создаём три пакета: Episode
(«Серия»), Character
(«Персонаж») и utils
.
Что такое utils
? Это пакет, содержащий базовые и общие классы, которые используются более чем в двух классах.
Episode
(«Серия») и Character
(«Персонаж») содержат данные, предметную область (бизнес-логику) и представление:
Эти пакеты будут выглядеть так:
Пакет API содержит интерфейс «CharacterApi», который является лишь методом взаимодействия с сервером, и «CharacterApiImpl» для реализации этого взаимодействия.
```java
interface CharacterApi {
@GET("api/character")
fun getCharacters(): Single<ResponseCharacter>
}
```
Пакет базы данных содержит интерфейс «Dao»:
```java
@Dao
interface CharacterDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertCharacter(characters: CharactersData): Maybe<Long>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertCharacters(characters: List<CharactersData>): Maybe<List<Long>>
}
```
В «CharacterRepositoryImpl» вызываем только те методы, которые нам нужны. Здесь нет бизнес-логики.
```java
class CharactersRepositoryImpl @Inject constructor(
private val charactersApi: CharactersApiImpl,
private val characterDao: CharacterDao
) : CharactersRepository {
override fun getCharacters(): Single<ResponseCharacter> {
return charactersApi.getCharacters()
}
}
```
В предметной области мы определяем модель, которую надо сохранить в базе данных:
```java
@Entity(tableName = "Characters")
@TypeConverters(StringConverter::class)
data class CharactersData(
@PrimaryKey(autoGenerate = true)
val characterId: Int? = 0,
var image: String? = null,
val gender: String? = null,
val url: String? = null,
@SuppressWarnings(RoomWarnings.DEFAULT_CONSTRUCTOR)
@Embedded(prefix = "org")
var origin: Origin? = null,
var name: String? = null,
@SuppressWarnings(RoomWarnings.DEFAULT_CONSTRUCTOR)
@Embedded(prefix = "loc")
var location: Location? = null,
var status: String? = null,
val episodes : List<String?>? = null
)
```
Репозиторий — это интерфейс, реализуемый в приведённом выше классе.
```java
interface CharactersRepository {
fun getCharacters(): Single<ResponseCharacter>
}
```
Прецедент, подобный упомянутому ранее, выполняет только одну задачу. Например, получение данных с сервера с сохранением их в базе данных.
```java
class GetCharactersUseCase @Inject constructor(private val charactersRepository: CharactersRepository) {
sealed class Result {
object Loading : Result()
data class Success(val responseCharacter: List<CharactersData>) : Result()
data class Failure(val throwable: Throwable) : Result()
}
fun getCharacters(hasNetwork: Boolean): Observable<Result> {
return if (!hasNetwork) {
return charactersRepository.getCharactersFromDb()
.toObservable()
.map {
Success(it) as Result
}
.onErrorReturn { Failure(it) }
.startWith(Result.Loading)
} else {
charactersRepository.deleteAllCharacters()
charactersRepository.getCharacters().toObservable().map {
val data = CharacterToDbMapper().reverseMap(it.results)
Success(data) as Result
}
.onErrorReturn { Failure(it) }
.startWith(Result.Loading)
}
}
}
```
Мы используем запечатанный класс для передачи данных и наблюдения за ними во ViewModel
. И передаём прецедент конструктору для взаимодействия между этими двумя классами.
Для внедрения зависимостей используем hilt
, поэтому пакета di
нет в Episode
(«Серии») и Character
(«Персонаже»), но в util
мы определяем «NetworkModule»:
```java
@InstallIn(ApplicationComponent::class)
@Module
class NetworkModule {
@Provides
@Singleton
fun provideCharacterApiService(retrofit: Retrofit): CharacterApi =
retrofit.create(CharacterApi::class.java)
@Provides
@Singleton
fun provideEpisodeApiService(retrofit: Retrofit): EpisodeApi =
retrofit.create(EpisodeApi::class.java)
@Provides
@Singleton
fun provideGsonRetrofit(
httpClient: OkHttpClient.Builder,
convertFactory: GsonConverterFactory
): Retrofit =
Retrofit.Builder()
.baseUrl("https://rickandmortyapi.com")
.client(httpClient.build())
.addConverterFactory(convertFactory)
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.build()
@Provides
@Singleton
fun provideOkHttpClient(httpLoggingInterceptor: HttpLoggingInterceptor): OkHttpClient.Builder {
val httpClient = OkHttpClient.Builder()
if (BuildConfig.DEBUG) {
httpClient.addInterceptor(httpLoggingInterceptor)
}
httpClient.retryOnConnectionFailure(true)
return httpClient
}
@Provides
@Singleton
fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor=
HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)
@Provides
@Singleton
fun provideJacksonConverterFactory(): JacksonConverterFactory =
JacksonConverterFactory.create()
@Provides
@Singleton
fun provideGsonConverterFactory(): GsonConverterFactory =
GsonConverterFactory.create()
}
```
И определяем «AppModule» в utils
следующим образом:
```java
@InstallIn(ApplicationComponent::class)
@Module
class DatabaseModule {
@Singleton
@Provides
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"CHARACTERS-DATA.db"
).allowMainThreadQueries()
.build()
}
@Provides
fun provideCharacterDao(database: AppDatabase): CharacterDao {
return database.charactersDao()
}
@Provides
fun provideEpisodeDao(database: AppDatabase): EpisodeDao {
return database.episodesDao()
}
@Provides
fun provideCharacterEpisodeDao(database: AppDatabase): CharacterEpisodeDao {
return database.characterEpisodesDao()
}
```
А в пакете репозитория в utils
определяем модуль репозитория:
```java
@InstallIn(ApplicationComponent::class)
@Module
class RepositoryModule {
@Provides
fun provideEpisodeRepository(repo: EpisodeRepositoryImpl): EpisodeRepository = repo
@Provides
fun provideCharacterEpisodeRepository(repo: CharacterEpisodeRepositoryImpl): CharacterEpisodeRepository =
repo
@Provides
fun provideCharactersRepository(repo: CharactersRepositoryImpl): CharactersRepository = repo
}
```
Заключение
Итак, мы узнали, как это здорово — разрабатывать свой проект с чистой архитектурой для обеспечения лучшей тестопригодности с возможностью повторного использования. Кроме того, мы теперь можем изменить код, не беспокоясь о том, что станет с остальной частью проекта.
Надеюсь, статья была полезной. Проект с чистой архитектурой загружен на GitHub.
Читайте также:
- Выполнение AES/GCM в Android
- RxPermissions: простой способ управления разрешениями в Android M
- Внедрение зависимостей на Android с помощью Hilt
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Golnaz Torabi: Clean Architecture with MVVM