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



Конечно, сейчас уже не 2010 год, когда разработчикам приходилось использовать знаменитые паттерны A/B/C или вообще запускать AsyncTask-и и сильно бить в бубен. Появилось большое количество различных библиотек, которые позволяют вам без особых усилий выполнять запросы, в том числе и асинхронно. Эти библиотеки весьма интересны, и нам тоже стоит начать с выбора подходящей. Но для начала давайте немного вспомним, что у нас уже есть.

Раньше в Android единственным доступным средством для выполнения сетевых запросов был клиент Apache, который на самом деле далек от идеала, и не зря сейчас Google усиленно старается избавиться от него в новых приложениях. Позже плодом стараний разработчиков Google стал класс HttpUrlConnection. Он ситуацию исправил не сильно. По-прежнему не хватало возможности выполнять асинхронные запросы, хотя модель HttpUrlConnection + Loaders уже является более-менее работоспособной.

2013 год стал в этом плане весьма эффективным. Появились замечательные библиотеки Volley и Retrofit. Volley — библиотека более общего плана, предназначенная для работы с сетью, в то время как Retrofit специально создана для работы с REST Api. И именно последняя библиотека стала общепризнанным стандартом при разработке клиент-серверных приложений.

У Retrofit, по сравнению с другими средствами, можно выделить несколько основных преимуществ:
1) Крайне удобный и простой интерфейс, который предоставляет полный функционал для выполнения любых запросов;
2) Гибкая настройка — можно использовать любой клиент для выполнения запроса, любую библиотеку для разбора json и т.д.;
3) Отсутствие необходимости самостоятельно выполнять парсинг json-а — эту работу выполняет библиотека Gson (и уже не только Gson);
4) Удобная обработка результата и ошибок;
5) Поддержка Rx, что тоже является немаловажным фактором сегодня.

Если вы еще не знакомы с библиотекой Retrofit, самое время изучить ее. Но я в любом случае сделаю небольшое введение, а заодно мы немного рассмотрим новые возможности версии 2.0.0 (советую также посмотреть презентацию по Retrofit 2.0.0).

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

В первую очередь нам нужно подключить все выбранные библиотеки и требуемые зависимости для Retrofit:
compile 'com.squareup.retrofit:retrofit:2.0.0-beta1'
compile 'com.squareup.retrofit:converter-gson:2.0.0-beta1'
compile 'com.squareup.okhttp:okhttp:2.0.0'

Мы будем получать аэропорты в виде списка объектов определенного класса.
Поэтому этот класс надо создать
public class Airport {

    @SerializedName("iata")
    private String mIata;

    @SerializedName("name")
    private String mName;

    @SerializedName("airport_name")
    private String mAirportName;

    public Airport() {
    }
}


Создаем сервис для запросов:
public interface AirportsService {

    @GET("/places/coords_to_places_ru.json")
    Call<List<Airport>> airports(@Query("coords") String gps);

}

Примечание про Retrofit 2.0.0
Раньше для выполнения синхронных и асинхронных запросов мы должны были писать разные методы. Теперь при попытке создать сервис, который содержит void метод, вы получите ошибку. В Retrofit 2.0.0 интерфейс Call инкапсулирует запросы и позволяет выполнять их синхронно или асинхронно.
Раньше
public interface AirportsService {

    @GET("/places/coords_to_places_ru.json")
    List<Airport> airports(@Query("coords") String gps);

    @GET("/places/coords_to_places_ru.json")
    void airports(@Query("coords") String gps, Callback<List<Airport>> callback);

}


Сейчас
AirportsService service = ApiFactory.getAirportsService();
Call<List<Airport>> call = service.airports("55.749792,37.6324949");

//sync request
call.execute();

//async request
Callback<List<Airport>> callback = new RetrofitCallback<List<Airport>>() {
    @Override
    public void onResponse(Response<List<Airport>> response) {
        super.onResponse(response);
    }
};
call.enqueue(callback);


Теперь создадим вспомогательные методы:
public class ApiFactory {

    private static final int CONNECT_TIMEOUT = 15;
    private static final int WRITE_TIMEOUT = 60;
    private static final int TIMEOUT = 60;

    private static final OkHttpClient CLIENT = new OkHttpClient();

