现代化 Android 开发:逻辑层

简介: 本文为现代化 Android 开发系列文章第三篇。

写业务时一件繁琐的事情,会涉及产品、后端等多端联调,而且多是一些 CRUD 的事情,所以也多被认为是没什么技术含量。但真的去写时,又是 bug 满天飞。所以写 CRUD 没啥难度,但是写好 CRUD 就没那么容易了。如果连个 CRUD 都写不好,那还能谈什么写组件?谈什么写框架?


同是写 CRUD,前后端的侧重点就完全不一样。后端整个逻辑链路简单些,但是考验高并发、考验大数据量。前端的并发度不高,但是整个链路更复杂,涉及的场景更复杂。但不管怎样,如果链路走通了,不同业务的代码其实都是大同小异。所以,能否对自家 App 的业务逻辑进行抽象,也是对大家业务能力的考验。

我们需要考虑哪些?


我们不讨论只有本地数据的情况,这个没有多少讨论价值。


首先我们要考虑的网络数据获取与本地存储:

1.是否需要本地存储?本地存储有很多好处,例如可以无网络情况下也能使用。

2.网络数据是全量同步还是增量同步?如果是非推荐类和非实时性的数据,每次都向后端请求全量数据,那是浪费用户流量,而且增加了后端处理的数据量,所以用增量同步是比较好的方式,但是客户端与服务端的逻辑处理就会变得更为复杂。


App 自身业务逻辑就更为复杂:

1.数据源有网络,有本地数据库,我们的加载数据的逻辑是怎样的?

2.UI 该如何感知加载状态?

3.异常该怎么处理?如何处理重新加载?

4.如果是列表数据,可能存在下拉刷新和加载更多,该怎么封装?


除此之外,还有很多边缘场景,例如:

1.频繁进出某个界面,怎么做请求复用?

2.下拉刷新与加载更多怎么阻止频繁触发数据请求?


我们每写一个业务逻辑,都需要思考这些问题,如果写一点思考一点,发现一点问题再解决一点问题,那就会特别痛苦,如果写之前就考虑了所有场景,那代码写起来可能就行云流水。而如果从框架层面加以封装,那就再完美不过了。

单一数据源


使用单一数据源,应该是最佳实践的常识了,其主要的点就是 UI 层数据的来源应该只有一个,如果是只有网络请求或只有本地数据,那好办,而如果数据来源既有网络也有本地数据库,那我们 UI 层数据应该只来源于本地数据库。所以简单流程如下图:

6db27cf258cbef12a3563973e83c0eb.png

数据驱动


现在应该基本上都是数据驱动的方式去更新 UI 了吧。Room 可以直接让返回一个 Flow 或者 LiveData 的数据结构,就是为了方便我们监听数据库的变化。但是用它的问题是有状态信息传递到 UI,所以往往还需要另外搞一个状态的 StateFlow,写起来并不爽,所以我也现在也不用它的这一套,(LiveData 我也不用,毕竟是 Java 时代的产物,对可空处理非常不友好)。


所以我还是封装自己的实现:

fun <T> logic(
    scope: CoroutineScope,
    dbAction: suspend () -> T?,
    syncAction: suspend () -> RespResult<SyncRet>
) = flow {
    // LogicResult 在前文已经提及过
    // 首先发送 loading 状态
    emit(logicResultLoading())
    // 记录下数据结果,网络异常或者网络数据没变更,可以复用数据
    var ret: T? = null
    // 然后异步开启一个协程去查询本地数据
    // 因为本地数据一般比网络数据快,所以先查询一次,交给 UI 渲染,可以减少用户等待
    val local = scope.async {
        dbAction()
    }
    // 开启另一个协程,查询网络
    val sync = scope.async {
        syncAction()
    }
    try {
        // 等待本地数据结果
        ret = local.await()
        // 发送本地数据结果,status 声明为 Local
        emit(LogicResult(LogicStatus.Local, ret))
    } catch (e: Throwable) {
        // 发送异常
        emit(LogicResult(status, ret, LOCAL_CODE_ERROR_CATCH, e.message))
    }
     try {
        // 等待网络数据结果
        val syncRet = sync.await()
        if (syncRet.isOk()) {
            // 同步数据成功,那就重新从 DB 读取一次数据, 状态更新为网络
            // 其实如果数据没有变更,可以复用前一次的数据结果
            emit(LogicResult(LogicStatus.Network, dbAction()))
        } else {
            // 发送服务端错误
            emit(LogicResult(LogicStatus.Network, ret, syncRet.code, syncRet.msg))
        }
    } catch (e: Throwable) {
        // 发送异常
        emit(LogicResult(LogicStatus.Network, ret, LOCAL_CODE_ERROR_CATCH, e.message))
    }
}.flowOn(Dispatchers.IO)

