Библиотеки
Java-библиотеки позволяют хранить код для какой-то задачи в одном месте и использовать в разных проекта, просто подключая её.
В Android Studio библиотека подключается через Gradle. Просто добавляем строку в блок с зависимостями и студия сама скачает и установит библиотеку.
Описания популярных библиотек
Библиотека EventBus — рассылаем и реагируем на события.
AndroidSlidingUpPanel — выдвигающая панель сверху или снизу.
Rebound — библиотека для создания анимационных эффектов у компонентов
GSON — библиотека для работы с JSON.
Moshi — современная библиотека для работы с JSON.
Card Library — интересная библиотека для создания карточек. Автор библиотеки рассказывает о ней на своей странице. А на ГитХабе сама библиотека в исходных кодах. Сам ещё не использовал, но демо библиотеки мне понравилось.
svg-android — работа с векторными изображениями SVG
Android-Query (AQuery) — простой способ использования асинхронных задач и управления UI-элементами
Библиотека Okio для операций ввода/вывода.
Библиотеки для загрузки изображений с котиками
Picasso — библиотека для загрузки изображений с разнообразным функционалом.
Glide — похожа по синтаксису и функционалу на Picasso. Библиотека поддерживает анимированные GIF-файлы.
Universal Image Loader — библиотека для загрузки изображений из сети или локальных носителей
koush/ion — и ещё одна популярная библиотека для асинхронных соединений и загрузок изображений.
Fresco | An image management library — Facebook тоже решил внести свою лепту в создании библиотеки для загрузки изображений.
Coil — относительно новая библиотека на Kotlin с корутинами.
Графики и диаграммы
AChartEngine — библиотека для рисования графиков
GraphView — ещё одна библиотека для графиков. Доступны два вида. Встраивается в разметку активности через код.
HoloGraphLibrary — Ещё одна библиотека для рисования графиков.
blackfizz/EazeGraph — и ещё одна библиотека с разными типами графиков.
Пошаговое руководство. Привязка библиотеки Android Kotlin
В настоящее время рассматривается возможность использования настраиваемых привязок на платформе Xamarin. Примите участие в этом опросе, чтобы помочь определить дальнейшие направления разработки.
Xamarin позволяет разработчикам мобильных приложений создавать собственные кросс-платформенные мобильные приложения с использованием Visual Studio и C#. Вы можете использовать готовые компоненты пакета SDK для платформы Android, но во многих случаях вам также может понадобиться использовать сторонние пакеты SDK, написанные для этой платформы, и Xamarin позволяет сделать это с помощью привязок. Чтобы внедрить стороннюю платформу Android в приложение Xamarin.Android, необходимо создать привязку Xamarin.Android, чтобы использовать ее в приложениях.
Платформа Android, а также ее машинные языки и средства постоянно развиваются. К примеру, недавно введен язык Kotlin, который, в конечном итоге, заменяет Java. Существует ряд сторонних пакетов SDK, которые уже перенесены с Java на Kotlin, и это ставит перед нами новые задачи. Несмотря на то, что процесс привязки Kotlin аналогичен этому процессу на Java, он требует дополнительных шагов и параметров конфигурации для успешной сборки и запуска в рамках приложения Xamarin.Android.
Цель этого документа — описать общий подход для подготовки такого сценария и предоставить подробные пошаговые инструкции с простым примером.
Общие сведения
Kotlin выпущен в феврале 2016 г. и представлен в качестве альтернативы стандартному компилятору Java в Android Studio к 2017 г. Позднее в 2019 г. корпорация Google объявила о том, что язык Kotlin стал предпочтительным языком для разработчиков приложений Android. Общий подход на основе привязки аналогичен процессу привязки регулярных библиотек Java, но при использовании в Kotlin необходимы несколько важных специальных шагов.
Необходимые компоненты
Для выполнения задач этого руководства необходимы:
- Android Studio
- Visual Studio для Mac
- Декомпилятор Java
Сборка собственной библиотеки
Первым шагом является сборка собственной библиотеки Kotlin с помощью Android Studio. Библиотека обычно предоставляется сторонним разработчиком или доступна в репозитории Google Maven и других удаленных репозиториях. Например, в этом учебнике создается привязка для библиотеки Kotlin Bubble Picker:
- Скачайте исходный код из GitHub для библиотеки и распакуйте его в локальной папке Bubble-Picker.
- Запустите Android Studio, выберите пункт меню Open an existing Android Studio project (Открыть существующий проект Android Studio) и укажите локальную папку Bubble-Picker:
- Убедитесь, что Android Studio не устарела, в том числе Gradle. Исходный код можно успешно собрать в Android Studio версии 3.5.3, Gradle версии 5.4.1. Инструкции по обновлению Gradle до последней версии можно найти здесь.
- Убедитесь, что установлен обязательный пакет SDK для Android. Исходный код требует пакет SDK для Android версии 25. Чтобы установить компоненты пакета SDK, откройте меню диспетчера SDK для средств>.
- Обновите и синхронизируйте главный файл конфигурации build.gradle, расположенный в корне папки проекта:
- Задайте параметру версии Kotlin значение 1.3.10.
buildscript
allprojects < repositories < jcenter() maven < url "https://maven.google.com" >> >
После обновления файла конфигурации он не синхронизирован, и в Gradle будет отображаться кнопка Sync Now (Синхронизировать сейчас), нажмите ее и дождитесь завершения процесса:
Совет Кэш зависимостей Gradle может быть поврежден, это иногда происходит после истечения времени ожидания сетевого подключения. Повторно скачайте зависимости и синхронизируйте проект (требуется подключение к сети).
Совет У процесса сборки Gradle (управляющей программ) может быть поврежденное состояние. Остановка всех управляющих программ Gradle может решить эту проблему. Остановите процессы сборки Gradle (требуется перезапуск). В случае повреждения процессов Gradle можно также закрыть интегрированную среду разработки, а затем завершить все процессы Java.
Совет Возможно, ваш проект использует сторонний подключаемый модуль, который несовместим с другими подключаемыми модулями в проекте или версии Gradle, запрошенной проектом.
Файл AAR — это архив Android, который содержит скомпилированный исходный код и ресурсы Kotlin, необходимые Android для выполнения приложения с использованием этого пакета SDK.
Подготовка метаданных
Вторым шагом является подготовка файла преобразования метаданных, который используется Xamarin.Android для создания соответствующих классов C#. Проект привязки Xamarin.Android будет обнаруживать все собственные классы и члены из заданного архива Android с последующим созданием файла XML с соответствующими метаданными. Затем созданный вручную файл преобразования метаданных необходимо применить к ранее созданному базовому плану, чтобы создать окончательный файл XML определения, используемый для создания кода C#.
Метаданные используют синтаксис XPath и используются генератором привязок, чтобы повлиять на создание сборки привязки. В статье Метаданные привязок Java приведены дополнительные сведения о преобразованиях, которые могут быть применены:
-
Создайте пустой файл Metadata.xml:
- У собственной библиотеки Kotlin есть две зависимости, которые вам не нужно предоставлять в коде C#, задайте два преобразования, чтобы полностью их игнорировать. Важно отметить, что собственные члены не будут удалены из результирующего двоичного файла, только не будут создаваться классы C#. Декомпилятор Java можно использовать для определения зависимостей. Запустите средство и откройте созданный ранее файл AAR, в результате чего будет показана структура архива Android со всеми зависимостями, значениями, ресурсами, манифестами и классами: Преобразования для пропуска обработки этих пакетов определяются с помощью инструкций XPath:
BackgroundColor BackgroundColor
public open fun fooUIntMethod(value: UInt) : String < return "fooUIntMethod$" >
Этот код компилируется в следующий байтовый код Java:
@NotNull public String fooUIntMethod-WZ4Q5Ns(int value)
Более того, связанные типы, такие как UIntArray, UShortArray, ULongArray, UByteArray , также затрагиваются Kotlin. Имя метода изменяется и включает в себя дополнительный суффикс, а параметры изменяются на массив элементов подписанных версий тех же типов. В примере ниже параметр типа UIntArray преобразуется автоматически в int[] , а имя метода изменяется с fooUIntArrayMethod на fooUIntArrayMethod—ajY-9A . Имя метода обнаруживается средствами Xamarin.Android и формируется как допустимое имя метода:
public open fun fooUIntArrayMethod(value: UIntArray) : String < return "fooUIntArrayMethod$" >
Этот код компилируется в следующий байтовый код Java:
@NotNull public String fooUIntArrayMethod--ajY-9A(@NotNull int[] value)
Чтобы дать ему понятное имя, в Metadata.xml можно добавить следующие метаданные, которые обновят имя на исходное, определенное в коде Kotlin:
fooUIntArrayMethod
public open fun fooGenericMethod(value: T) : String < return "fooGenericMethod$" >
После создания привязки Xamarin.Android метод предоставляется в C# следующим образом:
[Register ("fooGenericMethod", "(Ljava/lang/Object;)Ljava/lang/String;", "GetFooGenericMethod_Ljava_lang_Object_Handler")] [JavaTypeParameters (new string[] < "T" >)] public virtual string FooGenericMethod (Java.Lang.Object value);
Универсальные шаблоны Java и Kotlin не поддерживаются привязками Xamarin.Android, поэтому создается обобщенный метод C# для доступа к универсальному API. В качестве обходного решения можно создать библиотеку-оболочку Kotlin и предоставить необходимые API строго типизированным способом без универсальных шаблонов. Кроме того, можно создавать вспомогательные приложения на C# для решения проблемы таким же образом с помощью строго типизированных API.
Совет При преобразовании метаданных к созданной привязке можно применить любые изменения. В статье Привязка библиотеки Java подробно объясняется, как создаются и обрабатываются метаданные.
Сборка библиотеки привязки
Следующим шагом является создание проекта привязки Xamarin.Android с помощью шаблона привязки Visual Studio, добавления необходимых метаданных, собственных ссылок и последующая сборка проекта для создания готовой к использованию библиотеки:
- Откройте Visual Studio для Mac и создайте проект библиотеки привязки Xamarin.Android, присвойте ему имя, в этом случае — testBubblePicker.Binding, и завершите работу мастера. Шаблон привязки Xamarin.Android расположен по следующему пути: библиотека привязки библиотеки > Android>:В папке Transformations есть три основных файла преобразования:
- Metadata.xml — позволяет вносить изменения в окончательный API, например изменять пространство имен созданной привязки.
- EnumFields.xml — содержит сопоставление константами int Java и перечислениями C#.
- EnumMethods.xml — позволяет изменять параметры метода и типы возвращаемых значений с целочисловых констант Java на перечисления C#.
Сохраните пустые файлы EnumFields.xml и EnumMethods.xml, а затем обновите Metadata.xml для определения преобразований.
Совет Из-за ограничения Xamarin.Android средства привязки можно добавить только в один архив Android (AAR) для каждого проекта привязки. Если необходимо добавить несколько файлов AAR, требуется несколько проектов Xamarin.Android, по одному на каждый AAR. Если бы мы столкнулись с этим в этом пошаговом руководстве, то предыдущие четыре действия этого шага надо было бы повторить для каждого архива. В качестве альтернативного варианта можно вручную объединить несколько архивов Android в один, и в результате можно использовать один проект привязки Xamarin.Android.
Использование библиотеки привязки
Последним шагом является использование библиотеки привязки Xamarin.Android в приложении Xamarin.Android. Создайте проект Xamarin.Android, добавьте ссылку на библиотеку привязки и визуализируйте пользовательский интерфейс Bubble Picker:
- Создайте проект Xamarin.Android. Используйте приложение Android App > Android > в качестве отправной точки и выберите «Последние и величайшие» в качестве параметра «Целевые платформы», чтобы избежать проблем совместимости. Все последующие шаги предназначены для этого проекта:
- Добавьте ссылку на проект в проект привязки или добавьте ссылку на созданную ранее библиотеку DLL:
- Добавьте ссылку на пакет Xamarin.Kotlin.StdLib NuGet, который вы ранее добавили в проект привязки Xamarin.Android. Этот пакет поддерживает любые специальные типы Kotlin, которые должны обрабатываться в среде выполнения. Без этого пакета приложение можно скомпилировать, но произойдет сбой во время выполнения:
- Добавьте элемент управления BubblePicker в макет Android для MainActivity . Откройте файл testBubblePicker/Resources/layout/content_main.xml и добавьте узел элементов управления BubblePicker в виде последнего элемента корневого элемента управления RelativeLayout:
protected override void OnCreate(Bundle savedInstanceState) < . var picker = FindViewById(Resource.Id.picker); picker.BubbleSize = 20; picker.Adapter = new BubblePickerAdapter(); picker.Listener = new BubblePickerListener(picker); . >
BubblePickerAdapter и BubblePickerListener — это два класса, создаваемые с нуля. Они обрабатывают данные пузырьков и управляют взаимодействием:
public class BubblePickerAdapter : Java.Lang.Object, IBubblePickerAdapter < private List_bubbles = new List(); public int TotalCount => _bubbles.Count; public BubblePickerAdapter() < for (int i = 0; i < 10; i++) < _bubbles.Add($"Item "); > > public PickerItem GetItem(int itemIndex) < if (itemIndex < 0 || itemIndex >= _bubbles.Count) return null; var result = _bubbles[itemIndex]; var item = new PickerItem(result); return item; > > public class BubblePickerListener : Java.Lang.Object, IBubblePickerListener < public View Picker < get; >public BubblePickerListener(View picker) < Picker = picker; >public void OnBubbleDeselected(PickerItem item) < Snackbar.Make(Picker, $"Deselected: ", Snackbar.LengthLong) .SetAction("Action", (Android.Views.View.IOnClickListener)null) .Show(); > public void OnBubbleSelected(PickerItem item) < Snackbar.Make(Picker, $"Selected: ", Snackbar.LengthLong) .SetAction("Action", (Android.Views.View.IOnClickListener)null) .Show(); > >
Поздравляем! Вы успешно создали приложение Xamarin.Android и библиотеку привязок, которая использует библиотеку Kotlin.
Теперь у вас должно быть базовое приложение Xamarin.Android, которое использует собственную библиотеку Kotlin через библиотеку привязки Xamarin.Android. В этом пошаговом руководстве намеренно используется простой пример для выделения основных понятий. В реальных сценариях, скорее всего, потребуется предоставить большее количество API и применить к ним преобразования метаданных.
Дополнительные ссылки
- Android Studio
- Установка Gradle
- Visual Studio для Mac
- Декомпилятор Java
- Библиотека Kotlin BubblePicker
- Привязка библиотеки Java
- XPath
- Метаданные привязки Java
- Xamarin.Kotlin.StdLib NuGet
- Репозиторий примеров проектов
Своя библиотека под Android за один вечер
В процессе написания статьи она незаметно для меня трансформировалась из туториала по публикации Android-проекта как библиотеки в максимально душную статью о том, как математика пригодилась разработчику с гуманитарным бэкграундом в отрисовке анимашек. Статью подробную, разжеванную, с множеством строк кода. Возможно, не для слабонервных.
Что, если у вас появилась потребность использовать один и тот же код на Jetpack Compose между несколькими проектами, да еще так, чтобы он импортировался одинаково и автоматически на нескольких машинах? Такая ситуация может возникнуть с большой вероятностью, потому что Compose не блещет обилием предоставляемых из коробки виджетов и тулзов (хотя их количество постоянно растет). Быть может, ваш дизайнер пришел к вам с чем-то настолько диковинным, что готовыми компонентами просто не обойтись. Тогда тот пайплайн разработки и публикации собственной библиотеки, который я опишу ниже, может оказаться для вас полезным.
В качестве примера возьмем не самый очевидный элемент интерфейса — кнопку с движущейся синусоидоподобной волной. Отлично подойдет для управления, например, голосовым вводом.
В процессе создания библиотеки я буду пользоваться Gradle Kotlin DSL, а не Groovy. В Intellij Idea или Android Studio создаем модуль-библиотеку (Project Structure -> New Module -> Android Library). Минимальную версию Android SDK выставляем по вкусу, но не стоит ставить ниже, чем у проектов, в которых библиотека будет использоваться, иначе не пройдет ее импорт в последующем.
Чтобы сделать кнопку круглой, я решил использовать обыкновенный Row вот так:
val lightBlue = Color(173, 216, 230) Row( Modifier .padding(bottom = 24.dp) .size(size) .border(width = 1.dp, brush = SolidColor(lightBlue), shape = RoundedCornerShape(50)) .background( Brush.radialGradient( listOf( lightBlue, Color.Transparent, ) ), RoundedCornerShape(50) ) .pointerInput(Unit) < detectTapGestures( onDoubleTap = < focused = !focused speed = focused.toAnimationSpeed() onAction() >) > .clip(RoundedCornerShape(50)) ) <>
Прежде чем перейти к собственно отрисовке эффекта, оговорюсь: все, что мне до этого приходилось делать с анимациями в Jetpack Compose, было намного проще. Было бы очень скучно, если бы все юзкейсы можно было бы исчерпывающе покрыть всякими AnimatedVisibility и AnimatedContent , не правда ли? По этой причине код ниже, скорее всего, покажется кому-то экспериментальным и/или имеющим потенциал для оптимизации.
Начать отрисовку бесконечной анимации чего угодно, на мой взгляд, стоит с корутины, которая будет выдавать время. Добавим к этой переменной коэффициент скорости и получим что-то вроде
val frequency = 4 var speed by remember < mutableStateOf(1f) >val time by produceState(0f) < while (true) < withInfiniteAnimationFrameMillis < value = it / 1000f * speed >> >
Frequency я назвал так потому, что эта переменная определяет количество изгибов синусоиды, видимых пользователю в момент времени.
Теперь приступим к самой отрисовке.
private fun Modifier.drawWaves(time: Float, frequency: Int) = drawBehind < // Calculate the mean of bell curve and the distance between each wriggle on x-axis val mean = size.width / 2 val pointsDistance = size.width / frequency // Calculate the initial offset between the three waves on x-axis val initialOffset = pointsDistance / 3 // Draw the three waves with different initial offsets. drawWave(frequency, pointsDistance, time, mean, -initialOffset) drawWave(frequency, pointsDistance, time, mean, 0f) drawWave(frequency, pointsDistance, time, mean, initialOffset) >
Этот нехитрый код готовит важные для отрисовки параметры, такие как центр плоскости отрисовки, расстояние между любыми двумя пересечениями волной оси x ( pointsDistance ) и расстояние между двумя волнами по оси x ( initialOffset ). В будущем стоит сделать количество волн настраиваемым, но для начала и так сойдет 🙂
Самое интересное — это отрисовка самой волны. Мне кажется, имеет смысл декомпозировать ее алгоритм так:
1) расчет положения n точек на оси x в зависимости от времени time и частоты frequency:
private fun constructXPoints( frequency: Int, pointsDistance: Float, time: Float, initialOffset: Float, ): MutableList < val points = mutableListOf() for (i in 0 until frequency) < val xMin = initialOffset + pointsDistance * i val addUp = time % 1 * pointsDistance val offsetX = xMin + addUp points.add(offsetX) >return points >
2) смещение каждой из этих точек на четверть шага назад вправо и влево для разворачивания одной полный волны синусоиды
3) расчет координаты y для каждой точки x на кривой нормального распределения и отзеркаливание полученного значения по оси y
Для определения координаты точки на кривой нормального распределения используем такую функцию:
private fun calculateY(x: Float, mean: Float, heightRatio: Float): Float < val stdDev = mean / 3 val exponent = -0.5 * ((x - mean) / stdDev).pow(2) val denominator = sqrt(2 * PI) return mean + (heightRatio * mean * exp(exponent) / denominator).toFloat() >
Наконец, соберем логику, описанную выше, в единый ансамбль с отрисовкой кривых Безье и получим такого Франкенштейна:
private fun DrawScope.drawWave( frequency: Int, pointsDistance: Float, time: Float, mean: Float, initialOffset: Float, heightRatio: Float = 1f, ) < // The step between wriggles val subStep = pointsDistance / 4 // Construct the X points of the wave using the given parameters. val xPoints = constructXPoints( frequency = frequency, pointsDistance = pointsDistance, time = time, initialOffset = initialOffset ) // Create a path object and populate it with the cubic Bézier curves that make up the wave. val strokePath = Path().apply < for (index in xPoints.indices) < val offsetX = xPoints[index] when (index) < 0 -> < // Move to the first point in the wave. val offsetY = calculateY(offsetX, mean, heightRatio) moveTo(offsetX - subStep, offsetY) >xPoints.indices.last -> < // Draw the last cubic Bézier curve in the wave. val sourceXNeg = xPoints[index - 1] + subStep val sourceYNeg = mean * 2 - calculateY(sourceXNeg, mean, heightRatio) val xMiddle = (sourceXNeg + offsetX) / 2f val targetOffsetX = offsetX + subStep val targetOffsetY = calculateY(targetOffsetX, mean, heightRatio) cubicTo(xMiddle, sourceYNeg, xMiddle, targetOffsetY, targetOffsetX, targetOffsetY) >else -> < // Draw the cubic Bézier curves between the first and last points in the wave. val sourceXNeg = xPoints[index - 1] + subStep val sourceYNeg = mean * 2 - calculateY(sourceXNeg, mean, heightRatio) val targetXPos = offsetX - subStep val targetYPos = calculateY(targetXPos, mean, heightRatio) val xMiddle1 = (sourceXNeg + targetXPos) / 2f cubicTo(xMiddle1, sourceYNeg, xMiddle1, targetYPos, targetXPos, targetYPos) val targetXNeg = offsetX + subStep val targetYNeg = mean * 2 - calculateY(targetXNeg, mean, heightRatio) val xMiddle2 = (targetXPos + targetXNeg) / 2f cubicTo(xMiddle2, targetYPos, xMiddle2, targetYNeg, targetXNeg, targetYNeg) >> > > // Draw the wave path. drawPath( path = strokePath, color = Color.White, style = Stroke( width = 2f, cap = StrokeCap.Round ) ) >
Результат — лаконичная кнопка с бесконечно бегущими внутри нее волнами. Симпатично, не так ли?
Остаётся опубликовать код как gradle-зависимость. Для этого в корневой build.gradle.kts проекта нужно добавить несколько строк:
plugins < id("com.android.library") version "7.4.0" // или другая версия Android Gradle Plugin id("maven-publish") . >android < . publishing < multipleVariants < allVariants() withJavadocJar() withSourcesJar() >> > afterEvaluate < publishing < publications < create("mavenRelease") < groupId = "com.jetwidgets" artifactId = "jetwidgets" version = "1.0" from(components["release"]) >create("mavenDebug") < groupId = "com.jetwidgets" artifactId = "jetwidgets" version = "1.0" from(components["debug"]) >> > >
Теперь все готово к публикации. Перед отправкой билда в облачный репозиторий стоит убедиться, что библиотека публикуется и импортируется локально:
./gradlew clean ./gradlew build ./gradlew publishToMavenLocal
Для импорта в другом проекте достаточно просто добавить mavenLocal() в repositories и соответствующую зависимость в dependencies , понятное дело. Дальше создаём и пушим тэг с версией релиза на GitHub:
git tag 1.0.0 git push --tags
В веб-интерфейсе на гитхабе создаём новый релиз (Releases -> Draft new release). Jitpack сам подхватит исходный код ветки main или master и упакует его в jar. Чтобы проверить, все ли прошло успешно, в поисковой строке Jitpack введем url репозитория с GitHub:
Если билд не был успешным, это можно определить по красной иконке вместо зелёной, по ней же будут доступны логи. Почему это может произойти? Дело в том, что для компиляции кода Jitpack использует версию Java 1.8, тогда как наш код написан под Java 11 или даже 17. Чтобы это исправить, достаточно создать файл jitpack.yml в корне проекта и вписать в него следующее:
jdk: - openjdk
Все, теперь билд проходит успешно и можно использовать библиотеку в любом другом проекте:
repositories < maven < url = uri("https://jitpack.io") >> dependencies
Например, можно сделать кнопку с речевым вводом для голосового ассистента со скином Хлои из Detroit Become Human:
Но это уже совсем другая история 🙂
Как импортировать библиотеку в Android Studio?
Понимаю, что тема эта перерыта вдоль и поперек, но гугл мне, увы, не помог.
Решил подключить удаленную БД MySql для своего Android-проекта. Увидел хорошую статью, начал делать по ней. Автор использует стороннюю библиотеку com.devcolibri.parser.JSONParser(исходники: devcolibri.com/download/1548/AndroidAndMySQL.zip).
Я кладу из этого архива файл json-20090211.jar в папку /libs/ своего проекта. Далее иду в Project Structure->modules->app->Dependencies->add->jar dependency и выбираю этот файл.
Проверяю build.gradle — вроде бы всё, о чем пишут в интернете, добавлено.
Каким образом мне импортировать теперь её в Activity?
В примере делают так: «import com.devcolibri.parser.JSONParser;»
Вроде бы должно работать, если «devcolibri» заменить на название своего проекта, но что-то не получается.
Помогите пожалуйста разобраться и извините за очередной глупый вопрос 🙂
- Вопрос задан более трёх лет назад
- 1836 просмотров