Как делать11 октября 2026 г.8 мин чтения
Flutter + DartШаг 2 из 6

Flutter и Dart: первое приложение

Коротко

Flutter-приложение — это дерево виджетов на Dart: runApp получает корневой виджет, MaterialApp даёт тему и навигацию, а экран собирается из Scaffold, Column, Row, Expanded и других виджетов раскладки. Виджеты без изменяемых данных наследуют от StatelessWidget, а данные, которые меняются от действий пользователя, хранят в State у StatefulWidget и меняют через setState. Из Dart для старта нужны null safety (String?), классы с именованными параметрами и async/await.

Пошаговый урокУровень: НачальныйFlutter и DartПрактика: ~1,5–3 ч

После материала вы сможете

  • написать на Dart класс с final-полями, именованными параметрами и nullable-полем String? и асинхронную функцию с Future и await
  • создать проект командой flutter create, проверить его через flutter analyze и flutter test
  • собрать экран из Scaffold, Card, Column, Row, Expanded и ListView без переполнения на узком экране
  • выбрать между StatelessWidget и StatefulWidget и обновить экран через setState

Проверено на

Flutter 3.47.7 (stable)Dart 3.13.5flutter_lints 6.0.0

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Что соберём и что понадобится
  2. 02Dart за 15 минут: типы, null safety, классы, async
  3. 03Всё — виджет: дерево виджетов и метод build
  4. 04Раскладка: Column, Row, Padding, Expanded, ListView
  5. 05StatefulWidget и setState: кнопка записи
  6. 06Hot reload, тесты и проверка результата
  7. 07Частые ошибки и упражнения

Что соберём и что понадобится

Flutter — фреймворк Google для интерфейсов, которые из одного кода собираются под Android, iOS, веб и десктоп. Пишут на нём на языке Dart. В этом уроке мы соберём экран карточки услуги — тот же, что в уроке по Expo, чтобы подходы было удобно сравнить: название, описание, длительность, отметка «Премиум» и кнопка «Записаться», которая меняет вид после нажатия.

Урок рассчитан на тех, кто уже понимает функции и переменные хотя бы в одном языке и знает, чем мобильное приложение отличается от сайта. Весь код проверен на Flutter 3.47.7 и Dart 3.13.5 (актуальный stable на дату проверки): flutter analyze без замечаний, два widget-теста проходят, flutter build web собирается. На реальном телефоне код не запускался — это отмечено в журнале проверки.

  1. Установите Flutter

    Официальный быстрый старт предлагает VS Code (или другой редактор на базе Code OSS): поставьте Git и расширение Flutter, затем командой Flutter: New Project выберите Download SDK — редактор сам скачает SDK и предложит добавить его в PATH.

  2. Проверьте окружение

    Выполните flutter doctor. Предупреждение об отсутствии Android Studio можно игнорировать, если для начала вы запускаете приложение в браузере.

  3. Создайте проект

    flutter create service_card создаёт проект со стартовым счётчиком, папками платформ, pubspec.yaml (зависимости) и настроенным линтером flutter_lints.

  4. Запустите

    Документация советует для первого раза запустить приложение в Chrome: в VS Code — Flutter: Select Device, затем F5; из терминала — flutter run -d chrome.

bash
flutter doctor
flutter create service_card
cd service_card
flutter run -d chrome
Команды из терминала. Имя проекта — строчными буквами через подчёркивание.

Dart за 15 минут: типы, null safety, классы, async

Dart — язык со статической типизацией и выводом типов: var year = 1977 получит тип int из значения. Переменную, которую не будут переприсваивать, объявляют через final, а значение, известное на этапе компиляции, — через const. Для Flutter особенно важны четыре темы: null safety, классы с именованными параметрами, перечисления и асинхронность.

Null safety (безопасность от null) означает, что по умолчанию переменная не может быть null. Если значение может отсутствовать, к типу добавляют ?: String?. Документация Dart подчёркивает: начиная с Dart 3 (май 2023) null safety обязательна и «звуковая» — анализатор не даст обратиться к полю значения, которое может оказаться null, пока вы его не проверили. После проверки if (city == null) return … Dart сам «сужает» тип до String.

