Введение
Несмотря на обилие социальных сетей, за последние несколько лет появился целый ряд новых, оригинальных и необычных социальных приложений, таких как
just yo,
snapchat,
secret, и пр. Успех приложения
just yo, ограниченного единственной функцией – отправкой сообщения фиксированного содержания, меня заинтересовал и мы с друзьями тоже решили попробовать написать очередную социальную сеть на Android. Нашей целью было очертить круг задач общий для большинства подобных приложений, предложить их решения и подготовить скелет, из которого каждый сможет сделать что-то своё и оригинальное, не тратя время на решение рутинных вопросов. С результатами работы можно сразу ознакомиться на
github –
android клиент и
сервер на ruby on rails.
Содержание
Концепция и функционал
Интерфейс
Ускорение загрузки фотографий
Граф друзей
Сетевые запросы
Авторизация
SQLite для БД контактов, изображений
Продолжение следует
Концепция и функционал
Для начала мы определились с концепцией нового сервиса и выбрали модель – гибрид
instagram и
whatsapp. От инстаграма мы взяли главный сценарий использования – загрузку фотографий в ленту друзей с комментариями и лайками. А от вотсапа принцип организации графа друзей – через записную книжку по телефонным номерам друзей в автоматическом режиме.
Особенности выбранной модели
Выбранная модель графа подразумевает среднюю скорость роста сети, т.к. каждый новый пользователь может вовлечь в приложение максимум несколько десятков/сотен человек из записной книжки. С другой стороны, такой подходит исключает необходимость модерации и фильтрации контента на начальном этапе развития, т.к. доступность контента определяется личными контактами пользователя и предотвращает массовые спам-рассылки и прочие пагубные явления. В итоге имеем простой сервис, готовый для использования в кругу друзей и не требующий больших затрат на операционную поддержку с технической и организационной точки зрения.
Далее мы очертили функциональный минимум нашего приложения:
- Загрузка фотографий и их просмотр в галерее
- Управление списком друзей на основе записной книжки
- Лента фотографий, загруженных друзьями
Интерфейс
Очертив круг базовых функций, мы перешли к дизайну интерфейса. Каждая функция хорошо ложится на отдельный фрагмент и равновероятность их использования подсказывает использование горизонтального
swipe-перехода с помощью
ViewPager (Туториалы по swipe и ViewPager
туториал 1 туториал 2). На первом этапе у нас получилась следующая диаграмма переходов:
Рис. 1. Диаграмма переходов
Рассмотрим каждый из фрагментов более детально.
Контакт лист
Рис. 2. Контакт лист - wireframe и скриншот
Контакт лист отображает список друзей по записной книжке, зарегистрированных в сервисе, позволяет подписаться, а также открывает подробный профиль пользователя при нажатии. Он реализован обычным
ListView (
туториал).
Галерея
Рис. 3. Галерея - wireframe и скриншот
Галерея отображает локальные и загруженные на сервер фотографии пользователя с кратким описанием в заголовке.
Т.к. размер фотографий с учётом заголовка и соотношение сторон может различаться, мы решили использовать асимметричный
GridView от
Etsy AndroidStaggeredGrid. Алгоритм позиционирования отображений в таком случае требует особого подхода, в частности в
AndroidStaggeredGrid вместо
ImageView используется
DynamicHeightImageView с заранее предопределяемым соотношением. В результате получается довольно красивая и плавно прокручиваемая плиточная галерея.
Лента
Лента отображает фотографии, загруженные друзьями, на которых подписан пользователь. Здесь мы также применили обычный
ListView, т.к. каждая фотография может занимать большую часть экрана для удобства просмотра и масштабирование может проходить по ширине экрана. При нажатии на изображение открывается подробное описание фотографии с комментариями.
Рис. 4. Лента - wireframe и скриншот
Подробное описание фотографии
Подробное описание фотографии содержит метаданные выбранного изображения (имя автора, описание, кол-во лайков и т.д.), а также список комментариев.
Рис. 5. Описание фотографии - wireframe и скриншот
Профиль пользователя
Профиль пользователя содержит метаданные пользователя и галерею загруженных фотографий пользователя.
Рис. 6. Профиль пользователя - wireframe и скриншот
Ускорение загрузки фотографий
L1/L2 кэш
Подгрузка фотографий является ключевым и достаточно ресурсоёмким процессом. Фотографии загружаются как из локальной галереи на устройстве, так и с удаленного хранилища. Скорость подгрузки влияет на плавность прокрутки галереи и общее удобство интерфейса, поэтому мы решили использовать двухуровневый кэш – L1 cache в оперативной памяти и L2 cache на дисковом носителе устройства.
В качестве дискового L2 кэша мы выбрали популярный плагин от
Jake Wharton, он поддерживает журналирование и предоставляет удобную обёртку над стандартным
DiskLruCache из
Андроид SDK. L1 cache реализован стандартным андроидовским
LruCache (см.
com.freecoders.photobook.utils.DiskLruBitmapCache и
com.freecoders.photobook.utils.MemoryLruCache).
Упреждающее масштабирование
В случае с лентой новостей возможен вариант, когда лента уже загружена, а фотографии продолжают загружаться с удаленного хранилища. Тогда при прокрутке возможен
скачкообразный эффект при завершении подгрузки, если пользователь уже прокрутил список вниз. Чтобы его избежать мы применили
упреждающее масштабирование отображений в ленте – т.е. размеры рамки для фотографии высчитываются на основе соотношения сторон и задаются еще до того, как фотография загрузилась с сервера. Таким образом позиция элементов в списке ListView не изменяется после подгрузки новых фотографий.
Код 1. Пример упреждающего масштабирования в DynamicHeightImageView
class FeedAdapter
public View getView(int position, View convertView, ViewGroup parent) {
...
holder.imgView.setHeightRatio(feedEntry.image.ratio);
holder.imgView.setTag(pos);
mImageLoader.get(feedEntry.image.url_medium, new ImageListener(pos, holder.imgView, null));
...
}
Даунсэмплинг
Кроме того, в Android существуют ограничения на максимальный размер и разрешение фотографии, отображаемой на экране. Это предусмотрено для предотвращения переполнения памяти. Поэтому перед загрузкой bitmap необходимо произвести
downsampling.
Код 2. Пример даунсэмплинга изображений
class ImageUtils
public static int calculateInSampleSize(BitmapFactory.Options options,
int reqWidth, int reqHeight) {
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
final int halfHeight = height / 2;
final int halfWidth = width / 2;
while (((halfHeight / inSampleSize) > reqHeight)
|| ((halfWidth / inSampleSize) > reqWidth)) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
public static Bitmap decodeSampledBitmap(String imgPath, int reqWidth, int reqHeight) {
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(imgPath, options);
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
options.inJustDecodeBounds = false;
return BitmapFactory.decodeFile(imgPath, options);
}
Миниатюры
Также если media scanner успел обработать все фотографии, то для них уже могут быть созданы миниатюры в памяти устройства. Это правило выполняется не всегда, но если миниатюра есть, то это значительно ускоряет процесс загрузки и позволяет избежать downsampling.
Код 3. Пример загрузки миниатюр из MediaStore
class ImagesDataSource
public String getThumbURI(String strMediaStoreID) {
ContentResolver cr = mContext.getContentResolver();
String strThumbUri = "";
Cursor cursorThumb = cr.query(MediaStore.Images.Thumbnails.EXTERNAL_CONTENT_URI,
new String[]{MediaStore.Images.Thumbnails.DATA},
MediaStore.Images.Thumbnails.IMAGE_ID + "= ?",
new String[]{strMediaStoreID}, null);
if( cursorThumb != null && cursorThumb.getCount() > 0 ) {
cursorThumb.moveToFirst();
strThumbUri = cursorThumb.getString(
cursorThumb.getColumnIndex( MediaStore.Images.Thumbnails.DATA ));
}
cursorThumb.close();
return strThumbUri;
}
Граф друзей
Следующим шагом, определяющим интерфейс и логику сервиса была детализация графа друзей. Мы рассматривали два подхода – принцип друзей (как вконтакте) и принцип подписчиков (как в твиттере). Принцип друзей подразумевает взаимное согласие на доступ к галерее и личному профилю, а также требует подтверждения знакомства с противоположной стороны. В случае с подписчиками не требуется запрашивать подтверждение с противоположной стороны и позволяет каждой из сторон независимо определять источники наполнения своей фотоленты.
Данный выбор подразумевает направленный граф, который будет реализован в виде строк (ID подписчика, ID автора) реляционной БД на сервере.
Сетевые запросы
Все сетевые запросы выполняются асинхронно, а результат операции с помощью
callback-интерфейса передаются в запрашивающий модуль. В 2013 году Google представила собственный плагин
Volley как замену
Apache HTTPClient. Её преимуществами является поддержка
очереди запросов,
приоритизация, стандартные
обёртки для string и json запросов,
keepalive,
повторная отправка при неудаче, и пр. Мы решили использовать её в качестве основы для большинства сетевых запросов.
Что не понравилось в Volley
Забегая вперед скажу, что Volley действительно упрощает разработку сетевых интерфейсов по сравнению с HTTPClient, но на момент разработки стандартные обёртки для String и Json запросов от Volley еще были довольно сырыми, например не позволяли настроить ContentType или HttpHeaders, отсутствовала поддержка MultiPart запросов, поэтому нам пришлось их немного переписать (см. com.freecoders.photobook.network.MultiPartRequest и com.freecoders.photobook.network.StringRequest)
Код 4. Пример сетевого запроса (Запрос профиля пользователя)
class ServerInterface
public static final void getUserProfileRequest (Context context, String[] userIds,
final Response.Listener<HashMap<String, UserProfile>> responseListener,
final Response.ErrorListener errorListener) {
HashMap<String, String> headers = makeHTTPHeaders();
String strIdHeader = userIds.length > 0 ? userIds[0] : "";
for (int i = 1; i < userIds.length; i++) strIdHeader = strIdHeader + "," + userIds[i];
headers.put(Constants.KEY_ID, strIdHeader);
Log.d(LOG_TAG, "Get user profile request");
StringRequest request = new StringRequest(Request.Method.GET,
Constants.SERVER_URL + Constants.SERVER_PATH_USER, "", headers,
new Response.Listener<String>() {
@Override
public void onResponse(String response) {
Log.d(LOG_TAG, response);
Type type = new TypeToken<ServerResponse
<HashMap<String, UserProfile>>>(){}.getType();
try {
ServerResponse<HashMap<String, UserProfile>> res =
gson.fromJson(response, type);
if (res != null && res.isSuccess() && res.data != null
&& responseListener != null)
responseListener.onResponse(res.data);
else if (responseListener != null)
responseListener.onResponse(new HashMap<String, UserProfile>());
} catch (Exception e) {
if (responseListener != null)
responseListener.onResponse(new HashMap<String, UserProfile>());
}
}
}, new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
if ((error != null) && (error.networkResponse != null)
&& (error.networkResponse.data != null))
Log.d(LOG_TAG, "Error: " +
new String(error.networkResponse.data));
if (errorListener != null) errorListener.onErrorResponse(error);
}
}
);
VolleySingleton.getInstance(context).addToRequestQueue(request);
}
Код 5. VolleySingleton
class VolleySingleton
public class VolleySingleton {
...
public <T> void addToRequestQueue(Request<T> req) {
int socketTimeout = 90000;
RetryPolicy policy = new DefaultRetryPolicy(socketTimeout,
DefaultRetryPolicy.DEFAULT_MAX_RETRIES,
DefaultRetryPolicy.DEFAULT_BACKOFF_MULT);
req.setRetryPolicy(policy);
getRequestQueue().add(req);
}
...
}
Авторизация
Для авторизации мы решили использовать пару
публичный/приватный id. При регистрации клиенту высылается эта пара, причём приватный id доступен только этому пользователю и отправляется в заголовке HTTP с каждым запросом к серверу. А публичный id доступен всем пользователям и используется другими клиентами при запросах на добавление в друзья или просмотре профиля.
SQLite для БД контактов, изображений
Локальная БД клиента содержит необходимый минимум информации для облегчения нагрузки на сервер:
- Список друзей
- Список загруженных фотографий
Список друзей из адресной книги, зарегистрированных в сервисе, их метаданные (аватар, имя и пр.) запрашивается на сервере при каждом запуске приложения и сохраняется в локальной БД для наполения первого фрагмента (список контактов). Список загруженных фотографий содержит метаданные фотографий, загруженных на сервер. Оба списка синхронизируются с сервером на случай переустановки приложения.
Код 6. Пример работы в SQLite
class FriendsDataSource
public class FriendsDataSource {
private SQLiteDatabase database;
private SQLiteHelper dbHelper;
private String[] allColumns = { SQLiteHelper.COLUMN_ID,SQLiteHelper.COLUMN_NAME,
SQLiteHelper.COLUMN_CONTACT_KEY, SQLiteHelper.COLUMN_USER_ID,
SQLiteHelper.COLUMN_AVATAR, SQLiteHelper.COLUMN_STATUS};
...
public FriendEntry createFriend(String Name, String ContactKey, String UserId,
String Avatar, int Status) {
//Add new FriendEntry
ContentValues cv = new ContentValues();
cv.put(dbHelper.COLUMN_CONTACT_KEY,ContactKey);
cv.put(dbHelper.COLUMN_NAME,Name);
cv.put(dbHelper.COLUMN_USER_ID,UserId);
cv.put(dbHelper.COLUMN_AVATAR,Avatar);
cv.put(dbHelper.COLUMN_STATUS,Status);
cv.put(dbHelper.COLUMN_TYPE,FriendEntry.INT_TYPE_PERSON);
database.insert(dbHelper.TABLE_FRIENDS, null, cv);
return null;
}
public ArrayList<FriendEntry> getFriendsByStatus(int StatusSet[]) {
String selection = dbHelper.COLUMN_STATUS + " IN (?";
String values[] = new String[StatusSet.length];
values[0] = String.valueOf(StatusSet[0]);
for (int i = 1; i < StatusSet.length; i++) {
selection = selection + ",?";
values[i] = String.valueOf(StatusSet[i]);
}
selection = selection + ") ";
selection = selection + " AND "
+ SQLiteHelper.COLUMN_TYPE + " = " + FriendEntry.INT_TYPE_PERSON;
String orderBy = SQLiteHelper.COLUMN_NAME + " ASC";
Cursor cursor = database.query(dbHelper.TABLE_FRIENDS,
null, selection, values, null, null, orderBy);
ArrayList<FriendEntry> listFriends = new ArrayList<FriendEntry>();
if (cursor == null) {
return listFriends;
} else if (!cursor.moveToFirst()) {
cursor.close();
return listFriends;
}
do{
listFriends.add(cursorToFriendEntry(cursor));
}while (cursor.moveToNext());
cursor.close();
return listFriends;
}
private FriendEntry cursorToFriendEntry(Cursor cursor) {
FriendEntry friend = new FriendEntry();
friend.setId(cursor.getInt(idColIndex));
friend.setName(cursor.getString(nameColIndex));
friend.setUserId(cursor.getString(userIdColIndex));
friend.setAvatar(cursor.getString(avatarColIndex));
friend.setStatus(cursor.getInt(statusColIndex));
friend.setType(cursor.getInt(typeColIndex));
friend.setContactKey(cursor.getString(ContactKeyColIndex));
return friend;
}
...
}
Продолжение следует
В этой части статьи мы попытались рассмотреть только основные вопросы и проблемы, с которыми мы столкнулись при разработке Android-клиента. Проект разрабатывался в стиле хакатона выходного дня и без каких-либо коммерческих целей, поэтому мы не претендуем на оригинальность подходов, не можем похвастаться целостной стилистикой кода. Если у вас есть другие советы, решения или идеи по разработке мобильных социальных приложений, то будем рады услышать их в комментариях. Также если вам понравилось или пригодилось наше пособие, то можете свободно использовать его в своих проектах, улучшать или даже присылать pull-request'ы, за что будем особенно благодарны.
Во
второй части мы рассмотрим более подробно серверную часть сервиса, особенности загрузки изображений на облачное хранилище AWS S3, постобработки изображений, доставки Push-уведомлений, и пр.
Всем хороших выходных и до новых встреч!
This entry passed through the Full-Text RSS service - if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.