作者 / Very Good Ventures Team

我们 (Very Good Ventures 团队) 与 Google 合作,在今年的 Google I/O 大会上推出了 照相亭互动体验 (I/O Photo Booth)。您可以与深受喜爱的 Google 吉祥物合影: Flutter 的 Dash、Android Jetpack、Chrome 的 Dino 和 Firebase 的 Sparky,并用各种贴纸装饰照片,包括派对帽、披萨、时髦眼镜等。当然,您也可以通过社交媒体下载并分享,或者用作您的个人头像!

△ Flutter 的 Dash、Firebase 的 Sparky、Android Jetpack 和 Chrome 的 Dino

我们使用 Flutter webFirebase 构建了 I/O 照相亭。因为 Flutter 现在支持打造 Web 应用,我们认为这将是一个很好的方式,可以让世界各地的与会者在今年的线上 Google I/O 大会上轻松访问这一应用。Flutter web 消除了必须通过应用商店安装应用的障碍,同时用户还可以灵活选择运行应用的设备: 移动设备、桌面设备或平板电脑。因此,只要能使用浏览器,用户便可无需下载直接使用 I/O 照相亭。

尽管 I/O 照相亭旨在提供 Web 体验,但所有代码均采用与平台无关的架构编写而成。当相机插件等原生功能的支持在各个平台就绪后,这套代码即可在所有平台 (桌面、Web 和移动设备) 通用。

使用 Flutter 构建虚拟照相亭

构建 Web 版 Flutter 相机插件

第一个挑战即在 Web 上为 Flutter 构建摄像头插件。最初,我们联系了 Baseflow 团队,因为他们负责维护现有的开源 Flutter 摄像头插件。Baseflow 致力于构建适用于 iOS 和 Android 的一流摄像头插件支持,我们也很乐于与其合作,使用 联合插件 方法为插件提供 Web 支持。我们尽可能符合官方插件接口,以便我们可以在准备就绪时将其合并回官方插件。

我们确定了两个对于在 Flutter 中构建 I/O 照相亭相机体验至关重要的 API。

  • 初始化摄像头: 应用首先需要访问您的设备摄像头。对于桌面设备,访问的可能是网络摄像头,而对于移动设备,我们选择了访问前置摄像头。我们还提供了 1080p 的预期分辨率,以根据用户设备类型充分提高拍摄质量。
  • 拍照: 我们使用了内置的 HtmlElementView,该控件使用平台视图将原生 Web 元素渲染为 Flutter widget。在此项目中,我们将 VideoElement 渲染为原生 HTML 元素,这便是您在拍照前会在屏幕上看到的内容。我们还使用了一个 CanvasElement,用于在您点击拍照按钮时从媒体流中捕获图像。
Future<CameraImage> takePicture() async {
final videoWidth = videoElement.videoWidth;
final videoHeight = videoElement.videoHeight;
final canvas = html.CanvasElement(
width: videoWidth,
height: videoHeight,
);
canvas.context2D
..translate(videoWidth, 0)
..scale(-1, 1)
..drawImageScaled(videoElement, 0, 0, videoWidth, videoHeight);
final blob = await canvas.toBlob();
return CameraImage(
data: html.Url.createObjectUrl(blob),
width: videoWidth,
height: videoHeight,
);
}

摄像头权限

在 Web 上完成 Flutter 摄像头插件后,我们创建了一个抽象布局,以根据相机权限显示不同的界面。例如,在等待您允许或拒绝使用浏览器摄像头时,或者如果没有可供访问的摄像头时,我们可以显示一条说明性消息。

Camera(
controller: _controller,
placeholder: (_) => const SizedBox(),
preview: (context, preview) => PhotoboothPreview(
preview: preview,
onSnapPressed: _onSnapPressed,
),
error: (context, error) => PhotoboothError(error: error),
)

在上面的抽象布局中,placeholder 会在应用等待您授予摄像头权限时返回初始界面。Preview 则会在您授予权限后返回真实的界面,并显示摄像头的实时视频流。结尾的 Error 构造语句则可以在错误发生时捕获错误并显示相应的消息。

生成镜像照片

我们的下一个挑战是生成镜像照片。如果我们照原样使用摄像头拍摄的照片,那么您看到的内容将与您在照镜子时所看到的内容不一样。某些设备会提供专门处理这一问题的设置选项,所以,如果您用前置摄像头拍照,您看到的其实是照片的镜像版本。

在我们的第一种方法中,我们尝试捕捉默认的摄像头视图,然后围绕 y 轴对其进行 180 度翻转。这种方法似乎有效,但后来我们遇到了 一个问题,即 Flutter 偶尔会覆盖这个翻转,导致视频恢复到未镜像的版本。

在 Flutter 团队的帮助下,我们将 VideoElement 放在 DivElement 中,并更新 VideoElement 以填充 DivElement 的宽度和高度,解决了这个问题。这样一来,我们能够为视频元素应用镜像,同时因为父元素是 div,所以不会被 Flutter 覆盖翻转效果。如此一来,我们便获得了所需的镜像摄像头视图!

△ 未镜像的视图

△ 镜像视图

保持宽高比

在大屏幕上保持 4:3 宽高比,以及在小屏幕上保持 3:4 宽高比,这个操作起来比看起来更难!保持宽高比非常重要,既要符合 Web 应用的整体设计,又要确保在社交媒体上分享照片时,令其中的像素呈现出清晰的本色效果。这是一项具有挑战性的任务,因为不同设备上内置摄像头的宽高比差异很大。

为了强制保持宽高比,应用首先使用 JavaScript getUserMedia API 从设备摄像头请求可能的最大分辨率。随后,我们将此 API 传递到 VideoElement 流中,这便是您在摄像头视图中看到的内容 (当然是已镜像的版本)。我们还应用了 object-fit CSS 属性来确保视频元素能盖住其父级容器。我们使用 Flutter 自带的 AspectRatio widget 来设置宽高比。因此,摄像头不会对显示的宽高比做出任何假设;它始终返回支持的最大分辨率,然后遵守 Flutter 提供的约束条件 (在本例中为 4:3 或 3:4)。

final orientation = MediaQuery.of(context).orientation;
final aspectRatio = orientation == Orientation.portrait
? PhotoboothAspectRatio.portrait
: PhotoboothAspectRatio.landscape;
return Scaffold(
body: _PhotoboothBackground(
aspectRatio: aspectRatio,
child: Camera(
controller: _controller,
placeholder: (_) => const SizedBox(),
preview: (context, preview) => PhotoboothPreview(
preview: preview,
onSnapPressed: () => _onSnapPressed(
aspectRatio: aspectRatio,
),
),
error: (context, error) => PhotoboothError(error: error),
),
),
);

通过拖放添加贴纸

I/O 照相亭的一大重要体验在于与您最喜欢的 Google 吉祥物合影并添加道具。您能够在照片中拖放吉祥物和道具,以及调整大小和旋转,直到获得您喜欢的图像。您也会发现,在将吉祥物添加到屏幕上时,您可以拖动它们并调整其大小。吉祥物们还是有动画效果的——这种效果由 sprite sheet 来实现。

for (final character in state.characters)
DraggableResizable(
canTransform: character.id == state.selectedAssetId,
onUpdate: (update) {
context.read<PhotoboothBloc>().add(
PhotoCharacterDragged(
character: character,
update: update,
),
);
},
child: _AnimatedCharacter(name: character.asset.name),
),

为调整对象的大小,我们创建了可拖动、可调整大小且可以容纳其他 Flutter widget 的 widget,在本例中,即为吉祥物和道具。该 widget 会使用 LayoutBuilder,根据窗口的约束条件来处理 widget 的缩放。在内部,我们使用 GestureDetector 以挂接到 onScaleStart、onScaleUpdate 和 onScaleEnd 事件。这些回调提供了必要的手势详细信息,以反映用户对吉祥物和道具的操作。

通过多个 GestureDetector 回馈的数据,Transform widget 和 4D 矩阵变换即可根据用户所做的各种手势处理缩放,以及旋转吉祥物和道具。

Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..scale(scale)
..rotateZ(angle),
child: _DraggablePoint(...),
)

最后,我们创建了单独的 package 来确定您的设备是否支持触摸输入。可拖动、可调整大小的 widget 会根据触摸功能做出相应的调整。在具有触摸输入功能的设备上,您并不能看到调整大小的锚点和旋转图标,因为您可以通过双指张合和平移手势来直接操纵图像;而在不支持触摸输入的设备 (例如您的桌面设备) 上,我们则添加了锚点和旋转图标,以适应单击和拖动操作。

针对 Web 优化 Flutter

使用 Flutter 针对 Web 进行开发

这是我们使用 Flutter 构建的首批纯 Web 项目之一,其与移动应用具有不同的特征。

我们需要确保该应用对任何设备上的任何浏览器都具有 响应性和自适应性。也就是说,我们必须确保 I/O 照相亭可以根据浏览器大小进行缩放,并且能够处理移动设备和 Web 端的输入。我们通过以下几种方式做到了这一点:

  • 响应式调整大小: 用户能够随意调整浏览器的大小,并且界面能做出响应。如果您的浏览器窗口为纵向,则相机会从 4:3 的横向视图翻转为 3:4 的纵向视图。
  • 响应式设计: 针对桌面浏览器,我们设计为在右侧显示 Dash、Android Jetpack、Dino 和 Sparky,而对于移动设备,这些要素则会显示在顶部。我们针对桌面设备,在摄像头右侧设计使用了抽屉式导航栏,而对于移动设备,则使用了 BottomSheet 类。
  • 自适应输入: 如果您使用桌面设备访问 I/O 照相亭,则鼠标点击操作将被视为输入,如果您使用的是平板电脑或手机,则使用触摸输入。在调整贴纸大小并将其放置在照片中时,这一点尤其重要。移动设备支持双指张合和平移手势,桌面设备支持点击和拖动操作。

可扩展架构

我们还为此应用构建了可扩展的移动应用。我们的 I/O 照相亭在创建之初就具有稳固的基础,包括良好的空安全性、国际化,以及从第一次提交开始就做到的 100% 单元和 widget 测试覆盖率。我们使用 flutter_bloc 进行状态管理,因为它支持我们轻松测试业务逻辑,并观察应用中的所有状态变化。这对于生成开发者日志和确保可追溯性特别有用,因为我们可以准确地观察到从一个状态到另一个状态的变化,并更快地隔离问题。

我们还实现了由功能驱动的单一代码库结构。例如,贴纸、分享和实时相机预览,均在各自的文件夹中得到实现,其中每个文件夹包含其各自的界面组件和业务逻辑。这些功能也会用到外部依赖,例如位于 package 子目录中的相机插件。利用这种架构,我们的团队能够在互不干扰的情况下并行处理多个功能,最大限度地减少合并冲突,并有效地重用代码。例如,界面组件库是名为 photobooth_ui 的单独 package,相机插件也是单独的。

通过将组件分成独立的 package,我们可以提取未与此特定项目绑定的各个组件,并将其开源。与 MaterialCupertino 组件库类似,我们甚至可以将界面组件库 package 做开源处理,以供 Flutter 社区使用。

Firebase + Flutter = 完美组合

Firebase Auth、存储、托管等

照相亭利用 Firebase 生态系统进行各种后端集成。firebase_auth package 支持用户在应用启动后立即匿名登录。每个会话都使用 Firebase Auth 创建具有唯一 ID 的匿名用户。

当您来到共享页面时,此设置即会开始发挥作用。您可以下载照片以保存为个人头像,也可以直接将其分享到社交媒体。如果您下载照片,则该照片将存储在您的本地设备上。如果您分享照片,我们会使用 firebase_storage package 将照片存储在 Firebase 中,以便稍后检索并生成帖子通过社交媒体发布。

我们在 Firebase 的存储分区上定义了 Firebase 安全规则,确保照片在创建后不可变。这可以防止其他用户修改或删除存储分区中的照片。此外,我们使用 Google Cloud 提供的 对象生命周期管理,定义了一个删除 30 天前所有对象的规则,但您可以按照应用中列出的说明请求尽快删除您的照片。

此应用还使用 Firebase Hosting 快速安全地进行托管。我们可以借助 action-hosting-deploy GitHub Action,根据目标分支,将应用自动部署到 Firebase Hosting。当我们将变更合并到主分支时,该操作会触发一个工作流,用于构建应用的特定开发版本,并将其部署到 Firebase Hosting。同样,当我们将变更合并到发布分支时,该操作也会触发部署生产版本。通过结合使用 GitHub Action 与 Firebase Hosting,我们的团队能够快速迭代,并始终得到最新版本的预览。

