Меня зовут Женя Тютюев, я 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 ушёл с рынка в качестве провайдера данных для транспорта, а другие российские приложения были не особо доступными. У пользователей не осталось альтернатив, и нам массово (насколько это возможно для такой небольшой аудитории) начали писать, что всё работает плохо.

Примерно в это же время я смог связаться с экспертами по тестированию доступности из Сбера. Без их помощи и советов вряд ли бы получилось двигаться дальше. Ещё неожиданно написал неравнодушный тифлопедагог (я даже не знал, что такие существуют) и добавил мотивации продолжать начатое дело.

Также к тому же моменту появился титанический труд Михаила Рубанова — спасибо ему! Его работа помогала решать типовые задачи. Крайне советую начинать онбординг в VoiceOver с этой книги. 

Благодаря всем этим факторам появились желание и силы сделать новый VoiceOver, более серьёзное и адаптированное приложение. 

Важные вводные

С точки зрения продуктовой разработки, фича VoiceOver всегда проигрывает другим задачам по всем критериям. Для неё никогда не находится время на разработку и на тестирование. Если не подталкивать всех — дизайнеров, продактов, QA, разработчиков и даже конечных пользователей — энтропия делает своё беспощадное дело. Без регулярного регресса и контроля за новыми функциями, вероятность того, что случайный сценарий станет недоступен за 2-3 недели активной разработки, неуклонно стремится к 100%. И из этих вводных важно понимать, что никто не будет проводить этот регресс примерно никогда.

И ещё один диксклеймер.

2ГИС — настоящий Nero burning ROM (только диски уже писать не умеет: кто поймет, тот поймёт). У нас на экране десятки контролов, реализованы сотни пользовательских сценариев. Есть боевой код, написанный ещё в 2012 году. В таких условиях я честно понимал, что всё адаптировать невозможно. Адаптировать надо только то, чем точно пользуются люди: данные брал на основе обратной связи.

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

Поэтому во втором очередном подходе разработку я начал с тестирования. Пришло чёткое осознание — нужны бездушные тесты, чтобы руки разработчиков, не участвующих в разработке этой фичи или написании UI-тестов на уже существующий экран, ничего больше не сломали. И было стойкое желание, чтобы эти тесты не занимали вечность (то есть не были UI-тестами, которые гоняются десятки секунд). А еще хотелось, чтобы их было супер просто писать, ещё проще поддерживать, и чтобы при их падении сразу было понятно, что пошло не так. Типичный ИКР по ТРИЗу. Кажется, что так не бывает, но на самом деле бывает.

Snapshot-тестирование 

К счастью, нам не нужно делать скриншоты экрана, ведь такие тесты не решают главной проблемы — быстро понять, что пошло не так.

Всё гораздо проще, достаточно лишь использовать простой советский...⁠⁠ 

снапшот букв, которые слышит пользователь.

Для этого пришлось придумать новый вид тестирования, который быстро интегрируется в нашу систему тестирования, заводится с полпинка на существующей инфраструктуре и легко попадёт на 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.

Динамический расчёт 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’ов, кнопок, картинок и описаний.

Всю эту конструкцию можно превратить в один доступный элемент.

Для начала мы делаем всю ячейку доступной кнопкой, предназначенной исключительно для людей. Для 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ГИС не стал исключением: 

Если адаптировать сториз в лоб, то пользователю нужно будет пролистать примерно X историй, где X сильно больше 10, прежде чем перейти к следующему доступному элементу. 

В книге «Доступность для всех» приводится пример решения такой проблемы с хорошим описанием. Также есть рекомендации Apple.

В этих референсах для таких горизонтальных списков предлагается использовать компонент AccesibilityCarousel. Однако как уважающий себя разработчик я решил написать собственный велосипед, который больше подходит под наши потребности. Этот компонент можно легко интегрировать с любым UICollectionView, чтобы он заработал как карусель. Пример использования есть в демо-проекте.

В основном приложении это выглядит так:

Нюансы, которые о которых неплохо знать

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

Для разметки заголовков лучше использовать хэдеры

Это значительно структурирует большие списки и с помощью специальных механизмов роторов можно очень быстро навигироваться: 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-разработчика.

Комментарии (4)


  1. akaDuality
    04.07.2024 16:26
    +2

    Клевая статья, спасибо! Очень понравился с примером юнит-тестирования доступности, я не понимаю почему cashapp не хотят добавить его прямо в либу.

    Для карусели у нас тоже написано очень похожий код, можно обобщить и в библиотеку вынести


    1. teanet Автор
      04.07.2024 16:26
      +1

      Спасибо! Твоя книга просто лучшая компиляция всех знаний по этой теме что я смог найти.

      По поводу карусели буквально на днях понял что ее нужно разметить как
      [.adjustable, .button, .updatesFrequently]
      на случай когда появляются ячейки c traits == .selected.
      тогда она корректно начинает обрабатывать изменение value на лету

      еще написал для карусели парсер который вытаскивает активные элементы из дерева accessibilityElements, на случай если у ячейки нет возможности прокинуть value + label напрямую


  1. ADPopko
    04.07.2024 16:26
    +4

    Евгений,
    Спасибо большое за силы и время, вложенные в адаптацию 2GIS. Доступных сервисов, содержащих актуальную информацию о различных организациях и позволяющих прокладывать маршруты (да ещё и представлять их в читабельном виде), много не бывает. Я посмотрел на приложение - действительно очень многое сделано и сделано хорошо (с точки зрения честного пользователя VoiceOver).
    Прошу прощения за свой вклад в то, что наше сотрудничество не состоялось, и присоединяюсь к словам благодарности коллегам из Сбера.


    1. teanet Автор
      04.07.2024 16:26

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

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