使用 HTML5 的增强现实

作者:Rick Rogers

在之前的 Linux Journal 文章(“开发可移植的移动 Web 应用程序”,2010 年 9 月,www.linuxjournal.com/article/10789)中,我探讨了 HTML5 以及如何使用它来编写移动电话应用程序。该文章中介绍的技术非常适用于使用文本、按钮、图像、音频甚至视频的应用程序,但是对于突破移动电话功能极限的前沿应用程序又如何呢?为了找出答案,我决定实现一个相当简单的移动增强现实应用程序,尽可能多地使用 HTML5。本文探讨了扩展 JavaScript 功能以编写超出标准 HTML5 可能范围的应用程序的技术。

增强现实

增强现实 (AR) 是指一类应用程序的名称,这类应用程序结合了移动电话的独特功能,以扩展用户对其环境的感知。Layar (www.layar.com) 是最早的 AR 应用程序之一,并且仍然是最具创意的应用程序之一。增强现实将当前的相机预览屏幕与附加信息叠加 - 您可以在此 YouTube 视频中看到示例:(www.youtube.com/watch?v=A6Le50-QN3o&feature=player_embedded)。图 1 显示了当加载“星巴克”图层且相机指向一家有星巴克咖啡店的购物中心时 Layar 的外观。

Augmented Reality with HTML5

图 1. 带有“星巴克”图层的 Layar

此应用程序利用了许多移动电话功能

  • 相机预览。

  • 指南针(相机指向的方向)。

  • 位置。

  • 2D 图形(用于叠加层)。

  • 数据库功能。

Layar 是一个非常高级的应用程序,具有许多使其易于使用的选项。同样,AR 的本质是用户在相机预览上看到叠加的附加信息。

HTML5 扩展

您将如何使用 HTML5 实现这种应用程序?为了创建一个示例应用程序,让我们将 AR 简化为一个简单的案例:在用户屏幕上显示当前的相机预览,并将当前的指南针方向叠加在预览之上。我们还要动画指南针卡,使其随着手机相机的平移而移动。原则上,叠加层可以是任何东西,但指南针卡是一个开始。

HTML5 大大扩展了 HTML 应用程序的功能,但对于此应用程序,仍然缺少一些东西

  1. HTML5 不包含指南针 API。您需要一种方法来访问移动电话当前的指南针方向,并在方向更改时接收定期更新。您可以使用 Web 应用程序工具包(例如 PhoneGap 或 Titanium)中的 API 来实现此目的,但让我们创建我们自己的接口,并演示如何从 JavaScript 访问几乎任何对象。

  2. 您需要在屏幕上显示实时相机预览,而 HTML5 中没有相机 API。HTML5 的扩展,例如 WAC(批发应用程序社区,public.wholesaleappcommunity.com),正在定义相机预览的 API,但目前还没有 WAC 移动电话。

  3. 为了向移动平台添加您自己的 HTML5 扩展,您必须进行一些平台特定的代码编写。这意味着您必须放弃一些可移植性,但让我们接受这种权衡,并专注于一个平台,Android。让我们创建所需的 Dalvik/Java 代码来实现这个简单的 AR 应用程序,并了解 JavaScript 如何调用 Dalvik 方法,反之亦然。

ARCompass 应用程序

该应用程序将是一个混合的 Dalvik/HTML5 应用程序。HTML5 部分将在浏览器中运行。Android 应用程序通过两种方式创建 Internet 浏览器视图

  1. 发出带有要打开的 URL 的 Intent,Android 将通过打开浏览器应用程序并将 URL 传递给它来解析该 Intent。当您退出浏览器时,控制权将返回给调用应用程序。这种方法对于常规 HTML5 应用程序来说效果很好,但它没有提供一种向 JavaScript 添加新接口的方法。

  2. 展开一个 WebView 并将 URL 传递给它。与浏览器应用程序相比,WebView 具有更大的灵活性,包括一个公共方法 addJavascriptInterface (Object obj, String InterfaceName)。此方法允许您为 WebView 运行的脚本创建自己的 JavaScript API。请注意,这里存在一个安全漏洞——您向 JavaScript 公开的任何内容都可以被此 WebView 运行的 任何 JavaScript 脚本访问,无论您是否编写了该脚本。您需要确保用户无法导航到可能滥用您的接口的随机网站。在这种情况下,让我们将 HTML 和 JavaScript 文件包含在应用程序中,并且不给用户任何导航离开的机会。

