大多数Flutter开发者在职业生涯中的某个阶段都会遇到这样一种情况。

你正在构建一个需要显示用户姓名、他们最近发布的五篇帖子以及每篇帖子的点赞数等的界面。这看起来似乎很简单:你向`/users/42`发送请求,服务器却返回了二十个你并未要求获取的数据字段。

你再次向`/users/42/posts`发送请求,服务器依然会返回关于这些帖子的一切信息,其中一些数据字段根本不会在用户界面上显示出来。

后来你发现还需要知道每篇帖子的评论数量,于是便遍历所有帖子,又发出了五次请求。

等到界面最终加载完成时,你的Flutter应用实际上已经进行了七次网络请求,下载了大量数据但这些数据很快又被丢弃了;而对于那些使用较慢网络的用户来说,他们看到的只是不断旋转的加载图标,根本不知道应用程序是否出现了故障。

这种情况并非个例。在基于传统的REST API构建复杂的用户界面时,这种问题其实非常普遍,任何发布过正式移动应用的开发者都应该经历过这样的困扰。

你真正需要的数据与API提供的数据几乎从来都不会完全匹配:要么得到的数据太多,要么太少。而由此带来的代价包括浪费的网络带宽、加载速度变慢、客户端代码变得更为复杂,以及用户体验的下降。

GraphQL的正是在为了解决这些问题而诞生的。它并非出于理论研究或学术目的,而是因为在2012年,Facebook的工程师们需要为那些运行在2G网络上的移动设备构建一个新闻推送系统,而传统的REST架构显然已经无法满足他们的需求。

他们的解决方案就是让客户端能够完全控制自己所接收的数据结构。而不是由服务器来决定你该得到什么,而是你需要什么,服务器就会直接提供给你——而且这一切只需要一次请求即可完成。

这本手册是一本内容全面、讲解深入的指南,它会帮助你从基础原理入手理解GraphQL,并教你如何使用`graphql_flutter`包在Flutter应用中自信地运用这一技术。

你不仅会了解到API的具体结构,还会明白为什么这个库会被设计成现在的样子,各部分是如何在架构上相互配合的,规范化缓存的作用及其重要性,以及如何围绕GraphQL来构建可维护性强的生产环境级应用程序。

读完这本书后,你将能够使用GraphQL来开发实际的Flutter应用。同时,你也能够清楚地判断在哪些情况下GraphQL是合适的选择,在哪些情况下则不然,从而避免那些初次尝试使用这一技术时常常会遇到的陷阱。

目录

先决条件

在开始学习GraphQL和`graphql_flutter`之前,你应当对一些基础概念有足够的了解。本指南并不假定你在这些领域都是专家,但整个学习过程都会以这些基础知识为前提。

  1. Flutter与Dart的基础知识。你应该能够使用`StatefulWidget`和`StatelessWidget`来构建多屏幕的Flutter应用程序。理解组件树结构、`BuildContext`、`setState`以及Dart的异步/等待模型是非常重要的。如果你曾经用Flutter开发过天气应用或待办事项列表应用,那么你就已经具备了继续学习所需的所有基础。

  2. HTTP与API。你应该了解什么是API、什么是HTTP请求,以及JSON数据在客户端和服务器之间是如何传递的。虽然不需要深入了解HTTP的内部机制,但至少需要知道客户端会发送请求,而服务器会以结构化的数据形式进行响应——这个基础知识是本指南后续内容讲解的基础。

  3. 基本的状态管理机制。熟悉至少一种状态管理方法(如Provider、Bloc或Riverpod),将有助于你理解后续章节中关于应用程序架构的讨论。即使不掌握这些知识,也可以跟随本指南进行学习,但如果你了解过Flutter应用程序是如何将用户界面与业务逻辑分离的,那么那些内容会更容易理解。

  4. 项目开发所需工具。

    在开始开发之前,请确保你的开发环境具备以下工具:

    • Flutter SDK 3.x或更高版本

    • 如VS Code或Android Studio这样的代码编辑器

    • 可以在终端中使用的flutter和dart命令行工具

    • 用于测试的Android模拟器、iOS模拟器或真实设备

  5. 用于Android构建的Java 17版本。

    由于`graphql_flutter` ^5.3.0版本要求使用Java 17进行开发,因此在添加相关依赖包之前,请先确认你的JDK版本是否为17。在终端中运行`java -version`命令,确认输出显示的版本确实是17。如果不是,请先安装JDK 17后再继续操作。如果不满足这个要求,Android应用程序的构建过程将会出现错误。

  6. 本指南所使用的依赖包。

    你的`pubspec.yaml`文件中会包含以下依赖项:

dependencies:
  flutter:
    sdk: flutter
  graphql_flutter: ^5.3.0
  flutter_hooks: ^0.20.0

只有当你想要使用基于钩子的API(如`useQuery`、`useMutation`)时,才需要依赖`flutter_hook`。本指南会同时介绍基于组件的开发方式与基于钩子的开发方式。

什么是GraphQL?

想象有两家餐厅。在第一家餐厅,你坐下后,服务员会给你端上来一盘固定的食物:一个汉堡、薯条、一份配菜和一杯饮料。如果你只想吃汉堡加番茄酱,但这家餐厅并不提供这样的选择——你必须全盘接受这份食物,否则就不能用餐。如果你还想吃甜点,就需要再叫一次服务员。

在第二家餐厅,服务员会给你一张空白纸。你在纸上清楚地写下自己的需求:一个放在烤面包上的汉堡肉饼,只加番茄酱,再配一块巧克力熔岩蛋糕。你把这张纸交给厨房,他们就会一次性把你点的东西端上来。没有浪费,也不会第二次再来取菜。

第一家餐厅相当于REST API,而第二家餐厅则相当于GraphQL。

这个比喻很好地概括了其中的核心思想,但实际上还有更深入的含义。在第二家餐厅里,那张空白纸并不意味着你有无限的自由——厨房仍然只有一份他们知道如何制作的食材清单,你只能点那些他们厨房里确实有的东西。

用GraphQL的概念来说,这份可用食材清单就被称为“模式”,它是服务器与所有与之交互的客户端之间的一份正式协议。

GraphQL是一种用于API的查询语言,同时也是一种用于执行这些查询的工具。它是由Facebook(现在称为Meta)在2012年开发的,并于2015年开源。与REST不同,REST将各种操作映射到特定的URL和HTTP动词上,而GraphQL则通过一个统一的接口来处理所有请求——所有的操作都是以结构化文档的形式在请求体中发送的。

关键的区别在于:在REST系统中,是服务器决定你能够获取哪些数据;而在GraphQL系统中,是由客户端来决定的。服务器会提供一个描述所有可用数据的“模式”,客户端则发送一个查询来指定自己需要哪些数据,服务器会精确地返回这些数据,既不会添加任何多余的内容,也不会省略任何必要的信息。

下面是一个简单的GraphQL查询示例:

query GetUser($userId: ID!) {
user(id: $userId) {
id
name
profilePic
}
}

相应的响应结果如下:

{
"data": {
"user": {
"id": "42",
"name": "Atuoha Anthony",
"profilePic": "https://cdn.example.com/atuoha.jpg"
}
}
}

服务器并没有返回emailcreatedAtfollowersCount或其他任何它存储的数据字段,它只返回了查询中明确要求的内容。

为什么Facebook要开发GraphQL?

GraphQL之所以被创建,是因为在2012年,Facebook遇到了三个具体且普遍存在的问题。了解这些问题非常重要,因为这些很可能也是你在自己开发的应用程序中会遇到的问题。

第一个问题是网络请求过多。Facebook的新闻推送功能需要从数十个不同的数据源获取信息:帖子、评论、点赞信息、用户资料、媒体文件以及广告内容。使用REST架构时,每个数据源都对应一个独立的接口,因此要构建一个页面就需要依次访问这些接口,这样效率很低;或者需要通过复杂的客户端逻辑来并行处理这些请求,但这既耗时又容易出错。

第二个问题是数据冗余问题。REST接口返回的数据结构是由服务器预先定义好的,因此移动端应用会接收到服务器认为可能有用的所有信息。但在2012年,使用2G或3G网络运行的移动设备根本无法承受下载那些永远不会被显示出来的数据,这种浪费会导致加载速度变慢、用户的数据流量费用增加,同时还会加速设备的电池消耗。

第三个问题是API的演化速度。每当新闻推送团队想要展示新的信息时,他们要么必须修改现有的API接口(这样可能会影响其他客户的使用),要么就必须创建新的接口(这会导致API结构变得过于复杂,并引发版本管理上的问题)。服务器与客户端之间的耦合程度过高,因此每次产品功能的更新都必然需要先对后端代码进行修改。

GraphQL同时解决了这三个问题:它允许用户通过一次请求获取任意组合的数据,客户可以自行选择所需字段,从而避免数据浪费;同时,它的架构设计也使得API的演化无需依赖版本管理机制。

2015年Facebook将GraphQL开源后,广大开发者立刻意识到:这些问题并非Facebook所特有,而是所有API都会面临的普遍性问题,而GraphQL正是解决这些问题的通用方案。

了解问题: GraphQL出现之前的状况

REST的工作原理

REST(表示性状态转移)这种架构风格在过去的十年里一直主导着API的设计,至今仍是业界最常用的方法。

其核心理念非常简单:每一项资源都对应着一个特定的URL地址,即接口端点。我们可以通过HTTP请求动词与这些接口进行交互:使用GET获取信息,使用POST创建新数据,使用PUT或PATCH更新数据,使用DELETE删除数据。

对于一个典型的社交应用来说,它的REST API可能如下所示:

GET  /users/42            -- 获取用户#42的信息
GET  /users/42/posts      -- 获取用户#42发布的帖子
GET  /posts/17/comments   -- 获取帖子#17下的评论
POST /posts               -- 创建新帖子

每个接口端点都会返回由服务器预先定义的固定格式的数据。当你发送请求GET /users/42时,无论你当前正在构建哪个页面,也不管你实际上需要哪些数据,你总是会收到相同的内容。

REST请求/响应的处理流程

这种设计在很多应用和团队中确实效果不错,但它也存在一些结构上的局限。当应用程序及其数据需求变得越来越复杂时,这些局限就会逐渐变得令人头疼。

过度获取数据:数据量过大

当API返回的数据量超过了客户端当前页面所需的数据量时,就会发生“过度获取数据”的情况。例如,你的个人主页只需要用户的名字和头像,但服务器却发送了15个字段的数据。

在网络连接速度快的桌面设备上,这些多余的数据几乎不会对性能产生任何影响;但在网络拥堵的移动设备上,它们会增加延迟,加快电池消耗,对于使用按流量计费的用户来说,还会造成额外的经济负担。

