diff --git a/learning/kotlin-multiplatform/di.md b/learning/kotlin-multiplatform/di.md new file mode 100644 index 000000000..a35451dc6 --- /dev/null +++ b/learning/kotlin-multiplatform/di.md @@ -0,0 +1,266 @@ +# Dependency Injection + +В настоящее время на наших проектах с KMP для Dependency injection мы используем [Koin](https://github.com/InsertKoinIO/koin).
+[Документация Koin](https://insert-koin.io).
Как именно он используется, можно посмотреть в шаблоне проектов и в [статье универа](https://kmm.icerock.dev/university/icerock-basics/di).
+Ранее использовался подход с фабриками, который еще можно встретить на старых проектах. Ниже вы найдете его описание. + +## Устаревший подход к DI на проектах с помощью SharedFactory + +Вся логика приложения находится в общем коде. На платформах (`iOS` и `Android`) мы просто реализуем `UI` и связываем его с логикой. +В общем коде вся логика сосредоточена во вьюмоделях разных фич, поэтому, для каждого экрана от общего кода нужно получить нужную ему вьюмодель. + +Однако, вьюмодель - это как правило большой и сложный класс, который нуждается в настройке. +Например, для создания стандартной вьюмодели ей необходимы: +- строки локализации - строки, использующиеся в общем коде +- репозиторий, через который идет общение с источником данных +- `exceptionHandler` - объект, реализующий интерфейс [ExceptionHandler](https://github.com/icerockdev/moko-errors/blob/ece79111fb5a9451e6179ba8c5367213c117421b/errors/src/commonMain/kotlin/dev/icerock/moko/errors/handler/ExceptionHandler.kt) и помогающий обрабатывать ошибки из общего кода (о нем вы узнаете позднее из `moko-errors`) +- `eventsDispatcher` - объект, служащий для отправки событий(actions) от `viewModel` на `UI` (о нем вы узнаете уже в следующем разделе) + +Наша цель - избавить платформу от сложности настройки вьюмоделей, чтобы не пришлось во фрагменте или вьюконтроллере получать все эти объекты, необходимые для создания вьюмодели. + +Решение - по максимуму оставить логику настройки вьюмоделей в общем коде, чтобы со стороны платформы можно было практически сразу получить готовую вьюмодель. + +### Уровень фичи + +Первый уровень абстракции над вьюмоделями это фабрика фичи. Она позволяет получить все вьюмодели одной фичи. Разбирать будем на примере фичи авторизации, а вьюмодель, которую мы хотим получить - вьюмодель экрана сброса пароля. + +Начнем с вьюмодели: + +`ResetPasswordViewModel.kt`: +```kotlin +class ResetPasswordViewModel( + override val eventsDispatcher: EventsDispatcher, + val exceptionHandler: ExceptionHandler, + private val repository: ResetPasswordRepository, + private val strings: Strings +) { + interface Strings { + val resetDescription: StringDesc + } +} +``` +Вьюмодель объявляет интерфейс `Strings` - необходимые ей строки локализации. Далее мы разберем это подробнее. + +Рядом с `ResetPasswordViewModel` создаем интерфейс репозитория. Сделали мы это для того, чтобы не устанавливать связь фича-модуля на модуль со строками локализации. В конструктор `ResetPasswordViewModel` принимает объект, который реализует этот интерфейс. В данном случае - кого-то, кто реализует метод для сброса пароля. + +`ResetPasswordRepository.kt` +```kotlin +interface ResetPasswordRepository { + suspend fun resetPassword( + phoneNumber: String, + confirmCode: String + ) +} +``` +Класс репозитория фичи - `AuthRepository`, который будет реализовывать этот интерфейс разберем позднее. + +Теперь сделаем `AuthFactory` - класс, с помощью которого будем настраивать общие компоненты вьюмоделей фичи авторизации и создавать их. Класс фабрики также объявляется в модуле фичи. + +`AuthFactory.kt`: +```kotlin +class AuthFactory( + private val createExceptionHandler: () -> ExceptionHandler, + private val authRepository: AuthRepository, + private val strings: Strings +) { + fun createResetPasswordViewModel( + eventsDispatcher: EventsDispatcher + ) = ResetPasswordViewModel( + eventsDispatcher = eventsDispatcher, + exceptionHandler = createExceptionHandler(), + repository = authRepository, + strings = strings + ) + + interface Strings : ResetPasswordViewModel.Strings +} +``` +`interface Strings` фабрики реализует все интерфейсы `Strings` из других вьюмоделей. + +В эту фабрику мы будем добавлять методы, аналогичные `createResetPasswordViewModel` для создания других вьюмоделей, для них всех `createExceptionHandler`, `repository` и `strings` будут одинаковыми. + +Теперь у нас есть доступ ко всем вьюмоделям фичи авторизации - чтобы создать какую-либо вьюмодель нужно просто вызвать нужную функцию у фабрики и передать один единственный аргумент. + +### Уроверь mpp-library + +Логика работы приложения с источником данных (сервер, БД и т.д.) выносятся в классы - репозитории, в данном случае сделаем репозиторий для фичи авторизации - `AuthRepository` + +`AuthRepository.kt`: +```kotlin +internal class AuthRepository constructor( + private val keyValueStorage: KeyValueStorage, + private val dao: AppDao, + private val coroutineScope: CoroutineScope +) : ResetPasswordRepository { + override fun resetPassword( + phoneNumber: String, + confirmCode: String + ) { + // TODO + } +} +``` +Этот класс реализует все интерфейсы вьюмоделей фичи авторизации для работы с источником данных. Для всех новых вьюмоделей других фичей мы будем объявлять свои интерфейсы, и реализовывать их в классе репозитория конкретной фичи, а затем прокидывать объект репозитория всем вьюмоделям. + +Второй уровень абстракции: фабрика фабрик - `SharedFactory`. В ней мы также создадим все фабрики, как до этого создавали вьюмодели в фабриках, настроим их, чтобы для работы с общим кодом нужно было создать только одну общую фабрику - `SharedFactory`. + +`SharedFactory.kt`: +```kotlin +class SharedFactory internal constructor( + settings: Settings, + antilogs: List, + databaseDriverFactory: DatabaseDriverFactory, + repositoryCoroutineScope: CoroutineScope +) { + // public constructor for platform side usage + constructor( + settings: Settings, + antilog: Antilog?, + databaseDriverFactory: DatabaseDriverFactory?, + mpiServiceConnector: MpiServiceConnector? + ) : this( + settings = settings, + antilogs = listOfNotNull( + antilog, + CrashReportingAntilog(CrashlyticsLogger()) + ), + databaseDriverFactory = databaseDriverFactory, + mpiServiceConnector = mpiServiceConnector, + repositoryCoroutineScope = CoroutineScope(Dispatchers.Main) + ) + + internal val authRepository: AuthRepository by lazy { + AuthRepository( + //TODO + ) + } + + val authFactory: AuthFactory by lazy { + AuthFactory( + createExceptionHandler = ::createExceptionHandler, + authRepository = authRepository, + strings = object : AuthFactory.Strings { + override val resetDescription: StringDesc = + MR.strings.reset_description.desc() + } + ) + } + + private fun createExceptionHandler(): ExceptionHandler = ExceptionHandler( + // TODO + ) +} +``` +В `SharedFactory` мы создали оставшиеся необходимые фабрикам компоненты - `authRepository` и `createExceptionHandler`, а также установили все строки локализации, необходимые фиче. +Поскольку, вьюмодель у нас пока что-то одна, объект `strings` для `AuthFactory` содержит только строки `ResetPasswordViewModel`. Если бы вьюмоделей было больше - все необходимые им строки задавались бы здесь. + +*** +Фиче может понадобиться гораздо больше строк локализации, чем одна, и самих фич в проекте может быть очень много. Если инициализировать строки локализации каждой в фабрики фичей именно в `SharedFactory`, то класс со временем сильно разрастется и ориентироваться в нем будет сложно. +Предлагаем вам использовать вспомогательные функции, расположенные рядом с `SharedFactory`, чтобы инициализировать фабрики строками именно там, а в `SharedFactory` вызывать эти функции. + +`AuthFactoryInit.kt`: +```kotlin +internal fun AuthFactory( + createExceptionHandler: () -> ExceptionHandler, + authRepository: AuthRepositoryInterface +): AuthFactory { + return AuthFactory( + createExceptionHandler = createExceptionHandler, + authRepository = authRepository, + strings = object : AuthFactory.Strings { + override val resetDescription: StringDesc = + MR.strings.reset_description.desc() + } + ) +} +``` +Вызов в `SharedFactory`: + +```kotlin +val authFactory: AuthFactory by lazy { + AuthFactory( + createExceptionHandler = ::createExceptionHandler, + authRepository = authRepository + ) +} +``` +*** + +### Уроверь платформы + +Параметры `SharedFactory` - это то, что мы не можем создать из общего кода а можем получить только с платформы. + + +***iOS*** + +Класс со статической переменной - фабрикой +``` +class AppComponent { + static var factory: SharedFactory! +} +``` + +В методе `application` класса `AppDelegate` инициализируем фабрику и прокидываем дальше в `AppCoordinator`. О нем вы узнаете уже в следующем разделе `Навигация между экранами`. +``` +func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + FirebaseApp.configure() + MokoFirebaseCrashlytics.setup() + + let antilog: Antilog? + #if DEBUG + antilog = DebugAntilog(defaultTag: "debug") + #else + antilog = nil + #endif + + AppComponent.factory = SharedFactory( + settings: AppleSettings(delegate: UserDefaults.standard), + antilog: antilog, + databaseDriverFactory: SqlDatabaseDriverFactory(), + ) + + let window = UIWindow() + + coordinator = AppCoordinator( + window: window, + factory: AppComponent.factory + ) + coordinator.start() + + window.makeKeyAndVisible() + self.window = window + + return true +} +``` + +`AppCoordinator` прокидывает ее дальше, в дочерние координаторы, которые, в свою очередь, отправляют ее уже в контроллеры. +Получение вьюмоедли в контроллере выглядит вот так: + +``` +vc.resetPasswordViewModel = factory +.authFactory +.createResetPasswordViewModel(eventsDispatcher: EventsDispatcher(listener: vc)) +``` + +***Android*** + +```kotlin +val factory = SharedFactory( + AndroidSettings( + delegate = context.getSharedPreferences("app", MODE_PRIVATE) + ), + antilog = antilog, + databaseDriverFactory = SqlDatabaseDriverFactory(context) +) + +val resetPasswordViewModel = factory.authFactory.createResetPasswordViewModel( + eventsDispatcher = eventsDispatcherOnMain() +) +``` + +Наконец, как добавлять новые компоненты в фичи и вьюмодели, если вдруг что-то понадобилось: + - все что общее для вьюмоделей одной фичи - настраивается в фабрике + - все, что общее для всех фабрик - настраивается в `SharedFactory` + +Таким образом, чтобы начать работу с общим кодом - нужно только создать объект `SharedFactory`, передав ему несколько параметров, доступных только на платформе. diff --git a/learning/libraries/ktor/ktor-client.md b/learning/libraries/ktor/ktor-client.md index 10d2db329..11a94b0cc 100644 --- a/learning/libraries/ktor/ktor-client.md +++ b/learning/libraries/ktor/ktor-client.md @@ -147,56 +147,8 @@ append("image", File("ktor_logo.png").readBytes(), Headers.build { ... ``` -### Передача файла как Input - -Этот подход подразумевает использование *kotlinx-io* `Input` ([ссылка на класс](https://ktor.kotlincn.net/kotlinx/io/io/input-output.html)). В таком случае используется другой подход формирования `formData`: -```kotlin -val data: List = formData { - appendInput( - key = "yourKey", - block = { input }, - headers = Headers.build { - append( - HttpHeaders.ContentType, - ContentType.Application.OctetStream.toString() - ) - append( - HttpHeaders.ContentDisposition, ContentDisposition.File - .withParameter(ContentDisposition.Parameters.FileName, fileName) - .toString() - ) - } - ) -} -``` -В примере представлен вариант добавления `headers`, по умолчанию *Empty*. Здесь можно конфигурировать хедеры под ваши нужды. - Касаемо использования formData, существует несколько подходов в формировании *ktor* HTTP клиента: -### Передача файла как Input в common коде - -Чтобы реализовать потоковую передачу файла в общем коде, используя ktor, необходимо получить объект [Input](https://api.ktor.io/older/1.6.8/ktor-io/io.ktor.utils.io.core/-input/index.html) на основе файла. Сделать это в общем коде можно используя expect/actual функции: - -***commonMain:*** -```kotlin -expect fun inputByFilepath(filePath: String): Input -``` -***androidMain:*** -```kotlin -actual fun inputByFilepath(filePath: String): Input{ - val file = File(filePath) - val inputStream = file.inputStream() - return inputStream.asInput() -} -``` -***iosMain:*** -```kotlin -actual fun inputByFilepath(filePath: String): Input { - val fileHandle = NSFileHandle.fileHandleForReadingAtPath(path = filePath) - return Input(fileHandle!!.fileDescriptor) -} -``` - ### submitFormWithBinaryData Для использования этого метода необходимо заранее сформировать `formData`. Код с таким методом выглядит следующим образом: @@ -217,6 +169,147 @@ val result = httpClient.post { } ``` +### Пример реализации на Android + +Стек: Retrofit 2.11.0, KotlinX.io, Multipart-formdata для передачи файла. +Для передачи файла в Retrofit надо представить его в формате понятном ему - RequestBody. Создаем наследника данного класса: + +```kotlin +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okio.BufferedSink +import io.example.FileSource +import kotlin.math.min + +class FileSourceRequestBody( + private val fileSource: FileSource, + private val onUploadCallback: (Float) -> Unit, +) : RequestBody() { + + // Определяем тип контента для передачи в заголовке MediaType + override fun contentType(): MediaType? { + return when { + fileSource.fileName.endsWith(".png", ignoreCase = true) -> "image/png" + fileSource.fileName.endsWith(".jpg", ignoreCase = true) || + fileSource.fileName.endsWith(".jpeg", ignoreCase = true) -> "image/jpeg" + fileSource.fileName.endsWith(".bmp", ignoreCase = true) -> "image/bmp" + fileSource.fileName.endsWith(".gif", ignoreCase = true) -> "image/gif" + fileSource.fileName.endsWith(".webp", ignoreCase = true) -> "image/webp" + else -> "application/octet-stream" + }.toMediaTypeOrNull() + } + + override fun contentLength(): Long { + return fileSource.fileSize + } + + override fun writeTo(sink: BufferedSink) { + // Создаем буфер + val buffer = Buffer() + // Объем загруженный на сервер, для расчета прогресса загрузки + var totalBytesRead = 0L + + // Открываем поток, который будет закрыт автоматически по окончании работы с ним + fileSource.source.use { source -> + var readBytes: Long + + while (totalBytesRead != fileSource.fileSize) { + // Читаем файл по размеру буффера, либо по оставшемуся количеству от файла для загрузки + // Чтение происходит с удалением прочитанных байтов из source + readBytes = source.readAtMostTo( + sink = buffer, + byteCount = min( + a = DEFAULT_BUFFER_SIZE, + b = fileSource.fileSize - totalBytesRead + ) + ) + + totalBytesRead += readBytes + + // Записываем прочитанный объем в исходящий поток данных + sink.write( + source = buffer.readByteArray(), + offset = 0, + byteCount = readBytes.toInt() + ) + + // Вычисляем прогресс загрузки + calculateProgress(totalBytesRead, onUploadCallback) + } + + // Очищаем текущий поток + sink.flush() + } + } + + private fun calculateProgress( + totalBytesRead: Long, + onUploadCallback: (Float) -> Unit, + ) { + val progress: Float = (totalBytesRead / contentLength().toFloat()) + onUploadCallback(progress) + } + + companion object { + private const val DEFAULT_BUFFER_SIZE = 4096L + } +} +``` + +В качестве входящих параметров для класса нужно передать информацию о файле, вторым параметром передаем callback для отображения прогресса загрузки на ui. Структура FileSource: + +```kotlin +import kotlinx.io.RawSource + +data class FileSource( + val fileName: String, + val source: RawSource, + val fileSize: Long, +) +``` +Теперь когда готовы основные структуры для загрузки файла на сервер, давайте создадим интерфейс для нашего API: + +```kotlin +import okhttp3.MultipartBody +import retrofit2.Response +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part + +interface UploadApi { + @Multipart + @POST("/api/images") + suspend fun uploadImage( + @Part image: MultipartBody.Part, + ): Response +} +``` +Для обозначения, что в теле запроса содержится multi-part на него нужно повесить аннотацию '@Multipart', а для параметра содержащий его '@Part'. При вызове данного запроса в репозитории необходимо будет создать MultipartBody.Part, вызывом createFormData: + +```kotlin +... + suspend fun uploadImage( + fileSource: FileSource, + onUploadCallback: (Float) -> Unit, + ): ImageUploadResult { + return uploadApi.uploadImage( + image = MultipartBody.Part.createFormData( + name = "image", // имя Multipart файла указанное в api бекенда + filename = fileSource.fileName, // Имя файла передается в заголовке form-data + body = FileSourceRequestBody( + fileSource = fileSource, + onUploadCallback = onUploadCallback + ) + ) + ) + }.toDomain() + } +... +``` + ### application/octet-stream Данный подход используется довольно редко, но все же используется. Для данного типа запроса нет возможности передать несколько параметров или файлов, можно отправлять файл, притом только один. Для реализации подхода необходимо создать класс, унаследованный от `WriteChannelContent` ([ссылка на класс](https://www.mvndoc.com/c/io.ktor/ktor-http-iosarm64/io/ktor/http/content/OutgoingContent.WriteChannelContent.html)). Пример кода: diff --git a/learning/libraries/moko/moko-fields.md b/learning/libraries/moko/moko-fields.md index c8a29ef67..e80fa79f7 100644 --- a/learning/libraries/moko/moko-fields.md +++ b/learning/libraries/moko/moko-fields.md @@ -15,16 +15,52 @@ sidebar_position: 6 ## FormField Потребность в библиотеке возникла из-за того, что для создания логики формы ввода в общем коде необходимы следующие элементы: -- `LiveData(String)` - текст поля -- `LiveData(Bool)` - валидно/невалидно поле -- `LiveData(String)` - текст ошибки валидации +- `LiveData/StateFlow(String)` - текст поля +- `LiveData/StateFlow(Bool)` - валидно/невалидно поле +- `LiveData/StateFlow(String)` - текст ошибки валидации А представьте, что у вас 7 или 8 таких полей, получится много однотипного кода, в котором легко будет запутаться и допустить ошибку. -Библиотека позволяет использовать специальный класс `FormField` для форм ввода, который включает в себя все эти три лайвдаты. +Библиотека позволяет использовать специальный класс `FormField` для форм ввода, который включает в себя все эти три лайвдаты/стейт флоу. Для создания `FormField` необходимо только установить тип и задать валидацию для этого значения. Тип поля не обязательно должен быть `String`, подойдет любой, который можно как-то установить: `int`, `bitmap`, `data` и тд. +Для Jetpack Compose и Compose Multiplatform двусторонная связка для передачи значения введенного текста поля в View Model не работает, необходимо реализовывать на UI TextField c onValueChange.
+В коде экрана: + +```kotlin +val (code, onCodeChange) = viewModel.code.data.collectAsMutableState() + +AuthCodeContent( + code = code, + onCodeChange = onCodeChange, + codeError = viewModel.code.error.collectAsState().value?.localized(), + ... +) +``` +В контенте экрана: +```kotlin +@Composable +fun AuthCodeContent( + code: String, + onCodeChange: (String) -> Unit, + codeError: String?, + ... +) { + ... + BasicTextField( + ... + value = code, + onValueChange = { newValue -> + onCodeChange(newValue) + }, + isError = codeError !=null + ) + ... +} + +``` + ## Валидация Разберем, как добавлять валидацию в `FormField`: - можно использовать [встроенные валидаторы](https://github.com/icerockdev/moko-fields/tree/c9c09069da717d4995ee6c96f8ec6ef7446af503/fields/src/commonMain/kotlin/dev/icerock/moko/validations) @@ -36,3 +72,4 @@ sidebar_position: 6 - поля можно объединить в список и валидировать их одновременно - валидация полей может быть завязана на других полях (пароль + повторите пароль) - у FormField есть поле `isValid` и `validationError` + diff --git a/university/1-android-basics/android-intro.md b/university/1-android-basics/android-intro.md index d7627429d..96372e267 100644 --- a/university/1-android-basics/android-intro.md +++ b/university/1-android-basics/android-intro.md @@ -15,18 +15,15 @@ sidebar_position: 0 - `Service` - `BroadcastReceiver` - `ContentProvider` -- `Fragment` -- Верстка UI используя xml layout +- Верстка UI используя Compose - Библиотеки AndroidX и Jetpack от Google -- `RecyclerView` -- `LiveData` +- Kotlin flows, `StateFlow`, `SharedFlow` - `ViewModel` -- Жизненный цикл `Application`, `Activity`, `Fragment`, `ViewModel` -- `ViewBinding` +- Жизненный цикл `Application`, `Activity`, `ViewModel` - Библиотека Android Navigation Component от Google - Библиотека Retrofit от Square -- Библиотека Hilt от Google +- Библиотека Koin :::info Для тех кому всё перечисленное уже знакомо, использовано на практике и есть уверенное понимание о чем речь - можно пропустить ознакомление с теоретическим блоком и сразу перейти к [практической задаче](practice). -::: \ No newline at end of file +::: diff --git a/university/1-android-basics/app-logic.md b/university/1-android-basics/app-logic.md index 071fa7db1..292d4b4c6 100644 --- a/university/1-android-basics/app-logic.md +++ b/university/1-android-basics/app-logic.md @@ -19,12 +19,12 @@ sidebar_position: 5 ## ViewModel -Ознакомиться детальнее с ViewModel и LiveData помогут следующие материалы: +Ознакомиться детальнее с ViewModel и Kotlin flows помогут следующие материалы: - [Единый стейт экрана](../../learning/state) - статья о состояних и событиях -- [Android Kotlin Fundamentals: 5.1 ViewModel](https://developer.android.com/codelabs/kotlin-android-training-view-model) - не пропуская Summary и тест в Homework -- [Android Kotlin Fundamentals: LiveData and LiveData observers](https://developer.android.com/codelabs/kotlin-android-training-live-data) - не пропуская Summary и тест в Homework -- [Incorporate Lifecycle-Aware Components](https://developer.android.com/codelabs/android-lifecycles) - для закрепления связей жизненного цикла android компонентов и ViewModel, LiveData +- [Kotlin Flows on Android](https://developer.android.com/kotlin/flow) +- [StateFlow and SharedFlow](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow) +- [Android Basics Compose: 5. ViewModel and State in Compose](https://developer.android.com/codelabs/basic-android-kotlin-compose-viewmodel-and-state) - для закрепления связей жизненного цикла android компонентов и ViewModel, StateFlow. Не пропуская Conclusion и ссылки в нем ## Retrofit diff --git a/university/10-push-notifications/testing.md b/university/10-push-notifications/testing.md index f304d9943..a083109d3 100644 --- a/university/10-push-notifications/testing.md +++ b/university/10-push-notifications/testing.md @@ -58,9 +58,6 @@ sidebar_position: 2 Результат запроса: ![img.png](media/response-example.png) -:::info -Пуши на Android можно протестировать на эмуляторе. -Пуши на iOS протестировать на эмуляторе не получится, только на реальном девайсе. -::: -![img.jpg](media/response-example-phone.png) +Пуши на Android можно протестировать на эмуляторе без особых сложностей. +Чтобы разобраться, как тестировать push-уведомления на симуляторе iOS, прочитайте [тьюториал](https://sparrowcode.io/ru/tutorials/testing-push-notifications-ios-simulator). diff --git a/university/4-icerock-basics/android-just-like-ios.md b/university/4-icerock-basics/android-just-like-ios.md index ab8401740..0eaadb042 100644 --- a/university/4-icerock-basics/android-just-like-ios.md +++ b/university/4-icerock-basics/android-just-like-ios.md @@ -1,5 +1,5 @@ --- -sidebar_position: 12 +sidebar_position: 11 --- # Android ≈ iOS diff --git a/university/4-icerock-basics/di.md b/university/4-icerock-basics/di.md index 54be7278f..4c17b2026 100644 --- a/university/4-icerock-basics/di.md +++ b/university/4-icerock-basics/di.md @@ -13,7 +13,7 @@ sidebar_position: 5 - общие картинки - и т.д. -Поэтому, нам нужно обеспечить передачу некоторых общих компонентов и классов во все модули. Использовать один общий модуль для таких компонентов мы не можем, это также описывалось в блоке [многомодульность](multimodularity). +Поэтому нам нужно обеспечить передачу некоторых общих компонентов и классов во все модули. Использовать один общий модуль для таких компонентов мы не можем, это также описывалось в блоке [многомодульность](multimodularity). В этом случае нам подойдет вариант с обратной зависимостью: - модули не зависят от каких-то компонент - необходимые модулю компоненты будут предоставляться извне @@ -24,7 +24,7 @@ sidebar_position: 5 ### Пример Допустим, мы делаем фичу авторизации. Для авторизации нам нужно: отправить запрос на сервер с номером телефона и кодом авторизации. -За логику работы с сетью у нас отвечает общий для всех модулей репозиторий. Поэтому, в фиче авторизации объявляем интерфейс с функцией `signIn`: +За логику работы с сетью у нас отвечает общий для всех модулей репозиторий. Поэтому в фиче авторизации объявляем интерфейс с функцией `signIn`: ```kotlin interface AuthRepository { @@ -43,47 +43,39 @@ class AuthViewModel( ) ``` Таким образом вьюмодель как бы объявляет: мне для работы нужен кто-то, кто реализует интерфейс `AuthRepository`, потому что у него есть нужный мне метод `signIn`. Мне вообще не важно, кто и как будет его реализовывать. -В классе общего репозитория реализуем интерфейс `AuthRepository` и, при создании фичи будем передавать объект общего репозитория. +В классе общего репозитория реализуем интерфейс `AuthRepository` и при создании фичи будем передавать объект общего репозитория. ## DI на проектах Вся логика приложения находится в общем коде. На платформах (`iOS` и `Android`) мы просто реализуем `UI` и связываем его с логикой. -В общем коде вся логика сосредоточена во вьюмоделях разных фич, поэтому, для каждого экрана от общего кода нужно получить нужную ему вьюмодель. +В общем коде вся логика сосредоточена во вьюмоделях разных фич, поэтому для каждого экрана от общего кода нужно получить нужную ему вьюмодель. Однако, вьюмодель - это как правило большой и сложный класс, который нуждается в настройке. Например, для создания стандартной вьюмодели ей необходимы: -- строки локализации - строки, использующиеся в общем коде - репозиторий, через который идет общение с источником данных - `exceptionHandler` - объект, реализующий интерфейс [ExceptionHandler](https://github.com/icerockdev/moko-errors/blob/ece79111fb5a9451e6179ba8c5367213c117421b/errors/src/commonMain/kotlin/dev/icerock/moko/errors/handler/ExceptionHandler.kt) и помогающий обрабатывать ошибки из общего кода (о нем вы узнаете позднее из `moko-errors`) Наша цель - избавить платформу от сложности настройки вьюмоделей, чтобы не пришлось во фрагменте или вьюконтроллере получать все эти объекты, необходимые для создания вьюмодели. -Решение - по максимуму оставить логику настройки вьюмоделей в общем коде, чтобы со стороны платформы можно было практически сразу получить готовую вьюмодель. +Решение - по максимуму оставить логику настройки вьюмоделей в общем коде, используя Koin для предоставления зависимостей, чтобы со стороны платформы можно было получить готовую вьюмодель. ### Уровень фичи -Первый уровень абстракции над вьюмоделями это фабрика фичи. Она позволяет получить все вьюмодели одной фичи. Разбирать будем на примере фичи авторизации, а вьюмодель, которую мы хотим получить - вьюмодель экрана сброса пароля. +Нам потребуется модуль Koin для получения всех вьюмоделей одной фичи. Разбирать будем на примере фичи авторизации, а вьюмодель, которую мы хотим получить - вьюмодель экрана сброса пароля. -Начнем с вьюмодели: +Начнем с вьюмодели, которую создадим в папке presentation фичи: `ResetPasswordViewModel.kt`: ```kotlin class ResetPasswordViewModel( - override val eventsDispatcher: EventsDispatcher, - val exceptionHandler: ExceptionHandler, - private val repository: ResetPasswordRepository, - private val strings: Strings + private val repository: ResetPasswordRepository ) { - interface Strings { - val resetDescription: StringDesc - } + ... } ``` -Вьюмодель объявляет интерфейс `Strings` - необходимые ей строки локализации. Далее мы разберем это подробнее. - -Рядом с `ResetPasswordViewModel` создаем интерфейс репозитория. Сделали мы это для того, чтобы не устанавливать связь фича-модуля на модуль со строками локализации. В конструктор `ResetPasswordViewModel` принимает объект, который реализует этот интерфейс. В данном случае - кого-то, кто реализует метод для сброса пароля. +Рядом с `ResetPasswordViewModel` в папке model создаем интерфейс репозитория. В конструктор `ResetPasswordViewModel` принимает объект, который реализует этот интерфейс. В данном случае - кого-то, кто реализует метод для сброса пароля. -`ResetPasswordRepository.kt` +`ResetPasswordRepository.kt`: ```kotlin interface ResetPasswordRepository { suspend fun resetPassword( @@ -92,45 +84,53 @@ interface ResetPasswordRepository { ) } ``` -Класс репозитория фичи - `AuthRepository`, который будет реализовывать этот интерфейс разберем позднее. +Класс репозитория фичи - `AuthRepositoryImpl`, который будет реализовывать этот интерфейс разберем позднее. -Теперь сделаем `AuthFactory` - класс, с помощью которого будем настраивать общие компоненты вьюмоделей фичи авторизации и создавать их. Класс фабрики также объявляется в модуле фичи. +Теперь сделаем `featureAuthModule` - модуль Koin, с помощью которого будем предоставлять зависимости для вьюмоделей фичи авторизации и создавать вьюмодели. -`AuthFactory.kt`: +`featureAuthModule.kt`: ```kotlin -class AuthFactory( - private val createExceptionHandler: () -> ExceptionHandler, - private val authRepository: AuthRepository, - private val strings: Strings -) { - fun createResetPasswordViewModel( - eventsDispatcher: EventsDispatcher - ) = ResetPasswordViewModel( - eventsDispatcher = eventsDispatcher, - exceptionHandler = createExceptionHandler(), - repository = authRepository, - strings = strings - ) +... +import org.koin.core.Koin +import org.koin.core.module.Module +import org.koin.core.module.dsl.factoryOf +import org.koin.core.parameter.parametersOf +import org.koin.dsl.module + +val featureAuthModule: Module = module { + factoryOf(::ResetPasswordViewModel) + factoryOf(::AuthPhoneViewModel) + factoryOf(::AuthCodeViewModel) +} +fun Koin.createResetPasswordViewModel(): ResetPasswordViewModel { + return get() +} - interface Strings : ResetPasswordViewModel.Strings +fun Koin.createAuthPhoneViewModel: AuthPhoneViewModel { + return get() } -``` -`interface Strings` фабрики реализует все интерфейсы `Strings` из других вьюмоделей. -В эту фабрику мы будем добавлять методы, аналогичные `createResetPasswordViewModel` для создания других вьюмоделей, для них всех `createExceptionHandler`, `repository` и `strings` будут одинаковыми. +fun Koin.createAuthCodeViewModel(phone: String): AuthCodeViewModel { + return get { + parametersOf(phone) + } +} +``` -Теперь у нас есть доступ ко всем вьюмоделям фичи авторизации - чтобы создать какую-либо вьюмодель нужно просто вызвать нужную функцию у фабрики и передать один единственный аргумент. +В этот модуль мы будем добавлять методы, аналогичные `createResetPasswordViewModel` для создания других вьюмоделей, для них всех `repository` будет одинаковым. -### Уроверь mpp-library +Теперь у нас есть доступ ко всем вьюмоделям фичи авторизации, чтобы создать какую-либо вьюмодель нужно просто вызвать нужную функцию, где требуется, с передачей аргумента (в примере выше это номер телефона). -Логика работы приложения с источником данных (сервер, БД и т.д.) выносятся в классы - репозитории, в данном случае сделаем репозиторий для фичи авторизации - `AuthRepository` +### Уровень mpp-library -`AuthRepository.kt`: +Логика работы приложения с источником данных (сервер, БД и т.д.) выносятся в классы - репозитории, в данном случае сделаем репозиторий для фичи авторизации, имплементирующий интерфейс ResetPasswordRepository - `AuthRepositoryImpl` + +`AuthRepositoryImpl.kt`: ```kotlin -internal class AuthRepository constructor( +class AuthRepositoryImpl internal constructor( private val keyValueStorage: KeyValueStorage, private val dao: AppDao, - private val coroutineScope: CoroutineScope + private val api: AuthApi ) : ResetPasswordRepository { override fun resetPassword( phoneNumber: String, @@ -142,170 +142,176 @@ internal class AuthRepository constructor( ``` Этот класс реализует все интерфейсы вьюмоделей фичи авторизации для работы с источником данных. Для всех новых вьюмоделей других фичей мы будем объявлять свои интерфейсы, и реализовывать их в классе репозитория конкретной фичи, а затем прокидывать объект репозитория всем вьюмоделям. -Второй уровень абстракции: фабрика фабрик - `SharedFactory`. В ней мы также создадим все фабрики, как до этого создавали вьюмодели в фабриках, настроим их, чтобы для работы с общим кодом нужно было создать только одну общую фабрику - `SharedFactory`. +Также нам потребуется модуль Koin для получения всех репозиториев, в том числе для фичи авторизации. -`SharedFactory.kt`: ```kotlin -class SharedFactory internal constructor( - settings: Settings, - antilogs: List, - databaseDriverFactory: DatabaseDriverFactory, - repositoryCoroutineScope: CoroutineScope -) { - // public constructor for platform side usage - constructor( - settings: Settings, - antilog: Antilog?, - databaseDriverFactory: DatabaseDriverFactory?, - mpiServiceConnector: MpiServiceConnector? - ) : this( - settings = settings, - antilogs = listOfNotNull( - antilog, - CrashReportingAntilog(CrashlyticsLogger()) - ), - databaseDriverFactory = databaseDriverFactory, - mpiServiceConnector = mpiServiceConnector, - repositoryCoroutineScope = CoroutineScope(Dispatchers.Main) - ) - - internal val authRepository: AuthRepository by lazy { - AuthRepository( - //TODO +... +import org.koin.core.module.Module +import org.koin.core.module.dsl.binds +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module + +internal val repositoriesModule: Module = module { + singleOf(::AuthRepositoryImpl) { + binds( + classes = listOf( + ResetPasswordRepository::class, + AuthCodeRepository::class, + ... + ) ) } + singleOf(::ProfileRepositoryImpl) bind ProfileRepository::class - val authFactory: AuthFactory by lazy { - AuthFactory( - createExceptionHandler = ::createExceptionHandler, - authRepository = authRepository, - strings = object : AuthFactory.Strings { - override val resetDescription: StringDesc = - MR.strings.reset_description.desc() - } - ) - } - - private fun createExceptionHandler(): ExceptionHandler = ExceptionHandler( - // TODO - ) +``` +Теперь нужно собрать все модули фичей в `FeatureModule.kt`: +```kotlin +internal val featuresModules = module { + includes(featureAuthModule) + includes(featureProfileModule) + ... } ``` -В `SharedFactory` мы создали оставшиеся необходимые фабрикам компоненты - `authRepository` и `createExceptionHandler`, а также установили все строки локализации, необходимые фиче. -Поскольку, вьюмодель у нас пока что-то одна, объект `strings` для `AuthFactory` содержит только строки `ResetPasswordViewModel`. Если бы вьюмоделей было больше - все необходимые им строки задавались бы здесь. -*** -Фиче может понадобиться гораздо больше строк локализации, чем одна, и самих фич в проекте может быть очень много. Если инициализировать строки локализации каждой в фабрики фичей именно в `SharedFactory`, то класс со временем сильно разрастется и ориентироваться в нем будет сложно. -Предлагаем вам использовать вспомогательные функции, расположенные рядом с `SharedFactory`, чтобы инициализировать фабрики строками именно там, а в `SharedFactory` вызывать эти функции. +Теперь нужно зарегистрировать все модули Koin: -`AuthFactoryInit.kt`: ```kotlin -internal fun AuthFactory( - createExceptionHandler: () -> ExceptionHandler, - authRepository: AuthRepositoryInterface -): AuthFactory { - return AuthFactory( - createExceptionHandler = createExceptionHandler, - authRepository = authRepository, - strings = object : AuthFactory.Strings { - override val resetDescription: StringDesc = - MR.strings.reset_description.desc() - } - ) -} +internal fun registerKoinModules( + baseUrl: String +): List = listOf( + apiModule(baseUrl = baseUrl), + featuresModules, + repositoriesModule, + ... +) ``` -Вызов в `SharedFactory`: + +Также в commonMain подготовим функцию для инициализации Koin: ```kotlin -val authFactory: AuthFactory by lazy { - AuthFactory( - createExceptionHandler = ::createExceptionHandler, - authRepository = authRepository - ) +fun startDI( + baseUrl: String, + antilog: Antilog?, + exceptionLogger: ExceptionLogger, + appDeclaration: KoinAppDeclaration? = null +): KoinApplication { + antilog?.also { Napier.base(antilog = it) } + Napier.base(CrashReportingAntilog(exceptionLogger)) + configureExceptionMappers() + + return startKoin { + modules( + registerKoinModules( + baseUrl = baseUrl + ) + ) + appDeclaration?.invoke(this) + } } ``` -*** -### Уроверь платформы +*** -Параметры `SharedFactory` - это то, что мы не можем создать из общего кода а можем получить только с платформы. +### Уровень платформы +Параметры `startDI` - это то, что мы не можем создать из общего кода, а можем получить только с платформы. ***iOS*** -Класс со статической переменной - фабрикой +Для удобства создаем `Koin.swift`: +```swift +typealias KoinApplication = Koin_coreKoinApplication +typealias Koin = Koin_coreKoin ``` -class AppComponent { - static var factory: SharedFactory! +И `KoinManager.swift`: + +```swift +fileprivate var koinInstance: Koin! + +extension Koin { + static var shared: Koin { + return koinInstance + } + + internal static func setup() { + guard koinInstance == nil else { + fatalError("koin already initialized!") + } + + let antilog: Antilog? + #if DEBUG + antilog = DebugAntilog(defaultTag: "debug") + #else + antilog = nil + #endif + + let koinApp: KoinApplication = KoinKt.startDI( + baseUrl: Environment.Keys.serverBaseUrl.value(), + antilog: antilog, + exceptionLogger: CrashlyticsExceptionLogger() + ) + + koinInstance = koinApp.koin + } } + ``` -В методе `application` класса `AppDelegate` инициализируем фабрику и прокидываем дальше в `AppCoordinator`. О нем вы узнаете уже в следующем разделе `Навигация между экранами`. +В методе `application` класса `AppDelegate` инициализируем Koin. ``` -func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { +func application( + _: UIApplication, + didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil +) -> Bool { FirebaseApp.configure() - MokoFirebaseCrashlytics.setup() - let antilog: Antilog? #if DEBUG - antilog = DebugAntilog(defaultTag: "debug") - #else - antilog = nil + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(false) #endif - AppComponent.factory = SharedFactory( - settings: AppleSettings(delegate: UserDefaults.standard), - antilog: antilog, - databaseDriverFactory: SqlDatabaseDriverFactory(), - ) - - let window = UIWindow() - - coordinator = AppCoordinator( - window: window, - factory: AppComponent.factory - ) - coordinator.start() - - window.makeKeyAndVisible() - self.window = window + Koin.setup() return true } ``` -`AppCoordinator` прокидывает ее дальше, в дочерние координаторы, которые, в свою очередь, отправляют ее уже в контроллеры. -Получение вьюмоедли в контроллере выглядит вот так: +Получение вьюмодели в контроллере выглядит вот так: ``` -vc.resetPasswordViewModel = factory -.authFactory -.createResetPasswordViewModel(eventsDispatcher: EventsDispatcher(listener: vc)) +private var resetPasswordViewModel: ResetPasswordViewModel = Koin.shared + .createResetPasswordViewModel() ``` ***Android*** ```kotlin -val factory = SharedFactory( - AndroidSettings( - delegate = context.getSharedPreferences("app", MODE_PRIVATE) - ), - antilog = antilog, - databaseDriverFactory = SqlDatabaseDriverFactory(context) -) +class MainApplication : Application() { + override fun onCreate() { + super.onCreate() + ... + val antilog: LogcatAntilog? = if (BuildConfig.DEBUG) { + LogcatAntilog() + } else { + null + } -val resetPasswordViewModel = factory.authFactory.createResetPasswordViewModel( - eventsDispatcher = eventsDispatcherOnMain() -) + startDI( + baseUrl = BuildConfig.BASE_URL, + antilog = antilog, + exceptionLogger = CrashlyticsLogger() + ) { + if (BuildConfig.DEBUG) { + androidLogger() + } + androidContext(this@MainApplication) + } + } +} ``` -Наконец, как добавлять новые компоненты в фичи и вьюмодели, если вдруг что-то понадобилось: - - все что общее для вьюмоделей одной фичи - настраивается в фабрике - - все, что общее для всех фабрик - настраивается в `SharedFactory` - - -Таким образом, чтобы начать работу с общим кодом - нужно только создать объект `SharedFactory`, передав ему несколько параметров, доступных только на платформе. +Таким образом, чтобы начать работу с общим кодом, нужно только инициализировать Koin c помощью `startDI`, передав ему несколько параметров, доступных только на платформе. ## Практическое задание - Используйте проект, готовый после раздела [Многомодульность](./multimodularity#практическое-задание) -- Добавьте фабрики для создания фичей +- Добавьте модули Koin для фичей и его инициализацию diff --git a/university/4-icerock-basics/entities.md b/university/4-icerock-basics/entities.md index b920ad4ad..1b24b644c 100644 --- a/university/4-icerock-basics/entities.md +++ b/university/4-icerock-basics/entities.md @@ -1,5 +1,5 @@ --- -sidebar_position: 15 +sidebar_position: 14 --- # Сущности приложения @@ -35,6 +35,7 @@ data class StarShipResponse( val uniqueInfo: String? = null ) ``` +Сетевые сущности должны содержать аннотацию с именем поля для всех полей класса. Пример доменного класса с учетом того, что в приложении используется только следующая информация о корабле: - название diff --git a/university/4-icerock-basics/logging-and-errors.md b/university/4-icerock-basics/logging-and-errors.md index 010c3fd6c..beee9a7c0 100644 --- a/university/4-icerock-basics/logging-and-errors.md +++ b/university/4-icerock-basics/logging-and-errors.md @@ -1,5 +1,5 @@ --- -sidebar_position: 11 +sidebar_position: 10 --- # Логирование и обработка ошибок diff --git a/university/4-icerock-basics/mock-engine.md b/university/4-icerock-basics/mock-engine.md index 1c6a08a1a..4c8214e23 100644 --- a/university/4-icerock-basics/mock-engine.md +++ b/university/4-icerock-basics/mock-engine.md @@ -1,5 +1,5 @@ --- -sidebar_position: 14 +sidebar_position: 13 --- # Мокирование данных diff --git a/university/4-icerock-basics/practice.md b/university/4-icerock-basics/practice.md index 0c76e082d..9bea4529e 100644 --- a/university/4-icerock-basics/practice.md +++ b/university/4-icerock-basics/practice.md @@ -1,5 +1,5 @@ --- -sidebar_position: 16 +sidebar_position: 15 --- # Практическое задание @@ -26,23 +26,24 @@ sidebar_position: 16 3. Создать отдельные модули для: `common` кода, фичи авторизации, и фичи репозитория. 4. Сохранять токен авторизации в хранилище устройства: `SharedPreferences` для `Android` и `NSUserDefaults` для `iOS`. Работу с хранилищем делегировать классу `KeyValueStorage` 5. Использовать `multiplatform-settings` для работы с хранилищем устройства -6. Использовать `moko-mvvm` для внедрения всех ее возможностей, о которых вы узнали [статьи](../../learning/libraries/moko/moko-mvvm) +6. Использовать `moko-mvvm` для внедрения всех ее возможностей, о которых вы узнали из [статьи](../../learning/libraries/moko/moko-mvvm) 7. Использовать `moko-resources` для использования строк локализации приложения 8. Использовать `moko-units` для реализации списка репозиториев 9. Использовать `ExceptionMappersStorage` из `moko-errors` (не используйте `ExceptionHandler`) -10. Вся логика должна находиться в `common` коде -11. Навигация на `iOS` должна быть реализована используя `AppCoordinator`, без `storyboards` -12. Логика хранения данных должна находиться в `common` коде -13. Логика работы с сетью должна находиться в `common` коде -14. Для работы с сетью использовать `Ktor Client` -15. Используйте доменные сущности, вместо сетевых -16. При перезапуске приложения авторизация должна сохраняться -17. Использовать локализацию для всех строк, показываемых пользователю -18. Использовать векторную графику везде, где это возможно -19. Обеспечить поддержку Android API 21 -20. Локализовать проект используя `sheets-localizations-generator` +10. Вся логика должна находиться в `common` коде +11. Используйте Koin для внедрения зависимостей +12. Навигация на `iOS` должна быть реализована используя `AppCoordinator`, без `storyboards` +13. Логика хранения данных должна находиться в `common` коде +14. Логика работы с сетью должна находиться в `common` коде +15. Для работы с сетью использовать `Ktor Client` +16. Используйте доменные сущности, вместо сетевых +17. При перезапуске приложения авторизация должна сохраняться +18. Использовать локализацию для всех строк, показываемых пользователю +19. Использовать векторную графику везде, где это возможно +20. Обеспечить поддержку Android API 21 +21. Локализовать проект используя `sheets-localizations-generator` - обеспечьте поддержку русского и английского языков -21. Обеспечить поддержку iOS 13.0 +22. Обеспечить поддержку iOS 13.0 ## Классы приложения @@ -85,8 +86,8 @@ class KeyValueStorage { ### mpp-library-feature-auth ```kotlin class AuthViewModel { - val token: MutableLiveData - val state: LiveData + val token: MutableStateFlow + val state: StateFlow val actions: Flow fun onSignButtonPressed() { @@ -111,7 +112,7 @@ class AuthViewModel { ### mpp-library-feature-repo ```kotlin class RepositoryInfoViewModel { - val state: LiveData + val state: StateFlow sealed interface State { object Loading : State @@ -134,7 +135,7 @@ class RepositoryInfoViewModel { } class RepositoriesListViewModel { - val state: LiveData + val state: StateFlow sealed interface State { object Loading : State @@ -233,5 +234,6 @@ GitHubRepoRepository --> KeyValueStorage 6. [Подключение Ktor Client](https://ktor.io/docs/gradle.html) 7. [Настройке запросов в Ktor Client](https://ktor.io/docs/request.html) 8. [multiplatform-settings](https://github.com/russhwolf/multiplatform-settings) -9. [Android Дизайн](https://www.figma.com/file/Mh3ga5XAzyJNCY87NBp01G/Git_test) -10. [iOS Дизайн](https://www.figma.com/file/XmpoCqkdWTGb2NGdR2bgiQ/Git_test-iOS) \ No newline at end of file +9. [Koin](https://github.com/InsertKoinIO/koin) +10. [Android Дизайн](https://www.figma.com/file/Mh3ga5XAzyJNCY87NBp01G/Git_test) +11. [iOS Дизайн](https://www.figma.com/file/XmpoCqkdWTGb2NGdR2bgiQ/Git_test-iOS) diff --git a/university/4-icerock-basics/repository.md b/university/4-icerock-basics/repository.md index e1b705119..41105b1a7 100644 --- a/university/4-icerock-basics/repository.md +++ b/university/4-icerock-basics/repository.md @@ -1,5 +1,5 @@ --- -sidebar_position: 13 +sidebar_position: 12 --- # Реактивный источник данных diff --git a/university/4-icerock-basics/ui-units.md b/university/4-icerock-basics/ui-units.md deleted file mode 100644 index 0d4c95183..000000000 --- a/university/4-icerock-basics/ui-units.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -sidebar_position: 10 ---- -# Построение экранов вертикальными списками - -В мобильных приложениях наиболее распространен интерфейс с вертикальной прокруткой (scroll) экрана. Это связано с форм-фактором устройств и количеством контента, который нужно показывать пользователю. - -Для реализации таких экранов используются два подхода: -- `RecyclerView`/`UITableView` с переиспользованием элементов при прокрутке - этот подход используется в любой ленте или списке однотипных элементов; -- `ScrollView`/`UIScrollView` с ручной версткой содержимого, которое должно быть в scroll. Данный подход используется чаще всего на экранах с где контент не является набором однотипных элементов, но не влезает на экран устройства полностью. - -| `RecyclerView` / `UITableView` | `ScrollView` / `UIScrollView` | -| ----- | ----- | -| ![listview](media/listview.png) | ![scrollview](media/scrollview.png) | - -Первый вариант экранов, с однотипными списками элементов, достаточно очевидно как можно использовать с общей логикой - собираем список с данными в `ViewModel`, а на UI передаем эти данные в `Adapter`/`DataSource`. - -Для второго же предполагается много ручной работы по верстке UI под конкретное расположение элементов и выдачу данных из `ViewModel` отдельными полями. - -Мы используем подход по построению практически любого экрана с вертикальной прокруткой через первый вариант - `RecyclerView`/`UITableView`. Аналогичный подход используют Airbnb в своей библиотеке [epoxy](https://github.com/airbnb/epoxy). - -Это позволяет нам контролировать содержимое экрана из общей логики, частично управляя и расположением и видимостью элементов на UI. По сути экран бьется на строки - вертикальные блоки, которые мы назвали `Unit` (элемент). Разберем приведенный выше пример, вот как можно разбить на блоки: - -| | | -| ----- | ----- | -| ![units-1](media/scrollview-units-1.png) | ![units-2](media/scrollview-units-2.png) | - -## moko-units - -Библиотека [moko-units](https://github.com/icerockdev/moko-units) позволяет нам реализовать описанный выше подход с управлением составом отображаемых на UI блоков из общего кода. Со стороны каждой платформы интеграция очень простая и стандартная. Вся основная работа состоит в том, чтобы сверстать все блоки и прописать логику их создания из `sealed interface` находящегося в общем коде. - -Посмотрите видео материал с подробным пояснением причин обобщения реализации между платформами и принципами работы. - - - -## Практическое задание -- Используйте проект, готовый после раздела [Ресурсы на iOS](./ios-resources#практическое-задание) -- Подключите `moko-units` -- Реализуйте логику построения списков в общем коде (только для списка репозиториев) -- iOS и Android приложения должны работать