[Перевод] Правила компоновки во Flutter, которые должен знать каждый

k6varih2f-scv4jc6ipe2tyx2rk.jpeg

Когда новичок во Flutter спрашивает, почему какой-то виджет с width: 100 не ширины 100 пикселей, обычно ему отвечают, что надо обернуть этот виджет в Center, верно?

Не надо так делать

Если так отвечать, то к вам будут возвращаться снова и снова, спрашивая, почему какой-то FittedBox не работает, почему этот Column переполнен или как работает IntrinsicWidth.

Сначала объясните, что Flutter компоновка очень отличается от HTML компоновки (особенно, если говорите с веб-разработчиком), а затем скажите, что необходимо запомнить следующее правило:


Ограничения для виджетов объявляются в родителях. Размеры (желаемые) задаются в самом виджете. Позиция виджета на экране устанавливается родителем

На мой взгляд, это правило нужно изучить, как можно раньше, так как без него по-настоящему понять компоновку во Flutter нельзя.

Более детально:


  • Виджет получает свои ограничения от своего родителя. «Ограничение» — это всего 4 значения: минимальная и максимальная ширина, минимальная и максимальная высота.
  • Затем виджет проходит по своему списку детей. Виджет сообщает своим дочерним элементам, каковы их ограничения (которые могут быть разными для каждого ребенка), а затем спрашивает каждого ребенка, какого размера он «хочет» быть.
  • Затем виджет размещает свои дочерние элементы (горизонтально по оси x и вертикально по оси y) один за другим.
  • И, наконец, виджет сообщает своему родителю о собственном размере (в пределах исходных ограничений, конечно).

Например, виджет, похожий на столбец с некоторыми отступами, хочет расположить два дочерних элемента:
lbypp5ui8k8cqvht184irv2awlo.png


Виджет: Родитель, каковы мои ограничения?

Родитель: Ты должен быть от 90 до 300 пикселей в ширину и от 30 до 85 в высоту.

Виджет: Хм, так как я хочу иметь 5 пикселей отступа, то мои дети могут иметь не более 290 пикселей ширины и 75 пикселей высоты.

Виджет: Первый ребенок, вы должны быть от 0 до 290 пикселей в ширину и от 0 до 75 в высоту.

Первый ребенок: Хорошо, тогда я хочу быть 290 пикселей в ширину и 20 пикселей в высоту.

Виджет: Хм, поскольку я хочу поместить своего второго ребенка ниже первого, это оставляет только 55 пикселей высоты для моего второго ребенка.

Виджет: Эй, второй ребенок, ты должен быть от 0 до 290 в ширину и от 0 до 55 в высоту.

Второй ребенок: Хорошо, я хочу быть 140 пикселей в ширину и 30 пикселей в высоту.

Виджет: Очень хорошо. Я расположу своего первого ребенка в точке x: 5 и y: 5, а второго — в x: 80 и y: 25.

Виджет: Родитель, я решил, что мой размер будет 300 пикселей в ширину и 60 пикселей в высоту.


Ограничения

В результате вышеописанного правила механизм компоновки Flutter имеет несколько важных ограничений:


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


Примеры

Чтобы посмотреть, как работают примеры, можно:


Пример 1

ulcty9vbdzpojt_ut3eawprnire.png

Container(color: Colors.red)

«Экран» является родителем для Container. Это заставляет красный Container быть точно такого же размера, что и «экран».

Итак, Container полностью заполняет видимую область, и та становится красной.


Примечание переводчика. У автора говорится, что «screen» определяет ограничения, но самом деле родителем виджета-примера является
ConstrainedBox( 
    constraints: BoxConstraints.tightFor(width: double.infinity, height: double.infinity), 
    child: example
)


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



Примечание к примечанию. Если проверять приведенные примеры следующим образом
import 'package:flutter/material.dart';
void main() => runApp(ExampleTest());
class ExampleTest extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.red
    );
  }
}


