How I build a clean architecture RSS Feed Reader Android app using Kotlin and Jetpack Compose?
This is the very first Jetpack Compose Android app that I built. It is a simple app that reads my blog's rss.xml and stores them in a local database. You can bookmark articles, mark articles as read, share articles and search articles by title. It shows the full article content within the app.
High-level Architecture
This is the high-level architecture design which is based on MVVM / recommended Android app architecture.
As you may already know, the UI event flows downward and data flows upward through callback or Flow
. The dependency direction is also one way from UI layer to Data layer.
The following table summarizes the responsibility of all the components in UI, domain and data layers.
UI Layer | Responsibility |
---|---|
MainActivity |
Constructs MainViewModel and all its dependencies such as ArticlesRepositoryImpl , ArticlesDatabase and WebService
|
MainScreen |
Setup top bar and bottom bar navigation, build navigation graph, setup snack bar UI display |
HomeScreen |
Acts as start destination screen which lists all the articles from rss.xml. Provides the ability to bookmark, share, mark as unread on each article, add search articles feature at top bar |
UnreadScreen |
Lists all unread articles here |
BookmarkScreen |
Lists all bookmarked articles here |
SearchScreen |
Shows the article search results |
MainViewModel |
Provides UI states (data needed by all the composable functions), collect flows from ArticlesRepository , refresh the articles in ArticlesRepository
|
Domain Layer | Responsibility |
---|---|
ArticlesRepository |
Acts as interface between UI layer and data layer. Provides domain data model (articles information) to the UI layer through Flow
|
Data Layer | Responsibility |
---|---|
ArticlesRepositoryImpl |
Implements the ArticlesRepository interface, fetches articles from WebService and write into the ArticlesDatabase , map and transform local data to domain data |
ArticlesDatabase |
Implements local RoomDatabase which acts as single source of truth |
WebServce |
Fetches XML string using ktor client , parses the XML feed and converts the XML to remote data (which is transformed to local data for local database writing) |
Important note: This app is under heavy development. So, the information provided in this article may be outdated. For example, I have
Changed the app to use multiple view models instead of a single view model
Added usecase classes in the domain layer and move all repository-related classes (including the
ArticlesRepository
interface) from the domain to the data layer.Implemented Proto DataStore to store user preferences instead of using the same
ArticlesDatabase
room database.
Implementation Details
I just highlight the high-level implementations that are worth mentioning. The source code shown here may not be complete. For details, please refer to the source code directly.
Top and Bottom App Bars
The top and bottom app bars are implemented using Scaffold composable function.
@Composable
fun MainScreen(viewModel: MainViewModel, useSystemUIController: Boolean) {
/*...*/
val scaffoldState = rememberScaffoldState()
val navHostController = rememberNavController()
Scaffold(
scaffoldState = scaffoldState,
topBar = { TopBar(navHostController, viewModel) },
bottomBar = { BottomBarNav(navHostController) }
) {
NavGraph(viewModel, navHostController)
}
/*...*/
}
Navigation Graph
The navigation graph implementation is very similar to what I did in this article:
The screen navigation back stack looks like this.
HomeScreen
is the start destination which navigates to different screens. Because the bottom navigation can navigate from and to any screen, calling popUpTo(NavRoute.Home.path)
us to ensure the back stack is always 2-level depth.
@Composable
private fun BottomNavigationItem() {
/*...*/
val selected = currentNavRoutePath == targetNavRoutePath
rowScope.BottomNavigationItem(
/*...*/
onClick = {
if(!selected) {
navHostController.navigate(targetNavRoutePath) {
popUpTo(NavRoute.Home.path) {
inclusive = (targetNavRoutePath == NavRoute.Home.path)
}
}
}
},
/*...*/
)
}
For bottom navigation implementation, you can refer to this article:
Image Loading
For image loading, I used the rememberImagePainter()
composable function from the coil image loading library.
@Composable
private fun ArticleImage(article: Article) {
Image(
painter = rememberImagePainter(
data = article.image,
builder = {
placeholder(R.drawable.loading_animation)
}
),
contentScale = ContentScale.Crop,
contentDescription = "",
modifier = Modifier
.size(150.dp, 150.dp)
.clip(MaterialTheme.shapes.medium)
)
}
coil is the only image loading libary that supports Jetpack Compose as far as I know
There is this landscapist library that wraps around other image-loading libraries for Jetpack Compose, but I don't know if there are any advantages of using it.
XML Fetching and Parsing
To fetch the XML remotely, I use Ktor Client library, which is the multiplatform asynchronous HTTP client. The implementation is super simple here.
class WebService {
suspend fun getXMlString(url: String): String {
val client = HttpClient()
val response: HttpResponse = client.request(url)
client.close()
return response.body()
}
}
The issue with Ktor Client is probably its performance. Based on the little experience I did in the following article, it runs 2x slower!
However, it is not a direct comparison, as this usage is pretty straightforward. It doesn't use Kotlin Serialization which potentially is the main issue here. Well, this is something for me to experiment in the future.
[Updated - Jan 15, 2023]: Ktor Client throws the following exception on API 21
java.util.concurrent.ExecutionException: java.lang.NoClassDefFoundError: io.ktor.util.collections.ConcurrentMap$$ExternalSyntheticLambda0
To workaround this issue, I use the
OkHttpClient
.
interface WebService {
suspend fun getXMlString(url: String): String
}
class OkHttpWebService : WebService {
override suspend fun getXMlString(url: String): String {
val client = OkHttpClient()
val request: Request = Request.Builder()
.url(url)
.build()
var response = client.newCall(request).execute()
return response.body?.string() ?: ""
}
}
Please note that I have extracted out the
WebService
as an interface as I want to keep both Ktor Client and OkHttp Client implementations.
To parse the XML, I used the XmlPullParser library. FeedPaser.parse()
is the high-level implementation. It converts the XML string to List<ArticleFeed>
.
class FeedParser {
private val pullParserFactory = XmlPullParserFactory.newInstance()
private val parser = pullParserFactory.newPullParser()
fun parse(xml: String): List<ArticleFeed> {
parser.setInput(xml.byteInputStream(), null)
val articlesFeed = mutableListOf<ArticleFeed>()
var feedTitle = ""
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG && parser.name == "title") {
feedTitle = readText(parser)
} else if (parser.eventType == XmlPullParser.START_TAG && parser.name == "item") {
val feedItem = readFeedItem(parser)
val articleFeed = ArticleFeed(
feedItem = feedItem,
feedTitle = feedTitle)
articlesFeed.add(articleFeed)
}
parser.next()
}
return articlesFeed
}
/*...*/
}
Local SQLite Database
I used the Room database library from Android Jetpack to build the SQLite local database. The usage is pretty standard, so I'm not going to talk about it. Instead, I share with you what I did a bit differently in the following.
Instead of hard coding the table name, I declare a singleton below.
object DatabaseConstants {
const val ARTICLE_TABLE_NAME = "article"
}
Then, I use it in ArticleEntity
@Entity(tableName = DatabaseConstants.ARTICLE_TABLE_NAME)
data class ArticleEntity(
@PrimaryKey(autoGenerate = true)
val id: Int,
val title: String,
val link: String,
val author: String,
val pubDate: Long,
val image: String,
val bookmarked: Boolean,
val read: Boolean,
val feedTitle: String,
)
and also in ArticlesDao
interface.
@Dao
interface ArticlesDao {
@Query("SELECT * FROM ${DatabaseConstants.ARTICLE_TABLE_NAME} ORDER by pubDate DESC")
fun selectAllArticles(): Flow<List<ArticleEntity>>
/*...*/
}
Another problem I faced is deleting all the articles does not reset the auto-increment of the primary key. To fix this, I need to bypass Room and run SQL query directly using runSqlQuery()
to delete the sqlite_sequence
.
@Database(
version = 1,
entities = [ArticleEntity::class],
exportSchema = false)
abstract class ArticlesDatabase : RoomDatabase() {
protected abstract val dao: ArticlesDao
/*...*/
fun deleteAllArticles() {
dao.deleteAllArticles()
// reset auto increment of the primary key
runSqlQuery("DELETE FROM sqlite_sequence WHERE name='${DatabaseConstants.ARTICLE_TABLE_NAME}'")
}
/*...*/
}
Article Screen
By right, I should be able to build the article screen from the feed's data, but I took the shortcut to implement an in-app web browser using WebView. I just need to wrap it inside the AndroidView composable function.
@Composable
private fun ArticleWebView(url: String) {
if (url.isEmpty()) {
return
}
Column {
AndroidView(factory = {
WebView(it).apply {
webViewClient = WebViewClient()
loadUrl(url)
}
})
}
}
It is very simple, isn't it? The drawback is it doesn't support offline view. I did try to work around by loading the HTML instead of URL, but no luck.
Swipe Refresh
To refresh the articles, I use the Swipe Refresh library from Accompanist to call MainViewModel.refresh()
when you swipe down the screen.
@Composable
fun ArticlesScreen() {
/*...*/
SwipeRefresh(
state = rememberSwipeRefreshState(viewModel.isRefreshing),
onRefresh = { viewModel.refresh() }
) {
/*..*/
}
}
[Updated - Jan 2, 2023]: Swipe refresh library from Accompanish is deprecated and replaced by
Modifier.pullRefresh()
inandroidx.compose.material
library.
After the migration, the code looks like this.
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ArticlesScreen(
onReadClick: (Article) -> Unit,
) {
/*...*/
val pullRefreshState = rememberPullRefreshState(
viewModel.isRefreshing,
viewModel.Refresh)
Box(Modifier.pullRefresh(pullRefreshState)) {
/*..*/
PullRefreshIndicator(
viewModel.isRefreshing,
pullRefreshState,
Modifier.align(Alignment.TopCenter))
}
}
Data Mapper
Article
is the domain data used by the UI layer. ArticleEntity
is the local database data and ArticleFeed
is the remote data in the data layer. The following Kotlin's extension functions are used to implement this data mapping / transformation:
ArticleFeed.asArticleEntity()
ArticleEnitty.asArticle()
Article.asArticleEntity()
To store ArticleFeed
into the ArticlesDatabase
(single source of truth), ArticleFeed
is required to be converted or mapped to ArticleEntity
first.
To display the Article
from ArticlesDatabse
, ArticleEntity
is required to be converted or mapped to Article
first.
To update the ArticlesDatabase
(e.g. bookmark the article), Article
is required to be converted or mapped to the ArticleEntity
first.
This is asArticle()
extension function as an example (which also includes the List<ArticleEntity>
-> List<Article>
transformation):
fun List<ArticleEntity>.asArticles() : List<Article> {
return map { articleEntity ->
articleEntity.asArticle()
}
}
fun ArticleEntity.asArticle(): Article {
return Article(
id = id,
title = title,
link = link,
author = author,
pubDate = pubDate,
image = image,
bookmarked = bookmarked,
read = read,
feedTitle = feedTitle,
)
}
Splash Screen
[Updated - Jan 29, 2023]: Added this splash screen implementation into this app.
WorkManager and Notification
[Updated - Feb 11, 2023]: Implemented a background task using WorkManager to synch the latest articles and post a notification when new articles arrived.
These are the high-level steps to schedule the work request that can be done in onCreate()
in your Application()
Set the work constraints (required internet connection)
Create the periodic work request (that runs every 24 hours)
Enqueue a periodic work request using WorkManager
class AndroidNewsApplication: Application() {
override fun onCreate() {
super.onCreate()
// (1)
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
// (2)
val syncWorkRequest = PeriodicWorkRequestBuilder<SyncWorker>(
24,
TimeUnit.HOURS
)
.setConstraints(constraints)
.build()
// (3)
val workManager = WorkManager.getInstance(this)
workManager.enqueueUniquePeriodicWork(
"SyncWorker",
ExistingPeriodicWorkPolicy.REPLACE,
syncWorkRequest)
}
}
For a more detailed example, you can refer to the following article.
Folder Structure
The high-level folder structure looks like this, which is organized by layer.
Since this is a simple app, organizing by layer makes sense to me. For more details about organizing Android package folder structure, refer to this article.
Unit and Instrumented Tests
I did not write a lot of testing here. The unit test simply checks all articles in MainViewModel
are not null.
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class HomeViewModelTest {
private lateinit var viewModel: AllArticlesViewModel
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()
@Before
fun setupViewModel() {
val repository = FakeArticlesRepositoryImpl()
viewModel = AllArticlesViewModel(repository)
}
@Test
fun allArticles_areNotNull() = runTest {
Assert.assertNotEquals(null, viewModel.articles.first())
delay(1000)
Assert.assertNotEquals(null, viewModel.articles)
}
}
FakeArticlesRepositoryImpl
implementation can be found here.
For the instrumented test, I just checked the package name and the bottom navigation names.
@RunWith(AndroidJUnit4::class)
class AppContextTest {
@Test
fun useAppContext() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("vtsen.hashnode.dev.androidnews", appContext.packageName)
}
}
class ComposeTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun bottomNavigationNames_areValid() {
var text = composeTestRule.activity.getString(R.string.home)
composeTestRule.onNodeWithText(text).assertExists()
text = composeTestRule.activity.getString(R.string.unread_articles)
composeTestRule.onNodeWithText(text).assertExists()
text = composeTestRule.activity.getString(R.string.bookmarks)
composeTestRule.onNodeWithText(text).assertExists()
}
}
Future Work
One mistake I made is naming conversion of a composable function, that I didn't start with a noun. This is quoted from Compose API guidelines
@Composable
annotation usingPascalCase
, and the name MUST be that of a noun, not a verb or verb phrase, nor a nouned preposition, adjective or adverb. Nouns MAY be prefixed by descriptive adjectives.
For example, BuildNavGraph()
should be renamed to NavGraph()
. It is a component / widget, not an action. It shouldn't start with a verb BuildXxx
.
I also tried to convert the MainViewModel
to use hilt dependency inject. I documented the steps I did in this article:
Since this is my first Jetpack Compose app, I'm sure there is room for improvement. All the potential enhancements that can be done for this app is documented in the GitHub's issues here.
Maybe you can download and install the app and let me know any feedbacks?
Source Code
GitHub Repository:
Android News(master branch) - latest development
Android News(master_org branch) - original implementation which is no longer updated
Originally published at https://vtsen.hashnode.dev.
Top comments (0)