How to implement Periodic processing of user input?
Asked Answered
X

4

12

My current Android application allows users to search for content remotely.

e.g. The user is presented with an EditText which accepts their search strings and triggers a remote API call that returns results that match the entered text.

Worse case is that I simply add a TextWatcher and trigger an API call each time onTextChanged is called. This could be improved by forcing the user to enter at least N characters to search for before making the first API call.

The "Perfect" solution would have the following features:-

Once the user starts entering search string(s)

Periodically (every M milliseconds) consume the entire string(s) entered. Trigger an API call each time the period expires and the current user input is different to the previous user input.

[Is it possible to have a dynamic timeout related to the entered texts length? e.g while the text is "short" the API response size will be large and take longer to return and parse; As the search text gets longer the API response size will reduce along with "inflight" and parsing time]

When the user restarts typing into the EditText field restart the Periodic consumption of text.

Whenever the user presses the ENTER key trigger "final" API call, and stop monitoring user input into the EditText field.

Set a minimum length of text the user has to enter before an API call is triggered but combine this minimum length with an overriding Timeout value so that when the user wishes to search for a "short" text string they can.

I am sure that RxJava and or RxBindings can support the above requirements however so far I have failed to realise a workable solution.

My attempts include

private PublishSubject<String> publishSubject;

  publishSubject = PublishSubject.create();
        publishSubject.filter(text -> text.length() > 2)
                .debounce(300, TimeUnit.MILLISECONDS)
                .toFlowable(BackpressureStrategy.LATEST)
                .subscribe(new Consumer<String>() {
                    @Override
                    public void accept(final String s) throws Exception {
                        Log.d(TAG, "accept() called with: s = [" + s + "]");
                    }
                });


       mEditText.addTextChangedListener(new TextWatcher() {
            @Override
            public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) {

            }

            @Override
            public void onTextChanged(final CharSequence s, final int start, final int before, final int count) {
                publishSubject.onNext(s.toString());
            }

            @Override
            public void afterTextChanged(final Editable s) {

            }
        });

And this with RxBinding

 RxTextView.textChanges(mEditText)
                .debounce(500, TimeUnit.MILLISECONDS)
                .subscribe(new Consumer<CharSequence>(){
                    @Override
                    public void accept(final CharSequence charSequence) throws Exception {
                        Log.d(TAG, "accept() called with: charSequence = [" + charSequence + "]");
                    }
                });

Neither of which give me a conditional filter that combines entered text length and a Timeout value.

I've also replaced debounce with throttleLast and sample neither of which furnished the required solution.

Is it possible to achieve my required functionality?

DYNAMIC TIMEOUT

An acceptable solution would cope with the following three scenarios

i). The user wishes to search for the any word beginning with "P"

ii). The user wishes to search for any word beginning with "Pneumo"

iii). The user wishes to search for the word "Pneumonoultramicroscopicsilicovolcanoconiosis"

In all three scenarios as soon as the user types the letter "P" I will display a progress spinner (however no API call will be executed at this point). I would like to balance the need to give the user search feedback within a responsive UI against making "wasted" API calls over the network.

If I could rely on the user entering their search text then clicking the "Done" (or "Enter") key I could initiate the final API call immediately.

Scenario One

As the text entered by the user is short in length (e.g. 1 character long) My timeout value will be at its maximum value, This gives the user the opportunity to enter additional characters and saves "wasted API calls".

As the user wishes to search for the letter "P" alone, once the Max Timeout expires I will execute the API call and display the results. This scenario gives the user the worst user experience as they have to wait for my Dynamic Timeout to expire and then wait for a Large API response to be returned and displayed. They will not see any intermediary search results.

Scenario Two

This scenario combines scenario one as I have no idea what the user is going to search for (or the search strings final length) if they type all 6 characters "quickly" I can execute one API call, however the slower they are entering the 6 characters will increase the chance of executing wasted API calls.

This scenario gives the user an improved user experience as they have to wait for my Dynamic Timeout to expire however they do have a chance of seeing intermediary search results. The API responses will be smaller than scenario one.

Scenario Three

This scenario combines scenario one and two as I have no idea what the user is going to search for (or the search strings final length) if they type all 45 characters "quickly" I can execute one API call (maybe!), however the slower they type the 45 characters will increase the chance of executing wasted API calls.

I'am not tied to any technology that delivers my desired solution. I believe Rx is the best approach I've identified so far.

Xe answered 14/3, 2018 at 12:17 Comment(4)
i think if the response is that large when the text is short, then you need to use some kind of paginationTrichroism
@Trichroism Im a long way from addressing pagination. I need to get an RxBinding/RxJava solution for satisfying all (or most) of my initial requirementsXe
Can you explain a bit more about the "dynamic timeout"? Do you want to show a loader while your response comes(which will be shown for a longer duration in case of short texts and for shorter duration in case of long texts)?Threonine
Also, is a solution without RxJava and/or RxBinding acceptable?Threonine
L
8

Something like this should work (didn't really try it)

 Single<String> firstTypeOnlyStream = RxTextView.textChanges(mEditText)
            .skipInitialValue()
            .map(CharSequence::toString)
            .firstOrError();

    Observable<CharSequence> restartTypingStream = RxTextView.textChanges(mEditText)
            .filter(charSequence -> charSequence.length() == 0);

    Single<String> latestTextStream = RxTextView.textChanges(mEditText)
            .map(CharSequence::toString)
            .firstOrError();

    Observable<TextViewEditorActionEvent> enterStream =
            RxTextView.editorActionEvents(mEditText, actionEvent -> actionEvent.actionId() == EditorInfo.IME_ACTION_DONE);

    firstTypeOnlyStream
            .flatMapObservable(__ ->
                    latestTextStream
                            .toObservable()
                            .doOnNext(text -> nextDelay = delayByLength(text.length()))
                            .repeatWhen(objectObservable -> objectObservable
                                    .flatMap(o -> Observable.timer(nextDelay, TimeUnit.MILLISECONDS)))
                            .distinctUntilChanged()
                            .flatMap(text -> {
                                if (text.length() > MINIMUM_TEXT_LENGTH) {
                                    return apiRequest(text);
                                } else {
                                    return Observable.empty();
                                }
                            })
            )
            .takeUntil(restartTypingStream)
            .repeat()
            .takeUntil(enterStream)
            .mergeWith(enterStream.flatMap(__ ->
                    latestTextStream.flatMapObservable(this::apiRequest)
            ))
            .subscribe(requestResult -> {
                //do your thing with each request result
            });

The idea is to construct the stream based on sampling rather then the text changed events itself, based on your requirement to sample each X time.

The way I did it here, is to construct one stream (firstTypeOnlyStream for the initial triggering of the events (the first time user input text), this stream will start the entire processing stream with the first typing of the user, next, when this first trigger arrives, we will basically sample the edit text periodically using the latestTextStream. latestTextStream is not really a stream over time, but rather a sampling of the current state of the EditText using the InitialValueObservable property of RxBinding (it simply emits on subscription the current text on the EditText) in other words it's a fancy way to get current text on subscription, and it's equivalent to:
Observable.fromCallable(() -> mEditText.getText().toString());
next, for dynamic timeout/delay, we update the nextDelay based on the text length and using repeatWhen with timer to wait for the desired time. together with distinctUntilChanged, it should give the desired sampling based on text length. further on, we'll fire the request based on the text (if long enough).

Stop by Enter - use takeUntil with enterStream which will be triggered on Enter and it also will trigger the final query.

Restarting - when the user 'restarts' typing - i.e. text is empty, .takeUntil(restartTypingStream) + repeat() will stop the stream when empty string enter, and restarts it (resubscribe).

Locust answered 18/3, 2018 at 17:59 Comment(0)
G
1

Well, you could use something like this:

RxSearch.fromSearchView(searchView)
            .debounce(300, TimeUnit.MILLISECONDS)
            .filter(item -> item.length() > 1)
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe(query -> {
                adapter.setNamesList(namesAPI.searchForName(query));
                adapter.notifyDataSetChanged();
                apiCallsTextView.setText("API CALLS: " + apiCalls++);
            });    

public class RxSearch {
    public static Observable<String> fromSearchView(@NonNull final SearchView searchView) {
        final BehaviorSubject<String> subject = BehaviorSubject.create("");

        searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {

            @Override
            public boolean onQueryTextSubmit(String query) {
                subject.onCompleted();
                return true;
            }

            @Override
            public boolean onQueryTextChange(String newText) {
                if (!newText.isEmpty()) {
                    subject.onNext(newText);
                }
                return true;
            }
        });

        return subject;
    }
}

blog referencia

Glennieglennis answered 21/3, 2018 at 13:9 Comment(0)
S
1

your query can be easily solved by using RxJava2 methods, before i post code i will add the steps of what i am doing.

  1. add an PublishSubject that will take your inputs and add a filter to it which will check if the input is greater than two or not.
  2. add debounce method so that all input events that are fired before 300ms are ignored and the final query which is fired after 300ms is taken into consideration.
  3. now add a switchmap and add your network request event into it,
  4. Subscribe you event.

The code is as follows :

subject = PublishSubject.create(); //add this inside your oncreate
    getCompositeDisposable().add(subject
            .doOnEach(stringNotification -> {
                if(stringNotification.getValue().length() < 3) {
                    getMvpView().hideEditLoading();
                    getMvpView().onFieldError("minimum 3 characters required");
                }
            })
            .debounce(300,
                    TimeUnit.MILLISECONDS)
            .filter(s -> s.length() >= 3)
            .switchMap(s -> getDataManager().getHosts(
                    getDataManager().getDeviceToken(),
                    s).subscribeOn(Schedulers.io()))
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe(hostResponses -> {
                getMvpView().hideEditLoading();
                if (hostResponses.size() != 0) {
                    if (this.hostResponses != null)
                        this.hostResponses.clear();
                    this.hostResponses = hostResponses;
                    getMvpView().setHostView(getHosts(hostResponses));
                } else {
                    getMvpView().onFieldError("No host found");
                }

            }, throwable -> {
                getMvpView().hideEditLoading();
                if (throwable instanceof HttpException) {
                    HttpException exception = (HttpException) throwable;
                    if (exception.code() == 401) {
                        getMvpView().onError(R.string.code_expired,
                                BaseUtils.TOKEN_EXPIRY_TAG);
                    }
                }

            })
);

this will be your textwatcher:

searchView.addTextChangedListener(new TextWatcher() {
        @Override
        public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {

        }

        @Override
        public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
            subject.onNext(charSequence.toString());
        }

        @Override
        public void afterTextChanged(Editable editable) {

        }
    });

P.S. This is working for me!!

Sunk answered 23/3, 2018 at 10:24 Comment(0)
S
0

You might find what you need in the as operator. It takes an ObservableConverter which allows you to convert your source Observable into an arbitrary object. That object can be another Observable with arbitrarily complex behavior.

public class MyConverter implements ObservableConverter<Foo, Observable<Bar>> {
    Observable<Bar> apply(Observable<Foo> upstream) {
        final PublishSubject<Bar> downstream = PublishSubject.create();
        // subscribe to upstream
        // subscriber publishes to downstream according to your rules
        return downstream;
    }
}

Then use it like this:

someObservableOfFoo.as(new MyConverter())... // more operators

Edit: I think compose may be more paradigmatic. It's a less powerful version of as specifically for producing an Observable instead of any object. Usage is essentially the same. See this tutorial.

Stauffer answered 14/3, 2018 at 17:17 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.