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

Swift

Настало время офигительных историй. Кастомные транзишены в 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. Указываем длительность анимации.

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

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

Подробнее..

Как внедряли Kotlin Multiplatform в Профи

07.05.2021 16:08:17 | Автор: admin

Привет, Хабр! Я Миша Игнатов, тимлид в компании Профи. Моя команда отвечает за клиентские мобильные приложения на Android и iOS. Мы используем Kotlin Multiplatform в production с 2019 года. Расскажу, почему мы выбрали именно эту технологию, как внедряли её, какие ключевые этапы прошли и какие сделали выводы.

Коротко о Kotlin Multiplatform

Kotlin Multiplatform позволяет запускать один и тот же код, написанный на Kotlin, на множестве платформ. В августе 2020 года компания JetBrains представила Kotlin Multiplatform Mobile (КММ) SDK, который помогает упростить использование общего кода на Android и iOS. Цель технологии вынос бизнес-логики. UI-слой остаётся нативным, что хорошо сказывается на опыте пользователя и внешнем виде приложений.

Почему мы выбрали Kotlin Multiplatform

Мы изучали разные кросс-платформенные технологии. Например, React Native и Flutter позволяют писать сразу всё в одном проекте на обе платформы, но ограничивают разработчика языком и набором библиотек. Остановились на Kotlin Multiplatform по трём причинам.

  1. Легко интегрировать

    Общий код, написанный на Kotlin, можно внедрить с минимальными усилиями в готовое приложение. Он компилируется в привычные для платформ библиотеки. Для Android это jar или aar-библиотека, для iOS Universal Framework. Подключение и дальнейшая работа не сильно отличаются от взаимодействия с любой нативной библиотекой.

  2. Синтаксис языка Kotlin близок к Swift

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

  3. Не нужно тратить ресурсы дважды на одну задачу

    Бизнес-логика наших приложений одинаковая. Более 70% кода не связано с платформой, на которой его запускают. Мы запрашиваем данные с сервера, преобразуем их, кешируем и готовим к отображению. Поэтому пишем код в двух проектах, дублируя логику, Android на языке Kotlin и iOS на Swift. Отличия есть только в дизайне из-за разного UX на мобильных платформах и взаимодействия с системой (запросы к различной периферии: камера, геолокация, галерея, уведомления и т.д.).

Как внедряли

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

Шаг 1. Первая строчка в общем коде

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

Обмен данными с сервером у нас реализован на GraphQL. Запрос в коде это multiline строка. Бывает пять строк, а бывает под сотню. Если отправить такой объём, бэкенду придётся тратить время на парсинг структуры. С другой стороны, нужно контролировать запрашиваемые данные во время код-ревью и валидации запросов на проде. Поэтому перед релизом мы обучаем сервер новым запросам. Это позволяет использовать хеши вместо строк.

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

Решили вынести в общий код несколько запросов. Для этого в Android-проекте сделали мультиплатформенный модуль shared. Перенесли в него строки запросов и обернули в классы-синглтоны object, а в клиентских приложениях вызывали методы этих классов. Забавный факт использовать КММ предложил iOS-разработчик.

Первая строчка в общем коде
package ru.profi.shared.queries.client.city/*** Запрос поиска города по [Params.term]*/object GeoSelectorWarpQuery : WarpQuery<Params> {   override val hash: String? = "\$GQLID{c9d4adbb7b9ef49fc044064b9a3e662b}"   override val dirtyQuery = listOf("\$term").let { (term) ->       """       query geoSelector($term: String) {         suggestions: simpleGeoSelector(term: $term, first: 100) {           edges {             node {               name               geoCityId               regionName               hostname               countryId             }           }         }       }       """   }.trimIndent()}
Использование в Android проекте
override fun getQuery() = GeoSelectorWarpQuery.getQuery()
Использование в iOS проекте
import KotlinComponentsstruct GraphQLWarpRequests {    static let GeoSelectorWarpQuery = GeoSelectorWarpQuery()...}let model = GraphQLRequestModel(query: GraphQLWarpRequests.GeoSelectorWarpQuery.getQuery(), variables: variables)

Теперь структуры запросов лежат в одном месте. В следующем релизе общую библиотеку подключили на обеих платформах, и всё работало без проблем. Размер приложения на iOS увеличился всего на 0,8 Мб. Впоследствии вынос запросов в общий код сократил количество подходов к обучению в два раза.

Проблему ручного обучения решили утилитарной библиотекой из нескольких классов, написанных на Kotlin. Она находит в коде необученные запросы, генерирует и отправляет новые хеши через pull request в репозиторий backend. Теперь мы не тратим время на обучение, оно полностью автоматизировано.

На этом шаге мы построили инфраструктуру для общего кода на Kotlin Мultiplatform. Можно переходить к более серьёзным задачам.

Шаг 2. Создаём мультиплатформенный SDK

В один момент компания решила создать свою in-house аналитику на базе Clickhouse. Для этого на стороне backend создали API для приложений. Моей команде оставалось только отправлять события. Чтобы не мешать работе основного функционала и не терять события, если у пользователя нет сети, нужно было научиться кешировать, группировать пачки событий и отправлять их с меньшим приоритетом, чем запросы на основной функционал.

Модуль решили писать в общем коде. Для отправки событий взяли network client ktor. Для работы с сетью он нас полностью устраивал.

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

Для асинхронных операций использовали kotlinx.coroutines. Для сериализации и десериализации выбрали kotlinx.serialization.

Чтобы повысить надёжность кода, функционал модуля покрыли unit-тестами. Удобно, что их можно запускать на разных платформах.

При интеграции приложения на Android проблем не возникло, но на iOS были падения на старте. В консоли XCode и логах Firebase Crashlytics трассировка стека не сильно приближала нас к причине. Но было ясно, что падает внутри общего кода.

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

Оказалось, что отправка события происходила после отмены скоупа корутины. Это и приводило к падению. Причина мы неправильно отменяли CoroutineScope в жизненном цикле приложения.

Kotlin Multiplatform позволил объединить в один модуль ответственность за отправку и хранение аналитических событий. В итоге мы построили полноценный SDK в общем коде.

Шаг 3. Переносим бизнес-логику из приложения Android в мультиплатформу

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

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

В приложении на Android бизнес-логику чатов недавно уже переписали на Kotlin. Поэтому решили попробовать вынести существующий модуль в общий код и адаптировать его под iOS.

Нам помогли ребята из IceRock.dev. Они уже давно встали на путь мультиплатформы, активно продвигают KMM и развивают сообщество. Вместе мы составили план переезда.

  1. Настроить поддержку Kotlin Multiplatform в gradle-модуле.
    Создать модуль, подключить плагины, настроить sourceSets и зависимости.

  2. Перенести платформенно-независимые классы в commonMain.
    Перенести всё, что не зависит от JVM и Android, в commonMain. Это место для общего кода, в котором нет платформенных зависимостей.

  3. Заменить библиотеки JVM/Android на мультиплатформенные аналоги.
    Перейти с org.json на kotlinx.serialization и с JodaTime на klock. Некоторые части пришлось вынести в платформозависимый код в виде expect/actual.

  4. Перенести в commonMain JVM-зависимый код, который требует изменений.
    Например, заменить JVM IOException на kotlin.Exception, а ConcurrentHashMap на использование Stately.

  5. Перенести в commonMain Android-зависимый код, который требует изменений.
    Единственной зависимостью Android SDK был компонент Service, который работает с WebSocket. Стабильного мультиплатформенного аналога на Kotlin пока нет.

    Мы решили оставить нативные реализации в приложении и подключить их через интерфейс SocketService.

    Интерфейс SocketService
    interface SocketService {    /**     * Присоединиться по сокету к [chatUrl]. Все события из сокета необходимо отдавать в [callback]     */    fun connect(chatUrl: String, callback: (SocketEvent) -> Unit)    /**     * Отсоединиться от текущего подключения по сокету.     */    fun disconnect()    /**     * Отправить сообщение [msg] в текущем подключении по сокету     */    fun send(msg: String)}
    
  6. Сделать модуль API удобным для обеих платформ.
    Так как в iOS невозможно перехватить runtime-исключения из Kotlin, мы решили обрабатывать их внутри SDK и добавить в методы интерфейса callback onError. Поэтому пришлось немного переделать интерфейс взаимодействия с клиентскими приложениями.

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

План от IceRock.dev помог нам двигаться увереннее и быстрее. Мы продолжаем созваниваться и делиться опытом разработки.

Что мы поняли

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

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

Разработчики быстро освоились. Для Android-разработчиков оказались в новинку только мультиплатформенные библиотеки и настройка build-скрипта модуля. Остальное привычно и не вызывает сложностей. iOS-разработчикам было легко понять синтаксис языка, но пришлось покопаться в сборке через Gradle. Но сейчас каждый из них уже решил как минимум одну задачу в общем коде.

Главный минус технологии время сборки для iOS. Например, когда мы искали причину падения приложения, пересобирать общий код для iOS приходилось часто. При публикации общих модулей это не ощущается. С выпусками новых версий Kotlin скорость сборки растёт, что добавляет надежд на будущий комфорт разработки.

Иногда мы спотыкались о проблемы по незнанию. Когда мы начинали, информации по внедрению KMM было очень мало, поэтому набивали шишки сами. Сейчас сообщество Kotlin Multiplatform быстро развивается. Появляется всё больше статей и докладов на конференциях и митапах. Есть каналы в Slack и Telegram, библиотеки для Kotlin Multiplatform.

Оно того стоило

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

Сейчас у нас уже 10 общих модулей разной сложности, и мы продолжаем выносить бизнес-логику в общий код. Уверен, что Kotlin Multiplatform Mobile готов к покорению мира разработки мобильных приложений.

Подробнее..

Перевод Связанные не явные выражения в Swift 5.4

14.04.2021 00:14:01 | Автор: admin

В Swift 5.4: не явные выражения для членов классов (также известные как точечный синтаксис) теперь могут использоваться даже при обращении к свойству или методу в результате такого выражения, пока окончательный тип возвращаемого значения остается прежним.

Обратите внимание, что на момент написания статьи Swift 5.4 находится в стадии бета-тестирования в качестве части Xcode 12.5.

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

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

// In Swift 5.3 and earlier, an explicit type reference is always// required when dealing with chained expressions:let view = UIView()view.backgroundColor = UIColor.blue.withAlphaComponent(0.5)...// In Swift 5.4, the type of our expression can now be inferred:let view = UIView()view.backgroundColor = .blue.withAlphaComponent(0.5)...

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

extension UIColor {    static var chiliRed: UIColor {        UIColor(red: 0.89, green: 0.24, blue: 0.16, alpha: 1)    }}let view = UIView()view.backgroundColor = .chiliRed.withAlphaComponent(0.5)...

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

extension ImageFilter {    static var dramatic: Self {        ImageFilter(            name: "Dramatic",            icon: .drama,            transforms: [                .portrait(withZoomMultipler: 2.1),                .contrastBoost,                .grayScale(withBrightness: .dark)            ]        )    }}

При использовании Swift 5.4 (или более поздних версий в будущем) мы могли бы добавить что-то вроде такого, что позволяет нам легко объединить два экземпляра ImageFilter, путем объединения их .transforms:

extension ImageFilter {    func combined(with filter: Self) -> Self {        var newFilter = self        newFilter.transforms += filter.transforms        return newFilter    }}

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

let filtered = image.withFilter(.dramatic.combined(with: .invert))

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

Подробнее..

Как мы подружили Flutter с CallKit Call Directory

21.04.2021 14:19:32 | Автор: admin

Flutter+CallKitCallDirectory=Love


Привет!


В этом лонгриде я расскажу о том, как мы в Voximplant пришли к реализации собственного Flutter плагина для использования CallKit во Flutter приложении, и в итоге оказались первыми, кто сделал поддержку блокировки/определения номеров через Call Directory для Flutter.


Что такое CallKit


Apple CallKit это фреймворк для интеграции звонков стороннего приложения в систему.


Если звонок из стороннего приложения отображается как нативный, то тут задействован CallKit. Если звонок из стороннего приложения отображается в списке звонков системного приложения Phone тоже CallKit. Сторонние приложения, выступающие в качестве определителя номера CallKit. Звонки из сторонних приложений, которые не могут пробиться через режим Не беспокоить ну вы поняли.



CallKit предоставляет сторонним разработчикам системный UI для отображения звонков



А что с CallKit на Flutter?


CallKit является частью iOS SDK, во Flutter он не представлен, однако доступ к нему из Flutter возможен путём взаимодействия с нативным кодом. Для использования функциональности этого фреймворка потребуется подключить сторонний плагин, инкапсулирующий взаимодействие Flutter с iOS, или реализовывать всё самостоятельно, например, так:



Пример реализации CallKit сервиса для Flutter, где код iOS приложения (platform code) связывает приложение Flutter с системой




Готовые решения с CallKit на Flutter


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


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



Как мы пришли к созданию своего решения


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


Мы задумались о том, чтобы реализовать своё решение с учетом этих недостатков.


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



Наша Реализация


Нам удалось перенести всё CallKit API на Dart с сохранением иерархии классов и механизмов взаимодействия с ними.



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


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


Например, нативное CallKit API CXProviderDelegate.provider(_:execute:) требует синхронно возвращать Bool значение:


optional func provider(_ provider: CXProvider,     execute transaction: CXTransaction) -> Bool

Этот метод вызывается каждый раз, когда нужно обработать новую транзакцию CXTransaction. Можно вернуть true, чтобы обработать транзакцию самостоятельно и уведомить об этом систему. Вернув false, получим дефолтное поведение, при котором для каждого CXAction, содержащегося в транзакции, будет вызван соответствующий метод обработчик в CXProviderDelegate.


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


Проблемы с асинхронностью возникают и в нативной части. Например, есть iOS фреймворк PushKit, он не является частью CallKit, но часто они используются вместе, так что интеграция с ним была необходима. При получении VoIP пуша требуется немедленно уведомить CallKit о входящем звонке в нативном коде, в противном случае приложение упадет. Для обработки этого нюанса мы решили дать возможность репортить входящие звонки напрямую в CallKit из нативного кода без асинхронного крюка в виде Flutter. В итоге для этой интеграции реализовали несколько хелперов в нативной части плагина (доступны через FlutterCallkitPlugin iOS класс) и несколько на стороне Flutter (доступны через FCXPlugin Dart класс).


Дополнительные возможности плагина мы объявили в его собственном классе, чтобы отделить интерфейс плагина от интерфейса CallKit.

Как зарепортить входящий звонок напрямую в CallKit

При получении VoIP пуша вызывается один из методов PKPushRegistryDelegate.pushRegistry(_: didReceiveIncomingPushWith:). Здесь необходимо создать экземпляр CXProvider и вызвать reportNewIncomingCall для уведомления CallKit о звонке. Так как для дальнейшей работы со звонком необходим тот же экземпляр провайдера, мы добавили метод FlutterCallkitPlugin.reportNewIncomingCallWithUUID с нативной стороны плагина. При его вызове плагин сам зарепортит звонок в CXProvider, а так же вызовет FCXPlugin.didDisplayIncomingCall хендлер на стороне Dart для продолжения работы со звонком.


