Pull to refresh

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

Reading time 18 min
Views 20K
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. Машину времени не продам ┬┴┬┴┤(・_├┬┴┬┴

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'а.
Tags:
Hubs:
+19
Comments 17
Comments Comments 17

Articles