throttle/debounce应用及原理


“防抖”一词经常让人联想到“摄像头防抖”之类的技术,不过摄像头所说的防抖是补偿式、阻尼式的防抖动,是实实在在的防“抖动”,软件上的防抖动其实更多是指“ 控制频率”。
软件上的防抖和节流虽然往往是一体的,但还是先大致分清其区别:

  • 防抖
    频次过高的数据丢掉,仅保留频次低的数据;
  • 节流
    单位时间或空间内,仅保留一次数据;

两者均可以理解为按制定的规则过滤事件或数据,以达到控制事件或数据触发频率的目的
防抖的规则是过滤掉频次过高的数据项,只要频次过高的数据均视为无效数据;
节流的规则是每个单位时间内都要保留一次有效数据;

可以看出,我们在移动端的实际场景中所谓的”防抖“,往往更贴近节流的概念。

基础应用

“时间”防抖

最常见的防抖应用场景莫过于一段时间内调用多次,经过防抖处理后变为单位时间内仅允许调用一次,比如耗时任务的提交、界面按钮的点击;
这里”制定的规则“就是”单位时间内仅允许调用一次“,标准是时间,或者说时间间隔

一个简单的实际应用场景就是【确定按钮仅允许在2秒之内点击生效一次】,那么单位时间就是2秒,对于这种场景一个简单的判断时间间隔的工具类即可解决问题:

public class ShakeAvoid {
	private static class AvoidShakeHolder {
		private static final ShakeAvoid INSTANCE = new ShakeAvoid();
	}

	/**
	 * 记录时间点
	 */
	private Map<Integer, Long> mPeriodMap;

	private ShakeAvoid() {
		mPeriodMap = new HashMap<>();
	}

	public static ShakeAvoid get() {
		return AvoidShakeHolder.INSTANCE;
	}

	/**
	 * @param id     标志
	 * @param period 时间间隔
	 * @return 是否符合
	 */
	public boolean check(int id, long period) {
		long curTime = System.currentTimeMillis();
		Long previousTime = mPeriodMap.get(id);
		if (previousTime != null && curTime - previousTime < period) {
			return false;
		}
		mPeriodMap.put(id, curTime);
		return true;
	}
}

很简单,就是判断时间间隔是否达到单位允许时间,时间单位默认为ms。
那么对于下面的调用,

			TimeUnit.SECONDS.sleep(1);
			System.out.println(ShakeAvoid.get().check(111, 2000));
			TimeUnit.SECONDS.sleep(1);
			System.out.println(ShakeAvoid.get().check(111, 2000));
			TimeUnit.SECONDS.sleep(1);
			System.out.println(ShakeAvoid.get().check(111, 2000));
			TimeUnit.SECONDS.sleep(1);
			System.out.println(ShakeAvoid.get().check(111, 2000));
			TimeUnit.SECONDS.sleep(1);
			System.out.println(ShakeAvoid.get().check(111, 2000));

结果就是

true
false
true
false
true

需要注意,对于这种应用场景,生效的是单位时间内的第一次

“空间”防抖

如果我们把规则从时间间隔换为重复次数,那么就变成单位次数内只允许生效一次
一个简单的应用场景就是【每3次只生效1次】,同样地只需要一个简单工具类即可解决:

public class ShakeAvoid {
	private static class AvoidShakeHolder {
		private static final ShakeAvoid INSTANCE = new ShakeAvoid();
	}

	/**
	 * 记录次数
	 */
	private Map<Integer, Integer> mCounterMap;

	private ShakeAvoid() {
		mCounterMap = new HashMap<>();
	}

	public static ShakeAvoid get() {
		return AvoidShakeHolder.INSTANCE;
	}

	/**
	 * @param id       标志
	 * @param maxTimes 最大重复次数
	 * @return 是否符合规则
	 */
	public boolean check(int id, int maxTimes) {
		Integer counter = mCounterMap.get(id);
		if (counter != null && counter < maxTimes) {
			counter++;
			mCounterMap.put(id, counter);
			return false;
		} else {
			mCounterMap.put(id, 1);
		}
		return true;
	}
}

这一次调用不再强调时间,而是强调次数:

			System.out.println(ShakeAvoid.get().check(222, 3));
			System.out.println(ShakeAvoid.get().check(222, 3));
			System.out.println(ShakeAvoid.get().check(222, 3));
			System.out.println(ShakeAvoid.get().check(222, 3));
			System.out.println(ShakeAvoid.get().check(222, 3));
			System.out.println(ShakeAvoid.get().check(222, 3));

