Некоторые “подводные камни” разработки под Android

в 12:04, , рубрики: android, метки:

Недавно наша команда завершила разработку приложения под 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

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js