Flutter 笔记 | Flutter 文件IO、网络请求、JSON、日期与国际化

文件IO操作

Dart的 IO 库包含了文件读写的相关类,它属于 Dart 语法标准的一部分,所以通过 Dart IO 库,无论是 Dart VM 下的脚本还是 Flutter,都是通过 Dart IO 库来操作文件的,不过和 Dart VM 相比,Flutter 有一个重要差异是文件系统路径不同,这是因为 Dart VM 是运行在 PC 或服务器操作系统下,而 Flutter 是运行在移动操作系统中,他们的文件系统会有一些差异。

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来执行对文件系统的读/写操作。例如:

import 'dart:async';
import 'dart:io';
import 'dart:convert';

// Reading a file as text
// When reading a text file encoded using UTF-8,
// you can read the entire file contents with readAsString().
// When the individual lines are important, you can use readAsLines().
// In both cases, a Future object is returned that provides the contents of the
// file as one or more strings.
Future<void> readFileAsText() async {
    
    
  var config = File('config.txt');

// Put the whole file in a single string.
  var stringContents = await config.readAsString();
  print('The file is ${
      
      stringContents.length} characters long.');

// Put each line of the file into its own string.
  var lines = await config.readAsLines();
  print('The file is ${
      
      lines.length} lines long.');
}

// Reading a file as binary
// The following code reads an entire file as bytes into a list of ints.
// The call to readAsBytes() returns a Future, which provides the result when it’s available.
Future<void> readFileAsBinary() async {
    
    
  var config = File('config.txt');
  var contents = await config.readAsBytes();
  print('The file is ${
      
      contents.length} bytes long.');
}

// Handling errors
// To capture errors so they don’t result in uncaught exceptions, you can register
// a catchError handler on the Future, or (in an async function) use try-catch:
Future<void> handlingErrors() async {
    
    
  var config = File('config.txt');
  try {
    
    
    var contents = await config.readAsString();
    print(contents);
  } catch (e) {
    
    
    print(e);
  }
}

// Streaming file contents
// Use a Stream to read a file, a little at a time. You can use either the Stream
// API or await for, part of Dart’s asynchrony support.
Future<void> streamingFileContents() async {
    
    
  var config = File('config.txt');
  Stream<List<int>> inputStream = config.openRead();

  var lines = utf8.decoder
      .bind(inputStream)
      .transform(const LineSplitter());
  try {
    
    
    await for (final line in lines) {
    
    
      print('Got ${
      
      line.length} characters from stream');
    }
    print('file is now closed');
  } catch (e) {
    
    
    print(e);
  }
}

// Writing file contents
// You can use an IOSinklaunch to write data to a file. Use the File openWrite()
// method to get an IOSink that you can write to. The default mode, FileMode.write,
// completely overwrites existing data in the file.
Future<void> writingFileContent() async {
    
    
  var logFile = File('log.txt');
  var sink = logFile.openWrite();
  sink.write('FILE ACCESSED ${
      
      DateTime.now()}\n');
  // To add to the end of the file, use the optional mode parameter to specify FileMode.append:
  // var sink = logFile.openWrite(mode: FileMode.append);
  await sink.flush();
  await sink.close();
}

// To write binary data, use add(List<int> data).Listing files in a directory
// Finding all files and subdirectories for a directory is an asynchronous operation.
// The list() method returns a Stream that emits an object when a file or directory is encountered.
Future<void> writeBinaryData() async {
    
    
  var dir = Directory('tmp');
  try {
    
    
    var dirList = dir.list();
    await for (final FileSystemEntity f in dirList) {
    
    
      if (f is File) {
    
    
        print('Found file ${
      
      f.path}');
      } else if (f is Directory) {
    
    
        print('Found dir ${
      
      f.path}');
      }
    }
  } catch (e) {
    
    
    print(e.toString());
  }
}

更多有关 Dart 进行文件处理的详细内容可以参考 Dart 语言文档,下面我们看一个简单的例子。

示例

我们还是以计数器为例,实现在应用退出重启后可以恢复点击次数。 这里,我们使用文件来保存数据:

  1. 引入PathProvider插件;在pubspec.yaml文件中添加如下声明:
path_provider: ^2.0.15

添加后,执行 flutter packages get 获取一下, 版本号可能随着时间推移会发生变化,可以使用最新版。

实现:

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);

  
  _FileOperationRouteState createState() => _FileOperationRouteState();
}

class _FileOperationRouteState extends State<FileOperationRoute> {
    
    
  int _counter = 0;

  
  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');
  }


  
  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),
      ),
    );
  }
}

上面代码比较简单,不再赘述,需要说明的是,本示例只是为了演示文件读写,而在实际开发中,如果要存储一些简单的数据,使用shared_preferences插件会比较简单。

网络请求

通过HttpClient发起HTTP请求

Dart IO库中提供了用于发起Http请求的一些类,我们可以直接使用HttpClient来发起请求。使用HttpClient发起请求分为五步:

  1. 创建一个HttpClient
 HttpClient httpClient = HttpClient();
  1. 打开Http连接,设置请求头:
HttpClientRequest request = await httpClient.getUrl(uri);

这一步可以使用任意Http Method,如httpClient.post(...)httpClient.delete(...)等。如果包含Query参数,可以在构建uri时添加,如:

Uri uri = Uri(scheme: "https", host: "flutterchina.club", queryParameters: {
    
    
    "xx":"xx",
    "yy":"dd"
  });

通过HttpClientRequest可以设置请求header,如:

request.headers.add("user-agent", "test");

如果是postput等可以携带请求体方法,可以通过HttpClientRequest对象发送请求体,如:

String payload="...";
request.add(utf8.encode(payload)); 
//request.addStream(_inputStream); //可以直接添加输入流
  1. 等待连接服务器:
HttpClientResponse response = await request.close();

这一步完成后,请求信息就已经发送给服务器了,返回一个HttpClientResponse对象,它包含响应头(header)和响应流(响应体的Stream),接下来就可以通过读取响应流来获取响应内容。

  1. 读取响应内容:
String responseBody = await response.transform(utf8.decoder).join();

我们通过读取响应流来获取服务器返回的数据,在读取时我们可以设置编码格式,这里是utf8

  1. 请求结束,关闭HttpClient
httpClient.close();

关闭client后,通过该client发起的所有请求都会终止。

示例

下面代码实现点击按钮后请求百度首页,请求成功后,将返回内容显示出来并在控制台打印响应header,代码如下:

import 'dart:convert';
import 'dart:io';

import 'package:flutter/material.dart';

class HttpTestRoute extends StatefulWidget {
    
    
  
  _HttpTestRouteState createState() => _HttpTestRouteState();
}

class _HttpTestRouteState extends State<HttpTestRoute> {
    
    
  bool _loading = false;
  String _text = "";

  
  Widget build(BuildContext context) {
    
    
    return SingleChildScrollView(
      child: Column(
        children: <Widget>[
          ElevatedButton(
            child: Text("获取百度首页"),
            onPressed: _loading ? null : request,
          ),
          Container(
            width: MediaQuery.of(context).size.width - 50.0,
            child: Text(_text.replaceAll(RegExp(r"\s"), "")),
          )
        ],
      ),
    );
  }

  request() async {
    
    
    setState(() {
    
    
      _loading = true;
      _text = "正在请求...";
    });
    try {
    
    
      //创建一个HttpClient
      HttpClient httpClient = HttpClient();
      //打开Http连接
      HttpClientRequest request =
          await httpClient.getUrl(Uri.parse("https://www.baidu.com"));
      //使用iPhone的UA
      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;
      });
    }
  }
}

控制台输出:

I/flutter (18545): connection: Keep-Alive
I/flutter (18545): cache-control: no-cache
I/flutter (18545): set-cookie: ....  //有多个,省略...
I/flutter (18545): transfer-encoding: chunked
I/flutter (18545): date: Tue, 30 Oct 2018 10:00:52 GMT
I/flutter (18545): content-encoding: gzip
I/flutter (18545): vary: Accept-Encoding
I/flutter (18545): strict-transport-security: max-age=172800
I/flutter (18545): content-type: text/html;charset=utf-8
I/flutter (18545): tracecode: 00525262401065761290103018, 00522983

