5. 作业 2 - 使用 Android 平板电脑控制 Arduino
接下来我们将学习如何使用平板电脑控制 Arduino 开发板。后续示例将基于本课程中的 [client-android-skel] 项目(参见第 2 段)。
5.1. 项目架构
整个项目将采用以下架构:
![]() |
- 模块 [1](Web 服务器/JSON 及 Arduino 设备)将由我们提供;
- 您需要构建模块 [2],即用于与 Web 服务器/JSON 通信的 Android 平板程序。
5.2. 硬件
您可使用以下组件:
- 一块配备以太网扩展板、LED 灯和温度传感器的 Arduino 开发板;
- 一个 miniHub(需与另一名学生共享);
- 一条用于为 Arduino 供电的 USB 数据线;
- 两根网线,用于将 Arduino 和电脑连接到同一局域网;
- 一台安卓平板电脑;
5.2.1. Arduino
![]() |
以下是将各个组件连接在一起的方法:
- 将网线从电脑上拔下;
- 使用网线将电脑与Arduino连接起来;
- 您手中的 Arduino 已预先编程,其 IP 地址为 [192.168.2.2]。为了让电脑识别 Arduino,您必须为其分配一个位于 [192.168.2] 网络内的 IP 地址。Arduino 已预设为与 IP 地址为 [192.168.2.1] 的电脑进行通信。 操作步骤如下:
进入 [控制面板\网络和互联网\网络和共享中心]:
![]() |
- 在 [1] 中,单击 [局域网] 链接;
- 在 [2] 中,点击本地网络的 [属性] 按钮;
![]() | ![]() |
- 在 [3] 中,点击 [本地连接] 适配器的 [IPv4] 属性;
- 在 [4] 中,为该适配器分配 IP 地址 [192.168.2.1] 和子网掩码 [255.255.255.0];
- 在 [5] 中,根据需要多次点击 [确定] 以退出向导。
5.2.2. 平板电脑
- 使用您的 Wi-Fi 适配器,将计算机连接到我们将提供的 Wi-Fi 网络。对您的平板电脑也进行同样的操作;
- 在命令提示符窗口中输入 [ipconfig],查看电脑的 Wi-Fi IP 地址。您将看到类似 [192.168.x.y] 的地址;
dos>ipconfig
Configuration IP de Windows
Carte réseau sans fil Wi-Fi :
Suffixe DNS propre à la connexion. . . :
Adresse IPv6 de liaison locale. . . . .: fe80::39aa:47f6:7537:f8e1%2
Adresse IPv4. . . . . . . . . . . . . .: 192.168.1.25
Masque de sous-réseau. . . . . . . . . : 255.255.255.0
Passerelle par défaut. . . . . . . . . : 192.168.1.1
- 请检查平板电脑的 Wi-Fi IP 地址。如果不确定如何操作,请咨询您的讲师。您会看到类似 [192.168.x.z] 的地址;
- 如果电脑的防火墙处于启用状态,请将其禁用 [控制面板\系统和安全\Windows 防火墙];
- 在命令提示符窗口中,输入命令 [ping 192.168.x.z](其中 [192.168.x.z] 是您的平板电脑 IP 地址)来验证电脑和平板电脑能否通信。此时平板电脑应作出响应:
dos>ping 192.168.1.26
Envoi d'une requête 'Ping' 192.168.1.26 avec 32 octets de données :
Réponse de 192.168.1.26 : octets=32 temps=102 ms TTL=64
Réponse de 192.168.1.26 : octets=32 temps=134 ms TTL=64
Réponse de 192.168.1.26 : octets=32 temps=168 ms TTL=64
Réponse de 192.168.1.26 : octets=32 temps=208 ms TTL=64
Statistiques Ping pour 192.168.1.26:
Paquets : envoyés = 4, reçus = 4, perdus = 0 (perte 0%),
Durée approximative des boucles en millisecondes :
Minimum = 102ms, Maximum = 208ms, Moyenne = 153ms
您的系统网络配置现已就绪。
5.2.3. [Genymotion] 模拟器
[Genymotion] 模拟器(参见第 6.9 节)是平板电脑的绝佳替代方案。它的运行速度几乎与平板电脑相当,且无需 Wi-Fi 连接。我们建议使用此方法。您可以在应用程序的最终测试阶段使用平板电脑。
5.3. Arduino编程
本节重点介绍为 Arduino 编写 C 代码:
![]() |
另请参阅
- 安装 Arduino 开发 IDE(参见第 6.1 节);
- 使用 JSON 库(附录,第 6.6 节);
- 在 Arduino IDE 中,测试 TCP 服务器示例(例如 Web 服务器)和 TCP 客户端示例(例如 Telnet 客户端);
- 第 6.1 节中关于 Arduino 编程环境的附录。
![]() |
Arduino 是一组连接到硬件的引脚。这些引脚可作为输入或输出,其值可以是二进制或模拟的。要控制 Arduino,有两种基本操作:
- 将二进制/模拟值写入通过编号标识的引脚;
- 从指定编号的引脚读取二进制/模拟值;
除了这两项基本操作外,我们还将添加第三项:
- 使 LED 在特定时长内以特定频率闪烁。该操作可通过反复调用前两个基本操作来实现。然而,我们在测试中将看到,[DAO] 层与 Arduino 之间的交互耗时约一秒。因此,无法实现例如每 100 毫秒闪烁一次 LED 的效果。所以,我们将直接在 Arduino 上实现这一闪烁功能。
Arduino 的工作原理如下:
- [DAO]层与Arduino之间的通信通过TCP-IP网络进行,采用JSON(JavaScript对象表示法)格式的文本行进行交换;
- 启动时,Arduino 会连接到位于 [DAO] 层的一个注册服务器的 100 号端口。它向服务器发送一行文本:
这是一段描述正在连接的 Arduino 的 JSON 字符串:
- id:Arduino的标识符;
- desc:Arduino的功能描述。此处仅指定了Arduino的型号;
- mac:Arduino的MAC地址;
- 端口:Arduino 将通过该端口等待来自 [DAO] 层的命令。
除端口号(为整数)外,所有信息均为字符串格式。
- Arduino 在向注册服务器完成注册后,将开始在其向服务器指定的端口(上文中的 102)上监听。它将等待以下格式的 JSON 命令:
这是一个包含以下元素的 JSON 字符串:
- id:命令的标识符。可以是任意字符串;
- ac:操作。共有三种:
- pw(引脚写入)用于向引脚写入值,
- pr(引脚读取)用于从引脚读取值,
- cl(闪烁):使 LED 闪烁;
- pa:操作的参数。这些参数取决于具体操作。
- Arduino 总是会向其客户端返回响应。这是一个格式如下所示的 JSON 字符串:
其中
- id:被响应的命令的标识符;
- er(错误):若发生错误则返回错误代码,否则返回 0;
- et (状态):一个字典,除读取命令 pr 外始终为空。此时该字典包含所请求的引脚编号 x 的值。
以下是一些示例,用于阐明上述规范:
让第 8 号 LED 以 100 毫秒的间隔闪烁 10 次:
命令 | |
响应 |
cl 命令的参数包括:闪光持续时间(dur,单位为毫秒)、闪光次数(nb)以及 LED 的引脚编号。
将二进制值 1 写入第 7 引脚:
命令 | |
响应 |
pw 命令的 pa 参数包括:写入模式 mod(b 表示二进制,a 表示模拟)、待写入的值 val 以及引脚编号。对于二进制写入,val 为 0 或 1;对于模拟写入,val 的取值范围为 [0,255]。
将模拟值 120 写入引脚 2:
命令 | |
响应 |
从引脚 0 读取模拟值:
命令 | |
响应 |
pr 命令的 pa 参数包括:读取模式(二进制或模拟)以及引脚编号。如果没有错误,Arduino 会将请求引脚的值放入响应的 "et" 键中。在此处,pin0 表示请求了第 0 引脚的值,而 1023 即为该值。在读取模式下,模拟值将在 [0, 1024] 范围内。
我们已经介绍了 cl、pw 和 pr 这三个命令。有人可能会疑惑,为什么不在 JSON 字符串中使用更明确的字段——例如用 action 代替 ac,用 pinwrite 代替 pw,用 parameters 代替 pa。Arduino 的内存非常有限。然而,与 Arduino 交换的 JSON 字符串会占用内存。因此,我们选择尽可能地缩短它们。
现在让我们来看几个错误情况:
命令 | |
响应 |
发送的命令不符合 JSON 格式。Arduino 返回了错误代码 100。
命令 | |
响应 |
发送的 pr 命令未包含 pin 参数。Arduino 返回了错误代码 302。
命令 | |
响应 |
我们发送了一个未知的 pinread 命令(即 pr)。Arduino 返回了错误代码 104。
我们将不再继续演示这些示例。规则很简单:无论发送什么命令,Arduino 都绝不能崩溃。在执行 JSON 命令之前,它会确保该命令有效。一旦发生错误,Arduino 就会停止执行该命令,并向客户端返回 JSON 错误字符串。同样,由于内存空间有限,我们返回的是错误代码而非完整的错误信息。
本文档示例中提供了在 Arduino 上运行的程序代码:
![]() |
将其传输到 Arduino:
- 将其连接到电脑;
![]() | ![]() |
- 在 [1] 中,打开文件 [arduino_uno.ino]。Arduino IDE 将启动并加载该文件;
注:该代码最初是在 Arduino IDE 1.5.x 版本下编写并测试的。此后,IDE 已发布了其他版本。该代码在 Arduino IDE 1.6.x 版本下无法运行。看来 1.6 版与 1.5 版之间存在向后兼容性问题。
- 在[2-4]中,请指定所使用的Arduino型号;
![]() | ![]() |
- 在 [5-7] 中,指定其连接到 PC 的哪个串口;
- 在 [8] 中,将 [arduino_uno] 程序上传至 Arduino;
该程序代码注释详尽。感兴趣的读者可自行查阅。我们仅重点标出配置 Arduino 与 PC 之间双向客户端/服务器通信的代码行:
#include <SPI.h>
#include <Ethernet.h>
#include <ajSON.h>
// ---------------------------------- CONFIGURATION DE L'ARDUINO UNO
// adresse MAC de l'Arduino UNO
byte macArduino[] = {
0x90, 0xA2, 0xDA, 0x0D, 0xEE, 0xC7 };
char * strMacArduino="90:A2:DA:0D:EE:C7";
// l'adresse IP de l'Arduino
IPAddress ipArduino(192,168,2,2);
// son identifiant
char * idArduino="cuisine";
// port du serveur Arduino
int portArduino=102;
// description de l'Arduino
char * descriptionArduino="contrôle domotique";
// le serveur Arduino travaillera sur le port 102
EthernetServer server(portArduino);
// IP du serveur d'enregistrement
IPAddress ipServeurEnregistrement(192,168,2,1);
// port du serveur d'enregistrement
int portServeurEnregistrement=100;
// le client Arduino du serveur d'enregistrement
EthernetClient clientArduino;
// la commande du client
char commande[100];
// la réponse de l'Arduino
char message[100];
// initialisation
void setup() {
// Le moniteur série permettra de suivre les échanges
Serial.begin(9600);
// démarrage de la connection Ethernet
Ethernet.begin(macArduino,ipArduino);
// mémoire disponible
Serial.print(F("Memoire disponible : "));
Serial.println(freeRam());
}
// boucle infinie
void loop()
{
...
}
- 第 8 行:Arduino 的 MAC 地址。这里并不太重要,因为 Arduino 将与一台 PC 以及一个或多个 Arduino 共同位于一个私有网络中。 MAC 地址只需在该私有网络中保持唯一即可。通常,Arduino 的网络板上有贴纸标注了板的 MAC 地址。如果该贴纸丢失且您不知道板的 MAC 地址,只要遵循私有网络中 MAC 地址唯一性的规则,您可以在第 8 行输入任意值;
- 第 11 行:网卡的 IP 地址。同样,您可以输入 [192.168.2.x] 格式的任意值,并根据私有网络中不同 Arduino 的情况调整 x 的数值;
- 第 13 行:Arduino 标识符。必须在同一私有网络中的 Arduino 标识符中保持唯一;
- 第 15 行:Arduino 的服务端口。可输入任意值;
- 第 17 行:Arduino 功能的描述。可输入任意内容。由于 Arduino 内存有限,请注意避免使用过长的字符串;
- 第 21 行:PC 上 Arduino 日志服务器的 IP 地址。请勿修改;
- 第 23 行:此日志服务的端口。不得更改;
5.4. Web/JSON 服务器
5.4.1. 安装

