Site icon AppTractor

Гибкая и современная архитектура приложений для Android: пошаговое руководство

Недавно я писал о теории, лежащей в основе хорошей архитектуры приложений для Android. Эта статья стала самой популярной за все время, и многие люди любезно сообщили, что она им помогла.

Одним из наиболее распространенных вопросов был: «А как же X? Это не совсем соответствует правилам». Именно поэтому я всегда говорил:

Изучайте принципы, а не слепо следуйте правилам.

Эта статья призвана продемонстрировать практическую сторону: научить Android-архитектуре на реальном примере. В первую очередь это означает показать, как принимаются те или иные архитектурные решения. Мы будем сталкиваться с ситуациями, когда есть несколько возможных ответов, и в каждом случае мы будем опираться на принципы, а не заучивать набор правил.

Итак, давайте вместе создадим приложение.

Приложение, которое мы собираемся создать

Мы собираемся создать приложение для любителей наблюдать за планетами. Оно будет выглядеть примерно так:

Лучшее приложение в мире для наблюдением за планетами

Наше приложение будет обладать следующими возможностями:

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

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

Вот репозиторий, который мы получим в итоге: https://github.com/tdcolvin/PlanetSpotters.

Архитектурные принципы, которые мы будем использовать

Мы будем опираться на принципы SOLID, принципы «чистой архитектуры» и собственные принципы Google Modern App Architecture.

Мы не будем рассматривать ни один из этих принципов как жесткие правила, поскольку мы достаточно умны, чтобы использовать то, что лучше подходит нашему приложению (в частности, лучше соответствует предполагаемому его развитию). Например, если вы будете неукоснительно следовать Clean Architecture, вы создадите надежное, прочное, расширяемое программное обеспечение, но ваш код будет чрезмерно сложным для простого приложения. Принципы Google позволяют создавать более простой код, но они менее уместны, если в один прекрасный день приложение будет поддерживаться несколькими большими командами разработчиков.

Мы начнем с топологии Google, а по ходу дела будем опираться на принципы чистой архитектуры.

Топология Google выглядит следующим образом:

Мы будем реализовывать эту топологию по частям, и в моей предыдущей статье мы подробно рассмотрели каждый из этих разделов. Но в качестве краткого обзора.

Слой UI

Слой UI реализует пользовательский интерфейс. Он подразделяется на:

Доменный слой

Доменный слой предназначен для сценариев использования, содержащих бизнес-логику высокого уровня. Например, если мы хотим добавить планету, то AddPlanetUseCase будет описывать набор шагов, необходимых для этого. Это список «что», а не «как»: например, мы скажем «сохранить данные объекта Planet». Это высокоуровневая инструкция. Мы не скажем «сохранить в локальном кэше», а тем более «сохранить в локальном кэше, используя базу данных Room» — такие детали реализации нижнего уровня относятся к другому уровню.

Слой данных

Google призывает нас иметь единый источник истины для всех данных в приложении, т.е. способ получения окончательно «правильной» версии данных. Именно это и должен дать нам слой данных (для всех структур данных, кроме тех, которые описывают то, что только что ввел пользователь). Он подразделяется на:

Хорошая архитектура позволяет откладывать решения

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

Но есть и то, что мы еще не решили. Мы не знаем точно, как будет выглядеть пользовательский интерфейс и какие технологии мы будем использовать для его создания (Jetpack Compose, XML, …). Мы не знаем, какую форму примет локальный кэш. Мы не знаем, какое проприетарное решение мы будем использовать для доступа к данным в Интернете. Мы не знаем, будем ли мы поддерживать телефоны, планшеты или другие форм-факторы.

Вопрос: Нужно ли нам знать что-либо из вышеперечисленного для построения архитектуры?

Ответ: Нет!

Все вышеперечисленное — это низкоуровневые соображения (в чистой архитектуре их код находился бы в самом внешнем кольце). Это детали реализации, а не логики. Принцип инверсии зависимостей SOLID говорит нам о том, что никакой код не должен зависеть от них.

Другими словами, мы должны иметь возможность писать (и тестировать!) остальной код приложения, не зная ничего из вышеперечисленного. А когда мы узнаем ответы на эти вопросы, ничего из уже написанного не должно измениться.

Это означает, что этап создания кода может начаться до того, как дизайнеры закончат проектирование, и до того, как заинтересованные стороны примут решение об использовании сторонних технологий. Таким образом, хорошая архитектура позволяет принимать отложенные решения (и иметь гибкость, позволяющую отменить любые такие решения, не вызывая массового выпадения кода).

Архитектурная диаграмма нашего проекта

Вот первая попытка вписать наше приложение для наблюдения за планетами в топологию Google.

Слой данных

У нас будет хранилище данных о планете и два источника данных: локальный кэш и удаленные данные.

Слой пользовательского интерфейса

Будет два хранилища состояний: одно для страницы списка планет, другое — для страницы добавления планеты. Каждая страница также будет иметь свой набор элементов пользовательского интерфейса, написанных с использованием технологии, которая пока может оставаться TBD.

Доменный слой

Существует два совершенно правильных варианта архитектуры доменного слоя:

  1. Мы можем добавлять сценарии использования только там, где есть повторяющаяся бизнес-логика. В нашем приложении единственная повторяющаяся логика — это добавление планет: она нужна как при добавлении пользователем примеров планет, так и при ручном вводе данных о своей планете. Поэтому мы создадим только один вариант использования: AddPlanetUseCase. В других случаях (например, для удаления планеты) держатель состояния будет напрямую взаимодействовать с хранилищем.
  2. Мы можем добавить варианты использования для каждого взаимодействия с хранилищем, чтобы никогда не было прямого контакта между держателем состояния и хранилищем. В этом случае у нас будут варианты использования для добавления планет, удаления планет и составления списка планет.

Преимущество варианта №2 заключается в том, что он соответствует правилам чистой архитектуры. Лично я считаю, что для большинства приложений он слишком тяжел, поэтому склоняюсь к варианту №1. Так мы и поступим в данном случае.

В результате мы получим следующую архитектурную диаграмму:

Архитектурная схема нашего приложения

С какого кода следует начинать?

Правило таково:

Начинать с высокоуровневого кода и работать вниз.

Это означает, что сначала нужно написать сценарии использования, поскольку это позволит определить, какие требования предъявляются к уровню хранилища. Когда мы знаем, что требуется от хранилища, мы можем написать, что требуется от источников данных для его работы.

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

Конечно, мы можем отложить написание элементов пользовательского интерфейса и источников данных (т.е. всего низкоуровневого кода) на неопределенное время, когда старшие инженеры и заинтересованные стороны проекта согласуют используемые технологии.

На этом теория заканчивается. Теперь давайте приступим к созданию приложения. Я расскажу вам о решениях, которые мы принимаем, по мере того как мы к ним будем подходить.

Шаг 0: Создание проекта

Откройте Android Studio и создайте “No activity” проект:

На следующем экране назовите проект PlanetSpotters и оставьте все остальное как есть:

Добавление инъекции зависимостей

Нам понадобится фреймворк для инъекции зависимостей, который поможет применить принцип инверсии зависимостей SOLID. Мне больше всего нравится Hilt, и, к счастью, именно его рекомендует Google.

Чтобы добавить Hilt, добавьте следующее в корневой файл Gradle:

И добавьте это в файл app/build.gradle:

(Обратите внимание, что здесь мы устанавливаем совместимость с Java 17, как того требует Kapt, который использует Hilt. Вам потребуется Android Studio Flamingo или выше).

Наконец, добавьте переопределение класса Application, содержащее аннотацию @HiltAndroidApp. То есть создайте в папке пакетов вашего приложения (здесь com.tdcolvin.planetspotters) файл PlanetSpottersApplication со следующим содержанием:

А затем укажите ОС на его инстанцирование, добавив его в манифест:

Когда у нас будет основная Активити, нам нужно будет добавить в нее @AndroidEntryPoint. Но на этом мы завершаем настройку Hilt.

Наконец, мы добавим поддержку других полезных библиотек, добавив эти строки в app/build.gradle:

dependencies {
    ... 

    //Coroutines
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'

    //viewModelScope
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
}

Шаг 1: Перечислите все, что может делать и видеть пользователь.

Этот шаг необходим для подготовки к программированию сценариев использования и репозитория. Напомним, что сценарий использования — это одна задача, которую может выполнить пользователь, описанная на высоком уровне (что, а не как).

Поэтому давайте начнем с написания этих задач — исчерпывающего списка всего, что пользователь может сделать и увидеть в приложении.

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

Здесь необходима письменная спецификация. Дизайн пользовательского интерфейса не требуется, но если он есть, то, конечно, помогает визуализации.

Вот наш список:

Получить список обнаруженных планет, который обновляется автоматически

Ввод: Ничего

Вывод: Flow<List<Planet>>

Действие: Запрос текущего списка обнаруженных планет из хранилища, требуемый в форме, которая будет информировать нас о том, что что-то изменилось.

Получение сведений об одной обнаруженной планете, которые обновляются автоматически

Ввод: String — идентификатор планеты, которую мы получаем

Вывод: Flow<Planet>

Действие: Запрос планеты с заданным идентификатором из хранилища, прося его сообщать нам об изменениях

Добавление/редактирование вновь обнаруженной планеты

Ввод:

Вывод: None (успех определяется завершением без исключений)

Действия: Создать объект Planet из входных данных и передать его в хранилище (для добавления в его источники данных).

Добавить нескольких примеров планет

Ввод: нет

Вывод: нет (выкидывает при ошибке)

Действия: Попросить хранилище добавить три планеты-образца с датой открытия в текущий момент времени: Трензалор (300 световых лет), Скаро (0,5 световых лет), Галлифрей (40 световых лет).

Удалить планету

Ввод: String — идентификатор удаляемой планеты

Вывод: нет (выбрасывается при ошибке)

Действия: попросить хранилище удалить планету с заданным идентификатором.

Теперь, когда у нас есть этот список, мы можем приступить к программированию сценариев использования и хранилища.

Шаг 2: Программирование сценариев использования

Из шага 1 мы получили список задач, которые может выполнять пользователь. И ранее мы решили, что из всех этих задач единственной, которую мы запрограммируем как сценарий использования, будет «добавить планету» (мы решили добавлять только те сценарии использования, в которых задачи повторяются в разных областях приложения).

Таким образом, у нас остается один вариант использования, который мы и напишем здесь: AddPlanetUseCase.

Приятным приемом в Kotlin является размещение логики сценариев использования внутри функций operator fun invoke(…). Это позволяет вызывающему коду «вызывать» экземпляр класса, как если бы он был функцией, например, так:

val addPlanetUseCase: AddPlanetUseCase = …

//Use our instance as if it were a function:
addPlanetUseCase(…)

Вот наш AddPlanetUseCase, созданный с использованием этого трюка:

Здесь PlanetsRepository — это интерфейс, в котором перечислены методы, которыми будет обладать хранилище. Подробнее об этом позже (в частности, о том, почему мы создаем интерфейс, а не класс). Но пока давайте создадим его, чтобы наш код компилировался:

И тип данных, описывающий планету:

Метод addPlanet (как и функция invoke в примере использования) объявлен как suspend, поскольку мы знаем, что он будет работать в фоновом режиме. В дальнейшем мы будем добавлять в этот интерфейс дополнительные методы, но пока этого достаточно.

Кстати, можно задаться вопросом, почему мы взялись за создание такого простого варианта использования. Ответ заключается в том, как он может развиваться. В будущем он, скорее всего, станет более сложным, а внешний код можно изолировать от этой сложности.

Шаг 2.1: Тестирование вариантов использования

