使用 Chrome 构建

将 LEGO® 积木引入多设备网络

Hans Eklund
Hans Eklund

Build with Chrome 是专为桌面版 Chrome 用户推出的一项趣味实验,最初在澳大利亚推出,于 2014 年重新发布,其功能覆盖全球、与 LEGO® MOVIETM 结合,并且增加了对移动设备的支持。在本文中,我们将分享该项目的一些经验,尤其是关于从仅支持桌面设备的体验迁移到支持鼠标和触摸输入的跨屏解决方案的转变。

Build with Chrome 的历史

Build with Chrome 于 2012 年在澳大利亚发布。我们希望以全新的方式展示网络的力量,并将 Chrome 带给全新的受众群体。

该网站分为两个主要部分:“建造”模式和“探索”模式,用户可以在其中使用乐高积木搭建各种作品,而“探索”模式则用于浏览由乐高积木打造的 Google 地图版本。

为了向用户提供最佳的乐高搭建体验,互动式 3D 是必不可少的。2012 年,WebGL 只面向桌面浏览器公开发布,因此 Build 的设计目标是提供仅限桌面设备的体验。探索使用 Google 地图来显示创作,但当缩放到足够近时,它切换到了以 3D 形式显示创作的地图的 WebGL 实现,同时仍使用 Google 地图作为底板纹理。我们希望营造出一个环境,让各个年龄段的乐高爱好者都能轻松、直观地表达他们的创造力,并一起探索彼此的创意。

2013 年,我们决定将 Build with Chrome 扩展到新的网络技术。其中一项技术就是 Chrome(Android 版)的 WebGL,这自然让 Build with Chrome 得以进化为移动体验。首先,我们先开发触摸原型,然后再向“构建器工具”询问硬件,以了解与移动应用相比,我们通过浏览器可能会遇到的手势行为和触觉响应能力。

响应式前端

我们需要支持同时具有触摸和鼠标输入的设备。然而,由于空间限制,在小触摸屏上使用相同的界面,结果并不是理想的解决方案。

在 Build 中,我们提供了许多互动功能:缩放、更改砖块颜色,当然还有选择、旋转和放置积木。用户经常需要在这款工具中花费大量时间,因此,务必要让用户能快速访问经常使用的所有内容,而且他们应该能够轻松自如地与之互动。

在设计高度互动的触摸应用时,您会发现屏幕很快就感觉很小,而且用户在互动时用手指通常会覆盖很多屏幕。使用 Builder 时,这一点对我们来说显而易见。在进行设计时,您确实必须考虑物理屏幕尺寸,而不是图形中的像素。请务必尽量减少按钮和控件的数量,以尽可能多地占据实际内容的空间。

我们的目标是让 Build 在触摸设备上显得自然,不仅仅是将触控输入添加到原始的桌面实现,而是让构建看起来像真正适用于触摸。我们最终设计了两个界面变体,一个适用于大屏幕的桌面设备和平板电脑,另一个适用于屏幕较小的移动设备。如有可能,最好使用单一实现,并在模式之间实现流畅的过渡。在我们的示例中,我们确定这两种模式之间的体验存在如此显著的差异,我们决定依赖于特定的断点。这两个版本有许多共同的功能,我们试图仅使用一个代码实现完成大多数工作,但这两个版本的界面的某些方面工作方式不同。

我们使用用户代理数据检测移动设备,然后检查视口大小,以决定是否应使用小屏幕的移动界面。为“大屏幕”应选择断点并非易事,因为很难获得可靠的物理屏幕尺寸值。幸运的是,在我们的示例中,在大屏幕的触摸设备上显示小屏幕界面并不重要,因为该工具仍然可以正常运行,只是有些按钮可能会让人觉得有点过大。最后,我们将断点设置为 1000 像素;如果您从宽度大于 1000 像素的窗口(在横屏模式下)加载网站,您将获得大屏幕版本。

我们来简单介绍一下两种屏幕尺寸和体验:

大屏幕,支持鼠标和触摸功能

大屏幕版本可投放到所有支持鼠标的桌面设备,以及大屏幕的触摸设备(如 Google Nexus 10)。此版本在可用的导航控件类型方面接近原始桌面解决方案,但是我们添加了触摸支持和一些手势。我们根据窗口大小调整界面,因此当用户调整窗口大小时,可能会移除部分界面或调整其大小。我们通过 CSS 媒体查询来完成这项工作。

示例:当可用高度小于 730 像素时,“探索”模式下的缩放滑块控件会隐藏:

@media only screen and (max-height: 730px) {
    .zoom-slider {
        display: none;
    }
}

