Flutter学习之文件操作与网络请求以及JSON转数据模型

430 阅读6分钟

文件操作

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多一点。

基于此,我们可以设计一个简单的多线程的文件分块下载器,实现的思路是:

  1. 先检测是否支持分块传输,如果不支持,则直接下载;若支持,则将剩余内容分块下载。
  2. 各个分块下载时保存到各自临时文件,等到所有分块下载完后合并临时文件。
  3. 删除临时文件。

实现

// 通过第一个分块请求检测服务器是否支持分块传输  
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。

步骤

  1. 连接到WebSocket服务器。
  2. 监听来自服务器的消息。
  3. 将数据发送到服务器。
  4. 关闭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完全可以在收到数据后解析出正确的类型,所以就无需开发者去关心,当服务器传输的数据是指定为二进制时,StreamBuildersnapshot.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.fromJsonUser.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);  
}

有了上面的设置,源码生成器将生成用于序列化nameemail字段的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

image.png 填写实体类名字以及JSON. 然后点击Make就生成成功了。