Start Debugging

Решение: Null check operator used on a null value во Flutter

Оператор ! наткнулся на null во время выполнения. Замените его на ?. и ?? для значения по умолчанию или защитите явной проверкой на null, вместо того чтобы утверждать значение, которого не было.

Вы написали something!, и something было null, когда выполнялась эта строка. Оператор проверки на null (bang) обещает компилятору “это никогда не null”, и Dart обеспечивает это обещание во время выполнения, выбрасывая исключение в тот момент, когда обещание нарушено. Решение почти всегда в том, чтобы перестать утверждать и начать обрабатывать: используйте ?. для короткого замыкания, ?? для значения по умолчанию или защиту if (x != null), которая позволит компилятору сузить тип за вас. На этой странице используются Flutter 3.44 (стабильный, май 2026) и Dart 3.x.

Ошибка в контексте

Когда оператор bang наталкивается на null, вы получаете TypeError с этим точным сообщением:

Unhandled Exception: Null check operator used on a null value

В слое виджетов он обычно появляется обёрнутым фреймворком, и именно так его видит большинство:

======== Exception caught by widgets library =======================================================
The following _TypeError was thrown building ProfilePage(dirty):
Null check operator used on a null value

The relevant error-causing widget was:
  ProfilePage ProfilePage:file:///lib/profile_page.dart:18:12

Класс это _TypeError (подтип TypeError), то же семейство, которое Dart использует для неудачных приведений. Это подсказка: оператор bang это приведение. Он приводит T? к T, и, как любое приведение, может завершиться неудачей во время выполнения.

Почему это происходит: оператор bang это проверяемое приведение

В строгой null safety String? и String это разные типы. Постфиксный ! это сокращение, которое, по словам документации Dart, “берёт выражение слева от себя и приводит его к лежащему в основе ненулевому типу”. Приведение из типа, допускающего null, к ненулевому нельзя доказать как безопасное во время компиляции, поэтому компилятор вставляет проверку во время выполнения. Если значение оказывается null, когда проверка выполняется, вы получаете Null check operator used on a null value.

Так что это никогда не баг компилятора и не баг фреймворка. Это значение, которое оказалось null в момент, когда ваш код поклялся, что оно им не будет. Задача в том, чтобы найти значение и решить, что должно происходить, когда оно действительно отсутствует, а не замазывать это ещё одним !.

Минимальное воспроизведение

Самая простая версия это одна переменная, допускающая null, которой ещё ничего не присвоено:

// Flutter 3.44, Dart 3.x
String? name;          // nullable, defaults to null
void main() {
  print(name!.length); // throws: Null check operator used on a null value
}

В реальном коде Flutter самая частая форма это данные, которые ещё не загрузились. Поле равно null, пока его не заполнит сетевой вызов, но build выполняется немедленно и разыменовывает его:

// Flutter 3.44, Dart 3.x -- crashes on first build
class ProfilePage extends StatefulWidget {
  const ProfilePage({super.key});
  @override
  State<ProfilePage> createState() => _ProfilePageState();
}

class _ProfilePageState extends State<ProfilePage> {
  User? _user; // null until the fetch returns

  @override
  void initState() {
    super.initState();
    _loadUser(); // async, completes some frames later
  }

  Future<void> _loadUser() async {
    final u = await api.fetchUser();
    setState(() => _user = u);
  }

  @override
  Widget build(BuildContext context) {
    return Text(_user!.name); // <-- _user is null on the first build
  }
}

build вызывается до того, как _loadUser завершится, поэтому _user всё ещё null на первом кадре, и _user! выбрасывает исключение.

Решение, в порядке предпочтения

Правильное решение зависит от того, является ли null законным состоянием (данные ещё грузятся, необязательное поле, отсутствующий ключ) или багом (вы ожидали значение, и его отсутствие означает, что выше по цепочке что-то сломано). Чаще всего это первое, и фреймворк даёт вам идиоматичные инструменты для этого.

1. Задайте значение по умолчанию через ??

Если есть разумное запасное значение, оператор объединения с null это самое короткое корректное решение. Он возвращает правую сторону, когда левая равна null:

// Flutter 3.44, Dart 3.x
Text(_user?.name ?? 'Loading...');

_user?.name равно null, когда _user равно null (?. замыкает всю цепочку), и ?? подставляет заглушку. Без выброса исключения, и интерфейс показывает что-то полезное, пока грузятся данные.

2. Явно ветвитесь по состоянию загрузки

Когда нет хорошего значения по умолчанию, отрисовывайте разные виджеты для загруженного и ещё-не-загруженного состояния. Проверка if (x != null) повышает локальную переменную до ненулевой внутри ветки, так что ! вам вообще не нужен:

// Flutter 3.44, Dart 3.x
@override
Widget build(BuildContext context) {
  final user = _user;            // copy to a local for promotion
  if (user == null) {
    return const Center(child: CircularProgressIndicator());
  }
  return Text(user.name);        // user is User here, not User?
}

Сначала скопируйте поле в локальную переменную. Dart повышает только локальные переменные, а не поля экземпляра, потому что другой метод (или другой isolate) мог бы изменить поле между проверкой и использованием. Локальная переменная это несущая часть этого шаблона.

3. Пусть FutureBuilder возьмёт на себя состояние null

Если значение приходит из одного асинхронного вызова, не мастерите флаг вручную. FutureBuilder моделирует загрузку, ошибку и данные как один объект, и вы читаете data только после того, как подтвердили, что оно присутствует:

// Flutter 3.44, Dart 3.x
FutureBuilder<User>(
  future: _userFuture, // created once, not in build -- see below
  builder: (context, snapshot) {
    if (snapshot.connectionState != ConnectionState.done) {
      return const CircularProgressIndicator();
    }
    if (snapshot.hasError) {
      return Text('Failed: ${snapshot.error}');
    }
    return Text(snapshot.data!.name); // safe: hasData is implied here
  },
);