小屏幕,仅限触控

此版本适用于移动设备和小型平板电脑(目标设备 Nexus 4 和 Nexus 7)。此版本需要多点触控支持。

在小屏幕设备上,我们需要为内容提供尽可能大的屏幕空间,因此我们进行了一些调整以最大限度地利用空间,主要是将不常用的元素移出视线:

  • 在建造时,Build 砖块选择器会最小化为颜色选择器。
  • 我们已将缩放和方向控件替换为多点触控手势。
  • Chrome 的全屏功能也有助于获得额外的屏幕空间。
针对大屏设备开发应用
在大屏设备上构建应用。砖块选择器始终可见,并且右侧有多个控件。
在小屏幕上构建
在小屏幕上构建应用。系统已最小化块选择器,并移除了部分按钮。

WebGL 性能和支持

现代触摸设备都配备了非常强大的 GPU,但它们与桌面设备上的 GPU 相差甚远,因此我们深知性能方面将会面临一些挑战,尤其是在“探索 3D”模式下,我们需要同时渲染大量作品。

我们想要发挥创造力,添加几种形状复杂甚至透明的新型砖块,这些块在 GPU 上通常会受到很大的负担。但是,我们必须实现向后兼容性,并继续在第一个版本的基础上支持创作,因此我们无法设置任何新的限制,例如大幅减少作品中的积木总数。

在 Build 的第一个版本中,一次创作中可以使用的积木数量存在上限。这里有一个“积木仪”图标,用来指示剩余的积木数量。在新实现中,一些新积木对砖仪的影响程度比标准积木更大,因此稍微减少了最大积木总数。这是在添加新积木的同时仍能保持良好性能的一种方式。

在“探索 3D”模式下,系统可同时执行多项操作,例如加载底板纹理、加载创建的内容、对创建的内容进行动画和渲染,等等。这需要 GPU 和 CPU 执行很多工作,因此我们在 Chrome DevTools 中进行了很多帧性能分析,以尽可能地优化这些部分。在移动设备上,我们决定将屏幕放大一点,这样就不必同时渲染太多作品了。

之前,有些设备需要我们重新审视并简化一些 WebGL 着色器,但我们总能找到找到解决方案的方法,在努力解决问题并不断进步。

支持非 WebGL 设备

我们希望即使访问者的设备不支持 WebGL,网站也应该具有一定的易用性。有时,可以使用画布解决方案或 CSS3D 功能以简化方式表示 3D。遗憾的是,我们没有找到一个足够好的解决方案,无法在不使用 WebGL 的情况下复制 Build 和 3D 功能。

为了保持一致性,内容在所有平台上的视觉风格必须相同。我们本来可以尝试 2.5D 解决方案,但这样会导致创建的内容在某些方面有所不同。我们还必须考虑如何确保使用第一版 Build with Chrome 构建的内容在新版网站中看上去没有什么不同,并且运行在新版网站中也与在第一版网站中一样顺畅。

非 WebGL 设备仍然可以使用“探索 2D”模式,即使无法创建新作品,也无法以 3D 形式进行探索。因此,如果用户使用的是支持 WebGL 的设备,他们仍然可以了解该项目的深度,以及他们可以使用此工具创作什么。如果没有 WebGL 支持,网站可能没那么有价值,但至少应该做个宣传片,引导他们试一试。

有时无法保留 WebGL 解决方案的后备版本。可能的原因有很多,包括性能、视觉样式、开发和维护成本等。不过,如果您决定不实现回退机制,则至少应该照顾好未使用 WebGL 的访问者,说明他们无法完全访问网站的原因,并说明他们如何通过使用支持 WebGL 的浏览器解决问题。

资产管理

2013 年,Google 推出了新版 Google 地图,界面自推出以来最重大。因此,我们决定对 Build with Chrome 进行重新设计,以适应新的 Google 地图界面,并且在这个过程中将其他因素纳入了重新设计。新的设计比较扁平,采用简洁的纯色和简单的形状。这使得我们可以在许多界面元素上使用纯 CSS,从而最大限度地减少使用图像。

在“探索”中,我们需要加载大量图片;作品的缩略图,为底板绘制纹理,最后是实际的 3D 作品。我们格外谨慎,确保在不断加载新图片时不会发生内存泄漏。

3D 创作以自定义文件格式存储,并打包为 PNG 图片。通过保留以图像形式存储的 3D 作品数据,我们基本上能够将数据直接传递给渲染作品的着色器。

