Кюветы Android, Часть 3: SDK и RxJava (Финал)

Android SDK и «внезапности» — почти близнецы. Вы можете наизусть знать development.android.com, но при этом продолжать рвать на себе волосы при попытке сделать что-то покруче, чем форма-кнопка-прогрессбар.
Это заключительная, третья, часть из серии статей о Кюветах Android’а. На деле конечно их должно было быть десятка два, но я слишком скромный. На этот раз я наконец дорасказываю о неприятностях в SDK, с которыми мне довелось столкнуться, а так же затрону популярную нынче технологию ReactiveX.
В общем, Android SDK, RxJava, Кюветы — поехали!
Предыдущие части:

1. Activity.onOptionsItemSelected () не вызывается при установленном actionLayout


Ситуация
Как-то раз делал я тестовое задание. Было оно скучным, однообразным и… старым. Очень старым. PSD будто из прошлого века. Ну да не суть. Закончив все основные моменты, я принялся за вычитку всех отступов (агась, ручками, по линейке, по старинке). Дело шло хорошо, пока я не обнаружил неприятное несоответствие меню в приложении и в PSD'шке. Иконка была та же, а вот padding не тот. Я, как любитель приключений, не стал уменьшать иконку, а решил воспользоваться свойством actionLayout у MenuItem. Быстренько добавив новый layout с нужными мне параметрами и перепроверив отступы иконки на эмуляторе, я отправил решение и ушел в закат.Ситуация
Каково же было моё удивление, когда в ответ пришло (дословно): «Не работает редактирование». Приложение кстати я тестировал и так, и сяк и не должен был что-то упустить. Усиливало панику и лаконичная форма ответа из которой было не ясно, что же конкретно не работает…
… к счастью, долго искать не пришлось. Как уже стало понятно из заголовка, onOptionsItemSelected () просто игнорируется при установке кастомного layout’а.

Почему?
image


Именно с тех пор я четко осознал, что с Android’ом шутки плохи и даже изменения в дизайне могут повлечь изменения в поведении приложения. Ну и как всегда решение:

workaround
    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        MenuInflater inflater = getMenuInflater();
        inflater.inflate(R.menu.main_menu, menu);
        final Menu m = menu;
        final MenuItem item = menu.findItem(R.id.your_menu_item);
        item.getActionView().setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {   
                onOptionsItemSelected(item);
            }
        });
        return true;
    }


2. MVC/MVP/MVVM и прочие красивые слова vs. повотора экрана


Ситуация
Пожалуй, каждый из нас хотя бы раз слышал об MVC и его родственниках. На андроиде MVVM пока не построить (вру, можно, но пока что Бета), а вот MVC да MVP используются активно. Но как? Любому андроид-разработчику известно о том, что при повороте экрана Activity и Fragment полностью уничтожаются (а с ними и горсть нервов в придачу). Так как же применить, например, MVP и при этом иметь возможность повернуть экран без вреда для Presenter’а? Решение
И тут есть аж 3 основных решения:

  1. «Применяйте везде Fragment.setRetainInstance () и будет вам счастье» — или как-то так обычно говорят новички. К сожалению, подобное решение хоть и спасает поначалу, но рушит все планы при необходимости добавить Presenter в Activity. А такое бывает. Чаще всего при введение DualPane.
    Какой ещё DualPane?
    image

    А ещё setRetainInstance () имеет баг, которые невилирует его пользу. Но об этом чуть позже.
  2. Библиотеки, фреймворки и т.д., и т.п. К счастью, их довольно много: Moxy (статья «must read» по подобной теме), Mosby, Mortar и т.д. Некоторые из них заодно сохранят вам нервы при попытке восстановить так называемый View State.
  3. Ну и подход «очумелые ручки» — создаем Singleton, даём ему метод GetUniqueId () (пусть возвращает значения AtomicInteger’а с инкрементом по вызову). Создаем Presenter’а и сохраняем полученный ранее ID в Bundle у Activity/Fragment'е, а Presenter храним внутри Singleton’а с доступом по ID. Готово. Теперь ваш Presenter не зависит от lifecycle (ещё бы, он ж в Singleton’е). Не забудье только удалять Presenter’ов в onDestroy ()!