Мы написали сценарий использования, но не можем его запустить. Во-первых, он зависит от интерфейса PlanetsRepository, а его реализации у нас пока нет. Hilt не будет знать, что с ним делать.

Но мы можем написать тесты, предоставляющие поддельный экземпляр PlanetsRepository, и запустить его с помощью нашего фреймворка тестирования. Именно это и следует сделать на данном этапе.

Поскольку это учебное пособие по архитектуре, специфика тестирования не рассматривается, поэтому этот шаг оставлен в качестве упражнения. Но отметим, что хорошее проектирование архитектуры позволило нам разделить компоненты на легко тестируемые части.

Шаг 3: слой данных, написание PlanetsRepository

Помните, что задача репозитория — объединять различные источники данных, управлять расхождениями между ними и предоставлять операции CRUD.

Использование инверсии зависимостей и инъекции зависимостей

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

Это объясняет, почему ранее мы создали PlanetsRepository в виде интерфейса (а не класса). Вызывающий код будет зависеть только от интерфейса, а реализацию он получит с помощью инъекции зависимостей. Поэтому теперь мы добавим в интерфейс дополнительные методы и создадим его реализацию, которую назовем DefaultPlanetsRepository.

(Примечание: В некоторых командах разработчиков принято называть реализацию <имя интерфейса>Impl; например, PlanetsRepositoryImpl. Я не думаю, что это соглашение помогает читаемости: имя класса должно говорить вам, почему вы реализуете интерфейс. Поэтому я избегаю этого. Но я упоминаю об этом, поскольку это довольно широко используется).

Использование Kotlin Flows для обеспечения доступности данных

Если вы еще не знакомы с потоками Kotlin Flow, остановите свои дела и прочитайте о них прямо сейчас. Они изменят вашу жизнь.

Они обеспечивают «конвейер» данных, который изменяется по мере получения новых результатов. Пока вызывающая сторона подписана на этот конвейер, она будет получать обновления при каждом изменении. Таким образом, теперь наш пользовательский интерфейс может автоматически обновляться по мере обновления данных, практически без дополнительных усилий. В отличие от прежних времен, когда нам приходилось вручную отмечать в пользовательском интерфейсе, что данные изменились.

Существуют и другие решения, такие как RxJava и MutableLiveData, которые делают подобное, но они не столь гибки и просты в использовании, как Flow.

Добавление вездесущего класса WorkResult

Класс WorkResult — это обычный возврат из слоя данных. Он позволяет описать, был ли запрос успешным или нет, и выглядит следующим образом:

Вызывающий код может проверить, является ли данный WorkResult объектом Success, Error или Loading (последнее означает, что запрос еще не завершен), и, следовательно, определить, был ли запрос успешным.

Интерфейс нашего репозитория

Давайте соберем все вышесказанное воедино и разработаем спецификацию методов и свойств, которые будут составлять наш репозиторий PlanetsRepository.

В нем есть два метода для получения планет. Первый просто получает одну планету по ее ID:

fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>

Второй получает Flow, представляющий список планет:

fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>

Оба эти метода являются единственным источником истины для соответствующих данных. Каждый раз мы будем возвращать данные, хранящиеся в нашем локальном кэше, поскольку нам нужно, чтобы эти методы выполнялись очень часто, а локальные данные быстрее и дешевле, чем обращение к удаленному источнику данных. Но нам понадобится метод для обновления локального кэша. Он будет обновлять локальный источник данных из удаленного источника данных:

suspend fun refreshPlanets()

Далее нам нужны методы для добавления, обновления и удаления планет:

suspend fun addPlanet(planet: Planet)

suspend fun deletePlanet(planetId: String)

Таким образом, наш интерфейс теперь выглядит следующим образом:

Написание интерфейсов источников данных по ходу работы

Для того чтобы написать класс, реализующий интерфейс, необходимо следить за тем, какие методы понадобятся нашим источникам данных. Напомним, что у нас есть два источника данных: LocalDataSource и RemoteDataSource. Мы еще не решили, какую стороннюю технологию использовать для каждого из них, да это и не нужно.

