Привет, Хабр! Представляю вашему вниманию перевод статьи "Modern Android development with Kotlin (Part 2)" автора Mladen Rakonjac.

Примечание. Данная статья является переводом циклов статей от Mladen Rakonjac, дата статьи: 23.09.2017. GitHub. Начав читать первую часть от SemperPeritus обнаружил, что остальные части почему-то не были переведены. Поэтому и предлагаю вашему вниманию вторую часть. Статья получилась объёмной.

image

«Очень сложно найти один проект, который охватывал бы всё новое в разработке под Android в Android Studio 3.0, поэтому я решил написать его.»

В этой статье мы разберём следующее:

  1. Android Studio 3, beta 1 Часть 1
  2. Язык программирования Kotlin Часть 1
  3. Варианты сборки Часть 1
  4. ConstraintLayout Часть 1
  5. Библиотека привязки данных Data Binding Часть 1
  6. Архитектура MVVM + Паттерн Repository + Android Manager Wrappers
  7. RxJava2 и как это помогает нам в архитектуре Part 3
  8. Dagger 2.11, что такое внедрение зависимости, почему вы должны использовать это Part 4
  9. Retrofit (with Rx Java2)
  10. Room (with Rx Java2)

Архитектура MVVM + Паттерн Repository + Android Manager Wrappers


Пару слов об Архитектуре в мире Андроид


Довольно долгое время андроид-разработчики не использовали какую-либо архитектуру в своих проектах. В последние три года вокруг неё в сообществе андроид-разработчиков поднялось много шумихи. Время God Activity прошло и Google опубликовал репозиторий Android Architecture Blueprints, с множеством примеров и инструкций по различным архитектурным подходам. Наконец, на Google IO ’17 они представили Android Architecture Components — коллекцию библиотек, призванных помочь нам создавать более чистый код и улучшить приложения. Component говорит, что вы можете использовать их все, или только один из них. Впрочем, я нашёл их все реально полезными. Далее в тексте и в следующих частях мы будет их использовать. Сперва я в коде доберусь до проблемы, после чего проведу рефакторинг, используя эти компоненты и библиотеки, чтобы увидеть, какие проблемы они призваны решить.

Существуют два основных архитектурных паттерна, которые разделяют GUI-код:

  • MVP
  • MVVM

Трудно сказать, что лучше. Вы должны попробовать оба и решить. Я предпочитаю MVVM, используя lifecycle-aware компоненты и я напишу об этом. Если вы никогда не пробовали использовать MVP, на Medium есть куча хороших статей об этом.

Что такое MVVM паттерн?


MVVM — это архитектурный паттерн, раскрывается как Model-View-ViewModel. Я думаю это название смущает разработчиков. Если бы я был тем, кто придумал ему имя, я бы назвал его View-ViewModel-Model, потому что ViewModel находится посередине, соединяя View и Model.

View — это абстракция для Activity, Fragment'а или любой другой кастомной View (Android Custom View). Обратите внимание, важно не путать эту View с Android View. View должна быть тупой, мы не должны писать какую-либо логику в неё. View не должна содержать данные. Она должна хранить ссылку на экземпляр ViewModel и все данные, которые нужны View, должны приходить оттуда. Кроме того, View должна наблюдать за этими данными и layout должен поменяться, когда данные из ViewModel изменятся. Подводя итог, View отвечает за следующее: вид layout'а для различных данных и состояний.

ViewModel — это абстрактное имя для класса, содержащего данные и логику, когда эти данные должны быть получены и когда показаны. ViewModel хранит текущее состояние. Также ViewModel хранит ссылку на одну или несколько Model'ей и все данные получает от них. Она не должна знать, к примеру, откуда получены данные, из базы данных или с сервера. Кроме того, ViewModel не должна ничего знать о View. Более того, ViewModel вообще ничего не должна знать о фреймворке Android.

Model — это абстрактное имя для слоя, который подготавливает данные для ViewModel. Это класс, в котором мы будем получать данные с сервера и кэшировать их, или сохранять в локальную базу данных. Заметьте, что это не те же классы, что и User, Car, Square, другие классы моделей, которые просто хранят данные. Как правило, это реализация шаблона Repository, который мы рассмотрим далее. Model не должна ничего знать о ViewModel.

MVVM, если реализован правильно, это отличный способ разбить ваш код и сделать его более тестируемым. Это помогает нам следовать принципам SOLID, поэтому наш код легче поддерживать.

Пример кода


Сейчас я напишу простейший пример, показывающий как это работает.

Для начала, давайте создадим простенькую Model, которая возвращает некую строчку:

RepoModel.kt
class RepoModel {

    fun refreshData() : String {
        return "Some new data"
    }
}


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

RepoModel.kt
class RepoModel {

    fun refreshData(onDataReadyCallback: OnDataReadyCallback) {
        Handler().postDelayed({ onDataReadyCallback.onDataReady("new data") },2000)
    }
}

interface OnDataReadyCallback {
    fun onDataReady(data : String)
}


Я создал интерфейс OnDataReadyCallback с методом onDataReady. И теперь метод refreshData реализует (имплементирует) OnDataReadyCallback. Для имитации ожидания я использую Handler. Раз в 2 секунды метод onDataReady будет вызываться у классов, реализующих интерфейс OnDataReadyCallback.

Давайте создадим ViewModel:

MainViewModel.kt
class MainViewModel {
    var repoModel: RepoModel = RepoModel()
    var text: String = ""
    var isLoading: Boolean = false
}


Как вы можете видеть, здесь есть экземпляр RepoModel, text, который будет показан и переменная isLoading, которая хранит текущее состояние. Давайте создадим метод refresh, отвечающий за получение данных:

MainViewModel.kt
class MainViewModel {
    ...

    val onDataReadyCallback = object : OnDataReadyCallback {
        override fun onDataReady(data: String) {
            isLoading.set(false)
            text.set(data)
        }
    }

    fun refresh(){
        isLoading.set(true)
        repoModel.refreshData(onDataReadyCallback)
    }
}


Метод refresh вызывает refreshData у RepoModel, который в аргументах берёт реализацию OnDataReadyCallback. Хорошо, но что такое object? Всякий раз, когда вы хотите реализовать (implement) какой-либо интерфейс или унаследовать (extend) какой-либо класс без создания подкласса, вы будете использовать объявление объекта (object declaration). А если вы захотите использовать это как анонимный класс? В этом случае вы используете object expression:

MainViewModel.kt
class MainViewModel {
    var repoModel: RepoModel = RepoModel()
    var text: String = ""
    var isLoading: Boolean = false

    fun refresh() {
        repoModel.refreshData( object : OnDataReadyCallback {
        override fun onDataReady(data: String) {
            text = data
        })
    }
}


Когда мы вызываем refresh, мы должны изменить view на состояние загрузки и когда данные придут, установить у isLoading значение false.

Также мы должны заменить text на
ObservableField<String>
, а isLoading на
ObservableField<Boolean>
. ObservableField это класс из библиотеки Data Binding, который мы можем использовать вместо создания объекта Наблюдателя (Observable).Он оборачивает объект, который мы хотим наблюдать.

MainViewModel.kt
class MainViewModel {
    var repoModel: RepoModel = RepoModel()

    val text = ObservableField<String>()

    val isLoading = ObservableField<Boolean>()

    fun refresh(){
        isLoading.set(true)
        repoModel.refreshData(object : OnDataReadyCallback {
            override fun onDataReady(data: String) {
                isLoading.set(false)
                text.set(data)
            }
        })
    }
}


Обратите внимание, что я использую val вместо var, потому что мы изменим только значение в поле, но не само поле. И если вы захотите проинициализировать его, используйте следующее:

initobserv.kt
val text = ObservableField("old data")
val isLoading = ObservableField(false)



Давайте изменим наш layout, чтобы он мог наблюдать за text и isLoading. Для начала, привяжем MainViewModel вместо Repository:

activity_main.xml
<data>
    <variable
        name="viewModel"
        type="me.mladenrakonjac.modernandroidapp.MainViewModel" />
</data>


Затем:

  • Изменим TextView для наблюдения за text из MainViewModel
  • Добавим ProgressBar, который будет виден только если isLoading true
  • Добавим Button, которая при клике будет вызывать метод refresh из MainViewModel и будет кликабельна только в случае isLoading false

main_activity.xml
...

        <TextView
            android:id="@+id/repository_name"
            android:text="@{viewModel.text}"
            ...
            />

        ...
        <ProgressBar
            android:id="@+id/loading"
            android:visibility="@{viewModel.isLoading ? View.VISIBLE : View.GONE}"
            ...
            />

        <Button
            android:id="@+id/refresh_button"
            android:onClick="@{() -> viewModel.refresh()}"
            android:clickable="@{viewModel.isLoading ? false : true}"
            />
...


Если вы сейчас запустите, то получите ошибку View.VISIBLE and View.GONE cannot be used if View is not imported. Что ж, давайте импортируем:

main_activity.xml
<data>
        <import type="android.view.View"/>

