Skip to content

Rick and Morty application that makes use of The Rick and Morty API.

Notifications You must be signed in to change notification settings

Nauruz-Guliev/RickAndMorty

Repository files navigation

Мобильное приложение "Rick and Morty"

Введение

Данное мобильное приложение позволяет познакомиться с миром анимационного сериала Рик и Морти. Пользователю доступна подробная информация о персонажах, эпизодах и локациях, которые предоставляются сервисом "The Rick and Morty Api".

Требования к приложению

  • Главный экран состоит из нижней навигации с тремя вкладками: персонажи, эпизоды и локации.
  • Экран с деталями о персонаже, эпизоде и локации.
  • Списки на главных экранах выполнены в виде двух столбцов.
  • Фильтрация и поиск данных на главных экранах.
  • Обновление данных на каждом экране посредством смахивания сверху вниз (Pull-to-Reresh).
  • Кнопка назад на экранах с деталями.
  • Скрывание нижней навигации на экранах с деталями.
  • Поддержка работы без интернета путём кэширования данных.
  • Возможность навигации на предыдущий экран.
  • Поддержка пагинации на главных экранах.
  • Наличие начального экрана (Splash Screen).
  • Отображение прогресс-индикатора в момент загрузки на всех экранах.
  • Уведомление для пользователя в случае, если не было найдено данных соотвествующих фильтру. (уточнение: уведомление не в шторке)

Какие темы были затронуты в рамках интенсива и применены в проекте

  1. Git

Сохранение версий приложения, разделение на ветки для удобства разработки.

  1. Android studio

Вся разработка велась в Android Studio, сборщик - Gradle. К тому же использовался файл с раширением .toml в сочетании с kotlin для настройки зависомстей, что облегчило работу с модулями.

3,4) View

Помимо обычных view (button, edittext) и viewgroup (linear layout, constraint layout) использовались: ChipGroup для фильтра, общий Searchview для поиска, своя реализация Toolbar, MotionLayout для разорачивания списков на экранах с деталями, Coordinator Layout для реализации выезжающего фильтра. Хотелось достигнуть схожего результата, как в примере Material Backdrop

  1. Fragments

Весь проект построен на связке 1 activity + 6 fragments. Для реализации Splash Screen использовалась официальная библиотека Splash Screen Api, которая появлиась в Android 12. Навигация между фрагментами реализована с помошью Fragment Manager. За всю навигацию в приложении отвечает класс Router, который реализует интерфейсы-роутеры других модулей и предоставляет реализацию этих интерфейсов.

  1. Recycler View

Реализация Recycler View есть на каждом экране, отличаются лишь адаптеры. Для главных экранов использовался PagingDataAdapter, который является частью билиотеки Paging 3. В качестве Layout Manager выступает Staggerred Layout Manager. Для экранов с деталями использовался List Adapter.

  1. Concurrency

На экранах с деталями использовалась связка Handler + Looper, так как результат из Connectivity Manager мог приходить не на главном потоке, вся остальная работа с параллелилизмом велась с помощью RxJava и Kotlin Coroutines.

  1. Network

Работа с сетью производится с помощью Retrofit 2, OkHttp и Glide. OkHttp понадобился для создания перехватчиков (Interceptor) логирования и отслеживания состояния подключения к интернету. Glide используется для загрузки изображений.

  1. SQL

Для взаимодействия с базой данных использовался Room. Так как для пагинации понадобился RemoteMediator и в проекте была RxJava, были добавлены дополнительные завимости для Room.

  1. MVVM / MVP /CA

Приложение спроектировано с использованием шаблона MVVM и чистой архитектуры. Слои явно не знают друг о друге. View подписана на события (состояние) во View Model. Взаимодействие view и viewmodel происходит с помощью LiveData/Kotlin flow. Приложение разделено на 7 модулей. App - связывает все модули приложения. Data отвечает за взаимодействие с внешними источниками данных. Ui - в основном хранит в себе ресурсы, которые могут понадобится в других модулях. Common - хранит данные, которые могут понадобится в любом из модулей. Оставшиеся 3 - фиче-модули locations, episodes, characters, которые несут в себе основную бизнес- логику. Каждый фиче-модуль разделен на Data, Domain, Di, Presentation. В Data слое - мапперы, реализация репозиториев. В Domain - интерфейсы репозиториев, сущности, usecase'ы. Di - компоненты, модули, интерфейсы зависимостей. Presentation - модели, адаптеры, фрагменты, вью модели.

  1. Services

Сервисы не понадобились.

  1. Coroutines

