Flutter开源项目|升级到Riverpod2的Wanandroid

4,310 阅读12分钟

距离上一次写关于这个项目的文章满打满算差不多也一年了,写这个之前瞅了一眼之前写的TODO,貌似一个都没有完成。。

上一篇的传送门

首页

展示

home.png
  • 之前的轮播图做的比较直,这次参考酷安做了一点优化,取色之前就有,用的palette_generator
  • 异形bottomNavigationBar在升级到Flutter 3的时候移除了,原因是当时为了兼容中间凸起的搜索按钮把BottomNavigationBar的源码拉出来魔改塞了一个SizedBox进去占位,然后到大版本更新的时候就要处理比较多的冲突,索性就一并移除了
    • 还有一个点是异形的凸起其实也意味着在首页的每个页面都要对应留出凸起占用的空间,这样页面才能完全滑动到底部,其实也相当于挤压了屏幕的可用空间了,而移动端的可用空间相对来说还是比较宝贵的

等级标签

看到张鑫旭大佬发的一篇文章,就想着用Flutter实现一下,后面想着做成等级的标签,再后来还参考了一下B站,失去的异形会以别的形式再回来

展示

DefaultisOutlined
default_level_tag.pngis_outlined_level_tag.png

主题拓展

为了适配黑暗模式顺便体验了一把ThemeExtension,效果还是可以的,因为本身就是基于MaterialApp的themeMode去适配的黑暗模式,所以现在通过拓展就可以很方便地集成一些Theme里没有的属性,源码

使用

// 配置
ThemeData.light().copyWith(
  extensions: <ThemeExtension<dynamic>>[
    GradientColors.light(), // 将自己定义的ThemeExtension添加到ThemeData的extensions中
  ],
  ...
)

// 使用
final GradientColors gradientColors = Theme.of(context).extension<GradientColors>()!; // 配置过了就大胆用!吧

完整代码

自动补全

看了王叔的视频

展示

Autocomplate.png

代码

RawAutocomplete<AccountCache>(
  textEditingController: _usernameTextEditingController,
  focusNode: _usernameFocusNode,
  displayStringForOption: (AccountCache option) =>
      option.username,
  onSelected: (AccountCache option) { // 选中后的回调,操作密码输入框控制器进入密码的赋值
    if (option.password != null) {
      _passwordTextEditingController.text = ref
          .read(authorizedProvider.notifier)
          .decryptString(option.password!);
    }
  },
  optionsBuilder: (TextEditingValue textEditingValue) => // 构建可选项的回调
      DatabaseManager.accountCaches
          .filter()
          .usernameStartsWith(textEditingValue.text)
          .sortByUpdateTimeDesc()
          .findAll(), // 这里用的isar的查询,后面会提到isar。。
  optionsViewBuilder: (
    BuildContext context,
    void Function(AccountCache) onSelected,
    Iterable<AccountCache> options,
  ) => // 构建可选项widget的回调
      _AccountOptionsView(
    onSelected: onSelected,
    options: options,
    lastLoginAccount: _lastLoginAccountCache,
  ),
  fieldViewBuilder: (
    _,
    TextEditingController textEditingController,
    FocusNode focusNode,
    __,
  ) => // 构建输入框widget的回调
      CustomTextFormField(
    controller: textEditingController,
    focusNode: focusNode,
    textInputAction: TextInputAction.next,
    validator: (String? value) {
      if (value == null || value.isEmpty) {
        return S.of(context).usernameEmptyTips;
      }

      return null;
    },
    decoration: InputDecoration(
      prefixIcon:
          const Icon(IconFontIcons.accountCircleLine),
      hintText: S.of(context).username,
    ),
    onEditingComplete: () {
      _passwordFocusNode.requestFocus();
    },
  ),
),

架构用法变更

原先的MVVM架构是搭配一些封装好的widget配合使用,后面在升级Flutter 3时由于pull_to_refresh迟迟没有适配,再到后来想舍弃pull_to_refresh改用别的三方库;

但是大部分三方库都有一套自带的状态管理在里面,两种状态管理混用显得太累赘,再后来就自己摸索采用CupertinoSliverRefreshControl借鉴Copyloading_more_list中加载更多的方式自己玩了。

CupertinoSliverRefreshControl强依赖CustomScrollViewBouncingScrollPhysics,整体效果差强人意吧

后来感觉维护配合使用widget的形式还是有很多不灵活的地方,就又大改一番采用了mixin的形式去配合provider;

灵活性是增加了但是使用上的心智负担和样板代码也变多了,但是目前也就这样了,至少解决了每次升级merge的问题(dog。

样板代码多的问题借鉴riverpod_generator使用typedef可以稍微改善一下。

感兴趣可以了解一下

迁移到isar

感觉isar最酷的是isar inspect,调试的时候太方便了。

用例

搜索历史

StreamBuilder<List<SearchHistory>>(
  stream: DatabaseManager.searchHistoryCaches
      .where()
      .sortByUpdateTimeDesc()
      .build()
      .watch(fireImmediately: true),
  builder: (_, AsyncSnapshot<List<SearchHistory>> snapshot) {
    if (snapshot.data == null || snapshot.data!.isEmpty) {
      return const SliverToBoxAdapter(
        child: nil,
      );
    }

    return SliverToBoxAdapter(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          ...
          Padding(
            padding: AppTheme.bodyPaddingOnlyHorizontal,
            child: Wrap(
              spacing: wrapSpace,
              runSpacing: wrapSpace,
              children: snapshot.data!
                  .map(
                    (SearchHistory e) => CapsuleInk(
                      child: Text(e.keyword),
                      onTap: () {
                        widget.onTap.call(e.keyword);
                      },
                    ),
                  )
                  .toList(),
            ),
          ),
        ],
      ),
    );
  },
),

一些简单的需求就可以不借助状态管理。

用户偏好设置

static UserSettings writeUniqueUserSettings({
  bool? rememberPassword,
  ThemeMode? themeMode,
  Language? language,
  bool enforceWriteLanguage = false,
}) {
  final UserSettings? uniqueUserSettings =
      DatabaseManager.userSettingsCache.where().findFirstSync();

  final UserSettings userSettings = UserSettings();

  if (uniqueUserSettings != null) {
    userSettings
      ..id = uniqueUserSettings.id
      ..rememberPassword =
          rememberPassword ?? uniqueUserSettings.rememberPassword
      ..themeMode = themeMode ?? uniqueUserSettings.themeMode
      ..language = enforceWriteLanguage
          ? language
          : language ?? uniqueUserSettings.language;
  } else {
    if (rememberPassword != null) {
      userSettings.rememberPassword = rememberPassword;
    }

    if (themeMode != null) {
      userSettings.themeMode = themeMode;
    }

    if (language != null || enforceWriteLanguage) {
      userSettings.language = language;
    }
  }

每次写入偏好设置时先读取一次,使App从始至终只有一份设置缓存。

其实做到这里的时候感觉可以让缓存主键跟用户ID挂钩,登录不同的用户可以使用不同的偏好设置。

注意事项

  • 应该始终尝试将连续的操作分组到单个事务中,文档

迁移到go_router

Flutter Favorites里3个Navigator2.0的库我选了Beamer,然后go_router被官方拿到packages里了。

我之前在Beamer中路由导航的方式是设定各种参数然后基于ChangeNotifier来操作App中唯一的路由栈;

更换到go_router之后重新回到了1.0的导航方式,其实也钻牛角尖试图将所有路径变成深度链接,但是这样做不仅麻烦而且是反业务逻辑的,并且在遇到BottomNavigation和Tabbar这种场景会出大问题。

被官方维护之后很快多了一个go_router_builder,可以简化编写路由表的样板代码和更加方便安全地进行导航。

注意事项

  • go_router通过go方法进行深度链接的导航,该种方式的导航会直接替换当前路由栈转去匹配当前URI的路由栈。

    举个例子

    @TypedGoRoute<SettingsRoute>(
      path: '/settings',
      routes: <TypedGoRoute<GoRouteData>>[
        TypedGoRoute<LanguagesRoute>(path: 'languages'),
        TypedGoRoute<StorageRoute>(path: 'storage'),
      ],
    )
    class SettingsRoute extends GoRouteData {
      const SettingsRoute();
    
      @override
      Widget build(BuildContext context, GoRouterState state) =>
        const SettingsScreen();
    }
    

    这段代码会被编译成在根路由声明了一个匹配路径/settingsSettingsRoute的路由表,其中包括2个子路由/settings/languages/settings/storage,分别匹配到LanguagesRouteStorageRoute,根路由路径需要加/,子路由不需要。

    // 此时如果通过push进行导航,会将`StorageRoute`中的页面推到栈顶,即跟1.0的导航效果是一样的
    const StorageRoute().push(context);
    
    // 如果通过go进行导航,当前的路由栈将会被替换成
    // ```
    //  => /settings/storage
    //  => /settings
    // ```
    const StorageRoute().go(context);
    

    我能想到的应用场景是通知栏消息点击跳转到某一内页,相当于一次性帮用户打开了一组页面,然后可以正常的返回到首页,也就是深度链接。

  • go_router导航时基于构造时配置的routes生成的路由表,将debugLogDiagnostics设置为ture可以很直观地看到;顶级重定向接收不到未定义的路径,所以当需要处理BottomNavigationBar和Tabbar等类型的路由时,可以使用路由级重定向,将路径配置到路由表。

  • errorBuilder构造的时候截止发文仍是替换整个路由栈的,在结合riverpod使用时需注意在页面级的provider中要使用AutoDispose的provider。

完整代码

主题

因为一直在用beta channel,所以每次upgrade首当其冲的就是theme里的弃用了,这几个版本为了适配Material 3,theme也没少折腾;

一切还是在向好的方向发展的,比如ColorScheme可以简化很多原来认属性名配颜色的情况,最近还发现新增了scrim用于遮罩层的颜色。

升级到riverpod2

因为架构是基于StateNotifier搭建的,最开始升级其实就改了个版本号,然后发现心爱的Reader被移除了,被迫传递ProviderContainer来read,感觉就很重;

但是就在文章别的写的差不多的时候,我才发现Remi大佬在2.0升级的终极奥义其实是riverpod_generator

吐槽一下

dev_dependencies:
  build_runner: ^2.1.5 // 万"恶"之源
  flutter_lints: ^2.0.1
  flutter_test:
    sdk: flutter
  freezed: ^2.3.2 // 数据模型generator
  go_router_builder: ^1.0.16 // 路由generator
  intl_utils: ^2.8.1 // 国际化generator
  isar_generator: ^3.0.5 // 缓存generator
  json_serializable: ^6.5.4
  riverpod_generator: 1.1.1 // 状态管理generator

入了Remi神教,everything is generated

riverpod_generator

Riverpod已经有中文文档了,这是关于riverpod_generator的部分

Remi大佬还是极力推荐我们使用代码生成的,并且

Riverpod的一些功能将只支持代码生成

其实目前比较明显的就是

向provider传递参数现在不受限制。不再局限于使用 family 和传递单个参数, 现在可以传递任何形式的参数。这包括命名参数、可选参数甚至默认值。

对比

不使用riverpod_generator

class CollectionTypeModel {
  const CollectionTypeModel({
    required this.type,
    required this.id,
  });

  final CollectionType type;
  final int? id;
}

final AutoDisposeStateNotifierProviderFamily<CollectedNotifier,
        ViewState<CollectedCommonModel>, CollectionTypeModel>
    collectedModelProvider = StateNotifierProvider.autoDispose.family<
        CollectedNotifier,
        ViewState<CollectedCommonModel>,
        CollectionTypeModel>((
  AutoDisposeStateNotifierProviderRef<CollectedNotifier,
          ViewState<CollectedCommonModel>>
      ref,
  CollectionTypeModel typeModel,
) {
  return CollectedNotifier(
    const ViewState<CollectedCommonModel>.loading(),
    ref: ref,
    typeModel: typeModel,
  );
});

使用riverpod_generator

@riverpod
class HandleCollected extends _$HandleCollected {
  @override
  CollectedCommonModel? build({required CollectionType type, int? id}) {
    CollectedCommonModel? model;

    if (id == null) {
      return model;
    }
    
    ...省略无关代码

    return model;
  }
}

这是操作收藏项的一个provider,收藏项有A和B类型,通过id的有无判定是修改还是新增,在不使用riverpod_generator我们需要额外定义一个class来传递状态,其实主要是family构造的限制;使用riverpod_generator就没有这个问题了;

这个例子在代码长度的对比方面举的不是很好,不使用前是因为我强行继承了BaseListViewNotifier导致的;

上面我有提到本身架构上的样板代码其实就是指各种类型的Provider名字过长了,riverpod_generator也通过typedef很大程度上改善了这个问题;

然后我就将一些简单的Provider先进行了迁移,体验很不错

未来可能独占功能并且又看到了前进方向的情况下,就想着要把全部的Provider都用上riverpod_generator。

目前项目的riverpod_generator版本固定在了1.1.1,要等这个issue解决

改架构

又要改架构了,先看看别人怎么写。。

class InifiniteList extends ConsumerWidget {
  const InifiniteList({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final totalCount = ref.watch(totalCountProvider); // 这个是通过watch pageDataProvider(0)得到totalCount
    return totalCount.when(
      loading: () => const LoadingWidget(),
      error: (e, s) => const ErrorWidget(),
      data: (count) => ListView.builder(
        itemCount: count, // 拿到totalCount后赋值给itemCount
        itemBuilder: (context, index) { // build到哪加载到哪
          final page = index ~/ 20; // 得到当前页
          final elementIndex = index % 20; // 下标除以pageSize的余数 用于定位provider中对应的item
          final element = ref
              .watch(pageDataProvider(page)) // watch当前页的数据
              .whenData((page) => page.content.elementAt(elementIndex)); // 通过余数拿到正确的数据

          return ProviderScope(
            overrides: [
              elementProvider.overrideWithValue(element), // override到provider中
            ],
            child: const ItemWidget(), // 通过provider拿到override过去的数据
          );
        },
      ),
    );
  }
}

这段代码是拷自这里的,更完整的Example可以看这里,这个也是Riverpod文档里的第三方例子;

有兴趣的话可以跑跑看,我没跑,所以以下对这个代码的评价是基于我自己的预测;

反正我看到第一眼确实也觉得很惊艳,拿到totalCount后的ListView就是可以无限滚动的,可以直接一拉到底,elementProvider中的模型数据因为watchpageDataProvider所以值也是被AsyncValue包装后的,在ItemWidget中处理好对应的loadingdataerror就完成了,全自动的build到哪加载到哪

请求频繁的问题,由于是全自动嘛,假设用户一拉到底,此时服务器会收到频繁但不重复的请求;

如果这个Provider是autoDispose的,在超出cacheExtent范围后我们可以通过cancelToken去取消请求,这里分享一个出自Remi大佬的小技巧代码

extension CancelTokenX on Ref {
  CancelToken cancelToken() {
    final CancelToken cancelToken = CancelToken();
    onDispose(cancelToken.cancel);

    return cancelToken;
  }
}

// 使用
final CancelToken cancelToken = ref.cancelToken();

dart的闭包用法(dog

如果不是autoDispose,可能会造成内存占用过大?

展示

exmaple 1example 2
example_1.gifexample_2.gif

优点

  • 分页逻辑完全基于ListView的特性,provider代码简单明了
  • 搭配ScrollController可以快速完成置顶和置底的操作
  • 很纯函数,很契合riverpod

缺点

  • 没有example2那样的节流效果
    • 但是反过来说用户就也不能跨页了,必须加载完第二页才能再加载第三页
  • 请求出错时会导致某一页的item全部出错,重试时也会全部loading
  • 下拉刷新需要重新请求totalCount会导致整页loading

其实如果不考虑请求出错的情况,example1的效果确实看起来要高级很多,主要还是看需求的场景吧。

升级感受

一开始是想要把所有provider都替换成使用riverpod_generator的,然后纠结了几天之前的封装,因为generator不支持StateNotifier,且就算支持由于我是通过继承Base抽象类再来写各自独立的provider所以个人感觉也很难能用上generator,所以当时是纠结了一段时间要不要全部替换成example1那种写法;

后来思索出那些缺点,再参考了目前国内的一些App做法之后感觉还是不能,至少不能全部换成那种用法,不然出错的时候太怪了一点;

最终还是保留了原来的写法,但是做了一些修改,把基本类型class的error跟AsyncValue对齐了,因为在generator中使用Future会默认返回AsyncValue的包装类,不对齐的话统一处理起来就不一致了;

是时候表演真正的技术了

用riverpod处理单例

上面提到因为Reader被移除了,被迫传递ProviderContainer来使用read嘛;但是其实还可以这么做

class Http { ... }

@Riverpod(keepAlive: true)
class Network extends _$Network {
  @override
  Http build() {
    return Http()..initConfig(ref: ref);
  }
}

/// 使用
final Http http = ref.watch(networkProvider); // watch 或者 read

之前的做法是把Http定义成单例,然后所有方法定义成static;现在只需要watch或者read我们就能拿到Http的“单例”了。

为啥要处理成这样呢,单例用的好好的也没啥问题啊,原因有两个

原因一

wanandroid的API是依赖cookie来鉴权的,所以我们需要CookieManager作为拦截器来处理cookie,拦截器又依赖PersistCookieJar来作为容器真正地去操作我们拿到的cookie,容器又需要一个路径去存放cookie,而获取路径的方法是异步的,我们需要在runApp之前拿到路径来初始化它,main方法可以是异步的大家应该都知道,但是现在通过riverpod我们可以

@Riverpod(keepAlive: true)
Directory appTemporaryDirectory(AppTemporaryDirectoryRef _) {
  throw UnimplementedError();
}

@Riverpod(keepAlive: true)
class AppCookieJar extends _$AppCookieJar {
  @override
  PersistCookieJar build() {
    final Directory directory = ref.read(appTemporaryDirectoryProvider);

    final String path = '${directory.path}/cookie_jar';

    if (!Directory(path).existsSync()) {
      Directory(path).createSync();
    }

    return PersistCookieJar(
      storage: FileStorage(path),
      ignoreExpires: true,
    );
  }
}

/// 在main方法中
Future<void> main() async {
  final Directory temporaryDirectory = getTemporaryDirectory()

  runApp(ProviderScope(
    overrides: <Override>[
      appTemporaryDirectoryProvider.overrideWithValue(temporaryDirectory),
    ],
    child: const MyApp(),
  ));
}

Override在刚刚的example1中item值的获取里我们已经见过了,ItemWidget能够加上const就是因为内部是通过ref.watch(elementProvider)去取值的;经过这么一番操作,在任意地方我们都能拿到这个temporaryDirectory了;

绕了那么大一圈,写了一堆魔法,这跟我用单例方法去初始化有啥区别?

其实除了用provider做了一些拆解,然后现在就比较像一个provider只做一件事了,比较贴合纯函数的理念之外,确实没啥分别,但是别急

原因二

在上一篇对riverpod的小结我说过,基本上所有的变量class都可以通过riverpod进行管理,甚至是dio...数据像水流一样流动,你来决定它们流向何方,当源头水发生变化时,流到的地方会自动跟着变化

现在,水要开始流了

之前呆的一家公司里有一个需求,客户是有自己的服务器的,所以我们开发的App需要能够自由切换baseUrl

切个baseUrl而已,很多方法都能实现;单例可以抛出一个方法做修改,拦截器直接拦缓存里的值等等。。

但是Url切了,原来的请求数据咋办;最简单的办法是切完重启或者在根页面做切换。。

/// 但现在只需要
@riverpod
Future<List<ProjectTypeModel>> projectType(ProjectTypeRef ref) {
  return ref.watch(networkProvider) // watch它
    .fetchProjectTypes();
}

/// 给我变
ref.invalidate(networkProvider); // 重新执行networkProvider的build并通知所有监听者

这里插一句

像在ElevatedButton的 onPressed 中那样,watch 方法不应该被异步调用。 它也不应该在 initState 和其他 State 的生命周期中使用。

在这种情况下,请考虑使用 ref.read

如果有Provider经验的话应该更熟悉一些,watch的使用场景是有限制的。

如果我们在请求时的provider中watch了dio那么跟在widget中watch一样,在重置dio时我们就会收到通知来重新执行我们本身provider的“build”;

事实上在上一篇文章中的有趣的用法里,项目文章也是基于先拿到项目类型再进行请求对应项目的文章的,只不过现在换成了dio。

应用场景

  • 语言切换时不需要重启App就能把数据请求直接给切换到对应语言

缺点

  • 现有分页逻辑的provider初始化都是从第一页开始的,所以通过这种方式重置的请求也会丢失页数
    • 如果采用example1里的写法就不会,缺点可能是如果使用非AutoDisposeProvider,且页数过多的话,瞬间的请求量还是会比较多,所以还是要多用AutoDispose
    • 当然如果在页面层级较深时使用重置也会造成瞬间请求量过多,但是这个就看产品了,大刷的操作页面层级应该是不宜过深的

小结

Remi大佬解决context的方法就是让ref既能在widget里拿到也能通过各种类型的Provider在class里拿到,对会用到的地方进行了全覆盖;同时在class里用上watch,让写逻辑代码跟写widget一样,对状态的控制可以说是拿捏了,绝。

升级过程的PR

杂七杂八

web

受限于isar2.5以后暂时不支持web,Flutter for web暂时搁浅了。

启动屏

一直不爽安卓启动屏刚启动时候状态栏有遮罩,找了很多资料之后发现了这个

- <!-- Displays an Android View that continues showing the launch screen
-       Drawable until Flutter paints its first frame, then this splash
-       screen fades out. A splash screen is useful to avoid any visual
-       gap between the end of Android's launch screen and the painting of
-       Flutter's first frame. -->
- <meta-data
-   android:name="io.flutter.embedding.android.SplashScreenDrawable"
-   android:resource="@drawable/launch_background"
-   />

把这段删了之后发现Android启动屏完全正常了,闪屏那个问题也没了。

注意需要在Flutter 2.5以后。

安装包

上一篇文章第一条评论就是问我要apk,我说周末看看,这一看就是一年,如果那位哥们还在的话,那么现在安装包有了。

回复评论

想问下 Navigator1.0中的pop(data);这种在Navigator2.0中怎么实现~或者说直接是用其它的方式?

用了Navigator2之后失去了Future式导航确实还挺不习惯的,我自己是返璞归真地处理的

class _MyAppState extends State<MyApp> with RouteAware { // 混入RouteAware
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();

    Instances.routeObserver.subscribe(this, ModalRoute.of<dynamic>(context)!); // 全局routeObserver添加订阅
  }

  @override
  void didPopNext() {
    super.didPopNext();

    // 做点啥..
  }

  @override
  void dispose() {
    Instances.routeObserver.unsubscribe(this); // 取消订阅

    super.dispose();
  }
  
  ...
}

缺点就是其实拿不到pop回来的数据,这个可能就想办法状态管理一下?或者也许不管数据直接执行需要做的事(我目前的就是这么处理的;

但是看到这位朋友提问我突然想起来在go_router上看过这个issue,于是找了一下还真找到了,并且已经有PR了,那我们就静候佳音吧~