Guide to app architecture 2 - UI layer Overview

proheart
中文

UI Layer

The roles of the UI: 1. where data is displayed on the screen, 2. where the user interacts.
User interactions (such as button presses), external inputs (such as network responses) all cause data to change, and the UI should update to reflect those changes. In effect, the UI can be thought of as a visual representation of the application state retrieved from the data layer.

There is a problem here, the data retrieved from the data layer, its format and the data to be displayed are usually not the same. For example, only a portion of the retrieved data is to be displayed on the UI, or two different data sources are to be combined for the UI to use. Either way, you give the UI all the information it needs. Therefore, the UI layer needs a pipeline. One end of the pipeline is the data obtained from the data layer, and the other end is the data that is organized in accordance with the UI format.
image.png

A basic case study

To design a news app, list the following list according to your needs:

  1. have a news list
  2. Browse by category
  3. Support user login
  4. Logged in users can bookmark
  5. Has premium features
    image.png

The following chapters use this case to introduce the principle of Unidirectional Data Flow (UDF) and to illustrate how UDF solves the problem in the context of UI layer architecture.

UI Layer architecture

The UI here refers to UI elements, such as activity and fragment, which refer to the container used to display data, which should be distinguished from various Views or Jetpack Compose in the API. The role of the data layer is to hold, manage the data, and provide an interface for other parts to access the data. The UI layer must complete the following steps (here, an example is used to replace the translation of the original text, which feels more intuitive):

  1. The data A obtained in the data layer --> the data B that the UI can use
  2. Pass data B to UI element, such as set data to the adapter of RecyclerView.
  3. Handle user interactions with UI elements, such as a user bookmarking a piece of news
  4. Repeat 1-3 as needed

The rest of the guide describes how to implement the UI layer to accomplish the above steps, covering the following tasks and concepts:

  1. How to define UI State
  2. Generate and manage UI state with one-way data flow
  3. How to use the one-way data flow principle to expose UI state and observable data type
  4. How to implement UI to consume observable UI state

Let's take a look at the most basic problem: the definition of UI State.

Define UI State

In the news app, the UI will display a list of articles and some metadata about each article, and the information presented to the user is the UI state.
In other words: UI state determines the UI presented to the user, UI is a visual representation of UI state, and any UI state changes are immediately reflected on UI.
image.png
In order to meet the requirements of the news app, to display all the information on the UI, you can encapsulate a class NewsUiState, as follows:

 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,
    ...
)

Immutability

The UI State definition above is immutable. The advantage of this is that immutable objects ensure that no matter what the application state changes at any time, the UI state cannot be affected. This allows the UI to focus on its single responsibility: reading state and updating UI elements. Therefore, UI state should not be modified directly in the UI unless the UI itself is the only source of its data. Violating this principle can lead to multiple sources of truth for the same information, leading to data inconsistencies and subtle bugs.
For example, if in the case of NewsItemUiState the bookmarked of the object is updated in the activity class, then the markup will compete with the data layer and also compete as the data source for the bookmark state. And Immutability data class prevents this trouble.

Note: Only the data source or the owner of the data can update the data they expose.

Naming conventions in this guide

The naming convention of UI state in this guide is based on the function of the interface or the description of a certain part of the interface. The convention is as follows:
functionality + UIState
For example, the news home page in the example, the state is called NewsUiState , and the state of the news item is called NewsItemUiState .

Manage state with UDFs

The previous section established that UI state is an immutable snapshot of the UI. But the dynamic nature of data means that state can change over time. User interactions or other events modify the underlying data used to populate the application.
These interactions may be handled by a mediator, which defines the corresponding logic for each event, converts the data source format and creates the UI state. These interactions and logic may be contained in the UI itself (although the name of the UI implies only doing something here, but as the UI gets more complex, you put a lot of other code in before you know it, perfect for being a producer , owner, converter...). Soon it becomes unwieldy, a tightly coupled mixture with no discernible boundaries, which affects testability. To avoid this, unless UI State is simple, the only responsibility of the UI is to consume and display UI state.

State Holder

State Holder is called a state holder and is a class. This class is responsible for generating UI State and contains the logic required for this process. State Holders can be large or small, depending on the scope of UI elements they manage, ranging from a single widget (such as a bottom app bar) to an entire screen or navigation component.
A typical implementation is an instance of ViewModel. For example, in the news app, the NewsViewModel class is used as a state holder to generate UI state for the part displayed on the screen.

Note: ViewModel is recommended to manage screen-level UI state and access the data layer. Also, it automatically handles configuration changes. The ViewModel class defines logic to handle various events that occur in the app and generate updated state.