        <variable
            name="viewModel"
            type="me.fleka.modernandroidapp.MainViewModel" />
</data>


Хорошо, с макетом закончили. Теперь закончим со связыванием. Как я сказал, View должна иметь экземпляр ViewModel:

MainActivity.kt
class MainActivity : AppCompatActivity() {

    lateinit var binding: ActivityMainBinding

    var mainViewModel = MainViewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.viewModel = mainViewModel
        binding.executePendingBindings()

    }
}


Наконец, можем запустить


Вы можете видеть, что old data (старые данные) заменяются на new data (новые данные).

Это был простой пример MVVM.

Но есть одна проблемка, давайте повернём экран


old data заменили new data. Как это возможно? Взглянем на жизненный цикл Activity:

Activity Lifecycle
image

Когда вы повернули телефон, новый экземпляр Activity создался и метод onCreate() вызывался. Взглянем на нашу activity:

MainActivity.kt

class MainActivity : AppCompatActivity() {

    lateinit var binding: ActivityMainBinding

    var mainViewModel = MainViewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.viewModel = mainViewModel
        binding.executePendingBindings()

    }
}


Как вы можете видеть, когда экземпляр Activity создался, экземпляр MainViewModel создался тоже. Хорошо ли это, если каким-то образом мы имеем тот же экземпляр MainViewModel для каждой пересозданной MainActivity?

Введение в Lifecycle-aware components


Т.к. многие разработчики сталкиваются с этой проблемой, разработчики из Android Framework Team решили сделать библиотеку, призванную помочь это решить. Класс ViewModel один из них. Это класс, от которого должны наследоваться все наши ViewModel.

Давайте унаследуем MainViewModel от ViewModel из lifecycle-aware components. Сначала мы должны добавить библиотеку lifecycle-aware components в наш build.gradle файл:

build.gradle
dependencies {
    ... 
    implementation "android.arch.lifecycle:runtime:1.0.0-alpha9"
    implementation "android.arch.lifecycle:extensions:1.0.0-alpha9"
    kapt "android.arch.lifecycle:compiler:1.0.0-alpha9"


Сделаем MainViewModel наследником ViewModel:

MainViewModel.kt
package me.mladenrakonjac.modernandroidapp

import android.arch.lifecycle.ViewModel

class MainViewModel : ViewModel() {
    ...
}


Метод onCreate() нашей MainActivity будет выглядеть так:

MainActivity.kt
class MainActivity : AppCompatActivity() {

    lateinit var binding: ActivityMainBinding
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
        binding.executePendingBindings()

    }
}


Заметьте, что мы не создали новый экземпляр MainViewModel. Мы получим его с помощью ViewModelProviders. ViewModelProviders — это утилитный класс (Utility), который имеет метод для получения ViewModelProvider. Всё дело в scope. Если вы вызовете ViewModelProviders.of(this) в Activity, тогда ваша ViewModel будет жить до тех пор, пока жива эта Activity (пока не будет уничтожена без пересоздания). Следовательно, если вы вызовете это во фрагменте, тогда ваша ViewModel будет жить, пока жив Фрагмент и т.д. Взглянем на диаграмму:

Scope lifecycle
image

ViewModelProvider несёт ответственность за создание нового экземпляра в случае первого вызова или же возвращение старого, если ваша Activity или Fragment пересозданы.

Не путайтесь с

MainViewModel::class.java

В Котлине, если вы выполните

MainViewModel::class

это вернёт вам KClass, что не тождественно Class из Java. Так что если мы напишем .java, то по документации это:
Вернёт экземпляр Class Java, соответствующий данному экземпляру KClass
Посмотрим, что происходит при повороте экрана


Мы имеем те же данные, что и до поворота экрана.

В последней статье я сказал, что наше приложение получит список репозиториев Github и покажет их. Чтобы сделать это, мы должны добавить функцию getRepositories, которая вернет фейковый список репозиториев:

RepoModel.kt
class RepoModel {

    fun refreshData(onDataReadyCallback: OnDataReadyCallback) {
        Handler().postDelayed({ onDataReadyCallback.onDataReady("new data") },2000)
    }

    fun getRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {
        var arrayList = ArrayList<Repository>()
        arrayList.add(Repository("First", "Owner 1", 100 , false))
        arrayList.add(Repository("Second", "Owner 2", 30 , true))
        arrayList.add(Repository("Third", "Owner 3", 430 , false))
        
        Handler().postDelayed({ onRepositoryReadyCallback.onDataReady(arrayList) },2000)
    }
}

