Migrating an Android App from MVP to Jetpack Compose: A Step-by-Step Guide
Jetpack Compose is Android’s modern toolkit for building native UI. It simplifies and accelerates UI development by using a declarative approach, which is a significant shift from the traditional imperative XML-based layouts. If you have an existing Android app written in Kotlin using the MVP (Model-View-Presenter) pattern with XML layouts, fragments, and activities, migrating to Jetpack Compose can bring numerous benefits, including improved developer productivity, reduced boilerplate code, and a more modern UI architecture.
In this article, we’ll walk through the steps to migrate an Android app from MVP with XML layouts to Jetpack Compose. We’ll use a basic News App to explain in detail how to migrate all layers of the app. The app has two screens:
- A News List Fragment to display a list of news items.
- A News Detail Fragment to show the details of a selected news item.
We’ll start by showing the original MVP implementation, including the Presenters, and then migrate the app to Jetpack Compose step by step. We’ll also add error handling, loading states, and use Kotlin Flow instead of LiveData for a more modern and reactive approach.
1. Understand the Key Differences
Before diving into the migration, it’s essential to understand the key differences between the two approaches:
- Imperative vs. Declarative UI: XML layouts are imperative, meaning you define the UI structure and then manipulate it programmatically. Jetpack Compose is declarative, meaning you describe what the UI should look like for any given state, and Compose handles the rendering.
- MVP vs. Compose Architecture: MVP separates the UI logic into Presenters and Views. Jetpack Compose encourages a more reactive and state-driven architecture, often using ViewModel and State Hoisting.
- Fragments and Activities: In traditional Android development, Fragments and Activities are used to manage UI components. In Jetpack Compose, you can replace most Fragments and Activities with composable functions.
2. Plan the Migration
Migrating an entire app to Jetpack Compose can be a significant undertaking. Here’s a suggested approach:
- Start Small: Begin by migrating a single screen or component to Jetpack Compose. This will help you understand the process and identify potential challenges.
- Incremental Migration: Jetpack Compose is designed to work alongside traditional Views, so you can migrate your app incrementally. Use
ComposeView
in XML layouts orAndroidView
in Compose to bridge the gap. - Refactor MVP to MVVM: Jetpack Compose works well with the MVVM (Model-View-ViewModel) pattern. Consider refactoring your Presenters into ViewModels.
- Replace Fragments with Composable Functions: Fragments can be replaced with composable functions, simplifying navigation and UI management.
- Add Error Handling and Loading States: Ensure your app handles errors gracefully and displays loading states during data fetching.
- Use Kotlin Flow: Replace LiveData with Kotlin Flow for a more modern and reactive approach.
3. Set Up Jetpack Compose
Before starting the migration, ensure your project is set up for Jetpack Compose:
- Update Gradle Dependencies:
Add the necessary Compose dependencies to yourbuild.gradle
file:android { ... buildFeatures { compose true } composeOptions { kotlinCompilerExtensionVersion '1.5.3' } } dependencies { implementation 'androidx.activity:activity-compose:1.8.0' implementation 'androidx.compose.ui:ui:1.5.4' implementation 'androidx.compose.material:material:1.5.4' implementation 'androidx.compose.ui:ui-tooling-preview:1.5.4' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2' implementation 'androidx.navigation:navigation-compose:2.7.4' // For navigation implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2' // For Flow implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' // For Flow }
- Enable Compose in Your Project:
Ensure your project is using the correct Kotlin and Android Gradle plugin versions.
4. Original MVP Implementation
a. News List Fragment and Presenter
The NewsListFragment
displays a list of news items. The NewsListPresenter
fetches the data and updates the view.
NewsListFragment.kt
class NewsListFragment : Fragment(), NewsListView {
private lateinit var presenter: NewsListPresenter
private lateinit var adapter: NewsListAdapter
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_news_list, container, false)
val recyclerView = view.findViewById<RecyclerView>(R.id.recyclerView)
adapter = NewsListAdapter { newsItem -> presenter.onNewsItemClicked(newsItem) }
recyclerView.adapter = adapter
recyclerView.layoutManager = LinearLayoutManager(context)
presenter = NewsListPresenter(this)
presenter.loadNews()
return view
}
override fun showNews(news: List<NewsItem>) {
adapter.submitList(news)
}
override fun showLoading() {
// Show loading indicator
}
override fun showError(error: String) {
// Show error message
}
}
NewsListPresenter.kt
class NewsListPresenter(private val view: NewsListView) {
fun loadNews() {
view.showLoading()
// Simulate fetching news from a data source (e.g., API or local database)
try {
val newsList = listOf(
NewsItem(id = 1, title = "News 1", summary = "Summary 1"),
NewsItem(id = 2, title = "News 2", summary = "Summary 2")
)
view.showNews(newsList)
} catch (e: Exception) {
view.showError(e.message ?: "An error occurred")
}
}
fun onNewsItemClicked(newsItem: NewsItem) {
// Navigate to the news detail screen
val intent = Intent(context, NewsDetailActivity::class.java).apply {
putExtra("newsId", newsItem.id)
}
startActivity(intent)
}
}
NewsListView.kt
interface NewsListView {
fun showNews(news: List<NewsItem>)
fun showLoading()
fun showError(error: String)
}
b. News Detail Fragment and Presenter
The NewsDetailFragment
displays the details of a selected news item. The NewsDetailPresenter
fetches the details and updates the view.
NewsDetailFragment.kt
class NewsDetailFragment : Fragment(), NewsDetailView {
private lateinit var presenter: NewsDetailPresenter
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_news_detail, container, false)
presenter = NewsDetailPresenter(this)
val newsId = arguments?.getInt("newsId") ?: 0
presenter.loadNewsDetail(newsId)
return view
}
override fun showNewsDetail(newsItem: NewsItem) {
view?.findViewById<TextView>(R.id.title)?.text = newsItem.title
view?.findViewById<TextView>(R.id.summary)?.text = newsItem.summary
}
override fun showLoading() {
// Show loading indicator
}
override fun showError(error: String) {
// Show error message
}
}
NewsDetailPresenter.kt
class NewsDetailPresenter(private val view: NewsDetailView) {
fun loadNewsDetail(newsId: Int) {
view.showLoading()
// Simulate fetching news detail from a data source (e.g., API or local database)
try {
val newsItem = NewsItem(id = newsId, title = "News $newsId", summary = "Summary $newsId")
view.showNewsDetail(newsItem)
} catch (e: Exception) {
view.showError(e.message ?: "An error occurred")
}
}
}
NewsDetailView.kt
interface NewsDetailView {
fun showNewsDetail(newsItem: NewsItem)
fun showLoading()
fun showError(error: String)
}
5. Migrate to Jetpack Compose
a. Migrate the News List Fragment
Replace the NewsListFragment
with a composable function. The NewsListPresenter
will be refactored into a NewsListViewModel
.
NewsListScreen.kt
@Composable
fun NewsListScreen(viewModel: NewsListViewModel, onItemClick: (NewsItem) -> Unit) {
val newsState by viewModel.newsState.collectAsState()
when (newsState) {
is NewsState.Loading -> {
// Show loading indicator
CircularProgressIndicator()
}
is NewsState.Success -> {
val news = (newsState as NewsState.Success).news
LazyColumn {
items(news) { newsItem ->
NewsListItem(newsItem = newsItem, onClick = { onItemClick(newsItem) })
}
}
}
is NewsState.Error -> {
// Show error message
val error = (newsState as NewsState.Error).error
Text(text = error, color = Color.Red)
}
}
}
@Composable
fun NewsListItem(newsItem: NewsItem, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable { onClick() }
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = newsItem.title, style = MaterialTheme.typography.h6)
Text(text = newsItem.summary, style = MaterialTheme.typography.body1)
}
}
}
NewsListViewModel.kt
class NewsListViewModel : ViewModel() {
private val _newsState = MutableStateFlow<NewsState>(NewsState.Loading)
val newsState: StateFlow<NewsState> get() = _newsState
init {
loadNews()
}
private fun loadNews() {
viewModelScope.launch {
_newsState.value = NewsState.Loading
try {
// Simulate fetching news from a data source (e.g., API or local database)
val newsList = listOf(
NewsItem(id = 1, title = "News 1", summary = "Summary 1"),
NewsItem(id = 2, title = "News 2", summary = "Summary 2")
)
_newsState.value = NewsState.Success(newsList)
} catch (e: Exception) {
_newsState.value = NewsState.Error(e.message ?: "An error occurred")
}
}
}
}
sealed class NewsState {
object Loading : NewsState()
data class Success(val news: List<NewsItem>) : NewsState()
data class Error(val error: String) : NewsState()
}
b. Migrate the News Detail Fragment
Replace the NewsDetailFragment
with a composable function. The NewsDetailPresenter
will be refactored into a NewsDetailViewModel
.
NewsDetailScreen.kt
@Composable
fun NewsDetailScreen(viewModel: NewsDetailViewModel) {
val newsState by viewModel.newsState.collectAsState()
when (newsState) {
is NewsState.Loading -> {
// Show loading indicator
CircularProgressIndicator()
}
is NewsState.Success -> {
val newsItem = (newsState as NewsState.Success).news
Column(modifier = Modifier.padding(16.dp)) {
Text(text = newsItem.title, style = MaterialTheme.typography.h4)
Text(text = newsItem.summary, style = MaterialTheme.typography.body1)
}
}
is NewsState.Error -> {
// Show error message
val error = (newsState as NewsState.Error).error
Text(text = error, color = Color.Red)
}
}
}
NewsDetailViewModel.kt
class NewsDetailViewModel : ViewModel() {
private val _newsState = MutableStateFlow<NewsState>(NewsState.Loading)
val newsState: StateFlow<NewsState> get() = _newsState
fun loadNewsDetail(newsId: Int) {
viewModelScope.launch {
_newsState.value = NewsState.Loading
try {
// Simulate fetching news detail from a data source (e.g., API or local database)
val newsItem = NewsItem(id = newsId, title = "News $newsId", summary = "Summary $newsId")
_newsState.value = NewsState.Success(newsItem)
} catch (e: Exception) {
_newsState.value = NewsState.Error(e.message ?: "An error occurred")
}
}
}
}
sealed class NewsState {
object Loading : NewsState()
data class Success(val news: NewsItem) : NewsState()
data class Error(val error: String) : NewsState()
}
6. Set Up Navigation
Replace Fragment-based navigation with Compose navigation:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
NewsApp()
}
}
}
@Composable
fun NewsApp() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "newsList") {
composable("newsList") {
val viewModel: NewsListViewModel = viewModel()
NewsListScreen(viewModel = viewModel) { newsItem ->
navController.navigate("newsDetail/${newsItem.id}")
}
}
composable("newsDetail/{newsId}") { backStackEntry ->
val viewModel: NewsDetailViewModel = viewModel()
val newsId = backStackEntry.arguments?.getString("newsId")?.toIntOrNull() ?: 0
viewModel.loadNewsDetail(newsId)
NewsDetailScreen(viewModel = viewModel)
}
}
}
7. Test and Iterate
After migrating the screens, thoroughly test the app to ensure it behaves as expected. Use Compose’s preview functionality to visualize your UI:
@Preview(showBackground = true)
@Composable
fun PreviewNewsListScreen() {
NewsListScreen(viewModel = NewsListViewModel(), onItemClick = {})
}
@Preview(showBackground = true)
@Composable
fun PreviewNewsDetailScreen() {
NewsDetailScreen(viewModel = NewsDetailViewModel())
}
8. Gradually Migrate the Entire App
Once you’re comfortable with the migration process, continue migrating the rest of your app incrementally. Use ComposeView
and AndroidView
to integrate Compose with existing XML