3. TextView с картинкой


И как обычно один не Кювет, но совет.
Что вы предпримите, если вам нужно будет сделать что-то наподобие такого?

Иконка с надписью
image


Если ваш ответ «Пф! Какие проблемы? TextView да ImageView в LinearLayout или RelativeLayout» — тогда этот совет для вас. Как ни странно, у TextView существует свойство TextView.drawable{ANY_SIDE} вместе с TextView.drawablePadding! Они делают именно то, что предполагается и никаких вам вложенных layout'ов.

Как выглядят разные TextView.drawable{ANY_SIDE}
image


Честно признаюсь, сам узнал об этом свойство сравнительно недавно и то случайно, ведь даже в голову не приходило искать у TextView свойства, относящиеся к картинкам.

4. Fragment.setRetainInstance () позволяет сохранить только прямых потомков Activity (AppCompat)


Ситуация
Если ваш отец — Джон Тайтор, а мать — Сара Коннор, и вы пришли из далекого 2013, то в вас ещё свежо чувство ненависти к вложенным Fragment’ам. Действительно, в то время было довольно сложно совладать с их «непослушанием» (тыц, тыц) и «код с вложенными Fragment’ами» быстренько превращался в «код с костылями».
В то время я ещё только начинал программировать и, начитавшись подобных ужасов, зарекся брать вложенные Fragment’ы в руки.
Шло время, вложенностью Fragment’ов я не пользовался, а все новости этого плана почему-то проходили мимо меня… И вот, внезапно, я наткнулся на новость (извините, ссылку посеял) о том, что Fragment’ы то теперь во всю Nested и вообще жизнь == сказка. И что сказать — я поверил! Создал проект, накатал пример, где hash Presenter’ов у Fragment’ов преобразовывался в цвет (это бы сразу позволило определить, сработал ли retain), запустил, повернул экран и…И…?
И потратил все выходные в поисках причины, почему сохраняются лишь Fragment’ы первого уровня (те, что хранятся в самом Activity). Естественно первое, на что я стал грешить — на самого себя. Перерыл весь код, начиная с кода покраски, заканчивая искоренением MVP, поизучал исходники SDK, прорыл тонны постов по Nested Fragment’ам (а их такая туча, что даже жалко разработчиков становится), переустановил эмулятор (!) и лишь к концу последнего выходного обнаружил ЭТО!
Для тех, кому лень читать: Fragment.setRetainInstance () удерживает Fragment от уничтожения при помощи FragmentManager — с этим всё окей. Однако почему-то кто-то из разработчиков взял, да и добавил строчку mFragmentManager = null; , и только для Fragment’овой реализации — поэтому то у Activity и было всё впорядке!
Почему, зачем и как так вышло — интересные вопросы, которые останутся без ответа. Этот однострочный баг тянется уже аж 2.5 версии. В приведенной ранее ссылке (для ленивых, она же) описывается workaround на рефлексии. К сожалению, пока что это единственный способ решения проблемы (ну кроме полного копирования исходников к себе в проект конечно же). Сама проблема ещё более детально описана на баг-трекере.