HttpClient配置

HttpClient有很多属性可以配置,常用的属性列表如下:

属性 含义
idleTimeout 对应请求头中的keep-alive字段值,为了避免频繁建立连接,httpClient在请求结束后会保持连接一段时间,超过这个阈值后才会关闭连接。
connectionTimeout 和服务器建立连接的超时,如果超过这个值则会抛出SocketException异常。
maxConnectionsPerHost 同一个host,同时允许建立连接的最大数量。
autoUncompress 对应请求头中的Content-Encoding,如果设置为true,则请求头中Content-Encoding的值为当前HttpClient支持的压缩算法列表,目前只有"gzip"
userAgent 对应请求头中的User-Agent字段。

可以发现,有些属性只是为了更方便的设置请求头,对于这些属性,你完全可以通过HttpClientRequest直接设置header,不同的是通过HttpClient设置的对整个httpClient都生效,而通过HttpClientRequest设置的只对当前请求生效。

HTTP请求认证

Http协议的认证(Authentication)机制可以用于保护非公开资源。如果Http服务器开启了认证,那么用户在发起请求时就需要携带用户凭据,如果你在浏览器中访问了启用Basic认证的资源时,浏览就会弹出一个登录框,如图:

在这里插入图片描述
我们先看看Basic认证的基本过程:

  1. 客户端发送http请求给服务器,服务器验证该用户是否已经登录验证过了,如果没有的话, 服务器会返回一个401 Unauthozied给客户端,并且在响应header中添加一个 “WWW-Authenticate” 字段,例如:

    WWW-Authenticate: Basic realm="admin"

    其中"Basic"为认证方式,realm为用户角色的分组,可以在后台添加分组。

  2. 客户端得到响应码后,将用户名和密码进行base64编码(格式为用户名:密码),设置请求头Authorization,继续访问:

    Authorization: Basic YXXFISDJFISJFGIJIJG

    服务器验证用户凭据,如果通过就返回资源内容。

注意,Http 的方式除了 Basic 认证之外还有:Digest 认证、Client 认证、Form Based 认证等,目前 Flutter 的 HttpClient 只支持 BasicDigest 两种认证方式,这两种认证方式最大的区别是发送用户凭据时,对于用户凭据的内容,前者只是简单的通过 Base64 编码(可逆),而后者会进行哈希运算,相对来说安全一点点,但是为了安全起见,无论是采用Basic认证还是Digest认证,都应该在Https协议下,这样可以防止抓包和中间人攻击。

HttpClient关于Http认证的方法和属性:

  1. addCredentials(Uri url, String realm, HttpClientCredentials credentials)

    该方法用于添加用户凭据,如:

httpClient.addCredentials(_uri,
 "admin", 
  HttpClientBasicCredentials("username","password"), //Basic认证凭据
);

如果是Digest认证,可以创建Digest认证凭据:

HttpClientDigestCredentials("username","password")
  1. 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",
      HttpClientBasicCredentials("username","pwd"), 
    );
    return true;
  }
  return false;
};

一个建议是,如果所有请求都需要认证,那么应该在HttpClient初始化时就调用addCredentials()来添加全局凭证,而不是去动态添加。

代理

可以通过findProxy来设置代理策略,例如,我们要将所有请求通过代理服务器(192.168.1.2:8888)发送出去:

  client.findProxy = (uri) {
    
    
    // 如果需要过滤uri,可以手动判断
    return "PROXY 192.168.1.2:8888";
 };

findProxy 回调返回值是一个遵循浏览器PAC脚本格式的字符串,详情可以查看API文档,如果不需要代理,返回"DIRECT"即可。

在APP开发中,很多时候我们需要抓包来调试,而抓包软件(如charles)就是一个代理,这时我们就可以将请求发送到我们的抓包软件,我们就可以在抓包软件中看到请求的数据了。

有时代理服务器也启用了身份验证,这和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);

他们的使用方法和上面“HTTP请求认证”中介绍的addCredentialsauthenticate 相同,故不再赘述。

证书校验

Https中为了防止通过伪造证书而发起的中间人攻击,客户端应该对自签名或非CA颁发的证书进行校验。HttpClient对证书校验的逻辑如下:

  1. 如果请求的Https证书是可信CA颁发的,并且访问host包含在证书的domain列表中(或者符合通配规则)并且证书未过期,则验证通过。
  2. 如果第一步验证失败,但在创建HttpClient时,已经通过 SecurityContext 将证书添加到证书信任链中,那么当服务器返回的证书在信任链中的话,则验证通过。
  3. 如果1、2验证都失败了,如果用户提供了badCertificateCallback回调,则会调用它,如果回调返回true,则允许继续链接,如果返回false,则终止链接。

综上所述,我们的证书校验其实就是提供一个badCertificateCallback回调,下面通过一个示例来说明。

示例

假设我们的后台服务使用的是自签名证书,证书格式是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 = SecurityContext();
// file为证书路径
sc.setTrustedCertificates(file);
// 创建一个HttpClient
HttpClient httpClient = HttpClient(context: sc);

注意,通过setTrustedCertificates()设置的证书格式必须为 PEMPKCS12,如果证书格式为PKCS12,则需将证书密码传入,这样则会在代码中暴露证书密码,所以客户端证书校验不建议使用 PKCS12 格式的证书。

Http请求库-dio

我们发现直接使用HttpClient发起网络请求是比较麻烦的,很多事情得我们手动处理,如果再涉及到文件上传/下载、Cookie管理等就会非常繁琐。幸运的是,Dart社区有一些第三方http请求库,用它们来发起http请求将会简单的多,下面介绍一下目前人气较高的 dio 库。

dio 中文文档地址:点击这里

引入 dio:

dependencies:
  dio: ^5.1.2 #请使用pub上的最新版本

导入并创建 dio 实例:

import 'package:dio/dio.dart';
Dio dio =  Dio();

接下来就可以通过 dio 实例来发起网络请求了,注意,一个dio实例可以发起多个http请求,一般来说,APP只有一个http数据源时,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会将请求headercontentType设为“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内部仍然使用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实例来分别实现。

除了这些基本的用法,dio还支持请求配置、拦截器等,其功能非常丰富,详情可以参考dio官方文档。

实例:我们通过Github开放的API来请求flutterchina组织下的所有公开的开源项目

  1. 在请求阶段弹出loading
  2. 请求结束后,如果请求失败,则展示错误信息;如果成功,则将项目名称列表展示出来。

代码如下:

class _FutureBuilderRouteState extends State<FutureBuilderRoute> {
    
    
  Dio _dio = Dio();

  
  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分块下载原理

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);
}

下面我们使用diodownload 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); //合并后的文件重命名为真正的名称
}

下面我们看一下完整实现:

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()}%");
    }
  });
}

思考

1. 分块下载真的能提高下载速度吗?

  • 其实下载速度的主要瓶颈是取决于网络速度和服务器的出口速度,如果是同一个数据源,分块下载的意义并不大,因为服务器是同一个,出口速度确定的,主要取决于网速,而上面的例子正是同源分块下载,可以自己对比一下分块和不分块的的下载速度。如果有多个下载源,并且每个下载源的出口带宽都是有限制的,这时分块下载可能会更快一下,之所以说“可能”,是由于这并不是一定的,比如有三个源,三个源的出口带宽都为1Gb/s,而我们设备所连网络的峰值假设只有800Mb/s,那么瓶颈就在我们的网络。即使我们设备的带宽大于任意一个源,下载速度依然不一定就比单源单线下载快,试想一下,假设有两个源A和B,速度A源是B源的3倍,如果采用分块下载,两个源各下载一半的话,可以算一下所需的下载时间,然后再算一下只从A源下载所需的时间,看看哪个更快。

  • 分块下载的最终速度受设备所在网络带宽、源出口速度、每个块大小、以及分块的数量等诸多因素影响,实际过程中很难保证速度最优。在实际开发中,可以先测试对比后再决定是否使用。

2. 分块下载有什么实际的用处吗?

  • 分块下载还有一个比较使用的场景是断点续传,可以将文件分为若干个块,然后维护一个下载状态文件用以记录每一个块的状态,这样即使在网络中断后,也可以恢复中断前的状态,具体实现可以自己尝试一下,还是有一些细节需要特别注意的,比如分块大小多少合适?下载到一半的块如何处理?要不要维护一个任务队列?

使用WebSockets

Http协议是无状态的,只能由客户端主动发起,服务端再被动响应,服务端无法向客户端主动推送内容,并且一旦服务器响应结束,链接就会断开,所以无法进行实时通信。WebSocket协议正是为解决客户端与服务端实时通信而产生的技术,现在已经被主流浏览器支持,所以对于Web开发者来说应该比较熟悉了,Flutter也提供了专门的包来支持WebSocket协议。

注意:Http协议中虽然可以通过keep-alive机制使服务器在响应结束后链接会保持一段时间,但最终还是会断开,keep-alive机制主要是用于避免在同一台服务器请求多个资源时频繁创建链接,它本质上是支持链接复用的技术,而并非用于实时通信,请注意这两者的区别。

WebSocket协议本质上是一个基于TCP的协议,它是先通过HTTP协议发起一条特殊的http请求进行握手后,如果服务端支持WebSocket协议,则会进行协议升级。WebSocket会使用http协议握手后创建的TCP链接,和http协议不同的是,WebSocketTCP链接是个长链接(不会断开),所以服务端与客户端就可以通过此TCP连接进行实时通信。

下面我们重点看看Flutter中如何使用WebSocket

通信步骤

使用 WebSocket 通信分为4个步骤:

  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连接

在我们使用WebSocket后,要关闭连接:

channel.sink.close();

下面是一个演示WebSocket通信过程的完整实例。

import 'package:flutter/material.dart';
import 'package:web_socket_channel/io.dart';

class WebSocketRoute extends StatefulWidget {
    
    
  
  _WebSocketRouteState createState() => _WebSocketRouteState();
}

class _WebSocketRouteState extends State<WebSocketRoute> {
    
    
  TextEditingController _controller = TextEditingController();
  IOWebSocketChannel channel;
  String _text = "";


  
  void initState() {
    
    
    //创建websocket连接
    channel = IOWebSocketChannel.connect('ws://echo.websocket.org');
  }

  
  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);
    }
  }

  
  void dispose() {
    
    
    channel.sink.close();
    super.dispose();
  }
}

我们现在思考一个问题,假如我们想通过WebSocket传输二进制数据应该怎么做(比如要从服务器接收一张图片)?我们发现StreamBuilderStream都没有指定接收类型的参数,并且在创建WebSocket链接时也没有相应的配置,貌似没有什么办法……其实很简单,要接收二进制数据仍然使用StreamBuilder,因为WebSocket中所有发送的数据使用帧的形式发送,而帧是有固定格式,每一个帧的数据类型都可以通过Opcode字段指定,它可以指定当前帧是文本类型还是二进制类型(还有其他类型),所以客户端在收到帧时就已经知道了其数据类型,所以flutter完全可以在收到数据后解析出正确的类型,所以就无需开发者去关心,当服务器传输的数据是指定为二进制时,StreamBuildersnapshot.data的类型就是List<int>,是文本时,则为String

使用Socket API

Socket API 是操作系统为实现应用层网络协议提供的一套基础的、标准的API,它是对传输层网络协议(主要是TCP/UDP)的一个封装。Socket API 实现了端到端建立链接和发送/接收数据的基础API,而高级编程语言中的 Socket API 其实都是对操作系统 Socket API 的一个封装。

我们之前介绍的 Http 协议和 WebSocket 协议都属于应用层协议,除了它们,应用层协议还有很多如:SMTP、FTP 等,这些应用层协议都是通过 Socket API 来实现的。

综上,如果我们需要自定义协议或者想直接来控制管理网络链接、又或者我们觉得自带的 HttpClient 不好用想重新实现一个,这时我们就需要使用Socket。Flutter 的 Socket API 在 dart:io 包中,下面我们看一个使用 Socket 实现简单 http 请求的示例。

使用 Socket 实现Http Get请求

以请求百度首页为例:

