NativeScript-Angular-移动开发-全-
NativeScript Angular 移动开发(全)
原文:
zh.annas-archive.org/md5/289e6d84a31dea4e7c2b3cd2576adf55译者:飞龙
前言
NativeScript 是由 Progress 构建的一个开源框架,用于使用 Angular、TypeScript,甚至是古老的纯 JavaScript 构建真正的原生移动应用。Angular 也是一个由 Google 构建的开源框架,它提供了声明式模板、依赖注入和丰富的模块来构建应用程序。Angular 的多功能视图处理架构允许您的视图以真实的原生 UI 组件的形式渲染——对 iOS 或 Android 来说是原生的——这些组件提供了卓越的性能和流畅的可用性。Angular 视图渲染层的解耦,结合 NativeScript 中原生 API 的力量,共同创造了激动人心的 NativeScript for Angular 的世界。
本书重点介绍您需要了解的关键概念,以便在 iOS 和 Android 上为您的 Angular 移动应用构建 NativeScript。我们将构建一个有趣的多人录音室应用,涉及您在开始构建自己的应用时需要了解的强大原生关键概念。拥有正确的结构对于开发可扩展、高度可维护和可移植的应用至关重要,因此我们将从使用 Angular 的@NgModule 进行项目组织开始。我们将使用 Angular 组件来构建我们的第一个视图,然后创建我们可以通过 Angular 的依赖注入使用的服务。
您将了解 NativeScript 的 tns 命令行工具,用于在 iOS 和 Android 上运行应用。我们将集成第三方插件来构建一些核心功能。接下来,我们将集成@ngrx store 和 effects,建立一些稳固的实践(受 Redux 启发)来处理状态管理。如果应用看起来不好或用户体验不佳,那么拥有出色的数据流和稳固的架构就没有意义,因此我们将使用 SASS 为我们的应用打磨一个样式。之后,我们将处理调试问题,并投入一些时间编写测试,以防止未来出现错误。最后,我们将使用 webpack 打包我们的应用,并将其部署到苹果应用商店和谷歌 Play。
到本书结束时,您将了解构建 NativeScript for Angular 应用所需的大部分关键概念。
本书涵盖的内容
第一章,使用@NgModule 进入形状,讨论了@NgModule 装饰器,它明确定义了您应用中的一个功能段。这将是您项目的组织单元。在您开始构建应用之前,花点时间思考您可能需要或想要的应用的各种单元/部分/模块,这将带来许多好处。
第二章, 功能模块,教您如何使用功能模块来构建您的应用,这将为您未来的维护带来许多优势,并减少整个应用中的代码重复。
第三章, 通过组件构建我们的第一个视图,实际上让我们第一次看到了我们的应用,在这里我们需要为我们的第一个视图构建一个组件。
第四章, 使用 CSS 打造更美观的视图,探讨了如何通过几个 CSS 类将我们的第一个视图转变为令人惊叹的美丽,同时也会关注如何利用 NativeScript 的核心主题提供一个一致的样式框架来构建。
第五章, 路由和懒加载,允许用户在应用中的各种视图之间导航,这将需要设置路由。Angular 提供了一个强大的路由器,当与 NativeScript 结合使用时,与 iOS 和 Android 上的原生移动页面导航系统协同工作。此外,我们还将设置各种路由的懒加载,以确保我们的应用启动时间尽可能快。
第六章, 在 iOS 和 Android 上运行应用,重点关注如何通过 NativeScript 的 tns 命令行工具在 iOS 和 Android 上运行我们的应用。
第七章, 构建多轨播放器,涵盖了插件集成,并通过 NativeScript 直接访问 iOS 上的 Objective C/Swift API 和 Android 上的 Java API。
第八章, 构建音频录音机,与原生 API 合作构建 iOS 和 Android 的音频录音机。
第九章, 赋予您的视图力量,利用 Angular 的灵活性和 NativeScript 的强大功能,以充分利用您应用的用户界面。
第十章, @ngrx/store + @ngrx/effects 用于状态管理,通过 ngrx 的单个存储管理应用状态。
第十一章, 使用 SASS 进行润色,集成 nativescript-dev-sass 插件,以 SASS 润色我们的应用样式。
第十二章, 单元测试,设置 Karma 单元测试框架,以确保我们的应用具有未来性。
第十三章, 使用 Appium 进行集成测试,设置 Appium 进行集成测试。
第十四章, 使用 webpack 打包进行部署准备,与 webpack 合作以优化发布时的包。
第十五章, 部署到 Apple App Store,让我们通过 Apple App Store 分发我们的应用。
第十六章, 部署到 Google Play,让我们通过 Google Play 分发我们的应用。
您需要为此书准备的内容
本书假设您正在使用 NativeScript 3 或更高版本和 Angular 4.1 或更高版本。如果您计划进行 iOS 开发,您需要一个安装了 XCode 的 Mac 来运行配套的应用程序。您还应安装至少一个模拟器(最好运行 7.0.0 版本,API 24 或更高版本)的 Android SDK 工具。
这本书适合谁阅读
本书面向所有对 iOS 和 Android 移动应用开发感兴趣的软件开发人员。它专门针对那些已经对 TypeScript 有一般了解以及一些基本 Angular 功能的用户。刚开始接触 iOS 和 Android 移动应用开发的 Web 开发者也可能从本书的内容中获得很多收益。
约定
在这本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“支持各种常见属性(padding、font size、font weight、color、background color等)。此外,缩写边距/填充也有效,即 padding: 15 5。”
代码块应如下设置:
[default]
export class AppComponent {}
当我们希望引起您对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
[default]
public init() {
const item = {};
item.volume = 1; }
任何命令行输入或输出都应如下编写:
# tns run ios --emulator
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“再次运行我们的应用,我们现在在点击 Record 按钮时看到登录提示”。
警告或重要注意事项如下所示。
技巧和窍门如下所示。
读者反馈
我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者的反馈对我们来说很重要,因为它帮助我们开发出您真正能从中受益的标题。要发送一般反馈,请简单地发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书的标题。如果您在某个主题领域有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已经成为 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您充分利用您的购买。
下载示例代码
您可以从www.packtpub.com的账户下载这本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的 SUPPORT 标签上。
-
点击代码下载与勘误。
-
在搜索框中输入书籍名称。
-
选择您想要下载代码文件的书籍。
-
从您购买此书的下拉菜单中选择。
-
点击代码下载。
一旦文件下载完成,请确保使用最新版本的以下软件解压或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/NativeScript-for-Angular-Mobile-Development。我们还有其他来自我们丰富图书和视频目录的代码包可供在github.com/PacktPublishing/找到。查看它们吧!
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/NativeScriptforAngularMobileDevelopment_ColorImages.pdf下载此文件。
勘误
尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书籍中找到错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
海盗行为
在互联网上,版权材料的盗版问题是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在网上遇到任何形式的我们作品的非法副本,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。请通过发送链接到copyright@packtpub.com与我们联系,以提供疑似盗版材料。我们感谢您的帮助,以保护我们的作者和向您提供有价值内容的能力。
问题
如果您在这本书的任何方面遇到问题,您可以联系我们的questions@packtpub.com,我们将尽力解决问题。
第一章:使用 @NgModule 进行塑形
在本章中,我们将从一些扎实的项目组织练习开始,为使用 NativeScript for Angular 构建一个惊人的应用程序做好准备。我们希望向您提供一些见解,让您在规划架构时考虑一些重要且强大的概念,以便在考虑可扩展性的同时,铺就一条顺畅的开发体验之路。
将 Angular 与 NativeScript 结合使用提供了大量有用的范例和工具,用于构建和规划您的应用程序。正如常说的那样,权力越大,责任越大,虽然这种技术组合在创建惊人的应用程序方面非常出色,但它们也可以用于创建过度工程化和难以调试的应用程序。让我们用几章的时间来探讨一些可以帮助您避免常见陷阱并真正发挥此堆栈全部潜力的练习。
我们将向您介绍 Angular 的 @NgModule 装饰器,我们将专门使用它来帮助将我们的应用程序代码组织成具有明确目的和可移植性的逻辑单元。我们还将介绍一些我们将在我们的架构中使用的一些 Angular 概念,例如可依赖注入的服务。在为构建一个坚实的基础而努力之后,我们将在第三章末快速接近运行我们的应用程序。
在本章中,我们将涵盖以下主题:
-
NativeScript for Angular 是什么?
-
设置您的原生移动应用程序
-
项目组织
-
架构规划
-
@NgModule装饰器 -
@Injectable装饰器 -
将您的应用程序拆分为模块
心理准备
在直接进入编码之前,您可以通过规划应用程序所需的各种服务和功能来极大地提高您项目的开发体验。这样做将有助于减少代码重复,构建您的数据流,并为未来的快速功能开发指明方向。
服务是一个通常处理处理和/或为您的应用程序提供数据的类。您对这些服务的使用不需要知道数据的具体来源,只需知道它可以请求服务以实现其目的,并且它就会发生。
绘图练习
这是一个很好的练习,可以勾勒出您应用程序视图的一个粗略想法。您可能还不知道它将是什么样子,这没关系;这是一个纯粹的练习,旨在考虑用户期望,作为引导您思考构建满足这些期望所需的各种部分或模块的第一步。它还将帮助您思考应用程序需要管理的各种状态。
以我们即将构建的应用程序为例,TNSStudio(Telerik NativeScript(TNS))。 我们将在第二章“功能模块”中深入了解我们的应用程序是什么以及它确切会做什么。

从上到下,我们可以看到一个带有菜单按钮、标志和录音按钮的标题。然后,我们有用户录制轨道的列表,每个轨道都有一个(重新)录音按钮和一个静音或取消静音按钮。
从这个草图出发,我们可以考虑应用可能需要提供的一些服务:
-
播放服务
-
录音服务
-
一个持久存储服务,用于记住用户为每个录制轨道设置的音量级别设置,以及用户是否已认证
我们还可以了解应用可能需要管理的各种状态:
-
用户录制/轨道列表
-
无论应用是否正在播放音频
-
无论应用是否处于录音模式
低级思考
提供一些低级服务,提供方便的 API 来访问事物,例如 HTTP 远程请求和/或日志记录,这也是有利的。这样做将允许您创建独特的特性,您或您的团队喜欢在与低级 API 交互时使用。例如,也许您的后端 API 需要在每个请求的特殊认证头之外设置一个唯一的头。创建一个 HTTP 服务的低级包装器将允许您隔离这些独特的特性,并为您的应用提供一个一致的 API 来与之交互,以确保所有 API 调用都在一个地方增强了它们所需的内容。
此外,您的团队可能希望有一种能力将所有日志代码汇总到第三方日志分析器(用于调试或其他性能相关指标)。通过创建围绕某些框架服务的轻量级包装器,将允许您的应用快速适应这些潜在需求。
使用@NgModule 模块化
然后,我们可以考虑将这些服务拆分成组织单元或模块。
Angular 为我们提供了@NgModule装饰器,它将帮助我们定义这些模块的外观以及它们为我们的应用提供的内容。为了使我们的应用启动/启动时间尽可能快,我们可以以这种方式组织我们的模块,以便在应用启动后可以延迟加载一些服务/功能。通过使用应用启动所需的小部分代码启动一个模块,可以帮助将启动阶段保持最小。
我们应用模块分解
这是我们将通过模块分解应用组织的方式:
-
CoreModule:提供低级服务、组件和实用工具,构成一个良好的基础层。例如,与日志记录、对话框、HTTP 和其他各种常用服务的交互。 -
AnalyticsModule******:可能,您可以有一个模块提供各种服务来处理您应用的统计分析。 -
PlayerModule*****:提供我们应用播放音频所需的一切。 -
RecorderModule*****:提供我们应用记录音频所需的一切。
()*这些被认为是功能模块。()我们将从本书的示例中省略此模块,但在此处提及以提供上下文。
模块优势
使用类似的组织结构为您和您的团队提供了几个有利之处:
-
高度易用性:通过设计低级的
CoreModule,您和您的团队有机会以独特的方式设计如何与常用服务协同工作,不仅限于您现在正在构建的应用程序,而且更多地关注未来。您可以轻松地将CoreModule移动到完全不同的应用程序中,并在处理底层服务时获得为该应用程序设计的所有相同独特 API。 -
将您的应用程序代码视为“功能模块”:这样做将帮助您专注于应用程序应提供的独特功能,而不仅仅是
CoreModule提供的功能,同时减少代码的重复。 -
鼓励并增强快速开发:通过将常用功能限制在我们的
CoreModule中,我们减轻了在功能模块中担心这些细节的负担。我们可以简单地注入CoreModule提供的服务,并使用这些 API,而无需重复。 -
可维护性:在未来,如果由于您的应用程序需要与底层服务协同工作,需要更改某些底层细节,则只需在
CoreModule服务中更改一次(而不是在应用程序的不同部分中可能存在的冗余代码中),从而减少代码的重复。 -
性能:将应用程序拆分为模块将允许您在启动时仅加载所需的模块,然后在以后按需懒加载其他功能。最终,这将导致更快的应用程序启动时间。
考虑因素?
您可能会想,为什么不将播放器/录制器模块合并成一个模块呢?
回答:我们的应用程序只允许在注册用户认证时进行录制。因此,考虑认证上下文的可能性以及哪些功能仅对认证用户(如果有的话)可访问是有益的。这将使我们能够进一步微调应用程序的加载性能,使其在需要时才进行。
开始使用
我们将假设您已经在计算机上正确安装了 NativeScript。如果没有,请按照nativescript.org上的安装说明进行操作。安装完成后,我们需要使用 shell 提示符创建我们的应用程序框架:
tns create TNSStudio --ng
tns代表 Telerik NativeScript。它是您将用于创建、构建、部署和测试任何 NativeScript 应用程序的主要命令行用户界面(CLI)工具。
此命令将创建一个名为TNSStudio的新文件夹。其中包含您的主要项目文件夹,包括构建应用程序所需的一切。它将包含与该项目相关的所有内容。在创建项目文件夹后,您还需要做一件事才能拥有一个完全可运行的应用程序。那就是,添加 Android 和/或 iOS 的运行时:
cd TNSStudio
tns platform add ios
tns platform add android
如果你使用的是 Macintosh,你可以为 iOS 和 Android 构建。如果你在 Linux 或 Windows 设备上运行,你可以在本地机器上编译的仅限 Android 平台。
创建我们的模块外壳
在还没有编写我们服务的实现之前,我们可以通过开始定义它应该提供的内容来使用NgModule定义我们的CoreModule大致看起来会是什么样子:
让我们创建app/modules/core/core.module.ts:
// angular
import { NgModule } from '@angular/core';
@NgModule({})
export class CoreModule { }
可注入服务
现在,让我们创建我们服务所需的样板代码。注意,这里的 injectable 装饰器是从 Angular 导入的,用于声明我们的服务将通过 Angular 的依赖注入(DI)系统提供,该系统允许将这些服务注入到可能需要它们的任何类构造函数中。DI 系统提供了一种很好的方式来保证这些服务将被实例化为单例并在我们的应用中共享。还值得注意的是,如果我们不想它们是单例,而是为组件树中的某些分支创建唯一的实例,我们可以将这些服务提供在组件级别上。在这种情况下,尽管如此,我们希望它们被创建为单例。我们将向我们的CoreModule添加以下内容:
-
LogService:服务用于将所有我们的控制台日志引导通过。 -
DatabaseService:用于处理我们应用需要的任何持久数据的服务。对于我们的应用,我们将实现原生移动设备的存储选项,例如应用程序设置,作为一个简单的键/值存储。然而,你在这里可以实现更高级的存储选项,例如通过 Firebase 等远程存储。
创建app/modules/core/services/log.service.ts:
// angular
import { Injectable } from '@angular/core';
@Injectable()
export class LogService {
}
此外,创建app/modules/core/services/database.service.ts:
// angular
import { Injectable } from '@angular/core';
@Injectable()
export class DatabaseService {
}
一致性和标准
为了保持一致性、减少我们导入的长度以及为更好的可扩展性做准备,让我们也在app/modules/core/services中创建一个index.ts文件,该文件将导出我们的服务集合以及导出这些服务(按字母顺序排列以保持整洁):
import { DatabaseService } from './database.service';
import { LogService } from './log.service';
export const PROVIDERS: any[] = [
DatabaseService,
LogService
];
export * from './database.service';
export * from './log.service';
我们将在整本书中遵循类似的组织模式。
完成 CoreModule
我们现在可以修改我们的CoreModule以使用我们已创建的内容。我们将借此机会也导入我们的应用将需要用于与其他 NativeScript for Angular 功能协同工作的NativeScriptModule,我们希望这些功能对应用来说是全局可访问的。既然我们知道我们希望这些功能是全局的,我们也可以指定它们是导出的,这样当我们导入和使用我们的CoreModule时,我们就不必担心在其他地方导入NativeScriptModule。以下是我们的CoreModule修改应该看起来像什么:
// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module';
// angular
import { NgModule } from '@angular/core';
// app
import { PROVIDERS } from './services';
@NgModule({
imports: [
NativeScriptModule
],
providers: [
...PROVIDERS
],
exports: [
NativeScriptModule
]
})
export class CoreModule { }
现在我们已经为我们的CoreModule建立了一个良好的起点,其详细内容将在接下来的章节中实现。
摘要
在本章中,我们为我们的应用程序打下了坚实的基础。你学习了如何从模块的角度思考你的应用程序架构。你还学习了如何利用 Angular 的@NgModule装饰器来构建这些模块。最后,我们现在有一个很好的基础架构,可以在此基础上构建我们的应用程序。
既然你已经掌握了一些关键概念,我们现在可以进入我们应用程序的核心部分,即功能模块。让我们深入了解我们应用程序的主要功能,以继续在第二章,“功能模块”中构建我们的服务层。我们将在第三章,“通过组件构建我们的第一个视图”中为我们的应用程序创建一些视图,并在 iOS 和 Android 上运行应用程序。
第二章:功能模块
我们将通过构建应用程序所需的核心功能模块(播放器和录音器)的基础来继续构建我们应用程序的基础。我们还将注意,录音功能只有在用户认证并首次进入录音模式时才会加载和可用。最后,我们将完成从我们在第一章中创建的 CoreModule 的服务实现,使用 @NgModule 进入形状。
在本章中,我们将涵盖以下主题:
-
创建功能模块
-
应用程序功能的关注点分离
-
设置
AppModule以高效引导,仅加载我们首次视图所需的功能模块 -
使用 NativeScript 的
application-settings模块作为我们的键/值存储 -
在一个地方提供控制我们应用程序调试日志的能力
-
创建一个新服务,该服务将使用其他服务来展示我们的可扩展架构
播放器和录音模块
让我们创建我们两个主要功能模块的框架。请注意,我们还向以下模块的导入中添加了 NativeScriptModule:
PlayerModule: 它将提供播放器特定的服务和组件,无论用户是否认证,都将可用。
让我们创建 app/modules/player/player.module.ts:
// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module';
// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
@NgModule({
imports: [ NativeScriptModule ]
schemas: [ NO_ERRORS_SCHEMA ]
})
export class PlayerModule { }
RecorderModule: 这将提供仅当用户认证并首次进入录音模式时才会加载和提供的录音特定服务和组件。
让我们创建 app/modules/recorder/recorder.module.ts:
// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module';
// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
@NgModule({
imports: [ NativeScriptModule ],
schemas: [ NO_ERRORS_SCHEMA ]
})
export class RecorderModule { }
我们数据的一个共享模型
在我们创建服务之前,让我们为我们的应用程序将使用的数据的核心部分创建一个接口和模型实现。TrackModel 将代表一个单独的轨道,如下所示:
-
filepath: (到本地文件) -
name: (用于我们的视图) -
order: 位置(用于轨道查看列表) -
volume: 我们希望我们的播放器能够通过不同的音量级别设置混合不同的轨道 -
solo: 我们是否只想在我们的混合中听到这个轨道
我们还将向我们的模型添加一个方便的构造函数,它将接受一个对象来初始化我们的模型。
创建 app/modules/core/models/track.model.ts,因为它将在我们的播放器和录音器之间共享:
export interface ITrack {
filepath?: string;
name?: string;
order?: number;
volume?: number;
solo?: boolean;
}
export class TrackModel implements ITrack {
public filepath: string;
public name: string;
public order: number;
public volume: number = 1; // set default to full volume
public solo: boolean;
constructor(model?: any) {
if (model) {
for (let key in model) {
this[key] = model[key];
}
}
}
}
构建服务 API 的框架
现在,让我们创建我们的服务将为我们的应用程序提供的服务 API。从 PlayerService 开始,我们可以想象以下 API 可能对管理轨道和控制播放很有用。其中大部分应该是相当自解释的。我们可能稍后会重构它,但这是一个很好的开始:
-
playing: boolean; -
tracks: Array<ITrack>; -
play(index: number): void; -
pause(index: number): void; -
addTrack(track: ITrack): void; -
removeTrack(track: ITrack): void; -
reorderTrack(track: ITrack, newIndex: number): void;
创建 app/modules/player/services/player.service.ts 并为其中的一些方法提供占位符;其中一些我们可以立即实现:
// angular
import { Injectable } from '@angular/core';
// app
import { ITrack } from '../../core/models';
@Injectable()
export class PlayerService {
public playing: boolean;
public tracks: Array<ITrack>;
constructor() {
this.tracks = [];
}
public play(index: number): void {
this.playing = true;
}
public pause(index: number): void {
this.playing = false;
}
public addTrack(track: ITrack): void {
this.tracks.push(track);
}
public removeTrack(track: ITrack): void {
let index = this.getTrackIndex(track);
if (index > -1) {
this.tracks.splice(index, 1);
}
}
public reorderTrack(track: ITrack, newIndex: number) {
let index = this.getTrackIndex(track);
if (index > -1) {
this.tracks.splice(newIndex, 0, this.tracks.splice(index, 1)[0]);
}
}
private getTrackIndex(track: ITrack): number {
let index = -1;
for (let i = 0; i < this.tracks.length; i++) {
if (this.tracks[i].filepath === track.filepath) {
index = i;
break;
}
}
return index;
}
}
现在,让我们通过为我们模块导出此服务来应用我们的标准。
创建 app/modules/player/services/index.ts:
import { PlayerService } from './player.service';
export const PROVIDERS: any[] = [
PlayerService
];
export * from './player.service';
最后,修改我们的 PlayerModule 以指定正确的提供者,因此我们的最终模块应该看起来如下:
// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module';
// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
// app
import { PROVIDERS } from './services';
@NgModule({
imports: [ NativeScriptModule ],
providers: [ ...PROVIDERS ],
schemas: [ NO_ERRORS_SCHEMA ]
})
export class PlayerModule { }
接下来,我们可以设计 RecorderService 以提供简单的记录 API。
创建 app/modules/recorder/services/recorder.service.ts:
-
record(): void -
stop(): void
// angular
import { Injectable } from '@angular/core';
@Injectable()
export class RecorderService {
public record(): void { }
public stop(): void { }
}
现在,通过导出此服务为我们模块应用我们的标准。
创建 app/modules/recorder/services/index.ts:
import { RecorderService } from './recorder.service';
export const PROVIDERS: any[] = [
RecorderService
];
export * from './recorder.service';
最后,修改我们的 RecorderModule 以指定正确的提供者,因此我们的最终模块应该看起来如下:
// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module';
// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
// app
import { PROVIDERS } from './services';
@NgModule({
imports: [ NativeScriptModule ],
providers: [ ...PROVIDERS ],
schemas: [ NO_ERRORS_SCHEMA ]
})
export class RecorderModule { }
在我们的两个主要功能模块搭建并准备就绪后,让我们回顾一下在第一章 Get Into Shape with @NgModule 中创建的两个低级服务,并提供实现。
实现 LogService
日志是你在应用开发周期以及生产过程中都希望拥有的重要盟友。它可以帮助你调试,同时深入了解你的应用是如何被使用的。通过一个单一的路径运行所有日志也提供了一个机会,只需切换一下开关,就可以将所有应用日志重定向到其他地方。例如,你可以通过 Segment (segment.com) 使用第三方调试跟踪服务,如 TrackJS (trackjs.com))。你希望将应用的重要方面大量通过日志运行,并且它提供了一个很好的地方来拥有很多控制和灵活性。
让我们打开 app/modules/core/services/log.service.ts 并开始工作。让我们首先定义一个静态布尔值,它将作为一个简单的标志,我们可以在 AppModule 中切换以启用/禁用。同时,我们也添加一些有用的方法:
import { Injectable } from '@angular/core';
@Injectable()
export class LogService {
public static ENABLE: boolean = true;
public debug(msg: any, ...formatParams: any[]) {
if (LogService.ENABLE) {
console.log(msg, formatParams);
}
}
public error(msg: any, ...formatParams: any[]) {
if (LogService.ENABLE) {
console.error(msg, formatParams);
}
}
public inspect(obj: any) {
if (LogService.ENABLE) {
console.log(obj);
console.log('typeof: ', typeof obj);
if (obj) {
console.log('constructor: ', obj.constructor.name);
for (let key in obj) {
console.log(`${key}: `, obj[key]);
}
}
}
}
}
-
debug:这将作为我们最常用的日志输出 API。 -
error:当我们知道某个条件是错误时,这将帮助我们识别日志中的这些位置。 -
inspect:有时候查看一个对象可以帮助我们找到错误或帮助我们理解应用在任何给定时刻的状态。
在我们的 LogService 实现之后,我们现在将在整个应用以及本书的其余部分中使用它,而不是直接使用控制台。
实现 DatabaseService
我们的 DatabaseService 需要提供以下几项:
-
一个持久存储来保存和检索应用所需的所有数据。
-
它应该允许存储任何类型的数据;然而,我们特别希望它能够处理 JSON 序列化。
-
所有我们希望存储的数据的静态键。
-
一个指向已保存用户的静态引用?嗯,是的,可以。然而,这引出了一个我们将在稍后解决的问题。
关于第一项,我们可以使用 NativeScript 的 application-settings 模块。在底层,此模块提供了一个一致的 API 来与两个原生移动 API 一起工作:
-
iOS:
NSUserDefaults:developer.apple.com/reference/foundation/userdefaults -
Android:
SharedPreferences:developer.android.com/reference/android/content/SharedPreferences.html
关于序列化 JSON 数据,application-settings模块提供了setString和getString方法,这将允许我们结合使用JSON.stringify和JSON.parse。
在代码库的多个不同位置使用字符串值来引用应该保持恒定的相同键,可能会变得容易出错。因此,我们将保留一个类型化的(为了类型安全)静态哈希表,其中包含我们应用程序将使用的有效键。我们可能目前只知道一个(认证用户作为'current-user'),但创建这个哈希表将提供一个单一的位置来随着时间的推移扩展这些键。
四个?我们将在稍后解决四个问题。
打开app/modules/core/services/database.service.ts并修改它,以便提供类似于网络localStorage API 的类似 API,以简化操作:
// angular
import { Injectable } from '@angular/core';
// nativescript
import * as appSettings from 'application-settings';
interface IKeys {
currentUser: string;
}
@Injectable()
export class DatabaseService {
public static KEYS: IKeys = {
currentUser: 'current-user'
};
public setItem(key: string, value: any): void {
appSettings.setString(key, JSON.stringify(value));
}
public getItem(key: string): any {
let item = appSettings.getString(key);
if (item) {
return JSON.parse(item);
}
return item;
}
public removeItem(key: string): void {
appSettings.remove(key);
}
}
此服务现在提供了一种通过setItem存储对象的方式,这确保了对象通过JSON.stringify正确地存储为字符串。它还提供了一种通过getItem检索值的方式,它还通过JSON.parse为我们处理序列化回对象。我们还有一个用于从持久存储中简单删除值的remove API。最后,我们有一个指向所有有效键的静态引用,这些键是我们持久存储将跟踪的。
那么,关于保存用户的静态引用呢?
我们希望能够在应用程序的任何地方轻松访问我们的认证用户。为了简单起见,我们可以在DatabaseService中提供一个静态引用,但我们的目标是实现关注点的清晰分离。既然我们知道我们将来会想要展示一个模态窗口让用户注册并解锁那些录制功能,那么创建一个新的服务来管理这一点是有意义的。由于我们已经设计了可扩展的架构,我们可以轻松地添加另一个服务,所以现在就让我们这么做吧!
创建AuthService以帮助处理我们应用程序的认证状态
对于我们的AuthService来说,一个重要的考虑因素是理解,我们的应用程序中的一些组件可能会从认证状态变化时得到通知。这是一个利用 RxJS 的完美用例。RxJS 是一个非常强大的库,用于通过观察者来简化处理变化的数据和事件。观察者是一种数据类型,您可以使用它不仅来监听事件,还可以在事件发生时进行过滤、映射、归约和运行代码序列。通过使用观察者,我们可以极大地简化我们的异步开发。我们将使用一种特定的观察者类型,即BehaviorSubject,来发出我们的组件可以订阅的变化。
创建app/modules/core/services/auth.service.ts并添加以下内容:
// angular
import { Injectable } from '@angular/core';
// lib
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
// app
import { DatabaseService } from './database.service';
import { LogService } from './log.service';
@Injectable()
export class AuthService {
// access our current user from anywhere
public static CURRENT_USER: any;
// subscribe to authenticated state changes
public authenticated$: BehaviorSubject<boolean> =
new BehaviorSubject(false);
constructor(
private databaseService: DatabaseService,
private logService: LogService
) {
this._init();
}
private _init() {
AuthService.CURRENT_USER = this.databaseService
.getItem(DatabaseService.KEYS.currentUser);
this.logService.debug(`Current user: `,
AuthService.CURRENT_USER);
this._notifyState(!!AuthService.CURRENT_USER);
}
private _notifyState(auth: boolean) {
this.authenticated$.next(auth);
}
}
这里有一些有趣的事情在进行。我们正在放置另外两个我们设计的立即工作的服务,LogService和DatabaseService。它们帮助我们检查用户是否被保存/认证,并记录这个结果。
我们在通过 Angular 的依赖注入系统构建我们的服务时,也会调用一个private _init方法。这允许我们立即检查在我们的持久存储中是否存在已认证的用户。然后,我们调用一个私有的可重用方法_notifyState,它将在我们的authenticated$可观察对象上发出true或false。这将提供一种很好的方式,让其他组件通过订阅这个可观察对象来轻松地得到认证状态变化的通知。我们使_notifyState可重用,因为我们的登录和注册方法(将在未来实现)将能够在从我们可能在 UI 中显示的模态返回结果时使用它。
我们现在可以轻松地将AuthService添加到我们的PROVIDERS中,并且我们不需要做任何其他事情来确保它被添加到我们的CoreModule中,因为我们的PROVIDERS已经添加到了CoreModule。
我们需要做的只是修改app/modules/core/services/index.ts并添加我们的服务:
import { AuthService } from './auth.service';
import { DatabaseService } from './database.service';
import { LogService } from './log.service';
export const PROVIDERS: any[] = [
AuthService,
DatabaseService,
LogService
];
export * from './auth.service';
export * from './database.service';
export * from './log.service';
等等!我们想要做的一件重要的事情是确保我们的 AuthService 初始化!
Angular 的依赖注入系统只会实例化在某个地方注入的服务。尽管我们已经在我们的CoreModule中将所有服务指定为提供者,但它们实际上只有在被注入到某个地方时才会被构建!
打开app/app.component.ts并将其内容替换为以下内容:
// angular
import { Component } from '@angular/core';
// app
import { AuthService } from './modules/core/services';
@Component({
selector: 'my-app',
templateUrl: 'app.component.html',
})
export class AppComponent {
constructor(private authService: AuthService) { }
}
我们通过将AuthService指定为组件构造函数的参数来注入我们的AuthService。这将导致 Angular 构建我们的服务。我们代码中的所有后续注入都将接收相同的单例。
准备启动 AppModule
我们现在为我们的功能模块提供了一个良好的设置,现在是时候将它们全部整合到我们的根AppModule中,它负责启动我们的应用程序。
只启动你初始视图所需的部分。其余部分按需懒加载。
重要的是要尽可能快地启动我们的应用程序。为了实现这一点,我们只想使用我们初始视图所需的主要功能来启动应用程序,并在需要时懒加载其余部分。我们知道我们希望我们的低级服务在任何地方都可用并准备好使用,所以我们肯定会希望CoreModule预先加载。
我们从草图中的初始视图将开始于播放器和列表上的 2-3 个曲目,这样用户就可以立即播放我们将与应用程序一起提供的预录制的曲目的混合。出于这个原因,当我们的应用程序启动时,我们将指定PlayerModule预先加载,因为它将是我们想要立即参与的主要功能。
我们将设置一个路由配置,当用户点击初始视图右上角的记录按钮以开始录音会话时,将懒加载我们的RecorderModule。
考虑到这一点,我们可以在app/app.module.ts中设置我们的AppModule,如下所示:
// angular
import { NgModule } from '@angular/core';
// app
import { AppComponent } from './app.component';
import { CoreModule } from './modules/core/core.module';
import { PlayerModule } from './modules/player/player.module';
@NgModule({
imports: [
CoreModule,
PlayerModule
],
declarations: [AppComponent],
bootstrap: [AppComponent]
})
export class AppModule { }
摘要
在整个过程中,我们一直在努力创建一个坚实的基础来构建我们的应用程序。我们创建了一个CoreModule来提供一些低级服务,例如日志记录和持久存储,并设计了该模块以便在需要时轻松扩展更多服务。此外,此模块是可移植的,并且可以完整地插入到其他项目中。
在典型的应用程序开发中,你可能想在 iOS 和/或 Android 模拟器上运行你的应用程序,在这个过程中双重检查一些你的设计/架构选择,这是值得推荐的!我们还没有这样做,因为我们已经有一个预计划的应用程序,并希望你能专注于我们正在做出的选择以及为什么这样做。
我们还创建了两个主要的功能模块,这是我们的应用程序核心能力所需的,即PlayerModule和RecorderModule。播放器将预先设置,加载并准备好 2-3 条录音轨道,以便在启动时即可播放,因此我们将使用PlayerModule的功能来启动我们的应用程序。
我们将提供一个简单的方法,让用户能够注册账户,这样他们就可以记录自己的轨迹并添加到混合中。一旦他们登录,他们将通过一个路径进入记录模式,该路径会懒加载RecorderModule。
在下一章中,我们将创建我们的第一个视图,配置我们的路由,并最终,一睹我们的应用程序的真容。
第三章:通过组件构建我们的第一个视图
我们一直在努力构建第二章中“功能模块”的基础,现在终于可以一窥我们所工作的内容。这全部关于将我们的草图中的第一个视图移动到移动设备屏幕上。
使用 NativeScript 为 Angular 构建视图与网页视图构建没有太大区别。我们将使用 Angular 的组件装饰器来构建我们 UI 需要的各种组件,以实现我们追求的可用性。我们将使用 NativeScript XML 而不是 HTML 标记,因为 NativeScript XML 是一个非常强大、简单且简洁的抽象,它代表了 iOS 和 Android 上的所有原生视图组件。
我们不会涵盖所有可用的组件的好处和类型;但若想了解更多,我们推荐以下任何一本书籍:
本章我们将涵盖以下主题:
-
使用组件装饰器来组合我们的视图
-
创建可重用组件
-
使用管道创建自定义视图过滤器
-
在 iOS 和 Android 模拟器上运行应用
通过组件构建我们的第一个视图
如果我们查看第一章中的草图,“使用 @NgModule 进入形状”,我们可以看到应用顶部的标题栏,其中将包含我们的应用标题和右侧的记录按钮。我们还可以看到底部的曲目列表和一些播放器控制。我们可以将这些 UI 设计的关键元素分解为基本上三个主要组件。一个组件是由 NativeScript 框架提供的,即 ActionBar,我们将用它来表示顶部标题栏。
NativeScript 提供了许多丰富的视图组件来构建我们的 UI。标记不是 HTML,而是具有 .html 扩展名的 XML,这可能会显得有些不寻常。使用 NativeScript for Angular 的 .html 扩展名用于 XML 视图模板的原因是自定义渲染器(github.com/NativeScript/nativescript-angular)使用 DOM 适配器来解析视图模板。每个 NativeScript XML 组件代表各自平台上的真实原生视图小部件。
对于其他两个主要组件,我们将使用 Angular 的组件装饰器。在应用开发周期的这个阶段,考虑封装的 UI 功能块非常重要。我们将把我们的曲目列表封装为一个组件,并将播放器控制封装为另一个组件。在这个练习中,我们将从抽象的观点开始,逐步到每个组件的实现细节,采用自外向内的方法来构建我们的 UI。
首先,让我们关注我们的 Angular 应用程序中的根组件,因为它将定义我们第一个视图的基本布局。打开 app/app.component.html,清除其内容,并用以下内容替换,以从我们的草图草拟初始 UI 概念:
<ActionBar title="TNSStudio">
</ActionBar>
<GridLayout rows="*, 100" columns="*">
<track-list row="0" col="0"></track-list>
<player-controls row="1" col="0"></player-controls>
</GridLayout>
我们使用 ActionBar 和主要视图的主要布局容器 GridLayout 来表达我们的视图。在 NativeScript 中,每个视图以一个布局容器作为根节点(在 ActionBar 或 ScrollView 之外)开始是很重要的,就像在 HTML 标记中使用 div 标签一样。在撰写本文时,NativeScript 提供了六个布局容器:StackLayout、GridLayout、FlexboxLayout、AbsoluteLayout、DockLayout 和 WrapLayout。对于我们的布局,GridLayout 会工作得很好。
关于 GridLayout 的所有内容
GridLayout 是你在 NativeScript 应用程序中会用到的三种最常用的布局之一(其他的是 FlexboxLayout 和 StackLayout)。这是允许你轻松构建复杂布局的布局。使用 GridLayout 非常类似于 HTML 中的增强表格。你基本上会想要将屏幕区域划分为你需要的部分。它将允许你告诉列(或行)占屏幕剩余宽度(和高度)的百分比。网格支持三种类型的值;绝对大小、剩余空间的百分比和已用空间。
对于绝对大小,你只需输入数字。例如,100表示它将使用 100 dp 的空间。
dp 的另一个名称是 dip。它们是相同的。设备无关像素(也称为密度无关像素,DIP 或 DP)是基于计算机坐标系统的物理单位,代表了一个用于应用程序的像素抽象,底层系统将其转换为物理像素。
如果你选择支持的最小 iOS 设备,它的屏幕宽度为 320dp。对于其他设备,例如平板电脑,一些设备的宽度为 1024 dp。因此,100 dp 几乎是 iOS 手机的一个三分之一,而在平板电脑上则是屏幕的十分之一。所以,在使用固定绝对值时,你需要考虑这一点。通常,使用已用空间比使用固定值更好,除非你需要将列限制为特定大小。
要使用基于剩余空间的值,即 ***,*** 告诉它使用剩余的空间。如果列(或行)设置为 *,则空间将分为两个相等的剩余空间。同样,rows="*,*,*,*,*" 将指定五个等大小的行。你还可以指定一些事情,例如 columns="2*,3*,*",,你将得到三个列;第一个列将是屏幕的六分之二,第二个列将是屏幕的六分之三,最后一个列将是屏幕的六分之一(即 2+3+1=6)。这允许你在使用剩余空间方面有极大的灵活性。
第三种尺寸是空间使用。所以,当网格内部的内容被测量后,列将被分配一个大小,这个大小是该列(或行)中使用的最大值。这在您有一个包含数据但不确定大小或您并不真的在乎的网格时非常有用;您只是希望它看起来不错。所以,这是 auto 关键字。我可能有columns="auto,auto,*,auto"。这意味着第 1、2 和 4 列将根据这些列中的内容自动调整大小;而第 3 列将使用剩余的空间。这对于布局整个屏幕或屏幕的某些部分非常有用,您希望达到某种特定的外观。
GridLayout 之所以是最佳布局之一,最后一个原因是当您将项目分配给 GridLayout 时,您实际上可以将多个项目分配给相同的行和/或列,并且您可以使用行或列跨度来允许项目使用多个行和/或列。
要分配一个对象,您只需通过row="0"和/或col="0"进行分配(请注意,这些是基于索引的位置)。您还可以使用rowSpan和colSpan来使一个元素跨越多个行和/或列。总的来说,GridLayout 是最灵活的布局,允许您轻松创建您在应用程序中需要的几乎任何布局。
回到我们的布局
在网格内部,我们声明了一个track-list组件来表示我们的轨道列表,该组件将垂直伸缩,占据所有垂直空间,并为player-controls留下 100 像素的高度。我们将track-list标记为row="0" col="0",因为行和列是基于索引的。网格的灵活(剩余)垂直高度是通过 GridLayout 的*在行属性中定义的。网格的底部部分(第 1 行)将代表播放器控制,允许用户播放/暂停混合并移动播放位置。
现在我们已经以相当抽象的方式定义了应用程序的主要视图,让我们深入了解我们需要构建的两个自定义组件,track-list和player-controls。
构建 TrackList 组件
轨道列表应该是所有已记录轨道的列表。列表中的每一行应提供一个单独的记录按钮以重新录制,以及一个名称标签来显示用户提供的标题。它还应提供一个开关,允许用户仅独奏该特定轨道。
我们可以注入PlayerService并将其声明为public,以便我们可以直接绑定到服务中的轨道集合。
我们也可以模拟一些我们的绑定来启动一些操作,比如record动作。目前,我们只允许传入一个轨道,并通过LogService打印出该轨道的检查信息。
让我们从创建app/modules/player/components/track-list/ track-list.component.ts(与匹配的.html模板)开始:
// angular
import { Component, Input } from '@angular/core';
// app
import { ITrack } from '../../../core/models';
import { LogService } from '../../../core/services';
import { PlayerService } from '../../services/player.service';
@Component({
moduleId: module.id,
selector: 'track-list',
templateUrl: 'track-list.component.html'
})
export class TrackListComponent {
constructor(
private logService: LogService,
public playerService: PlayerService
) { }
public record(track: ITrack) {
this.logService.inspect(track);
}
}
对于视图模板 track-list.component.html,我们将使用强大的 ListView 组件。此小部件代表 iOS 上的原生 UITableView (developer.apple.com/reference/uikit/uitableview) 和 Android 上的原生 ListView (developer.android.com/guide/topics/ui/layout/listview.html),提供具有复用行的 60 fps 虚拟滚动。在移动设备上的性能无与伦比:
<ListView [items]="playerService.tracks">
<ng-template let-track="item">
<GridLayout rows="auto" columns="75,*,100">
<Button text="Record" (tap)="record(track)"
row="0" col="0"></Button>
<Label [text]="track.name" row="0" col="1"></Label>
<Switch [checked]="track.solo" row="0" col="2">
</Switch>
</GridLayout>
</ng-template>
</ListView>
这个视图模板中有很多内容,让我们稍微检查一下。
由于我们在组件构造函数中注入 playerService 时将其设置为 public,我们可以通过 ListView 项的属性直接绑定到其轨道,使用标准的 Angular 绑定语法表示为 [items]。这将是我们列表迭代的集合。
template 节点内部允许我们封装列表每一行布局的方式。它还允许我们声明一个变量名(let-track),用作我们的迭代器引用。
我们从 GridLayout 开始,因为每一行将包含一个记录按钮(允许重新录制轨道),我们将为其分配宽度 75。此按钮将绑定到 tap 事件,如果用户已认证,则将激活一个录制会话。
然后,我们将有一个标签来显示用户提供的轨道名称,我们将将其分配为 * 以确保它扩展以填充我们左侧和右侧列之间的水平空间。我们使用文本属性将其绑定到 track.name。
最后,我们将使用 switch 允许用户切换混音中的轨道独奏。这提供了 checked 属性,使我们能够将 track.solo 属性绑定到它。
构建一个对话框包装服务以提示用户
如果您还记得 第一章 中 使用 @NgModule 进入形状,录制是一个仅应提供给认证用户的功能。因此,当用户在每条轨道上点击记录按钮时,我们将想要提示用户登录对话框。如果他们已经登录,我们将想要提示他们确认是否想要重新录制轨道,以确保良好的用户体验。
我们可以直接在组件中处理这个对话框,通过导入提供跨平台一致 API 的 NativeScript 对话框服务。NativeScript 框架的 ui/dialogs 模块(docs.nativescript.org/ui/dialogs)是一个非常方便的服务,允许你创建原生警报、确认、提示、操作和基本的登录对话框。然而,我们可能希望在将来为 iOS 和 Android 提供定制的原生对话框实现,以获得更好的 UX 体验。有几个插件提供了非常优雅的原生对话框,例如,github.com/NathanWalker/nativescript-fancyalert。
为了准备这个丰富的用户体验,让我们构建一个快速的 Angular 服务,我们可以注入并在任何地方使用,这将允许我们轻松地实现这些细微之处。
由于这应该被视为我们应用的 核心 服务,让我们创建 app/modules/core/services/dialog.service.ts:
// angular
import { Injectable } from '@angular/core';
// nativescript
import * as dialogs from 'ui/dialogs';
@Injectable()
export class DialogService {
public alert(msg: string) {
return dialogs.alert(msg);
}
public confirm(msg: string) {
return dialogs.confirm(msg);
}
public prompt(msg: string, defaultText?: string) {
return dialogs.prompt(msg, defaultText);
}
public login(msg: string, userName?: string, password?: string) {
return dialogs.login(msg, userName, password);
}
public action(msg: string, cancelButtonText?: string,
actions?: string[]) {
return dialogs.action(msg, cancelButtonText, actions);
}
}
初看之下,这可能会显得非常浪费!为什么创建一个提供与 NativeScript 框架中已存在的服务完全相同 API 的包装器?
是的,确实如此,在这个阶段看起来是这样的。然而,我们正在为未来的灵活性及处理这些对话框的能力做准备,以实现卓越。请继续关注,可能会有关于这个有趣且独特的整合润色的额外章节。
在我们继续使用此服务之前,我们需要确保它被添加到我们的核心服务 PROVIDERS 集合中。这将确保 Angular 的 DI 系统知道我们的新服务是一个有效的令牌,可用于注入。
打开 app/modules/core/services/index.ts 并按照以下方式修改:
import { AuthService } from './auth.service';
import { DatabaseService } from './database.service';
import { DialogService } from './dialog.service';
import { LogService } from './log.service';
export const PROVIDERS: any[] = [
AuthService,
DatabaseService,
DialogService,
LogService
];
export * from './auth.service';
export * from './database.service';
export * from './dialog.service';
export * from './log.service';
我们现在已准备好注入和使用我们的新服务。
将 DialogService 集成到我们的组件中
让我们打开 track-list.component.ts 并注入 DialogService 以在我们的记录方法中使用。我们还需要确定用户是否已登录,以便有条件地显示登录对话框或确认提示,所以让我们也注入 AuthService:
// angular
import { Component, Input } from '@angular/core';
// app
import { ITrack } from '../../../core/models';
import { AuthService, LogService, DialogService } from '../../../core/services';
import { PlayerService } from '../../services/player.service';
@Component({
moduleId: module.id,
selector: 'track-list',
templateUrl: 'track-list.component.html'
})
export class TrackListComponent {
constructor(
private authService: AuthService,
private logService: LogService,
private dialogService: DialogService,
public playerService: PlayerService
) { }
public record(track: ITrack, usernameAttempt?: string) {
if (AuthService.CURRENT_USER) {
this.dialogService.confirm(
'Are you sure you want to re-record this track?'
).then((ok) => {
if (ok) this._navToRecord(track);
});
} else {
this.authService.promptLogin(
'Provide an email and password to record.',
usernameAttempt
).then(
this._navToRecord.bind(this, track),
(usernameAttempt) => {
// initiate sequence again
this.record(track, usernameAttempt);
}
);
}
}
private _navToRecord(track: ITrack) {
// TODO: navigate to record screen
this.logService.debug('yes, re-record', track);
}
}
记录方法现在首先检查用户是否通过静态 AuthService.CURRENT_USER 引用进行认证,该引用是在 AuthService 首次通过 Angular 的依赖注入在应用启动时设置的(参见第二章,功能模块)。
如果用户已认证,我们将展示一个确认对话框以确保操作是故意的。
如果用户未认证,我们希望提示用户登录。为了减少本书的负担,我们假设用户已经通过后端 API 注册,因此我们不会要求用户注册。
我们需要在AuthService中实现promptLogin方法以持久化用户的登录凭据,这样每次他们返回应用时,它将自动登录。记录方法现在提供了一个额外的可选参数usernameAttempt,这在用户在输入验证错误后重新初始化登录序列时重新填充登录提示的用户名字段时将非常有用。我们在这里不会进行彻底的用户输入验证,但我们可以至少进行轻量级的有效电子邮件检查。
在你自己的应用中,你可能需要进行更多的用户输入验证。
为了保持关注点的清晰分离,打开app/modules/core/services/auth.service.ts以实现promptLogin。以下是整个服务及其修改内容:
// angular
import { Injectable } from '@angular/core';
// lib
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
// app
import { DatabaseService } from './database.service';
import { DialogService } from './dialog.service';
import { LogService } from './log.service';
@Injectable()
export class AuthService {
// access our current user from anywhere
public static CURRENT_USER: any;
// subscribe to authenticated state changes
public authenticated$: BehaviorSubject<boolean> =
new BehaviorSubject(false);
constructor(
private databaseService: DatabaseService,
private dialogService: DialogService,
private logService: LogService
) {
this._init();
}
public promptLogin(msg: string, username: string = '')
: Promise<any> {
return new Promise((resolve, reject) => {
this.dialogService.login(msg, username, '')
.then((input) => {
if (input.result) { // result = false when canceled
if (input.userName &&
input.userName.indexOf('@') > -1) {
if (input.password) {
// persist user credentials
this._saveUser(
input.userName, input.password
);
resolve();
} else {
this.dialogService.alert(
'You must provide a password.'
).then(reject.bind(this, input.userName));
}
} else {
// reject, passing userName back
this.dialogService.alert(
'You must provide a valid email address.'
).then(reject.bind(this, input.userName));
}
}
});
});
}
private _saveUser(username: string, password: string) {
AuthService.CURRENT_USER = { username, password };
this.databaseService.setItem(
DatabaseService.KEYS.currentUser,
AuthService.CURRENT_USER
);
this._notifyState(true);
}
private _init() {
AuthService.CURRENT_USER =
this.databaseService
.getItem(DatabaseService.KEYS.currentUser);
this.logService.debug(
`Current user: `, AuthService.CURRENT_USER
);
this._notifyState(!!AuthService.CURRENT_USER);
}
private _notifyState(auth: boolean) {
this.authenticated$.next(auth);
}
}
我们使用dialogService.login方法打开一个原生登录对话框,允许用户输入用户名和密码。一旦他们选择确定,我们将对输入进行最小验证,如果成功,则通过DatabaseService持久化用户名和密码。否则,我们简单地提醒用户错误,并拒绝我们的承诺,传递他们输入的用户名。这允许我们通过重新显示带有失败用户名的登录对话框来帮助用户,使他们更容易进行更正。
在完成这些服务级别的细节后,track-list组件看起来相当不错。然而,在我们处理这个组件的时候,我们应该采取一个额外的步骤。如果你还记得,我们的 TrackModel 包含一个顺序属性,这将帮助用户以任何他们希望的方式方便地对轨道进行排序。
创建 Angular 管道 - OrderBy
Angular 提供了 Pipe 装饰器,以便轻松创建视图过滤器。让我们首先展示我们如何在视图中使用它。你可以看到它看起来非常类似于 Unix shell 脚本中使用的命令行管道;因此,它被命名为:Pipe:
<ListView [items]="playerService.tracks | orderBy: 'order'">
这将获取playerService.tracks集合,并确保它通过每个TrackModel的order属性进行排序,以便在视图显示中使用。
由于我们可能希望在我们的应用视图中任何地方使用这个管道,让我们将其作为CoreModule的一部分添加。创建app/modules/core/pipes/order-by.pipe.ts,以下是我们将如何实现OrderByPipe:
import { Pipe } from '@angular/core';
@Pipe({
name: 'orderBy'
})
export class OrderByPipe {
// Comparator method
static comparator(a: any, b: any): number {
if (a === null || typeof a === 'undefined') a = 0;
if (b === null || typeof b === 'undefined') b = 0;
if ((isNaN(parseFloat(a)) || !isFinite(a)) ||
(isNaN(parseFloat(b)) || !isFinite(b))) {
// lowercase strings
if (a.toLowerCase() < b.toLowerCase()) return -1;
if (a.toLowerCase() > b.toLowerCase()) return 1;
} else {
// ensure number values
if (parseFloat(a) < parseFloat(b)) return -1;
if (parseFloat(a) > parseFloat(b)) return 1;
}
return 0; // values are equal
}
// Actual value transformation
transform(value: Array<any>, property: string): any {
return value.sort(function (a: any, b: any) {
let aValue = a[property];
let bValue = b[property];
let comparison = OrderByPipe
.comparator(aValue, bValue);
return comparison;
});
}
}
我们不会过多地详细介绍这里发生的事情,因为这在大 JavaScript 中排序集合是很典型的。为了完成这个任务,确保app/modules/core/pipes/index.ts遵循我们的标准约定:
import { OrderByPipe } from './order-by.pipe';
export const PIPES: any[] = [
OrderByPipe
];
最后,导入前面的集合以与app/modules/core/core.module.ts一起使用。以下是包含所有修改的完整文件:
// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module';
// angular
import { NgModule } from '@angular/core';
// app
import { PIPES } from './pipes';
import { PROVIDERS } from './services';
@NgModule({
imports: [
NativeScriptModule
],
declarations: [
...PIPES
],
providers: [
...PROVIDERS
],
exports: [
NativeScriptModule,
...PIPES
]
})
export class CoreModule { }
由于管道是视图级别的实现,我们确保它们作为exports集合的一部分添加,以便其他模块可以使用它们。
现在,如果我们在这个时候运行我们的应用,你会注意到我们用于track-list.component.html视图模板的OrderBy管道将不会工作!
Angular 模块在彼此隔离的情况下编译。
这是一个需要理解的关键点:Angular 将PlayerModule编译为声明TrackListComponent的自定义模块。由于我们已将OrderByPipe作为CoreModule的一部分进行声明,而PlayerModule目前没有对CoreModule的依赖,因此TrackListComponent在编译时对OrderByPipe没有任何认识!你最终会在控制台看到以下错误生成:
CONSOLE ERROR file:///app/tns_modules/tns-core-modules/trace/trace.js:160:30: ns-renderer: ERROR BOOTSTRAPPING ANGULAR
CONSOLE ERROR file:///app/tns_modules/tns-core-modules/trace/trace.js:160:30: ns-renderer: Template parse errors:
The pipe 'orderBy' could not be found ("
</ListView>-->
<ListView [ERROR ->][items]="playerService.tracks | orderBy: 'order'">
<ng-template let-track="item">
<GridLayout rows"): TrackListComponent@10:10
为了解决这个问题,我们想要确保PlayerModule了解来自CoreModule的视图相关声明(如管道或其他组件),通过确保CoreModule被添加到PlayerModule的imports集合中。这也为我们提供了一项额外的便利。如果你注意到,CoreModule指定了NativeScriptModule作为导出,这意味着任何导入CoreModule的模块都会自动获得NativeScriptModule。以下是允许一切协同工作的PlayerModule的最终修改:
// angular
import { NgModule } from '@angular/core';
// app
import { CoreModule } from '../core/core.module';
import { COMPONENTS } from './components';
import { PROVIDERS } from './services';
@NgModule({
imports: [
CoreModule
],
providers: [...PROVIDERS],
declarations: [...COMPONENTS],
exports: [...COMPONENTS]
})
export class PlayerModule { }
我们现在可以继续到player-controls组件。
构建PlayerControls组件
我们的控制器应该包含一个用于整个混音的播放/暂停切换按钮。它还应提供一个滑动控制,以便我们可以跳过播放和倒带。
让我们创建app/modules/player/components/player-controls/player-controls.component.html(与匹配的.ts文件):
<GridLayout rows="100" columns="75,*" row="1" col="0">
<Button [text]="playStatus" (tap)="togglePlay()" row="0" col="0"></Button>
<Slider minValue="0" [maxValue]="duration"
[value]="currentTime" row="0" col="1"></Slider>
</GridLayout>
我们从一个具有显式 100 高度的单一行GridLayout开始。然后,第一列将被限制为 75 宽,以容纳我们的播放/暂停切换按钮。然后,第二列将占据剩余的水平空间,用*表示,与Slider组件一起。这个组件由 NativeScript 框架提供,允许我们将maxValue属性绑定到混音的总时长,以及播放的currentTime。
然后,对于player-controls.component.ts:
// angular
import { Component, Input } from '@angular/core';
// app
import { ITrack } from '../../../core/models';
import { LogService } from '../../../core/services';
import { PlayerService } from '../../services';
@Component({
moduleId: module.id,
selector: 'player-controls',
templateUrl: 'player-controls.component.html'
})
export class PlayerControlsComponent {
public currentTime: number = 0;
public duration: number = 0;
public playStatus: string = 'Play';
constructor(
private logService: LogService,
private playerService: PlayerService
) { }
public togglePlay() {
let playing = !this.playerService.playing;
this.playerService.playing = playing;
this.playStatus = playing ? 'Stop' : 'Play';
}
}
目前,我们将currentTime和duration直接放置在组件上,然而,我们将在稍后将其重构到PlayerService中。最终,当我们实现后续章节中的插件来处理我们的音频时,与播放器相关的所有状态都将来自PlayerService。togglePlay方法也只是一个通用行为的占位符,切换按钮的文本为播放或停止。
快速预览
在这一点上,我们将快速查看我们迄今为止所构建的内容。目前,我们的播放器服务返回一个空的曲目列表。为了看到结果,我们应该向其中添加一些占位符数据。例如,在PlayerService中,我们可以添加:
constructor() {
this.tracks = [
{name: "Guitar"},
{name: "Vocals"},
];
}
如果它看起来不漂亮,请不要感到惊讶;我们将在下一章中介绍这一点。我们也不会介绍所有可用的运行时命令;我们将在第六章“在 iOS 和 Android 上运行应用”中详细介绍。
iOS 上的预览
你必须在一台安装了 XCode 的 Mac 上预览 iOS 应用:
tns run ios --emulator
这应该启动 iOS 模拟器,你应该看到以下截图:

安卓预览
你需要在安卓 SDK 和工具安装完毕后,才能在安卓模拟器上预览:
tns run android --emulator
这应该会启动一个安卓模拟器,你应该会看到以下截图:

恭喜!我们有了第一个视图。嘿,没人说过它现在就会很漂亮!
摘要
我们已经开始构建第二部分,其中我们布置了根组件app.component.html以容纳我们的主要视图,在那里你学习了GridLayout,一个非常有用的布局容器。
Angular 的组件装饰器使我们能够轻松构建TrackListComponent和PlayerControlsComponent。我们还学会了如何构建一个 Angular Pipe来帮助我们的视图保持跟踪列表的顺序。Angular 的NgModule教导我们,我们需要确保任何组件所需的任何与视图相关的声明都正确导入。这种 Angular 设计模式有助于保持模块隔离,作为可以相互导入模块的独立代码单元。
我们还增强了一部分服务,以支持我们希望与组件一起使用的某些可用性。
最后,我们终于能够快速瞥一眼我们所构建的内容。尽管目前看起来并不美观,但我们已经能看到事物正在逐渐融合。
在第四章《使用 CSS 美化视图》中,你将学习如何使用 CSS 来从我们的视图中提取美观。
第四章:使用 CSS 更美观的视图
NativeScript 带来的众多关键优势之一是能够使用标准 CSS 来样式化原生视图组件。你会发现对许多常见和高级属性都有很好的支持;然而,有些属性没有直接的关联,而有些则是完全独特的,仅适用于原生视图布局。
让我们看看如何使用几个 CSS 类将我们的第一个视图转变为非常棒的东西。你还将学习如何利用 NativeScript 的核心主题提供一致的样式框架来构建。
在本章中,我们将涵盖以下主题:
-
使用 CSS 来样式化视图
-
理解典型网页样式和原生样式之间的一些差异
-
通过平台特定文件解锁 NativeScript 的功能
-
学习如何使用 nativescript-theme-core 样式框架插件
-
调整 iOS 和 Android 上的状态栏背景色和文字颜色
是时候变得优雅了
让我们从查看App目录中的app.css文件开始,这是我们的应用程序的主要文件:
/*
In NativeScript, the app.css file is where you place CSS rules that
you would like to apply to your
entire application. Check out
http://docs.nativescript.org/ui/styling for a full list of the CSS
selectors and
properties you can use to style UI components.
/*
For example, the following CSS rule changes the font size
of all UI
components that have the btn class name.
*/
.btn {
font-size: 18;
}
/*
In many cases you may want to use the NativeScript core theme instead
of writing your own CSS rules. For a full list
of class names in the theme
refer to http://docs.nativescript.org/ui/theme.
*/
@import 'nativescript-
theme-core/css/core.light.css';
默认情况下,--ng模板暗示了你可以选择两种选项来构建你的 CSS:
-
编写你自己的自定义类
-
使用 nativescript-theme-core 样式框架插件作为基础
让我们暂时探索第一个选项。在.btn类之后添加以下内容:
.btn {
font-size: 18;
}
.row {
padding: 15 5;
background-color: yellow;
}
.row .title {
font-size: 25;
color: #444;
font-weight: bold;
}
Button {
background-color: red;
color: white;
}
从这个简单的例子中,你可以立即发现许多有趣的事情:
-
填充不使用你熟悉的网页样式的
px后缀。-
别担心,使用
px后缀不会伤害你。 -
从 NativeScript 3.0 版本开始,支持发布单位,因此你可以使用 dp(设备无关像素)或
px(设备像素)。如果没有指定单位,将使用 dp。对于宽度/高度和边距,你还可以在 CSS 中使用百分比作为单位类型。
-
-
支持各种常见属性(如
padding、font size、font weight、color、background color等)。此外,缩写边距/填充也适用,即padding: 15 5。 -
你可以使用标准的十六进制颜色名称,如黄色,或缩写代码,如#444。
-
CSS 作用域的工作方式正如你所期望的那样,即
.row .title { ...}。 -
元素/标签/组件名称可以全局样式化。
尽管你可以通过标签/组件名称进行样式化,但这样做并不建议。我们将向您展示一些您需要了解的针对原生设备的有趣考虑因素。
现在,让我们打开app/modules/player/components/track-list/track-list.component.html,并将row和title类添加到我们的模板中:
<ListView [items]="playerService.tracks | orderBy: 'order'">
<template let-track="item">
<GridLayout rows="auto" columns="100,*,100" class="row">
<Button text="Record" (tap)
="record(track)" row="0" col="0"></Button>
<Label [text]="track.name" row="0" col="1"
class="title"></Label>
<Switch row="0" col="2"></Switch>
</GridLayout>
</template>
</ListView>
让我们快速预览一下使用tns run ios --emulator会发生什么,你应该会看到以下内容:

如果你使用tns run android --emulator在 Android 上查看,你应该会看到以下内容:

我们可以看到,在两个平台上,这些样式都得到了一致的应用,同时仍然保持了每个平台独特的特性。例如,iOS 在按钮上保持了扁平化设计美学,开关提供了熟悉的 iOS 感觉。相比之下,在 Android 上,按钮保留了微妙的默认阴影和全大写文本,以及熟悉的 Android 开关。
然而,有一些微妙(可能是不理想的)差异,这些差异需要理解和解决。从这个例子中,我们可能注意到以下几点:
-
Android 的按钮左右边距比 iOS 宽。
-
行标题的对齐不一致。在 iOS 上,标签默认垂直居中;然而,在 Android 上它对齐到顶部。
-
如果你点击记录按钮来查看登录对话框,你也会注意到一些相当不理想的地方:

项目#3 可能是最令人惊讶和意外的。它体现了一个主要的原因,即不建议全局样式化 Element/Tag/Component 名称。由于原生对话框默认使用Buttons,我们添加的一些全局Button样式正在渗入对话框(特别是color: white)。为了解决这个问题,我们可以确保我们正确地限制了所有组件名称的作用域:
.row Button {
background-color: red;
color: white;
}
或者更好的是,只需在你的按钮上使用类名:
.row .btn {
background-color: red;
color: white;
} <Button text="Record" (tap)="record(track)" row="0" col="0"
class="btn"></Button>
要修复项目#2(行标题对齐),我们可以引入 NativeScript 的一个特殊功能:能够根据你运行的平台构建特定平台的文件。让我们创建一个新文件,app/common.css,并将app/app.css中的所有内容重构到这个新文件中。然后,让我们创建另外两个新文件,app/app.ios.css和app/app.android.css(然后删除app.css,因为它将不再需要),它们的内容如下:
@import './common.css';
这将确保我们的共享样式被导入 iOS 和 Android CSS 中。现在,我们有了应用特定平台样式修复的方法!
让我们通过修改app/app.android.css来解决这个问题垂直对齐问题:
@import './common.css';
.row .title {
vertical-align: center;
}
这只为 Android 添加了额外的样式调整,现在我们有了这样的效果:

太好了,好多了。
要修复#1,如果我们想让两个平台上的按钮具有相同的边距,我们需要应用更多针对特定平台的调整。
到目前为止,你可能想知道你需要自己调整多少来处理一些这些特定平台的问题。你应该很高兴地知道这不是一个详尽的列表,但充满活力的 NativeScript 社区共同努力创造了一些更好的东西,一个类似于 bootstrap 的核心主题,它提供了许多这些微妙的调整,例如标签的垂直对齐以及许多其他微妙的调整。
欢迎使用 NativeScript 核心主题
所有新的 NativeScript 项目都自带核心主题安装,并且可以直接使用。如前所述,你提供了两种你可以用来设计应用程序的选项。前面的部分概述了在从头开始设计应用程序时可能会遇到的一些事情。
让我们来看看选项#2:使用nativescript-theme-core插件。这个主题是现成的,旨在扩展和构建在它之上。它提供了一系列的实用类,用于间距、着色、布局、着色皮肤等等。由于其坚实的基础和惊人的灵活性,我们将在这个主题之上构建我们的应用程序样式。
值得注意的是,nativescript-theme-前缀是有意为之,作为标准,它有助于在npm上提供一个共同的搜索前缀,以找到所有 NativeScript 主题。如果你设计和发布自己的自定义 NativeScript 主题,建议使用相同的前缀。
让我们移除我们的自定义样式,只保留导入的核心主题。然而,我们不会使用默认的浅色皮肤,而是会使用深色皮肤。这就是我们的app/common.css文件现在应该看起来像这样:
@import 'nativescript-theme-core/css/core.dark.css';
现在,我们想要开始用核心主题提供的某些类来分类我们的组件。你可以在这里学习类列表的完整列表:docs.nativescript.org/ui/theme。
从app/app.component.html开始,让我们添加以下类:
<ActionBar title="TNSStudio" class="action-bar">
</ActionBar>
<GridLayout
rows="*, 100" columns="*" class="page">
<track-list row="0" col="0"></track-list>
<player-controls row="1" col="0"></player-controls>
</GridLayout>
action-bar类确保我们的皮肤适当地应用到应用程序的标题上,同时为 iOS 和 Android 上的ActionBar提供细微的一致性调整。
page类确保我们的皮肤应用到整个页面上。在任何一个组件视图中,这个类都应应用到根布局容器上。
经过这两个调整,我们现在应该在 iOS 上看到以下内容:

这是 Android 上的样子:

你会注意到在ListView上 iOS 和 Android 之间还有一个差异。iOS 默认有一个白色背景,而 Android 看起来有一个透明的背景,允许皮肤页面的颜色显示出来。让我们继续用核心主题的更多类来分类我们的组件,这些类有助于解决这些细微差别。打开app/modules/player/components/track-list/track-list.component.html并添加以下类:
<ListView [items]="playerService.tracks | orderBy: 'order'" class="list-group">
<ng-
template let-track="item">
<GridLayout rows="auto" columns="100,*,100" class="list-group-
item">
<Button text="Record" (tap)="record(track)" row="0" col="0" class="c-
ruby"></Button>
<Label [text]="track.name" row="0" col="1"
class="h2"></Label>
<Switch row="0" col="2"
class="switch"></Switch>
</GridLayout>
</ng-template>
</ListView>
父级list-group类有助于将所有内容正确地缩小到list-group-item。然后,我们添加c-ruby来在我们的记录按钮上洒一些红色。有几个着色颜色提供了姓氏:c-sky、c-aqua、c-charcoal、c-purple等等。在这里查看所有这些:docs.nativescript.org/ui/theme#color-schemes。
然后,我们将h2添加到标签中,以稍微增加其字体大小。最后,switch类有助于标准化音轨独奏开关。
现在我们已经在 iOS 上有了这个:

这是 Android 上的样子:

让我们继续前进到最后一个组件(目前是这样),player-controls。打开app/modules/player/components/player-controls/player-controls.component.html并添加以下内容:
<GridLayout rows="100" columns="100,*" row="1" col="0" class="p-x-10">
<Button
[text]="playStatus" (tap)="togglePlay()" row="0" col="0" class="btn btn-primary w-
100"></Button>
<Slider minValue="0" [maxValue]="duration" [value]="currentTime" row="0" col="1"
class="slider"></Slider>
</GridLayout>
首先,我们给左/右容器(GridLayout)添加p-x-10类以添加10的内边距。然后,我们将btn btn-primary w-100添加到播放/暂停按钮上。w-100类将按钮的宽度设置为固定值100。然后,我们将slider类添加到滑块上。
现在,iOS 上的事情开始有形了:

它在 Android 上的外观如下:

哇,好吧,现在,一切开始成形。我们将继续在前进的过程中进一步完善细节,但这个练习已经展示了你如何快速通过使用大量内置类来调整核心主题的风格。
调整 iOS 和 Android 的状态栏背景色和文本色
你可能之前已经注意到,在 iOS 上,状态栏文本是黑色,与我们的深色皮肤不太搭配。此外,我们可能还想改变 Android 的状态栏色调颜色。NativeScript 提供了对原生 API 的直接访问,因此我们可以轻松地将这些更改为我们想要的任何颜色。这两个平台处理方式不同,因此我们可以有条件地更改每个平台的状态栏。
打开app/app.component.ts并添加以下内容:
// angular
import { Component } from '@angular/core';
// nativescript
import { isIOS } from 'platform';
import { topmost } from 'ui/frame';
import * as app from 'application';
// app
import { AuthService } from
'./modules/core/services';
declare var android;
@Component({
moduleId:
module.id,
selector: 'my-app',
templateUrl: 'app.component.html',
})
export class AppComponent {
constructor(
private authService: AuthService
) {
if (isIOS) {
/**
* 0 = black text
* 1 = white text
*/
topmost().ios.controller.navigationBar.barStyle = 1;
} else {
// adjust text to darker color
let decorView =
app.android.startActivity.getWindow()
.getDecorView();
decorView.setSystemUiVisibility(android.view.View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
}
}
}
这将使 iOS 状态栏文本变为白色:

条件语句的第二部分调整 Android 以在状态栏中使用深色文本:

让我们趁热打铁,也调整一下ActionBar的背景色,以增添一些美感。在 iOS 上,状态栏背景色采用ActionBar的背景色,而在 Android 上,状态栏的背景色必须通过App_Resources中的 Android colors.xml进行调整。从 iOS 开始,让我们打开app/common.css并添加以下内容:
.action-bar {
background-color:#101B2E;
}
这为 iOS 的ActionBar设置了以下颜色:

对于 Android,我们希望状态栏背景色与ActionBar背景色形成互补色。要做到这一点,我们需要打开app/App_Resources/Android/values/colors.xml并做出以下调整:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color
name="ns_primary">#F5F5F5</color>
<color
name="ns_primaryDark">#284472</color>
<color name="ns_accent">#33B5E5</color>
<color name="ns_blue">#272734</color>
</resources>
这是 Android 上的最终结果:

摘要
最后,给我们的应用添加一个面孔让人耳目一新且有趣;然而,我们当然还没有完成样式设计。我们将继续通过 CSS 润色视图,并在接下来的章节中引入 SASS 以进一步细化。然而,这一章已经向你介绍了你在通过 CSS 设计应用时需要了解的各种考虑因素。
你已经了解到常见的 CSS 属性都得到了支持,我们还探讨了 iOS 和 Android 在处理某些默认特性方面的差异。能够针对特定平台使用 CSS 覆盖是一个很好的好处和特殊能力,你将在你的跨平台 NativeScript 应用中想要充分利用这一点。了解如何控制两个平台上的状态栏外观对于实现你应用所需的外观和感觉至关重要。
在下一章中,我们将从样式设计暂时休息,通过懒加载深入探讨路由和导航,为完善我们应用的整体可用性流程做好准备。准备好深入探索我们应用中一些更有趣的 Angular 特性。
第五章:路由和懒加载
路由对于任何应用程序的稳定可用性流程至关重要。让我们了解一个利用 Angular 路由器所有灵活性的移动应用程序的路由配置的关键元素。
在本章中,我们将涵盖以下主题:
-
在 NativeScript 应用程序中配置 Angular Router
-
通过路由懒加载模块
-
为 Angular 的
NgModuleFactoryLoader提供NSModuleFactoryLoader -
理解如何结合使用
router-outlet和page-router-outlet -
了解如何在多个懒加载的模块之间共享单例服务
-
使用身份验证守卫来保护需要有效身份验证的视图
-
了解如何自定义返回移动导航的
NavigationButton -
通过引入后期功能需求来利用我们灵活的路由设置
在 Route 66 上享受乐趣
当我们开始在这条充满冒险的道路上旅行时,让我们先在我们的当地服务店停下来,确保我们的车辆处于最佳状态。转到app的根目录,为我们的车辆引擎构建一个新的附加组件:路由模块。
创建一个新的路由模块,app/app.routing.ts,包含以下内容:
import { NgModule } from '@angular/core';
import { NativeScriptRouterModule }
from 'nativescript-angular/router';
import { Routes } from '@angular/router';
const routes: Routes = [
{
path: '',
redirectTo: '/mixer/home',
pathMatch: 'full'
},
{
path: 'mixer',
loadChildren: () => require('./modules/mixer/mixer.module')['MixerModule']
},
{
path: 'record',
loadChildren: () => require('./modules/recorder/recorder.module')['RecorderModule']
}
];
@NgModule({
imports: [
NativeScriptRouterModule.forRoot(routes)
],
exports: [
NativeScriptRouterModule
]
})
export class AppRoutingModule { }
定义根''路径以重定向到懒加载的模块提供了非常灵活的路由配置,正如您在本章中将会看到的。您将看到一个新模块,MixerModule,我们将立即创建它。实际上,它最终将变成现在的AppComponent。以下是一些使用类似此配置的路由配置所获得的优势列表:
-
通过仅预先加载最基本的最小根模块配置,然后快速懒加载第一个路由的模块,保持应用程序启动时间快速
-
提供了使用
page-router-outlet与router-outlet结合的能力,以实现主/详细导航以及clearHistory页面导航的组合 -
将路由配置责任隔离到相关的模块中,这样随着时间的推移可以很好地扩展
-
如果我们决定更改用户最初看到的初始页面,我们可以轻松地针对不同的起始页面进行目标定位
这使用NativeScriptRoutingModule.forRoot(routes),因为这将被认为是我们的应用程序路由配置的根。
我们还导出NativeScriptRoutingModule,因为我们将在稍后导入这个AppRoutingModule到我们的根AppModule中。这使得路由指令对根模块的根组件可用。
为NgModuleFactoryLoader提供 NSModuleFactoryLoader
默认情况下,Angular 的内置模块加载器使用 SystemJS;然而,NativeScript 提供了一个增强的模块加载器,称为NSModuleFactoryLoader。让我们在我们的主路由模块中提供这个加载器,以确保所有模块都使用它而不是 Angular 的默认模块加载器。
对app/app.routing.ts进行以下修改:
import { NgModule, NgModuleFactoryLoader } from '@angular/core';
import { NativeScriptRouterModule, NSModuleFactoryLoader } from 'nativescript-angular/router';
const routes: Routes = [
{
path: '',
redirectTo: '/mixer/home',
pathMatch: 'full'
},
{
path: 'mixer',
loadChildren: './modules/mixer/mixer.module#MixerModule'
},
{
path: 'record',
loadChildren: './modules/recorder/recorder.module#RecorderModule',
canLoad: [AuthGuard]
}
];
@NgModule({
imports: [
NativeScriptRouterModule.forRoot(routes)
],
providers: [
AuthGuard,
{
provide: NgModuleFactoryLoader,
useClass: NSModuleFactoryLoader
}
],
exports: [
NativeScriptRouterModule
]
})
export class AppRoutingModule { }
现在,我们可以通过 loadChildren 使用标准的 Angular 懒加载语法,通过指定默认的 NgModuleFactoryLoader,但应该使用 NativeScript 的增强型 NSModuleFactoryLoader。我们不会详细介绍 NSModuleFactoryLoader 提供的内容,因为它在这里解释得很好:www.nativescript.org/blog/optimizing-app-loading-time-with-angular-2-lazy-loading,而且我们在这本书中还有更多内容要介绍。
极好。有了这些升级,我们可以离开服务店,继续沿着高速公路前行。让我们继续实施我们的新路由设置。
打开 app/app.component.html;将其内容剪切到剪贴板,并用以下内容替换:
<page-router-outlet></page-router-outlet>
这将是视图级别实现的基础。page-router-outlet 允许任何组件插入其位置,无论是单个扁平路由还是具有自己子视图的路由。它还允许其他组件视图推送到移动导航堆栈,从而实现带有后退历史记录的主/详细移动导航。
为了使这个 page-router-outlet 指令正常工作,我们需要我们的根 AppModule 导入新的 AppRoutingModule。我们还将利用这个机会移除之前在这里导入的 PlayerModule。打开 app/app.module.ts 并进行以下修改:
// angular
import { NgModule } from '@angular/core';
// app
import { CoreModule } from './modules/core/core.module';
import { AppRoutingModule } from './app.routing';
import { AppComponent } from './app.component';
@NgModule({
imports: [
CoreModule,
AppRoutingModule
],
declarations: [AppComponent],
bootstrap: [AppComponent]
})
export class AppModule { }
创建 MixerModule
这个模块实际上不会有什么新内容,因为它将作为之前根组件视图的重新定位。然而,它将引入一个额外的优点:能够定义自己的内部路由。
创建 app/modules/mixer/components/mixer.component.html,并将从 app.component.html 中剪切的内容粘贴进去:
<ActionBar title="TNSStudio" class="action-bar"></ActionBar><GridLayout rows="*, 100" columns="*" class="page">
<track-list row="0" col="0"></track-list>
<player-controls row="1" col="0"></player-controls></GridLayout>
然后创建一个匹配的 app/modules/mixer/components/mixer.component.ts:
import { Component } from '@angular/core';
@Component({
moduleId: module.id,
selector: 'mixer',
templateUrl: 'mixer.component.html'
})
export class MixerComponent {}
现在,我们将创建 BaseComponent,它将作为前面 MixerComponent 的占位符,以及我们可能希望在它的位置展示的任何其他子视图组件。例如,我们的混音器可能希望允许用户将单个轨道从混音器弹出,进入一个隔离的视图来处理音频效果。
创建 app/modules/mixer/components/base.component.ts,内容如下:
// angular
import { Component } from '@angular/core';
@Component({
moduleId: module.id,
selector: 'mixer-base',
template: `<router-outlet></router-outlet>`
})
export class BaseComponent { }
这提供了一个插槽,可以插入我们的混音器配置的任何子路由,其中之一就是 MixerComponent 本身。由于视图只是一个简单的 router-outlet,实际上没有必要创建一个单独的 templateUrl,所以我们在这里直接内联了它。
现在,我们准备实现 MixerModule;创建 app/modules/mixer/mixer.module.ts,内容如下:
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
import { NativeScriptRouterModule } from
'nativescript-angular/router';
import { Routes } from '@angular/router';
import { PlayerModule } from '../player/player.module';
import { BaseComponent } from './components/base.component';
import { MixerComponent } from
'./components/mixer.component';
const COMPONENTS: any[] = [
BaseComponent,
MixerComponent
]
const routes: Routes = [
{
path: '',
component: BaseComponent,
children: [
{
path: 'home',
component: MixerComponent
}
]
}
];
@NgModule({
imports: [
PlayerModule,
NativeScriptRouterModule.forChild(routes)
],
declarations: [
...COMPONENTS
],
schemas: [
NO_ERRORS_SCHEMA
]
})
export class MixerModule { }
我们已经导入了 PlayerModule,因为混合器使用了那里定义的组件/小部件(即 track-list 和 player-controls)。我们还在使用 NativeScriptRouterModule.forChild(routes) 方法来指示这些是特定的子路由。我们的路由配置在根 ' ' 路径上设置了 BaseComponent,它将 'home' 定义为 MixerComponent。如果您还记得,我们的应用 AppRoutingModule 如下配置了应用的根路径:
...
{
path: '',
redirectTo: '/mixer/home',
pathMatch: 'full'
},
...
这将直接路由到这里定义的 MixerComponent,即 'home'。如果我们想,我们可以轻松地将启动页面指向不同的视图,只需将 redirectTo 指向我们的混合器中不同的子视图。由于 BaseComponent 仅仅是 router-outlet,任何定义在混合器路由根 ' ' 下的子组件(在我们的整体应用路由中看起来是 '/mixer')将直接插入该视图槽。如果您现在运行它,您应该看到我们之前相同的启动页面。
恭喜!您应用的启动时间现在很快,您已经懒加载了第一个模块!
然而,有几个令人惊讶的事情需要注意:
-
您可能会在启动页面出现之前注意到一个快速的白色闪烁(至少在 iOS 上是这样)
-
您可能会注意到控制台日志打印了两次
`当前用户:`。
我们将分别解决这些问题。
- 在启动页面显示之前,移除启动屏幕后的白色闪烁。
这是正常的,这是默认页面背景颜色为白色造成的。为了提供无缝的启动体验,打开 app/common.css 文件,将全局 Page 类定义改为与我们的 ActionBar 背景颜色相同:
Page {
background-color:#101B2E;
}
现在,将不再有白色闪烁,应用的启动将看起来无缝。
- 控制台日志打印了两次
`当前用户:`。
由于懒加载,Angular 的依赖注入器导致了这个问题。
这来自 app/modules/core/services/auth.service.ts,在那里我们有一个私有的 init 方法,它从服务的构造函数中被调用:
...
@Injectable()
export class AuthService {
...
constructor(
private databaseService: DatabaseService,
private logService: LogService
) {
this._init();
}
...
private _init() {
AuthService.CURRENT_USER = this.databaseService.getItem(
DatabaseService.KEYS.currentUser);
this.logService.debug(`Current user: `,
AuthService.CURRENT_USER);
this._notifyState(!!AuthService.CURRENT_USER);
}
...
}
等等!这是什么意思?AuthService 被构建了两次吗?!
是的。它确实如此。 😦
我能听到汽车轮胎的尖叫,你现在正偏离这条高速公路冒险进入沟渠。 😉
这肯定是一个大问题,因为我们绝对希望 AuthService 是一个全局共享的单例,可以在任何地方注入并共享,以提供我们应用的当前认证状态。
我们必须立即解决这个问题,但在寻找一个可靠的解决方案之前,让我们先简要了解一下为什么会发生这种情况。
在懒加载模块时理解 Angular 的依赖注入器
我们不会重复细节,而是直接从 Angular 的官方文档(《https://angular.io/guide/ngmodule-faq#!#q-why-child-injector》)中转述,它完美地解释了这一点:
对于非懒加载的模块,Angular 会将@NgModule.providers添加到应用程序根注入器中。对于懒加载的模块,Angular 创建一个子注入器并将模块的提供者添加到子注入器中。
这意味着模块的行为取决于它是与应用程序启动时一起加载还是稍后懒加载。忽视这种差异可能会导致不良后果。
为什么 Angular 不将懒加载的提供者添加到应用程序根注入器,就像它对急切加载的模块所做的那样?
这个答案基于 Angular 依赖注入系统的基本特性。注入器可以添加提供者,直到它首次使用。一旦注入器开始创建和提供服务,其提供者列表就冻结了;不允许添加新的提供者。
当应用程序启动时,Angular 首先使用所有急切加载模块的提供者配置根注入器,然后创建其第一个组件并注入任何提供的服务。一旦应用程序开始,应用程序根注入器对新提供者已关闭。
时间流逝,应用程序逻辑触发模块的懒加载。Angular 必须在某个地方将懒加载模块的提供者添加到注入器中。它不能将它们添加到应用程序根注入器,因为该注入器对新提供者已关闭。因此,Angular 为懒加载模块的上下文创建了一个新的子注入器。
如果我们查看我们的根AppModule,我们可以看到它导入了CoreModule,该模块提供了AuthService:
...
@NgModule({
imports: [
CoreModule,
AppRoutingModule
],
declarations: [AppComponent],
bootstrap: [AppComponent],
schemas: [NO_ERRORS_SCHEMA]
})
export class AppModule { }
如果我们查看PlayerModule,我们可以看到它也导入了CoreModule,因为PlayerModule的组件使用了它声明的OrderByPipe以及它提供的几个服务(即AuthService、LogService和DialogService):
...
@NgModule({
imports: [
CoreModule
],
providers: [...PROVIDERS],
declarations: [...COMPONENTS],
exports: [...COMPONENTS],
schemas: [ NO_ERRORS_SCHEMA ]
})
export class PlayerModule { }
由于我们新奇的路线配置,PlayerModule现在与MixerModule一起懒加载。这导致 Angular 的依赖注入器为我们的懒加载MixerModule注册了一个新的子注入器,它带来了PlayerModule,它也带来了其导入的CoreModule,该模块定义了那些提供者,包括AuthService、LogService等。当 Angular 注册MixerModule时,它将注册新模块中定义的所有提供者,包括其导入的模块,以及新的子注入器,从而产生了那些服务的新实例。
Angular 的文档还提供了一种推荐的模块设置来解决这个问题,因此让我们再次从https://angular.io/guide/ngmodule-faq#!#q-module-recommendations中转述:
SharedModule
创建一个SharedModule,其中包含你在应用程序的每个地方都使用的组件、指令和管道。此模块应完全由声明组成,其中大多数都导出。SharedModule可以重新导出其他小部件模块,例如CommonModule、FormsModule以及包含你广泛使用的 UI 控制的模块。《SharedModule》不应有提供者,如前所述。也不应该有任何导入或重新导出的模块有提供者。如果你偏离了这个指南,要知道你在做什么以及为什么。在你的功能模块中导入SharedModule,包括在应用程序启动时加载的模块以及稍后懒加载的模块。
创建一个CoreModule,其中包含在应用程序启动时加载的单例服务的提供者。
仅在根AppModule中导入CoreModule。永远不要在其他任何模块中导入CoreModule。
考虑将CoreModule制作成一个没有声明的纯服务模块。
哇!这是一个极好的建议。特别值得注意的是最后一行:
考虑将CoreModule制作成一个没有声明的纯服务模块。
因此,我们已经有了一个CoreModule,这是一个好消息,但我们将希望将其制作成一个没有声明的纯服务模块。我们还将仅在根AppModule中导入CoreModule。永远不要在其他任何模块中导入CoreModule。然后,我们可以创建一个新的SharedModule,只为...**我们在应用程序的每个地方都使用的组件、指令和管道提供。
让我们创建app/modules/shared/shared.module.ts,如下所示:
// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module';
// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
// app
import { PIPES } from './pipes';
@NgModule({
imports: [
NativeScriptModule
],
declarations: [
...PIPES
],
exports: [
NativeScriptModule,
...PIPES
],
schemas: [ NO_ERRORS_SCHEMA ]
})
export class SharedModule {}
对于PIPES,我们只是将管道目录从app/modules/core移动到app/modules/shared文件夹。现在,SharedModule是我们可以在需要任何管道或未来共享组件/指令的多个不同模块中自由导入的模块。它将不会定义任何服务提供者,正如这个建议所提到的:
SharedModule不应有提供者,如前所述,也不应有任何导入或重新导出的模块有提供者。
然后,我们可以按照以下方式调整CoreModule(位于app/modules/core/core.module.ts)以成为一个没有声明的纯服务模块:
// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module';
import { NativeScriptFormsModule } from 'nativescript-angular/forms';
import {NativeScriptHttpModule } from 'nativescript-angular/http';
// angular
import { NgModule, Optional, SkipSelf } from '@angular/core';
// app
import { PROVIDERS } from './services';
const MODULES: any[] = [
NativeScriptModule,
NativeScriptFormsModule,
NativeScriptHttpModule
];
@NgModule({
imports: [
...MODULES
],
providers: [
...PROVIDERS
],
exports: [
...MODULES
]
})
export class CoreModule {
constructor (
@Optional() @SkipSelf() parentModule: CoreModule) {
if (parentModule) {
throw new Error(
'CoreModule is already loaded. Import it in the AppModule only');
}
}
}
此模块现在仅定义提供者,即包含AuthService、DatabaseService、DialogService和LogService的集合,我们都在本书中较早创建过,我们想确保它们是跨我们的应用程序使用的真正的单例,无论它们是否在懒加载的模块中使用。
为什么我们使用...PROVIDERS展开符号而不是直接分配集合?
由于可扩展性的原因。将来,如果我们需要添加一个额外的提供者或覆盖一个提供者,我们只需在模块中直接添加到集合中即可。对于导入和导出也是如此。
我们还利用这个机会导入了一些我们想要确保在整个应用程序中全局使用的附加模块。NativeScriptModule、NativeScriptFormsModule 和 NativeScriptHttpModule 都是基本模块,它们会覆盖 Angular 各个提供者中的某些 Web API,以增强我们的应用程序的本地 API。例如,应用程序将使用在 iOS 和 Android 上可用的本地 HTTP API(而不是 Web API XMLHttpRequest),以实现最佳的网络性能。我们确保也导出这些模块,这样我们的根模块就不再需要导入它们,而是可以直接导入这个 CoreModule。
最后,我们定义了一个构造函数,这将帮助我们未来避免意外地将此 CoreModule 导入其他懒加载模块。
我们还不知道 PlayerModule 提供的 PlayerService 是否会被 RecorderModule 需要,后者也将是懒加载的。如果将来出现这种情况,我们还可以将 PlayerService 重构到 CoreModule 中,以确保它是我们在整个应用程序中共享的真正单例。目前,我们将其保留在 PlayerModule 中作为一部分。
现在,让我们根据我们所做的一切调整其他模块的最终设置。
app/modules/player/player.module.ts 文件现在应该看起来像这样:
// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
// app
import { SharedModule } from '../shared/shared.module';
import { COMPONENTS } from './components';
import { PROVIDERS } from './services';
@NgModule({
imports: [ SharedModule ],
providers: [ ...PROVIDERS ],
declarations: [ ...COMPONENTS ],
exports: [
SharedModule,
...COMPONENTS
],
schemas: [ NO_ERRORS_SCHEMA ]
})
export class PlayerModule { }
app/modules/recorder/recorder.module.ts 文件现在应该看起来像这样:
// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
// app
import { SharedModule } from '../shared/shared.module';
import { PROVIDERS } from './services';
@NgModule({
imports: [ SharedModule ],
providers: [ ...PROVIDERS ],
schemas: [ NO_ERRORS_SCHEMA ]
})
export class RecorderModule { }
注意我们现在导入的是 SharedModule 而不是 CoreModule。这使我们能够通过导入那个 SharedModule 在整个应用程序中共享指令、组件和管道(本质上就是模块声明部分中的任何内容)。
我们在 app/app.module.ts 中的根 AppModule 保持不变:
// angular
import { NgModule } from '@angular/core';
// app
import { CoreModule } from './modules/core/core.module';
import { AppRoutingModule } from './app.routing';
import { AppComponent } from './app.component';
@NgModule({
imports: [
CoreModule,
AppRoutingModule
],
declarations: [ AppComponent ],
bootstrap: [ AppComponent ]
})
export class AppModule { }
任何模块(无论是懒加载还是非懒加载)都可以注入 CoreModule 提供的任何服务,因为根 AppModule 现在导入了那个 CoreModule。这允许 Angular 的根注入器正好一次构建 CoreModule 提供的服务。然后,每当这些服务在任何地方注入(无论是懒加载模块还是其他地方),Angular 首先会询问父注入器(在懒加载模块的情况下,将是子注入器)该服务,如果在那里找不到,它将询问下一个父注入器,最终到达根注入器,在那里提供这些单例。
嗯,我们在这个沙漠小镇度过了一段美好的时光。让我们沿着高速公路驶向超安全的 51 区,在那里模块可以被锁定数年,除非出示适当的授权。
为 RecorderModule 创建 AuthGuard
我们应用程序的一个要求是,录制功能应该在用户认证之前被锁定并不可访问。这使我们能够拥有一个用户基础,并且如果将来我们希望的话,可以引入付费功能。
Angular 提供了在路由上插入守卫的能力,这只有在特定条件下才会激活。这正是我们实现这个功能需求所需要的,因为我们已经将 '/record' 路由隔离出来,以懒加载 RecorderModule,它将包含所有录音功能。我们只想允许认证用户访问那个 '/record' 路由。
让我们在新文件夹中创建 app/guards/auth-guard.service.ts,以便于扩展,因为我们可能需要在这里创建其他必要的守卫:
import { Injectable } from '@angular/core';
import { Route, CanActivate, CanLoad } from '@angular/router';
import { AuthService } from '../modules/core/services/auth.service';
@Injectable()
export class AuthGuard implements CanActivate, CanLoad {
constructor(private authService: AuthService) { }
canActivate(): Promise<boolean> {
return new Promise((resolve, reject) => {
if (this._isAuth()) {
resolve(true);
} else {
// login sequence to continue prompting
let promptSequence = (usernameAttempt?: string) => {
this.authService.promptLogin(
'Authenticate to record.',
usernameAttempt
).then(() => {
resolve(true);
}, (usernameAttempt) => {
if (usernameAttempt === false) {
// user canceled prompt
resolve(false);
} else {
// initiate sequence again
promptSequence(usernameAttempt);
}
});
};
// start login prompt sequence
// require auth before activating
promptSequence();
}
});
}
canLoad(route: Route): Promise<boolean> {
// reuse same logic to activate
return this.canActivate();
}
private _isAuth(): boolean {
// just get the latest value from our BehaviorSubject
return this.authService.authenticated$.getValue();
}
}
我们可以利用 AuthService 的 BehaviorSubject 来获取最新值,使用 this.authService.authenticated$.getValue() 来确定认证状态。我们使用这个值通过 canActivate 钩子(或通过 canLoad 钩子加载模块)立即激活路由,如果用户已认证。否则,我们通过服务的方法显示登录提示,但这次我们将其包裹在一个重新提示序列中,这样在失败尝试的情况下会继续提示,直到成功认证,或者如果用户取消提示则忽略。
对于这本书,我们不会连接到任何后端服务来进行任何真实的认证。我们将这部分留给你在自己的应用程序中完成。我们将在对输入进行非常简单的验证后,将你输入到登录提示中的电子邮件和密码持久化,作为有效用户。
注意,AuthGuard 是一个像其他服务一样的可注入服务,因此我们想要确保它被添加到 AppRoutingModule 的提供者元数据中。现在我们可以通过以下突出显示的修改来保护我们的路由 app/app.routing.ts 以使用它:
...
import { AuthGuard } from './guards/auth-guard.service';
const routes: Routes = [
...
{
path: 'record',
loadChildren:
'./modules/recorder/recorder.module#RecorderModule',
canLoad: [AuthGuard]
}
];
@NgModule({
...
providers: [
AuthGuard,
...
],
...
})
export class AppRoutingModule { }
要尝试这个功能,我们需要向我们的 RecorderModule 添加子路由,因为我们还没有这样做。打开 app/modules/recorder/recorder.module.ts 并添加以下突出显示的部分:
// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module';
import { NativeScriptRouterModule } from 'nativescript-angular/router';
// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
import { Routes } from '@angular/router';
// app
import { SharedModule } from '../shared/shared.module';
import { PROVIDERS } from './services';
import { RecordComponent } from './components/record.component';
const COMPONENTS: any[] = [
RecordComponent
]
const routes: Routes = [
{
path: '',
component: RecordComponent
}
];
@NgModule({
imports: [
SharedModule,
NativeScriptRouterModule.forChild(routes)
],
declarations: [ ...COMPONENTS ],
providers: [ ...PROVIDERS ],
schemas: [ NO_ERRORS_SCHEMA ]
})
export class RecorderModule { }
现在我们有了适当的子路由配置,当用户导航到 '/record' 路径时,将显示单个 RecordComponent。我们不会显示 RecordComponent 的详细信息,你可以参考书籍的 第五章,路由和懒加载 分支上的代码库。然而,目前它只是 app/modules/recorder/components/record.component.html 中的一个占位符组件,只显示一个简单的标签,因此我们可以尝试这个功能。
最后,我们需要一个按钮来路由到我们的 '/record' 路径。如果我们回顾我们的原始草图,我们想在 ActionBar 的右上角显示一个记录按钮,所以现在让我们实现它。
打开 app/modules/mixer/components/mixer.component.html 并添加以下内容:
<ActionBar title="TNSStudio" class="action-bar">
<ActionItem nsRouterLink="/record" ios.position="right">
<Button text="Record" class="action-item"></Button>
</ActionItem>
</ActionBar>
<GridLayout rows="*, 100" columns="*" class="page">
<track-list row="0" col="0"></track-list>
<player-controls row="1" col="0"></player-controls>
</GridLayout>
现在,如果我们要在 iOS 模拟器中运行这个程序,我们会注意到 ActionBar 中的记录按钮没有任何反应!这是因为 MixerModule 只导入了以下内容:
@NgModule({
imports: [
PlayerModule,
NativeScriptRouterModule.forChild(routes)
],
...
})
export class MixerModule { }
NativeScriptRouterModule.forChild(routes) 方法仅配置路由,但不会使各种路由指令,如 nsRouterLink,对我们的组件可用。
由于你之前已经了解到应该使用 SharedModule 来声明你想要在模块中(无论是懒加载还是非懒加载)共享的各种指令、组件和管道,这是一个利用它的完美机会。
打开 app/modules/shared/shared.module.ts 并进行以下突出显示的修改:
...
import { NativeScriptRouterModule } from 'nativescript-angular/router';
...
@NgModule({
imports: [
NativeScriptModule,
NativeScriptRouterModule
],
declarations: [
...PIPES
],
exports: [
NativeScriptModule,
NativeScriptRouterModule,
...PIPES
],
schemas: [NO_ERRORS_SCHEMA]
})
export class SharedModule { }
现在,回到 MixerModule,我们可以调整导入以使用 SharedModule:
...
import { SharedModule } from '../shared/shared.module';
@NgModule({
imports: [
PlayerModule,
SharedModule,
NativeScriptRouterModule.forChild(routes)
],
...
})
export class MixerModule { }
这确保了通过 NativeScriptRouterModule 暴露的所有指令现在都包含在内,并且可以通过我们的全局 SharedModule 在 MixerModule 中使用。
再次运行我们的应用,当我们点击 ActionBar 中的 Record 按钮时,现在会看到登录提示。如果我们输入格式正确的电子邮件地址和任何密码,它将持久保存详细信息,登录,并在 iOS 上显示 RecordComponent 如下:

你可能会注意到一些相当有趣的事情。ActionBar 从我们通过 CSS 分配的背景颜色和按钮颜色现在显示默认的蓝色。这是因为 RecordComponent 没有定义 ActionBar;因此,它正在回退到具有默认样式的 ActionBar,带有默认的返回按钮,该按钮承担了它刚刚导航离开的页面的标题。'/record' 路由也正在使用 page-router-outlet 将组件推送到移动导航堆栈。RecordComponent 正在动画进入视图,同时允许用户选择左上角的按钮进行导航回退(弹出导航历史记录)。
为了修复 ActionBar,我们只需将 ActionBar 添加到 RecordComponent 视图中,并使用自定义的 NavigationButton(一个模拟移动设备默认返回导航按钮的 NativeScript 视图组件)。我们可以在 app/modules/record/components/record.component.html 中进行调整:
<ActionBar title="Record" class="action-bar">
<NavigationButton text="Back"
android.systemIcon="ic_menu_back">
</NavigationButton>
</ActionBar>
<StackLayout class="p-20">
<Label text="TODO: Record" class="h1 text-center"></Label>
</StackLayout>
现在,这看起来好多了:

如果我们在 Android 上运行此应用并使用任何电子邮件/密码组合登录以持久保存用户,它将显示相同的 RecordComponent 视图;然而,你会注意到另一个有趣的细节。我们已经将 Android 设置为显示标准的返回箭头系统图标作为 NavigationButton,但是当你点击那个箭头时,它不会做任何事情。Android 的默认行为依赖于位于主页按钮旁边的设备的物理硬件返回按钮。然而,我们可以通过仅为 NavigationButton 添加点击事件来提供一致的用户体验,这样 iOS 和 Android 对返回按钮的点击反应相同。对模板进行以下修改:
<ActionBar title="Record" icon="" class="action-bar">
<NavigationButton (tap)="back()" text="Back"
android.systemIcon="ic_menu_back">
</NavigationButton>
</ActionBar>
<StackLayout class="p-20">
<Label text="TODO: Record" class="h1 text-center"></Label>
</StackLayout>
然后,我们可以在 app/modules/recorder/components/record.component.ts 中实现 back() 方法,使用 Angular 的 RouterExtensions 服务:
// angular
import { Component } from '@angular/core';
import { RouterExtensions } from 'nativescript-angular/router';
@Component({
moduleId: module.id,
selector: 'record',
templateUrl: 'record.component.html'
})
export class RecordComponent {
constructor(private router: RouterExtensions) { }
public back() {
this.router.back();
}
}
现在,Android 的返回按钮可以点击以返回,除了硬件返回按钮之外。iOS 简单地忽略了点击事件处理程序,因为它使用 NavigationButton 的默认原生行为。相当不错。以下是 Android 上的 RecordComponent 的样子:

我们将在接下来的章节中实现一个不错的录音视图。
我们现在肯定是在 66 号公路上飞驰了!
我们已经实现了懒加载的路由,提供了 AuthGuard 来保护我们应用程序录音功能的未授权使用,并在过程中学到了很多。然而,我们刚刚意识到我们错过了一个非常重要的功能。我们需要一种方法来随着时间的推移处理多个不同的混音。默认情况下,我们的应用程序可能会启动最后打开的混音,但我们会想创建新的混音(让我们考虑它们为作品)并记录完全新的单独音轨的混音作为单独的作品。我们需要一个新的路由来显示这些作品,我们可以适当地命名它们,这样我们就可以来回跳转并处理不同的材料。
处理晚期功能需求 - 管理作品
是时候处理 66 号公路上的意外交通了。我们遇到了一个晚期的功能需求,意识到我们需要一种方法来管理任意数量的不同混音,这样我们就可以随着时间的推移处理不同的材料。我们可以将每个混音称为音频音轨的组合。
好消息是我们已经花费了相当多的时间来设计一个可扩展的架构,我们即将收获我们的劳动成果。现在应对晚期的功能需求变得像在邻里间的一次愉快的周日散步。让我们通过花点时间来开发这个新功能,来展示我们应用程序架构的优势。
让我们从定义我们将要创建的新 MixListComponent 的新路由开始。打开 app/modules/mixer/mixer.module.ts 并进行以下突出显示的修改:
...
import { MixListComponent } from './components/mix-list.component';
import { PROVIDERS } from './services';
const COMPONENTS: any[] = [
BaseComponent,
MixerComponent,
MixListComponent
]
const routes: Routes = [
{
path: '',
component: BaseComponent,
children: [
{
path: 'home',
component: MixListComponent
},
{
path: ':id',
component: MixerComponent
}
]
}
];
@NgModule({
...
providers: [
...PROVIDERS
]
})
export class MixerModule { }
我们正在改变最初将 MixerComponent 作为主页的策略,相反,我们将在稍后创建一个新的 MixListComponent 来表示 'home' 主页,这将是我们正在工作的所有作品的列表。我们仍然可以在应用程序启动时自动选择最后选择的组合,以便以后方便使用。我们已经将 MixerComponent 定义为一个参数化路由,因为它将始终代表我们通过 ':id' 参数路由标识的一个工作组合,例如解析为 '/mixer/1' 这样的路由。我们还导入了 PROVIDERS,我们将在稍后创建它。
让我们修改由 CoreModule 提供的 DatabaseService,以帮助我们为我们的新数据需求提供一个恒定的持久键。我们希望通过这个恒定键名持久化用户创建的作品。打开 app/modules/core/services/database.service.ts 并进行以下突出显示的修改:
...
interface IKeys {
currentUser: string;
compositions: string;
}
@Injectable()
export class DatabaseService {
public static KEYS: IKeys = {
currentUser: 'current-user',
compositions: 'compositions'
};
...
让我们再创建一个新的数据模型来表示我们的组合。创建 app/modules/shared/models/composition.model.ts:
import { ITrack } from './track.model';
export interface IComposition {
id: number;
name: string;
created: number;
tracks: Array<ITrack>;
order: number;
}
export class CompositionModel implements IComposition {
public id: number;
public name: string;
public created: number;
public tracks: Array<ITrack> = [];
public order: number;
constructor(model?: any) {
if (model) {
for (let key in model) {
this[key] = model[key];
}
}
if (!this.created) this.created = Date.now();
// if not assigned, just assign a random id
if (!this.id)
this.id = Math.floor(Math.random() * 100000);
}
}
然后,坚持我们的约定,打开 app/modules/shared/models/index.ts 并重新导出这个新模型:
export * from './composition.model';
export * from './track.model';
我们现在可以使用这个新模型和数据库键在一个新的数据服务上构建这个新功能。创建 app/modules/mixer/services/mixer.service.ts:
// angular
import { Injectable } from '@angular/core';
// app
import { ITrack, IComposition, CompositionModel } from '../../shared/models';
import { DatabaseService } from '../../core/services/database.service';
import { DialogService } from '../../core/services/dialog.service';
@Injectable()
export class MixerService {
public list: Array<IComposition>;
constructor(
private databaseService: DatabaseService,
private dialogService: DialogService
) {
// restore with saved compositions or demo list
this.list = this._savedCompositions() ||
this._demoComposition();
}
public add() {
this.dialogService.prompt('Composition name:')
.then((value) => {
if (value.result) {
let composition = new CompositionModel({
id: this.list.length + 1,
name: value.text,
order: this.list.length // next one in line
});
this.list.push(composition);
// persist changes
this._saveList();
}
});
}
public edit(composition: IComposition) {
this.dialogService.prompt('Edit name:', composition.name)
.then((value) => {
if (value.result) {
for (let comp of this.list) {
if (comp.id === composition.id) {
comp.name = value.text;
break;
}
}
// re-assignment triggers view binding change
// only needed with default change detection
// when object prop changes in collection
// NOTE: we will use Observables in ngrx chapter
this.list = [...this.list];
// persist changes
this._saveList();
}
});
}
private _savedCompositions(): any {
return this.databaseService
.getItem(DatabaseService.KEYS.compositions);
}
private _saveList() {
this.databaseService
.setItem(DatabaseService.KEYS.compositions, this.list);
}
private _demoComposition(): Array<IComposition> {
// Starter composition to demo on first launch
return [
{
id: 1,
name: 'Demo',
created: Date.now(),
order: 0,
tracks: [
{
id: 1,
name: 'Guitar',
order: 0
},
{
id: 2,
name: 'Vocals',
order: 1
}
]
}
]
}
}
现在我们有一个服务,它将提供一个列表来绑定我们的视图以显示用户的保存组合。它还提供了一种添加和编辑组合以及为首次启动应用提供演示组合的方法,以获得良好的首次用户体验(稍后我们将添加实际的轨道到演示中)。
按照我们的约定,我们还可以添加 app/modules/mixer/services/index.ts,如下所示,这是我们之前在 MixerModule 中导入的:
import { MixerService } from './mixer.service';
export const PROVIDERS: any[] = [
MixerService
];
export * from './mixer.service';
现在让我们创建 app/modules/mixer/components/mix-list.component.ts 来消费和投影我们的新数据服务:
// angular
import { Component } from '@angular/core';
// app
import { MixerService } from '../services/mixer.service';
@Component({
moduleId: module.id,
selector: 'mix-list',
templateUrl: 'mix-list.component.html'
})
export class MixListComponent {
constructor(public mixerService: MixerService) { }
}
对于视图模板,app/modules/mixer/components/mix-list.component.html:
<ActionBar title="Compositions" class="action-bar">
<ActionItem (tap)="mixerService.add()"
ios.position="right">
<Button text="New" class="action-item"></Button>
</ActionItem>
</ActionBar>
<ListView [items]="mixerService.list | orderBy: 'order'"
class="list-group">
<ng-template let-composition="item">
<GridLayout rows="auto" columns="100,*,auto"
class="list-group-item">
<Button text="Edit" row="0" col="0"
(tap)="mixerService.edit(composition)"></Button>
<Label [text]="composition.name"
[nsRouterLink]="['/mixer', composition.id]"
class="h2" row="0" col="1"></Label>
<Label [text]="composition.tracks.length"
class="text-right" row="0" col="2"></Label>
</GridLayout>
</ng-template>
</ListView>
这将在视图中渲染 MixerService 用户保存的组合列表,并且当我们首次启动应用时,它将预加载一个包含两个录音的示例 Demo 组合,以便用户可以尝试。以下是首次启动后在 iOS 上的样子:

我们可以创建新的组合并编辑现有组合的名称。我们还可以点击组合的名称来查看 MixerComponent;然而,我们需要调整组件以获取路由 ':id' 参数并将其视图连接到选定的组合。打开 app/modules/mixer/components/mixer.component.ts 并添加突出显示的部分:
// angular
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs/Subscription';
// app
import { MixerService } from '../services/mixer.service';
import { CompositionModel } from '../../shared/models';
@Component({
moduleId: module.id,
selector: 'mixer',
templateUrl: 'mixer.component.html'
})
export class MixerComponent implements OnInit, OnDestroy {
public composition: CompositionModel;
private _sub: Subscription;
constructor(
private route: ActivatedRoute,
private mixerService: MixerService
) { }
ngOnInit() {
this._sub = this.route.params.subscribe(params => {
for (let comp of this.mixerService.list) {
if (comp.id === +params['id']) {
this.composition = comp;
break;
}
}
});
}
ngOnDestroy() {
this._sub.unsubscribe();
}
}
我们可以将 Angular 的 ActivatedRoute 注入以订阅路由的参数,这使我们能够访问 id。由于它默认为字符串,我们在服务列表中定位组合时使用 +params['id'] 将其转换为数字。我们为选定的 composition 分配一个本地引用,这现在允许我们在视图中绑定到它。在此期间,我们还将向 ActionBar 添加一个标签为 List 的按钮,以便导航回我们的组合(稍后,我们将实现字体图标来替换它们)。打开 app/modules/mixer/components/mixer.component.html 并进行以下突出显示的修改:
<ActionBar [title]="composition.name" class="action-bar">
<ActionItem nsRouterLink="/mixer/home">
<Button text="List" class="action-item"></Button>
</ActionItem>
<ActionItem nsRouterLink="/record" ios.position="right">
<Button text="Record" class="action-item"></Button>
</ActionItem>
</ActionBar>
<GridLayout rows="*, 100" columns="*" class="page">
<track-list [tracks]="composition.tracks" row="0" col="0"></track-list>
<player-controls row="1" col="0"></player-controls>
</GridLayout>
这允许我们在 ActionBar 的标题中显示所选组合的名字,并将其轨道传递给 track-list。我们需要向 track-list 添加 Input,以便它渲染组合的轨道,而不是现在绑定到的虚拟数据。让我们打开 app/modules/player/components/track-list/track-list.component.ts 并添加一个 Input:
...
export class TrackListComponent {
@Input() tracks: Array<ITrack>;
...
}
以前,TrackListComponent视图绑定到playerService.tracks,所以让我们调整app/modules/player/components/track-list/track-list.component.html中的组件视图模板,以绑定到我们新的Input,它现在将代表用户实际选择的组合中的曲目**:
<ListView [items]="tracks | orderBy: 'order'" class="list-group">
<template let-track="item">
<GridLayout rows="auto" columns="100,*,100" class="list-group-item">
<Button text="Record" (tap)="record(track)" row="0" col="0" class="c-ruby"></Button>
<Label [text]="track.name" row="0" col="1" class="h2"></Label>
<Switch [checked]="track.solo" row="0" col="2" class="switch"></Switch>
</GridLayout>
</template>
</ListView>
我们现在在我们的应用程序中有了以下序列来满足这个后期功能需求,我们只在这里的几页材料中就完成了它:

在 Android 上,它的工作方式完全相同,同时保留了其独特的本地特性。

然而,您可能会注意到,在 Android 上,ActionBar默认将所有ActionItem放置在右侧。我们想快速向您展示的一个最后的技巧是平台特定视图模板的能力。哦,而且不用担心那些丑陋的 Android 按钮;我们稍后会集成字体图标。
在你认为合适的地方创建特定平台的视图模板。这样做将帮助你在必要时为每个平台调整视图,并使它们易于维护。
让我们创建app/modules/mixer/components/action-bar/action-bar.component.ts:
// angular
import { Component, Input } from '@angular/core';
@Component({
moduleId: module.id,
selector: 'action-bar',
templateUrl: 'action-bar.component.html'
})
export class ActionBarComponent {
@Input() title: string;
}
然后,您可以创建一个特定于 iOS 的视图模板:app/modules/mixer/components/action-bar/action-bar.component.ios.html:
<ActionBar [title]="title" class="action-bar">
<ActionItem nsRouterLink="/mixer/home">
<Button text="List" class="action-item"></Button>
</ActionItem>
<ActionItem nsRouterLink="/record" ios.position="right">
<Button text="Record" class="action-item"></Button>
</ActionItem>
</ActionBar>
以及一个特定于 Android 的视图模板:app/modules/mixer/components/action-bar/action-bar.component.android.html:
<ActionBar class="action-bar">
<GridLayout rows="auto" columns="auto,*,auto" class="action-bar">
<Button text="List" nsRouterLink="/mixer/home" class="action-item" row="0" col="0"></Button>
<Label [text]="title" class="action-bar-title text-center" row="0" col="1"></Label>
<Button text="Record" nsRouterLink="/record" class="action-item" row="0" col="2"></Button>
</GridLayout>
</ActionBar>
然后,我们可以在app/modules/mixer/components/mixer.component.html中使用它:
<action-bar [title]="composition.name"></action-bar>
<GridLayout rows="*, 100" columns="*" class="page">
<track-list [tracks]="composition.tracks" row="0" col="0"></track-list>
<player-controls row="1" col="0"></player-controls>
</GridLayout>
只确保您将其添加到MixerModule的COMPONENTS中,在app/modules/mixer/mixer.module.ts:
...
import { ActionBarComponent } from './components/action-bar/action-bar.component';
...
const COMPONENTS: any[] = [
ActionBarComponent,
BaseComponent,
MixerComponent,
MixListComponent
];
...
哇!

摘要
我们已经到达了这条 66 号公路上的奇妙旅程的终点,希望您感到和我们一样兴奋。本章介绍了一些有趣的 Angular 概念,包括使用懒加载模块配置路由以保持应用程序启动时间快;使用本地文件处理 API 构建自定义模块加载器;将router-outlet的灵活性与 NativeScript 的page-router-outlet相结合;通过懒加载模块获得对单例服务的控制和理解;保护依赖于授权访问的路由;以及处理后期功能需求以展示我们出色的可扩展应用程序设计。
本章总结了我们的应用程序的一般可用性流程,到目前为止,我们已经准备好进入我们应用程序的核心竞争力:通过 iOS 和 Android 丰富的本地 API 进行音频处理。
在深入探讨细节之前,在下一章中,我们将简要检查 NativeScript 的各种tns命令行参数,以运行我们的应用程序,并确保我们对现在可以带到工作中的工具带有一个全面的教育。
第六章:在 iOS 和 Android 上运行应用
有几种方法可以构建、运行和开始使用 NativeScript 应用。我们将介绍命令行工具,因为它们是目前最支持的方法,也是处理任何 NativeScript 项目的最佳方式。
为了简化我们的理解,我们将首先介绍我们将经常使用的命令,然后我们将介绍其他不那么常用的命令。所以,让我们开始,并介绍您想要了解的命令。
在本章中,我们将涵盖以下主题:
-
如何运行应用
-
如何启动调试器
-
如何构建用于部署的应用
-
如何启动测试框架
-
如何运行 NativeScript 诊断
-
所有关于 Android 密钥库的内容
接收命令……
我们将要介绍的第一条命令是您每次都会使用的
启动您的应用。为了使事情更简单,我将使用<platform>来表示 iOS、Android,或者当它最终得到支持时,Windows。
tns run
使用tns run <platform>命令将自动构建您的应用并将其同步到设备和模拟器。它将完成所有繁重的工作,尝试使您的应用在设备上运行,然后启动应用。这个命令在多年中已经发生了变化,现在已经成为一个相当智能的命令,会自动做出某些选择以简化您的发展生活。这个命令的一个酷炫功能是它会将您的应用同步到所有正在运行和连接的设备。如果您连接了五台不同的设备,所有这五台设备都会接收到更改。这仅适用于每个平台,但您可以在一个命令窗口中运行tns run ios,在另一个命令窗口中运行tns run android,然后任何更改都将自动同步到连接到您的机器的所有设备。正如您可能想象的那样,这在测试和清理阶段非常有用,以确保在不同手机和平板电脑上一切看起来都很好。如果您没有将物理设备连接到您的计算机,它将自动为您启动一个模拟器。
通常情况下,由于应用已经存在于设备上,它只会快速同步已更改的文件。这是一个非常快速的过程,因为它只是将你的文件中的所有更改从你的app文件夹传输到所有连接的设备,然后启动应用。这个过程在大多数情况下是非常好的。然而,tns run <平台>并不会总是自动检测到你的node_modules文件夹中的任何更改,例如,当你升级插件时。如果出现这种情况,你需要取消当前正在运行的tns run,然后启动一个新的tns run。偶尔,tns run仍然认为它只需要同步,而实际上它应该需要重新构建应用。在这种情况下,你将想要使用方便的--clean选项。这在设备似乎没有检测到任何更改的时候非常重要。tns run <平台> --clean命令通常将强制应用重新构建;然而,如果--clean无法重新构建,那么请查看本章后面描述的tns build命令。还有一些其他命令参数使用得不多,但你可能需要它们来处理特定情况。--justlaunch将启动应用而不做其他任何事情;--no-watch将禁用实时同步,最后--device <设备 ID>将强制应用仅安装到特定的设备上。你可以通过运行tns devices来查看哪些设备可用于安装应用。
tns debug <平台>
我们接下来要讨论的命令是tns debug <平台>;这将允许你使用调试工具测试你的应用。这与tns run命令类似;然而,它不是运行你的应用,而是调试它。调试器将使用标准的 Chrome 开发工具,这使你能够逐步执行代码:断点、调用栈和控制台日志。此命令将提供一个 URL,你可以使用它来在 Chrome 中打开。在 iOS 特定情况下,你应该运行tns debug ios --chrome以获取 chrome-devtools 的 URL。以下是通过 Chrome 调试器调试 Android 的示例:

一些与tns run相同的参数在这里也是有效的,例如--no-watch、--device和--clean。除了这些命令之外,还有一些其他命令可用,例如--debug-brk,它用于使应用在应用启动时中断,这样你就可以在启动过程继续之前轻松设置断点。--start和--stop允许你附加和从已运行的应用中分离。
不要忘记,如果你目前正在使用调试器,JavaScript 有一个酷炫的debugger;命令,它将强制附加的调试器中断,就像你设置了断点一样。这可以用来在代码的任何地方设置断点,如果没有附加调试器,则会被忽略。
tns build <平台>
你需要了解的下一个命令是 tns build <platform>;这个命令会从头开始完全构建一个新的应用程序。现在,这个命令的主要用途是在你想要构建一个调试或发布版本的应用程序,以便将其提供给其他人进行测试或上传到某个商店时使用。然而,它也可以用来强制进行应用程序的完全清洁构建,如果你的应用程序的 tns run 版本处于一种奇怪的状态--这将进行完全重建。如果你不包含 --release 标志,构建将是默认的调试构建。
在 iOS 上,你将使用 --for-device,这将使应用程序为真实设备而不是模拟器进行编译。记住,你需要从苹果那里获得签名密钥才能进行适当的发布构建。
在 Android 上,当你使用 --release 时,你需要包含以下所有 --key-store-* 参数;这些参数是用于对你的 Android 应用程序进行签名所必需的:
--key-store-path |
你的密钥库文件所在的位置。 |
|---|---|
--key-store-password |
读取你的密钥库中任何数据的密码。 |
--key-store-alias |
这个应用程序的别名。因此,在你的密钥库中,你可能将 AA 作为别名,在你的心中它等于 AwesomeApp。我更喜欢将别名设置为应用程序的完整名称,但这完全取决于你。 |
--key-store-alias-password |
这是读取你刚刚设置的别名所分配的实际签名密钥所需的密码。 |
由于密钥库的处理可能会让人困惑,我们在这里将稍微偏离路径,讨论如何实际创建一个密钥库。这通常只是一件一次性的事情,你需要为每个你想要发布的 Android 应用程序执行。对于 iOS 应用程序,你不需要担心这个问题,因为苹果为你提供了签名密钥,并且完全控制它们。
Android 密钥库
在 Android 上,你将创建自己的应用程序签名密钥。因此,这个密钥将用于你应用程序的整个生命周期--这里的整个是指你使用相同的密钥发布你应用程序的每个版本。这个密钥是将版本 1.0 链接到 v1.1 再到 v2.0 的东西。如果不使用相同的密钥,应用程序将被视为一个完全不同的应用程序。
之所以有两个密码,是因为你的密钥库实际上可以包含无限数量的密钥,因此,密钥库中的每个密钥都有自己的密码。任何有权访问这个密钥的人都可以假装成你。这对于构建服务器是有帮助的,但如果你丢失了它们,那就不是那么有帮助了。你无法在以后更改密钥,因此备份你的密钥库文件非常重要。
没有你的密钥库,你永远无法发布具有相同应用程序名称的新版本,这意味着使用旧版本的用户将看不到你有一个更新版本。因此,再次强调,备份你的密钥库文件是至关重要的。
创建新的密钥库
keytool -genkey -v -keystore *<keystore_name>* -alias *<alias_name>* keyalg RSA -keysize 4096 -validity 10000
你提供一个路径,将文件保存到keystore_name中,对于alias_name,你使用实际的关键名称,我通常使用应用程序名称;所以你输入以下内容:
keytool -genkey -v -keystore *android.keystore* -alias *com.mastertechapps.awesomeapp* -keyalg RSA -keysize 4096 -validity 10000
然后,你会看到以下内容:
Enter keystore password:
Re-enter new password:
What is your first and last name?
[Unknown]: Nathanael Anderson
What is the name of your organizational unit?
[Unknown]: Mobile Applications
What is the name of your organization?
[Unknown]: Master Technology
What is the name of your City or Locality?
[Unknown]: Somewhere
What is the name of your State or Province?
[Unknown]: WorldWide
What is the two-letter country code for this unit?
[Unknown]: WW
Is CN=Nathanael Anderson, OU=Mobile Applications, O=Master Technology, L=Somewhere, ST=WorldWide, C=WW correct?
[no]: yes
Generating 4,096 bit RSA key pair and self-signed certificate (SHA256withRSA) with a validity of 10,000 days for: CN=Nathanael Anderson, OU=Mobile Applications, O=Master Technology, L=Somewhere, ST=WorldWide, C=WW
Enter key password for <com.mastertechapps.awesomeapp>
(RETURN if same as keystore password):
[Storing android.keystore]
你现在已经有了你应用程序的 keystore。
Android Google Play 指纹
如果你使用 Google Play 服务,你可能需要提供你的 Android 应用程序密钥指纹。要获取你的密钥指纹,你可以使用以下命令:
keytool -list -v -keystore *<keystore_name>* -alias *<alias_name>* -storepass *<password>* -keypass *<password>*
你应该会看到类似以下的内容:
Alias name: com.mastertechapps.awesomeapp
Creation date: Mar 14, 2017
Entry type: PrivateKeyEntry
Certificate chain length: 1
Certificate[1]:
Owner: CN=Nathanael Anderson, OU=Mobile Applications, O=Master Technology, L=Somewhere, ST=WorldWide, C=WW
Issuer: CN=Nathanael Anderson, OU=Mobile Applications, O=Master Technology, L=Somewhere, ST=WorldWide, C=WW
Serial number: 2f886ac2
Valid from: Sun Mar 14 14:14:14 CST 2017 until: Thu Aug 17 14:14:14 CDT 2044
Certificate fingerprints:
MD5: FA:9E:65:44:1A:39:D9:65:EC:2D:FB:C6:47:9F:D7:FB
SHA1: 8E:B1:09:41:E4:17:DC:93:3D:76:91:AE:4D:9F:4C:4C:FC:D3:77:E3
SHA256: 42:5B:E3:F8:FD:61:C8:6E:CE:14:E8:3E:C2:A2:C7:2D:89:65:96:1A:42:C0:4A:DB:63:D8:99:DB:7A:5A:EE:73
注意,除了确保你备份好你的 keystore 外,如果你将来要将应用程序卖给其他供应商,为每个应用程序使用单独的 keystore 会使转移过程更容易也更安全。如果你使用相同的 keystore 和/或别名,将很难区分谁得到了什么。所以,为了简单起见,我建议你为每个应用程序使用单独的 keystore 和别名。我通常将 keystore 与 app 一起保存在版本控制中。由于打开和访问别名都需要密码保护,除非你选择了糟糕的密码,否则你不会有问题。
回到命令行
现在我们已经偏离了处理 Android keystore 的路径,我们将更深入地探讨你偶尔会用到的一些 tns 命令。这些命令中的第一个是 tns plugin。
tns plugin 命令
这个功能实际上非常重要,但只有在你想处理插件时才会使用。这个命令最常见的版本就是tns plugin add <name>。例如,如果你想安装一个名为NativeScript-Dom的插件,你将执行tns plugin add nativescript-dom,它将自动安装这个插件的相关代码以便在你的应用程序中使用。要移除这个插件,你可以输入tns plugin remove nativescript-dom。我们还有tns plugin update nativescript-dom来移除插件并下载安装插件的新版本。最后,单独运行tns plugin将列出你已安装的插件及其版本:

然而,说实话,如果我真的需要这个信息,我正在寻找过时的插件,所以你更好的选择是输入npm outdated,让npm为你列出过时的插件及其当前版本:

如果你有一些过时的插件,你可以使用tns plugin update命令来升级它们。
tns install <dev_plugin>命令
这个功能并不常用,但在你需要的时候很有用,因为它允许你安装开发插件,例如 webpack、typescript、coffee script 或 SASS 支持。所以,如果你决定要使用webpack,你可以输入tns install webpack,它将安装 webpack 支持,以便你可以使用 webpack 来构建你的应用程序。
tns create <project_name>命令
这个命令是我们用来创建新项目的。这将创建一个新的目录并安装构建新应用所需的所有平台无关代码。这个命令的重要参数是 --ng,它告诉它使用 Angular 模板(这正是我们在本书中使用的内容--如果没有 --ng,你将得到纯 JS 模板)和 --appid,它允许你设置你的完整应用名称。所以,tns create AwesomeApp --ng --appid com.mastertechapps.awesomeapp 将在 AwesomeApp 目录中创建一个新的 Angular 应用,应用 ID 为 com.mastertechapps.awesomeapp。
tns info 命令
检查主 NativeScript 组件状态的另一个有用命令是 tns info;这个命令实际上会检查你的主 NativeScript 部件,并告诉你是否有任何内容过时:

如前例所示,NativeScript 命令行有一个更新的版本,我没有安装 ios 运行时。
tns platform [add|remove|clean|upgrade] 命令
你可以使用 tns platform [add|remove|clean|upgrade] <platform> 命令来安装、删除或更新平台模块,就像插件一样。这些就是你在之前的 tns info 命令中看到的 tns-android 和 tns-ios 模块。应用程序实际上需要安装这些特定平台的模块。默认情况下,当你执行 tns run 时,如果它们缺失,它将自动安装。偶尔,如果应用程序拒绝构建,你可以使用 tns platform clean <platform>,它将自动卸载并重新安装平台,这将重置构建过程。
注意,当你执行 tns platform clean/remove/update 时,这些操作将完全删除 platforms/<platform> 文件夹。如果你对这个文件夹中的文件进行了任何手动更改(这不被推荐),这些更改将被删除。
tns test 命令
tns test <platform> 命令允许你安装和/或启动测试框架。我们将在后面的章节中更深入地介绍测试,然而,为了完整性,我们将在本节中介绍这个命令。tns test init 将初始化测试系统;你将为每个应用程序做一次。它将要求你选择一个测试框架,然后安装你选择的测试框架。tns test <platform> 将在该特定平台上启动测试。
tns device 命令
如果你需要特别针对设备,使用 tns device 命令将给你一个已安装并连接到你的计算机的设备列表。这将允许你在 tns run/debug 命令中使用 --device <deviceid> 参数:

tns doctor 命令
tns doctor 命令检查您的环境是否存在常见问题。它将尝试检测是否所有内容都已正确安装和配置。它通常可以正常工作,但偶尔会失败,并指出某些东西出了问题,即使实际上一切正常。然而,它提供了非常好的第一个迹象,如果您的 tns run/build/debug 不再工作,可能会出什么问题。
TNS 帮助命令
如果您完全忘记了我们在这里写的内容,您可以执行 tns help,这将为您提供不同命令的概述。一些参数可能没有列出,但在此阶段,它们确实存在。在新版本中,新的参数和命令可能会添加到 tns 中,这是了解它们的最简单方法。
如果由于某种原因,您的应用程序似乎没有正确更新,最简单的方法是从设备中卸载应用程序。然后,尝试执行 tns build <platform>,然后 tns run <platform>。如果这不能解决问题,则再次卸载应用程序,执行 tns platform clean <platform>,然后执行您的 tns run。偶尔,平台可能会进入奇怪的状态,重置它是解决问题的唯一方法。
TNS 命令行速查表
| 命令行 | 描述 |
|---|---|
tns --version |
这将返回 NativeScript 命令的版本。如果您正在运行较旧版本,则可以使用 npm 升级您的 NativeScript 命令,如下所示:npm install -g nativescript。 |
tns create <your project name> |
这将创建一个全新的项目。以下是其参数:--ng 和 --appid。 |
tns platform add <platform> |
这会将一个目标平台添加到您的项目中。 |
tns platform clean <platform> |
此命令通常不需要,但如果您正在修改平台目录和平台,您可以删除并重新添加它。请注意,这将删除整个平台目录。因此,如果您对 Android 清单或 iOS Xcode 项目文件进行了任何特定的自定义,请在运行清理命令之前备份它们。 |
tns platform update <platform> |
这实际上是一个非常重要的命令。NativeScript 仍然是一个非常活跃的项目,正在经历大量的开发。此命令将您的平台代码升级到最新版本,这通常消除了错误并添加了许多新功能。请注意,这应该与通用 JavaScript 库的升级一起进行,因为它们通常彼此同步。 |
tns build <platform> |
这将使用参数 --release、--for-device 和 --key-store-* 为该平台构建应用程序。 |
tns deploy <platform> |
这将为该平台构建和部署应用程序到物理或虚拟设备。 |
tns run <平台> |
这将在物理设备或模拟器上构建、部署并启动应用程序。这是你将大多数时间用来运行应用程序和检查更改的命令。它的参数是 --clean、--no-watch 和 --justlaunch。 |
tns debug <平台> |
这将在调试模式下构建、部署并在物理设备或模拟器上启动应用程序。这可能是第二常用的命令。它的参数是 --clean、--no-watch、--dbg-break 和 --start。 |
tns plugin add <插件> |
这允许你添加第三方插件或组件。这些插件可以是完全基于 JavaScript 的代码,或者也可能包含从 Java 或 Objective-C 库编译的内容。 |
tns doctor |
这允许你在 NativeScript 似乎无法正常工作时对你的环境运行诊断检查。 |
tns devices |
这显示了用于与 --device 命令一起使用的连接设备列表。 |
tns install <开发插件> |
这将安装一个开发插件(即 webpack、typescript 等)。 |
tns test [ init | <平台> ] |
这允许你为你的应用程序创建或运行任何测试。使用 init 将初始化应用程序的测试框架。然后,你可以输入平台来在该平台上运行测试。 |
摘要
现在你已经了解了命令行的强大功能,你真正需要记住的就是 tns debug ios 和 tns run android;这些将成为我们冒险中的忠实伙伴。再添加几个 tns plugin add 命令,然后在最终完成时使用 tns build 来封装应用程序,你就成功了。然而,不要忘记其他命令;它们都各有用途。其中一些很少使用,但有些在你需要时非常有帮助。
在第七章 构建多轨播放器 中,我们将开始探索如何实际访问原生平台并与插件集成。
第七章:构建多轨播放器
我们已经到达了 NativeScript 开发的关键点:通过 TypeScript 直接访问 iOS 的 Objective-C/Swift API 和 Android 的 Java API。
这无疑是 NativeScript 最独特的特点之一,为您作为移动开发者打开了众多机会。特别是,我们的应用程序将需要利用 iOS 和 Android 上的丰富原生音频 API,以实现其核心能力,即为用户提供引人入胜的多轨录音/混音体验。
了解如何针对这些 API 进行编码对于解锁移动应用程序的全部潜力至关重要。此外,学习如何集成现有的 NativeScript 插件,这些插件可能已经在 iOS 和 Android 上提供了一致的 API,可以帮助您更快地实现目标。利用每个平台能提供的最佳性能将是我们在第三部分旅程中的重点。
在本章中,我们将涵盖以下内容:
-
集成 Nativescript-audio 插件
-
为我们的轨道播放器创建一个模型以实现未来的可扩展性
-
使用 RxJS 可观察对象进行工作
-
通过第三方库和视图绑定理解 Angular 的 NgZone
-
处理与多个音频源同步的音频播放
-
利用 Angular 的绑定以及 NativeScript 的原生事件绑定,以实现我们追求的精确可用性
-
使用 Angular 平台特定的指令为我们的播放器控件构建自定义穿梭滑块
通过 nativescript-audio 插件实现我们的多轨播放器
幸运的是,NativeScript 社区已经发布了一个插件,它为我们提供了一个一致的 API,可以在 iOS 和 Android 上使用,以便开始使用音频播放器。在实现功能之前,请随意浏览 plugins.nativescript.org,这是 NativeScript 插件的官方来源,以确定现有的插件是否适合您的项目。
在这个案例中,位于 plugins.nativescript.org/plugin/nativescript-audio 的 nativescript-audio 插件包含了我们开始集成应用程序功能播放器部分所需的内容,并且它在 iOS 和 Android 上都能工作。它甚至提供了一个我们可能能够使用的录音器。让我们先从安装它开始:
npm install nativescript-audio --save
NativeScript 框架允许您与任何 npm 模块集成,从而打开了一个令人眼花缭乱的集成可能性,包括 NativeScript 特定的插件。实际上,如果您遇到 npm 模块给您带来麻烦的情况(可能是因为它依赖于在 NativeScript 环境中不兼容的 node API),甚至有一个插件可以帮助您处理这种情况,请参阅www.npmjs.com/package/nativescript-nodeify。它详细描述在www.nativescript.org/blog/how-to-use-any-npm-module-with-nativescript。
在与 NativeScript 插件集成时,创建一个模型或 Angular 服务来围绕其集成提供隔离。
尝试通过创建一个可重用的模型或 Angular 服务来隔离第三方插件集成点。这不仅将为您的应用程序提供良好的未来可扩展性,而且在您需要用不同的插件替换该插件或为 iOS 或 Android 提供不同的实现时,将提供更多的灵活性。
为我们的多音轨播放器构建TrackPlayerModel
我们需要每个音轨都有自己的音频播放器实例,以及暴露一个 API 来加载音轨的音频文件。这也会提供一个很好的地方来暴露音频文件加载后的音轨时长。
由于这个模型可能会在整个应用程序中共享(预计未来还会与录音播放一起),我们将在app/modules/shared/models/track-player.model.ts中与我们的其他模型一起创建它:
// libs
import { TNSPlayer } from 'nativescript-audio';
// app
import { ITrack } from
'./track.model';
interface ITrackPlayer {
trackId: number;
duration: number;
readonly
player: TNSPlayer;
}
export class TrackPlayerModel implements ITrackPlayer {
public trackId:
number;
public duration: number;
private _player: TNSPlayer;
constructor() {
this._player = new TNSPlayer();
}
public load(track: ITrack): Promise<number> {
return
new Promise((resolve, reject) => {
this.trackId = track.id;
this._player.initFromFile({
audioFile: track.filepath,
loop: false
}).then(() => {
this._player.getAudioTrackDuration()
.then((duration) => {
this.duration = +duration;
resolve();
});
});
});
}
public get player():
TNSPlayer {
return this._player;
}
}
我们首先从nativescript-audio插件中导入甜美的 NativeScript 社区音频播放器TNSPlayer。然后,我们定义一个简单的接口来实现我们的模型,该接口将引用trackId、其duration以及一个用于player实例的readonly获取器。接着,我们将该接口包含在我们的实现中,它使用自身构建一个TNSPlayer实例。由于我们希望有一个灵活的模型,可以在任何时间加载其音轨文件,我们提供了一个接受ITrack的load方法,该方法利用了initFromFile方法。这反过来会异步获取音轨的总时长(以字符串形式返回,因此我们使用+duration),在模型中存储该数字,在解决音轨初始化完成之前。
为了保持一致性和标准,请确保也从app/modules/shared/models/index.ts导出这个新模型:
export * from './composition.model';
export * from './track-player.model';
export * from
'./track.model';
最后,我们提供了一个用于播放器实例的获取器,PlayerService将使用它。这使我们来到了下一步:打开app/modules/player/services/player.service.ts。我们将根据我们最新的发展对我们的初始实现进行一些修改;整体查看这个,我们将在之后解释:
// angular
import { Injectable } from '@angular/core';
// libs
import { Subject }
from 'rxjs/Subject';
import { Observable } from 'rxjs/Observable';
// app
import { ITrack, CompositionModel, TrackPlayerModel } from '../../shared/models';
@Injectable()
export class PlayerService {
// observable state
public playing$:
Subject<boolean> = new Subject();
public duration$: Subject<number> = new Subject
();
public currentTime$: Observable<number>;
// active composition
private _composition: CompositionModel;
// internal state
private _playing:
boolean;
// collection of track players
private _trackPlayers: Array<TrackPlayerModel>
= [];
// used to report currentTime from
private _longestTrack:
TrackPlayerModel;
constructor() {
// observe currentTime changes every 1 seconds
this.currentTime$ = Observable.interval(1000)
.map(_ => this._longestTrack ?
this._longestTrack.player.currentTime
: 0);
}
public set playing(value: boolean)
{
this._playing = value;
this.playing$.next(value);
}
public get playing(): boolean {
return
this._playing;
}
public get composition(): CompositionModel
{
return this._composition;
}
public set
composition(comp: CompositionModel) {
this._composition = comp;
// clear any previous players
this._resetTrackPlayers();
// setup
player instances for each track
let initTrackPlayer = (index: number) => {
let track = this._composition.tracks[index];
let trackPlayer = new
TrackPlayerModel();
trackPlayer.load(track).then(_ => {
this._trackPlayers.push(trackPlayer);
index++;
if (index <
this._composition.tracks.length) {
initTrackPlayer(index);
}
else {
// report total duration of composition
this._updateTotalDuration();
}
});
};
// kick off multi-track player initialization
initTrackPlayer
(0);
}
public togglePlay() {
this.playing =
!this.playing;
if (this.playing) {
this.play();
} else {
this.pause();
}
}
public play() {
for (let t of this._trackPlayers) {
t.player.play();
}
}
public
pause() {
for (let t of this._trackPlayers) {
t.player.pause
();
}
}
...
private
_updateTotalDuration() {
// report longest track as the total duration of the mix
let totalDuration = Math.max(
...this._trackPlayers.map(t =>
t.duration));
// update trackPlayer to reflect longest track
for (let
t of this._trackPlayers) {
if (t.duration === totalDuration) {
this._longestTrack = t;
break;
}
}
this.duration$.next(totalDuration);
}
private _resetTrackPlayers() {
for (let t of this._trackPlayers) {
t.cleanup();
}
this._trackPlayers = [];
}
}
目前 PlayerService 的基石不仅在于管理混音中播放多个音轨的繁重工作,还在于提供一个状态,我们的视图可以观察以反映组合的状态。因此,我们有以下内容:
...
// observable state
public playing$: Subject<boolean> = new Subject();
public duration$:
Subject<number> = new Subject();
public currentTime$: Observable<number>;
// active
composition
private _composition: CompositionModel;
// internal state
private _playing: boolean;
//
collection of track players
private _trackPlayers: Array<TrackPlayerModel> = [];
// used to report
currentTime from
private _longestTrack: TrackPlayerModel;
constructor() {
// observe currentTime
changes every 1 seconds
this.currentTime$ = Observable.interval(1000)
.map(_ => this._longestTrack ?
this._longestTrack.player.currentTime
: 0);
}
...
我们的视图需要知道播放状态以及 duration 和 currentTime。使用 Subject 对于 playing$ 和 duration$ 状态将工作得很好,因为它们如下:
-
它们可以直接发出值
-
它们不需要发出初始值
-
它们不需要任何可观察的组合
另一方面,currentTime$ 将会根据一些组合来设置,因为它的值将依赖于可能随时间发展的间歇性状态(关于这一点稍后会有更多说明!)。换句话说,playing$ 状态是我们通过用户(或基于播放器状态内部)进行的播放操作直接控制和发出的一个值,而 duration$ 状态是我们直接发出的一个值,作为所有音轨播放器初始化并准备好的结果。
currentTime 是播放器不会通过播放器事件自动发出的一个值,而是一个我们必须间歇性检查的值。因此,我们组合 Observable.interval(1000),它将在订阅时每秒自动发出一个映射值,代表最长音轨播放实例的实际 currentTime。
其他 private 引用有助于维护服务内部状态。最有趣的是,我们将保留 _longestTrack 的引用,因为我们的组合的总时长将始终基于最长音轨,因此也将用于跟踪 currentTime。
这种设置将提供我们视图所需的基本要素,以实现适当的用户交互。
RxJS 默认不包含任何操作符。因此,如果你现在运行 Observable.interval(1000) 和 .map,它们将使你的应用崩溃!
当你开始更多地使用 RxJS 时,创建一个 operators.ts 文件并将所有 RxJS 操作符导入其中是个好主意。然后,在根 AppComponent 中导入该文件,这样你就不需要在代码库的各个地方散布那些操作符导入。
创建 app/operators.ts 并包含以下内容:
import 'rxjs/add/operator/map';
import 'rxjs/add/observable/interval';
然后,打开 app/app.component.ts 并在第一行导入该文件:
import './operators';
...
现在,我们可以自由地在代码的任何地方使用 map、interval 以及我们需要的任何其他 rxjs 操作符,前提是我们将它们导入到那个单一文件中。
我们服务的下一部分相当直观:
public set playing(value: boolean) {
this._playing = value;
this.playing$.next(value);
}
public get playing(): boolean {
return this._playing;
}
public get composition(): CompositionModel
{
return this._composition;
}
我们的 playing 设置器确保内部状态 _playing 被更新,以及 playing$ 主题的值被发出,以便任何需要对此状态变化做出反应的订阅者。为了保险起见,还添加了一些方便的获取器。我们组合的下一个设置器变得相当有趣,因为这是我们与新的 TrackPlayerModel 交互的地方:
public set composition(comp: CompositionModel) {
this._composition = comp;
// clear any previous
players
this._resetTrackPlayers();
// setup player instances for each track
let initTrackPlayer =
(index: number) => {
let track = this._composition.tracks[index];
let trackPlayer = new
TrackPlayerModel();
trackPlayer.load(track).then(_ => {
this._trackPlayers.push
(trackPlayer);
index++;
if (index < this._composition.tracks.length) {
initTrackPlayer(index);
} else {
// report total duration of composition
this._updateTotalDuration();
}
});
};
// kick off multi-track player initialization
initTrackPlayer(0);
}
...
private _resetTrackPlayers() {
for (let t of this._trackPlayers) {
t.cleanup();
}
this._trackPlayers = [];
}
每当我们设置活动作品时,我们首先确保我们的服务内部_trackPlayers引用被正确清理和清除,使用this._resetTrackPlayers()。然后我们设置一个本地方法initTrackPlayer,它可以被迭代调用,考虑到每个播放器的load方法具有异步特性,以确保每个轨道的播放器正确加载音频文件,包括其时长。每次成功加载后,我们将它添加到我们的_trackPlayers集合中,迭代并继续,直到所有音频文件都加载完成。完成后,我们调用this._updateTotalDuration()来确定我们轨道作品的最终时长:
private _updateTotalDuration() {
// report longest track as the total duration of the mix
let
totalDuration = Math.max(
...this._trackPlayers.map(t => t.duration));
// update trackPlayer to reflect
longest track
for (let t of this._trackPlayers) {
if (t.duration === totalDuration) {
this._longestTrack = t;
break;
}
}
this.duration$.next(totalDuration);
}
由于应该始终使用最长时长的轨道来确定整个作品的持续时间,我们使用Math.max来确定最长时长,然后存储对轨道的引用。因为可能有多个轨道具有相同的时长,所以使用哪个轨道并不重要,只要有一个匹配最长时长即可。这个_longestTrack将作为我们的节奏设定者,因为它将用于确定整个作品的currentTime。最后,我们通过duration$主题将最长时长作为totalDuration发射给任何订阅的观察者。
接下来的几个方法提供了我们作品整体播放控制的基础:
public togglePlay() {
this.playing = !this.playing;
if (this.playing) {
this.play();
}
else {
this.pause();
}
}
public play() {
for (let t of this._trackPlayers) {
t.player.play();
}
}
public pause() {
for (let t of this._trackPlayers) {
t.player.pause();
}
}
我们 UI 中的主要播放按钮将使用togglePlay方法来控制播放,因此也用于切换内部状态以及激活所有轨道播放器的播放或暂停方法。
让音乐播放!
为了尝试所有这些,让我们添加来自由杰出的Jesper Buhl Trio创作的爵士曲目What Is This Thing Called Love的三个样本音频文件。这些曲目已经由鼓、贝斯和钢琴分开。我们可以将这些.mp3文件添加到app/audio文件夹中。
让我们修改MixerService中我们的演示作品的轨道,以提供对这些新真实音频文件的引用。打开app/modules/mixer/services/mixer.service.ts并进行以下修改:
private _demoComposition(): Array<IComposition> {
// starter composition for user to demo on first
launch
return [
{
id: 1,
name: 'Demo',
created: Date.now(),
order: 0,
tracks: [
{
id: 1,
name: 'Drums',
order: 0,
filepath:
'~/audio/drums.mp3'
},
{
id: 2,
name: 'Bass',
order: 1,
filepath: '~/audio/bass.mp3'
},
{
id: 3,
name: 'Piano',
order:
2,
filepath: '~/audio/piano.mp3'
}
]
}
];
}
现在让我们向我们的播放器控制输入一个输入,这将选择我们的选定作品。打开app/modules/mixer/components/mixer.component.html,并进行以下高亮显示的修改:
<action-bar [title]="composition.name"></action-bar>
<GridLayout rows="*, auto" columns="*"
class="page">
<track-list [tracks]="composition.tracks" row="0" col="0">
</track-list>
<player-controls [composition]="composition"
row="1" col="0"></player-controls>
</GridLayout>
然后,在PlayerControlsComponent的app/modules/player/components/player- controls/player-controls.component.ts中,我们现在可以通过其各种可观察对象观察PlayerService的状态:
// angular
import { Component, Input } from '@angular/core';
// libs
import { Subscription } from 'rxjs/Subscription';
// app
import { ITrack,
CompositionModel } from '../../../shared/models';
import { PlayerService } from '../../services';
@Component({
moduleId: module.id,
selector: 'player-controls',
templateUrl: 'player-
controls.component.html'
})
export class PlayerControlsComponent {
@Input() composition:
CompositionModel;
// ui state
public playStatus: string = 'Play';
public duration:
number = 0;
public currentTime: number = 0;
// manage subscriptions
private _subPlaying:
Subscription;
private _subDuration: Subscription;
private _subCurrentTime:
Subscription;
constructor(
private playerService: PlayerService
) { }
public togglePlay() {
this.playerService.togglePlay();
}
ngOnInit() {
// init audio player for composition
this.playerService.composition = this.composition;
// react to play state
this._subPlaying = this.playerService.playing$
.subscribe((playing: boolean) =>
{
// update button state
this._updateStatus(playing);
//
update slider state
if (playing) {
this._subCurrentTime =
this.playerService
.currentTime$
.subscribe
((currentTime: number) => {
this.currentTime = currentTime;
});
} else if (this._subCurrentTime) {
this._subCurrentTime.unsubscribe();
}
});
//
update duration state for slider
this._subDuration = this.playerService.duration$
.subscribe((duration: number) => {
this.duration = duration;
});
}
ngOnDestroy() {
// cleanup
if (this._subPlaying)
this._subPlaying.unsubscribe();
if
(this._subDuration)
this._subDuration.unsubscribe();
if
(this._subCurrentTime)
this._subCurrentTime.unsubscribe();
}
private _updateStatus(playing: boolean) {
this.playStatus =
playing ? 'Stop' : 'Play';
}
}
PlayerControlComponent 的基石现在是其通过 ngOnInit 中的 this.playerService.composition = this.composition 来设置活动组合的能力,这时组合输入已经就绪,以及订阅 PlayerService 提供的各种状态来更新我们的 UI。这里最有趣的是 playing$ 订阅,它根据是否播放来管理 currentTime$ 订阅。如果你还记得,我们的 currentTime$ 可观察者是从 Observable.interval(1000) 开始的,这意味着每秒会发出最长轨道的 currentTime,这里再次列出以供参考:
this.currentTime$ = Observable.interval(1000)
.map(_ => this._longestTrack ?
this._longestTrack.player.currentTime
: 0);
我们只想在播放参与时更新 Slider 的 currentTime;因此,当 playing$ 主题发出 true 时,这将允许我们的组件每秒接收播放器的 currentTime。当 playing$ 发出 false 时,我们取消订阅,不再接收 currentTime 更新。非常好。
我们还订阅了我们的 duration$ 主题来更新滑块的 maxValue。最后,我们确保通过它们在 ngOnDestroy 中的 Subscription 引用清理所有订阅。
让我们看看 PlayerControlsComponent 的视图绑定,它在 app/modules/player/components/player-controls/player-controls.component.html:
<GridLayout rows="100" columns="100,*"
row="1" col="0" class="p-x-10">
<Button [text]
="playStatus" (tap)="togglePlay()"
row="0" col="0" class="btn btn-primary w-
100"></Button>
<Slider [maxValue]="duration" [value]="currentTime"
minValue="0" row="0" col="1" class="slider">
</Slider>
</GridLayout>
如果你运行应用程序,你现在可以选中演示组合,并在 iOS 和 Android 上播放音乐。
音乐动听!这真是太棒了。事实上,它简直太棒了!!
在这个阶段,你可能注意到或希望以下几件事情:
-
在选择播放按钮后,它正确地变为停止,但当播放到达末尾时,它不会返回到原始的播放文本。
-
Slider也应该返回到位置 0 以重置播放。 -
iOS 上的总
duration和currentTime使用秒;然而,Android 使用毫秒。 -
在 iOS 上,如果你在组合的演示轨道播放期间多次选择播放/暂停,你可能会注意到所有轨道上非常微妙的播放同步问题。
-
需要当前时间和持续时间标签。
-
播放寻找 很好,能够将滑块穿梭以控制播放位置。
完善实现
我们在模型和服务中缺少一些重要的部分来真正完善我们的实现。让我们从处理轨道播放实例的完成和错误条件开始。打开 TrackPlayerModel 在 app/modules/shared/models/track-player.model.ts,并添加以下内容:
... export interface IPlayerError {
trackId: number;
error: any;
}
export class TrackPlayerModel implements ITrackPlayer {
...
private _completeHandler: (number) => void;
private _errorHandler:
(IPlayerError) => void;
...
public load(
track: ITrack,
complete: (number) => void,
error: (IPlayerError) => void
):
Promise<number> {
return new Promise((resolve, reject) => {
...
this._completeHandler = complete;
this._errorHandler = error;
this._player.initFromFile({
audioFile: track.filepath,
loop: false,
completeCallback: this._trackComplete.bind(this),
errorCallback:
this._trackError.bind(this) ... private _trackComplete(args: any) {
// TODO:
works well for multi-tracks with same length
// may need to change in future with varied lengths
this.player.seekTo(0);
console.log('trackComplete:', this.trackId);
if (this._completeHandler)
this._completeHandler(this.trackId);
}
private _trackError(args: any) {
let error =
args.error;
console.log('trackError:', error);
if (this._errorHandler)
this._errorHandler({
trackId: this.trackId, error });
}
我们首先使用 IPlayerError 定义每个轨道错误的形状。然后,我们定义通过 load 参数捕获的 _completeHandler 和 _errorHandler 函数的引用,现在这些函数需要完整的和错误回调。我们在将模型内部的 this._trackComplete 和 this._trackError(使用 .bind(this) 语法确保函数作用域锁定到自身)分配给 TNSPlayer 的 completeCallback 和 errorCallback 之前分配这些。
completeCallback和errorCallback将在区域外触发。这就是为什么我们在本章后面注入NgZone并使用ngZone.run()。我们可以通过创建一个使用zonedCallback函数的回调来避免这种情况。它将确保回调将在创建回调的同一区域中执行。例如:
this._player.initFromFile({
audioFile: track.filepath,
loop: false,
completeCallback:
zonedCallback(this._trackComplete.bind(this)),
errorCallback:
zonedCallback(this._trackError.bind(this))
...
这使我们能够在分发这些条件之前内部处理每个条件。
其中一个内部条件是在音频播放完成后将每个音频播放器重置为零,所以我们只需调用TNSPlayer的seekTo方法来重置它。我们标记为待办事项,因为尽管当所有轨道长度相同(如我们的演示轨道)时这效果很好,但在我们开始录制不同长度的多轨时,这可能会在未来变得可能有问题。想象一下,在一个作品中我们有两个轨道:轨道 1 时长 1 分钟,轨道 2 时长 30 秒。如果我们播放作品到 45 秒并按暂停,轨道 2 已经调用了其完成处理程序并重置回 0。然后我们按播放来继续。轨道 1 从 45 秒开始继续,但轨道 2 回到了 0。我们将在到达那里时解决它,所以不要担心! 到目前为止,我们正在完善第一阶段实现。
最后,我们调用分配的completeHandler来通知调用者哪个trackId已经完成。对于trackError,我们简单地调用传递trackId和error。
现在,让我们回到PlayerService并连接这个功能。打开app/modules/player/services/player.service.ts并做出以下修改:
// app
import { ITrack, CompositionModel, TrackPlayerModel, IPlayerError } from
'../../shared/models';
@Injectable()
export class PlayerService {
// observable state
...
public complete$: Subject<number> = new Subject();
... public set
composition(comp: CompositionModel) {...let initTrackPlayer = (index:
number) => {...trackPlayer.load(
track,
this._trackComplete.bind(this),
this._trackError.bind(this)
...
private _trackComplete(trackId: number) {
console.log('track complete:', trackId);
this.playing =
false;
this.complete$.next(trackId);
}
private _trackError(playerError: IPlayerError) {
console.log(`trackId ${playerError.trackId} error:`,
playerError.error);
}
...
我们添加了另一个主题,complete$,以便视图组件可以在轨道播放完成时订阅。此外,我们还添加了两个回调处理程序,_trackComplete和_trackError,我们将它们传递给TrackPlayerModel的load方法。
然而,如果我们尝试在任何视图组件中由于complete$订阅触发而更新视图绑定,你会注意到一些令人困惑的事情。视图不会更新!
每次你与第三方库集成时,请注意来自库的回调处理程序,你可能打算更新视图绑定。在需要的地方注入 NgZone 并使用this.ngZone.run(() => ...包裹。
提供回调的第三方库可能经常需要通过 Angular 的 NgZone 运行。如果你想了解更多关于区域的信息,Thoughttram 的伟大团队发布了一篇很好的文章,可以在blog.thoughtram.io/angular/2016/02/01/zones-in-angular-2.html找到。
第三方库nativescript-audio与 iOS 和 Android 的原生音频播放器集成,并提供你可以连接起来处理完成和错误条件的回调。这些回调在原生音频播放器的上下文中异步执行,并且由于它们不是在用户事件(如点击、网络请求的结果或setTimeout定时器)的上下文中处理的,因此如果我们希望它们导致视图绑定的更新,我们需要确保结果和随后的代码执行在 Angular 的 NgZone 中进行。
由于我们希望complete$主题导致视图绑定更新(特别是重置我们的滑块),我们将注入 NgZone 并包装我们的回调处理。回到app/modules/player/services/player.service.ts,让我们进行以下调整:
// angular
import { Injectable, NgZone } from '@angular/core';
@Injectable()
export class PlayerService {
...
constructor(private ngZone: NgZone) {}
...
private _trackComplete(trackId: number) {
console.log('track complete:', trackId);
this.ngZone.run(() => {
this.playing = false;
this.complete$.next(trackId);
});
}
...
现在,当使用这个新的complete$主题来响应我们的服务状态时,我们将非常明确。让我们调整PlayerControlsComponent在app/modules/player/components/player-controls/player-controls.component.ts中的设置,以观察complete$主题来重置我们的currentTime绑定:
export class PlayerControlsComponent {
...
private _subComplete: Subscription;
...
ngOnInit() {
...
// completion should reset currentTime
this._subComplete
= this.playerService.complete$.subscribe(_ => {
this.currentTime = 0;
});
}
ngOnDestroy() {
...
if (this._subComplete) this._subComplete.unsubscribe();
}
...
iOS 音频播放器报告duration和currentTime为秒,而 Android 报告为毫秒。我们需要标准化这一点!
让我们在PlayerService中添加一个方法来标准化时间,这样我们就可以依赖提供秒数时间的两个平台:
...
// nativescript
import { isIOS } from 'platform';
...
@Injectable()
export class PlayerService {
constructor() {
// observe currentTime changes
every 1 seconds
this.currentTime$ = Observable.interval(1000)
.map(_ => this._longestTrack ?
this._standardizeTime(
this._longestTrack.player.currentTime)
: 0;
);
}
...
private _updateTotalDuration() {
...
// iOS: reports
duration in seconds
// Android: reports duration in milliseconds
//
standardize to seconds
totalDuration = this._standardizeTime(totalDuration);
console.log('totalDuration of mix:', totalDuration);
this.duration$.next(totalDuration);
}
...
private _standardizeTime(time: number) {
return isIOS ? time : time * .001;
}
...
我们可以利用 NativeScript 的platform模块提供的isIOS布尔值,有条件地调整我们的时间,将 Android 的毫秒转换为秒。
使用 NativeScript 的platform模块中的isIOS和/或isAndroid布尔值是在代码库中需要时进行平台调整的非常有效的方法。
那么,iOS 上多个音轨的微妙播放同步问题怎么办呢?
在 iOS 上,如果你在 14 秒的播放时间内多次选择播放/暂停,你可能会注意到所有音轨上都非常微妙的播放同步问题。我们可以推测这可能在 Android 的某个时刻也会发生。
通过直接从 nativescript-audio 插件调用底层 iOS AVAudioPlayer 实例的本地 API 来利用 NativeScript 的优势。
让我们在播放/暂停逻辑中插入一些安全措施,以帮助确保我们的音轨尽可能同步到我们的编程能力。nativescript-audio插件提供了一个仅适用于 iOS 的方法,称为playAtTime。它与特殊的deviceCurrentTime属性协同工作,正如 Apple 在其针对此目的的文档中所描述的,可以在developer.apple.com/reference/avfoundation/avaudioplayer/1387462-devicecurrenttime?language=objc找到。
由于deviceCurrentTime属性没有被 nativescript-audio 插件暴露,我们可以通过ios获取器直接访问原生属性。让我们调整PlayerService的play方法来使用它:
public play() {
// for iOS playback sync
let shortStartDelay = .01;
let
now = 0;
for (let i = 0; i < this._trackPlayers.length; i++) {
let track = this._trackPlayers[i];
if (isIOS) {
if (i == 0) now =
track.player.ios.deviceCurrentTime;
(<any>track.player).playAtTime
(now + shortStartDelay);
} else {
track.player.play
();
}
}
}
由于track.player是我们的TNSPlayer实例,我们可以通过其ios获取器直接访问底层原生平台播放器实例(对于 iOS,它是AVAudioPlayer),以直接访问deviceCurrentTime。为了保险起见,我们提供了一个非常短的开始延迟,将其添加到第一首轨道的deviceCurrentTime中,并使用它来精确地同时启动所有轨道,这效果非常好!由于playAtTime没有通过 TypeScript 定义与 nativescript-audio 插件一起发布,我们在调用方法之前简单地将播放器实例(<any>track.player)进行类型转换,以满足 tsc 编译器。由于 Android 没有等效功能,我们将仅使用标准媒体播放器的播放方法,这对于 Android 来说效果很好。
现在让我们用类似的安全措施调整我们的暂停方法:
public pause() {
let currentTime = 0;
for (let i = 0; i <
this._trackPlayers.length; i++) {
let track = this._trackPlayers[i];
if
(i == 0) currentTime = track.player.currentTime;
track.player.pause();
// ensure tracks pause
and remain paused at the same time
track.player.seekTo(currentTime);
}
}
通过使用第一首轨道的currentTime作为速度设置器,我们在我们的混音中暂停每首轨道,并通过在暂停后立即跳转到相同的currentTime来确保它们保持完全相同的时间。这有助于确保当我们恢复播放时,它们都从相同的时间点开始。让我们在下一节中构建自定义穿梭滑块时使用所有这些功能。
创建自定义 ShuttleSliderComponent
如果没有来回穿梭混音的能力,我们就无法拥有多轨录音室体验!让我们加大力度,增强Slider的功能,通过结合 NativeScript 和 Angular 为我们提供的所有最佳选项。在这个过程中,我们的播放器控件将开始变得非常有用。
从高层次开始,打开app/modules/player/components/player-controls/player-controls.component.html并将其替换为以下内容:
<StackLayout row="1" col="0" class="controls">
<shuttle-slider [currentTime]
="currentTime"
[duration]="duration"></shuttle-slider>
<Button
[text]="playStatus" (tap)="togglePlay()"
class="btn btn-primary w-100"></Button>
</StackLayout>
我们正在用StackLayout替换GridLayout,以改变我们的播放器控件布局。让我们使用一个全宽的滑块堆叠在我们的播放/暂停按钮上方。我们追求的效果类似于 iPhone 上的 Apple Music 应用,其中滑块是全宽的,下面显示了当前时间和持续时间。现在,让我们构建我们的自定义shuttle-slider组件,并创建app/modules/player/components/player-controls/shuttle-slider.component.html,内容如下:
<GridLayout #sliderArea rows="auto, auto" columns="auto,*,auto"
class="slider-area">
<Slider
#slider slim-slider minValue="0" [maxValue]="duration"
colSpan="3" class="slider"></Slider>
<Label #currentTimeDisplay text="00:00" class="h4 m-x-5" row="1" col="0">
</Label>
<Label
[text]="durationDisplay" class="h4 text-right m-x-5"
row="1" col="2"></Label>
</GridLayout>
这里将会变得非常有趣。我们将结合有用的 Angular 绑定,例如这些绑定:[maxValue]="duration" 和 [text]="durationDisplay"。然而,对于我们的其他可用性连接,我们希望有更多精细粒度和手动控制。例如,我们的包含 GridLayout 通过 #sliderArea 将是用户能够触摸以穿梭前后而不是 Slider 组件本身的区域,我们将完全禁用用户与 Slider 本身的交互(因此,您看到了 slim-slider 指令属性)。滑块将仅用于其时间的视觉表示。
我们将这样做的原因是我们希望这种交互启动几个程序性操作:
-
在穿梭时暂停播放(如果正在播放)
-
在前后移动时更新当前时间显示标签
-
以受控方式启动轨道播放器实例的
seekTo命令;因此,减少多余的寻求命令 -
如果在尝试穿梭之前正在播放,则在不再穿梭时恢复播放
如果我们使用带有 Angular 绑定到 currentTime 的 Slider,并通过 currentTime$ 可观察对象来控制它,而这个可观察对象又是由我们与它的交互以及我们轨道播放器的状态来控制的,那么这些元素之间的耦合将过于紧密,无法实现我们需要的精细粒度控制。
我们即将要做的事情的美丽之处,作为如何灵活地将 Angular 与 NativeScript 结合的典范证明。让我们从 app/modules/player/components/player-controls/shuttle-slider.component.ts 中的交互编程开始;以下是我们将完全展示的完整设置,我们将在稍后分解:
// angular
import { Component, Input, ViewChild, ElementRef } from '@angular/core';
//
nativescript
import { GestureTypes } from 'ui/gestures';
import { View } from 'ui/core/view';
import { Label
} from 'ui/label';
import { Slider } from 'ui/slider';
import { Observable } from 'data/observable';
import
{ isIOS, screen } from 'platform';
// app
import { PlayerService } from '../../services';
@Component({
moduleId: module.id,
selector: 'shuttle-slider',
templateUrl: 'shuttle-
slider.component.html',
styles: [`
.slider-area {
margin: 10 10 0 10;
}
.slider {
padding:0;
margin:0 0 5 0;
height:5;
}
`]
})
export
class ShuttleSliderComponent {
@Input() currentTime: number;
@Input() duration: number;
@ViewChild('sliderArea') sliderArea: ElementRef;
@ViewChild('slider') slider: ElementRef;
@ViewChild('currentTimeDisplay') currentTimeDisplay: ElementRef;
public durationDisplay: string;
private _sliderArea: View;
private _currentTimeDisplay: Label;
private _slider: Slider;
private
_screenWidth: number;
private _seekDelay: number;
constructor(private playerService: PlayerService) {
}
ngOnChanges() {
if (typeof this.currentTime == 'number') {
this._updateSlider
(this.currentTime);
}
if (this.duration) {
this.durationDisplay =
this._timeDisplay(this.duration);
}
}
ngAfterViewInit() {
this._screenWidth =
screen.mainScreen.widthDIPs;
this._sliderArea = <View>this.sliderArea
.nativeElement;
this._slider = <Slider>this.slider.nativeElement;
this._currentTimeDisplay =
<Label>this.currentTimeDisplay
.nativeElement;
this._setupEventHandlers();
}
private _updateSlider(time: number) {
if (this._slider)
this._slider.value = time;
if (this._currentTimeDisplay)
this._currentTimeDisplay
.text =
this._timeDisplay(time);
}
private _setupEventHandlers() {
this._sliderArea.on
(GestureTypes.touch, (args: any) => {
this.playerService.seeking = true;
let x = args.getX();
if (x >= 0) {
let percent = x / this._screenWidth;
if (percent > .5) {
percent += .05;
}
let seekTo = this.duration * percent;
this._updateSlider
(seekTo);
if (this._seekDelay) clearTimeout(this._seekDelay);
this._seekDelay = setTimeout
(() => {
// android requires milliseconds
this.playerService
.seekTo
(isIOS ? seekTo : (seekTo*1000));
}, 600);
}
});
}
private
_timeDisplay(seconds: number): string {
let hr: any = Math.floor(seconds / 3600);
let min: any =
Math.floor((seconds - (hr * 3600))/60);
let sec: any = Math.floor(seconds - (hr * 3600)
- (min * 60));
if (min < 10) {
min = '0' + min;
}
if (sec < 10){
sec = '0' + sec;
}
return min + ':' + sec;
}
}
对于一个相当小的组件占用空间,这里正在进行着许多精彩的事情!让我们来分解一下。
让我们来看看那些属性装饰器,从 @Input 开始:
@Input() currentTime: number;
@Input() duration: number;
// allows these property bindings to flow into our view:
<shuttle-slider
[currentTime]
="currentTime"
[duration]="duration">
</shuttle-slider>
然后,我们有我们的 @ViewChild 引用:
@ViewChild('sliderArea') sliderArea: ElementRef;
@ViewChild('slider')
slider: ElementRef;
@ViewChild('currentTimeDisplay') currentTimeDisplay: ElementRef;
private _sliderArea: StackLayout;
private _currentTimeDisplay: Label;
private _slider: Slider;// provides us with references to these view components<StackLayout
#sliderArea class="slider-area">
<Slider #slider slim-slider
minValue="0 [maxValue]="duration" class="slider">
</Slider>
<GridLayout rows="auto"
columns="auto,*,auto"
class="m-x-5">
<Label #currentTimeDisplay text="00:00"
class="h4"
row="0" col="0"></Label>
<Label [text]="durationDisplay" class="h4 text-right"
row="0" col="2"></Label>
</GridLayout>
</StackLayout>
我们可以在我们的组件中访问这些 ElementRef 实例以编程方式与之交互;然而,不是立即。由于 ElementRef 是视图组件的代理包装器,其底层的 nativeElement(我们的实际 NativeScript 组件)只有在 Angular 的组件生命周期钩子 ngAfterViewInit 触发后才能访问。
在这里了解 Angular 的组件生命周期钩子:
angular.io/docs/ts/latest/guide/lifecycle-hooks.html.
因此,我们在这里为我们的实际 NativeScript 组件分配私有引用:
ngAfterViewInit() {
*this._screenWidth = screen.mainScreen.widthDIPs;*
this._sliderArea =
<StackLayout>this.sliderArea
.nativeElement;
this._slider = <Slider>this.slider.nativeElement;
this._currentTimeDisplay =
<Label>this.currentTimeDisplay
.nativeElement;
*this._setupEventHandlers();*
}
我们还利用这个机会,通过 platform 模块中的 screen 工具使用 密度无关像素(dip)单位来引用整个屏幕宽度。这将允许我们使用用户在 sliderArea StackLayout 上的手指位置进行一些计算,以调整 Slider 的实际值。然后我们调用设置我们基本事件处理程序。
使用我们的 _sliderArea 引用到的包含 StackLayout,我们添加一个 touch 触摸监听器来捕获用户对滑动区域的任何触摸操作:
private _setupEventHandlers() {
this._sliderArea.on(GestureTypes.touch, (args: any) => {
*this.playerService.seeking = true; // TODO*
let x = args.getX();
if (x >= 0) {
// x percentage of screen left to right
let percent = x / this._screenWidth;
if (percent > .5)
{
percent += .05; // non-precise adjustment
}
let seekTo = this.duration * percent;
this._updateSlider(seekTo);
if (this._seekDelay) clearTimeout(this._seekDelay);
this._seekDelay = setTimeout(() => {
// android requires milliseconds
this.playerService.seekTo(
isIOS ? seekTo : (seekTo*1000));
}, 600);
}
});
}
这允许我们通过 args.getX() 获取他们手指的 X 位置。我们使用这个值除以用户设备屏幕宽度来确定从左到右的百分比。由于我们的计算并不完全精确,当用户通过 50% 标记时,我们会进行微调。这种可用性目前适用于我们的用例,但我们将保留以后改进的选项;然而,现在这完全没问题。
然后,我们将持续时间乘以这个百分比来获取我们的 seekTo 标记,以更新 Slider 的值,以便使用手动精度获取即时的 UI 更新:
private _updateSlider(time: number) {
if (this._slider) this._slider.value = time;
if
(this._currentTimeDisplay)
this._currentTimeDisplay.text = this._timeDisplay(time);
}
在这里,我们实际上是在直接使用 NativeScript 组件,而不涉及 Angular 的绑定或 NgZone。在需要精细粒度和性能控制 UI 的情况下,这可以非常方便。由于我们希望 Slider 轨道随着用户的指尖立即移动,以及时间显示标签以标准音乐时间码格式化以表示他们交互时的实时时间,我们直接在适当的时间设置它们的值。
然后我们使用搜索延迟超时以确保我们不会向多轨播放器发出额外的搜索命令。用户的每次移动都会进一步延迟实际发出搜索命令,直到他们将其放置在想要的位置。我们还使用我们的 isIOS 布尔值根据每个平台音频播放器的需要适当地转换时间(iOS 为秒,Android 为毫秒)。
最有趣的可能就是我们的 ngOnChanges 生命周期钩子:
ngOnChanges() {
if (typeof this.currentTime == 'number') {
this._updateSlider(this.currentTime);
}
if (this.duration) {
this.durationDisplay = this._timeDisplay(this.duration);
}
}
当 Angular 检测到组件(或指令)的 输入属性 发生变化时,它会调用其 ngOnChanges() 方法。
这是一种让 ShuttleSliderComponent 对其 Input 属性变化、currentTime 和 duration 做出反应的绝佳方式。在这里,我们只是在它有效数字触发时,通过 this._updateSlider(this.currentTime) 手动更新我们的滑块和当前时间显示标签。最后,我们还确保更新我们的持续时间显示标签。此方法将在 PlayerService 的 currentTime$ 可观察对象在存在活动订阅的情况下每秒触发时调用。太棒了!哦,别忘了将 ShuttleSliderComponent 添加到 COMPONENTS 数组中,以便与模块一起包含。
现在,我们需要实际实现这一点:
*this.playerService.seeking = true; // TODO*
我们将使用一些更巧妙的可观察操作技巧来处理我们的搜索状态。让我们打开 app/modules/player/services/player.service.ts 中的 PlayerService 并添加以下内容:
...
export class PlayerService {
...
// internal state
private _playing: boolean;
private _seeking: boolean;
private _seekPaused: boolean;
private _seekTimeout: number;
...
constructor(private ngZone: NgZone) {
this.currentTime$ =
Observable.interval(1000)
.switchMap(_ => {
if (this._seeking)
{
return Observable.never();
} else if
(this._longestTrack) {
return Observable.of(
this._standardizeTime(
this._longestTrack.player.currentTime));
} else {
return Observable.of(0);
}
});
}
...
public set seeking(value: boolean) {
this._seeking =
value;
if (this._playing && !this._seekPaused) {
// pause
while seeking
this._seekPaused = true;
this.pause();
}
if (this._seekTimeout) clearTimeout(this._seekTimeout);
this._seekTimeout = setTimeout(() => {
this._seeking = false;
if
(this._seekPaused) {
// resume play
this._seekPaused =
false;
this.play();
}
},
1000);
}
public seekTo(time: number) {
for
(let track of this._trackPlayers) {
track.player.seekTo(time);
}
}
...
我们正在引入三个新的可观察操作符 switchMap、never 和 of,我们需要确保它们也被导入到我们的 app/operators.ts 文件中:
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/switchMap';
import
'rxjs/add/observable/interval';
import 'rxjs/add/observable/never';
import
'rxjs/add/observable/of';
switchMap允许我们的可观察对象根据几个条件切换流,帮助我们管理是否需要currentTime发出更新。显然,在搜索时,我们不需要对currentTime的变化做出反应。因此,当this._seeking为真时,我们将我们的可观察对象流切换到Observable.never(),确保观察者永远不会被调用。
在我们的seekingsetter 中,我们调整内部状态引用(this._seeking),如果它当前是this._playing并且尚未因为搜索而被暂停(因此,!this._seekPaused),我们立即暂停播放(仅一次)。然后,如果搜索开始时正在播放,我们设置另一个超时,在seekTo从组件触发后额外延迟 400 毫秒恢复播放(因此,对this._seekPaused的检查)。
这样,用户可以自由地用手指在我们的穿梭滑块上滑动,他们想滑多远就滑多远,想滑多快就滑多快。他们将在实时中看到Slider轨道的 UI 更新以及当前时间显示标签;在此期间,我们避免发送不必要的seekTo命令到我们的多轨播放器,直到它们停下来,从而提供真正出色的用户体验。
为 iOS 和 Android 原生 API 修改创建 SlimSliderDirective
我们仍然需要为Slider上的slim-slider属性创建一个指令:
<Slider #slider slim-slider minValue="0" [maxValue]="duration"
class="slider"></Slider>
我们将创建特定于平台的指令,因为我们将在 iOS 和 Android 上调用滑块的真正原生 API 来禁用用户交互并隐藏滑块,以实现无缝的外观。
对于 iOS,创建app/modules/player/directives/slider.directive.ios.ts,内容如下:
import { Directive, ElementRef } from '@angular/core';
@Directive({
selector: '[slim-
slider]'
})
export class SlimSliderDirective {
constructor(private el: ElementRef) { }
ngOnInit() {
let uiSlider = <UISlider>this.el.nativeElement.ios;
uiSlider.userInteractionEnabled =
false;
uiSlider.setThumbImageForState(
UIImage.new(), UIControlState.Normal);
}
}
我们通过Slider组件本身的iosgetter 访问底层的原生 iOS UISlider实例。我们使用 Apple 的 API 参考文档(developer.apple.com/reference/uikit/uislider)来定位适当的 API,通过userInteractionEnabled标志禁用交互,并通过将空白设置为滑块来隐藏滑块。完美。
对于 Android,创建app/modules/player/directives/slider.directive.android.ts,内容如下:
import { Directive, ElementRef } from '@angular/core';
@Directive({
selector: '[slim-
slider]'
})
export class SlimSliderDirective {
constructor(private el: ElementRef) { }
ngOnInit() {
let seekBar = <android.widget.SeekBar>this.el
.nativeElement.android;
seekBar.setOnTouchListener(
new android.view.View.OnTouchListener({
onTouch(view, event) {
return true;
}
})
);
seekBar.getThumb().mutate().setAlpha(0);
}
}
我们通过Slider组件上的androidgetter 访问原生android.widget.SeekBar实例。我们使用 Android 的 API 参考文档(developer.android.com/reference/android/widget/SeekBar.html)来定位 SeekBar 的 API,通过重写OnTouchListener来禁用用户交互,并通过将 Drawable alpha 设置为 0 来隐藏滑块。
现在,创建app/modules/player/directives/slider.directive.d.ts:
export declare class SlimSliderDirective { }
这将允许我们导入并使用我们的SlimSlider类作为标准的 ES6 模块;创建app/modules/player/directives/index.ts:
import { SlimSliderDirective } from './slider.directive';
export const DIRECTIVES: any[] = [
SlimSliderDirective
];
在运行时,NativeScript 只会将适当的平台特定文件构建到目标平台中,完全排除不适用代码。这是在代码库中创建平台特定功能的一种非常强大的方式。
最后,让我们确保我们的指令已声明在PlayerModule中,位于app/modules/player/player.module.ts,以下是一些更改:
...
import { DIRECTIVES } from './directives';
...
@NgModule({
...
declarations: [
...COMPONENTS,
...DIRECTIVES
],
...
})
export class PlayerModule { }
我们现在应该在 iOS 上看到这一点,我们的播放暂停在 6 秒处:

对于 Android,情况如下:

你现在可以观察到以下情况:
-
所有三个轨道以完美的混音一起播放
-
不论是否正在播放,都可以通过滑块切换播放
-
播放/暂停切换
-
当播放结束时,我们的控件会正确重置
并且这一切在 iOS 和 Android 上都能正常工作。毫无疑问,这是一项了不起的成就。
摘要
现在,我们已经完全沉浸在 NativeScript 的丰富世界中,因为我们已经介绍了插件集成以及 iOS 和 Android 上对原生 API 的直接访问。更不用说,我们还有一个非常棒的具有完整播放控制的多人轨播放器,包括在混音中切换!
包括其 RxJS 可观察性基础在内的 Angular 的激动人心的组合,现在开始真正闪耀,我们能够在需要的地方利用视图绑定,并使用强大的可观察性组合对服务事件流做出反应,同时仍然保留手动以细粒度控制我们的 UI 的能力。无论是我们的视图需要 Angular 指令来丰富其功能,还是通过原始 NativeScript 功能的手动触摸手势控制,我们现在都可以轻松掌握。
事实上,我们一直在构建一个完全本地的 iOS 和 Android 应用程序,这确实令人震惊。
在下一章中,我们将继续深入挖掘原生 API 和插件,随着我们将录音功能引入我们的应用程序,以满足多轨录音室移动应用程序的核心需求。
第八章:构建 Audio Recorder
录音是我们应用必须处理的性能最密集的操作。它也是拥有访问原生 API 将最有回报的功能之一。我们希望用户能够以尽可能低的延迟记录,以实现最高的音质保真度。此外,此录音可以选择性地在现有的同步播放的预录音轨混合之上发生。
由于我们应用开发的这一阶段将最深入地挖掘平台特定的原生 API,我们将我们的实现分为两个阶段。我们首先构建记录功能的 iOS 特定细节,然后是 Android。
在本章中,我们将涵盖以下内容:
-
使用一致的 API 为 iOS 和 Android 构建功能丰富的跨平台音频录音器
-
集成 iOS 框架库,例如完全用 Swift 构建的 AudioKit (
audiokit.io) -
如何将 Swift/Objective C 方法转换为 NativeScript
-
基于 native APIs 构建 custom reusable NativeScript 视图组件,以及如何在 Angular 中使用它们
-
配置一个可重用的 Angular 组件,该组件可以通过路由使用,也可以通过弹出模态打开
-
集成 Android Gradle 库
-
如何将 Java 方法转换为 NativeScript
-
使用 NativeScript 的 ListView 与多个项目模板
第一阶段 – 为 iOS 构建音频录音器
iOS 平台的音频功能令人印象深刻。一群才华横溢的音频爱好者和软件工程师合作,在平台的音频堆栈之上构建了一个开源框架层。这一世界级的工程努力是令人敬畏的 AudioKit (audiokit.io/),由无畏的 Aurelius Prochazka 领导,他是音频技术的真正先驱。
AudioKit 框架完全用 Swift 编写,当与 NativeScript 集成时,引入了一些有趣的表面级挑战。
挑战绕行 – 将基于 Swift 的库集成到 NativeScript 中
在撰写本文时,如果代码库通过所谓的桥接头正确地将类和类型暴露给 Objective-C,NativeScript 可以与 Swift 一起工作。你可以在这里了解更多关于桥接头的信息:developer.apple.com/library/content/documentation/Swift/Conceptual/BuildingCocoaApps/MixandMatch.html。 这个桥接头在 Swift 代码库编译成框架时自动生成。Swift 提供了丰富的语言特性,其中一些与 Objective C 没有直接关联。然而,在撰写本文时,可能需要考虑一些因素,尽管 NativeScript 最终可能会提供对最新 Swift 语言增强的全面支持。
AudioKit 利用 Swift 语言所能提供的最佳特性,包括增强的 enum 功能。你可以在这里了解更多关于 Swift 语言中扩展的 enum 功能:
尤其是在文档中有这样的描述:“它们采用了许多传统上仅由类支持的特性,例如计算属性,以提供有关枚举当前值的额外信息,以及实例方法,以提供与枚举表示的值相关的功能。”
这种 enum 在 Objective C 中是陌生的,因此不能在桥接头中提供。任何使用 Swift 的异构 enum 的代码在编译时生成桥接头时将被简单地忽略,导致 Objective C 无法与这些代码部分交互。这意味着你将无法在 NativeScript 中使用 Swift 代码库中的方法,这些方法直接使用这些增强结构(在撰写本文时)。
为了解决这个问题,我们将分叉 AudioKit 框架,并将 AKAudioFile 扩展文件中使用的异构枚举扁平化,这些扩展文件提供了一个强大且方便的导出方法,我们希望用它来保存我们的录音音频文件。我们需要修改的异构 enum 看起来是这样的 (github.com/audiokit/AudioKit/blob/master/AudioKit/Common/Internals/Audio%20File/AKAudioFile%2BProcessingAsynchronously.swift):
// From AudioKit's Swift 3.x codebase
public enum ExportFormat {
case wav
case aif
case mp4
case m4a
case caf
fileprivate var UTI: CFString {
switch self {
case .wav:
return AVFileTypeWAVE as CFString
case .aif:
return AVFileTypeAIFF as CFString
case .mp4:
return AVFileTypeAppleM4A as CFString
case .m4a:
return AVFileTypeAppleM4A as CFString
case .caf:
return AVFileTypeCoreAudioFormat as CFString
}
}
static var supportedFileExtensions: [String] {
return ["wav", "aif", "mp4", "m4a", "caf"]
}
}
这与您可能熟悉的任何枚举不同;如您所见,它除了枚举拥有的属性外还包括其他属性。当此代码编译并生成桥接头与 Objective-C 混合或匹配时,桥接头将排除使用此构造的任何代码。我们将将其扁平化,如下所示:
public enum ExportFormat: Int {
case wav
case aif
case mp4
case m4a
case caf
}
static public func stringUTI(type: ExportFormat) -> CFString {
switch type {
case .wav:
return AVFileTypeWAVE as CFString
case .aif:
return AVFileTypeAIFF as CFString
case .mp4:
return AVFileTypeAppleM4A as CFString
case .m4a:
return AVFileTypeAppleM4A as CFString
case .caf:
return AVFileTypeCoreAudioFormat as CFString
}
}
static public var supportedFileExtensions: [String] {
return ["wav", "aif", "mp4", "m4a", "caf"]
}
我们将调整AKAudioFile扩展的部分,以使用我们的扁平化属性。这将允许我们手动构建可以在应用中使用的AudioKit.framework,暴露我们想要使用的方法:exportAsynchronously。
我们不会详细介绍手动构建AudioKit.framework的细节,因为它在这里有很好的文档:github.com/audiokit/AudioKit/blob/master/Frameworks/INSTALL.md#building-universal-frameworks-from-scratch。使用我们自定义构建的框架,我们现在已准备好将其集成到我们的应用中。
将自定义构建的 iOS 框架集成到 NativeScript
我们现在可以创建一个内部插件,将这个 iOS 框架集成到我们的应用中。将我们构建的定制AudioKit.framework放入我们应用的根目录下创建一个nativescript-audiokit目录。然后我们在其中添加一个platforms/ios文件夹,将框架放入。这将让 NativeScript 知道如何将这些 iOS 特定文件构建到应用中。由于我们希望这个内部插件像任何标准 npm 插件一样被处理,我们还将直接在nativescript-audiokit文件夹中添加package.json文件,内容如下:
{
"name": "nativescript-audiokit",
"version": "1.0.0",
"nativescript": {
"platforms": {
"ios": "3.0.0"
}
}
}
我们现在将使用以下命令将其添加到我们的应用中(NativeScript 将首先在本地查找,然后找到nativescript-audiokit插件):
tns plugin add nativescript-audiokit
这将正确地将自定义构建的 iOS 框架添加到我们的应用中。
然而,我们还需要两个非常重要的项目:
- 由于 AudioKit 是基于 Swift 的框架,我们希望确保我们的应用包含适当的支持 Swift 库。添加一个新文件,
nativescript-audiokit/platforms/ios/build.xcconfig:
EMBEDDED_CONTENT_CONTAINS_SWIFT = true
-
由于我们将使用用户的麦克风,我们希望确保在应用属性列表中指示麦克风的使用。我们也将借此机会添加两个额外的属性设置来增强我们应用的能力。因此,总共我们将添加三个属性键,用于以下目的:
-
让设备知道我们的应用需要访问麦克风,并确保在首次访问时请求用户的权限。
-
如果应用被放置到后台,继续播放音频。
-
当手机连接到计算机时,提供查看应用
documents文件夹的能力。这将允许您通过应用文档直接在 iTunes 中查看记录的文件。这对于集成到桌面音频编辑软件可能很有用。
-
添加一个新文件,nativescript-audiokit/platforms/ios/Info.plist,内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>Requires access to microphone.</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
</dict>
</plist>
这里有一个截图,更好地说明了我们应用中内部插件的架构:

现在,当 NativeScript 构建 iOS 应用时,它将确保 AudioKit.framework 被包含为一个库,并将 build.xcconfig 和 Info.plist 的内容合并到我们的应用配置中。每次我们更改这个内部插件文件夹(nativescript-audiokit)内的文件时,我们都想确保我们的应用能够获取这些更改。为此,我们可以简单地移除并重新添加插件,所以现在让我们这么做:
tns plugin remove nativescript-audiokit
tns plugin add nativescript-audiokit
现在,我们准备使用 iOS 的 AudioKit API 来构建我们的音频录制器。
设置原生 API 类型检查并生成 AudioKit TypeScript 定义
我们首先想做的事情是安装 tns-platform-declarations:
npm i tns-platform-declarations --save-dev
现在,我们在项目的根目录中创建一个名为 references.d.ts 的新文件,其内容如下:
/// <reference path="./node_modules/tns-platform-declarations/ios.d.ts" />
/// <reference path="./node_modules/tns-platform-declarations/android.d.ts" />
这为我们提供了对 iOS 和 Android API 的完整类型检查和智能感知支持。
现在,我们想要为 AudioKit 框架本身生成类型定义。我们可以执行以下命令来生成包含的 AudioKit.framework 的类型定义:
TNS_TYPESCRIPT_DECLARATIONS_PATH="$(pwd)/typings" tns build ios
我们正在设置环境变量 TNS_TYPESCRIPT_DECLARATIONS_PATH 为当前工作目录(pwd),并带有 typings 文件夹前缀。当 NativeScript 创建 iOS 构建时,它还将为我们的应用可用的所有原生 API 生成类型定义文件,包括第三方库。我们现在将看到在项目中出现一个 typings 文件夹,其中包含两个文件夹:i386 和 x86_64。一个是用于模拟器架构的,另一个是用于设备的。两者都将包含相同的内容,所以我们可以只关注其中一个。打开 i386 文件夹,你会找到一个 objc!AudioKit.d.ts 文件。
我们只想使用那个文件,所以将其移动到 typings 文件夹的根目录:typings/objc!AudioKit.d.ts。然后我们可以删除 i386 和 x86_64 文件夹,因为我们不再需要它们(其他 API 定义文件通过 tns-platform-declarations 提供)。我们只是生成这些类型定义来获取 AudioKit 库的类型定义。这是一次性的事情,为了方便与这个本地库集成,所以你可以安全地将这个自定义 typings 文件夹添加到源控制中。
请再次检查 tsconfig.json 并确保已启用 "skipLibCheck": true 选项。我们现在可以修改我们的 references.d.ts 文件,以包含 AudioKit 库的附加类型:
/// <reference path="./node_modules/tns-platform-declarations/ios.d.ts" />
/// <reference path="./node_modules/tns-platform-declarations/android.d.ts" />
/// <reference path="./typings/objc!AudioKit.d.ts" />
我们的项目结构现在应该看起来像这样:

使用 AudioKit 构建录制器
我们将首先创建一个模型,围绕我们与 AudioKit 记录 API 的交互。你可以直接从你的 Angular 组件或服务开始直接编写针对这些 API 的代码,但由于我们希望提供跨 iOS 和 Android 的一致 API,所以有一个更智能的方式来设计这个架构。相反,我们将抽象出一个简单的 API,可以在两个平台上使用,它将在底层调用正确的原生实现。
这里将会有很多与 AudioKit 相关的有趣细节,但请创建 app/modules/recorder/models/record.model.ts,如下所示,我们将在稍后解释一些细节:
之后,我们将为此模型添加 .ios.ts 后缀,因为它将包含 iOS 特定的实现细节。然而,在第一阶段,我们将直接使用该模型(省略平台后缀)来开发我们的 iOS 录音器。
import { Observable } from 'data/observable';
import { knownFolders } from 'file-system';
// all available states for the recorder
export enum RecordState {
readyToRecord,
recording,
readyToPlay,
playing,
saved,
finish
}
// available events
export interface IRecordEvents {
stateChange: string;
}
// for use when saving files
const documentsFilePath = function(filename: string) {
return `${knownFolders.documents().path}/${filename}`;
}
export class RecordModel extends Observable {
// available events to listen to
private _events: IRecordEvents;
// control nodes
private _mic: AKMicrophone;
private _micBooster: AKBooster;
private _recorder: AKNodeRecorder;
// mixers
private _micMixer: AKMixer;
private _mainMixer: AKMixer;
// state
private _state: number = RecordState.readyToRecord;
// the final saved path to use
private _savedFilePath: string;
constructor() {
super();
// setup the event names
this._setupEvents();
// setup recording environment
// clean any tmp files from previous recording sessions
(<any>AVAudioFile).cleanTempDirectory();
// audio setup
AKSettings.setBufferLength(BufferLength.Medium);
try {
// ensure audio session is PlayAndRecord
// allows mixing with other tracks while recording
AKSettings.setSessionWithCategoryOptionsError(
SessionCategory.PlayAndRecord,
AVAudioSessionCategoryOptions.DefaultToSpeaker
);
} catch (err) {
console.log('AKSettings error:', err);
}
// setup mic with it's own mixer
this._mic = AKMicrophone.alloc().init();
this._micMixer = AKMixer.alloc().init(null);
this._micMixer.connect(this._mic);
// Helps provide mic monitoring when headphones are plugged in
this._micBooster = AKBooster.alloc().initGain(<any>this._micMixer, 0);
try {
// recorder takes the micMixer input node
this._recorder = AKNodeRecorder.alloc()
.initWithNodeFileError(<any>this._micMixer, null);
} catch (err) {
console.log('AKNodeRecorder init error:', err);
}
// overall main mixer uses micBooster
this._mainMixer = AKMixer.alloc().init(null);
this._mainMixer.connect(this._micBooster);
// single output set to mainMixer
AudioKit.setOutput(<any>this._mainMixer);
// start the engine!
AudioKit.start();
}
public get events(): IRecordEvents {
return this._events;
}
public get mic(): AKMicrophone {
return this._mic;
}
public get recorder(): AKNodeRecorder {
return this._recorder;
}
public get audioFilePath(): string {
if (this._recorder) {
return this._recorder.audioFile.url.absoluteString;
}
return '';
}
public get state(): number {
return this._state;
}
public set state(value: number) {
this._state = value;
// always emit state changes
this._emitEvent(this._events.stateChange, this._state);
}
public get savedFilePath() {
return this._savedFilePath;
}
public set savedFilePath(value: string) {
this._savedFilePath = value;
if (this._savedFilePath)
this.state = RecordState.saved;
}
public toggleRecord() {
if (this._state !== RecordState.recording) {
// just force ready to record
// when coming from any state other than recording
this.state = RecordState.readyToRecord;
if (this._recorder) {
try {
// resetting (clear previous recordings)
this._recorder.resetAndReturnError();
} catch (err) {
console.log('Recorder reset error:', err);
}
}
}
switch (this._state) {
case RecordState.readyToRecord:
if (AKSettings.headPhonesPlugged) {
// Microphone monitoring when headphones plugged
this._micBooster.gain = 1;
}
try {
this._recorder.recordAndReturnError();
this.state = RecordState.recording;
} catch (err) {
console.log('Recording failed:', err);
}
break;
case RecordState.recording:
this.state = RecordState.readyToPlay;
this._recorder.stop();
// Microphone monitoring muted when playing back
this._micBooster.gain = 0;
break;
}
}
public togglePlay() {
if (this._state === RecordState.readyToPlay) {
this.state = RecordState.playing;
} else {
this.stopPlayback();
}
}
public stopPlayback() {
if (this.state !== RecordState.recording) {
this.state = RecordState.readyToPlay;
}
}
public save() {
let fileName = `recording-${Date.now()}.m4a`;
this._recorder.audioFile
.exportAsynchronouslyWithNameBaseDirExportFormatFromSampleToSampleCallback(
fileName, BaseDirectory.Documents, ExportFormat.M4a, null, null,
(af: AKAudioFile, err: NSError) => {
this.savedFilePath = documentsFilePath(fileName);
});
}
public finish() {
this.state = RecordState.finish;
}
private _emitEvent(eventName: string, data?: any) {
let event = {
eventName,
data,
object: this
};
this.notify(event);
}
private _setupEvents() {
this._events = {
stateChange: 'stateChange'
};
}
}
RecordModel 将表现得有点像状态机,它可能处于以下状态之一:
-
readyToRecord: 默认起始状态。必须处于此状态才能进入录音状态。 -
recording: 工作室安静!正在录音中。 -
readyToPlay: 用户停止了录音,现在有一个可以与混音一起播放的录音文件。 -
playing: 用户正在播放带有混音的录音文件。 -
saved: 用户选择保存录音,这将启动保存新曲目与活动组合的操作。 -
finish: 保存操作完成后,记录器应关闭。
我们接着定义记录器将通过 IRecordEvents 提供的事件形状。在这种情况下,我们将有一个单独的事件,stateChange,当状态改变时会通知任何监听者(参见状态设置器)。我们的模型将扩展 NativeScript 的 Observable 类(因此,RecordModel extends Observable),这将为我们提供通知 API 来分发我们的事件。
我们接着设置了对我们将使用的各种 AudioKit 组件的多个引用。大部分设计直接来自这个 AudioKit 录音示例:github.com/audiokit/AudioKit/blob/master/Examples/iOS/RecorderDemo/RecorderDemo/ViewController.swift。我们甚至使用了相同的状态枚举设置(添加了一些额外的设置)。在他们示例中,使用了 AudioKit 的 AKAudioPlayer 进行播放;但,在我们的设计中,我们将加载我们的录音文件到我们的多轨播放器设计中以进行播放。我们可以在 iOS 的 TrackPlayerModel 中工作 AKAudioPlayer;但,TNSPlayer(来自 nativescript-audio 插件)是跨平台兼容的,并且可以正常工作。我们将在稍后详细说明如何将这些新录音文件加载到我们的设计中,但通知监听者记录器的状态将为我们提供处理所有这些所需的所有灵活性。
你可能会想知道为什么我们进行类型转换:
(<any>AVAudioFile).cleanTempDirectory();
好问题。AudioKit 为 Core Foundation 类如 AVAudioFile 提供扩展。这些在 Objective-C 中被称为 Categories:developer.apple.com/library/content/documentation/General/Conceptual/DevPedia-CocoaCore/Category.html;然而,在 Swift 中,它们被称为 Extensions:developer.apple.com/library/content/documentation/Swift/Conceptual/Swift_Programming_Language/Extensions.html。
如果你还记得,我们为 AudioKit 生成 TypeScript 定义;但我们只保留了 objc!AudioKit.d.ts 文件以供参考。如果我们查看基础定义,我们会看到对 AVAudioFile 的扩展。然而,由于我们没有保留这些定义,而是依赖于默认的 tns-platform-declarations 定义,这个 Extension 对我们的 TypeScript 编译器来说是未知的,所以我们简单地进行了类型转换,因为我们知道 AudioKit 提供了这个。
同样重要的是 RecordModel 将音频会话设置为 PlayAndRecord,因为这将允许我们在播放混音的同时进行录音:
AKSettings.setSessionWithCategoryOptionsError(
SessionCategory.PlayAndRecord,
AVAudioSessionCategoryOptions.DefaultToSpeaker
);
你可能也好奇为什么一些类使用 init() 而另一些使用 init(null):
this._mic = AKMicrophone.alloc().init();
this._micMixer = AKMixer.alloc().init(null);
this._micMixer.connect(this._mic);
AudioKit 类的一些初始化器接受一个可选参数,例如,AKMixer 接受一个可选的 NSArray 参数,用于连接 AVAudioNode。然而,我们的 TypeScript 定义将这些参数定义为必需的,所以我们只是传递 null 给这个参数,并直接使用 connect 节点 API。
如何将 Swift/ObjC 方法转换为 NativeScript
来自 RecordModel 的最后一个有趣点可能是 save 方法,它将我们的录音从应用的 tmp 目录导出到应用的 documents 文件夹,同时将其转换为更小的文件大小 .m4a 音频格式:
this._recorder.audioFile
.exportAsynchronouslyWithNameBaseDirExportFormatFromSampleToSampleCallback(
fileName, BaseDirectory.Documents, ExportFormat.M4a, null, null,
(af: AKAudioFile, err: NSError) => {
this.savedFilePath = documentsFilePath(fileName);
});
方法名很长,对吧?是的,确实如此;一些 Swift/ObjC 参数化方法名在合并时会变得非常长。Swift 中定义的特定方法如下:
exportAsynchronously(name:baseDir:exportFormat:fromSample:toSample:callback:)
// converted to NativeScript:
exportAsynchronouslyWithNameBaseDirExportFormatFromSampleToSampleCallback
由于我们为 AudioKit 生成 TypeScript 定义,它们在这里帮了我们大忙。然而,有时你并没有这样的便利。在 Swift/ObjC 方法中,各种参数在方法名开始和参数名开始之间添加 With 时会合并在一起,并且在合并时首字母大写。
为原生音频波形显示构建自定义可重用 NativeScript 视图
我们不会为我们的波形显示创建 Angular 组件,而是创建一个自定义的 NativeScript 视图组件,该组件可以访问原生 API,然后我们可以将其注册到 Angular 中,以便在我们的组件中使用。这样做的原因是由于 NativeScript 强大的view基类,我们可以扩展它,它为使用底层的原生 API 提供了良好的 API。这个波形显示将与我们刚刚创建的RecordModel协同工作,以实现设备麦克风的实时波形反馈显示。这也将非常棒,可以将这个波形显示作为我们的曲目列表上的静态音频文件波形渲染,作为我们主要合成视图的备用视图。AudioKit 提供了类和 API 来完成所有这些。
由于我们希望能够在我们的应用程序的任何地方使用它,我们将在共享模块目录中创建它;然而,请注意,它可以存在于任何地方。在这里这并不那么重要,因为这不是一个需要在NgModule中声明的 Angular 组件。此外,由于这将专门与原生 API 一起工作,让我们在新的native文件夹中创建它,以可能容纳其他 NativeScript 特定的视图组件。
创建app/modules/shared/native/waveform.ts,内容如下,我们将在稍后解释:
import { View, Property } from 'ui/core/view';
import { Color } from 'color';
// Support live microphone display as well as static audio file renders
type WaveformType = 'mic' | 'file';
// define properties
export const plotColorProperty = new Property<Waveform, string>({ name: 'plotColor' });
export const plotTypeProperty = new Property<Waveform, string>({ name: 'plotType' });
export const fillProperty = new Property<Waveform, string>({ name: 'fill' });
export const mirrorProperty = new Property<Waveform, string>({ name: 'mirror' });
export interface IWaveformModel {
readonly target: any;
dispose(): void;
}
export class Waveform extends View {
private _model: IWaveformModel;
private _type: WaveformType;
public set type(value: WaveformType) {
this._type = value;
}
public get type() {
return this._type;
}
public set model(value: IWaveformModel) {
this._model = value;
}
public get model() {
return this._model;
}
createNativeView() {
switch (this.type) {
case 'mic':
this.nativeView = AKNodeOutputPlot.alloc()
.initFrameBufferSize(this._model.target, CGRectMake(0, 0, 0, 0), 1024);
break;
case 'file':
this.nativeView = EZAudioPlot.alloc().init();
break;
}
return this.nativeView;
}
initNativeView() {
if (this._type === 'file') {
// init file with the model's target
// target should be absolute url to path of file
let file = EZAudioFile.alloc()
.initWithURL(NSURL.fileURLWithPath(this._model.target));
// render the file's data as a waveform
let data = file.getWaveformData();
(<EZAudioPlot>this.nativeView)
.updateBufferWithBufferSize(data.buffers[0], data.bufferSize);
}
}
disposeNativeView() {
if (this.model && this.model.dispose) this.model.dispose();
}
plotColorProperty.setNative {
this.nativeView.color = new Color(value).ios;
}
fillProperty.setNative {
this.nativeView.shouldFill = value === 'true';
}
mirrorProperty.setNative {
this.nativeView.shouldMirror = value === 'true';
}
plotTypeProperty.setNative {
switch (value) {
case 'buffer':
this.nativeView.plotType = EZPlotType.Buffer;
break;
case 'rolling':
this.nativeView.plotType = EZPlotType.Rolling;
break;
}
}
}
// register properties with it's type
plotColorProperty.register(Waveform);
plotTypeProperty.register(Waveform);
fillProperty.register(Waveform);
mirrorProperty.register(Waveform);
我们正在使用 NativeScript 的Property类创建几个属性,这将大大方便通过视图绑定属性公开原生视图属性。使用Property类定义这些属性的一个便利之处在于,这些设置器只会在nativeView定义时被调用,避免了双重调用的属性设置器(一个是通过纯 JS 属性设置器,这是另一种选择,以及当底层的nativeView准备好时的另一个设置器)。
当你想通过自定义组件公开可以绑定到视图的本地视图属性时,为它们定义几个Property类,并引用你希望用于视图绑定的名称。
// define properties
export const plotColorProperty = new Property<Waveform, string>({ name: 'plotColor' });
export const plotTypeProperty = new Property<Waveform, string>({ name: 'plotType' });
export const fillProperty = new Property<Waveform, string>({ name: 'fill' });
export const mirrorProperty = new Property<Waveform, string>({ name: 'mirror' });
通过设置这些Property实例,我们现在可以在我们的视图组件类中这样做:
plotColorProperty.setNative {
this.nativeView.color = new Color(value).ios;
}
这只会在nativeView准备好时调用一次,这正是我们想要的。你可以在这个由核心团队成员 Alex Vakrilov 撰写的草稿中了解更多关于这个特定语法和记法的信息:
gist.github.com/vakrilov/ca888a1ea410f4ea7a4c7b2035e06b07#registering-the-property.
然后,在我们的类定义之后,我们在Property实例中注册了该类:
// register properties
plotColorProperty.register(Waveform);
plotTypeProperty.register(Waveform);
fillProperty.register(Waveform);
mirrorProperty.register(Waveform);
好的,解释到这里,让我们来看看这个实现的其他元素。
我们还引入了一个有用的接口,我们将在稍后将其应用于RecordModel:
export interface IWaveformModel {
readonly target: any;
dispose(): void;
}
这将帮助定义其他模型要实现的形状,确保它们符合波形显示期望的 API:
-
target:定义了与本地类一起使用的键输入。 -
dispose(): 每个模型都应该提供此方法来处理视图销毁时的任何清理操作。
这是自定义 NativeScript 3.x 视图生命周期调用执行顺序:
-
createNativeView():AnyNativeView;// 创建你的原生视图。 -
initNativeView():void;// 初始化你的原生视图。 -
disposeNativeView():void;// 清理你的原生视图。
从 NativeScript 的 View 类中重写的 createNativeView 方法可能是最有趣的:
createNativeView() {
switch (this.type) {
case 'mic':
this.nativeView = AKNodeOutputPlot.alloc()
.initFrameBufferSize(this._model.target, CGRectMake(0, 0, 0, 0), 1024);
break;
case 'file':
this.nativeView = EZAudioPlot.alloc().init();
break;
}
return this.nativeView;
}
在这里,我们允许 type 属性确定应该渲染哪种类型的 Waveform 显示。
在 mic 的情况下,我们利用 AudioKit 的 AKNodeOutputPlot(实际上在底层扩展了 EZAudioPlot)来使用我们模型的目标初始化一个波形(即 audioplot),这将最终成为我们的 RecordModel 的麦克风。
在 file 的情况下,我们直接利用 AudioKit 的 EZAudioPlot 创建一个表示音频文件的静态波形。
initNativeView 方法,也是从 NativeScript 的 View 类中重写的,在其生命周期中第二个被调用,并提供了一种初始化你的原生视图的方式。你可能会发现我们在这里再次调用了设置器。设置器在通过 XML 设置组件绑定并实例化类时首先被调用,即在 createNativeView 和 initNativeView 被调用之前。这就是为什么我们在私有引用中缓存值的原因。然而,我们还想让这些设置器在 Angular 的视图绑定(当动态变化时)中修改 nativeView,这就是为什么我们在设置器中也有 if (this.nativeView) 的原因,以便在可用时动态更改 nativeView。
当 View 被销毁时,会调用 disposeNativeView 方法(正如你所猜到的,这也是从 View 类的 {N} 中重写的),这是调用模型 dispose 方法的位置,如果有的话。
将自定义 NativeScript 视图集成到我们的 Angular 应用中
要在 Angular 中使用我们的 NativeScript Waveform 视图,我们首先需要注册它。你可以在根模块、根应用程序组件或另一个在启动时初始化的地方(通常不在懒加载模块中)做这件事。为了整洁,我们将在同一目录下的 SharedModule 中注册它,所以请在 app/modules/shared/shared.module.ts 中添加以下内容:
...
// register nativescript custom components
import { registerElement } from 'nativescript-angular/element-registry';
import { Waveform } from './native/waveform';
registerElement('Waveform', () => Waveform);
...
@NgModule({...
export class SharedModule {...
registerElement 方法允许我们定义在 Angular 组件中想要使用的组件名称,作为第一个参数,并接受一个解析函数,该函数应该返回用于它的 NativeScript View 类。
现在我们来使用我们新的 IWaveformModel 并清理一些 RecordModel 以便使用它,同时为创建我们的 Android 实现(即将推出!)做准备。让我们将 RecordModel 中的几件事情重构到一个公共文件中,以便在 iOS 和 Android(即将推出!)模型之间共享代码。
创建 app/modules/recorder/models/record-common.ts:
import { IWaveformModel } from '../../shared/native/waveform';
import { knownFolders } from 'file-system';
export enum RecordState {
readyToRecord,
recording,
readyToPlay,
playing,
saved,
finish
}
export interface IRecordEvents {
stateChange: string;
}
export interface IRecordModel extends IWaveformModel {
readonly events: IRecordEvents;
readonly recorder: any;
readonly audioFilePath: string;
state: number;
savedFilePath: string;
toggleRecord(): void;
togglePlay(startTime?: number, when?: number): void;
stopPlayback(): void;
save(): void;
finish(): void;
}
export const documentsFilePath = function(filename: string) {
return `${knownFolders.documents().path}/${filename}`;
}
这包含了RecordModel顶部的几乎所有内容,增加了IRecordModel接口,该接口扩展了IWaveformModel。由于我们已经构建了 iOS 实现,我们现在有一个模型形状,我们希望我们的 Android 实现遵循这个形状。将这个形状抽象成一个接口将为我们提供一个清晰的路径,当我们暂时转移到 Android 时可以遵循。
为了方便起见,我们还在app/modules/recorder/models/index.ts中为我们的模型创建了一个索引,这将也会暴露这个常用文件:
export * from './record-common.model';
export * from './record.model';
我们现在可以修改RecordModel以导入这些常用项,以及实现这个新的IRecordModel接口。由于这个新接口也扩展了IWaveformModel,它将立即告诉我们我们需要实现readonly target获取器和dispose()方法,这是与我们的波形视图一起使用所必需的:
import { Observable } from 'data/observable';
import { IRecordModel, IRecordEvents, RecordState, documentsFilePath } from './common';
export class RecordModel extends Observable implements IRecordModel {
...
public get target() {
return this._mic;
}
public dispose() {
AudioKit.stop();
// cleanup
this._mainMixer = null;
this._recorder = null;
this._micBooster = null;
this._micMixer = null;
this._mic = null;
// clean out tmp files
(<any>AVAudioFile).cleanTempDirectory();
}
...
RecordModel的target将是波形视图将使用的麦克风。我们的dispose方法将在进行参考清理的同时停止 AudioKit 引擎,并确保清理在录音过程中创建的任何临时文件。
创建录音视图布局
当用户在应用程序右上角点击录音时,它会提示用户进行身份验证,然后应用程序路由到录音视图。此外,如果组合中包含轨道,最好在模态弹出窗口中重用这个录音视图以显示,这样用户就不会感觉在录音时离开了组合。然而,当组合是新的时,通过路由导航到录音视图是完全可以的。我们将展示如何做到这一点,但首先让我们使用新的花哨的波形视图和我们的强大新RecordModel来设置我们的布局。
在app/modules/recorder/components/record.component.html中添加以下内容:
<ActionBar title="Record" icon="" class="action-bar">
<NavigationButton visibility="collapsed"></NavigationButton>
<ActionItem text="Cancel"
ios.systemIcon="1" android.systemIcon="ic_menu_back"
(tap)="cancel()"></ActionItem>
</ActionBar>
<FlexboxLayout class="record">
<GridLayout rows="auto" columns="auto,*,auto" class="p-10" *ngIf="isModal">
<Button text="Cancel" (tap)="cancel()"
row="0" col="0" class="c-white"></Button>
</GridLayout>
<Waveform class="waveform"
[model]="recorderService.model"
type="mic"
plotColor="yellow"
fill="false"
mirror="true"
plotType="buffer">
</Waveform>
<StackLayout class="p-5">
<FlexboxLayout class="controls">
<Button text="Rewind" class="btn text-center"
(tap)="recorderService.rewind()"
[isEnabled]="state == recordState.readyToPlay || state == recordState.playing">
</Button>
<Button [text]="recordBtn" class="btn text-center"
(tap)="recorderService.toggleRecord()"
[isEnabled]="state != recordState.playing"></Button>
<Button [text]="playBtn" class="btn text-center"
(tap)="recorderService.togglePlay()"
[isEnabled]="state == recordState.readyToPlay || state == recordState.playing">
</Button>
</FlexboxLayout>
<FlexboxLayout class="controls bottom"
[class.recording]="state == recordState.recording">
<Button text="Save" class="btn"
[class.save-ready]="state == recordState.readyToPlay"
[isEnabled]="state == recordState.readyToPlay"
(tap)="recorderService.save()"></Button>
</FlexboxLayout>
</StackLayout>
</FlexboxLayout>
我们使用FlexboxLayout是因为我们希望波形视图能够扩展以覆盖全部可用的垂直空间,只留下录音控制位于底部。FlexboxLayout是一个非常灵活的布局容器,它提供了与网页上 flexbox 模型相同的大多数 CSS 样式属性。
有趣的是,我们只在以模态形式显示时在GridLayout容器内显示一个取消按钮,因为我们需要一个关闭模态的方式。当视图通过模态打开时,会忽略ActionBar并且不显示。
当视图通过模态打开时,会忽略ActionBar,因此在模态中不显示。ActionBar仅在导航视图中显示。
此外,我们的ActionBar设置在这里相当有趣,并且是 iOS 和 Android 在 NativeScript 视图布局中差异最大的领域之一。在 iOS 上,NavigationButton有一个默认行为,会自动从堆栈中弹出视图并动画回到前一个视图。此外,iOS 上NavigationButton上的任何点击事件都被完全忽略,而在 Android 上,点击事件会在NavigationButton上触发。由于这个关键差异,我们想通过使用visibility="collapsed"来完全忽略ActionBar的NavigationButton,以确保它永远不会显示。相反,我们使用具有显式点击事件的ActionItem,以确保在两个平台上都能触发我们组件的正确逻辑。
iOS 和 Android 上的NavigationButton行为不同:
-
iOS:
NavigationButton忽略(点击)事件,当导航到视图时,此按钮默认出现。 -
Android:
NavigationButton(点击)事件被触发。
你可以在这里看到我们使用的波形(自定义 NativeScript)视图。由于它是一个对象,我们使用 Angular 的绑定语法来绑定模型。对于其他属性,我们直接指定它们的值,因为它们是原始值。然而,如果我们想通过用户交互动态更改这些值,我们也可以使用 Angular 的绑定语法。例如,我们可以显示一个有趣的颜色选择器,允许用户实时更改波形的颜色(plotColor)。
我们将为我们的记录组件提供特定的样式表,app/modules/recorder/components/record.component.css:
.record {
background-color: rgba(0,0,0,.5);
flex-direction: column;
justify-content: space-around;
align-items: stretch;
align-content: center;
}
.record .waveform {
background-color: transparent;
order: 1;
flex-grow: 1;
}
.controls {
width: 100%;
height: 200;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
align-items: center;
align-content: center;
}
.controls.bottom {
height: 90;
justify-content: flex-end;
}
.controls.bottom.recording {
background-color: #B0342D;
}
.controls.bottom .btn {
border-radius: 40;
height: 62;
padding: 2;
}
.controls.bottom .btn.save-ready {
background-color: #42B03D;
}
.controls .btn {
color: #fff;
}
.controls .btn[isEnabled=false] {
background-color: transparent;
color: #777;
}
如果你已经在网页上使用过 flexbox 模型,一些 CSS 属性可能看起来很熟悉。一个学习更多关于 flexbox 样式的极好和有趣资源是 Dave Geddes 的 Flexbox Zombies:flexboxzombies.com。
到目前为止,我们的 CSS 开始增长,我们可以用 SASS 清理很多东西。我们将很快做到这一点,所以请耐心等待!
现在,让我们看看app/modules/recorder/components/record.component.ts中的组件:
// angular
import { Component, OnInit, OnDestroy, Optional } from '@angular/core';
// libs
import { Subscription } from 'rxjs/Subscription';
// nativescript
import { RouterExtensions } from 'nativescript-angular/router';
import { ModalDialogParams } from 'nativescript-angular/directives/dialogs';
import { isIOS } from 'platform';
// app
import { RecordModel, RecordState } from '../models';
import { RecorderService } from '../services/recorder.service';
@Component({
moduleId: module.id,
selector: 'record',
templateUrl: 'record.component.html',
styleUrls: ['record.component.css']
})
export class RecordComponent implements OnInit, OnDestroy {
public isModal: boolean;
public recordBtn: string = 'Record';
public playBtn: string = 'Play';
public state: number;
public recordState: any = {};
private _sub: Subscription;
constructor(
private router: RouterExtensions,
@Optional() private params: ModalDialogParams,
public recorderService: RecorderService
) {
// prepare service for brand new recording
recorderService.setupNewRecording();
// use RecordState enum names as reference in view
for (let val in RecordState ) {
if (isNaN(parseInt(val))) {
this.recordState[val] = RecordState[val];
}
}
}
ngOnInit() {
if (this.params && this.params.context.isModal) {
this.isModal = true;
}
this._sub = this.recorderService.state$.subscribe((state: number) => {
this.state = state;
switch (state) {
case RecordState.readyToRecord:
case RecordState.readyToPlay:
this._resetState();
break;
case RecordState.playing:
this.playBtn = 'Pause';
break;
case RecordState.recording:
this.recordBtn = 'Stop';
break;
case RecordState.finish:
this._cleanup();
break;
}
});
}
ngOnDestroy() {
if (this._sub) this._sub.unsubscribe();
}
public cancel() {
this._cleanup();
}
private _cleanup() {
this.recorderService.cleanup();
invokeOnRunLoop(() => {
if (this.isModal) {
this._close();
} else {
this._back();
}
});
}
private _close() {
this.params.closeCallback();
}
private _back() {
this.router.back();
}
private _resetState() {
this.recordBtn = 'Record';
this.playBtn = 'Play';
}
}
/**
* Needed on iOS to prevent this potential exception:
* "This application is modifying the autolayout engine from a background thread after the engine was accessed from the main thread. This can lead to engine corruption and weird crashes."
*/
const invokeOnRunLoop = (function () {
if (isIOS) {
var runloop = CFRunLoopGetMain();
return function(func) {
CFRunLoopPerformBlock(runloop, kCFRunLoopDefaultMode, func);
CFRunLoopWakeUp(runloop);
}
} else {
return function (func) {
func();
}
}
}());
从该文件的底部开始,你可能想知道invokeOnRunLoop是什么东西. 这是一种确保线程安全的好方法,在这些情况下,线程可能会露出其丑陋的一面。在这种情况下,AudioKit 的引擎是从RecordModel中的 UI 线程启动的,因为 NativeScript 在 UI 线程上封装了原生调用。然而,当我们的记录视图关闭时(无论是从模态还是导航回退),一些后台线程被调用。用invokeOnRunLoop封装我们处理关闭此视图的方式有助于解决这个短暂的异常。这是如何在 NativeScript 中使用 iOS 的dispatch_async(dispatch_get_main_queue(…))的答案。
在文件中向上工作时,我们会遇到this.recorderService.state$.subscribe((state: number) => …)。在不久的将来,我们将实现一种方法来观察录制state$作为可观察对象,这样我们的视图就可以简单地对其状态变化做出反应。
另一个值得注意的点是,将RecordState枚举折叠到我们可以用作视图绑定的属性中,以与当前状态进行比较(this.state = state;)。
当组件被构建时,recorderService.setupNewRecording()将为每次此视图出现时准备我们的服务进行全新录制。
最后,请注意注入@Optional()private params: ModalDialogParams. 之前,我们提到过在模态弹出窗口中复用这个记录视图会很好。有趣的部分在于ModalDialogParams只有在组件以模态打开时才提供给组件。换句话说,Angular 的依赖注入不知道任何关于ModalDialogParams服务的信息,除非组件通过 NativeScript 的ModalService明确打开,所以这将破坏我们原本设置的将此组件路由到的能力,因为 Angular 的 DI 默认无法识别这样的提供者。为了允许这个组件继续作为路由组件工作,我们将简单地标记该参数为@Optional(),这样当不可用时会将其值设置为 null,而不是抛出依赖注入错误。
这将允许我们的组件被路由到,以及在一个模态中打开!全面复用!
为了通过路由有条件地导航到这个组件,或者以模态打开它,我们可以进行一些小的调整,记住RecorderModule是懒加载的,因此我们希望在打开它作为模态之前懒加载该模块。
打开app/modules/mixer/components/action-bar/action-bar.component.ts并做出以下修改:
// angular
import { Component, Input, Output, EventEmitter } from '@angular/core';
// nativescript
import { RouterExtensions } from 'nativescript-angular/router';
import { PlayerService } from '../../../player/services/player.service';
@Component({
moduleId: module.id,
selector: 'action-bar',
templateUrl: 'action-bar.component.html'
})
export class ActionBarComponent {
...
@Output() showRecordModal: EventEmitter<any> = new EventEmitter();
...
constructor(
private router: RouterExtensions,
private playerService: PlayerService
) { }
public record() {
if (this.playerService.composition &&
this.playerService.composition.tracks.length) {
// display recording UI as modal
this.showRecordModal.next();
} else {
// navigate to it
this.router.navigate(['/record']);
}
}
}
在这里,如果组合中包含轨道,我们使用带有组件装饰器Output的EventEmitter有条件地发出一个事件;否则,我们导航到记录视图。然后我们在视图模板中调整Button以使用该方法:
<ActionItem (tap)="record()" ios.position="right">
<Button text="Record" class="action-item"></Button>
</ActionItem>
我们现在可以修改app/modules/mixer/components/mixer.component.html以使用名称作为正常事件来使用Output:
<action-bar [title]="composition.name" (showRecordModal)="showRecordModal()"></action-bar>
<GridLayout rows="*, auto" columns="*" class="page">
<track-list [tracks]="composition.tracks" row="0" col="0"></track-list>
<player-controls [composition]="composition" row="1" col="0"></player-controls>
</GridLayout>
现在是时候进行有趣的部分了。由于我们希望能够打开任何组件在模态中,无论它是否是懒加载模块的一部分,让我们向DialogService添加一个新方法,可以在任何地方使用。
对app/modules/core/services/dialog.service.ts做出以下修改:
// angular
import { Injectable, NgModuleFactory, NgModuleFactoryLoader, ViewContainerRef, NgModuleRef } from '@angular/core';
// nativescript
import * as dialogs from 'ui/dialogs';
import { ModalDialogService } from 'nativescript-angular/directives/dialogs';
@Injectable()
export class DialogService {
constructor(
private moduleLoader: NgModuleFactoryLoader,
private modalService: ModalDialogService
) { }
public openModal(componentType: any, vcRef: ViewContainerRef, context?: any, modulePath?: string): Promise<any> {
return new Promise((resolve, reject) => {
const launchModal = (moduleRef?: NgModuleRef<any>) => {
this.modalService.showModal(componentType, {
moduleRef,
viewContainerRef: vcRef,
context
}).then(resolve, reject);
};
if (modulePath) {
// lazy load module which contains component to open in modal
this.moduleLoader.load(modulePath)
.then((module: NgModuleFactory<any>) => {
launchModal(module.create(vcRef.parentInjector));
});
} else {
// open component in modal known to be available without lazy loading
launchModal();
}
});
}
...
}
在这里,我们注入ModalDialogService和NgModuleFactoryLoader(实际上它是NSModuleFactoryLoader,因为如果你还记得,我们在第五章,路由和懒加载中提供了它)来按需加载任何模块以在模态中打开一个组件(在该懒加载的模块中声明)。它也适用于不需要懒加载的组件。换句话说,如果提供了路径,它将可选地懒加载任何模块,然后使用其NgModuleFactory获取模块引用,我们可以将其作为选项(通过moduleRef键)传递给this.modalService.showModal以打开在该懒加载模块中声明的组件。
这将在以后再次派上用场;然而,让我们现在就通过以下修改app/modules/mixer/components/mixer.component.ts来使用它:
// angular
import { Component, OnInit, OnDestroy, ViewContainerRef } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs/Subscription';
// app
import { DialogService } from '../../core/services/dialog.service';
import { MixerService } from '../services/mixer.service';
import { CompositionModel } from '../../shared/models';
import { RecordComponent } from '../../recorder/components/record.component';
@Component({
moduleId: module.id,
selector: 'mixer',
templateUrl: 'mixer.component.html'
})
export class MixerComponent implements OnInit, OnDestroy {
public composition: CompositionModel;
private _sub: Subscription;
constructor(
private route: ActivatedRoute,
private mixerService: MixerService,
private dialogService: DialogService,
private vcRef: ViewContainerRef
) { }
public showRecordModal() {
this.dialogService.openModal(
RecordComponent,
this.vcRef,
{ isModal: true },
'./modules/recorder/recorder.module#RecorderModule'
);
}
...
}
这将懒加载RecorderModule并在弹出模态中打开RecordComponent。酷!
使用 RecorderService 完成实现
现在,让我们使用RecorderService在app/modules/recorder/services/recorder.service.ts中完成这个实现:
// angular
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { Subscription } from 'rxjs/Subscription';
// app
import { DialogService } from '../../core/services/dialog.service';
import { RecordModel, RecordState } from '../models';
import { PlayerService } from '../../player/services/player.service';
import { TrackModel } from '../../shared/models/track.model';
@Injectable()
export class RecorderService {
public state$: Subject<number> = new Subject();
public model: RecordModel;
private _trackId: number;
private _sub: Subscription;
constructor(
private playerService: PlayerService,
private dialogService: DialogService
) { }
public setupNewRecording() {
this.model = new RecordModel();
this._trackId = undefined; // reset
this.model.on(this.model.events.stateChange, this._stateHandler.bind(this));
this._sub = this.playerService.complete$.subscribe(_ => {
this.model.stopPlayback();
});
}
public toggleRecord() {
this.model.toggleRecord();
}
public togglePlay() {
this.model.togglePlay();
}
public rewind() {
this.playerService.seekTo(0); // reset to 0
}
public save() {
this.model.save();
}
public cleanup() {
// unbind event listener
this.model.off(this.model.events.stateChange, this._stateHandler.bind(this));
this._sub.unsubscribe();
if (!this.model.savedFilePath) {
// user did not save recording, cleanup
this.playerService.removeTrack(this._trackId);
}
}
private _stateHandler(e) {
this.state$.next(e.data);
switch (e.data) {
case RecordState.readyToRecord:
this._stopMix();
break;
case RecordState.readyToPlay:
this._stopMix();
this._trackId = this.playerService
.updateCompositionTrack(this._trackId, this.model.audioFilePath);
break;
case RecordState.playing:
this._playMix();
break;
case RecordState.recording:
this._playMix(this._trackId);
break;
case RecordState.saved:
this._handleSaved();
break;
}
}
private _playMix(excludeTrackId?: number) {
if (!this.playerService.playing) {
// ensure mix plays
this.playerService.togglePlay(excludeTrackId);
}
}
private _stopMix() {
if (this.playerService.playing) {
// ensure mix stops
this.playerService.togglePlay();
}
// always reset to beginning
this.playerService.seekTo(0);
}
private _handleSaved() {
this._sub.unsubscribe();
this._stopMix();
this.playerService
.updateCompositionTrack(this._trackId, this.model.savedFilePath);
this.playerService.saveComposition();
this.model.finish();
}
}
我们录制服务的巅峰是其对模型状态变化的反应能力。这反过来又发出一个 Observable 流,通知观察者(我们的RecordComponent)状态变化,以及内部执行控制RecordModel和PlayerService所需的工作。我们设计的关键是我们希望我们的活动作品曲目在录制时在后台播放,这样我们就可以与混合一起播放。这个情况很重要:
case RecordState.readyToPlay:
this._stopMix();
this._trackId = this.playerService
.updateCompositionTrack(this._trackId, this.model.audioFilePath);
break;
当RecordModel处于readyToPlay状态时,我们知道已创建录制,现在可以播放。我们停止播放混合,获取录制文件的路径引用。然后,我们更新PlayerService以排队播放这条新曲目。我们稍后将展示更新的PlayerService,它负责将新文件添加到混合中,但它像我们混合中的其他一切一样添加一个新的TrackPlayer。然而,文件目前指向一个临时录制文件,因为我们不希望在用户决定正式提交并保存录制之前保存作品。录制会话将允许用户在录制不满意时重新录制。这就是为什么我们保留对_trackId的引用。如果录制已经添加到混合中,我们使用那个_trackId在重新录制时排除它,因为我们不希望听到我们正在重新录制的录制:
case RecordState.recording:
this._playMix(this._trackId);
break;
我们还用它来清理用户选择取消而不是保存的情况:
public cleanup() {
// unbind event listener
this.model.off(this.model.events.stateChange, this._stateHandler.bind(this));
this._sub.unsubscribe();
if (!this.model.savedFilePath) {
// user did not save recording, cleanup
this.playerService.removeTrack(this._trackId);
}
}
让我们看看我们需要对PlayerService进行哪些修改以支持我们的录制:
...
import { MixerService } from '../../mixer/services/mixer.service';
@Injectable()
export class PlayerService {
// default name of new tracks
private _defaultTrackName: string = 'New Track';
...
constructor(
private ngZone: NgZone,
private mixerService: MixerService
) { ... }
...
public saveComposition() {
this.mixerService.save(this.composition);
}
public togglePlay(excludeTrackId?: number) {
if (this._trackPlayers.length) {
this.playing = !this.playing;
if (this.playing) {
this.play(excludeTrackId);
} else {
this.pause();
}
}
}
public play(excludeTrackId?: number) {
// for iOS playback sync
let shortStartDelay = .01;
let now = 0;
for (let i = 0; i < this._trackPlayers.length; i++) {
let track = this._trackPlayers[i];
if (excludeTrackId !== track.trackId) {
if (isIOS) {
if (i == 0) now = track.player.ios.deviceCurrentTime;
(<any>track.player).playAtTime(now + shortStartDelay);
} else {
track.player.play();
}
}
}
}
public addTrack(track: ITrack): Promise<any> {
return new Promise((resolve, reject) => {
let trackPlayer = this._trackPlayers.find((p) => p.trackId === track.id);
if (!trackPlayer) {
// new track
trackPlayer = new TrackPlayerModel();
this._composition.tracks.push(track);
this._trackPlayers.push(trackPlayer);
} else {
// update track
this.updateTrack(track);
}
trackPlayer.load(
track,
this._trackComplete.bind(this),
this._trackError.bind(this)
).then(_ => {
// report longest duration as totalDuration
this._updateTotalDuration();
resolve();
});
})
} public updateCompositionTrack(trackId: number, filepath: string): number {
let track;
if (!trackId) {
// Create a new track
let cnt = this._defaultTrackNamesCnt();
track = new TrackModel({
name: `${this._defaultTrackName}${cnt ? ' ' + (cnt + 1) : ''}`,
order: this.composition.tracks.length,
filepath
});
trackId = track.id;
} else {
// find by id and update
track = this.findTrack(trackId);
track.filepath = filepath;
}
this.addTrack(track);
return trackId;
}
private _defaultTrackNamesCnt() {
return this.composition.tracks
.filter(t => t.name.startsWith(this._defaultTrackName)).length;
}
...
这些更改将支持我们的录制器与活动作品交互的能力。
注意:考虑在模态中重用组件进行懒加载以及通过路由进行懒加载时的注意事项。
如果 Angular 服务旨在在所有懒加载模块以及根模块中共享为单例,则必须在 根 级别提供它们。RecorderService 在导航到时与 RecordModule 懒加载,同时在模态中打开。由于我们现在将 PlayerService 注入到我们的 RecorderService(它是懒加载的)中,而 PlayerService 现在注入 MixerService(它也是作为我们应用中的根路由懒加载的),我们将不得不创建一个问题,即我们的服务不再是单例。实际上,如果你尝试导航到 RecordComponent,你甚至可能会看到这样的错误:
JS: 错误 Error: Uncaught (in promise): Error: No provider for PlayerService!
为了解决这个问题,我们将从 PlayerModule 和 MixerModule 中删除提供者(因为这两个模块都是懒加载的)并在我们的 CoreModule 中仅提供这些服务:
修改后的 app/modules/player/player.module.ts 如下:
...
// import { PROVIDERS } from './services'; // commented out now
@NgModule({
...
// providers: [...PROVIDERS], // no longer provided here
...
})
export class PlayerModule {}
修改后的 app/modules/mixer/mixer.module.ts 如下:
...
// import { PROVIDERS } from './services'; // commented out now
@NgModule({
...
// providers: [...PROVIDERS], // no longer provided here
...
})
export class MixerModule {}
更新为仅从 CoreModule 提供这些服务作为真正的单例,app/modules/core/core.module.ts 的代码如下:
...
import { PROVIDERS } from './services';
import { PROVIDERS as MIXER_PROVIDERS } from '../mixer/services';
import { PROVIDERS as PLAYER_PROVIDERS } from '../player/services';
...
@NgModule({
...
providers: [
...PROVIDERS,
...MIXER_PROVIDERS,
...PLAYER_PROVIDERS
],
...
})
export class CoreModule {
这就是解决这些类型问题的方法;但这正是我们为什么建议在第十章 Chapter 10 中使用 Ngrx 的原因,即将推出的 @ngrx/store + @ngrx/effects for State Management,因为它可以帮助缓解这些依赖注入问题。
在这个阶段,我们的设置运行得很好;但是,当我们开始集成 ngrx 以实现更 Redux 风格的架构时,它可以得到极大的改进和简化。我们已经在这里做了一些反应式的事情,例如我们的 RecordComponent 对服务中的 state$ 可观察对象做出反应;但是,我们需要将 MixerService 注入到 PlayerService 中,这在架构上感觉有点不合适,因为 PlayerModule 实际上不应该依赖于 MixerModule 提供的任何东西。再次强调,这技术上确实可以正常工作,但是当我们开始在第十章 Chapter 10 中使用 ngrx 时,你将看到我们如何在整个代码库中减少依赖混合。
让我们花点时间放松一下,拍拍自己的背,因为这是一项令人印象深刻的成果。看看我们辛勤工作的成果:

第二阶段 – 为 Android 构建音频录音器
信不信由你,我们实际上已经完成了大部分繁重的工作,让这一切在 Android 上工作!这就是 NativeScript 的美妙之处。设计一个有意义的 API,以及一个可以插入/播放底层原生 API 的架构,对于 NativeScript 开发至关重要。在这个阶段,我们只需要将 Android 的组件插入到我们设计的形状中。所以,总结一下,我们现在有以下内容:
-
与
PlayerService协同工作的RecorderService,以协调我们的多轨处理能力 -
一个灵活的波形视图,准备好在底层提供 Android 实现
-
RecordModel应该调用适当的底层目标平台 API,并准备好将 Android 的详细信息插入其中 -
定义了模型形状的接口,Android 模型只需实现即可知道它们应该定义哪些 API
让我们开始工作。
我们希望将record.model.ts重命名为record.model.ios.ts,因为它仅针对 iOS,但在这样做之前,我们希望有一个 TypeScript 定义文件(.d.ts),以便我们的代码库可以继续导入为'record.model'。这可以通过几种方式完成,包括手动编写一个。然而,tsc 编译器有一个方便的-d标志,它可以为我们生成定义文件:
tsc app/modules/recorder/models/record.model.ts references.d.ts -d true
这将产生大量的 TypeScript 警告和错误;但在这个情况下,这并不重要,因为我们的定义文件将正确生成。我们不需要生成 JavaScript,只需要定义,所以你可以忽略由此产生的问题墙。
现在我们有两个新文件:
-
record-common.model.d.ts(你可以删除它,因为我们不再需要它) -
record.model.d.ts
RecordModel导入record-common.model文件,这就是为什么也为它生成了定义;但你可以删除它。现在,我们有了定义文件,但我们想稍作修改。我们不需要任何private声明和/或它包含的任何本地类型;你会注意到它包含了以下内容:
...
readonly target: AKMicrophone;
readonly recorder: AKNodeRecorder;
...
由于这些是 iOS 特有的,我们将想要将它们类型化为any,这样它们就适用于 iOS 和 Android。这是我们的修改后的样子:
import { Observable } from 'data/observable';
import { IRecordModel, IRecordEvents } from './common';
export declare class RecordModel extends Observable implements IRecordModel {
readonly events: IRecordEvents;
readonly target: any;
readonly recorder: any;
readonly audioFilePath: string;
state: number;
savedFilePath: string;
toggleRecord(): void;
togglePlay(): void;
stopPlayback(): void;
save(): void;
dispose(): void;
finish(): void;
}
完美,现在将record.model.ts重命名为record.model.ios.ts。我们现在已经完成了 iOS 的实现,并确保了最大程度的代码复用来将我们的重点转向 Android。NativeScript 将在构建时使用目标平台后缀文件,所以你永远不需要担心 iOS 特有的代码会出现在 Android 上,反之亦然。
我们之前生成的.d.ts定义文件将在 TypeScript 编译器进行 JavaScript 转换时使用,而运行时将使用特定平台的 JS 文件(不带扩展名)。
好的,现在创建app/modules/recorder/models/record.model.android.ts:
import { Observable } from 'data/observable';
import { IRecordModel, IRecordEvents, RecordState, documentsFilePath } from './common';
export class RecordModel extends Observable implements IRecordModel {
// available events to listen to
private _events: IRecordEvents;
// recorder
private _recorder: any;
// state
private _state: number = RecordState.readyToRecord;
// the final saved path to use
private _savedFilePath: string;
constructor() {
super();
this._setupEvents();
// TODO
}
public get events(): IRecordEvents {
return this._events;
}
public get target() {
// TODO
}
public get recorder(): any {
return this._recorder;
}
public get audioFilePath(): string {
return ''; // TODO
}
public get state(): number {
return this._state;
}
public set state(value: number) {
this._state = value;
this._emitEvent(this._events.stateChange, this._state);
}
public get savedFilePath() {
return this._savedFilePath;
}
public set savedFilePath(value: string) {
this._savedFilePath = value;
if (this._savedFilePath)
this.state = RecordState.saved;
}
public toggleRecord() {
if (this._state !== RecordState.recording) {
// just force ready to record
// when coming from any state other than recording
this.state = RecordState.readyToRecord;
}
switch (this._state) {
case RecordState.readyToRecord:
this.state = RecordState.recording;
break;
case RecordState.recording:
this._recorder.stop();
this.state = RecordState.readyToPlay;
break;
}
}
public togglePlay() {
if (this._state === RecordState.readyToPlay) {
this.state = RecordState.playing;
} else {
this.stopPlayback();
}
}
public stopPlayback() {
if (this.state !== RecordState.recording) {
this.state = RecordState.readyToPlay;
}
}
public save() {
// we will want to do this
// this.savedFilePath = documentsFilePath(fileName);
}
public dispose() {
// TODO
}
public finish() {
this.state = RecordState.finish;
}
private _emitEvent(eventName: string, data?: any) {
let event = {
eventName,
data,
object: this
};
this.notify(event);
}
private _setupEvents() {
this._events = {
stateChange: 'stateChange'
};
}
}
这可能看起来非常像 iOS 端,这是因为它们几乎相同!实际上,这种设置非常出色,所以现在我们只想填写 Android 的具体细节。
在我们的 RecordModel 中使用 nativescript-audio 的 TNSRecorder 为 Android
我们可以使用一些花哨的 Android API 和/或库来为我们的录音器,但在这个案例中,我们用于跨平台多轨播放器的nativescript-audio插件也提供了一个跨平台录音器。我们甚至可以用它来配合 iOS,但我们想在那里特别使用 AudioKit 强大的 API。然而,在这里的 Android 上,让我们使用插件中的录音器并对record.model.android.ts进行以下修改:
import { Observable } from 'data/observable';
import { IRecordModel, IRecordEvents, RecordState, documentsFilePath } from './common';
import { TNSRecorder, AudioRecorderOptions } from 'nativescript-audio';
import { Subject } from 'rxjs/Subject';
import * as permissions from 'nativescript-permissions';
declare var android: any;
const RECORD_AUDIO = android.Manifest.permission.RECORD_AUDIO;
export class RecordModel extends Observable implements IRecordModel {
// available events to listen to
private _events: IRecordEvents;
// target as an Observable
private _target$: Subject<number>;
// recorder
private _recorder: TNSRecorder;
// recorder options
private _options: AudioRecorderOptions;
// recorder mix meter handling
private _meterInterval: number;
// state
private _state: number = RecordState.readyToRecord;
// tmp file path
private _filePath: string;
// the final saved path to use
private _savedFilePath: string;
constructor() {
super();
this._setupEvents();
// prepare Observable as our target
this._target$ = new Subject();
// create recorder
this._recorder = new TNSRecorder();
this._filePath = documentsFilePath(`recording-${Date.now()}.m4a`);
this._options = {
filename: this._filePath,
format: android.media.MediaRecorder.OutputFormat.MPEG_4,
encoder: android.media.MediaRecorder.AudioEncoder.AAC,
metering: true, // critical to feed our waveform view
infoCallback: (infoObject) => {
// just log for now
console.log(JSON.stringify(infoObject));
},
errorCallback: (errorObject) => {
console.log(JSON.stringify(errorObject));
}
};
}
public get events(): IRecordEvents {
return this._events;
}
public get target() {
return this._target$;
}
public get recorder(): any {
return this._recorder;
}
public get audioFilePath(): string {
return this._filePath;
}
public get state(): number {
return this._state;
}
public set state(value: number) {
this._state = value;
this._emitEvent(this._events.stateChange, this._state);
}
public get savedFilePath() {
return this._savedFilePath;
}
public set savedFilePath(value: string) {
this._savedFilePath = value;
if (this._savedFilePath)
this.state = RecordState.saved;
}
public toggleRecord() {
if (this._state !== RecordState.recording) {
// just force ready to record
// when coming from any state other than recording
this.state = RecordState.readyToRecord;
}
switch (this._state) {
case RecordState.readyToRecord:
if (this._hasPermission()) {
this._recorder.start(this._options).then((result) => {
this.state = RecordState.recording;
this._initMeter();
}, (err) => {
this._resetMeter();
});
} else {
permissions.requestPermission(RECORD_AUDIO).then(() => {
// simply engage again
this.toggleRecord();
}, (err) => {
console.log('permissions error:', err);
});
}
break;
case RecordState.recording:
this._resetMeter();
this._recorder.stop();
this.state = RecordState.readyToPlay;
break;
}
}
public togglePlay() {
if (this._state === RecordState.readyToPlay) {
this.state = RecordState.playing;
} else {
this.stopPlayback();
}
}
public stopPlayback() {
if (this.state !== RecordState.recording) {
this.state = RecordState.readyToPlay;
}
}
public save() {
// With Android, filePath will be the same, just make it final
this.savedFilePath = this._filePath;
}
public dispose() {
if (this.state === RecordState.recording) {
this._recorder.stop();
}
this._recorder.dispose();
}
public finish() {
this.state = RecordState.finish;
}
private _initMeter() {
this._resetMeter();
this._meterInterval = setInterval(() => {
let meters = this.recorder.getMeters();
this._target$.next(meters);
}, 200); // use 50 for production - perf is better on devices
}
private _resetMeter() {
if (this._meterInterval) {
clearInterval(this._meterInterval);
this._meterInterval = undefined;
}
}
private _hasPermission() {
return permissions.hasPermission(RECORD_AUDIO);
}
private _emitEvent(eventName: string, data?: any) {
let event = {
eventName,
data,
object: this
};
this.notify(event);
}
private _setupEvents() {
this._events = {
stateChange: 'stateChange'
};
}
}
哇!好的,这里有很多有趣的事情在进行。让我们先解决一个必要的事情,确保对于 API 级别 23+的 Android,权限得到正确处理。为此,你可以安装权限插件:
tns plugin add nativescript-permissions
我们还想要确保我们的清单文件包含正确的权限键。
打开app/App_Resources/Android/AndroidManifest.xml并在正确位置添加以下内容:
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
我们使用 nativescript-audio 插件的TNSRecorder作为我们的实现,并相应地将其 API 连接起来。AudioRecorderOptions提供了一个metering选项,允许通过间隔监控麦克风的音量计。
我们整体设计最灵活的地方在于,我们的模型的目标可以是任何东西。在这种情况下,我们创建了一个 RxJS Subject 可观察对象作为_target$,然后将其作为我们的目标获取器返回。这允许我们通过Subject可观察对象发射麦克风的音量计值,以便我们的波形使用。你很快就会看到我们如何利用这一点。
现在我们已经准备好继续进行 Android 的波形实现。
就像我们对模型所做的那样,我们希望将公共部分重构到一个共享文件中并处理后缀。
创建app/modules/shared/native/waveform-common.ts:
import { View } from 'ui/core/view';
export type WaveformType = 'mic' | 'file';
export interface IWaveformModel {
readonly target: any;
dispose(): void;
}
export interface IWaveform extends View {
type: WaveformType;
model: IWaveformModel;
createNativeView(): any;
initNativeView(): void;
disposeNativeView(): void;
}
然后,只需调整app/modules/shared/native/waveform.ts以使用它:
...
import { IWaveform, IWaveformModel, WaveformType } from './waveform-common';
export class Waveform extends View implements IWaveform {
...
在将我们的波形重命名为包含.ios后缀之前,让我们先为它生成一个 TypeScript 定义文件:
tsc app/modules/shared/native/waveform.ts references.d.ts -d true --lib es6,dom,es2015.iterable --target es5
你可能会再次看到 TypeScript 的错误或警告,但我们不需要担心这些,因为它应该仍然生成了一个waveform.d.ts文件。让我们稍微简化一下,只包含适用于 iOS 和 Android 的部分:
import { View } from 'ui/core/view';
export declare type WaveformType = 'mic' | 'file';
export interface IWaveformModel {
readonly target: any;
dispose(): void;
}
export interface IWaveform extends View {
type: WaveformType;
model: IWaveformModel;
createNativeView(): any;
initNativeView(): void;
disposeNativeView(): void;
}
export declare class Waveform extends View implements IWaveform {}
好的,现在,将waveform.ts重命名为waveform.ios.ts并创建app/modules/shared/native/waveform.android.ts:
import { View } from 'ui/core/view';
import { Color } from 'color';
import { IWaveform, IWaveformModel, WaveformType } from './common';
export class Waveform extends View implements IWaveform {
private _model: IWaveformModel;
private _type: WaveformType;
public set type(value: WaveformType) {
this._type = value;
}
public get type() {
return this._type;
}
public set model(value: IWaveformModel) {
this._model = value;
}
public get model() {
return this._model;
}
createNativeView() {
switch (this.type) {
case 'mic':
// TODO: this.nativeView = ?
break;
case 'file':
// TODO: this.nativeView = ?
break;
}
return this.nativeView;
}
initNativeView() {
// TODO
}
disposeNativeView() {
if (this.model && this.model.dispose) this.model.dispose();
}
}
好的,太棒了!这是我们需要的最基本设置,但我们应该使用哪种原生的 Android 视图?
如果你正在寻找开源的 Android 库,你可能会遇到一群非常出色的开发者,他们来自乌克兰的Yalantis,一家出色的移动开发公司。Roman Kozlov 和他的团队创建了一个开源项目,Horizon,它提供了美丽的音频可视化:
yalantis.com/blog/horizon-open-source-library-for-sound-visualization/
就像 iOS 一样,我们也想为可以渲染静态波形文件的 Waveform 视图做好准备。进一步查看开源选项,我们可能会遇到另一支才华横溢的团队——位于波兰广阔首都华沙的Semantive。他们为 Android 创建了一个功能强大的 Waveform 视图:
github.com/Semantive/waveform-android
让我们将这两个库集成到我们的 Android Waveform 集成中。
类似于我们为 iOS 集成的 AudioKit,让我们在根目录下创建一个名为android-waveform-libs的文件夹,并按照以下设置提供include.gradle:

为什么在包含本地库时偏离nativescript-前缀?
如果你计划将来将内部插件重构为通过 npm 发布的开源插件,以便社区使用,例如使用github.com/NathanWalker/nativescript-plugin-seed,那么使用前缀是一个不错的选择。
有时候,你可能只需要为特定平台集成几个本地库,就像我们在这个案例中一样,所以我们不需要在我们的文件夹上使用nativescript-前缀。
我们确保添加package.json,这样我们就可以像添加任何其他插件一样添加这些本地库:
{
"name": "android-waveform-libs",
"version": "1.0.0",
"nativescript": {
"platforms": {
"android": "3.0.0"
}
}
}
现在,我们只需将它们作为插件添加到我们的项目中:
tns plugin add android-waveform-libs
我们现在已准备好将这些库集成到我们的 Waveform 视图中。
让我们对app/modules/shared/native/waveform.android.ts文件进行以下修改:
import { View } from 'ui/core/view';
import { Color } from 'color';
import { Subscription } from 'rxjs/Subscription';
import { IWaveform, IWaveformModel, WaveformType } from './common';
import { screen } from 'platform';
declare var com;
declare var android;
const GLSurfaceView = android.opengl.GLSurfaceView;
const AudioRecord = android.media.AudioRecord;
// Horizon recorder waveform
// https://github.com/Yalantis/Horizon
const Horizon = com.yalantis.waves.util.Horizon;
// various recorder settings
const RECORDER_SAMPLE_RATE = 44100;
const RECORDER_CHANNELS = 1;
const RECORDER_ENCODING_BIT = 16;
const RECORDER_AUDIO_ENCODING = 3;
const MAX_DECIBELS = 120;
// Semantive waveform for files
// https://github.com/Semantive/waveform-android
const WaveformView = com.semantive.waveformandroid.waveform.view.WaveformView;
const CheapSoundFile = com.semantive.waveformandroid.waveform.soundfile.CheapSoundFile;
const ProgressListener = com.semantive.waveformandroid.waveform.soundfile.CheapSoundFile.ProgressListener;
export class Waveform extends View implements IWaveform {
private _model: IWaveformModel;
private _type: WaveformType;
private _initialized: boolean;
private _horizon: any;
private _javaByteArray: Array<any>;
private _waveformFileView: any;
private _sub: Subscription;
public set type(value: WaveformType) {
this._type = value;
}
public get type() {
return this._type;
}
public set model(value: IWaveformModel) {
this._model = value;
this._initView();
}
public get model() {
return this._model;
}
createNativeView() {
switch (this.type) {
case 'mic':
this.nativeView = new GLSurfaceView(this._context);
this.height = 200; // GL view needs height
break;
case 'file':
this.nativeView = new WaveformView(this._context, null);
this.nativeView.setSegments(null);
this.nativeView.recomputeHeights(screen.mainScreen.scale);
// disable zooming and touch events
this.nativeView.mNumZoomLevels = 0;
this.nativeView.onTouchEvent = function (e) { return false; }
break;
}
return this.nativeView;
}
initNativeView() {
this._initView();
}
disposeNativeView() {
if (this.model && this.model.dispose) this.model.dispose();
if (this._sub) this._sub.unsubscribe();
}
private _initView() {
if (!this._initialized && this.nativeView && this.model) {
if (this.type === 'mic') {
this._initialized = true;
this._horizon = new Horizon(
this.nativeView,
new Color('#000').android,
RECORDER_SAMPLE_RATE,
RECORDER_CHANNELS,
RECORDER_ENCODING_BIT
);
this._horizon.setMaxVolumeDb(MAX_DECIBELS);
let bufferSize = 2 * AudioRecord.getMinBufferSize(
RECORDER_SAMPLE_RATE, RECORDER_CHANNELS, RECORDER_AUDIO_ENCODING);
this._javaByteArray = Array.create('byte', bufferSize);
this._sub = this._model.target.subscribe((value) => {
this._javaByteArray[0] = value;
this._horizon.updateView(this._javaByteArray);
});
} else {
let soundFile = CheapSoundFile.create(this._model.target,
new ProgressListener({
reportProgress: (fractionComplete: number) => {
console.log('fractionComplete:', fractionComplete);
return true;
}
}));
setTimeout(() => {
this.nativeView.setSoundFile(soundFile);
this.nativeView.invalidate();
}, 0);
}
}
}
}
我们开始我们的 Android 实现,通过定义对需要访问的各种打包类的const引用,以减轻在 Waveform 中每次都要引用完全限定包位置的需要。就像在 iOS 端一样,我们通过允许类型('mic'或'file')驱动使用哪种渲染来设计一个双用途的 Waveform。这使我们能够将这个功能用于我们的实时麦克风可视化记录视图,另一个用于将我们的音轨作为 Waveform 静态渲染(更多内容将在后面介绍!)。
Horizon 库利用 Android 的GLSurfaceView作为主要渲染,因此:
this.nativeView = new GLSurfaceView(this._context);
this.height = 200; // GL view needs height
在开发过程中,我们发现GLSurfaceView至少需要一个高度来限制它,否则它将以全屏高度渲染。因此,我们明确地将自定义 NativeScript 视图的height设置为合理的200,这将自动为我们处理原生视图的测量。有趣的是,我们还发现有时我们的模型设置器会在initNativeView之前触发,有时会在之后。因为模型是初始化我们的 Horizon 视图的关键绑定,我们设计了一个自定义的内部_initView方法,其中包含适当的条件,可以从initNativeView调用,也可以在我们模型设置器触发后调用。条件(!this._initialized && this.nativeView && this.model)确保它只初始化一次。这是处理这些方法调用顺序中任何潜在竞争条件的方法。
本地Horizon.java类提供了一个update方法,该方法期望一个具有以下签名的 Java 字节数组:
updateView(byte[] buffer)
在 NativeScript 中,我们保留了一个表示这个本地 Java 字节数组的结构的引用:
let bufferSize = 2 * AudioRecord.getMinBufferSize(
RECORDER_SAMPLE_RATE, RECORDER_CHANNELS, RECORDER_AUDIO_ENCODING);
this._javaByteArray = Array.create('byte', bufferSize);
通过使用 Android 的android.media.AudioRecord类以及我们设置的各个录音设置,我们能够收集到一个初始的bufferSize,我们用它来初始化我们的字节数组大小。
我们随后利用我们整体灵活的设计,其中在这个实现中我们的模型目标是 rxjs Subject Observable,这使得我们可以订阅其事件流。对于'mic'类型,这个流将是录音的计数值变化,我们用它来填充我们的字节数组,并相应地更新Horizon视图:
this._sub = this._model.target.subscribe((value) => {
this._javaByteArray[0] = value;
this._horizon.updateView(this._javaByteArray);
});
这为我们提供了记录器一个很好的可视化效果,它将随着输入级别的变化而动画化。这里是一个预览;然而,风格仍然有点丑,因为我们还没有应用任何 CSS 润色:

对于我们的静态音频文件波形渲染,我们在createNativeView构造过程中使用 Android 上下文初始化WaveformView。然后我们使用其 API 对其进行配置,以便在我们的使用中。
在初始化过程中,我们创建了一个CheapSoundFile实例,这是WaveformView所要求的,并且有趣的是,我们在setTimeout内部使用setSoundFile,同时调用this.nativeView.invalidate(),这是在调用WaveformView的 invalidate。这导致原生视图使用处理后的文件进行更新,如下所示(再次,我们将在稍后解决风格润色问题):

摘要
本章介绍了大量关于如何在 iOS 和 Android 上使用原生 API 的工作的强大概念和技术。了解如何使用开源原生库对于充分利用您的应用程序开发并实现您所追求的功能集至关重要。直接从 TypeScript 访问这些 API,让您享受从未离开您首选的开发环境的奢侈,同时以有趣和易于访问的方式与您所爱的语言互动。
此外,学习如何/何时创建自定义 NativeScript 视图,并在您的 Angular 应用程序中与之交互,是充分利用这个技术栈的关键要素之一。
在下一章中,我们将通过增强我们的播放列表视图,添加更多功能,利用在这里学到的一些知识,提供一些额外的精彩内容。
第九章:赋能你的视图
Angular 和 NativeScript 的结合在开发中非常有趣,对于移动开发来说功能强大得无法估量。无论你需要提供服务来与移动设备的硬件功能交互,例如音频录制,还是通过引人入胜的视图来丰富你的应用可用性,NativeScript for Angular 都提供了令人兴奋的机会。
让我们继续使用在前一章中开发的一些概念,以提供我们轨道的另一种丰富视图,同时重用我们迄今为止所涵盖的一切,以及一些新的技巧/技巧。
在本章中,我们将涵盖以下主题:
-
使用
ListView和templateSelector的多个项行模板 -
使用
ListView处理行模板更改以及何时如何刷新它们 -
通过
NativeScriptFormsModule使用ngModel数据绑定 -
利用共享的单例服务在多个模块之间共享状态
-
在存储前序列化数据,并在从持久状态检索后进行恢复
-
利用和重用 Angular 指令来丰富 NativeScript Slider,使其具有更多独特的特性
使用 NativeScript 的 ListView 的多个项模板
在第八章“构建音频录制器”中,我们设计了一个双用途的定制 NativeScript Waveform 视图,它利用 iOS 和 Android 的各种本地库,专门用于丰富我们的作品列表视图。让我们通过重用我们的多才多艺的 Waveform 视图来处理我们的作品列表视图。这将为我们提供一种方法来显示混音滑动控制(通常在音频制作和声音工程中称为 Fader),与我们的轨道一起显示,使用户能够在整体作品中调整每个轨道的音量级别。让我们设置我们的TrackListComponent的ListView,使其能够为用户提供两种不同的方式来查看和处理他们的轨道,每种方式都有其独特的效用。在此同时,我们还将利用这个机会最终连接我们轨道上的静音开关。
让我们对app/modules/player/components/track-list/track-list.component.html进行以下修改:
<ListView #listview [items]="tracks | orderBy: 'order'" class="list-group"
[itemTemplateSelector]="templateSelector">
<ng-template let-track="item" nsTemplateKey="default">
<GridLayout rows="auto" columns="100,*,100" class="list-group-item"
[class.muted]="track.mute">
<Button text="Record" (tap)="record(track)" row="0" col="0" class="c-ruby"></Button>
<Label [text]="track.name" row="0" col="1" class="h2"></Label>
<Switch row="0" col="2" class="switch" [(ngModel)]="track.mute"></Switch>
</GridLayout>
</ng-template>
<ng-template let-track="item" nsTemplateKey="waveform">
<AbsoluteLayout [class.muted]="track.mute">
<Waveform class="waveform w-full" top="0" left="0" height="80"
[model]="track.model"
type="file"
plotColor="#888703"
fill="true"
mirror="true"
plotType="buffer"></Waveform>
<Label [text]="track.name" row="0" col="1" class="h3 track-name-float"
top="5" left="20"></Label>
<Slider slim-slider="fader.png" minValue="0" maxValue="1"
width="94%" top="50" left="0"
[(ngModel)]="track.volume" class="slider fader"></Slider>
</AbsoluteLayout>
</ng-template>
</ListView>
这里发生了很多有趣的事情。首先,[itemTemplateSelector]="templateSelector"提供了在飞行中更改我们的ListView项行的能力。templateSelector函数的结果应该是一个字符串,它与任何 ng-template 的ngTemplateKey属性提供的值相匹配。为了使所有这些工作,我们需要准备一些事情,首先是具有通过#listview和ViewChild访问ListView的Component:
// angular
import { Component, Input, ViewChild, ElementRef } from '@angular/core';
import { Router } from '@angular/router';
// nativescript
import { ListView } from 'ui/list-view';
// app
import { ITrack } from '../../../shared/models';
import { AuthService, DialogService } from '../../../core/services';
import { PlayerService } from '../../services/player.service';
@Component({
moduleId: module.id,
selector: 'track-list',
templateUrl: 'track-list.component.html',
})
export class TrackListComponent {
public templateSelector: Function;
@Input() tracks: Array<ITrack>;
@ViewChild('listview') _listviewRef: ElementRef;
private _listview: ListView;
private _sub: any;
constructor(
private authService: AuthService,
private dialogService: DialogService,
private router: Router,
private playerService: PlayerService
) {
this.templateSelector = this._templateSelector.bind(this);
}
ngOnInit() {
this._sub = this.playerService.trackListViewChange$.subscribe(() => { // since this involves our templateSelector, ensure ListView knows about it
// refresh list
this._listview.refresh();
});
}
ngAfterViewInit() {
this._listview = <ListView>this._listviewRef.nativeElement;
}
private _templateSelector(item: ITrack, index: number, items: ITrack[]) {
return this.playerService.trackListViewType;
}
...
我们设置了一个ViewChild来保留对ListView的引用,稍后我们将使用它来调用this._listview.refresh()。在 Angular 中,当我们需要ListView在更改后更新显示时,这是必需的。第一个惊喜很可能是PlayerService的注入,第二个可能是this.templateSelector = this._templateSelector.bind(this)。templateSelector绑定不是作用域绑定的,并且由于我们需要它从Component上的this.playerService返回属性引用,我们通过绑定一个Function引用来确保它正确地绑定到Component的作用域。我们将在这个点上使用PlayerService作为沟通的渠道,以帮助从位于MixerModule中的ActionBarComponent传递状态。
这个例子展示了服务如何帮助在整个应用中传递状态。然而,通过利用 ngrx 来减少交织的依赖关系并解锁具有 Redux 风格架构的纯响应式设置,这种做法可以得到极大的改进。ngrx 增强功能将在第十章中介绍,即@ngrx/store + @ngrx/effects for State Management。
我们的视图切换按钮将位于ActionBar(在MixerModule中),并且我们希望在那里点击以切换我们位于PlayerModule内部的ListView。PlayerService目前是一个单例(由CoreModule提供),并在整个应用中共享,因此它是一个完美的候选者来协助这里。让我们首先查看app/modules/mixer/components/action-bar/action-bar.component.ios.html中的ActionBarComponent更改:
<ActionBar [title]="title" class="action-bar">
<ActionItem nsRouterLink="/mixer/home">
<Button text="List" class="action-item"></Button>
</ActionItem>
<ActionItem (tap)="toggleList()" ios.position="right">
<Button [text]="toggleListText" class="action-item"></Button>
</ActionItem>
<ActionItem (tap)="record()" ios.position="right">
<Button text="Record" class="action-item"></Button>
</ActionItem>
</ActionBar>
然后,我们将查看app/modules/mixer/components/action-bar/action-bar.component.android.html中的更改:
<ActionBar class="action-bar">
<GridLayout rows="auto" columns="auto,*,auto,auto" class="action-bar">
<Button text="List" nsRouterLink="/mixer/home"
class="action-item" row="0" col="0"></Button>
<Label [text]="title" class="action-bar-title text-center" row="0" col="1"></Label>
<Button [text]="toggleListText" (tap)="toggleList()"
class="action-item" row="0" col="2"></Button>
<Button text="Record" (tap)="record()"
class="action-item" row="0" col="3"></Button>
</GridLayout>
</ActionBar>
我们还将查看Component中的更改:
...
import { PlayerService } from '../../../player/services/player.service';
@Component({
moduleId: module.id,
selector: 'action-bar',
templateUrl: 'action-bar.component.html'
})
export class ActionBarComponent {
...
public toggleListText: string = 'Waveform';
constructor(
private router: RouterExtensions,
private playerService: PlayerService
) { }
...
public toggleList() {
// later we can use icons, using labels for now let type = this.playerService.trackListViewType === 'default' ? 'waveform' : 'default';
this.playerService.trackListViewType = type;
this.toggleListText = type === 'default' ? 'Waveform' : 'Default';
}
}
如您所见,我们在ActionBar中添加了一个按钮,它将使用Waveform或Default标签,具体取决于其状态。然后,我们使用PlayerService修改了一个新的设置器**this.playerService.trackListViewType** **=** **type**。现在让我们查看app/modules/player/services/player.service.ts:
...
@Injectable()
export class PlayerService {
...
// communicate state changes from ActionBar to anything else
public trackListViewChange$: Subject<string> = new Subject(); ... public get trackListViewType() {
return this._trackListViewType;
}
public set trackListViewType(value: string) {
this._trackListViewType = value;
this.trackListViewChange$.next(value);
} ...
这就完成了这项工作。
如前所述,我们将在下一章中使用 ngrx 改进这个设置,ngrx 全部关于润色和简化我们处理应用状态的方式。
为了确保所有我们的新功能都能正常工作,我们还需要做一些其他事情。首先,没有NativeScriptFormsModule,[(ngModel)]绑定将完全不起作用。
如果你在你的组件视图中使用ngModel绑定,你必须确保声明你的Component的模块导入了NativeScriptFormsModule。如果它使用SharedModule,确保SharedModule导入了并导出了NativeScriptFormsModule。
让我们将前一条提示中提到的模块添加到我们的SharedModule中,这样我们的所有模块都可以在需要的地方使用ngModel:
...
import { NativeScriptFormsModule } from 'nativescript-angular/forms';
...
@NgModule({
imports: [
NativeScriptModule,
NativeScriptRouterModule,
NativeScriptFormsModule
],
...
exports: [
NativeScriptModule,
NativeScriptRouterModule,
NativeScriptFormsModule,
...PIPES
]
})
export class SharedModule {}
现在,我们需要每个轨道的静音和音量属性更改来通知我们的音频播放器。这涉及到稍微修改我们的 TrackModel 以适应这个新功能;为此,打开 app/modules/shared/models/track.model.ts:
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
...
export class TrackModel implements ITrack {
public id: number;
public filepath: string;
public name: string;
public order: number;
public model: any;
public volume$: BehaviorSubject<number>;
private _volume: number = 1; // default full volume
private _mute: boolean;
private _origVolume: number; // return to after unmute
constructor(model?: ITrack) {
this.volume$ = new BehaviorSubject(this._volume);
...
}
public set mute(value: boolean) {
this._mute = value;
if (this._mute) {
this._origVolume = this._volume;
this.volume = 0;
} else {
this.volume = this._origVolume;
}
}
public get mute() {
return this._mute;
}
public set volume(value: number) {
this._volume = value;
this.volume$.next(this._volume);
if (this._volume > 0 && this._mute) {
// if just increasing volume from a muted state
// ensure it's unmuted
this._origVolume = this._volume;
this._mute = false;
}
}
public get volume() {
return this._volume;
}
}
我们现在想要修改我们的 TrackPlayerModel 以与这些新功能协同工作。之前,我们只保留 trackId;然而,随着这个新功能的加入,保留整个 TrackModel 对象的引用将很有帮助,所以打开 app/modules/shared/models/track-player.model.ts 并进行以下修改:
...
import { Subscription } from 'rxjs/Subscription';
...
interface ITrackPlayer {
track: TrackModel; // was trackId only
duration: number;
readonly player: TNSPlayer;
}
...
export class TrackPlayerModel implements ITrackPlayer {
public track: TrackModel;
...
private _sub: Subscription;
...
public load(track: TrackModel, complete: Function, error: Function): Promise<number> {
return new Promise((resolve, reject) => {
this.track = track;
this._player.initFromFile({
...
}).then(() => {
...
// if reloading track, clear subscription before subscribing again
if (this._sub) this._sub.unsubscribe();
this._sub = this.track.volume$.subscribe((value) => {
if (this._player) {
// react to track model property changes
this._player.volume = value;
}
});
}, reject);
});
}
...
public cleanup() {
// cleanup and dispose player
if (this.player) this.player.dispose();
if (this._sub) this._sub.unsubscribe();
}
...
我们的音乐播放器现在可以通过观察每个轨道通过数据绑定进行的音量更改来响应音量变化。由于静音本质上只需要修改播放器的音量,我们确保相应地更新音量,并在切换静音/取消静音时保持原始音量,这样任何自定义的音量设置都将被保留。
我们对轨道的新丰富视图包括可重用的波形视图,但这次是 type="file",因为这将激活音频文件静态波形以渲染,这样我们就可以 看到 我们的音乐。我们还提供了调整每个轨道音量(混音控制)并将标签浮到左上角的能力,这样用户仍然知道是什么。所有这些操作都是通过利用 AbsoluteLayout 容器来完成的,它允许我们重叠组件并手动将它们放置在彼此之上。
序列化数据以持久化并在检索时恢复
所有这些都工作得非常好,然而,我们引入了一个问题。我们的 MixerService 提供了保存这些包含所有轨道的合成的功能。然而,现在的轨道包含复杂对象,如可观察对象,甚至带有获取器和设置器的私有引用。
在持久化数据时,你通常会想使用 JSON.stringify 序列化对象以便在存储时可以稍后检索并如果需要的话将其注入到更复杂的模型中。
事实上,如果你现在尝试使用 JSON.stringify 处理我们的 TrackModel,它将会失败,因为你不能将某些结构序列化为字符串。因此,我们现在需要在存储之前将我们的数据序列化,以及当检索数据时恢复我们的更复杂的模型。让我们对我们的 MixerService 进行一些修改以解决这个问题。打开 app/modules/mixer/services/mixer.service.ts 并进行以下修改:
// nativescript
import { knownFolders, path } from 'file-system';
...
@Injectable()
export class MixerService {
public list: Array<IComposition>;
constructor(
private databaseService: DatabaseService,
private dialogService: DialogService
) {
// restore with saved compositions or demo list
this.list = this._hydrateList(this._savedCompositions() || this._demoComposition());
}
...
private _saveList() {
this.databaseService.setItem(DatabaseService.KEYS.compositions, this._serializeList());
}
private _serializeList() {
let serialized = [];
for (let comp of this.list) {
let composition: any = Object.assign({}, comp);
composition.tracks = [];
for (let track of comp.tracks) {
let serializedTrack = {};
for (let key in track) {
// ignore observable, private properties and waveform model (redundant)
// properties are restored upon hydration
if (!key.includes('_') && !key.includes('$') && key != 'model') {
serializedTrack[key] = track[key];
}
}
composition.tracks.push(serializedTrack);
}
// serialized composition
serialized.push(composition);
}
return serialized;
}
private _hydrateList(list: Array<IComposition>) {
for (let c = 0; c < list.length; c++) {
let comp = new CompositionModel(list[c]);
for (let i = 0; i < comp.tracks.length; i++) {
comp.tracks[i] = new TrackModel(comp.tracks[i]);
// for waveform
(<any>comp.tracks[i]).model = {
// fix is only for demo tracks since they use files from app folder
target: fixAppLocal(comp.tracks[i].filepath)
};
}
// ensure list ref is updated to use hydrated model
list[c] = comp;
}
return list;
}
...
}
const fixAppLocal = function (filepath: string) {
if (filepath.indexOf('~/') === 0) { // needs to be absolute path and not ~/ app local shorthand
return path.join(knownFolders.currentApp().path, filepath.replace('~/', ''));
}
return filepath;
}
现在,我们将确保每次保存我们的合成时,它都正确地序列化为一个更安全、更简化的形式,这样就可以通过 JSON.stringify 处理。然后,当从我们的持久存储(在这种情况下,通过在 DatabaseService 的底层使用 NativeScript 的应用程序设置模块;这将在 第二章,功能模块中介绍)检索数据时,我们将数据重新注入到我们的模型中,这将使用我们的可观察属性丰富数据。
利用 Angular 指令丰富 NativeScript 滑块的独特特性
对于每个轨道滑块(也称为我们的混音/音量控制),实际上渲染一个看起来像滑块的控件会很好,这样就可以清楚地知道这些滑块是混音旋钮,不会被误认为是该轨道的快进播放。我们可以创建一个用于这些滑块的图形,它看起来会是这样:

对于 iOS,我们还需要一个按下/高亮状态,以便用户按下滑块时用户体验良好:

我们现在可以为这些文件创建两个版本,并将它们放入app/App_Resources/iOS;原始版本将是 100x48 像素用于标准分辨率,而对于 iPhone Plus 及以上型号,我们将有一个@3x 版本,大小为 150x72 像素(基本上是 24 像素加上标准的 48 像素高度):
-
fader-down.png -
fader-down@3x.png -
fader.png -
fader@3x.png
我们现在可以重用我们的SlimSliderDirective(目前用于自定义快进滑块的样式)并提供一个输入,以便我们可以提供从我们的应用程序资源中使用的文件名:
打开app/modules/player/directives/slider.directive.ios.ts并做出以下修改:
import { Directive, ElementRef, Input } from '@angular/core';
@Directive({
selector: '[slim-slider]'
})
export class SlimSliderDirective {
@Input('slim-slider') imageName: string;
constructor(private el: ElementRef) { }
ngAfterViewInit() {
let uiSlider = <UISlider>this.el.nativeElement.ios;
if (this.imageName) {
uiSlider.setThumbImageForState(
UIImage.imageNamed(this.imageName), UIControlState.Normal);
// assume highlighted state always suffixed with '-down'
let imgParts = this.imageName.split('.');
let downImg = `${imgParts[0]}-down.${imgParts[1]}`;
uiSlider.setThumbImageForState(
UIImage.imageNamed(downImg), UIControlState.Highlighted);
} else {
// used for shuttle control
uiSlider.userInteractionEnabled = false;
uiSlider.setThumbImageForState(UIImage.new(), UIControlState.Normal);
}
}
}
这允许我们指定要作为组件本身上的Slider滑块的文件名:
<Slider slim-slider="fader.png" minValue="0" maxValue="1"
width="94%" top="50" left="0"
[(ngModel)]="track.volume" class="slider fader"></Slider>
在此基础上,我们现在在轨道混音视图切换激活时拥有了这些整洁的滑块控件:

图形和资源处理(针对 Android)
现在,让我们也处理 Android 版本。我们首先将标准的 48 像素高度的滑块图形复制到 app/App_Resources/Android/drawable-hdpi 文件夹中。然后我们可以创建这个图形的适当分辨率版本,并将它们复制到各个分辨率相关的文件夹中。在处理 Android 时,需要注意的一点是它不像 iOS 那样使用"@3x"后缀标识符,所以我们只是将这些文件命名为"fader.png"。以下是我们的图形在分辨率相关文件夹中的一个视图(在这种情况下是hdpi,它处理“高密度”屏幕分辨率):

我们现在可以自定义我们的 Android 滑块指令,包括滑块图像处理,打开app/modules/player/directives/slider.directive.android.ts:
import { Directive, ElementRef, Input } from '@angular/core';
import { fromResource } from 'image-source';
import { getNativeApplication } from 'application';
let application: android.app.Application;
let resources: android.content.res.Resources;
const getApplication = function () {
if (!application) {
application = (<android.app.Application>getNativeApplication());
}
return application;
}
const getResources = function () {
if (!resources) {
resources = getApplication().getResources();
}
return resources;
}
@Directive({
selector: '[slim-slider]'
})
export class SlimSliderDirective {
@Input('slim-slider') imageName: string;
private _thumb: android.graphics.drawable.BitmapDrawable;
constructor(private el: ElementRef) {
el.nativeElement[(<any>slider).colorProperty.setNative] = function (v) {
// ignore the NativeScript default color setter of this slider
};
}
ngAfterViewInit() {
let seekBar = <android.widget.SeekBar>this.el.nativeElement.android;
if (this.imageName) {
if (!seekBar) {
// part of view toggle - grab on next tick
// this helps ensure the seekBar instance can be accessed properly
// since this may fire amidst the view toggle switching on our tracks
setTimeout(() => {
seekBar = <android.widget.SeekBar>this.el.nativeElement.android;
this._addThumbImg(seekBar);
});
} else {
this._addThumbImg(seekBar);
}
} else {
// seekBar.setEnabled(false);
seekBar.setOnTouchListener(new android.view.View.OnTouchListener({
onTouch(view, event) {
return true;
}
}));
seekBar.getThumb().mutate().setAlpha(0);
}
}
private _addThumbImg(seekBar: android.widget.SeekBar) {
if (!this._thumb) {
let imgParts = this.imageName.split('.');
let name = imgParts[0];
const res = getResources();
if (res) {
const identifier: number = res.getIdentifier(
name, 'drawable', getApplication().getPackageName());
if (0 < identifier) {
// Load BitmapDrawable with getDrawable to make use of Android internal caching
this._thumb = <android.graphics.drawable.BitmapDrawable>res.getDrawable(identifier);
}
}
}
if (this._thumb) {
seekBar.setThumb(this._thumb);
seekBar.getThumb().clearColorFilter();
if (android.os.Build.VERSION.SDK_INT >= 21) {
(<any>seekBar).setSplitTrack(false);
}
}
}
}
上述 Android 实现中最不寻常且可能令人惊讶的方面之一是这一点:
constructor(private el: ElementRef) {
el.nativeElement[(<any>slider).colorProperty.setNative] = function (v) {
// ignore the NativeScript color setter of the slider
};
}
通常情况下,在 NativeScript 中重用和扩展控件相当容易。然而,这是一个例外情况,其中默认的 NativeScript 滑块控件设置器实际上会在Android上给我们带来问题。默认设置器将尝试将滑块的拇指颜色设置为蓝色,并附带一个混合方法。当它将此标志设置为滑块时,我们设置的任何图形形状都会被设置为全蓝色。因此,为了使我们的滑块类能够处理自定义图形,我们必须消除控件上的默认滑块拇指颜色设置器。我们通过附加一个新的“color”设置器来控制这一点,该设置器实际上什么也不做。这样,当 NativeScript 框架在初始化或重置控件时尝试设置默认颜色时,什么也不会发生,这样我们就可以完全控制发生的事情。作为预防措施,在_addThumbImg方法结束时,我们还调用seekBar.getThumb().clearColorFilter();以确保在能够使默认颜色设置器沉默之前,任何潜在的颜色过滤器设置都被取消。
最后,当我们的曲目列表视图切换到混音模式时,我们可以自定义显示在每个轨道上的音频波形所使用的颜色。由于 Android 的波形插件使用应用程序的颜色资源,我们可以在 app/App_Resources/Android/values/colors.xml 中添加插件文档中找到的正确命名的属性,并且相同的颜色也应该复制到 app/App_Resources/Android/values-v21/colors.xml 中:

这现在为我们提供了在混音模式下 Android 文件波形显示的自定义样式:

摘要
我们希望提供一些额外的宝贝,与你在第三部分中学到的所有丰富内容一起使用;我们希望你喜欢!使用ListView的多个项目行模板在许多情况下都很有用,但希望这会给你提供工具,让你知道如何让它为你和你的应用程序工作。
对于数据持久性,特殊考虑是任何引人入胜的应用程序的重要因素,因此我们研究了在存储之前序列化数据,并在从持久状态恢复数据时注水数据。
最后,我们研究了进一步丰富我们的视图组件,添加更多的 Angular 指令优点。随着第三部分的完成,我们现在已经完成了为本书提供的应用程序的核心能力和功能集。然而,我们离完成应用程序还远着呢。本书中介绍的开发工作流程和过程是我们为任何我们构建的应用程序带来的典型开发周期。我们将在第十四章部署准备与 webpack 捆绑中介绍改进我们的架构和进一步打磨我们的应用程序,以准备通过 Google Play 和 App Store 进行公开发布。
现在让我们继续前进,通过在第十章中使用ngrx集成来改进我们应用的状态处理,即使用@ngrx/store + @ngrx/effects进行状态管理。值得一提的是,使用类似 Redux 的架构是一个更好的决定,最好在构建应用之前就做出,正如我们在这里所做的那样。然而,这并不是必需的,也不是强制性的,因此,我们希望在不包含它的前提下构建应用,以展示该应用在本质上运行得很好。现在,我们将继续将其整合进来,以展示您可以通过它获得的各项优势。
第十章:@ngrx/store + @ngrx/effects 用于状态管理
随着应用随时间扩展,管理任何应用的状态可能会变得麻烦。我们希望对我们的应用行为的可预测性有完全的信心,而掌握其状态是获得这种信心的关键。
状态可以广泛地定义为某人在特定时间所处的特定条件。就我们的应用而言,状态可以包括我们的播放器是否在播放,录音机是否在录音,以及轨道列表 UI 是否在混音模式中等。
将状态存储在单个位置允许你确切地知道在任何给定时刻应用的状态。没有单一的状态存储,你通常会在不同的组件和服务中找到隐藏的状态,这通常会导致随着功能的构建出现两个或更多不同的状态版本。当不同的功能需要相互交互时,这种状态的不规则增长变得更加麻烦,而这些交互可能或可能不依赖于彼此。
在本章中,我们将涵盖以下主题:
-
理解 Redux 是什么
-
理解 ngrx 是什么以及它与 Redux 的关系
-
定义应用的状态
-
集成@ngrx/store 来管理状态
-
理解@ngrx/effects 是什么
-
集成副作用来帮助我们的状态管理
-
将我们的代码库从非活动状态转换为响应状态(Mike Ryan/Brandon Roberts^(TM))
理解 Redux 和集成@ngrx/store
Redux 是一个开源库,它将自己定义为 JavaScript 应用的预测状态容器。这些概念并不完全新颖,但细节是在 2015 年由 Dan Abramov 开发的,他受到了 Facebook 的 Flux 和函数式编程语言 Elm 的影响。它在 React 社区中迅速流行起来,因为 Facebook 广泛使用了它。
我们不想重新定义 Redux 是什么,所以我们将直接引用 Redux 仓库(github.com/reactjs/redux):
应用程序的全部状态都存储在单个store中的对象树内。
改变状态树的唯一方法是发出一个action,一个描述发生了什么的对象。
要指定动作如何转换状态树,你需要编写纯reducers。
就这样!
这个概念相当简单且非常出色。你向系统发出动作(这些是带有表示要传递数据的有效载荷的简单字符串类型对象),这些动作最终会击中 reducer(一个纯函数)来定义这些动作如何转换状态。
重要的是不要混淆 transform 和 mutate。Redux 的一个基本概念是所有状态都是不可变的;因此,每个 reducer 都是一个纯函数。
纯函数总是对相同的参数返回相同的结果。它的执行不依赖于整个系统的状态[en.wikipedia.org/wiki/Pure_function]。
因此,尽管 reducer 会转换状态,但它不会对其进行修改。
深入研究,已经对变化检测系统进行了工程研究,并且与深层嵌套属性上的对象比较检查相比,对象相等性/引用检查在速度上具有优势。我们不会深入探讨这一点的理由,但你的应用程序数据流的不可变性对你的性能微调有重大影响,尤其是在 Angular 方面。
除了性能提升外,Redux 的概念进一步增强了整个代码库的解耦,导致各种分散的依赖减少。有了描述我们应用程序各种交互的动作的强大功能,我们不再需要注入显式的服务依赖来执行其 API。相反,我们可以简单地发出动作,Redux 的原则将为我们传播和处理应用程序所需的功能,同时保持一个单一且可靠的真相来源。
@ngrx/store 是什么?
在 Angular 的重写早期(从 1.x 到 2.x+),一位核心团队成员在 Google 担任开发者倡导者,Rob Wormald,开发了ngrx/store,这是一个“由 RxJS 驱动的 Angular 应用程序的状态管理[系统],灵感来自 Redux”。这个短语中的关键点是术语“RxJS”。因此,ngrx这个名字是从将“ng”与 Angular 结合,以及从RxJS 中的“rx”中得来的。这个开源库迅速吸引了像 Mike Ryan、Brian Troncone 和 Brandon Roberts 这样才华横溢的贡献者,并迅速发展成为现代 Angular 应用程序的一个极其智能和强大的状态管理系统。
虽然它深受 Redux 的启发并利用了相同的概念,但在将 RxJS 作为系统连接的第一公民方面,它具有独特的不同之处。它使Observables在 Redux 的所有概念中完整地循环,使真正响应式的用户界面和应用程序成为可能。
如果这些概念对你来说都是新的,Brian Troncone 的详尽文章肯定会帮助你更好地理解,因为我们在这里无法涵盖 ngrx 的所有细节。请参阅这篇文章:
设计状态模型
在集成 ngrx 之前,首先思考一下应用程序中状态的各种方面以及它们可能属于哪个模块是个好主意。就我们的应用程序而言,这里有一个合理的起始列表(目前不是完整或详尽的):
-
CoreModule:-
user: any;与用户相关的状态:-
recentUsername: string;最近使用的成功用户名 -
current: any;认证用户(如果有)
-
-
-
MixerModule:-
mixer: any;与 mixer 相关的状态-
compositions: Array<IComposition>;用户保存的组成列表 -
activeComposition: CompositionModel;当前组成
-
-
-
PlayerModule:-
player: any;玩家状态的各个方面。-
playing: boolean; 音频是否正在播放。 -
duration: number; 播放的总时长。 -
completed: boolean; 是否播放已达到末尾并完成。这将有助于确定用户停止播放或播放器因到达末尾而自动停止之间的区别。 -
seeking: boolean; 是否正在播放寻求。
-
-
-
RecorderModule:recorder: RecordState; 通过枚举简单表示的录制状态
没有特定的模块,只是我们想要观察的状态:
-
ui: any; 用户界面状态trackListViewType: string; 轨迹列表当前活动的视图切换
这里的关键点不是担心第一次就完全正确。当你第一次构建应用程序时,很难知道精确的状态模型,并且它很可能会随着时间的推移而略有变化,这是可以接受的。
目前,我们的应用程序状态更易于理解,因为我们已经构建了一个工作中的应用程序,所以这稍微容易一些。通常,在构建应用程序之前规划这一点会更困难;然而,再次提醒,不要担心第一次就完全正确!你可以很容易地在以后重构和调整它。
让我们用 ngrx 将这个状态整合到我们的应用程序中。
安装和集成 @ngrx/store
我们首先想安装 @ngrx/store:
npm i @ngrx/store --save
我们现在可以通过 StoreModule 为我们的应用程序提供单个存储库。我们在 CoreModule 中定义这些初始状态切片,当应用程序启动时它们将可用,而每个懒加载的功能模块将在需要时添加自己的状态和减少器。
提供初始应用程序状态,排除任何懒加载模块状态
我们想首先定义初始应用程序状态,排除任何懒加载的功能模块状态。由于我们的 CoreModule 提供了 AuthService,它负责处理我们的用户,因此我们将 用户 切片视为我们应用程序初始状态的基本关键。
尤其是让我们首先定义我们的用户状态的结构。
创建 app/modules/core/states/user.state.ts:
export interface IUserState {
recentUsername?: string;
current?: any;
loginCanceled?: boolean;
}
export const userInitialState: IUserState = {};
我们的用户状态非常简单。它包含一个 recentUsername,代表最近成功认证的用户名字符串(如果用户注销并稍后返回登录,这很有用)。然后,我们有 当前,如果认证,将代表用户对象,如果没有,则为 null。我们还包含一个 loginCanceled 布尔值,因为我们推测,如果我们开始将状态作为分析数据报告,它可能对分析用户交互有用。
与认证相关的任何数据点都可能对我们应用程序的用户基础的理解至关重要。例如,了解是否要求认证来记录是否导致取消登录比注册更多,这可能会直接影响用户保留。
为了与本书中的方法保持一致,也创建 app/modules/core/states/index.ts:
export * from './user.state';
现在,让我们创建我们的用户操作;创建 app/modules/core/actions/user.action.ts:
import { Action } from '@ngrx/store';
import { IUserState } from '../states';
export namespace
UserActions {
const CATEGORY: string = 'User';
export interface IUserActions {
INIT:
string;
LOGIN: string;
LOGIN_SUCCESS: string;
LOGIN_CANCELED: string;
LOGOUT:
string;
UPDATED: string;
}
export const ActionTypes: IUserActions = {
INIT:
`${CATEGORY} Init`,
LOGIN: `${CATEGORY} Login`,
LOGIN_SUCCESS: `${CATEGORY} Login Success`,
LOGIN_CANCELED: `${CATEGORY} Login Canceled`,
LOGOUT: `${CATEGORY} Logout`,
UPDATED:
`${CATEGORY} Updated`
};
export class InitAction implements Action {
type =
ActionTypes.INIT;
payload = null;
}
export class LoginAction implements Action {
type
= ActionTypes.LOGIN;
constructor(public payload: { msg: string; usernameAttempt?: string}) { }
}
export class LoginSuccessAction implements Action {
type = ActionTypes.LOGIN_SUCCESS;
constructor
(public payload: any /*user object*/) { }
}
export class LoginCanceledAction implements Action {
type = ActionTypes.LOGIN_CANCELED;
constructor(public payload?: string /*last attempted username*/) { }
}
export class LogoutAction implements Action {
type = ActionTypes.LOGOUT;
payload =
null;
}
export class UpdatedAction implements Action {
type = ActionTypes.UPDATED;
constructor(public payload: IUserState) { }
}
export type Actions =
InitAction
|
LoginAction
| LoginSuccessAction
| LoginCanceledAction
| LogoutAction
|
UpdatedAction;
}
然后,按照我们的标准;创建 app/modules/core/actions/index.ts:
export * from './user.action';
好吧,现在这些动作有什么问题?!以下是我们定义的内容:
-
INIT: 当应用启动时立即初始化用户。换句话说,这个动作将用于检查持久性和在启动时将用户对象恢复到应用的状态中。 -
LOGIN: 开始登录序列。在我们的应用中,这将显示登录对话框。 -
LOGIN_SUCCESS: 由于登录是异步的,这个动作将在登录完成后分发。 -
LOGIN_CANCELED: 如果用户取消登录。 -
LOGOUT: 当用户注销时。 -
UPDATED: 我们将使用这个简单的动作来更新我们的用户状态。这通常不会直接分发,但将在我们稍后创建的 reducer 中使用。
你在这里看到的正式性提供了统一且强类型的结构。通过使用命名空间,我们能够通过名称唯一地识别这一组动作,即UserActions。这允许内部命名在许多其他我们将为懒加载模块状态创建的命名空间动作中保持一致,提供了一个很好的工作标准。CATEGORY是必要的,因为每个动作都必须是唯一的,不仅在这个动作集中,在整个应用中都必须是唯一的。接口帮助在使用我们的动作时提供良好的智能,同时提供类型安全。各种动作类有助于确保所有分发的动作都是新实例,并提供了一种强大的方式来强类型化我们的动作负载。这也使得我们的代码在未来易于重构。我们结构中的最后一个实用工具是底部的联合类型,它帮助我们的 reducer 确定它应该关注的适用动作。
说到那个 reducer,我们现在就创建它:app/modules/core/reducers/user.reducer.ts:
import { IUserState, userInitialState } from '../states/user.state';
import { UserActions } from
'../actions/user.action';
export function userReducer(
state: IUserState = userInitialState,
action: UserActions.Actions
): IUserState {
switch (action.type) {
case
UserActions.ActionTypes.UPDATED:
return Object.assign({}, state, action.payload);
default:
return state;
}
}
Reducer 非常简单。如前所述,它是一个纯函数,它接受现有的状态以及一个动作,并返回一个新的状态(除非是默认的起始情况,否则是一个新对象)。这保持了不可变性,并保持了事物的优雅。UPDATED 动作将是任何动作链中最后触发的动作,并最终改变用户状态。在这种情况下,我们将保持简单,并允许我们的 UPDATED 动作是唯一实际改变用户状态的动作。其他动作将设置一个链,最终在需要改变用户状态时触发 UPDATED。你当然可以根据我们的动作设置更多的情况来改变状态;然而,在我们的应用中,这将唯一改变用户状态的动作。
动作链? 我们所说的 动作链 是什么意思?!你可能想知道如果需要,我们如何将这些动作连接起来进行交互?
安装和集成 @ngrx/effects
不重新定义,让我们看看 @ngrx/effects 的描述,直接来自仓库 (github.com/ngrx/effects):
在 @ngrx/effects 中,效果是动作的来源。您使用 @Effect() 装饰器来提示服务上的哪些可观察者是动作源,并且 @ngrx/effects 会自动合并您的动作流,让您可以订阅它们到存储。
为了帮助您组合新的动作源,@ngrx/effects 导出一个动作可观察服务,该服务会发出您应用程序中发出的每个动作。
换句话说,我们可以通过效果将我们的动作链起来,以在我们的应用程序中提供强大的数据流组合。它们允许我们在动作发出和最终更改状态之前插入应该发生的行为。最常见的情况是处理 HTTP 请求和/或其他异步操作;然而,它们有许多有用的应用。
要使用,我们首先安装 @ngrx/effects:
npm i @ngrx/effects --save
现在,让我们看看我们的用户操作在效果链中的样子。
然而,为了保持命名结构的一致性,让我们将 auth.service.ts 重命名为 user.service.ts。当我们有一个在整个范围内一致的命名标准时,这有助于我们。
现在,创建 app/modules/core/effects/user.effect.ts:
// angular
import { Injectable } from '@angular/core';
// libs
import { Store, Action } from
'@ngrx/store';
import { Effect, Actions } from '@ngrx/effects';
import { Observable } from
'rxjs/Observable';
// module
import { LogService } from '../../core/services/log.service';
import {
DatabaseService } from '../services/database.service';
import { UserService } from '../services/user.service';
import { UserActions } from '../actions/user.action';
@Injectable()
export class UserEffects {
@Effect() init$: Observable<Action> = this.actions$
.ofType(UserActions.ActionTypes.INIT)
.startWith(new UserActions.InitAction())
.map(action => {
const current =
this.databaseService
.getItem(DatabaseService.KEYS.currentUser);
const recentUsername =
this.databaseService
.getItem(DatabaseService.KEYS.recentUsername);
this.log.debug(`Current user:
`, current || 'Unauthenticated');
return new UserActions.UpdatedAction({ current, recentUsername });
});
@Effect() login$: Observable<Action> = this.actions$
.ofType
(UserActions.ActionTypes.LOGIN)
.withLatestFrom(this.store)
.switchMap(([action, state]) => {
const current = state.user.current;
if (current) {
// user already logged in, just fire
updated
return Observable.of(
new UserActions.UpdatedAction({ current })
);
} else {
this._loginPromptMsg = action.payload.msg;
const usernameAttempt =
action.payload.usernameAttempt
|| state.user.recentUsername;
return
Observable.fromPromise(
this.userService.promptLogin(this._loginPromptMsg,
usernameAttempt)
)
.map(user => (new UserActions.LoginSuccessAction(user)))
.catch
(usernameAttempt => Observable.of(
new UserActions.LoginCanceledAction(usernameAttempt)
));
}
});
@Effect() loginSuccess$: Observable<Action> = this.actions$
.ofType(UserActions.ActionTypes.LOGIN_SUCCESS)
.map((action) => {
const user = action.payload;
const recentUsername = user.username;
this.databaseService
.setItem
(DatabaseService.KEYS.currentUser, user);
this.databaseService
.setItem
(DatabaseService.KEYS.recentUsername, recentUsername);
this._loginPromptMsg = null; // clear, no longer
needed
return (new UserActions.UpdatedAction({
current: user,
recentUsername,
loginCanceled: false
}));
});
@Effect() loginCancel$ = this.actions$
.ofType(UserActions.ActionTypes.LOGIN_CANCELED)
.map(action => {
const usernameAttempt =
action.payload;
if (usernameAttempt) {
// reinitiate sequence, login failed, retry
return new UserActions.LoginAction({
msg: this._loginPromptMsg,
usernameAttempt
});
} else {
return new UserActions.UpdatedAction({
loginCanceled: true
});
}
});
@Effect() logout$: Observable<Action> = this.actions$
.ofType(UserActions.ActionTypes.LOGOUT)
.map(action => {
this.databaseService
.removeItem(DatabaseService.KEYS.currentUser);
return new UserActions.UpdatedAction({
current:
null
});
});
private _loginPromptMsg: string;
constructor(
private
store: Store<any>,
private actions$: Actions,
private log: LogService,
private
databaseService: DatabaseService,
private userService: UserService
) { }
}
我们已经澄清了我们关于 UserService 的数据流意图,并将责任委托给这个效果链。这使我们能够以清晰和一致的方式,以及极大的灵活性和能力来组合我们的数据流。例如,我们的 InitAction 链现在允许我们通过以下方式自动初始化用户:
.startWith(new UserActions.InitAction())
之前,我们在服务构造函数内部调用了一个私有方法--this._init()--但是,一旦模块启动,我们不再需要这样的显式调用,因为效果会被运行并排队。.startWith 操作符将导致可观察者一次性触发(在模块创建时),允许在应用程序初始化的特定有利时机执行初始化序列。我们的初始化序列与我们之前在服务中处理的是相同的;然而,这次我们考虑了新的 recentUsername 持久值(如果存在)。然后我们以 UserActions.UpdatedAction 结束初始化序列:
new UserActions.UpdatedAction({ current, recentUsername })
注意,没有效果链连接到 UserActions.ActionTypes.UPDATED。这是因为当 Action 发生时,不应该发生任何副作用。由于没有更多的副作用,可观察序列最终会进入具有 switch 语句以处理它的还原器:
export function userReducer(
state: IUserState = userInitialState,
action: UserActions.Actions
):
IUserState {
switch (action.type) {
case UserActions.ActionTypes.UPDATED:
return Object.assign({}, state, action.payload);
default:
return state;
}
}
这将获取有效负载(其类型为用户状态形状,IUserState),并覆盖现有状态中的值以返回一个新的用户状态。重要的是,Object.assign 允许源对象中任何现有的值不被覆盖,除非显式由传入的有效负载定义。这允许只有新的传入有效负载值反映在我们的状态上,同时仍然保持现有值。
我们UserEffect链的其余部分相当直观。主要的是,它处理了服务之前处理的大部分内容,除了提示登录对话框,效果链正在使用服务方法来完成。然而,值得一提的是,我们可以完全移除这个服务,因为promptLogin方法的内容现在可以直接在我们的效果中执行。
当决定是否在效果或指定服务中处理更多逻辑时,这实际上取决于个人偏好和/或可扩展性。如果你有相当长的服务逻辑,并且在与效果一起工作时需要处理多个方法,创建一个指定服务将非常有帮助。你可以将更多功能扩展到服务中,而不会稀释你的效果链的清晰度。
最后,有了专门的服务和更多的逻辑,单元测试将会更容易。在这种情况下,我们的逻辑相当简单;然而,为了示例目的以及最佳实践,我们还将保留UserService。
说到这里,让我们看看我们的UserService现在看起来有多简化
在app/modules/core/services/user.service.ts中:
// angular
import { Injectable } from '@angular/core';
// app
import { DialogService } from
'./dialog.service';
@Injectable()
export class UserService {
constructor(
private dialogService: DialogService
) { }
public promptLogin(msg: string, username: string = ''):
Promise<any> {
return new Promise((resolve, reject) => {
this.dialogService.login(msg,
username, '').then((input) => {
if (input.result) { // result will be false when canceled
if
(input.userName && input.userName.indexOf('@') > -1) {
if (input.password) {
resolve({
username: input.userName,
password: input.password
});
} else {
this.dialogService.alert('You must provide a password.')
.then(reject.bind(this, input.userName));
}
} else {
// reject,
passing userName back to try again
this.dialogService.alert('You must provide a valid email
address.')
.then(reject.bind(this, input.userName));
}
} else {
// user chose cancel
reject(false);
}
});
});
}
}
现在要干净多了。好吧,那么我们如何让我们的应用知道所有这些新功能呢?
首先,让我们遵循我们的一个标准,为整个核心模块添加一个索引;添加app/modules/core/index.ts:
export * from './actions';
export * from './effects';
export * from './reducers';
export * from
'./services';
export * from './states';
export * from './core.module';
我们简单地导出核心模块现在提供的所有好东西,包括模块本身。
然后,打开app/modules/core/core.module.ts来完成我们的连接:
// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module';
import {
NativeScriptFormsModule } from 'nativescript-angular/forms';
import { NativeScriptHttpModule } from 'nativescript-
angular/http';
// angular
import { NgModule, Optional, SkipSelf } from '@angular/core';
// libs
import { StoreModule } from '@ngrx/store';
import {
EffectsModule } from '@ngrx/effects';
// app
import { UserEffects } from
'./effects';
import { userReducer } from './reducers';
import { PROVIDERS } from
'./services';
import { PROVIDERS as MIXER_PROVIDERS } from '../mixer/services';
import { PROVIDERS as
PLAYER_PROVIDERS } from '../player/services';
const MODULES: any[] = [
NativeScriptModule,
NativeScriptFormsModule,
NativeScriptHttpModule
];
@NgModule({
imports: [
...MODULES,
// define core app state
StoreModule.forRoot({
user:
userReducer
}),
// register core effects
EffectsModule.forRoot([
UserEffects
]),
],
providers: [
...PROVIDERS,
...MIXER_PROVIDERS,
...PLAYER_PROVIDERS
],
exports: [
...MODULES
]
})
export class CoreModule {
constructor (@Optional() @SkipSelf() parentModule: CoreModule)
{
if (parentModule) {
throw new Error(
'CoreModule is already loaded. Import it in the
AppModule only');
}
}
}
在这里,我们确保定义我们的user状态键以使用userReducer,并将其与StoreModule注册。然后我们调用EffectsModule.forRoot(),使用一系列单例效果提供者进行注册,如我们的UserEffects。
现在,让我们看看这如何改进代码库的其余部分,因为我们无疑在几个地方注入了UserService(之前命名为AuthService)。
我们之前在AppComponent中注入AuthService,以确保 Angular 的依赖注入在应用启动时尽早构建它,创建应用所需的单例。然而,随着UserEffects现在在启动时自动运行,它反过来注入(现在重命名为)UserService,我们不再需要这个相当愚蠢的必要性,因此,我们可以更新AppComponent如下:
@Component({
moduleId: module.id,
selector: 'my-app',
templateUrl: 'app.component.html',
})
export class AppComponent {
constructor() { // we removed AuthService (UserService) here
一举一动,我们的代码库现在变得更聪明、更精简。让我们继续看看我们的 ngrx 集成带来的其他好处。
打开app/auth-guard.service.ts,我们现在可以做出以下简化:
import { Injectable } from '@angular/core';
import { Route, CanActivate, CanLoad } from
'@angular/router';
// libs
import { Store } from '@ngrx/store';
import { Subscription } from 'rxjs/Subscription';
// app
import { IUserState,
UserActions } from '../modules/core';
@Injectable()
export class AuthGuard implements
CanActivate, CanLoad {
private _sub: Subscription;
constructor(private
store: Store<any>) { }
canActivate(): Promise<boolean> {
return new Promise
((resolve, reject) => {
this.store.dispatch(
new
UserActions.LoginAction({ msg: 'Authenticate to record.' })
);
this._sub = this.store.select(s => s.user).subscribe((state:
IUserState) => {
if (state.current) {
this._reset();
resolve
(true);
} else if (state.loginCanceled) {
this._reset
();
resolve(false);
}
});
});
}
canLoad(route: Route): Promise<boolean> {
// reuse same
logic to activate
return this.canActivate();
}
private _reset() {
if (this._sub) this._sub.unsubscribe();
}
}
当激活/record路由时,由于我们需要一个经过身份验证的用户来使用录制功能,我们每次都会分发LoginAction。我们的登录效果链正确处理了用户已经经过身份验证的情况,所以我们只需要设置我们的状态订阅以相应地做出反应。
Ngrx 是灵活的,你如何设置你的动作和效果链完全取决于你。
提供懒加载的功能模块状态
我们现在可以将可扩展的 ngrx 结构构建到我们的各个功能模块中,这将提供状态。从 MixerModule 开始,让我们用以下内容修改 app/modules/mixer/mixer.module.ts:
...
// libs
import { StoreModule } from '@ngrx/store';
...
@NgModule({
imports: [
PlayerModule,
SharedModule,
NativeScriptRouterModule.forChild(routes),
StoreModule.forFeature('mixerModule', {
mixer: {} // TODO: add reducer when ready
})
],
...
})
export class MixerModule { }
在这里,我们定义了 MixerModule 状态将提供的内容。现在,让我们定义其形状;创建
app/modules/mixer/states/mixer.state.ts:
import { IComposition } from '../../shared/models';
export interface IMixerState {
compositions?:
Array<IComposition>;
activeComposition?: any;
}
export const mixerInitialState: IMixerState =
{
compositions: []
};
为了保持本书中的一致性,也创建 app/modules/mixer/states/index.ts:
export * from './mixer.state';
现在,让我们创建我们的混音动作;创建 app/modules/mixer/actions/mixer.action.ts:
import { ViewContainerRef } from '@angular/core';
import { Action } from '@ngrx/store';
import {
IMixerState } from '../states';
import { IComposition, CompositionModel, TrackModel } from '../../shared/models';
export namespace MixerActions {
const CATEGORY: string = 'Mixer';
export interface
IMixerActions {
INIT: string;
ADD: string;
EDIT: string;
SAVE: string;
CANCEL:
string;
SELECT: string;
OPEN_RECORD: string;
UPDATE: string;
UPDATED: string;
}
export const ActionTypes: IMixerActions = {
INIT: `${CATEGORY} Init`,
ADD: `${CATEGORY}
Add`,
EDIT: `${CATEGORY} Edit`,
SAVE: `${CATEGORY} Save`,
CANCEL: `${CATEGORY} Cancel`,
SELECT: `${CATEGORY} Select`,
OPEN_RECORD: `${CATEGORY} Open Record`,
UPDATE: `${CATEGORY} Update`,
UPDATED: `${CATEGORY} Updated`,
};
export class InitAction implements Action {
type =
ActionTypes.INIT;
payload = null;
}
export class AddAction implements Action {
type =
ActionTypes.ADD;
payload = null;
}
export class EditAction implements Action {
type =
ActionTypes.EDIT;
constructor(public payload: CompositionModel) { }
}
export class SaveAction
implements Action {
type = ActionTypes.SAVE;
constructor(public payload?: Array<CompositionModel>)
{ }
}
export class CancelAction implements Action {
type = ActionTypes.CANCEL;
payload = null;
}
export class SelectAction implements Action {
type = ActionTypes.SELECT;
constructor(public payload: CompositionModel) { }
}
export class OpenRecordAction implements
Action {
type = ActionTypes.OPEN_RECORD;
constructor(public payload?: {
vcRef:
ViewContainerRef, track?: TrackModel
}) { }
}
export class UpdateAction implements Action
{
type = ActionTypes.UPDATE;
constructor(public payload: CompositionModel) { }
}
export class UpdatedAction implements Action {
type = ActionTypes.UPDATED;
constructor(public payload:
IMixerState) { }
}
export type Actions =
InitAction
| AddAction
|
EditAction
| SaveAction
| CancelAction
| SelectAction
| OpenRecordAction
|
UpdateAction
| UpdatedAction;
}
与我们的 UserActions 类似,我们也将使用一个 INIT 动作来自动使用用户保存的合成(或我们的示例演示合成)初始化此状态。以下是简要概述:
-
INIT: 在应用启动时立即初始化混音。就像我们使用UserActions一样,这个动作将用于检查持久性并在启动时将任何用户保存的合成恢复到混音状态。 -
ADD: 显示添加新合成对话框。 -
EDIT: 通过提示对话框编辑合成的名称。 -
SAVE: 保存合成。 -
CANCEL: 通用动作,用于取消任何效果链。 -
SELECT: 选择一个合成。我们将使用此动作来驱动 Angular 路由到主选定的合成视图。 -
OPEN_RECORD: 处理打开录音视图的准备,包括检查认证、暂停播放(如果正在播放)以及在模态中打开或将其路由到。 -
UPDATE: 启动对合成的更新。 -
UPDATED: 这通常不会直接分发,但会在效果序列的末尾使用,以便还原器最终更改混音状态。
现在,我们可以创建一个与我们的用户还原器类似的还原器:
import { IMixerState, mixerInitialState } from '../states';
import { MixerActions } from '../actions';
export function mixerReducer(
state: IMixerState = mixerInitialState,
action: MixerActions.Actions
):
IMixerState {
switch (action.type) {
case MixerActions.ActionTypes.UPDATED:
return
Object.assign({}, state, action.payload);
default:
return state;
}
}
然后,让我们在 app/modules/mixer/effects/mixer.effect.ts 中创建我们的 MixerEffects:
// angular
import { Injectable, ViewContainerRef } from '@angular/core';
// nativescript
import { RouterExtensions } from 'nativescript-angular/router';
// libs
import { Store, Action } from
'@ngrx/store';
import { Effect, Actions } from '@ngrx/effects';
import { Observable } from
'rxjs/Observable';
// module
import { CompositionModel } from '../../shared/models';
import {
PlayerActions } from '../../player/actions';
import { RecordComponent } from
'../../recorder/components/record.component';
import { MixerService } from '../services/mixer.service';
import {
MixerActions } from '../actions';
@Injectable()
export class MixerEffects {
@Effect()
init$: Observable<Action> = this.actions$
.ofType(MixerActions.ActionTypes.INIT)
.startWith(new
MixerActions.InitAction())
.map(action =>
new MixerActions.UpdatedAction({
compositions: this.mixerService.hydrate(
this.mixerService.savedCompositions()
||
this.mixerService.demoComposition())
})
);
@Effect() add$: Observable<Action> =
this.actions$
.ofType(MixerActions.ActionTypes.ADD)
.withLatestFrom(this.store)
.switchMap
(([action, state]) =>
Observable.fromPromise(this.mixerService.add())
.map(value => {
if (value.result) {
let compositions = [...state.mixerModule.mixer.compositions];
let composition = new CompositionModel({
id: compositions.length + 1,
name:
value.text,
order: compositions.length // next one in line
});
compositions.push(composition);
// persist changes
return new MixerActions.SaveAction
(compositions);
} else {
return new MixerActions.CancelAction();
}
})
);
@Effect() edit$: Observable<Action> = this.actions$
.ofType
(MixerActions.ActionTypes.EDIT)
.withLatestFrom(this.store)
.switchMap(([action, state]) => {
const composition = action.payload;
return Observable.fromPromise(this.mixerService.edit(composition.name))
.map(value => {
if (value.result) {
let compositions =
[...state.mixerModule.mixer.compositions];
for (let i = 0; i < compositions.length; i++) {
if (compositions[i].id === composition.id) {
compositions[i].name = value.text;
break;
}
}
// persist changes
return new
MixerActions.SaveAction(compositions);
} else {
return new MixerActions.CancelAction();
}
})
});
@Effect() update$: Observable<Action> = this.actions
$
.ofType(MixerActions.ActionTypes.UPDATE)
.withLatestFrom(this.store)
.map(([action, state])
=> {
let compositions = [...state.mixerModule.mixer.compositions];
const composition =
action.payload;
for (let i = 0; i < compositions.length; i++) {
if (compositions[i].id ===
composition.id) {
compositions[i] = composition;
break;
}
}
// persist changes
return new MixerActions.SaveAction(compositions);
});
@Effect()
select$: Observable<Action> = this.actions$
.ofType(MixerActions.ActionTypes.SELECT)
.map(action
=> {
this.router.navigate(['/mixer', action.payload.id]);
return new MixerActions.UpdatedAction
({
activeComposition: action.payload
});
});
@Effect({ dispatch: false })
openRecord$: Observable<Action> = this.actions$
.ofType(MixerActions.ActionTypes.OPEN_RECORD)
.withLatestFrom(this.store)
// always pause/reset playback before handling
.do(action => new
PlayerActions.PauseAction(0))
.map(([action, state]) => {
if
(state.mixerModule.mixer.activeComposition &&
state.mixerModule.mixer.activeComposition.tracks.length)
{
// show record modal but check authentication
if (state.user.current) {
if
(action.payload.track) {
// rerecording
this.dialogService
.confirm
(
'Are you sure you want to re-record this track?'
).then((ok) => {
if (ok)
this._showRecordModal(
action.payload.vcRef,
action.payload.track
);
});
} else {
this._showRecordModal(action.payload.vcRef);
}
} else {
this.store.dispatch(
new UserActions.LoginToRecordAction(action.payload));
}
} else {
//
navigate to it
this.router.navigate(['/record']);
}
return action;
});
@Effect() save$: Observable<Action> = this.actions$
.ofType(MixerActions.ActionTypes.SAVE)
.withLatestFrom(this.store)
.map(([action, state]) => {
const compositions = action.payload ||
state.mixerModule.mixer.compositions;
// persist
this.mixerService.save
(compositions);
return new MixerActions.UpdatedAction({ compositions });
});
constructor
(
private store: Store<any>,
private actions$: Actions,
private router:
RouterExtensions,
private dialogService: DialogService,
private mixerService: MixerService
) { }
private _showRecordModal(vcRef: ViewContainerRef, track?: TrackModel) {
let context: any = {
isModal: true };
if (track) {
// re-recording track
context.track = track;
}
this.dialogService.openModal(
RecordComponent,
vcRef,
context,
'./modules/recorder/recorder.module#RecorderModule'
);
}
}
可能最有趣的效果是 openRecord$ 链。我们使用 @Effect({ dispatch: false }) 来指示它不应该在末尾分发任何动作,因为我们正在使用它直接执行工作,例如检查用户是否已认证或 activeComposition 是否包含轨道,以便有条件地在模态中打开记录视图或将其作为路由。我们使用了另一个操作符:
.do(action => new PlayerActions.PauseAction(0))
这允许我们插入任意动作而不影响事件序列。在这种情况下,我们确保当用户尝试打开记录视图时播放总是暂停(因为他们在播放时可以尝试打开记录视图)。我们通过这个链提供了一些更高级的使用选项,只是为了展示什么是可能的。我们也在向前迈进,因为我们还没有展示 PlayerActions 的创建;然而,我们将在本章中只展示几个亮点。
通过这个效果链,我们可以简化我们的 MixerService,如下所示:
...
@Injectable()
export class MixerService {
...
public add() {
return
this.dialogService.prompt('Composition name:');
}
public edit(name: string) {
return this.dialogService.prompt('Edit name:', name);
}
...
我们简化了服务逻辑,将大部分结果处理工作留在了效果链中。你可能会决定在服务中保留更多逻辑,并保持效果链更简单;然而,我们设计这个设置作为一个示例,以展示 ngrx 的更多灵活设置。
为了完成我们的懒加载状态处理,确保这些效果被运行;当 MixerModule 加载时,我们可以对这些模块进行调整:
...
// libs
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from
'@ngrx/effects';
...
import { MixerEffects } from './effects';
import
{ mixerReducer } from './reducers';
@NgModule({
imports: [
PlayerModule,
SharedModule,
NativeScriptRouterModule.forChild(routes),
// mixer state
StoreModule.forFeature
('mixerModule', {
mixer: mixerReducer
}),
// mixer effects
EffectsModule.forFeature([
MixerEffects
])
],
...
})
export
class MixerModule { }
现在,让我们看看这是如何改善我们的组件处理的,从 app/modules/mixer/components/mixer.component.ts 开始:
...
export class MixerComponent implements OnInit, OnDestroy {
...
constructor( private store: Store<any>,
private vcRef: ViewContainerRef ) { }
ngOnInit()
{
this._sub = this.store.select(s => s.mixerModule.mixer)
.subscribe
((state: IMixerState) => {
this.composition = state.activeComposition;
});
}
public record(track?: TrackModel) {
this.store.dispatch(new MixerActions.OpenRecordAction({
vcRef: this.vcRef,
track
}));
}
ngOnDestroy() {
this._sub.unsubscribe();
}
}
这次,在 ngOnInit 中,我们只是将组件设置为对混合器的状态做出响应,将组成设置为 activeComposition。这保证它总是用户当前选中并正在工作的任何组成。我们在 record 方法内部触发 OpenRecordAction,传递适当的 ViewContainerRef 和一个跟踪,如果用户正在重新录制。
接下来是对 app/modules/mixer/components/mix-list.component.ts 的简化:
// angular
import { Component } from '@angular/core';
// libs
import { Store } from
'@ngrx/store';
import { Observable } from 'rxjs/Observable';
// app
import { MixerActions } from '../actions';
import { IMixerState } from '../states';
@Component({
moduleId: module.id,
selector: 'mix-list',
templateUrl: 'mix-list.component.html'
})
export class MixListComponent {
public mixer$: Observable<IMixerState>;
constructor(private store: Store<any>) {
this.mixer$ = store.select(s => s.mixerModule.mixer);
}
public add() {
this.store.dispatch(new MixerActions.AddAction());
}
public edit(composition) {
this.store.dispatch(new MixerActions.EditAction(composition));
}
public select(composition) {
this.store.dispatch(new MixerActions.SelectAction(composition));
}
}
我们已经移除了 MixerService 注入,并通过设置状态可观察对象--mixer$--使其变得响应式,并集成了我们的 MixerActions。这使得组件变得更轻量,更容易测试和维护,因为它不再有对 MixerService 的显式依赖,之前它还被用于视图绑定。如果我们查看视图,我们现在可以利用 Angular 的异步管道通过状态访问用户保存的组成:
<ActionBar title="Compositions" class="action-bar">
<ActionItem (tap)="add()"
ios.position="right">
<Button text="New" class="action-item"></Button>
</ActionItem>
</ActionBar>
<ListView [items]="(mixer$ | async)?.compositions |
orderBy: 'order'" class="list-group">
<ng-template let-composition="item">
<GridLayout
rows="auto" columns="100,*,auto" class="list-group-item">
<Button text="Edit" (tap)="edit(composition)"
row="0" col="0"></Button>
<Label [text]="composition.name" (tap)="select(composition)" row="0"
col="1" class="h2"></Label>
<Label [text]="composition.tracks.length" row="0" col="2" class="text-
right"></Label>
</GridLayout>
</ng-template>
</ListView>
根据官方文档:Angular 的异步管道订阅一个可观察对象或 Promise,并返回它发出的最新值。当有新值发出时,异步管道标记组件以检查更改。当组件被销毁时,异步管道会自动取消订阅,以避免潜在的内存泄漏。
这确实非常出色且极其方便,使我们能够创建高度可维护和灵活的响应式组件。
检查代码!自行探索更多
由于我们之前看到的大部分内容都是应用于我们代码库其余部分的相同原则,为了避免进一步增加本书的篇幅,我们邀请你探索本书附带的代码仓库中同一章节分支上的 ngrx 集成。
通过查看实际代码、运行它,甚至逐步执行它,希望你能对 ngrx 如何融入你的应用以及它能够带来的许多优势有一个坚实的理解。
社区很幸运有像 Rob Wormald、Mike Ryan、Brian Troncone、Brandon Roberts 等成员,他们帮助使 ngrx 的使用变得如此愉快,因此向所有贡献者表示衷心的感谢!
摘要
希望你已经开始在整合 ngrx 的过程中看到数据流简化和清晰化的模式。它通过为各种可能发生(从懒加载模块或非懒加载模块)的动作提供一致的效果链,帮助减少了代码量,并改善了数据流。通过减少管理显式注入依赖项的开销,并转而依赖 Store 和 Actions 来启动适当的工作,我们正在提高我们应用的维护性和可扩展性。除此之外,它还在为有效的可测试性铺平道路,这将在第十二章 单元测试中进行介绍。
本章强调了将 NativeScript 与 Angular 结合使用时的额外优势,通过开放与丰富库如 ngrx 的集成潜力,以改善我们的应用架构和数据流。
这已经期待已久,我们对即将到来的第十一章 Polish with SASS 感到无比兴奋。终于,我们准备好对我们的应用进行打磨,让它散发出那独特的火花!
第十一章:使用 SASS 进行抛光
在上一章中介绍了一些底层管道改进和 ngrx 状态管理之后,现在终于到了抛光此应用以改善其整体外观和感觉的时候了。样式的时机完全取决于您的发展流程,而且我们通常喜欢边走边抛光。在这本书中,我们选择避免将 CSS 抛光与功能开发混合,以保持概念更加集中。然而,现在我们已经到了这里,我们无法更兴奋地看到我们的应用获得那个漂亮的外观。
由于标准 CSS 随着样式的增长而变得难以维护,我们将集成 SASS 以提供帮助。实际上,我们将利用由帮助提出 NativeScript 品牌名称的那个人,Todd Anglin,开发的社区插件。
在本章中,我们将涵盖以下主题:
-
将 SASS 集成到您的应用中
-
构建核心主题 SASS 设置的最佳实践
-
构建可扩展的样式设置,以最大化 iOS 和 Android 之间的样式重用
-
使用字体图标,例如 Font Awesome,使用 nativescript-ngx-fonticon 插件
使用 SASS 进行抛光
SASS 是世界上最成熟、最稳定、功能最强大的专业级 CSS 扩展语言... Sass 是 CSS 的扩展,它为基本语言增添了力量和优雅。它允许您使用变量、嵌套规则、混入、内联导入等,所有这些都具有完全兼容 CSS 的语法。SASS 有助于保持大型样式表的良好组织,并使小型样式表快速运行。
听起来不错?当然。
我们首先想要安装由 Todd Anglin 发布的社区插件:
npm install nativescript-dev-sass --save-dev
此插件将设置一个钩子,在构建您的应用之前自动将 SASS 编译为 CSS,因此您无需担心安装任何其他构建工具。
现在,我们希望以特定的方式组织我们的 SASS 源文件,这样不仅有助于维护 iOS 和 Android 之间的共享样式,而且还可以轻松地进行平台特定的调整/覆盖。默认安装的核心主题(nativescript-theme-core)附带了一套完整的 SASS 源文件,这些文件已经组织得很好,可以帮助您轻松地在它之上构建自定义 SASS。
让我们从重命名以下内容开始:
-
app.ios.css改为app.ios.**scss** -
app.android.css改为app.android.**scss**
然后对于 app.ios.scss 的内容:
@import 'style/common';
@import 'style/ios-overrides';
对于 app.android.scss:
@import 'style/common';
@import 'style/android-overrides';
好的,现在,让我们创建那个 style 文件夹,包含各种部分 SASS 导入文件,以帮助我们设置,从变量开始:
style/_variables.scss:
// baseline theme colors
@import '~nativescript-theme-core/scss/dark';
// define our own variables or simply override those from the light set here...
实际上,您可以根据许多不同的皮肤/颜色来构建您应用的样式表。查看以下文档部分以了解可用内容:docs.nativescript.org/ui/theme#color-schemes。对于我们的应用,我们将基于 dark 皮肤来设置我们的颜色。
现在,创建一个公共共享的 SASS 文件,这是共享样式的大部分内容将放置的地方。实际上,我们将把在 common.css 文件中定义的所有内容都放在这里(之后,删除我们之前拥有的 common.css 文件):
style/_common.scss:
// customized variables
@import 'variables';
// theme standard rulesets
@import '~nativescript-theme-core/scss/index';
// all the styles we had created previously in common.css migrated into here:
.action-bar {
background-color:#101B2E; // we can now convert this to a SASS variable
}
Page {
background-color:#101B2E; // we can now convert this to a SASS variable
}
ListView {
separator-color: transparent;
}
.track-name-float {
color: RGBA(136, 135, 3, .5); // we can now convert this to a SASS variable
}
.slider.fader {
background-color: #000; // we could actually use $black from core theme now
}
.list-group .muted {
opacity:.2;
}
这使用了我们刚刚创建的变量文件,它使我们能够提供从核心主题中提供的自己的基线变量以及我们对颜色的自定义调整。
现在,创建 Android 覆盖文件以备不时之需:
styles/_android-overrides.scss:
@import '~nativescript-theme-core/scss/platforms/index.android';
// our custom Android overrides can go here if needed...
这将导入核心主题的 Android 覆盖,同时仍然允许我们在需要时应用我们自己的自定义覆盖。
我们现在可以为 iOS 做同样的事情:
styles/_ios-overrides.scss:
@import '~nativescript-theme-core/scss/platforms/index.ios';
// our custom iOS overrides can go here if needed...
最后,我们现在可以将任何特定组件的 .css 文件转换为 **.scss**。我们有一个组件使用它自己的定义样式,record.component.css。只需将其重命名为 **.scss**。NativeScript SASS 插件会自动编译它找到的任何嵌套 .scss 文件。
你可能还想做两件事:
除了在您的 IDE 中隐藏 .css 和 .js 文件外,还要忽略所有 *.css 文件从 git 中。
你不希望在未来与其他开发人员发生合并冲突,因为你的 .css 文件将在每次构建应用时通过 SASS 编译新生成。
将以下内容添加到您的 .gitignore 文件中:
*.js
*.map
*.css
hooks
lib
node_modules
/platforms
然后,为了在 VS Code 中隐藏 .js 和 .css 文件,我们可以这样做:
{
"files.exclude": {
"**/app/**/*.css": {
"when": "$(basename).scss"
},
"**/app/**/*.js": {
"when": "$(basename).ts"
},
"**/hooks": true,
"**/node_modules": true,
"platforms": true
}
}
下面是现在结构应该看起来像的截图:

使用 nativescript-ngx-fonticon 插件使用字体图标
真的很好,我们可以用漂亮的清晰图标替换所有那些无聊的标签按钮,让我们来做这件事。NativeScript 提供了使用按钮、标签和更多文本属性上的 Unicode 值来支持自定义字体图标的功能。然而,使用 Angular,我们可以利用另一个巧妙的插件,它将提供一个很好的 Pipe,允许我们使用字体名称以提高使用便利性和清晰度。
安装以下插件:
npm install nativescript-ngx-fonticon --save
对于这个应用,我们将使用多功能的 font-awesome 图标,所以让我们从这里从官方网站下载这个包:fontawesome.io/。
在其中,我们将找到所需的字体文件和 css。我们首先将 fontawesome-webfont.ttf 文件复制到我们在 app 文件夹中创建的 new fonts 文件夹中。NativeScript 在构建应用时会查找该文件夹中的任何自定义字体文件:

现在,我们想要将 css/font-awesome.css 文件也复制到我们的应用文件夹中。我们可以将其放置在文件夹的根目录或子文件夹中。我们将创建一个 assets 文件夹来存放这个文件以及未来可能的其他类似项目。
然而,我们需要稍微修改这个 .css 文件。nativescript-ngx-fonticon 插件只与字体类名一起工作,不需要字体 Awesome 提供的任何实用类。因此,我们需要修改它,移除顶部的大量内容,使其看起来像这样:

你可以在以下视频中了解更多信息:www.youtube.com/watch?v=qb2sk0XXQDw。
我们还设置了 git 忽略所有 *.css 文件;然而,我们不想忽略以下文件:
*.js
*.map
*.css
!app/assets/font-awesome.css
hooks
lib
node_modules
/platforms
现在,我们准备设置插件。由于这应该是我们应用核心设置的一部分,我们将使用我们的插件配置修改 app/modules/core/core.module:
...
import { TNSFontIconModule } from 'nativescript-ngx-fonticon';
...
@NgModule({
imports: [
...MODULES,
// font icons
TNSFontIconModule.forRoot({
'fa': './assets/font-awesome.css'
}),
...
],
...
})
export class CoreModule {
由于该模块依赖于 TNSFontIconService,让我们修改我们的根组件以注入它,确保 Angular 的 DI 为我们实例化单例以供应用全局使用。
app/app.component.ts:
...
// libs
import { TNSFontIconService } from 'nativescript-ngx-fonticon';
@Component({
moduleId: module.id,
selector: 'my-app',
templateUrl: 'app.component.html'
})
export class AppComponent {
constructor(private fontIconService: TNSFontIconService) {
...
接下来,我们想要确保 fonticon 管道对任何我们的视图组件都是可访问的,所以让我们从我们的 SharedModule 在 app/modules/shared/shared.module.ts 中导入和导出模块:
...
// libs
import { TNSFontIconModule } from 'nativescript-ngx-fonticon';
...
@NgModule({
imports: [
NativeScriptModule,
NativeScriptRouterModule,
NativeScriptFormsModule,
TNSFontIconModule
],
...
exports: [
...
TNSFontIconModule, ...PIPES ]
})
export class SharedModule {}
最后,我们需要一个类来指定哪些组件应该使用字体 Awesome 来渲染自己。由于这个类将在 iOS/Android 之间共享,修改 app/style/_common.scss 如下:
// customized variables
@import 'variables';
// theme standard rulesets
@import '~nativescript-theme-core/scss/index';
.fa {
font-family: 'FontAwesome', fontawesome-webfont;
font-size: 25;
}
我们定义两个字体家族的原因是 iOS 和 Android 之间的差异。Android 使用文件的名称作为字体家族的名称(在本例中为 fontawesome-webfont.ttf)。而 iOS 使用实际的字体名称;示例可以在 github.com/FortAwesome/Font-Awesome/blob/master/css/font-awesome.css#L8 找到。如果你想要,你可以将字体文件名重命名为 FontAwesome.ttf 以使用 font-family: FontAwesome。你可以在 fluentreports.com/blog/?p=176 了解更多。
现在,让我们尝试在我们的应用中渲染图标的新功能。打开 app/modules/mixer/components/mix-list.component.html:
<ActionBar title="Compositions" class="action-bar">
<ActionItem (tap)="add()" ios.position="right">
<Button [text]="'fa-plus' | fonticon" class="fa action-item"></Button>
</ActionItem>
</ActionBar>
<ListView [items]="(mixer$ | async)?.compositions | orderBy: 'order'" class="list-group">
<ng-template let-composition="item">
<GridLayout rows="auto" columns="100,*,auto" class="list-group-item">
<Button [text]="'fa-pencil' | fonticon" (tap)="edit(composition)"
row="0" col="0" class="fa"></Button>
<Label [text]="composition.name" (tap)="select(composition)"
row="0" col="1" class="h2"></Label>
<Label [text]="composition.tracks.length"
row="0" col="2" class="text-right"> </Label>
</GridLayout>
</ng-template>
</ListView>
让我们还将调整 ListView 的背景颜色为黑色。现在我们甚至可以使用核心主题中的预定义变量,使用 SASS 在 app/style/_common.scss 中:
.list-group {
background-color: $black;
.muted {
opacity:.2;
}
}

我们的内容列表视图现在开始看起来相当不错。
让我们继续并添加一些图标到我们的曲目列表视图 app/modules/player/components/track-list/track-list.component.html:
<ListView #listview [items]="tracks | orderBy: 'order'"
class="list-group" [itemTemplateSelector]="templateSelector">
<ng-template let-track="item" nsTemplateKey="default">
<GridLayout rows="auto" columns="60,*,30"
class="list-group-item" [class.muted]="track.mute">
<Button [text]="'fa-circle' | fonticon"
(tap)="record(track)" row="0" col="0" class="fa c-ruby"></Button>
<Label [text]="track.name" row="0" col="1" class="h2"></Label>
<Label [text]="(track.mute ? 'fa-volume-off' : 'fa-volume-up') | fonticon"
row="0" col="2" class="fa" (tap)="track.mute=!track.mute"></Label>
</GridLayout>
</ng-template>
...
我们正在用设计用来切换两个不同图标的标签替换我们之前的开关。我们还利用了核心主题的便捷颜色类,如 c-ruby。
我们还可以通过添加一些图标来改进我们的自定义 ActionBar 模板:
<ActionBar [title]="title" class="action-bar">
<ActionItem nsRouterLink="/mixer/home">
<Button [text]="'fa-list-ul' | fonticon" class="fa action-item"></Button>
</ActionItem>
<ActionItem (tap)="toggleList()" ios.position="right">
<Button [text]="((uiState$ | async)?.trackListViewType == 'default' ? 'fa-sliders' : 'fa-list') | fonticon" class="fa action-item"></Button>
</ActionItem>
<ActionItem (tap)="recordAction.next()" ios.position="right">
<Button [text]="'fa-circle' | fonticon" class="fa c-ruby action-item"></Button>
</ActionItem>
</ActionBar>
我们现在可以美化 app/modules/player/components/player-controls/player-controls.component.html 中的播放器控件:
<StackLayout row="1" col="0" class="controls">
<shuttle-slider></shuttle-slider>
<Button [text]="((playerState$ | async)?.player?.playing ? 'fa-pause' : 'fa-play') | fonticon" (tap)="togglePlay()" class="fa c-white t-30"></Button>
</StackLayout>
我们将利用核心主题中的更多辅助类。c-white类将我们的图标变为白色,而t-30设置font-size: 30。后者是text-30的简称,其他还有color-white。
让我们来看看:

真是令人惊叹,一些简单的样式润色就能真正展现你应用的个性。让我们再次拿起画笔,在我们的app/modules/recorder/components/record.component.html记录视图中进行润色:
<ActionBar title="Record" icon="" class="action-bar">
<NavigationButton visibility="collapsed"></NavigationButton>
<ActionItem text="Cancel" ios.systemIcon="1" (tap)="cancel()"></ActionItem>
</ActionBar>
<FlexboxLayout class="record">
<GridLayout rows="auto" columns="auto,*,auto" class="p-10" [visibility]="isModal ? 'visible' : 'collapsed'">
<Button [text]="'fa-times' | fonticon" (tap)="cancel()" row="0" col="0" class="fa c-white"></Button>
</GridLayout>
<Waveform class="waveform"
[model]="recorderService.model"
type="mic"
plotColor="yellow"
fill="false"
mirror="true"
plotType="buffer">
</Waveform>
<StackLayout class="p-5">
<FlexboxLayout class="controls">
<Button [text]="'fa-backward' | fonticon" class="fa text-center" (tap)="recorderService.rewind()" [isEnabled]="state == recordState.readyToPlay || state == recordState.playing"></Button>
<Button [text]="recordBtn | fonticon" class="fa record-btn text-center" (tap)="recorderService.toggleRecord()" [isEnabled]="state != recordState.playing" [class.is-recording]="state == recordState.recording"></Button>
<Button [text]="playBtn | fonticon" class="fa text-center" (tap)="recorderService.togglePlay()" [isEnabled]="state == recordState.readyToPlay || state == recordState.playing"></Button>
</FlexboxLayout>
<FlexboxLayout class="controls bottom" [class.recording]="state == recordState.recording">
<Button [text]="'fa-check' | fonticon" class="fa" [class.save-ready]="state == recordState.readyToPlay" [isEnabled]="state == recordState.readyToPlay" (tap)="recorderService.save()"></Button>
</FlexboxLayout>
</StackLayout>
</FlexboxLayout>
我们现在可以调整我们的组件类来处理recordBtn和playBtn了:
...
export class RecordComponent implements OnInit, OnDestroy {
...
public recordBtn: string = 'fa-circle';
public playBtn: string = 'fa-play';
然后,为了将一切画到位,我们可以在app/modules/recorder/components/record.component.scss中添加以下内容:
@import '../../../style/variables';
.record {
background-color: $slate;
flex-direction: column;
justify-content: space-around;
align-items: stretch;
align-content: center;
}
.record .waveform {
background-color: transparent;
order: 1;
flex-grow: 1;
}
.controls {
width: 100%;
height: 200;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
align-items: center;
align-content: center;
.fa {
font-size: 40;
color: $white;
&.record-btn {
font-size: 70;
color: $ruby;
margin: 0 50 0 50;
&.is-recording {
color: $white;
}
}
}
}
.controls.bottom {
height: 90;
justify-content: flex-end;
}
.controls.bottom.recording {
background-color: #B0342D;
}
.controls.bottom .fa {
border-radius: 60;
font-size: 30;
height: 62;
width: 62;
padding: 2;
margin: 0 10 0 0;
}
.controls.bottom .fa.save-ready {
background-color: #42B03D;
}
.controls .btn {
color: #fff;
}
.controls .btn[isEnabled=false] {
background-color: transparent;
color: #777;
}
经过这次润色,我们现在有了以下截图:

收尾工作
让我们用颜色来最终确定我们的初始应用样式。是时候改变ActionBar中使用的基色,以提供我们想要的整个应用的感觉了。让我们首先在app/style/_variables.scss中定义几个变量:
// baseline theme colors
@import '~nativescript-theme-core/scss/dark';
$slate: #150e0c;
// page
$background: $black;
// action-bar
$ab-background: $black;
通过这些小小的改动,我们给我们的应用带来了不同的(从客观上来说更流畅)感觉:

摘要
在本章中,我们终于能够给应用的外观和感觉添加一些漂亮的润色。我们能够安装nativescript-dev-sass插件,它添加了一个编译步骤来构建我们的 CSS,同时保持对样式的整洁方法。了解如何通过适当的文件组织来最佳地利用核心主题的 SASS 是获得灵活基础的关键。请将本章中提出的概念告诉我们,看看它们是如何帮助您实现所追求的样式目标的;我们非常乐意听到您的反馈!
我们还查看了一下如何使用nativescript-ngx-fonticon插件在我们的应用中利用字体图标。这有助于清理那些笨重的文本标签,并用简洁的图标视觉来替代。
在下一章中,我们将探讨如何对几个关键特性进行单元测试,以保护我们的应用代码库免受新功能集成的影响,这些集成可能会引入回归。测试来拯救!
第十二章:单元测试
让我们从测试开始这一章;大多数人认为测试很无聊。猜猜看,他们大多数是对的!测试可以很有趣,因为你有机会尝试破坏你的代码,但有时也可能是一项繁琐的工作。然而,它可以帮助你在客户之前捕捉到错误,并且作为额外的好处,它可以防止你多次犯同样的错误。你的声誉对你的客户或顾客有多重要?一点繁琐的工作可能意味着 Triple-A 级应用和普通应用之间的区别。
在本章中,我们将涵盖以下主题:
-
Angular 测试框架
-
NativeScript 测试框架
-
如何使用 Jasmine 编写测试
-
如何运行 Karma 测试
单元测试
单元测试用于测试应用程序代码功能的小部分,以验证其正确性。这也允许我们在重构代码和/或添加新功能时,验证功能是否仍然按预期工作。NativeScript 和 Angular 都提供了单元测试框架。我们将探讨这两种类型的单元测试,因为它们都有优点和缺点。
在任何时间开发测试都是好的。然而,最好是在开发项目代码的同时开发它们。你的思维将保持清醒,对新的功能、修改以及你刚刚添加的所有新代码有清晰的认识。在我们的案例中,因为我们在这本书中介绍了许多新的概念,所以我们没有遵循最佳实践,因为这会使书籍更加复杂。所以,尽管后来添加测试是好的,但在添加新代码之前或同时添加测试被认为是最佳实践。
Angular 测试
我们将要介绍的第一种单元测试是 Angular 单元测试。它基于Karma (karma-runner.github.io/) 和 Jasmine (github.com/pivotal/jasmine)。Karma 是一个功能齐全的测试运行器,由 Angular 团队开发。当团队在实施 Angular 时,遇到了一些问题,例如如何测试 Angular,所以他们开发了 Karma。Karma 最终成为了一个行业标准的多用途测试运行器。Jasmine 是一个开源的测试框架,实现了一系列测试结构,帮助你轻松完成所有测试。它存在的时间比 Karma 长得多。由于在 Karma 之前就有很多人在使用它,因此它成为了 Angular 社区中的默认测试库。你可以自由使用其他框架,如 Mocha、Chia,甚至你自己的定制测试框架。然而,由于 Angular 社区中几乎所有的内容都是基于 Jasmine 的,所以我们将使用它。
让我们安装 NativeScript 测试所需的 Angular 测试组件:
npm install jasmine-core karma karma-jasmine karma-chrome-launcher --save-dev
npm install @types/jasmine karma-browserify browserify watchify --save-dev
你可能还需要全局安装 Karma,尤其是在 Windows 上。然而,在其他平台上这样做也是有帮助的,这样你就可以直接输入karma并运行它。为了做到这一点,请输入以下命令:
npm -g install karma
如果您没有全局安装 TypeScript,您可以直接输入tsc并构建,那么您应该全局安装它。在运行任何测试之前,您必须将 TypeScript 转换为 JavaScript。要全局安装 TypeScript,请输入以下命令:
npm -g install typescript
Karma 被设计为在浏览器中运行测试;然而,NativeScript 代码根本不在浏览器中运行。因此,我们必须做一些不同的事情,以便标准的 Karma 测试系统能够与一些 NativeScript 应用程序代码一起运行。在大多数情况下,正常的 Angular 特定 Karma 配置将不起作用。如果您打算进行任何与 Web 方面的 Angular 工作,您应该查看标准的 Angular 测试 QuickStart 项目(github.com/angular/quickstart/)。该项目将为在浏览器中运行的传统的 Angular 应用程序设置一切。
然而,在我们的情况下,因为我们使用的是 NativeScript Angular,我们需要一个完全定制的Karma.conf.js文件。我们已经在 git 仓库中的自定义配置文件中包含了它,或者您可以从这里输入。将此文件保存为Karma.ang.conf.js。我们给出不同的配置名称,因为稍后讨论的 NativeScript 测试将使用默认的Karma.conf.js名称:
module.exports = function(config) {
config.set({
// Enable Jasmine (Testing)
frameworks: ['jasmine', 'browserify'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-browserify')
],
files: [ 'app/**/*.spec.js' ],
preprocessors: {
'app/**/*.js': ['browserify']
},
reporters: ['progress'],
browsers: ['Chrome'],
});
};
此配置设置 Karma 使用 Jasmine、Browserify 和 Chrome 来运行所有测试。由于 Karma 和 Angular 最初是为浏览器设计的,因此所有测试仍然必须在浏览器内运行。这是在执行 NativeScript 代码时 Angular 测试系统的重大缺点。它不会支持任何 NativeScript 特定的代码。因此,这种测试最好在数据模型文件上执行,或者在任何不包含 NativeScript 特定代码的代码上执行,遗憾的是,在您的某些应用程序中可能代码并不多。然而,如果您正在使用相同的代码库同时进行 NativeScript 和 Web 应用程序的开发,那么您应该有很多可以通过标准 Angular 测试框架运行的代码。
对于 Angular 测试,您将创建 Jasmine 规范文件,并且它们都以.spec.ts结尾。我们必须在您正在测试的代码所在的同一目录中创建这些文件。所以,让我们尝试创建一个新的规范文件来测试。由于这种单元测试不允许您使用任何 NativeScript 代码,我选择了一个随机的模型文件来展示这种类型的单元测试是多么简单。让我们在app/modules/shared/models文件夹中创建一个名为track.model.spec.ts的文件;这个文件将用于测试同一文件夹中的track.model.ts文件。以下是我们的测试代码:
// This disables a issue in TypeScript 2.2+ that affects testing
// So this line is highly recommend to be added to all .spec.ts files
export = 0;
// Import our model file (This is what we are going to test)
// You can import ANY files you need
import {TrackModel} from './track.model';
// We use describe to describe what this test set is going to be
// You can have multiple describes in a testing file.
describe('app/modules/shared/models/TrackModel', () => {
// Define whatever variables you need
let trackModel: TrackModel;
// This runs before each "it" function runs, so we can
// configure anything we need to for the actual test
// There is an afterEach for running code after each test
// If you need tear down code
beforeEach( () => {
// Create a new TrackModel class
trackModel = new TrackModel({id: 1,
filepath: 'Somewhere',
name: 'in Cyberspace',
order: 10,
volume: 5,
mute: false,
model: 'My Model'});
});
// Lets run the first test. It makes sure our model is allocated
// the beforeEach ran before this test, meaning it is defined.
// This is a good test to make sure everything is working properly.
it( "Model is defined", () => {
expect(trackModel).toBeDefined();
});
// Make sure that the values we get OUT of the model actually
// match what default values we put in to the model
it ("Model to be configured correctly", () => {
expect(trackModel.id).toBe(1);
expect(trackModel.filepath).toBe('Somewhere' );
expect(trackModel.name).toBe('in Cyberspace');
expect(trackModel.order).toBe(10);
expect(trackModel.model).toBe('My Model');
});
// Verify that the mute functionality actually works
it ('Verify mute', () => {
trackModel.mute = true;
expect(trackModel.mute).toBe(true);
expect(trackModel.volume).toBe(0);
trackModel.mute = false;
expect(trackModel.volume).toBe(5);
});
// Verify the volume functionality actually works
it ('Verify Volume', () => {
trackModel.mute = true;
expect(trackModel.volume).toBe(0);
trackModel.volume = 6;
expect(trackModel.volume).toBe(6);
expect(trackModel.mute).toBe(false);
});
});
那么,让我们来分析一下。第一行解决了在浏览器中使用 TypeScript 构建的模块化文件进行测试时的问题。正如我在注释中提到的,这应该添加到您所有的spec.ts文件中。下一行是我们将要测试的模型加载的地方;您可以在这里导入任何需要的文件,包括 Angular 库。
记住,.spec.js文件只是一个普通的 TypeScript 文件;唯一区别的是它能够访问 Jasmine 的全局化函数,并在浏览器中运行。所以,你所有的正常 TypeScript 代码都将正常工作。
以下行是我们开始实际测试框架的地方。这是一个用于创建测试的 Jasmine 函数。Jasmine 使用describe函数开始一组测试。describe有两个参数:要打印的文本描述,然后是实际要运行的函数。所以,我们基本上输入我们正在测试的模型名称,然后创建函数。在每个describe函数内部,我们添加我们需要的it函数。每个it用于测试的一个子集。如果需要,你也可以有多个describes。
因此,在我们的测试中,我们有四个独立的测试组;第一个实际上只是验证一切是否正确。它只是确保我们的模型被正确定义。所以,我们只是使用 Jasmine 的expect命令来测试使用.toBeDefined()函数创建的有效对象。简单,不是吗?
下一个测试集试图确保beforeEach函数正确设置了默认值。正如你所见,我们再次使用expect命令与.toBe(value)函数。这实际上是高度推荐的;看起来很明显设置的值应该与读取的值匹配,但你想将你的模块视为一个黑盒。验证所有输入和输出以确保它们确实以你设置的方式设置。所以,尽管我们知道我们设置了 ID 为 1,但我们正在验证当我们获取 ID 时,它仍然等于 1。
第三个测试函数开始测试静音功能,最后一个测试函数测试音量功能。请注意,静音和音量都有几个状态和/或影响多个变量。任何超出简单赋值的内容都应该通过你所知的每个状态进行测试,无论是有效还是无效,如果可能的话。在我们的案例中,我们注意到静音会影响音量,反之亦然。因此,我们验证当一个变量改变时,另一个变量也会随之改变。这被用作一个合约,以确保将来如果这个类发生变化,它在外部保持不变,或者我们的测试将会失败。在这种情况下,这更像是一个灰盒测试;我们知道静音的一个副作用,并且我们在应用程序中依赖于这个副作用,因此我们将测试这个副作用以确保它永远不会改变。
运行测试
现在,让我们通过输入tsc来运行测试,创建转译后的 JS 文件,然后运行以下命令:
karma start karma.ang.conf.js
Karma 将会找到所有的.spec.js文件,然后在你的 Chrome 浏览器上运行所有这些文件,测试你在每个.spec.js文件中定义的所有功能。
测试失败异常

现在不是很有趣吗?我们中的一个测试实际上失败了;TrackModel Creation Verify mute FAILED和Expected 1 to be 5.。这个失败并不是为这本书预先计划的;实际上,这是一个我们刚刚发现的真实边缘情况,因为我们开始使用单元测试。如果你想快速查看代码,这里是将TrackModel.ts代码简化到只显示相关例程的代码:
export class TrackModel implements ITrack {
private _volume: number = 1;
private _mute: boolean;
private _origVolume: number;
constructor(model?: ITrack) {
if (model) {
for (let key in model) {
this[key] = model[key];
}
}
}
public set mute(value: boolean) {
value = typeof value === 'undefined' ? false : value;
this._mute = value;
if (this._mute) {
this._origVolume = this._volume;
this.volume = 0;
} else {
this.volume = this._origVolume;
}
}
public set volume(value: number) {
value = typeof value === 'undefined' ? 1 : value;
this._volume = value;
if (this._volume > 0 && this._mute) {
this._origVolume = this._volume;
this._mute = false;
}
}
}
现在,我会给你几分钟时间看看前面的测试代码和这段代码,检查你是否能找出测试失败的原因。
好的,我看到,你回来了;你看到边缘情况在哪里了吗?如果你不能很快找到它,不要难过;它花了我几分钟才弄清楚为什么它会失败。
好吧,首先,看看错误信息;它说Verify Mute FAILED,这意味着是我们的 mute 测试失败了。然后,我们在测试 mute 功能的it函数中放入了Verify mute。第二个线索是错误,Expected 1 to be 5。所以,我们期望某物是 5,但实际上它是 1。因此,这个特定的测试和这一行代码在测试中失败了:
it ('Verify mute', () => {
expect(trackModel.volume).toBe(5);
});
为什么它会失败?
让我们从测试初始化,beforeEach,开始看起,你会看到mute: false。嗯,接下来,让我们看看构造函数;它基本上做了this.mute = false,然后 mute 设置器运行其else分支,即this.volume = this._origVolume。猜猜看?this._origVolume还没有被设置,所以它将this.volume设置为undefined。现在看看音量例程;新的音量传入undefined,它被设置为1,这覆盖了我们原来的设置 5。所以,测试Expected 1 to be 5.失败了。
有趣的边缘情况;如果我们没有在测试属性初始化时将mute设置为false,这种情况就不会发生。然而,这是我们应该测试的事情,因为也许在应用程序的一个修订版本中,我们将存储 mute 值并在启动时恢复它。
为了修复这个问题,我们应该稍微修改一下类。我们将让你做出你认为必要的更改来修复这个问题。如果你卡住了,你可以根据track.model.ts文件重命名track.model.fixed.ts;它包含正确的代码。
修复后,运行相同的tsc,然后是karma start karma.ang.conf.js命令;你应该看到一切都很成功。
测试通过

正如这个例子所指出的,你的代码在某些情况下可能运行正确,但在其他情况下可能会失败。单元测试可以指出你可能不会立即看到的逻辑错误。在添加新功能或修复错误时,这尤其重要。强烈建议你为这两者都创建新的测试,然后你至少可以确保在做出任何代码更改后,你的新或修改后的代码表现正常。
让我们稍微转换一下思路,看看 NativeScript 测试框架;Angular 框架相当酷,但它有一个讨厌的限制,就是没有 NativeScript 框架调用可用,这限制了它的很多用途。
NativeScript 测试框架
好的,准备好玩 NativeScript 测试框架吧。安装它相当简单,你只需输入以下命令:
tns test init
没有必要切换测试框架,所以在询问你使用 NativeScript 测试框架时选择 jasmine。这将安装 NativeScript 测试系统所需的所有资源。NativeScript 的测试系统也使用 Karma,并支持几个不同的测试框架,但为了保持一致性,我们希望继续使用 Jasmine。
记得我之前说过 Karma 使用浏览器来执行所有测试,我也说过 NativeScript 代码不会在浏览器中运行吗?那么,为什么 NativeScript 会使用 Karma?Karma 是如何运行 NativeScript 代码的?这是很好的问题!实际上,Karma 被欺骗成认为你的 NativeScript 应用程序是一个浏览器。Karma 将测试上传到浏览器(即 NativeScript 应用程序),然后运行它们。所以,实际上,你的应用程序对 Karma 来说就像是一个浏览器;这是 NativeScript 团队的一个相当巧妙的解决方案。
现在,NativeScript 测试系统的最大优点是它实际上可以测试你所有的 NativeScript 代码。它将自动在你的模拟器(或真实设备)上运行一个特殊的构建版本的应用程序,这样它就可以运行所有的 NativeScript 代码并正确访问设备。NativeScript 测试系统的最大缺点是它需要更多的资源,因为它必须使用模拟器(或真实设备)来运行测试。所以,运行测试可能比我们在本章 earlier 讨论的标准单元测试要花费更多的时间。
好的,现在你已经安装好了所有内容。让我们继续前进。所有的 NativeScript 测试文件都将位于 app/tests 文件夹中。这个文件夹是在你运行 tns test init 命令时创建的。如果你打开这个文件夹,你会看到 example.js 文件。你可以随意删除或保留这个文件。它只是一个示例测试,用来展示如何使用 Jasmine 格式化你的测试。
所以,对于我们的 NativeScript 测试,我选择了一个使用 NativeScript 代码的简单服务。让我们在 app/test 文件夹中创建我们的 database.service.test.ts 文件。你在这个文件夹中的文件可以命名为任何名字,但为了更容易找到,我们将以 .test.ts 结尾。你还可以创建子目录来组织你所有的测试。在这种情况下,我们将测试 app/modules/core/services/database.service.ts 文件。
这个特定的服务,如果你查看代码,实际上使用了 NativeScript 的 AppSettings 模块来存储和检索来自 Android 和 iOS 系统级存储系统的数据。所以,这是一个很好的测试文件。让我们创建我们的测试文件:
// Import the reflect-metadata because angular needs it, even if we don't.
// We could import the entire angular library; but for unit-testing;
// smaller is better and faster.
import 'reflect-metadata';
// Import our DatabaseService, we need at least something to test... ;-)
import { DatabaseService } from "../modules/core/services/database.service";
// We do the exact same thing as we discussed earlier;
// we describe what test group we are testing.
describe("database.service.test", function() {
// So that we can easily change the Testing key in case we find out later in our app
// we need "TestingKey" for some obscure reason.
const TestingKey = "TestingKey";
// As before, we define a "it" function to define a test group
it("Test Database service class", function() {
// We are just going to create the DatabaseService class here,
// no need for a beforeEach.
const dbService = new DatabaseService();
// Lets attempt to write some data.
dbService.setItem(TestingKey, {key: "alpha", beta: "cygnus", delta: true});
// Lets get that data back out...
let valueOut = dbService.getItem(TestingKey);
// Does it match?
expect(valueOut).toBeDefined();
expect(valueOut.key).toBe("alpha");
expect(valueOut.beta).toBe("cygnus");
expect(valueOut.delta).toBe(true);
// Lets write some new data over the same key
dbService.setItem(TestingKey, {key: "beta", beta: true});
// Lets get the new data
valueOut = dbService.getItem(TestingKey);
// Does it match?
expect(valueOut).toBeDefined();
expect(valueOut.key).toBe("beta");
expect(valueOut.beta).toBe(true);
expect(Object.keys(valueOut).length).toBe(2);
// Lets remove the key
dbService.removeItem(TestingKey);
// Lets make sure the key is gone
valueOut = dbService.getItem(TestingKey);
expect(valueOut).toBeFalsy();
});
});
您可能已经能够很容易地阅读这个测试文件。基本上,它调用数据库服务几次以设置具有不同值的相同键。然后,它要求数据库服务返回存储的值并验证结果是否与我们存储的相匹配。然后,我们告诉数据库服务删除我们的存储键并验证该键已消失,所有这些都非常直接。这个文件中唯一不同的是 include 'reflect-metadata'。这是因为数据库服务使用了元数据,所以我们必须确保在加载数据库服务类之前加载元数据类。
运行测试
让我们尝试测试应用程序;要运行您的测试,请输入以下命令:
tns test android
或者,您也可以运行以下命令:
tns test ios
这将启动测试,您应该会看到类似以下内容:

注意,这个屏幕上有一个 ERROR;这是一个错误。基本上,当应用程序完成其测试运行后,它会退出。Karma 看到应用程序意外退出并记录为 "ERROR" Disconnected。导入信息是错误下面的那一行,其中说 Executed 2 of 2 SUCCESS。这意味着它运行了两个不同的 described 测试(即我们的 test.ts 文件和额外的 example.js 文件)。
您可能也已经注意到,我们的测试文件与 Angular 测试文件完全相同。这是因为它们都使用 Jasmine 和 Karma。因此,测试文件可以几乎以相同的方式设置。在这个特定的情况下,因为测试实际上是在您的应用程序内部运行的,所以任何插件、代码和模块,包括任何原生代码都可以用于测试。这就是 NativeScript 测试工具包更加强大和有用的原因。然而,它的最大优势也是它的弱点。由于它必须在一个运行的 NativeScript 应用程序内部运行,因此构建、启动和运行所有测试需要更多的时间。这就是标准 Angular 测试框架可以证明比 NativeScript 测试框架更有益的地方。任何不使用任何 NativeScript 特定代码的内容都可以从您的命令行几乎瞬间运行,几乎没有开销。您的测试运行得越快,您就越有可能频繁地运行它们。
摘要
在本章中,我们讨论了如何进行单元测试以及两种单元测试方法的优缺点。简而言之,Angular 测试适用于不调用任何 NativeScript 特定代码的通用 TypeScript 代码,并且它运行测试非常快。NativeScript 测试工具包在您的 NativeScript 应用程序内部运行,并可以完全访问您所写的内容以及正常 NativeScript 应用程序所能做到的一切。然而,它需要在 NativeScript 应用程序运行时才能运行其测试,因此可能需要在运行测试之前进行完整的构建步骤。
现在我们已经讨论了两种单元测试类型,请继续戴上你的测试帽。在下一章中,我们将介绍如何进行端到端测试或全屏及应用程序测试你的出色应用。
第十三章:使用 Appium 进行集成测试
在上一章中,我们探讨了如何进行单元测试,但单元测试不允许你测试应用程序中使用的按钮是否仍然实际运行一个函数,或者当用户向左滑动时会发生什么。为此,我们需要应用程序测试或端到端测试。好吧,让我们开始学习端到端测试;这是测试变得复杂和有趣的地方。
在本章中,我们将涵盖以下主题:
-
Appium 测试框架
-
编写 MochaJS、ChaiJS 和 ShouldJS 测试
-
如何在屏幕上查找和与元素交互
-
如何运行测试
-
Travis 和 GitHub 集成
集成测试
现在有几个完整的应用程序框架,但我们将向您展示如何使用 Appium(appium.io)。Appium 是一个出色的开源应用程序测试框架。Appium 支持 iOS 和 Android,这使得它非常适合进行所有我们的设备测试。您想要开始创建测试来测试您通过应用程序的基本流程,甚至创建更复杂的测试来测试通过应用程序的替代流程。
让我们先安装它;运行以下命令:
npm install appium wd nativescript-dev-appium --save-dev
前一个命令安装了 Appium,Appium 通信驱动WD(admc.io/wd/),以及NativeScript 驱动程序(github.com/NativeScript/nativescript-dev-appium)。WD 驱动程序是与 Appium 通信以及 NativeScript 驱动程序的通信。nativescript-dev-appium是与 WD 交互以及与你的测试代码交互的驱动程序。实际上,NativeScript 驱动程序是 WD 驱动程序的一个非常薄的包装,仅便于一些配置,然后将 WD 驱动程序暴露给应用程序。因此,交互命令可以在 WD 文档中找到。
应用程序/集成测试需要更多的工作,因为你必须以普通用户交互的方式程序化地运行它。因此,你必须做一些事情,比如找到按钮元素,然后执行button.tap()。所以,你的测试可能会有些冗长,但这允许你测试任何,以及所有的功能。缺点是这需要更多的时间来运行,并且在更改屏幕时需要更多的维护工作。然而,回报是当你添加代码时,它会自动验证你的应用程序在每个屏幕上仍然可以正常运行,并且你可以自动在多个设备和分辨率上测试它。
安装完成后,你将在应用程序的根目录下有一个全新的e2e-tests文件夹。这个文件夹将存放你所有的端到端测试文件。现在,你需要知道的是,Appium NativeScript 驱动程序使用 MochaJS 测试框架(mochajs.org/)。Mocha 测试框架类似于我们在上一章中讨论的 Jasmine 框架。它使用相同的describe和it函数来开始测试,就像 Jasmine 一样。此外,它还使用 Chai (chaijs.com/)和 ShouldJS (github.com/shouldjs/should.js)测试框架,这些框架与 Mocha 测试框架和 WD 驱动程序协同工作。
另一点需要注意的是,所有这些都是在纯 JavaScript 的基础上设计的。你可以为 Mocha、Should 和 Chai 获取类型定义,但对于 NativeScript Appium 驱动程序或 WD 驱动程序,类型定义不存在。你可以使用 TypeScript,但这会很尴尬,因为命令不仅仅是基于 WD 的命令,而是通过 mocha 链式执行的。TypeScript 很容易混淆你当前所处的上下文。因此,Appium 测试主要是在纯 JavaScript 而不是 TypeScript 中创建的。然而,如果你想使用 TypeScript,请确保在运行测试之前运行tsc来构建JS文件。
配置
你还需要进行的一个设置步骤是在项目的根目录下创建一个appium.capabilities.json文件。这是一个基本的配置文件,你可以用它来配置你需要在上面运行任何测试的模拟器。该文件在 Appium 网站上有所说明,但为了让你快速启动,你可以使用我们使用的简化版文件,如下所示:
{
"android44": {
"browserName": "",
"appium-version": "1.6.5",
"platformName": "Android",
"platformVersion": "4.4",
"deviceName": "Android 44 Emulator",
"noReset": false,
"app": ""
},
"ios10phone": {
"browserName": "",
"appium-version": "1.6.5",
"platformName": "iOS",
"platformVersion": "10.0",
"deviceName": "iPhone 6 Simulator",
"app": ""
}
}
我们简化了它,并删除了所有其他模拟器条目以节省空间。然而,你需要为每个模拟器条目提供一个键——你告诉 Appium 使用该键来指定你将要运行的模拟器配置。这个示例文件显示了两个配置。第一个是一个 Android 4.4 设备,第二个是一个 iOS 模拟器(iPhone 6 运行 iOS 10)。你可以在该文件中设置任意多的配置。当你运行 Appium 时,你可以使用--runType=KEY参数来告诉它你将针对哪个设备。
创建一个测试
让我们开始我们的旅程,创建一个新的测试文件:list.test.js。这个文件将测试我们的混合列表屏幕。屏幕的 HTML(/app/modules/mixer/components/mix-list.component.html)看起来如下:
<ActionBar title="Compositions" class="action-bar">
<ActionItem (tap)="add()" ios.position="right">
<Button [text]="'fa-plus' | fonticon" class="fa action-item"></Button>
</ActionItem>
</ActionBar>
<ListView [items]="(mixer$ | async)?.compositions | orderBy: 'order'" class="list-group">
<ng-template let-composition="item">
<GridLayout rows="auto" columns="100,*,auto" class="list-group-item">
<Button [text]="'fa-pencil' | fonticon" (tap)="edit(composition)" row="0" col="0" class="fa"></Button>
<Label [text]="composition.name" (tap)="select(composition)" row="0" col="1" class="h2"></Label>
<Label [text]="composition.tracks.length" row="0" col="2" class="text-right"></Label>
</GridLayout>
</ng-template>
</ListView>
我们在这里包含了代码,这样你可以很容易地看到我们如何根据屏幕上提供的详细信息制作测试。
// In JavaScript code, "use strict"; is highly recommended,
// it enables JavaScript engine optimizations.
"use strict";
// Load the Appium driver, this driver sets up our connection to Appium
// and the emulator or device.
const nsAppium = require("nativescript-dev-appium");
我们需要在 JavaScript 测试代码中包含 NativeScript Appium 驱动程序;这是用来实际通信和设置 Mocha、ShouldJS、WD、Appium 和 Chai 以使其能够正常工作的。以下行对于你的使用是必需的:
// Just like Jasmine, Mocha uses describe to start a testing group.
describe("Simple example", function () {
// This is fairly important, you need to give the driver time to wait
// so that your app has time to start up on the emulator/device.
// This number might still be too small if you have a slow machine.
this.timeout(100000);
如源代码中的注释所述,确保你有足够的时间启动 Appium 和你的模拟器中的应用程序至关重要。因此,我们个人的默认值是100,000;你可以调整这个数字,但这将是它等待宣布测试失败的最大时间量。较大的值意味着你给你的模拟器和 Appium 更多的时间来实际启动。Appium 会快速给出其启动输出,但当它开始初始化测试和驱动器时,这个过程需要花费很多时间。一旦测试开始运行,它就会非常快地运行:
// This holds the driver; that will be used to communicate with Appium & Device.
let driver;
// This is ran once before any tests are ran. (There is also a beforeEach)
before(function () {
// VERY, VERY important line here; you NEED a driver to communicate to your device.
// No driver, no tests will work.
driver = nsAppium.createDriver();
});
在你的测试运行之前初始化和创建驱动器也非常重要。在整个测试过程中,这个驱动器是全局的。因此,我们将在describe函数中全局声明它,然后使用在所有测试运行之前运行的 Mocha before函数来初始化它:
// This is ran once at the end of all the tests. (There is also a afterEach)
after(function () {
// Also important, the Appium system works off of promises
// so you return the promise from the after function
// NOTICE no ";", we are chaining to the next command.
return driver
// This tells the driver to quit....
.quit()
// And finally after it has quit we print it finished....
.finally(function () {
console.log("Driver quit successfully");
});
});
我们还添加了一个 Mocha 后置函数,在我们全部完成时关闭驱动器。确保每次你在使用驱动器时都正确地返回它非常重要。实际上,几乎每一个测试片段下面都隐藏着一个承诺。如果你忘记返回承诺,测试工具将变得混乱不堪,可能会以错误的顺序运行测试,甚至在测试完成之前关闭驱动器。所以,总是要返回承诺:
// Just like jasmine, we define a test here.
it("should find the + button", function () {
// Again, VERY important, you need to return the promise
return driver
// This searches for an element by the Dom path; so you can find sub items.
.elementByXPath("//" + nsAppium.getXPathElement('Button'))
it函数的使用方式就像我们在 Jasmine 中做的那样——你正在描述你计划运行的测试,这样你就可以在测试失败时找到它。再次强调,我们返回承诺链;你绝对不要忘记做这件事。驱动器变量是我们在处理模拟器时获得不同功能的原因。所以,该功能的文档在 WD 仓库中,但我将给你一个快速概述,帮助你开始。
.elementByXPath和.elementById是真正能很好地用于在 NativeScript 中正确查找元素的唯一两个函数。然而,也存在.waitForElementByXPath和.waitForElementById,这两个函数都会等待元素出现。如果你查看文档,你会观察到很多elementByXXX命令,但 Appium 是为浏览器设计的,而 NativeScript 不是浏览器。这就是为什么,只有一些在 nativescript-dev-appium 驱动器中模拟的命令才能在 NativeScript DOM 中查找元素。
因此,我们的测试是使用 XPath 查找元素。XPath 允许你深入 DOM 并找到任何深度的组件,以及其他组件的子组件。所以,如果你做类似/GridLayout/StackLayout/Label的事情,它将找到一个Label,它是StackLayout的子组件,而StackLayout又是GridLayout的子组件。使用*//*意味着你可以在 DOM 的任何级别找到该元素。最后,nsAppium.getXPathElement是一个由 Nathanael Anderson 添加到官方 NativeScript 驱动程序的方法,允许我们进行跨平台的 XPath 测试。实际上,你传递给 XPath 函数的是对象的真正原生名称。例如,在 Android 上,按钮是android.widget.Button,或者它可能是 iOS 上的UIAButton或XCUIElementTypeButton。所以因为你不想硬编码getByElementXPath("android.widget.Button"),这个辅助函数将 NativeScript 的Button转换为 NativeScript 在创建按钮时实际使用的正确底层 OS 元素。如果将来你添加了一个使用getXPathElement不知道的元素的插件,你仍然可以使用元素的真正名称进行这些测试:
// This element should eventually exist
.text().should.eventually.exist.equal('\uf067');
});
.text()是一个 Appium 驱动程序公开的函数,用于获取它找到的元素的文本值。.should.eventually.exist.equal是 Mocha 和 Should 代码。我们基本上确保一旦找到这个项目,它实际上匹配 F067 的 Unicode 值,在 Font-Awesome 中是加号字符(fa-plus)。一旦它存在,我们就满意了——测试要么成功,要么失败,这取决于我们是否打破了屏幕,或者屏幕是否继续以我们期望的方式存在。此外,在**.equal**之后,我们还可以链式调用更多命令,例如.tap(),如果我们想触发按钮的话。
好的,让我们看看下一个运行的测试:
it("should have a Demo label", function () {
// Again, VERY important, you need to return the promise
return driver
// Find all Label elements, that has text of "Demo"
.elementByXPath("//" + nsAppium.getXPathElement("Label") + "[@text='Demo']")
// This item should eventually exist
.should.eventually.exist
// Tap it
.tap();
});
这个测试在屏幕上搜索以显示Demo ListView 项。我们正在寻找 NativeScript Label(即nsAppium.getXPathElement),它在 NativeScript DOM(即*//*)中的任何地方都包含文本值 Demo。(即[@text='Demo'])。这个元素最终应该存在,一旦它存在,它就调用tap()函数。现在,如果你查看源代码,你会看到以下内容:
<Label [text]="composition.name" (tap)="select(composition)" row="0" col="1" class="h2"></Label>
因此,当tap事件触发时,它将运行选择函数。select函数最终会加载/app/modules/player/components/track-list/track-list.component.html文件,该文件用于在屏幕上显示该混音项的组成。
所有测试都是顺序执行的,并且应用的状态从一个测试保留到另一个测试。这意味着测试并不是像我们编写单元测试时那样相互独立的。
我们将要验证的下一个测试是,在点击它之后,Demo 标签实际上会在下一个测试中切换屏幕:
it("Should change to another screen", function () {
// As usual return the promise chain...
return driver
// Find all Label elements, that has text of "Demo"
.waitForElementByXPath("//" + nsAppium.getXPathElement("Label") + "[@text='Drums']")
// This item should eventually exist
.should.eventually.exist.text();
});
现在,我们已经进入了一个新的屏幕,我们将验证ListView是否包含一个名为Drums的标签。这个测试只是验证在我们点击前一个测试中的Demo标签时,屏幕实际上是否发生了变化。我们本可以验证文本值,但如果我们确认它存在,我们就没问题了。所以,让我们看看下一个测试:
it("Should change mute button", function () {
// Again, returning the promise
return driver
// Find all Label elements that contains the FA-Volume
.waitForElementByXPath("//" + nsAppium.getXPathElement("Label") + "[@text='\uf028']")
// This item should eventually exist
.should.eventually.exist
// It exists, so tap it...
.tap()
// Make sure the text then becomes the muted volume symbol
.text().should.eventually.become("\uf026");
});
// This closes the describe we opened at the top of this test set.
});
我们最后的示例测试展示了链式调用。我们搜索具有音量控制符号的标签。一旦它存在,我们就点击它。然后,我们验证文本实际上变成了音量关闭符号。f028是fa-volume-up的 Font Awesome Unicode 值,而f026是fa-volume-off的 Font Awesome Unicode 值。
所以现在你有了这个非常酷的测试,你想要启动你的模拟器。模拟器应该已经运行了。你还应该确保设备上有应用程序的最新版本。然后,要运行测试,只需输入以下命令:
npm run appium --runType=android44
确保你输入你将要使用的运行类型配置,几分钟后你应该能看到类似以下内容:

记住,Appium 端到端测试启动需要一段时间,所以如果它看起来冻结了一段时间,不要慌张并退出它。第一次测试可能需要 24 秒,每个额外的测试可能需要几秒钟。第一次测试包含了所有时间。Appium 在模拟器上启动驱动程序和应用程序需要很长时间是正常的。这种延迟通常发生在你看到前几行文本打印出来之后,如前述屏幕所示,所以,请有点耐心。
更多 Appium 测试
我想包括一个额外的测试(在这个应用程序中没有使用),这是我过去为不同的项目编写的,因为这会给你一个关于 Appium 有多强大的概念:
it("should type in an element", function (done) {
driver
.elementByXPath('//' + nsAppium.getXPathElement("EditText") + "[@text='Enter your name']")
.sendKeys('Testing')
.text()
.then(function (v) {
if ('Testing' !== v) {
done(new Error("Value in name field does not match"));
} else {
done();
}
}, done);
});
});
你可能会注意到的第一件事是,我没有返回 promise 链。这是因为这个例子展示了如何使用it的异步支持。对于异步支持,你可以使用一个 promise 或者让传入it的函数有一个done回调函数。当 Mocha 检测到it中的回调函数时,它将以异步模式运行你的it测试,并且不需要 promise 来让它知道它可以继续下一个测试。有时,你可能只是想保持完全控制,或者你可能正在调用需要异步回调的代码。
这个测试寻找一个包含Enter your name的EditText元素。然后,它实际上使用sendKeys将Testing输入其中。接下来,它请求字段的text并使用 promise 的then部分来检查值与硬编码的测试值是否匹配。当一切完成后,它调用done函数。如果你向done函数传递一个Error对象,那么它就知道测试失败了。所以,你可以在if语句中看到我们传递了一个new Error,并且我们将done函数放在了then语句的catch部分。
我们几乎只是触及了使用 Appium、Should、Mocha 和 Chia 所能做到的一小部分。你可以控制应用几乎所有的方面,就像你在手动进行每一步一样。最初,在你的开发过程中,手动测试要快得多。然而,当你开始构建端到端测试时,每次你进行更改,你都可以检查应用是否仍然正常工作,而且你不必长时间坐在多个设备前——你只需启动测试,稍后查看结果即可。
自动化测试
还有一点需要注意,你使测试自动化程度越高,你越有可能使用它并从中获益。如果你必须不断手动运行测试,你可能会感到厌烦并停止运行它们。所以,我们认为自动化这一点至关重要。由于关于这个主题有许多书籍,我们只是给你提供一些你可以研究并继续前进的提示。
大多数源代码控制系统允许你创建钩子。通过这些钩子,你可以创建一个提交钩子,以便在检查任何新代码时运行你的测试框架。这些钩子通常很简单创建,因为它们只是每次提交时运行的简单脚本。
此外,如果你使用 GitHub,你可以轻松地将 Travis 等网站集成进来,而无需进行任何钩子更改。
GitHub 和 Travis 集成
这里是如何与 GitHub 和 Travis 进行一些集成的示例;这将允许你的 NativeScript 测试框架,我们在上一章中讨论过,在每次更改或 pull request 时自动运行你的测试。在你的 GitHub 仓库的根目录中创建一个新的.travis.yml文件。这个文件应该看起来像这样:
language: android
jdk: oraclejdk8
android:
components:
- tools
- platform-tools
- build-tools-25.0.2
- android-25
- extra-android-m2repository
- sys-img-armeabi-v7a-android-21
before_cache:
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
cache:
directories:
- .nvm
- $HOME/.gradle/caches/
- $HOME/.gradle/wrapper/
install:
- nvm install node
- npm install -g nativescript
- tns usage-reporting disable
- tns error-reporting disable
before_script:
- echo no | android create avd --force -n test -t android-21 -b armeabi-v7a
- emulator -avd test -no-audio -no-window &
- android-wait-for-emulator
script:
- npm run travissetup
- npm run travistest
这基本上配置了 Travis 启动一个 Android 模拟器;它等待模拟器启动,然后运行npm命令。你可以从你的package.json中了解这些npm命令的作用。
因此,在你的根应用中,即你的应用的package.json文件,你想要添加以下键:
"scripts": {
"travissetup": "npm i && tns platform add android && tns build android",
"travistest": "tns test android"
}
这两个更改之后,Travis 将自动测试你仓库中的每一个 pull request,这意味着你可以编码,而 Travis 将不断进行所有单元测试。
此外,你可以通过以下方式更改先前的 Travis 配置文件,以便添加 Appium 进行安装和运行:
-
将 Appium 依赖项添加到你的主
package.json依赖项中。 -
在你的项目根目录中添加一个具有
travisAndroid键的appium.capabilities.json。 -
在
package.json文件中将&& npm run appium --runType=travisAndroid添加到travistest键中。
GitHub 已经内置了与 Travis 的集成,因此记录文档并启动它非常简单。如果你使用 Gitlabs,你可以使用 Gitlabs CI 系统进行测试。此外,你还可以使用仓库钩子来使用大量可用的其他持续集成服务。最后,你也可以开发自己的服务。
摘要
在本章中,我们介绍了如何安装和运行 Appium,如何构建完整的端到端测试,以及如何使用测试框架全面测试你的屏幕。此外,我们还讨论了自动化单元测试和 Appium 运行的重要性,以及你可以使用 Travis 和 GitHub 来实现这一目标的一种方法。
现在请系好安全带——我们将快速转向,开始讨论如何部署和使用 Webpack 来优化你的构建以供发布。
第十四章:使用 webpack 打包进行部署准备
我们希望将我们的应用部署到两个领先的移动应用商店,即 Apple App Store 和 Google Play;然而,我们需要做一些准备,以便我们的应用可以分发。
为了确保我们使用最小的 JavaScript 大小,并辅以 Angular 的 AoT 编译器,以帮助我们的应用尽可能快地执行,我们将使用 webpack 来打包所有内容。值得注意的是,webpack 不是创建可分发 NativeScript 应用的必需品。然而,它提供了非常不错的优势,这应该使它成为任何人在分发他们的应用时的重要步骤。
在本章中,我们将涵盖以下主题:
-
为 NativeScript for Angular 项目安装 webpack
-
准备项目以使用 webpack 进行打包
-
解决各种 webpack 打包问题
-
自定义 webpack 插件编写指南,用于解决特定情况
使用 webpack 打包应用
如果没有 Sean Larkin,你可能永远不会听说 webpack。他对打包器社区的贡献和参与帮助 webpack 进入 Angular CLI,并使其成为许多事物的首选bundler。我们非常感激他在社区中的努力和善意。
准备使用 webpack
让我们看看 webpack 如何被利用来减少我们的 NativeScript for Angular 应用的打包大小,同时确保它在用户的移动设备上执行得最优。
让我们首先安装插件:
npm install nativescript-dev-webpack --save-dev
这会自动创建一个webpack.config.js文件(位于项目根目录),预先配置了基本设置,这将使大多数应用能够合理地进一步发展。此外,它还会创建一个tsconfig.aot.json文件(同样位于项目根目录),因为 NativeScript 的 webpack 使用将在打包时使用 Angular 的 AoT 编译器。它还在package.json中添加了一些巧妙的 npm 脚本,以帮助我们处理所有我们想要的打包选项;考虑以下示例:
-
使用
npm run build-android-bundle为 Android 构建 -
使用
npm run build-ios-bundle为 iOS 构建 -
使用
npm run start-android-bundle在 Android 上运行 -
使用
npm run start-ios-bundle在 iOS 上运行
然而,在我们尝试这些新命令之前,我们需要对我们的应用进行一些审计。
我们应该首先确保所有 NativeScript 导入路径都以前缀tns-core-modules/[module]开头;考虑以下示例:
BEFORE:
import { isIOS } from 'platform';
import { topmost } from 'ui/frame';
import * as app from 'application';
AFTER:
import { isIOS } from 'tns-core-modules/platform';
import { topmost } from 'tns-core-modules/ui/frame';
import * as app from 'tns-core-modules/application';
我们将遍历我们的应用并立即这样做。这对于开发和生产构建都适用。
你可能会想,“嘿!既然我们需要遍历整个代码库并在事后更改导入,为什么还要使用其他形式呢?”
极大的关注!实际上,有很多示例展示了方便的短形式导入路径,所以我们选择在整个章节中使用该路径构建应用程序,以演示它对开发来说效果良好,以帮助避免混淆,以防你在未来遇到这样的示例。此外,实际上在事后编辑它以准备 webpack 并不需要太多,但现在你知道了。
立即运行以下命令:
npm run build-ios-bundle
我们可以看到以下错误——我已经列举了它们——我们将在下一节中按顺序提供解决方案:
-
错误:在
/path/to/TNSStudio/app/modules/player/directives/slider.directive.d.ts中意外值SlimSliderDirective,由模块PlayerModule在/path/to/TNSStudio/app/modules/player/player.module.ts中声明。请添加@Pipe/@Directive/@Component注解。 -
错误:无法确定类
SlimSliderDirective的模块,在/path/to/TNSStudio/app/modules/player/directives/slider.directive.android.ts!将SlimSliderDirective添加到NgModule中修复它。无法确定类SlimSliderDirective的模块,在/path/to/TNSStudio/app/modules/player/directives/slider.directive.ios.ts!将SlimSliderDirective添加到NgModule中修复它。 -
错误:在静态解析符号值时遇到错误。调用函数
ModalDialogParams,不支持函数调用。考虑用导出函数的引用替换该函数或 lambda,解析符号RecorderModule在/path/to/TNSStudio/app/modules/recorder/recorder.module.ts,解析符号RecorderModule在/path/to/TNSStudio/app/modules/recorder/recorder.module.ts。 -
错误:入口模块未找到:错误:无法解析
./app.css在/path/to/TNSStudio/app。 -
错误:[copy-webpack-plugin]无法在
/path/to/TNSStudio/app/app.css中找到app.css。
前三个错误纯粹与 Angular Ahead of Time (AoT)编译相关。最后两个纯粹与 webpack 配置相关。让我们逐一查看每个错误以及如何正确解决它。
解决方案 #1:意外值 'SlimSliderDirective...'
考虑前一小节中提到的第一个完整错误:
ERROR in Unexpected value 'SlimSliderDirective in /path/to/TNSStudio/app/modules/player/directives/slider.directive.d.ts' declared by the module 'PlayerModule in /path/to/TNSStudio/app/modules/player/player.module.ts'. Please add a @Pipe/@Directive/@Component annotation.
解决前述错误的方案是安装一个额外的 webpack 插件:
npm install nativescript-webpack-import-replace --save-dev
然后,打开webpack.config.js并按以下方式配置插件:
function getPlugins(platform, env) {
let plugins = [
...
new ImportReplacePlugin({
platform: platform,
files: [
'slider.directive'
]
}),
...
这将找到app/modules/players/directives/index.ts中的slider.directive导入,并附加正确的目标平台后缀,因此 AoT 编译器将选择正确的目标平台实现文件。
在撰写本书时,该错误还没有解决方案,因此我们开发了 nativescript-webpack-import-replace 插件来解决这个问题。由于你可能会遇到需要通过插件提供一些额外 webpack 帮助的 webpack 捆绑情况,我们将分享我们如何开发插件来解决该错误的概述,以防你遇到其他可能需要创建插件的晦涩错误。
让我们先看看剩余的初始错误解决方案,然后我们将突出 webpack 插件开发。
解决方案 #2:无法确定类 SlimSliderDirective 的模块...
考虑到在 准备使用 webpack 部分提到的第二个完整错误:
ERROR in Cannot determine the module for class SlimSliderDirective in /path/to/TNSStudio/app/modules/player/directives/slider.directive.android.ts! Add SlimSliderDirective to the NgModule to fix it.
Cannot determine the module for class SlimSliderDirective in /path/to/TNSStudio/app/modules/player/directives/slider.directive.ios.ts! Add SlimSliderDirective to the NgModule to fix it.
解决前面错误的方法是打开 tsconfig.aot.json,并做出以下更改:
BEFORE:
...
"exclude": [
"node_modules",
"platforms"
],
AFTER:
...
"files": [
"./app/main.ts"
]
由于 AoT 编译使用 tsconfig.aot.json 配置,我们希望针对编译的目标文件更加具体。由于 ./app/main.ts 是我们启动应用的入口点,我们将针对该文件,并移除 exclude 块。
如果我们现在尝试捆绑,我们会解决我们看到的错误;然而,我们会看到以下 新 错误:
ERROR in .. lazy
Module not found: Error: Can't resolve '/path/to/TNSStudio/app/modules/mixer/mixer.module.ngfactory.ts' in '/path/to/TNSStudio'
@ .. lazy
@ ../~/@angular/core/@angular/core.es5.js
@ ./vendor.ts
ERROR in .. lazy
Module not found: Error: Can't resolve '/path/to/TNSStudio/app/modules/recorder/recorder.module.ngfactory.ts' in '/path/to/TNSStudio'
@ .. lazy
@ ../~/@angular/core/@angular/core.es5.js
@ ./vendor.ts
这是因为我们针对 ./app/main.ts,它分支到我们应用的所有其他导入文件,除了那些懒加载的模块。
解决前面错误的方法是在 files 部分添加懒加载模块路径:
"files": [
"./app/main.ts",
"./app/modules/mixer/mixer.module.ts",
"./app/modules/recorder/recorder.module.ts"
],
好吧,我们已经解决了 lazy 错误;然而,现在这揭示了几个 新 错误,如下所示:
ERROR in /path/to/TNSStudio/app/modules/recorder/components/record.component.ts (128,19): Cannot find name 'CFRunLoopGetMain'.
ERROR in /path/to/TNSStudio/app/modules/recorder/components/record.component.ts (130,9): Cannot find name 'CFRunLoopPerformBlock'.
ERROR in /path/to/TNSStudio/app/modules/recorder/components/record.component.ts (130,40): Cannot find name 'kCFRunLoopDefaultMode'.
ERROR in /path/to/TNSStudio/app/modules/recorder/components/record.component.ts (131,9): Cannot find name 'CFRunLoopWakeUp'.
大概就在这个时候...
那个 funk soul 兄弟。
是的,你可能正在唱 Fatboy Slim 或即将失去理智,我们理解。有时使用 webpack 捆绑可能相当冒险。我们能提供的最好建议是保持耐心和勤奋,一次处理一个错误;我们几乎做到了。
解决前面错误的方法是包含 iOS 和 Android 平台声明,因为我们正在我们的应用中使用原生 API:
"files": [
"./app/main.ts",
"./app/modules/mixer/mixer.module.ts",
"./app/modules/recorder/recorder.module.ts",
"./node_modules/tns-platform-declarations/ios.d.ts",
"./node_modules/tns-platform-declarations/android.d.ts"
]
哈喽,我们现在已经完全解决了第二个问题。让我们继续下一个问题。
解决方案 #3:在静态解析符号值时遇到错误
考虑到在 准备使用 webpack 部分提到的第三个完整错误:
ERROR in Error encountered resolving symbol values statically. Calling function 'ModalDialogParams', function calls are not supported. Consider replacing the function or lambda with a reference to an exported function, resolving symbol RecorderModule in /path/to/TNSStudio/app/modules/recorder/recorder.module.ts, resolving symbol RecorderModule in /path/to/TNSStudio/app/modules/recorder/recorder.module.ts
解决前面错误的方法是打开 app/modules/recorder/recorder.module.ts,并做出以下更改:
...
// factory functions
export function defaultModalParamsFactory() {
return new ModalDialogParams({}, null);
};
...
@NgModule({
...
providers: [
...PROVIDERS,
{
provide: ModalDialogParams,
useFactory: defaultModalParamsFactory
}
],
...
})
export class RecorderModule { }
这将满足 Angular AoT 编译器静态解析符号的需求。
解决方案 #4 和 #5:无法解析 './app.css'
考虑到在 准备使用 webpack 部分提到的第 4 和第 5 个错误:
4\. ERROR in Entry module not found: Error: Can't resolve './app.css' in '/path/to/TNSStudio/app'
5\. ERROR in [copy-webpack-plugin] unable to locate 'app.css' at '/path/to/TNSStudio/app/app.css'
解决前面错误的方法实际上与我们在使用特定平台的 .ios.css 和 .android.css 有关,这些文件通过 SASS 编译。我们需要更新我们的 webpack 配置,使其知道这一点。打开插件为我们自动添加的 webpack.config.js,并做出以下更改:
module.exports = env => {
const platform = getPlatform(env);
// Default destination inside platforms/<platform>/...
const path = resolve(nsWebpack.getAppPath(platform));
const entry = {
// Discover entry module from package.json
bundle: `./${nsWebpack.getEntryModule()}`,
// Vendor entry with third-party libraries
vendor: `./vendor`,
// Entry for stylesheet with global application styles
[mainSheet]: `./app.${platform}.css`,
};
...
function getPlugins(platform, env) {
...
// Copy assets to out dir. Add your own globs as needed.
new CopyWebpackPlugin([
{ from: "app." + platform + ".css", to: mainSheet },
{ from: "css/**" },
{ from: "fonts/**" },
{ from: "**/*.jpg" },
{ from: "**/*.png" },
{ from: "**/*.xml" },
], { ignore: ["App_Resources/**"] }),
...
好吧,我们现在已经解决了所有捆绑问题,或者等等....我们真的解决了吗?!
我们还没有在模拟器或设备上尝试运行应用程序。如果我们现在使用npm run start-ios-bundle或通过 XCode 或npm run start-android-bundle来尝试这样做,您可能会在应用程序尝试启动时遇到崩溃,并出现如下错误:
JS ERROR Error: No NgModule metadata found for 'AppModule'.
解决前面错误的方法是确保您的应用程序包含一个./app/main.aot.ts文件,并包含以下内容:
import { platformNativeScript } from "nativescript-angular/platform-static";
import { AppModuleNgFactory } from "./app.module.ngfactory";
platformNativeScript().bootstrapModuleFactory(AppModuleNgFactory);
如果您还记得,我们有一个演示组合设置,它从audio文件夹加载其音轨文件。我们还利用了来自assets文件夹的 font-awesome.css 文件中的 font-awesome 图标。我们需要确保这些文件夹也复制到我们的生产 webpack 构建中。打开webpack.config.js并做出以下更改:
new CopyWebpackPlugin([
{ from: "app." + platform + ".css", to: mainSheet },
{ from: "assets/**" },
{ from: "audio/**" },
{ from: "css/**" },
{ from: "fonts/**" },
{ from: "**/*.jpg" },
{ from: "**/*.png" },
{ from: "**/*.xml" },
], { ignore: ["App_Resources/**"] }),
成功!
我们现在可以使用以下命令运行我们的捆绑应用程序,而不会出现任何错误:
-
npm run start-ios-bundle -
打开 XCode 项目并运行
npm run start-android-bundle
值得注意的是,我们为了使应用程序能够进行 webpack 捆绑发布所做的所有更改,在开发中也同样完美有效,所以请放心,您在这个阶段只是改进了应用程序的设置。
偏航 - 开发 webpack 插件的概述
我们现在想回到我们在捆绑应用程序时遇到的第一个错误,它是这样的:
- 错误:在
/path/to/TNSStudio/app/modules/player/directives/slider.directive.d.ts中意外值SlimSliderDirective由模块PlayerModule在/path/to/TNSStudio/app/modules/player/player.module.ts中声明。请添加@Pipe/@Directive/@Component注解。
在撰写本书时,这个错误还没有解决方案,所以我们创建了nativescript-webpack-import-replace(github.com/NathanWalker/nativescript-webpack-import-replace)插件来解决这个问题。
详细介绍开发 webpack 插件超出了本书的范围,但我们想为您提供一些关于该过程的亮点,以防您需要为您的应用程序创建一个来解决特定情况。
我们首先创建了一个单独的项目,并包含一个package.json文件,这样我们就可以像安装其他 npm 插件一样安装我们的 webpack 插件:
{
"name": "nativescript-webpack-import-replace",
"version": "1.0.0",
"description": "Replace imports with .ios or .android suffix for target mobile platforms.",
"files": [
"index.js",
"lib"
],
"engines": {
"node": ">= 4.3 < 5.0.0 || >= 5.10"
},
"author": {
"name": "Nathan Walker",
"url": "http://github.com/NathanWalker"
},
"keywords": [
"webpack",
"nativescript",
"angular"
],
"nativescript": {
"platforms": {
"android": "3.0.0",
"ios": "3.0.0"
},
"plugin": {
"nan": "false",
"pan": "false",
"core3": "true",
"webpack": "true",
"category": "Developer"
}
},
"homepage": "https://github.com/NathanWalker/nativescript-webpack-import-replace",
"repository": "NathanWalker/nativescript-webpack-import-replace",
"license": "MIT"
}
nativescript键实际上有助于在各个 NativeScript 插件列表网站上对这个插件进行分类。
然后,我们创建了lib/ImportReplacePlugin.js来表示我们将在 webpack 配置中导入和使用的实际插件类。我们在这个lib文件夹中创建了此文件,以防万一我们需要添加额外的支持文件来帮助我们的插件,以便在插件组织中获得良好的关注点分离。在这个文件中,我们通过定义一个包含我们的插件构造函数的闭包来设置导出:
exports.ImportReplacePlugin = (function () {
function ImportReplacePlugin(options) {
if (!options || !options.platform) {
throw new Error(`Target platform must be specified!`);
}
this.platform = options.platform;
this.files = options.files;
if (!this.files) {
throw new Error(`An array of files containing just the filenames to replace with platform specific names must be specified.`);
}
}
return ImportReplacePlugin;
})();
这将接受我们在 webpack 配置中定义的目标platform,并将其作为选项传递,同时附带一个files集合,该集合将包含我们需要替换的所有导入的文件名。
我们接下来想要连接到 webpack 的 make 生命周期钩子,以便抓取正在处理的源文件以便解析:
ImportReplacePlugin.prototype.apply = function (compiler) {
compiler.plugin("make", (compilation, callback) => {
const aotPlugin = getAotPlugin(compilation);
aotPlugin._program.getSourceFiles()
.forEach(sf => {
this.usePlatformUrl(sf)
});
callback();
})
};
function getAotPlugin(compilation) {
let maybeAotPlugin = compilation._ngToolsWebpackPluginInstance;
if (!maybeAotPlugin) {
throw new Error(`This plugin must be used with the AotPlugin!`);
}
return maybeAotPlugin;
}
这将抓取所有的 AoT 源文件。然后我们设置一个循环逐个处理它们,并为所需的内容添加处理方法:
ImportReplacePlugin.prototype.usePlatformUrl = function (sourceFile) {
this.setCurrentDirectory(sourceFile);
forEachChild(sourceFile, node => this.replaceImport(node));
}
ImportReplacePlugin.prototype.setCurrentDirectory = function (sourceFile) {
this.currentDirectory = resolve(sourceFile.path, "..");
}
ImportReplacePlugin.prototype.replaceImport = function (node) {
if (node.moduleSpecifier) {
var sourceFile = this.getSourceFileOfNode(node);
const sourceFileText = sourceFile.text;
const result = this.checkMatch(sourceFileText);
if (result.index > -1) {
var platformSuffix = "." + this.platform;
var additionLength = platformSuffix.length;
var escapeAndEnding = 2; // usually "\";" or "\';"
var remainingStartIndex = result.index + (result.match.length - 1) + (platformSuffix.length - 1) - escapeAndEnding;
sourceFile.text =
sourceFileText.substring(0, result.index) +
result.match +
platformSuffix +
sourceFileText.substring(remainingStartIndex);
node.moduleSpecifier.end += additionLength;
}
}
}
ImportReplacePlugin.prototype.getSourceFileOfNode = function (node) {
while (node && node.kind !== SyntaxKind.SourceFile) {
node = node.parent;
}
return node;
}
ImportReplacePlugin.prototype.checkMatch = function (text) {
let match = '';
let index = -1;
this.files.forEach(name => {
const matchIndex = text.indexOf(name);
if (matchIndex > -1) {
match = name;
index = matchIndex;
}
});
return { match, index };
}
构建 webpack 插件的一个有趣的部分(并且可以说是最具挑战性的)是与你的源代码的 抽象语法树(ASTs)一起工作。我们插件的一个关键方面是从 AST 中获取“源文件”节点,如下所示:
ImportReplacePlugin.prototype.getSourceFileOfNode = function (node) {
while (node && node.kind !== SyntaxKind.SourceFile) {
node = node.parent;
}
return node;
}
这有效地排除了任何其他不是源文件的节点,因为我们的插件只需要处理这些节点。
最后,我们在根目录创建一个 index.js 文件,以便简单地导出插件文件以供使用:
module.exports = require("./lib/ImportReplacePlugin").ImportReplacePlugin;
在这个 webpack 插件的帮助下,我们能够完全解决我们在应用中遇到的所有 webpack 打包错误。
摘要
在这一章中,我们通过将 webpack 添加到我们的构建链中来准备我们的应用分发,以确保我们有最小的 JavaScript 大小和最佳代码执行性能。这也使得 Angular 的 AoT 编译在我们的应用中成为可能,这有助于提供我们代码的最佳性能。
在此过程中,我们提供了一系列解决方案的食谱,以解决你可能在应用开发过程中遇到的 webpack 打包错误。此外,我们还从高层次的角度探讨了开发自定义 webpack 插件,以帮助解决我们应用中特定的错误条件,从而实现成功的打包。
现在我们已经得到了我们应用代码的最佳打包,我们现在准备完成我们的分发步骤,以便在下一章中最终部署我们的应用。
第十五章:部署到 Apple 应用商店
在本章中,我们将重点介绍如何将我们的应用部署到 Apple 应用商店。我们将遵循几个重要的步骤,所以请密切关注这里展示的所有细节。
无论你需要与签名证书一起构建我们的应用发布目标,生成应用图标和启动画面,还是在 XCode 中归档我们的应用以上传到应用商店,我们将在本章中涵盖所有这些主题。
Progress 的 NativeScript 专家和开发者倡导者 TJ VanToll 撰写了一篇优秀的文章,详细介绍了部署步骤,题为《发布您的 NativeScript 应用到应用商店的 8 个步骤》(www.nativescript.org/blog/steps-to-publish-your-nativescript-app-to-the-app-stores)。我们将从那篇文章中摘录内容,并在本章和下一章尽可能扩展相关部分。
没有必要向你撒谎——将 iOS 应用发布到 iOS 应用商店是你软件开发生涯中会经历的最痛苦的过程之一。所以,如果你在这些步骤中遇到困难或感到困惑,请记住,这不仅仅是你——每个人在第一次发布 iOS 应用时都会感到沮丧。
本章涵盖了以下主题:
-
如何创建 App ID 和生产证书以签名你的应用发布目标
-
如何配置 NativeScript 应用,使其具有发布所需的适当元数据
-
如何处理应用图标和启动画面
-
使用 NativeScript CLI 上传你的构建到 iTunes Connect
准备 App Store 分发
要将 iOS 应用部署到 iOS 应用商店,你绝对需要一个活跃的 Apple 开发者账户。成为该项目的成员每年需要支付 99 美元 USD,你可以在developer.apple.com/register注册。
App ID、证书和配置文件
一旦你创建了 Apple 开发者账户,你需要在 Apple 开发者门户上创建 App ID、生产证书和分发配置文件。这是整个过程中最繁琐的部分,因为它需要一些时间来学习这些各种文件的作用以及如何使用它们:
- 对于我们的应用,我们将从以下步骤开始创建 App ID:

- 一旦我们创建了此 App ID,我们现在可以创建一个生产证书:

- 选择继续。然后,下一屏幕将提供如何签名生产证书的说明,我们将在下一步进行讲解。首先,打开
/Applications/Utilities/Keychain Access.app,然后转到左上角的菜单并选择 证书助手 | 使用如下设置从证书颁发机构请求证书:

这将在你选择的任何位置保存一个签名请求文件,你将在下一步需要它。
- 现在,在门户的这个步骤中选择那个签名请求文件:

- 在下一屏,非常重要的一点是下载并双击您需要安装到钥匙链中的文件,因为它指定了:

- 当双击文件以将其安装到钥匙链时,可能会提示您提供钥匙链以安装文件;使用登录钥匙链将正常工作:

您现在应该在钥匙链访问应用程序中看到以下类似截图:

-
您现在可以退出钥匙链访问。
-
接下来,我们想要创建一个发行配置文件:

- 在下一屏,只需确保您选择了您创建的应用程序 ID:

- 然后,在下一屏,您应该能够选择您创建的发行证书:

- 然后,您将能够为配置文件命名:

- 您可以下载配置文件并将其放置在您的
ios_distribution.cer文件旁边;然而,没有必要打开该配置文件,因为 XCode 将处理其他所有事情。
配置应用程序元数据,如应用程序 ID 和显示名称
在将应用程序部署到其相应商店之前,iOS 和 Android 应用程序有很多信息需要您配置。NativeScript 为许多这些值提供了智能默认值,但在部署之前,您可能需要审查其中的一些。
应用程序 ID
刚才在苹果开发者门户中配置的应用程序 ID 是您的应用程序的唯一标识符,它使用一种称为反向域名表示法的东西。我们的 NativeScript 应用程序的元数据必须匹配。我们这个应用程序的应用程序 ID 是io.nstudio.nStudio。NativeScript CLI 在应用程序创建期间设置应用程序 ID 有一个约定:
tns create YourApp --appid com.mycompany.myappname
我们在创建我们的应用程序时没有使用此选项;然而,更改我们的应用程序 ID 很容易。
打开应用程序根目录下的package.json文件,找到nativescript键。确保id属性包含您想要使用的值:

显示名称
您应用程序的显示名称是用户在屏幕上看到您图标旁边的名称。默认情况下,NativeScript 会根据您传递给tns create的值设置您的应用程序显示名称,这通常不是您希望用户看到的。例如,运行tns create my-app会导致应用程序的显示名称为myapp。
要在 iOS 上更改此值,首先打开您的应用程序的app/App_Resources/iOS/Info.plist文件。Info.plist文件是 iOS 的主要配置文件,在这里您将找到许多您在发布应用程序之前可能想要调整的值。对于显示名称,您将想要更改CFBundleDisplayName值。
这是nStudio的此值的外观:

尽管显示名称没有实际的字符限制,但 iOS 和 Android 都会在约 10-12 个字符后截断您的显示名称。
创建您的应用程序图标和启动画面
你的应用图标是用户首先注意到你的应用的地方。当你开始一个新的 NativeScript 应用时,你会得到一个占位符图标,这对于开发来说是可以的;然而,对于生产版本,你需要用你想要提交到商店的图片替换占位符图标。
为了将你的生产就绪应用图标文件放置到位,你首先需要创建一个代表你应用的 1024 x 1024 像素的.png图像资产。
为了让你更难,iOS 和 Android 都要求你提供一系列不同尺寸的图标图片。不过别担心;一旦你有一个 1024 x 1024 的图片,有几个网站可以生成 Android 和 iOS 所需的各种尺寸的图片。对于 NativeScript 开发,我推荐使用 Nathanael Anderson 的 NativeScript Image Builder,它可以在images.nativescript.rocks找到。
我们将在 Photoshop 中创建我们的图标:

我们可以将其导出为.png格式并上传到images.nativescript.rocks:

当你点击“Go”时,将下载一个 zip 文件,其中包含你的应用图标和启动画面。你可以将这些图片分别复制到app/App_Resources文件夹中,用于 iOS(我们将在下一章介绍 Android)。
我们现在已经准备好了应用图标和启动画面。
构建发布版的应用
由于我们已经在上一章中介绍了 webpack 打包问题,我们现在可以使用以下命令构建最终的发布包:
npm run build-ios-bundle -- --release --forDevice --teamId KXPB57C8BE
注意,--teamId对于你来说将是不同的。它是上一条命令中提供的 App ID 的前缀。
在此命令完成后,你将在platforms/ios/build/device文件夹中获得所需的.ipa文件。请记下该文件的位置,因为在指南的最终步骤中你需要用到它。
呼吁!希望你已经完好无损地到达了这个阶段。你现在可以进入最终步骤,iTunes Connect。
上传到 iTunes Connect
你首先需要做的是注册你的应用。为此,请访问itunesconnect.apple.com/,点击“我的应用”,然后点击屏幕左上角的+按钮(目前位于屏幕顶部左侧),接着选择“新建应用”。在接下来的屏幕上,确保你选择了正确的 Bundle ID,SKU 可以是任何你想要用来识别应用的数字;我们喜欢使用当前日期:

在提供这些信息后,你将被带到应用仪表板,在那里我们需要提供更多关于我们应用的数据。大部分信息都很直接,比如描述和定价,但也有一些有趣的部分我们需要处理,比如截图。
iTunes Connect 现在要求你上传两组截图,一组用于最大的 iPhone 设备(5.5 英寸显示屏),另一组用于最大的 iPad 设备(12.9 英寸设备)。苹果仍然允许你为每个 iOS 设备尺寸提供优化的截图,但如果你只提供 5.5 英寸和 12.9 英寸的截图,苹果将自动将你提供的截图缩放到较小的显示设备。
要获取这些截图,我们可以在物理 iPhone Plus 和 iPad Pro 设备上运行应用,但我们发现从 iOS 模拟器中获取这些截图要容易得多。
在正确的模拟设备运行时,我们可以使用模拟器的Cmd + S键盘快捷键来截取应用屏幕截图,这将把适当的图像保存到我们的桌面上。
到目前为止,我们已经准备就绪。我们将使用 DaVinci(www.davinciapps.com)等服务来润色我们的图像文件,但当我们准备好时,我们会将图像拖入 iTunes Connect 的应用预览和截图区域。
上传你的.ipa 文件
我们即将完成!一旦所有信息都已输入到 iTunes Connect,最后一步是将构建的.ipa文件与我们所输入的所有信息关联起来。
我们将使用 NativeScript CLI 来完成这项工作。
记住你的.ipa 文件位于应用的platforms/ios/build/device文件夹中。
运行以下命令将你的应用发布到 iTunes Connect:
tns publish ios --ipa <path to your ipa file>
就这样。不过,有一个重要的注意事项,尽管原因不明,但当你上传 iOS 应用和你的应用出现在 iTunes Connect 之间有一个非微不足道的延迟。我们观察到延迟可能短至 30 秒,长达一小时。一旦构建出现在那里,我们就可以继续点击大型的“提交审核”按钮,并交叉手指。
苹果公司在审核你提交的 iOS 应用时,以众所周知的间歇性延迟著称。在撰写本书时,iOS 应用商店的平均审核时间为大约 2 天。
摘要
在本章中,我们强调了发布应用到 Apple App Store 必须采取的关键步骤,包括签名证书、应用 ID、应用图标和启动屏幕。这个过程一开始可能看起来非常复杂,但一旦你更好地理解了各个步骤,它就会变得更加清晰。
现在,我们的应用已经在商店中等待审核,我们正朝着让我们的应用在全球范围内对用户可用迈进。
在下一章中,让我们通过将应用部署到 Google Play Store 来扩展我们的受众来完成这项工作。
第十六章:部署到 Google Play
与苹果 App Store 相比,将应用部署到 Google Play 可能稍微简单一些,但仍有一些关键步骤需要我们注意。我们在第十四章(part0154.html#4IRMK0-289fe2426d594f99a90e4363b2c9c34d)使用 webpack 捆绑的部署准备和第十五章(part0163.html#4REBM0-289fe2426d594f99a90e4363b2c9c34d)部署到苹果 App Store中介绍了一些准备工作,例如使用 webpack 捆绑应用和准备应用图标和启动画面,因此我们将直接进入构建可发布的 APK。
我们对 TJ VanToll 表示感激,他撰写了一篇优秀的八步文章,介绍了如何部署 NativeScript 应用(www.nativescript.org/blog/steps-to-publish-your-nativescript-app-to-the-app-stores),我们将从中摘录并尽可能进行扩展。
本章涵盖了以下主题:
-
使用生成密钥库来构建你的 APK
-
使用 NativeScript CLI 构建可发布的 APK
-
将 APK 上传到 Google Play 进行发布
为 Google Play 构建 APK
在你打开 Google Play 进行注册和发布此应用(这是下一步)之前,让我们再次检查一些事情,以确保我们的元数据是正确的。
打开app/App_Resources/Android/app.gradle并确保applicationId对于你的包名是正确的:

此外,在项目根目录打开package.json,也仔细检查那里的nativescript.id:

现在,你需要为你的应用程序生成一个可执行的 Android 文件。在 Android 上,此文件具有.apk扩展名,你可以使用 NativeScript CLI 生成此文件。
你在 NativeScript 开发期间使用的tns run命令实际上为你生成了一个.apk文件,并将其安装到 Android 模拟器或设备上。然而,对于 Google Play 的发布,你创建的构建也必须进行代码签名。如果你想深入了解代码签名的加密细节,可以参考 Android 的文档(developer.android.com/studio/publish/app-signing.html),但总的来说,你需要完成以下两个步骤来创建 Android 应用的发布版本:
-
创建一个
.keystore或.jks(Java 密钥库)文件 -
使用那个
.keystore或.jks文件在构建过程中对你的应用进行签名
Android 文档为你提供了一些创建密钥库文件的方法(developer.android.com/studio/publish/app-signing.html#release-mode)。我们首选的方法是keytool命令行工具,它包含在 NativeScript 为你安装的 Java JDK 中,因此它应该已经存在于你的开发机器的命令行中。
要使用 keytool 生成用于代码签名的密钥库,我们将使用以下命令:
keytool -genkey -v -keystore nstudio.jks -keyalg RSA -keysize 2048 -validity 10000 -alias nstudio
keytool 工具会问你一些问题,其中一些是可选的(组织名称以及你所在的城市、州和国家名称),但最重要的一个是密钥库和别名(稍后详细说明)的密码。以下是当我们生成密钥库时 keytool 处理过程的样子:

在我们继续介绍如何使用这个 .jks 文件之前,有一件重要的事情你需要知道。将这个 .jks 文件放在一个安全的地方,并且不要忘记密钥库或别名的密码。(我个人喜欢为我的密钥库和别名使用相同的密码,以简化我的生活。)Android 要求你必须使用这个确切的 .jks 文件来为任何和所有应用的更新签名。这意味着如果你丢失了这个 .jks 文件或其密码,你将无法更新你的 Android 应用。你将不得不在 Google Play 中创建一个新的条目,并且你的现有用户将无法升级——所以请务必不要丢失它!
哦,还有一点需要注意,在大多数情况下,你希望使用单个密钥库文件来为所有个人或公司的 Android 应用程序签名。还记得你不得不向 keytool 工具传递 -alias 标志,以及那个别名有自己的密码吗?实际上,一个密钥库可以有多个别名,你将为每个构建的 Android 应用程序创建一个。
好的,现在你有了这个 .jks 文件,并且将它存储在一个安全的地方,接下来的过程就相当简单了。
使用 webpack 构建我们的 Android 应用,并传递你刚刚用来创建 .jks 文件的信息。例如,以下命令用于创建 nStudio 的发布版本:
npm run build-android-bundle -- --release --keyStorePath ~/path/to/nstudio.jks --keyStorePassword our-pass --keyStoreAlias nstudio --keyStoreAliasPassword our-alias-pass
一旦命令运行完成,你将在应用的 platforms/android/build/outputs/apk 文件夹中有一个可发布的 .apk 文件;注意该文件的位置,因为你将在下一步——在 Google Play 上部署你的应用时需要它。

上传到 Google Play
Google Play 是 Android 用户寻找和安装应用的地方,Google Play 开发者控制台([play.google.com/apps/publish/](https://play.google.com/apps/publish/))是开发者注册和上传应用给用户的地方。
你首先将根据名称创建一个新的应用程序,然后你会看到它被列出:

Android 关于上传应用和设置商店列表的文档相当不错,所以我们不会在这里重新创建所有这些信息。相反,我们将提供一些你可能在上传自己的 NativeScript 应用到 Google Play 时觉得有用的提示。
在 Google Play 开发者控制台的“商店列表”标签页,你必须至少提供两张你的应用运行时的截图,如下所示:

使用 tns run android --emulator 命令在 Android Android 虚拟设备(AVD)中启动您的应用。Android AVD 内置了使用模拟器侧边栏中的小相机图标来截图的方式。
使用此按钮来获取您应用中最重要屏幕的几张截图,图像文件将出现在您的桌面上。从那里,您可以取出这些文件并直接上传到谷歌应用开发者控制台。还需要一个 1024 x 500 的特色图形图像文件,它将出现在您的商店列表顶部,如下面的截图所示:

尽管前一个截图中没有显示,但我们建议您使用像 DaVinci (www.davinciapps.com) 这样的服务来为您的截图增添一些风采,并将它们变成展示您的应用功能的简短教程。
APK
在本章前面的步骤中,Google Play 开发者控制台的“应用发布”部分是您上传生成的 .apk 文件的地方。
当您查看“应用发布”部分时,可能会提到选择加入谷歌应用签名。最好是现在就加入,而不是以后。一旦您加入,它将显示为“已启用”:

然后,您可以继续上传位于您的应用 platforms/android/build/outputs/apk 文件夹中的 apk 文件。
一旦您上传了 APK,您应该会在同一页面上看到它列出,您可以在该页面上用您选择的多种语言编写上传版本的发布说明:

在该页面上点击“保存”后,您可能想返回商店列表部分,以完成填写您应用的所有信息。一旦一切就绪,您就可以提交您的应用了。Android 应用审核通常需要几个小时,除非谷歌标记出任何问题,否则您的应用应在半天左右的时间内出现在谷歌应用商店中。
摘要
哇哦!我们在苹果应用商店和谷歌应用商店都从零开始发布了应用,这是一次充满曲折和转折的冒险。我们真诚地希望这能让您对 NativeScript for Angular 应用程序开发有更深的了解,同时也为那些对此技术栈好奇的人解开了神秘的面纱。
NativeScript 和 Angular 都拥有蓬勃发展的全球社区,我们鼓励您参与其中,分享您的经验,并与其他人分享您和您的团队可能正在进行的所有激动人心的项目中的兴奋。永远不要犹豫去寻求帮助,因为我们都对这两种技术充满热爱和钦佩。
这里还有一些额外的有用资源您可以查看:
当然,了解文档也是非常重要的!
docs.nativescript.org/angular/start/introduction.html
干杯!


浙公网安备 33010602011771号