依据规则结果则是:

true
false
false
true
false
false

同样的,这里的应用场景,生效的是单位次数内的第一次

这种“空间防抖”更近于“过滤重复项”的思想,当然在大多数环境下我们没有必要去制定这样一个规则去过滤,更多地往往是直接是同类项过滤,比如【2,2,2,2仅第一个2生效】。

进阶应用

以上的简单场景如果逻辑不复杂,都可以自行构建工具类进行判断,如果需求五花八门,规则更复杂,那么参考RxJava或是直接使用其API也不失为一个更好的选择。

RxJava中有debounce和throttle相关的API,debounce指防抖动,throttle指节流;

throttleFirst

这里和上面的时间防抖作用一样,从API名字也可以看出,生效的是一个时间段的第一次,我们在上下游都加上时间戳的打印,以便观察时间的变动:

		Observable.create(new ObservableOnSubscribe<Integer>() {
			@Override
			public void subscribe(ObservableEmitter<Integer> observableEmitter) throws Exception {
				for (int i = 1; i < 7; i++) {
					System.out.println(simpleTime() + " : emit->" + i);
					observableEmitter.onNext(i);
					TimeUnit.MILLISECONDS.sleep(300);
				}
				observableEmitter.onComplete();
			}
		}).throttleFirst(700, TimeUnit.MILLISECONDS)
				.subscribe(new Consumer<Integer>() {
					@Override
					public void accept(Integer num) throws Exception {
						System.out.println(simpleTime() + " : accept<-" + num);
					}
				});

	private static long simpleTime() {
		long mills = System.currentTimeMillis();
		double result = mills / 100;
		long t = Math.round(result) * 100;
		return t;
	}

每300ms触发一次,设定时间间隔阈值为700ms,结果为:

1617199273400 : emit->1
1617199273400 : accept<-1
1617199273700 : emit->2
1617199274000 : emit->3
1617199274300 : emit->4
1617199274300 : accept<-4
1617199274600 : emit->5
1617199274900 : emit->6

可以看到1生效后立刻被下游接收,2、3均被过滤,立刻被下游接收就代表着时间间隔判断是从此时开始的,后面的4也一样。
这里生效的时间节点就是第一个满足条件的数据产生时。
用符号稍微表示一下,假定一个".“表示100ms,”|"表示间隔周期,那么以上场景可以这样表示:

|1...2...3.|..|4...5...6.|..

千万不要理解成下面这种:

|1...2...3.|..4...5..|.6...

因为我们判断时间间隔,都是从生效的那一个开始计算时间,并且时间点会随下一次的生效点重置;而不是从第一个开始计时,一直累积时间进行分割判断。
可参考官方文档throttlefirst

throttleLast

同样是以时间间隔防抖,但生效的是单位时间内最后一次
这样的规则更强调等待,类似于生活中等待公交车的场景,无论公交站来了多少人,都要等一次单位时间内的公交车才能带走,错过一次的只能等待下一个单位时间内的公交车。
依照上面的应用场景重写一次:

		Observable.create(new ObservableOnSubscribe<Integer>() {
			@Override
			public void subscribe(ObservableEmitter<Integer> observableEmitter) throws Exception {
				for (int i = 1; i < 7; i++) {
					System.out.println(simpleTime() + " : emit->" + i);
					observableEmitter.onNext(i);
					TimeUnit.MILLISECONDS.sleep(300);
				}
				observableEmitter.onComplete();
			}
		}).throttleLast(700, TimeUnit.MILLISECONDS)
				.subscribe(new Consumer<Integer>() {
					@Override
					public void accept(Integer num) throws Exception {
						System.out.println(simpleTime() + " : accept<-" + num);
					}
				});

同样是每300ms触发一次,设定时间间隔阈值为700ms,结果则为:

1617199384700 : emit->1
1617199385000 : emit->2
1617199385300 : emit->3
1617199385400 : accept<-3
1617199385600 : emit->4
1617199385900 : emit->5
1617199386100 : accept<-5
1617199386200 : emit->6

可以看到从上游发射3到下游接收到3,中间是有100ms延时的,这100ms延时就是在等待时间间隔补足700ms,补足时间后,3才会生效,后面的5同理。
也就是这里每个生效的时间节点其实是700ms的整数倍。
用符号表示为:

|1...2...3.|..4...5..|.6...

这里我们就可以发现,事情变成我们曾经理解过的一种场景。
这是因为必须要判断单位时间的最后一项,然而不到最后时间并不知道哪一项是最后一项,所以在这个时间段里的每一项其实都是记录了时间点的,于是整个阶段就被均分,形成每个时间间隔相等的区间。
最后取每个区间内的最后一项即可。
这里的时间节点的起始和运行起始时间是一致的,也就是说如果我们在1前面再加延时,也会被计算进去,比如将上游改为:

				TimeUnit.MILLISECONDS.sleep(200);
				for (int i = 1; i < 7; i++) {
					System.out.println(simpleTime() + " : emit->" + i);
					observableEmitter.onNext(i);
					TimeUnit.MILLISECONDS.sleep(300);
				}
				observableEmitter.onComplete();

在起始位置再添加200ms延时,那么结果将变为:

1617240278400 : emit->1
1617240278700 : emit->2
1617240278900 : accept<-2
1617240279000 : emit->3
1617240279300 : emit->4
1617240279600 : emit->5
1617240279600 : accept<-4
1617240279900 : emit->6

用符号表示:

..1...2..|.3...4...|5...6...

这里4与5之间可能会因为时间误差都会输出,可能是4,也可能是5,不过原理是一样的。这里强调的是时间是从一开始就开始计算累积。
可以参考官方文档throttleLast

throttleLatest

throttleLastest和throttleLast有相通之处,同样是强调等待,但这里是等待超时,必须要确定超时后才能真正生效。
将上面的例子调用方法改为throttleLastest后,

		Observable.create(new ObservableOnSubscribe<Integer>() {
			@Override
			public void subscribe(ObservableEmitter<Integer> observableEmitter) throws Exception {
				for (int i = 1; i < 7; i++) {
					System.out.println(simpleTime() + " : emit->" + i);
					observableEmitter.onNext(i);
					TimeUnit.MILLISECONDS.sleep(300);
				}
				observableEmitter.onComplete();
			}
		}).throttleLatest(700, TimeUnit.MILLISECONDS)
				.subscribe(new Consumer<Integer>() {
					@Override
					public void accept(Integer num) throws Exception {
						System.out.println(simpleTime() + " : accept<-" + num);
					}
				});

得到的结果是:

1617199630500 : emit->1
1617199630500 : accept<-1
1617199630800 : emit->2
1617199631100 : emit->3
1617199631200 : accept<-3
1617199631400 : emit->4
1617199631700 : emit->5
1617199631900 : accept<-5
1617199632000 : emit->6

虽然看结果只多了1,但逻辑并不尽然相同。
可以看到,当1产生后,立即被下游接收(生效),然后等待700ms,才会判断决定下游接收到的是谁,而这个700ms的区间内最近的一个是3,于是3在补足超时时间700ms才会被下游接收,同样以3被下游接收的这个时间节点为基准,下一个700ms后会再判断出下一个接收的是谁,以此类推。
这里的时间节点,是由以第一个生效数据的时间为起始的700ms的周期节点,和上面的throttleLast是有区别的。
可以用符号表示为:

1|...2...3.|..4...5..|.6...

拣出每个时间段最后一个即可,即1.3.5。
可参考官方文档throttleLatest

debounce

传统意义上的防抖,相邻数据之间的间隔都必须独立计算,只有大于所设置的阈值,才能让前一个数据生效。
我们为官方的例子加上时间戳再看:

		Observable<String> source = Observable.create(emitter -> {
			System.out.println(simpleTime()+" : emit->A");
			emitter.onNext("A");

			Thread.sleep(1_500);
			System.out.println(simpleTime()+" : emit->B");
			emitter.onNext("B");

			Thread.sleep(500);
			System.out.println(simpleTime()+" : emit->C");
			emitter.onNext("C");

			Thread.sleep(250);
			System.out.println(simpleTime()+" : emit->D");
			emitter.onNext("D");

			Thread.sleep(2_000);
			System.out.println(simpleTime()+" : emit->E");
			emitter.onNext("E");
			emitter.onComplete();
		});

		source.subscribeOn(Schedulers.io())
				.debounce(1, TimeUnit.SECONDS)
				.blockingSubscribe(
						item -> System.out.println(simpleTime()+" : accept<-" + item),
						Throwable::printStackTrace,
						() -> System.out.println("onComplete"));

