使用 promise 重构 Android 异步代码
背景
业务当中写 Android 异步任务一直是一项挑战,以往的回调和线程管理方式比较复杂和繁琐,造成代码难以维护和阅读。在前端领域中 JavaScript 其实也面临同样的问题,Promise 就是它的比较主流的一种解法。 在尝试使用 Promise 之前我们也针对 Android 现有的一些异步做了详细的对比。
文章思维导图
What:什么是 Promise?
对于 Android 开发的同学,可能很多人不太熟悉 Promise,它主要是前端的实践,所以先解析概念。Promise 是 JavaScript 语言提供的一种标准化的异步管理方式,它的总体思想是,需要进行 io、等待或者其它异步操作的函数,不返回真实结果,而返回一个“承诺”,函数的调用方可以在合适的时机,选择等待这个承诺兑现(通过 Promise 的 then 方法的回调)。
最简单例子(JavaScript)
实例化一个 Promise 对象,构造函数接受一个函数作为参数,该参数分别是
resolve
和reject
。resolve 函数:将 Promise 对象状态从 pending 变成 resolvedreject 函数:将 Promise 对象状态从 pending 变成 rejectedthen 函数:回调 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+
版权声明: 本文为 InfoQ 作者【巫山老妖】的原创文章。
原文链接:【http://xie.infoq.cn/article/3c09bf9d103d3e794cc43fc99】。文章转载请联系作者。
评论