snapshot.data! здесь законен, потому что вы уже доказали, что future завершился без ошибки. Одна оговорка, которая бьёт многих: создавайте future один раз и сохраняйте его, никогда не встраивайте прямо в build, иначе каждая перестройка запускает новую загрузку. Это отдельная ловушка, разобранная в почему FutureBuilder постоянно пересоздаёт свой Future.

4. Используйте late только когда инициализация действительно предшествует первому чтению

Если значение присваивается ровно один раз, до того как что-либо его прочитает, late убирает нулевость без bang. Но это сделка, а не бесплатный выигрыш: поле late, прочитанное до присваивания, выбрасывает LateInitializationError, другой и, пожалуй, худший сбой, потому что легко предположить, что late сделало значение безопасным. Прибегайте к нему только когда порядок гарантирован, например значение, заданное в initState и читаемое в build:

// Flutter 3.44, Dart 3.x
late final AnimationController _controller;

@override
void initState() {
  super.initState();
  _controller = AnimationController(vsync: this); // assigned before any build
}

Если порядок не гарантирован, держите поле допускающим null и защищайте его. Полный разбор того, когда late помогает, а когда вредит, есть в как исправить LateInitializationError во Flutter.

Обычные подозреваемые помимо флага загрузки

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

GlobalKey.currentState! до того, как виджет смонтирован. Вызов _formKey.currentState!.validate(), когда Form нет в дереве (или он ещё не построен), выбрасывает исключение, потому что currentState равно null, пока виджет не прикрепится. Используйте ?.:

// Flutter 3.44, Dart 3.x
if (_formKey.currentState?.validate() ?? false) {
  // form is valid and present
}

Аргументы маршрута, которые не были переданы. ModalRoute.of(context)!.settings.arguments as Args предполагает и что маршрут существует, и что аргументы были предоставлены. Если вы открываете маршрут без аргументов, arguments равно null, и последующее as или следующий ! взрывается. Читайте защитно:

// Flutter 3.44, Dart 3.x
final args = ModalRoute.of(context)?.settings.arguments as Args?;
if (args == null) return const ErrorScreen('Missing arguments');

Доступ к Map и JSON через [key]!. Поиск в map возвращает null для отсутствующего ключа, а json['email']! выбрасывает исключение в тот момент, когда поле отсутствует или API его переименовал. Декодируйте через модель с явной нулевостью или задавайте значение по умолчанию каждому полю:

// Flutter 3.44, Dart 3.x
final email = (json['email'] as String?) ?? '';

firstWhere с bang над результатом. Иногда пишут list.firstWhere((e) => e.id == id, orElse: () => null)!, чтобы “найти или упасть”. Это ровно непроверенное предположение. Предпочитайте firstWhereOrNull из package:collection и обрабатывайте пустой случай:

// Flutter 3.44, Dart 3.x
final match = list.firstWhereOrNull((e) => e.id == id);
if (match == null) { /* handle not found */ }

Варианты, которыми это не является, и куда идти вместо этого

Поисковый трафик по этой ошибке часто принадлежит соседней странице. Три двойника:

LateInitializationError: Field '_x' has not been initialized это родственник, а не та же ошибка. Она происходит от чтения переменной late до присваивания, а не от ! над допускающим null значением. Если в трассировке стека написано LateInitializationError, решение находится на странице LateInitializationError, а не здесь.

Проверка на null, которая срывается только после навигации и только в release, часто это симптом мёртвого контекста. Поиск по деактивированному контексту возвращает null в release-сборках (assert, который это ловит, только для debug), и этот null затем срабатывает на ! где-то ниже. Если сбой коррелирует с await, за которым следует использование контекста, прочитайте как безопасно использовать BuildContext после await, потому что настоящий баг находится выше bang.

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

Линтер, который ловит это до выполнения

Dart не может предупредить о каждом !, который мог бы упасть, потому что в этом и весь смысл оператора: вы переопределяете анализатор. Но он может отметить те, которые может доказать как бесполезные. Правило unnecessary_non_null_assertion, включённое по умолчанию в flutter_lints, срабатывает, когда вы ставите bang на значение, которое анализатор уже знает как ненулевое, что обычно означает, что ваша мысленная модель и система типов не согласны:

# analysis_options.yaml -- on by default via flutter_lints
include: package:flutter_lints/flutter.yaml

Более широкая дисциплина в том, чтобы относиться к каждому !, который вы печатаете, как к утверждению, которое надо защитить. Если вы не можете указать на строку, которая гарантирует, что значение ненулевое на этом пути, у вас не !, у вас скрытый Null check operator used on a null value. Моделирование асинхронных данных как явных состояний загрузки и ошибки, как в состояния загрузки и ошибки с AsyncValue, убирает большинство таких утверждений полностью, потому что фреймворк выдаёт вам значение только в той ветке, где оно существует.

Привычка, которая отправляет ошибку на пенсию

Оператор bang это обещание компилятору, погашаемое во время выполнения, а Null check operator used on a null value это квитанция о нарушенном обещании. Всякий раз, когда вас тянет написать !, спросите себя, что должно произойти, когда значение действительно null: заглушка (??), другой виджет (if (x != null)) или настоящая ошибка, которую вы выбрасываете намеренно. Выберите одно из этого, и сбой никогда не дойдёт до пользователя. Оставьте ! для редкого случая, когда null был бы настоящим нарушением инварианта, и даже тогда предпочтите выбросить StateError с сообщением, которое объясняет, что пошло не так, голому bang, который говорит лишь “это было null”.

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад