Привет, Хабр! Хочу рассказать вам о библиотеке Chronos для Android (API level >= 9), цель которой – облегчить написание долгих операций, например, сетевых запросов, или обращений к БД.

Какую проблему решаем?

Не секрет, что для Android задача выполнения асинхронных операций всегда была одной из самых частовстречающихся. Действительно, крайне мало приложений работают исключительно в оффлайн, и где можно обойтись без сетевого взаимодействия. И уж совсем крохотная их часть обходится без обращения к постоянной памяти устройства, будь то база данных, Preferences или обычный файл. Однако, на протяжении истории развития системы нам так и не было предложено ни одного достаточно удобного решения “из коробки”.


Чем решали проблему – краткая история
Давайте взглянем на имеющийся инструментарий в контексте задачи “отработать клик по кнопке “авторизация” ”. Собственно, чем мы располагаем?

1. Стандартные потоки

Button signInButton = (Button) findViewById(R.id.button_auth);
signInButton.setOnClickListener(new View.OnClickListener() {
   @Override
   public void onClick(final View v) {
       final Activity activity = AuthActivity.this;
       showProgress();
       new Thread(new Runnable() {
           @Override
           public void run() {
               APIFactory.getApi().signIn();
               activity.runOnUiThread(new Runnable() {
                   @Override
                   public void run() {
                       goToMainContent();
                   }
               });
           }
       }).start();

   }
});

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

2. AsynkTask

Button signInButton = (Button) findViewById(R.id.button_auth);
signInButton.setOnClickListener(new View.OnClickListener() {
   @Override
   public void onClick(final View v) {
      new AuthTask().execute();
   }
});

private class AuthTask extends AsyncTask<Void, Void, Boolean>{

   @Override
   protected void onPreExecute() {
       showProgress();
   }

   @Override
   protected Boolean doInBackground(final Void... params) {
       try {
           APIFactory.getApi().signIn();
       }catch (Exception e){
           return false;
       }
       return true;
   }

   @Override
   protected void onPostExecute(final Boolean result) {
       if(!isCancelled() && result) {
           goToMainContent();
       }
   }
}

Уже чуть лучше, но все еще недостаточно. Появилась читаемая обработка ошибок, возможность отмены. Однако до сих пор этот код не способен правильно отработать при повороте экрана в момент выполнения запроса к API – утекает ссылка на Activity, в которой определен класс.

3. Loader
Когда Google представил Loader’ы, то казалось, что они станут Silver bullet для асинхронных запросов, сместив классические на тот момент AsyncTask’и. К сожалению, чуда не произошло. На данный момент Loader’ы – редкий гость в коммерческий проектах, поскольку очень уж они оказались неудобны в использовании. В этом разделе я не буду приводить код по аналогии с предыдущими двумя. Вместо этого рекомендую любопытному читателю ознакомиться с официальным гайдом по этой технологии, чтобы оценить объем кода, требующегося Loader’ам: developer.android.com/reference/android/content/AsyncTaskLoader.html

4. Service
Сервисы хороши для выполнения долгих задач, которые «висят» в фоне на протяжении использования приложения. Однако для запуска операций, результат которых нужен здесь и сейчас, структура сервисов не идеальна. Главным образом, ограничение накладывает методика передачи данных через Intent, который, во-первых, вмещает только ограниченное количество данных, а во-вторых, требует чтобы передаваемые данные были тем или иным способом сериализуемы. На этой технологии работает популярная библиотека Robospice.

Что предлагает Chronos?



Chronos делает за вас всю работу по выполнению задачи в параллельном потоке и доставке результата или ошибки выполнения в основной поток. Грубо говоря, эта библиотека предоставляет контейнер для любого рода долгих операций.
В проекте есть полноценная wiki, часть кода оттуда будет использоваться в статье, однако для более полного руководства обращайтесь на github.com/RedMadRobot/Chronos/wiki.

Пример