p.s. Машину времени не продам ┬┴┬┴┤(・_├┬┴┬┴

5. RxJava: разница между observeOn () и subscribeOn ()


Пожалуй, начну с самого простого и при этом самого важного.
Когда я только взялся за Rx, мне было совершенно не ясна разница между этими методами. С точки зрения логики, subscribeOn () изменяет Scheduler, на котором вызывается subscribe (). Но… с точки зрения ещё одной логики, Subscriber наследует Observer, а что делает Observer? Observe’ирует наверно. И вот тут и происходил когнтивный диссонанс. Понятности не привносили ни google, ни stackoverflow, ни даже официальные marbles. Но конечно же подобное знание крайне важно и пришло само после недели-двух ошибок со Scheduler’ами.
Я частенько слышу этот вопрос от своих знакомых и иногда встречаю на различных форумах, поэтому вот пояснение для тех, кто ещё только собирается быть «реактивным» или использует эти операторы просто интутивно, не заботясь о последствиях:

Код
Observable.just(null)
        .doOnNext(v0id -> Log.i("TAG", "0")) // Выполнится на: computation
        
        .observeOn(Schedulers.newThread())
        .doOnNext(v0id -> Log.i("TAG", "1")) // Выполнится на: newThread
        
        .observeOn(Schedulers.io()) // io
        .doOnNext(v0id -> Log.i("TAG", "2")) Выполнится на: io

        .subscribeOn(Schedulers.computation())
        .subscribe(v0id -> Log.i("TAG", "3")); // По-прежнему выполнится на: io



Полагаю (по своему опыту), больше всего непонятности вносит то, что повсюду ReactiveX продвигается со слоганом «Всё — это поток». В итоге, новичок ожидает, что каждый оператор влияет лишь на следующие за ним операторы, но никак не на весь поток целиком. Однако, это не так. Например, startWith () влияет на начало потока, а finallyDo — на его окончание.
А что же касается имён, покопавшись в исходниках Rx, обнаруживаешь, что данные генерируются не классом Observable (внезапно, да?), а классом OnSubscribe. Думаю именно отсюда такое путающее именование оператора subscribeOn ().
Кстати, крайне советую новичкам, да и матёрым знатокам, ознакомиться с либой для логирования Frodo. Сохраните себе очень много времени, ибо дебажить Rx-код — та ещё задачка.

6. RxJava: Operator’ы и Transformer’ы


Ситуация
Частенько случается так, что Rx-код разрастается и хочется его как-то сократить. Способ вызовов методов в виде chain’ов хорош, да, но вот переиспользование у него нулевое — придётся каждый раз вызывать всё те же методы делающие небольшие вещи и т.д. и т.п.
Столкнувшись с такой необходимостью, новички начинают думать в терминах ООП и создают, если уж совсем всё плохо, статик-методы и оборачивают начало цепочки вызовов в него. Если вовремя не покончить с таким подходом, это выродится в 3–4 обёртки на один Observable.

Реальный код в одном из реальных продуктов
RxUtils.HandleErrors(
        RxUtils.FireGlobalEvents(
                RxUtils.SaveToCaches(
                        Observable.defer(() -> storeApi.getAll(filter)).subscribeOn(Schedulers.io()), caches)
                , new StoreDataLoadedEvent()
        )
).subscribe(storeDataObserver);



В будущем это принесёт очень много проблем и тем, кто хочет просто понять, что делает код, и тем, кто хочет что-то изменить.И что теперь?
Chain-методы хороши именно тем, что они легко читаются. Советую как можно скорее научиться делать свои операторы и трансформеры. Это проще, чем кажется. Важно лишь понимать, что Operator работает с единицей данных (например, одним вызовом onNext () за раз), а Transformer преобразует сам Observable (тут вы сможете комбинировать обычные map () / doOnNext () и т.д. в одно целое).

Всё, закончили с детскими играми. Перейдём к Кюветам.

7. RxJava: Хаос в реализации Subscription’ов


Ситуация
Итак, Вы — реактивны! Вы попробовали, вам понравилось, вы хотите ещё! Вы уже пишите все тестовые задания на Rx. Вы переписываете свой домашний проект на Rx. Вы учите Rx’у свою кошку. И вот настал час создать Грааль — построить всю архитектуру на Rx. Вы готовы, вы дышите часто и томно и… начинаете… мооооя преееелестьК чему это я?
К сожалению, описанное выше — точно про меня. Я был настолько поражен мощю Rx, что решил полностью пересмотреть все свои подходы к написанию архитектуры. Можно сказать я пытался переизобрести MVVM через MVP + Rx.
Однако я допустим самую большую ошибку новичка — я решил, что я понял Rx.
Чтобы хорошо понять его, совершенно недостаточно написать пару-тройку Rx-приложений. Как только появится задача посложнее, чем связать клик и скачку фото, видео и тестовых данных с трех разных источников — вот тогда и проявят себя внезапные проблемы типа backpressure. А когда вы решите, что знаете backpressure, вы поймёте, что ничего не знаете о Producer (на которого даже нормальной документации нет)… Что-то я отвлекся (и в конце статьи станет понятно, почему).
В общем, суть проблемы опять в логике, которая идёт вразрез с тем, что имеется в действительности.

Как происходит listening обычно?
//...
data.registerListener(listener); // data.mListener == listener
//...
data.unregisterListener(); // data.mListener == null



Т.е., источник данных хранит ссылку на слушателя.
Но что же происходит в Rx? (осторожно, сейчас пойдут куски немного быдлокода)
observer.unsubscribe () через 500 мс

Код
Observable.interval(300, TimeUnit.MILLISECONDS).map(i -> "t1-" + i).subscribe(observer);
l("interval-1");
Observable.interval(330, TimeUnit.MILLISECONDS).map(i -> "t2-" + i).subscribe(observer);
l("interval-2");

Observable.timer(500, TimeUnit.MILLISECONDS).map(i -> "t3-" + i).subscribe(ignored -> observer.unsubscribe());



Результат
interval-1
interval-2
t1-0
t2-0



Полагаю, это самый ожидаемый результат. Да, в нашем класс Subscriber(он же Observer) хранит ссылки на источники данных, а не наоборот, поэтому после первой отписки всё затихает (на всякий случай напомню, что unsubscribed является одним из конечных состояний в Rx, из которого не выбраться никак, кроме как пересоздать всё и вся).subscription1.unsubscribe () через 500 мс
А теперь попробуем отписаться от Subscription, а не от Subscriber. С логической точки зрения, subscription должен связывать Observer и Observable как 1:1 и позволять выборочно отписаться от чего-то, но…

Код
Subscription subscription1 = Observable.interval(300, TimeUnit.MILLISECONDS).map(i -> "t1-" + i).subscribe(observer);
l("interval-1");
Observable.interval(330, TimeUnit.MILLISECONDS).map(i -> "t2-" + i).subscribe(observer);
l("interval-2");

Observable.timer(500, TimeUnit.MILLISECONDS).map(i -> "t3-" + i).subscribe(ignored -> subscription1.unsubscribe());



Результат
interval-1
interval-2
t1-0
t2-0



… внезапно результат точно такой же. Об этом я узнал далеко не в самом начале знакомства с Rx, хотя и использовал подобный подход долгое время думая, что оно работает. Дело в том, что Subscriber реализует интерфейс Observer и… Subscription. Т.е. тот Subscription, что мы имеем — это тот же Observer! Вот такой вот поворот.Observable.defer () и Observable.fromCallable ()
Думаю, defer () — это один из самых часто используемых операторов в Rx (где-то на равне с Observable.flatMap ()). Его задача — отложить инициализацию данных Observable’а до момента вызова subscribe (). Попробуем:

Код
Observable.defer(() -> Observable.just("s1")).subscribe(observer);
l("just-1");
Observable.defer(() -> Observable.just("s2")).subscribe(observer);
l("just-2");
observer.unsubscribe();
Observable.defer(() -> Observable.just("s3")).subscribe(observer);
l("just-3");



Результат
s1
just-1
s2
just-2
s3
just-3



«И что? Ничего неожиданного» — скажете вы. «Наверное» — отвечу я.
Но что если вам надоело писать Observable.just ()? В Rx и на это найдется ответ. Быстрый поиск в гугле находит метод Observable.fromCallable (), которые позволяет defer’ить не Observable, а обычную лямбду. Пробуем:

Код
Observable.fromCallable(() -> "z1").subscribe(observer);
l("callable-1");
Observable.fromCallable(() -> "z2").subscribe(observer);
l("callable-2");
observer.unsubscribe();
Observable.fromCallable(() -> "z3").subscribe(observer);
l("callable-3");



Результат (ВНИМАНИЕ! Уберите детей и хомячков от экрана)
z1
callable-1
callable-2
callable-3



Казалось бы, метод, делающий то же самое, только с другими исходными данными, но такая разница. Самое непонятное (если рассуждать логически) в этом результате то, что он не z1-z2-callable… (если верить всему, описанному до этого момента), а именно z1-callable…. В чём же дело? Дело в том, что…
А теперь к сути. Дело в том, что многие операторы написаны по разному. Кто-то перед очередным onNext () проверяет подписку Subscriber’а, кто-то проверяет её после эмита, но до конца onNext (), а кто-то и до, и после и т.д. Это вносит некоторый… хаос в ожидаемый результат. Но даже это не объясняет поведение Observable.fromCallable ().
Внутри Rx существует класс SafeSubscriber. Это именно тот класс, который ответственен за главный контракт Rx (ну тот, который гласит: «после onError () не будет больше onNext () и произойдёт отписка, и т.д., и т.п.»). И нужно ли использовать его (SafeSubscriber) в операторе или нет — нигде не прописано. В общем, Observable.fromCallable () вызывает обычный subscribe (), поэтому неявно создается SafeSubscriber и происходит unsubscribe () после эмита, а вот Observable.defer () вызывает unsafeSubscribe (), который не вызывает unsubscribe () по окончанию. Так что на самом деле (внезапно!) это Observable.defer () плохой, а не Observable.fromCallable ().

8. RxJava: repeatWhen () вместо ручной отписки/подписки


Ситуация
Нужно сделать обновление данных каждые Х-секунд. Загрузку новых данных, конечно же, нельзя делать до тех пор, пока не произойдёт загрузка старых (такое возможно из-за лагов, багов и прочей нечести). Что делать?
И в ответ начинается всякое: Observable.interval () с Observable.throttle () или AtomicBoolean, а некоторые даже через ручной unsubscribe () умудряются сделать. На деле, всё куда проще.Решение
Порой создается впечатление, что у Rx есть операторы на все случаи жизни. Так и сейчас. Существует метод repeatWhen (), который сделает всё за вас — переподпишется на Observable через заданный интервал:

Пример использования repeatWhen ()
Log.i("MY_TAG", "Loading data");
Observable.defer(() -> api.loadData()))
        .doOnNext(data -> view.setDataWithNotify(data))
        .repeatWhen(completed -> completed.delay(7_777, TimeUnit.MILLISECONDS))
        .subscribe(
                data -> Log.i("MY_TAG", "Data loaded"), 
                e -> {}, 
                v0id -> Log.i("MY_TAG", "Loading data")); // "Loading data" - никогда не выведется; "Data loaded" - будет повторяться каждые ~8 сек.



Единственный минус — поначалу не совсем ясно, как вообще этот метод работает. Но как обычно, вот вам хорошая статья по repeatWhen () / retryWhen ().retryWhen
Кстати помимо repeatWhen () есть ещё retryWhen (), делающий то же самое, но для onError (). Но в отличие от repeatWhen (), ситуации, где может пригодиться retryWhen () довольно специфичны. В случае, описанном выше, возможно, можно было бы добавить и его. Но в целом, лучше воспользоваться Rx Plugins/Hooks и повесить глобальный обработчик на интересующую ошибку. Это позволит не только переподписаться к любому Observable в случае ошибки, но ещё и оповестить об этом пользователя (я нечто подобное использую для SocketTimeoutException например).

Extra. RxJava: 16


Ну и наконец, то, из-за чего я вообще затеял писать про Кюветы. Проблема, которой я посвятил 2 недели своей жизни и до сих пор понятия не имею, что за… магия там творится… Но давайте по порядку.Ситуация
Нужно сделать экран авторизации, с проверкой на неверно заполненные поля и выдачей особого предупреждения на каждую 3ю ошибку.
Сама по себе задача не сложная, и именно поэтому я выбрал её в качестве «тестовой площадки» для Rx. Думал, решу, посмотрю, как Rx себя ведёт в деле, отличном от простой скачки данных с сервера.
Итак, код был примерно следующим:

Код обработки ошибок логина
PublishSubject wrongPasswordSubject = PublishSubject.create();
/*...*/
wrongPasswordSubject
        .compose(IndexingTransformer.Create())
        .map(indexed -> String.format(((indexed.index % 3 == 0) ? "GREAT ERROR" : "Simple error") + " #%d : %s", indexed.index, indexed.value))

        .observeOn(AndroidSchedulers.mainThread())
        .subscribe(message -> getView().setMessage(message));



Код обработки кнопки [Sign In]
private void setSignInAction() {
        getView().getSignInButtonObservable()
                .observeOn(AndroidSchedulers.mainThread()) 
                .doOnNext((v) -> getView().setSigningInState()) // ставим прогресс бар

                .observeOn(Schedulers.newThread())
                .withLatestFrom(formDataSubject, (v, formData) -> formData)
                .map(formData -> auth(formData.login, formData.password)) // логинимся. Бросает только WrongLoginOrPassException

                .lift(new SuppressErrorOperator<>(throwable -> wrongPasswordSubject.onNext(throwable.getMessage()))) // оповещаем об ошибке наш обработчик
                .compose(new UnObservableTransformer<>()) // тогда я ещё не знал про flatMap(). Код этого оператора не важен

                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(user -> getView().setSignedInState(user)); // happy end
}



