Недавно наша команда завершила разработку приложения под Android. В процессе разработки и затем поддержки мы столкнулись с некоторыми проблемами технического характера. Часть из них — это наши баги, которых мы могли бы избежать, другая часть — это совсем неочевидные особенности Android, которые либо плохо описаны в документации, либо не описаны вообще.
В этой статье я бы хотел рассмотреть несколько реальных багов, которые возникли у наших пользователей и рассказать о путях их решения.
Статья не претендует на подробный анализ потенциальных проблем, это просто рассказ из жизни одно реального Android приложения.
RTFM (http://en.wikipedia.org/wiki/RTFM)
Так как при разработке под Android надо иметь в виду, что ваше приложение может работать на огромном количестве различных устройств, то надо думать о проблемах их совместимости.
Вот например одна из ошибок, которая возникала у наших пользователей:
java.lang.NoClassDefFoundError: android.util.Patterns
А причина — проста, согласно документации класс android.util.Patterns
доступен начиная с версии API 8 (Android 2.2.x), а у пользователя была версия 2.1. Решили мы это конечно оберткой этого кода в try/catch
.
Вот еще одна подобная проблема вызванная невнимательным чтением документации:
android.os.NetworkOnMainThreadException
at android.os.StrictMode$AndroidBlockGuardPolicy.onNetwork(StrictMode.java:1077)
at java.net.InetAddress.lookupHostByName(InetAddress.java:477)
at java.net.InetAddress.getAllByNameImpl(InetAddress.java:277)
at java.net.InetAddress.getAllByName(InetAddress.java:249)
Все дело в том, что strict mode (http://developer.android.com/reference/android/os/StrictMode.html) был включен по умолчанию в Android начиная с версии 3.0. Это значит, что ваше приложение не может обращаться к сети напрямую из основного UI потока, так как это может занимать некоторое время и при этом основной поток блокируется и не отвечает на другие события. Мы старались избегать подобного поведения, но в одном из мест все-таки остался простой сетевой вызов. Решена эта проблема была тем, что мы вынесли этот код в другой поток.
Поворот устройства — что может быть проще и привычнее для пользователя?
Казалось бы, для мобильных устройств смена ориентации экрана — это вещь настолько часто используемая и привычная, что это должно отразиться и в API. Т.е. эта ситуация должна обрабатываться очень просто. Но нет. Тут много нюансов.
Допустим, что нам необходимо сделать приложение, которое загружает список чего-либо и выводит его на экран. Т.е. при старте Activity
(в методе onCreate()
) мы запускаем поток (чтобы не блокировать UI поток), который будет загружать данные. Этот поток отрабатывает некоторое время, поэтому мы будет отображать процесс загрузки в ProgressDialog
. Все просто и замечательно работает.
Но, после загрузки, пользователь повернул устройство и тут мы обнаруживаем, что снова появился ProgressDialog
и мы опять загружаем наши данные. Но ведь ничего не изменилось? Просто человеку удобнее смотреть на этот список повернув устройство.
А все дело в том, что метод onCreate()
вызывается не только при создании Activity
, но и при повороте экрана! Но мы не хотим засталять пользователя снова ждать загрузки. Все что нам надо — это снова показать уже загруженные данные.
Если мы поищем в интернете, мы найдем много ссылок на описание этой проблемы и также огромное количество примеров как решить эту проблему. И что самое плохое, что большинство таких ссылок предлагает неверное решение. Вот пример этого — http://codesex.org/articles/33-android-rotate
Не делайте обработку поворота экрана через onConfigurationChanged()
! Это неверно! Официальная документация ясно утверждает что “Using this attribute should be avoided and used only as a last-resort.“ http://developer.android.com/guide/topics/manifest/activity-element.html#config
А вот правильный подход описан здесь — http://developer.android.com/guide/topics/resources/runtime-changes.html И как показывает практика — он не прост в имплементации.
Идея в том, что перед разворотом экрана Android вызовет метод onRetainNonConfigurationInstance()
вашего Activity
. И вы можете вернуть данные (например список загруженных объектов) из этого метода, которые и будут сохранены между 2 вызовами метода onCreate()
вашего Activity
. Затем при вызове onCreate()
вы можете вызвать getLastNonConfigurationInstance()
который и вернет вам сохраненные данные. Т.е. при создании Activity
вы вызываете getLastNonConfigurationInstance()
и если он вам вернул данные — то эти данные уже были загружены и надо только отобразить их. Если же не вернул данные — то запускаете загрузку.
Но вот на практике ситуация выглядит так. У нас может быть 2 варианта когда пользователь поворачивает устройство. Первый вариант — когда идет загрузка данных (работает наш поток который загружает данные и при этом отображается ProgressDialog
) или данные уже загружены и мы сохранили их в списке для отображения. Получается, что в первом случае мы при повороте должны сохранить ссылку на работающий поток, а во втором случае ссылку на уже загруженный список. Мы так и делаем.
Но это усложняет наш код и не кажется мне простым и интуитивным. Более того, когда идет смена ориентации экрана и мы сохранили поток загрузки данных, мы потом вынуждены при onCreate()
опять восстанавливать ProgressDialog
! А если добавить сюда, что в нашем приложении пользователь может загружать данные из разных мест и у нас не один поток загрузки данных, а несколько — то количество кода которое обслуживает простой поворот экрана становится просто огромным.
Честно сказать, я не понимаю почему это было сделано так сложно.
Чуть более подробнее о потоках или использование AsyncTask.
Давайте рассмотрим загрузку данных в другом потоке чуть более подробно, так как и при этом нас ждали неожиданные “сюрпризы”.
Немного теории: для облегчения создания и работы в потоке отличном от основного UI потока был сделан специальный класс — AsyncTask (http://developer.android.com/reference/android/os/AsyncTask.html)
Суть его в том, что в нем уже есть готовые методы onPreExecute()
и onPostExecute(Result)
, которые выполняются в основном UI потоке и которые служат для отображение чего-либо и есть метод doInBackground(Params...) внутри которого и происходит основная работа и он запускается в отдельном потоке автоматически. Вот примерный код как это выглядит:
private class MyTask extends AsyncTask<Void, Void, Void> {
private ProgressDialog spinner;
@Override
protected void onPreExecute() {
// Вначале мы покажем пользователю ProgressDialog
// чтобы он понимал что началась загрузка
// этот метод выполняется в UI потоке
spinner = new ProgressDialog(MyActivity.this);
spinner.setMessage("Идет загрузка...");
spinner.show();
}
@Override
protected Void doInBackground(Void... text) {
// тут мы делаем основную работу по загрузке данных
// этот метод выполяется в другом потоке
}
@Override
protected void onPostExecute(Void result) {
// Загрузка закончена. Закроем ProgressDialog.
// этот метод выполняется в UI потоке
spinner.dismiss();
}
}
Все просто и красиво.
Но теперь — немного практики. Вот Вам ошибка от реального пользователя:
android.view.WindowManager$BadTokenException: Unable to add window -- token android.os.BinderProxy@40515bd0 is not valid; is your activity running?
at android.view.ViewRoot.setView(ViewRoot.java:534)
at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:177)
at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:91)
at android.view.Window$LocalWindowManager.addView(Window.java:424)
at android.app.Dialog.show(Dialog.java:241)
at ru.reminded.android.social.SocialDataLoader.onPreExecute(SocialDataLoader.java:106)
at android.os.AsyncTask.execute(AsyncTask.java:391)
at ru.reminded.android.util.SocialAdapterUtils.loadAdapterData(SocialAdapterUtils.java:52)
at ru.reminded.android.util.SocialAdapterUtils.access$0(SocialAdapterUtils.java:50)
at ru.reminded.android.util.SocialAdapterUtils$1.onComplete(SocialAdapterUtils.java:41)
А эта ошибка означает, что когда мы вызываем spinner.show()
в методе onPreExecute()
, то этот ProgressDialog
созданный со ссылкой на MyActivity
уже неактивен и его нет на экране! Ладно, я еще понимаю как такое может быть при вызове onPostExecute()
. Т.е. например пока мы загружали данные пользователь нажал Back и наш ProgressDialog
ушел с экрана. Но как такое может быть сразу при вызове загрузки, когда этот код стартует сразу при затуске Activity
— это для меня неясно.
Но в любом случае, мы должны обрабатывать такие ситуации поэтому мы решили это обеткой методов spinner.show()
и spinner.dismiss()
в try/catch
. Решение конечно не очень красивое, но в нашем случае — вполне функциональное.
Кстати, такой же код есть и например в Facebook SDK for Android, который был разработан другими опытными разрабочиками. И там тоже у нас были ситуации когда приложение вылетало при закрытии ProgressDialog
. Нам пришлось добавить обработку и в их код. Так что проблема эта — не только в нас.
Добавьте сюда еще проблему, что была описана ранее. Что при повороте устройства надо пересоздавать ProgressDialog
если поворот был во время загрузки данных. Это тоже добавит сюда вспомогательного кода.
И еще стоит вспомнить что метод doInBackground()
выполняется в отдельном потоке и поэтому если при загрузке данных произошла ошибка, то нельзя выдать Alert прямо оттуда, так как это не UI поток. Надо сохранить ошибку, а затем после выхода из потока загрузки в методе onPostExecute(Void result)
уже можно что-то показать.
Т.е. опять много вспомогательного кода и не все так просто…
AlarmManager
А еще есть моменты которые вообще не описаны в документации. Например, мы в своем приложении используем AlarmManager (http://developer.android.com/reference/android/app/AlarmManager.html) который помогает нам выдавать сообщения пользователю через некоторое время когда само наше приложение уже закрыто.
Этот AlarmManager — штука очень полезная, вот только проблема в том, что иногда он “теряет” созданные в нем нотификации! Мы потратили кучу времени чтобы понять как и почему это происходит, перерыли всю документацию и ничего не нашли. Совершенно случайно мы “набрели” на это обсуждение — http://stackoverflow.com/questions/9101818/how-to-create-a-persistent-alarmmanager.
Оказывается, что если приложение падает, или пользователь сам убивает приложение через task manager (что возможно и совершенно обычно), то ВСЕ нотификации для этого приложения в AlarmManager УДАЛЯЮТСЯ! Вот это — сюрприз! Особенно, мне понравился там один из комментариев: “Re Alarm canceled: Thanks for the clarification. I asked this on the Android team office hours g+ hangout and they even seemed confused about this behavior. Is it documented anywhere?”
Так что теперь при старте приложения мы вынуждены пересоздавать настроенные нотификации, так как даже нет API в AlarmManager чтобы проверить есть ли такие нотификации или нет.
Бывает и такое...
Вот вам еще несколько ошибок которые мы зарегистрировали у наших пользователей. В своем приложении мы используем OAuth идентификацию для различных социальных сетей и поэтому вынуждены запускать штатный браузер в Android (через который и должен работать OAuth). При этом, он периодически “падает”
android.util.AndroidRuntimeException: { what=1004 when=-14ms arg2=1 } This message is already in use.
at android.os.MessageQueue.enqueueMessage(MessageQueue.java:187)
at android.os.Handler.sendMessageAtTime(Handler.java:457)
at android.os.Handler.sendMessageDelayed(Handler.java:430)
at android.os.Handler.sendMessage(Handler.java:367)
at android.os.Message.sendToTarget(Message.java:349)
at android.webkit.WebView$5.onClick(WebView.java:1250)
at com.android.internal.app.AlertController$ButtonHandler.handleMessage(AlertController.java:172)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:130)
at android.app.ActivityThread.main(ActivityThread.java:3703)
at java.lang.reflect.Method.invokeNative(Native Method)
at java.lang.reflect.Method.invoke(Method.java:507)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:841)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:599)
at dalvik.system.NativeStart.main(Native Method)
Еще одно:
java.lang.NullPointerException
at android.os.Message.sendToTarget(Message.java:348)
at android.webkit.WebView$4.onClick(WebView.java:1060)
at com.android.internal.app.AlertController$ButtonHandler.handleMessage(AlertController.java:158)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:123)
at android.app.ActivityThread.main(ActivityThread.java:4627)
at java.lang.reflect.Method.invokeNative(Native Method)
at java.lang.reflect.Method.invoke(Method.java:521)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:860)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:618)
at dalvik.system.NativeStart.main(Native Method)
И еще:
java.lang.IndexOutOfBoundsException: setSpan (-1 ... -1) starts before 0
at android.text.SpannableStringBuilder.checkRange(SpannableStringBuilder.java:949)
at android.text.SpannableStringBuilder.setSpan(SpannableStringBuilder.java:522)
at android.text.SpannableStringBuilder.setSpan(SpannableStringBuilder.java:514)
at android.text.Selection.setSelection(Selection.java:74)
at android.text.Selection.setSelection(Selection.java:85)
at android.widget.TextView.performLongClick(TextView.java:8621)
at android.webkit.WebTextView.performLongClick(WebTextView.java:617)
at android.webkit.WebView.performLongClick(WebView.java:4471)
at android.webkit.WebView$PrivateHandler.handleMessage(WebView.java:8285)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:150)
at android.app.ActivityThread.main(ActivityThread.java:4389)
at java.lang.reflect.Method.invokeNative(Native Method)
at java.lang.reflect.Method.invoke(Method.java:507)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:849)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:607)
at dalvik.system.NativeStart.main(Native Method)
Заключение
Несмотря на все вешеописанное, мне понравилась заниматься разработкой под Android. В основном API вполне продуманный и удобный.
Вот только везде стоит использовать try/catch
. Даже там, где это совсем неочевидно.
И еще… Обязательно, нет — не так, а вот так — ОБЯЗАТЕЛЬНО собирайте информацию об ошибках у пользователей. Мы для этого пользуемся бесплатной библиотекой ACRA (http://code.google.com/p/acra/). Спасибо огромное ее разработчикам!
Автор: reminded