то поведение соответствуют описанному автором.


Пример 2

ulcty9vbdzpojt_ut3eawprnire.png

Container(width: 100, height: 100, color: Colors.red)

Красный Container хочет быть 100 × 100, но он не может, потому что «экран» приводит его к своим размерам.
Таким образом, Container заполняет экран.


Пример 3

0oeg7sx_1hpkxddiavxfxt7k5o8.png

Center(
   child: Container(width: 100, height: 100, color: Colors.red)
)

«Экран» приводит Center к своему размеру. Таким образом, Center заполняет «экран».
Center сообщает Container, что тот может быть любого размера, но не больше «экрана». Теперь Container действительно может быть 100 × 100.


Пример 4

jjhfme6hgydudlhjga-g_nqlkbu.png

Align(
   alignment: Alignment.bottomRight,
   child: Container(width: 100, height: 100, color: Colors.red),
)

В отличие от предыдущего примера здесь используется использует Align вместо Center.

Align также говорит Container, что он может быть любого размера, но если есть пустое место, оно не будет центрировать Container, а вместо этого поместит его в правом нижнем углу доступного пространства.


Пример 5

ulcty9vbdzpojt_ut3eawprnire.png

Center(
   child: Container(
      color: Colors.red,
      width: double.infinity,
      height: double.infinity,
   )
)

«Экран» приводит Center к своему размеру. Таким образом, Center заполняет всю видимую область.
Center сообщает Container, что он может быть любого размера, но не больше «экрана». Container хочет быть бесконечного размера, но так как он не может быть больше «экрана», он просто заполнит «экран».


Пример 6

ulcty9vbdzpojt_ut3eawprnire.png

Center(child: Container(color: Colors.red))

«Экран» приводит Center к своему размеру. Таким образом, Center заполняет всю видимую область.
Center сообщает Container, что тот может быть любого размера, но не больше «экрана». Поскольку у Container нет дочернего элемента и нет фиксированного размера, он решает, что хочет быть как можно больше, поэтому он заполняет весь «экран».

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


Пример 7

7zgpgdbeuxs7c3jdpyl2mpmcri4.png

Center(
   child: Container(
      color: Colors.red,
      child: Container(color: Colors.green, width: 30, height: 30),
   )
)

«Экран» приводит Center к своему размеру. Таким образом, Center заполняет весь «экран».
Center сообщает Container, что тот может быть любого размера, но не больше «экрана». Поскольку у красного Container нет размера, но есть дочерний виджет, то он решает, что хочет быть того же размера, что и его ребенок.

Красный Container говорит своему дочернему виджету, что тот может быть любого размера, но не больше «экрана».

Ребенок оказывается зеленым Container, который хочет быть размером 30 × 30. Как уже было сказано, красный Container сам по себе будет соответствовать размеру его детей, поэтому он также будет 30 × 30. Красный цвет не будет виден, так как зеленый Container будет занимать собой весь красный Container.


Пример 8

dduu8y8cka0w2kxcpxqx8qbgunm.png

Center(
   child: Container(
     color: Colors.red,
     padding: const EdgeInsets.all(20.0),
     child: Container(color: Colors.green, width: 30, height: 30),
   )
)

Размер красного Container будет соответствовать размеру своих детей, но в этот раз необходимо учесть собственные отступы (padding). Так что итоговый размер красного Container будет 30 × 30 плюс отступы. Благодаря отступам красный цвет будет виден, а зеленый Container будет того же размера, что и в предыдущем примере.


Пример 9

ulcty9vbdzpojt_ut3eawprnire.png

ConstrainedBox(
   constraints: BoxConstraints(
      minWidth: 70, 
      minHeight: 70,
      maxWidth: 150, 
      maxHeight: 150,
   ),
   child: Container(color: Colors.red, width: 10, height: 10),
)