对于所有用户生成的图片,该设计使我们能够针对所有平台使用相同的图片大小,从而最大限度地减少存储空间和带宽用量。

管理屏幕方向

当从竖屏模式切换到横屏模式(或从横屏模式切换到竖屏模式)时,您很容易忘记屏幕宽高比的变化幅度。在针对移动设备进行调整时,您需要从一开始就考虑到这一点。

在启用了滚动功能的传统网站上,您可以应用 CSS 规则来获取能够重新排列内容和菜单的自适应网站。只要可以使用滚动功能,情况就相当容易管理。

我们也在 Build 中使用了这种方法,但在解决布局问题方面存在一些限制,因为我们需要始终显示内容,并且仍然能够快速访问许多控件和按钮。对于新闻网站等纯内容网站,流畅的布局非常合理,但对于像我们这样的游戏应用来说,这并非易事。想要找到一种在横屏和竖屏都能搞定的布局,同时又能很好地掌握内容概况并轻松自如地进行互动就变得很困难。最后,我们决定将 build 保持为横向模式,并通知用户旋转设备。

在这两种屏幕方向中,“探索”的解法都容易得多。我们只需根据屏幕方向调整 3D 的缩放级别,即可获得一致的体验。

大部分内容布局由 CSS 控制,但有些与方向相关的操作需要在 JavaScript 中实现。我们发现,使用 window.orientation 来识别屏幕方向,并没有什么很好的跨设备解决方案,所以最后我们只通过比较 window.innerWidth 和 window.innerHeight 来确定设备的屏幕方向。

if( window.innerWidth > window.innerHeight ){
  //landscape
} else {
  //portrait
}

添加触控支持

为 Web 内容添加触摸支持相当简单。基本互动(例如点击事件)在桌面设备和支持触摸的设备上的运作方式相同,但涉及更高级的互动时,您还需要处理触摸事件:touchstart、touchmove 和 touchend。这篇文章介绍了有关如何使用这些事件的基础知识。Internet Explorer 不支持触摸事件,而是使用指针事件(pointerdown、 pointermove、pointsup)。指针事件已提交给 W3C 进行标准化,但目前仅在 Internet Explorer 中实现。

在“探索 3D”模式下,我们希望实现与标准 Google 地图实现相同的导航功能:使用单指平移地图,使用双指张合进行缩放。由于这些作品是 3D 形式的,我们还添加了双指旋转手势。这通常需要使用触摸事件。

一种比较好的做法是避免进行繁重的计算,例如在事件处理脚本中更新或渲染 3D。请改为将触控输入存储在变量中,并在 requestAnimationFrame 渲染循环中对输入做出响应。这也更便于同时实现鼠标,您只需将相应的鼠标值存储在相同的变量中即可。

首先初始化用于存储输入内容的对象,并添加 touchstart 事件监听器。在每个事件处理脚本中,我们都调用 event.preventDefault()。这是为了防止浏览器继续处理轻触事件,这可能会导致一些意外行为,例如滚动或缩放整个页面。

var input = {dragStartX:0, dragStartY:0, dragX:0, dragY:0, dragDX:0, dragDY:0, dragging:false};
plateContainer.addEventListener('touchstart', onTouchStart);

function onTouchStart(event) {
  event.preventDefault();
  if( event.touches.length === 1){
    handleDragStart(event.touches[0].clientX , event.touches[0].clientY);
    //start listening to all needed touchevents to implement the dragging
    document.addEventListener('touchmove', onTouchMove);
    document.addEventListener('touchend', onTouchEnd);
    document.addEventListener('touchcancel', onTouchEnd);
  }
}

function onTouchMove(event) {
  event.preventDefault();
  if( event.touches.length === 1){
    handleDragging(event.touches[0].clientX, event.touches[0].clientY);
  }
}

function onTouchEnd(event) {
  event.preventDefault();
  if( event.touches.length === 0){
    handleDragStop();
    //remove all eventlisteners but touchstart to minimize number of eventlisteners
    document.removeEventListener('touchmove', onTouchMove);
    document.removeEventListener('touchend', onTouchEnd);
    //also listen to touchcancel event to avoid unexpected behavior when switching tabs and some other situations
    document.removeEventListener('touchcancel', onTouchEnd);
  }
}

实际上,我们并未在事件处理脚本中存储输入,而是在单独的处理程序(handleDragStart、handleDragging 和 handleDragStop)中存储输入。这是因为我们还希望能够从鼠标事件处理脚本调用这些事件。请注意,用户可能会同时使用触摸和鼠标,尽管不太可能发生。我们不是直接处理这种情况,而是确保不会出现任何问题。

function handleDragStart(x ,y ){
  input.dragging = true;
  input.dragStartX = input.dragX = x;
  input.dragStartY = input.dragY = y;
}

function handleDragging(x ,y ){
  if(input.dragging) {
    input.dragDX = x - input.dragX;
    input.dragDY = y - input.dragY;
    input.dragX = x;
    input.dragY = y;
  }
}

function handleDragStop(){
  if(input.dragging) {
    input.dragging = false;
    input.dragDX = 0;
    input.dragDY = 0;
  }
}

在执行基于 touchmove 的动画时,还存储自上次事件后的增量移动通常很有用。例如,我们使用此参数作为镜头在“探索”中所有底板上移动时的速度参数,因为您实际上不是拖动底板,而是在移动镜头。

function onAnimationFrame() {
  requestAnimationFrame( onAnimationFrame );

  //execute animation based on input.dragDX, input.dragDY, input.dragX or input.dragY
 /*
  /
  */

  //because touchmove is only fired when finger is actually moving we need to reset the delta values each frame
  input.dragDX=0;
  input.dragDY=0;
}

嵌入式示例:使用触摸事件拖动对象。与在 Build with Chrome 中拖动“探索 3D 地图”时的实现类似:http://cdpn.io/qDxvo

多点触控手势

有多个框架或库(例如 HammerQuoJS)可用于简化多点触控手势的管理,但如果您想要组合多种手势并获得完全控制权,有时最好从零开始。

为了管理双指张合和旋转手势,我们会存储将第二根手指放在屏幕上时,两根手指之间的距离和角度:

//variables representing the actual scale/rotation of the object we are affecting
var currentScale = 1;
var currentRotation = 0;

function onTouchStart(event) {
  event.preventDefault();
  if( event.touches.length === 1){
    handleDragStart(event.touches[0].clientX , event.touches[0].clientY);
  }else if( event.touches.length === 2 ){
    handleGestureStart(event.touches[0].clientX, event.touches[0].clientY, event.touches[1].clientX, event.touches[1].clientY );
  }
}

function handleGestureStart(x1, y1, x2, y2){
  input.isGesture = true;
  //calculate distance and angle between fingers
  var dx = x2 - x1;
  var dy = y2 - y1;
  input.touchStartDistance=Math.sqrt(dx*dx+dy*dy);
  input.touchStartAngle=Math.atan2(dy,dx);
  //we also store the current scale and rotation of the actual object we are affecting. This is needed to support incremental rotation/scaling. We can't assume that an object is always the same scale when gesture starts.
  input.startScale=currentScale;
  input.startAngle=currentRotation;
}

在 touchmove 事件中,我们会连续测量这两根手指之间的距离和角度。然后,系统会使用起始距离和当前距离之间的差值来设置比例,并使用起始角度与当前角度之间的差值来设置角度。

function onTouchMove(event) {
  event.preventDefault();
  if( event.touches.length  === 1){
    handleDragging(event.touches[0].clientX, event.touches[0].clientY);
  }else if( event.touches.length === 2 ){
    handleGesture(event.touches[0].clientX, event.touches[0].clientY, event.touches[1].clientX, event.touches[1].clientY );
  }
}

function handleGesture(x1, y1, x2, y2){
  if(input.isGesture){
    //calculate distance and angle between fingers
    var dx = x2 - x1;
    var dy = y2 - y1;
    var touchDistance = Math.sqrt(dx*dx+dy*dy);
    var touchAngle = Math.atan2(dy,dx);
    //calculate the difference between current touch values and the start values
    var scalePixelChange = touchDistance - input.touchStartDistance;
    var angleChange = touchAngle - input.touchStartAngle;
    //calculate how much this should affect the actual object
    currentScale = input.startScale + scalePixelChange*0.01;
    currentRotation = input.startAngle+(angleChange*180/Math.PI);
    //upper and lower limit of scaling
    if(currentScale<0.5) currentScale = 0.5;
    if(currentScale>3) currentScale = 3;
  }
}

您可以采用与拖动示例类似的方式使用每个 touchmove 事件之间的距离变化,但是当您需要持续移动时,该方法通常更为有用。

function onAnimationFrame() {
  requestAnimationFrame( onAnimationFrame );
  //execute transform based on currentScale and currentRotation
  /*
  /
  */

  //because touchmove is only fired when finger is actually moving we need to reset the delta values each frame
  input.dragDX=0;
  input.dragDY=0;
}

如果您愿意,还可以启用在执行双指张合和旋转手势时拖动对象的功能。在这种情况下,您应使用两个手指之间的中心点作为拖动处理程序的输入。

嵌入式示例:在 2D 空间内旋转和缩放对象。与“探索”中地图的实现方式类似:http://cdpn.io/izloq

同一硬件上的鼠标和触摸支持

如今,多款笔记本电脑(例如 Chromebook Pixel)既支持鼠标输入,又支持触控输入。如果您不小心,可能会导致一些意外行为。

重要的是,您不应只检测触摸支持,然后忽略鼠标输入,而是同时支持两者。

如果您没有在触摸事件处理脚本中使用 event.preventDefault(),则还会触发一些模拟鼠标事件,以确保大多数未针对触摸进行优化的网站仍能正常运行。例如,对于屏幕上的单次点按,这些事件可能会按以下快速顺序触发:

  1. 轻触开始
  2. 轻触移动
  3. 触屏
  4. 鼠标悬停
  5. mousemove
  6. 鼠标按下
  7. 鼠标上移
  8. 点击

如果您的互动较为复杂,这些鼠标事件可能会导致一些意外行为,并干扰您的实现。通常,最好在触摸事件处理脚本中使用 event.preventDefault(),并在单独的事件处理脚本中管理鼠标输入。您需要注意,在触摸事件处理脚本中使用 event.preventDefault() 还会阻止某些默认行为,例如滚动和点击事件。

“在 Build with Chrome 中,我们不希望用户点按两次网站时发生缩放,尽管这在大多数浏览器中都是标准做法。因此,我们使用视口元标记来告知浏览器,当用户点按两次时不要进行缩放。这还可以消除 300 毫秒的点击延迟,提高网站的响应能力。(启用“点按两次”缩放后,点击延迟会区分点按一次和点按两次)。

<meta name="viewport" content="width=device-width,user-scalable=no">

请记住,使用此功能时,您需要确保网站在所有尺寸的屏幕上都能清晰可辨,因为用户无法进一步放大。

鼠标、触摸和键盘输入

在“探索 3D”模式下,我们希望通过三种方式浏览地图:鼠标(拖动)、触摸(拖动、双指张合即可缩放和旋转)和键盘(使用箭头键进行导航)。所有这些导航方法的运作方式略有不同,但我们对所有方法都使用了相同的方法;在事件处理脚本中设置变量,并在 requestAnimationFrame 循环中对此执行操作。 requestAnimationFrame 循环不必知道使用哪种方法进行导航。

例如,我们可以使用所有三种输入法设置地图的移动(dragDX 和 DragDY)。键盘实现如下:

document.addEventListener('keydown', onKeyDown );
document.addEventListener('keyup', onKeyUp );

function onKeyDown( event ) {
  input.keyCodes[ "k" + event.keyCode ] = true;
  input.shiftKey = event.shiftKey;
}

function onKeyUp( event ) {
  input.keyCodes[ "k" + event.keyCode ] = false;
  input.shiftKey = event.shiftKey;
}

//this needs to be called every frame before animation is executed
function handleKeyInput(){
  if(input.keyCodes.k37){
    input.dragDX = -5; //37 arrow left
  } else if(input.keyCodes.k39){
    input.dragDX = 5; //39 arrow right
  }
  if(input.keyCodes.k38){
    input.dragDY = -5; //38 arrow up
  } else if(input.keyCodes.k40){
    input.dragDY = 5; //40 arrow down
  }
}

function onAnimationFrame() {
  requestAnimationFrame( onAnimationFrame );
  //because keydown events are not fired every frame we need to process the keyboard state first
  handleKeyInput();
  //implement animations based on what is stored in input
   /*
  /
  */

  //because touchmove is only fired when finger is actually moving we need to reset the delta values each frame
  input.dragDX = 0;
  input.dragDY = 0;
}

嵌入式示例:使用鼠标、触摸和键盘进行导航:http://cdpn.io/catlf

摘要

通过调整 Build with Chrome,使其能够支持具有多种不同屏幕尺寸的触摸设备,这已是一次学习体验。该团队在触摸设备上进行这种程度的互动经验并不丰富,在此过程中我们学到了很多东西。

事实证明,最大的挑战是如何解决用户体验和设计方面的问题。他们面临的技术挑战是管理许多屏幕尺寸、触摸事件和性能问题。

尽管触摸设备上的 WebGL 着色器面临一些挑战,但效果几乎好于预期。设备变得越来越强大,WebGL 实现也在迅速改进。我们认为不久之后会在更多设备上使用 WebGL。

现在就构建一些出色的应用吧(如果您还没有这样做的话)!