如何使用Flutter、Fauna和GraphQL构建全栈移动应用程序

Avatar of Shadid Haque
Shadid Haque

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

Flutter是Google的UI框架,用于创建灵活、富有表现力的跨平台移动应用程序。它是增长最快的移动应用程序开发框架之一。另一方面,Fauna是一个面向开发人员的、支持原生GraphQL的事务性无服务器数据库。Flutter + Fauna是天作之合。如果您希望在创纪录的时间内构建和发布功能丰富的全栈应用程序,Flutter和Fauna是正确的工具。在本文中,我们将引导您使用Fauna和GraphQL后端构建您的第一个Flutter应用程序。

您可以在GitHub上找到本文的完整代码,点击此处查看

学习目标

在阅读完本文后,您应该了解如何

  1. 设置Fauna实例,
  2. 为Fauna编写GraphQL模式,
  3. 在Flutter应用程序中设置GraphQL客户端,以及
  4. 对Fauna GraphQL后端执行查询和变异。

FaunaAWS AmplifyFirebase:Fauna解决了哪些问题?它与其他无服务器解决方案有何不同?如果您是Fauna的新手,并且想了解更多关于Fauna与其他解决方案的比较信息,我建议您阅读这篇文章。

我们正在构建什么?

我们将构建一个简单的移动应用程序,允许用户添加、删除和更新他们最喜欢的电影和电视剧角色。

设置Fauna

访问fauna.com并创建一个新帐户。登录后,您应该能够创建一个新的数据库。

为您的数据库命名。我将我的数据库命名为flutter_demo。接下来,我们可以选择一个区域组。在本演示中,我们将选择经典。Fauna是一个全球分布的无服务器数据库。它是唯一一个支持从任何位置进行低延迟读写访问的数据库。可以把它想象成CDN(内容分发网络),但它是针对您的数据库的。要了解有关区域组的更多信息,请遵循此指南

生成管理员密钥

数据库创建完成后,转到安全选项卡。点击“新建密钥”按钮,为您的数据库创建一个新的密钥。请妥善保管此密钥,因为我们将在GraphQL操作中使用它。

我们将为我们的数据库创建一个管理员密钥。具有管理员角色的密钥用于管理其关联的数据库,包括数据库访问提供程序、子数据库、文档、函数、索引、密钥、令牌和用户定义的角色。您可以在以下链接中了解有关Fauna的各种安全密钥和访问角色的更多信息。

编写GraphQL模式

我们将构建一个简单的应用程序,允许用户添加、更新和删除他们最喜欢的电视剧角色。

创建新的Flutter项目

让我们通过运行以下命令创建一个新的Flutter项目。

flutter create my_app

在项目目录中,我们将创建一个名为graphql/schema.graphql的新文件。

在模式文件中,我们将定义集合的结构。Fauna中的集合类似于SQL中的表。目前我们只需要一个集合。我们将它命名为Character

### schema.graphql
type Character {
    name: String!
    description: String!
    picture: String
}
type Query {
    listAllCharacters: [Character]
}

如上所示,我们定义了一个名为Character的类型,它具有多个属性(例如,namedescriptionpicture等)。可以将属性视为SQL数据库的列或NoSQL数据库的键值对。我们还定义了一个查询。此查询将返回角色列表。

现在让我们回到Fauna仪表盘。点击GraphQL,然后点击“导入模式”将我们的模式上传到Fauna。

导入完成后,我们将看到Fauna生成了GraphQL查询和变异。

不喜欢自动生成的GraphQL?想要更多地控制业务逻辑?在这种情况下,Fauna允许您定义自定义GraphQL解析器。要了解更多信息,请遵循此链接

在Flutter应用程序中设置GraphQL客户端

让我们打开pubspec.yaml文件并添加所需的依赖项。

...
dependencies:
  graphql_flutter: ^4.0.0-beta
  hive: ^1.3.0
  flutter:
    sdk: flutter
...

我们在这里添加了两个依赖项。graphql_flutter是Flutter的GraphQL客户端库。它将GraphQL客户端的所有现代功能整合到一个易于使用的包中。我们还将hive包添加为我们的依赖项。Hive是一个用纯Dart编写的轻量级键值数据库,用于本地存储。我们使用hive来缓存我们的GraphQL查询。

接下来,我们将创建一个名为lib/client_provider.dart的新文件。我们将在该文件中创建一个提供程序类,其中将包含我们的Fauna配置。

