Введение


Несмотря на обилие социальных сетей, за последние несколько лет появился целый ряд новых, оригинальных и необычных социальных приложений, таких как just yo, snapchat, secret, и пр. Успех приложения just yo, ограниченного единственной функцией – отправкой сообщения фиксированного содержания, меня заинтересовал и мы с друзьями тоже решили попробовать написать очередную социальную сеть на Android. Нашей целью было очертить круг задач общий для большинства подобных приложений, предложить их решения и подготовить скелет, из которого каждый сможет сделать что-то своё и оригинальное, не тратя время на решение рутинных вопросов. С результатами работы можно сразу ознакомиться на githubandroid клиент и сервер на ruby on rails.

Содержание


Концепция и функционал
Интерфейс
Ускорение загрузки фотографий
Граф друзей
Сетевые запросы
Авторизация
SQLite для БД контактов, изображений
Продолжение следует


Концепция и функционал


Для начала мы определились с концепцией нового сервиса и выбрали модель – гибрид instagram и whatsapp. От инстаграма мы взяли главный сценарий использования – загрузку фотографий в ленту друзей с комментариями и лайками. А от вотсапа принцип организации графа друзей – через записную книжку по телефонным номерам друзей в автоматическом режиме.
Особенности выбранной модели
Выбранная модель графа подразумевает среднюю скорость роста сети, т.к. каждый новый пользователь может вовлечь в приложение максимум несколько десятков/сотен человек из записной книжки. С другой стороны, такой подходит исключает необходимость модерации и фильтрации контента на начальном этапе развития, т.к. доступность контента определяется личными контактами пользователя и предотвращает массовые спам-рассылки и прочие пагубные явления. В итоге имеем простой сервис, готовый для использования в кругу друзей и не требующий больших затрат на операционную поддержку с технической и организационной точки зрения.

Далее мы очертили функциональный минимум нашего приложения:
  • Загрузка фотографий и их просмотр в галерее
  • Управление списком друзей на основе записной книжки
  • Лента фотографий, загруженных друзьями


Интерфейс


Очертив круг базовых функций, мы перешли к дизайну интерфейса. Каждая функция хорошо ложится на отдельный фрагмент и равновероятность их использования подсказывает использование горизонтального swipe-перехода с помощью ViewPager (Туториалы по swipe и ViewPager туториал 1 туториал 2). На первом этапе у нас получилась следующая диаграмма переходов:
Рис. 1. Диаграмма переходов
image

Рассмотрим каждый из фрагментов более детально.

Контакт лист

Рис. 2. Контакт лист - wireframe и скриншот
image

Контакт лист отображает список друзей по записной книжке, зарегистрированных в сервисе, позволяет подписаться, а также открывает подробный профиль пользователя при нажатии. Он реализован обычным ListView (туториал).

Галерея

Рис. 3. Галерея - wireframe и скриншот
image

Галерея отображает локальные и загруженные на сервер фотографии пользователя с кратким описанием в заголовке.
Т.к. размер фотографий с учётом заголовка и соотношение сторон может различаться, мы решили использовать асимметричный GridView от Etsy AndroidStaggeredGrid. Алгоритм позиционирования отображений в таком случае требует особого подхода, в частности в AndroidStaggeredGrid вместо ImageView используется DynamicHeightImageView с заранее предопределяемым соотношением. В результате получается довольно красивая и плавно прокручиваемая плиточная галерея.

Лента

Лента отображает фотографии, загруженные друзьями, на которых подписан пользователь. Здесь мы также применили обычный ListView, т.к. каждая фотография может занимать большую часть экрана для удобства просмотра и масштабирование может проходить по ширине экрана. При нажатии на изображение открывается подробное описание фотографии с комментариями.

Рис. 4. Лента - wireframe и скриншот
image

Подробное описание фотографии

Подробное описание фотографии содержит метаданные выбранного изображения (имя автора, описание, кол-во лайков и т.д.), а также список комментариев.
Рис. 5. Описание фотографии - wireframe и скриншот
image

Профиль пользователя

Профиль пользователя содержит метаданные пользователя и галерею загруженных фотографий пользователя.
Рис. 6. Профиль пользователя - wireframe и скриншот
image


Ускорение загрузки фотографий


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 автора) реляционной БД на сервере.
image


Сетевые запросы


Все сетевые запросы выполняются асинхронно, а результат операции с помощью 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-уведомлений, и пр.

Всем хороших выходных и до новых встреч!

Продолжение — Часть II (Сервер)

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


  1. MpaK999
    16.05.2015 20:58
    -2

    серверный код просто ужасен, нет тестов, не свежий Rails 4.1.1 и ужасно пахнет (константы в моделях, хотя можно enum, as_jsonn с двумя nn) все от моделей до контроллеров, не надо так!


    1. izac
      16.05.2015 22:18
      +3

      pull request не кто не отменял. Тем более ребята старались для общего блага. Да и в такой короткий срок как выходные.


  1. anton9088
    17.05.2015 01:33
    +3

    Можно было сделать еще быстрее, если:
    1) для отображения картинок использовать Picasso или UniversalImageLoader
    2) для работы с базой использовать какой-нибудь ORM, например ActiveAndroid
    3) для формирования и выполнения http запросов использовать Retrofit

    Еще я бы не называл Volley заменой HttpClient'a, т.к. они выполняют разные задачи


    1. Dimezis
      17.05.2015 11:30
      +2

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


      1. SunSunSun
        17.05.2015 16:51

        Это же круто что ребята сделали велосипед! Для себя старались и теперь знают как сделать велосипед получше.


  1. jaleel
    17.05.2015 19:23

    Что-то судя по коммитам клиент явно не за несколько выходных писался.
    Немного комментариев:

    • Как уже сказали ORM для работы с базой и для картинок можно было Picasso. И тут вопрос — Volley же вроде умеет картинки грузить насколько я помню? В адаптере AsyncTask как то не очень :)
    • В ноябре можно было уже прочитать про RecyclerView + Staggered Grid вместо AndroidStaggeredGrid.
    • Опять же, во всю уже Material, можно было хотя бы whiteframe из гайдов использовать.
    • Ну и опять про актуальность — метод setNavigationMode(ActionBar.NAVIGATION_MODE_TABS) deprecated. Поэтому лучше было использовать SlidingTabLayout с ViewPager'ом.


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