You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Riverpod StateNotifierProvider丢失state问题求助

问题背景

我开发了一个影视分类页面,每个分类以轮播组件形式展示影片,轮播组件嵌套在可展开面板中,用户可单独展开/收起分类,这部分功能正常。

需求

需要添加一个按钮,实现一键展开/收起所有分类。

实现方案

使用带family参数的StateNotifierProvider实现:

  • 状态包含:
    • genreKeysOnPage:页面所有分类ID列表(通过family参数传入)
    • filteredGenreKeys:用户手动收起的分类ID列表
  • 实现toggleCollapseAll方法:勾选时将genreKeysOnPage内容复制到filteredGenreKeys,取消勾选则清空filteredGenreKeys
问题现象

state中的genreKeysOnPage会随机变为空列表。在toggleCollapseAll方法中添加打印,连续点击5次的结果为:
1-3次:输出[14, 878, 28, 12]
4-5次及之后:输出空列表[]
我从未修改过genreKeysOnPage属性,且Provider仅初始化一次。

求助代码

Controller与Provider代码

class ViewCarouselByGenreController
    extends StateNotifier<ViewCarouselByGenreState> {
  ViewCarouselByGenreController(
      this._localUserRepo, this._userService, this._genreKeys)
      : super(ViewCarouselByGenreState.initial()) {
    _init();
  }

  final List<String> _genreKeys;
  final LocalUserRepo _localUserRepo;
  final UserService _userService;

  void _init() async {
    final user = await _localUserRepo.fetchUserLocally();
    final filteredGenres = user.filteredGenreIds;
    state = state.copyWith(
      genreKeysOnPage: _genreKeys,
      filteredGenreKeys: filteredGenres,
    );
  }

  bool checkIfFiltered(String genreKey) {
    if (state.filteredGenreKeys.contains(genreKey)) {
      return true;
    } else {
      return false;
    }
  }

  // ! This stops looping because state.genreKeysOnPage randomly empties!
  bool? detectCollapseAllStatus() {
    int filteredCount = 0;
    for (final item in state.genreKeysOnPage) {
      if (state.filteredGenreKeys.contains(item)) {
        filteredCount++;
      }
    }
    if (filteredCount == state.genreKeysOnPage.length) {
      return true;
    } else if (filteredCount == 0) {
      return false;
    } else {
      return null;
    }
  }

  void toggleCollapseAll() {
    final allGenreKeys = state.genreKeysOnPage;
    // ! This will be empty after from the 4th execution, Even if i pass _genreKeys directly 
    // ! (i.e the property received via family constructor) it still empties!!! 
    print('ALL GENRE KEYS: $allGenreKeys');
    final allCollapsed = detectCollapseAllStatus();
    if (allCollapsed == true) {
      state = state.copyWith(
        filteredGenreKeys: [],
        allGenresCollapsed: false,
      );
    } else {
      state = state.copyWith(
        filteredGenreKeys: allGenreKeys,
        allGenresCollapsed: true,
      );
    }
  }

  void toggleCollapseSingle(String filterKey) async {
    var filteredGenreKeys = state.filteredGenreKeys;
    if (filteredGenreKeys.contains(filterKey)) {
      filteredGenreKeys.removeWhere((element) => element == filterKey);
    } else {
      filteredGenreKeys.add(filterKey);
    }
    state = state.copyWith(filteredGenreKeys: filteredGenreKeys);
  }

  void saveFilterUpdates() {
    _userService.saveFilteredGenreUpdates(
        state.genreKeysOnPage, state.filteredGenreKeys);
  }
}

final viewCarouselByGenreControllerProvider = StateNotifierProvider.autoDispose
    .family<ViewCarouselByGenreController, ViewCarouselByGenreState,
        List<String>>(
  (ref, genreKeys) {
    return ViewCarouselByGenreController(
      ref.watch(localUserRepoProvider),
      ref.watch(userServiceProvider),
      genreKeys,
    );
  },
);

State类代码

class ViewCarouselByGenreState {
  ViewCarouselByGenreState({
    this.allGenresCollapsed,
    required this.filteredGenreKeys,
    required this.genreKeysOnPage,
  });

  bool? allGenresCollapsed;
  List<String> filteredGenreKeys;
  List<String> genreKeysOnPage;