要连接到Fauna的GraphQL API,我们首先需要创建一个GraphQLClient。GraphQLClient需要一个缓存和一个链接才能初始化。让我们看一下下面的代码。

// lib/client_provider.dart
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:flutter/material.dart';

ValueNotifier<GraphQLClient> clientFor({
  @required String uri,
  String subscriptionUri,
}) {

  final HttpLink httpLink = HttpLink(
    uri,
  );
  final AuthLink authLink = AuthLink(
    getToken: () async => 'Bearer fnAEPAjy8QACRJssawcwuywad2DbB6ssrsgZ2-2',
  );
  Link link = authLink.concat(httpLink);
  return ValueNotifier<GraphQLClient>(
    GraphQLClient(
      cache: GraphQLCache(store: HiveStore()),
      link: link,
    ),
  );
} 

在上面的代码中,我们创建了一个ValueNotifier来包装GraphQLClient。请注意,我们在第13-15行(突出显示)配置了AuthLink。在第14行,我们将Fauna的管理员密钥添加为令牌的一部分。在这里,我硬编码了管理员密钥。但是,在生产应用程序中,我们必须避免硬编码Fauna的任何安全密钥。

有多种方法可以在Flutter应用程序中存储机密信息。请参考这篇博文

我们希望能够从应用程序的任何小部件调用QueryMutation。为此,我们需要使用GraphQLProvider小部件包装我们的部件。

// lib/client_provider.dart

....

/// Wraps the root application with the `graphql_flutter` client.
/// We use the cache for all state management.
class ClientProvider extends StatelessWidget {
  ClientProvider({
    @required this.child,
    @required String uri,
  }) : client = clientFor(
          uri: uri,
        );
  final Widget child;
  final ValueNotifier<GraphQLClient> client;
  @override
  Widget build(BuildContext context) {
    return GraphQLProvider(
      client: client,
      child: child,
    );
  }
}

接下来,我们转到main.dart文件,并使用ClientProvider小部件包装我们的主部件。让我们看一下下面的代码。

// lib/main.dart
...

void main() async {
  await initHiveForFlutter();
  runApp(MyApp());
}
final graphqlEndpoint = 'https://graphql.fauna.com/graphql';
class MyApp extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return ClientProvider(
      uri: graphqlEndpoint,
      child: MaterialApp(
        title: 'My Character App',
        debugShowCheckedModeBanner: false,
        initialRoute: '/',
        routes: {
          '/': (_) => AllCharacters(),
          '/new': (_) => NewCharacter(),
        }
      ),
    );
  }
}

此时,我们所有的下游部件都将能够访问运行QueriesMutations函数,并可以与GraphQL API交互。

应用程序页面

演示应用程序应该简单易懂。让我们继续创建一个简单列表部件,它将显示所有角色的列表。让我们创建一个名为lib/screens/character-list.dart的新文件。在这个文件中,我们将编写一个名为AllCharacters的新部件。

// lib/screens/character-list.dart.dart

class AllCharacters extends StatelessWidget {
  const AllCharacters({Key key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          SliverAppBar(
            pinned: true,
            snap: false,
            floating: true,
            expandedHeight: 160.0,
            title: Text(
              'Characters',
              style: TextStyle(
                fontWeight: FontWeight.w400, 
                fontSize: 36,
              ),
            ),
            actions: <Widget>[
              IconButton(
                padding: EdgeInsets.all(5),
                icon: const Icon(Icons.add_circle),
                tooltip: 'Add new entry',
                onPressed: () { 
                  Navigator.pushNamed(context, '/new');
                },
              ),
            ],
          ),
          SliverList(
            delegate: SliverChildListDelegate([
                Column(
                  children: [
                    for (var i = 0; i < 10; i++) 
                      CharacterTile()
                  ],
                )
            ])
          )
        ],
      ),
    );
  }
}

// Character-tile.dart
class CharacterTile extends StatefulWidget {
  CharacterTilee({Key key}) : super(key: key);
  @override
  _CharacterTileState createState() => _CharacterTileeState();
}
class _CharacterTileState extends State<CharacterTile> {
  @override
  Widget build(BuildContext context) {
    return Container(
       child: Text(&quot;Character Tile&quot;),
    );
  }
}

