Привет, я Сергей, iOS-разработчик в ЮKassa, занимаюсь ее мобильным приложением. Недавно в нем появился просмотр аналитики по счетам и платежам. И сегодня я расскажу, как мы реализовали эту возможность, а еще — зачем и как разработали собственный фреймворк для создания графиков.
Немного об аналитике в ЮKassa
Аналитика — одна из важнейших функций нашего сервиса. Она помогает оценить ключевые показатели магазина: количество свободных денег, маржу, число возвратов и, например, средний чек.
В нашей аналитике большое значение имеют графики. Они позволяют наглядно отобразить изменения разных показателей. У такого графика на оси абсцисс расположены даты за выбранный период, на оси ординат — значения нужного показателя. График строится по точкам, где каждой дате соответствует конкретное значение. Благодаря этому можно посмотреть, например, сколько магазин заработал за 4 октября 2020.
Что мы хотели получить
Наша разработка началась с создания концепт-дизайна. Мы решили, что в приложении должна быть возможность выбора периода и типа аналитики — к примеру, по выручке или среднему чеку. Мы определились сразу, как сделать карусель для типов аналитики и кнопки для периодов, — через CollectionView и StackView соответственно. А вот как нарисовать сам график и сделать так, чтобы им можно было управлять жестами, — эти задачи оказалась труднее.
Еще нужно было, чтобы график менялся в соответствии с выбранными параметрами. Допустим, для периода за день нужно показать только одну точку по центру, а для двух дней и больше — кривую линию, соединяющую набор точек.
С бэкенда нам могут прийти данные для аналитики за три года с интервалом в день. Поэтому за раз мы должны уметь отобразить чуть больше 1000 точек на графике и дать пользователю возможность свободно взаимодействовать с ним — без подвисаний и глюков при стабильных 60 кадрах в секунду.
Что мы попробовали
Для создания графиков мы попробовали три фреймворка. Это самые популярные открытые решения — Charts и SwiftCharts, а еще платное — SciChart. Для нас важнее всего было не наличие большого количества функций, а возможность рисования графиков нужным образом и высокая производительность.
Кастомизация | Отображение | Анимация | Производи-тельность | |
Charts | Отсутствует отрисовка | Есть всё необходимое | Отсутствует анимация перерисовки | Просадки при более чем 500 точек |
SwiftCharts | Отсутствует отрисовка | Некорректное отображение дат на оси X | Отсутствует анимация перерисовки и смены графиков | Просадки при более чем 800 точек |
SciChart | Отсутствует отрисовка | Есть всё необходимое | Есть всё необходимое | Отличная оптимизация |
Должен сказать, что все три фреймворка не умели отображать только одно значение и размещать его по центру. А вот различий у них было гораздо больше.
С помощью Charts удалось добиться правильного отображения графика, но не получилось задать кастомную анимацию при переключении между графиками. К тому же при количестве значений 500+ на графике были существенные просадки частоты кадров.
В SwiftCharts тоже было невозможно задать кастомную анимацию при переключении между графиками. Еще отсутствовала анимация при перерисовке — взаимодействии с графиком жестами. Некорректно отображались даты на оси X. И при числе значений 800+ тоже заметно проседала частота кадров.
А вот SciChart объединял в себе почти все нужные нам функции, да еще и отличался хорошей производительностью.
Этот фреймворк очень крут, потому что рендерит графики с помощью 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.
Как все это работает
Думаю, теперь стоит отдельно поговорить об экстремумах графика — минимальном и максимальном значениях, которые важны при нахождении Y-координаты для любой точки. В нашем фреймворке можно задать три типа экстремумов. Я знаю, что не совсем корректно называть экстремумом минимум или максимум в каком-либо наборе значений, но все равно делаю это для простоты изложения.
Первый тип — статичные экстремумы. Они вычисляются при создании графика и дальше не меняются. При изменении видимой области они не пересчитываются, поэтому график не растягивается и не сжимается по вертикали.
Второй тип — произвольные экстремумы.
Здесь как со статичными экстремумами, разница лишь в том, что мы не вычисляем экстремумы, а сами инициализируем их произвольными числами. Например, максимальное значение равно «5», а минимальное — «1», но так как произвольные максимум и минимум равны — «10» и «-5» соответственно, то график сжимается по вертикали.
И последний тип — динамические экстремумы.
В этом случае минимальное и максимальное значения вычисляются при каждом изменении видимой области. При динамических экстремумах минимум и максимум можно находить, просто перебирая всю видимую область значений при изменении левой или правой границы. Например, так реализован алгоритм в фреймворках Charts и SwiftChart. Но это не оптимально — и можно ускорить процесс.
С учетом всего этого мы разработали алгоритм, который работает следующим образом.
Если из видимой области исключаются точки (например, при приближении графика), то проверяем, есть ли среди них текущие экстремумы. Если нет, ничего не делаем, а если есть, находим новые экстремумы в видимой области и заменяем ими текущие.
Если в видимую область добавляются новые точки (допустим, при отдалении графика), то проверяем, есть ли среди них такие, значения которых больше текущих экстремумов. Если нет, ничего не делаем, а если есть, меняем текущие экстремумы на только что найденные новые значения.
Итак, мы научились находить координаты точек. Осталось соединить их линией, отобразить и добавить градиент. Для формирования линии создаем кривую Безье из вычисленных координат для каждой точки. Затем добавляем кривую в SKShapeNode — это математическая фигура из SpriteKit, которую можно обводить или красить заливкой. Градиент создаем с помощью SKSpriteNode — объекта в SpriteKit, который можно залить любым цветом или градиентом. Маску для градиента делаем через SKCropNode.
Другой важный момент — в фреймворке доступно три типа отображения графика.
Линейное. Кривая не сглаживается.
Квадратичное. Есть сглаживание кривой, но точки могут не соответствовать своим значениям — для большей плавности графика. Этот вариант подходит для визуализации, где точность данных не принципиальна.
Горизонтально-квадратичное. Кривая сглаживается, и все точки соответствуют своим значениям.
Техника жестов и никакой магии
После разработки алгоритма для нахождения точек координат мы приступили к реализации управления жестами. Для этого внедрили в фреймворк три типа жестов.
Первый — долгое зажатие (он же 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 можно плавно перенести вьюшку в нужную позицию.
Что получилось в итоге
Перед отображением двух графиков с 1000 точек Charts зависает на несколько секунд, а при работе с графиком выдает всего лишь около 20 кадров/сек. SwiftCharts справляется лучше и выдает около 40 кадров/сек. Наш фреймворк, который мы создали за две недели и назвали AnalyticsChart, выдает стабильные 50-60 кадров/сек. — при работе с графиком в iOS-приложении как на iPhone (XS Max), так и на iPad. К тому же наш фреймворк соответствует нужному нам дизайну, позволяет отображать и взаимодействовать с одним и больше графиками.
И еще большущий плюс — мы понимаем, как устроен наш фреймворк под капотом. Это позволяет нам в дальнейшем кастомизировать его любым образом и быстро добавлять в него новые возможности.
Конечный результат наших стараний и производительность для двух графиков с 1000 точек:
Расскажите про ваш опыт создания фреймворков для графиков. Или — если есть вопросы, задавайте их в комментариях.