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

Предыдущие части:


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. Машину времени не продам T+T++(?_+T+T+

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<String> 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<T> implements Observable.Operator<T, T> {
	final Action1<Throwable> errorHandler;

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

	@Override
	public Subscriber<? super T> call(final Subscriber<? super T> subscriber) {
		return new Subscriber<T>(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. И если кто-нибудь из вас знает, что за магия творится с последнем Кювете — милости прошу в комментарии, личку или ещё куда. Буду рад подробностям больше, чем новогодним праздникам.

UPD: Совершенно внезапно вопрос про 16 на stackoverflow посетил akarnokd (один из основных контрибьютеров RxJava, как верно подметил artemgapchenko). Причина оказалась в том, что observeOn() decople'ит операторы до и после себя, а сам работает как backpressure buffer. Т.к. при возникновении Exception я не вызываю request(), а просто «проглатываю» данные, то и observeOn() отдает лишь столько, сколько запросили изначально — то есть константу 16. onBackpressureBuffer() же решает проблему потому, что он изначально запрашивает Long.MAX_VALUE. Оригинал ответа akarnokd'а.

Комментарии (17)


  1. artemgapchenko
    27.04.2016 10:10
    +1

    Rx мне очень даже нравится… Ребята из Netflix явные молодцы.

    Ну, если посмотреть по статистике коммитов, то на первом месте окажется akarnokd, автор reactive4java (первой реализации reactive extensions для JVM), никак не связанный с Netflix. Так что его тоже не стоит забывать. :)


    1. basnopisets
      27.04.2016 13:52
      +2

      он практически один сейчас и тянет проект после ухода Бена


      1. xGromMx
        27.04.2016 17:46

        Чего Бен ушел?


        1. basnopisets
          27.04.2016 17:53
          +2

          в Facebook ушел
          по гиту он уже год как практически не контрибьютит
          image


    1. basnopisets
      27.04.2016 17:54

      У Карнока еще блог полезный


  1. Beanut
    27.04.2016 11:01
    -5

    Узнал о drawableLeft/Right? Срочно пиши статью на хабр!


  1. aelimill
    27.04.2016 11:06
    +1

    По поводу пункта 3, там все же есть один кювет:
    На некоторых устройствах (точно помню что для Sony Xperia S) может не отображаться compound drawable, приходилось делать

    // Reset drawables
    this.label.setCompoundDrawables(null, null, null, null);
    this.label.setCompoundDrawablesWithIntrinsicBounds(drawable,null,null,null);


  1. snuk182
    27.04.2016 11:10

    > Если ваш ответ «Пф! Какие проблемы? TextView да ImageView в LinearLayout или RelativeLayout»
    … тогда вы точно не искали решение вопроса ни в stackoverflow, ни в гугле, да еще и забыли про главное правило построения интерфейсов в андроиде — не плодить вложенные layouts без крайней необходимости.
    жаль, что обтекание текстом с этими боковыми картинками не реализуемо.


    1. Yoto
      27.04.2016 11:48

      Вы правы, я действительно поначалу не искал решения подобной задачи в гугле (о чём и упомянул в статье).
      Довольно долгое время я использовал именно RelativeLayout, т.к. помимо картинки и текста были ещё какие-то элементы, которые все вместе хорошо умещались внутри самого layout'а. Я даже не задумывался, что можно сделать как-то иначе.
      Однако, когда потребовалось штамповать картинку&текст в больших объемах и без соседних элементов — я по привычки начал с layout'ов, затем пожалел об этом, и только потом нашел drawableLeft у TextView.
      Полагаю, со стороны это действительно незначительным пунктом для статьи.


      1. snuk182
        27.04.2016 12:27
        +1

        справедливости ради, весь цикл статей очень полезен, за что большое спасибо (хоть я оттуда и сталкивался почти со всем, кроме Rx, ибо старпёр и давно это было)


  1. basnopisets
    27.04.2016 13:54
    +1

    по поводу compoundDrawable могу сказать, что в студии в таких макетах вылезает warning с предложением использовать compoundDrawable. Достаточно почитать то, что пишет Lint


  1. Bo_bda
    27.04.2016 23:00

    а давайте так, что не всегда адекватно работает drawable (any direction) особенно в случаях match_parent и как раз таки спасает <ImageView/> <TextView/> <LinearLayout/>


  1. ogbash
    28.04.2016 05:48

    У меня изучение РХ следующим по списку, но есть небольшой опыт с promises (CompletableFuture). Если ошибка ожидается только одна, то зачем нарушать соглашение? Мне вообще кажется странным, что все не остановилось после первого exception. Например, CompletableFuture.completeExceptionally(exc) блокирует нормальное значение, если оно придет позже. Я ожидал бы подобного поведения и тут.


    1. Yoto
      28.04.2016 05:51

      Если вы о Extra-части, то, как я уже упомянал, код был написан мною ещё в самом начале изучения Rx. Поэтому я пошел против соглашения и решил не блокировать Observable после первого же Exception, ибо в моём случае Exception не являлся чем-то плохим. Того же можно было бы достигнуть, обернув auth() в try/catch, чего я тогда не сделал и, в итоге, получил такое странное поведение.


  1. almkhaj
    28.04.2016 14:55
    +1

    Прочитал все 3 части «кюветов». Спасибо, очень познавательно. У меня вопрос: не приходилось ли Вам реализовывать вывод в pdf? Для android 4.4 и выше реализовано из коробки, а ниже — увы. Если брать сторонние библиотеки, то что-то работает, но нет, например, поддержки кириллицы. Интересуют бесплатные решения.


    1. Yoto
      28.04.2016 14:58

      Сожалею, но не приходилось, хотя и было как-то желание посмотреть, как обстоит работа с pdf'ками. Теперь буду знать, что и здесь ожидают мучения :(


  1. beproto_00
    28.04.2016 20:27

    github.com/henrytao-me/mvvm-life-cycle
    Говоря про «Можно сказать я пытался переизобрести MVVM»