Адаптируем VoiceOver для людей
Меня зовут Женя Тютюев, я iOS-разработчик в компании 2ГИС. Хочу поделиться, как адаптировал наше приложение под VoiceOver:
делюсь историей про сдвиг парадигмы и как перешёл из стадии «делать, потому что так Эпл советует» в совершенно новую — «делать для людей».
как разработал новый вид snapshot-тестирования, чтобы ничего не ломалось при добавлении новых неадаптированных элементов.
описал несколько нюансов, которые важно учесть в работе: escape, динамический расчёт accessibility, укрупнение элементов.
Надеюсь, мой опыт вдохновит принести неоценимую пользу и сделать собственные приложения доступными для большего числа пользователей.
Что такое VoiceOver
VoiceOver — функция, доступная на всех устройствах Apple, таких как iPhone, iPad, Mac, Apple Watch, TV и VisionPro. Она озвучивает элементы интерфейса, позволяя незрячим пользователям управлять устройством с помощью жестов. Например, тап по экрану фокусирует и озвучивает элемент, двойной тап активирует его, а прокрутка тремя пальцами используется для навигации по экрану список основных жестах можно глянуть на сайте Apple.
Вот, например, видео, как пользуются VoiceOver:
Как и многие разработчики, я впервые столкнулся с VO, когда начал писать тесты. По всему проекту расставлял accessibilityIdentifier, по которому в UI-тестах уже можно найти элемент на экране, узнать его состояние или взаимодействовать с этим элементом по этому id.
let app = XCUIApplication()
app.launch()
// Находим кнопку по AccessibilityID
let button = app.buttons["myButtonAccessibilityID"]
// Проверяем, что кнопка найдена
XCTAssertTrue(button.exists)
// Нажимаем на кнопку
button.tap()
// Пример проверки, что текст изменился после нажатия
XCTAssertEqual(button.label, "New Button Label")
Шли годы, периодически приходилось тестировать всё более сложные штуки, и уже просто accessibilityIdentifier не хватало, пришлось закапываться в документацию. Тогда я рассматривал VO как утилитарную технологию для роботов и долгое время жил в этой парадигме. По мере просмотра лекций на WWDC начало появляться стойкое ощущение, что эта функция вообще-то разрабатывалась не для тестов, а для людей.
Посмотрев ещё парочку видео, я был вдохновлен, — можно разметить обычные стандартные контролы и дать им нормальные имена, и профит! Просто и быстро сделал, как советовал Apple. Заходил в их accessibility-inspector, инспектировал по инструкции из видео, проверял, что кнопочки нажимаются и проблем с вёрсткой нет. Ну, думал, теперь всё по-взрослому: срочно в релиз, доступность с пылу с жару! Ну как срочно… я размечал все элементы месяца два: никто не говорил, что будет легко. Всё ушло в релиз, и некоторое время работало. Моя совесть была чиста — я сделал всё, что мог, и даже больше.
И долгое время это была просто обезличенная работа: читал документацию, следовал ей, проверял результаты по ней же. А потом наткнулся на видео от Wylsacom, как незрячий пользуется iPhone, MacBook и Apple Watch. Тогда я понял, что на свете есть люди, которые по-настоящему пользуются VoiceOver. Это их связь с миром, их «глаза», и он существенно помогает им в повседневной жизни.
И на волне нахлынувшего энтузиазма я решил тёплыми вечерами причинить непоправимую пользу человечеству. Из стадии «делать, потому что Эпл советует» я перешёл в другую — «делать для людей». Это оказался совершенно другой подход. Решил делать всё по-честному.
VoiceOver, версия 1.0
У Apple есть все инструменты для адаптации, они как бы шепчут: бери да и делай. Но в основном информация поверхностная и мало «живых» примеров. Также всё здорово, если на проекте стандартный UIKit, стандартные системные кнопочки. Шаг влево, шаг вправо, и совсем нет понимания, как всё адаптировать, чтобы людям было удобно пользоваться.
Приложение 2ГИС тяжелое (189214 файлов и 29452584 строчек кода на момент написания статьи), относительно хорошо покрытое UI-тестами — 700+ честных UI-тестов (это которые через XCUIApplication) и 1000+ нечестных UI-тестов (поделка, которая трогает UI в рамках Unit-тестирования, без поднятия всего приложения). Потыкав во всё приложение, определил, какие сценарии нужно взять в работу, а какими для начала можно пренебречь. Без какого-либо опыта, включил интуицию.
То, без чего я не мог начать погружаться в эту тему, — разделение кода для пользователей и кода для роботов. В первой итерации адаптации я даже не думал об этом. Чем больше доступности, тем лучше, правда же? Спойлер: на самом деле, нет.
Для разделения в переменные окружения для тестов нужно написать специальный флаг:
extension XCUIApplication {
@discardableResult
/// Запустить приложение для тестов или как для обычного пользователя
func customLaunch(asUser: Bool = false) -> XCUIApplication {
if !asUser {
self.launchEnvironment["v4ios_uitests"] = "YES"
}
self.launch()
return self
}
}
extension ProcessInfo {
static var isUITests: Bool {
ProcessInfo.processInfo.environment["v4ios_uitests"] == "YES"
}
}
В дальнейшем все сниппеты и примеры можно щупать на GitHub. Там полностью рабочий проект со всеми примерами, что я расписал ниже.
Раньше я думал, что покрыл 80% важных сценариев, и это хорошо. Однако реальным людям такая адаптация хоть и позволяла работать с приложением, но не была такой, к которой хотелось бы возвращаться снова и снова. Хотелось, чтобы человек с опытом использования адаптаций проинспектировал наше приложение и дал рекомендации.
На волне энтузиазма я связался с Павлом, героем видео Wylsacom. Коллаборация с ним по неизвестным мне причинам не сложилась, но он посоветовал обратиться в другое сообщество. Также нам периодически писали пользователи с отзывами о приложении. Я создал чат в WhatsApp (да, именно WhatsApp, так как он оказался более доступным, чем Telegram на iOS) и приглашал туда всех, кого находил.
Потихоньку я добавлял в приложение функции, адаптированные по документации Apple, и просил пользователей оставлять обратную связь. Это помогало находить и исправлять ошибки. Процесс был итеративным: некоторые обновления сразу заливал в прод, а другие отправлял в виде TestFlight-сборок.
Спустя год у нас появилась версия приложения, которую условно можно было назвать адаптированной. Работало не идеально, но всё функционировало. Самое главное, что мы учли мнение реальных пользователей, которым новая адаптация стала удобнее и понятнее.
Но прошли годы, приложение пережило редизайны и переписывание на Swift, и вся адаптация постепенно превратилась в тыкву. Если честно, я тогда немного (на самом деле, прилично) выгорел. Люди не фидбэчили, и я думал: или всё работает нормально, или никто не пользуется. Чат постепенно умер.
VoiceOver, версия 2.0
В 2022 случились санкции, которые усугубили ситуацию для пользователей: Google ушёл с рынка в качестве провайдера данных для транспорта, а другие российские приложения были не особо доступными. У пользователей не осталось альтернатив, и нам массово (насколько это возможно для такой небольшой аудитории) начали писать, что всё работает плохо.
Примерно в это же время я смог связаться с экспертами по тестированию доступности из Сбера. Без их помощи и советов вряд ли бы получилось двигаться дальше. Ещё неожиданно написал неравнодушный тифлопедагог (я даже не знал, что такие существуют) и добавил мотивации продолжать начатое дело.
![2791194d47503691d944d3074ae6d13c.jpeg](https://habrastorage.org/r/w780q1/getpro/habr/upload_files/279/119/4d4/2791194d47503691d944d3074ae6d13c.jpeg)
Также к тому же моменту появился титанический труд Михаила Рубанова — спасибо ему! Его работа помогала решать типовые задачи. Крайне советую начинать онбординг в VoiceOver с этой книги.
Благодаря всем этим факторам появились желание и силы сделать новый VoiceOver, более серьёзное и адаптированное приложение.
Важные вводные
С точки зрения продуктовой разработки, фича VoiceOver всегда проигрывает другим задачам по всем критериям. Для неё никогда не находится время на разработку и на тестирование. Если не подталкивать всех — дизайнеров, продактов, QA, разработчиков и даже конечных пользователей — энтропия делает своё беспощадное дело. Без регулярного регресса и контроля за новыми функциями, вероятность того, что случайный сценарий станет недоступен за 2–3 недели активной разработки, неуклонно стремится к 100%. И из этих вводных важно понимать, что никто не будет проводить этот регресс примерно никогда.
И ещё один диксклеймер.
2ГИС — настоящий Nero burning ROM (только диски уже писать не умеет: кто поймет, тот поймёт). У нас на экране десятки контролов, реализованы сотни пользовательских сценариев. Есть боевой код, написанный ещё в 2012 году. В таких условиях я честно понимал, что всё адаптировать невозможно. Адаптировать надо только то, чем точно пользуются люди: данные брал на основе обратной связи.
И дальше я буду описывать не идеальную адаптацию, а адаптацию, которую может сделать один человек за сторипоинты, которые выбиваются с потом и кровью. В гигантском проекте, который поддерживается множеством людей, каждый из которых может случайно сломать что-то (не со злости, а по незнанию). В проекте, где появляются новые экраны, о которых я могу не знать, и постоянно меняются уже существующие. И самое главное, чтобы этим могли пользоваться настоящие люди. И было бы неплохо, чтобы всё не сломалось на следующий день.
Поэтому во втором очередном подходе разработку я начал с тестирования. Пришло чёткое осознание — нужны бездушные тесты, чтобы руки разработчиков, не участвующих в разработке этой фичи или написании UI-тестов на уже существующий экран, ничего больше не сломали. И было стойкое желание, чтобы эти тесты не занимали вечность (то есть не были UI-тестами, которые гоняются десятки секунд). А еще хотелось, чтобы их было супер просто писать, ещё проще поддерживать, и чтобы при их падении сразу было понятно, что пошло не так. Типичный ИКР по ТРИЗу. Кажется, что так не бывает, но на самом деле бывает.
Snapshot-тестирование
К счастью, нам не нужно делать скриншоты экрана, ведь такие тесты не решают главной проблемы — быстро понять, что пошло не так.
Всё гораздо проще, достаточно лишь использовать простой советский…
![44a7647eeb02c685134578401a0663e8.jpg](https://habrastorage.org/r/w780q1/getpro/habr/upload_files/44a/764/7ee/44a7647eeb02c685134578401a0663e8.jpg)
снапшот букв, которые слышит пользователь.
Для этого пришлось придумать новый вид тестирования, который быстро интегрируется в нашу систему тестирования, заводится с полпинка на существующей инфраструктуре и легко попадёт на Jenkins.
Эти тесты создают текстовый слепок всего экрана, фиксируя текст, который услышит незрячий пользователь. Описание доступных элементов прибивается «гвоздями» в тестах для тестируемого экрана: если кто-то добавит недоступный элемент или удалит существующий, тест не пройдет и код не будет замёржен = профит.
Если вы хотите начать или уже ведёте серьёзную разработку на долгие годы, то советую начинать именно с этого. Для начала можно, как и мы, «поиграться» с первым подходом. Но затем сами поймете, что это ненадёжно.
Для такого снапшот-тестирования нужно построить дерево всех View, найти среди них доступные, вытащить из них необходимые данные (id, label, value, actions), схлопнуть всё до одного текстового значения и в конце сделать дерево плоским, получив массив текстов для каждого доступного элемента.
Сам я парсить всё не хотел и времени не было, но до меня всё это сделал вот этот замечательный «человек» — https://github.com/cashapp/AccessibilitySnapshot.
Уже неплохо, но тот вид данных, который отдаёт библиотека, неудобен для тестов, поэтому напишем свою обёртку, которая позволяет превратить UIView во что-то человекочитаемое. Для тестов мы используем Nimble, он симпатично выводит ошибки. Ещё одно «приседание», и готов «матчер», который позволяет тестировать в удобном виде любую View. Вывести любую иерархию в лог можно так: printAccessibilityHierarchySnapshot.
Результатом вызова будет такое сообщение в логе:
[
.label("Some text. Button."),
]
Дальше нужно взять эти буквы и вставить в матчер haveAccessibilityHierarchyOfElements (…)
Так теперь у нас есть код, который за разработчиков пишет тесты и сравнивает иерархию. В итоговом варианте тест начинает выглядеть вот так:
func test_проверяем_иерахию_простого_контроллера() throws {
let vc = ViewController()
self.uiTester.showChild(vc)
expect(vc.view).to(haveAccessibilityHierarchyOfElements([
.label("Some text. Button."),
]))
}
Если иерархия меняется, то тест падает с ошибкой:
Когда появились тесты, можно начинать адаптировать приложение.
Escape
В VoiceOver есть важный момент: вы можете запустить любой сценарий (открыть новый экран), но должна быть возможность выйти с этого экрана. Это то, о чём мало кто задумывается. Поэтому правило хорошего тона — если мы забыли или не успели адаптировать экран, лучшее, что мы можем сделать, — это дать пользователю возможность уйти с него.
Лучше всего для этого подходит метод accessibilityPerformEscape (а тут пример). В любой момент пользователь может сделать жест Z двумя пальцами по экрану, и iOS по responder chain найдёт первый элемент, который вернёт true. В этом методе настоятельно рекомендую реализовать способ сбежать с экрана. Очень желательно добавить реализацию этого метода в каждый ViewController, куда может попасть пользователь.
Высший пилотаж — сделать первым элементом иерархии кнопку, которая закрывает экран. Типичный пример — UINavigationController со своей кнопкой back.
![740e6e21fcf8c052a9f3b722d8b7b581.jpeg](https://habrastorage.org/r/w780q1/getpro/habr/upload_files/740/e6e/21f/740e6e21fcf8c052a9f3b722d8b7b581.jpeg)
Динамический расчёт accessibility
Мы хотим вручную управлять доступными элементами, чтобы доступным было только то, в чем мы уверены. Наша цель — собрать весь код по доступности в одном предсказуемом месте, а не распределять его по всему экрану. Это также поможет выиграть неравный бой с автоматизаторами, которые пишут доступность для роботов.
В iOS 17 появилась нативная ленивая динамическая доступность (например, вот):
/*
Block based setters take precedence over single line setters (i.e setAccessibilityLabel:(NSString *)) and property overrides (i.e. accessibilityLabel).
These methods require the block to have a specific return type that corresponds to the attribute's type.
Each of these block based setters have a corresponding accessibility property.
See the notes for the property for more specific information about that property.
*/
Но это не наш случай, нам нужна поддержка для iOS 15. Кроме того, нам необходимы проверки на isHidden и alpha. Поэтому напишем специальный блок, который возвращает предсказуемую ленивую иерархию для ViewController.
self.customAccessibilityElementsBlock = { [weak self] in
guard let self else { return nil }
/// если мы хотим вручную задать порядок элементов доступности, с учётом их видимости
/// по дефолту читаем сверху-вниз слева-направо
/// но если по какой-то причине хотим, чтобы верхний элемент был в конце, можем перемешать дефолтный порядок
return [
self.carousel,
self.label2,
self.label,
]
}
Нам это понадобилось по нескольким причинам:
Повышаем перформанс, избегая выполнения ненужной работы.
Не боимся изменение экрана — когда экран обновляется (например, зашли в интернет) или пользователь взаимодействует с ним, то могут добавляться новые элементы или что-то удалиться. Не придётся делать дополнительные действия, Accessibility-иерархия всегда в консистентном состоянии.
Делаем настройку удобной. Вся настройка Accessibility-иерархии сосредоточена в одном месте, что позволяет легко разделить код доступности для людей и роботов.
Теперь об accessibility самих элементов. Обычно нас интересуют динамические свойства (label, value, actions). Мы стараемся вычислять их лениво по необходимости, то есть в геттерах:
override var accessibilityLabel: String? {
get { "ячейка" }
set {}
}
override var accessibilityValue: String? {
get { self.label.text }
set {}
}
Если иерархия поменялась, можно дёрнуть метод:
UIAccessibility.post(notification: .layoutChanged, argument: nil)
И тогда iOS пересчитает всё дерево доступности. В аргумент можно передать любой доступный элемент, и фокус ставится на него. Если ничего не передать, то фокус останется на текущем элементе, если он остался.
Укрупнение элементов
Еще один столб доступности, который делает конструкцию более устойчивой к изменениям, — это объединение нескольких блоков текста или кнопок в одну смысловую конструкцию. Такой блок обычно удобнее в использовании, проще в поддержке и сложнее сломать.
Самый очевидный пример смыслового блока — это ячейка в таблице, которая может содержать несколько label«ов, кнопок, картинок и описаний.
![393c2a19609491ff3aa9344f38378f4c.png](https://habrastorage.org/r/w1560/getpro/habr/upload_files/393/c2a/196/393c2a19609491ff3aa9344f38378f4c.png)
Всю эту конструкцию можно превратить в один доступный элемент.
Для начала мы делаем всю ячейку доступной кнопкой, предназначенной исключительно для людей. Для UI-тестов необходимо, чтобы все элементы ячейки можно было идентифицировать по их id. После укрупнения элементов мы рискуем потерять доступ к ним. Конечно, Apple завезла нативный инструмент, который можно посмотреть тут, но он поддерживается только с iOS 17 и не соответствует требованиям наших текущих тестов. Поэтому мы используем старое доброе проверенное годами решение:
if !ProcessInfo.isUITests {
// если не тесты, то ячейка становится одной большой кнопкой
cell.isAccessibilityElement = true
cell.accessibilityTraits.formUnion(.button)
}
В нашем проекте мы применяем MVVM, и каждая view имеет свою ViewModel. Для каждой ячейки существует соответствующая ViewModel, которая содержит все сырые данные. Мы собираем все данные для всех label в ячейке в одну ленивую конструкцию, представленную в виде текста в одной большой куче, используя метод accessibilityLabel ().
var accessibilityLabel: String? {
[
self.title,
self.subtitle,
…
].accessibilityLabel()
}
Также соберём все кнопки в ячейке (если такие имеются) в такую конструкцию:
override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
get {
guard let name = self.buttonContent?.accessibilityLabel else { return nil }
return [
UIAccessibilityCustomAction(name: name) { [weak self] _ in
guard let self else { return false }
return self.performCallToAction()
},
]
}
set { _ = newValue }
}
В самой ячейке нужно переопределить методы:
override var accessibilityLabel: String? {
get { viewModel.accessibilityLabel }
set {}
}
override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
get { viewModel.accessibilityCustomActions }
set {}
}
Возможно, использование одной большой ячейки не самый удобный способ взаимодействия для пользователя, но это очень простой и универсальный подход в разработке и тестировании. Такой код легко написать, ведь есть чёткое правило: одна ячейка — один доступный элемент. Всего четыре возможных способа доставить данные в ячейку — через label, value, accessibilityCustomActions и иногда hint. Ещё можно accessibilityCustomContent для самых странных сценариев, но в данной статье я не рассматривал.
На выходе получим такой сценарий:
Сториз
Ещё один сценарий, который после Snapchat стал актуальным для каждого нового приложения — это сториз. Как правило, это один из первых элементов на экране. 2ГИС не стал исключением:
![cd5d349e67a2474c1bb309b3ece9410a.jpeg](https://habrastorage.org/r/w780q1/getpro/habr/upload_files/cd5/d34/9e6/cd5d349e67a2474c1bb309b3ece9410a.jpeg)
Если адаптировать сториз в лоб, то пользователю нужно будет пролистать примерно X историй, где X сильно больше 10, прежде чем перейти к следующему доступному элементу.
В книге «Доступность для всех» приводится пример решения такой проблемы с хорошим описанием. Также есть рекомендации Apple.
В этих референсах для таких горизонтальных списков предлагается использовать компонент AccesibilityCarousel. Однако как уважающий себя разработчик я решил написать собственный велосипед, который больше подходит под наши потребности. Этот компонент можно легко интегрировать с любым UICollectionView, чтобы он заработал как карусель. Пример использования есть в демо-проекте.
В основном приложении это выглядит так:
Нюансы, которые о которых неплохо знать
Один важный момент, о котором упомянули специалисты из Сбера: пользователи часто отключают accessibilityHint. Поэтому не следует помещать в него важную актуальную информацию, а использовать его лишь для инструкций по онбордингу для конкретных элементов, которые могут вести себя неочевидным образом.
Для разметки заголовков лучше использовать хэдеры.
![808bb392e664b838844d2e506ab2c619.jpeg](https://habrastorage.org/r/w780q1/getpro/habr/upload_files/808/bb3/92e/808bb392e664b838844d2e506ab2c619.jpeg)
Это значительно структурирует большие списки и с помощью специальных механизмов роторов можно очень быстро навигироваться: https://support.apple.com/en-us/111796.
В действии это выглядит так:
К нам приходят новые модные разработчики на SwiftUI, и мне самому приходится становится более современным. В данной статье часть принципов для SwiftUI уже не так актуальна, однако общие принципы по-прежнему применимы. К счастью, SwiftUI пока что использует UIKit под капотом, поэтому создаваемые им иерархии также понимает Snapshot-тестирование, и новый тип тестов пока что изобретать не требуется.
Приложение с плохой разметкой, без укрупнения, но с подписанными кнопками, которые можно нажимать в режиме VoiceOver, а также с возможностью уйти с недоступного экрана, гораздо лучше, чем зацикливаться на идеальном доступном приложении, которое может никогда не наступить.
Accessibility Inspector очень плох, при работе с симулятором не стоит рассчитывать на него. На девайсе он работает намного лучше. Фактически, нет ничего лучше, чем лично тестировать сценарии с закрытыми глазами или выключенным экраном на реальном устройстве.
В результате
Мы адаптировали сценарии, которые решают реальные пользовательские задачи. Например, вот так VoiceOver озвучит пункты стартового экрана приложения и помогает с навигацией по нему:
Киллер-фича (на данный момент) — это адаптация экрана транспортной выдачи под VoiceOver. Он основан на визуальных знаках и символах, которые нужно озвучивать для незрячих пользователей. Мы добавили логику, позволяющую озвучивать сложные маршруты.
Например, подсказка «M (14) → 2» будет озвучена как: «Садитесь на остановке МЦК Площадь Гагарина, выходите на остановке МЦК Кутузовская в сторону станции Лужники, из вагона налево, выход номер 2».
Начало и конец сегмента маршрута озвучиваются как «Садитесь на остановке» и «Выходите на остановке» вместо просто названия остановок.
Напоследок
Apple проделала гигантскую работу — разработала технологии, фреймворки и инструменты, которые продолжают совершенствовать каждый год. Их труд впечатляет, снимаю шляпу. Любое приложение может стать доступным для любого пользователя, адаптация необходима не только для людей с нарушением зрения.
Но в реальной жизни процесс адаптации приложения часто сталкивается с дилеммой курицы и яйца:
если никто не использует доступность, зачем её делать;
но если её не делать, никто и не начнёт использовать.
Поэтому, повторюсь, в больших коммерческих организациях адаптация приложения обычно уступает приоритетом другим фичам. Но этот круг можно разомкнуть. И потребуется настоящая отвага, чтобы довести адаптацию до конца и продолжать её поддерживать.
От стадии непонимания, как взяться за VoiceOver, мы пришли наконец в момент, когда мы выпустили версию 6.34, и начали получать благодарности от пользователей за возможность находить компании, изучать информацию о них и строить маршруты.
Многолетняя работа над этой фичей даёт о себе знать: копится усталость и чувствуется выгорание. Вот буквально на днях переписали «Избранное», и как обычно забыли про доступность.
Но понимаю, что останавливаться нельзя. До сих пор остаются ряд недоступных сценариев. И по мере сил продолжаю работать дальше: делаю VoiceOver-навигатор, который меня просили сделать.
Если вы тоже решитесь адаптировать приложение, то приходите в комменты или личные сообщения. Давайте общаться и делиться опытом!
Команда инженеров ведёт тг-канал, можно краем глаза заглянуть, почитать. Если захотите работать в нашей команде — у нас открыта вакансия iOS-разработчика.