如您在上面的代码中看到的,[第37行]我们有一个for循环,用一些假数据填充列表。最终,我们将向Fauna后端发出GraphQL查询,并从数据库中获取所有角色。在这样做之前,让我们尝试运行我们的应用程序。我们可以使用以下命令运行我们的应用程序

flutter run

此时,我们应该能够看到以下屏幕。

执行查询和变异

现在我们已经有一些基本的组件了,我们可以继续连接 GraphQL 查询。我们希望获取数据库中的所有角色并在AllCharacters组件中显示它们,而不是使用硬编码字符串。

让我们回到 Fauna 的 GraphQL playground。请注意,我们可以运行以下查询来列出所有角色。

query ListAllCharacters {
  listAllCharacters(_size: 100) {
    data {
      _id
      name
      description
      picture
    }
    after
  }
}

要从我们的组件执行此查询,我们需要对其进行一些更改。

import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:todo_app/screens/Character-tile.dart';

String readCharacters = ";";";
query ListAllCharacters {
  listAllCharacters(_size: 100) {
    data {
      _id
      name
      description
      picture
    }
    after
  }
}
";";";;

class AllCharacters extends StatelessWidget {
  const AllCharacters({Key key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          SliverAppBar(
            pinned: true,
            snap: false,
            floating: true,
            expandedHeight: 160.0,
            title: Text(
              'Characters',
              style: TextStyle(
                fontWeight: FontWeight.w400, 
                fontSize: 36,
              ),
            ),
            actions: <Widget>[
              IconButton(
                padding: EdgeInsets.all(5),
                icon: const Icon(Icons.add_circle),
                tooltip: 'Add new entry',
                onPressed: () { 
                  Navigator.pushNamed(context, '/new');
                },
              ),
            ],
          ),
          SliverList(
            delegate: SliverChildListDelegate([
              Query(options: QueryOptions(
                document: gql(readCharacters), // graphql query we want to perform
                pollInterval: Duration(seconds: 120), // refetch interval
              ), 
              builder: (QueryResult result, { VoidCallback refetch, FetchMore fetchMore }) {
                if (result.isLoading) {
                  return Text('Loading');
                }
                return Column(
                  children: [
                    for (var item in result.data\['listAllCharacters'\]['data'])
                      CharacterTile(Character: item, refetch: refetch),
                  ],
                );
              })
            ])
          )
        ],
      ),
    );
  }
} 

首先,我们定义了从数据库获取所有角色的查询字符串(第 5 行到第 17 行)。我们已经使用flutter_graphql中的 Query 组件包装了我们的列表组件。

随意查看flutter_graphql 库的官方文档。

在 query options 参数中,我们提供了 GraphQL 查询字符串本身。我们可以为 pollInterval 参数传递任何浮点数。轮询间隔定义了我们希望多久从后端重新获取一次数据。该组件还具有一个标准的 builder 函数。我们可以使用 builder 函数将查询结果、重新获取回调函数和获取更多回调函数传递到组件树中。

接下来,我将更新CharacterTile组件以在屏幕上显示角色数据。

// lib/screens/character-tile.dart
...
class CharacterTile extends StatelessWidget {
  final Character;
  final VoidCallback refetch;
  final VoidCallback updateParent;
  const CharacterTile({
    Key key, 
    @required this.Character, 
    @required this.refetch,
    this.updateParent,
  }) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: () {
      },
      child: Padding(
        padding: const EdgeInsets.all(10),
        child: Row(
          children: [
            Container(
              height: 90,
              width: 90,
              decoration: BoxDecoration(
                color: Colors.amber,
                borderRadius: BorderRadius.circular(15),
                image: DecorationImage(
                  fit: BoxFit.cover,
                  image: NetworkImage(Character['picture'])
                )
              ),
            ),
            SizedBox(width: 10),
            Expanded(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    Character['name'],
                    style: TextStyle(
                      color: Colors.black87,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  SizedBox(height: 5),
                  Text(
                    Character['description'],
                    style: TextStyle(
                      color: Colors.black87,
                    ),
                    maxLines: 2,
                  ),
                ],
              )
            )
          ],
        ),
      ),
    );
  }
}

添加新数据

我们可以通过运行以下变异来向我们的数据库添加新角色。

mutation CreateNewCharacter($data: CharacterInput!) {
    createCharacter(data: $data) {
      _id
      name
      description
      picture
    }
}

要从我们的组件运行此变异,我们可以使用flutter_graphql库中的Mutation组件。让我们创建一个带有简单表单的新组件,供用户交互并输入数据。表单提交后,将调用createCharacter变异。

// lib/screens/new.dart
...
String addCharacter = ";";";
  mutation CreateNewCharacter(\$data: CharacterInput!) {
    createCharacter(data: \$data) {
      _id
      name
      description
      picture
    }
  }
";";";;
class NewCharacter extends StatelessWidget {
  const NewCharacter({Key key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Add New Character'),
      ),
      body: AddCharacterForm()
    );
  }
}
class AddCharacterForm extends StatefulWidget {
  AddCharacterForm({Key key}) : super(key: key);
  @override
  _AddCharacterFormState createState() => _AddCharacterFormState();
}
class _AddCharacterFormState extends State<AddCharacterForm> {
  String name;
  String description;
  String imgUrl;
  @override
  Widget build(BuildContext context) {
    return Form(
      child: Padding(
        padding: EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            TextField(
              decoration: const InputDecoration(
                icon: Icon(Icons.person),
                labelText: 'Name *',
              ),
              onChanged: (text) {
                name = text;
              },
            ),
            TextField(
              decoration: const InputDecoration(
                icon: Icon(Icons.post_add),
                labelText: 'Description',
              ),
              minLines: 4,
              maxLines: 4,
              onChanged: (text) {
                description = text;
              },
            ),
            TextField(
              decoration: const InputDecoration(
                icon: Icon(Icons.image),
                labelText: 'Image Url',
              ),
              onChanged: (text) {
                imgUrl = text;
              },
            ),
            SizedBox(height: 20),
            Mutation(
              options: MutationOptions(
                document: gql(addCharacter),
                onCompleted: (dynamic resultData) {
                  print(resultData);
                  name = '';
                  description = '';
                  imgUrl = '';
                  Navigator.of(context).push(
                    MaterialPageRoute(builder: (context) => AllCharacters())
                  );
                },
              ), 
              builder: (
                RunMutation runMutation,
                QueryResult result,
              ) {
                return Center(
                  child: ElevatedButton(
                    child: const Text('Submit'),
                    onPressed: () {
                      runMutation({
                        'data': {
                          ";picture";: imgUrl,
                          ";name";: name,
                          ";description";: description,
                        }
                      });
                    },
                  ),
                );
              }
            )
          ],
        ),
      ),
    );
  }
}

如您从上面的代码中看到的,Mutation 组件的工作方式与 Query 组件非常相似。此外,Mutation 组件为我们提供了一个 onComplete 函数。此函数在变异完成后返回数据库中更新的结果。

删除数据

要从我们的数据库中删除角色,我们可以运行deleteCharacter变异。我们可以将此变异函数添加到我们的CharacterTile中,并在按下按钮时触发它。

// lib/screens/character-tile.dart
...

String deleteCharacter = ";";";
  mutation DeleteCharacter(\$id: ID!) {
    deleteCharacter(id: \$id) {
      _id
      name
    }
  }
";";";;

class CharacterTile extends StatelessWidget {
  final Character;
  final VoidCallback refetch;
  final VoidCallback updateParent;
  const CharacterTile({
    Key key, 
    @required this.Character, 
    @required this.refetch,
    this.updateParent,
  }) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: () {
        showModalBottomSheet(
          context: context,
          builder: (BuildContext context) {
            print(Character['picture']);
            return Mutation(
              options: MutationOptions(
                document: gql(deleteCharacter),
                onCompleted: (dynamic resultData) {
                  print(resultData);
                  this.refetch();
                },
              ), 
              builder: (
                RunMutation runMutation,
                QueryResult result,
              ) {
                return Container(
                  height: 400,
                  padding: EdgeInsets.all(30),
                  child: Center(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      mainAxisSize: MainAxisSize.min,
                      children: <Widget>[
                        Text(Character['description']),
                        ElevatedButton(
                          child: Text('Delete Character'),
                          onPressed: () {
                            runMutation({
                              'id': Character['_id'],
                            });
                            Navigator.pop(context);
                          },
                        ),
                      ],
                    ),
                  ),
                ); 
              }
            );
          }
        );
      },
      child: Padding(
        padding: const EdgeInsets.all(10),
        child: Row(
          children: [
            Container(
              height: 90,
              width: 90,
              decoration: BoxDecoration(
                color: Colors.amber,
                borderRadius: BorderRadius.circular(15),
                image: DecorationImage(
                  fit: BoxFit.cover,
                  image: NetworkImage(Character['picture'])
                )
              ),
            ),
            SizedBox(width: 10),
            Expanded(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    Character['name'],
                    style: TextStyle(
                      color: Colors.black87,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  SizedBox(height: 5),
                  Text(
                    Character['description'],
                    style: TextStyle(
                      color: Colors.black87,
                    ),
                    maxLines: 2,
                  ),
                ],
              )
            )
          ],
        ),
      ),
    );
  }
}

编辑数据

编辑数据的工作方式与添加和删除相同。它只是 GraphQL API 中的另一个变异。我们可以创建一个类似于新角色表单组件的编辑角色表单组件。唯一的区别是编辑表单将运行updateCharacter变异。为了编辑,我创建了一个新的组件lib/screens/edit.dart。这是此组件的代码。

// lib/screens/edit.dart

String editCharacter = """
mutation EditCharacter(\$name: String!, \$id: ID!, \$description: String!, \$picture: String!) {
  updateCharacter(data: 
  { 
    name: \$name 
    description: \$description
    picture: \$picture
  }, id: \$id) {
    _id
    name
    description
    picture
  }
}
""";
class EditCharacter extends StatelessWidget {
  final Character;
  const EditCharacter({Key key, this.Character}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Edit Character'),
      ),
      body: EditFormBody(Character: this.Character),
    );
  }
}
class EditFormBody extends StatefulWidget {
  final Character;
  EditFormBody({Key key, this.Character}) : super(key: key);
  @override
  _EditFormBodyState createState() => _EditFormBodyState();
}
class _EditFormBodyState extends State<EditFormBody> {
  String name;
  String description;
  String picture;
  @override
  Widget build(BuildContext context) {
    return Container(
       child: Padding(
         padding: const EdgeInsets.all(8.0),
         child: Column(
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
            TextFormField(
               initialValue: widget.Character['name'],
                decoration: const InputDecoration(
                  icon: Icon(Icons.person),
                  labelText: 'Name *',
                ),
                onChanged: (text) {
                  name = text;
                }
            ),
            TextFormField(
              initialValue: widget.Character['description'],
              decoration: const InputDecoration(
                icon: Icon(Icons.person),
                labelText: 'Description',
              ),
              minLines: 4,
              maxLines: 4,
              onChanged: (text) {
                description = text;
              }
            ),
            TextFormField(
              initialValue: widget.Character['picture'],
              decoration: const InputDecoration(
                icon: Icon(Icons.image),
                labelText: 'Image Url',
              ),
              onChanged: (text) {
                picture = text;
              },
            ),
            SizedBox(height: 20),
            Mutation(
              options: MutationOptions(
                document: gql(editCharacter),
                onCompleted: (dynamic resultData) {
                  print(resultData);
                  Navigator.of(context).push(
                    MaterialPageRoute(builder: (context) => AllCharacters())
                  );
                },
              ),
              builder: (
                RunMutation runMutation,
                QueryResult result,
              ) {
                print(result);
                return Center(
                  child: ElevatedButton(
                    child: const Text('Submit'),
                    onPressed: () {

                      runMutation({
                        'id': widget.Character['_id'],
                        'name': name != null ? name : widget.Character['name'],
                        'description': description != null ? description : widget.Character['description'],
                        'picture': picture != null ? picture : widget.Character['picture'],
                      });
                    },
                  ),
                );
              }
            ),
           ]
         )
       ),
    );
  }
}

您可以查看本文下方提供的完整代码

您对 Fauna 或 Flutter 有任何疑问吗?您可以在 Twitter 上联系我@HaqueShadid

下一步去哪里

本文的主要目的是让您开始使用 Flutter 和 Fauna。我们在这里只触及了表面。Fauna 生态系统为您的移动应用程序提供了一个完整的、自动扩展的、对开发人员友好的后端即服务。如果您的目标是在创纪录的时间内发布一个生产就绪的跨平台移动应用程序,请尝试使用Fauna 和 Flutter

我强烈建议您查看 Fauna 的官方文档站点。如果您有兴趣了解有关 Dart/Flutter 的 GraphQL 客户端的更多信息,请查看graphql_flutter的官方GitHub 仓库

祝您编程愉快,下次再见。