NativeScript-Angular-移动开发-一-

37 阅读1小时+

NativeScript Angular 移动开发(一)

原文:zh.annas-archive.org/md5/289e6d84a31dea4e7c2b3cd2576adf55

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

NativeScript 是由 Progress 构建的一个开源框架,用于使用 Angular、TypeScript,甚至是古老的纯 JavaScript 构建真正的原生移动应用。Angular 也是一个由 Google 构建的开源框架,它提供了声明式模板、依赖注入和丰富的模块来构建应用程序。Angular 的多功能视图处理架构允许您的视图以真实的原生 UI 组件的形式渲染——对 iOS 或 Android 来说是原生的——这些组件提供了卓越的性能和流畅的可用性。Angular 视图渲染层的解耦,结合 NativeScript 中原生 API 的力量,共同创造了激动人心的 NativeScript for Angular 的世界。

本书重点介绍您需要了解的关键概念,以便在 iOS 和 Android 上为您的 Angular 移动应用构建 NativeScript。我们将构建一个有趣的多人录音室应用,涉及您在开始构建自己的应用时需要了解的强大原生关键概念。拥有正确的结构对于开发可扩展、高度可维护和可移植的应用至关重要,因此我们将从使用 Angular 的@NgModule 进行项目组织开始。我们将使用 Angular 组件来构建我们的第一个视图,然后创建我们可以通过 Angular 的依赖注入使用的服务。

您将了解 NativeScript 的 tns 命令行工具,用于在 iOS 和 Android 上运行应用。我们将集成第三方插件来构建一些核心功能。接下来,我们将集成@ngrx store 和 effects,建立一些稳固的实践(受 Redux 启发)来处理状态管理。如果应用看起来不好或用户体验不佳,那么拥有出色的数据流和稳固的架构就没有意义,因此我们将使用 SASS 为我们的应用打磨一个样式。之后,我们将处理调试问题,并投入一些时间编写测试,以防止未来出现错误。最后,我们将使用 webpack 打包我们的应用,并将其部署到苹果应用商店和谷歌 Play。

到本书结束时,您将了解构建 NativeScript for Angular 应用所需的大部分关键概念。

本书涵盖的内容

第一章,使用@NgModule 进入形状,讨论了@NgModule 装饰器,它明确定义了您应用中的一个功能段。这将是您项目的组织单元。在您开始构建应用之前,花点时间思考您可能需要或想要的应用的各种单元/部分/模块,这将带来许多好处。

第二章, 功能模块,教您如何使用功能模块来构建您的应用,这将为您未来的维护带来许多优势,并减少整个应用中的代码重复。

第三章, 通过组件构建我们的第一个视图,实际上让我们第一次看到了我们的应用,在这里我们需要为我们的第一个视图构建一个组件。

第四章, 使用 CSS 打造更美观的视图,探讨了如何通过几个 CSS 类将我们的第一个视图转变为令人惊叹的美丽,同时也会关注如何利用 NativeScript 的核心主题提供一个一致的样式框架来构建。

第五章, 路由和懒加载,允许用户在应用中的各种视图之间导航,这将需要设置路由。Angular 提供了一个强大的路由器,当与 NativeScript 结合使用时,与 iOS 和 Android 上的原生移动页面导航系统协同工作。此外,我们还将设置各种路由的懒加载,以确保我们的应用启动时间尽可能快。

第六章, 在 iOS 和 Android 上运行应用,重点关注如何通过 NativeScript 的 tns 命令行工具在 iOS 和 Android 上运行我们的应用。

第七章, 构建多轨播放器,涵盖了插件集成,并通过 NativeScript 直接访问 iOS 上的 Objective C/Swift API 和 Android 上的 Java API。

第八章, 构建音频录音机,与原生 API 合作构建 iOS 和 Android 的音频录音机。

第九章, 赋予您的视图力量,利用 Angular 的灵活性和 NativeScript 的强大功能,以充分利用您应用的用户界面。

第十章, @ngrx/store + @ngrx/effects 用于状态管理,通过 ngrx 的单个存储管理应用状态。

第十一章, 使用 SASS 进行润色,集成 nativescript-dev-sass 插件,以 SASS 润色我们的应用样式。

第十二章, 单元测试,设置 Karma 单元测试框架,以确保我们的应用具有未来性。

第十三章, 使用 Appium 进行集成测试,设置 Appium 进行集成测试。

第十四章, 使用 webpack 打包进行部署准备,与 webpack 合作以优化发布时的包。

第十五章, 部署到 Apple App Store,让我们通过 Apple App Store 分发我们的应用。

第十六章, 部署到 Google Play,让我们通过 Google Play 分发我们的应用。

您需要为此书准备的内容

本书假设您正在使用 NativeScript 3 或更高版本和 Angular 4.1 或更高版本。如果您计划进行 iOS 开发,您需要一个安装了 XCode 的 Mac 来运行配套的应用程序。您还应安装至少一个模拟器(最好运行 7.0.0 版本,API 24 或更高版本)的 Android SDK 工具。

这本书适合谁阅读

本书面向所有对 iOS 和 Android 移动应用开发感兴趣的软件开发人员。它专门针对那些已经对 TypeScript 有一般了解以及一些基本 Angular 功能的用户。刚开始接触 iOS 和 Android 移动应用开发的 Web 开发者也可能从本书的内容中获得很多收益。

约定

在这本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“支持各种常见属性(paddingfont sizefont weightcolorbackground color等)。此外,缩写边距/填充也有效,即 padding: 15 5。”

代码块应如下设置:

[default]
export class AppComponent {}

当我们希望引起您对代码块中特定部分的注意时,相关的行或项目将以粗体显示:

[default]
public init() {
 const item = {};
 item.volume = 1; }

任何命令行输入或输出都应如下编写:

 # tns run ios --emulator

新术语重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“再次运行我们的应用,我们现在在点击 Record 按钮时看到登录提示”。

警告或重要注意事项如下所示。

技巧和窍门如下所示。

读者反馈

我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者的反馈对我们来说很重要,因为它帮助我们开发出您真正能从中受益的标题。要发送一般反馈,请简单地发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书的标题。如果您在某个主题领域有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在,您已经成为 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您充分利用您的购买。

下载示例代码

您可以从www.packtpub.com的账户下载这本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的 SUPPORT 标签上。

  3. 点击代码下载与勘误。

  4. 在搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从您购买此书的下拉菜单中选择。

  7. 点击代码下载。

一旦文件下载完成,请确保使用最新版本的以下软件解压或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/NativeScript-for-Angular-Mobile-Development。我们还有其他来自我们丰富图书和视频目录的代码包可供在github.com/PacktPublishing/找到。查看它们吧!

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/NativeScriptforAngularMobileDevelopment_ColorImages.pdf下载此文件。

勘误

尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书籍中找到错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

海盗行为

在互联网上,版权材料的盗版问题是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在网上遇到任何形式的我们作品的非法副本,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。请通过发送链接到copyright@packtpub.com与我们联系,以提供疑似盗版材料。我们感谢您的帮助,以保护我们的作者和向您提供有价值内容的能力。

问题

如果您在这本书的任何方面遇到问题,您可以联系我们的questions@packtpub.com,我们将尽力解决问题。

第一章:使用 @NgModule 进行塑形

在本章中,我们将从一些扎实的项目组织练习开始,为使用 NativeScript for Angular 构建一个惊人的应用程序做好准备。我们希望向您提供一些见解,让您在规划架构时考虑一些重要且强大的概念,以便在考虑可扩展性的同时,铺就一条顺畅的开发体验之路。

将 Angular 与 NativeScript 结合使用提供了大量有用的范例和工具,用于构建和规划您的应用程序。正如常说的那样,权力越大,责任越大,虽然这种技术组合在创建惊人的应用程序方面非常出色,但它们也可以用于创建过度工程化和难以调试的应用程序。让我们用几章的时间来探讨一些可以帮助您避免常见陷阱并真正发挥此堆栈全部潜力的练习。

我们将向您介绍 Angular 的 @NgModule 装饰器,我们将专门使用它来帮助将我们的应用程序代码组织成具有明确目的和可移植性的逻辑单元。我们还将介绍一些我们将在我们的架构中使用的一些 Angular 概念,例如可依赖注入的服务。在为构建一个坚实的基础而努力之后,我们将在第三章末快速接近运行我们的应用程序。

在本章中,我们将涵盖以下主题:

  • NativeScript for Angular 是什么?

  • 设置您的原生移动应用程序

  • 项目组织

  • 架构规划

  • @NgModule 装饰器

  • @Injectable 装饰器

  • 将您的应用程序拆分为模块

心理准备

在直接进入编码之前,您可以通过规划应用程序所需的各种服务和功能来极大地提高您项目的开发体验。这样做将有助于减少代码重复,构建您的数据流,并为未来的快速功能开发指明方向。

服务是一个通常处理处理和/或为您的应用程序提供数据的类。您对这些服务的使用不需要知道数据的具体来源,只需知道它可以请求服务以实现其目的,并且它就会发生。

绘图练习

这是一个很好的练习,可以勾勒出您应用程序视图的一个粗略想法。您可能还不知道它将是什么样子,这没关系;这是一个纯粹的练习,旨在考虑用户期望,作为引导您思考构建满足这些期望所需的各种部分或模块的第一步。它还将帮助您思考应用程序需要管理的各种状态。

以我们即将构建的应用程序为例,TNSStudioTelerik NativeScriptTNS)) 我们将在第二章“功能模块”中深入了解我们的应用程序是什么以及它确切会做什么。

图片

从上到下,我们可以看到一个带有菜单按钮、标志和录音按钮的标题。然后,我们有用户录制轨道的列表,每个轨道都有一个(重新)录音按钮和一个静音或取消静音按钮。

从这个草图出发,我们可以考虑应用可能需要提供的一些服务:

  • 播放服务

  • 录音服务

  • 一个持久存储服务,用于记住用户为每个录制轨道设置的音量级别设置,以及用户是否已认证

我们还可以了解应用可能需要管理的各种状态:

  • 用户录制/轨道列表

  • 无论应用是否正在播放音频

  • 无论应用是否处于录音模式

低级思考

提供一些低级服务,提供方便的 API 来访问事物,例如 HTTP 远程请求和/或日志记录,这也是有利的。这样做将允许您创建独特的特性,您或您的团队喜欢在与低级 API 交互时使用。例如,也许您的后端 API 需要在每个请求的特殊认证头之外设置一个唯一的头。创建一个 HTTP 服务的低级包装器将允许您隔离这些独特的特性,并为您的应用提供一个一致的 API 来与之交互,以确保所有 API 调用都在一个地方增强了它们所需的内容。

此外,您的团队可能希望有一种能力将所有日志代码汇总到第三方日志分析器(用于调试或其他性能相关指标)。通过创建围绕某些框架服务的轻量级包装器,将允许您的应用快速适应这些潜在需求。

使用@NgModule 模块化

然后,我们可以考虑将这些服务拆分成组织单元或模块。

Angular 为我们提供了@NgModule装饰器,它将帮助我们定义这些模块的外观以及它们为我们的应用提供的内容。为了使我们的应用启动/启动时间尽可能快,我们可以以这种方式组织我们的模块,以便在应用启动后可以延迟加载一些服务/功能。通过使用应用启动所需的小部分代码启动一个模块,可以帮助将启动阶段保持最小。

我们应用模块分解

这是我们将通过模块分解应用组织的方式:

  1. CoreModule:提供低级服务、组件和实用工具,构成一个良好的基础层。例如,与日志记录、对话框、HTTP 和其他各种常用服务的交互。

  2. AnalyticsModule******:可能,您可以有一个模块提供各种服务来处理您应用的统计分析。

  3. PlayerModule*****:提供我们应用播放音频所需的一切。

  4. RecorderModule*****:提供我们应用记录音频所需的一切。

()*这些被认为是功能模块()我们将从本书的示例中省略此模块,但在此处提及以提供上下文。

模块优势

使用类似的组织结构为您和您的团队提供了几个有利之处:

  • 高度易用性:通过设计低级的CoreModule,您和您的团队有机会以独特的方式设计如何与常用服务协同工作,不仅限于您现在正在构建的应用程序,而且更多地关注未来。您可以轻松地将CoreModule移动到完全不同的应用程序中,并在处理底层服务时获得为该应用程序设计的所有相同独特 API。

  • 将您的应用程序代码视为“功能模块”:这样做将帮助您专注于应用程序应提供的独特功能,而不仅仅是CoreModule提供的功能,同时减少代码的重复。

  • 鼓励并增强快速开发:通过将常用功能限制在我们的CoreModule中,我们减轻了在功能模块中担心这些细节的负担。我们可以简单地注入CoreModule提供的服务,并使用这些 API,而无需重复。

  • 可维护性:在未来,如果由于您的应用程序需要与底层服务协同工作,需要更改某些底层细节,则只需在CoreModule服务中更改一次(而不是在应用程序的不同部分中可能存在的冗余代码中),从而减少代码的重复。

  • 性能:将应用程序拆分为模块将允许您在启动时仅加载所需的模块,然后在以后按需懒加载其他功能。最终,这将导致更快的应用程序启动时间。

考虑因素?

您可能会想,为什么不将播放器/录制器模块合并成一个模块呢?

回答:我们的应用程序只允许在注册用户认证时进行录制。因此,考虑认证上下文的可能性以及哪些功能仅对认证用户(如果有的话)可访问是有益的。这将使我们能够进一步微调应用程序的加载性能,使其在需要时才进行。

开始使用

我们将假设您已经在计算机上正确安装了 NativeScript。如果没有,请按照nativescript.org上的安装说明进行操作。安装完成后,我们需要使用 shell 提示符创建我们的应用程序框架:

tns create TNSStudio --ng

tns代表 Telerik NativeScript**。它是您将用于创建、构建、部署和测试任何 NativeScript 应用程序的主要命令行用户界面**(CLI)工具。

此命令将创建一个名为TNSStudio的新文件夹。其中包含您的主要项目文件夹,包括构建应用程序所需的一切。它将包含与该项目相关的所有内容。在创建项目文件夹后,您还需要做一件事才能拥有一个完全可运行的应用程序。那就是,添加 Android 和/或 iOS 的运行时:

cd TNSStudio
tns platform add ios
tns platform add android

如果你使用的是 Macintosh,你可以为 iOS 和 Android 构建。如果你在 Linux 或 Windows 设备上运行,你可以在本地机器上编译的仅限 Android 平台。

创建我们的模块外壳

在还没有编写我们服务的实现之前,我们可以通过开始定义它应该提供的内容来使用NgModule定义我们的CoreModule大致看起来会是什么样子:

让我们创建app/modules/core/core.module.ts

// angular
import { NgModule } from '@angular/core';
@NgModule({})
export class CoreModule { }

可注入服务

现在,让我们创建我们服务所需的样板代码。注意,这里的 injectable 装饰器是从 Angular 导入的,用于声明我们的服务将通过 Angular 的依赖注入DI)系统提供,该系统允许将这些服务注入到可能需要它们的任何类构造函数中。DI 系统提供了一种很好的方式来保证这些服务将被实例化为单例并在我们的应用中共享。还值得注意的是,如果我们不想它们是单例,而是为组件树中的某些分支创建唯一的实例,我们可以将这些服务提供在组件级别上。在这种情况下,尽管如此,我们希望它们被创建为单例。我们将向我们的CoreModule添加以下内容:

  • LogService:服务用于将所有我们的控制台日志引导通过。

  • DatabaseService:用于处理我们应用需要的任何持久数据的服务。对于我们的应用,我们将实现原生移动设备的存储选项,例如应用程序设置,作为一个简单的键/值存储。然而,你在这里可以实现更高级的存储选项,例如通过 Firebase 等远程存储。

创建app/modules/core/services/log.service.ts

// angular
import { Injectable } from '@angular/core';
@Injectable()
export class LogService {
}

此外,创建app/modules/core/services/database.service.ts

// angular
import { Injectable } from '@angular/core';
@Injectable()
export class DatabaseService {
}

一致性和标准

为了保持一致性、减少我们导入的长度以及为更好的可扩展性做准备,让我们也在app/modules/core/services中创建一个index.ts文件,该文件将导出我们的服务集合以及导出这些服务(按字母顺序排列以保持整洁):

import { DatabaseService } from './database.service';
import { LogService } from './log.service';

export const PROVIDERS: any[] = [
  DatabaseService,
  LogService
];

export * from './database.service';
export * from './log.service';

我们将在整本书中遵循类似的组织模式。

完成 CoreModule

我们现在可以修改我们的CoreModule以使用我们已创建的内容。我们将借此机会也导入我们的应用将需要用于与其他 NativeScript for Angular 功能协同工作的NativeScriptModule,我们希望这些功能对应用来说是全局可访问的。既然我们知道我们希望这些功能是全局的,我们也可以指定它们是导出的,这样当我们导入和使用我们的CoreModule时,我们就不必担心在其他地方导入NativeScriptModule。以下是我们的CoreModule修改应该看起来像什么:

// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module';
// angular
import { NgModule } from '@angular/core';
// app
import { PROVIDERS } from './services';
@NgModule({
  imports: [
    NativeScriptModule
  ],
  providers: [
    ...PROVIDERS
  ],
  exports: [
    NativeScriptModule
  ]
})
export class CoreModule { }

现在我们已经为我们的CoreModule建立了一个良好的起点,其详细内容将在接下来的章节中实现。

摘要

在本章中,我们为我们的应用程序打下了坚实的基础。你学习了如何从模块的角度思考你的应用程序架构。你还学习了如何利用 Angular 的@NgModule装饰器来构建这些模块。最后,我们现在有一个很好的基础架构,可以在此基础上构建我们的应用程序。

既然你已经掌握了一些关键概念,我们现在可以进入我们应用程序的核心部分,即功能模块。让我们深入了解我们应用程序的主要功能,以继续在第二章,“功能模块”中构建我们的服务层。我们将在第三章,“通过组件构建我们的第一个视图”中为我们的应用程序创建一些视图,并在 iOS 和 Android 上运行应用程序。

第二章:功能模块

我们将通过构建应用程序所需的核心功能模块(播放器和录音器)的基础来继续构建我们应用程序的基础。我们还将注意,录音功能只有在用户认证并首次进入录音模式时才会加载和可用。最后,我们将完成从我们在第一章中创建的 CoreModule 的服务实现,使用 @NgModule 进入形状

在本章中,我们将涵盖以下主题:

  • 创建功能模块

  • 应用程序功能的关注点分离

  • 设置 AppModule 以高效引导,仅加载我们首次视图所需的功能模块

  • 使用 NativeScript 的 application-settings 模块作为我们的键/值存储

  • 在一个地方提供控制我们应用程序调试日志的能力

  • 创建一个新服务,该服务将使用其他服务来展示我们的可扩展架构

播放器和录音模块

让我们创建我们两个主要功能模块的框架。请注意,我们还向以下模块的导入中添加了 NativeScriptModule

  1. 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 { }
  1. RecorderModule: 这将提供仅当用户认证并首次进入录音模式时才会加载和提供的录音特定服务和组件。

让我们创建 app/modules/recorder/recorder.module.ts:

// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module'; 

// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';

@NgModule({
  imports: [ NativeScriptModule ],
  schemas: [ NO_ERRORS_SCHEMA ]
})
export class RecorderModule { }

我们数据的一个共享模型

在我们创建服务之前,让我们为我们的应用程序将使用的数据的核心部分创建一个接口和模型实现。TrackModel 将代表一个单独的轨道,如下所示:

  • filepath: (到本地文件)

  • name: (用于我们的视图)

  • order: 位置(用于轨道查看列表)

  • volume: 我们希望我们的播放器能够通过不同的音量级别设置混合不同的轨道

  • solo: 我们是否只想在我们的混合中听到这个轨道

我们还将向我们的模型添加一个方便的构造函数,它将接受一个对象来初始化我们的模型。

创建 app/modules/core/models/track.model.ts,因为它将在我们的播放器和录音器之间共享:

export interface ITrack {
  filepath?: string;
  name?: string;
  order?: number;
  volume?: number;
  solo?: boolean;
}
export class TrackModel implements ITrack {
  public filepath: string;
  public name: string;
  public order: number;
  public volume: number = 1; // set default to full volume
  public solo: boolean;

  constructor(model?: any) {
    if (model) {
      for (let key in model) {
        this[key] = model[key];
      }
    }
  }
}

构建服务 API 的框架

现在,让我们创建我们的服务将为我们的应用程序提供的服务 API。从 PlayerService 开始,我们可以想象以下 API 可能对管理轨道和控制播放很有用。其中大部分应该是相当自解释的。我们可能稍后会重构它,但这是一个很好的开始:

  • playing: boolean;

  • tracks: Array<ITrack>;

  • play(index: number): void;

  • pause(index: number): void;

  • addTrack(track: ITrack): void;

  • removeTrack(track: ITrack): void;

  • reorderTrack(track: ITrack, newIndex: number): void;

创建 app/modules/player/services/player.service.ts 并为其中的一些方法提供占位符;其中一些我们可以立即实现:

// angular
import { Injectable } from '@angular/core';

// app
import { ITrack } from '../../core/models';
@Injectable()
export class PlayerService {

  public playing: boolean;
  public tracks: Array<ITrack>;

  constructor() {
    this.tracks = [];
  }

  public play(index: number): void {
    this.playing = true;
  }
  public pause(index: number): void {
    this.playing = false;
  }
  public addTrack(track: ITrack): void {
    this.tracks.push(track);
  }
  public removeTrack(track: ITrack): void {
    let index = this.getTrackIndex(track);
    if (index > -1) {
      this.tracks.splice(index, 1);
    }
  }
  public reorderTrack(track: ITrack, newIndex: number) {
    let index = this.getTrackIndex(track);
    if (index > -1) {
      this.tracks.splice(newIndex, 0, this.tracks.splice(index, 1)[0]);
    }
  }
  private getTrackIndex(track: ITrack): number {
    let index = -1;
    for (let i = 0; i < this.tracks.length; i++) {
      if (this.tracks[i].filepath === track.filepath) {
        index = i;
        break;
      }
    }
    return index;
  }
}

现在,让我们通过为我们模块导出此服务来应用我们的标准。

创建 app/modules/player/services/index.ts

import { PlayerService } from './player.service';

export const PROVIDERS: any[] = [
  PlayerService
];

export * from './player.service';

最后,修改我们的 PlayerModule 以指定正确的提供者,因此我们的最终模块应该看起来如下:

// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module'; 

// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';

// app
import { PROVIDERS } from './services';

@NgModule({
  imports: [ NativeScriptModule ],
  providers: [ ...PROVIDERS ],
  schemas: [ NO_ERRORS_SCHEMA ]
})
export class PlayerModule { }

接下来,我们可以设计 RecorderService 以提供简单的记录 API。

创建 app/modules/recorder/services/recorder.service.ts

  • record(): void

  • stop(): void

// angular
import { Injectable } from '@angular/core';
@Injectable()
export class RecorderService {
  public record(): void { }
  public stop(): void { }
}

现在,通过导出此服务为我们模块应用我们的标准。

创建 app/modules/recorder/services/index.ts

import { RecorderService } from './recorder.service';

export const PROVIDERS: any[] = [
  RecorderService
];

export * from './recorder.service';

最后,修改我们的 RecorderModule 以指定正确的提供者,因此我们的最终模块应该看起来如下:

// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module'; 

// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';

// app
import { PROVIDERS } from './services';

@NgModule({
  imports: [ NativeScriptModule ],
  providers: [ ...PROVIDERS ],
  schemas: [ NO_ERRORS_SCHEMA ]
})
export class RecorderModule { }

在我们的两个主要功能模块搭建并准备就绪后,让我们回顾一下在第一章 Get Into Shape with @NgModule 中创建的两个低级服务,并提供实现。

实现 LogService

日志是你在应用开发周期以及生产过程中都希望拥有的重要盟友。它可以帮助你调试,同时深入了解你的应用是如何被使用的。通过一个单一的路径运行所有日志也提供了一个机会,只需切换一下开关,就可以将所有应用日志重定向到其他地方。例如,你可以通过 Segment (segment.com) 使用第三方调试跟踪服务,如 TrackJS (trackjs.com))。你希望将应用的重要方面大量通过日志运行,并且它提供了一个很好的地方来拥有很多控制和灵活性。

让我们打开 app/modules/core/services/log.service.ts 并开始工作。让我们首先定义一个静态布尔值,它将作为一个简单的标志,我们可以在 AppModule 中切换以启用/禁用。同时,我们也添加一些有用的方法:

import { Injectable } from '@angular/core';

@Injectable()
export class LogService {

 public static ENABLE: boolean = true;

 public debug(msg: any, ...formatParams: any[]) {
   if (LogService.ENABLE) {
     console.log(msg, formatParams);
   }
 }

 public error(msg: any, ...formatParams: any[]) {
   if (LogService.ENABLE) {
     console.error(msg, formatParams);
   }
 }

 public inspect(obj: any) {
   if (LogService.ENABLE) {
     console.log(obj);
     console.log('typeof: ', typeof obj);
     if (obj) {
       console.log('constructor: ', obj.constructor.name);
       for (let key in obj) {
         console.log(`${key}: `, obj[key]);
       }
     }
   }
  }
}
  • debug:这将作为我们最常用的日志输出 API。

  • error:当我们知道某个条件是错误时,这将帮助我们识别日志中的这些位置。

  • inspect:有时候查看一个对象可以帮助我们找到错误或帮助我们理解应用在任何给定时刻的状态。

在我们的 LogService 实现之后,我们现在将在整个应用以及本书的其余部分中使用它,而不是直接使用控制台。

实现 DatabaseService

我们的 DatabaseService 需要提供以下几项:

  • 一个持久存储来保存和检索应用所需的所有数据。

  • 它应该允许存储任何类型的数据;然而,我们特别希望它能够处理 JSON 序列化。

  • 所有我们希望存储的数据的静态键。

  • 一个指向已保存用户的静态引用?嗯,是的,可以。然而,这引出了一个我们将在稍后解决的问题。

关于第一项,我们可以使用 NativeScript 的 application-settings 模块。在底层,此模块提供了一个一致的 API 来与两个原生移动 API 一起工作:

关于序列化 JSON 数据,application-settings模块提供了setStringgetString方法,这将允许我们结合使用JSON.stringifyJSON.parse

在代码库的多个不同位置使用字符串值来引用应该保持恒定的相同键,可能会变得容易出错。因此,我们将保留一个类型化的(为了类型安全)静态哈希表,其中包含我们应用程序将使用的有效键。我们可能目前只知道一个(认证用户作为'current-user'),但创建这个哈希表将提供一个单一的位置来随着时间的推移扩展这些键。

四个?我们将在稍后解决四个问题。

打开app/modules/core/services/database.service.ts并修改它,以便提供类似于网络localStorage API 的类似 API,以简化操作:

// angular
import { Injectable } from '@angular/core';

// nativescript
import * as appSettings from 'application-settings';

interface IKeys {
  currentUser: string;
}
@Injectable()
export class DatabaseService {

  public static KEYS: IKeys = {
    currentUser: 'current-user'
  };

  public setItem(key: string, value: any): void {
    appSettings.setString(key, JSON.stringify(value));
  }

  public getItem(key: string): any {
    let item = appSettings.getString(key);
    if (item) {
      return JSON.parse(item);
    } 
    return item;
  }

  public removeItem(key: string): void {
    appSettings.remove(key);
  }
}

此服务现在提供了一种通过setItem存储对象的方式,这确保了对象通过JSON.stringify正确地存储为字符串。它还提供了一种通过getItem检索值的方式,它还通过JSON.parse为我们处理序列化回对象。我们还有一个用于从持久存储中简单删除值的remove API。最后,我们有一个指向所有有效键的静态引用,这些键是我们持久存储将跟踪的。

那么,关于保存用户的静态引用呢?

我们希望能够在应用程序的任何地方轻松访问我们的认证用户。为了简单起见,我们可以在DatabaseService中提供一个静态引用,但我们的目标是实现关注点的清晰分离。既然我们知道我们将来会想要展示一个模态窗口让用户注册并解锁那些录制功能,那么创建一个新的服务来管理这一点是有意义的。由于我们已经设计了可扩展的架构,我们可以轻松地添加另一个服务,所以现在就让我们这么做吧!

创建AuthService以帮助处理我们应用程序的认证状态