    static {
        CLIENT.setConnectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS);
        CLIENT.setWriteTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS);
        CLIENT.setReadTimeout(TIMEOUT, TimeUnit.SECONDS);
    }

    @NonNull
    public static AirportsService getAirportsService() {
        return getRetrofit().create(AirportsService.class);
    }

    @NonNull
    private static Retrofit getRetrofit() {
        return new Retrofit.Builder()
                .baseUrl(BuildConfig.API_ENDPOINT)
                .addConverterFactory(GsonConverterFactory.create())
                .client(CLIENT)
                .build();
    }
}

Отлично! Подготовка завершена, и теперь мы можем выполнить запрос:
public class MainActivity extends AppCompatActivity implements Callback<List<Airport>> {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        AirportsService service = ApiFactory.getAirportsService();
        Call<List<Airport>> call = service.airports("55.749792,37.6324949");
        call.enqueue(this);
    }

    @Override
    public void onResponse(Response<List<Airport>> response) {
        if (response.isSuccess()) {
            List<Airport> airports = response.body();
            //do something here
        }
    }

    @Override
    public void onFailure(Throwable t) {
    }
}

Все кажется очень простым. Мы без особых усилий создали нужные классы, и уже можем делать запросы, получать результат и обрабатывать ошибки, и все это буквально за 10 минут. Что же еще нужно?

Однако такой подход является в корне неверным. Что будет, если во время выполнения запроса пользователь повернет устройство или вообще закроет приложение? С уверенностью можно сказать только то, что нужный результат вам не гарантирован, и мы недалеко ушли от первоначальных проблем. Да и запросы в активити и фрагментах никак не добавляют красоты вашему коду. Поэтому пора, наконец, вернуться к основной теме статьи — построение архитектуры клиент-серверного приложения.

В данной ситуации у нас есть несколько вариантов. Можно воспользоваться любой библиотекой, которая обеспечивает грамотную работу с многопоточностью. Здесь идеально подходит фреймворк Rx, тем более что Retrofit его поддерживает. Однако построить архитектуру с Rx или даже просто использовать функциональное реактивное программирование — это нетривиальные задачи. Мы пойдем по более простому пути: воспользуемся средствами, которые предлагает нам Android из коробки. А именно, лоадерами.

Лоадеры появились в версии API 11 и до сих пор остаются очень мощным средством для параллельного выполнения запросов. Конечно, в лоадерах можно делать вообще что угодно, но обычно их используют либо для чтения данных с базы, либо для выполнения сетевых запросов. И самое важное преимущество лоадеров — через класс LoaderManager они связаны с жизненным циклом Activity и Fragment. Это позволяет использовать их без опасения, что данные будут утрачены при закрытии приложения или результат вернется не в тот коллбэк.

Обычно модель работы с лоадерами подразумевает следующие шаги:
1) Выполняем запрос и получаем результат;
2) Каким-то образом кэшируем результат (чаще всего в базе данных);
3) Возвращаем результат в Activity или Fragment.

Примечание
Такая модель хороша тем, что Activity или Fragment не думают, как именно получаются данные. Например, с сервера может вернуться ошибка, но при этом лоадер вернет закэшированные данные.

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

Примечание
Все лоадеры должны работать с универсальным типом данных, чтобы можно было использовать интерфейс LoaderCallbacks в одной активити или фрагменте для разных типов загружаемых данных. Первым таким типом, который приходит на ум, является Cursor.

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

Loader + ContentProvider + асинхронные запросы


Предусловия: есть классы для работы с базой данных SQLite через ContentProvider, есть возможность сохранять сущности в эту базу.

В контексте данной модели крайне сложно вынести какую-то общую логику в базовый класс, поэтому в данном случае это всего лишь лоадер, от которого удобно наследоваться для выполнения асинхронных запросов. Его содержание не относится непосредственно к рассматриваемой архитектуре, поэтому он в спойлере. Однако вы также можете использовать его в своих приложениях:
BaseLoader
public class BaseLoader extends Loader<Cursor> {

    private Cursor mCursor;

    public BaseLoader(Context context) {
        super(context);
    }

    @Override
    public void deliverResult(Cursor cursor) {
        if (isReset()) {
            if (cursor != null) {
                cursor.close();
            }
            return;
        }
        Cursor oldCursor = mCursor;
        mCursor = cursor;

        if (isStarted()) {
            super.deliverResult(cursor);
        }

        if (oldCursor != null && oldCursor != cursor && !oldCursor.isClosed()) {
            oldCursor.close();
        }
    }