lib/models/service.dart
/// Уровень услуги: enum — фиксированный набор значений.
enum ServiceLevel { standard, premium }

class Service {
  const Service({
    required this.title,
    required this.durationMinutes,
    this.description,
    this.level = ServiceLevel.standard,
  });

  final String title;
  final int durationMinutes;
  final String? description; // может отсутствовать — тип String?
  final ServiceLevel level;

  // Геттер — вычисляемое свойство, вызывается без скобок.
  String get durationLabel => '$durationMinutes мин';

  // ?? подставляет запасное значение, если слева null.
  String get descriptionOrPlaceholder =>
      description ?? 'Описание скоро появится';

  bool get isPremium => level == ServiceLevel.premium;
}

/// Имитация запроса к серверу: через 300 мс возвращает список услуг.
Future<List<Service>> fetchServices() async {
  await Future<void>.delayed(const Duration(milliseconds: 300));
  return const [
    Service(
      title: 'Чистка лица',
      durationMinutes: 60,
      description: 'Ультразвуковая чистка и маска по типу кожи.',
      level: ServiceLevel.premium,
    ),
    Service(title: 'Массаж спины', durationMinutes: 45),
  ];
}
Модель услуги. Файл не импортирует Flutter — это чистый Dart, его можно запускать и тестировать отдельно.

final / const

Что делает
Неизменяемое поле / значение времени компиляции
В примере
все поля Service, const Service(…)

{required this.title}

Что делает
Именованный обязательный параметр конструктора
В примере
Service(title: …, durationMinutes: …)

String?

Что делает
Тип, допускающий null
В примере
description

??

Что делает
Значение по умолчанию вместо null
В примере
descriptionOrPlaceholder

get

Что делает
Вычисляемое свойство
В примере
durationLabel, isPremium

enum

Что делает
Фиксированный набор значений
В примере
ServiceLevel

Future<T> + async/await

Что делает
Результат, который появится позже
В примере
fetchServices()

Future — это обещание результата асинхронной операции: запроса к серверу, чтения файла, таймера. Внутри функции с пометкой async можно написать await и получить значение, не блокируя программу; ошибки ловятся обычным try/catch. Ниже — консольный скрипт, который использует модель. Его запускают командой dart run, Flutter для этого не нужен.

bin/basics.dart
// Консольный скрипт: здесь print уместен, поэтому отключаем правило линтера.
// ignore_for_file: avoid_print

import 'package:service_card/models/service.dart';

// Именованный параметр в фигурных скобках; String? — может быть null.
String greet(String name, {String? city}) {
  if (city == null) return 'Привет, $name!';
  // После проверки выше Dart знает, что city — уже String, а не String?.
  return 'Привет, $name из города ${city.toUpperCase()}!';
}

Future<void> main() async {
  print(greet('Аружан'));
  print(greet('Аружан', city: 'Алматы'));

  final services = await fetchServices(); // ждём Future, не блокируя программу
  for (final s in services) {
    print('${s.title} — ${s.durationLabel}. ${s.descriptionOrPlaceholder}');
  }

  final premiumCount = services.where((s) => s.isPremium).length;
  print('Премиум-услуг: $premiumCount');
}
Консольный скрипт в папке bin/ проекта: функции, именованные параметры, сужение типа после проверки на null и await.
terminal
$ dart run bin/basics.dart
Привет, Аружан!
Привет, Аружан из города АЛМАТЫ!
Чистка лица — 60 мин. Ультразвуковая чистка и маска по типу кожи.
Массаж спины — 45 мин. Описание скоро появится
Премиум-услуг: 1
Реальный вывод скрипта в проверочном проекте.

Всё — виджет: дерево виджетов и метод build

Документация Flutter формулирует главный принцип так: почти всё во Flutter — виджет, даже способы раскладки. Текст, отступ, строка, карточка, тема и само приложение — виджеты, вложенные друг в друга. Получается дерево: у каждого виджета есть родитель, а дети передаются через child (один) или children (список).

  1. 01ServiceApp
  2. 02MaterialApp
  3. 03Scaffold
  4. 04ListView
  5. 05ServiceCard
Ветка дерева виджетов нашего экрана: от корня приложения до карточки.

Виджет — это описание интерфейса, а не сам интерфейс. Метод build возвращает новое описание каждый раз, когда фреймворку нужно перерисовать часть экрана, поэтому build должен быть быстрым и без побочных эффектов. Виджет без изменяемых данных наследуют от StatelessWidget: все его поля final, а конструктор можно сделать const, чтобы Flutter переиспользовал экземпляр.

lib/main.dart
import 'package:flutter/material.dart';

import 'service_screen.dart';

void main() {
  runApp(const ServiceApp());
}

class ServiceApp extends StatelessWidget {
  const ServiceApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Карточка услуги',
      // Вся палитра Material 3 строится из одного «зерна».
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
      ),
      home: const ServiceScreen(),
    );
  }
}
Точка входа: runApp получает корневой виджет.

MaterialApp подключает Material Design: навигацию, тему, типографику. Material 3 во Flutter включён по умолчанию — свойство ThemeData.useMaterial3 равно true, а документация API предупреждает, что со временем этот флаг уберут и останется только Material 3. ColorScheme.fromSeed строит всю палитру — основной, вторичный, фоновые цвета и цвета «поверх» них — из одного исходного цвета.

Раскладка: Column, Row, Padding, Expanded, ListView

Column выстраивает детей сверху вниз, Row — слева направо. У каждой из них есть главная ось (вдоль которой идут дети) и поперечная: для Row главная ось горизонтальная, для Column — вертикальная. Выравнивание задают mainAxisAlignment и crossAxisAlignment. Padding добавляет отступ одному ребёнку, SizedBox — пустое место фиксированного размера, Card — поверхность Material со скруглением и тенью.

lib/service_screen.dart
import 'package:flutter/material.dart';

import 'models/service.dart';

const demoService = Service(
  title: 'Чистка лица',
  durationMinutes: 60,
  description: 'Ультразвуковая чистка и маска по типу кожи.',
  level: ServiceLevel.premium,
);

class ServiceScreen extends StatelessWidget {
  const ServiceScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Услуга')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: const [ServiceCard(service: demoService)],
      ),
    );
  }
}

class ServiceCard extends StatelessWidget {
  const ServiceCard({super.key, required this.service});

  final Service service;

  @override
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context).textTheme;

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                // Expanded отдаёт названию всё свободное место в строке.
                Expanded(
                  child: Text(service.title, style: textTheme.titleLarge),
                ),
                if (service.isPremium) const Chip(label: Text('Премиум')),
              ],
            ),
            const SizedBox(height: 8),
            Text(service.descriptionOrPlaceholder, style: textTheme.bodyMedium),
            const SizedBox(height: 16),
            Row(
              children: [
                const Icon(Icons.schedule, size: 20),
                const SizedBox(width: 4),
                // Expanded, а не Spacer: на узком экране текст сожмётся,
                // а кнопка останется у правого края.
                Expanded(child: Text(service.durationLabel)),
                const BookButton(),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

class BookButton extends StatefulWidget {
  const BookButton({super.key});

  @override
  State<BookButton> createState() => _BookButtonState();
}

class _BookButtonState extends State<BookButton> {
  bool _booked = false;

  @override
  Widget build(BuildContext context) {
    if (_booked) {
      return OutlinedButton.icon(
        onPressed: () => setState(() => _booked = false),
        icon: const Icon(Icons.check),
        label: const Text('Вы записаны'),
      );
    }
    return FilledButton(
      onPressed: () => setState(() => _booked = true),
      child: const Text('Записаться'),
    );
  }
}
Экран, карточка и кнопка записи. ServiceCard — StatelessWidget, BookButton — StatefulWidget (о нём в следующем разделе).

Column / Row

Когда брать
Дети друг под другом / в строку
Важное
Не прокручиваются; лишнее вылезает за край

Expanded

Когда брать
Ребёнку Row/Column нужно оставшееся место
Важное
flex задаёт долю, по умолчанию 1

Spacer

Когда брать
Пустое растягивающееся место
Важное
Сам не сжимает соседей

Padding / SizedBox

Когда брать
Отступ вокруг / пустой промежуток
Важное
Отступы — EdgeInsets

ListView

Когда брать
Содержимое может не поместиться по высоте
Важное
Прокручивается сам

Scaffold

Когда брать
Каркас экрана Material
Важное
appBar, body, floatingActionButton

Если содержимое не помещается, Flutter рисует жёлто-чёрную полосу у края и пишет ошибку A RenderFlex overflowed by … pixels. Мы столкнулись с этим при проверке: в первой версии нижней строки стояли текст, Spacer и кнопка, и тест с шириной экрана 320 логических пикселей упал с ошибкой A RenderFlex overflowed by 19 pixels on the right. Spacer занимает только свободное место, а когда его нет, никого не сжимает. Решение — обернуть в Expanded то, что может ужаться: текст длительности. С названием услуги та же история: без Expanded длинный заголовок в Row вылезает за экран.

StatefulWidget и setState: кнопка записи

Карточке не нужно ничего помнить: она получает Service и рисует его. А кнопка записи должна помнить, нажата ли она. Такой виджет наследуют от StatefulWidget. Он состоит из двух классов: сам виджет (неизменяемая конфигурация) и объект State, который живёт дольше отдельных вызовов build и хранит изменяемые данные — у нас поле _booked.

Чтобы изменить данные и обновить экран, их меняют внутри setState(() { … }). Документация называет этот вызов критически важным: он сообщает фреймворку, что состояние изменилось и виджет нужно перерисовать. Если просто присвоить _booked = true без setState, значение поменяется, но экран останется прежним.

StatelessWidget

  • Все поля final, данные приходят через конструктор
  • Перерисовывается, когда родитель передал новые данные
  • Карточка, текст, иконка, заголовок экрана

StatefulWidget

  • Изменяемые данные живут в объекте State
  • Перерисовывается после setState
  • Кнопка с переключением, поле ввода, счётчик

Документация даёт правило выбора, кто хранит состояние: если это данные пользователя (отмечен ли чекбокс, положение ползунка), ими лучше управлять в родителе; если это чисто визуальная деталь (анимация), — в самом виджете; если сомневаетесь — начинайте с родителя. Отметку «записан» в реальном приложении нужно отправить на сервер и показать на других экранах, поэтому со временем её поднимают выше. Этому посвящён следующий урок о состоянии и навигации.

Hot reload, тесты и проверка результата

Hot reload — горячая перезагрузка: Flutter подгружает изменённый код в уже запущенное приложение и перестраивает дерево виджетов, сохраняя состояние. Поменяйте seedColor или текст кнопки, сохраните файл — экран обновится, а нажатая кнопка останется нажатой. Работает только в режиме отладки.

Hot reload

Как вызвать
Сохранить файл в IDE или r в терминале flutter run
Что происходит
Новый код, состояние сохраняется; main() и initState() не выполняются заново

Hot restart

Как вызвать
R в терминале или кнопка в IDE
Что происходит
Приложение перезапускается, состояние сбрасывается

Полный перезапуск

Как вызвать
Остановить и снова flutter run
Что происходит
Нужен после изменений нативного кода (Kotlin, Swift)

Изменения в инициализаторах глобальных переменных и статических полей, а также превращение enum в обычный класс hot reload не подхватит — нужен hot restart. Проверять экран глазами удобно, но надёжнее закрепить поведение widget-тестом: он запускает виджет без устройства, находит элементы по тексту и нажимает кнопки.

test/widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:service_card/main.dart';
import 'package:service_card/models/service.dart';
import 'package:service_card/service_screen.dart';

void main() {
  testWidgets('карточка показывает услугу, кнопка меняет состояние', (
    tester,
  ) async {
    await tester.pumpWidget(const ServiceApp());

    expect(find.text('Чистка лица'), findsOneWidget);
    expect(find.text('60 мин'), findsOneWidget);
    expect(find.text('Премиум'), findsOneWidget);

    await tester.tap(find.text('Записаться'));
    await tester.pump(); // перерисовка после setState

    expect(find.text('Вы записаны'), findsOneWidget);
    expect(find.text('Записаться'), findsNothing);
  });

  testWidgets('длинное название на узком экране не переполняет строку', (
    tester,
  ) async {
    tester.view.physicalSize = const Size(320, 640);
    tester.view.devicePixelRatio = 1.0;
    addTearDown(tester.view.reset);

    const service = Service(
      title: 'Очень длинное название услуги, которое не помещается в строку',
      durationMinutes: 90,
      level: ServiceLevel.premium,
    );
    await tester.pumpWidget(
      const MaterialApp(
        home: Scaffold(body: ServiceCard(service: service)),
      ),
    );

    expect(tester.takeException(), isNull); // нет ошибки переполнения
    expect(find.text('Описание скоро появится'), findsOneWidget);
  });
}
Два widget-теста: поведение кнопки и отсутствие переполнения на узком экране 320×640.
terminal
$ flutter analyze
Analyzing service_card...
No issues found! (ran in 11.1s)

$ flutter test
00:00 +0: карточка показывает услугу, кнопка меняет состояние
00:00 +1: длинное название на узком экране не переполняет строку
00:00 +2: All tests passed!
Реальный результат в проверочном проекте (Flutter 3.47.7).
  • flutter analyze пишет No issues found!
  • flutter test — All tests passed!
  • в приложении после нажатия «Записаться» кнопка меняется на «Вы записаны»
  • на узком экране нет жёлто-чёрной полосы переполнения

Частые ошибки и упражнения

Жёлто-чёрная полоса, RenderFlex overflowed

Причина
Дети Row/Column не помещаются
Исправление
Expanded для гибкого ребёнка или ListView вместо Column

Нажатие ничего не меняет на экране

Причина
Поле изменено без setState
Исправление
Менять данные внутри setState(() { … })

The property 'length' can't be unconditionally accessed

Причина
Обращение к значению типа String?
Исправление
Проверить на null, использовать ?? или ?.

Замечание линтера avoid_print

Причина
print в коде приложения
Исправление
Для отладки — debugPrint; в консольном скрипте правило отключают комментарием ignore_for_file

Изменение не видно после hot reload

Причина
Код в main(), initState() или в инициализаторе глобальной переменной
Исправление
Сделать hot restart (R)

Следующий шаг — урок о состоянии и навигации во Flutter: как поднять состояние выше, разделить его между экранами через ChangeNotifier и provider и перейти от списка к деталям с передачей параметров.

Как проверено5 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
  • запущено, работает

    lib/models/service.dart, bin/basics.dart

    flutter analyze (No issues found) + dart run bin/basics.dart — вывод в статье скопирован из прогона; Docker ghcr.io/cirruslabs/flutter:stable, SDK переключён на тег 3.47.7 (Dart 3.13.5); RU и EN-версии проверены отдельно

  • запущено, работает

    lib/main.dart, lib/service_screen.dart (ServiceApp, ServiceCard, BookButton)

    flutter analyze без замечаний, flutter build web — ✓ Built build/web; flutter run -d web-server — сервер поднялся, выведены клавиши r/R; первая версия с Spacer упала в тесте 320×640 (RenderFlex overflowed by 19 pixels) — исправлено на Expanded

  • запущено, работает

    test/widget_test.dart

    flutter test: 2 теста, All tests passed (RU и EN)

  • запущено, работает

    Решения упражнений (TwoCardsScreen, FavoriteButton, ServiceListScreen)

    lib/exercises.dart + test/exercises_test.dart (3 теста) в том же проекте, flutter analyze без замечаний

  • запущено, работает

    Утверждение «Text без Expanded в Row переполняет строку» и сообщение анализатора о String?

    отдельный тест (A RenderFlex overflowed by 573 pixels) и dart analyze на файле с s.length для String? — текст ошибки сверен

  • не запускалось

    Запуск на Android/iOS-устройстве и в Chrome (flutter run -d chrome), hot reload вживую

    в контейнере нет устройств и Chrome; сверено с документацией docs.flutter.dev/install/quick и /tools/hot-reload

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

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

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

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