对于我们的AuthService来说,一个重要的考虑因素是理解,我们的应用程序中的一些组件可能会从认证状态变化时得到通知。这是一个利用 RxJS 的完美用例。RxJS 是一个非常强大的库,用于通过观察者来简化处理变化的数据和事件。观察者是一种数据类型,您可以使用它不仅来监听事件,还可以在事件发生时进行过滤、映射、归约和运行代码序列。通过使用观察者,我们可以极大地简化我们的异步开发。我们将使用一种特定的观察者类型,即BehaviorSubject,来发出我们的组件可以订阅的变化。

创建app/modules/core/services/auth.service.ts并添加以下内容:

// angular
import { Injectable } from '@angular/core';

// lib
import { BehaviorSubject } from 'rxjs/BehaviorSubject';

// app
import { DatabaseService } from './database.service';
import { LogService } from './log.service';

@Injectable()
export class AuthService {

 // access our current user from anywhere
 public static CURRENT_USER: any;

 // subscribe to authenticated state changes
 public authenticated$: BehaviorSubject<boolean> = 
   new BehaviorSubject(false);

 constructor(
   private databaseService: DatabaseService,
   private logService: LogService
 ) {
   this._init();
 } 

 private _init() {
   AuthService.CURRENT_USER = this.databaseService
     .getItem(DatabaseService.KEYS.currentUser);
   this.logService.debug(`Current user: `,
     AuthService.CURRENT_USER);
   this._notifyState(!!AuthService.CURRENT_USER);
 }

 private _notifyState(auth: boolean) {
   this.authenticated$.next(auth);
 }
}

这里有一些有趣的事情在进行。我们正在放置另外两个我们设计的立即工作的服务,LogServiceDatabaseService。它们帮助我们检查用户是否被保存/认证,并记录这个结果。

我们在通过 Angular 的依赖注入系统构建我们的服务时,也会调用一个private _init方法。这允许我们立即检查在我们的持久存储中是否存在已认证的用户。然后,我们调用一个私有的可重用方法_notifyState,它将在我们的authenticated$可观察对象上发出truefalse。这将提供一种很好的方式,让其他组件通过订阅这个可观察对象来轻松地得到认证状态变化的通知。我们使_notifyState可重用,因为我们的登录和注册方法(将在未来实现)将能够在从我们可能在 UI 中显示的模态返回结果时使用它。

我们现在可以轻松地将AuthService添加到我们的PROVIDERS中,并且我们不需要做任何其他事情来确保它被添加到我们的CoreModule中,因为我们的PROVIDERS已经添加到了CoreModule

我们需要做的只是修改app/modules/core/services/index.ts并添加我们的服务:

import { AuthService } from './auth.service';
import { DatabaseService } from './database.service';
import { LogService } from './log.service';

export const PROVIDERS: any[] = [
 AuthService,
 DatabaseService,
 LogService
];

export * from './auth.service';
export * from './database.service';
export * from './log.service';

等等!我们想要做的一件重要的事情是确保我们的 AuthService 初始化!

Angular 的依赖注入系统只会实例化在某个地方注入的服务。尽管我们已经在我们的CoreModule中将所有服务指定为提供者,但它们实际上只有在被注入到某个地方时才会被构建!

打开app/app.component.ts并将其内容替换为以下内容:

// angular
import { Component } from '@angular/core';

// app
import { AuthService } from './modules/core/services';

@Component({
 selector: 'my-app',
 templateUrl: 'app.component.html',
})
export class AppComponent {

 constructor(private authService: AuthService) { }

}

我们通过将AuthService指定为组件构造函数的参数来注入我们的AuthService。这将导致 Angular 构建我们的服务。我们代码中的所有后续注入都将接收相同的单例。

准备启动 AppModule

我们现在为我们的功能模块提供了一个良好的设置,现在是时候将它们全部整合到我们的根AppModule中,它负责启动我们的应用程序。

只启动你初始视图所需的部分。其余部分按需懒加载。

重要的是要尽可能快地启动我们的应用程序。为了实现这一点,我们只想使用我们初始视图所需的主要功能来启动应用程序,并在需要时懒加载其余部分。我们知道我们希望我们的低级服务在任何地方都可用并准备好使用,所以我们肯定会希望CoreModule预先加载。

我们从草图中的初始视图将开始于播放器和列表上的 2-3 个曲目,这样用户就可以立即播放我们将与应用程序一起提供的预录制的曲目的混合。出于这个原因,当我们的应用程序启动时,我们将指定PlayerModule预先加载,因为它将是我们想要立即参与的主要功能。

我们将设置一个路由配置,当用户点击初始视图右上角的记录按钮以开始录音会话时,将懒加载我们的RecorderModule

考虑到这一点,我们可以在app/app.module.ts中设置我们的AppModule,如下所示:

// angular 
import { NgModule } from '@angular/core'; 

// app
import { AppComponent } from './app.component';
import { CoreModule } from './modules/core/core.module';
import { PlayerModule } from './modules/player/player.module'; 

@NgModule({ 
  imports: [ 
    CoreModule, 
    PlayerModule 
  ], 
  declarations: [AppComponent],
  bootstrap: [AppComponent] 
})
export class AppModule { }

摘要

在整个过程中,我们一直在努力创建一个坚实的基础来构建我们的应用程序。我们创建了一个CoreModule来提供一些低级服务,例如日志记录和持久存储,并设计了该模块以便在需要时轻松扩展更多服务。此外,此模块是可移植的,并且可以完整地插入到其他项目中。

在典型的应用程序开发中,你可能想在 iOS 和/或 Android 模拟器上运行你的应用程序,在这个过程中双重检查一些你的设计/架构选择,这是值得推荐的!我们还没有这样做,因为我们已经有一个预计划的应用程序,并希望你能专注于我们正在做出的选择以及为什么这样做。

我们还创建了两个主要的功能模块,这是我们的应用程序核心能力所需的,即PlayerModuleRecorderModule。播放器将预先设置,加载并准备好 2-3 条录音轨道,以便在启动时即可播放,因此我们将使用PlayerModule的功能来启动我们的应用程序。

我们将提供一个简单的方法,让用户能够注册账户,这样他们就可以记录自己的轨迹并添加到混合中。一旦他们登录,他们将通过一个路径进入记录模式,该路径会懒加载RecorderModule

在下一章中,我们将创建我们的第一个视图,配置我们的路由,并最终,一睹我们的应用程序的真容。

第三章:通过组件构建我们的第一个视图

我们一直在努力构建第二章中“功能模块”的基础,现在终于可以一窥我们所工作的内容。这全部关于将我们的草图中的第一个视图移动到移动设备屏幕上。

使用 NativeScript 为 Angular 构建视图与网页视图构建没有太大区别。我们将使用 Angular 的组件装饰器来构建我们 UI 需要的各种组件,以实现我们追求的可用性。我们将使用 NativeScript XML 而不是 HTML 标记,因为 NativeScript XML 是一个非常强大、简单且简洁的抽象,它代表了 iOS 和 Android 上的所有原生视图组件。

我们不会涵盖所有可用的组件的好处和类型;但若想了解更多,我们推荐以下任何一本书籍:

本章我们将涵盖以下主题:

  • 使用组件装饰器来组合我们的视图

  • 创建可重用组件

  • 使用管道创建自定义视图过滤器

  • 在 iOS 和 Android 模拟器上运行应用

通过组件构建我们的第一个视图

如果我们查看第一章中的草图,“使用 @NgModule 进入形状”,我们可以看到应用顶部的标题栏,其中将包含我们的应用标题和右侧的记录按钮。我们还可以看到底部的曲目列表和一些播放器控制。我们可以将这些 UI 设计的关键元素分解为基本上三个主要组件。一个组件是由 NativeScript 框架提供的,即 ActionBar,我们将用它来表示顶部标题栏。

NativeScript 提供了许多丰富的视图组件来构建我们的 UI。标记不是 HTML,而是具有 .html 扩展名的 XML,这可能会显得有些不寻常。使用 NativeScript for Angular 的 .html 扩展名用于 XML 视图模板的原因是自定义渲染器(github.com/NativeScript/nativescript-angular)使用 DOM 适配器来解析视图模板。每个 NativeScript XML 组件代表各自平台上的真实原生视图小部件。

对于其他两个主要组件,我们将使用 Angular 的组件装饰器。在应用开发周期的这个阶段,考虑封装的 UI 功能块非常重要。我们将把我们的曲目列表封装为一个组件,并将播放器控制封装为另一个组件。在这个练习中,我们将从抽象的观点开始,逐步到每个组件的实现细节,采用自外向内的方法来构建我们的 UI。

首先,让我们关注我们的 Angular 应用程序中的根组件,因为它将定义我们第一个视图的基本布局。打开 app/app.component.html,清除其内容,并用以下内容替换,以从我们的草图草拟初始 UI 概念:

<ActionBar title="TNSStudio">
</ActionBar>
<GridLayout rows="*, 100" columns="*">
  <track-list row="0" col="0"></track-list>
  <player-controls row="1" col="0"></player-controls>
</GridLayout>

我们使用 ActionBar 和主要视图的主要布局容器 GridLayout 来表达我们的视图。在 NativeScript 中,每个视图以一个布局容器作为根节点(在 ActionBarScrollView 之外)开始是很重要的,就像在 HTML 标记中使用 div 标签一样。在撰写本文时,NativeScript 提供了六个布局容器:StackLayoutGridLayoutFlexboxLayoutAbsoluteLayoutDockLayoutWrapLayout。对于我们的布局,GridLayout 会工作得很好。

关于 GridLayout 的所有内容

GridLayout 是你在 NativeScript 应用程序中会用到的三种最常用的布局之一(其他的是 FlexboxLayout 和 StackLayout)。这是允许你轻松构建复杂布局的布局。使用 GridLayout 非常类似于 HTML 中的增强表格。你基本上会想要将屏幕区域划分为你需要的部分。它将允许你告诉列(或行)占屏幕剩余宽度(和高度)的百分比。网格支持三种类型的值;绝对大小剩余空间的百分比和已用空间

对于绝对大小,你只需输入数字。例如,100表示它将使用 100 dp 的空间。

dp 的另一个名称是 dip。它们是相同的。设备无关像素(也称为密度无关像素,DIP 或 DP)是基于计算机坐标系统的物理单位,代表了一个用于应用程序的像素抽象,底层系统将其转换为物理像素。

如果你选择支持的最小 iOS 设备,它的屏幕宽度为 320dp。对于其他设备,例如平板电脑,一些设备的宽度为 1024 dp。因此,100 dp 几乎是 iOS 手机的一个三分之一,而在平板电脑上则是屏幕的十分之一。所以,在使用固定绝对值时,你需要考虑这一点。通常,使用已用空间比使用固定值更好,除非你需要将列限制为特定大小。

要使用基于剩余空间的值,即 ****** 告诉它使用剩余的空间。如果列(或行)设置为 *,则空间将分为两个相等的剩余空间。同样,rows="*,*,*,*,*" 将指定五个等大小的行。你还可以指定一些事情,例如 columns="2*,3*,*",,你将得到三个列;第一个列将是屏幕的六分之二,第二个列将是屏幕的六分之三,最后一个列将是屏幕的六分之一(即 2+3+1=6)。这允许你在使用剩余空间方面有极大的灵活性。

第三种尺寸是空间使用。所以,当网格内部的内容被测量后,列将被分配一个大小,这个大小是该列(或行)中使用的最大值。这在您有一个包含数据但不确定大小或您并不真的在乎的网格时非常有用;您只是希望它看起来不错。所以,这是 auto 关键字。我可能有columns="auto,auto,*,auto"。这意味着第 1、2 和 4 列将根据这些列中的内容自动调整大小;而第 3 列将使用剩余的空间。这对于布局整个屏幕或屏幕的某些部分非常有用,您希望达到某种特定的外观。

GridLayout 之所以是最佳布局之一,最后一个原因是当您将项目分配给 GridLayout 时,您实际上可以将多个项目分配给相同的行和/或列,并且您可以使用行或列跨度来允许项目使用多个行和/或列。

要分配一个对象,您只需通过row="0"和/或col="0"进行分配(请注意,这些是基于索引的位置)。您还可以使用rowSpancolSpan来使一个元素跨越多个行和/或列。总的来说,GridLayout 是最灵活的布局,允许您轻松创建您在应用程序中需要的几乎任何布局。

回到我们的布局

在网格内部,我们声明了一个track-list组件来表示我们的轨道列表,该组件将垂直伸缩,占据所有垂直空间,并为player-controls留下 100 像素的高度。我们将track-list标记为row="0" col="0",因为行和列是基于索引的。网格的灵活(剩余)垂直高度是通过 GridLayout 的*在行属性中定义的。网格的底部部分(第 1 行)将代表播放器控制,允许用户播放/暂停混合并移动播放位置。

现在我们已经以相当抽象的方式定义了应用程序的主要视图,让我们深入了解我们需要构建的两个自定义组件,track-listplayer-controls

构建 TrackList 组件

轨道列表应该是所有已记录轨道的列表。列表中的每一行应提供一个单独的记录按钮以重新录制,以及一个名称标签来显示用户提供的标题。它还应提供一个开关,允许用户仅独奏该特定轨道。

我们可以注入PlayerService并将其声明为public,以便我们可以直接绑定到服务中的轨道集合。

我们也可以模拟一些我们的绑定来启动一些操作,比如record动作。目前,我们只允许传入一个轨道,并通过LogService打印出该轨道的检查信息。

让我们从创建app/modules/player/components/track-list/ track-list.component.ts(与匹配的.html模板)开始:

// angular
import { Component, Input } from '@angular/core';

// app
import { ITrack } from '../../../core/models';
import { LogService } from '../../../core/services';
import { PlayerService } from '../../services/player.service';

@Component({
 moduleId: module.id,
 selector: 'track-list',
 templateUrl: 'track-list.component.html'
})
export class TrackListComponent {

 constructor(
   private logService: LogService,
   public playerService: PlayerService
 ) { }

 public record(track: ITrack) {
   this.logService.inspect(track);
 }
}

对于视图模板 track-list.component.html,我们将使用强大的 ListView 组件。此小部件代表 iOS 上的原生 UITableView (developer.apple.com/reference/uikit/uitableview) 和 Android 上的原生 ListView (developer.android.com/guide/topics/ui/layout/listview.html),提供具有复用行的 60 fps 虚拟滚动。在移动设备上的性能无与伦比:

