iOS Storyboards: анализ плюсов и минусов, best practices

xxgq7wvcgxjuew_bku55csqqvlu.png

Apple создала Storyboards, чтобы разработчики могли визуализировать экраны iOS-приложений и связи между ними. Не всем понравился этот инструмент, и на то есть разумные причины. Я встречал много статей с критикой Storyboards, однако так и не нашел подробного и непредвзятого анализа всех плюсов и минусов с учетом best practices. В итоге я решил написать такую статью сам.
Я постараюсь подробно разобрать недостатки и преимущества использования Storyboards. Взвесив их, вы сможете принять осмысленное решение, нужны они в проекте или нет. Это решение не обязательно должно быть радикальным. Если в одних ситуациях Storyboards создают проблемы, в других — их использование оправдано: помогает эффективно решать поставленные задачи и писать простой, легко поддерживаемый код.

Итак, начнем с недостатков.

Недостатки


1. В Storyboards тяжело править конфликты при слиянии изменений


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

  • Не помещать весь UI в один единственный Storyboard, разделить его на несколько более мелких. Это позволит распределить работу над Storyboards между разработчиками без риска возникновения конфликтов, а в случае их неизбежности — упростит задачу по их разрешению.
  • Если нужно использовать один и то же View в нескольких местах — выделить его в отдельный подкласс с собственным Xib-файлом.
  • Делать коммиты чаще, так как гораздо проще работать с изменениями, поступающими небольшими кусками.


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

2. Storyboards мешают повторному использованию кода


Если речь идет об использовании в проекте только Storyboards без Xibs, то проблемы обязательно возникнут. Однако Xibs, на мой взгляд, — необходимые элементы при работе со Storyboards. Благодаря им можно легко создавать переиспользуемые Views, с которыми также удобно работать и в коде.

Для начала, создадим базовый класс XibView, который отвечает за отрисовку UIView, созданного в Xib, в Storyboard:

@IBDesignable
class XibView: UIView {
	
    var contentView: UIView?
}


XibView будет загружать UIView из Xib в contentView и добавлять его как свой subview. Сделаем это в методе setup():

private func setup() {
    guard let view = loadViewFromNib() else { return }

    view.frame = bounds
    view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    addSubview(view)
    contentView = view
}


Метод loadViewFromNib() выглядит так:

private func loadViewFromNib() -> UIView? {
    let nibName = String(describing: type(of: self))
    let nib = UINib(nibName: nibName, bundle: Bundle(for: XibView.self))
    return nib.instantiate(withOwner: self, options: nil).first as? UIView
}


Метод setup() должен вызываться в инициализаторах:

override init(frame: CGRect) {
    super.init(frame: frame)
    setup()
}

required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
    setup()
}


Класс XibView готов. Переиспользуемые Views, внешний вид которых отрисован в Xib-файле, будут наследоваться от XibView:

final class RedView: XibView {
}


j8aknyaa51sdpr6y8kbh7yjfwvs.png
Если теперь добавить новый UIView в Storyboard и установить его класс в RedView, то все успешно отобразится:
0la-waqvswrxya3gc8clxvro2iw.png
Создание экземпляра RedView в коде происходит обычным образом:

let redView = RedView()


Еще одной полезной деталью, о которой могут не все знать, является возможность добавлять цвета в каталог .xcassets. Это позволяет менять их глобально во всех Storyboards и Xibs, где они используются.

Чтобы добавить цвет, нажимаем »+» внизу слева и выбираем «New Color Set»:
zm3uqiunozwu020zpfckzd_slbo.png
Указываем нужное имя и цвет:
4jveltbjt7fiqalngh6zzg1otr4.png
Созданный цвет появится в разделе «Named Colors»:
kmclakpy0pduyyudog1_daxmx8o.png
Кроме того, его можно получить и в коде:

innerView.backgroundColor = UIColor(named: "BackgroundColor")


3. Нельзя использовать кастомные инициализаторы для UIViewControllers, созданных в Storyboard


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

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    guard segue.identifier == "detail", let detailVC = segue.destination as? DetailViewController else {
        return
    }
    let object = Object()
    detailVC.object = object
}