Вы могли бы предположить, что размер Container должен быть от 70 до 150 пикселей, но это не так. ConstrainedBox только накладывает дополнительные ограничения к тем, что она получила от своего родителя.

В данном случае «экран» приводит ConstrainedBox к своему размеру, поэтому дочерний Container также будет получит размеры «экрана», игнорируя полученные ограничения.


Пример 10

_31rugwdhaaz_jtmxie-echw78m.png

Center(
   child: ConstrainedBox(
      constraints: BoxConstraints(
         minWidth: 70, 
         minHeight: 70,
         maxWidth: 150, 
         maxHeight: 150,
      ),
      child: Container(color: Colors.red, width: 10, height: 10),
   )    
)

В данном случае Center позволяет ConstrainedBox быть любого размера, но не больше «экрана». Дочерний виджет ConstrainedBox получит дополнительные ограничения от параметра constraints.

Таким образом, размер Container должен составлять от 70 до 150 пикселей. Он хочет иметь 10 пикселей в ширину и высоту, поэтому в конечном итоге у него будет (минимум) 70.


Пример 11

4uphsfqu8xus_ktvhiwdotwhfxa.png

Center(
  child: ConstrainedBox(
     constraints: BoxConstraints(
        minWidth: 70, 
        minHeight: 70,
        maxWidth: 150, 
        maxHeight: 150,
        ),
     child: Container(color: Colors.red, width: 1000, height: 1000),
  )  
)

В данном варианте Center позволяет ConstrainedBox быть любого размера, но не больше «экрана». Дочерний виджет ConstrainedBox получит дополнительные ограничения от параметра constraints.

Таким образом, размер Container должен составлять от 70 до 150 пикселей. Он хочет иметь 1000 пикселей в ширину и высоту, поэтому в конечном итоге у него будет (максимум) 150.


Пример 12

fm6i-bz0bnwkaito9qd2h7roho4.png

Center(
   child: ConstrainedBox(
      constraints: BoxConstraints(
         minWidth: 70, 
         minHeight: 70,
         maxWidth: 150, 
         maxHeight: 150,
      ),
      child: Container(color: Colors.red, width: 100, height: 100),
   ) 
)

В таком варианте Center позволяет ConstrainedBox быть любого размера, но не больше «экрана». Дочерний виджет ConstrainedBox получит дополнительные ограничения от параметра constraints.

Таким образом, размер Container должен составлять от 70 до 150 пикселей. Он хочет иметь 100 пикселей в ширину и высоту, и именно таким он будет, так как это значение между 70 и 150.


Пример 13

bs2-_tkyal9ksq4wplxpk190n0w.png

UnconstrainedBox(
   child: Container(color: Colors.red, width: 20, height: 50),
)

«Экран» приводит UnconstrainedBox к своему размеру. Как бы то ни было, UnconstrainedBox позволяет дочернему Container быть желаемого размера, в данном случае 20 × 50.


Пример 14

r6ijkzgqyqfxjdttuevduozfmfc.png

UnconstrainedBox(
   child: Container(color: Colors.red, width: 4000, height: 50),
)

«Экран» приводит UnconstrainedBox к своему размеру. Как бы то ни было, UnconstrainedBox позволяет дочернему Container быть желаемого размера (4000 × 50).

К сожалению, в таком случае у Container ширина 4000 пикселей и он слишком велик, чтобы поместиться в UnconstrainedBox, поэтому UnconstrainedBox будет показывать очень страшное «предупреждение о переполнении» (... OVERFLOWED BY ... PIXELS).


Пример 15

4duolsgtlsolwdbgxmusuohrlsq.png

OverflowBox(
    child: Center(child: Container(color: Colors.red, width: 4000, height: 50)),
)

«Экран» приводит OverflowBox к своему размеру. Как бы то ни было, Center внутри OverflowBox позволяет дочернему Container быть желаемого размера (4000 × 50).

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

Container имеет ширину 4000 пикселей и слишком велик, чтобы поместиться в OverflowBox, но OverflowBox просто покажет, что может, без каких-либо предупреждений.