<ListView [items]="playerService.tracks">
  <ng-template let-track="item">
    <GridLayout rows="auto" columns="75,*,100">
      <Button text="Record" (tap)="record(track)" 
          row="0" col="0"></Button>
      <Label [text]="track.name" row="0" col="1"></Label>
      <Switch [checked]="track.solo" row="0" col="2">
      </Switch>
    </GridLayout>
  </ng-template>
</ListView>

这个视图模板中有很多内容,让我们稍微检查一下。

由于我们在组件构造函数中注入 playerService 时将其设置为 public,我们可以通过 ListView 项的属性直接绑定到其轨道,使用标准的 Angular 绑定语法表示为 [items]。这将是我们列表迭代的集合。

template 节点内部允许我们封装列表每一行布局的方式。它还允许我们声明一个变量名(let-track),用作我们的迭代器引用。

我们从 GridLayout 开始,因为每一行将包含一个记录按钮(允许重新录制轨道),我们将为其分配宽度 75。此按钮将绑定到 tap 事件,如果用户已认证,则将激活一个录制会话。

然后,我们将有一个标签来显示用户提供的轨道名称,我们将将其分配为 * 以确保它扩展以填充我们左侧和右侧列之间的水平空间。我们使用文本属性将其绑定到 track.name

最后,我们将使用 switch 允许用户切换混音中的轨道独奏。这提供了 checked 属性,使我们能够将 track.solo 属性绑定到它。

构建一个对话框包装服务以提示用户

如果您还记得 第一章 中 使用 @NgModule 进入形状,录制是一个仅应提供给认证用户的功能。因此,当用户在每条轨道上点击记录按钮时,我们将想要提示用户登录对话框。如果他们已经登录,我们将想要提示他们确认是否想要重新录制轨道,以确保良好的用户体验。

我们可以直接在组件中处理这个对话框,通过导入提供跨平台一致 API 的 NativeScript 对话框服务。NativeScript 框架的 ui/dialogs 模块(docs.nativescript.org/ui/dialogs)是一个非常方便的服务,允许你创建原生警报、确认、提示、操作和基本的登录对话框。然而,我们可能希望在将来为 iOS 和 Android 提供定制的原生对话框实现,以获得更好的 UX 体验。有几个插件提供了非常优雅的原生对话框,例如,github.com/NathanWalker/nativescript-fancyalert

为了准备这个丰富的用户体验,让我们构建一个快速的 Angular 服务,我们可以注入并在任何地方使用,这将允许我们轻松地实现这些细微之处。

由于这应该被视为我们应用的 核心 服务,让我们创建 app/modules/core/services/dialog.service.ts

// angular
import { Injectable } from '@angular/core';

// nativescript
import * as dialogs from 'ui/dialogs';

@Injectable()
export class DialogService {

  public alert(msg: string) {
    return dialogs.alert(msg);
  }

  public confirm(msg: string) {
    return dialogs.confirm(msg);
  }

  public prompt(msg: string, defaultText?: string) {
    return dialogs.prompt(msg, defaultText);
  }

  public login(msg: string, userName?: string, password?: string) {
    return dialogs.login(msg, userName, password);
  }

  public action(msg: string, cancelButtonText?: string, 
    actions?: string[]) {
    return dialogs.action(msg, cancelButtonText, actions);
  }
}

初看之下,这可能会显得非常浪费!为什么创建一个提供与 NativeScript 框架中已存在的服务完全相同 API 的包装器?

是的,确实如此,在这个阶段看起来是这样的。然而,我们正在为未来的灵活性及处理这些对话框的能力做准备,以实现卓越。请继续关注,可能会有关于这个有趣且独特的整合润色的额外章节。

在我们继续使用此服务之前,我们需要确保它被添加到我们的核心服务 PROVIDERS 集合中。这将确保 Angular 的 DI 系统知道我们的新服务是一个有效的令牌,可用于注入。

打开 app/modules/core/services/index.ts 并按照以下方式修改:

import { AuthService } from './auth.service';
import { DatabaseService } from './database.service';
import { DialogService } from './dialog.service';
import { LogService } from './log.service';

export const PROVIDERS: any[] = [
 AuthService,
 DatabaseService,
 DialogService,
 LogService
];

export * from './auth.service';
export * from './database.service';
export * from './dialog.service';
export * from './log.service';

我们现在已准备好注入和使用我们的新服务。

将 DialogService 集成到我们的组件中

让我们打开 track-list.component.ts 并注入 DialogService 以在我们的记录方法中使用。我们还需要确定用户是否已登录,以便有条件地显示登录对话框或确认提示,所以让我们也注入 AuthService

// angular
import { Component, Input } from '@angular/core';

// app
import { ITrack } from '../../../core/models';
import { AuthService, LogService, DialogService } from '../../../core/services';
import { PlayerService } from '../../services/player.service';

@Component({
  moduleId: module.id,
  selector: 'track-list',
  templateUrl: 'track-list.component.html'
})
export class TrackListComponent {

 constructor(
   private authService: AuthService,
   private logService: LogService,
   private dialogService: DialogService,
   public playerService: PlayerService
 ) { }

 public record(track: ITrack, usernameAttempt?: string) {
   if (AuthService.CURRENT_USER) {
     this.dialogService.confirm(
       'Are you sure you want to re-record this track?'
     ).then((ok) => {
       if (ok) this._navToRecord(track);
     });
   } else {
     this.authService.promptLogin(
       'Provide an email and password to record.',
       usernameAttempt
     ).then(
       this._navToRecord.bind(this, track), 
       (usernameAttempt) => {
         // initiate sequence again
         this.record(track, usernameAttempt);
       }
     ); 
    }
  }

  private _navToRecord(track: ITrack) {
    // TODO: navigate to record screen
    this.logService.debug('yes, re-record', track);
  }
}

记录方法现在首先检查用户是否通过静态 AuthService.CURRENT_USER 引用进行认证,该引用是在 AuthService 首次通过 Angular 的依赖注入在应用启动时设置的(参见第二章,功能模块)。

如果用户已认证,我们将展示一个确认对话框以确保操作是故意的。

如果用户未认证,我们希望提示用户登录。为了减少本书的负担,我们假设用户已经通过后端 API 注册,因此我们不会要求用户注册。

我们需要在AuthService中实现promptLogin方法以持久化用户的登录凭据,这样每次他们返回应用时,它将自动登录。记录方法现在提供了一个额外的可选参数usernameAttempt,这在用户在输入验证错误后重新初始化登录序列时重新填充登录提示的用户名字段时将非常有用。我们在这里不会进行彻底的用户输入验证,但我们可以至少进行轻量级的有效电子邮件检查。

在你自己的应用中,你可能需要进行更多的用户输入验证。

为了保持关注点的清晰分离,打开app/modules/core/services/auth.service.ts以实现promptLogin。以下是整个服务及其修改内容:

// angular
import { Injectable } from '@angular/core';

// lib
import { BehaviorSubject } from 'rxjs/BehaviorSubject';

// app
import { DatabaseService } from './database.service';
import { DialogService } from './dialog.service';
import { LogService } from './log.service';

@Injectable()
export class AuthService {

 // access our current user from anywhere
 public static CURRENT_USER: any;

 // subscribe to authenticated state changes
 public authenticated$: BehaviorSubject<boolean> = 
   new BehaviorSubject(false);

 constructor(
 private databaseService: DatabaseService,
 private dialogService: DialogService,
 private logService: LogService
 ) {
   this._init();
 } 

 public promptLogin(msg: string, username: string = '')
   : Promise<any> {
   return new Promise((resolve, reject) => {
     this.dialogService.login(msg, username, '')
       .then((input) => {
         if (input.result) { // result = false when canceled
           if (input.userName && 
               input.userName.indexOf('@') > -1) {
               if (input.password) {
                 // persist user credentials
                 this._saveUser(
                   input.userName, input.password
                 );
                 resolve();
               } else {
                 this.dialogService.alert(
                   'You must provide a password.'
                 ).then(reject.bind(this, input.userName));
               }
           } else {
             // reject, passing userName back
             this.dialogService.alert(
               'You must provide a valid email address.'
             ).then(reject.bind(this, input.userName));
           }
         }
       });
     });
 }

 private _saveUser(username: string, password: string) {
   AuthService.CURRENT_USER = { username, password };
   this.databaseService.setItem(
     DatabaseService.KEYS.currentUser,
     AuthService.CURRENT_USER
   );
   this._notifyState(true);
 }

  private _init() {
    AuthService.CURRENT_USER =
      this.databaseService
      .getItem(DatabaseService.KEYS.currentUser);
    this.logService.debug(
      `Current user: `, AuthService.CURRENT_USER
    );
    this._notifyState(!!AuthService.CURRENT_USER);
  }

  private _notifyState(auth: boolean) {
    this.authenticated$.next(auth);
  }
}

我们使用dialogService.login方法打开一个原生登录对话框,允许用户输入用户名和密码。一旦他们选择确定,我们将对输入进行最小验证,如果成功,则通过DatabaseService持久化用户名和密码。否则,我们简单地提醒用户错误,并拒绝我们的承诺,传递他们输入的用户名。这允许我们通过重新显示带有失败用户名的登录对话框来帮助用户,使他们更容易进行更正。

在完成这些服务级别的细节后,track-list组件看起来相当不错。然而,在我们处理这个组件的时候,我们应该采取一个额外的步骤。如果你还记得,我们的 TrackModel 包含一个顺序属性,这将帮助用户以任何他们希望的方式方便地对轨道进行排序。

创建 Angular 管道 - OrderBy

Angular 提供了 Pipe 装饰器,以便轻松创建视图过滤器。让我们首先展示我们如何在视图中使用它。你可以看到它看起来非常类似于 Unix shell 脚本中使用的命令行管道;因此,它被命名为:Pipe

<ListView [items]="playerService.tracks | orderBy: 'order'">

这将获取playerService.tracks集合,并确保它通过每个TrackModelorder属性进行排序,以便在视图显示中使用。

由于我们可能希望在我们的应用视图中任何地方使用这个管道,让我们将其作为CoreModule的一部分添加。创建app/modules/core/pipes/order-by.pipe.ts,以下是我们将如何实现OrderByPipe

import { Pipe } from '@angular/core';

@Pipe({
 name: 'orderBy'
})
export class OrderByPipe {

 // Comparator method
 static comparator(a: any, b: any): number {
   if (a === null || typeof a === 'undefined') a = 0;
   if (b === null || typeof b === 'undefined') b = 0;

   if ((isNaN(parseFloat(a)) || !isFinite(a)) || 
       (isNaN(parseFloat(b)) || !isFinite(b))) {
      // lowercase strings
      if (a.toLowerCase() < b.toLowerCase()) return -1;
      if (a.toLowerCase() > b.toLowerCase()) return 1;
   } else {
     // ensure number values
     if (parseFloat(a) < parseFloat(b)) return -1;
     if (parseFloat(a) > parseFloat(b)) return 1;
   }

   return 0; // values are equal
 }

 // Actual value transformation
 transform(value: Array<any>, property: string): any {
   return value.sort(function (a: any, b: any) {
     let aValue = a[property];
     let bValue = b[property];
     let comparison = OrderByPipe
                      .comparator(aValue, bValue);
     return comparison;
   });
 } 
}

我们不会过多地详细介绍这里发生的事情,因为这在大 JavaScript 中排序集合是很典型的。为了完成这个任务,确保app/modules/core/pipes/index.ts遵循我们的标准约定:

import { OrderByPipe } from './order-by.pipe';

export const PIPES: any[] = [
 OrderByPipe
];

最后,导入前面的集合以与app/modules/core/core.module.ts一起使用。以下是包含所有修改的完整文件:

// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module'; 

// angular
import { NgModule } from '@angular/core';

// app
import { PIPES } from './pipes';
import { PROVIDERS } from './services';

@NgModule({
 imports: [
   NativeScriptModule
 ],
 declarations: [
   ...PIPES
 ],
 providers: [
   ...PROVIDERS
 ],
 exports: [
   NativeScriptModule,
   ...PIPES
 ]
})
export class CoreModule { }

由于管道是视图级别的实现,我们确保它们作为exports集合的一部分添加,以便其他模块可以使用它们。

现在,如果我们在这个时候运行我们的应用,你会注意到我们用于track-list.component.html视图模板的OrderBy管道将不会工作!

Angular 模块在彼此隔离的情况下编译。

这是一个需要理解的关键点:Angular 将PlayerModule编译为声明TrackListComponent的自定义模块。由于我们已将OrderByPipe作为CoreModule的一部分进行声明,而PlayerModule目前没有对CoreModule的依赖,因此TrackListComponent在编译时对OrderByPipe没有任何认识!你最终会在控制台看到以下错误生成:

CONSOLE ERROR file:///app/tns_modules/tns-core-modules/trace/trace.js:160:30: ns-renderer: ERROR BOOTSTRAPPING ANGULAR
CONSOLE ERROR file:///app/tns_modules/tns-core-modules/trace/trace.js:160:30: ns-renderer: Template parse errors:
 The pipe 'orderBy' could not be found ("
 </ListView>-->

 <ListView [ERROR ->][items]="playerService.tracks | orderBy: 'order'">
   <ng-template let-track="item">
     <GridLayout rows"): TrackListComponent@10:10

为了解决这个问题,我们想要确保PlayerModule了解来自CoreModule的视图相关声明(如管道或其他组件),通过确保CoreModule被添加到PlayerModuleimports集合中。这也为我们提供了一项额外的便利。如果你注意到,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';
 }

}

目前,我们将currentTimeduration直接放置在组件上,然而,我们将在稍后将其重构到PlayerService中。最终,当我们实现后续章节中的插件来处理我们的音频时,与播放器相关的所有状态都将来自PlayerServicetogglePlay方法也只是一个通用行为的占位符,切换按钮的文本为播放或停止。

快速预览

在这一点上,我们将快速查看我们迄今为止所构建的内容。目前,我们的播放器服务返回一个空的曲目列表。为了看到结果,我们应该向其中添加一些占位符数据。例如,在PlayerService中,我们可以添加:

constructor() {
  this.tracks = [
    {name: "Guitar"},
    {name: "Vocals"},
  ];
}

如果它看起来不漂亮,请不要感到惊讶;我们将在下一章中介绍这一点。我们也不会介绍所有可用的运行时命令;我们将在第六章“在 iOS 和 Android 上运行应用”中详细介绍。