  factory ViewCarouselByGenreState.initial() {
    return ViewCarouselByGenreState(
      filteredGenreKeys: [],
      genreKeysOnPage: [],
    );
  }

  String get filteredKeysCommaSeparated =>
      filteredGenreKeys.map((e) => e).toList().join(', ');

  ViewCarouselByGenreState copyWith({
    bool? allGenresCollapsed,
    List<String>? filteredGenreKeys,
    List<String>? genreKeysOnPage,
  }) {
    return ViewCarouselByGenreState(
      allGenresCollapsed: allGenresCollapsed ?? this.allGenresCollapsed,
      filteredGenreKeys: filteredGenreKeys ?? this.filteredGenreKeys,
      genreKeysOnPage: genreKeysOnPage ?? this.genreKeysOnPage,
    );
  }

  @override
  bool operator ==(covariant ViewCarouselByGenreState other) {
    if (identical(this, other)) return true;

    return other.allGenresCollapsed == allGenresCollapsed &&
        listEquals(other.filteredGenreKeys, filteredGenreKeys) &&
        listEquals(other.genreKeysOnPage, genreKeysOnPage);
  }

  @override
  int get hashCode =>
      allGenresCollapsed.hashCode ^
      filteredGenreKeys.hashCode ^
      genreKeysOnPage.hashCode;

  @override
  String toString() =>
      'ViewCarouselByGenreState(allGenresCollapsed: $allGenresCollapsed, filteredGenreKeys: $filteredGenreKeys, genreKeysOnPage: $genreKeysOnPage)';
}

问题原因与修复方案

核心问题:可变列表的引用共享导致状态污染

代码存在两个关键问题,共同导致genreKeysOnPage被意外清空:

1. toggleCollapseSingle直接修改原状态列表

该方法中直接获取state.filteredGenreKeys的引用并修改,违反了StateNotifier的不可变状态原则:

var filteredGenreKeys = state.filteredGenreKeys;
filteredGenreKeys.removeWhere(...) // 直接修改原状态的列表

这种操作会破坏状态的不可变性,导致后续状态更新时出现引用混乱。

2. toggleCollapseAll传递列表引用导致共享

在切换全选时,直接将genreKeysOnPage的引用赋值给filteredGenreKeys:

filteredGenreKeys: allGenreKeys

这意味着两个列表指向同一个内存对象,后续修改filteredGenreKeys时,genreKeysOnPage会被同步修改,最终被清空。

修复步骤

步骤1:修改toggleCollapseSingle,创建列表副本

避免直接修改原状态列表,改用副本操作:

void toggleCollapseSingle(String filterKey) {
  final filteredGenreKeys = List<String>.from(state.filteredGenreKeys);
  if (filteredGenreKeys.contains(filterKey)) {
    filteredGenreKeys.removeWhere((element) => element == filterKey);
  } else {
    filteredGenreKeys.add(filterKey);
  }
  state = state.copyWith(filteredGenreKeys: filteredGenreKeys);
}

步骤2:修改toggleCollapseAll,传递列表副本

确保filteredGenreKeys使用独立的列表对象:

void toggleCollapseAll() {
  final allGenreKeys = state.genreKeysOnPage;
  print('ALL GENRE KEYS: $allGenreKeys');
  final allCollapsed = detectCollapseAllStatus();
  if (allCollapsed == true) {
    state = state.copyWith(
      filteredGenreKeys: [],
      allGenresCollapsed: false,
    );
  } else {
    state = state.copyWith(
      filteredGenreKeys: List<String>.from(allGenreKeys),
      allGenresCollapsed: true,
    );
  }
}

步骤3:初始化时传入列表副本(可选)

为避免外部修改传入的_genreKeys影响状态,在_init中也创建副本:

void _init() async {
  final user = await _localUserRepo.fetchUserLocally();
  final filteredGenres = user.filteredGenreIds;
  state = state.copyWith(
    genreKeysOnPage: List<String>.from(_genreKeys),
    filteredGenreKeys: List<String>.from(filteredGenres),
  );
}

额外优化

简化checkIfFiltered方法:

bool checkIfFiltered(String genreKey) => state.filteredGenreKeys.contains(genreKey);

内容的提问来源于stack exchange,提问作者Alex Cretney

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.27 13:07:12