company_banner

Нестыдные вопросы про жизненный цикл



    Каждый разработчик сталкивался с вопросами про жизненный цикл Activity: что такое bind-сервис, как сохранить состояние интерфейса при повороте экрана и чем Fragment отличается от Activity.
    У нас в FunCorp накопился список вопросов на похожие темы, но с определёнными нюансами. Некоторыми из них я и хочу с вами поделиться.


    1. Все знают, что если открыть второе активити поверх первого и повернуть экран, то цепочка вызовов жизненного цикла будет выглядеть следующим образом:


    Открытие Activity

    FirstActivity: onPause
    SecondActivity: onCreate
    SecondActivity: onStart
    SecondActivity: onResume
    FirstActivity: onSaveInstanceState
    FirstActivity: onStop


    Поворот

    SecondActivity: onPause
    SecondActivity: onSaveInstanceState
    SecondActivity: onStop
    SecondActivity: onCreate
    SecondActivity: onStart
    SecondActivity: onRestoreInstanceState
    SecondActivity: onResume


    Возврат назад

    SecondActivity: onPause
    FirstActivity: onCreate
    FirstActivity: onStart
    FirstActivity: onRestoreInstanceState
    SecondActivity: onStop


    А что будет в случае, если второе активити прозрачное?


    Решение


    В случае с прозрачным верхним активити с точки зрения логики всё немного отличается. Именно потому, что оно прозрачное, после поворота необходимо восстановить содержимое и того активити, которое находится непосредственно под ним. Поэтому порядок вызовов будет немного отличаться:


    Открытие activity

    FirstActivity: onPause
    SecondActivity: onCreate
    SecondActivity: onStart
    SecondActivity: onResume


    Поворот

    SecondActivity: onPause
    SecondActivity: onSaveInstanceState
    SecondActivity: onStop
    SecondActivity: onCreate
    SecondActivity: onStart
    SecondActivity: onRestoreInstanceState
    SecondActivity: onResume
    FirstActivity: onSaveInstanceState
    FirstActivity: onStop
    FirstActivity: onCreate
    FirstActivity: onStart
    FirstActivity: onRestoreInstanceState
    FirstActivity: onResume
    FirstActivity: onPause


    2. Ни одно приложение не обходится без динамического добавления вью, но иногда приходится перемещать одну и ту же вью между разными экранами. Можно ли один и тот же объект добавить одновременно в два разных активити? Что будет, если я создам её с контекстом Application и захочу добавлять одновременно в различные активити?


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



    Решение


    Ничего не мешает создать вью с контекстом Application. Она просто применит дефолтные стили, не относящиеся к какому-либо активити. Также без проблем можно перемещать эту вью между разными активити, но нужно следить, чтобы она была добавлена только в одного родителя


        private void addViewInner(View child, int index, LayoutParams params, boolean preventRequestLayout) {  
            ...
            if (child.getParent() != null) {  
                throw new IllegalStateException("The specified child already has a parent. " +  
                        "You must call removeView() on the child's parent first.");  
          }
          ...
        }

    Можно, например, подписаться на ActivityLifecycleCallbacks, на onStop удалять (removeView) из текущего активити, на onStart добавлять в следующее открываемое (addView).


    3. Фрагмент можно добавить через add и через replace. А в чём отличие между этими двумя вариантами с точки зрения порядка вызова методов жизненного цикла? В чём преимущества каждого из них?


    Решение


    Даже если вы добавляете фрагмент через replace, то это не значит, что он полностью заменяется. Это значит, что на этом месте в контейнере заменится его вью, следовательно, у текущего фрагмента будет вызвано onDestroyView, а при возврате назад будет снова вызван onCreateView.



    Это довольно сильно меняет правила игры. Приходится детачить все контроллеры и классы, связанные с UI именно в onDestroyView. Нужно чётко разделять получение данных, необходимых фрагменту, и заполнение вью (списков и т.д.), так как заполнение и разрушение вью будет происходить намного чаще, чем получение данных (чтение каких-то данных из БД).


    Также появляются нюансы с восстановлениям состояния: например, onSaveInstanceState иногда приходит после onDestroyView. К тому же стоит учитывать, что если в onViewStateRestored пришёл null, то это значит, что не нужно ничего восстанавливать, а не сбрасываться до дефолтного состояния.


    Если говорить про удобства между add и replace, то replace экономнее по памяти, если у вас глубокая навигация (у нас глубина навигации юзера — один из продуктовых KPI). Также намного удобнее с replace управлять панелью инструментов, так как в onCreateView можно её переинфлейтить. Из плюсов add: меньше проблем с жизненным циклом, при возврате назад не пересоздаются вью и не нужно ничего заново заполнять.


    4. Иногда всё ещё приходится работать напрямую с сервисами и даже с bind-сервисами. С одним из подобных сервисов взаимодействует активити (только один активити). Он коннектится к сервису и передаёт в него данные. При повороте экрана наш активити разрушается, и мы обязаны отбайндится от этого сервиса. Но если нет ни одного соединения, то сервис разрушается, и после поворота bind будет к совершенно другому сервису. Как сделать так, чтобы при повороте сервис оставался жить?


    Решение


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


        @Override
        protected void onDestroy() {
            super.onDestroy();
            ThreadsUtils.postOnUiThread(new Runnable() {
                @Override
                public void run() {
                    unbindService(mConnection);
                }
            });
        }

    5. Недавно мы переделали навигацию внутри нашего приложения на Single Activity (с помощью одной из доступных библиотек). Раньше каждый экран приложения был отдельным активити, сейчас навигация работает на фрагментах. Проблема возврата к активити в середине стека решалась intent-флагами. Как можно вернуться к фрагменту в середине стека?


    Решение


    Да, решения из коробки FragmentManager не предоставляет. Cicerone делает внутри себя нечто подобное:


        protected void backTo(BackTo command) {
            String key = command.getScreenKey();
    
            if (key == null) {
                backToRoot();
    
            } else {
                int index = localStackCopy.indexOf(key);
                int size = localStackCopy.size();
    
                if (index != -1) {
                    for (int i = 1; i < size - index; i++) {
                        localStackCopy.pop();
                    }
                    fragmentManager.popBackStack(key, 0);
                } else {
                    backToUnexisting(command.getScreenKey());
                }
            }
        }

    6. Также недавно мы избавились от такого неэффективного и сложного компонента, как ViewPager, потому что логика взаимодействия с ним очень сложна, а поведение фрагментов непрогнозируемо в определённых кейсах. В некоторых фрагментах мы использовали Inner-фрагменты. Что будет при использовании фрагментов внутри элементов RecycleView?


    Решение


    В общем случае не будет ничего плохого. Фрагмент без проблем добавится и будет отображаться. Единственное, с чем мы столкнулись, — это нестыковки с его жизненным циклом. Реализация на ViewPager управляет жизненным циклом фрагментов посредством setUserVisibleHint, а RecycleView делает всё в лоб, не думая про фактическую видимость и доступность фрагментов.


    7. Всё по той же причине перехода с ViewPager мы столкнулись с проблемой восстановления состояния. В случае с фрагментами это реализовывалось силами фреймворка: в нужных местах мы просто переопределяли onSaveInstanceState и сохраняли в Bundle все необходимые данные. При пересоздании ViewPager все фрагменты восстанавливались силами FragmentManager и возвращали свое состояние. Что делать в случае с RecycleView и его ViewHolder?


    Решение


    «Надо писать всё в базу и каждый раз читать из неё», — скажете вы. Или логика сохранения состояния должна быть снаружи, а список — это просто отображение. В идеальном мире так и есть. Но в нашем случае каждый элемент списка — это сложный экран со своей логикой. Поэтому пришлось изобрести свой велосипед в стиле «сделаем такую же логику, как во ViewPager и фрагменте»:


    Адаптер
    public class RecycleViewGalleryAdapter extends RecyclerView.Adapter<GalleryItemViewHolder> implements GalleryAdapter {
        private static final String RV_STATE_KEY = "RV_STATE";
        @Nullable private Bundle mSavedState;
    
        @Override
        public void onBindViewHolder(GalleryItemViewHolder holder, int position) {
            if (holder.isAttached()) {
                holder.detach();
            }
    
            holder.attach(createArgs(position, getItemViewType(position)));
            restoreItemState(holder);
        }
    
        @Override
        public void saveState(Bundle bundle) {
            Bundle adapterState = new Bundle();
            saveItemsState(adapterState);
            bundle.putBundle(RV_STATE_KEY, adapterState);
        }
    
        @Override
        public void restoreState(@Nullable Bundle bundle) {
            if (bundle == null) {
                return;
            }
            mSavedState = bundle.getBundle(RV_STATE_KEY);
        }
    
        private void restoreItemState(GalleryItemViewHolder holder) {
            if (mSavedState == null) {
                holder.restoreState(null);
                return;  }
    
            String stateKey = String.valueOf(holder.getGalleryItemId());
            Bundle state = mSavedState.getBundle(stateKey);
            if (state == null) {
                holder.restoreState(null);
                mSavedState = null;
                return;  }
    
            holder.restoreState(state);
            mSavedState.remove(stateKey);
        }
    
        private void saveItemsState(Bundle outState) {
            GalleryItemHolder holder = getCurrentGalleryViewItem();
            saveItemState(outState, (GalleryItemViewHolder) holder);
        }
    
        private void saveItemState(Bundle bundle, GalleryItemViewHolder holder) {
            Bundle itemState = new Bundle();
            holder.saveState(itemState);
            bundle.putBundle(String.valueOf(holder.getGalleryItemId()), itemState);
        }
    }

    На Fragment.onSaveInstanceState мы считываем состояние нужных нам холдеров и кладём их в Bundle. При пересоздании холдеров мы достаем сохранённый Bundle и на onBindViewHolder передаём найденные состояния внутрь холдеров:


    8. Чем нам это грозит?


          @Override  
          protected void onCreate(Bundle savedInstanceState) {  
              super.onCreate(savedInstanceState);  
              setContentView(R.layout.activity); 
              ViewGroup root = findViewById(R.id.default_id);  
              ViewGroup view1 = new LinearLayout(this);  
              view1.setId(R.id.default_id);  
              root.addView(view1);  
              ViewGroup view2 = new FrameLayout(this);  
              view2.setId(R.id.default_id);  
              view1.addView(view2);  
              ViewGroup view3 = new RelativeLayout(this);  
              view3.setId(R.id.default_id);  
              view2.addView(view3);  
          }

    Решение


    На самом деле, ничего плохого в этом нет. В том же RecycleView хранятся списки из элементов с одинаковыми id. Однако всё-таки есть небольшой нюанс:


        @Override
        protected <T extends View> T findViewTraversal(@IdRes int id) {
            if (id == mID) {
                return (T) this;
            }
    
            final View[] where = mChildren;
            final int len = mChildrenCount;
    
            for (int i = 0; i < len; i++) {
                View v = where[i];
    
                if ((v.mPrivateFlags & PFLAG_IS_ROOT_NAMESPACE) == 0) {
                    v = v.findViewById(id);
    
                    if (v != null) {
                        return (T) v;
                    }
                }
            }
    
            return null;
        }

    Стоит быть внимательнее, если у нас в иерархии есть элементы с одинаковыми id, т.к. возвращается всегда именно первый найденный элемент, и на разных уровнях вызова findViewById это могут быть разные объекты.


    9. Вы падаете с TooLargeTransaction при повороте экрана (да, здесь по-прежнему косвенно виноват наш ViewPager). Как найти виновного?


    Решение


    Всё довольно просто: повесить ActivityLifecycleCallbacks на Application, ловить все onActivitySaveInstanceState и парсить всё, что лежит внутри Bundle. Там же можно достать и состояние всех вью и всех фрагментов внутри этого активити.


    Ниже пример, как мы достаём состояние фрагментов из Bundle:


    /**
     * Tries to find saved [FragmentState] in bundle using 'android:support:fragments' key. 
    */
    fun Bundle.getFragmentsStateList(): List<FragmentBundle>? {
        try {
            val fragmentManagerState: FragmentManagerState? = getParcelable("android:support:fragments")
            val active = fragmentManagerState?.mActive
                    ?: return emptyList()
    
            return active.filter {
                it.mSavedFragmentState != null
            }.map { fragmentState ->
                FragmentBundle(fragmentState.mClassName, fragmentState.mSavedFragmentState)
            }
        } catch (throwable: Throwable) {
            Assert.fail(throwable)
            return null
        }
    }
    
    fun init() {
        application.registerActivityLifecycleCallbacks(object : SimpleActivityLifecycleCallback() {
            override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle?) {
                super.onActivitySaveInstanceState(activity, outState)
                outState?.let {
                    ThreadsUtils.runOnMainThread {
                        trackActivitySaveState(activity, outState)
                    }
                }  }
        })
    }
    
    @MainThread
    private fun trackActivitySaveState(activity: Activity, outState: Bundle) {
        val sizeInBytes = outState.getSizeInBytes()
        val fragmentsInfos = outState.getFragmentsStateList()
                ?.map {
                    mapFragmentsSaveInstanceSaveInfo(it)
                }
    
        ...
    }

    Далее мы просто вычисляем размер Bundle и логируем его:


        fun Bundle.getSizeInBytes(): Int {  
           val parcel = Parcel.obtain()  
           return try {  
              parcel.writeValue(this)  
              parcel.dataSize()  
           } finally {  
              parcel.recycle()  
           }  
        }
    

    10. Предположим, у нас есть активити и набор зависимостей на нём. При определённых условиях нам нужно пересоздать набор этих зависимостей (например, по клику запустить какой-то эксперимент с другим UI). Как нам это реализовать?


    Решение


    Конечно, можно повозиться с флагами и сделать это каким-то «костыльным» перезапуском активити через запуск интента. Но на деле всё очень просто — у активити есть метод recreate.


    Скорее всего, большая часть этих знаний вам и не пригодится, так как к каждому из них приходишь не от хорошей жизни. Однако некоторые из них хорошо демонстрируют, как человек умеет рассуждать и предлагать свои решения. Мы используем подобные вопросы на собеседованиях. Если у вас есть интересные задачи, которые вам предлагали решить на собеседованиях, или вы сами их ставите, напишите их в комментариях — интересно будет обсудить!

    FunCorp
    Разработка развлекательных сервисов

    Comments 8

      +2
      Спасибо за советы, в свое время намучившись с lifecycle, наткнулся на материал — Dumb UI is a good UI. Из побочных приятных эффектов работает у меня и для iOS.
        0
        Даже если вы добавляете фрагмент через replace, то это не значит, что он полностью заменяется.

        Это не совсем верно. Если заменяемый фрагмент был предварительно положен в бэкстэк — да, произойдет все как вы описали. Если же заменяемый фрагмент не лежит в бекстеке и нигде более во фрагмент менеджере не содержится ссылок на этот фрагмент — он благополучно удалится.

        А чем, позвольте, вам не угодил ViewPager?
          0

          Да, вы правы про фрагмент. Ситуация именно про поведение с добавлением в бекстэк


          А насчет ViewPager по следующим причинам:
          1) RecycleView гибче — можно экспериментировать со свободным или постраничным скроллом, с вертикальной лентой или горизонтальной, с элементами, которые наезжают друг на друга контролами; экспериментировать с анимациями и расположением элементов.
          2) Иерархия вью в этих элементах очень сложная и хочется все это переиспользовать, настраивать пул для разных типов элементов, также можно прогревать RecycledPool еще до начала использования самой ленты
          3) Из-за наших особенностей хранения данных и структуры этих данных мы хотим очищать списки при уходе в глубину — с ViewPager с такой логикой возникает куча проблем, так как у него фрагменты жестко привязаны к элементам списка и логика восстановления фрагментов очень неочевидная и размазанная между нашей бизнес-логикой хранения данных, методами жизненного цикла и состояниями FragmentManager. Например у ViewPager после onDestroyView могут приходить запросы на создание фрагментов.
          4) Ну и просто лишний слой в виде фрагментов и их жизненного цикла нам показался лишним и без каких-либо преимуществ использования перед RecycleView

            0
            Простите, но не соглашусь с Вами. Каждой задаче- свой инструмент.

            RecyclerView больше для реализации view-логики на ячейку\холдер. И сам по себе предполагает, что контент будет структурно повторяться, для чего и (в дефолтных вариантах) кеширует вьюхи.

            View Pager же- именно страничный механизм и предполагает реализацию подобия presentation-логики на страницу. В основном тяжелой логики или хранения в памяти большого количества данных на страницу. FragmentStatePagerAdapter позволяет осуществлять, по факту, менеджмент памяти из коробки благодаря ЖЦ фрагментов. В некоторых случаях можно вообще взять базовый адаптер и реализовывать прямо на вьюхах (без фрагментов) и брать менеджмент на себя.

            Да, я понимаю, что бизнес-требования не всегда просты и не всегда адекватны. И что, иногда, проще «отверткой отковырять гвоздь». Но я бы поостерегся называть ViewPager неэффективными и сложным- он не сложнее ListView и много проще RecyclerView.

            Поймите правильно, в Вашей статье много полезного. Но не все решения стоит рекомендовать на повседневное использование.
              0

              Да, если говорить про страницы разного типа (как минимум табы или подобные ui-элементы), то, конечно, ViewPager более удобен. Я же говорил именно про списки в любых их проявлениях

            0
            Если вы знаете красивое решение, то напишите в комментариях.
            Взять ViewModel из Android Architecture Components, использовать в качестве презентера, и bind производить на старте, а unbind в onCleared? Если честно, не пробовал конкретно с bindService, но первое, что приходит в голову- сделать презентер независимым от поворота и коммуникацию с сервисом производить именно там. В итоге чище и более предсказуемо.
            Можно еще через moxy это реализовать, но, на мой взгляд, AAC гибче и стабильнее
              0

              С ViewModel хорошее решение, добавлю его в статью. Спасибо

                0
                Как вариант можно еще сначала запускать сервис через startService(), а потом делать bind. В onDestroy() уже делать stopService().

              Only users with full accounts can post comments. Log in, please.