Пример 16

9tcxr1wlt_b-tgvom8jwaqtdby4.png

UnconstrainedBox(
   child: Container(
      color: Colors.red, 
      width: double.infinity, 
      height: 100,
   )
)

На экране ничего не отобразистя, и вы получите ошибку в консоли.

UnconstrainedBox позволяет своему ребенку иметь любой размер, который он хочет, однако, у его дочернего виджета Container ширина бесконечна (width: double.infinity).

Flutter не может визуализировать бесконечные размеры, поэтому он выдаст ошибку со следующим сообщением: Box Constraints forces an infinite width.


Пример 17

0oeg7sx_1hpkxddiavxfxt7k5o8.png

UnconstrainedBox(
   child: LimitedBox(
      maxWidth: 100,
      child: Container( 
         color: Colors.red,
         width: double.infinity, 
         height: 100,
      )
   )
)

Здесь вы больше не получите ошибку. Да, LimitedBox получает возможность быть сколько угодно большим от UnconstrainedBox, но он передает своему потомку максимальную ширину 100.

Обратите внимание, что если вы измените UnconstrainedBox на Center, то LimitedBox больше не будет применять свои ограничения (так как они применяются только тогда, когда сам LimitedBox получает «бесконечные» ограничения), и ширина Container сможет быть больше 100.

Это должно помочь понять разницу между LimitedBox и ConstrainedBox.


Пример 18

zinbdq9nln-wi7xocszgqxpmsus.png

FittedBox(
    child: Text('Some Example Text.', textDirection: TextDirection.ltr),
)


Примечание переводчика. Чтобы данный пример воспроизводился в качестве первого виджета в приложении, добавлено свойство textDirection: TextDirection.ltr в Text, так как оно необходимо, если в дереве до Text не было виджета, определяющего направление текста. Если вы используете MaterialApp, то Text не требует явного указания textDirection.

«Экран» приводит FittedBox к своему размеру. Text будет иметь некоторую естественную ширину (также называемую его intrinsic width), которая зависит от количества текста, его размера шрифта и т.д.

FittedBox позволит Text иметь любой размер, который он хочет, но после того, как Text сообщит свой размер FittedBox, FittedBox будет масштабировать его до тех пор, пока не заполнит всю доступную ширину.


Пример 19

xiug3grugdfxmvmbakthlzuby54.png

Center(
    child: FittedBox(
        child: Text('Some Example Text.', textDirection: TextDirection.ltr,),
    )
)

Но что случится, если мы поместим FittedBox в Center? Center позволит FittedBox иметь любой размер, который она хочет, вплоть до размера экрана.

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


Пример 20

axlvn5o0ncftaocqf9tjpczt0jw.png

Center(
    child: FittedBox(
        child: Text('This is some very very very large text that is too big to fit a regular screen in a single line.', textDirection: TextDirection.ltr,),
    )
)

Однако, что произойдет, если FittedBox в Center, но Text слишком большой, чтобы поместиться на экране?

FittedBox попытается стать таким же, как и Text, но он не может быть больше «экрана». Поэтому он примет размер «экрана» и изменит размер Text так, чтобы тот тоже влез в «экран».


Пример 21

y_rso01pbb4ypvm-lueefwohmie.png

Center(
    child: Text('This is some very very very large text that is too big to fit a regular screen in a single line.', textDirection: TextDirection.ltr,),
)

Однако, если убрать FittedBox, то Text получит максимальную ширину от «экрана» и затем перенесет текст на новую строку, чтобы влезть в «экран».


Пример 22

ozpp_dgahvzxs_t9ve01zviljx4.png

FittedBox(
   child: Container(
      height: 20.0, 
      width: double.infinity,
   )
)

Примечание. FittedBox может масштабировать только виджет, который ограничен (имеет не бесконечную ширину и высоту). В противном случае он ничего не будет рендерить, и вы получите ошибку в консоли: RenderConstrainedBox object was given an infinite size during layout..