iOS 上的预览

你必须在一台安装了 XCode 的 Mac 上预览 iOS 应用:

tns run ios --emulator

这应该启动 iOS 模拟器,你应该看到以下截图:

安卓预览

你需要在安卓 SDK 和工具安装完毕后,才能在安卓模拟器上预览:

tns run android --emulator

这应该会启动一个安卓模拟器,你应该会看到以下截图:

图片

恭喜!我们有了第一个视图。嘿,没人说过它现在就会很漂亮!

摘要

我们已经开始构建第二部分,其中我们布置了根组件app.component.html以容纳我们的主要视图,在那里你学习了GridLayout,一个非常有用的布局容器。

Angular 的组件装饰器使我们能够轻松构建TrackListComponentPlayerControlsComponent。我们还学会了如何构建一个 Angular Pipe来帮助我们的视图保持跟踪列表的顺序。Angular 的NgModule教导我们,我们需要确保任何组件所需的任何与视图相关的声明都正确导入。这种 Angular 设计模式有助于保持模块隔离,作为可以相互导入模块的独立代码单元。

我们还增强了一部分服务,以支持我们希望与组件一起使用的某些可用性。

最后,我们终于能够快速瞥一眼我们所构建的内容。尽管目前看起来并不美观,但我们已经能看到事物正在逐渐融合。

在第四章《使用 CSS 美化视图》中,你将学习如何使用 CSS 来从我们的视图中提取美观。

第四章:使用 CSS 更美观的视图

NativeScript 带来的众多关键优势之一是能够使用标准 CSS 来样式化原生视图组件。你会发现对许多常见和高级属性都有很好的支持;然而,有些属性没有直接的关联,而有些则是完全独特的,仅适用于原生视图布局。

让我们看看如何使用几个 CSS 类将我们的第一个视图转变为非常棒的东西。你还将学习如何利用 NativeScript 的核心主题提供一致的样式框架来构建。

在本章中,我们将涵盖以下主题:

  • 使用 CSS 来样式化视图

  • 理解典型网页样式和原生样式之间的一些差异

  • 通过平台特定文件解锁 NativeScript 的功能

  • 学习如何使用 nativescript-theme-core 样式框架插件

  • 调整 iOS 和 Android 上的状态栏背景色和文字颜色

是时候变得优雅了

让我们从查看App目录中的app.css文件开始,这是我们的应用程序的主要文件:

/*
In NativeScript, the app.css file is where you place CSS rules that
you would like to apply to your 

entire application. Check out
http://docs.nativescript.org/ui/styling for a full list of the CSS
selectors and 

properties you can use to style UI components.

/*
For example, the following CSS rule changes the font size 

of all UI
components that have the btn class name.
*/
.btn {
  font-size: 18;
}

/*
In many cases you may want to use the NativeScript core theme instead
of writing your own CSS rules. For a full list 

of class names in the theme
refer to http://docs.nativescript.org/ui/theme.
*/
@import 'nativescript-

theme-core/css/core.light.css';

默认情况下,--ng模板暗示了你可以选择两种选项来构建你的 CSS:

  • 编写你自己的自定义类

  • 使用 nativescript-theme-core 样式框架插件作为基础

让我们暂时探索第一个选项。在.btn类之后添加以下内容:

.btn {
  font-size: 18;
}

.row {
 padding: 15 5;
 background-color: yellow;
}

.row .title {
 font-size: 25;
 color: #444;
 font-weight: bold;
}

Button {
 background-color: red;
 color: white;
}

从这个简单的例子中,你可以立即发现许多有趣的事情:

  • 填充不使用你熟悉的网页样式的px后缀。

    • 别担心,使用px后缀不会伤害你。

    • 从 NativeScript 3.0 版本开始,支持发布单位,因此你可以使用 dp(设备无关像素)或px(设备像素)。

      如果没有指定单位,将使用 dp。对于宽度/高度和边距,你还可以在 CSS 中使用百分比作为单位类型。

  • 支持各种常见属性(如paddingfont sizefont weightcolorbackground color等)。此外,缩写边距/填充也适用,即padding: 15 5

  • 你可以使用标准的十六进制颜色名称,如黄色,或缩写代码,如#444。

  • CSS 作用域的工作方式正如你所期望的那样,即.row .title { ...}

  • 元素/标签/组件名称可以全局样式化。

尽管你可以通过标签/组件名称进行样式化,但这样做并不建议。我们将向您展示一些您需要了解的针对原生设备的有趣考虑因素。

现在,让我们打开app/modules/player/components/track-list/track-list.component.html,并将rowtitle类添加到我们的模板中:

<ListView [items]="playerService.tracks | orderBy: 'order'">
  <template let-track="item">

<GridLayout rows="auto" columns="100,*,100" class="row">
      <Button text="Record" (tap)

="record(track)" row="0" col="0"></Button>
      <Label [text]="track.name" row="0" col="1" 

class="title"></Label>
      <Switch row="0" col="2"></Switch>

</GridLayout>
  </template>
</ListView>

让我们快速预览一下使用tns run ios --emulator会发生什么,你应该会看到以下内容:

图片

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

图片

我们可以看到,在两个平台上,这些样式都得到了一致的应用,同时仍然保持了每个平台独特的特性。例如,iOS 在按钮上保持了扁平化设计美学,开关提供了熟悉的 iOS 感觉。相比之下,在 Android 上,按钮保留了微妙的默认阴影和全大写文本,以及熟悉的 Android 开关。

然而,有一些微妙(可能是不理想的)差异,这些差异需要理解和解决。从这个例子中,我们可能注意到以下几点:

  1. Android 的按钮左右边距比 iOS 宽。

  2. 行标题的对齐不一致。在 iOS 上,标签默认垂直居中;然而,在 Android 上它对齐到顶部。

  3. 如果你点击记录按钮来查看登录对话框,你也会注意到一些相当不理想的地方:

项目#3 可能是最令人惊讶和意外的。它体现了一个主要的原因,即不建议全局样式化 Element/Tag/Component 名称。由于原生对话框默认使用Buttons,我们添加的一些全局Button样式正在渗入对话框(特别是color: white)。为了解决这个问题,我们可以确保我们正确地限制了所有组件名称的作用域:

.row Button {
 background-color: red;
 color: white;
} 

或者更好的是,只需在你的按钮上使用类名:

.row .btn {
 background-color: red;
 color: white;
} <Button text="Record" (tap)="record(track)" row="0" col="0" 

class="btn"></Button>

要修复项目#2(行标题对齐),我们可以引入 NativeScript 的一个特殊功能:能够根据你运行的平台构建特定平台的文件。让我们创建一个新文件,app/common.css,并将app/app.css中的所有内容重构到这个新文件中。然后,让我们创建另外两个新文件,app/app.ios.cssapp/app.android.css(然后删除app.css,因为它将不再需要),它们的内容如下:

@import './common.css';

这将确保我们的共享样式被导入 iOS 和 Android CSS 中。现在,我们有了应用特定平台样式修复的方法!

让我们通过修改app/app.android.css来解决这个问题垂直对齐问题:

@import './common.css';

.row .title {
  vertical-align: center;
}

这只为 Android 添加了额外的样式调整,现在我们有了这样的效果:

太好了,好多了。

要修复#1,如果我们想让两个平台上的按钮具有相同的边距,我们需要应用更多针对特定平台的调整。

到目前为止,你可能想知道你需要自己调整多少来处理一些这些特定平台的问题。你应该很高兴地知道这不是一个详尽的列表,但充满活力的 NativeScript 社区共同努力创造了一些更好的东西,一个类似于 bootstrap 的核心主题,它提供了许多这些微妙的调整,例如标签的垂直对齐以及许多其他微妙的调整。

欢迎使用 NativeScript 核心主题

所有新的 NativeScript 项目都自带核心主题安装,并且可以直接使用。如前所述,你提供了两种你可以用来设计应用程序的选项。前面的部分概述了在从头开始设计应用程序时可能会遇到的一些事情。

让我们来看看选项#2:使用nativescript-theme-core插件。这个主题是现成的,旨在扩展和构建在它之上。它提供了一系列的实用类,用于间距、着色、布局、着色皮肤等等。由于其坚实的基础和惊人的灵活性,我们将在这个主题之上构建我们的应用程序样式。

值得注意的是,nativescript-theme-前缀是有意为之,作为标准,它有助于在npm上提供一个共同的搜索前缀,以找到所有 NativeScript 主题。如果你设计和发布自己的自定义 NativeScript 主题,建议使用相同的前缀。

让我们移除我们的自定义样式,只保留导入的核心主题。然而,我们不会使用默认的浅色皮肤,而是会使用深色皮肤。这就是我们的app/common.css文件现在应该看起来像这样:

@import 'nativescript-theme-core/css/core.dark.css';

现在,我们想要开始用核心主题提供的某些类来分类我们的组件。你可以在这里学习类列表的完整列表:docs.nativescript.org/ui/theme

app/app.component.html开始,让我们添加以下类:

<ActionBar title="TNSStudio" class="action-bar">
</ActionBar>
<GridLayout 

rows="*, 100" columns="*" class="page">
  <track-list row="0" col="0"></track-list>
  <player-controls row="1" col="0"></player-controls>
</GridLayout>

action-bar类确保我们的皮肤适当地应用到应用程序的标题上,同时为 iOS 和 Android 上的ActionBar提供细微的一致性调整。

page类确保我们的皮肤应用到整个页面上。在任何一个组件视图中,这个类都应应用到根布局容器上。

经过这两个调整,我们现在应该在 iOS 上看到以下内容:

图片

这是 Android 上的样子:

图片

你会注意到在ListView上 iOS 和 Android 之间还有一个差异。iOS 默认有一个白色背景,而 Android 看起来有一个透明的背景,允许皮肤页面的颜色显示出来。让我们继续用核心主题的更多类来分类我们的组件,这些类有助于解决这些细微差别。打开app/modules/player/components/track-list/track-list.component.html并添加以下类:

<ListView [items]="playerService.tracks | orderBy: 'order'" class="list-group">
  <ng-

template let-track="item">
    <GridLayout rows="auto" columns="100,*,100" class="list-group-

item">
      <Button text="Record" (tap)="record(track)" row="0" col="0" class="c-

ruby"></Button>
      <Label [text]="track.name" row="0" col="1" 

class="h2"></Label>
      <Switch row="0" col="2" 

class="switch"></Switch>
    </GridLayout>
  </ng-template>
</ListView>

父级list-group类有助于将所有内容正确地缩小到list-group-item。然后,我们添加c-ruby来在我们的记录按钮上洒一些红色。有几个着色颜色提供了姓氏:c-skyc-aquac-charcoalc-purple等等。在这里查看所有这些:docs.nativescript.org/ui/theme#color-schemes

然后,我们将h2添加到标签中,以稍微增加其字体大小。最后,switch类有助于标准化音轨独奏开关。

现在我们已经在 iOS 上有了这个:

图片

这是 Android 上的样子:

图片

让我们继续前进到最后一个组件(目前是这样),player-controls。打开app/modules/player/components/player-controls/player-controls.component.html并添加以下内容:

<GridLayout rows="100" columns="100,*" row="1" col="0" class="p-x-10">
  <Button 

[text]="playStatus" (tap)="togglePlay()" row="0" col="0" class="btn btn-primary w-

100"></Button>
  <Slider minValue="0" [maxValue]="duration" [value]="currentTime" row="0" col="1" 

class="slider"></Slider>
</GridLayout>

首先,我们给左/右容器(GridLayout)添加p-x-10类以添加10的内边距。然后,我们将btn btn-primary w-100添加到播放/暂停按钮上。w-100类将按钮的宽度设置为固定值100。然后,我们将slider类添加到滑块上。

现在,iOS 上的事情开始有形了:

它在 Android 上的外观如下:

哇,好吧,现在,一切开始成形。我们将继续在前进的过程中进一步完善细节,但这个练习已经展示了你如何快速通过使用大量内置类来调整核心主题的风格。

调整 iOS 和 Android 的状态栏背景色和文本色

你可能之前已经注意到,在 iOS 上,状态栏文本是黑色,与我们的深色皮肤不太搭配。此外,我们可能还想改变 Android 的状态栏色调颜色。NativeScript 提供了对原生 API 的直接访问,因此我们可以轻松地将这些更改为我们想要的任何颜色。这两个平台处理方式不同,因此我们可以有条件地更改每个平台的状态栏。

打开app/app.component.ts并添加以下内容:

// angular
import { Component } from '@angular/core';

// nativescript
import { isIOS } from 'platform';
import { topmost } from 'ui/frame';
import * as app from 'application';

// app
import { AuthService } from 

'./modules/core/services';

declare var android;

@Component({
  moduleId: 

module.id,
  selector: 'my-app',
  templateUrl: 'app.component.html',
})
export class AppComponent {

  constructor(
    private authService: AuthService
  ) { 
    if (isIOS) {
 /**
 * 0 = black text
 * 1 = white text
 */
 topmost().ios.controller.navigationBar.barStyle = 1;
 } else {
 // adjust text to darker color
 let decorView = 

app.android.startActivity.getWindow()
 .getDecorView();
 decorView.setSystemUiVisibility(android.view.View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
 }
  }
}

这将使 iOS 状态栏文本变为白色:

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

让我们趁热打铁,也调整一下ActionBar的背景色,以增添一些美感。在 iOS 上,状态栏背景色采用ActionBar的背景色,而在 Android 上,状态栏的背景色必须通过App_Resources中的 Android colors.xml进行调整。从 iOS 开始,让我们打开app/common.css并添加以下内容:

.action-bar {
  background-color:#101B2E;
}

这为 iOS 的ActionBar设置了以下颜色:

对于 Android,我们希望状态栏背景色与ActionBar背景色形成互补色。要做到这一点,我们需要打开app/App_Resources/Android/values/colors.xml并做出以下调整:

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <color 

name="ns_primary">#F5F5F5</color>
  <color 

name="ns_primaryDark">#284472</color>
  <color name="ns_accent">#33B5E5</color>

<color name="ns_blue">#272734</color>
</resources>

这是 Android 上的最终结果:

摘要

最后,给我们的应用添加一个面孔让人耳目一新且有趣;然而,我们当然还没有完成样式设计。我们将继续通过 CSS 润色视图,并在接下来的章节中引入 SASS 以进一步细化。然而,这一章已经向你介绍了你在通过 CSS 设计应用时需要了解的各种考虑因素。

你已经了解到常见的 CSS 属性都得到了支持,我们还探讨了 iOS 和 Android 在处理某些默认特性方面的差异。能够针对特定平台使用 CSS 覆盖是一个很好的好处和特殊能力,你将在你的跨平台 NativeScript 应用中想要充分利用这一点。了解如何控制两个平台上的状态栏外观对于实现你应用所需的外观和感觉至关重要。

在下一章中,我们将从样式设计暂时休息,通过懒加载深入探讨路由和导航,为完善我们应用的整体可用性流程做好准备。准备好深入探索我们应用中一些更有趣的 Angular 特性。

第五章:路由和懒加载

路由对于任何应用程序的稳定可用性流程至关重要。让我们了解一个利用 Angular 路由器所有灵活性的移动应用程序的路由配置的关键元素。

在本章中,我们将涵盖以下主题:

  • 在 NativeScript 应用程序中配置 Angular Router

  • 通过路由懒加载模块

  • 为 Angular 的NgModuleFactoryLoader提供NSModuleFactoryLoader

  • 理解如何结合使用router-outletpage-router-outlet

  • 了解如何在多个懒加载的模块之间共享单例服务

  • 使用身份验证守卫来保护需要有效身份验证的视图

  • 了解如何自定义返回移动导航的NavigationButton

  • 通过引入后期功能需求来利用我们灵活的路由设置

在 Route 66 上享受乐趣

当我们开始在这条充满冒险的道路上旅行时,让我们先在我们的当地服务店停下来,确保我们的车辆处于最佳状态。转到app的根目录,为我们的车辆引擎构建一个新的附加组件:路由模块。

创建一个新的路由模块,app/app.routing.ts,包含以下内容:

import { NgModule } from '@angular/core';
import { NativeScriptRouterModule } 
  from 'nativescript-angular/router';
import { Routes } from '@angular/router';

const routes: Routes = [
  {
    path: '',
    redirectTo: '/mixer/home',
    pathMatch: 'full'
  },
  {
    path: 'mixer',
    loadChildren: () => require('./modules/mixer/mixer.module')['MixerModule']
  },
  {
    path: 'record',
    loadChildren: () => require('./modules/recorder/recorder.module')['RecorderModule']
  }
];

@NgModule({
  imports: [
    NativeScriptRouterModule.forRoot(routes)
  ],
  exports: [
    NativeScriptRouterModule
  ]
})
export class AppRoutingModule { }

定义根''路径以重定向到懒加载的模块提供了非常灵活的路由配置,正如您在本章中将会看到的。您将看到一个新模块,MixerModule,我们将立即创建它。实际上,它最终将变成现在的AppComponent。以下是一些使用类似此配置的路由配置所获得的优势列表:

  • 通过仅预先加载最基本的最小根模块配置,然后快速懒加载第一个路由的模块,保持应用程序启动时间快速

  • 提供了使用page-router-outletrouter-outlet结合的能力,以实现主/详细导航以及clearHistory页面导航的组合

  • 将路由配置责任隔离到相关的模块中,这样随着时间的推移可以很好地扩展

  • 如果我们决定更改用户最初看到的初始页面,我们可以轻松地针对不同的起始页面进行目标定位

这使用NativeScriptRoutingModule.forRoot(routes),因为这将被认为是我们的应用程序路由配置的根。

我们还导出NativeScriptRoutingModule,因为我们将在稍后导入这个AppRoutingModule到我们的根AppModule中。这使得路由指令对根模块的根组件可用。

NgModuleFactoryLoader提供 NSModuleFactoryLoader

默认情况下,Angular 的内置模块加载器使用 SystemJS;然而,NativeScript 提供了一个增强的模块加载器,称为NSModuleFactoryLoader。让我们在我们的主路由模块中提供这个加载器,以确保所有模块都使用它而不是 Angular 的默认模块加载器。

app/app.routing.ts进行以下修改:

import { NgModule, NgModuleFactoryLoader } from '@angular/core';
import { NativeScriptRouterModule, NSModuleFactoryLoader } from 'nativescript-angular/router';

const routes: Routes = [
  {
    path: '',
    redirectTo: '/mixer/home',
    pathMatch: 'full'
  },
  {
    path: 'mixer',
    loadChildren: './modules/mixer/mixer.module#MixerModule'
  },
  {
    path: 'record',
    loadChildren: './modules/recorder/recorder.module#RecorderModule',
    canLoad: [AuthGuard]
  }
];

@NgModule({
  imports: [
    NativeScriptRouterModule.forRoot(routes)
  ],
  providers: [
    AuthGuard,
    {
 provide: NgModuleFactoryLoader,
 useClass: NSModuleFactoryLoader
 }
  ],
  exports: [
    NativeScriptRouterModule
  ]
})
export class AppRoutingModule { }

现在,我们可以通过 loadChildren 使用标准的 Angular 懒加载语法,通过指定默认的 NgModuleFactoryLoader,但应该使用 NativeScript 的增强型 NSModuleFactoryLoader。我们不会详细介绍 NSModuleFactoryLoader 提供的内容,因为它在这里解释得很好:www.nativescript.org/blog/optimizing-app-loading-time-with-angular-2-lazy-loading,而且我们在这本书中还有更多内容要介绍。

极好。有了这些升级,我们可以离开服务店,继续沿着高速公路前行。让我们继续实施我们的新路由设置。

打开 app/app.component.html;将其内容剪切到剪贴板,并用以下内容替换:

<page-router-outlet></page-router-outlet>

这将是视图级别实现的基础。page-router-outlet 允许任何组件插入其位置,无论是单个扁平路由还是具有自己子视图的路由。它还允许其他组件视图推送到移动导航堆栈,从而实现带有后退历史记录的主/详细移动导航。

为了使这个 page-router-outlet 指令正常工作,我们需要我们的根 AppModule 导入新的 AppRoutingModule。我们还将利用这个机会移除之前在这里导入的 PlayerModule。打开 app/app.module.ts 并进行以下修改:

// angular
import { NgModule } from '@angular/core';

// app
import { CoreModule } from './modules/core/core.module';
import { AppRoutingModule } from './app.routing';
import { AppComponent } from './app.component';

@NgModule({
 imports: [
   CoreModule,
   AppRoutingModule
 ],
 declarations: [AppComponent],
 bootstrap: [AppComponent]
})
export class AppModule { }

创建 MixerModule

这个模块实际上不会有什么新内容,因为它将作为之前根组件视图的重新定位。然而,它将引入一个额外的优点:能够定义自己的内部路由。

创建 app/modules/mixer/components/mixer.component.html,并将从 app.component.html 中剪切的内容粘贴进去:

<ActionBar title="TNSStudio" class="action-bar"></ActionBar><GridLayout rows="*, 100" columns="*" class="page">  
  <track-list row="0" col="0"></track-list>  
  <player-controls row="1" col="0"></player-controls></GridLayout>

然后创建一个匹配的 app/modules/mixer/components/mixer.component.ts:

import { Component } from '@angular/core';

@Component({ 
  moduleId: module.id, 
  selector: 'mixer', 
  templateUrl: 'mixer.component.html'
})
export class MixerComponent {}

现在,我们将创建 BaseComponent,它将作为前面 MixerComponent 的占位符,以及我们可能希望在它的位置展示的任何其他子视图组件。例如,我们的混音器可能希望允许用户将单个轨道从混音器弹出,进入一个隔离的视图来处理音频效果。

创建 app/modules/mixer/components/base.component.ts,内容如下:

// angular
import { Component } from '@angular/core';

@Component({
 moduleId: module.id,
 selector: 'mixer-base',
 template: `<router-outlet></router-outlet>`
})
export class BaseComponent { }

这提供了一个插槽,可以插入我们的混音器配置的任何子路由,其中之一就是 MixerComponent 本身。由于视图只是一个简单的 router-outlet,实际上没有必要创建一个单独的 templateUrl,所以我们在这里直接内联了它。

现在,我们准备实现 MixerModule;创建 app/modules/mixer/mixer.module.ts,内容如下:

import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
import { NativeScriptRouterModule } from 
  'nativescript-angular/router';
import { Routes } from '@angular/router';

import { PlayerModule } from '../player/player.module';
import { BaseComponent } from './components/base.component';
import { MixerComponent } from 
  './components/mixer.component';

const COMPONENTS: any[] = [
  BaseComponent,
  MixerComponent
]

const routes: Routes = [
  {
    path: '',
    component: BaseComponent,
    children: [
      {
        path: 'home',
        component: MixerComponent
      }
    ]
  }
];

@NgModule({
  imports: [
    PlayerModule,
    NativeScriptRouterModule.forChild(routes)
  ],
  declarations: [
    ...COMPONENTS
  ],
  schemas: [
    NO_ERRORS_SCHEMA
  ]
})
export class MixerModule { }

我们已经导入了 PlayerModule,因为混合器使用了那里定义的组件/小部件(即 track-listplayer-controls)。我们还在使用 NativeScriptRouterModule.forChild(routes) 方法来指示这些是特定的子路由。我们的路由配置在根 ' ' 路径上设置了 BaseComponent,它将 'home' 定义为 MixerComponent。如果您还记得,我们的应用 AppRoutingModule 如下配置了应用的根路径:

...
{
  path: '',
  redirectTo: '/mixer/home',
  pathMatch: 'full'
},
...

这将直接路由到这里定义的 MixerComponent,即 'home'。如果我们想,我们可以轻松地将启动页面指向不同的视图,只需将 redirectTo 指向我们的混合器中不同的子视图。由于 BaseComponent 仅仅是 router-outlet,任何定义在混合器路由根 ' ' 下的子组件(在我们的整体应用路由中看起来是 '/mixer')将直接插入该视图槽。如果您现在运行它,您应该看到我们之前相同的启动页面。

恭喜!您应用的启动时间现在很快,您已经懒加载了第一个模块!

然而,有几个令人惊讶的事情需要注意:

  • 您可能会在启动页面出现之前注意到一个快速的白色闪烁(至少在 iOS 上是这样)

  • 您可能会注意到控制台日志打印了两次 `当前用户:`

我们将分别解决这些问题。

  1. 在启动页面显示之前,移除启动屏幕后的白色闪烁。

这是正常的,这是默认页面背景颜色为白色造成的。为了提供无缝的启动体验,打开 app/common.css 文件,将全局 Page 类定义改为与我们的 ActionBar 背景颜色相同:

Page {
  background-color:#101B2E;
}

现在,将不再有白色闪烁,应用的启动将看起来无缝。