Отложим претензии к Rx-стилю кода — плохо всё, сам знаю. Дело не в том, да и писалось это давно.
Итак, getView ().getSignInButtonObservable () возвращает Observable, полученный от RxAndroid’а для клика по кнопку [Sign In]. Это hot-observable, т.е., он никогда не будет в состоянии completed. События начинаются от него, проходят через map (), в котором происходит авторизация и далее по цепочке. Если же произошла ошибка, кастомный Operator перехватит ошибку и просто не пропустит её дальше:

SuppressErrorOperator
public final class SuppressErrorOperator implements Observable.Operator {
        final Action1 errorHandler;

        public SuppressErrorOperator(Action1 errorHandler) {
                this.errorHandler = errorHandler;
        }

        @Override
        public Subscriber call(final Subscriber subscriber) {
                return new Subscriber(subscriber) {
                        @Override
                        public void onCompleted() {
                                subscriber.onCompleted();
                        }

                        @Override
                        public void onError(Throwable e) {
                                errorHandler.call(e); // съели ошибку, дальше не пускаем
                        }

                        @Override
                        public void onNext(T t) {
                                subscriber.onNext(t);
                        }
                };
        }
}



Итак, вопрос. Что с этим кодом не так?
Если бы об этом спросили меня, я бы даже сейчас ответил: «всё ок». Ну разве что утечки памяти, ведь нигде нет сохранения Subscription. Да, в subscribe перезаписывается только onNext, но другие методы никогда и не вызовутся. Всё впорядке, работаем дальше.Боль
Завязка
И тут начинается самое странное. Код действительно работает. Однако я человек дотошный и посему решил нажать на кнопку авторизации… много раз. И, совершенно внезапно, обнаружил, что почему-то после 5 го «GREAT ERROR» прогресс-бар авторизации (который поставлен был через setSigningInState ()) не снялся (ещё эта функция выключает кнопку [Sign In]).
«Хм» — думаю я. Перепроверил функции во Fragment'е, ответственные за UI (вдруг там что-то не то вставил). Пересмотрел функцию auth (), авось там таймаут поставил для тестов. Нет. Всё впорядке.
Тогда я решил, что это гонка потоков. Запустил ещё раз и проверил снова… Ровно 5 «GREAT ERROR» и снова застой бесконечного прогресс-бара. И тут я напрягся. Запустил снова, а потом ещё и ещё. Ровно 5! Каждый раз ровно после 5 го «GREAT ERROR» кнопка перестает реагировать на нажатия, прогресс-бар крутится и тишина.
«Окей» — решил я, «уберу ка я setSigningInState (). Мало ли, Android любит играться с людьми. Вдруг там что-то в SDK сломалось и всё дело лишь в том, что я именно не могу нажать кнопку ещё раз, а не в том, что её обработчик не срабатывает». Нет. Не помогло.
К этому моменту я уже очень сильно напрягся. В LogCat пусто, никаких ошибок не было, приложение работает и не зависло. Просто обработчик больше не обрабатывает.Анализ
Оказалось, что меня обманула сама задача. Я считал количество «GREAT ERROR», однако на деле же нужно было считать количество нажатий кнопки. Ровно 16. Количество поменялось, а ситуация осталась.
Итак, код следующей попытки после избавления от всего ненужного:

Код с логами в doOnNext ()
private void setSignInAction() {
        getView().getSignInButtonObservable()
                .observeOn(AndroidSchedulers.mainThread())
                .doOnNext((v) -> l("1"))

                .observeOn(Schedulers.newThread())
                .doOnNext((v) -> l("2"))
                .map(v -> {
                        throw new RuntimeException();
                })
                .lift(new SuppressErrorOperator<>(throwable -> wrongPasswordSubject.onNext(throwable.getMessage())))

                .doOnNext((v) -> l("3"))
                .observeOn(AndroidSchedulers.mainThread())
                .doOnNext((v) -> l("4"))
                .subscribe(user -> runOnView(view -> view.setTextString("ON NEXT")));
}



И тут ситуация стала ещё страннее. С 1 по 15 клик шли как надо, выводились цифры »1» и »2», однако на 16ый раз последняя строка в логах была… »1»! Оно просто не дошло до генератора ошибок!
«Так может дело вовсе не в Exception’ах?!» — подумал я. Заменил throw new RuntimeException () на return null и… всё работает, все 4 цифры выводятся сколько бы я не кликал (помнится, тогда я прокликал более 100 раз надеясь, что всё же сейчас всё зависнет…, но нет).
К этому моменту пошел уже 2ой или 3ий день моих мучей и всё, что я к тому времени имел:

  • после 16 раза обработчик замолкает
  • проблема точно в Exception
  • почему-то doOnNext () не выводит »2», хотя Exception генерируется после него
  • клочёк волос в правой руке

Развязка… ну или хотелось бы
За последующую неделю я полностью прошерстил официальный сайт ReactiveX в поисках подсказки. Я заглянул в RxJava репозиторий на гитхабе, а точнее, в его wiki, но ответа я так и не нашел, поэтому я решился на отчаянный шаг и… начал применять «методы тыка».
Я перепробовал всё, что смог и наконец нашел то, что решило проблему: onBackpressureBuffer (). Что такое backpressure описано на wiki RxJava’вского репозитория, и как я уже отметил, он был мною прочтён во время поисков, однако магия по прежнему оставалась магией.
Для тех, кто не в курсе. Проблема backpressure возникает, когда оператор не успевает обрабатывать данные, приходящие ему от предыдущего оператора. Самый яркий пример — zip (). Если первый его оператор генерирует элементы 1 раз в минуту, а второй — 1 раз в секунду, то zip () загнётся. onBackpressureBuffer () — неявно вводит массив, в котором хранит все значения за всё время, генерируемые оператором и потому, zip () будет работать как задумано (правда вы в конце концов получите OutOfMemoryException, ну да ладно).
И тут соответственно вопрос, почему onBackpressureBuffer () вообще помог? Я запускал программу и так, и эдак. Даже пробовал по таймеру кликать по [Sign In] только раз в минуту (ну мало ли, вдруг я The Flash и слишком быстро кликаю?). Конечно же это не помогло.Финал
В итоге, всё же, я понял, что умирает код в момент observeOn (). «А он тут каким боком?» — спросите вы.» ¯\_(ツ)_/¯ » — отвечу я.
У меня ушло очень много времени на изучение его кода, и кода onBackpressureBuffer () и вообще всей структуры Observable. Тогда же я узнал о OnSubscribe-классе, Producer и других интересных вещах… однако всё это ни на йоту не приблизило меня к разгадке. Я не говорю, что я досканально разобрался в исходниках Rx, нет, это слишком круто, но насколько смог — не помогло, а копать ещё глубже — действительно непросто.
Конечно же я задал свой вопрос на stackoverflow, но ответа так и не получил.
Этот Кювет отнял у меня порядка 2ух недель несмотря на то, что onBackpressureBuffer () я обнаружил достаточно быстро (но кто будет использовать то, что решает проблему, не понимая, почему вообще проблема взялась?).

Используя свой текущий опыт, предположу, что observeOn () порождает Subscriber-обёртку над моим Subscriber и когда происходит Exception’ы, они накапливается в обёртке (ведь по контракту Exception должен быть один, так что никто не ожидал, что их будет 16). А когда приходит 17ый клик, observeOn () проверяет isUnsubscribed () и, т.к. оно равно true, никого не пускает. (но это лишь моя догадка).
Что касается магического числа 16 — это размер константы Backpressure Buffer для Android’а. Для обычной Java он был бы 128 и, возможно, тогда я бы никогда не узнал об этой ошибке. Стоило догадаться, что число 16 скорее всего связано с каким-то размером массива, но начинал я с числа 5 — поэтому я совсем не подумал об этом. К моменту перехода к числу 16 я уже был тведо уверен, что 2+2=17.
И самое последнее, то, что добавило больше всего магии — SuppressErrorOperator. Если бы ошибки изначально не игнорировались, я бы сразу заметил MissingBackpressureException и гадал в этом направлении. Сохранило бы пару-тройку дней. Хотя на деле же, всё равно остается странность — SuppressErrorOperator должен был поглотить все ошибки, включая MissingBackpressureException . Т.к. оператор не проверял тип ошибки, то всё должно было продолжать работать (разве что после 16ой попытки [Sign In] все последующие были бы всегда тщетными).

Заключение


Вот и подошла к концу последняя часть из серии. Несмотря на критику, на самом деле сама идиома Rx мне очень даже нравится — однажды попробовав реактив уже не хочется иметь ничего общего с Loader’ами и прочим. Ребята из Netflix явные молодцы.
Однако, Rx имеет и свои минусы: его сложно дебажить и некоторые операторы имеют непредсказуемые последствия. Описывать эти проблемы, полагаю, не стоит — пол статьи об этом. Но кое-что я всё же скажу. Rx — интересная, но непростая вещь. Есть много степеней Rx-головного мозга. Можно использовать его лишь для небольших действий (например, как результат Retrofit-вызвов), а можно пытаться строить всю архитектуру на Rx, применяя сложные операторы направо и налево, следя за десятками Subscription и т.д. (я тут как-то пытался сделать очередь команд для восстановления View State после поворота экрана через Backpressure с Producer. Советую вам не пробовать этого. Настоятельно). В общем, если не перебарщивать, то выйдет очень даже классно.
Для тех, кто ищет источники по Rx, нет ничего лучше, чем: официальный сайт со всеми операторами (Ctrl+F и вот вы уже знаете всё о каком-нибудь Scan), RxJava wiki на github’е и (для самых-самых новичков) интерактивные примеры операторов онлайн.
p.s. И если кто-нибудь из вас знает, что за магия творится с последнем Кювете — милости прошу в комментарии, личку или ещё куда. Буду рад подробностям больше, чем новогодним праздникам.

© Habrahabr.ru