    @Override
    protected void onStartLoading() {
        if (mCursor != null) {
            deliverResult(mCursor);
        } else {
            forceLoad();
        }
    }

    @Override
    protected void onReset() {
        if (mCursor != null && !mCursor.isClosed()) {
            mCursor.close();
        }
        mCursor = null;
    }

}


Тогда лоадер для загрузки аэропортов может выглядеть следующим образом:
public class AirportsLoader extends BaseLoader {

    private final String mGps;

    private final AirportsService mAirportsService;

    public AirportsLoader(Context context, String gps) {
        super(context);
        mGps = gps;
        mAirportsService = ApiFactory.getAirportsService();
    }

    @Override
    protected void onForceLoad() {
        Call<List<Airport>> call = mAirportsService.airports(mGps);
        call.enqueue(new RetrofitCallback<List<Airport>>() {
            @Override
            public void onResponse(Response<List<Airport>> response) {
                if (response.isSuccess()) {
                    AirportsTable.clear(getContext());
                    AirportsTable.save(getContext(), response.body());
                    Cursor cursor = getContext().getContentResolver().query(AirportsTable.URI,
                            null, null, null, null);
                    deliverResult(cursor);
                } else {
                    deliverResult(null);
                }
            }
        });
    }
}

И теперь мы наконец можем использовать его в UI классах:
public class MainActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks<Cursor> {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        getLoaderManager().initLoader(R.id.airports_loader, Bundle.EMPTY, this);
    }

    @Override
    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
        switch (id) {
            case R.id.airports_loader:
                return new AirportsLoader(this, "55.749792,37.6324949");
            default:
                return null;
        }
    }

    @Override
    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
        int id = loader.getId();
        if (id == R.id.airports_loader) {
            if (data != null && data.moveToFirst()) {
                List<Airport> airports = AirportsTable.listFromCursor(data);
                //do something here
            }
        }
        getLoaderManager().destroyLoader(id);
    }

    @Override
    public void onLoaderReset(Loader<Cursor> loader) {
    }
}

Как видно, здесь нет ничего сложного. Это абсолютно стандартная работа с лоадерами. На мой взгляд, лоадеры предоставляют идеальный уровень абстракции. Мы загружаем нужные данные, но без лишних знаний о том, как именно они загружаются.

Эта модель стабильная, достаточно удобная для использования, но все же имеет недостатки:
1) Каждый новый лоадер содержит свою логику для работы с результатом. Этот недостаток можно исправить, и частично мы сделаем это в следующей модели и полностью — в последней.
2) Второй недостаток намного серьезнее: все операции с базой данных выполняются в главном потоке приложения, а это может приводить к различным негативным последствиям, даже до остановки приложения при очень большом количестве сохраняемых данных. Да и в конце концов, мы же используем лоадеры. Давайте делать все асинхронно!

Loader + ContentProvider + синхронные запросы


Спрашивается, зачем мы выполняли запрос асинхронно с помощью Retrofit-а, когда лоадеры и так позволяют нам работать в background? Исправим это.

Эта модель упрощенная, но основное отличие заключается в том, что асинхронность запроса достигается за счет лоадеров, и работа с базой уже происходит не в основном потоке. Наследники базового класса должны лишь вернуть нам объект типа Cursor. Теперь базовый класс может выглядеть следующим образом:
public abstract class BaseLoader extends AsyncTaskLoader<Cursor> {

    public BaseLoader(Context context) {
        super(context);
    }

    @Override
    protected void onStartLoading() {
        super.onStartLoading();
        forceLoad();
    }

    @Override
    public Cursor loadInBackground() {
        try {
            return apiCall();
        } catch (IOException e) {
            return null;
        }
    }

    protected abstract Cursor apiCall() throws IOException;
}

И тогда реализация абстрактного метода может выглядеть следующим образом:
@Override
protected Cursor apiCall() throws IOException {
    AirportsService service = ApiFactory.getAirportsService();
    Call<List<Airport>> call = service.airports(mGps);
    List<Airport> airports = call.execute().body();
    AirportsTable.save(getContext(), airports);
    return getContext().getContentResolver().query(AirportsTable.URI, null, null, null, null);
}

Работа с лоадером в UI у нас никак не изменилась.

По факту, эта модель является модификацией предыдущей, она частично устраняет ее недостатки. Но на мой взгляд, этого все равно недостаточно. Тут можно снова выделить недостатки:
1) В каждом лоадере присутствует индивидуальная логика сохранения данных.
2) Возможна работа только с базой данных SQLite.

И наконец, давайте полностью устраним эти недостатки и получим универсальную и почти идеальную модель!

Loader + любое хранилище данных + синхронные запросы


Перед рассмотрением конкретных моделей я говорил о том, что для лоадеров мы должны использовать единый тип данных. Кроме Cursor ничего на ум не приходит. Так давайте создадим такой тип! Что должно в нем быть? Естественно, он не должен быть generic-типом (иначе мы не сможем использовать коллбэки лоадера для разных типов данных в одной активити / фрагменте), но в то же время он должен быть контейнером для объекта любого типа. И вот здесь я вижу единственное слабое место в этой модели — мы должны использовать тип Object и выполнять unchecked преобразования. Но все же, это не столь существенный минус. Итоговая версия данного типа выглядит следующим образом:
public class Response {

    @Nullable private Object mAnswer;

    private RequestResult mRequestResult;

    public Response() {
        mRequestResult = RequestResult.ERROR;
    }

    @NonNull
    public RequestResult getRequestResult() {
        return mRequestResult;
    }

    public Response setRequestResult(RequestResult requestResult) {
        mRequestResult = requestResult;
        return this;
    }

    @Nullable
    public <T> T getTypedAnswer() {
        if (mAnswer == null) {
            return null;
        }
        //noinspection unchecked
        return (T) mAnswer;
    }

    public Response setAnswer(@Nullable Object answer) {
        mAnswer = answer;
        return this;
    }

    public void save(Context context) {
    }
}

Данный тип может хранить результат выполнения запроса. Если мы хотим что-то делать для конкретного запроса, нужно унаследоваться от этого класса и переопределить / добавить нужные методы. Например, так:
public class AirportsResponse extends Response {

    @Override
    public void save(Context context) {
        List<Airport> airports = getTypedAnswer();
        if (airports != null) {
            AirportsTable.save(context, airports);
        }
    }
}

Отлично! Теперь напишем базовый класс для лоадеров:
public abstract class BaseLoader extends AsyncTaskLoader<Response> {

    public BaseLoader(Context context) {
        super(context);
    }

    @Override
    protected void onStartLoading() {
        super.onStartLoading();
        forceLoad();
    }

    @Override
    public Response loadInBackground() {
        try {
            Response response = apiCall();
            if (response.getRequestResult() == RequestResult.SUCCESS) {
                response.save(getContext());
                onSuccess();
            } else {
                onError();
            }
            return response;
        } catch (IOException e) {
            onError();
            return new Response();
        }
    }

    protected void onSuccess() {
    }

    protected void onError() {
    }

    protected abstract Response apiCall() throws IOException;
}

Этот класс лоадера является конечной целью данной статьи и, на мой взгляд, отличной, работоспособной и расширяемой моделью. Хотите перейти с SQLite, например, на Realm? Не проблема. Рассмотрим это в качестве следующего примера. Классы лоадеров не изменятся, изменится только модель, которую вы бы в любом случае редактировали. Не удалось выполнить запрос? Не проблема, доработайте в наследнике метод apiCall. Хотите очистить базу данных при ошибке? Переопределите onError и работайте — этот метод выполняется в фоновом потоке.

А любой конкретный лоадер можно представить следующим образом (опять-таки, покажу только реализацию абстрактного метода):
@Override
protected Response apiCall() throws IOException {
    AirportsService service = ApiFactory.getAirportsService();
    Call<List<Airport>> call = service.airports(mGps);
    List<Airport> airports = call.execute().body();
    return new AirportsResponse()
            .setRequestResult(RequestResult.SUCCESS)
            .setAnswer(airports);
}

Примечание
При неудачно выполненном запросе будет выброшен Exception, и мы попадем в catch-ветку базового лоадера.

В итоге мы получили следующие результаты:
1) Каждый лоадер зависит исключительно от своего запроса (от параметров и результата), но при этом он не знает, что он делает с полученными данными. То есть он будет меняться только при изменении параметров конкретного запроса.
2) Базовый лоадер управляет всей логикой выполнения запросов и работы с результатами.
3) Более того, сами классы модели тоже не имеют понятия о том, как устроена работа с базой данных и прочее. Все это вынесено в отдельные классы / методы. Я этого нигде не указывал явно, но это можно посмотреть в примере на Github — ссылка в конце статьи.

