Содержание

вторник, 29 апреля 2014 г.

Android tricks

Последние полтора года я в основном программировал под Android, разработка под который хоть и включает в себя программирование на языке с синтаксисом Java 6, но все же runtime окружение значительно отличается от того к чему привыкли Java разработчики. Несмотря на это, я все таки решил написать здесь топик о некоторых вещах в Android, которые мне показались интересны, хотя они немного и отличаются от тематики данного блога.
  1. Созданние Bitmap в off-heap 
  2. Эффективная работа с базой данных
  3. GC и плавность UI (fluidity)
  4. Thread priority 
  5. Shared GLContext
  6. Wifi and DNS 
  7. Unsafe и MemoryFile

1. Созданние Bitmap в off-heap

Если вы пишете приложение, которые интенсивно работает с картинками, то вы, скорее всего, заметили как многочисленные GC негативно влияет на плавность UI вашего приложения. Дело в том, что при создании Bitmap, андроид выделяет массив байтов в heap и декодирует туда изображение. В блоге инженеров Facebook я вычитал, что возможно заставить андоид декодировать изображение в нативную память (читай секцию 'c' тут), используя флаг inPurgeable. Однако сколько я не бился, мне этот флаг декодировать файл в нативную память не позволял. На удивление даже всезнающий stackoverflow.com не помог. Зато, как всегда, на выручки пришли открытые исходники андроида. Оказывает, что данная возможность предусмотрена только для декодирования из файлового дескриптора, массива или Resources. Использовать нативную память для декодирования из файла или InputStream не получится. Ниже привожу кусочек рабочего кода с использованием библиотечных методов BitmapFactory, которые позволяют (или не позволяют, см. комментарии в коде) использовать нативную память. Чтобы быстро проверить, удалось ли вам достичь желаемого результат, достаточно в дебаге заглянуть в поле mBuffer, полученного экземпляра Bitmap.
File file = new File(Environment.getExternalStorageDirectory(), "Pictures/tema.jpg");

BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inPurgeable = true;
opts.inInputShareable = true;

// native memory allocation, hooray!
FileDescriptor fd = new FileInputStream(file).getFD();
Bitmap bitmapFromFD = BitmapFactory.decodeFileDescriptor(fd, null, opts);// native heap

// heap only allocation, sorry
Bitmap bitmapFromStream = BitmapFactory.decodeStream(new FileInputStream(file), null, opts);

// heap only allocation, sorry
Bitmap bitmapFromFile = BitmapFactory.decodeFile(file.getPath(), opts);

// native memory allocation, hooray!
byte[] byteArray = FileUtils.readFileToByteArray(file);
Bitmap bitmapFromBitArray = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.length, opts);

// native memory allocation, hooray!
Bitmap bitmapFromResource =  BitmapFactory.decodeResource(getResources(), R.raw.tema, opts);

2. Эффективная работа с базой данных

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

По-умолчанию, если один поток пишет в вашу базу данных, то читать из нее другой поток не сможет, даже если вы пытаетесь работать с разными таблицами. Не надо пытаться открывать несколько SQLiteDatabase к одной базе данных и городить что-то вроде пула соединений, к которым мы так привыкли при работе с классическими базами данных. Это не поможет. База данных надежна защищена от этого как на уровне библиотечных классов андродиа многочисленными java lock-ми, так и на уровне самой SQLite при помощи файловых блокировок. Однако если таблицы полностью независимы, то можно поместить их в разные базы данных (читай разные файлы), тем самым достигнув полного параллелизма. Оверхеда в этом особо не будет, если вы не собираетесь создавать сотни разных баз данных.

Хотя если параллельного чтения и записи очень хочется, то разработчики SQLite, на самом деле, об этом позаботились с помощью так называемой Write-Ahead Logging опции. В андроде она включается методом android.database.sqlite.SQLiteOpenHelper#setWriteAheadLoggingEnabled. Данный подход уже позволяет параллельно писать и читать из базы данных. Библиотечные классы анродида при выставленной опции создают пул соединений android.database.sqlite.SQLiteConnectionPool внутри android.database.sqlite.SQLiteSession, что позволяет поднять параллелизм операций. Стратегия локов для write-ahead существенно отличается от поведения по умолчанию.

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

