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


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

目录
相关文章
|
8天前
|
编解码 Android开发 iOS开发
探索安卓与iOS开发的差异:平台选择对项目成功的影响
在移动应用开发的世界中,安卓和iOS是两大主导力量。本文深入探讨了这两个平台在开发过程中的主要差异,并分析了这些差异如何影响项目的成功。通过对比分析,我们旨在为开发者提供决策时的参考,帮助他们根据项目需求和目标用户群体做出最合适的平台选择。
|
2天前
|
JavaScript 前端开发 Java
FFmpeg开发笔记(四十七)寒冬下安卓程序员的几个技术转型发展方向
IT寒冬使APP开发门槛提升,安卓程序员需转型。选项包括:深化Android开发,跟进Google新技术如Kotlin、Jetpack、Flutter及Compose;研究Android底层框架,掌握AOSP;转型Java后端开发,学习Spring Boot等框架;拓展大前端技能,掌握JavaScript、Node.js、Vue.js及特定框架如微信小程序、HarmonyOS;或转向C/C++底层开发,通过音视频项目如FFmpeg积累经验。每条路径都有相应的书籍和技术栈推荐,助你顺利过渡。
12 3
FFmpeg开发笔记(四十七)寒冬下安卓程序员的几个技术转型发展方向
|
6天前
|
Java Android开发 iOS开发
探索安卓与iOS开发的差异:平台选择对项目成功的影响
在移动应用开发的世界中,选择正确的平台是关键。本文通过比较安卓和iOS开发的核心差异,揭示平台选择如何影响应用的性能、用户体验和市场覆盖。我们将深入探讨各自的开发环境、编程语言、用户界面设计原则以及发布流程,以帮助开发者和企业做出明智的决策。
27 9
|
3天前
|
移动开发 开发工具 Android开发
探索安卓与iOS开发的差异:技术选择的影响
【8月更文挑战第17天】 在移动应用开发的广阔天地中,安卓和iOS两大平台各领风骚。本文通过比较这两个平台的编程语言、开发工具及市场策略,揭示了技术选择对开发者和产品成功的重要性。我们将从开发者的视角出发,深入探讨不同平台的技术特性及其对项目实施的具体影响,旨在为即将步入移动开发领域的新手提供一个清晰的指南,同时给予资深开发者新的思考角度。
|
6天前
|
Java 开发工具 Android开发
探索安卓与iOS开发的差异:从新手到专家的旅程
在数字时代的浪潮中,移动应用开发成为了连接世界的桥梁。本文将带你走进安卓与iOS这两大移动操作系统的开发世界,通过比较它们的编程语言、开发工具和环境、用户界面设计以及市场分布等方面,揭示各自的独特之处。无论你是初涉编程的新手,还是寻求进阶的开发者,这篇文章都将为你提供宝贵的洞见,助你在移动应用开发的征途上一帆风顺。
20 5
|
4天前
|
vr&ar Android开发 iOS开发
探索安卓和iOS开发的未来趋势
在移动应用开发的广阔天地里,安卓和iOS两大平台如同双子星座般璀璨夺目。随着技术的不断进步,这两个平台的开发趋势也在悄然发生着变化。本文将带你一探究竟,看看未来安卓和iOS开发将会迎来哪些令人激动的新特性和挑战。让我们一起跟随技术的脚步,开启这场探索之旅吧!
|
5天前
|
移动开发 Java Android开发
安卓与iOS开发:异同探析
在移动应用开发的广阔天地中,安卓和iOS两大平台各自占据半壁江山。本文旨在深入探讨这两个平台在开发环境、编程语言、用户界面设计、性能优化及市场分布等方面的异同,为开发者提供实用的比较视角和决策参考。通过对比分析,我们不仅能更清晰地认识到各平台的特性,还能洞察未来移动开发的可能趋势。
|
6天前
|
Java 开发工具 Android开发
探索Android和iOS开发的差异与挑战
在移动应用开发的广阔天地中,Android和iOS两大平台如同两座高峰,各自拥有独特的风景。本文将深入探讨这两个平台的开发差异,包括编程语言、开发工具、用户界面设计等方面,并分析开发者面临的挑战。无论你是初涉移动应用开发的新手,还是已经在这条路上走了很远的老手,这篇文章都将为你提供新的视角和思考。让我们一起走进这个充满创新与挑战的世界,发现那些隐藏在代码背后的秘密。
|
10天前
|
Java Android开发 Swift
安卓与iOS开发:异同与未来趋势
在移动应用开发的广阔天地中,安卓和iOS两大平台各领风骚。本文将深入浅出地探讨这两大系统在开发过程中的异同点,以及它们如何影响开发者的选择和未来的技术走向。从编程语言到用户界面设计,再到市场分布和盈利模式,我们将逐一剖析,为即将踏入或已在这片热土上耕耘的开发者提供一份清晰的指南。
|
7天前
|
编解码 Android开发 iOS开发
安卓与iOS开发:平台差异下的技术创新之路
在数字时代的浪潮中,移动应用开发如同两股潮流——安卓与iOS,各自携带着独特的技术生态和文化基因。本文将深入探讨这两大平台的开发环境、编程语言和工具的差异,以及它们如何塑造了不同的用户体验和技术趋势。通过比较分析,我们旨在揭示跨平台开发的可能性和挑战,同时探索未来技术创新的方向。让我们一起跟随代码的足迹,穿越安卓的开放草原和iOS的精密园林,发现那些隐藏在平台差异之下的创新机遇。
15 1