发布于 2026-01-06 3 阅读
0

使用 React Native 和 Pusher 创建打车应用 DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

使用 React Native 和 Pusher 创建一个打车应用

由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!

在本教程中,我们将使用 React Native 和 Pusher 创建一个打车应用。我们将创建的应用类似于 Uber、Lyft 或 Grab 等流行的打车应用。
我们将使用 React Native 为司机和乘客分别创建一个 Android 应用。Pusher 将用于实现两者之间的实时通信。

你将创造什么

就像其他打车应用一样,这款应用也会有司机端应用和乘客端应用。乘客端应用用于预订行程,司机端应用则接收来自乘客端应用的叫车请求。为了方便起见,我们将这款应用统称为“grabClone”。

应用流程

我们将要创建的克隆版流程与市面上任何一款打车应用的流程基本相同:乘客预订行程 → 应用寻找司机 → 司机接受请求 → 司机接载乘客 → 司机前往目的地 → 乘客向司机付款。

在这里,我只想向您展示一下这个过程在应用程序内部是如何进行的。这样您就能清楚地了解您将要创建的内容。

  1. 该应用程序确定用户的位置并在地图上显示(注意:此时需要启用 GPS)。
    默认屏幕

  2. 用户通过乘客应用程序点击“预订行程”。

  3. 将会弹出一个模态框,乘客可以从中选择他们想去的地方。
    当前位置

  4. 该应用程序会要求乘客确认目的地。
    确认目的地

  5. 确认后,应用会向司机端应用发送接载乘客的请求。在应用等待司机接受请求期间,会显示加载动画。
    等待司机

  6. 司机端应用收到请求后,司机可以选择接受或拒绝该请求。
    司机收到请求

  7. 司机接受请求后,司机的详细信息将显示在乘客应用程序中。
    司机信息将发送给乘客。

  8. 乘客应用程序会在地图上显示司机的当前位置。
    驾驶员当前位置

  9. 当司机距离乘客位置 50 米以内时,乘客会看到一条提示信息,表明司机已接近。

  10. 当司机距离乘客位置20米以内时,司机端应用程序会向乘客端应用程序发送消息,告知司机即将到达。
    下车点附近有通知

  11. 司机接上乘客后,将车辆开往目的地。

  12. 当司机距离目的地还有 20 米时,司机端应用程序会向乘客端应用程序发送消息,告知他们即将到达目的地。
    通知位于下车点附近 20 米处

至此,行程结束,乘客可以预订下一趟行程。司机也可以自由接受任何新的乘车请求。

先决条件

接下来,点击“应用设置”选项卡,勾选“启用客户端事件”。这样,司机端应用和乘客端应用就可以直接相互通信了。
启用客户端事件

最后,点击“应用密钥”并复制凭据。如果您担心价格,Pusher 沙盒计划非常慷慨,您可以在测试应用时免费使用。

  • 安装 Android Studio——其实你并不一定需要 Android Studio,但它自带 Android SDK,而这正是我们需要的。谷歌也不再单独提供 Android SDK 的下载。
  • 安装 React Native——我推荐使用原生方式构建项目。在 React Native 官网上,点击“使用原生代码构建项目”选项卡,然后按照其中的说明操作。Expo客户端非常适合快速构建应用原型,但它并没有提供快速测试我们应用所需的地理定位功能的方法。
  • Genymotion——用于测试驾驶员应用程序。我们之所以使用它而不是默认的安卓模拟器,是因为它自带GPS模拟工具,可以让我们搜索特定位置并将其用作模拟设备的定位。它使用谷歌地图作为界面,并且可以移动标记。这使我们能够模拟车辆的行驶。
    安装Genymotion后,您需要登录您的帐户才能添加设备。我这里安装的是谷歌Nexus 5x用于测试。
    Genymotion

  • Android 设备- 此设备将用于测试乘客应用。请务必检查您手机的 Android 版本。如果版本低于 4.2,则需要通过 Android SDK 管理器安装额外的软件包。这是因为 React Native 默认支持 API 版本 23 或更高版本。这意味着您的手机 Android 版本至少需要达到 6.0,否则应用将无法运行。如果您已安装 Android Studio,可以通过以下步骤访问 SDK 管理器:打开 Android Studio → 点击“配置” → 选择“SDK 管理器”。然后在“SDK 平台”下,勾选您想要支持的 Android 版本。
    Android SDK 管理器

在那里,点击“SDK 工具”,确保你安装的工具和我安装的一样:

Android SDK 工具

  • 额外的电脑——这是可选的。我在这里提到它,是因为 React Native 一次只能在一台设备或模拟器上运行应用。因此,你需要做一些额外的工作才能同时运行这两个应用,稍后你会看到。

创建身份验证服务器

现在是时候动手实践了。首先,我们来构建认证服务器。这是必需的,因为我们将从应用发送客户端事件,而客户端事件需要 Pusher 通道是私有的,私有通道的访问权限受到限制。这就是认证服务器的作用所在。它可以让 Pusher 确认尝试连接的用户是否确实是该应用的注册用户。

首先安装依赖项:

npm install --save express body-parser pusher
Enter fullscreen mode Exit fullscreen mode

接下来,创建一个server.js文件并添加以下代码:

var express = require('express');
var bodyParser = require('body-parser');
var Pusher = require('pusher');

var app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

var pusher = new Pusher({ // connect to pusher
  appId: process.env.APP_ID, 
  key: process.env.APP_KEY, 
  secret:  process.env.APP_SECRET,
  cluster: process.env.APP_CLUSTER, 
});

app.get('/', function(req, res){ // for testing if the server is running
  res.send('all is well...');
});

// for authenticating users
app.get("/pusher/auth", function(req, res) {
  var query = req.query;
  var socketId = query.socket_id;
  var channel = query.channel_name;
  var callback = query.callback;

  var auth = JSON.stringify(pusher.authenticate(socketId, channel));
  var cb = callback.replace(/\"/g,"") + "(" + auth + ");";

  res.set({
    "Content-Type": "application/javascript"
  });

  res.send(cb);
});

app.post('/pusher/auth', function(req, res) {
  var socketId = req.body.socket_id;
  var channel = req.body.channel_name;
  var auth = pusher.authenticate(socketId, channel);
  res.send(auth);
});

var port = process.env.PORT || 5000;
app.listen(port);
Enter fullscreen mode Exit fullscreen mode

以上代码的具体功能在用户身份验证文档中已有详细说明,因此我不再赘述
为了简化说明,我没有添加检查用户是否真实存在于数据库中的代码。您可以/pusher/auth通过检查用户名是否存在来实现这一点。以下是一个示例:

var users = ['luz', 'vi', 'minda'];
var username = req.body.username;

if(users.indexOf(username) !== -1){
  var socketId = req.body.socket_id;
  var channel = req.body.channel_name;
  var auth = pusher.authenticate(socketId, channel);
  res.send(auth);
}

// otherwise: return error
Enter fullscreen mode Exit fullscreen mode

username稍后在客户端连接 Pusher 时,不要忘记传入该参数。

完成后尝试运行服务器:

node server.js
Enter fullscreen mode Exit fullscreen mode

请在浏览器中访问http://localhost:5000,看看是否有效。

部署身份验证服务器

由于 Pusher 需要连接到认证服务器,因此该服务器必须能够从互联网访问。
您可以使用now.sh来部署认证服务器。您可以使用以下命令安装它:

npm install now
Enter fullscreen mode Exit fullscreen mode

安装完成后,您可以找到文件所在的文件夹server.js并执行该文件now。系统会要求您输入电子邮件地址并验证您的帐户。

账户验证成功后,请执行以下操作,将您的 Pusher 应用设置作为环境变量添加到 now.sh 账户中,以便您可以在服务器内部使用它:

now secret add pusher_app_id YOUR_PUSHER_APP_ID
now secret add pusher_app_key YOUR_PUSHER_APP_KEY
now secret add pusher_app_secret YOUR_PUSHER_APP_SECRET
now secret add pusher_app_cluster YOUR_PUSHER_APP_CLUSTER
Enter fullscreen mode Exit fullscreen mode

接下来,部署服务器时,请提供您添加的密钥值:

now -e APP_ID=@pusher_app_id -e APP_KEY=@pusher_app_key -e APP_SECRET=@pusher_app_secret APP_CLUSTER=@pusher_app_cluster
Enter fullscreen mode Exit fullscreen mode

这样,您就可以从服务器内部访问 Pusher 应用的设置,如下所示:

process.env.APP_ID
Enter fullscreen mode Exit fullscreen mode

now.sh 返回的部署 URL 是您稍后将用来将应用程序连接到身份验证服务器的 URL。

创建司机应用程序

现在你可以开始创建司机端应用程序了。

首先,创建一个新的 React Native 应用:

react-native init grabDriver
Enter fullscreen mode Exit fullscreen mode

安装依赖项

完成上述步骤后,进入该grabDriver目录并安装我们需要的库。这包括用于操作 Pusher 的pusher-js 、用于显示地图的React Native Maps以及用于将坐标反向地理编码为实际地点名称的React Native Geocoding :

npm install --save pusher-js react-native-maps react-native-geocoding
Enter fullscreen mode Exit fullscreen mode

所有库安装完毕后,React Native Maps 还需要一些额外的步骤才能正常工作。首先是链接项目资源:

react-native link react-native-maps
Enter fullscreen mode Exit fullscreen mode

接下来,您需要创建一个 Google 项目,从Google 开发者控制台获取 API 密钥,并启用Google Maps Android APIGoogle Maps Geocoding API。之后,打开android\app\src\main\AndroidManifest.xml项目目录中的文件。在`<script>`<application>标签下,添加一个<meta-data>包含服务器 API 密钥的元素。

<application>
    <meta-data
      android:name="com.google.android.geo.API_KEY"
      android:value="YOUR GOOGLE SERVER API KEY"/>
</application>
Enter fullscreen mode Exit fullscreen mode

您可以在添加权限时,在默认权限下方添加以下内容。这将允许我们检查网络状态并从设备请求地理位置数据。

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
Enter fullscreen mode Exit fullscreen mode

还要确保它的目标 API 版本与您使用 Genymotion 安装的设备相同。正如我之前所说,如果版本是 23 或更高,您无需进行任何操作;但如果低于 23,则版本必须完全一致,应用程序才能正常工作。

<uses-sdk
        android:minSdkVersion="16"
        android:targetSdkVersion="23" />
Enter fullscreen mode Exit fullscreen mode

最后,由于我们将主要使用 Genymotion 来测试司机端应用程序,您需要按照此处的说明进行操作。如果链接失效,请按以下步骤操作:

  1. 访问opengapps.org
  2. 选择 x86 作为平台。
  3. 选择与您的虚拟设备对应的安卓版本。
  4. 选择 nano 作为变体。
  5. 下载压缩文件。
  6. 将 zip 安装程序拖放到新的 Genymotion 虚拟设备中(仅限 2.7.2 及更高版本)。
  7. 按照弹出窗口的指示操作。

我们需要这样做是因为 React Native Maps 库主要使用 Google Maps。为了使其正常工作,我们需要添加 Google Play 服务。与大多数预装了该服务的 Android 手机不同,由于知识产权原因,Genymotion 默认没有安装该服务。因此,我们需要手动安装。

如果您是在本文发布一段时间后才阅读此内容,请务必查看安装文档,以确保您没有遗漏任何内容。

编写驾驶员应用程序

现在你可以开始编写应用程序代码了。首先打开index.android.js文件,并将默认代码替换为以下代码:

import { AppRegistry } from 'react-native';
import App from './App';
AppRegistry.registerComponent('grabDriver', () => App);
Enter fullscreen mode Exit fullscreen mode

这样做会导入App应用程序的主组件。然后将其注册为默认组件,以便在屏幕上渲染。

接下来,创建App.js文件并从 React Native 包中导入我们需要的内容:

import React, { Component } from 'react';
import {
  StyleSheet,
  Text,
  View,
  Alert
} from 'react-native';
Enter fullscreen mode Exit fullscreen mode

同时导入我们之前安装的第三方库:

import Pusher from 'pusher-js/react-native';
import MapView from 'react-native-maps';

import Geocoder from 'react-native-geocoding';
Geocoder.setApiKey('YOUR GOOGLE SERVER API KEY');
Enter fullscreen mode Exit fullscreen mode

最后,导入helpers文件:

import { regionFrom, getLatLonDiffInMeters } from './helpers';
Enter fullscreen mode Exit fullscreen mode

helpers.js文件包含以下内容:

export function regionFrom(lat, lon, accuracy) {
  const oneDegreeOfLongitudeInMeters = 111.32 * 1000;
  const circumference = (40075 / 360) * 1000;

  const latDelta = accuracy * (1 / (Math.cos(lat) * circumference));
  const lonDelta = (accuracy / oneDegreeOfLongitudeInMeters);

  return {
    latitude: lat,
    longitude: lon,
    latitudeDelta: Math.max(0, latDelta),
    longitudeDelta: Math.max(0, lonDelta)
  };
} 

export function getLatLonDiffInMeters(lat1, lon1, lat2, lon2) {
  var R = 6371; // Radius of the earth in km
  var dLat = deg2rad(lat2-lat1);  // deg2rad below
  var dLon = deg2rad(lon2-lon1); 
  var a = 
    Math.sin(dLat/2) * Math.sin(dLat/2) +
    Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * 
    Math.sin(dLon/2) * Math.sin(dLon/2)
    ; 
  var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); 
  var d = R * c; // Distance in km
  return d * 1000;
}

function deg2rad(deg) {
  return deg * (Math.PI/180)
}
Enter fullscreen mode Exit fullscreen mode

这些函数用于获取 React Native Maps 库显示地图所需的经纬度差值。另一个函数(getLatLonDiffInMeters)用于确定两个坐标之间的距离(以米为单位)。稍后,这将使我们能够告知用户他们是否已经很近,或者何时接近目的地。

接下来,创建主应用程序组件并声明默认状态:

export default class grabDriver extends Component {

  state = {
    passenger: null, // for storing the passenger info
    region: null, // for storing the current location of the driver
    accuracy: null, // for storing the accuracy of the location
    nearby_alert: false, // whether the nearby alert has already been issued
    has_passenger: false, // whether the driver has a passenger (once they agree to a request, this becomes true)
    has_ridden: false // whether the passenger has already ridden the vehicle
  }
}
// next: add constructor code
Enter fullscreen mode Exit fullscreen mode

在构造函数中,初始化应用程序中将要使用的变量:

constructor() {
  super();

  this.available_drivers_channel = null; // this is where passengers will send a request to any available driver
  this.ride_channel = null; // the channel used for communicating the current location
  // for a specific ride. Channel name is the username of the passenger

  this.pusher = null; // the pusher client
}

// next: add code for connecting to pusher
Enter fullscreen mode Exit fullscreen mode

authEndpoint在挂载组件之前,请连接到您之前创建的身份验证服务器。请务必替换 pusher key和 的cluster

componentWillMount() {
  this.pusher = new Pusher('YOUR PUSHER KEY', {
    authEndpoint: 'YOUR PUSHER AUTH SERVER ENDPOINT',
    cluster: 'YOUR PUSHER CLUSTER',
    encrypted: true
  });

  // next: add code for listening to passenger requests
}
Enter fullscreen mode Exit fullscreen mode

现在您已连接到身份验证服务器,可以开始监听来自乘客应用程序的请求了。第一步是订阅一个私有频道。所有乘客和司机都需要订阅此频道。在本例中,司机使用此频道监听乘车请求。由于安全原因,客户端事件只能在私有频道和在线状态频道上触发,因此必须使用私有频道。您可以通过频道前缀来判断它是私有频道private-

this.available_drivers_channel = this.pusher.subscribe('private-available-drivers'); // subscribe to "available-drivers" channel
Enter fullscreen mode Exit fullscreen mode

接下来,监听client-driver-request事件。你可以通过事件client-前缀判断这是一个客户端事件。客户端事件无需服务器干预即可正常工作,消息直接从客户端发送到客户端。这就是为什么我们需要一个身份验证服务器来确保所有尝试连接的用户都是该应用的真实用户。

回到代码,我们通过调用bind已订阅通道上的方法来监听客户端事件,并将事件名称作为第一个参数传递。第二个参数是当另一个客户端(任何使用乘客应用请求乘车的用户)触发此事件时要执行的函数。在下面的代码中,我们显示一条提示信息,询问司机是否接受乘客。请注意,该应用假定任何时候只能有一位乘客。

// listen to the "driver-request" event
this.available_drivers_channel.bind('client-driver-request', (passenger_data) => {

  if(!this.state.has_passenger){ // if the driver has currently no passenger
    // alert the driver that they have a request
    Alert.alert(
      "You got a passenger!", // alert title
      "Pickup: " + passenger_data.pickup.name + "\nDrop off: " + passenger_data.dropoff.name, // alert body
      [
        {
          text: "Later bro", // text for rejecting the request
          onPress: () => {
            console.log('Cancel Pressed');
          },
          style: 'cancel'
        },
        {
          text: 'Gotcha!', // text for accepting the request
          onPress: () => {
            // next: add code for when driver accepts the request
          }  
        },
      ],
      { cancelable: false } // no cancel button
    );

  }

});
Enter fullscreen mode Exit fullscreen mode

一旦司机同意接载乘客,我们就会订阅他们的专属频道。该频道仅供司机和乘客之间沟通,因此我们使用乘客的唯一用户名作为频道名称的一部分。

this.ride_channel = this.pusher.subscribe('private-ride-' + passenger_data.username);
Enter fullscreen mode Exit fullscreen mode

与频道类似available-drivers,我们需要监听订阅成功(pusher:subscription_succeeded)才能进行其他操作。这是因为我们会立即触发一个客户端事件发送给乘客。该事件(client-driver-response)是一个握手事件,用于告知乘客他们发送请求的司机仍然可用。如果此时乘客仍未乘车,乘客端应用会触发相同的事件,告知司机他们仍然可以接单。此时,我们会更新状态,以便用户界面随之改变。

this.ride_channel.bind('pusher:subscription_succeeded', () => {
   // send a handshake event to the passenger
  this.ride_channel.trigger('client-driver-response', {
    response: 'yes' // yes, I'm available
  });

  // listen for the acknowledgement from the passenger
  this.ride_channel.bind('client-driver-response', (driver_response) => {

    if(driver_response.response == 'yes'){ // passenger says yes

      //passenger has no ride yet
      this.setState({
        has_passenger: true,
        passenger: {
          username: passenger_data.username,
          pickup: passenger_data.pickup,
          dropoff: passenger_data.dropoff
        }
      });

      // next: reverse-geocode the driver location to the actual name of the place

    }else{
      // alert that passenger already has a ride
      Alert.alert(
        "Too late bro!",
        "Another driver beat you to it.",
        [
          {
            text: 'Ok'
          },
        ],
        { cancelable: false }
      );
    }

  });

});
Enter fullscreen mode Exit fullscreen mode

接下来,我们使用地理编码库来确定司机当前所在位置的名称。这实际上是在后台使用 Google 地理编码 API,通常会返回街道名称。收到响应后,我们会触发一个事件,found-driver通知乘客应用已为其找到司机。该事件包含司机信息,例如姓名和当前位置。

Geocoder.getFromLatLng(this.state.region.latitude, this.state.region.longitude).then(
  (json) => {
    var address_component = json.results[0].address_components[0];

    // inform passenger that it has found a driver
    this.ride_channel.trigger('client-found-driver', { 
      driver: {
        name: 'John Smith'
      },
      location: { 
        name: address_component.long_name,
        latitude: this.state.region.latitude,
        longitude: this.state.region.longitude,
        accuracy: this.state.accuracy
      }
    });

  },
  (error) => {
    console.log('err geocoding: ', error);
  }
);  
// next: add componentDidMount code
Enter fullscreen mode Exit fullscreen mode

组件挂载完成后,我们使用React Native 的 Geolocation API来监听位置更新。您传递给该watchPosition函数的参数会在每次位置发生变化时执行。

componentDidMount() {
  this.watchId = navigator.geolocation.watchPosition(
    (position) => {

      var region = regionFrom(
        position.coords.latitude, 
        position.coords.longitude, 
        position.coords.accuracy
      );
      // update the UI
      this.setState({
        region: region,
        accuracy: position.coords.accuracy
      });

      if(this.state.has_passenger && this.state.passenger){
        // next: add code for sending driver's current location to passenger
      }
    },
    (error) => this.setState({ error: error.message }),
    { 
      enableHighAccuracy: true, // allows you to get the most accurate location
      timeout: 20000, // (milliseconds) in which the app has to wait for location before it throws an error
      maximumAge: 1000, // (milliseconds) if a previous location exists in the cache, how old for it to be considered acceptable 
      distanceFilter: 10 // (meters) how many meters the user has to move before a location update is triggered
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

接下来,将司机的当前位置发送给乘客。这将更新乘客应用程序的界面,显示司机的当前位置。稍后在编写乘客应用程序代码时,您将看到乘客应用程序如何绑定到此事件。

this.ride_channel.trigger('client-driver-location', { 
  latitude: position.coords.latitude,
  longitude: position.coords.longitude,
  accuracy: position.coords.accuracy
});
Enter fullscreen mode Exit fullscreen mode

接下来,我们需要通知乘客和司机他们已经很近了。为此,我们使用文件getLatLonDiffInMeters中的函数helpers.js来确定乘客和司机之间的距离(以米为单位)。由于司机在接受请求时已经获得了乘客的位置信息,因此我们只需要获取司机的当前位置并将其传递给函数,即可计算出两车之间的距离(以getLanLonDiffInMeters米为单位)。之后,我们只需根据距离(以米为单位)通知司机或乘客即可。稍后您将看到乘客应用程序是如何接收这些事件的。

var diff_in_meter_pickup = getLatLonDiffInMeters(
  position.coords.latitude, position.coords.longitude, 
  this.state.passenger.pickup.latitude, this.state.passenger.pickup.longitude);

if(diff_in_meter_pickup <= 20){

  if(!this.state.has_ridden){
    // inform the passenger that the driver is very near
    this.ride_channel.trigger('client-driver-message', {
      type: 'near_pickup',
      title: 'Just a heads up',
      msg: 'Your driver is near, let your presence be known!'
    });

    /*
    we're going to go ahead and assume that the passenger has rode 
    the vehicle at this point
    */
    this.setState({
      has_ridden: true
    });
  }

}else if(diff_in_meter_pickup <= 50){

  if(!this.state.nearby_alert){
    this.setState({
      nearby_alert: true
    });
    /* 
    since the location updates every 10 meters, this alert will be triggered 
    at least five times unless we do this
    */
    Alert.alert(
      "Slow down",
      "Your passenger is just around the corner",
      [
        {
          text: 'Gotcha!'
        },
      ],
      { cancelable: false }
    );

  }

}

// next: add code for sending messages when near the destination
Enter fullscreen mode Exit fullscreen mode

此时,我们假设司机已接载乘客,并正在前往目的地。因此,我们获取当前位置与下车点之间的距离。当距离下车点还有 20 米时,司机端应用会向乘客发送消息,告知他们即将到达目的地。之后,我们假设乘客将在几秒钟后下车。因此,我们解除正在监听的事件绑定,并取消订阅乘客的私有频道。这实际上切断了司机端和乘客端应用之间的连接。唯一保持连接的是频道available-drivers

var diff_in_meter_dropoff = getLatLonDiffInMeters(
  position.coords.latitude, position.coords.longitude, 
  this.state.passenger.dropoff.latitude, this.state.passenger.dropoff.longitude);

if(diff_in_meter_dropoff <= 20){
  this.ride_channel.trigger('client-driver-message', {
    type: 'near_dropoff',
    title: "Brace yourself",
    msg: "You're very close to your destination. Please prepare your payment."
  });

  // unbind from passenger event
  this.ride_channel.unbind('client-driver-response');
  // unsubscribe from passenger channel 
  this.pusher.unsubscribe('private-ride-' + this.state.passenger.username);

  this.setState({
    passenger: null,
    has_passenger: false,
    has_ridden: false
  });

}

// next: add code for rendering the UI
Enter fullscreen mode Exit fullscreen mode

驾驶员应用程序的用户界面仅显示地图以及驾驶员和乘客的标记。

render() {
  return (
    <View style={styles.container}>
      {
        this.state.region && 
        <MapView
          style={styles.map}
          region={this.state.region}
        >
            <MapView.Marker
              coordinate={{
              latitude: this.state.region.latitude, 
              longitude: this.state.region.longitude}}
              title={"You're here"}
            />
            {
              this.state.passenger && !this.state.has_ridden && 
              <MapView.Marker
                coordinate={{
                latitude: this.state.passenger.pickup.latitude, 
                longitude: this.state.passenger.pickup.longitude}}
                title={"Your passenger is here"}
                pinColor={"#4CDB00"}
              />
            }
        </MapView>
      }
    </View>
  );
}
// next: add code when component unmounts
Enter fullscreen mode Exit fullscreen mode

在组件卸载之前,我们通过调用以下clearWatch方法停止位置监视器:

componentWillUnmount() {
  navigator.geolocation.clearWatch(this.watchId);
} 
Enter fullscreen mode Exit fullscreen mode

最后,添加样式:

const styles = StyleSheet.create({
  container: {
    ...StyleSheet.absoluteFillObject,
    justifyContent: 'flex-end',
    alignItems: 'center',
  },
  map: {
    ...StyleSheet.absoluteFillObject,
  },
});
Enter fullscreen mode Exit fullscreen mode

创建乘客应用程序

乘客端应用和司机端应用非常相似,所以我不再赘述相同部分的细节。请继续创建新应用:

react-native init grabClone
Enter fullscreen mode Exit fullscreen mode

安装依赖项

你还需要安装相同的库以及另外几个库:

npm install --save pusher-js react-native-geocoding github:geordasche/react-native-google-place-picker react-native-loading-spinner-overlay react-native-maps
Enter fullscreen mode Exit fullscreen mode

另外两个库是Google Place PickerLoading Spinner Overlay 。不过,由于原版 Google Place Picker 与 React Native Maps 存在兼容性问题,我们使用的是其分支版本。

既然我们已经安装了相同的库,您可以返回到之前进行额外配置以使库正常工作的章节。完成这些配置后再回到这里。

接下来,Google 地点选择器还需要一些额外的配置才能正常工作。首先,打开该android/app/src/main/java/com/grabClone/MainApplication.java文件,并在最后一个导入语句下方添加以下内容:

import com.reactlibrary.RNGooglePlacePickerPackage;
Enter fullscreen mode Exit fullscreen mode

将你刚刚导入的库添加到getPackages()函数下方。同时,确保它也MapsPackage()被列出来。

protected List<ReactPackage> getPackages() {
  return Arrays.<ReactPackage>asList(
      new MainReactPackage(),
      new MapsPackage(),
      new RNGooglePlacePickerPackage() // <- add this
  );
}
Enter fullscreen mode Exit fullscreen mode

接下来,打开android/settings.gradle文件,并在指令上方添加以下内容include ':app'

include ':react-native-google-place-picker'
project(':react-native-google-place-picker').projectDir = new File(rootProject.projectDir,         '../node_modules/react-native-google-place-picker/android')
Enter fullscreen mode Exit fullscreen mode

同时,请确保也添加了 React Native Maps 的资源:

include ':react-native-maps'
project(':react-native-maps').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-maps/lib/android')
Enter fullscreen mode Exit fullscreen mode

接下来,打开android/app/build.gradle文件并在以下位置添加以下内容dependencies

dependencies {
  compile project(':react-native-google-place-picker') // <- add this
}
Enter fullscreen mode Exit fullscreen mode

最后,请确保 React Native Maps 也已编译:

compile project(':react-native-maps')
Enter fullscreen mode Exit fullscreen mode

乘客应用程序的编码

打开index.android.js文件并添加以下内容:

import { AppRegistry } from 'react-native';
import App from './App';
AppRegistry.registerComponent('grabClone', () => App);
Enter fullscreen mode Exit fullscreen mode

就像驾驶员应用程序一样,它也使用App.js相同的组件作为主要组件。请导入库文件。它也使用相同的helpers.js文件,因此您也可以从驾驶员应用程序中复制该文件。

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

import Pusher from 'pusher-js/react-native';
import RNGooglePlacePicker from 'react-native-google-place-picker';
import Geocoder from 'react-native-geocoding';
import MapView from 'react-native-maps';
import Spinner from 'react-native-loading-spinner-overlay';

import { regionFrom, getLatLonDiffInMeters } from './helpers'; 

Geocoder.setApiKey('YOUR GOOGLE SERVER API KEY');
Enter fullscreen mode Exit fullscreen mode

创建组件并声明默认状态:

export default class App extends Component {
  state = {
    location: null, // current location of the passenger
    error: null, // for storing errors
    has_ride: false, // whether the passenger already has a driver which accepted their request
    destination: null, // for storing the destination / dropoff info
    driver: null, // the driver info
    origin: null, // for storing the location where the passenger booked a ride
    is_searching: false, // if the app is currently searching for a driver
    has_ridden: false // if the passenger has already been picked up by the driver
  };

  // next: add constructor code
}
Enter fullscreen mode Exit fullscreen mode

为了简化操作,我们在构造函数中声明乘客的用户名。我们还初始化了 Pusher 通道:

constructor() {
  super();
  this.username = 'wernancheta'; // the unique username of the passenger
  this.available_drivers_channel = null; // the pusher channel where all drivers and passengers are subscribed to
  this.user_ride_channel = null; // the pusher channel exclusive to the passenger and driver in a given ride
  this.bookRide = this.bookRide.bind(this); // bind the function for booking a ride
}
// next: add bookRide() function
Enter fullscreen mode Exit fullscreen mode

当用户点击“预订行程”按钮时,该bookRide()功能将被执行。这将打开一个地点选择器,允许用户选择目的地。选择地点后,应用程序会向所有司机发送行程请求。正如您之前在司机端应用程序中看到的那样,这会在司机端应用程序中触发一个提示框,询问司机是否接受该请求。此时,加载指示器会持续旋转,直到有司机接受请求为止。

bookRide() {

  RNGooglePlacePicker.show((response) => {
    if(response.didCancel){
      console.log('User cancelled GooglePlacePicker');
    }else if(response.error){
      console.log('GooglePlacePicker Error: ', response.error);
    }else{
      this.setState({
        is_searching: true, // show the loader
        destination: response // update the destination, this is used in the UI to display the name of the place
      });

      // the pickup location / origin
      let pickup_data = {
        name: this.state.origin.name,
        latitude: this.state.location.latitude,
        longitude: this.state.location.longitude
      };

      // the dropoff / destination
      let dropoff_data = {
        name: response.name,
        latitude: response.latitude,
        longitude: response.longitude
      };

      // send a ride request to all drivers
      this.available_drivers_channel.trigger('client-driver-request', {
        username: this.username,
        pickup: pickup_data,
        dropoff: dropoff_data
      });

    }
  });
}
// next: add _setCurrentLocation() function
Enter fullscreen mode Exit fullscreen mode

_setCurrentLocation()函数获取乘客的当前位置。请注意,这里我们使用的是 `<location>`,getCurrentPosition()而不是watchPosition()之前在司机端应用中使用的 `<location>`。两者唯一的区别在于,` getCurrentPosition()<location>` 只会获取一次位置信息。

_setCurrentLocation() {

  navigator.geolocation.getCurrentPosition(
    (position) => {
      var region = regionFrom(
        position.coords.latitude, 
        position.coords.longitude, 
        position.coords.accuracy
      );

      // get the name of the place by supplying the coordinates      
      Geocoder.getFromLatLng(position.coords.latitude, position.coords.longitude).then(
        (json) => {
          var address_component = json.results[0].address_components[0];

          this.setState({
            origin: { // the passenger's current location
              name: address_component.long_name, // the name of the place
              latitude: position.coords.latitude,
              longitude: position.coords.longitude
            },
            location: region, // location to be used for the Map
            destination: null, 
            has_ride: false, 
            has_ridden: false,
            driver: null    
          });

        },
        (error) => {
          console.log('err geocoding: ', error);
        }
      );

    },
    (error) => this.setState({ error: error.message }),
    { enableHighAccuracy: false, timeout: 10000, maximumAge: 3000 },
  );

}

// next: add componentDidMount() function
Enter fullscreen mode Exit fullscreen mode

当组件挂载时,我们希望设置乘客的当前位置,连接到身份验证服务器,并订阅两个频道:可用司机频道和乘客的私有频道,该私有频道仅用于与发送乘车请求的司机进行通信。

componentDidMount() {

  this._setCurrentLocation(); // set current location of the passenger
  // connect to the auth server
  var pusher = new Pusher('YOUR PUSHER API KEY', {
    authEndpoint: 'YOUR AUTH SERVER ENDPOINT',
    cluster: 'YOUR PUSHER CLUSTER',
    encrypted: true
  });

  // subscribe to the available drivers channel
  this.available_drivers_channel = pusher.subscribe('private-available-drivers');

  // subscribe to the passenger's private channel
  this.user_ride_channel = pusher.subscribe('private-ride-' + this.username);

  // next: add code for listening to handshake responses

}
Enter fullscreen mode Exit fullscreen mode

接下来,添加监听司机握手响应的代码。当司机接受乘车请求时,司机端应用会发送此响应。这可以确保乘客仍在寻找乘车服务。只有当乘客回复“是”时,司机才会发送其信息。

this.user_ride_channel.bind('client-driver-response', (data) => {
  let passenger_response = 'no';
  if(!this.state.has_ride){ // passenger is still looking for a ride
    passenger_response = 'yes';
  }

  // passenger responds to driver's response
  this.user_ride_channel.trigger('client-driver-response', {
    response: passenger_response
  });
});

// next: add listener for when a driver is found
Enter fullscreen mode Exit fullscreen mode

司机通过触发client-found-driver事件发送其信息。正如您之前在司机端应用中看到的那样,这些信息包含司机的姓名及其当前位置。

this.user_ride_channel.bind('client-found-driver', (data) => {
  // the driver's location info  
  let region = regionFrom(
    data.location.latitude,
    data.location.longitude,
    data.location.accuracy 
  );

  this.setState({
    has_ride: true, // passenger has already a ride
    is_searching: false, // stop the loading UI from spinning
    location: region, // display the driver's location in the map
    driver: { // the driver location details
      latitude: data.location.latitude,
      longitude: data.location.longitude,
      accuracy: data.location.accuracy
    }
  });

  // alert the passenger that a driver was found
  Alert.alert(
    "Orayt!",
    "We found you a driver. \nName: " + data.driver.name + "\nCurrent location: " + data.location.name,
    [
      {
        text: 'Sweet!'
      },
    ],
    { cancelable: false }
  );      
});
// next: add code for listening to driver's current location
Enter fullscreen mode Exit fullscreen mode

此时,乘客可以收听司机发送的位置变化信息。我们只需在每次触发此事件时更新用户界面即可:

this.user_ride_channel.bind('client-driver-location', (data) => {
  let region = regionFrom(
    data.latitude,
    data.longitude,
    data.accuracy
  );

  // update the Map to display the current location of the driver
  this.setState({
    location: region, // the driver's location
    driver: {
      latitude: data.latitude,
      longitude: data.longitude
    }
  });

});
Enter fullscreen mode Exit fullscreen mode

接下来是特定情况下触发的事件。它的主要目的是向乘客发送有关司机位置(near_pickup)以及他们何时接近下车地点(near_dropoff)的更新信息。

this.user_ride_channel.bind('client-driver-message', (data) => {
  if(data.type == 'near_pickup'){ // the driver is very near the pickup location
    // remove passenger marker since we assume that the passenger has rode the vehicle at this point
    this.setState({
      has_ridden: true 
    });
  }

  if(data.type == 'near_dropoff'){ // they're near the dropoff location
    this._setCurrentLocation(); // assume that the ride is over, so reset the UI to the current location of the passenger
  }

  // display the message sent from the driver app
  Alert.alert(
    data.title,
    data.msg,
    [
      {
        text: 'Aye sir!'
      },
    ],
    { cancelable: false }
  );        
});

// next: render the UI
Enter fullscreen mode Exit fullscreen mode

用户界面由加载旋转器(仅在应用程序搜索司机时可见)、标题、预订行程按钮、乘客位置(origin)及其目的地以及地图组成,地图最初显示用户的当前位置,然后在预订行程后显示司机的当前位置。

render() {

  return (
    <View style={styles.container}>
      <Spinner 
          visible={this.state.is_searching} 
          textContent={"Looking for drivers..."} 
          textStyle={{color: '#FFF'}} />
      <View style={styles.header}>
        <Text style={styles.header_text}>GrabClone</Text>
      </View>
      {
        !this.state.has_ride && 
        <View style={styles.form_container}>
          <Button
            onPress={this.bookRide}
            title="Book a Ride"
            color="#103D50"
          />
        </View>
      }

      <View style={styles.map_container}>  
      {
        this.state.origin && this.state.destination &&
        <View style={styles.origin_destination}>
          <Text style={styles.label}>Origin: </Text>
          <Text style={styles.text}>{this.state.origin.name}</Text>

          <Text style={styles.label}>Destination: </Text>
          <Text style={styles.text}>{this.state.destination.name}</Text>
        </View>  
      }
      {
        this.state.location &&
        <MapView
          style={styles.map}
          region={this.state.location}
        >
          {
            this.state.origin && !this.state.has_ridden &&
            <MapView.Marker
              coordinate={{
              latitude: this.state.origin.latitude, 
              longitude: this.state.origin.longitude}}
              title={"You're here"}
            />
          }

          {
            this.state.driver &&
            <MapView.Marker
              coordinate={{
              latitude: this.state.driver.latitude, 
              longitude: this.state.driver.longitude}}
              title={"Your driver is here"}
              pinColor={"#4CDB00"}
            />
          }
        </MapView>
      }
      </View>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

最后,添加样式:

const styles = StyleSheet.create({
  container: {
    ...StyleSheet.absoluteFillObject,
    justifyContent: 'flex-end'
  },
  form_container: {
    flex: 1,
    justifyContent: 'center',
    padding: 20
  },
  header: {
    padding: 20,
    backgroundColor: '#333',
  },
  header_text: {
    color: '#FFF',
    fontSize: 20,
    fontWeight: 'bold'
  },  
  origin_destination: {
    alignItems: 'center',
    padding: 10
  },
  label: {
    fontSize: 18
  },
  text: {
    fontSize: 18,
    fontWeight: 'bold',
  },
  map_container: {
    flex: 9
  },
  map: {
   flex: 1
  },
});
Enter fullscreen mode Exit fullscreen mode

运行应用程序

现在您可以运行应用程序了。正如我在之前的“先决条件”部分提到的,您可以选择使用两台计算机,分别运行乘客应用程序和司机应用程序。这样您就可以为console.log两个应用程序启用日志记录()。但如果您只有一台计算机,则必须按特定顺序运行它们:先运行乘客应用程序,再运行司机应用程序。

请将您的安卓设备连接到电脑,然后运行以下命令:

react-native run-android
Enter fullscreen mode Exit fullscreen mode

这将编译、安装并在您的设备上运行该应用程序。运行完成后,请终止监视器并断开设备与计算机的连接。

编译应用程序

接下来,打开 Genymotion 并启动之前安装的设备。这次运行驱动程序。程序运行后,你会看到一个空白屏幕。这是正常现象,因为程序需要定位信息才能渲染内容。你可以点击模拟器界面右上角的“GPS”按钮,然后启用 GPS 定位。

在 Genymotion 上启用 GPS

您也可以点击地图按钮,选择特定位置:

更改位置

选择位置后,应用程序中的地图界面应该显示您选择的同一位置。

接下来,您可以按照之前“应用流程”部分的步骤操作。请注意,您可以通过点击 Genymotion 地图界面来模拟车辆行驶。如果乘客已预订行程且司机已接受请求,系统应该会开始更新乘客应用和司机应用,显示司机的当前位置。

如果你使用两台机器,那么你可以直接react-native run-android在两台机器上运行。一台机器连接到你的设备,另一台机器打开 Genymotion 模拟器。

结论

好了!在本教程中,你学习了如何使用 Pusher 创建一个打车应用。正如你所看到的,你创建的应用功能非常基础。我们只构建了打车应用最核心的部分。如果你愿意,可以向应用添加更多功能,甚至可以将其用于你自己的项目中。你可以在该应用的GitHub 代码库中找到它所使用的源代码。

最初发表于Pusher 博客

文章来源:https://dev.to/wernancheta/creating-a-ride-booking-app-with-react-native-and-pusher-1gf1