Давайте решим типовую задачу, используя Chronos: в Activity нужно запросить какой-то объект у некоего хранилища, доступ к которому достаточно долго, чтобы не делать запрос в UI потоке. Сначала напишем код, а потом разберем, что у нас получилось.

1. Первым делом нужно подключить Chronos к проекту. Для этого достаточно прописать зависимость в gradle:

compile 'com.redmadrobot:chronos:1.0.5'

2. Теперь опишем Activity. Базовый класс ChronosActivity– это одна из компонент библиотеки, однако вы легко можете написать его аналог, примеры этого есть в документации. Так же Chronos можно использовать во фрагментах, код не будет отличаться.

class MyActivity extends ChronosActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        Button startButton = (Button) findViewById(R.id.button_start);
        startButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(final View v) {
                runOperation(new MyOperation());
            }
        });
    }

    public void onOperationFinished(final MyOperation.Result result) {
        if (result.isSuccessful()) {
            showData(result.getOutput());
        } else {
            showDataLoadError(result.getError());
        }
    }

    private void showData(BusinessObject data){
        //...
    }

    private void showDataLoadError(Exception exception){
        //...
    }
}


3. И, наконец, опишем бизнес-логику получения данных в классе MyOperation:

class MyOperation extends ChronosOperation<BusinessObject> {

    @Nullable
    @Override
    public BusinessObject run() {
        final BusinessObject result ;
        // here you should write what you do to get the BusinessObject
        return result;
    }

    @NonNull
    @Override
    public Class<? extends ChronosOperationResult<BusinessObject>> getResultClass(){
        return Result.class;
    }

    public final static class Result extends ChronosOperationResult<BusinessObject> {
    }
}

Вот, собственно, и все. Давайте разберемся подробно, что же происходит в этом коде. Начнем с начала.

Настройка класса UI
class MyActivity extends ChronosActivity {

Чтобы работать с Chronos, базовый класс Acvitity или фрагмента должен либо наследоваться от предложенных в библиотеке, либо содержать определенный код в методах жизненного цикла, примеры можно увидеть в документации.

Запуск операции
runOperation(new MyOperation());

Здесь вызывается базовый метод класса ChronosActivity, в который передается только что созданная операция. Сразу после вызова этого метода Chronos заберет операцию в очередь и начнет ее выполнение в параллельном потоке.

Обработка результата операции
public void onOperationFinished(final MyOperation.Result result) {
        if (result.isSuccessful()) {
            showData(result.getOutput());
        } else {
            showDataLoadError(result.getError());
        }
    }

Этот метод будет вызван после того, как операция будет выполнена, либо в ходе выполнения выбросится исключение. Такие методы-обработчики обязательно должны иметь сигнатуру public void onOperationFinished(ResultType). Важный момент: метод вызовется только между вызовами onResume() и onPause(), то есть в нем вы спокойно можете изменять UI, не боясь, что он к тому моменту уже стал невалидным. Более того, если Activity была пересоздана из-за поворота, ухода в бэкграунд, или других причин – Chronos вернет результат в любом случае (единственное исключение – в системе закончилась память, в этом случае для предотвращения OutOfMemory Chronos может стереть старые данные результатов).
“откуда идет вызов?”
Внимательный читатель заметит, что Activity не реализует никаких специфических интерфейсов, так откуда же вызовется именно этот метод? Ответ – из кода, содержащего рефлексию. Решение делать рефлексию вместо интерфейса было принято из-за TypeErasure в Java, который делает невозможным одновременную реализацию одного и того же шаблонного интерфейса с разными параметрами. То есть это сделано, чтобы в одной Activity можно было обработать результат скольких угодно типов операций.

Настройка класса операции
class MyOperation extends ChronosOperation<BusinessObject> {

Класс ChronosOperation инкапсулирует в себе бизнес-логику получения объекта определенного типа, в данном случае – BusinessObject. Все пользовательские операции должны наследоваться от ChronosOperation.

Бизнес-логика
    @Nullable
    @Override
    public BusinessObject run() {
        final BusinessObject result ;
        // here you should write what you do to get the BusinessObject
        return result;
    }

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

Именование результата
    @NonNull
    @Override
    public Class<? extends ChronosOperationResult<BusinessObject>> getResultClass(){
        return Result.class;
    }

