NativeScript Angular 移动开发(二)
原文:
zh.annas-archive.org/md5/289e6d84a31dea4e7c2b3cd2576adf55译者:飞龙
第六章:在 iOS 和 Android 上运行应用
有几种方法可以构建、运行和开始使用 NativeScript 应用。我们将介绍命令行工具,因为它们是目前最支持的方法,也是处理任何 NativeScript 项目的最佳方式。
为了简化我们的理解,我们将首先介绍我们将经常使用的命令,然后我们将介绍其他不那么常用的命令。所以,让我们开始,并介绍您想要了解的命令。
在本章中,我们将涵盖以下主题:
-
如何运行应用
-
如何启动调试器
-
如何构建用于部署的应用
-
如何启动测试框架
-
如何运行 NativeScript 诊断
-
所有关于 Android 密钥库的内容
接收命令……
我们将要介绍的第一条命令是您每次都会使用的
启动您的应用。为了使事情更简单,我将使用<platform>来表示 iOS、Android,或者当它最终得到支持时,Windows。
tns run
使用tns run <platform>命令将自动构建您的应用并将其同步到设备和模拟器。它将完成所有繁重的工作,尝试使您的应用在设备上运行,然后启动应用。这个命令在多年中已经发生了变化,现在已经成为一个相当智能的命令,会自动做出某些选择以简化您的发展生活。这个命令的一个酷炫功能是它会将您的应用同步到所有正在运行和连接的设备。如果您连接了五台不同的设备,所有这五台设备都会接收到更改。这仅适用于每个平台,但您可以在一个命令窗口中运行tns run ios,在另一个命令窗口中运行tns run android,然后任何更改都将自动同步到连接到您的机器的所有设备。正如您可能想象的那样,这在测试和清理阶段非常有用,以确保在不同手机和平板电脑上一切看起来都很好。如果您没有将物理设备连接到您的计算机,它将自动为您启动一个模拟器。
通常情况下,由于应用已经存在于设备上,它只会快速同步已更改的文件。这是一个非常快速的过程,因为它只是将你的文件中的所有更改从你的app文件夹传输到所有连接的设备,然后启动应用。这个过程在大多数情况下是非常好的。然而,tns run <平台>并不会总是自动检测到你的node_modules文件夹中的任何更改,例如,当你升级插件时。如果出现这种情况,你需要取消当前正在运行的tns run,然后启动一个新的tns run。偶尔,tns run仍然认为它只需要同步,而实际上它应该需要重新构建应用。在这种情况下,你将想要使用方便的--clean选项。这在设备似乎没有检测到任何更改的时候非常重要。tns run <平台> --clean命令通常将强制应用重新构建;然而,如果--clean无法重新构建,那么请查看本章后面描述的tns build命令。还有一些其他命令参数使用得不多,但你可能需要它们来处理特定情况。--justlaunch将启动应用而不做其他任何事情;--no-watch将禁用实时同步,最后--device <设备 ID>将强制应用仅安装到特定的设备上。你可以通过运行tns devices来查看哪些设备可用于安装应用。
tns debug <平台>
我们接下来要讨论的命令是tns debug <平台>;这将允许你使用调试工具测试你的应用。这与tns run命令类似;然而,它不是运行你的应用,而是调试它。调试器将使用标准的 Chrome 开发工具,这使你能够逐步执行代码:断点、调用栈和控制台日志。此命令将提供一个 URL,你可以使用它来在 Chrome 中打开。在 iOS 特定情况下,你应该运行tns debug ios --chrome以获取 chrome-devtools 的 URL。以下是通过 Chrome 调试器调试 Android 的示例:
一些与tns run相同的参数在这里也是有效的,例如--no-watch、--device和--clean。除了这些命令之外,还有一些其他命令可用,例如--debug-brk,它用于使应用在应用启动时中断,这样你就可以在启动过程继续之前轻松设置断点。--start和--stop允许你附加和从已运行的应用中分离。
不要忘记,如果你目前正在使用调试器,JavaScript 有一个酷炫的debugger;命令,它将强制附加的调试器中断,就像你设置了断点一样。这可以用来在代码的任何地方设置断点,如果没有附加调试器,则会被忽略。
tns build <平台>
你需要了解的下一个命令是 tns build <platform>;这个命令会从头开始完全构建一个新的应用程序。现在,这个命令的主要用途是在你想要构建一个调试或发布版本的应用程序,以便将其提供给其他人进行测试或上传到某个商店时使用。然而,它也可以用来强制进行应用程序的完全清洁构建,如果你的应用程序的 tns run 版本处于一种奇怪的状态--这将进行完全重建。如果你不包含 --release 标志,构建将是默认的调试构建。
在 iOS 上,你将使用 --for-device,这将使应用程序为真实设备而不是模拟器进行编译。记住,你需要从苹果那里获得签名密钥才能进行适当的发布构建。
在 Android 上,当你使用 --release 时,你需要包含以下所有 --key-store-* 参数;这些参数是用于对你的 Android 应用程序进行签名所必需的:
--key-store-path | 你的密钥库文件所在的位置。 |
|---|---|
--key-store-password | 读取你的密钥库中任何数据的密码。 |
--key-store-alias | 这个应用程序的别名。因此,在你的密钥库中,你可能将 AA 作为别名,在你的心中它等于 AwesomeApp。我更喜欢将别名设置为应用程序的完整名称,但这完全取决于你。 |
--key-store-alias-password | 这是读取你刚刚设置的别名所分配的实际签名密钥所需的密码。 |
由于密钥库的处理可能会让人困惑,我们在这里将稍微偏离路径,讨论如何实际创建一个密钥库。这通常只是一件一次性的事情,你需要为每个你想要发布的 Android 应用程序执行。对于 iOS 应用程序,你不需要担心这个问题,因为苹果为你提供了签名密钥,并且完全控制它们。
Android 密钥库
在 Android 上,你将创建自己的应用程序签名密钥。因此,这个密钥将用于你应用程序的整个生命周期--这里的整个是指你使用相同的密钥发布你应用程序的每个版本。这个密钥是将版本 1.0 链接到 v1.1 再到 v2.0 的东西。如果不使用相同的密钥,应用程序将被视为一个完全不同的应用程序。
之所以有两个密码,是因为你的密钥库实际上可以包含无限数量的密钥,因此,密钥库中的每个密钥都有自己的密码。任何有权访问这个密钥的人都可以假装成你。这对于构建服务器是有帮助的,但如果你丢失了它们,那就不是那么有帮助了。你无法在以后更改密钥,因此备份你的密钥库文件非常重要。
没有你的密钥库,你永远无法发布具有相同应用程序名称的新版本,这意味着使用旧版本的用户将看不到你有一个更新版本。因此,再次强调,备份你的密钥库文件是至关重要的。
创建新的密钥库
keytool -genkey -v -keystore *<keystore_name>* -alias *<alias_name>* keyalg RSA -keysize 4096 -validity 10000
你提供一个路径,将文件保存到keystore_name中,对于alias_name,你使用实际的关键名称,我通常使用应用程序名称;所以你输入以下内容:
keytool -genkey -v -keystore *android.keystore* -alias *com.mastertechapps.awesomeapp* -keyalg RSA -keysize 4096 -validity 10000
然后,你会看到以下内容:
Enter keystore password:
Re-enter new password:
What is your first and last name?
[Unknown]: Nathanael Anderson
What is the name of your organizational unit?
[Unknown]: Mobile Applications
What is the name of your organization?
[Unknown]: Master Technology
What is the name of your City or Locality?
[Unknown]: Somewhere
What is the name of your State or Province?
[Unknown]: WorldWide
What is the two-letter country code for this unit?
[Unknown]: WW
Is CN=Nathanael Anderson, OU=Mobile Applications, O=Master Technology, L=Somewhere, ST=WorldWide, C=WW correct?
[no]: yes
Generating 4,096 bit RSA key pair and self-signed certificate (SHA256withRSA) with a validity of 10,000 days for: CN=Nathanael Anderson, OU=Mobile Applications, O=Master Technology, L=Somewhere, ST=WorldWide, C=WW
Enter key password for <com.mastertechapps.awesomeapp>
(RETURN if same as keystore password):
[Storing android.keystore]
你现在已经有了你应用程序的 keystore。
Android Google Play 指纹
如果你使用 Google Play 服务,你可能需要提供你的 Android 应用程序密钥指纹。要获取你的密钥指纹,你可以使用以下命令:
keytool -list -v -keystore *<keystore_name>* -alias *<alias_name>* -storepass *<password>* -keypass *<password>*
你应该会看到类似以下的内容:
Alias name: com.mastertechapps.awesomeapp
Creation date: Mar 14, 2017
Entry type: PrivateKeyEntry
Certificate chain length: 1
Certificate[1]:
Owner: CN=Nathanael Anderson, OU=Mobile Applications, O=Master Technology, L=Somewhere, ST=WorldWide, C=WW
Issuer: CN=Nathanael Anderson, OU=Mobile Applications, O=Master Technology, L=Somewhere, ST=WorldWide, C=WW
Serial number: 2f886ac2
Valid from: Sun Mar 14 14:14:14 CST 2017 until: Thu Aug 17 14:14:14 CDT 2044
Certificate fingerprints:
MD5: FA:9E:65:44:1A:39:D9:65:EC:2D:FB:C6:47:9F:D7:FB
SHA1: 8E:B1:09:41:E4:17:DC:93:3D:76:91:AE:4D:9F:4C:4C:FC:D3:77:E3
SHA256: 42:5B:E3:F8:FD:61:C8:6E:CE:14:E8:3E:C2:A2:C7:2D:89:65:96:1A:42:C0:4A:DB:63:D8:99:DB:7A:5A:EE:73
注意,除了确保你备份好你的 keystore 外,如果你将来要将应用程序卖给其他供应商,为每个应用程序使用单独的 keystore 会使转移过程更容易也更安全。如果你使用相同的 keystore 和/或别名,将很难区分谁得到了什么。所以,为了简单起见,我建议你为每个应用程序使用单独的 keystore 和别名。我通常将 keystore 与 app 一起保存在版本控制中。由于打开和访问别名都需要密码保护,除非你选择了糟糕的密码,否则你不会有问题。
回到命令行
现在我们已经偏离了处理 Android keystore 的路径,我们将更深入地探讨你偶尔会用到的一些 tns 命令。这些命令中的第一个是 tns plugin。
tns plugin 命令
这个功能实际上非常重要,但只有在你想处理插件时才会使用。这个命令最常见的版本就是tns plugin add <name>。例如,如果你想安装一个名为NativeScript-Dom的插件,你将执行tns plugin add nativescript-dom,它将自动安装这个插件的相关代码以便在你的应用程序中使用。要移除这个插件,你可以输入tns plugin remove nativescript-dom。我们还有tns plugin update nativescript-dom来移除插件并下载安装插件的新版本。最后,单独运行tns plugin将列出你已安装的插件及其版本:
然而,说实话,如果我真的需要这个信息,我正在寻找过时的插件,所以你更好的选择是输入npm outdated,让npm为你列出过时的插件及其当前版本:
如果你有一些过时的插件,你可以使用tns plugin update命令来升级它们。
tns install <dev_plugin>命令
这个功能并不常用,但在你需要的时候很有用,因为它允许你安装开发插件,例如 webpack、typescript、coffee script 或 SASS 支持。所以,如果你决定要使用webpack,你可以输入tns install webpack,它将安装 webpack 支持,以便你可以使用 webpack 来构建你的应用程序。
tns create <project_name>命令
这个命令是我们用来创建新项目的。这将创建一个新的目录并安装构建新应用所需的所有平台无关代码。这个命令的重要参数是 --ng,它告诉它使用 Angular 模板(这正是我们在本书中使用的内容--如果没有 --ng,你将得到纯 JS 模板)和 --appid,它允许你设置你的完整应用名称。所以,tns create AwesomeApp --ng --appid com.mastertechapps.awesomeapp 将在 AwesomeApp 目录中创建一个新的 Angular 应用,应用 ID 为 com.mastertechapps.awesomeapp。
tns info 命令
检查主 NativeScript 组件状态的另一个有用命令是 tns info;这个命令实际上会检查你的主 NativeScript 部件,并告诉你是否有任何内容过时:
如前例所示,NativeScript 命令行有一个更新的版本,我没有安装 ios 运行时。
tns platform [add|remove|clean|upgrade] 命令
你可以使用 tns platform [add|remove|clean|upgrade] <platform> 命令来安装、删除或更新平台模块,就像插件一样。这些就是你在之前的 tns info 命令中看到的 tns-android 和 tns-ios 模块。应用程序实际上需要安装这些特定平台的模块。默认情况下,当你执行 tns run 时,如果它们缺失,它将自动安装。偶尔,如果应用程序拒绝构建,你可以使用 tns platform clean <platform>,它将自动卸载并重新安装平台,这将重置构建过程。
注意,当你执行 tns platform clean/remove/update 时,这些操作将完全删除 platforms/<platform> 文件夹。如果你对这个文件夹中的文件进行了任何手动更改(这不被推荐),这些更改将被删除。
tns test 命令
tns test <platform> 命令允许你安装和/或启动测试框架。我们将在后面的章节中更深入地介绍测试,然而,为了完整性,我们将在本节中介绍这个命令。tns test init 将初始化测试系统;你将为每个应用程序做一次。它将要求你选择一个测试框架,然后安装你选择的测试框架。tns test <platform> 将在该特定平台上启动测试。
tns device 命令
如果你需要特别针对设备,使用 tns device 命令将给你一个已安装并连接到你的计算机的设备列表。这将允许你在 tns run/debug 命令中使用 --device <deviceid> 参数:
tns doctor 命令
tns doctor 命令检查您的环境是否存在常见问题。它将尝试检测是否所有内容都已正确安装和配置。它通常可以正常工作,但偶尔会失败,并指出某些东西出了问题,即使实际上一切正常。然而,它提供了非常好的第一个迹象,如果您的 tns run/build/debug 不再工作,可能会出什么问题。
TNS 帮助命令
如果您完全忘记了我们在这里写的内容,您可以执行 tns help,这将为您提供不同命令的概述。一些参数可能没有列出,但在此阶段,它们确实存在。在新版本中,新的参数和命令可能会添加到 tns 中,这是了解它们的最简单方法。
如果由于某种原因,您的应用程序似乎没有正确更新,最简单的方法是从设备中卸载应用程序。然后,尝试执行 tns build <platform>,然后 tns run <platform>。如果这不能解决问题,则再次卸载应用程序,执行 tns platform clean <platform>,然后执行您的 tns run。偶尔,平台可能会进入奇怪的状态,重置它是解决问题的唯一方法。
TNS 命令行速查表
| 命令行 | 描述 |
|---|---|
tns --version | 这将返回 NativeScript 命令的版本。如果您正在运行较旧版本,则可以使用 npm 升级您的 NativeScript 命令,如下所示:npm install -g nativescript。 |
tns create <your project name> | 这将创建一个全新的项目。以下是其参数:--ng 和 --appid。 |
tns platform add <platform> | 这会将一个目标平台添加到您的项目中。 |
tns platform clean <platform> | 此命令通常不需要,但如果您正在修改平台目录和平台,您可以删除并重新添加它。请注意,这将删除整个平台目录。因此,如果您对 Android 清单或 iOS Xcode 项目文件进行了任何特定的自定义,请在运行清理命令之前备份它们。 |
tns platform update <platform> | 这实际上是一个非常重要的命令。NativeScript 仍然是一个非常活跃的项目,正在经历大量的开发。此命令将您的平台代码升级到最新版本,这通常消除了错误并添加了许多新功能。请注意,这应该与通用 JavaScript 库的升级一起进行,因为它们通常彼此同步。 |
tns build <platform> | 这将使用参数 --release、--for-device 和 --key-store-* 为该平台构建应用程序。 |
tns deploy <platform> | 这将为该平台构建和部署应用程序到物理或虚拟设备。 |
tns run <平台> | 这将在物理设备或模拟器上构建、部署并启动应用程序。这是你将大多数时间用来运行应用程序和检查更改的命令。它的参数是 --clean、--no-watch 和 --justlaunch。 |
tns debug <平台> | 这将在调试模式下构建、部署并在物理设备或模拟器上启动应用程序。这可能是第二常用的命令。它的参数是 --clean、--no-watch、--dbg-break 和 --start。 |
tns plugin add <插件> | 这允许你添加第三方插件或组件。这些插件可以是完全基于 JavaScript 的代码,或者也可能包含从 Java 或 Objective-C 库编译的内容。 |
tns doctor | 这允许你在 NativeScript 似乎无法正常工作时对你的环境运行诊断检查。 |
tns devices | 这显示了用于与 --device 命令一起使用的连接设备列表。 |
tns install <开发插件> | 这将安装一个开发插件(即 webpack、typescript 等)。 |
tns test [ init | <平台> ] | 这允许你为你的应用程序创建或运行任何测试。使用 init 将初始化应用程序的测试框架。然后,你可以输入平台来在该平台上运行测试。 |
摘要
现在你已经了解了命令行的强大功能,你真正需要记住的就是 tns debug ios 和 tns run android;这些将成为我们冒险中的忠实伙伴。再添加几个 tns plugin add 命令,然后在最终完成时使用 tns build 来封装应用程序,你就成功了**。**然而,不要忘记其他命令;它们都各有用途。其中一些很少使用,但有些在你需要时非常有帮助。
在第七章 构建多轨播放器 中,我们将开始探索如何实际访问原生平台并与插件集成。
第七章:构建多轨播放器
我们已经到达了 NativeScript 开发的关键点:通过 TypeScript 直接访问 iOS 的 Objective-C/Swift API 和 Android 的 Java API。
这无疑是 NativeScript 最独特的特点之一,为您作为移动开发者打开了众多机会。特别是,我们的应用程序将需要利用 iOS 和 Android 上的丰富原生音频 API,以实现其核心能力,即为用户提供引人入胜的多轨录音/混音体验。
了解如何针对这些 API 进行编码对于解锁移动应用程序的全部潜力至关重要。此外,学习如何集成现有的 NativeScript 插件,这些插件可能已经在 iOS 和 Android 上提供了一致的 API,可以帮助您更快地实现目标。利用每个平台能提供的最佳性能将是我们在第三部分旅程中的重点。
在本章中,我们将涵盖以下内容:
-
集成 Nativescript-audio 插件
-
为我们的轨道播放器创建一个模型以实现未来的可扩展性
-
使用 RxJS 可观察对象进行工作
-
通过第三方库和视图绑定理解 Angular 的 NgZone
-
处理与多个音频源同步的音频播放
-
利用 Angular 的绑定以及 NativeScript 的原生事件绑定,以实现我们追求的精确可用性
-
使用 Angular 平台特定的指令为我们的播放器控件构建自定义穿梭滑块
通过 nativescript-audio 插件实现我们的多轨播放器
幸运的是,NativeScript 社区已经发布了一个插件,它为我们提供了一个一致的 API,可以在 iOS 和 Android 上使用,以便开始使用音频播放器。在实现功能之前,请随意浏览 plugins.nativescript.org,这是 NativeScript 插件的官方来源,以确定现有的插件是否适合您的项目。
在这个案例中,位于 plugins.nativescript.org/plugin/nativescript-audio 的 nativescript-audio 插件包含了我们开始集成应用程序功能播放器部分所需的内容,并且它在 iOS 和 Android 上都能工作。它甚至提供了一个我们可能能够使用的录音器。让我们先从安装它开始:
npm install nativescript-audio --save
NativeScript 框架允许您与任何 npm 模块集成,从而打开了一个令人眼花缭乱的集成可能性,包括 NativeScript 特定的插件。实际上,如果您遇到 npm 模块给您带来麻烦的情况(可能是因为它依赖于在 NativeScript 环境中不兼容的 node API),甚至有一个插件可以帮助您处理这种情况,请参阅www.npmjs.com/package/nativescript-nodeify。它详细描述在www.nativescript.org/blog/how-to-use-any-npm-module-with-nativescript。
在与 NativeScript 插件集成时,创建一个模型或 Angular 服务来围绕其集成提供隔离。
尝试通过创建一个可重用的模型或 Angular 服务来隔离第三方插件集成点。这不仅将为您的应用程序提供良好的未来可扩展性,而且在您需要用不同的插件替换该插件或为 iOS 或 Android 提供不同的实现时,将提供更多的灵活性。
为我们的多音轨播放器构建TrackPlayerModel
我们需要每个音轨都有自己的音频播放器实例,以及暴露一个 API 来加载音轨的音频文件。这也会提供一个很好的地方来暴露音频文件加载后的音轨时长。
由于这个模型可能会在整个应用程序中共享(预计未来还会与录音播放一起),我们将在app/modules/shared/models/track-player.model.ts中与我们的其他模型一起创建它:
// libs
import { TNSPlayer } from 'nativescript-audio';
// app
import { ITrack } from
'./track.model';
interface ITrackPlayer {
trackId: number;
duration: number;
readonly
player: TNSPlayer;
}
export class TrackPlayerModel implements ITrackPlayer {
public trackId:
number;
public duration: number;
private _player: TNSPlayer;
constructor() {
this._player = new TNSPlayer();
}
public load(track: ITrack): Promise<number> {
return
new Promise((resolve, reject) => {
this.trackId = track.id;
this._player.initFromFile({
audioFile: track.filepath,
loop: false
}).then(() => {
this._player.getAudioTrackDuration()
.then((duration) => {
this.duration = +duration;
resolve();
});
});
});
}
public get player():
TNSPlayer {
return this._player;
}
}
我们首先从nativescript-audio插件中导入甜美的 NativeScript 社区音频播放器TNSPlayer。然后,我们定义一个简单的接口来实现我们的模型,该接口将引用trackId、其duration以及一个用于player实例的readonly获取器。接着,我们将该接口包含在我们的实现中,它使用自身构建一个TNSPlayer实例。由于我们希望有一个灵活的模型,可以在任何时间加载其音轨文件,我们提供了一个接受ITrack的load方法,该方法利用了initFromFile方法。这反过来会异步获取音轨的总时长(以字符串形式返回,因此我们使用+duration),在模型中存储该数字,在解决音轨初始化完成之前。
为了保持一致性和标准,请确保也从app/modules/shared/models/index.ts导出这个新模型:
export * from './composition.model';
export * from './track-player.model';
export * from
'./track.model';
最后,我们提供了一个用于播放器实例的获取器,PlayerService将使用它。这使我们来到了下一步:打开app/modules/player/services/player.service.ts。我们将根据我们最新的发展对我们的初始实现进行一些修改;整体查看这个,我们将在之后解释:
// angular
import { Injectable } from '@angular/core';
// libs
import { Subject }
from 'rxjs/Subject';
import { Observable } from 'rxjs/Observable';
// app
import { ITrack, CompositionModel, TrackPlayerModel } from '../../shared/models';
@Injectable()
export class PlayerService {
// observable state
public playing$:
Subject<boolean> = new Subject();
public duration$: Subject<number> = new Subject
();
public currentTime$: Observable<number>;
// active composition
private _composition: CompositionModel;
// internal state
private _playing:
boolean;
// collection of track players
private _trackPlayers: Array<TrackPlayerModel>
= [];
// used to report currentTime from
private _longestTrack:
TrackPlayerModel;
constructor() {
// observe currentTime changes every 1 seconds
this.currentTime$ = Observable.interval(1000)
.map(_ => this._longestTrack ?
this._longestTrack.player.currentTime
: 0);
}
public set playing(value: boolean)
{
this._playing = value;
this.playing$.next(value);
}
public get playing(): boolean {
return
this._playing;
}
public get composition(): CompositionModel
{
return this._composition;
}
public set
composition(comp: CompositionModel) {
this._composition = comp;
// clear any previous players
this._resetTrackPlayers();
// setup
player instances for each track
let initTrackPlayer = (index: number) => {
let track = this._composition.tracks[index];
let trackPlayer = new
TrackPlayerModel();
trackPlayer.load(track).then(_ => {
this._trackPlayers.push(trackPlayer);
index++;
if (index <
this._composition.tracks.length) {
initTrackPlayer(index);
}
else {
// report total duration of composition
this._updateTotalDuration();
}
});
};
// kick off multi-track player initialization
initTrackPlayer
(0);
}
public togglePlay() {
this.playing =
!this.playing;
if (this.playing) {
this.play();
} else {
this.pause();
}
}
public play() {
for (let t of this._trackPlayers) {
t.player.play();
}
}
public
pause() {
for (let t of this._trackPlayers) {
t.player.pause
();
}
}
...
private
_updateTotalDuration() {
// report longest track as the total duration of the mix
let totalDuration = Math.max(
...this._trackPlayers.map(t =>
t.duration));
// update trackPlayer to reflect longest track
for (let
t of this._trackPlayers) {
if (t.duration === totalDuration) {
this._longestTrack = t;
break;
}
}
this.duration$.next(totalDuration);
}
private _resetTrackPlayers() {
for (let t of this._trackPlayers) {
t.cleanup();
}
this._trackPlayers = [];
}
}
目前 PlayerService 的基石不仅在于管理混音中播放多个音轨的繁重工作,还在于提供一个状态,我们的视图可以观察以反映组合的状态。因此,我们有以下内容:
...
// observable state
public playing$: Subject<boolean> = new Subject();
public duration$:
Subject<number> = new Subject();
public currentTime$: Observable<number>;
// active
composition
private _composition: CompositionModel;
// internal state
private _playing: boolean;
//
collection of track players
private _trackPlayers: Array<TrackPlayerModel> = [];
// used to report
currentTime from
private _longestTrack: TrackPlayerModel;
constructor() {
// observe currentTime
changes every 1 seconds
this.currentTime$ = Observable.interval(1000)
.map(_ => this._longestTrack ?
this._longestTrack.player.currentTime
: 0);
}
...
我们的视图需要知道播放状态以及 duration 和 currentTime。使用 Subject 对于 playing$ 和 duration$ 状态将工作得很好,因为它们如下:
-
它们可以直接发出值
-
它们不需要发出初始值
-
它们不需要任何可观察的组合
另一方面,currentTime$ 将会根据一些组合来设置,因为它的值将依赖于可能随时间发展的间歇性状态(关于这一点稍后会有更多说明!)。换句话说,playing$ 状态是我们通过用户(或基于播放器状态内部)进行的播放操作直接控制和发出的一个值,而 duration$ 状态是我们直接发出的一个值,作为所有音轨播放器初始化并准备好的结果**。**
currentTime 是播放器不会通过播放器事件自动发出的一个值,而是一个我们必须间歇性检查的值。因此,我们组合 Observable.interval(1000),它将在订阅时每秒自动发出一个映射值,代表最长音轨播放实例的实际 currentTime。
其他 private 引用有助于维护服务内部状态。最有趣的是,我们将保留 _longestTrack 的引用,因为我们的组合的总时长将始终基于最长音轨,因此也将用于跟踪 currentTime。
这种设置将提供我们视图所需的基本要素,以实现适当的用户交互。
RxJS 默认不包含任何操作符。因此,如果你现在运行 Observable.interval(1000) 和 .map,它们将使你的应用崩溃!
当你开始更多地使用 RxJS 时,创建一个 operators.ts 文件并将所有 RxJS 操作符导入其中是个好主意。然后,在根 AppComponent 中导入该文件,这样你就不需要在代码库的各个地方散布那些操作符导入。
创建 app/operators.ts 并包含以下内容:
import 'rxjs/add/operator/map';
import 'rxjs/add/observable/interval';
然后,打开 app/app.component.ts 并在第一行导入该文件:
import './operators';
...
现在,我们可以自由地在代码的任何地方使用 map、interval 以及我们需要的任何其他 rxjs 操作符,前提是我们将它们导入到那个单一文件中。
我们服务的下一部分相当直观:
public set playing(value: boolean) {
this._playing = value;
this.playing$.next(value);
}
public get playing(): boolean {
return this._playing;
}
public get composition(): CompositionModel
{
return this._composition;
}
我们的 playing 设置器确保内部状态 _playing 被更新,以及 playing$ 主题的值被发出,以便任何需要对此状态变化做出反应的订阅者。为了保险起见,还添加了一些方便的获取器。我们组合的下一个设置器变得相当有趣,因为这是我们与新的 TrackPlayerModel 交互的地方:
public set composition(comp: CompositionModel) {
this._composition = comp;
// clear any previous
players
this._resetTrackPlayers();
// setup player instances for each track
let initTrackPlayer =
(index: number) => {
let track = this._composition.tracks[index];
let trackPlayer = new
TrackPlayerModel();
trackPlayer.load(track).then(_ => {
this._trackPlayers.push
(trackPlayer);
index++;
if (index < this._composition.tracks.length) {
initTrackPlayer(index);
} else {
// report total duration of composition
this._updateTotalDuration();
}
});
};
// kick off multi-track player initialization
initTrackPlayer(0);
}
...
private _resetTrackPlayers() {
for (let t of this._trackPlayers) {
t.cleanup();
}
this._trackPlayers = [];
}
每当我们设置活动作品时,我们首先确保我们的服务内部_trackPlayers引用被正确清理和清除,使用this._resetTrackPlayers()。然后我们设置一个本地方法initTrackPlayer,它可以被迭代调用,考虑到每个播放器的load方法具有异步特性,以确保每个轨道的播放器正确加载音频文件,包括其时长。每次成功加载后,我们将它添加到我们的_trackPlayers集合中,迭代并继续,直到所有音频文件都加载完成。完成后,我们调用this._updateTotalDuration()来确定我们轨道作品的最终时长:
private _updateTotalDuration() {
// report longest track as the total duration of the mix
let
totalDuration = Math.max(
...this._trackPlayers.map(t => t.duration));
// update trackPlayer to reflect
longest track
for (let t of this._trackPlayers) {
if (t.duration === totalDuration) {
this._longestTrack = t;
break;
}
}
this.duration$.next(totalDuration);
}
由于应该始终使用最长时长的轨道来确定整个作品的持续时间,我们使用Math.max来确定最长时长,然后存储对轨道的引用。因为可能有多个轨道具有相同的时长,所以使用哪个轨道并不重要,只要有一个匹配最长时长即可。这个_longestTrack将作为我们的节奏设定者,因为它将用于确定整个作品的currentTime。最后,我们通过duration$主题将最长时长作为totalDuration发射给任何订阅的观察者。
接下来的几个方法提供了我们作品整体播放控制的基础:
public togglePlay() {
this.playing = !this.playing;
if (this.playing) {
this.play();
}
else {
this.pause();
}
}
public play() {
for (let t of this._trackPlayers) {
t.player.play();
}
}
public pause() {
for (let t of this._trackPlayers) {
t.player.pause();
}
}
我们 UI 中的主要播放按钮将使用togglePlay方法来控制播放,因此也用于切换内部状态以及激活所有轨道播放器的播放或暂停方法。
让音乐播放!
为了尝试所有这些,让我们添加来自由杰出的Jesper Buhl Trio创作的爵士曲目What Is This Thing Called Love的三个样本音频文件。这些曲目已经由鼓、贝斯和钢琴分开。我们可以将这些.mp3文件添加到app/audio文件夹中。
让我们修改MixerService中我们的演示作品的轨道,以提供对这些新真实音频文件的引用。打开app/modules/mixer/services/mixer.service.ts并进行以下修改:
private _demoComposition(): Array<IComposition> {
// starter composition for user to demo on first
launch
return [
{
id: 1,
name: 'Demo',
created: Date.now(),
order: 0,
tracks: [
{
id: 1,
name: 'Drums',
order: 0,
filepath:
'~/audio/drums.mp3'
},
{
id: 2,
name: 'Bass',
order: 1,
filepath: '~/audio/bass.mp3'
},
{
id: 3,
name: 'Piano',
order:
2,
filepath: '~/audio/piano.mp3'
}
]
}
];
}
现在让我们向我们的播放器控制输入一个输入,这将选择我们的选定作品。打开app/modules/mixer/components/mixer.component.html,并进行以下高亮显示的修改:
<action-bar [title]="composition.name"></action-bar>
<GridLayout rows="*, auto" columns="*"
class="page">
<track-list [tracks]="composition.tracks" row="0" col="0">
</track-list>
<player-controls [composition]="composition"
row="1" col="0"></player-controls>
</GridLayout>
然后,在PlayerControlsComponent的app/modules/player/components/player- controls/player-controls.component.ts中,我们现在可以通过其各种可观察对象观察PlayerService的状态:
// angular
import { Component, Input } from '@angular/core';
// libs
import { Subscription } from 'rxjs/Subscription';
// app
import { ITrack,
CompositionModel } from '../../../shared/models';
import { PlayerService } from '../../services';
@Component({
moduleId: module.id,
selector: 'player-controls',
templateUrl: 'player-
controls.component.html'
})
export class PlayerControlsComponent {
@Input() composition:
CompositionModel;
// ui state
public playStatus: string = 'Play';
public duration:
number = 0;
public currentTime: number = 0;
// manage subscriptions
private _subPlaying:
Subscription;
private _subDuration: Subscription;
private _subCurrentTime:
Subscription;
constructor(
private playerService: PlayerService
) { }
public togglePlay() {
this.playerService.togglePlay();
}
ngOnInit() {
// init audio player for composition
this.playerService.composition = this.composition;
// react to play state
this._subPlaying = this.playerService.playing$
.subscribe((playing: boolean) =>
{
// update button state
this._updateStatus(playing);
//
update slider state
if (playing) {
this._subCurrentTime =
this.playerService
.currentTime$
.subscribe
((currentTime: number) => {
this.currentTime = currentTime;
});
} else if (this._subCurrentTime) {
this._subCurrentTime.unsubscribe();
}
});
//
update duration state for slider
this._subDuration = this.playerService.duration$
.subscribe((duration: number) => {
this.duration = duration;
});
}
ngOnDestroy() {
// cleanup
if (this._subPlaying)
this._subPlaying.unsubscribe();
if
(this._subDuration)
this._subDuration.unsubscribe();
if
(this._subCurrentTime)
this._subCurrentTime.unsubscribe();
}
private _updateStatus(playing: boolean) {
this.playStatus =
playing ? 'Stop' : 'Play';
}
}
PlayerControlComponent 的基石现在是其通过 ngOnInit 中的 this.playerService.composition = this.composition 来设置活动组合的能力,这时组合输入已经就绪,以及订阅 PlayerService 提供的各种状态来更新我们的 UI。这里最有趣的是 playing$ 订阅,它根据是否播放来管理 currentTime$ 订阅。如果你还记得,我们的 currentTime$ 可观察者是从 Observable.interval(1000) 开始的,这意味着每秒会发出最长轨道的 currentTime,这里再次列出以供参考:
this.currentTime$ = Observable.interval(1000)
.map(_ => this._longestTrack ?
this._longestTrack.player.currentTime
: 0);
我们只想在播放参与时更新 Slider 的 currentTime;因此,当 playing$ 主题发出 true 时,这将允许我们的组件每秒接收播放器的 currentTime。当 playing$ 发出 false 时,我们取消订阅,不再接收 currentTime 更新。非常好。
我们还订阅了我们的 duration$ 主题来更新滑块的 maxValue。最后,我们确保通过它们在 ngOnDestroy 中的 Subscription 引用清理所有订阅。
让我们看看 PlayerControlsComponent 的视图绑定,它在 app/modules/player/components/player-controls/player-controls.component.html:
<GridLayout rows="100" columns="100,*"
row="1" col="0" class="p-x-10">
<Button [text]
="playStatus" (tap)="togglePlay()"
row="0" col="0" class="btn btn-primary w-
100"></Button>
<Slider [maxValue]="duration" [value]="currentTime"
minValue="0" row="0" col="1" class="slider">
</Slider>
</GridLayout>
如果你运行应用程序,你现在可以选中演示组合,并在 iOS 和 Android 上播放音乐。
音乐动听!这真是太棒了。事实上,它简直太棒了!!
在这个阶段,你可能注意到或希望以下几件事情:
-
在选择播放按钮后,它正确地变为停止,但当播放到达末尾时,它不会返回到原始的播放文本。
-
Slider也应该返回到位置 0 以重置播放。 -
iOS 上的总
duration和currentTime使用秒;然而,Android 使用毫秒。 -
在 iOS 上,如果你在组合的演示轨道播放期间多次选择播放/暂停,你可能会注意到所有轨道上非常微妙的播放同步问题。
-
需要当前时间和持续时间标签。
-
播放寻找 很好,能够将滑块穿梭以控制播放位置。
完善实现
我们在模型和服务中缺少一些重要的部分来真正完善我们的实现。让我们从处理轨道播放实例的完成和错误条件开始。打开 TrackPlayerModel 在 app/modules/shared/models/track-player.model.ts,并添加以下内容:
... export interface IPlayerError {
trackId: number;
error: any;
}
export class TrackPlayerModel implements ITrackPlayer {
...
private _completeHandler: (number) => void;
private _errorHandler:
(IPlayerError) => void;
...
public load(
track: ITrack,
complete: (number) => void,
error: (IPlayerError) => void
):
Promise<number> {
return new Promise((resolve, reject) => {
...
this._completeHandler = complete;
this._errorHandler = error;
this._player.initFromFile({
audioFile: track.filepath,
loop: false,
completeCallback: this._trackComplete.bind(this),
errorCallback:
this._trackError.bind(this) ... private _trackComplete(args: any) {
// TODO:
works well for multi-tracks with same length
// may need to change in future with varied lengths
this.player.seekTo(0);
console.log('trackComplete:', this.trackId);
if (this._completeHandler)
this._completeHandler(this.trackId);
}
private _trackError(args: any) {
let error =
args.error;
console.log('trackError:', error);
if (this._errorHandler)
this._errorHandler({
trackId: this.trackId, error });
}
我们首先使用 IPlayerError 定义每个轨道错误的形状。然后,我们定义通过 load 参数捕获的 _completeHandler 和 _errorHandler 函数的引用,现在这些函数需要完整的和错误回调。我们在将模型内部的 this._trackComplete 和 this._trackError(使用 .bind(this) 语法确保函数作用域锁定到自身)分配给 TNSPlayer 的 completeCallback 和 errorCallback 之前分配这些。
completeCallback和errorCallback将在区域外触发。这就是为什么我们在本章后面注入NgZone并使用ngZone.run()。我们可以通过创建一个使用zonedCallback函数的回调来避免这种情况。它将确保回调将在创建回调的同一区域中执行。例如:
this._player.initFromFile({
audioFile: track.filepath,
loop: false,
completeCallback:
zonedCallback(this._trackComplete.bind(this)),
errorCallback:
zonedCallback(this._trackError.bind(this))
...
这使我们能够在分发这些条件之前内部处理每个条件。
其中一个内部条件是在音频播放完成后将每个音频播放器重置为零,所以我们只需调用TNSPlayer的seekTo方法来重置它。我们标记为待办事项,因为尽管当所有轨道长度相同(如我们的演示轨道)时这效果很好,但在我们开始录制不同长度的多轨时,这可能会在未来变得可能有问题。想象一下,在一个作品中我们有两个轨道:轨道 1 时长 1 分钟,轨道 2 时长 30 秒。如果我们播放作品到 45 秒并按暂停,轨道 2 已经调用了其完成处理程序并重置回 0。然后我们按播放来继续。轨道 1 从 45 秒开始继续,但轨道 2 回到了 0。我们将在到达那里时解决它,所以不要担心! 到目前为止,我们正在完善第一阶段实现。
最后,我们调用分配的completeHandler来通知调用者哪个trackId已经完成。对于trackError,我们简单地调用传递trackId和error。
现在,让我们回到PlayerService并连接这个功能。打开app/modules/player/services/player.service.ts并做出以下修改:
// app
import { ITrack, CompositionModel, TrackPlayerModel, IPlayerError } from
'../../shared/models';
@Injectable()
export class PlayerService {
// observable state
...
public complete$: Subject<number> = new Subject();
... public set
composition(comp: CompositionModel) {...let initTrackPlayer = (index:
number) => {...trackPlayer.load(
track,
this._trackComplete.bind(this),
this._trackError.bind(this)
...
private _trackComplete(trackId: number) {
console.log('track complete:', trackId);
this.playing =
false;
this.complete$.next(trackId);
}
private _trackError(playerError: IPlayerError) {
console.log(`trackId ${playerError.trackId} error:`,
playerError.error);
}
...
我们添加了另一个主题,complete$,以便视图组件可以在轨道播放完成时订阅。此外,我们还添加了两个回调处理程序,_trackComplete和_trackError,我们将它们传递给TrackPlayerModel的load方法。
然而,如果我们尝试在任何视图组件中由于complete$订阅触发而更新视图绑定,你会注意到一些令人困惑的事情。视图不会更新!
每次你与第三方库集成时,请注意来自库的回调处理程序,你可能打算更新视图绑定。在需要的地方注入 NgZone 并使用this.ngZone.run(() => ...包裹。
提供回调的第三方库可能经常需要通过 Angular 的 NgZone 运行。如果你想了解更多关于区域的信息,Thoughttram 的伟大团队发布了一篇很好的文章,可以在blog.thoughtram.io/angular/2016/02/01/zones-in-angular-2.html找到。
第三方库nativescript-audio与 iOS 和 Android 的原生音频播放器集成,并提供你可以连接起来处理完成和错误条件的回调。这些回调在原生音频播放器的上下文中异步执行,并且由于它们不是在用户事件(如点击、网络请求的结果或setTimeout定时器)的上下文中处理的,因此如果我们希望它们导致视图绑定的更新,我们需要确保结果和随后的代码执行在 Angular 的 NgZone 中进行。
由于我们希望complete$主题导致视图绑定更新(特别是重置我们的滑块),我们将注入 NgZone 并包装我们的回调处理。回到app/modules/player/services/player.service.ts,让我们进行以下调整:
// angular
import { Injectable, NgZone } from '@angular/core';
@Injectable()
export class PlayerService {
...
constructor(private ngZone: NgZone) {}
...
private _trackComplete(trackId: number) {
console.log('track complete:', trackId);
this.ngZone.run(() => {
this.playing = false;
this.complete$.next(trackId);
});
}
...
现在,当使用这个新的complete$主题来响应我们的服务状态时,我们将非常明确。让我们调整PlayerControlsComponent在app/modules/player/components/player-controls/player-controls.component.ts中的设置,以观察complete$主题来重置我们的currentTime绑定:
export class PlayerControlsComponent {
...
private _subComplete: Subscription;
...
ngOnInit() {
...
// completion should reset currentTime
this._subComplete
= this.playerService.complete$.subscribe(_ => {
this.currentTime = 0;
});
}
ngOnDestroy() {
...
if (this._subComplete) this._subComplete.unsubscribe();
}
...
iOS 音频播放器报告duration和currentTime为秒,而 Android 报告为毫秒。我们需要标准化这一点!
让我们在PlayerService中添加一个方法来标准化时间,这样我们就可以依赖提供秒数时间的两个平台:
...
// nativescript
import { isIOS } from 'platform';
...
@Injectable()
export class PlayerService {
constructor() {
// observe currentTime changes
every 1 seconds
this.currentTime$ = Observable.interval(1000)
.map(_ => this._longestTrack ?
this._standardizeTime(
this._longestTrack.player.currentTime)
: 0;
);
}
...
private _updateTotalDuration() {
...
// iOS: reports
duration in seconds
// Android: reports duration in milliseconds
//
standardize to seconds
totalDuration = this._standardizeTime(totalDuration);
console.log('totalDuration of mix:', totalDuration);
this.duration$.next(totalDuration);
}
...
private _standardizeTime(time: number) {
return isIOS ? time : time * .001;
}
...
我们可以利用 NativeScript 的platform模块提供的isIOS布尔值,有条件地调整我们的时间,将 Android 的毫秒转换为秒。
使用 NativeScript 的platform模块中的isIOS和/或isAndroid布尔值是在代码库中需要时进行平台调整的非常有效的方法。
那么,iOS 上多个音轨的微妙播放同步问题怎么办呢?
在 iOS 上,如果你在 14 秒的播放时间内多次选择播放/暂停,你可能会注意到所有音轨上都非常微妙的播放同步问题。我们可以推测这可能在 Android 的某个时刻也会发生。
通过直接从 nativescript-audio 插件调用底层 iOS AVAudioPlayer 实例的本地 API 来利用 NativeScript 的优势。
让我们在播放/暂停逻辑中插入一些安全措施,以帮助确保我们的音轨尽可能同步到我们的编程能力。nativescript-audio插件提供了一个仅适用于 iOS 的方法,称为playAtTime。它与特殊的deviceCurrentTime属性协同工作,正如 Apple 在其针对此目的的文档中所描述的,可以在developer.apple.com/reference/avfoundation/avaudioplayer/1387462-devicecurrenttime?language=objc找到。
由于deviceCurrentTime属性没有被 nativescript-audio 插件暴露,我们可以通过ios获取器直接访问原生属性。让我们调整PlayerService的play方法来使用它:
public play() {
// for iOS playback sync
let shortStartDelay = .01;
let
now = 0;
for (let i = 0; i < this._trackPlayers.length; i++) {
let track = this._trackPlayers[i];
if (isIOS) {
if (i == 0) now =
track.player.ios.deviceCurrentTime;
(<any>track.player).playAtTime
(now + shortStartDelay);
} else {
track.player.play
();
}
}
}
由于track.player是我们的TNSPlayer实例,我们可以通过其ios获取器直接访问底层原生平台播放器实例(对于 iOS,它是AVAudioPlayer),以直接访问deviceCurrentTime。为了保险起见,我们提供了一个非常短的开始延迟,将其添加到第一首轨道的deviceCurrentTime中,并使用它来精确地同时启动所有轨道,这效果非常好!由于playAtTime没有通过 TypeScript 定义与 nativescript-audio 插件一起发布,我们在调用方法之前简单地将播放器实例(<any>track.player)进行类型转换,以满足 tsc 编译器。由于 Android 没有等效功能,我们将仅使用标准媒体播放器的播放方法,这对于 Android 来说效果很好。
现在让我们用类似的安全措施调整我们的暂停方法:
public pause() {
let currentTime = 0;
for (let i = 0; i <
this._trackPlayers.length; i++) {
let track = this._trackPlayers[i];
if
(i == 0) currentTime = track.player.currentTime;
track.player.pause();
// ensure tracks pause
and remain paused at the same time
track.player.seekTo(currentTime);
}
}
通过使用第一首轨道的currentTime作为速度设置器,我们在我们的混音中暂停每首轨道,并通过在暂停后立即跳转到相同的currentTime来确保它们保持完全相同的时间。这有助于确保当我们恢复播放时,它们都从相同的时间点开始。让我们在下一节中构建自定义穿梭滑块时使用所有这些功能。
创建自定义 ShuttleSliderComponent
如果没有来回穿梭混音的能力,我们就无法拥有多轨录音室体验!让我们加大力度,增强Slider的功能,通过结合 NativeScript 和 Angular 为我们提供的所有最佳选项。在这个过程中,我们的播放器控件将开始变得非常有用。
从高层次开始,打开app/modules/player/components/player-controls/player-controls.component.html并将其替换为以下内容:
<StackLayout row="1" col="0" class="controls">
<shuttle-slider [currentTime]
="currentTime"
[duration]="duration"></shuttle-slider>
<Button
[text]="playStatus" (tap)="togglePlay()"
class="btn btn-primary w-100"></Button>
</StackLayout>
我们正在用StackLayout替换GridLayout,以改变我们的播放器控件布局。让我们使用一个全宽的滑块堆叠在我们的播放/暂停按钮上方。我们追求的效果类似于 iPhone 上的 Apple Music 应用,其中滑块是全宽的,下面显示了当前时间和持续时间。现在,让我们构建我们的自定义shuttle-slider组件,并创建app/modules/player/components/player-controls/shuttle-slider.component.html,内容如下:
<GridLayout #sliderArea rows="auto, auto" columns="auto,*,auto"
class="slider-area">
<Slider
#slider slim-slider minValue="0" [maxValue]="duration"
colSpan="3" class="slider"></Slider>
<Label #currentTimeDisplay text="00:00" class="h4 m-x-5" row="1" col="0">
</Label>
<Label
[text]="durationDisplay" class="h4 text-right m-x-5"
row="1" col="2"></Label>
</GridLayout>
这里将会变得非常有趣。我们将结合有用的 Angular 绑定,例如这些绑定:[maxValue]="duration" 和 [text]="durationDisplay"。然而,对于我们的其他可用性连接,我们希望有更多精细粒度和手动控制。例如,我们的包含 GridLayout 通过 #sliderArea 将是用户能够触摸以穿梭前后而不是 Slider 组件本身的区域,我们将完全禁用用户与 Slider 本身的交互(因此,您看到了 slim-slider 指令属性)。滑块将仅用于其时间的视觉表示。
我们将这样做的原因是我们希望这种交互启动几个程序性操作:
-
在穿梭时暂停播放(如果正在播放)
-
在前后移动时更新当前时间显示标签
-
以受控方式启动轨道播放器实例的
seekTo命令;因此,减少多余的寻求命令 -
如果在尝试穿梭之前正在播放,则在不再穿梭时恢复播放
如果我们使用带有 Angular 绑定到 currentTime 的 Slider,并通过 currentTime$ 可观察对象来控制它,而这个可观察对象又是由我们与它的交互以及我们轨道播放器的状态来控制的,那么这些元素之间的耦合将过于紧密,无法实现我们需要的精细粒度控制。
我们即将要做的事情的美丽之处,作为如何灵活地将 Angular 与 NativeScript 结合的典范证明。让我们从 app/modules/player/components/player-controls/shuttle-slider.component.ts 中的交互编程开始;以下是我们将完全展示的完整设置,我们将在稍后分解:
// angular
import { Component, Input, ViewChild, ElementRef } from '@angular/core';
//
nativescript
import { GestureTypes } from 'ui/gestures';
import { View } from 'ui/core/view';
import { Label
} from 'ui/label';
import { Slider } from 'ui/slider';
import { Observable } from 'data/observable';
import
{ isIOS, screen } from 'platform';
// app
import { PlayerService } from '../../services';
@Component({
moduleId: module.id,
selector: 'shuttle-slider',
templateUrl: 'shuttle-
slider.component.html',
styles: [`
.slider-area {
margin: 10 10 0 10;
}
.slider {
padding:0;
margin:0 0 5 0;
height:5;
}
`]
})
export
class ShuttleSliderComponent {
@Input() currentTime: number;
@Input() duration: number;
@ViewChild('sliderArea') sliderArea: ElementRef;
@ViewChild('slider') slider: ElementRef;
@ViewChild('currentTimeDisplay') currentTimeDisplay: ElementRef;
public durationDisplay: string;
private _sliderArea: View;
private _currentTimeDisplay: Label;
private _slider: Slider;
private
_screenWidth: number;
private _seekDelay: number;
constructor(private playerService: PlayerService) {
}
ngOnChanges() {
if (typeof this.currentTime == 'number') {
this._updateSlider
(this.currentTime);
}
if (this.duration) {
this.durationDisplay =
this._timeDisplay(this.duration);
}
}
ngAfterViewInit() {
this._screenWidth =
screen.mainScreen.widthDIPs;
this._sliderArea = <View>this.sliderArea
.nativeElement;
this._slider = <Slider>this.slider.nativeElement;
this._currentTimeDisplay =
<Label>this.currentTimeDisplay
.nativeElement;
this._setupEventHandlers();
}
private _updateSlider(time: number) {
if (this._slider)
this._slider.value = time;
if (this._currentTimeDisplay)
this._currentTimeDisplay
.text =
this._timeDisplay(time);
}
private _setupEventHandlers() {
this._sliderArea.on
(GestureTypes.touch, (args: any) => {
this.playerService.seeking = true;
let x = args.getX();
if (x >= 0) {
let percent = x / this._screenWidth;
if (percent > .5) {
percent += .05;
}
let seekTo = this.duration * percent;
this._updateSlider
(seekTo);
if (this._seekDelay) clearTimeout(this._seekDelay);
this._seekDelay = setTimeout
(() => {
// android requires milliseconds
this.playerService
.seekTo
(isIOS ? seekTo : (seekTo*1000));
}, 600);
}
});
}
private
_timeDisplay(seconds: number): string {
let hr: any = Math.floor(seconds / 3600);
let min: any =
Math.floor((seconds - (hr * 3600))/60);
let sec: any = Math.floor(seconds - (hr * 3600)
- (min * 60));
if (min < 10) {
min = '0' + min;
}
if (sec < 10){
sec = '0' + sec;
}
return min + ':' + sec;
}
}
对于一个相当小的组件占用空间,这里正在进行着许多精彩的事情!让我们来分解一下。
让我们来看看那些属性装饰器,从 @Input 开始:
@Input() currentTime: number;
@Input() duration: number;
// allows these property bindings to flow into our view:
<shuttle-slider
[currentTime]
="currentTime"
[duration]="duration">
</shuttle-slider>
然后,我们有我们的 @ViewChild 引用:
@ViewChild('sliderArea') sliderArea: ElementRef;
@ViewChild('slider')
slider: ElementRef;
@ViewChild('currentTimeDisplay') currentTimeDisplay: ElementRef;
private _sliderArea: StackLayout;
private _currentTimeDisplay: Label;
private _slider: Slider;// provides us with references to these view components<StackLayout
#sliderArea class="slider-area">
<Slider #slider slim-slider
minValue="0 [maxValue]="duration" class="slider">
</Slider>
<GridLayout rows="auto"
columns="auto,*,auto"
class="m-x-5">
<Label #currentTimeDisplay text="00:00"
class="h4"
row="0" col="0"></Label>
<Label [text]="durationDisplay" class="h4 text-right"
row="0" col="2"></Label>
</GridLayout>
</StackLayout>
我们可以在我们的组件中访问这些 ElementRef 实例以编程方式与之交互;然而,不是立即。由于 ElementRef 是视图组件的代理包装器,其底层的 nativeElement(我们的实际 NativeScript 组件)只有在 Angular 的组件生命周期钩子 ngAfterViewInit 触发后才能访问。
在这里了解 Angular 的组件生命周期钩子:
angular.io/docs/ts/latest/guide/lifecycle-hooks.html.
因此,我们在这里为我们的实际 NativeScript 组件分配私有引用:
ngAfterViewInit() {
*this._screenWidth = screen.mainScreen.widthDIPs;*
this._sliderArea =
<StackLayout>this.sliderArea
.nativeElement;
this._slider = <Slider>this.slider.nativeElement;
this._currentTimeDisplay =
<Label>this.currentTimeDisplay
.nativeElement;
*this._setupEventHandlers();*
}
我们还利用这个机会,通过 platform 模块中的 screen 工具使用 密度无关像素(dip)单位来引用整个屏幕宽度。这将允许我们使用用户在 sliderArea StackLayout 上的手指位置进行一些计算,以调整 Slider 的实际值。然后我们调用设置我们基本事件处理程序。
使用我们的 _sliderArea 引用到的包含 StackLayout,我们添加一个 touch 触摸监听器来捕获用户对滑动区域的任何触摸操作:
private _setupEventHandlers() {
this._sliderArea.on(GestureTypes.touch, (args: any) => {
*this.playerService.seeking = true; // TODO*
let x = args.getX();
if (x >= 0) {
// x percentage of screen left to right
let percent = x / this._screenWidth;
if (percent > .5)
{
percent += .05; // non-precise adjustment
}
let seekTo = this.duration * percent;
this._updateSlider(seekTo);
if (this._seekDelay) clearTimeout(this._seekDelay);
this._seekDelay = setTimeout(() => {
// android requires milliseconds
this.playerService.seekTo(
isIOS ? seekTo : (seekTo*1000));
}, 600);
}
});
}
这允许我们通过 args.getX() 获取他们手指的 X 位置。我们使用这个值除以用户设备屏幕宽度来确定从左到右的百分比。由于我们的计算并不完全精确,当用户通过 50% 标记时,我们会进行微调。这种可用性目前适用于我们的用例,但我们将保留以后改进的选项;然而,现在这完全没问题。
然后,我们将持续时间乘以这个百分比来获取我们的 seekTo 标记,以更新 Slider 的值,以便使用手动精度获取即时的 UI 更新:
private _updateSlider(time: number) {
if (this._slider) this._slider.value = time;
if
(this._currentTimeDisplay)
this._currentTimeDisplay.text = this._timeDisplay(time);
}
在这里,我们实际上是在直接使用 NativeScript 组件,而不涉及 Angular 的绑定或 NgZone。在需要精细粒度和性能控制 UI 的情况下,这可以非常方便。由于我们希望 Slider 轨道随着用户的指尖立即移动,以及时间显示标签以标准音乐时间码格式化以表示他们交互时的实时时间,我们直接在适当的时间设置它们的值。
然后我们使用搜索延迟超时以确保我们不会向多轨播放器发出额外的搜索命令。用户的每次移动都会进一步延迟实际发出搜索命令,直到他们将其放置在想要的位置。我们还使用我们的 isIOS 布尔值根据每个平台音频播放器的需要适当地转换时间(iOS 为秒,Android 为毫秒)。
最有趣的可能就是我们的 ngOnChanges 生命周期钩子:
ngOnChanges() {
if (typeof this.currentTime == 'number') {
this._updateSlider(this.currentTime);
}
if (this.duration) {
this.durationDisplay = this._timeDisplay(this.duration);
}
}
当 Angular 检测到组件(或指令)的 输入属性 发生变化时,它会调用其 ngOnChanges() 方法。
这是一种让 ShuttleSliderComponent 对其 Input 属性变化、currentTime 和 duration 做出反应的绝佳方式。在这里,我们只是在它有效数字触发时,通过 this._updateSlider(this.currentTime) 手动更新我们的滑块和当前时间显示标签。最后,我们还确保更新我们的持续时间显示标签。此方法将在 PlayerService 的 currentTime$ 可观察对象在存在活动订阅的情况下每秒触发时调用。太棒了!哦,别忘了将 ShuttleSliderComponent 添加到 COMPONENTS 数组中,以便与模块一起包含。
现在,我们需要实际实现这一点:
*this.playerService.seeking = true; // TODO*
我们将使用一些更巧妙的可观察操作技巧来处理我们的搜索状态。让我们打开 app/modules/player/services/player.service.ts 中的 PlayerService 并添加以下内容:
...
export class PlayerService {
...
// internal state
private _playing: boolean;
private _seeking: boolean;
private _seekPaused: boolean;
private _seekTimeout: number;
...
constructor(private ngZone: NgZone) {
this.currentTime$ =
Observable.interval(1000)
.switchMap(_ => {
if (this._seeking)
{
return Observable.never();
} else if
(this._longestTrack) {
return Observable.of(
this._standardizeTime(
this._longestTrack.player.currentTime));
} else {
return Observable.of(0);
}
});
}
...
public set seeking(value: boolean) {
this._seeking =
value;
if (this._playing && !this._seekPaused) {
// pause
while seeking
this._seekPaused = true;
this.pause();
}
if (this._seekTimeout) clearTimeout(this._seekTimeout);
this._seekTimeout = setTimeout(() => {
this._seeking = false;
if
(this._seekPaused) {
// resume play
this._seekPaused =
false;
this.play();
}
},
1000);
}
public seekTo(time: number) {
for
(let track of this._trackPlayers) {
track.player.seekTo(time);
}
}
...
我们正在引入三个新的可观察操作符 switchMap、never 和 of,我们需要确保它们也被导入到我们的 app/operators.ts 文件中:
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/switchMap';
import
'rxjs/add/observable/interval';
import 'rxjs/add/observable/never';
import
'rxjs/add/observable/of';
switchMap允许我们的可观察对象根据几个条件切换流,帮助我们管理是否需要currentTime发出更新。显然,在搜索时,我们不需要对currentTime的变化做出反应。因此,当this._seeking为真时,我们将我们的可观察对象流切换到Observable.never(),确保观察者永远不会被调用。
在我们的seekingsetter 中,我们调整内部状态引用(this._seeking),如果它当前是this._playing并且尚未因为搜索而被暂停(因此,!this._seekPaused),我们立即暂停播放(仅一次)。然后,如果搜索开始时正在播放,我们设置另一个超时,在seekTo从组件触发后额外延迟 400 毫秒恢复播放(因此,对this._seekPaused的检查)。
这样,用户可以自由地用手指在我们的穿梭滑块上滑动,他们想滑多远就滑多远,想滑多快就滑多快。他们将在实时中看到Slider轨道的 UI 更新以及当前时间显示标签;在此期间,我们避免发送不必要的seekTo命令到我们的多轨播放器,直到它们停下来,从而提供真正出色的用户体验。
为 iOS 和 Android 原生 API 修改创建 SlimSliderDirective
我们仍然需要为Slider上的slim-slider属性创建一个指令:
<Slider #slider slim-slider minValue="0" [maxValue]="duration"
class="slider"></Slider>
我们将创建特定于平台的指令,因为我们将在 iOS 和 Android 上调用滑块的真正原生 API 来禁用用户交互并隐藏滑块,以实现无缝的外观。
对于 iOS,创建app/modules/player/directives/slider.directive.ios.ts,内容如下:
import { Directive, ElementRef } from '@angular/core';
@Directive({
selector: '[slim-
slider]'
})
export class SlimSliderDirective {
constructor(private el: ElementRef) { }
ngOnInit() {
let uiSlider = <UISlider>this.el.nativeElement.ios;
uiSlider.userInteractionEnabled =
false;
uiSlider.setThumbImageForState(
UIImage.new(), UIControlState.Normal);
}
}
我们通过Slider组件本身的iosgetter 访问底层的原生 iOS UISlider实例。我们使用 Apple 的 API 参考文档(developer.apple.com/reference/uikit/uislider)来定位适当的 API,通过userInteractionEnabled标志禁用交互,并通过将空白设置为滑块来隐藏滑块。完美。
对于 Android,创建app/modules/player/directives/slider.directive.android.ts,内容如下:
import { Directive, ElementRef } from '@angular/core';
@Directive({
selector: '[slim-
slider]'
})
export class SlimSliderDirective {
constructor(private el: ElementRef) { }
ngOnInit() {
let seekBar = <android.widget.SeekBar>this.el
.nativeElement.android;
seekBar.setOnTouchListener(
new android.view.View.OnTouchListener({
onTouch(view, event) {
return true;
}
})
);
seekBar.getThumb().mutate().setAlpha(0);
}
}
我们通过Slider组件上的androidgetter 访问原生android.widget.SeekBar实例。我们使用 Android 的 API 参考文档(developer.android.com/reference/android/widget/SeekBar.html)来定位 SeekBar 的 API,通过重写OnTouchListener来禁用用户交互,并通过将 Drawable alpha 设置为 0 来隐藏滑块。
现在,创建app/modules/player/directives/slider.directive.d.ts:
export declare class SlimSliderDirective { }
这将允许我们导入并使用我们的SlimSlider类作为标准的 ES6 模块;创建app/modules/player/directives/index.ts:
import { SlimSliderDirective } from './slider.directive';
export const DIRECTIVES: any[] = [
SlimSliderDirective
];
在运行时,NativeScript 只会将适当的平台特定文件构建到目标平台中,完全排除不适用代码。这是在代码库中创建平台特定功能的一种非常强大的方式。
最后,让我们确保我们的指令已声明在PlayerModule中,位于app/modules/player/player.module.ts,以下是一些更改:
...
import { DIRECTIVES } from './directives';
...
@NgModule({
...
declarations: [
...COMPONENTS,
...DIRECTIVES
],
...
})
export class PlayerModule { }
我们现在应该在 iOS 上看到这一点,我们的播放暂停在 6 秒处:
对于 Android,情况如下:
你现在可以观察到以下情况:
-
所有三个轨道以完美的混音一起播放
-
不论是否正在播放,都可以通过滑块切换播放
-
播放/暂停切换
-
当播放结束时,我们的控件会正确重置
并且这一切在 iOS 和 Android 上都能正常工作。毫无疑问,这是一项了不起的成就。
摘要
现在,我们已经完全沉浸在 NativeScript 的丰富世界中,因为我们已经介绍了插件集成以及 iOS 和 Android 上对原生 API 的直接访问。更不用说,我们还有一个非常棒的具有完整播放控制的多人轨播放器,包括在混音中切换!
包括其 RxJS 可观察性基础在内的 Angular 的激动人心的组合,现在开始真正闪耀,我们能够在需要的地方利用视图绑定,并使用强大的可观察性组合对服务事件流做出反应,同时仍然保留手动以细粒度控制我们的 UI 的能力。无论是我们的视图需要 Angular 指令来丰富其功能,还是通过原始 NativeScript 功能的手动触摸手势控制,我们现在都可以轻松掌握。
事实上,我们一直在构建一个完全本地的 iOS 和 Android 应用程序,这确实令人震惊。
在下一章中,我们将继续深入挖掘原生 API 和插件,随着我们将录音功能引入我们的应用程序,以满足多轨录音室移动应用程序的核心需求。
第八章:构建 Audio Recorder
录音是我们应用必须处理的性能最密集的操作。它也是拥有访问原生 API 将最有回报的功能之一。我们希望用户能够以尽可能低的延迟记录,以实现最高的音质保真度。此外,此录音可以选择性地在现有的同步播放的预录音轨混合之上发生。
由于我们应用开发的这一阶段将最深入地挖掘平台特定的原生 API,我们将我们的实现分为两个阶段。我们首先构建记录功能的 iOS 特定细节,然后是 Android。
在本章中,我们将涵盖以下内容:
-
使用一致的 API 为 iOS 和 Android 构建功能丰富的跨平台音频录音器
-
集成 iOS 框架库,例如完全用 Swift 构建的 AudioKit (
audiokit.io) -
如何将 Swift/Objective C 方法转换为 NativeScript
-
基于 native APIs 构建 custom reusable NativeScript 视图组件,以及如何在 Angular 中使用它们
-
配置一个可重用的 Angular 组件,该组件可以通过路由使用,也可以通过弹出模态打开
-
集成 Android Gradle 库
-
如何将 Java 方法转换为 NativeScript
-
使用 NativeScript 的 ListView 与多个项目模板
第一阶段 – 为 iOS 构建音频录音器
iOS 平台的音频功能令人印象深刻。一群才华横溢的音频爱好者和软件工程师合作,在平台的音频堆栈之上构建了一个开源框架层。这一世界级的工程努力是令人敬畏的 AudioKit (audiokit.io/),由无畏的 Aurelius Prochazka 领导,他是音频技术的真正先驱。
AudioKit 框架完全用 Swift 编写,当与 NativeScript 集成时,引入了一些有趣的表面级挑战。
挑战绕行 – 将基于 Swift 的库集成到 NativeScript 中
在撰写本文时,如果代码库通过所谓的桥接头正确地将类和类型暴露给 Objective-C,NativeScript 可以与 Swift 一起工作。你可以在这里了解更多关于桥接头的信息:developer.apple.com/library/content/documentation/Swift/Conceptual/BuildingCocoaApps/MixandMatch.html。 这个桥接头在 Swift 代码库编译成框架时自动生成。Swift 提供了丰富的语言特性,其中一些与 Objective C 没有直接关联。然而,在撰写本文时,可能需要考虑一些因素,尽管 NativeScript 最终可能会提供对最新 Swift 语言增强的全面支持。
AudioKit 利用 Swift 语言所能提供的最佳特性,包括增强的 enum 功能。你可以在这里了解更多关于 Swift 语言中扩展的 enum 功能:
尤其是在文档中有这样的描述:“它们采用了许多传统上仅由类支持的特性,例如计算属性,以提供有关枚举当前值的额外信息,以及实例方法,以提供与枚举表示的值相关的功能。”
这种 enum 在 Objective C 中是陌生的,因此不能在桥接头中提供。任何使用 Swift 的异构 enum 的代码在编译时生成桥接头时将被简单地忽略,导致 Objective C 无法与这些代码部分交互。这意味着你将无法在 NativeScript 中使用 Swift 代码库中的方法,这些方法直接使用这些增强结构(在撰写本文时)。
为了解决这个问题,我们将分叉 AudioKit 框架,并将 AKAudioFile 扩展文件中使用的异构枚举扁平化,这些扩展文件提供了一个强大且方便的导出方法,我们希望用它来保存我们的录音音频文件。我们需要修改的异构 enum 看起来是这样的 (github.com/audiokit/AudioKit/blob/master/AudioKit/Common/Internals/Audio%20File/AKAudioFile%2BProcessingAsynchronously.swift):
// From AudioKit's Swift 3.x codebase
public enum ExportFormat {
case wav
case aif
case mp4
case m4a
case caf
fileprivate var UTI: CFString {
switch self {
case .wav:
return AVFileTypeWAVE as CFString
case .aif:
return AVFileTypeAIFF as CFString
case .mp4:
return AVFileTypeAppleM4A as CFString
case .m4a:
return AVFileTypeAppleM4A as CFString
case .caf:
return AVFileTypeCoreAudioFormat as CFString
}
}
static var supportedFileExtensions: [String] {
return ["wav", "aif", "mp4", "m4a", "caf"]
}
}
这与您可能熟悉的任何枚举不同;如您所见,它除了枚举拥有的属性外还包括其他属性。当此代码编译并生成桥接头与 Objective-C 混合或匹配时,桥接头将排除使用此构造的任何代码。我们将将其扁平化,如下所示:
public enum ExportFormat: Int {
case wav
case aif
case mp4
case m4a
case caf
}
static public func stringUTI(type: ExportFormat) -> CFString {
switch type {
case .wav:
return AVFileTypeWAVE as CFString
case .aif:
return AVFileTypeAIFF as CFString
case .mp4:
return AVFileTypeAppleM4A as CFString
case .m4a:
return AVFileTypeAppleM4A as CFString
case .caf:
return AVFileTypeCoreAudioFormat as CFString
}
}
static public var supportedFileExtensions: [String] {
return ["wav", "aif", "mp4", "m4a", "caf"]
}
我们将调整AKAudioFile扩展的部分,以使用我们的扁平化属性。这将允许我们手动构建可以在应用中使用的AudioKit.framework,暴露我们想要使用的方法:exportAsynchronously。
我们不会详细介绍手动构建AudioKit.framework的细节,因为它在这里有很好的文档:github.com/audiokit/AudioKit/blob/master/Frameworks/INSTALL.md#building-universal-frameworks-from-scratch。使用我们自定义构建的框架,我们现在已准备好将其集成到我们的应用中。
将自定义构建的 iOS 框架集成到 NativeScript
我们现在可以创建一个内部插件,将这个 iOS 框架集成到我们的应用中。将我们构建的定制AudioKit.framework放入我们应用的根目录下创建一个nativescript-audiokit目录。然后我们在其中添加一个platforms/ios文件夹,将框架放入。这将让 NativeScript 知道如何将这些 iOS 特定文件构建到应用中。由于我们希望这个内部插件像任何标准 npm 插件一样被处理,我们还将直接在nativescript-audiokit文件夹中添加package.json文件,内容如下:
{
"name": "nativescript-audiokit",
"version": "1.0.0",
"nativescript": {
"platforms": {
"ios": "3.0.0"
}
}
}
我们现在将使用以下命令将其添加到我们的应用中(NativeScript 将首先在本地查找,然后找到nativescript-audiokit插件):
tns plugin add nativescript-audiokit
这将正确地将自定义构建的 iOS 框架添加到我们的应用中。
然而,我们还需要两个非常重要的项目:
- 由于 AudioKit 是基于 Swift 的框架,我们希望确保我们的应用包含适当的支持 Swift 库。添加一个新文件,
nativescript-audiokit/platforms/ios/build.xcconfig:
EMBEDDED_CONTENT_CONTAINS_SWIFT = true
-
由于我们将使用用户的麦克风,我们希望确保在应用属性列表中指示麦克风的使用。我们也将借此机会添加两个额外的属性设置来增强我们应用的能力。因此,总共我们将添加三个属性键,用于以下目的:
-
让设备知道我们的应用需要访问麦克风,并确保在首次访问时请求用户的权限。
-
如果应用被放置到后台,继续播放音频。
-
当手机连接到计算机时,提供查看应用
documents文件夹的能力。这将允许您通过应用文档直接在 iTunes 中查看记录的文件。这对于集成到桌面音频编辑软件可能很有用。
-
添加一个新文件,nativescript-audiokit/platforms/ios/Info.plist,内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>Requires access to microphone.</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
</dict>
</plist>
这里有一个截图,更好地说明了我们应用中内部插件的架构:
现在,当 NativeScript 构建 iOS 应用时,它将确保 AudioKit.framework 被包含为一个库,并将 build.xcconfig 和 Info.plist 的内容合并到我们的应用配置中。每次我们更改这个内部插件文件夹(nativescript-audiokit)内的文件时,我们都想确保我们的应用能够获取这些更改。为此,我们可以简单地移除并重新添加插件,所以现在让我们这么做:
tns plugin remove nativescript-audiokit
tns plugin add nativescript-audiokit
现在,我们准备使用 iOS 的 AudioKit API 来构建我们的音频录制器。
设置原生 API 类型检查并生成 AudioKit TypeScript 定义
我们首先想做的事情是安装 tns-platform-declarations:
npm i tns-platform-declarations --save-dev
现在,我们在项目的根目录中创建一个名为 references.d.ts 的新文件,其内容如下:
/// <reference path="./node_modules/tns-platform-declarations/ios.d.ts" />
/// <reference path="./node_modules/tns-platform-declarations/android.d.ts" />
这为我们提供了对 iOS 和 Android API 的完整类型检查和智能感知支持。
现在,我们想要为 AudioKit 框架本身生成类型定义。我们可以执行以下命令来生成包含的 AudioKit.framework 的类型定义:
TNS_TYPESCRIPT_DECLARATIONS_PATH="$(pwd)/typings" tns build ios
我们正在设置环境变量 TNS_TYPESCRIPT_DECLARATIONS_PATH 为当前工作目录(pwd),并带有 typings 文件夹前缀。当 NativeScript 创建 iOS 构建时,它还将为我们的应用可用的所有原生 API 生成类型定义文件,包括第三方库。我们现在将看到在项目中出现一个 typings 文件夹,其中包含两个文件夹:i386 和 x86_64。一个是用于模拟器架构的,另一个是用于设备的。两者都将包含相同的内容,所以我们可以只关注其中一个。打开 i386 文件夹,你会找到一个 objc!AudioKit.d.ts 文件。
我们只想使用那个文件,所以将其移动到 typings 文件夹的根目录:typings/objc!AudioKit.d.ts。然后我们可以删除 i386 和 x86_64 文件夹,因为我们不再需要它们(其他 API 定义文件通过 tns-platform-declarations 提供)。我们只是生成这些类型定义来获取 AudioKit 库的类型定义。这是一次性的事情,为了方便与这个本地库集成,所以你可以安全地将这个自定义 typings 文件夹添加到源控制中。
请再次检查 tsconfig.json 并确保已启用 "skipLibCheck": true 选项。我们现在可以修改我们的 references.d.ts 文件,以包含 AudioKit 库的附加类型:
/// <reference path="./node_modules/tns-platform-declarations/ios.d.ts" />
/// <reference path="./node_modules/tns-platform-declarations/android.d.ts" />
/// <reference path="./typings/objc!AudioKit.d.ts" />
我们的项目结构现在应该看起来像这样:
使用 AudioKit 构建录制器
我们将首先创建一个模型,围绕我们与 AudioKit 记录 API 的交互。你可以直接从你的 Angular 组件或服务开始直接编写针对这些 API 的代码,但由于我们希望提供跨 iOS 和 Android 的一致 API,所以有一个更智能的方式来设计这个架构。相反,我们将抽象出一个简单的 API,可以在两个平台上使用,它将在底层调用正确的原生实现。
这里将会有很多与 AudioKit 相关的有趣细节,但请创建 app/modules/recorder/models/record.model.ts,如下所示,我们将在稍后解释一些细节:
之后,我们将为此模型添加 .ios.ts 后缀,因为它将包含 iOS 特定的实现细节。然而,在第一阶段,我们将直接使用该模型(省略平台后缀)来开发我们的 iOS 录音器。
import { Observable } from 'data/observable';
import { knownFolders } from 'file-system';
// all available states for the recorder
export enum RecordState {
readyToRecord,
recording,
readyToPlay,
playing,
saved,
finish
}
// available events
export interface IRecordEvents {
stateChange: string;
}
// for use when saving files
const documentsFilePath = function(filename: string) {
return `${knownFolders.documents().path}/${filename}`;
}
export class RecordModel extends Observable {
// available events to listen to
private _events: IRecordEvents;
// control nodes
private _mic: AKMicrophone;
private _micBooster: AKBooster;
private _recorder: AKNodeRecorder;
// mixers
private _micMixer: AKMixer;
private _mainMixer: AKMixer;
// state
private _state: number = RecordState.readyToRecord;
// the final saved path to use
private _savedFilePath: string;
constructor() {
super();
// setup the event names
this._setupEvents();
// setup recording environment
// clean any tmp files from previous recording sessions
(<any>AVAudioFile).cleanTempDirectory();
// audio setup
AKSettings.setBufferLength(BufferLength.Medium);
try {
// ensure audio session is PlayAndRecord
// allows mixing with other tracks while recording
AKSettings.setSessionWithCategoryOptionsError(
SessionCategory.PlayAndRecord,
AVAudioSessionCategoryOptions.DefaultToSpeaker
);
} catch (err) {
console.log('AKSettings error:', err);
}
// setup mic with it's own mixer
this._mic = AKMicrophone.alloc().init();
this._micMixer = AKMixer.alloc().init(null);
this._micMixer.connect(this._mic);
// Helps provide mic monitoring when headphones are plugged in
this._micBooster = AKBooster.alloc().initGain(<any>this._micMixer, 0);
try {
// recorder takes the micMixer input node
this._recorder = AKNodeRecorder.alloc()
.initWithNodeFileError(<any>this._micMixer, null);
} catch (err) {
console.log('AKNodeRecorder init error:', err);
}
// overall main mixer uses micBooster
this._mainMixer = AKMixer.alloc().init(null);
this._mainMixer.connect(this._micBooster);
// single output set to mainMixer
AudioKit.setOutput(<any>this._mainMixer);
// start the engine!
AudioKit.start();
}
public get events(): IRecordEvents {
return this._events;
}
public get mic(): AKMicrophone {
return this._mic;
}
public get recorder(): AKNodeRecorder {
return this._recorder;
}
public get audioFilePath(): string {
if (this._recorder) {
return this._recorder.audioFile.url.absoluteString;
}
return '';
}
public get state(): number {
return this._state;
}
public set state(value: number) {
this._state = value;
// always emit state changes
this._emitEvent(this._events.stateChange, this._state);
}
public get savedFilePath() {
return this._savedFilePath;
}
public set savedFilePath(value: string) {
this._savedFilePath = value;
if (this._savedFilePath)
this.state = RecordState.saved;
}
public toggleRecord() {
if (this._state !== RecordState.recording) {
// just force ready to record
// when coming from any state other than recording
this.state = RecordState.readyToRecord;
if (this._recorder) {
try {
// resetting (clear previous recordings)
this._recorder.resetAndReturnError();
} catch (err) {
console.log('Recorder reset error:', err);
}
}
}
switch (this._state) {
case RecordState.readyToRecord:
if (AKSettings.headPhonesPlugged) {
// Microphone monitoring when headphones plugged
this._micBooster.gain = 1;
}
try {
this._recorder.recordAndReturnError();
this.state = RecordState.recording;
} catch (err) {
console.log('Recording failed:', err);
}
break;
case RecordState.recording:
this.state = RecordState.readyToPlay;
this._recorder.stop();
// Microphone monitoring muted when playing back
this._micBooster.gain = 0;
break;
}
}
public togglePlay() {
if (this._state === RecordState.readyToPlay) {
this.state = RecordState.playing;
} else {
this.stopPlayback();
}
}
public stopPlayback() {
if (this.state !== RecordState.recording) {
this.state = RecordState.readyToPlay;
}
}
public save() {
let fileName = `recording-${Date.now()}.m4a`;
this._recorder.audioFile
.exportAsynchronouslyWithNameBaseDirExportFormatFromSampleToSampleCallback(
fileName, BaseDirectory.Documents, ExportFormat.M4a, null, null,
(af: AKAudioFile, err: NSError) => {
this.savedFilePath = documentsFilePath(fileName);
});
}
public finish() {
this.state = RecordState.finish;
}
private _emitEvent(eventName: string, data?: any) {
let event = {
eventName,
data,
object: this
};
this.notify(event);
}
private _setupEvents() {
this._events = {
stateChange: 'stateChange'
};
}
}
RecordModel 将表现得有点像状态机,它可能处于以下状态之一:
-
readyToRecord: 默认起始状态。必须处于此状态才能进入录音状态。 -
recording: 工作室安静!正在录音中。 -
readyToPlay: 用户停止了录音,现在有一个可以与混音一起播放的录音文件。 -
playing: 用户正在播放带有混音的录音文件。 -
saved: 用户选择保存录音,这将启动保存新曲目与活动组合的操作。 -
finish: 保存操作完成后,记录器应关闭。
我们接着定义记录器将通过 IRecordEvents 提供的事件形状。在这种情况下,我们将有一个单独的事件,stateChange,当状态改变时会通知任何监听者(参见状态设置器)。我们的模型将扩展 NativeScript 的 Observable 类(因此,RecordModel extends Observable),这将为我们提供通知 API 来分发我们的事件。
我们接着设置了对我们将使用的各种 AudioKit 组件的多个引用。大部分设计直接来自这个 AudioKit 录音示例:github.com/audiokit/AudioKit/blob/master/Examples/iOS/RecorderDemo/RecorderDemo/ViewController.swift。我们甚至使用了相同的状态枚举设置(添加了一些额外的设置)。在他们示例中,使用了 AudioKit 的 AKAudioPlayer 进行播放;但,在我们的设计中,我们将加载我们的录音文件到我们的多轨播放器设计中以进行播放。我们可以在 iOS 的 TrackPlayerModel 中工作 AKAudioPlayer;但,TNSPlayer(来自 nativescript-audio 插件)是跨平台兼容的,并且可以正常工作。我们将在稍后详细说明如何将这些新录音文件加载到我们的设计中,但通知监听者记录器的状态将为我们提供处理所有这些所需的所有灵活性。
你可能会想知道为什么我们进行类型转换:
(<any>AVAudioFile).cleanTempDirectory();
好问题。AudioKit 为 Core Foundation 类如 AVAudioFile 提供扩展。这些在 Objective-C 中被称为 Categories:developer.apple.com/library/content/documentation/General/Conceptual/DevPedia-CocoaCore/Category.html;然而,在 Swift 中,它们被称为 Extensions:developer.apple.com/library/content/documentation/Swift/Conceptual/Swift_Programming_Language/Extensions.html。
如果你还记得,我们为 AudioKit 生成 TypeScript 定义;但我们只保留了 objc!AudioKit.d.ts 文件以供参考。如果我们查看基础定义,我们会看到对 AVAudioFile 的扩展。然而,由于我们没有保留这些定义,而是依赖于默认的 tns-platform-declarations 定义,这个 Extension 对我们的 TypeScript 编译器来说是未知的,所以我们简单地进行了类型转换,因为我们知道 AudioKit 提供了这个。
同样重要的是 RecordModel 将音频会话设置为 PlayAndRecord,因为这将允许我们在播放混音的同时进行录音:
AKSettings.setSessionWithCategoryOptionsError(
SessionCategory.PlayAndRecord,
AVAudioSessionCategoryOptions.DefaultToSpeaker
);
你可能也好奇为什么一些类使用 init() 而另一些使用 init(null):
this._mic = AKMicrophone.alloc().init();
this._micMixer = AKMixer.alloc().init(null);
this._micMixer.connect(this._mic);
AudioKit 类的一些初始化器接受一个可选参数,例如,AKMixer 接受一个可选的 NSArray 参数,用于连接 AVAudioNode。然而,我们的 TypeScript 定义将这些参数定义为必需的,所以我们只是传递 null 给这个参数,并直接使用 connect 节点 API。
如何将 Swift/ObjC 方法转换为 NativeScript
来自 RecordModel 的最后一个有趣点可能是 save 方法,它将我们的录音从应用的 tmp 目录导出到应用的 documents 文件夹,同时将其转换为更小的文件大小 .m4a 音频格式:
this._recorder.audioFile
.exportAsynchronouslyWithNameBaseDirExportFormatFromSampleToSampleCallback(
fileName, BaseDirectory.Documents, ExportFormat.M4a, null, null,
(af: AKAudioFile, err: NSError) => {
this.savedFilePath = documentsFilePath(fileName);
});
方法名很长,对吧?是的,确实如此;一些 Swift/ObjC 参数化方法名在合并时会变得非常长。Swift 中定义的特定方法如下:
exportAsynchronously(name:baseDir:exportFormat:fromSample:toSample:callback:)
// converted to NativeScript:
exportAsynchronouslyWithNameBaseDirExportFormatFromSampleToSampleCallback
由于我们为 AudioKit 生成 TypeScript 定义,它们在这里帮了我们大忙。然而,有时你并没有这样的便利。在 Swift/ObjC 方法中,各种参数在方法名开始和参数名开始之间添加 With 时会合并在一起,并且在合并时首字母大写。
为原生音频波形显示构建自定义可重用 NativeScript 视图
我们不会为我们的波形显示创建 Angular 组件,而是创建一个自定义的 NativeScript 视图组件,该组件可以访问原生 API,然后我们可以将其注册到 Angular 中,以便在我们的组件中使用。这样做的原因是由于 NativeScript 强大的view基类,我们可以扩展它,它为使用底层的原生 API 提供了良好的 API。这个波形显示将与我们刚刚创建的RecordModel协同工作,以实现设备麦克风的实时波形反馈显示。这也将非常棒,可以将这个波形显示作为我们的曲目列表上的静态音频文件波形渲染,作为我们主要合成视图的备用视图。AudioKit 提供了类和 API 来完成所有这些。
由于我们希望能够在我们的应用程序的任何地方使用它,我们将在共享模块目录中创建它;然而,请注意,它可以存在于任何地方。在这里这并不那么重要,因为这不是一个需要在NgModule中声明的 Angular 组件。此外,由于这将专门与原生 API 一起工作,让我们在新的native文件夹中创建它,以可能容纳其他 NativeScript 特定的视图组件。
创建app/modules/shared/native/waveform.ts,内容如下,我们将在稍后解释:
import { View, Property } from 'ui/core/view';
import { Color } from 'color';
// Support live microphone display as well as static audio file renders
type WaveformType = 'mic' | 'file';
// define properties
export const plotColorProperty = new Property<Waveform, string>({ name: 'plotColor' });
export const plotTypeProperty = new Property<Waveform, string>({ name: 'plotType' });
export const fillProperty = new Property<Waveform, string>({ name: 'fill' });
export const mirrorProperty = new Property<Waveform, string>({ name: 'mirror' });
export interface IWaveformModel {
readonly target: any;
dispose(): void;
}
export class Waveform extends View {
private _model: IWaveformModel;
private _type: WaveformType;
public set type(value: WaveformType) {
this._type = value;
}
public get type() {
return this._type;
}
public set model(value: IWaveformModel) {
this._model = value;
}
public get model() {
return this._model;
}
createNativeView() {
switch (this.type) {
case 'mic':
this.nativeView = AKNodeOutputPlot.alloc()
.initFrameBufferSize(this._model.target, CGRectMake(0, 0, 0, 0), 1024);
break;
case 'file':
this.nativeView = EZAudioPlot.alloc().init();
break;
}
return this.nativeView;
}
initNativeView() {
if (this._type === 'file') {
// init file with the model's target
// target should be absolute url to path of file
let file = EZAudioFile.alloc()
.initWithURL(NSURL.fileURLWithPath(this._model.target));
// render the file's data as a waveform
let data = file.getWaveformData();
(<EZAudioPlot>this.nativeView)
.updateBufferWithBufferSize(data.buffers[0], data.bufferSize);
}
}
disposeNativeView() {
if (this.model && this.model.dispose) this.model.dispose();
}
plotColorProperty.setNative {
this.nativeView.color = new Color(value).ios;
}
fillProperty.setNative {
this.nativeView.shouldFill = value === 'true';
}
mirrorProperty.setNative {
this.nativeView.shouldMirror = value === 'true';
}
plotTypeProperty.setNative {
switch (value) {
case 'buffer':
this.nativeView.plotType = EZPlotType.Buffer;
break;
case 'rolling':
this.nativeView.plotType = EZPlotType.Rolling;
break;
}
}
}
// register properties with it's type
plotColorProperty.register(Waveform);
plotTypeProperty.register(Waveform);
fillProperty.register(Waveform);
mirrorProperty.register(Waveform);
我们正在使用 NativeScript 的Property类创建几个属性,这将大大方便通过视图绑定属性公开原生视图属性。使用Property类定义这些属性的一个便利之处在于,这些设置器只会在nativeView定义时被调用,避免了双重调用的属性设置器(一个是通过纯 JS 属性设置器,这是另一种选择,以及当底层的nativeView准备好时的另一个设置器)。
当你想通过自定义组件公开可以绑定到视图的本地视图属性时,为它们定义几个Property类,并引用你希望用于视图绑定的名称。
// define properties
export const plotColorProperty = new Property<Waveform, string>({ name: 'plotColor' });
export const plotTypeProperty = new Property<Waveform, string>({ name: 'plotType' });
export const fillProperty = new Property<Waveform, string>({ name: 'fill' });
export const mirrorProperty = new Property<Waveform, string>({ name: 'mirror' });
通过设置这些Property实例,我们现在可以在我们的视图组件类中这样做:
plotColorProperty.setNative {
this.nativeView.color = new Color(value).ios;
}
这只会在nativeView准备好时调用一次,这正是我们想要的。你可以在这个由核心团队成员 Alex Vakrilov 撰写的草稿中了解更多关于这个特定语法和记法的信息:
gist.github.com/vakrilov/ca888a1ea410f4ea7a4c7b2035e06b07#registering-the-property.
然后,在我们的类定义之后,我们在Property实例中注册了该类:
// register properties
plotColorProperty.register(Waveform);
plotTypeProperty.register(Waveform);
fillProperty.register(Waveform);
mirrorProperty.register(Waveform);
好的,解释到这里,让我们来看看这个实现的其他元素。
我们还引入了一个有用的接口,我们将在稍后将其应用于RecordModel:
export interface IWaveformModel {
readonly target: any;
dispose(): void;
}
这将帮助定义其他模型要实现的形状,确保它们符合波形显示期望的 API:
-
target:定义了与本地类一起使用的键输入。 -
dispose(): 每个模型都应该提供此方法来处理视图销毁时的任何清理操作。
这是自定义 NativeScript 3.x 视图生命周期调用执行顺序:
-
createNativeView():AnyNativeView;// 创建你的原生视图。 -
initNativeView():void;// 初始化你的原生视图。 -
disposeNativeView():void;// 清理你的原生视图。
从 NativeScript 的 View 类中重写的 createNativeView 方法可能是最有趣的:
createNativeView() {
switch (this.type) {
case 'mic':
this.nativeView = AKNodeOutputPlot.alloc()
.initFrameBufferSize(this._model.target, CGRectMake(0, 0, 0, 0), 1024);
break;
case 'file':
this.nativeView = EZAudioPlot.alloc().init();
break;
}
return this.nativeView;
}
在这里,我们允许 type 属性确定应该渲染哪种类型的 Waveform 显示。
在 mic 的情况下,我们利用 AudioKit 的 AKNodeOutputPlot(实际上在底层扩展了 EZAudioPlot)来使用我们模型的目标初始化一个波形(即 audioplot),这将最终成为我们的 RecordModel 的麦克风。
在 file 的情况下,我们直接利用 AudioKit 的 EZAudioPlot 创建一个表示音频文件的静态波形。
initNativeView 方法,也是从 NativeScript 的 View 类中重写的,在其生命周期中第二个被调用,并提供了一种初始化你的原生视图的方式。你可能会发现我们在这里再次调用了设置器。设置器在通过 XML 设置组件绑定并实例化类时首先被调用,即在 createNativeView 和 initNativeView 被调用之前。这就是为什么我们在私有引用中缓存值的原因。然而,我们还想让这些设置器在 Angular 的视图绑定(当动态变化时)中修改 nativeView,这就是为什么我们在设置器中也有 if (this.nativeView) 的原因,以便在可用时动态更改 nativeView。
当 View 被销毁时,会调用 disposeNativeView 方法(正如你所猜到的,这也是从 View 类的 {N} 中重写的),这是调用模型 dispose 方法的位置,如果有的话。
将自定义 NativeScript 视图集成到我们的 Angular 应用中
要在 Angular 中使用我们的 NativeScript Waveform 视图,我们首先需要注册它。你可以在根模块、根应用程序组件或另一个在启动时初始化的地方(通常不在懒加载模块中)做这件事。为了整洁,我们将在同一目录下的 SharedModule 中注册它,所以请在 app/modules/shared/shared.module.ts 中添加以下内容:
...
// register nativescript custom components
import { registerElement } from 'nativescript-angular/element-registry';
import { Waveform } from './native/waveform';
registerElement('Waveform', () => Waveform);
...
@NgModule({...
export class SharedModule {...
registerElement 方法允许我们定义在 Angular 组件中想要使用的组件名称,作为第一个参数,并接受一个解析函数,该函数应该返回用于它的 NativeScript View 类。
现在我们来使用我们新的 IWaveformModel 并清理一些 RecordModel 以便使用它,同时为创建我们的 Android 实现(即将推出!)做准备。让我们将 RecordModel 中的几件事情重构到一个公共文件中,以便在 iOS 和 Android(即将推出!)模型之间共享代码。
创建 app/modules/recorder/models/record-common.ts:
import { IWaveformModel } from '../../shared/native/waveform';
import { knownFolders } from 'file-system';
export enum RecordState {
readyToRecord,
recording,
readyToPlay,
playing,
saved,
finish
}
export interface IRecordEvents {
stateChange: string;
}
export interface IRecordModel extends IWaveformModel {
readonly events: IRecordEvents;
readonly recorder: any;
readonly audioFilePath: string;
state: number;
savedFilePath: string;
toggleRecord(): void;
togglePlay(startTime?: number, when?: number): void;
stopPlayback(): void;
save(): void;
finish(): void;
}
export const documentsFilePath = function(filename: string) {
return `${knownFolders.documents().path}/${filename}`;
}
这包含了RecordModel顶部的几乎所有内容,增加了IRecordModel接口,该接口扩展了IWaveformModel。由于我们已经构建了 iOS 实现,我们现在有一个模型形状,我们希望我们的 Android 实现遵循这个形状。将这个形状抽象成一个接口将为我们提供一个清晰的路径,当我们暂时转移到 Android 时可以遵循。
为了方便起见,我们还在app/modules/recorder/models/index.ts中为我们的模型创建了一个索引,这将也会暴露这个常用文件:
export * from './record-common.model';
export * from './record.model';
我们现在可以修改RecordModel以导入这些常用项,以及实现这个新的IRecordModel接口。由于这个新接口也扩展了IWaveformModel,它将立即告诉我们我们需要实现readonly target获取器和dispose()方法,这是与我们的波形视图一起使用所必需的:
import { Observable } from 'data/observable';
import { IRecordModel, IRecordEvents, RecordState, documentsFilePath } from './common';
export class RecordModel extends Observable implements IRecordModel {
...
public get target() {
return this._mic;
}
public dispose() {
AudioKit.stop();
// cleanup
this._mainMixer = null;
this._recorder = null;
this._micBooster = null;
this._micMixer = null;
this._mic = null;
// clean out tmp files
(<any>AVAudioFile).cleanTempDirectory();
}
...
RecordModel的target将是波形视图将使用的麦克风。我们的dispose方法将在进行参考清理的同时停止 AudioKit 引擎,并确保清理在录音过程中创建的任何临时文件。
创建录音视图布局
当用户在应用程序右上角点击录音时,它会提示用户进行身份验证,然后应用程序路由到录音视图。此外,如果组合中包含轨道,最好在模态弹出窗口中重用这个录音视图以显示,这样用户就不会感觉在录音时离开了组合。然而,当组合是新的时,通过路由导航到录音视图是完全可以的。我们将展示如何做到这一点,但首先让我们使用新的花哨的波形视图和我们的强大新RecordModel来设置我们的布局。
在app/modules/recorder/components/record.component.html中添加以下内容:
<ActionBar title="Record" icon="" class="action-bar">
<NavigationButton visibility="collapsed"></NavigationButton>
<ActionItem text="Cancel"
ios.systemIcon="1" android.systemIcon="ic_menu_back"
(tap)="cancel()"></ActionItem>
</ActionBar>
<FlexboxLayout class="record">
<GridLayout rows="auto" columns="auto,*,auto" class="p-10" *ngIf="isModal">
<Button text="Cancel" (tap)="cancel()"
row="0" col="0" class="c-white"></Button>
</GridLayout>
<Waveform class="waveform"
[model]="recorderService.model"
type="mic"
plotColor="yellow"
fill="false"
mirror="true"
plotType="buffer">
</Waveform>
<StackLayout class="p-5">
<FlexboxLayout class="controls">
<Button text="Rewind" class="btn text-center"
(tap)="recorderService.rewind()"
[isEnabled]="state == recordState.readyToPlay || state == recordState.playing">
</Button>
<Button [text]="recordBtn" class="btn text-center"
(tap)="recorderService.toggleRecord()"
[isEnabled]="state != recordState.playing"></Button>
<Button [text]="playBtn" class="btn text-center"
(tap)="recorderService.togglePlay()"
[isEnabled]="state == recordState.readyToPlay || state == recordState.playing">
</Button>
</FlexboxLayout>
<FlexboxLayout class="controls bottom"
[class.recording]="state == recordState.recording">
<Button text="Save" class="btn"
[class.save-ready]="state == recordState.readyToPlay"
[isEnabled]="state == recordState.readyToPlay"
(tap)="recorderService.save()"></Button>
</FlexboxLayout>
</StackLayout>
</FlexboxLayout>
我们使用FlexboxLayout是因为我们希望波形视图能够扩展以覆盖全部可用的垂直空间,只留下录音控制位于底部。FlexboxLayout是一个非常灵活的布局容器,它提供了与网页上 flexbox 模型相同的大多数 CSS 样式属性。
有趣的是,我们只在以模态形式显示时在GridLayout容器内显示一个取消按钮,因为我们需要一个关闭模态的方式。当视图通过模态打开时,会忽略ActionBar并且不显示。
当视图通过模态打开时,会忽略ActionBar,因此在模态中不显示。ActionBar仅在导航视图中显示。
此外,我们的ActionBar设置在这里相当有趣,并且是 iOS 和 Android 在 NativeScript 视图布局中差异最大的领域之一。在 iOS 上,NavigationButton有一个默认行为,会自动从堆栈中弹出视图并动画回到前一个视图。此外,iOS 上NavigationButton上的任何点击事件都被完全忽略,而在 Android 上,点击事件会在NavigationButton上触发。由于这个关键差异,我们想通过使用visibility="collapsed"来完全忽略ActionBar的NavigationButton,以确保它永远不会显示。相反,我们使用具有显式点击事件的ActionItem,以确保在两个平台上都能触发我们组件的正确逻辑。
iOS 和 Android 上的NavigationButton行为不同:
-
iOS:
NavigationButton忽略(点击)事件,当导航到视图时,此按钮默认出现。 -
Android:
NavigationButton(点击)事件被触发。
你可以在这里看到我们使用的波形(自定义 NativeScript)视图。由于它是一个对象,我们使用 Angular 的绑定语法来绑定模型。对于其他属性,我们直接指定它们的值,因为它们是原始值。然而,如果我们想通过用户交互动态更改这些值,我们也可以使用 Angular 的绑定语法。例如,我们可以显示一个有趣的颜色选择器,允许用户实时更改波形的颜色(plotColor)。
我们将为我们的记录组件提供特定的样式表,app/modules/recorder/components/record.component.css:
.record {
background-color: rgba(0,0,0,.5);
flex-direction: column;
justify-content: space-around;
align-items: stretch;
align-content: center;
}
.record .waveform {
background-color: transparent;
order: 1;
flex-grow: 1;
}
.controls {
width: 100%;
height: 200;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
align-items: center;
align-content: center;
}
.controls.bottom {
height: 90;
justify-content: flex-end;
}
.controls.bottom.recording {
background-color: #B0342D;
}
.controls.bottom .btn {
border-radius: 40;
height: 62;
padding: 2;
}
.controls.bottom .btn.save-ready {
background-color: #42B03D;
}
.controls .btn {
color: #fff;
}
.controls .btn[isEnabled=false] {
background-color: transparent;
color: #777;
}
如果你已经在网页上使用过 flexbox 模型,一些 CSS 属性可能看起来很熟悉。一个学习更多关于 flexbox 样式的极好和有趣资源是 Dave Geddes 的 Flexbox Zombies:flexboxzombies.com。
到目前为止,我们的 CSS 开始增长,我们可以用 SASS 清理很多东西。我们将很快做到这一点,所以请耐心等待!
现在,让我们看看app/modules/recorder/components/record.component.ts中的组件:
// angular
import { Component, OnInit, OnDestroy, Optional } from '@angular/core';
// libs
import { Subscription } from 'rxjs/Subscription';
// nativescript
import { RouterExtensions } from 'nativescript-angular/router';
import { ModalDialogParams } from 'nativescript-angular/directives/dialogs';
import { isIOS } from 'platform';
// app
import { RecordModel, RecordState } from '../models';
import { RecorderService } from '../services/recorder.service';
@Component({
moduleId: module.id,
selector: 'record',
templateUrl: 'record.component.html',
styleUrls: ['record.component.css']
})
export class RecordComponent implements OnInit, OnDestroy {
public isModal: boolean;
public recordBtn: string = 'Record';
public playBtn: string = 'Play';
public state: number;
public recordState: any = {};
private _sub: Subscription;
constructor(
private router: RouterExtensions,
@Optional() private params: ModalDialogParams,
public recorderService: RecorderService
) {
// prepare service for brand new recording
recorderService.setupNewRecording();
// use RecordState enum names as reference in view
for (let val in RecordState ) {
if (isNaN(parseInt(val))) {
this.recordState[val] = RecordState[val];
}
}
}
ngOnInit() {
if (this.params && this.params.context.isModal) {
this.isModal = true;
}
this._sub = this.recorderService.state$.subscribe((state: number) => {
this.state = state;
switch (state) {
case RecordState.readyToRecord:
case RecordState.readyToPlay:
this._resetState();
break;
case RecordState.playing:
this.playBtn = 'Pause';
break;
case RecordState.recording:
this.recordBtn = 'Stop';
break;
case RecordState.finish:
this._cleanup();
break;
}
});
}
ngOnDestroy() {
if (this._sub) this._sub.unsubscribe();
}
public cancel() {
this._cleanup();
}
private _cleanup() {
this.recorderService.cleanup();
invokeOnRunLoop(() => {
if (this.isModal) {
this._close();
} else {
this._back();
}
});
}
private _close() {
this.params.closeCallback();
}
private _back() {
this.router.back();
}
private _resetState() {
this.recordBtn = 'Record';
this.playBtn = 'Play';
}
}
/**
* Needed on iOS to prevent this potential exception:
* "This application is modifying the autolayout engine from a background thread after the engine was accessed from the main thread. This can lead to engine corruption and weird crashes."
*/
const invokeOnRunLoop = (function () {
if (isIOS) {
var runloop = CFRunLoopGetMain();
return function(func) {
CFRunLoopPerformBlock(runloop, kCFRunLoopDefaultMode, func);
CFRunLoopWakeUp(runloop);
}
} else {
return function (func) {
func();
}
}
}());
从该文件的底部开始,你可能想知道invokeOnRunLoop是什么东西*.* 这是一种确保线程安全的好方法,在这些情况下,线程可能会露出其丑陋的一面。在这种情况下,AudioKit 的引擎是从RecordModel中的 UI 线程启动的,因为 NativeScript 在 UI 线程上封装了原生调用。然而,当我们的记录视图关闭时(无论是从模态还是导航回退),一些后台线程被调用。用invokeOnRunLoop封装我们处理关闭此视图的方式有助于解决这个短暂的异常。这是如何在 NativeScript 中使用 iOS 的dispatch_async(dispatch_get_main_queue(…))的答案。
在文件中向上工作时,我们会遇到this.recorderService.state$.subscribe((state: number) => …)。在不久的将来,我们将实现一种方法来观察录制state$作为可观察对象,这样我们的视图就可以简单地对其状态变化做出反应。
另一个值得注意的点是,将RecordState枚举折叠到我们可以用作视图绑定的属性中,以与当前状态进行比较(this.state = state;)。
当组件被构建时,recorderService.setupNewRecording()将为每次此视图出现时准备我们的服务进行全新录制。
最后,请注意注入@Optional()private params: ModalDialogParams. 之前,我们提到过在模态弹出窗口中复用这个记录视图会很好。有趣的部分在于ModalDialogParams只有在组件以模态打开时才提供给组件。换句话说,Angular 的依赖注入不知道任何关于ModalDialogParams服务的信息,除非组件通过 NativeScript 的ModalService明确打开,所以这将破坏我们原本设置的将此组件路由到的能力,因为 Angular 的 DI 默认无法识别这样的提供者。为了允许这个组件继续作为路由组件工作,我们将简单地标记该参数为@Optional(),这样当不可用时会将其值设置为 null,而不是抛出依赖注入错误。
这将允许我们的组件被路由到,以及在一个模态中打开!全面复用!
为了通过路由有条件地导航到这个组件,或者以模态打开它,我们可以进行一些小的调整,记住RecorderModule是懒加载的,因此我们希望在打开它作为模态之前懒加载该模块。
打开app/modules/mixer/components/action-bar/action-bar.component.ts并做出以下修改:
// angular
import { Component, Input, Output, EventEmitter } from '@angular/core';
// nativescript
import { RouterExtensions } from 'nativescript-angular/router';
import { PlayerService } from '../../../player/services/player.service';
@Component({
moduleId: module.id,
selector: 'action-bar',
templateUrl: 'action-bar.component.html'
})
export class ActionBarComponent {
...
@Output() showRecordModal: EventEmitter<any> = new EventEmitter();
...
constructor(
private router: RouterExtensions,
private playerService: PlayerService
) { }
public record() {
if (this.playerService.composition &&
this.playerService.composition.tracks.length) {
// display recording UI as modal
this.showRecordModal.next();
} else {
// navigate to it
this.router.navigate(['/record']);
}
}
}
在这里,如果组合中包含轨道,我们使用带有组件装饰器Output的EventEmitter有条件地发出一个事件;否则,我们导航到记录视图。然后我们在视图模板中调整Button以使用该方法:
<ActionItem (tap)="record()" ios.position="right">
<Button text="Record" class="action-item"></Button>
</ActionItem>
我们现在可以修改app/modules/mixer/components/mixer.component.html以使用名称作为正常事件来使用Output:
<action-bar [title]="composition.name" (showRecordModal)="showRecordModal()"></action-bar>
<GridLayout rows="*, auto" columns="*" class="page">
<track-list [tracks]="composition.tracks" row="0" col="0"></track-list>
<player-controls [composition]="composition" row="1" col="0"></player-controls>
</GridLayout>
现在是时候进行有趣的部分了。由于我们希望能够打开任何组件在模态中,无论它是否是懒加载模块的一部分,让我们向DialogService添加一个新方法,可以在任何地方使用。
对app/modules/core/services/dialog.service.ts做出以下修改:
// angular
import { Injectable, NgModuleFactory, NgModuleFactoryLoader, ViewContainerRef, NgModuleRef } from '@angular/core';
// nativescript
import * as dialogs from 'ui/dialogs';
import { ModalDialogService } from 'nativescript-angular/directives/dialogs';
@Injectable()
export class DialogService {
constructor(
private moduleLoader: NgModuleFactoryLoader,
private modalService: ModalDialogService
) { }
public openModal(componentType: any, vcRef: ViewContainerRef, context?: any, modulePath?: string): Promise<any> {
return new Promise((resolve, reject) => {
const launchModal = (moduleRef?: NgModuleRef<any>) => {
this.modalService.showModal(componentType, {
moduleRef,
viewContainerRef: vcRef,
context
}).then(resolve, reject);
};
if (modulePath) {
// lazy load module which contains component to open in modal
this.moduleLoader.load(modulePath)
.then((module: NgModuleFactory<any>) => {
launchModal(module.create(vcRef.parentInjector));
});
} else {
// open component in modal known to be available without lazy loading
launchModal();
}
});
}
...
}
在这里,我们注入ModalDialogService和NgModuleFactoryLoader(实际上它是NSModuleFactoryLoader,因为如果你还记得,我们在第五章,路由和懒加载中提供了它)来按需加载任何模块以在模态中打开一个组件(在该懒加载的模块中声明)。它也适用于不需要懒加载的组件。换句话说,如果提供了路径,它将可选地懒加载任何模块,然后使用其NgModuleFactory获取模块引用,我们可以将其作为选项(通过moduleRef键)传递给this.modalService.showModal以打开在该懒加载模块中声明的组件。
这将在以后再次派上用场;然而,让我们现在就通过以下修改app/modules/mixer/components/mixer.component.ts来使用它:
// angular
import { Component, OnInit, OnDestroy, ViewContainerRef } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs/Subscription';
// app
import { DialogService } from '../../core/services/dialog.service';
import { MixerService } from '../services/mixer.service';
import { CompositionModel } from '../../shared/models';
import { RecordComponent } from '../../recorder/components/record.component';
@Component({
moduleId: module.id,
selector: 'mixer',
templateUrl: 'mixer.component.html'
})
export class MixerComponent implements OnInit, OnDestroy {
public composition: CompositionModel;
private _sub: Subscription;
constructor(
private route: ActivatedRoute,
private mixerService: MixerService,
private dialogService: DialogService,
private vcRef: ViewContainerRef
) { }
public showRecordModal() {
this.dialogService.openModal(
RecordComponent,
this.vcRef,
{ isModal: true },
'./modules/recorder/recorder.module#RecorderModule'
);
}
...
}
这将懒加载RecorderModule并在弹出模态中打开RecordComponent。酷!
使用 RecorderService 完成实现
现在,让我们使用RecorderService在app/modules/recorder/services/recorder.service.ts中完成这个实现:
// angular
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { Subscription } from 'rxjs/Subscription';
// app
import { DialogService } from '../../core/services/dialog.service';
import { RecordModel, RecordState } from '../models';
import { PlayerService } from '../../player/services/player.service';
import { TrackModel } from '../../shared/models/track.model';
@Injectable()
export class RecorderService {
public state$: Subject<number> = new Subject();
public model: RecordModel;
private _trackId: number;
private _sub: Subscription;
constructor(
private playerService: PlayerService,
private dialogService: DialogService
) { }
public setupNewRecording() {
this.model = new RecordModel();
this._trackId = undefined; // reset
this.model.on(this.model.events.stateChange, this._stateHandler.bind(this));
this._sub = this.playerService.complete$.subscribe(_ => {
this.model.stopPlayback();
});
}
public toggleRecord() {
this.model.toggleRecord();
}
public togglePlay() {
this.model.togglePlay();
}
public rewind() {
this.playerService.seekTo(0); // reset to 0
}
public save() {
this.model.save();
}
public cleanup() {
// unbind event listener
this.model.off(this.model.events.stateChange, this._stateHandler.bind(this));
this._sub.unsubscribe();
if (!this.model.savedFilePath) {
// user did not save recording, cleanup
this.playerService.removeTrack(this._trackId);
}
}
private _stateHandler(e) {
this.state$.next(e.data);
switch (e.data) {
case RecordState.readyToRecord:
this._stopMix();
break;
case RecordState.readyToPlay:
this._stopMix();
this._trackId = this.playerService
.updateCompositionTrack(this._trackId, this.model.audioFilePath);
break;
case RecordState.playing:
this._playMix();
break;
case RecordState.recording:
this._playMix(this._trackId);
break;
case RecordState.saved:
this._handleSaved();
break;
}
}
private _playMix(excludeTrackId?: number) {
if (!this.playerService.playing) {
// ensure mix plays
this.playerService.togglePlay(excludeTrackId);
}
}
private _stopMix() {
if (this.playerService.playing) {
// ensure mix stops
this.playerService.togglePlay();
}
// always reset to beginning
this.playerService.seekTo(0);
}
private _handleSaved() {
this._sub.unsubscribe();
this._stopMix();
this.playerService
.updateCompositionTrack(this._trackId, this.model.savedFilePath);
this.playerService.saveComposition();
this.model.finish();
}
}
我们录制服务的巅峰是其对模型状态变化的反应能力。这反过来又发出一个 Observable 流,通知观察者(我们的RecordComponent)状态变化,以及内部执行控制RecordModel和PlayerService所需的工作。我们设计的关键是我们希望我们的活动作品曲目在录制时在后台播放,这样我们就可以与混合一起播放。这个情况很重要:
case RecordState.readyToPlay:
this._stopMix();
this._trackId = this.playerService
.updateCompositionTrack(this._trackId, this.model.audioFilePath);
break;
当RecordModel处于readyToPlay状态时,我们知道已创建录制,现在可以播放。我们停止播放混合,获取录制文件的路径引用。然后,我们更新PlayerService以排队播放这条新曲目。我们稍后将展示更新的PlayerService,它负责将新文件添加到混合中,但它像我们混合中的其他一切一样添加一个新的TrackPlayer。然而,文件目前指向一个临时录制文件,因为我们不希望在用户决定正式提交并保存录制之前保存作品。录制会话将允许用户在录制不满意时重新录制。这就是为什么我们保留对_trackId的引用。如果录制已经添加到混合中,我们使用那个_trackId在重新录制时排除它,因为我们不希望听到我们正在重新录制的录制:
case RecordState.recording:
this._playMix(this._trackId);
break;
我们还用它来清理用户选择取消而不是保存的情况:
public cleanup() {
// unbind event listener
this.model.off(this.model.events.stateChange, this._stateHandler.bind(this));
this._sub.unsubscribe();
if (!this.model.savedFilePath) {
// user did not save recording, cleanup
this.playerService.removeTrack(this._trackId);
}
}
让我们看看我们需要对PlayerService进行哪些修改以支持我们的录制:
...
import { MixerService } from '../../mixer/services/mixer.service';
@Injectable()
export class PlayerService {
// default name of new tracks
private _defaultTrackName: string = 'New Track';
...
constructor(
private ngZone: NgZone,
private mixerService: MixerService
) { ... }
...
public saveComposition() {
this.mixerService.save(this.composition);
}
public togglePlay(excludeTrackId?: number) {
if (this._trackPlayers.length) {
this.playing = !this.playing;
if (this.playing) {
this.play(excludeTrackId);
} else {
this.pause();
}
}
}
public play(excludeTrackId?: number) {
// for iOS playback sync
let shortStartDelay = .01;
let now = 0;
for (let i = 0; i < this._trackPlayers.length; i++) {
let track = this._trackPlayers[i];
if (excludeTrackId !== track.trackId) {
if (isIOS) {
if (i == 0) now = track.player.ios.deviceCurrentTime;
(<any>track.player).playAtTime(now + shortStartDelay);
} else {
track.player.play();
}
}
}
}
public addTrack(track: ITrack): Promise<any> {
return new Promise((resolve, reject) => {
let trackPlayer = this._trackPlayers.find((p) => p.trackId === track.id);
if (!trackPlayer) {
// new track
trackPlayer = new TrackPlayerModel();
this._composition.tracks.push(track);
this._trackPlayers.push(trackPlayer);
} else {
// update track
this.updateTrack(track);
}
trackPlayer.load(
track,
this._trackComplete.bind(this),
this._trackError.bind(this)
).then(_ => {
// report longest duration as totalDuration
this._updateTotalDuration();
resolve();
});
})
} public updateCompositionTrack(trackId: number, filepath: string): number {
let track;
if (!trackId) {
// Create a new track
let cnt = this._defaultTrackNamesCnt();
track = new TrackModel({
name: `${this._defaultTrackName}${cnt ? ' ' + (cnt + 1) : ''}`,
order: this.composition.tracks.length,
filepath
});
trackId = track.id;
} else {
// find by id and update
track = this.findTrack(trackId);
track.filepath = filepath;
}
this.addTrack(track);
return trackId;
}
private _defaultTrackNamesCnt() {
return this.composition.tracks
.filter(t => t.name.startsWith(this._defaultTrackName)).length;
}
...
这些更改将支持我们的录制器与活动作品交互的能力。
注意:考虑在模态中重用组件进行懒加载以及通过路由进行懒加载时的注意事项。
如果 Angular 服务旨在在所有懒加载模块以及根模块中共享为单例,则必须在 根 级别提供它们。RecorderService 在导航到时与 RecordModule 懒加载,同时在模态中打开。由于我们现在将 PlayerService 注入到我们的 RecorderService(它是懒加载的)中,而 PlayerService 现在注入 MixerService(它也是作为我们应用中的根路由懒加载的),我们将不得不创建一个问题,即我们的服务不再是单例。实际上,如果你尝试导航到 RecordComponent,你甚至可能会看到这样的错误:
JS: 错误 Error: Uncaught (in promise): Error: No provider for PlayerService!
为了解决这个问题,我们将从 PlayerModule 和 MixerModule 中删除提供者(因为这两个模块都是懒加载的)并在我们的 CoreModule 中仅提供这些服务:
修改后的 app/modules/player/player.module.ts 如下:
...
// import { PROVIDERS } from './services'; // commented out now
@NgModule({
...
// providers: [...PROVIDERS], // no longer provided here
...
})
export class PlayerModule {}
修改后的 app/modules/mixer/mixer.module.ts 如下:
...
// import { PROVIDERS } from './services'; // commented out now
@NgModule({
...
// providers: [...PROVIDERS], // no longer provided here
...
})
export class MixerModule {}
更新为仅从 CoreModule 提供这些服务作为真正的单例,app/modules/core/core.module.ts 的代码如下:
...
import { PROVIDERS } from './services';
import { PROVIDERS as MIXER_PROVIDERS } from '../mixer/services';
import { PROVIDERS as PLAYER_PROVIDERS } from '../player/services';
...
@NgModule({
...
providers: [
...PROVIDERS,
...MIXER_PROVIDERS,
...PLAYER_PROVIDERS
],
...
})
export class CoreModule {
这就是解决这些类型问题的方法;但这正是我们为什么建议在第十章 Chapter 10 中使用 Ngrx 的原因,即将推出的 @ngrx/store + @ngrx/effects for State Management,因为它可以帮助缓解这些依赖注入问题。
在这个阶段,我们的设置运行得很好;但是,当我们开始集成 ngrx 以实现更 Redux 风格的架构时,它可以得到极大的改进和简化。我们已经在这里做了一些反应式的事情,例如我们的 RecordComponent 对服务中的 state$ 可观察对象做出反应;但是,我们需要将 MixerService 注入到 PlayerService 中,这在架构上感觉有点不合适,因为 PlayerModule 实际上不应该依赖于 MixerModule 提供的任何东西。再次强调,这技术上确实可以正常工作,但是当我们开始在第十章 Chapter 10 中使用 ngrx 时,你将看到我们如何在整个代码库中减少依赖混合。
让我们花点时间放松一下,拍拍自己的背,因为这是一项令人印象深刻的成果。看看我们辛勤工作的成果:
第二阶段 – 为 Android 构建音频录音器
信不信由你,我们实际上已经完成了大部分繁重的工作,让这一切在 Android 上工作!这就是 NativeScript 的美妙之处。设计一个有意义的 API,以及一个可以插入/播放底层原生 API 的架构,对于 NativeScript 开发至关重要。在这个阶段,我们只需要将 Android 的组件插入到我们设计的形状中。所以,总结一下,我们现在有以下内容:
-
与
PlayerService协同工作的RecorderService,以协调我们的多轨处理能力 -
一个灵活的波形视图,准备好在底层提供 Android 实现
-
RecordModel应该调用适当的底层目标平台 API,并准备好将 Android 的详细信息插入其中 -
定义了模型形状的接口,Android 模型只需实现即可知道它们应该定义哪些 API
让我们开始工作。
我们希望将record.model.ts重命名为record.model.ios.ts,因为它仅针对 iOS,但在这样做之前,我们希望有一个 TypeScript 定义文件(.d.ts),以便我们的代码库可以继续导入为'record.model'。这可以通过几种方式完成,包括手动编写一个。然而,tsc 编译器有一个方便的-d标志,它可以为我们生成定义文件:
tsc app/modules/recorder/models/record.model.ts references.d.ts -d true
这将产生大量的 TypeScript 警告和错误;但在这个情况下,这并不重要,因为我们的定义文件将正确生成。我们不需要生成 JavaScript,只需要定义,所以你可以忽略由此产生的问题墙。
现在我们有两个新文件:
-
record-common.model.d.ts(你可以删除它,因为我们不再需要它) -
record.model.d.ts
RecordModel导入record-common.model文件,这就是为什么也为它生成了定义;但你可以删除它。现在,我们有了定义文件,但我们想稍作修改。我们不需要任何private声明和/或它包含的任何本地类型;你会注意到它包含了以下内容:
...
readonly target: AKMicrophone;
readonly recorder: AKNodeRecorder;
...
由于这些是 iOS 特有的,我们将想要将它们类型化为any,这样它们就适用于 iOS 和 Android。这是我们的修改后的样子:
import { Observable } from 'data/observable';
import { IRecordModel, IRecordEvents } from './common';
export declare class RecordModel extends Observable implements IRecordModel {
readonly events: IRecordEvents;
readonly target: any;
readonly recorder: any;
readonly audioFilePath: string;
state: number;
savedFilePath: string;
toggleRecord(): void;
togglePlay(): void;
stopPlayback(): void;
save(): void;
dispose(): void;
finish(): void;
}
完美,现在将record.model.ts重命名为record.model.ios.ts。我们现在已经完成了 iOS 的实现,并确保了最大程度的代码复用来将我们的重点转向 Android。NativeScript 将在构建时使用目标平台后缀文件,所以你永远不需要担心 iOS 特有的代码会出现在 Android 上,反之亦然。
我们之前生成的.d.ts定义文件将在 TypeScript 编译器进行 JavaScript 转换时使用,而运行时将使用特定平台的 JS 文件(不带扩展名)。
好的,现在创建app/modules/recorder/models/record.model.android.ts:
import { Observable } from 'data/observable';
import { IRecordModel, IRecordEvents, RecordState, documentsFilePath } from './common';
export class RecordModel extends Observable implements IRecordModel {
// available events to listen to
private _events: IRecordEvents;
// recorder
private _recorder: any;
// state
private _state: number = RecordState.readyToRecord;
// the final saved path to use
private _savedFilePath: string;
constructor() {
super();
this._setupEvents();
// TODO
}
public get events(): IRecordEvents {
return this._events;
}
public get target() {
// TODO
}
public get recorder(): any {
return this._recorder;
}
public get audioFilePath(): string {
return ''; // TODO
}
public get state(): number {
return this._state;
}
public set state(value: number) {
this._state = value;
this._emitEvent(this._events.stateChange, this._state);
}
public get savedFilePath() {
return this._savedFilePath;
}
public set savedFilePath(value: string) {
this._savedFilePath = value;
if (this._savedFilePath)
this.state = RecordState.saved;
}
public toggleRecord() {
if (this._state !== RecordState.recording) {
// just force ready to record
// when coming from any state other than recording
this.state = RecordState.readyToRecord;
}
switch (this._state) {
case RecordState.readyToRecord:
this.state = RecordState.recording;
break;
case RecordState.recording:
this._recorder.stop();
this.state = RecordState.readyToPlay;
break;
}
}
public togglePlay() {
if (this._state === RecordState.readyToPlay) {
this.state = RecordState.playing;
} else {
this.stopPlayback();
}
}
public stopPlayback() {
if (this.state !== RecordState.recording) {
this.state = RecordState.readyToPlay;
}
}
public save() {
// we will want to do this
// this.savedFilePath = documentsFilePath(fileName);
}
public dispose() {
// TODO
}
public finish() {
this.state = RecordState.finish;
}
private _emitEvent(eventName: string, data?: any) {
let event = {
eventName,
data,
object: this
};
this.notify(event);
}
private _setupEvents() {
this._events = {
stateChange: 'stateChange'
};
}
}
这可能看起来非常像 iOS 端,这是因为它们几乎相同!实际上,这种设置非常出色,所以现在我们只想填写 Android 的具体细节。
在我们的 RecordModel 中使用 nativescript-audio 的 TNSRecorder 为 Android
我们可以使用一些花哨的 Android API 和/或库来为我们的录音器,但在这个案例中,我们用于跨平台多轨播放器的nativescript-audio插件也提供了一个跨平台录音器。我们甚至可以用它来配合 iOS,但我们想在那里特别使用 AudioKit 强大的 API。然而,在这里的 Android 上,让我们使用插件中的录音器并对record.model.android.ts进行以下修改:
import { Observable } from 'data/observable';
import { IRecordModel, IRecordEvents, RecordState, documentsFilePath } from './common';
import { TNSRecorder, AudioRecorderOptions } from 'nativescript-audio';
import { Subject } from 'rxjs/Subject';
import * as permissions from 'nativescript-permissions';
declare var android: any;
const RECORD_AUDIO = android.Manifest.permission.RECORD_AUDIO;
export class RecordModel extends Observable implements IRecordModel {
// available events to listen to
private _events: IRecordEvents;
// target as an Observable
private _target$: Subject<number>;
// recorder
private _recorder: TNSRecorder;
// recorder options
private _options: AudioRecorderOptions;
// recorder mix meter handling
private _meterInterval: number;
// state
private _state: number = RecordState.readyToRecord;
// tmp file path
private _filePath: string;
// the final saved path to use
private _savedFilePath: string;
constructor() {
super();
this._setupEvents();
// prepare Observable as our target
this._target$ = new Subject();
// create recorder
this._recorder = new TNSRecorder();
this._filePath = documentsFilePath(`recording-${Date.now()}.m4a`);
this._options = {
filename: this._filePath,
format: android.media.MediaRecorder.OutputFormat.MPEG_4,
encoder: android.media.MediaRecorder.AudioEncoder.AAC,
metering: true, // critical to feed our waveform view
infoCallback: (infoObject) => {
// just log for now
console.log(JSON.stringify(infoObject));
},
errorCallback: (errorObject) => {
console.log(JSON.stringify(errorObject));
}
};
}
public get events(): IRecordEvents {
return this._events;
}
public get target() {
return this._target$;
}
public get recorder(): any {
return this._recorder;
}
public get audioFilePath(): string {
return this._filePath;
}
public get state(): number {
return this._state;
}
public set state(value: number) {
this._state = value;
this._emitEvent(this._events.stateChange, this._state);
}
public get savedFilePath() {
return this._savedFilePath;
}
public set savedFilePath(value: string) {
this._savedFilePath = value;
if (this._savedFilePath)
this.state = RecordState.saved;
}
public toggleRecord() {
if (this._state !== RecordState.recording) {
// just force ready to record
// when coming from any state other than recording
this.state = RecordState.readyToRecord;
}
switch (this._state) {
case RecordState.readyToRecord:
if (this._hasPermission()) {
this._recorder.start(this._options).then((result) => {
this.state = RecordState.recording;
this._initMeter();
}, (err) => {
this._resetMeter();
});
} else {
permissions.requestPermission(RECORD_AUDIO).then(() => {
// simply engage again
this.toggleRecord();
}, (err) => {
console.log('permissions error:', err);
});
}
break;
case RecordState.recording:
this._resetMeter();
this._recorder.stop();
this.state = RecordState.readyToPlay;
break;
}
}
public togglePlay() {
if (this._state === RecordState.readyToPlay) {
this.state = RecordState.playing;
} else {
this.stopPlayback();
}
}
public stopPlayback() {
if (this.state !== RecordState.recording) {
this.state = RecordState.readyToPlay;
}
}
public save() {
// With Android, filePath will be the same, just make it final
this.savedFilePath = this._filePath;
}
public dispose() {
if (this.state === RecordState.recording) {
this._recorder.stop();
}
this._recorder.dispose();
}
public finish() {
this.state = RecordState.finish;
}
private _initMeter() {
this._resetMeter();
this._meterInterval = setInterval(() => {
let meters = this.recorder.getMeters();
this._target$.next(meters);
}, 200); // use 50 for production - perf is better on devices
}
private _resetMeter() {
if (this._meterInterval) {
clearInterval(this._meterInterval);
this._meterInterval = undefined;
}
}
private _hasPermission() {
return permissions.hasPermission(RECORD_AUDIO);
}
private _emitEvent(eventName: string, data?: any) {
let event = {
eventName,
data,
object: this
};
this.notify(event);
}
private _setupEvents() {
this._events = {
stateChange: 'stateChange'
};
}
}
哇!好的,这里有很多有趣的事情在进行。让我们先解决一个必要的事情,确保对于 API 级别 23+的 Android,权限得到正确处理。为此,你可以安装权限插件:
tns plugin add nativescript-permissions
我们还想要确保我们的清单文件包含正确的权限键。
打开app/App_Resources/Android/AndroidManifest.xml并在正确位置添加以下内容:
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
我们使用 nativescript-audio 插件的TNSRecorder作为我们的实现,并相应地将其 API 连接起来。AudioRecorderOptions提供了一个metering选项,允许通过间隔监控麦克风的音量计。
我们整体设计最灵活的地方在于,我们的模型的目标可以是任何东西。在这种情况下,我们创建了一个 RxJS Subject 可观察对象作为_target$,然后将其作为我们的目标获取器返回。这允许我们通过Subject可观察对象发射麦克风的音量计值,以便我们的波形使用。你很快就会看到我们如何利用这一点。
现在我们已经准备好继续进行 Android 的波形实现。
就像我们对模型所做的那样,我们希望将公共部分重构到一个共享文件中并处理后缀。
创建app/modules/shared/native/waveform-common.ts:
import { View } from 'ui/core/view';
export type WaveformType = 'mic' | 'file';
export interface IWaveformModel {
readonly target: any;
dispose(): void;
}
export interface IWaveform extends View {
type: WaveformType;
model: IWaveformModel;
createNativeView(): any;
initNativeView(): void;
disposeNativeView(): void;
}
然后,只需调整app/modules/shared/native/waveform.ts以使用它:
...
import { IWaveform, IWaveformModel, WaveformType } from './waveform-common';
export class Waveform extends View implements IWaveform {
...
在将我们的波形重命名为包含.ios后缀之前,让我们先为它生成一个 TypeScript 定义文件:
tsc app/modules/shared/native/waveform.ts references.d.ts -d true --lib es6,dom,es2015.iterable --target es5
你可能会再次看到 TypeScript 的错误或警告,但我们不需要担心这些,因为它应该仍然生成了一个waveform.d.ts文件。让我们稍微简化一下,只包含适用于 iOS 和 Android 的部分:
import { View } from 'ui/core/view';
export declare type WaveformType = 'mic' | 'file';
export interface IWaveformModel {
readonly target: any;
dispose(): void;
}
export interface IWaveform extends View {
type: WaveformType;
model: IWaveformModel;
createNativeView(): any;
initNativeView(): void;
disposeNativeView(): void;
}
export declare class Waveform extends View implements IWaveform {}
好的,现在,将waveform.ts重命名为waveform.ios.ts并创建app/modules/shared/native/waveform.android.ts:
import { View } from 'ui/core/view';
import { Color } from 'color';
import { IWaveform, IWaveformModel, WaveformType } from './common';
export class Waveform extends View implements IWaveform {
private _model: IWaveformModel;
private _type: WaveformType;
public set type(value: WaveformType) {
this._type = value;
}
public get type() {
return this._type;
}
public set model(value: IWaveformModel) {
this._model = value;
}
public get model() {
return this._model;
}
createNativeView() {
switch (this.type) {
case 'mic':
// TODO: this.nativeView = ?
break;
case 'file':
// TODO: this.nativeView = ?
break;
}
return this.nativeView;
}
initNativeView() {
// TODO
}
disposeNativeView() {
if (this.model && this.model.dispose) this.model.dispose();
}
}
好的,太棒了!这是我们需要的最基本设置,但我们应该使用哪种原生的 Android 视图?
如果你正在寻找开源的 Android 库,你可能会遇到一群非常出色的开发者,他们来自乌克兰的Yalantis,一家出色的移动开发公司。Roman Kozlov 和他的团队创建了一个开源项目,Horizon,它提供了美丽的音频可视化:
yalantis.com/blog/horizon-open-source-library-for-sound-visualization/
就像 iOS 一样,我们也想为可以渲染静态波形文件的 Waveform 视图做好准备。进一步查看开源选项,我们可能会遇到另一支才华横溢的团队——位于波兰广阔首都华沙的Semantive。他们为 Android 创建了一个功能强大的 Waveform 视图:
github.com/Semantive/waveform-android
让我们将这两个库集成到我们的 Android Waveform 集成中。
类似于我们为 iOS 集成的 AudioKit,让我们在根目录下创建一个名为android-waveform-libs的文件夹,并按照以下设置提供include.gradle:
为什么在包含本地库时偏离nativescript-前缀?
如果你计划将来将内部插件重构为通过 npm 发布的开源插件,以便社区使用,例如使用github.com/NathanWalker/nativescript-plugin-seed,那么使用前缀是一个不错的选择。
有时候,你可能只需要为特定平台集成几个本地库,就像我们在这个案例中一样,所以我们不需要在我们的文件夹上使用nativescript-前缀。
我们确保添加package.json,这样我们就可以像添加任何其他插件一样添加这些本地库:
{
"name": "android-waveform-libs",
"version": "1.0.0",
"nativescript": {
"platforms": {
"android": "3.0.0"
}
}
}
现在,我们只需将它们作为插件添加到我们的项目中:
tns plugin add android-waveform-libs
我们现在已准备好将这些库集成到我们的 Waveform 视图中。
让我们对app/modules/shared/native/waveform.android.ts文件进行以下修改:
import { View } from 'ui/core/view';
import { Color } from 'color';
import { Subscription } from 'rxjs/Subscription';
import { IWaveform, IWaveformModel, WaveformType } from './common';
import { screen } from 'platform';
declare var com;
declare var android;
const GLSurfaceView = android.opengl.GLSurfaceView;
const AudioRecord = android.media.AudioRecord;
// Horizon recorder waveform
// https://github.com/Yalantis/Horizon
const Horizon = com.yalantis.waves.util.Horizon;
// various recorder settings
const RECORDER_SAMPLE_RATE = 44100;
const RECORDER_CHANNELS = 1;
const RECORDER_ENCODING_BIT = 16;
const RECORDER_AUDIO_ENCODING = 3;
const MAX_DECIBELS = 120;
// Semantive waveform for files
// https://github.com/Semantive/waveform-android
const WaveformView = com.semantive.waveformandroid.waveform.view.WaveformView;
const CheapSoundFile = com.semantive.waveformandroid.waveform.soundfile.CheapSoundFile;
const ProgressListener = com.semantive.waveformandroid.waveform.soundfile.CheapSoundFile.ProgressListener;
export class Waveform extends View implements IWaveform {
private _model: IWaveformModel;
private _type: WaveformType;
private _initialized: boolean;
private _horizon: any;
private _javaByteArray: Array<any>;
private _waveformFileView: any;
private _sub: Subscription;
public set type(value: WaveformType) {
this._type = value;
}
public get type() {
return this._type;
}
public set model(value: IWaveformModel) {
this._model = value;
this._initView();
}
public get model() {
return this._model;
}
createNativeView() {
switch (this.type) {
case 'mic':
this.nativeView = new GLSurfaceView(this._context);
this.height = 200; // GL view needs height
break;
case 'file':
this.nativeView = new WaveformView(this._context, null);
this.nativeView.setSegments(null);
this.nativeView.recomputeHeights(screen.mainScreen.scale);
// disable zooming and touch events
this.nativeView.mNumZoomLevels = 0;
this.nativeView.onTouchEvent = function (e) { return false; }
break;
}
return this.nativeView;
}
initNativeView() {
this._initView();
}
disposeNativeView() {
if (this.model && this.model.dispose) this.model.dispose();
if (this._sub) this._sub.unsubscribe();
}
private _initView() {
if (!this._initialized && this.nativeView && this.model) {
if (this.type === 'mic') {
this._initialized = true;
this._horizon = new Horizon(
this.nativeView,
new Color('#000').android,
RECORDER_SAMPLE_RATE,
RECORDER_CHANNELS,
RECORDER_ENCODING_BIT
);
this._horizon.setMaxVolumeDb(MAX_DECIBELS);
let bufferSize = 2 * AudioRecord.getMinBufferSize(
RECORDER_SAMPLE_RATE, RECORDER_CHANNELS, RECORDER_AUDIO_ENCODING);
this._javaByteArray = Array.create('byte', bufferSize);
this._sub = this._model.target.subscribe((value) => {
this._javaByteArray[0] = value;
this._horizon.updateView(this._javaByteArray);
});
} else {
let soundFile = CheapSoundFile.create(this._model.target,
new ProgressListener({
reportProgress: (fractionComplete: number) => {
console.log('fractionComplete:', fractionComplete);
return true;
}
}));
setTimeout(() => {
this.nativeView.setSoundFile(soundFile);
this.nativeView.invalidate();
}, 0);
}
}
}
}
我们开始我们的 Android 实现,通过定义对需要访问的各种打包类的const引用,以减轻在 Waveform 中每次都要引用完全限定包位置的需要。就像在 iOS 端一样,我们通过允许类型('mic'或'file')驱动使用哪种渲染来设计一个双用途的 Waveform。这使我们能够将这个功能用于我们的实时麦克风可视化记录视图,另一个用于将我们的音轨作为 Waveform 静态渲染(更多内容将在后面介绍!)。
Horizon 库利用 Android 的GLSurfaceView作为主要渲染,因此:
this.nativeView = new GLSurfaceView(this._context);
this.height = 200; // GL view needs height
在开发过程中,我们发现GLSurfaceView至少需要一个高度来限制它,否则它将以全屏高度渲染。因此,我们明确地将自定义 NativeScript 视图的height设置为合理的200,这将自动为我们处理原生视图的测量。有趣的是,我们还发现有时我们的模型设置器会在initNativeView之前触发,有时会在之后。因为模型是初始化我们的 Horizon 视图的关键绑定,我们设计了一个自定义的内部_initView方法,其中包含适当的条件,可以从initNativeView调用,也可以在我们模型设置器触发后调用。条件(!this._initialized && this.nativeView && this.model)确保它只初始化一次。这是处理这些方法调用顺序中任何潜在竞争条件的方法。
本地Horizon.java类提供了一个update方法,该方法期望一个具有以下签名的 Java 字节数组:
updateView(byte[] buffer)
在 NativeScript 中,我们保留了一个表示这个本地 Java 字节数组的结构的引用:
let bufferSize = 2 * AudioRecord.getMinBufferSize(
RECORDER_SAMPLE_RATE, RECORDER_CHANNELS, RECORDER_AUDIO_ENCODING);
this._javaByteArray = Array.create('byte', bufferSize);
通过使用 Android 的android.media.AudioRecord类以及我们设置的各个录音设置,我们能够收集到一个初始的bufferSize,我们用它来初始化我们的字节数组大小。
我们随后利用我们整体灵活的设计,其中在这个实现中我们的模型目标是 rxjs Subject Observable,这使得我们可以订阅其事件流。对于'mic'类型,这个流将是录音的计数值变化,我们用它来填充我们的字节数组,并相应地更新Horizon视图:
this._sub = this._model.target.subscribe((value) => {
this._javaByteArray[0] = value;
this._horizon.updateView(this._javaByteArray);
});
这为我们提供了记录器一个很好的可视化效果,它将随着输入级别的变化而动画化。这里是一个预览;然而,风格仍然有点丑,因为我们还没有应用任何 CSS 润色:
对于我们的静态音频文件波形渲染,我们在createNativeView构造过程中使用 Android 上下文初始化WaveformView。然后我们使用其 API 对其进行配置,以便在我们的使用中。
在初始化过程中,我们创建了一个CheapSoundFile实例,这是WaveformView所要求的,并且有趣的是,我们在setTimeout内部使用setSoundFile,同时调用this.nativeView.invalidate(),这是在调用WaveformView的 invalidate。这导致原生视图使用处理后的文件进行更新,如下所示(再次,我们将在稍后解决风格润色问题):
摘要
本章介绍了大量关于如何在 iOS 和 Android 上使用原生 API 的工作的强大概念和技术。了解如何使用开源原生库对于充分利用您的应用程序开发并实现您所追求的功能集至关重要。直接从 TypeScript 访问这些 API,让您享受从未离开您首选的开发环境的奢侈,同时以有趣和易于访问的方式与您所爱的语言互动。
此外,学习如何/何时创建自定义 NativeScript 视图,并在您的 Angular 应用程序中与之交互,是充分利用这个技术栈的关键要素之一。
在下一章中,我们将通过增强我们的播放列表视图,添加更多功能,利用在这里学到的一些知识,提供一些额外的精彩内容。