Flutter 数据驱动指北:副作用、缓存和脏标记

Riverpod 是一个 Flutter 响应式缓存和数据绑定框架,其提供了一个用于存储数据 Data 的容器 Pod,其数据 Data 可通过监听响应式更新,并且配合 Reactive GUI 框架实现自动脏标记和数据驱动界面刷新。它的功能和定位类似于 Swift 的 SwiftUI 和 Combine,ClojureScript re-frame、reagent 的 atom,JavaScript 生态中 React.js 的 Redux。

状态一致性问题

不论是传统 MVC 中 Controller 更新 UI 组件而或 Reactive Framework 中脏标记更新 UI 描述,状态一致性问题 是大部分软件复杂性的根源,对于 GUI 而言,那些由 UI 同步模型和业务异步模型不匹配导致的状态一致性问题尤甚:异步业务,比如 Socket 访问结果会在未来得到,而在其 pending 的过程中,UI 组件仍然需要高速绘制渲染,单个状态的维护不足为道,但整个软件的复杂性会随着维护互相依赖状态的增多而指数级上升,最终导致代码腐烂。

从细节上看,对于一个含有状态的响应式视图而言,每个状态单元都至少包含三个部分:①副作用、②缓存、③脏标记:当用户点击、拖拽或滚动事件触发业务后,业务异步将结果写入变量并进行脏标记以驱动渲染引擎刷新界面。

数据驱动视图

有很多解决方案被用来减缓或解决状态的一致性问题,其中以双向绑定模式最为开发者友好,其中则以 SwiftUI @State 最为优雅:可以将 @State 看作维护了一个内部数据的容器 Pod,通过 getter 可直接读取,通过 setter 更新时顺带触发当前视图更新。这个容器可以通过 $ 运算符在组件间传递,从而实现状态共享,这种机制使得 SwiftUI 代码极其精简优美,富有表达力:

import SwiftUI
struct ContentView: View {
    @State private var inputText = ""
    var body: some View {
        VStack {
            TextField("Enter text here", text: $inputText)
            Text("Your input: \(inputText)")
        }
    }
}

类似实现还有 ClojureScript reagent 的 atom,一个 atom 就是一个包含内部数据的容器,通过 @ deref 操作符获取值,在获取的时候附加了当前组件的监听,当值发生变化,则触发监听脏标记组件。

(ns reagent-ratom-example.core
  (:require [reagent.core :as reagent]))

(defn main
  []
  (let [app-state (reagent/atom "Hello World!")]
    [:div
      [:input {:type "text"
               :value @app-state
               :on-change #(reset! app-state (-> % .-target .-value))}]
      [:p @app-state]]))

总的来说,区别于手动更新组件属性或通过 setState 回调进行脏标记,这种视图根据数据变更自动重新渲染的双向绑定被称作“数据驱动视图”,其很好的简化了状态一致性问题,尤其是当多个视图需要共享相同数据的时候,将数据容器互相传递,在任意位置触发副作用导致缓存数据变更,将导致依赖它的所有视图均获得更新。

ClojureScript 的 atom,以及 SwiftUI 的 @State 实现数据驱动视图的方式是通过 getter 时添加视图监听,当 setter 时如果数据变更,执行监听以进行脏标记并驱动视图更新实现的。注意这和 React.js 的 useState 不同,后者在任意变更时通过当前视图的脏标记刷新当前视图,而非在 getter 时建立其所在视图的监听。Riverpod 的做法和前者类似,但略有差异:Flutter 构建依赖 WidgetTree、ElementTree 和 Render/LayoutTree,WidgetTree 是 UI 不可变描述,而 ElementTree 则是组件实例,脏标记会驱动 ElementTree 重新构建 Widget 来读取界面变更信息,更新自己或重新创建组件,类似于 React.js 的虚拟 DOM。Riverpod 通过 ref.watch 获取数据(类似于 getter),这里的 ref 就是 Element 组件,其通过依赖中心找到 Pod,即 Provider,为其注册监听,并获取数据,异步数据获取需要时间,当数据准备就绪,则通知监听找到 Element 将其标记为脏并驱动引擎更新 Widget 绘制新界面,这里的数据和 Provider 的关系,正如 Widget 和 Element 的关系一样,后者是前者的“响应式实例”。

数据驱动数据

对于 Swift、ClojureScript、Vue.js 开发者而言,他们所使用的框架天生就带数据和视图的双向绑定能力,大幅降低了状态同步的困难。而对于 React.js 或 Flutter 的开发者而言,实现类似功能也不算困难,业务副作用发生后写入缓存变量,await 并 setState 即可脏标记视图驱动界面刷新,差异好像微不足道。

实际上,不论是基于 setState 回调而或双向绑定的数据驱动视图,其都不能满足包含多个状态的复杂业务的需求:当业务副作用的结果和视图所需数据结构不存在对应关系,比如存在搜索、排序的需求,那么为了最大化性能,一般还要维护一个排序和搜索后的、对应视图所需数据结构的缓存,当搜索键入关键词变更或排序方式变化时,从业务缓存中读取数据并应用变更到自身缓存。如是就出现了两个缓存的同步问题:当下拉刷新业务数据时,不仅要写入其自身缓存,还需要将被依赖的其他状态重新计算以得到正确的结果,这在双向绑定和“数据驱动视图”框架中依旧存在。

对于 Flutter 开发者而言,为了避免这种麻烦,新手往往采取在 build 方法中排序和过滤,这严重损害了渲染进程的性能。问题还不仅如此,当搜索内容和排序方法并非同步状态,而也是来自远程,比如网络或本地存储的异步状态时,同步这两个状态就变成了一种心智游戏:不仅需要在初始化视图时触发这两者副作用,此外当得到数据时还需要小心处理:当数据先到达时,需要在搜索内容和排序方法到达时对其处理以得到上屏数据(等价于用户更改搜索内容和排序的流程),而当设置先到达,数据到达后就需要手动触发依赖它的设置项计算以得到上屏数据(等价于刷新数据的流程)。最后,这两个副作用发生时如何渲染视图?FutureBuilder 只能处理一个 Future,如果将其串联,那么就必须等待有数据和设置后才能首次渲染,而为了更好的用户体验,应当是数据来了渲染数据,设置来了应用变更再次渲染数据。这是极端情况吗?并不,当一个视图需要三个甚至更多的同步或异步数据共同渲染的情况才是最“实际”的场景:主、辅业务、搜索和排序、用户消息是非常常见的场景,这种模式的 GUI 开发不能很好的应对业务复杂性,最终将导致充满 BUG 和不可维护的代码。

在前端 Web 中,对于基于模板的传统网站,数据总是后端数据库查询后得到的用于渲染当前视图而准备的,而对于基于 SPA 的应用,API 往往为前端视图所定制数据结构,后端返回一般就是聚合了多个业务,为上屏所准备的数据,因此前端社区对状态复杂性解决方案的需求并不高。而对于客户端或复杂 SPA,API 数据来源各异,且往往需要经过计算、过滤、整合和变换才能得到用于上屏的视图数据结构,那么状态复杂性是如何解决呢?传统上来说,解决此问题的方式是 CES(Compositional Event Systems) “流”,在 Swift 中是 Combine @Publisher,在 Android 中是 RxJava,在 Web 中则是 Rx.js,一个视图依赖的数据就像瀑布从天而降,在数据流动的过程中可以聚合多个数据源,对数据进行映射、缓存、过滤,最终得到视图数据,当数据发生变更,只需要在瀑布的某处更新数据,视图自然会响应式更新,通过这种方式,状态被隐藏在了流的运动中,却又被推动着改变视图状态,简化了开发人员的心智负担。这套模式很古老,在 MVC 的年代就已经被广泛使用,但也足够陌生,因为框架引入成本很高 —— 只有当某个视图具有足够多、足够复杂的状态需要展示时才能一展所长,但不幸的是,在大部分时候 setState 回调或双向绑定即可满足要求,因此对于大部分应用,引入它的收益不高。

Swift 的 Combine 框架使用了响应式流,不过对其进行了封装,@Publisher 创建流,setter 往流中写入数据,getter 从流中读取数据,其可以无缝的通过 $ 运算符传递,就像 @State 一样,因此很好的融入了其双向绑定的数据驱动视图模型,实现了“数据驱动数据”的能力,满足了复杂状态一致性需求。而对于 Riverpod 而言,ref.watch 不仅能发生在 Widget 和 Element 环境下,还能够发生在 Provider 中,不过这里的 ref 就不再是 Element,riverpod 源码将其定义为 ElementProxy,本质上是一种模拟的 Element,其通过监听维持和外部数据的依赖关系,以实时反应源数据变更,也实现了“数据驱动数据”的能力。此外,新版本的 riverpod 通过 build_runner 代码生成通过单一 @riverpod 注解将 “数据驱动数据” 的能力和传统 “命令式” 业务逻辑以及 “数据驱动视图” 模型无缝融合,也因此,使用 riverpod 的开发者很容易发现,借助于 riverpod,大部分的 StatefulWidget 可以重写为 StatelessWidget,尤其是搭配上 flutter_hooks,将 TextEditingController、AnimationController 抽象出来后,业务和视图分离,复杂状态被隐藏,GUI 开发的心智模型得到极大简化,组件可复用性和重构效率得到了大幅提升

Reactive Atom 实现

一个完整的简单例子

下面是一个基于 Redis 实现的 API 调用监听的前端页面,列表显示的是监听 API 和最近调用次数,右上按钮为按照 A-Z 或访问量排序,以及将当前搜索保存为快捷方式,列表下方是搜索栏和快捷方式,当点击快捷方式时,会自动填充搜索栏并触发列表变更。

如下是 riverpod “Way” 的实现,通过将远程数据、远程设置项、搜索结果看作三个不同的 Pod,通过 Provider 暴露数据和操作。其中搜索结果依赖远程数据和远程设置项,当前两者发生变化时(比如下拉刷新、更改排序和搜索内容)会响应式变更视图以反映最新的更改。

import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:http/http.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';

import '../pocket/config.dart';

part 'track.g.dart';
part 'track.freezed.dart';

@freezed
class TrackSearchItem with _$TrackSearchItem {
  const factory TrackSearchItem(
      {required String title,
      required String search,
      required String id}) = _TrackSearchItem;
  factory TrackSearchItem.fromJson(Map<String, dynamic> json) =>
      _$TrackSearchItemFromJson(json);
}

@Freezed(makeCollectionsUnmodifiable: false)
class TrackSetting with _$TrackSetting {
  const factory TrackSetting(
      {@Default(true) bool sortByName,
      @Default([]) List<TrackSearchItem> searchItems}) = _TrackSetting;
  factory TrackSetting.fromJson(Map<String, dynamic> json) =>
      _$TrackSettingFromJson(json);
}

@riverpod
class TrackSettings extends _$TrackSettings {
  @override
  Future<TrackSetting> build() async {
    final prefs = await SharedPreferences.getInstance();
    final jsonData = prefs.getString('trackSetting');
    try {
      if (jsonData != null) {
        final data = TrackSetting.fromJson(jsonDecode(jsonData));
        return data;
      }
    } catch (e) {
      debugPrintStack(stackTrace: StackTrace.current, label: e.toString());
    }
    return const TrackSetting();
  }

  setTrack(List<TrackSearchItem>? items) async {
    final prefs = await SharedPreferences.getInstance();
    final sort = state.value?.sortByName ?? true;
    final data = TrackSetting(sortByName: sort, searchItems: items ?? []);
    await prefs.setString('trackSetting', jsonEncode(data.toJson()));
    state = AsyncData(data);
  }

  setTrackSortReversed() async {
    final prefs = await SharedPreferences.getInstance();
    final sort = state.value?.sortByName ?? true;
    final items = state.value?.searchItems;
    final data = TrackSetting(sortByName: !sort, searchItems: items ?? []);
    await prefs.setString('trackSetting', jsonEncode(data.toJson()));
    state = AsyncData(data);
  }

  addTrack(TrackSearchItem item) async {
    final prefs = await SharedPreferences.getInstance();
    final sort = state.value?.sortByName ?? true;
    final items = state.value?.searchItems;
    final data = TrackSetting(
        sortByName: sort,
        searchItems: {...items ?? <TrackSearchItem>[], item}.toList());
    await prefs.setString('trackSetting', jsonEncode(data.toJson()));
    state = AsyncData(data);
  }
}

@riverpod
Future<List<(String, String)>> fetchTrack(FetchTrackRef ref) async {
  final setting = ref.watch(trackSettingsProvider).value;
  if (setting == null) return [];
  final Response r =
      await get(Uri.parse(Config.visitsUrl), headers: config.cyberBase64Header);
  final d = jsonDecode(r.body);
  if ((d["status"] as int?) == 1) {
    final res = (d["data"] as List)
        .map((e) => e as List)
        .map((e) => (e.first.toString(), e.last.toString()))
        .toList(growable: false);
    return res;
  } else {
    return [];
  }
}

/// NOTE. 此处使用 List<(String,String)> 更好,可降低一半的无效界面渲染
@riverpod
Future<List<(String, String)>> trackData(
    TrackDataRef ref, String searchText) async {
  final setting = ref.watch(trackSettingsProvider).value;
  final data = ref.watch(fetchTrackProvider).value;
  if (setting == null || data == null) return [];
  var res = data;
  if (setting.sortByName) {
    res.sort((a, b) {
      return a.$1.compareTo(b.$1);
    });
  } else {
    res.sort((a, b) {
      return int.parse(b.$2).compareTo(int.parse(a.$2));
    });
  }
  if (searchText.isNotEmpty) {
    res = res.where((e) {
      return e.$1.contains(searchText);
    }).toList(growable: false);
  }
  return res;
}

由于将业务完全从视图层剥离,且业务基于数据响应式驱动,因此视图层大部分代码只关乎界面布局。“增删改”操作现在变成了 ref.invalidate、ref.refresh、ref.read(notifier).action 通用接口。业务变更得到结果后会自动使用新数据渲染 UI,大幅降低了心智负担。

import 'dart:convert';

import 'package:clipboard/clipboard.dart';
import 'package:cyberme_flutter/api/track.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher_string.dart';

import '../config.dart';
import '../models/track.dart';

class TrackView extends ConsumerStatefulWidget {
  const TrackView({super.key});

  @override
  ConsumerState<TrackView> createState() => _TrackViewState();
}

class _TrackViewState extends ConsumerState<TrackView> {
  final search = TextEditingController();

  @override
  void dispose() {
    search.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final setting = ref.watch(trackSettingsProvider).value;
    final data = ref.watch(trackDataProvider.call(search.text)).value ?? [];

    final appBar =
        AppBar(centerTitle: true, title: const Text("Redis Track"), actions: [
      IconButton(
          onPressed: () =>
              ref.read(trackSettingsProvider.notifier).setTrackSortReversed(),
          icon: Icon(setting?.sortByName ?? true
              ? Icons.format_list_numbered
              : Icons.sort_by_alpha)),
      IconButton(onPressed: handleAddSearchItem, icon: const Icon(Icons.add))
    ]);

    if (setting == null) {
      return Scaffold(
          appBar: appBar,
          body: const Center(child: CupertinoActivityIndicator()));
    }

    final dataList = ListView.builder(
        itemBuilder: (ctx, idx) {
          final c = data[idx];
          return InkWell(
              child: Padding(
                padding: const EdgeInsets.only(
                    left: 10, right: 10, top: 8, bottom: 8),
                child: Row(
                    children: [Expanded(child: Text(c.$1)), Text(c.$2)],
                    mainAxisAlignment: MainAxisAlignment.spaceBetween),
              ),
              onTap: () => Navigator.of(context).push(MaterialPageRoute(
                  builder: (ctx) => TrackDetailView(url: c.$1, count: c.$2))));
        },
        itemCount: data.length);

    final searchBar = CupertinoSearchTextField(
        onChanged: (value) {
          ref.invalidate(trackDataProvider);
        },
        autofocus: true,
        controller: search,
        placeholder: "搜索",
        padding: const EdgeInsets.only(left: 10, right: 10));

    searchItem(e) {
      return RawChip(
          label: Text(e.title),
          tooltip: "${e.search}\n${e.id}",
          onDeleted: () => deleteQuickSearchItem(setting, e.id),
          onSelected: (v) {
            search.text = v ? e.search : "";
            setState(() {});
          },
          selected: search.text == e.search,
          deleteIconColor: Colors.black26,
          labelPadding: const EdgeInsets.only(left: 3, right: 3),
          deleteIcon: const Icon(Icons.close, size: 18),
          visualDensity: VisualDensity.compact);
    }

    return Scaffold(
        appBar: appBar,
        body: RefreshIndicator(
            onRefresh: () async => await ref.refresh(fetchTrackProvider),
            child: Column(
                mainAxisAlignment: MainAxisAlignment.start,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Expanded(child: dataList),
                  Container(
                      height: 45,
                      padding: const EdgeInsets.only(
                          left: 10, right: 10, top: 5, bottom: 10),
                      child: searchBar),
                  Padding(
                      padding: const EdgeInsets.only(left: 10, right: 10),
                      child: Wrap(
                          spacing: 5,
                          runSpacing: 5,
                          children: setting.searchItems
                              .map((e) => searchItem(e))
                              .toList(growable: false))),
                  const SizedBox(height: 10)
                ])));
  }

  Future deleteQuickSearchItem(TrackSetting setting, String id) async {
    final value = await showDialog(
        context: context,
        builder: (context) => AlertDialog(
                title: const Text("确定删除?"),
                content: const Text("此操作不可恢复。"),
                actions: [
                  TextButton(
                      onPressed: () => Navigator.of(context).pop(false),
                      child: const Text("取消")),
                  TextButton(
                      onPressed: () => Navigator.of(context).pop(true),
                      child: const Text("确定"))
                ]));
    if (value) {
      ref.read(trackSettingsProvider.notifier).setTrack(setting.searchItems
          .where((element) => element.id != id)
          .toList(growable: false));
    }
  }

  void handleAddSearchItem() async {
    var title = "";
    var searchC2 = TextEditingController(text: search.text);
    handleAdd() {
      if (title.isNotEmpty && searchC2.text.isNotEmpty) {
        ref.read(trackSettingsProvider.notifier).addTrack(TrackSearchItem(
            title: title,
            search: search.text,
            id: DateTime.now().millisecondsSinceEpoch.toString()));
        Navigator.of(context).pop();
      } else {
        showDialog(
            context: context,
            builder: (context) => AlertDialog(
                    title: const Text("警告"),
                    content: const Text("搜索内容和标题均不能为空"),
                    actions: [
                      TextButton(
                          onPressed: () => Navigator.of(context).pop(),
                          child: const Text("确定"))
                    ]));
      }
    }

    await showDialog(
        context: context,
        builder: (context) => AlertDialog(
                title: const Text("添加快捷方式"),
                content: Column(mainAxisSize: MainAxisSize.min, children: [
                  TextField(
                      controller: searchC2,
                      decoration: const InputDecoration(labelText: "搜索内容")),
                  const SizedBox(height: 10),
                  TextField(
                      autofocus: true,
                      decoration: const InputDecoration(labelText: "快捷方式标题"),
                      onChanged: (value) => title = value)
                ]),
                actions: [
                  TextButton(
                      onPressed: () => Navigator.of(context).pop(),
                      child: const Text("取消")),
                  TextButton(onPressed: handleAdd, child: const Text("确定"))
                ]));
  }
}

应对复杂业务

还是觉得引入 riverpod 大材小用?看看如下这个例子,思考一下不使用数据驱动的原生 Flutter 如何实现。

这是一个显示热门电影和电视剧的小应用,右上角第二个按钮可切换电影或电视,第三个按钮可切换最近或热门,第二、三个按钮会使用不同的选项使用不同的 URL 请求不同的数据,并将结果展示在网格中。网格中的项目可点击打开菜单,可跳转到网站、为网站链接生成短链接、将其标记为已看(电影或电视)或添加到追踪(电视)。

主界面提供过滤器 - 图二,对于电影,可以根据其类型进行筛选,对于电影和电视,可以根据其星级筛选,默认显示了平均星级。此外,还可以过滤是否显示已看和正在追的条目,其更改会直接影响到主界面是否显示正在观看和追踪的、满足类型和评分的电影电视。

存在用户远程设置项,设置项包含①已经观看的电视和已经观看的电影 ②用户是否需要在列表显示已看和正追踪的电影电视 ③用户最后的搜索结果,其在用户离开页面后上传到服务器,在首次打开页面时从服务器加载。

右上角第一个按钮可打开图四的视图,显示正在追踪的电视。当点击主页某个电视项目的菜单的“添加到追踪”选项时,也会打开图四的视图,并自动触发其右上角 + 按钮并填入当前电视项目名称和 URL,点击确定以添加到追踪,当剧集有更新则自动 Slack 通知。

简单分析一下,这个小应用包含了主页、过滤以及订阅三个视图,数据需要在这三个视图间保持一致。每个视图都有一系列的动作以更新当前视图和其他视图的状态,比如图一内容决定了图二过滤器类别的条目,图二某个过滤器按钮按下后,其自身呈现选中样式,且主页视图按照类别进行过滤,显示已观看和显示正追踪按钮切换时,主页视图显示或隐藏对应条目,比如图二过滤和设置后,图一的过滤器栏会简要的描述类别和设置情况,比如图四新建追踪后,图一项目左上角要加上“在追”标记。

实际上我实现这个例子采用了如下的 Pod 以提供实现:

  • 一个用来根据显示电视还是电影,热门还是最近影视请求后端数据的函数式 Provider:getMovies(只读无需副作用)
  • 一个用来返回远程记录当前用户已看电影和电视,是否显示已看、已追踪项的类 Provider:MovieSettings(需要读写)
  • 一个用来返回远程记录正在追踪电影的类 Provider:MovieTracks(需要读写)
  • 一个依赖 getMovies,MovieSettings 的,反映当前过滤项的类 Provider:MovieFilters(需要读写)
  • 一个依赖 getMovies, MovieSettings, MovieTracks, MovieFilters 的用来渲染过滤后视图内容的函数式 Provider:getMovieFiltered(只读无需副作用)

对于图一主页视图,其 watch getMovieFiltered 渲染主要数据,对于下方过滤器栏渲染,其 watch MovieFilters 和 movieSettings 并生成概述。此外,其还 watch MovieTracks 和 MovieSettings 显示 banner:已看 or 在追。当点击右上角切换按钮时,ref.read(getMovies.call(isTv,isHot)) 拉取数据并通知应用过滤后显示结果。

对于图二过滤视图,其 watch MovieFilters,因为此 Pod 自动依赖 getMovies,因此可实时反映类别和平均星级,以及这两个设置项的值。当过滤器变更,其更新 MovieFilters Pod,后者更新 getMovieFiltered 并反映在图一主页上。当设置项变更,其更新 MovieSettings Pod,并在界面显示变更。

对于图三的内容,根据 MovieSettings 中是否观看、追踪显示条目,当点击后,更新 MovieSettings 触发主页刷新显示 banner 已看。

对于图四的内容,界面展示 MovieTracks 结果,当其通过图三点击追踪时,触发打开 + 按钮添加追踪,之后更新 MovieTracks Pod,由于图一主页 watch 了 MovieTracks,因此其会自动为项目添加 banner 在追。

最佳实践:选择性响应、注意合并上一状态

注意,通过“数据驱动数据”的方式构建响应式数据流时,监听应该最小化以降低重复构建导致的成本,对于 RiverPod 而言,这意味着可以使用 ref.watch(xxxProvider.select/selectAsync)。此外,要注意 Provider 返回数据时总是应该考虑上一个状态的结果,比如 MovieFilters Provider 用于构建过滤器面板,当用户点选过滤时,此时内存中存在一个实例,由于 MovieFilters 依赖 getMovies,因此当电影和电视面板切换的时候,MovieFilters 响应式更新,自动调用 build 方法,这里要通过 stateOrNull 获取上一个状态的实例并基于此更新数据,否则内存中用户所做的选择将会丢失。此外,由于 MovieFilters 只需要对电影构建类别 Chip,因此其 watch 了 MovieSettings,而 MovieSettings 的变化更多,当用户添加、删除、标记某个电影电视已看、在追时都会更新,因此这里使用 select 只读取 MovieSettings 中当前显示电影还是电视的布尔值即可,拒绝无效更新。

最佳实践:带有依赖和多个输入源的 Pod 构建问题

在一些时候,可能遇到一个响应式单元存在多个输入源的情况,对于 RiverPod 而言,造成此问题的原因在于 build 方法并不仅第一次构建数据时使用,而在其依赖变更时也会调用,而这两者没有区分。

由于响应式流封装了状态,因此处理这种问题是困难的:比如为了保存 MovieFilters 数据,我们在 MovieSettings 中新建一个 lastFilters 字段,MovieFilters 读取它并在第一次构建时从中恢复数据。这个问题看似简单,但存在问题:MovieFilters build 方法中依赖了 getMovies 和 MovieSettings 的 isTv、lastFilters 字段,其本身可能存在上一个状态,那么应该在什么时候使用 lastFilters,什么时候使用上一个状态作为变化的基础呢?build 方法根本没有第一次实例化、refresh/invalidate/依赖的外部数据变更调用的区分,即类似于 StatefulWidget 的 initState, didChangedDependiences 这种区别。

这个问题最没有简单处理办法,一种可能的方案是为 MovieFilters 引入一个 update 字段存放时间戳,其在任何时候返回 MovieFilters 实例,比如 build、副作用调用时均更新时间戳。这样,不管是第一次构建还是因为依赖变更调用 build 方法,只需要简单比较上一个状态的时间戳、MovieSettings 的 lastFilters 字段中的时间戳,用最新的那个即可(第一次构建时 movie 为空,时间戳为 0,第二次得到 setting 后,和 0 比使用远程服务器数据,之后副作用更新 filter,当 movie 更新时,比如切换 tv/movie,setting 的远程服务器时间总是小于副作用更新时的时间,即能确保使用上一个状态,换言之可以保留内存中的用户过滤数据,而退出页面时,setting 时间戳更新为最后副作用更改的时间戳,其依旧会比任何新的用户操作导致副作用的时间戳小,因此可以保证仅在首次加载时用来设置默认值)。

注意,这里提出的问题展示了响应式流的局限,没有银弹,将状态隐藏在某些时候需要付出代价。尽管如此,响应式流的“数据驱动数据”模式对于整洁架构依旧十分重要。

最佳实践:同步 -> 异步 -> 同步的依赖传递状态错位问题

考虑到这个场景:点击切换展示 Tv/Movie 的按钮,按照上面的实现,其会更改 MovieSettings 并通知依赖者:getMovies, MovieFilters 以及 getMovieFiltered,如果按照选择性响应原则,getMovies 仅需要对 isTv/Movie 字段变更响应,MovieFilters 只需要对 lastFilters 字段变更响应,而 getMovieFiltered 则需要利用全部的 MovieSettings 数据,因此,实际发生了:getMovies 首先失效并重新从服务器获取新的类别的数据(比如如果之前是电影,那么现在就是电视),这是一个异步 Pod,MovieFilters 监听到了 getMovies 失效其自身也重新 build,由于其是同步 Pod,所以其利用 getMovies 的缓存构建自身,换言之,现在的状态不一致了:MovieSettings 目前认为正在显示电视,getMovies 正在获取电视列表,而 MovieFilters 则根据 getMovies 上一次结果,即电影的数据构建,getMovieFiltered 此时收到这些不一致的数据,其从 getMovies 中获取的同样是上一次结果,即电影的数据,因此其将电影的 MovieFilters 应用到电影数据上,看上去没有问题,对吧?但由于 MovieSettings 目前 isTv 字段为 true,因此如果 MovieFilters 选择了过滤已看,那么我们需要根据 watchedTv 而非 watchedMovie 字段来执行过滤,这就导致了最终上屏的数据错误,表现就是“共计xx项”的数字和主视图内容短暂闪烁。

不足之处:状态来源追溯与状态合并

如果说,Flutter 原生的状态管理是基于视图视角的大杂烩,那么 RiverPod 则是采用的是时间维度视角,将简单状态按照管道进行拼接,状态在这些“毛细血管”间流动,从而在仅暴露状态瞬时状态值的情况下使得视图层基本不可变,简化了心智模型,使得开发者可以从容构建复杂应用。但 Riverpod 也有不足之处,如果在一个视图中订阅多个简单状态,那么在 build 方法中无从知晓谁推动了重绘的结果,这看起来似乎无关紧要,但的确存在这样的需求:比如一个“时钟”应用,一个 Pod 用于每秒更新时间,一个 Pod 用于每隔 60 秒拉取数据,现在想要在拉取数据时让时钟数字出现半透明效果,但如果我们无从区分重绘来源,那么这样简单的需求实现起来就很困难:除非将每隔 60s 这个逻辑放在视图层。

一种可能的解决办法是将两个状态合并,使用一个新的 Pod watch 时间同步 Pod 和 API 异步 Pod,然后视图订阅这个新 Pod,此 Pod 值可以用一个字段来表示当前数据是否有一次邻近的 API 更新,而非时间流逝。但较快的同步和较慢的异步状态合并是一种灾难,如果这个新 Pod 采用异步实现,那么视图将不得不在每秒刷新两次,如果其采用同步实现,那么当异步请求发送后,在 await 的过程中,同步由于过于快速导致状态覆盖,因此最后的数据中,临近 API 更新的字段将永远是 false。当然,这样的问题并不常见,并且也不是框架设计缺陷 —— 而仅仅是我们选择了基于简单状态的时间而非复杂状态的空间进行抽象的特性决定的。况且,当这种问题出现后,回退到不那么干净的原生 Flutter setState 也不是困难的事。

基于订阅/发布的实现

在实际开发中,实现“数据驱动数据”并无缝衔接“数据驱动视图”的框架和方式有很多,SwiftUI & Combine 是基于 CES 的例子,Flutter RiverPod 和 ClojureScript 的 ratom 则选择了 Reactive Pod 方案,而 ClojureScript 的 re-frame 则是基于 Pub/Sub 发布订阅的实现,其中 re-frame 的诞生最早,但放在今天也依旧先进:

re-frame 维护了一个全局 map,其通过 reg-event-fx 和 reg-event-db 注册异步和同步副作用,之后在视图事件函数中通过 (rf/dispatch [:fetch-docs]) 这样的方式触发,结果更新在全局 map db 中。视图 docs-view 渲染时,其通过 (rf/subscribe [:docs]) 依赖一个通过 reg-sub 创建的订阅,re-frame 会从全局 map db 读取并过滤、计算得到数据以渲染上屏。当在 docs-view 界面再次通过 dispatch :fetch-docs 拉取数据更新全局 db 后,当前视图 docs-view 订阅 :docs 因为 @ deref 创建监听,其自动执行所依赖的 :docs 订阅函数得到数据,当数据发生变更时驱动页面进行更新。

和 RiverPod 相比,@ deref 就是 ref.watch,reg-sub :docs 就是 @riverpod 声明 Functional Provider,而 reg-event-fx 和 reg-event-db 则对应 @riverpod 同步或异步的带有副作用的 Class Provider。区别是,Pub/Sub 的接口和全局变量更符合不可变函数式理念,其应用可观测性更好,Debug 更加容易,甚至能够实现重放。Flutter 的 RiverPod 受限于静态类型而不能使用类似的全局数据库,只有将 Reactive Atom 放置在各处,但其将复杂业务和简单不可变视图分离的方式和能力则是接近的。

(rf/reg-event-db ;effect (sync)
  :set-docs
  (fn [db [_ docs]]
    (assoc db :docs docs)))

(rf/reg-event-fx ;side effect (async)
  :fetch-docs
  (fn [_ _]
    {:http-xhrio {:method          :get
                  :uri             "/docs"
                  :response-format (ajax/raw-response-format)
                  :on-success      [:set-docs]}}))

(rf/reg-sub ;query atom
  :docs
  (fn [db _]
    (:docs db)))

(defn docs-view [] ;view
  (let [doc @(rf/subscribe [:docs])
    [:p doc]]))

JavaScript 生态中,React.js 的 Redux 的实现和上述方案类似,其包含 Command Dispatcher(Actions),Reducer(Side Effect)和 Selector(Query Atoms)分别用于触发事件,执行业务副作用,选择器选择数据并响应式更新视图。

订阅发布和响应式 Pod 的问题一样,由于其框架采用了基于简单状态的时间切面,从而隐藏了状态,提供了几乎不可变的视图层,简化了心智模型,降低了复杂应用开发难度,但这些简单状态难以完成复杂协作:虽然 Pod 可以根据其他 Pod 当前状态进行响应式更新,但这种简单能力的确很难匹敌传统 Rx 模式的 CES 的灵活性:流条件合并、基于某条流的状态进行映射等等。因此,在状态管理上没有银弹,要根据需求随时做好回退到复杂状态一锅烩的传统模式,或者引入更加复杂但灵活的 Rx 系统的准备。

纵观 GUI 这二十年的发展和变化,各种框架和平台日新月异,但核心的困难依旧没有改变:状态复杂性问题始终是制约应用扩展性的根本。而为了应对包含业务副作用、缓存数据以及脏标记这三者的状态单元,简单的方式是通过“数据驱动视图”,这种机制在现代大部分 GUI 框架中都有实现,而随着视图引入状态彼此依赖增多,也要有“数据驱动数据”的能力,传统上使用 CES Rx 模式,而现在则有 Pub/Sub 订阅发布模式,响应式数据模式可用。但不管编程语言、平台和 API 如何,不管这些框架所建构的心理模型如何,其本质都是对包含一些 Data 数据的 Pod 容器通过建立监听实现的响应式变更操纵,而这些容器的本质,则依旧是对业务副作用、缓存数据和脏标记的状态单元的生命周期管理,不过之前需要开发者手动管理的状态一致性问题,现在被隐藏在 CES 流、Pub/Sub 订阅、响应式数据单元之后,由框架统一管理了。

如果说基于不可变视图描述操纵的响应式 GUI 是对传统 GUI 组件 API 调用的一次革命,那么带有“数据驱动数据”和“数据驱动视图”能力的状态管理框架则为响应式 GUI 极大的增强了应用的可扩展和可维护性,注入新的生命力。

更新记录:

  • 2023年12月26日 补充了电影的例子,以及 re-frame 的例子,重构整个叙述框架
  • 2023年12月27日 增加了电影例子中两个最佳实践
  • 2024年2月18日 增加了 Riverpod 状态来源区分和合并的一些思考