如果在一个复杂的应用程序中,每个API请求都会出现这种情况,那么累积起来的数据浪费就会严重影响应用程序的性能。

数据获取不足:数据量不够

“数据获取不足”指的是某个接口返回的数据不足以满足显示需求,因此客户端需要发送多次请求才能获得完整的信息。上图中的场景就是一个典型的例子:为了渲染一个页面,竟然需要发送两次请求。

当涉及到列表时,这个问题会变得更加严重。如果你需要展示十篇帖子,每篇帖子都附带作者的头像,而帖子接口本身并不包含作者信息,那么你首先会发送一次请求来获取所有帖子,然后再分别发送十次请求来获取每个作者的详细资料。

这就是所谓的“N+1问题”:先发送一次请求来获取N条数据,之后又需要再发送N次请求来补充这些数据。对于一个包含十条数据的列表来说,这意味着为了显示一个页面,总共需要进行十一次网络请求。

版本控制:当API无法顺利进化时

随着应用程序的发展,不同的页面可能需要使用相同数据的不同格式。但你不能随意修改现有的接口,因为其他客户端可能仍然依赖该接口当前的响应结构。

于是你创建了/v2/users这个版本,然后又创建了/v3/users,很快你就需要维护多个版本的同一个接口,并且不敢删除旧版本,因为你无法确定还有哪些客户端仍在使用它们。

这样一来,你的API文档就会变成一堆已经被弃用的接口地址的集合,而后端开发团队也会花费大量精力去维护这些已经不再被使用的功能。

单接口架构

单个接口,客户端自定义数据格式

GraphQL用一个单一的接口取代了多接口模型,这个接口通常是/graphql。与传统的URL编码方式不同,请求体才是用来传递所需数据的结构化信息。无论你是想读取数据、修改数据,还是监听实时事件,所有操作都是通过向同一个接口发送结构化的文档来完成的。

以上图中关于个人资料页面的示例,用GraphQL语法重新表述如下:

query GetUserProfile($userId: ID!) {
  user(id: $userId) {
    id
    name
    profilePic
    posts(last: 5) {
      id
      title
      likeCount
    }
  }
}

只需发送一次请求,就能得到相应的响应结果,而且数据的结构完全符合查询要求。

{
  "data": {
    "user": {
      "id": "42",
      "name": "Atuoha Anthony",
      "profilePic": "https://cdn.example.com/atuoha.jpg",
      "posts": [
        { "id": "101", "title": "我的第一篇帖子", "likeCount": 42 },
        { "id": "102", "title": "Flutter使用技巧", "likeCount": 118 }
      ]
    }
  }
}

REST与GraphQL的对比

为什么GraphQL不需要版本控制

GraphQL采用“先定义模式”的设计方式,几乎完全消除了版本控制带来的问题。当你向模式中添加新的字段或类型时,那些没有请求这些新字段的现有客户端可以继续正常使用原有功能,而无需进行任何修改。当你想淘汰某个字段时,只需在模式中使用@deprecated指令来标记它即可。

开发工具会将字段被淘汰的信息告知客户端开发者,让他们有足够的时间来迁移原有的代码。与此同时,该字段会继续提供数据,直到你确认所有客户端都已经完成了迁移。因此,你根本不需要为同一个接口维护多个并行版本。

核心GraphQL概念:深入探讨

模式:客户端与服务器之间的契约

模式是每个GraphQL API的基础。它使用模式定义语言(SDL)编写,用于正式规定服务器能够提供的各种数据类型以及客户端可以执行的操作类型。服务器和客户端都会根据这个模式来获取相应的数据。

在开发Flutter应用程序时,你的工具会在发送任何网络请求之前,先验证查询语句是否符合模式要求。如果你请求了一个在模式中并不存在的字段,错误会立即在查询阶段被检测出来,而不会在生产环境中运行时才出现问题。

以下是一个博客应用的模式示例:

type User {
  id: ID!
  name: String!
  email: String!
  bio: String
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  likeCount: Int!
  comments: [Comment!]!
  publishedAt: String!
}

type Comment {
  id: ID!
  text: String!
  author: User!
}

type Query {
  user(id: ID!): User
  post(id: ID!): Post
  allPosts(page: Int, limit: Int): [Post!]!
}

type Mutation {
  createPost(title: String!, content: String!): Post!
  likePost(postId: ID!): Post!
  deletePost(postId: ID!): Boolean!
}

type Subscription {
  postAdded: Post!
  commentAdded(postId: ID!): Comment!
}

类型名称后面的!表示该字段是不可空的,即它永远不会返回null值。[Post!]!则表示一个由不可空的Post对象组成的列表。QueryMutationSubscription是根类型,它们定义了访问API的入口点。

作为Flutter开发者,你不需要自己编写模式(这是后端开发人员的职责),但你必须能够熟练地理解它,因为它清楚地说明了你可以查询哪些数据,以及响应结果会是什么格式。

类型:构成API的基本元素

GraphQL中的类型大致可以分为两类:标量类型和对象类型。

标量类型是查询结果中的基本数据单元,它们没有子字段,也不能被进一步分解。

五种内置的标量类型分别是StringIntFloatBooleanID。其中ID比较特殊,因为它代表一个唯一的标识符,并且会被序列化为字符串形式。服务器也可以定义自定义的标量类型,比如DateTimeURLJSON,以便用来表示特定领域的数值类型。

对象类型包含具有名称的字段,这些字段可以表示为标量或其他对象类型。在GraphQL中,它们构成了数据图结构,你可以通过嵌套字段选择来遍历这些关系:

query {
  post(id: "17") {
    title       # 标量
    author {    # 对象类型 -- 用于遍历关系
      name      # 关系内部嵌套的标量
    }
  }
}

枚举类型会限制字段只能取预定义的值集,从而防止使用不合法的字符串,因为只有特定的值才是有效的:

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

输入类型专门用于在 mutation 中作为复杂参数使用。由于普通的对象类型不能被用作参数,因此你需要为结构化的 mutation 输入定义专门的输入类型:

input CreatePostInput {
  title: String!
  content: String!
  status: PostStatus!
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
}

查询:读取数据

查询是用于读取数据的GraphQL操作,它相当于 GraphQL世界中的“GET”请求。你声明自己想要获取的具体字段,服务器就会返回这些字段。

query GetPostDetails($postId: ID!) {
  post(id: $postId) {
    id
    title
    content
    publishedAt
    author {
      id
      name
    }
    comments {
      id
      text
      author {
        name
      }
    }
    likeCount
  }
}

让我们逐行分析这个查询语句,以便更清楚地了解其结构:

query这个词用来声明操作类型,告诉服务器你是在读取数据,而不是写入数据。

GetPostDetails是操作的名称。虽然它是可选的,但强烈建议在调试、日志记录和代码生成时使用它。

(\(postId: ID!)是变量声明部分:\)postId是一个类型为ID!的占位符,在查询执行时才会被赋予实际的值。post(id: $postId)调用了根Query类型中的post字段,并将这个变量作为参数传递进去。

大括号内的所有内容都表示你想要服务器返回的具体字段。注意authorcomments都是通过嵌套的字段选择集来获取数据的。你是按照数据模式中定义的关系结构来遍历这些数据的,服务器会解析每一条关系,并将结果包含在最终的响应中。

返回的结果与你的查询请求完全一致:

{
  "data": {
    "post": {
      "id": "17",
      "title": "开始使用GraphQL",
      "content": "GraphQL是一种用于API的查询语言……",
      "publishedAt": "2024-01-15T10:30:00Z",
      "author": { "id": "42", "name": "Franklin Oladipo" },
      "comments": [
        {
          "id": "201",
          "text": "很棒的文章!",
          "author": { "name": "Bede Hampo" }
        }
      ],
      "likeCount": 247
    }
  }
}

变异操作:修改数据

变异操作是GraphQL中用于修改数据的操作,包括创建、更新或删除记录。其语法与查询操作完全相同,唯一的区别在于使用mutation关键字而非query关键字。

mutation CreateNewPost(\(title: String!, \)content: String!) {
  createPost(title: \(title, content: \)content) {
    id
    title
    publishedAt
    author {
      name
    }
  }
}

变异操作最强大的特点之一就是它们能够返回数据。在创建了一篇帖子后,你可以在同一操作中立即获取该新创建对象中的任何字段信息。这样一来,你的用户界面就可以直接使用服务器提供的最新数据进行更新,而无需再进行额外的查询操作。

与查询操作不同,变异操作默认是按顺序执行的。如果你在一次请求中发送多个变异操作,它们会依次执行,而不会并行运行。这种设计可以避免那些一个变异操作依赖于前一个操作结果的情况所可能引发的问题。

订阅机制:实时数据更新

订阅功能是GraphQL内置的实时数据更新机制。客户端无需主动轮询新数据,每当有相关事件发生时,服务器会主动推送数据给客户端。订阅功能是通过WebSockets实现的,也是GraphQL规范中不可或缺的一部分。

subscription OnNewComment($postId: ID!) {
  commentAdded(postId: $postId) {
    id
    text
    author {
      name
      profilePic
    }
  }
}

当客户端对某篇特定的帖子订阅了commentAdded事件时,服务器会保持WebSockets连接处于开启状态。每当有新的评论添加到这篇帖子中,服务器会立即将这些评论数据推送给所有已订阅的客户端。

对于聊天应用、实时通知系统、实时协作功能来说,这种机制再合适不过了;在任何用户期望用户界面能够自动响应服务器端事件而无需手动刷新的场景中,这种技术都十分有用。

变量:让查询操作更安全、更可重用

变量的存在使得我们可以在不直接将动态值嵌入到查询字符串中的情况下,将这些值传递给GraphQL操作。这种分离方式不仅是一种惯例,更是所有GraphQL库为保障安全性与效率而强制要求遵守的规定。

如果没有变量,动态值就必须被直接插入到查询字符串中,这样就会增加遭受注入攻击的风险,同时也会影响查询级别的缓存机制。而使用变量的话,查询字符串就变成了一个固定不变、不可修改的模板。只有变量会在每次请求时发生变化:

# 查询字符串始终都是这个固定的内容,永远不会改变。
query GetUser($userId: ID!) {
  user(id: $userId) {
    name
  }
}
// 每次请求时,变量对象的内容都会发生变化。
{ "userId": "42" }

服务器会接收这两类数据,并分别对它们进行处理。它永远不会将你的变量值插入到查询字符串中。这种处理方式才是处理动态数据的正确方法,而`graphql_flutter`库在其整个API设计中都严格遵守这一原则。

片段:可复用的字段集

当查询的复杂性增加时,你会发现在多个查询中都需要从同一类型的数据中选取相同的字段集合。而片段技术正是为了解决这个问题而存在的——它允许你使用具有名称的可复用字段选择代码段:

# 在特定类型上定义一次片段
fragment UserBasicInfo on User {
  id
  name
  profilePic
}

# 根据需要,在多个查询中使用这个片段
query GetPost($postId: ID!) {
  post(id: $postId) {
    title
    author {
      ...UserBasicInfo   // 在这里使用该片段
    }
    comments {
      text
      author {
        ...UserBasicInfo  // 也在这里使用该片段
      }
    }
  }
}

`...UserBasicInfo`这种写法告诉服务器,应该用片段中定义的完整字段集合来替换这些占位符。在像Flutter这样的组件驱动型用户界面中,片段尤其有用——因为每个组件都可以根据自身所需的数据来定义相应的片段,而屏幕级别的查询则是通过组合这些组件中的片段来构建的。

解析器:服务器如何处理查询请求

作为Flutter开发者,你可能不需要亲自编写解析器代码,但理解它们的工作原理会帮助你更有效地使用GraphQL API,并有助于你分析相关性能问题。

解析器是服务器上的一种函数,它的作用就是获取特定字段的数据。数据模型中的每个字段都对应着一个解析器。当查询请求到达时,GraphQL运行时会遍历字段选择集,并为每个被请求的字段调用相应的解析器,最终将所有结果组装成客户端所期望的形式返回。

Query: { user(id: "42") { name posts { title } } }

服务器执行过程:
  1. 调用User.id("42")的解析器 -> 返回{ id: "42", name: "Tony" }
  2. 调用User/posts("42")的解析器 -> 返回[ { title: "Post 1" } ]
  3. 将所有结果组装起来 -> 最终返回{ user: { name: "Tony", posts: [...] } }

每个解析器都会独立地获取自己负责的数据,这些数据可能来自关系型数据库、微服务、第三方API或内存缓存。GraphQL运行体会将所有这些数据拼接在一起,最终形成客户端所需的结果。客户端根本不需要了解也不关心这些数据的获取方式,它只需要声明自己想要什么,然后就能得到处理后的结果。

Flutter中的GraphQL架构

各组件是如何相互连接的

在Flutter应用程序中使用GraphQL时,有四个不同的层次会共同发挥作用。在编写任何代码之前,了解这些层次的功能及其之间的界限是至关重要的。

  1. Flutter的用户界面层包含了所有的组件。这些组件会使用QueryMutationSubscription这些组件(或钩子)来指定自己需要哪些数据。它们与HTTP、WebSockets或缓存机制毫无关系,它们只是描述自己的数据需求,并根据获取到的结果做出相应的反应。

  2. GraphQL客户端是整个系统的核心。graphql_flutter包负责管理与服务器的连接、实现数据的规范化缓存、请求队列管理、重复数据的去重处理,以及将处理后的结果发送给各个组件。

  3. 链接链是一种可组合的中间件机制,所有请求在到达服务器之前都会经过这一环节。这些链接可以添加认证头信息、记录请求日志、处理错误、重新尝试失败的请求,并根据操作类型在不同类型的连接(HTTP或WebSocket)之间进行路由转发。

  4. GraphQL服务器接收用户的请求,根据数据结构对其进行验证,然后执行相应的处理逻辑,最终返回JSON格式的响应结果。

Flutter GraphQL架构:请求/响应生命周期

规范化缓存:GraphQL的秘密武器

GraphQL客户端的缓存功能是其最强大的特性之一,同时也是人们最容易误解的部分。

与那些仅用于存储原始响应数据的简单HTTP缓存不同,GraphQL缓存实际上是一个规范化的对象存储系统。每个对象只会被存储一次,其存储方式取决于它的类型和ID。例如,id为“17”、类型为Post的帖子,其缓存键就是Post:17。如果同一个帖子在十个不同的页面上出现了十次,那么它也只会被存储一次。

这种设计带来的好处是非常显著的。当有数据发生变化时,缓存系统会自动更新其中保存的唯一副本。因此,应用中所有曾经获取过该数据的组件都会立即接收到更新后的信息并重新生成显示内容。比如,在帖子详情页面上修改的点赞数,会自动同步到动态信息页、用户个人主页以及该帖子出现的其他所有地方,而无需再次进行数据请求。

正是这种共享的、基于缓存的规范化存储机制,使得构建得当的GraphQL应用能够实现流畅的用户体验。同时,它也使得乐观主义的UI更新机制能够在整个组件树中同步生效。

在Flutter中配置GraphQL

添加依赖项

打开你的pubspec.yaml文件,然后添加以下包:

dependencies:
  flutter:
    sdk: flutter
  graphql_flutter: ^5.3.0

之后运行以下命令进行安装:

flutter pub get

Android构建配置

对于Android目标平台来说,这一步是必不可少的,而且很容易被忽略。请打开`android/app/build.gradle`文件,确保其中包含了Java兼容性设置:

android {
    compileSdkVersion 34

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_17
        targetCompatibility JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = "17"
    }
}

同时,还需要更新`android/gradle/wrapper/gradle-wrapper.properties`文件,以便使用Gradle 8.4:

distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip

如果跳过这一步,在构建过程中就会出现难以诊断的Java兼容性错误,尤其是当你不知道应该在这里进行检查时。

初始化Hive以实现持久化缓存

`graphql_flutter`框架使用Hive(通过`hive_ce`模块)来实现磁盘上的持久化缓存。这意味着即使应用程序重新启动,缓存数据也会保留下来:因此,在没有网络连接的情况下打开应用程序时,用户仍然能够看到之前加载的数据,而不会看到空白屏幕。

要启用这一功能,必须在调用`runApp()`之前先执行`initHiveForFlutter()`方法,并且必须等待该操作完成:

import 'package:flutter/material.dart';
import 'package:graphql_flutter graphqlflutter.dart';

void main() async {
  // 在调用任何Flutter插件代码之前,必须先执行此步骤。
  // `initHiveForFlutter()`会利用平台相关的机制,在不同平台上找到正确的存储目录。
  Widgets FlutterBinding.ensureInitialized();

  // 设置Hive的存储目录并注册必要的适配器。完成这些操作后,
  // `HiveStore`就可以在`GraphQLCache`中使用了。
  await initHiveForFlutter();

  runApp(const MyApp());
}

如果你不希望缓存数据在会话之间持续保留,可以跳过这个初始化步骤,在创建缓存时使用`InMemoryStore`代替`HiveStore`。但对于生产环境的应用程序来说,`HiveStore`几乎总是更合适的选择。

创建GraphQL客户端

`GraphQLClient`是整个系统中的核心组件。它只需要被创建一次,然后通过`GraphQLProvider`传递给 widget 树。

以下是完整的配置流程,其中每一步都附有详细的解释:

import 'package:flutter/material.dart';
import 'package:graphqlflutter graphql_flutter.dart';

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // `HttpLink`是整个请求链中的最终环节,它负责向服务器发送HTTP POST请求。
    // 它的唯一参数就是你的GraphQL接口地址。
    final HttpLink httpLink = HttpLink(
      'https://api.yourapp.comgraphql',
    );

    // `AuthLink`是一个非终结链接,它在`HttpLink`之前执行。
    // 它的作用是在每个请求中添加Authorization头信息。
    // `getToken`是一个异步操作,你可以从安全存储中获取令牌,
    // 或者通过其他异步方式来获取它。
    final AuthLink authLink = AuthLink(
     -token: () async {
        // 在生产环境中,应该从FlutterSecureStorage或你的认证状态管理机制中获取令牌,
        // 而不是从普通的存储系统中获取。
        final token = await _getTokenFromStorage();
        return 'Bearer $token';
      },
    );

    // `concat()`方法用于组合所有的请求链接。请求会按照从左到右的顺序执行:
    // 先执行`AuthLink`(添加Authorization头),然后再执行`HttpLink`(发送实际的HTTP请求)。
    // 根据需要,你可以在它们之间插入任意数量的非终结链接。
    final Link link = authLink.concat(httpLink);

    // `ValueNotifier`是`GraphQLProvider`所必需的。
    // 将客户端包装在`ValueNotifier`中,可以让你在运行时替换整个客户端(例如,在用户登出时清除缓存),
    // `GraphQLProvider`会自动使用新的客户端重新构建所有的子组件。
    final ValueNotifier client = ValueNotifier(
      GraphQLClient(
        link: link,
        // `HiveStore`提供了持久化的磁盘缓存功能。
        // 如果你希望每次应用程序重启时都清除缓存,可以将`HiveStore()`替换为`InMemoryStore()`。
        cache: GraphQLCache(store: HiveStore()),
      ),
    );

    // `GraphQLProvider`会通过`InheritedWidget`将客户端注入到 widget 树中。
    // 任何子组件都可以通过`GraphQLProvider.of(context)`来访问这个客户端。
    // 因为使用了`MatMaterial`, 所以应用程序中的每个页面都能使用这个客户端。
    return GraphQLProvider(
      client: client,
      child: MaterialApp(
        title: '我的GraphQL应用',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
          useMaterial3: true,
        ),
        home: const HomePage(),
      ),
    );
  }

  Future _getTokenFromStorage() async {
    // 请用你实际的安全存储实现方式替换这段代码。
    return 'your-auth-token';
  }
}

为订阅功能添加 WebSocket 支持

如果您的应用程序使用实时订阅功能,那么除了 `HttpLink` 之外,您还需要 `WebSocketLink`。这两种链接会通过 `Link.split` 方法进行组合,该方法会根据请求的类型将其路由到相应的传输方式:

final HttpLink httpLink = HttpLink('https://api.yourapp.comgraphql');

final WebSocketLink webSocketLink = WebSocketLink(
  // 在生产环境中,使用 wss:// 协议来连接安全的 WebSocket 连接;
  // 仅在本地开发环境下使用 ws:// 协议。
  'wss://api.yourapp.com graphql',
  config: const SocketClientConfig(
    autoReconnect: true,
    delayBetweenConnectAttempts: Duration(seconds: 5),
  ),
);

// Link.split 会针对每个传入的请求来判断应该使用哪种链接。
// 如果判断结果为 true,那么第一个链接就会处理该请求;
// 否则,第二个链接将会进行处理。
// 对于订阅操作,request.isSubscription 的值为 true。
final Link link = authLink.concat(
  Link.split(
    (request) => request.isSubscription,
    webSocketLink,  // 订阅相关请求会通过这个链接发送
    httpLink,       // 查询和修改操作相关请求会通过这个链接发送
  ),
);

authLink 位于分割逻辑之前,因此它会处理所有类型的请求。无论是 HTTP 还是 WebSocket 连接,通常都需要进行身份验证。

在 Flutter 中使用 GraphQL:查询、修改操作和订阅功能

查询:获取并显示数据

Query 组件会执行 GraphQL 查询,并且每当查询结果发生变化时,就会重新执行该查询。它是在屏幕上加载数据的主要方式。

import 'package:flutter/material.dart';
import 'package:graphql_flutter graphqlflutter.dart';

// 始终将查询字符串定义为顶层常量,而不要在 `build()` 方法内部定义。
// 前缀 `r` 可以使字符串保持原始格式,这样美元符号和反斜杠就不会被视为 Dart 的转义字符或字符串插值操作。
// `gql()` 方法会将这个字符串解析成客户端可以执行的 DocumentNode 树结构。
const String fetchPostsQuery = r'''
  query FetchPosts(\(limit: Int!, \)page: Int!) {
    allPosts(limit: \(limit, page: \)page) {
      id
      title
      publishedAt
      likeCount
      author {
        name
        profilePic
      }
    }
  }
''';

class PostListScreen extends StatelessWidget {
  const PostListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Posts')),
      body: Query(
        options: QueryOptions(
          document: gql.fetchPostsQuery),

          // 变量以普通的 Dart Map 形式传递。
          // 这个库会将这些变量序列化为 JSON 格式,并将它们与查询字符串一起作为请求体中的字段发送出去。
          variables: const {'limit': 10, 'page': 1},

          // cacheAndNetwork:如果缓存中有数据,就立即返回缓存数据;
          // 如果没有缓存数据,则在后台发起网络请求,并在数据到达后重新构建界面。
          // 这样用户就可以立刻看到结果,同时也能确保数据与服务器上的数据保持一致。
          fetchPolicy: FetchPolicy.cacheAndNetwork,
        ),

        // builder 函数会在状态发生变化时被调用:
        // 例如,在开始加载数据、数据到达、出现错误,或者缓存数据被更新时都会调用它。
        //
        // result  -- 当前状态:加载进度、数据内容、是否出现错误
        // refetch -- 用于手动重新执行查询的回调函数
        // fetchMore -- 用于分页操作的回调函数(后面会详细介绍)
        builder: (QueryResult result, {VoidCallback? refetch, FetchMore? fetchMore}) {

          // 首先检查是否出现了错误。
          // OperationException 类可以包裹网络层和 GraphQL 层出现的各种错误。
          if (result.hasException) {
            return Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  const Icon'icon Fehler_outline', size: 48, color: Colors.red),
                  const SizedBox(height: 12),
                  Text(
                    result.exception?.graphqlErrors.firstOrNull?.message
                        ?? result.exception?.linkException.toString()
                        ?? '发生了错误',
                    textAlign: TextAlign.center,
                  ),
                  const SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: refetch,
                    child: const Text('重新尝试'),
                  ),
                ],
              ),
            );
          }

          //只有在初次加载数据且没有缓存数据存在时,isLoading 的值才会为 true。
          // 使用 cacheAndNetwork 策略后,即使后台正在发起网络请求,isLoading 的值也会显示为 false。
          if (result isLoading && result.data == null) {
            return const Center(child: CircularProgressIndicator());
          }

          // result.data 是一个 Map 类型,其键值对与你在查询中指定的字段相对应。
          final List:? posts =
              result.data?['allPosts'] as List?

          if (posts == null || posts.isEmpty) {
            return const Center(child: Text('没有找到任何帖子'));
          }

          return RefreshIndicator(
            onRefresh: () async => refetch?.call(),
            child: ListView.builder(
              itemCount: posts.length,
              itemBuilder: (context, index) {
                final post = posts[index] as Map;
                return PostCard(post: post);
              },
            ),
          );
        },
      ),
    );
  }
}

class PostCard extends StatelessWidget {
  final Map post;

  const PostCard({super.key, required this.post});

  @override
  Widget build(BuildContext context) {
    final author = post['author'] as Map

cacheAndNetwork这种数据获取策略值得重点强调。当用户第二次访问这个页面时,系统会直接使用缓存的数据进行渲染,从而完全避免任何网络等待时间;同时,后台也会发起一个网络请求。一旦该请求完成,界面就会使用最新的数据重新生成。

构建逻辑会被执行两次:一次是使用缓存的数据,另一次则是使用从网络获取到的最新数据。对于大多数采用信息流布局的页面来说,这种设计能够带来最佳的用户体验——既保证了操作响应的迅速性,又确保了数据的新鲜度。

使用钩子进行查询操作

如果你的团队更喜欢功能更为明确、结构更加清晰的开发方式,graphql_flutter提供了与flutter_hooks配合使用的useQuery钩子。它的使用效果与Query组件相同,但这种API设计避免了构建函数中出现的深度嵌套问题。

import 'package:flutter/material.dart';
import 'packageflutter_hooks/flutter-hooks.dart';
import 'package:graphql_flutter graphql_flutter.dart';

// 当使用钩子时,HookWidget会替代 StatelessWidget。
class PostListScreen extends HookWidget {
  const PostListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // useQuery会返回一个包含查询结果及相关辅助功能的QueryHookResult对象。
    final queryResult = useQuery(
      QueryOptions(
        document: gql-fetchPostsQuery),
        variables: const {'limit': 10, 'page': 1},
        fetchPolicy: FetchPolicy.cacheAndNetwork,
      ),
    );

    final result = queryResult.result;
    final refetch = queryResult.refetch;

    if (result.hasException) {
      return Scaffold(
        body: Center(child: Text(result.exception.toString')),
      );
    }

    if (result isLoading && result.data == null) {
      return const Scaffold(
        body: Center(child: CircularProgressIndicator()),
      );
    }

    final posts = result.data?['allPosts'] as List? ?? [];

    return Scaffold(
      appBar: AppBar(title: const Text('文章列表')),
      body: RefreshIndicator(
        onRefresh: () async => refetch(),
        child: ListView.builder(
          itemCount: posts.length,
          itemBuilder: (context, index) {
            final post = posts[index] as Map;
            return PostCard(post: post);
          },
        ),
      ),
    );
  }
}

这两种开发方式都是被完全支持的,而且它们的功能也是等价的。对于那些没有React开发经验的开发者来说,基于组件的API设计会更加容易理解;而当一个组件需要执行多个操作时,使用钩子编写的代码结构会更加简洁,因为这样可以避免出现回调函数嵌套的问题。

变异操作:触发数据更新

Mutation组件在其构建逻辑中提供了RunMutation函数。与会在渲染时自动执行的Query不同,Mutation需要用户明确调用runMutation才能开始执行操作。变异操作的触发是由用户的操作引起的,而不是在组件创建时自动发生的。

const String likePostMutation = r'''
  mutation LikePost($postId: ID!) {
    likePost(postId: $postId) {
      id
      likeCount
      viewerHasLiked
    }
  }
''';

class LikeButton extends StatelessWidget {
  final String postId;
  final bool initiallyliked;
  final int likeCount;

  const LikeButton({
    super.key,
    required this.postId,
    required this.initiallyliked,
    required this.likeCount,
  });

  @override
  Widget build(BuildContext context) {
    return Mutation(
      options: MutationOptions(
        document: gql(likePostMutation),

        // 在突变操作完成之后,会执行更新逻辑。
        // 由于我们的突变操作会返回更新后的帖子信息,其中包含id、likeCount以及viewerHasLiked字段,
        // 因此缓存系统可以自动更新这些数据。任何之前获取过该帖子信息的查询组件都会使用到新的likeCount值。
        // 只有在需要向缓存列表中添加或删除元素时,才需要手动进行缓存更新操作。
        update: (GraphQLDataProxy cache, QueryResult? result) {
          // 缓存系统的自动更新机制可以很好地处理这种情况。
        },

        // 在突变操作成功完成后,会执行onCompleted回调函数。
        // 可以利用这个回调函数来显示通知栏、进行导航操作或触发分析事件等。
        onCompleted: (dynamic resultData) {
          if (resultData != null) {
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('帖子已被点赞!')),
            );
          }
        },

        // 在突变操作失败时,会执行onError回调函数。
        onError: (OperationException? error) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text(
                error?.graphqlErrors.firstOrNull?.message
                    ?? '尝试点赞帖子时发生错误',
              ),
            ),
          );
        },
      },

      // runMutation:调用这个方法来执行突变操作。
      // result:表示上次突变操作的结果状态,在第一次调用之前,result的值为null。
      builder: (RunMutation runMutation, QueryResult? result) {
        final isLoading = result?.isLoading ?? false;
        final hasLiked = result?.data?['likePost']?['viewerHasliked'] as bool?
            ?? initiallyLiked;
        final currentCount =
            result?.data?['likePost']?['likeCount'] as int? ?? likeCount;

        return GestureDetector(
          onTap: isLoading
              ? null
              : () => runMutation({'postId': postId}),
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              isLoading
                  ? const SizedBox(
                      width: 20,
                      height: 20,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                  : Icon(
                      hasLiked ? Icons.favorite : Icons_favorite_border,
                      color: hasliked ? Colors.red : Colors.grey,
                    ),
              const SizedBox(width: 4),
              Text('$currentCount'),
            ],
          ),
        );
      },
    );
  }
}

updateonCompletedonError之间的关系常常会引起混淆。可以这样理解:update用于缓存操作,即使获取到的是乐观结果,也会被执行;onCompleted用于处理操作成功后的后续操作;而onError则用于处理操作失败后的后续处理。

千万不要将导航逻辑放在update方法中,因为该方法会在小部件重建周期完成之前就被执行,这样就会导致导航错误。

订阅:接收实时事件

Subscription小部件会建立WebSocket连接,并且每当服务器发送新的事件时,就会调用其构建函数。每次调用构建函数时,都会接收到最新的单个事件,而不是所有过去事件的累积记录。随着时间的推移,负责管理和维护状态的数据就是开发者的职责。

const String commentAddedSubscription = r'''
  subscription CommentAdded($postId: ID!) {
    commentAdded(postId: $postId) {
      id
      text
      author {
        id
        name
        profilePic
      }
    }
  }
''';

class CommentsSection extends StatefulWidget {
  final String postId;
  const CommentsSection({super.key, required this.postId});

  @override
  State createState() => _CommentsSectionState();
}

class _CommentsSectionState extends State {
  final List> _comments = [];

