Работа с API различных порталов — одна из самых распространенных задач, возникающих при разработке под Android. Казалось бы, ничего сложного — асинхронно посылать HTTP-запросы и отображать ответы, но дьявол, как всегда, кроется в деталях.
Основные антипаттерны:
- Отправка запроса прямо из кода Activity в основном треде — тут без комментариев, т.к. это приводит к заморозке UI, вследствие чего система может предложить убить приложением;
- Отправка запроса из кода Activity при помощи AsyncTask — плохо, т.к. если пользователь, к примеру, повернет экран, Activity пересоздастся и запрос придется выполнять заново, что приводит увеличению времени ожидания и количества потребляемого трафика;
- Отсутствие кэширования — после каждого действия пользователя ему придется ждать полной загрузки данных.
Решение проблем
Эти проблемы далеко не новы, и в 2010 году на коференции Google I/O Virgil Dobjanschi представил три патерна для REST клиентов (слайды с презентации). Их объединяют следующие черты:
- Все данные кэшируются в базу данных SQLite, работа с ней ведется при помощи ContentProvider-а, что обеспечивает отделение данных от представление и удобство работы с ними;
- Сетевые вопросы выполняются через специально созданный сервис (каждый запрос в отдельном потоке), это необходимо для того, чтобы даже если пользователь выйдет из приложения, данные все равно докачались и сохранились.
Сами паттерны:
A. Activity → Service → ContentProvider, Activity взаимодействует с сервисом для выполнения сетевых запросов, сервис сохраняет полученные из сети данные в ContentProvider, откуда потом Activity их получает;
B. Activity → ContentProvider → Service, Activity взаимодействует только с ContentProvider, ContentProvider выполняет сетевые запросы через сервис;
C. Activity → ContentProvider → SyncAdapter, аналогично предыдущему, но предназначено для специфической ситуации, в которой локальные данные полностью синхронизируются с данными в облаке. В качестве примера можно
указать на контакты в аккаунте Google.
Наше приложение
В этой статье мы разберемся с паттерном A и создадим простейшее приложение, использующее его для отображения постов некоторого пользователя из твиттера. Подробная схема паттерна:
ContentProvider
В первую очередь, нам необходимо создать модель данных для нашего приложения. Данные будем хранить в виде таблицы tweets, в которой два поля кроме _id: user_name и body — имя пользователя и текст твита, соответственно.
Опишем контакт нашего провайдера:
public final class Contract {
public static final String AUTHORITY = "com.example.rest_a";
public static final Uri AUTHORITY_URI = Uri.parse("content://" + AUTHORITY);
public interface TweetsCoulmns {
public static final String USER_NAME = "user_name";
public static final String BODY = "body";
}
public static final class Tweets implements BaseColumns, TweetsCoulmns {
public static final String CONTENT_PATH = "tweets";
public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, CONTENT_PATH);
public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd." + AUTHORITY + "." + CONTENT_PATH;
}
}
Исходный код самого класса провайдера можно опустить, в нем создается база данных SQLite при помощи DatabaseHelper-а с названными полями и обеспечивается прямая работа с ней, в точности как в примере из документации. Стоит отметить только необходимость оповещать об изменениях данных подключенных наблюдателей, для чего в методе insert() имеется строка
getContext().getContentResolver().notifyChange(Contract.Tweets.CONTENT_URI, null);
а в методе query(), соответственно,
cursor.setNotificationUri(getContext().getContentResolver(), Contract.Tweets.CONTENT_URI);
Service
Теперь пришло время создать сервис, который бы выполнял запросы к серверу. Мы не будем изобретать велосипед и воспользуемся библитекой DataDroid для этого.
Для начала создадим напишем код, который получает данные из твиттера из объекта и вставляет их в базу данных. В DataDroid его нужно писать в классе, реализующем интерфейс Operation:
public final class TweetsOperation implements Operation {
@Override
public Bundle execute(Context context, Request request)
throws ConnectionException, DataException, CustomRequestException {
NetworkConnection connection = new NetworkConnection(context, "https://api.twitter.com/1/statuses/user_timeline.json");
HashMap<String, String> params = new HashMap<String, String>();
params.put("screen_name", request.getString("screen_name"));
connection.setParameters(params);
ConnectionResult result = connection.execute();
ContentValues[] tweetsValues;
try {
JSONArray tweetsJson = new JSONArray(result.body);
tweetsValues = new ContentValues[tweetsJson.length()];
for (int i = 0; i < tweetsJson.length(); ++i) {
ContentValues tweet = new ContentValues();
tweet.put("user_name", tweetsJson.getJSONObject(i).getJSONObject("user").getString("name"));
tweet.put("body", tweetsJson.getJSONObject(i).getString("text"));
tweetsValues[i] = tweet;
}
} catch (JSONException e) {
throw new DataException(e.getMessage());
}
context.getContentResolver().delete(Contract.Tweets.CONTENT_URI, null, null);
context.getContentResolver().bulkInsert(Contract.Tweets.CONTENT_URI, tweetsValues);
return null;
}
Как видно из исходника, параметр screen_name передается через объект Request из DataDroid, который реализует интерфейс Parcelable и может быть передан через Intent. Напишем вспомогательный класс, который создавал бы Request-ы:
public final class RequestFactory {
public static final int REQUEST_TWEETS = 1;
public static Request getTweetsRequest(String screenName) {
Request request = new Request(REQUEST_TWEETS);
request.put("screen_name", screenName);
return request;
}
private RequestFactory() {
}
}
и класс сервиса, родителем которого является RequestService из DataDroid, так что нам достаточно определить соответствие типов запросов и объектов Operation:
public class RestService extends RequestService {
@Override
public Operation getOperationForType(int requestType) {
switch (requestType) {
case RequestFactory.REQUEST_TWEETS:
return new TweetsOperation();
default:
return null;
}
}
Осталось определить синглотон типа RequestManager из DataDroid, в конструктор которому передать наш сервис, и модель готова.
Activity
Реализуем Activity. Для начала создадим layout со списком, правда, вместо ListView будем использовать PullToRefreshListView для обновления ленты скроллингом вниз.
Создаем SimpleCursorAdapter и подключаем к нему CursorLoader, который будет загружать данных из нашего ContentProvider-а:
private static final int LOADER_ID = 1;
private static final String[] PROJECTION = {
Tweets._ID,
Tweets.USER_NAME,
Tweets.BODY
};
private LoaderCallbacks<Cursor> loaderCallbacks = new LoaderCallbacks<Cursor>() {
@Override
public Loader<Cursor> onCreateLoader(int loaderId, Bundle arg1) {
return new CursorLoader(
MainActivity.this,
Tweets.CONTENT_URI,
PROJECTION,
null,
null,
null
);
}
@Override
public void onLoadFinished(Loader<Cursor> arg0, Cursor cursor) {
adapter.swapCursor(cursor);
}
@Override
public void onLoaderReset(Loader<Cursor> arg0) {
adapter.swapCursor(null);
}
};
protected void onCreate(Bundle savedInstanceState) {
// ...
adapter = new SimpleCursorAdapter(this,
R.layout.tweet_view,
null,
new String[]{ Tweets.USER_NAME, Tweets.BODY },
new int[]{ R.id.user_name_text_view, R.id.body_text_view },
0);
listView.setAdapter(adapter);
getSupportLoaderManager().initLoader(LOADER_ID, null, loaderCallbacks);
// ...
}
Теперь осталось добавить загрузку твитов. Для этого нужно при помощи RequestFactory создать объект запроса для загрузки твитов и запустить этот запрос при помощи RequestManager-а. Все это повесим на событие pull-down нашего списка. Вот код:
private RestRequestManager requestManager;
// ...
protected void onCreate(Bundle savedInstanceState) {
// ...
listView.setOnRefreshListener(new OnRefreshListener<ListView>() {
@Override
public void onRefresh(PullToRefreshBase<ListView> refreshView) {
update();
}
});
requestManager = RestRequestManager.from(this);
}
// ...
void update() {
listView.setRefreshing();
Request updateRequest = new Request(RequestFactory.REQUEST_TWEETS);
updateRequest.put("screen_name", "habrahabr");
requestManager.execute(updateRequest, requestListener);
}
RequestListener requestListener = new RequestListener() {
@Override
public void onRequestFinished(Request request, Bundle resultData) {
listView.onRefreshComplete();
}
void showError() {
listView.onRefreshComplete();
AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this);
builder.
setTitle(android.R.string.dialog_alert_title).
setMessage(getString(R.string.faled_to_load_data)).
create().
show();
}
@Override
public void onRequestDataError(Request request) {
showError();
}
@Override
public void onRequestCustomError(Request request, Bundle resultData) {
showError();
}
@Override
public void onRequestConnectionError(Request request, int statusCode) {
showError();
}
};
Все, прописываем Service и ContentProvider в манифест и запускаем приложение:
Ссылки
- Исходники на github: github.com/therussianphysicist/rest_a
- Исходники из книги O'Reilly «Programming Android», реализующие паттерн B: github.com/bmeike/ProgrammingAndroidExamples (проекты FinchFramework и FinchVideo)
Автор: therussianphysicist