Angular-NativeScript-移动开发-全-
Angular NativeScript 移动开发(全)
原文:
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 移动应用程序所需了解的关键概念。我们将构建一个有趣的多轨录音工作室应用程序,涉及到您在开始构建自己的应用程序时需要了解的强大的本地关键概念。拥有正确的结构对于开发一个可扩展且易于维护和移植的应用程序至关重要,因此我们将从使用 Angular 的@NgModule 进行项目组织开始。我们将使用 Angular 组件构建我们的第一个视图,然后创建服务,我们可以通过 Angular 的依赖注入来使用。
您将了解 NativeScript 的 tns 命令行实用程序,以在 iOS 和 Android 上运行应用程序。我们将集成第三方插件来构建一些核心功能。接下来,我们将集成@ngrx 存储加效果,以建立一些可靠的实践(受 Redux 启发)来处理状态管理。如果应用程序看起来不好或提供出色的用户体验,那么拥有良好的数据流和坚实的架构是毫无意义的,因此我们将使用 SASS 为我们的应用程序打磨样式。之后,我们将处理调试问题,并投入一些时间编写测试,以防止将来出现错误。最后,我们将使用 webpack 捆绑我们的应用程序,并将其部署到 Apple 应用商店和 Google Play。
在书的结尾,您将了解构建用于 Angular 应用程序的 NativeScript 所需的大部分关键概念。
本书内容
第一章,使用@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 应用商店,让我们通过 Apple 应用商店分发我们的应用。
第十六章,发布到 Google Play,让我们通过 Google Play 分发我们的应用。
您需要准备什么
本书假定您正在使用 NativeScript 3 或更高版本和 Angular 4.1 或更高版本。如果您计划进行 iOS 开发,您将需要安装 XCode 的 Mac 来运行配套应用。您还应该安装了 Android SDK 工具,并且至少有一个模拟器,最好是运行 API 24 或更高版本的 7.0.0。
本书适合对象
本书适用于所有类型的软件开发人员,他们对 iOS 和 Android 的移动应用开发感兴趣。它专门为那些已经对 TypeScript 有一般了解并且具有一些基本水平的 Angular 特性的人提供帮助。刚开始接触 iOS 和 Android 移动应用开发的 Web 开发人员也可能从本书的内容中获益良多。
约定
在本书中,您将找到一些文本样式,用于区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"支持各种常见属性(填充
、字体大小
、字重
、颜色
、背景颜色
等)。此外,简写的边距/填充也同样有效,即填充:15 5。"
代码块设置如下:
[default]
export class AppComponent {}
当我们希望引起您对代码块特定部分的注意时,相关行或项目会以粗体显示:
[default]
public init() {
const item = {};
item.volume = 1; }
任何命令行输入或输出都以以下方式书写:
# tns run ios --emulator
新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,会以这样的方式出现在文本中:"再次运行我们的应用程序,现在当我们点击“记录”按钮时,我们会看到登录提示"。
警告或重要提示会以这种方式出现。提示和技巧会以这种方式出现。
第一章:使用@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 是您可以在本地计算机上编译的唯一平台。
创建我们的模块外壳
尚未编写服务实现的情况下,我们可以通过开始定义它应该提供什么来大致了解我们的CoreModule
将会是什么样子,使用NgModule
:
让我们创建app/modules/core/core.module.ts
:
// angular
import { NgModule } from '@angular/core';
@NgModule({})
export class CoreModule { }
可注入的服务
现在,让我们为我们的服务创建模板。请注意,这里导入了可注入的装饰器,以声明我们的服务将通过 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
文件,它将导出我们的服务的const
集合,并按字母顺序导出这些服务(以保持整洁):
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
来使用我们创建的内容。我们还将利用这个机会导入NativeScriptModule
,这是我们的应用程序需要与其他 NativeScript for Angular 功能一起使用的。因为我们知道我们将全局使用这些功能,我们还可以指定它们被导出,这样当我们导入和使用我们的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 { }
我们的两个主要功能模块已经搭好架子,准备就绪,让我们重新审视我们在第一章“使用@NgModule 进入形状”中创建的两个低级服务,并提供实现。
实现 LogService
日志记录是您在应用程序的开发生命周期以及在生产中想要的重要工具。它可以帮助您调试,以及获得对应用程序使用方式的重要见解。通过一个单一的路径运行所有日志也提供了一个机会,通过翻转开关重新路由所有应用程序日志到其他地方。例如,您可以使用第三方调试跟踪服务,如 TrackJS(trackjs.com
),通过 Segment(segment.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
并修改它,以提供类似于 Web 的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
。这将导致 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 构建视图与为 Web 构建视图并没有太大的不同。我们将使用 Angular 的 Component 装饰器来构建各种组件,以实现我们所需的可用性。我们将使用 NativeScript XML 而不是 HTML 标记,这是一个非常强大、简单而简洁的抽象,可以在 iOS 和 Android 上使用所有本地视图组件。
我们不会在这里涵盖您可以访问的所有组件的好处和类型;但是要了解更多信息,我们建议阅读以下任何一本书:
在本章中,我们将涵盖以下主题:
-
使用 Component 装饰器来组合我们的视图
-
创建可重用的组件
-
使用管道创建自定义视图过滤器
-
在 iOS 和 Android 模拟器上运行应用程序
我们的第一个视图通过组件构建
如果我们从第一章 使用@NgModule 塑造应用程序中查看我们的草图,我们可以看到应用程序顶部有一个标题,其中包含我们的应用程序标题和右侧的记录按钮。我们还可以看到一些播放器控件的轨道列表在底部。我们可以将我们的 UI 设计的这些关键元素基本上分解为三个主要组件。一个组件已经由 NativeScript 框架提供,ActionBar,我们将使用它来表示顶部标题。
NativeScript 提供了许多丰富的视图组件来构建我们的 UI。标记不是 HTML,而是 XML,具有.html
扩展名,这可能看起来不太寻常。使用.html
扩展名用于 NativeScript for Angular 的 XML 视图模板的原因是,自定义渲染器(github.com/NativeScript/nativescript-angular
)使用 DOM 适配器来解析视图模板。每个 NativeScript XML 组件代表各自平台上的真正本地视图小部件。
对于另外两个主要组件,我们将使用 Angular 的 Component 装饰器。在应用程序开发周期的这个阶段,思考封装的 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)。这使您在如何使用剩余空间方面具有极大的灵活性。
第三种大小类型是使用的空间。所以发生的情况是网格内的内容被测量,然后列被分配为该列(或行)中使用的最大尺寸。当您有一个包含数据的网格,但不确定大小或者您并不在乎时,这是非常有用的;您只是希望它看起来不错。因此,这是自动关键字。我可能会写 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 的*
在 rows 属性中定义了灵活(剩余)的垂直高度。网格的底部部分(第 1 行)将表示播放器控件,允许用户播放/暂停混音并移动播放位置。
现在我们已经以相当抽象的方式定义了应用程序的主视图,让我们深入研究我们需要构建的两个自定义组件,track-list
和player-controls
。
构建 TrackList 组件
曲目列表应该是所有录制曲目的列表。列表中的每一行都应该提供一个单独的录制按钮,以重新录制,另外还应该提供一个用于显示用户提供的标题的名称标签。它还应该提供一个开关,允许用户独奏特定的曲目。
我们可以注入PlayerService
并将其声明为public
,以便我们可以直接绑定到服务的 tracks 集合。
我们还可以模拟一些绑定来启动一些操作,比如record
操作。现在,让我们允许传入一个 track,并通过LogService
打印出对该 track 的检查。
让我们从创建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
),提供了 60fps 的虚拟滚动和重用行。它在移动设备上的性能是无与伦比的:
<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
,我们可以通过标准的 Angular 绑定语法[items]
直接绑定到其 tracks,这将是我们的列表将迭代的集合。
内部的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 提供自定义的原生对话框实现,以获得更好的用户体验。有几个插件提供非常优雅的原生对话框,例如,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
引用进行了身份验证,该引用是在应用启动时通过 Angular 的依赖注入首次构建AuthService
时设置的(参见第二章,特性模块)。
如果用户已经通过身份验证,我们会呈现一个确认对话框以确保操作是有意的。
如果用户没有经过身份验证,我们希望提示用户登录。为了减少本书的负担,我们将假设用户已经通过后端 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 包含一个 order 属性,这将帮助用户方便地按照他们喜欢的方式对曲目进行排序。
创建一个 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 将声明TrackListComponent
的PlayerModule
编译到自身中,以孤立的方式。由于我们将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 模拟器,你应该会看到以下截图:
在 Android 上预览
你需要安装 AndroidSDKk 和工具才能在 Android 模拟器上预览:
tns run android --emulator
这将启动一个 Android 模拟器,你应该会看到以下截图:
恭喜!我们有了我们的第一个视图。嘿,没人说它会很漂亮!
总结
我们已经开始了第二部分的组件构建,我们已经布置了我们的根组件app.component.html
来容纳我们的主视图,你将学习到GridLayout
,一个非常有用的布局容器。
Angular 的组件装饰器使我们能够轻松构建TrackListComponent
和PlayerControlsComponent
。我们还学会了如何构建一个 Angular Pipe
来帮助我们的视图保持我们的曲目列表有序。Angular 的NgModule
教会了我们需要确保任何与视图相关的声明都被正确导入。这种 Angular 设计模式有助于保持模块隔离,作为可以通过相互导入模块相互混合的独立代码单元。
我们还增强了一些服务,以支持我们对组件所需的一些可用性。
最后,我们能够快速地看一下我们正在构建的东西。尽管目前还不够漂亮,但我们可以看到事情正在逐渐成形。
在第四章 使用 CSS 创建更漂亮的视图 中,你将学习如何使用 CSS 来美化我们的视图。
第四章:使用 CSS 创建更美观的视图
NativeScript 为原生应用程序开发带来的许多关键好处之一是能够使用标准 CSS 为原生视图组件设置样式。您会发现对许多常见和高级属性有很好的支持;然而,有些属性没有直接对应,而其他属性则完全是原生视图布局的独特之处。
让我们看看如何使用一些 CSS 类将我们的第一个视图变得非常惊人。您还将学习如何利用 NativeScript 的核心主题来提供一致的样式框架以构建。
在本章中,我们将涵盖以下主题:
-
使用 CSS 来为视图设置样式
-
了解典型 Web 样式和原生样式之间的一些区别
-
使用特定于平台的文件解锁 NativeScript 的功能
-
学习如何使用 nativescript-theme-core 样式框架插件
-
调整 iOS 和 Android 上状态栏的背景颜色和文本颜色
是时候开始优雅了
让我们首先看看我们应用程序主要的app.css
文件,位于App
目录中:
/*
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;
}
从这个简单的例子中,您可能会立即注意到一些有趣的事情:
-
padding
不使用您在 Web 样式中熟悉的px
后缀。 -
不用担心,使用
px
后缀不会伤害您。 -
从 NativeScript 3.0 开始,支持发布单位,因此您可以使用 dp(设备独立像素)或
px
(设备像素)。
如果未指定单位,则将使用 dp。对于宽度/高度和边距,您还可以在 CSS 中使用百分比作为单位类型。
-
支持各种常见属性(
padding
,font size
,font weight
,color
,background color
等)。同样,简写的margin/padding
也可以使用,即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
会发生什么,你应该会看到以下内容:
如果您在 Android 中使用 tns run android --emulator
,您应该会看到以下内容:
我们可以看到,在两个平台上,这些样式都得到了一致的应用,同时仍然保持了每个平台的独特特征。例如,iOS 保持了按钮的扁平设计美学,开关提供了熟悉的 iOS 感觉。相比之下,在 Android 上,按钮保留了其微妙的默认阴影和全大写文本,同时保留了熟悉的 Android 开关。
然而,有一些微妙的(可能不理想的)差异是重要的理解和解决的。从这个例子中,我们可能注意到以下内容:
-
Android 的按钮左右边距比 iOS 宽。
-
行标题的对齐不一致。在 iOS 上,默认情况下,标签是垂直居中的;然而,在 Android 上,它是对齐到顶部的。
-
如果您点击“记录”按钮查看登录对话框,您还会注意到一些非常不理想的东西:
第 3 项可能是最令人惊讶和意想不到的。它展示了全局样式元素/标签/组件名称不建议的主要原因之一。由于原生对话框默认使用 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 项目都安装了一个核心主题,并且可以立即使用。如前所述,您可以选择两种选项来为您的应用程序设置样式。前面的部分概述了您在从头开始为您的应用程序设置样式时可能遇到的一些问题。
让我们来看看 Option #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>
首先,我们添加p-x-10
类来为左/右容器(GridLayout
)添加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 路由器给我们带来的所有灵活性。
在本章中,我们将涵盖以下主题:
-
配置 Angular 路由器与 NativeScript 应用程序
-
按路由懒加载模块
-
为 Angular 的
NgModuleFactoryLoader
提供NSModuleFactoryLoader
-
了解如何在
page-router-outlet
与router-outlet
结合使用 -
学习如何在多个延迟加载模块之间共享单例服务
-
使用身份验证守卫保护需要有效身份验证的视图
-
了解如何使用
NavigationButton
自定义后退移动导航 -
通过引入后期功能需求来利用我们灵活的路由设置
在 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 { }
现在,我们可以使用标准的 Angular 懒加载语法通过 loadChildren
来指定默认的 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
,我们只是将 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。例如,应用程序将使用本机 HTTP API 而不是XMLHttpRequest
(这是一个 Web API),从而提高 iOS 和 Android 的网络性能。我们还确保将它们导出,这样我们的根模块就不再需要导入它们,而是只需导入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
在整个应用程序中共享指令、组件和管道(基本上是模块声明部分中的任何内容)。
我们的根AppModule
在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 { }
任何模块(懒加载或非懒加载)仍然可以注入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'
路径。如果我们回顾一下我们最初的草图,我们想要一个 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
中的 Record 按钮没有任何作用!这是因为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 { }
这确保了通过利用我们应用程序范围的SharedModule
,MixerModule
中现在包含并可用于使用的NativeScriptRouterModule
暴露的所有指令。
再次运行我们的应用程序,现在当我们点击ActionBar
中的 Record 按钮时,我们会看到登录提示。如果我们输入一个格式正确的电子邮件地址和任何密码,它将保留这些详细信息,登录我们,并在 iOS 上显示RecordComponent
如下:
您可能会注意到一些非常有趣的事情。ActionBar
从我们通过 CSS 分配的背景颜色和按钮颜色现在显示默认的蓝色。这是因为RecordComponent
没有定义ActionBar
;因此,它会恢复到一个具有默认返回按钮的默认样式的ActionBar
,该按钮将采用刚刚导航离开的页面的标题。'/record'路由还使用了page-router-outlet
的能力将组件推送到移动导航栈上。RecordComponent
被动画化显示,同时允许用户选择左上角按钮进行导航返回(将导航历史后退一步)。
要修复ActionBar
,让我们在RecordComponent
视图中添加ActionBar
和自定义的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>
然后,我们可以使用 Angular 的RouterExtensions
服务在app/modules/recorder/components/record.component.ts
中实现back()
方法。
// 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
的默认本机行为。相当不错。以下是RecordComponent
在 Android 上的外观:
我们将在接下来的章节中实现一个不错的录音视图。
现在,我们肯定是在 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'
起始页面,这将是我们正在处理的所有作品的列表。我们仍然可以让MixListComponent
在应用启动时自动选择最后选择的作品,以方便以后使用。我们现在已经将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
用户保存的作品列表呈现到视图中,并且当我们首次启动应用时,它将被预先加载一个样本演示作品,其中包含两个录音,以便用户可以玩耍。现在 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
,所以让我们调整组件的视图模板,使其绑定到我们的新 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>
只需确保将其添加到app/modules/mixer/mixer.module.ts
中的MixerModule
的COMPONENTS
中:
...
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 <platform>
不会总是自动检测到node_modules
文件夹的任何更改,例如当您升级插件时。如果是这种情况,您需要取消当前运行的tns run
,然后启动一个新的tns run
。偶尔,tns run
仍然会认为它只需要同步,而实际上它应该重新构建应用程序。在这种情况下,您将需要使用方便的--clean
选项。这对于设备似乎没有接收到任何更改的情况非常重要。tns run <platform> --clean
命令通常会强制重新构建应用程序;然而,如果--clean
无法重新构建,那么请查看本章后面描述的tns build
命令。还有一些其他命令参数并不经常使用,但您可能需要它们来处理特定情况。--justlaunch
将启动应用程序并且不做其他操作;--no-watch
将禁用实时同步,最后--device <device id>
将强制应用程序仅安装在特定设备上。您可以通过运行tns devices
来查看哪些设备可用于安装应用程序。
tns debug
我们将讨论的下一个命令是tns debug <platform>
;这将允许您使用调试工具来测试您的应用程序。这与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]
现在您为应用程序拥有了一个密钥库。
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
请注意,除了确保您保留了密钥库的良好备份外,如果您将应用程序出售给另一个供应商,每个应用程序都有单独的密钥库会使转移对您来说更加简单和安全。如果您使用相同的密钥库和/或别名,这将使您难以区分谁得到了什么。因此,为了简单起见,我个人建议您为每个应用程序设置单独的密钥库和别名。我通常将密钥库保存在应用程序中并进行版本控制。由于打开和访问别名都受到密码保护,除非您选择密码不当,否则一切都很好。
返回命令
现在我们已经花了一些时间处理 Android 密钥库,我们将更深入地了解一些您偶尔在这里和那里使用的 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 信息命令
用于检查主要 NativeScript 组件状态的另一个有用命令是tns info
;这个命令实际上会检查你的主要 NativeScript 部分,并告诉你是否有任何过期的内容:
从上面的例子中可以看出,NativeScript 命令行有一个更新版本,而我没有安装ios
运行时。
tns 平台[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 测试命令
tns test <platform>
命令允许你安装和/或启动测试框架。我们将在后面的章节中更深入地介绍测试,但为了完整起见,我们将在本节中介绍这个命令。tns test init
将初始化测试系统;你将每个应用程序都要做一次。它会要求你选择一个测试框架,然后安装你选择的测试框架。tns test <platform>
将在特定平台上启动测试。
tns 设备命令
如果你需要特定地针对一个设备,使用 tns device
命令将会给你列出已安装并连接到你的计算机的设备。这将允许你在 tns run/debug
命令上使用 --device <deviceid>
参数:
TNS doctor 命令
tns doctor
命令会检查你的环境是否存在常见问题。它会尝试检测一切是否安装和配置正确。它大多数时候都有效,但偶尔会失败并声明某些东西出了问题,即使一切实际上都正常。然而,它提供了一个非常好的第一指示,如果你的 tns run/build/debug
不再工作。
TNS help 命令
如果你完全忘记了我们在这里写的东西,你可以执行 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 <platform> |
这将在物理设备或模拟器上构建、部署和启动应用程序。这是您大部分时间用来运行应用程序并查看更改的命令。其参数包括--clean 、--no-watch 和--justlaunch 。 |
tns debug <platform> |
这将在调试模式下构建、部署然后启动应用程序在物理设备或模拟器上。这可能是第二常用的命令。它的参数包括--clean 、--no-watch 、--dbg-break 和--start 。 |
tns plugin add <plugin> |
这允许您添加第三方插件或组件。这些插件可以完全基于 JavaScript 代码,也可能包含从 Java 或 Objective-C 库编译而来。 |
tns doctor |
如果 NativeScript 似乎无法正常工作,这允许您对环境运行诊断检查。 |
tns devices |
这显示了可用于--device 命令的连接设备列表。 |
tns install <dev plugin> |
这将安装开发插件(例如 webpack、typescript 等)。 |
tns test [ init | <platform> ] |
这允许您为应用程序创建或运行任何测试。使用 init 将为应用程序初始化测试框架。然后,您可以输入要在该平台上运行测试的平台。 |
Summary
现在你已经了解了命令行的强大之处,你真正需要记住的是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 插件的官方来源,以确定现有插件是否适用于您的项目。
在这种情况下,nativescript-audio插件位于plugins.nativescript.org/plugin/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
getter。然后,我们包括该接口以与我们的实现一起使用,该实现使用自身构造了TNSPlayer
的实例。由于我们希望一个灵活的模型可以随时加载其音轨文件,我们提供了一个接受ITrack
的load
方法,该方法利用initFromFile
方法。这反过来会异步获取音轨的总持续时间(以字符串形式返回,因此我们使用+duration
)来存储模型上的数字,然后解析音轨的初始化完成。
为了一致性和标准,确保还要从app/modules/shared/models/index.ts
导出这个新模型:
export * from './composition.model';
export * from './track-player.model';
export * from
'./track.model';
最后,我们为播放器实例提供一个 getter,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);
}
...
我们的视图还需要知道播放状态以及“持续时间”和“当前时间”。对于playing$
和duration$
状态,使用Subject
将很好地工作,因为它们如下:
-
它们可以直接发出值
-
它们不需要发出初始值
-
它们不需要任何可观察的组合
另一方面,currentTime$
将根据一些组合设置,因为它的值将取决于随时间可能发展的间歇状态(稍后详细介绍!)。换句话说,playing$
状态是我们通过用户的播放操作(或基于播放器状态的内部操作)直接控制和发出的值,而duration$
状态是我们直接作为所有曲目播放器初始化和准备就绪的结果发出的值。
currentTime
是播放器不会自动通过播放器事件发出的值,而是我们必须间歇性地检查的值。因此,我们组合Observable.interval(1000)
,它将在订阅时每 1 秒自动发出我们映射的值,表示最长曲目播放器实际的currentTime
。
其他“私有”引用帮助维护服务的内部状态。最有趣的是,我们将保留对_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>
然后,在app/modules/player/components/player-controls/player-controls.component.ts
中的PlayerControlsComponent
中,我们现在可以通过其各种可观察对象观察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
的基石现在是通过this.playerService.composition = this.composition
在ngOnInit
中设置活动组合的能力,这是在准备好组合输入时,以及订阅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$
主题以更新 Slider 的最大值。最后,我们通过它们在ngOnDestroy
中的Subscription
引用确保所有订阅都被清理。
现在让我们来看看app/modules/player/components/player-controls/player-controls.component.html
中PlayerControlsComponent
的视图绑定:
<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 上选择演示曲目并播放音乐。
音乐到我们的耳朵!这相当棒。事实上,它非常棒!
在这一点上,您可能会注意到或希望有一些事情:
-
选择播放按钮后,它会正确地变为停止,但当播放到末尾时,它不会返回到原来的播放文本。
-
“滑块”也应该返回到位置 0 以重置播放。
-
iOS 上的总“持续时间”和“当前时间”使用秒;然而,Android 使用毫秒。
-
在 iOS 上,如果您选择在演奏作品的演示曲目播放期间多次播放/暂停,您可能会注意到所有曲目上都有一个非常微妙的播放同步问题。
-
需要当前时间和持续时间标签。
-
播放搜索很好能够使用滑块来控制播放位置。
完善实现
我们的模型和服务中缺少一些重要的部分,以真正完善我们的实现。让我们从处理曲目播放器实例的完成和错误条件开始。打开app/modules/shared/models/track-player.model.ts
中的TrackPlayerModel
,并添加以下内容:
... 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
方法进行重置。我们标记了一个TODO,因为虽然这在所有音轨长度相同时效果很好(就像我们的演示音轨),但当我们开始录制不同长度的多轨音轨时,这肯定会在未来变得有问题。想象一下,我们有两个音轨:音轨 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 运行。Thoughtram 的伟大人员发表了一篇关于 Zone 的精彩文章,如果你想了解更多,请访问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 的优势,直接利用 nativescript-audio 插件中底层 iOS AVAudioPlayer 实例的本机 API
让我们在我们的播放/暂停逻辑中插入一些保护措施,以帮助确保我们的曲目在我们的编程能力范围内保持同步。nativescript-audio插件提供了一个仅适用于 iOS 的方法,称为playAtTime
。它与特殊的deviceCurrentTime
属性一起工作,正如苹果的文档中为此目的描述的那样。
由于nativescript-audio
插件没有暴露deviceCurrentTime
,我们可以通过ios
getter 直接访问原生属性。让我们调整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 getter 访问底层的原生平台播放器实例(对于 iOS,它是AVAudioPlayer
)来直接访问deviceCurrentTime
。我们为了保险起见提供了一个非常短的起始延迟,将其加入到第一首曲目的deviceCurrentTime
中,并使用它来确保我们的所有曲目在同一时间开始,这非常有效!由于playAtTime
没有通过nativescript-audio
插件的 TypeScript 定义发布,我们在调用该方法之前只需对播放器实例进行类型转换(<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
作为pace setter,我们暂停我们混音中的每一首曲目,并确保它们通过立即定位到相同的currentTime
保持在完全相同的时间。这有助于确保当我们恢复播放时,它们都从同一时间点开始。让我们在下一节中利用所有这些内容来构建一个自定义的穿梭滑块。
创建一个自定义的 ShuttleSliderComponent
我们不能没有能够在我们的混音中来回穿梭的能力!让我们加倍努力,通过结合 NativeScript 和 Angular 提供给我们的所有选项的优势来增强Slider
的功能。在这个过程中,我们的播放控件将开始变得更加有用。
从高层次开始,打开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
组件本身,我们将完全禁用用户与滑块本身的交互(因此,你看到的slim-slider
指令属性)。滑块将仅用于时间的视觉表示。
我们将要这样做的原因是因为我们希望这种交互能够启动几个程序化的动作:
-
在穿梭时暂停播放(如果正在播放)
-
在来回移动时更新当前时间显示标签
-
以受控方式启动
seekTo
命令到我们的轨道播放器实例,从而减少多余的搜索命令 -
如果之前正在播放,那么在不再进行穿梭时恢复播放
如果我们使用Slider
和 Angular 绑定到currentTime
通过currentTime$
observable,这取决于我们与其交互以及轨道播放器状态的控制,事情会耦合得太紧,无法实现我们需要的精细控制。
我们即将要做的事情之美,是对 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
是视图组件的代理包装器,只有在 Angular 的组件生命周期钩子ngAfterViewInit
触发后,才能访问其底层的nativeElement
(我们实际的 NativeScript 组件)。
在这里了解有关 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$
observable 每秒触发一次。不错! 哦,别忘了将 ShuttleSliderComponent
添加到 COMPONENTS
数组中,以便与模块一起包含。
现在我们需要实际实现这一点:
*this.playerService.seeking = true; // TODO*
我们将使用更多巧妙的 observable 技巧来处理我们的寻找状态。让我们打开 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);
}
}
...
我们引入了三个新的 observable 操作符 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
允许我们的 observable 根据几个条件切换流,帮助我们管理 currentTime
是否需要发出更新。显然,在寻找时,我们不需要对 currentTime
的变化做出反应。因此,当 this._seeking
为 true 时,我们将我们的 Observable 流切换到 Observable.never()
,确保我们的观察者永远不会被调用。
在我们的 seeking
setter 中,我们调整内部状态引用(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);
}
}
通过 NativeScript 的Slider
组件本身的ios
获取器,我们可以访问底层的本机 iOS UISlider
实例。我们使用苹果的 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
组件上的android
获取器,我们可以访问本机的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。最重要的是,我们有一个非常棒的多轨播放器,具有完整的播放控制,包括混音播放!
令人兴奋的 Angular 组合,包括其 RxJS 可观察对象的基础,真的开始显现出来,我们已经能够利用视图绑定,以及通过强大的可观察组合来响应服务事件流,同时仍然保留了手动控制我们的 UI 的能力。无论我们的视图是否需要 Angular 指令来丰富其功能,还是通过原始 NativeScript 功能进行手动触摸手势控制,现在我们都可以轻松实现。
我们一直在构建一个完全原生的 iOS 和 Android 应用程序,这真是令人惊叹。
在下一章中,我们将继续深入研究原生 API 和插件,将录音引入我们的应用程序,以满足我们多轨录音工作室移动应用程序的核心要求。
第八章:构建音频录音机
录制音频是我们的应用必须处理的性能最密集的操作。这也是唯一一个访问原生 API 将最有回报的功能。我们希望用户能够以移动设备可能的最低延迟录制,以实现最高保真度的声音。此外,这种录制应该可以选择地发生在现有预先录制的音轨的顶部,所有音轨都在同步播放。
由于我们的应用开发的这个阶段将深入到特定平台的原生 API,我们将把我们的实现分为两个阶段。我们将首先构建出录音功能的 iOS 特定细节,然后是 Android。
在本章中,我们将涵盖以下内容:
-
为 iOS 和 Android 构建一个功能丰富的跨平台音频录音机,具有一致的 API
-
集成 iOS 框架库,比如完全使用 Swift 构建的 AudioKit(
audiokit.io
) -
如何将 Swift/Objective C 方法转换为 NativeScript
-
基于原生 API 构建自定义可重复使用的 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 没有直接对应关系。最新的 Swift 语言增强功能的全面支持可能最终会到 NativeScript,但是在撰写本文时,有一些需要牢记的考虑。
AudioKit 利用了 Swift 语言所提供的最佳功能,包括丰富的枚举功能。您可以在这里了解 Swift 语言中扩展的枚举功能:
特别是,文档中有这样的内容:"它们采用了传统上仅由类支持的许多功能,例如计算属性以提供有关枚举当前值的附加信息,以及实例方法以提供与枚举表示的值相关的功能。”
这样的枚举对 Objective C 来说是陌生的,因此无法在桥接头文件中使用。在编译时生成桥接头文件时,任何使用 Swift 的奇异枚举的代码都将被简单地忽略,导致 Objective C 无法与代码的这些部分进行交互。这意味着您将无法在 NativeScript 中使用 Swift 代码库中的方法,该方法使用了这些增强的构造(在撰写本文时)。
为了解决这个问题,我们将 fork 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"]
}
}
这与您可能熟悉的任何enum都不同;正如您所看到的,它包括除枚举之外的属性。当这段代码被编译并生成桥接头文件以与 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
- 由于我们将要使用用户的麦克风,我们希望确保麦克风的使用在我们应用程序的属性列表中得到了指示。我们还将利用这个机会添加两个额外的属性设置来增强我们应用程序的能力。因此,总共我们将为以下目的添加三个属性键:
-
让设备知道我们的应用程序需要访问麦克风,并确保在第一次访问时请求用户的权限。
-
在应用程序被放入后台时继续播放音频。
-
提供在连接到计算机时能够在 iTunes 中看到应用程序的
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 库的 TypeScript 定义。这是一次性的事情,用于轻松集成这个本地库,所以您可以放心将这个自定义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
文件来引用。如果我们查看了 foundation 的定义,就会看到对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 视图
我们将创建一个自定义的 NativeScript 视图组件,而不是为我们的波形显示创建一个 Angular 组件,该组件可以利用本机 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
类定义这些属性的一个便利之处在于,这些 setter 只有在nativeView
被定义时才会被调用,避免了双重调用属性 setter(一个是通过纯 JS 属性 setter,这是另一种选择,可能还有一个是在底层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;
}
这将有助于为其他模型定义一个形状,以确保它们符合 Waveform 显示所期望的 API:
-
target
:定义要与本地类一起使用的关键输入。 -
dispose()
:每个模型应提供此方法来处理视图销毁时的任何清理工作。
这是自定义的 NativeScript 3.x 视图生命周期调用执行顺序:
-
创建本地视图():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
属性确定应该呈现哪种类型的波形显示。
在mic
的情况下,我们利用 AudioKit 的AKNodeOutputPlot
(实际上在底层扩展了EZAudioPlot
)来使用我们模型的目标初始化波形(即audioplot
),这将最终成为我们的 RecordModel 的麦克风。
在file
的情况下,我们直接利用 AudioKit 的EZAudioPlot
来创建表示音频文件的静态波形。
initNativeView
方法,也是从 NativeScript 的 View
类中重写而来,是在其生命周期中第二次被调用的,它提供了一种初始化原生视图的方式。你可能会发现有趣的是,我们在这里再次调用了 setters。当组件绑定通过 XML 设置并且类实例化时,setters 首先被调用,这是在 createNativeView
和 initNativeView
被调用之前。这就是为什么我们在私有引用中缓存这些值。然而,我们也希望这些 setters 在 Angular 视图绑定中修改 nativeView
(在动态改变时),这就是为什么我们在 setters 中也有 if (this.nativeView)
来在可用时动态改变 nativeView
。
disposeNativeView
方法(你猜对了,也是从 View
类的 {N}
中重写而来)在 View
被销毁时被调用,这是我们调用模型的 dispose
方法的地方(如果可用)。
将自定义的 NativeScript 视图集成到我们的 Angular 应用中
要在 Angular 中使用我们的 NativeScript 波形视图,我们首先需要注册它。你可以在根模块、根应用组件或者在启动时初始化的其他地方进行注册(通常不是在懒加载的模块中)。为了整洁,我们将在相同目录下的 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
getter 和dispose()
方法,以便与我们的 Waveform 视图一起使用:
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
将是 Waveform 视图将使用的麦克风。我们的dispose
方法将在清理引用的同时停止 AudioKit 引擎,同时确保清除录制过程中创建的任何临时文件。
创建录音机视图布局
当用户点击应用程序右上角的“录制”时,它会提示用户进行身份验证,之后应用程序会路由到录制视图。此外,很好地重用这个录制视图作为模态弹出窗口显示,以便在录制时用户不会感觉离开作品。但是,当作品是新的时,通过路由导航到录制视图是可以的。我们将展示如何做到这一点,但首先让我们使用新的时髦 Waveform 视图和我们强大的新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
,因为我们希望我们的 Waveform 视图能够延伸到覆盖整个可用垂直空间,只留下底部定位的录音机控件。FlexboxLayout
是一个非常多才多艺的布局容器,它提供了大部分在 Web 上使用的 flexbox 模型中找到的相同的 CSS 样式属性。
有趣的是,我们只在显示为模态框时在GridLayout
容器内显示取消按钮,因为我们需要一种关闭模态框的方式。当通过模态框打开视图时,操作栏将被忽略和不显示。
当通过模态框打开视图时,操作栏将被忽略,因此在模态框中不显示。ActionBar
仅在导航视图上显示。
此外,我们的ActionBar
设置在这里相当有趣,也是 NativeScript 视图布局中 iOS 和 Android 差异最大的领域之一。在 iOS 上,NavigationButton
具有默认行为,会自动从堆栈中弹出视图,并动画返回到上一个视图。此外,在 iOS 上,对NavigationButton
的任何点击事件都会被完全忽略,而在 Android 上,点击事件会在NavigationButton
上触发。由于这个关键的差异,我们希望完全忽略ActionBar
的NavigationButton
,通过使用visibility="collapsed"
来确保它永远不会显示。相反,我们使用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 的引擎是从 UI 线程在RecordModel
中启动的,因为 NativeScript 在 UI 线程上调用本机调用。然而,当我们的记录视图关闭时(无论是从模态还是返回导航),会调用一些后台线程。用invokeOnRunLoop
包装我们关闭这个视图的处理有助于解决这个瞬态异常。这就是如何在 NativeScript 中使用 iOS dispatch_async(dispatch_get_main_queue(…))
的答案。
在文件中向上工作,我们会遇到this.recorderService.state$.subscribe((state: number) => …
。一会儿,我们将实现一种观察录音state$
作为可观察对象的方法,这样我们的视图就可以简单地对其状态变化做出反应。
还值得注意的是,将RecordState enum
折叠成我们可以用作视图绑定的属性,以便与当前状态进行比较(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']);
}
}
}
在这里,我们使用EventEmitter
有条件地发出事件,如果组合包含轨道,则使用组件Output
装饰器;否则,我们导航到录制视图。然后我们调整视图模板中的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 完成实现
现在,让我们在app/modules/recorder/services/recorder.service.ts
中完成对RecorderService
的实现:
// 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
准备好播放时,我们知道已经创建了一个录音并且现在可以播放。我们停止播放混音,获取录制文件路径的引用。然后,我们更新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:错误错误:未捕获的(在承诺中):错误: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 {
这就是您可以解决这些问题的方法;但是,这正是我们建议在第十章中使用 Ngrx 的原因,@ngrx/store + @ngrx/effects for State Management,即将到来,因为它可以帮助缓解这些依赖注入问题。
在这一点上,我们的设置运行良好;但是,当我们开始集成 ngrx 以实现更简化的 Redux 风格架构时,它可以得到极大改进甚至简化。在这里,我们已经做了一些响应式的事情,比如我们的RecordComponent
对我们服务的state$
可观察对象做出反应;但是,我们需要将MixerService
注入到PlayerService
中,从架构上来说这有点不太对,因为PlayerModule
实际上不应该依赖于MixerModule
提供的任何东西。再次强调,这在技术上是完全正常的,但是当我们在第十章开始使用 ngrx 时,@ngrx/store + @ngrx/effects for State Management,您将看到我们如何在整个代码库中减少依赖混合。
让我们稍作休息,为自己的工作感到自豪,因为这已经是一项令人印象深刻的工作量。看看我们的劳动成果产生了什么:
第二阶段 - 为 Android 构建音频录制器
信不信由你,我们实际上已经完成了让这项工作在 Android 上运行的大部分工作!这就是 NativeScript 的美妙之处。设计一个有意义的 API,以及一个可以插入/播放底层原生 API 的架构,对于 NativeScript 的开发至关重要。在这一点上,我们只需要将 Android 部分插入到我们设计的形状中。因此,总结一下,我们现在有以下内容:
-
RecorderService
与PlayerService
协调我们的多轨处理能力 -
一个灵活且准备在幕后提供 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
record-common.model
文件被RecordModel
导入,这就是为什么为它生成了一个定义;但是,您可以删除它。现在,我们有了定义文件,但我们希望稍微修改它。我们不需要任何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
定义文件将在 JavaScript 转译时由 TypeScript 编译器使用,而运行时将使用特定于平台的 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'
};
}
}
哇!好的,这里发生了很多有趣的事情。让我们先为 Android 解决一个必要的问题,并确保在 API 级别 23+上正确处理权限。为此,您可以安装权限插件:
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$
,然后将其作为我们的目标 getter 返回。这允许我们通过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 上一样,我们还希望为多功能的波形视图做好准备,它还可以为单个文件渲染静态波形。在查看开源选项时,我们可能会遇到另一个位于波兰首都华沙的Semantive团队,他们创建了一个非常强大的 Android 波形视图:
github.com/Semantive/waveform-android
让我们为我们的 Android 波形集成整合这两个库。
与我们在 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
现在,我们已经准备好将这些库整合到我们的波形视图中。
让我们对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);
}
}
}
}
我们通过定义对各种打包类的const
引用来开始我们的 Android 实现,以减轻我们在 Waveform 中每次都需要引用完全限定的包位置。就像在 iOS 端一样,我们通过允许类型('mic'
或'file'
)来驱动使用哪种渲染,设计了一个双重用途的 Waveform。这使我们能够在实时麦克风可视化的录制视图中重用它,并在其他情况下静态地渲染我们的轨道作为 Waveforms(很快会详细介绍更多!)。
Horizon 库利用 Android 的GLSurfaceView
作为主要渲染,因此:
this.nativeView = new GLSurfaceView(this._context);
this.height = 200; // GL view needs height
在开发过程中,我们发现GLSurfaceView
至少需要一个高度来限制它,否则它会以全屏高度渲染。因此,我们明确地为自定义的 NativeScript 视图设置了一个合理的height
为200
,这将自动处理测量原生视图。有趣的是,我们还发现有时我们的模型 setter 会在initNativeView
之前触发,有时会在之后触发。因为模型是初始化我们 Horizon 视图的关键绑定,我们设计了一个带有适当条件的自定义内部_initView
方法,它可以从initNativeView
中调用,也可以在我们的模型 setter 触发后调用。条件(!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 样式,所以风格仍然有点丑陋:
对于我们的静态音频文件波形渲染,我们使用 Android 上下文初始化WaveformView
。然后我们在createNativeView
中使用其 API 来配置它以供我们使用。
在初始化期间,我们根据WaveformView
的要求创建一个CheapSoundFile
的实例,有趣的是,我们在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 滑块的独特特性
使用 NativeScript 的 ListView 进行多个项目模板
在整个第八章中,构建音频录制器,我们设计了一个双重用途的自定义 NativeScript 波形视图,它利用了 iOS 和 Android 的各种本机库,特别是为了丰富我们的作曲曲目列表视图。让我们继续重用我们多才多艺的波形视图来展示我们的曲目列表视图。这也将为我们提供一种显示混音滑块控件(在音频制作和声音工程中通常称为淡入淡出器)的方法,以便用户可以调整每个曲目在整体作品中的音量水平。让我们设置我们的TrackListComponent
的ListView
,使用户能够以两种不同的方式查看和处理他们的曲目,每种方式都有其独特的实用性。与此同时,我们还将利用这个机会最终连接我们曲目上的mute
开关。
让我们对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()
。当我们需要ListView
在更改后更新显示时,这在 Angular 中是必需的。第一个惊喜可能是注入PlayerService
,第二个可能是this.templateSelector = this._templateSelector.bind(this)
。templateSelector
绑定不是作用域绑定的,由于我们需要它从我们的this.playerService
返回一个属性引用,我们确保它正确地绑定到Component
的作用域,通过绑定一个Function
引用。在这一点上,我们将使用PlayerService
作为一个通道,以帮助从MixerModule
中的ActionBarComponent
传递状态。
这个例子展示了服务如何帮助在整个应用程序中传递状态。然而,通过利用ngrx
来帮助减少交织的依赖关系并解锁具有 Redux 风格架构的纯响应式设置,这种实践可以得到极大的改进。@ngrx 增强功能将在第十章中进行介绍,@ngrx/store + @ngrx/effects for State Management。
我们的 View Toggle 按钮将在ActionBar
(在MixerModule
中),我们将希望在那里点击以切换我们的ListView
,它位于我们的PlayerModule
内。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
来修改一个新的 setter,**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 改进这个设置,这是关于改进和简化我们处理应用程序状态的方式。
还有一些事情我们需要做,以确保我们所有的新添加都能正常工作。首先,[(ngModel)]
绑定将完全无法工作,如果没有NativeScriptFormsModule
。
如果您在组件的视图中使用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();
}
...
我们的音频播放器现在可以通过观察volume$
主题可观察对象来对每个音轨通过数据绑定进行的音量更改做出反应。由于静音实质上只需要修改播放器的音量,我们确保相应地更新音量,并在打开/关闭静音时保持原始音量,因此任何自定义音量设置都将被保留。
我们对轨道的新丰富视图包括可重复使用的波形视图,但这一次使用type="file"
,因为这将使音频文件的静态波形得以呈现,以便我们可以看到我们的音频。我们还提供了调整每个轨道音量(混音控制)的能力,并将标签浮动到左上角,以便用户仍然知道是什么。这一切都是通过利用AbsoluteLayout
容器完成的,这允许我们重叠组件并手动将它们定位在彼此之上。
对数据进行持久化序列化,并在检索时重新注入
这一切都非常顺利,然而,我们引入了一个问题。我们的MixerService
提供了保存所有轨道的作品的能力。然而,现在轨道包含了诸如可观察对象甚至具有 getter 和 setter 的私有引用等复杂对象。
在持久化数据时,您通常会希望使用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
进行处理。然后,在从持久存储中检索数据时(在这种情况下,通过 NativeScript 的应用程序设置模块在我们的DatabaseService
的幕后使用;这在第二章中有所涵盖,特性模块),我们将数据重新注入到我们的模型中,这将使用我们的可观察属性丰富数据。
利用 Angular 指令丰富 NativeScript 滑块的独特特性
对于每个轨道混音器(也称为我们的混音/音量控制),实际上渲染一个看起来像混音旋钮的控制旋钮会很好,以便清楚地表明这些滑块是混音旋钮,不会被误认为是该轨道的播放。我们可以创建一个用于这些滑块的图形,它将如下所示:
对于 iOS,我们还希望有一个按下/高亮状态,这样当用户按下淡入淡出时,可用性会很好:
现在我们可以创建每个文件的两个版本,并将它们放入app/App_Resources/iOS
;原始文件将是 100x48 用于标准分辨率,然后对于 iPhone Plus 及以上,我们将有一个 150x72 的@3x 版本(基本上是标准 48 高度再加上 24):
-
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>
有了这个,当轨道混音视图切换打开时,我们现在可以为 iOS 定制这些整洁的淡入淡出控件:
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 上给我们带来问题。默认的设置器将尝试将滑块的颜色设置为蓝色,并进行混合处理。当它在滑块上设置这个标志时,我们设置的任何图形形状都会被设置为全蓝色。因此,为了使我们的滑块类能够处理自定义图形,我们必须消除控件上的默认滑块颜色设置器。我们通过附加一个新的“颜色”设置器来控制这一点,这个设置器实际上什么都不做。这样,当 NativeScript 框架在初始化或重置控件时尝试设置默认颜色时,什么都不会发生,从而使我们完全控制发生的事情。作为一种预防措施,在_addThumbImg
方法的末尾,我们还调用seekBar.getThumb().clearColorFilter();
以确保在我们能够消除默认颜色设置之前,任何潜在的颜色过滤器都被撤消。
最后,当我们的音轨列表视图切换到混音模式时,我们可以自定义每个音轨显示的音频波形中使用的颜色。由于 Android 的波形插件利用了应用程序的颜色资源,我们可以在 app/App_Resources/Android/values/colors.xml 中添加插件文档中找到的适当命名的属性,并且相同的颜色也应该复制到 app/App_Resources/Android/values-v21/colors.xml 中:
这样,我们现在为 Android 的文件波形显示在混音模式下提供了自定义样式:
总结
我们希望为您提供一些额外的好东西,以配合您在第三部分学习到的丰富内容;希望您喜欢!在许多情况下,使用ListView
的多个项目行模板可能非常方便,但希望这将为您提供工具,让您知道如何使其为您和您的应用程序工作。
数据持久性的特殊考虑因素对于任何引人入胜的应用程序都是一个重要因素,因此我们在存储数据之前查看了数据序列化以及在恢复数据时对数据进行水合处理。
最后,我们将进一步丰富我们的视图组件,使用更多的 Angular 指令优点。 随着第三部分的完成,我们现在已经完成了本书的核心能力和功能集。 但是,我们的应用程序还远未完成。 本书中介绍的开发工作流程和流程是我们为任何构建的应用程序带来的典型开发周期。 我们将在第十四章中涵盖改进我们的架构和进一步完善我们的应用程序,以准备通过 Google Play 和 App Store 进行公开发布,使用 webpack 打包进行部署准备。
让我们现在开始通过在第十章中集成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 应用程序的可预测状态容器。这些概念并不是全新的,但细节是由 Dan Abramov 在 2015 年开发的,他受到了 Facebook 的 Flux 和函数式编程语言 Elm 的影响。它很快在 React 社区中变得流行,因为它在 Facebook 中被广泛使用。
我们不想重新定义 Redux 是什么,所以我们将直接引用 Redux 仓库(github.com/reactjs/redux
)中的内容:
您的应用程序的整个状态存储在单个store内的对象树中。
改变状态树的唯一方法是发出一个action,描述发生了什么。
为了指定操作如何转换状态树,您需要编写纯reducers。
就是这样!
这个概念相当简单,而且非常聪明。您对系统发出动作(这些动作是简单的字符串类型对象,带有表示要传递的数据的有效负载),这些动作最终会触发一个减速器(一个纯函数),定义这些动作如何转换状态。
重要的是不要混淆转换和突变。Redux 的一个基本概念是所有状态都是不可变的;因此,每个减速器都是一个纯函数。
纯函数在给定相同参数的情况下总是返回相同的结果。它的执行不依赖于系统作为整体的状态[en.wikipedia.org/wiki/Pure_function
]。
因此,尽管减速器转换状态,但它不会改变状态。
深入的工程研究已经对变更检测系统以及对象相等性/引用检查在速度上的优势进行了研究,与深度嵌套属性的对象比较检查相比。我们不会详细介绍这一点,但是应用程序数据流的不可变性对于您如何调整其性能有重大影响,特别是关于 Angular。
除了性能增强之外,Redux 的概念进一步增强了整个代码库的解耦,从而减少了分散在各处的各种依赖关系。通过描述我们的应用程序涉及的各种交互的动作的力量,我们不再需要注入显式的服务依赖来执行其 API。相反,我们可以简单地发出动作,Redux 的原则将为我们传播和处理应用程序需求的必要功能,同时保持一个单一可靠的真相来源。
@ngrx/store 是什么?
在 Angular 的重写过程中(从 1.x 到 2.x+),谷歌的核心团队成员、开发者倡导者 Rob Wormald 开发了ngrx/store作为“由 RxJS 驱动的 Angular 应用程序状态管理[系统],灵感来自于 Redux。”该短语中的关键点是“RxJS”一词。因此,ngrx的名称来源于将“ng”(代表 Angular)与“rx”(来自RxJS)相结合。这个开源库迅速吸引了像 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
: 混音器相关的状态 -
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
,处理我们的用户,我们将把user片段视为应用程序初始状态的基本关键。
特别是,让我们首先定义我们的用户状态的形状。
创建app/modules/core/states/user.state.ts
:
export interface IUserState {
recentUsername?: string;
current?: any;
loginCanceled?: boolean;
}
export const userInitialState: IUserState = {};
我们的用户状态非常简单。它包含一个recentUsername
,表示最近成功验证的用户名的字符串(如果用户注销并稍后返回登录,则此信息很有用)。然后,我们有current,如果经过身份验证,则表示用户对象,如果没有,则为 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
: 当用户注销时。 -
已更新
:我们将使用这个简单的动作来更新我们的用户状态。通常不会直接分发,而是将在我们马上创建的 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 非常简单。如前所述,它是一个纯函数,接受现有状态和一个动作,并返回一个新状态(作为一个新对象,除非它是默认的起始情况)。这保持了不可变性并保持了事物的优雅。已更新
动作将始终是任何动作链中的最后一个,最终触发并改变用户状态。在这种情况下,我们将保持简单,并允许我们的 已更新
动作是实际改变用户状态的唯一动作。其他动作将建立一个链,它们最终会分发 已更新
,如果它们需要改变用户状态。基于我们的动作来改变状态,你当然可以在这里设置更多的情况;然而,在我们的应用中,这将是最终改变用户状态的唯一动作。
动作链? 什么是 动作链?!如果需要,我们是如何将这些动作连接起来相互作用的?
安装和集成 @ngrx/effects
不重新定义,让我们直接从存储库(github.com/ngrx/effects
)中查看 @ngrx/effects 的描述:
在@ngrx/effects
中,effects 是 actions 的来源。您可以使用@Effect()
装饰器来提示服务上的哪些 observables 是 action sources,@ngrx/effects
会自动合并您的 action 流,让您订阅它们到 store。
为了帮助您编写新的 action sources,@ngrx/effects
导出了一个 action observable 服务,该服务会发出应用程序中分发的每个 action。
换句话说,我们可以使用 effects 将我们的 actions 链接在一起,以在整个应用程序中提供强大的数据流组合。它们允许我们插入应该在 action 分发之间和状态最终改变之前发生的行为。最常见的用例是处理 HTTP 请求和/或其他异步操作;然而,它们有许多有用的应用。
首先,让我们安装@ngrx/effects
:
npm i @ngrx/effects --save
现在让我们来看看我们的用户 actions 在 effect 链中是什么样子的。
不过,为了保持与我们的命名结构一致,让我们将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
的数据流意图,并将责任委托给了这个 effect 链。这使我们能够以清晰一致的方式组合我们的数据流,具有很大的灵活性和强大的功能。例如,我们的InitAction
链现在允许我们通过以下方式自动初始化用户:
.startWith(new UserActions.InitAction())
早些时候,我们在服务构造函数中调用了一个私有方法--this._init()
;然而,现在我们不再需要像那样显式调用,因为 effects 会在模块启动时运行和排队。.startWith
操作符将使 observable 在一个特定的时间点触发一次(在模块创建时),允许初始化序列在一个特别合适的时间执行,当我们的应用程序正在初始化时。我们的初始化序列与我们之前在服务中处理的相同;然而,这次我们考虑了我们的新的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
:这通常不会直接分发,而是在效果序列的最后使用,reducer 会最终改变混音器状态。
现在,我们可以创建一个类似于我们用户 reducer 的 reducer:
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
的注入,并通过设置状态 Observable--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 的异步管道订阅 Observable 或 Promise 并返回它发出的最新值。当发出新值时,异步管道会标记组件以进行更改检查。当组件被销毁时,异步管道会自动取消订阅,以避免潜在的内存泄漏。
这真是非常了不起和非常方便,使我们能够创建高度可维护和灵活的响应式组件。
检查代码!自己探索更多
由于我们之前看到的很多内容都是应用于我们代码库的其他部分的相同原则,而不是进一步增加本书的篇幅,我们邀请您在本书附带的代码存储库的同一章节分支中探索其余的 ngrx 集成。
浏览实际代码,运行它,甚至逐步执行它,希望能让您对 ngrx 如何适应您的应用程序以及它可以带来的许多优势有一个扎实的理解。
社区很幸运能有像 Rob Wormald,Mike Ryan,Brian Troncone,Brandon Roberts 等成员,他们帮助使 ngrx 的使用变得如此愉快,因此非常感谢所有的贡献者!
总结
希望您开始看到在集成 ngrx 时简化和澄清数据流的模式。它有助于减少代码,同时通过为各种操作提供一致的效果链来改进数据流,这些操作可能需要在任何地方发生(从惰性加载的模块或其他地方)。通过减少在整个过程中管理显式注入的依赖项的开销,而是依赖 Store 和 Actions 来启动适当的工作,我们正在增加应用程序的可维护性和可扩展性。最重要的是,它为有效的可测试性铺平了道路,我们将在《第十二章》《单元测试》中进行介绍。
本章突出了将 NativeScript 与 Angular 相结合的额外优势,通过打开与丰富库(如 ngrx)的集成潜力,改进我们的应用程序架构和数据流。
这是一个漫长的过程,我们对接下来的《第十一章》《使用 SASS 进行波兰语》感到非常兴奋。最终,我们准备好了,要打磨我们的应用程序,赋予它特别的火花!
第十一章:用 SASS 打磨
在上一章节中涵盖了一些关于 ngrx 状态管理的底层改进之后,现在终于是时候打磨这个应用,改善其整体外观和感觉了。样式的时间完全取决于您的开发流程,通常情况下,我们喜欢边开发边打磨。在本书中,我们选择避免通过 CSS 混合打磨功能开发,以保持概念更加专注。然而,现在我们在这里,我们对为我们的应用获得漂亮外观感到非常兴奋。
由于随着样式的增长,标准 CSS 可能变得难以维护,我们将集成 SASS 来帮助。事实上,我们将利用一个由 Todd Anglin 开发的社区插件,他是帮助创建 NativeScript 品牌名称的人。
在本章中,我们将涵盖以下主题:
-
将 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';
现在,让我们创建带有各种部分 SASS 导入文件的style
文件夹,以帮助我们的设置,从变量开始:
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
文件之外,还要从 git 中忽略所有*.css
文件。
您不希望在将来与其他开发人员发生合并冲突,因为每次构建应用程序时,您的.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,我们可以利用另一个巧妙的插件,它将提供一个很好的管道,使我们可以使用字体名称以方便使用和清晰度。
安装以下插件:
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
插件只能使用字体类名,不需要 font-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 {}
最后,我们需要一个类来指定哪些组件应该使用 font-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
的背景颜色,暂时设为黑色。我们甚至可以在app/style/_common.scss
中使用核心主题的预定义变量来使用 SASS:
.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
插件,在整个应用中利用字体图标。这有助于用简洁的图标视觉清理笨重的文本标签。
在下一章中,我们将看看如何对一些关键功能进行单元测试,以未来保护我们应用的代码库免受新功能集成可能引入的回归。测试来拯救!
第十二章:单元测试
让我们从测试开始这一章;大多数人认为测试很无聊。猜猜,他们大多是对的!测试可以很有趣,因为你可以尝试并破坏你的代码,但有时也可能是乏味的工作。然而,它可以帮助你在客户之前捕捉到错误,并且作为一个奖励,它可以防止你多次出现相同的错误。你的声誉对你的客户或顾客来说值多少?一点点乏味的工作可能意味着一个三 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 测试快速入门项目(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 最初是为浏览器设计的,所有的测试仍然必须在浏览器中运行。这是 Angular 测试系统在进行 NativeScript 代码时的主要缺点。它不支持任何 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
卡尔玛将找到所有的.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
,这意味着我们的静音测试失败了。然后,我们在测试静音功能的it
函数中放置了Verify mute
。第二个线索是错误,Expected 1 to be 5
。所以,我们期望某物是 5,但实际上是 1。所以,这个特定的测试和这行代码在测试中失败了:
it ('Verify mute', () => {
expect(trackModel.volume).toBe(5);
});
为什么它失败了?
让我们从测试初始化beforeEach
开始;你会看到mute: false
。接下来,让我们看一下构造函数;它基本上执行this.mute = false
,然后静音设置器沿着它的else
路径运行,即this.volume = this._origVolume
。猜猜看?this._origVolume
还没有被设置,所以它设置this.volume = undefined
。现在看看音量例程;新的音量是undefined
,它被设置为1
,这覆盖了我们原来设置的 5。所以,测试Expected 1 to be 5.
失败了。
有趣的边缘情况;如果我们在测试属性初始化时没有将mute
设置为false
,这种情况就不会发生。然而,这是我们应该测试的东西,因为也许在应用程序的某个版本中,我们会存储静音值,并在启动时恢复它。
为了解决这个问题,我们应该稍微修改这个类。我们会让你做出你认为必要的更改来解决这个问题。如果你遇到困难,你可以根据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 测试系统的最大缺点是它需要更多的资源,因为它必须使用模拟器(或真实设备)来运行测试。因此,运行测试可能比我们在本章前面讨论的标准单元测试要耗费更多时间。
好的,现在你已经安装好了。让我们继续。所有的 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 一样。此外,它还使用了与 Mocha 测试框架和 WD 驱动程序紧密配合的 Chai(chaijs.com/
)和 ShouldJS(github.com/shouldjs/should.js
)测试框架。
另一件事需要注意的是,所有这些都是围绕纯 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 和 Chia 以正常工作的内容。仅需要以下一行代码来使用:
// 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 after 函数,在完成所有操作时关闭驱动程序。确保在使用驱动程序时,始终正确返回它非常重要。实际上,几乎每个测试片段都是一个 promise。如果忘记返回 promise,测试工具将会混乱,并可能按顺序运行测试,甚至在测试完成之前关闭驱动程序。因此,始终返回 promise:
// 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 中所做的一样 - 你正在描述一个你计划运行的测试,以便在测试失败时找到它。同样,我们返回 promise 链;非常重要的是,你不要忘记这样做。driver 变量是在处理模拟器时给我们不同功能的东西。因此,功能的文档在 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 在创建按钮时实际使用的底层操作系统元素。如果将来添加一个使用 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
项。我们正在寻找一个包含 Demo 文本值的 NativeScript 标签(即nsAppium.getXPathElement
)在 NativeScript DOM 中的任何位置(即*//*
)(即[@text='Demo']
)。这个元素应该最终存在,一旦存在,就调用tap()
函数。现在,如果你看源代码,你会看到以下内容:
<Label [text]="composition.name" (tap)="select(composition)" row="0" col="1" class="h2"></Label>
所以,当tap
被触发时,它将运行select
函数。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 来让它知道可以继续进行下一个测试。有时,你可能只想保持完全控制,或者你可能正在调用需要异步回调的代码。
这个测试查找包含输入你的名字
的EditText
元素。然后,它使用sendKeys
实际输入Testing。接下来,它要求从字段中获取text
,并使用 promise 的then
部分来检查该值是否与硬编码的 testing 相匹配。当所有的操作都完成时,它调用done
函数。如果你向done
函数传递一个Error
对象,那么它就知道测试失败了。所以,你可以在if
语句中看到我们传递了一个new Error
,并且我们将done
函数放在then
语句的catch
部分。
我们只是触及了 Appium、Should、Mocha 和 Chia 可以做的一小部分。您几乎可以控制应用程序的所有方面,就好像您手动执行每个步骤一样。最初,在您的开发中,手动测试速度要快得多。然而,当您开始构建端到端的测试时,每次进行更改时,您都可以检查应用程序是否仍然正常工作,而无需花费大量时间坐在多个设备前--您只需开始测试,稍后查看结果。
自动化测试
您应该注意的另一件事是,您使测试自动化程度越高,您就越有可能使用它并从中获益。如果您不断地手动运行测试,您很可能会感到恼火并停止运行它们。因此,在我们看来,自动化这一点至关重要。由于有许多关于这个主题的书籍,我们只会给您一些指针,让您可以进行研究,然后继续前进。
大多数源代码控制系统都允许您创建钩子。通过这些钩子,您可以创建一个提交钩子,以便在检入任何新代码时运行您的测试框架。这些钩子通常很容易创建,因为它们只是简单的脚本,每次提交时都会运行。
此外,如果您正在使用 GitHub,有一些网站(如 Travis)可以轻松地与之集成,而无需进行任何钩子更改。
GitHub 和 Travis 集成
以下是如何与 GitHub 和 Travis 进行一些集成;这将允许我们在前一章中讨论的 NativeScript 测试框架自动在每次更改或拉取请求时运行您的测试。在 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 将自动测试您存储库中的每个拉取请求,这意味着您可以编写代码,Travis 将持续进行所有单元测试。
此外,您可以更改前面的 Travis 配置文件,以添加 Appium 的安装和运行,只需执行以下操作:
-
将 Appium 依赖项添加到您的主
package.json
依赖项中。 -
在项目的根目录中添加一个具有
travisAndroid
键的appium.capabilities.json
。 -
在
package.json
文件中的travistest
键中添加&& npm run appium --runType=travisAndroid
。
GitHub 已经内置了与 Travis 的集成,因此很容易进行文档化并运行。如果您使用 Gitlabs,可以使用 Gitlabs CI 系统进行测试。此外,您还可以使用存储库钩子来使用许多其他可用的持续集成服务。最后,您还可以开发自己的持续集成服务。
摘要
在本章中,我们介绍了如何安装和运行 Appium,如何构建完整的端到端测试以及如何使用测试框架全面测试您的屏幕。此外,我们还介绍了自动运行单元测试和 Appium 的重要性,而您可以使用 Travis 和 GitHub 来实现这一点。
现在紧紧抓住——我们将快速转向并开始讨论如何部署和使用 Webpack 来优化您的发布构建。
第十四章:使用 webpack 进行部署准备
我们希望将我们的应用程序部署到两个主要的移动应用商店,苹果应用商店和谷歌 Play 商店;然而,有一些事情我们需要做来准备我们的应用程序进行分发。
为了确保你使用最小的 JavaScript 大小,以及 Angular 的 AoT 编译器来帮助我们的应用尽可能快地执行,我们将使用 webpack 来捆绑所有内容。值得注意的是,webpack 并不是创建可分发的 NativeScript 应用程序的必需条件。然而,它提供了非常好的好处,应该使它成为任何人在分发他们的应用程序时的重要步骤。
在本章中,我们将涵盖以下主题:
-
为 NativeScript for Angular 项目安装 webpack
-
准备项目以使用 webpack 进行捆绑
-
解决各种 webpack 捆绑问题
-
编写自己的自定义 webpack 插件以解决特定情况的入门指南
使用 webpack 来捆绑应用程序
如果不是 Sean Larkin,你可能永远不会听说过 webpack。他在捆绑器社区的贡献和参与帮助将 webpack 引入了 Angular CLI,并使其成为许多事情的主要首选捆绑器。我们非常感谢他在社区中的努力和善意。
准备使用 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
我们可以看到以下错误——我已经列举出来——我们将在下一节中按顺序提出解决方案:
-
意外值
SlimSliderDirective
在/path/to/TNSStudio/app/modules/player/directives/slider.directive.d.ts
中的模块 PlayerModule 中声明。请添加@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
中。 -
入口模块未找到:错误:无法解析
/path/to/TNSStudio/app
中的./app.css
。 -
错误在[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'.
就在此时...
放克灵魂兄弟。
是的,你可能正在唱 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
文件夹加载其轨道文件。我们还利用了 font-awesome 图标,借助于从assets
文件夹加载的 font-awesome.css 文件。我们需要确保这些文件夹也被复制到我们的生产 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 插件概述
现在我们想要回到我们在捆绑应用程序时遇到的第一个错误,即:
- ERROR in 意外值
SlimSliderDirective
在/path/to/TNSStudio/app/modules/player/directives/slider.directive.d.ts
中由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 插件,以帮助解决应用程序中特定的错误条件,从而实现成功的捆绑。
现在我们已经有了应用程序代码的最佳捆绑,我们现在准备完成我们的分发步骤,最终在下一章部署我们的应用程序。
第十五章:部署到苹果应用商店
在这一章中,我们将重点讨论如何将我们的应用部署到苹果应用商店。我们将要遵循几个重要的步骤,所以请密切关注这里呈现的所有细节。
无论您是否需要使用签名证书来构建我们应用的发布目标,生成应用图标和启动画面,还是在 XCode 中为上传到应用商店归档我们的应用,我们将在本章中涵盖所有这些主题。
NativeScript 专家、Progress 的开发者倡导者 TJ VanToll 撰写了一篇关于部署步骤的优秀文章,标题为8 Steps to Publish Your NativeScript App to the App Stores (www.nativescript.org/blog/steps-to-publish-your-nativescript-app-to-the-app-stores
)。我们将从该文章中摘录内容,并在本章和下一章中尽可能扩展各个部分。
没有必要欺骗你——将 iOS 应用发布到 iOS 应用商店是您在软件开发生涯中将经历的最痛苦的过程之一。所以,如果您在这些步骤中遇到困难或困惑,只需知道不仅是您——每个人在首次发布 iOS 应用时都会感到沮丧。
本章涵盖以下主题:
-
如何创建应用 ID 和生产证书以签署您的应用发布目标
-
如何配置 NativeScript 应用程序所需的适当元数据以进行发布
-
如何处理应用图标和启动画面
-
使用 NativeScript CLI 将您的构建上传到 iTunes Connect
为应用商店分发做准备
要将 iOS 应用程序部署到 iOS 应用商店,您绝对必须拥有一个活跃的苹果开发者帐户。加入该计划每年需要 99 美元,并且您可以在developer.apple.com/register上注册。
应用 ID、证书和配置文件
一旦您创建了苹果开发者帐户,您将需要在苹果开发者门户上创建应用 ID、生产证书和分发配置文件。这是整个过程中最繁琐的部分,因为需要一些时间来学习这些各种文件的作用以及如何使用它们:
- 对于我们的应用,我们将从以下内容开始创建应用 ID:
- 一旦我们创建了这个应用 ID,我们现在可以创建一个生产证书:
- 选择继续。然后,下一个屏幕将提供有关如何签署您的生产证书的说明,接下来我们将详细介绍。首先,打开
/Applications/Utilities/Keychain Access.app
,然后转到左上角菜单,选择 Certificate Assistant | Request a Certificate from a Certificate Authority,使用此设置:
这将在您选择的任何位置保存一个签名请求文件,您将在下一步中需要它。
- 现在,在门户网站的这一步中选择签名请求文件:
- 在下一个屏幕上,非常重要的是下载然后双击需要安装到您的钥匙串的文件,因为它指定了:
- 双击文件安装到钥匙串时,可能会提示您提供要安装文件的钥匙串;使用登录钥匙串将正常工作:
现在,在您的钥匙串访问应用程序中应该看到类似以下截图的内容:
-
现在,您可以退出钥匙串访问。
-
接下来,我们要创建一个分发配置文件:
- 在下一个屏幕上,只需确保选择您创建的应用程序 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 的 App 预览和屏幕截图区域。
上传您的.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 秒,长至 1 小时。一旦构建出现在那里,我们就可以点击大大的“提交审核”按钮,然后祈祷。
苹果对于审核你提交的 iOS 应用程序有着臭名昭著的不定期延迟。在撰写本书时,iOS App Store 的平均审核时间大约为 2 天。
总结
在本章中,我们强调了发布应用程序到苹果应用商店所必须采取的关键步骤,包括签名证书、应用程序 ID、应用图标和启动画面。这个过程一开始可能看起来很复杂,但一旦你更好地理解了各个步骤,它就会变得更清晰。
我们现在在商店中有一个待审核的应用程序,并且正在朝着让我们的应用程序在全球范围内为用户提供的目标迈进。
在下一章中,让我们通过将我们的应用程序部署到 Google Play 商店来扩大我们的受众群体。
第十六章:部署到 Google Play
尽管与苹果应用商店相比,将应用部署到 Google Play 可能稍微简单一些,但我们仍然需要注意一些关键步骤。我们在第十四章 使用 webpack 捆绑进行部署准备和第十五章 部署到苹果应用商店中涵盖了一些准备步骤,例如使用 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/
)是开发人员注册和上传应用供用户使用的地方。
您将首先按名称创建一个新应用,然后将其列出:
Android 关于上传应用程序和设置商店列表的文档非常好,因此我们不会在这里重复所有这些信息。相反,我们将提供一些提示,这些提示在将您自己的 NativeScript 应用程序上传到 Google Play 时可能会有所帮助。
在 Google Play 开发者控制台的商店列表选项卡中,您将需要提供应用程序运行时的至少两个屏幕截图,如下所示:
使用tns run android --emulator
命令在 Android 虚拟设备(AVD)上启动您的应用。Android AVD 具有内置的方法,可以使用模拟器侧边栏中的小相机图标来截取屏幕截图。
使用此按钮来截取应用程序中最重要的屏幕的几个屏幕截图,图像文件本身将出现在您的桌面上。此外,还需要一个 1024 x 500 的特色图像文件,它将显示在您商店列表的顶部,如下图所示:
尽管在上述屏幕截图中没有显示,但我们建议您使用 DaVinci(www.davinciapps.com
)等服务为您的屏幕截图增添一些特色,并将它们制作成一个小教程,展示您的应用的功能。
APK
Google Play 开发者控制台的应用发布部分是您上传在本章前一步骤中生成的.apk
文件的地方。
当您查看应用发布部分时,您可能会看到有关选择加入 Google Play 应用签名的提及。最好现在选择加入,而不是以后。一旦您选择加入,它将显示为已启用:
然后,您可以继续上传应用程序的 apk 文件到platforms/android/build/outputs/apk
文件夹中。
一旦您上传了您的 APK 文件,您应该在同一页上看到它列出,您可以在那里为上传的版本输入多种语言的发布说明:
在您点击该页面上的“保存”按钮后,您可能会想返回到商店列表部分,完成填写您应用的所有信息。一旦一切就绪,您就可以提交您的应用了。Android 应用的审核通常需要几个小时,除非 Google 标记出任何问题,您的应用应该在 Google Play 上可用,大约需要半天左右。
总结
哇哦!我们在 Apple App Store 和 Google Play 商店中从零到发布构建了一个应用。这是一次充满曲折和转折的冒险。我们真诚地希望这为您深入了解了 NativeScript 和 Angular 应用开发,并为那些好奇的人解开了这个激动人心的技术堆栈的任何领域。
NativeScript 和 Angular 都有蓬勃发展的全球社区,我们鼓励您参与其中,分享您的经验,并与他人分享您和您的团队可能正在进行的所有激动人心的项目。永远不要犹豫寻求帮助,因为我们都对这两种技术的热爱和钦佩负有责任。
还有一些其他有用的资源可以查看:
当然还要了解文档!
docs.nativescript.org/angular/start/introduction.html
干杯!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 提示词工程——AI应用必不可少的技术
· 地球OL攻略 —— 某应届生求职总结
· 字符编码:从基础到乱码解决
· SpringCloud带你走进微服务的世界
2023-05-18 100 个 Go 错误以及如何避免:9~12
2023-05-18 100 个 Go 错误以及如何避免:5~8
2023-05-18 100 个 Go 错误以及如何避免:1~4