interface OnDataReadyCallback {
    fun onDataReady(data : String)
}

interface OnRepositoryReadyCallback {
    fun onDataReady(data : ArrayList<Repository>)
}


Также мы должны иметь метод в MainViewModel, который вызовет getRepositories из RepoModel:

MainViewModel.kt
class MainViewModel : ViewModel() {
    ...
    var repositories = ArrayList<Repository>()

    fun refresh(){
        ...
    }

    fun loadRepositories(){
        isLoading.set(true)
        repoModel.getRepositories(object : OnRepositoryReadyCallback{
            override fun onDataReady(data: ArrayList<Repository>) {
                isLoading.set(false)
                repositories = data
            }
        })
    }
}


И наконец, мы должны показать эти репозитории в RecyclerView. Чтобы сделать это, мы должны:

  • Создать layout rv_item_repository.xml
  • Добавить RecyclerView в layout activity_main.xml
  • Создать RepositoryRecyclerViewAdapter
  • Установить адаптер у recyclerview

Для создания rv_item_repository.xml я использовал библиотеку CardView, так что мы должны добавить её в build.gradle (app):

implementation 'com.android.support:cardview-v7:26.0.1'

Вот как он выглядит:

rv_item_repository.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <import type="android.view.View" />

        <variable
            name="repository"
            type="me.mladenrakonjac.modernandroidapp.uimodels.Repository" />
    </data>

    <android.support.v7.widget.CardView
        android:layout_width="match_parent"
        android:layout_height="96dp"
        android:layout_margin="8dp">

        <android.support.constraint.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <TextView
                android:id="@+id/repository_name"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginEnd="16dp"
                android:layout_marginStart="16dp"
                android:text="@{repository.repositoryName}"
                android:textSize="20sp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintHorizontal_bias="0.0"
                app:layout_constraintLeft_toLeftOf="parent"
                app:layout_constraintRight_toRightOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintVertical_bias="0.083"
                tools:text="Modern Android App" />

            <TextView
                android:id="@+id/repository_has_issues"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginEnd="16dp"
                android:layout_marginStart="16dp"
                android:layout_marginTop="8dp"
                android:text="@string/has_issues"
                android:textStyle="bold"
                android:visibility="@{repository.hasIssues ? View.VISIBLE : View.GONE}"
                app:layout_constraintBottom_toBottomOf="@+id/repository_name"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintHorizontal_bias="1.0"
                app:layout_constraintStart_toEndOf="@+id/repository_name"
                app:layout_constraintTop_toTopOf="@+id/repository_name"
                app:layout_constraintVertical_bias="1.0" />

            <TextView
                android:id="@+id/repository_owner"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:layout_marginEnd="16dp"
                android:layout_marginStart="16dp"
                android:text="@{repository.repositoryOwner}"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@+id/repository_name"
                app:layout_constraintVertical_bias="0.0"
                tools:text="Mladen Rakonjac" />

            <TextView
                android:id="@+id/number_of_starts"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:layout_marginEnd="16dp"
                android:layout_marginStart="16dp"
                android:layout_marginTop="8dp"
                android:text="@{String.valueOf(repository.numberOfStars)}"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintHorizontal_bias="1"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@+id/repository_owner"
                app:layout_constraintVertical_bias="0.0"
                tools:text="0 stars" />

        </android.support.constraint.ConstraintLayout>
    </android.support.v7.widget.CardView>
</layout>


Следующим шагом добавляем RecyclerView в activity_main.xml. Прежде, чем сделать это, не забудьте добавить библиотеку RecyclerView:

implementation 'com.android.support:recyclerview-v7:26.0.1'

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <import type="android.view.View"/>

        <variable
            name="viewModel"
            type="me.fleka.modernandroidapp.MainViewModel" />
    </data>

    <android.support.constraint.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context="me.fleka.modernandroidapp.MainActivity">

        <ProgressBar
            android:id="@+id/loading"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:indeterminate="true"
            android:visibility="@{viewModel.isLoading ? View.VISIBLE : View.GONE}"
            app:layout_constraintBottom_toTopOf="@+id/refresh_button"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <android.support.v7.widget.RecyclerView
            android:id="@+id/repository_rv"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:indeterminate="true"
            android:visibility="@{viewModel.isLoading ? View.GONE : View.VISIBLE}"
            app:layout_constraintBottom_toTopOf="@+id/refresh_button"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:listitem="@layout/rv_item_repository" />

        <Button
            android:id="@+id/refresh_button"
            android:layout_width="160dp"
            android:layout_height="40dp"
            android:layout_marginBottom="8dp"
            android:layout_marginEnd="8dp"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:onClick="@{() -> viewModel.loadRepositories()}"
            android:clickable="@{viewModel.isLoading ? false : true}"
            android:text="Refresh"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="1.0" />

    </android.support.constraint.ConstraintLayout>
