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

完好目录为:

  • 现代化 Android 开发:根底架构
  • 现代化 Android 开发:数据类
  • 现代化 Android 开发:逻辑层(本文)
  • 现代化 Android 开发:组件化与模块化的抉择
  • 现代化 Android 开发:多 Activity 多 Page 的 UI 架构
  • 现代化 Android 开发:Jetpack Compose 最佳实践
  • 现代化 Android 开发:功能监控

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

同是写 CRUD,前后端的侧重点就彻底不一样。后端整个逻辑链路简略些,可是检测高并发、检测大数据量。前端的并发度不高,可是整个链路更杂乱,触及的场景更杂乱。但不管怎样,假如链路走通了,不同事务的代码其实都是迥然不同。所以,能否对自家 App 的事务逻辑进行抽象,也是对大家事务才能的检测。

咱们需求考虑哪些?

咱们不评论只要本地数据的状况,这个没有多少评论价值。

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

  1. 是否需求本地存储?本地存储有许多优点,例如可以无网络状况下也能运用。
  2. 网络数据是全量同步仍是增量同步?假如对错推荐类和非实时性的数据,每次都向后端恳求全量数据,那是糟蹋用户流量,并且增加了后端处理的数据量,所以用增量同步是比较好的方式,可是客户端与服务端的逻辑处理就会变得更为杂乱。

App 本身事务逻辑就更为杂乱:

  1. 数据源有网络,有本地数据库,咱们的加载数据的逻辑是怎样的?
  2. UI 该怎样感知加载状况?
  3. 反常该怎样处理?怎样处理从头加载?
  4. 假如是列表数据,或许存在下拉改写和加载更多,该怎样封装?

除此之外,还有许多边际场景,例如:

  1. 频频进出某个界面,怎样做恳求复用?
  2. 下拉改写与加载更多怎样阻止频频触发数据恳求?

咱们每写一个事务逻辑,都需求考虑这些问题,假如写一点考虑一点,发现一点问题再解决一点问题,那就会特别苦楚,假如写之前就考虑了一切场景,那代码写起来或许就行云流水。而假如从框架层面加以封装,那就再完美不过了。

单一数据源

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

现代化 Android 开发:逻辑层

数据驱动

现在应该基本上都是数据驱动的方式去更新 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 都是一堆屁事,细节多,但写好逻辑也不是那么一件简单的事,仍是要多考虑多总结。这也是锻炼自己了解运用各种数据结构的时机。假如十年开发,仍是用榜首年的写法去写事务逻辑,那走底层、写框架有何意义?

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


我是古哥E下,前微信读书客户端程序猿 / 自学 5 年中医,现为岐黄小筑 App 的负责人。

关注我可得:ChatGPT 开发玩法 | 程序员学习经验 | 组件库新变化 | 中医健康调度。