mcpField
H
host-your-flutter-project-as-a-rest-api

host-your-flutter-project-as-a-rest-api

github.com/rodydavis开发6043 次安装2 周前更新
代码仓库

关于 host-your-flutter-project-as-a-rest-api

面向 Flutter 和 Dart 开发者的教程,介绍如何通过 REST API 提供 Flutter 项目的模型与业务逻辑。示例展示客户端和服务端共享代码,并使用 Docker 配置部署到 Google Cloud Run。

主要功能

  1. 01共享 Dart 模型与业务逻辑
  2. 02使用 shelf 提供 REST 端点
  3. 03将请求路由到控制器
  4. 04返回格式统一的 JSON 响应
  5. 05准备部署至 Google Cloud Run 的 Docker 配置

适用场景

  1. 01通过 REST API 暴露 Flutter 模型
  2. 02保持客户端与服务端逻辑一致
  3. 03不运行 Flutter widgets 而执行共享 Dart 逻辑

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/rodydavis/skills --skill host-your-flutter-project-as-a-rest-api

安装后在 Claude Code 中输入/host-your-flutter-project-as-a-rest-api确认已加载。

详细介绍

将 Flutter 项目托管为 REST API

构建完 Flutter 项目后,你可能想要复用 lib 文件夹中的模型和业务逻辑。我将介绍如何设置项目,让一个项目同时支持 iOS、Android、Web、Windows、MacOS、Linux 和 REST API 接口。REST API 还可以部署到 Google Cloud Run,让 Dart 无处不在。

README 配图

客户端和服务端使用同一代码库。

这样一来,你就可以将 Dart 模型作为 REST API 暴露出来,并在应用运行模型的同时,执行 lib 文件夹中的业务逻辑。最终项目请见这里。

设置

和任何 Flutter 项目一样,我假设你已在计算机上安装了 Flutter,并且能够创建项目。这属于中等难度,做好挑战准备的话就继续读下去。你还需要了解 Docker 的基础知识。

为什么只用一个项目?

这可能并不明显,但构建复杂应用时,你迟早会需要一个服务端,以及一个调用该服务端的应用。Firebase 是实现这一点的绝佳选择,我几乎在所有项目中都会使用它。Firebase Functions 功能非常强大,但你只能使用 Javascript 或 Typescript。如果你能使用 Flutter 项目中相同的包,甚至让两者使用同一套包,会怎么样?

README 配图

当服务端项目和客户端项目通过 REST API 或 Firebase 之类的客户端 SDK 通信时,你会遇到这样的问题:服务端存储着对象模型,而客户端也存储着对象模型。如果模型在你不知情的情况下发生变化,就可能导致严重的不一致。GraphQL 能提供很大帮助,因为你可以定义要接收的模型。这种做法可以让客户端和服务端的业务逻辑始终保持同步。

客户端设置

第一步是构建应用。唯一的不同之处在于,我们会将 UI 和业务逻辑分开。刚开始使用 Flutter 时,很容易把所有逻辑都塞进屏幕,并在数据变化时调用 setState。创建新的 Flutter 项目时,应用本身也会采用这种做法。这就是为什么选择状态管理方案如此重要。

为了让结构简洁清晰,我们会在 lib 文件夹中创建 2 个文件夹。

  • ui:存放所有 Flutter Widget 和屏幕

  • src:存放所有业务逻辑、类、模型和实用函数

这样,main.dart 就只作为客户端应用的入口。

import 'package:flutter/material.dart';

import 'plugins/desktop/desktop.dart';
import 'ui/home/screen.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData.light(),
      darkTheme: ThemeData.dark(),
      home: HomeScreen(),
    );
  }
}

首先,为 2 个屏幕创建一个标签栏。在 ui/home/screen.dart 文件中添加以下内容:

import 'package:flutter/material.dart';

import '../counter/screen.dart';
import '../todo/screen.dart';

class HomeScreen extends StatefulWidget {
  @override
  _HomeScreenState createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  int _currentIndex = 0;

@override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(
        index: _currentIndex,
        children: <Widget>[
          CounterScreen(),
          TodosScreen(),
        ],
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (val) {
          if (mounted)
            setState(() {
              _currentIndex = val;
            });
        },
        type: BottomNavigationBarType.fixed,
        items: [
          BottomNavigationBarItem(
            icon: Icon(Icons.add),
            title: Text('Counter'),
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.list),
            title: Text('Todos'),
          ),
        ],
      ),
    );
  }
}

这只是一个基础屏幕,看起来应该很常见。

计数器示例

README 配图

现在创建 ui/counter/screen.dart 文件,并添加以下内容:

import 'package:flutter/material.dart';
import 'package:shared_dart/src/models/counter.dart';

class CounterScreen extends StatefulWidget {
  @override
  _CounterScreenState createState() => _CounterScreenState();
}

class _CounterScreenState extends State<CounterScreen> {
  CounterModel _counterModel = CounterModel();

void _incrementCounter() {
    setState(() {
      // This call to setState tells the Flutter framework that something has
      // changed in this State, which causes it to rerun the build method below
      // so that the display can reflect the updated values. If we changed
      // _counter without calling setState(), then the build method would not be
      // called again, and so nothing would appear to happen.
      _counterModel.add();
    });
  }

@override
  Widget build(BuildContext context) {
    // This method is rerun every time setState is called, for instance as done
    // by the _incrementCounter method above.
    //
    // The Flutter framework has been optimized to make rerunning build methods
    // fast, so that you can just rebuild anything that needs updating rather
    // than having to individually change instances of widgets.
    return Scaffold(
      appBar: AppBar(
        // Here we take the value from the MyCounterPage object that was created by
        // the App.build method, and use it to set our appbar title.
        title: Text('Counter Screen'),
      ),
      body: Center(
        // Center is a layout widget. It takes a single child and positions it
        // in the middle of the parent.
        child: Column(
          // Column is also a layout widget. It takes a list of children and
          // arranges them vertically. By default, it sizes itself to fit its
          // children horizontally, and tries to be as tall as its parent.
          //
          // Invoke "debug painting" (press "p" in the console, choose the
          // "Toggle Debug Paint" action from the Flutter Inspector in Android
          // Studio, or the "Toggle Debug Paint" command in Visual Studio Code)
          // to see the wireframe for each widget.
          //
          // Column has various properties to control how it sizes itself and
          // how it positions its children. Here we use mainAxisAlignment to
          // center the children vertically; the main axis here is the vertical
          // axis because Columns are vertical (the cross axis would be
          // horizontal).
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '${_counterModel.count}',
              style: Theme.of(context).textTheme.display1,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

这是创建 Flutter 应用时默认提供的计数器应用,但有一处改动:它使用 CounterModel 来保存逻辑。

在 src/models/counter.dart 中创建计数器模型,并添加以下内容:

class CounterModel {
  CounterModel();

  int _count = 0;

  int get count => _count;

  void add() => _count++;

  void subtract() => _count--;

  void set(int val) => _count = val;
}

如你所见,只暴露我们需要的内容,同时保留完全的灵活性,非常简单。你可以选择在这里使用 provider,也可以使用 bloc 和/或 streams。

Todo 示例

README 配图

我们在 ui/todos/screen.dart 创建一个文件,并添加以下内容:

import 'package:flutter/material.dart';

import '../../src/classes/todo.dart';
import '../../src/models/todos.dart';

class TodosScreen extends StatefulWidget {
  @override
  _TodosScreenState createState() => _TodosScreenState();
}

class _TodosScreenState extends State<TodosScreen> {
  final _model = TodosModel();
  List<ToDo> _todos;

@override
  void initState() {
    _model.getList().then((val) {
      if (mounted)
        setState(() {
          _todos = val;
        });
    });
    super.initState();
  }

@override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Todos Screen'),
      ),
      body: Builder(
        builder: (_) {
          if (_todos != null) {
            return ListView.builder(
              itemCount: _todos.length,
              itemBuilder: (context, index) {
                final _item = _todos[index];
                return ListTile(
                  title: Text(_item.title),
                  subtitle: Text(_item.completed ? 'Completed' : 'Pending'),
                );
              },
            );
          }
          return Center(
            child: CircularProgressIndicator(),
          );
        },
      ),
    );
  }
}

你会看到,逻辑放在 TodosModel 中,并使用 ToDo 类进行 toJson 和 fromJson 操作。

在 src/classes/todo.dart 位置创建一个文件,并添加以下内容:

// To parse this JSON data, do
//
//     final toDo = toDoFromJson(jsonString);

import 'dart:convert';

List<ToDo> toDoFromJson(String str) => List<ToDo>.from(json.decode(str).map((x) => ToDo.fromJson(x)));

String toDoToJson(List<ToDo> data) => json.encode(List<dynamic>.from(data.map((x) => x.toJson())));

class ToDo {
    int userId;
    int id;
    String title;
    bool completed;

ToDo({
        this.userId,
        this.id,
        this.title,
        this.completed,
    });

factory ToDo.fromJson(Map<String, dynamic> json) => ToDo(
        userId: json["userId"],
        id: json["id"],
        title: json["title"],
        completed: json["completed"],
    );

Map<String, dynamic> toJson() => {
        "userId": userId,
        "id": id,
        "title": title,
        "completed": completed,
    };
}

然后创建模型文件 src/models/todo.dart,并添加以下内容:

import 'dart:convert';

import 'package:http/http.dart' as http;
import 'package:shared_dart/src/classes/todo.dart' as t;

class TodosModel {
  final kTodosUrl = '[https://jsonplaceholder.typicode.com/todos'](https://jsonplaceholder.typicode.com/todos');

Future<List<t.ToDo>> getList() async {
    final _response = await http.get(kTodosUrl);
    if (_response != null) {
      final _todos = t.toDoFromJson(_response.body);
      if (_todos != null) {
        return _todos;
      }
    }
    return [];
  }

Future<t.ToDo> getItem(int id) async {
    final _response = await http.get('$kTodosUrl/$id');
    if (_response != null) {
      final _todo = t.ToDo.fromJson(json.decode(_response.body));
      if (_todo != null) {
        return _todo;
      }
    }
    return null;
  }
}

这里,我们从一个会返回 json 的 url 获取虚拟数据,并将其转换为我们的类。这是我想通过网络请求展示的一个示例。获取数据的地方只有一处。

运行项目(Web)

README 配图

README 配图

如你所见,在 Chrome 上运行项目时,你会看到与在移动端上相同的应用。网络请求在 Web 上也能正常工作。你可以调用模型并获取列表,就像预期的那样。

服务端设置

现在来见证奇迹……

在项目根目录中创建一个名为 Dockerfile 的文件,并添加以下内容:

# Use Google's official Dart image.
# [https://hub.docker.com/r/google/dart-runtime/](https://hub.docker.com/r/google/dart-runtime/)
FROM google/dart-runtime

在根目录中再创建一个名为 service.yaml 的文件,并添加以下内容:

apiVersion: serving.knative.dev/v1
    kind: Service
    metadata:
      name: PROJECT_NAME
      namespace: default
    spec:
      template:
        spec:
          containers:
            - image: docker.io/YOUR_DOCKER_NAME/PROJECT_NAME
              env:
                - name: TARGET
                  value: "PROJECT_NAME v1"

将 PROJECT_NAME 替换为你的项目名称。本示例中我使用的是 shared-dart。

你还需要将 YOUR_DOCKER_NAME 替换为你的 Docker 用户名,这样才能正确部署容器。

用以下内容更新你的 pubspec.yaml:

name: shared_dart
description: A new Flutter project.
publish_to: none
version: 1.0.0+1

environment:
  sdk: ">=2.1.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  shelf: ^0.7.3
  cupertino_icons: ^0.1.2
  http: ^0.12.0+2

dev_dependencies:
  flutter_test:
    sdk: flutter

flutter:
  uses-material-design: true

这里的重要依赖包是 shelf,它能让我们使用 dart 运行 HTTP 服务。

在项目根目录中创建一个名为 bin 的文件夹,然后在其中添加一个名为 server.dart 的文件,并将内容替换为以下内容:

import 'dart:io';

import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as io;

import 'src/routing.dart';

void main() {
  final handler = const shelf.Pipeline()
      .addMiddleware(shelf.logRequests())
      .addHandler(RouteUtils.handler);

final port = int.tryParse(Platform.environment['PORT'] ?? '8080');
  final address = InternetAddress.anyIPv4;

io.serve(handler, address, port).then((server) {
    server.autoCompress = true;
    print('Serving at [http://${server.address.host}:${server.port}'](http://${server.address.host}:${server.port}'));
  });
}

这会告诉容器要监听哪个端口,以及如何处理请求。

在 bin 文件夹中创建一个名为 src 的文件夹,并在其中添加一个名为 routing.dart 的文件,将内容替换为以下内容:

import 'dart:async';

import 'package:shelf/shelf.dart' as shelf;

import 'controllers/index.dart';
import 'result.dart';

class RouteUtils {
  static FutureOr<shelf.Response> handler(shelf.Request request) {
    var component = request.url.pathSegments.first;
    var handler = _handlers(request)[component];
    if (handler == null) return shelf.Response.notFound(null);
    return handler;
  }

static Map<String, FutureOr<shelf.Response>> _handlers(
      shelf.Request request) {
    return {
      'info': ServerResponse('Info', body: {
        "version": 'v1.0.0',
        "status": "ok",
      }).ok(),
      'counter': CounterController().result(request),
      'todos': TodoController().result(request),
    };
  }
}

目前还没有导入主项目中的任何内容,不过你会开始发现一些相似之处。这里我们为 todos 和 counter 的 URL 路径指定控制器。

'counter': CounterController().result(request),
'todos': TodoController().result(request),

这意味着,任何符合以下路径的 URL:https://mydomain.com/todos,https://mydomain.com/todos/1

都会被路由到 TodoController 来处理请求。

这是我第一次了解到 FutureOr。它允许你返回同步或异步函数。

构建 REST API 时,一个重要的部分是确保响应正文格式一致。因此,我们可以在这里创建一个包装器,添加一些始终需要返回的字段,例如调用状态、消息和正文。

在 src/result.dart 位置创建一个文件,并添加以下内容:

import 'dart:convert';

import 'package:shelf/shelf.dart' as shelf;

class ServerResponse {
  final String message;
  final dynamic body;
  final StatusType type;

ServerResponse(
    this.message, {
    this.type = StatusType.success,
    this.body,
  });

Map<String, dynamic> toJson() {
    return {
      "status": type.toString().replaceAll('StatusType.', ''),
      "message": message,
      "body": body ?? '',
    };
  }

String toJsonString() {
    return json.encode(toJson());
  }

shelf.Response ok() {
    return shelf.Response.ok(
      toJsonString(),
      headers: {
        'Content-Type': 'application/json',
      },
    );
  }
}

enum StatusType { success, error }

abstract class ResponseImpl {
  Future<shelf.Response> result(shelf.Request request);
}

它始终会返回 json 以及我们希望展示的字段。你也可以在这里包含分页元数据。

在 src/controllers/counter.dart 位置创建一个文件,并添加以下内容:

import 'package:shared_dart/src/models/counter.dart';
import 'package:shelf/shelf.dart' as shelf;

import '../result.dart';

class CounterController implements ResponseImpl {
  const CounterController();

@override
  Future<shelf.Response> result(shelf.Request request) async {
    final _model = CounterModel();
    final _params = request.url.queryParameters;
    if (_params != null) {
      final _val = int.tryParse(_params['count'] ?? '0');
      _model.set(_val);
    } else {
      _model.add();
    }
    return ServerResponse('Info', body: {
      "counter": _model.count,
    }).ok();
  }
}

你会看到这里导入了项目根目录下的 lib 文件夹。由于它们共享 pubspec.yaml,因此可以共用所有依赖包。你可以导入我们之前创建的 CounterModel。

在 src/controllers/todos.dart 位置创建一个文件,并添加以下内容:

import 'package:shared_dart/src/models/todos.dart';
import 'package:shelf/src/request.dart';

import 'package:shelf/src/response.dart';

import '../result.dart';

class TodoController implements ResponseImpl {
  @override
  Future<Response> result(Request request) async {
    final _model = TodosModel();
    if (request.url.pathSegments.length > 1) {
      final _id = int.tryParse(request.url.pathSegments[1] ?? '1');
      final _todo = await _model.getItem(_id);
      return ServerResponse('Todo Item', body: _todo).ok();
    }
    final _todos = await _model.getList();
    return ServerResponse(
      'List Todos',
      body: _todos.map((t) => t.toJson()).toList(),
    ).ok();
  }
}

和之前一样,我们从 lib 文件夹导入 TodosModel 模型。

为方便起见,在 src/controllers/index.dart 位置添加一个文件,并添加以下内容:

export 'counter.dart';
export 'todo.dart';

这样就能更方便地导入所有控制器。

运行项目(服务端)

如果你使用的是 VSCode,则需要使用以下内容更新 launch.json:

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: [https://go.microsoft.com/fwlink/?linkid=830387](https://go.microsoft.com/fwlink/?linkid=830387)
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Client",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart"
        }, 
          {
            "name": "Server",
            "request": "launch",
            "type": "dart",
            "program": "bin/server.dart"
        }
    ]
}

现在,选择 Server 并点击运行,就会看到以下输出:

README 配图

你可以在浏览器中访问,也可以在 Postman 中使用。

README 配图

README 配图

只需在 URL 中添加 todos 和 todos/1,就会返回不同的响应。

对于计数器模型,我们也可以使用查询参数!

README 配图

README 配图

只需添加 ?count=22,就会使用输入值更新模型。

请注意,这是在运行 Flutter 项目 lib 文件夹中的 Dart 代码,无需使用 Flutter widgets!

此外,我们还可以在桌面端运行此项目。查看最终项目,了解 Flutter Desktop Embedding 所需的桌面文件夹。

README 配图

README 配图

结论

现在,如果你想将容器部署到 Cloud Run,可以使用以下命令:

gcloud builds submit — tag gcr.io/YOUR_GOOGLE_PROJECT_ID/PROJECT_NAME .

将 PROJECT_NAME 替换为你的项目名称。本示例中使用的是 shared-dart。

你还需要将 YOUR_GOOGLE_PROJECT_ID 替换为你的 Google Cloud 项目 ID。你可以在此处创建一个。

最后,项目源代码在此处。欢迎告诉我你的想法!

评论

支持 Markdown