Perfect Pulled Pork with React Native, Expo, and Express
. 或者:每30秒拍一张照片并发送到服务器。. 我计划第二天做手撕猪肉。那天晚上,我设置好了Weber烤炉,拿出了木炭袋和一些苹果木块,把所有东西都摆好。俗话说,准备工作(Mise en place)。
或者:每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 :)
宏伟计划:系统布局
就像任何好的项目一样,我开始思考它应该如何工作。
我需要
- 一部带摄像头的手机(旧款iPhone 4S)。
- 手机上运行一个全天拍照的应用程序。
- 一个运行在我的笔记本电脑上的接收照片的服务器。
- 同一个服务器来提供最新照片。
我决定尽量保持最简化(主要是因为已经是晚上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本身的权限。
实时相机视图
现在,让我们将“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来实现这一点。但有一个小问题:相机需要一点时间才能对焦,然后再拍照。所以我们真正需要的是这样的:
- 激活相机(屏幕显示实时相机)
- 让它对焦3秒
- 拍照(屏幕显示静态图像)
- 等待27秒
- 转到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+++,下次还会再做。
手撕猪肉也非常美味。
体会
我认为在编程项目中加入乐趣很重要。允许自己去构建已经存在的东西,即使只是为了学习如何自己构建它。它不必是一个宏大的严肃项目,也不必是一个完美的作品集。
就此而言,不要害怕粗制滥造。这是一个有趣的计划!写一些你知道是糟糕的代码。不要过于担心完美的抽象和最佳实践,也不要觉得你必须集成每一个新库和工具。没事的。你总可以在写博客文章时重构它;)
食谱、工具、代码……
- 您可以在Github上获取此项目的完整代码。
- 我遵循了Amazing Ribs的完美手撕猪肉食谱。
- 我使用的是Weber 22英寸烤炉,搭配Slow n’ Sear(显然已停产,但我看到有v2,看起来很相似)。
- 温度计是ThermoWorks DOT。
(无联盟链接,只是好产品)
感谢阅读!
Perfect Pulled Pork with React Native, Expo, and Express 最初由Dave Ceddia于2018年6月7日在Dave Ceddia上发布。