FLutter了解之文件操作、模型转换、网络请求
目录
1. 文件操作
2. Json转Model
3. HttpClient
4. dio三方库
5. Http分块下载
6. 使用WebSockets
7. 使用Socket API(dart:io包中)
8. http三方库
1. 文件操作
无论是Flutter还是DartVM下的脚本(系统路径不同,Dart VM运行在PC或服务器操作系统下,Flutter运行在移动操作系统下)都通过Dart IO库来操作文件。
在实际开发中,如果存储一些简单的数据,推荐使用shared_preferences插件。
获取目录位置
Android和iOS的应用存储目录不同,PathProvider三方插件提供了一种平台透明的方式来访问设备文件系统上的常用位置。
1. 临时目录: getTemporaryDirectory()
系统可随时清除的临时目录(存放缓存文件)。
在iOS上,这对应于NSTemporaryDirectory() 返回的值。
在Android上,这是getCacheDir())返回的值。
2. 文档目录: getApplicationDocumentsDirectory()
当应用程序被卸载时,系统才会清除该目录。
在iOS上,这对应于NSDocumentDirectory。
在Android上,这是AppData目录。
3. 外部存储目录(SD卡):getExternalStorageDirectory();
在iOS下调用该方法会抛出UnsupportedError异常(iOS不支持外部目录)。
在Android下结果是android SDK中getExternalStorageDirectory的返回值。
例
一个计数器,应用退出重启后可以恢复点击次数。
1. 引入PathProvider插件;在pubspec.yaml文件中添加如下声明:
path_provider: ^0.4.1
添加后,执行flutter packages get 获取一下
2.完整代码如下:
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import 'dart:async';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.yellow,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home:MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key}) : super(key: key);
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter;
@override
void initState() {
super.initState();
_readCounter().then((int value) {
setState(() {
_counter = value;
});
});
}
// 从文件读取点击次数
Future<int> _readCounter() async {
try {
File file = await _getLocalFile();
// 读取点击次数(以字符串)
String contents = await file.readAsString();
return int.parse(contents);
} on FileSystemException {
return 0;
}
}
// 获取文件
Future<File> _getLocalFile() async {
// 获取应用的Documents目录
String dir = (await getApplicationDocumentsDirectory()).path;
return new File('$dir/counter.txt');
}
// 点击按钮后自增,并将点击次数以字符串类型写到文件中
Future<Null> _incrementCounter() async {
setState(() {
_counter++;
});
await (await _getLocalFile()).writeAsString('$_counter');
}
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(title: new Text('文件操作')),
body: new Center(
child: new Text('点击了 $_counter 次'),
),
floatingActionButton: new FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: new Icon(Icons.add),
),
);
}
}
2. Json转Model
Flutter中没有像Java开发中的Gson/Jackson一样的Json序列化类库,因为这样的库需要使用运行时反射,这在Flutter中是禁用的, 所以Flutter无法实现动态转化Model的功能。运行时反射会干扰Dart的tree shaking,使用tree shaking,可以在release版中“去除”未使用的代码,这可以显著优化应用程序的大小。由于反射会默认应用到所有代码,因此tree shaking会很难工作,因为在启用反射时很难知道哪些代码未被使用,因此冗余代码很难剥离。
- 手动序列化和反序列化 (适合小项目)
需要导入dart:convert库
json.decode(jsonStr) :将JSON格式的字符串转为Dart对象(List或Map)。
json.ecode(list) :将Dart对象转为JSON格式的字符串。
例
// 导入库
import 'dart:convert'
// 一个JSON格式的用户列表字符串
String jsonStr='[{"name":"Jack"},{"name":"Rose"}]';
// json字符串转为Dart对象
List items=json.decode(jsonStr); // items[0]["name"]
// Dart对象转为json字符串
String jsonString=json.ecode(items);
弊端
json.decode() 没有外部依赖或其它的设置,对于小项目很方便。但当项目变大时,这种手动编写序列化逻辑可能变得难以管理且容易出错
String json='{"name": "John Smith","email": "john@example.com"}';
Map<String, dynamic> user = json.decode(json);
print(' ${user['name']}');
print(' ${user['email']}');
由于json.decode()仅返回一个Map<String, dynamic>,这意味着直到运行时才知道值的类型。 通过这种方法,失去了大部分静态类型语言特性:类型安全、自动补全和最重要的编译时异常。这样一来,代码可能会变得非常容易出错。例如,当访问name或email字段时,输入错误的字段名,由于这个JSON在map结构中,所以编译器不知道这个错误的字段名,所以编译时不会报错。
其实,这个问题在很多平台上都会遇到,而也早就有了好的解决方法即“Json Model化”,具体做法就是,通过预定义一些与Json结构对应的Model类,然后在请求到数据后再动态根据数据创建出Model类的实例。
在模型类中序列化JSON
可以通过引入一个简单的模型类来解决前面提到的问题。
在User类内部有:
1. 一个User.fromJson 构造函数, 用于从一个map构造出一个 User实例。
2. 一个toJson 方法, 将 User 实例转化为一个map.
这样,调用代码现在可以具有类型安全、自动补全字段以及编译时异常。如果将拼写错误字段视为int类型而不是String, 那么代码就不会通过编译,而不是在运行时崩溃。
user.dart文件
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,
};
}
现在,序列化和反序列化的逻辑移到了模型本身内部。
这样,调用代码就不用担心JSON序列化了,但是Model类还是必须的。
在实践中,User.fromJson和User.toJson方法都需要单元测试到位,以验证正确的行为。
反序列化
Map userMap = json.decode(json);
var user = new User.fromJson(userMap);
print('Howdy, ${user.name}!');
print('We sent the verification link to ${user.email}.');
序列化
// 只是将该User对象传递给该json.encode方法。不需要手动调用toJson这个方法,因为JSON.encode内部会自动调用。
String json = json.encode(user);
- 通过代码生成自动序列化和反序列化(大中型项目)
代码生成功能的JSON序列化是指通过外部库自动生成序列化模板。
需要一些初始设置,并运行一个文件观察器,从model类生成代码。
如果访问JSON字段时拼写错误,会在编译时捕获。
缺点:生成的源文件可能会在项目导航器会显得混乱。
json_serializable三方库(官方推荐): 一个自动化的源代码生成器,可以在开发阶段生成JSON序列化模板。
第一步:pubspec.yaml文件(添加依赖包并下载)
dependencies:
json_annotation: ^2.0.0
dev_dependencies:
build_runner: ^1.0.0
json_serializable: ^2.0.0
运行 flutter packages get
第二步:user.dart文件(以json_serializable的方式创建model类)
import 'package:json_annotation/json_annotation.dart';
// user.g.dart文件 会在运行生成命令后自动生成。此处必须先写上。
part 'user.g.dart';
/// 这个标注是告诉生成器,这个类是需要生成Model类的
@JsonSerializable()
class User{
String name;
String email;
User(this.name, this.email);
// 忽略这里的错误,$UserFromJson、_$UserToJson会在下面的步骤中在user.g.dart文件中自动生成。
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
自定义命名策略
例如,如果正在使用的API返回带有snake_case的对象,但想在模型中使用lowerCamelCase, 那么可以使用@JsonKey标注:
// 显式关联JSON字段名与Model属性的对应关系
@JsonKey(name: 'registration_date_millis')
final int registrationDateMillis;
第三步:运行代码生成器,为Model自动生成json序列化代码
方式1. 一次性生成
flutter packages pub run build_runner build
在项目根目录下运行,该命令通过源文件找出需要生成Model类的源文件(包含@JsonSerializable标注的)来生成对应的.g.dart文件。
方式2. 持续生成
flutter packages pub run build_runner watch
在项目根目录下运行,该命令会启动watcher,watcher会监视项目中文件的变化,并在需要时自动构建必要的文件。
上面的方法有一个最大的问题就是要为每一个json写模板。
解决:自动化生成模板。用dart实现一个脚本或者使用IDE插件,将JSON文本转换为模板。
- 自动化生成模板(脚本)
1. 定义一个"模板的模板",命名为"template.dart"。
模板中的“%t”、“%s”为占位符,将在脚本运行时动态被替换为合适的导入头和类名。
import 'package:json_annotation/json_annotation.dart';
%t
part '%s.g.dart';
@JsonSerializable()
class %s {
%s();
%s
factory %s.fromJson(Map<String,dynamic> json) => _$%sFromJson(json);
Map<String, dynamic> toJson() => _$%sToJson(this);
}
2. 写一个自动生成模板的脚本(mo.dart),它可以根据指定的JSON目录,遍历生成模板,在生成时定义一些规则:
1. 如果JSON文件名以下划线“_”开始,则忽略此JSON文件。
2. 复杂的JSON对象往往会出现嵌套,可以通过一个特殊标志来手动指定嵌套的对象。
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as path;
const TAG="\$";
const SRC="./json"; //JSON 目录
const DIST="lib/models/"; //输出model目录
void walk() { // 遍历JSON目录生成模板
var src = new Directory(SRC);
var list = src.listSync();
var template=new File("./template.dart").readAsStringSync();
File file;
list.forEach((f) {
if (FileSystemEntity.isFileSync(f.path)) {
file = new File(f.path);
var paths=path.basename(f.path).split(".");
String name=paths.first;
if(paths.last.toLowerCase()!="json"||name.startsWith("_")) return ;
if(name.startsWith("_")) return;
//下面生成模板
var map = json.decode(file.readAsStringSync());
//为了避免重复导入相同的包,我们用Set来保存生成的import语句。
var set= new Set<String>();
StringBuffer attrs= new StringBuffer();
(map as Map<String, dynamic>).forEach((key, v) {
if(key.startsWith("_")) return ;
attrs.write(getType(v,set,name));
attrs.write(" ");
attrs.write(key);
attrs.writeln(";");
attrs.write(" ");
});
String className=name[0].toUpperCase()+name.substring(1);
var dist=format(template,[name,className,className,attrs.toString(),
className,className,className]);
var _import=set.join(";\r\n");
_import+=_import.isEmpty?"":";";
dist=dist.replaceFirst("%t",_import );
//将生成的模板输出
new File("$DIST$name.dart").writeAsStringSync(dist);
}
});
}
String changeFirstChar(String str, [bool upper=true] ){
return (upper?str[0].toUpperCase():str[0].toLowerCase())+str.substring(1);
}
//将JSON类型转为对应的dart类型
String getType(v,Set<String> set,String current){
current=current.toLowerCase();
if(v is bool){
return "bool";
}else if(v is num){
return "num";
}else if(v is Map){
return "Map<String,dynamic>";
}else if(v is List){
return "List";
}else if(v is String){ //处理特殊标志
if(v.startsWith("$TAG[]")){
var className=changeFirstChar(v.substring(3),false);
if(className.toLowerCase()!=current) {
set.add('import "$className.dart"');
}
return "List<${changeFirstChar(className)}>";
}else if(v.startsWith(TAG)){
var fileName=changeFirstChar(v.substring(1),false);
if(fileName.toLowerCase()!=current) {
set.add('import "$fileName.dart"');
}
return changeFirstChar(fileName);
}
return "String";
}else{
return "String";
}
}
//替换模板占位符
String format(String fmt, List<Object> params) {
int matchIndex = 0;
String replace(Match m) {
if (matchIndex < params.length) {
switch (m[0]) {
case "%s":
return params[matchIndex++].toString();
}
} else {
throw new Exception("Missing parameter for string format");
}
throw new Exception("Invalid format string: " + m[0].toString());
}
return fmt.replaceAllMapped("%s", replace);
}
void main(){
walk();
}
3. 写一个shell(mo.sh),先生成Model,再为Model自动生成json序列化代码。
dart mo.dart
flutter packages pub run build_runner build --delete-conflicting-outputs
4. 在根目录下新建一个json目录,然后把user.json移进去,然后在lib目录下创建一个models目录,用于保存最终生成的Model类。
现在只需要一句命令即可生成Model类了:
./mo.sh
嵌套JSON的情况
一个person.json内容如下:
{
"name": "John Smith",
"email": "john@example.com",
"mother":{
"name": "Alice",
"email":"alice@example.com"
},
"friends":[
{
"name": "Jack",
"email":"Jack@example.com"
},
{
"name": "Nancy",
"email":"Nancy@example.com"
}
]
}
每个Person都有name 、email 、 mother和friends四个字段,由于mother也是一个Person,朋友是多个Person(数组),所以期望生成的Model是下面这样:
import 'package:json_annotation/json_annotation.dart';
part 'person.g.dart';
@JsonSerializable()
class Person {
String name;
String email;
Person mother;
List<Person> friends;
Person();
factory Person.fromJson(Map<String,dynamic> json) => _$PersonFromJson(json);
Map<String, dynamic> toJson() => _$PersonToJson(this);
}
这时,只需要简单修改一下JSON,添加一些特殊标志,重新运行mo.sh即可:
{
"name": "John Smith",
"email": "john@example.com",
"mother":"$person",
"friends":"$[]person"
}
脚本在遇到特殊标志符后会先把相应字段转为相应的对象或对象数组。如果与内容冲突,可以修改mo.dart中的TAG常量,自定义标志符。
1. 对象使用 $
2. 对象数组使用 $[] 后跟具体类型名
如果每个项目都手动构建一个这样的脚本显然很麻烦,为此,将脚本和生成模板封装成一个包(已经发布到了Pub上,包名为Json_model),开发者直接添加该依赖包,便可以用一条命令根据Json文件生成Dart类。
- 自动化生成模板(IDE插件)
IDE插件和Json_model对比:
1. Json_model需要单独维护一个存放Json文件的文件夹,如果有改动,只需修改Json文件便可重新生成Model类;而IDE插件一般需要用户手动将Json内容拷贝复制到一个输入框中,这样生成之后Json文件没有存档的话,之后要改动就需要手动。
2. Json_model可以手动指定某个字段引用的其它Model类,可以避免生成重复的类;而IDE插件一般会为每一个Json文件中所有嵌套对象都单独生成一个Model类,即使这些嵌套对象可能在其它Model类中已经生成过。
3. Json_model 提供了命令行转化方式,可以方便集成到CI等非UI环境的场景。
3. 通过HttpClient发起HTTP请求(Dart IO库提供)
支持GET、POST、PUT、DELETE等常用http操作
import 'dart:io';
// 使用HttpClient发起请求分为五步
get() async {
// 1. 创建一个HttpClient
var httpClient = new HttpClient();
// 2. 创建URL
var uri = new Uri.http(
'example.com', '/path1/path2', {'param1': '42', 'param2': 'foo'});
/*
var uri=Uri(scheme: "https", host: "flutterchina.club", queryParameters: {
"xx":"xx",
"yy":"dd"
});
*/
// 3. 发送请求
var request = await httpClient.getUrl(uri);
/*
// 设置请求header
request.headers.add("user-agent", "test");
// post时设置请求体
request.add(utf8.encode("hello world"));
//request.addStream(_inputStream); //可以直接添加输入流
*/
// 4. 获取响应
var response = await request.close();
// 解析响应内容
var responseBody = await response.transform(UTF8.decoder).join();
}
// 5. 关闭client(通过该client发起的所有请求都会中止)
httpClient.close();
例
import 'package:flutter/material.dart';
import 'dart:io';
import 'dart:convert';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.yellow,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home:MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key}) : super(key: key);
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
var _ipAddress = '未知';
_getIPAddress() async {
//
var httpClient = new HttpClient();
//
var url = 'https://httpbin.org/ip';
String result;
try {
//
var request = await httpClient.getUrl(Uri.parse(url));
//
var response = await request.close();
if (response.statusCode == HttpStatus.ok) {
//
var json = await response.transform(utf8.decoder).join();
//
var data = jsonDecode(json);
result = data['origin'];
} else {
result =
'获取IP地址失败:\nHttp status ${response.statusCode}';
}
} catch (exception) {
result = '获取IP地址失败';
}
// 组件没有被移除时更新UI
if (!mounted) return;
setState(() {
_ipAddress = result;
});
}
@override
Widget build(BuildContext context) {
var spacer = new SizedBox(height: 32.0);
return new Scaffold(
body: new Center(
child: new Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
new Text('当前IP地址:'),
new Text('$_ipAddress.'),
spacer,
new RaisedButton(
onPressed: _getIPAddress,
child: new Text('获取IP地址'),
),
],
),
),
);
}
}
例2
点击“获取百度首页”按钮后,会请求百度首页,请求成功后,将返回内容显示出来并在控制台打印响应header
import 'package:flutter/material.dart';
import 'dart:io';
import 'dart:convert';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.yellow,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key}) : super(key: key);
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
bool _loading = false;
String _text = "";
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: BoxConstraints.expand(),
child: SingleChildScrollView(
child: Column(
children: <Widget>[
RaisedButton(
child: Text("获取百度首页"),
onPressed: _loading
? null
: () async {
setState(() {
_loading = true;
_text = "正在请求...";
});
try {
//
HttpClient httpClient = new HttpClient();
//
HttpClientRequest request = await httpClient
.getUrl(Uri.parse("https://www.baidu.com"));
// user-agent
request.headers.add("user-agent",
"Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1");
//
HttpClientResponse response = await request.close();
//
_text = await response.transform(utf8.decoder).join();
// 响应头
print(response.headers);
//关闭client后,通过该client发起的所有请求都会中止。
httpClient.close();
} catch (e) {
_text = "请求失败:$e";
} finally {
setState(() {
_loading = false;
});
}
}),
Container(
width: MediaQuery.of(context).size.width - 50.0,
child: Text(_text.replaceAll(new RegExp(r"\s"), "")))
],
),
),
);
}
}
connection: Keep-Alive
cache-control: no-cache
set-cookie: .... //有多个,省略...
transfer-encoding: chunked
date: Tue, 30 Oct 2018 10:00:52 GMT
content-encoding: gzip
vary: Accept-Encoding
strict-transport-security: max-age=172800
content-type: text/html;charset=utf-8
tracecode: 00525262401065761290103018, 00522983
HttpClient配置
HttpClient提供的这些属性和方法最终都会作用在请求header里,也可以直接去设置header。
不同的是通过HttpClient设置的对整个httpClient都生效,而通过HttpClientRequest设置的只对当前请求生效。
idleTimeout
对应请求头中的keep-alive字段值
为了避免频繁建立连接,httpClient在请求结束后会保持连接一段时间,超过这个阈值后才会关闭连接。
connectionTimeout
和服务器建立连接的超时,如果超过这个值则会抛出SocketException异常。
maxConnectionsPerHost
同一个host,同时允许建立连接的最大数量。
autoUncompress
对应请求头中的Content-Encoding
如果设置为true,则请求头中Content-Encoding的值为当前HttpClient支持的压缩算法列表,目前只有"gzip"
userAgent
对应请求头中的User-Agent字段。
HTTP请求认证(Authentication)
Http协议的认证机制可以用于保护非公开资源。如果Http服务器开启了认证,那么用户在发起请求时就需要携带用户凭据。
如果在浏览器中访问了启用Basic认证的资源时,浏览就会弹出一个登录框。
除了Basic认证之外还有:Digest认证、Client认证、Form Based认证等,目前Flutter的HttpClient只支持Basic和Digest两种认证方式,这两种认证方式最大的区别是发送用户凭据时,对于用户凭据的内容,前者只是简单的通过Base64编码(可逆),而后者会进行哈希运算,相对来说安全一点,但是为了安全起见,无论是采用Basic认证还是Digest认证,都应该在Https协议下,这样可以防止抓包和中间人攻击。
Basic认证的基本过程:
1. 客户端发送http请求给服务器,服务器验证该用户是否已经登录验证过了,如果没有的话, 服务器会返回一个401 Unauthozied给客户端,并且在响应header中添加一个 “WWW-Authenticate” 字段,例如:
WWW-Authenticate: Basic realm="admin"
其中"Basic"为认证方式,realm为用户角色的分组,可以在后台添加分组。
2. 客户端得到响应码后,将用户名和密码进行base64编码(格式为用户名:密码),设置请求头Authorization,继续访问 :
Authorization: Basic YXXFISDJFISJFGIJIJG
服务器验证用户凭据,如果通过就返回资源内容。
HttpClient关于Http认证的方法和属性
如果所有请求都需要认证,那么应该使用方法1: 在HttpClient初始化时就调用addCredentials()来添加全局凭证,而不是方法2: 去动态添加。
1. addCredentials(Uri url, String realm, HttpClientCredentials credentials)
该方法用于添加用户凭据,如:
httpClient.addCredentials(_uri,
"admin",
new HttpClientBasicCredentials("username","password"), //Basic认证凭据
);
如果是Digest认证,可以创建Digest认证凭据:
HttpClientDigestCredentials("username","password")
2. authenticate(Future<bool> f(Uri url, String scheme, String realm))
这是一个setter,类型是一个回调,当服务器需要用户凭据且该用户凭据未被添加时,httpClient会调用此回调,在这个回调当中,一般会调用addCredential()来动态添加用户凭证,例如:
httpClient.authenticate=(Uri url, String scheme, String realm) async{
if(url.host=="xx.com" && realm=="admin"){
httpClient.addCredentials(url,
"admin",
new HttpClientBasicCredentials("username","pwd"),
);
return true;
}
return false;
};
代理
可以通过findProxy来设置代理策略
有时代理服务器也启用了身份验证,这和http协议的认证是相似的,HttpClient提供了对应的Proxy认证方法和属性:
set authenticateProxy(
Future<bool> f(String host, int port, String scheme, String realm));
void addProxyCredentials(
String host, int port, String realm, HttpClientCredentials credentials);
使用方法和addCredentials和authenticate 相同
例
将所有请求通过代理服务器(192.168.1.2:8888)发送出去:
client.findProxy = (uri) {
// 如果需要过滤uri,可以手动判断
// findProxy 回调返回值是一个遵循浏览器PAC脚本格式的字符串,如果不需要代理,返回"DIRECT"即可。
return "PROXY 192.168.1.2:8888";
};
证书校验
Https中为了防止通过伪造证书而发起的中间人攻击,客户端应该对自签名或非CA颁发的证书进行校验。
证书校验其实就是提供一个badCertificateCallback回调
HttpClient对证书校验的逻辑如下:
1. 如果请求的Https证书是可信CA颁发的,并且访问host包含在证书的domain列表中(或者符合通配规则)并且证书未过期,则验证通过。
2. 如果第一步验证失败,但在创建HttpClient时,已经通过SecurityContext将证书添加到证书信任链中,那么当服务器返回的证书在信任链中的话,则验证通过。
3. 如果1、2验证都失败了,如果用户提供了badCertificateCallback回调,则会调用它,如果回调返回true,则允许继续链接,如果返回false,则终止链接。
例
假设后台服务使用的是自签名证书,证书格式是PEM格式。将证书的内容保存在本地字符串中,那么校验逻辑如下:
String PEM="XXXXX";//可以从文件读取
...
httpClient.badCertificateCallback=(X509Certificate cert, String host, int port){
if(cert.pem==PEM){
return true; //证书一致,则允许发送数据
}
return false;
};
X509Certificate是证书的标准格式,包含了证书除私钥外所有信息。另外,上面的示例没有校验host,是因为只要服务器返回的证书内容和本地的保存一致就已经能证明是我们的服务器了(而不是中间人),host验证通常是为了防止证书和域名不匹配。
对于自签名的证书,也可以将其添加到本地证书信任链中,这样证书验证时就会自动通过,而不会再走到badCertificateCallback回调中:
SecurityContext sc=new SecurityContext();
//file为证书路径
sc.setTrustedCertificates(file);
//创建一个HttpClient
HttpClient httpClient = new HttpClient(context: sc);
注意,通过setTrustedCertificates()设置的证书格式必须为PEM或PKCS12,如果证书格式为PKCS12,则需将证书密码传入,这样则会在代码中暴露证书密码,所以客户端证书校验不建议使用PKCS12格式的证书。
例(http库)
添加http依赖
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
Future<Post> fetchPost() async {
// http.get方法返回类型:Future<http.Response>
final response =
await http.get('https://jsonplaceholder.typicode.com/posts/1');
final response = await http.get(
'https://jsonplaceholder.typicode.com/posts/1',
headers: {HttpHeaders.AUTHORIZATION: "Basic your_api_token_here"}, // 认证请求
);
final responseJson = json.decode(response.body);
return new Post.fromJson(responseJson);
}
class Post {
final int userId;
final int id;
final String title;
final String body;
Post({this.userId, this.id, this.title, this.body});
factory Post.fromJson(Map<String, dynamic> json) {
return new Post(
userId: json['userId'],
id: json['id'],
title: json['title'],
body: json['body'],
);
}
}
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'Fetch Data Example',
theme: new ThemeData(
primarySwatch: Colors.blue,
),
home: new Scaffold(
appBar: new AppBar(
title: new Text('Fetch Data Example'),
),
body: new Center(
child: new FutureBuilder<Post>(
future: fetchPost(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return new Text(snapshot.data.title);
} else if (snapshot.hasError) {
return new Text("${snapshot.error}");
}
// By default, show a loading spinner
return new CircularProgressIndicator();
},
),
),
),
);
}
}
直接使用HttpClient发起网络请求是比较麻烦的,很多事情得手动处理,如果再涉及到文件上传/下载、Cookie管理等就会非常繁琐。
4. dio库
支持:Restful API、FormData、拦截器、请求取消、Cookie管理、文件上传/下载、超时、请求配置等。
一个dio实例可以发起多个http请求,一般来说,APP只有一个http数据源时,dio应该使用单例模式。
1. 添加dio依赖包并下载:
dependencies:
dio: #lastverssion
2. 导入并创建dio实例:
import 'package:dio/dio.dart';
Dio dio = Dio();
3.
GET 请求 :
Response response;
// 等价于response=await dio.get("/test",queryParameters:{"id":12,"name":"wendu"});
response=await dio.get("/test?id=12&name=wendu");
print(response);
print(response.data.toString());
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,则dio会将请求header的contentType设为“multipart/form-data”。
FormData formData = new FormData.from({
"name": "wendux",
"age": 25,
});
response = await dio.post("/info", data: formData)
通过FormData上传多个文件:
FormData formData = new FormData.from({
"name": "wendux",
"age": 25,
"file1": new UploadFileInfo(new File("./upload.txt"), "upload1.txt"),
"file2": new UploadFileInfo(new File("./upload.txt"), "upload2.txt"),
// 支持文件数组上传
"files": [
new UploadFileInfo(new File("./example/upload.txt"), "upload.txt"),
new UploadFileInfo(new File("./example/upload.txt"), "upload.txt")
]
});
response = await dio.post("/info", data: formData)
dio内部仍然使用HttpClient发起的请求,所以代理、请求认证、证书校验等和HttpClient是相同的,可以在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实例即可。
例
通过Github开放的API来请求flutterchina组织下的所有公开的开源项目,实现:
在请求阶段弹出loading
请求结束后,如果请求失败,则展示错误信息;如果成功,则将项目名称列表展示出来。
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.yellow,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key}) : super(key: key);
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
//
Dio _dio = new Dio();
@override
Widget build(BuildContext context) {
return new Scaffold(
body: new 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();
}),
));
}
}
5. Http分块下载
1. 分块下载的最终速度受设备所在网络带宽、源出口速度、每个块大小、以及分块的数量等诸多因素影响,实际过程中很难保证速度最优。下载速度的主要瓶颈是取决于网络速度和服务器的出口速度,如果是同一个数据源,分块下载的意义并不大,因为服务器是同一个,出口速度确定的,主要取决于网速。如果有多个下载源,并且每个下载源的出口带宽都是有限制的,这时分块下载可能会更快一下,之所以说“可能”,是由于这并不是一定的,比如有三个源,三个源的出口带宽都为1G/s,而我们设备所连网络的峰值假设只有800M/s,那么瓶颈就在我们的网络。即使我们设备的带宽大于任意一个源,下载速度依然不一定就比单源单线下载快,试想一下,假设有两个源A和B,速度A源是B源的3倍,如果采用分块下载,两个源各下载一半的话。
2. 分块下载有一个比较使用的场景是断点续传,可以将文件分为若干个块,然后维护一个下载状态文件用以记录每一个块的状态,这样即使在网络中断后,也可以恢复中断前的状态。分块大小、下载到一半的块如何处理、要不要维护一个任务队列
Http协议定义了分块传输的响应header字段,但具体是否支持取决于Server的实现,
可以指定请求头的"range"字段来验证服务器是否支持分块传输。
例
利用curl命令来验证:
$ 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
< Server: Tengine
< Content-Type: application/octet-stream
< Content-Length: 11
< Connection: keep-alive
< Date: Fri, 25 Sep 2020 16:02:41 GMT
< Content-Range: bytes 0-10/233295878
。。。
说明:
在请求头中添加"Range: bytes=0-10"的作用是,告诉服务器本次请求只想获取文件0-10(包括10,共11字节)这块内容。如果服务器支持分块传输,则响应状态码为206,表示“部分内容”,并且同时响应头中包含“Content-Range”字段,如果不支持则不会包含。
0-10表示本次返回的区块,233295878代表文件的总长度,单位都是byte。
例2
设计一个简单的多线程的文件分块下载器,实现的思路是:
1. 先检测是否支持分块传输,如果不支持,则直接下载;若支持,则将剩余内容分块下载。
2. 各个分块下载时保存到各自临时文件,等到所有分块下载完后合并临时文件。
3. 删除临时文件。
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import 'dart:io';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.yellow,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key}) : super(key: key);
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
///
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);
}
};
}
// 使用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"}, //指定请求的内容区间
),
);
}
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()}%");
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: main,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}
6. 使用WebSockets
Http协议是无状态的,只能由客户端主动发起,服务端再被动响应,服务端无法向客户端主动推送内容,并且一旦服务器响应结束,链接就会断开,所以无法进行实时通信。WebSocket协议正是为解决客户端与服务端实时通信而产生的技术,现在已经被主流浏览器支持,Flutter也提供了专门的包来支持WebSocket协议。
Http协议中虽然可以通过keep-alive机制使服务器在响应结束后链接会保持一段时间,但最终还是会断开,keep-alive机制主要是用于避免在同一台服务器请求多个资源时频繁创建链接,它本质上是支持链接复用的技术,而并非用于实时通信。
WebSocket协议本质上是一个基于tcp的协议,它是先通过HTTP协议发起一条特殊的http请求进行握手后,如果服务端支持WebSocket协议,则会进行协议升级。WebSocket会使用http协议握手后创建的tcp链接,和http协议不同的是,WebSocket的tcp链接是个长链接(不会断开),所以服务端与客户端就可以通过此TCP连接进行实时通信。
要接收二进制数据仍然使用StreamBuilder,因为WebSocket中所有发送的数据使用帧的形式发送,而帧是有固定格式,每一个帧的数据类型都可以通过Opcode字段指定,它可以指定当前帧是文本类型还是二进制类型(还有其它类型),所以客户端在收到帧时就已经知道了其数据类型,所以flutter完全可以在收到数据后解析出正确的类型,所以就无需开发者去关心,当服务器传输的数据是指定为二进制时,StreamBuilder的snapshot.data的类型就是List<int>,是文本时,则为String。
web_socket_channel包提供了连接到WebSocket服务器的工具。该package提供了一个WebSocketChannel允许既可以监听来自服务器的消息,又可以将消息发送到服务器的方法。
使用步骤:
1. 连接到WebSocket服务器
// 创建一个WebSocketChannel,并连接到一台服务器:
final channel = IOWebSocketChannel.connect('ws://echo.websocket.org');
2. 监听来自服务器的消息
// WebSocketChannel提供了一个来自服务器的消息Stream 。该Stream类是dart:async包中的一个基础类。它提供了一种方法来监听来自数据源的异步事件。与Future返回单个异步响应不同,Stream类可以随着时间推移传递很多事件。该StreamBuilder 组件将连接到一个Stream, 并在每次收到消息时通知Flutter重新构建界面。
new StreamBuilder(
stream: widget.channel.stream,
builder: (context, snapshot) {
return new Text(snapshot.hasData ? '${snapshot.data}' : '');
},
);
3. 将数据发送到服务器
// 将数据发送到服务器,WebSocketChannel提供了一个StreamSink,它将消息发给服务器。StreamSink类提供了给数据源同步或异步添加事件的一般方法。
channel.sink.add('Hello!');
4. 关闭WebSocket连接
// 使用WebSocket后,要关闭连接:
channel.sink.close();
例
import 'package:flutter/material.dart';
import 'package:web_socket_channel/io.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.yellow,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key}) : super(key: key);
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
TextEditingController _controller = new TextEditingController();
IOWebSocketChannel channel;
String _text = "";
@override
void initState() {
// 创建websocket连接
channel = new IOWebSocketChannel.connect('ws://echo.websocket.org');
}
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text("WebSocket(内容回显)"),
),
body: new Padding(
padding: const EdgeInsets.all(20.0),
child: new Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
new Form(
child: new TextFormField(
controller: _controller,
decoration: new InputDecoration(labelText: '发送内容'),
),
),
new StreamBuilder(
stream: channel.stream,
builder: (context, snapshot) {
//网络不通会走到这
if (snapshot.hasError) {
_text = "网络不通...";
} else if (snapshot.hasData) {
_text = "echo: "+snapshot.data;
}
return new Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: new Text(_text),
);
},
)
],
),
),
floatingActionButton: new FloatingActionButton(
onPressed: _sendMessage,
tooltip: 'Send message',
child: new Icon(Icons.send),
),
);
}
void _sendMessage() {
if (_controller.text.isNotEmpty) {
channel.sink.add(_controller.text);
}
}
@override
void dispose() {
channel.sink.close();
super.dispose();
}
}
7. 使用Socket API(dart:io包中)
Http协议和WebSocket协议都属于应用层协议,除了它们,应用层协议还有很多如:SMTP、FTP等,这些应用层协议的实现都是通过Socket API来实现的。其实,操作系统中提供的原生网络请求API是标准的,在C语言的Socket库中,它主要提供了端到端建立链接和发送数据的基础API,而高级编程语言中的Socket库其实都是对操作系统的socket API的一个封装。
如果需要自定义协议或者想直接来控制管理网络链接、又或者想重新实现一个HttpClient,这时就需要使用Socket。
使用Socket需要自己实现Http协议(需要自己实现和服务器的通信过程)
例
_request() async{
//建立连接
var socket=await Socket.connect("baidu.com", 80);
//根据http协议,发送请求头
socket.writeln("GET / HTTP/1.1");
socket.writeln("Host:baidu.com");
socket.writeln("Connection:close");
socket.writeln();
await socket.flush(); //发送
//读取返回内容
_response =await socket.transform(utf8.decoder).join();
await socket.close();
}
8. http三方库
1.添加依赖包,并下载
http: #lastversion
2.导入库
import 'package:http/http.dart' as http;
import 'dart:convert' as convert;
3. 使用(在initState中调用请求方法)
var url = 'https://...';
// post
var response = await http.post(url, body: {'name': '张三', 'password': '123456'});
// get
// var response = await http.get(url);
if (response.statusCode == 200) { // 请求成功
// 解析数据
var jsonResponse = convert.jsonDecode(response.body);
var name = jsonResponse['name'];
} else {
print('请求失败 状态码: ${response.statusCode}.');
}