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

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

Попередні частини:


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

Ситуація
Як-то раз робив я тестове завдання. Було воно нудним, одноманітним і… старим. Дуже старим. PSD ніби з минулого століття. Ну так не суть. Закінчивши всі основні моменти, я взявся за вичитку всіх відступів (агась, ручками, по лінійці, по-старому). Діло йшло добре, поки я не виявив неприємне невідповідність меню в додатку і у PSD'шке. Іконка була та ж, а ось padding не той. Я, як любитель пригод, не став зменшувати іконку, а вирішив скористатися властивістю actionLayout MenuItem. Швиденько додавши новий layout з потрібними мені параметрами та перевіривши відступи іконки на емуляторі, я відправив рішення і пішов на захід.

Ситуація
Яке ж було моє здивування, коли у відповідь прийшло (дослівно): «Не працює редагування». Додаток до речі я тестував і так, і сяк і не повинен був щось упустити. Посилювало паніку і лаконічна форма відповіді з якої було не ясно, що ж конкретно не працює…
… на щастя, довго шукати не довелося. Як вже стало зрозуміло з заголовка, onOptionsItemSelected() просто ігнорується при установці кастомного layout'а.
Чому?image

Саме з тих пір я чітко усвідомив, що з На ом жарти погані і навіть зміни в дизайні можуть спричинити зміни в поведінці додатки. Ну і як завжди решение:
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's в руки.
Минав час, вкладеністю Fragment'ів я не користувався, а всі новини цього плану чомусь проходили повз мене… І ось, раптово, я натрапив на новину (вибачте, посилання посіяв) про те, що Fragment's то тепер у всю Nested і взагалі життя == казка. І що сказати — я повірив! Створив проект, накатав приклад, де hash Presenter'ів у Fragment'ів перетворювався в колір (це відразу дозволило визначити, чи спрацював retain), запустив, повернув екран і…

І..?
І витратив усі вихідні у пошуках причини, чому зберігаються лише Fragment's першого рівня (ті, що зберігаються у самому 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'ами.
Я частенько чую це питання від своїх знайомих і іноді зустрічаю на різних форумах, тому ось пояснення для тих, хто ще тільки збирається бути «реактивним» або використовує ці оператори просто интутивно, не піклуючись про наслідки:
Код
Вами.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, виявляєш, що дані генеруються не класом Вами (раптово, так?), а класом OnSubscribe. Думаю саме звідси таке заплутує іменування оператора subscribeOn().
До речі, дуже раджу новачкам, так і досвідченим знавцям, ознайомитися з либой для логування Frodo. Збережіть собі дуже багато часу, бо дебажити Rx-код — та ще задачка.

6. RxJava: Operator's і Transformer's

Ситуація
Частенько трапляється так, що Rx-код розростається і хочеться його якось скоротити. Спосіб викликів методів у вигляді chain'ів гарний, так, але от переиспользование у нього нульовий — доведеться кожен раз викликати всі ті ж методи роблять невеликі речі і т. д. і т. п.
Зіткнувшись з такою необхідністю, новачки починають думати в термінах ООП і створюють, якщо вже зовсім все погано, статік-методи і обертають початок ланцюжка викликів в нього. Якщо вчасно не покінчити з таким підходом, це виллється в 3-4 обгортки на один Вами.
Реальний код в одному з реальних продуктів
RxUtils.HandleErrors(
RxUtils.FireGlobalEvents(
RxUtils.SaveToCaches(
Вами.defer(() -> storeApi.getAll(filter)).subscribeOn(Schedulers.io()), caches)
, new StoreDataLoadedEvent()
)
).subscribe(storeDataObserver);


В майбутньому це принесе дуже багато проблем і тим, хто просто хоче зрозуміти, що робить код, і тим, хто хоче щось змінити.