Coroutines использовались для асинхронного выполнения запросов в сеть и доступа к базе данных. В основном использовался Dispatcher - IO, который внедрялся в репозитории. Для хранения состояния в view model был применен Kotlin Flow.

  1. RxJava

RxJava выступала альтернативой Coroutines в местах, где использовалась Java. В качестве Scheduler во всем проекте использовались IO и Android Main Thread. Последний подключался отдельной зависимостью.

  1. Dagger

Зависимости внедрялись с помощью Dagger 2. Из одного модуля в другой зависимость поставляется посредством интерфейсов. Фиче-модуль содержит интерфейс, App модуль его реализует и отдаёт реализацию. Жизненный цикл компонентов в фиче модулях завязан на вью моделе.

  1. Unit Tests

Были написаны несколько unit-тестов. Все тесты, которых не так много, написаны для модуля Episodes.

Содержание

  1. Главный экран - Персонажи.

Данный экран содержит список всех персонажей. Каждый элемент списка отображает: статус, имя, изображение, вид и пол. По нажатию кнопки фильтра раскрывается меню (кнопка выделена на изображение ниже). Поведение экрана со списком персонажей похоже на Bottom Sheet Fragment. Экран со списком представляет из себя развернутый Bottom Sheet. Нажатие кнопки фильтра регистрируется на уровне активити, которая следит за тем, какой фрагмент активен. Если активен фрагмент, который реализует интерфейс LayoutBackDropManager, то у такого фрагмента будет метод toggle. Активити может этот метод вызывать, чтобы фрагмент развернулся/свернулся.

1.1 Фильтрация

Применяется фильтр по кнопке "применить"(apply). Сворачивание по крестику не приводит к применению фильтра. Аналогично реализован и сброс фильтра.

Что происходит во время применения фильтра?

Данные из Chip Group и всех Edit Text преобразуются в класс на уровне вью модели. Вьюмодель хранит состояние данных и фильтров. Чтобы загрузились новые данные, на уровне фрагмента вызывается метод, который сообщает Remote Mediator об обновлении.

adapter?.refresh()

То есть запрос проходит такой путь: fragment -> viewmodel -> usecase -> repository -> remotemediator -> room and retrofit.

Для внедрения фильтра в конструкторе Remote Mediator использовался Assisted Inject

Чтобы данные точно загрузились, необходимо получать одинаковые значения по фильтрам из локального хранилища и из сети. То есть запрос в сеть не может вернуть 5 персонажей, а запрос в базу данных 10. В таком случае скорее всего ничего не загрузится. Чтобы достичь одинакогого результата были использованы следующие методы:

Запрос в базу данных:

 @Query(
        "SELECT * FROM character " +
                "WHERE (:name IS NULL OR name LIKE '%' || :name  || '%') " +
                "AND (:species IS NULL OR species LIKE '%' || :species  || '%') " +
                "AND (:type IS NULL OR type LIKE '%' || :type  || '%') " +
                "AND (:gender IS NULL OR gender LIKE :gender) " +
                "AND (:status IS NULL OR status LIKE :status) " +
                "ORDER BY id ASC"
    )
    fun getCharactersFilteredPaged(
        name: String?,
        species: String?,
        type: String?,
        status: String?,
        gender: String?
    ): PagingSource<Int, CharacterEntity>
Параметры фильтра:
  • name - имя
  • species - вид
  • type - вид
  • gender - пол
  • status - статус

Логика фильтра в запросе для каждого поля совпадает, поэтому рассмотрим запрос на примере с именем.

  (:name IS NULL OR name LIKE '%' || :name  || '%')

:name означает, что на это место будет подставлено значение из парамтра метода getCharactersFilteredPaged с таким именем. IS NULL используется для того, чтобы сделать это сравнение необязательным. То есть, если вместо имени пришел Null, то сравнение даст true, что означает, что любое имя соответсвует фильтру. LIKE '%' || :name: || % используется для того, чтобы искать совпадение по всему имени. Если пользователь ввел 'ort' то Morty должен соответсвовать такому запросу, так как ort содержится в Morty.

Аннотация Query сигнализирует о том, что это SQL запрос.

Возвращаемый класс PagingSource необходим для реализации пагинации и используется в Remote Mediator. Типовой параметр Int - отвечает за индекс сущности, а CharacterEntity - сама сущность.

ORDER BY id ASC означает, что возвращаемые данные будут отсортированы по возрастанию поля ID. Именно в таком виде данные приходят из Api.