Пример 23

zis36jfydmyx_rnkzi7viylrd4s.png

Row(textDirection: TextDirection.ltr, children: [
    Container(
        color: Colors.red,
        child: Text(
            'Hello!',
            textDirection: TextDirection.ltr,
        )),
    Container(
        color: Colors.green,
        child: Text(
            'Goodbye!',
            textDirection: TextDirection.ltr,
        )),
]);

«Экран» приводит Row к своему размеру.

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


Пример 24

w50f9fpovv4bfjs57douwgy9oo8.png

Row(textDirection: TextDirection.ltr, children: [
    Container(
        color: Colors.red,
        child: Text(
            'This is a very long text that won’t fit the line.',
            textDirection: TextDirection.ltr,
        )),
    Container(
        color: Colors.green,
        child: Text(
            'Goodbye!',
            textDirection: TextDirection.ltr,
        )),
]);

Поскольку Row не накладывает никаких ограничений на своих детей, вполне возможно, что дочерние элементы будут слишком большими, чтобы соответствовать доступной ширине строки. В этом случае, как и в случае с UnconstrainedBox, Row будет отображать «предупреждение о переполнении».


Пример 25

__wq1_myx7bi9e_3rbppu0ft9vc.png

Row(textDirection: TextDirection.ltr, children: [
    Expanded(
        child: Container(
            color: Colors.red,
            child: Text(
                'This is a very long text that won’t fit the line.',
                textDirection: TextDirection.ltr,
            ))),
    Container(
        color: Colors.green,
        child: Text(
            'Goodbye!',
            textDirection: TextDirection.ltr,
        )),
])

Когда у Row дочерний элемент обернут в Expanded виджет, то Row больше не позволяет этому дочернему элементу определять свою ширину.

Вместо этого он будет определять ширину Expanded в соответствии с другими дочерними элементами, и затем Expanded приводит ширину исходного дочернего элемента к своей.

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


Пример 26

zltc8rv8uc0csnlhogo_jre60ne.png

Row(textDirection: TextDirection.ltr, children: [
    Expanded(
        child: Container(
            color: Colors.red,
            child: Text(
                'This is a very long text that won’t fit the line.',
                textDirection: TextDirection.ltr,
            ))),
    Expanded(
        child: Container(
            color: Colors.green,
            child: Text(
                'Goodbye!',
                textDirection: TextDirection.ltr,
            ))),
])

Если у Row все дочерние элементы обернуты в Expanded, то каждый Expanded будет иметь размер, пропорциональный его параметру flex, и затем каждый Expanded виджет приведет ширину дочернего элемента к своей.


Пример 27

2_bhfnoush86whddf5wdiqhlip8.png

Row(textDirection: TextDirection.ltr, children: [
    Flexible(
        child: Container(
            color: Colors.red,
            child: Text(
                'This is a very long text that won’t fit the line.',
                textDirection: TextDirection.ltr,
            ))),
    Flexible(
        child: Container(
            color: Colors.green,
            child: Text(
                'Goodbye!',
                textDirection: TextDirection.ltr,
            ))),
])

Единственное отличие, если вы используете Flexible вместо Expanded, заключается в том, что Flexible позволяет своему дочернему элементу иметь ту же или меньшую ширину, чем у самого Flexible, в то время как Expanded заставляет дочерний элемент иметь точно такую же ширину, что и Expanded.

Но и Expanded, и Flexible игнорируют ширину своих детей при определении своих размеров.

Обратите внимание. Выше сказанное означает, что невозможно получить размеры Flexible/Expanded пропорционально размерам соответствующих дочерних элементов у Row. Row будет либо использовать точные значения длины дочерних элементов, либо полностью игнорировать их при использовании Flexible/Expanded.


Пример 28

0zaaxbwzwyjcwcoimtbrfbn3c5k.png