最后,我们使用 Firebase 性能监测 来监控主要的 Web 性能指标。

使用 Cloud Functions 进行社交

在生成您的社交帖子之前,我们首先会确保照片内容是像素级完美的。最终图像包含漂亮的边框,以呈现 I/O 照相亭特色,并按 4:3 或 3:4 的宽高比进行裁剪,以便在社交帖子上呈现出色的效果。

我们使用 OffscreenCanvas API 或 CanvasElement 来合成原始照片、吉祥物和道具的图层,并生成您可以下载的单个图像。这个处理步骤由 image_compositor package 负责执行。

然后,我们利用 Firebase 强大的 Cloud Functions,来将照片分享到社交媒体。当您点击分享按钮时,系统会带您前往新标签页,并在所选的社交平台上自动生成待发布的帖子。该帖子还包含一个链接,连接到我们编写的 Cloud Functions。浏览器在分析网址时,会检测 Cloud Functions 生成的动态元数据,并据此在您的社交帖子中显示照片的精美预览,以及一个指向分享页面的链接,您的粉丝们可以在该页面上查看照片,并导航回 I/O 照相亭应用,以获取他们自己的照片。

function renderSharePage(imageFileName: string, baseUrl: string): string {
const context = Object.assign({}, BaseHTMLContext, {
appUrl: baseUrl,
shareUrl: `${baseUrl}/share/${imageFileName}`,
shareImageUrl: bucketPathForFile(`${UPLOAD_PATH}/${imageFileName}`),
});
return renderTemplate(shareTmpl, context);
}

成品如下所示:

有关如何在 Flutter 项目中使用 Firebase 的更多信息,请查看 此 Codelab

最终成果

本项目详细地示范了如何针对 Web 来构建应用的方法。令我们感到惊喜的是,与使用 Flutter 构建移动应用的体验相比,这个 Web 应用的构建工作流与之非常相似。我们必须考虑窗口大小、自适应、触摸与鼠标输入、图像加载时间、浏览器兼容性等元素,以及在构建 Web 应用时所必需考虑的其他所有因素。但是,我们仍然可以使用相同的模式、架构和编码标准来编写 Flutter 代码,这让我们在构建 Web 应用时感到非常自在。Flutter package 提供的工具和不断发展的生态系统,包括 Firebase 工具套件,帮助我们实现了 I/O 照相亭。

△ 打造 I/O 照相亭的 Very Good Ventures 团队

我们已经开放了所有源代码,欢迎大家前往 GitHub 查看 photo_booth 项目,也别忘了多多拍照秀出来哦!

用 Flutter 和 Firebase 轻松构建 Web 应用的更多相关文章

  1. 腾讯大牛半年心血高级编程PDF,帮你轻松构建企业级Web应用

    毫无疑问,Java 是这些年来最流行的编程语言之一.它无处不在一计算机. 手机.网站以及各种嵌入式设备中都存在着大量的Java 应用程序,而其中应用最为广泛的应该就是Java EE Web应用程序(以 ...

  2. maven(3)------maven构建web项目详细步骤

    eclipse集成工具,轻松通过maven构建web项目步骤如下: 一, 右键,new -->project, 进入下一页面 二,选择"Maven Project", 点击下 ...

  3. NodeJs+http+fs+request+cheerio 采集,保存数据,并在网页上展示(构建web服务器)

    目的: 数据采集 写入本地文件备份 构建web服务器 将文件读取到网页中进行展示 目录结构: package.json文件中的内容与上一篇一样:NodeJs+Request+Cheerio 采集数据 ...

  4. 使用EXtjs6.2构建web项目

    一.项目简介 众所周知ext是一款非常强大的表格控件,尤其是里边的grid为用户提供了非常多的功能,现在主流的还是用extjs4.0-4.2,但是更高一点的版本更加符合人的审美要求.因此,在今天咱们构 ...

  5. ASP.NET SignalR 与 LayIM2.0 配合轻松实现Web聊天室 实战系列

    ASP.NET SignalR 与 LayIM2.0 配合轻松实现Web聊天室(零) 前言  http://www.cnblogs.com/panzi/p/5742089.html ASP.NET S ...

  6. 《深入浅出Node.js》第8章 构建Web应用

    @by Ruth92(转载请注明出处) 第8章 构建Web应用 一.基础功能 请求方法:GET.POST.HEAD.DELETE.PUT.CONNECT GET /path?foo=bar HTTP/ ...

  7. ASP.NET SignalR 与 LayIM2.0 配合轻松实现Web聊天室(十二) 代码重构使用反射工厂解耦(一)缓存切换

    前言 上一篇中,我们用了反射工厂来解除BLL和UI层耦合的问题.当然那是最简单的解决方法,再复杂一点的程序可能思路相同,但是在编程细节中需要考虑的就更多了,比如今天我在重构过程中遇到的问题.也是接下来 ...

  8. 基于MVC4+EasyUI的Web开发框架经验总结(2)- 使用EasyUI的树控件构建Web界面

    最近花了不少时间在重构和进一步提炼我的Web开发框架上,力求在用户体验和界面设计方面,和Winform开发框架保持一致,而在Web上,我主要采用EasyUI的前端界面处理技术,走MVC的技术路线,在重 ...

  9. localForage——轻松实现 Web 离线存储

    Web 应用程序有离线功能,如保存大量数据集和二进制文件.你甚至可以做缓存 MP3 文件这样的事情.浏览器技术可以保存离线数据和大量的储存.但问题是,如何选择合适技术,如何方便灵活的实现. 如果你需要 ...

  10. ASP.NET SignalR 与 LayIM2.0 配合轻松实现Web聊天室(零) 前言

    前端时间听一个技术朋友说 LayIM 2.0 发布了,听到这个消息抓紧去官网看了一下.(http://layim.layui.com/)哎呀呀,还要购买授权[大家支持一下哦],果断买了企业版,喜欢钻研 ...

随机推荐

  1. 让ABAP开发者更加轻松的若干快捷键

    引言 ABAP是一种和当代编程语言在许多方面有着相当不同的编程语言.ABAP的某些方面可能会让我们奇怪,为什么它会如此复杂?而它的某些方面又是那么杰出,给予了ABAP开发者们比其它任何语言更多的便利. ...

  2. IntelliJ IDEA 乱码解决方案 (项目代码、控制台等)

    IntelliJ IDEA 乱码解决方案 (项目代码.控制台等) 最近IDE从eclipse改成IntelliJ IDEA 了,原因是公司大部分人都在用这个IDE,而且一直推荐用,所以尝尝鲜.换的第一 ...

  3. Android之数据库升级onUpgrade降级onDowngrade

    借用API文档解释: public abstract void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) 当数据库需要升 ...

  4. 我的Android第三章:Android的组件介绍

    小编摘录了Android文档介绍Android四大组件的基本内容,感觉文档的内容写的很详细所以小编将它写入了博客 Android 使用Java语言开发.Android SDK 工具编译代码-以及任意数 ...

  5. js6类和对象

    // 第一种:对象 var person = {};// 或者var obj = new Object(); person.name = "king"; person.age =  ...

  6. java面向对象编程——第六章 数组

    1.数组概述 数组是存储在一个连续的内存块中的元素集合.数组中的每个元素必须是相同的数据类型,并且通过索引进行区分.数组中的第一个元素的索引为0. 在java中,创建数组有两个步骤: 声明一个对数组的 ...

  7. POJ 3311 【状态压缩DP】

    题意: 给n个点,给出矩阵代表i到j单向边的距离. 要求,不介意访问每个点的次数,要求访问完每个点,使得路程总和最小. 思路: 由于不介意访问每个点的次数,所以可以先进行FLOYD求出任意两个点之间的 ...

  8. rails bug

    variable @fontAwesomeEotPath_iefix is undefined rails generate bootstrap:install如果还有错,保证在加载主提之前impor ...

  9. 关于Js脚本的延迟执行

      关于标签,我们有一般会放在两个地方,一个是在标签的内部,另一个是放在前面.这一次我们着重讲解一下放在标签内部.在我们用Js操作DOM元素时,如果不延时执行,DOM元素其实是根本还没有被渲染出来的. ...

  10. 零基础教你写python爬虫

    大家都知道python经常被用来做爬虫,用来在互联网上抓取我们需要的信息. 使用Python做爬虫,需要用到一些包: requests urllib BeautifulSoup 等等,关于python ...