
Привет, Хабровчане!
Меня зовут Дмитрий Новиков, я – разработчик департамента разработки корпоративных решений в 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*.
Таким образом мы убиваем сразу нескольких зайцев:
Фреймворк поставляется в виде Binary Target внутри SPM пакета, что позволяет экономить время на сборке проекта, к которому прилинкован фреймворк;
SPM не тратит время на линковку зависимостей и их индексацию, в частности при большом количестве исходных файлов на один пакедж;
Мы можем запаковать сразу несколько архитектур внутри одного пространства имён, чтобы иметь поддержку как iOS и iPadOS устройств, так и симуляторов для x86_64 и arm64 архитектур.
* Xcode Fat Framework: фреймворк поставляется в виде двоичного файла, тем самым упрощая сборку проектов, в которых он используется, и обеспечивая поддержку необходимого набора архитектур как для реальных устройств, так и для симуляторов под платформы Apple и Intel.
В итоге мы сели, прикинули набор необходимых фич, которые хотелось вынести в отдельные сущности, и получили законченный и готовый к использованию SDK с необходимым набором функционала.
Фреймворк включает следующую функциональность:
-
Device Specification
Live Preview
Project Specifications
-
Haptic Feedback
Video Player
-
-
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, например, для отладки приложения на этапе тестирования, чтобы было понимание, какие версия и билд используются в приложении на момент теста. Для этих целей были созданы пара классов, также конформящихся к протоколам и реализующих функциональность по получению версии и номера сборки.

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


Сами графики очень гибки в настройке и очень просты в использовании; например, чтобы скруглить отображение секций, достаточно просто указать Cap Style, выбрав нужное значение из перечисленных. Данные графики хорошо подойдут, если вам в проекте необходимо вывести множество статистической информации и при этом как-то графически отделять между собой эту информацию в виде паттернов.
При разработке этих графиков мы задались вопросом, как их лучше применять на практике. До их разработки в нашем проекте лежало порядка 2–3 сторонних библиотек, морально устаревших, неподдерживаемых долгое время, а также не имеющих такую гибкую систему кастомизации. Реализовав набор из пары пайчартов, мы избавились от лишних зависимостей, и у нас появилась возможность самим определять дальнейшую функциональность графиков, а также расширять её.
IBSBlurVisualFX & IBSVibrancyVisualFX
Первый класс позволяет задавать степень размытия своего слоя. Второй – упрощает работу с Vibrancy Effect для добавляемого набора отображений и подкапотно использует класс IBSBlurVisualFX, также позволяющий задавать степень размытия добавленных отображений.


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

Заключение
Во время подготовки к выпуску нашего фреймворка мы получили ценный практический опыт создания необходимой функциональности и разработки удобного API для использования в сторонних приложениях. Разумеется, некоторые из реализованных нами фич уже есть в сторонних фреймворках, однако не всегда удобно подключать десяток зависимостей к проекту и следить за актуальностью каждого из них.
Мы планируем расширять функциональность нашего решения и в наших ближайших планах разработать:
IBSExtendedTabBarController - аналог UITabBarController от Apple, но с определёнными дополнениями и набором других поведений. Он позволяет независимо от открытой вкладки схлопнуть всё по нажатию на центральную табу или запрезентить некоторый контроллер, не помещая его в основной стек контроллеров. Также у него есть кнопка «назад», которая анимировано появляется и исчезает в левой части таб бара на уровне с иконками вкладок;
IBSLogger - аналог большинства сервисов по логированию данных как на устройстве, так и с доступом в сеть, с удобным API для работы, оптимальным временем чтения и записи логов в память, а также оптимизированный под носимые устройства по уровню потребления ресурсов.
Спасибо за внимание.
P.S. Наши наработки на Github:
rockwavefm
За Live Preview особенная благодарочка ❤️