非产品级Flutter开源项目WanAndroid

8,868 阅读8分钟

标题和项目都很大程度上参考了《产品级Flutter开源项目FunAndroid,Provider MVVM的最佳实践》,还有OpenJMU,在此特别感谢phoenixskyA少

介绍

项目地址

背景

做这个项目主要是Flutter更新得太快了,各种新的插件和API都让我手痒,公司的项目以稳为主肯定很难跟得上Flutter这种频率的更新速度,所以不如自己捣鼓一个可以乱来的项目;于是我就把自己感兴趣的插件都加到一起乱炖,也算是一种技术储备。

插件

  1. beamer
  2. riverpod
  3. freezed

已完成的功能

  • 登录和注册
  • 首页轮播和文章
  • 广场及问答文章
  • 项目文章及项目类型切换
  • 搜索及搜索结果
  • 用户的分享和文章
  • 积分排行榜
  • 我的积分
  • 我的收藏及收藏的添加,编辑和删除
  • 我的分享及分享的添加,编辑和删除
  • 黑暗模式和多语言的支持
  • Todo
  • Flutter for web
  • ..

展示

首页项目搜索
home_screen.pngproject_screen.pngsearch_screen.png
搜索结果文章侧边栏
search_result_screen.pngarticle_screen.png.png

路由管理

这部分内容纯粹是自己使用之后的某种结论,大部分未经Debug验证,且截止发文也还存在一些bug,后续估计还是要做大量改动,所以各位可以参考着配置运行这个项目,但不建议作为指南去参考,具体使用方法建议参考官方文档

在刚刚接触Flutter的时候就看到了《Flutter Navigator2.0 完全指南与原理解析》,当时对里面Page,Router,RouterDelegate的概念压根就搞不明白,却牢牢记住了它可以让一个页面从A -> B -> C直接变成D -> E -> F -> G,并且支持web的URL输入导航,算是我很早就想尝试的API了

所以一开始就Copy拿着代码开始捣鼓,捣鼓了一段时间后想到一个登录拦截的问题,还没等我想咋解决,Flutter2.8.0发布,Flutter Favorites里多了几个Navigator2.0的库,并且beamer里还有路由守卫的功能。

起初因为使用了Navigator2.0的缘故,App的开发一直是在Web端的,当时因为本身Navigator2.0就不太熟练叠加强行使用beamer,所以各种奇怪的问题不断,最终我选择了使用ChangeNotifier管理路由的状态,然后定义了一个复杂的HomeState,并且每添加一个页面就需要在里面定义一小堆东西,我感觉我肯定是哪里搞错了,但是例子确实基本上也都是这么写的。

因为有一个切换ProjectType的功能想使用showModalBottomSheet,在当前页面用底部弹窗的形式实现,然后发现貌似并没有对应的Route可以使用,因为官方对应的Route是私有的,于是把官方的Route拿出来改了一下,意外地实现了;后面的showSearch也就如法炮制,代码在这里

官方私有的ModalBottomSheetRoute目前已经被PR给公开了

使用展示

/lib/navigator/home/home_state.dart

后改用了go_router

在此处定义HomeState,其实叫AppState或者RouteState比较合适,之所以叫HomeState是因为当时以为可以将各种页面的State区分开来,后来发现好像不太行(涉及到一个在buildPages中的state转换的问题,当时没想出太好的做法),于是本着先把功能实现的目的就一直沿用了,写到这里的时候想到官方文档有个示例,但是在最新版本貌似已经删除了,个人一点不负责任的理解,感觉自定义多个Location应该适用于A -> B -> CA1 -> B1 -> C1这种ABC的路由栈不会与A1B1C1有任何交集这种

HomeState({
    String initialPath = RouterName.initialPath,
    /// 一般增加新的标识变量,通常是字符串,数字或者布尔值
  })  : _initialPath = initialPath;
  
/// 配置fromJson,toJson,updateWith,copyWith
/// updateWith中的notifyListeners配合HomeLocation中的
/// state.addListener(notifyListeners)实际上就是路由切换的原因
/// updateWith在更新int类型时要注意将-1视作null
/// 因为updateWith时默认即是null值,会忽略null传入的情况

@override
HomeState fromRouteInformation(RouteInformation routeInformation) {
  final Uri uri =
      Uri.parse(routeInformation.location ?? RouterName.home.location);
  LogUtils.d('from routeInformation : $uri');
  final String uriString = uri.toString();
  
  /// 这里的routeInformation.state是想将toRouteInformation中设置的state在此处获取
  /// 并还原,如果没有就取默认值
  final HomeState homeState = HomeState.fromJson(
      routeInformation.state as Map<String, dynamic>? ?? <String, dynamic>{});
  
  /// 在此处添加一个判断用于处理新的uri
  if (RouterName.homeTabsPath.contains(uriString)) {
    return homeState.copyWith(
      initialPath: uriString,
    );
  }
  /// 如果是具有参数的uri可以用这种方式获取
  if (uri.pathSegments.first == RouterName.article.title.toLowerCase() &&
      uri.pathSegments.contains('id')) {
    return homeState.copyWith(
      articleId: uri.pathSegments.last as int,
    );
  }
}
  
@override
RouteInformation toRouteInformation() {
  LogUtils.d('$runtimeType to routeInformation ${toJson()}');
  
  /// 这里需要注意由于HomeState中的各种数据是存在叠加的
  /// 比如当在切换语言页面时,isSettings和isLanguages都是true
  /// 所以在这里处理判断逻辑时应改跟HomeLocation的[buildPages]顺序完全相反

  /// The order here should be reversed from the Location [buildPages]
  if (showSplash) {
    return RouteInformation(
      location: RouterName.splash.location,
      state: toJson(),
    );
  }
}

/lib/navigator/home/home_location.dart

后改用了go_router

@override
List<BeamPage> buildPages(BuildContext context, HomeState state) {
  LogUtils.d('$runtimeType build pages: state = $state');

  /// 注意排列的顺序,用户看到的页面是数组中的最后一项
  /// 即如果我的页面栈是[A -> B -> C],那么这里就应该是[A,B,C]

  return <BeamPage>[
    BeamPage(
      key: ValueKey<String>(RouterName.home.location),
      title: RouterName.home.title,
      child: HomeScreen(
        initialPath: state.initialPath,
      ),
    ),
    if (state.showSearch)
      BeamPage(
        /// key值必须传入,这决定了navigator对buildPages的优化
        key: ValueKey<String>(RouterName.search.location),

        /// 决定浏览器中的标题
        title: RouterName.search.title,
        routeBuilder: (_, RouteSettings settings, Widget child) {
          return SearchPageRoute<void>(
            delegate: HomeSearchDelegate(),
            settings: settings,
          );
        },
        child: const SizedBox.shrink(),

        /// 这里很重要,在返回时将HomeState的状态重置到进来前
        /// 这样我们可以在页面中调用Navigator.of(context).maybePop()进行返回
        onPopPage: (
          _,
          __,
          RouteInformationSerializable<dynamic> state,
          ___,
        ) {
          (state as HomeState).updateWith(
            showSearch: false,
          );
          return true;
        },
      ),
  ];
}

导航

/// 在页面中进行导航
/// 此时由于整个路由栈都由HomeState的各种值决定
/// 所以我们可以轻松实现各种各样的导航
AppRouterDelegate.instance.currentBeamState.updateWith(
  articleId: article.id,
);

守卫

BeamerDelegate _crateDelegate(Reader reader) => BeamerDelegate(
      initialPath: RouterName.home.location,
      notFoundRedirectNamed: RouterName.unknown.location,
      navigatorObservers: <NavigatorObserver>[
        FlutterSmartDialog.observer,
        Instances.routeObserver,
      ],
      locationBuilder: BeamerLocationBuilder(
        beamLocations: <BeamLocation<RouteInformationSerializable<dynamic>>>[
          HomeLocation(),
        ],
      ),
      guards: <BeamGuard>[
        BeamGuard(
          /// [guardNonMatching]为true的意思是除了[pathPatterns]之外的都拦截
          /// 默认false即是只拦截[pathPatterns]内的页面
          guardNonMatching: true,
          pathPatterns: <Pattern>[RouterName.splash.location],
          /// 返回一个布尔值
          check: (_, __) => reader.call(splashProvider),
          /// 处理一下HomeState
          beamTo: (
            _,
            __,
            BeamLocation<RouteInformationSerializable<dynamic>> target,
          ) =>
              (target as HomeLocation)
                ..state.updateWith(
                  showSplash: true,
                ),
        ),
        BeamGuard(
          pathPatterns: <Pattern>[
            ...RouterName.homeDrawerPath,
          ],
          check: (_, __) => reader.call(authorizedProvider) != null,
          /// 需要将被拦截的HomeState值置为false,否则路由栈会变成[被拦截的页面 -> 重定向的页面]
          beamTo: (
            _,
            __,
            BeamLocation<RouteInformationSerializable<dynamic>> target,
          ) =>
              (target as HomeLocation)
                ..state.updateWith(
                  isLogin: true,
                  isMyCollections: false,
                  isMyPoints: false,
                  isMyShare: false,
                ),
        ),
      ],
    );

小结

其实整个体验下来感觉还不如1.0,例如我个人挺喜欢的1.0Future式的导航方式可以自由地返回一些数据而不必借助状态管理工具,在2.0反而不知道如何处理这种情况了,当然实际上在2.0还是可以像1.0一样去导航,但是那种页面就脱离了2.0的路由状态,需要像1.0一样去处理那种页面了;整个的HomeState实现下来感觉也有点过于复杂了,肯定不如1.0来得清晰直观,作为一个web开发者对web终究还是有感情摆着的,还是想要挑战一下Flutter的web,目前来看算是挑战失败了吧,还是对2.0理解不够。

状态管理

关于riverpod的原理刚好最近郭老师有篇文章《Flutter Riverpod 全面深入解析,为什么官方推荐它?》,我是个粗人,一般就是边实践边等大佬们出文章or小册再理解。

我的provider启蒙老师就是phoenixsky的那篇文章,至今仍受益匪浅,所以当想做这个项目的时候首先就要考虑用riverpod把phoenixsky用provider的实现的架构给实现出来

  1. 首先利用freezed联合特性封装好三种常用模型的联合模型,使其天然具有状态,即loading,success和error,代码在这里
  2. 根据三种模型构造对应的抽象类,将获取数据,处理错误之类的逻辑抽象出来,代码在这里
  3. 将抽象类与widget对应起来,这里主要是对BaseListViewNotifier<T>BaseRefreshListViewNotifier<T>的widget封装,代码在这里 此时就基本上完成了与phoenixsky一样的架构。

widget已经移除了,在第二篇中有说明,改用mixin

使用展示

/// 根据需求继承需要的抽象类
/// 实现[loadData]方法
class QuestionNotifier extends BaseArticleNotifier {
  QuestionNotifier(RefreshListViewState<ArticleModel> state) : super(state);

  @override
  Future<RefreshListViewStateData<ArticleModel>> loadData(
      {required int pageNum, required int pageSize}) async {
    return (await WanAndroidAPI.fetchQuestionArticles(
      pageNum,
      pageSize,
    ))
        .toRefreshListViewStateData();
  }
}

/// 根据需求定义好provider,并返回Notifier
/// 此处需要给Notifier一个初始值(即默认值)
/// 此处默认值为 RefreshListViewState<ArticleModel>.loading()
final StateNotifierProvider<QuestionNotifier,
        RefreshListViewState<ArticleModel>> questionArticleProvider =
    StateNotifierProvider<QuestionNotifier, RefreshListViewState<ArticleModel>>(
  (_) {
    return QuestionNotifier(
      const RefreshListViewState<ArticleModel>.loading(),
    );
  },
  name: kQuestionArticleProvider,
);

/// 配合组件使用
/// 传入[provider]后可在[onInitState]中决定何时初始化
RefreshListViewWidget<
    StateNotifierProvider<QuestionNotifier,
        RefreshListViewState<ArticleModel>>,
    ArticleModel>(
  provider: questionArticleProvider,
  onInitState: (Reader reader) {
    reader.call(questionArticleProvider.notifier).initData();
  },
  builder: (_, __, List<ArticleModel> list) {
    return SliverList(
      delegate: CustomSliverChildBuilderDelegate.separated(
        itemBuilder: (_, int index) {
          return ArticleTile(
            article: list[index],
          );
        },
        itemCount: list.length,
      ),
    );
  },
),

有趣的用法

wanandroid中的项目数据接口是这样的/project/list/$pageNum/json?cid=$categoryId,依赖一个categoryId,这个id需要通过/project/tree/json这个数据接口请求过来

/// 先创建ProjectTypes的Provider和Notifier
final StateNotifierProvider<ProjectTypeNotifier,
        ListViewState<ProjectTypeModel>> projectTypesProvider =
    StateNotifierProvider<ProjectTypeNotifier, ListViewState<ProjectTypeModel>>(
        (_) {
  return ProjectTypeNotifier(
    const ListViewState<ProjectTypeModel>.loading(),
  );
});

class ProjectTypeNotifier extends BaseListViewNotifier<ProjectTypeModel> {
  ProjectTypeNotifier(ListViewState<ProjectTypeModel> state) : super(state);

  int _selectedIndex = 0;
  int get selectedIndex => _selectedIndex;

  @override
  Future<List<ProjectTypeModel>> loadData() async {
    final List<ProjectTypeModel> data = await WanAndroidAPI.fetchProjectTypes();
    data.first = data.first.copyWith(isSelected: true);
    return data;
  }

  void selected(int index) {
    state.whenOrNull(
      (List<ProjectTypeModel> value) {
        if (_selectedIndex != index) {
          value[_selectedIndex] =
              value[_selectedIndex].copyWith(isSelected: false);

          value[index] = value[index].copyWith(isSelected: true);

          _selectedIndex = index;

          state = ListViewState<ProjectTypeModel>(list: value);
        }
      },
    );
  }
}

/// 创建当前选中的ProjectType的Provider
final StateProvider<ViewState<ProjectTypeModel>> currentProjectTypeProvider =
    StateProvider<ViewState<ProjectTypeModel>>(
        (StateProviderRef<ViewState<ProjectTypeModel>> ref) {
  /// 监听projectTypesProvider的状态返回ViewState<ProjectTypeModel>
  return ref.watch(projectTypesProvider).when(
        /// projectTypesProvider有值时将当前选中的ProjectTypeModel返回
        (List<ProjectTypeModel> value) => ViewStateData<ProjectTypeModel>(
          value: value[ref.read(projectTypesProvider.notifier).selectedIndex],
        ),
        /// projectTypesProvider在等待时也返回等待状态
        loading: () => const ViewStateLoading<ProjectTypeModel>(),
        /// projectTypesProvider出错时将projectTypesProvider的错误信息返回
        error: (int? statusCode, String? message, String? detail) =>
            ViewStateError<ProjectTypeModel>(
          statusCode: statusCode,
          message: message,
          detail: detail,
        ),
      );
});

/// 创建projectArticleProvider和ProjectNotifier
final StateNotifierProvider<ProjectNotifier, RefreshListViewState<ArticleModel>>
    projectArticleProvider =
    StateNotifierProvider<ProjectNotifier, RefreshListViewState<ArticleModel>>(
  (StateNotifierProviderRef<ProjectNotifier, RefreshListViewState<ArticleModel>>
      ref) {
    /// 监听currentProjectTypeProvider的状态返回RefreshListViewState<ArticleModel>
    return ref.watch(currentProjectTypeProvider).when(
          /// currentProjectTypeProvider有值时将categoryId传入
          /// 并执行initData()
          (ProjectTypeModel? value) => ProjectNotifier(
            const RefreshListViewState<ArticleModel>.loading(),
            categoryId: value!.id,
          )..initData(),
          /// currentProjectTypeProvider在等待时也返回等待状态
          loading: () => ProjectNotifier(
            const RefreshListViewState<ArticleModel>.loading(),
            categoryId: null,
          ),
          /// currentProjectTypeProvider出错时将错误信息返回
          /// 此时的错误信息实际上是projectTypesProvider的错误信息
          error: (int? statusCode, String? message, String? detail) =>
              ProjectNotifier(
            RefreshListViewState<ArticleModel>.error(
              statusCode: statusCode,
              message: message,
              detail: detail,
            ),
            categoryId: null,
          ),
        );
  },
  name: kProjectArticleProvider,
);

class ProjectNotifier extends BaseArticleNotifier {
  ProjectNotifier(
    RefreshListViewState<ArticleModel> state, {
    required this.categoryId,
  }) : super(state);

  final int? categoryId;

  @override
  Future<RefreshListViewStateData<ArticleModel>> loadData(
      {required int pageNum, required int pageSize}) async {
    return (await WanAndroidAPI.fetchProjectArticles(pageNum, pageSize,
            categoryId: categoryId!))
        .toRefreshListViewStateData();
  }
}

/// 在Widget中
RefreshListViewWidget<StateNotifierProvider<ProjectNotifier, RefreshListViewState<ArticleModel>>, ArticleModel>(
  provider: projectArticleProvider,
  onInitState: (Reader reader) {
    /// 只需初始化projectTypesProvider
    reader.call(projectTypesProvider.notifier).initData();
  },
  /// 覆盖默认的重试方法
  onRetry: (Reader reader) {
    /// 当projectTypesProvider出错时,重试projectTypesProvider
    if (reader.call(projectTypesProvider) is ListViewStateError<ProjectTypeModel>) {
      reader.call(projectTypesProvider.notifier).initData();
    } else {
      /// 否则使用自身的重试方法
      reader.call(projectArticleProvider.notifier).initData();
    }
  },
  builder: (_, __, List<ArticleModel> list) {
    return SliverList(
      delegate: CustomSliverChildBuilderDelegate.separated(
        itemBuilder: (_, int index) {
          return ArticleTile(
            article: list[index],
          );
        },
        itemCount: list.length,
      ),
    );
  },
  slivers: <Widget>[
    SliverPinnedPersistentHeader(
      delegate:
          _ProjectTypeSwitchSliverPinnedPersistentHeaderDelegate(
        extentProtoType: const _ProjectTypeSwitchExtentProtoType(),
      ),
    ),
  ],
),

这里简单的放一下实现的效果

project_screen.gif

奇怪的用法

wanandroid中有文章收藏的功能,因为App端中显示文章一般是使用内置的webview进行展示,此时如果想要在webview中操作收藏的状态就会涉及到一个尴尬的问题,收藏的状态是在文章列表中的,如果在webview中操作了状态是需要同步到外部的,并且光是在首页就有4个文章列表

phoenixsky的解决方案是在外部自己维护一个收藏文章的id列表,每次修改收藏状态时去同步列表的状态

我的解决方案是找鸿洋大神要接口,但是鸿洋大神好像并没有空理我,那咋办嘛

2023年02月19日更新,理了

/// 给Provider起名字,这里是首页的4个Provider
const List<String> articles = <String>[
  kHomeArticleProvider,
  kSquareArticleProvider,
  kQuestionArticleProvider,
  kProjectArticleProvider,
];

/// 使用了AutoDispose的Provider需要区分开来
const List<String> autoDisposeArticles = <String>[
  kSearchArticleProvider,
  kMyShareProvider,
];

/// 收藏的Provider
const List<String> collects = <String>[
  kMyCollectedArticleProvider,
  kMyCollectedWebsiteProvider,
];

/// 扔一起
const List<String> allArticles = <String>[
  ...articles,
  ...autoDisposeArticles,
  ...collects,
];

final AutoDisposeStateNotifierProviderFamily<ArticleNotifier,
        ViewState<WebViewModel>, int> articleProvider =
    StateNotifierProvider.autoDispose
        .family<ArticleNotifier, ViewState<WebViewModel>, int>((
  AutoDisposeStateNotifierProviderRef<ArticleNotifier, ViewState<WebViewModel>>
      ref,
  int articleId,
) {
  return ArticleNotifier(
    reader: ref.read,
    /// 一切罪恶的开始
    providerContainer: ref.container,
    id: articleId,
  );
});

class ArticleNotifier extends BaseViewNotifier<WebViewModel> {
  ArticleNotifier({
    required this.reader,
    required this.providerContainer,
    required this.id,
  }) : super(const ViewState<WebViewModel>.loading());

  final Reader reader;
  final ProviderContainer providerContainer;
  final int id;

  late String from;
  late ProviderBase<dynamic> provider;

  final List<String> articleOrigin = <String>[];
  final List<ProviderBase<dynamic>> articleOriginProvider =
      <ProviderBase<dynamic>>[];

  @override
  Future<WebViewModel?> loadData() async {
    late WebViewModel webViewModel;

    CollectedArticleModel? collectedArticle;
    CollectedWebsiteModel? collectedWebsite;

    ArticleModel? article;
    
    /// 如果Provider的名字在allArticles内就把它连名字一起塞进这个map中
    final Map<String, ProviderBase<dynamic>> providers = providerContainer
        .getAllProviderElements()
        .fold(<String, ProviderBase<dynamic>>{},
            (Map<String, ProviderBase<dynamic>> previousValue,
                ProviderElementBase<dynamic> e) {
      if (allArticles.contains(e.provider.name)) {
        return <String, ProviderBase<dynamic>>{
          ...previousValue,
          e.provider.name!: e.provider,
        };
      }
      return previousValue;
    });
    
    /// 我们只有一个articleId
    /// 也拿到了ProviderContainer中所有存活的有可能有对应文章的provider
    /// 找肯定是能找的到的了,但是还是可以优化一下找法
    /// 比如AutoDispose的provider可以优先拉出来找
    /// 因为一般情况下他们都是不存在或者销毁了的,
    /// 如果存在了,就说明用户当前就在那个页面上,
    /// 此时先在里面找就好了

    /// [kMyCollectedArticleProvider] and [kMyCollectedWebsiteProvider] is
    /// autoDispose provider, So if they exist, it can be considered to be
    /// currently in the [MyCollectionsScreen]
    /// that is, they can be searched first from them
    if (providers.keys.contains(kMyCollectedArticleProvider)) {
      if (providers.keys.contains(kMyCollectedWebsiteProvider)) {
        from = kMyCollectedWebsiteProvider;
        provider = providers[kMyCollectedWebsiteProvider]!;

        collectedWebsite =
            (reader.call(provider) as ListViewState<CollectedWebsiteModel>)
                .whenOrNull((List<CollectedWebsiteModel> list) => list)
                ?.firstWhereOrNull((CollectedWebsiteModel e) => e.id == id);
      } else {
        from = kMyCollectedArticleProvider;
        provider = providers[kMyCollectedArticleProvider]!;

        collectedArticle = (reader.call(provider)
                as RefreshListViewState<CollectedArticleModel>)
            .whenOrNull((_, __, List<CollectedArticleModel> list) => list)
            ?.firstWhereOrNull((CollectedArticleModel e) => e.id == id);
      }
    } else {
      for (final String key in providers.keys) {
        if (<String>[...articles, ...autoDisposeArticles].contains(key)) {
          from = key;
          provider = providers[key]!;
          article =
              (reader.call(provider) as RefreshListViewState<ArticleModel>)
                  .whenOrNull((_, __, List<ArticleModel> list) => list)
                  ?.firstWhereOrNull((ArticleModel e) => e.id == id);

          if (article != null) {
            break;
          }
        }
      }
    }

    if (collectedArticle != null) {
      webViewModel = WebViewModel(
        id: collectedArticle.id,
        link: collectedArticle.link.startsWith('http')
            ? collectedArticle.link
            : 'https://${collectedArticle.link}',
        originId: collectedArticle.originId,
        title: collectedArticle.title,
        collect: collectedArticle.collect,
      );

      if (collectedArticle.originId != null) {
        for (final String key in providers.keys) {
          if (<String>[...articles, ...autoDisposeArticles].contains(key)) {
            articleOrigin.add(key);
            articleOriginProvider.add(providers[key]!);
          }
        }
      }
    } else if (collectedWebsite != null) {
      webViewModel = WebViewModel(
        id: collectedWebsite.id,
        link: collectedWebsite.link.startsWith('http')
            ? collectedWebsite.link
            : 'https://${collectedWebsite.link}',

        /// Use -2 as the logo of collected sites
        /// because -1 has a role in collected articles :)
        originId: -2,
        title: collectedWebsite.name,
        collect: collectedWebsite.collect,
      );
    } else if (article != null) {
      webViewModel = WebViewModel(
        id: article.id,
        link: article.link,
        title: article.title,
        collect: article.collect,
      );
    } else {
      /// 如果还没找到说明provider中没有这个文章,就返回找不到
      /// 在flutter for web中直接输入URL会永远返回这个,因为provider们还没初始化
      throw AppException(
        errorCode: 404,
        message: S.current.articleNotFound,
        detail: S.current.articleNotFoundMsg,
      );
    }

    return webViewModel;
  }
  
  /// 切换收藏状态时因为我们已经拿到对应文章的provider了,所以用provider中的方法去同步收藏状态就好了
  void collect(bool changedValue) {
    state.whenOrNull((WebViewModel? value) async {
      if (value != null) {
        state = ViewStateData<WebViewModel>(
          value: value.copyWith(
            collect: changedValue,
          ),
        );
        try {
          if (value.originId != null) {
            /// from MyCollectionsScreen
            if (value.originId == -2) {
              /// from MyCollectionsScreen - website
              final AutoDisposeStateNotifierProvider<MyCollectedWebsiteNotifier,
                      ListViewState<CollectedWebsiteModel>> realProvider =
                  provider as AutoDisposeStateNotifierProvider<
                      MyCollectedWebsiteNotifier,
                      ListViewState<CollectedWebsiteModel>>;
              if (changedValue) {
                final CollectedWebsiteModel? newCollectedWebsite =
                    await reader.call(realProvider.notifier).add(
                          title: value.title ?? '',
                          link: value.link,
                          needLoading: false,
                        );

                if (newCollectedWebsite != null) {
                  state = ViewStateData<WebViewModel>(
                    value: value.copyWith(
                      id: newCollectedWebsite.id,
                      collect: true,
                    ),
                  );
                }
              } else {
                await reader
                    .call(realProvider.notifier)
                    .requestCancelCollect(collectId: value.id);
              }

              reader.call(realProvider.notifier).switchCollect(
                    id,
                    changedValue: changedValue,
                  );
            } else {
              /// from MyCollectionsScreen - article
              final AutoDisposeStateNotifierProvider<MyCollectedArticleNotifier,
                      RefreshListViewState<CollectedArticleModel>>
                  realProvider = provider as AutoDisposeStateNotifierProvider<
                      MyCollectedArticleNotifier,
                      RefreshListViewState<CollectedArticleModel>>;
              if (changedValue) {
                await WanAndroidAPI.addCollectedArticleByArticleId(
                    articleId: id);
              } else {
                reader.call(realProvider.notifier).requestCancelCollect(
                      collectId: id,
                      articleId: value.originId,
                    );
              }
              reader.call(realProvider.notifier).switchCollect(
                    id,
                    changedValue: changedValue,
                  );

              if (articleOrigin.isNotEmpty) {
                for (int index = 0; index < articleOrigin.length; index++) {
                  if (autoDisposeArticles.contains(articleOrigin[index])) {
                    reader
                        .call((articleOriginProvider[index]
                                as AutoDisposeStateNotifierProvider<
                                    BaseArticleNotifier,
                                    RefreshListViewState<ArticleModel>>)
                            .notifier)
                        .switchCollect(
                          value.originId!,
                          changedValue: changedValue,
                        );
                  } else {
                    reader
                        .call((articleOriginProvider[index]
                                as StateNotifierProvider<BaseArticleNotifier,
                                    RefreshListViewState<ArticleModel>>)
                            .notifier)
                        .switchCollect(
                          value.originId!,
                          changedValue: changedValue,
                        );
                  }
                }
              }
            }
          } else {
            /// from other article screen
            /// eg. HomeArticleScreen, SearchScreen
            if (changedValue) {
              await WanAndroidAPI.addCollectedArticleByArticleId(articleId: id);
            } else {
              await WanAndroidAPI.deleteCollectedArticleByArticleId(
                  articleId: id);
            }

            if (autoDisposeArticles.contains(from)) {
              reader
                  .call((provider as AutoDisposeStateNotifierProvider<
                          BaseArticleNotifier,
                          RefreshListViewState<ArticleModel>>)
                      .notifier)
                  .switchCollect(
                    id,
                    changedValue: changedValue,
                  );
            } else {
              reader
                  .call((provider as StateNotifierProvider<BaseArticleNotifier,
                          RefreshListViewState<ArticleModel>>)
                      .notifier)
                  .switchCollect(
                    id,
                    changedValue: changedValue,
                  );
            }
          }
        } catch (e, s) {
          DialogUtils.danger(getError(e, s).message ?? S.current.failed);
        }
      }
    });
  }
}

性能问题,当数据量过大时遍历肯定会有问题,那就开摆,就当作学了一个riverpod奇怪的用法吧:)

小结

最近发现riverpod已经2.0.0-dev.3了,并且作者Remi Rousselet这个issue中提到了在2.0.0上面文档需要做的一些工作,我也找到一些作者自己做的例子,基本上所有的变量都可以通过riverpod进行管理,甚至是dio,riverpod自己也提供了FutureProviderAsyncValue,但是可能是先入为主的缘故,感觉还是用StateNotifier更容易把状态和方法一起聚合起来,State的变化和初始化都可以自由控制,接触下来感觉riverpod提倡的其实跟ReactiveX很像,数据像水流一样流动,你来决定它们流向何方,当源头水发生变化时,流到的地方会自动跟着变化。

样式相关

样式方面在做项目之前也考虑了很多东西,得益于一些适配黑暗模式的经验,做之前就想好好研究一下ThemeData

  1. 样式最重要的其实是颜色,那么颜色这么复杂的东西怎么搞呢《字节跳动旗下 ArcoDesign 开源啦》
  2. 然后是字体,也是参考了里面然后根据自个的审美微调,稍微多提一点,在我刚做Theme的时候其实就感觉headlinesubtitlebodyText感觉比较抽象实际使用的时候经常会有语义上的一些小问题然后也受限于可使用的比较少,经常都是subtitlebodyText两个里面选着用,但是因为Theme.of(context)使用的形式可以很方便的去适配黑暗模式又不得不用,然后就在使用Beta channel时意外发现在Material3规范中TextTheme直接重做成了displayheadlinetitlebodylabel,并且每种都分为LargeMediumSmall,我个人感觉是会好用一些了,但是刚用的时候找不到熟悉的headline6:) 3.最后就是长长的ThemeData了,其实都是一些很枯燥的适配,代码在这里

小结

总的来说感觉样式方面的适配始终都还是开发者和设计师之间的友好交流吧,面对近年的各种黑暗模式,适老化适配,无障碍适配等等,其实如果能从一开始就考虑到一些其实在后续的开发过程应该是可以轻松不少的,但是谁也难说能一开始就把所有情况都考虑到,我做的这些其实也只是基于我的一些理解做的探索了,仅供各位参考,按需学习。

总结

这个项目做到现在基本上算是完成了我的目标 (Web除外),把想要实现的功能都捣鼓了一下,用了一些花里胡哨的库,但是也并没有完全结束,因为我的初衷其实是做一个可以自己折腾的项目嘛,现在项目有了,但是想要折腾的东西以后肯定会越来越多,如果有什么好玩的库以后估计也还是会在这里实践检验一下的,然后也可以第一时间体验Flutter新版本什么的。

2023/02/19更新

  • 第二篇的传送门
  • 部分链接因为更新失效了,标识了删除符
  • 补充了一些后续