Русский
Русский
English
Статистика
Реклама

Ios development

Из песочницы Защищаемся от трекеров на мобильных платформах

06.08.2020 14:19:11 | Автор: admin

Отслеживание в интернете


Многие пользователи сталкиваются с отслеживанием в интернете повседневно. Одним из самых явных и крупных следствий является таргетированная реклама. Любой, кто хоть раз имел дело с такими гигантами как, например, Google AdWords, знает насколько обширны настройки целевой аудитории, доступные рекламодателю.



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


Как происходит отслеживание пользователей


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



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


Идентификация трекеров


Для идентификации трекера можно использовать существующие списки доменов (disconnectme). При отправке пакетов с устройства проверять адрес назначения, и блокировать пакеты если они должны были попасть на трекер.


Есть сложные решения (Pi-hole и другие), но нам преимущественно интересны Android и iOS.


Фильтрация трафика на Android


Один из способов конвертировать доменные имена трекеров в IP-адреса и блокировать их с помощью iptables. Принципиальная проблема такого подхода необходимость root-прав для выполнения, так как Android не дает прав на модификацию параметров брандмауэра. Но есть способ обойти это ограничение без root.


Если выделить весь трафик в отдельный виртуальный слой позволяющий фильтрацию на уровне приложения. Такой способ существует. С помощью VpnService можно создать локальный VPN, по средствам которого можно фильтровать трафик по необходимым параметрам. Для реализации мы можем создать три очереди: сеть-устройство, устройство-сеть-tcp, устройство-сеть-udp. Сырой входящий/исходящий трафик записывается в очереди. По средствам четырех объектов TcpIn, TcpOut, UdpIn, UdpOut обрабатывать пакеты, в том числе утилизируя не проходящие проверку. Пример реализации можно посмотреть на Github (LocalVPN).


Фильтрация трафика на iOS


Для фильтрации трафика будем использовать NetworkExtension.
Как и на Android, ОС не дает нам прямого доступа к настройке брандмауэра. Но при этом есть возможность задать фильтр для сетевого контента из коробки по средствам NEFilterControlProvider и NEFilterDataProvider. Мы данный вариант рассматривать не будем, так как Content Filter Providers работают только в контролируемом (supervised) режиме, из-за чего публикация такого приложения в AppStore становится невозможной. Если же такой подход интересен можно рассмотреть пример рабочего приложения на Github (sift-ios, FilterControlProvider, FilterDataProvider).


Для нашего решения мы также будем использовать локальный VPN. В NetworkExtension есть три варианта работы с VPN.


Personal VPN. Использует только встроенные протоколы. Нам это не подходит, так как необходим пользовательский протокол.


App Proxy Provider. Используется для создания пользовательского потокоориентированного VPN протокола. Для нас важна фильтрация отдельных пакетов, что приводит нас к.
Packet Tunnel Provider. Используется для создания пользовательского пакетоориентированного VPN протокола. Мы будем использовать именно его.


Для реализации мы наследуемся от NEPacketTunnelProvider. При запуске тоннеля нам необходимо указать конечную точку укажем в её качестве локальный прокси сервер, для этого можем использовать, например, GCDHTTPProxyServer, так как он используется в примере который я приведу позже. С помощью прокси сервера на выбор мы утилизируем не нужные нам пакеты. Рабочий пример приложения с использованием NEPacketTunnelProvider и GCDHTTPProxyServer на Github (lockdown-ios PacketTunnelProvider).


Проблема приватности, при использовании удаленного VPN


Во всех примерах выше опускался вариант простого подключения к удаленному VPN, на котором настроена фильтрация. Такой вариант имеет серьезные плюсы простоту создания клиентов под любую систему (на которой есть возможность использовать VPN) и возможность скрыть фильтры. Но критическим минусом является необходимость проводить трафик через сторонний удаленный сервер. Это требует наличия сетевой инфраструктуры и вызывает недоверие пользователей (так как трафик проходит через сторонний сервер для фильтрации). Данный вариант хоть и имеет свое место и значимость, но не является целью данной статьи.


Несколько слов в заключение


Сегодня мы рассмотрели способы реализации фильтров трафика для ОС Android и iOS. Рассмотрели самый не ограничивающий способ локальный VPN и основы его реализации с примерами готовых продуктов и примеров. Для этого мы воспользовались возможностью создания пользовательских протоколов VPN. С их помощью мы с прикладного уровня получили доступ к сетевому, что позволило нам применить фильтр к входящим/исходящим пакетам. Фильтр мы основываем на черных списках доменов трекеров, доступных в сети интернет.


Статья подготовлена для Telegram канала @paradiSEcurity.

Подробнее..

Keychain API в iOS

20.11.2020 14:23:38 | Автор: admin
Всем привет!
Не так давно столкнулся с необходимостью использования Keychain-а для обеспечения дополнительной защиты при входе в приложение.
Я нашел много хороших статей по этой теме, но в основном там описываются сторонние фреймворки, упрощающие жизнь, а было интересно посмотреть, как работать с API напрямую. В этой статье я попытался объединить документацию Apple с практикой на простом примере.

Начнем с небольших определений


Keychain зашифрованная база данных, куда сохраняются небольшие объемы пользовательской информации (см. документацию Apple).
Общая схема работы продемонстрирована на рисунке.
image
Keychain API Services в свои очередь являются часть фреймворка Security, но его рассмотрение требует отдельной статьи.

Добавление элемента


let keychainItemQuery = [     kSecValueData: pass.data(using: .utf8)!,     kSecClass: kSecClassGenericPassword ] as CFDictionary let status = SecItemAdd(keychainItemQuery, nil) print("Operation finished with status: \(status)")

Выше приведен пример сохранения пароля в Keychain.
Рассмотрим функцию SecItemAdd подробнее.
func SecItemAdd(_ attributes: CFDictionary,               _ result: UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus

На вход подается объект класса CFDictionary, который в свою очередь является ссылкой на неизменяемый объект словаря.
Что в это словарь входит? На самом деле его состав зависит от решаемой задачи здесь же мы просто сохраняем простой пароль, давайте разберем этот простейший запрос.
Итак, kSecClass этот ключ используется для значений хранимых элементов, список их можно посмотреть тут, мы же выбрали стандартный пароль.
kSecValueData ключ, использующийся для передачи данных элемента.
На этом обязательные ключи заканчиваются, далее идут опциональные. Список таких параметров доступен в документации.
Возвращаемое значение типа OSStatus определяет результат операции сохранения/изменения/удаления, у него так же есть масса значений.

Получение элемента


Для получения элемента из Keychain-а используется метод SecItemCopyMatching.
Формируем запрос в виде словаря, где содержится искомый пароль.

let keychainItem = [     kSecValueData: pass.data(using: .utf8)!,     kSecClass: kSecClassGenericPassword,     kSecReturnAttributes: true,     kSecReturnData: true ] as CFDictionary         var ref: AnyObject? let status = SecItemCopyMatching(keychainItem, &ref) if let result = ref as? NSDictionary, let passwordData = result[kSecValueData] as? Data {     print("Operation finished with status: \(status)")     print(result)     let str = String(decoding: passwordData, as: UTF8.self)     print(str) }

Посмотрим логи:
Operation finished with status: 0
{
accc = "<SecAccessControlRef: ak>";
acct = "";
agrp = "xxx.com.maximenko.xxx";
cdat = "2020-11-19 20:39:43 +0000";
mdat = "2020-11-19 20:39:43 +0000";
musr = {length = 0, bytes = 0x};
pdmn = ak;
persistref = {length = 0, bytes = 0x};
sha1 = {length = 20, bytes = 0xxxxxxxxxxxxxxxxxxxxxxx};
svce = "";
sync = 0;
tomb = 0;
"v_Data" = {length = 3, bytes = 0x4b656b};
}
Kek

Как мы видим, возвращен 0, символизирующий успешный результат поиска и выведен весь список атрибутов, полученных из API (Access group параметр затерт на всякий случай:)). Эти атрибуты подробно описаны тут.
Значение по ключу kSecValueData нас собственно тут интересует, успешно разворачиваем его в строку, далее выведенную терминал.

Обновление элемента


Для этого есть метод SecItemUpdate.
На вход подается 2 CFDictionary словаря в первом информация об обновляемом элементе, во втором та информация, на которую надо будет заменить старую.
let query = [     kSecClass: kSecClassGenericPassword,] as CFDictionarylet updateFields = [     kSecValueData: pass.data(using: .utf8)!] as CFDictionarylet status = SecItemUpdate(query, updateFields)

На примере простого пароля в первом запросе указываем что именно требуется найти для замены, во втором то ключ-значение, которое мы обновим в найденном элементе.

Удаление элемента


Для удаления используем SecItemDelete.
У него на входе один параметр словарь c информацией об удаляемом элементе, который надо найти.
Возвращает статус выполнения операции типа OSStatus.
let query = [     kSecClass: kSecClassGenericPassword,     kSecValueData: pass.data(using: .utf8)!] as CFDictionarylet res = SecItemDelete(query)


Подведение итогов


В данной статье рассматривается работа с Keychain-ом на примере нескольких основных методов. Если увидите какие-то неточности, ошибки или просто хотите более подробно обсудить тему, пишите в комментарии или Telegram (skipperprivate).

P.S.
Если будет интересно, можно отдельную статью посвятить работе с более сложными сущностями вроде Интернет-пароля, с большим количеством полей и т.д., или обсудить подобное в комментариях.
Подробнее..

Да кто такой этот ваш Mobile DevOps?

20.12.2020 18:08:28 | Автор: admin

Сегодня почти у каждого проекта мобильного приложения есть базовая инфраструктура: ваш код хранится на git хостинге и весь новый код регулярно проверяется на CI, чтобы не сломать старый. Если ваша команда в несколько человек производит не очень много кода, то скорее всего вы настроили весь пайплайн один раз и радуетесь его работе. Но что если ваша мобильная команда это пара десятков или даже сотен разработчиков? Много разработчиков производят много кода, что создает нагрузку на инфраструктуру, увеличивает вероятность сломать важные флоу приложения и просто навсего унести время сборки проекта в небеса. Единожды кем-то настроенный пайплайн обречен на изменения, если он не справляется с нагрузкой.

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

Mobile DevOps существует, даже если вы о нем не знаете

Говорить на тему DevOps особенно в направлении Mobile можно сколько угодно. Это тема богата на детали, обсуждения, вопросы, альтернативы и многое другое. Можно прочитать море определений понятия DevOps, попытаться интегрировать сюда мобильную разработку, но так до конца и не понять что же это такое. Поэтому я приготовил для вас небольшой рассказ, который поможет понять что это за зверь - Mobile DevOps. А после самого рассказа, в ходе которого вы сами найдете ответы на вопросы, я дам немного пояснений, который прольют еще больше света на эту интересную тему. Устраивайтесь поудобнее и наслаждайтесь рассказом.

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

Представьте как выглядит обычный рабочий день Android разработчика Миши в компании Galera Development: Миша пришел в офис (или просто встал с кровати, ееее удаленка), приготовил себе чашечку кофе и сел за работу. Сегодня у него отличное настроение и работа обещает быть бодрой. Для разминки Миша берет задачу на простой фикс бага, быстро с ней справляется и создает пул реквест в develop. Остается дождаться успешной сборки на CI и пул реквест можно заливать. Миша на 200% уверен, что сборка пройдет успешно, ведь баг был прост, и он локально все проверил.

Но как оно иногда бывает - не все идет по плану. Сборка на пул реквест Миши почему-то не собралась. Наш герой подумал, что это какая-то разовая акция и перезапустил свою сборку. Проходит пара минут и Миша получает опять упавшую сборку. Что поделать, придется идти разбираться. Миша идет на CI в логи своей сборки и видит ошибку, которая совсем не похожа на те, что пишет Android Studio. Что-то на эльфийском - подумал он, но пул реквест заливать надо и надо как-то получить успешную сборку на CI. Миша слышал раньше, что настраивал CI его коллега Паша, и он наверняка должен знать как совладать с этим зверем.

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

У Миши уже настроение не настолько прекрасно как было утром, но он идет и перезапускает сборку. Через пару минут сборка действительно проходит, бодрый настрой и мотивация возвращаются к Мише, он заливает свой пул реквест и сообщает Паше, что сборка прошла и все в его жизни прекрасно. Обошлось - подумал Паша.

Проходит час, и на почту Паше пишут уже 5 разработчиков, которые тоже знают, что Паша маг и знает что происходит на CI. У них такая же история как и у Миши час назад - падают сборки. Что делать? Паша сейчас занят важной фичей, которая принесет компании несколько сотен миллионов в первую же неделю релиза. Еще один человек знает как устроен этот ваш зверь CI, но теперь он руководит небольшой командой IOS разработчиков в другой компании. Да и если Паше бросать свою работу над фичей и погружаться в проблему на CI, то на обнаружение проблемы и ее решение уйдет много времени, ведь Паша сам уже не помнит когда в последний раз заглядывал на CI и смутно помнит как там все устроено.

Паша решает сообщить о возникшей ситуации своему руководителю Антону. Так как надо много чего обсудить, они договариваются на созвон.

Антон: Привет! Ну как там с билдами?

Паша: Привет! Все плохо, я посмотрел логи, но сразу ничего не могу сказать, надо садиться и разбираться, но у меня на это совсем нет времени. Я занят сам знаешь какой фичей и мне не вариант откладывать работу по ней.

Антон: А еще не вариант тормозить всю разработку проблемой на CI! Предлагаю следующее: ты сейчас разбираешься с проблемой на CI, а я подключаю к тебе на разработку фичи еще 2 разработчиков, годится?

Паша: Другого варианта не вижу, так и поступим.

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

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

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

Когда он полгода назад поднимал эти ресурсы для команды мобильной разработки все летало, и команда на руках его носила за проделанную работу. Паша пытается понять причину упора в потолок по ресурсам, что такого произошло, что именно сегодня начали всплывать такие проблемы. Все оказалось просто: за последние 2 месяца в команду мобильной разработки пришло пара десятков новых разработчиков. Разумеется, команда стала писать больше кода, который надо собирать на CI, что и дало нагрузку, пик которой пришелся на сегодня. Вот это денек! Паша прикинул сколько ресурсов дополнительно потребуется и создал заявку на ресурсы администраторам серверов.

А еще Паша заметил, что время сборки проекта стало в 4 раза больше! Он когда-то для проекта занимался оптимизацией времени, но сделал лишь часть от того что знал и планировал, очередные горячие фичи были приоритетнее. Паша тогда сделал лишь те оптимизации, которые существенно ускоряют сборку, а те, что ускоряют не сильно, или будут ускорять, только если проект будет намного больше - не сделал. Об этом он сейчас вспомнил и отметил себе, чтобы рассказать Антону. Так и подошел конец рабочего дня. Паша звонит Антону и рассказывает об успехах.

А вы тоже уже на этом моменте подумали как будет затратно компании если Паша уйдет?

Антон: Как успехи?

Паша: Я разобрался с причиной: мы уперлись в потолок по ресурсам. Я уже завел заявку админам, скоро нам должны выделить еще немного ресурсов, что стабилизирует сборки.

Антон: Круто! Спасибо тебе, выручил. Теперь можешь спокойно возвращаться к работе над своей фичей.

Паша: На самом деле есть еще кое-что. Я посмотрел как изменилось время сборки в последнее время - оно сильно выросло. Теперь вместо прежних 10 минут это 45 минут. Я думаю с этим надо что-то делать.

Антон: Ну и кода у нас стало больше. Скорее всего это закономерно.

Паша: Не соглашусь с тобой. У нас есть еще большой задел для оптимизации. Я могу провести ряд работ, но это займет некоторое время, чтобы провести оптимизации и протестировать их работу.

Антон: Даже если ты сейчас вернешься к работе над нашей фичей, то после нее тебя ждет другая, у нас очень много работы. Боюсь, оптимизацию придется отложить. Есть возражения?

Паша: Да. Сейчас расскажу почему. У нас работает 50 Android разработчиков, которые собирают свои пул реквесты на CI да и локально тоже, но сейчас не об этом. Сейчас сборка занимает примерно 45 минут. Я не говорю, что смогу ее ускорить до 10 прежних минут. Но проходить минут за 30 она точно будет, а в некоторых случаях и быстрее. То есть я предлагаю сэкономить на каждой сборке по 15 минут. В среднем разработчик собирает на CI по 2 пул реквеста в день. Это 0.5 часа в день экономии времени разработчика на ожидание сборки на CI и 0.5 часа занимаемых ресурсов. Ладно, отложим в сторону ресурсы и сосредоточимся на времени разработчиков: у нас 50 разработчиков, которые могут в день экономить по полчаса своего времени, то есть за месяц вся команда Android разработки сэкономит 50 разработчиков * 20 рабочих дней * 0.5 часа экономии = 500 часов разработки. Планирую я на это дело потратить примерно месяц, то есть 160 часов. Как видишь, заняться оптимизацией сейчас это просто отличная возможность сэкономить 500 часов Android разработки каждый месяц. Неплохо, не правда ли? Плюс, я могу покопаться со сборкой IOS проекта, думаю, там тоже есть большой задел для оптимизаций.

Антон: Ты заставил меня задуматься над этим. Давай я все обдумаю и созвонимся завтра. Решим что делать.

Паша: Договорились. До завтра.

Антон: Пока.

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

Наступает следующий день. Паша садится с кружкой кофе (но уже покрепче в 2 раза чем вчера) и начинает смотреть почту. В целом ничего интересного: поставили на ревью, накинули замечаний в его пул реквест с небольшим баг фиксом, уведомление об обновлении версии CI завтра, корпоративные новости, день рождение коллеги Погоди, что? Обновление версии CI завтра?! Паша понял, что без внимания этот момент оставить нельзя. Совсем неизвестно как это повлияет на его текущую конфигурацию, которая теперь более менее стабильно собирает проект, а еще не известно как повлияет на интеграцию CI с другими сервисами инфраструктуры. Похоже придется провести ряд проверок, которые займут неизвестно сколько времени, а обновление уже завтра. Вот и еще одна тема для обсуждения с Антоном.

Антон: Доброе утро! Я тут все обдумал, и, кажется, ты прав, нам стоит провести ряд оптимизаций.

Паша: Привет! Это отличная новость. Когда приступать?

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

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

Антон: Да, конечно. Более того, я подумал над всеми этими задачами, заглянул в наш бэклог и нашел еще несколько задач на оптимизацию наших процессов, которые могут тоже неплохо сэкономить нам ресурсы разработчиков. Я прикинул, затраты на выполнение этих инфраструктурных задач окупятся даже в ближайший квартал! Плюс, я планирую обсудить задачи такого типа еще и с IOS командой, у них наверняка тоже что-то есть. Поэтому я сделал запрос на найм к нам в команду еще одного инженера, который бы помог организовать работу нашей инфраструктуры. В общем решили искать Mobile DevOps инженера, если можно так выразиться конечно. Будем надеяться что получится найти такого, хоть и на рынке Mobile DevOps инженеров единицы. Ну а пока ты ответственный за работу нашей инфраструктуры, можешь довести до конца свои текущие Android задачи и заняться инфраструктурой. Если все-таки не получится нанять Mobile DevOps a, то тебе нужно будет взять кого-то к себе в команду, кто хотел бы заниматься инфраструктурой и ввести в курс дела. Что скажешь насчет всего этого?

Паша: Звучит здорово. Я в деле. При таком раскладе у меня есть срочная инфраструктурная задача: от админов пришло письмо, в котором сообщается об обновлении нашего CI, надо срочно проверить что в новой версии все будет работать по прежнему и всякие интеграции тоже. Планирую ей сейчас заняться.

Антон: Спасибо, что заметил это письмо и понял чем оно может для нас обернуться. Тогда не буду тебя отвлекать. О деталях твоих новых рабочих процессов поговорим позже. Поздравляю, теперь ты Mobile DevOps, ахахаха!

Паша: Договорились. Спасибо! Хорошего дня!

Антон: Спасибо, и тебе!

И Паша отправился непростой и интересной дорогой Mobile DevOps инженера!

Что я только что прочитал?

Надеюсь, я справился со своей задачей, прочитав этот небольшой рассказ вы нашли в нем ответы на вопросы:

  1. Что такое Mobile DevOps?

  2. Какие проблемы Mobile DevOps решает?

  3. Как Mobile DevOps решает проблемы?

  4. Как получить Mobile DevOps инженера в команду?

  5. Как стать Mobile DevOps инженером?

Но я все-таки отвечу на каждый вопрос более развернуто. То что вы прочитали должно дать плотный фундамент для понимания темы Mobile DevOps и этих вопросов конкретно.

Что такое Mobile DevOps?

Здесь я не скажу ничего особо отличающегося от понятия классического DevOps. Это все так же Development и Operations, но в области мобильной разработки. Инженер, занимающийся Mobile DevOps, занимается не только разработкой и поддержкой инфраструктуры (как мы увидели в рассказе мага Пашу, который умел колдовать на CI, оптимизировать сборку с помощью магического Gradle и многое другое), но и совершенствованием процессов мобильных команд (когда Паша начал продумывать как проверять UI тесты для нового кода коллег, чтобы не сломать существующий код).

Про первую часть Development вам всегда будет напоминать огромный список задач в бэклоге, про нее вы никогда не забудете. А вот про вторую часть действительно стоит помнить. Главная цель нашего Mobile DevOps - обеспечивать команде эффективную и комфортную работу. Мы должны прислушиваться к команде, слушать когда они говорят о своих болях и пытаться их решить инструментами, которые мы можем даже пока и не знать.

Mobile DevOps инженер должен хорошо знать как работает команда, понимать процессы и видеть уязвимые места, которые стоит усилить. Чтобы всегда быть в курсе процессов, возможно, не помешает участие в общих ретроспективах, чтобы не упустить какое-то важное изменение в процессах команды или услышать обратную связь по текущим процессам.

Какие проблемы Mobile DevOps решает?

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

  1. CI. Создание CI сервера, его конфигурирование, общение с админами по поводу обеспечения сервера ресурсами. Настройка сборок для всех проектов команды.

  2. Оптимизация сборок. Конфигурирование системы сборки (например, Gradle для Android) проекта для обеспечения наилучшего времени сборки как локально, так и на CI. Обеспечение эффективного использования кэша сборки.

  3. Автоматические проверки кода. Это могут быть простые тулзы-анализаторы код стайла типа Sonar или Detekt, а может что-то посложнее и самописное в виде Gradle тасок, например, таска для проверки соответствия правилам зависимостей проекта.

  4. Прогон UI тестов на CI. Обеспечение работы набора устройств или эмуляторов и обеспечение возможности прогона на них UI тестов.

  5. Impact analysis. Анализ измененного кода для получения списка затронутых UI тестов, которые надо прогнать, чтобы убедиться, что в новом коде разработчик не сломал старый.

  6. Интеграция сервисов инфраструктуры. Обеспечение стабильной интеграции сервисов инфраструктуры типа CI, git-хостинга кода и других сервисов.

  7. Release Train. Автоматизация процесса релиза вплоть до автоматического деплоя бандла в Play Console и раскатки его в продакшн.

Это я привел лишь основные направления, по которым требуются усовершенствования для любой крупной команды. Но у каждой команды есть свои особенности в процессах, из-за которых может появиться еще больше направлений для работы Mobile DevOps инженера.

Как Mobile DevOps решает проблемы?

Тут все по классике: чтобы решить проблему ее нужно понять. Как я уже говорил ранее - понимание процессов команды невероятно важно в работе Mobile DevOps инженера. Это поможет выявить слабые места в процессах и усилить их.

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

Вот примеры инструментов, с помощью которых можно решить инфраструктурные и процессные проблемы:

  1. Gradle таски, которые будут выполняться в нужный момент (для Android)

  2. Gradle плагины (для Android)

  3. Скрипты, которые могут выполняться как вручную, так и автоматически, например, на CI

  4. Шаги сборки на CI, которые могут выполнять абсолютно разные действия

  5. Создание клиент-серверных приложений на различном стеке технологий

  6. Контейнеризация для изоляции ваших сервисов, автоматизации их развертки и конфигурирования

  7. Виртуализация для работы с эмуляторами

  8. Создание плагинов для любого из сервисов вашей инфраструктуры, если есть открытое API

И это только примеры. Разумеется, инструменты могут быть невероятно различными.

Как получить Mobile DevOps инженера в команду?

Как вы уже поняли по рассказу, вероятно, у вас уже есть человек, который знает как работает ваша инфраструктура, он умеет решать какие-то критические проблемы. Если он имеет желание заниматься инфраструктурой и процессами вашей команды, то вам повезло и у вас есть Mobile DevOps инженер, который готов решить ваши боли.

Но хочу отметить, что ваш новоиспеченный Mobile Devops только вчера занимался парсингом jsonа и передвигал кнопки, поэтому не стоит требовать решения проблем в совсем ближайшее время. Специфика работы и инструменты решения проблем Mobile DevOps сильно отличается от Android или IOS разработки, и развитие вашего инженера будет постепенным. Через какое-то время (это может быть полгода, год, все очень ситуативно) ваш инженер будет действительно способен решать ваши проблемы быстро и качественно. И вы будете счастливы и горды, что в вашей команде есть такой специалист.

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

Сколько нужно Mobile DevOps инженеров?

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

А почему бы просто не привлечь настоящего DevOps?

Как я уже говорил ранее, DevOps это не только про инфраструктуру, но и про процессы. Человек, который варился много времени в процессах Android или IOS команд, хорошо понимает специфику работы и отлично понимает боли команд, потому что сам их скорее всего испытывал. Да, он пока не так хорош в инфраструктуре, как обычный DevOps, но все это можно изучить и наверстать. Сам опыт процессов невероятно важен и если вы получите к себе в команду Mobile DevOps (обучите своего или, если повезет, найдете на рынке), то станете действительно богаты в плане ресурсов инженеров.

Что делать, если вы хотите стать Mobile DevOps инженером?

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

Заключение

Спасибо вам, дорогие читатели за внимание! Надеюсь, прочитав статью, вы получили все полезные знания, которые я постарался вложить в нее. А также приобрели мотивацию задуматься о ваших процессах и о концепции Mobile DevOps для ваших команд. Если мне все это удалось, то я очень рад, что смог вам помочь найти путь джедая Mobile DevOps!

Естественно, я приглашаю вас обсудить возникшие в ходе прочтения статьи вопросы в комментарии :)

Подробнее..

Настало время офигительных историй. Кастомные транзишены в iOS. 22

07.04.2021 12:05:29 | Автор: admin

В прошлой статье мы реализовали анимацию ZoomIn/ZoomOut для открытия и закрытия экрана с историями.

В этот раз мы прокачаем StoryBaseViewController и реализуем кастомные анимации при переходе между историями.

Навигация между историями

Давайте сделаем анимацию для переходов между историями.

enum TransitionOperation {    case push, pop}public class StoryBaseViewController: UIViewController {        // MARK: - Constants    private enum Spec {        static let minVelocityToHide: CGFloat = 1500                enum CloseImage {            static let size: CGSize = CGSize(width: 40, height: 40)            static var original: CGPoint = CGPoint(x: 24, y: 50)        }    }        // MARK: - UI components    private lazy var closeButton: UIButton = {        let button = UIButton(type: .custom)        button.setImage(#imageLiteral(resourceName: "close"), for: .normal)        button.addTarget(self, action: #selector(closeButtonAction(sender:)), for: .touchUpInside)        button.frame = CGRect(origin: Spec.CloseImage.original, size: Spec.CloseImage.size)        return button    }()        // MARK: - Private properties    // 1    private lazy var percentDrivenInteractiveTransition: UIPercentDrivenInteractiveTransition? = nil    private lazy var operation: TransitionOperation? = nil        // MARK: - Lifecycle    public override func loadView() {        super.loadView()        setupUI()    }    }extension StoryBaseViewController {        private func setupUI() {        // 2        let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))        panGestureRecognizer.delegate = self        view.addGestureRecognizer(panGestureRecognizer)        view.addSubview(closeButton)    }        @objc    private func closeButtonAction(sender: UIButton!) {        dismiss(animated: true, completion: nil)    }    }// MARK: UIPanGestureRecognizerextension StoryBaseViewController: UIGestureRecognizerDelegate {        @objc    func handlePanGesture(_ panGesture: UIPanGestureRecognizer) {        handleHorizontalSwipe(panGesture: panGesture)    }        // 3    private func handleHorizontalSwipe(panGesture: UIPanGestureRecognizer) {                let velocity = panGesture.velocity(in: view)        // 4 Отвечает за прогресс свайпа по экрану, в диапазоне от 0 до 1        var percent: CGFloat {            switch operation {            case .push:                return abs(min(panGesture.translation(in: view).x, 0)) / view.frame.width                            case .pop:                return max(panGesture.translation(in: view).x, 0) / view.frame.width                            default:                return max(panGesture.translation(in: view).x, 0) / view.frame.width            }        }                // 5        switch panGesture.state {        case .began:            // 6            percentDrivenInteractiveTransition = UIPercentDrivenInteractiveTransition()            percentDrivenInteractiveTransition?.completionCurve = .easeOut                        navigationController?.delegate = self            if velocity.x > 0 {                operation = .pop                navigationController?.popViewController(animated: true)            } else {                operation = .push                                let nextVC = StoryBaseViewController()                nextVC.view.backgroundColor = UIColor.random                navigationController?.pushViewController(nextVC, animated: true)            }                    case .changed:            // 7            percentDrivenInteractiveTransition?.update(percent)                    case .ended:            // 8            if percent > 0.5 || velocity.x > Spec.minVelocityToHide {                percentDrivenInteractiveTransition?.finish()            } else {                percentDrivenInteractiveTransition?.cancel()            }            percentDrivenInteractiveTransition = nil            navigationController?.delegate = nil                    case .cancelled, .failed:            // 9            percentDrivenInteractiveTransition?.cancel()            percentDrivenInteractiveTransition = nil            navigationController?.delegate = nil                    default:            break        }    }    }
  1. Чтобы наша анимация была интерактивной и следовала за движением пальца, мы создаем объект percentDrivenInteractiveTransition. А operation отвечает за тип перехода (push или pop).