While there are many ways to model the interdependence between UI and state generators, since the interaction between UI and ViewModel classes can be understood to a large extent as event input and consequent state output, Its relationship can be represented as the following diagram:
image.png
The pattern in which states flow down and events flow up is called Unidirectional Data Flow (UDF). The impact of this pattern on the application architecture is as follows:

  • The ViewModel saves and provides the state required by the UI. The UI state is obtained by converting the data obtained by the ViewModel from the data layer through the ViewModel.
  • The UI feeds back user events to the ViewModel
  • ViewModel handles the above event and updates the state
  • The updated state is fed back to the UI for rendering
  • The above steps are repeated for any event that causes a state change

Repository or other use case classes are injected into the ViewModel, and the VM obtains data with their help and converts the data into UI state; in the above steps, the VM also receives events from the UI, and some events will cause state changes, and the VM must also process them these changes. In the previous example, there is a list of articles on the screen, and each article has information such as title, description, source, sitting, date, whether it has been bookmarked, etc.:
image.png
Example of a possible state change: A user wants to bookmark an article.
As a state producer, it is the responsibility of the VM to: 1. Define all required logic in order to populate all fields in the UI state 2. Handle events from the UI.
The following figure shows the flow cycle of data and events in a unidirectional data flow
image.png
In the following chapters, let's take a look at events that cause state changes and how to handle them in UDFs.

Types of logic

Bookmarking an article is a typical business logic. There are several important logical types to define here:

  • Business logic: Business logic is what to do when the state changes. For example, bookmarking an article. Business logic is usually placed in the domain layer or data layer, but must not be placed in the UI layer.
  • UI behavior logic / UI logic: How UI logic displays state changes on the screen. For example: get the correct text through Android resources and display it on the screen; open a specific page when the user clicks a button; display a message through a toast or snackbar.

UI logic should be placed in the UI (especially when it comes to the Context), not the ViewModel. If the UI becomes more and more complex, it needs to be refactored a little to delegate the UI logic to a class, which is easier to test and follows SOC principles. A simple class can be created as a state holder. Simple classes created in the UI can take on Android SDK dependencies because they follow the UI's lifecycle; ViewModel objects have a longer lifecycle.
For more information on state holders and how they fit into the context that helps build UIs, see the Jetpack Compose State guide.

Why use Unidirectional Data Flow (UDF)?

UDFs model state production cycles. It also isolates each part: where state changes originate, where transitions occur, and where final consumption occurs. This separation allows the UI to do exactly what its name implies: display information by observing state changes, and convey user intent by passing those changes to the ViewModel.
In other words, UDFs bring the following benefits:

  • Data consistency. UI has a single source of truth.
  • Testability. State sources are isolated so they can be tested independently of the UI.
  • maintainability. Mutations of state follow a well-defined pattern, and mutations are the result of user events and extracted data sources.

Announce UI state

After defining the UI state and determining how to manage the generation of this state, the next step is to present the generated state to the UI. Because the production of state is managed using UDFs, the produced state can be viewed as a stream—in other words, multiple versions of the state are produced over time. Therefore, UI state should be exposed in observable data holders such as LiveData or StateFlow. The reason for this is that the UI can react to any changes made in the state without manually pulling data directly from the ViewModel. These types also have the benefit of always caching the latest version of UI state, which is useful for quickly restoring state after configuration changes.

 class NewsViewModel(...) : ViewModel() {

    val uiState: StateFlow<NewsUiState> = …
}

For an introduction to LiveData as an observable data holder, see this cod https://developer.android.com/codelabs/basic-android-kotlin-training-livedata elab. For a similar introduction to Kotlin streams, see Kotlin Streams on Android .

Note: In a Jetpack Compose application, you can use Compose's mutableStateOf or snapshotFlow and other observable state APIs to expose UI state. Any type of observable data holder you see in this guide, such as StateFlow or LiveData, can be easily used in Compose with the appropriate extension.

If the data exposed to the UI is relatively simple, it is often desirable to wrap the data in a UI state type because it conveys the relationship between the state holder's emission and its associated UI element. In addition, as UI elements become more complex, it is easy to add the required UI states with additional information needed to render the UI elements.

A common way to create a UiState stream is to expose a supported mutable stream as an immutable stream from a ViewModel -- for example, MutableStateFlow<UiState> as StateFlow<UiState> .

 class NewsViewModel(...) : ViewModel() {

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    ...

}

The ViewModel can then expose methods that change state internally, publishing updates for the UI to consume. For example, to do asynchronous operations, you can use viewModelScope to start a coroutine, and you can update mutable state when you're done.

 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)
                 }
            }
        }
    }
}

In the code above, the NewsViewModel class attempts to fetch articles of a certain category, and then updates the UI state with the result of the attempt (whether the attempt was successful or not). See the Show errors on the screen section for more information on error handling.

Note: The pattern shown in the example above changes state through a function in the ViewModel, which is the more popular way to implement unidirectional data flow.

Other considerations

