スロットル/デバウンスの適用と原理


防振というと「カメラ防振」などの技術を思い浮かべる人が多いですが、カメラでいう防振とは補正・制振防振のことで、本当の防振です。電話では、実際には「 制御周波数 」を指します。
多くの場合、ソフトウェアのアンチシェイクとスロットリングは統合されていますが、最初に違いを大まかに区別することをお勧めします。

  • アンチシェイク
    周波数が高すぎるデータは失われ、周波数が低いデータのみが保持されます。
  • スロットリング
    単位時間または単位空間で、データを 1 回だけ保持します。

どちらも、確立されたルールに従ってイベントまたはデータをフィルタリングして、イベントまたはデータのトリガー頻度を制御するという目的を達成するものとして理解できます
アンチシェイクのルールは、頻度が高すぎるデータが無効なデータと見なされる限り、頻度が高すぎるデータ項目を除外することであり、スロットリングのルールは、有効なデータを単位時間ごとに 1 回保持することです

モバイル端末の実際のシーンでのいわゆる「アンチシェイク」は、多くの場合、スロットリングの概念に近いことがわかります。

基本的なアプリケーション

「時間」手ぶれ補正

最も一般的なアンチシェイク アプリケーションのシナリオは、一定期間内に複数回呼び出されることです.アンチシェイク処理後、時間のかかるタスクの送信やインターフェイスのクリックなど、単位時間あたり 1 回の呼び出しのみが許可されますボタン;
ここで「策定されたルール」は「単位時間に1回の呼び出しのみが許可される」であり、基準はtime、またはtime intervalです。

簡単な実用的な適用シナリオは [OK ボタンは 2 秒以内に 1 回クリックするだけで有効になる] であり、単位時間は 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

この適用シナリオでは、単位時間ごとに初めて有効になることに注意してください

「スペース」手ぶれ補正

ルールを時間間隔から繰り返し回数に変更すると、単位回数に 1 回だけ有効になります。
単純なアプリケーション シナリオは [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 にはデバウンスおよびスロットル関連の API があり、デバウンスはアンチシェイクを指し、スロットルはスロットリングを指します。

スロットルファースト

これは、上記の時間ブレ防止機能と同じです. 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;
	}

300 ミリ秒ごとにトリガーし、時間間隔のしきい値を 700 ミリ秒に設定すると、結果は次のようになります。

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は同じです.
ここで有効になる時間ノードは、条件に一致する最初のデータが生成されたときです。
「.」は 100 ミリ秒、「|」はインターバル期間を意味すると仮定して、記号を使用して少し表現すると、上記のシーンは次のように表現できます。

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

次のように解釈しないでください

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

時間間隔を判断するため、最初の時点からカウントして分割と判断の時間を累積するのではなく、有効な時点から時間を計算し、次の有効な時点で時点をリセットします。
公式ドキュメントのthrottlefirstを参照できます。

スロットルラスト

時間間隔でも手ぶれ防止ですが、単位時間あたり最後に有効になります。
この種のルールは、人生のバスを待つシーンと同様に、待つことに重点を置いています. バス停に何人来ても、単位時間内にバスを待たなければ、彼らを連れ去ることはできません.一度乗り遅れた人は、次の単位時間まで待つしかありません。
上記のアプリケーション シナリオに従って書き直します。

		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);
					}
				});

これも 300 ミリ秒ごとにトリガーされ、時間間隔のしきい値は 700 ミリ秒に設定され、結果は次のようになります。

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();

開始位置にさらに 200 ミリ秒の遅延を追加すると、結果は次のようになります。

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を参照できます。

スロットル最新

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 はタイムアウト期間を補う ダウンストリームが受信するのに 700 ミリ秒かかります. また、ダウンストリームがノード 3 を受信する時間に基づいて、次の 700 ミリ秒を使用して、次に誰が受信されるかを決定します.
ここでの時間ノードは、上記のthrottleLastとは異なり、最初の有効データの時間から始まる700msの周期ノードです。
次のように象徴的に表現できます。

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

各期間の最後の期間、つまり 1.3.5 を選択します。
公式ドキュメントのthrottleLatestを参照できます。

デバウンス

従来の意味での手ぶれ補正では、隣接するデータ間の間隔を個別に計算する必要があり、前のデータは、設定されたしきい値よりも大きい場合にのみ有効になります。
公式の例にタイムスタンプを追加しましょう:

		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"));

トリガー時間が異なります。時間間隔を 1 秒に設定すると、結果は次のようになります。

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 の間隔だけが 1 秒を超えるため、A と D は下流で受信され、最後の E は下流に圧縮され、下流の出力は A、D、E になります。

メカニズム探査

RxJavaの演算子は簡単に使えますが、知っていてもその理由が分からないと本当に理解するのは難しいので、上記演算子のソースコードを見てどのように実装されているか見てみましょう。
その前に、大まかな推測を行ってから、ソース コードの検証が推測と一致するかどうかを確認できます。

スロットル

1 つ目は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;
        }

原理は非常に単純であることがわかります.どのデータをタグ値ゲートを介して下流に送信する必要があるかを判断することです. . タグがすべて false の場合, データは機能するものです.ポイントは
タグの値をいつ変更するかです. 明らかに、遅延タスクを使用して値を変更すること
です。

		worker.schedule(this, timeout, unit)

この遅延タイムアウトは、上記のテスト コードで設定された 700 ミリ秒の時間間隔とまったく同じです。
また、この遅延タスクは、データの一部が有効になった (ダウンストリームに送信された) 後に実行されることに注意してください。これは、上記のタイムスタンプの出力によって確認されます。

throttleLast は、オペレータ サンプルのソース コードを直接適用します。コア ロジックは 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 のようにデータが有効になるたびに遅延タスクを呼び出す代わりに、スケジュールされたタスクが最初に開始されます上記のテスト コードでは、周期は 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 の 2 つのタグであることがわかります。このサイクルが中断連続動作から飛び出している状態であること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 は完了をマークするためのもので、onComplete のときに true に設定されるため、最後のデータもダウンストリームに送信されます。

デバウンス

デバウンスの原理はより興味深いもので、「振り返る」感覚があります。

        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);
            }
        }
    }

プロセスも非常に単純で、インデックス インデックスを指定すると、上流でデータが生成されるたびにインデックスが 1 増加し、インデックスが DebounceEmitter に保存され、遅延タスクが DebounceEmitter に設定されて現在の指定された時間後のインデックス. 保存されたインデックスと等しいかどうか;
等しい場合は、指定された期間中に新しいデータが生成されないことを意味します, つまり、「ジッター」がないことを意味します;
等しくない場合は、別のデータが生成される、つまり「ジッター」が生成されることを意味します;
1 つの文の説明は、「DebounceEmitter を定期的に振り返って、他の人をフォローしているかどうかを確認します。フォローしている場合は揺れます。フォローしていない場合は震えます」 、 私はしません。"

上記ソースコードのRx関連のバージョンはRxJava3.xです。

Guess you like

Origin blog.csdn.net/ifmylove2011/article/details/115342598