Вместо заключения


Чуть выше я обещал показать еще один пример — переход с SQLite на Realm — и убедиться, что мы действительно не затронем лоадеры. Давайте сделаем это. На самом деле, кода здесь совсем чуть-чуть, ведь работа с базой у нас сейчас выполняется лишь в одном методе (я не учитываю изменения, связанные со спецификой Realm, а они есть, в частности, правила именования полей и работа с Gson; их можно посмотреть на Github).

Подключим Realm:
compile 'io.realm:realm-android:0.82.1'

И изменим метод save в AirportsResponse:
public class AirportsResponse extends Response {

    @Override
    public void save(Context context) {
        List<Airport> airports = getTypedAnswer();
        if (airports != null) {
            AirportsHelper.save(Realm.getInstance(context), airports);
        }
    }
}

AirportsHelper
public class AirportsHelper {

    public static void save(@NonNull Realm realm, List<Airport> airports) {
        realm.beginTransaction();
        realm.clear(Airport.class);
        realm.copyToRealm(airports);
        realm.commitTransaction();
    }

    @NonNull
    public static List<Airport> getAirports(@NonNull Realm realm) {
        return realm.allObjects(Airport.class);
    }
}


Вот и все! Мы элементарным образом, не затрагивая классы, которые содержат другую логику, изменили способ хранения данных.

Все-таки заключение


Хочу выделить один достаточно важный момент: мы не рассмотрели вопросы, связанные с использованием закэшированных данных, то есть при отстуствии интернета. Однако стратегия использования закэшированных данных в каждом приложении индивидуальна, и навязывать какой-то определенный подход я не считаю правильным. Да и так статья растянулась.

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

Спасибо, что дочитали до конца. Удачной разработки!