  1. 控制台日志打印了两次 `当前用户:`

由于懒加载,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 的官方文档(《angular.io/guide/ngmod…

对于非懒加载的模块,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以及它提供的几个服务(即AuthServiceLogServiceDialogService):

...
@NgModule({
  imports: [
    CoreModule
  ],
  providers: [...PROVIDERS],
  declarations: [...COMPONENTS],
  exports: [...COMPONENTS],
  schemas: [ NO_ERRORS_SCHEMA ]
})
export class PlayerModule { }

由于我们新奇的路线配置,PlayerModule现在与MixerModule一起懒加载。这导致 Angular 的依赖注入器为我们的懒加载MixerModule注册了一个新的子注入器,它带来了PlayerModule,它也带来了其导入的CoreModule,该模块定义了那些提供者,包括AuthServiceLogService等。当 Angular 注册MixerModule时,它将注册新模块中定义的所有提供者,包括其导入的模块,以及新的子注入器,从而产生了那些服务的新实例。

Angular 的文档还提供了一种推荐的模块设置来解决这个问题,因此让我们再次从https://angular.io/guide/ngmodule-faq#!#q-module-recommendations中转述:

SharedModule

创建一个SharedModule,其中包含你在应用程序的每个地方都使用的组件、指令和管道。此模块应完全由声明组成,其中大多数都导出。SharedModule可以重新导出其他小部件模块,例如CommonModuleFormsModule以及包含你广泛使用的 UI 控制的模块。《SharedModule》不应有提供者,如前所述。也不应该有任何导入或重新导出的模块有提供者。如果你偏离了这个指南,要知道你在做什么以及为什么。在你的功能模块中导入SharedModule,包括在应用程序启动时加载的模块以及稍后懒加载的模块。

创建一个CoreModule,其中包含在应用程序启动时加载的单例服务的提供者。

仅在根AppModule中导入CoreModule。永远不要在其他任何模块中导入CoreModule

考虑将CoreModule制作成一个没有声明的纯服务模块。

哇!这是一个极好的建议。特别值得注意的是最后一行:

考虑将CoreModule制作成一个没有声明的纯服务模块。

因此,我们已经有了一个CoreModule,这是一个好消息,但我们将希望将其制作成一个没有声明的纯服务模块。我们还将仅在根AppModule中导入CoreModule。永远不要在其他任何模块中导入CoreModule然后,我们可以创建一个新的SharedModule,只为...**我们在应用程序的每个地方都使用的组件、指令和管道提供。

让我们创建app/modules/shared/shared.module.ts,如下所示:

// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module'; 

// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';

// app
import { PIPES } from './pipes';

@NgModule({
  imports: [
    NativeScriptModule
  ],
  declarations: [
    ...PIPES
  ],
  exports: [
    NativeScriptModule,
    ...PIPES
  ],
  schemas: [ NO_ERRORS_SCHEMA ]
})
export class SharedModule {}

对于PIPES,我们只是将管道目录从app/modules/core移动到app/modules/shared文件夹。现在,SharedModule是我们可以在需要任何管道或未来共享组件/指令的多个不同模块中自由导入的模块。它将不会定义任何服务提供者,正如这个建议所提到的:

SharedModule不应有提供者,如前所述,也不应有任何导入或重新导出的模块有提供者。

然后,我们可以按照以下方式调整CoreModule(位于app/modules/core/core.module.ts)以成为一个没有声明的纯服务模块:

// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module'; 
import { NativeScriptFormsModule } from 'nativescript-angular/forms'; 
import {NativeScriptHttpModule } from 'nativescript-angular/http';
// angular
import { NgModule, Optional, SkipSelf } from '@angular/core';

// app
import { PROVIDERS } from './services';

const MODULES: any[] = [
  NativeScriptModule,
  NativeScriptFormsModule,
  NativeScriptHttpModule
];

@NgModule({
  imports: [
    ...MODULES
  ],
  providers: [
    ...PROVIDERS
  ],
  exports: [
    ...MODULES
  ]
})
export class CoreModule {
  constructor (
    @Optional() @SkipSelf() parentModule: CoreModule) {
    if (parentModule) {
      throw new Error(
        'CoreModule is already loaded. Import it in the AppModule only');
    }
  }
}

此模块现在仅定义提供者,即包含AuthServiceDatabaseServiceDialogServiceLogService的集合,我们都在本书中较早创建过,我们想确保它们是跨我们的应用程序使用的真正的单例,无论它们是否在懒加载的模块中使用。

为什么我们使用...PROVIDERS展开符号而不是直接分配集合?

由于可扩展性的原因。将来,如果我们需要添加一个额外的提供者或覆盖一个提供者,我们只需在模块中直接添加到集合中即可。对于导入和导出也是如此。

我们还利用这个机会导入了一些我们想要确保在整个应用程序中全局使用的附加模块。NativeScriptModuleNativeScriptFormsModuleNativeScriptHttpModule 都是基本模块,它们会覆盖 Angular 各个提供者中的某些 Web API,以增强我们的应用程序的本地 API。例如,应用程序将使用在 iOS 和 Android 上可用的本地 HTTP API(而不是 Web API XMLHttpRequest),以实现最佳的网络性能。我们确保也导出这些模块,这样我们的根模块就不再需要导入它们,而是可以直接导入这个 CoreModule

最后,我们定义了一个构造函数,这将帮助我们未来避免意外地将此 CoreModule 导入其他懒加载模块。

我们还不知道 PlayerModule 提供的 PlayerService 是否会被 RecorderModule 需要,后者也将是懒加载的。如果将来出现这种情况,我们还可以将 PlayerService 重构到 CoreModule 中,以确保它是我们在整个应用程序中共享的真正单例。目前,我们将其保留在 PlayerModule 中作为一部分。

现在,让我们根据我们所做的一切调整其他模块的最终设置。

app/modules/player/player.module.ts 文件现在应该看起来像这样:

// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';

// app
import { SharedModule } from '../shared/shared.module';
import { COMPONENTS } from './components';
import { PROVIDERS } from './services';

@NgModule({
  imports: [ SharedModule ],
  providers: [ ...PROVIDERS ],
  declarations: [ ...COMPONENTS ],
  exports: [
    SharedModule,
    ...COMPONENTS
  ],
  schemas: [ NO_ERRORS_SCHEMA ]
})
export class PlayerModule { }

app/modules/recorder/recorder.module.ts 文件现在应该看起来像这样:

// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';

// app
import { SharedModule } from '../shared/shared.module';
import { PROVIDERS } from './services';

@NgModule({
 imports: [ SharedModule ],
 providers: [ ...PROVIDERS ],
 schemas: [ NO_ERRORS_SCHEMA ]
})
export class RecorderModule { }

注意我们现在导入的是 SharedModule 而不是 CoreModule。这使我们能够通过导入那个 SharedModule 在整个应用程序中共享指令、组件和管道(本质上就是模块声明部分中的任何内容)。

我们在 app/app.module.ts 中的根 AppModule 保持不变:

// angular
import { NgModule } from '@angular/core';

// app
import { CoreModule } from './modules/core/core.module';
import { AppRoutingModule } from './app.routing';
import { AppComponent } from './app.component';

@NgModule({
  imports: [
    CoreModule,
    AppRoutingModule
  ],
  declarations: [ AppComponent ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }

任何模块(无论是懒加载还是非懒加载)都可以注入 CoreModule 提供的任何服务,因为根 AppModule 现在导入了那个 CoreModule。这允许 Angular 的根注入器正好一次构建 CoreModule 提供的服务。然后,每当这些服务在任何地方注入(无论是懒加载模块还是其他地方),Angular 首先会询问父注入器(在懒加载模块的情况下,将是子注入器)该服务,如果在那里找不到,它将询问下一个父注入器,最终到达根注入器,在那里提供这些单例。

嗯,我们在这个沙漠小镇度过了一段美好的时光。让我们沿着高速公路驶向超安全的 51 区,在那里模块可以被锁定数年,除非出示适当的授权。

为 RecorderModule 创建 AuthGuard

我们应用程序的一个要求是,录制功能应该在用户认证之前被锁定并不可访问。这使我们能够拥有一个用户基础,并且如果将来我们希望的话,可以引入付费功能。

Angular 提供了在路由上插入守卫的能力,这只有在特定条件下才会激活。这正是我们实现这个功能需求所需要的,因为我们已经将 '/record' 路由隔离出来,以懒加载 RecorderModule,它将包含所有录音功能。我们只想允许认证用户访问那个 '/record' 路由。

让我们在新文件夹中创建 app/guards/auth-guard.service.ts,以便于扩展,因为我们可能需要在这里创建其他必要的守卫:

import { Injectable } from '@angular/core';
import { Route, CanActivate, CanLoad } from '@angular/router';
import { AuthService } from '../modules/core/services/auth.service';

@Injectable()
export class AuthGuard implements CanActivate, CanLoad {

  constructor(private authService: AuthService) { }

  canActivate(): Promise<boolean> {
    return new Promise((resolve, reject) => {
      if (this._isAuth()) {
        resolve(true);
      } else {
        // login sequence to continue prompting
        let promptSequence = (usernameAttempt?: string) => {
          this.authService.promptLogin(
            'Authenticate to record.',
            usernameAttempt
          ).then(() => {
            resolve(true); 
          }, (usernameAttempt) => {
            if (usernameAttempt === false) {
              // user canceled prompt
              resolve(false);
            } else {
              // initiate sequence again
              promptSequence(usernameAttempt);
            }
          });
        };
        // start login prompt sequence
        // require auth before activating
        promptSequence();
      }
    });
  }

  canLoad(route: Route): Promise<boolean> {
    // reuse same logic to activate
    return this.canActivate();
  }

  private _isAuth(): boolean {
    // just get the latest value from our BehaviorSubject
    return this.authService.authenticated$.getValue();
  }
}

我们可以利用 AuthServiceBehaviorSubject 来获取最新值,使用 this.authService.authenticated$.getValue() 来确定认证状态。我们使用这个值通过 canActivate 钩子(或通过 canLoad 钩子加载模块)立即激活路由,如果用户已认证。否则,我们通过服务的方法显示登录提示,但这次我们将其包裹在一个重新提示序列中,这样在失败尝试的情况下会继续提示,直到成功认证,或者如果用户取消提示则忽略。

对于这本书,我们不会连接到任何后端服务来进行任何真实的认证。我们将这部分留给你在自己的应用程序中完成。我们将在对输入进行非常简单的验证后,将你输入到登录提示中的电子邮件和密码持久化,作为有效用户。

注意,AuthGuard 是一个像其他服务一样的可注入服务,因此我们想要确保它被添加到 AppRoutingModule 的提供者元数据中。现在我们可以通过以下突出显示的修改来保护我们的路由 app/app.routing.ts 以使用它:

...
import { AuthGuard } from './guards/auth-guard.service';

const routes: Routes = [
  ...
  {
    path: 'record',
    loadChildren: 
      './modules/recorder/recorder.module#RecorderModule',
    canLoad: [AuthGuard]
  }
];

@NgModule({
  ...
  providers: [
    AuthGuard,
    ...
  ],
  ...
})
export class AppRoutingModule { }

要尝试这个功能,我们需要向我们的 RecorderModule 添加子路由,因为我们还没有这样做。打开 app/modules/recorder/recorder.module.ts 并添加以下突出显示的部分:

// nativescript
import { NativeScriptModule } from 'nativescript-angular/nativescript.module';
import { NativeScriptRouterModule } from 'nativescript-angular/router';

// angular
import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
import { Routes } from '@angular/router';

// app
import { SharedModule } from '../shared/shared.module';
import { PROVIDERS } from './services';
import { RecordComponent } from './components/record.component';

const COMPONENTS: any[] = [
 RecordComponent
]

const routes: Routes = [
 {
 path: '',
 component: RecordComponent
 }
];

@NgModule({
  imports: [
    SharedModule,
    NativeScriptRouterModule.forChild(routes)
  ],
  declarations: [ ...COMPONENTS ],
  providers: [ ...PROVIDERS ],
  schemas: [ NO_ERRORS_SCHEMA ]
})
export class RecorderModule { }

现在我们有了适当的子路由配置,当用户导航到 '/record' 路径时,将显示单个 RecordComponent。我们不会显示 RecordComponent 的详细信息,你可以参考书籍的 第五章,路由和懒加载 分支上的代码库。然而,目前它只是 app/modules/recorder/components/record.component.html 中的一个占位符组件,只显示一个简单的标签,因此我们可以尝试这个功能。

最后,我们需要一个按钮来路由到我们的 '/record' 路径。如果我们回顾我们的原始草图,我们想在 ActionBar 的右上角显示一个记录按钮,所以现在让我们实现它。

打开 app/modules/mixer/components/mixer.component.html 并添加以下内容:

<ActionBar title="TNSStudio" class="action-bar">
  <ActionItem nsRouterLink="/record" ios.position="right">
 <Button text="Record" class="action-item"></Button>
 </ActionItem>
</ActionBar>
<GridLayout rows="*, 100" columns="*" class="page">
  <track-list row="0" col="0"></track-list>
  <player-controls row="1" col="0"></player-controls>
</GridLayout>

现在,如果我们要在 iOS 模拟器中运行这个程序,我们会注意到 ActionBar 中的记录按钮没有任何反应!这是因为 MixerModule 只导入了以下内容:

@NgModule({
  imports: [
    PlayerModule,
    NativeScriptRouterModule.forChild(routes)
  ],
  ...
})
export class MixerModule { }

NativeScriptRouterModule.forChild(routes) 方法仅配置路由,但不会使各种路由指令,如 nsRouterLink,对我们的组件可用。

由于你之前已经了解到应该使用 SharedModule 来声明你想要在模块中(无论是懒加载还是非懒加载)共享的各种指令、组件和管道,这是一个利用它的完美机会。

打开 app/modules/shared/shared.module.ts 并进行以下突出显示的修改:

...
import { NativeScriptRouterModule } from 'nativescript-angular/router'; 
...

@NgModule({
  imports: [
    NativeScriptModule, 
    NativeScriptRouterModule
  ],
  declarations: [
    ...PIPES
  ],
  exports: [
    NativeScriptModule,
    NativeScriptRouterModule,
    ...PIPES
  ],
  schemas: [NO_ERRORS_SCHEMA]
})
export class SharedModule { }

现在,回到 MixerModule,我们可以调整导入以使用 SharedModule

...
import { SharedModule } from '../shared/shared.module'; 
@NgModule({
  imports: [
    PlayerModule,
    SharedModule,
    NativeScriptRouterModule.forChild(routes)
  ],
  ...
})
export class MixerModule { }

这确保了通过 NativeScriptRouterModule 暴露的所有指令现在都包含在内,并且可以通过我们的全局 SharedModuleMixerModule 中使用。

再次运行我们的应用,当我们点击 ActionBar 中的 Record 按钮时,现在会看到登录提示。如果我们输入格式正确的电子邮件地址和任何密码,它将持久保存详细信息,登录,并在 iOS 上显示 RecordComponent 如下:

你可能会注意到一些相当有趣的事情。ActionBar 从我们通过 CSS 分配的背景颜色和按钮颜色现在显示默认的蓝色。这是因为 RecordComponent 没有定义 ActionBar;因此,它正在回退到具有默认样式的 ActionBar,带有默认的返回按钮,该按钮承担了它刚刚导航离开的页面的标题。'/record' 路由也正在使用 page-router-outlet 将组件推送到移动导航堆栈。RecordComponent 正在动画进入视图,同时允许用户选择左上角的按钮进行导航回退(弹出导航历史记录)。

为了修复 ActionBar,我们只需将 ActionBar 添加到 RecordComponent 视图中,并使用自定义的 NavigationButton(一个模拟移动设备默认返回导航按钮的 NativeScript 视图组件)。我们可以在 app/modules/record/components/record.component.html 中进行调整:

<ActionBar title="Record" class="action-bar">
  <NavigationButton text="Back"
    android.systemIcon="ic_menu_back">
  </NavigationButton>
</ActionBar>
<StackLayout class="p-20">
  <Label text="TODO: Record" class="h1 text-center"></Label>
</StackLayout>

现在,这看起来好多了:

如果我们在 Android 上运行此应用并使用任何电子邮件/密码组合登录以持久保存用户,它将显示相同的 RecordComponent 视图;然而,你会注意到另一个有趣的细节。我们已经将 Android 设置为显示标准的返回箭头系统图标作为 NavigationButton,但是当你点击那个箭头时,它不会做任何事情。Android 的默认行为依赖于位于主页按钮旁边的设备的物理硬件返回按钮。然而,我们可以通过仅为 NavigationButton 添加点击事件来提供一致的用户体验,这样 iOS 和 Android 对返回按钮的点击反应相同。对模板进行以下修改:

<ActionBar title="Record" icon="" class="action-bar">
  <NavigationButton (tap)="back()" text="Back" 
    android.systemIcon="ic_menu_back">
  </NavigationButton>
</ActionBar>
<StackLayout class="p-20">
  <Label text="TODO: Record" class="h1 text-center"></Label>
</StackLayout>

然后,我们可以在 app/modules/recorder/components/record.component.ts 中实现 back() 方法,使用 Angular 的 RouterExtensions 服务:

// angular
import { Component } from '@angular/core';
import { RouterExtensions } from 'nativescript-angular/router';

@Component({
 moduleId: module.id,
 selector: 'record',
 templateUrl: 'record.component.html'
})
export class RecordComponent { 

  constructor(private router: RouterExtensions) { }

  public back() {
    this.router.back();
  }
}

现在,Android 的返回按钮可以点击以返回,除了硬件返回按钮之外。iOS 简单地忽略了点击事件处理程序,因为它使用 NavigationButton 的默认原生行为。相当不错。以下是 Android 上的 RecordComponent 的样子:

图片

我们将在接下来的章节中实现一个不错的录音视图。

我们现在肯定是在 66 号公路上飞驰了!

我们已经实现了懒加载的路由,提供了 AuthGuard 来保护我们应用程序录音功能的未授权使用,并在过程中学到了很多。然而,我们刚刚意识到我们错过了一个非常重要的功能。我们需要一种方法来随着时间的推移处理多个不同的混音。默认情况下,我们的应用程序可能会启动最后打开的混音,但我们会想创建新的混音(让我们考虑它们为作品)并记录完全新的单独音轨的混音作为单独的作品。我们需要一个新的路由来显示这些作品,我们可以适当地命名它们,这样我们就可以来回跳转并处理不同的材料。

处理晚期功能需求 - 管理作品

是时候处理 66 号公路上的意外交通了。我们遇到了一个晚期的功能需求,意识到我们需要一种方法来管理任意数量的不同混音,这样我们就可以随着时间的推移处理不同的材料。我们可以将每个混音称为音频音轨的组合。

好消息是我们已经花费了相当多的时间来设计一个可扩展的架构,我们即将收获我们的劳动成果。现在应对晚期的功能需求变得像在邻里间的一次愉快的周日散步。让我们通过花点时间来开发这个新功能,来展示我们应用程序架构的优势。

让我们从定义我们将要创建的新 MixListComponent 的新路由开始。打开 app/modules/mixer/mixer.module.ts 并进行以下突出显示的修改:

...
import { MixListComponent } from './components/mix-list.component';
import { PROVIDERS } from './services';

const COMPONENTS: any[] = [
  BaseComponent,
  MixerComponent,
  MixListComponent
]

const routes: Routes = [
  {
    path: '',
    component: BaseComponent,
    children: [
      {
 path: 'home',
 component: MixListComponent
 },
 {
 path: ':id',
 component: MixerComponent
 }
    ]
  }
];

@NgModule({
   ...
   providers: [
 ...PROVIDERS
 ]
})
export class MixerModule { }

我们正在改变最初将 MixerComponent 作为主页的策略,相反,我们将在稍后创建一个新的 MixListComponent 来表示 'home' 主页,这将是我们正在工作的所有作品的列表。我们仍然可以在应用程序启动时自动选择最后选择的组合,以便以后方便使用。我们已经将 MixerComponent 定义为一个参数化路由,因为它将始终代表我们通过 ':id' 参数路由标识的一个工作组合,例如解析为 '/mixer/1' 这样的路由。我们还导入了 PROVIDERS,我们将在稍后创建它。

让我们修改由 CoreModule 提供的 DatabaseService,以帮助我们为我们的新数据需求提供一个恒定的持久键。我们希望通过这个恒定键名持久化用户创建的作品。打开 app/modules/core/services/database.service.ts 并进行以下突出显示的修改:

...
interface IKeys {
  currentUser: string;
  compositions: string;
}

@Injectable()
export class DatabaseService {

  public static KEYS: IKeys = {
    currentUser: 'current-user',
    compositions: 'compositions'
  };
...

让我们再创建一个新的数据模型来表示我们的组合。创建 app/modules/shared/models/composition.model.ts

import { ITrack } from './track.model';

export interface IComposition {
  id: number;
  name: string;
  created: number;
  tracks: Array<ITrack>;
  order: number;
}
export class CompositionModel implements IComposition {
  public id: number;
  public name: string;
  public created: number;
  public tracks: Array<ITrack> = [];
  public order: number;

  constructor(model?: any) {
    if (model) {
      for (let key in model) {
        this[key] = model[key];
      }
    }
    if (!this.created) this.created = Date.now();
    // if not assigned, just assign a random id
    if (!this.id)
      this.id = Math.floor(Math.random() * 100000);
  }
}

然后,坚持我们的约定,打开 app/modules/shared/models/index.ts 并重新导出这个新模型:

export * from './composition.model';
export * from './track.model';

我们现在可以使用这个新模型和数据库键在一个新的数据服务上构建这个新功能。创建 app/modules/mixer/services/mixer.service.ts

// angular
import { Injectable } from '@angular/core';

// app
import { ITrack, IComposition, CompositionModel } from '../../shared/models';
import { DatabaseService } from '../../core/services/database.service';
import { DialogService } from '../../core/services/dialog.service';

@Injectable()
export class MixerService {

  public list: Array<IComposition>;

  constructor(
    private databaseService: DatabaseService,
    private dialogService: DialogService
  ) {
    // restore with saved compositions or demo list
    this.list = this._savedCompositions() || 
      this._demoComposition();
  } 

  public add() {
    this.dialogService.prompt('Composition name:')
      .then((value) => {
        if (value.result) {
          let composition = new CompositionModel({
            id: this.list.length + 1,
            name: value.text,
            order: this.list.length // next one in line
          });
          this.list.push(composition);
          // persist changes
          this._saveList();
        }
      });
  }

  public edit(composition: IComposition) {
    this.dialogService.prompt('Edit name:', composition.name)
      .then((value) => {
        if (value.result) {
          for (let comp of this.list) {
            if (comp.id === composition.id) {
              comp.name = value.text;
              break;
            }
          }
          // re-assignment triggers view binding change
          // only needed with default change detection
          // when object prop changes in collection
          // NOTE: we will use Observables in ngrx chapter
          this.list = [...this.list];
          // persist changes
          this._saveList();
        }
      });
  }

  private _savedCompositions(): any {
    return this.databaseService
      .getItem(DatabaseService.KEYS.compositions);
  }

  private _saveList() {
    this.databaseService
      .setItem(DatabaseService.KEYS.compositions, this.list);
  }

  private _demoComposition(): Array<IComposition> {
    // Starter composition to demo on first launch
    return [
      {
        id: 1,
        name: 'Demo',
        created: Date.now(),
        order: 0,
        tracks: [
          {
            id: 1,
            name: 'Guitar',
            order: 0
          },
          {
            id: 2,
            name: 'Vocals',
            order: 1
          }
        ]
      }
    ]
  }
}

现在我们有一个服务,它将提供一个列表来绑定我们的视图以显示用户的保存组合。它还提供了一种添加和编辑组合以及为首次启动应用提供演示组合的方法,以获得良好的首次用户体验(稍后我们将添加实际的轨道到演示中)。

按照我们的约定,我们还可以添加 app/modules/mixer/services/index.ts,如下所示,这是我们之前在 MixerModule 中导入的:

import { MixerService } from './mixer.service';

export const PROVIDERS: any[] = [
  MixerService
];

export * from './mixer.service';

现在让我们创建 app/modules/mixer/components/mix-list.component.ts 来消费和投影我们的新数据服务:

// angular
import { Component } from '@angular/core';

// app
import { MixerService } from '../services/mixer.service';

@Component({
  moduleId: module.id,
  selector: 'mix-list',
  templateUrl: 'mix-list.component.html'
})
export class MixListComponent {

  constructor(public mixerService: MixerService) { } 
}

对于视图模板,app/modules/mixer/components/mix-list.component.html

<ActionBar title="Compositions" class="action-bar">
  <ActionItem (tap)="mixerService.add()" 
    ios.position="right">
    <Button text="New" class="action-item"></Button>
  </ActionItem>
</ActionBar>
<ListView [items]="mixerService.list | orderBy: 'order'" 
  class="list-group">
  <ng-template let-composition="item">
    <GridLayout rows="auto" columns="100,*,auto" 
      class="list-group-item">
      <Button text="Edit" row="0" col="0" 
        (tap)="mixerService.edit(composition)"></Button>
      <Label [text]="composition.name"
        [nsRouterLink]="['/mixer', composition.id]"
        class="h2" row="0" col="1"></Label>
      <Label [text]="composition.tracks.length" 
        class="text-right" row="0" col="2"></Label>
    </GridLayout>
  </ng-template>
</ListView>

这将在视图中渲染 MixerService 用户保存的组合列表,并且当我们首次启动应用时,它将预加载一个包含两个录音的示例 Demo 组合,以便用户可以尝试。以下是首次启动后在 iOS 上的样子:

图片

我们可以创建新的组合并编辑现有组合的名称。我们还可以点击组合的名称来查看 MixerComponent;然而,我们需要调整组件以获取路由 ':id' 参数并将其视图连接到选定的组合。打开 app/modules/mixer/components/mixer.component.ts 并添加突出显示的部分:

// angular
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs/Subscription';

// app
import { MixerService } from '../services/mixer.service';
import { CompositionModel } from '../../shared/models';

@Component({
 moduleId: module.id,
 selector: 'mixer',
 templateUrl: 'mixer.component.html'
})
export class MixerComponent implements OnInit, OnDestroy {

  public composition: CompositionModel; 
 private _sub: Subscription;

 constructor(
 private route: ActivatedRoute,
 private mixerService: MixerService
 ) { } 

 ngOnInit() {
 this._sub = this.route.params.subscribe(params => {
 for (let comp of this.mixerService.list) {
 if (comp.id === +params['id']) {
 this.composition = comp;
 break;
 }
 }
 });
 } 

 ngOnDestroy() {
 this._sub.unsubscribe();
 }
}

我们可以将 Angular 的 ActivatedRoute 注入以订阅路由的参数,这使我们能够访问 id。由于它默认为字符串,我们在服务列表中定位组合时使用 +params['id'] 将其转换为数字。我们为选定的 composition 分配一个本地引用,这现在允许我们在视图中绑定到它。在此期间,我们还将向 ActionBar 添加一个标签为 List 的按钮,以便导航回我们的组合(稍后,我们将实现字体图标来替换它们)。打开 app/modules/mixer/components/mixer.component.html 并进行以下突出显示的修改:

<ActionBar [title]="composition.name" class="action-bar">
  <ActionItem nsRouterLink="/mixer/home">
 <Button text="List" class="action-item"></Button>
 </ActionItem>
  <ActionItem nsRouterLink="/record" ios.position="right">
    <Button text="Record" class="action-item"></Button>
  </ActionItem>
</ActionBar>
<GridLayout rows="*, 100" columns="*" class="page">
  <track-list [tracks]="composition.tracks" row="0" col="0"></track-list>
  <player-controls row="1" col="0"></player-controls>
</GridLayout>

这允许我们在 ActionBar 的标题中显示所选组合的名字,并将其轨道传递给 track-list。我们需要向 track-list 添加 Input,以便它渲染组合的轨道,而不是现在绑定到的虚拟数据。让我们打开 app/modules/player/components/track-list/track-list.component.ts 并添加一个 Input

...
export class TrackListComponent {

 @Input() tracks: Array<ITrack>;

 ...
}

以前,TrackListComponent视图绑定到playerService.tracks,所以让我们调整app/modules/player/components/track-list/track-list.component.html中的组件视图模板,以绑定到我们新的Input,它现在将代表用户实际选择的组合中的曲目**:

<ListView [items]="tracks | orderBy: 'order'" class="list-group">
  <template let-track="item">
    <GridLayout rows="auto" columns="100,*,100" class="list-group-item">
      <Button text="Record" (tap)="record(track)" row="0" col="0" class="c-ruby"></Button>
      <Label [text]="track.name" row="0" col="1" class="h2"></Label>
      <Switch [checked]="track.solo" row="0" col="2" class="switch"></Switch>
    </GridLayout>
  </template>
</ListView>

我们现在在我们的应用程序中有了以下序列来满足这个后期功能需求,我们只在这里的几页材料中就完成了它:

图片

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

图片

然而,您可能会注意到,在 Android 上,ActionBar默认将所有ActionItem放置在右侧。我们想快速向您展示的一个最后的技巧是平台特定视图模板的能力。哦,而且不用担心那些丑陋的 Android 按钮;我们稍后会集成字体图标。

在你认为合适的地方创建特定平台的视图模板。这样做将帮助你在必要时为每个平台调整视图,并使它们易于维护。

让我们创建app/modules/mixer/components/action-bar/action-bar.component.ts

// angular
import { Component, Input } from '@angular/core';

@Component({
  moduleId: module.id,
  selector: 'action-bar',
  templateUrl: 'action-bar.component.html'
})
export class ActionBarComponent {

  @Input() title: string;
}

然后,您可以创建一个特定于 iOS 的视图模板:app/modules/mixer/components/action-bar/action-bar.component.ios.html

<ActionBar [title]="title" class="action-bar">
  <ActionItem nsRouterLink="/mixer/home">
    <Button text="List" class="action-item"></Button>
  </ActionItem>
  <ActionItem nsRouterLink="/record" ios.position="right">
    <Button text="Record" class="action-item"></Button>
  </ActionItem>
</ActionBar>

以及一个特定于 Android 的视图模板:app/modules/mixer/components/action-bar/action-bar.component.android.html

<ActionBar class="action-bar">
  <GridLayout rows="auto" columns="auto,*,auto" class="action-bar">
    <Button text="List" nsRouterLink="/mixer/home" class="action-item" row="0" col="0"></Button>
    <Label [text]="title" class="action-bar-title text-center" row="0" col="1"></Label>
    <Button text="Record" nsRouterLink="/record" class="action-item" row="0" col="2"></Button>
  </GridLayout>
</ActionBar>

然后,我们可以在app/modules/mixer/components/mixer.component.html中使用它:

<action-bar [title]="composition.name"></action-bar>
<GridLayout rows="*, 100" columns="*" class="page">
  <track-list [tracks]="composition.tracks" row="0" col="0"></track-list>
  <player-controls row="1" col="0"></player-controls>
</GridLayout>

只确保您将其添加到MixerModuleCOMPONENTS中,在app/modules/mixer/mixer.module.ts

...
import { ActionBarComponent } from './components/action-bar/action-bar.component';
...

const COMPONENTS: any[] = [
  ActionBarComponent,
  BaseComponent,
  MixerComponent,
  MixListComponent
];
...

哇!

图片

摘要

我们已经到达了这条 66 号公路上的奇妙旅程的终点,希望您感到和我们一样兴奋。本章介绍了一些有趣的 Angular 概念,包括使用懒加载模块配置路由以保持应用程序启动时间快;使用本地文件处理 API 构建自定义模块加载器;将router-outlet的灵活性与 NativeScript 的page-router-outlet相结合;通过懒加载模块获得对单例服务的控制和理解;保护依赖于授权访问的路由;以及处理后期功能需求以展示我们出色的可扩展应用程序设计。

本章总结了我们的应用程序的一般可用性流程,到目前为止,我们已经准备好进入我们应用程序的核心竞争力:通过 iOS 和 Android 丰富的本地 API 进行音频处理

在深入探讨细节之前,在下一章中,我们将简要检查 NativeScript 的各种tns命令行参数,以运行我们的应用程序,并确保我们对现在可以带到工作中的工具带有一个全面的教育。