Давайте сейчас создадим определения интерфейсов, готовые к тому, чтобы по ходу работы добавлять сигнатуры методов:

Теперь, когда эти интерфейсы готовы к заполнению, мы можем написать DefaultPlanetsRepository. Давайте рассмотрим эти методы по очереди.

Напишем getPlanetFlow() и getPlanetsFlow()

Оба метода просты: мы возвращаем данные, находящиеся в локальном источнике. (Почему не в удаленном источнике? Потому что локальный источник существует для того, чтобы быть быстрым, не требующим больших ресурсов способом доступа к данным. Удаленный источник может быть всегда актуальным, но он медленный. Если бы нам нужны были строго самые свежие данные, то перед вызовом getPlanetsFlow() можно было бы воспользоваться функцией refershPlanets(), приведенной ниже).

Таким образом, это зависит от функций getPlanetFlow() и getPlanetsFlow() в LocalDataSource. Сейчас мы добавим их в интерфейс, чтобы наш код компилировался.

Пишим refreshPlanets()

Для обновления локального кэша мы получаем текущий список планет из удаленного источника данных и сохраняем его в локальном источнике данных. (Затем локальный источник данных может «заметить» изменения и выдать новый список планет через Flow, возвращаемый функцией getPlanetsFlow()).

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

Обратите внимание, что все эти методы объявлены как suspend fun. Это передает ответственность за потоки и контекст корутины вызывающей стороне.

Написание функций addPlanet() и deletePlanet()

Обе эти функции работают по одной и той же схеме: выполняют операцию записи в удаленный источник данных и в случае успеха зеркально отражают изменения в локальном кэше.

Мы ожидаем, что после попадания в базу данных удаленный источник данных присвоит объекту Planet уникальный идентификатор, поэтому функция RemoteDataSource addPlanet() возвращает обновленный объект Planet с ненулевым идентификатором.

И после всего этого вот наш конечный интерфейс источника данных:

Код, реализующий их, мы напишем позже, а пока перейдем к пользовательскому интерфейсу.

Шаг 5: Держатели состояний, написание модели PlanetsListViewModel

Напомним, что слой пользовательского интерфейса состоит из элементов пользовательского интерфейса и держателей состояний:

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

Написание спецификации для модели PlanetsListViewModel

В пользовательском интерфейсе будет две страницы: одна — для списка и удаления планет, другая — для добавления и редактирования планет. Модель PlanetsListViewModel обслуживает первую. Это означает, что она должна передавать данные элементам пользовательского интерфейса экрана списка планет, а также быть готовой получать события от элементов пользовательского интерфейса при выполнении пользователем тех или иных действий.

В частности, наша модель PlanetsListViewModel должна отображать:

Объект PlanetsListUiState: текущее состояние страницы

Я считаю полезным хранить все состояние страницы в одном классе данных:

Обратите внимание, что я определил ее в том же файле, что и модель представления. Она содержит только простые объекты: никаких потоков и т.д., только примитивные типы, массивы и простые классы данных. Обратите также внимание на то, что все поля имеют значения по умолчанию — это поможет нам в дальнейшем.

(Есть несколько веских причин, по которым вы можете даже не захотеть, чтобы объект Planet появился в приведенной выше модели. Приверженцы чистой архитектуры указали бы на то, что слишком много переходов между тем, где была определена Planet, и тем, где онаиспользуется. А принципы поднятия состояния говорят нам о том, что мы должны предоставлять только те части данных, которые нам нужны. Например, сейчас нам нужны только имя планеты и расстояние до нее, поэтому мы должны иметь только их, а не весь объект Planet. Лично я считаю, что это излишне усложняет код и затрудняет внесение изменений в будущем, но вы можете с этим не соглашаться!)

Итак, определив это, мы можем создать переменную state внутри нашей модели представления, которая будет ее отображать:

Видите, как создается «следующее» состояние пользовательского интерфейса в зависимости от различных видов результатов, только что полученных из хранилища?