让我们编写一个 Dalvik 应用程序,该应用程序显示相机预览屏幕,并使用 WebView 覆盖该屏幕,WebView 将绘制和动画指南针卡。当然,您还需要将指南针信息从 Android 传递回 HTML5 代码,以便它可以正确地动画卡片。

假设您已经加载了 Android SDK(来自 developer.android.com),您可以通过从 ftp.linuxjournal.com/pub/lj/listings/issue203/10920.tgz 下载 ARCompass.prj 项目文件以及 HTML 和 JavaScript 文件来继续学习。

HTML5 部分

在深入研究应用程序的 Dalvik 部分之前,让我们看一下 HTML5 部分,它绘制指南针卡并旋转卡片以显示手机指向的当前方向。此处使用的 .html、.js 和 .png 文件存储在 Dalvik 应用程序的 assets 文件夹中,该文件夹在 Eclipse 创建 Android 项目时自动创建。

清单 1. arcompass.html

<!DOCTYPE HTML PUBLIC>
 <head>
  <title>AR Compass</title>
   <script type="text/javascript"
     src="arcompass.js">
   </script>
 </head>
 <body>
   <div id="extra">
    <button type="button"
     onclick="window.direction.turnOnCompass()">
      Start</button>
   </div>
   <div id="overlay" style="position: absolute;
     left:280; top:60; z-index:500;
     background-color:#0000" >
    <canvas id="e" width="200" height="200">
    </canvas>
    <script>
     drawCompass();
    </script>
   </div>
 </body>
</html>

清单 2. arcompass.js

var currDir;
var canvas;
var context;
var card;
function drawCompass() {
 currDir = 0;
 canvas = document.getElementById("e");
 context = canvas.getContext("2d");
 card = new Image();
 card.src = "CompassCard.png";
 card.onload = function() {
  context.translate(100,100);
  context.globalAlpha = 0.5;
  context.drawImage(card, -100, -100, 200, 200);
  }
}

function updateView(dir) {
 context.rotate(currDir*2*Math.PI/360);
 context.rotate(-dir*2*Math.PI/360);
 context.drawImage(card, -100,-100,200,200);
 currDir = dir;
 }

HTML 文件的头部声明了一个标题并引用了 JavaScript 文件。主体由两个 <div> 组成:一个带有按钮,另一个带有 <canvas>。您实际上不需要应用程序的按钮,但我想展示如何从 JavaScript/HTML 调用 Dalvik 例程。请注意,按钮的 onclick 属性设置为window.direction.turnOnCompass()。稍后您将看到如何在 Dalvik 中声明该 API,以及如何将其连接以启动指南针传感器发送方向更新。

第二个 <div> 是您绘制指南针卡的画布。让我们假设应用程序采用横向方向,并将画布放置在屏幕的右侧。在实际应用程序中,您需要考虑您运行的设备的特定屏幕几何形状。为了简单起见,我在这里硬编码了一些像素值。然后,一个简短的嵌入式脚本要求 drawCompass() 函数绘制初始指南针卡图像。

JavaScript 文件声明了一些变量并定义了两个函数

  1. drawCompass() 绘制初始指南针卡,北方指向上方。

  2. updateView(dir) 将在您从指南针传感器获得更新的指南针方向时调用(我稍后会解释如何)。它适当地旋转绘图上下文并重绘指南针卡。

Android 部分

让我们将注意力转向应用程序的 Dalvik 部分。您需要 manifest 和布局文件(清单 3 和 4)。

清单 3. AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android=
"http://schemas.android.com/apk/res/android"
 package="com.lj.ARCompass"
 android:versionCode="1"
 android:versionName="1.0">
  <application android:icon="@drawable/icon"
   android:label="@string/app_name"
   android:debuggable="true">
   <activity android:name=".ARCompass"
     android:label="@string/app_name">
    <intent-filter>
     <action android:name="android.intent.action.MAIN" />
     <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
   </activity>
</application>
<uses-permission
 android:name="android.permission.CAMERA">
