Технические аспекты обеспечения невизуальной доступности Android-приложений

09a947ea6bf04e18be8c893a8e4d82f0.jpgВозможно, читателю, далекому от рассматриваемой проблематики, название покажется абсурдным, ведь дизайн интерфейса как самой системы Android, так и разрабатываемых для нее приложений, ориентирован прежде всего именно на визуальную наглядность и привлекательность, что усугубляется использованием сенсорного экрана в качестве главного органа взаимодействия пользователя с устройством. Однако существует категория пользователей, волею природы или случая лишенных возможности в полной мере насладиться всеми этими прелестями. Благодаря тому, что в Android предусмотрены альтернативные, — или, лучше сказать, дополнительные, — способы взаимодействия, интерфейс и основной функционал системы отнюдь не являются принципиально недоступными для данной категории пользователей. Именно обеспечению такой доступности посвящены пункт «Специальные возможности» в меню настроек системы и входящее в ее состав приложение TalkBack. Что же касается невизуальной доступности сторонних приложений, то она варьируется от случая к случаю и порой требует от разработчика не то чтобы каких-то специальных сверхусилий, но хотя бы минимального внимания к проблеме.Список Android-приложений, протестированных на предмет невизуальной доступности, с соответствующими комментариями можно найти, например, здесь. Разумеется, это не единственный такой список в глобальной сети и наверное не самый представительный, но я ссылаюсь на него прежде всего как на источник примеров, наглядно иллюстрирующих то, о чем идет речь. Заметим, что невизуальная доступность интерфейса многих из этих приложений обусловлена не специальными стараниями их разработчиков, а является естественным результатом работы встроенных в систему механизмов. Разработчики же приложений просто этому не препятствуют, что, впрочем, я бы тоже вменил им в немалую заслугу.

Не станем углубляться в обсуждение целесообразности заботы о невизуальной доступности приложений в принципе. Об этом достаточно сказано в других местах. Отметим лишь, что разработчики Android уделяют этой заботе определенное внимание, о чем можно судить по истории развития средств специального доступа. Мы же сосредоточим свое внимание на чисто технических аспектах. Рассмотрим ряд типичных проблем и укажем пути их решения. Иными словами, данное сочинение ориентировано главным образом на разработчиков Android-приложений, по тем или иным причинам решивших не игнорировать потребности пользователей, обремененных визуальными ограничениями, и целью своей имеет помочь им воплотить благородные помыслы в жизнь.

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

ae1bdcb3f25d4bba87e1e0f5d4ccc4c2.pngПриводимые ниже соображения и рекомендации будут иллюстрироваться и подкрепляться конкретными примерами, взятыми главным образом из проекта TeamTalk, мое участие в котором не в последнюю очередь было связано именно с решением проблем доступности Android-приложения.

Разумеется, как правило это будут не совсем буквальные выдержки из текста. Я буду максимально упрощать их и даже порою несколько видоизменять, дабы не утомлять читателя не относящимися к делу подробностями и сделать иллюстрируемые идеи наиболее выпуклыми. Ведь предметом нашего рассмотрения является не сам этот проект, а проблемы невизуальной доступности, достаточно типичные для Android-приложений вообще, и возможные пути их решения.

Те же, кто захочет ознакомиться с исходным кодом, скупые выдержки из коего будут сопровождать повествование, во всей его полноте, смогут легко удовлетворить свое законное любопытство на Github.

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

То есть, когда возникает соблазн воспользоваться при разработке интерфейса какой-либо сторонней библиотекой или же создать свой собственный совершенно оригинальный элемент управления, недурно бы для начала задуматься:, а так ли оно необходимо на самом деле? Android SDK предоставляет в распоряжение программиста весьма богатый набор средств такого рода, и без достаточно серьезных оснований за его пределы выходить не следует. Это, кстати, положительно отразится не только на доступности приложения, но и на его совместимости.

