65.9K
CodeProject 正在变化。 阅读更多。
Home

Perfect Pulled Pork with React Native, Expo, and Express

starIcon
emptyStarIcon
starIcon
emptyStarIconemptyStarIconemptyStarIcon

1.33/5 (3投票s)

2018年6月13日

CPOL

15分钟阅读

viewsIcon

11639

. 或者:每30秒拍一张照片并发送到服务器。. 我计划第二天做手撕猪肉。那天晚上,我设置好了Weber烤炉,拿出了木炭袋和一些苹果木块,把所有东西都摆好。俗话说,准备工作(Mise en place)。

Perfect Pulled Pork with React Native, Expo, and Express

或者:每30秒拍一张照片并发送到服务器。

我计划第二天做手撕猪肉。那天晚上,我设置好了Weber烤炉,拿出了木炭袋和一些苹果木块,把所有东西都摆好。俗话说,准备工作(Mise en place)。我第二天早上7点就会起床开始点火,我不相信睡眼惺忪的自己能记住所有事情。

我准备的东西之一是探针式温度计和2个探针:一个用于测量空气温度,一个用于测量肉的内部温度。烟熏是一种低温慢速的烹饪方法:您希望将空气温度升高到225°F并保持数小时,让肉慢慢烹饪并吸收烟熏味。烟熏猪肩肉(又名即将成为手撕猪肉的肉)可能需要8-12小时。这就是为什么我早上7点就起床。

我知道这篇教程很长,所以您可以注册以下载该文章的PDF副本。获取这篇19页的教程PDF

那么,React Native 在这其中扮演什么角色呢?

嗯,用Weber烤炉控制温度有点技巧。而且是手动控制。有2个可以调节的空气通风口——一个在顶部,一个在底部。打开它们以提高温度,关闭它们以降低温度。但是火需要一段时间才能响应。那是火,不是数字旋钮。所以,您作为烤炉大师,要一整天充当一个人类PID控制器。

我的意思是:您必须不断观察温度,调整通风口,然后重新检查。如果您做得好,您不必经常调整,但我是一个新手,所以我经常在那里。

我想在不每15分钟跑到烟熏炉那里查看的情况下,知道温度是否在225°F或接近的范围内。

这就是React Native派上用场的地方。

晚上9点,在我摆好所有材料之后,我有了这个想法:我做一个应用程序,每30秒拍一张温度计的照片,然后上传到服务器——这样我就可以刷新页面,而不是跑到烟熏炉那里了!

在我告诉您之前——是的,我知道有现成的远程温度计做同样的事情。是的,我也知道我本可以整天带着啤酒坐在外面看着它,那也很有趣。但实际上我只是想找个借口玩玩React Native :)

宏伟计划:系统布局

就像任何好的项目一样,我开始思考它应该如何工作。

我需要

  1. 一部带摄像头的手机(旧款iPhone 4S)。
  2. 手机上运行一个全天拍照的应用程序。
  3. 一个运行在我的笔记本电脑上的接收照片的服务器。
  4. 同一个服务器来提供最新照片。

我决定尽量保持最简化(主要是因为已经是晚上9点了,我明天早上7点还要起床)。几乎没有安全性。不会有websockets通知React应用下载最新图片。这个服务器只会接收图片,并在请求时返回最新的图片。

React Native

您可能听说过React Native——一个使用React和JS构建原生移动应用的框架。如果您会编写React应用,那么学习React Native会很快。核心概念是一样的,只是props和state。

不过,由于React Native背后没有DOM,所以有一些区别。主要是,您熟悉的HTML元素(div, span, img等)被React Native组件取代了(div == View, span == Text, img == Image)。

另外,“真实”的CSS不支持,但RN支持通过内联样式进行样式设置。Flexbox布局和大多数常规样式,如color和backgroundColor等都能正常工作。我注意到一些简写属性也不起作用:像border: 1px solid red这样的写法,需要写成更明确的形式,比如{ borderWidth: 1, borderColor: 'red' }。

Expo

Expo是一个用于使用React Native构建应用的工具和平台。

使用Expo的一个好处是,它允许您在不注册Apple开发者订阅的情况下将应用部署到手机上(至少对iPhone用户来说是这样)。我读到过,确实可以在没有Apple开发者订阅的情况下将应用安装到手机上,但这需要修改Xcode,而我今晚不想处理这个。

Expo的另一个大优势是它附带Expo SDK,它提供了许多现成的原生API——如加速度计、指南针、位置、地图,以及本项目最重要的:摄像头。

在电脑和手机上安装Expo

我使用了Expo命令行,但他们也提供了一个IDE。如果您想跟随操作,请使用NPM或Yarn安装Expo命令行工具

npm install -g exp

(是的,它是exp,不是expo)。

然后您需要在手机上安装Expo应用,可以在App Store / Play Store中找到。

创建项目

安装好命令行工具后,运行此命令创建一个新项目

exp init grillview

它会提示您选择一个模板:选择“blank”(空白)模板。

然后按照提供的说明启动它

$ cd grillview
$ exp start

在某个时候,它会要求您创建一个Expo账户。这是为了能够将应用从您的计算机部署到Expo的服务器。然后手机上的Expo应用就可以加载您的应用了。

按照说明将URL发送到您的设备,或者直接输入。Expo还允许您在模拟器中运行,但我认为用真机更有趣,所以我就这么做了。

一旦在手机上打开,开发体验就相当不错了。修改代码,保存,应用就会自动重新加载(自动刷新)——就像在本地使用Create React App进行开发一样。每次下载JS bundle都会有轻微的延迟。您也可以从Expo的开发者菜单启用热重载(无刷新),可以通过摇晃手机来调出。要轻柔地摇。不要把它扔出窗外之类的。

文件结构

Expo在项目根目录为我们设置了一个App.js文件,它导出一个App组件。这是生成应用的全部内容

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

export default class App extends React.Component {
  render() {
    return (
      <View style={styles.container}>
        <Text>Open up App.js to start working on your app!</Text>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

您会注意到View组件内部有一个Text组件。尝试保留“Open up App.js…”这段文本,但移除包裹它的Text组件,看看会发生什么。

如果您查看package.json,您会看到这一行

"main": "node_modules/expo/AppEntry.js"

这就是启动我们的应用,它期望找到一个导出根组件的App.js文件。

如果您想重新组织项目结构,第一步是将AppEntry.js复制到您的项目中并进行相应修改,但这次我们将坚持使用默认设置。

使用相机

权限已授予

要拍照,Expo提供了一个Camera组件。但在此之前,我们需要请求权限。

打开App.js,为camera和permissions对象添加一个新的import,并将组件修改为如下所示

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

// add this:
import { Camera, Permissions } from 'expo';

export default class App extends React.Component {
  // initialize state
  state = {
    cameraPermission: null
  };

  render() {
    const { cameraPermission } = this.state;

    // Render one of 3 things depending on permissions
    return (
      <View style={styles.container}>
        {cameraPermission === null ? (
          <Text>Waiting for permission...</Text>
        ) : cameraPermission === false ? (
          <Text>Permission denied</Text>
        ) : (
          <Text>yay camera</Text>
        )}
      </View>
    );
  }
}

现在应用应该显示“Waiting for permission…”(等待权限…)并且停在那里,因为我们还没有做任何事情。

我们将在componentDidMount生命周期钩子中请求权限。将其添加进去

export default class App extends React.Component {
  ...

  componentDidMount() {
    Permissions.askAsync(Permissions.CAMERA)
      .then(({ status }) =>
        this.setState({
          cameraPermission: status === 'granted'
        })
      );
  }

  render() {
    ...
  }
}

当您保存并应用刷新时,您会看到一个请求相机权限的对话框。一旦您允许,文本就会改变。

如果这是您第一次使用Expo,它可能会在询问您的应用之前先询问Expo本身的权限。

When permission is granted, the app re-renders

实时相机视图

现在,让我们将“yay camera”文本替换为渲染相机的组件。向App.js添加一个名为Autoshoot的新组件。目前,它将只渲染Camera,以便我们可以确保一切正常。

class Autoshoot extends React.Component {
  render() {
    return (
      <View style={{ flex: 1, width: '100%' }}>
        <Camera
          style={{ flex: 1 }}
          type={Camera.Constants.Type.back}
          ref={cam => this.camera = cam}>
        </Camera>
      </View>
    );
  }

我们将Camera放在View内部,给它们都设置flex: 1,使它们占据整个高度,并将width设置为'100%',使View占据整个屏幕(如果不设置宽度,您会看到一个空白屏幕:试试看!)。

我们使用的是“更好”的相机(至少在iPhone上是这样——后置摄像头,而不是前置自拍摄像头)。

并且我们保存了这个相机组件的ref,因为在下一部分我们将用它来触发快门。

现在这个组件已经存在,回到App的render方法,用这个Autoshoot组件替换“yay camera”元素

render() {
  const { cameraPermission } = this.state;

  // Render one of 3 things depending on permissions
  return (
    <View style={styles.container}>
      {cameraPermission === null ? (
        <Text>Waiting for permission...</Text>
      ) : cameraPermission === false ? (
        <Text>Permission denied</Text>
      ) : (
        <Autoshoot/>
      )}
    </View>
  );
}

最后:拍照

要触发快门,我们将在Camera组件内部放一个“按钮”。不幸的是,Camera不支持onPress prop(点击时触发的那个),所以我们将导入TouchableOpacity并将其作为子组件渲染在里面。

在顶部,导入它

import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';

然后在Autoshoot的render中,将组件插入为Camera的子级

render() {
  const { photo } = this.state;

  return (
    <Camera
      style={{ flex: 1 }}
      type={Camera.Constants.Type.back}
      ref={cam => this.camera = cam}>
      <TouchableOpacity
        style={{ flex: 1 }}
        onPress={this.takePicture}/>
    </Camera>
  );
}

然后我们需要一个takePicture方法,我们可以在render上方插入

takePicture = () => {
  this.camera.takePictureAsync({
    quality: 0.1,
    base64: true,
    exif: false
  }).then(photo => {
    this.setState({ photo });
  })
}

此时,应用的行为将保持不变:当您点击屏幕时,应用仍将显示相机(并且希望没有错误)。

接下来,我们需要在顶部初始化photo的状态

class Autoshoot extends React.Component {
  state = {
    photo: null
  }

  ...
}

然后在render中,我们将根据是否有照片来渲染照片(如果有)或相机

render() {
  const { photo } = this.state;

  return (
    <View style={{ flex: 1, width: '100%' }}>
     {photo ? (
       <ImageBackground
         style={{ flex: 1 }}
         source={{ uri: photo.uri }} />
     ) : (
       <Camera
         style={{ flex: 1 }}
         onPress={this.takePicture}
         type={Camera.Constants.Type.back}
         ref={cam => this.camera = cam}>
         <TouchableOpacity
           style={{ flex: 1 }}
           onPress={this.takePicture}/>
       </Camera>
     )}
    </View>
  );
}

我们在这里也第一次使用了ImageBackground组件,所以请确保从‘react-native’顶部导入它

import { StyleSheet, Text, View, TouchableOpacity, ImageBackground } from 'react-native';

好了!现在您可以点击屏幕拍照,照片将一直显示在屏幕上。

这里有一个小练习

让当您点击捕获的照片时,应用返回显示相机。提示:ImageBackground不支持onPress,所以您需要使用与我们使用TouchableOpacity相同的技巧。

定时拍照

我们已经有了手动拍照的代码——现在让我们自动化它。

我们可以通过在间隔内调用takePicture来实现这一点。但有一个小问题:相机需要一点时间才能对焦,然后再拍照。所以我们真正需要的是这样的:

  1. 激活相机(屏幕显示实时相机)
  2. 让它对焦3秒
  3. 拍照(屏幕显示静态图像)
  4. 等待27秒
  5. 转到1

一旦我们成功做到这一点,我们将插入一个步骤“3a”:将照片发送到服务器。(服务器还不存在,但我们稍后会处理)。

当Autoshoot最初渲染时,我们将启动一个30秒的计时器。让我们为计时器和对焦时间创建一个常量,因为我们将在几个地方用到它们。

const PHOTO_INTERVAL = 30000;
const FOCUS_TIME = 3000;

class Autoshoot extends React.Component {
  componentDidMount() {
    this.countdown = setTimeout(
      this.takePicture,
      PHOTO_INTERVAL
    );
  }

  componentWillUnmount() {
    clearInterval(this.countdown);
  }

  ...
}

出于测试目的,请将超时时间改为2秒,这样我们就不会等一整天。

当应用重新加载时(可以通过摇晃设备并选择“Reload JS Bundle”手动触发),照片将自动拍摄。太棒了。

启动另一个计时器

现在我们已经能自动拍照了,我们只需要再加几个计时器就能全天拍照。

有几种方法可以做到这一点:我们可以使用两个堆叠的计时器(一个27秒,然后触发一个3秒的),或者我们可以使用2个同时运行的计时器,或者我们可以使用setState回调。

后一种选择可能是最精确的(并避免潜在的竞态条件),但我们将选择简单的方法:2个同时运行的计时器。由于触发器之间的间隔很长,竞态条件/计时器重叠的可能性很小。

为了实现它,请用以下实现替换takePicture

takePicture = () => {
  this.camera.takePictureAsync({
    quality: 0.1,
    base64: true,
    exif: false
  }).then(photo => {
    this.setState({ photo });

    // In 27 seconds, turn the camera back on
    setTimeout(() => {
      this.setState({ photo: null });
    }, PHOTO_INTERVAL - FOCUS_TIME);

    // In 30 seconds, take the next picture
    setTimeout(this.takePicture, PHOTO_INTERVAL);
  });
}

现在当应用刷新时,它将无限期地拍照。(或者直到您的电池耗尽)

Express服务器

我们现在有了一个能够拍照的React Native应用。让我们来构建一个服务器来发送照片。

我们将使用Express来编写一个极简的服务器来处理两个路由

  • POST /: 上传新照片
  • GET /: 查看最新照片

对于这个最简单的服务器,我们将在grillview项目的根目录创建一个server.js文件。React Native和Express,并排运行。(这是创建Real Projects™的推荐方式吗?不,但这一切都有点 hacky,所以)。

我们需要几个包来实现这一点,所以现在安装它们

yarn add express body-parser

然后我们可以从一个极简的Express服务器开始。创建server.js文件并粘贴以下内容

const express = require('express');
const bodyParser = require('body-parser');

const app = express();

// If your phone has a modern camera (unlike my iPhone 4S)
// you might wanna make this bigger.
app.use(bodyParser.json({ limit: '10mb' }));

// TODO: handle requests

const port = process.env.PORT || 5005;
app.listen(port);

console.log(`Grill server listening on ${port}`);

这还不能处理请求,但可以运行。我们已经设置了bodyparser.json来处理POST的图片。现在让我们在TODO的位置添加POST请求处理程序

// Store the single image in memory.
let latestPhoto = null;

// Upload the latest photo for this session
app.post('/', (req, res) => {
  // Very light error handling
  if(!req.body) return res.sendStatus(400);

  console.log('got photo')

  // Update the image and respond happily
  latestPhoto = req.body.photo;
  res.sendStatus(200);
});

这只是接受来自客户端的图片并将其保存在一个本地变量中,稍后返回。

快速警告:这没有任何安全性措施。我们盲目地保存客户端发送的东西,然后将其传回,这在部署的应用中是灾难的根源。但由于我只在本地网络上运行它,所以我不太担心。对于真正的应用,在保存图片之前要进行一些验证。

在此下方,我们将添加将最新图片发送回来的GET处理程序

// View latest image
app.get('/', (req, res) => {
  // Does this session have an image yet?
  if(!latestPhoto) {
    return res.status(404).send("Nothing here yet");
  }

  console.log('sending photo');

  try {
    // Send the image
    var img = Buffer.from(latestPhoto, 'base64');
    res.writeHead(200, {
      'Content-Type': 'image/png',
      'Content-Length': img.length
    });
    res.end(img);
  } catch(e) {
    // Log the error and stay alive
    console.log(e);
    return res.sendStatus(500);
  }
});

我们正在创建一个缓冲区来将base64图片转换为二进制,然后将其发送给客户端。

再次重申:这不是一个安全的设置。我们假设客户端发送了我们一个好的base64图片,但规则1是“不要相信客户端”——在存储图片之前我们应该进行验证。

服务器只需要这些!启动它

node server.js

然后访问https://:5005——您应该看到消息“Nothing here yet”(这里还没有东西)。让服务器在一个单独的命令行终端中运行,我们将继续处理发送图片到服务器。

上传图片

回到App.js和Autoshoot组件,我们需要添加一个上传图片的方法。在一个更大的应用中,我们可能会将API方法提取到一个单独的文件中并将其导出为单独的函数——但由于我们只需要进行一次调用,所以我们将其放在Autoshoot中。添加这个方法

uploadPicture = () => {
  return fetch(SERVER_URL, {
    body: JSON.stringify({
      image: this.state.photo.base64
    }),
    headers: {
      'content-type': 'application/json'
    },
    method: 'POST'
  })
  .then(response => response.json())
}

在这里,我们使用fetch(它是React Native内置的)将数据POST到服务器。请注意SERVER_URL变量,我们还没有创建它。由于这只会在我们的本地网络上工作,所以我们可以将其硬编码在Autoshoot的上方

const SERVER_URL = 'http://<your-ip>:5005/'

将<your-ip>替换为您自己的开发机的IP地址。如果您不知道在哪里查找,Google是您的朋友 :)

现在我们将修改takePicture来调用uploadPicture,并且作为该更改的一部分,我们将把计时器代码提取到一个单独的方法中,因为我们想从两个地方调用它

// Here's the timer code, lifted from takePicture:
queuePhoto = () => {
  // In 27 seconds, turn the camera back on
  setTimeout(() => {
    this.setState({ photo: null });
  }, PHOTO_INTERVAL - FOCUS_TIME);

  // In 30 seconds, take the next picture
  setTimeout(this.takePicture, PHOTO_INTERVAL);
}

// Take the picture, upload it, and
// then queue up the next one
takePicture = () => {
  this.camera.takePictureAsync({
    quality: 0.1,
    base64: true,
    exif: false
  }).then(photo => {
    this.setState({ photo }, () => {
      this.uploadPicture()
        .then(this.queuePhoto)
        .catch(this.queuePhoto);
    });
  });
}

请注意,我在.then和.catch处理程序中都调用了queuePhoto。

我希望即使我重新启动服务器(这会导致请求失败),应用程序也能继续运行,所以我只是让它完全忽略错误。

在开发过程中,在这里添加一个console log来查看为什么东西会失败(语法错误等)很有用,但一旦一切正常,我就删除了它。

是时候开始烹饪手撕猪肉了!

有了最后这些更改,应用程序就可以工作了!

我迫不及待想试试。第二天早上,我设置好了温度计和手机。启动了应用,然后……嗯,没有一个好地方放手机。

我本可以把手机和温度计放在地上。我应该那样做的。一个理性的人会怎么做。

早上7点的戴夫没有这样做。他抓起一块旧木板,切了两块废木料,然后把它组装成一个小架子,靠在房子上。

A makeshift shelf for the phone

“木工活”。它有内六角螺钉。为什么?我不知道。

至于应用程序?

它的表现很出色。大部分时间。它只崩溃了几次。

事实证明它很有用,为我省去了上下楼检查温度的麻烦。A+++,下次还会再做。

The view from the grill cam

手撕猪肉也非常美味。

The final product: pulled pork

体会

我认为在编程项目中加入乐趣很重要。允许自己去构建已经存在的东西,即使只是为了学习如何自己构建它。它不必是一个宏大的严肃项目,也不必是一个完美的作品集。

就此而言,不要害怕粗制滥造。这是一个有趣的计划!写一些你知道是糟糕的代码。不要过于担心完美的抽象和最佳实践,也不要觉得你必须集成每一个新库和工具。没事的。你总可以在写博客文章时重构它;)

食谱、工具、代码……

(无联盟链接,只是好产品)

感谢阅读!

 

Perfect Pulled Pork with React Native, Expo, and Express 最初由Dave Ceddia于2018年6月7日在Dave Ceddia上发布。

© . All rights reserved.