</uses-permission>
<uses-permission
 android:name="android.permission.INTERNET">
</uses-permission>

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

清单 4. main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android=
"http://schemas.android.com/apk/res/android"
  android:orientation="horizontal"
  android:layout_width="fill_parent"
  android:layout_height="fill_parent"
  >
<SurfaceView
  android:id="@+id/preview"
  android:layout_width="fill_parent"
  android:layout_height="fill_parent"
  android:layout_weight="1"
  />
<WebView
  android:layout_width="fill_parent"
  android:layout_height="wrap_content"
  android:id="@+id/webView0"
  android:layout_alignTop="@id/preview"
  android:layout_alignBottom="@id/preview"
  />
</RelativeLayout>

manifest 文件说明该应用程序仅包含一个屏幕(ARCompass 活动),并且需要用户的许可才能访问相机和 Internet。它还请求了 SET_DEBUG_AP 权限,这允许您在使用 Eclipse 调试器时在真实设备上运行该应用程序。

布局文件说明该活动包含两个视图,一个巧妙地命名为 webView0 的 WebView 和一个名为 preview 的 SurfaceView。我正在使用 Relative Layout,以便您可以使用 webView0 的 layout_align_top 和 layout_align_bottom 属性将视图彼此叠放。我将在我要求 WebView 渲染的 HTML 中处理任何其他需要的布局。

应用程序的 Dalvik 部分更复杂,但如果您将其分解为几个部分,则不会那么糟糕

package com.lj.ARCompass;

import java.io.IOException;

import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.hardware.Camera;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.os.Bundle;
import android.util.Log;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.Window;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.widget.Toast;

public class ARCompass extends Activity
  implements SurfaceHolder.Callback {

 private WebView mWebView;
 private SensorManager mSensorManager;
 private float[] mValues;
 private boolean compassOn = false;

 private static final String TAG = "ARCompass";
 final Context mContext = this;

 private Camera mCamera;
 private SurfaceView mSurfaceView;
 private SurfaceHolder mSurfaceHolder;
 private boolean mPreviewRunning;

这些第一行导入您需要的所有库,声明一些需要的变量,并声明唯一的 Activity,ARCompass。请注意,我说过 ARCompass 将实现 SurfaceHolder.Callback 接口——这是相机预览所必需的。

下一段代码声明一个 SensorEventListener

 private final SensorEventListener mListener =
   new SensorEventListener() {
  @Override
  public void onAccuracyChanged
   (Sensor sensor, int accuracy) {
  }
  @Override
  public void onSensorChanged(SensorEvent event) {
   mValues = event.values;
   Log.d(TAG,"Compass update: " + mValues[0]);
   String url =
    "javascript:updateView(" + mValues[0] + ");";
   mWebView.loadUrl(url);
  }
 };

稍后,我将把此监听器连接到我将从指南针传感器获得的更新事件。现在,请注意 onSensorChanged() 方法中的最终结果是将 URL 加载到 WebView 中(稍后创建)。URL 的形式为 javascript:updateView(direction),因为在数组 event.values[] 中传递给您的第一个值实际上是当前的指南针方向。将 URL 加载到 WebView 中具有调用刚刚在 arcompass.js 中定义的 updateView() 函数的效果。

下一部分代码进入 onCreate() 方法,该方法在首次创建活动时调用

 /** Called when the activity is first created. */
 @Override
 public void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  Log.d(TAG, "onCreate");
  // Get rid of title
  requestWindowFeature(Window.FEATURE_NO_TITLE);

  setContentView(R.layout.main);

  // Initialize the surface for camera preview
  mSurfaceView =
   (SurfaceView)findViewById(R.id.preview);
  mSurfaceHolder = mSurfaceView.getHolder();
  mSurfaceHolder.addCallback(this);
  mSurfaceHolder.setType
   (SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
  Log.d(TAG, "SurfaceView initialized");

  // Initialize the WebView
  mWebView = (WebView) findViewById(R.id.webView0);
  WebSettings webSettings = mWebView.getSettings();
  webSettings.setSavePassword(false);
  webSettings.setSaveFormData(false);
  webSettings.setJavaScriptEnabled(true);
  webSettings.setSupportZoom(false);
  mWebView.setBackgroundColor(0);

  mWebView.addJavascriptInterface
   (new CompassJavaScriptInterface(), "direction");
  Log.d(TAG, "JavaScript interface added");

  /* Set WebChromeClient before calling loadUrl! */
  mWebView.setWebChromeClient
   (new WebChromeClient() {
   @Override
   public boolean onJsAlert(
    WebView view, String url, String message,
    final android.webkit.JsResult result){
    new AlertDialog.Builder(mContext)
    .setTitle("javaScript dialog")
    .setMessage(message)
    .setPositiveButton(android.R.string.ok,
    new AlertDialog.OnClickListener() {
     public void onClick(
      DialogInterface dialog, int which) {
      result.confirm();
     }
    })
    .setCancelable(false)
    .create()
    .show();
    return true;
   };
  });

  mWebView.loadUrl(
   "file:///android_asset/arcompass.html");
 }

在调用超类例程并设置一个要用于日志消息的 TAG 之后,我为窗口请求 FEATURE_NO_TITLE,因为我不想要或不需要通常的 Android 标题栏。然后,我连接到我之前看过的 main.xml 布局文件。

下一段代码初始化您将用于相机预览的 SurfaceView,下一段代码初始化 WebView。我将把大部分细节留给读者(Android SDK 帮助文件非常出色),但请注意特别的一行

webSettings.setJavaScriptEnabled(true);

默认情况下,WebView 不执行 JavaScript。此设置启用该功能。

WebView 设置后的下一行调用 addJavascriptInterface() 以添加一个新的 API,该 API 可以从 WebView 运行的脚本中调用。我稍后定义了 CompassJavaScriptInterface 类,包括方法 turnOnCompass(),但这是我在 arcompass.html 中定义的函数调用 “direction” 部分(window.direction.turnOnCompass())。

接下来的 20 行左右定义了一个 WebChromClient,以便您可以从 JavaScript 发出 alert() 函数调用,这些调用将转换为 Android 警报框。这对于调试很有用,但并非绝对必要,除非您的 JavaScript 使用警报。

本节中的最后一行将 arcompass.html 文件加载到 WebView 中。请注意文件引用的语法。同样,该文件位于应用程序项目的 assets 文件夹中,SDK 将该文件夹包含在安装应用程序时下载的 .apk 包中。下一部分代码将指南针传感器连接到应用程序

 final class CompassJavaScriptInterface {
  /* Note this runs in a separate thread */

  CompassJavaScriptInterface() {
  }
  public void turnOnCompass() {
   Log.d(TAG, "turnOnCompass");
   mSensorManager = (SensorManager)
     getSystemService(Context.SENSOR_SERVICE);
   Sensor mSensor =
     mSensorManager.getDefaultSensor
       (Sensor.TYPE_ORIENTATION);

   if(mSensor != null){
    mSensorManager.registerListener(mListener,
     mSensor, SensorManager.SENSOR_DELAY_NORMAL);
    compassOn = true;
    Log.d(TAG, "Compass started");
   }
   else{
    Toast.makeText(mContext,
      "No ORIENTATION Sensor",
      Toast.LENGTH_LONG).show();
    compassOn = false;
    finish();
   }

  }
 }

 @Override
 protected void onDestroy() {
 super.onDestroy();
  if(compassOn){
  mSensorManager.unregisterListener(mListener);
  }
 finish();
 }

首先,我声明了我在 addJavascriptInterface() 中引用的类。当您通过这种方式从 JavaScript 进行调用时,重要的是要知道此代码将在与上面调用它的线程不同的线程中运行。特别是,如果被调用的例程需要操作用户界面,它将不会在 UI 线程中运行,因此它需要发布一个 runnable 以供该线程拾取。在这种情况下,我只是在使用 Sensor 接口,因此在单独的线程中运行不是问题。

我定义的唯一方法是 turnOnCompass(),但我可以定义其他方法。如果我定义了另一个方法 blatz(),我可以从 JavaScript 中将其调用为 window.direction.blatz()。turnOnCompass() 方法调用 SensorManager 并请求默认方向传感器的句柄。如果存在默认方向传感器,它将注册我在开头定义的 SensorEventListener,设置一个 housekeeping 布尔值并返回。如果不存在方向传感器,它会使用 Toast 告知用户并退出。

本节中的最后一段代码确保在应用程序退出时您注销监听器。如果您恰好是方向的唯一注册监听器,这将使 Android 有机会关闭该服务,甚至该传感器。

Dalvik 代码的最后一部分处理相机预览

 // Create camera preview.
 public void surfaceCreated(SurfaceHolder holder){
  mCamera = Camera.open();
  try {
   mCamera.setPreviewDisplay(holder);
  } catch (IOException exception) {
   mCamera.release();
   mCamera = null;
  }
 }

 // Change preview's properties
 public void surfaceChanged(SurfaceHolder holder,
   int format, int w, int h){
  mCamera.startPreview();
  mPreviewRunning = true;
 }

 // Stop the preview.
 public void surfaceDestroyed
   (SurfaceHolder holder){
  mCamera.stopPreview();
  mPreviewRunning = false;
  mCamera.release();
 }
}

同样,相机预览实现的细节最好留给 Android SDK 文档。surfaceCreated()、surfaceChanged() 和 surfaceDestroyed() 这三个方法是我说过此活动将实现的 Surface.Callback 接口的方法,并且它们在每个事件中都会为您调用。创建 surface 时,您将相机预览连接到 SurfaceHolder。销毁 surface 时,您停止相机预览并释放相机。surfaceChanged 方法仅在 surfaceCreated 之后调用一次,您实际上在那里启动预览。

当您在移动电话上构建并运行此程序时,您会得到类似于图 2 所示的图片。这是一个 HTC EVO 屏幕截图。我没有做很多来考虑屏幕几何形状的差异,因此您的手机可能看起来有些不同。

Augmented Reality with HTML5

图 2. 在 HTC EVO 上运行的 ARCompass

用户看到一个“开始”按钮和一个叠加在当前相机预览上的指南针卡。“开始”按钮实际上不是必需的,但我包含它只是为了展示 JavaScript/HTML 如何调用 Dalvik 方法。

当您点击“开始”按钮时,应用程序的 HTML 部分调用 window.direction.turnOnCompass(),该方法在 Dalvik 中实现。该方法要求方向传感器开始向 mListener 发送指南针读数。每次 mListener 获得新的指南针读数时,它都会调用 JavaScript 例程 updateView() 以在屏幕上重绘指南针卡。

那么这一切意味着什么

我已经展示了如何使用 HTML5 和 Dalvik 编写混合应用程序。设置起来相对容易,以便 JavaScript 可以调用 Dalvik 方法,而 Dalvik 可以调用 JavaScript 方法。我已经展示了您可以创建相当高级的应用程序,这些应用程序组合了 Dalvik 和 HTML5 用户界面,使它们对用户来说看起来像一个整体。

但是您可以同样轻松地用 Dalvik 编写整个应用程序,那么用 HTML5 编写部分有什么优势呢?以下是优势

  1. 如果您正在编写一个实际的应用程序,则 HTML5 部分将(相对)可移植到其他平台。您不必重写它来移植到例如 iPhone。在示例中,HTML5 部分非常小,但原则上,它可以更大。

  2. 您可以将应用程序的 HTML5 部分保留在远程 HTTP 服务器上,以便在每次运行应用程序时进行更新,而无需用户下载更新。

  3. 如果您的应用程序显示来自 Web 的信息,则可以说 HTML5 是比 Dalvik 更自然的 Web 交互场所。

混合应用程序(例如此处的示例)可以是创建移动应用程序的有效方法,这些应用程序结合了 HTML5 和原生平台的功能。只要平台为您提供了一种在 JavaScript 和原生应用程序环境之间进行交互的方式,那么您似乎没有任何障碍可以编写各种应用程序。

指南针卡图形改编自 commons.wikimedia.org/wiki/File:Compass.svg

Rick Rogers 是一位专业的嵌入式开发人员,拥有 30 多年的经验。现在专注于移动应用程序软件,当 Rick 不是为了生计而编写软件时,他正在编写书籍和杂志文章,例如这篇文章。他欢迎对本文的反馈,地址为 portmobileapps@gmail.com

加载 Disqus 评论