Самое простое и очевидное, что разработчик приложения может (и должен, на мой взгляд) сделать для пользователей с визуальными ограничениями, не перетрудившись при этом и ничем не пожертвовав, — это аккуратно подписать все чисто графические элементы интерфейса через атрибут contentDescription. Однако, к сожалению, очень мало кто это делает. И должное уважение к данному атрибуту представляется скорее счастливым исключением, нежели общепринятой практикой.Рекомендации использовать contentDescription для повышения доступности интерфейса приложений встречаются и в руководящих документах Google, и в других источниках, так что, честно говоря, даже неловко напоминать еще раз. Я бы и воздержался, кабы все эти рекомендации не игнорировались с постоянством, достойным явно лучшего применения.

Порою же в ответ на прямую просьбу подписать графические кнопки от разработчиков доводилось слышать, что, мол, на экране для этого мало места. Разумеется, подобный ответ свидетельствует прежде всего о профессиональной несостоятельности программиста, который, не удосужившись даже мало-мальски ознакомиться с документацией, образно говоря, не пишет программу, а ляпает как попало. Хочется верить, что среди разработчиков приложений настолько безграмотных найдется немного, но все же на всякий случай еще раз подчеркну, что атрибут contentDescription совершенно безобиден, он абсолютно никак не отражается на внешнем виде приложения и не требует места на экране.

Однако, как и ко всему на свете, к заполнению contentDescription надлежит подходить с пониманием и без фанатизма. Механически бездумный подход скорее всего приведет к совершенно нежелательным результатам.

Проиллюстрируем сказанное примером. Предположим, мы собираемся отображать на экране список пользователей и для элемента списка имеем следующую схему:

Как видим, чисто графический элемент ImageView в этой схеме не имеет атрибута contentDescription. И это совершенно осознанно. Элемент списка здесь рассматривается как единое целое, то есть, его части (ImageView и TextView) не имеют самостоятельной роли: у них не установлен атрибут clickable. Текстовая информация, необходимая службе специального доступа, целиком содержится в TextView, а ImageView в данном случае играет по большей части декоративную роль и с точки зрения невизуального доступа полезной информации не несет.

Совсем другое дело если бы элемент ImageView на самом деле использовался в качестве кнопки, нажатие на которую вызывало быкакое-либо действие. В этом случае атрибут contentDescription был бы крайне полезен.

Теперь предположим, что пользователи в нашем списке могут находиться в различном состоянии, скажем, «online» и «offline», и для их индикации мы будем пользоваться разными цветами. Сделать эту дополнительную информацию также невизуально доступной нам опять-таки поможет атрибут contentDescription, который на сей раз мы будем задавать динамически вместе с цветом элемента в адаптере списка.

Вот как это может быть реализовано:

class UserListAdapter extends ArrayAdapter {

public UserListAdapter (Context context, int resource) { super (context, resource); }

@Override public View getView (int position, View convertView, ViewGroup parent) { Context context = getContext (); LayoutInflater inflater = LayoutInflater.from (context); if (convertView == null) convertView = inflater.inflate (R.layout.item_user, null); User user = getItem (position); TextView nickname = (TextView) convertView.findViewById (R.id.nickname); nickname.setText (user.nickname); if (user.stateOnline) { convertView.setBackgroundColor (Color.rgb (133, 229, 141)); // Мы намеренно дублируем здесь основное текстовое содержание, // так как при наличии атрибута contentDescription, // служба специального доступа использует именно его, // полностью игнорируя атрибут text. nickname.setContentDescription (context.getString (R.string.user_state_online, user.nickname)); } else { convertView.setBackgroundColor (Color.rgb (0, 0, 0)); // Обнуляя contentDescription, мы заставляем // службу специального доступа использовать атрибут text. nickname.setContentDescription (null); } return convertView; }

} Предполагается, что в строковом ресурсе имеется определение:

%1$s online Обратим внимание на то, что дополнительную информацию мы сообщаем службе специального доступа лишь тогда, когда пользователь пребывает в состоянии «online». Это помогает сократить объем речевых сообщений без ущерба для информативности, так как возможных состояний всего два, то есть никаких разночтений не возникает.

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

Кроме того, составляя комбинированный текст для contentDescription, мы размещаем имя пользователя перед обозначением его состояния, ибо из соображений эффективности восприятия наиболее востребованная информация должна располагаться в начале речевого сообщения.

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

public class MainActivity extends Activity {

private UserListAdapter userListAdapter; private CountDownTimer listUpdateTimer;

@Override protected void onCreate (Bundle savedInstanceState) { super.onCreate (savedInstanceState); userListAdapter = new UserListAdapter (this, R.layout.item_user); listUpdateTimer = new CountDownTimer (10000, 1000) {

@Override public void onTick (long millisUntilFinished) { userListAdapter.notifyDataSetChanged (); }

@Override public void onFinish () { start (); } };

listUpdateTimer.start (); }

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

С этой целью введем в рассмотрение вспомогательный класс:

public class AccessibilityAssistant extends AccessibilityDelegate {

private final Activity hostActivity; private volatile boolean eventsLocked;

public AccessibilityAssistant (Activity activity) { hostActivity = activity; eventsLocked = false; }

// Перед обновлением списка мы будем запрещать выдачу событий. public void lockEvents () { eventsLocked = true; }

// После обновления списка мы будем вновь разрешать выдачу событий, // однако реально это должно происходить лишь после того, // как информация на экране действительно обновится. public void unlockEvents () { if (! hostActivity.getWindow ().getDecorView ().post (new Runnable () { @Override public void run () { eventsLocked = false; } })) eventsLocked = false; }

@Override public void sendAccessibilityEvent (View host, int eventType) { if (! eventsLocked) super.sendAccessibilityEvent (host, eventType); }

@Override public void sendAccessibilityEventUnchecked (View host, AccessibilityEvent event) { if (! eventsLocked) super.sendAccessibilityEventUnchecked (host, event); }

} Теперь мы можем легко реализовать постоянное обновление информации на экране, не жертвуя невизуальной доступностью интерфейса:

public class MainActivity extends Activity {

private AccessibilityAssistant accessibilityAssistant; private ArrayAdapter userListAdapter; private CountDownTimer listUpdateTimer;

@Override protected void onCreate (Bundle savedInstanceState) { super.onCreate (savedInstanceState);

accessibilityAssistant = new AccessibilityAssistant (this);

userListAdapter = new ArrayAdapter (this, R.layout.item_user) { @Override public View getView (int position, View convertView, ViewGroup parent) { if (convertView == null) convertView = LayoutInflater.from (getContext ()).inflate (R.layout.item_user, null); User user = getItem (position); TextView nickname = (TextView) convertView.findViewById (R.id.nickname); nickname.setText (user.nickname); if (user.stateOnline) { convertView.setBackgroundColor (Color.rgb (133, 229, 141)); nickname.setContentDescription (getString (R.string.user_state_online, user.nickname)); } else { convertView.setBackgroundColor (Color.rgb (0, 0, 0)); nickname.setContentDescription (null); } // Мы собираемся блокировать именно те события, // источниками которых являются элементы списка. convertView.setAccessibilityDelegate (accessibilityAssistant); return convertView; } };

listUpdateTimer = new CountDownTimer (10000, 1000) {

@Override public void onTick (long millisUntilFinished) { // Запрещаем выдачу событий accessibilityAssistant.lockEvents (); // Инициируем обновление списка на экране userListAdapter.notifyDataSetChanged (); // Вновь разрешаем выдачу событий // когда информация на экране обновится accessibilityAssistant.unlockEvents (); }

@Override public void onFinish () { start (); } };

listUpdateTimer.start (); }

} В принципе, задачу можно было бы решить и переопределением метода notifyDataSetChanged () в адаптере списка:

public void notifyDataSetChanged () { accessibilityAssistant.lockEvents (); super.notifyDataSetChanged (); accessibilityAssistant.unlockEvents (); } Но этот вариант хуже, ибо блокируются события, возникающие при любом обновлении списка, даже если оно инициировано какими-либо действиями пользователя. Система же специального доступа ориентирована на то, чтобы пользователь имел адекватный отклик на свои действия, так что в общем случае такая блокировка нежелательна.

Теперь рассмотрим ситуацию, когда каждый элемент списка имеет сопряженную с ним кнопку, то есть описывается, например, следующей схемой: