购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

项目1 带距离预警的手机遥控车

说明

本项目中,我们将用乐高零件组装一台乐高小车,然后用手机做遥控器,遥控小车移动。同时,在小车上使用超声传感器来测定前方障碍物距离,当距离障碍物过近时,向遥控的手机发出警告信号,当距离障碍物达到极限时,强制停止小车并通知遥控手机。

构想

对小车的控制方式采用常见的手机赛车游戏的控制方式:提供两个按钮,分别是油门和刹车,整部手机可以当作方向盘左右摇晃控制左右转向。

手机上实时显示小车的电动机转速、速度和行驶里程。

当超声传感器检测到障碍物过近时,在手机上显示警报图标;当障碍物距离进入危险范围时,小车自动停车,并在手机上显示相应的图标。

调研

根据上面提到的构想可以看出,本项目的技术难点主要在于以下几个方面。

下面就逐个讲解如何实现。

手机与EV3的连接

EV3多了一个USB接口,如果接上支持的无线上网卡是可以支持连接无线WiFi的。但到我写稿时,EV3只支持两款无线上网卡,而且使用无线上网卡会影响EV3和其他乐高零件的拼装,本书就不介绍这种方式了。由于EV3支持基于蓝牙的个人局域网络(Personal Area Network, PAN),当连入PAN的时候,对程序来说,底层网络调用和连入WiFi的局域网是完全相同的,所以如果有读者想使用WiFi连接,只需要参考后面关于PAN连接的介绍即可。

接下来,先来研究如何通过蓝牙PAN连接EV3和Android手机。

如果你阅读下面内容时,有很多概念不了解其意思,可以阅读第二部分中的计算机网络基础知识章节进行学习。

无论是基于蓝牙的PAN还是基于WiFi的局域网,当建立连接后,都将形成一个基于TCP/IP协议的网络环境。那么连接在网络上的设备自然就可以通过TCP/IP协议进行通信。

基于TCP/IP协议的通信,在程序中,通常使用Socket来处理。基于Socket的编程,分为服务器端和客户端。考虑到手机的操作性要强一些,更适合成为需要设置服务器信息的客户端,因此我们将EV3设置为服务器。

既然是服务器,就要建立一个服务器端Socket,打开相应的端口进行监听,并等待连接。代码如下:

ServerSocket server=new ServerSocket(PORT);  //建立服务器
Socket socket=server.accept();         //监听网络

当server.accept()被调用的时候,程序会阻塞住,等待客户端的接入,不再向后执行。当有客户端接入时,返回一个Socket对象,继续执行后续程序。

有了Socket,就可以从中取得进行网络通信的输入/输出流(Input/Output Stream)对象来读取对方发来的数据和写入要发给对方的数据了。

作为调研程序,第一步先确认可以连接并传送数据,所以在EV3服务器端仅接收一个字节(byte)的数据。代码如下:

InputStream in=socket.getInputStream();   //获得输入流对象
int data=in.read();              //读取一个字节数据

InputStream.read()函数也是阻塞式函数,程序运行到这里将会等待,直到有数据从对方发来或者流已经结束。

在这个初步调研程序中,让稍后会介绍的客户端程序发送数字1过来。在服务器端,如果收到数字1,就发出“哔—”的声音;如果收到其他内容,则发出“噗—”的声音,然后断开网络连接,关闭服务器,退出程序。

if(data==1) {
  //如果收到数字1
  Sound.beep();    //发出“哔—”
} else {
  Sound.buzz();    //发出“噗—”
}

in.close();       //关闭输入流
socket.close();     //断开网络连接
server.close();       //关闭服务器

如果按照上面说的,在Eclipse中一步一步地把代码写下来,就会发现在很多代码下面会有红色的下划线,前面还会有刺眼的红叉。这是因为现在的代码存在错误,并没有做出相应的例外处理。

强制进行例外处理是Java语言的一种防止严重代码错误的机制,虽说这种机制的优劣尚有争议,但既然我们选择了Java语言,就要遵守它的规则。

在代码中,涉及网络连接、数据读写的部分都有可能因为网络环境的问题出现无法正常连接网络或无法正常读写数据的情况。类似这种与预想的顺利状况不同的情况就叫例外。在Java中将这类涉及数据读写或者说输入/输出的例外归入了IOException类。加上例外处理后,代码变为:

ServerSocket server=null;
Socket socket=null;
InputStream in=null;
try {
  server=new ServerSocket(PORT);    //建立服务器
  socket=server.accept();

  in=socket.getInputStream();
  int data=in.read();               //读取一个字节数据

  if(data==1) {
    //如果收到数字1
    Sound.beep();         //发出“哔—”
  } else {
    Sound.buzz();         //发出“噗—”
  }
} catch (IOException e) {
  //TODO: 加入例外处理
} finally {
  if(in !=null) {
    try {
      in.close();        //断开输入流
    } catch (IOException e) {
    }
  }
  if(socket !=null) {
    try {
      socket.close();      //断开网络连接
    } catch (IOException e) {
    }
  }
  if(server !=null) {
    try {
      server.close();      //关闭服务器
    } catch (IOException e) {
    }
  }
}

可以看到,刚才的代码被一个try-catch-finally块包了起来,并将一系列关闭处理放到了finally块中,这是为了确保无论是否发生例外,服务器都能被关闭,相关资源可以得到释放。在catch块中,我们只加了一条TODO,TODO是“待办”的意思,开发工具Eclipse会自动识别TODO,并标记出来,以防随着代码规模的扩大忘记需要补充的代码。既然标记了TODO,就意味着我们打算先放一放,暂时看看其他部分。

不太熟悉Java的读者可能会问,这里写出来的这么多代码应该放在哪里?

在成熟的软件产品中,通常会将联网这类代码单独整理到一个或者几个类中,由于这里仅仅是要做技术调研,所以不想大费周折,直接放到入口函数main()里就可以了。

然而,如果这样运行这段代码,屏幕上没有任何显示或者提示,程序运行后,甚至不知道是代码出错了还是在等待网络连接,所以,需要在代码中适当加入屏幕提示。

EV3的屏幕显示是通过GraphicsLCD类来处理的。例如,要显示“正在等待连接……”的英文“waiting connection...”,代码如下:

//取得GraphicsLCD实例
GraphicsLCD g=LocalEV3.get().getGraphicsLCD();
//在屏幕左上角显示文字
g.drawString("waiting connection...", 0, 0, 
    GraphicsLCD.LEFT | GraphicsLCD.TOP);

利用这种方式,可以在代码相应的位置加入屏幕显示以说明程序现在的状态。同样,之前标记为TODO的地方也可以在出现例外的时候将错误信息显示出来。

经过修改,本次调研的EV3服务器端完整代码如下:

package org.programus.book.mobilelego.research.connect;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

import lejos.hardware.Button;
import lejos.hardware.Sound;
import lejos.hardware.ev3.LocalEV3;
import lejos.hardware.lcd.Font;
import lejos.hardware.lcd.GraphicsLCD;

public class TcpipServer {
    private final static int PORT=9988;
    
    public static void main(String[] args) {
        //取得GraphicsLCD实例
        GraphicsLCD g=LocalEV3.get().getGraphicsLCD();
        //设置为小字体
        g.setFont(Font.getSmallFont());

        ServerSocket server=null;
        Socket socket=null;
        InputStream in=null;
        try {
            server=new ServerSocket(PORT);    //建立服务器
            g.clear();                        //清屏
            //在屏幕左上角显示文字
            g.drawString("waiting connection...", 0, 0, 
                    GraphicsLCD.LEFT | GraphicsLCD.TOP);
            socket=server.accept();

            in=socket.getInputStream();
            int data=in.read();               //读取一个字节数据

            if(data==1) {
                //如果收到数字1
                Sound.beep();                 //发出“哔—”
            } else {
                Sound.buzz();                 //发出“噗—”
            }
        } catch (IOException e) {
            g.clear();                        //清屏
            g.drawString(e.getMessage(), 0, 0, 
                GraphicsLCD.LEFT | GraphicsLCD.TOP);
            Button.waitForAnyPress();         //等待任意按键
        } finally {
            if(in !=null) {
                try {
                    in.close();               //断开输入流
                } catch (IOException e) {
                }
            }
            if(socket !=null) {
                try {
                    socket.close();           //断开网络连接
                } catch (IOException e) {
                }
            }
            if(server !=null) {
                try {
                    server.close();           //关闭服务器
                } catch (IOException e) {
                }
            }
        }

    }
}

图1-1-1 TcpipServer等待连接界面

代码完成后,将其编译并上传到EV3上(具体步骤请参阅第二部分中的leJOS基础知识)。在EV3上启动程序,就会看到图1-1-1所示的运行结果。

由图1-1-1中可以看出,程序在等待客户端的接入。由于客户端程序还没有写,可以先用计算机上的Telnet来模拟客户端连接到EV3上的服务器端程序。

由于打算使用基于蓝牙的PAN来实现底层网络,所以首先要让计算机与EV3建立蓝牙连接。

首先要确保EV3的蓝牙开启并处于可见状态,如图1-1-2中红色下划线部分所示。

图1-1-2 EV3蓝牙设置界面(红线标注出查看可见状态的方式)

在Mac OS上,只需要在蓝牙偏好设置中扫描找到EV3设备,EV3设备名称将显示在leJOS的主菜单界面最上方中间,如图1-1-3所示。然后进行配对、连接即可。

图1-1-3 EV3 leJOS主菜单屏幕

在Windows上,各个Windows版本可能略有不同,这里以Windows 8为例加以说明。在Windows 8上,首先确保蓝牙已经开启,然后从控制面板中找到“设备和打印机”并打开,单击上部的“添加设备”按钮,在弹出的对话框中等待计算机搜索EV3,当EV3出现在对话框中央时,选择它并单击“下一步”按钮,当询问密码时,单击“是”按钮。接着,计算机会安装相应的驱动程序。稍等片刻,EV3就被添加到设备中了。通常,在配对之后系统会自动与EV3建立PAN网络连接。希望断开网络的时候,在“设备和打印机”窗口中选择EV3,然后单击上部的“断开设备网络连接”按钮即可。再次连接时,只要单击同样位置上的“连接时使用”按钮,然后从弹出的菜单中选择“接入点”命令即可。

建立好蓝牙PAN网络环境之后,执行telnet,发送数据。

$telnet 10.0.1.1 9988
Trying 10.0.1.1...
Connected to 10.0.1.1.
Escape character is '^]'.
^A
Connection closed by foreign host.
$

这是在Mac OS下使用telnet连接的结果,其他操作系统也与此类似。其中黑色字是输入的内容,土黄色字是系统输出的内容。我们使用“telnet IP地址 端口”的命令来连接服务器。其中IP会在EV3的主菜单屏幕上显示,端口则是程序中定义好的9988。

连接建立后,需要输入数字1。但由于使用的是命令行工具,如果输入“1”则代表字符1,而不是数字1。怎么办呢?这里有个窍门,按Ctrl+A组合键,屏幕上显示为“^A”。这时,会听到EV3发出“哔—”的一声,刚好与程序设定相符;如果输入“^A”以外的内容,会听到“噗—”的一声。紧接着,连接被切断,EV3也回到了文件列表的屏幕。

由此可以证明,服务器端程序是按照预期正常工作的。

那么接下来让我们一起来写运行在手机上的客户端程序。

客户端是一个Android程序,如果你对如何开发一个Android程序还不清楚,可以先学习一下第二部分中的Android编程基础知识。

首先,通过ADT的向导创建一个Android Application Project。默认向导创建出来的Activity会使用Fragment来组合界面。这虽然是一种重用性更高、相对更好的方式,但会让代码变得复杂,影响我们关注真正想要的东西。所以,选择在创建项目时不使用向导创建Activity,而是自己创建一个。

创建Activity少不了三样东西: 一个Layout XML、一个Activity类和Android-Manifest.xml中的描述。

先看一下描述界面的Layout XML文件,我们的Activity是为了提供一个链接EV3、发送数据的界面,所以需要一个指定服务器IP地址的文本输入框、一个发送数据的按钮,此外,为了掌握连接、发送的状态,还需要一个显示状态的文本框。

布局文件,将其命名为main_activity.xml,详细代码如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <EditText
        android:id="@+id/ip_input"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:ems="10"
        android:inputType="number|text"
        android:text="@string/default_ip">

        <requestFocus />
    </EditText>

    <Button
        android:id="@+id/beep"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/label_beep" />

    <TextView
        android:id="@+id/log"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</LinearLayout>

在图形布局绘制工具中画好的布局如图1-1-4所示。

图1-1-4 连接EV3的Android界面设计

图1-1-4中使用的都是最基本的控件,所以内容就不多做解释了。如果有看不明白的地方,可以参考Android开发的帮助文档。

再来看Activity类,我们将类命名为MainActivity,继承自Activity类。需要在创建Activity的onCreate()方法中指定它使用刚才创建的main_activity.xml,并定义相应的变量来操作控件,当按钮被按下时,调用函数进行网络连接和数据发送。代码如下:

package org.programus.book.mobilelego.research.connect;

import android.app.Activity;
import android.os.Bundle;
import android.os.Handler;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;

public class MainActivity extends Activity {
    private TextView mIpInput;
    private Button mBeep;
    private TextView mLog;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        this.setContentView(R.layout.main_activity);
        this.initComponents();
    }
    
    private void initComponents() {
        this.mIpInput=(TextView) this.findViewById(
                R.id.ip_input);
        this.mLog=(TextView) this.findViewById(R.id.log);
        this.mBeep=(Button) this.findViewById(R.id.beep);
        
        this.mBeep.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                //为防止界面线程阻塞,在新线程执行网络相关代码
                Thread t=new Thread("net-thread") {
                    @Override
                    public void run() {
                        connectAndSendData();
                    }
                };
                t.start();
            }
        });
    }

    private void connectAndSendData() {
        //TODO: 追加网络连接和发送数据代码
    }

}

