PhoneGap-2-x-移动应用开发热点-全-
PhoneGap 2.x 移动应用开发热点(全)
原文:
zh.annas-archive.org/md5/0cfdb8b3b7385c6d5bb1fd49883e2110译者:飞龙
前言
为移动设备开发应用可以使用许多不同的方法和语言。许多应用都是原生开发的;这意味着它们是用 Java、Objective C 或某些其他语言开发的,这些语言是设备可用的 SDK 原生理解的。虽然原生开发提供了最大的灵活性和性能,但当你想将应用从一个平台迁移到另一个平台时,问题就出现了:你几乎需要从头开始编写应用。然后,如果你想要迁移到另一个平台,同样的事情会发生。一定有更好的方法!
所有当前移动平台都支持网络应用这一概念。这些应用完全使用 HTML 和 JavaScript 编写。对于简单的应用,或者不需要与设备功能交互的应用,这完全可行。但是,一旦你需要访问文件系统、使用摄像头等,你就开始需要更多对设备的访问权限。
这就是 PhoneGap 的作用。PhoneGap(基于 Cordova)仅用足够的原生应用来让你的网络应用在设备上感觉更舒适。这个包装器对每个平台都不同,但以一致的方式公开了常见功能。这有助于你在多个平台上编写更少的代码。
由于 PhoneGap 将你的 HTML 和 JavaScript 包裹在原生壳中,你也获得了将你的应用提交到平台应用商店的能力——这是仅使用简单网络应用所无法做到的。然而,请记住,大多数应用商店希望你的应用看起来和感觉像原生应用,有些在应用的外观和感觉方面要求更为严格。此外,不要只是将托管在其他服务器上的现有网站包裹起来——许多应用商店会拒绝这类应用。你的应用需要有支持 UI 并与设备交互的本地 HTML 和 JavaScript。
这本书的作用在哪里。虽然我们使用 PhoneGap 作为壳和访问一些有趣设备功能的接口,但我们并不是简单地重复 PhoneGap 的文档。相反,这里有完整的应用;每个应用都旨在利用设备的一个或多个功能,是的,但也是完全功能性的应用。
本书有望向你展示如何使用 PhoneGap 制作有趣甚至令人兴奋的应用,这些应用也是跨平台的。虽然我们只关注 iOS 和 Android,但本书中的技术可以很容易地通过少量修改扩展到 BlackBerry、Windows Phone 和其他平台。
本书涵盖的内容
项目 1,让我们本地化!,通过一个简单的问答游戏介绍了 PhoneGap。我们还将通过将游戏提供为英语和西班牙语来了解应用本地化。我们还将了解本书余下部分将使用的简单框架。
项目 2, 让我们社交吧!,帮助我们开发一个简单的社交应用,显示选定 Twitter 账户的动态。我们将介绍将插件安装到 PhoneGap 中,以便在需要访问 PhoneGap 不提供的原生功能时使用。
项目 3, 提高效率,向我们介绍了一个应用,就像大多数应用一样,它需要与文件系统协同工作以实现持久数据存储。这是一个简单的记事本应用,将允许我们全面探索创建、重命名、复制和删除文件。
项目 4, 一起去旅行,帮助我们构建一个记录用户在给定时间段内位置的应用。这需要访问设备的 GPS 功能。我们还需要使用 Google Maps。我们将在项目 3 中引入的文件管理基础上进行构建。
项目 5, 与你的应用对话,帮助我们创建一个可以记录语音备忘录的应用,并允许用户随时播放它们。在这个过程中,我们将集成 PhoneGap 的音频捕获和播放 API。
项目 6, 说 Cheese!,涵盖了如何以内存高效的方式显示缩略图,因为显示和捕获媒体在大多数应用中至关重要。我们还将与设备的相机和照片库进行交互。
项目 7, 去看电影!,与项目 6 非常相似,但在这里我们处理的是视频。我们将介绍在 iOS 和 Android(每个都非常不同)上播放视频,并且我们还将负责录制视频。最后,我们将编写我们的第一个插件,从视频中提取缩略图以在应用中显示。
项目 8, 玩耍一下,向我们介绍了一个使用 HTML5 画布来玩简单游戏的简单游戏,因为有很多应用做了一些重要的事情,有时我们只是想找点乐子。我们还将与设备的加速度计一起工作。
项目 9, 融入其中,将一个之前开发的应用应用于原生组件,使其看起来和感觉更像原生应用,因为有时我们只是想融入其中。虽然这个项目专门针对 iOS,但你也可以将这些概念应用于其他平台。
项目 10, 扩展应用,向我们介绍了检测平板电脑的概念,因为到目前为止,本书中的每个应用都是针对手机尺寸的设备定制的,但平板电脑也非常普遍。我们还将熟悉用于将我们的应用扩展到平板电脑尺寸的常见设计模式。
附录 A, 快速设计模式参考,涵盖了移动应用中使用的某些常见设计模式。
附录 B, 安装 ShareKit 2.0,涵盖了将 ShareKit 2.0 集成到项目中所需的所有步骤,因为有时将其与 iOS 集成可能会有些痛苦。
您需要本书的内容
要构建/运行本书提供的代码,需要以下软件(根据平台适当划分):
| Windows | Linux | OS X | |
|---|---|---|---|
| 针对 iOS 应用 | |||
| IDE | XCode 4.5+ | ||
| OS | OS X 10.7+ | ||
| SDK | iOS 5+ | ||
| 针对 Android 应用 | |||
| IDE | Eclipse 4.x Classic | Eclipse 4.x Classic | Eclipse 4.x Classic |
| OS | XP 或更新版本 | 支持 Eclipse 和 Android SDK 的任何现代发行版——Ubuntu、RHEL 等。 | OS X 10.6+(可能适用于较低版本) |
| Java | 1.6 或更高版本 | 1.6 或更高版本 | 1.6 或更高版本 |
| SDK | 版本 15+ | 版本 15+ | 版本 15+ |
| 针对所有平台 | |||
| Apache Cordova / PhoneGap | 2.2 | 2.2 | 2.2 |
| 插件 | 当前版本 | 当前版本 | 当前版本 |
| 版本控制* | Git(附录 B) | Git(附录 B) | Git(附录 B) |
- 仅用于在附录 B 中安装 ShareKit 2.0 插件。
以下网站可能对下载有用:
-
Xcode:
developer.apple.com/xcode/ -
Eclipse:
www.eclipse.org/downloads/packages/eclipse-classic-421/junosr1 -
Android SDK:
developer.android.com/sdk/index.html -
Apache Cordova/PhoneGap:
phonegap.com/download
本书面向的对象
本书面向任何有良好 HTML 和 JavaScript 开发感觉的开发者,但希望进入移动应用开发领域。开发者应知道如何编写 HTML,并对 JavaScript 有合理的理解。开发者还应熟悉设置开发环境,如 Eclipse 或 Xcode。
本书也面向任何寻求创建可以跨多个平台运行且修改有限的原生开发者。PhoneGap 是一个强大的工具,您可以用它构建一个可以在多个平台上运行的单一 HTML/JavaScript 代码库。
本书中的示例特别使用 PhoneGap 2.2。
规范
在本书中,您会发现几个标题频繁出现。
为了清楚地说明如何完成一个程序或任务,我们使用:
我们要构建什么?
本节解释您将要构建的内容。
它做什么?
本节解释项目将实现的内容。
它为什么很棒?
本节解释了为什么这个项目很酷、独特、令人兴奋和有趣。它描述了项目将带给您的优势。
我们将如何做?
本节解释了完成您的项目所需的主要任务。
-
任务 1
-
任务 2
-
任务 3
-
任务 4,等等
我需要准备什么来开始?
本节解释了项目的先决条件,例如需要下载的资源或库等。
任务 1
本节解释您将要执行的任务。
准备工作
本节解释了在开始任务之前您可能需要做的任何初步工作。
继续前进
本节列出了完成任务所需的步骤。
我们做了什么?
本节解释了上一节中执行的操作步骤是如何帮助我们完成任务的。
我还需要了解什么
本节中的额外信息与任务相关。
在这本书中,您还会找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码词如下所示:"addTranslation方法向特定区域添加翻译。"
代码块如下设置:
self.addAnswer = function( theAnswer )
{
self.answers.push ( theAnswer );
return self;
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
<div class="navigationBar">
<div id="gameView_title"></div>
<button class="barButton backButton" id="gameView_backButton" style="left:10px"></button>
</div>
新术语和重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:"文本左侧有一个返回按钮。"
注意
警告或重要提示将以这样的框显示。
小贴士
小技巧和窍门如下所示。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大收益的标题非常重要。
要发送给我们一般性的反馈,只需发送电子邮件到 <feedback@packtpub.com>,并在邮件主题中提及书名。
如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已经成为 Packt 书籍的骄傲所有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您在www.packtpub.com购买的 Packt 书籍的账户中下载所有示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
勘误
尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。您可以通过从www.packtpub.com/support选择您的标题来查看任何现有勘误。
盗版
互联网上版权材料的盗版是一个跨所有媒体持续存在的问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上遇到我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过<copyright@packtpub.com>与我们联系,并附上疑似盗版材料的链接。
我们感谢您在保护我们作者以及为我们带来有价值内容方面的帮助。
问题
如果您在本书的任何方面遇到问题,可以通过<questions@packtpub.com>联系我们,我们将尽力解决。
第一章。让我们开始本地化!
世界上有各种各样的语言,很可能你希望你的应用能够被尽可能广泛地使用和分发,这意味着你需要给你的应用提供多语言的能力。这可能是一项棘手的任务;本地化、翻译、货币格式、日期格式等等都需要很多工作。但幸运的是,有一些非常聪明的人已经解决了许多痛苦的问题。现在,轮到我们把这些工作用到好的用途上了。
我们要构建什么?
我们将要创建的项目是一个简单的游戏,名为 Quiz Time! 游戏将基本上以玩家的母语随机提出十个问题,并在游戏结束时总计并展示他们的分数。最后,应用将询问用户是否想要再次尝试。
应用本身将帮助你了解如何使用一个名为 YASMF (Yet Another Simple Mobile Framework)的简单框架来创建移动应用。市面上有无数的出色框架(jQuery Mobile、jQuery Touch、iUI、Sencha Touch 等等),但本书的目的并不是教你如何使用特定的框架;相反,目的是展示你如何使用 PhoneGap 做一些令人惊叹的事情。你选择使用的框架最终并不那么重要——它们都做了它们所宣传的事情——我们使用自定义框架并不是为了让你感到困惑。使用这个特定自定义框架的主要原因是因为它非常轻量级和简单,这意味着它所使用的概念将很容易转移到任何框架上。有关框架的更多信息,请访问 github.com/photokandyStudios/YASMF/wiki。
应用本身也将成为创建未来本地化应用的基础。本地化在开发初期就至关重要,这就是为什么我们从这里开始,为什么我们给予它如此重要的地位。本质上,这个第一个项目旨在使你未来的应用开发生涯更加容易。
它能做什么?
作为一款应用,Quiz Time! 非常简单。只有三个屏幕,其中只有一个稍微复杂。游戏内置了十个问题,会随机向玩家提问。如果问题回答正确,玩家会收到通知,并且他们的分数会增加一个任意大的数字。这是为了展示我们正确处理了玩家所在地区的数字显示。如果问题回答错误,我们也会通知用户,然后减少他们的分数。如果他们回答了足够多的问题错误,他们最终会进入负分区域,这对我们的本地化技能也是一个很好的测试。
一旦游戏结束,我们将向玩家显示分数和日期,并给他们再次尝试的机会。如果玩家选择再次尝试,我们将重置一切并重新开始游戏。
为什么它很棒?
你将主要学习两件事:在 PhoneGap 中构建一个简单的游戏,并从一开始就本地化该应用。许多项目直到项目接近尾声才考虑本地化,这时可怜的开发者会发现,在项目的大部分开发完成后强行加入本地化非常困难。例如,分配给某些文本的空间可能太小,无法容纳某些语言,或者用作按钮或其他小部件的图像可能不足以容纳本地化文本。应用本身可能在某些语言中崩溃,因为它没有预料到会接收到任何非英语字符。通过在应用开发的早期实现本地化,你将节省自己很多精力,即使你的应用的第一版只本地化到一个地区。
注意
你会在本书的代码示例中经常看到Cordova这个词。PhoneGap 最近被 Adobe 收购,其底层代码被提交给了 Apache 孵化器项目。这个项目被命名为Cordova,PhoneGap 利用它来提供其各种服务。所以如果你看到Cordova,现在它实际上意味着同一件事。
我们将如何做到这一点?
我们将遵循典型的开发周期:设计、实现和测试应用。我们的设计阶段不仅包括用户界面,还包括数据模型,即我们的问题是如何存储和检索的。实现将专注于我们应用的三阶段:起始视图、游戏视图和结束视图。实现后,我们将测试应用,不仅是为了确保它正确处理本地化,还要确保游戏能正确运行。
这里是总体概述:
-
设计应用,UI/交互
-
设计数据模型
-
实现数据模型
-
实现起始视图
-
实现游戏视图
-
实现结束视图
-
整合所有内容
我需要准备些什么才能开始?
首先,请确保从phonegap.com/download下载最新版本的 PhoneGap,目前是 2.2.0(在撰写本文时),并将其提取到适当的目录中。(例如,我使用/Applications/phonegap/phonegap220。)确保你已经安装了适当的 IDE(iOS 开发的 Xcode 和 Android 开发的 Eclipse)。
接下来,从github.com/photokandyStudios/YASMF/downloads下载最新版本的 YASMF 框架,并将其提取到任何位置。(例如,我使用了我的下载文件夹。)
如果你想要这本书的项目副本以便查看,或者为了避免以下项目创建步骤,你可以从github.com/photokandyStudios/phonegap-hotshot下载。
接下来,您需要为您打算支持的各个平台创建一个项目。以下是我们在 Mac OS X 上同时创建两个项目的步骤。这些命令经过一点修改后应该适用于 Linux 和仅 Android 的项目,同样,在 Windows 上创建 Android 项目时也需要进行一些额外的修改。对于以下步骤,请将 $PROJECT_HOME 理解为您项目的位置,$PHONEGAP_HOME 理解为您安装 PhoneGap 的位置,$YASMF_DOWNLOAD 理解为您解压 YASMF 框架的位置。
注意
以下步骤只是我在设置项目时使用的步骤。当然,您可以根据自己的喜好来结构化它,但您需要自己进行任何有关文件引用等方面的修改。
以下步骤假设您已经下载了 PhoneGap (Cordova) 2.2.0。如果您下载了更晚的版本,以下步骤应该经过最小修改后可以工作:
提示
下载示例代码
您可以从您在 www.packtpub.com 购买的所有 Packt 书籍的账户中下载示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册以将文件直接通过电子邮件发送给您。
-
使用以下代码片段:
mkdir $PROJECT_HOME cd $PROJECT_HOME mkdir Android iOS www cd $PHONEGAP_HOME/lib/android/bin ./create $PROJECT_HOME/Android/QuizTime com.phonegaphotshot.QuizTime QuizTime cd $PHONEGAP_HOME/lib/ios/bin ./create $PROJECT_HOME/iOS com.phonegaphotshot.QuizTime QuizTime cd $PROJECT_HOME mkdir www/cordova cp Android/QuizTime/assets/www/cordova-2.2.0.js www/cordova/cordova-2.2.0-android.js cp iOS/www/cordova-2.2.0.js www/cordova/cordova-2.2.0-ios.js cd Android/QuizTime/assets rm –rf www ln –s ../../../www cd ../../../iOS rm –rf www ln -s ../www cd .. cd www cp –r $YASMF_DOWNLOAD/framework . mkdir images models views style cd .. cd Android/QuizTime/src/com/phonegaphotshot/QuizTime edit QuizTime.java Change "index.html" to "index_android.html" Save the file. cd $PROJECT_HOME/iOS/QuizTime -
编辑
Cordova.plist。 -
搜索
UIWebViewBounce。 -
将其下面的
<true/>标签更改为<false/>。 -
搜索
ShowSplashScreenSpinner。 -
将其下面的
<true/>标签更改为<false/>。 -
搜索
ExternalHosts。 -
删除
<array/>行,并用 "<array>", "<string>*</string>" 和 "</array>" 替换它。这并不是您在发布应用程序时总是想要做的事情,但因为它允许我们的应用程序无限制地访问互联网,所以这对于测试目的来说很好。 -
保存文件。
-
启动 Eclipse。
-
导航到文件 | 新建 | 项目…。
-
选择Android 项目。
-
点击下一步 >.
-
选择从现有源创建项目选项。
-
点击浏览。
-
导航到
$PROJECT_HOME/Android/QuizTime/。 -
点击打开。
-
点击下一步 >。
-
取消勾选并重新勾选最高的Google APIs条目。(由于某种原因,Eclipse 在执行此操作时并不总是保留正确的 SDK 版本,因此您可能需要在项目创建后返回并重置它。只需右键单击任何目录,配置构建路径…并转到Android部分。然后您可以重新选择最高的 SDK。)
-
点击下一步 >。
-
将最小 SDK值更改为 8。
-
点击完成。
-
启动 Xcode。
-
导航到文件 | 打开…。
-
导航到
$PROJECT_HOME/iOS中的项目。 -
点击打开。
-
到目前为止,您应该已经打开了 Xcode 和 Eclipse 并加载了项目。请关闭它们;我们现在将使用我们最喜欢的编辑器。
当项目创建时,以下目录结构会出现:
-
/Android: Android 项目 -
/iOS: iOS 项目 -
/www-
/cordova:我们将在这里放置 PhoneGap 支持库。 -
/framework:我们的框架将放在这个目录中。 -
/cultures:任何本地化配置都将放在这里。框架自带en-US。 -
/images:我们所有的图像都将放在这个目录中。 -
/views:所有我们的视图都将放在这里。 -
/models:所有我们的数据模型都将放在这里。 -
/style:我们需要的任何自定义 CSS 都将放在这里。
-
一旦你创建了项目,你还需要从github.com/jquery/globalize下载 jQuery/Globalize 仓库。那里有很多内容,但我们最感兴趣的是lib目录。将globalize.culture.en-US.js和globalize.culture.es-ES.js文件复制到www/framework/cultures目录。(如果你想,也可以复制其他文化文件,如果你想尝试在你所了解的语言中进行本地化。)
注意
如果你使用 Eclipse,你必须确保你在www目录中使用的所有文件都设置为正确的编码。最简单的方法是在assets目录上右键单击,点击属性,然后点击其他。从下拉列表中选择UTF-8选项,然后点击应用。如果你不这样做,完全有可能你的一些本地化内容将无法正确显示。
设计应用程序 – UI/交互
在这个第一个任务中,我们将设计应用程序的外观和感觉,以及指定用户界面中各种元素与播放器之间的交互。对于这个任务的大部分,你可以使用铅笔和纸或者图形编辑器,但在某个时候,你需要一个图形编辑器,如 Adobe Photoshop 或 GIMP,以便创建应用程序所需的某些资源。
开始行动
设计一个可以在多个平台上运行的应用程序时遇到的困难是,每个平台在屏幕上如何显示事物方面都有自己的许多不同想法。有几种方法可以解决这个问题;以下将进行讨论:
-
你可以为你的主要市场构建用户界面,并在所有其他设备上使用完全相同的界面(但要注意;这通常会导致差评)。
-
你可以决定为每个设备定制应用程序的用户界面。这通常需要大量的工作来完成,并且要确保做得“恰到好处”,但这可以是非常有回报的,尤其是当最终用户不知道应用程序不是只为他们的平台编写的。
-
或者,你可以创建一个平台无关的外观和感觉。这是我们在这个应用中将要采取的方向。界面在 iOS 和 Android 设备上都会感觉相当舒适。这并不是说这两个设备上的外观将完全相同;它们不会,但它们将相似,同时也会融入一些特定平台的观念。
在我们走得更远之前,我们需要拿出我们的铅笔和纸,并勾勒出我们想要的应用程序外观的想法。它应该类似于以下截图:

我们的开场视图是一个相当简单的视图。在视图的顶部,我们将有一个包含我们应用程序标题的导航栏。在其他视图中,这个栏通常会包含其他按钮,包括一个返回上一个视图的按钮。在视图的底部,我们将有一个包含与当前视图相关的按钮的工具栏。
应用程序的标题将是一个包含应用程序标题的图像。这个图像应该使用有趣和风格的字体制作。图像将适当本地化。
在工具栏中我们将有一个按钮:一个开始按钮。文本需要本地化。
在导航栏下方是内容区域。在这里我们描述应用将要做什么。我们这里不会有非常花哨的东西;我们的空间有限,尤其是因为我们被限制在手机屏幕大小内。在未来,我们将讨论如何允许内容滚动,但现在我们将保持简洁。
我们确实想要在视图中添加一点魅力,所以我们将添加背景的彩色泼溅。你可以把它做成任何你想要的样子,我们将选择从底部向上射出的彩色光线。
我们的游戏视图看起来如下截图所示:

我们的游戏视图是我们在这个应用程序中最复杂的视图。让我们从外部开始,逐步深入。
在顶部,我们的导航栏将指示当前问题编号。这会让玩家知道他们已经回答了多少问题。在文本的左侧是一个返回按钮。如果点击,它应该将玩家带回到开场视图。
在底部,我们的工具栏中只有一个按钮:跳过。这个按钮将允许玩家跳过他们不想回答的任何问题。目前,我们不会对跳过问题进行任何惩罚,但如果你愿意,可以添加分数扣除或其他更糟糕的惩罚。如果你完全移除了这个按钮,那么也明智地移除工具栏。
在中间是我们的内容区域,这是视图中最复杂的部分;在顶部我们显示玩家的得分,这需要本地化。在其下方是正在提出的问题,同样需要正确本地化。
在问题下方,我们有几个按钮;这些按钮需要根据提出的问题动态生成。并非每个问题都会有三个答案;可能有些有两个答案,或者有四个或更多。答案本身也需要正确本地化。
点击一个按钮将检查按钮是否标记了正确的答案。如果是,我们将显示一个漂亮的提示并增加分数。如果不是,我们将指出这一点并减少分数。
在回答或跳过一个问题后,我们将得到一个新的问题并在屏幕上显示它。然后,在回答了十个问题之后,我们将结束游戏。结束视图看起来如下截图所示:

结束视图与起始视图相似,它并不特别复杂,但它确实有一些额外的功能。它需要正确显示最终得分并允许玩家再次玩游戏。
导航栏包含文本结果以及一个返回按钮。如果被点击,它与从头开始玩游戏相同。
工具栏包含重试?按钮。如果被点击,它也会重新开始游戏。
在内容区域,我们显示包含最终得分和完成日期的消息。
当然,视图上的所有内容都需要进行适当的本地化。数字已经很难处理;日期更是如此。幸好我们有 jQuery/Globalize 可以依赖,否则我们就不得不自己进行日期的本地化工作。
现在我们已经绘制了用户界面,是时候开始构建我们应用程序中需要的资源了。打开您的图形编辑器,构建一个任何视图可能看起来像的模板。我们在这里做的是确定显示的哪些部分需要生成图像,哪些部分可以使用文本或 CSS 生成。
并非绝对需要您有任何特定设备的精确尺寸。毕竟,应用程序可以在许多不同的设备上运行,每个设备都有不同的屏幕尺寸。我们将使用 640 x 920 px,这恰好是 iPhone 4 Retina 显示屏上的可用区域。
注意
您确实需要使用足够高的分辨率进行设计,以便从设计中获得 Retina 品质的资产。也就是说,如果您期望一个图标为 32 x 32 px,您实际上希望它是 64 x 64 px。现在,您是否构建在确切的大小上取决于您,但最好针对您认为将获得最多使用的设备。
这里是我们使用的最终模板:

那里有一点纹理。虽然您可以在 CSS 中完成这个任务,但使用图像更容易。这个纹理是可平铺的,因此它可以适应任何屏幕尺寸。导航栏应放置在images目录中,并命名为NavigationBar.png。
注意标题吗?虽然这也可以通过 CSS 和将字体添加到您的应用程序中处理,但这会涉及到许多棘手的许可问题。相反,我们将使用它的图像,这意味着字体本身永远不会被分发。标题应放置在images目录中,并命名为AppTitle-enus.png。西班牙语版本(应读作*¡Examen Tiempo!)应命名为AppTitle-eses.png。
背景也将是一张图片,尽管你很可能会用 CSS 来近似它(尽管在那里获得纹理可能会有些痛苦)。由于我们支持许多平台和屏幕尺寸,图片方法是最好的。这张图片应保存在images目录中,命名为Background.jpg。
我们将构建应用程序,使图片拉伸以填充屏幕。当然,会有一些轻微的扭曲,但鉴于这张图片只是一个色彩泼溅,这并不重要。(其他选项包括创建不同分辨率的背景,或者创建一个可平铺的背景,可以轻松填充到任何分辨率。)
另一方面,按钮在 CSS 中构建起来很容易,而且在许多平台上很容易正确设置。在最坏的情况下,按钮可能不会那么闪亮或圆润,但它仍然会传达出它应该被触摸的信息。
中间区域是放置其他所有内容的地方,玩家的得分、当前问题、问题的答案等等。由于所有这些都可以用 HTML、CSS 和 JavaScript 轻松实现,所以我们不会担心将这些元素放入模板中。
我们做了什么?
在这个任务中,我们设计了用户界面,并详细说明了各种视图和组件之间的交互。我们指出了哪些部分需要本地化(所有内容!)然后在我们的首选图形编辑器中绘制了一个漂亮的版本。从这个版本中,我们可以剪切需要保存为图片的各种元素,同时确定哪些部分可以用 HTML、CSS 和 JavaScript 渲染。
设计数据模型
数据模型正确性至关重要:这是我们存储问题、问题的答案以及每个问题的正确答案的方式。我们还将定义如何与模型交互,即如何获取问题、询问答案是否正确,等等。
准备工作
让我们再次拿出铅笔和纸,或者如果你更喜欢,使用你熟悉的图表工具。我们真正试图在这个步骤中做的是找出模型需要存储问题的属性,以及它为了正确完成我们所要求的一切所需的交互。
继续前进
我们将基本上有两个数据模型:单个问题和问题集合。让我们从问题模型应该做什么开始:
-
存储实际问题
-
有一个所有可能答案的列表
-
知道正确答案
-
创建时设置问题
-
当被要求时返回问题
-
将答案添加到其答案列表中
-
当被要求时返回答案列表(以随机顺序)
-
设置正确答案
-
当被要求时给出正确答案
-
当被要求时返回列表中的特定答案
-
检查给定的答案是否正确
-
返回答案的数量
我们可以通过创建以下简单的图表来表示这一点:

我们的问题集合应该:
-
拥有一个所有问题的列表
-
能够将问题添加到该列表中
-
返回列表中的问题总数
-
从列表中返回一个随机问题
包含这些点的图表看起来如下截图所示:

在定义了两个模型之后,让我们想出我们将要提出的问题,以及与之相关的答案(对于问题的完整列表,请参阅本书下载中的chapter1/www/models/quizQuestions.js):
| # | 英语 | 西班牙语 |
|---|---|---|
1 |
太阳的颜色是什么? | 太阳的颜色是什么? |
| 绿色 | 绿色 | |
| 白色 | 白色 | |
| 黄色(正确) | 黄色(正确) | |
2 |
第四颗行星的名字是什么? | ¿Cuál es el nombre del cuarto planeta? |
| 火星(正确) | 火星(正确) | |
| 金星 | 金星 | |
| 水星 | 水星 |
我们的模型设计完成,以及我们将要提出的问题,这个任务就完成了。接下来,我们将编写代码来实现这个模型。
我们做了什么?
在这个任务中,我们设计了两个数据模型:单个问题和问题集合。我们还确定了我们将要提出的问题,以及它们的本地化版本。
实现数据模型
我们将在www/models目录中创建两个 JavaScript 文件,分别命名为quizQuestion.js和quizQuestions.js。quizQuestion.js将是实际的模型:它将指定数据应该如何格式化以及我们如何与之交互。quizQuestions.js将包含我们实际的问题数据。
继续前进
在我们定义我们的模型之前,让我们定义一个命名空间,它将驻留于此。这是一个重要的习惯,因为它使我们不必担心是否会与其他具有相同名称的函数、对象或变量发生冲突。
虽然有各种方法可以创建命名空间,但我们将使用以下代码片段简单地完成它:
// quizQuestion.js
var QQ = QQ || {};
现在我们已经定义了命名空间,我们可以创建我们的question对象如下:
QQ.Question = function ( theQuestion )
{
var self = this;
注意
注意self的使用:这将允许我们使用self来引用对象,而不是使用this。(JavaScript 的this有点疯狂,所以我们总是最好引用一个我们知道它始终指向对象的变量。)
接下来,我们将根据第二步中创建的图表设置属性,使用以下代码片段:
self.question = theQuestion;
self.answers = Array();
self.correctAnswer = -1;
我们将self.correctAnswer的值设置为-1,以表示,目前,玩家提供的任何答案都被认为是正确的。这意味着你可以提出所有答案都正确的问题。
我们下一步是定义对象将拥有的方法或交互。让我们从确定一个答案是否正确开始。在以下代码中,我们将取一个传入的答案并将其与self.correctAnswer值进行比较。如果它们匹配,或者self.correctAnswer的值是-1,我们将表示答案正确:
self.testAnswer = function( theAnswerGiven )
{
if ((theAnswerGiven == self.correctAnswer)
|| (self.correctAnswer == -1))
{
return true;
}
else
{
return false;
}
}
我们需要一种方法来访问特定的答案,所以我们将定义answerAtIndex函数如下:
self.answerAtIndex = function ( theIndex )
{
return self.answers[ theIndex ];
}
为了成为一个定义良好的模型,我们应该始终有一种方法来确定模型中的项目数量,如下面的代码片段所示:
self.answerCount = function ()
{
return self.answers.length;
}
接下来,我们需要定义一个方法,允许向我们的对象添加一个答案。注意,借助返回值,我们返回自己以允许代码中的链式调用:
self.addAnswer = function( theAnswer )
{
self.answers.push ( theAnswer );
return self;
}
理论上,我们可以按照对象接收到的顺序显示问题的答案。在实践中,那将是一个非常无聊的游戏:答案总是以相同的顺序出现,而且第一个答案很可能是正确答案。所以让我们使用以下代码片段来给自己一个随机列表:
self.getRandomizedAnswers = function ()
{
var randomizedArray = Array();
var theRandomNumber;
var theNumberExists;
// go through each item in the answers array
for (var i=0; i<self.answers.length; i++)
{
// always do this at least once
do
{
// generate a random number less than the
// count of answers
theRandomNumber = Math.floor ( Math.random() *
self.answers.length );
theNumberExists = false;
// check to see if it is already in the array
for (var j=0; j<randomizedArray.length; j++)
{
if (randomizedArray[j] == theRandomNumber)
{
theNumberExists = true;
}
}
// If it exists, we repeat the loop.
} while ( theNumberExists );
// We have a random number that is unique in the
// array; add it to it.
randomizedArray.push ( theRandomNumber );
}
return randomizedArray;
}
随机列表只是一个数字数组,它索引到answers[]数组。为了获取实际的答案,我们必须使用answerAtIndex()方法。
我们的模型仍然需要一个设置正确答案的方法。再次注意以下代码片段中的返回值,它允许我们稍后进行链式调用:
self.setCorrectAnswer = function ( theIndex )
{
self.correctAnswer = theIndex;
return self;
}
现在我们已经正确地设置了正确答案,如果我们需要询问对象正确答案是什么怎么办?为此,让我们定义一个getCorrectAnswer函数,使用以下代码片段:
self.getCorrectAnswer = function ()
{
return self.correctAnswer;
}
当然,我们的对象还需要返回在创建时给出的任何问题;这可以通过以下代码片段来完成:
self.getQuestion = function()
{
return self.question;
}
}
对于question对象来说,这就足够了。接下来,我们将创建一个容器来存储所有的问题,使用以下代码行:
QQ.questions = Array();
我们可以采取常规的面向对象方法,将容器也做成一个对象,但在这个游戏中我们只有一个问题列表,所以这样做更简单。
接下来,我们需要有能力向容器中添加一个问题,这可以通过以下代码片段来完成:
QQ.addQuestion = function (theQuestion)
{
QQ.questions.push ( theQuestion );
}
任何好的数据模型都需要知道我们有多少个问题;我们可以使用以下代码片段来了解这一点:
QQ.count = function ()
{
return QQ.questions.length;
}
最后,我们需要能够从列表中随机获取一个问题,以便我们可以向玩家展示;这可以通过以下代码片段来完成:
QQ.getRandomQuestion = function ()
{
var theQuestion = Math.floor (Math.random() * QQ.count());
return QQ.questions[theQuestion];
}
我们的数据模型已经正式完成。让我们使用以下代码片段来定义一些问题:
// quizQuestions.js
//
// QUESTION 1
//
QQ.addQuestion ( new QQ.Question ( "WHAT_IS_THE_COLOR_OF_THE_SUN?" )
.addAnswer( "YELLOW" )
.addAnswer( "WHITE" )
.addAnswer( "GREEN" )
.setCorrectAnswer ( 0 ) );
注意我们是如何将addAnswer和setCorrectAnswer方法附加到新的问题对象上的。这就是所谓的链式调用:它帮助我们少写一点代码。
你可能想知道为什么我们使用大写字母来表示问题和答案。这是因为我们将如何本地化文本,这是下一步:
PKLOC.addTranslation ( "en", "WHAT_IS_THE_COLOR_OF_THE_SUN?", "What is the color of the Sun?" );
PKLOC.addTranslation ( "en", "YELLOW", "Yellow" );
PKLOC.addTranslation ( "en", "WHITE", "White" );
PKLOC.addTranslation ( "en", "GREEN", "Green" );
PKLOC.addTranslation ( "es", "WHAT_IS_THE_COLOR_OF_THE_SUN?", "¿Cuál es el color del Sol?" );
PKLOC.addTranslation ( "es", "YELLOW", "Amarillo" );
PKLOC.addTranslation ( "es", "WHITE", "Blanco" );
PKLOC.addTranslation ( "es", "GREEN", "Verde" );
问题与答案本身是实际翻译的钥匙。这有两个作用:它使我们的代码中的键显而易见,这样我们知道文本将在以后被替换,如果我们忘记为某个键包含翻译,它将以大写字母的形式出现。
在前面的代码片段中使用的 PKLOC 是我们用于本地化库的命名空间。它在 www/framework/localization.js 中定义。addTranslation 方法是一个将翻译添加到特定区域的方法。第一个参数是我们为定义翻译指定的区域,第二个参数是键,第三个参数是翻译文本。
PKLOC.addTranslation 函数看起来像以下代码片段:
PKLOC.addTranslation = function (locale, key, value)
{
if (PKLOC.localizedText[locale])
{
PKLOC.localizedText[locale][key] = value;
}
else
{
PKLOC.localizedText[locale] = {};
PKLOC.localizedText[locale][key] = value;
}
}
addTranslation 方法首先检查在 PKLOC.localizedText 数组下是否定义了所需区域的数组。如果存在,它就添加键/值对。如果不存在,它首先创建数组,然后添加键/值对。你可能想知道 PKLOC.localizedText 数组最初是如何定义的。答案是它在脚本加载时定义,在文件中稍微高一点的位置:
PKLOC.localizedText = {};
以这种方式继续添加问题,直到你创建了所有想要的问题。quizQuestions.js 文件包含十个问题。当然,你可以添加尽可能多的。
我们做了什么?
在这个任务中,我们创建了我们的数据模型并为模型创建了一些数据。我们还展示了如何将翻译添加到每个区域。
我还需要了解什么?
在我们继续进行下一个任务之前,让我们先了解一下我们将要使用的本地化库的一些内容。我们的本地化工作分为两个部分:翻译和数据格式化。
对于翻译工作,我们使用我们自己的简单翻译框架,实际上只是一个基于区域的键和值的数组。每当代码请求某个键的翻译时,我们将在数组中查找它,并返回我们找到的任何翻译,如果有的话。但首先,我们需要确定玩家的实际区域,使用以下代码片段:
// www/framework/localization.js
PKLOC.currentUserLocale = "";
PKLOC.getUserLocale = function()
{
确定区域并不难,但也不是像你最初想象的那样容易。WebKit 浏览器下有一个属性(navigator.language),从技术上讲应该返回区域,但在 Android 下有一个错误,因此我们必须使用 userAgent。对于 WP7,我们必须使用三个属性之一来确定值。
因为这需要一些工作,所以我们会检查我们是否已经定义了它;如果我们已经定义了它,我们将返回那个值:
if (PKLOC.currentUserLocale)
{
return PKLOC.currentUserLocale;
}
接下来,我们通过使用 Cordova 提供的 device 对象来确定我们当前所在的设备。我们首先检查它是否存在,如果不存在,我们将假设我们可以使用以下代码片段通过 navigator 对象的四个属性之一来访问它:
var currentPlatform = "unknown";
if (typeof device != 'undefined')
{
currentPlatform = device.platform;
}
如果我们无法确定用户的区域设置,我们将提供合适的默认区域设置,如下代码片段所示:
var userLocale = "en-US";
接下来,如果我们在 Android 平台上,我们将处理解析用户代理。以下代码大量借鉴了在线提供的答案 stackoverflow.com/a/7728507/741043。
if (currentPlatform == "Android")
{
var userAgent = navigator.userAgent;
var tempLocale = userAgent.match(/Android.*([a-zA-Z]{2}-[a-zA-Z]{2})/);
if (tempLocale)
{
userLocale = tempLocale[1];
}
}
如果我们在任何其他平台上,我们将使用 navigator 对象来检索区域设置,如下所示:
else
{
userLocale = navigator.language ||
navigator.browserLanguage ||
navigator.systemLanguage ||
navigator.userLanguage;
}
一旦我们有了区域设置,我们就如下返回它:
PKLOC.currentUserLocale = userLocale;
return PKLOC.currentUserLocale;
}
这种方法被所有我们的翻译代码反复调用,这意味着它需要高效。这就是为什么我们定义了 PKLOC.currentUserLocale 属性。一旦设置,前面的代码就不会再尝试计算它。这也带来了另一个好处:我们可以通过覆盖此属性轻松测试我们的翻译代码。虽然始终重要的是要测试当设备设置为特定语言和区域时代码是否正确本地化,但切换这些设置通常需要相当多的时间。能够设置特定的区域设置有助于我们在初始测试中节省时间,因为它绕过了切换设备设置所需的时间。它还允许我们专注于特定的区域设置,尤其是在测试时。
文本翻译是通过名为 __T() 的便利函数完成的。便利函数将成为我们除特定命名空间之外的唯一函数,因为我们旨在使用易于输入和易于记忆的名称,这些名称不会使我们的代码变得复杂。这尤其重要,因为它们将包装我们代码中的每个字符串、数字、日期或百分比。
__T() 函数依赖于两个函数:substituteVariables 和 lookupTranslation。第一个函数定义如下:
PKLOC.substituteVariables = function ( theString, theParms )
{
var currentValue = theString;
// handle replacement variables
if (theParms)
{
for (var i=1; i<=theParms.length; i++)
{
currentValue = currentValue.replace("%" + i, theParms[i-1]);
}
}
return currentValue;
}
此函数所做的只是处理替换变量。这意味着我们可以在文本中定义一个带有 %1 的翻译,并且我们能够用传递给函数的某个值替换 %1。
下一个函数 lookupTranslation 定义如下:
PKLOC.lookupTranslation = function ( key, theLocale )
{
var userLocale = theLocale || PKLOC.getUserLocale();
if ( PKLOC.localizedText[userLocale] )
{
if ( PKLOC.localizedText[userLocale][key.toUpperCase()] )
{
return PKLOC.localizedText[userLocale][key.toUpperCase()];
}
}
return null;
}
实际上,我们正在检查是否存在针对给定键和区域设置的特定翻译。如果存在,我们将返回翻译,如果不存在,我们将返回 null。请注意,键始终被转换为大写,因此在查找翻译时大小写无关紧要。
我们的 __T() 函数如下所示:
function __T(key, parms, locale)
{
var userLocale = locale || PKLOC.getUserLocale();
var currentValue = "";
首先,我们确定请求的翻译是否可以在区域设置中找到,无论该区域设置是什么。请注意,它可以传递,因此可以覆盖当前区域设置。这可以通过以下代码片段完成:
if (! (currentValue=PKLOC.lookupTranslation(key,
userLocale)) )
{
区域设置通常具有 xx-YY 的形式,其中 xx 是两位字符的语言代码,而 YY 是两位字符的区域代码。我的区域设置定义为 en-US。另一个玩家的可能被定义为 es-ES。
如果你记得,我们只为语言定义了翻译。这带来了一个问题:前面的代码除非我们为语言和国家定义了翻译,否则不会返回任何翻译。
注意
有时定义特定于语言和国家的翻译是至关重要的。虽然从技术角度来看,各个地区可能说同一种语言,但习语往往不同。如果你在翻译中使用习语,你需要将它们本地化到使用它们的特定地区,否则可能会产生混淆。
因此,我们截断国家代码,并尝试以下操作:
userLocale = userLocale.substr(0,2);
if (! (currentValue=PKLOC.lookupTranslation(key, userLocale)) )
{
但我们只为英语(en)和西班牙语(es)定义了翻译!如果玩家的区域设置为fr-FR(法语),前面的代码将失败,因为我们没有为fr语言(法语)定义任何翻译。因此,我们将检查合适的默认值,我们将其定义为en-US,美国英语:
userLocale = "en-US";
if (! (currentValue=PKLOC.lookupTranslation(key, userLocale)) )
{
当然,我们现在又回到了之前的情况:在我们的游戏中没有为en-US定义翻译。因此,我们需要回退到en,如下所示:
userLocale = "en";
if (! (currentValue=PKLOC.lookupTranslation(key, userLocale)) )
{
但如果我们根本找不到翻译怎么办?我们可以抛出一个恶意的错误,也许你确实想这么做,但在我们的例子中,我们只是返回传入的键。如果始终遵循将键大写的约定,我们仍然能够看到某些内容尚未翻译。
currentValue = key;
}
}
}
}
最后,我们将currentValue参数传递给substituteVariables属性,以便处理我们可能需要的任何替换,如下所示:
return PKLOC.substituteVariables( currentValue, parms
);
}
实现起始视图
要创建我们的视图,我们首先需要为其创建文件。文件应命名为startView.html,并位于www/views目录下。我们创建的视图最终将类似于以下 iOS 截图:

对于西班牙语本地化的 Android,视图将如下所示:

在我们实际创建视图之前,让我们定义视图的结构。根据使用的框架,视图的结构可能会有很大的不同。对于 YASMF 框架,我们的视图将包含一些依赖于预定义 CSS 的 HTML,以及在该 HTML 下方定义的一些 JavaScript。你可以很容易地提出将 JavaScript 和内联样式也分离出来的观点,如果你愿意,你可以这样做。
我们所有视图的 HTML 部分将具有以下形式:
<div class="viewBackground">
<div class="navigationBar">
<div id="theView_AppTitle"></div>
<button class="barButton backButton"
id="theView_backButton" style="left:10px" ></button>
</div>
<div class="content avoidNavigationBar avoidToolBar"
id="theView_anId">
</div>
<div class="toolBar">
<button class="barButton" id="theView_aButton"
style="right:10px"></button>
</div>
</div>
如你所见,在这段代码中没有任何可见的文本。由于一切都必须本地化,我们将通过 JavaScript 程序化地插入文本。
viewBackground类将是我们的视图容器:与视图结构相关的所有内容都在其中定义。样式定义在www/framework/base.css和www/style/style.css中;后者是用于我们应用的定制样式。
navigationBar 类表示 div 类只是一个导航栏。对于 iOS 用户来说,这具有即时意义,但对于其他人来说应该也很清楚:这个栏包含视图的标题以及任何用于导航的按钮(例如返回按钮)。请注意,标题和返回按钮都有 id 值。这个值使得我们稍后在 JavaScript 中访问它们变得容易。请注意,我们使用视图名称和下划线来命名空间这些 id 值;这是为了避免使用相同的 id 两次时出现任何问题。
下一个 div 类被赋予 content avoidNavigationBar avoidToolBar 类;所有内容都将放在这里。后两个类指定它应该从屏幕顶部偏移,并且足够短,以避免导航栏(已定义)和工具栏(即将出现)。
最后,定义了工具栏。这是一个与导航栏类似的栏,但目的是包含与视图相关的按钮。对于 Android,这通常会在屏幕顶部或附近显示,而对于 iPhone 和 WP7,这个栏在底部显示。(另一方面,iPad 会将其显示在导航栏下方或导航栏上。我们将在 项目 10 中关注这一点,扩展。)
在此 HTML 块下方,我们将定义我们可能需要的任何本地化模板,然后最后,任何我们需要的 JavaScript。
继续前进
在考虑到这些提示后,让我们创建我们的起始视图,它应该命名为 startView.html 并位于 www/views 目录中,如下所示:
<div class="viewBackground">
<div class="navigationBar">
<div id="startView_AppTitle"></div>
</div>
<div class="content avoidNavigationBar avoidToolBar"
id="startView_welcome">
</div>
<div class="toolBar">
<button class="barButton" id="startView_startButton"
style="right:10px"></button>
</div>
</div>
上述代码几乎与之前定义的视图模板完全相同,只是我们缺少了一个 back 按钮。这是因为我们向用户显示的第一个视图没有返回的地方,所以我们省略了该按钮。id 值也发生了变化,包括我们视图的名称。
尽管如此,这些都没有定义我们的视图将看起来是什么样子。为了确定这一点,我们需要通过在 www/style/style.css 中设置它们来覆盖我们的框架样式。
首先,为了定义 navigationBar 的外观,我们使用项目早期定义的模板中的哑光黑色栏,如下所示:
.navigationBar
{
background-image: url(../images/NavigationBar.png);
color: #FFF;
background-color: transparent;
}
工具栏的定义与此类似,如下所示:
.toolBar
{
background-image: url(../images/ToolBar.png);
}
视图的背景定义如下:
.viewBackground
{
background-image: url(../images/Background.jpg);
background-size: cover;
}
这就是使我们的起始视图开始看起来像真实应用所需的一切。当然,www/framework/base.css 中有很多预构建的内容,您可以在自己的项目中分析和重用。
现在,我们已经定义了视图和外观,我们需要定义一些视图的内容。我们将通过使用几个具有附加到其 id 值的本地化的隐藏 div 元素来完成此操作,如下所示:
<div id="startView_welcome_en" class="hidden">
<h2>PhoneGap-Hotshot Sample Application</h2>
<h3>Chapter 1: Let's Get Local!</h3>
<p>This application demonstrates localization
between two languages, based on your device's
language settings. The two languages implemented
are English and Spanish.</p>
</div>
<div id="startView_welcome_es" class="hidden">
<h2>Ejemplo de aplicación de PhoneGap-Hotshot</h2>
<h3>Capítulo 1: Let's Get Local!</h3>
<p>Esta aplicación muestra la localización
entre los dos idiomas, sobre la base de su dispositivo de
la configuración de idioma. Las dos lenguas aplicadas
son Inglés y Español.</p>
</div>
这两个div元素被标记为hidden,因此它们对玩家不可见。然后我们将使用一些 JavaScript 将它们的内容复制到视图内部的内容区域。这比使用__T()和PKLOC.addTranslation()函数处理所有文本要简单得多,不是吗?
接下来是以下 JavaScript 代码:
<script>
var startView = $ge("startView") || {}; // properly namespace
我们的第一步是将所有脚本放入一个命名空间。与我们的其他大多数命名空间定义不同,我们实际上将利用"startView"元素(聪明的读者会注意到这个元素还没有定义;这将在本项目的末尾附近完成)。虽然这个元素是一个合适的 DOM 元素,但它也为我们提供了一个完美的附加点,只要我们避免使用 DOM 方法名称作为我们自己的,我保证我们不会这样做。
你可能想知道$ge做什么。由于我们没有包含任何 JavaScript 框架如 jQuery,我们没有通过 ID 获取元素的便利方法。jQuery 使用$()方法做这件事,因为你可能会在实际使用 jQuery 的同时使用我们正在使用的框架,我选择使用$ge()方法,简称获取元素。它在www/framework/utility.js中定义,如下代码片段,它只是document.getElementById的简写版本:
function $ge ( elementId )
{
return document.getElementById ( elementId );
}
回到我们的起始视图脚本,我们定义了视图初始化时需要发生的事情。在这里,我们挂钩到视图中的各种按钮和其他界面元素,以及如下本地化所有文本和内容:
startView.initializeView = function ()
{
PKLOC.addTranslation ("en", "APP_TITLE_IMAGE",
"AppTitle-enus.png");
PKLOC.addTranslation ("es", "APP_TITLE_IMAGE",
"AppTitle-eses.png");
startView.applicationTitleImage =
$ge("startView_AppTitle");
startView.applicationTitleImage.style.backgroundImage =
"url('./images/" + __T("APP_TITLE_IMAGE") + "')";
这是我们的第一次使用__T()函数。这就是我们如何正确本地化一个图片。APP_TITLE_IMAGE键被设置为指向标题图片的英语版本或西班牙语版本,而__T()函数根据我们的区域设置返回正确的版本。
PKLOC.addTranslation ("en", "START", "Start");
PKLOC.addTranslation ("es", "START", "Comenzar");
startView.startButton = $ge("startView_startButton");
startView.startButton.innerHTML = __T("START");
现在我们已经正确本地化了start按钮,但我们如何让它做些什么呢?我们使用一个在www/framework/ui-core.js中定义的小函数PKUI.CORE.addTouchListener(),如下所示:
PKUI.CORE.addTouchListener( startView.startButton,
"touchend", startView.startGame );
最后,我们需要使用以下代码片段在内容区域显示正确的欢迎文本:
var theWelcomeContent = $geLocale("startView_welcome");
$ge("startView_welcome").innerHTML =
theWelcomeContent.innerHTML;
}
现在我们介绍另一个便利函数:$geLocale()函数。这个函数的行为类似于$ge()函数,但它假设我们将有一个区域设置附加到我们请求的元素的 ID 上。它在同一个文件(utility.js)中定义,如下所示:
function $geLocale ( elementId )
{
var currentLocale = PKLOC.getUserLocale();
var theLocalizedElementId = elementId + "_" + currentLocale;
if ($ge(theLocalizedElementId)) { return
$ge(theLocalizedElementId); }
theLocalizedElementId = elementId + "_" +
currentLocale.substr(0,2);
if ($ge(theLocalizedElementId)) { return
$ge(theLocalizedElementId); }
theLocalizedElementId = elementId + "_en-US";
if ($ge(theLocalizedElementId)) { return
$ge(theLocalizedElementId); }
theLocalizedElementId = elementId + "_en";
if ($ge(theLocalizedElementId)) { return
$ge(theLocalizedElementId); }
return $ge( elementId );
}
与我们的__T()函数类似,它试图找到一个带有我们完整区域设置的元素(即_xx-YY)。如果找不到,它将尝试_xx,如果我们的区域是英语或西班牙语,这里应该会成功。如果不是,我们将寻找_en-US,如果找不到,我们将寻找_en。如果没有找到合适的元素,我们将返回原始元素——在我们的例子中,这个元素不存在,这意味着我们将返回"undefined"。
在我们的起始视图脚本中接下来,我们有一个函数,每当按下起始按钮时都会被调用,如下面的代码片段所示:
startView.startGame = function()
{
PKUI.CORE.pushView ( gameView );
}
</script>
简短而有力。这向玩家展示了我们的游戏视图,实际上开始游戏。对于支持它的设备(在撰写本文时,iOS 和 Android),玩家还会看到这个视图(起始)和下一个视图(游戏)之间的动画。
如果你想了解更多关于pushView()方法的工作原理,请访问github.com/photokandyStudios/YASMF/wiki/PKUI.CORE.pushView。
呼呼!对于一个相当简单的视图来说,这是一项大量的工作。幸运的是,实际上大部分工作都是由框架完成的,所以我们的实际startView.html文件相当小。
我们做了什么?
我们实现了起始视图,当玩家首次启动应用时呈现给玩家。我们根据玩家的地区正确本地化了视图的标题图像,并且根据地区正确本地化了 HTML 内容。
我们为视图上的小部件定义了各种钩子和文本,例如起始按钮,并将触摸监听器附加到它们上,使它们能够正确地工作。
我们还介绍了一部分框架,它提供了将视图推送到屏幕上的支持。
我还需要了解什么?
猜测起来可能不难,但pushView方法有几个互补函数:popView、showView 和 hideView。
popView 函数与 pushView 函数正好相反,即通过从视图栈中移除视图来将视图向右移动(而不是向左)。
showView 和 hideView 函数基本上做的是同一件事,但更简单。它们根本不做任何动画。此外,由于它们不涉及视图栈上的任何其他视图,所以在应用开始时最为有用,那时我们不得不确定如何显示我们的第一个视图,因为没有之前的视图来进行动画。
如果你想了解更多关于视图管理的知识,你可能想访问github.com/photokandyStudios/YASMF/wiki/Understanding-the-View-Stack-and-View-Management并探索github.com/photokandyStudios/YASMF/wiki/PKUI.CORE。
实现我们的游戏视图
要开始,在www/views目录下创建一个名为gameView.html的文件。完成之后,我们将有一个如下截图所示的视图,适用于 iOS:

对于 Android,视图将如下截图所示:

现在,在我们深入探讨视图本身之前,让我们先了解一下视图栈以及它是如何帮助我们处理导航的。视图栈在下面的屏幕截图中有展示:

视图堆栈实际上只是一个堆栈,它维护着之前可见的视图列表和当前可见的视图。当我们的应用首次启动时,堆栈将是空的,如前一个截图中的第一步所示。然后,使用 showView 方法将 startView 视图推入堆栈,得到(2)中的堆栈。当玩家点击 开始 按钮时,gameView 视图被推入堆栈,结果是(3)中的堆栈。然后,当游戏结束时,我们将 endView 视图推入堆栈,结果是(4)。
由于我们正在跟踪所有这些视图,包括那些不再可见的视图(尤其是在游戏结束时),这使得返回到之前的视图变得容易。对于 iOS,这是通过 后退 按钮实现的。对于 Android,设备通常有一个物理后退按钮被用来代替。无论 back 事件是如何触发的,我们都需要能够回退堆栈。
假设用户现在决定在堆栈中返回;我们将得到(5)中的堆栈。如果他们决定再退一步,将得到(6)。在这个时候,iOS 不允许进一步回退,但对于 Android,另一个 back 事件应该让用户退出应用。
继续前进
游戏视图将非常类似于我们的起始视图,但它稍微复杂一些。毕竟,它要玩整个游戏。幸运的是,这里实际上没有什么特别新的东西,所以应该进展顺利。
让我们从视图的 HTML 部分开始,如下所示:
<div class="viewBackground">
<div class="navigationBar">
<div id="gameView_title"></div>
<button class="barButton backButton"
id="gameView_backButton" style="left:10px"></button>
</div>
<div class="content avoidNavigationBar avoidToolBar"
id="gameView_gameArea">
<div id="gameView_scoreArea" style="height:1em; text-
align: center;"></div>
<div id="gameView_questionArea" style="text-align:
center"></div>
</div>
<div class="toolBar">
<button class="barButton" id="gameView_nextButton"
style="right:10px" ></button>
</div>
</div>
我已经在前面的代码中突出显示了新的内容,但正如你所见,并不多。首先,我们定义了一个位于导航栏中的 back 按钮,在内容区域中我们定义了两个新的区域:一个用于玩家的得分,另一个用于实际的问题(和答案)。
接下来,虽然与起始视图中的本地化内容相似,但我们有模板指定了如何显示问题和其答案;如下所示:
<div id="gameView_questionTemplate" class="hidden">
<h2>%QUESTION%</h2>
<div style="text-align:center;">%ANSWERS%</div>
</div>
首先,我们定义问题模板,它由一个二级标题组成,将包含问题的文本,以及一个包含所有答案的 div 元素。但答案看起来会是什么样子?接下来就会揭晓:
<div id="gameView_answerTemplate" class="hidden">
<button class="barButton answerButton"
onclick="gameView.selectAnswer(%ANSWER_INDEX%);">%ANSWER%
</button><br/>
</div>
每个答案将以一个包含答案文本的按钮形式呈现,并且附加一个 onclick 事件来调用 gameView.selectAnswer() 方法以选择答案。
当然,由于这些是模板,它们不会显示给玩家,因此它们被赋予了 hidden 类。但当我们构建一个实际随机问题以显示给玩家时,我们肯定会使用它们。现在让我们来看看脚本:
<script>
var gameView = $ge("gameView") || {};
gameView.questionNumber = -1;
gameView.score = 0;
gameView.theCurrentQuestion;
到现在为止,你应该已经熟悉我们的命名空间技术,这是我们代码中的第一部分。然而,之后我们定义了视图中的属性。问题编号,它将作为我们的计数器,以便当它达到十时,我们知道游戏结束了;得分;以及当前问题。后者并不明显,但它将是一个实际的问题对象,而不是对象的索引。
之后,我们有initializeView函数,它将连接所有小部件并完成文本的本地化,如下代码片段所示:
gameView.initializeView = function ()
{
PKUTIL.include ( ["./models/quizQuestions.js",
"./models/quizQuestion.js"] );
gameView.viewTitle = $ge("gameView_title");
gameView.viewTitle.innerHTML = __T("APP_TITLE");
gameView.backButton = $ge("gameView_backButton");
gameView.backButton.innerHTML = __T("BACK");
PKUI.CORE.addTouchListener(gameView.backButton, "touchend",
function () { PKUI.CORE.popView(); });
gameView.nextButton = $ge("gameView_nextButton");
gameView.nextButton.innerHTML = __T("SKIP");
PKUI.CORE.addTouchListener(gameView.nextButton, "touchend",
gameView.nextQuestion);
gameView.scoreArea = $ge("gameView_scoreArea");
gameView.questionArea = $ge("gameView_questionArea");
}
我在前面的代码块中突出显示了一些区域。最后几个大致相同,因为我们正在将gameView_scoreArea和gameView_questionArea元素存储到属性中以供以后使用,所以这并不是什么新东西。它的新颖之处在于我们还没有将任何内容加载到它们中。
第二个亮点并不是你真的会添加到生产游戏中的东西。你可能会问,为什么它在这里?这个按钮的目的是让我们能够无惩罚地跳过当前问题。为什么?答案是测试。我不想不得不点击答案,点击提示说如果我对或错了一百万次,看看本地化是否对所有问题都有效。因此,跳过功能就诞生了。
第一个亮点更有趣。它是一个 JavaScript 包含。 “等等,”我听到你说,“JavaScript 不做包含。” 你是对的。
但是,可以通过使用XmlHttpRequest来模拟包含,这通常被称为 AJAX。通过这个简短的包含语句,我们要求浏览器代表我们加载两个引用的 JavaScript 文件(quizQuestions.js和quizQuestion.js)。这一点也很重要;否则,我们的游戏将没有问题!
PKUTIL.include()函数定义在www/framework/utility.js中。我们将在本项目的稍后部分关注完整的实现细节,但可以说,它确实做了它所说的。当我们需要使用问题时,脚本已经加载并等待我们。(此时,有无数问题的读者会问这个关键问题,“顺序重要吗?”答案是,“是的。”你很快就会看到原因。)
因此,现在我们已经完成了gameView的初始化,让我们看看另一个关键方法:viewWillAppear。它如下代码片段所示:
gameView.viewWillAppear = function ()
{
gameView.questionNumber =1;
gameView.score = 0;
gameView.nextQuestion();
}
这段代码的后半部分相当无害。我们将问题编号设置为 1,得分设为零,并调用nextQuestion()方法,它实际上渲染下一个问题并显示给玩家。
如您所记得的,viewWillAppear()函数是在PKUI.CORE.pushView()和PKUI.CORE.showView()方法在视图实际在屏幕上渲染之前的动画之前被调用的。因此,起始视图上的开始按钮将调用此函数,并开始游戏。
当我们通过弹出末尾视图从视图中返回时,它也适用。我们将收到一个viewWillAppear通知,重置游戏,就像用户得到了一个全新的游戏一样。这几乎是魔法!
注意
对于那些使用苹果框架进行过任何 Objective-C 编程的 iOS 开发者,我现在就为使用框架中的概念道歉。只是,嗯,它们与视图模型非常契合!如果你更喜欢 Android 的方法,或者微软的方法,请随意替换。我只是碰巧喜欢苹果为他们平台构建的框架。
当然,当按下返回按钮时,我们需要实际执行一些操作,以下是相应的代码:
gameView.backButtonPressed = function ()
{
PKUI.CORE.popView();
}
popView()方法实际上是pushView的逆操作。它获取当前可见视图(gameView),将其从堆栈中弹出,并显示下层的视图,在这种情况下是startView。这里最好的做法是提示玩家他们是否真的想这样做;这将结束他们的游戏,可能过早。现在,作为一个例子,我们将保持这个状态。
接下来,我们需要定义如何在屏幕上显示问题。我们在nextQuestion()中这样做,如下面的代码片段所示:
gameView.nextQuestion = function ()
{
首先,我们将从QQ命名空间中随机获取一个问题:
// load the next question into the view
gameView.theCurrentQuestion = QQ.getRandomQuestion();
接下来,我们获取我们的模板:
var theQuestionTemplate =
$ge("gameView_questionTemplate").innerHTML;
var theAnswerTemplate =
$ge("gameView_answerTemplate").innerHTML;
现在我们有了模板,我们将按照以下代码片段替换所有"%QUESTION%"的出现,以替换为翻译后的问题:
theQuestionTemplate = theQuestionTemplate.replace(
"%QUESTION%",
__T(gameView.theCurrentQuestion.getQuestion()) );
生成答案稍微有些复杂。任何一个问题可能有两个、三个或更多答案,因此我们首先为随机答案列表中的问题提问,然后在该列表中循环,同时构建 HTML 字符串,如下面的代码片段所示:
var theAnswers =
gameView.theCurrentQuestion.getRandomizedAnswers();
var theAnswersHTML = "";
for (var i=0; i<theAnswers.length; i++)
{
对于每个答案,我们将用翻译后的答案文本替换%ANSWER%文本,并用当前索引(i)替换"%ANSWER_INDEX%",如下面的截图所示:
theAnswersHTML += theAnswerTemplate.replace(
"%ANSWER%",
__T(gameView.theCurrentQuestion.answerAtIndex(
theAnswers[i] ) )).replace ( "%ANSWER_INDEX%",
theAnswers[i] );
}
现在我们已经得到了答案的 HTML,我们可以按照以下方式将%ANSWERS%在问题模板中替换为它:
theQuestionTemplate = theQuestionTemplate.replace (
"%ANSWERS%", theAnswersHTML );
到这一点,我们可以向玩家显示问题:
gameView.questionArea.innerHTML = theQuestionTemplate;
我们还希望更新玩家的分数。我们将采用一个人为的荒谬评分系统来突出我们的本地化是否正确工作。请注意,以下代码片段中的2指定我们希望在分数中保留两位小数。
gameView.scoreArea.innerHTML = __T("SCORE_%1",
[ __N(gameView.score, "2") ]);
我们还将更新视图的标题为当前问题编号。这次代码片段后面的"0"表示没有小数点:
gameView.viewTitle.innerHTML = __T("QUESTION_%1",
[ __N(gameView.questionNumber, "0") ]);
}
所有这些都很不错,但没有用户能够选择答案,这就需要下一个函数来处理:
gameView.selectAnswer = function ( theAnswer )
{
首先,我们将使用以下代码片段来检查所选答案是否正确,并询问当前问题:
if (gameView.theCurrentQuestion.testAnswer ( theAnswer ))
{
如果答案是正确的,我们将告诉用户他们答对了,并按照以下方式增加他们的分数:
alert (__T("CORRECT"));
gameView.score += 483.07;
}
else
{
但如果它是错误的,我们将表明它是错误的,并减少他们的分数(我们可能有点苛刻。其实并不是这样——我们想要测试负数也能工作。),使用以下代码片段:
alert (__T("INCORRECT"));
gameView.score -= 192.19;
}
接下来,我们检查是否已经问完了这个集合中的最后一个问题,如下所示:
if (gameView.questionNumber >= 10)
{
如果我们有,我们将把分数传达给最终视图并将其推入堆栈。这样,游戏就结束了,使用以下代码片段:
endView.setScore ( gameView.score );
PKUI.CORE.pushView ( endView );
}
else
{
在这种情况下,我们还有更多的问题需要回答,所以我们按照以下方式加载下一个问题:
gameView.questionNumber++;
gameView.nextQuestion();
}
}
</script>
这样,我们就完成了游戏视图。告诉我,这并不太难,对吧?
我们做了什么?
我们在一个视图中实现了实际的游戏。我们还学会了如何处理 Android 上的后退按钮和 iOS 上的后退导航。我们还了解了如何使用作为动态内容模板的隐藏 HTML 块。
我还需要知道什么?
如果你还记得,我提到过我们将会更详细地讨论那个奇妙的小include函数。让我们更仔细地看看它:
PKUTIL.include = function ( theScripts, completion )
{
首先,让我给你透露一点:我们在这里使用递归来加载脚本。所以,正如你将在下面的代码中看到的,我们在测试传入数组的长度,如果它是零,我们就调用我们传递给completion方法的completion方法。这允许我们——如果我们喜欢——在所有脚本加载后调用代码。这个代码块如下:
var theNewScripts = theScripts;
if (theNewScripts.length == 0)
{
if (completion)
{
completion();
}
return;
}
在下一节中,我们将弹出下一个要加载的脚本。这也解释了数组必须按照它们的依赖关系逆序包含脚本。是的,你可以自己反转数组,你应该这样做,但我想要强调这一点。弹出脚本的以下代码指令被使用:
var theScriptName = theNewScripts.pop();
然后,我们调用另一个之前未知的函数,PKUTIL.load()。这个方法接受脚本文件名,然后调用我们给它提供的completion函数。无论成功与否,它都会调用它。注意,它是完成函数的传入参数。这个函数在下面的屏幕截图中显示:
PKUTIL.load ( theScriptName, true, function ( success, data )
{
如果脚本成功加载,我们创建一个SCRIPT DOM 元素并将数据添加到其中。需要注意的是,直到我们将脚本实际附加到 DOM 上,脚本都不会有任何动作。我们通过将子元素添加到BODY来实现这一点。正是在这一点上,脚本中的内容将被执行。这个条件if块在下面的代码片段中显示:
if (success)
{
var theScriptElement = document.createElement("script");
theScriptElement.type = "text/javascript";
theScriptElement.charset = "utf-8";
theScriptElement.text = data;
document.body.appendChild ( theScriptElement ); // add it as a script tag
}
如果我们无法加载脚本,我们将在控制台生成一个日志消息。你可以认为应该发生更糟糕的事情,比如一个停止一切操作的致命错误,但这也允许加载可能存在或不存在库,并利用它们。也许这不是一个经常使用的功能,但有时仍然很有用。条件else块如下:
else
{
console.log ("WARNING: Failed to load " + theScriptName );
}
然后向我们的这个小帮手,递归,问好。我们用脚本名称数组(减去我们刚刚弹出的那个)和 completion 函数调用自己,迟早我们会结束于数组中没有项目。然后,completion 函数将像以下代码块中所示那样被调用:
PKUTIL.include ( theNewScripts, completion );
}
);
}
PKUTIL.load() 函数是另一个有趣的生物,它必须正确工作,以便我们的包含能够工作。它定义得像以下这样(对于完整的实现细节,请访问 github.com/photokandyStudios/YASMF/blob/master/framework/utility.js#L126):
PKUTIL.load = function ( theFileName, aSync, completion )
{
首先,我们将检查浏览器是否理解 XMLHttpRequest。如果不理解,我们将使用失败通知和描述我们无法加载任何内容的消息调用 completion,如下所示:
if (!window.XMLHttpRequest)
{
if (completion)
{
completion ( PKUTIL.COMPLETION_FAILURE,
"This browser does not support
XMLHttpRequest." );
return;
}
}
接下来我们设置 XMLHttpRequest,并如下分配 onreadystatechange 函数:
var r = new XMLHttpRequest();
r.onreadystatechange = function()
{
在加载过程中,这个函数可以被多次调用,因此我们需要检查一个特定的值。在这种情况下,4 表示内容已经被加载:
if (r.readyState == 4)
{
当然,仅仅因为我们得到了数据并不意味着它是可用的数据。我们需要验证加载的状态,在这里我们进入了一个有点模糊的区域。iOS 使用 0 值定义成功,而 Android 使用 200:
if ( r.status==200 || r.status == 0)
{
如果我们成功加载了数据,我们将使用成功通知和数据调用 completion 函数,如下所示:
if (completion)
{
completion ( PKUTIL.COMPLETION_SUCCESS,
r.responseText );
}
}
但如果我们未能加载数据,我们将使用失败通知和加载状态值调用 completion 函数,如下所示:
else
{
if (completion)
{
completion ( PKUTIL.COMPLETION_FAILURE,
r.status );
}
}
}
}
请记住,我们仍在设置 XMLHttpRequest 对象,并且我们尚未实际触发加载。
下一步是指定文件的路径,在这里我们在 WP7 与 Android 和 iOS 之间遇到了问题。在 Android 和 iOS 上,我们可以相对于 index.html 文件加载文件,但在 WP7 上,我们必须相对于 /app/www 目录加载它们。虽然难以追踪,但至关重要。尽管我们在这本书中不支持 WP7,但框架支持,因此它需要使用以下代码片段来处理此类情况:
if (device.platform=="WinCE")
{
r.open ('GET', "/app/www/" + theFileName, aSync);
}
else
{
r.open ('GET', theFileName, aSync);
}
现在我们已经设置了文件名,我们开始加载:
r.send ( null );
}
注意
如果你决定支持 WP7,那么即使框架支持为 aSync 传递 false,这应该会导致同步加载,但你实际上绝对不应该这样做。当 WP7 的浏览器无法异步加载数据时,它会做一些奇怪的事情。一方面,它仍然会异步加载(这不是你预期的行为),另一方面,它倾向于认为文件根本不存在。所以,你不会加载脚本,而是在控制台得到错误,表明发生了 404 错误。你(我!)会挠头(我确实这样做了!)想不明白为什么文件就在那里却会出现这种情况。然后你会想起这个长长的笔记,将值改回 true,事情突然开始工作。(你真的不想知道我调试 WP7 了多少小时才最终弄清楚这个问题。我想把那些时间要回来!)
实现结束视图
我们将在 www/views 目录下创建文件名 endView.html。完成之后,我们将得到以下 iOS 视图:

安卓的视图将如下所示:

继续前进
和我们之前的视图一样,第一步是定义 HTML 表示:
<div class="viewBackground">
<div class="navigationBar">
<div id="endView_title"></div>
<button class="barButton backButton" id="endView_backButton" style="left:10px" ></button>
</div>
<div class="content avoidNavigationBar avoidToolBar" id="endView_gameArea">
<div id="endView_resultsArea"></div>
</div>
<div class="toolBar">
<button class="barButton" id="endView_tryAgainButton" style="right:10px" ></button>
</div>
</div>
我在这段代码中突出显示了两个区域:resultsArea,我们将告诉玩家他们的得分,以及工具栏中的按钮,这次是一个 Try Again? 按钮。它的工作方式就像一个返回按钮一样。
接下来,我们需要本地化内容。在这种情况下,它既是本地化内容又是模板,如下面的代码片段所示:
<div id="endView_template_en" class="hidden">
<h2>Congratulations!</h2>
<p>You finished that round with a score of %SCORE%!</p>
<p>Dated: %DATE%</p>
</div>
<div id="endView_template_es" class="hidden">
<h2>¡Felicitaciones!</h2>
<p>¡Se terminó la ronda con una puntuación de %SCORE%!</p>
<p>Fecha: %DATE%</p>
</div>
同样,这些 div 元素被隐藏起来,这样玩家就看不到它们,但我们会取它们的内容,替换 %SCORE% 和 %DATE%,然后将结果内容显示给玩家。
让我们看看我们的脚本:
<script>
var endView = $ge("endView") || {}; // properly namespace
endView.score = 0;
首先,我们将分数设置为零,主要是为了初始化目的。我们将在下一节提供一个设置任何分数的实用函数。你应该记得,当游戏在游戏视图中结束时,会调用这个函数。初始化的代码片段如下:
endView.setScore = function( theScore )
{
endView.score = theScore;
}
如同我们之前所有视图的典型做法,我们有一个 initializeView() 方法。有一点不同是它没有本地化内容区域;这是因为我们此时还不知道分数。initializeView() 函数在游戏甚至开始之前就被调用了,更不用说完成游戏了。这个函数的定义如下:
endView.initializeView = function ()
{
endView.viewTitle = $ge("endView_title");
endView.viewTitle.innerHTML = __T("RESULTS");
endView.backButton = $ge("endView_backButton");
endView.backButton.innerHTML = __T("BACK");
PKUI.CORE.addTouchListener(endView.backButton, "touchend",
function () { PKUI.CORE.popView(); });
endView.nextButton = $ge("endView_tryAgainButton");
endView.nextButton.innerHTML = __T("TRY_AGAIN?");
PKUI.CORE.addTouchListener(endView.nextButton, "touchend",
function () { PKUI.CORE.popView(); });
endView.questionArea = $ge("endView_resultsArea");
}
注意到两个按钮,back 按钮和 try again 按钮都做同样的事情,它们弹出视图。这是因为当我们弹出视图时,gameView 将获得 viewWillAppear 通知,这将重置游戏。
这个视图也需要这样的通知来设置内容区域,因为我们会在 endView 出现在屏幕上时知道分数:
endView.viewWillAppear = function ()
{
var theTemplate = $geLocale("endView_template").innerHTML;
theTemplate = theTemplate.replace ( "%SCORE%",
__N(endView.score, "2") );
theTemplate = theTemplate.replace ( "%DATE%",
__D(new Date(), "D") );
endView.questionArea.innerHTML = theTemplate;
}
我们获取了正确本地化的模板,并用实际得分替换%SCORE%,用当前日期替换%DATE%(这里的D表示长格式日期)。然后我们将其展示给最终用户。所有这些都在视图在屏幕上动画之前发生。
我们需要编写能够处理按下“返回”按钮的代码,这将弹回到“游戏视图”:
endView.backButtonPressed = function ()
{
PKUI.CORE.popView();
}
令人惊讶的是,就是这样。这里真的没有新的领域要覆盖,没有新的框架方法,没有新的实用方法,也没有新的本地化概念。唯一看起来新的就是__D()函数,正如你可能猜到的,它是用来本地化日期的。实际上,还有两个类似的功能:__C(),用于本地化货币,以及__PCT(),用于本地化百分比。我们将在后面的应用程序中处理这些。
我们做了什么?
我们创建了结束视图。我们正确地本地化了内容模板,并本地化了数字和日期。
将一切整合起来
我们几乎已经拥有了一个功能齐全的应用程序,但我们缺少几个关键组件:加载所有内容并启动它的部分。为此,我们将在www目录下创建一个app.js文件和两个 HTML 文件。
继续前进
index.html和index_android.html文件通过加载必要的脚本并调用app.js来启动一切。这些通常是每个应用程序的标准配置,因此在整个书籍的其余部分中它们不会发生太大变化。
首先,index.html,这是为 iOS 准备的,如下所示:
<!DOCTYPE html>
<html>
<head>
<title>Chapter 1 App: Quiz Time</title>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="viewport" content="width=device-width, maximum-scale=1.0" />
<meta name="format-detection" content="telephone=no" />
<link rel="stylesheet" href="./framework/base.css" type="text/css" />
<link rel="stylesheet" href="./style/style.css" type="text/css" />
<script type="application/javascript" charset="utf-8" src="img/cordova-2.2.0-ios.js"></script>
<script type="application/javascript" charset="utf-8" src="img/utility.js"></script>
<script type="application/javascript" charset="utf-8" src="img/app.js"></script>
</head>
<body>
<div class="container" id="rootContainer">
</div>
<div id="preventClicks"></div>
</body>
</html>
接下来是index_android.html,这是为 Android 准备的,如下所示:
<!DOCTYPE html>
<html>
<head>
<title>Chapter 1 App: Quiz Time</title>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="viewport" content="width=device-width, maximum-scale=1.0, target-densityDpi=160" />
<meta name="format-detection" content="telephone=no" />
<link rel="stylesheet" href="./framework/base.css" type="text/css" />
<link rel="stylesheet" href="./style/style.css" type="text/css" />
<script type="application/javascript" charset="utf-8" src="img/cordova-2.2.0-android.js"></script>
<script type="application/javascript" charset="utf-8" src="img/utility.js"></script>
<script type="application/javascript" charset="utf-8" src="img/app.js"></script>
</head>
<body>
<div class="container" id="rootContainer">
</div>
<div id="preventClicks"></div>
</body>
</html>
app.js文件实际上是启动我们应用程序的文件。它也是初始化本地化、设置当前区域设置、加载各种库(如ui-core.js),并最终启动我们的应用程序的文件。现在让我们看看代码:
var APP = APP || {};
如同往常,我们设置了我们的命名空间,这次是 APP。接下来,我们将为deviceready事件附加一个事件监听器;这个事件在 Cordova 完成加载其库时触发。我们必须等待这个事件,然后我们才能做很多事情,特别是依赖于 Cordova 的事情。如果我们不这样做,我们会得到错误。我们将命名空间设置如下:
document.addEventListener("deviceready", onDeviceReady, false);
function onDeviceReady()
{
APP.start();
}
前面的函数所做的只是调用APP.start()函数,该函数定义如下:
APP.start = function ()
{
PKUTIL.include ( [ "./framework/ui-core.js",
"./framework/device.js",
"./framework/localization.js" ],
function () { APP.initLocalization(); } );
}
你已经看到了PKUTIL.include,所以它对你来说不是什么新东西,但在这里我们正在加载三个库,并包含一个completion函数来调用APP.initLocalization。因为包含是异步的,所以我们不能在这次调用之后继续编写依赖于这些库的代码,否则有很大可能库不会及时加载。因此,我们在所有三个库完全加载后调用initLocalization函数。
下一个函数initLocalization通过加载其库来初始化jQuery/Globalize,当它完成时,我们加载我们可能需要的任何地区。当这些地区加载完成后,我们调用APP.init,这里真正的任务开始了。APP.init函数如下所示:
APP.initLocalization = function ()
{
PKLOC.initializeGlobalization(
function ()
{
PKLOC.loadLocales ( ["en-US","en-AU","en-GB",
"es-ES","es-MX","es-US","es"],
function ()
{
APP.init();
} );
}
);
}
APP.init()函数定义了我们应用程序的基本翻译矩阵(你可能在这里看到之前见过的翻译;那是因为它们起源于这里),我们接着将我们创建的三个视图加载到文档中:
APP.init = function ()
{
首先,我们通过将其设置为西班牙语语言和西班牙国家来模拟我们的地区设置。如果你想让应用程序通过查询系统来确定地区,请取消注释该行。
PKLOC.currentUserLocale = "es-ES";
接下来,我们有我们的基本翻译矩阵,应用程序标题,正确和错误的翻译,开始,返回和跳过,等等:
PKLOC.addTranslation ("en", "APP_TITLE", "Quiz Time");
PKLOC.addTranslation ("en", "APP_TITLE_IMAGE", "AppTitle-enus.png");
PKLOC.addTranslation ("en", "CORRECT", "Correct!");
PKLOC.addTranslation ("en", "INCORRECT", "Incorrect.");
PKLOC.addTranslation ("en", "START", "Start");
PKLOC.addTranslation ("en", "BACK", "Back");
PKLOC.addTranslation ("en", "SKIP", "Skip");
PKLOC.addTranslation ("en", "QUESTION_%1", "Question %1");
PKLOC.addTranslation ("en", "SCORE_%1", "Score: %1");
PKLOC.addTranslation ("en", "RESULTS", "Results");
PKLOC.addTranslation ("en", "TRY_AGAIN?", "Try Again?");
PKLOC.addTranslation ("es", "APP_TITLE", "Examen Tiempo");
PKLOC.addTranslation ("es", "APP_TITLE_IMAGE", "AppTitle-eses.png");
PKLOC.addTranslation ("es", "CORRECT", "¡Correcto!");
PKLOC.addTranslation ("es", "INCORRECT", "Incorrecto.");
PKLOC.addTranslation ("es", "START", "Comenzar");
PKLOC.addTranslation ("es", "BACK", "Volver");
PKLOC.addTranslation ("es", "SKIP", "Omitir");
PKLOC.addTranslation ("es", "QUESTION_%1", "Pregunta %1");
PKLOC.addTranslation ("es", "SCORE_%1", "Puntuación: %1");
PKLOC.addTranslation ("es", "RESULTS", "Resultados");
PKLOC.addTranslation ("es", "TRY_AGAIN?", "¿Intentar du nuevo?");
接下来,我们调用PKUI.CORE中的一个函数initializeApplication。这个函数所做的只是附加一个特殊的事件处理器来跟踪设备的方向。但通过这样做,它也将设备、形态和方向附加到BODY元素上,这使得我们能够通过 CSS 针对各种平台。这个函数如下所示:
PKUI.CORE.initializeApplication ( );
接下来,我们加载一个视图,在这种情况下是gameView(顺序在这里并不重要):
PKUTIL.loadHTML ( "./views/gameView.html",
{ id : "gameView",
className: "container",
attachTo: $ge("rootContainer"),
aSync: true
},
function (success)
{
if (success)
{
gameView.initializeView();
}
});
我们通过调用PKUTIL.loadHTML来完成这个任务,如果你认为它和PKUTIL.include非常相似,你就对了。我们稍后会查看其定义,但可以简单地说,我们正在加载gameView.html内的内容,将其包裹在一个具有id值为gameView和class为container的div中,并将其附加到rootContainer上,并指示它可以异步加载。
一旦加载完成,我们将在其上调用initializeView()。
我们以相同的方式加载结束视图:
PKUTIL.loadHTML ( "./views/endView.html",
{ id : "endView",
className: "container",
attachTo: $ge("rootContainer"),
aSync: true
},
function (success)
{
if (success)
{
endView.initializeView();
}
});
我们以几乎相同的方式加载起始视图,如下所示:
PKUTIL.loadHTML ( "./views/startView.html",
{ id : "startView",
className: "container",
attachTo: $ge("rootContainer"),
aSync: true
},
function (success)
{
if (success)
{
startView.initializeView();
PKUI.CORE.showView (startView);
}
});
}
我们唯一不同的地方是在初始化后显示startView。此时游戏已经完全加载并运行,等待玩家点击开始。
我们做了什么?
我们通过创建app.js文件将所有内容整合在一起。我们学习了如何初始化 jQuery/Globalize 库,如何模拟地区设置,以及如何设置我们的翻译矩阵。我们还学习了如何加载视图,以及如何显示第一个视图。
我还需要了解什么?
让我们更仔细地看看PKUTIL.loadHTML:
PKUTIL.loadHTML = function( theFileName, options, completion )
{
var aSync = options["aSync"];
我们首先提取出aSync选项,我们需要它来调用PKUTIL.load。再次提醒,关于 WP7 和同步加载的警告仍然适用。最好假设你总是使用true,除非你可以排除 WP7 作为你的支持平台。我们使用aSync选项如下:
PKUTIL.load ( theFileName, aSync, function ( success, data )
{
if (success)
{
到目前为止,我们已经成功加载了 HTML 文件,如下面的代码片段所示,现在我们必须弄清楚如何处理它:
var theId = options["id"];
var theClass = options["className"];
var attachTo = options["attachTo"];
首先,我们提取出我们需要的其他参数,即 id、className 和 attachTo:
var theElement = document.createElement ("DIV");
theElement.setAttribute ("id", theId);
theElement.setAttribute ("class", theClass);
theElement.style.display = "none";
theElement.innerHTML = data;
接下来,我们创建一个 div 元素,并给它 id 和 class。我们还将数据加载到元素中,如下面的代码块所示:
if (attachTo)
{
attachTo.appendChild (theElement);
}
else
{
document.body.appendChild (theElement);
}
如果可能,我们将连接到 attachTo 中指定的元素,但如果它未定义,我们将连接到 BODY 元素。就在这一点上,我们成为了显示层次结构中的真实 DOM 元素。
不幸的是,这还不是全部。记住,我们的 HTML 文件中包含 SCRIPT 标签。由于某种原因,这些脚本以这种方式加载时不会自动执行。我们必须再次为它们创建 SCRIPT 标签,如下面的代码片段所示:
var theScriptTags = theElement.getElementsByTagName
("script");
首先,我们获取我们新创建元素中的所有 SCRIPT 标签。然后我们将遍历每一个,如下所示:
for (var i=0;i<theScriptTags.length;i++)
{
try
{
// inspired by
http://bytes.com/topic/javascript/answers/513633-innerhtml-script-tag
var theScriptElement =
document.createElement("script");
theScriptElement.type = "text/javascript";
theScriptElement.charset = "utf-8";
if (theScriptTags[i].src)
{
theScriptElement.src = theScriptTags[i].src;
}
else
{
theScriptElement.text = theScriptTags[i].text;
}
document.body.appendChild (theScriptElement);
如果这段代码看起来有些熟悉,那是因为 PKUTIL.include 有一个类似的变体。重要的区别是它只关注脚本的 数据;这里我们必须担心脚本是否定义为外部脚本。这就是为什么我们要检查 SRC 属性是否已定义。
我们还将其放在一个 try/catch 块中,以防脚本中存在错误:
}
catch ( err )
{
console.log ( "When loading " + theFileName +
", error: " + err );
}
}
我们已经完成了 HTML 和脚本的加载,因此我们调用 completion 函数:
if (completion)
{
completion (PKUTIL.COMPLETION_SUCCESS);
}
}
如果由于某种原因我们无法加载视图,我们将生成一个日志消息,并调用 completion 函数,如下所示,带有失败通知:
else
{
console.log ("WARNING: Failed to load " + theFileName );
if (completion)
{
completion (PKUTIL.COMPLETION_FAILURE);
}
}
}
);
}
接下来,我们应该回顾我们遇到的新本地化功能。第一个是 PKLOC.initializeGlobalization():
PKLOC.initializeGlobalization = function ( completion )
{
PKUTIL.include ( [ "./framework/globalize.js" ],
completion );
}
如您所见,它所做的只是加载 jQuery/Globalize 框架,然后调用其完成处理程序。
下一个函数是 PKLOC.loadLocales。它旨在使加载 jQuery/Globalize 文化文件变得容易。这些文件位于 www/framework/cultures 目录中,你可以拥有并加载尽可能多的文件。只需记住,你拥有的越多,你的应用程序就越大,启动所需的时间就越长:
PKLOC.loadLocales = function ( theLocales, completion )
{
for (var i=0; i<theLocales.length; i++)
{
theLocales[i] =
"./framework/cultures/globalize.culture." +
theLocales[i] + ".js";
}
PKUTIL.include ( theLocales, completion );
}
我们利用 PKUTIL.include 接受脚本文件数组的事实。传入的本地化(没有真正的特定顺序;jQuery/Globalize 文化文件只依赖于已加载的 jQuery/Globalize 库)已经在一个数组中,因此我们修改数组以包含文化文件的完整路径和名称。完成之后,我们将它们包含进来,当它们全部加载完毕时,将调用 completion 函数。
游戏结束..... 结束
哇,在这个第一个项目中,我们一起经历了许多。我们也学到了很多,包括:
-
如何正确本地化文本
-
如何正确本地化数字
-
如何正确本地化日期
-
如何正确本地化图像
-
如何正确本地化 HTML
-
如何实现简单的 HTML 模板
-
如何创建一个新的视图
-
如何显示视图
-
如何将新视图推送到屏幕上,并将视图从屏幕上弹出
-
如何处理 Android/WP7 返回按钮
-
如何在我们的 JavaScript 中包含文件
-
如何确定用户的区域设置
-
如何初始化 jQuery/Globalize 以及加载我们可能需要的区域设置
有些资源你可能觉得很有趣。你可能想查阅 YASMF 文档,了解更多关于我们使用的框架的信息。以下是一些资源的提及:
-
Adobe Photoshop 在
www.adobe.com/PhotoshopFamily -
GIMP 在
www.gimp.org -
PhoneGap 下载在
www.phonegap.com/download -
PhoneGap 文档在
docs.phonegap.com -
YASMF GitHub 在
github.com/photokandyStudios/YASMF/ -
YASMF 文档在
github.com/photokandyStudios/YASMF/wiki/ -
Xcode 在
developer.apple.com/xcode -
Eclipse Classic 4.2.1 在
www.eclipse.org/downloads/packages/eclipse-classic-421/junosr1 -
Android SDK 下载在
developer.android.com/sdk/index.html
你能承受住热度吗?热手挑战
这个项目可以通过很多种方式来增强。你为什么不尝试其中之一或更多呢?
-
游戏目前支持英语和西班牙语。尝试添加另一种语言。
-
如果你玩这个游戏任何一段时间,你会发现同一个问题经常在同一个集合中再次被问起。让它变得如此,一个问题在每个集合中只能问一次。
-
为问题添加类别,然后允许用户选择他们想要玩过的类别。
-
为问题添加难度级别。这些可能会影响所获得的分数。允许用户选择他们想要玩的难度级别。
-
提出一个游戏的不同外观和感觉,并实现它。也许甚至允许用户决定他们想要使用的外观和感觉。
第二章。让我们社交起来!
社交网络已经改变了我们在这个世界上分享信息的方式。过去,它可能是一封发往朋友的电子邮件(甚至是一封信!),现在它可能是一条 Twitter 或 Facebook 帖子,常常是供全世界看到的。更令人惊讶的是,各种社交网络相对较年轻,它们是如何迅速改变我们的沟通和信息消费方式的。正因为这种转变,我们的应用程序需要支持社交网络分享,否则我们的应用程序可能会显得过时。
我们要构建什么?
在这个项目中,我们将构建一个应用程序,展示社交网络方程的两个方面。一方面是消费来自各种来源的信息;我们将使用 Twitter 流来完成这项工作。另一方面是分享信息;我们将使用每个平台的本地分享功能来完成这项工作,但在 iOS 上,我们将使用一个名为 ShareKit 的项目来实现分享。(请注意,iOS 5 支持 Twitter 分享,iOS 6 将其扩展到 Facebook。迟早会有一个插件支持这些功能,但 ShareKit 提供了更多的目标。)
它能做什么?
我们的应用程序,名为 Socializer,将显示来自五个预设 Twitter 账户的 Twitter 流。用户可以阅读这些流,如果他们发现一条有趣的推文,他们可以点击它来进行更多操作。例如,他们可能希望查看推文中嵌入的链接。更重要的是,最终用户可能希望使用他们自己的社交网络来分享信息,应用程序将提供一个分享按钮来实现这一点。
为了实现这一点,我们将与 Twitter 的 JSON API 合作,这对于主要用 JavaScript 编写的应用程序来说是一个自然的选择。唯一的缺点是 Twitter 对 API 请求的速率限制设定了一个相当低的上限,因此我们还将为这种情况构建一些基本支持。(说实话,这种情况更有可能发生在我们作为开发者身上,而不是用户,因为我们经常重新加载应用程序来测试新功能,这会导致新的 API 请求比最终用户通常产生的要快得多。)
我们还将介绍 PhoneGap 插件的概念,因为默认情况下,典型的 PhoneGap 安装中不包含分享功能。插件本质上是在一定数量的本地代码(如 Java、Objective-C 或 C#)和我们的 JavaScript 代码之间的一座桥梁。
对于这个项目,我们将为每个我们支持的平台使用两个插件。一个是ChildBrowser,它在大多数平台上都受到支持,这使得编写使用它的代码变得容易得多。第二个是基于平台的分享能力和该平台和 PhoneGap 可用的插件。由于这些各不相同,我们将不得不为每个平台处理不同的代码路径,但理念将是相同的——分享内容。
为什么它很棒?
这个项目是使用 JSON 处理 API(包括 Twitter 的 API)的绝佳入门,尽管我们只使用了 Twitter API 的一小部分,但在这个项目中学到的经验可以扩展到处理其他 API。此外,JSON API 在许多网络平台上被广泛使用,学习如何处理 Twitter 的 API 是学习如何处理任何 JSON API 的绝佳方法。
我们还将处理如何共享内容。虽然 Android 为所有提供良好共享应用列表的应用程序提供了一个共享机制,但 iOS 没有。因此,我们还需要编写特定于平台的代码来处理每个平台(以及相应的插件)在支持共享方面的差异。
我们还将与 PhoneGap 插件一起工作,许多应用最终都会以某种方式需要这些插件。例如,我们的应用应该能够处理指向外部网站的链接;最好的方式是让ChildBrowser插件来处理。这使用户能够留在我们的应用中,并在完成操作后轻松返回。如果没有它,我们就需要将用户从应用中移出,进入默认浏览器。
我们将如何进行?
为了做到这一点,我们将把我们的应用创建过程分解成几个不同的部分,如下所示:
-
设计应用 – UI/交互设计
-
设计应用 – 数据模型
-
实现数据模型
-
配置插件
-
实现社交视图
-
实现推文视图
就像在之前的那个项目中一样,在处理实现之前,我们将专注于应用的设计。
我需要准备些什么才能开始?
您需要继续创建您的项目,就像我们在上一个项目中做的那样。在一定程度上,您可以复制上一个项目,并替换必要的文件和设置。还有一个额外的 iOS 设置需要修改,但这完全取决于个人喜好(您是否喜欢黑色或灰色的状态栏):
-
打开
Socializer-info.plist。 -
添加
状态栏样式并将其设置为UIStatusBarStyleOpaqueBlack。
我们还将使用与上一个项目相同的目录结构,但有两个例外:我们将添加一个www/childbrowser目录和一个www/plugins目录。在www/plugins目录下,我们将为每个平台创建一个目录:即/www/plugins/Android和/www/plugins/iOS。我们将在稍后填充这些目录,但现在请先创建它们。
我们将使用相同的框架,所以请确保复制框架文件。我们不会担心本地化内容,但即便如此,我们也会使用所有本地化功能,以便于将来进行本地化。我们还将使用一个名为scroller.js的脚本来处理www/framework中的滚动,您需要将其添加到索引文件中才能正确使用,如下面的代码片段所示:
<script type="application/javascript" charset="utf-8" src="img/scroller.js"></script>
你还需要下载位于www.github.com/phonegap/phonegap-plugins的 PhoneGap 插件库。这将确保你拥有我们需要的所有必要插件,以及你可能感兴趣自己使用的任何插件。
最后,对于 iOS,我们需要获取位于github.com/ShareKit/ShareKit的 ShareKit 2.0 插件。由于它的分发方式,你需要安装 Git 并确保为项目启用 Git。(或者,你可以使用下载文件中的/Submodules目录)。
设计应用程序 – UI/交互设计
我们的首要任务是设计用户界面以及各种小部件和视图之间的交互。就像在之前的任务中一样,我们将有三个视图:开始视图、社会视图和推文视图。
开始行动
我们将从开始视图开始。就像在最后一个项目中一样,这将是一个非常简单的视图,在这个应用程序中完全是可选的。我们将会解释应用程序并提供一种方式来移动到主视图。
考虑到这一点,以下是我们开始视图的截图:

在这个截图中,我们有一个开始按钮(1),它将社会视图推入视图堆栈。我们还有一些说明性文字(2)。
我们下一个视图是社会视图,如下面的截图所示:

社会观点本质上是一系列推文的列表,一个接一个。我们将一次显示几条推文,因此我们不得不处理滚动问题。虽然你可以使用各种库来完成这个任务,但我们将使用我们自己的极简滚动库。
每条推文将包括一个个人资料图片(1),屏幕名称和真实姓名(如果可用)(2),以及推文文本(3)。当用户点击一条推文时,我们将过渡到推文视图。
在视图底部(4),我们有五个不同 Twitter 账户的系列个人资料图片。这些图片将从 Twitter 本身检索;我们不会自己存储这些图片。当图片被点击时,我们将加载相应的 Twitter 流。
我们的推文视图看起来如下面的截图所示:

首先,请注意,我们的推文视图重复了用户在流视图中点击的推文(1)。相同的信息被重复,但我们还列出了推文可能包含的各种网页链接(2),任何标签(3),以及任何用户提及(4)。项目(2)到(4)旨在可点击:也就是说,如果用户点击(2),他们应该被带到特定的网站。如果他们点击(3),他们应该返回到包含该标签的推文流的社会视图。如果他们点击(4),也应该发生同样的情况,除了它会是那个特定用户的流。
在我们的导航栏中,我们还有一个 返回 按钮用于将用户带回到上一个视图,以及工具栏中的 分享 按钮(5)。当按下此按钮时,应该显示一个包含各种社交网络服务的列表(6)。这个列表的外观将取决于应用程序所在的平台以及设备上安装的社交网络。
现在我们已经创建了我们的草图,我们需要定义我们将需要的某些资源。让我们打开我们的编辑程序,开始设计我们的应用程序。

上一张截图相当好地展示了我们的最终产品将是什么样子。其中很多都可以通过 CSS 完成。Twitter 流和导航栏的背景是唯一两个可能比较困难的部分,因此我们应该将它们保存到我们的 www/images 目录中,分别命名为 Background.png 和 NavigationBar.png。注意,它们都有纹理,所以请确保以这种方式保存它们,以便它们可以无缝拼接。
我们做了什么?
对于这个任务,我们已经定义了我们的 UI 应该看起来什么样,以及小部件和视图之间的各种交互。我们还使用我们的图形编辑器创建了一个应用程序的草图,并为后续使用创建了一些图像资源。
设计应用程序 – 数据模型
在这个任务中,我们将设计我们的数据模型来处理 Twitter 用户和流。我们的模型在一定程度上将依赖于 Twitter 的模型。我们将未修改地使用它从其 API 返回的结果。我们将在这个任务中定义模型的其他部分。
开始行动
让我们看看我们的数据模型:

我们将使用 TWITTER 作为命名空间,并在其中,我们将使用两个经常使用的对象:TwitterUser 和 TwitterStream。TwitterUser 的理念是作为一个特定用户的实例,我们将通过在流视图的工具栏上的一个图像来表示它。TwitterStream 对象将表示一个单一的流。
让我们更仔细地检查 TwitterUser。该对象有两个属性:screenName 和 userData。screenName 属性包含用户的 Twitter 用户名。userData 属性将包含来自 Twitter API 的响应。它将包含有关用户的大量不同信息,包括他们的个人资料图片 URL、他们的真实姓名等等。
构造函数将根据提供的屏幕名称返回一个初始化的 TwitterUser。内部,构造函数只是调用 setScreenName() 方法,该方法将从 Twitter 请求用户数据。getScreenName() 方法简单地返回屏幕名称。getProfileImageUrl() 方法将返回用户个人资料的图片 URL。getUserData() 方法将返回 Twitter 返回的数据,而 getTimeline() 方法将为特定用户创建一个 TwitterStream 对象。
TwitterStream对象基于类似的想法:它将存储 Twitter 返回的数据。TwitterStream对象还提供了获取特定用户流的能力,以及返回任何搜索(如标签)流的能力。
当构造时,我们传递三个选项:屏幕名或搜索短语,要返回的最大推文数量(最多 200 条),以及当流完全加载时调用的函数。它将调用loadStream()方法来进行实际的加载。
我们有一些与对象中的属性相关的方法,例如setScreenName()、setSearchPhrase()、getSearchPhrase()、setMaxCount()、getMaxCount()和getStream()。
setScreenName()方法与设置searchPhrase()方法做的是同样的事情,只不过它会在名字前添加一个@字符。然后loadStream()方法可以决定在加载流时调用哪个 API,要么调用 API 返回用户的流,要么调用搜索 API。
我们做了什么?
我们为我们的应用程序创建并定义了数据模型。我们定义了两个对象:即TwitterUser和TwitterStream,并看到了它们是如何交互的。
实现数据模型
我们将创建两个文件:即twitter.js和twitterUsers.js。将这些文件放在www/models目录下。
继续前进
让我们从twitter.js文件开始:
var TWITTER = TWITTER || {};
和往常一样,我们定义了我们的命名空间,在这个例子中,是TWITTER,如下代码片段所示:
TWITTER._baseURL = "http://api.twitter.com/1/";
TWITTER._searchBase = "http://search.twitter.com/";
我们定义了两个属于TWITTER命名空间的全局变量:即_baseURL和_searchBase。这两个 URL 指向 Twitter 的 JSON API;第一个用于 API 请求,如用户查找、用户流等,而后者仅用于搜索。我们在这里定义它们有两个原因:使 URL 在接下来的代码中不那么令人讨厌,并且如果 Twitter 决定有不同版本的 API(并且你想更改它),你可以在这里进行更改。
接下来,我们定义我们的第一个对象,TwitterUser:
TWITTER.TwitterUser = function ( theScreenName, completion )
{
var self = this;
self._screenName = "";
self._userData = {};
我们已经定义了我们的两个属性:_screenName和_userData。与上一个项目不同,我们使用下划线来表示这些是内部(私有)变量,外部对象不应访问。相反,外部对象应使用我们定义的下一个get/set方法:
self.getScreenName = function ()
{
return self._screenName;
}
这个很简单,它只是当被请求时返回私有成员。但下一个更复杂:
self.setScreenName = function ( theScreenName, completion
)
{
self._screenName = theScreenName;
就像正常的set方法一样,我们将theScreenName赋值给_screenName。但在这个时候,我们希望从 Twitter 加载用户信息。这就是为什么在私有方法前有get/set方法很重要的原因。你可能只需要在值改变或读取时做一些重要的事情。
var getUserURL = TWITTER._baseURL + "users/lookup.json?screen_name=" + encodeURIComponent(theScreenName);
在这里,我们定义了我们将要用来请求 Twitter 查找特定用户的 URL。有关此特定 URL 如何工作的更多信息,请参阅 Twitter 文档dev.twitter.com/docs/api/1/get/users/lookup。您可以在页面底部看到一个完整的返回示例。
现在我们有了我们的 URL,我们将使用在PKUTIL中为我们定义的另一个实用函数loadJSON()。它使用 AJAX 向前面的 URL 发送请求,然后 Twitter 以 JSON 的形式发送响应。当它完成时,该函数将调用作为getUserURL的第二个参数传递的completion函数。此方法可以检查请求是否成功,并设置任何必要的私有成员。我们还将调用传递给setScreenName()方法的completion函数。这些操作在下面的代码片段中定义:
PKUTIL.loadJSON ( getUserURL, function (
success, data )
{
if (success)
{
self._userData = data;
如果成功为真,那么 JSON 已经被正确返回并解析到数据参数中。我们只需将其分配给前面代码块中看到的私有_userData成员。
}
else
{
self._userData = { "error": "Twitter error; rate
limited?" };
但如果成功为假,那么可能出了问题。可能发生了任何数量的情况,Twitter 可能宕机(并非闻所未闻),网络连接可能失败,或者 Twitter 可能对我们进行了速率限制。目前我们只是假设后者,但您当然可以构建更复杂的错误检测方案来确定是哪一个。
}
if (completion)
{
completion ();
}
最后,无论成功与否,我们都会调用传递给我们的completion函数。这很重要,这样我们就可以知道何时可以安全地访问_userData成员(通过稍低一点的getUserData)。
}
);
}
self.getProfileImageURL = function ()
{
if (self._userData[0])
{
return self._userData[0].profile_image_url;
}
return "";
}
在前面的代码片段中,getProfileImageURL()方法是一个便利函数,用于返回用户的个人资料图片 URL。这是一个指向 Twitter 所使用的头像的链接。首先我们检查_userData[0]是否存在,如果存在,则返回profile_image_url,这是一个由 Twitter API 定义的值。如果不存在,我们只返回一个空字符串。
self.getUserData = function ()
{
return self._userData;
}
接下来,使用getUserData()方法来返回_userData成员。如果它已经被正确加载,它将包含很多值,所有这些值都是由 Twitter 决定的。如果它未能加载,它将包含一个错误属性,如果它根本未加载,它将是空的。
self.getTimeline = function ( theMaxCount, completion )
{
return new TWITTER.TwitterStream ( "@" +
self._theScreenName, completion, theMaxCount || 25
);
}
getTimeline()也是一个便利函数,用于获取 Twitter 用户的微博。theMaxCount是返回的最大推文数(最多 200 条),而completion是一个在所有操作完成后要调用的函数。我们通过创建一个新的TwitterStream对象(在下面的代码片段中定义)并在 Twitter 屏幕名称前加上一个@字符来实现这一点。
如果未指定theMaxCount,我们使用一个小的||技巧来表示默认值,即25条推文。
self.setScreenName ( theScreenName, completion );
}
我们最后要做的事情实际上是调用setScreenName()方法,并将构造函数中传入的theScreenName和completion函数传递给它。如果你还记得你的 JavaScript,这个整个函数,虽然我们可以将其视为定义一个对象,但实际上也是该对象的构造函数。在这种情况下,一旦你创建了TwitterUser对象,我们就会向 Twitter 发送一个请求来加载用户数据并将其设置为_userData。
我们下一个对象是TwitterStream对象,定义如下:
TWITTER.TwitterStream = function (
theScreenNameOrSearchPhrase, completion, theMaxCount )
{
var self = this;
self._searchPhrase = "";
self._stream = {};
self._theMaxCount = 25;
在这里,我们定义了三个属性,即_searchPhrase、_stream和_theMaxCount。_searchPhrase属性可以是用户的屏幕名称或一个字面搜索词,如标签。_stream属性是从 Twitter 获取的实际推文集合,而_theMaxCount属性是请求的推文最大数量。(请记住,Twitter 可以返回少于这个数量的推文。)
你可能会问为什么我们要存储搜索词或屏幕名称。原因是我们在尝试促进代码重用。假设 Twitter 流是 Twitter 流,这是合乎逻辑的,无论它是通过请求特定用户的流还是通过搜索一个词来找到的。这是一个很好的假设,对吧?
是的,完全错误。流非常接近,足够接近以至于我们可以处理差异,但仍然不是同一个。所以尽管我们在这里将它们视为同一事物,但实际上它们并不是,至少直到 Twitter 决定更改其搜索 API 以更好地匹配其非搜索 API。
self.setMaxCount = function ( theMaxCount )
{
self._theMaxCount = theMaxCount;
}
self.getMaxCount = function ()
{
return self._theMaxCount;
}
在这里,我们有_theMaxCount的get/set方法。我们只是设置和检索值。有一点要注意的是,这应该在实际上载流之前调用;这个值是我们发送给 Twitter 的最终 URL 的一部分。
self.setScreenName = function ( theScreenName )
{
self._searchPhrase = "@" + theScreenName;
}
self.setSearchPhrase = function ( theSearchPhrase )
{
self._searchPhrase = theSearchPhrase;
}
self.getSearchPhrase = function ()
{
return self._searchPhrase;
}
注意,当我们只有一个get方法时,我们有两个set方法作用于_searchPhrase。我们在这里做的是允许某人调用setScreenName()而不带@字符。然后_searchPhrase属性将被设置为在屏幕名称前添加的@。下一个set方法(setSearchPhrase())是为了在设置真实搜索词(如标签)时使用。
在内部,我们将使用前面的@来表示特殊含义,但你会看到这一点。
self.getStream = function ()
{
return self._stream;
}
getStream()方法只是返回我们的_stream,如果我们还没有加载一个,它将是空的。所以让我们看看loadStream()方法:
self.loadStream = function ( completion )
{
var theStreamURL;
var forScreenName = false;
loadStream()方法接受一个completion函数;无论操作结果如何,我们都会在操作结束时调用它。它让我们的其他代码知道何时可以通过getStream()安全地访问_stream成员。
另一个组件是forScreenName变量;如果为真,我们将请求 Twitter 获取存储在_searchPhrase中的屏幕名称所属的流;否则,我们将请求 Twitter 对_searchPhrase进行实际搜索。这个变量在以下代码片段中定义:
if (self._searchPhrase.substr(0,1)=="@")
{
theStreamURL = TWITTER._baseURL +
"statuses/user_timeline.json?include_entities=
true&include_rts=true&count=" +
self._theMaxCount + "&screen_name=" +
encodeURIComponent(self._searchPhrase);
forScreenName = true;
}
else
{
theStreamURL = TWITTER._searchBase +
"search.json?q=" +
encodeURIComponent(self._searchPhrase) +
"&include_entities=true" +
"&include_rts=true&rpp=" + self._theMaxCount;
forScreenName = false;
}
我们到目前为止所做的一切只是定义theStreamURL指向搜索 API(对于搜索词)或非搜索 API(对于屏幕名的流)。接下来,我们将使用以下代码片段中的loadJSON( )来加载它:
PKUTIL.loadJSON ( theStreamURL, function (success,
data)
{
if (success)
{
if (forScreenName)
{
self._stream = data;
}
else
{
self._stream = data.results;
}
}
这里是另一个我们需要知道我们是在处理屏幕名还是搜索的原因,我们得到的 JSON 略有不同。在搜索时,Twitter 会很有帮助地包括其他信息(例如执行搜索所需的时间)。在我们的情况下,我们只对结果感兴趣,因此有两个不同的代码路径。
else
{
self._stream = { "error": "Twitter error; rate
limited?" };
}
再次,如果我们遇到失败,我们假设我们受到了速率限制。
if (completion)
{
completion( self._stream );
}
完成后,我们调用completion方法,并帮助传递数据流。
}
);
}
self.setSearchPhrase ( theScreenNameOrSearchPhrase );
self.setMaxCount ( theMaxCount || 25 );
self.loadStream ( completion );
}
就像在上一个对象的末尾一样,我们也在这个对象的末尾调用了一些方法。首先,我们设置搜索短语,然后设置要返回的最大推文数量(如果没有给出,则为 25),然后使用completion函数调用loadStream()方法。这意味着当我们创建一个新的TwitterStream对象时,它已经开始加载我们将要访问的所有推文。
我们已经处理了几乎所有的数据模型,但在twitterUsers.js中我们还有一小部分工作要做:
TWITTER.users = Array();
首先,我们在TWITTER命名空间中创建一个users()数组。我们将使用这个数组来存储预定义的 Twitter 用户,这些用户将通过以下loadTwitterUsers()方法加载:
TWITTER.loadTwitterUsers = function ( completion )
{
TWITTER.users.push ( new TWITTER.TwitterUser ( "photoKandy" , function ()
{ TWITTER.users.push ( new TWITTER.TwitterUser ( "CNN" ,
function ()
{ TWITTER.users.push ( new TWITTER.TwitterUser (
"BBCWorld" , function ()
{ TWITTER.users.push ( new TWITTER.TwitterUser (
"espn", function ()
{ TWITTER.users.push ( new TWITTER.TwitterUser (
"lemondefr", completion ) ); }
) ); }
) ) ; }
) ) ; }
) ) ;
}
我们在这里本质上只是将针对五个不同 Twitter 账户的五个请求连接在一起。你可以将它们存储在数组中并一次性请求它们,是的,但我们的应用程序需要知道它们何时全部加载。你也可以通过使用用户数组的递归来完成这个任务,但我们将把它作为一个例子留给读者。
我们做了什么?
我们实现了我们的数据模型,并预定义了我们想要使用的五个 Twitter 账户。我们还介绍了PKUTIL中的loadJSON()方法,它有助于整个流程。我们还介绍了 Twitter API。
我还需要知道什么?
在我们继续之前,让我们看看你刚刚接触到的loadJSON()方法。它已被添加到这个项目的www/framework/utility.js文件中,定义如下:
PKUTIL.loadJSON = function ( theURL, completion )
{
PKUTIL.load( theURL, true, function ( success, data )
{
首先,这是一个相当简单的函数。我们真正做的是利用PKUTIL.load()来完成调用 URL 并传递给我们响应的繁重工作,但是当响应被接收时,它将作为数据变量返回给我们。
var theParsedData = {};
theParsedData变量将存储实际已完全解析的 JSON 数据。
if (success)
{
try
{
theParsedData = JSON.parse ( data );
如果 URL 返回成功的结果,我们尝试解析数据。假设它是一个有效的 JSON 字符串,它将被放入theParsedData中。如果不是,JSON.parse()将抛出一个异常,如下面的代码块所示:
}
catch (err)
{
console.log ("Failed to parse JSON from " + theURL);
success = COMPLETION_FAILURE;
}
任何异常都将记录到控制台,并且我们将告诉我们的completion函数请求失败,如下面的代码块所示:
}
if (completion)
{
completion (success, theParsedData);
}
最后,我们调用我们的completion函数,并告诉它请求是否失败或成功,以及 JSON 数据(如果成功解析)。
}
);
}
配置插件
大多数 PhoneGap 插件安装或配置并不特别困难,但它们无疑将在您的应用中扮演至关重要的角色,尤其是如果您需要使用 PhoneGap 本身不提供的功能。
在我们的案例中,我们需要两个插件,一个用于在我们的应用中显示网站,另一个用于分享内容。对于前者,我们将使用名为ChildBrowser的插件在所有我们支持的平台上使用,但对于后者,我们将不得不为 iOS 和 Android 使用不同的插件。
完成后,每个分享插件将看起来如下,从 iOS 开始:

对于 Android,分享插件将如下所示:

准备工作
如果您还没有做,您应该下载位于github.com/phonegap/phonegap-plugins的整个社区 PhoneGap 插件仓库。这将为您提供使用插件所需的几乎所有内容。
如果您希望在 iOS 上支持分享,您还需要下载 ShareKit 2.0,它可在github.com/ShareKit/ShareKit找到,或者使用本书提供的代码捆绑的分支。它位于每个项目的目录之外,在一个标记为Submodules的目录中。
开始行动
我们将根据每个平台的不同步骤和环境来拆分这项工作。
iOS 插件配置
您可能不知道,但我们的第一个平台也是最难的一个。实际上,它会让剩下的两个平台感觉有点像儿戏。
ChildBrowser插件本身易于安装和配置,但 ShareKit 2.0 则不然,尤其是在与 PhoneGap 一起使用时。问题源于当您将 PhoneGap 和 ShareKit 2.0 一起编译项目时,一些符号会重复,链接器会抛出一个讨厌的小错误。长话短说,您的应用无法编译。这可不是什么好事。
让我们首先看看安装ChildBrowser插件所需的步骤,因为这些步骤对于大多数插件来说更为典型:
-
打开您下载的插件集合,导航到
iOS/ChildBrowser。 -
将
ChildBrowser.bundle、ChildBrowserCommand.h、ChildBrowserCommand.m、ChildBrowserViewController.h、ChildBrowserViewController.m和ChildBrowserViewController.xib拖入 XCode 的Socializer/Plugins中,如下面的截图所示:![iOS 插件配置]()
-
在提示时,请确保复制文件(而不是链接到它们)。这可以通过勾选将项目复制到目标组的文件夹中选项来完成,如下截图所示:
![iOS 插件配置]()
-
将
ChildBrowser.js复制到您的www/plugins/iOS目录。您可以在 XCode 或 Finder 中完成此操作。 -
在 XCode 中的
Socializer/Supporting Files中的Cordova.plist中添加插件。找到插件行,并添加一个新条目,如下表所示:ChildBrowserCommandStringChildBrowserCommand这可以通过以下截图更好地解释:
![iOS 插件配置]()
好了,很简单,不是吗?现在来点难的,安装并使 ShareKit 2.0 运行起来。为此,我们将参考附录 B,安装 ShareKit 2.0,因为这个过程相当长。
完成后,我们需要复制为ChildBrowser所做的插件设置,除了 ShareKit,按照以下步骤进行:
-
导航到插件仓库中的
iOS/ShareKitPlugin目录。 -
将
ShareKitPlugin.h、ShareKitPlugin.m、SHKSharer+Phonegap.h、SHKSharer+Phonegap.m复制到项目中的Plugins文件夹。 -
将
ShareKitPlugin.js复制到您的www/plugins/iOS文件夹。 -
修改
Cordova.plist以添加此新插件到列表中。 -
找到插件行,并添加一个新条目,如下表所示:
ShareKitPluginStringShareKitPlugin这可以通过以下截图更好地解释:
![iOS 插件配置]()
最后一步是更新我们的www/index.html文件,以包含这两个插件。在加载cordova-2.2.0-ios.js脚本之后的行后添加以下行:
<script type="application/javascript" charset="utf-8"
src="img/ShareKitPlugin.js"></script>
<script type="application/javascript" charset="utf-8"
src="img/ChildBrowser.js"></script>
呼呼!我们做到了,我们为 iOS 设备安装了两个插件。现在,让我们解决剩余的平台。别晕倒。Android 要简单得多。
Android 插件配置
对于 Android,我们将使用两个插件:即ChildBrowser和Share。这两个插件都位于您应该已经从 GitHub 下载的仓库中。让我们先按照以下步骤安装和配置ChildBrowser:
-
在项目的
src文件夹下创建一个新的包(文件 | 新建 | 包)。将其命名为com.phonegap.plugins.childBrowser。 -
导航到
Android/ChildBrowser/src/com/phonegap/plugins/childBrowser,并将ChildBrowser.java拖到新创建的包中。 -
前往项目中的
res/xml,并使用文本编辑器打开plugins.xml(通常是通过右键单击然后导航到打开方式 | 文本编辑器)。 -
在文件的底部添加以下行,位于
</plugins>结束标签之上:<plugin name="ChildBrowser" value="com.phonegap.plugins.childBrowser.ChildBrowser"/> -
导航到仓库中的
Android/ChildBrowser/www文件夹。 -
将
childbrowser.js复制到assets/www/plugins/Android。 -
将
childbrowser文件夹复制到assets/www(复制文件夹,而不是内容,完成后你应该会有assets/www/childbrowser)。
对于我们的下一个插件 Share,请按照以下步骤操作:
-
在 Eclipse 中,在你的项目
src目录下创建一个名为com.schaul.plugins.share的包。 -
导航到插件库中的
Android/Share,并将Share.java复制到 Eclipse 中的包。 -
在
plugins.xml文件的底部添加以下一行:<plugin name="Share" value="com.schaul.plugins.share.Share"/> -
将
share.js复制到你的项目assets/www/plugins/Android目录。 -
最后一步是更新我们的
www/index_Android.html文件,在加载cordova-2.2.0-android.js文件的部分下方添加以下几行:<script type="application/javascript" charset="utf-8" src="img/childbrowser.js"></script> <script type="application/javascript" charset="utf-8" src="img/share.js"></script>
就这样!我们的插件已经正确安装并配置好了 Android。
我们做了什么?
我们在我们的两个支持平台上设置了 ChildBrowser 插件。我们为 iOS 设置了 ShareKit 2.0 和 ShareKitPlugin,以及 Android 的 Share 插件。
我还需要了解什么?
我们实际上并没有处理如何使用插件;我们只是安装了它们。当我们实施项目时,我们会处理必要的步骤。但有一个重要的细节需要注意:插件的 readme 文件,如果有的话。
此文件通常会指示必要的安装步骤,或者你可能需要注意的任何怪癖。插件的正确使用通常也会详细说明。不幸的是,一些插件没有提供说明。在这种情况下,最好的做法是尝试以 正常 方式安装它(就像我们之前为 ChildBrowser 和所有其他插件(除了 ShareKit)所做的那样)并查看它是否工作。
另一个需要注意的事情是,PhoneGap 是一个持续的项目。这意味着有些插件已经过时(实际上,有些作者已经为此书更新了它们),并且无法与 PhoneGap 的最新版本正确工作。你需要注意插件,以便知道它支持哪个版本,以及是否需要修改以与较新的 PhoneGap 版本兼容。修改通常并不特别困难,但它确实涉及到进入原生代码,所以你可能需要向社区寻求修改帮助。(请参阅项目末尾的社区链接。)
实现社交视图
虽然我们的应用有三个视图,但起始视图与之前项目的起始视图非常相似,所以我们不会在这个项目中详细说明它是如何工作的。你可以查看 www/views/startView.html 文件中的代码。
我们的大部分代码将驻留在社交视图和推文视图中,因此我们的主要关注点将在这里。那么,让我们开始吧!
准备工作
现在根据我们讨论的内容创建 socialView.html 文件。然后我们将回顾你之前没有看到的部分。
继续前进
完成这个任务后,我们应该有一个看起来像这样的视图,适用于 iOS:

Android 的视图将如下所示:

就像到目前为止的所有视图一样,我们将从描述实际视图的 HTML 部分开始;它如下所示:
<div class="viewBackground">
<div class="navigationBar">
<div id="socialView_title"></div>
<button class="barButton backButton"
id="socialView_backButton" style="left:10px" ></button>
</div>
<div class="content avoidNavigationBar avoidToolBar"
style="padding:0; overflow: scroll;"
id="socialView_scroller">
<div id="socialView_contentArea" style="padding: 0;
height: auto; position: relative;">
</div>
</div>
<div class="toolBar" id="socialView_toolbar" style="text-
align: center">
</div>
</div>
通常,这看起来非常像我们之前的视图,除了有几个关键细节。我们给内部div元素添加了一个样式。这移除了我们的默认div样式(来自www/framework/base.css)并强制高度适应内容(而不是屏幕)。这意味着当我们想要滚动时,我们将有整个内容可以滚动。
实际上,这是我们第一次在我们的应用中讨论滚动,而且有很好的理由:在移动平台上这通常很困难。在一个完美的世界里,我们可以仅仅依靠overflow:scroll在所有平台上工作,但这根本不起作用。我们可以依赖 iOS 5 及以后的本地滚动,但这也有它自己的问题(取决于 PhoneGap 的版本和各种其他 WebKit 的陷阱),并且排除了任何低版本的平台,当然,在任何版本的 Android 上都不起作用。所以对于 iOS 和 Android,我们将不得不使用我们自己的滚动实现或使用第三方滚动库,如 iScroll 4。在这种情况下,我们正在使用我们自己的实现,我们稍后会对其进行简要介绍。
首先,我们需要确定我们的工具栏将如何使用以下模板来显示其个人头像:
<div class="hidden" id="socialView_profileImageIcon">
<a class="profileImage" style="background-
image:url(%PROFILE_IMAGE_URL%)"
href="javascript:socialView.loadStreamFor
('@%SCREEN_NAME%');"></a>
</div>
注意,我们有一段 JavaScript 代码在用户触摸图像时触发,这是为了加载该图像的适当流。
接下来,我们需要定义推文在我们视图中的样子。这是通过以下代码片段完成的:
<div class="hidden" id="socialView_twitterTemplate">
<div class="twitterItem" onclick="socialView.selectTweet(%INDEX%);">
<img src="img/%PROFILE_IMAGE_URL%" width=32 height=32 border=0
/>
<div class="twitterName">
<span class="twitterRealName">%REAL_NAME%</span>
<span class="twitterScreenName">@%SCREEN_NAME%</span>
</div>
<div class="twitterTweet">%TWEET%</div>
</div>
</div>
在这个 HTML 段中,我们定义了推文的其余部分应该看起来像什么。我们给每个div和span都添加了一个类,这样我们就可以在style.css文件中定位它们(位于www/style)。这主要是为了尽可能地将推文的显示与推文的内容分开,并且使我们可以轻松地更改推文的样式。请查看style.css文件以了解它们是如何工作的,以给我们的推文添加一些样式。
接下来是我们的代码:
var socialView = $ge("socialView") || {};
socialView.firstTime = true;
socialView.currentStream = {};
socialView.lastScrollTop = 0;
socialView.myScroll = {};
和往常一样,我们给自己定义了一个命名空间,在这个例子中是socialView。我们还声明了一些属性:firstTime,它将跟踪这个视图是否是第一次显示,以及currentStream,它将保存从 Twitter 当前可见的流。lastScrollTop属性将记录用户在我们当前页面上滚动的位置,这样我们就可以在他们从查看单个推文返回时恢复它,而myScroll将保存我们的实际滚动器。
socialView.initializeView = function ()
{
PKUTIL.include ( ["./models/twitterStreams.js",
"./models/twitterStream.js"], function ()
{
// load our toolbar
TWITTER.loadTwitterUsers (
socialView.initializeToolbar );
}
);
socialView.viewTitle = $ge("socialView_title");
socialView.viewTitle.innerHTML = __T("APP_TITLE");
socialView.backButton = $ge("socialView_backButton");
socialView.backButton.innerHTML = __T("BACK");
PKUI.CORE.addTouchListener(socialView.backButton,
"touchend", function () { PKUI.CORE.popView(); });
if (device.platform != "WinCE")
{
socialView.myScroll = new SCROLLER.
GenericScroller ('socialView_contentArea');
}
}
我们的initializeView()方法与我们的前一个项目没有太大区别。我突出显示了几个行,但是请注意,我们加载我们的模型,当它们完成时,我们调用TWITTER.loadTwitterUsers()。我们传递一个完成函数,我们将在下一部分定义它,这样当 Twitter 返回我们五个 Twitter 用户的用户数据时,我们就可以调用它。
我们还定义了我们的滚动器。如果你想查看完整的代码,请查看www/framework/scroller.js,但可以说,它是一个相当不错的滚动器,使用简单。它并不比原生滚动更好,但也没有什么可以做到这一点。你可以自由地用任何你喜欢的库替换它,但在这个项目的目的上,我们已经选择了这条路。
socialView.initializeToolbar = function ()
{
var toolbarHtml = "";
var profileImageTemplate =
$ge("socialView_profileImageIcon").innerHTML;
var users = TWITTER.users;
if (users.error)
{
console.log (streams.error);
alert ("Rate limited. Please try again later.");
}
在获取模板的 HTML 之后,我们首先要检查我们的TWITTER.users数组。这个数组应该已经填充了各种用户数据,但如果 Twitter 出于某种原因限制了我们的请求频率,它可能没有。因此,我们会检查是否存在错误条件,如果有,我们会通知用户。当然,这不是通知用户的最优方法,但对我们这个示例应用来说,这已经足够了。
// go through each stream and request the profile image
for (var i=0; i<users.length; i++)
{
var theTemplate = profileImageTemplate.replace
("%SCREEN_NAME%", users[i].getScreenName())
.replace ("%PROFILE_IMAGE_URL%",
users[i].getProfileImageURL());
toolbarHtml += theTemplate;
}
接下来,我们遍历每个用户。应该有五个,但你可以配置为不同的数量,并构建一个 HTML 字符串,然后按照以下方式将其放入工具栏中:
$ge("socialView_toolbar").innerHTML = toolbarHtml;
}
我们下一个函数loadStreamFor()在这个视图中做了真正困难的工作。它从 Twitter 请求一个流,然后对其进行处理以供显示。它的代码片段如下:
socialView.loadStreamFor = function ( searchPhrase )
{
var aStream = new TWITTER.TwitterStream ( searchPhrase,
function ( theStream )
{
需要注意的是,我们现在处于completion函数中,这个函数将在获取 Twitter 流时被调用。
var theTweetTemplate =
$ge("socialView_twitterTemplate").innerHTML;
var theContentArea = $ge("socialView_contentArea");
var theStreamHTML = "";
if (theStream.error)
{
console.log (theStream.error);
alert ("Rate limited. Please try again later.");
}
由于 Twitter 可能在任何时候限制我们的请求频率,我们在前面的代码片段中再次检查流中的任何错误。
for (var i=0; i<theStream.length; i++)
{
var theTweet = theStream[i];
var theTemplate =
theTweetTemplate.replace("%INDEX%", i)
.replace ("%PROFILE_IMAGE_URL%",
theTweet.profile_image_url ||
theTweet.user.profile_image_url)
.replace ("%REAL_NAME%",
theTweet.from_user ||
theTweet.user.name)
.replace ("%SCREEN_NAME%",
theTweet.from_user ||
theTweet.user.screen_name)
.replace ("%TWEET%",
theTweet.text);
theStreamHTML += theTemplate;
}
在这里,我们正在遍历流中的每个项目,并从我们之前定义的模板构建一个大的 HTML 字符串。
一个需要注意的重要部分是我们如何获取推文的数据,使用theTweet.from_user || theTweet.user.screen_name等。这是为了处理 Twitter 在搜索单词或标签时返回的数据格式与返回用户时间线时的数据格式略有不同的情况。如果其中一个未定义,我们将加载另一个,因为我们只能获取其中一个,所以这比构建大量 if 语句来处理它要简单得多。
theContentArea.innerHTML = theStreamHTML;
socialView.currentStream = theStream;
if (socialView.myScroll.scrollTo)
{
socialView.myScroll.scrollTo ( 0, 0 );
}
一旦我们的流 HTML 构建完成,我们就将其分配给内容区域,以便用户可以看到它。我们还将其存储到currentStream属性中,以便我们稍后可以引用它。完成这些后,我们滚动到页面顶部,以便用户可以看到最新的推文。
}
, 100
);
}
那最后的100?实际上它是TwitterStream()调用的一部分。这是流中要返回的项目数量。
我们下一个函数处理的是当用户点击显示的推文时应该发生的事情:
socialView.selectTweet = function ( theIndex )
{
var theTweet = socialView.currentStream[theIndex];
tweetView.setTweet ( theTweet );
PKUI.CORE.pushView ( tweetView );
}
这个功能相当简单。我们只是告诉推文视图哪个推文被点击了,然后将其推送到视图堆栈中。
socialView.viewWillAppear = function ()
{
document.addEventListener("backbutton",
socialView.backButtonPressed, false );
if (socialView.firstTime)
{
socialView.loadStreamFor ( "@photokandy" );
socialView.firstTime = false;
}
if (socialView.myScroll.scrollTo)
{
PKUTIL.delay ( 50, function ()
{
socialView.myScroll.scrollTo ( 0,
socialView.lastScrollTop );
}
);
}
}
这个viewWillAppear()方法与上一个项目非常相似,除了中间和最后部分。在中间,我们检查这是否是视图第一次显示。如果是,我们希望为用户加载默认流。记住,到目前为止,我们只在用户在工具栏中点击个人资料图片时加载流。但我们不希望每次我们的视图显示时都重新加载这个流;我们可能正从推文视图返回,用户可能想要继续他们在上一个流中的位置。在最后部分,我们检查是否有之前的滚动位置,如果有,我们将视图滚动到那个点。我们必须在这里创建一个延迟,因为如果我们设置得太早,视图将不在屏幕上(并且不会滚动),或者它将在屏幕上,这将让用户注意到。
剩下的两个函数viewWillHide()和backButtonPressed()没有提供新的功能,所以虽然你需要在你的代码中包含它们,但这里我们不会详细说明。
就这样,并不特别困难,但它做到了我们需要的——显示推文列表。一旦用户点击推文,他们就会被带到推文视图进行更多操作,这就是我们将在下一个任务中要查看的内容。
我们做了什么?
在这个任务中,我们定义了我们的社交视图的 HTML 代码和模板。我们还使用了 Twitter 流数据来构建一个最终用户可以与之交互的 Twitter 流。
实现推文视图
我们的推文视图将是用户与特定推文交互的地方。他们可以使用ChildBrowser插件打开推文内的任何链接,或者他们可以搜索推文(或提及)中包含的任何标签(或任何提及)。视图还给了用户分享推文到他们任何社交网络的机会。
准备中
根据我们讨论的内容,创建你自己的www/tweetView.html文件。我们将讨论新的代码,其余的留给你自己审查。
继续前进
对于下一个任务,我们应该得到一个在 iOS 上看起来如下所示的观点:

对于 Android,视图将如下所示:

这次,我们不会显示定义视图布局的 HTML。你可能会问为什么?这是因为你之前已经看过几次,可以在本项目的代码中查找。我们将从定义内容的模板开始:
<div class="hidden" id="tweetView_tweetTemplate">
<div class="twitterItem" onclick="tweetView.selectTweet(%INDEX%);">
<img src="img/%PROFILE_IMAGE_URL%" width=64 height=64
border=0 />
<div class="twitterRealName">%REAL_NAME%</div>
<div class="twitterScreenName">@%SCREEN_NAME%</div>
<div class="twitterTweet">%TWEET%</div>
<div class="twitterEntities">%ENTITIES%</div>
</div>
</div>
这段代码与上一个视图中的模板非常相似,有几个例外:我们使个人资料图片更大,并且添加了一个div元素,列出了推文中的所有实体。Twitter 将实体定义为 URL、标签或对其他 Twitter 用户的提及。我们将显示推文中包含的任何这些内容,以便用户可以点击它们以获取更多信息。
<div class="hidden" id="tweetView_entityTemplate">
<DIV class="entity %TYPE%">%ENTITY%</DIV>
</div>
这是我们的任何实体的模板。请注意,我们给它分配了 entity 类,这样我们的所有实体都可以有相似的外观。
接下来,我们定义每个特定实体看起来像什么,在这个例子中,是 URL 模板。
<div class="hidden" id="tweetView_urlEntityTemplate">
<a href="javascript:PKUTIL.showURL('%URL%');"
class="openInNewWindow url" target="_blank">%DISPLAYURL%</a>
</div>
注意这个模板中 PKUTIL.showURL() 的使用。这是我们在 PKUTIL 中定义的一个便利方法,用于使用 ChildBrowser 显示网页。我们已经完成了在每个平台上如何工作的组合工作,并将其放入一个函数中,以便于调用。我们稍后会看看它。
<div class="hidden" id="tweetView_hashEntityTemplate">
<a href="javascript:socialView.loadStreamFor('%23%HASHTAG%');
PKUI.CORE.popView();" class="hash">#%TEXT%</a>
</div>
这个模板是为标签设计的。与之前的模板相比,最大的不同之处在于它实际上是在引用我们之前的视图!这样做是为了告诉它加载一个标签的流,然后我们调用 popView() 返回到视图。很可能会是,视图还没有加载来自 Twitter 的信息,但给它一点时间,它会重新加载并显示新的流。
类似地,提及的代码如下:
<div class="hidden" id="tweetView_userEntityTemplate">
<a href="javascript:socialView.loadStreamFor('@%USER%');
PKUI.CORE.popView();" class="user" >@%TEXT%</a>
</div>
因此,定义了我们的推文的外观和工作方式,让我们看看视图实际上是如何创建推文的:
var tweetView = $ge("tweetView") || {};
tweetView.theTweet = {};
tweetView.setTweet = function ( aTweet )
{
tweetView.theTweet = aTweet;
}
在这里,我们定义了 setTweet() 方法,它将一个特定的推文存储到我们的 theTweet 属性中。记住,当点击推文以发送给我们显示的推文时,这个方法是从 Twitter 流视图调用的。
下一个我们感兴趣的方法是 loadTweet()。我们将跳过 initializeView() 方法,因为它与之前的视图相似。loadTweet() 方法的定义如下:
tweetView.loadTweet = function ()
{
var theTweet = tweetView.theTweet;
var theTweetTemplate =
$ge("tweetView_tweetTemplate").innerHTML;
var theEntityTemplate =
$ge("tweetView_entityTemplate").innerHTML;
var theURLEntityTemplate =
$ge("tweetView_urlEntityTemplate").innerHTML;
var theHashEntityTemplate =
$ge("tweetView_hashEntityTemplate").innerHTML;
var theUserEntityTemplate =
$ge("tweetView_userEntityTemplate").innerHTML;
首先,我们获取我们需要的每个模板的 HTML——而且有很多!这些如下所示:
var theContentArea = $ge("tweetView_contentArea");
var theTweetHTML = "";
var theEntitiesHTML = "";
var theURLEntities = theTweet.entities.urls;
for (var i=0;i<theURLEntities.length;i++)
{
var theURLEntity = theURLEntities[i];
theEntitiesHTML += theEntityTemplate.replace
("%TYPE%", "url")
.replace ("%ENTITY%",
theURLEntityTemplate.replace ("%URL%",
theURLEntity.url )
.replace ("%DISPLAYURL%",
theURLEntity.display_url )
);
}
在这段代码中,我们已经遍历了 Twitter 发送给我们的每个 URL 实体,并将其添加到我们的实体 HTML 字符串中。我们将对标签和提及重复这一过程,但由于代码非常相似,这里不再重复。
var theTemplate = theTweetTemplate
.replace ("%PROFILE_IMAGE_URL%",
theTweet.profile_image_url ||
theTweet.user.profile_image_url)
.replace ("%REAL_NAME%",
theTweet.from_user ||
theTweet.user.name)
.replace ("%SCREEN_NAME%",
theTweet.from_user ||
theTweet.user.screen_name)
.replace ("%TWEET%", theTweet.text)
.replace ("%ENTITIES%", theEntitiesHTML );
theTweetHTML += theTemplate;
theContentArea.innerHTML = theTweetHTML;
一旦我们处理完所有实体,我们就会处理推文本身。注意,我们必须先处理实体,因为我们之前已经处理了替换。就像之前的视图一样,我们正确地处理了推文来自搜索或来自时间线的情况。
下一个我们感兴趣的方法是 share() 方法,所以我们将跳过 viewWillAppear()、viewWillHide() 和 backButtonPressed()。简单来说,viewWillAppear() 方法与其它方法唯一的不同之处在于,当我们的视图显示时,它会调用 loadTweet() 方法来显示推文。
share() 方法是我们调用每个平台的插件进行分享的地方。每个平台都有略微不同的语法,因此我们必须检查我们所在的平台,并根据这个决定调用哪个插件。我们可以使用以下代码片段来完成:
tweetView.share = function ()
{
switch (device.platform)
{
case "Android": window.plugins.share.show(
{ subject: 'Share',
text: tweetView.theTweet.text,
},
function() {},
function() { alert ('Error sharing.'); }
);
break;
对于 Android,我们使用Share插件,以下是使用它的方法。如果用户已经安装了这些服务,Android 将显示一个支持分享的服务列表,包括 Twitter 和 Facebook。我们给它的文本将被包含在消息中,而且 Android 足够友好,允许我们在推文后发送成功和失败函数。
default:
window.plugins.shareKit.share (
tweetView.theTweet.text );
}
}
我们默认的方法是针对 iOS 的,它将显示一个动作表,列出一些服务,可能是 Twitter 和 Facebook,用户可以点击他们想要分享的服务按钮。一旦他们验证了服务,他们就可以发送消息。
我们做了什么?
我们展示了一条推文,并处理了其中的各种实体。我们通过使用PKUTIL.showURL()演示了如何在ChildBrowser插件中加载外部网站。我们还演示了如何使用各种分享插件。
我还需要了解什么?
让我们快速看一下PKUTIL.showURL(),这是用来显示带有外部网站的ChildBrowser的方法。这是一个相当简单的函数,但由于它以三种不同的方式显示ChildBrowser,我们将其打包成一个易于使用的函数。
PKUTIL.showURL = function ( theURL )
{
switch (device.platform)
{
case "Android":
window.plugins.childBrowser.showWebPage( theURL );
break;
对于 Android,调用ChildBrowser很简单。通常,这就是你在 PhoneGap 中调用任何插件的方式。
case "WinCE":
var options =
{
url:theURL,
geolocationEnabled:false
};
Cordova.exec(null, null,"ChildBrowserCommand",
"showWebPage", options);
break;
WP7 也在这里,因为该平台支持它,而且稍微困难一些。我们必须将 URL 打包到一个选项数组中,然后发送到插件以显示。
default:
cordova.exec("ChildBrowserCommand.showWebPage",
theURL);
}
}
对于 iOS,方法与 Android 非常相似,只是我们直接调用它,而不是使用window.plugins.*。
游戏结束..... 结束语
嗯,你已经做到了。你成功地编写了一个应用程序,显示从 Twitter 获取的信息,并允许用户在自己的社交网络上分享它。对于某些平台,配置完成这项工作的插件并不太难,而对于 iOS,你可能比预期的更熟悉 Xcode 和头文件路径等。其余的都是容易的;添加插件越做越容易,而且几乎在每一个项目中你都需要至少ChildBrowser插件。幸运的是,安装它也很简单!
一些你可能觉得有价值的资源如下:
-
ShareKit:
github.com/ShareKit/ShareKit -
JSON:
www.json.org/ -
Twitter JSON 文档:
dev.twitter.com/docs/api/1 -
Phonegap 插件:
www.github.com/phonegap/phonegap-plugins -
Phonegap 社区:
groups.google.com/group/phonegap -
iScroll 4:
cubiq.org/iscroll-4
你能承受压力吗?热手挑战
作为项目,Socializer 完成了它设定的目标,但实际上你还可以做更多的事情来使其真正有用。为什么不尝试以下一个或多个挑战:
-
允许最终用户选择他们自己的初始 Twitter 账户,而不仅仅是我们的前五个。
-
在加载 Twitter 流时显示一个加载图形,以便用户知道应用正在处理某事。
-
将 Twitter 流中的任何链接、提及或标签样式化,使它们更加突出。
-
尝试使用你喜欢的任何社交网络的 API 进行操作。
-
尝试添加 OAuth 身份验证。
第三章. 提高生产力
PhoneGap 不仅可以用于简单的游戏和社交媒体应用;它还可以用于创建非常有用的生产力应用。然而,为了做到这一点,我们需要了解如何使用 PhoneGap 的文件 API 存储持久数据。在这个项目中,我们将这样做。我们将构建一个名为 Filer 的简单记事本应用,它使用文件 API 来管理可用的笔记。
我们将构建什么?
在本质上,Filer 更关注文件管理而不是记笔记,但正确管理文件至关重要。当应用损坏或丢失用户数据时,用户不会友好地对待,因此你必须确保正确管理。一旦完成,你就可以继续使应用更复杂。幸运的是,你在本项目中学习的概念可以应用于你未来的所有应用。
它能做什么?
如其名所示,该应用允许用户将笔记存档起来以供以后检索。这样做需要使用 PhoneGap 提供的文件 API。我们不仅需要能够保存和加载笔记,还需要管理它们。这包括根据用户的要求删除笔记、重命名它们以及复制它们。
一旦创建或打开笔记,应用本身就变得非常简单,本质上是一个大型的TEXTAREA元素,可以接受你想要放入的任何类型的文本。我们还将探讨保存和检索你输入的数据的好方法。
为什么它很棒?
这个应用是学习 PhoneGap 中用于管理应用需要保存和检索数据的文件 API 的绝佳方式。我们还将考虑如何以用户易于理解的形式展示这些内容。
我们将如何实现?
我们将按照创建过去应用的方式创建这个应用,使用以下指南:
-
设计用户界面
-
设计数据模型
-
实现数据模型
-
实现文档视图
-
实现文件视图
我需要什么来开始?
你应该能够以与先前应用相同的方式创建你的项目并设置它。将这个项目命名为Filer。
注意,如果你愿意,你可以包含第二个项目中的共享库。我们不会直接使用它们,但在项目的最后有一个挑战要求你添加共享功能。如果你打算这样做,你不妨现在就添加所有内容。
设计用户界面
首先,拿出你的纸和铅笔,或者使用你最喜欢的图像编辑器。像以前的项目一样,我们首先使用草图和线框来设计我们的视图,然后进一步细化以设计图形资产。
开始行动
与之前的项目一样,第一个视图是起始视图,但由于它与所有先前的应用相同,我们这里不会详细介绍它(请参阅项目 1 的设计 UI/交互部分,让我们本地化!)。相反,让我们转到文档视图,如下面的截图所示:

在这个视图中,我们实际上有两种外观;左边是 iPhone 的,而右边是 Android 的。这两种不同外观的原因仅仅是许多应用在每个平台上处理事情的方式。在 iOS 上,你通常看到大型的水平滚动界面,而在 Android 上,你通常看到代表文件的垂直列表。
让我们来看看这个视图是如何工作的。导航栏中的按钮,命名为创建,允许用户创建一个新的笔记。在导航栏下方是可用的文件列表。当然,在第一次运行时,这将是一个空列表,但随着文件的创建,它们会被添加到这里。这个视图将根据需要滚动,以显示整个列表。
列表中的每一项都将包含相同的内容,尽管它们的排列和大小不同。首先是表示该项的图标;许多应用都会以这种图标的形式呈现内容。为了避免复杂性,我们这里不会这么做;我们将使用静态图像。点击图标将打开笔记。接下来是图标的标签,这显示了文件名。但是,当按下时,它将允许用户重命名文件。
在文件名下方有三个图标:
-
用于复制(复制)笔记
-
用于分享笔记
-
用于销毁(删除)笔记
起初,这一切并不特别困难,实际上,也并不困难。但是,由于文件 API 的实现方式,要正确实现它确实需要一些工作。
让我们继续到文件视图,如下面的截图所示:

这个视图非常简单:它显示笔记的内容,并允许用户编辑它。请注意,这里没有保存按钮;想法是笔记将自动保存。
当视图首次出现时,键盘将不可见。这使用户能够看到笔记填满整个屏幕。但是,一旦点击笔记,键盘就会出现,用户就可以将笔记更改为他们想要的。
现在我们已经创建了线框,让我们进入我们的图形程序并创建我们的资源。这是我们得到的结果:

我们将大部分界面以图像的形式展示,包括图标、大纸张图像以及导航栏和视图背景本身。对于 Android,只有图标和纸张图像是重要的;后两者仅适用于 iOS。
图标本身可以从 App-Bits 免费获得(app-bits.com/free-icons.html),背景纹理来自 Subtle Patterns,同样免费(subtlepatterns.com)。
我们做了什么?
在这个任务中,我们创建了所需的外观和感觉,并为我们的应用程序生成了必要的资源。
设计数据模型
再次拿出你的纸和铅笔。我们需要为应用程序设计数据模型。我们将有两个部分:一个用于管理可用文档的列表,另一个用于管理单个文档。
继续前进
下面是我们的模型看起来像什么:

第一个模型,命名为 FilerDocuments,负责管理应用程序可用的所有文件,而右侧的模型,命名为 FilerDocument,仅负责单个笔记。后者负责加载笔记和保存笔记,而前者负责读取整个笔记目录,然后通过重命名、复制和删除来管理它们。
在完成这个任务之前,我们注意到一些有趣的笔记。注意所有以 …Success 结尾的方法。这仅仅是由于文件 API 的结构方式;所有操作都是异步进行的,因此你必须为每个调用编写回调函数,以指向一个 success 和一个 failure 函数。success 函数指向相应的 Success 方法,而 failure 函数指向通用的 dispatchFailure 方法。(失败相当通用;我们希望记录失败,而成功可能需要额外的步骤来完成操作。)
fileSystem 和 fileEntry 属性也与文件 API 有关。fileEntry 属性是指向特定文件的指针,而 fileSystem 属性是指向设备上特定目录的指针。(PhoneGap 允许你指定目录应该是持久的还是临时的;我们使用持久目录。)
在第二个模型中,注意标题和文本属性以及相关的 get/set 方法。这是单个笔记的实际数据;其他一切都是为了管理它。
我们做了什么?
我们为文档管理器和单个笔记创建了数据模型。在下一个任务中,我们将实现这两个模型。
实现数据模型
到目前为止,你应该已经创建了你的项目。我们将在 www/models 目录下创建两个模型,分别命名为 filerDocuments.js 和 filerDocument.js。
继续前进
让我们开始工作,先从管理所有可用文档的 Documents 模型入手:
var DOCS = DOCS || {};
DOCS.Filers = function ( completion, failure )
{
var self = this;
这是 Filers 对象构造函数的开始。completion 和 failure 变量被传递进来,因为在构造函数的末尾,我们将启动目录读取操作,并且我们希望在完成时(或遇到错误时)通知应用程序。
self.state = "";
state 属性将存储操作的当前进度,这将在操作失败时使调试更容易。
self.completion = completion;
在这里,completion 函数最初接收完成信息,但它还存储了对象内部其他函数使用的 completion 函数。这是因为一个操作可能需要几个步骤,每个步骤都需要一个临时的 completion 方法。这恰好是应用程序中的一个,而不是我们对象内部的一个。
self.documents = [];
documents 属性存储了我们从文件系统中读取的每个文件的接收信息。它不是实际的文档。
self.fileSystem = {};
fileSystem 属性指向设备上的持久存储。大多数操作都是从请求文件系统开始的,我们可以在第一次请求时保存它,这样其他操作就可以使用我们的缓存值。
self.failure = failure;
就像 completion 一样,这也是一个 failure 函数。dispatchFailure() 将首先被调用,然后如果它非空,将调用这个函数。
self.fileEntry = {};
对于我们的某些操作,我们必须存储有关特定文件的信息;我们使用 fileEntry 属性来完成此操作。
self.loadFileSystem = function ( completion, failure )
{
self.completion = completion;
self.failure = failure;
self.getFileSystem();
}
loadFileSystem() 函数可以在任何时间由应用程序调用,但它通常是在应用程序怀疑我们可用的文档已更改时调用。比如说,可能有一个新的文档出现,我们想要确保向用户显示它。这个类中的大多数操作都会在操作(如重命名文件)之后尝试重新读取目录,但并非每个操作都支持此操作,这并不会阻止出现我们没有明确创建的文档(比如说,来自 iTunes 导入)。
self.getFileSystem = function()
{
self.state = "Requesting File System";
window.requestFileSystem ( LocalFileSystem.PERSISTENT, 0, self.getFileSystemSuccess, self.dispatchFailure );
}
getFilesystem() 函数在我们请求查看我们可用的文件时必须做的第一件事:请求文件系统。在这种情况下,我们请求持久文件系统,以便数据永久存储。
self.dispatchFailure = function ( e )
{
console.log ("While " + self.State + ", encountered error: " + JSON.stringify(e));
if (self.failure)
{
self.failure ( e );
}
}
通常,我喜欢将 success/failure 方法与调用方法保持接近,但失败可以相当通用地处理(在我们的情况下),所以我只有一个 failure 函数,我们的所有操作都可以调用。它为我们记录了一条漂亮的日志消息,然后检查应用程序是否注册了失败回调,如果有,我们也会调用它。
self.getFileSystemSuccess = function ( fileSystem )
{
self.state = "Received File System";
self.fileSystem = fileSystem;
self.getDocuments ( fileSystem.root );
}
当我们在前面的函数中时,我们有一个有效的文件系统。我们将其保存以供以后使用,然后我们还会调用 getDocuments() 以开始获取应用程序可以访问的每个文档的过程。
self.getDocuments = function ( directoryEntry )
{
self.state = "Requesting Reader";
var directoryReader = directoryEntry.createReader();
self.state = "Requesting Entries from Reader";
directoryReader.readEntries (
self.getDocumentsSuccess, self.dispatchFailure );
}
为了遍历文件系统目录中的每个条目,我们必须创建一个目录读取器。我们可以通过使用传递给我们的 directoryEntry 函数(它指向我们请求的文件系统)来完成此操作。一旦我们有了它,我们就要求它读取所有条目,并在完成后调用 getDocumentsSuccess()。
self.getDocumentsSuccess = function ( entries )
{
var theDocuments = [];
for (var i=0; i<entries.length; i++)
{
// is the entry a file? (we won't iterate subdirs)
if (entries[i].isFile)
{
var theFileName = entries[i].name;
var theFileType =
theFileName.substr(theFileName.length-4,4);
if (theFileType === ".fln")
{
// a file we know we can process
theDocuments.push ( entries[i] );
}
}
}
self.documents = theDocuments;
self.state = "";
if (self.completion)
{
self.completion ( self );
}
}
在前面的函数中,我们阅读了我们得到的所有条目。一个人永远不应该假设目录中的所有条目都是我们的应用可以处理的,因此我们会筛选子目录(我们不会创建,所以它不会是我们能处理的任何东西),然后我们也会检查文件扩展名。如果是.fln,我们假设文件是我们的文件之一,并将其添加到列表中。如果有其他任何东西,我们就会忽略它。
一旦我们遍历完列表,我们就会调用completion方法(如果存在),这样应用就可以对列表做它想做的事情。
self.getDocumentCount = function ()
{
return self.documents.length;
}
self.getDocumentAtIndex = function ( idx )
{
return self.documents[ idx ];
}
前两个方法相当直观。第一个返回我们从目录中获取的文档数量,第二个返回特定文档获取的信息。
self.deleteDocumentAtIndex = function ( idx, completion, failure )
{
self.completion = completion;
self.failure = failure;
self.state = "Removing a Document";
self.documents [ idx ].remove (
self.deleteDocumentAtIndexSuccess,
self.dispatchFailure);
}
这个方法并不是在玩弄文字;它将物理地删除指定索引处的文档。我们的应用会先询问用户是否想要删除文档,这样就不会意外调用,但这个函数本身不会询问任何人是否可以这样做。所以调用时要小心。
self.deleteDocumentAtIndexSuccess = function ()
{
self.state = "";
self.getFileSystem();
}
在成功删除后,我们需要重新读取文件系统,以便我们的documents数组保持最新。我们通过调用getFileSystem()来实现这一点。你可能想知道在deleteDocumentAtIndex中定义的completion方法是如何被调用的。它是在getFileSystem()的末尾被调用的。它会检查completion属性是否已经设置(我们在deleteDocumentAtIndex的开始处这样做),如果设置了,就会调用它。这是我们许多操作将遵循的模式。
self.renameDocumentAtIndexTo = function ( idx, newName,
completion, failure )
{
self.completion = completion;
self.failure = failure;
self.state = "Renaming a Document";
self.documents [ idx ].moveTo (
self.fileSystem.root, newName,
self.renameDocumentAtIndexToSuccess,
self.dispatchFailure);
}
self.renameDocumentAtIndexToSuccess = function ()
{
self.state = "";
self.getFileSystem();
}
重命名文档只是将moveTo操作应用于同一目录。它遵循与前面的delete操作相同的操作模式。请注意,这里没有检查新文件名是否已被现有文件使用。如果有名称冲突,新文件将覆盖旧文件,这很可能不是你想要发生的事情。由于前面的deletion方法没有询问,我们这里也不会询问,但这是你在应用本身中应该做的事情。
self.copyDocumentAtIndexTo = function ( idx, newName,
completion, failure )
{
self.completion = completion;
self.failure = failure;
self.state = "Duplicating a Document";
self.documents [ idx ].copyTo ( self.fileSystem.root,
newName, self.copyDocumentAtIndexToSuccess,
self.dispatchFailure);
}
self.copyDocumentAtIndexToSuccess = function ()
{
self.state = "";
self.getFileSystem();
}
复制与重命名非常相似,如前代码所示;区别在于我们使用copyTo而不是moveTo。操作也略有不同;如果你尝试复制一个现有的文档,尝试会失败,这与移动现有文档不同。
self.createDocument = function ( theDocumentName,
completion, failure )
{
self.completion = completion;
self.failure = failure;
self.state = "Creating a Document";
self.fileSystem.root.getFile ( theDocumentName,
{create: true, exclusive: false},
function ( theFileEntry )
{
self.fileEntry = theFileEntry;
self.state = "";
self.getFileSystem();
}, self.dispatchFailure );
}
createDocument()方法在目录中创建一个新文件,并在这样做之后重新读取文件系统。这展示了使用…Success()方法的替代方案。它的工作方式完全相同。就像重命名一样,如果已经存在具有相同名称的文件,这可能会很危险,所以调用此方法之前一定要检查。
self.openDocumentAtIndex = function ( idx, completion,
failure )
{
self.completion = completion;
self.failure = failure;
self.state = "Opening a Document";
self.fileSystem.root.getFile (
self.documents[idx].name, {create: false,
exclusive: false},
function ( theFileEntry )
{
self.fileEntry = theFileEntry;
self.state = "";
self.getFileSystem();
}, self.dispatchFailure );
}
如前代码所示,打开文档与创建文档非常相似,只是我们不会要求文件系统在它不存在时创建它。
self.getFileEntry = function ()
{
return self.fileEntry;
}
一些操作,如创建和打开文档,也会将fileEntry属性设置为新打开的文档。这在要求笔记打开自身时很有用。它可以读取此属性中的文件内容。
self.getFileSystem ();
}
在查看我们模型的代码之前,我们提到过,我们会在创建时初始化目录读取,这就是模型末尾所做的工作。这样,当我们创建一个对象时,它会立即开始读取目录中的条目。
现在,让我们看看单个文档的代码:
var DOC = DOC || {};
DOC.Filer = function ( theFileEntry, completion, failure )
{
var self = this;
// file and state
self.fileEntry = theFileEntry;
self.fileName = self.fileEntry.name;
self.completion = completion;
self.failure = failure;
self.state = "";
// file-specific
self.title = "My Filer";
self.text = "";
self.getTitle = function ()
{
return self.title;
}
self.setTitle = function ( theTitle )
{
self.title = theTitle;
}
self.getText = function ()
return self.text;
}
self.setText = function ( theText )
{
self.text = theText;
}
前面的代码现在应该是自解释的。接下来,在下面的代码中,我们看到如何读取文件的内容:
self.readFileContents = function()
{
self.state = "Reading a File";
self.fileEntry.file ( self.gotFile, self.dispatchFailure );
}
self.dispatchFailure = function( e )
{
console.log ("While " + self.State + ", encountered
error: " + e.target.error.code);
if (self.failure)
{
self.failure ( e );
}
}
在请求读取文件时,我们必须调用文件对应的fileEntry的file()方法。如果它找到了文件,它会调用gotFile(),但如果由于某种原因它无法读取,它会调用dispatchFailure()。
self.gotFile = function ( theFile )
{
var reader = new FileReader ();
reader.onloadend = self.finishedReadingFile;
reader.onloaderror = self.dispatchFailure;
reader.readAsText ( theFile );
}
一旦我们有了文件,我们必须为它创建一个FileReader变量。与其他 API 调用不同,我们必须设置一些事件处理程序,但它们在这里与completion和failure意味着相同的事情。然后我们要求读者读取文件。
self.finishedReadingFile = function ( e )
{
var theFileContents = e.target.result;
一旦我们到达这里,e.target.result就有整个文件的内容。现在我们可以尝试加载它。
if (!theFileContents)
{
theFileContents = '{"title":"New
File","text":""}';
}
如果文件中没有内容,我们会设置一些合理的默认值。注意,我们在这里使用JSON。这是因为我们将文件存储在JSON文件格式中。
接下来,我们尝试将文件内容解析为JSON。这就是try/catch块发挥作用的地方。如果我们无法解析文件内容,我们会得到一个错误,并可以调用failure函数。但如果我们正确解析了它,我们可以将我们自己的title和text设置为文件的title和text,这样我们就成功加载了文件的内容。
self.saveFileContents = function ( completion , failure )
{
self.completion = completion;
self.failure = failure;
self.fileEntry.createWriter ( self.gotFileWriter,
self.dispatchFailure );
}
self.gotFileWriter = function ( writer )
{
writer.onerror = self.failure;
writer.onwriteend = function ( e )
{
if (self.completion)
{
self.completion();
}
};
writer.write ( JSON.stringify ( self.serialize() ) );
}
保存文件与加载文件并没有太大的不同,只是我们可以直接从fileEntry属性创建文件写入器,而不是首先调用file()。然而,在gotFileWriter中,在调用write()文件内容之前,我们必须设置类似的事件。我们将serialize()的结果stringify,使其成为正确的JSON格式。
self.serialize = function ()
{
return { "title": self.title, "text": self.text };
}
谈到序列化,这里就是实现它的方法。并不难,但你可能会问为什么我们不是直接将self转换为字符串。这是一个很好的问题。实际上,你不能将包含方法的对象转换为字符串,因为这会丢失那些方法;所以这是其中一个原因。另一个原因是,我们实际上并不需要保存整个对象,只需要标题和文本;所以,我们不会保存我们不需要的大量东西,而是只返回一个包含我们所需内容的对象。
self.readFileContents();
}
就像我们的第一个模型一样,我们要求文档在创建时立即加载其文件内容。
我们做了什么?
在本节中,我们创建了两个数据模型,一个用于目录中可用的文档列表,另一个用于实际的笔记本身。
我还需要知道什么?
文件 API 因其难以适应而闻名,尤其是对于认为处理顺序总是紧随其后语句的程序员来说。然而,文件 API 的工作方式却不同,它要求每个操作都必须有一个success和failure回调。此外,在读取或保存文件(或读取目录)时,有几个操作,因此回调链可能会变得相当混乱。这通常是我尝试使用单独的函数而不是内联回调的原因,但有时内联回调才是最合适的。
文件 API 可以做的不仅仅是这里所涵盖的,所以你最好去查看一下docs.phonegap.com/en/edge/cordova_file_file.md.html#File。只需记住回调的工作方式,你就可以做得很好,即使你的代码可能感觉有点像意大利面一样。
实现文档视图
文档视图将用于向最终用户显示可用的文档列表。它还将允许用户创建文档、重命名文档、复制文档和删除文档。
让我们快速看一下最终产品,首先是 iOS 版本:

对于 Android,视图将如下所示:

注意,iPhone 的截图与 Android 的截图在外观和感觉上完全不同。虽然许多 iPhone 应用使用 Android 使用的替代方法,但在 iPhone 上,通过水平滚动大文档表示的方法更为常见,这也是我们在这里使用的方法。幸运的是,这只需要少量的代码更改和一些 CSS 即可渲染两种截然不同的外观。否则,它们的功能是相同的。
开始吧
和往常一样,我们将从视图的 HTML 部分开始。样板部分几乎与我们的前一个应用相同,所以我们将从模板开始:
<div id="documentsView_documentTemplate" class="hidden">
<div class="documentContainer">
<div class="documentImage">
<img src="img/DocumentImage.png" border=0 onclick="documentsView.openDocument(%INDEX%)"/>
</div>
<div class="documentTitle" onclick="documentsView.renameDocument(%INDEX%)">
<span >%TITLE%</span>
</div>
<div class="documentActions">
<img src="img/Copy.png" width=28 height=28 border=0 onclick="documentsView.copyDocument(%INDEX%)" />
<img src="img/Share.png" width=27 height=28 border=0 onclick="documentsView.shareDocument(%INDEX%)" />
<img src="img/Trash.png" width=28 height=28 border=0 onclick="documentsView.deleteDocument(%INDEX%)" />
</div>
</div>
</div>
此模板定义了我们显示的每个文档的 HTML。它并不复杂。注意,我们为模板的每个可以响应触摸的部分都设置了onClick处理程序,但除此之外,样式由style.css控制。
让我们看看驱动这个视图的代码:
var documentsView = $ge("documentsView") || {};
documentsView.lastScrollLeft = 0;
documentsView.myScroll = {};
documentsView.availableDocuments = {};
首先,是我们的属性。lastScrollLeft用于在切换视图时保持我们的滚动位置。myScroll将保存我们的滚动器(用于 iOS 和 Android),而availableDocuments将保存文件系统为我们应用提供的所有文档。
initializeView()方法与我们的前一个项目非常相似(参考项目 1 中的实现起始视图部分),我将跳过它,直接跳到displayAvailableDocuments()(initializeView()方法确实调用了它)。
documentsView.displayAvailableDocuments = function ()
{
documentsView.availableDocuments = new DOCS.Filers (
documentsView.documentIterator
,
function () // failure function
{
var anAlert = new PKUI.MESSAGE.Alert
(__T("Oops!"),
__T("I couldn't read your persistent
storage!"));
anAlert.show();
}
);
}
首先,我们创建一个新的 DOCS.Filers 对象。记住,这将立即向文件系统发送请求,获取我们可用的所有文件。当它成功完成请求时,它将调用 documentsView.documentIterator() 方法,该方法将遍历列表中的每个项目并渲染前面的模板。如果失败,它将调用之前定义的 failure 函数并显示一个警告消息。
这是一个很大的变化;我们不再使用内置的 alert() 方法!相反,我们创建了一个新的 Alert 对象,标题为 Oops! 和 I couldn't read your persistent storage! 虽然这并不是世界上最好的错误消息,但如果这种情况发生,我们实际上已经失败了。更大的问题是,这个对象,我们将在完成这个任务的过程中更详细地介绍,为我们提供了平台特定的非原生警告。这意味着我们可以根据需要自定义它们;在这种情况下,这并不多,我们只是显示一个错误消息,但 PKUI.MESSAGE 命名空间提供了提示选项。Alert 对象还允许我们在按钮按下时指定一个回调,这在需要询问是/否问题时非常有用。
下一个方法是 reloadAvailableDocuments(),它与之前的方法非常相似,所以我也会跳过它。它仅在文件视图从视图堆栈中弹出时使用。
documentsView.documentIterator = function ( o )
{
var theHTML = "";
var theNumberOfDocuments = 0;
for (var i=0; i<o.getDocumentCount(); i++)
{
var theDocumentEntry = o.getDocumentAtIndex ( i );
theHTML += PKUTIL.instanceOfTemplate (
$ge("documentsView_documentTemplate"),
{ "title":
theDocumentEntry.name.substr(0,
theDocumentEntry.name.length-4),
"index": i
}
);
theNumberOfDocuments++;
}
if (PKDEVICE.platform()=="ios")
{
$ge("documentsView_contentArea").style.width =
(((theNumberOfDocuments) * 246)) + "px";
}
$ge("documentsView_contentArea").innerHTML = theHTML;
}
这是一个相当简单的函数:我们只是遍历从文件系统返回的文档,并为 documentsView_documentTemplate 模板创建一个新的实例。我们使用一个新的便利方法 PKUTIL.instanceOfTemplate() 来简化这个过程。它将接受一个 DOM 元素和一个包含应替换的属性的对象,在这个例子中是 title 和 index 以及它们对应的值。(使用 substr() 方法来截断文件扩展名。)
这个方法执行的是我们之前手动执行的任务,使用 replace(),但它做得更好。如果你没有注意到,我们巧妙地避免在我们的模板中多次使用相同的替换变量。这是因为 replace() 只能一次替换一个实例。我们的便利方法会一直调用 replace(),直到所有实例都被替换,这意味着我们现在可以随意使用 %TITLE% 和 %INDEX%。
专门针对 iOS 的代码部分只是确定内容区域的宽度,以便进行滚动。对于 Android,这段代码不会执行。
documentsView.openDocument = function ( idx )
{
documentsView.availableDocuments.openDocumentAtIndex
( idx,
function ()
{
fileView.setFileEntry ( documentsView.
availableDocuments.getFileEntry() );
PKUI.CORE.pushView ( fileView );
},
function (e) { console.log (JSON.stringify(e))
}
);
}
打开文档发生在用户点击文档图标时。(对于 Android,这是你应该考虑更改的事情,但为了这个应用的目的,我们将保持一致。)
我们调用openDocumentAtIndex()并传递当文档打开时被调用的completion和failure函数。success方法将设置fileView方法的fileEntry属性,并将其推送到屏幕上。这一行为将触发加载内容。失败将错误记录到控制台,尽管您可能还应该添加一个有意义的错误警报。
documentsView.createNewDocument = function ()
{
{
var anAlert = new PKUI.MESSAGE.Prompt
(__T("Create Document"),
__T("This will create a new document
with the name below:"),
"text",
"New Filer " + __D(new Date(),
"yyyy-MM-dd-HH-mm-ss"),
__T("Don't Create<|Create>"),
function (i)
{
if (i===1)
{
documentsView.availableDocuments.
createDocument ( "" +
anAlert.inputElement.value+".fln",
function ()
{
fileView.setFileEntry (
documentsView.
availableDocuments.
getFileEntry() );
PKUI.CORE.pushView ( fileView );
},
function (e)
{
var anAlert = new
PKUI.MESSAGE.Alert (
__T("Oops!"),
__T("Couldn't create the
file.") );
anAlert.show();
}
);
}
}
);
anAlert.show();
}
}
欢迎来到链式回调的奇妙世界!创建一个文档需要两个步骤。首先,我们询问用户希望将文档命名为什么(使用一个公认的晦涩的默认值)。然后我们创建文档,这意味着我们必须有另一个成功/失败回调。如果我们无法创建文档,我们将创建另一个警报来进一步混淆问题。
然而,真正重要的是,我们最初对用户的请求实际上是在给他们一个机会在我们的警报消息中输入一些内容!在我们所有的应用中,我们还没有这样做过,这是一个巨大的进步。此外,我们还有自定义按钮——一个“不创建”按钮和一个“创建”按钮。
如果您想知道前面代码中按钮旁边的<和>是什么——太棒了!这些主要用于 iOS,尽管您也可以将这些扩展到其他平台。iOS 有破坏性行动的概念;这些按钮应该总是用红色。或者,如果您要针对的本地使用不同的颜色,请使用该颜色。它还有取消按钮颜色的概念(通常是较深的灰色)。为了增加这一点,我们决定将进行下一步操作的按钮涂成绿色。
这些按钮的每个都将在按钮名称的末尾得到一个特殊字符。例如,Cancel<将使按钮颜色变深,并使用Cancel的文本作为按钮。Go>将使用Go作为文本,并将按钮涂成绿色。另一方面,Delete*将使用Delete作为文本,但将按钮涂成红色。
为了让您对每个系统上的警报/提示有一个良好的了解,这里是一个 iOS 的示例:

对于 Android,视图将如下所示:

重命名文档与创建新文档有些相似,但我们在最后不会显示文档。我们将询问用户新的名称应该是什么,如果他们选择继续,我们将尝试执行该功能。以下代码片段可以用于此操作:
documentsView.renameDocument = function ( idx )
{
var theFileName = documentsView.availableDocuments.
getDocumentAtIndex(idx).name;
theFileName = theFileName.substr(0,theFileName.length-4);
var anAlert = new PKUI.MESSAGE.Prompt (
__T("Rename Document"),
__T("Rename your document to the
following:"),
"text",
theFileName,
__T("Cancel<|Rename>"),
function (i)
{
if (i==1)
{
var theNewFileName =
""+anAlert.inputElement.value+".fln";
try {
documentsView.availableDocuments.
renameDocumentAtIndexTo
( idx, theNewFileName,
documentsView.documentIterator,
function ( e )
{
var anAlert = new
PKUI.MESSAGE.Alert
(__T("Oops!"),
__T("Couldn't rename the
file.") );
anAlert.show();
}
);
}
catch (e)
{
var anotherAlert = new
PKUI.MESSAGE.Alert (
__T("Oops!"),
__T("Couldn't rename the
file.") );
anotherAlert.show();
}
}
}
);
anAlert.show();
}
如果发生某种类型的失败,我们将通过显示一个错误来表示,一次作为“失败”函数,第二次在try/catch块的catch部分。
注意,按照目前的写法,我们没有检查新的名称是否会与其他文件冲突。因此,如果用户将一个文件重命名为另一个文件的名称,则之前的文件将被覆盖。您应该在代码中添加一个额外的检查,以确保新的文件名尚未存在。
copyDocument()方法几乎相同,所以我们将跳过它,转到以下代码片段中显示的deleteDocument()方法:
documentsView.deleteDocument = function ( idx )
{
var anAlert = new PKUI.MESSAGE.Confirm (
__T("Remove Document"),
__T("This will remove the document. This
action is unrecoverable."),
__T("Don't Remove<|Remove*"),
function (i)
{
if (i==1)
{
documentsView.availableDocuments.
deleteDocumentAtIndex
( idx, documentsView.documentIterator,
function (e)
{
var anAlert = new PKUI.MESSAGE.Alert
(__T("Oops!"),
__T("Couldn't delete the file.") );
anAlert.show();
}
);
}
);
anAlert.show();
}
删除文档比复制或重命名文档要简单得多,所以我们在这里不会深入回调链。我想强调的主要一点是使用*来表示“删除”按钮将以红色按钮的形式显示,以警告用户在 iOS 上这是一个破坏性操作。尽管如此,你仍然可以修改框架,在 Android 上也显示类似的颜色。
剩余的方法与之前视图中的方法类似,所以我们将跳过它们。
我们做了什么?
虽然还有很多改进的空间,但我们已经为我们的应用程序创建了一个相当不错的文档管理器。我们允许用户重命名文件、删除文件、复制文件、打开文件和创建文件,这些都是一个好的文件管理器应该做的。唯一我们没有做的是允许用户共享文件,尽管有一个愉快的分享图标表示这个意图。这只是为了节省空间,并且因为这是一个我们之前已经讨论过的话题。
我还需要了解什么?
在我们这个文件管理器中,还有很多我们没有涉及的内容,它们都是重要的问题,而且肯定是你需要考虑自己实现的功能。代码本身是自我解释的,所以我们不会深入细节,但以下是一些主要问题:
-
文件名不能包含某些字符:每个人处理这个问题的方式都有些不同;你可以向用户显示一个错误信息,告诉他们需要选择不同的字符,或者你可以默默地将其更改为其他内容(这在 iOS 中很常见)。无论如何,你都应该在创建新文件或重命名/复制文档之前检查这些字符。
-
创建/重命名操作可能会覆盖现有数据:你会认为由于复制操作如果目标文件存在会失败,那么重命名/创建也会失败。不幸的是,并非如此。它们会直接覆盖文件。你必须遍历整个目录结构,以确定你是否即将覆盖一个现有的文件!用户不喜欢丢失数据,即使他们自己是原因。
-
为非 iOS 用户打开文档:Android 用户不应该需要知道点击图标会打开文档;他们会假设整个区域都是可点击的(除了图标)。因此,为它们提供一个用于重命名文件的另一个图标,并允许文件名本身(以及文档图标)打开文件,而不是重命名文件,这会是一个好主意。
-
iOS 文档图像应反映文件的内容:这个实现起来可能更困难,但通常文档图标会包含文件实际内容的某个部分。有各种方法可以实现这一点,从读取实际内容并在 DOM 上显示它们(并在某个部分后剪辑它们)到将它们渲染到 HTML
canvas标签并将结果保存为缩略图。无论如何,这是用户会期望的。
实现文件视图
这个视图相当简单;实际上它本质上是一个大的 TEXTAREA 元素,带有一些代码,每隔几秒自动保存内容。让我们看看它将在每个平台上如何渲染,首先是 iOS:

对于 Android,视图将如下所示:

所有这些都看起来很相似,反映了视图的简单性。
继续前进
视图的 HTML 部分与先前的视图非常相似,所以我们现在先跳过它。只需知道,有一个名为 fileView_text 的 TEXTAREA 元素,我们的代码将引用它。还有一个在标题栏上的 onClick 处理器,用于启用更改笔记标题。这些在以下代码片段中可以看到:
var fileView = $ge("fileView") || {};
fileView.theFileEntry = {};
fileView.theFilerDocument = {};
fileView.theSaveTimer = -1;
和往常一样,我们有几个属性。第一个是用来存储我们目前正在处理的文件的信息,而第二个是实际的文档内容。最后一个属性将存储由 setInterval() 返回的值,这用于每隔几秒调用我们的自动保存功能。
fileView.setFileEntry = function ( theNewFileEntry )
{
fileView.theFileEntry = theNewFileEntry;
fileView.theFilerDocument = {};
}
在这里,我们只是提供了一个方法,让 documentView 方法告诉我们要使用哪个文件。
下一个方法 initializeView() 与其他视图足够相似,所以我们将其跳过。接下来是 entitleDocument(),如下代码片段所示:
fileView.entitleDocument = function ()
{
var anAlert = new PKUI.MESSAGE.Prompt (
__T("Entitle"),
__T("What's the title of this document?"),
"text",
fileView.theFilerDocument.getTitle(),
__T("Cancel<|Entitle>"),
function (i)
{
if (i==1)
{
fileView.theFilerDocument.setTitle (
anAlert.inputElement.value );
fileView.viewTitle.innerHTML =
fileView.theFilerDocument.getTitle();
}
}
);
anAlert.show();
}
当点击标题时,我们将向用户显示一个提示,使他们能够更改笔记的标题。
fileView.loadDocument = function ()
{
fileView.viewTitle = $ge("fileView_title");
fileView.viewTitle.innerHTML = fileView.theFileEntry.name.substr(0,fileView.theFileEntry.name.length-4);
fileView.theTextElement = $ge("fileView_text");
fileView.theTextElement.value = "";
fileView.theFilerDocument = new DOC.Filer (fileView.theFileEntry,function ()
{
fileView.viewTitle.innerHTML = fileView.theFilerDocument.getTitle();
fileView.theTextElement.value = fileView.theFilerDocument.getText();
fileView.theSaveTimer = setInterval (
fileView.saveDocument, 5000 );
},
function (e)
{
PKUI.CORE.popView();
var anAlert = new PKUI.MESSAGE.Alert
(__T("Oops!"),
__T("Couldn't open the file.") );
anAlert.show();
}
);
}
通过创建一个新的 DOC.Filer() 对象并使用我们的 fileEntry 属性的内容来加载特定文档的内容。这假设在将我们推入视图堆栈之前已经由 documentView 设置。
在成功解析文档后,我们将导航栏的标题设置为文档的标题,并将 TEXTAREA 元素的文本内容设置为笔记的文本。然后我们设置每五秒自动保存一次。
如果由于某种原因我们无法打开文件,我们将显示一个错误,但也会从视图堆栈中弹出我们自身。如果我们甚至无法打开文件,就没有必要显示编辑器。
fileView.saveDocument = function ()
{
fileView.theFilerDocument.setText ( fileView.theTextElement.value );
fileView.theFilerDocument.saveFileContents (
function ()
{
console.log ("Auto save successful.");
},
function (e)
{
PKUI.CORE.popView();
var anAlert = new PKUI.MESSAGE.Alert (
__T("Oops!"),
__T("Couldn't save to the file.") );
anAlert.show();
}
);
}
保存内容是一件简单的事情。我们从 TEXTAREA 元素中复制文本,并将其放入 Filer 对象中。然后我们要求它保存文件的内容。如果成功,我们只需在控制台(在生产应用中你会移除它)中记录一条消息,如果不成功,我们显示一个错误并弹出视图。(弹出视图是否是一个好主意是有争议的。)
fileView.viewWillAppear = function ()
{
fileView.loadDocument();
}
我们的viewWillAppear()方法相当简单:我们启动加载我们的笔记。这意味着通过设置fileEntry并将我们推入视图堆栈,我们将自动加载笔记的内容。
fileView.viewWillHide = function ()
{
if (fileView.theSaveTimer!==-1)
{
clearInterval (fileView.theSaveTimer);
fileView.theSaveTimer = -1;
}
fileView.saveDocument();
documentsView.reloadAvailableDocuments();
}
我们的viewWillHide()方法稍微复杂一些。在这里,我们禁用了自动保存功能。毕竟,我们不想保存一个已经不再打开的文档。然后我们强制保存文档。也许用户在自动保存间隔之间正在导航;他们不希望丢失任何数据,对吧?
在保存内容后,我们还强制documentsView方法重新加载文档列表。当我们编辑现有文档时,这并不是什么大问题,但当我们创建新文档时,这却是一个大问题,因为我们希望文件管理器能够显示我们新创建的笔记。
我们做了什么?
我们创建了一个简单的文本编辑视图,可以打开文件内容并将其保存回来。它还实现了一个简单的自动保存功能。
我还需要了解什么?
你将要针对的大多数设备都使用软键盘进行输入。这意味着屏幕的一部分将被屏幕键盘覆盖。
每个设备如何做这取决于平台和键盘类型。例如,Android 允许安装许多不同的键盘,而且不是每个键盘都以相同的方式操作。
实质上,所发生的情况是可用的空间被移动或调整大小,以允许显示屏幕键盘。这意味着我们的用户界面也会随着键盘一起移动。这种操作是否流畅取决于平台(在 Android 上,键盘本身在一定程度上也起到作用)。iOS 在这方面做得最好;涉及到的麻烦最小,显示屏滚动得也很整洁,以确保文本保持在屏幕上。
不幸的是,在执行此操作时,Android 会有一些闪烁,而我们几乎无法控制键盘本身的外观。
一个有趣的选择可能是我们自己用纯 HTML、CSS 和 JavaScript 实现软键盘。技术上这可以行得通,但这仍然是一个相当大的漏洞,而且你的软键盘永远不会像平台上的合法键盘那样工作。(在 Android 上,喜欢特定键盘配置的用户会立刻讨厌它。)你还得考虑用户连接了蓝牙键盘的情况。这通常会导致软键盘不显示,这意味着屏幕的全部空间都被用于我们的显示。由于没有方法(除非开发我们自己的插件)来确定是否连接了物理键盘,我们强烈建议不要使用这个选项。
一些 Android 发行版还添加了一个有趣的特性。看起来当input和textarea元素被编辑时,实际上会在它们自己上方显示另一个可编辑区域。在我的手机上,这通过当前编辑器下方可见的闪烁光标的一部分来体现,几乎就像 DOM 元素只是原生输入元素的镜像。如果不是因为它们略有错位,这不会引起注意。无论如何,我觉得应该提一下。
游戏结束..... 结束语
我们在这个任务中已经取得了相当多的成就,虽然这些成就并不特别辉煌,但对于接下来的工作来说绝对是必要的。我们的应用程序必须能够永久存储数据,并且它们还必须能够检索相同的数据。同样,它们需要提供管理这些数据的方法,包括重命名、复制和删除。
你能承受高温吗?热手挑战
有几种方法可以改进这个应用程序:
-
我们的应用程序只询问用户是否真的想要删除文件,但其他操作同样危险。如果用户即将执行的操作会覆盖数据(例如,将文档重命名为现有文档的名称或创建与现有文档同名的新文档),则应添加确认提示。
-
添加功能以检查用户提供的文件名是否有效。然后,要么向用户指示这一点,要么将无效字符静默地更改为有效字符。
-
我们没有提供子目录功能,但你完全有能力做到。实际上,我们在代码中明确忽略了子目录,因为它们会给文件管理系统增加很多复杂性。你为什么不给应用程序添加子目录管理功能呢?
-
而不是存储笔记,也许你可以存储一些表格。也许是一些简单的地址或提醒——实际上,几乎可以是任何东西。
第四章。让我们去旅行
地理定位在当今世界变得非常重要,尤其是在大多数手机都有能力以惊人的精确度确定你的位置之后。鉴于这曾经(不久前)是昂贵 GPS 设备的主要功能,这种能力迅速普及真是令人惊讶。因此,用户期望有位置感知的应用程序,不仅如此,他们还期望有一个能够响应他们输入的漂亮地图。
我们要构建什么?
我们的项目旨在实现两个概念,第一个是简单地使用(非常)小部分的 Google Maps API(截至本文写作时,3.9)。我们将使用这个 API 来显示一个以用户当前位置为中心的完整功能地图。其次,我们将使用 PhoneGap 提供的地理定位功能来获取用户的当前位置。完成之后,我们将有一个应用程序,不仅可以显示以用户当前位置为中心的地图,还可以记录他们的移动并在屏幕上显示。与其想象成录音机,不如想象成位置记录器。
它做什么?
在上一个项目中,我们引入了文档管理,在这个项目中我们将进一步巩固这些功能。幸运的是,大部分工作已经为我们完成,但正如你所回忆的,有几个情况下用户可能会遇到麻烦(如果他们使用了一个与现有文件冲突的名称)。在这个项目中,我们将解决这个问题,以便我们有一个更加健壮的解决方案。
我们还将介绍我们可以使用的各种方法来加载 Google Maps API,这比最初想象的要困难一些,而且我们还要处理可能失去(或没有)网络访问的可能性。我们不会使用整个 Google Maps API,它值得一本自己的书,但我们将使用一些基本功能,包括标记和线条。
最后,我们将使用地理定位。一些浏览器足够好,提供了良好的地理定位实现,由于 PhoneGap 的实现遵循 W3C 标准,如果浏览器解决方案足够好,它将使用浏览器解决方案。这也意味着,如果我们使用localStorage而不是持久文件,我们构建的内容甚至可以在 PhoneGap 之外工作。
当我们将所有这些整合在一起时,我们将有一个相当有趣的应用程序。一个可以记录我们的位置(在我们允许的情况下)并显示它的应用程序。这为扩展提供了各种可能性:你可以与朋友分享路径,你可以导出为 KML 以供其他应用程序使用,等等。
它为什么很好?
地理定位和交互式地图是现代应用中用户所期望的功能。如果你显示地址,至少应该能够显示一个地图与之配合。如果你提供基于位置的搜索,你应该能够定位用户的位置并向他们提供相关结果。地理定位和交互式地图不仅用于路线导航或帮助迷路的人;它们在许多其他应用中也非常有价值。
我们该如何进行?
在许多方面,这个任务比之前的任务更容易。一方面,我们的框架已经变得相当稳定(尽管这个项目有一些变化),我们在文档管理方面也有了一个良好的开端。真正剩下的是创建一个能够存储位置信息并保存和检索它的数据模型。这也是我们第一个不需要开始视图的应用——从现在起,我们将直接进入应用。
为了实现这一点,我们将使用之前使用过的相同熟悉步骤:
-
设计我们的 UI 和外观感受
-
设计我们的数据模型
-
实现我们的数据模型
-
更改我们的文档管理器
-
实现我们的地图视图
我需要什么来开始?
和往常一样,继续创建你的项目。虽然你不需要担心插件支持;我们不需要任何本机插件来为这个应用。
此外,也请查看一下谷歌地图 API (developers.google.com/maps/documentation/javascript/3.9/reference)。在那里,你可能还想注册一个 API 密钥。虽然你可以不使用密钥使用 API(我们在这里就是这样做的),但拥有一个密钥可以提供使用指标,并且如果你的应用足够受欢迎,你将能够为你的使用付费,这样你就不会受到为非密钥用户强制执行的低 API 配额的限制。务必查看他们的文档;那里有很多内容,足以填满几个章节,但它非常值得浏览。这样说吧:有些谷歌地图能做的事情,我以前从未知道它能够做到,你也可能会发现同样的情况。
设计我们的 UI 和外观感受
和往常一样,在我们对应用的外观有一个很好的想法之前,不要开始编码。幸运的是,我们实际上只需要关注地图视图。我们在上一个项目中已经涵盖了文档管理器的外观和感受,这里并没有太大的变化。此外,由于视图的大部分实际上将由谷歌提供的交互式地图占据,所以我们甚至不需要做太多。
准备就绪
再次,拿出你的铅笔和纸或者你最喜欢的图形编辑器;我们将使用它们来设计我们的线框图,然后构建我们可能以后需要的任何资产。
开始行动吧
以下截图展示了我们地图视图的最终原型:

初看之下,这似乎是一个相当简单的视图——确实如此——但不要被它欺骗。其下隐藏着巨大的力量!
让我们回顾一下各种项目:
-
返回按钮将用户带回到文档管理界面。当然,对于 Android 来说,这个按钮将不会出现;设备的物理返回按钮将起到作用。
-
导航栏上的标题将是文档的标题。用户可以点击它,通过我们框架的提示警报来更改文档。
-
带有地球仪的按钮打算作为找到我按钮。当显示时,视图将自动执行此操作,但交互式地图的一个显著特点是您应该能够自己探索它,而无需不断被拖回到当前位置。此按钮的目的是在您进行了一些探索后重新定位您的地图。
-
带有红点的按钮是记录按钮。当点击时,视图将开始记录您的位置(每次位置改变时)并绘制一条跟随您进度的线。如果再次点击,它将停止跟踪您的位置。
-
在导航栏下方是谷歌地图(此处为X图像)。当然,这将由谷歌提供。用手指移动地图将平移地图,但更重要的是,我们将捕捉这个事件,以便我们可以解锁地图,使其从您的当前位置开始。
-
向下的箭头是典型的谷歌标记的表示;这将指示您的当前位置。
-
线条是表示已经记录的一些路径的表示;它将指示在记录过程中路径的每次更新。
既然我们已经定义了所有东西应该如何协同工作,让我们进入我们的图形程序,创建我们需要的图形资产。
文档视图将如下所示:

地图视图将如下所示:

通常,我们使用了与先前项目相同的资产,尽管我们的文档图像已更改为地图。我们还需要使用按钮上的图像,一个用于标准的找到我图标,两个用于记录按钮的各种状态——一个圆圈(用于记录)和一个暂停图标(未显示)。这些图标来自 App-Bits 的免费图标集,可在app-bits.com/free-icons.html找到。如果您还没有,您可能想先下载这个图标集。
我们做了什么?
在这个任务中,我们介绍了用户界面的设计以及它的各个部分如何工作。我们跳过了文档管理器的讨论,因为它与先前项目几乎相同,唯一的变化是我们使用的图像和文件扩展名。
设计我们的数据模型
在这个任务中,我们将致力于设计我们的数据模型。我们将关注文档及其包含的项目;文档管理器模型与先前项目保持不变。
准备工作
如果你查阅地理位置的文档,你会注意到位置信息包含相当多的信息,包括纬度、经度、海拔、航向和速度。大多数实现也会返回位置和海拔的精度,但我们现在将忽略这一点。由于我们的地图将显示当前位置,我们假设用户只有在当前位置正确的情况下才会开始记录,因此等待精度稳定下来就不那么重要了。如果我们打算立即开始记录,我们需要等待精度缩小到可接受的极限,这就是这些值变得有用的地方。
拿出你的纸和笔,我们将开始构建我们的数据模型。
继续前进
我们的数据模型将类似于以下截图:

技术上,我们有三个模型:前一个截图显示的两个模型和 PathRecDocumentCollection 模型,后者与我们在上一个项目中看到的文档管理器模型相同。由于它相同,我们将跳过它,专注于前一个截图显示的两个模型。让我们从 PathRecDocumentItem 开始:
-
timestamp、latitude、longitude、altitude、heading和speed都是项目需要存储的属性。我们将在对象创建时收集这些属性,并通过setPosition()存储它们,以便我们的项目能够立即填充。与用于地理位置的position对象不同,我们不会在coords对象中存储坐标,但我们必须稍后处理这个问题。 -
setPosition()方法可以接受一个地理位置位置(带有coords对象)或一个序列化的PathRecDocumentItem对象(不带coords对象)。它将适当地更新属性。 -
get…()属性将返回请求的属性值。 -
getLatLong()属性以lat, long的形式返回纬度和经度。 -
getGoogleLatLng()属性返回一个 Google MapsLatLng对象。 -
getGoogleMarker()属性返回一个 Google MapsMarker对象。 -
serialize()属性返回一个准备好存储在 JSON 文档中的对象。
请记住,前面的模型只存储单个地理位置位置;要将多个位置串联起来,需要下一个模型 PathRecDocument,它包括以下内容:
-
fileEntry、filename、completion、failure和state都与我们在上一个项目中使用的文档相同。 -
title属性存储文档的标题。 -
nodes是先前列出的项的数组;这是我们存储一系列地理位置位置的方式。将它们全部放在一行中,我们就会得到用户在录制过程中走过的路径。 -
get/setTitle()方法返回并设置文档的标题。 -
get/setNodes()方法将返回并设置节点;这些需要数组。 -
addNode()方法将节点推送到笔记列表中;这必须是一个PathRecDocumentItem。 -
getNodeAtIndex()方法将返回给定索引处的节点。 -
getNodeCount()方法将返回路径中的节点数量。 -
剩余的方法与上一个项目的文档模型相同。
-
serialize()方法将返回一个适合存储在文件中的对象。与上一个项目不同,这次serialize()必须遍历每个节点,调用其serialize()方法来构建一个不包含PathRecDocumentItem中所有额外方法的节点数组。(毕竟,没有理由存储这些。)结果将是一个具有标题属性和仅包含位置信息的节点数组的对象;其他所有内容都将被删除。
我们做了什么?
在这个任务中,我们创建了我们的数据模型,并重用了我们之前项目数据模型的部分。毕竟,为什么要重新发明轮子,对吧?
接下来,我们需要实际实现这个数据模型。我们将在下一个任务中处理这个问题。
实现我们的数据模型
我们将创建两个文件,即 PathRecDocumentCollection.js 和 PathRecDocument.js,以存储我们的三个数据模型。由于第一个与上一个项目的文档管理器非常相似,我们将跳过项目中的大部分代码,并专注于后面的脚本。
准备工作
打开你的编辑器,并将 www/models 目录中的 PathRecDocument.js 和 PathRecDocumentCollection.js 文件复制到你的项目中,这样你就可以跟随我们关于实现的讨论。
开始吧
在我们开始真正的重点之前,让我们快速看一下 PathRecDocumentCollection.js 文件中我们的 PathRecDocumentCollection 模型的一些变化:
self.renameDocumentAtIndexTo = function ( idx, newName, completion, failure )
{
self.completion = completion;
self.failure = failure;
self.state = "Renaming a Document";
for (var i=0; i<self.documents.length;i++)
{
if (self.documents[i].name.toLowerCase().trim() == newName.toLowerCase().trim())
{
self.dispatchFailure ( { "error": "The file already exists" } );
return;
}
}
self.documents [ idx ].moveTo ( self.fileSystem.root, newName.trim(), self.renameDocumentAtIndexToSuccess, self.dispatchFailure);
}
你会注意到我们的 renameDocumentAtIndexTo 现在多了几行代码,以确保我们不会覆盖已经存在的文件。如果存在同名文件,我们会向 failure 方法发送错误,我们的文档管理器会愉快地阻止用户进行任何危险的操作。我们对创建文档和复制文档也做了同样的事情。
在处理完这些之后,让我们转到 PathRecDocument.js 中的 PathRecDocumentItem:
var DOC = DOC || {};
DOC.PathRecDocumentItem = function ( position )
{
var self = this;
self.timestamp = {};
self.latitude = 0;
self.longitude = 0;
self.altitude = 0;
self.heading = 0;
self.speed = 0;
与我们的模型一样,前面的定义了我们的属性。
self.setPosition = function ( position )
{
self.timestamp = position.timestamp;
if (position.coords)
{
self.latitude = position.coords.latitude;
self.longitude = position.coords.longitude;
self.altitude = position.coords.altitude;
self.heading = position.coords.heading;
self.speed = position.coords.speed;
}
else
{
self.latitude = position.latitude;
self.longitude = position.longitude;
self.altitude = position.altitude;
self.heading = position.heading;
self.speed = position.speed;
}
}
setPosition() 方法会将属性设置为传入的位置。如果是一个地理位置位置(它将有一个 coords 对象),我们使用这些值,但如果它是一个序列化的 PathRecDocumentItem,我们则只使用这些值。
self.getLatitude = function ()
{
return self.latitude;
}
像所有好的对象一样,我们为所有属性提供了获取器。由于它们都非常简单,我们不会逐一介绍每一个。
self.getLatLong = function ()
{
return self.latitude + "," + self.longitude;
}
self.getGoogleLatLng = function ()
{
return new google.maps.LatLng( self.latitude, self.longitude );
}
前两个方法实际上是便利方法。一个是返回 lat, long 格式的经纬度,另一个是返回一个 Google Maps LatLng 对象。这个对象是 Google Maps API 中的一个关键对象。
self.getGoogleMarker = function ( withMap )
{
return new google.maps.Marker(
{
map:withMap,
title:self.getLatLong(),
draggable:false,
position:self.getGoogleLatLng()
}
);
}
这也是一个便利的方法,但它返回一个 Google Maps Marker。这需要一个已经初始化的 Google 地图,否则它将设置一个带有标题Lat, Long的标记,位置相同。
self.serialize = function ()
{
return {
"timestamp": self.timestamp,
"latitude": self.latitude,
"longitude": self.longitude,
"altitude": self.altitude,
"heading": self.heading,
"speed": self.speed
};
}
为了将项目保存到文件中,它需要被序列化。由于我们不需要序列化方法,我们只需返回一个包含位置的物体对象。
if (position)
{
self.setPosition ( position );
}
}
最后,在构造函数的末尾,我们将设置位置,如果传给了我们。如果没有,对象将没有任何位置数据设置。
接下来,我们将查看PathRecDocument对象。其中大部分与上一个项目中的文档对象相似,所以我们将省略那些部分。
DOC.PathRecDocument = function ( theFileEntry, completion, failure )
{
self.title = "My Path";
self.nodes = [];
到目前为止,唯一的真正区别是,我们不再存储文本,而是存储一个PathRecDocumentItems数组。这些将用于存储路径内的坐标。
self.getNodes = function ()
{
return self.nodes;
}
self.setNodes = function ( theNodes )
{
self.nodes = theNodes;
}
到目前为止,这些获取器和设置器相当典型。我们可以请求项目列表(getNodes),并给对象一个新的列表(setNodes)。
self.addNode = function ( aNode )
{
self.nodes.push ( aNode );
}
addNode()方法会将一个新的PathRecDocumentItem放入我们的节点列表中。
self.getNodeAtIndex = function ( idx )
{
return self.nodes[idx];
}
self.getNodeCount = function ()
{
return self.nodes.length;
}
虽然我们可以使用getNodes()方法来返回整个列表,但逐个处理它们也很方便;因此,我们使用getNodeAtIndex和getNodeCount。
self.finishedReadingFile = function ( e )
{
var theFileContents = e.target.result;
if (!theFileContents)
{
theFileContents = '{"title":"New File","nodes":[]}';
}
到实际加载我们的文档之前的代码大部分在这里被省略了。它与前一个项目相同,但这里我们开始看到一些差异。首先,如果没有内容在文件中,我们假设它是一个空白文档,但我们需要用默认标题和空节点列表初始化我们的文档。
try
{
var data = JSON.parse ( theFileContents );
self.title = data.title;
for (var i=0; i<data.nodes.length; i++)
{
self.addNode ( new DOC.PathRecDocumentItem ( data.nodes[i] ) );
}
}
接下来,虽然设置标题很容易,但我们必须遍历文件中的节点列表并将它们添加到文档中。当我们完成时,我们的文档将包含文件中保存的所有节点。
self.serialize = function ()
{
var serializedNodes = [];
for (var i=0; i<self.nodes.length; i++)
{
serializedNodes.push ( self.nodes[i].serialize() );
}
return { "title": self.title, "nodes": serializedNodes };
}
保存文件内容实际上与上一个项目相同,但前一个代码片段中改变的是serialize()方法。首先,我们创建一个空数组,然后遍历我们的位置列表。然后我们序列化每一个,并将序列化的结果添加到我们的数组中。这确保了数组中只有位置数据而没有方法定义。然后我们返回标题和序列化的位置,这足以保存文档!
我们做了什么?
在这个任务中,我们为每个地理位置位置创建了数据模型,包含它们的文档,然后重新使用了上一个项目中的文档管理器实现。
改变我们的文档管理器
我们对文档管理器的工作方式做了一些小的改动。虽然不是什么大改动,但确实值得一看。
准备工作
在编辑器中打开www/views/documentsView.html,以便你可以跟随讨论。
继续前进
最大的变化是我们如何根据我们所在的平台处理文档列表显示部分的点击。如果您还记得上一个项目,Android 感觉并不那么舒适,因为必须点击图标才能打开文档,但如果他们点击了名称,就会提示重命名文档。在我们的新管理器中,我们已经将其反转,点击名称将打开文档,点击图标将提示重命名操作。
这些更改在以下文档模板中显示:
<div id="documentsView_documentTemplate" class="hidden">
<div class="documentContainer">
<div class="documentTapArea" onclick="documentsView.documentContainerTapped(%INDEX%)"></div>
<div class="documentImage">
<img src="img/DocumentImage.png" border=0 onclick="documentsView.documentIconTapped(%INDEX%)"/>
</div>
<div class="documentTitle" onclick="documentsView.documentNameTapped(%INDEX%)">
<span >%TITLE%</span>
</div>
<div class="documentActions">
<img src="img/Copy.png" width=28 height=28 border=0 onclick="documentsView.copyDocument(%INDEX%)" />
<img src="img/Share.png" width=27 height=28 border=0 onclick="documentsView.shareDocument(%INDEX%)" />
<img src="img/Trash.png" width=28 height=28 border=0 onclick="documentsView.deleteDocument(%INDEX%)" />
</div>
</div>
</div>
已经突出显示了关键差异。首先,我们引入了一个名为 documentTapArea 的新 div 元素。它位于整个文档详情之后,以便它可以成为 Android 的点击目标。它将触发 documentContainerTapped(),以便我们可以在事件发生时做出响应。
下一个差异是对于图标:我们触发 documentIconTapped() 而不是 openDocument。
最后的区别是标题:我们触发 documentNameTapped() 而不是 renameDocument。
这些更改在代码中很容易被发现,如下面的代码片段所示:
documentsView.documentIconTapped = function ( idx )
{
if (PKDEVICE.platform() == "ios")
{
documentsView.openDocument(idx);
}
else
{
documentsView.renameDocument(idx);
}
}
documentsView.documentNameTapped = function ( idx )
{
if (PKDEVICE.platform() == "ios")
{
documentsView.renameDocument(idx);
}
else
{
documentsView.openDocument(idx);
}
}
documentsView.documentContainerTapped = function ( idx )
{
if (PKDEVICE.platform() == "ios")
{
return;
}
else
{
documentsView.openDocument(idx);
}
}
在这些方法中,我们做的是根据当前平台确定如何反应。如果我们是 iOS 设备,图标应该打开文档(它是最大的点击目标),文档下的点击目标应该什么都不做,标题应该提示重命名。另一方面,所有其他设备如果点击了名称或点击目标,应该打开文档,只有当点击了图标时才提示重命名。
我们做了什么?
在这个任务中,我们介绍了一些对文档管理器进行的微妙更改,以帮助它在 Android 平台上感觉更加舒适。
实现我们的地图视图
实际上,我们的地图视图的外观将会相当简单。Google 地图将负责交互内容(以及滚动,感谢上帝),因此我们的主要关注点将是导航栏和与 Google 地图的交互。
当我们完成时,我们应该在 iOS 上看到以下内容:

Android 设备上的视图应该是这样的:

准备工作
我们将从 www/views 目录下的 mapView.js 文件开始工作,所以请打开它,以便您可以跟上。
开始行动
让我们先看看视图的 HTML:
<div class="viewBackground">
<div class="navigationBar">
<div id="mapView_title"></div>
<span style="display: block; position: absolute; right:10px; top: 6px; width:auto; text-align: right;">
<span class="iconButton " id="mapView_trackButton" style="margin-right: 10px;" ></span>
<span class="iconButton " id="mapView_actionButton" ></span>
</span>
<button class="barButton backButton" id="mapView_backButton" style="left:10px;" ></button>
</div>
首先,我们的导航栏中包含几个按钮。trackButton 的目的是在地图被移动后重新将地图中心定位到用户的位置。actionButton 实际上是 记录 按钮,但我们将其命名为 action,因为它控制着记录的状态,本质上就是 开始 和 停止。
<div class="content avoidNavigationBar" style="padding:0; overflow:hidden; " id="mapView_scroller">
<div id="mapView_contentArea">
<div id="mapView_mapCanvas" style="width:100%; height:100%;">
</div>
</div>
</div>
</div>
在代码的这个阶段,我们其他视图与这个视图之间唯一的真正区别是名为 mapView_mapCanvas 的 div 元素;这个元素将被用来包含我们的 Google 地图。我们给它设置了 100% 的宽度和高度,以便它填充视图。
这就是视图的全部内容。现在让我们看看背后的代码:
var mapView = $ge("mapView") || {};
mapView.theFirstTime = true;
mapView.theFileEntry = {};
mapView.thePathDocument = {};
mapView.theSaveTimer = -1;
mapView.map = {};
mapView.watchID = -1;
mapView.polyline;
mapView.currentPositionMarker = {};
mapView.keepMapCentered = true;
mapView.lastKnownPosition = {};
mapView.recordingPath = false;
初始属性应该看起来很熟悉,它们与上一个项目中的属性类似。但除此之外,我们还有一些将对我们观点至关重要的属性。在继续之前,让我们来回顾一下:
-
map属性将存储我们将最终创建的 Google 地图的引用。 -
watchID属性将存储为我们创建的地理位置创建的计时器 ID。想法是 PhoneGap 将在位置每次变化时调用一个update函数,并给我们这个 ID,以便在需要时取消此更新功能。当我们处于视图状态时,我们可以获取所需的所有位置更新,但当我们离开视图时,我们应该取消监视。 -
Polyline是另一个 Google Maps API 对象。它将在一个数组中存储一系列位置,就像我们自己的PathRecDocument.nodes属性一样,但它以 Google 的方式这样做。为了在地图上显示路径,我们必须将我们的位置项转换为这个对象。 -
currentPositionMarker属性将存储一个 Google MapsMarker对象。我们将使用这个标记来指示用户的当前位置。你可以对标记做得很花哨,包括动画和自定义图像,但就目前而言,我们将使用默认设置。 -
keepMapCentered属性跟踪我们是否应该,正如其名所示,保持地图在用户的当前位置上。如果为真,我们将在每次更新时将地图中心平移到当前位置。但如果为假,则不会这样做。我们会在检测到地图上的 拖动 时更改此值。这意味着用户想要探索当前位置周围的地图,如果我们不将其设置为false,则每次位置变化时地图都会弹回到当前位置,从而产生令人不安和痛苦的经历。 -
lastKnownPosition属性将存储我们最后已知的位置。这将始终保持更新,以便我们可以在keepMapCentered设置为true后,一旦设置为false,就可以平移到最后已知的位置。 -
recordingPath属性指示我们是否正在记录当前位置。如果为真,我们将存储位置更新并将它们添加到文档中,但如果为假,则不会存储任何更新。用户可以通过点击actionButton来切换此功能。mapView.actionButtonPressed = function () { mapView.recordingPath = !mapView.recordingPath; if (mapView.recordingPath) { mapView.actionButton.innerHTML = __T("STOP");} else { mapView.actionButton.innerHTML = __T("RECORD"); } mapView.geolocationUpdate ( mapView.lastKnownPosition ); }
initializeView() 方法本身与我们的其他视图类似,所以我们将跳到 actionButtonPressed()。这个函数将只切换我们是否正在记录(并相应地更新按钮)。然后它将带有最后已知位置的地定位更新发送到我们的视图。这样,记录就会立即开始,而不是等待接收到新的位置。
mapView.trackButtonPressed = function ()
{
mapView.keepMapCentered = true;
mapView.geolocationUpdate ( mapView.lastKnownPosition );
}
如果用户平移地图,我们将keepMapCentered设置为false,这样地图就不会总是弹回到当前位置。此方法将将其重新设置为true并发送新的更新,以便地图立即移动到当前位置(而不是等待新的更新)。这确保了地图能够立即响应,否则用户可能不会认为有任何事情发生。
如果我们在记录,这当然会在我们的路径中增加一个额外的位置,但它将位于之前已知的位置,因此不会显示。可以添加代码以防止添加与上一个节点完全相同的节点。
mapView.geolocationUpdate = function ( position )
{
var theLatLng = new google.maps.LatLng (
position.coords.latitude,
position.coords.longitude );
mapView.lastKnownPosition = position;
if (mapView.keepMapCentered)
{
mapView.map.panTo ( theLatLng );
}
mapView.currentPositionMarker.setPosition (theLatLng);
if (mapView.recordingPath)
{
mapView.polyline.getPath().push ( theLatLng );
mapView.thePathDocument.addNode ( new
DOC.PathRecDocumentItem ( position ) );
}
}
每当用户的地理位置发生变化时(或者如果您愿意,以设定频率),就会调用geolocationUpdate()方法。我们首先创建一个新的LatLng对象,包含坐标,以便我们可以更新我们的标记。我们还把位置存储到我们的lastKnownPosition属性中,这样我们可以在任何时刻重新居中地图。
如果keepMapCentered是true,我们在地图上调用panTo()方法,这将平滑地将地图平移到新位置,如果可能的话。如果新位置远离当前地图视图,谷歌地图将直接跳转到新位置而没有任何动画。幸运的是,我们不必自己跟踪这一点;我们可以确信地图将居中在正确的位置。
然后我们更新我们的标记到正确的位置,以便它也是最新的。如果用户正在与地图互动,这意味着他们可以始终看到当前位置(如果处于视图中)。
最后,如果我们在记录,我们调用路径的push()方法。这有一个很好的好处,就是自动更新谷歌地图视图以包括路径的变化。然后我们将节点添加到我们的文档中,这样我们也可以跟踪它。
mapView.geolocationError = function ( error )
{
var anAlert =
new PKUI.MESSAGE.Alert (
__T("Geolocation Error"),
__T(error.message) );
anAlert.show();
}
地理位置功能还允许一个错误函数。您在这里可以执行许多不同的事情,因为错误对象将以某种粒度指示函数被调用的原因:超时(在特定时间段内无法检索位置)、位置本身不可用,或者我们请求位置被拒绝(用户有权这样做)。在我们的情况下,我们只是在警告框中显示消息。
...
mapView.loadDocument = function ()
{
mapView.viewTitle = $ge("mapView_title");
mapView.viewTitle.innerHTML = mapView.theFileEntry.name.substr(0,mapView.theFileEntry.name.length-4);
mapView.thePathDocument = new DOC.PathRecDocument (mapView.theFileEntry,function ()
{
mapView.viewTitle.innerHTML = mapView.thePathDocument.getTitle();
mapView.theSaveTimer = setInterval ( mapView.saveDocument, 5000 );
for (var i=0; i<mapView.thePathDocument.getNodeCount(); i++)
{
var theNode = mapView.thePathDocument.getNodeAtIndex ( i );
mapView.polyline.getPath().push ( new google.maps.LatLng ( theNode.latitude, theNode.longitude ) );
}
},
function (e)
{
PKUI.CORE.popView();
var anAlert = new PKUI.MESSAGE.Alert (__T("Oops!"),__T("Couldn't open the file.") );
anAlert.show();
}
);
mapView.polyline = new google.maps.Polyline ( { strokeColor: '#80A0C0', strokeOpacity:0.85, strokeWeight:5 } );
mapView.polyline.setMap ( mapView.map );
}
加载我们的文档与上一个项目非常相似,只是在文档完全加载到内存之后发生的事情不同。为了在谷歌地图上显示我们的路径,我们必须将所有加载的位置推送到多线中,这是在方法接近结束时创建的。
…
mapView.viewWillAppear = function ()
{
document.addEventListener("backbutton", mapView.backButtonPressed, false );
mapView.actionButton.innerHTML = __T("RECORD");
mapView.trackButton.innerHTML = __T("CENTER");
}
mapView.viewDidAppear = function ()
{
if (mapView.theFirstTime)
{
mapView.map = new google.maps.Map ( $ge("mapView_mapCanvas"),
{ disableDefaultUI: true,
center: new google.maps.LatLng(40,-90),
zoom: 15,
mapTypeId: google.maps.MapTypeId.ROADMAP
}
);
google.maps.event.addListener ( mapView.map, 'dragstart', function () {
mapView.keepMapCentered = false; } );
mapView.currentPositionMarker = new
google.maps.Marker(
{
map:mapView.map,
title: "Current Position"
}
);
}
mapView.watchID = navigator.geolocation.watchPosition ( mapView.geolocationUpdate, mapView.geolocationError,
{
enableHighAccuracy: true
}
);
mapView.loadDocument();
}
当首次展示视图时,我们将创建谷歌地图。没有真正的原因要在之后再次创建它,这就是为什么我们只做一次。地图使用我们的mapView_mapCanvas元素,该元素被设置为填充整个视图。请注意,我们向构造函数传递了几个属性:
-
当
disableDefaultUI为true时,将关闭谷歌地图上的所有 UI 元素。这意味着像在卫星图和道路图类型之间切换的按钮将消失,以及街景、平移和缩放。与地图的交互仍然有效,但在这个情况下,移除控制元素是个好主意,因为其中一些会导致应用打开新的浏览器(如街景)。 -
center表示地图应该最初居中的位置。纬度和经度没有特别的意义。当我们获得第一次地理定位更新时,它将被重置。 -
zoom表示地图应该缩放多远。15 级允许用户看到他们当前的街道,所以这是一个很好的起始级别。当然,用户可以稍后更改他们的缩放级别,以满足他们的需求。 -
mapTypeId表示地图的类型,在这种情况下,是道路图类型。
一旦我们创建了一个地图,我们也会为它添加一个监听器,当用户自己平移地图时,这个监听器会被触发。如果他们这样做,我们将keepMapCentered设置为false,这样未来的更新就不会重新居中地图。
之后,我们初始化我们的当前位置标记。我们必须告诉标记它将显示在哪个地图上(我们刚刚创建的那个),以及标记的标题。标题默认不显示,所以我们选择了一些既漂亮又通用的东西。
在方法接近结束时,我们使用navigator.geolocation.watchPosition()创建一个对用户位置的监视。第一个参数指示要调用的success方法,第二个参数指示failure方法,第三个参数表示各种选项。在这个选项列表中,我们只请求 GPS 返回一个高度精确的位置。这可能需要几秒钟才能获得,尤其是如果 GPS 一段时间内没有使用过,所以用户会看到位置更新随着 GPS 缩小范围。这就是为什么我们不立即开始记录显示视图;否则,我们就必须实现某种容差方法来确定位置何时足够准确。在我们的情况下,用户只有在他们对屏幕上的当前位置满意后才会开始记录,这样他们就在为我们工作了。
在方法结束时,我们启动加载我们的文档,以便尽可能快地填充视图。
哦,如果你在问我们为什么使用viewWillAppear和viewDidAppear,那是因为谷歌地图不喜欢在视图正在动画过程中被创建。因此,我们让动画发生,然后在一切平静下来后加载地图。
mapView.viewWillHide = function ()
{
navigator.geolocation.clearWatch ( mapView.watchID );
mapView.recordingPath = false;
mapView.keepMapCentered = true;
mapView.polyline.setMap (null); // remove from the map.
mapView.polyline = null; // and destroy.
}
当视图即将被关闭时,我们清除用户位置的监视。毕竟,当视图不是堆栈中的当前视图时,继续接收更新几乎没有理由。我们还关闭了录制(以防用户在关闭视图之前没有停止录制),并将地图居中功能重新打开。然后我们告诉折线它不再应该在地图上可见,并销毁它。如果我们不这样做,以前加载的文档中的先前路径很容易就会显示在地图上。
我们做了什么?
在这个任务中,我们创建了一个交互式谷歌地图,显示记录的路径以及用户的当前位置。我们处理了折线(实际上在地图上显示路径)以及标记。我们还处理了记录路径和地图居中。
我还需要了解什么?
这里是我们当前应用的悲惨现实:当手机锁定或应用处于后台时,不会发生更新。这是预期行为,因为手机没有真正的方式知道我们想要继续接收更新。你可以使用特定于平台的方法来注册你的应用,以便在锁定或后台运行时继续工作,但我们会让你自己深入了解这些内容。目前,应用只有在屏幕开启且应用处于前台时才会接收更新。
游戏结束..... 结束一切
在这个项目中,我们取得了相当大的成就。我们创建了一个用户可以平移和缩放的交互式谷歌地图。我们还创建了在地图上显示的标记和折线。我们还创建了一种记录路径并在以后显示的方法。我们还创建了一个文档模型,可以存储位置信息,以便可以保存和加载。最后,我们还对文档管理器进行了一些小的修改,使其对 Android 设备更加友好。
一些你可能觉得有用的资源:
-
Google Maps API (3.9) at
developers.google.com/maps/documentation/javascript/3.9/reference
你能承受压力吗?热手挑战
在这个项目中,我们已经做了很多,但总有改进和扩展的空间。你为什么不看看自己能否完成一些这些挑战?
-
添加与朋友分享路径的功能。这可能包括路径的图片、文件或一些创意表示。或者,你可以深入了解原生部分,看看如何告诉手机你的应用可以打开某些类型的文件。
-
添加导出路径到 KML 的功能。这是地理定位数据的标准交换格式。
-
添加显示我们正在捕获的附加数据。我们只显示使用纬度和经度的路径,但我们还捕获航向、速度和高度。
-
添加在记录路径后编辑路径的能力,包括移动、删除和添加点。
-
想出一个减少记录数据量的方法。我们跟踪用户位置变化时的每一个点,但这种情况通常并不必要。如果某人正在直线驾驶,那么丢弃中间的点(以及节省内存)将是最合理的选择。
-
这可能需要一些本地编码,但调查并实现所需的功能,以便即使在后台运行或设备锁定时,应用程序也能捕获地理位置数据。(这将节省电池寿命,因为不需要显示可见。)
第五章 与您的应用对话
我们移动设备的媒体功能实际上非常惊人,尤其是当你考虑到五年、十年、十五年前我们所在的位置。第一台大规模生产的 MP3 播放器是 1997 年推出的SaeHan/Eiger MPMan (en.wikipedia.org/wiki/Portable_media_player#SaeHan.2FEiger_MPMan)。该设备有 32MB 的存储空间,足以存储大约 6 到 7 首歌曲(假设每分钟 1MB,每首歌 5 分钟)。虽然按照今天的标准可能看起来很少,但它是一场革命,并催生了一种新的听音乐的方式。
今天的设备现在不仅仅是便携式娱乐设备,可以玩游戏、视频和所有类型的音频。能够在您的应用程序中播放声音至关重要,而且很少有应用程序可以声称完全没有声音。虽然可能有些极端,但TweetBot是一个经典的例子,该应用程序通过用户的交互产生的声音得到了增强。
今天的设备也可以出于各种原因记录音频,无论是为了稍后提醒,记录演讲或会议,还是更多。有很多应用程序可能不需要这种功能,但对于某些细分市场来说,了解如何录音是很重要的。
我们要构建什么?
我们将构建一个相当简单的应用程序,其目的只有一个:存储和回放最终用户的录音,无论它们是什么。它们可能是一段简短的备忘录或一次会议。我们将大量使用我们现有的框架,从视觉上看可能不多,但在下面有很多东西在支持音频播放和录音。
它能做什么?
在这个项目中,您将能够播放和录音音频。我们将为 iOS 录制WAV格式,为 Android 录制AMR格式。其他平台支持其他格式,所以如果您针对的不是 Android 或 iOS 以外的平台,请务必核实支持哪些格式。
您还将能够播放音频;我们将支持 Android 上的MP3和WAV,以及 iOS 上的WAV(我们在这里排除 iOS 上的 MP3 的主要原因是一个导致MP3格式音频以糟糕的质量和极其大的音量渲染的 bug)。
这为什么很棒?
这个项目之所以如此出色还有另一个原因:我们引入了手势支持。没错:滑动删除和长按也将在这个应用程序中体现。
我们将如何实现?
我们将遵循与之前项目相同的任务列表:
-
设计用户界面和外观
-
设计数据模型
-
实现数据模型
-
实现手势支持
-
实现主视图
我需要准备些什么才能开始?
像往常一样,按照我们在以前项目中使用的相同步骤创建您的项目。您可能还想参考 PhoneGap 媒体 API 文档,因为我们将会大量使用它。(参考docs.phonegap.com/en/edge/cordova_media_media.md.html#Media)
设计用户界面和外观
这个应用将比我们之前的任何应用都要简单。我们只需要一个视图,而这个视图的外观已经由我们过去两个项目的 Android 界面在很大程度上定义了。没错;视图本质上是一个项目列表,没有太多花哨的地方。
继续前进
我们将在项目列表中做一些改变。本质上,我们将通过隐藏操作图标(删除、分享等)并在收到手势时显示它们来清理列表。我们还将列表项中包含播放和暂停按钮,而不是任何特定的文档图像。毕竟,我们没有用户自己创建的录音的专辑封面。
让我们看看原型:

如您所见,这个原型与我们在以前项目中使用的 Android 文件列表非常相似。它与 iOS 上的基于文档的列表有很大不同,但前面的视图足够常见,用户会知道如何使用它。
列表中的图标将不是文档图标。相反,我们将使用播放和暂停图标来显示文档的状态。如果它目前正在播放,我们将显示暂停图标,如果没有播放,我们将显示播放图标。
右侧的删除按钮是通过使用水平滑动手势来显示的;这些按钮在其他情况下是不可见的。
你可能会问,我们的文档操作,比如重命名或复制,在哪里?这是一个很好的问题。它们仍然可用,但只有在用户将手指放在项目上超过一秒时才可用。到那时,长按滑动将被识别,并弹出一个小菜单,询问用户他们想做什么。
录音按钮旨在开始录音会话。它将要求输入文件名,一旦输入,它将显示另一个弹出窗口,表明正在录音。用户可以通过按下弹出窗口上的停止按钮来停止录音。我们还会在这个弹出窗口上显示一个麦克风图标,以向用户表明应用正在录音。
现在我们已经完成了原型,让我们在我们的图形编辑器中处理我们的图形设计。结果将如下所示:

以下是我们作为原型的一部分创建的一些图标。您可以在本项目的代码文件中找到它们的图像。
麦克风如下所示:

播放按钮如下所示:

暂停按钮将如下所示:

注意,背景画布和导航栏与我们的前一个项目相同。我们真正需要为此任务提供的只是播放状态、暂停状态和麦克风的图标。其他一切都可以通过 CSS 和 HTML 处理。
我们做了什么?
在本节中,我们设计了用户界面并定义了应用程序的感觉。我们还设计了我们将需要的图标。
设计数据模型
数据模型并不特别复杂,但确实与我们的前一个项目略有不同。文档集合在本质上相同,所以我们不会介绍该模型,但文档本身是不同的。它必须加载和管理音频资源而不是常规文件。
开始行动
我们的模式定义如下:
| VoiceRecDocument | |
|---|---|
| - fileEntry | 对象 |
| - filename | 字符串 |
| - fileType | 字符串 |
| - completion | 函数 |
| - failure | 函数 |
| - state | 字符串 |
| - title | 字符串 |
| - media | 对象 |
| - position | 数字 |
| - duration | 数字 |
| - playing | 布尔值 |
| - recording | 布尔值 |
| - paused | 布尔值 |
| - positionTimer | 定时器 ID |
| - durationTimer | 定时器 ID |
| - getFileName() | |
| - setFileName() | |
| - initializeMediaObject() | |
| - isPlaying() | |
| - isRecording() | |
| - updatePosition() | |
| - updateDuration() | |
| - getPlaybackPosition() | |
| - setPlaybackPosition() | |
| - getDuration() | |
| - startPlayback() | |
| - pausePlayback() | |
| - releaseResources() | |
| - stopPlayback() | |
| - startRecording() | |
| - stopRecording() | |
| - dispatchFailure() | |
| - dispatchSuccess() |
让我们来看看每个属性和方法应该做什么:
-
fileEntry属性存储从 File API 获取的文件条目。 -
fileName属性存储音频文件的完整路径。 -
fileType属性存储音频文件的扩展名(WAV、MP3 等)。 -
completion和failure方法指向completion和failure函数。 -
title属性存储文件名称(不包括路径和扩展名)。 -
media属性将存储来自MediaAPI 的Media对象。 -
position属性将指示当前播放位置(以秒为单位)。 -
duration属性将指示当前录音时长或文件播放时长(以秒为单位)。 -
playing、recording、paused是用于跟踪对象内部发生情况的内部状态方法。 -
positionTimer和durationTimer是用于更新位置和时长属性的定时器 ID。 -
get/setFileName方法获取/设置fileName属性。 -
isPlaying/isRecording方法返回相应的属性。 -
updatePosition/updateDuration是在播放和录音期间更新position和duration属性的内部方法。 -
get/setPlaybackPosition将获取或设置当前播放位置。如果设置,这将使用MediaAPI 的seekTo()方法。 -
start/pause/stopPlayback将开始、暂停或停止播放。 -
releaseResources将允许释放媒体文件消耗的内存,以便我们不会耗尽内存。 -
start/stopRecording将开始或停止录制。 -
dispatchFailure/Success将调用failure或completion方法。
我们做了什么?
在这个任务中,我们定义了 VoiceRecDocument 的模型以及内部进行的各种交互。
实现数据模型
现在我们已经设计了模型,让我们继续实现它。
准备就绪
就像我们之前的工程项目一样,我们将有两个数据模型:第一个用于处理可播放文件的集合,第二个用于处理特定可播放文件的处理。第一个,VoiceRecDocumentCollection.js 与我们之前的工程项目非常相似,所以我们不会在这个任务中详细说明。但 VoiceRecDocument.js 文件非常不同,所以请打开它(位于 www/models 目录),这样你可以跟随操作。
继续前进
让我们开始使用以下代码片段:
DOC.VoiceRecDocument = function(theFileEntry, completion, failure)
{
var self = this;
self.fileEntry = theFileEntry;
self.fileName = self.fileEntry.fullPath;
self.fileType = PKUTIL.FILE.getFileExtensionPart(self.fileName);
self.completion = completion;
self.failure = failure;
self.state = "";
如同我们之前的工程项目,传入的参数包括从 File API 获取的文件条目。我们将使用它来确定要播放的音频文件的名称及其类型。我们使用一些新函数,这些函数是在本版本的框架中引入的,用于处理文件的不同部分,即路径、文件名和文件扩展名。之前,我们使用 PKUTIL.FILE.getFileExtensionPart() 来获取文件的类型,无论是 MP3、WAV 还是其他类型。
self.title = PKUTIL.FILE.getFileNamePart(self.fileName);
self.media = null;
self.position = 0;
self.duration = 0;
self.playing = false;
self.recording = false;
self.paused = false;
self.positionTimer = -1;
self.durationTimer = -1;
在这里,我们定义了几个属性,我们将使用它们来跟踪我们需要用于正确管理音频的各种状态和定时器:
-
title: 这个属性给出了文件的标题,基本上是文件名减去路径和扩展名。 -
media: 这个属性给出了 PhoneGap 提供的Media对象。每当程序需要播放声音或录制某些内容时,此属性将被设置。 -
position: 这个属性给出了播放时的近似位置。它之所以是近似的,是因为它每隔几毫秒就会更新位置。我们稍后会讨论原因。 -
duration: 这个属性给出了声音文件的持续时间(如果正在播放),或者录制(在录制期间或之后)的近似持续时间。 -
playing,recording,paused: 这些只是简单的布尔变量,旨在使我们能够轻松地确定我们在做什么。我们在播放文件,录制文件,如果我们正在播放,我们是否暂停? -
durationTimer,positionTimer: 定时器 ID 用于跟踪每次我们加载媒体文件或为其录制做准备时创建的间隔。durationTimer属性更新duration属性,而positionTimer属性更新位置。self.getFileName = function() { return self.fileName; } self.setFileName = function(theFileName) { self.theFileName = theFileName; self.fileType = PKUTIL.FILE.getFileExtensionPart(self.fileName); self.title = PKUTIL.FILE.getFileNamePart(self.fileName); }
前面的代码片段处理获取和设置文件名。如果我们设置了一个文件名,我们必须更新文件名、类型和标题。
self.initializeMediaObject = function()
{
这个方法是一个非常重要的方法;我们将在大多数与播放或录制一起工作的方法顶部调用它。这是为了确保media属性被正确初始化。但这也是为了确保一些其他细节被正确设置,如下所示:
if (self.media == null)
{
if (PKDEVICE.platform()=="android")
{
self.fileName = self.fileName.replace ("file://","");
}
首先,我们只在这些步骤中执行这些步骤,如果我们已经有了media对象。如果我们已经有了,就没有必要再次初始化它。
其次,我们检查我们是否在 Android 上运行。如果是,从File API 出来的file://前缀会混淆Media API,所以我们将其移除。
self.media = new Media(self.fileName, self.dispatchSuccess, self.dispatchFailure);
接下来,我们使用一个新的Media对象初始化media属性。这个对象需要音频文件的文件名,以及两个函数:一个用于各种音频功能成功完成时(通常只在播放或录制停止时),另一个用于出错时。
self.positionTimer = setInterval(self.updatePosition, 250);
self.durationTimer = setInterval(self.updateDuration, 250);
}
}
最后,我们也设置了两个计时器,每四分之一秒更新一次。这些时间可以根据你喜欢的更新粒度加快或减慢,但250毫秒似乎已经足够了。
self.isPlaying = function()
{
return self.playing;
}
self.isRecording = function()
{
return self.recording;
}
当然,像任何好的模型一样,我们需要提供方法来指示我们的状态。因此,在先前的代码片段中使用了isPlaying和isRecording。
self.updatePosition = function()
{
if (self.playing)
{
self.media.getCurrentPosition(function(position)
{
self.position = position;
}, self.dispatchFailure);
} else
{
if (self.recording)
{
self.position += 0.25;
} else
{
self.position = 0;
}
}
}
如果你还记得,这个函数在播放和录制过程中会连续调用。如果在播放,我们会询问Media API 当前的位置,但我们必须提供一个回调方法才能实际找出位置。这通常应该几乎立即完成,但我们不能保证,这就是为什么我们稍微封装了获取位置的原因。我们将在后面定义一个getPosition()方法,它只是查看position属性,而不是每次我们想知道在音频文件中的位置时都要进行回调。
self.updateDuration = function()
{
if (self.media.getDuration() > -1)
{
self.duration = self.media.getDuration();
clearInterval(self.durationTimer);
self.durationTimer = -1;
} else
{
self.duration--;
if (self.duration < -20)
{
self.duration = -1;
clearInterval(self.durationTimer);
self.durationTimer = -1;
}
}
}
获取持续时间比获取当前位置更困难,主要是因为Media API 很可能正在从互联网上流式传输文件,而不是播放本地文件。因此,获取持续时间可能需要一些时间。
只要持续时间计时器在运行,我们就会询问Media API 是否已经有了文件的持续时间。如果没有,它会返回-1。如果它确实返回一个大于-1的值,我们可以停止计时器,因为一旦我们得到持续时间,它不太可能改变。
没有必要永远不断地询问持续时间,特别是如果我们无法确定持续时间,所以我们使用duration属性的-1到-20的负数作为超时。每次我们未能获取有效的持续时间,我们就减去一,如果我们低于-20,我们就停止计时器放弃。
self.getPlaybackPosition = function()
{
return self.position;
}
self.setPlaybackPosition = function(newPosition)
{
self.position = newPosition;
self.initializeMediaObject();
self.media.seekTo(newPosition * 1000);
}
获取播放位置现在很简单,我们只需返回自己的position属性。但有时我们需要更改当前的播放位置。为此,我们使用Media API 的seekTo()方法来调整位置。由于任何原因,seekTo()方法中使用的位置是以毫秒为单位的,而我们的计时器不断获取的位置是以秒为单位的,因此需要乘以1000。
self.getDuration = function()
{
return self.duration;
}
self.startPlayback = function()
{
self.initializeMediaObject();
self.media.play();
self.paused = false;
self.recording = false;
self.playing = true;
}
self.pausePlayback = function()
{
self.initializeMediaObject();
self.media.pause();
self.playing = false;
self.paused = true;
self.recording = false;
}
开始播放实际上非常简单:一旦我们初始化对象,我们只需在它上面调用play()方法。播放将尽快开始。我们还设置我们的状态属性以指示我们正在播放。
一旦开始播放,我们也可以轻松暂停:我们只需调用pause()方法。我们更新我们的状态以反映我们已经暂停。
self.releaseResources = function()
{
if (self.recording)
{
self.stopRecording();
}
if (self.positionTimer > -1)
{
clearInterval(self.positionTimer);
}
if (self.durationTimer > -1)
{
clearInterval(self.durationTimer);
}
self.durationTimer = -1;
self.positionTimer = -1;
self.media.release();
self.media = null;
}
由于媒体文件可能会消耗大量内存,因此当它们不再使用时,应该从内存中释放。当我们释放文件时,如果计时器正在运行,我们还需要停止计时器。
self.stopPlayback = function()
{
self.initializeMediaObject();
self.media.stop();
self.isPlaying = false;
self.isPaused = false;
self.isRecording = false;
}
停止播放相当简单:只需调用stop()方法而不是pause()方法。两者之间的区别在于,暂停播放允许后续调用play()方法立即从暂停的位置继续。调用stop()方法将重置我们的位置为零,因此下一个play()方法将从开头开始。
self.startRecording = function()
{
self.initializeMediaObject();
self.media.startRecord();
self.isPlaying = false;
self.isPaused = false;
self.isRecording = true;
}
self.stopRecording = function()
{
self.initializeMediaObject();
self.media.stopRecord();
self.isPlaying = false;
self.isPaused = false;
self.isRecording = false;
}
录音同样简单:我们只需调用startRecord()或stopRecord()。在录音过程中没有提供暂停的功能。
self.dispatchFailure = function(e)
{
console.log("While " + self.State + ", encountered error: " + e.target.error.code);
if (self.failure)
{
self.failure(e);
}
}
我们的failure方法相当简单。如果发生错误,我们将记录它,然后调用在创建此对象时给出的failure方法。
self.dispatchSuccess = function()
{
if (self.completion)
{
self.completion();
}
}
}
success函数甚至更简单:我们只需调用在创建对象时传入的completion()方法。
我们做了什么?
在这个任务中,我们创建了特定音频文件的数据模型以及启动、暂停和停止播放的方法,以及启动和停止录音的方法。
我还需要了解什么?
completion方法通常在播放结束时调用,尽管也可以出于其他原因调用。通常,人们会使用这个来清理媒体对象,但如果在预期之外调用,结果将是播放的突然停止。
另一个重要的问题是,每个平台只支持某些媒体文件进行播放,甚至对于录音支持不同的文件。以下是一个简短的列表:
| 平台 | 扮演 | 记录 |
|---|---|---|
| iOS | WAV | WAV |
| Android | MP3,WAV, 3GR | 3GR |
实现手势支持
手势现在是大多数移动平台的一个关键组件,用户期望他们使用的应用程序支持它们。一个手势可以相当复杂(比如,绘制一个形状,或使用多个手指),或者很简单(只需按住一个项目一段时间),但重要的是你要习惯这个想法。
准备中
当使用原生代码在设备上工作时,手势识别通常几乎免费提供给我们。也就是说,操作系统提供的框架完成了识别手势的繁重工作。
不幸的是,使用 PhoneGap,我们失去了这部分免费服务,不得不自己实现手势。这就是 www/framework 目录中的 ui-gestures.js 发挥作用的地方。请打开它,这样我们可以了解它的一些功能。
继续前进
让我们看一下以下代码,从顶部开始:
var GESTURES = GESTURES || {};
GESTURES.consoleLogging = false;
GESTURES.SimpleGesture = function(element)
{
我们首先定义一个新的命名空间,称为 GESTURES。然后我们在其中创建一个 SimpleGesture 类。SimpleGesture 将成为我们支持的所有单指手势的基础,包括长按手势、水平滑动手势和垂直滑动手势。
var self = this;
self.theElement = { };
self._touchStartX = 0;
self._touchStartY = 0;
self._touchX = 0;
self._touchY = 0;
self._deltaX = 0;
self._deltaY = 0;
self._duration = 0;
self._timerId = -1;
self._distance = 0;
self._event = { };
self._cleared = false;
有很多属性用于检测手势。本质上,我们必须跟踪触摸开始的位置,然后跟踪触摸结束的位置(touchStartX、touchStartY、touchX、touchY)。我们还需要知道最终触摸与开始时的距离(deltaX、deltaY、distance)。为了防止在某人长时间按住屏幕以缓慢滚动列表时识别手势,我们还跟踪触摸的持续时间。如果持续时间过长,我们拒绝检测手势,并可能中断用户执行的其他操作。
我们还跟踪手势是否已被识别或取消。这是通过 _cleared 属性完成的。如果 _cleared 是 true,则手势已被识别或取消,并且不得再次识别(直到用户从屏幕上抬起手指)。
self.attachToElement = function(element)
{
self.theElement = element;
self.theElement.addEventListener("touchstart", self.touchStart, false);
self.theElement.addEventListener("touchmove", self.touchMove, false);
self.theElement.addEventListener("touchend", self.touchEnd, false);
self.theElement.addEventListener("mousedown", self.mouseDown, false);
self.theElement.addEventListener("mousemove", self.mouseMove, false);
self.theElement.addEventListener("mouseup", self.mouseUp, false);
}
然而,第一步是把我们所有的监听器都附加到特定的元素上。我们附加了六个,分别是 touchstart、touchmove、touchend、mousedown、mousemove 和 mouseup。前三个是为 WebKit 浏览器准备的;后三个是为 Windows Phone 浏览器准备的。(由于手势支持是 YASMF 框架的一部分,它需要支持不仅仅是 iOS 和 Android,因此这里也支持 WP7。)
self.recognizeGesture = function(o)
{
if (GESTURES.consoleLogging)
{
console.log("default recognizer...");
}
}
self.attachGestureRecognizer = function(fn)
{
self.recognizeGesture = fn;
}
SimpleGesture 类之所以如此灵活的部分原因在于它允许使用 attachGestureRecognizer() 方法覆盖先前代码片段中的 recognizeGesture() 方法。这也意味着作为默认实现的一部分,我们目前根本不识别任何手势。它只是一个为后续识别引擎预留的占位符。
self.updateGesture = function()
{
self._duration += 100;
self._distance = Math.sqrt((self._deltaX * self._deltaX) + (self._deltaY * self._deltaY));
if (GESTURES.consoleLogging)
{
console.log("gesture: start: (" + self._touchStartX + "," + self._touchStartY + ") current: (" + self._touchX + "," + self._touchY + ") delta: (" + self._deltaX + "," + self._deltaY + ") delay: " + self._duration + "ms, " + self._distance + "px");
}
if (!self._cleared)
{
self.recognizeGesture(self);
}
}
当一个疑似的手势正在进行时,我们每 100 毫秒调用一次 updateGesture() 方法。然后它将有助于计算初始触摸的距离,并调用 recognizeGesture() 方法,假设在此之前尚未识别出任何手势。
尝试找出我们如何获得距离;你应该能从你的几何课程中认出它。
self.clearEvent = function()
{
if (self._cleared)
{
if (self._event.cancelBubble)
{
self._event.cancelBubble();
}
if (self._event.stopPropagation)
{
self._event.stopPropagation();
}
if (self._event.preventDefault)
{
self._event.preventDefault();
} else
{
self._event.returnValue = false;
}
}
if (self._timerId > -1)
{
clearInterval(self._timerId);
self._timerId = -1;
}
self._cleared = true;
}
当我们识别一个手势或确定根本不存在手势时,实际上没有必要继续跟踪手指等,因此前述方法取消所有计时器。然而,它仅阻止了如果手势本身被物理识别(而不仅仅是取消)时本将发生的默认操作(如点击)。这是因为识别过程的一部分,我们可以调用此方法一次(取消)或两次(识别)。第二次调用时,我们将取消所有默认操作。这意味着只要手势没有被识别,将手势附加到元素上不会阻止点击事件触发。
self.eventStart = function()
{
if (GESTURES.consoleLogging)
{
console.log("eventstart");
}
self._duration = 0;
self._deltaX = 0;
self._deltaY = 0;
self._cleared = false;
self._touchStartX = self._touchX;
self._touchStartY = self._touchY;
self._timerId = setInterval(self.updateGesture, 100);
}
eventStart() 是一个相当通用的函数。它所做的只是清除我们的一些属性,然后将其他属性设置为第一个触摸点。它还启动了调用 updateGesture 方法的计时器。
self.touchStart = function(event)
{
if (GESTURES.consoleLogging)
{
console.log("touchstart");
}
if (event)
{
self._touchX = event.touches[0].screenX;
self._touchY = event.touches[0].screenY;
self._event = event;
} else
{
self._touchX = window.event.screenX;
self._touchY = window.event.screenY;
self._event = window.event;
}
self.eventStart();
}
self.mouseDown = function(event)
{
if (GESTURES.consoleLogging)
{
console.log("mousedown");
}
if (event)
{
self._touchX = event.screenX;
self._touchY = event.screenY;
self._event = event;
} else
{
self._touchX = window.event.screenX;
self._touchY = window.event.screenY;
self._event = window.event;
}
self.eventStart();
}
你可能会想知道为什么我们对 touchstart 和 mousedown 有如此相似的处理程序。这是因为框架的这一部分在技术上可以存在于框架之外。这意味着它可以在台式计算机上识别鼠标事件。然而,还有另一件事需要记住,WP7 认为触摸是鼠标事件,而不是触摸事件,这就是为什么我们必须跟踪差异。注意,我们调用 eventStart() 方法来完成每种方法共有的操作。
self.eventMove = function()
{
if (GESTURES.consoleLogging)
{
console.log("eventmove");
}
self._deltaX = self._touchX - self._touchStartX;
self._deltaY = self._touchY - self._touchStartY;
}
当触摸移动时,eventMove 最终将由 touchMove() 或 mouseMove() 调用。它们的代码与 touchStart/mouseStart() 非常相似,所以我们在这里不会介绍它们的代码。主要点是,随着触摸的移动,增量会不断更新,以便当调用 updateGesture() 时,可以准确地确定距离。
self.eventEnd = function()
{
if (GESTURES.consoleLogging)
{
console.log("eventend");
}
elf.clearEvent();
}
当手指从屏幕上抬起时,eventEnd() 将由 touchEnd() 或 mouseUp() 调用。我们调用 clearEvent() 来重置所有涉及的跟踪和计时器。
self.attachToElement(element);
}
最后,在对象创建过程的最后,我们将事件附加到传入的元素上。在那个时刻,任何应用于元素的手势都将被跟踪,但尚未被识别。这正是下一段代码所涵盖的内容:
GESTURES.LongPressGesture = function(element, whatToDo, delayToRecognition, delayToCancel)
{
检测长按可能是最容易的手势类型。本质上,长按是在一定时间内停留在某个特定位置上的触摸。由于人类并不完美,我们必须允许手指在这段时间内可以摆动的某些容差。因此,我们不能在检测到手指移动的瞬间取消手势。话虽如此,如果手指移动超出特定半径(这里我们将使用 25 px),我们应该取消手势,因为用户可能正在进行完全不同的手势(或者根本没有任何手势)。
var myGesture = new GESTURES.SimpleGesture(element);
首先,我们创建一个新的 SimpleGesture 对象。然后我们将使用一种简陋的继承方式来扩展它。(这并不是真正的面向对象继承,但对于我们的需求来说已经足够好了。)
myGesture._delayToRecognition = delayToRecognition || 1000;
myGesture._delayToCancel = delayToCancel || 3000;
myGesture._whatToDo = whatToDo;
我们然后将 whatToDo、delayToRecognition 和 delayToCancel 参数附加到新对象上。如果后两个参数未提供,我们将默认设置为 3000 毫秒,并且忽略手势,以及将 1000 毫秒用于长按的识别。
whatToDo 必须是一个函数;如果识别到手势,我们将调用它。
myGesture.attachGestureRecognizer(function(o)
{
if (GESTURES.consoleLogging)
{
console.log("longpress recognizer...");
}
if (o._distance < 25)
{
if (o._duration >= o._delayToRecognition && o._duration <= o._delayToCancel)
{
o.clearEvent();
o._whatToDo(o);
}
}
这里我们覆盖了 SimpleGesture 对象的 do-nothing 识别器,并附加了我们自己的识别器。如果触摸的第一个位置和当前位置之间的距离小于 25 px,我们将考虑查看这个手势。然后手势的持续时间必须长于 delayToRecognition(默认为 1000 毫秒)参数,但不能长于 delayToCancel(默认为 3000 毫秒)参数。如果我们处于两者之间,我们将清除事件并调用 whatToDo()。
else
{
o.clearEvent();
}
});
另一方面,如果距离超过 25 px,那么这个人不是在进行长按。他们正在做其他事情,因此我们完全取消手势。
return myGesture;
}
GESTURES.HorizontalSwipeGesture = function(element, whatToDo, radiusToRecognition, delayToCancel)
{
水平滑动稍微复杂一些,但并不太多。首先,我们需要有一个水平方向的定义。同样,当人们做出手势时,手指可能会轻微晃动和扭动。我们还需要一个最小长度;水平方向上移动几个像素不应该足以触发手势。在这里,我们将水平滑动定义为任何长度超过 50 px 的滑动,并且垂直方向上没有超过 25 px 的变化(总共 50 px)。
我们还取消了用于长按的 delayToRecognition,这是滑动长度发挥作用的地方。
var myGesture = new GESTURES.SimpleGesture(element);
就像我们的长按一样,我们将从 SimpleGesture 对象创建新的手势。
myGesture._radiusToRecognition = radiusToRecognition || 50;
myGesture._delayToCancel = delayToCancel || 3000;
myGesture._whatToDo = whatToDo;
然后我们附加各种参数。radiusToRecognition 参数实际上是滑动的长度。半径内的任何东西都不会被考虑,但半径外的任何东西都足够长,可以被考虑。
myGesture.attachGestureRecognizer(function(o)
{
if (GESTURES.consoleLogging)
{
console.log("horizontal recognizer...");
}
if (o._distance > o._radiusToRecognition)
{
if (o._duration <= o._delayToCancel)
{
if (Math.abs(o._deltaY) < 25)
{
o.clearEvent();
o._whatToDo(o);
}
}
}
});
return myGesture;
}
识别器本身相当简单;第一个点和当前点之间的距离必须大于 radiusToRecognition 参数,持续时间不能超过 delayToCancel(默认为 3000 毫秒)参数,并且手指从第一次触摸点起不能上下移动超过 25 px。如果所有这些条件都满足,我们就检测到了水平滑动,然后清除事件并调用 whatToDo()。
垂直滑动本质上与之前代码中的使用 deltaY 相同,只是它使用的是 deltaX。只有当手指在水平轴上没有超过 25 px 的变化时,垂直滑动才是有效的。由于它们非常相似,我们不会详细讲解代码。
我们做了什么?
在这个任务中,我们探讨了如何识别三种简单的手势,即长按手势、水平滑动手势和垂直滑动手势。在下一个任务中,我们将实现手势识别器,以便提供我们可以在文档上执行的各种操作,例如复制、重命名或删除它们。
实现主视图
我们的主视图在视觉上相当简单,但下面却绝对复杂。让我们看看最终结果将如何呈现。首先,这是录制时的样子:

接下来,这是录制后的样子:

如果我们在新录制的项上滑动,我们会在下一个屏幕截图中看到删除按钮:

如果我们长按录制的项,会出现以下屏幕:

准备就绪
请打开www/views中的documentsView.js文件,以便您可以跟随操作。
继续前进
让我们先看看视图的 HTML。第一部分(带有viewBackground类)与所有之前的项目类似,所以我们将跳过这部分,直接查看以下代码块中显示的屏幕上每个列表项的模板:
<div id="documentsView_documentTemplate" class="hidden">
<div class="documentContainer"
id="documentsView_item%INDEX%">
<div class="documentTapArea"
id="documentsView_item%INDEX%_area"
onclick="documentsView.documentContainerTapped(%INDEX%);">
</div>
<div class="documentImage">
<img id="documentsView_item%INDEX%_img"
src="img/playButton.png" border=0
onclick="documentsView.documentContainerTapped(%INDEX%);
"/>
</div>
<div class="documentTitle"
onclick="documentsView.documentContainerTapped(%INDEX%);">
<span>%TITLE%</span>
</div>
<div class="documentActions"
id="documentsView_actions%INDEX%">
<button class="destructive barButton"
id="documentsView_deleteButton%INDEX%"
onclick="documentsView.deleteDocument(%INDEX%); return
false;">
%DELETE%
</button>
</div>
</div>
</div>
这个模板与之前项目中使用的模板类似,但并不完全相同。我们所做的是将div元素的documentImage类更改为文档的状态。如果它显示一个播放图标,则可以点击该特定项以开始播放。如果显示暂停,则可以点击以暂停播放。
删除按钮包含在div元素的documentActions类中(默认情况下是隐藏的)。当点击时,它会调用带有被点击项索引的deleteDocument()。
所有其他元素,如title,调用documentContainerTapped()。这允许在可见时点击除删除按钮以外的任何内容以开始或暂停播放。如果我们没有在这些地方使用onclick处理程序,一些元素可能不会像我们预期的那样响应用户触摸。
现在我们来了解一下视图的工作原理:
var documentsView = $ge("documentsView") || { };
documentsView.firstTime = true;
documentsView.lastScrollLeft = 0;
documentsView.lastScrollTop = 0;
documentsView.myScroll = { };
documentsView.availableDocuments = { };
到目前为止,与之前的项目的实现几乎相同。
documentsView.currentDocument = null;
documentsView.documentCurrentlyPlaying = -1;
然而,这里我们开始有所不同。我们需要存储当前播放的项及其索引。如果我们不这样做,并且我们点击两个或多个项,最终所有这些项都会同时播放。相反,我们需要停止之前的项并开始点击的项,以确保一次只播放一个音频文件。
我们将跳过初始化视图,因为它与其他所有项目类似。相反,我们将查看以下代码片段中的documentContainterTapped()方法:
documentsView.documentContainerTapped = function(idx)
{
var theElement = $ge("documentsView_item" + idx + "_img");
if (documentsView.documentCurrentlyPlaying == idx)
{
if (documentsView.currentDocument.isPlaying())
{
documentsView.currentDocument.pausePlayback();
theElement.setAttribute("src", "./images/playButton.png");
} else
{
documentsView.currentDocument.startPlayback();
theElement.setAttribute("src", "./images/pauseButton.png");
}
}
首先,我们检查索引 (idx) 是否与当前播放的文件相同。如果是,我们需要暂停(或恢复)它。我们不希望释放任何资源或停止文件——否则当项目再次被点击时,播放将从头开始。我们还根据需要将特定项目的图像设置为播放或暂停图标。
else
{
if (documentsView.documentCurrentlyPlaying > -1)
{
var theOldElement = $ge("documentsView_item" + documentsView.documentCurrentlyPlaying + "_img");
documentsView.currentDocument.releaseResources();
documentsView.currentDocument = null;
documentsView.documentCurrentlyPlaying = -1;
theOldElement.setAttribute("src", "./images/playButton.png");
}
如果索引 (idx) 不同,我们首先检查是否有任何内容正在播放(或暂停)。如果有,我们释放这些资源,这样在我们开始播放不同项目时就不会继续占用它们。
documentsView.currentDocument = new DOC.VoiceRecDocument(documentsView.availableDocuments.getDocumentAtIndex(idx), documentsView.mediaSuccess, null);
documentsView.currentDocument.startPlayback();
documentsView.documentCurrentlyPlaying = idx;
theElement.setAttribute("src", "./images/pauseButton.png");
}
}
接下来,我们创建一个新的 VoiceRecDocument 函数并开始播放。
documentsView.mediaSuccess = function()
{
var theElement = $ge("documentsView_item" + documentsView.documentCurrentlyPlaying + "_img");
documentsView.currentDocument.releaseResources();
documentsView.currentDocument = null;
documentsView.documentCurrentlyPlaying = -1;
theElement.setAttribute("src", "./images/playButton.png");
}
在创建 VoiceRecDocument 函数时传递的 mediaSuccess() 方法,通常在音频文件被强制停止(未暂停)或自行停止时调用。由于我们没有提供自己的停止方法,我们可以安全地假设文件是自行停止播放的。在这种情况下,我们释放资源,这样当文件没有播放时就不会占用任何内存。
然后,我们将跳转到 documentIterator() 方法:
documentsView.documentIterator = function(o)
{
var theHTML = "";
var theNumberOfDocuments = 0;
for (var i = 0; i < o.getDocumentCount(); i++)
{
var theDocumentEntry = o.getDocumentAtIndex(i);
theHTML += PKUTIL.instanceOfTemplate($ge("documentsView_documentTemplate"),
{
"title" : PKUTIL.FILE.getFileNamePart ( theDocumentEntry.name ),
"index" : i,
"delete" : __T("DELETE")
});
theNumberOfDocuments++;
}
$ge("documentsView_contentArea").innerHTML = theHTML;
这一部分相当直观。我们将项目的标题分配给文件名(减去路径和扩展名),分配索引,并在遇到 delete 时填写该单词。
PKUTIL.delay(100, function()
{
接下来,我们延迟一段时间,以确保在继续处理新项目之前,DOM 已经有时间处理所有新项目。
for (var i = 0; i < theNumberOfDocuments; i++)
{
var theElement = $ge("documentsView_item" + i + "");
我们创建的每个元素都需要应用两个手势:一个长按手势和一个水平滑动手势。因此,我们首先使用前面的代码片段查找元素。
var theLPGesture = new GESTURES.LongPressGesture(theElement, function(o)
{
documentsView.longPressReceived(o.data);
});
theLPGesture.data = i;
接下来,我们创建长按手势并将其附加到元素上。当手势被识别时,我们将使用 o.data 调用 longpressReceived()。这些数据是我们下一行设置的数据;它将是长按的项目索引。
var theHSGesture = new GESTURES.HorizontalSwipeGesture(theElement, function(o)
{
documentsView.horizontalSwipeReceived(o.data);
});
theHSGesture.data = i;
}
});
}
分配水平滑动手势的过程几乎相同,只是我们调用 horizontalSwipeReceived。
documentsView.longPressReceived = function(idx)
{
var anAlert = new PKUI.MESSAGE.Confirm(__T("Select Action"), __T("Select an action to perform:"), "Copy|Rename|Cancel<", function(i)
{
PKUTIL.delay(100, function()
{
if (i == 0)
{
documentsView.copyDocument(idx);
}
if (i == 1)
{
documentsView.renameDocument(idx);
}
});
});
anAlert.show();
}
当接收到长按时,我们将调用前面的方法。我们将创建一个带有三个可能操作的确认弹出窗口:复制、重命名和取消。我们稍微修改了框架以支持弹出窗口上的按钮超过两个,所以不用担心它们会占用太多空间。
如果用户点击复制,我们将调用 copyDocument() 方法,如果他们点击重命名,我们将调用 renameDocument()。
documentsView.horizontalSwipeReceived = function(idx)
{
var theActionContainer = $ge("documentsView_actions" + idx);
if (theActionContainer.style.display == "block")
{
theActionContainer.style.opacity = "0";
PKUTIL.delay(400, function()
{
theActionContainer.style.display = "none";
});
} else
{
theActionContainer.style.display = "block";
PKUTIL.delay(50, function()
{
theActionContainer.style.opacity = "1";
});
}
}
然而,当接收到水平滑动时,我们会做不同的事情:我们希望显示或隐藏该项目的删除按钮。我们可以通过检查 style.display 属性来查看它是否可见。我们已经采取了设置 opacity 和 display 来显示或隐藏它的方法。这可能并不完全符合原生方法(例如,iOS 会滑动此按钮),但它效果很好。
documentsView.startRecording = function (theFileEntry)
{
录制可能是最复杂且最难做正确的事情。毕竟,每个平台都支持不同的录制类型,但它们也有自己的怪癖(例如,文件是否必须已经存在)。
documentsView.currentDocument = new DOC.VoiceRecDocument(theFileEntry, null, null);
首先,我们使用期望的文件名创建新文档;我们在createDocument()方法中询问用户。
var anAlert = new PKUI.MESSAGE.Confirm(
__T("Recording..."),
"<img src='./images/microphone.png' width=54 height=123>",
__T("STOP_"), function(i)
{
documentsView.currentDocument.stopRecording();
documentsView.currentDocument.releaseResources();
documentsView.currentDocument = null;
documentsView.documentCurrentlyPlaying = -1;
documentsView.reloadAvailableDocuments();
});
anAlert.show();
接下来,我们显示一个包含麦克风图像和停止按钮的简单警告框。(结尾的_表示让按钮填充整个警告框的宽度,这样更容易点击。)当用户点击停止按钮时,我们将停止录制并释放所有资源。我们还需要重新加载文档,以便用户可以点击它来播放,如果他们想的话。
documentsView.currentDocument.startRecording();
}
在这里,我们利用了弹出系统的一个重要事实——它们不会阻塞我们的脚本执行。这意味着,我们可以在显示警告框的同时继续做其他工作,在这种情况下,就是录制。
documentsView.createNewDocument = function()
{
这部分很简单;准备录制是难点。我们在createNewDocument()中做这件事,当按下REC按钮时会被调用。
var anAlert = new PKUI.MESSAGE.Prompt(__T("Create Document"), __T("This will create a new document with the name below:"), "text", "Memo " + __D(new Date(), "yyyy-MM-dd HH-mm-ss"), __T("Don't Create<|Create>"), function(i)
{
和往常一样,我们要求用户为我们文档提供一个新名称。这里我们使用Memo和日期。
if (i === 1)
{
var fileType = ".wav";
if (device.platform()=="android")
{
fileType = ".3gr";
}
documentsView.availableDocuments.createDocument("" + anAlert.inputElement.value + fileType, function()
首先,我们根据平台确定可以录制到哪种类型的文件,然后将它传递给createDocument()。然后,我们定义当createDocument()成功时应该发生什么:
{
if (documentsView.documentCurrentlyPlaying > -1)
{
documentsView.mediaSuccess();
}
如果我们正在播放现有的音频文件,我们会停止它。毕竟,我们不希望它干扰录制。
var theFileEntry = documentsView.availableDocuments.getFileEntry();
if (PKDEVICE.platform()=="ios")
{
console.log(4);
theFileEntry.createWriter(function(writer)
{
console.log(5);
writer.onwriteend = function(e)
{
documentsView.startRecording (theFileEntry);
};
writer.write("It doesn't matter what goes here.");
console.log(12);
}, function(err)
{
console.log(6);
var anAlert = new PKUI.MESSAGE.Alert(__T("Oops!"), __T("Couldn't create the file."));
anAlert.show();
});
}
然后,对于 iOS,我们创建一个包含一些垃圾文本的新文件。由于某种原因,Media API 要求在录制之前文件必须存在,否则将无法录制。其他平台没有这个限制。
else
{
if (PKDEVICE.platform()=="android")
{
theFileEntry.remove( function () { documentsView.startRecording(theFileEntry); } , null );
}
话虽如此,文件仍然会被创建,可能会让 Android 感到困惑。因此,在录制之前,我们完全删除该文件。
else
{
documentsView.startRecording(theFileEntry);
}
}
}, function(e)
{
var anAlert = new PKUI.MESSAGE.Alert(__T("Oops!"), __T("Couldn't create the file."));
anAlert.show();
});
}
});
anAlert.show();
}
documentsView.renameDocument = function(idx)
{
var theFile = documentsView.availableDocuments.getDocumentAtIndex(idx).name;
var theFileName = PKUTIL.FILE.getFileNamePart(theFile);
var theFileExt = PKUTIL.FILE.getFileExtensionPart(theFile);
此前,我们只需要担心列表中的一种文件类型。但在重命名文件或复制文件时,我们需要对正在处理的文件类型保持敏感,因为我们需要在新的名称上复制文件扩展名。我们通过在先前的代码片段中获取文件扩展名来实现这一点。
var anAlert = new PKUI.MESSAGE.Prompt(__T("Rename Document"), __T("Rename your document to the following:"), "text", theFileName, __T("Cancel<|Rename>"), function(i)
{
if (i == 1)
{
if (documentsView.documentCurrentlyPlaying > -1)
{
documentsView.currentDocument.releaseResources();
}
然后,如果我们正在重命名(或删除)文件(我们不为此复制),我们在实际执行操作之前停止正在重命名(或删除)的文件的播放。
var theNewFileName = "" + anAlert.inputElement.value + PKUTIL.FILE.extensionSeparator + theFileExt;
接下来,我们使用与旧文件相同的扩展名构建新的文件名。从现在开始,代码与先前的项目相同,所以我们将不会详细说明。这里重要的是,在重命名或复制文件时,必须保留任何文件的文件扩展名。
我们做了什么?
我们创建了一个可以列出可用音频文件、管理它们的播放以及录制新文件的视图。我们还为每个项目创建了一个长按手势和一个水平滑动手势来实现必要的文件管理操作。
游戏结束..... 总结
我们创建了一个简单的媒体录制和回放应用程序。它可以管理它创建的所有文件(甚至播放它没有创建的一些文件)。它还首次支持简单的手势,这对于简化用户界面的复杂性至关重要。
你能承受热度吗?热手挑战
当然,你可以以许多方式增强这个项目,如下所示:
-
在播放项目时,在文档视图上弹出简单的媒体播放器视图。为用户提供一种在音频文件中向前和向后跳转的方法。
-
在录制时创建一个单独的视图,以突出显示的方式显示录制时长。
-
如果你想真正发挥创意,将这个项目添加到
Filer项目中。允许在设备上编写文档时创建录音,然后在稍后查看该文档时播放录音,以便用户可以跟随。
第六章. 拍照吧!
曾经有一段时间,手机上还没有摄像头。然后又有一段时间,那些摄像头非常糟糕,它们实际上只适用于拍一些提醒照片,例如,拍下你想要稍后查找更多信息商店物品的照片。但现在,许多移动设备都配备了优秀的摄像头,可以拍摄出令人惊叹的照片。在这个项目中,我们将探讨如何在我们的应用程序中使用这些摄像头。
我们要构建什么?
在这个项目中,我们将构建一个名为Imgn的应用程序。它不会是一个完整的应用程序;它只会拍照并允许你查看它们。但你可以添加很多其他功能,比如分享功能或创意滤镜,这些功能可以在拍照后改变图像。
然而,这个应用程序将展示通过设备摄像头拍照的过程,以及访问用户照片库以导入图像的过程。就像我们之前的应用程序一样,我们将拥有完整的文件管理功能,因此可以随意删除、复制和重命名图像。不过,我们将以不同的方式实现这一点;我们将实现许多照片应用程序中看到的功能——图像网格。
它能做什么?
最终,这个应用程序将允许你拍照或从你的照片库中导入一张照片,然后查看它。虽然听起来很简单,但这个应用程序具有完整的图像管理功能,这意味着我们将介绍如何删除、复制和重命名图像。在大多数照片应用程序中,通常有批量删除图像的选项(比如说你拍了几张模糊的照片,不再需要它们),而使用File API 批量删除图像可能有点棘手。
大多数照片应用程序也使用所谓的图像网格,这是一个小图像缩略图的网格,允许用户在屏幕上同时查看多张图像并滚动浏览它们。这个网格并不难实现;它实际上只是一系列使用古老的 HTML 进行包装的缩略图。这个想法失败的地方在于,PhoneGap 中没有创建缩略图的功能;这意味着作为缩略图显示的图像实际上是全尺寸图像,它们只是被缩小到一个小缩略图。这种缩放引入了许多性能问题,作为解决方案,我们将使用 HTML5 的Canvas标签来恢复一些性能。
为什么它很棒?
在这个项目中,我们将涵盖许多技术,如果你想要制作一个性能良好的照片应用程序,这些技术都是至关重要的。我们将探讨如何拍照以及如何从用户的照片库中导入照片——这两者在大量应用程序中都是至关重要的。社交应用程序经常使用这种功能,但还有其他应用程序也这样做。
和往常一样,我们将涵盖文件管理,这从来都不是那么容易。在这个项目中,我们将介绍File API 包装器的概念,这应该会使处理文件变得稍微容易一些。然而,最终我们仍然在处理文件时需要变得有些巧妙,所以请准备好让你的大脑变得错综复杂。
我们将使用在移动网络世界中越来越受欢迎的东西:HTML5 Canvas。它已经在桌面网站上得到了很好的应用,但现在我们的移动设备才开始足够快,能够以有趣的方式使用 Canvas。
我们将如何实现?
就像我们之前的所有任务一样,我们将采用我们经过验证的方法来处理这个问题:
-
设计用户界面和外观
-
设计数据模型
-
实现文档视图
-
实现图像视图
我需要准备些什么才能开始?
为了开始,你需要创建项目,就像你之前在每一个项目中做的那样。你还应该查看项目文件中的资源目录。我们有一些图标文件,你可能觉得很有趣。
设计用户界面和外观
从用户界面角度来看,这个应用程序在概念上非常简单。如果你在手机上看过照片应用程序,你很可能已经知道我们将要走向何方。即便如此,让我们设计一个原型,然后对其进行一些扩展,以确定我们需要的外观和感觉资产。
开始行动
让我们检查这个项目的原型:

最左侧的屏幕只是一个网格(1),显示了用户添加到应用程序中的所有图片。这些图片可能是用相机拍摄的,也可能是以各种方式导入的。
在导航栏中,我们将有一个花哨的标题——不知何故,照片应用程序似乎需要比我们最近的一些应用程序更少的功能性。我们还将包括一个可以改变状态为完成按钮(2)的编辑按钮。此按钮表示当前状态;如果用户点击编辑,图片将变为可选择的,以便进行批量操作,如删除操作。在此模式下,按钮切换到完成。如果用户再次点击按钮,他们将退出选择模式,按钮将再次指示编辑。用户所做的任何选择都将被丢弃。
自从我们有了工具栏(3)以来已经有一段时间了。我们将根据当前的编辑状态显示各种图标。如果我们不在选择模式(导航栏按钮显示编辑),我们将显示一个相机和一个胶卷。这两个图标将允许与相机和相册进行交互。如果我们处于选择模式(导航栏按钮显示完成),并且至少选择了一张图片,我们将显示一个垃圾桶图标和一个人物图标,分别表示删除和分享。
如果我们不在选择模式中,用户可以点击一个图像来查看它放大。此时,我们将移动到前一个截图中最右侧的屏幕。导航栏将有一个返回按钮(4)。图像将在内容区域(5)显示。如果图像比屏幕大,它将可以滚动。在工具栏(6)中,我们将提供删除图像或分享图像的方式。
现在我们已经讨论了原型,让我们更详细地展开。这是在我们最喜欢的图形编辑器中做一些工作后的最终结果:

虽然我们所有应用的基本元素都存在,但很明显,它们已经被大大改进了。导航栏被涂上了新的油漆,并为应用的标题使用了俏皮的字体。工具栏也被升级到了更深的颜色。
每个图像都会被赋予一个简单的白色边框和阴影,以帮助它从背景中脱颖而出。这些效果可以通过 CSS 轻松实现,因此我们不需要为这个特定部分使用图像。
然而,对于应用的其他部分,情况就不同了。我们将有四个图标,分别是相机、胶卷、垃圾桶和人物。这些图标将始终位于工具栏中,并且可以点击执行操作。导航栏和工具栏本身也需要是图形资源。仅使用 CSS 渲染这些图标将非常困难(特别是由于它们都有一些细微的噪声来增加它们的纹理)。最后,标题本身也需要是图像资源,因为我们使用的字体可能不在设备上可用。在这个工作中,除了照片本身之外,我们唯一可以不使用图像完成的事情就是导航栏上的编辑按钮。我们将使用我们一直在使用的相同 CSS 来完成按钮的显示。
这个应用的第二屏,即图像查看器,在这里没有制作原型——它将只是一个中间的大图像,其余的设计保持不变。工具栏中的两个图标将是垃圾桶和人物,但这就是所有不同的地方。
既然我们已经确定了需要成为图形资源的部分,那么就是时候从我们的原型中提取它们了。您可以在我们项目的www/images目录中看到最终结果。
小贴士
我们将标题制作成独立于导航栏的图像。这是因为导航栏可以自由拼接,我们不想让标题与它一起拼接!
我们做了什么?
在这个任务中,我们制作了用户界面并详细说明了所有部分是如何协同工作的。我们还完善了原型,以便我们能够生成应用实现所需的图像资源。
设计数据模型
我们的数据库模型将首次非常简单。这里实际上没有太多需要跟踪的内容。从某种意义上说,我们的数据模型与持久存储中的内容完全相同——即图像本身。
继续前进
就像在之前的任务中一样,我们确实有一个文档集合模型,它读取持久存储中的所有图像,并允许我们的文档视图与之交互。这个特定模型几乎没有任何变化(除了名称之外),所以在这里我们不会对其进行介绍。
我们将要介绍的内容并不完全是一个数据模型,但仍然很重要。当用户点击编辑按钮时,我们希望他们能够选择多张图片进行批量操作(例如删除)。为了做到这一点,我们需要跟踪哪些图像被选中,哪些没有被选中。
该模型本身非常简单,实际上并没有自己的代码文件。它只是一个数组,结合一个表示我们是否处于选择模式的单个属性。它看起来是这样的:
-
inSelectionMode -
selectedItems[]
就这些。非常简单,是的,但理解它是如何工作的对于提供选择机制非常重要。
当应用开始时,我们不会处于选择模式,所以inSelectionMode将是false。如果用户点击编辑,我们将将其改为true,并将所有图像周围的边框颜色改为浅黄色。颜色本身并不重要;这样做只是为了表明设备对点击做出了响应,并且所有图像目前都是未选择的(我们将使用红色来表示已选择的图像)。这也意味着selectedItems数组也将是空的。
我们有几种方法可以跟踪哪些图像被选中。我们可以设置一个数组,其项目数量与屏幕上的图像数量相同。这可以工作,但可能性很大,大多数这些图像在整个选择操作中都将保持未选择状态。除非用户有意删除所有图像(虽然这是可能的,但这种情况并不常见),否则真的没有必要浪费那么多空间。
相反,我们将使用一个稀疏数组来跟踪这些选择;数组中的每个项目将指向所选的图像。这意味着,如果我们选择了三幅图像,数组只需要三个项目长。任何不在数组中的图像都可以被认为是未选择的,而那些在数组中的被认为是已选择的。
虽然这确实在如何管理这个数组方面带来了一些困难。幸运的是,理解这一点并不太难。
让我们假设用户在选择模式下点击了图像 3。首先,我们将边框颜色改为引人注目的颜色(在我们的例子中是红色)以表明我们“听到了”用户。然后,我们将使用数组的push方法将图像添加到选择中。此时,我们的数组中恰好有一个值为3的项目。用户继续选择一些其他项目,最终我们可能得到一个包含3、1、9的数组。这意味着图像 1、3 和 9 被选中。(请注意,顺序并不重要。)
现在,让我们假设用户再次点击图像 1。它已经选中了,所以我们应该取消选中它。为了做到这一点,我们需要从数组中删除第二个元素,这样我们就剩下了一个包含3、9的数组。虽然这些方法的名称可能不是立即显而易见的,但 JavaScript 确实使这个过程变得非常简单。
首先,我们将使用indexOf来找到1在数组中的位置。一旦我们找到了位置,我们将使用splice()来告诉 JavaScript 删除那个特定的项。splice()方法可以用于非常酷的数组操作,但它也特别擅长进行项删除。
在数组上有这三种方法后,我们可以跟踪任何图像的选择状态。如果我们无法在数组中找到图像,我们知道它没有被选中。如果我们确实在数组中找到了图像,我们知道它已被选中。而这实际上就是我们需要知道的所有内容。
当用户完成他们的选择后,他们可以选择对它们进行一些操作。这里可能会有一些困难。假设他们想一次性删除多张图片。File API 有点痛苦,正如我们从之前的任务中看到的,现在我们必须想出一种方法来连续多次调用它。在其他具有同步文件操作的语言中,我们会使用简单的for循环,但我们在 PhoneGap 提供的File API 中没有这样的便利,因为它是一个异步 API。
用户还可以做的另一件事是结束他们的选择,他们可以通过点击完成来做到这一点。当这种情况发生时,我们将将所有图像边框变回白色,以表明我们已经听到了用户的要求,并且也表明任何已选图像现在已取消选择。
我们做了什么?
在这个任务中,我们检查了一个简单的数据模型,该模型跟踪数组中的选择。我们讨论了我们将如何使用push()、indexOf()和splice()来维护这个数组,以及所有这些将如何对用户显示。
由于这个模型非常简单,我们没有费心给它一个单独的文件;我们将将其作为我们将在下一个实现中实现的文档视图的一部分。
实现文档视图
虽然我们的文档视图中有一些部分与之前的项目相同或类似,但也有相当多的不同。视图必须处理拍照、导入图片,并在批量操作之前处理用户一次性选择多张图片。这意味着即使底层模型相当简单,也有很多事情要做。
这就是视图的外观,首先是 iOS 版本:

对于 Android,视图如下所示:

准备就绪
如果你想跟进,我们的视图位于此项目的文件www/views/documentsView.html中。
开始行动
和往常一样,让我们从视图的 HTML 部分开始:
<div class="viewBackground">
<div class="navigationBar">
<div style="padding-top:7px" id="documentsView_title"></div>
<button class="barButton" id="documentsView_editButton" style="right:10px" ></button>
</div>
<div class="content avoidNavigationBar avoidToolBar" style="padding:0; overflow: scroll;" id="documentsView_scroller">
<div id="documentsView_contentArea" style="padding: 0; height: auto; position: relative;"></div>
</div>
<div class="toolBar">
<span class="icon" id="documentsView_cameraButton"><img src="img/photo_64.png" width=32 height=32 /></span>
<span class="icon" id="documentsView_importButton"><img src="img/film_64.png" width=32 height=32 /></span>
<span class="icon" id="documentsView_deleteButton"><img src="img/trash_64.png" width=32 height=32 /></span>
<span class="icon" id="documentsView_shareButton"><img src="img/man_64.png" width=32 height=32 /></span>
</div>
</div>
这段代码的大部分与之前项目中的文档视图相似。div 元素 documentsView_title 有一个内联样式,将标题图片向下移动一点;否则,它将与导航栏的顶部对齐。其余的更改都在 div 类名为 toolBar 的部分,其中定义了四个图标,分别是照片(photo_64.png)图标、电影(film_64.png)图标、垃圾箱(trash_64.png)图标和人物(man_64.png)图标。由于这些图标不会根据本地化而改变,因此在这里放置它们而不是在 initializeView() 中定义内容是安全的。
接下来,让我们看看我们将为每张图片使用的模板:
<div id="documentsView_documentTemplate" class="hidden">
<div class="documentContainer" id="documentsView_item%INDEX%">
<div class="documentImage">
<canvas width=84 height=84
id="documentsView_item%INDEX%_canvas"
onclick="documentsView.documentContainerTapped(%INDEX%);"></canvas>
</div>
</div>
</div>
这可能是我们很长时间以来最简单的模板了。其中只包含一个具有唯一 id 值的 canvas 标签和一个 click 处理器。不要被使用 canvas 标签而不是 img 标签的简单性所欺骗,这意味着我们稍后必须编写代码将图像绘制到 canvas 标签上。然而,使用 canvas 带来的好处是值得这段额外代码的。
提示
我们已经给 canvas 标签指定了特定的 width 和 height;这给它一个定义的形状,直到我们可以稍后用实际图像的宽度和高度覆盖它。这只是为了使从未加载的图像到已加载的图像的过渡更加平滑。
HTML 部分处理完毕后,让我们看看代码:
var documentsView = $ge("documentsView") || {};
documentsView.firstTime = true;
documentsView.lastScrollLeft = 0;
documentsView.lastScrollTop = 0;
documentsView.myScroll = {};
documentsView.availableDocuments = {};
documentsView.inSelectionMode = false;
documentsView.selectedItems = [];
documentsView.globalAlert = null;
大部分这些仅使用的属性都是我们从之前的项目中熟悉的。然而,有三个属性对于这个特定项目非常重要,如下所述:
-
inSelectionMode:这表示用户是否已将我们置于选择模式。在选择模式下,用户可以选择多张图像进行批量操作(如删除操作),而在选择模式之外,点击图片将导致查看其大图。 -
selectedItems:这是一个数组,如前一个任务中讨论的,它包含选定的图像。这是一个 稀疏 数组,因为它只包含实际选定的图像,而不包含未选定的图像。 -
globalAlert:这是一个占位符,用于显示警告。当进行批量删除时,我们将使用它。如果用户一次性删除多张图片,可能需要几秒钟,因此我们希望能够在下面的操作上显示一个警告。
在此之后,我们有视图的初始化代码,如下所示:
documentsView.initializeView = function()
{
PKUTIL.include(["./models/ImageDocumentCollection.js", "./models/VoiceRecDocument.js"], function()
{
documentsView.displayAvailableDocuments();
});
documentsView.viewTitle = $ge("documentsView_title");
documentsView.viewTitle.innerHTML = __T("APP_TITLE_IMG");
documentsView.editButton = $ge("documentsView_editButton");
documentsView.editButton.innerHTML = __T("EDIT");
PKUI.CORE.addTouchListener(documentsView.editButton, "touchend",
function(e)
{
documentsView.toggleSelection();
}
);
到目前为止,与我们的前几个项目没有太大区别。在前面的代码中,我们向 编辑 按钮添加了文本和代码——如果被点击,我们将调用 toggleSelection(),这将切换选择模式。
接下来,我们将使用以下代码片段定义工具栏上每个图标的处理程序:
documentsView.cameraButton = $ge("documentsView_cameraButton");
PKUI.CORE.addTouchListener(documentsView.cameraButton, "touchend",
function(e)
{
documentsView.takePicture();
}
);
documentsView.importButton = $ge("documentsView_importButton");
PKUI.CORE.addTouchListener(documentsView.importButton, "touchend",
function(e)
{
documentsView.importPicture();
}
);
documentsView.deleteButton = $ge("documentsView_deleteButton");
documentsView.deleteButton.style.display="none";
PKUI.CORE.addTouchListener(documentsView.deleteButton, "touchend",
function(e)
{
documentsView.confirmDeletePictures();
}
);
documentsView.shareButton = $ge("documentsView_shareButton");
documentsView.shareButton.style.display="none";
提示
我们目前还没有将触摸监听器附加到 分享 按钮上。如果您想实现分享功能,请参考 项目 2,让我们社交吧!
需要注意的重要事项是,最后两个图标被设置为display为none,这意味着它们不会显示在屏幕上。这是因为它们仅适用于选中图片,如果它们无法执行任何操作,就没有必要显示它们。当我们更改选择模式并且至少选中了一张图片时,我们将重新显示它们。
在视图初始化后,让我们看看toggleSelection()。这是当用户点击编辑按钮时的处理程序:
documentsView.toggleSelection = function ()
{
var i;
var anElement;
documentsView.inSelectionMode = !documentsView.inSelectionMode;
根据方法名称,我们首先需要做的事情是切换选择模式。我们将利用布尔值的工作原理来简单地切换值:如果它进来时是false,我们将将其切换为true,反之亦然。
if (documentsView.inSelectionMode)
{
documentsView.editButton.innerHTML=__T("DONE");
documentsView.selectedItems=[];
for (i=0; i<documentsView.availableDocuments.getDocumentCount(); i++)
{
anElement = $ge("documentsView_item"+i+"_canvas");
anElement.style.border = "3px solid #FF8";
}
documentsView.cameraButton.style.display="none";
documentsView.importButton.style.display="none";
documentsView.deleteButton.style.display="none";
documentsView.shareButton.style.display="none";
}
如前述代码所示,如果我们现在处于选择模式,我们将更改编辑按钮以显示完成,以便用户知道如何结束选择模式。接下来,我们将清空selectedItems数组,以便删除任何之前的选中项。由于我们使用图片边框来指示选择状态,我们需要遍历每个图片并将它的边框设置为未选择状态(浅黄色)。最后,我们将隐藏工具栏中的所有图标,因为它们都不立即适用。
else
{
documentsView.editButton.innerHTML=__T("EDIT");
for (i=0; i<documentsView.availableDocuments.getDocumentCount(); i++)
{
anElement = $ge("documentsView_item"+i+"_canvas");
anElement.style.border = "3px solid #FFF";
}
documentsView.cameraButton.style.display="inline";
documentsView.importButton.style.display="inline";
documentsView.deleteButton.style.display="none";
documentsView.shareButton.style.display="none";
}
}
另一方面,如果我们正在结束选择,我们需要将完成按钮切换回编辑,然后将所有图片边框恢复为白色。我们还需要重新启用前两个图标(相机和导入),因为它们现在适用于我们的当前状态。最后两个图标被隐藏(因为它们可能在结束选择之前是可见的)。
当然,切换选择模式本身不足以实际实现选择,让我们看看documentContainerTapped(),它每次点击图片时都会被调用:
documentsView.documentContainerTapped = function(idx)
{
var theElement = $ge("documentsView_item" + idx + "_canvas");
if (documentsView.inSelectionMode)
{
点击图片的含义取决于我们是否处于选择模式。如果我们处于选择模式,点击它应该选择图片(如果之前未选择),取消选择图片(如果之前已选择),然后更新工具栏;这是通过以下代码片段完成的:
if ( documentsView.selectedItems.indexOf (idx) > -1 )
{
theElement.style.border = "3px solid #FF8";
documentsView.selectedItems.splice (
documentsView.selectedItems.indexOf( idx), 1);
}
要确定图片是否被选中,我们使用indexOf()。如果图片在数组中,我们知道图片当前被选中;因此,点击它应该取消选中,我们将边框颜色更改为浅黄色,并使用splice()方法从selectedItems数组中删除图片。
else
{
theElement.style.border = "3px solid #800";
documentsView.selectedItems.push(idx);
}
如果图片在selectedItems数组中找不到,我们知道我们需要选择它,因此我们更改边框颜色(红色)并通过push()方法将其添加到数组中。
if (documentsView.selectedItems.length>0)
{
documentsView.deleteButton.style.display="inline";
documentsView.shareButton.style.display="inline";
}
else
{
documentsView.deleteButton.style.display="none";
documentsView.shareButton.style.display="none";
}
}
无论我们是否选中或取消选中了图片,我们都需要处理工具栏。如果我们至少选中了一张图片,我们将显示删除和分享图标。如果选择变为空,我们将再次隐藏它们。
else
{
PKUI.CORE.pushView (imageView);
PKUTIL.delay(500, function()
{
imageView.setImage ( documentsView.availableDocuments.getDocumentAtIndex(idx).fullPath, idx );
} );
}
}
如果我们不在选择模式中,点击图片应该将视图移动到图片查看器,以便我们可以看到其全尺寸。我们首先推送视图,然后在 500 毫秒后,我们实际上告诉视图要显示的图片。这乍一看可能有些奇怪(我们通常会按相反的顺序这样做),但这是为了平滑过渡到新视图。从相机获取的图片可能相当大,加载这张图片需要一些时间。如果它在过渡的同时加载,过渡将会卡顿,使应用程序感觉比实际运行得慢。因此,我们等待过渡完成,然后告诉视图加载图片。
在选择完成后,让我们看看如何拍摄和导入图片。本质上,这些操作是相同的事情,只是图片的来源不同。当然,对于用户来说,它们是非常不同的。一个涉及拍照(构图、等待合适的时机、按下快门等),而另一个只涉及用户在相机中搜索已存在的图片。但对于应用程序来说,它们在技术上是一样的,只是图片来源不同。正因为如此,我们将有三个方法,前两个用于确定图片来源,最后一个用于实际拍摄或导入图片。
documentsView.takePicture = function()
{
documentsView.doPicture ( Camera.PictureSourceType.CAMERA );
}
首先是拍照。我们将使用 doPicture() 函数,并传入 CAMERA 作为源,这表示图片将从设备相机获取。用户界面会根据平台和设备的不同而有所变化,但我们不需要担心这一点,因为 PhoneGap 已经为我们提供了接口。
documentsView.importPicture = function()
{
documentsView.doPicture ( Camera.PictureSourceType.PHOTOLIBRARY );
}
要导入图片,我们使用 PHOTOLIBRARY 作为源调用 doPicture() 函数。这表示图片应来自用户的图像库。同样,用户界面会根据平台和设备的不同而有所变化,但我们的应用程序不需要担心这一点,因为 PhoneGap 会为我们处理所有细节。
documentsView.doPicture = function( source )
{
navigator.camera.getPicture (
function (uri)
{
PKFILE.moveFileTo ( uri, "doc://" + PKUTIL.getUnixTime() + ".jpg",
function ()
{
documentsView.reloadAvailableDocuments();
},
function (evt)
{
console.log (JSON.stringify(evt));
var anAlert = new
PKUI.MESSAGE.Alert(__T("Oops!"), __T("Failed to save the image."));
anAlert.show();
} )
},
function (msg)
{
var anAlert = new PKUI.MESSAGE.Alert(__T("Oops!"),
msg);
anAlert.show();
},
{ quality: 50,
destinationType: Camera.DestinationType.FILE_URI,
sourceType: source,
encodingType: Camera.EncodingType.JPEG,
mediaType: Camera.MediaType.PICTURE,
correctOrientation: true,
saveToPhotoAlbum: false
}
);
}
在这个函数中存在多层回调,每一层都依赖于前一步的正确执行。现在我们先看看外层回调。
要拍摄图片或导入图片,我们调用 navigator.camera.getPicture() 函数,并传入三个参数:success 函数、failure 函数和选项。我们函数中的选项位于方法的最后。以下是每个选项的含义:
-
quality:这是图像的压缩方式。我们使用50,因为它在质量和文件大小之间提供了一个良好的平衡。此外,一些设备处理来自相机的质量高于50的图片时可能会出现问题。(通常,在质量大于50的设备上,应用程序可能会崩溃。这不是一个好的用户体验。) -
destinationType: 它决定了目的地。这里有两种选择:我们可以请求一个表示图像数据的 base64 编码字符串,或者我们可以请求图像保存的文件位置。我们请求文件位置而不是 base64,主要是因为处理方便和内存考虑。(Base64 至少是图像文件大小的两倍。) -
sourceType: 它决定了图像应该来自哪里。如果设置为CAMERA,它将从相机获取图像。如果设置为PHOTOLIBRARY,它将从用户的库中获取。请注意,我们在这里接收传入的参数,这是takePicture()和importPicture()发送给我们的。 -
encodingType: 这是图像格式,通常是 JPEG 或 PNG。PNG 非常适合具有大量像素重复的图像(如图表),并且是无损的。然而,对于摄影来说,PNG 会太大。因此,我们将使用 JPEG。虽然是有损的,但它们不会大到难以处理。 -
mediaType: 相机通常可以用来拍摄视频而不是静态图像。在这种情况下,我们想要的只是一个静态图像,所以我们发送PICTURE。这也限制了导入图像时可用格式。如果没有这个,用户可能会导入一个视频,而我们无法处理。 -
correctOrientation: 这个参数可以用来纠正拍照时使用的方向。例如,如果手机被旋转了,而我们没有纠正方向,图像可能会显示为侧放或颠倒。启用此功能后,我们得到的是正立的图像。 -
saveToPhotoAlbum: 这可以是true或false。如果为true,相机拍摄的图像将被保存到相册和我们的应用中。如果为false,只有我们的应用接收图像。虽然我们在这里使用false,但这实际上是一个选择问题。将图像保存到相册以及您的应用中是否有意义?对这个问题的答案取决于您的应用及其目标受众。
如果我们进入navigator.camera.getPicture()的success函数,我们会看到这一行:
PKFILE.moveFileTo ( uri, "doc://" + PKUTIL.getUnixTime() + ".jpg",
相机给我们的文件可能位于临时位置(尤其是在 iOS 上),所以我们首先将文件移动到一个更永久的位置。我们使用PKFILE FILE API 包装器来完成此操作,我们将在本任务的稍后部分讨论。使用doc://确保文件被写入持久存储,使用 Unix 时间(自 1970 年 1 月 1 日起的毫秒数)确保文件名几乎唯一。(用户不可能在足够短的时间内连续拍摄两张图像,以至于它们落在同一个毫秒上。)
当移动完成时,我们调用另一个success函数,该函数简单地重新加载可用的文档,在我们的例子中,它重新显示包含新图像的图像网格。
在过程中,我们还有failure函数,其中包含alerts。如果相机由于某种原因无法拍照或导入出现问题,这些是很重要的。
接下来,让我们处理一次性删除多张图片的情况(删除单张图片的方式与我们在过去项目中删除文档的方式相同):
documentsView.confirmDeletePictures = function ()
{
var anAlert = new PKUI.MESSAGE.Confirm(__T("Delete Image(s)"), __T("This will delete the selected image(s). This action is unrecoverable."), __T("Don't Delete<|Delete*"), function(i)
{
if (i == 1)
{
PKUTIL.delay ( 100, documentsView.deleteSelectedPictures );
}
});
anAlert.show();
}
首先,我们询问用户是否真的想要删除选定的图片,因为这个操作是不可恢复的。如果他们同意,我们将在短暂的延迟后调用deleteSelectedPictures()。这个延迟是为了确保在deleteSelectedPictures()显示自己的警告之前,第一个警告已经消失。
documentsView.deleteSelectedPictures = function ()
{
if (documentsView.selectedItems.length > 0)
{
var currentIndex = documentsView.selectedItems.pop();
if (documentsView.globalAlert == null)
{
documentsView.globalAlert = new PKUI.MESSAGE.Alert(__T("Please Wait"), __T("Deleting Selected Images..."));
documentsView.globalAlert.show();
}
PKUTIL.delay (100, function () {
PKFILE.removeFile ( documentsView.availableDocuments.getDocumentAtIndex(currentIndex).fullPath,
documentsView.deleteSelectedPictures,
function (e)
{
documentsView.globalAlert.hide();
var anAlert = new PKUI.MESSAGE.Alert (__T("Oops!"), __T("Failed to remove file."));
anAlert.show();
documentsView.reloadAvailableDocuments();
}
);
}
);
}
else
{
if (documentsView.globalAlert)
{
documentsView.toggleSelection();
documentsView.reloadAvailableDocuments();
documentsView.globalAlert.hide();
PKUTIL.delay (750, function() { documentsView.globalAlert = null; } );
}
else
{
console.log ("ASSERT: We shouldn't be able to delete anything without having prior selected something.");
}
}
}
这个方法需要我们稍微转换一下思维方式,因为有一些我们通常期望存在的东西缺失——一个for循环。因为FILE API 都是异步的,我们不能在它们周围循环,我们需要确保在告诉用户我们已完成之前,所有的 API 请求都已经完成。
因此,我们使用类似递归的方法。这不是真正的递归,因为函数调用不是嵌套的,但它足够接近,以至于对我们的神经元来说有点痛苦。
我们首先检查selectedItems数组的长度。如果其中包含图片,我们知道我们需要删除一张。如果数组为空,我们知道我们已经完成了工作,可以清理一切。
如果我们需要删除一个图片,我们调用PKFILE.removeFile函数,并传入图片的完整路径。同时,我们也将当前方法传递给success函数。这意味着一旦图片成功删除,我们会被再次调用以重复此过程,直到selectedImages数组中的所有图片都被删除。只有在发生错误或selectedImages数组为空时,这个过程才会停止。
当selectedImages数组为空时,我们需要清理一些东西。我们清除在开始删除过程时创建的警告,并重新加载所有可用的文档。
在讨论重新加载文档列表的过程中,也许我们应该稍微深入一点:
documentsView.documentIterator = function(o)
{
这个函数的第一部分与之前的工程相同,所以我们直接跳到下面代码片段中有趣的部分:
PKUTIL.delay(100, function()
{
for (var i = 0; i < theNumberOfDocuments; i++)
{
var theDocumentEntry = o.getDocumentAtIndex(i);
var theElement = $ge("documentsView_item" + i + "");
var theLPGesture = new GESTURES.LongPressGesture(theElement, function(o)
{
documentsView.longPressReceived(o.data);
});
theLPGesture.data = i;
就像在我们的上一个项目中一样,我们给每个图片附加了一个长按手势。当接收到长按手势时,我们会显示一个菜单,允许用户删除、复制或重命名图片。
更有趣的部分是下面的部分,我们在这里实际上将图片的缩略图渲染到每个canvas上:
var img = new Image();
img.i = i;
首先,对于每个可用的文档,我们创建一个新的Image对象。同时,我们也会给对象的属性分配一个index,因为当图片加载完成后,我们还需要用到它。
img.onload = function ()
{
然后,我们给图片的onload事件附加一个方法。这就是我们将图片渲染到画布上的地方。
var newWidth = 84;
var newHeight = (this.height / this.width) * 84;
if (newHeight > 84)
{
newHeight = 84;
newWidth = (this.width / this.height) * 84;
}
图像加载后,我们首先确定缩略图的大小。由于我们希望保持宽高比,我们必须知道图像的宽度和高度,这可以通过使用this.height和this.width来实现。首先我们假设图像将适合84像素的宽度,并使用高度与宽度的比例来确定高度。但有些图像可能会比84像素高,因此我们基于84像素的高度重新计算这些图像的尺寸。
var newLeft = 42 - (newWidth/2);
var newTop = 42 - (newHeight/2);
一旦确定了缩略图的宽度和高度,我们就可以计算出顶部和左侧的位置,使图像在其 84 x 84 的容器中居中。
var theCanvas = $ge("documentsView_item" + this.i + "_canvas");
theCanvas.setAttribute ("width", newWidth * window.devicePixelRatio);
theCanvas.setAttribute ("height",newHeight * window.devicePixelRatio);
theCanvas.style.width = ""+newWidth+"px"; theCanvas.style.height = ""+newHeight+"px";
theCanvas.style.left = ""+newLeft +"px"; theCanvas.style.top = ""+newTop+"px";
接下来,我们获取画布并将其宽度和高度设置为新的计算值。请注意,我们设置了 CSS 和 HTML 的宽度和高度;这是因为不同像素比率的显示上画布的实际大小可能不同。(在视网膜显示屏上,画布实际上大了一倍,但 CSS 将其压缩回原始大小以供视觉使用。)
var theCanvasCtx = theCanvas.getContext("2d");
theCanvasCtx.save();
theCanvasCtx.scale (window.devicePixelRatio, window.devicePixelRatio);
theCanvasCtx.imageSmoothingEnabled = false;
接下来,我们从画布中获取一个上下文。然后我们将画布缩放到像素比例,这样我们就可以在每一点超过一个像素的设备上继续使用基于点的像素。然后我们关闭图像平滑;这是为了帮助加快下一行:
theCanvasCtx.drawImage (this, 0, 0, newWidth, newHeight);
这一行实际上将图像绘制到我们之前计算出的尺寸的画布上。一旦完成,我们就会有一个漂亮的缩略图而不是一个空白的画布。
然而,这个操作并不是免费的;将图像绘制到更小的比例需要时间,但我们必须只做一次(无论何时加载可用的文档)。如果我们为所有缩略图都使用了IMG标签,那么每次视图发生变化时都必须这样做,这将慢到无法使用。
theCanvasCtx.restore();
}
img.src = theDocumentEntry.fullPath;
}
});
}
最后,我们设置图像的源。这会触发图像的加载,加载完成后会触发图像的onload()方法。这个操作也不是免费的,但同样,它只在我们加载文档列表时发生。
视图代码的其余部分与以前的项目非常相似,如果不是完全相同,所以我们不会再次介绍那段代码。
我们做了什么?
在这个任务中,我们已经涉及了很多内容。我们创建了可以获取和导入图片的代码,我们使用了 HTML5 的canvas标签,并且开始使用一个使文件操作变得稍微容易一点的FILE API 包装器。
我还需要知道什么?
我们提到过会更多地讨论FILE API 包装器,这里就是一个很好的地方。
将PKFILE视为一个方便的包装器,它使文件操作变得更容易一些。它并没有消除异步性,但它封装了一些文件操作通常必须执行的操作,特别是获取文件系统。它还使我们能够添加一些用于引用持久和临时存储的快捷方式。
首先,我们来覆盖最后一部分。任何包含以下之一的文件名都会自动转换为系统特定的值:
-
doc://转换为/path/to/app/persistent/storage/。 -
tmp://转换为/path/to/app/temporary/stora。lhost被替换为"";FILEAPI 无法处理以这个开头的路径。
因为
PKFILE会将任何路径或文件名中的这些值转换为系统特定的值,所以我们不再需要担心自己获取文件系统。这至少减少了回调链的数量,并简化了我们对自己应用存储中文件的引用方式。例如,我们可以用doc://photo.jpg而不是像/var/something/somethingelse/app/Documents/photo.jpg这样的路径来引用。PKFILE中的每个方法都接受一些文件名、success和failure参数的组合。failure函数总是传递一个对象,表明失败的原因。success函数不传递任何参数。我们不会详细讲解包装器的代码,主要是因为它并不是你以前没有见过的。如果你想看看,它位于
framework/fileutil.js中,这是本项目提供的文件之一。
实现图片视图
图片视图本身非常简单:它只显示一个图片以及工具栏中的两个图标(删除和分享)。以下是我们的视图将如何看起来,首先是 iOS:

对于 Android,视图如下所示:

准备就绪
如果你想跟上来,代码在 www/views/imageView.js。
继续进行
通常我们会从视图的 HTML 开始,但这个与上一个项目非常相似。因此,我们将从用于显示图片的模板开始:
<div id="imageView_documentTemplate" class="hidden">
<img src="img/%SRC%" width=100% />
</div>
这可能是我们见过的最简单的模板。它实际上只是一个指定宽度的图片。高度将根据图片的宽高比推断出来。
就像模板一样,代码也将非常简单:
var imageView = $ge("imageView") || {};
imageView.imagePath = "";
imageView.imageIndex = -1;
imageView.setImage = function ( imagePath, imageIndex )
{
imageView.imagePath = imagePath;
imageView.imageIndex = imageIndex;
$ge("imageView_contentArea").innerHTML =
PKUTIL.instanceOfTemplate($ge("imageView_documentTemplate"),
{ "src" : imageView.imagePath });
}
我们存储两个项目:图片的路径和图片的索引。我们还提供了一个名为 setImage 的方法,其他人可以使用它来告诉我们加载哪张图片。一旦调用,我们就用图片数据替换内容区域。
我们的观点也支持删除正在查看的图片。以下是处理方式:
imageView.confirmDeletePictures = function ()
{
var anAlert = new PKUI.MESSAGE.Confirm(__T("Delete Image"), __T("This will delete the selected image.This action is unrecoverable."), __T("Don't Delete<|Delete*"), function(i)
{
if (i == 1)
{
PKUTIL.delay ( 100, imageView.deleteSelectedPicture );
}
});
anAlert.show();
}
imageView.deleteSelectedPicture = function ()
{
PKFILE.removeFile ( imageView.imagePath,
function ()
{
PKUTIL.delay(100, function() { PKUI.CORE.popView(); } );
documentsView.reloadAvailableDocuments();
},
function (e)
{
var anAlert = new PKUI.MESSAGE.Alert (__T("Oops!"), __T("Failed to remove file."));
anAlert.show();
documentsView.reloadAvailableDocuments();
}
);
}
我们首先询问用户是否确定,如果是的话,我们将通过 PKFILE.removeFile() 删除文件。一旦删除,我们就从视图堆栈中移除自己,因为查看现在已被删除的图片没有太多意义。我们还告诉 documentsView 重新加载其文档,因为我们已经修改了文件系统。
imageView.viewDidHide = function ()
{
$ge("imageView_contentArea").innerHTML = "";
}
还有一个方法需要介绍,那就是前面代码片段中看到的 viewDidHide() 方法。我们在这里所做的只是清除内容区域,这样当图像视图不被显示时,就不会有图像隐藏在那里占用内存。这也意味着下次视图显示时,不会出现一个奇怪的过渡,即最后加载的图像在新图像加载之前可见几秒钟。(记住,我们在加载新图片之前等待几毫秒,以确保平滑过渡。)
我们做了什么?
在这个任务中,我们实现了图像视图,处理了图像的加载,还处理了图像的删除。
游戏结束..... 结束语
嗯,这并不太难,对吧?我们已经能够使用内置相机拍照,并且我们还能直接从用户的照片库中导入图像。我们还通过使用 HTML5 的 canvas 标签来显示缩略图,从而提高了性能。最后,我们与 FILE API 包装器进行了一些工作,以帮助简化我们的代码编写。
你能承受高温吗?热手挑战
现有的项目还有很多可以改进的地方。你为什么不尝试几个呢?
-
为应用程序添加共享功能。同时,也要小心处理批量共享多张图片。
-
为图像添加过滤器,以便可以将它们转换为黑白、棕褐色等。如果你想保存文件,你需要编写一个本地插件来保存从画布返回的数据。
-
如果图像还没有缩略图,可以通过生成缩略图来加快速度。如果你想存储缩略图,可以将其存储在本地存储中,它不会很大,或者使用
FileAPI 来存储。
第七章。让我们去看电影!
在手机上引入相机不久后,人们就开始问,“视频怎么办?”最初,视频录制受到手机存储空间和硬件的限制。拍几张可能每张只有几百千字节的照片是一回事,但拍摄几秒钟以上的视频,其文件大小却相当大,这是另一回事。此外,视频还需要编码和压缩,这可以通过软件完成,但在硬件上完成会更好。
当视频录制变得可行时,它改变了我们看待世界的方式。突然之间,只要有手机的地方就能拍摄新闻事件——几乎无处不在。视频录制变得司空见惯,所以现在几乎所有的手机都支持视频录制。在这个项目中,我们将探讨如何在我们的应用程序中录制和播放视频。
我们要构建什么?
实事求是地说,我们将要构建的是项目 6,“说 Cheese!”。还记得那个吗?我们将不再处理静态图片,而是处理视频。
虽然用户界面和大部分代码将保持不变,但视频确实带来了一些有趣的挑战。例如,如何获取视频的缩略图?我们都知道缩略图应该是什么样子,但实际如何获取呢?或者坦白说,我们如何不仅仅显示视频?我们如何实现播放?
不幸的是(或者从你的观点来看,幸运的是),为了完成这些任务,我们需要深入研究一些本地代码。这些任务本身并不复杂,但 PhoneGap 没有提供对视频缩略图的支持,Android 平台也没有提供对 HTML5 VIDEO标签的良好支持,因此我们将不得不使用另一个插件来播放视频。
它能做什么?
我们的应用程序名为Mem'ry(我们得赶时髦,把元音字母去掉,对吧?)将允许用户在应用程序内录制视频。任何录制的视频都可以播放。此外,我们将使用之前使用的相同文档管理功能来管理这些文件——删除、复制和重命名。
为什么它很棒?
坦白说,录制视频并不是很多应用程序必须处理的事情。但播放视频呢?这却是大多数应用程序需要处理的事情。因此,了解如何为最终用户播放视频非常重要,但如果你需要录制视频,这个应用程序也会为你提供必要的工具。
我们还将深入研究一些 PhoneGap 实际上并不提供支持的领域。这并不是 PhoneGap 的错,实际上,视频不仅仅是图像的集合。它们被压缩和编码,没有明显的方法可以从视频中获取缩略图并将其显示为 HTML 中的图像,这是我们迄今为止在处理 PhoneGap 时一直在使用的方法。图像的缩略图很简单:只需缩小图像即可。视频的缩略图?并不简单;你必须从压缩和编码的文件中构建它,而 HTML 不知道如何做到这一点。幸运的是,最流行的平台提供的 SDK 使得这变得容易,但我们需要进行一些原生编码才能达到这个目标。
我们该如何进行?
我们将遵循以下步骤:
-
准备视频缩略图插件
-
实现 iOS 版本的视频缩略图插件
-
实现 Android 版本的视频缩略图插件
-
与视频缩略图插件集成
-
实现视频录制和导入
-
实现视频播放
我需要准备什么?
首先,请确保从本书提供的下载中获取此项目的文件。由于代码与项目 6 中的代码非常相似,说奶酪!,所以为了让你能够跟上,你应该有之前项目的快速参考或我们的文件。
第二,请确保从github.com/phonegap/phonegap-plugins/tree/master/Android/VideoPlayer下载 Android 视频播放器插件。在这个项目中,我们使用了适用于 PhoneGap 2.x 及更高版本的插件。
准备视频缩略图插件
我们的 App 在用户界面和交互方面与先前的项目几乎相同,因此我们不会走完整个设计流程。相反,我们将直接开始实现视频缩略图插件。
准备工作
在本书中,我们将首次使用原生代码。虽然我们之前使用过插件(项目 2 中的 ChildBrowser 插件,让我们社交!),但我们从未自己创建过。由于我们支持多个平台,我们还需要多次编写插件。
开始行动
这个任务本质上由三个步骤组成:
-
配置项目以使用插件
-
创建 JavaScript 接口
-
创建原生代码
前两个任务相当简单,但最后一个,嗯,我们将在几页后解决这个问题。
配置项目以使用插件
我们需要做的第一件事是配置我们正在构建的项目以使用新的插件。对于每个平台,步骤都不同,但从高层次来看,它们基本上是相同的事情。我们正在告诉 PhoneGap 关于插件的信息,以及它可供使用。不同平台的步骤如下:
-
iOS
-
使用 Xcode,导航到
Cordova.plist文件。 -
展开插件部分。
-
当鼠标悬停在插件上时,点击出现的+号。这将插入一个新行。
-
将
PKVideoThumbnail作为键,将PKVideoThumbnail作为值添加。 -
保存文件。
-
打开
index.html文件,并添加以下PKVideoThumbnail.js脚本文件:<script type="application/javascript" charset="utf-8" src="img/PKVideoThumbnail.js"> </script>
-
-
Android
-
使用 Eclipse,导航到
/res/xml/config.xml文件并打开它。如果您没有显示可编辑的 XML,请切换到 XML 文本编辑视图。 -
在 XML 文件中找到插件部分。
-
添加以下行:
<plugin name="PKVideoThumbnail" value="com.kerrishotts.PKVideoThumbnail.PKVideoThumbnail"/> -
保存文件。
-
打开
index_android.html文件,并添加以下PKVideoThumnail.js脚本文件:<script type="application/javascript" charset="utf-8" src="img/PKVideoThumbnail.js"> </script>
-
所有这些只是告诉 PhoneGap 我们将有一个名为PKVideoThumbnail的插件可用。没有这个,应用程序将无法正确工作,因为它不知道如何联系插件。
创建 JavaScript 接口
虽然在技术上可以在没有任何对应的.js文件的情况下调用插件,但事实是,创建一个接口通常会使调用插件变得更容易一些。接口文件将几乎相同,但 iOS 版本将有所不同,以至于它无法在 Android 平台上工作,反之亦然。
在www/plugins/iOS目录下,创建一个名为PKVideoThumbnail.js的文件,内容如下:
var PKVideoThumbnail = PKVideoThumbnail || {};
PKVideoThumbnail.createThumbnail = function ( source, target, success, failure )
{
cordova.exec(success, failure,
"PKVideoThumbnail",
"createThumbnail",
["file://localhost" + source, target]);
}
上述代码的作用是简单地创建一个易于使用的包装器调用,这样我们就可以在需要获取视频缩略图时调用PKVideoThumbnail.createThumbnail(),而不是使用cordova.exec(some_function,error_function,"PKVideoThumbnail","createThumbnail",[…])。
对于提供大量功能的某些插件,JavaScript 接口基本上充当中间人。它将参数转换为插件可以理解的内容,然后在返回到 JavaScript 时处理返回结果。在我们的案例中,包装器非常小。
对于 Android 版本,在www/plugins/Android目录下创建一个名为PKVideoThumbnail.js的文件,内容如下:
var PKVideoThumbnail = PKVideoThumbnail || {};
PKVideoThumbnail.createThumbnail = function ( source, target, success, failure )
{
cordova.exec(success, failure,
"PKVideoThumbnail",
"createThumbnail",
[source, target]);
}
您能看出这两个文件之间的区别吗?
我给你一个提示:看看倒数第二行,就在source之前。iOS 版本添加了file://localhost,而 Android 版本没有。这是一个小细节,但没有它,iOS 版本的应用程序将无法工作。
我们做了什么?
在这个任务中,我们修改了项目设置,以便项目知道我们即将创建的插件。我们还为 iOS 和 Android 创建了 JavaScript 接口,这将允许我们与本地代码进行通信。
实现 iOS 的视频缩略图插件
视频缩略图的 iOS 版本将使用一个隐藏的视频播放器来构建缩略图。技术上,我们可以使用另一个库,但视频播放器在从视频构建图像时既方便又快速。我们将其隐藏以确保用户永远不会真正看到正在发生的事情。
准备中
首先,让我们在 Xcode 中创建一个新的Objective-C 类。最简单的方法是在插件文件夹上右键单击并选择新建文件...,如以下截图所示:

重要的是你要使用与其他源和资源目录平级的插件文件夹;而不是位于www目录中的那个。

接下来,确保选择了Cocoa Touch类别,然后选择Objective-C 类图标。点击下一步。

然后给这个类起一个名字,在这个例子中是PKVideoThumbnail,并确保它是CDVPlugin的子类。再次点击下一步,然后你会被提示验证你想要保存文件的位置。它应该设置为插件文件夹;如果不是,在完成任务之前一定要导航到那里。
开始行动
现在我们有了.h和.m文件,我们需要填充它们。让我们先从.h文件开始。以下是为这个类提供的接口或规范:
#import <Cordova/CDVPlugin.h>
@interface PKVideoThumbnail : CDVPlugin
#if CORDOVA_VERSION_MIN_REQUIRED <= __CORDOVA_2_0_0
- (void) createThumbnail:(NSMutableArray*)arguments withDict:(NSMutableDictionary*)options;
#else
- (void) createThumbnail:(CDVInvokedUrlCommand*)command;
#endif
@end
在前面的代码中,我们正在定义一个名为createThumbnail的方法,它将在从 JavaScript 调用PKideoThumbnail.createThumbnail()时执行。
注意#if…#else…#endif结构;PhoneGap 在 2.0 和 2.1 版本之间更改了方法签名,因此这处理了两种变体。
然而,到目前为止,我们还没有编写任何实质性的代码;我们一直在进行声明或定义。让我们通过以下代码片段中的.m文件来改变这种情况:
#import "PKVideoThumbnail.h"
#import <Cordova/CDVPluginResult.h>
#import <MediaPlayer/MediaPlayer.h>
首先,我们导入我们需要构建插件的一些库。我们还导入了我们的.h文件;否则,编译器会报错。
@implementation PKVideoThumbnail
BOOL extractVideoThumbnail ( NSString *theSourceVideoName,NSString *theTargetImageName )
{
UIImage *thumbnail;
接下来,我们定义一个名为extractVideoThumbnail的方法,它接受两个参数,即视频的路径以及创建图像时应使用的位置和名称。如果成功,我们的方法将返回YES,如果不成功,则返回NO。(这是 Objective-C 中的布尔值TRUE和FALSE的等价物。)
我们还定义了一个类型为UIImage的thumbnail。星号(*)表示这是一个指针——在基于 C 的语言中非常重要。基本上,你会在声明任何对象变量或参数时使用它。当使用数字时,你不会使用星号,但在这个例子中,我们只声明了一个变量来开始。
// BASED ON http://stackoverflow.com/a/6432050 //
MPMoviePlayerController *mp = [[MPMoviePlayerController
alloc]
initWithContentURL: [NSURL
URLWithString:theSourceVideoName] ];
mp.shouldAutoplay = NO;
mp.initialPlaybackTime = 1;
mp.currentPlaybackTime = 1;
我们接下来要做的是声明并创建MPMoviePlayerController。它是一个对象,所以它也得到一个星号。我们将它简称为mp。
我们传递了视频的路径,该路径需要以 file://localhost 前缀;记住我们在 PKVideoThumbnail.js 中这样做。
然后,我们将播放时间设置为 1 秒,并指示它不应自动播放。我们只想从视频中获取一个单独的图像,所以我们不想立即为用户播放它。
thumbnail = [mp thumbnailImageAtTime:1
timeOption:MPMovieTimeOptionNearestKeyFrame];
[mp stop];
[mp release];
接下来,我们请求电影中 1 秒点附近的最接近的图像。由于电影中的压缩和编码使用关键帧,我们可能无法在确切的 1 秒标记处获得图像,但它应该非常接近。
return [UIImageJPEGRepresentation ( thumbnail, 1.0) writeToFile:theTargetImageName atomically:YES];
最后,我们将缩略图保存到所需的文件中——我们的 JavaScript 通常会使用电影名称并添加一个 .jpg 扩展名。操作的返回值将是 YES 或 NO。如果是 NO,则缩略图没有成功写入。
}
#if CORDOVA_VERSION_MIN_REQUIRED <= __CORDOVA_2_0_0
接下来,我们需要为 PhoneGap 2.0 或更低版本定义插件处理程序:
- (void) createThumbnail:(NSMutableArray*)arguments withDict:(NSMutableDictionary*)options
{
NSString* callbackId = [arguments objectAtIndex:0];
CDVPluginResult* pluginResult = nil;
NSString* javaScript = nil;
这三个变量总是在插件中定义的。它们对于插件的功能至关重要。第一个是一个唯一 ID,PhoneGap 使用它来跟踪 JavaScript 和原生代码之间的调用。第二个是我们插件活动的结果;我们可以用它将数据传回 JavaScript。最后一个是我们返回代码的 JavaScript 结果;这用于调用成功或失败例程。
@try {
NSString* theSourceVideoName = [arguments objectAtIndex:1];
NSString* theTargetImageName = [arguments objectAtIndex:2];
接下来,我们获取应该传递给插件的两个参数。
if ( extractVideoThumbnail(theSourceVideoName, theTargetImageName) )
{
pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsString:theTargetImageName];
javaScript = [pluginResult toSuccessCallbackString:callbackId];
}
else
{
pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:theTargetImageName];
javaScript = [pluginResult toErrorCallbackString:callbackId];
}
我们使用两个参数调用 extractVideoThumbnail 方法。正如我们之前所说的,如果它返回 YES,那么它就成功了,因此我们的插件结果将是 OK。如果它返回 NO,我们将返回一个错误结果。
} @catch (NSException* exception) {
pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_JSON_EXCEPTION messageAsString:[exception reason]];
javaScript = [pluginResult toErrorCallbackString:callbackId];
}
[self writeJavascript:javaScript];
}
这里的 @catch 块也很重要;它捕获在 @try 块中发生的任何错误。这可能会发生在某些事情真的出了问题或发送了错误数量(或类型)的情况下。
#else
- (void) createThumbnail:(CDVInvokedUrlCommand*)command
{
CDVPluginResult* pluginResult = nil;
NSString* javaScript = nil;
@try {
NSString* theSourceVideoName = [command.arguments objectAtIndex:0];
NSString* theTargetImageName = [command.arguments objectAtIndex:1];
if ( extractVideoThumbnail(theSourceVideoName, theTargetImageName) )
{
pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsString:theTargetImageName];
javaScript = [pluginResult toSuccessCallbackString:command.callbackId];
}
else
{
pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:theTargetImageName];
javaScript = [pluginResult toErrorCallbackString:command.callbackId];
}
} @catch (NSException* exception) {
pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_JSON_EXCEPTION messageAsString:[exception reason]];
javaScript = [pluginResult toErrorCallbackString:command.callbackId];
}
[self writeJavascript:javaScript];
}
#endif
@end
最后,我们基本上是重复自己,但使用 PhoneGap 2.1 版本的插件接口。仔细看看,它做的是同样的事情,但有一些细微的差别。
我们做了什么?
就这样!现在当我们调用 PKVideoThumbnail.createThumbnail() 时,我们就能从我们拍摄或导入的任何视频中提取缩略图。酷吧,不是吗?
我还需要知道什么?
好吧,所以 extractVideoThumbnail() 并不完全符合标准的 Objective-C 风格。通常,人们会这样写:
BOOL extractThumbnailToFile: (NSString *) theTargetImageName fromVideoNamed: (NSString *)theSourceVideoName
我们会这样调用它:
if (extractThumbnailToFile:theTargetImageName fromVideoNamed:theSourceVideoName) …
但我们的方法做的是同样的事情,而且更简洁。然而,当与 Objective-C 方法一起工作时,重要的是要认识到定义方法签名之间的差异。如果你要写很多 Objective-C 代码,最好习惯后者,但在紧急情况下,前者也可以用。
最后一点:如果我们无法从视频中提取缩略图会发生什么?你会注意到代码中没有处理这种可能性的内容。很可能是thumbnail将是NULL,并且尝试将缩略图写入存储将返回NO或引发异常。无论如何,我们后面的代码中已经处理了,如果返回NO,我们将返回ERROR。
实现 Android 的视频缩略图插件
插件的 Android 版本与 iOS 版本非常相似,尽管它不必担心使用的 PhoneGap 版本,所以它要短一些。最终,尽管如此,步骤是相同的:从视频中抓取一个帧,保存到存储,然后返回 JavaScript。
准备工作
首先,通过打开文件菜单,选择新建,然后选择类来创建一个新的类。
接下来,将包名设置为com.kerrishotts.PKVideoThumbnail,将类的名称字段设置为PKVideoThumbnail,然后取消选中您想创建哪些方法存根?下的第一个选项——我们不需要任何示例代码。这在上面的屏幕截图中显示:

接下来,打开生成的文件PKVideoThumbnail.java,我们将开始编写 Android 版本。
继续前进
与 iOS 不同,我们只需要一个文件,而且它最终还要短一些,如下所示:
package com.kerrishotts.PKVideoThumbnail;
import org.apache.cordova.api.Plugin;
import org.apache.cordova.api.PluginResult;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import android.graphics.Bitmap;
import android.graphics.Bitmap.CompressFormat;
import android.media.*;
import android.provider.MediaStore;
import java.io.*;
首先,就像 iOS 一样,我们导入插件工作所需的库。
public class PKVideoThumbnail extends Plugin {
public PluginResult execute(String action, JSONArray args, String callbackId) {
与 iOS 不同,我们定义了一个名为execute的单个方法。如果我们的插件有多个动作,我们需要在这个execute方法中处理每一个。在 iOS 中,这已经为我们做好了。
try {
if (action.equals("createThumbnail")) {
由于我们需要检查传入的操作是否为createThumbnail,所以我们有前面的代码。技术上,如果我们只执行一个操作的插件,我们可以避免这样做,但这将是不标准的。
String sourceVideo = args.getString(0);
String targetImage = args.getString(1);
在前面的代码中,我们定义了我们从 JavaScript 传递的两个参数。注意,与 iOS 版本不同,这里没有星号。不用担心那些讨厌的东西,不是吗?
Bitmap thumbnail = ThumbnailUtils.createVideoThumbnail ( sourceVideo, MediaStore.Images.Thumbnails.MINI_KIND);
从视频中创建缩略图实际上非常简单;已经有一个简单的例程预先为我们写好了。我们只需传递视频的路径,并请求一个特定的尺寸(在我们的情况下,MINI_KIND)。
FileOutputStream theOutputStream;
try
{
File theOutputFile = new File (targetImage.substring(7));
if (!theOutputFile.exists())
{
if (!theOutputFile.createNewFile())
{
return new PluginResult(PluginResult.Status.ERROR,"Could not save thumbnail.");
}
}
if (theOutputFile.canWrite())
{
theOutputStream = new FileOutputStream (theOutputFile);
if (theOutputStream != null)
{
thumbnail.compress(CompressFormat.JPEG, 75, theOutputStream);
}
else
{
return new PluginResult(PluginResult.Status.ERROR, "Could not save thumbnail; target not writeable.");
}
}
}
catch (IOException e)
{
e.printStackTrace();
return new PluginResult(PluginResult.Status.IO_EXCEPTION, "I/O exception saving thumbnail.");
}
实际上保存缩略图到存储要复杂一些。我们需要检查是否应该首先创建文件(通过检查它是否存在),然后检查是否可以写入文件。一旦我们这样做,我们就可以使用thumbnail.compress()方法来完成保存文件的实际工作。所有其他东西都是为了处理错误等,Java 要求你必须处理它们。如果不这样做,代码将无法编译。
return new PluginResult (PluginResult.Status.OK, targetImage );
到目前为止,如果我们正在执行这段代码,缩略图已经成功创建,因此我们返回OK。如果我们不在这里,我们已经返回了ERROR或IO_EXCEPTION或甚至其他,如以下代码片段中所示:
} else {
return new PluginResult(PluginResult.Status.INVALID_ACTION);
}
} catch (JSONException e) {
return new PluginResult(PluginResult.Status.JSON_EXCEPTION);
}
}
}
我们做了什么?
就这些!我们已经从一个视频文件中提取了缩略图,并将其保存为 JPEG 文件,以便我们的 JavaScript 进行处理。
我还需要了解什么?
好的,所以如果你仔细观察这段代码保存出来的内容,你会发现它实际上并不是一个非常小的缩略图。实际上,它是一个与视频分辨率相同的图像。不过,这并不是什么大问题,因为我们在 JavaScript 中会将其缩小,但我还是想让你知道这一点。
集成视频缩略图插件
接下来,我们需要实际修改我们的代码,以便显示视频缩略图。打开 www/views 下的 documentsView.html 文件,以便你可以跟随操作。
准备工作
显示缩略图的原理与我们在上一个项目中显示缩略图的原理非常相似;也就是说,我们仍然使用 canvas 标签来加快应用程序的响应速度,我们仍然从 JPEG 文件生成缩略图。不同之处在于我们必须从视频文件生成那些 JPEG 文件。
开始行动
让我们从以下代码片段中的 documentIterator() 方法开始看起:
documentsView.documentIterator = function(o)
{
var theHTML = "";
var theNumberOfDocuments = 0;
documentsView.documentToIndex = {};
for (var i = 0; i < o.getDocumentCount(); i++)
{
var theDocumentEntry = o.getDocumentAtIndex(i);
theHTML += PKUTIL.instanceOfTemplate($ge("documentsView_documentTemplate"),
{ "src" : theDocumentEntry.fullPath,
"index" : i
});
documentsView.documentToIndex[ PKUTIL.FILE.getFileNamePart ( theDocumentEntry.fullPath ) ] = i;
theNumberOfDocuments++;
}
$ge("documentsView_contentArea").innerHTML = theHTML;
与我们之前的项目相比,内容到目前为止非常相似。唯一的区别是高亮的那一行。我们在文件中之前定义了一个名为 documentToIndex 的变量,它是一个对象。我们将其用作关联数组,这样我们就可以稍后根据文件将其映射回其索引。例如,如果文件 1239548.mov 是我们文档列表中的第三个项目,我们将在 1239548.mov 的空间中存储 3。
接下来,就像之前一样,我们在附加长按处理程序等之前等待 100 毫秒,如下面的代码片段所示:
PKUTIL.delay(100, function()
{
for (var i = 0; i < theNumberOfDocuments; i++)
{
var theDocumentEntry = o.getDocumentAtIndex(i);
var theElement = $ge("documentsView_item" + i + "");
var theLPGesture = new GESTURES.LongPressGesture(theElement, function(o)
{
documentsView.longPressReceived(o.data);
});
theLPGesture.data = i;
PKVideoThumbnail.createThumbnail ( theDocumentEntry.fullPath,
PKUTIL.FILE.getPathPart ( theDocumentEntry.fullPath ) + PKUTIL.FILE.getFileNamePart ( theDocumentEntry.fullPath ) + ".jpg",documentsView.renderVideoThumbnail,function ( theError )
{ console.log ( JSON.stringify ( theError ) );
}
)
这最后的部分是我们要求我们的新插件执行提取视频缩略图的工作。我们知道视频的路径在持久存储中,因此我们可以传递这部分信息。我们还可以构造 JPEG 文件的文件名(我们正在做的是移除视频扩展名,并用 .jpg 替换)。然后当视频缩略图成功生成后,renderVideoThumbnail() 将被调用。如果发生错误,我们将将其记录到控制台。
}
});
}
documentsView.renderVideoThumbnail = function ( theTargetImage )
{
var img = new Image();
var i = documentsView.documentToIndex[ PKUTIL.FILE.getFileNamePart ( theTargetImage ) ];
renderVideoThumbnail() 方法整体上与上一个项目中 documentIterator() 方法内部的其余代码非常相似。我们将其分离出来,以便更容易阅读,但除此之外,它做的是同样的事情。这两个方法之间的唯一区别是我们必须找出索引——我们正在谈论哪个图像。如果你记得我们之前定义的变量 documentToIndex[],我们可以从文件名中找出图像的索引,这正是我们在前面的代码片段中所做的。从那里开始,代码是相同的,我们这里不再列出其余部分。
我们做了什么?
在这个任务中,我们修改了documentIterator()方法以配合我们的新插件。我们已经从视频中请求了缩略图,并且我们已经成功地在需要时将其显示给最终用户。
实现视频录制和导入
我们已经完成了我们应用的第一部分,即显示视频缩略图,但在我们将任何内容放入应用之前,我们必须实际录制它们。在这个任务中,我们将做的是——录制一个新的视频。
准备工作
如果你想跟随,我们将在这个www/views目录下的documentsView.html文件中工作。
继续前进
你可能会认为我们会使用之前项目中的相机代码,你部分是对的。对于 iOS,我们确实可以使用几乎完全相同的代码来导入新视频,但对于任何平台录制视频,我们必须使用一个新的 API——CAPTURE API。
让我们来看看takeMovie()函数的代码:
documentsView.takeMovie = function()
{
navigator.device.capture.captureVideo(
CAPTURE API 提供了比仅仅捕获视频更多的方法;你还可以捕获音频(这与在项目 5 中使用MEDIA API 类似,与你的应用交谈)。在我们的情况下,我们使用captureVideo()方法。它接受三个参数:success函数、failure函数以及我们想要传递的任何选项。在我们的情况下,唯一的选项是我们将限制用户一次只能有一个视频。技术上,API 将允许在会话中录制多个视频,但出于我们的目的,一次一个简化了事情。
function (mediaFiles)
{
var uri = mediaFiles[0].fullPath;
var fileExt = PKUTIL.FILE.getFileExtensionPart ( uri );
PKFILE.moveFileTo ( uri,
"doc://" + PKUTIL.getUnixTime() + "." + fileExt,
function ()
{
documentsView.reloadAvailableDocuments();
},
function (evt)
{
console.log (JSON.stringify(evt));
var anAlert = new PKUI.MESSAGE.Alert(__T("Oops!"), __T("Failed to save the video."));
anAlert.show();
} )
},
上述代码显示了success方法,它将使用文件列表调用。在我们的情况下,它将只有一个文件名,我们可以通过使用列表的zero索引来获取它。从那时起,它几乎与我们在项目 6 中从临时存储复制文件到永久存储的方式相同。唯一的区别是我们不假设文件扩展名将是.jpg。不同平台上的视频通常会有非常不同的扩展名。
function (error)
{
var msg = 'An error occurred during capture: ' + error.code;
var anAlert = new PKUI.MESSAGE.Alert(__T("Oops!"), msg);
anAlert.show();
},
接下来是failure函数,我们唯一要关心的是让用户知道错误代码,但如果你愿意,你可以根据代码给出一个更好的错误信息。
{limit: 1});
}
最后,第三个参数是那一组选项。在这种情况下,我们只想一次有一个视频,但还有其他可以传递的选项,例如视频编码类型。不同的平台以不同的方式支持这些其他选项,所以我们在这里不会过多讨论它们,但如果你需要,它们可以在 PhoneGap API 文档中找到。(docs.phonegap.com/en/edge/cordova_media_capture_capture.md.html#Capture)
对于导入视频,我们可以使用之前项目中几乎相同的代码,但我们将它全部放在importMovie()中:
documentsView.importMovie = function()
{
navigator.camera.getPicture ( function (uri)
{
var fileExt = PKUTIL.FILE.getFileExtensionPart ( uri );
PKFILE.moveFileTo ( uri, "doc://" + PKUTIL.getUnixTime() + "." + fileExt, function ()
{
documentsView.reloadAvailableDocuments();
},
function (evt)
{
console.log (JSON.stringify(evt));
var anAlert = new PKUI.MESSAGE.Alert(__T("Oops!"), __T("Failed to save the video."));
anAlert.show();
} )
},
function (msg)
{
var anAlert = new PKUI.MESSAGE.Alert(__T("Oops!"), msg);
anAlert.show();
},
{ destinationType: Camera.DestinationType.FILE_URI,
sourceType: Camera.PictureSourceType.PHOTOLIBRARY,
mediaType: Camera.MediaType.VIDEO,
saveToPhotoAlbum: false
}
);
}
大部分代码与之前项目中 doPicture() 的代码相同。唯一的真正区别是文件扩展名和 mediaType 选项的处理。注意我们传递了 Camera.MediaType.VIDEO。这确保了我们只会得到视频。
一个要注意的问题:这似乎在 Android 上工作得不太好。我们还没有在应用程序中禁用它(以防你运气更好),但你可能希望在 Android 应用程序中禁用导入功能。然而,在 iOS 上,它工作得相当好。
我们做了什么?
在这个任务中,我们实现了录制视频和导入视频的代码。
实现视频播放
播放视频是一个非常重要的功能,尤其是如果我们正在录制它,对吧?即使应用程序不支持视频录制,视频播放也可以是必不可少的。考虑一个电子学习类型的应用程序;阅读主题对很多人来说效果很好,但真正看到主题的实际操作可以帮助更多。视频将是这种学习类型的绝佳平台。
准备工作
如果你想跟上来,我们将会在 www/views 目录下的 documentsView.html 和 movieView.html 中工作。
继续前进
首先,有一个问题。在 iOS 设备上播放视频非常简单。在其他设备上播放视频,嗯,不那么简单。
对于 iOS,我们将使用电影视图,这与我们之前项目中的图像视图类似。大部分代码是重复的,所以我们只在这里讨论更改。
电影视图的模板部分看起来如下所示:
<div id="movieView_documentTemplate" class="hidden">
<video src="img/%SRC%" controls autoplay autobuffer style="width:100%; height: 100%;" />
</div>
相比之下,在之前的项目中,我们使用 IMG 标签来显示图片,而现在我们使用 VIDEO 标签来显示视频。这是 HTML 5 的一个特性,iOS 支持得非常好,因此,这使得我们很容易支持视频播放。
当文档视图调用 setMovie() 时,标签将用 %SRC% 替换视频文件名,这看起来如下所示:
movieView.setMovie = function ( moviePath, movieIndex )
{
movieView.moviePath = moviePath;
movieView.movieIndex = movieIndex;
$ge("movieView_contentArea").innerHTML =
PKUTIL.instanceOfTemplate($ge("movieView_documentTemplate"),
{ "src" : movieView.moviePath,
"thumb": PKUTIL.FILE.getPathPart ( moviePath ) +PKUTIL.FILE.getFileNamePart ( moviePath ) + ".jpg"});
}
它基本上与之前项目中的 setImage() 相同,尽管我们引入了一个 缩略图 部分,如果你想要显示一个用户必须点击才能播放电影的小缩略图,你可以使用它。
一旦电影视图被推入(由文档视图),并且调用了 setMovie(),前面的代码中的 VIDEO 标签将导致 iOS 设备上的视频立即开始播放。很可能会填充整个屏幕,这在所有移动平台上都很常见。平板电脑通常允许内联视频,但较小的设备通常尝试以全屏模式播放视频。
所以,正如我们之前所说的,iOS 很简单:HTML 5 视频得到了良好的支持,我们可以简单地显示它,无需太多麻烦。
哦,如果其他平台也这么好就好了。例如,Android 声称支持 VIDEO 标签,但它的实现非常糟糕,你找到真正能工作的设备的可能性很小。是的,控件会显示,但仅此而已。
我们该怎么办呢?我们使用另一个插件,这次是由 Simon MacDonald 编写的,他决定帮助 PhoneGap 社区,通过提供一个简单的视频播放器插件来提供帮助。(simonmacdonald.blogspot.com/2011/11/video-player-plugin-for-phonegap.html)
首先,你需要按照以下步骤将插件安装到你的 Java 项目中:
-
将
Android/VideoPlayer目录中的src目录复制到你的项目中。确保包含内容;在这个目录深处有一个名为VideoPlayer.java的文件,你需要将其包含在你的项目中。 -
将插件包中
Android/VideoPlayer/www目录下的video.js文件复制到项目中的www/plugins/Android目录。 -
将以下行添加到你的
index_android.html文件中:<script type="application/javascript" charset="utf-8" src="img/video.js"></script> -
将以下行添加到你的
/res/xml/config.xml文件中:<plugin name="VideoPlayer" value="com.phonegap.plugins.video.VideoPlayer"/> -
接下来,我们将修改
documentsView.html中的代码,以便在 Android 设备上使用此播放器播放视频。我们将在documentContainerTapped()方法中这样做:documentsView.documentContainerTapped = function(idx) { var theElement = $ge("documentsView_item"+ idx + "_canvas"); if (documentsView.inSelectionMode) { … this code is identical to the previous chapter … } else { if ( PKDEVICE.platform() != "android" ) { PKUI.CORE.pushView (movieView); PKUTIL.delay(500, function() { movieView.setMovie ( documentsView.availableDocuments.getDocumentAtIndex(idx).fullPath, idx ); } ); } else { window.plugins.videoPlayer.play( documentsView.availableDocuments.getDocumentAtIndex(idx).fullPath ); } } }
如果我们不是 Android 平台,我们将尝试使用电影视图来播放视频,但如果我们在 Android 上,我们将使用前面代码片段中突出显示的代码,该代码要求视频播放器插件播放所需的视频。当被要求时,视频将立即全屏播放。这意味着 Android 设备将永远不会显示电影视图,但视图内可用的操作(删除和分享)在文档视图中也是可用的,所以这并不是一个很大的损失。
我们做了什么?
在这个任务中,我们使用了 HTML 5 的VIDEO标签来在支持它的设备上播放视频,我们还学习了如何使用 Simon MacDonald 创建的视频播放器插件在 Android 设备上播放视频。
游戏结束..... 结束
让我们看看我们最终得到了什么;首先对于 iOS 视图将如下所示:

对于 Android 视图将如下所示:

如果你看看我们的最终应用,它与我们的前一个项目的应用非常相似。是的,我们改变了一些图形,但从视觉上看,它们几乎是相同的,从代码的角度来看,它们几乎是相同的。我们替换了仅与图像相关的部分,并用处理视频的部分替换了它们。我们使用了 HTML 5 的VIDEO标签,并使用原生代码编写了自己的插件。你现在应该能够将你所学的应用到自己的应用中,以便录制和播放视频文件。
你能承受热度吗?热手挑战
你可以通过几种方式进一步改进这个应用。你愿意挑战自己尝试几种吗?
-
通过分享按钮添加上传视频文件到社交网络的功能。
-
允许用户一次性录制多个视频,并相应地处理每个视频。
-
将整个应用程序转变为一个电子学习风格的 APP,其中视频不是由用户录制的,而是嵌入到应用程序中。然后允许用户观看您的视频,以便他们可以了解特定主题。
第八章。玩玩
智能手机并不陌生于那些帮助消磨时间的有趣小游戏。从看似永恒存在的纸牌游戏到蛇、俄罗斯方块或泡泡龙变体,我们找到了用我们的移动设备消磨时间的方法。即使你几乎总是编写生产性应用程序,迟早,编写游戏的“虫子”很可能会咬你。
我们要构建什么?
在这个项目中,我们将组合一个名为 Cave Runner 的游戏。好吧,它不会因为游戏(或标题)的原创性而赢得任何奖项,也不会赢得 年度最佳游戏。但它很有趣,有很多扩展的潜力,因此是一个很好的基础,特别是对于许多游戏试图适应的快速娱乐类别。
它能做什么?
为了实现这一点,我们将严重依赖 HTML5 Canvas,这是我们实现接近 60 fps(大多数游戏的目标)的唯一方式。即便如此,只有最近且功能强大的设备才能达到这个目标,因此我们还需要在如何创建不依赖于帧率的游戏方面进行数学上的研究。如果游戏的计时完全依赖于帧率,30 fps 会感觉像是在泥潭中挣扎,也就是说,游戏会感觉像是在慢动作中前进。相反,我们必须表现得像是在以 60 fps 的速度奔跑,即使我们无法显示那么多帧,这样我们就可以避免这种效果。
在控制控制台、便携式游戏机或 PC 上的游戏角色时,控制游戏角色在可能没有这些功能的移动设备上可能很明显(键盘、鼠标、D-pad、摇杆等),那么如何在移动设备上控制游戏角色呢?有两种答案:使用多触控屏幕,可以用来模拟摇杆或 D-pad,或者使用设备的内置加速度计。我们将在本任务中讨论使用这两种方法。
这就引出了最后一项重要的事情;实现起来并不难,但绝对有必要——持久 设置。如果我们打算提供两种控制方法,我们需要一种方法来保存用户偏好的方法。虽然我们在以前的项目中使用了 File API 来存储持久内容,但这次我们将使用 localStorage。毕竟,我们只存储一个简单的标志,而不是大量的用户生成内容。
为什么它很棒?
希望你能从目前这个游戏中获得一些乐趣,但即使作为一个简单的游戏,它也介绍了你将来创建复杂游戏所需的概念。我们将努力保持游戏以相同的速度进行,无论帧率如何。我们将讨论使用 localStorage 的持久化设置。我们还将研究如何使用触摸屏和加速度计来控制游戏。所有这些因素结合起来,创造了一个好游戏,你应该有一个很好的基础,可以在此基础上进行任何未来的努力。
我们将如何做到这一点?
我们将像以前的项目一样处理这个问题:
-
设计游戏
-
实现选项视图
-
生成水平
-
在画布上绘制
-
保持进度
-
执行更新
-
处理基于触摸的输入
-
处理加速度计
我需要什么来开始?
继续创建你的项目,或者使用本书代码包中的项目作为起点。你将需要使用www/images中的图片。如果你想了解我们如何设计图形资产,请随意查看本项目代码包中的/resources目录。
在一般情况下,我们将更多地讨论已经编写的代码,而不是逐字拼写代码。因此,最好下载项目,以便你有一个代码作为参考。然后,为你的设备编译它,并与之互动,以更好地了解我们将要讨论的内容。
设计游戏
在以前的项目中,我们会开发用户界面以及各种小部件和视图之间的交互,而我们将设计游戏的外观和动作。虽然相似,但设计游戏(图形资产、水平设计、角色设计、动画等等)需要投入更多的工作。不幸的是,鉴于项目的长度,我们无法涵盖所有内容,但我们可以给你一个好的开始。
继续前进
游戏的主要主题可能已经从标题“洞穴跑者”中显而易见。这类游戏自第一台计算机问世以来就存在了,即使图形质量有点粗糙。简而言之,我们将开发一个游戏,玩家(控制一艘飞船)必须安全地通过一系列水平才能前进。每个水平都比前一个更难,在我们的特定版本中,只要玩家能够跟上,水平就没有尽头。实际上,总会有一个点,玩家无法安全地通过给定的水平,因此游戏总是以崩溃结束。把它想象成一场我们已经知道结果的耐力赛跑——重点是旅程。
水平由一个类似洞穴的结构组成,屏幕两侧都有墙壁。这些墙壁是不规则和随机的,共同形成一条安全的路径供飞船通过。如果飞船触碰到边缘,游戏就结束了。

为了使事情变得稍微困难一些,有一些障碍物会阻碍飞船的行进。在最初的水平中,它们并不经常出现,但随着水平的难度增加,障碍物出现的频率也会增加。障碍物看起来像有一个开口的墙,飞船必须通过开口才能安全通过。
我们的水平将根据某些参数随机生成,以创建一个不断变化的景观。尽管我们的水平是随机的,但你完全可以轻松创建静态水平并在需要的地方加载它们,这是我们建议在项目结束时做的事情。
我们的飞船将非常简单:一个三角形。是的,可以通过动画等方式使其更加复杂,但考虑到我们游戏的简单视觉风格,它很好地满足了我们的需求。
为了移动飞船,玩家有两个选择:触摸或滑动屏幕以控制飞船,或者倾斜设备。飞船将根据滑动或倾斜的方向移动,也就是说,向左倾斜或滑动将使飞船向左移动,反之亦然。由于我们称我们的角色为 飞船,我们故意在移动中引入了一些模糊的机制。换句话说,飞船不会立即响应,也不会立即停止。想象一下,飞船上好像有推进器。
当然,我们本可以决定飞船的位置直接与屏幕上手指的位置或倾斜度相关,对于某些游戏来说这可能很合适。始终重要的是要认识到你应该根据你的游戏调整你的控制机制,并使用合理的方法。
我们的游戏本身将包含在一个视图中——游戏视图。游戏之外将存在起始视图和选项视图。起始视图包含两个按钮,即 玩 和 选项。点击 玩 将切换到游戏视图,而 选项 按钮将切换到选项视图。
选项视图提供了两种控制移动的标志性表示:一种用于倾斜设备,另一种用于在屏幕上滑动手指。点击这两个元素中的任何一个将选择该方法作为控制方法。一个额外的 返回 按钮让用户返回到起始视图。
在游戏视图中,我们有几个需要显示的项目。当然,级别和飞船是必需的,但游戏通常会显示其他信息。在我们的案例中,我们将显示当前级别和通过级别的距离。如果我们需要显示一条消息(例如 碰撞! 或 关卡完成),我们将在屏幕中间显示它,并附带两个按钮:一个用于重新开始或继续,具体取决于情况,另一个用于返回游戏视图。
实际上就是这样,这并不是一个复杂的游戏,但它可以为未来更复杂的尝试提供一个基础。
在我们完成这个任务之前,让我们快速看一下我们的图形资源。我们的启动画面看起来是这样的:

我们的控制图标将看起来像这样:

我们在整个游戏中使用的按钮不需要任何图形资源。我们只需使用带有边框和阴影的圆角矩形,这可以通过 CSS 实现。
我们做了什么?
在这个任务中,我们设计了游戏机制和资源。我们也确定了所需的视图。
我还需要了解什么?
游戏设计绝不是这么简单。我们可以快速浏览这个特定的游戏,部分原因是因为它既简单,机制也众所周知。即使是稍微复杂一些的游戏,也需要花费大量的时间去设计,最好在写任何代码之前就完成。确定你的视觉风格、声音风格、游戏机制、控制机制、关卡和动画。所有这些都需要时间,并且需要大量的纸张。
实现选项视图
开始视图是一个简单的视图,我们不会过多地讨论;您可以自由地查看www/views/startView.html中的代码。在这个任务中,我们将关注位于www/views/optionsView.html的选项视图。它比开始视图稍微复杂一些,所以一些代码非常相似。
完成后,我们将得到以下截图所示的内容:

开始实施
让我们先看看视图的 HTML 代码:
<div class="viewBackground">
<div id="optionsView_contentArea" style="padding: 0; height: auto; position: relative;"></div>
</div>
第一部分非常简单;实际内容在一个模板中,我们将对其进行本地化处理:
<div id="optionsView_actions" class="hidden">
<div id="optionsView_changeControls">
<div id="optionsView_tilt" ontouchend="optionsView.selectTilt();" ><img src="img/tilt.png" width=128 height=128>
%TILT%
</div>
<div id="optionsView_slide" ontouchend="optionsView.selectSlide();"><img src="img/slide.png" width=128 height=128>
%SLIDE%
</div>
</div>
<div id="optionsView_backButton" ontouchend="PKUI.CORE.popView();" >%BACK%</div>
</div>
在这段代码中,我们定义了两种控制方法。触摸optionsView_tilt或optionsView_slide图标将调用选择该控制方法的方法。返回按钮将弹出视图并返回到开始视图。
注意,我们在这里没有定义任何样式;样式位于www/styles/style.css。以下是我们的视图使用的样式:
#startView_contentArea,
#optionsView_contentArea
{
background-image: url('../images/splash.png') !important;
height: 100% !important;
}
我们为开始视图和选项视图(但不是游戏视图)使用背景图像,我们在前面的代码中定义了它。这将在我们的控件后面放置一个漂亮的图像,因此我们需要小心地放置按钮,以免覆盖任何关键文本或图形元素。
每个按钮的样式如下:
…
#optionsView_backButton,
…
{
position: absolute;
left: 50px;
width: 200px;
background-color: #8BF;
height: 2em;
font-size: 24pt;
line-height: 1.75em;
text-align: center;
color: white;
border: 4px solid white;
border-radius: 1em;
text-shadow: 0 1px 1px #000;
box-shadow: 0px 10px 20px #000, 1px 1px 1px #000 inset;
-webkit-transform: rotate(-12deg);
}
这给我们一个漂亮的圆角按钮,颜色鲜艳,如本节标题截图所示。它也稍微倾斜,这在游戏中通常是可以接受的。在生产力应用程序中这样做并不是一个好主意。
#optionsView_backButton
{
top: 300px;
left: 32px;
-webkit-transform: rotate(12deg);
}
对于每个按钮,我们必须指定按钮的位置,如果我们想的话,我们还可以覆盖旋转,使屏幕上的按钮以不同的角度旋转。
#optionsView_changeControls
{
position: absolute;
top: 120px;
left: 32px;
width: 256px;
}
对于我们的控制选择,我们首先指示图标将位于哪里,在前面的代码片段中,然后指定每个图标的属性如下:
#optionsView_tilt,
#optionsView_slide
{
width: 128px;
text-align: center;
font-family: "Bradley Hand",sans-serif;
font-size: 24pt;
color: #FFFF80;
text-shadow: 0px -1px 1px #000;
height: 160px;
float: left;
color: #FFFFFF;
}
在这种情况下,字体给我们一种手写的感受。请注意,我们提供了一个回退方案,以防设备不支持这种字体,这在 Android 设备上很可能是这种情况。所有最新的 iOS 设备都会自动提供这种字体。
#optionsView_tilt.selected,
#optionsView_slide.selected
{
color: #FFFF80;
background-color: rgba(255,255,255,0.25);
border-radius: 25px;
}
当选中时,我们改变文本的颜色,并使选中的项看起来被突出显示。
让我们来看看代码;它实际上非常简单:
var optionsView = $ge("optionsView") || {};
optionsView.initializeView = function()
{
$ge("optionsView_contentArea").innerHTML =
PKUTIL.instanceOfTemplate ( $ge("optionsView_actions"),
{ "tilt": __T("TILT"),
"slide": __T("SLIDE"),
"back": __T("BACK")
}
);
optionsView.displayControlSetting();
}
和往常一样,我们的initializeView方法用于设置视图并执行必要的本地化。它还会调用displayControlSetting,这将突出显示适当的方法:
optionsView.displayControlSetting = function ()
{
$ge("optionsView_slide").className="";
$ge("optionsView_tilt").className="";
if (localStorage.controlSetting)
{
// use the saved setting
if (localStorage.controlSetting == "slide")
{
$ge("optionsView_slide").className="selected";
}
else
{
$ge("optionsView_tilt").className="selected";
}
}
else
{
// default to the slide control option
$ge("optionsView_slide").className="selected";
}
}
注意,我们在这里使用的是localStorage;它几乎微妙到你可以错过它。首先,我们检查我们想要的属性(controlSetting)是否存在。如果不存在,我们将默认使用滑动控制方法。如果存在,我们将使用属性中存储的任何值。
虽然localStorage并不保证 100%持久(iOS 设备空间不足时可以选择删除),但对于这类设置来说已经足够好了。
当用户点击其中一个控制方法时,我们将localStorage.controlSetting设置为以下内容:
optionsView.selectTilt = function ()
{
localStorage.controlSetting = "tilt";
optionsView.displayControlSetting();
}
optionsView.selectSlide = function ()
{
localStorage.controlSetting = "slide";
optionsView.displayControlSetting();
}
从这个点开始,代码就像我们之前使用的那样,所以这里不会重新打印它。
我们做了什么?
在这个任务中,我们创建了我们的选项视图。我们为用户提供了选择他们的角色是使用倾斜还是滑动控制方式的能力,并且我们使用了localStorage来保存和读取用户的偏好。
我还需要了解什么?
如果用户看到这个屏幕并决定滑动是他们想要的控制方法呢?这意味着我们从未在localStorage中设置任何属性。这意味着当游戏开始时,它将如何知道使用哪种控制方法?
简单,我们也会在那里进行检查。如果localStorage中没有内容,我们将假设用户想要使用滑动方法。这里的关键是要保持一致性;如果游戏决定使用倾斜方法,但在我们的选项视图中显示滑动作为默认选项,玩家显然会困惑于哪个选项代表什么。
生成关卡
想象一个没有至少一个关卡的游戏是很难的,而且这个关卡需要包含一些内容。在这个任务中,我们将探讨如何为游戏中的关卡生成内容。
准备就绪
在www/views中打开gameView.html文件。我们将相当频繁地使用这个文件,所以最好将其打开以供参考。
继续前进
有几种方法可以生成关卡。可以使用随机内容、伪随机内容或静态内容。第一种很简单:只需为所有内容使用随机数字。不幸的是,这通常不会产生非常好的关卡,而且很难保证可赢性和难度。
第三种方法也很简单:使用静态内容。这意味着你在游戏开始之前就已经确定了整个关卡,并将其存储在文件中。当游戏请求关卡时,可以读取它。这意味着每次都是相同的,这可以是好事(或坏事),取决于游戏,但它也意味着你有明确的方式来确保可赢性和难度。对于解谜游戏,这种方法几乎总是首选。
我们的方法是伪随机。我们将使用大量的随机数;我们不希望洞穴墙壁是完美的直线或容易被猜到的路径。但我们还希望随着时间的推移在难度上增加一些层次,以及将级别限制在几个参数内,以帮助确保(尽管不能保证)可玩性。通过足够的代码可以保证级别可玩,但在这个游戏中我们不会走那么远。
让我们通过以下代码片段来查看用于生成级别的代码:
function generateLevel ( lvl )
{
points = new Array();
points[0] = new Array();
points[1] = new Array();
points[2] = new Array();
points[3] = new Array();
首先,我们初始化我们的points[]数组,然后在其中定义四个数组。前两个数组包含构成洞穴墙壁的左右点。最后两个数组包含任何障碍物开口的边缘,如果没有障碍物,则为-1。
…
var lastLeft= (cWidth/5) ;
var lastRight=(cWidth/5) ;
接下来,我们开始定义一些我们将使用来控制我们级别的变量。这两个变量存储洞穴生成的最后几个点,但我们需要有一个起点。cWidth在文件中之前被定义为屏幕宽度,所以你可以看到这将在我们级别的开始时在屏幕中间生成一个开放区域。这很重要,因为我们不希望玩家遇到他们无法避免的即时障碍。
var bias = 0;
bias控制着我们的洞穴墙壁倾向于哪个方向。它们仍然会被随机生成,但每当墙壁碰到屏幕边缘时,我们就引入bias,以确保始终有一些移动。
var rndWidth = Math.floor(cWidth/ 10) + (lvl*10);
rndWidth控制我们的洞穴墙壁在特定距离内可以变化的程度。在这种情况下,它由屏幕宽度和我们当前的级别控制。这意味着随着我们通过级别,洞穴的导航会变得更加困难。
var channelWidth = Math.floor(cWidth / 2.25) - (lvl*16);
另一方面,channelWidth限制了洞穴墙壁可以靠近的程度。它也基于屏幕宽度和级别。你会在某些高级别时注意到通道太小,无法让飞船通过。在这种情况下,游戏可以被认为是结束了,或者也可以设计一种方法来防止这个值变得太小。
var wallChance = 0.75 - (lvl/25);
if (wallChance < 0.15) { wallChance = 0.15; };
障碍物或墙壁只会在某些时候生成;我们不希望障碍物在每个点都出现。因此,我们生成一种基于级别的机会。较简单的级别将有较少的障碍物,而较难的级别将有几个障碍物。
var wallEvery = Math.floor(30 - (lvl/2));
if (wallEvery < 10) { wallEvery = 10; };
wallEvery也考虑了障碍物出现的频率,但以不同的方式。它控制着在障碍物有机会生成之前必须有多少点。在这种情况下,我们将从 29 个点开始,但随着级别的增加,我们将稳步降低它。这意味着障碍物不仅会出现的更频繁,而且会更靠近。
for (var i=0; i< Math.floor(300 + ( 125 * (lvl/2) )); i++)
{
接下来,我们想要创建一个长度为几百点的洞穴。第一个级别将从 366 个点开始,并且只会从那里增加。
var newLeft = lastLeft + ( bias * (7+lvl) ) + ( (rndWidth/2) - Math.floor( Math.random()* (rndWidth+1) ) );
var newRight = lastRight + ( bias * (7+lvl) ) + ( (rndWidth/2) - Math.floor( Math.random()* (rndWidth+1) ) );
对于每个点,我们确定洞穴的左右两侧。我们基于前一个点,加入偏差(随着关卡的增加而增加),然后在我们允许的宽度内添加一个随机数,我们就有了会随机变化但不会变化太大的洞穴墙壁(至少在最初关卡中是这样)。
if ( newLeft < 10 ) { newLeft = 10; bias = 1; }
if ( newLeft > (cWidth/1.5) ) { newLeft = cWidth/1.5; bias = -1; }
if ( cWidth - newRight < newLeft + channelWidth )
{
newRight = cWidth - ( newLeft + (channelWidth) );
}
if ( cWidth - newRight > newLeft + (channelWidth*1.5))
{
newRight = newRight + (Math.random() * rndWidth);
}
if ( newRight < 10 ) { newRight = 10; }
if ( newRight > (cWidth-10)) { newRight = cWidth-10; }
当然,如果没有对两侧的一些限制,洞穴就有可能偏离屏幕,如果玩家看不到它,这对玩家没有任何好处。所以,我们保持洞穴在屏幕上。对于前两个限制,我们也影响了偏差;这通常会使得洞穴呈现出锯齿状的图案。
points[0].push ( newLeft );
points[1].push ( newRight );
lastLeft = newLeft;
lastRight = newRight;
最后,我们将点添加到数组中,并存储起来以供将来参考(循环的下一个迭代)。
if ( (i % wallEvery) == 0 && ( i > 30 ) )
{
接下来,我们确定是否需要放置障碍物。首先,我们只是偶尔检查一下(wallEvery),并且我们还限制任何障碍物出现在洞穴前 30 个点内。
if (Math.random()>wallChance)
{
接下来,我们决定在这个点是否会出现墙壁;这使障碍物在早期阶段相当罕见,但在后面的关卡中会逐渐增加。
var openingWidth = channelWidth/1.35;
var caveWidth = ((cWidth-newRight) - newLeft) - openingWidth;
var wallOpening = Math.floor ( Math.random() * caveWidth );
points[2].push ( newLeft + wallOpening );
points[3].push ( newLeft + wallOpening + openingWidth );
对于障碍物,我们创建一个比洞穴宽度小的开口;这意味着墙壁会从洞穴中突出一定角度。然后我们确定洞穴开口范围内的一个随机值,这就是开口将出现的位置。
}
else
// no wall
points[2].push ( -1 );
points[3].push ( -1 );
}
}
else
// no wall
points[2].push ( -1 );
points[3].push ( -1 );
}
}
}
如果没有障碍物,我们推送-1;这样我们就可以知道在任意给定点是否有障碍物(或者没有)。
就这样!这将创建一个又长又弯的洞穴,随着关卡级别的提高,它将变得更加危险。
我们做了什么?
在这个任务中,我们根据伪随机生成和一些特定的规则为我们的游戏生成了关卡,这些规则使得关卡在游戏过程中难度逐渐增加。
我还需要了解什么?
当然,生成伪随机关卡的方法不止一种。还有很多方法超出了这本书的范围,而且你还可以做一些事情来确保关卡始终可赢。关卡生成是一个独立的主题(在这个领域有许多许多聪明的人在工作),所以很快你就能被一些关卡生成技术所震撼。参见en.wikipedia.org/wiki/Procedural_generation了解一些游戏如何通过程序生成关卡以及一些描述如何通过程序生成关卡的链接。请记住,这非常具体地取决于你正在开发的游戏类型。
将绘图绘制到画布上
当然,如果我们不向玩家展示生成的关卡,那么生成关卡就没有任何意义。这正是我们将在这个任务中做的。
继续进行
首先,我们设置了画布,在这个过程中也注意到了处理视网膜屏幕。
var c = $ge("gameView_canvas");
var ctx = c.getContext("2d");
c.setAttribute ("width", cWidth * window.devicePixelRatio);
c.setAttribute ("height",cHeight * window.devicePixelRatio);
c.setAttribute ("left", (screen.width/2) - (cWidth/2));
c.style.width = ""+cWidth+"px";
c.style.height = ""+cHeight+"px";
我们将这些存储为全局变量,因为对于每一帧的内容,我们都不需要做 DOM 遍历——这只会减慢我们的速度——当我们试图在 16 毫秒内渲染一帧时(如果我们目标是 60 fps,我们能够承受的最大时间),这是一件坏事。
function doAnim(timestamp)
{
…
ctx.save();
ctx.scale (window.devicePixelRatio, window.devicePixelRatio);
ctx.fillStyle = "#802010";
ctx.strokeStyle = "#A04020";
ctx.clearRect ( 0, 0, cWidth, cHeight);
接下来,我们设置一些属性,然后清除画布。对于每一帧清除画布是至关重要的;否则,你会在后面留下前帧的幽灵。
然后我们使用循环绘制洞穴的两侧:
for (var i=0; i<2; i++)
{
var pts;
var cLeft = -10;
if (i==0) { pts = points[0]; }
if (i==1) { pts = points[1]; cLeft = cWidth+10; }
根据我们正在绘制的洞穴部分,我们将points[]的0或1索引分配给另一个变量pts。这让我们避免了需要双重索引点数组,如points[i][x],而是使用pts[x]。我们还定义了屏幕外的墙壁的左侧(或右侧);这对于绘制洞穴很有用,因为我们需要填充洞穴墙壁以使其成为实心。这意味着我们实际上是在绘制一个大矩形,其中一部分非常粗糙;粗糙的一侧是洞穴墙壁。
ctx.beginPath();
ctx.moveTo ( cLeft, -pieceWidth );
我们开始路径,然后移动到画布的最左上角;实际上,离它相当远。这确保玩家永远不会看到我们正在绘制的矩形大框的边缘。
for (var j = Math.floor ( currentTop / pieceWidth )-1;
j < Math.floor ( currentTop / pieceWidth ) + ( (cHeight+(2*pieceWidth)) / pieceWidth );
j++)
{
接下来,我们遍历数组中的每个点,但只遍历必要的点。如果玩家已经完成了关卡的一半,就没有必要绘制前面的点,也没有必要绘制屏幕高度之外的洞穴部分。
var p = pts[j];
var y = (j * pieceWidth) - currentTop;
为了保持洞穴运动的平滑,我们将当前块乘以块宽度,并从关卡中的当前位置减去。技术上,这会在第一个和最后一个绘制的索引处产生跳跃视图,但我们在两边都绘制了一些点,所以任何跳跃都会保持在屏幕之外。
if (i==1) { p = cWidth - p; }
ctx.lineTo ( p, y );
接下来,我们画一条线到给定的点。如果我们正在处理右侧,那么这个点位于屏幕右侧边缘的左侧,也就是洞穴的右侧。
if ( points[2][j] > -1 )
{
ctx.lineTo ( points[i+2][j], y );
ctx.lineTo ( points[i+2][j], y+pieceWidth );
}
如果我们有障碍物要显示,我们也会画一条线到那个点,然后画一条pieceWidth高的垂直线。这将使障碍物看起来像有一个开口的墙壁。
}
ctx.lineTo ( cLeft, ((cWidth+2)*pieceWidth) );
ctx.closePath();
ctx.fill();
ctx.stroke();
}
最后,我们再次在屏幕外画一条线,然后继续填充和描边路径。玩家只会看到洞穴墙壁的粗糙边缘。
ctx.strokeStyle = "#FFFFFF";
ctx.beginPath();
ctx.moveTo ( shipPositionX-10, shipPositionY-5 );
ctx.lineTo ( shipPositionX+10, shipPositionY-5 );
ctx.lineTo ( shipPositionX , shipPositionY+25 );
ctx.lineTo ( shipPositionX-10, shipPositionY-5 );
ctx.closePath();
ctx.stroke();
在我们绘制洞穴墙壁之后,我们需要绘制飞船。在这种情况下,我们绘制一个简单的三角形。
ctx.fillStyle = "#FFFF00";
ctx.font = "16px Helvetica";
ctx.fillText ( "Level: " + currentLevel, 10, 30 );
ctx.fillText ( "Distance: " + Math.floor((currentTop / (points[0].length*pieceWidth))*100) + "%", 10, 48 );
通常,大多数游戏需要显示一些文本(例如得分),所以我们也做了类似的事情。我们显示当前关卡和通过关卡的距离。
if (amTouching)
{
ctx.fillStyle = "rgba(255,255,255,0.25)";
ctx.beginPath();
ctx.arc ( lastTouchX, 400, 50, 0, 2*Math.PI, false );
ctx.closePath();
ctx.fill();
}
ctx.restore();
…
}
最后,如果用户的控制方法是滑动,我们会在用户触摸的地方显示一个半透明的圆圈(假设它在屏幕底部),这样用户就知道触摸已经被记录了。
请记住,在绘制帧时,我们需要快速,幸运的是,我们刚才看到的操作依赖于硬件;我们可以在几毫秒内完成一帧。如果我们保持在 17 毫秒以下,我们几乎可以达到 60 fps,尽管在较旧的硬件上,这可能是 20-30 毫秒。所以我们需要做其他事情,我们将在下一节中讨论,那就是丢弃帧,以便游戏不会感觉太迟缓。
我们做了什么?
在这个任务中,我们在画布上绘制了关卡,显示了飞船,并在画布上放置了各种文本。我们还保持了快速,这是通过使用canvas标签实现的。
我还需要了解什么?
当然,前面的代码只绘制了一帧;我们需要多次调用它才能实现流畅的滚动。有两种方法可以实现这一点:我们可以使用setTimeout或者使用requestAnimationFrame。后者更受欢迎,因为它比setTimeout具有更好的分辨率,但还不是所有移动浏览器都支持。就我们的目的而言,我们使用setTimeout。它在doUpdate中调用,我们稍后会讨论,而不是doAnim。
保持同步
即使我们不能达到 60 fps,我们也需要表现得像是达到了 60 fps。如果我们不这样做,任何减速都会使游戏感觉像是在慢动作中。相反,我们需要丢弃帧,并像达到 60 fps 一样继续前进。显示可能不会那么平滑,但直到帧率真的降得很低,游戏体验才不会变得那么慢。
继续前进
如果你在上一个任务中仔细观察我们的代码,你会注意到我们跳过了一些行。这些行对于使游戏以 60 fps 的速度进行至关重要:
var startTime;
function doAnim(timestamp)
{
if (!timestamp) {
timestamp = (new Date()).getTime();
}
var diff = timestamp - startTime;
…
doUpdate ( 60/(1000/diff) );
startTime = timestamp;
}
我们所做的一切只是测量帧之间的时间,然后对它应用一些数学运算。然后我们将其传递给doUpdate,我们稍后会讨论。这个数字等同于在给定时间段内应该通过的帧数。如果我们是 60 fps,我们总是会得到这个数字为 1;但如果我们是 30 fps,这个数字将是 2;在 15 fps 时将是 4,以此类推。由于这个数字可以是分数,我们可以在更新游戏时非常细致。
我们做了什么?
所以,这很简单,但并不是一个特别复杂的概念。但它非常重要;如果没有它,如果设备因为任何原因变慢,游戏也会变慢,给人一种慢动作的感觉。相反,我们将丢弃帧以保持速度。它可能不会那么平滑,但游戏体验应该始终优于每帧都显示。
执行更新
当然,反复绘制相同的帧是没有用的。我们还需要更新游戏。这包括响应用户输入、检查碰撞以及沿着洞穴移动。
继续前进
我们将在...猜猜看!doUpdate中进行所有更新:
function doUpdate ( f )
{
如果你还记得上一个任务,f是输入帧乘数。我们希望它是 1,但如果我们的帧率是 30,它可能是 2,如果是 15,它可能是 4。我们将在多个地方使用这个值来乘以任何更新,以便让一切看起来像是在 60 帧下运行,即使我们实际上不是。
var gameOver = false;
var levelOver = false;
var pixels = ctx.getImageData(Math.floor(shipPositionX * window.devicePixelRatio),
Math.floor(shipPositionY * window.devicePixelRatio),1,1).data;
有多种进行碰撞检测的方法。我们可以通过数学方法来确定,但这会变得相当痛苦。相反,我们将使用画布自己的数据,并执行基于像素的碰撞检测。
前一行从画布请求的数据非常少。实际上,我们只是在检查飞船的中心点是否有碰撞。这样做的一部分原因是为了对用户宽容一些,另一部分原因是懒惰,但还有一部分原因是基于画布的像素碰撞检测非常慢。实际上,仅仅检查一个像素就能将我们的帧率几乎减半。
if ( pixels[0] != 0 )
{
$ge("gameView_nextButton").innerHTML = __T("START_OVER");
showMessage (__T('CRASHED'));
gameOver = true;
currentLevel = 0;
}
如果从画布返回的数据中的像素数据不是零(黑色),那么我们知道我们已经撞击了某个东西;我们撞击的物体不重要。我们标记游戏结束,并通知用户。
if (f > 0 && f != Infinity)
{
有时f会是0或Infinity。这通常发生在游戏开始时,我们必须传递一个时间差,但实际上并没有。在这种情况下,doAnim中的除法将返回infinity。我们不想在两种情况下做任何事情,所以我们确保只有在f是合理值时才进行操作。
if (controlMethod == 0)
{
if (buttonDown != 0)
{
if (Math.abs(shipAcceleration)<1)
{ shipAcceleration = buttonDown; }
shipAcceleration = Math.min ( 10, shipAcceleration + ( buttonDown * deviceFactor) );
//shipAcceleration = buttonDown * 3;
}
else
{
shipAcceleration = shipAcceleration / 1.5;
if (Math.abs(shipAcceleration)<0.25)
{ shipAcceleration = 0; }
}
shipPositionX += (shipAcceleration*f);
}
buttonDown反映了用户使用触摸控制时的输入。如果他们向左滑动,buttonDown将是负数。如果他们向右移动,buttonDown将是正数。如果他们什么都没做,它将是零。如果他们使用倾斜控制,我们将以不同的方式计算这个值,但将在下一节中展示。
如果我们非零,我们会积累一些加速度,就像飞船上有推进器一样。由于推进器不能立即反应,飞船需要一点时间来反应。这会使游戏变得更难;玩家必须考虑到飞船的反应时间。
如果我们为零,我们会减少加速度,就像飞船在减速一样。一旦我们达到某个阈值,我们就完全停止飞船,但在那之前,仍然有一些移动。再次强调,这增加了难度,因为移动飞船时必须考虑这一点。
在前面的代码片段中,一个重要的变量是deviceFactor。这是主观的;当飞船在 Android 设备上移动感觉合适时,在 iOS 设备上感觉太慢,因此这个变量对此差异进行了一些补偿。永远不要害怕在不同的设备上调整移动机制,以便感觉相同,即使技术上并不相同。
注意,我们通过f乘以加速度;这样可以使飞船的运动看起来就像是在一个每秒 60 帧的游戏中发生。
var speed = ((4+currentLevel) * (f));
currentTop+= speed;
}
接下来,我们计算通过洞穴的垂直距离,这是通过向currentTop添加一个数字来完成的。我们还根据当前级别缓慢调整它,因此级别越高,速度越快。再次,我们乘以f以保持事物感觉平滑。
if ( Math.floor (currentTop/pieceWidth) > points[0].length )
{
$ge("gameView_nextButton").innerHTML = __T("CONTINUE");
showMessage (__T('NEXT_LEVEL'));
levelOver = true;
}
如果玩家设法完成了整个关卡,我们需要停止游戏并告诉用户他们成功了。当他们继续时,他们将开始下一个更高级别的游戏。
if (!gameOver && !levelOver)
{
timer = setTimeout ( doAnim,17);
}
}
这是我们的setTimeout,它使一切继续进行。注意,除非游戏结束或关卡结束,否则我们会这样做。如果有人想添加暂停功能,也会避免在这个时候设置定时器。
这里的17是为了尽可能接近 60 fps。在现实中,这并不总是能达到,因为浏览器的分辨率并不好,所以下一帧可能需要 12 毫秒或 30 毫秒。幸运的是,WebKit 大约有 4 毫秒的分辨率,所以它不太可能偏离17太多,因此我们可以达到 56 fps,假设设备是现代的。
我们做了什么?
在这个任务中,我们处理了船只位置的更新,洞穴中的位置,以及我们是否发生碰撞或完成关卡。
我还需要了解什么?
嗯,基于像素的碰撞检测是一种相当懒惰的方法。在几乎所有情况下,基于数学的碰撞检测都是更好的(并且更快的)方法。关于如何进行良好的碰撞检测有很多好的资料,但这超出了这个项目的范围。你可以从en.wikipedia.org/wiki/Collision_detection开始了解更多信息。
如果不是因为请求画布数据中的一个像素几乎会减半我们的帧率,我们的游戏不会这么糟糕。我怀疑这与必须将数据从 GPU 传输到 CPU 进行处理的操作有关,但这只是猜测。即使如此,这也有些痛苦,如果我在编写碰撞检测例程时喝更多的咖啡,我可能会选择数学方法。
处理基于触摸的输入
由于我们没有物理键盘、摇杆或 D-pad,我们不得不在屏幕上模拟一个。我们可以用屏幕上的两个按钮来为我们的游戏做这件事:一个向左移动,一个向右移动。事实上,游戏已经内置了这个功能,只是隐藏了。另一种方法是允许滑动可能发生的差异:当我们的船不在危险中时,进行缓慢移动;当我们需要紧急避开障碍物时,进行突然移动。另一种方法是将屏幕上的触摸位置与船只连接起来;本质上,我们的手指必须沿着洞穴的路径移动。对于较小的设备,这可能是可以的,但对于较大的设备,最好采用其他方法。
继续进行
在我们的游戏中,我们的触摸输入可以处理滑动(手指不总是在屏幕上)来使飞船进行短时间的移动,或者它可以处理长拖动,手指始终在屏幕上,轻微的移动就会产生移动。
简单来说:如果我们向左移动,飞船应该向左移动,反之亦然。然而,如果我们需要紧急避开障碍物,我们不需要移动很长的距离。我们应该能够快速移动一小段距离,因此我们也测量移动之间的距离,以便我们可以调整飞船的移动速度以匹配手指的移动速度。如果手指移动缓慢,飞船移动缓慢。如果手指移动得快,飞船就移动得非常快。
这一切都不难;事实上,最难的部分不是让它工作,而是让它工作得很好。很难让控制方法感觉完全自然,我不会在这里声称我已经掌握了它。这需要大量的测试才能得到一个恰到好处的控制机制。
让我们看看我们的代码:
// check to see if our control method has changed
if (!localStorage.controlSetting)
{
localStorage.controlSetting = "slide";
}
controlMethod = ( (localStorage.controlSetting) == "slide" ? 0 : 1 );
在游戏开始之前,我们检查用户选择的控制方法——记得选项视图。如果控制方法是slide,我们将事件附加到覆盖DIV上:
if (controlMethod == 0)
{
$ge("gameView_overlay").addEventListener ( "touchstart", canvasTouchStart );
$ge("gameView_overlay").addEventListener ( "touchmove", canvasTouchMove );
$ge("gameView_overlay").addEventListener ( "touchend", canvasTouchEnd );
}
这个覆盖层实际上覆盖了整个画布。您可能会想知道为什么我们必须使用覆盖层——结果是画布本身并不总是擅长处理触摸事件!
function canvasTouchStart (evt)
{
lastTouchX = evt.touches[0].pageX;
amTouching = true;
}
当手指触摸屏幕时,我们记录初始触摸,并告诉游戏有手指触摸屏幕。如果您还记得doUpdate,这最后一部分告诉游戏在触摸的 x 位置绘制一个半透明的圆圈,以给用户反馈。
function canvasTouchMove (evt)
{
if (touchTimer>-1) { clearTimeout(touchTimer); touchTimer = -1; }
var curTouchX = evt.touches[0].pageX;
var deltaX = curTouchX-lastTouchX;
if (Math.abs(deltaX)> 1)
{
buttonDown = ( (deltaX) / Math.abs(deltaX) ) / ( 8/Math.min(Math.abs(deltaX),8));
lastTouchX = curTouchX;
}
else
{
buttonDown = 0;
}
// if player stays in same spot, clear the button...
touchTimer = setTimeout ( function() { buttonDown = 0; }, 25 );
}
如果收到移动,我们需要计算最后 x 位置和新的 x 位置之间的距离。然后根据移动的方向给buttonDown赋予负值或正值。如果移动缓慢,我们还会将其除以;如果是一个快速的移动(超过五个像素),我们会有-1的左值和+1的右值,但缓慢的移动可能返回-0.2和+0.2。
如果手指移动的距离不大(需要移动超过一个像素才能将移动注册到飞船上),那么我们将buttonDown设置为0,这样飞船就会滑行到停止。我们还设置了一个计时器,在几毫秒后触发,将buttonDown设置为零。这是因为如果手指完全静止不动,我们不会收到touchMove事件,所以我们需要一种方法来捕捉这种情况。如果在计时器到期之前收到移动,我们将取消计时器,这样只要存在足够的移动,值就不会变成零。
function canvasTouchEnd (evt)
{
buttonDown = 0;
amTouching = false;
}
当手指抬起时,我们将立即允许飞船滑行到停止,并停止显示半透明的圆圈。
这不是处理移动的唯一方法,我强烈建议您尝试不同的处理基于触摸输入的方法。
我们做了什么?
在这个任务中,我们处理了基于触摸的输入,以便允许用户移动我们游戏中的飞船。
处理加速度计
为了响应设备的倾斜,我们需要使用设备的加速度计。这些并不是最容易处理的事情,我们的实现有点天真。不幸的是,不久你就会开始接触到相当复杂的数学,而这超出了这个项目的范围。
继续进行
基于加速度计的输入很困难——真的很困难。实际上,游戏对此并没有一个特别好的实现。你被鼓励尝试很多设备和算法,以找到一种好的控制方案。
要启用加速度计检查,我们首先必须为它设置一个监视器:
tiltWatch = navigator.accelerometer.watchAcceleration (
updateAccelerometer,
accelerometerError,
{ frequency: 40 } );
这设置了 40 毫秒的监视器——并不完全像我期望的那样快,但可以工作。每 40 毫秒,updateAccelerometer 方法将被调用。如果发生错误,则调用 accelerometerError。
监视加速度计需要一些努力,所以当完成它时,总是清除它是一个好主意:
navigator.accelerometer.clearWatch (tiltWatch);
tiltWatch = -1;
那么,加速度计能给我们带来什么呢?我们得到一个包含四个值的对象:一个时间戳,一个 x 值,一个 y 值和一个 z 值。这些值表示给定方向上的加速度。如果设备平放在桌子上,x 和 y 值将是零,而 z 值将等于重力。一般来说,我们可以假设 x 值对应于左右倾斜(假设设备是直立的),这是我们游戏所需要的。
function updateAccelerometer ( a )
{
if (amCalibrated)
{
var p = previousAccelerometer;
var avgX = (p.x * 0.7) + (a.x * 0.3);
previousAccelerometer = a;
previousAccelerometer.x = avgX;
}
当我们收到更新时,我们将新的输入和之前的输入应用加权平均值,给之前的输入更多的权重。结果证明,基于加速度计的输入真的很嘈杂,所以我们只给新的输入很小的权重。不幸的是,这有一个副作用,就是让移动感觉有点迟缓。分数值在船的响应能力上有很大的影响,但以船感觉抖动为代价。请随意尝试这些数字,但它们需要加起来等于一。
注意 amCalibrated 变量;你也可以将数据与校准值进行比较。校准值通常在关卡开始前不久获得,当时 amCalibrated 会被设置为 false。下一个加速度计更新将校准设备:
else
{
calibratedAccelerometer = a;
previousAccelerometer = a;
amCalibrated = true;
}
}
校准加速度计的一个更好的方法是从一系列输入中取平均值。就这个话题而言,这也是平滑加速度计本身的方法;但那些算法超出了这本书的范围。
现在,让我们回到 doUpdate 并查看针对基于倾斜输入的特定代码:
if (f > 0 && f != Infinity)
{
if (controlMethod == 0)
{
…
}
else
{
// calculate the position based on the acceleromter data
if (amCalibrated)
{
shipPositionX = (window.innerWidth / 2) - (previousAccelerometer.x * 32);
if ( shipPositionX < 0 )
{
shipPositionX = 0;
}
if ( shipPositionX > (window.innerWidth))
{
shipPositionX = window.innerWidth;
}
}
}
在这里,我们假设在没有倾斜的情况下,飞船应该在屏幕中间(即window.innerWidth / 2)。然后我们从最后一个加速度计样本的 x 值中减去该值,并将其乘以32。这个数字实际上相当随意,你可以在这里自由实验。我个人觉得32感觉恰到好处——不需要将设备倾斜到极限,但同时也需要足够的倾斜来使移动感觉足够明显。
剩余的行确保飞船不能离开屏幕的边缘。
我们做了什么?
在这个任务中,我们处理了基于加速度计的输入。
我还需要知道什么?
与加速度计一起工作并不简单。虽然我们的代码相当简单,但有很多复杂的算法旨在减少噪声,同时保持移动感觉不迟钝——这并不是我们真正实现的地方。
另一个选择是使用设备的陀螺仪。这些值不是基于加速度,而是基于设备本身的位置,尽管它们很嘈杂,但它们并不像加速度计值那么嘈杂。因此,它们可以更容易地处理。问题是只有 iOS 将这些暴露给浏览器。在 Android 上,需要编写一个插件来处理这种类型的传感器。此外,并非每个设备都有陀螺仪,因此你需要提供一个回退到加速度计的方案,以防万一。
游戏结束..... 结束
呼!这是一项很大的工作,但结果相当有趣。看看你在它变得太难之前能走多远。让我告诉你,我崩溃和失败得并不快。
最终结果将在以下页面显示。我没有包括 Android 截图,因为它们几乎完全相同。
启动屏幕将如下所示:

选项视图将如下所示:

在以下屏幕截图中可以看到一个不幸的崩溃:

你能承受高温吗?热手挑战
有很多种方法可以增强这款游戏。你为什么不尝试几种呢?
-
游戏目前缺少暂停选项;你为什么不添加一个呢?
-
我们的游戏在多任务处理方面比较简单。在恢复时,它会愉快地外推我们可能在洞穴中的位置,这可能已经过去很长时间了。更好的方法是在游戏处于后台时暂停游戏。
-
我们正在使用基于像素的碰撞检测。你为什么不尝试使用基于数学的检测呢?
-
尝试各种控制方案,直到找到你喜欢的。
-
如果可用,请使用陀螺仪值。
-
在地图中添加可以影响玩家的增强道具或其他物体。
-
为游戏制作静态关卡,这些关卡可以按需加载。
-
添加逻辑以确保任何关卡都是可赢的。
第九章。融入
CSS、HTML 和 JavaScript 可以让我们在构建感觉 99%原生的应用方面走得很远。也就是说,它几乎感觉原生,看起来几乎原生,行为上主要是原生。但如果你仔细观察,你可以看到那些告诉任何有丰富设备经验的用户,事情其实并不原生的小差异。为了帮助克服这一点,我们可以使用插件来融入我们的环境,这些插件使用真正的、纯正的原生组件。
我们将构建什么?
我们将重新审视项目 2 中的Socializer应用,让我们社交吧!它非常适合融入,我们可以在不担心太多改变应用本身的情况下轻松地添加我们的原生组件。当然,有一些小的变化,但总体来说,它的工作方式几乎相同,甚至在过程中增加了一些功能!
它有什么作用?
虽然我们将向应用添加一些功能,但此处的首要目标是使用原生组件。为此,我们将利用 PhoneGap 插件仓库中的几个优秀插件(github.com/phonegap/phonegap-plugins/tree/master/iOS)。正如 URL 所暗示的,我们只为 iOS 做这件事。不幸的是,这个仓库中为 Android 提供的原生插件并不多。你可以四处搜索,这里那里找到一些(以及一些非常好的),但到目前为止,我们将专注于 iOS。
事实上,我们将使用相当多的原生组件:导航栏、标签栏、操作表、消息框、选择器和电子邮件编辑器。哦,别忘了 ChildBrowser!
为什么它很棒?
这个项目将使我们更好地理解与多个插件交互,我们也将更接近原生外观和感觉。
我们将如何做到这一点?
这里是我们将要遵循的步骤:
-
安装插件
-
添加导航栏
-
添加标签栏
-
添加操作表
-
添加消息框
-
添加选择器
-
添加电子邮件编辑器
我需要准备什么来开始?
确保你已经下载了此项目的代码,这样你就可以跟上了。
安装插件
我们已经处理过一些插件了,从 ChildBrowser 到我们自己的插件。但这次,我们将添加很多。要安装插件,请打开(或创建)你的 Xcode 项目。
开始行动
当你下载了 PhoneGap 插件仓库后,你应该能够提取它并导航到其中的iOS文件夹。按照项目 2 中配置插件部分所述,安装 ChildBrowser 插件,让我们社交吧!。一旦完成,你需要进入以下每个目录并安装每个插件:
-
操作表-
将
ActionSheet.h和ActionSheet.m文件复制到 Xcode 的Plugins目录中。 -
使用 Finder 将
ActionSheet.js文件复制到www/plugins/iOS目录。
-
-
EmailComposer-
将
EmailComposer.h和EmailComposer.m文件复制到 Xcode 的Plugins目录中。 -
使用 Finder 将
EmailComposer.js文件复制到www/plugins/iOS目录。
-
-
MessageBox-
将
MessageBox.h和MessageBox.m文件复制到 Xcode 的Plugins目录中。 -
使用 Finder 将
MessageBox.js文件复制到www/plugins/iOS目录。
-
-
NavigationBar-
将
CDVNavigationBar.xib、CDVNavigationBarController.h、CDVNavigationBarController.m、NavigationBar.h和NavigationBar.m文件复制到 Xcode 的Plugins目录中。 -
使用 Finder 将
NavigationBar.js文件复制到www/plugins/iOS目录。
-
-
PickerView-
将
PickerView.h和PickerView.m文件复制到 Xcode 的Plugins目录中。 -
使用 Finder 将
PickerView.js文件复制到www/plugins/iOS目录。
-
-
TabBar-
将
TabBar.h和TabBar.m文件复制到 Xcode 的Plugins目录中。 -
使用 Finder 将
TabBar.js文件复制到www/plugins/iOS目录。
-
完成后,你应该在 Xcode 中看到如下所示的内容:

接下来,导航到 Resources 目录中的 Cordova.plist,并在 Plugins 部分添加以下键值对:
-
ActionSheet,String,ActionSheet -
NavigationBar,String,NavigationBar -
MessageBox,String,MessageBox -
TabBar,String,TabBar -
PickerView,String,PickerView -
EmailComposer,String,EmailComposer
结果应该看起来像以下截图:

接下来,将 script 标签添加到我们的 index.html 文件中:
<script type="application/javascript" charset="utf-8" src="img/ChildBrowser.js"></script>
<script type="application/javascript" charset="utf-8" src="img/NavigationBar.js"></script>
<script type="application/javascript" charset="utf-8" src="img/TabBar.js"></script>
<script type="application/javascript" charset="utf-8" src="img/MessageBox.js"></script>
<script type="application/javascript" charset="utf-8" src="img/ActionSheet.js"></script>
<script type="application/javascript" charset="utf-8" src="img/PickerView.js"></script>
<script type="application/javascript" charset="utf-8" src="img/EmailComposer.js"></script>
我们做了什么?
在本节中,你将我们项目将使用的所有插件添加到了 Xcode 中。
我还需要了解什么?
在编译过程中可能会遇到一些问题的几个文件——ActionSheet.h 和 PickerView.h。特别是,错误信息提到了无法找到 CDVPlugin.h。只需更改顶部的代码,直到它看起来像以下这样:
//#ifdef CORDOVA_FRAMEWORK
#import <CORDOVA/CDVPlugin.h>
//#else
//#import "CDVPlugin.h"
//#endif
添加导航栏
我们已经非常熟悉导航栏的概念了。我们已经在我们的一些项目中使用它作为 HTML 视图的顶部导航栏了。然而,这次,我们将移除它,并用原生导航栏替换它。以下是它的样子:

准备中
这将需要对 www/views 目录中的三个视图——startView.html、socialView.html 和 tweetView.html 进行一些小的修改。请打开这些文件以便跟随,你可能还想打开 项目 2 的版本,让我们社交!,以便查看发生了什么变化。
继续前进
首先,我们的 HTML 视图将要发生变化,因为我们需要移除我们自己的导航栏。
对于 startView.html:
<div class="viewBackground">
<div class="content" style="padding:0; overflow: scroll; -webkit-overflow-scrolling: touch;" id="startView_scroller">
<div class="content" id="startView_welcome">
</div>
</div>
</div>
对于 socialView.html:
<div class="viewBackground">
<div class="content" style="padding:0; overflow: scroll; -webkit-overflow-scrolling: touch;" id="socialView_scroller">
<div id="socialView_contentArea" style="padding: 0; height: auto; position: relative;">
</div>
</div>
</div>
对于 tweetView.html:
<div class="viewBackground">
<div class="content " style="padding:0; overflow:scroll; -webkit-overflow-scrolling: touch;" id="tweetView_scroller">
<div id="tweetView_contentArea" style="padding: 0; height: auto; position: relative;">
</div>
</div>
</div>
如果你将每个视图与 项目 2 进行比较,让我们社交! 每个视图中的导航栏和工具栏都已移除。我们还为每个视图添加了原生的 iOS 滚动 -webkit-overflow-scrolling: touch。
通过这样做,我们也移除了几个按钮:我们需要在导航栏中替换的按钮。但我们也需要移除对它们的旧引用,通常在每个视图的 initializeView() 方法中:
对于 startView.html:
startView.initializeView = function() {
var theWelcomeContent = $geLocale("startView_welcome");
$ge("startView_welcome").innerHTML = theWelcomeContent.innerHTML;
}
对于 socialView.html:
socialView.initializeView = function() {
PKUTIL.include(["./models/twitterStreams.js", "./models/twitterStream.js"], function() {
TWITTER.loadTwitterUsers(socialView.initializeToolbar);
});
}
对于 tweetView.html:
(the method is removed)
那么,如果我们不在 intializeView() 中初始化我们的按钮或导航栏,我们将在何时何地初始化它们?
首先,初始化部分实际上需要在 app.js 中发生。导航栏将跨越我们所有的视图,我们只能有一个。因此,我们需要在应用程序的非常开始处初始化它。所以,在 app.js 中的 APP.init() 方法(在 PKUI.CORE.initializeApplication 之后),我们添加以下代码片段:
window.addEventListener("resize", function() {
plugins.navigationBar.resize();
, false);
plugins.navigationBar.init();
plugins.navigationBar.create();
plugins.navigationBar.hideLeftButton();
plugins.navigationBar.hideRightButton();
plugins.navigationBar.setTitle(__T("APP_TITLE"));
plugins.navigationBar.show();
这将初始化并创建导航栏,隐藏其两个按钮,然后设置其标题。最后,我们在屏幕上显示它。
一旦我们这样做,我们就可以在我们的视图中做一些工作,以调整每个视图中的导航栏以满足我们的需求。
在 startView.html 中:
startView.viewDidAppear = function () {
plugins.navigationBar.hideLeftButton();
plugins.navigationBar.hideRightButton();
plugins.navigationBar.setTitle(__T("APP_TITLE"));
plugins.navigationBar.setupRightButton( __T("START"), null, startView.startApp);
plugins.navigationBar.showRightButton();
}
首先,我们隐藏按钮(以防万一有我们不想看到的可见按钮),然后将标题设置为应用程序的标题——Socializer2。然后,我们给右边的按钮一个标题为 Start,并将其链接到 startApp() 方法。最后,我们也显示了右边的按钮。
startView.viewWillHide = function ()
{
plugins.navigationBar.hideLeftButton();
plugins.navigationBar.hideRightButton();
plugins.navigationBar.setTitle("");
}
当视图即将隐藏时,我们基本上会清除整个导航栏,这样按钮就不会在下一个视图中挂在那里。
在 socialView.html 中:
socialView.viewDidAppear = function() {
plugins.navigationBar.hideLeftButton();
plugins.navigationBar.hideRightButton();
plugins.navigationBar.setTitle(socialView.currentTitle);
plugins.navigationBar.setupLeftButton( __T("BACK"), null, socialView.backButtonPressed);
plugins.navigationBar.setupRightButton(__T("#"), null, socialView.changeReturnCount);
plugins.navigationBar.showLeftButton();
plugins.navigationBar.showRightButton();
…
}
在这种情况下,我们将视图的标题设置为当前选定的 Twitter 账户,默认情况下将是第一个。我们在 socialView 中添加了 currentTitle,并在 loadStreamFor() 中设置它,以便我们可以跟踪它。
我们还在左侧添加了一个 Back 按钮,然后在右侧添加了一个 # 按钮。Back 按钮就像我们过去做的每一个返回按钮一样,所以它会带我们回到起始视图。# 按钮——这个按钮会很有趣,但我们稍后再说。
socialView.viewWillHide = function() {
plugins.navigationBar.hideLeftButton();
plugins.navigationBar.hideRightButton();
plugins.navigationBar.setTitle("");
…
}
再次,像好公民一样,我们清理自己的事情!
在 tweetView.html 中:
tweetView.viewDidAppear = function ()
{
plugins.navigationBar.hideLeftButton();
plugins.navigationBar.hideRightButton();
plugins.navigationBar.setupLeftButton( __T("BACK"), null, tweetView.backButtonPressed);
plugins.navigationBar.showLeftButton();
plugins.navigationBar.setupRightButton( __T("SHARE"), null, tweetView.share );
plugins.navigationBar.showRightButton();
}
好吧,这有点不同。你注意到有什么缺失的吗?没错,我们没有设置标题。那是因为我们实际上会在 loadTweet() 中设置它:
{ …
plugins.navigationBar.setTitle(theTweet.text);
… }
因为我们在加载推文视图时立即加载推文,这会导致导航栏标题设置为推文文本。
但如果推文文本太长怎么办?实际上,它太长的可能性真的很大。原生导航栏会高兴地将其截断并在末尾添加一个“…”,所以我们永远不必担心它是否会实际溢出其边界。
当然,我们会清理我们的工作,但再次打印相同的代码是没有意义的。
我们做了什么?
在这个任务中,我们添加了导航栏并修改了其标题,并与导航栏上的按钮进行了交互。
我还需要了解什么?
iOS 原生导航栏可以完成许多酷炫的功能,但插件并没有暴露所有这些酷炫特性(例如更改色调颜色)。因此,目前我们只能得到一个看起来相当普通的导航栏——当然不是我们在项目 2 中使用的颜色,让我们社交!你可以通过使用一些原生 Objective-C 代码来解决这个问题,但插件本身并没有提供其他选项,只有黑色光泽导航栏。
很长一段时间以来,iOS 导航栏的限制是两个按钮——一个在左侧,一个在右侧。对于 iPhone 和 iPod Touch 来说,这仍然是一个相当好的主意。对于 iPad 来说,可以在不干扰任何文本的情况下在栏上添加相当多的按钮。然而,插件并没有暴露这个功能,所以我们只能有一个按钮在左侧,一个按钮在右侧。
通常左侧的按钮是返回按钮。这通常会给一个指向左边的箭头,但唯一的方法是创建一个图像并将其传递给插件。对我们来说,我们决定使用一个没有指向左边箭头的普通按钮。如果你想要它,插件确实有关于如何创建图像并将其添加到项目的说明。
添加标签栏
在社交视图和推文视图中,我们已经有了一个类似于 iOS 原生标签栏的东西——我们称之为工具栏。这是推文视图中的分享按钮所在的位置。不幸的是,没有原生的插件可以用于实际的工具栏,所以我们把分享按钮移到了导航栏上。
然而,在社交视图中,我们使用工具栏就像一个真正的标签栏——即切换视图内容的方法。我们有五个图标代表 Twitter 账户,按下任何一个都会加载该流中的最新帖子。这对于标签栏来说工作得很好。以下是它的样子:

准备工作
只有一个小问题。虽然我们能够在 HTML 标签栏中显示全彩、漂亮的头像,但 iOS 原生标签栏不支持这一点。实际上,它要求图像是遮罩。iOS 将根据这个遮罩创建标签栏上使用的非选中状态和选中状态的图像。本质上,所有白色(实际上,所有值不是透明的)都会画在标签栏上,而所有透明的则不会画在标签栏上。
这意味着我们不能使用我们从 Twitter 获得的图像。我们最终会得到五个矩形图标,与原始头像没有任何相似之处。因此,我们需要在 Photoshop 或你喜欢的编辑器中创建我们自己的版本。
对于标签栏,针对非视网膜显示屏的最佳尺寸是 30 x 30,而对于视网膜显示屏则是 60 x 60。然后,每个图像都保存为 tab#.png 和 tab#@2x.png。带有 @2x 的版本是为视网膜显示屏准备的。您可以在我们的代码下载中的 www/images 目录中看到每一个。我们所做的一切就是从 Twitter 上获取头像,将其颜色调整为白色,然后移除所有背景内容,使其透明。
继续前进
我们这次将在我们的社交视图中做大部分工作,因为它确实是唯一一个几乎能实现真实标签栏功能的视图。即便如此,我们还是需要在 app.js 中再次添加一些代码(与上一个任务相同的位置):
window.addEventListener("resize", function() {
plugins.navigationBar.resize();
plugins.tabBar.resize(); }
, false);
plugins.navigationBar.init();
plugins.tabBar.init();
plugins.navigationBar.create();
plugins.tabBar.create();
plugins.navigationBar.hideLeftButton();
plugins.navigationBar.hideRightButton();
plugins.navigationBar.setTitle(__T("APP_TITLE"));
plugins.navigationBar.show();
注意初始化和创建标签栏的两个命令。这里必须这样做,因为标签栏必须始终跟随导航栏的初始化和创建;否则,WebView(夹在中间)的大小将被错误设置。
现在,让我们转到我们的社交视图:
socialView.initializeToolbar = function() {
var users = TWITTER.users;
if (users.error) {
console.log(streams.error);
alert("Rate limited. Please try again later.");
return;
}
for (var i = 0; i < users.length; i++) {
plugins.tabBar.createItem("tab" + i,users[i].getScreenName(),"/www/images/tab"+i+".png",
{
onSelect: function(tabName) {var i = tabName.substr(3,1);
socialView.loadStreamFor('@' + users[i].getScreenName());
}
});
}
}
首先,我们为每个 Twitter 账户创建一个标签栏项。我们给它命名为 tab#——所以是 tab0、tab1 等等。我们将 Twitter 账户名称作为标签栏的文本,然后我们使用之前创建的图像作为每个标签栏的图标,而不是使用 Twitter 提供的头像。请注意,我们从未指定 @2x;iOS 在视网膜显示屏上会自动使用它。(神奇!也值得注意,当编写原生代码时,这种情况也会发生;很少需要担心程序化地追加 @2x。)
我们还为每个标签项添加了一个 onSelect 处理器。我们将取标签项名称的最后一个字符,它将是一个从 0 到 4 的数字,然后加载该索引的流。这意味着点击第一个标签栏(命名为 tab0)将加载第一个 Twitter 账户的流。
…
socialView.viewDidAppear = function() {
plugins.navigationBar.hideLeftButton();
plugins.navigationBar.hideRightButton();
plugins.navigationBar.setTitle(socialView.currentTitle);
plugins.navigationBar.setupLeftButton( __T("BACK"), null, socialView.backButtonPressed);
plugins.navigationBar.setupRightButton(__T("#"), null, socialView.changeReturnCount);
plugins.navigationBar.showLeftButton();
plugins.navigationBar.showRightButton();
plugins.tabBar.show();
plugins.tabBar.showItems("tab0","tab1","tab2","tab3","tab4");
…
}
现在我们已经添加了显示标签栏的代码。我们还添加了显示每个标签栏项的代码。由于我们知道我们始终会有五个项,所以我们现在只是硬编码这些值,但同样也可以创建很多标签栏项,一次只显示几个。
socialView.viewWillHide = function() {
plugins.navigationBar.hideLeftButton();
plugins.navigationBar.hideRightButton();
plugins.navigationBar.setTitle("");
plugins.tabBar.hide();
…
}
最后,我们在完成任务后清理,并在视图隐藏时隐藏标签栏。这确保了标签栏在任何其他视图中都不会可见。
我们做了什么?
在这个任务中,我们创建了标签栏,并将其分配给标签栏项。我们还为每个标签栏项分配了回调函数。
我还需要了解什么?
苹果在标签栏的工作方式上非常严格。一方面,在 iPad 之外的所有设备上,它不应有超过五个图标。(原因很明显:空间有限!)
那么,如果你需要超过五个标签页怎么办?可接受的方法是显示四个图标,添加一个更多图标(由三个点组成的图像),然后当用户点击该标签页时,以表格列表的形式显示剩余的标签页。你可以在 iPhone 和 iPod Touch 上的音乐应用中看到这种行为,如果我们编写的是原生代码,我们几乎可以免费获得这种行为。
但我们不是编写原生代码,因此我们必须手动完成。这意味着,如果你想要超过五个标签页,你需要手动创建更多标签页,并自行在列表中显示剩余的标签页。还有一个问题:在 iPad 上,你应该显示所有标签页——在 iPhone 和 iPod Touch 上显示更多按钮是有效的。
最后,苹果强烈建议在 iPad 上不要使用超过七个标签页,但这并不是强制执行的。
添加动作表
动作表是向用户展示有限选择的好方法,到目前为止,我们一直是通过使用带有多个按钮的弹出消息框来做到这一点的。对于这个项目,当用户在推文视图中点击分享按钮时,我们将显示一个动作表。它看起来会是这样:

继续进行
在TweetView.html中:
tweetView.share = function() {
var actionSheet = window.plugins.actionSheet;
actionSheet.create(__T('Share'), ['Twitter', 'Facebook', __T('Email'), __T('Cancel')],
function(buttonValue, buttonIndex) {
if (buttonIndex==0)
{
PKUTIL.showURL("https://twitter.com/intent/tweet?text=" + encodeURIComponent(tweetView.theTweet.text) + "%20(via%20" + encodeURIComponent("@" + (tweetView.theTweet.from_user || tweetView.theTweet.user.screen_name)) + ")");
}
}, {cancelButtonIndex: 3});
首先,我们使用actionSheet.create()创建动作表。我们给表格一个标题(分享),然后指定可以出现的按钮(Twitter、Facebook、Email和取消)。然后我们指定动作表的处理程序,如果用户点击Twitter按钮,它将分享推文到 Twitter。对于其他按钮,目前它不会做任何事情。最后,我们指出取消按钮是最后一个按钮。这样 iOS 就会知道将取消按钮染成不同的颜色,使其明显不同。(记住索引是从零开始的。)
当我们的回调被调用时,我们得到两个值:buttonIndex和buttonValue。虽然buttonValue可能很有用,但很可能是任何东西,尤其是当我们考虑到本地化时。因此,最好使用buttonIndex。第一个按钮将是buttonIndex 零,以此类推。
我们做了什么?
在这个任务中,当用户点击特定推文的分享按钮时,我们添加了一个动作表。
我还需要了解什么?
动作表是一个非常酷的东西,在 iPhone 或 iPod Touch 上工作得非常好。在 iPad 上,它将以弹出窗口的形式出现在屏幕中央,这不是最好的用户界面,但我们能处理。你可以使用原生方法来正确定位它并添加一个箭头,但苹果似乎也对使用居中动作表的应用程序不太关心,所以我们现在不考虑这个问题。
我们还需要知道的是,保持项目数量少是一个好主意,否则列表会变得非常长。虽然 ActionSheet 应该在发生这种情况时将这些内容压缩成可滚动的列表视图,但关于何时发生的情况存在一些错误。这意味着你可能会得到一个非常长的列表,其中一些按钮被截断或完全缺失。(这通常在 iPad 上最明显。)
长话短说,保持项目数量在一个合理的范围内。在小设备上,五项或更少可能是一个好主意,而在 iPad 上,十项或更少可能更合适。
添加消息框
到目前为止,我们自己的消息框做得相当不错。它并不完全像 iOS 原生消息框,但已经很接近了。然而,在这种情况下,我们想要做到极致。
开始行动
与消息框插件一起工作非常简单。让我们回到我们的 Tweet View 中的share()方法:
tweetView.share = function() {
var actionSheet = window.plugins.actionSheet;
actionSheet.create('Share', ['Twitter', 'Facebook', 'Email', 'Cancel'],
function(buttonValue, buttonIndex) {
if (buttonIndex==0)
{
PKUTIL.showURL("https://twitter.com/intent/tweet?text=" + encodeURIComponent(tweetView.theTweet.text) + "%20(via%20" + encodeURIComponent("@" + (tweetView.theTweet.from_user || tweetView.theTweet.user.screen_name)) + ")");
}
if (buttonIndex==1)
{
var messageBox = window.plugins.messageBox;
messageBox.alert('Not Implemented', 'Sorry, sharing with FaceBook is not yet implemented.',
function(button) {
console.log('button ' + button + ' pressed');
});
}
}, {cancelButtonIndex: 3});
我已经突出显示了显示原生消息框的代码。在这种情况下,我们用它来显示我们的 ActionSheet 上的第二个按钮:FaceBook。由于我们还没有实现它,我们向用户显示一个友好的通知。它看起来是这样的:

就像 ActionSheet 和标签栏一样,我们可以响应(如果我们愿意)任何被按下的按钮。在这种情况下,我们只是将其记录到控制台,但可以使用确认消息框来根据按下的按钮执行不同的操作。
我们做了什么?
我们显示了一个原生消息框,并在按钮被按下时处理回调。
我还需要知道什么?
永远不要在真实的应用程序中这样做。我的意思不是永远不要使用消息框;不,我的意思是永远不要一开始就告诉用户一个功能尚未实现。实际上,永远不要向他们展示该功能原本打算实现但并未实现。用户对此并不太欣赏,而且如果苹果发现这种情况,肯定会拒绝该应用。
消息框插件描述了其他用途,包括确认框、输入框和密码输入框。阅读插件的 readme 以获取有关如何使用这些附加功能的更多信息。
添加选择器
选择器在 iOS 中到处都是,我们自己的框架中还没有一个好的类似物。这些东西看起来如下截图所示:

它们擅长同时显示多个选择并让用户选择一个。它们经常用于选择日历日期、时间,甚至只是从大量数字中选择一个特定的数字。它们之所以适合这样做,是因为它们允许用户快速滚动到大量范围。
在我们的例子中,我们只是提供了一些选项,但这里可以有二百个项目而不会真正损失功能(但实际上,在我们的情况下,用户不需要那么多)。
开始行动
我们将暂时回到社交视图,并回顾我们说过要回过头来的事情。还记得那个#按钮吗?是的,现在是我们处理它的时候了。
实际上,我们将给用户选择一次加载多少条推文的选项。酷吧?这可以通过 ActionSheet 来实现,但项目数量可能太多,不太适合,所以我们选择使用选择器。
socialView.numberOfTweets = 100;
…
socialView.loadStreamFor = function(searchPhrase) {
…
var aStream = new TWITTER.TwitterStream(searchPhrase, function(theStream) {
…
}, socialView.numberOfTweets);
}
首先,我们在视图中添加了一个新属性:要加载的推文数量;并将其默认设置为100。然后我们修改了loadStreamFor(),使其使用这个值而不是项目 2 中的硬编码值 Let's Get Social!。
socialView.changeReturnCount = function ()
{
var pickerView = window.plugins.pickerView;
var slots = [
{name: 'count', value: socialView.numberOfTweets, data: [
{value: 25, text: __T('Twenty-Five')},
{value: 50, text: __T('Fifty')},
{value: 75, text: __T('Seventy-Five')},
{value:100, text: __T('One Hundred')},
{value:125, text: __T('One Hundred Twenty-Five')},
{value:150, text: __T('One Hundred Fifty')},
{value:175, text: __T('One Hundred Seventy-Five')},
{value:200, text: __T('Two Hundred')}
]}
];
接下来,我们创建我们想在选择器中显示的值。名称不会在任何地方显示;它只是在用户告诉我们他们选择了什么时使用。第一个值是当前选中的值。这意味着选择器将向用户显示当前选中的项。其余的项定义了每个项的值和显示文本。因此,用户将看到Twenty-Five,但我们会得到25。
pickerView.create(__T('Number of Tweets'), slots, function(selectedValues, buttonIndex) {
if (buttonIndex == 1)
{
socialView.numberOfTweets = selectedValues["count"];
socialView.loadStreamFor(socialView.currentTitle);
}
}, {style: 'black-opaque', doneButtonLabel: __T('OK'), cancelButtonLabel: __T('Cancel')});
}
接下来,我们实际上创建选择器。我们给它命名为Number of Tweets,这样用户就知道他们在设置什么。我们传入要显示的值(插槽),然后传入一个回调处理程序。这个处理程序将值设置为选中的值,并重新加载 Twitter 流,但只有当用户点击OK时。最后,我们设置样式(黑色),并传入两个按钮的名称。
选择器不仅限于单值列表。你可以创建多列,这在设置日期时很有用。例如,你可以有一个年份列、月份列和日期列。有关如何操作的更多信息,请参阅插件的 readme 文件。
我们做了什么?
我们创建了一个选择器,填充了数据,并响应用户选择特定结果。
添加电子邮件编辑器
通过电子邮件分享几乎内置在每一个应用中,但实际上不使用插件很难实现。由于这个应用旨在轻松分享,让我们添加通过电子邮件分享的功能。
继续前进
在我们的推文视图中,我们有以下内容:
tweetView.share = function() {
var actionSheet = window.plugins.actionSheet;
actionSheet.create('Share', ['Twitter', 'Facebook', 'Email', 'Cancel'],
function(buttonValue, buttonIndex) {
if (buttonIndex==0)
{
PKUTIL.showURL("https://twitter.com/intent/tweet?text=" + encodeURIComponent(tweetView.theTweet.text) + "%20(via%20" + encodeURIComponent("@" + (tweetView.theTweet.from_user || tweetView.theTweet.user.screen_name)) + ")");
}
if (buttonIndex==1)
{
var messageBox = window.plugins.messageBox;
messageBox.alert('Not Implemented', 'Sorry, sharing with FaceBook is not yet implemented.',
function(button) {
console.log('button ' + button + ' pressed');
});
}
if (buttonIndex==2)
{
cordova.exec(null, null, "EmailComposer", "showEmailComposer",
[{"body": tweetView.theTweet.text + " (via @" + (tweetView.theTweet.from_user || tweetView.theTweet.user.screen_name) + ")",
"subject": "Thought this was interesting..." }]);
}
}, {cancelButtonIndex: 3});
}
再次,我们处于share()方法中,我已经突出显示了更改的代码。这里我们响应电子邮件按钮,它是索引2。我们使用cordova.exec()调用插件,给它插件名称和我们想要使用的方法(showEmailComposer)。然后我们向插件传递正文和主题。正文将是推文,以及它是谁发的,主题将是"这个很有趣…"。我们可以传递更多信息,例如消息应该发送给谁,但在我们的情况下,我们不知道这些信息,所以没有将其发送到插件。
一旦完成,我们就完全放手,尽管有可能确定用户是否实际上通过电子邮件分享了。在我们的情况下,我们并不关心他们是否这样做,只是我们提供了这样的选项。
这就是它的样子:

我们做了什么?
在这个任务中,我们创建了一个电子邮件编辑器,主题和正文设置为指定的数据。
游戏结束..... 结束
我们已经取得了一些成就——总共使用了七个插件,我们的应用看起来和感觉现在相当原生。它还不是完美的;例如,在原生应用中导航栏有一个酷炫的动画,而在我们的应用中,在视图变化时它会变空白,但除此之外,其他方面看起来和感觉都相当接近用户所期望的。
你能承受 HEAT 吗?热手挑战
当然,总有可以添加和改变的地方。你为什么不尝试以下几个挑战:
-
将 Facebook 分享功能添加到应用中。
-
存储要加载的推文数量,以便它成为一个持久的设置。
-
改变在每次视图结束时清除导航栏的方式;尝试达到更原生的感觉。要真正达到原生感觉,你可能需要深入研究原生代码。
-
想要一个真正复杂的挑战?从 Twitter 下载头像,然后找出如何程序化地遮罩它们。然后保存到用户的临时存储中,并用作标签栏上的图标。
第十章。扩展
到目前为止,我们真正覆盖的只是如何为小设备如手机创建应用程序。但还有很多其他非手机形状的移动设备,即平板电脑(以及所谓的“大屏手机”,通常为 7 英寸而不是 10 英寸)。尽管拥有平板电脑的用户不如手机用户多,但这仍然是一个极其重要的市场。
有时,在更大的设备上简单地显示相同用户界面是可能的。这在游戏中很常见,图形和控制区域通常只是按设备屏幕大小进行缩放。有时,可以大量使用相同的用户界面,但需要对它进行一些小的调整,以便在更大的屏幕上良好运行。还有其他时候,别无选择;用户界面必须完全重新设计以适应更大的屏幕。
我们要构建什么?
在这个项目中,我们将回顾我们在项目 3 中创建的应用程序。该应用程序并不特别复杂,但它足够灵活,可以支持各种扩展方式,这正是我们将要做的。我们将创建几个不同的 Filer 版本,每个版本都有不同的扩展到平板电脑屏幕大小的概念。
它能做什么?
当一个人只针对小屏幕开发时,开发大屏幕的一个主要问题是“如何处理所有这些空间?”当一个人被限制在开发 320 x 80 或 600 x 800 的维度时,突然意识到自己有更多的像素要填充可能会感到震惊。通常,这些更大的显示器尺寸为 1024 x 768、1280 x 768、1280 x 800 或更高。实际上,iPad 3 的显示屏在技术上为 2048 x 1536,当你这么想的时候,这相当令人惊讶。幸运的是,iPad 3 将其缩放回 1024 x 768 为我们。
在这个项目中,我们将重新思考 Filer 的用户界面,以适应更大的显示区域。我们不会过多关注实际的功能性——我们已经在项目 3 中完成了这项工作,提高生产力,但我们将处理如何处理更大的屏幕。
我们将关注两种典型场景:放大,其中我们只是将界面放大以适应新的屏幕大小,以及分割视图(也称为主详情),其中我们将向界面添加侧边栏(例如,在 iPad 上你经常会看到,例如设置应用程序)。
它为什么这么好?
有时,应用程序只是需要更多的空间——记事应用程序也不例外。更大的屏幕意味着更大的屏幕键盘,更大的屏幕意味着有更多空间用于重要内容——例如文本。在其他时候,我们可以通过使用分割视图布局来将应用程序过渡到更大的屏幕,这些布局允许我们有效地简化应用程序的层次结构。我们将通过 Filer 的三个版本来探索所有这些选项。
我们将如何做到这一点?
我们将按以下方式处理这三个设计:
-
设计扩展的用户界面
-
实现扩展的用户界面
-
设计分割视图用户界面
-
实现分割视图用户界面
我需要准备些什么才能开始?
对于这个特定的任务,我们将使用这个项目的文件,所以如果你想跟上,请下载它们。有两个名为1和2的目录,这是本项目中的应用版本。第一个是我们接下来要关注的,而第二个是我们稍后要工作的。
设计扩展的用户界面
许多应用可以简单地“扩展”以适应更大的屏幕,幸运的是,我们的框架为我们做了很多“扩展”的工作。虽然这对游戏来说效果很好,但我们确实需要做更多的工作来使 Filer 更好地适应大屏幕。
开始行动
如果你还记得项目 3 中的 Filer 应用,那么在“提高生产力”中,有三个视图:一个起始视图、一个文档视图和一个文档视图。我们将放弃第一个视图——无论如何,在大屏幕上也没有什么可以做的来使其工作。相反,我们将专注于最后两个视图——坦白说,在这个任务中,我们主要将对第一个视图进行大量更改。
让我们看看项目 3 中“提高生产力”的 Filer 应用的文档视图截图:

对于我们的平板电脑尺寸的应用,我们将水平垂直地显示这个文档列表,而不仅仅是水平显示。在 iPad 上,纵向时大约显示三个图标,横向时显示四个图标。这意味着我们的原型看起来是这样的:

在这个项目的目的下,我们将保留导航栏上的创建按钮,但在项目结束时,我们将将其转变为文档列表中的一个更大功能。例如,一些应用可能有一个带有“+”图标的空白文档图像来表示创建新文档。其他应用可能使用虚线矩形来表示相同的意思。对于更大的显示,这样的设计无疑是合适的;而在小屏幕上,这会被视为空间的浪费。
除了对文档列表进行这个更改外,我们还将对这个应用进行其他一些更改。由于我们的框架设计为填充屏幕,其余部分在大屏幕上将按原样工作。
我们做了什么?
在这个任务中,我们回顾了项目 3 中的应用“提高生产力”,并为更大屏幕的用户界面创建了一个新的原型。
我还需要了解什么?
扩展 iPhone 应用相对简单,只要你已经为未来的扩展做好了规划。也就是说,如果你已经将所有事情规划到像素级别,并为 320 x 480 的屏幕构建了应用,那么你将不得不在更大的屏幕上更改所有这些像素。当处理像 Filer 这样的简单生产力应用时,构建可以扩展到更大屏幕的布局并不特别困难,但一旦涉及到更复杂的布局和图形,这就会变成一个挑战。
在某些方面,高度图形化的游戏既是最难也是最简单的。当扩展到新屏幕时,游戏可能保持相同的用户界面——可能只是对按钮位置或大小进行一些小的调整。然而,图形在视觉上将是相同的。然而,在底层,这些图形可能在不同的分辨率下渲染。某个图形可能在小型屏幕上运行良好,但放大到更大的屏幕上,它可能会显得太大或太小。为了避免强制浏览器缩放所有内容(这总是会减慢速度并导致一些模糊),最好是针对目标屏幕重新渲染图形,因为你永远不知道未来会有什么样的屏幕。因此,始终以矢量格式创建你的图形会更好——这样你就可以在需要新尺寸时始终创建新的版本。
其中最难妥善处理的是全屏图像。这些可能是游戏背景、菜单背景、启动画面等等,你希望它们看起来尽可能好。在我们的示例游戏(项目 8, 玩转)中,我们没有过多关注这一点,但如果你有一个与我设备屏幕宽高比差异很大的设备,你可能会注意到当全屏资源显示时出现了一些黑边。这是不进行大量工作的方法之一——另一种方法是缩放并裁剪图像,可能会稍微模糊一些,并丢失图像的部分。唯一其他现实的选择是为每个支持的分辨率创建一个特定的图像。
为了最佳的视觉效果,你应该始终以设备的原生分辨率渲染你的图片。对于一个 Retina iPad,全屏图片的分辨率将是 2048 x 1536。当然,对于几乎每一款 Android 设备来说,这都不同,而且处理起来并没有特别简单的方法。你可以根据屏幕大小通过 JavaScript 替换图形,或者你可以使用媒体查询来针对特定的图形元素。你应该注意,尽管我们使用的框架确实在手机尺寸设备、平板尺寸设备以及非 Retina 和 Retina 显示之间做出了区分,但它对 Android 上所有不同的分辨率并没有做什么。你最好的选择是使用 CSS 媒体查询(更多信息,请参阅developer.mozilla.org/en-US/docs/CSS/Media_queries)。
在其他方面,非游戏应用在扩展时可能会非常痛苦。你可能正在处理大量格式化得相当复杂的内 容。在某个屏幕尺寸下看起来很棒,但在另一个屏幕上,东西可能会在奇怪的地方破裂,尤其是在仅仅缩放它的时候。有时我们使用 HTML 和 CSS 工作的事实会拯救我们——它旨在处理复杂的布局,但同样多的时间,它会导致外观和感觉以一种你没有预想到的方式出错。
这时,可能需要为平板尺寸屏幕创建特定的代码和布局。你可以在你的 JavaScript、HTML 和 CSS 代码中处理这些尺寸——你可以在 HTML 中放置一个带有tablet类的DIV标签,并有一个 CSS 规则在除了平板电脑之外隐藏它。同样,如果你试图创建一个可以在手机尺寸屏幕和平板尺寸屏幕上运行的全能应用,你可以隐藏手机 UI 元素。或者,如果你正在使用 JavaScript 定位某些东西,你总是可以查看你正在运行的设备类型,以获得关于要做什么的好主意——最坏的情况是查看屏幕的宽度和高度。再次强调,使用媒体查询在处理多个分辨率时通常很有帮助。
实现放大后的用户界面
现在我们已经为 Filer HD mark I 设计了用户界面,是时候实现它了。我们对代码所做的更改数量惊人地少,所以准备好保持警觉——眨眼之间,你可能会错过它!
准备工作
虽然我们主要关注 iOS 平台上的这个应用,但这些概念同样适用于任何基于平台的平板电脑。话虽如此,为了渲染针对 iPad 的 iOS 应用,需要在项目本身中设置一些设置,而不仅仅是代码之外:
在 Xcode 的项目设置中,将设备设置从通用更改为iPad。
你应该看到如下所示的项目设置:

除了这个更改(以及为 iOS 项目在Cordova.plist中设置我们的典型设置)之外,我们将从项目 3,提高生产力中复制文件。如果你想跟上来,请导航到本项目代码文件中的/1/www。
继续前进
实际上,我们为了使应用适应更大的屏幕所需要做的非常少。正如我们之前所说的,我们的框架确实做了很多工作——它总是试图确保内容填满屏幕,我们视图中的 HTML 也有帮助——尽可能,我们想使用百分比而不是像素。(这并不是说使用像素不好;例如,我们在处理导航栏上的按钮位置时,会大量使用它们。)
就此提醒一下,这是文档视图的 HTML:
<div class="viewBackground">
<div class="navigationBar">
<div id="documentsView_title"></div>
<button class="barButton" id="documentsView_createButton" style="right:10px" ></button>
</div>
<div class="content avoidNavigationBar " style="padding:0; overflow: scroll;" id="documentsView_scroller">
<div id="documentsView_contentArea" style="padding: 0; height: auto; position: relative;">
</div>
</div>
</div>
<div id="documentsView_documentTemplate" class="hidden">
<div class="documentContainer">
<div class="documentImage">
<img src="img/DocumentImage.png" border=0 onclick="documentsView.openDocument(%INDEX%)"/>
</div>
<div class="documentTitle" onclick="documentsView.renameDocument(%INDEX%)">
<span >%TITLE%</span>
</div>
<div class="documentActions">
<img src="img/Copy.png" width=28 height=28 border=0 onclick="documentsView.copyDocument(%INDEX%)" />
<img src="img/Share.png" width=27 height=28 border=0 onclick="documentsView.shareDocument(%INDEX%)" />
<img src="img/Trash.png" width=28 height=28 border=0 onclick="documentsView.deleteDocument(%INDEX%)" />
</div>
</div>
</div>
这里实际上没有什么需要更改的。接下来,这是我们的部分样式:
.documentContainer
{
width: 240px;
height: auto;
padding: 40px;
padding-left: 20px;
padding-right: 0px;
display: inline-block;
text-align: center;
}
.documentContainer .documentImage img
{
width: 190px;
height: 242px;
}
.landscape .documentContainer
{
padding: 10px;
}
.landscape .documentContainer .documentImage img
{
width: 125px;
height: 160px;
}
到目前为止,对于 iPad 设备没有变化。我们移除了将此转换为 Android 设备的列表的代码,因此所有平板设备都将获得漂亮的大文档图标。
我们进行了一些更改的地方实际上是在实际代码中,所以让我们看看那里:
documentsView.documentIterator = function ( o )
{
var theHTML = "";
var theNumberOfDocuments = 0;
for (var i=0; i<o.getDocumentCount(); i++)
{
var theDocumentEntry = o.getDocumentAtIndex ( i );
theHTML += PKUTIL.instanceOfTemplate ( $ge("documentsView_documentTemplate"),
{ "title": theDocumentEntry.name.substr(0, theDocumentEntry.name.length-4),
"index": i
}
);
theNumberOfDocuments++;
}
// code deleted
$ge("documentsView_contentArea").innerHTML = theHTML;
}
看到关于已删除代码的注释了吗?
没错——我们删除了一些东西!如果你记得,在 iPhone 上,我们希望文档列表可以水平滚动,所以有一些代码设置了文档列表容器的宽度为项目数量乘以宽度。这将使DIV变大,因此允许内容水平滚动。对于这个应用,我们希望宽度为单屏宽度,然后我们希望浏览器换行我们的文档,就像页面上的一行字。所以屏幕右侧的下一个文档实际上是在最左侧文档下方开始的新一行。
猜猜看——那就是全部。我们的按钮不需要改变位置,我们文档视图中的文本编辑器已经编码为填满整个屏幕,所以我们那里不需要做任何事情。唯一我们需要移除的是专门为手机尺寸设备编写的代码。

那现在看起来是什么样子?

我们做了什么?
在这个任务中,我们将一个手机应用转换成了平板应用。
我还需要了解什么?
我们通过实际上删除限制应用程序为手机版本的代码来实现这一点,但并非所有应用程序都可以如此简单地进行扩展。有时你可能需要重新定位按钮、内容和各种元素以更好地适应更大的屏幕。如果你想使应用程序保持通用性(即,它将在手机尺寸的设备和平板尺寸的设备上运行),你的工作将变得更加困难,因为你必须保留两种布局。幸运的是,CSS、HTML 和 JavaScript 来拯救我们,通过让我们使用 CSS 针对特定的类和 ID 或媒体查询,以及编写针对特定布局的 JavaScript 代码来帮助我们。在我们的情况下,我们可以通过检查设备的尺寸来保留手机尺寸的特定代码——框架愉快地处理了其余的尺寸问题。
有时你可以通过框架处理不同视口尺寸的方式应付过去,但往往会有时候这种方法不起作用——UI 在大型屏幕上可能太稀疏,在小型屏幕上可能太拥挤。如果发生这种情况,最好是针对每个设备尺寸专门构建你的 UI,而不是依赖框架来正确处理事情——因为有时候它并不正确。
设计分割视图用户界面
分割视图布局是将应用程序扩展到平板电脑的最受欢迎的方法之一。它还有附带的好处,即简化应用程序的信息层次结构,这仅仅是技术上的说法,意味着在应用程序中到达某个地方所需的“点击”次数更少。
大多数平板电脑平台以类似的方式实现这种视图——在横屏模式下它始终位于左侧(但有时位于右侧),而在竖屏模式下通常隐藏在屏幕之外,准备并等待用户点击按钮将其调出。有时在竖屏模式下视图始终可见,但这取决于应用程序的类型以及屏幕空间损失是否值得始终显示分割视图。
继续前进
分割视图实际上只是将两个视图组合在一起。这样思考起来很简单——一个视图位于左侧的较小侧边栏中,而第二个视图位于右侧。左侧视图在技术上被称为主视图,而右侧视图被称为详细视图。在技术上,这种模式被称为主-详细模式,当与数据记录一起工作时最为明显,记录选择发生在主视图(左侧),而记录的详细信息显示在详细视图中(右侧)。
在我们的应用程序中,我们将使文档列表成为主视图,以便文档本身可以成为用户的焦点。这意味着一个特定的文档将成为详细内容。然而,在这种安排中,我们需要从上一个版本应用程序中文档的精美网格列表切换回简单的列表。
现在看看我们的设计是什么样的:

在这个草图里唯一不太“真实”的是顶部的创建按钮。实际上,旁边还将有一个标题,只是由于原型设计中的空间不足,无法将其放置在那里。所以它看起来不会像上一个截图那样显得突兀。
不明显的是,当设备旋转到纵向模式时会发生什么。侧边栏实际上会消失——只留下文本文档可见。我们将在左侧有一个名为文档的按钮,点击它将恢复侧边栏,一旦侧边栏显示,将有一个关闭按钮可以关闭它。在项目末尾的挑战中,你将被要求实现手势来打开和关闭侧边栏。
我们做了什么?
在这个任务中,我们为分割视图布局设计了用户界面。
我还需要了解什么?
这实际上取决于你的应用,当应用处于纵向模式时,主视图是否需要消失。有些应用可以容忍屏幕宽度的损失,而有些应用则不能。当处理内容(如图形、文本等)时,在纵向模式下移除它可能是个好主意。如果你处理的是设置、属性或类似的内容,那么你可以保留它,对可用性的影响很小。
实现分割视图 UI
现在,我们需要实现使我们的应用成为分割视图应用所需的更改。从这一点开始,如果你想跟上,我们将工作在/2/www。我们从上一个任务中的代码开始,然后修改它以适应新的用户界面。
继续前进
与上次不同,我们将进行几次修改。请注意,这并不是很多代码,但我们将触及几个不同的文件,并对它们进行微调以支持更改。文档视图只进行了一些小的修改,尽管我们将显示从网格改为列表,文件视图则进行了几个修改。首先,它必须处理没有加载文档的情况(这将在应用开始时发生)。其次,它必须改变处理自动保存内容的方式(因为不再有视图的关闭操作)。
但首先,我们需要设置布局以显示两个视图并排。我们将从index.html开始:
<body>
<div class="container" id="rootContainer">
<div class="container leftSplit" id="leftSplitContainer">
</div>
<div class="container rightSplit" id="rightSplitContainer">
</div>
</div>
</body>
注意我们已经向rootContainer元素添加了两个元素。第一个是leftSplitContainer,第二个是rightSplitContainer。这些元素的定位应该从它们的ID值中明显看出。
仅此还不够,我们还需要适当地对这些内容进行样式设计——为此,我们在框架的基本 CSS 中做了一些更改。查看2/www/framework/base.css:
.landscape .container.leftSplit {
width:319px;
}
.landscape .container.rightSplit {
left:320px;
}
.portrait .container.leftSplit
{
display: none;
width: 319px;
z-index:2;
box-shadow: rgb(0, 0, 0) 0px 0px 8px;
}
.portrait .container.rightSplit
{
left:0;
}
我们所做的是表明,当设备处于横屏方向时,两个分割应该并排显示。左侧将宽 319 像素,右侧视图将从像素 320 开始。屏幕上剩余的部分将决定右侧视图的宽度。这个侧边栏的大小并不是一成不变的——如果你的应用需要更小的侧边栏,可以适当减小尺寸——同样,如果需要更大的,可以设置得更大。然而,最好不要超过屏幕宽度的一半。如果你觉得有必要,现在是时候决定你的视图是否在正确的位置了。
在竖屏模式下,我们隐藏侧边栏。尽管如此,当用户想要时,这个侧边栏可以再次出现,所以我们还确保它被索引在其他所有内容之上。我们还给它添加了一个阴影,以便用户在侧边栏和下面的内容之间有一个视觉上的区分。
然而,仅仅改变样式是不够的。让我们看看我们在app.js中做了什么改变:
// load our document view
PKUTIL.loadHTML ( "./views/documentsView.html",
{ id : "documentsView",
className: "container",
attachTo: $ge("leftSplitContainer"),
aSync: true
},
function (success)
{
if (success)
{
documentsView.initializeView();
PKUI.CORE.showView ( documentsView );
}
});
// load our fileView
PKUTIL.loadHTML ( "./views/fileView.html",
{ id : "fileView",
className: "container",
attachTo: $ge("rightSplitContainer"),
aSync: true
},
function (success)
{
if (success)
{
fileView.initializeView();
PKUI.CORE.showView ( fileView );
}
});
window.addEventListener('orientationchange', APP.updateSidebar, false);
}
首先,我们的加载代码有所变化。注意,我们不再连接到rootContainer,而是连接到leftSplitContainer和rightSplitContainer。这一步至关重要,确保每个视图的内容最终显示在屏幕的正确位置。还要注意,我们显示每个视图。这也是新的——之前我们只会显示一个视图,但既然我们在屏幕上组合了两个视图,我们需要它们两个都可见。
在上一段代码的底部有一个最后的新特性——一个新的事件监听器。我们将覆盖以下代码,但基本上我们要求浏览器通知我们任何方向的变化。虽然 CSS 和 HTML 在很大程度上确保了方向变化时布局的正确性,但它们并不能做到全部,因此我们需要一些代码来找出其余的部分。
APP.toggleSidebar = function ()
{
$ge("leftSplitContainer").style.display =
( ($ge("leftSplitContainer").style.display == "block") ? "none" : "block" );
}
这段代码被文档视图和文件视图同时调用,它的唯一目的是切换侧边栏的显示。如果侧边栏是可见的,这个函数将隐藏它,反之亦然。
APP.updateSidebar = function ()
{
if (PKDEVICE.isPortrait())
{
$ge("leftSplitContainer").style.display = "none";
}
else
{
$ge("leftSplitContainer").style.display = "block";
}
}
这段代码在每次方向变化时都会被调用。如果我们改变为竖屏,我们将隐藏侧边栏,如果改变为横屏,我们将显示它。
接下来,我们需要对文档视图做一些小的修改:
<div class="viewBackground">
<div class="navigationBar">
<div id="documentsView_title"></div>
<button class="barButton" id="documentsView_closeButton" style="left:10px" ></button>
<button class="barButton" id="documentsView_createButton" style="right:10px" ></button>
</div>
<div class="content avoidNavigationBar " style="padding:0; overflow: scroll;" id="documentsView_scroller">
<div id="documentsView_contentArea" style="padding: 0; height: auto; position: relative;">
</div>
</div>
</div>
主要的不同之处在于我们在视图中添加了一个关闭按钮——这个按钮将允许用户在之前选择在竖屏模式下显示它时关闭侧边栏。我们稍后会添加一些样式来防止它在横屏模式下可见。我们还更改了某些区域的点击处理程序,因为现在这是一个列表而不是网格。
为了支持新的按钮,我们在documentsView.initializeView中添加了一些代码:
documentsView.initializeView = function ()
{
PKUTIL.include ( ["./models/FilerDocuments.js", "./models/FilerDocument.js"], function ()
{
// display the list of available documents
documentsView.displayAvailableDocuments();
}
);
documentsView.viewTitle = $ge("documentsView_title");
documentsView.viewTitle.innerHTML = __T("APP_TITLE");
documentsView.closeButton = $ge("documentsView_closeButton");
documentsView.closeButton.innerHTML = __T("CLOSE");
PKUI.CORE.addTouchListener(documentsView.closeButton, "touchend", function () { APP.toggleSidebar(); });
documentsView.createButton = $ge("documentsView_createButton");
documentsView.createButton.innerHTML = __T("CREATE");
PKUI.CORE.addTouchListener(documentsView.createButton, "touchend", function () { documentsView.createNewDocument(); });
这只是会在点击关闭按钮时调用APP.toggleSidebar。由于调用这个函数时侧边栏是可见的,这意味着它将通过隐藏它来关闭侧边栏。
唯一的其他更改?我们移除了对 PKUI.CORE.pushView 的调用。这些调用通常会将文件视图推入堆栈,但由于它已经可见,我们不需要推入任何内容。所以我们只是移除了那些行。
然而,我们确实对样式进行了更改(在 style.css 中):
.documentContainer
{
padding: 10px;
background-color: #FFFFFF;
width: 100%;
height: 90px;
color: #000;
text-align: left;
border-bottom: 1px solid #C0C2C4;
}
.documentContainer .documentImage img
{
width: 60px;
height: 70px;
}
.documentContainer .documentImage
{
width: 70px;
height: 80px;
float: left;
}
.documentContainer .documentTitle
{
height: 2em;
}
之前的代码将使每个文档项成为一个带有图标在左侧、标题在右侧以及操作图标在下面的漂亮的小列表项。你可以从以前的项目中学到的东西,并添加手势支持。
文件视图本身发生了相当大的变化:
<div class="viewBackground">
<div class="navigationBar">
<button class="barButton" id="fileView_documentsButton" style="left:10px" ></button>
<div id="fileView_title"></div>
</div>
<div class="content avoidNavigationBar" style="padding:0; " id="fileView_scroller">
<div id="fileView_contentArea">
<textarea id="fileView_text"></textarea>
</div>
</div>
</div>
第一个不同之处在于,我们在导航栏上添加了一个文档按钮,取代了返回按钮。当处于纵向模式时,此按钮将显示侧边栏。当处于横向模式时,我们将有一个特殊样式使此按钮消失。
接下来,让我们看看代码:
fileView.initializeView = function ()
{
fileView.viewTitle = $ge("fileView_title");
fileView.viewTitle.innerHTML = __T("Select or Create a Document");
PKUI.CORE.addTouchListener(fileView.viewTitle, "touchend", function () { fileView.entitleDocument(); } );
fileView.documentsButton = $ge("fileView_documentsButton");
fileView.documentsButton.innerHTML = __T("DOCUMENTS");
PKUI.CORE.addTouchListener(fileView.documentsButton, "touchend", function () {APP.toggleSidebar(); } )
$ge("fileView_text").style.display = "none";
}
这段代码相当类似——我们不是向 返回 按钮添加监听器,而是向 文档 按钮添加监听器。我们做的另一件事是隐藏 TEXTAREA 控制器——如果没有加载文档,就没有理由显示它。
fileView.hasLoadedDocument = false;
…
fileView.setFileEntry = function ( theNewFileEntry )
{
if (fileView.hasLoadedDocument)
{
// we're potentially loading a NEW document -- save the old one.
if (fileView.theSaveTimer!==-1)
{
// clear the interval so we don't save again.
clearInterval (fileView.theSaveTimer);
fileView.theSaveTimer = -1;
}
fileView.saveDocument(); // force the save so we have the up-to-date contents
documentsView.reloadAvailableDocuments(); // reload our directory structure (just in case)
}
// now load the correct document
fileView.theFileEntry = theNewFileEntry;
fileView.theFilerDocument = {};
fileView.hasLoadedDocument = true;
fileView.loadDocument();
}
这段代码中没有新内容——只是大量代码的移动。其中一些代码曾经位于 viewWillHide 中——用于在视图消失前处理保存。但现在这个视图永远不会消失,我们如何知道应该保存文档呢?结果是,我们唯一知道的时候是当选择了一个新文档。所以在加载那个文档之前,我们保存当前加载的文档。我们可以通过 fileView.hasLoadedDocument 来判断是否已加载文档——一旦我们加载了一个文档,我们就将其设置为 true。
函数末尾的 fileView.loadDocument() 之前位于 viewWillAppear 中。由于我们可能没有选择文档(尤其是在应用开始时),我们不会尝试在那里加载一个不存在的文档,所以我们将其移动到这里。
说到加载文档,考虑以下代码:
fileView.loadDocument = function ()
{
// load our document.
fileView.viewTitle = $ge("fileView_title");
fileView.viewTitle.innerHTML = fileView.theFileEntry.name.substr(0,
fileView.theFileEntry.name.length-4);
fileView.theTextElement = $ge("fileView_text");
fileView.theTextElement.value = "";
$ge("fileView_text").style.display = "block";
…
}
在 loadDocument 中高亮的代码确保我们的 TEXTAREA 元素可见,以便用户可以编辑它。我们的代码没有做的一件事是在出现错误时再次隐藏它——你应该在你的代码中适当地处理错误,通过隐藏 TEXTAREA 元素并确保不自动保存等。
唯一必要的其他更改是移除处理返回按钮事件的任何代码——这使得 viewWillAppear 和 viewWillHide 完全为空!请注意,对于 Android,我们仍然需要一个 backButtonPressed 函数,但我们没有在其中做任何事情。(如果你愿意,你可以在其中保存文档并退出应用。)
我们几乎完成了——在退出之前还有最后一件事要做,那就是确保样式适当地隐藏我们的按钮。在 style.css 中,我们有:
.landscape #documentsView_closeButton,
.landscape #fileView_documentsButton
{
display: none;
}
.portrait #documentsView_closeButton,
.portrait #fileView_documentsButton
{
display: inline-block;
}
就这样!以下是横向模式下的样子:

在纵向模式(侧边栏可见)下,它看起来像这样:

注意右边的图片,在纵向模式下侧边栏是可见的——这是因为我们点击了文档按钮将其展开。现在如果我们点击关闭,侧边栏就会消失。
我们做了什么?
我们将 Filer 应用转换成了适用于平板屏幕尺寸的分割视图应用。
我还需要了解什么?
如果你的应用包含几个视图(就像这个一样),那么转换将会非常简单。但如果你的应用需要在两个视图中进行导航,那么你将会遇到问题,因为我们的当前框架实际上不支持这一点。
我们当前框架实现的视图堆栈假设屏幕上一次只显示一个视图。但在分割视图应用中,你可以在屏幕上显示多个视图。尽管框架计划在不久的将来支持这一点,但目前尚不可用,因此你需要自己处理视图之间的导航。简而言之,不要尝试使用视图推送或弹出——否则会发生奇怪的事情。
如果你想在这个书之外跟踪框架的进展,请访问框架的 Github 页面:github.com/photokandyStudios/YASMF。
游戏结束..... 总结
好吧,我们已经完成了。我们将一个应用转换成了两种不同的形式,以适应平板屏幕尺寸。尽管这些是目前最受欢迎的做法,但这并不意味着另一种方法可能不会更适合你的应用,或者几种方法的组合。这完全取决于你的内容和应用本身的工作方式。你最好也查阅一下你平台上的人类界面指南(附录 A, 快速设计模式参考)。只有在你对内容、布局、图形等进行彻底审查之后,你才能确定哪种方法最好——即使如此,也不要害怕尝试和尝试其他方法。
你能承受压力吗?热手挑战
这个项目只涵盖了两种将界面扩展到平板的方法。当然,有无数种方法可以改进我们在这里展示的内容,或者使用其他设计模式来扩展到平板界面。你为什么不尝试几种呢?
-
分割视图模式在纵向模式下左侧分割视图(或主视图)会消失。当点击文档按钮时,它会立即出现。你为什么不添加一些动画,使这个过程不那么突兀呢?(别忘了在点击关闭时也要进行动画处理。)
-
继续这个主题,当在纵向模式下选择(或创建)文档时,自动关闭左侧分割视图。
-
今天的大多数应用都允许通过手势打开和关闭侧边栏(通常是水平滑动)。将这个功能添加到应用中。
-
最后,当应用在纵向模式下打开时,没有真正的指示要做什么(除了轻触文档按钮之外)——让侧边栏自动出现。
-
不要使用创建按钮来创建文档,而是在文档列表中使用“创建”选项。这可以与现有的文档项形状相似,也可以不同——由你决定!
-
将分割视图再进一步,并加入一个转折!如果你看过 Facebook 的 iOS 应用,你会知道侧边栏实际上位于主内容下方。然后可以将主内容向右滑动,从而露出下方的侧边栏。尝试实现这种应用风格。
-
尝试将分割视图的侧边栏放置在不同的位置。将其放在右侧是最简单的,但更难的挑战是将它放在屏幕的顶部或底部。
附录 A. 快速设计模式参考
虽然让你的应用程序独特并脱颖而出很重要,但也要意识到一些事情已经相当标准化了,没有必要重新发明轮子。例如,当构建登录界面时,你知道你需要要求用户名或电子邮件和密码;这是因为这已经成为一个相当标准的设计模式,用户从他们在网站和其他应用程序的各种体验中已经熟悉了它。
本附录中展示的模式是快速草图。虽然附带了代码,但适用于这些模式的项目已被指出。每个草图的样式基于 iOS,但大多数移动平台中都有每个模式的对应形式。如果你想要一些真正优秀的模式和使用的应用程序,你可能需要考虑 Theresa Neil 的书籍,《移动设计模式画廊:移动应用程序的 UI 模式》,由O'Reilly出版。她还有一个网站,提供了许多有用的信息,网址为www.mobiledesignpatterngallery.com。一些包含大量各种模式真实示例的其他有用网站包括:
不要忘记遵循你平台的人机界面指南(HIG)。在某些平台(如苹果的 iOS 和微软的 Windows Phone 7 和 8)上,不遵循 HIG 可能会导致你的应用程序完全被拒绝。无论如何,HIG 旨在确保所有应用程序都具有一定的统一性和用户友好性。HIG 并不是为了让你屈服;这些指南是出于合法的好理由。
查阅以下指南以获取更多信息:
-
Apple iOS HIG:
developer.apple.com/library/ios/#documentation/UserExperience/Conceptual/MobileHIG/Introduction/Introduction.html -
Android 的 UI 指南:
developer.android.com/guide/practices/ui_guidelines/index.html -
Windows Phone 指南:
msdn.microsoft.com/en-us/library/windowsphone/design/hh202915(v=vs.92).aspx
导航列表
导航列表是您应用程序的一个简单导航模式。如果您有一些希望用户执行的项目,您可以使用此模式向他们展示一个选择菜单。当您的应用程序中的每个主题都与其他主题不同时,此模式效果很好。在下面的例子中,获取报价、进行支付和报告事件都会是截然不同的工作流程。

你可以充分利用这个机会来美化屏幕,但务必小心不要遮挡列表项本身。无论如何,包括图标等元素,但始终确保文本清晰易读。
如果用户需要为任何特定项目登录,确保他们在同一会话中使用的其他项目也能保持登录状态。(也就是说,如果我进行支付,我不应该再次登录来报告事件。)
通常情况下,这个列表中的项目数量应保持最少。如果你需要滚动查看,可能需要重新考虑你应用程序的层级结构。
网格
网格是一个用户非常熟悉的导航模式,对于图片和视频来说效果非常好(回想一下我们在项目 6、说奶酪!和项目 7、我们去看电影!中使用的Imgn和Mem'ry应用程序)。

确保以足够大的比例显示缩略图,以便可以看到细节。如果你想要显示标题,它应该出现在图片下方。
点击缩略图通常会以更大的比例显示缩略图。然而,如果你使用这种导航模式,比如你应用程序的某个部分,每个缩略图都是一个图标,点击该图标将带你到应用程序的相应部分。在这种情况下,务必确保你的图标都是独特且可识别的。
使用此模式最适合图片和视频,而不太适合应用程序的部分。尽管过去一些应用程序(如 Facebook 的旧版本)曾使用过此模式,但大多数应用程序都倾向于使用其他机制。
在处理图片和视频时,长按缩略图通常会弹出一个包含删除、移动等操作的列表。或者,应用程序将允许用户重新排列列表(想想 iOS 设备上摇动的首页)。
轮播图 1
轮播图有许多不同的用途。在下面的例子中,我们正在水平轮播图中显示一系列文档。主图像下方的每个图像都是一个操作。用户可以点击它们对该文档进行特定的操作。此外,文档的名称(在主图像下方)通常是可点击的,允许用户重命名文档。如果你还记得,我们在项目 3、提高生产力和项目 10、扩展规模中使用了这种模式。

有时操作按钮可能不在轮播图中;它们可能位于工具栏或导航栏上。使用最适合您用户的方式。
如果有多个文档可用,确保屏幕两侧显示其中一个其他文档的部分是一个好主意;这有助于用户了解轮播图可以滚动。
大图像通常应该是实际文档的表示,通常是文档的第一页或一张纸的表示。
轮播图 2
这个轮播图与上一个不同。它更常用于逐个显示大图像或以友好界面显示应用程序的导游。它还常用于在有限的空间内显示不同组的信息或选项。

主内容下方的圆圈是可选的;如果您正在查看图像,用户通常知道左右滑动以查看上一张或下一张图像。如果您正在查看应用程序的导游或不同组的信息,则最好显示圆圈(或平台的等效功能),以便用户有一个关于内容页数的好主意。
当显示圆圈时,最好将页数限制在七页或八页以内。超过这个数字,圆圈本身可能会分散注意力,并影响内容。
登录界面
大多数应用程序都有它们,尽管它们有时是用户存在的烦恼(主要是因为在小型屏幕上输入密码很困难),但它们也非常重要。

如果可能的话,最好使用电子邮件作为用户的唯一名称。这是他们已经知道并手头的信息。如果不可能,则将电子邮件替换为用户名。
总是让密码字段隐藏字符。这通常是通过在输入时用点代替每个字符来完成的。最后一个字符可以显示,但应该只显示很短的时间。大多数平台只要您指定输入字段是密码字段,就会免费提供这项服务。
有时,提供一个选项以显示整个密码是可以接受的。这通常是在密码字段下方提供的复选框或切换按钮。它并不常用,而且仅在密码本身可能非常复杂的情况下使用。一个很好的例子是在输入 Wi-Fi 信息时;一些设备允许您在不隐藏字母的情况下看到您输入的密码,因为 Wi-Fi 密码非常复杂。
让登录或签到按钮明显。它应该让人想点击。给它不同的颜色、更大的文字——任何能吸引用户注意的方法。
不要忘记为用户提供重置或检索其密码(以及如果未使用他们的电子邮件,则用户名)的方法。如果你的应用程序不提供这种机制,你将面临一些非常沮丧的用户。
如果你的应用程序适合,你可能还想要考虑一个记住我选项。这通常用于安全性不是特别重要,比如银行应用程序的应用程序中。如果你不希望应用程序永远记住用户,通常可以接受记住用户几周或一个月。如果你添加了这个功能,请务必警告用户在共享设备和不可信的网络中使用此功能的危险。
最后一点:使用 SSL。也就是说,登录过程应该在安全连接上进行。
注册表单
登录表单的对应物是注册表单。如果可能的话,如果用户需要登录才能使用你的应用程序,请包括此表单,并在登录表单上提供一个选项以显示此表单。某些平台限制了显示此类表单的能力(尤其是如果平台应用商店之外可以进行支付),但如果可能的话,为了用户的利益,请包括它。没有比发现登录界面,但无法创建全新的账户更糟糕的事情了。

简洁明了。不要询问用户的生活故事,如果你必须询问大量字段,请使用多部分表单。最好将问题数量保持在七或八以下。你问的问题越少,用户注册的可能性就越大。
当要求电子邮件或密码时,使用第二个字段确认这些输入是个好主意。输入电子邮件或密码错误真是太容易了;如果没有确认,用户可能会继续下去,不知道自己犯了什么错误。然后当他们无法登录时,他们可能会责怪你的应用程序。
如果你必须要求提供个人信息,不要忘记解释你如何处理用户的私人数据。链接到你的隐私政策。并且不要忘记为注册表单使用 SSL。
让你的注册按钮看起来非常诱人,让人想点击。给它一个漂亮的颜色,大号字体;它需要引起注意。此外,如果可能的话,提供一个不,谢谢按钮,允许用户在不创建账户的情况下使用你的应用程序。对于某些应用程序,有很多内容可以在不登录的情况下浏览,一些用户会将其作为演示你的服务的方式。也就是说,他们会用它来看看是否想创建账户。如果你不需要,不要将你的内容放在登录墙后面。
表格
表格无处不在。我们在大多数应用程序中使用了它们,从项目 2 到 7,以及项目 9 到 10。这些可以设计得几乎无法辨认出是表格,但本质上任何可重复的内容都是表格。所以,推文列表、电子邮件列表或联系列表都是表格。

表格应以合理简洁的形式提供信息。重要的文本应突出显示,而辅助信息应使用较浅的颜色或较小的字体。如果提供了图像,请确保它足够大,以便有用,并且文本能够很好地围绕它包裹。仔细考虑图像的位置;位置可能传达重要信息(例如,在消息应用中,左侧的图像可能表示发送给您的消息,而右侧的图像可能表示您发送的消息)。
对于支持展开图标(箭头、勾选标记等)的平台,请确保将它们放置在您平台正确的位置。如果点击它们会执行不同于点击行所发生的操作,请确保提供足够大的可点击区域,以便用户能够定位。
一些应用为表格行添加了许多手势。TweetBot 是一个很好的例子,其中向一个方向滑动将执行一个动作,而向另一个方向滑动将执行另一个动作。通常这很新颖,用户不太可能在表格行上做很多滑动,除非看起来内容是可以删除的。在这种情况下,他们可能会从右向左滑动以尝试删除一行。(如果该行实际上是可以删除的,那么出现一个 删除 按钮将是合适的。)以下截图展示了这种情况:

选择列表
这些以许多形式出现,但它们都归结为同一件事:应用希望您从列表中选择某项。列表可以是单选;也就是说,只能选择一个项目,或者它允许多选(这通常用单选按钮、勾选标记或其他类似图标表示)。

外观可能会有很大的差异,从 iOS 上的 选择 列表,到 iOS 的 动作表单,再到 Android 的菜单。选择最适合当前情况和平台的方法。
除非数量非常少,否则不要使用这些来选择数字。没有人愿意滚动浏览 100 行编号为 1-100 的内容,只是为了选择 97。对于这种情况,请使用输入字段。但如果可能的值是 25、50、75 和 100,那么这是可以的(尽管明智的做法可能是将数字拼写出来)。
批量处理事务
批量执行操作有许多不同的方法,但以下示例是一个非常常见的模式。我们在 项目 7,让我们去看电影! 和 项目 8,玩耍 中使用了类似的方法。

在前面的模式中,点击一个项目将标记该项目为选中状态。这可以通过在其旁边放置勾选标记、更改边框颜色(这是我们使用的),或使用其他方法突出显示来实现。只要明显标出哪些项目被选中,哪些项目未被选中,这里就有很多可能性。
一旦做出选择,那么这个模式底部的操作就会生效。用户可能会删除项目,或者他们可能想要做其他事情。如果您决定使用图标而不是文字,请确保使用用户已经理解的图标,例如,垃圾桶可以很好地代替删除。
尽量将可能采取的操作数量减少到绝对最小,尤其是在处理手机有限的可用空间时。
搜索
如果您的应用显示大量数据,几乎不可避免地需要提供一种机制来搜索这些数据。

上述模式是大多数平台上最常见的模式之一。用户可以在搜索字段中输入内容,结果将显示在搜索字段下方的表格中。取消按钮并不总是存在。有时,它只是一个小的图标,但这取决于应用和平台。
搜索是在用户输入时发生,还是需要他们点击屏幕键盘上的搜索按钮,这取决于您。如果您可以快速搜索数据集,那么在用户输入时显示搜索结果可能很明智。这样他们可以快速看到结果集被缩小到他们想要的内容。
然而,如果搜索需要很长时间,那么最好等待用户输入他们想要的内容,然后告诉您何时进行搜索。然后您可以慢慢来(别忘了显示某种通知,表明您的应用正在思考),然后在搜索完成后显示结果。
搜索时的另一个常见模式是需要对搜索范围进行限定,如下面的模式所示:

在这里,搜索栏下方的按钮是分段按钮,这是 iOS 的典型特征。大多数平台都有相应的功能。如果其中一个被点击,它会被突出显示,并且搜索只会在显示的范围内进行。
在这个例子中,如果用户点击目的地,那么搜索将只针对每个项目的目的地部分进行。
当涉及到搜索时,也经常有对数据进行排序和筛选的要求。您通常可以通过使用带有排序和筛选按钮的工具栏来满足这些要求,并显示一个列出各种选项的菜单。例如,点击排序按钮可能会显示一个包含姓氏、名字、账户号码等的菜单。可以实现更多复杂排序和筛选的各种模式,但如果可能的话,请保持简单。
需要记住的一些事项
通常,细节才是关键。以下是一些提示,帮助确保您不仅拥有一个外观出色的应用,而且还有一个感觉良好的应用:
-
避免新颖或不可发现的交互。或者,如果你确实想使用这样的交互,尝试也提供一个实现相同目的的第二个可发现的交互方法。有人可能会争辩说,类似于 Facebook 的滑动菜单可以被认为是新颖的,但如果手势没有被发现,触发菜单的按钮非常明显,很可能会被点击。
-
尊重用户对 UI 元素和手势的期望。一开始这似乎很明显;你不会通过点击垃圾桶来发送电子邮件。尽管如此,有很多方法你可以做用户没有预料到的事情,即使是以微妙的方式。用户的期望根据平台而高度具体,因此遵循你平台的 HIG 将在这个领域有很大帮助。
-
要融入。我的意思是,你的应用应该看起来像是属于你用户的设备。这意味着你的应用应该尊重平台的 HIG。这也意味着你的应用应该给人一种原生应用的外观。未能做到这一点可能会让用户感觉你的应用是这个设备上的二等公民。通过给人一种原生应用的外观,你的应用也应该尽力感觉原生——也就是说,它应该有接近原生应用的速度和响应性。(在某些平台上使用 PhoneGap 可能并不总是容易,甚至可能不可能。在这种情况下,你应该尝试尽可能接近,而不影响你的项目和其时间表。)
-
完美主义者。我的意思是,你应该确保所有你的对象对齐得很好,你的纹理无缝融合,图像缩放正确(特别是根据宽高比),等等。这需要细致入微的注意力,但最终你的应用看起来和感觉会更好。
-
保持响应。尽可能避免冻结用户界面。如果必须冻结 UI,那么向用户显示一个指示器,让他们知道他们的输入将被忽略。当谈到响应性时,这不仅仅是响应按钮的点击,还包括滚动性能。如果一个应用滚动得像抽搐一样,应用会感觉很慢。
-
节约你的数据。虽然你的应用可能经常在 Wi-Fi 连接下使用,但不要忘记那些可能需要处理蜂窝连接的用户。不仅他们的连接本身可能较慢,而且他们通常面临相当苛刻的数据限制。广泛地缓存。避免下载已经下载的内容。如果可以压缩,请务必压缩。或者,给用户提供一个“出路”——如果你的应用无论如何都会使用大量数据,你可能希望让他们在蜂窝连接时禁用使用大量数据的你的应用部分。
摘要
这些只是众多设计模式中的一小部分。在可能的情况下,做一些研究,看看在你的应用中是否有一个适合你想要做的模式的。是的,有时候,你的应用可能需要你做一些完全独特的事情,但大多数情况下,你会发现许多应用已经使用过的成功模式。你的应用将更加易于使用,你的用户也会因此感谢你。你也会收到更少的关于如何使用你应用的客服电话。
附录 B. 安装 ShareKit 2.0
实事求是地说,iOS 上没有系统定义的框架可以轻松地在许多不同的服务之间进行共享。iOS 5 确实提供了 Twitter 框架,iOS 6 通过提供 Facebook 框架对其进行了扩展,但没有任何系统定义的框架可以让你轻松地共享到更广泛的服务。也许 iOS 7 会解决这个问题,但不要抱太大希望。
幸运的是,有一个名为 ShareKit 的开源框架可以帮助解决这个问题。它为我们提供了一个轻松地将内容共享到任何数量服务的方法(假设每个服务都有一个 API 密钥),并且从 PhoneGap 调用它也不困难。不幸的是,安装它也是一个非常痛苦的过程。
我们列出了以下对我们有效的步骤。其中一些步骤来自 ShareKit 的 Wiki,网址为github.com/ShareKit/ShareKit/wiki/Installing-sharekit,因此最好也参考该文档。
-
您需要获取 ShareKit 文件。ShareKit 建议使用 Git 来完成此操作,或者您可以使用我们的副本——这是您的选择。坦白地说,使用我们代码包中
Submodules目录下的副本会更简单。它可能不是 ShareKit 的最新版本,但它包含了工作所需的所有源代码更改。 -
使用以下步骤将 ShareKit 添加到项目中:
- 导航到
Submodules/ShareKit,并将ShareKit.xcodeproj拖动到你的项目中,如图所示:![安装 ShareKit 2.0]()
![安装 ShareKit 2.0]()
- 导航到
-
我们需要对 ShareKit 进行各种调整以允许其编译:
-
前往项目设置,点击构建阶段,展开目标依赖。添加静态库(ShareKit)和资源包(ShareKit)依赖项,如图所示:
![安装 ShareKit 2.0]()
-
展开链接二进制库,并添加如图所示的libShareKit.a:
![安装 ShareKit 2.0]()
-
展开复制资源包,并将 Sharekit 的资源包(位于
Products文件夹下的 ShareKit 子项目中,命名为ShareKit.bundle)复制到列表中,如图所示:![安装 ShareKit 2.0]()
![安装 ShareKit 2.0]()
-
-
接下来我们需要调整头文件搜索路径和链接器标志:
-
切换到构建设置。
-
搜索
user header。 -
双击名为用户头文件搜索路径的行。
-
添加子模块并勾选新条目旁边的复选框。它应该看起来像**子模块/****,如图所示:
![安装 ShareKit 2.0]()
-
现在搜索
other linker。 -
双击名为其他链接器标志的行。
-
如果尚未添加,请添加
-all_load和单独一行上的-ObjC,如图所示:![安装 ShareKit 2.0]()
![安装 ShareKit 2.0]()
-
-
我们需要添加此插件所需的各个 Apple 框架:
-
返回摘要选项卡。
-
滚动到链接库和框架。
-
添加
SystemConfiguration.framework、Security.framework、MessageUI.framework、CFNetwork.framework(用于 Flickr 共享)、CoreLocation.framework(用于 FourSquare 共享)、Twitter.Framework和CoreFoundation.framework。 -
如以下截图所示,将
CoreFoundation.framework设置为可选:
![安装 ShareKit 2.0]()
-
到目前为止,您应该能够无错误地构建项目。
小贴士
如果您没有使用我们的 ShareKit 副本,您可能会收到一个关于找不到协议定义的错误。这发生在 Flickr 代码中;要消除错误,只需删除导致错误的行上的OFFlickrAPIRequestDelegate。由于我们在示例中没有使用 Flickr,这不会引起问题。(然而,如果您将来决定使用 Flickr,可能会引起问题。)
我们需要获取我们打算使用的服务的 API 密钥。在我们的项目中,我们使用了 Twitter、Facebook 和 ReadItLater(现在称为 Pocket)。您可以使用任何您喜欢的服务,但您必须使用自己的 API 密钥来这样做。(我们的代码包不会为您提供 API 密钥。)请访问github.com/ShareKit/ShareKit/wiki/3rd-party-api-links获取有关如何获取这些密钥的一些有用信息。请参考以下步骤:
-
我们必须使用这些密钥配置 ShareKit 2.0,因此请在 XCode 中创建一个新文件,并将其设置为
DefaultSHKConfigurator的子类。我们使用了MySHKConfiguration作为名称,但您可以使用任何您喜欢的名称。请确保将此新文件保存到您的项目中,而不是子项目中。 -
查看 ShareKit | 类 | ShareKit | 分享者 | 配置 | DefaultSHKConfigurator.m 以了解您可以覆盖的方法。每个方法都带有注释,指示针对每个社交网络服务的特定代码。
-
复制您需要覆盖的代码,并将其粘贴到您自己的
configurator文件中。 -
修改每个方法以返回适当的 API 密钥、密钥或其它字符串。以下是一个示例:
- (NSString*)appName { return @"Socializer"; } - (NSString*)appURL { return @"http://www.example.com"; } - (NSString*)facebookAppId { return @"122308337901327"; } - (NSString*)facebookLocalAppId { return @""; } - (NSArray*)facebookListOfPermissions { return [NSArray arrayWithObjects:@"publish_stream", @"offline_access", nil]; } - (NSString*)readItLaterKey { return @"apikey"; } // Twitter - http://dev.twitter.com/apps/newhttp://dev.twitter.com/apps/new - (NSNumber*)forcePreIOS5TwitterAccess { return [NSNumber numberWithBool:true]; } - (NSString*)twitterConsumerKey { return @"apikey"; } - (NSString*)twitterSecret { return @"apikey"; } // You need to set this if using OAuth, see note above (xAuth users can skip it) - (NSString*)twitterCallbackUrl { return @"http://www.example.com/callback"; } // To use xAuth, set to 1 - (NSNumber*)twitterUseXAuth { return [NSNumber numberWithInt:0]; } - (NSString*)twitterUsername { return @""; } -
导航到您的项目的
/Classes/AppDelegate.m文件,并在didFinishLaunchingWithOptions:方法的末尾添加以下代码,紧接在返回语句之前(假设您的文件名为MySHKConfigurator):DefaultSHKConfigurator *configurator = [[MySHKConfigurator alloc] init]; [SHKConfiguration sharedInstanceWithConfigurator:configurator]; -
还需要在导入部分添加以下代码:
#import "SHK.h" #import "SHKConfiguration.h" #import "MySHKConfiguration.h" #import "SHKFacebook.h" -
接下来,我们需要启用离线共享(可选):
- 在上一步新添加的代码下方添加
[SHK flushOfflineQueue];。
- 在上一步新添加的代码下方添加
-
如果您支持 Facebook,则需要支持单点登录(SSO):
-
在
AppDelegate.m中的handleOpenURL:方法末尾添加以下代码(替换return YES;语句):return [self handleOpenURL:url]; } - (BOOL)handleOpenURL:(NSURL*)url { NSString* scheme = [url scheme]; NSString* prefix = [NSString stringWithFormat:@"fb%@", SHKCONFIG(facebookAppId)]; if ([scheme hasPrefix:prefix]) return [SHKFacebook handleOpenURL:url]; return YES; } - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceApplication:(NSString *)sourceApplication annotation:(id)annotation { return [self handleOpenURL:url]; } -
在您的项目的
info.plist文件中添加自定义 URL 方案:-
添加URL 类型
-
添加项目 0
-
添加URL 方案
-
添加项目 0,并设置值为"
fb"以及您的 Facebook 应用 ID
-
![安装 ShareKit 2.0]()
-
为了确定您的AppDelegate.m文件完成后的样子,请将您的文件与下载的代码包中的我们的文件进行核对。
我们几乎完成了;现在我们只需要指出我们不支持的社交网络:
-
将
ShareKit子项目中的SHKSharers.plist文件复制到您的项目中。请确保将其重命名为不同的名称,例如MySHKSharers.plist。 -
删除对应于您不支持的网络的行。以下是我们的一个示例:
![安装 ShareKit 2.0]()
-
将以下代码添加到您的
configurator文件末尾:- (NSString*)sharersPlistName { return @"MySHKSharers.plist"; }
到目前为止,您应该已经将 ShareKit 框架集成到您的项目中。您需要参考项目 2,让我们社交吧!来完成与 ShareKit PhoneGap 插件的集成。
我希望我能给您提供更多关于构建错误需要注意的事项。不幸的是,每次我这样做,似乎都会遇到之前没见过的新的错误。(本文档中之前提到的 Flickr 错误就是一个很好的例子!)通常需要交叉手指并希望的方法来解决这类错误(例如我移除导致错误的协议),但移除错误本身可能并不总是有效。如果您遇到问题,向 PhoneGap 社区或 ShareKit 社区寻求建议是个好主意。















浙公网安备 33010602011771号