This is the second day of my participation in Gwen Challenge
With the announcement of Compose 1.0 coming out at THE I/O conference, the API level has stabilized, but a proper application architecture is still needed to get the project off the ground. Are traditional Android architectures like MVP and MVVM still usable in this new species of declarative UI?
Using a simple business scenario as an example, this article tries to find an architectural pattern that best fits into Compose
Sample : Wanandroid Search
Basic functions of the App: Users enter keywords, search for relevant content on wanAndroid website and display it
Although the function is simple, but the collection of data request, UI display and other common business scenarios, can be used to do the decoupling experiment of UI layer and logic layer.
Preparation: Model layer
No matter how X changes in MVX, the Model can be implemented in the same way. Let’s start by defining a DataRepository that gets search results from WanAndroid. The Model layer in Sample below is based on this Repo implementation
@ViewModelScoped
class DataRepository @Inject constructor() {private val okhttpClient by lazy {
OkHttpClient.Builder().build()
}
private val apiService by lazy {
Retrofit.Builder()
.baseUrl("https://www.wanandroid.com/")
.client(okhttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build().create(ApiService::class.java)
}
suspend fun getArticlesList(key: String) =
apiService.getArticlesList(key)
}
Copy the code
Why does Compose need an architecture?
First, let’s look at what the Compose code looks like without any architecture.
In the absence of an architecture, the logical code is coincidently coupled with the UI code, and this disadvantage is particularly evident in Compose. Regular Android development defaults to MVC, and the XML layout provides a preliminary decoupling between the UI layer and the logic layer. In Compose, however, both the layout and the logic are implemented using Kotlin, and when the layout is mixed with hybrid logic, the boundaries become more blurred.
In addition, the incorporation of logical code into the Compose UI brings additional potential pitfalls. As the Composable is frequently reorganized, logical code involving I/O must be treated as SideEffect{}, and some objects that cannot be frequently created with the Composable must also be saved with remember{}. When these logic are scattered in the UI, the mental burden of developers will be increased. Omissions can easily occur.
The business scenario of Sample is very simple, and a few remember{} and LaunchedEffect{} appear in the UI seems to be ok. For some relatively simple business scenarios, there is no problem with the following codes:
@Composable
fun NoArchitectureResultScreen(
answer: String
) {
val isLoading = remember { mutableStateOf(false)}val dataRepository = remember { DataRepository() }
var result: List<ArticleBean> by remember { mutableStateOf(emptyList()) }
LaunchedEffect(Unit) {
isLoading.value = true
result = withContext(Dispatchers.IO) { dataRepository.getArticlesList(answer).data.datas }
isLoading.value = false
}
SearchResultScreen(result, isLoading.value , answer)
}
Copy the code
However, when the business is complex enough, you will find such code unbearable. As in React front-end development, Hooks provide logic handling capabilities, but they do not replace Redux.
MVP, MVVM, and MVI are some common architectural patterns in Android. They all aim to decoupled the UI layer from the logical layer, but their decoupling methods are different. How to choose depends on the preferences of the user and the characteristics of the project
“There is no best architecture, only the most appropriate architecture.”
So what architecture is most appropriate for the Compose project?
MVP
The main feature of MVP is that the Presenter communicates with the View through the interface, and the Presenter updates the UI by calling the View method.
This requires Presenter to hold a reference to a View layer object, but Compose obviously cannot obtain this reference because the Composable used to create the UI must return Unit as follows:
@Composable
fun HomeScreen(a) {
Column {
Text("Hello World!")}}Copy the code
The official documentation also explicitly constrains the requirement for no return value:
Compose The function doesn’t return anything. Compose functions that emit UI do not need to return anything, Because they describe the desired screen state home constructing UI widgets. Developer.android.com/jetpack/com…
Compose UI is composed in the Android system. The Compose UI must have a starting point to connect to the Android world. The starting point may be an Activity or Fragment.
This is possible in theory, but when an Activity receives a Presenter notification, it still can’t get a local reference internally. Instead, it tries to trigger a global Recomposition, which completely loses the MVP advantage of getting a local reference to refresh accurately.
The analysis leads to the conclusion: “MVP decoupling, which relies on interface communication, cannot be used in the Compose project.”
MVVM (without Jetpack)
In contrast to the INTERFACE communication of the MVP, the MVVM communicates based on observer mode, updating itself when the UI observes changes in data from the ViewModle. It no longer matters whether the UI layer can return a reference handle, which fits nicely with the way Compose works.
Since Android named a Jetpack component after the ViewModel, Jetpack seems to be synonymous with MVVM in many people’s minds. This has certainly helped the popularity of MVVM, but Jetpack’s ViewModel is not limited to MVVM (for example, MVI, as described below); Conversely, MVVM can be implemented without Jetpack.
How does MVVM work without Jetpack?
Create a ViewModel in the Activity
First the View layer creates the ViewModel for the subscription
class MvvmActivity : AppCompatActivity() {
private val mvvmViewModel = MvvmViewModel(DataRepository())
override fun onCreate(savedInstanceState: Bundle?). {
super.onCreate(savedInstanceState)
setContent {
ComposePlaygroundTheme {
MvvmApp(mvvmViewModel) // Pass the VM to the Composable}}}}Copy the code
The Compose project typically uses a single Activity structure, and the Activity is a good global entry point for creating a global ViewModel. The sub-compoables need to communicate with each other based on the ViewModel, so the ViewModel is passed in as a parameter when building the Composable.
The ViewModel we created in the Activity in Sample is only passed to the MvvmApp for use. In this case, we can also pass Lazy
to delay the creation until it is really needed to improve performance.
Define NavGraph
When it comes to the Compose page switch, navigation-compose is a good choice, and the Sample has specially designed the switch scenario for SearchBarScreen and SearchResultScreen
// build.gradle
implementation "androidx.navigation:navigation-compose:$latest_version"
Copy the code
@Composable
fun MvvmApp(
mvvmViewModel: MvvmViewModel
) {
val navController = rememberNavController()
LaunchedEffect(Unit) {
mvvmViewModel.navigateToResults
.collect {
navController.navigate("result") // Subscribe to VM routing event notifications to handle route jumps
}
}
NavHost(navController, startDestination = "searchBar") {
composable("searchBar") {
MvvmSearchBarScreen(
mvvmViewModel,
)
}
composable("result") {
MvvmSearchResultScreen(
mvvmViewModel,
)
}
}
}
Copy the code
-
NavGraph (composable(“$dest_id”){}){ViewModel (composable(“$dest_id”){}){})
-
Each Composable has a CoroutineScope bound to its Lifecycle and LaunchedEffect{} can initiate coroutines to handle side effects within this Scope. A once-only Effect subscription ViewModel route event notification is used in the code
-
Of course, we can also pass the navConroller to MvvmSearchBarScreen and initiate the route jump directly inside it. However, in more complex projects, the jump logic and page definition should be decoupled as much as possible, which is better for page reuse and testing.
-
We could also create states directly mutableStateOf() in Composeable to handle route jumps, but since ViewModel has been chosen, we should try to centralize all state into ViewModle management if possible.
Note that navigateToResults, which handles route jumps in the example above, is an “event” rather than a “state”, which will be explained later
Defines the Screen
Let’s look at two concrete implementations of Screens
@Composable
fun MvvmSearchBarScreen(
mvvmViewModel: MvvmViewModel.) {
SearchBarScreen {
mvvmViewModel.searchKeyword(it)
}
}
@Composable
fun MvvmSearchResultScreen(
mvvmViewModel: MvvmViewModel
) {
val result by mvvmViewModel.result.collectAsState()
val isLoading by mvvmViewModel.isLoading.collectAsState()
SearchResultScreen(result, isLoading, mvvmViewModel.key.value)
}
Copy the code
A lot of logic is abstracted into the ViewModel, so Screen is very clean
-
The SearchBarScreen accepts user input and sends the search keywords to the ViewModel
-
MvvmSearchResultScreen serves as the result page to display the data sent by the ViewModel, including Loading state and search results.
-
CollectAsState Is the state used to convert a Flow to Compose, which triggers a Composable reorganization whenever the Flow receives new data. Compose also supports collectAsState from other responsive libraries such as LiveData and RxJava
For more information on the UI layer, see the source code for SearchBarScreen and SearchResultScreen. After logic extraction, only the layout-related code remains for the two Composables, which can be reused in either MVX.
The ViewModel implementation
Finally, look at the implementation of the ViewModel
class MvvmViewModel(
private val searchService: DataRepository,
) {
private val coroutineScope = MainScope()
private val _isLoading: MutableStateFlow<Boolean> = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
private val _result: MutableStateFlow<List<ArticleBean>> = MutableStateFlow(emptyList())
val result = _result.asStateFlow()
private val _key = MutableStateFlow("")
val key = _key.asStateFlow()
// Use Channel to define events
private val _navigateToResults = Channel<Boolean>(Channel.BUFFERED)
val navigateToResults = _navigateToResults.receiveAsFlow()
fun searchKeyword(input: String) {
coroutineScope.launch {
_isLoading.value = true
_navigateToResults.send(true)
_key.value = input
val result = withContext(Dispatchers.IO) { searchService.getArticlesList(input) }
_result.emit(result.data.datas)
_isLoading.value = false}}}Copy the code
-
After receiving user input, a search request is made through the DataRepository
-
During the search process, loading (loading displays state), navigateToResult(page jump event), key (search keyword), result (search result) and other contents are successively updated to constantly drive UI refresh
All states are centrally managed by the ViewModel, and even events such as page jumps and Toast pop-ups are notified by the ViewModel, which is very friendly for unit testing and eliminates the need to mock various UI-related contexts in a single test.
Jetpack MVVM
The point of Jeptack is to reduce the implementation cost of MVVM on Android.
The code hasn’t changed much since Jetpack was introduced, with the main change being the creation of the ViewModel.
Jetpack provides several components that reduce the cost of using the ViewModel:
- The DI of HILt reduces the cost of ViewModel construction without manually passing in dependencies such as DataRepository
- Any Composable can fetch the ViewModel from the nearest Scope without layering parameters.
@HiltViewModel
class JetpackMvvmViewModel @Inject constructor(
private val searchService: DataRepository // DataRepository relies on DI injection
) : ViewModel() {
...
}
Copy the code
@Composable
fun JetpackMvvmApp(a) {
val navController = rememberNavController()
NavHost(navController, startDestination = "searchBar", route = "root") {
composable("searchBar") {
JetpackMvvmSearchBarScreen(
viewModel(navController, "root") // The viewModel can be retrieved as needed, without having to implement the creation and pass it in as an argument
)
}
composable("result") {
JetpackMvvmSearchResultScreen(
viewModel(navController, "root") // You can get the same ViewModel instance)}}}Copy the code
@Composable
inline fun <reified VM : ViewModel> viewModel(
navController: NavController,
graphId: String = ""
): VM =
// Create the ViewModel in NavGraph global scope using Hilt
hiltNavGraphViewModel(
backStackEntry = navController.getBackStackEntry(graphId)
)
Copy the code
Jetpack even provides a Hilt-navigation-compose library that can retrieve viewmodels of NavGraph scopes or Destination scopes ina Composable and automatically rely on HILT to build. Destination Scope’s ViewModel will automatically Clear after BackStack’s pop-up, avoiding leaks.
// build.gradle
implementation androidx.hilt:hilt-navigation-compose:$latest_versioin
Copy the code
“Synergies between Jetpack components will become stronger and stronger in the future.”
MVI
MVI is similar to MVVM in that it draws on the ideas of the front-end framework and emphasizes the one-way flow of data and the unique data source, which can be regarded as the combination of MVVM + Redux.
The “I” in MVI refers to an Intent, not the Intent that starts an Activity, but rather an encapsulation of the user’s Action, which can also be called an Action or other name to avoid confusion. User actions are sent to the Model layer as actions for processing. In code, we can use Jetpack’s ViewModel to handle the acceptance and handling of intents because viewModels are easily accessible in the Composable.
After the SearchBarScreen user enters a keyword, Action notifies the ViewModel to search
@Composable
fun MviSearchBarScreen(
mviViewModel: MviViewModel,
onConfirm: () -> Unit
) {
SearchBarScreen {
mviViewModel.onAction(MviViewModel.UiAction.SearchInput(it))
}
}
Copy the code
Communicating through actions helps further decouple the View from the ViewModel, and all calls are aggregated into one place in the form of actions to facilitate centralized analysis and monitoring of behavior
@Composable
fun MviSearchResultScreen(
mviViewModel: MviViewModel
) {
val viewState by mviViewModel.viewState.collectAsState()
SearchResultScreen(
viewState.result, viewState.isLoading, viewState.key
)
}
Copy the code
MVVM’s ViewModle defines multiple states dispersively. MVI uses ViewState to centrally manage states. It only needs to subscribe to a ViewState to obtain all states of a page, which reduces a lot of template code compared with MVVM.
The ViewModel also has some changes relative to MVVM
class MviViewModel(
private val searchService: DataRepository,
) {
private val coroutineScope = MainScope()
private val _viewState: MutableStateFlow<ViewState> = MutableStateFlow(ViewState())
val viewState = _viewState.asStateFlow()
private val _navigateToResults = Channel<OneShotEvent>(Channel.BUFFERED)
val navigateToResults = _navigateToResults.receiveAsFlow()
fun onAction(uiAction: UiAction) {
when (uiAction) {
is UiAction.SearchInput -> {
coroutineScope.launch {
_viewState.value = _viewState.value.copy(isLoading = true)
val result =
withContext(Dispatchers.IO) { searchService.getArticlesList(uiAction.input) }
_viewState.value =
_viewState.value.copy(result = result.data.datas, key = uiAction.input)
_navigateToResults.send(OneShotEvent.NavigateToResults)
_viewState.value = _viewState.value.copy(isLoading = false)}}}}data class ViewState(
val isLoading: Boolean = false.val result: List<ArticleBean> = emptyList(),
val key: String = ""
)
sealed class OneShotEvent {
object NavigateToResults : OneShotEvent()
}
sealed class UiAction {
class SearchInput(val input: String) : UiAction()
}
}
Copy the code
-
All state of a page is defined in the data class ViewState, and state modification can only be done in onAction. Other sites are immutable, ensuring that data streams can only be modified in one direction. In MVVM, MutableStateFlow is converted to immutable when exposed to ensure this security, which requires a lot of template code and is still easy to miss.
-
Events are uniformly defined in OneShotEvent. Event differs from State in that an Event of the same type can be responded to more than once, so define events to use channels instead of StateFlow.
Compose encourages more State and less Event, which is suitable for a few scenarios such as toasts
A ViewModel’s responsibilities can be clarified by looking at its ViewState and Aciton definitions, and can be used directly as an interface document.
Page routing
One of the main reasons why the Sample uses events rather than states to handle route jumps is because of Navigation. Navigation has its own backstack management and will automatically return us to the previous page when the back key is clicked. If we use state to describe the current page, there is no chance to update the state when we click back, which will result in a mismatch between ViewState and UI.
Suggestions on routing schemes: For simple projects, it is ok to use events to control page redirection, but for complex projects, it is recommended to use state for page management, so that the logical layer can be aware of the current UI state at any time.
We can synchronize the state of the NavController backstack with the state of the ViewModel:
class MvvmViewModel(
private val searchService: DataRepository,
) {
...
// Use StateFlow to describe the page
private val _destination = MutableStateFlow(DestSearchBar)
val destination = _destination.asStateFlow()
fun searchKeyword(input: String){ coroutineScope.launch { ... _destination.value = DestSearchResult ... }}fun bindNavStack(navController: NavController) {
// The state of navigation is synchronized to the viewModel at any timenavController.addOnDestinationChangedListener { _, _, arguments -> run { _destination.value = requireNotNull(arguments? .getString(KEY_ROUTE)) } } } }Copy the code
As mentioned above, when the navigation state changes, it is synchronized to the ViewModel so that the page state can be described using StateFlow instead of Channel.
@Composable
fun MvvmApp(
mvvmViewModel: MvvmViewModel
) {
val navController = rememberNavController()
LaunchedEffect(Unit) {
with(mvvmViewModel) {
bindNavStack(navController) // Establish synchronization
destination
.collect {
navController.navigate(it)
}
}
}
}
Copy the code
At the entrance, create a synchronous binding for the NavController and ViewModel.
Clean Architecture
In larger projects, Clean Architecture will be introduced to further decompose the logic within the ViewModel through Use cases. Compose is just a UI framework that governs the logical layer below ViewModle in the same way that traditional Andorid development does. So complex architectures like Clean Architecture can still be used in the Compose project
conclusion
Considering the many architectures, which one fits in best with Compose?
Compose’s declarative UI comes from React, so MVI, which also comes from Redux, should be Compose’s perfect companion. Of course, MVI is just an improvement on MVVM. If you already have an MVVM project and want to make the UI part of Compose, there is no need to refactor to make it into MVI. MVVM also works well with Compose. But if you’re trying to convert an MVP project into Compose, it might be a little expensive.
As for Jetpack, if your project is only working on Android, Jetpack is a great tool. However, Compose will have a wide range of applications in the future, and if you expect to develop cross-platform applications with KMP in the future, you’ll need to learn how to develop without Jetpack, which is one of the reasons why this article introduces MVVM without Jetpack.
The Sample code
Github.com/vitaviva/Je…