Запрос в сеть:

  @GET(CHARACTER_END_POINT)
  fun getCharactersByPageFiltered(
      @Query("page") page: String,
      @Query("name") name: String? = null,
      @Query("species") species: String? = null,
      @Query("type") type: String? = null,
      @Query("status") status: String? = null,
      @Query("gender") gender: String? = null,
      ): Call<CharactersResponseModel>

@Get - гет-запрос.

CHARACTER_END_POINT - константа, вынесенная в отдельное поле.

@Query(значение) - значение будет добавлено в качестве query-параметра в ссылку. Как и в случае с запросом в базу данных, параметры нулабельны. По умолчанию все равны null за исключением page. Page указывает на страницу. В отличие от запроса в базу данных, здесь этот параметр необходим и обязателен.

Call - обертка ретрофита, с помощью которой можно ассинхронно получить данные.

CharactersResponseModel - возвращаемая сущность запроса.

1.2 Поиск

Поиск работает без использования фильтра и производится только по имени. По сути отрабатывает метод фильтра с параметром name. Важно!!! Поиск сбрасывается, когда открываем фильтр.

Как реализован поиск?

Для реализации использовались 2 интерфейса SearchFragment и SearchActivity, которые доступны всем модулям. Фрагмент, в котором доступен поиск, должен реализовать интерфейс SearchFragment вместе с которым появляется метод:

    fun doSearch(searchQuery: String?)

Активити следит за тем, какой фрагмент активен. Если это фрагмент, реализующий интерфейс SearchFragment, то у такого фрагмента найдется метод doSearch, в который передастся строка из Search View активити. Из метода doSearch строка отправляется во вьюмодель, где применяется фильтр, описанный раннее. Интерфейс Search Activity используется для общения фрагмента с активити.

Рассмотрим пример, когда необходимо из фрагмента сбросить фильтр и закрыть поиск по нажатию кнопки сброса фильтра ("clear filter"):

(requireActivity() as? SearchActivity)?.closeSearchInterface()

Помимо сброса фильтра на уровне вьмодели кнопка "clear filter" из фрагмента вызывает активити, которому фрагмент принадлежит, "безопасно" приводит активити к SearchActivity и вызывает метод закрытия поиска. На уровне активити метод выглядит так:

override fun closeSearchInterface() {
      searchCloseButton?.callOnClick()
}

Чтобы скрыть интерфейс поиска, моделируется нажатие кнопки закрыть(крестик в SearchView).

  1. Главный экран - Эпизоды.

    Данный экран содержит список всех эпизодов. Каждый элемент списка отображает: эпизод(код), имя и дату выходу.

    Этот экран похож на экран с персонажами. Фильтр и поиск открываются по нажатию соответсвующих кнопок, как выделено на скриншоте.

2.1 Фильтрация

Фильтрация на данном экране доступна только по двум полям. Применяется фильтр по кнопке "применить". Нажатие крестика не приводит к применению или сбросу фильтра. (Сбросится может только поиск, как описывалось ранее). Крестик скрывает интерфейс фильтра.

Помимо количества фильтров, отличие от вкладки с персонажами в том, что Bottom Sheet переходит в состояние STATE_HALF_EXPANDED вместо STATE_COLLAPSED, как это происходит на экране с персонажами. Также меняется видимость списка с alpha 1 до alpha 0.3 в полу-свернутом состоянии.

2.2 Поиск

Реализация поиска аналогична тому, как это сделано на экране с персонажами и отличий, кроме используемых методов-эндпоинтов - нет. Здесь поиск так же представляет из себя применение фильтра по полю name. Также, если данных не было найдено, есть возможность сбросить фильтр.

  1. Главный экран - Локации.

Данный экран содержит список всех эпизодов. Каждый элемент списка отображает: тип, имя и измерение.

Список локаций в реализации почти не отличается с персонажами и эпизодами. Фильтр и поиск открываются аналогично по нажатию соответсвующих кнопок, выделенных на изображениях ниже.

3.1 Фильтрация

Фильтр на экране локаций состоит из 3 полей. Работает точно так же, как и на экране с эпизодами. Отличие лишь в вызываемых эндпоинтах у api и методов в базе данных.

3.2 Поиск

Поиск так же ничем не отличается от экранов с персонажами и эпизодами. Применяется фильтр по полю name. Открытие фильтра сбрасывает поиск, потому что фильтр - это продвинутый поиск.

  1. Детальный экран - Персонаж.

Здесь пользователю доступна полная информация о персонаже с возможностью перехода к локации или к месту происхождения персонажа, а также к эпизоду из списка.

Навигация

Для навигации в модуле characters есть интерфейс:

interface CharactersRouter {
    fun navigateToCharacterDetails(id: Int)
    fun navigateToLocationDetails(id: Int)
    fun navigateToEpisodeDetails(id: Int)
}

Реализует этот интерфейс главный роутер приложения и с помощью Dagger эту реализацию предоставляет. В нем настраиваются транзакции: добавление фрагментов в backstack, создание и передача bundle и сам переход.

При переходе из экрана с персонажами на другой экран, вызывается метод в главном роутере. Этот метод при помощи fragment manager совершает транзакцию, передав ID. Новый фрагмент (детали локации или эпизода) получает ID из bundle и передаёт дальше посредством Assisted Inject во view model.

Экран с эпизодами представляет из себя вертикальный список с одним столбцом.

Раскрывается он свайпом снизу вверх. Был использован Motion Layout.

Загрузка данных

Загрузка данных, а именно эпизодов и локаций, происходит посредством использования id, полученных из API. Эти id достаются из ссылок с помощью утилитного класса UrlIdExtractor, который сначала проверяет, пришла ли именно ссылка, затем отдаёт id полученный из ссылки. В случае неудачи возвращается пустая строка.

Для получения списка эпизодов используется другой утилитный класс ApiQueryGenerator, который превращает список из id, в одну строку из всех id, разделенных запятыми. Это необходимо для создания такого эндпоинта:

https://rickandmortyapi.com/api/episode/1,2,3,4,12,15    //последние цифры - id

Для получениях эпизодов из базы данных используется сам список из id, а не конкатенация из id. Метод выглядит так:

  @Query("SELECT * FROM episode WHERE id IN (:ids)")
  fun getEpisodes(ids: List<String>?): Maybe<List<EpisodeEntity>>
  • ids - список из id

Maybe - особый вид Obsrvable в rxJava, который может вернуть null. Этим он отличается от Single и был использован для единократного получения нулабельного значения.

"SELECT * FROM episode WHERE id IN (:ids)" с помощью этого запроса получаются все эпизоды, у которых id входит в список переданных id.

List - список эпизодов

Все полученные данные собираются в одну сущность и передаются дальше в view model.

  1. Детальный экран - Эпизод.

Подробная информация об эпизодах представлена на этом экране.

Навигация

Навигация построена похожим образом, как это сделано на экране с персонажами. Реализуется интерфейс EpisodesRouter

Загрузка данных

Получение данных происходит аналогично тому, как это устроено на экране с персонажами. Отличие в том, что используются другие эндпоинты, а также возможности Kotlin, а именно Coroutines + Flow вместо rxJava + LiveData.

Список персонажей

На этом экране доступен список из всех персонажей, относящихся к эпизоду. Расширение происходит аналогично тому, как это реализовано на детальном экране персонажа.

  1. Детальный экран - Локация.

Детали о локации можно найти здесь.

Навигация

Навигация также строится с помощью интерфейса, который реализуется главным роутером.

Список персонажей

Помимо деталей локации, присутсвует список из персонажей, которые встречались в локации. Раскрыть список можно жестом, как и на экране с деталями о персонаже.

Архитектура.

Зависимость модулей

Dagger 2 зависимости

Фиче модули получают зависимости из app-модуля с помощью интерфейсов.

Структура проекта

Навигация

Обработка исключений

505 в данном случае своя имитированная ошибка, которую приложение обработало. Почему 2 скриншота с экраном о персонажей? Важно отметить, что внезапное отключение интернета и загрузка локальных данных - 2 разных случаях, что приложением и отслеживается. Есть обработка случая, когда при использовании пагинации не удалось загрузить данные. Можно попробовать загрузить их заново. Текст в данном случае на русском, так как скриншот делался на физическом девайсе, вместо эмулятора. Важно отметить, что приложение поддерживает 2 языка: русский и английский.

Развернуть скриншоты

Использованные библиотеки и технологии

Технология Использование Версия
Room Сохранение локальных данных. 2.5.1
Retrofit Запросы в сеть. 2.9.0
RxJava Работа с ассинхронным кодом. 3.1.5
Coroutines Работа с ассинхронным кодом. 1.8.20
Glide Загрузка изображений. 4.15.1
Dagger 2 Внедрение зависимостей. 2.45
OkHttp Перехватчики запросов. 4.10.0
Moshi Сериализация данных. 1.14.0
Paging 3 Пагинация. 3.1.1
Splash Screen Api Начальный экран. 1.0.0
Gson Сериализация данных. 2.10.1
Constraint Layout Верстка. 2.1.4
Gradle Сборка проекта. 7.4.2

Некоторые наброски делались в фигме.