本节主要介绍原生和Flutter之间如何共享图像,以及如何在Flutter中嵌套原生组件。
Texture(示例:使用摄像头)
前面说过Flutter本身只是一个UI系统,对于一些系统能力的调用我们可以通过消息传送机制与原生交互。但是这种消息传送机制并不能覆盖所有的应用场景,比如我们想调用摄像头来拍照或录视频,但在拍照和录视频的过程中我们需要将预览画面显示到我们的Flutter UI中,如果我们要用Flutter定义的消息通道机制来实现这个功能,就需要将摄像头采集的每一帧图片都要从原生传递到Flutter中,这样做代价将会非常大,因为将图像或视频数据通过消息通道实时传输必然会引起内存和CPU的巨大消耗!为此,Flutter提供了一种基于Texture的图片数据共享机制。
Texture可以理解为GPU内保存将要绘制的图像数据的一个对象,Flutter engine会将Texture的数据在内存中直接进行映射(而无需在原生和Flutter之间再进行数据传递),Flutter会给每一个Texture分配一个id,同时Flutter中提供了一个Texture组件,Texture构造函数定义如下:
const Texture({
Key key,
@required this.textureId,
})
Texture
组件正是通过textureId
与Texture数据关联起来;在Texture
组件绘制时,Flutter会自动从内存中找到相应id的Texture数据,然后进行绘制。可以总结一下整个流程:图像数据先在原生部分缓存,然后在Flutter部分再通过textureId
和缓存关联起来,最后绘制由Flutter完成。
如果我们作为一个插件开发者,我们在原生代码中分配了textureId
,那么在Flutter侧使用Texture
组件时要如何获取textureId
呢?这又回到了之前的内容了,textureId
完全可以通过MethodChannel来传递。
另外,值得注意的是,当原生摄像头捕获的图像发生变化时,Texture
组件会自动重绘,这不需要我们写任何Dart 代码去控制。
Texture用法
如果我们要手动实现一个相机插件,和前面几节介绍的“获取剩余电量”插件的步骤一样,需要分别实现原生部分和Flutter部分。考虑到大多数读者可能并非同时既了解Android开发,又了解iOS开发,如果我们再花大量篇幅来介绍不同端的实现可能会没什么意义,另外,由于Flutter官方提供的相机(camera)插件和视频播放(video_player)插件都是使用Texture来实现的,它们本身就是Texture非常好的示例,所以在本书中将不会再介绍使用Texture的具体流程了,读者有兴趣查看camera和video_player的实现代码。下面我们重点介绍一下如何使用camera和video_player。
相机示例
下面我们看一下camera包自带的一个示例,它包含如下功能:
- 可以拍照,也可以拍视频,拍摄完成后可以保存;排号的视频可以播放预览。
- 可以切换摄像头(前置摄像头、后置摄像头、其它)
- 可以显示已经拍摄内容的预览图。
下面我们看一下具体代码:
首先,依赖camera插件的最新版,并下载依赖。
dependencies:
... //省略无关代码
camera: ^0.5.2+2
在main方法中获取可用摄像头列表。
void main() async {
// 获取可用摄像头列表,cameras为全局变量
cameras = await availableCameras();
runApp(MyApp());
}
构建UI。现在我们构建如图12-4的测试界面:
下面是完整的代码:
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: public_member_api_docs
import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:video_player/video_player.dart';
class CameraExampleHome extends StatefulWidget {
@override
_CameraExampleHomeState createState() {
return _CameraExampleHomeState();
}
}
/// Returns a suitable camera icon for [direction].
IconData getCameraLensIcon(CameraLensDirection direction) {
switch (direction) {
case CameraLensDirection.back:
return Icons.camera_rear;
case CameraLensDirection.front:
return Icons.camera_front;
case CameraLensDirection.external:
return Icons.camera;
}
throw ArgumentError('Unknown lens direction');
}
void logError(String code, String message) =>
print('Error: $code\nError Message: $message');
class _CameraExampleHomeState extends State<CameraExampleHome>
with WidgetsBindingObserver {
CameraController controller;
String imagePath;
String videoPath;
VideoPlayerController videoController;
VoidCallback videoPlayerListener;
bool enableAudio = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// App state changed before we got the chance to initialize.
if (controller == null || !controller.value.isInitialized) {
return;
}
if (state == AppLifecycleState.inactive) {
controller?.dispose();
} else if (state == AppLifecycleState.resumed) {
if (controller != null) {
onNewCameraSelected(controller.description);
}
}
}
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
@override
Widget build(BuildContext context) {
return Scaffold(
key: _scaffoldKey,
appBar: AppBar(
title: const Text('Camera example'),
),
body: Column(
children: <Widget>[
Expanded(
child: Container(
child: Padding(
padding: const EdgeInsets.all(1.0),
child: Center(
child: _cameraPreviewWidget(),
),
),
decoration: BoxDecoration(
color: Colors.black,
border: Border.all(
color: controller != null && controller.value.isRecordingVideo
? Colors.redAccent
: Colors.grey,
width: 3.0,
),
),
),
),
_captureControlRowWidget(),
_toggleAudioWidget(),
Padding(
padding: const EdgeInsets.all(5.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
_cameraTogglesRowWidget(),
_thumbnailWidget(),
],
),
),
],
),
);
}
/// Display the preview from the camera (or a message if the preview is not available).
Widget _cameraPreviewWidget() {
if (controller == null || !controller.value.isInitialized) {
return const Text(
'Tap a camera',
style: TextStyle(
color: Colors.white,
fontSize: 24.0,
fontWeight: FontWeight.w900,
),
);
} else {
return AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: CameraPreview(controller),
);
}
}
/// Toggle recording audio
Widget _toggleAudioWidget() {
return Padding(
padding: const EdgeInsets.only(left: 25),
child: Row(
children: <Widget>[
const Text('Enable Audio:'),
Switch(
value: enableAudio,
onChanged: (bool value) {
enableAudio = value;
if (controller != null) {
onNewCameraSelected(controller.description);
}
},
),
],
),
);
}
/// Display the thumbnail of the captured image or video.
Widget _thumbnailWidget() {
return Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
videoController == null && imagePath == null
? Container()
: SizedBox(
child: (videoController == null)
? Image.file(File(imagePath))
: Container(
child: Center(
child: AspectRatio(
aspectRatio:
videoController.value.size != null
? videoController.value.aspectRatio
: 1.0,
child: VideoPlayer(videoController)),
),
decoration: BoxDecoration(
border: Border.all(color: Colors.pink)),
),
width: 64.0,
height: 64.0,
),
],
),
),
);
}
/// Display the control bar with buttons to take pictures and record videos.
Widget _captureControlRowWidget() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
mainAxisSize: MainAxisSize.max,
children: <Widget>[
IconButton(
icon: const Icon(Icons.camera_alt),
color: Colors.blue,
onPressed: controller != null &&
controller.value.isInitialized &&
!controller.value.isRecordingVideo
? onTakePictureButtonPressed
: null,
),
IconButton(
icon: const Icon(Icons.videocam),
color: Colors.blue,
onPressed: controller != null &&
controller.value.isInitialized &&
!controller.value.isRecordingVideo
? onVideoRecordButtonPressed
: null,
),
IconButton(
icon: controller != null && controller.value.isRecordingPaused
? Icon(Icons.play_arrow)
: Icon(Icons.pause),
color: Colors.blue,
onPressed: controller != null &&
controller.value.isInitialized &&
controller.value.isRecordingVideo
? (controller != null && controller.value.isRecordingPaused
? onResumeButtonPressed
: onPauseButtonPressed)
: null,
),
IconButton(
icon: const Icon(Icons.stop),
color: Colors.red,
onPressed: controller != null &&
controller.value.isInitialized &&
controller.value.isRecordingVideo
? onStopButtonPressed
: null,
)
],
);
}
/// Display a row of toggle to select the camera (or a message if no camera is available).
Widget _cameraTogglesRowWidget() {
final List<Widget> toggles = <Widget>[];
if (cameras.isEmpty) {
return const Text('No camera found');
} else {
for (CameraDescription cameraDescription in cameras) {
toggles.add(
SizedBox(
width: 90.0,
child: RadioListTile<CameraDescription>(
title: Icon(getCameraLensIcon(cameraDescription.lensDirection)),
groupValue: controller?.description,
value: cameraDescription,
onChanged: controller != null && controller.value.isRecordingVideo
? null
: onNewCameraSelected,
),
),
);
}
}
return Row(children: toggles);
}
String timestamp() => DateTime.now().millisecondsSinceEpoch.toString();
void showInSnackBar(String message) {
_scaffoldKey.currentState.showSnackBar(SnackBar(content: Text(message)));
}
void onNewCameraSelected(CameraDescription cameraDescription) async {
if (controller != null) {
await controller.dispose();
}
controller = CameraController(
cameraDescription,
ResolutionPreset.medium,
enableAudio: enableAudio,
);
// If the controller is updated then update the UI.
controller.addListener(() {
if (mounted) setState(() {});
if (controller.value.hasError) {
showInSnackBar('Camera error ${controller.value.errorDescription}');
}
});
try {
await controller.initialize();
} on CameraException catch (e) {
_showCameraException(e);
}
if (mounted) {
setState(() {});
}
}
void onTakePictureButtonPressed() {
takePicture().then((String filePath) {
if (mounted) {
setState(() {
imagePath = filePath;
videoController?.dispose();
videoController = null;
});
if (filePath != null) showInSnackBar('Picture saved to $filePath');
}
});
}
void onVideoRecordButtonPressed() {
startVideoRecording().then((String filePath) {
if (mounted) setState(() {});
if (filePath != null) showInSnackBar('Saving video to $filePath');
});
}
void onStopButtonPressed() {
stopVideoRecording().then((_) {
if (mounted) setState(() {});
showInSnackBar('Video recorded to: $videoPath');
});
}
void onPauseButtonPressed() {
pauseVideoRecording().then((_) {
if (mounted) setState(() {});
showInSnackBar('Video recording paused');
});
}
void onResumeButtonPressed() {
resumeVideoRecording().then((_) {
if (mounted) setState(() {});
showInSnackBar('Video recording resumed');
});
}
Future<String> startVideoRecording() async {
if (!controller.value.isInitialized) {
showInSnackBar('Error: select a camera first.');
return null;
}
final Directory extDir = await getApplicationDocumentsDirectory();
final String dirPath = '${extDir.path}/Movies/flutter_test';
await Directory(dirPath).create(recursive: true);
final String filePath = '$dirPath/${timestamp()}.mp4';
if (controller.value.isRecordingVideo) {
// A recording is already started, do nothing.
return null;
}
try {
videoPath = filePath;
await controller.startVideoRecording(filePath);
} on CameraException catch (e) {
_showCameraException(e);
return null;
}
return filePath;
}
Future<void> stopVideoRecording() async {
if (!controller.value.isRecordingVideo) {
return null;
}
try {
await controller.stopVideoRecording();
} on CameraException catch (e) {
_showCameraException(e);
return null;
}
await _startVideoPlayer();
}
Future<void> pauseVideoRecording() async {
if (!controller.value.isRecordingVideo) {
return null;
}
try {
await controller.pauseVideoRecording();
} on CameraException catch (e) {
_showCameraException(e);
rethrow;
}
}
Future<void> resumeVideoRecording() async {
if (!controller.value.isRecordingVideo) {
return null;
}
try {
await controller.resumeVideoRecording();
} on CameraException catch (e) {
_showCameraException(e);
rethrow;
}
}
Future<void> _startVideoPlayer() async {
final VideoPlayerController vcontroller =
VideoPlayerController.file(File(videoPath));
videoPlayerListener = () {
if (videoController != null && videoController.value.size != null) {
// Refreshing the state to update video player with the correct ratio.
if (mounted) setState(() {});
videoController.removeListener(videoPlayerListener);
}
};
vcontroller.addListener(videoPlayerListener);
await vcontroller.setLooping(true);
await vcontroller.initialize();
await videoController?.dispose();
if (mounted) {
setState(() {
imagePath = null;
videoController = vcontroller;
});
}
await vcontroller.play();
}
Future<String> takePicture() async {
if (!controller.value.isInitialized) {
showInSnackBar('Error: select a camera first.');
return null;
}
final Directory extDir = await getApplicationDocumentsDirectory();
final String dirPath = '${extDir.path}/Pictures/flutter_test';
await Directory(dirPath).create(recursive: true);
final String filePath = '$dirPath/${timestamp()}.jpg';
if (controller.value.isTakingPicture) {
// A capture is already pending, do nothing.
return null;
}
try {
await controller.takePicture(filePath);
} on CameraException catch (e) {
_showCameraException(e);
return null;
}
return filePath;
}
void _showCameraException(CameraException e) {
logError(e.code, e.description);
showInSnackBar('Error: ${e.code}\n${e.description}');
}
}
class CameraApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CameraExampleHome(),
);
}
}
List<CameraDescription> cameras = [];
Future<void> main() async {
// Fetch the available cameras before initializing the app.
try {
WidgetsFlutterBinding.ensureInitialized();
cameras = await availableCameras();
} on CameraException catch (e) {
logError(e.code, e.description);
}
runApp(CameraApp());
}
如果代码运行遇到困难,请直接查看camera官方文档。
PlatformView (示例:WebView)
如果我们在开发过程中需要使用一个原生组件,但这个原生组件在Flutter中很难实现时怎么办(如webview)?这时一个简单的方法就是将需要使用原生组件的页面全部用原生实现,在flutter中需要打开该页面时通过消息通道打开这个原生的页面。但是这种方法有一个最大的缺点,就是原生组件很难和Flutter组件进行组合。
在 Flutter 1.0版本中,Flutter SDK中新增了AndroidView和UIKitView 两个组件,这两个组件的主要功能就是将原生的Android组件和iOS组件嵌入到Flutter的组件树中,这个功能是非常重要的,尤其是对一些实现非常复杂的组件,比如webview,这些组件原生已经有了,如果Flutter中要用,重新实现的话成本将非常高,所以如果有一种机制能让Flutter共享原生组件,这将会非常有用,也正因如此,Flutter才提供了这两个组件。
由于AndroidView和UIKitView 是和具体平台相关的,所以称它们为PlatformView。需要说明的是将来Flutter支持的平台可能会增多,则相应的PlatformView也将会变多。那么如何使用Platform View呢?我们以Flutter官方提供的webview_flutter插件为例:
注意,在本书写作之时,webview_flutter仍处于预览阶段,如您想在项目中使用它,请查看一下webview_flutter插件最新版本及动态。
原生代码中注册要被Flutter嵌入的组件工厂,如webview_flutter插件中Android端注册webview插件代码:
public static void registerWith(Registrar registrar) {
registrar.platformViewRegistry().registerViewFactory("webview",
WebViewFactory(registrar.messenger()));
}
WebViewFactory的具体实现请参考 webview_flutter 插件的实现源码,在此不再赘述
首先需要添加依赖到pubspec.yaml文件中
dependencies:
webview_flutter: ^0.3.21
在Flutter中使用;打开Flutter中文社区首页。 ```dart // Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file.
// ignore_for_file: public_member_api_docs
import ‘dart:async’; import ‘dart:convert’; import ‘package:flutter/material.dart’; import ‘package:webview_flutter/webview_flutter.dart’;
void main() => runApp(MaterialApp(home: WebViewExample()));
const String kNavigationExamplePage = ‘’’ <!DOCTYPE html>
The navigation delegate is set to block navigation to the youtube website.
‘’’;class WebViewExample extends StatefulWidget { @override _WebViewExampleState createState() => _WebViewExampleState(); }
class _WebViewExampleState extends State
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(‘Flutter WebView example’),
// This drop down menu demonstrates that Flutter widgets can be shown over the web view.
actions:
JavascriptChannel _toasterJavascriptChannel(BuildContext context) { return JavascriptChannel( name: ‘Toaster’, onMessageReceived: (JavascriptMessage message) { Scaffold.of(context).showSnackBar( SnackBar(content: Text(message.message)), ); }); }
Widget favoriteButton() {
return FutureBuilder
enum MenuOptions { showUserAgent, listCookies, clearCookies, addToCache, listCache, clearCache, navigationDelegate, }
class SampleMenu extends StatelessWidget { SampleMenu(this.controller);
final Future
void _onShowUserAgent( WebViewController controller, BuildContext context) async { // Send a message with the user agent string to the Toaster JavaScript channel we registered // with the WebView. await controller.evaluateJavascript( ‘Toaster.postMessage(“User Agent: “ + navigator.userAgent);’); }
void _onListCookies(
WebViewController controller, BuildContext context) async {
final String cookies =
await controller.evaluateJavascript(‘document.cookie’);
Scaffold.of(context).showSnackBar(SnackBar(
content: Column(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children:
void _onAddToCache(WebViewController controller, BuildContext context) async { await controller.evaluateJavascript( ‘caches.open(“test_caches_entry”); localStorage[“test_localStorage”] = “dummy_entry”;’); Scaffold.of(context).showSnackBar(const SnackBar( content: Text(‘Added a test entry to cache.’), )); }
void _onListCache(WebViewController controller, BuildContext context) async { await controller.evaluateJavascript(‘caches.keys()’ ‘.then((cacheKeys) => JSON.stringify({“cacheKeys” : cacheKeys, “localStorage” : localStorage}))’ ‘.then((caches) => Toaster.postMessage(caches))’); }
void _onClearCache(WebViewController controller, BuildContext context) async { await controller.clearCache(); Scaffold.of(context).showSnackBar(const SnackBar( content: Text(“Cache cleared.”), )); }
void _onClearCookies(BuildContext context) async { final bool hadCookies = await cookieManager.clearCookies(); String message = ‘There were cookies. Now, they are gone!’; if (!hadCookies) { message = ‘There are no cookies.’; } Scaffold.of(context).showSnackBar(SnackBar( content: Text(message), )); }
void _onNavigationDelegateExample( WebViewController controller, BuildContext context) async { final String contentBase64 = base64Encode(const Utf8Encoder().convert(kNavigationExamplePage)); await controller.loadUrl(‘data:text/html;base64,$contentBase64’); }
Widget _getCookieList(String cookies) {
if (cookies == null || cookies == ‘“”‘) {
return Container();
}
final List
@override
Widget build(BuildContext context) {
return FutureBuilder
class NavigationControls extends StatelessWidget { const NavigationControls(this._webViewControllerFuture) : assert(_webViewControllerFuture != null);
final Future
@override
Widget build(BuildContext context) {
return FutureBuilder
注意,使用PlatformView的开销是非常大的,因此,如果一个原生组件用Flutter实现的难度不大时,我们应该首选Flutter实现。
另外,PlatformView的相关功能在作者写作时还处于预览阶段,可能还会发生变化,因此,读者如果需要在项目中使用的话,应查看一下最新的文档。