背景
业务当中写Android异步任务一直是一项挑战,以往的回调和线程管理方式比较复杂和繁琐,造成代码难以维护和阅读。在前端领域中JavaScript其实也面临同样的问题,Promise 就是它的比较主流的一种解法。 在尝试使用Promise之前我们也针对Android现有的一些异步做了详细的对比。
文章思维导图
What:什么是Promise?
对于Android开发的同学,可能很多人不太熟悉Promise,它主要是前端的实践,所以先解析概念。
Promise 是 JavaScript 语言提供的一种标准化的异步管理方式,它的总体思想是,需要进行 io、等待或者其它异步操作的函数,不返回真实结果,而返回一个“承诺”,函数的调用方可以在合适的时机,选择等待这个承诺兑现(通过 Promise 的 then 方法的回调)。
最简单例子(JavaScript)
const promise = new Promise(function(resolve, reject) {
// ... some code
if (/* 异步操作成功 */){
resolve(value);
} else {
reject(error);
}
}).then(function(value) {
console.log('resolved.');
}).catch(function(error) {
console.log('发生错误!', error);
});
实例化一个Promise对象,构造函数接受一个函数作为参数,该参数分别是
resolve
和reject
。
resolve函数:将Promise 对象状态从pending 变成 resolved
reject函数:将Promise 对象状态从 pending 变成 rejected
then函数:回调 resolved状态的结果
catch函数:回调 rejected状态的结果
可以看到Promise的状态是非常简单且清晰的,这也让它在实现异步编程减少很多认知负担。
Why:为什么要考虑引入Promise
前面说的Promise 不就是 JavaScript 异步编程的一种思想吗,那这跟 Android 开发有什么关系? 虽然前端和终端领域有所不同,但面临的问题其实是大同小异的,比如常见的异步回调导致回调地狱,逻辑处理不连贯等问题。
从事Android开发的同学应该对以下异步编程场景比较熟悉:
- 单个网络请求
- 多个网络请求竞速
- 等待多个异步任务返回结果
- 异步任务回调
- 超时处理
- 定时轮询
这里可以停顿思考一下,如果利用 Android常规的方式去实现以上场景,你会怎么做?你的脑子可能有以下解决方案:
- 使用 Thread 创建
- 使用 Thread + Looper + Handler
- 使用 Android 原生 AsyncTask
- 使用 HandlerThread
- 使用 IntentService
- 使用 线程池
- 使用 RxJava 框架
以上方案都能在Android中实现异步任务处理,但或多或少存在一些问题和适用场景,我们详细剖析下各自的优缺点:
通过不同的异步实现方式的对比,可以发现每种实现方式都有适用场景,我们面对业务复杂度也是不一样的,每一种解决方案都是为了降低业务复杂度,用更低成本的方式来编码,但我们也知道代码写出来是给人看的,是需要持续迭代和维护,类似RxJava 这种框架于我们而言太复杂了,繁琐的操作符容易写出不易维护的代码,简单易理解应该是更好的追求,而不是炫技,所以我们才会探索用更轻量更简洁的编码方式来提升团队的代码一致性,就目前而言使用 Promise 来写代码将会有以下好处:
- 解决回调地狱:Promise 可以把一层层嵌套的 callback 变成
.then().then()...
,从而使代码编写和阅读更直观 - 易于处理错误:Promise 比 callback 在错误处理上更清晰直观
- 非常容易编写多个异步操作的代码
How:怎么使用 Promise 重构业务代码?
这里由于我们的Java版本的Promise组件未开源,所以本部分只分析重构Case使用案例。
重构case1: 如何实现一个带超时的网络接口请求?
这是一段未重构前的获取付款码的异步代码:
可以看到以上代码存在以下问题:
- 需要定义异步回调接口
- 很多 if-else 判断,圈复杂度较高
- 业务实现了一个超时类,为了不受网络库默认超时影响
- 逻辑不够连贯,不易于维护
使用 Promise重构后:
可以看到有以下变化:
- 消除了异步回调接口,链式调用让逻辑更连贯更清晰了
- 通过 Promise 包装了网络请求调用,统一返回 Promise
- 指定了 Promise 超时时间,无需额外实现繁琐的超时逻辑
- 通过 validate 方法 替代 if - else 的判断,如果需要还可以定义校验规则
- 统一处理异常错误,逻辑变得更加完备
重构case2:如何更优雅的实现长链接降级短链接?
重构前的做法:
代码存在以下问题:
- 处理长链接请求超时,通过回调再处理降级逻辑
- 使用Handler实现定时器轮询请求异步结果并处理回调
- 处理各种逻辑判断,代码难以维护
- 不易于模拟超时降级,代码可测试性差
使用Promise重构后:
第一个Promise处理长链接Push监听 ,设置5s超时,超时异常发生回调except方法,判断throwable 类型,如果为PromiseTimeoutException实例对象,则执行降级短链接。短链接是另外一个Promise,通过这种方式将逻辑都完全结果,代码不会割裂,逻辑更连贯。
短链接轮训查单逻辑使用Promise实现:
- 最外层Promise,控制整体的超时,即不管轮询的结果如何,超过限定时间直接给定失败结果
- Promise.delay(),这个比较细节,我们认定500ms轮询一定不会返回结果,则通过延迟的方式来减少一次轮询请求
- Promise.retry(),真正重试的逻辑,限定了最多重试次数和延时逻辑,RetryStrategy定义的是重试的策略,延迟(delay)多少和满足怎样的条件(condition)才允许重试
这段代码把复杂的延时、条件判断、重试策略都通过Promise这个框架实现了,少了很多临时变量,代码量更少,逻辑更清晰。
重构case3:实现 iLink Push支付消息和短链接轮训查单竞速
后面针对降级策略重构成竞速模型,采用Promise.any很轻松得实现代码重构,代码如下图所示。
总结
本文提供一种异步编程的思路,借鉴了Promise思想来重构了Android的异步代码。通过Promise组件提供的多种并发模型能够更优雅的解决绝大部分的场景需求。
防踩坑指南
如果跟Activity或Fragment生命周期绑定,需要在生命周期结束时,取消掉promise的线程运行,否则可能会有内存泄露;这里可以采用AbortController来实现更优雅的中断 Promise。
并发模型
● 多任务并行请求
Promise.all():接受任意个Promise对象,并发执行异步任务。全部任务成功,有一个失败则视为整体失败。
Promise.allSettled(): 任务优先,所有任务必须执行完毕,永远不会进入失败状态。
Promise.any():接受任意个Promise对象,并发执行异步任务。等待其中一个成功即为成功,全部任务失败则进入错误状态,输出错误列表。
● 多任务竞速场景
Promise.race(): 接受任意个Promise对象,并发执行异步任务。时间是第一优先级,多个任务以最先返回的那个结果为准,此结果成功即为整体成功,失败则为整体失败。
扩展思考
- Promise 最佳实践
- 避免过长的链式调用:虽然Promise可以通过链式调用来避免回调地狱,但是如果Promise的链过长,代码的可读性和维护性也会变差。
- 及时针对Promise进行abort操作:Promise使用不当可能会造成内存泄露,比如未调用abort,页面取消未及时销毁proimse。
- 需要处理except异常回调,处理PromiseException.
- 可以使用validation来实现规则校验,减少if-else的规则判断
- Java Promise 组件实现原理
- 状态机实现(pending、fulfilled、rejected)
- 默认使用 ForkJoinPool 线程池,适合计算密集型任务。针对阻塞IO类型,可以使用内置ThreadPerTaskExecutor 简单线程池模型。
- Promise vs Kotlin协程
Promise 链式调用,代码清晰,上手成本较低;底层实现仍然是线程,通过线程池管理线程调度
Koitlin 协程,更轻量的线程,使用比较灵活,可以由开发者控制,比如挂起和恢复
刷掌业务相对比较简单,轻量的操作比较少,所以使用基本的线程池就能满足需求,如果需要频繁创建线程和切换,可以考虑使用协程来减少线程池的开销。
- 可测试性的思考
根据 Promise 的特点,可以通过Mock状态(resolve、reject、outTime)来实现模拟成功,拒绝、超时;
实现思路:
● 自定义注解类辅助定位Hook点
● 使用ASM字节码对Promise 进行代码插桩
附录
● Promise - JavaScript | MDN
● Promises/A+