触发时间不一,设置时间间隔为1s,结果为:

1617200693800 : emit->A
1617200694800 : accept<-A
1617200695300 : emit->B
1617200695800 : emit->C
1617200696100 : emit->D
1617200697100 : accept<-D
1617200698100 : emit->E
1617200698100 : accept<-E
onComplete

可以看到,上游的数据必须等待1秒的时间空档(超时),才能被下游接收,如果中间有其他数据插入,那么这个时间空档就要重置,进行下一轮的等待。
但有一个特殊的地方是如果调用了onComplete,那么最后一个数据将不会再等待,直接生效被下游接收。
同样用符号尝试表示一下,不过用一个"."表示250ms:

A....|..B..C.D....|....E

只有AB和DE之间满足了超过1s的间隔时间,所以A和D会被下游接收,加上最后一个E被挤压到下游,下游输出即为A、D、E。

机制探究

RxJava的操作符固然好用,但如果知其然而不知所以然终究是雾里看花,难以真正掌握,所以我们就上面的几个操作符的源码来看是通过怎样的方式实现的。
在这之前,也可以大致作一个猜测,然后来看源码验证是否与猜测想符合。

throttle

首先是throttleFirst,其核心实现是在ObservableThrottleFirstTimed.DebounceTimedObserver中:

        volatile boolean gate;

        @Override
        public void onNext(T t) {
            if (!gate) {
                gate = true;

                downstream.onNext(t);

                Disposable d = get();
                if (d != null) {
                    d.dispose();
                }
                DisposableHelper.replace(this, worker.schedule(this, timeout, unit));
            }
        }

        @Override
        public void run() {
            gate = false;
        }

可以发现原理很简单,就是通过标记值gate(RxJava2.x版本中有另外一个标记done,在3.x已被废弃),来判断哪些数据是需要发送到下游的,当标记均为false时,数据就是生效的;
重点是改变标记值的时机
很明显,是利用一个延时任务来进行变化值,

		worker.schedule(this, timeout, unit)

这个延时timeout对于上文的测试代码来说正是设置的时间间隔700ms。
另外注意到,这个延时任务是在一个数据生效(被发往下游)后才开始执行的,这就与上文时间戳的打印相印证。

throttleLast则直接套用操作符sample的源码,核心逻辑在ObservableSampleTimed.SampleTimedNoLast中,其主要逻辑代码为:

        @Override
        public void onSubscribe(Disposable d) {
            if (DisposableHelper.validate(this.upstream, d)) {
                this.upstream = d;
                downstream.onSubscribe(this);

                Disposable task = scheduler.schedulePeriodicallyDirect(this, period, period, unit);
                DisposableHelper.replace(timer, task);
            }
        }

        @Override
        public void onNext(T t) {
            lazySet(t);
        }
        
        void emit() {
            T value = getAndSet(null);
            if (value != null) {
                downstream.onNext(value);
            }
        }
        
        @Override
        public void run() {
            emit();
        }
        
        @Override
        public void onError(Throwable t) {
            cancelTimer();
            downstream.onError(t);
        }

        @Override
        public void onComplete() {
            cancelTimer();
            complete();
        }

lazySet和getAndSet视作线程安全的缓存策略,用于存取数据;
这里不再像上面throttleFirst要在每次数据生效的时候去调用延时任务,而是一开始就开启了一个定时任务,其起始延时时间和循环周期都是一样的,以上面的测试代码而言,period就是700ms:

scheduler.schedulePeriodicallyDirect(this, period, period, unit);

定时去调用emit方法,而emit也很好理解,就是去取出onNext中被lazySet的值,也就是取出这个定时周期内上游的最后一个值。
直到结束才会取消这个定时任务。

