ладно, возвращаемся
для начала не будет забавной пикчи, будет предположения о последствиях нового закона об IT
плюсы:
• освобождение от налогов — у компаний останется значительно больше дохода на развитие
• отсрочка от армии — приличной части айтишников действительно будет полезнее провести год, занимаясь профильным делом
• льготная ипотека сотрудникам — кредиты под хорошую ставку всегда хорошо
минусы:
• освобождение от налогов
тут нужно учитывать, что у государства не так много видов дохода. один из них — налоги
если их начинают собирать меньше, то в бюджете становится меньше денег на добрые дела (развитие инфраструктуры, безопасности, поддержки нуждающихся)
значит, нужно либо больше печатать, либо больше собирать с других, либо урезать расходы
• отсрочка от армии
вероятнее всего, в законе будет прописано, что отсрочку получат только те, кто отработал энное количество времени на организацию
значит, у компаний будет козырь на снижение зарплат молодым специалистам: "не хочешь работать за полцены? мы тебя списываем с должности жизненно важного айтишника и твоя отсрочка сгорает"
не то чтобы это совсем нечестно, просто к этому нужно быть готовым
• льготная ипотека сотрудникам
риски аналогичны предыдущему пункту
перебежать из компании в компанию (или шантажировать начальство офером в другую компанию) станет куда сложнее, если твоя ипотека превращается в анти-льготную при переходе в другую компанию
опять же, это абсолютно честно, но бесконечный рост айтишных зарплат замедлит
для начала не будет забавной пикчи, будет предположения о последствиях нового закона об IT
плюсы:
• освобождение от налогов — у компаний останется значительно больше дохода на развитие
• отсрочка от армии — приличной части айтишников действительно будет полезнее провести год, занимаясь профильным делом
• льготная ипотека сотрудникам — кредиты под хорошую ставку всегда хорошо
минусы:
• освобождение от налогов
тут нужно учитывать, что у государства не так много видов дохода. один из них — налоги
если их начинают собирать меньше, то в бюджете становится меньше денег на добрые дела (развитие инфраструктуры, безопасности, поддержки нуждающихся)
значит, нужно либо больше печатать, либо больше собирать с других, либо урезать расходы
• отсрочка от армии
вероятнее всего, в законе будет прописано, что отсрочку получат только те, кто отработал энное количество времени на организацию
значит, у компаний будет козырь на снижение зарплат молодым специалистам: "не хочешь работать за полцены? мы тебя списываем с должности жизненно важного айтишника и твоя отсрочка сгорает"
не то чтобы это совсем нечестно, просто к этому нужно быть готовым
• льготная ипотека сотрудникам
риски аналогичны предыдущему пункту
перебежать из компании в компанию (или шантажировать начальство офером в другую компанию) станет куда сложнее, если твоя ипотека превращается в анти-льготную при переходе в другую компанию
опять же, это абсолютно честно, но бесконечный рост айтишных зарплат замедлит
👍2
Типы permissions для получения локации
Для работы с местоположением, нужно явно запросить одно из разрешений:
• ACCESS COARSE LOCATION
когда приложение находится в статусе foreground (открыта активити или запущен сервис с уведмолением) и нужно получить примерную локацию (точность порядка +-3 км)
• ACCESS FINE LOCATION
статус foreground и нужно получить точную локацию (порядка +-10-50м)
• ACCESS BACKGROUND LOCATION
можно получать локацию, когда приложение не имеет статуса foreground
такое нужно приложениям для семейного контроля, отслеживанию друзей или IoT-пультам
особенности:
• можно запросить только COARSE, но нельзя запросить только FINE
если вам нужно разрешение ACCESS FINE LOCATION, запрашивайте сразу оба, а пользователь выберет - дать точную локацию, приближенную или отказать
• если пользователь уже выдал COARSE, можно запросить апгрейд до FINE
для этого снова выполните запрос на оба разрешения
• запрашивать ACCESS BACKGROUND LOCATION можно после получения одного из foreground-разрешений
при этом, если получено только COARSE, то только эта точность и будет доступна в background
....
описанные механики разнятся от версии к версии андроида
приведенная логика справедлива для Android 12, в более старых некоторые требования мягче
Для работы с местоположением, нужно явно запросить одно из разрешений:
• ACCESS COARSE LOCATION
когда приложение находится в статусе foreground (открыта активити или запущен сервис с уведмолением) и нужно получить примерную локацию (точность порядка +-3 км)
• ACCESS FINE LOCATION
статус foreground и нужно получить точную локацию (порядка +-10-50м)
• ACCESS BACKGROUND LOCATION
можно получать локацию, когда приложение не имеет статуса foreground
такое нужно приложениям для семейного контроля, отслеживанию друзей или IoT-пультам
особенности:
• можно запросить только COARSE, но нельзя запросить только FINE
если вам нужно разрешение ACCESS FINE LOCATION, запрашивайте сразу оба, а пользователь выберет - дать точную локацию, приближенную или отказать
• если пользователь уже выдал COARSE, можно запросить апгрейд до FINE
для этого снова выполните запрос на оба разрешения
• запрашивать ACCESS BACKGROUND LOCATION можно после получения одного из foreground-разрешений
при этом, если получено только COARSE, то только эта точность и будет доступна в background
....
описанные механики разнятся от версии к версии андроида
приведенная логика справедлива для Android 12, в более старых некоторые требования мягче
👍11🔥2
Философия LayoutParams
xml-вьюшки стремительно отмирают в пользу Compose. уважим их несколькими постами и заодно разберемся, почему у людей возникло желание от них избавиться
свойства LayoutParams:
• базовый класс — ViewGroup.LayoutParams
он содержит только два свойства - height, weight
• View получают LayoutParams от ближайшего родительского ViewGroup
если родитель вьюшки - RelativeLayout, то View.mLayoutParams = RelativeLayout.LayoutParams
если у вьюшки нет родителя (она еще не attached), то View.mLayoutParams = null
• ViewGroup.LayoutParams расширяется наследниками
например, есть такая иерархия: RelativeLayout.LayoutParams extends ViewGroup.MarginLayoutParams extends ViewGroup.LayoutParams
• если вызвать View.setLayoutParams(...), то View передаст новые параметры родителю, тот перерисует себя, а после перерисуется сама View
....
какие отрицательные побочки мы получаем от наследовательной архитектуры LayoutParams? в чем ее минусы (из-за которых набирает популярность compose)?
xml-вьюшки стремительно отмирают в пользу Compose. уважим их несколькими постами и заодно разберемся, почему у людей возникло желание от них избавиться
свойства LayoutParams:
• базовый класс — ViewGroup.LayoutParams
он содержит только два свойства - height, weight
• View получают LayoutParams от ближайшего родительского ViewGroup
если родитель вьюшки - RelativeLayout, то View.mLayoutParams = RelativeLayout.LayoutParams
если у вьюшки нет родителя (она еще не attached), то View.mLayoutParams = null
• ViewGroup.LayoutParams расширяется наследниками
например, есть такая иерархия: RelativeLayout.LayoutParams extends ViewGroup.MarginLayoutParams extends ViewGroup.LayoutParams
• если вызвать View.setLayoutParams(...), то View передаст новые параметры родителю, тот перерисует себя, а после перерисуется сама View
....
какие отрицательные побочки мы получаем от наследовательной архитектуры LayoutParams? в чем ее минусы (из-за которых набирает популярность compose)?
👍6
Xml — зачем префиксы android, tools, app
Описывая вьюшки в xml-файлах, мы чаще всего пишем android:paramName="value"
В этом случае android — это префикс
Префикс принято объявлять в самом старшем элементе xml-файла — используется следующая конструкция:
xmlns:prefixName="URI"
Зачем?
чтобы можно было легко подменять способ применения параметра, не изменяя его имени
например, параметр text для TextView
• если написать, android:text="value", то после запуска приложения мы увидим в TextView текст "value"
• если написать tools:text="value", то в редакторе студии мы увидим в TextView текст "value", а при запуске приложения - нет
один и тот же параметр обработался по-разному
Существуют следующие префиксы:
• android — параметры, которые повлияют на работу приложения
• tools — параметры, которые учитываются Lint и визуальным редактором студии, но вырезаются при компиляции
• app — создание собственных параметров (или использования кастомных параметров из библиотек)
....
почему ссылка из объявления префикса не открывается?
например, xmlns:app="http://schemas.android.com/apk/res-auto"
Описывая вьюшки в xml-файлах, мы чаще всего пишем android:paramName="value"
В этом случае android — это префикс
Префикс принято объявлять в самом старшем элементе xml-файла — используется следующая конструкция:
xmlns:prefixName="URI"
Зачем?
чтобы можно было легко подменять способ применения параметра, не изменяя его имени
например, параметр text для TextView
• если написать, android:text="value", то после запуска приложения мы увидим в TextView текст "value"
• если написать tools:text="value", то в редакторе студии мы увидим в TextView текст "value", а при запуске приложения - нет
один и тот же параметр обработался по-разному
Существуют следующие префиксы:
• android — параметры, которые повлияют на работу приложения
• tools — параметры, которые учитываются Lint и визуальным редактором студии, но вырезаются при компиляции
• app — создание собственных параметров (или использования кастомных параметров из библиотек)
....
почему ссылка из объявления префикса не открывается?
например, xmlns:app="http://schemas.android.com/apk/res-auto"
👍17🤩1
Философия LayoutInflator
LayoutInflator — класс, умеющий создавать из xml-разметки объект View
По умолчанию он использует стандартный парсер XmlPullParser, но можно передать свой (зачем - придумать сложно)
Способности:
• inflate(...) — парсит xml-файл в View-объект
если передать ссылку на родителя-ViewGroup, то LayoutInflator автоматически прикрепит View к родителю и вернет ссылку не на View, а на этого родителя
при передачи родителя View получает родительский LayoutParams. если родителя нет — View.mLayoutParams = null
• createView(...)
может создать нужную View без xml-файла
замечание:
findViewById(...) — не парсит xml, а осуществляет поиск по уже созданным View
LayoutInflator — класс, умеющий создавать из xml-разметки объект View
По умолчанию он использует стандартный парсер XmlPullParser, но можно передать свой (зачем - придумать сложно)
Способности:
• inflate(...) — парсит xml-файл в View-объект
если передать ссылку на родителя-ViewGroup, то LayoutInflator автоматически прикрепит View к родителю и вернет ссылку не на View, а на этого родителя
при передачи родителя View получает родительский LayoutParams. если родителя нет — View.mLayoutParams = null
• createView(...)
может создать нужную View без xml-файла
замечание:
findViewById(...) — не парсит xml, а осуществляет поиск по уже созданным View
👍27🔥1🎉1
Kotlin Operator
Еще одна возможность котлина, которой можно придумать применение в своем проекте
чтобы коллеги потом ходили и спрашивали, чего ты там такой умный понапридумывал
Как работает:
1. помечаем функцию словом operator
2. даем ей одно из заложенных в языке названий
например, unaryPlus() связана с оператором "+"
3. пишем реализацию этой функции — все, что душе угодно
Например (напишем через extensions-функцию — гулять так гулять):
operator fun User.unaryPlus() : User {
this. name = this. name + " vot eto ya molodec"
return this
}
используем:
user. name = "Ivan"
print( (+user).name) )
получаем:
Ivan vot eto ya molodec
также доступны функции:
• unaryMinus() == "-"
• not() = "!"
• plus() == a "+" b
• rangeTo() == a".."b
и другие
....
где в реальном проекте этому можно найти хорошее применение?
Еще одна возможность котлина, которой можно придумать применение в своем проекте
чтобы коллеги потом ходили и спрашивали, чего ты там такой умный понапридумывал
Как работает:
1. помечаем функцию словом operator
2. даем ей одно из заложенных в языке названий
например, unaryPlus() связана с оператором "+"
3. пишем реализацию этой функции — все, что душе угодно
Например (напишем через extensions-функцию — гулять так гулять):
operator fun User.unaryPlus() : User {
this. name = this. name + " vot eto ya molodec"
return this
}
используем:
user. name = "Ivan"
print( (+user).name) )
получаем:
Ivan vot eto ya molodec
также доступны функции:
• unaryMinus() == "-"
• not() = "!"
• plus() == a "+" b
• rangeTo() == a".."b
и другие
....
где в реальном проекте этому можно найти хорошее применение?
👍9🔥7😁3🎉1
URL vs URI
При работе с файлами или интернетом можно заметить, что иногда нужно указывать URL, а иногда — URI. а почему?
URI — это более общее понятие, включающее в себя URL и URN
UR* - Uniform Resource
• I - Identifier
• L - Location
• N - Name
значит:
• URI может являться и путем до файла, и именем файла
• URL — указывает расположение файла (причем обычно в сети Интернет, кхе)
• URN — имя файла (без пути до него)
URL/URI имеют схожую структуру:
scheme://authoritypath?query# fragment
• scheme — протокол (например, http: или mailto: или tel:)
• authority — логин или хост
• path — путь до файла, разделенный косыми чертами
• query — слова-фильтры
• fragment — произвольный параметр
URI могут быть абсолютные или относительные (вести куда-то, относительно папки, в которой будут открыты)
....
чем отличаются две scheme, встречающиеся при работе с файлами в андроиде — content и file?
При работе с файлами или интернетом можно заметить, что иногда нужно указывать URL, а иногда — URI. а почему?
URI — это более общее понятие, включающее в себя URL и URN
UR* - Uniform Resource
• I - Identifier
• L - Location
• N - Name
значит:
• URI может являться и путем до файла, и именем файла
• URL — указывает расположение файла (причем обычно в сети Интернет, кхе)
• URN — имя файла (без пути до него)
URL/URI имеют схожую структуру:
scheme://authoritypath?query# fragment
• scheme — протокол (например, http: или mailto: или tel:)
• authority — логин или хост
• path — путь до файла, разделенный косыми чертами
• query — слова-фильтры
• fragment — произвольный параметр
URI могут быть абсолютные или относительные (вести куда-то, относительно папки, в которой будут открыты)
....
чем отличаются две scheme, встречающиеся при работе с файлами в андроиде — content и file?
❤10🔥10👍9
MVI — архитектура за 100 слов
Этот подход набирает все большую популярность, вытесняя MVVM
Яркий признак MVI — наличие State
State — неизменяемое состояние (объект, который называет состояние и передает все связанные с ним данные)
Например, ErrorState(val errorMessage: String)
Слои:
• Model — источник State для View
• View — принимает State и отображает
• Intent — события
Не буду вдаваться в философию, приведу пример:
когда пользователь открывает экран, Model создает State.Defult
загружает в этот State всю информацию, которую нужно отобразить, и отдает View
через некоторые время пользователь нажимает кнопку
View сообщает об новом Intent в Model
Model формирует новый State и отдает View
В целом — это все. Но мир не идеален, поэтому:
Intent можно разделить на 2 группы:
• Действия юзера
• События от бекенда (например, пришло новое сообщение)
State можно поделить на 2 группы:
• Стабильные — должны отображаться даже после переворота экрана
• Эффекты — должны отобразиться один раз (например, Toast\Snackbar)
Плюсы MVI:
• наглядность — прямой "поток" данных и событий, без макарон
• модульность — тестируемость
• заранее прописываешь стейты, так что не забываешь учесть разные состояния (обычно State.Default, State.Failed, State.Loading, State.Success)
Минусы MVI:
• часто приходится делать тяжелый моральный выбор - если на экране появляются новые вьюшка, то нужно создавать новый State или модифицировать старый?
Этот подход набирает все большую популярность, вытесняя MVVM
Яркий признак MVI — наличие State
State — неизменяемое состояние (объект, который называет состояние и передает все связанные с ним данные)
Например, ErrorState(val errorMessage: String)
Слои:
• Model — источник State для View
• View — принимает State и отображает
• Intent — события
Не буду вдаваться в философию, приведу пример:
когда пользователь открывает экран, Model создает State.Defult
загружает в этот State всю информацию, которую нужно отобразить, и отдает View
через некоторые время пользователь нажимает кнопку
View сообщает об новом Intent в Model
Model формирует новый State и отдает View
В целом — это все. Но мир не идеален, поэтому:
Intent можно разделить на 2 группы:
• Действия юзера
• События от бекенда (например, пришло новое сообщение)
State можно поделить на 2 группы:
• Стабильные — должны отображаться даже после переворота экрана
• Эффекты — должны отобразиться один раз (например, Toast\Snackbar)
Плюсы MVI:
• наглядность — прямой "поток" данных и событий, без макарон
• модульность — тестируемость
• заранее прописываешь стейты, так что не забываешь учесть разные состояния (обычно State.Default, State.Failed, State.Loading, State.Success)
Минусы MVI:
• часто приходится делать тяжелый моральный выбор - если на экране появляются новые вьюшка, то нужно создавать новый State или модифицировать старый?
👍28🔥8❤2
Прошло несколько месяцев — мы праздновали первую сотню человек в нашей тусовочке — теперь масштабы серьезнее
Теперь мы поднимаем кружки кофе и пива за первую 1000 человек — торжественное ура
.....
Желаю всем классного ворк-лайф баланса, чтобы разработка приносила интересные события в жизнь и новые технологии перестали появляться каждую неделю)
А мне желаю наконец-то найти классных рекламодателей — это будет следующий большой рывок в развитии этой тусовки
🍻🤝🍻
.....
в ближайших планах:
• продолжаю равно то же самое, пытаюсь удержаться от ввода новых рубрик. еще не время)
• появится форма обратной связи, через которую можно будет влиять на темы следующих постов
Теперь мы поднимаем кружки кофе и пива за первую 1000 человек — торжественное ура
.....
Желаю всем классного ворк-лайф баланса, чтобы разработка приносила интересные события в жизнь и новые технологии перестали появляться каждую неделю)
А мне желаю наконец-то найти классных рекламодателей — это будет следующий большой рывок в развитии этой тусовки
🍻🤝🍻
.....
в ближайших планах:
• продолжаю равно то же самое, пытаюсь удержаться от ввода новых рубрик. еще не время)
• появится форма обратной связи, через которую можно будет влиять на темы следующих постов
👍15🎉7🔥5❤1🤩1🤮1
Порядок инициализации полей, конструкторов и блоков
Рассмотрим самый сложный пример — представим, что у нас есть наследование, статика, конструкторы и поля (+ companion object, + init)
Порядок инициализации в Java и Kotlin немного различны, хотя и схожи по общей идее
Java:
Статические и нестатические поля и блоки инициализируются в порядке объявления (чем выше строчка, тем первее она будет инициализирована)
1. Статические поля и блоки родителя
2. Статические поля и блоки ребенка
3. Нестатические поля и блоки родителя
4. Конструктор родителя
5. Нестатические поля и блоки ребенка
6. Конструктор ребенка
Kotlin
В котлине нет статики, нельзя написать код блока вне функции, но появляются companion object и init-функция
1. Поля внутри companion object родителя
2. Поля внутри companion object ребенка
3. Поля и блок init родителя (в порядке объявления)
4. Конструктор родителя
5. Поля и блок init ребенка (в порядке объявления)
6. Конструктор ребенка
....
когда приходится использовать статические или нестатические блоки кода в боевых проектах?
в какой момент инициализируется статическое поле, если к нему обратиться, не создавая объект класса? как это повлияет на порядок инициализации в момент создания объекта класса?
Рассмотрим самый сложный пример — представим, что у нас есть наследование, статика, конструкторы и поля (+ companion object, + init)
Порядок инициализации в Java и Kotlin немного различны, хотя и схожи по общей идее
Java:
Статические и нестатические поля и блоки инициализируются в порядке объявления (чем выше строчка, тем первее она будет инициализирована)
1. Статические поля и блоки родителя
2. Статические поля и блоки ребенка
3. Нестатические поля и блоки родителя
4. Конструктор родителя
5. Нестатические поля и блоки ребенка
6. Конструктор ребенка
Kotlin
В котлине нет статики, нельзя написать код блока вне функции, но появляются companion object и init-функция
1. Поля внутри companion object родителя
2. Поля внутри companion object ребенка
3. Поля и блок init родителя (в порядке объявления)
4. Конструктор родителя
5. Поля и блок init ребенка (в порядке объявления)
6. Конструктор ребенка
....
когда приходится использовать статические или нестатические блоки кода в боевых проектах?
в какой момент инициализируется статическое поле, если к нему обратиться, не создавая объект класса? как это повлияет на порядок инициализации в момент создания объекта класса?
👍29❤3🤩1
Task или виды Activity launch mode
У каждого приложения может быть несколько стеков из Activity. Под каждый стек создается новая Task
Activity launch mode — описывает сценарий изменения стека при запуске новой Activity
Например, можно открывать каждую Activity в отдельном стеке. Тогда в списке запущенных приложений (Recent screen) вы увидите, что ваше приложение открыто в нескольких окнах, каждое из которых можно закрыть отдельно
Выбрать, что произойдет после запуска новой Activity, можно с помощью тега launchMode у <activity> в Manifest. И при помощи установка флага в Intent. А еще при помощи параметра taskAffinity, allowTaskReparenting, clearTaskOnLaunch, alwaysRetainTaskState, finishOnTaskLaunch....
Все гениальное просто, да?
Но разберем основные варианты, которые покрывают 99,99% случаев:
• standart
создастся новая Activity в текущей таске. даже если ее экземпляры есть в текущей таске или в другой
• singleTop
если в текущей таске Activity находится на вершине, то новый ее инстанс создан не будет. текущий инстанс получит Intent через onNewIntent()
в противном случае создастся новый экземпляр в текущей таске
• singleTask
если уже есть таска, в которой вызываемая Activity находится в корне стека, то откроется экземпляр этой Activity, ей будет передан Intent через onNewIntent(), а все остальные Activity в этой таске будут задестроены
в противном случае создается новая таска с новым Activity
• singleInstance
аналогично singleTask. отличие - в таске, в которой находится Activity с таким флагом, может находиться только эта Activity. Под остальные Activity, запущенные из этой Activity, будут создаваться новые таски
• singleInstancePerTask
аналогично singleTask. отличие - если добавить флаг FLAG ACTIVITY MULTIPLE TASK, запуск новой Activity может вызвать создание новой таски, даже если уже есть таска с инстансом этой Activity в корне стека
• standart + FLAG ACTIVITY CLEAR TOP
если в текущей таске есть запускаемая Activity, то она получит Intent через onNewIntent(), а все Activity, запущенные после нее, будут задестроены
в противном случае будет создан новый экземпляр Activity в текущей таске
....
1. есть Activity, есть Task. а зачем тогда вводится и за что отвечает абстракция Window?
2. все вышеописанное справедливо для приложения с множеством Activity. а возможно ли добиться создания нескольких Task в single activity приложении на Fragments/Compose?
У каждого приложения может быть несколько стеков из Activity. Под каждый стек создается новая Task
Activity launch mode — описывает сценарий изменения стека при запуске новой Activity
Например, можно открывать каждую Activity в отдельном стеке. Тогда в списке запущенных приложений (Recent screen) вы увидите, что ваше приложение открыто в нескольких окнах, каждое из которых можно закрыть отдельно
Выбрать, что произойдет после запуска новой Activity, можно с помощью тега launchMode у <activity> в Manifest. И при помощи установка флага в Intent. А еще при помощи параметра taskAffinity, allowTaskReparenting, clearTaskOnLaunch, alwaysRetainTaskState, finishOnTaskLaunch....
Все гениальное просто, да?
Но разберем основные варианты, которые покрывают 99,99% случаев:
• standart
создастся новая Activity в текущей таске. даже если ее экземпляры есть в текущей таске или в другой
• singleTop
если в текущей таске Activity находится на вершине, то новый ее инстанс создан не будет. текущий инстанс получит Intent через onNewIntent()
в противном случае создастся новый экземпляр в текущей таске
• singleTask
если уже есть таска, в которой вызываемая Activity находится в корне стека, то откроется экземпляр этой Activity, ей будет передан Intent через onNewIntent(), а все остальные Activity в этой таске будут задестроены
в противном случае создается новая таска с новым Activity
• singleInstance
аналогично singleTask. отличие - в таске, в которой находится Activity с таким флагом, может находиться только эта Activity. Под остальные Activity, запущенные из этой Activity, будут создаваться новые таски
• singleInstancePerTask
аналогично singleTask. отличие - если добавить флаг FLAG ACTIVITY MULTIPLE TASK, запуск новой Activity может вызвать создание новой таски, даже если уже есть таска с инстансом этой Activity в корне стека
• standart + FLAG ACTIVITY CLEAR TOP
если в текущей таске есть запускаемая Activity, то она получит Intent через onNewIntent(), а все Activity, запущенные после нее, будут задестроены
в противном случае будет создан новый экземпляр Activity в текущей таске
....
1. есть Activity, есть Task. а зачем тогда вводится и за что отвечает абстракция Window?
2. все вышеописанное справедливо для приложения с множеством Activity. а возможно ли добиться создания нескольких Task в single activity приложении на Fragments/Compose?
👍14❤2😁1🤩1
Как придумать свои единицы измерения на примере Compose
Вероятно, это начало серии постов о Compose. Начнем с малого
В Compose можно использовать привычные единицы измерения:
• 18.dp
• 18.sp
• 18.em
Но почему не приходится писать Dp(18) или Dp.Builder().get(18)?
Фокус в extensions-функциях, созданных для всех численных примитивов (Int, Float, Double)
Например:
inline val Int.dp: Dp get() = Dp(value = this.toFloat())
(если вы практикуете только Java, вам может стать плохо, это нормально)
Разберем каждую часть строчки:
• inline — слово, заставляющее компилятор на этапе компиляции упростить код за счет замены лямбда-выражения на последовательный код
если слово inline стоит перед переменной (как в текущем примере), то оно относится к функциям get() и set() переменной
• .dp: Dp — создание переменное типа Dp в классе Int (расширили класс Int новой переменной)
• get() — переопределили зарезервированную функцию get() для переменной dp
• this — ссылка на класс Int, то есть в случае 18.dp ссылка на класс Int(18)
• value — название параметра в конструкторе класса Dp (просто для наглядности)
вот так, использовав всего 5 трюков котлина в одной строчке, можно придумать собственную единицу измерения
....
в боевом проекте такую фишку можно применить для управления, например, балансом юзера — 18.rub + 20.dol = 30.eur. еще идеи?
Вероятно, это начало серии постов о Compose. Начнем с малого
В Compose можно использовать привычные единицы измерения:
• 18.dp
• 18.sp
• 18.em
Но почему не приходится писать Dp(18) или Dp.Builder().get(18)?
Фокус в extensions-функциях, созданных для всех численных примитивов (Int, Float, Double)
Например:
inline val Int.dp: Dp get() = Dp(value = this.toFloat())
(если вы практикуете только Java, вам может стать плохо, это нормально)
Разберем каждую часть строчки:
• inline — слово, заставляющее компилятор на этапе компиляции упростить код за счет замены лямбда-выражения на последовательный код
если слово inline стоит перед переменной (как в текущем примере), то оно относится к функциям get() и set() переменной
• .dp: Dp — создание переменное типа Dp в классе Int (расширили класс Int новой переменной)
• get() — переопределили зарезервированную функцию get() для переменной dp
• this — ссылка на класс Int, то есть в случае 18.dp ссылка на класс Int(18)
• value — название параметра в конструкторе класса Dp (просто для наглядности)
вот так, использовав всего 5 трюков котлина в одной строчке, можно придумать собственную единицу измерения
....
в боевом проекте такую фишку можно применить для управления, например, балансом юзера — 18.rub + 20.dol = 30.eur. еще идеи?
🔥14❤5👍3😁2
Thread Safe — когда пора начать об этом думать
Понятие Thread Safe имеет множество определений, но суть у них одна:
если переменную изменяют несколько потоков, то результат изменения должен быть предсказуемый
(если b = 1, и мы дважды вызываем b++, то ожидаем увидеть b = 3 — это и есть предсказуемость)
Что может пойти не так?
Во-первых, по умолчанию потоки работают с переменной не в общей памяти, а создают свою локальную копию. А после всех манипуляций с объектом внутри потока возвращают объект в общую память
То есть другие потоки смогут увидеть изменения в объекте только после завершения этапа работы первого потока
Во-вторых, один поток может взять переменную и начать изменять ее. Например, была строка var s = "Big", а на выходе должна получится строка s = s + "Hot"
Но пока первый поток не закончил изменение s, второй поток тоже считал s. И решил сделать из нее s = s + "Dog"
Получается, что мы хотели прибавить к "Big" строки "Hot" и "Dog", а получим на выходе не "BigHotDog", а "BigHot" или "BigDog" или "BigDogHot" — результат непредсказуем
Ответ на заголовок поста:
пора начинать разбираться в многопоточности, как только в проекте появляется два и более потоков, работающих с общими переменными
....
с чего стоит начать, если хочешь понять эту вашу многопоточность вдоль и поперек?
Понятие Thread Safe имеет множество определений, но суть у них одна:
если переменную изменяют несколько потоков, то результат изменения должен быть предсказуемый
(если b = 1, и мы дважды вызываем b++, то ожидаем увидеть b = 3 — это и есть предсказуемость)
Что может пойти не так?
Во-первых, по умолчанию потоки работают с переменной не в общей памяти, а создают свою локальную копию. А после всех манипуляций с объектом внутри потока возвращают объект в общую память
То есть другие потоки смогут увидеть изменения в объекте только после завершения этапа работы первого потока
Во-вторых, один поток может взять переменную и начать изменять ее. Например, была строка var s = "Big", а на выходе должна получится строка s = s + "Hot"
Но пока первый поток не закончил изменение s, второй поток тоже считал s. И решил сделать из нее s = s + "Dog"
Получается, что мы хотели прибавить к "Big" строки "Hot" и "Dog", а получим на выходе не "BigHotDog", а "BigHot" или "BigDog" или "BigDogHot" — результат непредсказуем
Ответ на заголовок поста:
пора начинать разбираться в многопоточности, как только в проекте появляется два и более потоков, работающих с общими переменными
....
с чего стоит начать, если хочешь понять эту вашу многопоточность вдоль и поперек?
👍24❤4
Как перехватывать Exception в Coroutine. Часть 1
Поймать Exception в Java и Kotlin просто — ошибка, выброшенная через throw, поднимается вверх по стеку функций, пока не попадет в try-catch блок или в Thread.UncaughtExceptionHandler
Но как только появляются корутины...
Это одна из самых сложных тем по двум причинам:
• зависимость пути проброса Exception от множества условий
• минимальное количество примеров (в документации и интернете в целом), покрывающих неочевидные кейсы
На логику обработки ошибок в корутинах влияет:
• создаем корутину верхнего уровня или вложенную
• используем ли мы Job или SupervisorJob
• используем ли мы билдер launch или async
• устанавливаем ли мы CoroutineExceptionHandler (и куда устанавливаем)
• еще и особая CancellationException обрабатывается по другим правилам
причем здесь не бинарная логика, а комбинация этих условий определяет, как и где мы сможем перехватить Exception
....
в следующих частях разберемся, зачем столько сложностей, и создадим единую логику отслеживания ошибок
Поймать Exception в Java и Kotlin просто — ошибка, выброшенная через throw, поднимается вверх по стеку функций, пока не попадет в try-catch блок или в Thread.UncaughtExceptionHandler
Но как только появляются корутины...
Это одна из самых сложных тем по двум причинам:
• зависимость пути проброса Exception от множества условий
• минимальное количество примеров (в документации и интернете в целом), покрывающих неочевидные кейсы
На логику обработки ошибок в корутинах влияет:
• создаем корутину верхнего уровня или вложенную
• используем ли мы Job или SupervisorJob
• используем ли мы билдер launch или async
• устанавливаем ли мы CoroutineExceptionHandler (и куда устанавливаем)
• еще и особая CancellationException обрабатывается по другим правилам
причем здесь не бинарная логика, а комбинация этих условий определяет, как и где мы сможем перехватить Exception
....
в следующих частях разберемся, зачем столько сложностей, и создадим единую логику отслеживания ошибок
👍22🔥2❤1😱1🎉1
Как перехватывать Exception в Coroutine. Часть 2
Разберемся с тем, как формируется контекст корутины
CoroutineContext — параметры корутины или CoroutineScope:
• Job или SupervisorJob
• Dispatcher (IO, Main, Default...)
• CoroutineExceptionHandler (или его отсутствие)
• CoroutineName (параметр для дебага)
Алгоритм формирования для CoroutineScope:
• стандарные параметры + унаследованные + указанные в билдере
Алгоритм формирования для корутины:
• стандарные параметры + унаследованные + указанные в билдере + Job
при этом слагаемые справа переопредяют предыдущие
Пример:
CoroutineScope() {
launch(Dispatcher.Main + SupervisorJob()) {
launch(Dispatcher.Default) {}
}
}
Контекст для CoroutineScope:
• Dispatcher.Default, CoroutineName("coroutine"), Job1
для внешнего launch:
• Dispatcher.Main, CoroutineName("coroutine"), Job2
для внутреннего launch:
• Dispatcher.Default, CoroutineName("coroutine"), Job3
На что обратить внимание:
• у внешнего launch в контексте исчезает SuperviserJob, потому что при формирование прибавляется Job справа
то есть установит SuperviserJob можно только в CoroutineScope
• между всеми Job установлены parent-child отношения
если вызвать Job1.cancel(), то Job1 и Job2 тоже будут остановлены
а если в launch явно передать Job(), то взаимоотношения разрушаться. родитель не будет ожидать завершения работы такого ребенка, и останавливать его не будет
Разберемся с тем, как формируется контекст корутины
CoroutineContext — параметры корутины или CoroutineScope:
• Job или SupervisorJob
• Dispatcher (IO, Main, Default...)
• CoroutineExceptionHandler (или его отсутствие)
• CoroutineName (параметр для дебага)
Алгоритм формирования для CoroutineScope:
• стандарные параметры + унаследованные + указанные в билдере
Алгоритм формирования для корутины:
• стандарные параметры + унаследованные + указанные в билдере + Job
при этом слагаемые справа переопредяют предыдущие
Пример:
CoroutineScope() {
launch(Dispatcher.Main + SupervisorJob()) {
launch(Dispatcher.Default) {}
}
}
Контекст для CoroutineScope:
• Dispatcher.Default, CoroutineName("coroutine"), Job1
для внешнего launch:
• Dispatcher.Main, CoroutineName("coroutine"), Job2
для внутреннего launch:
• Dispatcher.Default, CoroutineName("coroutine"), Job3
На что обратить внимание:
• у внешнего launch в контексте исчезает SuperviserJob, потому что при формирование прибавляется Job справа
то есть установит SuperviserJob можно только в CoroutineScope
• между всеми Job установлены parent-child отношения
если вызвать Job1.cancel(), то Job1 и Job2 тоже будут остановлены
а если в launch явно передать Job(), то взаимоотношения разрушаться. родитель не будет ожидать завершения работы такого ребенка, и останавливать его не будет
👍7❤1
А теперь к новостям канала
1. Появился бот приема обратной связи для выбора темы следующих постов — t.me/dolgopolobot
Если есть идея, о чем было бы интересно почитать в следующем посте, то можно оставить сообщение этому боту с тегом #idea_dpd
Принимаются темы любой сложности и актуальности — от "вот бы был пост про синхронизацию потоков и поддержку OS Android 2.1 в 2022 году" до "а какой жизненный цикл у Fragment?!"
2. Выходит в свет мой второй канал — там картинки со смешными местами из путешествий и рассказы об интересных штуках, о которых невозможно молчать
Отдыхать от программирования сюда — t.me/dolgo_polo_ahaha
3. Оказалась, есть часть людей, которая пользуется Яндекс.Дзеном
Игнорировать это больше нельзя, поэтому постепенно там (тут — zen.yandex.ru/android_dpd) будут появляться старые посты
Хотя есть подозрение, что эти посты многие из вас не видели. Кто вообще листает канал выше пяти последних сообщений?
1. Появился бот приема обратной связи для выбора темы следующих постов — t.me/dolgopolobot
Если есть идея, о чем было бы интересно почитать в следующем посте, то можно оставить сообщение этому боту с тегом #idea_dpd
Принимаются темы любой сложности и актуальности — от "вот бы был пост про синхронизацию потоков и поддержку OS Android 2.1 в 2022 году" до "а какой жизненный цикл у Fragment?!"
2. Выходит в свет мой второй канал — там картинки со смешными местами из путешествий и рассказы об интересных штуках, о которых невозможно молчать
Отдыхать от программирования сюда — t.me/dolgo_polo_ahaha
3. Оказалась, есть часть людей, которая пользуется Яндекс.Дзеном
Игнорировать это больше нельзя, поэтому постепенно там (тут — zen.yandex.ru/android_dpd) будут появляться старые посты
Хотя есть подозрение, что эти посты многие из вас не видели. Кто вообще листает канал выше пяти последних сообщений?
❤6👍4
Как перехватывать Exception в Coroutine. Часть 3
Факты, которые нам понадобятся:
• корутина верхнего уровня — корутина, чьим родителем является Scope, а не другая корутина
scope.launch { — верхнего уровня
launch { } — не верхнего
}
• CoroutineExceptionHandler — получатель необработанных ошибок
Его можнно назначить в конструкторе Scope или в билдере launch() верхнего уровня, если его родитель Scope(SupervisorJob)
• два понятия, которые нельзя путать:
throws (выбрасывать)
если ошибка выброшена, то она поднимается по стеку вызова функций, пока не попадется в try-catch или Thread.UncaughtExceptionHandler
propagate (распространять)
если ошибка распространяется, то она игнорирует блоки try-catch и передается от дочерний корутины к родителю
если ошибка добралась до коренного Scope, а у него не установлен CoroutineExceptionHandler, то ошибка передается в Thread.UncaughtExceptionHandler
• при создании Scope можно назначить два вида Coroutine Context:
Job — если внутри Scope появляется ошибка, не перехваченная try-catch, то Scope завершает работу всех дочерних корутин, завершается сам и распространяет ошибку
SupervisorJob — если внутри Scope появляется ошибка, не перехваченная try-catch, то Scope распространяет ошибку, не завершая дочерние корутины и себя
• CancellationException — особый вид ошибки. Выбрасывается, когда корутина была остановлена с помощью cancel()
Она, в отличие от остальных ошибок, не останавливает родительский Scope и не влияет на работу остальных корутин в Scope
....
если какой-то из пунктов вызывает вопросы — готов расписать подробнее в комментариях)
и нам наконец хватает знаний, чтобы перейти к практике в следующем посте
Факты, которые нам понадобятся:
• корутина верхнего уровня — корутина, чьим родителем является Scope, а не другая корутина
scope.launch { — верхнего уровня
launch { } — не верхнего
}
• CoroutineExceptionHandler — получатель необработанных ошибок
Его можнно назначить в конструкторе Scope или в билдере launch() верхнего уровня, если его родитель Scope(SupervisorJob)
• два понятия, которые нельзя путать:
throws (выбрасывать)
если ошибка выброшена, то она поднимается по стеку вызова функций, пока не попадется в try-catch или Thread.UncaughtExceptionHandler
propagate (распространять)
если ошибка распространяется, то она игнорирует блоки try-catch и передается от дочерний корутины к родителю
если ошибка добралась до коренного Scope, а у него не установлен CoroutineExceptionHandler, то ошибка передается в Thread.UncaughtExceptionHandler
• при создании Scope можно назначить два вида Coroutine Context:
Job — если внутри Scope появляется ошибка, не перехваченная try-catch, то Scope завершает работу всех дочерних корутин, завершается сам и распространяет ошибку
SupervisorJob — если внутри Scope появляется ошибка, не перехваченная try-catch, то Scope распространяет ошибку, не завершая дочерние корутины и себя
• CancellationException — особый вид ошибки. Выбрасывается, когда корутина была остановлена с помощью cancel()
Она, в отличие от остальных ошибок, не останавливает родительский Scope и не влияет на работу остальных корутин в Scope
....
если какой-то из пунктов вызывает вопросы — готов расписать подробнее в комментариях)
и нам наконец хватает знаний, чтобы перейти к практике в следующем посте
❤5👍5🎉1
Как перехватывать Exception в Coroutine. Часть 4
Разберемся с билдером launch
Рабочие способы перехватить ошибки:
• внутри launch
launch {
try {
throw Exception()
} catch(e: Exception) {}
}
• с помощью перехватчика необработанных ошибок, установленного в Scope или launch верхнего уровня
scope.launch(handler) {
throw Exception()
}
СoroutineScope(handler) {
launch {
throw Exception()
}
}
в этом способе важно понимать — если родитель Scope(Job), дети этого Scope будут остановлены
если родитель Scope(SupervisorJob), дети этого Scope продолжат работу
если родитель корутина, то дети этой корутины будут остановлены в любом случае
Что будет, если не обработать:
ошибка будет распространена родительским корутинам и Scope, пока не попадет в CoroutineExceptionHandler или Thread.UncaughtExceptionHandler
Что не получится сделать:
• обернуть launch в try-catch
такое не сработает:
try {
launch { throw Exception() }
} catch(e: Exception) {}
потому что launch распространяет ошибку родителю, а не выбрасывает
....
если вы встретили примеры, в которых не очевидно поведение корутин при возникновении ошибки — скидывайте их в комментарии, разберем)
Разберемся с билдером launch
Рабочие способы перехватить ошибки:
• внутри launch
launch {
try {
throw Exception()
} catch(e: Exception) {}
}
• с помощью перехватчика необработанных ошибок, установленного в Scope или launch верхнего уровня
scope.launch(handler) {
throw Exception()
}
СoroutineScope(handler) {
launch {
throw Exception()
}
}
в этом способе важно понимать — если родитель Scope(Job), дети этого Scope будут остановлены
если родитель Scope(SupervisorJob), дети этого Scope продолжат работу
если родитель корутина, то дети этой корутины будут остановлены в любом случае
Что будет, если не обработать:
ошибка будет распространена родительским корутинам и Scope, пока не попадет в CoroutineExceptionHandler или Thread.UncaughtExceptionHandler
Что не получится сделать:
• обернуть launch в try-catch
такое не сработает:
try {
launch { throw Exception() }
} catch(e: Exception) {}
потому что launch распространяет ошибку родителю, а не выбрасывает
....
если вы встретили примеры, в которых не очевидно поведение корутин при возникновении ошибки — скидывайте их в комментарии, разберем)
❤7👍4🎉1
Как перехватывать Exception в Coroutine. Часть 5 (финал)
Разберемся с билдером async
Рабочие способы обработки ошибки:
• внутри async
async {
try {
throw Exception()
} catch(e: Exception) {}
}
• с помощью try-catch, если async верхнего уровня
Ошибка выбрасывается во время вызова await()
runBlocking {
val job = scope.async {
throwError()
}
tryCatch {
job.await()
}
}
Что будет, если не обработать:
ошибка будет распространяться родительским корутинам и Scope, пока не попадет в CoroutineExceptionHandler или Thread.UncaughtExceptionHandler
....
в комментариях оставлю ссылку на Kotlin Playground с тестами, на которых можно проверить свое чутье — где и кем будет перехвачена ошибка
Разберемся с билдером async
Рабочие способы обработки ошибки:
• внутри async
async {
try {
throw Exception()
} catch(e: Exception) {}
}
• с помощью try-catch, если async верхнего уровня
Ошибка выбрасывается во время вызова await()
runBlocking {
val job = scope.async {
throwError()
}
tryCatch {
job.await()
}
}
Что будет, если не обработать:
ошибка будет распространяться родительским корутинам и Scope, пока не попадет в CoroutineExceptionHandler или Thread.UncaughtExceptionHandler
....
в комментариях оставлю ссылку на Kotlin Playground с тестами, на которых можно проверить свое чутье — где и кем будет перехвачена ошибка
🔥5❤1
GET vs POST vs PUT в Retrofit 2 — уровень "Мобильный разработчик"
Вероятно, если вы человек-бекендер, то нужно углубляться сильнее. А для нас достаточно следующих фактов:
Указывая аннотации @GET, @POST или @PUT, вы определяете, в каком виде будут переданы параметры запроса на сервер
• GET
Параметры запроса вставляются в URL запроса с помощью аннотации Query(parameterName) в формате key=value
Параметры добавляются после знака "?"
Длина URL ограничена 2048 символами
Например, на выходе можете получить такой запрос: https://www.google.com/search?q=tg+dolgo.polo+dev
key = q
value = tg+dolgo.polo+dev
• POST
Параметры запроса вставляются не в URL, а в поле requestBody с помощью аннотации @Body в формате key=value
POST-запросом можно отправить файл, так как он поддерживает отправку бинарных данных
Например, на выходе можете получить такой запрос: https://www.google.com/search
Параметры при этом передаются отдельно в поле requestBody
• PUT
То же самое, что и POST. Нужен для логического разделения ролей запросов в API (просто другое название)
POST — обновляет данные по какому-то правилу
например, увеличивает количество яблок в коробке: apples++
PUT — устанавливает данные
например, устанавливает количество яблок в коробке: apples = 1
Поэтому говорят, PUT — идемпотентный — можно отправлять один и тот же запрос много раз, данные изменятся только один раз
Еще есть мнение, что POST для списков, а PUT для обращения по конкретному id
....
а менее известные DELETE, PATCH, OPTIONS, CONNECT и TRACE зачем и когда используются?
Вероятно, если вы человек-бекендер, то нужно углубляться сильнее. А для нас достаточно следующих фактов:
Указывая аннотации @GET, @POST или @PUT, вы определяете, в каком виде будут переданы параметры запроса на сервер
• GET
Параметры запроса вставляются в URL запроса с помощью аннотации Query(parameterName) в формате key=value
Параметры добавляются после знака "?"
Длина URL ограничена 2048 символами
Например, на выходе можете получить такой запрос: https://www.google.com/search?q=tg+dolgo.polo+dev
key = q
value = tg+dolgo.polo+dev
• POST
Параметры запроса вставляются не в URL, а в поле requestBody с помощью аннотации @Body в формате key=value
POST-запросом можно отправить файл, так как он поддерживает отправку бинарных данных
Например, на выходе можете получить такой запрос: https://www.google.com/search
Параметры при этом передаются отдельно в поле requestBody
• PUT
То же самое, что и POST. Нужен для логического разделения ролей запросов в API (просто другое название)
POST — обновляет данные по какому-то правилу
например, увеличивает количество яблок в коробке: apples++
PUT — устанавливает данные
например, устанавливает количество яблок в коробке: apples = 1
Поэтому говорят, PUT — идемпотентный — можно отправлять один и тот же запрос много раз, данные изменятся только один раз
Еще есть мнение, что POST для списков, а PUT для обращения по конкретному id
....
а менее известные DELETE, PATCH, OPTIONS, CONNECT и TRACE зачем и когда используются?
👍20❤4🐳2👎1🙏1
Чек-лист профессионального программиста
Это свод правил, выработанный за годы фриланса
Когда соблюдаю их, приложение получается удобным для юзера
Да, этот список не сделает из вас сеньора, как и не делает им меня. Но это как с мужем-на-час, который не только починил кран, но и убрал за собой рабочее место — сразу видно, крутой
• сортировать списки
если список выводится пользователю, нужно не забыть понятную сортировку - по дате создания или алфавиту, если нет других заданных параметров
• проверять нули, пустые строки и нуллы, количество элементов в списке
продумайте, что увидит юзер. например, если список пуст, не забыть вывести "оëой, данных пока нет"
и не выводить "ваша скидка 0% !!!"
• поднимать ошибку по стеку в обертке
если вы запрашиваете Long из базы данных, то возвращать -1 в случае ошибки не лучшая идея. Лучше вернуть Success(value) или Error(errorCode)
• использовать варианты toDoubleOrNull() вместо toDouble()
никогда не надейтесь, что преобразование строки в число пройдёт успешно
а вдруг кто-то подсунет строку с запятой вместо точки?
приложение крашнется с ошибкой NotNumber, а юзер умрёт от инфаркта
лучше добавить лишнюю проверку на null (или обернуть в try-catch)
• сообщать о загрузке
если контент загружается больше 500мс, отобразите индикатор загрузки или скелет будущего контента
• помнить, что юзер может закрыть приложение в любой момент, но операция должна завершиться
юзер не обязан ждать, пока данные сохранятся - может свернуть приложение, заблокировать телефон, уронить его в лужу - операция должна выполниться в фоне
Это свод правил, выработанный за годы фриланса
Когда соблюдаю их, приложение получается удобным для юзера
Да, этот список не сделает из вас сеньора, как и не делает им меня. Но это как с мужем-на-час, который не только починил кран, но и убрал за собой рабочее место — сразу видно, крутой
• сортировать списки
если список выводится пользователю, нужно не забыть понятную сортировку - по дате создания или алфавиту, если нет других заданных параметров
• проверять нули, пустые строки и нуллы, количество элементов в списке
продумайте, что увидит юзер. например, если список пуст, не забыть вывести "оëой, данных пока нет"
и не выводить "ваша скидка 0% !!!"
• поднимать ошибку по стеку в обертке
если вы запрашиваете Long из базы данных, то возвращать -1 в случае ошибки не лучшая идея. Лучше вернуть Success(value) или Error(errorCode)
• использовать варианты toDoubleOrNull() вместо toDouble()
никогда не надейтесь, что преобразование строки в число пройдёт успешно
а вдруг кто-то подсунет строку с запятой вместо точки?
приложение крашнется с ошибкой NotNumber, а юзер умрёт от инфаркта
лучше добавить лишнюю проверку на null (или обернуть в try-catch)
• сообщать о загрузке
если контент загружается больше 500мс, отобразите индикатор загрузки или скелет будущего контента
• помнить, что юзер может закрыть приложение в любой момент, но операция должна завершиться
юзер не обязан ждать, пока данные сохранятся - может свернуть приложение, заблокировать телефон, уронить его в лужу - операция должна выполниться в фоне
👍27❤7🏆4🤔2