  @override
  Widget build(BuildContext context) {
    return Subscription(
      options: SubscriptionOptions(
        document: gql(commentAddedSubscription),
        variables: {'postId': widget.postId},
      ),
      builder: (QueryResult result) {
        if (result isLoading) {
          // 对于订阅来说,isLoading表示WebSocket连接正在建立中,并不意味着服务器数据正在加载。
          return const Center(child: CircularProgressIndicator());
        }

        if (result.hasException) {
          return Text('订阅错误:${result.exception}`);
        }

        if (result.data != null) {
          final newComment =
              result.data!['commentAdded'] as Map?;
          if (newComment != null) {
            // addPostFrameCallback的作用是防止在当前构建阶段调用setState方法,因为这样会引发Flutter错误。
            WidgetsBinding.instance.addPostFrameCallback((_) {
              if (mounted) {
                setState(() {
                  final exists =
                      _comments.any((c) => c['id'] == newComment['id']);
                  if (!exists) _comments.insert(0, newComment);
                });
              }
            });
          }
        }

        if (_comments.isEmpty) {
          return const Center(child: Text('目前还没有评论。快来第一条吧!'));
        }

        return ListView.builder(
          itemCount: _comments.length,
          itemBuilder: (context, index) {
            final comment = _comments[index];
            final author = comment['author'] as Map?;
            return ListTile(
              leading: CircleAvatar(
                backgroundImage: author?['profilePic'] != null
                    ? NetworkImage(author!['profilePic'] as String)
                    : null,
                child: author?['profilePic'] == null
                    ? const IconIcons.person)
                    : null,
              ),
              title: Text.author?['name'] as String? ?? '匿名'),
              subtitle: Text(comment['text'] as String? ?? '');
            );
          },
        );
      },
    );
  }
}

在生产代码中,你不会使用StatefulWidget来管理订阅状态。相反,你会将订阅事件发送到一个Bloc或provider中,让它们来存储这些事件,而 widget则会简单地显示该Bloc所提供的数据。

高级概念

// 账户设置——在会话期间很少发生变化,只需加载一次 QueryOptions( document: gql(getUserSettingsQuery), fetchPolicy: FetchPolicy.cacheFirst, ) // 新闻动态——优先从缓存中加载数据,必要时再通过后台请求更新数据以确保新鲜性 QueryOptions( document: gql(getNewsFeedQuery), fetchPolicy: FetchPolicy.cacheAndNetwork, ) // 支付记录——必须始终反映服务器上的最新状态 QueryOptions( document: gql(getPaymentHistoryQuery), fetchPolicy: FetchPolicy.networkOnly, )

使用“fetchMore”实现分页功能

大多数实际应用中都会遇到那些内容量太大、无法一次性加载完毕的列表。在Query构建器中提供的fetchMore函数可以通过执行新的查询并将结果与现有的数据合并来处理分页逻辑。

const String fetchPostsWithPaginationQuery = r'''
  query FetchPosts(\(cursor: String, \)limit: Int!) {
    postsConnection(after: \(cursor, first: \)limit) {
      edges {
        node {
          id
          title
          likeCount
        }
        cursor
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
''';

class PaginatedPostList extends StatelessWidget {
  const PaginatedPostList({super.key});

  @override
  Widget build(BuildContext context) {
    return Query(
      options: QueryOptions(
        document: gql.fetchPostsWithPaginationQuery),
        variables: const {'limit': 10, 'cursor': null},
        fetchPolicy: FetchPolicy.cacheAndNetwork,
      ),
      builder: (QueryResult result, {VoidCallback? refetch, FetchMore? fetchMore}) {
        if (result isLoading && result.data == null) {
          return const Center(child: CircularProgressIndicator());
        }

        final connection =
            result.data ? ['postsConnection'] as Map? ?? [];
        final edges = connection ? ['edges'] as List? ?? [];
        final pageInfo =
            connection ? ['pageInfo'] as Map? ?? {};
        final hasNextPage = pageInfo ? ['hasNextPage'] as bool? ?? false;
        final endCursor = pageInfo ? ['endCursor'] as String??

        return ListView.builder(
          itemCount: edges.length + (hasNextPage ? 1 : 0),
          itemBuilder: (context, index) {
            if (index == edges.length) {
              return Padding(
                padding: const EdgeInsets.all(16),
                child: ElevatedButton(
                  onPressed: () {
                    final FetchMoreOptions opts = FetchMoreOptions(
                      variables: {'cursor': endCursor, 'limit': 10},

                      // updateQuery会将新获取的数据与之前的数据合并。
                      // 必须从这个函数中返回合并后的数据集。
                      // previousResultData:迄今为止获取的所有数据。
                      // fetchMoreResultData:仅包含这个新页面的数据。
                      updateQuery: (previousResultData, fetchMoreResultData) {
                        final List allEdges = [
                          ...previousResultData['postsConnection']['edges']
                              as List,
                          ...fetchMoreResultData['postsConnection']['edges']
                              as List,
                        ];
                        // 将合并后的列表赋值给 fetchMoreResultData
                        // 并将其返回。库会使用返回的这个值作为查询的新结果。
                        fetchMoreResultData['postsConnection']['edges'] = allEdges;
                        return fetchMoreResultData;
                      },
                    );

                    fetchMore!(opts);
                  },
                  child: const Text('加载更多'),
                ),
              );
            }

            final node = edges[index]['node'] as Map>;
            return PostCard(post: node);
          },
        );
      },
    );
  }
}

fetchMore中最常见的错误就是直接修改previousResultData,而不是创建一个新的列表。应该始终将这两个参数视为只读数据,将合并后的列表作为新的对象来构建,然后将其赋值给fetchMoreResultData,最后返回fetchMoreResultData

乐观主义UI更新机制

乐观主义UI是一种这样的设计模式:在用户执行操作后,界面会立即进行更新,而无需等待服务器确认这一变更。如果服务器最终确认了该变更,那么之前显示的“乐观数据”就会被服务器提供的权威数据自动替换;如果服务器拒绝了这一变更,缓存则会自动恢复到变更之前的状态。

采用这种机制的应用程序会给人带来更加流畅的使用体验。例如,当用户点击“点赞”按钮时,心形图标会立即变成红色,点赞次数也会立刻增加——没有任何等待时间,也不会出现旋转动画。如果网络请求失败,界面也会自动恢复到原始状态,而无需编写任何手动回滚代码。

Mutation(
  options: MutationOptions(
    document: gql(likePostMutation),
    update: (GraphQLDataProxy cache, QueryResult? result) {
      // 当服务器的响应到达时,缓存会自动将“乐观数据”替换为服务器提供的权威数据。
    },
  ),
  builder: (RunMutation runMutation, QueryResult? result) {
    return IconButton(
      onPressed: () {
        runMutation(
          {'postId': postId},
          optimisticResult: {
            'likePost': {
              '__typename': 'Post',
              'id': postId,
              'likeCount': currentLikeCount + 1,
              'viewerHasLiked': true,
            }
          },
        );
      },
      icon: const Icon'icon_favorite_border),
    );
  },
);

runMutation被调用时,并且传入了optimisticResult,缓存会立即应用这些数据,并将更新结果发送给所有存储了该对象数据的组件。片刻之后,当服务器的最终响应到达时,缓存会再次根据服务器提供的数据进行更新,从而完成最终的重建过程。

错误处理:面向生产环境的高效方案

GraphQL错误主要分为两大类,正确处理这两类错误对于构建可靠的生产环境应用程序来说至关重要。

网络错误发生在传输层:例如没有互联网连接、DNS解析失败、服务器无法访问或连接超时等。这类错误会以LinkException的形式出现在result.exception中。

GraphQL错误则发生在GraphQL的执行层:比如认证失败、权限违规、模式验证错误,或者由服务器团队定义的定制业务逻辑错误等。这些错误会以GraphQLError对象的形式出现。

重要的是,GraphQL允许返回部分结果:当响应中同时包含dataerrors时,这意味着某些字段获取成功了,而某些字段则获取失败了。

Widget _buildFromResult(
    BuildContext context, QueryResult result, VoidCallback? refetch) {
  if (result.hasException) {
    final exception = result.exception!;

    // 先检查网络层错误
    if (exception.linkException != null) {
      if (exception.linkException is NetworkException) {
        return _NoInternetWidget(onRetry: refetch);
      }
      return _ServerErrorWidget(onRetry: refetch);
    }

    // 然后检查GraphQL层错误
    if (exception.graphqlErrors.isNotEmpty) {
      final firstError = exception.graphqlErrors.first;
      // 许多服务器会在扩展信息中包含机器可识别的代码
      final errorCode = firstErrorextensions?['code'] as String??

      switch (errorCode) {
        case 'UNAUTHENTICATED':
          WidgetsBinding.instance.addPostFrameCallback((_) {
            Navigator.of(context).pushReplacementNamed('/login');
          });
          return const SizedBox.shrink();

        case 'FORBIDDEN':
          return const _AccessDeniedWidget();

        case 'NOT_FOUND':
          return const _NotFoundWidget();

        default:
          return _GenericErrorWidget(
            message: firstError.message,
            onRetry: refetch,
          );
      }
    }
  }

  // 成功情况的处理代码放在这里...
  return const SizedBox.shrink();
}

认证:透明令牌刷新机制

在生产环境中,访问令牌会过期。为了避免过期的令牌导致请求失败,进而让用户不得不手动重新进行登录操作,你可以创建一个自定义链接,该链接能够在检测到认证错误时自动刷新令牌,然后再重试原来的请求。

class AuthRefreshLink extends Link {
  final Future〈String?>> Function() refreshToken;
  final Future〈void〉 Function() onAuthFailure;

  AuthRefreshLink({required this.refreshToken, required this.onAuthFailure});

  @override
  Stream〈Response〉 request(Request request, [NextLink? forward]) async* {
    await for (final result in forward!(request)) {
      final isAuthError = (result.errors ?? [])
          .any((e) => eextensions?['code'] == 'UNAUTHENTICATED');

      if (isAuthError) {
        final newToken = await refreshToken();

        if (newToken == null) {
          await onAuthFailure(); // 触发登出操作
          return;
        }

        // 使用新的令牌重试原来的请求
        final retryRequest = request.updateContextEntry〈HttpLinkHeaders〉(
          (headers) => HttpLinkHeaders(
            headers: {
              ...headers?.headers ?? {},
              'Authorization': 'Bearer $newToken',
            },
          ),
        );

        yield* forward(retryRequest);
      } else {
        yield result;
      }
    }
  }
}

这个链接位于HttpLink之前的位置。当出现UNAUTHENTICATED错误时,它会重新生成令牌,重新发送原始请求,从而使插件能够正常接收到数据,仿佛什么异常都没有发生一样。如果令牌的更新失败,就会调用onAuthFailure方法,进而触发登出流程。

实际应用中的最佳实践

可扩展的项目结构

将查询字符串分散分布在各个插件文件中,是导致代码难以维护的常见原因之一。以下是一种能够使GraphQL相关操作保持有序且便于查找的文件夹结构:

lib/
  graphql/
    client.dart              -- GraphQL客户端配置代码,可在全局范围内使用
    queries/
      post_queries.dart      -- 所有与帖子相关的查询语句
      userqueries.dart      -- 所有与用户相关的查询语句
    mutations/
      post_mutations.dart
      auth_mutations.dart
    subscriptions/
      commentsubs.dart
    fragments/
      post_fragments.dart    -- 可重复使用的帖子字段集合
      user_fragments.dart    -- 可重复使用的用户字段集合

  models/
    post.dart                -- 从GraphQL数据中解析得到的类型化Dart模型
    user.dart

  repositories/
    post_repository.dart     -- 数据访问抽象层

  blocs/
    post_bloc.dart           -- 业务逻辑及状态管理代码

  screens/
    post_list/
      post_list_screen.dart
      widgets/
        post_card.dart
        like_button.dart

从片段中组合查询语句

应该将片段定义在专门的文件中,然后通过字符串插值的方式来组合查询语句。这样就能确保不同查询语句中的字段集合保持一致,并且当模式发生变更时,这些变化只需在一个地方进行修改即可。

// libgraphql/fragments/post_fragments.dart

const String postBasicFieldsFragment = r'''
  fragment PostBasicFields on Post {
    id
    title
    publishedAt
    likeCount
  }
''';

const String postAuthorFragment = r'''
  fragment PostAuthorFields on Post {
    author {
      id
      name
      profilePic
    }
  }
''';
// libgraphql/queries/post_queries.dart

import 'package:your_appGraphQL/fragments/post_fragments.dart';

const String fetchPostsQuery = '''
  $postBasicFieldsFragment
  $postAuthorFragment

  query FetchPosts(\\(limit: Int!, \\)page: Int!) {
    allPosts(limit: \\(limit, page: \\)page) {
      ...PostBasicFields
      ...PostAuthorFields
    }
  }
''';

将GraphQL数据解析为类型化模型

如果在整个业务逻辑处理过程中直接使用Map<String, dynamic>结构,那么代码就会变得脆弱且容易出错。字符串键中出现的任何拼写错误都会在运行时导致程序出现“空值”问题,而这种错误并不会在编译阶段被检测出来。因此,应该定义类型化的模型类,并在数据层对GraphQL响应进行解析处理。

// lib/models/post.dart

class Post {
  final String id;
  final String title;
  final String content;
  final int likeCount;
  final DateTime publishedAt;
  final User author;

  const Post({
    required this.id,
    required this.title,
    required this.content,
    required this.likeCount,
    required this.publishedAt,
    required this.author,
  });

  factory Post.fromMap(Map map) {
    return Post(
      id: map['id'] as String,
      title: map['title'] as String,
      content: map['content'] as String,
      likeCount: map['likeCount'] as int? ?? 0,
      publishedAt: DateTime.parse(map['publishedAt'] as String),
      author: User.fromMap(map['author'] as Map

与Bloc及仓库层集成

对于生产环境中的应用来说,如果直接在界面中使用QueryMutation组件,那么用户的界面就会与GraphQL紧密地绑定在一起。通过引入一个仓库层来封装GraphQL相关的操作,并让Bloc作为仓库层与用户界面之间的中介,就可以实现职责的合理分离:

// lib/repositories/post_repository.dart

class PostRepository {
  final GraphQLClient _client;

  PostRepository(this._client);

  Future> fetchPosts({int page = 1, int limit = 10}) async {
    final result = await _client.query(
      QueryOptions(
        document: gql.fetchPostsQuery),
        variables: {'page': page, 'limit': limit},
        fetchPolicy: FetchPolicy.cacheAndNetwork,
      ),
    );

    if (result.hasException) throw _mapException(result.exception!);

    return (result.data!['allPosts'] as List)
        .cast>()
        .map(Post.fromMap)
        .ToList();
  }

  Future likePost(String postId) async {
    final result = await _client.mutate(
      MutationOptions(
        document: gql(likePostMutation),
        variables: {'postId': postId},
      ),
    );

    if (result.hasException) throw _mapException(result.exception!);

    return Post.fromMap(
      result.data!['likePost'] as Map,
    );
  }

  Stream(Post> watchNewPosts() {
    return _client
        .subscribe(
            SubscriptionOptions(document: gql(postAddedSubscription)))
        .where((result) => !result.hasException && result.data != null)
        .map((result) => Post.fromMap(
              result.data!['postAdded'] as Map,
            ));
  }

  Exception _mapException(OperationException e) {
    if (e.linkException != null) {
      return NetworkException('没有互联网连接');
    }
    return ApiException(
      e.graphqlErrors.firstOrNull?.message ?? '未知错误',
    );
  }
}

采用这种架构后,你的Bloc完全不需要了解GraphQL的相关知识;你的界面代码也同样与GraphQL无关。GraphQL实际上只是仓库层实现细节的一部分。因此,你的用户界面和业务逻辑可以在不模拟GraphQL的情况下进行单元测试,而这正是一个结构良好的数据层的体现。

何时使用GraphQL,何时不应使用它

GraphQL的优势所在

当你的应用程序确实非常复杂且数据量巨大时,GraphQL才是正确的选择。如果你的页面需要同时从多个相关实体中获取数据,而且不同的页面需要同一基础数据的不同子集,那么采用客户端驱动的数据获取方式几乎会立即带来显著的效果。

移动应用尤其适合使用GraphQL,因为移动设备的带宽和电池容量都是有限的资源,而GraphQL查询的精确性会对这些资源产生直接且可衡量的影响。

当你需要为多种类型的客户端提供服务时,GraphQL也同样非常适用——无论是网页应用、移动应用、平板应用,还是智能手表应用程序,它们都可以使用同一个API。而对于REST架构来说,要么为每种客户端单独开发专属的接口端点,要么强迫所有客户端从通用的接口端点获取数据;而使用GraphQL的话,每个客户端都能从统一的数据库模式中精确地获取自己所需的数据。

实时功能也是GraphQL的天然优势之一。订阅机制是GraphQL协议的重要组成部分,而非事后添加的功能。结合规范化的缓存系统,通过订阅机制传入的新数据可以同时更新被多个页面共享的缓存数据。

如果你的团队重视类型安全性以及自文档化的API设计,那么GraphQL所能提供的支持是REST架构无法比拟的——除非使用额外的工具才能实现类似的效果。GraphQL的数据库模式本身就是一份可被深入探索的“契约”;结合像graphql_codegen这样的代码生成工具,你可以从数据库模式定义开始,确保整个开发流程都具备类型安全性,直到最终生成Dart应用程序中的组件。

何时不应使用GraphQL

GraphQL会增加实际的复杂性:你需要维护数据库模式,编写解析器代码,配置链接链,并且还需要深入理解规范化的缓存机制才能正确使用它。

对于那些简单的CRUD应用程序来说,比如设置页面、联系表单或基本的注册流程,这种复杂性往往并不会带来实际的好处。REST架构更易于搭建,也更容易调试,而且大多数开发者都对其更加熟悉。

如果你的团队没有使用GraphQL的经验,并且面临紧迫的交付期限,那么学习 GraphQL所带来的困难确实是真实存在的。在决定采用这项技术之前,必须认真权衡这种得失。

虽然从技术上来说,通过多部分请求机制在GraphQL中实现文件上传是可行的,但实际上这种实现的复杂性要高于直接使用REST架构进行多部分POST请求。如果文件上传是你的应用程序的核心功能之一,那么REST架构会处理得更加自然。

对于那些希望通过简单的curl命令来测试你的API的第三方开发者来说,GraphQL也更加难以使用。而对于那些需要面向广大开发者群体、并且允许他们使用各种不同工具来访问的公开开发接口而言,REST架构才是更为合适和传统的选择。

常见错误

忽视规范化缓存的工作原理

对于刚接触GraphQL的开发者来说,最常见的错误就是不理解数据规范化的机制,从而导致无法正确使用缓存系统。当你执行一次mutation操作时,服务器虽然会更新数据,但用户界面并不会自动刷新。

这种情况通常由以下三个原因导致:

  1. mutation操作没有返回被更新的字段信息,因此缓存系统中没有新的数据可供规范化处理。请确保每次mutation操作都能返回用户界面所需的全部字段信息。

  2. 返回的对象中缺少id字段,有时也会缺少__typename字段,这样一来缓存系统就无法确定应该更新哪个对象。缓存系统会使用__typenameid组合起来作为键来存储数据。如果其中任何一个字段缺失,规范化处理就会失败,从而导致更新操作没有任何效果。

  3. mutation操作会在列表中添加或删除元素,但缓存系统并不会自动更新这些列表。缓存系统只能根据对象的键来识别并更新相应的数据。对于那些需要在列表中添加新元素的场景,开发者必须手动在update回调函数中使用cache.writeQuerycache.writeFragment方法来更新列表。

在构建方法中定义查询字符串

当在build()方法中将查询字符串定义为局部变量时,Dart会在每次重新构建应用程序时都重新生成这个查询字符串,而gql()函数也会在每次调用时重新解析这个字符串以生成AST文档对象。对于简单的组件来说,这种做法可能不会造成什么问题,但对于复杂的组件结构而言,这种重复性的操作会显著降低性能。因此,建议始终将查询字符串定义为顶级的const变量:

// 错误的做法——每次重新构建都会重新生成和解析查询字符串
Widget build(BuildContext context) {
  final query = '''
    query { ... }
  ''';
  return Query(options: QueryOptions(document: gql(query)), ...);
}

// 正确的做法——仅在程序启动时解析一次查询字符串,之后在多次重建中重复使用该字符串
const String myQuery = r'''
  query { ... }
''';

Widget build(BuildContext context) {
  return Query(options: QueryOptions(document: gql(myQuery)), ...);
}

对所有请求都使用networkOnly设置

有些开发者因为遇到过缓存失效的问题,所以将所有的查询请求都设置为networkOnly。虽然这样可以避免缓存数据失效的问题,但也会带来其他负面后果:性能下降(无法立即获取缓存数据)、数据消耗量增加、电池电量消耗更快,以及离线使用时会出现各种错误提示,而无法显示之前加载的内容。

正确的做法是根据数据的重要性和时效性来为不同的查询请求选择合适的获取策略,而不是对所有请求都统一应用相同的设置。

忘记取消订阅

Subscription组件会自动管理其WebSocket连接:当该组件被添加到界面中时,连接会被建立;而当组件从界面上移除时,连接也会被关闭。

但是,如果你在Bloc或任何长期存在的对象内部直接使用客户端的subscribe()方法,那么你将会得到一个需要自己管理的Stream对象。那些从未被取消的订阅会随着每次导航操作而默默地导致内存泄漏问题逐渐加剧:

class PostBloc extends Bloc〈PostEvent, PostState〉 {
  StreamSubscription? _commentSubscription;

  void startListeningToComments(String postId) {
    _commentSubscription = _repository
        .watchNewComments(postId)
        .listen((comment) => add(CommentReceived(comment)));
  }

  @override
  Future〈void〉 close() {
    _commentSubscription?.cancel(); // 在关闭之前一定要取消订阅
    return super.close();
  }
}

未正确处理部分GraphQL查询结果

GraphQL响应中可能同时包含dataerrors信息。这时得到的就是部分查询结果:有些查询操作成功了,而有些则失败了。如果你只检查result.hasException这个属性,那么你可能会忽略那些伴随着成功返回的数据出现的GraphQL错误。

因此,你必须同时检查result.dataresult.exception,并明确规定在各种情况下用户界面应该如何响应这些数据。

小型端到端示例

让我们构建一个可运行的完整应用程序,以便将所有相关内容放在具体的上下文中进行演示。我们将使用GitHub的GraphQL API,因此你无需自行搭建服务器就可以立即运行这个示例程序。该应用程序会获取当前已登录用户的仓库信息,并允许用户对这些仓库添加星标或取消星标,同时还会通过同一个代码库来展示查询操作、变更操作以及相应的UI响应效果。

在运行示例之前,请先前往https://github.com/settings/tokens生成一个个人访问令牌,确保该令牌至少具有read:userrepo权限。

GraphQL客户端

// lib graphql/client.dart

import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphqlflutter.dart';

// 在生产环境中切勿硬编码访问令牌,应使用flutter_secure_storage或其他安全机制来存储令牌。
const _githubToken = 'YOUR_GITHUB_TOKEN_HERE';

ValueNotifier〈GraphQLClient〉 buildGitHubClient() {
  final httpLink = HttpLink('https://api.github.comgraphql');

  final authLink = AuthLink(
    getToken: () => 'Bearer $_githubToken',
  );

  return ValueNotifier(
    GraphQLClient(
      link: authLink.concat(httpLink),
      cache:GraphQLCache(store: HiveStore()),
    ),
  );
}

这个文件用于配置一个GraphQL客户端,我们的Flutter应用程序将会使用这个客户端来与GitHub的GraphQL API进行交互。

它会建立与 https://api.github.comGraphQL 的 HTTP 连接,然后使用你的 GitHub 令牌添加身份验证层,因此每个请求都会包含一个 Bearer 令牌。

这两部分结合在一起,使得请求既能通过身份验证,又能正确地发送到 GitHub。

最后,它还会利用 GraphQLCacheHiveStore 实现缓存功能,这样数据就可以被存储在本地并重复使用,而无需每次都从网络中获取。

简单来说:它将我们的应用程序与 GitHub 连接起来,添加登录令牌,并通过本地缓存来提升性能。

查询操作

// libgraphql/queries/repo_queries.dart

const String fetchViewerReposQuery = r'''
  query FetchViewerRepos($count: Int!) {
    viewer {
      login
      name
      avatarUrl
      repositories(
        first: $count
        orderBy: { field: STARGAZERS, direction: DESC }
        ownerAffiliations: [OWNER]
      ) {
        nodes {
          id
          name
          description
          stargazerCount
          primaryLanguage {
            name
            color
          }
          viewerHasStarred
        }
      }
    }
  }
''';

这个文件定义了一个 GraphQL 查询,用于从 GitHub 获取当前登录用户及其仓库的相关信息。

该查询的名称为 FetchViewerRepos,它接受一个参数 $count,用于指定返回多少个仓库。

首先,它会获取代表当前登录用户的 viewer 对象,然后从中提取基本的个人资料信息,如 loginnameavatarUrl

接着,它会获取该用户的 repositories,并且查询结果会受到 $count 参数的限制。这些仓库会按照获得星标的数量降序排列,而且只有用户自己是这些仓库的所有者时,这些仓库才会被包含在查询结果中。

对于每一个仓库,它会请求以下信息:

  • id(用于识别和缓存),
  • name,
  • description,
  • stargazerCount(获得的星标数量),
  • primaryLanguage(包括其名称和颜色),
  • viewerHasStarred(当前用户是否给该仓库点了星标)。

简单来说,这个查询就是在请求:“请提供当前登录用户的个人资料信息以及他们最受欢迎的几个仓库的列表,并为每个仓库提供关键详细信息。”

变更操作

// libgraphql/mutations/repo_mutations.dart

const String addStarMutation = r'''
  mutation AddStar($repoId: ID!) {
    addStar(input: { starrableId: $repoId }) {
      starrable {
        ... on Repository {
          id
          stargazerCount
          viewerHasStarred
        }
      }
    }
  }
''';

const String removeStarMutation = r'''
  mutation RemoveStar($repoId: ID!) {
    removeStar(input: { starrableId: $repoId }) {
      starrable {
        ... on Repository {
          id
          stargazerCount
          viewerHasStarred
        }
      }
    }
  }
''';

该文件定义了两种GraphQL mutation,它们允许我们的应用程序在GitHub上为仓库添加星标或移除星标。

第一种mutation是addStarMutation,它的作用是为仓库添加星标。该mutation接受一个名为$repoId的变量,这个变量代表仓库的唯一ID。执行该mutation时,会使用这个ID调用addStar函数。响应返回的内容包括更新后的仓库信息,具体来说包括:

  • 仓库的id

  • 更新后的stargazerCount(即星标的数量),

  • 以及viewerHasStarred(添加星标后该值为true)。

第二种mutation是removeStarMutation,它的作用与前者相反,即从仓库中移除星标,同样使用$repoId作为参数。调用removeStar函数后,响应也会返回以下信息:

  • 仓库的id

  • 更新后的stargazerCount

  • 以及viewerHasStarred(移除星标后该值为false)。

这两种mutation都使用了GraphQL中的inline fragments技术(例如... on Repository),以确保返回的数据被正确地识别为Repository类型。

简单来说:一种mutation用于添加星标,另一种用于移除星标,但它们都会返回仓库状态的变化信息,这样用户的界面就能立即更新显示这些变化。

入口点

// lib/main.dart

import 'package:flutter/material.dart';
import 'package:graphql_flutter graphqlflutter.dart';
import 'graphql/client.dart';
import 'screens/repos/screen.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await initHiveForFlutter();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return GraphQLProvider(
      client: buildGitHubClient(),
      child: MaterialApp(
        title: 'GitHub Repos',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
          useMaterial3: true,
        ),
        home: const ReposScreen(),
      ),
    );
  }
}

这就是我们Flutter应用的入口点,它将所有组件连接在一起。

main()函数首先通过Widgets FlutterBinding.ensureInitialized()确保Flutter环境已经初始化,这是进行任何异步操作之前的必要步骤。随后它会调用initHiveForFlutter()来准备本地存储空间,因为我们的GraphQL客户端会使用Hive来进行缓存操作。最后,通过runApp()启动应用程序。

MyApp组件负责构建整个应用的结构。其中最重要的部分是GraphQLProvider,它将我们创建的GraphQL客户端(通过buildGitHubClient()获得)注入到整个应用程序的组件树中。这样一来,应用中的任何组件都可以直接使用GraphQL进行查询或操作,而无需手动传递客户端对象。

在 `GraphQLProvider` 中,你定义了一个带有基本应用设置的 `MaterialApp`,这些设置包括标题、主题以及是否禁用调试提示栏。首页被设置为 `ReposScreen`,这意味着当应用程序启动时,用户看到的第一个页面就是这个屏幕。

Repos Screen

// lib/screens/repos_screen.dart

import 'package:flutter/material.dart';
import 'package:graphql_flutter graphqlflutter.dart';
import '../graphql/queries/repo_queries.dart';
import '../graphql/mutations/repo_mutations.dart';

class ReposScreen extends StatelessWidget {
  const ReposScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Query(
      options: QueryOptions(
        document: gql-fetchViewerReposQuery),
        variables: const {'count': 15},
        fetchPolicy: FetchPolicy.cacheAndNetwork,
      ),
      builder: (QueryResult result,
          {VoidCallback? refetch, FetchMore? fetchMore}) {
        if (result isLoading && result.data == null) {
          return const Scaffold(
            body: Center(child: CircularProgressIndicator()),
          );
        }

        if (result.hasException) {
          return Scaffold(
            body: Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  const Icon(Icons.error_outline,
                      size: 48, color: Colors.red),
                  const SizedBox(height: 12),
                  Text(
                    result.exception?.graphqlErrors.firstOrNull?.message
                        ?? '发生错误',
                    textAlign: TextAlign.center,
                  ),
                  const SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: refetch,
                    child: const Text('重试'),
                  ),
                ],
              ),
            ),
          );
        }

        final viewer =
            result.data?['viewer'] as Map?;
        final repos =
            (viewer?['repositories']?['nodes'] as List(dynamic>)?? null
                    ?.cast>() ?? [];

        return Scaffold(
          appBar: AppBar(
            title: Row(
              children: [
                if (viewer?['avatarUrl'] != null)
                  CircleAvatar(
                    backgroundImage:
                        NetworkImage(viewer!['avatarUrl'] as String),
                    radius: 16,
                  ),
                const SizedBox(width: 8),
                Text(viewer?['name'] as String?? ?? viewer?['login'] as String?? ?? ""),
              ],
            ),
            // 用于指示背景数据正在更新中
            bottom: result isLoading
                ? const PreferredSize(
                    preferredSize: Size.fromHeight(2),
                    child: LinearProgressIndicator(),
                  )
                : null,
          ),
          body: RefreshIndicator(
            onRefresh: () async => refetch?.call(),
            child: ListView.separated(
              padding: const EdgeInsets.all(16),
              itemCount: repos.length,
              separatorBuilder: (_, __) => const SizedBox(height: 8),
              itemBuilder: (context, index) => {
                  RepoCard(repo: repos[index]);
            ),
          ),
        );
      },
    );
  }
}

class RepoCard extends StatelessWidget {
  final Map repo;

  const RepoCard({super.key, required this.repo});

  @override
  Widget build(BuildContext context) {
    final language =
        repo['primaryLanguage'] as Map?;
    final isStarred = repo['viewerHasStarred'] as bool?? false;
    final starCount = repo['stargazerCount'] as int?? 0;
    final repoId = repo['id'] as String;
    final mutationDoc = isStarred ? removeStarMutation : addStarMutation;
    final mutationKey = isStarred ? 'removeStar' : 'addStar';

    return Mutation(
      options: MutationOptions(
        document: gql(mutationDoc),
        // 该mutation操作会返回id、stargazerCount以及viewerHasStarded字段。
        // 系统会使用缓存机制根据id来更新相关数据,并将变化通知给所有存储有该仓库信息的组件。
        onError: (error) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text(
                error?.graphqlErrors.firstOrNull?.message
                    ?? '操作失败',
              ),
            ),
          );
        },
      ),
      builder: (RunMutation runMutation, QueryResult? mutationResult) {
        final isMutating = mutationResult?.isLoading?? false;

        // 优先使用mutation结果中的数据,这样UI就能显示最新的状态。
        final starrable =
            (mutationResult?.data?[mutationKey] as Map?)?[
                'starrable'] as Map?;

        final currentStarred =
            starrable?['viewerHasStarred'] as bool?? isStarred;
        final currentCount =
            starrable?['stargazerCount'] as int?? starCount;

        return Card(
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  children: [
                    Expanded(
                      child: Text(
                        repo['name'] as String?? '',
                        style: Theme.of(context)
                            .textTheme
                            .titleMedium
                            ?.copyWith.fontWeight: FontWeight.bold),
                      ),
                    ),
                    isMutating
                        ? const SizedBox(
                            width: 24,
                            height: 24,
                            child: CircularProgressIndicator(
                                strokeWidth: 2),
                          )
                        : IconButton(
                            onPressed: () => runMutation(
                              {'repoId': repoId},
                              // 如果是乐观更新,就会在服务器响应之前立即更新UI。
                              optimisticResult: {
                                mutationKey: {
                                  'starrable': {
                                    '__typename': 'Repository',
                                    'id': repoId,
                                    'stargazerCount': isStarred
                                        ? starCount - 1
                                        : starCount + 1,
                                    'viewerHasStarred': !isStarred,
                                  }
                                }
                              },
                            ),
                            icon: Icon(
                              currentStarred
                                  ? Icons.star
                                  : Icons.star_border,
                              color: currentStarred
                                  ? Colors.amber
                                  : Colors.grey,
                            ),
                            tooltip:
                                currentStarred ? '取消星标' : '添加星标',
                          ),
                  ],
                ),
                if (repo['description'] != null)
                  Padding(
                    padding: const EdgeInsets.only(top: 4),
                    child: Text(
                      repo['description'] as String,
                      style: Theme.of(context).textTheme.bodySmall,
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                    ),
                  ),
                const SizedBox(height: 12),
                Row(
                  children: [
                    if (language != null) ...[
                      Container(
                        width: 12,
                        height: 12,
                        decoration: BoxDecoration(
                          shape: BoxShape.circle,
                          color: _parseColor(
                              language['color'] as String?),
                        ),
                      ),
                      const SizedBox(width: 4),
                      Text(
                        language['name'] as String?? ?? '',
                        style: Theme.of(context).textTheme.bodySmall,
                      ),
                      const SizedBox(width: 16),
                    ],
                    const IconIcons.star, size: 14, color: Colors.amber),
                    const SizedBox(width: 4,
                      child: Text(_formatCount(currentCount),
                      style: Theme.of(context).styleType.bodySmall),
                    ),
                  ],
                ),
              ],
            ),
          ),
        );
      },
    );
  }

  Color _parseColor(String? hex) {
    if (hex == null) return Colors.grey;
    final hexValue = hex.replaceFirst('#', '');
    return Color(int.parse('FF$hexValue', radix: 16));
  }

  String _formatCount(int count) {
    if (count >= 1000) return `${(count / 1000).toStringAsFixed(1)}k';
    return count.toString();
  }
}

这段代码使用graphql_flutter在Flutter中构建了一个类似GitHub的仓库界面,它严重依赖GraphQL查询、变更操作以及缓存机制来确保用户界面与远程数据保持同步。
在顶层结构中,ReposScreen组件通过graphqlflutter提供的Query组件从GraphQL接口获取数据。该查询(fetchViewerReposQuery)会请求当前用户及其拥有的仓库列表,并通过参数count: 15来限制返回的仓库数量。其获取数据的策略为cacheAndNetwork,这意味着系统会先尝试显示缓存中的数据,然后再从网络中获取最新数据并进行更新。
当查询结果还在加载中且没有缓存数据时,界面会显示一个加载指示器;如果出现错误,则会显示错误信息并提供一个重试按钮,点击该按钮会重新执行查询操作。
一旦数据获取完成,程序会从响应结果中提取用户信息及仓库列表,然后使用Scaffold结构来呈现界面:AppBar上会显示用户的头像和名称,而ListView则会用RepoCard组件展示每个仓库的详细信息。
每个RepoCard代表一个仓库,其UI逻辑被封装在Mutation组件中。这个组件负责处理将某个仓库添加到“星标列表”中或从列表中移除的操作。根据该仓库是否已被标记为星标(viewerHasStarred),系统会自动选择相应的变更操作。
当用户点击星标按钮时,runMutation函数会被调用,并传入对应的仓库ID。同时,系统会使用optimisticResult机制来确保UI在服务器响应之前立即得到更新,因此星标的数量和图标会立刻发生变化,从而提供流畅的使用体验。
此外,这个变更操作还定义了一个onError处理函数,用于在操作过程中出现错误时显示提示信息。
Mutation组件的实现中,如果变更操作的结果已经可用,系统会优先使用这些数据来更新UI,而不是原始的查询结果。这样就能确保无论变更操作是否成功完成,UI都能反映最新的状态。
每个仓库卡片上会显示仓库名称、可选描述、主要使用的语言(用彩色圆点标出)以及星标数量。对于较大的星标数量,系统会采用“1.2k”这样的格式来显示。
在变更操作进行期间,星标按钮上也会显示加载指示器,这样用户就能知道系统正在处理相关请求。
总之,这段代码的核心思想在于:GraphQL的规范化缓存机制在幕后发挥了重要作用。当某个仓库的信息发生变动时,缓存会自动更新所有依赖于该仓库ID的UI元素,从而确保所有内容始终保持一致,而无需手动刷新整个列表。

这个功能完备、可运行的应用程序通过一个统一的代码库展示了所有主要的开发概念:

  • 使用AuthLinkHiveStore进行客户端配置;

  • 包含Query组件,该组件能够正确显示加载状态、错误信息以及数据内容,并提供拉取刷新功能及后台刷新提示机制;

  • 在每个列表项中都嵌入了Mutation组件,这种乐观主义的UI设计使得添加星标操作的感觉非常即时;

  • 当星标操作完成时,标准化的缓存机制会自动将更新内容应用到整个列表中。

结论

GraphQL并非仅仅是一种不同的API编写方式,它代表了一种关于服务器与客户端之间交互关系的全新理念。

从服务器驱动的数据获取模式转向客户端驱动的模式会带来切实可测的益处:消耗的带宽更少、网络请求次数减少、页面加载速度更快,同时前端开发团队也能在无需等待后端更新的情况下自由构建所需的用户界面。

对于Flutter开发者来说,这些优势在移动环境中会更加显著。每节省一个字节的数据传输量,就意味着减少了用户的延迟;每次避免了一次网络请求,也就等于延长了电池的使用时间;每一次通过缓存直接获取数据,都能有效节省电量。

这些都不是理论上的改进,它们会体现在应用程序的实际性能指标中、在连接不良时的崩溃率上,也会体现在用户对应用速度的反馈中。

graphql_flutter包将GraphQL与Flutter的响应式、基于组件树的架构完美结合在一起。QueryMutationSubscription等组件能够自然地融入Flutter应用程序的开发流程。标准化的缓存机制、可组合的链接系统以及乐观主义的UI设计,为构建复杂的生产环境级应用提供了必要的基础,而不仅仅是简单的示例。

只有首先理解问题所在,才能让其他所有环节都顺利进行。只有当你亲身经历过数据过度获取带来的麻烦以及N+1请求问题时,才会真正理解GraphQL的设计理念。

将数据模式视为客观存在的标准,而不是仅仅将其作为参考文档来浏览,这样才能建立起一个有效的开发反馈机制,在错误进入生产环境之前就将其发现并解决。采用标准化缓存机制,而非采取一刀切的仅依赖网络请求的策略,才能实现那种响应迅速、流畅自然的用户体验,这样的应用才能真正脱颖而出。而通过使用清晰的代码结构和管理状态的方法,可以使系统随着产品和团队的发展而持续保持可维护性。

对于某些项目来说,GraphQL并不是最佳选择。简单的应用程序、时间安排紧张的小型团队,以及那些数据量较大的工作流程,都是继续使用REST技术的合理理由。但对于那些需要处理大量数据、具有复杂的实体关系、多种屏幕类型并且有实时响应需求的项目而言,GraphQL无疑是一个非常强大的工具。

借助本手册所建立的基础,你现在拥有了做出自信判断所需的一切知识,也能够在GraphQL被纳入你的技术栈后正确地使用它。

参考资料

官方包文档

GraphQL语言与规范

  • GraphQL官方规范: 由GraphQL基金会维护的正式语言规范。https://spec.graphql.org/

  • GraphQL.org学习资源: 由GraphQL基金会编写和维护的官方入门文档,介绍了GraphQL的相关概念。https://graphql.org/learn/

  • 《GraphQL:用于API的查询语言》: Meta公司发布的关于GraphQL的技术介绍文章,解释了其设计目标、解决的问题以及基本理念。https://graphql.org/blog graphql-a-query-language/

工具与生态系统

  • graphql_codegen: 专为graphql_flutter开发的代码生成工具,能够直接根据您的.graphql模式文件生成类型安全的钩子函数及相关类。https://pub.dev/packagesGraphQLCodeGen

  • Altair GraphQL Client: 一款功能强大的桌面端及浏览器端GraphQL集成开发环境,可帮助您交互式地探索和测试API。https://altair.sirmuel/design/

  • hive_ce: 《graphqlflutter》所使用的Hive社区版软件包,用于实现持久化的磁盘缓存功能。https://pub.dev/packages/hive_ce

学习资源

  • How to GraphQL: 一个全面的免费教程平台,涵盖了GraphQL的基础知识到高级主题,并提供了多种语言和运行环境下的示例。https://www.howtographql.com/

  • GitHub GraphQL API Explorer: 专为GitHub API设计的浏览器内GraphQL集成开发环境,无需搭建自己的服务器即可练习针对真实生产环境的查询和操作。https://docs.github.com/en graphql/overview/explorer

  • GitHub GraphQL API Documentation: 关于GitHub GraphQL API中所有类型、查询、操作及订阅功能的完整参考资料,本手册中的示例均基于这些文档编写。https://docs.github.com/en graphql

本手册是为以下版本编写的: graphqlflutter: ^5.3.0,同时配合Flutter 3.x和Dart 3.x使用。早期或后期版本的API细节可能有所不同,请始终参考官方文档以获取最新信息。

Comments are closed.