    public final static class Result extends ChronosOperationResult<BusinessObject> {
    }

Следующие метод и класс призваны дать возможность в коде Activity прописать обработчик результатов для каждой конкретной операции, указывая класс в качестве типа параметра метода onOperationFinished. Допускается использование одного класса результата для разных операций, если вы хотите, чтобы их результат обрабатывался одинаково.

Резюмирую: соберем минимальный набор кодовых участков, нужных для работы с Chronos.

  • Класс операции
  • Код вызова операции в UI объекте
  • Код обработки результата в UI объекте

Что здесь есть еще?


Итак, почему и зачем можно использовать Chronos?

  • Chronos берет на себя передачу данных между потоками, оставляя вам заботы только о бизнес-логике.
  • Chronos учитывает все нюансы жизненного цикла Activity и фрагментов, доставляя результат только тогда, когда они готовы его обработать, сохраняя данные до тех пор.
  • В Chronos не течет память. Вы больше не рискуете поймать краш, потому что утекло слишком много объектов Activity.
  • Chronos покрыт unit-тестами.
  • И наконец, Chronos – open-source проект. Вы всегда можете взять код и переписать его под свои нужды. Благодаря тестам, вам будет легко валидировать изменения кода.

Ссылка на проект в GitHub. Там вы найдете полное руководство по библиотеке, примеры использования и, конечно, исходный код.

Читайте также:
Сажаем контроллеры на диету: Android
Архитектурный дизайн мобильных приложений: часть 1
Архитектурный дизайн мобильных приложений: часть 2

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


  1. forceLain
    20.07.2015 17:37

    Бегло почитал документацию, с ходу не нашел, может подскажите:
    — в каком контексте выполняется операция? сервис?
    — есть ли доступ к Context внутри операции?
    — если захватить этот контекст ничего не потечет?
    — очередь выполнения операций последовательная или параллельная? есть ли возможность влиять на параллельность? что-нибудь вроде тредпулов? Если запустить 100 операций — очередь не забьется?
    — можно ли задавать операциям приоритет?
    — как я понимаю, никакого IPC?
    Спасибо


    1. forceLain
      20.07.2015 17:38
      +2

      P.S. написал подобную штуку за день, используя EventBus и обычный сервис, а это оказывается библиотека :)


      1. scottKey
        20.07.2015 18:50

        На эту тему, совсем недавно была отличная статья содержащая ответ Синдре Сорхуса о объеме модулей/библиотек http://habrahabr.ru/post/262681/


      1. cVoronin
        23.07.2015 11:33

        Ога, аналогичное решение пользуем. Только даже без сервиса, thread результат постит.
        Кого напрягает потенциальное количество одновременных потоков — есть заклинание «ExecutorService».


    1. MaximEfimov Автор
      20.07.2015 17:44
      +1

      Про контекст: в самих операциях ссылка на Context не содержится, ей владеет посредник – объект класса ChronosConnector, но он ее теряет после вызова onPause(). Если вам нужен контекст в операции, то можно брать, например, контекст приложения.

      Про режим выполнения: под капотом на данный момент находится ExecutorService в лице CachedThreadPool, в будущем, возможно, вынесем реализацию в конфиг. Возможности задать приоритет нет, но это тоже в планах на расширение. IPC нет.


  1. forceLain
    20.07.2015 18:40
    +1

    Я бы добавил в README инструкций как завести вашу библиотеку под proguard, а то он выпиливает все ваши контрактные public void onOperationFinished(ResultType) методы как неиспользуемые


    1. MaximEfimov Автор
      20.07.2015 21:15

      Хорошая мысль, добавим.


  1. Eleph
    20.07.2015 19:17