</layout>



Обратите внимание, что мы удалили некоторые элементы TextView и теперь кнопка запускает loadRepositories вместо refresh:

button.xml
<Button
    android:id="@+id/refresh_button"
    android:onClick="@{() -> viewModel.loadRepositories()}" 
    ...
    />


Давайте удалим метод refresh из MainViewModel и refreshData из RepoModel за ненадобностью.

Теперь нужно создать Adapter для RecyclerView:

RepositoryRecyclerViewAdapter.kt
class RepositoryRecyclerViewAdapter(private var items: ArrayList<Repository>,
                                    private var listener: OnItemClickListener)
    : RecyclerView.Adapter<RepositoryRecyclerViewAdapter.ViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
        val layoutInflater = LayoutInflater.from(parent?.context)
        val binding = RvItemRepositoryBinding.inflate(layoutInflater, parent, false)
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int)
            = holder.bind(items[position], listener)

    override fun getItemCount(): Int = items.size

    interface OnItemClickListener {
        fun onItemClick(position: Int)
    }

    class ViewHolder(private var binding: RvItemRepositoryBinding) :
            RecyclerView.ViewHolder(binding.root) {

        fun bind(repo: Repository, listener: OnItemClickListener?) {
            binding.repository = repo
            if (listener != null) {
                binding.root.setOnClickListener({ _ -> listener.onItemClick(layoutPosition) })
            }

            binding.executePendingBindings()
        }
    }
}


Заметим, что ViewHolder берёт экземпляр типа RvItemRepositoryBinding, вместо View, так что мы можем реализовать Data Binding во ViewHolder для каждого элемента. Не смущайтесь однострочной функции (oneline):

override fun onBindViewHolder(holder: ViewHolder, position: Int)            = holder.bind(items[position], listener)

Это просто краткая запись для:

override fun onBindViewHolder(holder: ViewHolder, position: Int){
    return holder.bind(items[position], listener)
}

И items[position] это реализация для оператора индексирования. Он аналогичен items.get(position).

Ещё одна строчка, которая может смутить вас:

binding.root.setOnClickListener({ _ -> listener.onItemClick(layoutPosition) })

Вы можете заменить параметр на _, если вы его не используете. Приятно, да?

Мы создали адаптер, но всё ещё не применили его к recyclerView в MainActivity:

MainActivity.kt
class MainActivity : AppCompatActivity(), RepositoryRecyclerViewAdapter.OnItemClickListener {

    lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        val viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
        binding.viewModel = viewModel
        binding.executePendingBindings()

        binding.repositoryRv.layoutManager = LinearLayoutManager(this)
        binding.repositoryRv.adapter = RepositoryRecyclerViewAdapter(viewModel.repositories, this)

    }

    override fun onItemClick(position: Int) {
        TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
    }
}


Запустим приложение


Это странно. Что же случилось?

  • Activity создалась, поэтому новый адаптер также создался с репозиториями, которые фактически пусты
  • Мы нажимаем на кнопку
  • Вызывается loadRepositories, показывается progress
  • Спустя 2 секунды мы получаем репозитории, progress скрывается, но они не появляются. Это потому что в адаптере не вызывается notifyDataSetChanged
  • Когда мы поворачиваем экран, создается новая Activity, поэтому создается новый адаптер с параметром repositories с некоторыми данными

Итак, как MainViewModel должен уведомлять MainActivity о новых элементах, мы можем вызвать notifyDataSetChanged?

Не можем.

Это действительно важно, MainViewModel вообще не должен знать о MainActivity.

MainActivity — это тот, у кого есть экземпляр MainViewModel, поэтому он должен прослушивать изменения и уведомлять Adapter об изменениях.

Но как это сделать?

Мы можем наблюдать за репозиториями, поэтому после изменения данных мы можем изменить наш адаптер.

Что же не так с этим решением?

