Flutter-秘籍-六-

109 阅读11分钟

Flutter 秘籍(六)

原文:Flutter Recipes

协议:CC BY-NC-SA 4.0

十二、平台集成

在移动应用中,与原生平台集成是很常见的。您可以编写特定于平台的代码来使用本机平台 API。有大量的插件来执行不同的任务。

12.1 读写文件

问题

你想读写文件。

解决办法

使用File API。

讨论

在移动应用中,您可能需要在设备上保存文件。dart:io库提供文件 API 来读写文件。File类有读取内容、写入内容、查询文件元数据的方法。对文件系统的操作可以是同步的,也可以是异步的。大多数这些操作在File类中都有一对方法。异步方法返回一个Future对象,而同步方法使用Sync作为名称后缀并返回实际值。例如,readAsString()readAsStringSync()方法是返回字符串的读操作对。表 12-1 显示了File类的异步方法。

表 12-1

文件的异步方法

|

名字

|

描述

| | --- | --- | | copy(String newPath) | 将此文件复制到新路径。 | | create({bool recursive: false}) | 创建此文件。如果recursive为真,将创建所有目录。 | | open() | 用RandomAccessFile对象打开文件进行随机访问。 | | readAsBytes() | 以字节列表的形式读取整个文件内容。 | | readAsString({Encoding encoding: utf8}) | 使用指定的编码将整个文件内容作为字符串读取。 | | readAsLines(({Encoding encoding: utf8}) | 使用指定的编码将整个文件内容作为文本行读取。 | | writeAsBytes(List<int> bytes) | 将字节列表写入文件。 | | writeAsString(String contents) | 向文件中写入一个字符串。 | | rename(String newPath) | 将此文件重命名为新路径。 | | delete({bool recursive: false}) | 删除此文件。 | | exists() | 检查该文件是否存在。 | | stat() | 返回一个描述文件的FileStat对象。 | | lastAccessed() | 获取该文件的最后访问时间。 | | lastModified() | 获取该文件的最后修改时间。 | | length() | 获取这个文件的长度。 |

Directory类表示文件系统中的目录。给定一个Directory对象,list()listSync()方法可以用来列出文件和子目录。

要创建File对象,可以使用带有路径的默认构造函数。对于 Flutter 应用,路径可能是特定于平台的。有两个存储移动应用文件的常见位置:

  • 用于存储临时文件的临时目录,这些临时文件可以随时清除

  • 用于存储应用私有文件的文档目录,只有在删除应用时才会被清除

要获得这两个位置的特定于平台的路径,可以使用path_provider包( https://pub.dev/packages/path_provider )。这个包提供了获取临时目录路径的getTemporaryDirectory()函数和获取应用文档目录的getApplicationDocumentsDirectory()函数。

在清单 12-1 中,readConfig()方法从应用文档目录中读取config.txt文件,而writeConfig()方法将一个字符串写入同一个文件。

class ConfigFile {
  Future<File> get _configFile async {
    Directory directory = await getApplicationDocumentsDirectory();
    return File('${directory.path}/config.txt');
  }

  Future<String> readConfig() async {
    return _configFile
        .then((file) => file.readAsString())
        .catchError((error) => 'default config');
  }

  Future<File> writeConfig(String config) async {
    File file = await _configFile;
    return file.writeAsString(config);
  }
}

Listing 12-1Read and write files

12.2 存储键值对

问题

您希望存储类型安全的键值对。

解决办法

使用shared_preferences插件。

讨论

您可以使用文件 API 在设备上存储任何数据。使用通用文件 API 意味着您需要自己处理数据序列化和反序列化。如果需要存储的数据是简单的键值对,使用shared_preferences插件( https://pub.dev/packages/shared_preferences )是更好的选择。这个插件提供了一个基于映射的 API 来管理类型安全的键值对。键的类型总是String。只有几种类型可以用作值,包括StringbooldoubleintList<String>

为了管理键值对,您需要使用静态的SharedPreferences.getInstance()方法来获取SharedPreferences对象。表 12-2 显示了SharedPreferences类的方法。对于每种受支持的数据类型,都有一对获取和设置值的方法。例如,getBool()setBool()方法用于获取和设置bool值。

表 12-2

共享首选项的方法

|

名字

|

描述

| | --- | --- | | get(String key) | 读取指定键的值。 | | containsKey(String key) | 检查指定的键是否存在。 | | getKeys() | 拿一套钥匙。 | | remove(String key) | 移除具有指定密钥的密钥对。 | | clear() | 移除所有线对。 | | setString(String key, String value) | 写一个字符串值。 | | getString() | 读取一个字符串值。 |

在清单 12-2 中,SharedPreferences类用于读写一个键值对。

class AppConfig {
  Future<SharedPreferences> _getPrefs() async {
    return await SharedPreferences.getInstance();
  }

  Future<String> getName() async {
    SharedPreferences prefs = await _getPrefs();
    return prefs.getString('name') ?? ";
  }

  Future<bool> setName(String name) async {
    SharedPreferences prefs = await _getPrefs();
    return prefs.setString('name', name);
  }

}

Listing 12-2Use SharedPreferences

12.3 编写特定于平台的代码

问题

您希望编写特定于平台的代码。

解决办法

使用平台通道在 Flutter app 和底层主机平台之间传递消息。

讨论

在 Flutter 应用中,大多数代码都是用平台无关的 Dart 代码编写的。Flutter SDK 提供的功能有限。有时,您可能仍然需要编写特定于平台的代码来使用本机平台 API。一个生成的 Flutter 应用已经在androidios目录中有了特定于平台的代码。构建本机包需要这两个目录中的代码。

Flutter 使用消息传递来调用特定于平台的 API 并返回结果。消息通过平台通道传递。Flutter 代码通过平台通道向主机发送消息。宿主代码侦听平台通道并接收消息。然后,它使用特定于平台的 API 来生成响应,并通过相同的通道将其发送回 Flutter 代码。传递的消息实际上是异步方法调用。

在 Flutter 代码中,使用MethodChannel类创建平台通道。应用中的所有频道名称必须是唯一的。建议使用域名作为频道名称的前缀。要通过信道发送方法调用,这些方法调用在发送之前必须编码为二进制格式,接收到的结果解码为 Dart 值。使用MethodCodec类的子类完成编码和解码:

  • StandardMethodCodec类使用标准二进制编码。

  • JSONMethodCodec类使用 UTF-8 JSON 编码。

MethodChannel构造函数有name参数指定通道名,有codec参数指定MethodCodec对象。默认使用的MethodCodec对象是一个StandardMethodCodec对象。

给定一个MethodChannel对象,invokeMethod()方法使用指定的参数调用通道上的方法。返回值是一个Future<T>对象。此Future对象可能以不同的值完成:

  • 如果方法调用成功,它以结果结束。

  • 如果方法调用失败,它以一个PlatformException结束。

  • 如果该方法还没有实现,它以一个MissingPluginException结束。

invokeListMethod()方法也调用一个方法,但是返回一个Future<List<T>>对象。invokeMapMethod()方法调用一个方法并返回一个Future<Map<K, V>>对象。invokeListMethod()invokeMapMethod()方法都在内部使用invokeMethod(),但是增加了额外的类型转换。

在清单 12-3 中,getNetworkOperator方法在通道上被调用并返回网络操作符。

class NetworkOperator extends StatefulWidget {
  @override
  _NetworkOperatorState createState() => _NetworkOperatorState();
}

class _NetworkOperatorState extends State<NetworkOperator> {
  static const channel = const MethodChannel('flutter-recipes/network');

  String _networkOperator = ";

  @override
  void initState() {
    super.initState();
    _getNetworkOperator();
  }

  Future<void> _getNetworkOperator() async {
    String operator;
    try {
      operator = await channel.invokeMethod('getNetworkOperator') ?? 'unknown';
    } catch (e) {
      operator = 'Failed to get network operator: ${e.message}';
    }

    setState(() {
      _networkOperator = operator;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Center(
        child: Text(_networkOperator),
      ),
    );
  }
}

Listing 12-3
Get network operator

getNetworkOperator方法调用的 handler 需要在 Android 和 iOS 平台都实现。清单 12-4 显示了 Java 的实现。getNetworkOperator()方法使用 Android API 获取网络运营商。在通道的方法调用处理程序中,如果方法名为getNetworkOperator,则使用Result.success()方法将getNetworkOperator()方法的结果作为成功响应发回。如果要发回错误响应,可以使用Result.error()方法。如果方法未知,您应该使用Result.notImplemented()将该方法标记为未实现。

public class MainActivity extends FlutterActivity {
  private static final String CHANNEL = "flutter-recipes/network";

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    GeneratedPluginRegistrant.registerWith(this);

    new MethodChannel(getFlutterView(), CHANNEL)
        .setMethodCallHandler((methodCall, result) -> {
          if ("getNetworkOperator".equals(methodCall.method)) {
            result.success(getNetworkOperator());
          } else {
            result.notImplemented();
          }
        });
  }

  private String getNetworkOperator() {
    TelephonyManager telephonyManager =
        ((TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE));
    return telephonyManager.getNetworkOperatorName();
  }
}

Listing 12-4Android implementation of getNetworkOperator

清单 12-5 显示了 iOS 平台的AppDelegate.swift文件。receiveNetworkOperator()函数使用 iOS API 获取运营商名称,并使用 FlutterResult 作为响应发送回来。

import UIKit
import Flutter
import CoreTelephony

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?
  ) -> Bool {
    GeneratedPluginRegistrant.register(with: self)

    guard let controller = window?.rootViewController as? FlutterViewController else {
      fatalError("rootViewController is not type FlutterViewController")
    }
    let networkChannel = FlutterMethodChannel(name: "flutter-recipes/network", binaryMessenger: controller)
    networkChannel.setMethodCallHandler({
      [weak self] (call: FlutterMethodCall, result: FlutterResult) -> Void in
      guard call.method == "getNetworkOperator" else {
        result(FlutterMethodNotImplemented)
        return
      }
      self?.receiveNetworkOperator(result: result)
    })

    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  private func receiveNetworkOperator(result: FlutterResult) {
    let networkInfo = CTTelephonyNetworkInfo()
    let carrier = networkInfo.subscriberCellularProvider
    result(carrier?.carrierName)

  }
}

Listing 12-5Swift implementation of getNetworkOperator

12.4 创建插件

问题

您希望创建包含特定于平台的代码的可共享插件。

解决办法

使用插件模板创建 Flutter 项目。

讨论

菜谱 12-4 展示了如何为 Flutter 应用添加特定于平台的代码。添加到 Flutter 应用的代码不能在不同的应用之间共享。如果你想让特定平台的代码可重用,你可以创建 Flutter 插件。插件是 Flutter SDK 支持的另一类项目。插件可以像其他 Dart 包一样使用 Dart pub 工具( https://pub.dev/ )共享。

要创建一个新的 Flutter 插件,你可以使用flutter create --template=plugin命令。template=plugin参数意味着使用plugin模板创建一个 Flutter 项目。你可以选择在 Android 上使用 Java 或 Kotlin,在 iOS 上使用 Objective-C 或 Swift。默认情况下,Android 使用 Java,iOS 使用 Objective-C。您可以使用值为javakotlin-a参数来指定 Android 的语言,使用值为objcswift-i参数来指定 iOS 的语言。以下命令显示了如何使用 Swift for iOS 创建插件。

$ flutter create --template=plugin -i swift network

你也可以使用 Android Studio 或者 VS 代码来创建新的插件。

新创建的插件已经有了获取平台版本的框架代码。我们可以使用配方 12-3 中的代码,用新方法实现插件,以获得网络运营商。在生成插件的目录中,有几个子目录:

  • lib目录包含插件的公共 Dart API。

  • android目录包含公共 API 的 Android 实现。

  • ios目录包含公共 API 的 iOS 实现。

  • example目录包含一个使用这个插件的示例 Flutter 应用。

  • test目录包含测试代码。

我们首先在lib/network_plugin.dart文件中定义公共 Dart API。在清单 12-6 中,通过使用方法通道调用getNetworkOperator方法来检索networkOperator属性的值。

class NetworkPlugin {
  static const MethodChannel _channel =
    const MethodChannel('network_plugin');

  static Future<String> get networkOperator async {
    return await _channel.invokeMethod('getNetworkOperator');
  }
}

Listing 12-6Plugin Dart API

清单 12-7 中的NetworkPlugin.java文件是插件的 Android 实现。NetworkPlugin类实现了MethodCallHandler接口来处理从平台通道接收的方法调用。

public class NetworkPlugin implements MethodCallHandler {

  public static void registerWith(Registrar registrar) {
    final MethodChannel channel = new MethodChannel(registrar.messenger(), "network_plugin");
    channel.setMethodCallHandler(new NetworkPlugin(registrar));
  }

  NetworkPlugin(Registrar registrar) {
    this.registrar = registrar;
  }

  private final PluginRegistry.Registrar registrar;

  @Override
  public void onMethodCall(MethodCall call, Result result) {
    if (call.method.equals("getNetworkOperator")) {
      result.success(getNetworkOperator());
    } else {
      result.notImplemented();
    }
  }

  private String getNetworkOperator() {
    Context context = registrar.context();
    TelephonyManager telephonyManager =
        ((TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE));
    return telephonyManager.getNetworkOperatorName();
  }
}

Listing 12-7Android implementation

清单 12-8 中的SwiftNetworkPlugin.swift文件是插件的快速实现。

public class SwiftNetworkPlugin: NSObject, FlutterPlugin {
  public static func register(with registrar: FlutterPluginRegistrar) {
    let channel = FlutterMethodChannel(name: "network_plugin",
      binaryMessenger: registrar.messenger())
    let instance = SwiftNetworkPlugin()
    registrar.addMethodCallDelegate(instance, channel: channel)
  }

  public func handle(_ call: FlutterMethodCall,
      result: @escaping FlutterResult) {
    if (call.method == "getNetworkOperator") {
      self.receiveNetworkOperator(result: result)
    } else {
      result(FlutterMethodNotImplemented)
    }
  }

  private func receiveNetworkOperator(result: FlutterResult) {
    let networkInfo = CTTelephonyNetworkInfo()
    let carrier = networkInfo.subscriberCellularProvider
    result(carrier?.carrierName)
  }
}

Listing 12-8Swift implementation

示例项目和测试代码也需要用新的 API 进行更新。

12.5 显示网页

问题

你想显示网页。

解决办法

使用webview_flutter插件。

讨论

如果你想在 Flutter 应用里面显示网页,可以使用webview_flutter插件( https://pub.dartlang.org/packages/webview_flutter )。将webview_flutter: ⁰.3.6添加到pubspec.yaml文件的依赖项后,您可以使用WebView小部件显示网页并与之交互。对于 iOS,需要将值为YESio.flutter.embedded_views_preview键添加到ios/Runner/Info.plist文件中。

表 12-3 显示了 WebView 构造器的参数。要控制 web 视图,需要使用onWebViewCreated回调来获取WebViewController对象。javascriptMode的值可以是JavascriptMode.disabledJavascriptMode.unrestricted。要在网页中启用 JavaScript 执行,应该将JavascriptMode.unrestricted设置为值。类型为NavigationDelegatenavigationDelegate是一个接受NavigationRequest对象并返回NavigationDecision枚举值的函数。如果返回值为NavigationDecision.prevent,导航请求被阻止。如果返回值为NavigationDecision.navigate,则导航请求可以继续。您可以使用导航委托来阻止用户访问受限页面。onPageFinished回调接收加载页面的 URL。

表 12-3

WebView 构造函数的参数

|

名字

|

描述

| | --- | --- | | initialUrl | 要加载的初始 URL。 | | onWebViewCreated | 创建WebView时回调。 | | javascriptMode | 是否启用了 JavaScript。 | | javascriptChannels | 接收 web 视图中运行的 JavaScript 代码发送的消息的通道。 | | navigationDelegate | 确定是否应该处理导航请求。 | | onPageFinished | 页面加载完成时回调。 | | gestureRecognizers | web 视图识别的手势。 |

获得WebViewController对象后,可以使用表 12-4 所示的方法与 web 视图进行交互。所有这些方法都是异步的,并且返回Future对象。例如,canGoBack()方法返回一个Future<bool>对象。

表 12-4

WebViewController 的方法

|

名字

|

描述

| | --- | --- | | evaluateJavascript(String javascriptString) | 在当前页面的上下文中评估 JavaScript 代码。 | | loadUrl(String url, { Map<String, String> headers } | 加载指定的 URL。 | | reload() | 重新加载当前 URL。 | | goBack() | 回到导航历史中。 | | canGoBack() | 追溯历史是否有效。 | | goForward() | 在导航历史中前进。 | | canGoForward() | 历史前进是否有效。 | | clearCache() | 清除缓存。 | | currentUrl() | 获取当前 URL。 |

清单 12-9 展示了一个使用WebView小部件与 Google 搜索页面交互的例子。因为WebView小部件的创建是异步的,所以使用Completer<WebViewController>对象来捕获WebViewController对象。在onWebViewCreated回调中,Completer<WebViewController>对象被创建的WebViewController对象完成。在onPageFinished回调中,WebViewController对象的evaluateJavascript()方法用于执行 JavaScript 代码,该代码为输入设置值并单击搜索按钮。这导致WebView小部件加载搜索结果页面。

创建的JavascriptChannel对象带有一个通道名和一个JavascriptMessageHandler函数,用于处理从网页中运行的 JavaScript 代码发送的消息。清单 12-9 中的消息处理器使用一个SnackBar小部件来显示接收到的消息。通道名“Messenger”成为一个全局对象,该对象有一个用于 JavaScript 代码的postMessage函数来发回消息。

class GoogleSearch extends StatefulWidget {
  @override
  _GoogleSearchState createState() => _GoogleSearchState();
}

class _GoogleSearchState extends State<GoogleSearch> {
  final Completer<WebViewController> _controller =
      Completer<WebViewController>();

  @override
  Widget build(BuildContext context) {
    return WebView(
      initialUrl: 'https://google.com',
      javascriptMode: JavascriptMode.unrestricted,
      javascriptChannels:
          <JavascriptChannel>[_javascriptChannel(context)].toSet(),
      onWebViewCreated: (WebViewController webViewController) {
        _controller.complete(webViewController);
      },
      onPageFinished: (String url) {
        _controller.future.then((WebViewController webViewController) {
          webViewController.evaluateJavascript(
              'Messenger.postMessage("Loaded in " + navigator.userAgent);');
          webViewController.evaluateJavascript(
              'document.getElementsByName("q")[0].value="flutter";'
              'document.querySelector("button[aria-label*=Search]").click();');
        })

;
      },
    );
  }

  JavascriptChannel _javascriptChannel(BuildContext context) {
    return JavascriptChannel(
        name: 'Messenger',
        onMessageReceived: (JavascriptMessage message) {
          Scaffold.of(context).showSnackBar(
            SnackBar(content: Text(message.message)),
          );
        });
  }

}

Listing 12-9Use WebView

12.6 播放视频

问题

你想玩视频。

解决办法

使用video_player插件。

讨论

如果您想要播放来自素材、文件系统或网络的视频,您可以使用video_player插件( https://pub.dev/packages?q=video_player )。要使用这个插件,你需要将video_player: ⁰.10.0+5添加到pubspec.yaml文件的依赖项中。对于 iOS,你需要使用真实的设备而不是模拟器进行开发和测试。如果您想从任意位置加载视频,您需要将清单 12-10 中的代码添加到ios/Runner/Info.plist文件中。使用 NSAllowsArbitraryLoads 会降低应用的安全性。网络安全最好查一下苹果的指南( https://developer.apple.com/documentation/security/preventing_insecure_network_connections )。

<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsArbitraryLoads</key>
  <true/>
</dict>

Listing 12-10
iOS HTTP security config

如果你需要在 Android 上从网络加载视频,你需要将清单 12-11 中的代码添加到android/app/src/main /AndroidManifest.xml文件中。

<uses-permission android:name="android.permission.INTERNET"/>

Listing 12-11
Android

要播放视频,需要使用表 12-5 所示的构造函数来创建VideoPlayerController对象。

表 12-5

VideoPlayerController 的构造函数

|

名字

|

描述

| | --- | --- | | VideoPlayerController.asset(String dataSource, { String package }) | 播放素材中的视频。 | | VideoPlayerController.file(File file) | 播放本地文件系统中的视频。 | | VideoPlayerController.network(String dataSource) | 播放从网络加载的视频。 |

创建一个VideoPlayerController对象后,可以使用表 12-6 所示的方法控制视频播放。所有这些方法都返回Future对象。必须首先调用initialize()方法来初始化控制器。只有在initialize()方法返回的Future对象成功完成后,才能调用其他方法。

表 12-6

视频播放器控制器的方法

|

名字

|

描述

| | --- | --- | | play() | 播放视频。 | | pause() | 暂停视频。 | | seekTo(Duration moment) | 寻找到指定的位置。 | | setLooping(bool looping) | 是否循环播放视频。 | | setVolume(double volume) | 设置音频的音量。 | | initialize() | 初始化控制器。 | | dispose() | 处置控制器并清理资源。 |

VideoPlayerController类从ValueNotifier<VideoPlayerValue>类扩展而来。通过向状态添加侦听器,您可以在状态改变时得到通知。VideoPlayerValue类包含不同的属性来访问视频的状态。VideoPlayer class 是显示视频的实际小部件。它需要一个VideoPlayerController对象。

清单 12-12 中的VideoPlayerView类是一个小部件,用于播放从指定 URL 加载的视频。在initState()方法中,VideoPlayerController.network()构造函数用于创建VideoPlayerController对象。FutureBuilder widget 使用initialize()方法返回的Future对象构建 UI。由于VideoPlayerController对象也是一个Listenable对象,我们可以将AnimatedBuilderVideoPlayerController对象一起使用。AspectRatio小部件使用aspectRatio属性来确保在播放视频时使用正确的纵横比。VideoProgressIndicator小工具显示进度条,指示视频播放进度。

class VideoPlayerView extends StatefulWidget {
  VideoPlayerView({Key key, this.videoUrl}) : super(key: key);

  final String videoUrl;

  @override
  _VideoPlayerViewState createState() => _VideoPlayerViewState();
}

class _VideoPlayerViewState extends State<VideoPlayerView> {
  VideoPlayerController _controller;
  Future<void> _initializedFuture;

  @override
  void initState() {
    super.initState();
    _controller = VideoPlayerController.network(widget.videoUrl);
    _initializedFuture = _controller.initialize();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: _initializedFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          return AnimatedBuilder(
            animation: _controller,
            child: VideoProgressIndicator(_controller, allowScrubbing: true),
            builder: (context, child) {
              return Column(
                children: <Widget>[
                  AspectRatio(
                    aspectRatio: _controller.value.aspectRatio,
                    child: VideoPlayer(_controller),
                  ),
                  Row(
                    children: <Widget>[
                      IconButton(
                        icon: Icon(_controller.value.isPlaying
                            ? Icons.pause
                            : Icons.play_arrow),
                        onPressed: () {
                          if (_controller.value.isPlaying) {
                            _controller.pause();
                          } else {
                            _controller.play();
                          }
                        },
                      ),
                      Expanded(child: child),
                    ],
                  ),
                ],
              );
            },
          );
        } else {

          return Center(child: CircularProgressIndicator());
        }
      },
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

}

Listing 12-12Playing video

12.7 使用摄像机

问题

你想用相机拍照或者录视频。

解决办法

使用camera插件。

讨论

如果要访问设备上的摄像头,可以使用camera插件( https://pub.dev/packages/camera )。要安装这个插件,你需要将camera: ⁰.5.0添加到pubspec.yaml文件的依赖项中。对于 iOS,您需要将清单 12-13 中的代码添加到ios/Runner/Info.plist文件中。这两个键值对描述了访问摄像头和麦克风的目的。这是保护用户隐私所必需的。

<key>NSCameraUsageDescription</key>
<string>APPNAME requires access to your phone's camera.</string>
<key>NSMicrophoneUsageDescription</key>
<string>APPNAME requires access to your phone's microphone.</string>

Listing 12-13Privacy requirements for iOS

对于 Android,最低 Android SDK 版本需要在android/app/build.gradle文件中设置为 21。

要访问摄像机,您需要创建CameraController对象。CameraController构造函数需要CameraDescriptionResolutionPreset类型的参数。CameraDescription描述一种照相机。ResolutionPreset enum 描述屏幕分辨率的质量。ResolutionPreset是一个值为lowmediumhigh的枚举。要获取CameraDescription对象,您可以使用availableCameras()函数获取类型为List<CameraDescription>的可用摄像机列表。

表 12-7 显示了CameraController类的方法。所有这些方法都返回Future对象。一个CameraController对象需要首先被初始化。其他方法只能在initialize()返回的Future对象成功完成后调用。CameraController类从ValueNotifier<CameraValue>类扩展而来,因此您可以向它添加监听器以获得状态变化的通知。

表 12-7

摄像机控制器的方法

|

名字

|

描述

| | --- | --- | | takePicture(String path) | 拍摄照片并保存到文件中。 | | prepareForVideoRecording() | 准备录像。 | | startVideoRecording(String filePath) | 开始录像并保存到文件中。 | | stopVideoRecording() | 停止当前的视频录制。 | | startImageStream() | 开始图像流。 | | stopImageStream() | 停止当前的图像流。 | | initialize() | 初始化控制器。 | | dispose() | 处置控制器并清理资源。 |

在清单 12-14 中,CameraController对象是用传入的CameraDescription对象创建的。FutureBuilder widget 在CameraController对象初始化后构建实际的 UI。CameraPreview小工具显示相机的实时预览。按下图标时,会拍摄一张照片并保存到临时目录中。

class CameraView extends StatefulWidget {
  CameraView({Key key, this.camera}) : super(key: key);
  final CameraDescription camera;

  @override
  _CameraViewState createState() => _CameraViewState();
}

class _CameraViewState extends State<CameraView> {
  CameraController _controller;
  Future<void> _initializedFuture;

  @override
  void initState() {
    super.initState();
    _controller = CameraController(widget.camera, ResolutionPreset.high);
    _initializedFuture = _controller.initialize();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<void>(
      future: _initializedFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          return Column(

            children: <Widget>[
              Expanded(child: CameraPreview(_controller)),
              IconButton(
                icon: Icon(Icons.photo_camera),
                onPressed: () async {
                  String path = join((await getTemporaryDirectory()).path,
                      '${DateTime.now()}.png');
                  await _controller.takePicture(path);
                  Scaffold.of(context).showSnackBar(
                      SnackBar(content: Text('Picture saved to $path')));
                },
              ),
            ],
          );
        } else {
          return Center(child: CircularProgressIndicator());
        }
      },
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

Listing 12-14Use camera

在清单 12-15 中,availableCameras()函数获得了一个CameraDescription对象的列表,只有第一个用于创建CameraView小部件。

class CameraSelector extends StatelessWidget {
  final Future<CameraDescription> _cameraFuture =
      availableCameras().then((list) => list.first);

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<CameraDescription>(
      future: _cameraFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          if (snapshot.hasData) {
            return CameraView(camera: snapshot.data);
          } else {
            return Center(child: Text('No camera available!'));
          }
        } else {
          return Center(child: CircularProgressIndicator());
        }

      },
    );
  }
}

Listing 12-15Select camera

12.8 使用系统共享表

问题

您希望允许用户使用系统共享表共享项目。

解决办法

使用share插件。

讨论

如果您希望允许用户共享应用中的项目,您可以使用share插件( https://pub.dev/packages/share )来显示系统共享表。要使用这个插件,你需要将share: ⁰.6.1添加到pubspec.yaml文件的依赖项中。

share plugin 提供的 API 非常简单。它只有一个静态的share()方法来共享一些文本。您可以共享纯文本或 URL。清单 12-16 展示了如何使用share()方法共享一个 URL。

Share.share('https://flutter.dev');

Listing 12-16Share a URL

12.9 摘要

Flutter 应用可以使用特定于平台的代码来调用原生平台 API。有大量的社区插件可以在原生平台上使用不同的未来,包括摄像头、麦克风、传感器等等。在下一章,我们将讨论 Flutter 中的各种话题。

十三、杂项

这一章涵盖了 Flutter 的各种课题的食谱。

13.1 使用素材

问题

您希望在应用中捆绑静态素材。

解决办法

使用素材。

讨论

Flutter 应用可以包含代码和静态素材。有两种类型的素材:

  • 数据文件,包括 JSON、XML 和纯文本文件

  • 包括图像和视频的二进制文件

素材在pubspec.yaml文件的flutter/assets部分声明。在构建过程中,这些素材文件被捆绑到应用的二进制文件中。这些素材可以在运行时访问。将素材放在assets目录下是很常见的。在清单 13-1 中,有两个文件在pubspec.yaml文件中被声明为素材。

flutter:
  assets:
    - assets/dog.jpg
    - assets/data.json

Listing 13-1Assets in pubspec.yaml file

在运行时,AssetBundle类的子类用于从资源中加载内容。load()方法检索二进制内容,而loadString()方法检索字符串内容。使用这两种方法时,您需要提供素材键。密钥与pubspec.yaml文件中声明的素材路径相同。静态应用级的rootBundle属性指的是AssetBundle对象,它包含与应用打包在一起的素材。您可以直接使用此属性来加载素材。建议使用静态的DefaultAssetBundle.of()方法从构建上下文中获取AssetBundle对象。

在清单 13-2 中,JSON 文件assets/data.json使用loadString()方法作为字符串加载。

class TextAssets extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FutureBuilder<String>(
      future: DefaultAssetBundle.of(context)
          .loadString('assets/data.json')
          .then((json) {
        return jsonDecode(json)['name'];
      }),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          return Center(child: Text(snapshot.data));
        } else {
          return Center(child: CircularProgressIndicator());
        }
      },
    );
  }
}

Listing 13-2Load string assets

如果素材文件是一个图像,您可以使用带有Image小部件的AssetImage类来显示它。在清单 13-3 中,AssetImage类用于显示assets/dog.jpg图像。

Image(
  image: AssetImage('assets/dog.jpg'),
)

Listing 13-3Use AssetImage

对于图像资源,同一文件通常会有多个分辨率不同的变体。当使用AssetImage类加载素材图像时,将使用与当前设备像素比率最匹配的变量。

在清单 13-4 中,assets/2.0x/dog.jpg文件是assets/dog.jpg的变体,分辨率为2.0。如果设备像素比率为1.6,则使用assets/2.0x/dog.jpg文件。

flutter:
  assets:
    - assets/dog.jpg
    - assets/2.0x/dog.jpg
    - assets/3.0x/dog.jpg

Listing 13-4Image assets variants

13.2 使用手势

问题

您希望允许用户使用手势来执行操作。

解决办法

使用GestureDetector小工具检测手势。

讨论

手机应用的用户习惯于在执行动作时使用手势。例如,在查看图片库时,使用滑动手势可以轻松地在不同图片之间导航。在 Flutter 中,我们可以使用GestureDetector小部件来检测手势,并为手势调用指定的回调。GestureDetector构造函数有大量的参数,为不同的事件提供回调。一个手势可以在其生命周期中调度多个事件。例如,水平拖动的手势可以调度三个事件。以下是这三个事件的处理程序参数:

  • onHorizontalDragStart回调表示指针可能开始水平移动。

  • onHorizontalDragUpdate回调表示指针在水平方向移动。

  • onHorizontalDragEnd回调意味着指针与屏幕接触的时间更长。

不同事件的回调可以接收关于事件的详细信息。在清单 13-5 中,GestureDetector小部件包装了一个Container小部件。在onHorizontalDragEnd回调处理程序中,DragEndDetails对象的velocity属性是指针的移动速度。我们使用这个属性来确定拖动方向。

class SwipingCounter extends StatefulWidget {
  @override
  _SwipingCounterState createState() => _SwipingCounterState();
}

class _SwipingCounterState extends State<SwipingCounter> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Text('$_count'),
        Expanded(
          child: GestureDetector(
            child: Container(
              decoration: BoxDecoration(color: Colors.grey.shade200),
            ),
            onHorizontalDragEnd: (DragEndDetails details) {
              setState(() {
                double dx = details.velocity.pixelsPerSecond.dx;
                _count += (dx > 0 ? 1 : (dx < 0 ? -1 : 0));
              });
            },
          ),
        ),
      ]

