ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RxJava延迟操作符实践:delay、interval与防抖解析

RxJava延迟操作符实践:delay、interval与防抖解析 做移动开发这些年RxJava 用了不少但真正把它用得顺手是从彻底搞懂“延迟”这件事开始的。搜索框要防抖、验证码要倒计时、开屏广告要几秒后跳转、网络请求失败要隔一会儿重试——这些需求背后全站着同一个操作符家族延迟操作符。它们的代码量不大但选错了、用错了要么内存泄漏要么体验卡顿要么直接埋一个生产事故。这篇文章把我自己踩过的坑和验证过的方案整理出来适合正在学 RxJava 的初级开发者也适合想从“能跑”进阶到“用得明白”的朋友。中职移动应用开发模块里常见的技能训练点像验证码倒计时、搜索自动补全本质上练的也是这套东西搞懂它后面做项目会顺很多。1. 延迟操作符家族盘点四个核心操作符到底有什么区别1.1 delay推迟“事件到达消费者”的时间先看最直观的一个delay。它的作用是让上游发射出来的每一个事件都“晚到一会儿”。Flowable.just(A, B, C) .delay(1, TimeUnit.SECONDS) .subscribe { println(收到: $it) }这段代码的意思很清楚上游立刻发射 A、B、C但订阅者要过 1 秒才陆续收到。注意是“陆续”不是打包延迟。如果上游三个事件是同一瞬间发射的delay 之后它们也是同瞬间到只是整体晚 1 秒。如果上游本身就有间隔比如每 100ms 来一个那 delay 之后每个事件都在原基础上再推迟 1 秒彼此间隔保持不变。我见过不少新手在这里犯迷糊以为 delay 会把所有数据攒到延时结束再一次性发出来。其实它更像个“每个快递都晚点配送”的快递员而不是“在仓库攒一单再统一发”的仓库管理员。理解这一点后面用 delay 做开屏广告、延迟提示之类逻辑就不会出现顺序错乱的问题。1.2 delaySubscription推迟“订阅上游”的动作delaySubscription 和 delay 看着像双胞胎行为却完全不同。它不推迟事件的传递而是推迟“去订阅上游”这个动作本身。Flowable.interval(1, TimeUnit.SECONDS) .take(3) .delaySubscription(5, TimeUnit.SECONDS) .subscribe { println(收到: $it) }这段代码执行后前 5 秒什么都不会发生5 秒后才会去订阅上游这时 interval 才开始计时、发射数据。换句话说delay 改变的是“下游看事件的时间”delaySubscription 改变的是“上游开始工作的时间”。举个例子你有一个昂贵的数据库查询希望用户停留页面 2 秒后再发起查询。这时候 delaySubscription 就比 delay 合适。如果用 delay查询立刻执行了只是结果晚点给用户看用 delaySubscription查询本身延后了资源被真正节省下来。这个差异在真实项目里价值很大。1.3 timer 和 interval延迟的一次性和周期性拍档timer 就像只响一次的闹钟到了指定时间后发射一个 0L 信号然后结束。Flowable.timer(3, TimeUnit.SECONDS) .observeOn(AndroidSchedulers.mainThread()) .subscribe { // 3 秒后这里执行参数类型是 Long值为 0L navigateToHomePage() }interval 则是每隔固定时间就发射一次递增的 Long适合做倒计时、轮询、心跳等周期性任务。Flowable.interval(0, 1, TimeUnit.SECONDS) .take(60) .observeOn(AndroidSchedulers.mainThread()) .subscribe { // 0、1、2、3...59一共 60 次 updateCountdown(60 - it) }注意 interval 有两个时间参数第一个是“首次发射前的等待时间”第二个是“每隔多久发射一次”。我见过很多同学把第一个参数当成周期写成了interval(1, 0, ...)导致代码异常。想清楚第一个是“延迟多久开始”第二个才是“周期多久一次”。1.4 选型逻辑先问自己是延迟数据还是延迟订阅这四个操作符放在一起选型其实取决于一个问题你希望延后的动作发生在“上游生产数据”之前还是之后。操作符延迟的对象典型场景delay事件传递到下游的时间开屏广告倒计时跳转、延迟显示提示delaySubscription订阅上游、触发上游工作的动作延迟加载、延迟初始化请求timer一次性的延迟发射单次定时任务、考试自动交卷interval周期性的延时发射倒计时、轮询、心跳保活这个表格是我在做技术分享时常画的基本能覆盖移动开发中九成以上的延迟需求。剩下的像 debounce、throttleFirst属于“时间窗口”类操作符它们不是简单延迟而是按时间窗口来筛选或合并事件。下面展开讲。2. 5 个移动开发真实场景落地从需求到代码一步到位2.1 搜索框防抖debounce 是标配但还有两个细节要处理搜索框防抖是最经典的延迟场景用户输入文字没要求每敲一个字母就发一次请求而是等用户停下来 300ms 后再发。这个需求用 debounce 实现非常自然。private val querySubject PublishSubject.createString() fun initSearch() { querySubject .debounce(300, TimeUnit.MILLISECONDS) .distinctUntilChanged() .observeOn(AndroidSchedulers.mainThread()) .switchMap { query - searchApi.search(query) } .subscribe { result - showResult(result) } } fun onQueryTextChanged(newText: String) { querySubject.onNext(newText) }这里有两个细节值得展开。第一我加了distinctUntilChanged()防止用户在前后两次 debounce 后传了相同关键词时白打一次请求。第二我用switchMap而不是flatMap核心目的是处理竞态问题用户在等上一次请求结果时如果又输入了新内容switchMap 会立刻切断旧请求只保留最新一次搜索的结果。没有这一步你会在 UI 上看到旧结果覆盖新结果、搜索结果乱跳的经典 bug。debounce 的工作方式可以比喻成电梯关门电梯在等人只要有人进来按开门键门就重新计时直到没人再进来经过设定时间后门才关上。debounce 就是那个“持续有人进来”的关键词事件门合上后最后一个词才会被发出去。2.2 验证码倒计时interval take 的标准写法中职移动应用开发、高职移动应用开发竞赛里特别爱出验证码倒计时这个题因为麻雀虽小五脏俱全。这个功能的本质是点击“获取验证码”按钮后立即开始 60 秒倒计时期间按钮不可点击倒计时结束后恢复可点击状态。private var countdownDisposable: Disposable? null fun startCountdown() { countdownDisposable?.dispose() countdownDisposable Flowable.interval(0, 1, TimeUnit.SECONDS) .take(60) .map { remaining - 60 - it } .observeOn(AndroidSchedulers.mainThread()) .subscribe( { seconds - btnGetCode.text ${seconds}s 后重新获取 btnGetCode.isEnabled false }, { }, { btnGetCode.text 重新获取 btnGetCode.isEnabled true } ) } fun onDestroy() { countdownDisposable?.dispose() }注意这里我用interval(0, 1, ...)让第一次发射立即发生避免界面停在“60s 后重新获取”一秒没反应。take(60)保证只执行 60 次。最关键的是把 Disposable 存下来在页面销毁时 dispose否则页面关闭了定时器还在跑按钮回调触碰已销毁的 Activity轻则空指针重则内存泄漏。我在实际项目里还遇到过一种情况倒计时到 59 秒时用户退到后台过了两分钟再回来发现按钮还在倒计时。这个问题不是 RxJava 的锅而是业务要求决定的。如果要支持后台暂停、前台继续要么把结束时间戳存下来重新订阅时根据差值计算要么用 lifecycle-aware 的组件配合处理。倒计时功能看着简单业务边界不梳理清楚照样出问题。2.3 订单状态轮询interval 间隔轮询的正确姿势支付成功后查订单状态客户端通常隔几秒轮询一次接口。用 interval 可以写得很干净Flowable.interval(30, TimeUnit.SECONDS) .flatMap { orderApi.queryOrder(orderId) } .map { response - parseOrderStatus(response) } .takeUntil { orderStatus - orderStatus.isFinished() } .retry() .subscribe { status - updateOrderStatus(status) }这里最大的坑在 flatMap 的回调里如果某个时间点请求失败flatMap 内部抛出的异常会让整个 Flowable 终止轮询变成一次性任务后面再也不执行了。解决办法是给 internal 的请求套一层错误处理让错误被隔离在单独的请求单元里Flowable.interval(30, TimeUnit.SECONDS) .flatMap { orderApi.queryOrder(orderId) .onErrorReturn { DatabaseErrorResponse() } } .map { response - parseOrderStatus(response) } .takeUntil { orderStatus - orderStatus.isFinished() } .subscribe { status - updateOrderStatus(status) }这样某个周期内的请求挂了上游 interval 不受影响下一轮继续轮询。属于典型的“周期任务不能被内部异常打断”场景。顺便说一句轮询的时间单位不要用毫秒写一长串数字建议用 TimeUnit 表达代码一眼能看懂。我自己习惯把轮询间隔抽成常量比如private val POLL_INTERVAL 30L便于后期调整和排查问题。2.4 开屏广告倒计时跳转timer 生命周期管理开屏广告页面通常要展示 3 秒之后自动跳转到主页。实现方式极其简单就是前面见过的 timerprivate val disposable CompositeDisposable() fun onAdShow() { val d Flowable.timer(3, TimeUnit.SECONDS) .observeOn(AndroidSchedulers.mainThread()) .subscribe { jumpToMainPage() } disposable.add(d) } override fun onDestroy() { disposable.dispose() super.onDestroy() }但要注意一个问题跳转时机和生命周期绑定了吗如果广告页支持点击跳过用户点了跳过按钮后页面销毁onDestroy 里 dispose 掉 timerjumpToMainPage 就不会执行。这是正确行为。如果没写 onDestroy 里的 disposetimer 到点后仍然会回调一个已经 detach 的 context严重时直接崩溃。我踩过更隐蔽的坑开屏广告请求要 1 秒才回来用户其实已经在这个页面等了 1 秒如果再从广告回来时开始算 3 秒总等待时间就变成了 1 3 4 秒给用户的感觉是广告时间变长了。正确做法应该是广告请求发出时就开始计时或者在广告加载完成后只倒计时剩余时间。说到底延迟操作符只管“到点触发”具体计时从哪个瞬间开始需要业务代码自己定义清楚。2.5 网络请求失败自动重试delaySubscription 与 retryWhen 的取舍网络抖动的时候自动重试是常见的提体验方案。最简单的延迟重试思路是用 delaySubscription意思很简单失败后延迟 2 秒再重新订阅一次请求流程。var retryCount 0 fun loadDataWithRetry() { dataRepository.loadData() .delaySubscription(2, TimeUnit.SECONDS, TimeUnit.SECONDS) .subscribe( { data - render(data) }, { error - if (retryCount 3) { retryCount loadDataWithRetry() } else { showErrorPage() } } ) }这种写法的问题也明显重试逻辑散落在业务代码里retryCount 是外部状态难以测试、容易出错。如果有多个请求要复用代码会重复。更好的做法是 RxJava 的retryWhen把重试策略封装成操作符链var retryCount 0 dataRepository.loadData() .retryWhen { errors - errors.flatMap { e - if (retryCount 3) { Flowable.error(e) } else { retryCount Flowable.timer(2L * retryCount, TimeUnit.SECONDS) } } } .subscribe(...)这里我用了外部计数器因为retryWhen内部的实现容易让很多人误解它收到的每次错误信号对应一次重试触发如果在 flatMap 里用zipWith(Flowable.range(1, 3))想靠递增数字控制次数实际多次重试时信号会被错误地配对导致次数失效。所以我更推荐在这种场景下显式维护一个 AtomicInteger 或局部变量逻辑直白不容易踩坑。另外exponential backoff指数退避也是常见优化方向第一次失败等 2 秒第二次等 4 秒第三次等 8 秒。上面代码里2L * retryCount的线性增长够用真正高并发环境建议按指数设计。3. 深入线程调度与取消机制delay 为什么不会卡住主线程3.1 默认调度器 computation 在干什么很多初学者有个疑问Flowable.just(1).delay(1, TimeUnit.SECONDS)写在主线程里为什么主线程不会卡死 1 秒答案在调度器上。延迟操作符没有指定 Scheduler 时默认用的是Schedulers.computation()。这个调度器内部维护一组线程池专门用来执行计算密集型和延时任务。你可以把 computation 调度器理解成一群专门看表的后勤人员。你的业务代码在主线程继续走到了 delay 这一步操作符内部会把这个“1 秒后通知下游”的任务交给后勤人员让他们帮忙定个闹钟。主线程不用在那里傻等闹钟响之前的数据项都暂存在缓冲区里等时间到了再由线程池把事件推给下游。所以delay 本质上不是“让当前线程睡觉”而是“把事件派发动作交给另一个线程定时执行”。这也是为什么 delay 之后如果直接操作 UIAndroid 会抛CalledFromWrongThreadException。因为事件已经被挪到 computation 线程上了必须用observeOn(AndroidSchedulers.mainThread())切回主线程。3.2 为什么 delay 不阻塞当前线程拿 Android 主线程举例如果 delay 真是在主线程里 sleep那 UI 直接冻住所有点击都没反应——这显然不是我们观察到的现象。实际上Flowable.delay在收到上游的每个事件后会立即向上游返回一个“已接收可以继续”的应答信号同时把这个事件连同剩余时间登记到调度器的任务队列里。上游根本不需要等延时结束会继续依次发射后续事件。主线程自然就不会被卡住。用生活场景类比你叫了一份外卖骑手送到楼下后打电话给你你说“放丰巢吧我 2 小时后再取”。骑手马上确认然后继续送下一单快递柜里的东西 2 小时内保持原样2 小时后你再打开。上游骑手没有被你拖住下游取件时间被真正推迟了。delay 做的就是这个“寄存”工作。3.3 dispose 到底取消了什么和内存泄漏的关系Disposable 在延迟操作符里特别重要。以 timer 为例订阅发生时timer 内部会创建一个定时任务并把这个任务和一个 Disposable 关联。当你调用 dispose() 时调度器会尝试取消未执行的定时任务如果任务已经执行完毕dispose 只是切断下游的关系。但这里有个容易忽略的点dispose 不等于中断上游。例如上游是一个很慢的数据库查询你已经 dispose 了查询可能还在跑只是结果不会传给你。所以不要抱着“dispose 了一切都停了”的幻想真要取消上游副作用得依赖上游自己响应取消信号比如 Retrofit 请求可以被取消。在延迟操作符场景下我们能保证的是取消后不会再收到延迟触发的回调。对 UI 安全来说这已经够用了。内存泄漏的典型画面是页面退出时timer/interval 还在后台运行回调里持有 View 或 Activity。解决思路非常简单每次创建订阅都加入 CompositeDisposable页面销毁时统一 dispose。在 Activity 里写在 onDestroy在 Fragment 里写在 onDestroyView 或结合 lifecycleScope 使用。养成这个习惯延迟操作符基本不会给你惹麻烦。3.4 实践原则时间刻度、主线程回调和生命周期绑定经过前面几轮踩坑我自己总结出三条原则做延迟类功能时几乎照着写就行。第一条明确回调线程。延迟操作符默认在 computation 线程上触发凡是涉及 UI 更新的一律显式加observeOn(AndroidSchedulers.mainThread())不要赌默认线程。第二条把“计时开始点”想清楚。用户看到的等待时间是多个环节总和的比如网络请求耗时 延迟到点时间。开屏广告那种场景受网络耗时影响很大一定要把计时起点放在最早的触发动作上而不是等数据回来后重新计时。第三条凡订阅必归还。延迟操作符一旦进入等待状态就是一个“活着的定时器”。如果你不去 dispose在 Fragment 的 view 上执行 UI 更新、在 Activity 里执行跳转轻则内存泄漏重则崩溃。写代码的时候订阅语句后面顺手就想到销毁时的收尾工作。4. 高频问题排查与避坑实录4.1 debounce 把第一次输入也延迟了怎么办搜索框场景有个很常见的“副作用”用户第一次输入时启动搜索也被延后了感觉响应变慢。比如用户输入“a”停了一秒才发请求体验上就是卡顿。这种情况可以换个思路第一个事件不施加 debounce直接放行后续事件再走防抖。RxJava 3 里throttleFirst是另一个角度它规定“时间窗口内的第一个事件立即执行”而窗口内后续事件被丢弃。假如需求是“用户刚打开搜索页面第一次输入立刻搜索之后连续输入也要防抖”可以组合两个流第一条流用 throttleFirst 保证第一发立即执行第二条流用 debounce 处理后续停顿输入。也可以用PublishSubject 一个 boolean 标记判断第一次事件。更简洁的方案是用flowable.publish { shared - ... }把共享流拆成两条分支处理不过初学者容易绕晕。我建议起步阶段先用 boolean 标记逻辑清楚够用。还有一种情况要注意有些产品希望用户输入完一个字就开始搜索比如生词查询、扫描式输入这时候 debounce 根本不适合。做法是直接filter { it.isNotEmpty() }后立即请求再搭配distinctUntilChanged()和switchMap防重。选择延迟操作符之前一定要回到产品需求本身判断“是否真的需要等待”。4.2 interval 周期乱跳、请求叠加问题用 interval 做轮询时遇到过几次“周期乱跳”后来排查发现是操作符链上加了奇怪的 delay 导致每次发射整体偏移。比如Flowable.interval(1, TimeUnit.SECONDS) .delay(500, TimeUnit.MILLISECONDS) // 每个事件额外延迟这会让下游每两个事件之间变成 1.5 秒而不是你以为的 1 秒。interval 保证的是上游发射间隔为 1 秒如果在中间增加 delay整体周期就会叠加。需要改周期直接改 interval 参数即可不要依赖额外 delay 来微调否则时间线很容易失控。请求叠加是另一个高频问题interval 的每个 tick 触发一次网络请求如果某个请求响应特别慢1 秒后的下一个 tick 又来了于是并发请求越来越多。解决思路有两个方向一是用concatMap代替flatMap让前后请求串行执行一个请求完成之前不发起新的二是修改轮询节奏用“请求完成后延时 N 秒再发起下次请求”代替纯 interval。第二种更符合很多实时业务的预期代码大概是这样Flowable.defer { orderApi.queryOrder(orderId) .doOnNext { status - updateStatus(status) } .delay(3, TimeUnit.SECONDS) } .repeat() .retry() .subscribe()其中repeat()在上游完成后重新订阅delay(3, ...)让每次完成到下一次开始之间保持 3 秒间隔。这样请求超时或失败时不会像 interval 那样机械地叠加请求更可控。4.3 时间不准的误区RxJava 用的是相对时间而不是墙钟时间有段时间我拿 RxJava 的 delay 做“延迟 10 分钟提醒”发现手机手动改了系统时间后提醒提前触发了。研究了半天才明白RxJava 的定时任务依赖调度器内部的时间源默认是相对时间正常情况下不受系统时间跳变影响。但 Android 设备进入深度休眠时CPU 可能停摆computation 调度器里的延时任务会整体顺延等屏幕亮起时之前攒的延时任务立刻补执行。所以一旦业务对“真实时间点”有要求比如“到 10:00 整提醒我”就不该用 delay/timer 来做而是用底层的闹钟机制或者 WorkManager。延迟操作符适合的是“相对时长”场景等 3 秒、每 1 秒一次。这是两种完全不同的时间语义混淆了就会出很离奇的线上问题。4.4 单元测试中的虚拟时间TestScheduler 才是延迟操作符的试金石如果不在单元测试里验证延迟逻辑你就只能靠手动打开 App 等几秒来验证效率极低。RxJava 提供了TestScheduler专门解决“测试代码真实等待时间”的问题。它不真正等待而是通过手动拨动虚拟时间让所有定时任务瞬间按指定时间推进。Test fun testTimerDelay() { val scheduler TestScheduler() val results mutableListOfLong() Flowable.interval(0, 1, TimeUnit.SECONDS, scheduler) .take(3) .subscribe { results.add(it) } scheduler.advanceTimeBy(3, TimeUnit.SECONDS) assertEquals(listOf(0L, 1L, 2L), results) }这里最关键的是advanceTimeBy它会模拟时间的流逝触发所有到期的任务。有了它验证 debounce 的 300ms 是否生效、interval 的周期是否准确、retryWhen 的延迟重试是否符合预期都变得非常快。测出来不对就在测试环境里调参数而不是到真机上去黑盒试。在 Android 模块里如果要让全局默认调度器都变成 TestScheduler还可以用RxJavaPlugins.setComputationSchedulerHandler { scheduler }或RxJavaPlugins.setInitSchedulerHandler来替换。不过测试结束后记得恢复原调度器否则其他用例也会被影响。4.5 延迟操作符问题速查表症状可能原因排查与解决debounce 后首字符响应慢首事件也被防抖窗口拦截用 throttleFirst 或 boolean 标记区分首事件delay 后更新 UI 崩溃事件仍在 computation 线程加 observeOn(AndroidSchedulers.mainThread())interval 周期比预期慢链路上叠加了 delay调整 interval 本身参数移除多余 delay轮询请求越来越多flatMap 并发导致请求叠加改 concatMap或请求完成后延时再 repeat页面销毁后还收到回调没有 dispose 订阅用 CompositeDisposable 统一管理并在 onDestroy 释放自动重试次数不对retryWhen 内 zip 使用不当改用外部计数器或 AtomicInteger 控制次数开屏广告总时长过长网络请求耗时 延时计时串行请求发起时就开始计时或扣除加载耗时修改系统时间后提醒提前把相对时间延迟当成真实时间点改用闹钟机制或 WorkManager这张表基本覆盖了我这几年在团队和社区里看到的常见问题每次排查可以先对照定位再动代码。5. 一点个人实践体会最后聊聊我自己的习惯。现在写任何带延迟逻辑的代码第一步永远是拿张纸画出时间轴事件什么时候产生、什么时候触发、什么时候结束中间经过几次线程切换、有没有可能被取消。画不清楚写出来的操作符链几乎一定会出问题。具体项目里我越来越倾向把延迟策略封装成独立的小工具类比如SearchDebouncer、CountdownTimer、PollingScheduler每个类内部只负责一条完整操作符链对外暴露简单的回调接口。这样既方便复用也方便在单元测试里统一用 TestScheduler 去验证。中职移动应用开发模块的竞赛题里验证码倒计时、订单轮询这些任务其实非常适合用这种方式拎出来反复练练熟了RxJava 的延迟操作符基本就成了肌肉记忆。延迟操作符本身不复杂复杂的是和生命周期、线程、业务边界纠缠在一起时的取舍。把这几个维度想透写出来的代码会非常干净线上问题也会少很多。
返回列表