Давайте рассмотрим следующий случай:

  • В MainActivity мы наблюдаем за репозиториями: когда происходит изменение, мы выполняем notifyDataSetChanged
  • Мы нажимаем на кнопку
  • Пока мы ждем изменения данных, MainActivity может быть пересоздана из-за изменений конфигурации
  • Наша MainViewModel всё ещё жива
  • Спустя 2 секунды поле repositories получает новые элементы и уведомляет наблюдателя, что данные изменены
  • Наблюдатель пытается выполнить notifyDataSetChanged у adapter'а, который больше не существует, т.к. MainActivity была пересоздана

Что ж, наше решение недостаточно хорошее.

Введение в LiveData


LiveData это другой Lifecycle-aware component Он базируется на observable (наблюдаемый), который знает о жизненном цикле View. Так что когда Activity уничтожается из-за смены конфигурации, LiveData знает об этом, так что удаляет наблюдателя из уничтоженной Activity тоже.

Реализуем в MainViewModel:

MainViewModel.kt
class MainViewModel : ViewModel() {
    var repoModel: RepoModel = RepoModel()

    val text = ObservableField("old data")

    val isLoading = ObservableField(false)

    var repositories = MutableLiveData<ArrayList<Repository>>()

    fun loadRepositories() {
        isLoading.set(true)
        repoModel.getRepositories(object : OnRepositoryReadyCallback {
            override fun onDataReady(data: ArrayList<Repository>) {
                isLoading.set(false)
                repositories.value = data
            }
        })
    }
}


и начнём наблюдать за MainActivity:

MainActivity.kt
class MainActivity : LifecycleActivity(), RepositoryRecyclerViewAdapter.OnItemClickListener {

    private lateinit var binding: ActivityMainBinding
    private val repositoryRecyclerViewAdapter = RepositoryRecyclerViewAdapter(arrayListOf(), this)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        val viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
        binding.viewModel = viewModel
        binding.executePendingBindings()

        binding.repositoryRv.layoutManager = LinearLayoutManager(this)
        binding.repositoryRv.adapter = repositoryRecyclerViewAdapter
        viewModel.repositories.observe(this,
                Observer<ArrayList<Repository>> { it?.let{ repositoryRecyclerViewAdapter.replaceData(it)} })

    }

    override fun onItemClick(position: Int) {
        TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
    }
}


Что значит слово it? Если некоторая функция имеет только один параметр, то доступ к этому параметру может быть получен с помощью ключевого слова it. Итак, предположим, что у нас есть лямбда-выражение для умножения на 2:

((a) -> 2 * a)

Можно заменить следующим образом:

(it * 2)

Если запустить приложение сейчас, можно убедиться, что всё работает


...

Почему я предпочитаю MVVM, а не MVP?



  • Нет никакого скучного интерфейса для View, т.к. ViewModel не имеет ссылки на View
  • Нет скучного интерфейса для Presenter'а и в этом нет необходимости
  • Гораздо проще обрабатывать смену конфигурации
  • Используя MVVM, имеем меньше кода для Activity, Fragments etc.

...

Repository Pattern


Схема
image

Как я сказал ранее, Model это просто абстрактное имя для слоя, где мы готовим данные. Обычно он содержит репозитории и классы с данными. Каждый класс сущности (данных) имеет соответствующий класс Repository. К примеру, если мы имеем классы User и Post, мы должны также иметь UserRepository и PostRepository. Все данные приходят оттуда. Мы никогда не должны вызывать экземпляр Shared Preferences или DB из View или ViewModel.

Так что мы можем переименовать наш RepoModel в GitRepoRepository, где GitRepo придёт из репозитория Github и Repository придёт из паттерна Repository.

RepoRepositories.kt
class GitRepoRepository {

    fun getGitRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {
        var arrayList = ArrayList<Repository>()
        arrayList.add(Repository("First", "Owner 1", 100, false))
        arrayList.add(Repository("Second", "Owner 2", 30, true))
        arrayList.add(Repository("Third", "Owner 3", 430, false))

        Handler().postDelayed({ onRepositoryReadyCallback.onDataReady(arrayList) }, 2000)
    }
}

interface OnRepositoryReadyCallback {
    fun onDataReady(data: ArrayList<Repository>)
}


Хорошо, MainViewModel получает лист Github репозиториев из GitRepoRepsitories, но откуда получить GitRepoRepositories?

