Привет, Хабровчане!

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

Как у нас возникла идея создать собственный iOS фреймворк?

В рамках работы над разными проектами было разработано множество полезных функциональных модулей, которые решали актуальные задачи. Модули создавались обособленно друг от друга и представляли собой самодостаточные единицы, решающие конкретную поставленную задачу. При этом на каждом новом проекте вновь реализовывать ту же самую логику, например, для получения спецификации устройства или же для использования Canvas в UIKit проекте, нам не очень-то и хотелось. К тому же в этот момент мы заканчивали переход от менеджера зависимостей CocoaPods к Swift Package Manager (далее – SPM).

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

Задавшись вопросом хардверных и софтверных ограничений, мы пришли к выводу, что минимальными поддерживаемыми версиями iOS и iPadOS для нашего фреймворка станут iOS 13.0+ и iPadOS 13.0+, соответственно. Также решили, что не хотим поддерживать legacy, и хотим своевременно актуализировать исходный код фреймворка, поэтому минимальной версией IDE для сборки исходников фреймворка стал Xcode 14.0+, а также версия компилятора, поддерживающего Swift 5.7+.

В ряде случаев SPM создаёт некоторые проблемы сборки с исходным кодом пакеджей, перестаёт нормально собирать проект, а также индексирует и пересобирает зависимости бóльшую часть времени. В связи с этим было решено создать таргет Xcode Workspace с исходным кодом, который впоследствии билдился бы в Xcode Fat Framework*.

Таким образом мы убиваем сразу нескольких зайцев:

  1. Фреймворк поставляется в виде Binary Target внутри SPM пакета, что позволяет экономить время на сборке проекта, к которому прилинкован фреймворк;

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

  3. Мы можем запаковать сразу несколько архитектур внутри одного пространства имён, чтобы иметь поддержку как iOS и iPadOS устройств, так и симуляторов для x86_64 и arm64 архитектур.

Xcode Fat Framework: фреймворк поставляется в виде двоичного файла, тем самым упрощая сборку проектов, в которых он используется, и обеспечивая поддержку необходимого набора архитектур как для реальных устройств, так и для симуляторов под платформы Apple и Intel.

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

Фреймворк включает следующую функциональность:

  • Development Tools

    • Device Specification

    • Live Preview

    • Project Specifications

  • Hardware

    • Haptic Feedback

    • Video Player

  • Software

    • General Purpose Types

      • Data Types

      • Structure Types

    • Design System

      • Components

      • Controllers

Development Tools

Device Specification

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

Пример обращения к сервису IBSDevice для получения свойств устройства:

let specification = IBSDevice.current.specification

print(specification.device.name) // iPhone 14 Pro

Live Preview

Сразу оговорим, что хоть мы и используем SwiftUI, но далеко не на всех проектах, а переводить всю старую кодовую базу с UIKit на SwiftUI мы пока не готовы, но при этом Canvas, пришедший к нам с Xcode 11.0 и iOS 13.0, нам хотелось использовать уже здесь и сейчас. По этой причине у нас возникла мысль совместить всё лучшее из двух миров – продолжать вести разработку с использованием UIKit, но при этом пользоваться Canvas из SwiftUI в UIKit проекте. Поэтому мы написали расширения для классов UIViewController и UIView, дополненные методом livePreview(), который позволяет нам конформить эти два класса к протоколу View из SwiftUI и пользоваться всеми благами Canvas и Hot Reload.

Пример кода с реализацией красного квадрата:

import UIKit
import IBSKit
 