或许有读者会问,为什么按钮按下后的事件响应函数中要启动新的线程?这是因为connectAndSendData()方法中将包含可能很耗时的网络相关操作代码。而按钮的按下事件响应函数中的代码是在UI线程中执行的。UI线程专门用来处理与界面相关的事件,如按钮按下、手指触摸、滑动等,这些事件通常会按照触发的顺序排成一个队列,逐个等待UI线程来处理。这就好像我们去快餐店排队买饭一样,快餐店中漂亮的收银员就是UI线程,那些饥饿的排队人就好像一个个事件。试想,如果有一个人特别麻烦,点餐的时候犹豫不决,问这问那,迟迟不能决定吃什么,就会导致整个队列停滞不前,后面的人很久还吃不到东西。体现在计算机中就是新产生的事件不能及时得到处理。比如,我们明明已经手指触摸到了屏幕,程序却没有给出相应的动作,这往往就是由于没能及时处理完前面的事件所导致。所以,Android系统为了尽可能地防止开发者写出这类会导致停滞的程序,对于类似网络操作的代码,默认情况下是不允许写在UI线程处理中的,因此必须启动一个新的线程进行处理。

接下来完成TODO的部分。仍旧是Socket编程,这次是客户端,有了服务器端的经验,这里就不展开讲解了,可以阅读代码中的注释来了解各条语句的意思。

private void connectAndSendData() {
    this.clearLog();
    //从输入取得IP地址
    String ip=this.mIpInput.getText().toString();
    Socket socket=null;
    OutputStream out=null;
    try {
        //建立Socket连接
        this.appendLog(String.format("正在与%s:%d建立连接...", 
            ip, PORT));
        socket=new Socket(ip, PORT);
        this.appendLog(String.format("连接%s:%d成功!", ip, PORT));
        //取得输出流
        out=socket.getOutputStream();
        this.appendLog("成功取得输出流。");
        //输出数据
        out.write(1);
        this.appendLog(String.format("输出数据: %d。", 1));
        //清除本地缓存,确保数据发送出去
        out.flush();
    } catch (IOException e) {
        this.appendLog(e);
    } finally {
        //确保输出流和连接关闭
        if(out !=null) {
            try {
                this.appendLog("关闭输出流。");
                out.close();
            } catch (IOException e) {}
        }
        if(socket !=null) {
            try {
                this.appendLog("关闭socket。");
                socket.close();
            } catch (IOException e) {}
        }
    }
}

其中,appendLog(String)、appendLog(Exception)和clearLog()是类中的3个显示Log的方法。英文好的读者应该已经猜到了这3个方法的功能——前两个是添加日志内容,最后一个是清除所有日志。3个方法的代码如下:

/ **
   * 追加文本到日志文本框中
   * @param log 需要追加的文本
   * /
private void appendLog(final String log) {
    this.runOnUiThread(new Runnable() {
        @Override
        public void run() {
            mLog.append(log);
            mLog.append("\n");
        }
    });
}

/ **
   * 追加例外信息到日志文本框中
   * @param e 需要追加的例外
   */
private void appendLog(final Exception e) {
    StringWriter sw=new StringWriter();
    PrintWriter pw=new PrintWriter(sw);
    e.printStackTrace(pw);
    pw.flush();
    String stackTrace=sw.toString();
    pw.close();
    this.appendLog(stackTrace);
}

/ **
   * 清除日志
   */
private void clearLog() {
    this.runOnUiThread(new Runnable() {
        @Override
        public void run() {
            mLog.setText("");
        }
    });
}

上面代码中用到了runOnUiThread函数,这个函数的功能是将作为参数的Runnable实例中的run()方法放到UI线程中执行。由于更新控件文本属于UI操作,只能在UI线程中执行,所以采用了这样的方式。

至此,MainActivity类的代码编写就完成了。接下来要在AndroidManifest.xml中添加Activity的描述:

<activity android:name="MainActivity">
    <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter>
</activity>

这个Activity是应用程序的入口,所以除了使用<activity>标签来声明外,还要加上<intent-filter>中的内容来通知系统,使用此Activity来启动应用。

要运行本程序,在AndroidManifest.xml中除了添加上述Activity描述以外,还需要加入访问互联网的许可声明:

<uses-permission android:name="android.permission.INTERNET"/>

完整的AndroidManifest.xml文件如下:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="org.programus.book.mobilelego.research.connect"
    android:versionCode="1"
    android:versionName="1.0">

    <uses-sdk
        android:minSdkVersion="16"
        android:targetSdkVersion="19" />
    <uses-permission android:name="android.permission.INTERNET"/>

    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme">
        <activity android:name="MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>

</manifest>

到这里,手机端调研程序就算完成了。虽然不是很完美,但作为技术调研已经足够了。下面就来试试能否通过手机连接上EV3并发送数据让EV3发出“哔—”的声音。

为了防止程序的BUG导致无法正常连接,推荐在计算机上先用手机模拟器运行程序,然后在计算机与EV3建立起蓝牙PAN网络的状态下在模拟器中测试。模拟器中的测试界面如图1-1-5所示。

图1-1-5 基于蓝牙PAN的手机客户端在模拟器中的运行结果

在模拟器中测试通过后,进行手机真机测试。同样需要先进行蓝牙配对和连接。保证EV3的蓝牙开启并处于可见状态(见图1-1-2)的前提下,在手机的蓝牙设置中扫描找到EV3设备,单击找到的设备进行配对。配对成功后的界面如图1-1-6所示。再次单击,会出现“连接中……”、“已连接”的状态,但仅维持片刻就会再次断开连接。这说明无法构建基于蓝牙的PAN网络。

图1-1-6 手机蓝牙设置界面

为什么手机无法与EV3建立起PAN呢?要回答这个问题,先得简单说明建立蓝牙PAN的必需条件。蓝牙PAN的建立,需要PAN服务器和PAN客户端,PAN服务器等待配对的蓝牙客户端进行连接。然而,Android设备,不知基于何种考虑,通常情况下仅可以被用作PAN服务器,无法用作PAN客户端。同样,EV3上的leJOS也是只能被用作PAN服务器。这就导致两者之间无法建立起PAN网络。

那是不是上面的程序就白写了呢?作为调研程序,常常会出现这种情况。不过,这次也并不一定如此。

Android操作系统是基于Linux操作系统的,Linux本身是支持被用作蓝牙PAN客户端的,那么Android系统也理应可以支持这一功能。经过一番学习和调查,发现在Android上可以通过Linux命令pand -connect来连接PAN服务器。然而pand命令并没有公开给普通用户,若要执行此命令必须将Android设备root了才行。另外,在手机上输入一条命令是很痛苦的事情。再挖掘一下,可以找到一款应用,名叫Bluetooth PAN for Root Users,它可以很方便地实现将Android设备用作PAN客户端。同样,从应用的名称就可以看出,它也需要设备已经root。

在root过的手机上,使用Bluetooth PAN for Root Users与EV3建立PAN之后,再运行我们的程序,会发现结果和模拟器上一样,可以顺利连接EV3并发送数据。

Android的root,是让用户获取系统超级用户的过程。由于超级用户的用户名叫root,所以这个破解的过程也被称为root。因为获得超级用户权限,就有可能恶意加以利用,从而伤害到手机用户的安全和利益,root是手机生产商并不希望用户去做的事情。大多数情况下,root之后,手机也失去了支持官方系统更新的能力。由于这些原因,如果你不是很了解相关原理,我也不建议为了学习此书而将设备root。当然,假如你的手机并不是从官方指定的经销商处购买,有些无良商人会在手机售出前就完成root。所以,如果你因为这类原因恰好持有一部已经root过的手机,倒不妨试试这里的调研项目。

那么,是不是没有root过的手机,就没办法实现手机和EV3的连接了呢?当然不可能是这样的。接下来就介绍如何使用另一种蓝牙连接方式实现手机与EV3的互联。

为了保证基于蓝牙连接设备之间的互通性,蓝牙技术联盟(Bluetooth Special Interest Group,SIG)制定了一系列蓝牙规范(Bluetooth Profile),上面介绍的PAN就是其中之一。由于需要Android设备的root权限,所以再来看看还有什么蓝牙规范可以使用。乐高机器人,不论是NXT还是EV3都支持串行端口规范(Serial Port Profile,SPP),而且Android端不需要root也可以支持SPP。因此,下面就一起看看如何写一套基于蓝牙SPP的程序。

既然是网络连接,就要求一端是服务器,另一端是客户端。由于leJOS已经准备好了SPP服务器端的现成类和方法,我们仍旧让EV3来充当服务器。服务器端代码如下:

package org.programus.book.mobilelego.research.connect;

import java.io.IOException;
import java.io.InputStream;

import lejos.hardware.Button;
import lejos.hardware.Sound;
import lejos.hardware.ev3.LocalEV3;
import lejos.hardware.lcd.Font;
import lejos.hardware.lcd.GraphicsLCD;
import lejos.remote.nxt.BTConnector;
import lejos.remote.nxt.NXTConnection;

public class SppServer {
    / **
      * 程序入口函数
      * @param args 命令行参数(未使用)
      */
    public static void main(String[] args) {
        //取得GraphicsLCD实例
        GraphicsLCD g=LocalEV3.get().getGraphicsLCD();
        //设置为小字体
        g.setFont(Font.getSmallFont());
        //新建基于SPP的蓝牙连接器
        BTConnector connector=new BTConnector();
        //在屏幕左上角显示文字
        g.drawString("waiting connection...", 0, 0, 
            GraphicsLCD.LEFT | GraphicsLCD.TOP);      //等待连接
        NXTConnection conn=connector.waitForConnection(0, 
            NXTConnection.RAW);
        if (conn !=null) {
            //连接成功的情况
            InputStream in=null;
            in=conn.openInputStream();
            try {
                int data=in.read();                   //读取一个字节数据
                if(data==1) {
                    //如果收到数字1
                    Sound.beep();                     //发出“哔—”
                } else {
                    Sound.buzz();                     //发出“噗—”
                }
            } catch (IOException e) {
                g.clear();                            //清屏
                g.drawString(e.getMessage(), 0, 0, 
                    GraphicsLCD.LEFT | GraphicsLCD.TOP);
                Button.waitForAnyPress();             //等待任意按键
            }
            finally {
                if(in !=null) {
                    try {
                        in.close();                   //断开输入流
                    } catch (IOException e) { }
                }                
                try {
                    conn.close();
                } catch (IOException e) { }
            }
        } else {
            g.clear();
            g.drawString("Connect failed", 0, 0, 
                GraphicsLCD.LEFT | GraphicsLCD.TOP);
            Button.waitForAnyPress();                 //等待任意按键
        }
    }
}

有过上面基于蓝牙PAN的服务器代码说明,相信这段基于SPP的代码不需要过多的解释大家就可以读懂了。这里仅对建立连接处的代码做几点说明。

有的读者估计已经注意到,连接类是NXTConnection。为什么使用的是EV3,类名却是NXTConnection呢?这是个历史遗留问题,因为对SPP的支持,在NXT中就已经实现了,所以NXT版leJOS中已经有了相关的类,EV3版leJOS虽然并没有打算兼容NXT版,但由于是在NXT版的基础上扩展而来的,自然就保留了一部分历史内容。虽然名字里包含了NXT,但应用时对EV3同样适用。

另外,说一下BTConnector. waitForConnection(int timeout, int mode)中的两个参数。第一个参数,顾名思义,是等待超时时间,但在一些版本的leJOS中实际并未用到这个参数。第二个参数,mode是模式的意思。共有3种模式可选,即LCP、PACKET和RAW,前两个都是为了与乐高设备连接而用的,这次是与手机连接,所以使用最后一个RAW。这一模式下,发送和接收的数据不会有任何额外的加工。

连接SPP,Android端的程序要稍微复杂一些。在Google上有一页专门的编程指南来讲解。

首先,与之前的程序一样,需要一个Activity,那么就需要一个Layout XML、一个Activity类和AndroidManifest.xml中的描述。

Layout XML文件跟刚才的大同小异,唯一不同的是,之前的PAN方式需要指定IP地址,而SPP方式则只要从已配对设备中选择要连接的设备即可。所以,把前面Layout XML中的文本框换成下拉列表框,Android编程中的下拉列表框控件是Spinner。完成后的文件如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 
xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <Spinner
        android:id="@+id/paired_devices"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <Button
        android:id="@+id/beep"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/label_beep" />
    <TextView
        android:id="@+id/log"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</LinearLayout>

接着是Activity的类。Activity类的整体外壳与之前的程序相差无几,但其中建立连接的部分与前面基于PAN的程序会差很多。

之前的PAN连接,实际上是将与蓝牙设备相关的信息通过PAN网络屏蔽掉了,对编程人员来说,使用蓝牙的PAN还是WiFi的LAN都是一样的。而这次连接SPP则需要与蓝牙设备信息打交道,所以,程序启动时要检查蓝牙是否被支持、是否已经开启,如果没有开启,要提示用户开启蓝牙,接着还要列出所有已配对设备……

由于任务比较多,需要一个一个执行。首先检查蓝牙是否被设备支持、是否开启。在Android程序中,对蓝牙设备的操作是通过BluetoothAdapter这个类的对象来进行的。而这个对象由系统提供,可以通过BluetoothAdapter.getDefaultAdapter()来取得。如果取得的对象是null,也就是不存在,那么就说明设备并不支持蓝牙。接着,通过取得的对象,可以检查蓝牙是否开启。代码如下:

/  **
    * 取得蓝牙信息,并在未开启蓝牙时提示开启蓝牙
    */
private void enableBluetooth() {
    //取得蓝牙适配器
    this.mBtAdapter=BluetoothAdapter.getDefaultAdapter();
    if(this.mBtAdapter==null) {
        //无法取得蓝牙适配器,说明设备不支持蓝牙
        Toast.makeText(this, 
              R.string.msg_bluetooth_not_supported, 
              Toast.LENGTH_LONG).show();
        this.finish();
    }
    //检查蓝牙是否已经开启
    if(!this.mBtAdapter.isEnabled()) {
        //如果没有开启,请求开启
        this.requestEnableBluetooth();
    } else {
        //如果已经开启,将已配对设备列表填入下拉列表框
        this.fillBtDevicesToSpinner();
    }
}

这段代码中出现了R.string.msg_bluetooth_not_supported这样一行代码。这其实代表了一段字符串资源,可以从strings.xml文件中找到对应的实际文本内容。

<string name="msg_bluetooth_not_supported">此设备不支持蓝牙!
</string>

Android编程中,推荐使用这种方式,因为这样可以更方便地替换文本以及今后追加的多语言支持。

接下来看看刚才这段代码中的两个主要函数——请求开启蓝牙的requestEnable-Bluetooth()和将所有已配对蓝牙设备填充进下拉列表框的fillBtDevicesToSpinner()。

首先是requestEnableBluetooth()的代码。

/ **
   * 请求用户开启蓝牙
   */
private void requestEnableBluetooth() {
    Intent enableBtIntent=
            new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
    this.startActivityForResult(
            enableBtIntent, REQUEST_ENABLE_BT);
}

这段代码是Android蓝牙编程中的固定写法,具体作用是调用系统的Activity来询问用户是否要开启蓝牙,用户做出响应后会自动调用onActivityResult()函数,并将结果发送过去。由于onActivityResult()函数是用来响应所有Activity返回的函数,所以为了区分是什么Activity的结果,需要一个编程者自己定义的请求码(Request Code),这里用的请求码就是REQUEST_ENABLE_BT,其值可以是任意整数。

onActivityResult()函数目前仅对蓝牙开启请求的结果作出响应,代码如下:

@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
    if(requestCode==REQUEST_ENABLE_BT) {
        if(resultCode==Activity.RESULT_OK) {
            //用户开启了蓝牙,填充已配对设备列表
            this.fillBtDevicesToSpinner();
        } else {
            //否则提示需要蓝牙并退出程序
            Toast.makeText(this, 
                    R.string.msg_bluetooth_is_necessary, 
                    Toast.LENGTH_LONG).show();
            this.finish();
        }
    }
    super.onActivityResult(requestCode, resultCode, data);
}

从代码中可以看出,当用户开启蓝牙后,也会填充下拉列表框。那么接下来就看看这个填充下拉列表框的函数:

/ **
   * 将已配对设备填入下拉列表框
   */
private void fillBtDevicesToSpinner() {
    if(this.mBtAdapter !=null) {
        //取出已配对设备
        Set<BluetoothDevice>deviceSet=
            this.mBtAdapter.getBondedDevices();
        if(this.mDeviceList==null) {
            //如果存储设备信息的列表未初始化,则初始化
            this.mDeviceList=
                new ArrayList<BluetoothDevice>(deviceSet.size());
        }
        
        //新建下拉列表用的Adapter
        ArrayAdapter<String>adapter=
            new ArrayAdapter<String>(
                  this, android.R.layout.simple_spinner_item);
        adapter.setDropDownViewResource(
                android.R.layout.simple_spinner_dropdown_item);
        //循环将设备信息写入列表和下拉列表用的Adapter
        for(BluetoothDevice device: deviceSet) {
            this.mDeviceList.add(device);
            adapter.add(device.getName());
        }
        //将Adapter与下拉列表关联
        this.mDevices.setAdapter(adapter);
    }
}

这段代码有些长,稍微讲解一下。首先从系统取得所有已配对设备,取出来的设备放在一个Set里面。Set是一种计算机数据结构,对应数学中的集合,学过集合的读者都知道,集合中不会有重复元素,而且集合只关心自己里面有什么而不关心顺序。由于我们接下来要把设备信息放到下拉列表框里,并且还要根据列表框的选择进行连接,所以需要一个有顺序的数据结构——List,List对应数学中的数列。代码中的mDeviceList就是定义好的List。在计算机中,List是有大小的,而且List的大小关系到使用的内存大小。虽然系统会依据一定的算法来根据需要扩张List的大小,但这些算法为了保证不出错,往往会多保留一些内存,造成系统内存的浪费。这里的List由于是从Set转过来的,所以完全可以预知大小,故而在初始化的时候指定了大小。

这段代码中还出现了一个ArrayAdapter,它是用来往下拉列表框中填充数据用的。为了保证数据与显示的分离,在Android中,对于列表框、下拉列表框这类控件都不允许编程人员直接设置里面的值,而是通过Adapter来准备数据,然后将Adapter与控件关联,控件就会自动从Adapter中获取数据。因此,准备了一个ArrayAdapter来为下拉列表框填充数据。为了保证后面选择的时候能找到正确的设备,让Adapter里面的设备顺序与List中的设备顺序一致。

至此,我们的Activity代码完成了蓝牙连接前的准备工作。为了保证可以访问蓝牙设备,还要在AndroidManifest.xml中加上蓝牙的权限:

<uses-permission android:name="android.permission.BLUETOOTH"/>

当然,AndroidManifest.xml中还要加上Activity的信息,由于内容与PAN相似,这里就不重复了。

现在,可以阶段性地先测试一下我们的程序,看看下拉列表框中是不是有了我们的EV3设备。当然,在此之前先要做好配对。配对的方法在前面已经描述过了,这里就不赘述了。程序运行后的界面如图1-1-7所示,由图中可以看出,我们的EV3设备已经出现在列表中了。

图1-1-7 已配对设备列表

设备已经列出,接下来实现单击按钮后的代码,这里仍使用基于PAN代码中的函数名,代码如下:

/ **
 * 连接并发送数据到EV3
 */
private void connectAndSendData() {
    this.clearLog();
    if(this.mDeviceList !=null && this.mDeviceList.size()>0) {
        //从列表中取得用户选择的设备
        BluetoothDevice device=this.mDeviceList.get(
                this.mDevices.getSelectedItemPosition());
        BluetoothSocket socket=null;
        OutputStream out=null;
        try {
            //与设备建立SPP连接
            this.appendLog(String.format("正在与%s[%s]建立连接...", 
                    device.getName(), device.getAddress()));
            socket=device.createRfcommSocketToServiceRecord(
                    UUID.fromString(SPP_UUID));
            socket.connect();
            this.appendLog(String.forma("连接%s[%s]成功!", 
                    device.getName(), device.getAddress()));
            //取得输出流
            out=socket.getOutputStream();
            this.appendLog("成功取得输出流。");
            //输出数据
            out.write(1);
            this.appendLog(String.format("输出数据: %d。", 1));
            //清除本地缓存,确保数据发送出去
            out.flush();
        } catch (IOException e) {
            this.appendLog(e);
        } finally {
            //确保输出流和连接关闭
            if(out !=null) {
                try {
                    this.appendLog("关闭输出流。");
                    out.close();
                } catch (IOException e) {}
            }
            try {
                this.appendLog("关闭socket。");
                socket.close();
            } catch (IOException e) {}
        }
    }
}

比较这段代码和上面的PAN的代码可以看出,两者除了连接部分有些差异,其他基本相同。

在连接的部分调用了一个createRfcommSocketToServiceRecord(UUID)的函数。这个函数原本是用来让两个手机通过蓝牙建立连接的,其参数的UUID也通常是由程序自己定义的。然而,要使用的SPP有一个通用的UUID,我们将其定义为常量SPP_UUID,在这里使用。

这个通用的UUID的值是00001101-0000-1000-8000-00805F9B34FB。在Android的文档中也提到了这一点。

图1-1-8 基于SPP连接的手机端测试结果界面

至此,一个通过SPP连接EV3的手机程序就完成了。由于代码比较长,就不在本书正文中列出完整代码了,如有疑问可以在p01-research-bluetooth-spp-client工程中找到全部代码。

接下来是测试,过程与基于PAN的连接基本一样,就不详述了。如果一切正常,我们也将听到EV3发出“哔—”的一声。同时,手机端会有图1-1-8所示的结果。

手机与EV3间的数据传输

成功完成手机与EV3的连接后,接下来看看如何高效地在两者之间传输数据。

由于本书中的项目全部涉及手机与乐高机器人的通信,所以,最好能在一开始就架设好一个方便、清晰、扩展性强的通信架构。为了做到这一点,让我们暂时忘记写程序的事儿,来思考这样一个问题:如果我们要远程指挥一批人操作一台复杂的机器,应该如何安排?

为了更好地说明,先细化一下这个场景。例如,《星际迷航》系列中有一艘飞船,叫企业号,也有翻译成进取号的,英文是Enterprise。船长是柯克,上面有一批精英船员。很显然,要想开动这样一艘宇宙飞船,不是一个人能做到的。平常都是柯克船长在指挥室里对掌管各个系统的高级船员下达命令,这些指挥室里的船员又会进一步将命令下达到各个系统的操作室,从而驱动整艘飞船。现在,由于一项特殊任务,柯克船长必须离开飞船,但根据任务要求,他还要能够对飞船做出全权指挥和控制。作为柯克船长,怎样安排才能做好这件事呢?

首先,因为需要全权指挥和控制飞船,所以就不能设立代理船长来代劳,但如果远程控制还要分别对不同的船员下达命令,也明显不是个明智的做法。通常会设置一个专门负责传达指令的通信员。这个人要不断等待柯克船长发来的命令,并根据命令的种类转达给负责处理相关命令的船员。例如,与飞船行进相关的命令要发给舵手苏鲁,与科学鉴定相关的命令要发给科学官史波克……同时,这名通信员还要负责将各个船员那里的反馈信息发回给柯克船长。这样柯克船长只需要跟通信员一个人接触,而不需要考虑整艘飞船中复杂的人员和设备。其次,飞船如果出现状况,也要经由通信员及时汇报给柯克船长。比如,一直监视着飞船运转状况的轮机长斯科特突然发现一架引擎出现了故障,就要通过通信员向柯克船长汇报。此外,如果柯克船长下令保持关注数据也要及时地汇报观测结果。例如,由于得知有恶人在前方的行星上,欲改造X行星的大气环境以杀死原住民并征用为殖民地,船长下令,对X行星保持关注,每隔一个小时汇报一次行星上的大气组成。那么,史波克接到命令后,就会发出探测器检测X行星大气组成,并每隔一小时通过通信员向柯克船长汇报一次。

在整个过程中,有些命令很快就可以得到执行,通信员就可以等着命令得到执行后再读取柯克船长发来的下一条命令;而有些命令的执行很耗时,这时候就需要通信员和执行命令的船员各干各的,同时进行。

计算机科学其实可以算是一门仿生科学,程序的运行方式很多都来自于平时处理事情的方式。当使用手机遥控机器人的时候,在机器人上发生的事情就很像上面提到的企业号。我们也需要在上面构建一个通信员,这个通信员一边不断读取通过网络传来的消息,一边负责通过网络向对方发送消息。

下面就一起看看怎么用程序编写一个通信员。在Java中,一切都是对象,所以我们的通信员也是一个对象,要为他构建一个类。Communicate是交流、通信的意思,加一个表示人的后缀“-or”,可以将通信员这个类命名为Communicator。

我们在关于连接的调研中可以看到,发送和接收数据是通过输出流和输入流来完成的。所以,Communicator中也需要有一个输入流和一个输出流。

/ **
   * 通信员类
   * 通信员负责持续监听网络,取得消息
   * 并将其转发给相应的操作员——{@link Processor}
   * 同时提供发送数据功能
   * @author programus
   */
public class Communicator {
    
    / ** 读取消息用的输入流 */
    private ObjectInputStream input;
    / ** 发送消息用的输出流 */
    private ObjectOutputStream output;
}

大家或许已经注意到了,这里使用的输入流和输出流类是ObjectInputStream和ObjectOutputStream。之所以使用这两者,是为了方便程序的编写。为了进一步说明,就要提到另一个通信所涉及的问题——协议(Protocol)。

回到刚才《星际迷航》的例子,当柯克船长的命令发送到通信员那里时,通信员之所以能识别命令的种类,并转发给相应的高级船员处理,是因为船长和通信员之间有一种对命令的约定,柯克船长会按照约定来发出命令,而通信员则按照约定来理解命令。或许有人会说,柯克船长就是简单地发出命令,如“全速前进”,并不一定有什么特殊的约定吧。然而,要读懂“全速前进”这个命令,通信员要和柯克船长使用同样的语言,并且具有相关的知识来理解“全速前进”这个词的含义。这里,他们所使用的语言中所包含的语法、语义、词汇等知识本身就是我们所说的约定。显然,我们不会为了写一个程序而创造一种语言,所以使用一些简单的约定,这种约定就是协议。

回到刚才的问题,在Java中,一切都是对象,如果能够在网络间传送对象,也就意味着我们什么都可以传送了。而ObjectInputStream和ObjectOutputStream就是用来传送对象的输入/输出流。具体如何传送对象,则可以交给Java的API了。

这里,使用ObjectInputStream和ObjectOutputStream直接传送Java的对象这个约定,就是协议。这样的好处是,可以为每一种通过网络传递的命令或者消息定义一个类,在里面配备有意义的变量名和函数,就可以让程序的意义一目了然,可读性更强。

为了明确地表明一个类是一种网络消息(命令也是网络消息的一种),可为这些类创造一个共同的接口——NetMessage。

/ **
   * 所有网络消息的共同接口
   * @author programus
   */
public interface NetMessage extends Serializable {
}

因为Java规定,可以被传送的对象必须实现Serializable接口,所以,我们让NetMessage继承这个接口,以保证网络消息对象都可以被正常传送。

有了通信员和基于协议的网络消息,还需要能够处理消息的“高级船员”,在机器人问题里,称他们为操作员。操作员将会有很多,而对于操作员,只有一个要求——可以处理网络上传来的消息,所以要为所有的操作员定义一个接口,名叫Processor。又因为所有的操作员都要等待通信员分发命令才能工作,为了方便,将操作员设为通信员的一个内嵌接口。这样,Communicator的代码就变为:

/ **
   * 通信员类
   * 通信员负责持续监听网络,取得消息
   * 并将其转发给相应的操作员——{@link Processor},
   * 同时提供发送数据功能
   * @author programus
   */
public class Communicator {
    / **
       * 操作员接口,所有操作员类必须实现此接口
       * 用以操作通信员传来的消息
       * @param<T>操作员可处理的消息类型
       */
    public static interface Processor<T extends NetMessage>{
        void process(T msg, Communicator communicator);
    }

    / ** 读取消息用的输入流 */
    private ObjectInputStream input;
    / ** 发送消息用的输出流 */
    private ObjectOutputStream output;
}

为了让操作员工作更加专心,规定一个操作员只能处理一种网络消息,所以在接口定义上加了一个泛型T,来指定这个操作员所处理的消息所对应的网络消息类。

一个具体的操作员类(后面会有例子),要实现这个Processor接口,就要写出自己的process()方法。在方法中提供两个参数:一个是要处理的命令对象;一个是分发消息的Communicator对象。有了这个Communicator的对象,在处理命令的过程中如果需要发送反馈,就可以直接让这个“通信员”去做了。

有了操作员接口,可以编写很多操作员类来处理各种不同的网络消息,为了能够将消息转发给正确的操作员,通信员需要知道所有的操作员,以及他们都是处理什么消息类型的。但通信员怎么知道我们都有哪些操作员呢?

因此,还需要一个能够让通信员掌握所有操作员的机制。为了解决这个问题,还是先考虑现实生活中的情况。如果我们自己是通信员,那么怎么才能掌握所有的操作员呢?首先,需要有人告诉我们什么操作员是负责处理哪种消息的。然后,作为通信员,我们自己也要记录一个清单,来列出什么消息应该转发给哪些操作员。由于有的时候一个命令可能由多个操作员处理,所以我们的清单看起来应该如表1-1-1所示。

表1-1-1 命令清单

我们的程序也一样,通信员也需要程序告知都有哪些操作员,所以,Communicator类需要一个函数来加入对应的操作员对象。

/ **
   * 添加需要通信员转发消息的操作员
   * @param type 要追加的操作员可以处理的网络消息类型
   * @param processor 操作员对象
   */
public<M extends NetMessage>void addProcessor(
        Class<M>type, Processor<M>processor) {
    //TODO: 添加函数内容
}

函数有两个参数,第一个参数指定了所追加的操作员可以处理何种消息类型,第二个参数指定了操作员对象本身。

同样,程序中还需要一个清单,用来存储命令和操作员之间的对应关系。从上面清单的例子可以看出,需要一个一对多的数据结构。由于实际使用清单时,需要根据消息类型快速找到所有能够处理这一消息类型的操作员,所以,数据结构还要能够建立起A和B两种信息的关联关系,并最好能够通过A快速找到B。在Java中Map这种数据结构刚好能够实现关联两种信息,并快速根据其中的索引信息(Key)找到所关联的内容。然而,Map不支持一对多,但可以采取一种变通的方式,让一个消息类型对应一个操作员的列表。在Java中有List这种数据结构来表示列表。这样,数据结构就是一个Map,其索引信息是消息类型,存储内容是存放操作员的列表。Java代码表述为:

Map<String, List<Processor<? extends NetMessage>>>

这里要稍微提一点,由于使用类来作为索引信息有可能产生内存泄露问题,所以,将索引信息的类型换为字符串(String),其内容将是网络消息类的名字。

另外,由于我们的列表中可能存储的操作员所处理的消息类型在这个阶段是无法确定的,所以,操作员的泛型参数使用了“? extends NetMessage”,表示虽然现在不知道这会是个什么类(所以用了问号),但一定是NetMessage的子类。

在Java中,Map是个接口,因为这个数据结构在Java提供的API中有很多种实现。这里使用HashMap这种实现,因为它在处理以字符串为索引的数据时效率较高。

这样,Communicator类的代码变为:

/ **
   * 通信员类
   * 通信员负责持续监听网络,取得消息
   * 并将其转发给相应的操作员——{@link Processor}
   * 同时提供发送数据功能
   * @author programus
   */
public class Communicator {
    / **
     * 操作员接口,所有操作员类必须实现此接口
     * 用以操作通信员传来的消息
     * @param<T>操作员可处理的消息类型
     */
    public static interface Processor<T extends NetMessage>{
        void process(T msg, Communicator communicator);
    }

    / ** 存储所有操作员的Map*/
    private Map<String, List<Processor<? extends NetMessage>>>
        processorMap=
        new HashMap<String, 
            List<Processor<? extends NetMessage>>>();

    / ** 读取消息用的输入流 */
    private ObjectInputStream input;
    / ** 发送消息用的输出流 */
    private ObjectOutputStream output;
    
    / **
     * 添加需要通信员转发消息的操作员
     * @param type 要追加的操作员可以处理的消息类型
     * @param processor 操作员对象
     */
    public<M extends NetMessage>void addProcessor(
            Class<M>type, Processor<M>processor) {
        //TODO: 添加函数内容
    }
}

接下来,将addProcessor()函数的内容写完整,代码如下:

/ **
   * 添加需要通信员转发消息的操作员
   * @param type 要追加的操作员可以处理的消息类型
   * @param processor 操作员对象
   */
public<M extends NetMessage>void addProcessor(
        Class<M>type, Processor<M>processor) {
    //从Map中取出此消息类型对应的操作员列表
    List<Processor<? extends NetMessage>>processorList=
            processorMap.get(type.getName());
    if(processorList==null) {
        //如果列表不存在,说明目前为止尚无此类型操作员被加入
        //创建列表
        processorList=
                new LinkedList<Processor<? extends NetMessage>>();
        //将列表放入Map
        processorMap.put(type.getName(), processorList);
    }
    //向列表中追加操作员
    processorList.add(processor);
}

这段程序中,首先从Map中取出对应消息类型的操作员列表(函数体第一行),然后向列表中追加新指定的操作员对象(函数体最后一行)。然而,有一种情况是指定的消息类型所对应的操作员尚不存在,也就没有相应的列表存在Map中。这时,根据Java文档的说明,会取出一个null,也就是不存在的意思。这种情况下,要创建一个新的列表并放进Map中。在Java中,列表的实现也有很多种,所以List其实也是个接口。比较常用的列表是ArrayList,它内部是使用数组来存储内容的,好处是可以通过数字索引快速访问到其中的任意元素(例如,要取得第2个元素,就是通过数字索引“2”来访问第2个元素)。然而当内容个数频繁变动的时候,会造成内存的浪费和碎片。这里选用了LinkedList,中文称为链表。内部使用一种好像链条的数据结构来存储内容。查找一个元素时,只能像链条一样从头开始一环一环地找下去,所以根据数字索引访问内容的速度不如ArrayList,然而,它却像链条一样可以随时拆卸任意一环,对数据量有变动的存储很合适。这次的程序不需要根据数字索引查找其中的元素,只需要进行所有内容的遍历,加之数据量不定,所以做出了这样的选择。

现在通信员已经能够得到和管理好所有操作员的信息了。接下来该看看他如何完成自己的本职工作——接收和分发消息到相关操作员及发送消息。

首先,发送消息是很容易的,与前一个调研中写过的代码相差不大,核心代码只有两句:

output.writeObject(msg);
output.flush();

第一句,发送消息;第二句,清空发送端缓存,确保数据被发送。然而,考虑到线程安全及例外处理,还得多加点零碎,最终函数如下:

/ **
   * 发送消息
   * @param msg 消息
   */
public void send(NetMessage msg) {
    synchronized (output) {
        try {
            System.out.println(String.format(
                    "Send: %s", msg.toString()));
            output.writeObject(msg);
            output.flush();
        } catch (IOException e) {
            available=false;
        }
    }
}

这里的available是用来设置和表示通信员是否仍在活动的变量,当数据发送出现错误的时候,认为或许网络连接出现了问题,所以终止通信员的工作。

synchronized是Java中的一个关键字,用来处理线程间的同步问题。为了防止数据发送出现混乱,一次只允许一个线程来发送消息,所以将整段代码放进了synchronized块,以保证输出流output不会同时被多个线程访问。

为什么要考虑多线程的问题呢?这就涉及接下来要说的消息接收了。

当程序使用read()函数从输入流读取数据的时候,程序会阻塞,也就是说会停在那里等待数据的传入而不继续执行下去。如果没有相应的并行处理机制,程序就无法做其他任何事情了——无法发送数据、无法控制机器人的运转等。而计算机中的并行处理机制主要有两种,一种是多进程处理,另一种是多线程处理。多个程序间的并行使用多进程,一个程序内则常用多线程。程序只有一个,所以采用多线程方式处理。

在Communicator类中,单独启动一个线程,不断地循环读取输入流里的网络消息,并将消息转给相关的操作员处理。在Java中,创建线程,使用Thread类。可以通过继承Thread类并覆盖重写run()函数来创建自己的线程,但这种方法无法为线程指定一个名字。所以,这里采用了另一种方法,创建一个实现了Runnable接口的匿名类,并用这个匿名类来创建线程。

/ **
   * 启动读取输入流的线程
   */
private void startInputReadThread() {
    //创建一个新线程
    Thread t=new Thread(new Runnable() {
        @Override
        public void run() {
            //TODO: 填写线程执行代码
        }
    }, "read-input");
    //启动线程
    t.start();
}

接下来,将run()函数补全。run()函数中主要就是循环读取消息,然后处理消息。

while (available) {
    //当通信员未被关闭时,循环
    Object o=null;
    try {
        //读取消息
        o=input.readObject();
    } catch (Exception e) {
        available=false;
        break;
    }
    if(o !=null) {
        if(o instanceof ExitSignal) {
            //如果消息为退出命令,则关闭通信员
            close();
            //退出循环
            break;
        } else {
            NetMessage msg=(NetMessage) o;
            //处理消息
            processReceived(msg);
        }
    }
}
//结束通信员工作
finish();

对于退出命令,因为不需要额外的操作员处理,单独在这里做了特殊处理。对于其他命令的处理,转到processReceived()函数中做处理。在processReceived()函数里,从清单processorMap中取出消息所对应的操作员列表,并将消息传给每一个操作员。

/ **
   * 将接收到的消息转给操作员处理
   * @param msg 接收到的消息
   */
private<M extends NetMessage>void processReceived(M msg) {
    //检查传入参数的有效性
    if(msg !=null) {
        //取出消息类型对应的操作员列表
        List<Processor<? extends NetMessage>>processorList=
                processorMap.get(msg.getClass().getName());
        if(processorList !=null) {
            //当操作员列表存在时,循环所有操作员
            for(Processor<? extends NetMessage>processor: 
                processorList) {

                //强制转换操作员类型为实际的类型
                @SuppressWarnings("unchecked")
                Processor<M>p=(Processor<M>) processor;
                //让操作员处理消息
                p.process(msg, this);
            }
        }
    }
}

到这里,一个通信员的主要部分就完成了。其他,还有一些函数,如通信员结束工作时的收尾函数finish()、让通信员结束工作的close()函数、重设通信员的reset()函数等因为相对比较简单,就不在这里展开说明了。仅在下面列出代码。完整的Communicator类,可以在p01-research-bluetooth-comm-lib工程中找到。

/ **
   * 使用新的输入输出流对象重设通信员。此函数不会重设已添加的操作员信息
   * @param input 输入流
   * @param output 输出流
   * @throws IOException 当创建输入输出流出错时抛出
   */
public synchronized void reset(InputStream input, 
        OutputStream output) throws IOException {
    if(this.available) {
        this.finish();
    }
    this.available=true;
    this.output=new ObjectOutputStream(output);
    //对ObjectOutputStream,必须在建立输出流后立即清空缓存,方能避免阻塞
    this.output.flush();
    this.input=new ObjectInputStream(input);
    this.startInputReadThread();
}

/ **
   * 建议通信员结束工作。此函数不会强制关闭输入输出流
   */
public void close() {
    this.available=false;
}

/ **
   * 确认输入流读取线程仍在工作
   * @return 当输入流读取线程仍在工作时返回真
   */
public boolean isAvailable() {
    return this.available;
}

/ **
 * 彻底结束通信员工作,关闭输入输出流
 */
private synchronized void finish() {
    this.available=false;
    try {
        input.close();
    } catch (IOException e) {
    }
    synchronized (output) {
        try {
            output.close();
        } catch (IOException e) {
        }
    }
}

接下来,看看如何写一个网络消息类。

为了证明上面所说的一切都能够正常工作,在本调研中,我们来做一个简单的遥控机器人——用手机控制EV3上连接的一个电动机,控制电动机的运转、停止并可以设置速度,同时当开启报告时,让EV3向手机持续发送电动机的实际运转速度和转过的角度。

为了完成这个遥控功能,需要能够告知EV3控制电动机的网络消息。然而,控制电动机的运转和命令开启/关闭报告功能所需要的数据显然不同——前者需要速度和电动机如何运转的信息,而后者只需要一个说明开/关的数据即可。所以,为它们各设计一个网络消息类。

先说说比较简单的报告命令。由于只需要告诉机器人是要打开还是关闭报告功能,所以一个布尔类型的变量就足够了。那么这个网络消息类的代码如下:

/ **
   * 通知EV3打开/关闭电动机报告功能的网络消息
   * @author programus
   *
   */
public class MotorReportCommand implements NetMessage {
    private static final long serialVersionUID=
        -3009205522237798520L;

    private boolean reportOn;

    public boolean isReportOn() {
        return reportOn;
    }

    public void setReportOn(boolean reportOn) {
        this.reportOn=reportOn;
    }

    @Override
    public String toString() {
        return "MotorReportCommand [reportOn="+reportOn+"]";
    }
}

其中的serialVersionUID是Java建议实现了Serializable接口的类添加的变量。作用是在类有所变更的时候能够加以识别,在我们的程序中虽然没有什么用处,但还是保留了这一变量。毕竟有句俗话说得好:“听人劝吃饱饭。”这个变量的数值是Eclipse工具自动生成的。至于toString()函数,是用来在调试的时候能够更容易得知消息内容的。

对于遥控电动机运转情况的网络消息,需要速度数值和命令种类,代码如下:

/ **
  * 控制电动机运转的命令
  * @author programus
  *
  */
public class MotorMoveCommand implements NetMessage {
    / **
      * 命令种类枚举
      * @author programus
      *
      */
    public enum Command {
        / ** 前进 */
        Forward,
        / ** 后退 */
        Backword,
        / ** 切断动力、惯性滑行 */
        Float,
        / ** 停止 */
        Stop,
    }
    private static final long serialVersionUID=
        -7523347542695340161L;
    
    private Command command;
    private float speed;
    
    public Command getCommand() {
        return command;
    }
    public void setCommand(Command command) {
        this.command=command;
    }
    public float getSpeed() {
        return speed;
    }
    public void setSpeed(float speed) {
        this.speed=speed;
    }
    @Override
    public String toString() {
        return "MotorMoveCommand [command="+command 
          +", speed="+speed+"]";
    }
}

为了更方地便使用和减少错误,将所有的命令种类定义在一个内嵌枚举类型Command中,当使用时,就可以使用MotorMoveCommand.Command.Forward这种方式,让阅读代码的人一看就明白其意义。

写好了网络消息类,下面该为每一个类写一个操作员了。

先来看一下控制电动机运转的操作员类——MotorMoveProcessor,这个类比较简单,只是根据收到的命令调用电动机的相应函数即可。

/ **
  * 控制电动机运转的操作员
  * @author programus
  */
public class MotorMoveProcessor implements 
        Processor<MotorMoveCommand>{
    / ** 需要控制的电动机 */
    private BaseRegulatedMotor motor;
    
    public MotorMoveProcessor(BaseRegulatedMotor motor) {
        this.motor=motor;
    }
    
    @Override
    public void process(MotorMoveCommand cmd, 
            Communicator communicator) {
        Command command=cmd.getCommand();
        float speed=cmd.getSpeed();
        motor.setSpeed(speed);
        switch(command) {
        case Forward:
            motor.forward();
            break;
        case Backword:
            motor.backward();
            break;
        case Float:
            motor.flt(true);
            break;
        case Stop:
            motor.stop();
            break;
        }
    }
}

由于EV3中有两种类型的电动机——大型电动机和中型电动机(见图1-0-3),这里使用它们的父类BaseRegulatedMotor来定义电动机以保证两者都可以操作。

然后,再来看看处理报告命令的操作员类。当接到命令后,操作员需要判断命令是让开启报告功能还是关闭报告功能。如果是开启报告功能,则需要持续监视电动机的状态,并将数值通过通信员发送出去。很显然,持续监视将产生一个循环,在接收到停止报告命令之前不会停止。这样的代码要分离到一个新的线程中执行;否则会影响到其他代码的执行。

对于这种定时执行的多线程操作,Java提供了一种更方便的方式——Timer(定时器)+TimerTask(定时器任务)。只需要在TimerTask的子类中写明每次定时循环需要执行的代码,然后使用Timer启动即可。完整代码如下:

/ **
  * 处理开启/关闭报告命令的操作员类
  * @author programus
  */
public class MotorReportProcessor implements 
Processor<MotorReportCommand>{
    / ** 所操作的电动机 */
    private BaseRegulatedMotor motor;
    
    / ** 用以获取电动机参数的定时器 */
    private Timer timer=new Timer("Reporting Timer", true);
    / ** 用以获取电动机参数的定时任务 */
    private TimerTask task=null;
    
    / **
    * 构造函数
    * @param motor 所操作的电动机
    */
    public MotorReportProcessor(BaseRegulatedMotor motor) {
        this.motor=motor;
    }
    
    / **
    * 发送电动机数据报告,当报告内容没有变化时,不予发送
    * @param communicator 帮助发送消息的通信员
    * @param prevMsg 前次报告的内容
    * @return 本次报告的内容
    */
    private MotorReportMessage sendReport(
            Communicator communicator, 
            MotorReportMessage prevMsg) {
        //假定报告没有变化,将前次报告赋值给本次报告
        MotorReportMessage msg=prevMsg;
        //获取转速和转过的角度
        int speed=motor.getRotationSpeed();
        int tachoCount=motor.getTachoCount();
        //检查数值是否有变化(当报告为null时,表示这是第一次报告)
        if(msg==null || 
                speed !=prevMsg.getSpeed() || 
                tachoCount !=prevMsg.getTachoCount()) {
            //使用新的数值创建新报告
            msg=new MotorReportMessage();
            msg.setSpeed(speed);
            msg.setTachoCount(tachoCount);
            //发送报告
            communicator.send(msg);
        }
    
        //返回本次报告内容
        return msg;
    }
    
    / **
      * 启动定时报告任务
      * @param communicator 帮助发送报告的通信员
      */
    private void startReportTask(
            final Communicator communicator) {
        //因为定时器启动任务,会等待一个循环周期时间后第一次运行
        //所以在此立即发送一次报告
        final MotorReportMessage msg=
                sendReport(communicator, null);
        if(task==null) {
            //任务不存在,意味着任务未启动,创建新任务
            //报告定时任务的匿名类
            task=new TimerTask() {
                //用以存储前次报告的变量,初始值为启动前发送的报告
                MotorReportMessage prevMsg=msg;
                @Override
                public void run() {
                    //发送报告,并将本次报告内容存为下次报告时的前次报告
                    prevMsg=sendReport(communicator, prevMsg);
                }
            };
    
            //启动定时器,执行间隔100毫秒
            timer.schedule(task, 0, 100);
        }
    }
    
    / **
    * 停止定时报告任务
    */
    private void stopReportTask() {
        if(task !=null) {
            //当任务存在时,意味着定时器正在运行
            //取消任务
            task.cancel();
            //将任务置空
            task=null;
            //刷新定时器
            timer.purge();
        }
    }
    
    @Override
    public void process(MotorReportCommand msg, 
            Communicator communicator) {
        if(msg.isReportOn()) {
            this.startReportTask(communicator);
        } else {
            this.stopReportTask();
        }
    }
}

在这段代码中,为了减少网络传输的次数,在发送前对报告的内容进行了检查,如果报告没有变化,则不进行发送。这样可以防止过于频繁的网络传输影响数据传送速度。

在这里,还涉及一个MotorReportMessage类。这是发向手机端的网络消息类型。有了上面两个网络消息类的经验,这个就不列代码了,请读者自行完成。在这里要提一下关于网络消息类的命名。为了更好地区分消息的流向,在这个调研程序中和后面将出现的程序中都将使用同样的命名规则——手机发向EV3的类,将命名为XxxxCommand;而EV3发向手机的,将命名为XxxxMessage。其中的Xxxx部分是此消息类型的功能。

有了通信员-网络消息-操作员架构(不妨用3个类的第一个字母简写为CNO架构),我们的全双工并行网络通信就解决了。与《星际迷航》例子中,一方是飞船、一方是船长柯克不同,我们的程序在遥控手机端也存在复杂的分工和功能,所以在手机端也同样需要利用这个架构,然后编写好处理EV3发来消息的操作员来实现手机端的功能。由此可以看出,架构中的通信员和网络消息类在EV3端和手机端都是同样的内容,可以单独设置一个工程安装,然后在两边共享。这也体现出选择leJOS,可以在手机和EV3上都使用Java的优势了。

关于通信部分调研的核心部分到这里就讲完了,因为代码量较大,加之很多与之前调研中的内容重复,所以很多代码没有在正文列出,可以参考以下3个工程中的代码。

(1) p01-research-bluetooth-comm-lib——通信框架的共享代码。

(2) p01-research-bluetooth-comm-server——EV3端代码。

(3) p01-research-bluetooth-comm-client——手机端代码。

最终的实现效果是通过图1-1-9所示的手机界面来操纵EV3上的一个电动机(上面工程中的代码里,电动机是连接在B口上的),并能读到电动机上的转速和角度数据。

图1-1-9 通信调研程序手机界面

手机左右摇晃检测

在正式创建我们的机器人之前,还有最后一个技术障碍需要克服,那就是如何检测到手机在左右摇晃并通过这个摇晃来控制EV3机器人的左右转弯。

有了之前的通信框架,可以不必担心如何发送摇晃数据的问题了,在本调研中,让我们集中精力来解决如何取得手机摇晃数据的问题。完成这一调研,只需要一部手机,可以让EV3休息了。如果经过前两个调研的调试,EV3中的电池电量或许已经所剩不多,可以利用这个时间去充电或者换电池了。

言归正传,要检测手机的摇晃,就要使用到手机的传感器。关于传感器,在Google的官方Android开发者网站里有专门的一个篇章来说明。这次要用到的是动作传感器(Motion Sensor),相关介绍的网址如下:

在传感器概述(Sensor Overview)中,对传感器的使用已经有了比较清晰的描述。只需要从系统取得SensorManager的对象,再通过SensorManager中的getDefaultSensor()函数取得相应的Sensor对象,然后注册一个SensorEventListener就可以在这个SensorEventListener中完成对传感器数据的监控了。

/ **
* 初始化传感器
*/
private void initSensor() {
mSensorManager=
        (SensorManager) this.getSystemService(SENSOR_SERVICE);
mGravity=
        mSensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY);
    mSensorListener=new SensorEventListener() {
        @Override
        public void onSensorChanged(SensorEvent event) {
            //TODO: 处理传感器数据,计算手机偏转角
        }
    
        @Override
        public void onAccuracyChanged(
            Sensor sensor, int accuracy) {
            //不关心精度的变化,不做任何事
        }
    };
}
    
@Override
protected void onResume() {
    super.onResume();
    //注册传感器事件监听器
    mSensorManager.registerListener(
        mSensorListener, mGravity, 
        SensorManager.SENSOR_DELAY_GAME);
}
    
@Override
protected void onPause() {
    super.onPause();
    //解除传感器时间监听器
    mSensorManager.unregisterListener(mSensorListener);
}

Google推荐将注册传感器监听器的代码写在onResume()函数中,并将注销传感器监听器的代码写在onPause()函数中,以保证传感器在窗口不显示的时候不继续工作,这样可以节省电能。

在这个例子中,使用了重力传感器,因为当手机倾斜时,将会与永远竖直向下的重力有一个夹角,这个夹角的数值可以用来指示机器人的转向角度。

图1-1-10 手机传感器坐标系统

接下来探讨如何实现传感器数据的处理。从动作传感器的说明页中可以知道,重力传感器将传回3个值,分别是 X、Y、Z 3个方向上的重力值,单位是m/s 2 X、Y、Z 3个坐标方向与手机的关系如图1-1-10所示。

当横着拿手机时,根据牛顿力学的力的分解可以得到图1-1-11所示的手机受重力分析图。由图可知,重力在 Y 轴上的分量可以帮助我们计算出手机的倾角 α 。公式为

得到的 α 将是以rad(弧度)为单位的值。

图1-1-11 重力分解图

为了方便控制,将程序设计为横版界面。这就意味着,如果使用手机,需要将手机横过来操作,“手机平面方向上的重力加速度分量”就是 Y 轴上的分量;而如果使用平板设备,由于本身就是横版设备,“手机平面方向上的重力加速度分量”则是 X 轴上的分量。而且,由于坐标轴的指向不同,分量的正负也会有所差异。

另外,Android系统中的重力传感器给出的数值是来源于加速度传感器的,从系统得到的数值实际上并不是重力加速度的数值,而是相对于无加速度的惯性参考系得出的手机加速度数值。无加速度的惯性参考系在地球上指的是做自由落体运动物体所在的参考系,因此我们的设备总是拥有一个由于托在手里或者放在桌上的支撑力而产生的加速度。这个加速度的绝对数值与重力相同,方向相反。

考虑了上述种种因素后,计算手机偏转角的代码如下:

@Override
public void onSensorChanged(SensorEvent event) {
    //取得界面旋转信息
    int rotation=getWindowManager()
            .getDefaultDisplay().getRotation();
    float g=0;
    switch (rotation) {
    case Surface.ROTATION_0:
        //界面无旋转,取X轴方向分量
        //右转为正,数据取反
        g=-event.values[0];
        break;
    case Surface.ROTATION_90:
        //界面逆时针90°旋转,取Y轴分量
        g=event.values[1];
        break;
    case Surface.ROTATION_180:
        //界面旋转180°,取X轴方向分量
        g=event.values[0];
        break;
    case Surface.ROTATION_270:
        //界面逆时针旋转270°,取Y轴分量
        //右转为正,数据取反
        g=-event.values[1];
        break;
    }
    double alpha=Math.asin(g/SensorManager.GRAVITY_EARTH);
    displayAngle(alpha);
}

在函数的最后,调用了displayAngle()函数来将计算出的角度显示在界面上。虽然弧度值在计算中应用更多,但直观感受还是看角度更方便一些,所以显示时同时显示角度和弧度数值。

private void displayAngle(double angle) {
    double degree=Math.toDegrees(angle);
    mAngle.setText(String.format("%f/%f", angle, degree));
}

程序运行后效果如图1-1-12所示。

图1-1-12 手机倾斜检测测试程序界面(左倾时)

至此,所有的技术难题调研都结束了,下一步就可以设计机器人了。

硬件

这一节,让我们一起来设计一下机器人的硬件。作为一个机器人,肯定是需要EV3智能模块的。

图1-1-13 乐高EV3教育套装中的三轮车结构

然后,我们的机器人要能够前后移动和左右转弯,所以至少需要两个电动机。可转向的机器人结构,可以设计为类似汽车的结构——两个轮子负责动力,两个轮子负责转向;也可以设计成两个电动机分别连接两侧的轮子,通过电动机转速的差异达到转向的目的。前者需要考虑转向时轮子的转速同步问题,还要设计复杂的转向轮结构。这个项目的重点不在于机械结构,所以选择了后一种设计,同时将两侧设计成履带结构以达到整体的稳定。当然,也可以使用EV3教育套装中的三轮车结构,如图1-1-13所示。

接着,我们的机器人还要能够检测前方障碍,所以需要一个可以测距的超声波传感器或者红外线传感器。

总体来说,这个项目所需的机器人结构比较简单。机器人最终样式如图1-1-14所示。

图1-1-14 项目1的机器人模型

这一机器人的组装图,可以参考p01-vehicle.lxf文件。由于LDD软件的BUG,履带无法绘制到正确位置,但实际安装时可以直接套在轮子上。

其中各个元件的连接端口如下。

左轮电动机:端口B。

右轮电动机:端口C。

超声传感器:端口3。

软件

对于这个项目来说,软件才是重头戏。下面就来说一下软件的设计。

通信协议

作为一个遥控机器人,在调研中设计出的CNO架构显然是必需的。基于CNO架构,要先确定必需的消息类型。

首先,机器人要能够前行、后退和转向。所以,需要一个机器人移动命令,即RobotMoveCommand。用这个命令,可以让机器人知道自己需要多快的速度,做何种移动,移动时转向角度是多少。因此,这个类设计如下:

/ **
  * 控制机器人移动的命令
  * @author programus
  *
  */
public class RobotMoveCommand implements NetMessage {
    / **
    * 命令种类枚举
    * /
    public enum Command {
        / ** 前进 */
        Forward,
        / ** 后退 */
        Backward,
        / ** 切断动力,惯性滑行 */
        Float,
        / ** 停止,禁止转向 */
        Stop,
    }
    private static final long serialVersionUID=
        -7523347542695340161L;
    
    private Command command;
    / ** 机器人行进时的引擎转速,单位: 度/s */
    private short speed;
    / ** 机器人转向角度,单位: 度 */
    private short rotation;
    
    public Command getCommand() {
        return command;
    }
    public void setCommand(Command command) {
        this.command=command;
    }
    public short getSpeed() {
        return speed;
    }
    public void setSpeed(short speed) {
        this.speed=speed;
    }
    public short getRotation() {
        return rotation;
    }
    public void setRotation(short rotation) {
        this.rotation=rotation;
    }
    @Override
    public String toString() {
        return "RobotMoveCommand [command="+command+", 
            speed="+speed+", rotation="+rotation+"]";
    }
}

对于一个比较精确的程序来说,速度和旋转角度值本应该是双精度浮点型才对。然而,在大多数计算机硬件上,浮点数运算速度都要远远小于整数类型的运算。尤其在相对运算速度较低的EV3上表现得尤其严重。为了在运算速度和精确度上取得平衡,这里采用范围在-32768~32767的整数型short来存储速度和旋转角度,然后将单位分别设为较小的mm/s和度。

此外,在遥控器上要知道机器人的运行状态。根据前面提到的构想,机器人的行进速度、电动机的转速以及机器人行进的总里程都需要传送给手机遥控端。所以,这里需要一个通报这些信息的消息,即RobotReportMessage。

/ **
  * 机器人数据报告消息
  * @author programus
  * /
public class RobotReportMessage implements NetMessage {
    private static final long serialVersionUID=
        -8702695106516789834L;
    
    / ** 机器人行进速度,单位: mm/s */
    private short speed;
    / ** 机器人引擎转速,单位: 度/s */
    private short rotationSpeed;
    / ** 机器人行进总里程,单位: mm 
*(里程从每次程序运行时开始重新从零计算) 
*/
    private int distance;
    
    public short getSpeed() {
        return speed;
    }
    public void setSpeed(short speed) {
        this.speed=speed;
    }
    public short getRotationSpeed() {
        return rotationSpeed;
    }
    public void setRotationSpeed(short rotationSpeed) {
        this.rotationSpeed=rotationSpeed;
    }
    public int getDistance() {
        return distance;
    }
    public void setDistance(int distance) {
        this.distance=distance;
    }
    
    public boolean isSameAs(RobotReportMessage msg) {
        return this.speed==msg.speed && 
            this.rotationSpeed==msg.rotationSpeed && 
            this.distance==msg.distance;
    }
    @Override
    public String toString() {
        return "RobotReportMessage [speed="+speed+", 
        rotationSpeed="+rotationSpeed+", 
            distance="+distance+"]";
    }
}

同样,为了提高性能,都使用整数类型的变量。

此外,机器人还要能够监测前方障碍物状况,传送给手机遥控端,所以这里还设计了一个障碍物信息消息,即ObstacleInforMessage。

/ **
  * 障碍物信息消息
  * 用以向遥控手机端通报障碍物信息
  * @author programus
  *
  * /
public class ObstacleInforMessage implements NetMessage {
    private static final long serialVersionUID=
        5173579547303936055L;
    
    public static enum Type {
        Safe((short)800),
        Warning((short)400),
        Danger((short)200),
        Unknown((short)0);
    
        private final short value;
        Type(short mm) {
            this.value=mm;
        }
    }
    
    private Type type;
    / ** 障碍物距离,单位: mm */
    private int distance;
    
    public Type getType() {
        if(this.distance<Type.Unknown.value) {
            type=Type.Unknown;
        } else if  (this.distance<Type.Danger.value) {
            type=Type.Danger;
        } else if   (this.distance<Type.Warning.value) {
            type=Type.Warning;
        } else {
            type=Type.Safe;
        }
        return type;
    }
    
    public int getDistance() {
        return distance;
    }
    
    / **
       * 取得浮点类型距离值
       * @return 距离值,单位: mm
       * /
    public float getFloatDistanceInMm() {
        float result=this.distance;
        //对非常规数值进行转换,与setDistance(float)中的处理对应
        switch (this.distance) {
        case -1:
            result=Float.POSITIVE_INFINITY;
            break;
        case -2:
            result=Float.NEGATIVE_INFINITY;
            break;
        case -3:
            result=Float.NaN;
            break;
        }
        return result;
    }
    
    / **
      * 设置障碍物距离值,单位: m
      * @param distance 障碍物距离值
      * /
    public void setDistance(float distance) {
        if(Float.POSITIVE_INFINITY==distance) {
            //正无穷大,转为-1
            this.distance=-1;
        } else if(Float.NEGATIVE_INFINITY==distance) {
            //负无穷大,转为-2
            this.distance=-2;
        } else if(Float.isNaN(distance)) {
            //非合法数字,转为-3
            this.distance=-3;
        } else {
            this.distance=(int) (distance * 1000);
        }
    }
    
    / **
      * 设置障碍物距离值,单位: mm
      * @param distance 障碍物距离值
      * /
    public void setDistance(int distance) {
        this.distance=distance;
    }
    
    @Override
    public String toString() {
        return "ObstacleInforMessage [type="+this.getType()+", 
            distance="+distance+"]";
    }
}

实际上,障碍物的信息只有一个,就是障碍物距离。但为了在处理过程中能够方便地取得障碍物距离属于危险范围、警告范围还是安全范围的数值,在这里添加了一个表示距离类型的枚举。

网络通信用到的消息主要就是这些。

EV3端

Java是面向对象的语言,对于面向对象的程序设计,通常就参考实际问题中出现的对象来设计即可。我们现在有一个车型机器人,能够前进、后退、转弯,还能检测到障碍物的距离。那么,就设计一个类来产生这个对象。由于是车型机器人,将其命名为VehicleRobot。而在EV3端,程序中只需要有一个机器人对象就足够了,所以这里使用单例(singleton)设计模式来保证在整个EV3端只能取得一个机器人对象,并且无论在哪里都可以取得这个机器人对象。下面这段代码就是实现了单例设计模式的核心内容。

/ **
  * 被遥控的机器人
  * @author programus
  *
  * /
public class VehicleRobot {
    
    private static VehicleRobot inst=new VehicleRobot();
    
    private VehicleRobot() {
        //构造函数内容……
    }
    
    public static VehicleRobot getInstance() {
        return inst;
    }
}

其中将构造函数设置为private,保证了这个类的对象不能用new来创建。而静态的成员变量inst,保证了唯一对象的存在。使用静态方法getInstance()则可以让外界取得这个唯一对象。使用时,只需要使用以下代码的方式调用,即可取得唯一的VehicleRobot类的对象。

VehicleRobot robot=VehicleRobot.getInstance();

解决了唯一对象的问题,接下来要设计VehicleRobot的功能。这个机器人能前后移动,所以需要一个forward()方法和一个backward()方法。同时,机器人可以转弯。实际上,转弯和前进、后退是同时发生的,可以将它们归到一起处理。另外,为了防止重复计算,可以把backward()看作速度为负数的forward()。

public void backward(int speed, int angle) {
    this.forward(-speed, angle);
}

那么,关于机器人的移动只需要将forward()方法写好就行了。而这个forward()方法,归根到底,就是计算控制两个轮子的电动机的速度。

对于直行,也就是转向角为0°的情况,两个电动机的转速是相同的。如果将函数的第一个速度参数设计成电动机转速,那么两个电动机的速度就是这个速度值。当机器人转向时,为保持前进速度,在指定速度的基础上,其中一个电动机的速度要减小,另一个电动机的速度要增大,减小和增大的数值是相等的。可以记作:

Speed left =Speed+dv

Speed right =Speed-dv

显而易见,dv是机器人转弯角速度angle的某种函数,即

dv= f (angle)

只要理清其中的函数关系,机器人的移动就不再是个问题了。

图1-1-15是以机器人的旋转中心为原点绘制的坐标图,当机器人转弯的时候,其车轮相对于旋转中心所做的运动是圆周运动,图中并未完全显示的大圆就是机器人车轮的旋转轨迹,角 α 是单位时间内机器人转过的转角;左侧的两个小圆是机器人旋转前后的车轮位置,实际车轮应该是与绘图平面垂直的。为了方便描述,图中将车轮放倒绘制在同一个平面上,这一转变并不影响车轮转过角度的计算。图中dv就是单位时间内车轮转过的角度,也就是电动机的转速。

图1-1-15 车轮转速示意图

很显然,两个圆的转角所对应的圆弧长度是一样的。于是,根据圆弧长度的计算公式可以得出两个角度值之间的关系为

这里的 α 就是上文提到的angle,因此,dv与angle的函数关系就是简单的正比关系,可以记做

dv=RATE×angle

公式中的angle(同时也是forward()函数的参数)将由手机旋转产生后传给机器人,如果希望机器人转得相对快一些,就可以将RATE设置为一个比较大的值;如果希望相对转慢一点,就将RATE设小一点。这样,两个电动机的转速就都有了。

然而,还有一个问题是:电动机是有速度上限的,如果转弯时转速较快的电动机的速度超过了速度上限,EV3实际运转电动机的时候将以速度上限运转它,这时,就无法保证转弯的速度。所以,当有电动机达到速度上限时,需要对速度做出调整,让转速快的电动机以速度上限运转,另一边的电动机设为“上限速度-dv×2”。这样,就能够保证机器人的转向了。

最终,就得到以下代码:

/ **
  * 机器人前进
  * @param speed 前进时的平均引擎角速度,为负数时机器人后退
  * @param angle 机器人转弯角度,数值来自遥控手机,单位为度
  * /
public void forward(int speed, int angle) {
    if(signum(speed) !=signum(this.speed)) {
        //若驶向相反方向,则更新距离值
        //以免距离值被反向运动中和
        updateDistance();
    }
    
    //保证速度不超过上限
    speed=adjustSpeed(speed);
    
    //计算转弯时的两轮角速度与平均角速度之间的差
    int dv=angle * ANGULAR_RATE;
    if(speed<0) {
        //倒车时,差值取相反值
        dv=-dv;
    }
    //粗求两轮引擎的角速度,结果可能超过引擎速度上限
    int[] speeds={ speed+dv, speed-dv};
    //循环计算两轮引擎实际角速度
    for (int i=0; i<speeds.length; i++) {
        int x=speeds[i];
        int adv=Math.abs(dv)<<1;
        if(Math.abs(x)>speedLimit) {
            //如果粗算角速度值超过速度上限
            //当前引擎速度设为上限
            speeds[i]=x>0 ? speedLimit: -speedLimit;
            //对另外一个引擎的速度做出相应处理
            speeds[(~i) & 0x01]=x>0 ? 
                speedLimit-adv: -speedLimit+adv;
            break;
        }
    }
    
    //将计算出的速度设置到电动机上
    for (int i=0; i<wheelMotors.length; i++) {
        BaseRegulatedMotor motor=wheelMotors[i];
        int sp=speeds[i];
        motor.setSpeed(sp);
        int currSpeed=motor.getRotationSpeed();
        //仅在速度方向发生改变时,重新调用forward()或backward()方法
        if(sp>0 && currSpeed<=0) {
            motor.forward();
        } else if(sp<0 && currSpeed>=0){
            motor.backward();
        }
    }
}

这里,使用一个只有两个元素的数组来存储两个电动机的速度,第一个元素是左轮,第二个元素是右轮。同时,电动机也是用数组来处理的,这样可以通过循环来进行处理以减少重复代码。

细心的读者应该注意到了,代码一开头,有一个更新里程的部分。为什么会有这样一段程序呢?这里就要说一下里程的计算。

里程,说到底就是基于电动机转过的角度的总和得来的数值。在leJOS中为电动机提供了一个函数——getTachoCount(),可以取得电动机的总转角,然而当电动机从正向旋转变为反向旋转时,已经累积的角度值会减少。而一辆车走过的里程,不论是前行还是后退,都应该是不断累加的。因此,在电动机转向发生变化的时候,就有必要及时地将到目前为止的里程保存下来,并开始计算新的里程。电动机会发生转向的原因,就是我们的机器人速度从正变成负或者从负变成正,换句话说,就是符号发生了改变。所以,有了函数最开头那段判断速度符号,并更新里程的代码。

既然说到这里,就顺便看看更新里程的函数吧!

private synchronized void updateDistance() {
    int tachoCount=getTotalTachoCount();
this.distance+=
        this.getDistanceFromTotalTachoCount(
            Math.abs(tachoCount-prevTachoCount));
    prevTachoCount=tachoCount;
}

函数所做的事情就如上面说到的,取得当前的转角值,计算当前转角值与记录点值之差的绝对值,然后将这个绝对值累加到里程上,最后更新记录点值为当前转角值。

当然,实际的里程值不能是转角,而是这个转角对应的弧长。所以,这里调用了一个getDistanceFromTotalTachoCount()函数来计算弧长。由于代码不是太难,这里就不列出来了,可以自行去本书附带的工程中寻找。

除此之外,我们的机器人还要能返回速度、转速以及障碍物信息。转速就是电动机的旋转速度,通过leJOS提供的getRotationSpeed()函数即可轻松得到;速度则是转速对应的单位时间经过的弧长,仍旧是弧长计算,也不赘述了。

障碍物的信息,需要用到超声波传感器(如果你用的是EV3家庭套装,也可以用红外线传感器代替,只是两者的代码略有不同)。在针对EV3的leJOS中,对传感器数值的取得方式采用了统一的传感器框架。

所有的传感器,都被归类为SensorModes,可以使用getMode()方法取得对应的SampleProvider。而我们想要的数值,通过SampleProvider.fetchSample()函数就可以得到了。

由于传感器的种类不同,取得的数值就可能不同,为了统一,在fetchSample()函数中填充了一个float[]数组来满足各种传感器的需求。

我们这次要进行距离的测量,无论是使用超声波传感器还是红外线传感器,它们都有一个distance mode,从leJOS的文档中可以看到,两个传感器都有一个getDistanceMode()函数(或者getMode("Distance"))可以返回一个SampleProvider,而这个SampleProvider取值的时候,只有一个距离数值,所以用来取值用的float[]数组的长度设为1就够了。

传感器框架中取出来的数值都是国际单位制的,也就是说取出的距离值的单位将会是m。而前面介绍的障碍物信息消息中,为了提高运算速度,将单位设为mm,并采用了整数,所以在创建障碍物消息时,需要做一下转换。

对于传感器的信息和机器人的运行数据的发送,分别使用两个单独的对象来处理。这两个对象所对应的类也有所不同。然而,这两个类的处理机制大同小异,都是启动一个定时器,在新的线程中定时获取数据并通过通信员发送。其代码内容与调研项目中发送报告的部分类似,这里就不展开说明了。

至此,EV3端的主要内容就设计完成了。在机器人程序的主函数中,只需要获取机器人对象,启动服务器,创建连接后,设置好相应的处理员,并启动报告和障碍物信息监听就可以了,代码如下:

public static void main(String[] args) {
    //取得机器人对象
    final VehicleRobot robot=VehicleRobot.getInstance();
    //取得服务器对象
    Server server=Server.getInstance();
    //提示服务器等待连接
    promptWait();
    //启动服务器,等待连接
    server.start();
    //通知已连接
    promptConnected();
    try {
        //取得通信员对象
        Communicator communicator=server.getCommunicator();
        //追加机器人移动命令处理员
        communicator.addProcessor(RobotMoveCommand.class, 
            new RobotMoveProcessor(robot));
        //追加退出命令处理员
        communicator.addProcessor(ExitSignal.class, 
            new Communicator.Processor<ExitSignal>() {
            @Override
            public void process(ExitSignal msg, 
            Communicator communicator) {
                //退出时释放机器人资源
                robot.release();
            }
        });
        //创建障碍物监视器并启动
        ObstacleMonitor obsMonitor=
            new ObstacleMonitor(robot, communicator);
        obsMonitor.startReporting();
        //创建机器人状态报告器并启动
        RobotReporter reporter=
            new RobotReporter(robot, communicator);
        reporter.startReporting();
    } catch (IOException e) {
        Sound.buzz();
        e.printStackTrace();
    }
}

手机端

完成了EV3端的设计,再来看看手机端如何设计。

手机端的主要功能是遥控机器人和查看机器人传回的数据,所以不仅要能够实现功能,还需要有一个简洁方便的界面。比如,控制机器人的前后移动、设置速度等最好都在手指容易触到的地方,而信息显示部分虽然不要求手指能触到,但最好能直观地看到数值的变化。

另外,在调研时使用了一个下拉列表框和一个按钮来选择EV3设备并创建蓝牙连接。但这次的手机遥控涉及的内容比较多,我们就不希望这个蓝牙连接的部分还占用屏幕的大片区域了。从Android 3.0起,Google提倡使用Action Bar来放置常用功能菜单,所以,把这个蓝牙连接的功能改到Action Bar上面。

至于其他功能如何摆放,则需要规划一下。在正式绘制软件界面之前,先画一个草图,如图1-1-16所示。如上所述,控制机器人的部分需要手指容易触到,由于界面采用横屏,所以将调整移动方向和速度等控制类控件放在界面的两边。仿照汽车的操作设计,将油门(速度控制)和刹车放在右边,挡位(前后方向)放在左边。中间部分则显示机器人返回的信息:速度、转速、里程以及障碍物信息。中间部分的最下面,显示蓝牙连接的日志、错误信息等内容。

图1-1-16 手机遥控界面设计草稿

有了这份草图,接下来就在Android开发的图形界面设计器上开始画这个界面。控制速度的条状控件,使用SeekBar是最理想的,然而不幸的是,Android标准控件中只有横向的SeekBar,没有纵向的SeekBar。所以,需要自己做一些调整,在搜索引擎(如Google)上搜索“Android SeekBar Vertical”,可以找到很多开放源代码的纵向SeekBar的实现。我们可以选取其中之一,修改一些Bug并针对需要的功能稍做改变即可。

最终,控制界面设计如图1-1-17所示(实际运行时的界面)。

图1-1-17 手机遥控界面最终效果

其中,前进、后退挡位的部分没有采用设计草稿的开关方式,而是改作了单选框;左上角追加了转向角示意图;中间的信息显示部分,将草稿中位于下部的障碍物信息挪到了顶部;对转速和速度添加了进度条显示,这样可以让速度的大小更加直观。

然而,所有这些控制在手机和EV3建立连接之前,显然是不希望用户操作的。所以,在蓝牙连接前如果能加一个遮罩最好。另外,蓝牙连接的控制放在Action Bar上,也不够醒目,最好能给出一个提示来让用户知道如何连接EV3。所以,在程序刚启动后,会显示一个图1-1-18所示的界面。使用半透明的遮罩来挡住控件,并在右上角用文字提示用户单击CONNECT按钮。

图1-1-18 手机遥控启动界面

要实现这种界面效果,需要在设置界面的XML中以FrameLayout作为最底层的布局,因为FrameLayout允许上面的控件重叠,然后在其中放置两层界面内容——下面一层是遥控操纵的部分,上面一层则是一个带提示的半透明遮罩。当蓝牙连接成功后,只要将上一层的显示属性(visibility)设置为消失(gone)就可以显露出下面一层的内容并允许用户操纵了。

回过头来再多说两句蓝牙连接。蓝牙连接功能放在Action Bar的菜单按钮上,使得我们没有办法在单击按钮之前选择要连接的设备,所以,需要在单击按钮后提醒用户选择设备,这里使用列表对话框实现这一功能,效果如图1-1-19所示。列表对话框的实现,在Android官方的编程指南中有所介绍,只需要在创建对话框时用setItems()方法指定列表中的内容和选中时的处理方式即可。代码如下:

图1-1-19 手机遥控选择蓝牙设备界面

private void askForDeviceSel(final BluetoothDevice[] devices) {
    if(devices.length>0) {
        CharSequence[] deviceDescriptions=
            new CharSequence[devices.length];
        for(int i=0; i<devices.length; i++) {
            deviceDescriptions[i]=Html.fromHtml(
              String.format("<b>%s</b>[%s]", 
              devices[i].getName(), devices[i].getAddress()));
        }
        AlertDialog.Builder builder=
            new AlertDialog.Builder(this);
        builder.setTitle(R.string.title_select_device)
            .setItems(deviceDescriptions, 
                new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    if(which<0) {
                        setBtConnectState(
                            BtConnectState.Disconnected);
                    } else {
                        connect(devices[which]);
                    }
                }
            });
        AlertDialog dialog=builder.create();
        dialog.setOnCancelListener(
            new DialogInterface.OnCancelListener() {
            @Override
            public void onCancel(DialogInterface dialog) {
                setBtConnectState(BtConnectState.Disconnected);
            }
        });
        dialog.show();
    } else {
        Toast.makeText(this, 
            R.string.msg_bluetooth_pair_necessary, 
            Toast.LENGTH_LONG).show();
    }
}

关于蓝牙连接的其他部分代码,与调研中的内容基本一致,加之代码量较大,就不在正文中列出了。

对于手机操控部分,以及机器人信息的显示,都是比较基本的Android控件处理,由于Android编程的细节不在本书的讨论范围之内,读者可以参阅其他Android编程书籍学习和理解,也不在此一一列出说明了。

在这里想说一下的是机器人移动命令的发送。

首先是发送命令的时机。在没有新的命令到达时,机器人将维持上次命令给出的速度和转向角进行移动,所以,理论上只要速度或者转向角发生了变化,就应该发送新的命令。在我们的手机端程序中,速度通过拖动纵向SeekBar来改变,转向角由手机的倾斜变化来改变。两者都可能在很短的时间内发生很多次改变。例如,用手指缓慢地推动速度设置条,其中的数值就会不断地变化;同样,手机倾斜时,倾斜角在整个转动过程中都是不断变化的。如果在数值发生变化时就向机器人发送命令,就会出现瞬间产生巨量命令的情况。这无论对EV3的处理能力还是蓝牙网络的吞吐量都是一个极大的挑战。实际测试的结果也证明,这种方式会因为命令无法得到及时处理产生命令的延迟执行。例如,手机端已经将速度提到最大,机器人却还在缓慢地加速。显然,这不是我们所期望的结果。

那么,如何解决这个问题呢?

为了让命令可以及时得到处理,必须减少发送命令的频度,每次数值有变都发送命令是不行的。可以采取的方案有以下几种。

(1)设定一个命令数据采集间隔,每隔一定时间采集一次命令数据,并发送命令。

(2)每次发送命令后都留一段空白时间,忽略在空白时间中出现的数值变化。

(3)每次发送命令后都留一段空白时间,将空白时间中出现的数值变化暂存起来,下次发送。

3个方案都可以有效地解决命令无法及时得到处理的问题。然而,第(1)个方案,如果命令发出的时间刚好在一次数据采集之后,这个命令就会被延迟一个间隔。此外,第(1)个和第(2)个方案中,如果在采集数据的间隔期间或空白时间中出现了停止之类的关键命令,就有可能被忽略,会导致机器人的行为与发出的命令不符。而第(3)个方案,如果暂存起来的命令过多,仍然会出现命令处理延迟的问题。

那怎么做才好呢?可以从一些游戏中学到解决的方法。当我们玩一些大型3D游戏的时候,由于机器配置不佳,常常会出现运行缓慢或者卡顿的情况。常见的有两种:一种是做出的一系列操作,要等一段时间画面才会有所响应,如赛车游戏中,我们按下左右左的操作,会发现屏幕上的赛车会忠实地按顺序执行左转右转左转,只是慢了半拍;另一种是我们过快做出的一系列操作中只有最后的操作被游戏接受,中间的部分操作被抛弃了,同样以赛车游戏为例,按下左右左,会发现由于卡顿,最后赛车只有左转,中间的右转操作被无视了。显然,对于激烈对抗的游戏,后一种方式能让玩家更好地进行操作,因为在这种需要快速反应的游戏中,最后的操作才是最符合当时情况的,后一种方式更能反映玩家最新的判断结果。

我们现在面临的问题和上面提到的游戏很像,都是由于输入数据过多,但处理速度跟不上导致的。在没有办法完美处理所有输入的情况下,我们选用抛弃部分命令的方案来处理。或者说,相当于前面提到的第(2)种和第(3)种方案的一个折中方案,空白时间中仅暂存最后得到的命令,中间的命令抛弃掉。但是这样做,仍然会出现如停止之类的关键命令被抛弃的情况。所以,将机器人移动命令分为两类:一是移动类命令;二是停止类命令。用在RobotMoveCommand中定义的命令类型来说,移动类命令包含Forward和Backward,停止类命令包含Float和Stop。对于移动类命令,采用上面提到的发送—等待—丢弃—暂存的方案发送;对于停止类命令,无论何时都立即发送。

那么,这个发送—等待—丢弃—暂存的方案如何用代码实现呢?

首先设置一个变量,用以存储暂存的命令。然后构建一个新的线程,在其中检查暂存的命令,当暂存的命令存在时,发送命令并休眠一段时间,休眠结束后,继续检查暂存的命令,如此循环。当速度或转向角发生变化时,由主程序请求发送一个命令,这个命令将存在暂存命令的变量里。由于只有一个存储暂存命令的变量,当新的命令被请求后,旧的命令如果尚未发出,就会被覆盖,这样就保证了只有最新的命令才被暂存起来。

为了让程序结构清晰,将发送机器人移动命令部分移出来,单独创建了一个类,名叫RobotMoveCommandSender。上面提到的发送—等待—丢弃—暂存的实现,主要在以下几个函数中:

private void requestSendMove(RobotMoveCommand cmd) {
    mLock.lock();
    try {
        mWaitCommand=cmd;
        mHasCommand.signal();
    } finally {
        mLock.unlock();
    }
}
    
private void startCommandSendThread() {
    //创建命令发送线程
    Thread t=new Thread(new Runnable() {
        @Override
        public void run() {
            RobotMoveCommand cmd=null;
            while(true) {
                try {
                    mLock.lockInterruptibly();
                    try {
                        //没有命令等待时,线程暂停
                        while(mWaitCommand==null) {
                            mHasCommand.await();
                        }
                        //有命令等待时,取出命令
                        cmd=mWaitCommand;
                        mWaitCommand=null;
                    } finally {
                        mLock.unlock();
                    }
                    if(!isDuplicatedCommand(cmd)) {
                        //若此命令与前次命令不同,则发送此命令
                        sendMoveCommand(cmd);
                        mPrevCommand=cmd;
                    }
                } catch (InterruptedException e) {
                    break;
                }
            }
        }
    }, "Command send thread");
    t.setDaemon(true);
    t.start();
}
    
/ **
* 发送移动类命令,包括前进(forward)和后退(backward)
* @param cmd 移动类命令
* @throws InterruptedException 线程被打断时抛出
*/
private synchronized void sendMoveCommand(RobotMoveCommand cmd) 
throws InterruptedException {
    if(mComm !=null) {
        mComm.send(cmd);
        //为确保机器人的处理时间,暂停一段时间
        Thread.sleep(SEND_ITERVAL);
    }
}

对于暂存命令的变量mWaitCommand,将会有两个线程对其进行访问,所以就涉及一个访问同步的问题。如果不做好同步,就有可能因为另一个线程的干扰,出现前一行代码中变量还是旧的值,下一行代码就莫名其妙地变成了新的值。所以,这里使用一个锁(Lock)和一个条件(Condition)来处理同步。这是Java标准的高级线程同步方式。具体来说,在处理命令的线程中,当发现没有暂存的命令时(mWaitCommand == null),需要暂停下来等待,所以执行了mHasCommand.await(),这里的mHasCommand就是条件,调用await()函数表示需要在此等待条件得到满足。由于这个部分涉及mWaitCommand的操作,所以,需要在这之间加锁,故而前面调用了mLock.lockInterruptibly(),后面调用了mLock.unlock()。在这两段代码之间,除了条件的await()方法主动解开锁定时以外,保证没有其他同样夹在mLock.lockInterruptibly()和mLock.unlock()之间的代码可以执行。只要保证所有对mWaitCommand操作的代码都夹在这两句之间,就可以保证线程的同步了。所以,在requestSendMove()函数中也使用了锁,因为里面对mWaitCommand进行了赋值。在赋值之后,调用了mHasCommand.signal()通知计算机,我们的条件现在被满足了。如果有因为调用了mHasCommand.await()而停在那里的代码,此时将会继续开始执行。也就是说,如果处理暂存命令的线程刚好正在等待,此时将向下执行,去发送暂存的命令,在发送命令的函数中,使用Thread.sleep()函数让线程进入休眠状态一段时间。在这段时间,如果有新的命令请求,将在requestSendMove()函数中覆盖mWaitCommand。同时,由于没有因为调用了mHasCommand.await()而停在那里的线程,所以mHasCommand.signal()将不起任何作用。

这样,就相对完美地解决了命令积压而导致的延迟处理问题。

软件设计部分,要在本书正文中说明的主要就是这么多。至于代码的详细内容,请参考以下3个工程。

(1)p01-motion-rc-vehicle-lib: CNO架构及网络协议消息。

(2)p01-motion-rc-vehicle-remotecontrol:手机遥控端代码。

(3)p01-motion-rc-vehicle-robot: EV3机器人端代码。

测试

硬件组装好,软件安装好,接下来要让自己的机器人动起来了!引用魔术师刘谦老师的一句话——“接下来就是见证奇迹的时刻!”

一定有很多人这么想吧?

但残酷的现实往往并不让人如意。第一次启动机器人程序和手机程序后,最初的兴奋与期待,或许很快就被各种摸不着头脑的问题折磨殆尽,甚至变成了满腔的怒火。

所以,在讲解测试之前,首先希望各位读者能够静下心来,稍稍降低一些期望值。遇到问题不要慌,冷静地分析原因,一个一个地去解决。

任何程序在经过测试之前,通常都是问题满身、千疮百孔的,只有通过测试才能发现这些问题,然后修正它们,以保证程序的健壮性。事实上,前面章节中列出的程序几乎都不是一次成型的,而是经历了规模大小不等的测试之后,修改好的程序。

具体如何测试,是软件工程中单独的一门学科,也有很多方法。针对某个函数,可以使用单元测试来确定函数的输出是我们想要的结果;针对整体功能,可以使用用户验收测试来确定达到了我们最初的期望和构想……由于我们的程序规模较小,而且主要是自娱自乐,就不套用那些过于复杂的测试理论,而仅针对较为复杂的函数和整体功能进行一些基本的测试。

在此,以实际运行本项目程序的经过来做一下简单的说明。

本书的撰写方式,实际上是一边写程序一边写文字的。所以,软件设计部分提到的程序也都是在写完硬件设计章节之后才写出来的。由于有了前面的调研程序,所以最初对自己程序的信心还是蛮大的,开发过程中几乎没有进行针对函数的测试,仅在完成蓝牙连接部分后,做了连接的测试。之后,就在全部开发完后,直接开始控制机器人测试。

然而,实际运行效果让人大跌眼镜——机器人的运动完全不听从命令指挥。经过一番努力分析和排查之后,才找到问题的原因之一:在最初的设计中,所有消息中传送的信息都是以国际单位制存储的单精度或双精度浮点型,这使得机器人无法及时地计算出电动机转速,也就无法及时处理消息。通过在VehicleRobot.forward()函数中追加时间测量代码,发现执行一次forward()函数竟然需要100ms以上,而手机端在1s内会产生几十甚至上百的移动命令。针对这个原因修改了设计,将浮点数都改为整数类型,加快了处理速度,问题虽然有所改善,但仍然无法达到要求。于是在手机端增加了发送—等待—丢弃—暂存的消息发送机制。

出现机器人运动异常的另一个原因,则是因为forward()函数中存在多处计算错误。几次修改都没有得到理想的效果,最终认识到forward()函数内的逻辑确实略有些复杂,于是决定单独针对这个函数用程序进行测试。测试方法是传入一些关键值,查看计算后的电动机速度值。为了方便测试,对forward()函数进行了稍许改造,将设置电动机速度部分改为输出电动机速度。

测试用代码如下:

VehicleRobot robot=VehicleRobot.getInstance();
int [] speeds={0, 500, 800};
for(int speed: speeds) {
    for(int i=-89; i<90; i++) {
        System.out.printf(">>>>speed: %d, angle: %d\\n", speed, i);
        robot.forward(speed, i);
        robot.backword(speed, i);
    }
}

输出类似:

>>>>speed: 0, angle: -89
sp[0]: -445
sp[1]: 445
sp[0]: -445
sp[1]: 445
>>>>speed: 0, angle: -88
sp[0]: -440
sp[1]: 440
sp[0]: -440
sp[1]: 440
>>>>speed: 0, angle: -87
sp[0]: -435
 ⁝
sp[1]: 40
>>>>speed: 800, angle: 85
sp[0]: 800
sp[1]: -50
sp[0]: -800
sp[1]: 50
>>>>speed: 800, angle: 86
sp[0]: 800
sp[1]: -60
sp[0]: -800
sp[1]: 60
>>>>speed: 800, angle: 87
sp[0]: 800
sp[1]: -70
sp[0]: -800
sp[1]: 70
>>>>speed: 800, angle: 88
sp[0]: 800
sp[1]: -80
sp[0]: -800
sp[1]: 80
>>>>speed: 800, angle: 89
sp[0]: 800
sp[1]: -90
sp[0]: -800
sp[1]: 90

检查几个关键点上的输出结果,即可判断函数功能是否正确。

当然,对于函数级别的测试,也可以使用JUnit之类专业的测试工具,但由于使用JUnit要写期待结果,这次就没有使用。

这些问题都修正后,机器人总算能听从指挥向前跑了,但是当机器人报告自身速度为负数的时候,手机界面上的速度显示条却无法显示。这算是一个比较容易找到原因的问题,因为用来显示速度的进度条的显示范围被设定为0~最大速度,负数的时候当然会没有显示。作为解决方案,如果单纯地将范围设置为负最大速度~最大速度,就会在速度为0的时候也有一段长度显示,这不符合常规思维。最终,将界面设计改为当速度为负数时,用进度条显示速度的绝对值,但将速度的进度条背景设为暗红色。

除此之外,障碍物显示的部分,一旦出现了一个-1之后,就永远只有0和-1两个数值。分析后发现,由于将取样后的float类型转成了整数类型,原本float类型中的Infinity(无穷大)和NaN(非数字)将会被转为-1和0。当障碍物距离超出超声传感器的检测范围或太近的时候,会采样出这样的数值。另外,为了取得较为平均的结果,从传感器取值时,使用了MeanFilter来进行多次取样的结果平均。经过对leJOS代码的分析,发现MeanFilter存在一个BUG:一旦取样中出现了Infinity或者NaN,从那以后的数值就永远都是这两个数值了。这个BUG,我后来在leJOS的官方论坛中发帖得到了确认,leJOS开发团队成员已对其进行了修改,相信在本书出版时的最新版本中应该早已被修正了。由于现在使用的leJOS仍然是有BUG的版本,本项目中对障碍物距离又不要求是平均结果,所以在本程序中弃用了MeanFilter,改为直接采集传感器数据了。同时,对Infinity和NaN等特殊值也做了处理。

总之,类似这样的问题林林总总、不一而足,但只要保持一个清醒的头脑,不焦躁,冷静地分析,各个击破,总可以都解决掉的。

测试就是为了发现问题的,所以有问题并不可怕,真正可怕的是有问题却无法发现。一个好的测试方法可以尽可能避免遗漏问题。

由于测试主要是针对功能,所以,测试之前,最好有一个功能清单,然后针对功能清单,撰写测试用例。在测试用例中写明测试项目、期待结果、实际结果及测试时间等。这样,参考整个测试用例都测一遍,仍然没有问题,就算测试通过了。如果发现问题,问题修改后,应该将整个测试用例都重新测试一遍。

表1-1-2就是本项目的测试用例和测试结果的主要部分。

表1-1-2 项目1测试用例和测试结果

如表1-1-2所示,准备一份测试用例,反复进行测试,直到所有结果均为OK,测试方可结束。当然,根据质量标准,当存在一些无伤大雅的问题时,也可以算作测试结束。

测试结束,才可以宣告:一个可以使用的软件诞生了!

常见问题

问: Telnet如何使用?

答: 在命令行中输入telnet即可。详细命令帮助可以参阅操作系统中的帮助文档。

问: 我在Windows命令行下输入telnet,告诉我此命令不存在,是什么原因?

答: 有些版本的Windows出于安全考虑,默认不提供telnet命令。在Windows 7中可以从“控制面板”→“程序和功能”中选择“打开或关闭Windows功能”进入Windows功能设置对话框,在其中找到并勾选“Telnet客户端”后确定,即可完成Telnet的安装。其他Windows版本请自行到网络上搜索解决。

问: 端口为什么选择9988?

答: 理论上端口可以是小于65535的任意正整数,通常选择端口要避开常用的标准端口,如HTTP协议的80、FTP协议的21、Telnet协议的23等。一个比较大的端口数字,通常不会有人用,而常有测试程序使用9080、8080之类的端口,为了避免冲突,就选用了9988。

问: 命令行下按Ctrl+A组合键可以输入数字1,还有没有类似的窍门?

答: 按字母顺序排列,按Ctrl+B组合键可以输入数字2,按Ctrl+C组合键输入3,以此类推。不过按Ctrl+C组合键在操作系统中有终止程序的特殊意义,通常没办法真正做到输入3。

问: 为什么很多变量名前面都有一个小写的“m”?

答: 这是Google推荐的Android编程命名规范,小写“m”开头代表对象成员,是英文member的缩写,相对应的,小写“s”开头代表类成员,是英文static的缩写。所以,在Android端程序中,都会遵从这一规范;而leJOS的机器人端程序则主要遵从常用的标准Java编程规范,所以没有前缀“m”。

问: 这么多Android系统的函数和leJOS的函数,我记不住怎么办?

答: 我也记不住。但Google和leJOS都为我们准备了完备的文档,可以去查。

Android文档的地址是: http://developer.android.com/index.html。

leJOS for EV3文档的地址是: http://www.lejos.org/ev3/docs/。

问: 书中的代码都是片段,我想看到完整的程序怎么办?

答: 可以从随书附送的光盘或者我在前言中提供的网址中找到完整代码。

问: 很多代码中可以看到@author programus的字样,这是什么意思?

答: 这是Java的文档注释中用来标记代码作者的。Programus是本书作者的英文网络昵称,对应的中文网络昵称是“程序猎人”。有兴趣的读者可以去搜索一下,或许会有意外的发现。

问: 为什么我复制了书里的代码,却无法编译通过?

答: 原因可能很多。一方面,书中的代码有些是片段,本身无法成为独立文件编译通过,需要读者自己补全其他部分;另一方面,书中的代码为了节省篇幅,将import部分都省略了,需要读者自己将必需的import语句补上,如果使用Eclipse,可以按Ctrl+Shift+O组合键来自动完成import的添加。

问: 能前进和转弯的机器人一定需要两个电动机吗?

答: 曾见过一个日本乐高爱好者设计出一种一个电动机同时完成移动和转弯的结构,但那种结构并不能灵活地左右转弯,而是只能向一个方向转,通过转过很大的角度实现向相反方向转弯的目的。显然这不符合我们这个项目的要求,所以没有采纳。

问: 手机端界面设计草图是作者画的吗?

答: 是。因为三星Note Ⅱ手机带手写笔,在手机上画图也并不难。那个图是做好Action Bar部分之后截图出来,然后在截图上直接画的。 apshNAKcJ/7XZ6WYq/W5kQmHRvWILYbrwV2CGzE+ajfkwaaEGz1sSVRzPPi101Ro

点击中间区域
呼出菜单
上一章
目录
下一章
×