MaterialApp(
    home: Scaffold(
        body: Container(
            color: Colors.blue,
                child: Column(children: [
                    Text('Hello!'),
                    Text('Goodbye!'),
                ]))))

«Экран» приводит Scaffold к своему размеру. Так что Scaffold заполняет весь «экран».

Scaffold говорит Container, что он может быть любого размера, но не больше «экрана».

Примечание: когда виджет сообщает своему дочернему элементу, что он может быть меньше определенного размера, мы говорим, что виджет предоставляет «loose» (пер. свободные) ограничения своему дочернему элементу. Но об этом чуть позже.


Пример 29

ewuzy9vgiryofvwiflhd_gqsi8y.png

MaterialApp(
    home: Scaffold(
        body: SizedBox.expand(
            child: Container(
                color: Colors.blue,
                child: Column(
                    children: [
                        Text('Hello!'),
                        Text('Goodbye!'),
                    ],
                )))))

Если мы хотим, чтобы ребенок у Scaffold был точно такого же размера, как и сам Scaffold, мы можем обернуть этот дочерний виджет в SizedBox.expand.

Примечание: когда виджет сообщает своему дочернему элементу, что он должен иметь определенный размер, мы говорим, что виджет предоставляет «tight» (пер. жесткие) ограничения своему дочернему элементу.


Tight × Loose ограничения

Очень часто можно услышать, что некоторые ограничения являются «tight» или «loose», поэтому стоит знать, что это значит.

tight ограничение предполагает только точный размер. Другими словами, tight ограничение определяет свою максимальную ширину, равную его минимальной ширине, и определяет свою максимальную высоту, равную его минимальной высоте.

Если вы перейдете к box.dart в репозитории Flutter и поищете конструктуры BoxConstraints, то найдете следующее:

BoxConstraints.tight(Size size)
   : minWidth = size.width,
     maxWidth = size.width,
     minHeight = size.height,
     maxHeight = size.height;

Если вы вернетесь к Примеру 2, то увидите, что «экран» заставляет красный Container быть точно такого же размера, как и «экран». «Экран» делает это, передавая tight ограничения в Container.

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

BoxConstraints.loose(Size size)
   : minWidth = 0.0,
     maxWidth = size.width,
     minHeight = 0.0,
     maxHeight = size.height;

Если вы вернетесь к Примеру 3, то увидите, что Center позволяет красному Container быть меньше, но не больше «экрана». Center делает так, передавая loose ограничения контейнеру. В конечном счете, сама цель центра Center в том, чтобы преобразовать tight ограничения, которые он получил от своего родителя («экрана»), в loose ограничения для своего потомка (Container).


Изучение правил компоновки для конкретных виджетов

Знание общего правила компоновки необходимо, но его недостаточно.

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

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

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

Пример:


  • Найдите какой-нибудь Column в своем коде и перейдите к его исходному коду (Ctrl-B в IntelliJ). Откроется basic.dart файл. Поскольку Column наследует Flex, то перейдите к исходному коду Flex (также в basic.dart).
  • Теперь прокрутите вниз, пока не найдете метод под названием createRenderObject. Как вы можете видеть, этот метод возвращает RenderFlex. Это соответствующий объект визуализации для Column. Перейдите к исходному коду RenderFlex. Откроется flex.dart.
  • Прокрутите вниз, пока не найдете метод performLayout. Это метод, который отвечает за компоновку для Column.

ti8ozr9nd2zueza3x7dqfigpr60.png

Спасибо Simon Lightfoot за помощь в написании данной статьи.

Пакеты для компоновки за моим авторством

Еще мои Flutter пакеты


Обо мне


PS Всю критику, вопросы и предложения по переводу буду рад услышать в (личных) сообщениях.

PSS Ещё раз ссылка на оригинальную статью Flutter: The Advanced Layout Rule Even Beginners Must Know от Marcelo Glasberg

© Habrahabr.ru