final class ViewController: UIViewController {
    private let squareView: UIView = {
        let view = UIView()
        view.backgroundColor = .red
        view.isUserInteractionEnabled = false
        view.clipsToBounds = true
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
 
    override func viewDidLoad() {
        super.viewDidLoad()
 
        setupViews()
 
        makeLayout()
    }
 
    private func setupViews() {
        view.addSubview(squareView)
    }
 
    private func makeLayout() {
        NSLayoutConstraint.activate([
            squareView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            squareView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            squareView.widthAnchor.constraint(equalToConstant: 100),
            squareView.heightAnchor.constraint(equalToConstant: 100)
        ])
    }
}

#if DEBUG && canImport(SwiftUI)
import SwiftUI
 
@available(iOS 15.0, *)
struct ViewController_Preview: PreviewProvider {
    static var previews: some View {
        ViewController()
            .livePreview()
            .ignoresSafeArea()
    }
}
#endif

Также пример взаимодействия с Live Preview можно посмотреть на более сложных экранах, реализованных в проекте IBSKit Demo, демонстрирующем функциональность фреймворка.

Производительность {проблемы}

И всё бы хорошо, но при запуске Live Preview приходилось ждать больше минуты. Данная проблема возникала при использовании на крупных проектах и была связана с оптимизацией самой IDE в связке с работой нативного рендерера Canvas. С приходом Xcode 14.0 Apple удалось исправить большинство проблем при перерисовке Canvas, где теперь как минимум Canvas обновляется сам и нет необходимости нажимать кнопку Resume. А ещё фреймворк стал поставляться не в виде исходного кода, а в виде бинарника, что сокращало время на «холодную» сборку проекта.

Project Specifications

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

Project Specifications
Project Specifications

Пример обращения к сервису IBSSDK для получения информации о версии фреймворка:

let version = IBSSDK.info.version
let build = IBSSDK.info.build
 
print("SDK Ver. \(version.major).\(version.minor).\(version.patch)") // SDK Ver. 1.1.2
 
print("SDK Build (build)") // SDK Build 11

Hardware

Данный сервис был создан для использования в проектах, разрабатываемых под iPhone. Он позволяет использовать Taptic Engine без использования обёрток для согласования типа устройства, а также по дефолту поддерживает многопоточность. Чтобы заюзать вибротклик, достаточно обратиться к методу:

execute(with: IBSHaptic.FeedbackType)

Например:

IBSHaptic.feedback.execute(with: .success)

Video Player

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

Ряд базовых методов работы с плеером включает:

  • isPlaying() Bool. Позволяет узнать, воспроизводит ли плеер видеоряд;

public func isPlaying() -> Bool {
    guard let player else { return false }
 
    return player.rate > 0
}
  • play() Void. Позволяет начать воспроизведение видеоряда;

public func play() {
    guard
        let player,
        player.currentItem?.status == .readyToPlay
    else { return }
 
    player.play()
    player.rate = rate
}
  • pause() Void. Позволяет поставить видеоряд на паузу;

public func pause() {
    guard let player else { return }
 
    player.pause()
}
  • seekToPosition(seconds:) → Void. Позволяет поставить скраббер на конкретную секунду видеоряда. Можно использовать для реализации перемотки видеоряда.

public func seekToPosition(seconds: Float64) {
    guard let player else { return }
 
    pause()
 
    guard let timeScale = player.currentItem?.asset.duration.timescale else { return }
 
    player.seek(
        to: .init(
            seconds: seconds,
            preferredTimescale: timeScale
        )
    ) { [weak self] _ in
        guard let self else { return }
 
        self.play()
    }
}

Также данный класс включает набор методов делегата для работы с видеоплеером:

// Позволяет узнать прогресс воспроизведения видеоряда.
// Например, когда видеоряд загружается из сети и необходимо знать,
// какой прогресс видеоряда уже был проигран,
// чтобы можно было запросить следующий пакет данных,
// а также закэшировать предыдущий.
func downloadedProgress(with progress: Double)

// Информирует о том, что видеоряд готов к воспроизведению.
func readyToPlay()

// Информирует о том, что прогресс был обновлен.
func didUpdateProgress(with progress: Double, and currentTime: Double?)

// Информирует о том,
// что некоторый переданный видеоряд для воспроизведения
// был успешно воспроизведен.
func didFinishPlayItem()

// Информирует о том,
// что некоторый переданный видеоряд для воспроизведения
// не был успешно воспроизведен.
func didFailPlayToEnd() 

Software

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

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

General Purpose Types

Data Types

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

Structure Types

Structure Types – типы, представляющие собой набор структур, перечислений и т.п. для реализации атрибутов текста, отступов, применения множества стилей, используемых в качестве типов свойств, реализуемых внутри классов UI компонентов.

Design System

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

Components

IBSIndentedLabel

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

IBSCountdownView

Класс, позволяющий задать количество секунд для отсчета и запустить таймер; вьюха выезжает снизу экрана и отсчитывает каждую секунду, при этом исполняя виброотдачу на базе класса, описанного ранее. Включает в себя метод делегата, передающий управление обратно при окончании отсчёта для дальнейших действий. Например, данный класс можно использовать для таймера перед запуском тренировки, как это было реализовано в приложении Nike + iPod во времена iOS 6.

Интересной частью данного класса является метод startTimer(with:), реализующий таймер с передачей управления по завершении, а также сам обработчик таймера handleTimer(timer:) с отсчётом:

public func startTimer(with time: UInt16) {
    estimatedTime = time
 
    let timer = Timer(
        timeInterval: 1.0,
        target: self,
        selector: #selector(self.handleTimer(timer:)),
        userInfo: nil,
        repeats: true
    )
  
    // Переключение режима работы ранлупа
    // для непрерывного выполнения обработчика таймера
    // вне зависимости от взаимодействия с UI интерфейсом iOS приложения.
    RunLoop.current.add(timer, forMode: .common)
}
@objc
private func handleTimer(timer: Timer) {
    countdownLabel.text = "\(estimatedTime)"
 
    if estimatedTime == 0 {
        // Инвалидация таймера по завершении
        timer.invalidate()
 
        IBSHaptic.feedback.execute(with: .success)
      
        // Вызов метода делегата, 
        // в котором необходимо реализовывать логику
        // по окончании работы таймера
        delegate?.countdownDidFinished()
    } else {
        // Отсчет таймера
        estimatedTime -= 1
 
        IBSHaptic.feedback.execute(with: .soft)
    }
}

IBSPlayerView

Класс, являющийся подложкой-представлением для отображения видеоряда для IBSVideoPlayer. Передаётся в качестве параметра в инициализатор IBSVideoPlayer, куда видеоплеер рендерит последовательность видеоряда. Сам класс представляет собой наследника UIView и может быть размещен как subview на superview при помощи фреймов или auto layout.

IBSDonutChart & IBSPieChart

Особенно интересная часть. Необходимость создания кастомного Pie Chart возникла при разработке одного iPad проекта, в котором нужно было вывести большое количество статистической информации – таблицы, графики различных видов, фильтры и т.п. И вот в очередном спринте аналитик и дизайнер обрадовали нас новой задачей.

Необходимо было реализовать график, который:

  • умел бы базово выводить информацию по разным категориям,

  • имел бы графический паттерн для разделения данных категорий между собой,

  • мог бы автоматически закруглять края,

  • был бы способен уменьшать радиус total view,

  • умел бы выводить total text в центре графика.

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

По итогу мы получили такую пару графиков:

IBSPieChart
IBSPieChart
IBSDonutChart
IBSDonutChart

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

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

IBSBlurVisualFX & IBSVibrancyVisualFX

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

IBSBlurVisualFX
IBSBlurVisualFX
IBSVibrancyVisualFX
IBSVibrancyVisualFX

Controllers

IBSSplitSpaceController

Является аналогом класса UISplitViewController от Apple, но с некоторыми улучшениями. Позволяет задавать размер и первоначальное положение сайдбара, а также определяет метод, который свайпом по сайдбару сворачивает и разворачивает его. Метод имеет уровень доступа open и может быть переопределен на проде, либо если необходимы другие поведения при свайпе, либо при нажатии на левую часть Split Space Controller'а.

IBSSplitSpaceController
IBSSplitSpaceController

Заключение

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

Мы планируем расширять функциональность нашего решения и в наших ближайших планах разработать:

  • IBSExtendedTabBarController - аналог UITabBarController от Apple, но с определёнными дополнениями и набором других поведений. Он позволяет независимо от открытой вкладки схлопнуть всё по нажатию на центральную табу или запрезентить некоторый контроллер, не помещая его в основной стек контроллеров. Также у него есть кнопка «назад», которая анимировано появляется и исчезает в левой части таб бара на уровне с иконками вкладок;

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

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

P.S. Наши наработки на Github:

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


  1. rockwavefm
    25.01.2023 13:29

    За Live Preview особенная благодарочка ❤️