Vincent Tsen
Android Kotlin Weekly

Android Kotlin Weekly

Simple RSS Feed Reader - Jetpack Compose

How I build a clean architecture RSS Feed Reader Android app using Kotlin and Jetpack Compose?

Vincent Tsen's photo
Vincent Tsen
·Sep 3, 2022·

8 min read

Simple RSS Feed Reader - Jetpack Compose

Subscribe to my newsletter and never miss my upcoming articles

Table of contents

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.

Android_News_Overview.gif

High-level Architecture

This is the high-level architecture design which is based on MVVM / recommended Android app architecture.

Simple_RSS_Feed_Reader_Jetpack_Compose.drawio.png

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 LayerResponsibility
MainActivityConstructs MainViewModel and all its dependencies such as ArticlesRepositoryImpl, ArticlesDatabase and WebService
MainScreenSetup top bar and bottom bar navigation, build navigation graph, setup snack bar UI display
HomeScreenActs 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
UnreadScreenLists all unread articles here
BookmarkScreenLists all bookmarked articles here
SearchScreenShows the article search results
MainViewModelProvides UI states (data needed by all the composable functions), collect flows from ArticlesRepository, refresh the articles in ArticlesRepository
Domain LayerResponsibility
ArticlesRepositoryActs as interface between UI layer and data layer. Provides domain data model (articles information) to the UI layer through Flow
Data LayerResponsibility
ArticlesRepositoryImplImplements the ArticlesRepository interface, fetches articles from WebService and write into the ArticlesDatabase, map and transform local data to domain data
ArticlesDatabaseImplements local RoomDatabase which acts as single source of truth
WebServceFetches 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.

@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.

Simple_RSS_Feed_Reader_Navigation_Backstack.drawio.png

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.

@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 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.

@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 short-cut 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() }
    ) {
        /*..*/
    }
}

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()

Simple_RSS_Feed_Reader_Data_Mapper.drawio.png

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.

Simple_RSS_Feed_Reader_Jetpack_Compose_01.png

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.

@Before
fun 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 using PascalCase, 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? google-play-badge.png

Source Code

GitHub Repository: Android News

Did you find this article valuable?

Support Vincent Tsen by becoming a sponsor. Any amount is appreciated!

See recent sponsors Learn more about Hashnode Sponsors
 
Share this