五、吉他调音器

React Native 涵盖了 iOS 和 Android 中可用的大部分组件和 API。可以使用 React 本地组件在我们的 JavaScript 代码中完全设置 UI 组件、导航或网络等点,但并非所有平台的功能都已从本地映射到 JavaScript 世界。尽管如此,React Native 提供了一种编写真正的本机代码的方法,并且可以访问平台的全部功能。如果 React Native 不支持您需要的本机功能,您应该能够自己构建它。

在本章中,我们将使用 React-Native 的功能使 JavaScript 代码能够与自定义本机代码通信;具体来说,我们将编写一个本机模块来检测来自设备麦克风的频率。这些功能不应该是 React 本地开发人员日常任务的一部分,但最终,我们可能需要使用模块或 SDK,它们仅在 Objective-C、Swift 或 Java 上可用。

在本章中,我们将重点介绍 iOS,因为我们需要编写本机代码,这超出了本书的范围。将此应用移植到 Android 应该相当简单,因为我们可以完全重用 UI,但我们将在本章中排除这一点,以减少编写的本机代码量。由于我们只关注 iOS,因此我们将涵盖构建应用的所有方面,添加一个启动屏幕和一个图标,以便将其提交到应用商店。

We will need a Mac and XCode to add and compile native code for this project.

概述

吉他如何调音的概念应该简单易懂:吉他的六根弦中的每根弦在打开演奏时(也就是说,在不推琴的情况下)都会以特定的频率发出声音。调音吉他意味着拉紧琴弦,直到发出特定的频率。这是每个字符串应发射以进行标准调音的频率列表:

吉他的数字调音过程将遵循以下步骤:

  1. 记录通过设备麦克风捕获的频率的实时样本。
  2. 找出该样本中最显著的频率。
  3. 计算上表中最近的频率,以检测正在播放的字符串。
  4. 计算该弦的发射频率和标准调音频率之间的差值,以便让用户校正弦张力。

我们还需要克服一些缺陷,比如忽略低音量,这样我们就不会通过检测来自非字符串声音的频率来迷惑用户。

在这个过程中,我们将使用本机代码,这不仅是因为我们需要处理 React-native API 中不可用的功能(例如,通过麦克风录制),还因为我们可以以更有效的方式执行复杂的计算。我们将在这里使用的算法,从我们从麦克风采集的样本中检测主频,称为快速傅里叶变换FFT。这里我们不会详细介绍,但我们将使用本机库来执行这些计算。

这个应用的用户界面应该非常简单,因为我们只有一个屏幕来显示用户。复杂性在于逻辑,而不是显示一个漂亮的界面,尽管我们将使用一些图像和动画使其更具吸引力。重要的是要记住,界面是使应用在应用商店中具有吸引力的因素,因此我们不会忽视这一点。

这就是我们的应用完成后的样子:

在屏幕顶部,我们的应用显示一个“模拟”调音器,显示吉他弦发出的频率。调音器内部会出现一个红色指示灯,显示吉他弦是否接近调音频率。如果指示器位于左侧,则表示吉他弦的频率较低,需要拧紧。因此,用户应尝试使指示器转到调音器的中间,以确保字符串已调音。这是一种非常直观的方式来显示字符串的调音效果。

但是,我们需要让用户知道她要调整的字符串。我们可以通过检测最近的调音频率来猜测这一点。一旦我们知道推了什么弦,我们将在屏幕的底部向用户显示它,那里有每根弦的表示以及吉他调音后应播放的音符。我们将相应便笺的边框颜色更改为绿色,以通知用户应用检测到特定便笺。

让我们回顾一下本章将涉及的主题列表:

  • 从 JavaScript 运行本机代码
  • 动画图像
  • <StatusBar />
  • propTypes
  • 添加闪屏
  • 添加图标

设置文件夹结构

让我们使用 React-Native 的 CLI 初始化一个 React-Native 项目。该项目将命名为guitarTuner,并且仅适用于 iOS:

react-native init --version="0.45.1" guitarTuner

由于这是一个单屏幕应用,我们不需要 Redux 或 MobX 等状态管理库,因此,我们将使用一个简单的文件夹结构:

我们有三个图像支持我们的自定义界面:

  • indicator.jpg:表示字符串调音程度的红色条
  • tuner.jpg:指示灯移动的背景
  • string.jpg:吉他弦的代表

我们的src/文件夹包含两个子文件夹:

  • components/:存储<Strings/>组件和<Tuner/>组件
  • utils/:这是一个函数和常量列表,将用于我们应用的几个部分

最后,我们的应用的入口点将是index.ios.js,因为我们将专门为 iOS 平台构建我们的应用。

让我们看看我们的 To.t0t 来确定我们将有什么依赖关系:

/*** package.json ***/

{
        "name": "guitarTuner",
        "version": "0.0.1",
        "private": true,
        "scripts": {
                "start": "node node_modules/react-native/
                local-cli/cli.js start",
                "test": "jest"
        },
        "dependencies": {
                "react": "16.0.0-alpha.12",
                "react-native": "0.45.1"
        },
        "devDependencies": {
                "babel-jest": "20.0.3",
                "babel-preset-react-native": "2.0.0",
                "jest": "20.0.4",
                "react-test-renderer": "16.0.0-alpha.12"
        },
        "jest": {
                "preset": "react-native"
        }
}

可以看出,除了reactreact-native之外,没有其他依赖项,它们是在运行init脚本时由 React Native 的 CLI 创建的。

为了获得从麦克风录制的许可,我们还需要修改我们的ios/guitarTuner/Info.plist以添加麦克风使用说明,这是一条向用户显示的消息,以请求访问其设备上的麦克风。我们需要在最后一个</dict></plist>之前添加这些行:

<key>NSMicrophoneUsageDescription</key><key>NSMicrophoneUsageDescription</key> 
<string>This app uses the microphone to detect what guitar 
         string is being pressed.
</string>

在这最后一步中,我们应该让应用的 JavaScript 部分准备好开始编码。但是,我们仍然需要设置用于录制和频率检测的本机模块。

编写本机模块

我们需要 XCode 来编写本机模块,它将使用麦克风记录样本并分析这些样本以计算主频。由于我们对这些计算是如何进行的不感兴趣,我们将使用一个开源库来委托大部分记录和 FFT 计算。图书馆名为SCListener,在可以找到它的一个分支 https://github.com/emilioicai/sc_listener

我们需要按照以下步骤下载库并将其文件添加到项目中:

  1. 导航到 iOS 项目所在的文件夹:<project_folder>/ios/
  2. 双击guitarTuner.xcodeproj,将打开 XCode。

  3. 右键单击guitarTuner文件夹,然后单击将文件添加到“Guitartune”…:

  1. 从下载的SCListener库中选择所有文件:

  1. 点击接受。最终,XCode 中的文件结构应与以下类似:

  1. SCListener需要安装 AudioToolbox 框架。让我们通过在 XCode 中单击项目的根来实现这一点。
  2. 选择构建阶段选项卡。

  1. 转到将二进制文件链接到库。
  2. 单击+图标。
  3. 选择 AudioToolbox.framework

  1. 现在,让我们添加我们的模块,它将使用SCListener并将数据发送到 React Native。右键点击guitarTuner文件夹,点击新建文件。
  2. 添加名为FrequencyDetector.h的头文件:

  1. 让我们重复为我们的模块添加实现文件的过程:右键单击guitarTuner文件夹,然后单击新建文件。
  2. 添加名为FrequencyDetector.m的 Objective-C 文件:

我们的模块FrequencyDetector现已准备好实施。让我们来看看什么是 T1?

/*** FrequencyDetector.h ***/

#import <React/RCTBridgeModule.h>
#import <Accelerate/Accelerate.h>

@interface FrequencyDetector : NSObject 
@end

它只导入了两个模块:Accelerate用于进行傅里叶变换计算,RCTBridgeModule使我们的本机模块能够与应用的 JavaScript 代码交互。现在,让我们转到模块的实现:

/*** FrequencyDetector.m ***/

