Роль пользовательского интерфейса — отображать данные приложения на экране, а также служить основной точкой взаимодействия с пользователем. Всякий раз, когда данные изменяются, либо из-за взаимодействия с пользователем (например, нажатия кнопки), либо из-за внешнего ввода (например, ответа сети), пользовательский интерфейс должен обновляться, чтобы отразить эти изменения. По сути, пользовательский интерфейс — это визуальное представление состояния приложения, полученное из уровня данных.
Однако данные приложения, которые вы получаете с уровня данных, обычно имеют формат, отличный от формата информации, которую вам необходимо отобразить. Например, вам может понадобиться только часть данных для пользовательского интерфейса или вам может потребоваться объединить два разных источника данных, чтобы представить информацию, которая важна для пользователя. Независимо от применяемой логики, вам необходимо передать пользовательскому интерфейсу всю информацию, необходимую для полной визуализации. Уровень пользовательского интерфейса — это конвейер, который преобразует изменения данных приложения в форму, которую может представить пользовательский интерфейс, а затем отображает ее.
Базовый практический пример
Рассмотрим приложение, которое извлекает новостные статьи для чтения пользователем. В приложении есть экран статей, на котором представлены статьи, доступные для чтения, а также позволяет вошедшим в систему пользователям добавлять в закладки статьи, которые действительно выделяются. Учитывая, что в любой момент времени статей может быть много, читатель должен иметь возможность просматривать статьи по категориям. Подводя итог, можно сказать, что приложение позволяет пользователям делать следующее:
- Просмотрите статьи, доступные для чтения.
- Просмотрите статьи по категориям.
- Войдите и добавьте в закладки определенные статьи.
- Получите доступ к некоторым премиум-функциям, если имеете на это право.
В следующих разделах этот пример используется в качестве примера для ознакомления с принципами однонаправленного потока данных, а также для иллюстрации проблем, которые эти принципы помогают решить в контексте архитектуры приложения для уровня пользовательского интерфейса.
Архитектура уровня пользовательского интерфейса
Термин «пользовательский интерфейс» относится к элементам пользовательского интерфейса, таким как действия и фрагменты, которые отображают данные, независимо от того, какие API они для этого используют (Views или Jetpack Compose ). Поскольку роль уровня данных заключается в хранении, управлении и предоставлении доступа к данным приложения, уровень пользовательского интерфейса должен выполнить следующие шаги:
- Потребляйте данные приложения и преобразуйте их в данные, которые пользовательский интерфейс может легко отображать.
- Потребляйте данные, отображаемые в пользовательском интерфейсе, и преобразуйте их в элементы пользовательского интерфейса для представления пользователю.
- Потребляйте события пользовательского ввода из этих собранных элементов пользовательского интерфейса и при необходимости отражайте их эффекты в данных пользовательского интерфейса.
- Повторяйте шаги с 1 по 3 столько времени, сколько необходимо.
Остальная часть этого руководства демонстрирует, как реализовать уровень пользовательского интерфейса, выполняющий эти шаги. В частности, в этом руководстве рассматриваются следующие задачи и концепции:
- Как определить состояние пользовательского интерфейса.
- Однонаправленный поток данных (UDF) как средство создания и управления состоянием пользовательского интерфейса.
- Как представить состояние пользовательского интерфейса с помощью наблюдаемых типов данных в соответствии с принципами UDF.
- Как реализовать пользовательский интерфейс, который использует наблюдаемое состояние пользовательского интерфейса.
Самым фундаментальным из них является определение состояния пользовательского интерфейса.
Определить состояние пользовательского интерфейса
Обратитесь к тематическому исследованию , описанному ранее. Короче говоря, пользовательский интерфейс показывает список статей вместе с некоторыми метаданными для каждой статьи. Эта информация, которую приложение предоставляет пользователю, является состоянием пользовательского интерфейса.
Другими словами: если пользовательский интерфейс — это то, что видит пользователь, состояние пользовательского интерфейса — это то, что приложение говорит, что он должен видеть. Как две стороны одной медали, пользовательский интерфейс — это визуальное представление состояния пользовательского интерфейса. Любые изменения состояния пользовательского интерфейса немедленно отражаются в пользовательском интерфейсе.
Рассмотрим пример; Чтобы удовлетворить требованиям приложения News, информация, необходимая для полной визуализации пользовательского интерфейса, может быть инкапсулирована в класс данных NewsUiState
определенный следующим образом:
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf(),
val userMessages: List<Message> = listOf()
)
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
...
)
Неизменяемость
Определение состояния пользовательского интерфейса в приведенном выше примере является неизменяемым. Ключевым преимуществом этого является то, что неизменяемые объекты предоставляют гарантии относительно состояния приложения в данный момент времени. Это освобождает пользовательский интерфейс и позволяет ему сосредоточиться на единственной роли: считывать состояние и соответствующим образом обновлять элементы пользовательского интерфейса. В результате вам никогда не следует изменять состояние пользовательского интерфейса напрямую в пользовательском интерфейсе, если только сам пользовательский интерфейс не является единственным источником его данных. Нарушение этого принципа приводит к появлению нескольких источников достоверной информации для одной и той же информации, что приводит к несогласованности данных и тонким ошибкам.
Например, если флаг bookmarked
в объекте NewsItemUiState
из состояния пользовательского интерфейса в примере был обновлен в классе Activity
, этот флаг будет конкурировать с уровнем данных как источник статуса закладки статьи. Неизменяемые классы данных очень полезны для предотвращения такого рода антипаттернов.
Соглашения об именах в этом руководстве
В этом руководстве классы состояний пользовательского интерфейса названы в зависимости от функциональности экрана или части экрана, которую они описывают. Соглашение заключается в следующем:
функциональность + UiState .
Например, состояние экрана, отображающего новости, может называться NewsUiState
, а состояние элемента новостей в списке элементов новостей может быть NewsItemUiState
.
Управляйте состоянием с помощью однонаправленного потока данных
В предыдущем разделе было установлено, что состояние пользовательского интерфейса представляет собой неизменяемый снимок деталей, необходимых для отображения пользовательского интерфейса. Однако динамическая природа данных в приложениях означает, что состояние может со временем меняться. Это может быть связано с взаимодействием пользователя или другими событиями, которые изменяют базовые данные, используемые для заполнения приложения.
Эти взаимодействия могут получить выгоду от посредника для их обработки, определения логики, которая будет применяться к каждому событию, и выполнения необходимых преобразований в резервных источниках данных для создания состояния пользовательского интерфейса. Эти взаимодействия и их логика могут быть размещены в самом пользовательском интерфейсе, но это может быстро стать громоздким, поскольку пользовательский интерфейс начинает становиться чем-то большим, чем предполагает его название: он становится владельцем данных, производителем, преобразователем и многим другим. Более того, это может повлиять на тестируемость, поскольку результирующий код представляет собой тесно связанную смесь без видимых границ. В конечном итоге пользовательский интерфейс выиграет от снижения нагрузки. Если состояние пользовательского интерфейса не очень простое, единственной обязанностью пользовательского интерфейса должно быть использование и отображение состояния пользовательского интерфейса.
В этом разделе обсуждается однонаправленный поток данных (UDF) — архитектурный шаблон, который помогает обеспечить такое здоровое разделение ответственности.
Государственные держатели
Классы, которые отвечают за создание состояния пользовательского интерфейса и содержат необходимую логику для этой задачи, называются держателями состояния . Держатели состояний бывают разных размеров в зависимости от объема соответствующих элементов пользовательского интерфейса, которыми они управляют: от одного виджета, такого как нижняя панель приложения , до целого экрана или пункта назначения навигации.
В последнем случае типичной реализацией является экземпляр ViewModel , хотя в зависимости от требований приложения может быть достаточно и простого класса. Например, приложение «Новости» из примера использования использует класс NewsViewModel
в качестве держателя состояния для создания состояния пользовательского интерфейса для экрана, отображаемого в этом разделе.
Существует множество способов смоделировать взаимозависимость между пользовательским интерфейсом и его производителем состояния. Однако, поскольку взаимодействие между пользовательским интерфейсом и его классом ViewModel
в значительной степени можно понимать как ввод события и последующий вывод состояния, отношения можно представить, как показано на следующей диаграмме:
Схема, в которой состояние течет вниз, а события — вверх, называется однонаправленным потоком данных (UDF). Последствия этого шаблона для архитектуры приложения следующие:
- ViewModel хранит и предоставляет состояние, которое будет использоваться пользовательским интерфейсом. Состояние пользовательского интерфейса — это данные приложения, преобразованные ViewModel.
- Пользовательский интерфейс уведомляет ViewModel о пользовательских событиях.
- ViewModel обрабатывает действия пользователя и обновляет состояние.
- Обновленное состояние передается обратно в пользовательский интерфейс для рендеринга.
- Вышеупомянутое повторяется для любого события, вызывающего изменение состояния.
Для пунктов назначения или экранов навигации ViewModel работает с репозиториями или классами вариантов использования для получения данных и преобразования их в состояние пользовательского интерфейса, включая эффекты событий, которые могут вызвать изменения состояния. Упомянутое ранее тематическое исследование содержит список статей, каждая из которых имеет название, описание, источник, имя автора, дату публикации и наличие закладки. Пользовательский интерфейс для каждого элемента статьи выглядит следующим образом:
Пользователь, запрашивающий добавление статьи в закладки, является примером события, которое может вызвать изменения состояния. Как производитель состояния, ViewModel несет ответственность за определение всей логики, необходимой для заполнения всех полей в состоянии пользовательского интерфейса и обработки событий, необходимых для полной визуализации пользовательского интерфейса.
В следующих разделах более подробно рассматриваются события, вызывающие изменения состояния, и способы их обработки с помощью UDF.
Виды логики
Добавление статьи в закладки — это пример бизнес-логики , поскольку она повышает ценность вашего приложения. Дополнительную информацию об этом см. на странице уровня данных . Однако существуют различные типы логики, которые важно определить:
- Бизнес-логика — это реализация требований продукта к данным приложения. Как уже упоминалось, одним из примеров является добавление статьи в закладки в приложении для тематического исследования. Бизнес-логика обычно размещается на уровнях домена или данных, но никогда на уровне пользовательского интерфейса.
- Логика поведения пользовательского интерфейса или логика пользовательского интерфейса — это способ отображения изменений состояния на экране. Примеры включают получение правильного текста для отображения на экране с помощью
Resources
Android, переход к определенному экрану, когда пользователь нажимает кнопку, или отображение сообщения пользователя на экране с помощью всплывающего уведомления или панели закусок .
Логика пользовательского интерфейса, особенно когда она включает в себя такие типы пользовательского интерфейса, как Context
, должна находиться в пользовательском интерфейсе, а не во ViewModel. Если пользовательский интерфейс становится сложнее и вы хотите делегировать логику пользовательского интерфейса другому классу, чтобы обеспечить тестируемость и разделение задач, вы можете создать простой класс в качестве держателя состояния . Простые классы, созданные в пользовательском интерфейсе, могут использовать зависимости Android SDK, поскольку они следуют жизненному циклу пользовательского интерфейса; Объекты ViewModel имеют более длительный срок службы.
Дополнительные сведения о держателях состояний и о том, как они вписываются в контекст помощи в создании пользовательского интерфейса, см. в руководстве Jetpack Compose State .
Зачем использовать UDF?
UDF моделирует цикл производства состояния, как показано на рисунке 4. Он также разделяет место, где возникают изменения состояния, место, где они преобразуются, и место, где они в конечном итоге потребляются. Такое разделение позволяет пользовательскому интерфейсу делать именно то, что подразумевает его название: отображать информацию, наблюдая за изменениями состояния, и передавать намерения пользователя, передавая эти изменения в ViewModel.
Другими словами, UDF позволяет следующее:
- Согласованность данных. Для пользовательского интерфейса существует единый источник правды.
- Тестируемость. Источник состояния изолирован и, следовательно, доступен для тестирования независимо от пользовательского интерфейса.
- Ремонтопригодность. Изменение состояния следует четко определенному шаблону, где мутации являются результатом как пользовательских событий, так и источников данных, из которых они извлекаются.
Раскрыть состояние пользовательского интерфейса
После того как вы определите состояние пользовательского интерфейса и определите, как вы будете управлять созданием этого состояния, следующим шагом будет представление созданного состояния пользовательскому интерфейсу. Поскольку вы используете UDF для управления созданием состояния, вы можете рассматривать созданное состояние как поток — другими словами, с течением времени будет создаваться несколько версий состояния. В результате вы должны предоставить состояние пользовательского интерфейса в наблюдаемом держателе данных, таком как LiveData
или StateFlow
. Причина этого в том, что пользовательский интерфейс может реагировать на любые изменения, внесенные в состояние, без необходимости вручную извлекать данные непосредственно из ViewModel. Преимущество этих типов также заключается в том, что они всегда кэшируют последнюю версию состояния пользовательского интерфейса, что полезно для быстрого восстановления состояния после изменений конфигурации.
Просмотры
class NewsViewModel(...) : ViewModel() {
val uiState: StateFlow<NewsUiState> = …
}
Сочинить
class NewsViewModel(...) : ViewModel() {
val uiState: NewsUiState = …
}
Знакомство с LiveData
как с наблюдаемым хранилищем данных см. в этой кодовой лаборатории . Аналогичное введение в потоки Kotlin см. в разделе Потоки Kotlin на Android .
В тех случаях, когда данные, предоставляемые пользовательскому интерфейсу, относительно просты, часто имеет смысл обернуть данные в тип состояния пользовательского интерфейса, поскольку он передает связь между выбросом держателя состояния и связанным с ним экраном или элементом пользовательского интерфейса. Более того, по мере того, как элемент пользовательского интерфейса становится более сложным, всегда проще добавить к определению состояния пользовательского интерфейса дополнительную информацию, необходимую для отображения элемента пользовательского интерфейса.
Распространенный способ создания потока UiState
— предоставление резервного изменяемого потока как неизменяемого потока из ViewModel — например, представление MutableStateFlow<UiState>
как StateFlow<UiState>
.
Просмотры
class NewsViewModel(...) : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
...
}
Сочинить
class NewsViewModel(...) : ViewModel() {
var uiState by mutableStateOf(NewsUiState())
private set
...
}
Затем ViewModel может предоставлять методы, которые внутренне изменяют состояние, публикуя обновления для использования пользовательским интерфейсом. Возьмем, к примеру, случай, когда необходимо выполнить асинхронное действие; сопрограмму можно запустить с помощью viewModelScope
, а изменяемое состояние можно обновить после завершения.
Просмотры
class NewsViewModel(
private val repository: NewsRepository,
...
) : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
private var fetchJob: Job? = null
fun fetchArticles(category: String) {
fetchJob?.cancel()
fetchJob = viewModelScope.launch {
try {
val newsItems = repository.newsItemsForCategory(category)
_uiState.update {
it.copy(newsItems = newsItems)
}
} catch (ioe: IOException) {
// Handle the error and notify the UI when appropriate.
_uiState.update {
val messages = getMessagesFromThrowable(ioe)
it.copy(userMessages = messages)
}
}
}
}
}
Сочинить
class NewsViewModel(
private val repository: NewsRepository,
...
) : ViewModel() {
var uiState by mutableStateOf(NewsUiState())
private set
private var fetchJob: Job? = null
fun fetchArticles(category: String) {
fetchJob?.cancel()
fetchJob = viewModelScope.launch {
try {
val newsItems = repository.newsItemsForCategory(category)
uiState = uiState.copy(newsItems = newsItems)
} catch (ioe: IOException) {
// Handle the error and notify the UI when appropriate.
val messages = getMessagesFromThrowable(ioe)
uiState = uiState.copy(userMessages = messages)
}
}
}
}
В приведенном выше примере класс NewsViewModel
пытается получить статьи для определенной категории, а затем отражает результат попытки — будь то успех или неудача — в состоянии пользовательского интерфейса, где пользовательский интерфейс может отреагировать на него соответствующим образом. См. раздел «Показать ошибки на экране» , чтобы узнать больше об обработке ошибок.
Дополнительные соображения
В дополнение к предыдущим рекомендациям при раскрытии состояния пользовательского интерфейса учитывайте следующее:
Объект состояния пользовательского интерфейса должен обрабатывать состояния, связанные друг с другом. Это приводит к меньшему количеству несоответствий и облегчает понимание кода. Если вы выставите список новостей и количество закладок в двух разных потоках, вы можете оказаться в ситуации, когда один будет обновлен, а другой — нет. Когда вы используете один поток, оба элемента обновляются. Более того, для некоторой бизнес-логики может потребоваться комбинация источников. Например, вам может потребоваться отображать кнопку закладки только в том случае, если пользователь вошел в систему и является подписчиком платной службы новостей. Вы можете определить класс состояния пользовательского интерфейса следующим образом:
data class NewsUiState( val isSignedIn: Boolean = false, val isPremium: Boolean = false, val newsItems: List<NewsItemUiState> = listOf() ) val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
В этом объявлении видимость кнопки закладки является производным свойством двух других свойств. Поскольку бизнес-логика становится более сложной, наличие единого класса
UiState
, все свойства которого доступны сразу, становится все более важным.Пользовательский интерфейс сообщает: один поток или несколько потоков? Ключевым руководящим принципом выбора между представлением состояния пользовательского интерфейса в одном потоке или в нескольких потоках является предыдущий пункт: взаимосвязь между создаваемыми элементами. Самым большим преимуществом однопоточного доступа является удобство и согласованность данных: государственные потребители всегда имеют самую свежую информацию, доступную в любой момент времени. Однако есть случаи, когда могут быть уместны отдельные потоки состояния из ViewModel:
Несвязанные типы данных: некоторые состояния, необходимые для визуализации пользовательского интерфейса, могут быть полностью независимы друг от друга. В подобных случаях затраты на объединение этих разрозненных состояний могут перевесить выгоды, особенно если одно из этих состояний обновляется чаще, чем другое.
Различие
UiState
: чем больше полей в объектеUiState
, тем больше вероятность того, что поток выдаст сообщение в результате обновления одного из его полей. Поскольку представления не имеют механизма сравнения, позволяющего понять, являются ли последовательные выбросы разными или одинаковыми, каждый выпуск вызывает обновление представления. Это означает, что может потребоваться смягчение последствий с помощью API-интерфейсовFlow
или таких методов, какdistinctUntilChanged()
дляLiveData
.
Использовать состояние пользовательского интерфейса
Чтобы использовать поток объектов UiState
в пользовательском интерфейсе, вы используете оператор терминала для наблюдаемого типа данных, который вы используете. Например, для LiveData
вы используете метод observe()
, а для потоков Kotlin — метод collect()
или его варианты.
При использовании наблюдаемых держателей данных в пользовательском интерфейсе обязательно учитывайте жизненный цикл пользовательского интерфейса. Это важно, поскольку пользовательский интерфейс не должен отслеживать состояние пользовательского интерфейса, когда представление не отображается пользователю. Чтобы узнать больше об этой теме, прочтите эту публикацию в блоге . При использовании LiveData
LifecycleOwner
неявно заботится о проблемах жизненного цикла. При использовании потоков лучше всего обрабатывать это с помощью соответствующей области сопрограммы и API repeatOnLifecycle
:
Просмотры
class NewsActivity : AppCompatActivity() {
private val viewModel: NewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect {
// Update UI elements
}
}
}
}
}
Сочинить
@Composable
fun LatestNewsScreen(
viewModel: NewsViewModel = viewModel()
) {
// Show UI elements based on the viewModel.uiState
}
Показать текущие операции
Простой способ представить состояния загрузки в классе UiState
— использовать логическое поле:
data class NewsUiState(
val isFetchingArticles: Boolean = false,
...
)
Значение этого флага указывает на наличие или отсутствие индикатора выполнения в пользовательском интерфейсе.
Просмотры
class NewsActivity : AppCompatActivity() {
private val viewModel: NewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
// Bind the visibility of the progressBar to the state
// of isFetchingArticles.
viewModel.uiState
.map { it.isFetchingArticles }
.distinctUntilChanged()
.collect { progressBar.isVisible = it }
}
}
}
}
Сочинить
@Composable
fun LatestNewsScreen(
modifier: Modifier = Modifier,
viewModel: NewsViewModel = viewModel()
) {
Box(modifier.fillMaxSize()) {
if (viewModel.uiState.isFetchingArticles) {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
// Add other UI elements. For example, the list.
}
}
Отображать ошибки на экране
Отображение ошибок в пользовательском интерфейсе аналогично отображению выполняемых операций, поскольку они оба легко представлены логическими значениями, обозначающими их наличие или отсутствие. Однако ошибки могут также включать связанное сообщение для ретрансляции обратно пользователю или связанное с ними действие, которое повторяет неудачную операцию. Таким образом, пока выполняющаяся операция либо загружается, либо не загружается, состояния ошибок, возможно, придется моделировать с помощью классов данных, в которых размещаются метаданные, соответствующие контексту ошибки.
Например, рассмотрим пример из предыдущего раздела, в котором при загрузке статей отображался индикатор выполнения. Если эта операция приводит к ошибке, возможно, вы захотите отобразить пользователю одно или несколько сообщений с подробным описанием того, что пошло не так.
data class Message(val id: Long, val message: String)
data class NewsUiState(
val userMessages: List<Message> = listOf(),
...
)
Сообщения об ошибках затем могут быть представлены пользователю в виде элементов пользовательского интерфейса, таких как закусочные . Поскольку это связано с тем, как создаются и используются события пользовательского интерфейса, дополнительные сведения см. на странице «События пользовательского интерфейса» .
Потоки и параллелизм
Любая работа, выполняемая в ViewModel, должна быть безопасной для основного — безопасной для вызова из основного потока. Это связано с тем, что уровни данных и домена отвечают за перемещение работы в другой поток.
Если ViewModel выполняет длительные операции, то она также отвечает за перемещение этой логики в фоновый поток. Сопрограммы Kotlin — отличный способ управления параллельными операциями, а компоненты архитектуры Jetpack обеспечивают для них встроенную поддержку. Дополнительные сведения об использовании сопрограмм в приложениях Android см. в разделе Сопрограммы Kotlin для Android .
Навигация
Изменения в навигации по приложениям часто вызваны событиями. Например, после того, как класс SignInViewModel
выполняет вход, в UiState
для поля isSignedIn
может быть установлено значение true
. Подобные триггеры следует использовать так же, как те, которые описаны в разделе «Потребление состояния пользовательского интерфейса» выше, за исключением того, что реализация потребления должна подчиняться компоненту навигации .
Пейджинг
Библиотека подкачки используется в пользовательском интерфейсе с типом PagingData
. Поскольку PagingData
представляет и содержит элементы, которые могут меняться с течением времени (другими словами, это не неизменяемый тип), его не следует представлять в неизменяемом состоянии пользовательского интерфейса. Вместо этого вам следует предоставить его из ViewModel независимо в отдельном потоке. Конкретный пример этого см. в кодовой лаборатории Android Paging .
Анимации
Чтобы обеспечить плавные и плавные переходы навигации верхнего уровня, перед запуском анимации вам может потребоваться подождать, пока второй экран загрузит данные. Платформа представления Android предоставляет перехватчики для задержки переходов между местами назначения фрагментов с помощью API-интерфейсов postponeEnterTransition()
и startPostponedEnterTransition()
. Эти API позволяют гарантировать, что элементы пользовательского интерфейса на втором экране (обычно изображение, полученное из сети) готовы к отображению до того, как пользовательский интерфейс анимирует переход к этому экрану. Более подробную информацию и особенности реализации смотрите в примере Android Motion .
Образцы
Следующие примеры Google демонстрируют использование уровня пользовательского интерфейса. Изучите их, чтобы увидеть это руководство на практике:
Рекомендуется для вас
- Примечание: текст ссылки отображается, когда JavaScript отключен.
- Создание состояния пользовательского интерфейса
- Держатели состояний и состояние пользовательского интерфейса {:#mad-arch}
- Руководство по архитектуре приложения