Вы можете вызвать у экземпляра client'а или DB непосредственно в репозитории, но всё ещё не лучшая практика. Ваше приложение должно быть модульным, настолько, насколько вы можете это сделать. Что, если вы решите использовать разные клиенты, чтобы заменить Volley на Retrofit? Если вы имеете какую-то логику внутри, будет сложно сделать рефакторинг. Ваш репозиторий не должен знать какой клиент вы используете, чтобы извлечь удалённые (remote) данные.

  • Единственное, что должен знать репозиторий, это то, что данные поступают удалённо или локально. Нет необходимости знать, как мы получаем эти удаленные или локальные данные.
  • Единственное, что требуется ViewModel — данные
  • Единственное, что должен сделать View — показать эти данные

Когда я только начинал разрабатывать на Android, мне было интересно, как приложения работают в оффлайн режиме и как работает синхронизация данных. Хорошая архитектура приложения позволяет нам сделать это с легкостью. Для примера, когда loadRepositories во ViewModel вызывается, если есть интернет-соединение, GitRepoRepositories может получать данные из удалённого источника данных и сохранять в локальный источник данных. Когда телефон в режиме оффлайн, GitRepoRepository может получить данные из локального хранилища. Так что, Repositories должен иметь экземпляры RemoteDataSource и LocalDataSource и логикой, обрабатывающей откуда эти данные должны прийти.

Добавим локальный источник данных:

GitRepoLocalDataSource.kt
class GitRepoLocalDataSource {

    fun getRepositories(onRepositoryReadyCallback: OnRepoLocalReadyCallback) {
        var arrayList = ArrayList<Repository>()
        arrayList.add(Repository("First From Local", "Owner 1", 100, false))
        arrayList.add(Repository("Second From Local", "Owner 2", 30, true))
        arrayList.add(Repository("Third From Local", "Owner 3", 430, false))

        Handler().postDelayed({ onRepositoryReadyCallback.onLocalDataReady(arrayList) }, 2000)
    }

    fun saveRepositories(arrayList: ArrayList<Repository>){
        //todo save repositories in DB
    }
}

interface OnRepoLocalReadyCallback {
    fun onLocalDataReady(data: ArrayList<Repository>)
}


Здесь мы имеем два метода: первый, который возвращает фейковые локальные данные и второй, для фиктивного сохранения данных.

Добавим удалённый источник данных:

GitRepoRemoteDataSource.kt
class GitRepoRemoteDataSource {

    fun getRepositories(onRepositoryReadyCallback: OnRepoRemoteReadyCallback) {
        var arrayList = ArrayList<Repository>()
        arrayList.add(Repository("First from remote", "Owner 1", 100, false))
        arrayList.add(Repository("Second from remote", "Owner 2", 30, true))
        arrayList.add(Repository("Third from remote", "Owner 3", 430, false))

        Handler().postDelayed({ onRepositoryReadyCallback.onRemoteDataReady(arrayList) }, 2000)
    }
}

interface OnRepoRemoteReadyCallback {
    fun onRemoteDataReady(data: ArrayList<Repository>)
}


Здесь только один метод, возвращающий фейковые удалённые данные.

Теперь мы можем добавить некоторую логику в наш репозиторий:

GitRepoRepository.kt
class GitRepoRepository {

    val localDataSource = GitRepoLocalDataSource()
    val remoteDataSource = GitRepoRemoteDataSource()

    fun getRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {
       remoteDataSource.getRepositories( object : OnRepoRemoteReadyCallback {
           override fun onDataReady(data: ArrayList<Repository>) {
               localDataSource.saveRepositories(data)
               onRepositoryReadyCallback.onDataReady(data)
           }

       })
    }
}

interface OnRepositoryReadyCallback {
    fun onDataReady(data: ArrayList<Repository>)
}


Таким образом, разделяя источники, мы с легкостью сохраняем данные локально.

Что делать, если вам нужны только данные из сети, всё еще нужно использовать шаблон репозитория? Да. Это упрощает тестирование кода, другие разработчики могут лучше понимать ваш код, и вы можете поддерживать его быстрее!

...

Android Manager Wrappers


Что, если вы захотите проверить подключение к интернету в GitRepoRepository, чтобы знать, откуда запрашивать данные? Мы уже говорили, что не должны размещать какой-либо код, связанный с Android, в ViewModel и Model, так как справиться с этой проблемой?

Давайте напишем обёртку (wrapper) для интернет-соединения:

NetManager.kt (Аналогичное решение применимо и к другим менеджерам, например, к NfcManager)
class NetManager(private var applicationContext: Context) {
    private var status: Boolean? = false

    val isConnectedToInternet: Boolean?
        get() {
            val conManager = applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
            val ni = conManager.activeNetworkInfo
            return ni != null && ni.isConnected
        }
}


