Jetpack Compose:简单的 RSS Feed Reader

高级架构

这是基于MVVM / 推荐的Android 应用架构的高级架构设计。

您可能已经知道,UI 事件向下流动,数据通过回调或向上流动Flow。依赖方向也是从 UI 层到 Data 层的一种方式。

下表总结了 UI、域和数据层中所有组件的职责。

界面层责任
MainActivity构造MainViewModel及其所有依赖项,例如ArticlesRepositoryImpl,ArticlesDatabase和WebService
MainScreen设置顶栏和底栏导航,构建导航图,设置小吃吧 UI 显示
HomeScreen用作列出来自 rss.xml 的所有文章的起始目标屏幕。提供在每篇文章上添加书签、分享、标记为未读的功能,在顶部栏添加搜索文章功能
UnreadScreen在此处列出所有未读文章
BookmarkScreen在此处列出所有已添加书签的文章
SearchScreen显示文章搜索结果
MainViewModel提供 UI 状态(所有可组合功能所需的数据),从 中收集流量ArticlesRepository,刷新文章ArticlesRepository
领域层责任
ArticlesRepository作为 UI 层和数据层之间的接口。通过向 UI 层提供领域数据模型(文章信息)Flow
数据层责任
ArticlesRepositoryImpl实现ArticlesRepository接口,从中获取文章WebService并写入ArticlesDatabase,将本地数据映射和转换为域数据
ArticlesDatabase实现RoomDatabase作为单一事实来源的本地
WebServce使用 获取 XML 字符串ktor client,解析 XML 提要并将 XML 转换为远程数据(将其转换为本地数据以用于本地数据库写入)

实施细节

我只是强调值得一提的高级实现。此处显示的源代码可能不完整。详情请直接参考源码。

顶部和底部应用栏

顶部和底部应用栏是使用Scaffold可组合功能实现的。

@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)
    }
    /*...*/
}

导航图

屏幕导航返回堆栈如下所示。

HomeScreen是导航到不同屏幕的起始目的地。因为底部导航可以从任何屏幕导航到任何屏幕,调用popUpTo(NavRoute.Home.path)我们以确保返回堆栈始终是 2 级深度。

@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)
                    }
                }
            }
        },
        /*...*/
    )
}

图像加载

对于图像加载,我使用了coil图像加载库中的rememberImagePainter()可组合函数。

@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是唯一支持 Jetpack Compose 的图像加载库

有这个landsccapist库,它包含了 Jetpack Compose 的其他图像加载库,但我不知道使用它是否有任何优势。

XML 获取和解析

为了远程获取 XML,我使用Ktor 客户端库,它是多平台异步 HTTP 客户端。这里的实现非常简单。

class WebService {

    suspend fun getXMlString(url: String): String {
        val client = HttpClient()
        val response: HttpResponse = client.request(url)
        client.close()
        return response.body()
    }
}

使用 Ktor Client 的问题可能是它的性能。根据我在以下文章中所做的一点经验。它运行速度慢 2 倍!

但是,这不是直接比较,因为这种用法非常简单。它不使用Kotlin 序列化,这可能是这里的主要问题。嗯,这是我以后要试验的东西。

为了解析 XML,我使用了XmlPullParser库。FeedPaser.parse()是高层实现。它将 XML 字符串转换为List.

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
    }
    /*...*/
}

本地 SQLite 数据库

我使用 Android Jetpack 中的Room 数据库库来构建 SQLite 本地数据库。用法非常标准,所以我不打算谈论它。相反,我将在下面与您分享我所做的一些不同的事情。

我没有对表名进行硬编码,而是在下面声明了一个单例。

object DatabaseConstants {
    const val ARTICLE_TABLE_NAME = "article"
}

然后,我用它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,
)

并且还在ArticlesDao界面中。

@Dao
interface ArticlesDao {
    @Query("SELECT * FROM ${DatabaseConstants.ARTICLE_TABLE_NAME} ORDER by pubDate DESC")
    fun selectAllArticles(): Flow<List<ArticleEntity>>

    /*...*/
}

我面临的另一个问题是删除所有文章不会重置主键的自动增量。要解决这个问题,我需要绕过 Room 并直接使用运行 sql 查询runSqlQuery() 来删除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}'")
    }
    /*...*/
}

文章屏幕

正确地说,我应该能够从提要的数据构建文章屏幕,但我采取了捷径来使用WebView实现应用内 Web 浏览器。我只需要将它包装在AndroidView可组合函数中。

@Composable
private fun ArticleWebView(url: String) {

    if (url.isEmpty()) {
        return
    }

    Column {

        AndroidView(factory = {
            WebView(it).apply {
                webViewClient = WebViewClient()
                loadUrl(url)
            }
        })
    }
}

这很简单,不是吗?缺点是不支持离线查看。我确实尝试通过加载 html 而不是 url 来解决问题,但没有运气。

Swipe Refresh

为了刷新文章,我会在您向下滑动屏幕时调用 Accompanist 的Swipe Refresh库。MainViewModel.refresh()

@Composable
fun ArticlesScreen() {
    /*...*/
    SwipeRefresh(
        state = rememberSwipeRefreshState(viewModel.isRefreshing),
        onRefresh = { viewModel.refresh() }
    ) {
        /*..*/
    }
}

数据映射器

Article是 UI 层使用的领域数据。ArticleEntity是本地数据库数据,ArticleFeed是数据层中的远程数据。以下 Kotlin 的扩展函数用于实现此数据映射/转换:

  • ArticleFeed.asArticleEntity()
  • ArticleEnitty.asArticle()
  • Article.asArticleEntity()


要存储ArticleFeed到ArticlesDatabase(单一事实源)中,ArticleFeed需要先转换或映射到ArticleEntity。

要显示Articlefrom ArticlesDatabse, ArticleEntity需要先转换或映射到Article。

要更新ArticlesDatabase(例如为文章添加书签),Article需要转换或映射到第ArticleEntity一个。

以asArticle()扩展函数为例(其中还包括List->List

转换):

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,
    )
}

文件夹结构

高级文件夹结构如下所示,按层组织。


由于这是一个简单的应用程序,按层组织对我来说很有意义。

单元测试和仪器测试

我没有在这里写很多测试。单元测试只是检查所有文章MainViewModel都不为空。对于仪器测试,我只检查了包名称和底部导航名称。

所以这里没有什么特别的,但在单元测试中值得一提的是,不是将useFakeData参数传递给MainViewModel,我可能应该创建FakeArticlesPepositoryImpl。

@Before
fun setupViewModel() {
    val repository = ArticlesRepositoryImpl(
        ArticlesDatabase.getInstance(ApplicationProvider.getApplicationContext()),
        WebService(),
    )
    viewModel = MainViewModel(repository)
    mockViewModel = MainViewModel(repository, useFakeData = true)
}

我应该替换ArticlesRepositoryImpl并FakeArticlesRepositoryImpl摆脱useFakeData = true.

最后

我犯的一个错误是命名可组合函数的转换,我没有以名词开头。这是从Compose API 指南中引用的

@Composable注释使用PascalCase, 名称必须是名词,不能是动词或动词短语,也不能是名词介词、形容词或副词。名词可以以描述性形容词作为前缀。

例如,BuildNavGraph()应重命名为NavGraph(). 它是一个组件/小部件,而不是一个动作。它不应该以动词开头BuildXxx。

我还尝试将其转换MainViewModel为使用 hilt 依赖注入。

如果大伙有什么好的学习方法或建议欢迎大家在评论中积极留言哈,希望大家能够共同学习、共同努力、共同进步。

小编在这里祝小伙伴们在未来的日子里都可以 升职加薪,当上总经理,出任CEO,迎娶白富美,走上人生巅峰!!

不论遇到什么困难,都不应该成为我们放弃的理由!

很多人在刚接触这个行业的时候或者是在遇到瓶颈期的时候,总会遇到一些问题,比如学了一段时间感觉没有方向感,不知道该从那里入手去学习,需要一份小编整理出来的学习资料的关注我主页或者点击文末微信卡片即可免费领取~

这里是关于我自己的Android 学习,面试文档,视频收集大整理,有兴趣的伙伴们可以看看~

如果你看到了这里,觉得文章写得不错就给个赞呗?如果你觉得那里值得改进的,请给我留言,一定会认真查询,修正不足,谢谢。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值