Compose + MVI + Navigation to quickly implement Android client

Keywords: Android network Design Pattern kotlin Network Protocol

Recommended by Haowen:
Author: Ricardo mjiang

preface

At the end of July this year, Google officially released the 1.0 stable version of Jetpack Compose, which shows that Google believes that Compose can be used in the production environment. I believe that the wide application of Compose is in the near future. Now should be a better time to learn Compose
After understanding the basic knowledge and principle of Compose, it should be a better way to continue learning Compose through a complete project. This article is mainly based on Compose, MVI architecture and single Activity architecture to quickly implement an Android client. If it is helpful to you, you can click Star: wanAndroid-compose

design sketch

First look at the renderings


Main implementation introduction

The specific implementation of each page can view the source code. Here we mainly introduce some main implementations and principles

Using MVI architecture

MVI is very similar to MVVM. It draws on the idea of the front-end framework and emphasizes the one-way flow of data and the only data source. The architecture is shown below

It is mainly divided into the following parts

  1. Model: different from the model in MVVM, the model of MVI mainly refers to UI State. For example, page loading status and control location are all UI states
  2. View: consistent with the view in other MVX, it may be an Activity or any UI hosting unit. The view in MVI can refresh the interface by subscribing to the changes of the Model
  3. Intent: this intent is not an intent of an Activity. Any user operation will be wrapped as an intent and sent to the Model layer for data request

For example, the Model and Intent of the login page are defined as follows

/**
* Page all States
/
data class LoginViewState(
    val account: String = "",
    val password: String = "",
    val isLogged: Boolean = false
)

/**
 * One time event
 */
sealed class LoginViewEvent {
    object PopBack : LoginViewEvent()
    data class ErrorMessage(val message: String) : LoginViewEvent()
}