,
    );
  }
}

Listing 13-5Use GestureDetector

13.3 支持多个语言环境

问题

您希望应用支持多种语言环境。

解决办法

使用Localizations小部件和LocalizationsDelegate类。

讨论

Flutter 内置了对内部化的支持。如果你想支持多种语言环境,你需要使用Localizations小部件。Localizations类使用一列LocalizationsDelegate对象来加载本地化资源。LocalizationsDelegate<T>类是T类型的一组本地化资源的工厂。本地化资源集通常是一个具有提供本地化值的属性和方法的类。

要创建一个Localizations对象,您需要提供一个Locale对象和一个LocalizationsDelegate对象列表。大多数时候,不需要显式创建一个Localizations对象。WidgetsApp小工具已经创建了一个Localizations对象。WidgetsApp构造函数有被Localizations对象使用的参数。当需要使用本地化值时,可以使用 static Localizations.of<T>(BuildContext context, Type type)方法来获取给定类型的最近的封闭本地化资源对象。

默认情况下,Flutter 只提供美国英语本地化。为了支持其他地区,您需要首先为这些地区添加 Flutter 自己的本地化版本。这是通过将flutter_localizations包添加到pubspec.yaml文件的dependencies中来实现的;见清单 13-6 。有了这个包,您可以使用在MaterialLocalizations类中定义的本地化值。

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

Listing 13-6
flutter_localizations

添加了flutter_localizations包之后,我们需要启用那些本地化的值。在清单 13-7 中,这是通过将GlobalMaterialLocalizations.delegateGlobalWidgetsLocalizations.delegate添加到MaterialApp构造函数的localizationsDelegates列表中来实现的。localizationsDelegates参数的值被传递给Localizations构造函数。supportedLocales参数指定支持的语言环境。

MaterialApp(
  localizationsDelegates: [
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
  ],
  supportedLocales: [
    const Locale('en'),
    const Locale('zh', 'CN'),
  ],
);

Listing 13-7Enable Flutter localized values

在清单 13-8 中,MaterialLocalizations.of()方法从构建上下文中获取MaterialLocalizations对象。copyButtonLabel属性是在MaterialLocalizations类中定义的本地化值。在运行时,按钮的标签取决于设备的区域设置。MaterialLocalizations.of()方法在内部使用Localizations.of()来查找MaterialLocalizations对象。

RaisedButton(
  child: Text(MaterialLocalizations.of(context).copyButtonLabel),
  onPressed: () {},
);

Listing 13-8Use localized values

MaterialLocalizations类只提供了一组有限的本地化值。对于您自己的应用,您需要创建自定义的本地化资源类。清单 13-9 中的AppLocalizations类是一个定制的本地化资源类。AppLocalizations类有appName属性作为简单可本地化字符串的例子。greeting()方法是需要参数的可本地化字符串的一个例子。AppLocalizationsEnAppLocalizationsZhCn类分别是enzh_CN地区的AppLocalizations类的实现。

abstract class AppLocalizations {
  String get appName;
  String greeting(String name);

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

class AppLocalizationsEn extends AppLocalizations {
  @override
  String get appName => 'Demo App';

  @override
  String greeting(String name) {
    return 'Hello, $name';
  }
}

class AppLocalizationsZhCn extends AppLocalizations {
  @override
  String get appName => '示例应用';

  @override
  String greeting(String name) {
    return '你好, $name';
  }
}

Listing 13-9AppLocalizations and localized subclasses

我们还需要创建一个定制的LocalizationsDelegate类来加载AppLocalizations对象。有三种方法需要实现:

  • 方法检查一个区域是否被支持。

  • 方法加载给定地区的本地化资源对象。

  • shouldReload()方法检查是否应该调用load()方法来再次加载资源。

在清单 13-10 的load()方法中,根据给定的区域设置返回AppLocalizationsEnAppLocalizationsZhCn对象。

class _AppLocalizationsDelegate
    extends LocalizationsDelegate<AppLocalizations> {
  const _AppLocalizationsDelegate();

  static const List<Locale> _supportedLocales = [
    const Locale('en'),
    const Locale('zh', 'CN')
  ];

  @override
  bool isSupported(Locale locale) {
    return _supportedLocales.contains(locale);
  }

  @override
  Future<AppLocalizations> load(Locale locale) {
    return Future.value(locale == Locale('zh', 'CN')
        ? AppLocalizationsZhCn()
        : AppLocalizationsEn());
  }

  @override
  bool shouldReload(LocalizationsDelegate<AppLocalizations> old) {
    return false;
  }
}

Listing 13-10
Custom LocalizationsDelegate

_AppLocalizationsDelegate对象需要添加到清单 13-7 中的localizationsDelegates列表中。清单 13-11 展示了一个使用AppLocalizations类的例子。

Text(AppLocalizations.of(context).greeting('John'))

Listing 13-11Use AppLocalizations

13.4 生成翻译文件

问题

您希望从代码中提取可本地化的字符串,并集成翻译后的字符串。

解决办法

使用intl_translation包中的工具。

讨论

配方 13-3 描述了如何使用Localizations小部件和LocalizationsDelegate类支持多种语言环境。配方 13-3 中的解决方案的主要缺点是,您需要为所有支持的地区手动创建本地化的资源类。因为本地化字符串直接嵌入在源代码中,所以很难让翻译人员参与进来。更好的选择是使用intl_translation包提供的工具来自动化这个过程。您需要将intl_translation: ⁰.17.3添加到pubspec.yaml文件的dev_dependencies中。

清单 13-12 显示了新的AppLocalizations类,它具有与清单 13-9 相同的appName属性和greeting()方法。Intl.message()方法描述一个本地化的字符串。只有消息字符串是必需的。像namedescargsexamples这样的参数用于帮助翻译人员理解消息字符串。

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