In addition to the previous sections, other notable ones are:

  • UI state objects should handle states that are related to each other.
    This reduces inconsistencies and makes the code easier to understand. If you expose the list of news items and the number of bookmarks in two different streams, you may end up with one being updated and the other not. When you use a single stream, both elements are kept up to date. Also, some business logic may require a combination of sources. For example, you might want to display a bookmark button only if the user is logged in and the user is a subscriber to a premium news service. You can define UI state classes as follows:

     data class NewsUiState(
      val isSignedIn: Boolean = false,
      val isPremium: Boolean = false,
      val newsItems: List<NewsItemUiState> = listOf()
    )
    
    val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium

    In this declaration, the visibility of the bookmark button is a derived property of the other two properties. As business logic becomes more complex, it becomes increasingly important to have a single UiState class with all properties immediately available.

  • UI state: single-stream or multi-stream?
    The key guiding principle for choosing between exposing UI state in a single flow or multiple flows is the previous bullet point: the relationship between the emitted items. The biggest advantages of single-stream exposure are convenience and data consistency: consumers of state always have up-to-date information readily available. However, in some cases a separate state flow from the ViewModel may be appropriate:

    • Unrelated data types : Some states required to render the UI may be completely independent of each other. In this case, the costs of bundling these disparate states together may outweigh the benefits, especially if one of these states is updated more frequently than the other.
    • UiState Difference : The more fields in a UiState object, the more likely the stream will be emitted because one of its fields is updated. Because the view has no distinguishing mechanism to understand whether successive firings are different or the same, each firing causes the view to update. This means that mitigations may be required using the Flow API or methods such as distinctUntilChanged() on LiveData.

Consume UI state

To use a stream of UiState objects in your UI, you can use the terminal operator to denote the type of observable data you are working with. For example, for LiveData, use the observe() method, and for Kotlin streams, use the collect() method or its variants.

When using observable data holders in your UI, be sure to take the UI's lifecycle into account. This is important because the UI should not observe UI state when the view is not being displayed to the user. To learn more about this topic, see this blog post . When using LiveData, LifecycleOwner implicitly handles lifecycle issues. When using streams, this is best handled with proper coroutine scope and the repeatOnLifecycle API:

 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
                }
            }
        }
    }
}
Note: The specific StateFlow objects used in this example do not stop doing work when there are no active collectors, but when you use flows, you may not know how they are implemented. Using lifecycle-aware flow collection allows you to make such changes to the ViewModel flow later without revisiting downstream collector code.

show loading

 data class NewsUiState(
    val isFetchingArticles: Boolean = false,
    ...
)

This flag indicates whether the UI displays a progress bar.

 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 }
            }
        }
    }
}

show error message

Displaying errors in the UI is similar to displaying loading actions, they can both be represented by boolean values that indicate their presence or absence. However, errors may also include associated messages to be forwarded to the user, or actions associated with them to retry failed operations. Therefore, when an ongoing operation is loading or not, the error state may need to be modeled using data classes that carry metadata appropriate to the error context.

For example, consider the example in the previous section that displays a progress bar while fetching articles. If this action results in an error, you may want to display one or more messages to the user detailing the problem.

 data class Message(val id: Long, val message: String)

data class NewsUiState(
    val userMessages: List<Message> = listOf(),
    ...
)

Error messages may be presented to the user in the form of UI elements such as snackbars . Because this has to do with how UI events are generated and used, see the UI Events page for more information.

threads and concurrency

Any work performed in the ViewModel should be main thread safe. This is because the data layer and domain layer are responsible for offloading work to different threads.

If the ViewModel performs long-running operations, it is also responsible for moving that logic to a background thread. Kotlin coroutines are a great way to manage concurrent operations, and Jetpack architectural components provide built-in support for them. To learn more about using coroutines in Android applications, see Kotlin coroutines on Android .

navigation

Changes to app navigation are usually driven by the emission of similar events. For example, UiState might set the isSignedIn field to true after the SignInViewModel class performs a sign-in. These triggers should be used as described in the Using UI state section above, except that the consuming implementation should follow the navigation component .

pagination

The Paging library uses a type called PagingData in the UI. Because PagingData represents and contains items that can change over time - in other words, it's not an immutable type - it shouldn't be represented in immutable UI state. Instead, you should expose it independently in the ViewModel's own stream. See the Android Paging code for a concrete example of this.

animation

To provide smooth and smooth top-level navigation transitions, you may want to wait for the second screen to load data before starting the animation. The Android view framework provides hooks to delay transitions between fragment destinations and uses the preventEnterTransition() and startPostponedEnterTransition() APIs. These APIs provide a way to ensure that UI elements (usually images fetched from the network) on the second screen are ready to display before the UI animation transitions to that screen. See the Android Motion sample for more details and implementation details.

阅读 425

Developer, Java & Android

38 声望
20 粉丝
0 条评论

Developer, Java & Android

38 声望
20 粉丝
文章目录
宣传栏