关于 host-your-flutter-project-as-a-rest-api
面向 Flutter 和 Dart 开发者的教程,介绍如何通过 REST API 提供 Flutter 项目的模型与业务逻辑。示例展示客户端和服务端共享代码,并使用 Docker 配置部署到 Google Cloud Run。
主要功能
- 01共享 Dart 模型与业务逻辑
- 02使用 shelf 提供 REST 端点
- 03将请求路由到控制器
- 04返回格式统一的 JSON 响应
- 05准备部署至 Google Cloud Run 的 Docker 配置
适用场景
- 01通过 REST API 暴露 Flutter 模型
- 02保持客户端与服务端逻辑一致
- 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 无处不在。
![]()
客户端和服务端使用同一代码库。
这样一来,你就可以将 Dart 模型作为 REST API 暴露出来,并在应用运行模型的同时,执行 lib 文件夹中的业务逻辑。最终项目请见这里。
设置
和任何 Flutter 项目一样,我假设你已在计算机上安装了 Flutter,并且能够创建项目。这属于中等难度,做好挑战准备的话就继续读下去。你还需要了解 Docker 的基础知识。
为什么只用一个项目?
这可能并不明显,但构建复杂应用时,你迟早会需要一个服务端,以及一个调用该服务端的应用。Firebase 是实现这一点的绝佳选择,我几乎在所有项目中都会使用它。Firebase Functions 功能非常强大,但你只能使用 Javascript 或 Typescript。如果你能使用 Flutter 项目中相同的包,甚至让两者使用同一套包,会怎么样?
![]()
当服务端项目和客户端项目通过 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'),
),
],
),
);
}
}
这只是一个基础屏幕,看起来应该很常见。
计数器示例
![]()
现在创建 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 示例
![]()
我们在 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)
![]()
![]()
如你所见,在 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 并点击运行,就会看到以下输出:
![]()
你可以在浏览器中访问,也可以在 Postman 中使用。
![]()
![]()
只需在 URL 中添加 todos 和 todos/1,就会返回不同的响应。
对于计数器模型,我们也可以使用查询参数!
![]()
![]()
只需添加 ?count=22,就会使用输入值更新模型。
请注意,这是在运行 Flutter 项目 lib 文件夹中的 Dart 代码,无需使用 Flutter widgets!
此外,我们还可以在桌面端运行此项目。查看最终项目,了解 Flutter Desktop Embedding 所需的桌面文件夹。
![]()
![]()
结论
现在,如果你想将容器部署到 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。你可以在此处创建一个。
最后,项目源代码在此处。欢迎告诉我你的想法!