Занимательная история одного Android дебага

Дано:
— Activity — 1 штука.
— ViewPager, который живет в Activity — 1 штука.
— FragmentPagerAdapter для вышеупомянутого ViewPager — 1 штука. Производит на свет 3 фрагмента.

Проблема:
При закрытии Activity в сеть улетают запросы на загрузку контента (изображений), который живет во фрагментах, которые даже не были показаны.

Поскольку запросы в сеть улетают на загрузку изображений, просто найти место, где они запрашиваются и поставить брейкпоинт. Получаем стек трейс:

Да... о таком стек трейсе можно только мечтать. На него можно смотреть днями, и все равно будет непонятно, что здесь происходит :). Глаз цепляется за doFrame, живущий в классе Choreographer, с которого, похоже, всё начинается.

Если посмотреть на его код,

void doFrame(long frameTimeNanos, int frame) {
   final long startNanos;
   synchronized (mLock) {
       if (!mFrameScheduled) {
           return; // no work to do
       }

...
   doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);
   doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
 doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);
   ...
}

видно, что он запускает в главном потоке все запланированные колбеки, которые зарегистрированы в очередях.

Поскольку в нашем стек трейсе видно, что выполняется ViewRootImpl$TraversalRunnable, было бы интересно узнать, кто ставит его в очередь на выполнение.

Легко найти, где происходит регистрация колбеков в том же классе Choreographer:

private void postCallbackDelayedInternal(int callbackType,
       Object action, Object token, long delayMillis) {
   if (DEBUG) {
       Log.d(TAG, "PostCallback: type=" + callbackType
               + ", action=" + action + ", token=" + token
               + ", delayMillis=" + delayMillis);
   }

   synchronized (mLock) {
       final long now = SystemClock.uptimeMillis();
       final long dueTime = now + delayMillis;
 mCallbackQueues[callbackType].addCallbackLocked(dueTime, action, token);
       if (dueTime <= now) {
           scheduleFrameLocked(now);
       } else {
           Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_CALLBACK, action);
           msg.arg1 = callbackType;
           msg.setAsynchronous(true);
           mHandler.sendMessageAtTime(msg, dueTime);
       }
   }
}

Поставив брейкпоинт на функцию, получаем:

Ага... в неактивный Fragment ViewPager’a приходят данные через loader’ы. Loader устанавливает курсор в адаптер, который отвечает за отображение картинок, и Glide вытягивает картинки из сети. Но откуда изначальные данные и почему они пришли при закрытии активити?

Loader инициализируется в onActivityCreated, который вызывается только один раз при создании фрагмента. Почему андроид при закрытии активити передоставляет данные для курсора?

Опять же, в стеке есть ModernAsyncTask, при внимательном рассмотрении которого можно найти интересную функцию с сигнатурой:

public final 
ModernAsyncTask<Params, Progress, Result> 
executeOnExecutor(Executor exec,Params… params)

Поставив на нее брейкпоинт, получаем:

На этот скриншот можно и нужно смотреть очень долго. Здесь «блеск и нищета» смешаны в одну кучу и, наверно, можно найти причину некоторых сайд-еффектов в андроид приложениях.

Внимательно читаем стек снизу вверх. OnPause Activity приводит к старту фрагмента? What the f...?

Понять что происходит, можно, просто внимательно прочитав исходный код FragmentManagerImpl.

Краткая выдержка из кода:

...
public void dispatchActivityCreated() {
   mStateSaved = false;
   moveToState(Fragment.ACTIVITY_CREATED, false);
}

public void dispatchStart() {
   mStateSaved = false;
   moveToState(Fragment.STARTED, false);
}

public void dispatchResume() {
   mStateSaved = false;
   moveToState(Fragment.RESUMED, false);
}

public void dispatchPause() {
 moveToState(Fragment.STARTED, false);
}
...

Стоп.. мне одному кажется, что последняя функция как-то не вписывается в контекст? Неужели баг в support library? Однако похоже, что нет.

Как все уже знают, фрагменты и активити живут по своему собственному жизненному циклу

который по сути являет собою большую стейт машину с достаточно сложной реализацией внутри.

Если компонент проходит через onPause, но еще не добрался до onStop, то какое состояние долно быть текущим, учитывая, что может быть необходимо перевести компонент в состояние onResume? Судя по диаграмме выше можно предположить, что это пре-onResume и это STARTED.

Н-да... неочевидно. Ну ок. Что дальше?

