Примечание. Эта статья является дополнением к статье Дебаггинг приложения без Xcode. Зачем?

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

  • решает реальные проблемы нашей команды, упрощая ежедневную работу;

  • укладывается в четыре выделенных технических дня — наш формат работы предполагает выбор трех задач на квартал и их решение в течение 20% рабочего времени;

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

Среди технических задач мое внимание привлекла задача по визуализации иерархии элементов интерфейса. Она казалась мне идеальной: помимо очевидной пользы для разработчиков, которым не всегда удобно работать с View Hierarchy в Xcode (в некоторых случаях, изображения у вью отсутствуют), и невозможно при сборках через AppCenter или TestFlight, задача обещала интересные вызовы — возможность поработать с SceneKit. К тому же, наличие открытых библиотек, решающих подобные задачи, предполагало легкость интеграции в наш проект.

Главный экран во View Hierarchy
Главный экран во View Hierarchy

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

Изучаем существующие решения

Начал я с изучения существующих библиотек и остановился на:

  • Flex — наверное, самая популярная библиотека для дебага на iOS.

  • InAppViewDebugger — библиотека как раз про мою фичу: много звездочек на GitHub, и в Readme Flex-а говорится о ней, как об одном из референсов.

  • Glance — не такая популярная библиотека, но со схожим функционалом.

    Ожидаемый план работ
    Ожидаемый план работ

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

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

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

  • Во-вторых — множество артефактов. В общем и целом видно, что всё не очень хорошо.

  • И, в-третьих, барабанная дробь… приложение крашится!

Артефакты во Flex
Артефакты во Flex

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

Артефакты в InAppViewDebugger и Glance
Артефакты в InAppViewDebugger и Glance

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

View слой

Решил пойти от простого к сложному и начать работы с вью слоя — библиотеки не имели проблем с ним и решение тут позаимствовано. Для него используем SceneKit.

SceneKit — это высокоуровневый фреймворк для 3D-графики, который позволяет нам создавать 3D сцены. Работает поверх Metal-а и его преимущества перед использованием в первозданном виде заключается в простоте — не нужно погружаться ни в какие низкоуровневые API, всё предельно упрощено и понятно.

Небольшая историческая справка:
  • SceneKit был представлен на macOS в 2012.

  • В 2014 Apple портировали его на iOS.

  • К моменту анонса ARKit-а в 2017 был достаточно развит для использования их в паре.

Итак, начнем настройку нашей вью.

Первое, что надо сделать — создать экземпляры SCNScene и SCNView.

private let sceneView = SCNView()
private let scene = SCNScene()
...
addSubview(sceneView)
...
sceneView.allowsCameraControl = true
sceneView.scene = scene

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

SCNView — это вьюшка, которая может работать со сценой. Добавляем её в иерархию и включаем параметр allowsCameraControl — он позволяет менять положение камеры с помощью жестов — и присваиваем экземпляр сцены во вью.

Теперь настроим ноду камеры.

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

private let cameraNode = SCNNode()
cameraNode.camera = SCNCamera()
scene.rootNode.addChildNode(cameraNode)

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

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

  • изображение вью — снапшот;

  • глубина — положение вью по z-оси;

  • размер и положение — для этого используем фрейм;

  • ссылка на исходную вью для дополнительных действий (открытие деталей).

Итого, вью-модель будет выглядеть так: 

enum VisualDebugger {
    enum PresentModuleData {
        struct ViewModel: Equatable {
            let layoutModels: [LayoutModel]
        }
    }

    struct LayoutModel: Equatable {
        let image: UIImage
        let depth: Float
        let frame: CGRect
        let view: UIView
    }
}

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

Делать мы будем это c помощью следующей функции:

func addChildNode(for model: VisualDebugger.LayoutModel) {
    let geometry = SCNPlane(
        width: model.frame.size.width / сoefficient,
        height: model.frame.size.height / сoefficient
    )
    let material = SCNMaterial()
    material.isDoubleSided = true
    material.diffuse.contents = model.image
    geometry.materials = [material]
    let geometryNode = SCNNode(geometry: geometry)
    geometryNode.position = calculatePosition(
       for: model,
       spacing: appearance.defaultZSpacing
    )
    geometryNode.accessibilityLabel = makeID(for: model.view)
    childNodes.append(geometryNode)
    scene.rootNode.addChildNode(geometryNode)
}

Первым делом создаем геометрию — описание 3D-фигуры нода. Мы используем SCNPlane — это прямоугольник, которому мы задаем ширину и высоту. Также SceneKit позволяет создавать простые объемные фигуры типа шара, куба, цилиндра и импортировать сложные 3D-модели из внешних редакторов.