Для ускорения записи может быть использована sqlite pragma synchronous. Включить ее можно с помощью метода android.database.sqlite.SQLiteDatabase#execSQL("PRAGMA synchronous = 0;"). После этого ваши записи в БД будут значительно быстрее чтения, так как последние будет требовать доступ к диску, а первые уже нет. Для меня, как разработчика бэкенда, такая опция по-началу звучала дико, практика же показала, что ничего страшного нет. За два года, мы не видели ни одной жалобы от пользователей, что база данных оказалась в испорченном состоянии. Зато ускорение записи батчей было на лицо. Дело в том, что даже если приложение закрашится (crashed), то базе данных от этого ничего страшного не будет, так как последние записи уже будут в буффере операционной системы и с успехом допишутся на диск даже при краше приложения. Проблемы могут быть только в случае краша самаого андроида, что может случиться намного реже. В этом случае пользователю придется пойти в настройки и очистить данные приложения. В нашем случае это было адекватно, так как данные все хранились в облаке и могли в любой момент считаться оттуда.

3. GC и плавность UI (fluidity)

Чтобы анимация UI компонентов выглядела гладко без дерганий, обновление картинки должно происходить постоянное количество раз в секунду (FPS). Андроид поддерживает 60 FPS. Т.е. на одно обновление экрана дается 16.(6) мс. Если случается затяжная пауза GC, то андроиду может не хватить времени отобразить очередное обновление за оставшиеся от 16 мс время за вычетом паузы GC. Этот эффект можно очень легко воспроизвести, написав приложение, где на канвасе будет летать мячик от стенки до стенки, для чего в каждом вызове View.onDraw() нужно сдвигать координаты шарика на постоянное число. Потом создать Thread, который будет декодировать картинки в Bitmap, ну или просто создавать массивы в цикле и выбрасывать их. Благо heap в приложение для андроида весьма ограничен. Он зависит от версии и разных флагов, но для ориентира не может быть сильно больше 100MB, таким образом вряд ли вам удастся написать приложение с зависаниями GC больше, чем на секунду. Но как было показано выше, достаточно нерегулярных пауз GC порядка 10мс, чтобы пользователь видел дергания на экране при анимации или прокрутке компонентов на экране.

Есть классические рекомендации как избежать дерганий картинки: не блокировать (не обращаться к диску, не работать с базой данных, не работать с сокетами) в main thread и не создавать новых объектов в методе View.onDraw(), который вызывается 60 раз в секунду. На сколько я понимаю, в андроиде нет escape analysis, так что объекты на стеке вместо кучи создаваться не будут. Однако если у вас большое многофункциональное приложение с памятью в несоклько десятков MB, которое в фоне делает кучу всяких нужных вещей, как то, работает активно с базой данных, синхронизирует данные с сервером, создает бэкапы и отправляет их на сервер, кэширует данные, которые могут понадобиться пользователю, подгружает следующую страницу, пока пользователь работат с текущей, делает реконсилиацию состояния, сканирует MediaStore и тому подобное, тогда только этими приемами не обойтись.

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

4. Thread priority

Если ваше приложение на адроиде чуточку сложнее, чем HelloWorld, то скорее всего вы используете несколько потоков. Кроме основного потока (main thread или UI thread), есть еще GL thread, Binder Thread, AsyncTask, так же, возможно, вы создаете еще свои пулы потоков с помощью Executors или напрямую через класс Thread. Сколько какому потоку дать времени CPU решает scheduler. Кроме приоритета потока, который вы можете задать через Thread#setPriority или Process#setThreadPriority, в андроиде очень большую роль играет schedule policy. Она бывает BG (background) и FG (foreground). Если у потока FG policy, то ему разрешено занимать до 95% CPU. Потокам же с BG policy остается довольствоваться только тем процентом CPU, который остался от FG потоков.

Когда ваше приложение находиться в foreground (пользователь работает с ним непосредственно), то вы можете управлять schedule policy, изменяя приоритет потока методом Thread#setPriority. Все потоки с приоритетом ниже выставленного по-умолчанию (от Thread.MIN_PRIORITY до Thread.NORM_PRIORITY - 1 включительно) буду иметь BG policy. Если вы хотите какому-то потоку назначить приоритет ниже, чем main thread, но все же хотите, чтобы он обладал FG policy для scheduler, то это можно сделать с помощью метода android.os.Process#setThreadPriority, выставив приоритет 9. Этот метод работает в других единицах нежели Thread#setPriority. Ниже я приведу таблицу соответствия.