Дальше все FragmentManager’ы, ассоциированные с текущей Activity, и дочерние FragmentManager’ы непосредственных детей активити рекурсивно переводятся в состояние STARTED, а соответственно отрабатывают их перегруженные функции onStart.

Чуть выше по стеку можно найти обработчик onStart самого класса Fragment из android.support.v4.app.

Его код очень интересен:

public void onStart() {
   mCalled = true;

 if (!mLoadersStarted) {
       mLoadersStarted = true;
       if (!mCheckedForLoaderManager) {
           mCheckedForLoaderManager = true;
           mLoaderManager = mActivity.getLoaderManager(mWho, mLoadersStarted, false);
       }
       if (mLoaderManager != null) {
 mLoaderManager.doStart();
       }
   }
}

Судя по этому коду, если текущий фрагмент никогда не был STARTED, то loader’ы не будут фактически запущенны. Отсюда два важных вопроса: как так произошло, что живой фрагмент не прошел стадию STARTED, и что делать? :)

Ответ на первый вопрос достаточно прост: если в приложении есть ViewPager, в котором живут фрагменты, то несмотря на то, что фрагмент будет сконструирован, когда это требуется (зачастую в фоне для smooth transitioning), он не пройдет состояние STARTED, пока фактически не будет отображен.

Но FragmentActivity из android.support.v4.app ничего об этом не знает :)

В итоге закрытие активити приводит к тому, что:

1. Наряду с остальным обработчиками в существующих фрагментах вызывается обработчик onStarted.

2. Все фрагменты, находящиеся в состоянии pre-STARTED, запускают свои лоадеры, доставляют данные и процессят их так, как будто фрагмент только что был запущен.

Поскольку все это происходит на стороне Android SDK, без хаков пофиксить ситуацию довольно трудно. Как минимум можно следить, когда запускаются лоадеры, обязательно — в каком состоянии находится ассоциированная с фрагментом активити в обработчиках onLoadFinished при доставке данных, и, конечно, какой код живет в onStarted, onDestroy и т.д., чтобы не возникало незапланированных побочных эффектов.

В идеале проблему нужно решать радикально, чтобы лоадеры даже не запускались, и ресурсы мобильного устройства экономились.

Have fun!

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

👍НравитсяПонравилось0
В избранноеВ избранном0
Подписаться на автора
LinkedIn



Підписуйтесь: Soundcloud | Google Podcast | YouTube


24 комментария

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Не можу повторити на 5.1, support lib, ViewPager.
onStart в непотрібний момент не викликається. У вас баг завжди проявляється?

в 100% случаев. правда проверял я только на 4.3. чуть позже проверю на 5.1

github.com/gshcherb/apppause

там же и apk. достаточно просто его запустить и если повится тост — баг проявился. баг проявляется тогда когда есть лоадеры хотя бы в одном из фрагментов. Я воссоздавал среду моего рабочего проекта, поэтому уровней фрагментов 2. Не уверен что просто с одним уровнем активити/фрагмент баг будет проявляться.

фрагменты они такие... у меня еще материала не на одну статью набралось бы ;)

Тепер підтверджую. Схоже лише в Nested Fragments.
Вирішення проблеми:

-       getLoaderManager().initLoader(0, null, this);
+       getParentFragment().getLoaderManager().initLoader(0, null, this);

Дякую за цікаву статтю :-)

только тогда нужно следить чтобы ID лоадеров не оверлапились :)

так) пропоную передавати position з PageAdapter’а

А сколько времени понадобилось, чтобы разобраться ?

2 дня. Слишком много возможных вариантов исполнения кода и неочевидность стейт машины забрали много времени.

А еще можно не пользоваться бажными либами

P.S. правда потом могут не брать на работу, потому что не пользуешься «стандартными» android средствами :)

Предлагаю еще раз взглянуть на статью. Дело не в лоадерах. И еще мне было бы интересно услышать про альтернативные варианты доставки данных.

обработчик onStart самого класса Fragment из android.support.v4.app
имеет
if (!mLoadersStarted)
Дело в том что лоадеры и фрагменты пишет непонятно кто и они не могут пофиксить баги годами. Данные доставлять можно как угодно (коллбеки, reactive streams, event bus и тд) и почти любой из них будет проще, читабельней и с меньшим количеством сюрпризов.

Проблема с лоадером лишь следствие. Наряду с этим выполняется любой код в onStart. Если так рассуждать, то весь остальной код тоже «пишет непонятно кто и баги не могут пофиксить» :).

