Flutter 秘籍(六)
十二、平台集成
在移动应用中,与原生平台集成是很常见的。您可以编写特定于平台的代码来使用本机平台 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。只有几种类型可以用作值,包括String、bool、double、int和List<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 应用已经在android和ios目录中有了特定于平台的代码。构建本机包需要这两个目录中的代码。
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。您可以使用值为java和kotlin的-a参数来指定 Android 的语言,使用值为objc和swift的-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,需要将值为YES的io.flutter.embedded_views_preview键添加到ios/Runner/Info.plist文件中。
表 12-3 显示了 WebView 构造器的参数。要控制 web 视图,需要使用onWebViewCreated回调来获取WebViewController对象。javascriptMode的值可以是JavascriptMode.disabled或JavascriptMode.unrestricted。要在网页中启用 JavaScript 执行,应该将JavascriptMode.unrestricted设置为值。类型为NavigationDelegate的navigationDelegate是一个接受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对象,我们可以将AnimatedBuilder与VideoPlayerController对象一起使用。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构造函数需要CameraDescription和ResolutionPreset类型的参数。CameraDescription描述一种照相机。ResolutionPreset enum 描述屏幕分辨率的质量。ResolutionPreset是一个值为low、medium和high的枚举。要获取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.delegate和GlobalWidgetsLocalizations.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()方法是需要参数的可本地化字符串的一个例子。AppLocalizationsEn和AppLocalizationsZhCn类分别是en和zh_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()方法中,根据给定的区域设置返回AppLocalizationsEn或AppLocalizationsZhCn对象。
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()方法描述一个本地化的字符串。只有消息字符串是必需的。像name、desc、args和examples这样的参数用于帮助翻译人员理解消息字符串。
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 绘制自定义元素
问题
您想要绘制自定义元素。
解决办法
使用带有CustomPainter和Canvas类的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 中,第一个测试用例使用一个带有await的async函数来获取一个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对象调用MockHttpClient的get()方法时,一个带有 JSON 字符串的Future<Response>对象被用作结果。我们可以验证GitHubJobsClient的getJobs()方法可以解析响应并返回一个包含一个元素的List对象。在第二个测试用例中,MockHttpClient的get()方法的返回结果被设置为带有 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包。
讨论
使用test和mockito包足以编写 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() | 找到带有给定文本的Text和EditableText小部件。 |
| 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。在测试用例中,find是CommonFinders对象的顶层常量,它有方便的方法来创建SerializableFinder对象。byValueKey()方法通过指定的键找到清单 14-11 中的TextField小部件。FlutterDriver的tap()方法点击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对象的方法。这些方法仅支持使用String或int值作为参数。这是因为值在发送到应用时需要序列化。
表 14-4
flutter_driver 中的公共查找器方法
|名字
|
描述
|
| --- | --- |
| byType() | 按类名查找小部件。 |
| byValueKey() | 按键查找小部件。 |
| byTooltip() | 查找带有给定消息的工具提示的小部件。 |
| text() | 找到带有给定文本的Text和EditableText小部件。 |
| pageBack() | 找到后退按钮。 |
14.5 调试应用
问题
您想要调试应用中发现的问题。
解决办法
使用由 Flutter SDK 提供的 IDE 和实用程序。
讨论
当代码在运行时没有像你预期的那样工作时,你需要调试代码来找出原因。在 ide 的帮助下,调试 Flutter 应用非常简单。您可以在代码中添加断点,并在调试模式下启动应用。
调试代码的另一种常见方法是使用print()函数将输出写入系统控制台。可以使用flutter logs命令查看这些日志。Android Studio 也在控制台视图中显示这些日志。您还可以使用debugPrint()功能来节流输出,以避免日志被 Android 丢弃。
当创建您自己的小部件时,您应该覆盖debugFillProperties()方法来添加定制的诊断属性。这些属性可以在 Flutter 检查器中查看。在清单 14-14 中,DebugWidget具有name和price属性。在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 应用相关的主题。