/**
* page Intent,That is, the user's operation
/
sealed class LoginViewAction {
    object Login : LoginViewAction()
    object ClearAccount : LoginViewAction()
    object ClearPassword : LoginViewAction()
    data class UpdateAccount(val account: String) : LoginViewAction()
    data class UpdatePassword(val password: String) : LoginViewAction()
}

As shown above

  1. Define all States of the page through ViewState
  2. ViewEvent defines one-time events, such as Toast, page closing event, etc
  3. Define all user actions through ViewAction

The main differences between MVI architecture and MVVM architecture are:

  1. MVVM does not restrict the interaction between the View layer and the ViewModel. Specifically, the View layer can call the methods in the ViewModel at will. The implementation of ViewModel under MVI architecture shields the View layer and can only drive events by sending Intent.
  2. Multiple states are defined in MVVM's viewmode. MVI uses ViewState to centrally manage states. It only needs to subscribe to one ViewState to obtain all State s of the page, which reduces a lot of template code compared with MVVM

The declarative UI idea of Compose comes from React. In theory, MVI, which also comes from Redux's idea, should be the best partner of Compose
However, MVI has only made some improvements on the basis of MVVM. MVVM can also be used well with Compose. You can choose the appropriate architecture according to your own needs

For the architecture selection of Compose, please refer to: How to select the Jetpack Compose architecture? MVP, MVVM, MVI

Single Activity Architecture

As early as the View era, there were many articles recommending the single Activity + multi Fragment architecture, and Google also launched the Jetpack Navigation library to support this single Activity architecture
For composition, because Activity and composition are transferred through AndroidComposeView, the more activities, the more AndroidComposeView needs to be created, which has a certain impact on performance
Using the single Activity architecture, all page transitions are completed within Compose. It may be for this reason. At present, Google's sample projects are based on the single Activity+Navigation + multi Compose architecture

However, using the single Activity architecture also needs to solve some problems

  1. All viewmodels are in the ViewModelStoreOwner of an Activity. When a page is destroyed, when should the used viewModel of the page be destroyed?
  2. Sometimes a page needs to monitor the onResume, onPause and other life cycles of its own page. How to monitor the life cycle under a single Activity architecture?

Let's take a look at how to solve these two problems under the single Activity architecture

When is the page ViewModel destroyed?

In Compose, you can generally obtain the ViewModel in the following two ways

//Mode 1   
@Composable
fun LoginPage(
    loginViewModel: LoginViewModel = viewModel()
) {
	//...
}

//Mode 2   
@Composable
fun LoginPage(
    loginViewModel: LoginViewModel = hiltViewModel()
) {
	//...
}

As shown above:

  1. Method 1 will return a ViewModel bound to viewmodelstoreowner (usually Activity or Fragment). If it does not exist, it will be created. If it already exists, it will be returned directly. Obviously, the life cycle of the ViewModel created in this way will be consistent with the Activity. It will always exist in the single Activity architecture and will not be released.
  2. Method 2 is implemented through the Hilt. You can obtain the ViewModel of NavGraph Scope or Destination Scope in Composable and automatically rely on the Hilt to build. The ViewModel of Destination Scope will automatically Clear following the pop-up of BackStack to avoid leakage.

In general, it is a better choice to cooperate with Navigation through hiltViewModel

How does Compose get the lifecycle?

In order to get the life cycle in Compose, we need to understand side effect
Side effects can be summarized in one sentence: during the execution of a function, in addition to returning the function value, it will also have other additional effects on the caller, such as modifying global variables or parameters.

Side effects must be implemented at the right time. First, we need to clarify the life cycle of Composable:

  1. onActive (or onEnter): when Composable enters the component tree for the first time
  2. onCommit (or onUpdate): when the UI is updated with recomposition
  3. onDispose (or onLeave): when Composable is removed from the component tree

After understanding the life cycle of Compose, we can find that if we listen to the life cycle of Activity when onActive and cancel listening when onDispose, we can obtain the life cycle in Compose?
DisposableEffect can help us realize this requirement. DisposableEffect will be executed when the Key it listens to changes or onDispose
We can also add parameters to make it execute only when onActive and onDispose are used: for example, DisposableEffect(true) or DisposableEffect(Unit)

You can monitor the page life cycle in Compose in the following ways

@Composable
fun LoginPage(
    loginViewModel: LoginViewModel = hiltViewModel()
) {
    val lifecycleOwner = LocalLifecycleOwner.current
    DisposableEffect(key1 = Unit) {
        val observer = object : LifecycleObserver {
            @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
            fun onResume() {
                viewModel.dispatch(Action.Resume)
            }

            @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
            fun onPause() {
                viewModel.dispatch(Action.Pause)
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }

    }
}

Of course, sometimes it doesn't need to be so complicated. For example, we need to refresh the login status when entering or returning to the ProfilePage page and confirm the page UI according to the login status, which can be realized in the following ways

@Composable
fun ProfilePage(
    navCtrl: NavHostController,
    scaffoldState: ScaffoldState,
    viewModel: ProfileViewModel = hiltViewModel()
) {
    //...

    DisposableEffect(Unit) {
        Log.i("debug", "onStart")
        viewModel.dispatch(ProfileViewAction.OnStart)
        onDispose {
        }
    }
}    

As shown above, whenever we enter or return to the page, we can refresh the login status of the page

How does Compose save the LazyColumn list state

I believe that students who have used LazyColumn have encountered the following problems

Use Paging3 to load paging data and display it on the LazyColumn of page A, slide the LazyColumn down, then navigation.navigator jumps to page B, then navigatUp returns to page A, and the LazyColumn of page A returns to the top of the list

The main reason for this problem in LazyColumn is that the parameter LazyListState used to record the scroll position is not persisted. When you return to page A, the LazyListState data changes to the default value of 0 again and naturally returns to the top, as shown in the following figure

Since the reason is that LazyListState is not saved, we can save LazyListSate in ViewModel, as shown below

@HiltViewModel
class SquareViewModel @Inject constructor(
    private var service: HttpService,
) : ViewModel() {
    private val pager by lazy { simplePager { service.getSquareData(it) }.cachedIn(viewModelScope) }
    val listState: LazyListState = LazyListState()
}

@Composable
fun SquarePage(
    navCtrl: NavHostController,
    scaffoldState: ScaffoldState,
    viewModel: SquareViewModel = hiltViewModel()
) {
    val squareData = viewStates.pagingData.collectAsLazyPagingItems()
    // val listState = viewStates.listState / / generally, this is enough
    // For special processing when using 'Paging', you can generally directly use viewStates.listState    
    val listState = if (squareData.itemCount > 0) viewStates.listState else LazyListState()

    RefreshList(squareData, listState = listState) {
        itemsIndexed(squareData) { _, item ->
           //...
        }
    }
}

It should be noted that for general pages, you can directly use viewModel.listState. However, when I use paying, I find that when returning the page, the itemCount of Paging will temporarily change to 0, resulting in the listState becoming 0, so some special processing needs to be done
For a more detailed discussion on the loss of LazyColumn scrolling, please refer to: Scroll position of LazyColumn built with collectAsLazyPagingItems is lost when using Navigation

Posted by abo28 on Wed, 24 Nov 2021 00:31:08 -0800