diff --git a/src/kmm-icerock-onboarding/lesson-2/assets/android-studio-auth-rep-name-tip.png b/src/kmm-icerock-onboarding/lesson-2/assets/android-studio-auth-rep-name-tip.png new file mode 100644 index 0000000..8b911d9 Binary files /dev/null and b/src/kmm-icerock-onboarding/lesson-2/assets/android-studio-auth-rep-name-tip.png differ diff --git a/src/kmm-icerock-onboarding/lesson-2/assets/android-studio-auth-rep-structure.png b/src/kmm-icerock-onboarding/lesson-2/assets/android-studio-auth-rep-structure.png new file mode 100644 index 0000000..3a11959 Binary files /dev/null and b/src/kmm-icerock-onboarding/lesson-2/assets/android-studio-auth-rep-structure.png differ diff --git a/src/kmm-icerock-onboarding/lesson-2/assets/android-studio-create-select-file.png b/src/kmm-icerock-onboarding/lesson-2/assets/android-studio-create-select-file.png new file mode 100644 index 0000000..1736a0e Binary files /dev/null and b/src/kmm-icerock-onboarding/lesson-2/assets/android-studio-create-select-file.png differ diff --git a/src/kmm-icerock-onboarding/lesson-2/assets/android-studio-create-select-type.png b/src/kmm-icerock-onboarding/lesson-2/assets/android-studio-create-select-type.png new file mode 100644 index 0000000..ae5ff91 Binary files /dev/null and b/src/kmm-icerock-onboarding/lesson-2/assets/android-studio-create-select-type.png differ diff --git a/src/kmm-icerock-onboarding/lesson-2/kmm-icerock-onboarding-2.md b/src/kmm-icerock-onboarding/lesson-2/kmm-icerock-onboarding-2.md index 7c57e8a..be58b05 100644 --- a/src/kmm-icerock-onboarding/lesson-2/kmm-icerock-onboarding-2.md +++ b/src/kmm-icerock-onboarding/lesson-2/kmm-icerock-onboarding-2.md @@ -11,7 +11,7 @@ feedback link: https://github.com/icerockdev/kmp-codelabs/issues Duration: 10 -Теперь перейдем к написанию нашей первой фичи. Начинать мы будем с очень распространённой задачи — реализации авторизации в приложении. +Теперь перейдем к написанию нашей первой фичи. Начинать мы будем с очень распространённой задачи — реализации авторизации в приложении. Экран у нас будет несложный: два поля ввода — для логина и пароля, а также кнопка логина. Для отображения состояния загрузки нам понадобится лоадер, который мы будем показывать при отправлении запроса на сервер и сообщение об ошибке, на случай если что-то пойдет не так. Лоадер и показ диалога ошибки мы будем реализовывать стандартными нативными средствами. @@ -25,7 +25,7 @@ feature/auth Сразу из коробки в boilerplate проекте уже можно увидеть заготовку для нашей ViewModel авторизации. Подробнее об устройстве проекта можно прочитать здесь !!! ВСТАВИТЬ ССЫЛКУ НА ИТОГОВОЕ РАСПОЛОЖЕНИЕ СТАТЬИ С УСТРОЙСТВОМ ПРОЕКТА !!! Positive -: Чтобы быстро найти нужный файл можно воспользоваться хоткеем для поиска по файлам в Android Studio. Для этого используем либо двойное нажатие на Shift, либо сочетание Cmd + Shift + O. +: Чтобы быстро найти нужный файл можно воспользоваться хоткеем для поиска по файлам в Android Studio. Для этого используем либо двойное нажатие на Shift, либо сочетание Cmd + Shift + O. Это полезный инструмент, т.к. довольно часто бывает необходимость быстро найти конкретный файл и быстро перейти в него. Найдём нашу ViewModel в поиске: @@ -48,8 +48,8 @@ class AuthViewModel( Negative : Проверить, бьётся ли с кодлабой с описанием. Если там уже есть про диспатчеры, то убрать это отсюда. -Что такое eventsDispatcher и для чего он нужен? Это инструмент, который служит для связи ViewModel и нативной стороны. -Если в ViewModel произошло событие и об этом необходимо сообщить на сторону нативного приложения (например, для отображения сообщения, вызова перехода, +Что такое eventsDispatcher и для чего он нужен? Это инструмент, который служит для связи ViewModel и нативной стороны. +Если в ViewModel произошло событие и об этом необходимо сообщить на сторону нативного приложения (например, для отображения сообщения, вызова перехода, обновления экрана, либо некоторой нативной обработки) мы уведомляем об этом нативную часть через eventsDispatcher. Набор событий которые можно вызывать со стороны общего кода определяется интерфейсом EventsListener. Чуть дальше мы как раз добавим сюда новые методы. @@ -80,7 +80,7 @@ val passwordField: MutableLiveData = MutableLiveData("") Positive : Если не добавлять импорт, а сразу вставить поля, то MutableLiveData будет светиться красным, т.к. в рамках ViewModel этот класс неизвестен. При этом если Android Studio видит, что это за класс и нужен только импорт, то можно сделать это хоткеем — достаточно нажать на красное название -неимпортированного класса и нажать alt + Enter. Тогда данный импорт пропишется автоматически в блоке импортов. +неимпортированного класса и нажать alt + Enter. Тогда данный импорт пропишется автоматически в блоке импортов. Эти поля должны быть публичными. Их мы будем использовать для передачи вводимых пользователем данных с нативной части в общую. Также обращаем внимание, что необходимо явно указать их тип - MutableLiveData. Это хороший тон, который увеличивает читаемость кода и обеспечивает дополнительный контроль @@ -153,7 +153,7 @@ import dev.icerock.moko.mvvm.livedata.readOnly ``` Positive -: В случаях, когда нужно сделать пару полей, одно из которых — приватное изменяемое, а другое — его публичный неизменяемый аналог, +: В случаях, когда нужно сделать пару полей, одно из которых — приватное изменяемое, а другое — его публичный неизменяемый аналог, используются одинаковые имена, а перед приватным добавляется нижнее подчёркивание Готово! К этому публичному полю isLoading теперь можно прибиндиться с натива для отслеживания необходимости показать/скрыть лоадер. @@ -432,7 +432,7 @@ class AppCoordinator: BaseCoordinator { Positive : Рядом с этим классом также лежит BaseViewController - это чуть более расширенная версия MVVMController-а, т.к. в неё добавлены заготовки под обработку клавиатуры и -лоадеров. Конкретная реализация зависит от проекта. При использовании на проекте удобно использовать базовый контроллер, донастроить его под нужды +лоадеров. Конкретная реализация зависит от проекта. При использовании на проекте удобно использовать базовый контроллер, донастроить его под нужды проекта и экономить на этом время разработки, добавляя, к тому же, единообразия в реализации разных экранов. Перейдём к нашему контроллеру авторизации и унаследуем его от MVVM-контроллера, работающего на базе AuthViewModel. @@ -469,8 +469,8 @@ class AuthViewController: MVVMController ``` Здесь используется двухсторонний биндинг — TwoWay. Это означает, что он будет работать в обе стороны. Если пользователь изменяет данные в полях — информация об этом -будет прилетать во ViewModel. Также если и в рамках логики внутри ViewModel потребуется изменить значения в полях (например сбросить пароль при неудачной авторизации) -и данные в лайвдатах будут изменены — они также изменятся и на UI в нативе. +будет прилетать во ViewModel. Также если и в рамках логики внутри ViewModel потребуется изменить значения в полях (например сбросить пароль при неудачной авторизации) +и данные в лайвдатах будут изменены — они также изменятся и на UI в нативе. С кнопкой всё проще — так как MVVMController умеет хранить ссылку на ViewModel, то достаточно просто вызвать нужный метод внутри IBAction обработчика кнопки: @@ -555,6 +555,10 @@ UI-элементов, добавить необходимые вызовы пу Duration: 20 +Negative +: Нужна помощь со стороны андроида по заполнению этих разделов и актуализации. Основные моменты: +- не хватает указания файлов/папок в которых происходят изменения, не хватает импортов, на первом этапе надо убрать валидации полей + ### Создание нативного экрана авторизации Пришло время написать нативную реализацию экрана. @@ -719,27 +723,222 @@ Duration: 10 ## Репозиторий авторизации -!!!!!!! +### Немного о фабриках и архитектуре + +Следующим шагом мы разберёмся с тем, как правильно вызвать запрос авторизации на сервере из нашей AuthViewModel. +Но для этого нужно будет немного разобраться с подходом разделения ответственности и проброса необходимых зависимостей +в проектах. + +Мы стараемся делать фичи максимально независящими от контекста проекта. Иными словами — фича должна знать ровно +тот набор информации, который ей нужен для корректной работы в рамках самой себя. При этом важно сократить до минимума +её зависимости от каких либо других модулей. Наиболее критично такое сказывается на времени компиляции под iOS, +когда проект уже разросся. Например, если 3 разных модуля (разные фичи), имеют зависимость на какой-то один общий +вспомогательный модуль, назовём его shared, то при любом изменении shared мы получим полную пересборку всех этих трёх +модулей при сборке под iOS, в то время как на андроиде такого не будет. + +Возникает логичный вопрос - а как быть тогда с моделями, общими для всего проекта? С работой с сервером, ведь она +же должна быть общей для разных фичей? С какими-то вспомогательными расширениями, которые нужно использовать в +разных фичах? Сейчас проясним этот момент. + +### Как было раньше и какие возникали проблемы + +На первых стадиях архитектуры у нас были такие модули как domain и shared, а также фабрики DomainFactory и SharedFactory. + +Модуль domain включал в себя описания доменных сущностей, описание классов для работы с сервером, логику преобразования серверных +ответов в те самые доменные сущности, с которыми могло работать приложение. Также в нём содержалась и доменная фабрика DomainFactory, +которая создавала классы для работы с сетью, репозитории, управляющие данными и производила настройку http-клиента. А также именно +расширениями к DomainFactory реализовывались создания всех остальных фабрик для фичей. + +Модуль shared содержал большое количество полезных расширений, вспомогательных методов, упрощений и прочих переиспользуемых между +модулями вещей. + +А внутри модуля mpp-library располагалась и SharedFactory (либо просто Factory, на разных старых проектах навание может быть разным). +Её предназначение было получить с натива все данные, необходимые для реализации DomainFactory и, соответственно, DomainFactory на основе +этих же данных могла реализовывать свои внутренние компоненты. Плюс mpp-library служила прослойкой для маппинга всех доменных сущностей +в сущности фичей. Например модель юзера могла быть и в модуле авторизации и в модуле профилей. Но auth:User и profile:User - это были +разные модели и преобразование от доменной сущности domain:User (который мы получали после преобразования ответа сервера) требовалось +для каждой из них. + +И чтобы в фичах мы могли спокойно кидать запросы, использовать модели данных и применять вспомогательные методы из shared, приходилось +добавлять практически во всех фичах зависимости на shared. + +По началу всё шло неплохо. Производительность не сильно страдала. Проблемы начались тогда, когда мы имеем уже объёмный проект, состоящий из +10-15 модулей с фичами. И в какой-то момент нам для одной из фичей надо в shared добавить небольшой код или поправить реализацию уже имеющегося. +Это приводило к тому, что на iOS начинают пересобираться абсолютно все зависящие на shared модули, а разработчик, поменявший одну строчку, мог +ждать сборки iOS по 10 с лишним минут. + +И вторая большая проблема заключалась в том, что мы вынуждены были плодить множество сущностей. На примере всё того же юзера у нас была сетевая сущность юзера, которую присылал +бэк, доменная сущность юзера, в которую мы преобразовывали сетевую, а далее для фичей авторизации и профиля — ещё по одной сущности, которые относятся уже к самим фичам, а +они, в свою очередь, должны преобразовываться из доменных. Самый банальный пример — если на сервере добавляют новое поле в сущности, которое нам нужно использовать, то +его приходилось пробрасывать через все эти круги ада и тратить время. + +Особенно больно это делало при отладке багов, когда вносится фикс и его проверка занимает в 2, 3 или 4 раза больше времени, чем сам фикс. Потому что мы либо добавляем +много пробросов кода, либо долго ждём пересборку. + +### Изменения в архитектуре + +Поэтому от данного подхода было принято отказываться в сторону более нового - с учётом независимости фичи и проброса в неё внешних зависимостей. + +Negative +: Если в рамках работы над проектом вам встречается модуль domain, shared или DomainFactory, то это проект, построенный на старом варианте +архитектуры. Данный подход уже не актуален. В рамках поддержки существующих фичей, при невозможности изменения добавления зависимости от +модуля на проброс зависимостей через интерфейсы, придётся использовать старый способ. При создании новых фичей, даже с учётом старой архитектуры +в этом проекте, новые нужно реализовывать именно так, как будет описано ниже. + +На текущий момент наиболее актуальным архитектурным подходом является тот, что представлен в рамках boilerplate-проекта, с которого мы +начали разработку. -Добавить информацию, что Domain устарел, описать почему, описать про Shared factory, описать, почему не надо делать общий модуль shared +Ключевые отличия следующие: -!!!!!!! +- Модуль domain упразднён. Сущность и модели у каждой фичи свои в рамках модуля этой фичи. И они содержат достаточный + набор данных для её работы. Но в случаях, когда одни и те же сущности должны использоваться между несколькими фичами, для того, чтобы избежать + дублирования, такие сущности выносятся в отдельный модуль и в зависимость добавляется именно он. +- DomainFactory упразднена. Её роль забирает SharedFactory, которая доступна с натива и находится в mpp-library. Через неё же можно с нативной стороны достучаться + до всех необходимых фабрик фичей. Фабрики фичей всё также реализовываются как расширения, но уже к SharedFactory, а не к DomainFactory. + Инициализация классов работы с API также происходит в SharedFactory. +- Модуль shared упразднён. Подобные общие компоненты реализовываются внутри модуля mpp-library. +- Для реализации логики работы с данными, либо с сервером используются репозитории. Каждая ViewModel описывает у себя интерфейс + репозитория, покрывающий её нужды. Либо бывают случаи общего интерфейса репозитория на несколько ViewModel, но в рамках одной фичи. + Реализация этого репозитория должна передаваться при создании ViewModel. Сами реализации создаются в модуле mpp-library, а он, как мы знаем, + имеет информацию и о вьюмоделях (т.к. mpp-library знает о других модулях) и о сетевом слое. Соответственно в его рамках без проблем можно + описать реализации этих интерфейсов и пробросить их в фабрику фичи, которая, в свою очередь, передаст реализацию во ViewModel. Это же помогает избежать пачки мапперов + из сетевой сущности в доменную, из доменной в фичёвую. + +После этого длиннотекста нужен небольшой перерыв :) ### Роль репозитория -// Описать, что это, где хранится, для чего нужен +Чтобы лучше понять, как устроены зависимости между разными частями проекта — нужно начать добавлять их! Сейчас этим и займёмся. + +Начнём мы с репозитория для авторизации. Именно репозитории отвечают за предоставление данных для ViewModel и за реализацию логики изменения этих данных. А также +взаимодействуют с сервером и преобразовывают данные, полученные от сервера (либо другого источника данных, например, БД), в сущности, требующиеся для ViewModel, +и наоборот — полученные от ViewModel данные преобразовывает в тот вид, в котором их ожидает запрос на сервер. (или, к примеру, компонент, работающий с базой данных, +выполняющий запись в неё) + +Это позволяет ViewModel не заботиться о том, какая реализация будет у репозитория. Она не знает, что на другой стороне — сервер, база данных, локальные моки, магия, +костыльные заглушки и т.п. Для ViewModel важно только то, что есть реализация интерфейса репозитория, покрывающая ещё нужды. + +Сейчас увидим, как это выглядит на практике. + +### Создаём интерфейс репозитория + +Так как основная задача репозитория — обеспечить необходимым функционалом ViewModel, то отталкиваться будет от неё. Наша ViewModel простенькая и ей пока нужен всего +один метод - авторизоваться по логину и паролю. Создадим и опишем такой репозиторий. + +Переходим в наш проект в AndroidStudio и отправляемся в папку фичи нашей авторизации. В ней мы можем увидеть папку di, которая предназначена для всего, что связано +с передачей зависимостей извне. Именно в ней нам и нужно добавить интерфейс репозитория авторизации. + +В AndroidStudio может сразу при создании выбрать нужный тип создаваемого файла, чтобы сгенерировалось первоначальное состояние. Для этого в меню +папки di выберем New/Kotlin Class/File: + +![auth-rep-create-file](assets/android-studio-create-select-file.png) -### Создаём интерфейс репозитория и реализацию +А затем выберем тип Interface и введём название: -// Показать, как создать интерфейс репозитория, где создать реализацию +![auth_rep_create_type](assets/android-studio-create-select-type.png) -### Делаем мок реализации +И получим наш созданный файл интерфейса, который отличается зелёным значком с буквой I: -// Замокировать реализацию запроса, объяснить, почему мок нужно делать именно на уровне репозитория +![auth-rep-structure](assets/android-studio-auth-rep-structure.png) +и уже содержит заготовку интерфейса внутри себя: +```kotlin +package org.example.library.feature.auth.di + +interface AuthRepository { + +} +``` + +Теперь нужно добавить метод для авторизации. Какие у нас по нему требования? Он асинхронный, а на вход принимает логин и пароль. Значит это +suspend функция с двумя параметрами: + +```kotlin +interface AuthRepository { + suspend fun auth(login: String, password: String) +} +``` +Negative +: Тут может возникнуть вопрос "Почему нет результата? Как узнать, успешная ли была авторизация?". На самом деле ошибки тут нет - в случае получения +ошибки её выбросит сама корутина и мы сможем её обработать. Дойдём до этого чуть позже на шаге обработки ошибок + +### Добавляем репозиторий к AuthViewModel + +Мы создали интерфейс репозитория - теперь пора его добавить в AuthViewModel и обновить её немного. + +Начнём с того, что добавим репозиторий ещё одним параметром в конструктор AuthViewModel. Сама AndroidStudio при начале ввода имени подскажет +варианты для названия с учётом типа. Это удобно, потому что при выборе из списка автомаически добавляется и необходимый импорт. Если мы начнём вводить +имя "auth...", то студия подскажет: + +![auth-vm-rep-tip](assets/android-studio-auth-rep-name-tip.png) + +нажмём Enter и у нас ватоматически добавится и импорт, и дозаполнится название с типом: + +```kotlin + +import org.example.library.feature.auth.di.AuthRepository + +class AuthViewModel( + private val authRepository: AuthRepository, + override val eventsDispatcher: EventsDispatcher +) : ViewModel(), EventsDispatcherOwner + +``` + +Теперь у ViewModel при создании должна будет приходить реализация интерфейса AuthRepository. Значит мы можем вместо вызова печати в лог вызвать +метод авторизации у репозитория. Заменим текущую реализацию `sendAuthRequest`: + +```kotlin +private suspend fun sendAuthRequest() { + authRepository.auth(login = loginField.value, password = passwordField.value) +} +``` + +Теперь вместо лога ViewModel вызывает метод авторизации у репозитория. + +Positive +: Важный момент. Мы знаем, что никакой реализации этого репозитория ещё не существует. Но в рамках ViewModel никаких ошибок нет. Для неё всё хорошо. +Тут наглядно можно увидеть, что ей абсолютно нет никакого дела до факической реализации интерфейса репозитория. Это очень удобно со многих сторон. Например, +два разработчика могут согласовать интерфейс репозитория, а дальше независимо друг от друга делать его реализацию и саму ViewModel. Или при необходимости +изменить источник данных мы можем не опасаться за работоспособность ViewModel - изменится только реализация интерфейса, но она всё также будет +покрывать весь необходимый функционал. И один из наиболее частых полезных моментов, который мы применим ниже, это когда методы на сервере могут быть +просто ещё не готовы. И чтобы не блокироваться по разработке мы можем спокойно сделать моки на уровне репозитория. Тогда будет возможность полностью +закончить ViewModel, а когда методы появятся — просто заменить моки на вызов реальных запросов. + +Если попробовать собрать проект, то мы получим ошибку, т.к. не передали в фабрике авторизации никакой реализации для репозитория для вьюмодели. Надо это +исправить. Перейдём в AuthFactory.kt. + +Чтобы фабрика могла передать во ViewModel реализацию репозитория, она сама должна её где-то взять. И эта реализация ей тоже будет приходить параметром. +Поэтому добавим в конструктор фабрики такой же параметр, как добавляли в AuthViewModel и пробросим его дальше: + +```kotlin +class AuthFactory( + private val authRepository: AuthRepository // 1. Получаем репозиторий при создании фабрики +) { + fun createAuthViewModel( + eventsDispatcher: EventsDispatcher + ) = AuthViewModel( + eventsDispatcher = eventsDispatcher, + authRepository = authRepository // 2. Передаём его в создание ViewModel + ) +} +``` + +И как уже можно было догадаться — теперь надо передать реализацию AuthRepository при создании AuthFactory. Делается это в SharedFactory.kt. Но прежде чем +мы его передадим, надо создать саму реализацию. + +### Реализовываем AuthRepository + +// Добавить папку с фичами в mpp-library/src, создать под авторизацию, создать имплементацию для репозитория авторизации. Передать в фабрику, +показать, что проект собирается и продолжает печатать в лог сообщения + +### Делаем мок реализации успеха/ошибки + +// Замокировать реализацию запроса, объяснить, почему мок нужно делать именно на уровне репозитория. Считать правильными hellompp / kotlin, в остальных +случаях кидаем эксепшен ## Обработка результата авторизации @@ -952,7 +1151,7 @@ navController?.navigate(dir) ### Сохранение в локальное хранилище. // Добавить логику запоминания токена в локальном хранилище. Показать, как с сеттингсами работать. - + ### Построение экранов @@ -1085,4 +1284,4 @@ val isButtonEnabled: LiveData = listOf( passwordValidationError.map { it == null }, isLoading.map { it.not() } ).all(true) -``` \ No newline at end of file +```