Вы когда-нибудь задумывались о том, что происходит с вашим приложением после того, как система убила его процесс за ненадобностью? Печально, но многие об этом даже не беспокоятся, словно это будет происходить в параллельной вселенной и их это не касается. Особенно этому подвержены новички. Их слепая вера в непоколебимость статик ссылок просто поражает.
В этой статье я расскажу о некоторых ошибках, которые могут возникнуть в результате нарушения шестой заповеди (не убей) по отношению к процессу приложения, и о том как проверить на сколько качественно он возвращается с того света.
Ни для кого не секрет, что процесс может быть убит системой. А вы интересовались, реально ли сымитировать это? Можно попробовать“натравить” систему на свое приложение, запуская кучу других приложений, съедающих знатный кусок памяти, а затем надеяться, что система таки снизошла до нас убив нужное приложение. Прямо какой-то шаманизм получается, а это стезя админов, но никак не программистов. Меня заинтересовало, как можно легко и быстро убить приложение, да так, будто это сделала система для освобождения ресурсов. Ведь если получится повторить подобное поведение в “лабораторных условиях”, можно будет отлавливать множество ошибок ещё на стадии разработки, либо с лёгкостью воспроизвести их для выявления причин.
Как оказалось, нужный механизм уже имеется в SDK, и это… барабанная дробь… Кнопка «Terminate Application».
Нажатие на нее аналогично выполнению следующего кода:
// Код выхода здесь не играет большого значения.
// Однако, при нажатии "Terminate Application" используется именно “1”.
System.exit(1);
Это, собственно, и убивает процесс. Похожее действие происходит когда система пытается избавиться от ненужного процесса.Для того, чтобы воспроизвести ситуацию с возобновлением работы приложения после его полной остановки, нужно проделать следующую последовательность действий:
- Используя Android Stidio запустить приложение;
- Свернуть приложение нажатием кнопки «Home»;
- В Android Studio, перейти на вкладку Android, выбрать приложение и нажать кнопку «Terminate Application»;
- Развернуть свернутое приложение.
Если Activity не совсем корректно обрабатывает восстановление после уничтожения, вы сразу это заметите. В лучшем случае оно упадёт, в худшем зависнет.
Итак, мы научились выманивать один из самых скрытных типов ошибок. Давайте научимся эти ошибки предвидеть. Начнем с самых очевидных случаев, а затем плавно перейдем к менее однозначным случаям.
Ситуация 1: Статик — это не надёжно
Представим такую ситуацию: В одной Activity мы вводим некий набор данных, например имя и фамилию. Эта Activity строго следит за правильностью ввода данных, поэтому можно с твёрдой уверенностью гарантировать их «корректность», и вообще то, что они были введены.
public class StaticActivityFirst extends BaseActivity {
private EditText mEditFirstName;
private EditText mEditLastName;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.sit01_0_activity_first);
mEditFirstName = (EditText) findViewById(R.id.editFirstName);
mEditLastName = (EditText) findViewById(R.id.editLastName);
}
public void onNextClick(View view) {
String firstName = mEditFirstName.getText().toString().trim();
// Имя не должно быть пустым
if (TextUtils.isEmpty(firstName))
mEditFirstName.setError(getString(R.string.sit01_0_empty_text_error_message));
else {
mEditFirstName.setError(null);
String lastName = mEditLastName.getText().toString().trim();
// Фамилия не должна быть пустой
if (TextUtils.isEmpty(lastName))
mEditLastName.setError(getString(R.string.sit01_0_empty_text_error_message));
else {
mEditLastName.setError(null);
StaticActivitySecond.sPerson = new Person(firstName, lastName);
startActivity(new Intent(this, StaticActivitySecond.class));
}
}
}
}
public class StaticActivitySecond extends BaseActivity {
public static Person sPerson;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.sit01_0_activity_second);
TextView txtSummary = (TextView) findViewById(R.id.txtSummary);
txtSummary.setText(String.format(getString(R.string.sit01_0_summary_message), sPerson.firstName, sPerson.lastName));
}
}
Проделываем описанную ранее последовательность по корректному уничтожению процесса на второй Activity. Приложение падает.
Что случилось?
Проблема тут не в отсутствии проверки на null. Самая страшная проблема — это потеря пользовательских данных. Статические объекты не должны быть хранилищем данных, особенно если это их единственное место. Касается это как обычных переменных так и синглтонов. Так что если в статике хранится что-то важное, будьте готовы это важное потерять в любой момент.
Что делать?
Наличие таких ошибок, зачастую, свидетельствует о низкой квалификации программиста, либо о слишком высоком чувстве лени. О том как делать правильно, написано огромное количество туториалов. В приведенном примере лучше всего подойдёт передача данных через бандл. Также можно писать эти данные в SharedPreferences, либо стоит задуматься о создании базы данных.
Важно: Не стоит забывать, что синглтон это тоже статик переменная. Если вы используете синглтон, то он должен выступать лишь как инструмент облегчающий доступ к данным, но ни как не быть единственным хранилищем для них.
Волшебная сила Application
Как часто я вижу советы использовать класс Application как синглтон либо инициализировать синглтон в методе onCreate() класса Application. Якобы после этого он станет круче чем Ленин, то есть будет живее всех живых при любых обстоятельствах. Возможно, это частный случай заблуждения встретившийся только мне. Причем, все публикации которые я находил, явно не заявляют о подобных свойствах синглтона. В некоторых из них говорится, что синглтон может быть уничтожен сборщиком мусора если инициировать его в классе Activity (что для меня звучит немного дико). В других пугают выгрузкой класса из класслоадера (а это уже похоже на правду).
Сейчас я не собираюсь выяснять, что тут правда а что домыслы. В любом случае это лишь снижает вероятность потери статик ссылки но ни как не спасает от остановки процесса. Остановка процесса приведет к полному уничтожению класслоадера, а вместе с ним и уничтожению всех классов, включая класс Application.
Ситуация 2: setRetainInstance как решение всех проблем
Допустим, некий программист решил повторить пример с именем и фамилией, но с использованием диалог фрагментов.
Итак, есть DialogFragment с текстовым полем для ввода фамилии и имени. Этому фрагменту можно установить слушатель на ввод данных. Для того что бы не потерять ссылку на слушатель в onCreate() вызывается setRetainInstance(true).
public class Situation2DialogFragment extends DialogFragment {
private OnPersonChooseListener mListener;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true);
}
public void setListener(OnPersonChooseListener listener) {
mListener = listener;
}
public void invokePersonChoose(Person person) {
mListener.onPersonChoose(person);
}
public interface OnPersonChooseListener {
public void onPersonChoose(Person person);
}
// ...
}
На экране есть кнопка, при нажатии на которую этот диалог отображается.
public void onChoosePersonClick(View view) {
Situation2DialogFragment dialog = new Situation2DialogFragment();
dialog.setListener(new Situation2DialogFragment.OnPersonChooseListener() {
@Override
public void onPersonChoose(Person person) {
Toast.makeText(
Situation2Activity.this,
String.format(
getString(R.string.sit02_summary_message),
person.firstName,
person.lastName),
Toast.LENGTH_SHORT
).show();
}
});
dialog.show(getSupportFragmentManager(), Situation2DialogFragment.class.getName());
}
Такая реализация — стойка к любым вращениям экрана. Все будет работать как часы. До поры до времени…
Применим последовательность действий для хитрого уничтожения процесса, когда диалог открыт. При разворачивании ничего не произойдет. Однако, при вызове invokePersonChoose приложение вылетит с NullPointerException.
Что случилось?
setRetainInstance(true) не позволяло диалогу уничтожиться. После уничтожения процесса диалог все-таки был уничтожен. Activity восстанавливает фрагмент насколько это возможно. К сожалению, слушатель не восстанавливается, так как он был установлен совершенно в другом месте для совершенно другого объекта. Когда в диалоге, в методе invokePersonChoose, происходит обращение к слушателю, выбрасывается исключение. И беда тут не в отсутствии проверки на null. Поставить проверку на null без должной реакции на пустую ссылку будет еще более худшим решением.
Что делать?
В интернете описана куча способов передачи сообщений из фрагмента в Activity. К сожалению, не все из них правильные. Следующий способ правильный и один из моих любимых:
- Activity реализует нужный интерфейс:
Часть кода Activitypublic class Situation2Activity extends Activity implements OnPersonChooseListener { //… @Override public void onPersonChoose(Person person) { Toast.makeText( Situation2Activity.this, String.format( getString(R.string.sit02_summary_message), person.firstName, person.lastName), Toast.LENGTH_SHORT ).show(); } }
- Фрагмент получает ссылку на Activity через getActivity и приводит ее к соответствующему интерфейсу. Дальше вся работа осуществляется через интерфейс:
Получение слушателя во фрагментеpublic void onAttach(Activity activity) { super.onAttach(activity); if (activity instanceof OnPersonChooseListener) mListener = (OnPersonChooseListener) activity; else throw new RuntimeException("Activity must implement OnPersonChooseListener."); }
Единственное, что не стоит забывать, Activity может быть уже уничтожена когда дело дойдет до отправки сообщения. Поэтому обязательно проверьте добавлен ли фрагмент на Activity.
По мимо Activity можно использовать родительский фрагмент или Target фрагмент.
Еще пара слов про setRetainInstance
Замечу, это лишь частный случай с setRetainInstance. Количество проблем которые он может скрыть(а не решить) немного больше. Вместе со слушателями также теряются и все остальные переменные. Все, что не было сохранено в методе onSaveInstanceState, будет потеряно.
Также, он скрывает проблему когда класс диалога анонимный. Допустим, в момент создания нового объекта диалога, переопределен какой либо метод, в этом случае создастся объект анонимного класса.
DialogFragment dialog = new DialogFragment() {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true);
}
};
Если этот диалог будет уничтожен и система попытается его восстановить выбросится исключение ClassNotFoundException.
Ситуация 3: Разорванные нити
Пусть суть приложения заключаться в следующем:
На экране кнопка. При нажатии на кнопку открывается диалог прогресса, блокирующий взаимодействие с UI.
public class ProgressDialogFragment extends DialogFragment {
public static final String DISMISS_ACTION = "ru.kamisempai.ProgressDialogFragment.DISMISS_ACTION";
private boolean isWaitingForDismiss = false;
private BroadcastReceiver mDismissActionReceiver;
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
mDismissActionReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
dismiss();
}
};
LocalBroadcastManager.getInstance(activity)
.registerReceiver(mDismissActionReceiver, new IntentFilter(DISMISS_ACTION));
}
@Override
public void onDetach() {
if (mDismissActionReceiver != null)
LocalBroadcastManager.getInstance(getActivity())
.unregisterReceiver(mDismissActionReceiver);
super.onDetach();
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
final android.app.ProgressDialog dialog = new android.app.ProgressDialog(getActivity()) {
@Override
public void onBackPressed() {
// do nothing
}
};
dialog.setMessage("Please wait...");
dialog.setCancelable(false);
dialog.setCanceledOnTouchOutside(false);
return dialog;
}
@Override
public void onResume() {
super.onResume();
if(isWaitingForDismiss)
dismiss();
}
@Override
public void dismiss() {
if(isResumed())
super.dismiss();
else
isWaitingForDismiss = true;
}
}
Как можно понять из кода, этот диалог может быть закрыт через посылку широковещательного сообщения. А это значит, что нет необходимости хранить ссылку на него, достаточно иметь контекст приложения.
Сразу после запуска диалога запускается поток, выполняющий что-то некоторое время. В самом конце своей работы поток посылает сообщение для закрытия диалога прогресса.
public void onStartActionClick(View view) {
new ProgressDialogFragment()
.show(getSupportFragmentManager(), ProgressDialogFragment.class.getName());
final Context context = getApplicationContext();
new Thread() {
@Override
public void run() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
LocalBroadcastManager.getInstance(context)
.sendBroadcast(new Intent(ProgressDialogFragment.DISMISS_ACTION));
}
}.start();
}
Если запустить этот код и не дожидаясь его завершения свернуть приложение, а затем вырубить процесс, то при разворачивании ничего не предвещает беды, приложение не падает, а диалог показывается. Ждём немножко… Ещё ждём… Потом ещё ждём… Диалог не закрывается, хоть и должен был сделать это уже давно.
Что случилось?
При уничтожении процесса останавливаются все его потоки, а при восстановлении запускается только главный поток. Так что если ваш поток работает слишком долго, будьте готовы к тому, что он будет остановлен. Причём не обязательно делать что-то долго, на эти грабли можно наступить и при использовании wait notify. Особенно забавно будет, если в качестве объекта для блокировки использовать public static final Object, ведь мы то уже знаем, что статик объекты не исключение при уничтожении процесса.
private static final Object LOCK = new Object();
public void onStartWaitClick(View view) {
// ...
new Thread() {
@Override
public void run() {
synchronized (LOCK) {
try {
// Поток останавливается и ждет.
LOCK.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// Действия после ожидания
}
}.start();
}
public void onContinueClick(View view) {
new Thread() {
@Override
public void run() {
synchronized (LOCK) {
LOCK.notify();
}
}
}.start();
}
Что делать?
Если задача занимает много времени вынести ее в отдельный сервис и запустить в новом процессе, плюс вывести foreground Notification, иначе процесс все равно будет убит. В случае с wait notify все немного сложнее и зависит от конкретной ситуации. Вообще, тема работы с потоками достаточно обширна, и давать какой-то конкретный совет тут неуместно. Разве что не усложнять и не лезть в дебри, из которых не сможешь вылезти.
Ситуация 4: Письмо в никуда
Есть Activity с одной единственной View. Она подписана на получение широковещательных сообщений. Когда Activity получает такое сообщение, цвет View меняется в зависимости от содержимого этого сообщения. При клике на View открывается вторая Activity, через которую можно посылать те самые сообщения для смены цвета.
public class Situation4FirstActivity extends BaseActivity {
public static final String ACTION_SET_COLOR = "ru.kamisempai.Situation4FirstActivity.ACTION_SET_COLOR";
public static final String EXTRA_COLOR = "ru.kamisempai.Situation4FirstActivity.EXTRA_COLOR";
private View mView;
private BroadcastReceiver mSetColorActionReceiver;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mView = new View(this);
setContentView(mView);
mView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// Запуск второй Activity
startActivity(new Intent(Situation4FirstActivity.this, Situation4SecondActivity.class));
}
});
mSetColorActionReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, final Intent intent) {
// Менять цвет у view нужно в основном потоке
runOnUiThread(new Runnable() {
@Override
public void run() {
mView.setBackgroundColor(intent.getIntExtra(EXTRA_COLOR, Color.BLUE));
}
});
}
};
LocalBroadcastManager.getInstance(this)
.registerReceiver(mSetColorActionReceiver, new IntentFilter(ACTION_SET_COLOR));
}
@Override
protected void onDestroy() {
// При уничтожении Activity не забываем отписаться от получения сообщений
LocalBroadcastManager.getInstance(this)
.unregisterReceiver(mSetColorActionReceiver);
super.onDestroy();
}
}
Для того чтобы первая Activity не уничтожалась при поворотах экрана, в файл манифеста добавлены configChanges.
<activity android:name=".situation_04.Situation4FirstActivity"
android:configChanges="orientation|screenSize"/>
Во второй Activity есть кнопка, нажатие по которой шлет сообщение для смены цвета.
public class Situation4SecondActivity extends BaseActivity {
private Random mRandom;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.sit04_activity_second);
mRandom = new Random(System.currentTimeMillis());
}
public void onRandomizeColorClick(View view) {
Intent intent = new Intent(Situation4FirstActivity.ACTION_SET_COLOR);
intent.putExtra(
Situation4FirstActivity.EXTRA_COLOR,
Color.BLACK + mRandom.nextInt(0xFFFFFF));
LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
}
}
При возвращении на первую Activity цвет View будет изменен.
Однако, если перейти на вторую Activity, свернуть приложение, убить процесс, а затем развернуть приложение, случится кое-что не предвиденное. Сколько бы раз вы не нажимали на кнопку смены цвета, при возвращении на первую Activity ее View будет белоснежной.
Что случилось?
Не смотря на то, что стек активностей сохранился, после разворачивания приложения была восстановлена только вторая Activity. Фактически, сообщения о смене цвета уходили в никуда. Так как на тот момент не существовало объектов, подписанных на это событие. При возвращении на первую Activity она была восстановлена, но подписываться на событие смены цвета было уже поздно.
Что делать?
Первым делом нужно уяснить главное — если вы работаете с одной Activity, остальные для нее не существуют. Если нужно донести какую либо информацию, используйте предназначенный для этого setResult. Еще не следует слепо полагаться на жизненные циклы. Как видно из примера, если Activity 1 запустила Activity 2 то это еще не значит, что метод onCreate первой Activity был выполнен.
Также замечу, этот пример показывает не только на проблемы со стеком активностей, но и проблемы со всем, что связано с посылкой сообщений. Исключением будут только BroadcastReceiver-ы, прописанные в манифесте, либо запланированные через AlarmManager. Все остальное не дает 100% гарантии на доставку сообщения адресату.
Заключение
“Бывалым кодерам” эти ситуации могут показаться очевидными, а примеры на столько не естественными, что просто воротит. Однако, все мы когда-то начинали с нуля и писали код от которого сейчас было бы стыдно. Знай я об этих граблях в самом начале своего пути, шишек на лбу было бы меньше. Надеюсь, эта статья поможет наставить на путь истинный большое количество начинающих Android программистов. От “Бывалых” буду рад увидеть комментарии. А еще лучше если вы дополните список или напишете, помогла ли эта статья в ловле багов из разряда “Как?! Такого не может быть!”.
На этом закончу. Спасибо за внимание. Напоследок замечу, в рукаве у меня еще остался один неординарный случай. Он еще не изучен мной до конца, но я надеюсь в скором времени расколоть этот крепкий орешек. Так что ждите продолжения.
Комментарии (18)
KamiSempai
18.08.2015 14:44+1Можно и так убить
adb shell am force-stop my.package
Можно. Но тогда не сохранится стек активностей. Мало того у force-stop есть ряд более тяжелых последствий. Например в приложение перестанут приходить броадкасты.
Artem_zin
18.08.2015 17:17+4АКТИВНОСТЬ
Mikhail_dev
18.08.2015 18:01Я думал уже не осталось людей, которые обращают на это внимание, ан нет.
Использую в общении такие слова как активность, активити, и ничего, никто пока еще от этого не умер и все сразу понимали смысл.
KamiSempai
18.08.2015 18:03+4Видимо мир еще не готов к «АКТИВНОСТЯМ». Исправил все «Активности» на «Activity». Думаю, так действительно будет более привычно. Хотя, странно получается, к «сервисам» и «фрагментам» все нормально относятся, но вот «активности» в русском сообществе как-то не прижились. Видимо из за большего различия в произношении.
Skaner
18.08.2015 21:05АктивностьВ русском сообществе есть Сezurity — надежный страж от активности.mayorovp
18.08.2015 23:49«Activity» переводится на русский язык как «деятельность», а не как «активность».
grishkaa
19.08.2015 02:01+1Названия системных компонентов андроида лучше не переводить на русский вообще. А то так и будем показывать в представлениях внутри активности данные из предоставителя содержимого и обновлять по широковещательному намерению.
i12v
18.08.2015 23:08-2Слово «сымитировать» можно написать сто двадцатью восемью различными способами и только одним способом правильно.
kemsky
19.08.2015 20:58Terminate — жестокая штука. Спасибо, теперь буду проверять и такой случай. Изначально, использовал бандлы для передачи параметров, но как-то это неудобно слишком. Выходит, что единственное решение — всегда сохранять модель, а так как сохранение будет идти на диск, то еще и в другом потоке надо делать (можно конечно использовать отложенную запись преференсов)…
Defuera
25.08.2015 12:35А еще можно пойти в Developer Options и включить «Don't keep activities», по-русски называется «Не сохранять действия», если я не ошибаюсь. А затем просто свернуть развернуть приложение.
Mikhail_dev
05.09.2015 17:13Ага, я когда-то пол дня потратил в анализе того, почему мои активности умирают на 5 версии андроида, а на другом телефоне с четверкой всё хорошо, пока не докопался до этой штуки, которую включил и забыл.
Ganster41
… который тоже неправильный.
При использовании такого подхода нужно будет еще добавить обнуление ссылки на mListener в onDetach(), иначе имеется вероятность отправить результат в уже остановленную Acticity.
Правильнее, имхо, все же не кешировать результат вызова getActivity(), а получать его при каждом возврате значения, проверяя на null, и т.п.
KamiSempai
Обнуление листнера лишь добавит необходимость проверки на null. Я предпочитаю делать проверку на isAdded() при любых действиях с Activity. Это не даст совершить действия над уничтоженной активностью а также исключит случаи когда фрагмент удален или еще не добавлен.
dzigoro
Я использую подход с евент-басом. Активити подписывается на события в onCreate, отписывается в onStop, диалог посылает события. Полный декаплинг.