上述代码,我们采用 Flow 去构建数据流,正常流程,UI 端就可以收到 loadinglocalnetwork 状态与数据。如果有异常,也可以通过 status 判断异常来自于哪个环节。通过协程的 asyncawait,可以让整个流程看上去是串行的。`


当然,我们实际使用,会有更多的场景,例如:

1.下拉刷新时,或者静默刷新时,我们不需要 loading 状态,也不需要先读一次本地数据。

2.如果本地的操作更新了数据库,我们需要刷新数据,那我们也不需要再次同步网络数据。因为可能需要确定是否是本次请求的最终态,所有在状态请求我添加了 LocalButFinal 态,告诉 UI 层不会有网络数据了


具体做法就是添一个 mode 参数来控制具体要执行哪些操作:

// 不要加载态
const val LOGIC_FLAG_NOT_LOADING = 1
// 不先读取一次本地数据
const val LOGIC_FLAG_NOT_LOCAL = 1 shl 1
// 不读取网络
const val LOGIC_FLAG_NOT_SYNC = 1 shl 2
// 便捷函数生成 mode
fun logicMode(needLoading: Boolean, needLocal: Boolean, needSync: Boolean): Int {
    var logic = 0
    if (!needLoading) {
        logic = logic or LOGIC_FLAG_NOT_LOADING
    }
    if (!needLocal) {
        logic = logic or LOGIC_FLAG_NOT_LOCAL
    }
    if (!needSync) {
        logic = logic or LOGIC_FLAG_NOT_SYNC
    }
    return logic
}
fun Int.logicNeedLoading() = (this and LOGIC_FLAG_NOT_LOADING) == 0
fun Int.logicNeedLocal() = (this and LOGIC_FLAG_NOT_LOCAL) == 0
fun Int.logicNeedSync() = (this and LOGIC_FLAG_NOT_SYNC) == 0

通过用 bit 位去查看需要哪些操作,业务使用起来就很便利了。

请求复用


请求复用主要是网络层面的,因为是同步到数据库中,大多数情况也不需要去取消这个请求,因而使用 emoConcurrencyShare 就足以解决这个,我们回到第一篇文章的例子:

fun logicBookInfo(bookId: Int, mode: Int = 0) = logic(
    scope = authSession.coroutineScope, // 使用用户 session 协程 scope,因为有请求复用,所以退出界面,再进入,会复用之前的网络请求
    mode = mode,
    dbAction = { 
        db.bookDao().bookInfo(bookId)
    },
    syncAction = {
        // 如果已有请求,那么就等待前一个请求就好了
        concurrencyShare.joinPreviousOrRun("syncBookInfo-$bookId") {
            bookApi.bookInfo(bookId).syncThen { _, data ->
                db.runInTransaction {
                    db.userDao().insert(data.author)
                    db.bookDao().insert(data.info)
                }
                SyncRet.Full
            }
        }
    }
)

通过 ConcurrencyShare, 我们也避免了同一个请求的并发问题,例如多次发送同一个请求,因为是增量更新,如果后一个请求比前一个请求先回来,然后存了 DB, 那可能数据就错乱了。所以如果同一时间一定该方法的请求只有一个,那不仅节省了流量,也避免了很多并发导致的数据错乱问题。

列表加载更多


一般的 CRUD 业务逻辑,前面的封装基本就够了,但是对于列表而言,往往要分页加载,Jetpac Compose 提供了 Paging 的组件,但是也就要求数据库要返回 Flow 之类的了,而且易用性也不是很强。大多数场景也没有复杂到要使用它的情况,所以它的使用也不是很普及。


但我们很多开发习惯的加载更多,很习惯的写法就是列表使用 MutableList, 然后加载更多后往里面 append 数据,我们前一章有提过 Mutable 数据类型很容易出翔,那这也是一个典型的场景,特别是有时候你想要重新刷新列表的时候,可能会出现下面的执行顺序:

1.触发加载更多

2.触发列表刷新

3.列表刷新数据先回来了,清空 MutableList,填补新的数据

4.旧的加载更多的数据回来,appendMutableList,整个列表的数据就是乱序的了,甚至有可能出现重复数据。


如果清醒一点的同学,还能够在刷新列表时取消下正在执行的加载更多,更多人可能很难发现这个问题,并且因为偶现,想修复也无从下手。


所以列表加载更多虽然和上文的逻辑层关联不大,但我也在这里稍微提一下,写业务要谨防这种异步问题,写组件更要关注这种异步问题。


正确的做法就是封装成 Immutable,做法和 PersistentList 类似, 每次加载更多、刷新都是产生新的 ListWithLoadMore

data class ListWithLoadMore<T>(
    val list: PersistentList<T>,
    val hasMore: Boolean,
    private val doLoadMore: suspend (current: ListWithLoadMore<T>, count: Int) -> List<T>
) : EmptyChecker {
    suspend fun loadMore(count: Int): ListWithLoadMore<T> {
        if (list.isEmpty() || !hasMore) {
            return this
        }
    v   val more =  withContext(Dispatchers.IO) {
            doLoadMore(this@ListWithLoadMore, count)
        }
        return if (more.size < count) {
            if (more.isEmpty()) {
                ListWithLoadMore(list, false, doLoadMore)
            } else {
                ListWithLoadMore((list + more).toPersistentList(), false, doLoadMore)
            }
        } else {
            ListWithLoadMore((list + more).toPersistentList(), true, doLoadMore)
        }
    }
    fun prepend(item: T): ListWithLoadMore<T> {
        return ListWithLoadMore(list.add(0, item), hasMore, doLoadMore)
    }
    fun replace(origin: T, item: T): ListWithLoadMore<T> {
        val index = list.indexOf(origin)
        if (index < 0) {
            return this
        }
        return ListWithLoadMore(list.set(index, item), hasMore, doLoadMore)
    }
    fun update(index: Int, item: T): ListWithLoadMore<T> {
        return ListWithLoadMore(list.set(index, item), hasMore, doLoadMore)
    }
    fun del(index: Int): ListWithLoadMore<T> {
        return ListWithLoadMore(list.removeAt(index), hasMore, doLoadMore)
    }
    override fun isEmpty(): Boolean {
        return list.isEmpty()
    }
}

Compose 的使用也很简单:

@Composable
function BookListPage(data: ListWithLoadMore<Book>){ //这里的数据有界面刷新获得
    // 传入的参数作为 key,如果外层数据,那也直接更新 usedData, 
    // 前一次的加载更多因为 LaunchedEffect 参数变化而自动取消
    val usedData by remember(data) { 
        mutableStateOf(data)
    }
    LazyColumn { 
        items(usedData.list){
            //...
        }
        item {
            // LoadMore 渲染就触发加载更多
            LaunchedEffect(usedData){
                // 当然实际情况要处理加载出错的情况
                usedData = usedData.loadMore()
            }
            LoadMoreItemUI()
        }
    }
}

总结


写逻辑和写 UI 都是一堆屁事,细节多,但写好逻辑也不是那么一件容易的事,还是要多思考多总结。这也是锻炼自己熟悉使用各种数据结构的机会。如果十年开发,还是用第一年的写法去写业务逻辑,那走底层、写框架有何意义?


所以,今天提到的各个小点,你平时有思考到多少呢?

目录
相关文章
|
6天前
|
开发框架 前端开发 Android开发
安卓与iOS开发中的跨平台策略
在移动应用开发的战场上,安卓和iOS两大阵营各据一方。随着技术的演进,跨平台开发框架成为开发者的新宠,旨在实现一次编码、多平台部署的梦想。本文将探讨跨平台开发的优势与挑战,并分享实用的开发技巧,帮助开发者在安卓和iOS的世界中游刃有余。
|
12天前
|
搜索推荐 Android开发 开发者
探索安卓开发中的自定义视图:打造个性化UI组件
【10月更文挑战第39天】在安卓开发的世界中,自定义视图是实现独特界面设计的关键。本文将引导你理解自定义视图的概念、创建流程,以及如何通过它们增强应用的用户体验。我们将从基础出发,逐步深入,最终让你能够自信地设计和实现专属的UI组件。
|
13天前
|
Android开发 Swift iOS开发
探索安卓与iOS开发的差异和挑战
【10月更文挑战第37天】在移动应用开发的广阔舞台上,安卓和iOS这两大操作系统扮演着主角。它们各自拥有独特的特性、优势以及面临的开发挑战。本文将深入探讨这两个平台在开发过程中的主要差异,从编程语言到用户界面设计,再到市场分布的不同影响,旨在为开发者提供一个全面的视角,帮助他们更好地理解并应对在不同平台上进行应用开发时可能遇到的难题和机遇。
|
15天前
|
XML 存储 Java
探索安卓开发之旅:从新手到专家
【10月更文挑战第35天】在数字化时代,安卓应用的开发成为了一个热门话题。本文旨在通过浅显易懂的语言,带领初学者了解安卓开发的基础知识,同时为有一定经验的开发者提供进阶技巧。我们将一起探讨如何从零开始构建第一个安卓应用,并逐步深入到性能优化和高级功能的实现。无论你是编程新手还是希望提升技能的开发者,这篇文章都将为你提供有价值的指导和灵感。
|
13天前
|
存储 API 开发工具
探索安卓开发:从基础到进阶
【10月更文挑战第37天】在这篇文章中,我们将一起探索安卓开发的奥秘。无论你是初学者还是有经验的开发者,这篇文章都将为你提供有价值的信息和建议。我们将从安卓开发的基础开始,逐步深入到更复杂的主题,如自定义组件、性能优化等。最后,我们将通过一个代码示例来展示如何实现一个简单的安卓应用。让我们一起开始吧!
|
14天前
|
存储 XML JSON
探索安卓开发:从新手到专家的旅程
【10月更文挑战第36天】在这篇文章中,我们将一起踏上一段激动人心的旅程,从零基础开始,逐步深入安卓开发的奥秘。无论你是编程新手,还是希望扩展技能的老手,这里都有适合你的知识宝藏等待发掘。通过实际的代码示例和深入浅出的解释,我们将解锁安卓开发的关键技能,让你能够构建自己的应用程序,甚至贡献于开源社区。准备好了吗?让我们开始吧!
25 2
|
15天前
|
Android开发
布谷语音软件开发:android端语音软件搭建开发教程
语音软件搭建android端语音软件开发教程!
|
23天前
|
编解码 Java Android开发
通义灵码:在安卓开发中提升工作效率的真实应用案例
本文介绍了通义灵码在安卓开发中的应用。作为一名97年的聋人开发者,我在2024年Google Gemma竞赛中获得了冠军,拿下了很多项目竞赛奖励,通义灵码成为我的得力助手。文章详细展示了如何安装通义灵码插件,并通过多个实例说明其在适配国际语言、多种分辨率、业务逻辑开发和编程语言转换等方面的应用,显著提高了开发效率和准确性。
|
22天前
|
Android开发 开发者 UED
安卓开发中自定义View的实现与性能优化
【10月更文挑战第28天】在安卓开发领域,自定义View是提升应用界面独特性和用户体验的重要手段。本文将深入探讨如何高效地创建和管理自定义View,以及如何通过代码和性能调优来确保流畅的交互体验。我们将一起学习自定义View的生命周期、绘图基础和事件处理,进而探索内存和布局优化技巧,最终实现既美观又高效的安卓界面。
30 5
|
20天前
|
JSON Java Android开发
探索安卓开发之旅:打造你的第一个天气应用
【10月更文挑战第30天】在这个数字时代,掌握移动应用开发技能无疑是进入IT行业的敲门砖。本文将引导你开启安卓开发的奇妙之旅,通过构建一个简易的天气应用来实践你的编程技能。无论你是初学者还是有一定经验的开发者,这篇文章都将成为你宝贵的学习资源。我们将一步步地深入到安卓开发的世界中,从搭建开发环境到实现核心功能,每个环节都充满了发现和创造的乐趣。让我们开始吧,一起在代码的海洋中航行!
下一篇
无影云桌面