Так же хочу заметить, что когда пользователь выходит из вашего приложения, то всем потокам назначается BG policy (за исключением Binder потоков), вне зависимости от их приоритета.

Какой приоритет и какая scheduling policy у каждого потока можно посмотреть в рантайме с помощью команды:
adb shell ps -p -t -P <PID>
где -p показывает приоритет, -t показывает потоки и -P показывает полиси.

Изменить приоритет конкретного потока можно командой:
adb shell renice 10 -p <TPID>

И обещанная таблица соответствий:

Java* Linux** Nice*** PCY
Thread.MIN_PRIORITY(1) 39 19 bg
2 36 16 bg
3 33 13 bg
4 30 10 bg
-- -- -- *
N/A 29 9 fg
Thread.NORM_PRIORITY(5) 20 0 fg
6 18 -2 fg
7 16 -4 fg
8 15 -5 fg
9 14 -6 fg
Thread.MAX_PRIORITY(10) 12 -8 fg
N/A (GL updater) 10 -10 fg
N/A 0 -20 fg
  • * java.lang.Thread#setPriority
  • ** Linux priority 
  • *** android.os.Process#setThreadPriority or 'adb shell renice'

5. GLContext factory and shared context

Если вы напрямую работаете с OpenGL с помощью android.opengl.GLSurfaceView, а также активно используете textures, то скорее всего у вас есть кэш textures, который хотелось бы сохранить при переходе между различными Activity в вашем приложении, либо между сессиями пользователя, когда он закрывает ваше приложение, а потом возвращается к нему.

Внимательно почитав документацию, можно найти метод android.opengl.GLSurfaceView#setEGLContextFactory, который позволяет вам переопределить android.opengl.GLSurfaceView.EGLContextFactory и управлять жизненным циклом EGLContext, который и позволит вам сохранить ваши textures. Можно взять дефолтную реализацию из сорсов android.opengl.GLSurfaceView.DefaultContextFactory и подправить его, чтобы EGLContext не пересоздавался.

Если же вы хотите пойти дальше и использовать одни и те же textures из разных Activity, и вообще иметь возможность работать с ними в произвольном потоке (например иметь возможность почистить кэш вне GL потока), то вам может помочь так называемый shared context. Каждый поток, который работает с GL должен иметь GL context привязанный к поток (bind), а контексты, которые созданы на основе одного shared context, могут работать с одними и теми же textures. Правда реализовать это уже немного сложнее. Самая большая сложность для меня было создать некую фейковую surface, которую требует метод egl.eglMakeCurrent, чтобы привязать контекст к произвольному потоку. В конце-концов мне удалось сделать это сделать для всех платформ начиная с 2.3. Приведу несколько кусочков кода с комментариями (многочисленные обработки ошибок опущены).
// Создаем sharedContext на основе display и mEglConfig, полученные через вызовы GLSurfaceView.EGLContextFactory
// Я его создавал в методе android.opengl.GLSurfaceView.EGLContextFactory#createContext и оставлял ссылку на него в классе
EGLContext sharedContext = egl.eglCreateContext(display, mEglConfig, EGL10.EGL_NO_CONTEXT, ATTRIB_LIST);

// Создание display, для использования позже 
EGL10 egl = (EGL10) EGLContext.getEGL();
EGLDisplay display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY);

//create EGL context based on shared one
EGLContext context = egl.eglCreateContext(egl.eglGetDisplay(display), mEglConfig, sharedContext, ATTRIB_LIST);

//create fake EGL surface. It is needed to make a context current for the current thread
int[] PIXEL_BUFFER_SURFACE_ATTR_LIST = {EGL10.EGL_HEIGHT, 1, EGL10.EGL_WIDTH, 1, EGL10.EGL_NONE};
egl.eglInitialize(eglDisplay, new int[2])
EGLSurface eglSurface = egl.eglCreatePbufferSurface(display, mEglConfig, PIXEL_BUFFER_SURFACE_ATTR_LIST);

//make the context current for the current thread
egl.eglMakeCurrent(getEglDisplay(), surface, surface, context)

6. Wifi and DNS

Когда вам надо писать сетевое взаимодействие при разработке под андроид, это может существенно отличаться от того, к чему мы привыкли разрабатывая серверные приложения на java. Даже если вы пользуетесь теми же самыми библиотеками apache httpclient, есть несколько существенных отличий. Одно из них это интернет через перегруженную точку доступа wifi вместо надежного, широкополосного, кабельного ethernet внутри датацентра.

У нас несколько внутренних пользователей жаловались на непомерные сетевые лаги, когда работали с нашим клиентским приложением. Я долго не мог понять в чем дело, пока не подключился к wifi точке доступа Guest. И тут я увидел, на что похожа работа нашего приложения в таких публичных перегруженных сетях. Запустив несколько тестов на загрузку ресурсов, я обнаружил что из 40 запросов, 15 выполнились за 100 миллисекунд, 10 выполнились за 5 секунд и 15 окончились таймаутом за 15-20 секунд. В логах я видел множество "java.net.UnknownHostException : Unable to resolve host". Сняв несколько tcp дампов с помощью:

tcpdump -s 0 -v -w out.pcap

И проанализировав его с помощью Wireshark (предварительно отфильтровав по портам своего приложения, найдя их командой 'netstat -tp'), я обнаружил, что ответы на UDP пакеты на 8.8.8.8 (это DNS протокол на публичиный DNS сервер гугла) иногда возвращаются с через 5 секунда, а иногда вообще ничего не получают обратно. Что, в общем, ожидаемо для UDP пакетов, которые так любят выкидывать всякие промежуточные роутеры.

Конечно, сразу же встает вопрос, почему мы так часто резолвим адреса. Дело в том, что мы работаем с S3 и там ttl для DNS записи всего 60 секунд. Если вы заглянете в DNS записи каких-нибудь гуглоплюс фоток, то там тоже увидете ttl в 300 секунд. Зачем, почему и можно ли без этого - это уже совсем другая тема. Здесь же я пишу, что может сделать разработчик клиентского приложения, столкнувшись с этой проблемой.

В исходниках андроидского java.net.InetAddress можно найти поле addressCache с комментарием: "Our Java-side DNS cache". В бородатой 2.3.6 версии андроида, этот кэш хранил значение 10 минут. После многочисленных багов и жалоб, а так же, я думаю, улучшения сетевой DNS библиотеки на андроиде, в последних версиях этот кэш хранит значение всего 2 секунды.

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

Вкорячить описанную идею, можно довольно просто, если вы пользуетесь напряму HttpClient и ThreadSafeClientConnManager или их производными. Для этого нужно переопределить метод ThreadSafeClientConnManager#createConnectionOperator(SchemeRegistry) и вернуть расширенный класс DefaultClientConnectionOperator, в котором в методе #openConnection и реализовать описанную идею.
ClientConnectionManager connectionManager = new ThreadSafeClientConnManager(params, schemeRegistry){
            @Override
            protected ClientConnectionOperator createConnectionOperator(SchemeRegistry schemeRegistry) {
                return new DefaultClientConnectionOperator(schemeRegistry){
                    @Override
                    public void openConnection(OperatedClientConnection conn, HttpHost target, InetAddress local, HttpContext context, HttpParams params) throws IOException {
                        //TODO: copy the code from parent and replace InetAddress.getAllByName() w/ async caching login
                    }
                };
            }
};

7. Unsafe и MemoryFile

Ну и какая же статья в этом блоге без доступа к памяти вне java heap. К моему удивлению, на андроиде по уже наезженной дорожке рефлекшена можно легко получить доступ к sun.misc.Unsafe. Единственно, что в бородатых версия андроида надо будет доступаться к полю THE_ONE. Но в последних версиях, они добавили уже знакомое поле theUnsafe, так что можно использовать код, работающий с Unsafe, который был написан для openjdk.

Еще в андроиде придумали классную штуку под названием ashmem. Эта такая область вне heap, которая может сама почиститься (если вы явно разрешили это делать), когда андроид начнет испытывать недостаток памяти. Самое приятное, что для записи и чтения туда не надо плясать с бубном, так как есть библиотечный класс MemoryFile.