    Как принято, прошу прощения если что не так прочитал, но из статьи я не совсем понял следующее:
    1. Можно ли использовать Chronos во View а не в Activity.
    2. Что будет при смене ориентации (конфигурации)
    3. Что будет, если система захочет убить приложение (если Activity ушла в бекграунд)
    4. Можно ли запускать подтаски

    И еще коммент: мне было бы удобно использовать фоновую задачу непосредственно во View. Скажем, написал View, которая грузит картинку из сети, положил ее в Layout и забыл — она сама себя грузит. При смене ориентации сама восстанавливает свое состояние не стартуя загрузку заново. Когда таких View десяток и больше, то выносить фоновые таски в Activity, ну очень не хочется.

    Именно поэтому, некоторе время назад, я написал для своих нужд видоизмененный велосипед на основе AsyncTask, которй хранит пул таск в статик переменной.
    При смене конфигурации View отписывается от AsyncTask и при пересоздании заново подписывается на ту же AsyncTask.

    В данной реализации есть свои ограничения, но они несущественны на фоне возможности перенести фоновые таски во View.

    Если в Chronos это возможно, то наверное стоит его опробовать.


    1. MaximEfimov Автор
      20.07.2015 20:04
      +1

      Про View: в качестве «UI-клиента» можно использовать любой класс, в том числе и View. Однако, основная особенность библиотеки – привязка к жизненному циклу не сработает, поскольку у View нет как такового однозначного определения жизненного цикла. Вот здесь можете почитать рассуждения на этот счет: plus.google.com/u/0/+ArpitMathur/posts/cT1EuBbxEgN. Если придумаете, как правильно подключать View, то не стесняйтесь делать Pull Request. С другой стороны, выносить логику получения данных во View не рекомендуется, но это уже вопрос архитектуры.

      Про смену ориентации и уход в бэкграунд: если не был убит сам процесс, то после пересоздания Activity, Chronos вернет ей все результаты операций, которые скопились за время, пока она была неактивна. Также есть очень простой способ подхватывать запущенные таски, чтобы, например, не начинать грузить данные два раза при повороте экрана: github.com/RedMadRobot/Chronos/wiki/Single-launch-mode.


      1. Eleph
        20.07.2015 20:25

        Спасибо, теперь понятно.
        Касательно View и загрузки данных, да, конечно это вопрос архитектуры. В моем случае, этот подход кажется мне оправданым — в данном контексте View мало чем отличается от Activity — тот же элемент UI.

        Интересно, а можно ли Chronos точно так же заточить для использования во View — вместо onResume и onPause можно наврное использовать onAttachedToWindow и onDetachedFromWindow?


        1. Eleph
          20.07.2015 20:43

          Прошу прощения — посмотрел сорцы и сам ответил на свой вопрос.
          В целом, концепция у нас с вами похожа.


      1. recompileme
        21.07.2015 13:26

        Разве нельзя извлечь активити из вью?

                    Activity activity = null;
                    try {
                        activity = (Activity) view.getContext();
                    } catch (Exception e) {
                       //
                    }
                    if (activity!=null && !activity.isFinishing()) {
                    }
        


        1. MaximEfimov Автор
          21.07.2015 14:03

          Извлечь можно, но я не очень понимаю, как это поможет в данном случае. Нужно определить две точки, между которыми мы полагаем валидным оперирование компонентом UI, например – onResume() и onPause(). Для View выше предложили onAttachedToWindow() и onDetachedFromWindow(), возможно, это сработает.


  1. Artem_zin
    20.07.2015 19:42
    +2

    RxJava позволяет делать это и многое другое более элегантно.


    1. MaximEfimov Автор
      20.07.2015 20:12

      RxJava, безусловно, очень крутая библиотека, но, к сожалению, в ней нет легкой привязки к жизненному циклу. Если верить самим авторам библиотеки, то привязку сделать, действительно, можно, но это требует написания своего способа хранить Subscription при пересоздании Activity: github.com/ReactiveX/RxJava/wiki/The-RxJava-Android-Module#fragment-and-activity-life-cycle. В Chronos фокус направлен именно на легкую и бесшовную интеграцию фоновых задач с жизненным циклом UI-классов.


