- 将指定的 [amount] 的金额,从 [accountA] 转移到 [accountB]
*/
suspend fun transferMoney(accountA: String, accountB: String, amount: Int) {
// 使用了 IO dispatcher,所以该 DB 的操作在 IO 线程上进行
withContext(Dispatchers.IO) {
database.beginTransaction() //在 IO-Thread-1 线程上开始执行事务
try {
// 协程可以在与调度器(这里就是 Dispatchers.IO)相关联的任何线程上绑定并继续执行。同时,由于事务也是在 IO-Thread-1 中开始的,因此我们可能恰好可以成功执行查询。
moneyDao.decrease(accountA, amount) //挂起函数
// 如果协程又继续在 IO-Thread-2 上执行,那么下列操作数据库的代码可能会引起死锁,因为它需要等到 IO-Thread-1 的线程执行结束后才可以继续。
moneyDao.increase(accountB, amount) //挂起函数
database.setTransactionSuccessful() //永远不会执行这一行
} finally {
database.endTransaction() //永远不会执行这一行
}
}
}
Android 的 SQLite 事务受制于单个线程
上述代码中的问题在于 Android 的 SQLite 事务是受制于单个线程的。当一个正在进行的事务中的某个查询在当前线程中被执行时,它会被视为是该事务的一部分并允许继续执行。但当这个查询在另外一个线程中被执行时,那它就不再属于这个事务的一部分了,这样的话就会导致这个查询被阻塞,直到事务在另外一个线程执行完成。这也是 beginTransaction 和 endTransaction 这两个 API 能够保证原子性的一个前提。当数据库的事务操作都是在一个线程上完成的,这样的 API 不会有任何问题,但是使用协程之后问题就来了,因为协程是不绑定在任何特定的线程上的。也就是说,问题的根源就是在协程挂起之后会继续执行所绑定的那个线程,而这样是不能保证和挂起之前所绑定的线程是同一个线程。
在协程中使用数据库事务操作可能会引起死锁
简单实现
为了解决 Android SQLite 的这个限制,我们需要一个类似于 runInTransaction 这样可以接受挂起代码块的 API,这个 API 实现起来就像写一个单线程的调度器一样:
suspend fun RoomDatabase.runInTransaction(
block: suspend () -> T
): T = withContext(newSingleThreadContext(“DB”)) {
beginTransaction()
try {
val result = block.invoke(this)
setTransactionSuccessful()
return@runBlocking result
} finally {
endTransaction()
}
}
以上实现仅仅是个开始,但是当在挂起代码块中使用另一个调度器的话就会出问题了:
// 一个很简单的退税函数
suspend fun sendTaxRefund(federalAccount: String, taypayerList: List) {
database.runInTransaction {
val refundJobs = taypayerList.map { taxpayer ->
coroutineScope {
// 并行去计算退税金额
async(Dispatchers.IO) {
val amount = irsTool.calculateRefund(taxpayer)
moneyDao.decrease(federalAccount, amount)
moneyDao.increase(taxpayer.account, amount)
}
}
}
// 等待所有计算任务结束
refundJobs.joinAll()
}
}
因为接收的参数是一个挂起代码块,所以这部分代码就有可能使用一个不同的调度器来启动子协程,这样就会导致执行数据库操作的是另外的一个线程。因此,一个比较好的实现是应该允许使用类似于 async、launch 或 withContext 这样的标准协程构造器。而在实际应用中,只有数据库操作才需要被调度到单事务线程。
介绍 withTransaction
为了解决上面的问题,我们构建了 withTransaction API,它模仿了 withContext API,但是提供了专为安全执行 Room 事务而构建的协程上下文,您可以按照如下方式编写代码:
fun transferMoney(
accountA: String,
accountB: String,
amount: Int
) = GlobalScope.launch(Dispatchers.Main) {
roomDatabase.withTransaction {
moneyDao.decrease(accountA, amount)
moneyDao.increase(accountB, amount)
}
Toast.makeText(context, “Transfer Completed.”, Toast.LENGTH_SHORT).show()
}
在深入研究 Room withTransaction API 的实现前,让我们先回顾一下已提到的一些协程的概念。CoroutineContext 包含了需要对协程任务进行调度的信息,它携带了当前的 CoroutineDispatcher 和 Job 对象,以及一些额外的数据,当然也可以对它进行扩展来使其包含更多信息。CoroutineContext 的一个重要特征是它们被同一协程作用域下的子协程所继承,比如 withContext 代码块的作用域。这一机制能够让子协程继续使用同一个调度器,或在父协程被取消时,它们会被一起取消。本质上,Room 提供的挂起事务 API 会创建一个专门的协程上下文来在同一个事务作用域下执行数据库操作。
withTransaction API 在上下文中创建了三个关键元素:
-
单线程调度器,用于执行数据库操作;
-
上下文元素,帮助 DAO 函数判断其是否处在事务中;
-
ThreadContextElement,用来标记事务协程中所使用的调度线程。
事务调度器
CoroutineDispatcher 会决定协程该绑定到哪个线程中执行。比如,Dispatchers.IO 会使用一个共享线程池分流执行那些会发生阻塞的操作,而 Dispatchers.Main 会在 Android 主线程中执行协程。由 Room 创建的事务调度器能够从 Room 的 Executor 获取单一线程,并将事务分发给该线程,而不是分发给一个随意创建的新线程。这一点很重要,因为 executor 可以由用户来配置,并且可作为测试工具使用。在事务开始时,Room 会获得 executor 中某个线程的控制权,直到事务结束。在事务执行期间,即使调度器因子协程发生了变化,已执行的数据库操作仍会被分配到该事务线程上。
获取一个事务线程并不是一个阻塞操作,它也不应该是阻塞操作,因为如果没有可用线程的话,应该执行挂起操作,然后通知调用方,避免影响其他协程的执行。它还会将一个 runnable 插入队列,然后等待其运行,这也是线程可运行的一个标志。suspendCancellableCoroutine 函数为我们搭建了连接基于回调的 API 和协程之间的桥梁。在这种情况下,一旦之前入队列的 runnable 执行了,就代表着一个线程可用,我们会使用 runBlocking 启动一个事件循环来获取此线程的控制权。然后 runBlocking 所创建的调度器会将要执行的代码块分发给已获得的线程。另外,Job 被用来挂起和保持线程的可用性,直到事务执行完成为止。要注意的是,一旦协程被取消了或者是无法获取到线程,就要有防范措施。获取事务线程的相关代码如下:
/**
*构建并返回一个 [ContinuationInterceptor] 用来将协程分发到获取到的线程中,并执行事务。[controlJob] 用来通过取消任务来控制线程的释放。
*/
private suspend fun Executor.acquireTransactionThread(
controlJob: Job
): ContinuationInterceptor = suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
// 当我们在等待获取到可用线程时,如果失败了或者任务取消,我们是不能够停止等待这一动作的,但我们可以取消 controlJob,这样一旦获取到控制权,很快就会被释放。
controlJob.cancel()
}
try {
execute {
// runBlocking 创建一个 event loop 来执行协程中的任务代码
runBlocking {
// 获取到线程后,通过返回有 runBlocking 创建的拦截器来恢复 suspendCancellableCoroutine,拦截器将会被用来拦截和分发代码块到获取的线程中
continuation.resume(coroutineContext[ContinuationInterceptor]!!)
// 挂起 runBlocking 协程,直到 controlJob 完成。由于协程是空的,所以这将会阻止 runBlocking 立即结束。
controlJob.join()
}
}
} catch (ex: RejectedExecutionException) {
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数初中级安卓工程师,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年最新Android移动开发全套学习资料》送给大家,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频
如果你觉得这些内容对你有帮助,可以添加下面V无偿领取!(备注Android)
最后
为了方便有学习需要的朋友,我把资料都整理成了视频教程(实际上比预期多花了不少精力),由于篇幅有限,都放在了我的GitHub上,点击即可免费获取!
Androidndroid架构视频+BAT面试专题PDF+学习笔记
当程序员容易,当一个优秀的程序员是需要不断学习的,从初级程序员到高级程序员,从初级架构师到资深架构师,或者走向管理,从技术经理到技术总监,每个阶段都需要掌握不同的能力。早早确定自己的职业方向,才能在工作和能力提升中甩开同龄人。
- 无论你现在水平怎么样一定要 持续学习 没有鸡汤,别人看起来的毫不费力,其实费了很大力,这四个字就是我的建议!!
- 我希望每一个努力生活的IT工程师,都会得到自己想要的,因为我们很辛苦,我们应得的。
当程序员容易,当一个优秀的程序员是需要不断学习的,从初级程序员到高级程序员,从初级架构师到资深架构师,或者走向管理,从技术经理到技术总监,每个阶段都需要掌握不同的能力。早早确定自己的职业方向,才能在工作和能力提升中甩开同龄人。
无论你现在水平怎么样一定要 持续学习 没有鸡汤,别人看起来的毫不费力,其实费了很大力,没有人能随随便便成功。
在工作和能力提升中甩开同龄人。
- 无论你现在水平怎么样一定要 持续学习 没有鸡汤,别人看起来的毫不费力,其实费了很大力,这四个字就是我的建议!!
- 我希望每一个努力生活的IT工程师,都会得到自己想要的,因为我们很辛苦,我们应得的。
当程序员容易,当一个优秀的程序员是需要不断学习的,从初级程序员到高级程序员,从初级架构师到资深架构师,或者走向管理,从技术经理到技术总监,每个阶段都需要掌握不同的能力。早早确定自己的职业方向,才能在工作和能力提升中甩开同龄人。
无论你现在水平怎么样一定要 持续学习 没有鸡汤,别人看起来的毫不费力,其实费了很大力,没有人能随随便便成功。
加油,共勉。