Web/JSON 服务器的 Java 二进制文件如下:
![]() |
打开命令提示符,并输入以下命令:
如果 [java.exe] 不在命令提示符的 PATH 环境变量中,您需要输入 [java.exe] 的完整路径(通常为 C:\Program Files\java\...)。
此时将打开一个 DOS 窗口并显示日志:
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v0.5.0.M6)
2014-01-06 11:11:35.550 INFO 8408 --- [ main] arduino.rest.metier.Application : Starting Application on Gportpers3 with PID 8408 (C:\Users\SergeTahÚ\Desktop\part2\server.jar started by ST)
2014-01-06 11:11:35.587 INFO 8408 --- [ main] ationConfigEmbeddedWebApplicationContext : Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@6a4ba620: startup date [Mon Jan 06 11:11:35 CET 2014]; root of context hierarchy
2014-01-06 11:11:36.765 INFO 8408 --- [ main] o.apache.catalina.core.StandardService : Starting service Tomcat
2014-01-06 11:11:36.766 INFO 8408 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet Engine: Apache Tomcat/7.0.42
2014-01-06 11:11:36.876 INFO 8408 --- [ost-startStop-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2014-01-06 11:11:36.877 INFO 8408 --- [ost-startStop-1] o.s.web.context.ContextLoader : Root WebApplicationContext: initialization completed in 1293 ms
2014-01-06 11:11:37.084 INFO 8408 --- [ost-startStop-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring FrameworkServlet 'dispatcherServlet'
2014-01-06 11:11:37.084 INFO 8408 --- [ost-startStop-1] o.s.web.servlet.DispatcherServlet : FrameworkServlet 'dispatcherServlet': initialization started
2014-01-06 11:11:37.184 INFO 8408 --- [ost-startStop-1] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/**/favicon.ico] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2014-01-06 11:11:37.386 INFO 8408 --- [ost-startStop-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/arduinos/blink/{idCommande}/{idArduino}/{pin}/{duree}/{nombre}],methods=[GET],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public java.lang.String arduino.rest.metier.RestMetier.faireClignoterLed(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,javax.servlet.http.HttpServletResponse)
2014-01-06 11:11:37.388 INFO 8408 --- [ost-startStop-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/arduinos/commands/{idArduino}],methods=[POST],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public java.lang.String arduino.rest.metier.RestMetier.sendCommandesJson(java.lang.String,java.lang.String,javax.servlet.http.HttpServletResponse)
2014-01-06 11:11:37.388 INFO 8408 --- [ost-startStop-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/arduinos/],methods=[GET],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public java.lang.String arduino.rest.metier.RestMetier.getArduinos(javax.servlet.http.HttpServletResponse)
2014-01-06 11:11:37.389 INFO 8408 --- [ost-startStop-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/arduinos/pinRead/{idCommande}/{idArduino}/{pin}/{mode}],methods=[GET],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public java.lang.String arduino.rest.metier.RestMetier.pinRead(java.lang.String,java.lang.String,java.lang.String,java.lang.String,javax.servlet.http.HttpServletResponse)
2014-01-06 11:11:37.390 INFO 8408 --- [ost-startStop-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/arduinos/pinWrite/{idCommande}/{idArduino}/{pin}/{mode}/{valeur}],methods=[GET],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public java.lang.String arduino.rest.metier.RestMetier.pinWrite(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,javax.servlet.http.HttpServletResponse)
2014-01-06 11:11:37.463 INFO 8408 --- [ost-startStop-1] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2014-01-06 11:11:37.464 INFO 8408 --- [ost-startStop-1] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/webjars/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2014-01-06 11:11:37.881 INFO 8408 --- [ost-startStop-1] o.s.web.servlet.DispatcherServlet : FrameworkServlet 'dispatcherServlet': initialization completed in 796 ms
Serveur d'enregistrement lancÚ sur 192.168.2.1:100
2014-01-06 11:11:38.101 INFO 8408 --- [ Thread-4] arduino.dao.Recorder : Recorder : [11:11:38:101] : [Serveur d'enregistrement : attente d'un client]
2014-01-06 11:11:38.142 INFO 8408 --- [ main] arduino.rest.metier.Application : Started Application in 3.257 seconds
- 第 11 行:启动了一个嵌入式 Tomcat 服务器;
- 第 15 行:加载并执行 Spring MVC Servlet [dispatcherServlet];
- 第 18 行:检测到 REST URL [/arduinos/blink/{commandId}/{ArduinoId}/{pin}/{duration}/{count}];
- 第 19 行:检测到 REST URL [/arduinos/commands/{idArduino}];
- 第 20 行:检测到 REST URL [/arduinos/];
- 第 21 行:检测到 REST URL [/arduinos/pinRead/{commandId}/{ArduinoId}/{pin}/{mode}];
- 第 22 行:检测到 REST URL [/arduinos/pinWrite/{commandId}/{ArduinoId}/{pin}/{mode}/{value}];
- 第 26 行:启动 Arduino 日志服务器;
若尚未连接,请将 Arduino 连接至电脑。必须禁用电脑的防火墙。随后,在网页浏览器中输入 URL [http://localhost:8080/arduinos]:
![]() |
此时应能看到已连接 Arduino 的 ID。如果没有任何显示,请尝试重置 Arduino。它有一个专门用于此目的的重置按钮。
Web/JSON 服务器现已安装完毕。
5.4.2. Web/JSON 服务提供的 URL
参见:项目 [Example-15](参见第 1.16.1 节);
该 Web/JSON 服务采用 Spring MVC 实现,并提供以下 URL:
@Controller
public class WebController {
// business layer
@Autowired
private IMetier métier;
// list of arduinos
@RequestMapping(value = "/arduinos", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String getArduinos() throws JsonProcessingException {
...
}
// flashing
@RequestMapping(value = "/arduinos/blink/{idCommande}/{idArduino}/{pin}/{duree}/{nombre}", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String faireClignoterLed(@PathVariable("idCommande") String idCommande, @PathVariable("idArduino") String idArduino, @PathVariable("pin") int pin, @PathVariable("duree") int duree, @PathVariable("nombre") int nombre) throws JsonProcessingException {
...
}
// order dispatch JSON
@RequestMapping(value = "/arduinos/commands/{idArduino}", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String sendCommandesJson(@PathVariable("idArduino") String idArduino, HttpServletRequest request) throws IOException {
...
}
// pin reading
@RequestMapping(value = "/arduinos/pinRead/{idCommande}/{idArduino}/{pin}/{mode}", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String pinRead(@PathVariable("idCommande") String idCommande, @PathVariable("idArduino") String idArduino, @PathVariable("pin") int pin, @PathVariable("mode") String mode) throws JsonProcessingException {
....
}
// writing pin
@RequestMapping(value = "/arduinos/pinWrite/{idCommande}/{idArduino}/{pin}/{mode}/{valeur}", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String pinWrite(@PathVariable("idCommande") String idCommande, @PathVariable("idArduino") String idArduino, @PathVariable("pin") int pin, @PathVariable("mode") String mode, @PathVariable("valeur") int valeur) throws JsonProcessingException {
...
}
}
服务器发送的响应是以下 [Response<T>] 类的 JSON 表示形式:
package client.android.dao.service;
import java.util.List;
public class Response<T> {
// ----------------- properties
// operation status
private int status;
// any status messages
private List<String> messages;
// the body of the reply
private T body;
// manufacturers
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// getters and setters
...
}
URL [/arduinos] 返回类型为 [Response<List<Arduino>>] 的响应,其中 [Arduino] 是以下类:
package android.arduinos.entities;
import java.io.Serializable;
public class Arduino implements Serializable {
// data
private String id;
private String description;
private String mac;
private String ip;
private int port;
// getters and setters
...
}
- 第 7 行:[id] 是 Arduino 的标识符;
- 第 8 行:其描述;
- 第 9 行:其 MAC 地址;
- 第 10 行:其 IP 地址;
- 第 11 行:它用于监听命令的端口;
URL:
- [/arduinos/blink/{commandId}/{ArduinoId}/{pin}/{duration}/{count}];
- [/arduinos/pinRead/{commandId}/{ArduinoId}/{pin}/{mode}] ;
- [/arduinos/pinWrite/{commandId}/{ArduinoId}/{pin}/{mode}/{value}] ;
- [/arduinos/commands/{ArduinoID}];
返回类型为 [Response<ArduinoResponse>] 的响应,其中 [ArduinoResponse] 类表示标准的 Arduino 响应:
public class ArduinoResponse implements Serializable {
private String json;
private String id;
private String erreur;
private Map<String, Object> etat;
// getters and setters
...
}
- [json]: Arduino 发送的无法解码的 JSON 字符串(发生错误时),否则为 null;
- [id]: Arduino 响应的命令标识符;
- [error]: 错误代码,若成功则为 0,否则为其他值;
- [status]:一个包含该命令特定响应的字典。除非该命令请求从Arduino读取某个值,否则该字典通常为空;若请求读取值,该值将被放入此字典中;
5.4.3. Web 服务 / JSON 测试
通过测试以下 URL 熟悉 Web 服务器 / JSON:
以下是您应该看到的效果截图:
获取已连接的Arduino列表:
![]() |
从 Web 服务器 /JSON 接收到的 JSON 字符串是一个包含以下字段的对象:
- [status]:0 表示无错误,否则表示发生错误;
- [messages]:若发生错误,则为解释该错误的消息列表:
- [body]:若未发生错误,则为 Arduino 列表。每个 Arduino 由一个包含以下字段的对象描述:
- [id]: Arduino 的标识符。两个 Arduino 不能拥有相同的标识符;
- [description]:Arduino功能的简要描述;
- [mac]:Arduino的MAC地址;
- [ip]:Arduino的IP地址;
- [port]:用于监听命令的端口;
让标识为 [cuisine] 的 Arduino 第 8 引脚上的 LED 每 100 毫秒闪烁 20 次:
![]() |
从 Web 服务器 /JSON 接收到的 JSON 字符串是一个包含以下字段的对象:
- [status]:0 表示无错误,否则表示发生错误;
- [messages]:若发生错误,此处为解释错误原因的消息列表:
- [body]:若未发生错误,则为 Arduino 的响应:
- [id]: 命令标识符。该标识符即 [/blink/1] 中的 1。Arduino 会在其响应中包含此命令标识符;
- [error]: 错误编号。非 0 的值表示发生错误;
- [state]:仅用于读取引脚。其值为引脚的当前状态;
- [json]:仅在客户端与服务器之间发生 JSON 错误时使用。其值为 Arduino 发送的错误 JSON 字符串;
通过 [kitchen] 标识的 Arduino 引脚 0 的模拟读取值:
![]() |
从 Web 服务器 /json 接收到的 JSON 字符串与前一个类似,唯一不同之处在于 [state] 字段,它表示第 0 号引脚的值。
通过 [kitchen] 标识的 Arduino 引脚 5 的二进制读取结果:
![]() |
从 /json Web 服务器接收到的 JSON 字符串与之前的类似。
将数值 1 以二进制形式写入标识为 [kitchen] 的 Arduino 的第 8 引脚:
![]() |
从 /json Web 服务器接收到的 JSON 字符串与前一个类似。
测试 URL [http://localhost:8080/arduinos/commands/cuisine] 则更为复杂。处理此 URL 的 Web 服务器 /json 方法需要 POST 请求,而这无法通过浏览器轻松模拟。要测试此 URL,您可以使用安装了 [Advanced REST Client] 扩展程序的 Chrome 浏览器(参见第 6.13 节):
![]() |
- 在 [1] 中,待测试的 Web/JSON 方法的 URL;
- 在 [2] 中,用于发送请求的 POST 方法;
- 在 [3-4] 中,提交的值是 JSON;
- 在 [5] 中,即要提交的 JSON 字符串。请注意列表起始和结束处的方括号。此处,该列表仅包含一条 JSON 指令,该指令使第 8 引脚每 100 毫秒闪烁 10 次;
- 在 [6] 中,发送请求;
![]() |
- 在 [7] 中,服务器发送的 JSON 响应。该对象包含一个包含两个常规字段 [status] 和 [messages] 的对象,以及一个 [body] 字段,其值为对所发送的每个 JSON 命令的 Arduino 响应列表。
让我们看看当向 Arduino 发送语法错误的 JSON 命令时会发生什么:
![]() |
随后我们收到以下响应:
![]() |
我们可以看到,在 Arduino 的响应中,错误代码为 [104],这表明未识别 [xx] 命令。
5.5. Android 客户端测试
![]() |
以下是已完成的 Android 客户端可执行文件:
![]() |
使用鼠标将上方的 [app-debug.apk] 文件拖放到平板模拟器 [GenyMotion] 上。该文件将被保存并执行。如果尚未启动,请同时启动 web/jSON 服务器。将连接了 LED 的 Arduino 连接到电脑上。Android 客户端允许您远程管理 Arduino。它会向用户显示以下界面。
通过 [CONFIG] 选项卡,您可以连接到服务器并获取已连接的 Arduino 列表:

- 在 [1] 中,输入分配给您电脑的 IP 地址 [192.168.2.1](参见第 5.2 节)。
[PINWRITE] 选项卡允许您向 Arduino 引脚写入一个值:


[PINREAD] 选项卡允许您从 Arduino 引脚读取值:

[BLINK] 选项卡允许您使 Arduino LED 闪烁:

[COMMAND] 选项卡允许您向 Arduino 发送 JSON 命令:

5.6. Web 服务 / JSON 的 Android 客户端
接下来我们将讨论如何编写 Android 客户端。
5.6.1. 客户端架构
Android 客户端的架构将采用 [Example-15] 项目中的架构(参见第 1.16.2 节);
![]() |
- [DAO] 层与 Web/JSON 服务器进行通信;
Android 客户端必须能够同时控制多个 Arduino。例如,我们希望让两个 Arduino 上的两个 LED 灯同时闪烁,而不是一个接一个地闪烁。因此,我们的 Android 客户端将为每个 Arduino 创建一个异步任务,这些任务将并行运行。
5.6.2. Android Studio 客户端项目
将 [client-android-skel] 项目(参见第 2 节)复制为 [client-arduinos-01] 项目(如有必要,请参阅第 1.15 节了解如何复制 Gradle 项目):
5.6.3. 五个 XML 视图
![]() |
将有五个 XML 视图:
- [blink]:用于让 Arduino LED 闪烁。它与 [BlinkFragment] 片段相关联;
- [commands]:向 Arduino 发送 JSON 命令。该视图关联 [CommandsFragment] 片段;
- [config]:用于配置 Web 服务/JSON URL 并获取已连接 Arduino 的初始列表。该视图关联 [ConfigFragment] 片段;
- [pinread]:读取 Arduino 引脚的二进制或模拟值。它与 [PinReadFragment] 片段相关联;
- [pinwrite]:向 Arduino 引脚写入二进制或模拟值。它与 [PinWriteFragment] 片段相关联;
目前,这五个 XML 视图的内容均为空:
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/scrollView1"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
</RelativeLayout>
</ScrollView>
- 该视图位于 [RelativeLayout] 容器内(第 7–10 行),而该容器本身又位于 [ScrollView] 容器内(第 2–11 行)。这确保了当视图超出平板电脑屏幕尺寸时,我们可以对其进行滚动;
作业:创建这五个 XML 视图。
5.6.4. 片段菜单
我们知道,在 [client-android-skel] 构建的项目中,片段必须与一个菜单相关联,即使该菜单为空。在此,应用将不包含菜单。该项目中已存在一个空菜单;
![]() |
5.6.5. 该应用的五个模块
![]() | ![]() |
任务:将 [DummyFragment] 片段复制到应用程序的五个片段中,如图 [2] 所示。
[ConfigFragment] 片段具有以下骨架:
package client.android.fragments.behavior;
import client.android.R;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.custom.CoreState;
import client.android.fragments.state.DummyFragmentState;
import org.androidannotations.annotations.EFragment;
import org.androidannotations.annotations.OptionsMenu;
@EFragment
@OptionsMenu(R.menu.menu_vide)
public class ConfigFragment extends AbstractFragment {
// fields inherited from parent class -------------------------------------------------------
...
将第 10 行替换为以下内容:
@EFragment(R.layout.config)
任务:对其他四个片段也进行同样的操作,通过调整类中的 [@EFragment] 属性来实现。
片段 | 视图 |
ConfigFragment | |
PinReadFragment | |
PinWriteFragment | |
命令片段 | |
闪烁片段 | |
5.6.6. 片段状态
![]() | ![]() |
每个片段都会有一个状态。
任务:将 [DummyFragmentState] 类复制五次,以创建 [2] 中所示的五个状态。
5.6.7. 项目自定义
![]() |
[architecture / custom] 包包含应用程序架构中的可自定义元素。
5.6.7.1. [IMainActivity] 接口
[IMainActivity] 接口定义了片段可以向该活动请求的内容以及应用程序常量。该接口如下所示:
package client.android.architecture.custom;
import client.android.architecture.core.ISession;
import client.android.dao.service.IDao;
public interface IMainActivity extends IDao {
// session access
ISession getSession();
// change of view
void navigateToView(int position, ISession.Action action);
// wait management
void beginWaiting();
void cancelWaiting();
// constant application -------------------------------------
// debug mode
boolean IS_DEBUG_ENABLED = true;
// maximum time to wait for server response
int TIMEOUT = 1000;
// waiting time before executing customer request
int DELAY = 000;
// basic authentication
boolean IS_BASIC_AUTHENTIFICATION_NEEDED = false;
// fragment adjacency
int OFF_SCREEN_PAGE_LIMIT = 1;
// tab bar
boolean ARE_TABS_NEEDED = true;
// waiting image
boolean IS_WAITING_ICON_NEEDED = true;
// number of fragments
int FRAGMENTS_COUNT = 5;
// view n°s
int VUE_CONFIG = 0;
int VUE_BLINK = 1;
int VUE_PINREAD = 2;
int VUE_PINWRITE = 3;
int VUE_COMMANDS = 4;
}
- 第 25、28、31、40 行:[DAO] 层的配置。该应用程序查询一个 Web/JSON 服务器;
- 第 37 行:该应用程序具有标签页;
- 第 43 行:该应用程序包含五个片段;
- 第 46–50 行:五个片段的编号;
- 第 34 行:片段邻接关系。开发者可在此处设置一个值,范围为 [1, FRAGMENTS_COUNT-1];
5.6.7.2. [CoreState] 类
[CoreState] 类是片段状态的父类:
package client.android.architecture.custom;
import client.android.architecture.core.MenuItemState;
import client.android.fragments.state.*;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY)
@JsonSubTypes({
@JsonSubTypes.Type(value = ConfigFragmentState.class),
@JsonSubTypes.Type(value = BlinkFragmentState.class),
@JsonSubTypes.Type(value = PinReadFragmentState.class),
@JsonSubTypes.Type(value = PinWriteFragmentState.class),
@JsonSubTypes.Type(value = CommandsFragmentState.class)}
)
public class CoreState {
// fragment visited or not
protected boolean hasBeenVisited = false;
// status of any fragment menu
protected MenuItemState[] menuOptionsState;
// getters and setters
...
}
- 第 12–16 行:五个片段的状态类必须在此处声明;
5.6.8. [MainActivity] 类
![]() |
[MainActivity] 类的代码如下:
package client.android.activity;
import android.support.design.widget.TabLayout;
import android.util.Log;
import client.android.R;
import client.android.architecture.core.AbstractActivity;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.core.ISession;
import client.android.architecture.custom.IMainActivity;
import client.android.architecture.custom.Session;
import client.android.dao.entities.Arduino;
import client.android.dao.entities.ArduinoCommand;
import client.android.dao.entities.ArduinoResponse;
import client.android.dao.service.Dao;
import client.android.dao.service.IDao;
import client.android.dao.service.Response;
import client.android.fragments.behavior.*;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EActivity;
import org.androidannotations.annotations.OptionsMenu;
import rx.Observable;
import java.util.List;
import java.util.Locale;
@EActivity
@OptionsMenu(R.menu.menu_main)
public class MainActivity extends AbstractActivity {
// layer [DAO]
@Bean(Dao.class)
protected IDao dao;
// session
private Session session;
// methods parent class -----------------------
@Override
protected void onCreateActivity() {
// log
if (IS_DEBUG_ENABLED) {
Log.d(className, "onCreateActivity");
}
// session
this.session = (Session) super.session;
// creation of the five tabs
for (int i = 0; i < 5; i++) {
TabLayout.Tab newTab = tabLayout.newTab();
newTab.setText(getFragmentTitle(i));
tabLayout.addTab(newTab);
}
}
@Override
protected IDao getDao() {
return dao;
}
@Override
protected AbstractFragment[] getFragments() {
return new AbstractFragment[]{new ConfigFragment_(), new BlinkFragment_(), new PinReadFragment_(), new PinWriteFragment_(), new CommandsFragment_()};
}
@Override
protected CharSequence getFragmentTitle(int position) {
Locale l = Locale.getDefault();
switch (position) {
case 0:
return getString(R.string.config_titre).toUpperCase(l);
case 1:
return getString(R.string.blink_titre).toUpperCase(l);
case 2:
return getString(R.string.pinread_titre).toUpperCase(l);
case 3:
return getString(R.string.pinwrite_titre).toUpperCase(l);
case 4:
return getString(R.string.commands_titre).toUpperCase(l);
}
return null;
}
@Override
protected void navigateOnTabSelected(int position) {
// fragment n° position is displayed
navigateToView(position, ISession.Action.NAVIGATION);
}
@Override
protected int getFirstView() {
return IMainActivity.VUE_CONFIG;
}
// implémentation IDao -----------------------------------------
}
- 第 46–50 行:创建应用程序的五个选项卡;
- 第 48 行:标签页标题由第 63–79 行中的方法提供;
- 第 60 行实例化了五个片段。由于 AA 注解的存在,片段类即为之前定义的类,后缀为下划线;
- 第 63–79 行:为每个片段定义标题。这些标题将从文件 [res/values/strings.xml] 中获取
![]() |
[strings.xml] 的内容如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- application name -->
<string name="app_name">[arduinos-client-01]</string>
<!-- Fragments and tabs -->
<string name="config_titre">[Config]</string>
<string name="blink_titre">[Blink]</string>
<string name="pinread_titre">[PinRead]</string>
<string name="pinwrite_titre">[PinWrite]</string>
<string name="commands_titre">[Commands]</string>
</resources>
任务:创建上述元素并编译项目。编译过程中不应出现任何错误。
运行该项目。您应在模拟器上看到以下视图:

检查第一个视图显示时生成的日志,并追踪已执行的各个步骤。在标签页之间切换,并继续关注日志。
5.6.9. [config] XML视图
[config] XML 视图将如下所示:
![]() | ![]() |
上面的视图由以下 XML 代码生成:
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/scrollView1"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/txt_TitreConfig"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_centerHorizontal="true"
android:layout_marginTop="150dp"
android:text="@string/txt_TitreConfig"
android:textSize="@dimen/titre"/>
<TextView
android:id="@+id/txt_UrlServiceRest"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_below="@+id/txt_TitreConfig"
android:layout_marginTop="50dp"
android:text="@string/txt_UrlServiceRest"
android:textSize="20sp"/>
<EditText
android:id="@+id/edt_UrlServiceRest"
android:layout_width="300dp"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/txt_UrlServiceRest"
android:layout_alignBottom="@+id/txt_UrlServiceRest"
android:layout_marginLeft="20dp"
android:layout_toRightOf="@+id/txt_UrlServiceRest"
android:ems="10"
android:hint="@string/hint_UrlServiceRest"
android:inputType="textUri">
<requestFocus/>
</EditText>
<TextView
android:id="@+id/txt_MsgErreurIpPort"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_below="@+id/txt_UrlServiceRest"
android:layout_marginTop="20dp"
android:text="@string/txt_MsgErreurUrlServiceRest"
android:textColor="@color/red"
android:textSize="20sp"/>
<TextView
android:id="@+id/txt_arduinos"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_below="@+id/txt_MsgErreurIpPort"
android:layout_marginTop="40dp"
android:text="@string/titre_list_arduinos"
android:textColor="@color/blue"
android:textSize="20sp"/>
<Button
android:id="@+id/btn_Rafraichir"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/txt_arduinos"
android:layout_alignBottom="@+id/txt_arduinos"
android:layout_marginLeft="20dp"
android:layout_toRightOf="@+id/txt_arduinos"
android:text="@string/btn_rafraichir"/>
<Button
android:id="@+id/btn_Annuler"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/txt_arduinos"
android:layout_alignBottom="@+id/txt_arduinos"
android:layout_marginLeft="20dp"
android:layout_toRightOf="@+id/txt_arduinos"
android:text="@string/btn_annuler"
android:visibility="invisible"/>
<ListView
android:id="@+id/ListViewArduinos"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_alignParentLeft="true"
android:layout_below="@+id/txt_arduinos"
android:layout_marginTop="30dp"
android:background="@color/wheat">
</ListView>
</RelativeLayout>
</ScrollView>
该视图使用了在 [res/values/strings] 文件中定义的字符串(第 15、25、37、50、61、73 行中的 android:text):
![]() |
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">android-domotique</string>
<!-- Fragments and tabs -->
<string name="config_titre">[Config]</string>
<string name="blink_titre">[Blink]</string>
<string name="pinread_titre">[PinRead]</string>
<string name="pinwrite_titre">[PinWrite]</string>
<string name="commands_titre">[Commands]</string>
<!-- Config -->
<string name="txt_TitreConfig">Se connecter au serveur</string>
<string name="txt_UrlServiceRest">Url du service web / jSON</string>
<string name="txt_MsgErreurUrlServiceRest">L\'Url du service doit être entrée sous la forme Ip1.Ip2.Ip3.IP4:Port/contexte</string>
<string name="hint_UrlServiceRest">ex (192.168.1.120:8080/rest)</string>
<string name="btn_annuler">Annuler</string>
<string name="btn_rafraichir">Rafraîchir</string>
<string name="titre_list_arduinos">Liste des Arduinos connectés</string>
</resources>
该视图使用了在 [res/values/colors] 文件中定义的颜色(第 51 行和第 62 行的 android:textColor):
![]() |
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#3F51B5</color>
<color name="colorPrimaryDark">#303F9F</color>
<color name="colorAccent">#FF4081</color>
<color name="floral_white">#FFFAF0</color>
<!-- app -->
<color name="red">#FF0000</color>
<color name="blue">#0000FF</color>
<color name="wheat">#FFEFD5</color>
</resources>
该视图使用了在 [res/values/dimens] 文件中定义的尺寸(第 16 行中的 android:textSize):
![]() |
<resources>
<!-- Default screen margins, per the Android Design guidelines. -->
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
<dimen name="fab_margin">16dp</dimen>
<dimen name="appbar_padding_top">8dp</dimen>
<!-- appli -->
<dimen name="titre">30dp</dimen>
</resources>
并非所有尺寸都采用了此方法。不过,这是推荐的做法。它允许您在单一位置修改尺寸。
任务:创建上述元素。
再次运行您的项目。您应看到以下视图:

5.6.10. [ConfigFragment] 片段
![]() |
为了处理新的 [config] 视图,[ConfigFragment] 片段的代码如下所示:
package client.android.fragments.behavior;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.TextView;
import client.android.R;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.custom.CoreState;
import client.android.architecture.custom.IMainActivity;
import client.android.fragments.state.ConfigFragmentState;
import org.androidannotations.annotations.Click;
import org.androidannotations.annotations.EFragment;
import org.androidannotations.annotations.OptionsMenu;
import org.androidannotations.annotations.ViewById;
@EFragment(R.layout.config)
@OptionsMenu(R.menu.menu_vide)
public class ConfigFragment extends AbstractFragment {
// visual interface elements
@ViewById(R.id.btn_Rafraichir)
protected Button btnRafraichir;
@ViewById(R.id.btn_Annuler)
protected Button btnAnnuler;
@ViewById(R.id.edt_UrlServiceRest)
protected EditText edtUrlServiceRest;
@ViewById(R.id.txt_MsgErreurIpPort)
protected TextView txtMsgErreurUrlServiceRest;
@ViewById(R.id.ListViewArduinos)
protected ListView listArduinos;
@Click(R.id.btn_Rafraichir)
protected void doRafraichir() {
}
// fragment lifecycle management -------------------------------------
@Override
public CoreState saveFragment() {
return new ConfigFragmentState();
}
@Override
protected int getNumView() {
return IMainActivity.VUE_CONFIG;
}
@Override
protected void initFragment(CoreState previousState) {
}
@Override
protected void initView(CoreState previousState) {
// 1st visit?
if(previousState==null){
txtMsgErreurUrlServiceRest.setVisibility(View.INVISIBLE);
}
}
@Override
protected void updateOnSubmit(CoreState previousState) {
}
@Override
protected void updateOnRestore(CoreState previousState) {
}
@Override
protected void notifyEndOfUpdates() {
// buttons
initButtons();
}
@Override
protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
}
// méthodes privées --------------------------------------------
private void initButtons() {
// the [Execute] button replaces the [Cancel] button
btnAnnuler.setVisibility(View.INVISIBLE);
btnRafraichir.setVisibility(View.VISIBLE);
}
}
- 第 23–32 行:视觉界面元素;
- 第 58–60 行:首次访问片段时,隐藏错误消息;
- 第 73–76 行:每次显示片段时,[取消] 按钮会被隐藏(第 82 行),而 [刷新] 按钮会被显示(第 86–87 行)。实际上,在此应用中,当异步操作正在进行时无法显示片段,因此 [取消] 按钮是可见的;
任务:实现上述功能。
运行此新版本。此时初始视图应如下所示:

5.6.10.1. [刷新] 按钮
目前,我们将按以下方式处理 [刷新] 按钮的点击事件:
@Click(R.id.btn_Rafraichir)
protected void doRafraichir() {
// we're going to launch a task - we're preparing the wait
beginWaiting(1);
}
@Click(R.id.btn_Annuler)
protected void doAnnuler() {
if (isDebugEnabled) {
Log.d(className, "Annulation demandée");
}
// asynchronous tasks are cancelled
cancelRunningTasks();
}
protected void beginWaiting(int numberOfRunningTasks) {
// prepare to wait for tasks
beginRunningTasks(numberOfRunningTasks);
// the [Cancel] button replaces the [Refresh] button
btnRafraichir.setVisibility(View.INVISIBLE);
btnAnnuler.setVisibility(View.VISIBLE);
}
// fragment lifecycle management -------------------------------------
...
@Override
protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
// buttons in their original state
initButtons();
}
// méthodes privées --------------------------------------------
private void initButtons() {
// the [Execute] button replaces the [Cancel] button
btnAnnuler.setVisibility(View.INVISIBLE);
btnRafraichir.setVisibility(View.VISIBLE);
}
- 第 1-5 行:点击 [刷新] 按钮时执行的方法;
- 第 4 行:开始等待;
- 第 18 行:我们将要启动的异步任务数量传递给父类。此时将显示加载图标;
- 第 20-21 行:此等待操作将导致 [Cancel] 按钮出现、[Refresh] 按钮消失,并显示加载图标。除此之外不会发生其他情况。不过,用户可以点击 [Cancel] 按钮。此时将执行第 7-14 行中的方法;
- 第 13 行:请求父类取消所有任务。父类将执行此操作,并进而调用第 25–29 行中的方法,以通知所有任务已完成。参数 [runningTasksHaveBeenCanceled] 的值将设为 true,表示任务已被取消;
- 第35–36行:[取消]按钮将消失,而[刷新]按钮将重新出现。
任务:进行上述修改后运行项目。验证 [Refresh] 按钮是否能启动等待,[Cancel] 按钮是否能停止等待。观察日志。
5.6.10.2. 输入验证
在之前的版本中,我们未对输入的 URL 进行验证。为进行验证,我们在 [ConfigFragment] 中添加以下代码:
// entered values
private String urlServiceRest;
@Click(R.id.btn_Rafraichir)
protected void doRafraichir() {
// check entries
if (!pageValid()) {
return;
}
// we're going to launch a task - we're preparing the wait
beginWaiting(1);
}
// input verification
private boolean pageValid() {
// initially no error msg
txtMsgErreurUrlServiceRest.setVisibility(View.INVISIBLE);
// retrieve server IP and port
urlServiceRest = String.format("http://%s", edtUrlServiceRest.getText().toString().trim());
// we check its validity
try {
URI uri = new URI(urlServiceRest);
String host = uri.getHost();
int port = uri.getPort();
if (host == null || port == -1) {
throw new Exception();
}
} catch (Exception ex) {
// error msg display
txtMsgErreurUrlServiceRest.setVisibility(View.VISIBLE);
// back to UI
return false;
}
// it's good
return true;
}
- 第 2 行:输入的 URL;
- 第 7–9 行:在执行任何操作之前,我们先检查输入的有效性;
- 第 19 行:获取输入的 URL 并为其添加前缀 [http://];
- 第 22 行:尝试使用该 URL 构建一个 URI(统一资源标识符)对象。如果输入的 URL 语法错误,将抛出异常;
- 第 23–27 行:如果 URI 有效,但 [host==null] 且 [port==-1],则抛出异常。这是可能发生的情况;
- 第 30 行:已发生异常。显示错误信息;
- 第 32 行:返回 [false] 以表示页面无效;
- 第 35 行:未发生错误。返回 [true] 表示页面有效;
作业:实现上述功能。
测试此新版本,并验证无效的 URL 是否被正确标记。
5.6.10.3. 显示 Arduino 列表
![]() |
不同的视图需要显示已连接的 Arduino 列表。为此,我们将定义不同的类和一个 XML 视图:
- Arduino 将由 [Arduino] 类 [1] 表示;
- [CheckedArduino]类[1]继承自[Arduino]类,我们在其中添加了一个布尔变量,用于指示该Arduino在列表中是否已被选中;
[Arduino]类是服务器已使用的类,并在第5.4.2节中介绍过。其定义如下:
package android.arduinos.entities;
import java.io.Serializable;
public class Arduino implements Serializable {
// data
private String id;
private String description;
private String mac;
private String ip;
private int port;
// getters and setters
...
}
- 第 7 行:[id] 是 Arduino 的标识符;
- 第 8 行:其描述;
- 第 9 行:其 MAC 地址;
- 第 10 行:其 IP 地址;
- 第 11 行:它用于监听命令的端口;
该类对应于向服务器请求已连接Arduino列表时收到的JSON字符串:
![]() |
[CheckedArduino] 类继承自 [Arduino] 类:
package android.arduinos.entities;
public class CheckedArduino extends Arduino {
private static final long serialVersionUID = 1L;
// an Arduino can be selected
private boolean isChecked;
// manufacturer
public CheckedArduino(Arduino arduino, boolean isChecked) {
// parent
super(arduino.getId(), arduino.getDescription(), arduino.getMac(), arduino.getIp(), arduino.getPort());
// local
this.isChecked = isChecked;
}
// getters and setters
public boolean isChecked() {
return isChecked;
}
public void setChecked(boolean isChecked) {
this.isChecked = isChecked;
}
}
- 第 3 行:[CheckedArduino] 类继承自 [Arduino] 类;
- 第 6 行:我们添加了一个布尔变量,用于指示是否已从显示的 Arduino 列表中选定了一个 Arduino;
在 [ConfigFragment] 中,我们将模拟获取已连接 Arduino 的列表。
![]() |
@ViewById(R.id.ListViewArduinos)
protected ListView listArduinos;
..
@Click(R.id.btn_Rafraichir)
protected void doRafraichir() {
// check entries
if (!pageValid()) {
return;
}
// we're going to launch a task - we're preparing the wait
beginWaiting(1);
// we clean up the Arduinos list
clearArduinos();
// request the list of Arduinos running in the background
getArduinosInBackground();
}
private void getArduinosInBackground() {
...
}
// raz list of Arduinos
private void clearArduinos() {
// create an empty list
List<String> strings = new ArrayList<>();
// we display it
listArduinos.setAdapter(new ArrayAdapter<String>(activity, android.R.layout.simple_list_item_1, android.R.id.text1, strings));
}
- 第 2 行:显示连接到服务器的 Arduino 的 ListView;
- 第 5 行:获取已连接 Arduino 列表的方法;
- 第 11 行:告知父类我们将启动一个异步任务;
- 第 12 行:清空当前显示的 Arduino 列表;
- 第 15 行:作为后台任务请求已连接 Arduino 的列表;
- 第 23–28 行:清除当前显示的 Arduino 列表的方法;
[getArduinosInBackground] 方法如下:
private void getArduinosInBackground() {
// create a fictitious arduino list
List<Arduino> arduinos = new ArrayList<>();
for (int i = 0; i < 20; i++) {
arduinos.add(new Arduino("id" + i, "desc" + i, "mac" + i, "ip" + i, i));
}
// we simulate a server response
Response<List<Arduino>> response = new Response<>();
response.setBody(arduinos);
// we cancel the wait
cancelWaitingTasks();
// change the buttons
initButtons();
// we consume the answer
consumeArduinosResponse(response);
}
- 第 3–6 行:创建一个包含 20 个 Arduino 的列表;
- 第 8–9 行:构建类型为 [Response<List<Arduino>>](第 5.4.2 节)的响应,该响应将封装已创建的 Arduino 列表;
- 第 11 行:取消等待;
- 第 13 行:将按钮重置为初始状态;
- 第 15 行:处理响应;
[consumeArduinosResponse] 方法如下:
// response display
private void consumeArduinosResponse(Response<List<Arduino>> response) {
// mistake?
if (response.getStatus() != 0) {
// display
showAlert(response.getMessages());
// back to Ui
return;
}
// we create a list of [CheckedArduino]
List<CheckedArduino> checkedArduinos = new ArrayList<>();
for (Arduino arduino : response.getBody()) {
checkedArduinos.add(new CheckedArduino(arduino, false));
}
// we display them
showArduinos(checkedArduinos);
}
- 第 4-11 行:检查服务器发送的响应中的错误代码:
- 第 4 行:如果错误代码不为零;
- 第 6 行:显示服务器存储在响应 [messages] 字段中的消息;
- 第 8 行:返回用户界面;
- 第 11-16 行:如果没有错误,将接收到的 Arduino 列表转换为 List<CheckedArduino> 类型后显示;
[showArduinos] 方法如下:
private void showArduinos(List<CheckedArduino> checkedArduinos) {
// create a list of Strings from the list of Arduinos
List<String> strings = new ArrayList<>();
for (CheckedArduino checkedArduino : checkedArduinos) {
strings.add(checkedArduino.toString());
}
// we display it
listArduinos.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_list_item_1, android.R.id.text1, strings));
}
任务:进行上述修改并运行你的项目。
点击 [刷新] 按钮后,您应看到以下视图:

[1] 处的输入内容不会被使用。因此,只要符合预期格式,您可以输入任意内容。
5.6.10.4. Arduino 显示模板
目前,已连接的 Arduino 在 [配置] 视图中显示如下:

现在,我们希望将其显示为如下形式:
![]()
- 在 [1] 处,一个复选框,用于选择 Arduino。当需要显示不可选的 Arduino 列表时,该复选框将被隐藏;
- 在 [2] 处,显示 Arduino 的 ID;
- 在 [3] 处,显示其描述;
以下内容基于第 1.20 节中的 [example-19] 和 [example-19B] 项目所发展的概念。如有必要,请复习相关内容。
首先,我们创建一个视图,用于显示 Arduino 列表中的某个项目:
![]() |
上述 [listarduinos_item] 视图的代码如下:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/RelativeLayout1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/wheat"
android:orientation="vertical" >
<CheckBox
android:id="@+id/checkBoxArduino"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_alignParentTop="true"
android:layout_toRightOf="@+id/txt_arduino_description" />
<TextView
android:id="@+id/TextView1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/checkBoxArduino"
android:layout_marginLeft="40dp"
android:text="@string/txt_arduino_id" />
<TextView
android:id="@+id/txt_arduino_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/checkBoxArduino"
android:layout_alignParentTop="true"
android:layout_toRightOf="@+id/TextView1"
android:text="@string/dummy"
android:textColor="@color/blue" />
<TextView
android:id="@+id/TextView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/checkBoxArduino"
android:layout_alignParentTop="true"
android:layout_marginLeft="20dp"
android:layout_toRightOf="@+id/txt_arduino_id"
android:text="@string/txt_arduino_description" />
<TextView
android:id="@+id/txt_arduino_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/checkBoxArduino"
android:layout_alignTop="@+id/TextView2"
android:layout_toRightOf="@+id/TextView2"
android:text="@string/dummy"
android:textColor="@color/blue" />
</RelativeLayout>
- 第 9–15 行:复选框;
- 第 17–23 行:文本 [Id: ];
- 第 25–33 行:此处将输入 Arduino ID;
- 第 35–43 行:文本 [Description: ];
- 第 45–53 行:此处将输入 Arduino 描述;
此视图使用了在 [res/values/strings.xml] 中定义的文本(第 23、32、43 行):
<string name="dummy">XXXXX</string>
<!-- listarduinos_item -->
<string name="txt_arduino_id">Id : </string>
<string name="txt_arduino_description">Description : </string>
该视图还使用了在 [res / values / colors.xml] 中定义的颜色(第 33 行、第 53 行):
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="red">#FF0000</color>
<color name="blue">#0000FF</color>
<color name="wheat">#FFEFD5</color>
<color name="floral_white">#FFFAF0</color>
</resources>
![]() |
[ListArduinosAdapter] 类是 [ListView] 调用以显示 Arduino 列表中每个项目的类。其代码如下:
package istia.st.android.vues;
import istia.st.android.R;
...
public class ListArduinosAdapter extends ArrayAdapter<CheckedArduino> {
// the arduino board
private List<CheckedArduino> arduinos;
// execution context
private Context context;
// the layout id for displaying a line in the arduino list
private int layoutResourceId;
// whether or not the line contains a checkbox
private Boolean selectable;
// manufacturer
public ListArduinosAdapter(Context context, int layoutResourceId, List<CheckedArduino> arduinos, Boolean selectable) {
// parent
super(context, layoutResourceId, arduinos);
// memorize information
this.arduinos = arduinos;
this.context = context;
this.layoutResourceId = layoutResourceId;
this.selectable = selectable;
}
@Override
public View getView(final int position, View convertView, ViewGroup parent) {
...
}
}
- 第 18 行:类构造函数接受四个参数:当前运行的活动、用于显示数据源中每个项的视图 ID、填充列表的数据源,以及一个布尔值,用于指示是否显示与每个 Arduino 关联的复选框;
- 第 8–15 行:这四项信息被保存在本地;
第 29 行:[getView] 方法负责在 [ListView] 中生成第 [position] 个视图并处理其事件。其代码如下:
@Override
public View getView(int position, View convertView, ViewGroup parent) {
// the current arduino
final CheckedArduino arduino = arduinos.get(position);
// create the current line
View row = ((Activity) context).getLayoutInflater().inflate(layoutResourceId, parent, false);
// retrieve references on [TextView]
TextView txtArduinoId = (TextView) row.findViewById(R.id.txt_arduino_id);
TextView txtArduinoDesc = (TextView) row.findViewById(R.id.txt_arduino_description);
// fill in the line
txtArduinoId.setText(arduino.getId());
txtArduinoDesc.setText(arduino.getDescription());
// the CheckBox is not always visible
CheckBox ck = (CheckBox) row.findViewById(R.id.checkBoxArduino);
ck.setVisibility(selectable ? View.VISIBLE : View.INVISIBLE);
if (selectable) {
// we assign its value
ck.setChecked(arduino.isChecked());
// we manage the click
ck.setOnCheckedChangeListener(new OnCheckedChangeListener() {
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
arduino.setChecked(isChecked);
}
});
}
// we return the line
return row;
}
- 第 2 行:第一个参数是要创建的行在 [ListView] 中的位置。它也是本地存储的 Arduino 列表中的位置;
- 第 4 行:我们获取将与该行关联的 Arduino 的引用;
- 第 6 行:根据 [listarduinos_item.xml] 视图构建当前行;
- 第 8–9 行:获取两个 [TextView] 的引用;
- 第 11–12 行:为两个 [TextView] 设置其值;
- 第 14 行:获取复选框的引用;
- 第 15 行:根据最初传递给构造函数的 [selectable] 值,决定是否显示该复选框;
- 第 16 行:如果复选框存在;
- 第 18 行:将当前 Arduino 的 [isChecked] 值赋给该复选框;
- 第 20–26 行:处理复选框的点击事件;
- 第 23 行:将复选框的值存储到当前 Arduino 中;
管理 Arduino 列表
Arduino列表的显示目前由[ConfigFragment]类的两个方法处理:
- [clearArduinos]:显示一个空列表;
- [showArduinos]:显示服务器返回的列表;
这两个方法的工作原理如下:
// raz list of Arduinos
private void clearArduinos() {
// an empty list is displayed
ListArduinosAdapter adapter = new ListArduinosAdapter(getActivity(), R.layout.listarduinos_item, new ArrayList<CheckedArduino>(), false);
listArduinos.setAdapter(adapter);
}
// arduinos list display
private void showArduinos(List<CheckedArduino> checkedArduinos) {
// display Arduinos
ListArduinosAdapter adapter = new ListArduinosAdapter(getActivity(), R.layout.listarduinos_item, checkedArduinos, false);
listArduinos.setAdapter(adapter);
}
任务:进行这些修改并测试新应用。

5.6.10.5. 会话
会话是存储片段与活动之间共享信息的地方。所有片段都需要显示已连接的 Arduino 列表。因此,会话的初始版本如下所示:
package client.android.architecture.custom;
import client.android.activity.CheckedArduino;
import client.android.architecture.core.AbstractSession;
import java.util.ArrayList;
import java.util.List;
public class Session extends AbstractSession {
// data to be shared between fragments themselves and between fragments and activities
// elements that cannot be serialized as jSON must be annotated with @JsonIgnore
// don't forget the getters and setters required for serialization / deserialization jSON
// the Arduinos list
private List<CheckedArduino> checkedArduinos = new ArrayList<>();
// getters and setters
...
}
任务:创建上文所示的 [Session] 类。
要创建此会话,我们需要按以下方式修改现有代码:
// response display
private void consumeArduinosResponse(Response<List<Arduino>> response) {
// mistake?
if (response.getStatus() != 0) {
// display
showAlert(response.getMessages());
// cancellation
doAnnuler();
// back to Ui
return;
}
// we create a list of [CheckedArduino]
List<CheckedArduino> checkedArduinos = new ArrayList<>();
for (Arduino arduino : response.getBody()) {
checkedArduinos.add(new CheckedArduino(arduino, false));
}
// we put it in session
session.setCheckedArduinos(checkedArduinos);
// we display them
showArduinos(checkedArduinos);
// we cancel the wait
cancelWaitingTasks();
}
- 第 18 行:将前几行创建的 Arduino 列表放入会话中;
5.6.10.6. 片段状态管理
当设备旋转时,视图的视觉组件(默认情况下)将以视图设计时的状态进行渲染:
- [ListView] 包含设计者放置其中的项目;
- 错误消息处于设计者放置时的可见或不可见状态;
在恢复片段时,设计时视觉组件的状态可能合适,也可能不合适。这里的情况是怎样的?
- [ListView] 必须显示已连接的 Arduino 列表。因此,设计时 [ListView] 的值无法被使用;
- 用于显示错误消息的 [TextView] 必须恢复到保存时的可见或隐藏状态。其在设计时的值可能不适用于这两种情况;
因此,在保存片段的状态时,我们必须保存这两个组件的状态:
- 已连接的 Arduino 列表;
- 输入 Web 服务 URL/JSON 时错误消息的可见性(显示/隐藏);
由于 Arduino 列表存在于会话中,因此会自动保存。错误消息的可见性将存储在以下 [ConfigFragmentState] 类中:
![]() |
package client.android.fragments.state;
import client.android.architecture.custom.CoreState;
public class ConfigFragmentState extends CoreState {
// visibility error message
private boolean txtMsgErreurUrlServiceRestVisible;
// getters and setters
...
}
任务:创建上文所示的 [ConfigFragmentState] 类。
要正确恢复片段的状态,必须修改其 [getNumView] 和 [saveFragment] 方法。例如,[BlinkFragment] 片段的相应方法目前如下所示:
@Override
public CoreState saveFragment() {
// save the fragment
DummyFragmentState state=new DummyFragmentState();
// ...
return state;
// if there's nothing to save, do [return new CoreState();] and delete class [DummyFragmentState]
}
@Override
protected int getNumView() {
// return the fragment number in the table of fragments managed by the activity (cf MainActivity)
return 0;
}
如果不执行任何操作,第 6 行渲染的状态将保存在 [AbstractSession] 类(下文第 5 行)的 CoreState[] coreStates 数组的第 0 个元素(第 13 行)中:
public class AbstractSession implements ISession {
...
// view status
private CoreState[] coreStates = new CoreState[0];
...
但是,它必须保存在 [MainActivity] 类中定义的片段数组中与片段 ID [BlinkFragment] 对应的元素中(如下第 9 行):
@EActivity
@OptionsMenu(R.menu.menu_main)
public class MainActivity extends AbstractActivity {
...
@Override
protected AbstractFragment[] getFragments() {
return new AbstractFragment[]{new ConfigFragment_(), new BlinkFragment_(), new PinReadFragment_(), new PinWriteFragment_(), new CommandsFragment_()};
}
片段 ID 已在 [IMainActivity] 接口中定义:
public interface IMainActivity extends IDao {
...
// view n°s
int VUE_CONFIG = 0;
int VUE_BLINK = 1;
int VUE_PINREAD = 2;
int VUE_PINWRITE = 3;
int VUE_COMMANDS = 4;
}
最终,如果我们编写如下代码,[BlinkFragment] 片段的状态将得到正确管理:
@Override
public CoreState saveFragment() {
// save the fragment
DummyFragmentState state=new DummyFragmentState();
// ...
return state;
// if there's nothing to save, do [return new CoreState();] and delete class [DummyFragmentState]
}
@Override
protected int getNumView() {
// return the fragment number in the table of fragments managed by the activity (cf MainActivity)
return IMainActivity.VUE_BLINK;
}
- 第 14 行:返回该 Activity 管理的片段数组中的片段 ID [BlinkFragment];
此外,作为片段状态父类的 [CoreState] 类目前如下所示(参见第 5.6.7.2 节):
package client.android.architecture.custom;
import client.android.architecture.core.MenuItemState;
import client.android.fragments.state.*;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY)
@JsonSubTypes({
@JsonSubTypes.Type(value = ConfigFragmentState.class),
@JsonSubTypes.Type(value = BlinkFragmentState.class),
@JsonSubTypes.Type(value = PinReadFragmentState.class),
@JsonSubTypes.Type(value = PinWriteFragmentState.class),
@JsonSubTypes.Type(value = CommandsFragmentState.class)}
)
public class CoreState {
// fragment visited or not
protected boolean hasBeenVisited = false;
// status of any fragment menu
protected MenuItemState[] menuOptionsState;
// getters and setters
....
}
- 第 12–16 行:[DummyFragmentState] 类未列在 [CoreState] 类的子类中。 然而,[BlinkFragment] 类的 [saveFragment] 方法目前返回的是 [DummyFragmentState] 类型。若保持现状,会话的序列化/反序列化将失败,导致会话无法恢复,进而引发应用程序崩溃;
必须将 [BlinkFragment] 片段的 [saveFragment] 方法重写为如下形式:
@Override
public CoreState saveFragment() {
// save the fragment
BlinkFragmentState state=new BlinkFragmentState();
// ...
return state;
// if there's nothing to save, do [return new CoreState();] and delete class [DummyFragmentState]
}
任务:在每个片段中,修改 [getNumView] 方法使其返回片段编号,并修改 [saveFragment] 方法使其返回片段状态类的实例(如上所示)。
5.6.10.7. 片段生命周期管理
这里我们将重点关注 [ConfigFragment] 片段的生命周期,特别是以下四个方法:
- [saveFragment]:必须保存片段的状态,以便日后恢复;
- [initFragment]:在必要时初始化片段的某些字段。该方法在应用程序启动时以及设备每次旋转时都会被调用。更准确地说,当片段在前两个事件之一发生后变得可见时,该方法会被调用;
- [initView]:在必要时初始化片段的某些视图组件。该方法在每次调用 [initFragment] 之后,以及当片段在某个时刻移出显示区域导致视图需要重绘时被调用。与前文类似,该方法在上述事件发生后片段再次可见时被调用;
- [updateOnRestore]:该方法在设备旋转后,以及发生导航时,于前两个方法之后执行。其作用是恢复片段的先前状态;
这些方法如下:
// arduinos list adapter
private ListArduinosAdapter adapterListArduinos;
...
// fragment lifecycle management -------------------------------------
@Override
public CoreState saveFragment() {
ConfigFragmentState state = new ConfigFragmentState();
state.setTxtMsgErreurUrlServiceRestVisible(txtMsgErreurUrlServiceRest.getVisibility() == View.VISIBLE);
return state;
}
@Override
protected void initFragment(CoreState previousState) {
// adapter listArduinos
adapterListArduinos = new ListArduinosAdapter(activity, R.layout.listarduinos_item, session.getCheckedArduinos(), false);
}
@Override
protected void initView(CoreState previousState) {
// listview / adapter connection
listArduinos.setAdapter(adapterListArduinos);
// 1st visit?
if (previousState == null) {
// ListView empty - made by [initFragment]
// hidden error message
txtMsgErreurUrlServiceRest.setVisibility(View.INVISIBLE);
} else {
// error message visibility is restored
ConfigFragmentState state = (ConfigFragmentState) previousState;
txtMsgErreurUrlServiceRest.setVisibility(state.isTxtMsgErreurUrlServiceRestVisible() ? View.VISIBLE : View.INVISIBLE);
}
}
@Override
protected void updateOnSubmit(CoreState previousState) {
}
@Override
protected void updateOnRestore(CoreState previousState) {
}
@Override
protected void notifyEndOfUpdates() {
// buttons
initButtons();
}
- 第 2 行:Arduinos 的 ListView 适配器。由于它在不同的方法中都会用到,因此被定义为全局变量;
- 第 7–12 行:[saveFragment] 方法将 TextView txtMsgErreurUrlServiceRestVisible 的可见性(第 10 行)保存到 [ConfigFragmentState] 类型中;
- 第 14–19 行:[initFragment] 方法使用当前会话中的 Arduino 列表(第 17 行)初始化第 2 行中的适配器。请注意,[initFragment] 的作用是初始化片段的字段。在此处,无论是否为首次访问(previousState == null),都必须执行此初始化操作;
- 第 17 行:可见适配器绑定到了数据源 [session.getCheckedArduinos]。该值绝不能为 null。因此,在会话中,字段 [session.checkedArduinos] 被初始化为一个空列表:
// la liste des Arduinos
private List<CheckedArduino> checkedArduinos = new ArrayList<>();
- 第 21–35 行:[initView] 方法负责初始化视觉界面的某些组件,特别是那些在设备旋转时其值不会被保留的组件;
- 第 24 行:Arduino ListView 绑定到第 2 行定义的适配器;
- 第 28–32 行:区分首次访问与其他访问;
- 第 29 行:首次访问时,必须显示一个空的 [ListView]。这是因为在首次访问时,[ListView] 适配器关联的是一个空列表(第 17 行);
- 第 31 行:隐藏错误消息;
- 第 32–36 行:处理非首次访问的情况;
- 由于第24行已将[ListView]置于正确状态,因此无需进行其他操作;
- 第 34–35 行:将错误消息恢复到片段上次保存时的状态;
- 第 31–36 行:[updateOnRestore] 方法必须将片段恢复到初始状态。我们通过两种方式进入 [updateOnRestore] 方法:
- 要么是因为设备已旋转。在此情况下,所有必要的初始化已在 [initView] 中完成;
- 或是因为我们正从某个标签页导航至 [Config] 标签页。如果自上次离开 [Config] 片段以来,该片段一直处于显示片段列表中,则 [initView] 方法已执行完毕,片段已处于预期状态。如果自退出 [Config] 片段以来,该片段一直未离开显示片段列表,则其视觉组件状态未发生变化,无需执行任何操作;
我们可以看到 [updateOnRestore] 方法无需执行任何操作。这种情况有时成立,有时则不然。区别在于 [updateOnSubmit] 方法:如果该方法执行的操作使得 [initView] 中进行的某些初始化变得多余,那么这些初始化就应移至 [updateOnRestore] 方法中完成。 让我们以一个具有三个值(V1、V2 和 V3)的单选按钮为例。在与 [SUBMIT] 操作相关的导航场景中,选中的单选按钮必须始终是值为 V1 的那个。 在这种情况下,在 [initView] 方法中恢复单选按钮的值是多余的,因为在 [SUBMIT] 操作发生时,该值会被 [updateOnSubmit] 方法提供的值所替换。因此,最好将此恢复操作移至 [updateOnRestore] 方法中,以避免执行不必要的操作。
- 第 48–52 行:[notifyEndOfUpdates] 方法在所有前述方法执行完毕后被调用;
- 第 51 行:按钮被设置为初始状态:显示 [Refresh] 按钮,并隐藏 [Cancel] 按钮:
任务:将上述代码添加到 [ConfigFragment] 中,然后运行应用。请注意,当旋转设备时,[Config] 标签页会保留其状态(错误信息、Arduino 列表)。请验证当您仅从 [Config] 标签页导航至 [Commands] 标签页 --> [Config] 标签页时,是否也会出现相同的行为。 在后一种情况下,若您已在 [IMainActivity] 中将片段邻接性设置为 1,则切换至 [Commands] 标签页时 [ConfigFragment] 视图会被销毁,返回 [Config] 标签页时则会重新创建。测试过程中请检查日志。
5.6.10.8. 代码优化
[ConfigFragment] 片段的代码尚有优化空间。例如,我们编写了:
// arduinos list adapter
private ListArduinosAdapter adapterListArduinos;
...
// arduinos list display
private void showArduinos(List<CheckedArduino> checkedArduinos) {
// display Arduinos
ListArduinosAdapter adapter = new ListArduinosAdapter(getActivity(), R.layout.listarduinos_item, checkedArduinos, false);
listArduinos.setAdapter(adapter);
}
// raz list of Arduinos
private void clearArduinos() {
// an empty list is displayed
ListArduinosAdapter adapter = new ListArduinosAdapter(getActivity(), R.layout.listarduinos_item, new ArrayList<CheckedArduino>(), false);
listArduinos.setAdapter(adapter);
}
- 我们可以看到,在第 9 行和第 16 行中,我们使用了一个与第 2 行字段无关的局部变量,尽管我们试图操作的是同一个实体;
我们将代码更新如下:
// arduinos list adapter
private ListArduinosAdapter adapterListArduinos;
@Click(R.id.btn_Rafraichir)
protected void doRafraichir() {
...
}
private void getArduinosInBackground() {
...
// it is consumed
consumeArduinosResponse(response);
}
// response display
private void consumeArduinosResponse(Response<List<Arduino>> response) {
// mistake?
if (response.getStatus() != 0) {
// display
showAlert(response.getMessages());
// cancellation
doAnnuler();
// back to Ui
return;
}
// we create a list of [CheckedArduino]
List<CheckedArduino> checkedArduinos = session.getCheckedArduinos();
checkedArduinos.clear();
for (Arduino arduino : response.getBody()) {
checkedArduinos.add(new CheckedArduino(arduino, false));
}
// we display them
adapterListArduinos.notifyDataSetChanged();
// we cancel the wait
cancelWaitingTasks();
}
@Override
protected void initFragment(CoreState previousState) {
// adapt listArduinos
adapterListArduinos = new ListArduinosAdapter(activity, R.layout.listarduinos_item, session.getCheckedArduinos(), false);
}
@Override
protected void initView(CoreState previousState) {
// listview / adapter connection
listArduinos.setAdapter(adapterListArduinos);
...
}
- 当第 5 行中的方法被执行时,片段的生命周期已经结束。因此:
- 第 2 行的适配器已与其数据源关联(第 41 行);
- 已将连接的Arduinos的[ListView]与该适配器关联(第48行);
若要更改 [ListView] 的显示内容,我们需要执行两项操作:
- 修改数据源 [session.checkedArduinos] 的内容;
- 使用 [adapterListArduinos.notifyDataSetChanged()] 指令通知适配器此变更;
重要的是要修改数据源的内容,而不是数据源本身。如果我们修改了数据源本身,[adapterListArduinos.notifyDataSetChanged()] 操作将仍然显示旧的数据源。届时,我们需要将适配器与新的数据源关联起来。
代码如下:
- 第 27 行:我们获取数据源;
- 第 28 行:清空该数据源。因此,我们已移除了 [clearArduinos] 方法;
- 第 29–31 行:我们将新项目添加到这个现在为空的列表中;
- 第 33 行:我们通知适配器刷新。这将刷新关联的 [ListView] 的显示;
任务:进行这些修改,并验证应用程序是否仍能正常运行。
5.6.11. 视图间的通信
为了验证视图之间的通信,我们将让所有其他视图显示由 [Config] 视图获取的 Arduino 列表。让我们从 [blink.xml] 视图开始。虽然它之前什么也没显示,但现在将显示已连接的 Arduino 列表:

![]() |
[blink.xml] 视图的 XML 代码如下:
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/scrollView1"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/RelativeLayout1"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/txt_arduinos"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_marginTop="150dp"
android:text="@string/titre_list_arduinos"
android:textColor="@color/blue"
android:textSize="20sp" />
<ListView
android:id="@+id/ListViewArduinos"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_alignParentLeft="true"
android:layout_below="@+id/txt_arduinos"
android:layout_marginTop="30dp"
android:background="@color/wheat">
</ListView>
</RelativeLayout>
</ScrollView>
此代码直接摘自 [config.xml] 视图。我们仅修改了第 19 行中的顶部边距。
任务:将此代码复制到 [commands.xml、pinread.xml、pinwrite.xml] 视图中。
与 [blink.xml] 视图关联的 [BlinkFragment] 片段的代码也在发生变化:
![]() |
// visual components
@ViewById(R.id.ListViewArduinos)
protected ListView listArduinos;
// arduinos list adapter
private ListArduinosAdapter adapterListArduinos;
...
// methods imposed by the parent class -------------------------------------------------------
...
@Override
protected void initFragment(CoreState previousState) {
// adapter listArduinos
adapterListArduinos = new ListArduinosAdapter(activity, R.layout.listarduinos_item, session.getCheckedArduinos(), true);
}
@Override
protected void initView(CoreState previousState) {
// listview / adapter connection
listArduinos.setAdapter(adapterListArduinos);
}
...
- 第 2-3 行:用于已连接 Arduino 的 [ListView] 组件;
- 第 6 行:此 [ListView] 的适配器;
- 第 12-23 行:[initFragment] 和 [initView] 方法的代码与 [ConfigFragment] 片段中已使用的代码相同;
- 第 15 行:当需要重置片段时,我们将第 2 行中的适配器与会话中存储的 Arduino 列表关联,从而重置适配器。[ListArduinosAdapter] 构造函数的最后一个参数 [true] 表示我们希望在每个 Arduino 旁边显示一个复选框;
- 第 22 行:当需要重置片段视图时,我们将已连接 Arduino 的 [ListView] 绑定到第 6 行中的适配器;
任务:将此代码复制到其他片段中 [CommandsFragment, PinReadFragment, PinWriteFragment]。运行应用程序并验证每个标签页现在是否显示已连接的 Arduino 列表。同时验证:若在某个标签页中勾选了 Arduino,然后导航至另一个标签页,这些 Arduino 在后者中仍保持勾选状态。
注:Arduino 设备保持选中状态的原因如下。第 5.6.10.4 节中介绍了 [ListArduinosAdapter] 类。与复选框相关的代码如下:
// the current arduino
final CheckedArduino arduino = arduinos.get(position);
...
// the CheckBox is not always visible
CheckBox ck = (CheckBox) row.findViewById(R.id.checkBoxArduino);
ck.setVisibility(selectable ? View.VISIBLE : View.INVISIBLE);
if (selectable) {
// we assign its value
ck.setChecked(arduino.isChecked());
// we manage the click
ck.setOnCheckedChangeListener(new OnCheckedChangeListener() {
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
arduino.setChecked(isChecked);
}
});
}
- 第 11–15 行:如果选项卡 X 中的复选框被选中,则第 2 行中 Arduino 的 [checked] 属性被设置为 true(第 14 行);
- 切换到标签页 Y 时,该标签页中的 Arduino [ListView] 会被显示出来。在第 9 行,我们可以看到,如果第 2 行中的 Arduino 的 [checked] 属性设置为 true,那么第 5 行中的 [ck] 复选框就会被勾选;
5.6.12. [DAO] 层
![]() |
注意:本节内容请参考 [example-16B] 项目中 [DAO] 层的实现(参见第 2.8.3 节)。
到目前为止,我们都是手动生成已连接 Arduino 的列表。现在我们将从 Web 服务器 / JSON 中获取该列表。为此,我们将构建 [DAO] 层:
![]() |
5.6.12.1. IDao 接口
[DAO] 层的 [IDao] 接口如下所示:
package client.android.dao.service;
import client.android.dao.entities.Arduino;
import client.android.dao.entities.Response;
import rx.Observable;
import java.util.List;
public interface IDao {
// Web service url
void setUrlServiceWebJson(String url);
// user
void setUser(String user, String mdp);
// customer timeout
void setTimeout(int timeout);
// basic authentication
void setBasicAuthentification(boolean isBasicAuthentificationNeeded);
// debug mode
void setDebugMode(boolean isDebugEnabled);
// client wait time in milliseconds before request
void setDelay(int delay);
// spécifique ----------------------------------------
// list of arduinos
Observable<Response<List<Arduino>>> getArduinos();
}
- 第 11-26 行:这些代码行已在 [client-android-skel] 模板项目的 [IDao] 接口中存在;
- 第 30 行:[getArduinos] 方法将已连接的 Arduino 列表作为类型为 Observable<[Response<List<Arduino>>>] 的可观察对象返回;
请注意,[Response<T>] 是服务器以 JSON 字符串形式发送的所有响应的类型:
package client.android.dao.entities;
import java.util.List;
public class Response<T> {
// ----------------- properties
// operation status
private int status;
// any error messages
private List<String> messages;
// the body of the reply
private T body;
// manufacturers
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// getters and setters
...
}
5.6.12.2. [WebClient] 接口
![]() |
[WebClient] 接口是 AA 库提供实现的一个接口。该接口如下所示:
package client.android.dao.service;
import client.android.dao.entities.Arduino;
import client.android.dao.entities.Response;
import org.androidannotations.rest.spring.annotations.Get;
import org.androidannotations.rest.spring.annotations.Path;
import org.androidannotations.rest.spring.annotations.Rest;
import org.androidannotations.rest.spring.api.RestClientRootUrl;
import org.androidannotations.rest.spring.api.RestClientSupport;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import java.util.List;
@Rest(converters = {MappingJackson2HttpMessageConverter.class})
public interface WebClient extends RestClientRootUrl, RestClientSupport {
// RestTemplate
void setRestTemplate(RestTemplate restTemplate);
// spécifique --------------------------------------
// list of arduinos
@Get("/arduinos")
Response<List<Arduino>> getArduinos();
}
- 第 15-19 行:这些行默认包含在 [client-android-skel] 模板项目的 [WebClient] 接口中;
- 第 23 行:用于通过 GET 请求获取 Arduino 列表的服务器 URL。请注意,此 URL 是相对于第 16 行中的根 URL [RestClientRootUrl] 的;
- 第 24 行:服务器返回一个类型为 [Response<List<Arduino>>] 的 JSON 字符串。该 JSON 字符串会通过第 15 行中的 JSON 转换器 [MappingJackson2HttpMessageConverter] 自动反序列化为 [Response<List<Arduino>>] 类型;
5.6.12.3. [Dao] 类
[Dao] 类如下所示实现了 [IDao] 接口:
package client.android.dao.service;
import android.util.Log;
import client.android.dao.entities.Arduino;
import client.android.dao.entities.Response;
import org.androidannotations.annotations.AfterInject;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EBean;
import org.androidannotations.rest.spring.annotations.RestService;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import rx.Observable;
import java.util.ArrayList;
import java.util.List;
@EBean(scope = EBean.Scope.Singleton)
public class Dao extends AbstractDao implements IDao {
// web service customer
@RestService
protected WebClient webClient;
// safety
@Bean
protected MyAuthInterceptor authInterceptor;
// on RestTemplate
private RestTemplate restTemplate;
// factory du RestTemplate
private SimpleClientHttpRequestFactory factory;
@AfterInject
public void afterInject() {
// log
Log.d(className, "afterInject");
// we build the restTemplate
factory = new SimpleClientHttpRequestFactory();
restTemplate = new RestTemplate(factory);
// set the jSON converter
restTemplate.getMessageConverters().add(new MappingJackson2HttpMessageConverter());
// set the restTemplate of the web client
webClient.setRestTemplate(restTemplate);
}
@Override
public void setUrlServiceWebJson(String url) {
// set the URL of the web service
webClient.setRootUrl(url);
}
@Override
public void setUser(String user, String mdp) {
// the user is registered in the interceptor
authInterceptor.setUser(user, mdp);
}
@Override
public void setTimeout(int timeout) {
if (isDebugEnabled) {
Log.d(className, String.format("setTimeout thread=%s, timeout=%s", Thread.currentThread().getName(), timeout));
}
// factory configuration
factory.setReadTimeout(timeout);
factory.setConnectTimeout(timeout);
}
@Override
public void setBasicAuthentification(boolean isBasicAuthentificationNeeded) {
if (isDebugEnabled) {
Log.d(className, String.format("setBasicAuthentification thread=%s, isBasicAuthentificationNeeded=%s", Thread.currentThread().getName(), isBasicAuthentificationNeeded));
}
// authentication interceptor?
if (isBasicAuthentificationNeeded) {
// add the authentication interceptor
List<ClientHttpRequestInterceptor> interceptors = new ArrayList<ClientHttpRequestInterceptor>();
interceptors.add(authInterceptor);
restTemplate.setInterceptors(interceptors);
}
}
// méthodes privées -------------------------------------------------
private void log(String message) {
if (isDebugEnabled) {
Log.d(className, message);
}
}
// specific IDao implementation -----------------------------------------------
@Override
public Observable<Response<List<Arduino>>> getArduinos() {
// web client execution
return getResponse(new IRequest<Response<List<Arduino>>>() {
@Override
public Response<List<Arduino>> getResponse() {
return webClient.getArduinos();
}
});
}
}
- 第 19–87 行:这些行属于 [client-android-skel] 项目中的 [Dao] 类;
- 第 91–100 行:[getArduinos] 方法的实现;
- 第 94 行:调用父类的 [getResponse] 方法。该方法的唯一参数是 [IRequest<T>] 接口的实例;
- 第 95–99 行:[IRequest<T>] 接口的唯一方法是 [T getResponse()] 方法;
- 第 94 行:[IRequest<T>] 的类型 T 必须是第 92 行方法返回的 Observable<T> 结果的类型 T,因此这里应为类型 [Response<List<Arduino>>];
- 第 97 行:方法 [IRequest.getResponse()] 将工作委托给我们之前引入的方法 [webClient.getArduinos()]。在第 24 行定义的 [webClient] 由 AA 库实例化,是 [WebClient] 接口的实例;
5.6.13. [MainActivity]
![]() |
我们在第 5.6.8 节中已经介绍了 [MainActivity] 活动。它继承了 [AbstractActivity] 类,因此实现了 [IMainActivity] 接口,而该接口本身又继承了 [IDao] 接口。每当向 [IDao] 接口添加一个方法时,都必须在 [MainActivity] 类中实现该方法。 添加到 [IDao] 接口中的 [IDao.getArduinos] 方法将在 [MainActivity] 中实现如下:
...
@EActivity
@OptionsMenu(R.menu.menu_main)
public class MainActivity extends AbstractActivity {
// layer [DAO]
@Bean(Dao.class)
protected IDao dao;
// session
private Session session;
...
// implémentation IDao -----------------------------------------
@Override
public Observable<Response<List<Arduino>>> getArduinos() {
return dao.getArduinos();
}
}
- 第 15–18 行:[getArduinos] 方法通过将工作委托给第 8 行中引用的、我们刚刚引入的 [Dao] 类来实现;
5.6.14. 重新审视 [ConfigFragment] 片段
在 [ConfigFragment] 类中,点击 [Refresh] 按钮时执行的代码目前如下:
@Click(R.id.btn_Rafraichir)
protected void doRafraichir() {
...
// request the list of Arduinos running in the background
getArduinosInBackground();
}
private void getArduinosInBackground() {
// create a fictitious arduino list
List<Arduino> arduinos = new ArrayList<>();
for (int i = 0; i < 20; i++) {
arduinos.add(new Arduino("id" + i, "desc" + i, "mac" + i, "ip" + i, i));
}
// we simulate a server response
Response<List<Arduino>> response = new Response<>();
response.setBody(arduinos);
// it is consumed
consumeArduinosResponse(response);
}
// response display
private void consumeArduinosResponse(Response<List<Arduino>> response) {
...
}
我们需要重写第 10–16 行代码,这些代码硬编码了一个类型为 [Response<List<Arduino>>] 的响应。现在,我们需要通过 Activity 从 [DAO] 层请求该列表。代码如下:
@Click(R.id.btn_Rafraichir)
protected void doRafraichir() {
// check entries
if (!pageValid()) {
return;
}
// save input
mainActivity.setUrlServiceWebJson(urlServiceRest);
// we prepare to wait
beginWaiting(1);
// the asynchronous task is executed
executeInBackground(mainActivity.getArduinos(), new Action1<Response<List<Arduino>>>() {
@Override
public void call(Response<List<Arduino>> response) {
// we consume the answer
consumeArduinosResponse(response);
}
});
}
- 第 8 行:用户输入的 Web 服务 / JSON 的根 URL 通过 Activity 传递给 [DAO] 层。这将成为 [WebClient] 接口的根 URL(参见第 5.6.12.2 节);
- 第 10 行:通知父类即将启动一个异步任务;
- 第12–19行:启动异步任务,该任务将返回连接到服务器的Arduino列表;
- 第 12 行:调用父类的 [executeInBackground] 方法。该方法需要两个参数:
- 第 12 行:待监视的进程。此处通过 [mainActivity.getArduinos()] 方法提供该进程;
- 第 12–19 行:[Action1<T>] 接口的实例,其中类型 T 由进程提供,此处为 [Response<List<Arduino>>] 类型;
- 第 14–18 行:当异步任务返回类型为 [Response<List<Arduino>>] 的结果时调用的方法;
- 第 17 行:将接收到的响应传递给之前编写的 [consumeArduinosResponse] 方法;
任务:按照第 5.4 节所述启动服务器。将一个或多个 Arduino 连接到运行服务器的电脑上。然后启动 Android 客户端,并验证能否成功获取已连接 Arduino 的列表。观察日志。

- 输入[1]中指定的URL。这是您服务器的IP地址之一;
- 点击 [2] 按钮;
- 您应该会在[3]处看到已连接的Arduino列表;
请确认该列表在其他选项卡中是否也显示。
5.7. 后续步骤
按照与 [Config] 视图相同的步骤,依次实现并测试应用程序的其他四个视图:[Blink]、[PinRead]、[PinWrite] 和 [Commands]。
待创建的视图已在第 5.5 节中列出。
对于每个视图,您必须:
- 绘制 XML 视图(参见第 5.6.9 节);
- 构建相关的片段(参见第 5.6.10 节);
- 向 [WebClient] 接口添加一个方法(参见第 5.6.12.2 节);
- 向 [IDao] 接口添加一个方法(参见第 5.6.12.2 节);
- 向 [Dao] 类添加一个方法(参见第 5.6.12.3 节);
- 向 [MainActivity] 活动添加一个方法(参见第 5.6.13 节);
- 编写片段的事件处理程序(参见第 5.6.14 节);
- 测试并观察日志;
注 1:后续示例将参考课程中的 [Example-16B] 项目(参见第 2.8.3 节)。
注 2:待查询的 URL 及其响应类型已在第 5.4.2 节中给出。
注 3:
[CommandsFragment] 类会发送一个包含单条命令的列表,供一个或多个 Arduino 执行。该命令将被封装在以下 [ArduinoCommand] 类中:
package android.arduinos.dao;
import java.util.Map;
public class ArduinoCommand {
// data
private String id;
private String ac;
private Map<String, Object> pa;
// manufacturers
public ArduinoCommand() {
}
public ArduinoCommand(String id, String ac, Map<String, Object> pa) {
this.id = id;
this.ac = ac;
this.pa = pa;
}
// getters and setters
...
}
在 [WebClient] 接口中,执行此命令列表的方法如下:
// envoi de commandes JSON
@Post("/arduinos/commands/{idArduino}")
Response<List<ArduinoResponse>> sendCommands(@Body List<ArduinoCommand> commands, @Path String idArduino);
- 第 2 行:通过 HTTP POST 请求发送请求;
- 第 3 行:提交的值必须带有 [@Body] 注解;
注 4:建议按以下方式处理此任务:
- 只有在当前视图创建并测试通过后,才继续处理下一个视图;
- 仅在获得正常条件下可正常运行的应用程序后,才管理视图的状态。随后,针对每个视图,在设备上循环测试该视图的不同状态,并记录任何丢失的信息。这些就是需要保存并随后恢复的数据。接下来,验证导航功能:当您离开一个标签页并稍后返回时,该标签页应处于与离开时相同的状态;


















