      1. Artem_zin
        20.07.2015 20:37

        Это так, но написать такую привязку не сложно, её код будет зависеть от ваших потребностей, но в целом всё решается Fragment + setRetainInstanceState().


        1. MaximEfimov Автор
          20.07.2015 21:26
          +1

          Если нужно самому привязываться через setRetainInstanceState(), то это поддержка жизненного цикла на уровне AsyncTask'ов, их так же можно «завернуть» во фрагмент. Кстати, именно так и работают Loader'ы, только у них свой менеджер, а не фрагментный, но суть там абсолютно та же.
          Из готовых оберток над RxJava мне, к сожалению, не попадались достаточно качественные – в основном везде нужно или руками как-то сохранять подписки (особенно если их много), или при каждом запуске явно лезть в кеш и смотреть, пришли ли данные, или грузятся, или загрузка даже не запускалась.


        1. anton9088
          20.07.2015 23:18

          не забывайте что вложенные фрагменты не могут быть retain)


  1. Deepscorn
    21.07.2015 11:02

    Приятно читать стройную документацию. Несколько вопросов по функционалу:
    «в системе закончилась память, в этом случае для предотвращения OutOfMemory Chronos может стереть старые данные результатов»
    Что произойдет в этом случае с запросом? При возвращении в activity автоматом будет запущен новый или его нужно будет запускать явно?

    «Чтобы не потерять уже запущенный запрос при повороте экрана, Chronos предоставляет возможность “именовать” запуски операций»
    Предположим операция — это часть какой-то большой операции по обновлению инфы для пользователя. Как тогда быть с проблемой устаревания инфы? (Часть данных прогрузили с прошлого раза, потом приложение ушло в фон на недельку, вернулось в foreground и получается часть данных старая, а часть новая)

    «Есть возможность отмены операций»
    Круто, что есть возможность interrupt'ить поток загрузки. Насколько помню, в известном robospice cancel поток не interrupt'ит.


    1. MaximEfimov Автор
      21.07.2015 11:20

      Про освобождение памяти: GC соберет результаты операций, чтобы освободить память, и, когда Activity восстановится, они не будут доставлены. Повторно операция сама по себе не запустится, поскольку это, в общем случае, противоречит бизнес-логике – например, если это была операция включающая POST запрос на перевод денег в банковском приложении. Однако, вот здесь можете посмотреть, как написать код автоматически перезапускающихся операций: github.com/RedMadRobot/Chronos/wiki/Single-launch-mode, это очень просто.

      Про сложное обновление данных: в этом случае вам лучше будет в одной операции агрегировать несколько. Таким образом получится, что до UI слоя дойдет только информация о полной загрузке, либо ошибке обновления данных. Также в этом случае логично описывать что делать при неполной загрузке (откат транзакции БД, инвалидация кеша, нотификация об ошибке и тому подобное) по ходу выполнения агрегированных операций. Мы используем такой подход в ряде приложений, которые должны работать в offline, и при этом синхронизироваться с сервером по возможности.


  1. recompileme
    21.07.2015 12:24

    Привет, можно пожелание?
    Не всегда удобно экстендить уже существующие компоненты. Довольно элегантно эта проблема решалась через рефлекшен в android query

    public void asyncJson(){
            
            //perform a Google search in just a few lines of code
            
            String url = "http://www.google.com/uds/GnewsSearch?q=Obama&v=1.0";     
           //мы передаем класс (this) и имя метода (jsonCallback), в который придет результат
            aq.ajax(url, JSONObject.class, this, "jsonCallback");
            }
    
    public void jsonCallback(String url, JSONObject json, AjaxStatus status){
            
            if(json != null){               
                    //successful ajax call          
            }else{          
                    //ajax error
            }
            
    }
    


    Обратите внмание также на то, что Статус обработки приходит в тот же обработчик, что и в саксесс, что уменьшает количество кода и повышает читаемость.

    code.google.com/p/android-query/wiki/AsyncAPI


    1. MaximEfimov Автор
      21.07.2015 12:40

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

      Наследовать классы вовсе не обязательно, вот здесь пример, как «дружить» Chronos с обычной Activity: github.com/RedMadRobot/Chronos/wiki/How-to-use-Chronos#step-one--setting-up-gui. И у нас тоже в один и тот же метод-обработчик приходит как успешный результат, так и ошибка: github.com/RedMadRobot/Chronos/wiki/How-to-use-Chronos#step-four--handling-the-result.


      1. recompileme
        21.07.2015 13:04

        " вот здесь пример, как «дружить» Chronos с обычной Activity" —
        сейчас я асинктаск с тем же успехом могу допилить.

        Идея у Вас хорошая, но нужно же допилить ее до более удобного вида.

        1. Можно добавить в код проверки на то, что активити сдохло, например. И тогда не нужно плясать вокруг onResume onPause

        if (activity.isFinishing()) {
            AQUtility.warn("Warning", "Possible memory leak. Calling ajax with a terminated activity.");
            //какой ть интерфейс, например тут может теребится
        }
        this.activity  = new WeakReference<Activity>(activity); 
        


        2. Обработчики можно было бы сделать гибчее, например одинаковые для разных задач, передавать в них какие то произвольные таги, как во вьюхах или в okHttp или array params как в асинктасках
        class MyActivity {
            @Override
            protected void onCreate(Bundle savedInstanceState) {
                super.onCreate(savedInstanceState);
        
                Button startButton = (Button) findViewById(R.id.button_start);
                startButton.setOnClickListener(new View.OnClickListener() {
                    @Override
                    public void onClick(final View v) {
                        runOperation(new MyOperation(),MyActivity.this,"onOperationFinished",v);
                    }
                });
            }
        
            public void onOperationFinished(final MyOperation.Result result, final Object tag) {
                if (result.isSuccessful()) {
                    if (tag instanceоf Button) {...}
                    showData(result.getOutput());
                } else {
                    showDataLoadError(result.getError());
                }
            }
        
        }
        

        Я немножко скомкано объясняю наверно, простите нет времени подробно расписать. Сейчас это не выглядит как суперское универсальное и малобуквенное решение. Но может таким стать.


        1. MaximEfimov Автор
          21.07.2015 13:28

          Боюсь, что если прикручивать AsyncTask'и, то кода будет поболее. То, что я привел в примере достаточно один раз в базовом классе написать.

          Завязываться на isFinishing() получится только у Acitivity, ближайший родственник этого метода во Fragment – isRemoving(), но они не идентичны. Получается, что экономим две строчки кода, а придется придумывать новый способ привязки, который не факт, что будет проще и короче.

          Насчет тагов подумаем. Лично я очень не люблю instanceof и явные касты, поскольку это рушит ООП-дизайн. В качестве какого-то параметра можно всегда использовать любой объект, передаваемый в Operation при создании, и заключаемый в шаблонный Output при возвращении результата.


        1. MaximEfimov Автор
          21.07.2015 13:56

          И еще про isFinishing() – этот метод не позволяет отловить поворот экрана, так что остается потенциальная уязвимость краша при завершении операции во врем пересоздания UI.


          1. recompileme
            21.07.2015 16:02

            Спасибо за развернутый ответ. Согласен, что все непросто. В примере, который я привел после проверки на isFinishing — следующей строкой идет создание WeakReference:

            this.activity  = new WeakReference<Activity>(activity);
            

            Получается что можно однозначно определить из кода — живо ли еще активити и стоит ли слать результат — без перекрытия onPause в Активити при помощи isfinishing + weakreference. Мне кажется это немножко упростило бы внедрение. Потому что в крупном проекте — Ваша библиотека будет не единственной либой, которая захочет подписаться на onPause. Как то так.

            А хочется волшебную либу — чтоб все само работало — и прямо, и из коробки и подключалось парой строчек)