throttleLastest的核心逻辑在ObservableThrottleLatest.ThrottleLatestObserver中,

        volatile boolean timerFired;

        boolean timerRunning;

        @Override
        public void onNext(T t) {
            latest.set(t);
            drain();
        }

        @Override
        public void onError(Throwable t) {
            error = t;
            done = true;
            drain();
        }

        @Override
        public void onComplete() {
            done = true;
            drain();
        }

        @Override
        public void run() {
            timerFired = true;
            drain();
        }

        void drain() {
            if (getAndIncrement() != 0) {
                return;
            }

            int missed = 1;

            AtomicReference<T> latest = this.latest;
            Observer<? super T> downstream = this.downstream;

            for (;;) {

                for (;;) {
					//略	
                    boolean d = done;
                    
                    T v = latest.get();
                    boolean empty = v == null;

 					//略
                    if (d) {
                        v = latest.getAndSet(null);
                        if (!empty && emitLast) {
                            downstream.onNext(v);
                        }
                        downstream.onComplete();
                        worker.dispose();
                        return;
                    }
                    
                    if (empty) {
                        if (timerFired) {
                            timerRunning = false;
                            timerFired = false;
                        }
                        break;
                    }

                    if (!timerRunning || timerFired) {
                        v = latest.getAndSet(null);
                        downstream.onNext(v);

                        timerFired = false;
                        timerRunning = true;
                        worker.schedule(this, timeout, unit);
                    } else {
                        break;
                    }
                }

                missed = addAndGet(-missed);
                if (missed == 0) {
                    break;
                }
            }
        }

lastest同样可视为一个缓存,用于存取;
可以观察到,这里用的策略又有所不同,是在一个循环中去执行延时任务,判断条件仍然是两个标记,timerFired和timerRunning,分别代表着这个循环是处于跳出打断状态持续运行状态
当empty为true时,代表上游没有数据,直接跳出;
如果上游有数据,那么将进行下一个判断,也就是下面的判断:

                    if (!timerRunning || timerFired) {
                        v = latest.getAndSet(null);
                        downstream.onNext(v);

                        timerFired = false;
                        timerRunning = true;
                        worker.schedule(this, timeout, unit);
                    } else {
                        break;
                    }

只有在timerRunning为true且timerFired为false的时候,才会跳出;其他情况都会去取上游最后一个值,并发送到下游,然后进行一系列状态改变。
数据生效(发送到下游)的这一刻,这个循环是处于运行状态的,所以timerFired置为false而timerRuning置为true;
但因为要执行一个延时任务,这个延时任务的作用是将timerFired置为true,并再进这个循环体;

所以第一个数据一定会被发送到下游,因为timerRunning和timerFired默认为false,会直接将数据发送到下游,并且以此时为时间节点,开启延时,会再重复这个动作,此后的逻辑就和throttleFirst相似了。
以及,由于下面这段代码的原因,最后一个数据也一定会被发送到下游:

                    if (d) {
                        v = latest.getAndSet(null);
                        if (!empty && emitLast) {
                            downstream.onNext(v);
                        }
                        downstream.onComplete();
                        worker.dispose();
                        return;
                    }

d就是标记done,会在onComplete时置为true,所以最后一个数据也会被发送到下游。

debounce

debounce的原理比较有趣,有一种“回头看"的感觉:

        volatile long index;

        @Override
        public void onNext(T t) {
            if (done) {
                return;
            }
            long idx = index + 1;
            index = idx;

			//略
            DebounceEmitter<T> de = new DebounceEmitter<>(t, idx, this);
            timer = de;
            d = worker.schedule(de, timeout, unit);
            de.setResource(d);
        }

        void emit(long idx, T t, DebounceEmitter<T> emitter) {
            if (idx == index) {
                downstream.onNext(t);
                emitter.dispose();
            }
        }

    static final class DebounceEmitter<T> extends AtomicReference<Disposable> implements Runnable, Disposable {

        private static final long serialVersionUID = 6812032969491025141L;

        final T value;
        final long idx;
        final DebounceTimedObserver<T> parent;

        final AtomicBoolean once = new AtomicBoolean();

        DebounceEmitter(T value, long idx, DebounceTimedObserver<T> parent) {
            this.value = value;
            this.idx = idx;
            this.parent = parent;
        }

        @Override
        public void run() {
            if (once.compareAndSet(false, true)) {
                parent.emit(idx, value, this);
            }
        }
    }

流程也很简单,给定一个索引index,上游每产生一个数据,index便加1,且将index保存到DebounceEmitter中保存,并给DebounceEmitter定一个延时任务,让其在指定时间后来检查当前的索引和保存的索引是否相等;
如果相等,说明这个指定时间期间没有新数据产生,即没有“抖动”;
如果不相等,说明有其他数据产生,即产生了“抖动”;
一句话解释就是“让DebounceEmitter定时回头看下有没有跟着其他人,有就抖了,没有就不抖”。

以上源码中Rx相关版本是RxJava3.x。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值