P.S. Обещанная ссылка на код на GitHub.

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


  1. HotIceCream
    31.08.2015 14:28
    +1

    Мы пойдем по более простому пути...

    А жаль, про то как организовывать работу с RxJava было бы интересно почитать. Причем, интересует именно вопрос хранения данных при повороте экрана. В loader'ах об это думает система, а в RxAndroid обычно советуют cache() + какое-нибудь статическое хранилище для обсерверов. А как вы храните их?)


    1. Arturka
      31.08.2015 15:17
      +1

      Я допускаю, что и об архитектуре с Rx могу написать, но не обещаю :)
      Но на самом деле, мы в легионе используем для Rx похожую модель. Такое же использование лоадеров, чтобы система заботилась + всякие фишки и операторы от Rx, довольно неплохо получается. Вот только мне она не очень нравится (сильно больше классов выходит), а ничего получше я пока не придумал. Вот если появятся хорошие идеи, то можно и статью написать.


      1. ptiss
        08.09.2015 11:47

        В последнее время обкатываю связку Rx + Retrofit-like транспорт слой (возвращает Observable) + AndroidViewModel (MVVM библиотека с VM, переживающей повороты и пр., на похожей идее основана Chronos от Роботов). Пока полёт нормальный и таких простыней нет.


  1. psa
    31.08.2015 17:28
    +1

    Речь идет об архитектуре, а в статье нет ни одной диаграммы


  1. Valle
    31.08.2015 18:24

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


    1. Arturka
      31.08.2015 18:36
      +1

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


      1. Valle
        31.08.2015 19:56

        Это понятно, но имхо время жизни объектов, равно как и правила их кеширования должны задаваться в HTTP, и, следовательно, обрабатываться на том же уровне. Если ответ сервера еще не протух, то сетевая библиотека сразу же должна возвратить закешированный результат. Конечно, в некоторых случаях оправдано хранить модель данных локально, если есть жесткие требования к работе оффлайн, но это далеко не самый обычный http клиент.


        1. Arturka
          31.08.2015 20:17
          +1

          Я склонен считать, что всегда есть смысл хранить данные локально. Что будет, если у вас сервер вдруг отвалится? Никакое кэширование в HTTP уже не поможет. И приложение несколько даже не будет открываться.
          Все равно не понимаю вашу нелюбовь к локальной базе :) Занимает не так много места, а дает возможность постоянно быстро получать данные из разных мест без всяких запросов и прочем. Не вижу недостатков в таком подходе, а преимущества, как мне кажется, очевидны.


          1. Valle
            31.08.2015 21:00

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


            1. Arturka
              31.08.2015 21:12
              +1

              Честно скажу, что не работал с кэшированием в http, так что дальше могу ошибаться. Насколько я понимаю, кэширование тоже выполняется локально в файлах или как-то еще. Скорость, конечно, повыше чем из бд, но не настолько, чтобы это было явным преимуществом. Но если мы говорим о структурированных данных разного типа из разных запросов, то без бд тут не обойтись. Да и когда у нас в приложении 50 разных запросов, использовать кэш для всех тоже вряд ли разумно. Кроме того, может случиться так, что кэш очистится (если закрыл приложение и не использовал его какое-то время), запустил приложение, а сервер лежит. С бд не попадем в такую ситуацию.
              Впрочем да, я с вам согласен частично, в каких-то приложениях достаточно будет такого кэширование, но все же такая архитектура менее расширяема, и при росте приложения ее будет сложно поддерживать.


    1. dzigoro
      31.08.2015 18:56
      +1

      Потому что а) источник данных должен быть один б) активити может быть прибита в любой момент.

      Поэтому в ситуации создание активити — запрос — прибитие активити такой подход позволяет все-таки сохранить данные, и на следующем запуске их показать.

      Плюс, от запроса не всегда приходят просто данные для показа. Часто это данные бизнес-логики(данные для авторизации н-р).


      1. Valle
        31.08.2015 19:58

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


        1. Arturka
          31.08.2015 20:14
          +1

          Не знаю, правильно ли вас понял, но пример для б) я приводил в самом начале, с запросом в Activity. Там запрос выполнится, но при преждевременной смерти Activity, результат просто потеряется.
          По сути база данных нужна для нескольких кейсов:
          1) Даже если мы всегда ходим за данными на бэкенд, то все равно в один прекрасный момент интернет может пропасть, и нам придется работать локально.
          2) Мы работаем с какой-то сложной структурой данных, и нужно использовать данные от предыдущих запросов (даже из апи примера в статье, получили список аэропортов, потом нужно получить список популярных направлений с учетом этих аэропортов [может, бред говорю, не изучал это апи]. Как их хранить? Напрямую в памяти как-то не очень хорошо. Кэширование запроса тоже не подходит. Вот и используем БД.)


          1. Valle
            31.08.2015 21:07
            +1

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


            1. Arturka
              31.08.2015 21:18

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


              1. dev_troy
                31.08.2015 21:24

                Ой, ну ладно. Прям уж архаизм? Имхо, гораздо удобнее хранить сложные структуры в БД, нежели в Map<String, Map<String, Map<String… >>>>>>>>>> =) К тому же, :memory: хранилище в БД никто не отменял. Тот же SQLite прекрасно может в память!


                1. Valle
                  31.08.2015 21:28

                  Зачем же такие страсти с мапами? Ничего удобнее и быстрее чем доступ к POJO нет.


                  1. Arturka
                    31.08.2015 22:11

                    Мы вообще переходим на такой подход, что в БД храним чистый json, который приходит с сервера (то есть один столбец строковый в бд), а при создании объекта получаем эту строку и через Gson конвертируем. Получается вот такая немного странная сериализация. Намного удобнее, на самом деле. В этом случае нельзя сказать, что БД нам для чего-то необходима.

                    P.S. Упс, не совсем в ту ветку добавил коммент)


                    1. dev_troy
                      31.08.2015 22:54

                      Тоже подход, но это уже крайний случай, если уж совсем никак. Хотя, скоро будет праздник для любителей хранить JSON: www.sqlite.org/src/timeline?r=json


                  1. dev_troy
                    31.08.2015 22:51

                    Да, без сомнения POJO лучше, но сути особо не меняет, особенно, если нужны операции фильтрации, сортировки и прочие прелести (а они нужны в большинстве случаев).


  1. belozerow
    31.08.2015 19:14

    Вопрос вне данной архитектуры, но раз уж вы используете realm.io, то как вы сообщаете UI об изменениях в базе? Eventbus?
    Всем хорош realm.io, но этот вопрос не дает мне покоя.

    Про RX тоже очень интересно было бы почитать.


    1. Arturka
      31.08.2015 19:33

      Да, с реалмом вопрос хороший, и в принципе любой bus с этим может справиться, хотя они не добавляют плюсов к карме в архитектуру. Обычно в сочетании с Realm мы используем Rx, и уже соответственно, средства Rx для таких оповещений.
      Я не то чтобы сильный фанат реалма на самом деле, его использовал лишь для примера, что можно легко перейти к другой БД.
      Хорошо, я всерьез подумаю о том, чтобы в ближайший месяц максимально разобраться с Rx и что-то такое написать :) Хотя не исключено, что это сделает еще кто-нибудь :)


      1. belozerow
        31.08.2015 19:36

        А что вы используете в качестве БД в своих проектах?
        Голый SQLite это ад. Всякие annotation фрэимворки упрощают конечно это дело, но с реалмом ни в какое сравнение не идут.


        1. Arturka
          31.08.2015 19:40

          У нас в легионе внутренняя библиотека для SQLite, которая этот ад сильно уменьшает. Ну и иногда используем Realm, недавно начали.


  1. andrey7mel
    01.09.2015 00:04

    Спасибо за развернутый обзор!
    Подскажите пожалуйста, при использовании Rx, как решаете проблему кеширования и оффлайн работы? Например отображать сначала сохраненные данные, проверить необходимость обновления, запустить загрузку новых данных и отобразить.


    1. Arturka
      01.09.2015 08:33

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

      Такой подход можно использовать независимо от того, что вы используете для получения данных с бэкенда. Здесь важно лишь то, является ли критичным показ актуальных данных, или можно сначала показать старые. У нас общая политика обычно такая — всегда сначала ходим на бэкенд, а уже потом достаем из кэша, в случае ошибки. Обычно стараемся подгружать данные за экран-два до того, как они понадобятся, так что такой подход тоже неплох.

      Схема с Rx примерно такая (могу немного ошибаться, так как с Rx-проектами не работал особо) — получаем Observable, потом в flatMap сохраняем данные (или повторяем запрос, если ошибка) и прокидываем Observable в UI.


  1. ruslanys
    01.09.2015 01:37
    -2

    запускать AsyncTask-и и сильно бить в бубен

    Я последний раз работал с Android API 4.0.2, но бубнов не помю.


    Раньше в Android единственным доступным средством для выполнения сетевых запросов был клиент Apache, который на самом деле далек от идеала, и не зря сейчас Google усиленно старается избавиться от него в новых приложениях.

    HTTP Apache Client — прекрасная библиотека, используемая в куче Java проектов. В энтерпрайзе полно.


    Позже плодом стараний разработчиков Google стал класс HttpUrlConnection.

    Разве HttpUrlConnection не часть Java API? (JavaDoc).


    Он ситуацию исправил не сильно. По-прежнему не хватало возможности выполнять асинхронные запросы, хотя модель HttpUrlConnection + Loaders уже является более-менее работоспособной.

    А как связана многопоточность(асинхронность) и работа с сетью (HttpUrlConnection)?


    А Retrofit библиотека хорошая.


    1. Arturka
      01.09.2015 08:27

      Я последний раз работал с Android API 4.0.2, но бубнов не помю.

      AsyncTask-и никак не связаны с жизненным циклом Activity / Fragment. Со всеми вытекающими проблемами при уничтожении Activity / закрытии приложения.
      HTTP Apache Client — прекрасная библиотека, используемая в куче Java проектов. В энтерпрайзе полно.

      Библиотека отличная, без сомнений. Проблема в том, что в Android SDK включена не сама библиотека как зависимость, а ее самая начальная beta-версия. Сейчас в API 23 те, кто хочет продолжать использовать Apache, уже подключают саму библиотеку.
      Разве HttpUrlConnection не часть Java API? (JavaDoc).

      Да, здесь я погрешил против истины. В свое оправдание могу сказать, что Google все равно переработал этот класс.
      А как связана многопоточность(асинхронность) и работа с сетью (HttpUrlConnection)?

      Если рассматривать их отдельно, то никак. А если в контексте разработки приложения, то связь самая прямая. Не очень-то возможно работать с сетью без асинхронности / многопоточности.


  1. forgotten
    01.09.2015 09:23

    compile 'com.squareup.retrofit:retrofit:2.0.0-beta1'
    compile 'com.squareup.retrofit:converter-gson:2.0.0-beta1'
    compile 'com.squareup.okhttp:okhttp:2.0.0'
    
    public class Airport {
    
        @SerializedName("iata")
        private String mIata;
    
        @SerializedName("name")
        private String mName;
    
        @SerializedName("airport_name")
        private String mAirportName;
    
        public Airport() {
        }
    }
    
    public interface AirportsService {
    
        @GET("/places/coords_to_places_ru.json")
        Call<List<Airport>> airports(@Query("coords") String gps);
    
    }
    
    public class ApiFactory {
    
        private static final int CONNECT_TIMEOUT = 15;
        private static final int WRITE_TIMEOUT = 60;
        private static final int TIMEOUT = 60;
    
        private static final OkHttpClient CLIENT = new OkHttpClient();
    
        static {
            CLIENT.setConnectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS);
            CLIENT.setWriteTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS);
            CLIENT.setReadTimeout(TIMEOUT, TimeUnit.SECONDS);
        }
    
        @NonNull
        public static AirportsService getAirportsService() {
            return getRetrofit().create(AirportsService.class);
        }
    
        @NonNull
        private static Retrofit getRetrofit() {
            return new Retrofit.Builder()
                    .baseUrl(BuildConfig.API_ENDPOINT)
                    .addConverterFactory(GsonConverterFactory.create())
                    .client(CLIENT)
                    .build();
        }
    }
    
    public class MainActivity extends AppCompatActivity implements Callback<List<Airport>> {
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
            AirportsService service = ApiFactory.getAirportsService();
            Call<List<Airport>> call = service.airports("55.749792,37.6324949");
            call.enqueue(this);
        }
    
        @Override
        public void onResponse(Response<List<Airport>> response) {
            if (response.isSuccess()) {
                List<Airport> airports = response.body();
                //do something here
            }
        }
    
        @Override
        public void onFailure(Throwable t) {
        }
    }
    


    > Все кажется очень простым.



    То есть получение списка объектов вида «три строки» HTTP-запросом в три экрана кода — это просто и хорошо, что, наконец, появились удобные библиотеки???


    1. forgotten
      01.09.2015 09:29

      Так, для сравнения, на некоторых языках этот код выглядит вот так:

      fetch('/places/coords_to_places_ru.json').then(
          function (response) {
              return response.json();
          },
          console.error.bind(console)
      );
      


      1. psa
        01.09.2015 11:06

        Осталось распарсить json и преобразовать в объект


        1. forgotten
          01.09.2015 12:14

          response.json() именно это и делает.


          1. psa
            01.09.2015 15:04

            Да, верно, но он вернет JSON object, что не совсем похоже на:

            List<Airport> airports = response.body();
            


            Да и какой смысл сравнивать кусок javascript'a и часть архитектурного слоя android приложения, который наверняка писался не для того чтобы просто послать HTTP-запрос.


    1. Arturka
      01.09.2015 09:57

      Вот запрос.

      @GET("/places/coords_to_places_ru.json")
      Call<List<Airport>> airports(@Query("coords") String gps);
      

      MainActivity — это UI часть.
      ApiFactory — общий класс для всех запросов. Больше он не трогается.
      Зависимости считать кодом тоже как-то странно.
      Можете как-нибудь сравнить это с кодом, который писался раньше плюс ручной парсинг json-а.


      1. Valle
        01.09.2015 17:52

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


    1. senneco
      01.09.2015 23:27
      +2

      Мало того, решение можно сделать ещё более «простым», подпилив gson, чтоб не нужно было писать аннотации в модели =) Gson умеет «сам» убирать префикс m и переводить CamelCase к lower_case_with_underscores и обратно(когда переводим объект в json):

      new GsonBuilder()
                      .setFieldNamingPolicy(FieldNamingPolicy.UPPER_CAMEL_CASE)
                      .setFieldNamingStrategy(new FieldNamingStrategy {
                          public String translateName(Field field) {
                              String name = FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES.translateName(field);
                              name = name.substring(2, name.length()).toLowerCase();
                              return name;
                          }
                      })
                      .create();
      


  1. atetc
    05.09.2015 11:57

    Кстати в чате Android-разработчиков появился новый канал посвященный обсуждению паттернов и архитектуры приложений gitter.im/rus-speaking/android-patterns