#import "FrequencyDetector.h"
#import "SCListener.h"

NSString *freq = @"";

@implementation FrequencyDetector

RCT_EXPORT_MODULE();

RCT_EXPORT_METHOD(getFrequency:(RCTResponseSenderBlock)callback)
{
  double power = [[SCListener sharedListener] averagePower];
  if(power < 0.03) { //ignore low volumes
    freq = @"0";
  } else {
    freq = [NSString stringWithFormat:@"%0.3f",
           [[SCListener sharedListener] frequency]];
  }
  callback(@[[NSNull null], freq]);
}

RCT_EXPORT_METHOD(initialise)
{
  [[SCListener sharedListener] listen];
}

@end

即使对于非 Objective-C 开发人员,此代码也应该易于理解:

  1. 首先,我们导入SCListener,该模块公开了从设备麦克风记录的方法,并根据记录的样本计算 FFT
  2. 然后,我们展示了两种方法:getFrequencyinitialise

getFrequency的实现也很简单。我们只需要通过调用 SCListener 共享实例上的averagePower来读取在麦克风上检测到的音量。如果音量足够大,我们决定推送一个吉他字符串,因此我们更新一个名为freq的变量,该变量将被传递到 JavaScript 代码提供的回调中。注意,由于本机代码和 JavaScript 代码之间的桥梁性质,只能通过回调(或承诺)将数据发送回 JavaScript。

我们将方法从原生世界公开到 JavaScript 世界的方式是使用RCTBridgeModule提供的宏RCT_EXPORT_METHOD。我们还需要让 React Native 知道这个模块可以从我们的 JavaScript 代码中使用。我们通过调用另一个宏来实现:RCT_EXPORT_MODULE。这就是我们所需要的;从现在起,我们可以通过以下方式访问此模块的方法:

import { NativeModules } from 'react-native';
var FrequencyDetector = NativeModules.FrequencyDetector;

FrequencyDetector.initialise();
FrequencyDetector.getFrequency((res, freq) => {});

如我们所见,我们将回调传递给getFrequency,其中将接收当前记录的频率。我们现在可以使用这个值来计算按下了什么字符串以及它的调音程度。让我们来看看我们将如何在我们的应用的 JavaScript 组件中使用这个模块。

index.ios.js

我们已经看到了如何访问从本机模块FrequencyDetector公开的方法。现在让我们看看如何在组件树中使用它来更新应用的状态:

/*** index.ios.js ***/

...

var FrequencyDetector = NativeModules.FrequencyDetector;

export default class guitarTuner extends Component {

  ...

  componentWillMount() {
 FrequencyDetector.initialise();
    setInterval(() => {
      FrequencyDetector.getFrequency((res, freq) => {
        let stringData = getClosestString(parseInt(freq));
        if(!stringData) {
          this.setState({
            delta: null,
            activeString: null
          });
        } else {
          this.setState({
            delta: stringData.delta,
            activeString: stringData.number
          });
        }
      });
    }, 500);
  }

 ...

});

AppRegistry.registerComponent('guitarTuner', () => guitarTuner);

大部分逻辑将放在我们输入文件的componentWillMount方法中。我们需要初始化FrequencyDetector模块,从设备的麦克风开始监听,然后立即调用setInterval重复(每 0.5 秒)调用FrequencyDetectorgetFrequency方法来获取更新的突出频率。每次我们得到一个新的频率,我们都会通过调用名为getClosestString的支持函数来检查吉他弦,并将返回的数据保存在我们的组件状态。我们将把这个函数存储在我们的utils文件中。

乌提尔斯

在继续使用 TytT0T 之前,让我们看一下我们在 OrthT2A:

/*** src/utils/index.js ***/

const stringFrequencies = [
  { min: 287, max: 371, tuned: 329 },
  { min: 221, max: 287, tuned: 246 },
  { min: 171, max: 221, tuned: 196 },
  { min: 128, max: 171, tuned: 146 },
  { min: 96, max: 128, tuned: 110 },
  { min: 36, max: 96, tuned: 82}
];

export function getClosestString(freq) {
  let stringData = null;
  for(var i = 0; i < stringFrequencies.length; i++) {
    if(stringFrequencies[i].min < freq && stringFrequencies[i].max 
       >= freq){
      let delta = freq - stringFrequencies[i].tuned; //absolute delta
      if(delta > 0){
        delta = Math.floor(delta * 100 / (stringFrequencies[i].max - 
                           stringFrequencies[i].tuned));
      } else {
        delta = Math.floor(delta * 100 / (stringFrequencies[i].tuned - 
                           stringFrequencies[i].min));
      }
      if(delta > 75) delta = 75; //limit deltas
      if(delta < -75) delta = -75;
      stringData = { number: 6 - i, delta } //relative delta
      break;
    }
  }
  return stringData;
}

export const colors = {
  black: '#1f2025',
  yellow: '#f3c556',
  green: '#3bd78b'
}

getClosestString是一个函数,它根据提供的频率返回一个包含两个值的 JavaScript 对象:

  • number:这是最有可能被按下的吉他弦上的数字
  • delta:这是提供的频率与最有可能被压下的吉他弦的调音频率之间的差异

我们还将导出颜色及其十六进制表示的列表,一些用户界面组件将使用这些列表来保持整个应用的一致性。

调用getClosestString后,我们有足够的信息在应用中构建状态。当然,我们需要将这些数据提供给调音器(以显示吉他弦的调音程度)和弦的表示(以显示按下了什么吉他弦)。让我们看一下整个根组件,看看这些数据是如何在组件之间传播的:

/*** index.ios.js ***/

import React, { Component } from 'react';
import {
  AppRegistry,
  StyleSheet,
  Image,
  View,
  NativeModules,
  Animated,
  Easing,
  StatusBar,
  Text
} from 'react-native';
import Tuner from './src/components/Tuner';
import Strings from './src/components/Strings';
import { getClosestString, colors } from './src/utils/';

var FrequencyDetector = NativeModules.FrequencyDetector;

export default class guitarTuner extends Component {
  state = {
 delta: null,
    activeString: null
  }

  componentWillMount() {
    FrequencyDetector.initialise();
    setInterval(() => {
      FrequencyDetector.getFrequency((res, freq) => {
        let stringData = getClosestString(parseInt(freq));
        if(!stringData) {
          this.setState({
            delta: null,
            activeString: null
          });
        } else {
          this.setState({
            delta: stringData.delta,
            activeString: stringData.number
          });
        }
      });
    }, 500);
  }

  render() {
    return (
      <View style={styles.container}>
 <StatusBar barStyle="light-content"/>
        <Tuner delta={this.state.delta} />
        <Strings activeString={this.state.activeString}/>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: colors.black,
    flex: 1
  }
});

AppRegistry.registerComponent('guitarTuner', () => guitarTuner);

我们将使用两个组件来呈现当前按下的字符串(<Strings/>)以及按下的字符串的调音方式(<Tuner/>

除此之外,我们还使用了一个名为<StatusBar/>的 React 原生组件。<StatusBar/>允许开发者选择应用将在顶部栏中显示的颜色,其中显示载体、时间、电池电量等:

由于我们希望我们的应用有一个黑色的背景,我们决定使用light-content条样式。该组件允许我们完全隐藏该条,更改其背景颜色(仅限 Android),或隐藏网络活动,以及其他选项。

现在让我们转到显示所有可视组件的组件。我们将从<Tuner/>开始。

调音器

我们的<Tuner/>组件由两个元素组成:一个背景图像将屏幕分割成多个部分,另一个指示器将根据吉他弦的调音程度移动。为了便于用户使用,我们将使用动画移动指示器,类似于模拟调音器的行为:

/*** src/components/Tuner/index ***/

import React, { Component } from 'react';
import {
  StyleSheet,
  Image,
  View,
  Animated,
  Easing,
  Dimensions
} from 'react-native';

import { colors } from '../utils/';

var {height, width} = Dimensions.get('window');

export default class Tuner extends Component {
  state = {
 xIndicator:  new Animated.Value(width/2)
  }

  static propTypes = {
    delta: React.PropTypes.number
  }

  componentWillReceiveProps(newProps) {
    if(this.props.delta !== newProps.delta) {
      Animated.timing(
        this.state.xIndicator,
        {
          toValue: (width/2) + (newProps.delta*width/2)/100,
          duration: 500,
          easing: Easing.elastic(2)
        }
      ).start();
    }
  }

  render() {
    let { xIndicator } = this.state;

    return (
      <View style={styles.tunerContainer}>
        <Image source={require('../../img/tuner.jpg')} 
         style={styles.tuner}/>
 <Animated.Image source={require('../../img/indicator.jpg')} 
         style={[styles.indicator, {left: xIndicator}]}/>
      </View>
    )
  }
}

const styles = StyleSheet.create({
  tunerContainer: {
    flex: 1,
    backgroundColor: colors.black,
    marginTop: height * 0.05
  },
  tuner: {
    width,
    resizeMode: 'contain'
  },
  indicator: {
    position: 'absolute',
    top: 10
  }
});

我们将为名为xIndicator的动画使用组件的state变量,该变量将存储指示器应位于的位置的值(以动画方式)。请记住,离中心越近,弦的调音效果越好。每当我们使用componentWillReceiveProps方法和Animated.timing功能从父对象收到新的delta道具时,我们都会更新此值,以确保图像具有动画效果。为了使它更真实,我们还添加了一个缓和功能,这将使指标反弹,有点像一个真正的模拟指标。

我们还为类添加了一个propTypes静态属性以进行类型检查。我们将确保通过这种方式,我们的组件以正确的格式接收增量。

最后,还记得我们是如何在utils文件中导出颜色列表及其十六进制值的吗?我们在这里使用它来显示这个组件的背景是什么颜色。

最后一个部分是吉他的六根弦的表示。当我们的FrequencyDetector本机模块检测到播放的频率时,我们将在此处通过将音符的容器边框更改为绿色来显示哪个字符串能够发出最接近的频率:

因此,我们需要接受来自其父级的一个道具:活动吉他弦的数量。让我们来看看这个简单组件的代码:

/*** src/components/Strings ***/

import React, { Component } from 'react';
import {
  StyleSheet,
  Image,
  View,
  Text
} from 'react-native';

import { colors } from '../utils/';

const stringNotes = ['E','A','D','G','B','E'];

export default class Strings extends Component {
 static propTypes = {
    activeString: React.PropTypes.number
  }

  render() {
    return (
      <View style={styles.stringsContainer}>
        {
          stringNotes.map((note, i) => {
            return (
              <View key={i} style={styles.stringContainer}>
                <Image source={require('../../img/string.jpg')} 
                 style={styles.string}/>
                <View style={[styles.noteContainer, 
                 {borderColor: (this.props.activeString === (i+1))
                  ? '#3bd78b' : '#f3c556'}]}>
                  <Text style={styles.note}>
                    {note}
                  </Text>
                </View>
              </View>
            )
          })
        }
      </View>
    );
  }
}

const styles = StyleSheet.create({
  stringsContainer: {
    borderTopColor: colors.green,
    borderTopWidth: 5,
 justifyContent: 'space-around',
    flexDirection: 'row'
  },
  stringContainer: {
    alignItems: 'center'
  },
  note: {
    color: 'white',
    fontSize: 19,
    textAlign: 'center'
  },
  noteContainer: {
    top: 50,
    height: 50,
    width: 50,
    position: 'absolute',
    padding: 10,
    borderColor: colors.yellow,
    borderWidth: 3,
    borderRadius: 25,
    backgroundColor: colors.black
  }
});

我们正在渲染六幅图像,每根吉他弦一幅,并使用space-around将它们分布在整个设备屏幕上,在两侧留下两个小空间。我们使用一个常量数组,其中包含吉他中每个弦的音符,将它们映射到弦表示中。我们还将使用从父项收到的道具activeString来决定是否为每个音符显示黄色或绿色边框

我们再次使用propTypes检查所提供道具的类型(本例中为数字)。

这是所有的代码,我们需要建立我们的吉他调音器。现在让我们添加一个图标和一个闪屏,让应用准备好提交到应用商店。

添加图标

一旦我们设计了图标并将其保存为大型图像,我们需要将其调整为苹果要求的所有格式。一般来说,这些是所需的尺寸:

  • 20 x 20 像素(iPhone 通知 2x)
  • 60 x 60 像素(iPhone 通知 3x)
  • 58 x 58 像素(iPhone 聚光灯-iOS 5,6 2x)
  • 67 x 67 像素(iPhone 聚光灯-iOS 5,6 3x)
  • 80 x 80 像素(iPhone 聚光灯-iOS 7-10 2x)
  • 120 x 120 像素(iPhone 聚光灯-iOS 7-10 3x 和 iPhone 应用 iOS 7-10 2x)
  • 180 x 180 像素(iPhone 应用 ios 7-10 3x)

由于这是一个非常繁琐的过程,我们可以使用其中一个在线工具,通过提供足够大的图像自动执行所有调整大小的任务。最流行的工具之一可以在找到 https://resizeappicon.com/

一旦我们有了合适大小的图标,我们需要将它们添加到我们的 XCode 项目中。我们将通过单击 XCode 中的Images.xcassets并将每个图像及其相应大小添加到此窗口中的每个资产来完成此操作:

下次编译我们的应用时,我们将在模拟器中看到我们的新图标(使用命令+移位+H显示主屏幕)。

添加启动屏幕

启动屏幕是 iOS 在加载应用时显示的图像。有几种技术可以让这个介绍让用户感到愉快,比如在应用加载后显示用户将看到的用户界面预览。然而,我们将采取一种更简单的方法:我们将显示带有标题的应用徽标

最简单、更灵活的方法是通过点击LaunchScreen.xib在 XCode 中使用 interface builder:

界面生成器是一种所见即所得工具,它通过将组件拖放到容器中来帮助开发人员构建响应性屏幕。我们保持简单,只是添加了一个标签,上面有应用的名称和我们在应用图标上使用的相同徽标。

另一种选择是将图像上传为启动屏幕并删除LaunchScreen.xib文件,但这样我们就有可能根据应用运行的设备拉伸图像,因此推荐的方法是始终使用 interface builder 启动屏幕。

禁用横向模式

在测试我们的应用时,我们需要同时测试横向和纵向模式,因为这两种模式在默认情况下都将启用。在这个应用中,我们实际上不需要横向模式,因为它不会给纵向模式增加任何额外的值。考虑到这一点,我们需要禁用横向模式,以确保如果用户将设备定向为横向模式,则用户界面中不会出现任何异常行为。在选择项目根目录时,我们将通过常规选项卡在 XCode 中执行此操作:

我们需要取消选中横向左侧和横向右侧选项,以便在所有情况下仅允许纵向模式。

总结

这个应用的主要挑战是从我们的 JavaScript 代码访问一个用 Objective-C 编写的本机模块。幸运的是,React Native 能够用相对较少的代码行简化这两个世界之间的通信。

我们只关注该应用的 iOS,但现实情况是,在 Android 中构建相同的应用应该遵循一个非常类似的过程,考虑到我们应该用 Java 而不是 Objective-C 构建本机模块。此外,我们学习了在应用中包含图标和启动屏幕的过程,以在发布前完成开发周期。

由于我们的应用中只有一个屏幕,因此我们选择不使用任何路由或状态管理库,这使我们能够将重点放在 JavaScript 代码与我们实现的本机模块之间的通信上。

我们还创建了一些动画来模拟一个模拟调音器,它为这个应用提供了一个有吸引力和有趣的外观。

除了图标和启动屏幕外,我们还考虑了另一个视觉元素,这在许多应用中都很重要:状态栏。我们看到了根据我们的应用的外观改变其内容颜色是多么容易。在本例中,我们选择了深色背景,因此状态栏中需要浅色内容,尽管一些应用(如游戏)在完全没有状态栏的情况下看起来可能更好。

在下一章中,我们将转向另一种应用:消息传递应用。