І що тепер?
Chain-методи хороші саме тим, що вони легко читаються. Раджу якомога швидше навчитися робити свої оператори та трансформери. Це простіше, ніж здається. Важливо лише розуміти, що Operator працює з одиницею даних (наприклад, одним викликом onNext() за раз), а Transformer перетворює сам Вами (тут ви зможете комбінувати звичайні 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мс
Код
Вами.interval(300, TimeUnit.MILLISECONDS).map(i -> "t1-" + i).subscribe(observer);
l("interval-1");
Вами.interval(330, TimeUnit.MILLISECONDS).map(i -> "t2-" + i).subscribe(observer);
l("interval-2");

Вами.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 та Вами 1:1 і дозволяти вибірково відписатися від чогось, але…
Код
Subscription subscription1 = Вами.interval(300, TimeUnit.MILLISECONDS).map(i -> "t1-" + i).subscribe(observer);
l("interval-1");
Вами.interval(330, TimeUnit.MILLISECONDS).map(i -> "t2-" + i).subscribe(observer);
l("interval-2");

Вами.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! Ось такий поворот.

Вами.defer() і Вами.fromCallable()
Думаю, defer() — це один із найчастіше використовуваних операторів у Rx (десь на рівні з Вами.flatMap()). Його завдання — відкласти ініціалізацію даних Observable'а до моменту виклику subscribe(). Спробуємо:
Код
Вами.defer(() -> Вами.just("s1")).subscribe(observer);
l("just-1");
Вами.defer(() -> Вами.just("s2")).subscribe(observer);
l("just-2");
observer.unsubscribe();
Вами.defer(() -> Вами.just("s3")).subscribe(observer);
l("just-3");


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


«І що? Нічого несподіваного» — скажете ви. «Напевно» — відповім я.
Але що якщо вам набридло писати Вами.just()? У Rx і на це знайдеться відповідь. Швидкий пошук в гуглі знаходить метод Вами.fromCallable(), що дозволяє defer'ить Вами, а звичайну лямбду. Пробуємо:
Код
Вами.fromCallable(() -> "z1").subscribe(observer);
l("callable-1");
Вами.fromCallable(() -> "z2").subscribe(observer);
l("callable-2");
observer.unsubscribe();
Вами.fromCallable(() -> "z3").subscribe(observer);
l("callable-3");


Результат (УВАГА! Приберіть дітей і хом'ячків від екрану)
z1
callable-1
callable-2
callable-3


Здавалося б, метод, який робить те ж саме, тільки з іншими вихідними даними, але така різниця. Найнезрозуміліше (якщо міркувати логічно) в цьому результаті те, що він не z1-z2-callable... (якщо вірити всьому, описаному до цього моменту), а саме z1-callable.... У чому ж справа?

Справа в тому, що...
А тепер до суті. Справа в тому, що багато операторів написані по-різному. Хтось перед черговим onNext() перевіряє підписку Subscriber'а, хтось перевіряє її після эмита, але до кінця onNext(), а хтось і до, і після і т. д. Це вносить певний хаос… в очікуваний результат. Але навіть це не пояснює поведінку Вами.fromCallable().
Всередині Rx існує клас SafeSubscriber. Це саме той клас, який відповідальний за головний контракт Rx (ну той, який говорить: «після onError() не буде більше onNext() і відбудеться відписка, і т. д. і т. п.»). І чи потрібно використовувати його (SafeSubscriber) оператора чи ні — ніде не прописано. Загалом, Вами.fromCallable() викликає звичайний subscribe(), тому неявно створюється SafeSubscriber і відбувається unsubscribe() після эмита, а от Вами.defer() викликає unsafeSubscribe(), який не викликає unsubscribe() по закінченню. Так що насправді (раптово!) це Вами.defer() поганий, а не Вами.fromCallable().

8. RxJava: repeatWhen() замість ручної відписки/підписки

Ситуація
Потрібно зробити відновлення даних кожні Х-секунд. Завантаження нових даних, звичайно ж, не можна робити до тих пір, поки не відбудеться завантаження старих (таке можливо за лагів, багів і іншої нечесті). Що робити?
І у відповідь починається всяке: Вами.interval() Вами.throttle() або AtomicBoolean, а деякі навіть через ручний unsubscribe() примудряються зробити. На ділі, все куди простіше.

Рішення
Часом створюється враження, що у Rx є оператори на всі випадки життя. Так і зараз. Існує метод repeatWhen(), який зробить все за вас — переподпишется Вами через заданий інтервал:
Приклад використання repeatWhen()
Log.i("MY_TAG", "Loading data");
Вами.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 і повісити глобальний обробник на цікаву помилку. Це дозволить не тільки переподписаться до будь-якого Вами у разі помилки, але ще й оповістити про це користувача (я щось подібне використовую для 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() повертає Вами , отриманий від RxAndroid'а для натиснути кнопку [Sign In]. Це hot-вами, тобто, він ніколи не буде в змозі completed. Події починаються від нього, проходять через map(), в якому відбувається авторизація і далі по ланцюжку. Якщо ж помилка, кастомный Operator перехопить помилку і просто не пропустить її далі:
SuppressErrorOperator
public final class SuppressErrorOperator<T> implements Вами.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() і взагалі всієї структури Вами. Тоді ж я дізнався про OnSubscribe-класі, Producer і інших цікавих речах… проте все це ні на йоту не наблизило мене до розгадки. Я не кажу, що я досконало розібрався в исходниках Rx, ні, це занадто круто, але наскільки зміг — не допомогло, а копати ще глибше — дійсно непросто.
Звичайно ж я поставив свій питання на stackoverflow, але відповіді так і не отримав.
Цей Кювет відняв у мене близько 2-ох тижнів незважаючи на те, що onBackpressureBuffer() я знайшов досить швидко (але хто буде використовувати те, що вирішує проблему, не розуміючи, чому взагалі проблема взялась?).

Використовуючи свій досвід, припущу, що observeOn() породжує Subscriber-обгортку над моїм Subscriber і коли відбувається Exception's, вони накопичується в обгортці (адже за контрактом Exception повинен бути один, так що ніхто не очікував, що їх буде 16). А коли приходить 17ый клік, observeOn() перевіряє isUnsubscribed() і, т. до. воно дорівнює true, нікого не пускає. (але це лише моя гіпотеза).
Що стосується магічного числа 16 — це розмір константи Backpressure Buffer На. Для звичайної 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. І якщо хто-небудь з вас знає, що за магія коїться з останньому Кюветі — милості прошу в коментарі, лічку або ще куди. Буду радий подробицям більше, ніж новорічних свят.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.