Параметры scope и started для .stateIn(…) надежно ограничивают жизненный цикл этого StateFlow. Более подробную информацию можно найти в этой замечательной статье.

Добавление примеров планет

Для добавления наших трех примеров планет мы повторно вызываем созданный для этого сценарий использования.

Обновление и удаление

Функции обновления и удаления построены очень похоже друг на друга, просто вызывается соответствующая функция хранилища.

Шаг 6: Написание модели AddEditPlanetViewModel

Модель AddEditPlanetViewModel обеспечивает работу экрана, используемого для добавления новой или редактирования существующей планеты.

Как мы уже делали ранее — и, собственно, как и положено для любой модели представления, — мы определим класс данных для всего, что будет отображаться в пользовательском интерфейсе, и единый источник истины для него:

Если мы редактируем планету (а не добавляем новую), то мы хотим, чтобы начальное состояние представления отражало текущее состояние этой планеты.

Как и положено в хороших практиках, этому экрану будет передан только ID редактируемой планеты. (Мы не передаем весь объект Planet — он может стать слишком большим и сложным). Компоненты жизненного цикла в Android предоставляют нам SavedStateHandle, из которого мы можем получить идентификатор планеты и загрузить в него объект Planet:

Обратите внимание, как мы обновляем состояние пользовательского интерфейса с помощью этого паттерна:

_uiState.update { it.copy( ... ) }

В одной строке создается новое состояние AddEditPlanetUiState со значениями, скопированными из предыдущего, и отправляется через uiState Flow.

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

И, наконец, мы сохраняем объект планеты, используя наш пример AddPlanetUseCase:

Шаг 7: Написание источников данных и элементов пользовательского интерфейса

Теперь, когда у нас есть вся архитектура, мы можем написать код самого низкого уровня. Это элементы пользовательского интерфейса и источники данных. Для элементов пользовательского интерфейса мы можем выбрать, например, поддержку телефонов и планшетов с помощью Jetpack Compose. Для локального источника данных мы можем написать кэш с помощью Room DB, а для удаленного источника данных — имитировать доступ к удаленному API.

Эти слои должны оставаться как можно более тонкими. Например, код элементов пользовательского интерфейса не должен содержать никаких вычислений и логики, только то, что необходимо для получения состояния, заданного моделью представления, и отображения его на экране. Логика — это для моделей представления.

Для источников данных достаточно написать минимум кода, необходимого для реализации функций в интерфейсах LocalDataSource и RemoteDataSource.

Специфические сторонние технологии и фреймворки (такие как Compose и Room) выходят за рамки данного руководства, но пример реализации этих слоев можно посмотреть в репозитории кода.

Оставим низкий уровень напоследок

Обратите внимание, что мы смогли оставить эти части нашего приложения напоследок. Это очень полезно, поскольку позволяет заинтересованным сторонам оставить  максимальное количество времени для принятия решений о том, какие сторонние технологии использовать и как должно выглядеть приложение. И даже после того, как мы написали код, мы можем отменить эти решения без ущерба для остальной части приложения.

Полный репозиторий кода находится по адресу: https://github.com/tdcolvin/PlanetSpotters.

Заключение

В этом руководстве было много интересного; поздравляю вас с тем, что вы дошли до конца. Надеюсь, оно было полезным. У меня нет бейджей (тем более дипломов), которые можно было бы выдать, но не стесняйтесь (даже поощряю) сделать себе такой и опубликовать результат здесь.

И, конечно, если у вас есть вопросы или комментарии, или если вы с чем-то не согласны (на самом деле, особенно если вы с чем-то не согласны), то поделитесь этим! Оставляйте свои отклики в статье, я постараюсь ответить всем.

Наконец, в настоящее время я предлагаю несколько бесплатных сессий в неделю, чтобы помочь всем желающим с любым аспектом разработки Android или создания бизнеса приложений. Записаться ко мне можно здесь: calendly.com/tdcolvin/android-assistance.

Exit mobile version