文件操作
APP目录
Android 和 iOS 的应用存储目录不同,PathProvider插件提供了一种平台透明的方式来访问设备文件系统上的常用位置。该类当前支持访问两个文件系统位置:
- 临时目录: 可以使用
getTemporaryDirectory()来获取临时目录; 系统可随时清除的临时目录(缓存)。在 iOS 上,这对应于NSTemporaryDirectory()返回的值。在 Android上,这是getCacheDir()返回的值。 - 文档目录: 可以使用
getApplicationDocumentsDirectory()来获取应用程序的文档目录,该目录用于存储只有自己可以访问的文件。只有当应用程序被卸载时,系统才会清除该目录。在 iOS 上,这对应于NSDocumentDirectory。在 Android 上,这是AppData目录。 - 外部存储目录:可以使用
getExternalStorageDirectory()来获取外部存储目录,如 SD 卡;由于 iOS不支持外部目录,所以在 iOS 下调用该方法会抛出UnsupportedError异常,而在 Android 下结果是Android SDK 中getExternalStorageDirectory的返回值。
一旦你的 Flutter 应用程序有一个文件位置的引用,你可以使用 dart:io API来执行对文件系统的读/写操作。有关使用 Dart 处理文件和目录的详细内容可以参考 Dart 语言文档,下面我们看一个简单的例子。
1.引入PathProvider插件
path_provider: ^2.0.2
2.代码
import 'dart:io';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
class FileOperationRoute extends StatefulWidget {
FileOperationRoute({Key? key}) : super(key: key);
@override
_FileOperationRouteState createState() => _FileOperationRouteState();
}
class _FileOperationRouteState extends State<FileOperationRoute> {
int _counter = 0;
@override
void initState() {
super.initState();
//从文件读取点击次数
_readCounter().then((int value) {
setState(() {
_counter = value;
});
});
}
Future<File> _getLocalFile() async {
// 获取应用目录
String dir = (await getApplicationDocumentsDirectory()).path;
return File('$dir/counter.txt');
}
Future<int> _readCounter() async {
try {
File file = await _getLocalFile();
// 读取点击次数(以字符串)
String contents = await file.readAsString();
return int.parse(contents);
} on FileSystemException {
return 0;
}
}
_incrementCounter() async {
setState(() {
_counter++;
});
// 将点击次数以字符串类型写到文件中
await (await _getLocalFile()).writeAsString('$_counter');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('文件操作')),
body: Center(
child: Text('点击了 $_counter 次'),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}
Http请求库-dio
dio是一个强大的Dart Http请求库,支持Restful API、FormData、拦截器、请求取消、Cookie管理、文件上传/下载、超时等。dio的使用方式随着其版本升级可能会发生变化,如果本节所述内容和dio官方有差异,请以dio官方文档为准。
引入
dependencies:
dio: ^x.x.x #请使用pub上的最新版本
导入并创建dio实例:
import 'package:dio/dio.dart';
Dio dio = Dio();
发起 GET 请求 :
Response response;
response=await dio.get("/test?id=12&name=wendu")
print(response.data.toString());
对于GET请求可以将query参数通过对象来传递,上面的代码等同于:
response=await dio.get("/test",queryParameters:{"id":12,"name":"wendu"})
print(response);
发起一个 POST 请求:
response=await dio.post("/test",data:{"id":12,"name":"wendu"})
发起多个并发请求:
response= await Future.wait([dio.post("/info"),dio.get("/token")]);
下载文件
response=await dio.download("https://www.google.com/",_savePath);
发送 FormData:
FormData formData = FormData.from({
"name": "wendux",
"age": 25,
});
response = await dio.post("/info", data: formData)
如果发送的数据是FormData,则dio会将请求header的contentType设为“multipart/form-data”。
通过FormData上传多个文件:
FormData formData = FormData.from({
"name": "wendux",
"age": 25,
"file1": UploadFileInfo(File("./upload.txt"), "upload1.txt"),
"file2": UploadFileInfo(File("./upload.txt"), "upload2.txt"),
// 支持文件数组上传
"files": [
UploadFileInfo(File("./example/upload.txt"), "upload.txt"),
UploadFileInfo(File("./example/upload.txt"), "upload.txt")
]
});
response = await dio.post("/info", data: formData)
dio设置代理、请求认证、证书校验,可以在onHttpClientCreate回调中设置,例如:
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) {
//设置代理
client.findProxy = (uri) {
return "PROXY 192.168.1.2:8888";
};
//校验证书
httpClient.badCertificateCallback=(X509Certificate cert, String host, int port){
if(cert.pem==PEM){
return true; //证书一致,则允许发送数据
}
return false;
};
};
注意,onHttpClientCreate会在当前dio实例内部需要创建HttpClient时调用,所以通过此回调配置HttpClient会对整个dio实例生效,如果想针对某个应用请求单独的代理或证书校验策略,可以创建一个新的dio实例即可。
除了这些基本的用法,dio还支持请求配置、拦截器等,官方资料比较详细,dio主页:github.com/flutterchin… 。
使用
通过Github开放的API请求。
class _FutureBuilderRouteState extends State<FutureBuilderRoute> {
Dio _dio = Dio();
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.center,
child: FutureBuilder(
future: _dio.get("https://api.github.com/orgs/flutterchina/repos"),
builder: (BuildContext context, AsyncSnapshot snapshot) {
//请求完成
if (snapshot.connectionState == ConnectionState.done) {
Response response = snapshot.data;
//发生错误
if (snapshot.hasError) {
return Text(snapshot.error.toString());
}
//请求成功,通过项目信息构建用于显示项目名称的ListView
return ListView(
children: response.data.map<Widget>((e) =>
ListTile(title: Text(e["full_name"]))
).toList(),
);
}
//请求未完成时弹出loading
return CircularProgressIndicator();
}
),
);
}
}
实例:Http分块下载
原理
Http协议定义了分块传输的响应header字段,但具体是否支持取决于Server的实现,我们可以指定请求头的"range"字段来验证服务器是否支持分块传输。例如,我们可以利用curl命令来验证:
bogon:~ duwen$ curl -H "Range: bytes=0-10" http://download.dcloud.net.cn/HBuilder.9.0.2.macosx_64.dmg -v
# 请求头
> GET /HBuilder.9.0.2.macosx_64.dmg HTTP/1.1
> Host: download.dcloud.net.cn
> User-Agent: curl/7.54.0
> Accept: */*
> Range: bytes=0-10
# 响应头
< HTTP/1.1 206 Partial Content
< Content-Type: application/octet-stream
< Content-Length: 11
< Connection: keep-alive
< Date: Thu, 21 Feb 2019 06:25:15 GMT
< Content-Range: bytes 0-10/233295878
我们在请求头中添加"Range: bytes=0-10"的作用是,告诉服务器本次请求我们只想获取文件0-10(包括10,共11字节)这块内容。如果服务器支持分块传输,则响应状态码为206,表示“部分内容”,并且同时响应头中包含“Content-Range”字段,如果不支持则不会包含。我们看看上面“Content-Range”的内容:
Content-Range: bytes 0-10/233295878
0-10表示本次返回的区块,233295878代表文件的总长度,单位都是byte, 也就是该文件大概233M多一点。
基于此,我们可以设计一个简单的多线程的文件分块下载器,实现的思路是:
- 先检测是否支持分块传输,如果不支持,则直接下载;若支持,则将剩余内容分块下载。
- 各个分块下载时保存到各自临时文件,等到所有分块下载完后合并临时文件。
- 删除临时文件。
实现
// 通过第一个分块请求检测服务器是否支持分块传输
Response response = await downloadChunk(url, 0, firstChunkSize, 0);
if (response.statusCode == 206) { //如果支持
//解析文件总长度,进而算出剩余长度
total = int.parse(
response.headers.value(HttpHeaders.contentRangeHeader).split("/").last);
int reserved = total -
int.parse(response.headers.value(HttpHeaders.contentLengthHeader));
//文件的总块数(包括第一块)
int chunk = (reserved / firstChunkSize).ceil() + 1;
if (chunk > 1) {
int chunkSize = firstChunkSize;
if (chunk > maxChunk + 1) {
chunk = maxChunk + 1;
chunkSize = (reserved / maxChunk).ceil();
}
var futures = <Future>[];
for (int i = 0; i < maxChunk; ++i) {
int start = firstChunkSize + i * chunkSize;
//分块下载剩余文件
futures.add(downloadChunk(url, start, start + chunkSize, i + 1));
}
//等待所有分块全部下载完成
await Future.wait(futures);
}
//合并文件文件
await mergeTempFiles(chunk);
}
下面我们使用dio的download API 实现downloadChunk:
//start 代表当前块的起始位置,end代表结束位置
//no 代表当前是第几块
Future<Response> downloadChunk(url, start, end, no) async {
progress.add(0); //progress记录每一块已接收数据的长度
--end;
return dio.download(
url,
savePath + "temp$no", //临时文件按照块的序号命名,方便最后合并
onReceiveProgress: createCallback(no), // 创建进度回调,后面实现
options: Options(
headers: {"range": "bytes=$start-$end"}, //指定请求的内容区间
),
);
}
接下来实现mergeTempFiles:
Future mergeTempFiles(chunk) async {
File f = File(savePath + "temp0");
IOSink ioSink= f.openWrite(mode: FileMode.writeOnlyAppend);
//合并临时文件
for (int i = 1; i < chunk; ++i) {
File _f = File(savePath + "temp$i");
await ioSink.addStream(_f.openRead());
await _f.delete(); //删除临时文件
}
await ioSink.close();
await f.rename(savePath); //合并后的文件重命名为真正的名称
}
下面我们看一下完整实现:
/// Downloading by spiting as file in chunks
Future downloadWithChunks(
url,
savePath, {
ProgressCallback onReceiveProgress,
}) async {
const firstChunkSize = 102;
const maxChunk = 3;
int total = 0;
var dio = Dio();
var progress = <int>[];
createCallback(no) {
return (int received, _) {
progress[no] = received;
if (onReceiveProgress != null && total != 0) {
onReceiveProgress(progress.reduce((a, b) => a + b), total);
}
};
}
Future<Response> downloadChunk(url, start, end, no) async {
progress.add(0);
--end;
return dio.download(
url,
savePath + "temp$no",
onReceiveProgress: createCallback(no),
options: Options(
headers: {"range": "bytes=$start-$end"},
),
);
}
Future mergeTempFiles(chunk) async {
File f = File(savePath + "temp0");
IOSink ioSink= f.openWrite(mode: FileMode.writeOnlyAppend);
for (int i = 1; i < chunk; ++i) {
File _f = File(savePath + "temp$i");
await ioSink.addStream(_f.openRead());
await _f.delete();
}
await ioSink.close();
await f.rename(savePath);
}
Response response = await downloadChunk(url, 0, firstChunkSize, 0);
if (response.statusCode == 206) {
total = int.parse(
response.headers.value(HttpHeaders.contentRangeHeader).split("/").last);
int reserved = total -
int.parse(response.headers.value(HttpHeaders.contentLengthHeader));
int chunk = (reserved / firstChunkSize).ceil() + 1;
if (chunk > 1) {
int chunkSize = firstChunkSize;
if (chunk > maxChunk + 1) {
chunk = maxChunk + 1;
chunkSize = (reserved / maxChunk).ceil();
}
var futures = <Future>[];
for (int i = 0; i < maxChunk; ++i) {
int start = firstChunkSize + i * chunkSize;
futures.add(downloadChunk(url, start, start + chunkSize, i + 1));
}
await Future.wait(futures);
}
await mergeTempFiles(chunk);
}
}
现在可以进行分块下载了:
main() async {
var url = "http://download.dcloud.net.cn/HBuilder.9.0.2.macosx_64.dmg";
var savePath = "./example/HBuilder.9.0.2.macosx_64.dmg";
await downloadWithChunks(url, savePath, onReceiveProgress: (received, total) {
if (total != -1) {
print("${(received / total * 100).floor()}%");
}
});
}
其实下载速度的主要瓶颈是取决于网络速度和服务器的出口速度,如果是同一个数据源,分块下载的意义并不大,因为服务器是同一个,出口速度确定的,主要取决于网速。
那分块下载有什么实际的用处吗?
分块下载还有一个比较使用的场景是断点续传,可以将文件分为若干个块,然后维护一个下载状态文件用以记录每一个块的状态,这样即使在网络中断后,也可以恢复中断前的状态。
使用WebSockets
Http协议是无状态的,只能由客户端主动发起,服务端再被动响应,服务端无法向客户端主动推送内容,并且一旦服务器响应结束,链接就会断开(见注解部分),所以无法进行实时通信。WebSocket协议正是为解决客户端与服务端实时通信而产生的技术,现在已经被主流浏览器支持,所以对于Web开发者来说应该比较熟悉了,Flutter也提供了专门的包来支持WebSocket协议。
注意:Http协议中虽然可以通过keep-alive机制使服务器在响应结束后链接会保持一段时间,但最终还是会断开,keep-alive机制主要是用于避免在同一台服务器请求多个资源时频繁创建链接,它本质上是支持链接复用的技术,而并非用于实时通信,读者需要知道这两者的区别。
WebSocket协议本质上是一个基于tcp的协议,它是先通过HTTP协议发起一条特殊的http请求进行握手后,如果服务端支持WebSocket协议,则会进行协议升级。WebSocket会使用http协议握手后创建的tcp链接,和http协议不同的是,WebSocket的tcp链接是个长链接(不会断开),所以服务端与客户端就可以通过此TCP连接进行实时通信。有关WebSocket协议细节,可以看RFC文档。
Flutter中使用WebSocket。
步骤
- 连接到WebSocket服务器。
- 监听来自服务器的消息。
- 将数据发送到服务器。
- 关闭WebSocket连接。
1. 连接到WebSocket服务器
web_socket_channel package 提供了我们需要连接到WebSocket服务器的工具。该package提供了一个WebSocketChannel允许我们既可以监听来自服务器的消息,又可以将消息发送到服务器的方法。
在Flutter中,我们可以创建一个WebSocketChannel连接到一台服务器:
final channel = IOWebSocketChannel.connect('ws://echo.websocket.org');
2. 监听来自服务器的消息
现在我们建立了连接,我们可以监听来自服务器的消息,在我们发送消息给测试服务器之后,它会返回相同的消息。
我们如何收取消息并显示它们?在这个例子中,我们将使用一个StreamBuilder 来监听新消息, 并用一个Text来显示它们。
StreamBuilder(
stream: widget.channel.stream,
builder: (context, snapshot) {
return Text(snapshot.hasData ? '${snapshot.data}' : '');
},
);
工作原理
WebSocketChannel提供了一个来自服务器的消息Stream 。该Stream类是dart:async包中的一个基础类。它提供了一种方法来监听来自数据源的异步事件。与Future返回单个异步响应不同,Stream类可以随着时间推移传递很多事件。该StreamBuilder 组件将连接到一个Stream, 并在每次收到消息时通知Flutter重新构建界面。
3. 将数据发送到服务器
为了将数据发送到服务器,会add消息给WebSocketChannel提供的sink。
channel.sink.add('Hello!');
工作原理
WebSocketChannel提供了一个StreamSink ,它将消息发给服务器。
StreamSink类提供了给数据源同步或异步添加事件的一般方法。
4. 关闭WebSocket连接
channel.sink.close();
完整的例子
import 'package:flutter/material.dart';
import 'package:web_socket_channel/io.dart';
class WebSocketRoute extends StatefulWidget {
@override
_WebSocketRouteState createState() => _WebSocketRouteState();
}
class _WebSocketRouteState extends State<WebSocketRoute> {
TextEditingController _controller = TextEditingController();
IOWebSocketChannel channel;
String _text = "";
@override
void initState() {
//创建websocket连接
channel = IOWebSocketChannel.connect('ws://echo.websocket.org');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("WebSocket(内容回显)"),
),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Form(
child: TextFormField(
controller: _controller,
decoration: InputDecoration(labelText: 'Send a message'),
),
),
StreamBuilder(
stream: channel.stream,
builder: (context, snapshot) {
//网络不通会走到这
if (snapshot.hasError) {
_text = "网络不通...";
} else if (snapshot.hasData) {
_text = "echo: "+snapshot.data;
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: Text(_text),
);
},
)
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _sendMessage,
tooltip: 'Send message',
child: Icon(Icons.send),
),
);
}
void _sendMessage() {
if (_controller.text.isNotEmpty) {
channel.sink.add(_controller.text);
}
}
@override
void dispose() {
channel.sink.close();
super.dispose();
}
}
WebSocket中所有发送的数据使用帧的形式发送,而帧是有固定格式,每一个帧的数据类型都可以通过Opcode字段指定,它可以指定当前帧是文本类型还是二进制类型(还有其它类型),所以客户端在收到帧时就已经知道了其数据类型,所以flutter完全可以在收到数据后解析出正确的类型,所以就无需开发者去关心,当服务器传输的数据是指定为二进制时,StreamBuilder的snapshot.data的类型就是List<int>,是文本时,则为String。
Json转Dart Model类
1.自己写转换代码进行转换
例如:
{
"name": "John Smith",
"email": "john@example.com"
}
Model类
class User {
final String name;
final String email;
User(this.name, this.email);
User.fromJson(Map<String, dynamic> json)
: name = json['name'],
email = json['email'];
Map<String, dynamic> toJson() =>
<String, dynamic>{
'name': name,
'email': email,
};
}
使用
Map userMap = json.decode(json);
var user = User.fromJson(userMap);
print('Howdy, ${user.name}!');
print('We sent the verification link to ${user.email}.');
要序列化一个user,我们只是将该User对象传递给该json.encode方法。我们不需要手动调用toJson这个方法,因为`JSON.encode内部会自动调用。
String json = json.encode(user);
这样,调用代码就不用担心JSON序列化了,但是,Model类还是必须的。在实践中,User.fromJson和User.toJson方法都需要单元测试到位,以验证正确的行为。
2.自动生成Model
使用官方推荐的json_serializable package包。 它是一个自动化的源代码生成器,可以在开发阶段为我们生成 JSON 序列化模板,这样一来,由于序列化代码不再由我们手写和维护,我们将运行时产生 JSON 序列化异常的风险降至最低。
- 在项目中设置 json_serializable
要引用json_serializable到我们的项目中,我们需要一个常规和两个开发依赖项。
pubspec.yaml
dependencies:
json_annotation: <最新版本>
dev_dependencies:
build_runner: <最新版本>
json_serializable: <最新版本>
flutter packages get
将的User类转换为一个json_serializable。
import 'package:json_annotation/json_annotation.dart';
// user.g.dart 将在我们运行生成命令后自动生成
part 'user.g.dart';
///这个标注是告诉生成器,这个类是需要生成Model类的
@JsonSerializable()
class User{
User(this.name, this.email);
String name;
String email;
//不同的类使用不同的mixin即可
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
有了上面的设置,源码生成器将生成用于序列化name和email字段的JSON代码。
如果需要,自定义命名策略也很容易。例如,如果我们正在使用的API返回带有_snake_case_的对象,但我们想在我们的模型中使用_lowerCamelCase_, 那么我们可以使用@JsonKey标注:
//显式关联JSON字段名与Model属性的对应关系
@JsonKey(name: 'registration_date_millis')
final int registrationDateMillis;
一次性生成
通过在我们的项目根目录下运行:
flutter packages pub run build_runner build
这触发了一次性构建,我们可以在需要时为我们的 Model 生成 json 序列化代码,它通过我们的源文件,找出需要生成 Model 类的源文件(包含@JsonSerializable 标注的)来生成对应的 .g.dart 文件。一个好的建议是将所有 Model 类放在一个单独的目录下,然后在该目录下执行命令。
虽然这非常方便,但如果我们不需要每次在Model类中进行更改时都要手动运行构建命令的话会更好。
持续生成
使用_watcher_可以使我们的源代码生成的过程更加方便。它会监视我们项目中文件的变化,并在需要时自动构建必要的文件,我们可以通过flutter packages pub run build_runner watch在项目根目录下运行来启动_watcher_。只需启动一次观察器,然后它就会在后台运行,这是安全的。
3.自动化生成模板
上面的方法有一个最大的问题就是要为每一个json写模板,这是比较枯燥的。可以使用FlutterJsonBeanFactory插件来完成这个步骤。 首先安装插件然后new>JsonToDartBeanAction
填写实体类名字以及JSON. 然后点击Make就生成成功了。