func pushRegistry(_ registry: PKPushRegistry,                  didReceiveIncomingPushWith payload: PKPushPayload,                  for type: PKPushType,                  completion: @escaping () -> Void) {    // Достаем необходимые данные из пуша    guard let uuidString = payload["UUID"] as? String,        let uuid = UUID(uuidString: uuidString),        let localizedName = payload["identifier"] as? String    else {        return    }    let callUpdate = CXCallUpdate()    callUpdate.localizedCallerName = localizedName    let configuration = CXProviderConfiguration(        localizedName: "ExampleLocalizedName"    )        // Репортим звонок в плагин, а он зарепортит его в CallKit    FlutterCallkitPlugin.sharedInstance.reportNewIncomingCall(        with: uuid,        callUpdate: callUpdate,        providerConfiguration: configuration,        pushProcessingCompletion: completion    )}


Подводя итог: главной фишкой нашего плагина является то, что его использование на Flutter практически не отличается от использования нативного CallKit на iOS.


One more thing


Но оставалось ещё кое-что в Apple CallKit, что мы не реализовали у себя (и не реализовал никто в доступных сторонних решениях). Это поддержка Call Directory App Extension.



Что такое Call Directory


CallKit умеет блокировать и определять номера, доступ к этим возможностям для разработчиков открыт через специальное системное расширение Call Directory. Подробнее про iOS app extensions можно почитать в App Extension Programming Guide.



Call Directory app extension позволяет блокировать и/или идентифицировать номера


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


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



Пример архитектуры для реализации Call Directory


Примеры с передачей номеров в Call Directory уже есть на хабре: раз и два.


Подробнее про iOS App Extensions: App Extension Programming Guide.



Call Directory Extension на Flutter


Не так давно нам написал пользователь с запросом на добавление поддержки Call Directory. Начав изучать возможность реализации этой фичи, мы выяснили, что сделать Flutter API без необходимости написания пользователями нативного кода не выйдет. Проблема заключается в том, что, как было сказано выше, Call Directory работает в расширении. Оно запускается системой, работает очень короткое время и не зависит от приложения (и в том числе от Flutter). Таким образом, для поддержки этого функционала пользователю плагина так или иначе потребуется реализовать app extension и хранилище данных самостоятельно.



Пример работы с Call Directory во Flutter приложении



Принятое решение


Несмотря на сложности с нативным кодом, мы твёрдо решили сделать использование Call Directory максимально удобным для пользователей нашего фреймворка.


Проверив возможность работы такого расширения в связке с Flutter приложением, мы принялись за проектирование. Решение должно было сохранить все Call Directory Manager API, а также требовать от пользователя минимум написания нативного кода и быть удобным для взаимодействия через Flutter.


Так мы сделали версию 1.2.0 с поддержкой Call Directory Extension.



Как мы реализовывали Call Directory для Flutter


Итак, для реализации этого функционала требовалось учесть несколько аспектов:


  • Перенести интерфейс класса CXCallDirectoryManager (CallKit объект позволяющий управлять Call Directory)
  • Решить, что делать с app extension и хранилищем номеров для него
  • Создать удобный способ передачи данных из Dart в натив и обратно для управления списками номеров из Flutter приложения


Перенос интерфейсов CXCallDirectoryManager во Flutter


Код, приведенный в статье, был специально упрощен для облегчения восприятия, полную версию кода можно найти по ссылкам в конце статьи. Для реализации плагина мы использовали Objective-C, так как он был выбран основным в проекте ранее. Интерфейсы CallKit представлены на Swift для простоты.


Интерфейс


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


extension CXCallDirectoryManager {    public enum EnabledStatus : Int {        case unknown = 0        case disabled = 1        case enabled = 2    }}open class CXCallDirectoryManager : NSObject {    open class var sharedInstance: CXCallDirectoryManager { get }    open func reloadExtension(        withIdentifier identifier: String,        completionHandler completion: ((Error?) -> Void)? = nil    )    open func getEnabledStatusForExtension(        withIdentifier identifier: String,        completionHandler completion: @escaping (CXCallDirectoryManager.EnabledStatus, Error?) -> Void    )    open func openSettings(        completionHandler completion: ((Error?) -> Void)? = nil    )}

Воссоздадим аналог CXCallDirectoryManager.EnabledStatus энама в Dart:


enum FCXCallDirectoryManagerEnabledStatus {  unknown,  disabled,  enabled}

Теперь можно объявить класс и методы. Необходимости в sharedInstance в нашем интерфейсе нет, так что сделаем обычный Dart класс со static методами:


class FCXCallDirectoryManager {  static Future<void> reloadExtension(String extensionIdentifier) async { }  static Future<FCXCallDirectoryManagerEnabledStatus> getEnabledStatus(    String extensionIdentifier,  ) async { }  static Future<void> openSettings() async { }}

Сохранение API важно, но так же важно учитывать платформенные и языковые code-style, чтобы использование интерфейса было понятно и удобно для пользователей плагина.


Для API в Dart мы использовали более короткое название без слов-связок (длинное название пришло из objective-C) и заменили completion блок на Future. Future является стандартным механизмом, используемым для получения результата выполнения асинхронных методов в Dart. Мы также возвращаем Future из большинства Dart методов плагина, потому что коммуникация с нативным кодом происходит асинхронно.


Было getEnabledStatusForExtension(withIdentifier:completionHandler:)


Стало Future getEnabledStatus(extensionIdentifier)




Реализация


Для коммуникации между Flutter и iOS будем использовать FlutterMethodChannel.


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



On the Flutter side


Создадим объект MethodChannel:


const MethodChannel _methodChannel =  const MethodChannel('plugins.voximplant.com/flutter_callkit');


On the iOS side


Первым делом iOS класс плагина нужно подписать на протокол FlutterPlugin, чтобы иметь возможность взаимодействовать с Flutter:


@interface FlutterCallkitPlugin : NSObject<FlutterPlugin>@end

При инициализации плагина создадим FlutterMethodChannel с таким же идентификатором, что мы использовали выше:


+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {    FlutterMethodChannel *channel        = [FlutterMethodChannel           methodChannelWithName:@"plugins.voximplant.com/flutter_callkit"          binaryMessenger:[registrar messenger]];    FlutterCallkitPlugin *instance         = [FlutterCallkitPlugin sharedPluginWithRegistrar:registrar];    [registrar addMethodCallDelegate:instance channel:channel];}

Теперь можно использовать этот канал для вызова iOS методов из Flutter.



Рассмотрим подробно реализацию методов в Dart и нативной части плагина на примере getEnabledStatus.



On the Flutter side


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


Про MethodChannel

MethodChannel API позволяет асинхронно получить результат вызова из нативного кода посредством Future, но накладывает ограничения на передаваемые типы данных.




Итак, нам потребуется передать имя метода (его будем использовать в нативном коде для того, чтобы идентифицировать вызов) и аргумент extensionIdentifier в MethodChannel.invokeMethod, а затем преобразовать результат из простейшего типа int в FCXCallDirectoryManagerEnabledStatus. На случай ошибки в нативном коде следует обработать PlatformException.


static Future<FCXCallDirectoryManagerEnabledStatus> getEnabledStatus(  String extensionIdentifier,) async {  try {    // Воспользуемся объектом MethodChannel для вызова    // соответствующего метода в платформенном коде    // с аргументом extensionIdentifier.    int index = await _methodChannel.invokeMethod(      'Plugin.getEnabledStatus',      extensionIdentifier,    );    // Преобразуем результат в энам     // FCXCallDirectoryManagerEnabledStatus    // и вернем его значение пользователю    return FCXCallDirectoryManagerEnabledStatus.values[index];  } on PlatformException catch (e) {    // Если что-то пошло не так, обернем ошибку в собственный тип     // и отдадим пользователю    throw FCXException(e.code, e.message);  }}

Обратите внимание на идентификатор метода который мы использовали:


Plugin.getEnabledStatus


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


getEnabledStatus идентично названию метода во Flutter, а не в iOS (или Android).




On the iOS side


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


Вызовы через FlutterMethodChannel попадают в метод handleMethodCall:result:.


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


- (void)handleMethodCall:(FlutterMethodCall*)call                  result:(FlutterResult)result {    // Вызовы из Flutter можно идентифицировать по названию,    // которое передается в `FlutterMethodCall.method` проперти    if ([@"Plugin.getEnabledStatus" isEqualToString:call.method]) {        // При передаче аргументов с помощью MethodChannel,         // они упаковываются в `FlutterMethodCall.arguments`        // Извлечем extensionIdentifier, который         // мы передали сюда ранее из Flutter кода        NSString *extensionIdentifier = call.arguments;        if (isNull(extensionIdentifier)) {            // Если аргументы не валидны, вернём ошибку через             // `result` обработчик            // Ошибка должна быть упакована в `FlutterError`            // Она вылетит в виде PlatformException в Dart коде            result([FlutterError errorInvalidArguments:@"extensionIdentifier must not be null"]);            return;}        // Теперь, когда метод обнаружен,        // а аргументы извлечены и провалидированы,         // можно реализовать саму логику        // Для взаимодействия с этой функциональностью CallKit // потребуется экземпляр CallDirectoryManager        CXCallDirectoryManager *manager             = CXCallDirectoryManager.sharedInstance;        // Вызываем метод CallDirectoryManager        // с требуемой функциональностью        // и ожидаем результата        [manager             getEnabledStatusForExtensionWithIdentifier:extensionIdentifier            completionHandler:^(CXCallDirectoryEnabledStatus status,                                            NSError * _Nullable error) {            // completion с результатом вызова запустился,             // можем пробросить результат в Dart            // предварительно сконвертировав его в подходящие типы,             // так как через MethodChannel можно передавать            // лишь некоторые определенные типы данных.            if (error) {                // Ошибки передаются упакованные в `FlutterError`                result([FlutterError errorFromCallKitError:error]);            } else {                // Номера передаются упакованные в `NSNumber`                // Так как этот энам представлен значениями `NSInteger`,                 // выполним требуемое преобразование                result([self convertEnableStatusToNumber:enabledStatus]);            }}];    }}


По аналогии реализуем оставшиеся два метода FCXCallDirectoryManager



On the Flutter side


static Future<void> reloadExtension(String extensionIdentifier) async {  try {    // Задаем идентификатор, передаем аргумент     // и вызываем платформенный метод    await _methodChannel.invokeMethod(      'Plugin.reloadExtension',      extensionIdentifier,    );  } on PlatformException catch (e) {    throw FCXException(e.code, e.message);  }}static Future<void> openSettings() async {  try {    // А этот метод не принимает аргументов     await _methodChannel.invokeMethod(      'Plugin.openSettings',    );  } on PlatformException catch (e) {    throw FCXException(e.code, e.message);  }}


On the iOS side


if ([@"Plugin.reloadExtension" isEqualToString:call.method]) {    NSString *extensionIdentifier = call.arguments;    if (isNull(extensionIdentifier)) {        result([FlutterError errorInvalidArguments:@"extensionIdentifier must not be null"]);        return;    }    CXCallDirectoryManager *manager         = CXCallDirectoryManager.sharedInstance;    [manager         reloadExtensionWithIdentifier:extensionIdentifier        completionHandler:^(NSError * _Nullable error) {        if (error) {            result([FlutterError errorFromCallKitError:error]);        } else {            result(nil);        }    }];}if ([@"Plugin.openSettings" isEqualToString:call.method]) {    if (@available(iOS 13.4, *)) {        CXCallDirectoryManager *manager             = CXCallDirectoryManager.sharedInstance;        [manager             openSettingsWithCompletionHandler:^(NSError * _Nullable error) {            if (error) {                result([FlutterError errorFromCallKitError:error]);            } else {                result(nil);            }        }];    } else {        result([FlutterError errorLowiOSVersionWithMinimal:@"13.4"]);    }}


Готово, CallDirectoryManager реализован и может быть использован.


Подробнее про Platform-Flutter взаимодействие



App Extension и хранилище номеров


Так как из-за нахождения Call Directory в iOS расширении мы не сможем предоставить его реализацию с плагином, а работа с платформенным кодом обычно непривычна для Flutter разработчиков, не знакомых с нативной разработкой, постараемся по максимуму помочь им с помощью Документации!


Реализуем полноценный пример app extension и хранилища и подключим их к example app нашего плагина.


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


Примерно так выглядит интерфейс нашего хранилища:


// Доступ к хранилищу из iOS приложения@UIApplicationMainfinal class AppDelegate: FlutterAppDelegate {    @UserDefault("blockedNumbers", defaultValue: [])    private var blockedNumbers: [BlockableNumber]    @UserDefault("identifiedNumbers", defaultValue: [])    private var identifiedNumbers: [IdentifiableNumber]}// Доступ к хранилищу из app extensionfinal class CallDirectoryHandler: CXCallDirectoryProvider {    @UserDefault("blockedNumbers", defaultValue: [])    private var blockedNumbers: [BlockableNumber]    @UserDefault("identifiedNumbers", defaultValue: [])    private var identifiedNumbers: [IdentifiableNumber]    @NullableUserDefault("lastUpdate")    private var lastUpdate: Date?}


Код имплементации хранилища:


UserDefaults


Код iOS приложения:


iOS App Delegate


Код iOS расширения:


iOS App Extension


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


Передача номеров из Flutter в iOS и обратно


Итак, app extension настроен и связан с хранилищем, необходимые методы CallDirectoryManager реализованы, осталась последняя деталь научиться передавать номера из Flutter в платформенное хранилище или, наоборот, запрашивать номера оттуда.


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



Интерфейс


Посмотрим, какие интерфейсы могут понадобиться:


  • Добавление блокируемых/идентифицируемых номеров в хранилище
  • Удаление блокируемых/идентифицируемых номеров из хранилища
  • Запрос блокируемых/идентифицируемых номеров из хранилища


On the Flutter side


Для методов-хелперов мы ранее решили использовать классы плагина FCXPlugin (Flutter) и FlutterCallkitPlugin (iOS). Однако Call Directory является узкоспециализированным функционалом, который используется далеко не в каждом проекте. Поэтому хотелось вынести это в отдельный файл, но оставить доступ через объект класса FCXPlugin, для этого подойдет extension:


extension FCXPlugin_CallDirectoryExtension on FCXPlugin {  Future<List<FCXCallDirectoryPhoneNumber>> getBlockedPhoneNumbers()    async { }  Future<void> addBlockedPhoneNumbers(    List<FCXCallDirectoryPhoneNumber> numbers,  ) async { }  Future<void> removeBlockedPhoneNumbers(List<FCXCallDirectoryPhoneNumber> numbers,  ) async { }  Future<void> removeAllBlockedPhoneNumbers() async { }  Future<List<FCXIdentifiablePhoneNumber>> getIdentifiablePhoneNumbers()    async { }  Future<void> addIdentifiablePhoneNumbers(List<FCXIdentifiablePhoneNumber> numbers,  ) async { }  Future<void> removeIdentifiablePhoneNumbers(List<FCXCallDirectoryPhoneNumber> numbers,  ) async { }  Future<void> removeAllIdentifiablePhoneNumbers() async { }}


On the iOS side


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



@interface FlutterCallkitPlugin : NSObject<FlutterPlugin>@property(strong, nonatomic, nullable)NSArray<FCXCallDirectoryPhoneNumber *> *(^getBlockedPhoneNumbers)(void);@property(strong, nonatomic, nullable)void(^didAddBlockedPhoneNumbers)(NSArray<FCXCallDirectoryPhoneNumber *> *numbers);@property(strong, nonatomic, nullable)void(^didRemoveBlockedPhoneNumbers)(NSArray<FCXCallDirectoryPhoneNumber *> *numbers);@property(strong, nonatomic, nullable)void(^didRemoveAllBlockedPhoneNumbers)(void);@property(strong, nonatomic, nullable)NSArray<FCXIdentifiablePhoneNumber *> *(^getIdentifiablePhoneNumbers)(void);@property(strong, nonatomic, nullable)void(^didAddIdentifiablePhoneNumbers)(NSArray<FCXIdentifiablePhoneNumber *> *numbers);@property(strong, nonatomic, nullable)void(^didRemoveIdentifiablePhoneNumbers)(NSArray<FCXCallDirectoryPhoneNumber *> *numbers);@property(strong, nonatomic, nullable)void(^didRemoveAllIdentifiablePhoneNumbers)(void);@end


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


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


Реализация


Теперь реализуем связь между объявленными методами-хелперами во Flutter и обработчиками в iOS.


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

Get identifiable numbers



On the Flutter side


Future<List<FCXIdentifiablePhoneNumber>> getIdentifiablePhoneNumbers() async {  try {    // Вызываем платформенный метод и сохраняем результат    List<dynamic> numbers = await _methodChannel.invokeMethod(      'Plugin.getIdentifiablePhoneNumbers',    );    // Типизируем результат и возвращаем пользователю    return numbers      .map(        (f) => FCXIdentifiablePhoneNumber(f['number'], label: f['label']))      .toList();  } on PlatformException catch (e) {    throw FCXException(e.code, e.message);  }}


On the iOS side


if ([@"Plugin.getIdentifiablePhoneNumbers" isEqualToString:call.method]) {    if (!self.getIdentifiablePhoneNumbers) {        // Проверяем существует-ли обработчик,        // если нет  возвращаем ошибку        result([FlutterError errorHandlerIsNotRegistered:@"getIdentifiablePhoneNumbers"]);        return;    }    // Используя обработчик, запрашиваем номера у пользователя    NSArray<FCXIdentifiablePhoneNumber *> *identifiableNumbers        = self.getIdentifiablePhoneNumbers();    NSMutableArray<NSDictionary *> *phoneNumbers        = [NSMutableArray arrayWithCapacity:identifiableNumbers.count];    // Оборачиваем каждый номер в словарь,     // чтобы иметь возможность передать их через MethodChannel     for (FCXIdentifiablePhoneNumber *identifiableNumber in identifiableNumbers) {        NSMutableDictionary *dictionary             = [NSMutableDictionary dictionary];        dictionary[@"number"]             = [NSNumber numberWithLongLong:identifiableNumber.number];        dictionary[@"label"]             = identifiableNumber.label;        [phoneNumbers addObject:dictionary];    }    // Отправляем номера во Flutter    result(phoneNumbers);}


Add identifiable numbers



On the Flutter side


Future<void> addIdentifiablePhoneNumbers(  List<FCXIdentifiablePhoneNumber> numbers,) async {  try {    // Готовим номера для передачи через MethodChannel    List<Map> arguments = numbers.map((f) => f._toMap()).toList();    // Отправляем номера в нативный код    await _methodChannel.invokeMethod(      'Plugin.addIdentifiablePhoneNumbers',      arguments    );  } on PlatformException catch (e) {    throw FCXException(e.code, e.message);  }}


On the iOS side


if ([@"Plugin.addIdentifiablePhoneNumbers" isEqualToString:call.method]) {    if (!self.didAddIdentifiablePhoneNumbers) {        // Проверяем существует-ли обработчик,        // если нет  возвращаем ошибку        result([FlutterError errorHandlerIsNotRegistered:@"didAddIdentifiablePhoneNumbers"]);        return;    }    // Достаем переданные в аргументах номера    NSArray<NSDictionary *> *numbers = call.arguments;    if (isNull(numbers)) {        // Проверяем их валидность        result([FlutterError errorInvalidArguments:@"numbers must not be null"]);        return;    }    NSMutableArray<FCXIdentifiablePhoneNumber *> *identifiableNumbers        = [NSMutableArray array];    // Типизируем номера    for (NSDictionary *obj in numbers) {        NSNumber *number = obj[@"number"];        __auto_type identifiableNumber            = [[FCXIdentifiablePhoneNumber alloc] initWithNumber:number.longLongValue                                                                                     label:obj[@"label"]];        [identifiableNumbers addObject:identifiableNumber];    }    // Отдаём типизированные номера в обработчик пользователю    self.didAddIdentifiablePhoneNumbers(identifiableNumbers);    // Сообщаем во Flutter о завершении операции    result(nil);}


Остальные методы реализуются по аналогии, полный код:




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


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



Reload extension


Метод reloadExtension(withIdentifier:completionHandler:) используется для перезагрузки расширения Call Directory. Это может потребоваться, например, после добавления новых номеров в хранилище, чтобы они попали в CallKit.


Использование идентично нативному CallKit API: обращаемся к FCXCallDirectoryManager и запрашиваем перезагрузку по заданному extensionIdentifier:


final String _extensionID =  'com.voximplant.flutterCallkit.example.CallDirectoryExtension';Future<void> reloadExtension() async {  await FCXCallDirectoryManager.reloadExtension(_extensionID);}


Get identified numbers



On the Flutter side


Запрашиваем список идентифицируемых номеров через класс плагина из Flutter:


final FCXPlugin _plugin = FCXPlugin();Future<List<FCXIdentifiablePhoneNumber>> getIdentifiedNumbers() async {  return await _plugin.getIdentifiablePhoneNumbers();}


On the iOS side


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


private let callKitPlugin = FlutterCallkitPlugin.sharedInstance@UserDefault("identifiedNumbers", defaultValue: [])private var identifiedNumbers: [IdentifiableNumber]// Добавляем обработчик событий запроса номеровcallKitPlugin.getIdentifiablePhoneNumbers = { [weak self] in    guard let self = self else { return [] }    // Возвращаем номера из хранилища в обработчик    return self.identifiedNumbers.map {        FCXIdentifiablePhoneNumber(number: $0.number, label: $0.label)    }}


Теперь номера из пользовательского хранилища будут попадать в обработчик, а из него через плагин во Flutter.



Add identified numbers



On the Flutter side


Передаем номера, которые хотим идентифицировать, в объект плагина:


final FCXPlugin _plugin = FCXPlugin();Future<void> addIdentifiedNumber(String number, String id) async {  int num = int.parse(number);  var phone = FCXIdentifiablePhoneNumber(num, label: id);  await _plugin.addIdentifiablePhoneNumbers([phone]);}


On the iOS side


Добавляем обработчик didAddIdentifiablePhoneNumbers, который плагин использует для уведомления платформенного кода о получении новых номеров из Flutter. В обработчике сохраняем полученные номера в хранилище номеров:


private let callKitPlugin = FlutterCallkitPlugin.sharedInstance@UserDefault("identifiedNumbers", defaultValue: [])private var identifiedNumbers: [IdentifiableNumber]// Добавляем обработчик событий добавления номеровcallKitPlugin.didAddIdentifiablePhoneNumbers = { [weak self] numbers in    guard let self = self else { return }    // Сохраняем в хранилище номера, переданные плагином в обработчик    self.identifiedNumbers.append(        contentsOf: numbers.map {            IdentifiableNumber(identifiableNumber: $0)        }    )    // Номера в Call Directory обязательно должны быть отсортированы    self.identifiedNumbers.sort()}


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


Полные примеры:




Итог


У нас получилось дать возможность использовать CallKit Call Directory из Flutter!


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


Теперь во Flutter можно относительно просто блокировать и/или определять номера с помощью нативного Call Directory.



Пример работы с Call Directory в Flutter приложении с использованием flutter_callkit_voximplant



Результаты:


  • Интерфейс CallDirectoryManager полностью перенесен
  • Добавлен простой способ передачи номеров из Flutter кода в iOS, оставлена возможность использовать собственные решения передачи данных
  • Архитектура решения описана в README с визуальными схемами для лучшего понимания
  • Добавлен полноценный работоспособный example app, использующий всю функциональность Call Directory, реализующий пример платформенных модулей (таких как iOS расширение и хранилище данных)


Полезные ссылки


Source код flutter_callkit на GitHub


Example app код на GitHub


Полная документация по использованию Call Directory с flutter_callkit


CallKit Framework Documentation by Apple


App Extension Programming Guide by Apple


Writing custom platform-specific code by Flutter

Подробнее..

Перевод Построители результатов в Swift описание и примеры кода

05.05.2021 12:08:12 | Автор: admin

Перевод подготовлен в рамках набора на курс "iOS Developer. Professional".

Всех желающих приглашаем на открытый демо-урок Machine Learning в iOS с помощью CoreML и CreateML: изображения, текст, звук. На занятии обсудим:

1. Основные архитектуры нейронных сетей и их оптимизированные версии под мобильные устройства;
2. Возможности CoreML 3 и 4, обучение на iOS устройстве;
3. Самостоятельное обучение классификатора изображений с помощью CreateML и использование его с Vision;
4. Использование обученных моделей для работы с текстом и звуком в iOS.


Построители результатов (result builders) в Swift позволяют получать результирующее значение из последовательности компонентов выставленных друг за другом строительных блоков. Они появились в Swift5.4 и доступны в Xcode12.5 и более поздних версиях. Ранее эти средства были известны как function builders (построители функций). Вам, вероятно, уже приходилось использовать их при создании стеков представлений в SwiftUI.

Должен признаться: поначалу я думал, что это некая узкоспециализированная возможность Swift, которую я никогда не стану применять для организации своего кода. Однако стоило мне в ней разобраться и написать небольшое решение для создания ограничений представления в UIKit, как я обнаружил, что раньше просто не понимал всю мощь построителей результатов.

Что такое построители результатов?

Построитель результата можно рассматривать как встроенный предметно-ориентированный язык (DSL), описывающий объединение неких частей в окончательный результат. В простых объявлениях представлений SwiftUI за кадром используется атрибут @ViewBuilder, который представляет собой реализацию построителя результата:

struct ContentView: View {     var body: some View {         // This is inside a result builder         VStack {             Text("Hello World!") // VStack and Text are 'build blocks'         }     } }

Все дочерние представления (в данном случае VStack, содержащий Text) будут объединены в одно представление View. Другими словами, строительные блоки View встраиваются в результат View. Это важно понять, поскольку именно так работают построители результатов.

Если рассмотреть объявление протокола View в SwiftUI, можно заметить, что переменная body определяется с использованием атрибута @ViewBuilder:

@ViewBuilder var body: Self.Body { get }

Именно так можно использовать собственный построитель результата в качестве атрибута функции, переменной или сабскрипта.

Создание собственного построителя результата

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

var constraints: [NSLayoutConstraint] = [     // Single constraint     swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor) ] // Boolean check if alignLogoTop {     constraints.append(swiftLeeLogo.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)) } else {     constraints.append(swiftLeeLogo.centerYAnchor.constraint(equalTo: view.centerYAnchor)) } // Unwrap an optional if let fixedLogoSize = fixedLogoSize {     constraints.append(contentsOf: [         swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width),         swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)     ]) } // Add a collection of constraints constraints.append(contentsOf: label.constraintsForAnchoringTo(boundsOf: view)) // Returns an array // Activate NSLayoutConstraint.activate(constraints)

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

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

 @AutolayoutBuilder var constraints: [NSLayoutConstraint] {     swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor) // Single constraint          if alignLogoTop {         swiftLeeLogo.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)     } else {         swiftLeeLogo.centerYAnchor.constraint(equalTo: view.centerYAnchor) // Single constraint     }          if let fixedLogoSize = fixedLogoSize {         swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width)         swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)     }          label.constraintsForAnchoringTo(boundsOf: view) // Returns an array } 

Здорово, не правда ли?

Итак, рассмотрим способ создания такого решения.

Определение построителя для авторазметки

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

@resultBuilder struct AutolayoutBuilder {          // .. Handle different cases, like unwrapping and collections  } 

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

Это делается с помощью следующего метода:

 @resultBuilder struct AutolayoutBuilder {          static func buildBlock(_ components: NSLayoutConstraint...) -> [NSLayoutConstraint] {         return components     }  }

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

Теперь мы можем определить коллекцию ограничений следующим образом:

@AutolayoutBuilder var constraints: [NSLayoutConstraint] {     // Single constraint     swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor) } 

Обработка коллекции строительных блоков

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

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

Описание ошибки отлично объясняет происходящее:

Cannot pass array of type [NSLayoutConstraint] as variadic arguments of type NSLayoutConstraint Невозможно передать массив типа [NSLayoutConstraint] как вариативные аргументы типа NSLayoutConstraint

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

Список доступных методов в определении кастомного построителя результата.Список доступных методов в определении кастомного построителя результата.

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

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

 protocol LayoutGroup {     var constraints: [NSLayoutConstraint] { get } } extension NSLayoutConstraint: LayoutGroup {     var constraints: [NSLayoutConstraint] { [self] } } extension Array: LayoutGroup where Element == NSLayoutConstraint {     var constraints: [NSLayoutConstraint] { self } } 

Этот протокол позволит нам преобразовывать как отдельные ограничения, так и коллекцию ограничений в массив ограничений. Другими словами, мы можем объединить оба типа в один [NSLayoutConstraint].

Теперь мы можем переписать наш построитель результата так, чтобы он принимал наш протокол LayoutGroup:

 @resultBuilder struct AutolayoutBuilder {          static func buildBlock(_ components: LayoutGroup...) -> [NSLayoutConstraint] {         return components.flatMap { $0.constraints }     } } 

Для получения единой коллекции ограничений здесь используется метод flatMap. Если вы не знаете, для чего нужен метод flatMap или почему мы использовали его вместо compactMap, почитайте мою статью Методы compactMap и flatMap:в чем разница?

Наконец, мы можем обновить наше решение, чтобы задействовать новый обработчик коллекции строительных блоков:

@AutolayoutBuilder var constraints: [NSLayoutConstraint] {     // Single constraint     swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)          label.constraintsForAnchoringTo(boundsOf: view) // Returns an array } 

Разворачивание опционалов

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

Добавим метод buildOptional(..) к нашему построителю результата:

 @resultBuilder struct AutolayoutBuilder {          static func buildBlock(_ components: LayoutGroup...) -> [NSLayoutConstraint] {         return components.flatMap { $0.constraints }     }          static func buildOptional(_ component: [LayoutGroup]?) -> [NSLayoutConstraint] {         return component?.flatMap { $0.constraints } ?? []     } } 

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

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

@AutolayoutBuilder var constraints: [NSLayoutConstraint] {     // Single constraint     swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)          label.constraintsForAnchoringTo(boundsOf: view) // Returns an array          // Unwrapping an optional     if let fixedLogoSize = fixedLogoSize {         swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width)         swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)     } } 

Обработка условных операторов

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

 @AutolayoutBuilder var constraints: [NSLayoutConstraint] {     // Single constraint     swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)          label.constraintsForAnchoringTo(boundsOf: view) // Returns an array          // Unwrapping an optional     if let fixedLogoSize = fixedLogoSize {         swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width)         swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)     }          // Conditional check     if alignLogoTop {         // Handle either the first component:         swiftLeeLogo.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)     } else {         // Or the second component:         swiftLeeLogo.centerYAnchor.constraint(equalTo: view.centerYAnchor)     } } 

В наш построитель результата надо добавить еще пару обработчиков строительных блоков:

 @resultBuilder struct AutolayoutBuilder {          static func buildBlock(_ components: LayoutGroup...) -> [NSLayoutConstraint] {         return components.flatMap { $0.constraints }     }          static func buildOptional(_ component: [LayoutGroup]?) -> [NSLayoutConstraint] {         return component?.flatMap { $0.constraints } ?? []     }          static func buildEither(first component: [LayoutGroup]) -> [NSLayoutConstraint] {         return component.flatMap { $0.constraints }     }      static func buildEither(second component: [LayoutGroup]) -> [NSLayoutConstraint] {         return component.flatMap { $0.constraints }     } } 

В обоих обработчиках buildEither для получения ограничений и их возвращения в виде плоской структуры используется все тот же протокол LayoutGroup.

Это были последние два обработчика, необходимые для работы нашего примера. Ура!

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

Использование построителей результатов в качестве параметров функций

Отличный способ использовать построитель результата определить его как параметр функции. Так мы действительно получим пользу от нашего кастомного AutolayoutBuilder.

Например, можно добавить такое расширение к NSLayoutConstraint, чтобы немного упростить активацию ограничений:

extension NSLayoutConstraint {     /// Activate the layouts defined in the result builder parameter `constraints`.     static func activate(@AutolayoutBuilder constraints: () -> [NSLayoutConstraint]) {         activate(constraints())     } 

Применяться расширение будет вот так:

NSLayoutConstraint.activate {     // Single constraint     swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)          label.constraintsForAnchoringTo(boundsOf: view) // Returns an array          // Unwrapping an optional     if let fixedLogoSize = fixedLogoSize {         swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width)         swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)     }          // Conditional check     if alignLogoTop {         // Handle either the first component:         swiftLeeLogo.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)     } else {         // Or the second component:         swiftLeeLogo.centerYAnchor.constraint(equalTo: view.centerYAnchor)     } } 

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

 protocol SubviewContaining { } extension UIView: SubviewContaining { } extension SubviewContaining where Self == UIView {          /// Add a child subview and directly activate the given constraints.     func addSubview<View: UIView>(_ view: View, @AutolayoutBuilder constraints: (Self, View) -> [NSLayoutConstraint]) {         addSubview(view)         NSLayoutConstraint.activate(constraints(self, view))     } } 

Это можно использовать следующим образом:

 let containerView = UIView() containerView.addSubview(label) { containerView, label in          if label.numberOfLines == 1 {         // Conditional constraints     }          // Or just use an array:     label.constraintsForAnchoringTo(boundsOf: containerView)      } 

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

Как разработать собственное решение с построителем результата?

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

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

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

Наконец, я хотел бы сослаться на репозиторий с примерами построителей функций (которые теперь называются построителями результатов).

Заключение

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


Узнать подробнее о курсе "iOS Developer. Professional"

Смотреть вебинар Machine Learning в iOS с помощью CoreML и CreateML: изображения, текст, звук

Подробнее..

Пошаговый урок как начать делать что угодно на Touch Bar

10.05.2021 18:20:09 | Автор: admin

Я не Swift разработчик, и даже не objc. У меня просто был и есть Mac с навороченной вставкой в виде Touch Bar для которой захотелось сделать кастомизацию.

Эта статья покажет всем не iOS разработчикам как можно НАЧАТЬ создавать простые приложухи (развлекательного или полезного характера) для Touch Bar с самых первых этапах.

Притупим к делу :

  1. Открываем xCode > Create a new project -> App

    Пример заполнения формы. Важно выбрать Storyboard, так как работать мы будем именно с ней.Пример заполнения формы. Важно выбрать Storyboard, так как работать мы будем именно с ней.

    Существует несколько путей создания своего приложения для ios, один из них визуальное програмирования с использованием Storyboard, это когда вы не пишете условно говоря :

    view.attend(newElement(slider))

    А просто добавляете этот самый newElement(slider) на ваш Storyboard и потом программируете viewController используя обьект.

    Заканчиваем создание проекта и видим такую структуру :

    Такая структураТакая структура
  2. Переходим в Main.storyboard и видим развертку нашего приложения и что с чем коммуницирует, наблюдаем и точку входа. Но нам это сейчас неважно, кликаем на Windows окошко.

    Окошко Окошко
  3. Добавляем новый элемент в правом верхнем углу xCode есть жирний плюсик, вот туда тыкаем и ищем "NsTouchBar" и добавляем этот перетягиванием на Window. Теперь у нашего приложения есть свой Touch Bar.

    Если мы прямо сейчас запустим приложение, пройдёт время компиляции и мы увидим пустой Touch Bar.

    Тут важно что элементы справа, так же называемые Control Strip, будут присутствовать так как это часть private api macOs, что бы её менять нужно применять reverse engineering, на этом туториале такой задачи не стоит.

  4. Что бы добавить элементы на наш тачбар пишем "NsTouchBar Button" и Enter, нам нужно две кнопки. На одну мы повесим вывод изображения, другая будет триггером.
    Поэтому следющим этапом берем обычный ImageView и перетаскиваем прямо на одну из кнопок.

    На этом шаге такой результат должен получится :

    Our storyboard, yolo!Our storyboard, yolo!
  5. Далее нужно создать класс WindowController и добавить его к обработчику Storyboard.
    Нажимаем в строке меню, File -> New -> File -> Cocoa Class

    Создание обработчика. Важно указать в качестве Subclass : NsWindowController!Создание обработчика. Важно указать в качестве Subclass : NsWindowController!

    Что такое Cocoa, грубо говоря либа для разработки, которая по моим скромным иследованием не очень просто дружит с UISwift, ещё одним мощным иструментом разработки, который в свою очередь противоположен Storyboard.

  6. Далее нужно подключить наш обработчик, к обработчику Storyboard :

    1. Открываем Storyboard

    2. Выбираем Window Controller Scene в меню сцен Storyboard > Window Controller

    3. Открываем Inspectors > Identity Inspector

    4. Выбираем наш WindowController в качестве Custom class.

  7. Добавляем элементы Touchbar на WindowController

    1. Используя Add Editor on <<side>>, открываем два редактора, в одном обработчик, в другом Storyboard.

    2. С зажатой клавишей Control, перетаскиваем Button как показано на рисунке. И добавляем имя переменной (какое хотите).

      Вот такая паутина спайдермена пуляете её прямо в код!Вот такая паутина спайдермена пуляете её прямо в код!
    3. Так же делаем с View, важно перетаскивает не Touch Bar View айтем, а то что находится вложенным в View Controller.

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

Отобразим через Window -> Touch Bar -> Touch bar (2nd generation) сам тачбар, что бы я мог показать результат, например ><&

Вся работа будет с файлом `WindowController.swift`, кто ещё помнит, этот тот контроллер, который мы подключили к Storyboard.

Названия и вид переменных :

    @IBOutlet weak var switcher: NSButton!    @IBOutlet weak var image_view: NSImageView!

Допишем вызов функции установки изображения:

   override func windowDidLoad() {            super.windowDidLoad()            switcher.action = #selector(self.set_image)        }

и допишем саму фунцию :

        @objc func set_image(){          //Ссылаемся на наш Bundle            let app_bundle = Bundle.main//Если анимации нету, то нету и изображения            if (!image_view.animates) {//Берём путь относительно нашего Bundle, файл называется nyan.gif            let path = app_bundle.path(forResource: "nyan", ofType: "gif")!              // Добавляем файл в качестве изображения к кнопке с image view                image_view.image = NSImage.init(byReferencingFile: path)// Говорим что она движется                image_view.animates = true              // Меняем названия кнопки-триггера, что бы она показывала действия спрятать изображение                 switcher.title = "de-nyanifaction"            } else {              // Точно так же если изображение спрятано меняем title кнопки                switcher.title = "nyanifaction"              // Тот самый момент, что, если нету анимации то нету изображения                image_view.animates = false              // Убираем изображение                image_view.image = nil;            }        }

Весь файл (обработчика) целиком :

////  WindowController.swift//  nyan_warrior////  Created by nanallew on 05.05.2021.//import Cocoaclass WindowController: NSWindowController {    @IBOutlet weak var switcher: NSButton!    @IBOutlet weak var image_view: NSImageView!            @objc func set_image(){            let app_bundle = Bundle.main            if (!image_view.animates) {            let path = app_bundle.path(forResource: "nyan", ofType: "gif")!                image_view.image = NSImage.init(byReferencingFile: path)                image_view.animates = true                switcher.title = "de-nyanifaction"            } else {                switcher.title = "nyanifaction"                image_view.animates = false                image_view.image = nil;            }        }        override func windowDidLoad() {            super.windowDidLoad()            switcher.action = #selector(self.set_image)        }    }

Запускаем приложения и смотрим на бар :

Какая полезная фичаКакая полезная фича

На этом всё друзья, спасибо за внимание.

Github rep. as a source.

Подробнее..

Перевод Запускаем модель машинного обучения на iPhone

18.04.2021 18:19:42 | Автор: admin

Чего уж только на Хабре не было, и DOOM на осциллографе, тесте на беременности и калькуляторе запускали, даже сервер Minecraftна зеркалке Canon 200D поднимали. Сегодня же, специально к старту нового потока курса по Machine Learning и углубленного Machine Learning и Deep Learning, попробуем описать кратчайший путь от обучения модели машинного обучения на Python до доказательства концепции iOS-приложения, которое можно развернуть на iPhone. Цель статьи дать базовый скаффолдинг, оставляя место для дальнейшей настройки, подходящей для конкретного случая использования.


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

Шаг 1. Настройка среды

Во-первых, давайте создадим виртуальную среду Python под названием .core_ml_demo, а затем установим необходимые библиотеки: pandas, scikit-learn и coremltools. Чтобы создать виртуальную среду, выполните в терминале эти команды:

python3 -m venv ~/.core_ml_demosource  ~/.core_ml_demo/bin/activatepython3 -m pip install \pandas==1.1.1 \scikit-learn==0.19.2 \coremltools==4.0

Далее установим Xcode. XCode это инструментарий разработки для продуктов Apple. Обратите внимание, что Xcode довольно большой (больше 10 ГБ). Я бы порекомендовал выпить чашку кофе или запустить установку на ночь.

Примечание: в этом туториале используется Xcode 12.3 (12C33) на MacOS Catalina 10.15.5.

Шаг 2. Обучение модели

Мы будем использовать набор данных Boston Housing Price от scikit-learn для обучения модели линейной регрессии и прогнозирования цен на жильё на основе свойств и социально-экономических атрибутов.

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

import pandas as pdfrom sklearn.linear_model import LinearRegressionfrom sklearn.datasets import load_boston# Load databoston = load_boston()boston_df = pd.DataFrame(boston["data"])boston_df.columns = boston["feature_names"]boston_df["PRICE"]= boston["target"]y = boston_df["PRICE"]X = boston_df.loc[:,["RM", "AGE", "PTRATIO"]]# Train a modellm = LinearRegression()lm.fit(X, y)

Шаг 3. Преобразование модели в Core ML

Apple предоставляет два способа разработки моделей для iOS. Первый, Create ML, позволяет создавать модели полностью в рамках экосистемы Apple. Второй, Core ML, позволяет интегрировать модели от третьих лиц в платформу Apple, преобразовав их в формат Core ML. Поскольку мы заинтересованы в запуске обученной модели на iOS, воспользуемся вторым способом.

Перед импортом в Xcode мы преобразуем нашу модель sklearn в формат Core ML (.mlmodel) с помощью пакета python coremltool; coremltools позволяет назначать метаданные объекту модели, такие как информация об авторстве, описание функций модели и результатов.

# Convert sklearn model to CoreMLimport coremltools as cmlmodel = cml.converters.sklearn. \convert(lm,        ["RM", "AGE", "PTRATIO"],        "PRICE")# Assign model metadatamodel.author = "Medium Author"model.license = "MIT"model.short_description = \"Predicts house price in Boston"# Assign feature descriptionsmodel.input_description["RM"] = \"Number of bedrooms"model.input_description["AGE"] = \"Proportion of units built pre 1940"model.input_description["PTRATIO"] = \"Pupil-teacher ratio by town"# Assign the output descriptionmodel.output_description["PRICE"] = \"Median Value in 1k (USD)"# Save modelmodel.save('bhousing.mlmodel')

Шаг 4. Создание нового проекта в Xcode

С Python мы закончили. Теперь можно завершить прототип приложения при помощи только Xcode и Swift. Это можно сделать так:

  1. Откройте Xcode и создайте новый проект.

  2. Выберите iOS как тип мультиплатформы.

  3. Выберите тип приложения App.

Создание нового проекта Xcode для iOSСоздание нового проекта Xcode для iOS

4. Дайте проекту название и выберите интерфейс SwiftUI.

Конфигурация проектаКонфигурация проекта

Теперь просто перетащите созданный на третьем шаге файл модели .ml в каталог Xcode. Xcode автоматически сгенерирует класс Swift для вашей модели, как показано в редакторе ниже. Если вы посмотрите на класс, то заметите, что он содержит детали, которые мы ввели при сохранении нашей модели на Python с помощью coremltools, такие как описания объектов и целевых полей. Это удобно при управлении моделью.

Импорт файла .coreml в проект XcodeИмпорт файла .coreml в проект Xcode

Шаг 5. Создание UI модели

Далее создадим базовый пользовательский интерфейс, изменив файл contentView.swift. Приведённый ниже код на Swift отображает пользовательский интерфейс, который позволяет пользователям настраивать атрибуты дома, а затем прогнозировать его цену. Есть несколько элементов, которые мы можем здесь рассмотреть.

NavigationView содержит необходимый пользовательский интерфейс. Он включает:

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

  • Кнопку на панели навигации (строки 3140) для вызова нашей модели из функции predictPrice (строка 46). На экране появится предупреждающее сообщение с прогнозируемой ценой.

За пределами NavigationView у нас есть функция predictPrice (строки 4662). Она создаёт экземпляр класса Swift Core ML model и генерирует прогноз в соответствии с хранящимися в состояниях объектов значениями.

import SwiftUIimport CoreMLimport Foundationstruct ContentView: View {  @State private var rm = 6.5  @State private var age = 84.0  @State private var ptratio = 16.5      @State private var alertTitle = ""  @State private var alertMessage = ""  @State private var showingAlert = false      var body: some View {      NavigationView {        VStack {        Text("House Attributes")            .font(.title)        Stepper(value: $rm, in: 1...10,                step: 0.5) {            Text("Rooms: \(rm)")          }          Stepper(value: $age, in: 1...100,              step: 0.5) {          Text("Age: \(age)")          }          Stepper(value: $ptratio, in: 12...22,              step: 0.5) {          Text("Pupil-teacher ratio: \(ptratio)")          }          .navigationBarTitle("Price Predictor")          .navigationBarItems(trailing:              Button(action: predictPrice) {                  Text("Predict Price")              }          )          .alert(isPresented: $showingAlert) {              Alert(title: Text(alertTitle),                    message: Text(alertMessage),              dismissButton: .default(Text("OK")))          }        }      }  }            func predictPrice() {    let model = bhousing()    do { let p = try      model.prediction(          RM: Double(rm),          AGE: Double(age),          PTRATIO: Double(ptratio))        alertMessage = "$\(String((p.PRICE * 1000)))"      alertTitle = "The predicted price is:"  } catch {    alertTitle = "Error"    alertMessage = "Please retry."  }    showingAlert = true}}struct ContentView_Previews: PreviewProvider {    static var previews: some View {        ContentView()    }}

И, наконец, самое интересное: мы можем создать и запустить симуляцию приложения в Xcode, чтобы увидеть нашу модель в действии. В приведённом ниже примере я создал симуляцию с помощью iPhone 12.

Симуляция модели работает на iOS.Симуляция модели работает на iOS.

Заключение

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

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

Если вы кодите на Python и столкнулись в работе с задачами машинного обучения обратите внимание на наш курс Machine Learning, на котором вы сможете систематизировать и углубить полученные самостоятельно знания, пообщаться с профессионалами и применить модели Machine Learning на практике. Даже можно будет ворваться на хакатоны Kaggle.

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

Узнайте, как прокачаться и в других специальностях или освоить их с нуля:

Другие профессии и курсы
Подробнее..

О том как мы научили машину определять пол человека по его почерку

16.06.2021 16:13:28 | Автор: admin

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

Однако не будем углубляться в историю

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

Для начала кратко разберем понятие почерк:

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

В свою очередь, он имеет следующие основные свойства:

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

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

  3. Временная изменчивость почерка (возможность изменения письменно двигательного функционального динамического комплекса видоизменяться в зависимости от возраста);

  4. Типологическое своеобразие.

Разобрав свойства почерка становится понятно, что учесть всю вариационность отображения письменных знаков невозможно, в следствии воздействия на них многих факторов. Однако мы не учли свойство динамической устойчивости, которое означает способность к сохранению совокупности действий и операций, обозначающих специфическую структуру функционального динамического комплекса, их стандартность, стабильность во времени и по отношению к внешним и внутренним сбивающим факторам. То есть, есть определенный набор признаков, корый устойчив к изменению и несет за собой нужную нам информацию? Да! Как раз то, что нам нужно!

Но как понять устойчив ли тот или иной признак?

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

Но что мы понимаем под понятием признака?

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

  1. Общие (относительное размещение текста, форма линий письма, наклон, разгон, размер и степень связанности почерка, нажим и так далее);

  2. Диагностические. Разделяются на:

    - Признаки снижения координации первой и второй группы (извилистость движений при выполнении и соединении прямолинейных элементов письменных знаков, Несогласованность движений при выполнении и соединении письменных знаков, степень выраженности признаков, извилистая и ступенчатая форма письма, неравномерное направление линии письма и так далее),

    - Признаки замедленного темпа письма (большой или сверхбольшой размер почерка, преобладание усложненных и сложных вариантов букв, наличие тупых начал и окончаний движений и так далее)

    - Специфические диагностические признаки (зеркальность движений, выполнение букв по типу печатных и так далее);

  3. Частные. Делятся на:

    - Сложность движения при выполнении,

    - Форма движений при выполнении,

    - Направление движений при выполнении,

    - Протяженность при выполнении,

    - Количество движений при выполнении

    - Вид движений при выполнении,

    - Последовательность движений при выполнении,

    - Относительное размещения;

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

Узнав какие признаки существуют, нужно выделить устойчивые признаки, которые тем или иным образом могут быть связанны с полом исполнителя рукописи. К счастью мы можем подсмотреть в уже существующую методику дифференциации рукописей на мужские и женские по высоковыработанным почеркам, основанная на вероятностном моделировании (см. Судебно-почерковедческая экспертиза Ч 2, М., ВНИИСЭ, 1971г., с. 223-236) (P. S. это не единственная методика подобного рода). В данной методике изложены 208 признаков почерка с различными коэффициентами. Проще говоря, находим в тексте как можно больше перечисленных в методике признаков, суммируем их коэффициент и получаем определенную величину, по которой мы с определенной долей вероятности можем определить пол исполнителя рукописи.

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

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

Для решения мы будем использовать Keras и CoreML для удобного использования.

Начнем со сбора данных!

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

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

Пробную архитектуру возьмем VGG19, а суммарный объем данных 1400 изображений.

Результатом обучения стала 92% точность определения признака.

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

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

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

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

Рабочая область программного компелкса ФросяРабочая область программного компелкса Фрося

Список источников и литературы

  1. Судебно-почерковедческая экспертиза. Общая часть. Вып. I, II (Методическое пособие для экспертов, следователей, судей), М., ВНИИСЭ, 1988-1989.

  2. Почерковедение и почерковедческая экспертиза. Учебник / под ред. В. В. Серегина. Волгоград: ВА МВД России, 2012.

  3. Судебно-почерковедческая экспертиза. Особенная часть. Исследование рукописных текстов / под ред. В.Ф. Орловой. М., Наука, 2007.

  4. Аверьянова, Т.В. Судебная экспертиза: курс общей теории / Т.В. Аверьянова. М.: Норма, 2006. 479 с.

  5. Кошманов П.М. Компьютерные технологии в судебно-почерковедческой экспертизе: учеб, пособие / П.М. Кошманов. Волгоград: ВА МВД России, 2008. 72 с.: ил.

  6. Бобовкин М. В. Теория и практика судебно-диагностической экспертизы письма лиц, находящихся в психопатологическом состоянии. Диссертация доктора юридических наук. Волгоград, 2005. 466 с.

Подробнее..

Подходы к спискам на UICollectionView

14.04.2021 18:22:15 | Автор: admin

Введение

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

Копились бесчисленные баги и знания об устройстве этого инструмента и о лучших практиках. И когда мы получили очередной infinite scroll дизайн, мы поняли: пришло время задуматься и дать отпор тирании UITableViewDataSource и UITableViewDelegate.

Почему коллекция?

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

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

Так ли страшны коллекции и какие подводные камни они в себе таят? Мы сравнили.

  • Ячейки в таблице содержат лишние элементы: content view, group editing view, slide actions view, accessory view.

  • Использование UICollectionView дает единообразность при работе с любыми списками объектов, так как ее API в целом схож с UITableView.

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

Так же у нас были некоторые опасения:

  • Возможность использовать Pull to refresh

  • Отсутсвие лагов при отрисовке

  • Возможность скролла в ячейках

Но в ходе реализации все они развеялись.

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

Адаптеры

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

final class CurrencyViewController: UIViewController {    var tableView = UITableView()    var items: [ViewModel] = []    func setup() {        tableView.delegate = self        tableView.dataSource = self        tableView.backgroundColor = .white    tableView.rowHeight = 72.0                        tableView.contentInset = .init(top: Constants.topSpacing, left: 0, bottom: Constants.bottomSpacing, right: 0)        tableView.reloadData()    }}extension CurrencyViewController: UITableViewDelegate {    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {        output.didSelectBalance(at: indexPath.row)    }}extension CurrencyViewController: UITableViewDataSource {    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {        items.count    }    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {                let cell = tableView.dequeueReusable(cell: object.cellClass, at: indexPath)        cell.setup(with: object)                return cell    }}extension UITableView {    func dequeueReusable(cell type: UITableViewCell.Type, at indexPath: IndexPath) -> UITableViewCell {        if let cell: UITableViewCell = self.dequeueReusableCell(withIdentifier: type.name()) {            return cell        }        self.register(cell: type)        let cell: UITableViewCell = self.dequeueReusableCell(withIdentifier: type.name(), for: indexPath)        return cell    }    private func register(cell type: UITableViewCell.Type) {        let identifier: String = type.name()                self.register(type, forCellReuseIdentifier: identifier)     }}

Приходят на помощь джедаи адаптеры.

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

Ниже приведен пример такого использования.

private let listAdapter = CurrencyVerticalListAdapter()private let collectionView = UICollectionView(    frame: .zero,    collectionViewLayout: UICollectionViewFlowLayout())private var viewModel: BalancePickerViewModelfunc setup() {    listAdapter.setup(collectionView: collectionView)    collectionView.backgroundColor = .c0    collectionView.contentInset = .init(top: Constants.topSpacing, left: 0, bottom: Constants.bottomSpacing, right: 0)    listAdapter.onSelectItem = output.didSelectBalance    listAdapter.heightMode = .fixed(height: 72.0)    listAdapter.spacing = 8.0    listAdapter.reload(items: viewModel.items)}

Однако внутри адаптер представляет собой даже не один класс.

Рассмотрим для начала базовый (и вообще говоря абстрактный) класс адаптера списков:

public class ListAdapter<Cell> : NSObject, ListAdapterInput, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDragDelegate, UICollectionViewDropDelegate, UIScrollViewDelegate where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.RegistrableView {    public typealias Model = Cell.Model    public typealias ResizeCallback = (_ insertions: [Int], _ removals: [Int], _ skipNext: Bool) -> Void    public typealias SelectionCallback = ((Int) -> Void)?    public typealias ReadyCallback = () -> Void    public enum DragAndDropStyle {        case reorder        case none    }    public var dragAndDropStyle: DragAndDropStyle { get set }    internal var headerModel: ListHeaderView.Model?    public var spacing: CGFloat    public var itemSizeCacher: UICollectionItemSizeCaching?    public var onSelectItem: ((Int) -> Void)?    public var onDeselectItem: ((Int) -> Void)?    public var onWillDisplayCell: ((Cell) -> Void)?    public var onDidEndDisplayingCell: ((Cell) -> Void)?    public var onDidScroll: ((CGPoint) -> Void)?    public var onDidEndDragging: ((CGPoint) -> Void)?    public var onWillBeginDragging: (() -> Void)?    public var onDidEndDecelerating: (() -> Void)?    public var onDidEndScrollingAnimation: (() -> Void)?    public var onReorderIndexes: (((Int, Int)) -> Void)?    public var onWillBeginReorder: ((IndexPath) -> Void)?    public var onReorderEnter: (() -> Void)?    public var onReorderExit: (() -> Void)?    internal func subscribe(_ subscriber: AnyObject, onResize: @escaping ResizeCallback)    internal func unsubscribe(fromResize subscriber: AnyObject)    internal func subscribe(_ subscriber: AnyObject, onReady: @escaping ReadyCallback)    internal func unsubscribe(fromReady subscriber: AnyObject)    internal weak var collectionView: UICollectionView?    public internal(set) var items: [Model] { get set }    public func setup(collectionView: UICollectionView)    public func setHeader(_ model: ListHeaderView.Model)    public subscript(index: Int) -> Model? { get }    public func reload(items: [Model], needsRedraw: Bool = true)    public func insertItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func appendItem(_ item: Model, allowDynamicModification: Bool = true)    public func deleteItem(at index: Int, allowDynamicModification: Bool = true)    public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>)    public func deleteItems(at indexes: [Int], allowDynamicModification: Bool = true)    public func updateItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at range: PartialRangeFrom<Int>, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at indexes: [Int], allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [(index: Int, element: Model)], allowDynamicModification: Bool = true)    public func moveItem(at index: Int, to newIndex: Int)    public func performBatchUpdates(updates: @escaping (ListAdapter) -> Void, completion: ((Bool) -> Void)?)    public func performBatchUpdates(updates: () -> Void, completion: ((Bool) -> Void)?)    }public typealias ListAdapterCellConstraints = UICollectionViewCell & RegistrableView & AnimatedConfigurableViewpublic typealias VerticalListAdapterCellConstraints = ListAdapterCellConstraints & HeightMeasurableViewpublic typealias HorizontalListAdapterCellConstraints = ListAdapterCellConstraints & WidthMeasurableView

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

Как можно увидеть из примера выше: сначала идёт блок typealias'ов для того, чтобы определить ограничения на используемые типы.

DragAndDropStyle отвечает за возможность менять местами ячейки внутри коллекции.

headerModel - модель, которая представляет заголовок коллекции

spacing - расстояние между элементами

Дальше идёт блок замыканий, которые позволяют подписаться на определённые изменения в коллекции.

Методы для подписки onReady и onResize позволяют понять, когда коллекция адаптера стала готова к работе, и когда изменился размер коллекции из-за добавления или удаления объектов, соответственно.

collectionView, setup(collectionView:) - непосредственно используемый экземпляр коллекции и метод для её установки

items - набор моделей для отображения

setHeader - метод для установки заголовка коллекции

itemSizeCacher - класс, реализующий кеширование размеров элементов списка. Дефолтная реализация представлена ниже:

final class DefaultItemSizeCacher: UICollectionItemSizeCaching {        private var sizeCache: [IndexPath: CGSize] = [:]        func itemSize(cachedAt indexPath: IndexPath) -> CGSize? {        sizeCache[indexPath]    }        func cache(itemSize: CGSize, at indexPath: IndexPath) {        sizeCache[indexPath] = itemSize    }        func invalidateItemSizeCache(at indexPath: IndexPath) {        sizeCache[indexPath] = nil    }        func invalidate() {        sizeCache = [:]    }    }

Остальную часть интерфейса представляют методы для обновления элементов.

Также есть конкретные реализации, которые, например, заточены под определенное расположение ячеек по оси.

AnyListAdapter

До тех пор, пока мы работаем с динамическим контентом, все хорошо. Но во введении мы не зря говорили про infinite-scroll дизайн. Что делать, если в таблице нужно одновременно отображать и ячейки динамического контента(данные из сети) и статические вью? Для этого нам послужит AnyListAdapter.

public typealias AnyListSliceAdapter = ListSliceAdapter<AnyListCell>public final class AnyListAdapter : ListAdapter<AnyListCell>, UICollectionViewDelegateFlowLayout {    public var dimensionCalculationMode: DesignKit.AnyListAdapter.DimensionCalculationMode    public let axis: Axis    public init<Cell>(dynamicCellType: Cell.Type) where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.HeightMeasurableView, Cell : DesignKit.RegistrableView    public init<Cell>(dynamicCellType: Cell.Type) where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.RegistrableView, Cell : DesignKit.WidthMeasurableView}public extension AnyListAdapter {    convenience public init<C1, C2>(dynamicCellTypes: (C1.Type, C2.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.HeightMeasurableView, C1 : DesignKit.RegistrableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.HeightMeasurableView, C2 : DesignKit.RegistrableView    convenience public init<C1, C2, C3>(dynamicCellTypes: (C1.Type, C2.Type, C3.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.HeightMeasurableView, C1 : DesignKit.RegistrableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.HeightMeasurableView, C2 : DesignKit.RegistrableView, C3 : UICollectionViewCell, C3 : DesignKit.AnimatedConfigurableView, C3 : DesignKit.HeightMeasurableView, C3 : DesignKit.RegistrableView    convenience public init<C1, C2>(dynamicCellTypes: (C1.Type, C2.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.RegistrableView, C1 : DesignKit.WidthMeasurableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.RegistrableView, C2 : DesignKit.WidthMeasurableView    convenience public init<C1, C2, C3>(dynamicCellTypes: (C1.Type, C2.Type, C3.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.RegistrableView, C1 : DesignKit.WidthMeasurableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.RegistrableView, C2 : DesignKit.WidthMeasurableView, C3 : UICollectionViewCell, C3 : DesignKit.AnimatedConfigurableView, C3 : DesignKit.RegistrableView, C3 : DesignKit.WidthMeasurableView}public extension AnyListAdapter {    public enum Axis {        case horizontal        case vertical    }    public enum DimensionCalculationMode {        case automatic        case fixed(constant: CGFloat? = nil)    }}

Как не трудно догадаться, AnyListAdapter абстрагируется от конкретного типа ячейки. Его можно проинициализировать несколькими типами ячеек, но они все должны быть либо для горизонтального лейаута, либо вертикального. Условием здесь выступает удовлетворение протоколу HeightMeasurableView и WidthMeasurableView.

public protocol HeightMeasurableView where Self: ConfigurableView {    static func calculateHeight(model: Model, width: CGFloat) -> CGFloat    func measureHeight(model: Model, width: CGFloat) -> CGFloat   }public protocol WidthMeasurableView where Self: ConfigurableView {    static func calculateWidth(model: Model, height: CGFloat) -> CGFloat    func measureWidth(model: Model, height: CGFloat) -> CGFloat}

У списка так же фиксируется алгоритм подсчета высоты:

  • фиксированный(константа или статический метод расчета по модели)

  • автоматический (на основе лейаута).

Сила вся внутри ячейки-контейнера AnyListCell спрятана.

public class AnyListCell: ListAdapterCellConstraints {        // MARK: - ConfigurableView        public enum Model {        case `static`(UIView)        case `dynamic`(DynamicModel)    }        public func configure(model: Model, animated: Bool, completion: (() -> Void)?) {        switch model {        case let .static(view):            guard !contentView.subviews.contains(view) else { return }                        clearSubviews()            contentView.addSubview(view)            view.layout {                $0.pin(to: contentView)            }        case let .dynamic(model):            model.configure(cell: self)        }        completion?()    }        // MARK: - RegistrableView        public static var registrationMethod: ViewRegistrationMethod = .class        public override func prepareForReuse() {        super.prepareForReuse()                clearSubviews()    }        private func clearSubviews() {        contentView.subviews.forEach {            $0.removeFromSuperview()        }    }    }

Такая ячейка конфигурируется двумя видами модели: статической и динамической.

Первая как раз отвечает за отображение в списке обычных вью.

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

struct DynamicModel {    public init<Cell>(model: Cell.Model,                    cell: Cell.Type) {            // ...    }    func dequeueReusableCell(from collectionView: UICollectionView, for indexPath: IndexPath) -> UICollectionViewCell    func configure(cell: UICollectionViewCell)    func calcucalteDimension(otherDimension: CGFloat) -> CGFloat    func measureDimension(otherDimension: CGFloat) -> CGFloat}

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

private let listAdapter = AnyListAdapter(    dynamicCellTypes: (CommonCollectionViewCell.self, OperationCell.self))func configureSearchResults(with model: OperationsSearchViewModel) {    var items: [AnyListCell.Model] = []    model.sections.forEach {        let header = VerticalSectionHeaderView().configured(with: $0.header)        items.append(.static(header))        switch $0 {        case .tags(nil), .operations(nil):            items.append(                .static(OperationsNoResultsView().configured(with: Localisation.feed_search_no_results))            )        case let .tags(models?):            items.append(                contentsOf: models.map {                    .dynamic(.init(                        model: $0,                        cell: CommonCollectionViewCell.self                    ))                }            )        case .operations(let models?):            items.append(                contentsOf: models.map {                    .dynamic(.init(                        model: $0,                        cell: OperationCell.self                    ))                }            )        }    }    UIView.performWithoutAnimation {        listAdapter.deleteItemsIfNeeded(at: 0...)        listAdapter.reloadItems(items, at: 0...)    }}

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

Список по кускам

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

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

Для того, чтобы обезопасить себя при работе с вставкой/удалением/обновлением элементов, мы используем концепцию слайсов по аналогии с ArraySlice, представленным в стандартной библиотеке языка Swift.

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

Приведем пример сложного экрана.

let subjectsSectionHeader = SectionHeaderView(title: "Subjects")let pocketsSectionHeader = SectionHeaderView(title: "Pockets")let cardsSectionHeader = SectionHeaderView(title: "Cards")let categoriesHeader = SectionHeaderView(title: "Categories")let list = AnyListAdapter()listAdapter.reloadItems([    .static(subjectsSectionHeader),    .static(pocketsSectionHeader)    .static(cardsSectionHeader),    .static(categoriesHeader)])

Теперь распределим эти секции по контроллерам. Для простоты рассмотрим лишь один, так как остальные будут похожими на него.

class PocketsViewController: UIViewController {    var listAdapter: AnyListSliceAdapter! {        didSet {reload()        }    }    var pocketsService = PocketsService()    func reload() {        pocketsService.fetch { pockets, error in            guard let pocket = pockets else { return }            listAdapter.reloadItems(                pockets.map { .dynamic(.init(model: $0, cell: PocketCell.self)) },                at: 1...            )        }    }    func didTapRemoveButton(at index: Int) {listAdapter.deleteItemsIfNeeded(at: index)    }}let subjectsVC = PocketsViewController()subjectsVC.listAdapter = list[1..<2]

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

public extension ListAdapter {    subscript(range: Range<Int>) -> ListSliceAdapter<Cell> {        .init(listAdapter: self, range: range)    }    init(listAdapter: ListAdapter<Cell>,               range: Range<Int>) {        self.listAdapter = listAdapter        self.sliceRange = range        let updateSliceRange: ([Int], [Int], Bool) -> Void = { [unowned self] insertions, removals, skipNextResize in            self.handleParentListChanges(insertions: insertions, removals: removals)            self.skipNextResize = skipNextResize        }        let enableWorkingWithSlice = { [weak self] in            self?.onReady?()            return        }        listAdapter.subscribe(self, onResize: updateSliceRange)        listAdapter.subscribe(self, onReady: enableWorkingWithSlice)    }}

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

Кроме данных о рендже слайса, интерфейс слайс адаптера мало чем отличается от оригинального ListAdapter.

public final class ListSliceAdapter<Cell> : ListAdapterInput where Cell : UICollectionViewCell, Cell : ConfigurableView, Cell : RegistrableView {    public var items: [Model] { get }    public var onReady: (() -> Void)?    internal private(set) var sliceRange: Range<Int> { get set }    internal init(listAdapter: ListAdapter<Cell>, range: Range<Int>)    convenience internal init(listAdapter: ListAdapter<Cell>, index: Int)    public subscript(index: Int) -> Model? { get }    public func reload(items: [Model], needsRedraw: Bool = true)    public func insertItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func appendItem(_ item: Model, allowDynamicModification: Bool = true)    public func deleteItem(at index: Int, allowDynamicModification: Bool = true)    public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>)    public func deleteItems(at indexes: [Int], allowDynamicModification: Bool = true)    public func updateItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at range: PartialRangeFrom<Int>, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at indexes: [Int], allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [(index: Int, element: Model)], allowDynamicModification: Bool = true)    public func moveItem(at index: Int, to newIndex: Int)    public func performBatchUpdates(updates: () -> Void, completion: ((Bool) -> Void)?)}

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

public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>) {    guard canDelete(index: range.lowerBound) else { return }    let start = globalIndex(of: range.lowerBound)    let end = sliceRange.upperBound - 1    listAdapter.deleteItems(at: Array(start...end))}

При этом ключевую роль играет поддержка кусков внутри самого ListAdapter.

public class ListAdapter {    // ...    var resizeSubscribers = NSMapTable<AnyObject, NSObjectWrapper<ResizeCallback>>.weakToStrongObjects()}extension ListAdapter {public func appendItem(_ item: Model) {        let index = items.count               let changes = {            self.items.append(item)            self.handleSizeChange(insert: self.items.endIndex)            self.collectionView?.insertItems(at: [IndexPath(item: index, section: 0)])        }                if #available(iOS 13, *) {            changes()        } else {            performBatchUpdates(updates: changes, completion: nil)        }    }    func handleSizeChange(removal index: Int) {        notifyAboutResize(removals: [index])    }    func handleSizeChange(insert index: Int) {        notifyAboutResize(insertions: [index])    }    func notifyAboutResize(insertions: [Int] = [], removals: [Int] = [], skipNextResize: Bool = false) {        resizeSubscribers            .objectEnumerator()?            .allObjects            .forEach {                ($0 as? NSObjectWrapper<ResizeCallback>)?.object(insertions, removals, skipNextResize)            }    }    func shiftSubscribers(after index: Int, by shiftCount: Int) {        guard shiftCount > 0 else { return }        notifyAboutResize(            insertions: Array(repeating: index, count: shiftCount),            skipNextResize: true        )    }}

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

Выводы

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

И, что для ленивых самое важное - сетап экрана со списком занимает меньше 10 строк кода.

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

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

Подробнее..

Вас заметили! 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? Буду рад вашим комментариям и отзывам!

Подробнее..

Видео-стриминг на iOS

10.05.2021 22:07:35 | Автор: admin

Интро

Недавно получил интересную задачу в работу, сделать приложение для видео-стриминга, это для стартапа ShopStory (ecomm live streaming). Первую версию приложения реализовал используя Open Source библиотеку для стриминга по RTMP HaishinKit. А вторую версию на Larix SDK. В этой статье разберу какие проблемы возникали в процессе.

О проекте

СервисShopStory.live- одна из первых в России B2B платформ для запуска продаж через live видеоэфиры на сайте e-commerce, которая сотрудничает с крупными ритейлерами, организуя стримы и предоставляя партнёрам удобные инструменты для привлечения новых покупателей и увеличения продаж. Платформа позволяет вести прямые эфиры на сайте клиента в удобном интерфейсе, что позволяет зрителям, находясь дома, быть ближе к товару, которые подробно презентует инфлюенсер. Клиентами ShopStory.liveстановятся известные бренды декоративной и уходовой косметики, маркетплейсы и небольшие компании, которые в основном представляют beauty-сегмент и хотят быть в тренде и использовать формат live commerce в своих маркетинговых стратегиях.

Требования

До разработки своего приложения в ShopStory, стримеры использовали LarixBroadcaster для проведения стримов, это бесплатное приложение для Android и iOS. Но такое решение имеет свои минусы:

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

  2. Что бы смотреть чат, нужно рядом держать ноутбук, и с него читать что пишут зрители.

  3. Сложности с проведением тестовых эфиров.

  4. Куча настроек в приложении, в которых стримеру сложно и лень разбираться (битрейт, фпс, кодеки).

Что мы хотим от нашего приложения:

  • Видеть список запланированных стримов

  • Подготовка к проведению стрима (проверить камеру, микрофон)

  • ABR - Adaptive BitRate (при плохом соединении уметь снижать качество)

  • Готовые настройки битрейта, fps, энкодинга и т.п.

  • Простота для стримера при запуске.

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

  • LFLiveKit 4.2k звезд на гитхабе, последний коммит в 2016г. 115 issue, которые не решаются.

  • HaishinKit 2.1k звезд на гитхабе, на момент написания последний коммит 7 мая. 11 issues.

  • VideoCore 1.5k звезд на гитхабе, последний коммит 2015г. Не поддерживается.

  • KSY Live iOS SDK 0.8k звезд на гитхабе, последний коммит 22 марта 2020. Весь README на китайском.

Остановились на внедрении HaishinKit. Если у вас есть на примете хорошие варианты, велком в комментарии, поделитесь какие там есть плюсы и минусы.

HaishinKit

Понятная документация, внедрение супер простое. Данная библиотека забирает на себя максимум. Разработчику не нужно заботиться о работе с камерой/микрофоном, эта либа делает всё за тебя. Никаких AVCaptureSession, AVCaptureDevice, AVCaptureDeviceInput и тому подобное. Просто создаем View, делаем attach к RTMPStream.

Накидаем протокол:

protocol BroadcastService: AnyObject {    func connect()    func publish()    func stop()}

Из документации берем примеры конфигурации и реализуем нужный нам класс.

class HaishinBroadcastService: BroadcastService {}

ABR - Adaptive BitRate

При хорошем соединении передает более высокое качество видео, когда интернет на телефоне начинает болеть, то понижаем качество (битрейт).

Для реализации ABR, берем пример из issue. Имплементим протокол RTMPStreamDelegate.

extension HaishinBroadcastService: RTMPStreamDelegate {    func rtmpStream(_ stream: RTMPStream, didPublishInsufficientBW connection: RTMPConnection) {        guard self.config.adaptiveBitrate else { return }        guard let bitrate = self.currentBitrate else {            assertionFailure()            return        }        let newBitrate = max(UInt32(Double(bitrate) * Constants.bitrateDown), Constants.minBitrate)        self.rtmpStream.videoSettings[.bitrate] = newBitrate    }    func rtmpStream(_ stream: RTMPStream, didPublishSufficientBW connection: RTMPConnection) {        guard self.config.adaptiveBitrate else { return }        guard let currentBitrate = self.currentBitrate,              currentBitrate < Constants.maxBitrate else {            return        }        guard self.bitrateRetryCounter >= Constants.retrySecBeforeUpBitrate else {            self.bitrateRetryCounter += 1            return        }        self.bitrateRetryCounter = 0        let newBitrate = min(Constants.maxBitrate, UInt32(Double(currentBitrate) * Constants.bitrateUp))        if newBitrate == currentBitrate { return }        self.rtmpStream.videoSettings[.bitrate] = newBitrate    }}private struct Constants {    static let bitrateDown: Double = 0.75    static let bitrateUp: Double = 1.15    static let retrySecBeforeUpBitrate = 20}

В отличии от варианта из issue опускаем битрейт постепенно (там просто сразу в 2 раза уменьшается), поднимаем тоже битрейт постепенно. Метод didPublishInsufficientBW вызывается в случаях, когда библиотека не может отправить все фреймы стрима.

Опытным путём остановились на таких константах:

  • если либа не может отправить все фреймы, мы снижаем битрейт умножая текущий на 0.75

  • если успешно отправились фреймы, то через 20 сек (эти методы делегата работают по таймеру в самой либе), пытаемся поднять битрейт умножая на 1.15

Live update resolution

Так же при падении качества соединения на телефоне стримера, сделали попытку изменения разрешения стрима, но это не увенчалось успехом. RTMP не поддерживает изменение разрешения на лету. Посмотрели как сделано в VK Live и там они разрывают соединение при изменении разрешения. В Instagram смогли это реализовать, вероятно есть разные rtmp ссылки, для разного качества и при снижении скорости интернета, начинается стрим в другую ссылку, а бэкенд уже это склеивает и обрабатывает (это лишь догадки, глубокого исследования не проводили). В ShopStory возможно реализуем позже.

Графики

После проведения ряда стримов периодически наблюдаем странные падения. Это происходит как на Wi-Fi, так и на LTE. Решили пробовать платное решение Larix SDK. Потому что при использовании LarixBroadcaster подобное не происходило.

Larix SDK

При покупке тебе предоставляют архив с исходниками LarixBroadcaster + LarixDemo (упрощенный вариант), общую диаграмму архитектуры и описание компонентов, StepByStepGuide.

Плюсы:

  • Широко используется в крупных компаниях, и в некотором роде стандарт для стриминга

  • Есть живая русскоговорящая тех. поддержка с экспертизой в стриминге

Минусы:

  • платное

  • документация очень скудная, если хочешь что-то сделать изучай код LarixBroadcaster (я люблю почитать исходники, но не в этом случае: over 2000 строк на файл)

  • нет дисконнекта когда теряется соединение с интернетом

  • нет отличий в connect и publish

Изучай код если хочешь что-то сделать

Оооо это отдельная боль, в LarixBroadcaster пришлось изучать ViewController на 2100 строк, и еще один важный класс Streamer на 1100 строк. Не ожидал я такого от платной SDK. Ок Для меня было загадкой, почему они не добавили это всё в кишки библиотеки. Получил комментарий от @Aquary (приглашаю в комментарии):

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

На мой взгляд могли бы, для этого дать из SDK понятный интерфейс и закрыть это всё протоколами для возможности расширения и своих кастомизаций. Здесь же бери исходники, вытаскивай нужное и тащи к себе в проект. Таким образом для переезда c HaishinKit нужно писать код для работы с камерой, микрофоном и т.д. (ранее это было всё скрыто в HaishinKit).

Такая же проблема и с ABR, я ожидал (ваши ожидания ваша проблема), что это будет встроено в либу, и просто задав один параметр можешь включить адаптивный битрейт. Но это не так. В LarixBroadcaster есть просто 3 класса StreamConditionerMode1, 2, 3, которые реализуют логику. Хочешь себе в проект ABR? Тащи еще к себе в проект эти классы или пиши свою реализацию ABR на основе этих исходников (это и плюс и минус).

Нет дисконнекта

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

func connectionStateDidChangeId(_ connectionID: Int32, state: ConnectionState, status: ConnectionStatus, info: [AnyHashable: Any]) {}

В Larix просто будет копиться буффер фреймов для отправки.
Решение: из класса SDK StreamerEngineProxy можем получить bytesSent и bytesDelivered, на основе этих двух методов, можно решать делать реконнект или нет. Если видим, что уже собирается большой буфер, то делаем принудительный дисконнект.

Connect и Publish

По спецификации RTMP, есть отдельные команды publish и connect, в Larix я не нашел (а может этого и нет), как отдельно вызывать эти команды. Из-за этого наш протокол BroadcastService теперь имеет изъяны.

Для чего нам такая возможность?

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

  • Для нормальной записи стрима. Всё, что записывается после вызова publish, попадет в конечную запись, и нужно будет вырезать, например, начало (подготовку стримера). Если делать publish только после того как стример готов (и поправил свою прическу). То постобработка не нужна.

Графики

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

Вывод

Выбор бесплатной библиотеки для стриминга на iOS не очень большой и по сути всё сводится к одному варианту HaishinKit. У него есть несомненное преимущество открытый исходный код и если с Larix не удастся выровнять графики и повысить стабильность, будем погружаться в open source и искать места, которые можно улучшать.

Покупая платную SDK не ожидай что она решит все твои проблемы, возможно у тебя их станет больше (изучать vc на over 2000 строк).

А какие-то более глобальные выводы можно будет сделать только после того, как обкатаем сборку на большем количестве стримов.

ShopStory.live активно развивается, расширяет сферу своей деятельности и планирует выполнять ещё больше задач в работе с клиентами. С каждым новым партнёром и очередным стримом платформа добавляет плюс к своей экспертности в live commerce. Именно поэтому платформа ShopStory.live ищет разработчиков! Если ты готов стать частью команды, и у тебя есть идеи, которые помогут сервису стать лучше, приглашает тебя к сотрудничеству!

Подробнее..

Apple убивает TeamCity, Bitrise, Appcenter, Fastlane, Firebase, Sentry и иже с ними. Краткий обзор Xcode Cloud

10.06.2021 14:04:32 | Автор: admin

Заголовок конечно громковат, может не убивает, но уменьшит им доходы точно. Давайте кратко посмотрим что представила Apple на WWDC 2021, что такое Xcode Cloud?

Xcode Cloud - это сервис CI/CD, встроенный в Xcode и разработанный специально для разработчиков Apple. Он ускоряет разработку и доставку приложений, объединяя облачные инструменты, которые помогают создавать приложения, параллельно запускать автоматические тесты, доставлять приложения тестировщикам, а также просматривать отзывы пользователей и управлять ими.

Цикл разработки по мнению Apple заключается в этапах 1) Написать код 2) протестировать его 3) Интегрировать (в текущий) 4) Доставить до пользователя 5) Улучшить, и по новой. На то он и цикл. В принципе похоже на правду, так оно и есть.

Если вы хоть раз настраивали CI/CD для iOS приложений, вы знаете примерно какие там шаги, ничего сложного, но это может включать в себя использование нескольких сервисов, генерации сертификатов и тд и тп.

Теперь же Apple предлагает нам сделать это все не выходя из Xcode, давайте взглянем на процесс.

Для начала нам нужно настроить первый workflow, а потом уже который будет пробегать при PR/MR (pull request/merge request) на main/develop ветку в системе контроля версий.

I CI/CD

1) Жмем в новом Xcode при подключенном сервисе Xcode Cloud кнопку "создать workflow" и видим настройки

Name - название воркфлоу, Start condition когда запускать воркфлоу (например при изменении в main ветке), Environment - можно выбрать стабильную версию Xcode или новую бета версию, Actions - что собственно надо сделать, обычно выполнить archive и опубликовать например в TestFlight, после прогонки тестов, Post-Actions - что сделать после того как воркфлоу пройден, например написать в slack/telegram канал об этом событии

2) Выбираем репозиторий где хранится наш код

3) Выбираем с какой ветки собрать билд (при первой настройке)

Собственно готово, теперь можем посмотреть как выглядит в Xcode место где можно управлять сборками, запускать их, пересобирать и тд

Давайте теперь посмотрим как выглядит управлени воркфлоу (выше показан путь настройки первой сборки)

1) Выбираем "управление воркфлоу"

2) Выбираем настройки (например при pull/merge request что-то выполнять)

3) Выбираем какие тесты мы хотим прогнать в воркфлоу (UI или Unit тесты), я так понимаю речь именно про нативные тесты, про Appium и тд, пока ничего не известно.

4) И выбираем отправить сообщение в Slack после того как воркфлоу пройден

5) Готово

II Тесты

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

2) Посмотрим какие конкретно тесты прогнались на iPad Air, видим что тест кейс с Light mode, портретный режим, с Английским языком, далее видим какие конкретно тесты пройдены

3) Ну и совсем чудеса, можно смотреть скриншоты пройденных тестов

4) Можно также посмотреть какой тест упал, можно также пометить тест как Flaky (Флаки тест или другими словами тест неактуальный, который надо либо удалить либо переписать), для этого используется XCTExpectFailure (что в переводе логично видно по названию метода ожидаемый фейл)

Удобно.

III - Работа с системой контроля версий (и переписка прямо в коде в Xcode)

1) Изменения теперь видно еще нагляднее (привет всем кто пользуется визуальными штуками, а не через консоль при работе с git). Сверху мы видим наши локальные изменения (которые мы накодили) а снизу "висящие" pr/mr реквесты, которые можно посмотреть, и дать свой комментарий или approve (одобрение на слияние кода)

2) Даже видно какой тест план для этой фичи, которая просится в главную ветку

3) Переписка,комменты прямо в Xcode при pr/mr (а не на веб мордах gitlab/github/bitbucket и тд)

В общем очень круто и удобно

IV - Улучшения (Crashes/Сбои/Ошибки)

1) Все краши/сбои теперь видно прямо в Xcode (а не в веб морде Firebase или Sentry), код приходит сразу символизированный (symbolized log), то есть человекопонятный с указанием что и как произошло

2) А тестер (возможно и пользователь) может оставить комментарий при краше который вы сможете прочитать (и даже ответить!)

3) Ну и самое интересное вы сможете кликнуть открыть место краша в проекте

4) И вас за ручку проведут к вашему куску кода который натворил зло

Послесловие

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

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

Можете подать заявку на бета-тест здесь https://developer.apple.com/xcode-cloud/

Сколько он будет стоить пока тоже неизвестно.

И пока непонятно что с Android потому что обычно сервисы CI/CD используют сразу для двух платформ, так как приложения обычно тоже для двух платформ разрабатывают. Но может быть когда нибудь приложения для Android можно будет писать и в Xcode))

Изображение и информацию брал из видеосессий WWDC 2021, кому интересно как это выглядит вот видео про Xcode Cloud https://developer.apple.com/videos/play/wwdc2021/102/

Подробнее..

Дайджест интересных материалов для мобильного разработчика 398 (14 20 июня)

20.06.2021 12:09:43 | Автор: admin
В этой подборке исследуем StoreKit 2, распознаем лица и позы на Android, улучшаем производительность React-приложений, учим сквирклморфизм и многое другое!



Этот дайджест доступен в виде еженедельной рассылки. А ежедневно новости мы рассылаем в Telegram-канале.

iOS

За что App Store может отклонить приложение: чек-лист
Meet StoreKit 2
Тим Кук: на Android в 47 раз больше вредоносных программ, чем на iOS
Новый антимонопольный акт может заставить Apple продать App Store
Что нового во встроенных покупках в iOS 15 WWDC 21
Строим лабиринты с SwiftUI
iOS 15: заметные дополнения к UIKit
Info.plist отсутствует в Xcode 13 вот как его вернуть
ScrollView в XCode 11
Создаем игры на SwiftUI с помощью SpriteKit
Мастерим списки в SwiftUI
Как лучше структурировать свои проекты в Xcode
Глубокое погружение в Акторы в Swift 5.5
Разработка функций iOS-приложения в виде модулей в Xcode
Как делать видеозвонки с помощью SwiftUI
Euler: вычислительный фреймворк на Swift
WorldMotion: положение устройства относительно Земли

Android

Как использовать Android Data Binding в пользовательских представлениях?
AppSearch из Jetpack вышел в альфа-версии
Распознавание лиц и поз за 40 минут
Android Broadcast: новости #10
Создайте свою библиотеку KMM
История моего первого а-ха-момента с Jetpack Compose
Как стать ассоциированным разработчиком Android (Kotlin Edition)
Анимации Jetpack Compose в реальном времени
RecyclerView с NestedScrollView: лучшие практики
Android Bitbucket Pipeline CI/CD с Firebase App Distribution
CompileSdkVersion и targetSdkVersion в чем отличие?
Нижняя панель навигации Android с Jetpack Compose
Интеграция Google Sign-in в Android-приложение
Focus в Jetpack Compose
DashedView: полосатые View
Screen Tracker: название видимого Activity/Fragment
SquircleView: красивые View

Разработка

5 000 000 строк кода, 500 репозиториев: зачем мы адаптировали приложение AliExpress для Рунета
Десятикратное улучшение производительности React-приложения
gRPC + Dart, Сервис + Клиент, напишем
Podlodka #220: волонтерство в IT
Хороший день разработчика: Good Day Project от GitHub
К 2024 году 80% технологических продуктов будут создавать непрофессионалы
Сквирклморфизм (Squirclemorphism) в дизайне интерфейсов
12 рекомендаций, которые помогут улучшить процесс регистрации и входа в систему
React Native в Wix Архитектура (глубокое погружение)
Как узнать плохой код? 8 вещей
5 лучших пакетов Flutter, которые вы должны знать
Советы по кодинг интервью в Google
Как стать плохим разработчиком

Аналитика, маркетинг и монетизация

Гайд по тестированию рекламы для мобильных приложений
Вслед за Apple и Google комиссию магазина приложений снизила Amazon
make sense: О инфлюенсер-маркетинге
UserLeap получает еще $38 млн на отслеживание пользовательского опыта
Классическая MMORPG RuneScape запускается на iOS и Android
Маркетологи в мобайле: Александр Плёнкин (Vprok.ru Перекрёсток)
Почему такие скриншоты пустая трата времени? (пока у вас нет 4,000 загрузок в месяц)
Amplitude получил еще $150 млн
$100 млн для Free Fire: как младший брат может обогнать старшего на уже сложившемся рынке?
App Annie: рынок мобильных игр в России в 2020 вырос на 25% до $933 млн
Темные паттерны и уловки в мобильных приложениях
Использование BigQuery и Firebase Analytics для привлечения, вовлечения и оценки пользователей

AI, Устройства, IoT

Запускаем DOOM на лампочке
Быстрое обнаружение Covid-19 на рентгеновских снимках с помощью Raspberry Pi
Как я учу Python на Raspberry Pi 400 в библиотеке
Топ-5 преемников GPT-3, о которых вы должны знать в 2021 году

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

Как мы создали фреймворк для построения графиков в iOS-приложении

23.04.2021 10:10:48 | Автор: admin

Привет, я Сергей, iOS-разработчик в ЮKassa, занимаюсь ее мобильным приложением. Недавно в нем появился просмотр аналитики по счетам и платежам. И сегодня я расскажу, как мы реализовали эту возможность, а еще зачем и как разработали собственный фреймворк для создания графиков.

Немного об аналитике в ЮKassa

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

Аналитика платежей в ЮKassaАналитика платежей в ЮKassa

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

Что мы хотели получить

Наша разработка началась с создания концепт-дизайна. Мы решили, что в приложении должна быть возможность выбора периода и типа аналитики к примеру, по выручке или среднему чеку. Мы определились сразу, как сделать карусель для типов аналитики и кнопки для периодов, через CollectionView и StackView соответственно. А вот как нарисовать сам график и сделать так, чтобы им можно было управлять жестами, эти задачи оказалась труднее.

Концепт-дизайнКонцепт-дизайн

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

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

Что мы попробовали

Для создания графиков мы попробовали три фреймворка. Это самые популярные открытые решения Charts и SwiftCharts, а еще платное SciChart. Для нас важнее всего было не наличие большого количества функций, а возможность рисования графиков нужным образом и высокая производительность.

Кастомизация

Отображение

Анимация

Производи-тельность

Charts

Отсутствует отрисовка
одной точки

Есть всё необходимое

Отсутствует анимация перерисовки

Просадки при более чем 500 точек

SwiftCharts

Отсутствует отрисовка
одной точки

Некорректное отображение дат на оси X

Отсутствует анимация перерисовки и смены графиков

Просадки при более чем 800 точек

SciChart

Отсутствует отрисовка
одной точки

Есть всё необходимое

Есть всё необходимое

Отличная оптимизация

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

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

В SwiftCharts тоже было невозможно задать кастомную анимацию при переключении между графиками. Еще отсутствовала анимация при перерисовке взаимодействии с графиком жестами. Некорректно отображались даты на оси X. И при числе значений 800+ тоже заметно проседала частота кадров.

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

SciChartSciChart

Этот фреймворк очень крут, потому что рендерит графики с помощью OpenGL ES и Metal и даже при количестве значений в 100 000 держит стабильные 60 кадров в секунду. Но SciChart это профессиональное решение, которое используется для отображения данных и графиков в медицине, приложениях для трейдинга, работы с музыкой и т.п. Персональная лицензия стоит $1000 в год, а корпоративная $4000 в год.

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

Как мы сделали свое решение

Для разработки мы выбрали SpriteKit. Он простой в использовании и внедрении, рендерит через Metal с высокой производительностью и позволяет легко создавать анимации. Еще был вариант сделать фреймворк на Core Graphics или MetalKit, но в первом сложнее работать с анимациями, а со вторым мы вообще не имели дела, и на его освоение времени не было. Итак, SpriteKit.

Нахождение координат точек графикаНахождение координат точек графика

Прежде всего нужно было разработать алгоритм нахождения координат точек для графика. Здесь пояснение: ширина и высота графика это ширина и высота области, в которой он отображается (например, 200 на 200). Минимальное и максимальное значения графика это точки экстремума (для приведенного на картинке графика минимум это -1, а максимум 1).

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

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

Нахождение координат точек смещенного графикаНахождение координат точек смещенного графика

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

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

Весь алгоритм я описывать не буду, если захотите в нем разобраться, посмотрите исходный код фреймворка, который мы выложили на GitHub: https://github.com/yoomoney-tech/mobile-analytics-chart-swift.

Как все это работает

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

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

Статичные экстремумыСтатичные экстремумы

Второй тип произвольные экстремумы.

Произвольные экстремумыПроизвольные экстремумы

Здесь как со статичными экстремумами, разница лишь в том, что мы не вычисляем экстремумы, а сами инициализируем их произвольными числами. Например, максимальное значение равно 5, а минимальное 1, но так как произвольные максимум и минимум равны 10 и -5 соответственно, то график сжимается по вертикали.

И последний тип динамические экстремумы.

Динамические экстремумыДинамические экстремумы

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

Алгоритм нахождения динамических экстремумовАлгоритм нахождения динамических экстремумов

С учетом всего этого мы разработали алгоритм, который работает следующим образом.

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

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

Итак, мы научились находить координаты точек. Осталось соединить их линией, отобразить и добавить градиент. Для формирования линии создаем кривую Безье из вычисленных координат для каждой точки. Затем добавляем кривую в SKShapeNode это математическая фигура из SpriteKit, которую можно обводить или красить заливкой. Градиент создаем с помощью SKSpriteNode объекта в SpriteKit, который можно залить любым цветом или градиентом. Маску для градиента делаем через SKCropNode.

Построение графикаПостроение графика

Другой важный момент в фреймворке доступно три типа отображения графика.

Типы отображения линии графикаТипы отображения линии графика

1) Линейное. Кривая не сглаживается.

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

3) Горизонтально-квадратичное. Кривая сглаживается, и все точки соответствуют своим значениям.

Техника жестов и никакой магии

После разработки алгоритма для нахождения точек координат мы приступили к реализации управления жестами. Для этого внедрили в фреймворк три типа жестов.

Жесты для взаимодействия с графикомЖесты для взаимодействия с графиком

Первый долгое зажатие (он же LongPress), который позволяет отобразить промежуточное значение отдельной точки на графике. Второй сдвиг графика вправо или влево, реализованный при помощи Pan. Третий увеличение и уменьшение графика, сделанное через Pinch.

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

Финальный штрих анимации

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

Алгоритм анимации перерисовкиАлгоритм анимации перерисовкиАнимация перерисовкиАнимация перерисовки

Мы реализовали и другие анимации. Анимация переключения между графиками позволяет плавно перейти от одной фигуры к другой. Анимация загрузки данных избавляет от спиннеров и загрузчиков. Эти анимации реализованы через SKAction инструмент SpriteKit для анимированного изменения объекта, в нашем случае анимированного изменения цвета линии и градиента.

Анимация загрузки данных и переключения Анимация загрузки данных и переключения

В SpriteKit нет SKAction для анимированного изменения цвета, поэтому мы реализовали для этого кастомный SKAction. Мы написали функцию для создания промежуточного цвета, в нее передаются два параметра: elapsedTime время, прошедшее с момента начала анимации, и duration продолжительность анимации. FromColor это цвет в начале анимации, toColor цвет, который нужно получить в конце анимации.

Сначала вычисляем fraction долю прошедшего времени от всей продолжительности. Это значение от 0 до 1. Для более плавной анимации долю прошедшего времени мы прогоняем через функцию плавности CubicEaseOut. Дальше через линейную интерполяцию получаем промежуточные значения для красного, зеленого, голубого и для альфа-канала. На выходе получается промежуточный цвет, который можно использовать в нашем кастомном SKAction.

func makeTransColor(    elapsedTime: CGFloat,    duration: TimeInterval,    fromColor: UIColor,    toColor: UIColor) -> UIColor {    let fraction = cubicEaseOut(CGFloat(elapsedTime / CGFloat(duration)))    let startColorComponents = fromColor.toComponents()    let endColorComponents = toColor.toComponents()    return UIColor(        red: lerp(a: startColorComponents.red, b: endColorComponents.red, fraction: fraction),        green: lerp(a: startColorComponents.green, b: endColorComponents.green, fraction: fraction),        blue: lerp(a: startColorComponents.blue, b: endColorComponents.blue, fraction: fraction),        alpha: lerp(a: startColorComponents.alpha, b: endColorComponents.alpha, fraction: fraction)    )}// Функция плавностиfunc cubicEaseOut(    _ x: CGFloat) -> CGFloat {    let p = x - 1    return p * p * p + 1}// Линейная интерполяцияfunc lerp(    a: CGFloat,    b: CGFloat,    fraction: CGFloat) -> CGFloat {    return (b - a) * fraction + a}

Ниже приведен код кастомного SKAction для анимированной смены цвета обводки объекта (у нас это изменение цвета кривой на графике). Мы можем задать начальный и конечный цвета, а еще продолжительность анимации. В процессе анимации создаем промежуточный цвет и присваиваем его обводке объекта.

func strokeColorTransitionAction(    fromColor: UIColor,    toColor: UIColor,    duration: TimeInterval = 0.5) -> SKAction {    return SKAction.customAction(withDuration: duration) { (node: SKNode, elapsedTime: CGFloat) in        guard let shapeNode = node as? SKShapeNode else { return }        let transColor = makeTransColor(            elapsedTime: elapsedTime,            duration: duration,            fromColor: fromColor,            toColor: toColor        )        shapeNode.strokeColor = transColor    }}

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

static func gradientColorTransitionAction(    fromColor: UIColor,    toColor: UIColor,    duration: TimeInterval = 0.5,    direction: GradientDirection = .up) -> SKAction {    return SKAction.customAction(withDuration: duration) { (node: SKNode, elapsedTime: CGFloat) in        guard let spriteNode = node as? SKSpriteNode else { return }        let transColor = makeTransColor(            elapsedTime: elapsedTime,            duration: duration,            fromColor: fromColor,            toColor: toColor        )        let size = spriteNode.size        let textureSize = CGSize(            width: size.width / 2,            height: size.height / 2        )        let texture = SKTexture(            size: textureSize,            color1: CIColor(color: transColor.withAlphaComponent(0.0)),            color2: CIColor(color: transColor),            direction: direction        )        texture.filteringMode = .linear        spriteNode.texture = texture    }}

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

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

Для анимации вьюшки со значением в отдельной точке мы тоже использовали SKAction. Через функцию move можно плавно перенести вьюшку в нужную позицию.

Анимация view со значением в отдельной точкеАнимация view со значением в отдельной точке

Что получилось в итоге

Перед отображением двух графиков с 1000 точек Charts зависает на несколько секунд, а при работе с графиком выдает всего лишь около 20 кадров/сек. SwiftCharts справляется лучше и выдает около 40 кадров/сек. Наш фреймворк, который мы создали за две недели и назвали AnalyticsChart, выдает стабильные 50-60 кадров/сек. при работе с графиком в iOS-приложении как на iPhone (XS Max), так и на iPad. К тому же наш фреймворк соответствует нужному нам дизайну, позволяет отображать и взаимодействовать с одним и больше графиками.

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

Конечный результат наших стараний и производительность для двух графиков с 1000 точек:

Расскажите про ваш опыт создания фреймворков для графиков. Или если есть вопросы, задавайте их в комментариях.

Подробнее..

Перевод Переход вашего приложения на модули пакетов Swift

28.04.2021 16:12:49 | Автор: admin

Введение

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

http://personeltest.ru/aways/github.com/apple/swift-package-managerhttps://github.com/apple/swift-package-manager

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

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

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

Настройка

Поскольку мы делаем это по организационным причинам, а не для того, чтобы поделиться кодом между приложениями, будет проще всего воспользоваться концепцией monorepo. Это означает, что мы собираемся добавить новый пакет Swift как часть репозитория Git нашего текущего приложения.

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

Сначала создадим папку под названием Modules в корневом каталоге git, где мы сможем хранить все наши пакеты Swift.

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

Теперь нажмите символ плюс в левом нижнем углу и затем выберите "New Swift Package". Убедитесь, что новый пакет Swift помещён в папку Modules.

После этого все должно выглядеть так:

Package.swift

Теперь давайте исправим и очистим файл Package.swift и рассмотрим различные части.

let package = Package(    name: "NewModule", // This is the name of the package    defaultLocalization: "en", // This allows for localization    platforms: [.iOS(.v12)], // Our minimum deployment target is 12    products: [        .library(            name: "NewModule",            type: .static, // This is a static library            targets: ["NewModule"]        )    ],    dependencies: [    ],    targets: [        .target(            name: "NewModule",            dependencies: [            ],            path: "Sources", // This allows us to have a better folder structure            resources: [                .process("Media.xcassets") // We will store out assets here            ]        ),        .testTarget(            name: "NewModuleTests", // Unit tests            dependencies: ["NewModule"]        )    ])

Package.swift это файл, который передает компилятору, как создать ваш пакет Swift. Файл декларирует, что необходимо включить и что не нужно включать, и как пакеты должны быть собраны.

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

Очистка

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

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

Давайте удалим файлы LinuxMain.swift и XCTestManifests.swift, так как мы не будем запускать их в Linux. Затем переименуем папку NewModule в Public и создадим папку Internal для лучшей организации контроля доступа. Теперь давайте переименуем NewModule.swift в NewModuleViewController.swift, чтобы мы могли проверить, работает ли он.

В итоге все должно выглядеть так.

Подключайте!

Код выше должен дать нам UIViewController c оранжевым фоном, подтверждающим, что он работает правильно.

Когда проект выбран и наша главная цель (target) выделена, нажмите "плюс" в разделе Frameworks, Libraries, and Embedded Content.

Теперь выберите нашу новую библиотеку и нажмите "Add ".

Теперь вернемся в приложение и используем наш новый модуль, отредактировав Main.storyboard и изменив класс на NewModuleViewController.swift, а "Модуль" на NewModule.

Тест

Теперь, когда мы запускаем приложение, становится видно, что оно использует ViewController из нашего нового модуля! ?

Заключение

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


Перевод статьи подготовлен в рамках старта набора учащихся на курс "iOS Developer. Basic" подготовили традиционный перевод статьи.

Приглашаем также всех желающих на двухдневный интенсив Создание простейшего приложения без единой строчки кода. В первый день обсудим и сделаем:
Что такое XCode?
Как "рисуются экраны"
Добавим на экраны кнопки и поля ввода. Создадим экран авторизации.
Создадим второй экран нашего приложения и добавим переход на него из окна авторизации.

Подробнее..

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. Смотрите демо-проект, допиливайте его под себя, пользуйтесь!

Спасибо за внимание! Легкого кодинга, поменьше багов, побольше фич!

Подробнее..

Перевод Построение графиков в SwiftUI

13.05.2021 18:23:09 | Автор: admin

Перевод подготовлен в рамках курса "iOS Developer. Basic". Если вам интересно узнать о курсе больше, приходите на день открытых дверей онлайн.

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

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

Выбор пал на SwifUI-Charts, который предлагает действительно красиво выглядящие графики и простую интеграцию.

Установка и конфигурация проекта

Сначала мы начнем с создания проекта в XCode.

Отправной точкой является стандартное SwiftUI-приложение, которое будет модифицировано для отображения графика.

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

Нажав на кнопку "плюс", можно добавить новый пакет. Здесь необходимо указать полный путь к репозиторию: https://github.com/spacenation/swiftui-charts.git.

На следующем экране я оставил все значения по умолчанию.

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

Отображение постоянных данных

Пришло время добавить код. Для первого теста я просто взял несколько фрагментов кода из Github-Readme и добавил их в ContentView:

import Chartsimport SwiftUIstruct ContentView: View {    var body: some View {        Chart(data: [0.1, 0.3, 0.2, 0.5, 0.4, 0.9, 0.1])            .chartStyle(                LineChartStyle(.quadCurve, lineColor: .blue, lineWidth: 5)            )    }}

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

Это считается первым успешным тестом.

Отображение динамических данных

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

В качестве "сенсора" будет использоваться класс ObservableObject, который просто публикует случайное значение Double каждые 500 миллисекунд.

import Foundationclass ValuePublisher: ObservableObject {    @Published var value: Double = 0.0        init() {        Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in            self.value = Double.random(in: 0...1.0)        }    }}

Его нужно инстанцировать в ContentView как переменную @State.

@StateObject var valuePublisher = ValuePublisher()

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

struct Queue<T> {    var list = [T]()        mutating func enqueue(_ element: T) {        list.append(element)    }        mutating func dequeue() -> T? {        if !list.isEmpty {            return list.removeFirst()        } else {            return nil        }    }        func peek() -> T? {        if !list.isEmpty {            return list[0]        } else {            return nil        }    }}

Эта очередь будет инстанцирована как переменная @State в ContentView

@State var doubleQueue = Queue<Double>()

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

.onAppear {    doubleQueue.list = [Double](repeating: 0.0, count: 50)}

График также должен содержать информацию о списке, в котором хранятся значения.

Chart(data: doubleQueue.list)

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

.onChange(of: valuePublisher.value) { value in    self.doubleQueue.enqueue(value)    _ = self.doubleQueue.dequeue()}

На этом все, вот полный ContentView, где я также немного изменил внешний вид графика.

import Chartsimport SwiftUIstruct ContentView: View {        @State var doubleQueue = Queue<Double>()        @StateObject var valuePublisher = ValuePublisher()        var body: some View {        Chart(data: doubleQueue.list)            .chartStyle(                AreaChartStyle(.quadCurve, fill:                    LinearGradient(gradient: .init(colors: [Color.blue.opacity(1.0), Color.blue.opacity(0.5)]), startPoint: .top, endPoint: .bottom)                )            )            .onAppear {                doubleQueue.list = [Double](repeating: 0.0, count: 50)            }            .onChange(of: valuePublisher.value) { value in                self.doubleQueue.enqueue(value)                _ = self.doubleQueue.dequeue()            }            .padding()    }}

Вот скриншот окончательного варианта приложения

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

Видео: Графики в SwiftUI

Ресурсы

Подробнее..

Перевод Обертки свойств в Swift с примерами кода

20.05.2021 20:12:40 | Автор: admin

Перевод статьи подготовлен в рамках онлайн-курса "iOS Developer. Professional". Если вам интересно узнать подробнее о курсе, приходите на День открытых дверей онлайн.


Property Wrappers (Обертки Свойств) в Swift позволяют извлекать общую логику в отдельный объект-обертку. С момента представления во время WWDC 2019 и появления в Xcode 11 со Swift 5 было много примеров, которыми поделились в сообществе. Это изящное дополнение к библиотеке Swift, позволяющее удалить много шаблонного кода, который, вероятно, все мы писали в своих проектах.

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

Что такое обертка свойства?

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

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

extension UserDefaults {    @UserDefault(key: "has_seen_app_introduction", defaultValue: false)    static var hasSeenAppIntroduction: Bool}

Оператор @UserDefault выполняет вызов обертки свойства. Как видите, мы можем задать ему несколько параметров, которые используются для настройки обертки свойства. Существует несколько способов взаимодействия с оберткой свойства, например, использование обернутого и прогнозируемого значения. Вы также можете настроить обертку с внедренными свойствами, о которых мы поговорим позже. Давайте сначала рассмотрим пример обертки свойства User Defaults.

Обертки свойств и UserDefaults

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

extension UserDefaults {    public enum Keys {        static let hasSeenAppIntroduction = "has_seen_app_introduction"    }    /// Indicates whether or not the user has seen the onboarding.    var hasSeenAppIntroduction: Bool {        set {            set(newValue, forKey: Keys.hasSeenAppIntroduction)        }        get {            return bool(forKey: Keys.hasSeenAppIntroduction)        }    }}

Он позволяет устанавливать и получать значения из пользовательских настроек по умолчанию из любого места следующим образом:

UserDefaults.standard.hasSeenAppIntroduction = trueguard !UserDefaults.standard.hasSeenAppIntroduction else { return }showAppIntroduction()

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

Использование оберток свойств для удаления шаблонного кода

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

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

@propertyWrapperstruct UserDefault<Value> {    let key: String    let defaultValue: Value    var container: UserDefaults = .standard    var wrappedValue: Value {        get {            return container.object(forKey: key) as? Value ?? defaultValue        }        set {            container.set(newValue, forKey: key)        }    }}

Обертка позволяет передать значение по умолчанию, если еще нет зарегистрированного значения. Мы можем передать любое значение, поскольку обертка определяется общим значением Value.

Теперь мы можем изменить нашу предыдущую имплементацию кода и создать следующее расширение для типа UserDefaults:

extension UserDefaults {    @UserDefault(key: "has_seen_app_introduction", defaultValue: false)    static var hasSeenAppIntroduction: Bool}

Как видите, мы можем использовать сгенерированный по умолчанию инициализатор struct из обертки определяемого свойства. Мы передаем тот же ключ, что и раньше, и устанавливаем значение по умолчанию false. Использовать это новое свойство очень просто:

UserDefaults.hasSeenAppIntroduction = falseprint(UserDefaults.hasSeenAppIntroduction) // Prints: falseUserDefaults.hasSeenAppIntroduction = trueprint(UserDefaults.hasSeenAppIntroduction) // Prints: true

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

extension UserDefaults {    static let groupUserDefaults = UserDefaults(suiteName: "group.com.swiftlee.app")!    @UserDefault(key: "has_seen_app_introduction", defaultValue: false, container: .groupUserDefaults)    static var hasSeenAppIntroduction: Bool}

Добавление дополнительных свойств с помощью одной обертки

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

extension UserDefaults {    @UserDefault(key: "has_seen_app_introduction", defaultValue: false)    static var hasSeenAppIntroduction: Bool    @UserDefault(key: "username", defaultValue: "Antoine van der Lee")    static var username: String    @UserDefault(key: "year_of_birth", defaultValue: 1990)    static var yearOfBirth: Int}

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

Хранение опционалов с помощью обертки свойств пользователя по умолчанию

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

/// Allows to match for optionals with generics that are defined as non-optional.public protocol AnyOptional {    /// Returns `true` if `nil`, otherwise `false`.    var isNil: Bool { get }}extension Optional: AnyOptional {    public var isNil: Bool { self == nil }}

Мы можем расширить нашу обертку свойств UserDefault, чтобы она соответствовала этому протоколу:

extension UserDefault where Value: ExpressibleByNilLiteral {        /// Creates a new User Defaults property wrapper for the given key.    /// - Parameters:    ///   - key: The key to use with the user defaults store.    init(key: String, _ container: UserDefaults = .standard) {        self.init(key: key, defaultValue: nil, container: container)    }}

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

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

@propertyWrapperstruct UserDefault<Value> {    let key: String    let defaultValue: Value    var container: UserDefaults = .standard    var wrappedValue: Value {        get {            return container.object(forKey: key) as? Value ?? defaultValue        }        set {            // Check whether we're dealing with an optional and remove the object if the new value is nil.            if let optional = newValue as? AnyOptional, optional.isNil {                container.removeObject(forKey: key)            } else {                container.set(newValue, forKey: key)            }        }    }    var projectedValue: Bool {        return true    }}

Теперь это позволяет нам определять опционалы и принимать значения равными нулю:

extension UserDefaults {    @UserDefault(key: "year_of_birth")    static var yearOfBirth: Int?}UserDefaults.yearOfBirth = 1990print(UserDefaults.yearOfBirth) // Prints: 1990UserDefaults.yearOfBirth = nilprint(UserDefaults.yearOfBirth) // Prints: nil

Отлично! Теперь мы можем справиться со всеми сценариями с помощью обертки пользовательских настроек по умолчанию. Последнее, что нужно добавить, это прогнозируемое значение, которое будет преобразовано в Combine publisher, как и в обертке свойства @Published.

Прогнозирование значения из обертки свойства

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

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

import Combine  @propertyWrapper struct UserDefault<Value> {     let key: String     let defaultValue: Value     var container: UserDefaults = .standard     private let publisher = PassthroughSubject<Value, Never>()          var wrappedValue: Value {         get {             return container.object(forKey: key) as? Value ?? defaultValue         }         set {             // Check whether we're dealing with an optional and remove the object if the new value is nil.             if let optional = newValue as? AnyOptional, optional.isNil {                 container.removeObject(forKey: key)             } else {                 container.set(newValue, forKey: key)             }             publisher.send(newValue)         }     }     var projectedValue: AnyPublisher<Value, Never> {         return publisher.eraseToAnyPublisher()     } } We can now start 

Теперь мы можем начать наблюдать за изменениями в нашем объекте следующим образом:

let subscription = UserDefaults.$username.sink { username in     print("New username: \(username)") } UserDefaults.username = "Test" // Prints: New username: Test 

Это замечательно! Это позволяет нам реагировать на любые изменения. Поскольку до этого мы определяли наше свойство статически, теперь этот publisher будет работать во всем нашем приложении. Если вы хотите узнать больше о Combine, обязательно ознакомьтесь с моей статьей Начало работы с фреймворком Combine в Swift.

Определение образцов файлов с помощью обертки свойств

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

Возьмем следующую обертку свойств, в которой мы определяем файл-образец:

@propertyWrapperstruct SampleFile {    let fileName: String    var wrappedValue: URL {        let file = fileName.split(separator: ".").first!        let fileExtension = fileName.split(separator: ".").last!        let url = Bundle.main.url(forResource: String(file), withExtension: String(fileExtension))!        return url    }    var projectedValue: String {        return fileName    }}

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

struct SampleFiles {    @SampleFile(fileName: "sample-image.png")    static var image: URL}

Свойство projectedValue позволяет нам считывать имя файла, используемое в обертке свойства:

print(SampleFiles.image) // Prints: "../resources/sample-image.png"print(SampleFiles.$image) // Prints: "sample-image.png"

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

Доступ к определенным приватным свойствам

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

В приведенном выше примере мы можем получить доступ и к имени файла, используя префикс подчеркивания. Это позволяет нам получить доступ к приватному свойству filename:

extension SampleFiles {    static func printKey() {        print(_image.fileName)    }}

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

Другие примеры использования

Обертки свойств используются и в стандартных API Swift. Особенно в SwiftUI вы найдете такие обертки свойств, как @StateObject и @Binding. Все они имеют нечто общее: упрощение доступа к часто используемым шаблонам.

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

@Option(shorthand: "m", documentation: "Minimum value", defaultValue: 0)var minimum: Int

Или для представлений, макеты которых определены в коде:

final class MyViewController {    @UsesAutoLayout    var label = UILabel()}

Этот последний пример я часто использую в своих проектах для представлений, которые используют автоматическую компоновку и требуют, чтобы translatesAutoresizingMaskIntoConstraints был установлен в false. Подробнее об этом примере вы можете прочитать в моей статье в блоге: Автоматическая компоновка в Swift: Программное написание ограничений.

Заключение

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

Если вы хотите желаете узнать больше советов по Swift, загляните на страницу категории swift. Не стесняйтесь связаться со мной или написать мне в Twitter, если у вас есть дополнительные рекомендации или отзывы. Спасибо!

Подробнее..

Swift и CoreData. Или как построить Swift ORM на основе Objective-C ORM

04.06.2021 16:20:58 | Автор: admin

Хабр, здравствуй! Меня зовут Геор, и я развиваю iOS проекты в компании Prisma Labs. Как вы наверняка поняли, речь сегодня пойдет про кордату и многим из вас стало скучно уже на этом моменте. Но не спешите отчаиваться, так как говорить мы будет по большей части о магии свифта и про метал. Шутка - про метал в другой раз. Рассказ будет про то, как мы победили NSManaged-бойлерплейт, переизобрели миграции и сделали кордату great again.

Разработчики, пройдемте.

Пару слов о мотивации

Работать с кордатой сложно. Особенно в наше свифт-время. Это очень старый фреймворк, который был создан в качестве дата-слоя с акцентом на оптимизацию I/O, что по умолчанию сделало его сложнее других способов хранения данных. Но производительность железа со временем перестала быть узким горлышком, а сложность кордаты, увы, никуда не делась. В современных приложениях многие предпочитают кордате другие фреймворки: Realm, GRDB (топ), etc. Или просто используют файлы (почему бы и нет). Даже Apple в новых туториалах использует Codable сериализацию/десериализацию для персистенса.

Несмотря на то, что АПИ кордаты периодически пополнялся различными удобными абстракциями (напр. NSPersistentContainer), разработчики по-прежнему должны следить за жизненным циклом NSManaged объектов, не забывать выполнять чтение/запись на очереди контекста, к которому эти объекты привязаны и разумеется ругаться каждый раз, когда что-то пойдет не так. И наверняка во многих проектах есть дублирующий набор моделей доменного уровня и код для конвертации между ними и их NSManaged-парами.

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

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

Встречайте - Sworm.

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

Отказ от NSManagedObject-наследования и встроенной CoreData-кодогенерации

Вместо этого NSManagedObject'ы используются напрямую как key-value контейнеры. Идея не нова, а сложность заключается в том, как автоматизировать конвертацию между KV-контейнером и доменной моделью. Чтобы решить эту задачу нужно навести 3 моста:

  1. название

  2. аттрибуты

  3. отношения

С названием все просто - указав в типе вашей модели строчку с названием можно однозначно связать ее с сущностью в модели:

struct Foo {    static let entityName: String = "FooEntity"}

"Мост" отношений - уже более сложная техническая конструкция. В случае названия, статическое поле указанное внутри типа автоматически с ним связано:

Foo.entityName

Но чтобы определить отношение, помимо названия этого отношения нам нужен еще тип destination-модели, внутри которой так же должно быть название соответствующей сущности. Это наводит на две мысли. Во-первых, все модели, конвертируемые в NSManageObject должны следовать единому набору правил, то есть пришло время протокола, и, во-вторых, нам нужен дополнительный тип данных Relation<T: протокол>(name: String), который будет связывать название отношения в модели данных с типом, соответствующей ей доменной модели. Пока опустим детали, что отношения бывают разные - это на данном этапе неважно. Итак, протокол версия 1:

protocol ManagedObjectConvertible {    static var entityName: String { get }}

и тип для отношений:

Relation<T: ManageObjectConvertible>(name: String)

Применяем:

struct Foo: ManageObjectConvertible {    static var entityName: String = "FooEntity"    static let relation1 = Relation<Bar1>(name: "bar1")    static let relation2 = Relation<Bar2>(name: "bar2")}

Сразу напрашивается идея зафиксировать наличие связей (отношений) в нашем протоколе, но как это сделать, если количество связей всегда разное? Сделать коллекцию отношений не получится по нескольким причинам. Во-первых, в свифте дженерики инвариантны, во-вторых, рано или поздно нам придется вспомнить, что Relation распадется на несколько типов - one/many/orderedmany, и это автоматически приведет к мысли о гомогенности через стирание типов, что нас не устраивает. Но на самом деле, нас не интересуют сами отношения и мы можем даже не думать об их количестве. Поэтому мы добавим в протокол не конкретный тип отношений, а ассоциацию с типом отношений. Звучит странно и на первый взгляд непонятно, но подержите мое пиво - протокол версия 2:

protocol ManagedObjectConvertible {    associatedtype Relations    static var entityName: String { get }    static var relations: Relations { get }}

Все еще странно, продолжаем держать пиво:

struct Foo: ManageObjectConvertible {    static let entityName: String = "FooEntity"    struct Relations {        let relation1 = Relation<Bar1>(name: "bar1")        let relation2 = Relation<Bar2>(name: "bar2")    }    static let relations = Relations()}

И вот сейчас станет понятно - с помощью такой имплементации можно легко достать название отношения:

extension ManagedObjectConvertible {    func relationName<T: ManagedObjectConvertible>(        keyPath: KeyPath<Self.Relations, Relation<T>>    ) -> String {        Self.relations[keyPath: keyPath].name    }}

Пиво-то верни, что стоишь :)

Финальный мост - атрибуты

Как известно у любого босса есть слабые места и этот не исключение.

На первый взгляд, задача выглядит аналогичной "мосту" отношений, но в отличие от них, нам необходимо знать о всех доступных атрибутах, и обойтись ассоциированным типом не получится. Нужна полноценная коллекция атрибутов, каждый из которых должен уметь делать две вещи: заэнкодить значение из модели в контейнер и задекодить из контейнера обратно в модель. Очевидно, что это связь WritableKeyPath + String key. Но, как и в случае отношений, нам понадобится решить задачу - как сохранить информацию о типах, учитывая инвариантность дженериков и необходимость иметь гомогенную коллекцию атрибутов.

Пусть в роли атрибута выступит специальный объект типа Attribute<T>, где T - доменная модель. Тогда коллекцией атрибутов будет `[Attribute<T>]` и для нашего протокола заменим T на Self. Итак, протокол - версия 3:

public protocol ManagedObjectConvertible {    associatedtype Relations    static var entityName: String { get }    static var attributes: [Attribute<Self>] { get }    static var relations: Relations { get }}

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

final class Attribute<T: ManagedObjectConvertible, V> {    let keyPath: WritableKeyPath<T, V>    let key: String    ...    func update(container: NSManagedObject, model: T) {        container.setValue(model[keyPath: keyPath], forKey: key)    }    func update(model: inout T, container: NSManagedObject) {        model[keyPath: keyPath] = container.value(forKey: key) as! V    }}

Имплементация атрибута могла бы выглядеть так, но [Attribute<T, V>] - не наш случай. Как можно избавиться от V в сигнатуре класса, сохранив информацию об этом типе? Не все знают, но в свифте можно добавлять дженерики в сигнатуру инициализатора:

final class Attribute<T: ManagedObjectConvertible> {    ...    init<V>(        keyPath: WritableKeyPath<T, V>,        key: String    ) { ... }    ...}

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

final class Attribute<T: ManagedObjectConvertible> {    let encode: (T, NSManagedObject) -> Void    let decode: (inout T, NSManagedObject) -> Void    init<V>(keyPath: WritableKeyPath<T, V>, key: String) {        self.encode = {            $1.setValue($0[keyPath: keyPath], forKey: key)        }        self.decode = {            $0[keyPath: keyPath] = $1.value(forKey: key) as! V        }    }}

В нашем протоколе осталось еще одно пустое место. Мы знаем как создать NSManagedObject и заполнить его данными из модели, знаем как заполнить модель из NSManagedObject'а, но НЕ знаем, как создать инстанс нашей модели при необходимости.

Протокол - версия 4, финальная:

protocol ManagedObjectConvertible {    associatedtype Relations    static var entityName: String { get }    static var attributes: Set<Attribute<Self>> { get }    static var relations: Relations { get }    init()}

Все - мы победили наследование от NSManagedObject'ов, заменив его на имплементацию протокола.

Далее рассмотрим как можно сделать систему атрибутов гибче и шире.

Гибкая система атрибутов

Кордата поддерживает набор примитивных аттрибутов - bool, int, double, string, data, etc. Но помимо них есть малоиспользуемый Transformable, который позволяет сохранять в кордате данные различных типов. Идея отличная и мы решили вдохнуть в нее новую жизнь с помощью системы типов свифта.

Определим следующий набор атрибутов-примитивов:

Bool, Int, Int16, Int32, Int64, Float, Double, Decimal, Date, String, Data, UUID, URL

И утвердим правило: тип атрибута валиден, если данные можно сериализовать в один из примитивов и десериализовать обратно.

Это легко выразить в виде двух протоколов:

protocol PrimitiveAttributeType {}protocol SupportedAttributeType {    associatedtype P: PrimitiveAttributeType    func encodePrimitive() -> P    static func decode(primitive: P) -> Self}

Применив SupportedAttributeType в нашей имплементации Attribute

final class Attribute<T: ManagedObjectConvertible> {    let encode: (T, NSManagedObject) -> Void    let decode: (inout T, NSManagedObject) -> Void    init<V: SupportedAttributeType>(keyPath: WritableKeyPath<T, V>, key: String) {        self.encode = {            $1.setValue($0[keyPath: keyPath].encodePrimitive(), forKey: key)        }        self.decode = {            $0[keyPath: keyPath] = V.decode(primitive: $1.value(forKey: key) as! V.P)        }    }}

мы получим возможность хранить в кордате данные любых типов по аналогии с Transformable, но без objc-легаси.

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

Благодаря ManagedObjectConvertible мы однозначно связали типы наших моделей и информмацию о схеме данных. Но для того, чтобы на основе этой информации мы могли выполнять операции с данными нам потребуется слой data access объектов или DAO, поскольку доменные модели обычно выступают в роли DTO - data transfer объектов.

Скрываем NSManaged под капот

Если рассматривать NSManaged-слой в терминах DAO и DTO, то контекст+объекты это DAO+DTO, причем равны суммы, но не компоненты по отдельности, так как NSManagedObject помимо трансфера данных еще может их обновлять, но с участием контекста. Попробуем перераспределить функциональность между NSManaged-сущностями и нашими доменными моделями. Наши модели это DTO + метаинформация о схеме данных (имплементация ManagedObjectConvertible). Составим псевдоуравнение:

доменные модели + raw NSManaged- объекты + X = DAO+DTO

я пометил NSManaged как raw - так как с точки зрения компилятора мы забрали от них информацию о схеме данных и передали ее во владение доменным моделям.

А X - это тот недостающий фрагмент, который свяжет информацию о схеме данных, информацию о типах моделей с NSManaged-слоем.

Решением нашего псевдоуравнения будет выступать новая сущность:

final class ManagedObject<T: ManagedObjectConvertible> {    let instance: NSManagedObject    ...}

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

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

Если мы вспомним ManagedObjectConvertible предоставляет информацию о названи сущности в схеме данных, о атрибутах-конвертерах и отношениях между моделями. Я специально заострил тогда внимание на том, как с помощью Keypaths можно получить название отношения. Адаптируем тот код под нужды ManagedObject:

final class ManagedObject<T: ManagedObjectConvertible> {    ...    subscript<D: ManagedObjectConvertible>(        keyPath: KeyPath<T.Relations, Relation<D>>    ) -> ManagedObject<D> {        let destinationName = T.relations[keyPath: keyPath]        // получаем объект отношения через NSManaged API        return .init(instance: ...)    }}

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

managedObject[keyPath: \.someRelation]

Достаточно просто, но мы можем применить спец заклинание в свифте - dynamicMemberLookup:

@dynamicMemberLookupfinal class ManagedObject<T: ManagedObjectConvertible> {    ...    subscript<D: ManagedObjectConvertible>(        dynamicMember keyPath: KeyPath<T.Relations, Relation<D>>    ) -> ManagedObject<D> { ... }}

и сделать наш код проще и более читаемым:

managedObject.someRelation

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

Типизированные предикаты

Идея заключается в том, чтобы заменить строковые запросы кордаты типизированными свифт выражениями:

Вместо "foo.x > 9 AND foo.y = 10" написать \Foo.x > 9 && \Foo.y == 10 и из этого выражения получить обратно "foo.x > 9 AND foo.y = 10"

Сделать это имея на руках информацию из сущности Attribute и протоколов Equatable и Comparable достаточно просто. От нас понадобится заимплементировать набор операторов сравнения и логических операторов.

Разберем для примера логический оператор >. В левой части у него находится KeyPath нужного атрибута, а в правой - значение соответствующего типа. Наша задача превратить выражение \Foo.x > 9 в строку "x > 9". Самое простое - это знак оператора. Просто в имплементации функции оператора зашиваем литерал ">". Чтобы из кипаса получить название обратимся к имплементации нашего протокола ManagedObjectConvertible у сущности Foo и попытаемся найти в списке атрибутов тот, что соответствует нашему кипасу. Сейчас мы не храним кипас и ключ контейнера внутри объекта атрибута, но ничего не мешает нам это сделать:

final class Attribute<T: ManagedObjectConvertible> {    let key: String    let keyPath: PartialKeyPath<T>    let encode: (T, NSManagedObject) -> Void    let decode: (inout T, NSManagedObject) -> Void    ...}

Обратите внимание, что WritableKeyPath стал PartialKeyPath. И самое важное, что мы можем в рантайме сравнивать кипасы межды собой, так как они имплементируют Hashable. Это крайне интересный момент, который говорит о том, что кипасы играют важную роль не только в комплайл тайме, но и в рантайме.

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

Также нам нужно понимать, к каким атрибутам можно применять операции сравнения. Очевидно, что не все типы имплементируют Equatable и/или Comparable. Но на самом деле, нас интересует не сам тип атрибута, а тип его конечного примитива (см. SupportedAttributeType).

Поскольку в кордате мы оперируем именно примитивами, нам будут подходить те атрибуты, чьи примитивы имплементируют Equatable и/или Comparable:

func == <T: ManagedObjectConvertible, V: SupportedAttributeType>(    keyPath: KeyPath<T, V>,    value: V) -> Predicate where V.PrimitiveAttributeType: Equatable {    return .init(        key: T.attributes.first(where: { $0.keyPath == keyPath })!.key,        value: value.encodePrimitiveValue(),        operator: "="    )}

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

И для полноты картины не хватает логического оператора. Например AND. Его имплементация по сути дела является склейкой двух фрагментов в выражении и верхнеуровнево его можно представить как "(\(left)) AND (\(right))".

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

Заключение

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

Надеюсь, что Sworm сделает вашу жизнь чуточку проще, как помогает нам уже на протяжении года.

Всем добра!

Подробнее..

Делаем OpenVPN клиент для iOS

10.06.2021 02:22:28 | Автор: admin
Привет всем!
Давайте рассмотрим как создать собственное приложение, поддерживающее OpenVPN-протокол. Для тех, кто об этом слышит впервые ссылки на обзорные материалы, помимо Википедии, приведены ниже.

С чего начать?


Начнем с фреймворка OpenVPNAdapter написан на Objective-C, ставится с помощью Pods, Carthage, SPM. Минимальная поддерживаемая версия ОС 9.0.
После установки необходимо будет добавить Network Extensions для таргета основного приложения, в данном случае нам понадобится пока Packet tunnel опция.

image

Network Extension


Затем добавляем новый таргет Network Extension.
Сгенерированный после этого класс PacketTunnelProvider приведем к следующему виду:

import NetworkExtensionimport OpenVPNAdapterextension NEPacketTunnelFlow: OpenVPNAdapterPacketFlow {}class PacketTunnelProvider: NEPacketTunnelProvider {    lazy var vpnAdapter: OpenVPNAdapter = {        let adapter = OpenVPNAdapter()        adapter.delegate = self        return adapter    }()    let vpnReachability = OpenVPNReachability()    var startHandler: ((Error?) -> Void)?    var stopHandler: (() -> Void)?    override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {        guard            let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol,            let providerConfiguration = protocolConfiguration.providerConfiguration        else {            fatalError()        }        guard let ovpnContent = providerConfiguration["ovpn"] as? String else {            fatalError()        }        let configuration = OpenVPNConfiguration()        configuration.fileContent = ovpnContent.data(using: .utf8)        configuration.settings = [:]        configuration.tunPersist = true        let evaluation: OpenVPNConfigurationEvaluation        do {            evaluation = try vpnAdapter.apply(configuration: configuration)        } catch {            completionHandler(error)            return        }        if !evaluation.autologin {            guard let username: String = protocolConfiguration.username else {                fatalError()            }            guard let password: String = providerConfiguration["password"] as? String else {                fatalError()            }            let credentials = OpenVPNCredentials()            credentials.username = username            credentials.password = password            do {                try vpnAdapter.provide(credentials: credentials)            } catch {                completionHandler(error)                return            }        }        vpnReachability.startTracking { [weak self] status in            guard status == .reachableViaWiFi else { return }            self?.vpnAdapter.reconnect(afterTimeInterval: 5)        }        startHandler = completionHandler        vpnAdapter.connect(using: packetFlow)    }    override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {        stopHandler = completionHandler        if vpnReachability.isTracking {            vpnReachability.stopTracking()        }        vpnAdapter.disconnect()    }}extension PacketTunnelProvider: OpenVPNAdapterDelegate {        func openVPNAdapter(_ openVPNAdapter: OpenVPNAdapter, configureTunnelWithNetworkSettings networkSettings: NEPacketTunnelNetworkSettings?, completionHandler: @escaping (Error?) -> Void) {        networkSettings?.dnsSettings?.matchDomains = [""]        setTunnelNetworkSettings(networkSettings, completionHandler: completionHandler)    }    func openVPNAdapter(_ openVPNAdapter: OpenVPNAdapter, handleEvent event: OpenVPNAdapterEvent, message: String?) {        switch event {        case .connected:            if reasserting {                reasserting = false            }            guard let startHandler = startHandler else { return }            startHandler(nil)            self.startHandler = nil        case .disconnected:            guard let stopHandler = stopHandler else { return }            if vpnReachability.isTracking {                vpnReachability.stopTracking()            }            stopHandler()            self.stopHandler = nil        case .reconnecting:            reasserting = true        default:            break        }    }    func openVPNAdapter(_ openVPNAdapter: OpenVPNAdapter, handleError error: Error) {        guard let fatal = (error as NSError).userInfo[OpenVPNAdapterErrorFatalKey] as? Bool, fatal == true else {            return        }        if vpnReachability.isTracking {            vpnReachability.stopTracking()        }        if let startHandler = startHandler {            startHandler(error)            self.startHandler = nil        } else {            cancelTunnelWithError(error)        }    }    func openVPNAdapter(_ openVPNAdapter: OpenVPNAdapter, handleLogMessage logMessage: String) {    }}


И снова код


Возвращаемся к основному приложению. Нам необходимо работать с NetworkExtension, предварительно импортировав его. Обращу внимание на классы NETunnelProviderManager, с помощью которого можно управлять VPN-соединением, и NETunnelProviderProtocol, задающий параметры новому соединению. Помимо передачи конфига OpenVPN, задаем возможность передать логин и пароль в случае необходимости.

var providerManager: NETunnelProviderManager!    override func viewDidLoad() {        super.viewDidLoad()        loadProviderManager {            self.configureVPN(serverAddress: "127.0.0.1", username: "", password: "")        }     }    func loadProviderManager(completion:@escaping () -> Void) {       NETunnelProviderManager.loadAllFromPreferences { (managers, error) in           if error == nil {               self.providerManager = managers?.first ?? NETunnelProviderManager()               completion()           }       }    }    func configureVPN(serverAddress: String, username: String, password: String) {      providerManager?.loadFromPreferences { error in         if error == nil {            let tunnelProtocol = NETunnelProviderProtocol()            tunnelProtocol.username = username            tunnelProtocol.serverAddress = serverAddress            tunnelProtocol.providerBundleIdentifier = "com.myBundle.myApp"             tunnelProtocol.providerConfiguration = ["ovpn": configData, "username": username, "password": password]            tunnelProtocol.disconnectOnSleep = false            self.providerManager.protocolConfiguration = tunnelProtocol            self.providerManager.localizedDescription = "Light VPN"            self.providerManager.isEnabled = true            self.providerManager.saveToPreferences(completionHandler: { (error) in                  if error == nil  {                     self.providerManager.loadFromPreferences(completionHandler: { (error) in                         do {                           try self.providerManager.connection.startVPNTunnel()                         } catch let error {                             print(error.localizedDescription)                         }                                                                   })                  }            })          }       }    }


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

image

Добавим возможность выключения VPN-соединения.

do {            try providerManager?.connection.stopVPNTunnel()            completion()        } catch let error {            print(error.localizedDescription)        }


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

Проверять статус подключения Вашего VPN в приложении можно с помощью статусов.

if providerManager.connection.status == .connected {      defaults.set(true, forKey: "serverIsOn")}


Всего этих статусов 6.

@available(iOS 8.0, *)public enum NEVPNStatus : Int {    /** @const NEVPNStatusInvalid The VPN is not configured. */    case invalid = 0    /** @const NEVPNStatusDisconnected The VPN is disconnected. */    case disconnected = 1    /** @const NEVPNStatusConnecting The VPN is connecting. */    case connecting = 2    /** @const NEVPNStatusConnected The VPN is connected. */    case connected = 3    /** @const NEVPNStatusReasserting The VPN is reconnecting following loss of underlying network connectivity. */    case reasserting = 4    /** @const NEVPNStatusDisconnecting The VPN is disconnecting. */    case disconnecting = 5}


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

Полезные ссылки:
OpenVPNAdapter
Habr
Конфиги для теста
Подробнее..
Категории: Разработка под ios , Vpn , Swift , Ios , Openvpn

Категории

Последние комментарии

  • Имя: Макс
    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