  2. Добавляем наш жест во view.

  3. Реализуем обработчик нажатия/свайпа.

  4. percent отвечает за прогресс свайпа по экрану в диапазоне от 0 до 1.

  5. В зависимости от состояния жеста конфигурируем наши свойства.

  6. Как только начинается новый жест, создаем свежий экземпляр UIPercentDrivenInteractiveTransition и сообщаем делегату navigationControllerа, что мы самостоятельно его реализуем (реализация будет ниже). Если направление свайпа положительное, то мы сохраняем в переменную operation значение.pop, и сообщаем navigationControllerу, что мы начали процесс перехода с анимацией .navigationController?.popViewController(animated: true). Аналогично делаем для.push-перехода.

  7. Когда наш свайп уже активен, мы передаем его прогресс в percentDrivenInteractiveTransition.

  8. Если мы просвайпили более половины экрана, или это было сделано с скоростью более 1500, то мы завершаем наш переход percentDrivenInteractiveTransition?.finish(). В противном случае отменяем переход. При этом необходимо очистить percentDrivenInteractiveTransition и navigationController?.delegate.

  9. В случае отмены свайпа мы также отменяем переход и очищаем значения.

Сейчас при начале свайпа нужно сообщить navigationControllerу, что мы реализуем делегат navigationController?.delegate = self. Но мы этого так и не сделали. Самое время:

// MARK: UINavigationControllerDelegate    extension StoryBaseViewController: UINavigationControllerDelegate {        // 1    public func navigationController(        _ navigationController: UINavigationController,        animationControllerFor operation: UINavigationController.Operation,        from fromVC: UIViewController,        to toVC: UIViewController    ) -> UIViewControllerAnimatedTransitioning? {                switch operation {        case .push:            return StoryBaseAnimatedTransitioning(operation: .push)                    case .pop:            return StoryBaseAnimatedTransitioning(operation: .pop)                    default:            return nil        }    }        // 2    public func navigationController(        _ navigationController: UINavigationController,        interactionControllerFor animationController: UIViewControllerAnimatedTransitioning    ) -> UIViewControllerInteractiveTransitioning? {            return percentDrivenInteractiveTransition    }    }
  1. Этот метод возвращает аниматор для соответствующего перехода.

  2. Возвращаем объект типа UIPercentDrivenInteractiveTransition, который отвечает за прогресс интерактивного перехода.

Аниматор

Наконец-то реализуем аниматор, который непосредственно отвечает за поведение перехода.

Нам необходимы два метода делегата, отвечающие за продолжительность анимации и сам переход.

class StoryBaseAnimatedTransitioning: NSObject {        private enum Spec {        static let animationDuration: TimeInterval = 0.3        static let cornerRadius: CGFloat = 10        static let minimumScale = CGAffineTransform(scaleX: 0.85, y: 0.85)    }        private let operation: TransitionOperation        init(operation: TransitionOperation) {        self.operation = operation    }    }extension StoryBaseAnimatedTransitioning: UIViewControllerAnimatedTransitioning {        // http://fusionblender.net/swipe-transition-between-uiviewcontrollers/    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {                /// 1 Получаем view-контроллеры, которые будем анимировать.        guard            let fromViewController = transitionContext.viewController(forKey: .from),            let toViewController = transitionContext.viewController(forKey: .to)        else {            return        }                /// 2 Получаем доступ к представлению, на котором происходит анимация (которое участвует в переходе).        let containerView = transitionContext.containerView        containerView.backgroundColor = UIColor.clear                /// 3 Закругляем углы наших view при переходе.        fromViewController.view.layer.masksToBounds = true        fromViewController.view.layer.cornerRadius = Spec.cornerRadius        toViewController.view.layer.masksToBounds = true        toViewController.view.layer.cornerRadius = Spec.cornerRadius                /// 4 Отвечает за актуальную ширину containerView        // Swipe progress == width        let width = containerView.frame.width        /// 5 Начальное положение fromViewController.view (текущий видимый VC)        var offsetLeft = fromViewController.view.frame        /// 6 Устанавливаем начальные значения для fromViewController и toViewController        switch operation {        case .push:            offsetLeft.origin.x = 0            toViewController.view.frame.origin.x = width            toViewController.view.transform = .identity                    case .pop:            offsetLeft.origin.x = width            toViewController.view.frame.origin.x = 0            toViewController.view.transform = Spec.minimumScale        }                /// 7 Перемещаем toViewController.view над/под fromViewController.view, в зависимости от транзишена        switch operation {        case .push:            containerView.insertSubview(toViewController.view, aboveSubview: fromViewController.view)                    case .pop:            containerView.insertSubview(toViewController.view, belowSubview: fromViewController.view)        }                // Так как мы уже определили длительность анимации, то просто обращаемся к ней        let duration = self.transitionDuration(using: transitionContext)                UIView.animate(withDuration: duration, delay: 0, options: .curveEaseIn, animations: {                    /// 8. Выставляем финальное положение view-контроллеров для анимации и трансформируем их.            let moveViews = {                toViewController.view.frame = fromViewController.view.frame                fromViewController.view.frame = offsetLeft            }            switch self.operation {            case .push:                moveViews()                toViewController.view.transform = .identity                fromViewController.view.transform = Spec.minimumScale                            case .pop:                toViewController.view.transform = .identity                fromViewController.view.transform = .identity                moveViews()            }                    }, completion: { _ in                        ///9.  Убираем любые возможные трансформации и скругления            toViewController.view.transform = .identity            fromViewController.view.transform = .identity                        fromViewController.view.layer.masksToBounds = true            fromViewController.view.layer.cornerRadius = 0            toViewController.view.layer.masksToBounds = true            toViewController.view.layer.cornerRadius = 0                 /// 10. Если переход был отменен, то необходимо удалить всё то, что успели сделать. То есть необходимо удалить toViewController.view из контейнера.            if transitionContext.transitionWasCancelled {                toViewController.view.removeFromSuperview()            }                        containerView.backgroundColor = .clear            /// 11. Сообщаем transitionContext о состоянии операции            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)        })            }        // 12. Время длительности анимации    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {        return Spec.animationDuration    }
  1. Получаем view-контроллеры, которые будем анимировать.

  2. Получаем доступ к представлению containerView, на котором происходит анимация (участвующее в переходе).

  3. Закругляем углы наших view при переходе.

  4. width отвечает при анимации за актуальную ширину containerView.

  5. offsetLeft начальное положение fromViewController.

  6. Конфигурируем начальное положение для экранов.

  7. Перемещаем toViewController.view над/под fromViewController.view, в зависимости от перехода.

  8. Выставляем финальное положение view-контроллеров для анимации и трансформируем их.

  9. Убираем любые возможные трансформации и скругления.

  10. Если переход был отменен, то необходимо удалить всё то, что успели сделать. То есть необходимо удалить toViewController.view из контейнера.

  11. Сообщаем transitionContext о состоянии перехода.