Этот код будет работать только если добавим permission в manifest:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />

Но как создать экземпляр в Repository, если у нас нет контекста (context)? Мы можем запросить его в конструкторе:

GitRepoRepository.kt
class GitRepoRepository (context: Context){

    val localDataSource = GitRepoLocalDataSource()
    val remoteDataSource = GitRepoRemoteDataSource()
    val netManager = NetManager(context)

    fun getRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {
        remoteDataSource.getRepositories(object : OnRepoRemoteReadyCallback {
            override fun onDataReady(data: ArrayList<Repository>) {
                localDataSource.saveRepositories(data)
                onRepositoryReadyCallback.onDataReady(data)
            }

        })
    }
}

interface OnRepositoryReadyCallback {
    fun onDataReady(data: ArrayList<Repository>)
}


Мы создали перед новым экземпляром GitRepoRepository во ViewModel. Как мы теперь можем иметь NetManager во ViewModel, когда нужен контекст для NetManager? Вы можете использовать AndroidViewModel из библиотеки Lifecycle-aware components, которая имеет контекст. Это контекст приложения, а не Activity.

MainViewModel.kt
class MainViewModel : AndroidViewModel  {

    constructor(application: Application) : super(application)

    var gitRepoRepository: GitRepoRepository = GitRepoRepository(NetManager(getApplication()))

    val text = ObservableField("old data")

    val isLoading = ObservableField(false)

    var repositories = MutableLiveData<ArrayList<Repository>>()

    fun loadRepositories() {
        isLoading.set(true)
        gitRepoRepository.getRepositories(object : OnRepositoryReadyCallback {
            override fun onDataReady(data: ArrayList<Repository>) {
                isLoading.set(false)
                repositories.value = data
            }
        })
    }
}


В этой строчке

constructor(application: Application) : super(application)

мы определили конструктор для MainViewModel. Это необходимо, потому что AndroidViewModel запрашивает экземпляр приложения в своем конструкторе. Так что, в нашем конструкторе мы вызываем super метод, вызывающий конструктор AndroidViewModel, от которого мы наследуемся.

Примечание: мы можем избавиться от одной строчки, если мы сделаем:

class MainViewModel(application: Application) : AndroidViewModel(application) {
... 
}

И теперь, когда у нас есть экземпляр NetManager в GitRepoRepository, мы можем проверить подключение к интернету:

GitRepoRepository.kt
class GitRepoRepository(val netManager: NetManager) {

    val localDataSource = GitRepoLocalDataSource()
    val remoteDataSource = GitRepoRemoteDataSource()

    fun getRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {

        netManager.isConnectedToInternet?.let {
            if (it) {
                remoteDataSource.getRepositories(object : OnRepoRemoteReadyCallback {
                    override fun onRemoteDataReady(data: ArrayList<Repository>) {
                        localDataSource.saveRepositories(data)
                        onRepositoryReadyCallback.onDataReady(data)
                    }
                })
            } else {
                localDataSource.getRepositories(object : OnRepoLocalReadyCallback {
                    override fun onLocalDataReady(data: ArrayList<Repository>) {
                        onRepositoryReadyCallback.onDataReady(data)
                    }
                })
            }
        }
    }
}

interface OnRepositoryReadyCallback {
    fun onDataReady(data: ArrayList<Repository>)
}


Таким образом, если у нас есть подключение к интернету, мы получим удаленные данные и сохраним их локально. Если же у нас нет подключения к интернету, мы получим локальные данные.

Примечание по Котлину: оператор let проверяет на null и возвращает значение внутри it.

В одной из следующих статей я напишу о внедрении зависимостей (dependency injection), о том, как плохо создавать экземпляры репозитория во ViewModel и как избежать использования AndroidViewModel. Также я напишу о большом количестве проблем, которые сейчас есть в нашем коде. Я оставил их по причине…

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

P.S. Я изменил своё мнение о мапперах (mappers). Решил осветить это в следующих статьях.

Комментарии (2)


  1. zloyreznic
    12.12.2018 12:23
    +1

    Современная скорее без RX


    1. artofmainstreams Автор
      12.12.2018 23:26

      Ну, корутины это пока ещё не полноценная замена Rx, так что знать хотя бы основы не помешает) Название статьи согласен, подобрано не самым лучшим образом, довольно быстро всё меняется, но таков был оригинал, дата, напомню — сентябрь 2017-го. Следующие части уже этого года, хоть и без корутин, к сожалению.