class SocketRoute extends StatelessWidget {
    
    
  const SocketRoute({
    
    Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    
    
    return FutureBuilder(
      future: _request(),
      builder: (context, snapShot) {
    
    
        return Text(snapShot.data.toString());
      },
    );
  }

  _request() async {
    
    
    //建立连接
    var socket = await Socket.connect("baidu.com", 80);
    //根据http协议,发起 Get请求头
    socket.writeln("GET / HTTP/1.1");
    socket.writeln("Host:baidu.com");
    socket.writeln("Connection:close");
    socket.writeln();
    await socket.flush(); //发送
    //读取返回内容,按照utf8解码为字符串
    String _response = await utf8.decoder.bind(socket).join();
    await socket.close();
    return _response;
  }
}

可以看到,使用Socket需要我们自己实现Http协议(需要自己实现和服务器的通信过程),本例只是一个简单示例,没有处理重定向、cookie等,可以根据实际需要补充一些请求头。

JSON 转 Dart Model 类

在实战中,后台接口往往会返回一些结构化数据,如 JSONXML 等,如之前我们请求 Github API 的示例,它返回的数据就是 JSON 格式的字符串,为了方便我们在代码中操作 JSON,我们先将 JSON 格式的字符串转为 Dart 对象,这个可以通过 dart:convert 中内置的 JSON 解码器json.decode()来实现,该方法可以根据 JSON 字符串具体内容将其转为 ListMap,这样我们就可以通过他们来查找所需的值,如:

//一个JSON格式的用户列表字符串
String jsonStr ='[{"name":"Jack"},{"name":"Rose"}]';`
//将JSON字符串转为Dart对象(此处是List)
List items = json.decode(jsonStr);
//输出第一个用户的姓名
print(items[0]["name"]);

通过json.decode()JSON 字符串转为 List/Map 的方法比较简单,它没有外部依赖或其他的设置,对于小项目很方便。但当项目变大时,这种手动编写序列化逻辑可能变得难以管理且容易出错,例如有如下JSON

{
    
    
  "name": "John Smith",
  "email": "[email protected]"
}

我们可以通过调用 json.decode方法来解码 JSON ,使用 JSON 字符串作为参数:

Map<String, dynamic> user = json.decode(json);

print('Howdy, ${user['name']}!');
print('We sent the verification link to ${user['email']}.');

由于json.decode()仅返回一个Map<String, dynamic>,这意味着直到运行时我们才知道值的类型。 通过这种方法,我们失去了大部分静态类型语言特性:类型安全、自动补全和最重要的编译时异常。这样一来,我们的代码可能会变得非常容易出错。例如,当我们访问nameemail字段时,我们输入的很快,导致字段名打错了。但由于这个 JSONmap 结构中,所以编译器不知道这个错误的字段名,所以编译时不会报错。

其实,这个问题在很多平台上都会遇到,而也早就有了好的解决方法即“Json Model化”,具体做法就是,通过预定义一些与 Json 结构对应的 Model 类,然后在请求到数据后再动态根据数据创建出 Model 类的实例。这样一来,在开发阶段我们使用的是 Model 类的实例,而不再是 Map/List,这样访问内部属性时就不会发生拼写错误。

1. 手动编写 Dart Model 类

例如,我们可以通过引入一个简单的模型类 User Model class 来解决前面提到的问题。在User类内部,我们有:

  • 一个User.fromJson 命名构造函数, 用于从一个 map 构造出一个 User实例 map 结构。
  • 一个toJson 方法, 将 User 实例转化为一个 map

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,
    };
}

这样,调用代码现在可以具有类型安全、自动补全字段(nameemail)以及编译时异常。如果我们将拼写错误字段视为int类型而不是String, 那么我们的代码就不会通过编译,而不是在运行时崩溃。

现在,序列化逻辑移到了模型本身内部。采用这种新方法,我们可以非常容易地反序列化user

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方法都需要单元测试到位,以验证正确的行为。

另外,实际场景中,JSON对象很少会这么简单,嵌套的JSON对象并不罕见,如果有什么能为我们自动处理JSON序列化,那将会非常好。幸运的是,有!

2. 自动生成 Dart Model 类

我们介绍一下官方推荐的 json_serializable package 包。 它是一个自动化的源代码生成器,可以在开发阶段为我们生成 JSON 序列化模板,这样一来,由于序列化代码不再由我们手写和维护,我们将运行时产生 JSON 序列化异常的风险降至最低。

1)在项目中设置 json_serializable

要包含json_serializable到我们的项目中,我们需要一个常规和两个开发依赖项。简而言之,开发依赖项是不包含在我们的应用程序源代码中的依赖项,它是开发过程中的一些辅助工具、脚本,和 node 中的开发依赖项相似。

pubspec.yaml 中添加:

dependencies:
  json_annotation: <最新版本>

dev_dependencies:
  build_runner: <最新版本>
  json_serializable: <最新版本>

在项目根文件夹中运行 flutter packages get (或者在编辑器中点击 “Packages Get”) 以在项目中使用这些新的依赖项.

2)以 json_serializable 的方式创建 model 类

让我们看看如何将我们的User类转换为一个json_serializable。为了简单起见,我们使用前面示例中的简化JSON model。

user.dart

import 'package:json_annotation/json_annotation.dart';

// user.g.dart 将在我们运行生成命令后自动生成
part 'user.g.dart';

///这个标注是告诉生成器,这个类是需要生成Model类的
()

class User{
    
    
  User(this.name, this.email);

  String name;
  String email;
  
  ///在Terminal下面执行:flutter packages pub run build_runner build (只构建一次)
  ///             或者:flutter packages pub run build_runner watch (持续在后台监听文件变化并构建生成必要文件)
  ///通过上面命令之后就会生成user.g.dart文件,文件内容就是下面两个反序列化和序列化方法的实现代码
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);  
}

有了上面的设置,源码生成器将生成用于序列化nameemail字段的JSON代码。

如果需要,自定义命名策略也很容易。例如,如果我们正在使用的API返回带有snake_case的对象,但我们想在我们的模型中使用lowerCamelCase, 那么我们可以使用@JsonKey标注:

//显式关联JSON字段名与Model属性的对应关系 
(name: 'registration_date_millis')
final int registrationDateMillis;

3)运行代码生成程序

json_serializable第一次创建类时,您会看到与下图类似的错误。

在这里插入图片描述
这些错误是完全正常的,这是因为Model类的生成代码还不存在。为了解决这个问题,我们必须运行代码生成器来为我们生成序列化模板。

有两种运行代码生成器的方法:

  • 一次性生成

通过在我们的项目根目录下运行:

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 转 dart 类

上面的方法有一个最大的问题就是要为每一个json写模板,这是比较枯燥的。如果有一个工具可以直接根据JSON文本生成模板,那我们就能彻底解放双手了。

幸运的是已经有了一个功能完成的 Json_model 包,它具备灵活的的配置和自定义功能,开发者把该包加入开发依赖后,便可以用一条命令,根据 Json 文件生成Dart 类。

使用

  1. 在工程根目录下创建一个名为 “jsons” 的目录;
  2. 创建或拷贝Json文件到"jsons" 目录中 ;
  3. 运行 pub run json_model (Dart VM工程) or flutter packages pub run json_model(Flutter中) 命令生成Dart model类,生成的文件默认在"lib/models"目录下

例如,JSON 文件如下:

{
    
    
  "@meta": {
    
     // @meta 可以定制单个 json 的生成规则,默认使用全局配置
    "import": [
      "test_dir/profile.dart" // 导入其他文件
    ],
    "comments": {
    
    
      "name": "名字" // 给 "name" 字段添加注释
    },
    "nullable": false, // 字段默认非可空,会生成 late 
    "ignore": false // 是否跳过当前 JSON 的 model 类生成
  },
  "@JsonKey(ignore: true) Profile?": "profile",
  "@JsonKey(name: '+1') int?": "loved",
  "name": "wendux",
  "father": "$user",
  "friends": "$[]user",
  "keywords": "$[]String",
  "age?": 20 // 指定 age 字段可空
}

则生成的 Model 类如下:

import 'package:json_annotation/json_annotation.dart';
import 'test_dir/profile.dart';
part 'user.g.dart';

()
class User {
    
    
  User();

  (ignore: true) Profile? profile;
  (name: '+1') int? loved;
  //名字
  late String name;
  late User father;
  late List<User> friends;
  late List<String> keywords;
  num? age;
  
  factory User.fromJson(Map<String,dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

注:更多使用说明可以查看其官方文档,不过该库作者已经许久没有更新了,可能会有一些问题或兼容性,如果有问题可以查看其 issues 列表,有很多网友会分享一些基于该库的优化版本,例如json_to_modeljson5_modeljson2model 等等。

4. 使用 IDE 插件生成 model 类

如果觉得以上方法都嫌麻烦,那么更加懒人版的办法就是安装一个 IDE 插件帮你自动生成了,例如,我们可以在 Android Studio 的 Setting->Plugins 中搜索 “Json to Dart” 关键字就可以找到一大堆相类似的插件:

在这里插入图片描述

随便选择一个你喜欢的插件进行安装即可,因为功能都差不多。对于使用 Visual Studio Code 的用户也是类似,到插件市场中搜索即可。

一般安装后都会提供快捷键或在工具栏、右键菜单等可以打开一个文本输入框,然后粘贴JSON文本,即可自动生成Dart Model类。类似以前Android中常用的GsonFormat插件。

虽然这很简单,但是我们还是要了解一下IDE插件和Json_model命令行生成的优劣:

  • Json_model 需要单独维护一个存放Json文件的文件夹,如果有改动,只需修改Json文件便可重新生成Model类;而IDE插件一般需要用户手动将Json内容拷贝复制到一个输入框中,这样生成之后Json文件没有存档的化,之后要改动就需要手动。
  • Json_model 可以手动指定某个字段引用的其他Model类,可以避免生成重复的类;而IDE插件一般会为每一个Json文件中所有嵌套对象都单独生成一个Model类,即使这些嵌套对象可能在其他Model类中已经生成过。
  • Json_model 提供了命令行转化方式,可以方便集成到CI等非UI环境的场景。

5. 在线生成 model 类

如果你连插件都懒得装,那么请使用在线的吧,下面推荐两个可以在线转换Json为Dart Model的网站:

FAQ:很多人可能会问 Flutter 中有没有像 Java 开发中的 Gson/Jackson 一样的Json序列化类库?

  • 答案是没有!因为这样的库需要使用运行时反射,这在 Flutter 中是禁用的。运行时反射会干扰 Dart 的 tree shaking,使用 tree shaking ,可以在 release 版中 “去除”未使用的代码,这可以显著优化应用程序的大小。由于反射会默认应用到所有代码,因此 tree shaking 会很难工作,因为在启用反射时很难知道哪些代码未被使用,因此冗余代码很难剥离,所以 Flutter 中禁用了 Dart 的反射功能,而正因如此也就无法实现动态转化 Model 的功能。

包和插件

Flutter 中的包

一个最小的Package包括:

  • 一个pubspec.yaml文件:声明了Package的名称、版本、作者等的元数据文件。
  • 一个 lib 文件夹:包括包中公开的(public)代码,最少应有一个<package-name>.dart文件

Flutter 包分为两类:

  • Dart包:其中一些可能包含Flutter的特定功能,因此对Flutter框架具有依赖性,这种包仅用于Flutter,例如 fluro 包。
  • 插件包:一种专用的Dart包,其中包含用Dart代码编写的API,以及针对Android(使用Java或Kotlin)和针对iOS(使用OC或Swift)平台的特定实现,也就是说插件包括原生代码,例如 battery_plus 插件包。

Flutter 插件开发

Flutter 本质上只是一个 UI 框架,运行在宿主平台之上,Flutter 本身是无法提供一些系统能力,比如使用蓝牙、相机、GPS等,因此要在 Flutter 中调用这些能力就必须和原生平台进行通信。要调用特定平台 API 就需要写插件。插件是一种特殊的包,和纯 dart 包主要区别是插件中除了dart代码,还包括特定平台的代码,比如 image_picker 插件可以在 iOS 和 Android 设备上访问相册和摄像头。

关于 Flutter插件开发的具体流程 以及 Flutter与Native的通信机制,请参考我之前的笔记 Flutter Native 插件开发 (Android) 一文。

如何获取平台信息

很简单, Flutter 中提供了一个全局变量 defaultTargetPlatform 来获取当前应用的平台信息,defaultTargetPlatform定义在"platform.dart"中,它的类型是TargetPlatform,这是一个枚举类,定义如下:

enum TargetPlatform {
    
    
  android,
  fuchsia,
  iOS,
  linux, 
  macOS, 
  windows,
}

有时我们想根据宿主平台添加一些差异化的功能,就可以通过如下代码判断平台:

if (defaultTargetPlatform == TargetPlatform.android) {
    
    
    // 是安卓系统,do something
    ...
} else {
    
    
	...
}

由于不同平台有它们各自的交互规范,Flutter Material库中的一些组件都针对相应的平台做了一些适配,比如路由组件MaterialPageRoute,它在android和ios中会应用各自平台规范的切换动画。那如果我们想让我们的APP在所有平台都表现一致,比如希望在所有平台路由切换动画都按照ios平台一致的左右滑动切换风格该怎么做?Flutter中提供了一种覆盖默认平台的机制,我们可以通过显式指定debugDefaultTargetPlatformOverride全局变量的值来指定应用平台。比如:

debugDefaultTargetPlatformOverride=TargetPlatform.iOS;
print(defaultTargetPlatform); // 会输出TargetPlatform.iOS

上面代码即在Android中运行后,Flutter APP就会认为是当前系统是iOS,Material组件库中所有组件交互方式都会和iOS平台对齐,defaultTargetPlatform的值也会变为TargetPlatform.iOS

常用插件

Flutter 官方提供了一系列常用的插件,如访问相机/相册、本地存储、播放视频等,完整列表见:https://github.com/flutter/packages ,或者你可以在 https://pub.dev/ 上输入 sdk:flutter 关键字就可以得到官方维护的插件列表。

除了官方维护的插件,Flutter 社区也有很多非常好用的插件,具体可以根据需求在 https://pub.dev/ 上输入关键字自行查找。

日期和时间

日期和时间戳

获取当前日期时间:

DateTime d = DateTime.now();
print(d); // 2023-05-29 12:05:56.010143
print("${
      
      d.year}-${
      
      d.month}-${
      
      d.day} ${
      
      d.hour}:${
      
      d.minute}:${
      
      d.second}");

获取当前时间戳:

DateTime d = DateTime.now();
print(d.millisecondsSinceEpoch);

日期转换成时间戳:

DateTime d = DateTime(2022, 08, 1);
print(d.millisecondsSinceEpoch);

日期字符串转换成时间:

print(DateTime.parse("2021-08-01"));
print(DateTime.parse("2021-08-01 08:01:30"));
DateTime d = DateTime.parse("2021-08-01 08:01:30");
print(d.millisecondsSinceEpoch);

时间戳转换成日期:

DateTime d = DateTime(2023, 08, 1);
int unixtime = d.millisecondsSinceEpoch;
print(DateTime.fromMillisecondsSinceEpoch(unixtime));

时间加减与比较

日期加减:

DateTime time = DateTime.now();
print(time);
print(time.add(const Duration(minutes: 30)));
print(time.add(const Duration(minutes: -30)));

日期比较:

print(date1.isAfter(date2));  
print(date1.isBefore(date2)); 
print(date1.isAtSameMomentAs(date2)); 
final difference = dateTime1.difference(dateTime2); 
print(difference.inDays);  
print(difference.inHours);
print(difference.inMinutes);
print(difference.inSeconds);
print(difference.inMilliseconds);
print(difference > Duration(seconds: 10));
final date = DateTime.utc(1989, DateTime.november, 9);
print(DateTime.november); // 11
assert(date.month == DateTime.november);
assert(date.weekday == DateTime.thursday);

日期时间格式化

  1. 使用Dart官方社区库 intl 进行日期格式化

例如:

DateFormat("HH:mm:ss").format(DateTime.now());
DateFormat("yy:MM:dd HH:mm:ss").format(DateTime.now());
DateFormat.yMd().format(DateTime.now());
  1. 使用社区库 date_format 进行日期格式化

例如:

print(formatDate(DateTime.now(), [yyyy, '年', mm, '月', dd, '日']));
print(formatDate(DateTime(1989, 2, 21), [yyyy, '-', mm, '-', dd]));
print(formatDate(DateTime(1989, 2, 21), [yy, '-', m, '-', dd]));
print(formatDate(DateTime(1989, 2, 1), [yy, '-', m, '-', d]));

国际化

让App支持多语言

如果我们的应用要支持多种语言,那么我们需要“国际化”它。这意味着我们在开发时需要为应用程序支持的每种语言环境设置“本地化”的一些值,如文本和布局。Flutter SDK已经提供了一些组件和类来帮助我们实现国际化,下面我们来介绍一下Flutter中实现国际化的步骤。

接下来我们以MaterialApp类为入口的应用来说明如何支持国际化。

大多数应用程序都是通过MaterialApp为入口,但根据低级别的WidgetsApp类为入口编写的应用程序也可以使用相同的类和逻辑进行国际化。MaterialApp实际上也是WidgetsApp的一个包装。

注意,“本地化的值和资源”是指我们针对不同语言准备的不同资源,这些资源一般是指文案(字符串),当然也会有一些其他的资源会根据不同语言地区而不同,比如我们需要显示一个APP上架地的国旗图片,那么不同Locale区域我们就需要提供不同的的国旗图片。

支持国际化

默认情况下,Flutter SDK中的组件仅提供美国英语本地化资源(主要是文本)。要添加对其他语言的支持,应用程序须添加一个名为“flutter_localizations”的包依赖,然后还需要在MaterialApp中进行一些配置。 要使用flutter_localizations包,首先需要添加依赖到pubspec.yaml文件中:

dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter

接下来Pub get同步下载flutter_localizations库,然后指定MaterialApplocalizationsDelegatessupportedLocales

import 'package:flutter_localizations/flutter_localizations.dart';

MaterialApp(
  // 本地化的代理类 
 localizationsDelegates: [ 
   GlobalMaterialLocalizations.delegate,
   GlobalWidgetsLocalizations.delegate,
 ],
 //应用支持的语言类型 
 supportedLocales: [
    const Locale('en', 'US'), // 美国英语
    const Locale('zh', 'CN'), // 中文简体
    //其他Locales
  ],
  // ...
)

MaterialApp类为入口的应用不同, 对基于WidgetsApp类为入口的应用程序进行国际化时,不需要GlobalMaterialLocalizations.delegate

  • localizationsDelegates列表中的元素是生成本地化值集合的工厂类。

  • GlobalMaterialLocalizations.delegateMaterial 组件库提供的本地化的字符串和其他值,它可以使Material 组件支持多语言。

  • GlobalWidgetsLocalizations.delegate定义组件默认的文本方向,从左到右或从右到左,这是因为有些语言的阅读习惯并不是从左到右,比如如阿拉伯语就是从右向左的。

  • supportedLocales也接收一个Locale数组,表示我们的应用支持的语言列表,在本例中我们的应用只支持美国英语和中文简体两种语言。

获取当前区域Locale

Locale 类是用来标识用户的语言环境的,它包括语言和国家两个标志如:

MaterialApp(
	...
	locale: const Locale('zh', 'CN'), // 手动指定locale为中文简体, 这将忽略系统设置的locale
}

我们始终可以通过以下方式来获取应用的当前区域Locale

Locale myLocale = Localizations.localeOf(context); //当系统切换语言环境时这个值会更新

Localizations 组件一般位于widget树中其他业务组件的顶部,它的作用是定义区域Locale以及设置子树依赖的本地化资源。 如果系统的语言环境发生变化,则会使用对应语言的本地化资源。

如果localenull,则表示Flutter未能获取到设备的Locale信息,所以我们在使用locale之前一定要先判空。

监听系统语言切换

当我们更改系统语言设置时,APP中的Localizations组件会重新构建,Localizations.localeOf(context) 获取的Locale就会更新,最终界面会重新build达到切换语言的效果。但是这个过程是隐式完成的,我们并没有主动去监听系统语言切换,但是有时我们需要在系统语言发生改变时做一些事,比如系统语言切换为一种我们APP不支持的语言时,我们需要设置一个默认的语言,这时我们就需要监听locale改变事件。

我们可以通过localeResolutionCallbacklocaleListResolutionCallback回调来监听locale改变的事件,我们先看看localeResolutionCallback的回调函数签名:

Locale Function(Locale locale, Iterable<Locale> supportedLocales)
  • 参数locale的值为当前的当前的系统语言设置,当应用启动时或用户动态改变系统语言设置时此locale即为系统的当前locale。当开发者手动指定MaterialApplocale时,那么此locale参数代表开发者指定的locale,此时将忽略系统locale
  • supportedLocales 为当前应用支持的locale列表,是开发者在MaterialApp中通过supportedLocales属性注册的。
  • 返回值是一个Locale,此Locale为Flutter APP最终使用的Locale。通常在不支持的语言区域时返回一个默认的Locale

localeListResolutionCallbacklocaleResolutionCallback唯一的不同就在第一个参数类型,前者接收的是一个Locale列表,而后者接收的是单个Locale

Locale Function(List<Locale> locales, Iterable<Locale> supportedLocales)

在较新的Android系统中,用户可以设置一个语言列表,这样一来,支持多语言的应用就会得到这个列表,应用通常的处理方式就是按照列表的顺序依次尝试加载相应的Locale,如果某一种语言加载成功则会停止。 下面是Android系统中设置语言列表的截图:

在这里插入图片描述

在Flutter中,应该优先使用localeListResolutionCallback,当然你不必担心Android系统的差异性,如果在低版本的Android系统中,Flutter会自动处理这种情况,这时Locale列表只会包含一项。

Localization 组件

Localizations组件用于加载和查找应用当前语言下的本地化值或资源。应用程序通过Localizations.of(context, type) 来引用这些对象。 如果设备的Locale区域设置发生更改,则Localizations 组件会自动加载新区域的Locale值,然后重新build使用(依赖)了它们的组件,之所以会这样,是因为Localizations内部使用了InheritedWidget,我们在介绍该组件时讲过:当子组件的build函数引用了InheritedWidget时,会创建对InheritedWidget的隐式依赖关系。因此,当InheritedWidget发生更改时,即LocalizationsLocale设置发生更改时,将重建所有依赖它的子组件。

本地化值由LocalizationsLocalizationsDelegates 列表加载 。 每个委托必须定义一个异步load() 方法,以生成封装了一系列本地化值的对象。通常这些对象为每个本地化值定义一个方法。

在大型应用程序中,不同模块或Package可能会与自己的本地化值捆绑在一起。 这就是为什么要用Localizations 管理对象表的原因。 要使用由LocalizationsDelegateload方法之一产生的对象,可以指定一个BuildContext和对象的类型来找到它。例如,Material 组件库的本地化字符串由MaterialLocalizations 类定义,此类的实例由MaterialApp 类提供的LocalizationDelegate创建, 它们可以如下方式获取到:

Localizations.of<MaterialLocalizations>(context, MaterialLocalizations);

这个特殊的Localizations.of()表达式会经常使用,所以MaterialLocalizations类提供了一个便捷方法:

static MaterialLocalizations of(BuildContext context) {
    
    
  return Localizations.of<MaterialLocalizations>(context, MaterialLocalizations);
}

// 可以直接调用便捷方法
tooltip: MaterialLocalizations.of(context).backButtonTooltip,

使用打包好的LocalizationsDelegates

为了尽可能小而且简单,flutter软件包中仅提供美国英语值的MaterialLocalizationsWidgetsLocalizations接口的实现。 这些实现类分别称为DefaultMaterialLocalizationsDefaultWidgetsLocalizationsflutter_localizations 包包含GlobalMaterialLocalizationsGlobalWidgetsLocalizations的本地化接口的多语言实现, 国际化的应用程序必须按照本节开头说明的那样为这些类指定本地化的代理类。

上述的GlobalMaterialLocalizationsGlobalWidgetsLocalizations只是Material组件库的本地化实现,如果我们要让自己的布局支持多语言,那么就需要实现自己的Localizations

实现Localizations

我们已经知道Localizations类中主要实现提供了本地化值,如文本:

//Locale资源类
class DemoLocalizations {
    
    
  DemoLocalizations(this.isZh);
  //是否为中文
  bool isZh = false;
  //为了使用方便,我们定义一个静态方法
  static DemoLocalizations of(BuildContext context) {
    
    
    return Localizations.of<DemoLocalizations>(context, DemoLocalizations);
  }
  //Locale相关值,title为应用标题
  String get title {
    
    
    return isZh ? "Flutter应用" : "Flutter APP";
  }
  //... 其他的值  
}

DemoLocalizations中会根据当前的语言来返回不同的文本,如title,我们可以将所有需要支持多语言的文本都在此类中定义。DemoLocalizations的实例将会在Delegate类的load方法中创建。

实现Delegate类

Delegate类的职责是在Locale改变时加载新的Locale资源,所以它有一个load方法,Delegate类需要继承自LocalizationsDelegate类,实现相应的接口,示例如下:

//Locale代理类
class DemoLocalizationsDelegate extends LocalizationsDelegate<DemoLocalizations> {
    
    
  const DemoLocalizationsDelegate();

  //是否支持某个Local
  
  bool isSupported(Locale locale) => ['en', 'zh'].contains(locale.languageCode);

  // Flutter会调用此类加载相应的Locale资源类
  
  Future<DemoLocalizations> load(Locale locale) {
    
    
    print("$locale");
    return SynchronousFuture<DemoLocalizations>(
        DemoLocalizations(locale.languageCode == "zh")
    );
  }

  
  bool shouldReload(DemoLocalizationsDelegate old) => false;
}

shouldReload的返回值决定当Localizations组件重新build时,是否调用load方法重新加载Locale资源。一般情况下,Locale资源只应该在Locale切换时加载一次,不需要每次在Localizations重新build时都加载,所以返回false即可。可能有些人会担心返回false的话在APP启动后用户再改变系统语言时load方法将不会被调用,所以Locale资源将不会被加载。事实上,每当Locale改变时Flutter都会再调用load方法加载新的Locale,无论shouldReload返回true还是false

添加多语言支持

我们现在需要先注册DemoLocalizationsDelegate类,然后再通过DemoLocalizations.of(context)来动态获取当前Locale文本。

只需要在MaterialAppWidgetsApplocalizationsDelegates列表中添加我们的Delegate实例即可完成注册:

MaterialApp(
	localizationsDelegates: [
		 // 本地化的代理类
		 GlobalMaterialLocalizations.delegate,
		 GlobalWidgetsLocalizations.delegate,
		 // 注册我们的Delegate
		 DemoLocalizationsDelegate()
	],
)

接下来我们可以在Widget中使用Locale值:

return Scaffold(
  appBar: AppBar( 
    title: Text(DemoLocalizations.of(context).title), // 使用Locale title  
  ),
  ... //省略无关代码

这样,当在美国英语和中文简体之间切换系统语言时,APP的标题将会分别为“Flutter APP”和“Flutter应用”。

使用 Intl 包

上面的实例有一个严重的不足就是我们需要在DemoLocalizations类中获取title时手动的判断当前语言Locale,然后返回合适的文本。试想一下,当我们要支持的语言不是两种而是8种甚至20几种时,如果为每个文本属性都要分别去判断到底是哪种Locale从而获取相应语言的文本将会是一件非常复杂的事。还有,通常情况下翻译人员并不是开发人员,能不能像i18n或l10n标准那样可以将翻译单独保存为一个arb文件交由翻译人员去翻译,翻译好之后开发人员再通过工具将arb文件转为代码。答案是肯定的!我们将在下面介绍如何通过 Dart intl 包来实现这些。

使用 intl_generator 生成多语言配置

使用 Intl 包我们不仅可以非常轻松的实现国际化,而且也可以将字符串文本分离成单独的文件,方便开发人员和翻译人员分工协作。为了使用Intl 包我们需要添加两个依赖:

dependencies:
  #...省略无关项
  intl: ^0.17.0 
dev_dependencies:
   #...省略无关项
  intl_generator:  0.2.1 

intl_generator 包主要包含了一些工具,它在开发阶段主要主要的作用是从代码中提取要国际化的字符串到单独的arb文件和根据arb文件生成对应语言的dart代码,而intl包主要是引用和加载intl_generator生成后的dart代码。下面我们将一步步来说明如何使用:

第一步:创建必要目录

首先,在项目根目录下创建一个l10n-arb目录,该目录保存我们接下来通过intl_generator命令生成的arb文件。一个简单的arb文件内容如下:

{
    
    
  "@@last_modified": "2018-12-10T15:46:20.897228",
  "@@locale":"zh_CH",
  "title": "Flutter应用",
  "@title": {
    
    
    "description": "Title for the Demo application",
    "type": "text",
    "placeholders": {
    
    }
  }
}

我们根据"@@locale"字段可以看出这个arb对应的是中文简体的翻译,里面的title字段对应的正是我们应用标题的中文简体翻译。@title字段是对title的一些描述信息。

接下来,我们在lib目录下创建一个l10n的目录,该目录用于保存从arb文件生成的dart代码文件。

第二步:实现Localizations和Delegate类

和上一节中的步骤类似,我们仍然要实现LocalizationsDelegate类,不同的是,现在我们在实现时要使用intl包的一些方法(有些是动态生成的)。

下面我们在lib/l10n目录下新建一个“localization_intl.dart”的文件,文件内容如下:

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'messages_all.dart'; // 1

class DemoLocalizations {
    
    
  static Future<DemoLocalizations> load(Locale locale) {
    
    
    final String name = locale.countryCode.isEmpty ? locale.languageCode : locale.toString();
    final String localeName = Intl.canonicalizedLocale(name);
    // 2
    return initializeMessages(localeName).then((b) {
    
    
      Intl.defaultLocale = localeName;
      return DemoLocalizations();
    });
  }

  static DemoLocalizations of(BuildContext context) {
    
    
    return Localizations.of<DemoLocalizations>(context, DemoLocalizations);
  }

  String get title {
    
    
    return Intl.message(
      'Flutter APP',
      name: 'title',
      desc: 'Title for the Demo application',
    );
  }
}

//Locale代理类
class DemoLocalizationsDelegate extends LocalizationsDelegate<DemoLocalizations> {
    
    
  const DemoLocalizationsDelegate();

  //是否支持某个Local
  
  bool isSupported(Locale locale) => ['en', 'zh'].contains(locale.languageCode);

  // Flutter会调用此类加载相应的Locale资源类
  
  Future<DemoLocalizations> load(Locale locale) {
    
    
    // 3
    return  DemoLocalizations.load(locale);
  }

  // 当Localizations Widget重新build时,是否调用load重新加载Locale资源.
  
  bool shouldReload(DemoLocalizationsDelegate old) => false;
}

注意:

  • 注释 1 的"messages_all.dart"文件是通过intl_generator 工具从arb文件生成的代码,所以在第一次运行生成命令之前,此文件不存在。注释 2 处的initializeMessages()方法和"messages_all.dart"文件一样,是同时生成的。
  • 注释 3 处和上一节示例代码不同,这里我们直接调用DemoLocalizations.load()即可。

第三步:添加需要国际化的属性

现在我们可以在DemoLocalizations类中添加需要国际化的属性或方法,如上面示例代码中的title属性,这时我们就要用到Intl库提供的一些方法,这些方法可以帮我们轻松实现不同语言的一些语法特性,如复数语境,举个例子,比如我们有一个电子邮件列表页,我们需要在顶部显示未读邮件的数量,在未读数量不同事,我们展示的文本可能会不同:

在这里插入图片描述

我们可以通过Intl.plural(...)来实现:

remainingEmailsMessage(int howMany) => Intl.plural(howMany,
    zero: 'There are no emails left',
    one: 'There is $howMany email left',
    other: 'There are $howMany emails left',
    name: "remainingEmailsMessage",
    args: [howMany],
    desc: "How many emails remain after archiving.",
    examples: const {
    
    'howMany': 42, 'userName': 'Fred'});

可以看到通过Intl.plural方法可以在howMany值不同时输出不同的提示信息。Intl 包还有一些其他的方法,可以自行查看其文档。

第四步:生成arb文件

现在我们可以通intl_generator 包的工具来提取代码中的字符串到一个arb文件,运行如下命名:

flutter pub pub run intl_generator:extract_to_arb --output-dir=l10n-arb \ lib/l10n/localization_intl.dart

运行此命令后,会将我们之前通过 Intl API标识的属性和字符串提取到根目录下的“l10n-arb/intl_messages.arb”文件中,我们看看其内容:

{
    
    
  "@@last_modified": "2018-12-10T17:37:28.505088",
  "title": "Flutter APP",
  "@title": {
    
    
    "description": "Title for the Demo application",
    "type": "text",
    "placeholders": {
    
    }
  },
  "remainingEmailsMessage": "{howMany,plural, =0{There are no emails left}=1{There is {howMany} email left}other{There are {howMany} emails left}}",
  "@remainingEmailsMessage": {
    
    
    "description": "How many emails remain after archiving.",
    "type": "text",
    "placeholders": {
    
    
      "howMany": {
    
    
        "example": 42
      }
    }
  }
}

这个是默认的Locale资源文件,如果我们现在要支持中文简体,只需要在该文件同级目录创建一个"intl_zh_CN.arb"文件,然后将"intl_messages.arb"的内容拷贝到"intl_zh_CN.arb"文件,接下来将英文翻译为中文即可,翻译后的"intl_zh_CN.arb"文件内容如下:

{
    
    
  "@@last_modified": "2018-12-10T15:46:20.897228",
  "@@locale":"zh_CN",
  "title": "Flutter应用",
  "@title": {
    
    
    "description": "Title for the Demo application",
    "type": "text",
    "placeholders": {
    
    }
  },
  "remainingEmailsMessage": "{howMany,plural, =0{没有未读邮件}=1{有{howMany}封未读邮件}other{有{howMany}封未读邮件}}",
  "@remainingEmailsMessage": {
    
    
    "description": "How many emails remain after archiving.",
    "type": "text",
    "placeholders": {
    
    
      "howMany": {
    
    
        "example": 42
      }
    }
  }
}

我们必须要翻译titleremainingEmailsMessage字段,description是该字段的说明,通常给翻译人员看,代码中不会用到。

有两点需要说明:

  1. 如果某个特定的arb中缺失某个属性,那么应用将会加载默认的arb文件(intl_messages.arb)中的相应属性,这是Intl的托底策略。
  2. 每次运行提取命令时,intl_messages.arb都会根据代码重新生成,但其他arb文件不会,所以当要添加新的字段或方法时,其他arb文件是增量的,不用担心会覆盖。

arb文件是标准的,其格式规范可以自行了解。通常会将arb文件交给翻译人员,当他们完成翻译后,我们再通过下面的步骤根据arb文件生成最终的dart代码。

第五步:生成dart代码

最后一步就是根据arb生成dart文件:

flutter pub pub run intl_generator:generate_from_arb --output-dir=lib/l10n --no-use-deferred-loading lib/l10n/localization_intl.dart l10n-arb/intl_*.arb

这句命令在首次运行时会在"lib/l10n"目录下生成多个文件,对应多种Locale,这些代码便是最终要使用的dart代码。

注意,在windows下执行可能不识别*号通配符会报错,可以在git bash终端执行

如果发现 intl_translationintl 这两个库依赖版本发生冲突,可以暂时可以使用 intl_utils 这个库代替,使用方法:
1.将arb文件以intl_<LOCALE_ISO_CODE>.arb格式放在lib/l10n目录下
2.terminal中执行 flutter pub run intl_utils:generate 会在lib/generate下生成对应dart文件

总结

至此,我们将使用 Intl 包对APP进行国际化的流程介绍完了,我们可以发现,其中第一步和第二步只在第一次需要,而我们开发时的主要的工作都是在第三步。由于最后两步在第三步完成后每次也都需要,所以我们可以将最后两步放在一个shell脚本里,当我们完成第三步或完成arb文件翻译后只需要分别执行该脚本即可。我们在根目录下创建一个intl.sh的脚本,内容为:

flutter pub run intl_generator:extract_to_arb --output-dir=l10n-arb lib/l10n/localization_intl.dart
flutter pub run intl_generator:generate_from_arb --output-dir=lib/l10n --no-use-deferred-loading lib/l10n/localization_intl.dart l10n-arb/intl_*.arb

然后授予执行权限:

chmod +x intl.sh

执行intl.sh

./intl.sh

使用官方自动化生成方案

上面使用 intl_generator 包虽然能自动生成多语言配置的相关dart类,但是发现使用起来有点麻烦和繁琐,我们需要手动编写一部分代码(localization_intl.dart,且在该文件中手动添加国际化的属性还需要学习Intl 的API),并且需要执行很多命令(很容易报错),在实践中该方案还很容易产生不同的package包依赖冲突难以解决。

幸好,官方已经为我们提供了一套更加自动化的生成方案。依然是使用 intl 包,但是不用那么麻烦。

在引入 flutter_localizations package 后,请按照以下说明将本地化的文本添加到您的应用。

  1. intl package 添加为依赖,使用 any 作为 flutter_localizations 的版本值:
 flutter pub add intl:any
  1. 另外,在 pubspec.yaml 文件中,启用 generate 标志。该设置项添加在 pubspec 中 Flutter 部分,通常处在 pubspec 文件中后面的部分。
# The following section is specific to Flutter.
flutter:
  generate: true # Add this line
  1. 在 Flutter 项目的根目录中添加一个新的 yaml 文件,命名为 l10n.yaml,其内容如下:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart

该文件用于配置本地化工具;在上面的示例中,指定输入文件在 ${FLUTTER_PROJECT}/lib/l10n 中,app_en.arb 文件提供模板,生成的本地化文件在 app_localizations.dart 文件中。

  1. ${FLUTTER_PROJECT}/lib/l10n 中,添加 app_en.arb 模板文件。如下:
{
    
    
  "helloWorld": "Hello World!",
  "@helloWorld": {
    
    
    "description": "The conventional newborn programmer greeting"
  }
}
  1. 接下来,在同一目录中添加一个 app_es.arb 文件,对同一条信息做国际化的翻译,如西班牙语:
{
    
    
    "helloWorld": "¡Hola Mundo!"
}
  1. 现在,运行 flutter gen-l10n 命令,您将在 ${FLUTTER_PROJECT}/.dart_tool/flutter_gen/gen_l10n 中看到生成的文件。
    在这里插入图片描述
    app_localizations.dart中将会看到自动生成的AppLocalizations类。

    同样的,你可以在应用没有运行的时候运行那个命令来生成本地化文件。

  2. 在调用 MaterialApp 的构造函数,导入 app_localizations.dart 和使用 AppLocalizations.delegate

import 'package:flutter_gen/gen_l10n/app_localizations.dart';
return const MaterialApp(
  title: 'Localizations Sample App',
  localizationsDelegates: [
    AppLocalizations.delegate, // Add this line
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
    GlobalCupertinoLocalizations.delegate,
  ],
  supportedLocales: [
    Locale('en'), // English
    Locale('es'), // Spanish
  ],
  home: MyHomePage(),
);

AppLocalizations 类也可以自动自动生成 localizationsDelegatessupportedLocales 列表,而无需手动提供它们:

const MaterialApp(
  title: 'Localizations Sample App',
  localizationsDelegates: AppLocalizations.localizationsDelegates,
  supportedLocales: AppLocalizations.supportedLocales,
);
  1. 现在,你可以在应用的任意地方使用 AppLocalizations 了:
appBar: AppBar(
  // title 会根据选择的语言locale自动显示helloWorld对应的语言文本 
  title: Text(AppLocalizations.of(context)!.helloWorld),
),

如果目标设备的语言环境设置为英语,此代码生成的 Text widget 会展示「Hello World!」。如果目标设备的语言环境设置为西班牙语,则展示「Hola Mundo!」,在 arb 文件中,每个条目的键值都被用作 getter 的方法名称,而该条目的值则表示本地化的信息。

要查看以上步骤的完整示例 Flutter 应用,请参阅 gen_l10n_example

如果要设置国际化标题,请使用下面方式:

return MaterialApp(
      ...
      onGenerateTitle: (context) => AppLocalizations.of(context).title,
 )

总结

可以看到与使用 intl_generator 相比,这种方式完全不用自己写任何代码,只要翻译人员为我们提供好了arb文件,拷贝到指定目录下,执行一条简单的命令后就会自动生成dart代码了,接下来只需在MaterialApp中配置后即可使用了。

国际化常见问题

默认语言区域不对

在一些非大陆行货渠道买的一些Android和iOS设备,会出现默认的Locale不是中文简体的情况。这属于正常现象,但是为了防止设备获取的Locale与实际的地区不一致,所有的支持多语言的APP都必须提供一个手动选择语言的入口

如何对应用标题进行国际化

MaterialApp有一个title属性,用于指定APP的标题。在Android系统中,APP的标题会出现在任务管理器中。所以也需要对title进行国际化。但是问题是很多国际化的配置都是在MaterialApp上设置的,我们无法在构建MaterialApp时通过Localizations.of来获取本地化资源,如:

MaterialApp(
  title: DemoLocalizations.of(context).title, //不能正常工作!
  localizationsDelegates: [
    // 本地化的代理类
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
    DemoLocalizationsDelegate() // 设置Delegate
  ],
);

上面代码运行后,DemoLocalizations.of(context).title 是会报错的,原因是Localizations.of会从当前的context沿着widget树向顶部查找DemoLocalizations,但是我们在MaterialApp中设置完DemoLocalizationsDelegate后,实际上DemoLocalizations是在当前context的子树中的,所以DemoLocalizations.of(context)会返回null,报错。那么我们该如何处理这种情况呢?其实很简单,我们只需要设置一个onGenerateTitle回调即可:

MaterialApp(
  onGenerateTitle: (context){
    
    
    // 此时context在Localizations的子树中
    return DemoLocalizations.of(context).title;
  },
  localizationsDelegates: [
    DemoLocalizationsDelegate(),
    ...
  ],
);

如何为英语系的国家指定同一个locale

英语系的国家非常多,如美国、英国、澳大利亚等,这些英语系国家虽然说的都是英语,但也会有一些区别。如果我们的APP只想提供一种英语(如美国英语)供所有英语系国家使用,我们可以在前面介绍的localeListResolutionCallback中来做兼容:

localeListResolutionCallback:
    (List<Locale> locales, Iterable<Locale> supportedLocales) {
    
    
  // 判断当前locale是否为英语系国家,如果是直接返回Locale('en', 'US')     
}

参考:

猜你喜欢

转载自blog.csdn.net/lyabc123456/article/details/130919727