写业务时一件繁琐的事情,会涉及产品、后端等多端联调,而且多是一些 CRUD 的事情,所以也多被认为是没什么技术含量。但真的去写时,又是 bug 满天飞。所以写 CRUD 没啥难度,但是写好 CRUD 就没那么容易了。如果连个 CRUD 都写不好,那还能谈什么写组件?谈什么写框架?
同是写 CRUD,前后端的侧重点就完全不一样。后端整个逻辑链路简单些,但是考验高并发、考验大数据量。前端的并发度不高,但是整个链路更复杂,涉及的场景更复杂。但不管怎样,如果链路走通了,不同业务的代码其实都是大同小异。所以,能否对自家 App 的业务逻辑进行抽象,也是对大家业务能力的考验。
我们需要考虑哪些?
我们不讨论只有本地数据的情况,这个没有多少讨论价值。
首先我们要考虑的网络数据获取与本地存储:
1.是否需要本地存储?本地存储有很多好处,例如可以无网络情况下也能使用。
2.网络数据是全量同步还是增量同步?如果是非推荐类和非实时性的数据,每次都向后端请求全量数据,那是浪费用户流量,而且增加了后端处理的数据量,所以用增量同步是比较好的方式,但是客户端与服务端的逻辑处理就会变得更为复杂。
而 App
自身业务逻辑就更为复杂:
1.数据源有网络,有本地数据库,我们的加载数据的逻辑是怎样的?
2.UI
该如何感知加载状态?
3.异常该怎么处理?如何处理重新加载?
4.如果是列表数据,可能存在下拉刷新和加载更多,该怎么封装?
除此之外,还有很多边缘场景,例如:
1.频繁进出某个界面,怎么做请求复用?
2.下拉刷新与加载更多怎么阻止频繁触发数据请求?
我们每写一个业务逻辑,都需要思考这些问题,如果写一点思考一点,发现一点问题再解决一点问题,那就会特别痛苦,如果写之前就考虑了所有场景,那代码写起来可能就行云流水。而如果从框架层面加以封装,那就再完美不过了。
单一数据源
使用单一数据源,应该是最佳实践的常识了,其主要的点就是 UI
层数据的来源应该只有一个,如果是只有网络请求或只有本地数据,那好办,而如果数据来源既有网络也有本地数据库,那我们 UI
层数据应该只来源于本地数据库。所以简单流程如下图:
数据驱动
现在应该基本上都是数据驱动的方式去更新 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
端就可以收到 loading
、local
、network
状态与数据。如果有异常,也可以通过 status
判断异常来自于哪个环节。通过协程的 async
与 await
,可以让整个流程看上去是串行的。`
当然,我们实际使用,会有更多的场景,例如:
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
位去查看需要哪些操作,业务使用起来就很便利了。
请求复用
请求复用主要是网络层面的,因为是同步到数据库中,大多数情况也不需要去取消这个请求,因而使用 emo
的 ConcurrencyShare
就足以解决这个,我们回到第一篇文章的例子:
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.旧的加载更多的数据回来,append
进 MutableList
,整个列表的数据就是乱序的了,甚至有可能出现重复数据。
如果清醒一点的同学,还能够在刷新列表时取消下正在执行的加载更多,更多人可能很难发现这个问题,并且因为偶现,想修复也无从下手。
所以列表加载更多虽然和上文的逻辑层关联不大,但我也在这里稍微提一下,写业务要谨防这种异步问题,写组件更要关注这种异步问题。
正确的做法就是封装成 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 都是一堆屁事,细节多,但写好逻辑也不是那么一件容易的事,还是要多思考多总结。这也是锻炼自己熟悉使用各种数据结构的机会。如果十年开发,还是用第一年的写法去写业务逻辑,那走底层、写框架有何意义?
所以,今天提到的各个小点,你平时有思考到多少呢?