Skip to content

Commit

Permalink
Merge pull request #17 from anton6tak/8-data-storage
Browse files Browse the repository at this point in the history
8 data storage
  • Loading branch information
Alex009 authored May 25, 2022
2 parents ed78b3c + e357ee8 commit aca0d65
Show file tree
Hide file tree
Showing 6 changed files with 295 additions and 0 deletions.
4 changes: 4 additions & 0 deletions university/8-data-storage/_category_.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"position": 8,
"label": "8. Хранение данных"
}
53 changes: 53 additions & 0 deletions university/8-data-storage/how-to-store-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
sidebar_position: 1
---

# Как хранить данные

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

Все эти данные следует хранить по разному, сейчас разберемся как.

## Какие данные хранить в БД
Как вы знаете, база данных представляет из себя набор таблиц, в элементах которых как раз и находятся те данные, которые мы сохранили в БД.
Важно понимать, что в базе данных нужно хранить именно повторяющиеся структуры, которые использует приложение, например:
- объект `Order` для приложения-ресторана, чтобы собирать статистику по количеству заказов в месяц
- объект `News` для отображения новостей в социальных сетях, чтобы даже при отсутствии интернета юзеру можно было показать прошлые новости
- объект `Message` - сообщение в чате, чтобы также при отсутствии интернета юзер смог прочитать прошлые сообщения

В базе данных не стоит хранить файлы (картинки, электронные книги, документы и тд) потому что она не предназначена для хранения файлов.
Основная цель БД - организовать быстрый доступ к конкретным записям и возможность поиска по этим записям.

Если мы будем хранить в БД большой файл, то, во-первых, придется получать файлы из базы данных, что в принципе противоречит ее назначению, так еще и будет сильно тормозить ее работу.
Во-вторых - сортировать по бинарному файлу у нас не будет абсолютно никакой возможности.
Ну и в-третьих, незачем изобретать велосипед, когда для хранения файлов есть файловая система.

Однако, мы можете хранить в базе данных пути до этих файлов, либо же их названия.

## Какие данные хранить в KeyValue
Вспомните, как мы работаем с `KeyValue` хранилищем [на проектах](../kotlin-multiplatform-mobile/multiplatform-settings#keyvaluestorage).

Предназначение `KeyValue` хранилища в том, чтобы хранить там только те данные, которые в конкретный момент времени могут быть только в единственном экземпляре, например:
- код-пароль, который юзер задал при первом входе в приложение
- email и пароль
- id текущего заказа
- и тд.

Например, мы хотим знать id текущего заказа. Для этого мы добавили столбец в БД - `isCurrentOrder`. Во время разработки логики мы ошиблись и в какой-то момент у нас оказалось две записи в БД с isCurrentOrder = true - ошибка.
Поэтому, вместо БД для таких флагов мы будем использовать хранилище `KeyValue`, которое гарантирует единственность значения по конкретному ключу.

Также, в `KeyValue` не должно быть коллекций и списков, потому что множества нужно хранить в БД.

## Какие данные хранить в файлах
Большие файлы должны сохраняться как файлы, чтобы их можно было читать и записывать потоково, чтобы не забивать оперативную память устройства. Читать и записывать потоково в БД мы не можем, а в файл - можем.

## Отличия Sql от NoSql
Прочитайте [статью](https://smoff.ru/howitworks/otlichiya-sql-nosql) про разницу между SQL и NoSQL базами данных.
Пример реляционной базы данных - [SQLDelight](https://github.com/cashapp/sqldelight/) ([описание](https://cashapp.github.io/sqldelight/multiplatform_sqlite/)), нереляционной - [MongoDB Realm](https://github.com/realm/realm-kotlin) ([описание](https://www.mongodb.com/docs/realm/get-started/introduction-mobile/))
13 changes: 13 additions & 0 deletions university/8-data-storage/intro.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
sidebar_position: 0
---

# Вводная
Восьмой блок курса посвящен работе с данными и их хранению в базе данных.

Что вы узнаете:
- Какие данные стоит хранить в базе данных, а какие нет
- Чем отличаются SQL и NoSQL базы данных
- Как работать с файлами в общем коде
- Как работать с базой данных в общем коде
- Как реализовать реактивный источник данных с базой данных
29 changes: 29 additions & 0 deletions university/8-data-storage/practice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
sidebar_position: 4
---

# Практическое задание
Добавьте в ваше приложение реактивный источник данных, используя базу данных SQLDelight, храните в ней данные о репозиториях.

Во время работы над практическим заданием настоятельно рекомендуем обращаться к разделу [Памятки для разработчика](../memos/function)

## Функциональные требования
- Данные о репозиториях должны храниться в базе данных
- Данные о репозиториях должны реактивно обновляться
- Вынести работу с файлами(добавление картинки к issue) в общий код
- Возможность сделать pull-to-refresh на экране детального вида репозитория

## Технические требования
1. Использовать `SQLDelight` базу данных
2. Использовать миграции для обновления структуры базы данных
3. Использовать `okio` для работы с файлами в общем коде

## Test cases
На экране детального просмотра репозитория видим N звездочек. Заходим на сайт GitHub - ставим там этому репозиторию звездочку, делаем на экране pull-to-refresh - видим N+1 звездочек. Возвращаемся на экран списка репозиториев - там количество звездочек также обновилось.

## Материалы
1. [okio](https://github.com/square/okio)
2. [Документация](https://square.github.io/okio/3.x/okio/okio/okio/) okio
3. [SQLDelight](https://cashapp.github.io/sqldelight/)
4. [SQLDelight Getting Started with Multiplatform](https://cashapp.github.io/sqldelight/multiplatform_sqlite/)
5. [SQLDelight Migrations](https://cashapp.github.io/sqldelight/jvm_sqlite/migrations/)
183 changes: 183 additions & 0 deletions university/8-data-storage/sqldelight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
---
sidebar_position: 2
---

# SQLDelight

## SQLDelight

[SQLDelight](https://cashapp.github.io/sqldelight/multiplatform_sqlite/) - это библиотека для удобной работы с БД в общем коде. Она позволяет генерировать классы и методы для работы с базой данных.

Статьи для пошаговой настройки библиотеки:
- [Configuring SQLDelight and implementing cache logic](https://play.kotlinlang.org/hands-on/Networking%20and%20Data%20Storage%20with%20Kotlin%20Multiplatfrom%20Mobile/05_Configuring_SQLDelight_an_implementing_cache) от Kotlin
- [Настройка SQLDelight для хранения данных](https://runebook.dev/ru/docs/kotlin/docs/kmm-configure-sqldelight-for-data-storage)

Изучите [что такое миграции](https://ru.stackoverflow.com/questions/325882/%D0%97%D0%B0%D1%87%D0%B5%D0%BC-%D0%BD%D1%83%D0%B6%D0%BD%D1%8B-%D0%BC%D0%B8%D0%B3%D1%80%D0%B0%D1%86%D0%B8%D0%B8) и [прочитайте](https://cashapp.github.io/sqldelight/jvm_sqlite/migrations/) как их создавать в SQLDelight.

## Реактивный источник данных c SQLDelight

Для начала, вспомните что такое [реактивный источник данных](../icerock-basics/repository#проблема-и-решение)

Сейчас мы разберем, как реализовать реактивный источник данных, используя [SQLDelight](https://cashapp.github.io/sqldelight/) базу данных и `Flow`.

### Реализация источника данных

Допустим, у нас есть экран со списком кораблей, по клику на элемент списка нужно показать детальную информацию о конкретном корабле на следующем экране.
Как вы уже знаете из [статьи](/learning/android/data-sharing#какие-данные-можно-передавать), передавать нужно идентификаторы данных, а не сами данные.
Поэтому, по клику на корабль мы будем передавать `id` этого корабля на следующий экран, а там уже обращаться в БД и получать данные интересующего нас корабля.

Создадим таблицу, в которой будет храниться информация о корабле и запрос, позволяющий получить корабль по `id`.
Вот как будут выглядеть таблица и запрос:

```sqldelight
CREATE TABLE ShipsTable (
id INTEGER AS Int PRIMARY KEY,
name TEXT NOT NULL,
buildYear TEXT NOT NULL,
peopleCount INTEGER AS Int NOT NULL,
maxSpeed REAL AS Double NOT NULL,
loadCapacity INTEGER AS Int NOT NULL,
rating INTEGER AS Int NOT NULL
);
getShipById:
SELECT *
FROM ShipsTable
WHERE id = :shipId;
```

`shipId` в строке `WHERE id = :shipId;` - это название аргумента, который будет принимать функция `getShipById`.
Вот какая функция будет сгенерирована:

```kotlin
public fun getShipById(shipId: Int): Query<ShipsTable>
```

Названия аргументов не обязательно указывать явно - можно использовать `?`, однако это очень неудобно.
Например, у нас есть ~~очень полезная~~ функция `getShips`, позволяющая получить все корабли, если угадать и подставить подходящие аргументы :) Она принимает 3 параметра:
```sqldelight
getShips:
SELECT *
FROM ShipsTable
WHERE ? > 123 & ? < 456 & length(?) < 7;
```
Опустим обсуждение её смысла и полезности, посмотрим на то, что будет сгенерировано в этом случае:

```kotlin
public fun getShips(
`value`: Long,
value_: Long,
value__: Long?
): Query<ShipsTable>
```
Согласитесь, по названиям `value` не понятно абсолютно ничего: зачем эти аргументы, за что они отвечают и тд. Еще непонятнее станет вашему коллеге, который это увидит.
Поэтому, старайтесь всегда использовать именованные аргументы.

После создания таблицы и вызова нужной `gradle-задачи` - `generateSqlDelightInterface`, нам будем доступен для использования класс этой таблицы.

`ShipsTable.kt`:
```kotlin
public data class ShipsTable(
public val id: Int,
public val name: String,
public val buildYear: String,
public val peopleCount: Int,
public val maxSpeed: Double,
public val loadCapacity: Int,
public val rating: Int
) {
public override fun toString(): String = """
|ShipsTable [
| id: $id
| name: $name
| buildYear: $buildYear
| peopleCount: $peopleCount
| maxSpeed: $maxSpeed
| loadCapacity: $loadCapacity
| rating: $rating
|]
""".trimMargin()
}
```
Реализация метода `getShipById` будет следующей:

```kotlin
fun getShipById(id: Int): Flow<ShipsTable?> {
return shipsQueries.getShipById(id)
.asFlow()
.mapToOneOrNull()
}
```

В этой реализации есть одна проблема - мы работаем со сгенерированным на основе таблицы классом `ShipsTable`:
- при добавлении, удалении или изменении полей из таблицы нам может понадобиться исправлять все места, где используются объекты `ShipsTable`
- объекты `ShipsTable` будут содержать абсолютно все поля БД, которые редко будут нужны все сразу

Чтобы избежать этих проблем, создадим свой класс `Ship`, с объектами которого мы будем работать во всем приложении. Также, добавим `extension` - `ShipsTable.toFeature()` с помощью которого будем преобразовывать объекты `ShipsTable` в `Ship`.

`Ship.kt`:
```kotlin
data class Ship(
val id: Int,
val name: String,
val buildYear: String,
val peopleCount: Int,
val maxSpeed: Double,
val loadCapacity: Int,
val rating: Int
)
```

`ShipsTableMapper.kt`:
```kotlin
internal fun ShipsTable.toFeature(): Ship = Ship(
id = this.id,
name = this.name,
buildYear = this.buildYear,
peopleCount = this.peopleCount,
maxSpeed = this.maxSpeed,
loadCapacity = this.loadCapacity,
rating = this.rating
)
```

Обновленный метод `getShipById`:

```kotlin
fun getShipById(id: Int): Flow<Ship?> {
return shipsQueries.getShipById(shipId = id)
.asFlow()
.mapToOneOrNull()
.map { it?.toFeature() }
}
```
Такой подход позволит нам спокойно менять таблицу. После изменений нам нужно будет изменить только один метод - `toFeature()`.

### Как подписаться на Flow

Итак, теперь мы получаем от таблицы не просто объект `Ship`, а `Flow`, на который можем подписаться из `viewModel`:

Вариант подписки, используя `StateFlow`:
```kotlin
val currentShip: StateFlow<Ship?> = repository.getShipById(id).stateIn(viewModelScope, SharingStarted.Eagerly, null )
```

Вариант подписки, используя `LiveData`:
```kotlin
val currentShip: LiveData<Ship?> = repository.getShipById(id).asLiveData(viewModelScope, initialValue = null)
```

Теперь, если данные в таблице изменятся, то все методы, у которых изменилось возвращаемые значение, вызовутся еще раз. Все места в приложении, где используются данные из этого запроса, обновятся. Нигде не придется ничего вызывать и обновлять вручную. Данные изменились - `UI` сразу обновится.

Например: в источнике данных обновился `rating` корабля с идентификатором `id` - он автоматически обновится на всех экранах, где мы его отображаем, потому что результат `repository.getShipById(id)` - это `Flow`, а мы на него подписались.

## Практическое задание
Подключите базу данных [SQLDelight](https://cashapp.github.io/sqldelight/multiplatform_sqlite/) к вашему приложению, выполните следующие условия:
- Создание БД должно происходить в `SharedFactory`
- Доступ к БД должен быть только у репозитория
- Создайте таблицу - `RepoTable` с двумя столбцами: id и testMessage
- Создайте метод для добавления записи в БД
- Создайте метод для получения всех записей в БД
- Протестируйте работоспособность вашей БД

Главно - чтобы проект запустился и заработал на обеих платформах, дальше в практике мы заполним БД.
13 changes: 13 additions & 0 deletions university/8-data-storage/work-with-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
sidebar_position: 3
---

# Работа с файлами

## okio

Для работы с файлами в общем коде мы будем пользоваться библиотекой [okio](https://square.github.io/okio/).
Изучите документацию по [подключению](https://square.github.io/okio/multiplatform/#gradle-configuration) и [использованию](https://square.github.io/okio/file_system/) `okio` в общем коде.

## Практическое задание
Вынесите логику работы с файлами (для добавления картинки к issue) в общий код, используя библиотеку `okio`.

0 comments on commit aca0d65

Please sign in to comment.