Объект есть. А из какого он материала? Для присвоения материала для нашей геометрии берём SCNMaterial — набор свойств, описывающих то, как будет выглядеть наша геометрия после рендеринга.

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

Также SceneKit позволяет настроить metalness и roughness — свойства, определяющие то, насколько материал будет похож на металл и то, насколько у него будет насколько ровная поверхность, но в рамках нашей фичи мы не будем этого делать — не особо и нужно :)

Далее создадим ноду на базе геометрии и рассчитаем для неё позицию на базе фрейма вьюшки. При расчете позиции ноды стоит учитывать, что ось координат в SceneKit отличается от той, которую мы привыкли видеть в UIKit — ось Y инвертирована, а позиция нашей геометрии считается от её середины.

И перегонять фрейм в позицию ноды (SCNVector3) мы будем с помощью следующей функции:

func calculatePosition(
    for model: VisualDebugger.LayoutModel, 
    spacing: Float
) -> SCNVector3 {
    SCNVector3(
        x: Float(model.frame.midX / сoefficient),
        y: Float(model.frame.midY.negative / сoefficient),
        z: model.depth * spacing
     )
}

Здесь же присвоим accessabilityLabel для ноды, необходимый для поиска ассоциированной вью модели при тапе. А тапы реализовываем простым навешиванием gestureRecognizer на sceneView и вызовом hitTest для sceneView, который будет выдавать нужную ноду.

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

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

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

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

Готовим данные для показа

Дисклеймер! Некоторые фрагменты кода могут вас шокировать и может появиться непреодолимое желание что-то отрефакторить или сказать — не по ярку. Прошу отнестись с пониманием и помнить, что на задачу изначально отводилось только 4 дня.

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

  • Те вью, что мы не видим, не должны отображаться и наоборот. Это значит, что вью не должна отображается если isHidden == true, alpha == 0 и вью не влезает в область видимости рутовой вью (это та вью, для которой мы строим граф).

  • Если сама вью не отображается, но у нее есть сабвью, выходящая за ее баундс, и при этом у неёclipsToBounds == false, то сабвью мы уже должны отрисовать.

  • Те части вью, что мы не видим, должны обрезаться.

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

Провайдер данных

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

func getViewHierarchy() ->[VisualDebugger.LayoutModel] {
    var depth: Float = 0
    return getLayoutModelsRecursively(for: rootView, rootView: rootView, depth: &depth)
}

func getLayoutModelsRecursively(
    for view: UIView,
    rootView: UIView,
    depth: inout Float
) -> [VisualDebugger.LayoutModel] {
    guard checkValidity(of: view) else { return [] }
    var result: [VisualDebugger.LayoutModel] = []
    layoutModelBuilder.buildLayoutModel(for: view, in: rootView, depth: depth).flatMap {
        result.append($0)
    }
    ...
}

func checkValidity(of view: UIView) -> Bool {
    !filteredViewsTags.contains(view.tag) && !view.isHidden && view.alpha >= 0.01
}
  • Сначала мы проверяем, нужно ли нам отобразить эту вью — это необходимо, поскольку нужно отфильтровывать вью, которые нужно дебажить, от вьюшек запрезенченного DebugMenu.

  • Проверяем, не скрыта ли вью и не равна ли нулю альфа.

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

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

  • При этом смотрим, пересекается ли фрейм текущей сабвью с теми, что уже есть в массиве, из чего определяем, какую глубину использовать для текущего child-a — текущую для вью + 1, либо максимальную + 1.

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

Смешались рекурсия и inout параметры — лучшие практики «понятного» кода. В общем, код довольно сложен в понимании…

func getLayoutModelsRecursively(
    for view: UIView,
    rootView: UIView,
    depth: inout Float
) -> [VisualDebugger.LayoutModel] {
    guard checkValidity(of: view) else { return [] }
    var result: [VisualDebugger.LayoutModel] = []
    layoutModelBuilder.buildLayoutModel(for: view, in: rootView, depth: depth).flatMap {
        result.append($0)
    }
    var subviewsFrames: [CGRect] = []
    var maxChildDepth = depth
    view.subviews.forEach { subview in
        var childDepth = subviewsFrames.contains(where: { $0.intersects(subview.frame) })
            ? maxChildDepth + Configuration.depthSpacing
            : depth + Configuration.depthSpacing
        let subviewLayoutModels = getLayoutModelsRecursively(
            for: subview,
            rootView: rootView,
            depth: &childDepth
        )
        subviewsFrames.append(subview.frame)
        maxChildDepth = max(maxChildDepth, childDepth)
        result.append(contentsOf: subviewLayoutModels)
    }
    depth = maxChildDepth
    return result
}

Но, кажется, теперь у нас есть всё, чтобы спокойно сделать модельку.

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

func buildLayoutModel(
    for view: UIView,
    in rootView: UIView,
    depth: Float
) -> VisualDebugger.LayoutModel? {
    guard let visibleFrameInRootView = visibleFrame(for: view, in: rootView) else {
        return nil
    }
    let snapshot = makeSnapshot(
        of: view,
        in: rootView,
        frameInRootView: view.superview?.convert(view.frame, to: rootView),
        visibleFrameInRootView: visibleFrameInRootView
    )
    return snapshot.flatMap {
        VisualDebugger.LayoutModel(
            image: $0,
            depth: depth,
            frame: visibleFrameInRootView,
            view: view
        )
    }
}

Но теперь возникает очередная проблема.

  • Нам нужно перегнать фрейм вью в систему координат корневой вью.

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

И в этом нам вновь помогает рекурсия.

С помощью нижележащей функции мы идем от рассматриваемой вью до корневой, конвертируем фрейм в следующую по иерархии вью, учитываем пересечения с текущей и учитываем clipsToBounds — если он выключен, то нам, в целом, не важно, пересекается ли вью с текущей супервью или нет.

/// Видимый фрейм вью в рут вью
func visibleFrame(for view: UIView, in rootView: UIView) -> CGRect? {
    visibleFrame(
        currentIntersection: view.bounds,
        currentSuperview: view,
        rootView: rootView
    )
}

/// - Parameters:
///   - currentIntersection: Пересечение интересующей нас вью с currentSuperview
///   - currentSuperview: Текущая супервью, относительно конторой в текущей итерации рассчитано пресечение
///   - rootView: Вью, относительно которой рассчитываем фрейм
/// - Returns: Видимый фрейм в nextSuperview. Может не являться переceчением, если nextView.clipsToBounds == false
func visibleFrame(
    currentIntersection: CGRect,
    currentSuperview: UIView,
    rootView: UIView
) -> CGRect? {
    guard let nextSuperview = currentSuperview.superview, currentSuperview !== rootView else {
        return currentIntersection
    }

    let frameInNextSuperview = nextSuperview.convert(currentIntersection, from: currentSuperview)
    let intersectionWithNextSuperview = frameInNextSuperview.intersection(nextSuperview.bounds)
    var nextIntersection: CGRect?

    if !nextSuperview.clipsToBounds {
        nextIntersection = frameInNextSuperview
    } else if isIntersectionValid(intersectionWithNextSuperview) {
        nextIntersection = intersectionWithNextSuperview
    }

    return nextIntersection.flatMap { nextIntersection in
        return visibleFrame(
            currentIntersection: nextIntersection,
            currentSuperview: nextSuperview,
            rootView: rootView
        )
    }
}


func isIntersectionValid(_ frame: CGRect) -> Bool {
    !frame.isNull && frame.size.width > 0 && frame.size.height > 0
}

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

func makeSnapshot(
    of view: UIView,
    in rootView: UIView,
    frameInRootView: CGRect?,
    visibleFrameInRootView: CGRect
) -> UIImage? {
    var snapshot: UIImage? = makeSnapshot(of: view)
    // Если контент не влезает в экран - обрезаем снапшот под его видимую часть
    if let frameInRootView, visibleFrameInRootView.size != frameInRootView.size {
        let cropRect = rootView.convert(visibleFrameInRootView, to: view)
        snapshot = snapshot.flatMap { cropImage($0, to: cropRect) }
    }
    return snapshot
}

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

И как же мы это сделаем?

Мы скроем все сабвьюхи! До создания снапшота. А после вернем isHidden на место.

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

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

Решили скрытием только visibleCells в коллекции.

func makeSnapshot(of view: UIView) -> UIImage {
    var subviewsHidden: [Bool] = []
    let subviews = makeFilteredSubviews(of: view) 
    subviewsHidden.reserveCapacity(subviews.count)
    for subview in subviews {
        subviewsHidden.append(subview.isHidden)
        subview.isHidden = true
    }
    let snapshot = drawView(view)
    for (subview, isHidden) in zip(subviews, subviewsHidden) {
        subview.isHidden = isHidden
    }
    return snapshot
}

func drawView(_ view: UIView) -> UIImage {
    UIGraphicsImageRenderer(bounds: view.bounds).image { rendererContext in
        view.layer.render(in: rendererContext.cgContext)
    }
}

func makeFilteredSubviews(of view: UIView) -> [UIView] {
    guard let collectionView = view as? UICollectionView else {
        return view.subviews
    }
    let visibleCells = collectionView.visibleCells
    let subviews = collectionView.subviews.filter { !($0 is UICollectionViewCell) }
    return subviews + visibleCells
}

Решаем оставшиеся проблемы

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

Но как бы не так!

Решив запустить и потестить свою сборку, я обнаружил:

  • Фризы при подготовке данных.

  • Краши! С выводом тех же логов, что и для тестируемых библиотек.

  • Отображение лишних вью в графе.

Решаем долгую загрузку

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

Но решение довольно простое — я обернул всё в Promise (на проекте мы повсеместно используем PromiseKit)! Это равносильно тому, чтобы вызывать блок кода в DispatchQueue.main.async.

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

Теперь у пользователей не возникнет ощущения «что-то не то» и они, вероятно, не заметят проблемы.

func getViewHierarchy() -> Promise<[VisualDebugger.LayoutModel]> {
    Promise { [weak self] resolver in
        guard let self, let rootView = self.rootView else {
            resolver.fulfill([])
            return
        }
        var depth: Float = Configuration.startDepth
        let result = self.getLayoutModelsRecursively(for: rootView, rootView: rootView, depth: &depth)
        resolver.fulfill(result)
    }
}

Избавляемся от крашей

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

-[MTLTextureDescriptorInternalvalidateWithDevice:], line 1325: error 'Texture Descriptor Validation MTLTextureDescriptor has height (8334) greater than the maximum allowed size of 8192'

Эта ошибка летит к нам прямиком из библиотеки Metal, поверх которой работает SceneKit. И вещает она нам о том, что размер той текстуры, которую мы присваиваем в материал, превышает максимальное значение.

Найдя тред на developer.apple, я вышел на следующую таблицу, в которой говорится, что GPUFamily — это группа устройств, поддерживающих определенные фичи Metal-a, и определяется эта группа по процессору, а максимальный размер текстуры — это одна из фичей.

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

struct VisualDebuggerViewImageReducer: ReducesVisualDebuggerViewImage {
    func reduceImageIfNeeded(_ image: UIImage) -> UIImage {
        let scaledHeight = image.size.height * image.scale
        let scaledWidth = image.size.width * image.scale
        guard scaledHeight > 8_192 || scaledWidth > 8_192 else { return image }
        let scaleFactor = min(8_192 / scaledWidth, 8_192 / scaledHeight)
        let newSize = CGSize(
            width: image.size.width * scaleFactor,
            height: image.size.height * scaleFactor
        )
        return UIGraphicsImageRenderer(size: newSize).image { _ in
            image.draw(in: CGRect(origin: .zero, size: newSize))
        }
    }
}

Лишние вью в иерархии

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

Мы используем RouteComposer, поэтому получилось исправить этот момент с помощью использования PostRoutingTask для роута ведущего на DebugMenu, где CompletionPostRoutingTask — это замыкание, которое выполняется после того, как роутинг отработает.

return SharedRouter.Route { completion in
    try? RCRouter().navigate(
        to: StepAssembly(
            finder: DebugMenuListFactory<Self>.DefaultFinderByClassName(), factory: DebugMenuListFactory<Self>()
        )
        .adding(
            ClosePostRoutingTask<DebugMenuListFactory<Self>.ViewController, DebugMenuListFactory<Self>.Context>(
                closeBarButtonItemSide: .right
            )
        )
        .adding(
            CompletionPostRoutingTask<DebugMenuListFactory<Self>.ViewController, DebugMenuListFactory<Self>.Context> {
                // Устанавливаем тег на вью для фильтрации дебаг меню в иерархии вьюх
                $0.navigationController?.presentationController?.containerView?.tag = rootDebugMenuViewTag
            }
        )
        .wrapAlertInContainer(
            wrapperFactory: AlfaNavigationControllerFactory {
                $0.additionalSafeAreaInsets = CustomScreenSizeDataStore.shared.currentAdditionalSafeAreaInsets
                // Устанавливаем тег на вью для фильтрации дебаг меню в иерархии вьюх
                $0.view.tag = rootDebugMenuViewTag
            }
        )
        .using(GeneralAction.presentModally(presentationStyle: .overFullScreen))
        .from(GeneralStep.current()).assemble(),
        with: .init(screenTitle: title, navigationElements: .title, sectionProviders: currentSectionProviders),
        completion: completion
    )
}

Заключение

Пришло время подвести итоги.

Использование FLEX и подобных библиотек для отладки упрощает и ускоряет разработку, но это не универсальное решение для всех задач.

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

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

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