  String get appName {
    return Intl.message(
      'Demo App',
      name: 'appName',
      desc: 'Name of the app',
    );
  }

  String greeting(String name) {
    return Intl.message(
      'Hello, $name',
      name: 'greeting',
      args: [name],
      desc: 'Greeting message',
      examples: const {'name': 'John'},
    );
  }
}

Listing 13-12AppLocalizations using Intl.message()

现在我们可以使用intl_translation包提供的工具从源代码中提取本地化的消息。下面的命令从lib/app_intl.dart文件中提取用Intl.message()声明的消息,并保存到lib/l10n目录中。运行这个命令后,您应该会在lib/l10n目录中看到生成的intl_messages.arb文件。生成的文件是 ARB(应用资源包)格式( https://github.com/googlei18n/app-resource-bundle ),可以作为 Google Translator Toolkit 等翻译工具的输入。ARB 文件其实是 JSON 文件;您可以简单地使用文本编辑器来修改它们。

$ flutter packages pub run intl_translation:extract_to_arb --locale=en --output-dir=lib/l10n lib/app_intl.dart

现在,您可以为每个受支持的地区复制intl_messages.arb文件并翻译它们。例如,intl_messages_zh.arb文件是zh地区的翻译版本。翻译好文件后,您可以使用以下命令来生成 Dart 文件。运行这个命令后,您应该会看到每个地区的messages_all.dart文件和messages_*.dart文件。

$ flutter packages pub run intl_translation:generate_from_arb --output-dir=lib/l10n --no-use-deferred-loading lib/app_intl.dart lib/l10n/intl_*.arb

文件messages_all.dart中的initializeMessages()函数可以用来初始化给定地区的消息。清单 13-13 中的静态 load()方法首先使用initializeMessages()函数初始化消息,然后设置默认的区域设置。

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

Listing 13-13
Load messages

这个静态的AppLocalizations.load()方法可以被LocalizationsDelegate类的load()方法用来加载AppLocalizations对象。

13.5 绘制自定义元素

问题

您想要绘制自定义元素。

解决办法

使用带有CustomPainterCanvas类的CustomPaint小部件。

讨论

如果想完全自定义一个 widget 的绘制,可以使用CustomPaint widget。CustomPaint widget 提供了一个画布,可以在上面绘制定制元素。表 13-1 显示了CustomPaint构造器的参数。在绘制过程中,painter先在画布上绘制,然后绘制子 widget,最后foregroundPainter在画布上绘制。

表 13-1

CustomPaint 参数

|

名字

|

类型

|

描述

| | --- | --- | --- | | painter | CustomPainter | 在孩子面前画画的画家。 | | foregroundPainter | CustomPainter | 跟在孩子后面画画的画家。 | | size | Size | 要绘制的大小。 | | child | Widget | 子部件。 |

要创建CustomPainter对象,您需要创建CustomPainter的子类并覆盖paint()shouldRepaint()方法。在paint()方法中,canvas参数可以用来绘制自定义元素。Canvas类有一套绘制不同元素的方法;见表 13-2 。

表 13-2

画布的方法

|

名字

|

描述

| | --- | --- | | drawArc() | 画一个弧线。 | | drawCircle() | 用指定的圆心和半径画一个圆。 | | drawImage() | 绘制一个Image对象。 | | drawLine() | 在两点之间画一条线。 | | drawOval() | 画一个椭圆形。 | | drawParagraph() | 绘制文本。 | | drawRect() | 用指定的Rect对象绘制一个矩形。 | | drawRRect() | 画一个圆角矩形。 |

Canvas类中的大多数方法都有一个类型为Paint的参数,用来描述在画布上绘图时使用的样式。在清单 13-14 中,Shapes类在画布上绘制了一个矩形和一个圆形。在CustomShapes小部件中,Text小部件被绘制在Shapes画师的上方。

class CustomShapes extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      width: 300,
      height: 300,
      child: CustomPaint(
        painter: Shapes(),
        child: Center(child: Text('Hello World')),
      ),
    );
  }
}