По поводу данных, все перечисленное сверху хорошо, но это только механизмы (конвеерной) доставки данных, а нужно еще и получить эти данные из базы данных в большинстве случаев и мониторить их изменение. Ни один из выше-перечисленных механизмов эту задачу не решает. CursorLoader’ы являются частью фреймворка и идут в тесной связке с провайдерами, поэтому не используя их в большинстве случаев придется писать собственные велосипеды.

Мне было бы интересно взглянуть на серьезное приложение которое имеет собственный слой кеширования на клиенте и не использует лоадеры.

Да, выполняется любой код в onStart во время onPause, если вам нравится тратить по пару дней на сюрпризы то можете пользоваться дальше :)

Лоадер не может просто так получать данные из базы, вам надо создавать курсор (в случае базы). Если надо мониторить изменение, можно сделать доставку батч апдейтов ко всем кто подписан (коллбеки, стримы, евент бас), будет понятно что происходит и можно добавлять дополнительные хендлеры во время доставки. Провайдеры и лоадеры делают код непонятным. Если шарить данные из аппы не надо, то лучше провайдеры не использовать.

Слой кеширования зависит от приложения. Спрятать все запросы в базу за LruCache не сложно. Запросы в интернет хттп клиенты кешируют если ответ содержит хидеры корректные.

Ну, написать конечно можно все, но...Вот неполный список категорий проблем с которыми придется столкнуться в случае самописного решения без лоадеров и провайдера:

— синхронизация передачи данных между потоками приложения.
— доставка уведомлений в случае изменения данных в базе.
— если вдруг «шарить инфу из аппы» нужно будет в будущем, то придется делать все таки стандартные решения, а по сути дуплицировать еффорт.
— потеря значительного времени и усилий на написание кастомного решения для конкретного приложения.
— если данные кешируются в LruCache и нет локальной базы данных, то возникают проблемы с персистентностью данных между запусками приложения, иначе LruCache не нужен вообще.
— нужно реализовывать кастомный механизм мониторинга lifecycle’а компонентов
— частью провайдера обычно является контракт, описывающий данные, и он в большинстве случае обязателен. будет ли он обязателен тут?
— возможное проблемы нарушения абстракции данных. есть риск что некоторые части приложения будут плохо абстрагированны от моделей данных.
— пришедшему завтра новому человеку надо будет вникать в кастомное решение и тратить время, когда стандартные решения осваиваются в один день.

и многое другое.

Всех этих проблем нет в лоадерах, и описанная проблема к ним отношения по сути не имеет, поэтому думаю буду пользоваться дальше :)

— синхронизация передачи данных не проблема, UiThread очень помогает в этом
— доставка уведомлений в случае изменения данных не проблема
— делать архитектуру на если вдруг что-то случиться не надо
— потеря значительного времени не будет, потому что решения минималистичны
— если данные кешируются в LruCache то это не обязательно DiskLruCache
— нужно реализовывать кастомный механизм мониторинга lifecycle’а компонентов в случае коллбеков и то можно обойтись простым методом который отвечает на вопрос можно ли обновить ui в данные момент, в остальных не обязательно
— частью провайдера обычно является контракт, описывающий данные, и он в большинстве случае является усложнением, когда вместо контракта можно использовать обьекты с полями.
— я не понимаю зачем абстрагироваться от обьекта типа User показывая его профайл, если для вас это проблема, и надо абстрагироваться, то мы думаем очень по разному
— пришедшему завтра новому человеку прочитать немного кода (интерфейс биндинга данных и то что запросы в базу надо слать через специальный враппер) займет буквально минут 10

ну ок. если у Вас получилось все реализовать, и все работает, я только рад :)

После первого скрина из этой статьи мне почему-то очень сильно захотелось пересесть с андроида на виндовсфон.

Все не так плохо как кажется :)

О, Боже, код на доу. Код на доу. (v2)

Благодарю, было интересно почитать. А баг репорт вы засабмитили (а то это точно нужно фиксить)?

Бага пока нет. Не уверен даже что это легко пофиксить. По хорошему нужно пересматривать как работает lifecycle стейт-машина, но это может затронуть слишком много уже реализованного функционала. Баг думаю все таки стоит запостить, спасибо за напоминание.

Это таки баг, притом один из серьёзнейших и трудноуловимых. Фактически просто про**али необходимость ещё одного состояния, и получили кота Шрёдингера с рекурсией.

Подписаться на комментарии