Этот код можно сделать лучше с помощью каких-нибудь констант для представления идентификаторов или таких инструментов, как SwiftGen и R.swift, а может даже Perform. Но так мы лишь избавляемся от строковых литералов и добавляем синтаксический сахар, а не решаем проблемы, которые возникают:

  • Как узнать, каким образом в примере выше настраивается DetailViewController? Если вы новый на проекте и не обладаете этими знаниями, вам придется открыть файл с описанием этого контроллера и изучить его.
  • Свойства DetailViewController устанавливаются уже после инициализации, значит они должны быть опциональными. Нужно обработать случаи, когда какое-либо свойство равно nil, иначе приложение может упасть в самый неподходящий момент. Можно пометить свойства как неявно развернутые опциональные (var object: Object!), но суть не изменится.
  • Свойства должны быть помечены как var, не let. А значит возможна ситуация, когда кто-то извне захочет их изменить. DetailViewController должен обрабатывать и такие ситуации.


Один из вариантов решения описан в этой статье.

4. По мере роста Storyboard навигация в нем становится все сложнее


Как мы уже отмечали ранее, не нужно помещать все в один Storyboard, лучше разбить его на несколько более мелких. С появлением Storyboard Reference это стало очень просто.
Добавляем Storyboard Reference из библиотеки объектов в Storyboard:
m7v4wcm7xu_chbm7n6j_mmy1ssq.png
Выставляем необходимые значения полей в Attributes Inspector — это имя Storyboard-файла и по необходимости Referenced ID, что соответствует Storyboard ID нужного экрана. По умолчанию будет загружен Initial View Controller:
0j2aeevobxwbcwd4impkwryj1ye.png
Если указать неверное имя в поле Storyboard или сослаться на несуществующий Storyboard ID, Xcode предупредит об этом на этапе компиляции.

5. Xcode тормозит при загрузке Storyboards


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

6. Storyboards хрупкие, ошибка может привести к падению приложения на этапе выполнения


Основные слабые места:

  • Ошибки в идентификаторах UITableViewCell и UICollectionViewCell.
  • Ошибки в идентификаторах segues.
  • Использование подкласса UIView, которого уже не существует.
  • Синхронизация IBActions и IBOutlets с кодом.


Все это и некоторые другие проблемы способны привести к падению приложения на этапе выполнения, а значит есть вероятность, что такие ошибки попадут в релизную сборку. Например, когда мы задаем идентификаторы ячеек или segues в Storyboard, они должны быть скопированы в код везде, где используются. Изменив идентификатор в одном месте, он должен быть изменен во всех остальных. Есть вероятность, что вы просто забудете об этом или сделаете опечатку, но узнаете об ошибке только во время работы приложения.

Можно снизить вероятность ошибки, если избавиться от строковых литералов в коде. Для этого идентификаторам UITableViewCell и UICollectionViewCell можно присваивать названия самих классов ячеек: например, идентификатором ItemTableViewCell будет строка «ItemTableViewCell». В коде достаем ячейку так:

let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ItemTableViewCell.self)) as! ItemTableViewCell


Можно добавить в UITableView соответствующую generic-функцию:

extension UITableView {
	
    open func dequeueReusableCell() -> T where T: UITableViewCell {
        return dequeueReusableCell(withIdentifier: String(describing: T.self)) as! T
    }
}


И тогда получить ячейку становится проще:

let cell: ItemTableViewCell = tableView.dequeueReusableCell()


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

Что касается идентификаторов segues, то для них можно использовать перечисления. Создадим специальный протокол:

protocol SegueHandler {
	
    associatedtype SegueIdentifier: RawRepresentable
}


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

extension StartViewController: SegueHandler {
	
    enum SegueIdentifier: String {
        case signIn, signUp
    }
}


Кроме того, в расширении протокола SegueHandler определим две функции: одна принимает UIStoryboardSegue и возвращает соответствующее ей значение SegueIdentifier, а другая просто вызывает performSegue, принимая на вход SegueIdentifier:

extension SegueHandler where Self: UIViewController, SegueIdentifier.RawValue == String {

    func performSegue(withIdentifier segueIdentifier: SegueIdentifier, sender: AnyObject?) {
        performSegue(withIdentifier: segueIdentifier.rawValue, sender: sender)
    }

    func segueIdentifier(for segue: UIStoryboardSegue) -> SegueIdentifier {
        guard let identifier = segue.identifier, let identifierCase = SegueIdentifier(rawValue: identifier) else {
            fatalError("Invalid segue identifier \(String(describing: segue.identifier)).")
        }
        return identifierCase
    }
}


И теперь в UIViewController, поддерживающем новый протокол, с prepare(for:sender:) можно работать следующим образом:

extension StartViewController: SegueHandler {
	
    enum SegueIdentifier: String {
        case signIn, signUp
    }
	
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        switch segueIdentifier(for: segue) {
        case .signIn:
            print("signIn")
        case .signUp:
            print("signUp")
        }
    }
}


А запускать segue так:

performSegue(withIdentifier: .signIn, sender: nil)


Если добавить новый идентификатор в SegueIdentifier, то Xcode обязательно заставит его обработать в switch/case.

Еще один вариант избавиться от строковых литералов типа идентификаторов segues и других — использовать инструменты кодогенерации наподобие R.swift.

7. Storyboards менее гибкие, в отличие от кода


Да, это действительно так. Если стоит задача создать сложный экран с анимацией и эффектами, с которыми Storyboard не справится, то нужно использовать код!

8. Storyboards не позволяют сменить тип специальных UIViewControllers


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

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


Это Interface Builder и парсер Storyboards. Такие случаи — редкость, и зачастую их можно обойти другими решениями.

10. Сложный code review


Нужно принять во внимание, что code review — это не совсем поиск багов. Да, их находят в процессе просмотра кода, но главной целью является выявление слабых мест, способных создать проблемы в долгосрочной перспективе. Для Storyboards это, в первую очередь, работа Auto Layout. Не должно быть никаких ambiguous и misplaced. Чтобы их найти, достаточно воспользоваться поиском в Storyboard XML по строкам «ambiguous=«YES» и «misplaced=«YES» или просто открыть Storyboard в Interface Builder и искать красные и желтые точки:
u4bwl5psqejad5ziovb2bjxy1eq.png
Однако этого может быть недостаточно. Конфликты между constraints могут выявляться и во время работы приложения. Если подобная ситуация имеет место, информация об этом выводится в консоли. Такие случаи — не редкость, поэтому к их поиску тоже нужно отнестись серьезно.

Все остальное — соответствие положения и размеров элементов с дизайном, корректная привязка IBOutlets и IBActions — не для code review.

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

Итог


В списке недостатков Storyboards я оставил 4 пункта (в порядке убывания их значения):

  1. В Storyboards тяжело править конфликты при слиянии изменений.
  2. Storyboards менее гибкие, в отличие от кода.
  3. Storyboards хрупкие, ошибка может привести к падению приложения на этапе выполнения.
  4. Нельзя использовать кастомные инициализаторы для UIViewControllers, созданных в Storyboard.


Преимущества


1. Визуализация пользовательского интерфейса и constraints


Даже если вы новичок и только взялись за незнакомый проект, то легко найдете точку входа в приложение и как из нее перейти к нужному экрану. Вы знаете, как будет выглядеть каждая кнопка, метка или текстовое поле, какую позицию они будут занимать, как на них влияют constraints, как они взаимодействуют с другими элементами. С помощью нескольких щелчков мыши вы можете легко создать новый UIView, настроить его внешний вид и поведение. Auto Layout позволяет нам работать с UIView естественно, как если бы мы сказали: «Вот эта кнопка должна быть слева от той метки и иметь одинаковую с ней высоту». Такая работа с пользовательским интерфейсом интуитивно понятна и эффективна. Можно попытаться привести примеры, когда грамотно написанный код экономит больше времени при создании каких-то элементов UI, но глобально это мало что меняет. Storyboard хорошо справляется со своей задачей.

Отдельно отметим Auto Layout. Это очень мощный и полезный инструмент, без которого трудно было бы создать приложение, поддерживающее все множество различных размеров экрана. Interface Builder позволяет увидеть результат работы с Auto Layout без запуска приложения, и если какие-то constraints не вписываются в общую схему, Xcode сразу же предупредит об этом. Конечно, существуют случаи, когда Interface Builder не способен обеспечить нужное поведение какого-то очень динамичного и сложного интерфейса, тогда приходится полагаться на код. Но даже в таких ситуациях можно сделать большую часть в Interface Builder и дополнить это лишь парой строчек кода.

Рассмотрим несколько примеров, демонстрирующих полезные возможности Interface Builder.

Динамичные таблицы на основе UIStackView


Создаем новый UIViewController, добавляем UIScrollView на весь экран:
oh-gc88tyx_mhu5zm6iehrnpvrk.png
В UIScrollView добавляем вертикальный UIStackView, привязываем его к краям и устанавливаем высоту и ширину, равную UIScrollView. При этом высоте присвоим priority = Low (250):
hpdvv-gg-hkeh_xy5k2vbbcznr0.png
Далее создаем все необходимые ячейки и добавляем их в UIStackView. Может это будут обычные UIView в единственном экземпляре, а может и переиспользуемые UIView, для которых мы создали свой Xib-файл. В любом случае, весь UI этого экрана — в Storyboard, а благодаря правильно настроенному Auto Layout прокрутка будет работать идеально, подстраиваясь под содержимое:

vgprdl652o0neubp6d87fvcyupk.gif

Мы также можем сделать, чтобы ячейки адаптировались под размеры своего контента. Добавим в каждую ячейку по UILabel, привяжем их к краям:
eiu0ifpkzcudjmimrknducpg5f4.png
Уже понятно, как это все будет выглядеть на этапе выполнения. К ячейкам можно привязать любые действия, например, переход на другой экран. И все это без единой строчки кода.
Более того, если установить hidden = true для UIView из UIStackView, то оно не только скроется, но еще и не будет занимать пространства. UIStackView автоматически пересчитает свои размеры:

b8cschy8jwotcb1fge4ew2ip43s.gif

Self-sizing ячейки


В Size inspector таблицы устанавливаем Row Height = Automatic, а Estimate — в какое-нибудь среднее значение:
p2tpdecyxveakvtukce9maxkrbq.png
Чтобы это работало, в самих ячейках должны быть верно настроены constraints и позволять точно вычислить высоту ячейки на основе содержимого на этапе выполнения. Если не понятно, о чем идет речь, очень хорошее объяснение есть в официальной документации.

В итоге, запустив приложение, мы увидим, что все корректно отображается:
yimb4q-qt5ydm3_3ynqavuvr4ga.png

Self-sizing таблица


Нужно реализовать такое поведение таблицы:

6gppbf-ma4zin0czafyjn3wfj08.gif

Как добиться подобного динамического изменения высоты? В отличие от UILabel, UIButton и других подклассов UIView, с таблицей это сделать немного сложнее, так как Intrinsic Content Size не зависит от размеров ячеек внутри нее. Она не может вычислить свою высоту на основе содержимого, но есть возможность ей в этом помочь.

Заметим, что на видео в какой-то момент высота таблицы перестает меняться, достигая определенного максимального значения. Этого можно добиться, установив у таблицы height constraint со значением Relation = Less Than Or Equal:
xk47rcebixtr42k1ka4wqagftyy.png
На данном этапе Interface Builder еще не знает, какой высоты будет таблица, ему лишь известно ее максимальное значение, равное 200 (из height constraint). Как было отмечено ранее, Intrinsic Content Size не равен содержимому таблицы. Однако у нас есть возможность установить placeholder в поле Intrinsic Size:
tbi7eut1jafonqjknoyabqyev_q.png
Это значение действительно только на время работы с Interface Builder. Безусловно, Intrinsic Content Size не обязан быть равен этому значению во время выполнения. Мы лишь сказали Interface Builder, что все под контролем.

Далее, создаем новый подкласс таблицы CustomTableView:

final class CustomTableView: UITableView {

    override var contentSize: CGSize {
        didSet {
            invalidateIntrinsicContentSize()
        }
    }

    override var intrinsicContentSize: CGSize {
        return contentSize
    }
}


Один из тех случаев, когда код необходим. Здесь мы вызываем invalidateIntrinsicContentSize всегда, когда меняется contentSize таблицы. Это позволит системе принять новое значение Intrinsic Content Size. Оно, в свою очередь, возвращает contentSize, заставляя таблицу динамически регулировать свою высоту и отображать определенное количество ячеек без прокрутки. Прокрутка появляется в тот момент, когда мы достигаем предела height constraint.

Все эти три возможности Interface Builder можно комбинировать друг с другом. Они добавляют больше гибкости в вариантах организации содержимого без необходимости дополнительной настройки constraints или каких-либо UIView.

2. Возможность мгновенно увидеть результат своих действий


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

Использование @IBInspectable раскрывает это преимущество еще интереснее. Добавим к RedView два UILabel и два свойства:

final class RedView: XibView {

    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var subtitleLabel: UILabel!

    @IBInspectable var title: String = "" { didSet { titleLabel.text = title } }
    @IBInspectable var subtitle: String = "" { didSet { subtitleLabel.text = subtitle } }
}


В Attributes Inspector для RedView появится два новых поля — Title и Subtitle, которые мы пометили как @IBInspectable:
wvrhnnegpjpyo7zrj7857dlujom.png
Если мы попробуем ввести значения в эти поля, то сразу же увидим, как все будет выглядеть на этапе выполнения:

hnbd6urq0ebbpghnuiaueovz38i.gif

Можно управлять чем угодно: cornerRadius, borderWidth, borderColor. Например, расширим базовый класс UIView:

extension UIView {

    @IBInspectable var cornerRadius: CGFloat {
        set { layer.cornerRadius = newValue }
        get { return layer.cornerRadius }	
    }

    @IBInspectable var borderWidth: CGFloat {
        set { layer.borderWidth = newValue }
        get { return layer.borderWidth }
    }

    @IBInspectable var borderColor: UIColor? {
        set { layer.borderColor = newValue?.cgColor }
        get { return layer.borderColor != nil ? UIColor(cgColor: layer.borderColor!) : nil }
    }

    @IBInspectable var rotate: CGFloat {
        set { transform = CGAffineTransform(rotationAngle: newValue * .pi/180) }
        get { return 0 }
    }
}


Видим, что Attributes Inspector объекта RedView обзавелся еще 4-мя новыми полями, с которыми теперь тоже можно поиграться:

gqibbmd2an0a4zezig2iuifx5tm.gif

3. Предварительный просмотр всех размеров экрана одновременно


Вот мы закинули необходимые элементы на экран, настроили их вид и добавили нужные constraints. Как нам выяснить, будет ли содержимое корректно отображаться на разных размерах экрана? Конечно, можно запустить приложение на каждом симуляторе, но это займет немало времени. Существует вариант получше: у Xcode есть режим предварительного просмотра, он позволяет увидеть сразу несколько размеров экрана одновременно без запуска приложения.

Вызываем Assistant editor, в нем нажимаем на первый сегмент панели переходов, выбираем Preview –> Settings.storyboard (как пример):
awmgnrb4ajxr49hrygbe70fz5is.png
Сначала мы видим лишь один экран, но можем добавить столько, сколько нужно, нажав »+» в левом нижнем углу и выбрав необходимые устройства из списка:
183feokkgdpycsxlcr3a1upo8jk.png
Кроме того, если Storyboard поддерживает несколько языков, можно посмотреть, как будет выглядеть выбранный экран с каждым из них:
hynf6gc-nzhc30fxq0ir2a2uzcc.png
Язык можно выбрать как для всех экранов сразу, так и для каждого по отдельности.

4. Удаление шаблонного UI кода


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

5. Size classes


Каждый год появляются новые устройства, под которые нужно адаптировать пользовательский интерфейс. В этом помогают концепции trait variations и, в частности, size classes, которые позволяют создавать UI под любые варианты размеров и ориентации экрана.

Size classes классифицируют высоту (h) и ширину (w) экранов устройств в терминах compact и regular (C и R). Например, iPhone 8 имеет size class (wC hR) в портретной ориентации и (wC hC) — в альбомной, а iPhone 8 Plus — (wC hR) и (wR hC) соответственно. По остальным девайсам можно узнать здесь.

В одном Storyboard или Xib для каждого из size classes можно хранить свой набор данных, а приложение уже на этапе выполнения будет использовать подходящий в зависимости от устройства и ориентации экрана, идентифицируя таким образом текущий size class. Если какие-то параметры макета одинаковы для всех size classes, то их можно настроить в категории »Any», которые уже выбрана по умолчанию.

Например, настроим размер шрифта в зависимости от size class. Выберем для просмотра в Storyboard устройство iPhone 8 Plus в портретной ориентации и добавим новое условие для font: если width — Regular (все остальное устанавливаем в «Any»), то размер шрифта должен быть равен 37:
bdbs-7i602wtlxemns-hm3jz3ps.png
Теперь, если мы поменяем ориентацию экрана, размер шрифта увеличится — сработает новое условие, так как в альбомной ориентации iPhone 8 Plus имеет size class (wR hC). В Storyboard в зависимости от size class можно также скрывать Views, включать/отключать constraints, менять их значение constant и многое другое. Подробнее о том, как все это делать, можно прочитать здесь.

На скриншоте выше стоит еще отметить нижнюю панель с выбором устройства для отображения макета. Она позволяет быстро проверить адаптивность UI на любом устройстве и при любой ориентации экрана, а также показывает size class текущей конфигурации (рядом с названием устройства). Помимо прочего, справа есть кнопка »Vary for Traits». Ее цель в том, чтобы включить trait variations только для определенной категории ширины, высоты или ширины и высоты одновременно. Например, выбрав iPad с size class (wR hR), нажимаем «Vary for Traits» и ставим галочки напротив width и height. Теперь все последующие изменения макета будут применяться только к устройствам с (wR hR), пока мы не нажмем »Done Varying».

Заключение

Мы увидели, что Storyboards имеют свои сильные и слабые стороны. Мое мнение — не стоит полностью отказываться от их использования. При правильном применении они приносят огромную пользу и помогают эффективно решать поставленные задачи. Нужно лишь научиться расставлять приоритеты и забыть аргументы типа «мне не нравятся Storyboards» или «я привык так делать».

© Habrahabr.ru