class Shapes extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Rect rect = Offset(5, 5) & (size - Offset(5, 5));
    canvas.drawRect(
      rect,
      Paint()
        ..color = Colors.red
        ..strokeWidth = 2
        ..style = PaintingStyle.stroke,
    );
    canvas.drawCircle(
      rect.center,
      (rect.shortestSide / 2) - 10,
      Paint()..color = Colors.blue,
    );
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return false;
  }

}

Listing 13-14Use CustomPaint

13.6 自定义主题

问题

你想在 Flutter 应用中自定义主题。

解决办法

ThemeData类用于材质设计,将CupertinoThemeData类用于 iOS。

讨论

定制应用的外观和感觉是一个常见的需求。对于 Flutter apps,如果使用材质设计,可以使用ThemeData类自定义主题。ThemeData类有大量的参数来配置主题的不同方面。MaterialApp类有theme参数来提供ThemeData对象。对于 iOS 风格,CupertinoThemeData类也有同样的目的来指定主题。CupertinoApp类也有CupertinoThemeData类型的theme参数来定制主题。

如果你需要访问当前的主题对象,你可以使用静态的Theme.of()方法来获得最近的封闭的ThemeData对象,用于材质设计中的构建上下文。类似的CupertinoTheme.of()方法可以用于 iOS 风格。

在清单 13-15 中,第一个Text小部件使用当前Theme对象的textTheme.headline属性作为样式。第二个Text小部件使用colorScheme.error属性作为显示错误文本的颜色。

class TextTheme extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Text('Headline', style: Theme.of(context).textTheme.headline),
        Text('Error',
            style: TextStyle(color: Theme.of(context).colorScheme.error)),
      ],
    );
  }
}

Listing 13-15Use Theme

13.7 摘要

本章讨论了在不同场景中有用的各种 Flutter 主题。在下一章,我们将讨论 Flutter 的测试和调试。

十四、测试和调试

本章涵盖了与测试和调试 Flutter 应用相关的方法。

14.1 编写单元测试

问题

你想写单元测试。

解决办法

使用test包中的 API。

讨论

单元测试在应用开发中非常重要。要在 Flutter 应用中编写测试,您需要将test: ¹.5.3添加到pubspec.yaml文件的dev_dependencies部分。测试文件通常放在test目录中。清单 14-1 中的MovingBox类是要测试的类。move()方法更新内部_offset变量。

class MovingBox {
  MovingBox({Offset initPos = Offset.zero}) : _offset = initPos;
  Offset _offset;

  get offset => _offset;

  void move(double dx, double dy) {
    _offset += Offset(dx, dy);
  }
}

Listing 14-1Dart class to test

清单 14-2 显示了MovingBox类的测试。group()函数创建一个组来描述一组测试。test()函数用给定的描述和主体创建一个测试用例。主体是一个使用expect()函数声明期望来验证的函数。要调用expect()函数,您需要提供实际值和一个匹配器来检查该值。匹配器可以是来自matcher包的简单值或函数。常见的匹配器功能有contains()startsWith()endsWith()lessThan()greaterThan()inInclusiveRange()

void main() {
  group('MovingBox', () {
    test('position should be (0.0) by default', () {
      expect(MovingBox().offset, Offset.zero);
    });

    test('postion should be initial value', () {
      expect(MovingBox(initPos: Offset(10, 10)).offset, Offset(10, 10));
    });

    test('postion should be moved', () {
      final box = MovingBox();
      box.move(5, 5);
      expect(box.offset, Offset(5, 5));
      box.move(-1, -1);
      expect(box.offset, Offset(4, 4));
    });
  });
}

Listing 14-2Test of MovingBox

您可以使用async函数作为expect()函数的主体来编写异步测试。在清单 14-3 中,第一个测试用例使用一个带有awaitasync函数来获取一个Future对象的值。在第二个测试案例中,completion()函数等待一个Future对象的完成并验证该值。throwsA()函数验证Future对象抛出了给定的错误。在第三个测试用例中,expectAsync1()函数包装另一个函数来验证结果,并检查其调用次数。

void main() {
  test('future with async', () async {
    var value = await Future.value(1);
    expect(value, equals(1));
  });

  test('future', () {
    expect(Future.value(1), completion(equals(1)));
    expect(Future.error('error'), throwsA(equals('error')));
  });

  test('future callback', () {
    Future.error('error').catchError(expectAsync1((error) {
      expect(error, equals('error'));
    }, count: 1));
  });
}

Listing 14-3Asynchronous tests

您可以使用setUp()函数来添加一个在测试前运行的函数。类似地,tearDown()函数用于添加一个在测试后运行的函数。setUp()函数应该用来准备测试用例运行的环境。tearDown()函数应该用于运行清理任务。setUp()tearDown()函数通常成对出现。在清单 14-4 中,setUp()和 tearDown()函数会被调用两次。

void main() {
  setUp(() {
    print('setUp');
  });

  test('action1', () {
    print('action1');
  });

  test('action2', () {
    print('action2');
  });

  tearDown(() {
    print('tearDown');
  });
}

Listing 14-4setUp() and tearDown() functions

运行清单 14-4 中的测试用例后,输出应该如清单 14-5 所示。

setUp
action1
tearDown
setUp
action2
tearDown

Listing 14-5Output with setUp() and tearDown() functions

14.2 在测试中使用模拟对象

问题

您希望在测试用例中模仿依赖关系。

解决办法

使用mockito包。

讨论

当编写测试用例时,要测试的类可能具有需要外部资源的依赖性。例如,服务类需要访问后端 API 来获取数据。当测试这些类时,您不想使用真正的依赖项。依赖于外部资源,给测试用例的执行带来不确定性,并使它们不稳定。使用实时服务也很难测试所有可能的场景。

更好的方法是创建模拟对象来替换这些依赖关系。使用模拟对象,您可以很容易地模拟不同的场景。模拟对象是类的替代实现。您可以手动创建模拟对象或使用mockito包。要使用mockito包,需要在pubspec.yaml文件的dev_dependencies段添加mockito: ⁴.0.0

清单 14-6 中的GitHubJobsClient类使用http包中的Client类来访问 GitHub Jobs API。

class GitHubJobsClient {
  GitHubJobsClient({@required this.httpClient}) : assert(httpClient != null);

  final http.Client httpClient;

  Future<List<Job>> getJobs(String keyword) async {
    Uri url = Uri.https(
        'jobs.github.com', '/positions.json', {'description': keyword});
    http.Response response = await httpClient.get(url);
    if (response.statusCode != 200) {
      throw Exception('Failed to get job listings');
    }
    return (jsonDecode(response.body) as List<dynamic>)
        .map((json) => Job.fromJson(json))
        .toList();
  }
}

Listing 14-6GitHubJobsClient class to test

为了测试GitHubJobsClient类,我们可以为http.Client对象创建一个模拟对象。在清单 14-7 中,MockHttpClient类是http.Client类的模拟类。在第一个测试用例中,当用指定的Uri对象调用MockHttpClientget()方法时,一个带有 JSON 字符串的Future<Response>对象被用作结果。我们可以验证GitHubJobsClientgetJobs()方法可以解析响应并返回一个包含一个元素的List对象。在第二个测试用例中,MockHttpClientget()方法的返回结果被设置为带有 HTTP 500 错误的Future<Response>。然后,我们通过调用getJobs()方法来验证是否抛出了异常。

import 'package:mockito/mockito.dart';
class MockHttpClient extends Mock implements http.Client {}

void main() {
  group('getJobs', () {
    Uri url = Uri.https(
        'jobs.github.com', '/positions.json', {'description': 'flutter'});

    test('should return list of jobs', () {
      final httpClient = MockHttpClient();
      when(httpClient.get(url))
          .thenAnswer((_) async => http.Response('[{"id": "123"}]', 200));
      final jobsClient = GitHubJobsClient(httpClient: httpClient);
      expect(jobsClient.getJobs('flutter'), completion(hasLength(1)));
    });

    test('should throws an exception', () {
      final httpClient = MockHttpClient();
      when(httpClient.get(url))
          .thenAnswer((_) async => http.Response('error', 500));
      final jobsClient = GitHubJobsClient(httpClient: httpClient);
      expect(jobsClient.getJobs('flutter'), throwsException);
    });
  });
}

Listing 14-7GitHubJobsClient test with mock

14.3 编写小部件测试

问题

您希望编写测试用例来测试小部件。

解决办法

使用flutter_test包。

讨论

使用testmockito包足以编写 Dart 类的测试。然而,你需要使用flutter_test包来为小部件编写测试。flutter_test包已经包含在由flutter create命令创建的新项目的pubspec.yaml文件中。使用testWidgets()函数声明小部件的测试用例。调用testWidgets()时,需要提供一个描述和一个回调,在 Flutter 测试环境内部运行。回调接收一个WidgetTester对象来与小部件和测试环境交互。在创建了被测试的小部件之后,您可以使用Finder对象和匹配器来验证小部件的状态。

表 14-1 显示了WidgetTester类的方法。通过创建要测试的小部件,pumpWidget()方法通常是测试的入口点。当测试有状态小部件时,在改变状态后,需要调用pump()方法来触发重建。如果 widget 使用动画,您应该使用pumpAndSettle()方法等待动画结束。像enterText()ensureVisible()这样的方法使用Finder对象来寻找要交互的小部件。

表 14-1

WidgetTester 方法

|

名字

|

描述

| | --- | --- | | pumpWidget() | 呈现指定的小工具。 | | pump() | 触发导致小部件重建的帧。 | | pumpAndSettle() | 重复调用pump()方法,直到没有帧被调度。 | | enterText() | 在文本输入小部件中输入文本。 | | pageBack() | 关闭当前页面。 | | runAsync() | 异步运行回调。 | | dispatchEvent() | 调度事件。 | | ensureVisible() | 通过滚动其祖先Scrollable小部件使小部件可见。 | | drag() | 按照给定的偏移量拖动小工具。 | | press() | 按下小工具。 | | longPress() | 长按 widget。 | | tap() | 轻按 widget。 |

清单 14-8 中的小部件是要测试的有状态小部件。它有一个TextField小部件来输入文本。当按下按钮时,使用Text小部件显示输入文本的大写字母。

class ToUppercase extends StatefulWidget {
  @override
  _ToUppercaseState createState() => _ToUppercaseState();
}

class _ToUppercaseState extends State<ToUppercase> {
  final _controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Row(
          children: <Widget>[
            Expanded(child: TextField(controller: _controller)),
            RaisedButton(
              child: Text('Uppercase'),
              onPressed: () {
                setState(() {});
              },
            ),
          ],
        ),
        Text((_controller.text ?? ").toUpperCase()),
      ],
    );
  }
}

Listing 14-8Widget to test

清单 14-9 显示了ToUppercase小部件的测试用例。在测试之前,_wrapInMaterial()函数将ToUppercase小部件包装在一个MaterialApp中。这是因为TextField小部件需要一个祖先Material小部件。在测试用例中,首先使用pumpWidget()呈现小部件。find对象是CommonFinders类的顶级常量。它有方便的方法来创建不同种类的Finder对象。这里我们找到了类型为TextField的小部件,并使用enterText()来输入文本“abc”。然后点击RaisedButton小部件,状态改变。触发重建需要pump()方法。最后,我们验证带有文本“ABC”的Text小部件是否存在。

表 14-2

共同点的方法

|

名字

|

描述

| | --- | --- | | byType() | 按类型查找小部件。 | | byIcon() | 通过图标数据找到Icon widgets。 | | byKey() | 通过特定的Key对象查找小部件。 | | byTooltip() | 找到带有给定消息的Tooltip小部件。 | | byWidget() | 通过给定的小部件实例查找小部件。 | | text() | 找到带有给定文本的TextEditableText小部件。 | | widgetWithIcon() | 查找包含带有图标的后代小部件的小部件。 | | widgetWithText() | 查找包含带有给定文本的Text子体的小部件。 |

Widget _wrapInMaterial(Widget widget) {
  return MaterialApp(
    home: Scaffold(
      body: widget,
    ),
  );
}

void main() {
  testWidgets('ToUppercase', (WidgetTester tester) async {
    await tester.pumpWidget(_wrapInMaterial(ToUppercase()));
    await tester.enterText(find.byType(TextField), 'abc');
    await tester.tap(find.byType(RaisedButton));
    await tester.pump();
    expect(find.text('ABC'), findsOneWidget);
  });
}

Listing 14-9Test ToUppercase widget

对象与匹配器一起使用来验证状态。有四个匹配器来处理Finder对象:

  • findsOneWidget期望只找到一个小部件。

  • findsNothing期望找不到任何小部件。

  • findsNWidgets期望找到指定数量的小部件。

  • findsWidgets期望至少找到一个小部件。

14.4 编写集成测试

问题

您希望编写在模拟器或真实设备上运行的集成测试。

解决办法

使用flutter_driver包。

讨论

单元测试和小部件测试只能测试单独的类、函数或小部件。这些测试在开发或测试机器上运行。这些测试不能测试应用不同组件之间的集成。这个场景应该使用集成测试。

集成测试分为两部分。第一部分是部署到模拟器或真实设备的仪表化应用。第二部分是驱动应用和验证应用状态的测试代码。测试中的应用与测试代码相隔离,以避免干扰。

编写集成测试需要flutter_driver包。您需要将flutter_driver包添加到pubspec.yaml文件的dev_dependencies部分;见清单 14-10 。

dev_dependencies:
  flutter_driver:
    sdk: flutter

Listing 14-10Add flutter_driver package

集成测试文件通常放在test_driver目录中。测试的目标是在 GitHub 上搜索职位列表的页面。重要的是提供ValueKey对象作为集成测试需要使用的小部件的key参数。这使得在测试用例中更容易找到这些小部件。在清单 14-11 中,Key('keyword')创建一个名为“keyword”的ValueKey对象。

TextField(
  key: Key('keyword'),
  controller: _controller,
)

Listing 14-11Add key to widget

test_driver目录中的github_jobs.dart文件包含要测试的页面的检测版本。清单 14-12 显示了github_jobs.dart文件的内容。flutter_driver 包中的enableFlutterDriverExtension()函数使 Flutter Driver 能够连接到 app。

void main() {
  enableFlutterDriverExtension();
  runApp(SampleApp());
}

Listing 14-12App to test using Flutter Driver

清单 14-13 显示了github_jobs_test.dart文件的内容。通过在应用文件的名称后添加_test后缀来选择文件名。这是 Flutter 驱动程序用来查找 Dart 文件以运行测试中的应用的约定。在setUpAll()功能中,FlutterDriver.connect()用于连接 app。在测试用例中,findCommonFinders对象的顶层常量,它有方便的方法来创建SerializableFinder对象。byValueKey()方法通过指定的键找到清单 14-11 中的TextField小部件。FlutterDrivertap()方法点击TextField小部件使其获得焦点。然后使用enterText()方法向聚焦的TextField小部件输入搜索关键字。然后点击搜索按钮来触发数据加载。如果数据加载成功,带有jobsList键的ListView小部件可用。waitFor()方法等待ListView小部件出现。

void main() {
  group('GitHub Jobs', () {
    FlutterDriver driver;

    setUpAll(() async {
      driver = await FlutterDriver.connect();
    });

    test('searches by keyword', () async {
      await driver.tap(find.byValueKey('keyword'));
      await driver.enterText('android');
      await driver.tap(find.byValueKey('search'));
      await driver.waitFor(find.byValueKey('jobsList'),
          timeout: Duration(seconds: 5));
    });

    tearDownAll(() {
      if (driver != null) {
        driver.close();
      }
    });
  });
}

Listing 14-13Test using Flutter Driver

现在我们可以使用下面的命令来运行集成测试。Flutter Driver 将应用部署到仿真器或真实设备上,并运行测试代码来验证结果。

$ flutter driver --target=test_driver/github_jobs.dart

表 14-3 显示了FlutterDriver类的方法,这些方法可用于在测试期间与应用交互。如果你想执行自定义动作,你可以在调用enableFlutterDriverExtension()函数时提供一个DataHandler函数。使用requestData()方法发送的消息将由DataHandler处理。

表 14-3

振动驱动器的方法

|

名字

|

描述

| | --- | --- | | enterText() | 在当前聚焦的文本输入中输入文本。 | | getText() | 获取Text小部件中的文本。 | | tap() | 轻敲小工具。 | | waitFor() | 等待 finder 找到一个 widget。 | | waitForAbsent() | 等到 finder 无法再找到 widget。 | | scroll() | 按照给定的偏移量在小工具中滚动。 | | scrollIntoView() | 滚动小部件的Scrollable祖先,直到它可见。 | | scrollUntilVisible(SerializableFinder scrollable, SerializableFinder item) | 反复调用scrollable控件中的scroll(),直到item可见,然后调用item上的scrollIntoView()。 | | traceAction() | 运行操作并返回其性能跟踪。 | | startTracing() | 开始记录性能轨迹。 | | stopTracingAndDownloadTimeline() | 停止记录性能跟踪并下载结果。 | | forceGC() | 来运行垃圾收集。 | | getRenderTree() | 返回当前渲染树的转储。 | | requestData() | 向应用发送消息并接收响应。 | | screenshot() | 截图吧。 |

FlutterDriver类中的方法使用SerializableFinder对象来定位小部件。表 14-4 显示了CommonFinders类创建SerializableFinder对象的方法。这些方法仅支持使用Stringint值作为参数。这是因为值在发送到应用时需要序列化。

表 14-4

flutter_driver 中的公共查找器方法

|

名字

|

描述

| | --- | --- | | byType() | 按类名查找小部件。 | | byValueKey() | 按键查找小部件。 | | byTooltip() | 查找带有给定消息的工具提示的小部件。 | | text() | 找到带有给定文本的TextEditableText小部件。 | | pageBack() | 找到后退按钮。 |

14.5 调试应用

问题

您想要调试应用中发现的问题。

解决办法

使用由 Flutter SDK 提供的 IDE 和实用程序。

讨论

当代码在运行时没有像你预期的那样工作时,你需要调试代码来找出原因。在 ide 的帮助下,调试 Flutter 应用非常简单。您可以在代码中添加断点,并在调试模式下启动应用。

调试代码的另一种常见方法是使用print()函数将输出写入系统控制台。可以使用flutter logs命令查看这些日志。Android Studio 也在控制台视图中显示这些日志。您还可以使用debugPrint()功能来节流输出,以避免日志被 Android 丢弃。

当创建您自己的小部件时,您应该覆盖debugFillProperties()方法来添加定制的诊断属性。这些属性可以在 Flutter 检查器中查看。在清单 14-14 中,DebugWidget具有nameprice属性。在debugFillProperties()方法中,使用DiagnosticPropertiesBuilder对象添加了两个DiagnosticsProperty对象。

class DebugWidget extends StatelessWidget {
  DebugWidget({Key key, this.name, this.price}) : super(key: key);

  final String name;
  final double price;

  @override
  Widget build(BuildContext context) {
    return Text('$name - $price');
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);

    properties.add(StringProperty('name', name));
    properties.add(DoubleProperty('price', price));
  }
}

Listing 14-14debugFillProperties()

基于属性类型,有不同类型的DiagnosticsProperty子类可供使用。表 14-5 显示了常见的DiagnosticsProperty子类。

表 14-5

共同点的方法

|

名字

|

描述

| | --- | --- | | StringProperty | 对于String属性。 | | DoubleProperty | 对于double属性。 | | PercentProperty | 将double属性格式化为百分比。 | | IntProperty | 对于int属性。 | | FlagProperty | 将bool属性格式化为标志。 | | EnumProperty | 对于enum属性。 | | IterableProperty | 对于Iterable属性。 |

14.6 摘要

本章涵盖了与测试和调试 Flutter 应用相关的主题。