  12. Указываем длительность анимации.

Всё, наш аниматор готов. Теперь запускаем проект и наслаждаемся результатом. Анимации работают.

Весь исходный код можете скачать тут. Буду рад вашим комментариям и замечаниям!

Подробнее..

Детальный разбор навигации в Flutter

24.07.2020 14:12:14 | Автор: admin

Навигация во Flutter


image


Flutter набирает популярность среди разработчиков. Большенство подходов впостроении приложений уже устоялись иприменяются ежедневно вразработке E-commerce приложений. Тема навигации опускают навторой или третий план. Какой API навигации предоставляет Фреймворк? Какие подходы выработаны? Как использовать эти подходы иначто они годятся?


Введение


Начнём с того, что такое навигация? Навигация это метод который позволяет перемещаться между пользовательским интерфейсом с заданными параметрами.
К примеру в IOS мире организовывает навигацию UIViewController, а в Android Navigation component. А чтопредоставляет Flutter?



Экраны в Flutter называются route. Для перемещениями между route существует класс Navigator который имеющий обширный API для реализации различных видов навигации.



Начнём спростого.Навигация нановый экран(route) вызывается методом push() который принимает всебя один аргумент это Route.


Navigator.push(MaterialPageRoute(builder: (BuildContext context) => MyPage()));

Давайте детальнее разберёмся ввызове метода push:


Navigator Виджет, который управляет навигацией.
Navigator.push() метод который добавляет новый route виерархию виджетов.
MaterialPageRoute() Модальный route, который заменяет весь экран адаптивным кплатформе переходом анимации.
builder обязательный аргумент конструктора MaterialPageRoute, который возвращает пользовательский интерфейс Фреймворк для отрисовки.
[MyPage](https://miro.medium.com/max/664/1Xm96KtLeIAAMtAYWcr1-MA.png)* пользовательский интерфейс реализованный при помощи Stateful/Stateless Widget


Возвращение на предыдущий route


Для возвращения с экрана на предыдущий необходимо использовать метод pop().


Navigator.pop();

Переда данных между экранами


Данные на новый экран передаются через конструктор при создании экрана в builder методе. Этот принцип работает в методах Navigator, где нужно реализовать метод build.


Navigator.push(context, MaterialPageRoute(builder: (context) => MyPage(someData: data)));

В примере продемонстрирована передача данных в класс MyPage (в этом классе хранится пользовательский интерфейс).


Для того чтобы передать данные на предыдущий экран нужно вызвать метод pop() и передать опциональным аргументом туда данные.


Navigator.pop(data);


Состояние виджета Navigator, который вызван внутри одного из видов MaterialApp/CupertinoApp/WidgetsApp. State отвечает за хранение истории навигации и предоставляет API для управления историей.
Базовые методы навигации повторяют структуру данных Stack. В диаграмме можно наблюдать методы и "call flow" NavigatorState.


http://personeltest.ru/aways/habrastorage.org/webt/5w/dg/nb/5wdgnb-tjlngub4c8y4rlpqkeqi.png


Императивный vs Декларативный подход в навигации


Во всех примерах которые были приведены выше использовался императивный подход в навигации. Разница между императивным и декларативным подходом в том как будет выглядеть вызов нового route, и кто будет хранить реализацию перехода.


Давайте на простом примере:


Императивный подход , отвечает на вопрос как?
Пример: Я вижу, что тот угловой столик свободен. Мы пойдём туда и сядем там.


Декларативный подход, отвечает на вопрос что?
Пример: Столик для двоих, пожалуйста.


Для более глубокого понимания разницы советую прочитать эту статью Imperative vs Declarative Programming


Императивная навигация


Вернёмся к реализации навигации. В императивном подходе описывается детали работы в вызывающем коде. В нашем случае это поля Route. В Flutter много типов route, например MaterialPageRoute и CupertinoPageRoute. Например в CupertinoPageRoute задаётся title, или settings.


Пример:


Navigator.push(    context,    CupertinoPageRoute<T>(        title: "Setting",        builder: (BuildContext context) => MyPage(),        settings: RouteSettings(name:"my_page"),    ),);

Этот код и знания о новом route будут хранитьсяв ViewModel/Controller/BLoC/ У этот подхода существует недостаток.


Представим что потребовалось внести изменения в конструкторе в MyPage или в CupertinoPageRoute. Нужно искать каждый вызов метода push в проекте и изменять кодовую базу.


Вывод:


Этот подход не имеет единообразный подход к навигации, и знание о реализации route проникает в бизнес логику.

Декларативная навигация


Принцип декларативной навигации заключается в использовании декларативного подхода в программировании. Давайте разберём на примере.


Пример:


Navigator.pushNamed(context, '/my_page');

Принцип императивной навигации выглядит куда проще. Говорите "Отправь пользователя на экран настроек" передавая путь одним из аргументов навигации.
Для хранении реализации роста в самом Фреймворке предусмотрен механизм у MaterialApp/CupertinoApp/WidgetsApp. Это 2 колбэка onGenerateRoute и onUnknownRoute отвеспющие за хранение деталей реализации.


Пример:


MaterialApp(    onUnknownRoute: (settings) => CupertinoPageRoute(      builder: (context) {                return UndefinedView(name: settings.name);            }    ),  onGenerateRoute: (settings) {    if (settings.name == '\my_page') {      return CupertinoPageRoute(                title: "MyPage",                settings: settings,        builder: (context) => MyPage(),      );    }        // Тут будут описание других роутов  },);

Разберёмся подробнее в реализации:
Метод onGenerateRoute данный метод срабатывает когда был вызван Navigator.pushNamed(). Метод должен вернуть route.
Метод onUnknownRoute срабатывает когда метод onGenerateRoute вернул null. должен вернуть дефолтный route, по аналогии с web сайтами 404 page.


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

Диалоговые и модальные окна


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


Методы для вызова диалоговых и модальных окон:


  • showAboutDialog
  • showBottomSheet
  • showDatePicker
  • showGeneralDialog
  • showMenu
  • showModalBottomSheet
  • showSearch
  • showTimePicker
  • showCupertinoDialog
  • showDialog
  • showLicensePage
  • showCupertinoModalPopup

Эти методы покрывают базовые потребности разработчиков которые хотят работать с окнами. Если не хватает этого API, тогда стоит разобраться как эти методы работают.


Как работает это под капотом?


Давайте рассмотрим исходный код одного из методов, например showGeneralDialog.


Исходный код:


Future<T> showGeneralDialog<T>({  @required BuildContext context,  @required RoutePageBuilder pageBuilder,  bool barrierDismissible,  String barrierLabel,  Color barrierColor,  Duration transitionDuration,  RouteTransitionsBuilder transitionBuilder,  bool useRootNavigator = true,  RouteSettings routeSettings,}) {  assert(pageBuilder != null);  assert(useRootNavigator != null);  assert(!barrierDismissible || barrierLabel != null);  return Navigator.of(context, rootNavigator: useRootNavigator).push<T>(_DialogRoute<T>(    pageBuilder: pageBuilder,    barrierDismissible: barrierDismissible,    barrierLabel: barrierLabel,    barrierColor: barrierColor,    transitionDuration: transitionDuration,    transitionBuilder: transitionBuilder,    settings: routeSettings,  ));}

Давайте детальнее разберёмся в устройстве этого метода. showGeneralDialog вызывает метод push у NavigatorState с _DialogRoute(). Нижнее подчёркивание обозначает что этот класс приватный и используется только в пределах области видимости в которой сам описан, то есть в пределах этого файла.


Диалоговые и модальные окна которые отображаются при помощи глобальных методов это кастомные route которые реализованы разработчиками Фреймворка.

Типы route в фреймворке


Теперь понятно что"every thins is a route", то есть что связанное с навигацией. Давайте взглянем на то, какие route уже реализованы в Фреймворке.


Два основных route в Flutter это PageRoute и PopupRoute.


PageRoute Модальный route, который заменяет весь экран.
PopupRoute Модальный route, который накладывает виджет поверх текущего route.


Реализации PageRoute:


  • MaterialPageRoute
  • CupertinoPageRoute
  • _SearchPageRoute
  • PageRouteBuilder

Реализации PopupRoute:


  • _ContexMenuRoute
  • _DialogRoute
  • _ModalBottomSheetRoute
  • _CupertinoModalPopupRoute
  • _PopupMenuRoute

Реализация PopupRoute приватна и скрыты для внешних потребителей, но роуты используют глобальные методы для показа диалоговых и модальных окон.


Вывод:


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

Best practices


В этой части статьи можно задаться вопросом, "а насколько мне всё это нужно?". Ответить на этот вопрос можно легко, если у есть желание сделать масштабируемый API навигации которая в любой момент будет модифицирована под нужды проекта, то это один из вариантов. Если приложение будет в будущем расширяться со сложной логикой.


Начнём с того что мы сделаем некий сервис который будет будет соблюдать следующим аспектам:


  • Декларативный вызов навигации.
  • Отказ от использования BuildContext для навигации (Это критично если сервис навигации будет вызываться в компонентах, в которых нет возможности получить BuildContext).
  • Модульность. Можно вызвать любой route, CupertinoPageRoute, BottomSheetRoute, DialogRoute и т.д.

Для нашего сервиса навигации нам понадобится интерфейс:


abstract class IRouter {  Future<T> routeTo<T extends Object>(RouteBundle bundle);  Future<bool> back<T extends Object>({T data, bool rootNavigator});  GlobalKey<NavigatorState> rootNavigatorKey;}

Разберём методы:
routeTo - выполняет навигацию на новый экран.
back возвращает на предыдущий экран.
rootNavigatorKey GlobalKey умеющий вызывать методы NavigatorState.

После того как мы сделали интерфейс навигации, давайте сделаем реализацию этого интерфейса.


class Router implements IRouter {    @override  GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();    @override  Future<T> routeTo<T>(RouteBundle bundle) async {   // Push logic here  }    @override  Future<bool> back<T>({T data, bool rootNavigator = false}) async {        // Back logic here    }}

Супер, теперь нам нужно реализовать метод routeTo().


@override  Future<T> routeTo<T>(RouteBundle bundle) async {        assert(bundle != null, "The bundle [RouteBundle.bundle] is null");    NavigatorState rootState = rootNavigatorKey.currentState;    assert(rootState != null, 'rootState [NavigatorState] is null');    switch (bundle.route) {      case "/routeExample":        return await rootState.push(          _buildRoute<T>(            bundle: bundle,            child: RouteExample(),          ),        );      case "/my_page":        return await rootState.push(          _buildRoute<T>(            bundle: bundle,            child: MyPage(),          ),        );      default:        throw Exception('Route is not found');    }  }

Данный метод вызывает у root NavigatorState (который описан в WidgetsApp) метод push и конфигурирует его относительно RouteBundle который приходит одним из аргументов в данный метод.


Теперь нужно реализовать класс RouteBundle. Это просто модель, которая хранит в себе набор полей для конфигурации.


enum ContainerType {  /// The parent type is [Scaffold].  ///  /// In IOS route with an iOS transition [CupertinoPageRoute].  /// In Android route with an Android transition [MaterialPageRoute].  ///  scaffold,  /// Used for show child in dialog.  ///  /// Route with [DialogRoute].  dialog,  /// Used for show child in [BottomSheet].  ///  /// Route with [ModalBottomSheetRoute].  bottomSheet,  /// Used for show child only.  /// [AppBar] and other features is not implemented.  window,}class RouteBundle {  /// Creates a bundle that can be used for [Router].  RouteBundle({    this.route,    this.containerType,  });  /// The route for current navigation.  ///  /// See [Routes] for details.  final String route;  /// The current status of this animation.  final ContainerType containerType;}

enum ContainerType тип контейнера, котрый будет задаваться декларативно из вызываемого кода.
RouteBundle класс-холдер данных отвечающих конфигурацию нового route.


Как вы могли заметить у я использовал метод _buildRoute. Именно он отвечает за то, кой тип route будет вызван.


Route<T> _buildRoute<T>({@required RouteBundle bundle, @required Widget child}) {    assert(bundle.containerType != null, "The bundle.containerType [RouteBundle.containerType] is null");    switch (bundle.containerType) {      case ContainerType.scaffold:        return CupertinoPageRoute<T>(          title: bundle.title,          builder: (BuildContext context) => child,          settings: RouteSettings(name: bundle.route),        );      case ContainerType.dialog:        return DialogRoute<T>(          title: '123',          settings: RouteSettings(name: bundle.route),          pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {                        return child;                    },        );      case ContainerType.bottomSheet:        return ModalBottomSheetRoute<T>(          settings: RouteSettings(name: bundle.route),          isScrollControlled: true,          builder: (BuildContext context) => child,        );      case ContainerType.window:        return CupertinoPageRoute<T>(          settings: RouteSettings(name: bundle.route),          builder: (BuildContext context) => child,        );      default:        throw Exception('ContainerType is not found');    }  }

Думаю что в этой функции стоит рассказать о ModalBottomSheetRoute и DialogRoute, которые использую. Исходный код этих route позаимствован из раздела Material исходного кода Flutter.


Осталось сделать метод back.


@overrideFuture<bool> back<T>({T data, bool rootNavigator = false}) async {    NavigatorState rootState = rootNavigatorKey.currentState;  return await (rootState).maybePop<T>(data);}

Ну и конечно перед использованием сервиса необходимо передать rootNavigatorKey в App следующим образом:


MaterialApp(    navigatorKey: widget.router.rootNavigatorKey,    home: Home());

Кодовая база для нашего сервиса готова, давайте вызовем наш route. Для этого создадим инстанс нашего сервиса и каким-либо образом "прокинуть" в объект, который будет вызывать этот инстанс, например при помощи Dependency Injection.



router.routeTo(RouteBundle(route: '/my_page', containerType: ContainerType.window));

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


  • Декларативный вызов навигации
  • Отказ от BuildContext по средствам GlobalKey
  • Модульность достигнута возможностью конфигурирования route относительно имени пути и контейнера для View

Итог


В Фреймворке Flutter существуют различные методы для навигации, которые дают преимущества и недостатки.


Ну и конечно полезные ссылки:
Мой телеграм канал
Мои друзья Flutter Dev Podcast
Вакансии Flutter разработчиков

Подробнее..

Перевод Осваиваем Grid в SwiftUI

25.08.2020 18:19:47 | Автор: admin


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

  1. Быстрый старт в iOS-разработку
  2. Делаем многопоточное приложение Kotlin Multiplatform

А теперь перейдем к статье.



На этой неделе я хочу поговорить с вами о сетках элементов (Grids) одном из самых ожидаемых нововведений в SwiftUI. Все с нетерпением ждали альтернативы UICollectionView в SwiftUI, и, наконец, в этом году она появилась. SwiftUI предоставляет нам представления LazyVGrid и LazyHGrid, которые мы можем использовать для создания макетов с сетками элементов.

Основы


LazyVGrid и LazyHGrid это два новых типа представления (View), которые SwiftUI предоставляет нам для создания супер настраиваемого макета (Layout) на основе сетки элементов. Единственное различие между ними ось заполнения. LazyVGrid заполняет доступное пространство в вертикальном направлени. LazyHGrid же размещает свои дочерние элементы в горизонтальном направлении. Ось единственное различие между двумя этими представлениями. По этому все, что вы узнаете о LazyVGrid, применимо к LazyHGrid и наоборот. Давайте посмотрим на первый пример.

struct ContentView: View {    private var columns: [GridItem] = [        GridItem(.fixed(100), spacing: 16),        GridItem(.fixed(100), spacing: 16),        GridItem(.fixed(100), spacing: 16)    ]    var body: some View {        ScrollView {            LazyVGrid(                columns: columns,                alignment: .center,                spacing: 16,                pinnedViews: [.sectionHeaders, .sectionFooters]            ) {                Section(header: Text("Section 1").font(.title)) {                    ForEach(0...10, id: \.self) { index in                        Color.random                    }                }                Section(header: Text("Section 2").font(.title)) {                    ForEach(11...20, id: \.self) { index in                        Color.random                    }                }            }        }    }}




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

  1. Параметр columns это массив, который определяет столбцы в макете сетки (grid layout). Для описания столбца SwiftUI предоставляет нам тип GridItem. Мы поговорим о нем немного позже.
  2. Параметр alignment позволяет нам выровнять содержимое сетки с помощью перечисления HorizontalAlignment для LazyVGrid и VerticalAlignment для LazyHGrid. Работает так же, как stack alignment.
  3. Параметр spacing указывает расстояние между каждой строкой внутри LazyVGrid или пространство между каждым столбцом внутри LazyHGrid.
  4. Параметр pinnedViews позволяет определять опции для закрепления верхних и нижних колонтитулов секции (headers и footers). По умолчанию он пуст, что означает, что верхние и нижние колонтитулы ведут себя как содержимое и исчезают при прокрутке. Вы можете включить закрепление верхнего и нижнего колонтитулов, в этом случае они будут накладываться на контент и становятся постоянно видимыми.


GridItem


Каждый столбец в сетке должен быть определен с помощью структуры GridItem. Тип GridItem позволяет нам указывать размер (size), выравнивание (alignment) и интервал (spacing) для каждого столбца. Давайте посмотрим на небольшой пример.

private var columns: [GridItem] = [    GridItem(.fixed(50), spacing: 16, alignment: .leading),    GridItem(.fixed(75)),    GridItem(.fixed(100))]




Как видите, каждый столбец может иметь разные параметры размера, интервала и выравнивания. Самое интересное здесь размер. Есть три способа определить размер столбца внутри сетки. Он может быть фиксированным (fixed), гибким (flexible) или адаптивным (adaptive).

Fixed столбец является самым очевидным. Сетка размещает столбец в соответствии с заданным вами размером. В предыдущем примере мы создали макет с тремя столбцами, в котором столбцы имеют фиксированные размеры 50pt, 75pt и 100pt соответственно.

Опция flexible позволяет определить столбец который расширяется или сжимается в зависимости от доступного пространства. Мы также можем предоставить минимальный и максимальный размер гибкого столбца. По умолчанию он использует минимальное значение 10pt и не ограничен по максимуму.

private var columns: [GridItem] = [    GridItem(.flexible(minimum: 250)),    GridItem(.flexible())]




Здесь мы создаем макет, который делит доступное пространство между двумя гибкими столбцами. Первый столбец занимает 250pt своего минимального размера, а второму остается все остальное доступное пространство.

Самый интересная опция adaptive. Адаптивный вариант позволяет нам размещать несколько элементов в пространстве одного гибкого столбца. Давайте попробуем разобраться с этим на примере.

private var columns: [GridItem] = [    GridItem(.adaptive(minimum: 50, maximum: 100)),    GridItem(.adaptive(minimum: 150))]




Как вы можете видеть, у нас есть два адаптивных столбца. В первом столбце разместилось несколько элементов с минимальным размером 50pt и максимальным 100pt. Адаптивные столбцы удобны, когда количество элементов внутри столбца должно зависеть от доступного пространства.
Настоящая мощь сеток проявляется, когда вы начинаете смешивать типы столбцов. Вы можете создать макет из двух столбцов, где первый фиксированный, а второй адаптивный. Давайте посмотрим как это будет выглядеть.

private var columns: [GridItem] = [    GridItem(.fixed(100)),    GridItem(.adaptive(minimum: 50))]




Заключение


Сетка позволяет создавать очень сложные и интересные макеты путем смешивания различных типов GridItem. Следует отметить, что все изменения в сетках можно анимировать. Надеюсь, вам понравилась эта статья. Не стесняйтесь подписываться на меня в Твиттере и задавать свои вопросы, связанные с этой темой. Спасибо за внимание, до встречи!



Узнать подробнее о наших курсах вы можете по ссылкам ниже:



Подробнее..

SPM модуляризация проекта для увеличения скорости сборки

11.11.2020 12:15:25 | Автор: admin
Привет, Хабр! Меня зовут Эрик Басаргин, я iOS-разработчик в Surf.

На одном большом проекте мы столкнулись с низкой скоростью сборки от трёх минут и более. Обычно в таких случаях студии практикуют модуляризацию проектов, чтобы не работать с огромными монолитами. Мы в Surf решили поэкспериментировать и модуляризовать проект с помощью Swift Package Manager менеджера зависимостей от Apple.

О результатах расскажем в другой статье, а сейчас ответим на главные вопросы: зачем это всё нужно, почему мы выбрали SPM и как делали первые шаги.



Почему именно SPM


Ответ прост это нативно и ново. Он не создает overhead в виде xcworkspace, как Cocoapods, к примеру. К тому же SPM open-source проект, который активно развивается. Apple и сообщество исправляют в нем баги, устраняют уязвимости, обновляют вслед за Swift.

Делает ли это сборку проекта быстрее


Теоретически сборка ускорится из-за самого факта разделения приложения на модули framework'и. Это значит, что каждый модуль будет собираться только в том случае, когда в него внесли изменения. Но утверждать точно можно будет только по окончанию эксперимента.

Note: Эффективность модуляризации напрямую зависит от правильного разбиения проекта на модули.

Как сделать эффективное разбиение


Метод разбиения зависит от выбранной архитектуры, типа приложения, его объема и планов на дальнейшее развитие. Поэтому я расскажу о трёх правилах, которых мы стараемся придерживаться при разбиении.

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

  • CommonAssets набор ваших Assets'ов и public интерфейс для доступа к ним. Обычно он генерируется с помощью SwiftGen.
  • CommonExtensions набор расширений, к примеру Foundation, UIKit, дополнительные зависимости.

Разделять flow'ы приложения. Рассмотрим древовидную структуру, где MainFlow главное flow приложения. Представим, что у нас новостное приложение.

  • NewFlow экраны новостей и обзора конкретной новости.
  • FavoritesFlow экран со списком избранных новостей и экран обзора конкретной новости с дополнительным функционалом.
  • SettingsFlow экраны настроек приложения, аккаунта, категорий и т. д.

Выносить reusable компоненты в отдельные модули:

  • CommonUIComponents модуль, который содержит в себе любые небольшие UI-компоненты. Обычно они влезают в один файл.
  • Далее список бесконечен и зависит от количества внутренних кастомных UI компонентов. К примеру, нужно создать модули для генерации экранов результата, кастомной коллекции, билдера кастомных алертов и т. д.

Когда нужно выносить компонент в отдельный модуль


Допустим, у нас есть конкретный flow, и мы хотим добавить в него новые функции. Если компонент будет переиспользоваться или это теоретически возможно в будущем лучше вынести его в отдельный модуль или аналог CommonUIComponents. В других случаях можно оставить компонент локальным.

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

Создаём проект с использованием SPM


Рассмотрим создание тривиального тестового проекта. Я использую Multiplatform App project на SwiftUI. Платформа и интерфейс тут не имеют значения.

Note: Чтобы быстро создать Multiplatform App, нужен XCode 12.2 beta.

Создаём проект и видим следующее:



Теперь создадим первый модуль Common:

  • добавляем папку Frameworks без создания директории;
  • создаём SPM-пакет Common.



  • Добавляем поддерживаемые платформы в файл Package.swift. У нас это platforms: [.iOS(.v14), .macOS(.v10_15)]



  • Теперь добавляем наш модуль в каждый таргет. У нас это SPMExampleProject для iOS и SPMExampleProject для macOS.



Note: Достаточно подключать к таргетам только корневые модули. Они не добавлены как подмодули.

Подключение завершено. Теперь достаточно настроить модуль с public интерфейсом и вуаля, первый модуль готов.

Как подключить зависимость у локального SPM-пакета


Добавим пакет AdditionalInfo как Common, но без добавления к таргетам. Теперь изменим Package.swift у Common пакета.



Добавлять больше ничего не нужно. Можно использовать.

Пример, приближенный к реальности


Подключим к нашему тестовому проекту SwiftGen и добавим модуль Palette он будет отвечать за доступ к палитре цветов, утвержденной дизайнером.

  1. Создаем новый корневой модуль по инструкции выше.
  2. Добавляем к нему корневые каталоги Scripts и Templates.
  3. Добавляем в корень модуля файл Palette.xcassets и пропишем какие-либо color set'ы.
  4. Добавляем пустой файл Palette.swift в Sources/Palette.
  5. Добавим в папку Templates шаблон palette.stencil.
  6. Теперь нужно прописать конфигурационный файл для SwiftGen. Для этого добавим файл swiftgen.yml в папку Scripts и пропишем в нем следующее:

xcassets:  inputs:    - ${SRCROOT}/Palette/Sources/Palette/Palette.xcassets  outputs:    - templatePath: ${SRCROOT}/Palette/Templates/palette.stencil      params:        bundle: .module        publicAccess: true      output: ${SRCROOT}/Palette/Sources/Palette/Palette.swift


Итоговый внешний вид модуля Palette

Модуль Palette мы с вами настроили. Теперь надо настроить запуск SwiftGen, чтобы палитра генерировалась при старте сборки. Для этого заходим в конфигурацию каждого таргета и создаем новую Build Phase назовём ее Palette generator. Не забудьте перенести эту Build Phase на максимально высокую позицию.

Теперь прописываем вызов для SwiftGen:

cd ${SRCROOT}/Palette/usr/bin/xcrun --sdk macosx swift run -c release swiftgen config run --config ./Scripts/swiftgen.yml

Note: /usr/bin/xcrun --sdk macosx очень важный префикс. Без него при сборке вылетит ошибка: unable to load standard library for target 'x86_64-apple-macosx10.15.


Пример вызова для SwiftGen

Готово доступ к цветам можно получить следующим образом:
Palette.myGreen (Color type in SwiftUI) и PaletteCore.myGreen (UIColor/NSColor).

Подводные камни


Перечислю то, с чем мы успели столкнуться.

  • Всплывают ошибки архитектуры и портят всю логику разбиения на модули.
  • SwiftLint & SwiftGen не уживаются вместе при подгрузке их через SPM. Причина в разных версиях yml.
  • В крупных проектах не получится сразу избавиться от Cocoapods. А разбивать уже созданный проект с закреплёнными версиями подов настоящее испытание, потому что SPM только развивается и не везде поддерживается. Но SPM и Cocoapods более-менее работают параллельно: разве что поды могут кидать ошибку MergeSwiftModule failed with a nonzero exit code. Это происходит довольно редко, а решается очисткой и пересборкой проекта.
  • На данный момент SPM не позволяет прописать пути поиска библиотек. Приходится явно указывать их с завязкой на -L$(BUILD_DIR).

SPM замена Bundler?


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

SPM дает возможность вызывать swift run, если добавить Package.swift в корень вашего проекта. Что это нам дает? К примеру, можно вызвать fastlane или swiftlint. Пример вызова:

swift run swiftlint --autocorrect.
Подробнее..

Из песочницы Как сэкономить на разработке мобильного приложения

04.10.2020 16:16:23 | Автор: admin
image

Привет, я Евгений Бойченко сооснователь студии, которая разрабатывает мобильные приложения. За 10 лет работы вопрос Почему так дорого? я слышу чуть ли не ежедневно. Для многих клиентов мы искали возможности безболезненно снизить цену разработки, и в итоге у меня накопилось некоторое количество кейсов, которые решают проблему высокой стоимости мобильного приложения. В этом треде я призываю комьюнити делиться знаниями о том, как удешевить разработку мобильного софта без потерь. Начну с себя и своих секретиков, а вы присоединяйтесь в комментариях вместе создадим гайд по экономичной разработке, который будет полезен обществу.

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


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

  • Моя ниша достаточно велика?
  • Приложение решит мои бизнес-задачи?
  • Будет ли у меня такой поток клиентов, который оправдает вложения?
  • В насколько близких отношениях мои клиенты с мобильными технологиями?

Ответ нет хотя бы на один из этих вопросов повод задуматься о необходимости приложения. Мало времени и денег ещё два предупредительных выстрела. И того, и другого будет уходить много.

Сколько времени уходит на мобильную разработку


Вы должны будете рассказать студии, каких целей хотите достичь с приложением и какими именно путями, выслушать аргументы за и против, идти на компромиссы, оценивать, согласовывать, соглашаться и отказывать. Коммуникация будет вестись как устно, так и письменно. Есть ли у вас личное время или доверенный человек, который будет готов выделить несколько месяцев жизни на управление проектом? Если да, то это то, что нужно.

Сколько денег уходит на мобильную разработку


Готовы потратить семизначную сумму? Начинайте разработку смело. Не готовы? Читайте статью дальше.

MVP приложения


MVP (Minimum Viable Product минимально жизнеспособный продукт) пилотная версия приложения. Она нужна чтобы понять, как продукт или услуга заходит аудитории, при минимальных затратах на создание. Ей не требуются функциональные и дизайнерские украшательства всё, что там будет, работает строго на бизнес-цель продукта.

Стратегия MVP уместна, когда нужно выпустить приложение вовремя, понять, что люди будут им пользоваться, и проверить все гипотезы, которые вы сформулировали на этапе проектирования. Выбрав её, вы не потратите деньги на неинтересный аудитории продукт. А если интерес будет, то вы сможете развивать приложение дальше.

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

image

Проектирование, аналитика и техническое задание


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

Если вы ещё не выяснили, кто ваши потенциальные пользователи, как проект будет реализован, сколько он стоит и есть ли у вас конкуренты, сделать это можно на этапе бизнес-аналитики.

Суть кратко: системный аналитик собирает ваши требования к проекту и переводит их на язык разработки. Он выясняет:

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

Что при этом происходит:

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

Эта работа поможет вам отмести нежизнеспособные идеи и сэкономить деньги и время. Она будет стоить до 10% от общей цены разработки приложения. Аналитики укажут на точки роста и помогут сделать его лучше, а вы поймете, какие функциональные возможности в будущем продукте самые важные. В этом и есть смысл этапа проектирования. Оно подтверждает или опровергает идею о том, что ваш продукт нужен людям. Без этого этапа можно получить неожиданно маленькое количество установок, нулевые продажи и нулевой доход.

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

iOS или Android: что лучше выбрать для разработки мобильного приложения


На разработке приложения можно сэкономить в два и более раза, если делать приложение только под одну платформу iOS или Android. Но практика показала, что из-за сотен существующих на рынке моделей телефона на ОС Android и нескольких актуальных её версий разработка под эту платформу может стоить больше, чем разработка под iOS.

Есть несколько причин для того, чтобы начать с создания приложения для владельцев айфонов:

  • приложения для iOS лучше монетизируются;
  • нужно поддерживать меньше типов устройств;
  • на устройствах чаще всего стоит актуальная версия операционной системы;
  • хакерам сложнее украсть личные данные пользователей;
  • у приложений более высокое качество из-за придирчивой модерации.

Но окончательный выбор платформы зависит от цели приложения и его аудитории. Хотите зарабатывать и делаете ставку на платёжеспособных пользователей? Выбирайте iOS. Создаёте продукт, нацеленный на массы или регионы, жители которых не привыкли или не могут платить за цифровые продукты? Делаете сервисное приложение для курьеров и торговых представителей и не можете позволить себе дорогой парк устройств? Выбирайте Android. Хотите захватить мир? Выбирайте обе платформы.

image

Как сэкономить на дизайне мобильного приложения


Чтобы не переплатить за дизайн, нужно помнить минимум о двух условиях:

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

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

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

С анимациями похожая история: чем они сложнее и круче, тем больше времени и бюджета требуют.

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

Если своих шаблонов у студии нет, то на помощь могут прийти уже упомянутые выше UI-киты. На сайте UI8 изобилие уже готовых китов, иконок, вайрфреймов и всего, что нужно для работы над пользовательским интерфейсом.

Кроссплатформенные приложения: что это и как экономит деньги


Подход к разработке приложения может быть нативным и кроссплатформенным.

Нативные приложения создаются на конкретном языке программирования для конкретной платформы: языки Java и Kotlin для Android, а Swift не ниже третьей версии для iOS.

Достоинства:

  • мгновенная реакция на действия пользователей;
  • прямой доступ к аппаратной части устройства;
  • привычный для пользователей платформы интерфейс.

Недостаток: высокая стоимость разработки и поддержки из-за привлечения минимум одного разработчика для каждой платформы.

Кроссплатформенные разработка мобильных приложений осуществляется с помощью веб-технологий (HTML, CSS и JavaScript) инструментами Cordova, Xamarin, React Native и Flutter и работают сразу на iOS и Android. Чтобы написанный код заработал на мобильных устройствах, его нужно либо перевести на понятный им язык, либо сделать прослойку, которая работает на устройстве и переводит обращения к функциям устройства с непонятного для них языка на понятный.

Достоинство: низкая стоимость разработки и поддержки из-за привлечения одного веб-разработчика.

Недостатки:

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

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

image

Как сэкономить на бэкенде


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

  1. Хранить данные на стороне клиента, то есть в устройстве. В таком случае для работы приложения не нужен интернет, но так оно лишается интерактивности, а новый контент будет появляться только с новой версией приложения в сторе.
  2. Использовать бессерверную архитектуру приложений Serverless. Это решение не требует ни особых знаний для развёртывания и поддержки, ни ощутимого бюджета всю поддержку берёт на себя тот облачный сервис, на котором вы построите архитектуру. Много возможностей для этого имеют AWS, Azure и Firebase.
  3. Работать с данными через интеграции с бесплатными инструментами. Вместо хитрых кастомных форм можно использовать Google-форму, данные собирать не в административную панель, а в Google-таблицу, а вместо приложения использовать Telegram-бота.
  4. Использовать SaaS-сервисы. В приложении есть типовые функции, создание и поддержка которых не только дороги, но и отягощают клиента бумажной волокитой. Поэтому их разработка с нуля редкость. Один из примеров такой функции оплата, и стандарт де-факто использовать платёжный шлюз какого-нибудь банка.

И это касается массы других возможностей приложения. Нужны чаты или push-уведомления? Их дешевле брать готовыми, в виде SaaS (Software-as-a-Service программное обеспечение как услуга). В среднесрочной перспективе это дешевле и надёжнее, чем писать свою платформу. Вы тем самым избегаете всех грабель, которые собрали разработчики платформы до того, как она заработала.

Фриланс или агентство: выбираем разработчиков


Фрилансер это вольнонаёмный специалист, с которым вас будут связывать только деловые отношения. Так как фрилансер работает вне штата, он будет с вами только на время работы над проектом или его этапом, и ничто не помешает ему заниматься основной работой или другими проектами.

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

Минусы:

  • Отношения с фрилансером основаны на взаимном доверии, и всегда есть риск наткнуться на недобросовестного исполнителя. Кроме того, без тестирования и code review со стороны профессионалов не факт, что проект будет реализован без багов и другие специалисты смогут его поддерживать, если вы решите сменить исполнителя.
  • Срывы дедлайнов и растягивание задач частая ситуация в работе с фрилансерами. Если он работает над несколькими проектами, в первую очередь он будет решать задачи на горящих. Фрилансер может и вовсе перестать выходить на связь и пропасть.
  • Исключительно материальная заинтересованность фрилансера в вашем проекте и полное безразличие к его дальнейшей судьбе могут дать плохой результат. Включённость в проект важна, и от того, каких специалистов вы подберёте и как построите взаимодействие, будет зависеть успешность проекта.

Нанимать фрилансеров стоит при явно ограниченном бюджете, но от вас требуется богатый опыт в управлении проектами и готовность тратить много времени на общение с подрядчиками. Самое главное просчитывать риски и всегда иметь под рукой план Б.

Региональные студии или столичные: кому доверить проект


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

Начните поиск студии с рейтингов. Их изучение даст вам представление о количестве студий в России, стоимости их услуг и их положении в отрасли. Ориентироваться стоит на четыре рейтинга:

  • Тэглайн самый авторитетный рейтинг разработчиков мобильных приложений на российском рынке. Учитывает годовую выручку студии, количество сотрудников, качество сайта студии и её узнаваемость среди коллег по цеху.
  • Рейтинг Рунета главные критерии попадания в него количество выпущенных приложений и их средняя оценка в сторах, поэтому вам не придётся выбирать среди псевдостудий, сделавших два давно мёртвых проекта.
  • Clutch рейтинг из США. Позиции студий зависят от реальных отзывов и оценок от уже работавших с ними клиентов.
  • Ruward агрегатор других рейтингов. Учитывает позиции студий в Тэглайне, Рейтинге Рунета, Clutch Russia и ряде второстепенных рейтингов.

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

На что ещё обратить внимание при выборе студии мобильной разработки:

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

image

Экономия с помощью конструкторов мобильных приложений


Растущий рынок мобильных приложений в какой-то момент не мог не предложить малому и среднему бизнесу создавать приложения в конструкторах и генераторах.

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

Зачем нужно приложение на конструкторе? Чтобы осмотреться в мобильной среде, увидеть востребованность бизнес-идеи в ней, а если она есть, то это будет зелёным светом к разработке приложения с нуля.

Конструкторы для создания мобильных приложений делятся на два типа:

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

Признаемся, что автор не знает ни одного конструктора первого типа, на котором собрали бы приложение, пользующееся спросом и решающее реальные проблемы миллионов. Если вы не знакомы со своей аудиторией, слабо понимаете, что такое пользовательский опыт, то вам будет сложно получить достойно выглядящий, удобный и приносящий деньги результат. У таких конструкторов нет поддержки дизайнеров и разработчиков, а значит, вы будете сами себе аналитиком, UX/UI-дизайнером и маркетологом, то есть сами поведёте свой продукт к успеху. Чем это закончится вопрос риторический.

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

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

Есть множество индустрий, где конструкторы второго типа хорошо решают задачу. Среди них рестораны и кафе, на создание приложений для которых заточен конструктор WelcomeApp, службы доставки еды, решение для которых поставляет DeliveryApp, массовые мероприятия и корпоративные приложения, с которыми рады помочь такие конструкторы, как Eventicious и EventPlatform, и другие индустрии. Находятся даже платформы для массового выпуска приложений с программой лояльности и студии, которые готовы сделать клоны хоть твиттера и eBay, хоть уберы для любых специалистов.

Политика мобильных сторов в отношении шаблонных и сгенерированных приложений неустойчива. В августе 2017 года компания Apple добавила в инструкцию по публикации приложений в App Store пункт, гласящий, что модераторы не будут пропускать такие приложения. В июле 2019 компания пошла на уступки: такие приложения нельзя подписывать в App Store именем клиента, данные каждого клиента должны храниться на отдельном бинарном файле, а сам конструктор приложения должен предоставлять инструменты для создания приложений с уникальным пользовательским опытом. К таким инструментам и относятся профессиональные конструкторы.

Итог короткий: если и выбирать конструктор для создания мобильных приложений, то только профессиональный. Попробовать можно, зарабатывать и развивать с трудом. Сразу после подтверждения спроса думайте о полноценном приложении.

Сэкономить на мобильной разработке с помощью маркетплейсов


Молодому бизнесу, решившему зайти на территорию мобайла, порой разумнее не делать приложение, а подключиться к маркетплейсу. Например, ресторан или кухня могут не делать своё приложение, а завести аккаунт на Яндекс.Еде, а производитель обуви в маркетплейсе Bringly или Беру. Став партнёром, магазин платит площадке комиссию с каждой продажи. Если продажи через маркетплейс есть и имеют тенденцию к росту и возврату клиентов, то можно подумать об инвестициях в собственное мобильное приложение.

Пионерами и лидерами в этой нише остаются Amazon, eBay, Alibaba и Ozon, где большинство из нас что-то покупали хотя бы раз в жизни, а в России, по данным сайта Shopolog, существует несколько десятков маркетплейсов.

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

Мобильное приложение или PWA


А нужно ли вообще делать приложение? Если у вас есть адаптивный сайт и веб-разработчик, то несколько манипуляций и вы получаете PWA (Progressive Web App). Это не просто сайт: он всё ещё открывается в мобильном браузере, но уже может работать офлайн, посылать push-уведомления, иметь доступ к некоторым аппаратным частям устройства и открываться с рабочего стола через клик на иконку. При этом места на устройстве он занимает меньше.

Понятие PWA появилось в 2015 году на фоне популяризации принципа mobile first. Принцип гласит, что, поскольку мобильный интернет-трафик сравнялся с десктопным (а впоследствии и обогнал его), то дизайнеры и разработчики теперь должны делать сайты в первую очередь для пользователей смартфонов, то есть быстрыми, удобными и полезными. По релевантным запросам такие сайты идут в мобильной поисковой выдаче выше конкурентов.

За эти годы появилось уже достаточно кейсов, подтверждающих, что PWA играют на руку бизнесу: пользователям нравится тот опыт, который они получили от приложения, и они продолжают им пользоваться, нести трафик и покупать товары. От скорости загрузки PWA-версий сайта выиграли Lancome, Tinder, Uber, Pinterest и другие известные продукты.

Установить на своё устройство PWA-приложение пользователь мог только тогда, когда он работал с мобильным сайтом, и их нельзя было найти в магазинах приложений. Но в феврале 2019 с выходом браузера Chrome 72 и появления функции Trusted Web Activity в его Android-версии возможность скачать PWA из стора получили как минимум пользователи ОС Android.

Экономия на поддержке


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

Заключить договор о поддержке это уже само по себе экономия, но есть несколько способов сократить расходы и на неё:

  1. Сократите количество часов. За несколько месяцев работы продукта может выясниться, что вам будет много даже половины купленного на поддержку времени.
  2. Откажитесь от поддержки по SLA. В этом случае ваши задачи лишаются разделения по критичности, и насколько бы проблема ни была серьёзной, её никто не будет решать в срочном порядке в нерабочее время. Осторожно предположим: если у вас не DATA-центр, то вряд ли вам нужна незамедлительная реакция отдела поддержки.
  3. Сделайте легкоподдерживаемый продукт. Не пренебрегайте документацией, code review, подготовкой автотестов, проработкой архитектуры, рефакторингом вложенные в них средства оправдают себя позже. Хорошо задокументированный проект позволит разобраться в нём даже тому разработчику, который видит проект впервые.

image

Модели оплаты


Если вы создаёте приложение совместно со студией, то в зависимости от целей проекта работа и оплата ведутся по одной из двух моделей:

  • Fixed price. Модель предполагает, что за утверждённый бюджет в утверждённые сроки студия создаст то, что оговорено в техническом задании.
  • Time & Materials. Модель предполагает, что клиент платит постфактум за те человекочасы, которые команда потратила на решение отдельных задач.

Кажется, что FP выгоднее: ведь исполнитель назвал цену на берегу, а по T&M стоимость может оказаться непрогнозируемо больше. Так и есть, когда стоит цель сделать проект к конкретной дате или когда проект небольшой и не предполагает доработок. Но в случае с проектами посложнее исполнитель закладывает в оценку по FP максимум рисков, которые заказчик вынужден оплатить, даже если они не проявились. Так что с работой по T&M стоимость может стать непрогнозируемо меньше. Но всё же существуют оптимальные условия для такой модели:

Предстоит работать над стартапом среднего или большого размера


Рынок, на котором стартап хочет занять место, может быстро измениться, а вместе с ним поменяются и требования к продукту. Но при работе по FP клиент уже описал в техническом задании функциональность, которую исполнитель сделает при любых обстоятельствах даже если функциональность больше не нужна. Мало того, что он её описал, так он за неё ещё и заплатил. И если результат оказался не пригоден для рынка, придётся платить за доработку или замораживать проект. Модель Time & Materials даёт возможность быстро пересматривать и кардинально менять приоритеты.

У клиента есть время на регулярную коммуникацию с командой


Fixed Price освобождает клиента от необходимости отслеживать течение проекта: студия получает деньги и называет сроки, а клиент в это время занимается своими делами и ждёт результата. При работе по Time & Materials клиент это соучастник, напрямую влияющий на проект. Кстати, это самое соучастие и экономит бюджет, потому что требования к продукту не составляются в функциональное задание, а уточняются напрямую, и можно быстро обсудить возможные решения, их плюсы и минусы, а потом выбрать самый короткий путь реализации. На Fixed Price же студия должна заложить время на отработку рисков в оценку и нести за них ответственность сама. Под рисками мы имеем в виду неверно истолкованные части ФЗ, что вызывает неожиданный, мягко говоря, результат, недовольство клиента и переделки.

Нет чётких дедлайнов


Амбициозный проект трудно создавать в рамках строгих сроков и функциональности. Строгость черта модели Fixed Price. Но если риски вылезают наружу, то разработчик приходит к клиенту и говорит, что задача оказалась сложнее, чем предполагалось, и придётся упрощать разработку, чтобы уложиться в сроки. Модель Time & Materials позволяет избежать связанной с этим неловкости: оплата по факту выполнения задачи даёт возможность обсуждать её столько, сколько нужно, и работать без нервов.

Блок для тех, у кого мало времени: как сделать приложение и не попасть в долговую яму кратко


  1. Начинайте с MVP-версии. Она поможет вам оценить востребованность приложения у целевой аудитории минимум киллер-фич, только лаконичный дизайн и полезная функциональность.
  2. Не жалейте времени и денег на бизнес-аналитику. Этот этап поможет вам выбрать правильную стратегию для развития приложения, отметёт нежизнеспособные идеи и сэкономит ваши деньги в долгосрочной перспективе (особенно на этапе поддержки).
  3. Сделайте как можно меньше дизайна. В помощь гайдлайны от Google и Apple и UI-киты.
  4. Если в приложение закладывается простая функциональность сделайте его кроссплатформенным. Разработку сложных приложений можно начинать с одной платформы iOS или Android. Когда показатели конверсии оправдают себя, то смело принимайтесь за вторую.
  5. Храните данные на стороне клиента, не прибегая к бэкенду или используйте бессерверную архитектуру.
  6. Работайте с данными через интеграции с бесплатными инструментами (например, от Google) вместо таблиц и хитрых кастомных форм.
  7. Вместо разработки типовых функций с нуля используйте SaaS-сервисы и библиотеки.
  8. Выбирайте разработчика, который будет заинтересован в жизнеспособности вашего проекта. Не ориентируйтесь на столичные студии разработки, а найти хорошего исполнителя помогут рейтинги.
  9. Если бюджет совсем маленький, используйте конструктор приложений приложение, разработанное на конструкторе, поможет вам прощупать почву в мобильной среде и понять, нужно ли начинать полноценную разработку.
  10. Не начинайте с eCommerce-приложения станьте партнером маркетплейса, если хотите сначала протестировать спрос на ваши товары.
  11. Если у вас уже есть сайт и свободный веб-разработчик, создайте PWA вместо приложения. PWA-сайт построен на технологиях, которые позволяют ему работать как мобильное приложение: быть нативным, присылать пуши, быстро отвечать на запросы пользователя.
  12. Сократите расходы на поддержку приложения: не покупайте много часов заранее, откажитесь от поддержки по SLA, создайте понятную документацию, в которой разработчики смогут быстро и легко ориентироваться.
  13. Выбирайте работу по схеме Time & Materials, чтобы минимизировать расходы на риски.

Поделитесь своим опытом снижения цены мобильной разработки


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

Создаем Swift Package на основе Cбиблиотеки

13.01.2021 02:07:50 | Автор: admin
Фото Kira auf der HeideнаUnsplashФото Kira auf der HeideнаUnsplash

Данная статья поможет вам создать свой первый Swift Package. Мы воспользуемся популярной C++ библиотекой для линейной алгебры Eigen, чтобы продемонстрировать, как можно обращаться к ней из Swift. Для простоты, мы портируем только часть возможностей Eigen.


Трудности взаимодействия C++ и Swift

Использование C++ кода из Swift в общем случае достаточно трудная задача. Все сильно зависит от того, какой именно код вы хотите портировать. Данные 2 языка не имеют соответствия API один-к-одному. Для подмножества языка C++ существуют автоматические генераторы Swift интерфейса (например,Scapix,Gluecodium). Они могут помочь вам, если разрабатывая библиотеку, вы готовы следовать некоторым ограничениям, чтобы ваш код просто конвертировался в другие языки. Тем не менее, если вы хотите портировать чужую библиотеку, то, как правило, это будет не просто. В таких ситуациях зачастую ваш единственный выбор: написать обертку вручную.

Команда Swift уже предоставляет interop дляCиObjective-Cв их инструментарии. В то же время,C++ interopтолько запланирован и не имеет четких временных рамок по реализации. Одна из сложно портируемых возможностей C++ шаблоны. Может показаться, что темплейты в C++ и дженерики в Swift схожи. Тем не менее, у них есть важные отличия. На момент написания данной статьи, Swift не поддерживает параметры шаблона не являющиеся типом, template template параметры и variadic параметры. Также, дженерики в Swift определяются для типов параметров, которые соблюдают объявленные ограничения (похоже на C++20 concepts). Также, в C++ шаблоны подставляют конкретный тип в месте вызова шаблона и проверяют поддерживает ли тип используемый синтаксис внутри шаблона.

Итого, если вам нужно портировать C++ библиотеку с обилием шаблонов, то ожидайте сложностей!


Постановка задачи

Давайте попробуем портировать вручную С++ библиотеку Eigen, в которой активно используются шаблоны. Эта популярная библиотека для линейной алгебры содержит определения для матриц, векторов и численных алгоритмов над ними. Базовой стратегией нашей обертки будет: выбрать конкретный тип, обернуть его в Objective-C класс, который будет импортироваться в Swift.

Один из способов импортировать Objective-C API в Swift это добавить C++ библиотеку напрямую в Xcode проект и написатьbridging header. Тем не менее, обычно удобнее, когда обертка компилируется в качестве отдельного модуля. В этом случае, вам понадобится помощь менеджера пакетов. Команда Swift активно продвигаетSwift Package Manager (SPM). Исторически, в SPM отсутствовали некоторые важные возможности, из-за чего многие разработчики не могли перейти на него. Однако, SPM активно улучшался с момента его создания. В Xcode 12, вы можете добавлять в пакет произвольные ресурсы и даже попробовать пакет в Swift playground.

В данной статье мы создадим SPM пакетSwiftyEigen. В качестве конкретного типа мы возьмем вещественную float матрицу с произвольным числом строк и колонок. КлассMatrixбудет иметь конструктор, индексатор и метод вычисляющий обратную матрицу. Полный проект можно найти наGitHub.


Структура проекта

SPM имеет удобный шаблон для создания новой библиотеки:

foo@bar:~$ mkdir SwiftyEigen && cd SwiftyEigenfoo@bar:~/SwiftyEigen$ swift package initfoo@bar:~/SwiftyEigen$ git init && git add . && git commit -m 'Initial commit'

Далее, мы добавляем стороннюю библиотеку (Eigen) в качестве сабмодуля:

foo@bar:~/SwiftyEigen$ git submodule add https://gitlab.com/libeigen/eigen Sources/CPPfoo@bar:~/SwiftyEigen$ cd Sources/CPP && git checkout 3.3.9

Отредактируем манифест нашего пакета,Package.swift:

// swift-tools-version:5.3import PackageDescriptionlet package = Package(    name: "SwiftyEigen",    products: [        .library(            name: "SwiftyEigen",            targets: ["ObjCEigen", "SwiftyEigen"]        )    ],    dependencies: [],    targets: [        .target(            name: "ObjCEigen",            path: "Sources/ObjC",            cxxSettings: [                .headerSearchPath("../CPP/"),                .define("EIGEN_MPL2_ONLY")            ]        ),        .target(            name: "SwiftyEigen",            dependencies: ["ObjCEigen"],            path: "Sources/Swift"        )    ])

Манифест является рецептом для компиляции пакета. Сборочная система Swift соберет два отдельных таргета для Objective-C и Swift кода. SPM не позволяет смешивать несколько языков в одном таргете. Таргет ObjCEigen использует файлы из папкиSources/ObjC, добавляет папкуSources/CPP в header search paths, и опеделяетEIGEN_MPL2_ONLY, чтобы гарантировать лицензию MPL2 при использовании Eigen. ТаргетSwiftyEigen зависит отObjCEigen и использует файлы из папкиSources/Swift.


Ручная обертка

Теперь напишем заголовочный файл для Objective-C класса в папкеSources/ObjCEigen/include:

#pragma once#import <Foundation/Foundation.h>NS_ASSUME_NONNULL_BEGIN@interface EIGMatrix: NSObject@property (readonly) ptrdiff_t rows;@property (readonly) ptrdiff_t cols;- (instancetype)init NS_UNAVAILABLE;+ (instancetype)matrixWithZeros:(ptrdiff_t)rows cols:(ptrdiff_t)colsNS_SWIFT_NAME(zeros(rows:cols:));+ (instancetype)matrixWithIdentity:(ptrdiff_t)rows cols:(ptrdiff_t)colsNS_SWIFT_NAME(identity(rows:cols:));- (float)valueAtRow:(ptrdiff_t)row col:(ptrdiff_t)colNS_SWIFT_NAME(value(row:col:));- (void)setValue:(float)value row:(ptrdiff_t)row col:(ptrdiff_t)colNS_SWIFT_NAME(setValue(_:row:col:));- (EIGMatrix*)inverse;@endNS_ASSUME_NONNULL_END

У класса есть readonly свойства rows и cols, конструктор для нулевой и единичной матрицы, способы получить и изменить отдельные значения, и метод вычисления обратной матрицы.

Дальше напишем файл реализации вSources/ObjCEigen:

#import "EIGMatrix.h"#pragma clang diagnostic push#pragma clang diagnostic ignored "-Wdocumentation"#import <Eigen/Dense>#pragma clang diagnostic pop#import <iostream>using Matrix = Eigen::Matrix<float, Eigen::Dynamic, Eigen::Dynamic>;using Map = Eigen::Map<Matrix>;@interface EIGMatrix ()@property (readonly) Matrix matrix;- (instancetype)initWithMatrix:(Matrix)matrix;@end@implementation EIGMatrix- (instancetype)initWithMatrix:(Matrix)matrix {    self = [super init];    _matrix = matrix;    return self;}- (ptrdiff_t)rows {    return _matrix.rows();}- (ptrdiff_t)cols {    return _matrix.cols();}+ (instancetype)matrixWithZeros:(ptrdiff_t)rows cols:(ptrdiff_t)cols {    return [[EIGMatrix alloc] initWithMatrix:Matrix::Zero(rows, cols)];}+ (instancetype)matrixWithIdentity:(ptrdiff_t)rows cols:(ptrdiff_t)cols {    return [[EIGMatrix alloc] initWithMatrix:Matrix::Identity(rows, cols)];}- (float)valueAtRow:(ptrdiff_t)row col:(ptrdiff_t)col {    return _matrix(row, col);}- (void)setValue:(float)value row:(ptrdiff_t)row col:(ptrdiff_t)col {    _matrix(row, col) = value;}- (instancetype)inverse {    const Matrix result = _matrix.inverse();    return [[EIGMatrix alloc] initWithMatrix:result];}- (NSString*)description {    std::stringstream buffer;    buffer << _matrix;    const std::string string = buffer.str();    return [NSString stringWithUTF8String:string.c_str()];}@end

Теперь сделаем Objective-C код видимым из Swift с помощью файла вSources/Swift(смотритеSwift Forums):

@_exported import ObjCEigen

И добавим индексирование для более чистого API:

extension EIGMatrix {    public subscript(row: Int, col: Int) -> Float {        get { return value(row: row, col: col) }        set { setValue(newValue, row: row, col: col) }    }}

Пример использования

Теперь мы можем воспользоваться классом вот так:

import SwiftyEigen// Create a new 3x3 identity matrixlet matrix = EIGMatrix.identity(rows: 3, cols: 3)// Change a specific valuelet row = 0let col = 1matrix[row, col] = -2// Calculate the inverse of a matrixlet inverseMatrix = matrix.inverse()

Наконец, мы можем составить простой проект, который продемонстрирует возможности нашего пакета, SwiftyEigen. Приложение позволит вносить значения в матрицу 2x2 и вычислять обратную матрицу. Для этого, создаем новый iOS проект в Xcode, перетаскиваем папку с пакетом из Finder в project navigator, чтобы добавить локальную зависимость, и добавляем фреймворк SwiftyEigen в общие настройки проекта. Далее пишем UI и радуемся:

Смотрите полный проект наGitHub.


Ссылки

Спасибо за внимание!

Подробнее..

Доступный MVVM на хакнутых экстеншенах

10.07.2020 08:14:43 | Автор: admin


Много лет подряд я, помимо всего прочего, занимался настройкой MVVM в своих рабочих и не очень рабочих проектах. Я увлеченно делал это в Windows-проектах, где паттерн является родным. С энтузиазмом, достойным лучшего применения, я делал это в iOS-проектах, где MVVM просто так не приживается.


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


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


Введение


Как я уже говорил в прошлой статье, MVVM успешного программиста должен включать в себя, помимо реализации самого паттерна, решение для управления зависимостями и реализацию роутинга. Про управление зависимостями мы уже поговорили и сейчас перейдем непосредственно к реализации MVVM. В яблочном мире паттерн, придуманный в Microsoft, чувствует себя немного иностранцем, и его реализация потребует от нас дополнительных усилий. Эти усилия мы будем прикладывать к конкретному приложению, на примере которого рассмотрим все тонкости и подводные камни. Так как с идеями у меня традиционно не очень, это приложение будет состоять из одного-единственного экрана, отображающего список заказов.


Напомню также, что в коде я стараюсь с переменным успехом придерживаться нескольких простых правил, о которых уже рассказывал:


Нескольких простых правил, о которых уже рассказывал
  1. Не выпендривайся. Тупой и понятный код в большинстве случаев лучше умного и непонятного.
  2. Будь краток. Кода должно быть настолько мало, чтобы его не жалко было в любой момент выкинуть и написать заново за один день.
  3. Удобство превыше правил. Если можно облегчить себе жизнь, пожертвовав принципами SOLID, пожертвуй принципами SOLID.
  4. Получай удовольствие. Если есть разные варианты решения проблемы, выбирай более веселый.

Для тех, кто не приемлет неожиданностей, вот полное содержание статьи.


Полное содержание



Действующие лица


Я не планирую расшифровывать каждую букву аббревиатуры MVVM и объяснять, как работает паттерн и зачем он вообще нужен. Уверен, все это вы и без меня знаете. Если по какой-то причине MVVM для вас в новинку, советую перестать читать эту статью и поскорее заполнить пробел в знаниях. Невероятно скучная, но познавательная статья из Википедии может послужить неплохим стартом.


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


OrdersVC Вью-контроллер экрана заказов. Без него никак, потому что iOS это вью-контроллеры. Является источником событий жизненного цикла экрана и занимается отображением данных, которые приходят из вью-модели. В нашем случае он будет содержать таблицу для отображения списка заказов
OrdersView Вьюха для контроллера OrdersVC. Хорошая практика для каждого VC заводить свою собственную View отдельным классом, но в этой статье для упрощения мы так делать не будем. Поэтому OrdersView это такая вьюха, которой нет, но нужно помнить, что она очень даже может быть
OrdersVM Модель представления для OrdersVC, а также для его вьюхи, если бы она у него была. С помощью OrdersProvider вью-модель получает заказы и преобразует их в пригодный для отображения вид
Order Ничего особенного, типичная модель, каких много. Представляет собой заказ
OrderCell Ячейка UITableView, отображающая заказ
OrderVM Модель представления для ячейки OrderCell. Это тот же Order, но пригодный для отображения
OrdersProvider Сервис, который будет загружать заказы из базы данных, из файла, с бэкэнда неважно откуда. Для нашего обучающего примера мы будем грузить заказы из бездонной пустоты небытия

Вот так все эти ребята уживаются вместе на диаграмме классов.






Стоит отметить, что в мире MVVM нет такого понятия, как контроллер, в то время как в iOS, где безраздельно властвует MVC, без вью-контроллеров никуда. Чтобы разрешить это противоречие, здесь и далее мы будем считать, что контроллер это просто View, тем более что в iOS эти две сущности традиционно очень тесно связаны.


Запомните: все, что я говорю в этой статье о View, можно в равной степени отнести к контроллеру, и наоборот.


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


Знакомим представление с его моделью


Сплошная стрелочка, направленная от View к ViewModel, символизирует их абьюзивные отношения: вьюха владеет вью-моделью, держит ее сильной ссылкой и напрямую вызывает ее методы. Узаконим эти отношения с помощью протокола. Может существовать сколько угодно реализаций MVVM, но одна штука в них будет неизменной: у View должно появиться свойство viewModel:


protocol IHaveViewModel: AnyObject {    associatedtype ViewModel    var viewModel: ViewModel? { get set }    func viewModelChanged(_ viewModel: ViewModel)}

Буква I в начале имени протокола означает interface. В предсказуемом мире статической типизации у разных вьюх экземпляры вью-моделей, скорее всего, будут принадлежать разным классам. Чтобы выразить это средствами языка, нам пригодился протокол с дженериком ассоциированным типом.


Заметим, что свойство viewModel доступно для записи извне. В какой-то момент оно обязательно изменится, что неизбежно приведет к вызову метода viewModelChanged(_:), в котором вьюха обязуется проделать работу по синхронизации своего состояния в соответствии со своей моделью представления. Нехитрая реализация протокола IHaveViewModel на примере связки OrderCell OrderVM могла бы выглядеть вот так:


final class OrderCell: UITableViewCell, IHaveViewModel {    var viewModel: OrderVM? {        didSet {            guard let viewModel = viewModel else { return }            viewModelChanged(viewModel)        }    }    func viewModelChanged(_ viewModel: OrderVM) {        textLabel?.text = viewModel.name    }}

Замечу, что для простоты мы взяли ячейку стандартного лайаута, поэтому пользуемся свойством textLabel для отображения текста. Модель представления заказа нам не очень интересна, поэтому сделаем ее максимально неумной:


final class OrderVM {    let order: Order    var name: String {        return "\(order.name) #\(order.id)"    }    init(order: Order) {        self.order = order    }}

Нетерпеливый читатель может задаться вопросом, кто же проставляет свойство viewModel у ячейки и, что более важно, как именно это происходит. На самом деле типичная OrderCell не очень отличается от своих собратьев, и ее жизненный цикл протекает довольно обычным образом:


  1. В методе делегата таблицы tableView(_:cellForRowAt:) извлекаем ячейку при помощи вызова dequeueReusableCell(withIdentifier:for:) и получаем экземпляр класса UITableViewCell.
  2. Осуществляем приведение типа к протоколу IHaveViewModel, чтобы получить доступ к свойству viewModel и записать туда вью-модель.
  3. Грустим оттого, что код, который мы написали на шаге 2, не компилируется.
  4. Гуглим ошибку Protocol 'IHaveViewModel' can only be used as a generic constraint because it has Self or associated type requirements.

Чтобы справиться с такой ошибкой, нам придется применить специальную технику с загадочным названием стирание типов (type erasure). Некоторые авторы выделяют несколько разновидностей стирания типов. Для нашего случая подходит вариант, похожий на секретную технику ниндзя теневое стирание типов (shadow type erasure). Кто придумывает эти названия? На практике весь пафос сводится к тому, что надо просто завести еще один протокол:


protocol IHaveAnyViewModel: AnyObject {    var anyViewModel: Any? { get set }}

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


protocol IHaveViewModel: IHaveAnyViewModel {    associatedtype ViewModel    var viewModel: ViewModel? { get set }    func viewModelChanged(_ viewModel: ViewModel)}

Реализация OrderCell теперь будет выглядеть так:


final class OrderCell: UITableViewCell, IHaveViewModel {    typealias ViewModel = OrderVM    var anyViewModel: Any? {        didSet {            guard let viewModel = anyViewModel as? ViewModel else { return }            viewModelChanged(viewModel)        }    }    var viewModel: ViewModel? {        get {            return anyViewModel as? ViewModel        }        set {            anyViewModel = newValue        }    }    func viewModelChanged(_ viewModel: ViewModel) {        textLabel?.text = viewModel.name    }}

Свойство anyViewModel, лишенное информации о типе, удобно использовать снаружи класса. Оно позволяет любую вьюху привести к типу IHaveAnyViewModel и задать ей вью-модель. Свойство viewModel, которое содержит типизированную вью-модель, удобно использовать внутри класса, например для того, чтобы в методе viewModelChanged(_:) обновлять состояние вьюхи.


Удивительно, насколько отвратительную реализацию MVVM мы с вами только что написали: пользоваться ею решительно невозможно. Все потому, что в каждой вьюхе и вью-контроллере мы вынуждены будем писать очень много повторяющегося кода для реализации IHaveViewModel, что, вообще говоря, довольно утомительно и ни капельки не весело. Хорошая новость в том, что этот недостаток можно преодолеть с помощью расширения, обеспечивающего реализацию по умолчанию для протокола IHaveViewModel.


Реализация по умолчанию через расширение протокола


В основе концепции расширений (extensions) лежит очень простая идея: расширения не могут добавлять никаких новых данных к типу. Все свойства и методы, предоставляемые расширением, являются не чем иным, как комбинацией данных, которые уже объявлены в типе.


Таким образом, если мы попробуем написать реализацию по умолчанию для IHaveViewModel, то ожидаемо столкнемся с неизбежными сложностями в виде ошибки extensions must not contain stored properties:


extension IHaveViewModel {    var anyViewModel: Any? // Не компилируется :(}

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


Представьте, в какую анархию погрузилось бы программирование, если бы все могли вот так запросто добавлять новые данные к любым типам. Возможно, с помощью ошибки extensions must not contain stored properties создатели языка нежно заботятся о нас, не позволяя пойти по скользкой дорожке, покатиться под откос, ринуться в бурлящую бездну хаоса. Вопреки их стараниям, именно этим мы сейчас и займемся, предварительно хакнув свифтовые расширения с помощью старого доброго Objective-C-рантайма. Читай дальше, если не боишься, что полиция экстеншенов придет за тобой:


private var viewModelKey: UInt8 = 0extension IHaveViewModel {    var anyViewModel: Any? {        get {            return objc_getAssociatedObject(self, &viewModelKey)        }        set {            let viewModel = newValue as? ViewModel            objc_setAssociatedObject(self,                 &viewModelKey,                 viewModel,                 .OBJC_ASSOCIATION_RETAIN_NONATOMIC)            if let viewModel = viewModel {                viewModelChanged(viewModel)            }    }    var viewModel: ViewModel? {        get {            return anyViewModel as? ViewModel        }        set {            anyViewModel = newValue        }    }    func viewModelChanged(_ viewModel: ViewModel) {    }}

Это код компилируется, потому что в расширении мы не объявили ни одного нового хранимого свойства. Необходимого поведения удалось достичь с помощью двух функций языка Си: objc_getAssociatedObject и objc_setAssociatedObject, которые мы используем в гетере и сетере соответственно.


Эти функции с успехом заменяют хранимые свойства, так как позволяют сопоставить объекту некоторое значение по некоторому ключу. Обычно в качестве такого ключа используют адрес глобальной переменной, такой как viewModelKey. Благодаря расширению реализация OrderCell избавилась от лишнего кода и выглядит теперь гораздо привлекательнее:


final class OrderCell: UITableViewCell, IHaveViewModel {    typealias ViewModel = OrderVM    func viewModelChanged(_ viewModel: OrderVM) {        textLabel?.text = viewModel.name    }}

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


Отображение списка заказов (на самом деле нет)


Вооружившись дефолтной реализацией IHaveViewModel можно быстро накидать код связки OrdersVC OrdersVM. Вью-модель выглядит так:


final class OrdersVM {    var orders: [OrderVM] = []    private var ordersProvider: OrdersProvider    init(ordersProvider: OrdersProvider) {        self.ordersProvider = ordersProvider    }    func loadOrders() {        ordersProvider.loadOrders() { [weak self] model in            self?.orders = model.map { OrderVM(order: $0) }        }    }}

OrdersVM использует OrdersProvider для загрузки отзывов. OrdersProvider с умным видом имитирует асинхронный запрос и отвечает списком отзывов через секунду после вызова loadOrders(completion:):


struct Order {    let name: String    let id: Int}final class OrdersProvider {    func loadOrders(completion: @escaping ([Order]) -> Void) {        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {            completion((0...99).map { Order(name: "Order", id: $0) })        }    }}

И, наконец, вью-контроллер:


final class OrdersVC: UIViewController, IHaveViewModel {    typealias ViewModel = OrdersVM    private lazy var tableView = UITableView()    override func viewDidLoad() {        super.viewDidLoad()        tableView.dataSource = self        tableView.register(OrderCell.self, forCellReuseIdentifier: "order")        view.addSubview(tableView)        viewModel?.loadOrders()    }    override func viewDidLayoutSubviews() {        super.viewDidLayoutSubviews()        tableView.frame = view.bounds    }    func viewModelChanged(_ viewModel: OrdersVM) {        tableView.reloadData()    }}

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


extension OrdersVC: UITableViewDataSource {    func tableView(_ tableView: UITableView,         numberOfRowsInSection section: Int) -> Int {        return viewModel?.orders.count ?? 0    }    func tableView(_ tableView: UITableView,         cellForRowAt indexPath: IndexPath) -> UITableViewCell {        let cell = tableView.dequeueReusableCell(withIdentifier: "order",             for: indexPath)        if let cell = cell as? IHaveAnyViewModel {            cell.anyViewModel = viewModel?.orders[indexPath.row]        }        return cell    }}

Здесь все совершенно стандартно, за исключением приведения к протоколу IHaveAnyViewModel, которое жизненно необходимо для того, чтобы сконфигурировать вью-модель ячейки. Вот и все. Теперь мы можем собрать всех ребят вместе, примерно так:


let viewModel = OrdersVM(ordersProvider: OrdersProvider())let viewController = OrdersVC()viewController.viewModel = viewModel

После того как мы отобразим OrdersVC на экране, мы должны увидеть таблицу со списком заказов. Однако внимательный читатель, возможно, уже догадался, что ничего у нас не выйдет и список заказов мы так и не увидим.


Дело в том, что метод loadOrders(completion:) работает асинхронно, список заказов формируется только через секунду после вызова viewDidLoad(), а это значит, что на момент вызова reloadData() массив orders пуст. Для того чтобы все заработало, нам не хватает одной важной детали уведомления об изменениях вью-модели.


Уведомление об изменении модели представления


Одна из ключевых концепций MVVM состоит в том, что ViewModel ничего не желает знать о View. Она не держит ссылку на View и не вызывает ее методы ни напрямую, ни через протокол. Вью-модель ведет себя так, словно View просто-напросто не существует. Компенсируя свое нежелание общаться с View, вью-модель поддерживает механизм уведомления о важных событиях, происходящих в ее жизни. Этим механизмом пользуется View, чтобы поддерживать себя в актуальном виде, и на диаграмме классов это выражается пунктирной стрелкой, направленной от ViewModel к View.


В самобытном мире iOS-разработки сложилась невеселая ситуация: уведомления об изменении свойств модели представления чаще всего реализуют через реактивные сигналы. Этот подход настолько распространен, что некоторые авторы едва ли не ставят знак равенства между MVVM и Rx. Между тем MVVM вовсе не подразумевает использование стороннего реактивного фрэймворка. В том же .NET исторической родине паттерна уведомления работают через интерфейс INotifyPropertyChanged, реализуемый на стороне ViewModel, в связке с декларативными биндингами на стороне View.


Автор этой статьи, мягко говоря, не фанат реактивного подхода. Очень уж непросто бывает разобраться в хитросплетении сигналов, которые стреляют другими сигналами, которые трансформируются в третьи сигналы. Написать запутанный реактивный код слишком просто. Сегодня вы добавляете в проект один маленький сигнальчик, а завтра ваше простое на первый взгляд приложение превращается в неуправляемый реактивный истребитель, несущийся на сверхзвуковой скорости в бездну отчаяния. Да и не хочется в мелкий домашний проект целый RxSwift тащить, а Combine так вообще только с iOS 13.


В общем, мы с вами, как обычно, пойдем скользкой, но интересной дорожкой и напишем под iOS нечто похожее на события из .NET. Наше творение обеспечит нам вечную славу и поддержку уведомлений об изменениях ViewModel.


Заново изобретаем события


События в .NET это реализация известного паттерна Наблюдатель, такой сталкинг от программирования: вьюха очень пристально следит за тем, что происходит c вью-моделью. Для нас критически важно, чтобы событие поддерживало несколько подписчиков, потому что, например, на одно и то же событие вью-модели может подписаться как ViewController, так и его View.


Реализовать такое на Swift можно несколькими способами: через массив делегатов, через массив замыканий, через NotificationCenter. Тот способ, который поджидает читателя ниже по тексту, вынуждает нас для начала написать небольшой вспомогательный класс. Вот такой:


final class Weak<T: AnyObject> {    private let id: ObjectIdentifier?    private(set) weak var value: T?    var isAlive: Bool {        return value != nil    }    init(_ value: T?) {        self.value = value        if let value = value {            id = ObjectIdentifier(value)        } else {            id = nil        }    }}

Это нехитрая обертка, которая держит слабую ссылку на экземпляр ссылочного типа, передаваемый в инициализатор. Если в инициализатор пришло что-то отличное от nil, обертка запоминает ObjectIdentifier этого объекта, который впоследствии используется для реализации Hashable:


extension Weak: Hashable {    static func == (lhs: Weak<T>, rhs: Weak<T>) -> Bool {        return lhs.id == rhs.id    }    func hash(into hasher: inout Hasher) {        if let id = id {            hasher.combine(id)        }    }}

Вооружившись Weak<T>, можно приступить к реализации событий:


final class Event<Args> {    // Тут живут подписчики на событие и обработчики этого события    private var handlers: [Weak<AnyObject>: (Args) -> Void] = [:]    func subscribe<Subscriber: AnyObject>(        _ subscriber: Subscriber,        handler: @escaping (Subscriber, Args) -> Void) {        // Формируем ключ        let key = Weak<AnyObject>(subscriber)        // Почистим массив обработчиков от мертвых объектов, чтобы не засорять память        handlers = handlers.filter { $0.key.isAlive }        // Создаем обработчик события        handlers[key] = {            [weak subscriber] args in            // Захватываем подписчика слабой ссылкой и вызываем обработчик,            // только если подписчик жив            guard let subscriber = subscriber else { return }            handler(subscriber, args)        }    }    func unsubscribe(_ subscriber: AnyObject) {        // Отписываемся от события, удаляя соответствующий обработчик из словаря        let key = Weak<AnyObject>(subscriber)        handlers[key] = nil    }    func raise(_ args: Args) {        // Получаем список обработчиков с живыми подписчиками        let aliveHandlers = handlers.filter { $0.key.isAlive }        // Для всех живых подписчиков выполняем код их обработчиков событий        aliveHandlers.forEach { $0.value(args) }    }}

Этот код не очень сложный, но, чтобы он не казался вам совсем уж примитивным, я написал побольше комментариев. Класс Weak<T>, как оказалось, нужен был для того, чтобы хранить всех подписчиков в словаре и дать им спокойно умереть, если они того желают не держать их сильной ссылкой.


Обработчик события представляет собой замыкание, в аргументы которого попадает живой подписчик и некоторые данные, если таковые актуальны для данного события. Получившийся класс Event<Args> позволяет подписываться на событие с помощью метода subscribe(_:handler:) и отписываться от него с помощью метода unsubscribe(_:). Когда источник события (в нашем случае это вью-модель) захочет уведомить о чем-то свою армию подписчиков, ему следует воспользоваться методом raise(_:).


Для удобства можно обмазаться экстеншеном, который позволяет опустить аргументы типа Void при подписке на событие и при его вызове:


extension Event where Args == Void {    func subscribe<Subscriber: AnyObject>(        _ subscriber: Subscriber,        handler: @escaping (Subscriber) -> Void) {        subscribe(subscriber) { this, _ in            handler(this)        }    }    func raise() {        raise(())    }}

Пользоваться событиями можно примерно так. На стороне источника событий создаем объект события, после чего в какой-то момент стреляем нотификацией:


let event = Event<Void>()event.raise() // Какой-то момент наступил, стреляем

На стороне подписчика подписываемся и выполняем полезную работу в замыкании. Обратите внимание, что в аргументы обработчика события поступает живой экземпляр подписчика, что позволяет не писать weak self, если нам нужен доступ к подписчику внутри замыкания:


event.subscribe(self) { this in    this.foo() // Тут полезная работа}

Если подписчик более не заинтересован в получении событий, он делает вот так:


event.unsubscribe(self) // Нам лучше расстаться

Ура! Вы прочитали почти всю статью, осталось совсем немного. Чтобы передохнуть, отвлекитесь на минутку и подумайте о том, насколько MVVM прекрасен. Следующий раздел почти последний.


Отображение списка заказов


Чтобы научить OrdersVM уведомлять OrdersVC об изменении списка заказов, необходимо во вью-модель добавить соответствующее событие. Однако, согласитесь, не хочется в каждой вью-модели, которая должна уведомлять о своих изменениях, снова и снова писать код по созданию события. Поэтому мы пойдем уже знакомым путем и обратимся за помощью к запретным техникам Objective-C-рантайма, клятвенно пообещав себе больше никогда так не делать:


private var changedEventKey: UInt8 = 0protocol INotifyOnChanged {    var changed: Event<Void> { get }}extension INotifyOnChanged {    var changed: Event<Void> {        get {            if let event = objc_getAssociatedObject(self,                 &changedEventKey) as? Event<Void> {                return event            } else {                let event = Event<Void>()                objc_setAssociatedObject(self,                     &changedEventKey,                     event,                     .OBJC_ASSOCIATION_RETAIN_NONATOMIC)                return event            }        }    }}

С помощью протокола INotifyOnChanged и его дефолтной реализации любая вью-модель сможет бесплатно получить событие changed. С появлением INotifyOnChanged дефолтная реализация протокола IHaveViewModel вынуждена будет немного эволюционировать: в ней мы захотим подписаться на изменение вью-модели и вызвать viewModelChanged(_:) в обработчике события:


extension IHaveViewModel {    var anyViewModel: Any? {        get {            return objc_getAssociatedObject(self, &viewModelKey)        }        set {            (anyViewModel as? INotifyOnChanged)?.changed.unsubscribe(self)            let viewModel = newValue as? ViewModel            objc_setAssociatedObject(self,                 &viewModelKey,                 viewModel,                 .OBJC_ASSOCIATION_RETAIN_NONATOMIC)            if let viewModel = viewModel {                viewModelChanged(viewModel)            }            (viewModel as? INotifyOnChanged)?.changed.subscribe(self) { this in                if let viewModel = viewModel {                    this.viewModelChanged(viewModel)                }            }        }    }}

И, наконец, финальный штрих:


final class OrdersVM: INotifyOnChanged {    var orders: [OrderVM] = []    private var ordersProvider: OrdersProvider    init(ordersProvider: OrdersProvider) {        self.ordersProvider = ordersProvider    }    func loadOrders() {        ordersProvider.loadOrders() { [weak self] model in            self?.orders = model.map { OrderVM(name: $0.name) }            self?.changed.raise() // Пыщ!        }    }}

Все, что мы делали выше класс Weak<T>, класс Event<Args>, протокол INotifyOnChanged и его дефолтная реализация, было нужно ради того, чтобы мы смогли написать одну единственную строчку кода во вью-модели: changed.raise().


Вызов rise(), произведенный в подходящий момент, после получения всех данных, приводит к тому, что в контроллере вызывается метод viewModelChanged(_:), который перезагружает таблицу, и она успешно отображает список заказов.


One More Thing: подписка на изменение отдельных свойств модели представления через обертки свойств


Протокол INotifyOnChanged и событие changed неплохо справляются с задачей уведомления об обновлении всей вью-модели с последующей перерисовкой всей вьюхи. В большинстве случаев этого вполне достаточно, но что, если мы хотим из соображений производительности или, что более важно, ради развлечения рассказать View об изменении какого-то одного свойства ViewModel? Очевидно, что мы можем для этих целей завести во вью-модели отдельное событие myPropertyChanged, подписаться на него на стороне вьюхи и дело сделано.


Но зачем самим писать код, который за нас могут генерировать инженеры Apple?


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


Чтобы написать свой property wrapper, нужно создать класс или структуру, предоставить свойство wrappedValue и украсить все это дело, как вишенкой на торте, атрибутом @propertyWrapper. Однако обертки свойств не так просты и позволяют манипулировать не только самим свойством, которое они оборачивают, но и его проекцией через специальное свойство projectedValue. Согласитесь, звучит очень непонятно, поэтому, чтобы еще больше вас запутать, рассмотрим такой код:


@propertyWrapperstruct Observable<T> {    let projectedValue = Event<T>()    init(wrappedValue: T) {        self.wrappedValue = wrappedValue    }    var wrappedValue: T {        didSet {            projectedValue.raise(wrappedValue)        }    }}

Мы только что создали обертку свойства и назвали ее Observable. Она умеет работать со свойствами любых типов и может похвастаться наличием projectedValue. Проекция является событием, которое обучено сообщать своим подписчикам о любых изменениях wrappedValue. Это событие, как видно из кода, мы используем по своему прямому назначению в didSet.


Имея в своем арсенале обертку Observable<T>, мы можем применить ее к списку заказов:


@Observablevar orders: [OrderVM] = []

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


private var _orders = Observable<[OrderVM]>(wrappedValue: [])var orders: [OrderVM] {  get { _orders.wrappedValue }  set { _orders.wrappedValue = newValue }}var $orders: Event<[OrderVM]> {  get { _orders.projectedValue }}

Видно, что, помимо свойства orders, которое через обертку просто возвращает wrappedValue, компилятор сгенерировал дополнительное свойство $orders, которое возвращает нам projectedValue. В нашем случае projectedValue это экземпляр события, что позволяет на стороне вьюхи подписаться на изменение свойства orders вот таким нехитрым образом:


viewModel.$orders.subscribe(self) { this, orders in    this.update(with: orders)}

Поздравляю! Вы только что в 15 строчках кода написали свой собственный аналог атрибута Published из фрэймворка Combine от Apple, а я только что дописал очередную статью.


Заключение


Сегодня мы вероломно поступились основным принципом работы расширений, хакнув их с помощью Objective-C-рантайма. Это позволило нам, используя протоколы и экстеншены, реализовать паттерн MVVM в одном маленьком приложении под iOS. В процессе у нас возникло непреодолимое желание применить реактивный фреймворк, и мы едва удержались, написав вместо этого свою реализацию событий, вдохновившись дружественной технологией .NET. Попутно познакомились с парой полезных техник iOS-разработки, таких как shadow type erasure и property wrappers с применением projected value.




Весь код из этой статьи можно скачать в виде Swift Playground.

Подробнее..

Легковесный роутинг на микросервисах

17.07.2020 08:05:27 | Автор: admin


Мобильные приложения в последнее время стали по-настоящему большими не только в смысле своей значимости для нас с вами, но и в прямом смысле.


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


Кто делает всю эту важную, но незаметную работу? Советую запастись чаем и печеньками: это мини-сериал о том, как я ни в чем себе не отказывал, реализуя MVVM в одном из своих домашних проектов. Сегодня заключительная серия про слой роутинга в iOS-приложении.


Введение


Спешу сообщить, что у меня для вас две новости: первая и вторая. Начну с первой: прежде чем читать эту статью, придется прочитать предыдущие две (первая, вторая), иначе будет совершенно ничего не понятно. Вторая новость заключается в том, что в этот раз я в самом начале статьи покажу конечный результат. А вот и он:



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


Напомню также, что у меня есть некоторые правила и я стараюсь их придерживаться:


Некоторые правила, которых я стараюсь придерживаться
  1. Не выпендривайся. Тупой и понятный код в большинстве случаев лучше умного и непонятного.
  2. Будь краток. Кода должно быть настолько мало, чтобы его не жалко было в любой момент выкинуть и написать заново за один день.
  3. Удобство превыше правил. Если можно облегчить себе жизнь, пожертвовав принципами SOLID, пожертвуй принципами SOLID.
  4. Получай удовольствие. Если есть разные варианты решения проблемы, выбирай более веселый.

Традиционно в начале статьи будет ее содержание.


Традиционное содержание



В чем проблема?


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



Для тех, кто предпочитает больше конкретики, вот примитивная до неприличия реализация вью-модели нового экрана:


final class OrderDetailsVM: IPerRequest {    typealias Arguments = Order    let title: String    required init(container: IContainer, args: Order) {        self.title = "Details of \(args.name) #\(args.id)"    }}

Модель представления деталей заказа реализует IPerRequest (подробности в статье про DI), а значит, доступна из DI-контейнера. В качестве аргументов она принимает модель заказа и формирует из нее строковый заголовок, пригодный для отображения пользователю. Контроллер этого экрана будет выглядеть не намного сложнее:


final class OrderDetailsVC: UIViewController, IHaveViewModel {    typealias ViewModel = OrderDetailsVM    private lazy var titleLabel = UILabel()    override func viewDidLoad() {        super.viewDidLoad()        view.backgroundColor = .white        view.addSubview(titleLabel)        titleLabel.translatesAutoresizingMaskIntoConstraints = false        titleLabel.centerXAnchor            .constraint(equalTo: view.centerXAnchor)            .isActive = true        titleLabel.topAnchor            .constraint(equalTo: view.topAnchor, constant: 24)            .isActive = true    }    func viewModelChanged(_ viewModel: OrderDetailsVM) {        titleLabel.text = viewModel.title    }}

Контроллер OrderDetailsVC реализует IHaveViewModel (подробности в статье про MVVM) и просто отображает текст, который подготовила для него вью-модель. Для тестовых целей нам этого вполне достаточно.


Чтобы научить OrdersVC реагировать на тап по ячейке таблицы, дополним его экстеншеном:


extension OrdersVC: UITableViewDelegate {    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {        viewModel?.showOrderDetails(forOrderIndex: indexPath.row)    }}

Кстати, этот короткий кусок кода наглядно показывает, как паттерн MVC, который безраздельно властвует в мире iOS, уживается с паттерном MVVM на территории одного приложения. Действие пользователя (тап по ячейке таблицы) обрабатывается контроллером, потому что в iOS по-другому быть не может. Однако контроллер ничего самостоятельно не делает, вместо этого он делегирует всю грязную работу своей модели представления, снабдив ее необходимой информацией в виде индекса интересующего нас заказа.


Напомню, что OrdersVM, которую мы реализовывали в прошлой статье, выглядит так:


final class OrdersVM: IPerRequest, INotifyOnChanged {    typealias Arguments = Void    var orders: [OrderVM] = []    private let ordersProvider: OrdersProvider    required init(container: IContainer, args: Void) {        self.ordersProvider = container.resolve()    }    func loadOrders() {        ordersProvider.loadOrders() { [weak self] model in            self?.orders = model.map { OrderVM(order: $0) }            self?.changed.raise()        }    }    func showOrderDetails(forOrderIndex index: Int) {        let order = orders[index].order        // Что было дальше?        // ...    }}

Эта модель представления реализует IPerRequest, а значит, доступна из контейнера. Также из контейнера она извлекает OrdersProvider, с помощью которого осуществляет загрузку заказов. По окончании загрузки список заказов заботливо складывается в массив orders, а вью-контроллер получает соответствующее уведомление посредством вызова changed.raise().


В методе showOrderDetails(forOrderIndex:) мы находим нужный заказ и должны открыть новый экран, который отображает детали этого заказа. Чтобы модально показать экран в iOS, нужно создать контроллер этого экрана и воспользоваться методом present(_:animated:completion:), который следует вызвать на текущем контроллере.


В мире MVC это делается очень просто, но в мире MVVM такая элементарная задача вызывает затруднения: вью-модель абсолютно ничего не знает о текущем контроллере. Кроме того, вью-модель понятия не имеет, как создавать новые вью-контроллеры и вью-модели для них. Выпутаться из этой неприятной истории нам поможет отдельный сервис роутер, который осуществит навигацию на нужный экран.


Стоп, что за сервисы вообще такие?


Скорее всего, вы заметили, что модель представления OrdersVM не занимается загрузкой заказов самостоятельно, эту работу она поручает сервису OrdersProvider.


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


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



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


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


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


  1. Декомпозируйте функциональность приложения на (микро)сервисы с четко определенной зоной ответственности.
  2. Активно используйте композицию сервисов для повторного использования кода.
  3. Используйте DI-контейнер для разрешения зависимостей.

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


Обязательно нужен отдельный сервис для роутинга?


Действительно, если действие пользователя, такое как тап в ячейку, прилетает сразу в контроллер, почему бы из этого контроллера не показать новый экран простым вызовом present(_:animated:completion:). Я голосую против, потому что такой подход удобнее только на первый взгляд:


  1. Не всегда переход на другой экран результат действия пользователя. Например, мы можем захотеть показать новый VC по окончании какого-то асинхронного запроса, который будет происходить во вью-модели.
  2. Решение о том, какой экран показать, не всегда тривиальное. Это может быть результат работы сложной бизнес-логики, поэтому удобнее запустить показ экрана в том месте, где вся эта бизнес-логика происходит.
  3. На логику, упомянутую в предыдущем пункте, могут быть написаны тесты. Роутер можно замокать и производить проверки относительно того, какой экран мы собираемся открывать в том или ином случае.
  4. В целях отладки кода удобно иметь единую точку входа для всей навигации в приложении роутер. Это позволяет поставить брейкпоинт в нужном месте и проследить, откуда осуществляется тот или иной переход.

Такие образом, будет полезно вынести функциональность роутинга в отдельный сервис или в несколько сервисов. За показ модальных экранов мог бы отвечать, скажем, PresenterService.


Окей, автор, как мне реализовать роутер?


Вот три простых шага на пути к модальному открытию нового экрана:


  1. Найти экземпляр UIViewController, с которого будет осуществляться переход.
  2. Создать вью-контроллер нового экрана и вью-модель для него.
  3. Осуществить переход на новый экран.

Начнем с того, что объявим сам класс и сделаем его доступным из контейнера:


final class PresenterService: ISingleton {    private unowned let container: IContainer    public required init(container: IContainer, args: Void) {        self.container = container    }}

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


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


var topViewController: UIViewController? {   let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }   return findTopViewController(in: keyWindow?.rootViewController)}func findTopViewController(in controller: UIViewController?) -> UIViewController? {   if let navigationController = controller as? UINavigationController {       return findTopViewController(in: navigationController.topViewController)   } else if let tabController = controller as? UITabBarController,       let selected = tabController.selectedViewController {       return findTopViewController(in: selected)   } else if let presented = controller?.presentedViewController {       return findTopViewController(in: presented)   }   return controller}

Метод findTopViewController(in:) врывается в иерархию контроллеров, как товарищ майор с обыском, и пытается найти там контроллер, который в данный момент отображается на экране. Возможно, это не самый универсальный способ решить задачу и, если в вашем приложении используется более запутанная структура экранов, потребуются некоторые правки, но идея, думаю, понятна.


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


func present<VC: UIViewController & IHaveViewModel>(    _ viewController: VC.Type,    args: VC.ViewModel.Arguments) where VC.ViewModel: IResolvable {    let vc = VC()    vc.viewModel = container.resolve(args: args) // Тут вся магия    topViewController?.present(vc, animated: true, completion: nil)}

Давайте разбираться. Этот метод невероятно тесно интегрирован с нашей реализацией MVVM и с DI-контейнером и состоит, как вы наверняка заметили, всего из трех строк.


  1. В первой строке мы пользуемся тем, что у любого контроллера есть пустой инициализатор, и создаем экземпляр этого контроллера, зная его тип.
  2. Во второй строке мы создаем вью-модель и присваиваем ее соответствующему свойству контроллера. Вью-модель мы можем создать благодаря тому, что обязали ее реализовать IResolvable (про это была статья про DI). Нам всего лишь нужно знать ее тип и аргументы, от которых она зависит. Тип вью-модели известен, потому что все вью-контроллеры предоставляют свойство viewModel в рамках реализации протокола IHaveViewModel (про это была статья про MVVM). Кроме того, у нас имеются необходимые аргументы VC.ViewModel.Arguments и доступ к контейнеру прямо из сервиса. При создании экземпляра вью-модели с помощью магии DI-контейнера самым удобным образом разрешаются все ее зависимости. Прочувствуйте момент: DI-контейнер, MVVM и роутинг сходятся здесь и сейчас в одной точке, и эта точка одна строчка кода. Ух!
  3. И, наконец, в третьей строке, вооружившись знанием о том, какой вью-контроллер сейчас отображается на экране, мы осуществляем показ только что созданного контроллера с помощью банального вызова present(_:animated:completion:).

Чтобы пазл сложился, давайте еще раз взглянем на весь код PresenterService, который до этого мы разбирали по кусочкам:


final class PresenterService: ISingleton {    private unowned let container: IContainer    private var topViewController: UIViewController? {        let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }        return findTopViewController(in: keyWindow?.rootViewController)    }    required init(container: IContainer, args: Void) {        self.container = container    }    func present<VC: UIViewController & IHaveViewModel>(        _ viewController: VC.Type,        args: VC.ViewModel.Arguments) where VC.ViewModel: IResolvable {        let vc = VC()        vc.viewModel = container.resolve(args: args)        topViewController?.present(vc, animated: true, completion: nil)    }    func dismiss() {        topViewController?.dismiss(animated: true, completion: nil)    }    private func findTopViewController(        in controller: UIViewController?) -> UIViewController? {        if let navigationController = controller as? UINavigationController {            return findTopViewController(in: navigationController.topViewController)        } else if let tabController = controller as? UITabBarController,            let selected = tabController.selectedViewController {            return findTopViewController(in: selected)        } else if let presented = controller?.presentedViewController {            return findTopViewController(in: presented)        }        return controller    }}

Единственный незнакомый метод, который здесь добавился, это dismiss(), позволяющий закрыть текущий модальный экран. Окончательная реализация OrdersVM, которая с помощью PresenterService научилась отображать детали заказа, выглядит так:


final class OrdersVM: IPerRequest, INotifyOnChanged {    typealias Arguments = Void    var orders: [OrderVM] = []    private let ordersProvider: OrdersProvider    private let presenter: PresenterService    required init(container: IContainer, args: Void) {        self.ordersProvider = container.resolve()        self.presenter = container.resolve()    }    func loadOrders() {        ordersProvider.loadOrders() { [weak self] model in            self?.orders = model.map { OrderVM(order: $0) }            self?.changed.raise()        }    }    func showOrderDetails(forOrderIndex index: Int) {        let order = orders[index].order        // Открываем экран с деталями заказа        presenter.present(OrderDetailsVC.self, args: order)    }}

Как видно, в инициализаторе мы без лишней суеты достаем из контейнера наш PresenterService и используем его по назначению в методе showOrderDetails(forOrderIndex:).


Не хочу модальные экраны, хочу пушить экраны в стэк. Как быть?


Для работы с UINavigationController придется написать отдельный сервис. Назовем его, например, NavigationService. Вот три простых шага, которые нужно сделать, чтобы запушить новый экран:


  1. Найти экземпляр UINavigationController, который сейчас виден на экране.
  2. Создать вью-контроллер нового экрана и вью-модель для него.
  3. Осуществить переход на новый экран.

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


Реализация NavigationService
final class NavigationService: ISingleton {    private unowned let container: IContainer    private var topNavigationController: UINavigationController? {        let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }        let root = keyWindow?.rootViewController        let topViewController = findTopViewController(in: root)        return findNavigationController(in: topViewController)    }    required init(container: IContainer, args: Void) {        self.container = container    }    func pushViewController<VC: UIViewController & IHaveViewModel>(        _ viewController: VC.Type,        args: VC.ViewModel.Arguments) where VC.ViewModel: IResolvable {        let vc = VC()        vc.viewModel = container.resolve(args: args)        topNavigationController?.pushViewController(vc, animated: true)    }    func popViewController() {        topNavigationController?.popViewController(animated: true)    }    private func findTopViewController(        in controller: UIViewController?) -> UIViewController? {        if let navigationController = controller as? UINavigationController {            return findTopViewController(in: navigationController.topViewController)        } else if let tabController = controller as? UITabBarController,            let selected = tabController.selectedViewController {            return findTopViewController(in: selected)        } else if let presented = controller?.presentedViewController {            return findTopViewController(in: presented)        }        return controller    }    private func findNavigationController(        in controller: UIViewController?) -> UINavigationController? {        if let navigationController = controller as? UINavigationController {            return navigationController        } else if let navigationController = controller?.navigationController {            return navigationController        } else {            for child in controller?.children ?? [] {                if let navigationController = findNavigationController(in: child) {                    return navigationController                }            }        }        return nil    }}

Сервисы, подобные NavigationService и PresenterService, нужно будет написать для всех контроллеров, которые являются контейнерами для других контроллеров как для стандартных типа UITabBarController, так и для кастомных. Группа таких сервисов образует слой роутинга в вашем приложении.


Мне не подходит реализация роутинга. Что делать?


Весь код в этой и предыдущих статьях очень простой, в нем важна скорее идея, а не реализация. Напишите свою версию MVVM, роутинга, используйте другой DI-контейнер все это категорически неважно. Важны основополагающие принципы, важны прямоугольники со скругленными углами и стрелочки между ними:



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


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


Заключение


Сегодня мы говорили про роль (микро)сервисов в мобильных приложениях на примере роутинга. Сервисы роутинга мостик между миром MVC и миром MVVM. Они помогают вью-моделям осуществлять навигацию на новые экраны и имеют право напрямую обращаться к DI-контейнеру для создания пар вьюха вью-модель.


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




Весь код из этой статьи можно скачать в виде Swift Playground.

Подробнее..

Мой Covid-19 lockdown проект, или как я полез в кастомный UICollectionViewLayout и получил ChatLayout

14.10.2020 20:13:37 | Автор: admin

image


Да, да. Я понимаю что на дворе 2020 год, что все хардкорные IOS разработчики пишут исключительно на SwiftUI и Combine, и писать статьи про UIKit как то не айс. И, тем не менее, 2020 год выдался не таким как все предыдущие года. Совсем не таким.


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


Чат использует MessageKit в качестве UI компонента. MessageKit swift библиотека поддерживаемая комьюнити призванная заменить устаревшую JSQMessagesViewController. Мне довелось работать с JSQMessagesViewController лет эдак 5 назад. Он справлялся со своими задачами, был минимально гибок, написан в лучших традициях UIKit где все наследуется от всего и, конечно, к выходу swift-а уже полностью морально устарел. К счастью, мне не пришлось к нему больше возвращаться и я только порадовался появившейся тогда инициативе написать библиотеку для создания UI для чата которая бы заменила JSQMessagesViewController. И забыл об этом до марта 2020-го.


Как же я возненавидел MessageKit в марте 2020-го. Она мне показалась построчной перепиской JSQMessagesViewController с Objective-C на Swift и приветом из IOS разработки 2009 года. Я не буду вдаваться в подробности и ругать чужую работу в которой я не принимал участие.


Но, среди огромного количества issues, которые остаются открытыми на гит хабе, или тех, которые прямо документированы в коде библиотеки, остро выделялась проблема скроллинга. UIScrollView по умолчанию ведет себя так, что якорь скроллинга закреплен в в верхнем левом углу контента и новый контент добавляется в низ без сдвига остального контента. Это поведение подходит для 99% случаев использования, но не для чата, где ожидается обратное поведение. При добавлении нового контента в низ нам нужно сместить коэффициент сдвига (contentOffset) на величину добавленного контента.


Этот факт натолкнул меня на простую мысль. Все это делается какими-то странными хуками и паразитированием на UICollectionViewFlowLayout. Почему бы просто не написать лайаут который делал бы все это из коробки?


Второй момент, который подтолкнул меня к тому что хорошо бы разобраться с UICollectionViewLayout было то что несмотря на паразитирование на UICollectionViewFlowLayout, MessageKit не поддерживала автоматические размеры ячеек используя Auto-Layout и нужно было все размеры считать в коде самому. Не смотря на то что данная фича доступна в UICollectionViewFlowLayout из коробки.


Ну значит решено. Я напишу свой кастомный UICollectionViewLayout где постараюсь добавить из коробки все что нужно для чата. Сказано сделано. Я создал проект и создал класс который унаследовал от UICollectionViewLayout.
Все казалось довольно простым и я полез в документацию.


Анимированные вставка, удаление, обновление и изменение порядка


И вот тут то меня ждал первый сюрприз. Официальная документация на UICollectionViewLayout отсутствовала. Точнее она как бы номинально присутствует. Но слабо соответсвует действительности или опускает некоторые очень важные моменты.


Так, например, первым делом, я решил разобраться даже не с размером ячеек, а с анимацией вставки, удаления, обновления и изменения порядка ячеек. Как видим тут 4 типа обновления ячейки. За это дело в UICollectionViewLayout отвечают 2 метода:
initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes?
finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes?
А вот и официальная документация initialLayoutAttributesForAppearingItem и finalLayoutAttributesForDisappearingItem
Давайте прочтем документацию к initialLayoutAttributesForAppearingItem вместе:


When your app inserts new items into the collection view, the collection view calls this method for each item you insert. Because new items are not yet visible in the collection view, the attributes you return represent the items starting state. For example, you might return attributes that position the item offscreen or set its initial alpha to 0. The collection view uses the attributes you return as the starting point for any animations. (The end point of the animation is the items new location and attributes.) If you return nil, the layout uses the items final attributes for both the start point and end point of the animation.
The default implementation of this method returns nil. Subclasses are expected to override this method, as needed, and provide any initial attributes.

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


If you return nil, the layout uses the items final attributes for both the start point and end point of the animation.

и попробовать реализовать все остальное используя вот это утверждение.


Не тут то было. Оно работает так для самых примитивных случаев. Но, еще и самое забавное что никак не описан момент а что же происходит c itemIndexPath? Вот вставил я ячейку с индексом 0. Та которая была до этого 0 вероятно станет 1? А в какой момент? А если я вставляю ячейку 0 и сдвину ячейку которая была до этого номером 0 за ячейку номер 2?


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


Тогда я решил что я буду логировать вызовы функций UICollectionViewFlowLayout и то, что выводит мой ChatLayout, и, когда приведу их к одному и тому же состоянию, наверняка, мой код будет вести себя идентично. Я провел несколько вечером вглядываясь в листинги вызова функций и отдаваемых значений UICollectionViewFlowLayout и моего лайаута, пытаясь уловить логику и подкручивая мой лайаут что бы он выдавал те же значения. О, Наивный. В тот момент я еще не знал/не думал что UICollectionViewFlowLayout использует private API мне не доступное Нетленная классика от Apple и UIKit. Вообще, это все тянет на отдельную статью. Скажу только что в какой то момент, когда я уже был готов сдаться, у меня все типы этой анимации стали получаться. При том что мой лог вызовов/значений отличался от того что выдавал UICollectionViewFlowLayout. Ну работает и работает.


Определение размера ячейки при помощи AutoLayout



Настало время идти дальше и разобраться с автоматическим определением размеров используя AutoLayout. Тут я стал задаваться вопросом. А не изобретаю ли я велосипед. Почему же все так сложно? Померить то еще можно, но как это все впихнуть еще и в анимацию? Может уже есть кто-то кто разобрался со всеми этими проблемами и готовое решение лежит на гит-хабе?


Первая странность была в том что кастомных UICollectionViewLayout не очень то и много. Да они существуют. Но они могут только разложить ячейки в зависимости от задачи которые они решают, а вот анимацию или не поддерживают толком или анимированная вставка будет из коробки. Либо они наследуются от UICollectionViewFlowLayout и как то там подменяют ему атрибуты в prepare методе и пытаются как то с этим взлететь. Причем, вся эта анимация и поддержка AutoLayout ячеек отсутсвует или оставляет желать лучшего. И тут, я натолкнулся на луч света в темном царстве: airbnb/MagazineLayout. Вот прям реально и без сарказма. Это был единственный реально кастомный лайаут который делал почти все что я хотел. И я с удивлением обнаружил что моя реализация initialLayoutAttributesForAppearingItem /finalLayoutAttributesForDisappearingItem ну прям очень похожа на то что написали ребята из AirBnB.


Но дальше по вопросу задачи. Я подсмотрел у них то, что заняло бы у меня "сложно предположить" сколько времени как при обновлении ячейки заменить размеры ячейки с анимацией! Я пробовал разные способы и они работали, но всегда находился случай когда они работали криво.


Так вот, некое подобие состояния ячейки описано в UICollectionViewLayoutAttributes, и если во время анимации вы сохраните именно тот объект который вы вернули из initialLayoutAttributesForAppearingItem а потом в методе invalidationContext(forPreferredLayoutAttributes:, withOriginalAttributes originalAttributes:) вы возьмете этот объект и поменяете у него значение frame то внутри UICollectionView сработает какое то KVO (Key-Value-Observing) и она выдаст вам анимацию которую вы ожидаете. Забудьте о preferredAttributes, забудьте originalAttributes. Стабильно работает только вот так. РукаЛицо.


О прокрутке


Теперь, когда вроде что то как то заработало настала очередь разбираться с прокруткой. Точнее мне нужно было изменить поведение UIScrollView на обратное стандартному, что бы когда мы добавляем контент в конец, он не уходил вниз, а наоборот все остальное сдвигалось вверх. Некоторые разработчики решают это поворачивая коллекцию вверх ногами а потом переворачивая контент внутри обратно. Но тогда вы теряете стандартные вещи такие как adjustedContextInsets, и отступ клавиатуры надо отсчитывать сверху а не снизу, и вообще вся геометрия встает с ног на голову. Я подумал что решить это наверняка можно прям из UICollectionViewLayout. Казалось бы, простая задача. У UICollectionViewLayout есть метод targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint Переопределяй его и будет тебе счастье, UICollectionView перед обновлением скажет тебе я мол вот собираюсь сделать свой contentOffset вот таким, а если ты хочешь его поправить верни мне свой.


Вот только проблема в том что этот вызов происходит еще до того как ты можешь получить какие то реальные размеры ячеек, и, если какие то расчеты еще происходят во время анимированного апдейта, использовать его не получится. Да, можно частично в процессе анимированного апдейта возложить расчеты на имеющийся у UICollectionViewLayoutInvalidationContext contentOffsetAdjustment, но, вы не всегда можете им воспользоваться до завершения транзакции анимации. Поэтому пришлось рассчитывать 2 разных величины, proposedContentOffset то, что можно понять до начала анимации, и вторую которой я воспользуюсь в конце транзакции в методе finalizeCollectionViewUpdates. Вроде добился того что надо. Все это работало хорошо пока вы не удаляете ячейки таким образом, что у вас сверху не появляются ячейки, которые еще не были рассчитаны с помощью AutoLayout, и которые вовремя анимации могут изменить свой размер. А вот UICollectionView при уменьшении размера контента (contentSize) начинает игнорировать contentOffsetAdjustment и никаким образом я не смог заставить ее работать так как хотелось мне: ни используя contentSizeAdjustment в различных комбинациях, ни даже меняя contentOffset напрямую. Решением оказалось игнорировать необработанные ячейки при анимации и рассчитывать все остальное потом.


Но главное что желаемого удалось достичь.


Немного о багах и private API



У меня, при прокрутке, изображение слегка подергивалось и я никак не мог понять почему. Но решил не сосредотачиваться пока более менее не прояснится картинка. А потом начал гуглить и нашел вот такой rdar://40926834: Self Sizing + Prefetching Bugs От тех же замечательных ребят из AirBnB. Выключив префетчинг который включен по умолчанию, я моментально избавился от подобного поведения.


А вот еще один от них же rdar://46865293: Cell's autoresizing mask breaks self-sizing. Ну вот не вызывается func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? столько раз сколько обещано, хоть ты тресни.


Мой любимый, так же подсмотренный в MagazineLayout это использование UICollectionViewFlowLayout приватного метода _prepareForCollectionViewUpdates:withDataSourceTranslator: для того что бы все красиво рассчитать до вызова открытого метода prepareForCollectionViewUpdates. Ай да Apple, Ай да UIKit. Это удалось обойти введение специального флага: если транзакция начинается скажи UICollectionView что в ней ничего нет и начинай рассчитывать исходя из того что получишь в prepareForCollectionViewUpdates. Это позволило избавиться от некоторых артефактов.


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


Может, конечно, это и не баги вовсе. Возможно, я что то не понял. И, вообще, зря я на ребят из UIKit наезжаю. Я оставлю этот вопрос открытым.


Немного о хрупкости


Вообще, все эти UICollectionView + UICollectionViewLayout вещи довольно хрупкие. Меняете размер коллекции и, в этот момент, что то вставляете получите артефакты. Меняете анимированно contentInset, например, для клавиатуры, и что то меняете в коллекции получите артефакты. Скроллите коллекцию и меняете ее получите артефакты. Просто что то делаете не так получите артефакты. Почему я не исправлял некоторые вещи? Если я подставлял вместо своего лайаута UICollectionViewFlowLayout и получал точно такое же поведение я записывал это в недостатки самого устройства связки UICollectionView + UICollectionViewLayout и это означало, что они должны быть исправлены "из вне". Поэтому, вы увидите в приложении-примере, которое идет вместе с библиотекой, набор флагов из серии: выдвигаешь клавиатуру не обновляй/Выдвинул обнови и т.д.


Пора в продакшен?



К концу мая, проведя еще пару недель в профайлере устраняя проблемы производительности, я закончил некую публичную бету библиотеки. В свое свободное время выпилил из нашего приложения MessageKit и заменил его на ChatLayout. Удалил огромное количество которое обслуживало MessageKit и различные воркэраунды в попытке поправить ее недостатки. Вот просто удалил с облегчением.


Из нового кода оказался только UIViewController средних размеров. Средних это если вы понимаете о чем я. И я смог убедиться что все это может и должно работать в продакшене. Показал коллегам и после полного тестирования было/стало мы смержили это в master.


Больше мы не используем MessageKit. И пока что всем довольны. Производительность и удобство внесения изменений оказались достойными периодических похвал коллег. А большинство открытых багов чата в Jira закрылись сами собой.


Что в остатке?


А в остатке оказалась открытая библиотека ChatLayout. Не смотря на то, что я, пока, считаю ее preview, она вполне пригодна для использования в продакшене. И, мне кажется, что если кто-то еще начнет ее использовать, то, благодаря возможностям комьюнити, получится устранить ее возможные изъяны и добавить функции, которые мне не были нужны. Цель этой статьи представить ее сообществу и привлечь разработчиков.


Я оставил пока код слегка не оптимизированым для того что бы было удобнее вносить изменения. Без всякой там прямой записи в память при изменении и т.д. Производительность в релизной сборке на актуальных устройствах достойная и без этого. Кроме того: надо вычистить приложение-пример. Да и тестов добавить. Я сам помимо основной работы поддерживаю библиотеку RouteComposer. и, боюсь, что 2 open-source проекта я просто не потяну. Я, вот, едва нашел время и вдохновение написать эту статью.


Перечислю то что я на данный момент считаю достоинствами данного подхода.


О библиотеке


ChatLayout альтернативный подход к созданию UI для чата. Фактически библиотека состоит кастомного UICollectionViewLayout, который предоставляет некоторые дополнительные методы, которые могут потребоваться для решения этой задачи. Плюсом, в Extras лежат небольшие UIView, которые, при желании, можно использовать для каких-то тривиальных задач.


Достоинства


  • Полностью поддерживает динамический лайаут для ячеек (UICollectionViewCell) и вспомогательных UIView (UICollectionReusableView).
  • Поддерживает анимированную вставку/удаление/обновление/перемещение ячеек.
  • Удерживает contentOffset у основания видимой области UICollectionView во время апдейтов.
  • Предоставляет методы для точной прокрутки к нужной ячейке.
  • Поставляется с простыми UIView-контейнерами, которые могут облегчить создание кастомных ячеек сообщений.

Недостатки (но, по-моему, все равно достоинства)


ChatLayout это кастомный UICollectionViewLayout, поэтому:


  • Вам не нужно наследоваться от какого либо UIViewController или UICollectionView. Вам нужно написать их самим. Так как вам удобно
  • Вам не нужно использовать какие то особые UICollectionViewCell которые идут с библиотекой. Создавайте их так как вам удобно.
  • Вам не нужно в обязательном порядке рассчитывать размеры ячеек. ChatLayout сделает это за вас. При том, только для видимых в данный момент ячеек, не важно, сколько их в всего в коллекции. Но, если вы укажите хотя бы приблизительный размер производительность только выиграет.
  • Вам не предоставляется никакая базовая модель данных. Создавайте ее как вам удобно. И напишите свой UICollectionViewDataSource который будет отображать ее на экране. Приложение-пример использует для расчета изменений модели DifferenceKit, но вы можете использовать что угодно.
  • ChatLayout не работает с клавиатурой. Вы можете написать свое решение или можете использовать любую подходящую для этого библиотеку. Все что требуется от вас в конечно итоге для поддержки клавиатуры это изменить contentInsets вашего UICollectionView.
  • ChatLayout не предоставляет вам никакого поля для ввода текста. Приложение-пример использует стороннюю библиотеку InputBarAccessoryView (ту же что использует MessageKit). Вы можете использовать ее или любое другое решение.

Собственно все. Спасибо за внимание. Буду рад Вашим комментариям.


Ах, да. Гифки. (Осторожно эпилептикам)






Подробнее..

Мой Covid-19 lockdown проект, или, как я полез в кастомный UICollectionViewLayout и получил ChatLayout

14.10.2020 22:06:16 | Автор: admin

image


Да, да. Я понимаю что на дворе 2020 год, что все хардкорные IOS разработчики пишут исключительно на SwiftUI и Combine, и писать статьи про UIKit как то не айс. Тем не менее, 2020 год выдался не таким как все предыдущие года. Совсем не таким.


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


Наш чат использует MessageKit в качестве UI компонента. MessageKit swift библиотека, поддерживаемая комьюнити, призванная заменить устаревшую JSQMessagesViewController. Мне довелось работать с JSQMessagesViewController лет эдак 5 назад. Он справлялся со своими задачами, был минимально гибок, написан в лучших традициях UIKit где все наследуется от всего, и он, конечно, к выходу swift-а уже полностью морально устарел. К счастью, мне не пришлось к нему больше возвращаться и я только порадовался появившейся тогда инициативе написать библиотеку для создания UI для чата которая бы заменила JSQMessagesViewController. И забыл об этом до марта 2020-го.


Как же я возненавидел MessageKit в марте 2020-го. Она мне показалась построчной перепиской JSQMessagesViewController с Objective-C на Swift и приветом из IOS разработки 2009 года. Я не буду вдаваться в подробности и ругать чужую работу, в которой я не принимал участие.


Но, среди огромного количества issues, которые остаются открытыми на гит хабе, или тех, которые прямо документированы в коде библиотеки остро выделялась проблема скроллинга.


UIScrollView по умолчанию ведет себя так, что якорь скроллинга закреплен в в верхнем левом углу, и новый контент добавляется в низ без сдвига остального контента. Это поведение подходит для 99% случаев использования, но не для чата, где ожидается обратное поведение. При добавлении нового контента в низ нам нужно сместить коэффициент сдвига (contentOffset) на величину добавленного контента.


Этот факт натолкнул меня на простую мысль. Все это делается какими-то странными хуками и паразитированием на UICollectionViewFlowLayout. Почему бы просто не написать лайаут который делал бы все это из коробки?


Второй момент, который подтолкнул меня к тому что хорошо бы разобраться с UICollectionViewLayout было то что несмотря на паразитирование на UICollectionViewFlowLayout, MessageKit не поддерживала автоматические размеры ячеек используя Auto-Layout и нужно было все размеры считать в коде самому. Не смотря на то, что данная фича доступна в UICollectionViewFlowLayout из коробки.


Ну значит решено. Я напишу свой кастомный UICollectionViewLayout где постараюсь добавить из коробки все что нужно для чата. Сказано сделано. Я создал проект и создал класс который унаследовал от UICollectionViewLayout.


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


Анимированные вставка, удаление, обновление и изменение порядка


И вот тут то меня ждал первый сюрприз. Официальная документация на UICollectionViewLayout отсутствовала. Точнее она как бы номинально присутствует. Но слабо соответсвует действительности или опускает некоторые очень важные моменты.


Так, например, первым делом, я решил разобраться даже не с размером ячеек, а с анимацией вставки, удаления, обновления и изменения порядка ячеек. Как видим тут 4 типа обновления ячейки.


За это дело в UICollectionViewLayout отвечают 2 метода:
initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes?
finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes?


А вот и официальная документация initialLayoutAttributesForAppearingItem и finalLayoutAttributesForDisappearingItem


Давайте прочтем документацию к initialLayoutAttributesForAppearingItem вместе:


When your app inserts new items into the collection view, the collection view calls this method for each item you insert. Because new items are not yet visible in the collection view, the attributes you return represent the items starting state. For example, you might return attributes that position the item offscreen or set its initial alpha to 0. The collection view uses the attributes you return as the starting point for any animations. (The end point of the animation is the items new location and attributes.) If you return nil, the layout uses the items final attributes for both the start point and end point of the animation.
The default implementation of this method returns nil. Subclasses are expected to override this method, as needed, and provide any initial attributes.

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


If you return nil, the layout uses the items final attributes for both the start point and end point of the animation.

и попробовать реализовать все остальное используя вот это утверждение.


Не тут то было. Оно работает так как описано только для самых примитивных случаев. Еще и самое забавное, что никак не описан момент а что же происходит c itemIndexPath? Вот вставил я ячейку с индексом 0. Та которая была до этого 0 вероятно станет 1? А в какой момент? А если я вставляю ячейку 0 и сдвину ячейку которая была до этого номером 0 за ячейку номер 2?


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


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


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


О, Наивный. В тот момент я еще не знал/не думал что UICollectionViewFlowLayout использует private API мне не доступное Нетленная классика от Apple и UIKit. Вообще, это все тянет на отдельную статью. Скажу только, что в какой то момент, когда я уже был готов сдаться, у меня все типы этой анимации стали получаться. При том, что мой лог вызовов/значений отличался от того что выдавал UICollectionViewFlowLayout. Ну работает и работает.


Определение размера ячейки при помощи AutoLayout



Настало время идти дальше и разобраться с автоматическим определением размеров используя AutoLayout. Тут я стал задаваться вопросом. А не изобретаю ли я велосипед? Почему же все так сложно? Измерить ячейку то еще можно, но как это все впихнуть еще и в анимацию? Может уже есть кто-то кто разобрался со всеми этими проблемами и готовое решение лежит на гит-хабе?


Первая странность была в том что кастомных UICollectionViewLayout не очень то и много. Да они существуют. Но они могут только разложить ячейки в зависимости от задачи которые они решают, а вот анимацию или не поддерживают толком или анимированная вставка будет из коробки. Либо они наследуются от UICollectionViewFlowLayout и, как то там подменяя ему атрибуты в prepare методе, пытаются как то с этим взлететь. Причем, вся эта анимация и поддержка AutoLayout ячеек отсутсвует или оставляет желать лучшего.


И тут, я натолкнулся на луч света в темном царстве: airbnb/MagazineLayout. Вот прям реально и без сарказма. Это был единственный реально кастомный лайаут который делал почти все что я хотел. И я с удивлением обнаружил, что моя реализация initialLayoutAttributesForAppearingItem /finalLayoutAttributesForDisappearingItem ну прям очень похожа на то, что написали ребята из AirBnB.


Но дальше по вопросу задачи. Я подсмотрел у них то, что заняло бы у меня "сложно предположить" сколько времени как при обновлении ячейки заменить размеры ячейки с анимацией! Я пробовал разные способы и они работали, но всегда находился случай когда они работали криво.


Для справки: некое подобие состояния ячейки описывается в UICollectionViewLayoutAttributes, и, если во время анимации вы сохраните именно тот объект который вы вернули из initialLayoutAttributesForAppearingItem, а потом в методе invalidationContext(forPreferredLayoutAttributes:, withOriginalAttributes originalAttributes:) вы возьмете этот объект и поменяете у него значение frame то внутри UICollectionView сработает какое то KVO (Key-Value-Observing) и она выдаст вам анимацию которую вы ожидаете. Забудьте о preferredAttributes, забудьте originalAttributes. Стабильно работает только вот так. РукаЛицо.


О прокрутке


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


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


Я подумал, что решить это наверняка можно прямо из UICollectionViewLayout. Казалось бы, простая задача. У UICollectionViewLayout есть метод targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint Переопределяй его и будет тебе счастье, UICollectionView перед обновлением скажет тебе: я, мол, вот, собираюсь сделать свой contentOffset вот таким то, а если ты хочешь его поправить верни мне свой.


Вот только проблема в том, что этот вызов происходит еще до того как ты можешь получить какие то реальные размеры ячеек, и, если какие то расчеты еще происходят во время анимированного апдейта, использовать его не получится. Да, можно частично в процессе анимированного апдейта возложить расчеты на имеющийся у UICollectionViewLayoutInvalidationContext contentOffsetAdjustment, но вы не всегда можете им воспользоваться до завершения транзакции анимации. Поэтому пришлось рассчитывать 2 разных величины, proposedContentOffset то, что можно понять до начала анимации, и вторую которой я воспользуюсь в конце транзакции в методе finalizeCollectionViewUpdates. Вроде добился того что надо.


Все это работает хорошо пока вы не удаляете ячейки таким образом, что у вас сверху не начинают появляться ячейки, которые еще не были рассчитаны с помощью AutoLayout и которые вовремя анимации могут изменить свой размер. А вот UICollectionView при уменьшении размера контента (contentSize) начинает игнорировать contentOffsetAdjustment и, никаким образом, я не смог заставить ее работать так как хотелось мне: ни используя contentSizeAdjustment в различных комбинациях, ни даже меняя contentOffset напрямую. Решением оказалось игнорировать необработанные ячейки при анимации и рассчитывать все остальное потом.


Но главное что желаемого результата удалось достичь.


Немного о багах и private API



У меня, при прокрутке, изображение слегка подергивалось и я никак не мог понять почему. Но решил не сосредотачиваться на этом пока более менее не прояснится картинка. А, потом, начал гуглить и нашел вот такой rdar://40926834: Self Sizing + Prefetching Bugs От тех же замечательных ребят из AirBnB. Выключив префетчинг который включен по умолчанию, я моментально избавился от подобного поведения.


А вот еще один от них же rdar://46865293: Cell's autoresizing mask breaks self-sizing. Ну вот не вызывается layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? столько раз сколько обещано, хоть ты тресни.


Мой любимый трюк, так же подсмотренный в MagazineLayout это наглое использование UICollectionViewFlowLayout приватного метода _prepareForCollectionViewUpdates:withDataSourceTranslator: для того что бы все красиво рассчитать до вызова открытого метода prepareForCollectionViewUpdates. Ай да Apple, ай да UIKit. Это удалось обойти введением специального флага: если транзакция начинается скажи UICollectionView что в ней ничего нет и начинай рассчитывать исходя из того что получишь в prepareForCollectionViewUpdates. Это позволило избавиться от некоторых артефактов.


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


Может, конечно, это и не баги вовсе. Возможно, я что то не понял. И, вообще, зря я на ребят из UIKit наезжаю. Я оставлю этот вопрос открытым.


Немного о хрупкости


Вообще, все эти UICollectionView + UICollectionViewLayout вещи довольно хрупкие. Меняете размер коллекции и, в этот момент, что то вставляете получите артефакты. Меняете анимированно contentInset, например, для клавиатуры, и что то меняете в коллекции получите артефакты. Скроллите коллекцию и меняете ее получите артефакты. Просто что то делаете не так получите артефакты. Почему я не исправлял некоторые вещи? Если я подставлял вместо своего лайаута стандартный UICollectionViewFlowLayout и получал точно такое же поведение я записывал это в недостатки самого устройства связки UICollectionView + UICollectionViewLayout и, это означало, что они должны быть исправлены "из вне". Поэтому, вы увидите в приложении-примере, которое идет вместе с библиотекой, набор флагов из серии: выдвигаешь клавиатуру не обновляй/Выдвинул обнови и т.д.


Пора в продакшен?



К концу мая, проведя еще пару недель в профайлере устраняя проблемы производительности, я закончил некую публичную бету библиотеки. В свое свободное время выпилил из нашего приложения MessageKit и заменил его на ChatLayout. Удалил огромное количество кода, которое обслуживало MessageKit и различные воркэраунды в попытке поправить ее недостатки. Вот просто удалил с облегчением.


Из нового кода оказался только UIViewController средних размеров. Средних это если вы понимаете о чем я. И, я смог убедиться, что все это может и должно работать в продакшене. Показал коллегам и, после полного тестирования, было/стало мы смержили это в master.


Больше мы не используем MessageKit. И пока что всем довольны. Производительность и удобство внесения изменений оказались достойными периодических похвал коллег. А большинство открытых багов чата в Jira закрылись сами собой.


Что в остатке?


А в остатке оказалась открытая библиотека ChatLayout.


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


Я оставил пока код слегка не оптимизированым, для того что бы было удобнее вносить изменения. Без всякой там прямой записи в память при изменении и т.д. Производительность в релизной сборке на актуальных устройствах достойная и без этого. Кроме того: надо вычистить приложение-пример. Да и тестов добавить. Я сам помимо основной работы поддерживаю библиотеку RouteComposer. и, боюсь, что 2 open-source проекта я просто не потяну. Я, вот, едва нашел время и вдохновение написать эту статью.


Перечислю то, что я на данный момент считаю достоинствами данного подхода.


О библиотеке


ChatLayout альтернативный подход к созданию UI для чата. Фактически, библиотека состоит кастомного UICollectionViewLayout, который предоставляет некоторые дополнительные методы, которые могут потребоваться для решения этой задачи. Плюсом, в Extras лежат небольшие UIView, которые, при желании, можно использовать для каких-то тривиальных задач.


Достоинства


  • Полностью поддерживает динамический лайаут для ячеек (UICollectionViewCell) и вспомогательных UIView (UICollectionReusableView).
  • Поддерживает анимированную вставку/удаление/обновление/перемещение ячеек.
  • Удерживает contentOffset у основания видимой области UICollectionView во время апдейтов.
  • Предоставляет методы для точной прокрутки к нужной ячейке.
  • Поставляется с простыми UIView-контейнерами, которые могут облегчить создание кастомных ячеек сообщений.

Недостатки (но, по-моему, все равно достоинства)


ChatLayout это кастомный UICollectionViewLayout, поэтому:


  • Вам не нужно наследоваться от какого либо UIViewController или UICollectionView. Вам нужно написать их самим. Так, как вам удобно
  • Вам не нужно использовать какие то особые UICollectionViewCell которые идут с библиотекой. Создавайте их так, как вам удобно.
  • Вам не нужно в обязательном порядке рассчитывать размеры ячеек. ChatLayout сделает это за вас. При том, только для видимых в данный момент ячеек, не важно сколько их в всего в коллекции. Но, если вы укажите хотя бы приблизительный размер производительность только выиграет.
  • Вам не предоставляется никакая базовая модель данных. Создавайте ее как вам удобно. Напишите свой UICollectionViewDataSource который будет отображать ее на экране. Приложение-пример использует для расчета изменений модели DifferenceKit, но вы можете использовать что вам угодно.
  • ChatLayout не работает с клавиатурой. Вы можете написать свое решение или можете использовать любую подходящую для этого библиотеку. Все что требуется от вас в конечно итоге для поддержки клавиатуры это изменить contentInsets вашего UICollectionView.
  • ChatLayout не предоставляет вам никакого поля для ввода текста. Приложение-пример использует стороннюю библиотеку InputBarAccessoryView (ту же, что использует MessageKit). Вы можете использовать ее или любое другое решение.

Собственно все. Спасибо за внимание. Буду рад Вашим комментариям.


Ах, да. Гифки. (Осторожно эпилептикам)






Подробнее..

Адаптируем UITableView под MVVM

05.12.2020 16:17:54 | Автор: admin

Введение

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

На сегодняшний день, каждому iOS разработчику необходимо в совершенстве владеть UITableView, знать тонкости и понимать как его адаптировать под разные архитектуры, чтобы использование не вызывало лишних проблем и трудностей.

В этой статье мы поговорим о том, как адаптировать UITableView под архитектуру Model-View-ViewModel (MVVM). Начнём.

Содержание

  1. Введение

  2. Пример

  3. Реализация

  4. Использование

  5. Результат

  6. Вывод

Пример

В качестве примера я реализовал ячейку с кнопкой, картинкой и текстом.

Реализация

Первым делом создадим подкласс от UITableView и назовем его AdaptedTableView.

class AdaptedTableView: UITableView {    }

Определим метод setup(). Он необходим для конфигурации таблицы. Временно заполним обязательные для реализации методы UITableViewDataSource.

class AdaptedTableView: UITableView {        // MARK: - Public methods        func setup() {        self.dataSource = self    }    }// MARK: - UITableViewDataSourceextension AdaptedTableView: UITableViewDataSource {        func numberOfSections(in tableView: UITableView) -> Int {        .zero    }        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {        .zero    }        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {        UITableViewCell()    }    }

Согласно паттерну MVVM, view владеет viewModel. Создадим абстракцию для входных данных и назовем её AdaptedViewModelInputProtocol. AdaptedSectionViewModelProtocol необходим для описания viewModel секции. AdaptedCellViewModelProtocol служит лишь для полиморфизма подтипов наших viewModels для ячеек.

protocol AdaptedCellViewModelProtocol { }protocol AdaptedSectionViewModelProtocol {    var cells: [AdaptedCellViewModelProtocol] { get }}protocol AdaptedViewModelInputProtocol {    var sections: [AdaptedSectionViewModelProtocol] { get }}

Добавляем viewModel. Теперь у нас есть возможность корректно заполнить методы UITableViewDataSource.

class AdaptedTableView: UITableView {        // MARK: - Public properties        var viewModel: AdaptedViewModelInputProtocol?        // MARK: - Public methods        func setup() {        self.dataSource = self    }    }// MARK: - UITableViewDataSourceextension AdaptedTableView: UITableViewDataSource {        func numberOfSections(in tableView: UITableView) -> Int {        viewModel?.sections.count ?? .zero    }        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {        viewModel?.sections[section].cells.count ?? .zero    }        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {        guard let cellViewModel = viewModel?.sections[indexPath.section].cells[indexPath.row] else {            return UITableViewCell()        }            // TO DO: - Register cell      // TO DO: - Create cell                return UITableViewCell()    }    }

На данном этапе с AdaptedTableView почти все готов, однако есть еще пару нерешенных вопросов. Регистрация и переиспользование ячеек. Создадим протокол AdaptedCellProtocol, который будут реализовывать все наши подклассы UITableViewCell, добавим метод register(_ tableView:) и reuse(_ tableView:, for indexPath:).

protocol AdaptedCellProtocol {    static var identifier: String { get }    static var nib: UINib { get }    static func register(_ tableView: UITableView)    static func reuse(_ tableView: UITableView, for indexPath: IndexPath) -> Self}extension AdaptedCellProtocol {        static var identifier: String {        String(describing: self)    }        static var nib: UINib {        UINib(nibName: identifier, bundle: nil)    }        static func register(_ tableView: UITableView) {        tableView.register(nib, forCellReuseIdentifier: identifier)    }        static func reuse(_ tableView: UITableView, for indexPath: IndexPath) -> Self {        tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! Self    }    }

Для порождения ячеек создадим протокол фабричного метода AdaptedCellFactoryProtocol.

protocol AdaptedCellFactoryProtocol {    var cellTypes: [AdaptedCellProtocol.Type] { get }    func generateCell(viewModel: AdaptedCellViewModelProtocol, tableView: UITableView, for indexPath: IndexPath) -> UITableViewCell}

Добавим поле cellFactory и в didSet поместим регистрацию всех ячеек.

class AdaptedTableView: UITableView {        // MARK: - Public properties        var viewModel: AdaptedViewModelInputProtocol?    var cellFactory: AdaptedCellFactoryProtocol? {        didSet {            cellFactory?.cellTypes.forEach({ $0.register(self)})        }    }        ...    }

Исправим метод делегата.

extension AdaptedTableView: UITableViewDataSource {        ...        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {        guard            let cellFactory = cellFactory,            let cellViewModel = viewModel?.sections[indexPath.section].cells[indexPath.row]        else {            return UITableViewCell()        }                return cellFactory.generateCell(viewModel: cellViewModel, tableView: tableView, for: indexPath)    }    }

Использование

С необходимы абстракциями на этом все, пора перейти к конкретным реализациям.

1. Ячейка

В качестве примера я создам ячейку с лейблом по центру и viewModel к ней. Реализация ячейки с кнопкой и картинкой.

protocol TextCellViewModelInputProtocol {    var text: String { get }}typealias TextCellViewModelType = AdaptedCellViewModelProtocol & TextCellViewModelInputProtocolclass TextCellViewModel: TextCellViewModelType {        var text: String        init(text: String) {        self.text = text    }    }final class TextTableViewCell: UITableViewCell, AdaptedCellProtocol {        // MARK: - IBOutlets        @IBOutlet private weak var label: UILabel!        // MARK: - Public properties        var viewModel: TextCellViewModelInputProtocol? {        didSet {            bindViewModel()        }    }        // MARK: - Private methods        private func bindViewModel() {        label.text = viewModel?.text    }    }

2. Cекция

class AdaptedSectionViewModel: AdaptedSectionViewModelProtocol {        // MARK: - Public properties      var cells: [AdaptedCellViewModelProtocol]        // MARK: - Init        init(cells: [AdaptedCellViewModelProtocol]) {        self.cells = cells    }    }

3. Фабрика

struct MainCellFactory: AdaptedSectionFactoryProtocol {        var cellTypes: [AdaptedCellProtocol.Type] = [        TextTableViewCell.self    ]        func generateCell(viewModel: AdaptedCellViewModelProtocol, tableView: UITableView, for indexPath: IndexPath) -> UITableViewCell {        switch viewModel {        case let viewModel as TextCellViewModelType:            let view = TextTableViewCell.reuse(tableView, for: indexPath)            view.viewModel = viewModel            return view        default:            return UITableViewCell()        }    }    }

Ну и напоследок нам понадобится viewModel самого модуля.

final class MainViewModel: AdaptedSectionViewModelType {        // MARK: - Public properties        var sections: [AdaptedSectionViewModelProtocol]        // MARK: - Init        init() {        self.sections = []                self.setupMainSection()    }        // MARK: - Private methods        private func setupMainSection() {        let section = AdaptedSectionViewModel(cells: [            TextCellViewModel(text: "Hello!"),            TextCellViewModel(text: "It's UITableView with using MVVM")        ])        sections.append(section)    }    }

Все готово, пора добавить UITableView на ViewController, установив в качестве custom class наш AdaptedTableView.

В реальном проекте, MVVM очень часто используют с каким-то паттерном навигации, это может быть координатор или роутер. В зону ответственности таких объектов входит DI (Dependency Injection) внедрение всех необходимых модулю зависимостей. Так как это тестовый проект, я захардкодил viewModel и cellFactory прямо во ViewController.

class ViewController: UIViewController {        // MARK: - IBOutlets        @IBOutlet weak var tableView: AdaptedTableView! {        didSet {            tableView.viewModel = MainViewModel()            tableView.cellFactory = MainCellFactory()                        tableView.setup()        }    }    }

Результат

Вывод

В итоге мы получили решение, которое позволяет удобно использовать UITableView с MVVM. Стало очень просто работать с секциями, настраивать ячейки, писать меньше шаблонного кода. В то же время осталась возможность настройки таблицы и расширения функционала при необходимости.


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

Подробнее..

IOS. UI. Примы. Часть 1

27.03.2021 20:12:03 | Автор: admin

Привет читателям Хабра!

Я iOS-разработчик, и так случилось, что мне приходилось много делать в ui: кастомные view, тени, layout-ы, кнопки и вот это всё. В этой и паре следующих статей хочу поделиться некоторыми приёмами, которые помогали мне добиваться весьма красивых и интересных эффектов в плане рисования компонентов ui. Надеюсь, кому-нибудь это будет полезно. Ну или просто интересно.

Небольшое введение

Не берусь говорить за всех, но, исходя из личного опыта, сложилось впечатление, что для достаточно большого количества разработчиков рисование каких-то "плашек" с нестандартными формой и поведением крайне нежелательная задача. Кто-то больше в архитектуре, кто-то больше про "сделать бизнесу хорошо" с минимальными усилиями (соответственно, просят поумерить пыл дизайнеров) и т.п. И если уж приходится делать что-то из ряда вон, то начинается google, stackoverflow, эксперименты и т.д., что занимает немало времени, и появляется ощущение, что оно того вообще не стоит. Собственно, эту небольшую серию статей я и задумал как некоторую справку, прочтение которой снимет ряд вопросов и позволит быстрее оценивать/реализовывать нетипичные ui-компоненты. На конкретных примерах постараюсь продемонстрировать, как, что и почему можно делать.

Пример 1: view с нестандартными границей и тенью

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

Теперь чуть подробнее. У CALayer есть свойство mask. В документации можно прочитать, что это тот же самый опциональный CALayer, и если он не nil, то его альфа-канал используется как маска для контента исходного layer. То есть если взять png-картинку с котом и прозрачностью и каким-то образом засунуть ее в CALayer (назовем его catLayer), то при присваивании layer.mask = catLayer контент нашего исходного layer будет в виде кота, что бы ни находилось у него внутри. Может, текстовый кот получится, если внутри layer много текста. В нашем же случае нужен layer-маска в виде произвольной фигуры. Тут может помочь CAShapeLayer - наследник CALayer, который, грубо говоря, умеет внутри себя рисовать произвольную форму посредством задания ему проперти path. При использовании shapeLayer в качестве маски, всё, что находится вне формы, описываемой shapeLayer.path, работает как фильтр с alpha = 0.

Саму форму можно задать, используя UIBezierPath: для этого у последнего есть функции
addLine(to:), move(to:), addArc(withCenter:radius:startAngle:endAngle:clockwise) и т.д.
Здесь хотелось бы отметить пару моментов. Итоговый path должен выглядеть так, будто его "нарисовали, не отрывая карандаш от бумаги": стартуем из произвольной точки на границе и постепенно добавляем линии к общему пути так, чтобы конец предыдущей линии был началом следующей линии, и так далее. В конце возвращаемся в исходную точку. Некоторых сбивает с толку функция addArc, потому что в ней есть вроде и startAngle и endAngle, и clockwise. Вот clockwise как раз и нужен для того, чтобы управлять тем, вдоль какой из частей окружности, заданной двумя углами, мы двигаемся. В нашем примере в правом верхнем углу добавляется кусок окружности от -/2 до 0 с clockwise равным именно true, иначе мы бы просто вырезали целую окружность из нашей view:


А зачем здесь вообще дополнительный слой? Почему бы не задать маску у исходного?
Проблема в том, что маска работает так, что отрезает просто всё, что ей попадётся, в том числе и тень слоя. Так что если задавать mask у слоя исходной view, то тени просто не будет видно.

Наконец, чтобы придать нужную форму тени, у CALayer есть свойство shadowPath.

Полный код примера 1
import UIKitfinal class SimpleCustomBorderAndShadowView: UIView {  private let frontLayer = CALayer()  private let inset: CGFloat = 40    // MARK: Override    override init(frame: CGRect) {    super.init(frame: frame)    setup()  }    required init?(coder: NSCoder) {    super.init(coder: coder)    setup()  }    override func layoutSubviews() {    super.layoutSubviews()    frontLayer.frame = bounds        let maskAndShadowPath = UIBezierPath()    maskAndShadowPath.move(to: CGPoint(x: 0, y: inset))    maskAndShadowPath.addLine(to: CGPoint(x: inset, y: 0))    maskAndShadowPath.addLine(to: CGPoint(x: bounds.width - inset, y: 0))    maskAndShadowPath.addArc(withCenter: CGPoint(x: bounds.width - inset, y: inset),                             radius: inset,                             startAngle: -CGFloat.pi / 2,                             endAngle: 0,                             clockwise: true)    maskAndShadowPath.addLine(to: CGPoint(x: bounds.width, y: bounds.height - inset))    maskAndShadowPath.addLine(to: CGPoint(x: bounds.width - inset, y: bounds.height))    maskAndShadowPath.addLine(to: CGPoint(x: inset, y: bounds.height))    maskAndShadowPath.addArc(withCenter: CGPoint(x: inset, y: bounds.height - inset),                             radius: inset,                             startAngle: CGFloat.pi / 2,                             endAngle: CGFloat.pi,                             clockwise: true)    maskAndShadowPath.close()        (frontLayer.mask as? CAShapeLayer)?.frame = bounds    (frontLayer.mask as? CAShapeLayer)?.path = maskAndShadowPath.cgPath    layer.shadowPath = maskAndShadowPath.cgPath   }    // MARK: Setup    private func setup() {    backgroundColor = .clear        layer.shadowColor = UIColor.black.cgColor    layer.shadowOffset = .zero    layer.shadowRadius = 20    layer.shadowOpacity = 1        frontLayer.mask = CAShapeLayer()    frontLayer.backgroundColor = UIColor.white.cgColor    layer.addSublayer(frontLayer)  }}

Пример 2: view с вырезанной кривой произвольного вида

Данный пример выбран, чтобы продемонстрировать два момента: как вырезать что-то внутри слоя и как создать путь, как бы обводящий кривую линию на некотором расстоянии от неё.

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

Для формы кривой используется функция addQuadCurve(to:controlPoint:). И если взять UIBezierPath, вызывать addQuadCurve, проставить ему ширину с помощью lineWidth, и добавить это в итоговый path для маски то... Ничего не выйдет. Если чуть-чуть задуматься и ещё вспомнить про это, то всё начинает казаться логичным: CoreGraphics нужно как-то сказать о границах, при переходе через которые происходит подсчёт каких-то counter-ов для дальнейшего решения о том, красить данную область или нет. Чтобы построить путь именно вокруг кривой, у CGPath есть функция copy(strokingWithWidth:lineCap:lineJoin:miterLimit:). Сам CGPath, в свою очередь, можно получить из UIBezierPath, обращаясь к свойству cgPath.

Конечно, насчёт кривой именно произвольной формы, описывающей вырезаемую область, я немного слукавил, потому что при возникновении самопересечений с учётом ширины будут возникать проблемы.

Полный код примера 2
import UIKitfinal class ErasedPathView: UIView {  private let frontLayer = CAShapeLayer()    // MARK: Override    override init(frame: CGRect) {    super.init(frame: frame)    setup()  }    required init?(coder: NSCoder) {    super.init(coder: coder)    setup()  }    override func layoutSubviews() {    super.layoutSubviews()        frontLayer.frame = bounds        let maskAndShadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 20)        let curvePath = UIBezierPath()    curvePath.move(to: CGPoint(x: bounds.width / 4, y: bounds.height / 4))    curvePath.addQuadCurve(to: CGPoint(x: bounds.width * 3 / 4, y: bounds.height * 3 / 4),                           controlPoint: CGPoint(x: bounds.width, y: 0))        let innerPath =  UIBezierPath(cgPath: curvePath.cgPath.copy(strokingWithWidth: 70, lineCap: .round, lineJoin: .round, miterLimit: 0))    maskAndShadowPath.append(innerPath)        (frontLayer.mask as? CAShapeLayer)?.frame = bounds    (frontLayer.mask as? CAShapeLayer)?.path = maskAndShadowPath.cgPath    layer.shadowPath = maskAndShadowPath.cgPath  }    // MARK: Setup    private func setup() {    backgroundColor = .clear    frontLayer.backgroundColor = UIColor.white.cgColor        layer.addSublayer(frontLayer)    let mask = CAShapeLayer()    mask.fillRule = .evenOdd    frontLayer.mask = mask        layer.shadowColor = UIColor.black.cgColor    layer.shadowOffset = .zero    layer.shadowRadius = 20    layer.shadowOpacity = 1  }}

Пример 3: рисование форм внутри view

Для того, чтобы просто рисовать внутри вашей view всё, что нравится, без создания дополнительных слоёв, можно опять же использовать CAShapeLayer. Нужно сделать override статического свойства layerClass у исходной view, возвращая ShapeLayer.self, и так же как и в Примере 1 задать этому слою path.

Есть один нюанс, не упомянутый ранее. При построении непрерывного пути при рисовании произвольной формы можно случайно перепрыгнуть из конца очередной линии в совершенно другое место. Типичный пример добавление нового куска окружности при непустом path. В таких случаях CoreGraphics просто напросто дорисует за вас недостающую линию, соединяющую последнюю точку пути и новую точку очередной добавляемой линии. В совокупности с fillRule у CAShapeLayer этим можно аккуратно пользоваться. Например, на третьей справа картинке (карта треф) этот подход существенно упрощает рисование: не нужно думать о том, в каких именно местах пересекаются окружности.

Пики
import UIKitfinal class SpadeCardView: UIView {    var selfLayer: CAShapeLayer { layer as! CAShapeLayer }  private let inset: CGFloat = 20    // MARK: Override    static override var layerClass: AnyClass { CAShapeLayer.self }    override init(frame: CGRect) {    super.init(frame: frame)    setup()  }    required init?(coder: NSCoder) {    super.init(coder: coder)    setup()  }    override func layoutSubviews() {    super.layoutSubviews()        let path = UIBezierPath()    let size = bounds.width - 2 * inset    let radius = size / 4    let alpha = atan(2 * radius / size)        path.move(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2))    path.addArc(withCenter: CGPoint(x: inset + radius, y: bounds.height / 2),                radius: radius, startAngle: 0,                endAngle: CGFloat.pi + 2 * alpha,                clockwise: true)    path.addLine(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2 - size / 2))    path.addArc(withCenter: CGPoint(x: bounds.width / 2 + radius, y: bounds.height / 2),                radius: radius,                startAngle: -2 * alpha,                endAngle: CGFloat.pi,                clockwise: true)    path.addQuadCurve(to: CGPoint(x: bounds.width / 2 + radius, y: bounds.height / 2 + size / 2),                      controlPoint: CGPoint(x: bounds.width / 2, y: bounds.height / 2 + size / 2))    path.addLine(to: CGPoint(x: bounds.width / 2 - radius, y: bounds.height / 2 + size / 2))    path.addQuadCurve(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2),                      controlPoint: CGPoint(x: bounds.width / 2, y: bounds.height / 2 + size / 2))        selfLayer.path = path.cgPath  }    // MARK: Setup    private func setup() {    selfLayer.fillColor = UIColor.black.cgColor    selfLayer.strokeColor = UIColor.black.cgColor    selfLayer.lineWidth = 2        layer.shadowColor = UIColor.black.cgColor    layer.shadowOffset = .zero    layer.shadowRadius = 10    layer.shadowOpacity = 1  }}
Бубны
import UIKitfinal class DiamondCardView: UIView {    var selfLayer: CAShapeLayer { layer as! CAShapeLayer }  private let inset: CGFloat = 20  private let adjustment: CGFloat = 10    // MARK: Override    static override var layerClass: AnyClass { CAShapeLayer.self }    override init(frame: CGRect) {    super.init(frame: frame)    setup()  }    required init?(coder: NSCoder) {    super.init(coder: coder)    setup()  }    override func layoutSubviews() {    super.layoutSubviews()        let path = UIBezierPath()    let size = bounds.width - 2 * inset        path.move(to: CGPoint(x: inset, y: bounds.height / 2))    path.addQuadCurve(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2 - size / 2),                      controlPoint: CGPoint(x: bounds.width / 2 - adjustment, y: bounds.height / 2 - adjustment))    path.addQuadCurve(to: CGPoint(x: bounds.width - inset, y: bounds.height / 2),                      controlPoint: CGPoint(x: bounds.width / 2 + adjustment, y: bounds.height / 2 - adjustment))    path.addQuadCurve(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2 + size / 2),                      controlPoint: CGPoint(x: bounds.width / 2 + adjustment, y: bounds.height / 2 + adjustment))    path.addQuadCurve(to: CGPoint(x: inset, y: bounds.height / 2),                      controlPoint: CGPoint(x: bounds.width / 2 - adjustment, y: bounds.height / 2 + adjustment))        selfLayer.path = path.cgPath  }    // MARK: Setup    private func setup() {    selfLayer.fillColor = UIColor.red.cgColor    selfLayer.strokeColor = UIColor.red.cgColor    selfLayer.lineWidth = 2        layer.shadowColor = UIColor.black.cgColor    layer.shadowOffset = .zero    layer.shadowRadius = 20    layer.shadowOpacity = 1  }}
Трефы
import UIKitfinal class ClubCardView: UIView {    var selfLayer: CAShapeLayer { layer as! CAShapeLayer }  private let inset: CGFloat = 20  private let adjustment: CGFloat = 10    // MARK: Override    static override var layerClass: AnyClass { CAShapeLayer.self }    override init(frame: CGRect) {    super.init(frame: frame)    setup()  }    required init?(coder: NSCoder) {    super.init(coder: coder)    setup()  }    override func layoutSubviews() {    super.layoutSubviews()        let path = UIBezierPath()    let size = bounds.width - 2 * inset    let radius = size / 4        path.move(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2))    path.addArc(withCenter: CGPoint(x: bounds.width / 2 - radius, y: bounds.height / 2 + adjustment),                radius: radius,                startAngle: 0,                endAngle: 2 * CGFloat.pi,                clockwise: true)    path.addArc(withCenter: CGPoint(x: bounds.width / 2, y: bounds.height / 2 - radius),                radius: radius,                startAngle: CGFloat.pi / 2,                endAngle: 5 * CGFloat.pi / 2,                clockwise: true)    path.addArc(withCenter: CGPoint(x: bounds.width / 2 + radius, y: bounds.height / 2 + adjustment),                radius: radius,                startAngle: CGFloat.pi,                endAngle: 3 * CGFloat.pi,                clockwise: true)    path.addQuadCurve(to: CGPoint(x: bounds.width / 2 + radius, y: bounds.height / 2 + size / 2),                      controlPoint: CGPoint(x: bounds.width / 2, y: bounds.height / 2 + size / 2))    path.addLine(to: CGPoint(x: bounds.width / 2 - radius, y: bounds.height / 2 + size / 2))    path.addQuadCurve(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2),                      controlPoint: CGPoint(x: bounds.width / 2, y: bounds.height / 2 + size / 2))        selfLayer.path = path.cgPath  }    // MARK: Setup    private func setup() {    selfLayer.fillColor = UIColor.black.cgColor    selfLayer.strokeColor = UIColor.black.cgColor    selfLayer.fillRule = .nonZero    selfLayer.lineWidth = 2        layer.shadowColor = UIColor.black.cgColor    layer.shadowOffset = .zero    layer.shadowRadius = 20    layer.shadowOpacity = 1  }}
Черви
import UIKitfinal class HeartCardView: UIView {    var selfLayer: CAShapeLayer { layer as! CAShapeLayer }  private let inset: CGFloat = 20    // MARK: Override    static override var layerClass: AnyClass { CAShapeLayer.self }    override init(frame: CGRect) {    super.init(frame: frame)    setup()  }    required init?(coder: NSCoder) {    super.init(coder: coder)    setup()  }    override func layoutSubviews() {    super.layoutSubviews()        let path = UIBezierPath()    let size = bounds.width - 2 * inset    let radius = size / 4    let alpha = atan(4 * radius / (3 * size))        path.move(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2 + size / 2))    path.addArc(withCenter: CGPoint(x: inset + radius, y: bounds.height / 2 - radius),                radius: radius,                startAngle: CGFloat.pi - 2 * alpha,                endAngle: 0,                clockwise: true)    path.addArc(withCenter: CGPoint(x: bounds.width / 2 + radius, y: bounds.height / 2 - radius),                radius: radius,                startAngle: -CGFloat.pi,                endAngle: 2 * alpha,                clockwise: true)    path.addLine(to: CGPoint(x: bounds.width / 2, y: bounds.height / 2 + size / 2))        selfLayer.path = path.cgPath  }    // MARK: Setup    private func setup() {    selfLayer.fillColor = UIColor.red.cgColor    selfLayer.strokeColor = UIColor.red.cgColor        layer.shadowColor = UIColor.black.cgColor    layer.shadowOffset = .zero    layer.shadowRadius = 20    layer.shadowOpacity = 1  }}

Заключение

Ниже, так сказать, things to remember:

  • +1 CALayer, mask, CAShapeLayer, shadowPath для кастомной границы и тени

  • copy(strokingWithWidth:lineCap:lineJoin:miterLimit:) для объемной обводки path

  • CAShapeLayer, path + fillRule даёт интересные возможности

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

Подробнее..

Вас заметили! App Tracking Transparency (ATT) для iOS14.5

05.05.2021 12:08:12 | Автор: admin

Недавно вышла iOS 14.5, а чуть ранее Apple предупредила разработчиков, что начиная с этой версии ОС необходимо поддерживать фреймворк AppTrackingTransparency, который позволяет получить доступ к IDFA.

IDFA (The Device Identifier For Advertisers) это уникальный случайный идентификационный номер, который используется рекламными сетями для распознавания вашего устройства. Этот идентификатор позволяет подобрать для вас максимально точную рекламу. Также на его основе работают многие сервисы аналитики.

Если пользователь запретит доступ к своему идентификатору, тот будет состоять из нулей, что не позволит идентифицировать ваше устройство:

Zeroed out IDFA: 00000000-0000-0000-0000-000000000000

Помимо требования Apple поддерживать AppTrackingTransparency, модераторы с 26 апреля отклоняют все обновления приложений, которые не запрашивают доступ к идентификатору. Давайте посмотрим, как соблюдать новое требование.

Как быть дружелюбнее?

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

Руководство Apple: https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/accessing-user-data/

Facebook и Instagram решили надавить на жалость, и сказали, что собирают и продают данные, так как это им помогает оставаться бесплатными социальными сетями.

Нам в Ситимобил показалось, что нужно быть честнее и дружелюбнее. Необходимо объяснить пользователям, что давая согласие они сделают только лучше и нам, и себе; Win-win, так сказать. Поэтому мы сделали информационный экран, в котором только после согласия пользователя отображаем системное окно с запросом IDFA.

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

Техническая реализация

Реализация необходимой и достаточной поддержки AppTrackingTransparency не займет у вас много времени. Для начала вам необходимо в info.plist добавить параметр:

<key>NSUserTrackingUsageDescription</key><string>Можно ли использовать данные о вашей активности? Если вы разрешите, реклама Ситимобила на сайтах и в других приложениях будет более актуальной.</string>

Если ваше приложение поддерживает несколько языков, не забудьте про локализацию!

Далее необходимо запросить разрешение. Так как функция доступна начиная с iOS 14.5, то сначала необходимо проверить версию системы, и лишь потом запросить права.

private func requestTrackingAuthorization() {    guard #available(iOS 14, *) else { return }    ATTrackingManager.requestTrackingAuthorization { _ in        DispatchQueue.main.async { [weak self] in            // self?.router.close() or nothing to do        }    }}

Для примера можете вызвать получившийся метод в didFinishLaunchingWithOptions. И не забудьте про

import AppTrackingTransparency

Запустите приложение и взгляните на получившийся запрос. Теперь ваше приложение готово для обновления.

Пограничные случаи

В примере выше мы запросили права, но не проконтролировали, дал ли пользователь свое согласие. Это можно проверить через AuthorizationStatus, возвращаемый методом requestTrackingAuthorization.

private func requestTrackingAuthorization() {    guard #available(iOS 14, *) else { return }    ATTrackingManager.requestTrackingAuthorization { status in        DispatchQueue.main.async {            switch status {            case .authorized:                let idfa = ASIdentifierManager.shared().advertisingIdentifier                print("Пользователь разрешил доступ. IDFA: ", idfa)            case .denied, .restricted:                print("Пользователь запретил доступ.")            case .notDetermined:                print("Пользователь ещё не получил запрос на авторизацию.")            @unknown default:                break            }        }    }}
  • Функция requestTrackingAuthorization(completionHandler:) отобразит запрос на права только когда у авторизации отслеживания будет статус .notDetermined. После установки статуса вызов функции просто запустит обработчик без отображения запроса.

  • Обратите внимание, что обработчик функции запускается не в основном потоке. Учтите это, если работаете с UI.

Если необходимо, чтобы пользователь обязательно дал разрешение, то можно обработать статусы .denied и .restricted. Например, снова показать информационный экран с объяснением, для чего необходим доступ к IDFA, после чего перенаправить пользователя в настройки приложения, чтобы он дал разрешение. Вспомогательная функция, которая перенаправит в настройки приложения:

func goToAppSettings() {    guard let url = URL(string: UIApplication.openSettingsURLString),          UIApplication.shared.canOpenURL(url)    else {        return    }    UIApplication.shared.open(url, options: [:], completionHandler: nil)}

Теперь точно всё. Был ли у вас интересный опыт с внедрением ATT? Буду рад вашим комментариям и отзывам!

Подробнее..

Как увеличить срок хранения мобильного приложения? 6 проверенных способов

28.02.2021 02:15:32 | Автор: admin

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

1. Специальные акции для пользователей

Акции являются одним из способов увеличения удержания. Например акции с ограниченым временем на покупку или ограниченое колличество товаров.

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

Выделенные предложения также могут быть построены на основе анализа поведения предыдущих пользователей: если мы знаем, какие продукты просматривал наш клиент, мы можем предложить их по более выгодной цене. Кроме того, дополнительным стимулом для использования мобильного приложения является ограничение распространения некоторых предложений на мобильный канал.

Акцияможет быть дополненаперекрестнымипродажами(например, Купите два продукта из данной категории и получить третий бесплатно) и, таким образом, можно увеличить объем покупки.

2. Интеллектуальные push-уведомления

Данные о поведении и геолокации, часто получаемые в режиме реального времени, позволяют точно связаться с людьми с сообщением в нужном месте и времени (например, Здравствуйте! Вы чувствуете себя сонным? Сегодня со скидкой [здесь стоимость акции]). Это дает им преимущество перед, например, ремаркетингом на основе файлов cookie. Придавая правильную ценность пользователю через персонализированное сообщение, мы увеличиваем вероятность совершения покупки.

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

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

Подробно про это я уже писал в другой своей статье. Если будет интересно - выложу на хабре.

3. Индексация приложений, то есть индексация приложений в поисковой системе.

В 2015 году, после трех лет тестирования, Google, наконец, решил ввести функцию индексации приложений в поисковой системе общего пользования (избранные разработчики получили доступ к ней двумя годами ранее; именно их приложения проложили путь для преемников). В настоящее время Google индексирует приложения для мобильных устройств с iOS и Android, что означает, прежде всего, гораздо больше возможностей для охвата получателей. Индексируя приложение, потенциальные пользователи могут найти его в результатах поиска Google. Я говорю здесь - конечно - о людях, просматривающих интернет с телефонов и планшетов. Если у них есть конкретное приложение, нажимающее на ссылку (например, размещенную на веб-сайте), они автоматически переносятся на один из его экранов (например, ответы на конкретный вопрос или товар). Если нет - они могут установить его, нажав кнопку на странице результатов (или в мобильной версии сайта). В этой ситуации они переносятся прямо в App Store или платформу Google Play.

Следует сразу отметить, что Google индексирует только те приложения, которые соответствуют условиям. Программная документация о так называемой индексация приложений доступна на веб-сайтеFirebase App Indexing. Внедрение функций индексации (например, глубоких ссылок с мобильной страницы на экраны приложений) должно осуществляться на этапе разработки программы и являться частью более широкого плана присутствия в Интернете. Сам процесс реализации индексации приложений варьируется в зависимости от операционной системы (мы делаем по-разному в случае iOS, по-разному - Android) и подробно описанздесь (iOS)издесь (Android).

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

4. Ссылки, открывающие приложение, размещенные в рассылках, социальных сетях и т. Д. (так называемая глубокая связь)

Принцип роботы "глубоких ссылок" или deep link

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

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

5. Google UAC - универсальные кампании по продвижению приложений

Кампании попродвижению универсальных приложений(Universal App Campaigns, корочеUAC) - хороший вариант для людей, которые хотят автоматизировать рекламные процессы; После загрузки всех необходимых материалов в AdWords система распространения рекламы будет решать, кто, когда, что и как отображать, чтобы достичь нашей цели.

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

Вы можете узнать больше о кампаниях Universal App наофициальной странице справки Google. Там мы также найдем информацию о различных способах установления целей, ориентированных на удержание.

6. Увеличение удержания мобильного приложения за пределы цифрового маркетинга: ATL и BTL

Продвижение мобильного приложения не должно ограничиваться интернет-маркетингом. Продвижение ATL (выше линии), например, рекламные щиты, газеты, листовки и BTL (ниже линии), например, городские игры в среде дополненной реальности и POSy, при правильном использовании они также могут хорошо работать.

Резюмируя

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

Подробнее..

Swift Best Practices которые не стыдно знать

31.08.2020 18:16:08 | Автор: admin

Предисловие


Всем, по традиции, 404! Я собрал коллекцию и частью Swift Best Practices ( которые не только упростят вам жизнь, но и покажут ваш профессионализм на код ревью) поделюсь с вами в этой статье. Хочу чтоб ваш код был чистым, красивым и вы сами ему радовались!

Стартуем от сюда


0. Начнём с UserDefaults, не буду говорить как много я штук видел с ними. Вот небольшая хитрость, которую я использую, чтобы добиться согласованности ключей UserDefault в Swift (#function заменяется именем свойства в геттерах / сеттерах). Просто не забудьте написать хороший набор тестов, которые защитят вас от ошибок при изменении имен свойств.

extension UserDefaults {    var onboardingCompleted: Bool {        get { return bool(forKey: #function) }        set { set(newValue, forKey: #function) }    }}


1. Все знают оператор === с js но почему-то мало кто им пользуется в Swift. (Напомню: Оператор === позволяет проверить, являются ли два объекта одним и тем же экземпляром.) Очень полезно при проверке того, что массив содержит экземпляр в тесте:

protocol InstanceEquatable: class, Equatable {}extension InstanceEquatable {    static func ==(lhs: Self, rhs: Self) -> Bool {        return lhs === rhs    }}extension Enemy: InstanceEquatable {}func testDestroyingEnemy() {    player.attack(enemy)    XCTAssertTrue(player.destroyedEnemies.contains(enemy))}

2. Используя DispatchWorkItem, вы можете легко отменить отложенную асинхронную задачу GCD, если она вам больше не нужна:

let workItem = DispatchWorkItem {    // Ваш асинхронный код здесь}// Вызовем его через 1 секундуDispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)// А теперь можем прекратить его работу(в любое удобное для нас время)workItem.cancel()

3. С map можно придумать много крутых штук, но эта одна из самых полезных. Используя map, вы можете преобразовать Optional значение в optional Result тип, просто передав его в enum.

enum Result<Value> {    case value(Value)    case error(Error)}class Promise<Value> {    private var result: Result<Value>?        init(value: Value? = nil) {        result = value.map(Result.value)    }}

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

До

public func generate() throws {    let contentFolder = try folder.subfolder(named: "content")    let articleFolder = try contentFolder.subfolder(named: "posts")    let articleProcessor = ContentProcessor(folder: articleFolder)    let articles = try articleProcessor.process()    ...}

После

public func generate() throws {    let contentFolder = try folder.subfolder(named: "content")    let articles = try processArticles(in: contentFolder)    ...}private func processArticles(in folder: Folder) throws -> [ContentItem] {    let folder = try folder.subfolder(named: "posts")    let processor = ContentProcessor(folder: folder)    return try processor.process()}


Ну красиво, удобно, элегантно же, да? Да. Надеюсь был полезен, всем спасибо за прочтение. Красивого UI и чистого кода!
Подробнее..

Зачем нужно понимать ООП

03.12.2020 00:05:42 | Автор: admin


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

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

Примеры кода буду приводить из iOS разработки.

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

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

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

Наследование


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

Так как ООП это про моделирование, то код мы пишем начиная с абстракции частей системы и взаимодействие между этими частями, которые мы должны записать в виде кода. Например, социальная сеть, которая состоит из пользователей, взаимодействующих друг с другом. Помимо пользователей, система состоит из более мелких компонентов, таких как сообщения, посты, лайки, комментарии. Даже сам пользователь может являть собой подсистему в системе. Как человек состоит из различных органов и частей тела: сердце, мозг, руки, пальцы, так и пользователь в системе может состоять из более мелких составляющих. Но уже не руки или глаза, а адрес, интересы, записи об образовании, которые можно выносить как отдельные объекты. Уже на этапе анализа можно проследить принципы объектно-ориентированного подхода. У нас есть пользователь абстракция реального человека. Но у пользователя могут быть разные роли: админ, обычный пользователь, VIP пользователь, анонимный посетитель. Они все являются абстракциями реальных людей и пользователями данной системы. Но каждая из вышеперечисленных ролей имеет свои особенности и при этом все имеют общее они пользуются системой и должны зарегистрироваться в системе (у каждого может быть свой способ) и они все должны пройти процедуру входа в систему.

Это и есть принцип наследования, где каждый админ/VIP-клиент/аноним являются пользователями, но не каждый пользователь должен быть админом или VIP-пользователем.

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

Еще пример ошибочной трактовки принципа наследования, это когда базовый класс и наследник являются представителями разных логических групп. Выглядит это следующим образом, реализуем MVC в iOS проекте, где UIViewController это Controller с абстрактными методами, которые должен реализовать наследник. А наследник это уже Model. Там где по логике проектирования должно быть взаимодействие между двумя группами классов, один класс становиться одновременно и Model и Controller. Не говорим уже о том, что UIViewController в реалиях iOS разработки еще и берет на себя роль View. В итоге мы получаем один объект, который делает все сам. Если у нас есть пользователь (User), то он будет и Controller и View одновременно.

Пример наследования в iOS
/**    Нужно сделать экран профиля пользователя, в котором отображаются имя и фамилия. Этот экран должен переиспользоваться. */// INCORRECTclass UserProfileViewController: UIViewController {    // MARK: - IBOutlets    @IBOutlet private var firstNameLabel: UILabel!    @IBOutlet private var lastNameLabel: UILabel!    /**    Заполнение данных оставляем классу наследнику в виде абстрактных методов     */    // MARK: - Abstract methods    func firstName() -> String? {        return nil    }    func lastName() -> String? {        return nil    }    // MARK: - Lifecycle    override func viewDidLoad() {        super.viewDidLoad()        firstNameLabel.text = firstName()        lastNameLabel.text = lastName()    }}/**    Создаем класс-наследник, который отвечает за функцию заполнения данных. В таком случае, наследник будет выполнять роль не только UIViewController, а и роль модели, которая предоставляет данные для отображения. */class UserProfileModel: UserProfileViewController {    override func firstName() -> String? {        return "Name"    }    override func lastName() -> String? {        return "Last name"    }}// CORRECT    /**    Корректней будет, добавить новый класс-модель, которая будет предоставлять данные.     */class UserProfileModel {    func firstName() -> String? {        return "Name"    }    func lastName() -> String? {        return "Last name"    }}/**    В таком случае у нас будут два отдельных класса, каждый из которых имеет свою зону ответственности. */class UserProfileViewController: UIViewController {    var model: UserProfileModel?    // MARK: - IBOutlets    @IBOutlet private var firstNameLabel: UILabel!    @IBOutlet private var lastNameLabel: UILabel!    // MARK: - Lifecycle    override func viewDidLoad() {        super.viewDidLoad()        setupUserInfo()    }    // MARK: - Private    private func setupUserInfo() {        firstNameLabel.text = model?.firstName()        lastNameLabel.text = model?.lastName()    }}// PERFECT/**    Еще лучше, взаимодействие между двумя типами классов, контроллер и модель, сделать через протокол, чтобы можно было создавать и использовать разные модели. */protocol UserProfileProtocol {    func firstName() -> String?    func lastName() -> String?}class UserProfileViewController: UIViewController {    var model: UserProfileProtocol?    // MARK: - IBOutlets    @IBOutlet private var firstNameLabel: UILabel!    @IBOutlet private var lastNameLabel: UILabel!    // MARK: - Lifecycle    override func viewDidLoad() {        super.viewDidLoad()        setupUserInfo()    }    // MARK: - Private    private func setupUserInfo() {        firstNameLabel.text = model?.firstName()        lastNameLabel.text = model?.lastName()    }}class UserProfileModel: UserProfileProtocol {        // MARK: - UserProfileProtocol        func firstName() -> String? {        return "Name"    }    func lastName() -> String? {        return "Last name"    }}


Представляю эту картину в реальной жизни, у нас есть регистратура, где некий администратор ведет записи о посетителях в тетрадку. Также в этой тетради можно читать данные о посетителях. Мы получаем посетителя Model, человека в регистратуре Controller и тетрадь View. И в вышеуказанной интерпретации принципа наследования мы получаем, что посетитель всегда является регистратором и тетрадкой. Уже дико выглядит то, что человек и тетрадка одно целое. И логика наследования нарушается. Если рассматривать, что посетитель и регистратор люди и принять тот факт, что представитель класса регистратора может стать посетителем, то логичней сказать, что регистратор является наследником посетителя.

Это пример, когда непонимание принципа наследования приводит к сложности понимания системы.

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

Абстракция


Я уже затронул принцип наследования, хотя хотелось бы начать с такого принципа как абстракция. На мой взгляд это базовый принцип ООП (также абстракция относится и к остальным парадигмам) и он незаслуженно перешел в разряд опционального принципа.

Абстракция гласит останавливаем внимание на важных и необходимых аспектах объекта и игнорируем ненужные для нас.

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

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

Наверно пренебрежением этим принципом в реальной разработке, является добавление ненужных методов и свойств классу. Впоследствии, получаем непонимание и нарушение принципа единственной ответственности с SOLID.

Также к абстракции я бы отнес декомпозицию, когда сложный объект разбивается на систему. Мы абстрагируемся от некоторых особенностей и переносим их в отдельный компонент. Пример: пользователь у которого есть место проживание, то есть адрес. Адрес в свою очередь состоит из города, улицы, номера дома и т. д. В этот момент мы думаем, а нужно ли указывать страну или регион? Если это приложения для пользования администрацией конкретного района города, то можно упустить такие детали. В итоге мы получаем пользователя, который абстрагируется от некоторых деталей адреса. Опять-таки, непонимание того, что мы не только пишем код, но и занимаемся моделированием, приводит к тому, что у нас есть, допустим, MenuViewController, который состоит из 5000+ строк кода.

Пример абстракции через декомпозицию
/**    Распространенная ситуация: создаем класс, к примеру, простую модель пользователя. Но с добавлением функционала, все больше появляется полей и методов в этом классе. */// INCORRECTclass User {    let firstName: String    let lastName: String    let fullName: String    let age: Int    let birthday: Date    let street: String    let postalCode: Int    let city: String    var phoneNumber: String?    var phoneCode: String?    var phoneFlag: UIImage?    var isLoggined: Bool = false    var isAdmin: Bool = false    // MARK: - Init        init(firstName: String,         lastName: String,         fullName: String,         age: Int,         birthday: Date,         street: String,         postalCode: Int,         city: String) {        self.firstName = firstName        self.lastName = lastName        self.fullName = fullName        self.age = age        self.birthday = birthday        self.street = street        self.postalCode = postalCode        self.city = city    }    // MARK: - Admin functionality        func createNewReport() {        guard isAdmin else { return }                print("New report created")    }        func updateReport(for user: User) {        guard isAdmin else { return }                print("Update report for \(user.fullName)")    }}// CORRECT/**    Правильней будет, декомпозировать код, абстрагируя части большого сложного класса на маленькие компоненты. */class Address {    let street: String    let postalCode: Int    let city: String    init(street: String,         postalCode: Int,         city: String) {        self.street = street        self.postalCode = postalCode        self.city = city    }}class Name {    let firstName: String    let lastName: String    init(firstName: String,         lastName: String) {        self.firstName = firstName        self.lastName = lastName    }    var fullName: String {        firstName + " " + lastName    }}class PhoneNumber {    let phone: String    let code: String    let flag: UIImage    init(phone: String,         code: String,         flag: UIImage) {        self.phone = phone        self.code = code        self.flag = flag    }}class User {    /**    В результате, класс User уменьшился в размерах, при этом мы абстрагируемся от деталей имени и адреса.     */    let name: Name    let address: Address    let birthday: Date    var phoneNumber: PhoneNumber?    init(name: Name,         address: Address,         birthday: Date) {        self.name = name        self.address = address        self.birthday = birthday    }}/**    Так как после логина система получает залогиненого Пользователя, то класс User не должен отвечать за состояния системы. За статус логина будет отвечать новая сущность, тем самым система абстрагируется от деталей логики этого статуса. */class LoginSession {    var user: User?    var isLoggined: Bool {        user != nil    }}/**Дополнительные свойства Администратора выносяться в класс-наследник Пользователя. */class Admin: User {    func createNewReport() {        print("New report created")    }        func updateReport(for user: User) {        print("Update report for \(user.fullName)")    }}


Полиморфизм


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

Полиморфизм плавно вытекает с наследования. Гласит он следующее: можно создавать классы наследники, которые будут имитировать интерфейс базового класса, но со своей собственной реализацией. Этот принцип отражается в таком принципе SOLID как принцип Барбары Лисков: мы можем подставлять объекты классов наследников там, где предполагается использование базового класса, при этом замена не должна никак себя проявлять.

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

Пример в коде: множество наследников UIViewController, которые пушаться, презентятся и добавляются в UITabBarController как обычные UIViewController.
Как по мне, самый простой принцип. Но он довольно часто нарушается, когда класс наследник превращается в что-то новое и его уже нельзя использовать там, где использовался базовый класс. Также к нарушению относятся многочисленные опциональные методы и поля, которые в ходе неправильного наследования не нужны классу-наследнику. В этот момент, когда предполагается выполнение определенного метода базового класса, ничего не происходит. В лучшем случае ничего не произойдет, но может получиться так, что приложение либо неадекватно начинает себя вести либо крашиться вовсе.

Полиморфизм когда наследники делают все по своему, но результат работы такой же как у базового класса. Если наследник занимается чем-то своим и не дает результат такой, который ожидается от базового класса значит с наследованием и полиморфизмом что-то не так.

Пример: есть базовый класс автомобиль, который предположительно должен заехать в гараж. Создаем летающий автомобиль-наследник (как в фильме Назад в будущее 2). Какой будет результат при попытке загнать этого монстра в гараж? Да, он может залететь в гараж, если функция езды будет полностью заменена на функцию полета. А если функция полета была новым функционалом, а функция езды вообще заблокирована? То мы не сможем спрятать нашего летуна от непогоды. Это уже не будет автомобиль, это будет что-то новое. Результат из-за неправильного моделирования и наследования был нарушен принцип полиморфизма и получился нежелательный результат.

Летающий автомобиль

Добавлю, что полиморфизм тесно связан с наследованием и проблемы в наследовании отзываются еще большими проблемами в полиморфизме.

Инкапсуляция


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

Первый вариант иногда воспринимается как инкапсуляция = сокрытие, что, как я считаю, не совсем верное понимание.

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

К примеру, я разрабатываю кнопку, которая в момент нажатия становиться немного светлее. Проще говоря, когда кнопка в состоянии нажата, цвет фона становиться светлее.

Для этого я создаю класс наследник UIButton и в наследнике добавляю метод, который устанавливает цвет кнопки в цвет с альфа каналом 50% от оригинального. А также я добавляю метод, который возвращает бэкграунд цвет кнопки в оригинальный, без альфа канала.

Если эти два метода будут доступны при использовании моей кастомной кнопки, то может нарушиться поведение этой кнопки. К примеру в момент нажатия можно вызвать метод, который устанавливает оригинальный цвет. Или же вдруг сделать кнопку светлее, как будто она нажата, хотя это не так.

Пример кнопки с открытыми методами, которые могут нарушить повидение этой кнопки
/**    Создаем кнопку, у которой при нажатии цвет бэкграунда устанавливается в оригинальный цвет но с альфаканалом 0,5 */// INCORRECTclass Button: UIButton {     /**    Добавляем два метода, которые устанавливают цвет бекграунда для состояния нажатой кнопки и нормального состояния кнопки     */    func decorateSelected() {        backgroundColor = backgroundColor?.withAlphaComponent(0.5)    }        func decorateDeselected() {        backgroundColor = backgroundColor?.withAlphaComponent(1)    }    override var isSelected: Bool {        didSet {            if isSelected {                decorateSelected()            } else {                decorateDeselected()            }        }    }}// SAMPLE /**    Проблемой будет то, что методы, декорирующие кнопку в разных состояних, являются публичными. А это значит, что можно нарушить логику работы кнопки, вызвав метод в неправильный момент. */let button = Button()button.decorateSelected()// CORRECTclass Button: UIButton {        override var isSelected: Bool {        didSet {            if isSelected {                decorateSelected()            } else {                decorateDeselected()            }        }    }     /**    Мы сделали методы, настраивающие внешний вид кнопки, приватными, тем самым обеспечили правильную логику отображения.     */    // MARK: - Private    private func decorateSelected() {        backgroundColor = backgroundColor?.withAlphaComponent(0.5)    }        private func decorateDeselected() {        backgroundColor = backgroundColor?.withAlphaComponent(1)    }}// PERFECT /**    Но! У кнопки остаеться возможнось измененить цвет через базовое поле var backgroundColor: UIColor?. Поэтому, немного заморочившись, делаем невозможным менять цвет в момент, когда кнопка нажата. */class Button: UIButton {            override var backgroundColor: UIColor? {        get {            super.backgroundColor        }        set {            if isHighlighted == false {                super.backgroundColor = newValue            }        }    }        override var isHighlighted: Bool {        willSet {            if newValue {                decorateSelected()            }        }                didSet {            if isHighlighted == false {                decorateDeselected()            }        }    }    // MARK: - Private    private func decorateSelected() {        backgroundColor = backgroundColor?.withAlphaComponent(0.5)    }        private func decorateDeselected() {        backgroundColor = backgroundColor?.withAlphaComponent(1)    }}


Инкапсуляция не бездумное сокрытие каких-то полей или методов, а проектирование класса с определенным набором возможностей, которые не должны нарушаться.

По поводу объединения данных и методов, которые обрабатывают эти данные, я с этим согласен, но с оговоркой. В моем понимании ООП моделирование объектов в коде, что само собой подразумевает, наличие состояния и действий у этого объекта.

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

Заключение


Что в целом я хотел сказать? Я считаю, что программирование сложных систем, которые состоят из множества компонентов, начинается с моделирования, а не с кодинга. А для моделирования лучше всего подходит ООП парадигма, так как она собрала в себя принципы, позволяющие упростить моделирование и дальнейшее написания кода. Поэтому правильное понимание принципов ООП ключ к грамотному моделированию. В свою очередь хорошо спроектированная система проще переноситься в код и этот код проще поддерживается в будущем.
Подробнее..

Core Data Repository pattern. Детали реализации

10.05.2021 20:18:52 | Автор: admin

Всем привет! Данный пост рассчитан на людей, которые имеют представление о Core Data. Если вы не один из них, прочитайте краткую информацию тут и скорее присоединяйтесь. Прежде всего, мне хотелось бы поделиться своим взглядом на некоторые подходы к организации работы с данными в IOS приложениях, и в этом вопросе мне пришелся по душе паттерн репозиторий (далее - репозиторий). Поехали!

Немного про репозиторий

Основная идея репозитория заключается в том, чтобы абстрагироваться от источников данных, скрыть детали реализации, ведь, c точки зрения доступа к данным, эта самая реализация - не играет роли. Предоставить доступ к основным операциям над данными: сохранить, загрузить или удалить - главная задача репозитория.

Таким образом, к его преимуществам можно отнести:

- отсутствие зависимостей от реализации репозитория. Под капотом может быть все, что угодно: коллекция в оперативной памяти, UserDefaults, KeyChain, Core Data, Realm, URLCache, отдельный файл в tmp и т.п.;

- разделение зон ответственности. Репозиторий выступает прослойкой между бизнес-логикой и способом хранения данных, отделяя одно от другого;

- формирование единого, более структурированного подхода в работе с данными.

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

Ближе к деталям

Рассмотрим самый неблагоприятный сценарий использования Core Data.

Для общего понимания будем использовать класс DataProvider (далее - DP) - его задача получать данные откуда - либо (сеть, UI) и класть их в Repository . Также, при необходимости, DP может достать данные из репозитория, выступая для него фасадом. Под данными будем подразумевать массив доменных объектов. Именно ими оперирует репозиторий.

1. Core Data как один большой репозиторий с NSManagamentObject

Рисунок 1Рисунок 1

Идея оперировать NSManagedObject в качестве доменного объекта самая простая, но не самая удачная. При таком подходе перед нами встают сразу несколько проблем:

  1. Сore Data разрастается по всему проекту, вешая ненужные зависимости и нарушая зоны ответственности. Раскрываются детали реализации. Знать на чем завязана реализация репозитория - должен только репозиторий;

  2. Используя единый репозиторий для всех Data Provider, он будет разрастаться с появлением новых доменных объектов;

  3. В худшем случае, логика работы с объектами начнет пересекаться между собой и это может превратиться в один большой непредсказуемый magic.

2. Core Data + DB Client

Первое, что приходит на ум, для решения проблем из предыдущего примера, это вынести логику работы с объектами в отдельный класс (назовем его DB Client), тогда наш Repository будет только сохранять и доставать объекты из хранилища, в то время, как вся логика по работе с объектами ляжет в DB Client. На выходе должно получиться что-то такое:

Рисунок 2Рисунок 2

Обе схемы решают проблему 1. (Core Data ограничивается DB Client и Repository), и частично могут решить проблему 2 и 3 на небольших проектах, но не исключают их полностью. Продолжая мысль дальше, возможно придти к следующей схеме:

Рисунок 3Рисунок 3
  1. Core Data можно ограничить только репозиторием. DB Client конвертирует доменные объекты в NSManagedObject и обратно;

  2. Repository больше не единый и он не разрастается;

  3. Логика работы с данными более структурирована и консолидирована

Здесь, стоит отметить, что вариантов композиции и декомпозиции классов, а также способов организации взаимодействия между ними - множество. Тем не менее, выше-описанная схема показывает еще одну, на мой взгляд, важную проблему: на каждый новый доменный объект, в лучшем случае, требуется заводить X2 объектов (DB Client и Repository). Поэтому, предлагаю рассмотреть еще один способ реализации. Для удобства, проект лежит тут.

Подготовка к реализации

В первую очередь, таким я вижу репозиторий:

protocol AccessableRepository {   //1   associatedtype DomainModel   //2   var actualSearchedData: Observable<[DomainModel]>? {get}      //3   func save(_ objects: [DomainModel], completion: @escaping ((Result<Void>) -> Void))   //4   func save(_ objects: [DomainModel], clearBeforeSaving: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void))      //5   func present(by request: RepositorySearchRequest, completion: @escaping ((Result<[DomainModel]>) -> Void))      //6   func delete(by request: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void))   //7   func eraseAllData(completion: @escaping ((Result<Void>) -> Void))}
  1. Доменный объект, которым оперирует репозиторий;

  2. Возможность подписаться на отслеживание изменений в репозитории;

  3. Сохранение объектов в репозиторий;

  4. Сохранение объектов с возможностью очистки старых данных в рамках одного контекста;

  5. Загрузка данных из репозитория;

  6. Удаление объектов из репозитория;

  7. Удаление всех данных из репозитория.

Возможно, ваш набор требований к репозиторияю будет отличаться, но концептуально ситуацию это не изменит.

К сожалению, возможность работать с репозиторием через AccessableRepository отсутствует, о чем свидетельствует ошибка на рисунке 4:

Рисунок 4Рисунок 4

В таком случае, хорошо подходит Generic-реализация репозитория, которая выглядит следующим образом:

class Repository<DomainModel>: NSObject, AccessableRepository {   typealias DomainModel = DomainModel      var actualSearchedData: Observable<[DomainModel]>? {      fatalError("actualSearchedData must be overrided")   }      func save(_ objects: [DomainModel], completion: @escaping ((Result<Void>) -> Void)) {      fatalError("save(_ objects: must be overrided")   }   func save(_ objects: [DomainModel], clearBeforeSaving: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void)) {      fatalError("save(_ objects vs clearBeforeSaving: must be overrided")   }      func present(by request: RepositorySearchRequest, completion: @escaping ((Result<[DomainModel]>) -> Void)) {      fatalError("present(by request: must be overrided")   }      func delete(by request: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void)) {      fatalError("delete(by request: must be overrided")   }   func eraseAllData(completion: @escaping ((Result<Void>) -> Void)) {      fatalError("eraseAllData(completion: must be overrided")   }}
  1. NSObject нужен для взаимодействия с NSFetchResultController;

  2. AccessableRepository - для наглядности, прозрачности и порядка;

  3. FatalError играет роль предохранителя, чтобы всяк сюда входящий не использовал то, что не реализовано;

  4. Данное решение позволяет не привязываться к конкретной реализации, а также обойти предыдущую проблему:

Рисунок 5Рисунок 5

Для работы с выборкой объектов потребуются объект с двумя свойствами:

protocol RepositorySearchRequest {   /* NSPredicate = nil,  apply for all records    for deletion sortDescriptor is not Used    */   //1   var predicate: NSPredicate? {get}   //2   var sortDescriptors: [NSSortDescriptor] {get}}
  1. Условие для выборки, если условие отсуствует - выборка применяется ко всему набору данных;

  2. Условие для сортировки - может как отсуствтвоать так и присутствовать.

Поскольку, в дальнейшем может возникнуть необходимость использовать отдельный NSPersistentContainer (например для тестирования), который, в свою очередь, выступает источником контекста - нужно закрыть его протоколом:

protocol DBContextProviding {   //1   func mainQueueContext() -> NSManagedObjectContext   //2   func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void)}
  1. Контекст, с которым работает main Queue, необходим для использования NSFetchedResultsController;

  2. Требуется для выполнения операций с данными в фоновом потоке. Можно заменить на newBackgroundContext(). Про различия в работе этих двух методов можно прочитать тут.

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

class DBEntityMapper<DomainModel, Entity> {  //1   func convert(_ entity: Entity) -> DomainModel? {      fatalError("convert(_ entity: Entity: must be overrided")   }  //2   func update(_ entity: Entity, by model: DomainModel) {      fatalError("supdate(_ entity: Entity: must be overrided")   }  //3   func entityAccessorKey(_ entity: Entity) -> String {      fatalError("entityAccessorKey must be overrided")   }  //4   func entityAccessorKey(_ object: DomainModel) -> String {      fatalError("entityAccessorKey must be overrided")   }}
  1. Позволяет конвертировать NSManagedObject в доменную модель;

  2. Позволяет обновить NSManagedObject с помощью доменной модели;

  3. 4. - используется для связи между собой NSManagedObject и доменным объектом.

Когда-то, я использовал доменный объект, в качестве инициализатора NSManagedObject. С одной стороны, это было удобно, с другой стороны накладывало ряд ограничений. Например, когда использовались связи между объектами и один NSManagedObject создавал несколько других NSManagedObject. Такой подход размывал зоны ответственности и негативно сказывался на общей логике работы с данными.

Во время работы с репозиторием потребуется обрабатвать ошибки, для этого достаточно enum:

enum DBRepositoryErrors: Error {   case entityTypeError   case noChangesInRepository}

Здесь абстрактная часть репозитория подходит к концу, осталось самои интересное - реализация.

Реализация

Для данного примера, подойдет простая реализация DBContextProvider (без каких-либо дополнительных параметров):

final class DBContextProvider {   //1   private lazy var persistentContainer: NSPersistentContainer = {      let container = NSPersistentContainer(name: "DataStorageModel")           container.loadPersistentStores(completionHandler: { (_, error) in         if let error = error as NSError? {            fatalError("Unresolved error \(error),\(error.userInfo)")         }         container.viewContext.automaticallyMergesChangesFromParent = true      })      return container   }()   //2   private lazy var mainContext = persistentContainer.viewContext}//3//MARK:- DBContextProviding implementationextension DBContextProvider: DBContextProviding {   func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) {      persistentContainer.performBackgroundTask(block)   }   func mainQueueContext() -> NSManagedObjectContext {      self.mainContext   }}
  1. Инициализация NSPersistentContainer;

  2. Раньше такой подход избавлял от утечек памяти;

  3. Реализация DBContextProviding.

Основная составляющая репозитория выглядит следующим образом:

final class DBRepository<DomainModel, DBEntity>: Repository<DomainModel>, NSFetchedResultsControllerDelegate {      //1   private let associatedEntityName: String   //2   private let contextSource: DBContextProviding   //3   private var fetchedResultsController: NSFetchedResultsController<NSFetchRequestResult>?   //4   private var searchedData: Observable<[DomainModel]>?   //5   private let entityMapper: DBEntityMapper<DomainModel, DBEntity>      //6   init(contextSource: DBContextProviding, autoUpdateSearchRequest: RepositorySearchRequest?, entityMapper: DBEntityMapper<DomainModel, DBEntity>) {      self.contextSource = contextSource      self.associatedEntityName = String(describing: DBEntity.self)      self.entityMapper = entityMapper            super.init()     //7      guard let request = autoUpdateSearchRequest else { return }      self.searchedData  = .init(value: [])      //self.fetchedResultsController = configureactualSearchedDataUpdating(request)   }}
  1. Cвойство, которое будет использовать при работе с NSFetchRequest;

  2. DBContextProviding - для доступа к контексту, требуется для выполнения операций сохранения, загрузки, удаления;

  3. fetchedResultsController - необходим, когда требуется отслеживать изменения в NSPersistentStore (проще говоря, изменение объектов в базе);

  4. searchedData - зависит от fetchedResultsController и служит оберткой для fetchedResultsController, скрывая детали реализации и уведомляя подписчиков об изменениях в данных;

  5. entityMapper - конвертирует доменные объекты в NSManagedObject и обратно;

  6. Иницализатор;

  7. В случае если autoUpdateSearchReques != nil, выполняем конфигурацию fetchedResultsController для отслеживания изменениы в базе ;

Чтобы не порождать однотипный код для работы с контекстом, потребуется вспомогательный метод:

   private func applyChanges(context: NSManagedObjectContext, mergePolicy: Any = NSMergeByPropertyObjectTrumpMergePolicy, completion: ((Result<Void>) -> Void)? = nil) {      //1      context.mergePolicy = mergePolicy      switch context.hasChanges {      case true:         do {            //2            try context.save()         } catch {            ConsoleLog.logEvent(object: "DBRepository \(DBEntity.self)", method: "saveIn", "Error: \(error)")            completion?(Result.error(error))         }         ConsoleLog.logEvent(object: "DBRepository \(DBEntity.self)", method: "saveIn", "Saving Complete")         completion?(Result(value: ()))      case false:         //3         ConsoleLog.logEvent(object: "DBRepository \(DBEntity.self)", method: "saveIn", "No changes in context")         completion?(Result(error: DBRepositoryErrors.noChangesInRepository))      }   }
  1. mergePolicy - отвечает за то, как решаются конфликты при работе с контекстом. В данном случае, по умолчанию используется политика, которая отдает приоритет изменённым объектам, находящимся в памяти, а не в persistent store;

  2. Сохранение изменений в persistent store;

  3. Если изменения объектов отсутствуют, в completion-блок передается соответствующая ошибка.

Сохранение объектов реализовано следующим образом:

   private func saveIn(data: [DomainModel], clearBeforeSaving: RepositorySearchRequest?, completion: @escaping ((Result<Void>) -> Void))  {      contextSource.performBackgroundTask() { context in                  //1         if let clearBeforeSaving = clearBeforeSaving {            let clearFetchRequest = NSFetchRequest<NSManagedObject>(entityName: self.associatedEntityName)            clearFetchRequest.predicate = clearBeforeSaving.predicate            clearFetchRequest.includesPropertyValues = false            (try? context.fetch(clearFetchRequest))?.forEach({ context.delete($0) })         }                  //2         var existingObjects: [String: DBEntity] = [:]         let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: self.associatedEntityName)                  (try? context.fetch(fetchRequest) as? [DBEntity])?.forEach({            let accessor = self.entityMapper.entityAccessorKey($0)            existingObjects[accessor] = $0         })                  data.forEach({            let accessor = self.entityMapper.entityAccessorKey($0)            //3            let entityForUpdate: DBEntity? = existingObjects[accessor] ??  NSEntityDescription.insertNewObject(forEntityName: self.associatedEntityName, into: context) as? DBEntity            //4            guard let entity = entityForUpdate else { return }            self.entityMapper.update(entity, by: $0)         })         //5         self.applyChanges(context: context, completion: completion)      }   }
  1. Используется при необходимости удалить объекты, перед сохранением новых (в рамках текущего контекста);

  2. Выполняется выгрузка объектов, которые существуют в репозитории, для их дальнейшего изменения;

  3. Если объект с нужным entityAccessorKey отсутствует, создается новый экземпляр NSManagedObject;

  4. Выполнение мапинга свойств из доменного объекта в NSManagedObject;

  5. Применение выполненных изменений.

Важно: Возможно вас смутил п.2., данное решение оптимально на небольших наборах данных. Я выполнял замеры (ExampleCase3 в демо-проекте) на 10 000 записей, iPhone 6s Plus IOS 12.4.1 и получил следующие результаты:

  • время записи/перезаписи данных от 0,9 до 1.8 сек, cкачок потребления оперативной памяти в пике до 33 мб;

  • если убрать код в п2 и оставить только вставку новых объектов, то время записи/перезаписи данных +- 20 сек, cкачок потребления оперативной памяти в пике до 50 мб.

Для больших наборов данных я бы рекомендовал разделять их на части, использовать batchUpdate и batchDelete, а начиная с IOS 13 появился batchInsert.

Таким образом, реализация методов save cводится к вызову метода saveIn:

  override func save(_ objects: [DomainModel], completion: @escaping ((Result<Void>) -> Void)) {      saveIn(data: objects, clearBeforeSaving: nil, completion: completion)   }   override func save(_ objects: [DomainModel], clearBeforeSaving: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void)) {      saveIn(data: objects, clearBeforeSaving: clearBeforeSaving, completion: completion)   }

Методы present, delete, eraseAllData завязаны на работе с NSFetchRequest. В их реализации нет ничего особенного, поэтому не вижу смысла заострять на них внимание:

 override func present(by request: RepositorySearchRequest, completion: @escaping ((Result<[DomainModel]>) -> Void)) {      //1      let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: associatedEntityName)      fetchRequest.predicate = request.predicate      fetchRequest.sortDescriptors = request.sortDescriptors      contextSource.performBackgroundTask() { context in         do {            //2            let rawData = try context.fetch(fetchRequest)            guard rawData.isEmpty == false else {return completion(Result(value: [])) }            guard let results = rawData as? [DBEntity] else {               completion(Result(value: []))               assertionFailure(DBRepositoryErrors.entityTypeError.localizedDescription)               return            }            //3            let converted = results.compactMap({ return self.entityMapper.convert($0) })            completion(Result(value: converted))         } catch {            completion(Result(error: error))         }      }   }      override func delete(by request: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void)) {      //1      let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: associatedEntityName)      fetchRequest.predicate = request.predicate      fetchRequest.includesPropertyValues = false      contextSource.performBackgroundTask() { context in         //2         let results = try? context.fetch(fetchRequest)         results?.forEach({ context.delete($0) })         //3         self.applyChanges(context: context, completion: completion)      }   }   override func eraseAllData(completion: @escaping ((Result<Void>) -> Void)) {      //1      let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: associatedEntityName)      let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)            batchDeleteRequest.resultType = .resultTypeObjectIDs      contextSource.performBackgroundTask({ context in         do {            //2            let result = try context.execute(batchDeleteRequest)            guard let deleteResult = result as? NSBatchDeleteResult,                  let ids = deleteResult.result as? [NSManagedObjectID]            else {               completion(Result.error(DBRepositoryErrors.noChangesInRepository))               return            }                        let changes = [NSDeletedObjectsKey: ids]            NSManagedObjectContext.mergeChanges(               fromRemoteContextSave: changes,               into: [self.contextSource.mainQueueContext()]            )            //3            completion(Result(value: ()))            return         } catch {            ConsoleLog.logEvent(object: "DBRepository \(DBEntity.self)", method: "eraseAllData", "Error: \(error)")            completion(Result.error(error))         }      })
  1. Создание запроса;

  2. Выборка объектов и их обработка;

  3. Вовзрат результата операции.

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

 private func configureactualSearchedDataUpdating(_ request: RepositorySearchRequest) -> NSFetchedResultsController<NSFetchRequestResult> {      //1      let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: associatedEntityName)            fetchRequest.predicate = request.predicate      fetchRequest.sortDescriptors = request.sortDescriptors            //2      let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,                                                                managedObjectContext: contextSource.mainQueueContext(), sectionNameKeyPath: nil,                                                                cacheName: nil)      //3      fetchedResultsController.delegate = self      try? fetchedResultsController.performFetch()      if let content = fetchedResultsController.fetchedObjects as? [DBEntity] {         updateObservableContent(content)      }      return fetchedResultsController   } func updateObservableContent(_ content: [DBEntity]) {      let converted = content.compactMap({ return self.entityMapper.convert($0) })   //4      searchedData?.value = converted   }
  1. Формирование запроса, на основании которого будут отслеживаться изменения;

  2. Создание экземпляра класса NSFetchedResultsController;

  3. performFetch() позволяет выполнить запрос и получить данные, не дожидаясь изменений в базе. Например, это может быть полезно при реализации Ofline First;

  4. Изменение свойства searchedData, в свою очередь уведомляет подписчиков (если такие имеются) об изменении.

Заключение

На этом этапе реализация всех основных методов для работы с репозиторием подходит к концу. Для меня основными преимуществами данного подхода стало следующее:

  • логика работы репозитория с Core Data стала везде единая;

  • для добавления новых объектов в репозиторий, достаточно создать только EntityMapper (новое Entity требуется создать в любом случае). Вся логика по мапингу свойств также собрана в одном месте;

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

  • репозиторий легко можно подменить, например для тестов, или для отладки.

Подобный подход может подойти не всем, и это основной его минус. Часто логика работы с данными зависит в т.ч и от бэк-энда. Основная задача этого поста - донести один из концептов по работе с Core Data. Смотрите демо-проект, допиливайте его под себя, пользуйтесь!

Спасибо за внимание! Легкого кодинга, поменьше багов, побольше фич!

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru