An Interest In:
Web News this Week
- April 25, 2024
- April 24, 2024
- April 23, 2024
- April 22, 2024
- April 21, 2024
- April 20, 2024
- April 19, 2024
Simple RSS Feed Reader - Jetpack Compose
How I build a clean architecture RSS Feed Reader Android app using Kotlin and Jetpack Compose?
This article was originally published at vtsen.hashnode.dev on Sep 3, 2022.
This is my 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 articles 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 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) |
Implementation Details
I just highlight the high-level implementations that worth mentioning. The source code shown here may not be complete. For details, please refer to the source code directly.
Top and Bottom App Bar
The top and bottom app bar are implemented using Scaffold composable function.
@Composablefun 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 navigation from and to any screen, calling popUpTo(NavRoute.Home.path)
us to ensure the back stack is always 2-level depth.
@Composableprivate 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.
@Composableprivate 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 which 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 of using Ktor Client is probably its performance. Based on my little experience I did on the following article. It runs 2x slower!
However, it is not a direct comparison as this usage pretty straight forward. It doesn't use Kotlin Serialization which potentially is the main issue here. Well, this is something for me to experiment in the future.
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.
@Daointerface 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 short-cut to implement an in-app web browser using WebView. I just need to wrap it inside the AndroidView composable function.
@Composableprivate 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.
@Composablefun ArticlesScreen() { /*...*/ SwipeRefresh( state = rememberSwipeRefreshState(viewModel.isRefreshing), onRefresh = { viewModel.refresh() } ) { /*..*/ }}
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 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, )}
Folder Structure
The high-level folder structure looks like this, which is organized by layer.
Since this is a simple app, organize by layer makes senses 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 is simply check all articles in MainViewModel
are not null. For instrumented test, I just checked the package name and the bottom navigation names.
So nothing fancy here but one thing worth mentioning in unit testing is instead of passing useFakeData
parameter into the MainViewModel
, I probably should create FakeArticlesPepositoryImpl
instead.
@Beforefun setupViewModel() { val repository = ArticlesRepositoryImpl( ArticlesDatabase.getInstance(ApplicationProvider.getApplicationContext()), WebService(), ) viewModel = MainViewModel(repository) mockViewModel = MainViewModel(repository, useFakeData = true)}
I should replace ArticlesRepositoryImpl
with FakeArticlesRepositoryImpl
and get rid of useFakeData = true
.
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 my first Jetpack Compose app, I'm sure there are rooms of 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 know any feedbacks?
Source Code
GitHub Repository: Android News
See Also
Original Link: https://dev.to/vtsen/simple-rss-feed-reader-jetpack-compose-2ld7
Dev To
An online community for sharing and discovering great ideas, having debates, and making friendsMore About this Source Visit Dev To